很有意思!不到 2kb 就能实现一个 3D 物理引擎,史上最简单的柔性物理模拟系统
时间:2025-8-21 00:18 作者:独元殇 分类: WebGL
我之前的一篇文章,有分享过一个如何制作 2D 的物理引擎 https://www.ccgxk.com/front-end/544.html ,那个是个挺复杂的物理公式,今天这个是三维的,出乎我的意料,用到的物理公式反而更简单!
研究这个的原因是,我看到了今年 js13kgame ,心里痒痒,也想参加一下,写个简易的三维游戏。但游戏画面好写,主要是物理引擎很犯难,于是我就在研究开发一个 demo(因为在 13 kb 代码里塞下一个 javascript 物理引擎,是很难的,cannon.js 我压缩了很久也只压缩到 29kb)。 很遗憾,物理引擎比我想象中要难.... 倒不是难,主要是麻烦很多。要调得勉强能用,1 个月是不可能的。现在都 8 月 20 号了,时间还有 22 天。绝对不可能。于是,放弃了。留下下面这个烂摊子。
地址是 https://git.ccgxk.com/myWorkSpace/webgl_show/phy/demo001.html
倒也没多烂,其实还很好玩!这个代码的唯一优点就是..... 体积非常的小,最多也就 2kb !
玩法很简单, W A S D 控制物体上下左右。O 和 K 控制前后。

首先说明一下,三维的效果是纯 canvas 画的。我之前以为三维只能用 webgl 画,其实 canvas 也能画的不错,理论上性能会差很多,实际上也就那样儿。
代码其实很少,下面是代码的正文,全部代码就这点儿:
<canvas id="m" style="display: block; width: 992px; height: 827px;" width="12" height="10"></canvas>
<script>
_='var agetElementById("m"_bbody,c=a.getContext("2d"={up:~::rK::out:0}; t,e,l,n,a){{x:t-e,y:-W+t*_z:-3*W+t*+1.6_v:.2,u:+1.6)*a,q:-)*a,f:g:h:0}} t,e){ } ^t,e,n){,b=(v-v)*u+(u-u)*v+(q-q)*w,=-(l-+b)/,f+u,g+v,h+w,f-u,g-v,h-w} t,e,n,a,o,c,r,d=!0){7i=t;i--;)1U=u/l,V=v/l,Q=w/l,2r=(v*Q-w*V)*x-(u*Q-w*U)*y+(u*V-v*U)*z,u=,n.begPath(_m=e[i$mov132d?r/=l:r=r/l/-2,!(i%2)&r<=0&&(ncG80*r+"%,"+o+")`n()_!1===d&&n.stroke(); r}=M=1,B=1,W=120H=a.heK*=W/,=W,yForce=setInterval((){if(M){7@==[],long|=[],radius=45=20
=-20=9,=3cal=1,=9itRo=.1,#=16O=1,,;7=@,M=@=1,2}7q=cal;q--;){7f=.rK-.,h=.out-.,g=.~-.up,@=0;71,2;7vf,ug,qh,xv*,yu*,zq*,y>&&(J=.5*g,u=g=w=u=v,v=q,v-=J*u,q-=J*v,y=_z>
&&0<q&&(B=q=z=
)}T=-1,C=99,c.clearH_cCG2H_C=20cCG3W/4_C=#1)0_B=B<1?B+.1:1},15keyMap={|owUp:"up`|owDown:"~`|owLeft:"`|owRK:"rK`w:"up`s:"~`a:"`d:"rK`o:"`k:"out"};~.1,X,upX; [i].faceNum,l=Math.sqrt(u*u+v*v+w*w))%t,m=e[j$l[@++]=radius,#,,O,itRo_#=-#,O=1/Oe[i].forceState)%,=j,))%,^j,,)=*n*Math.s(6.3/l*ie[t].infunctionj=(i+)%t,u=xyz,allPotStyle="hsla("+i=;i--;)0,0*T+"%,"+B+")`cu/=l,v/=l,w/=l,wdow.addEventListener("keymoveScaleeTo(m.x*f+a/4,m.y*f+a/4_`(i){[keyMap[i.key]]=u=x--y-z,,,c,W,B,C,T,!#itLen$],f=600/(200-m.z_n.7for(@dexG+`99%,"+-JfrictionalKightOcledXconsole.log()})^calPotForce(i,_),`",|Arr~downSpeedmeasureLen(i,groundYitPotData(return-e[j].
frontZx,v=y=document.elasticitylong|[@++]Rect(W,left.fill,w=z);const +=elasCoerender(a.width,1+/2';for(Y in _$='
~|`_^XOKJG@7$#')with(_.split(_$[Y]))_=join(pop());eval(_)
</script>
把 JS 用 crash 压缩后,体积只有 1.9kb 大小。
但是,却可以实现一个复杂的3D物理引擎,外加一个 3D 渲染。
现在,我就将可读的源代码贴出来,然后为大家讲解一下,这个物理引擎是怎么实现的。
在核心代码上,一共由四部分组成:
- 生成圆带的 X Y Z 坐标数据。(顺便初始化各轴的速度、加速度)
- 通过虎克定理、弹力法则、牛顿2和3定理,计算出每个点所受的合力。
- 把合力,当做加速度,物理世界每计算一次,就叠加到速度上一次,计算出每一次的坐标。
- 根据坐标,渲染一次。
用到的公式,一共就两个。
虎克定理,计算弹力 F= kx (k 是弹性系数,x 是距离)就像弹力测重器一样,是线性关系。
牛顿第2定理, F=ma 。我们默认 m = 1,这样加速度就是受到的力的大小。
下面就是整个程序的 js 源码了。
<script>
/**
* 3D柔性体物理引擎(Soft-body allPointhysics Engine)
* X 朝右
* Y 朝下
* Z 朝外
*/
var a = document.getElementById('m');
var b = document.body;
var c = a.getContext("2d");
const forceState = { // 用于键盘操控
up : 0,
down : 0,
left : 0,
right : 0,
in : 0,
out : 0
};
/* -------------------- */
faceNum=0; // 粒子的数量
M=1; // 模式:1 代表创造模式,0 代表模拟
B=1; // 用于实现一个“闪光”的开场特效
W=1200; // 画布的宽度
// b.onclick = function(){M=!M}; // 每按一下屏幕,就重新生成新物体
H = a.height *= W/a.width; //+ 画布尺寸初始化
a.width = W;
yForce = 0; // 向下的力
setInterval(function() { // 一个每秒 66 帧的动画
if (M) { // 生成原始模型
index = 0; // 计数器
allPoint = []; // 存放所有的小珠子
longArr = []; // 储存组织的线,(珠子间的长度)
radius = 450; // 物体的直径(??半径)
groundY = 200; // 地面高度
frontZ = -200; // 面前的墙的 Z 距离
moveScale = 9; // 弹性移动速度
elasCoe = 30; // 弹性系数,弹簧硬度
calSpeed = 1; // 每帧的计算次数
/* 一个测试的预设数值 */
faceNum = 90; // 面数
initRoSpeed = 0.1; // 旋转速度
initLen = 160; // 初始长度(X 方向上)
inclined = 1; // 倾斜度
for (i=faceNum; i--;) { // 根据蓝图,生成模型的「珠子」,每面生成一次
allPoint[index++] = initPointData(radius, initLen, faceNum, inclined, initRoSpeed);
initLen = -initLen; //+ ??交错参数,翻转半径,为下一个点做准备
inclined = 1/inclined;
allPoint[index++] = initPointData(radius, initLen, faceNum, inclined, initRoSpeed);
initLen = -initLen; //+ 再次翻转,恢复原样
inclined = 1/inclined;
}
faceNum = index; // 更新粒子总数
M=0; // 开始模拟模式
index=0; // 计数器归 0
for (i=faceNum; i--;) { // 测量与记录线(橡皮筋)的长度。3 个面,每个面 3 次组成一个三角形
j = (i+1)%faceNum; // j 是 i 的下一个点(邻居)
longArr[index++] = measureLen(i, j, allPoint); // 将测量好的距离,记录到 longArr 里
j = (i+2)%faceNum;
longArr[index++] = measureLen(i, j, allPoint);
j = (i+1+faceNum/2)%faceNum;
longArr[index++] = measureLen(i, j, allPoint);
}
}
/* --- 开始模拟计算 --- */
for (q=calSpeed; q--;) { // 每帧循环 calSpeed 次,等于模拟的速率吧
for (i=faceNum; i--;) { // 重置受力
allPoint[i].f = forceState.right - forceState.left; //+ 清空每个原子在 X 和 Z 上的受力
allPoint[i].h = forceState.out - forceState.in;
// allPoint[i].g= 1 / W ; // 重力,每个点都要收的一个微小,向下的力(Y 方向下)
allPoint[i].g= forceState.down - forceState.up; // 重力为 0
index = 0; // 计数器归 0
}
for (i=faceNum; i--;) { // 虎克定律 F=Kx,计算每个点受到的弹力
j = (i+1)%faceNum; //+ i 的邻居,勾股定理计算 i 和 j 之间的橡皮筋的长度
calPointForce(i, j, allPoint, faceNum);
j = (i+2)%faceNum; // 另一个邻居(下同)
calPointForce(i, j, allPoint, faceNum);
j = (i+1+faceNum/2)%faceNum;
calPointForce(i, j, allPoint, faceNum);
}
for (i=faceNum; i--;) { // 挨个点进行 X Y Z 数据的更新(包括检测碰撞)。根据牛二定理, F=ma -> a=F/m (m=1) 。
allPoint[i].v += allPoint[i].f; //+ 将该点的力,当做加速度(也就是 a = F 嘛),加到该点的速度上。 V = V + a*T
allPoint[i].u += allPoint[i].g;
allPoint[i].q += allPoint[i].h;
allPoint[i].x += allPoint[i].v * moveScale; // 根据新的速度,更新 X 轴的位置(moveScale 用于控制模拟速度)
allPoint[i].y += allPoint[i].u * moveScale;
allPoint[i].z += allPoint[i].q * moveScale;
if (allPoint[i].y>groundY) { // 碰撞检测,如果到 y=200 以上(可能是地面)
frictional = allPoint[i].g * 0.5; // 计算一个摩擦力,摩擦系数等于 0.5,它与压力有关
allPoint[i].u = 0; // Y 轴上的速度和力(加速度)清零
allPoint[i].g = 0;
w = 0; // Y 上的速度,当然,是 0...
u = allPoint[i].v; // x 速度
v = allPoint[i].q; // z 速度
l = Math.sqrt(u*u + v*v + w*w); // 勾股定理,计算总速度
u = u/l; v = v/l; w = w/l; // 算出各轴的速度分量,向量。
allPoint[i].v -= frictional * u; //+ X Z 两轴,都在速度上加上这个摩擦力(摩擦力的分量)
allPoint[i].q -= frictional * v;
allPoint[i].y = groundY; // 强制将 y 轴位置拉回 200
}
if (allPoint[i].z>frontZ) { // 如果撞到背景墙
if (allPoint[i].q>0) {
B=0; // 关闭闪光特效
allPoint[i].q = 0; // 停止 Z 轴速度
allPoint[i].z = frontZ // 粘到墙上
};
}
}
}
/* ----- 三维渲染 ----- */
T=-1; // 临时变量重置
C=99; // 改变颜色用
c.clearRect(0,0,W,H); // 清空画板
c.fillStyle="hsla("+C+",99%," + -20*T +"%,"+B+")"; // 画笔颜色,一个洋红色
c.fillRect(0,0,W,H); // 涂满背景
C=200; // 恢复青色
c.fillStyle="hsla("+C+",99%," + -30*T +"%,"+B+")";
c.fillRect(0,0,W,W/4); // 顶部的矩形
C=initLen; // 获取一个随机颜色
render(faceNum, allPoint, c, W, B, C, T, false); // 外侧面
render(faceNum, allPoint, c, W, B, C, T, true);
B=(B<1)?B+=.1:1; // 让闪光逐渐退去,(0 -> 1)
},15);
// 初始化 顶点 的数据
function initPointData(radius, initLen, faceNum, inclined, initRoSpeed){
const res = {
x: radius-initLen, //+ 生成 xyz 坐标
y: (-W + radius * inclined * Math.sin(6.3/ faceNum * i)), // 6.3 ~ 2π
z: (-3 * W + radius * inclined * Math.sin(6.3/faceNum*i+1.6)),
v: 0.2, // Vx 速度
u: inclined * Math.sin(6.3 / faceNum * i + 1.6) * initRoSpeed, // Vy 速度
q: -inclined * Math.sin(6.3/faceNum*i) * initRoSpeed, // Vz 速度
f: 0, // 初始受力 Fx 力
g: 0, // Fy 力
h: 0 // Fz 力
}
return res;
}
// 勾股定理测量完的数据
function measureLen(i, j, allPoint){
u = allPoint[i].x - allPoint[j].x;
v = allPoint[i].y - allPoint[j].y;
w = allPoint[i].z - allPoint[j].z;
l = Math.sqrt(u*u + v*v + w*w);
return l;
}
// 计算和更新各点的力
function calPointForce(i, j, allPoint, faceNum){
u = allPoint[i].x - allPoint[j].x;
v = allPoint[i].y - allPoint[j].y;
w = allPoint[i].z - allPoint[j].z;
l = Math.sqrt(u*u + v*v + w*w); // 勾股定理
u = u/l; v = v/l; w = w/l; // i 指向 j 的方向向量上的投影模距离
b = (allPoint[i].v-allPoint[j].v)*u + // 在 X 上,两点的速度差(相对速度),乘以距离向量,3 个相加,得到合力
(allPoint[i].u-allPoint[j].u)*v + // 这个合力,就是(阻尼),为什么呢?因为速度差,就是阻力。
(allPoint[i].q-allPoint[j].q)*w;
elasticity = -(l-longArr[index++]+b)/ elasCoe; // 核心公式,弹力 elasticity =(当前长度-舒适长度+阻尼),其中 elasCoe 是弹簧的硬度、弹性系数
// 设置 i 的力
allPoint[i].f += elasticity*u; // 弹力分解到 X 轴(Y 轴速度)
allPoint[i].g += elasticity*v; // 弹力分解到 Y 轴(X 轴速度)
allPoint[i].h += elasticity*w; // 弹力分解到 Z 轴(Z 轴速度)
// j 的反作用力
allPoint[j].f -= elasticity*u; //+ 根据牛顿第三定律,反作用力(下同)
allPoint[j].g -= elasticity*v;
allPoint[j].h -= elasticity*w;
}
// 渲染 3D
function render(faceNum, allPoint, c, W, B, C, T, isInsideFace = true){
for (i=faceNum; i--;) { // 3D 投影绘制,每次循环
j = (i+1)%faceNum; // i 的邻居 J
u = allPoint[i].x - allPoint[j].x;
v = allPoint[i].y - allPoint[j].y;
w = allPoint[i].z - allPoint[j].z;
l = Math.sqrt(u*u + v*v + w*w);
U = u/l; V = v/l; Q = w/l; // 计算从 i 到 j 的方向
j = (i+2)%faceNum; // i 的另一个邻居
u = allPoint[i].x - allPoint[j].x;
v = allPoint[i].y - allPoint[j].y;
w = allPoint[i].z - allPoint[j].z;
l = Math.sqrt(u*u + v*v + w*w);
u = u/l; v = v/l; w = w/l; // 计算从 i 到另一个 j 的方向
T = (v*Q - w*V) * allPoint[i].x - // 向量叉乘计算光照
(u*Q - w*U) * allPoint[i].y + // ,结果的正负带边面收否朝向(背向)我们
(u*V - v*U) * allPoint[i].z; // 这个 T 就是光照强度,原理很简单,就是面的向量(法向)的方向
u = allPoint[i].x;
v = allPoint[i].y;
w = allPoint[i].z;
l = Math.sqrt(u*u + v*v + w*w); // 计算 i 点到原点的距离,用于计算透视
c.beginPath();
m=allPoint[i]; // m 是当前原子
f = 3 * 200 / (200-m.z); // 核心投影透视公式,计算缩放因子 f
c.moveTo( m.x*f+W/4, m.y*f + W/4 ); // 将原子的坐标转换为 2D 坐标,(W 为屏幕宽度),第一个点 X Y 控制左右上下,Z 控制 f
j = (i+1)%faceNum; // 找到 i 的邻居,第 2 个原子
m=allPoint[j];
f = 3*200/(200-m.z); // 同样,计算透视后的缩放因子 f
c.lineTo( m.x*f+W/4, m.y*f + W/4 ); // 两点之间的连线
j = (i+3)%faceNum; // 第 3 个原子(构成一个四边形)
m=allPoint[j]; f = 3*200/(200-m.z);
c.lineTo( m.x*f+W/4, m.y*f + W/4 );
j = (i+2)%faceNum; // 第 4 个原子
m=allPoint[j];
f = 3*200/(200-m.z);
c.lineTo( m.x*f+W/4, m.y*f + W/4 );
if(isInsideFace){
T=T/l;
} else {
T=T/l/-2;
}
if ((!(i%2))&(T<=0)) { // 如果这个面是偶数面(避免重复绘制),且朝向我们
c.fillStyle="hsla("+C+",99%," + -80*T +"%,"+B+")"; // 根据亮度 T 来填充颜色,?T 越小越亮
c.fill(); // 填充四边形
}
if(isInsideFace === false){
c.stroke(); // 给四边形描个边
}
}
return T;
}
const keyMap = {
'ArrowUp': 'up',
'ArrowDown': 'down',
'ArrowLeft': 'left',
'ArrowRight': 'right',
'w' : 'up',
's' : 'down',
'a' : 'left',
'd' : 'right',
'o' : 'in',
'k' : 'out',
}
window.addEventListener('keydown', function(event) {
forceState[keyMap[event.key]] = 0.1;
console.log(forceState);
});
window.addEventListener('keyup', function(event) {
forceState[keyMap[event.key]] = 0;
console.log(forceState);
});
</script>
下面我们来逐一解释。就按照上面我分成的 4 个部分。
其实代码的注释本身已经写的很清晰了,推荐大家摘录下来,静下心,慢慢看。不过还是需要简单的过一下。
/ 01 / 生成圆带的 X Y Z 坐标数据。(顺便初始化各轴的速度、加速度)
整个程序,是执行在一个动画里的,就是一个 定时函数 ,大概每秒 66 帧。
每次执行时,会判断 M 是否为真,以判断要不要初始化一下,先把这个立体模型的顶点数据、速度和加速度初始化。
我们事先定义了,这个「圆」带,是一个 90 面体。然后根据公式,将每个棱上的顶点数据生成一下,一共 90 个棱,180 个点。
allPoint() 这个函数被执行了两次,一个是左端的那个点,一个是右端那个点。
之后,将每条线的长度,也就是「橡皮筋」的长度记录一下。用于后续的弹性计算。这个就是舒适长度。
/ 02 / 通过虎克定理、弹力法则、牛顿2和3定理,计算出每个点所受的合力。
之后,就是激动人心的物理模拟了!
这是神奇的时刻,因为我们高中学过的简单的公式,写成代码,一共也没哟多少行,一目就尽收眼底,然后在 计算机 这个算题大机器上,就能因此呈现出如此神奇和惊人的效果。很爽。
每秒,是 66 帧,每帧将循环模拟计算物理 1 次。
每帧的最开头,将所有的力清零。同时,将我们使用键盘 WASD 给的力给叠加上去。
然后再挨个计算每个点紧挨的 3 个点的受力情况。使用 calPointForce() 这个函数计算。
u = u/l; v = v/l; w = w/l;
其中,有上面这个技巧,它类似于向量的那种计算吧 ~
这个力算出来,还有反作用力,相互加一下,就完成了力的计算!
如此简单。我们并不在乎整体这个模型长什么样,我们只在乎它的每个点。
这也是一种哲学。微观思维。
/ 03 / 把合力,当做加速度,物理世界每计算一次,就叠加到速度上一次,计算出每一次的坐标。
我们把力的信息存到我们的总档案数组 allPoint[] 里了。那接下来,就是「办正事」了。
我们要在 allPoint[] 里更新我们新的坐标。依据就是当前的速度以及加速度(就是力),很简单!
我们的模拟,坐标、加速度、速度,都少不了。
而这个「办正事儿」,则只需要力,和小学生加减乘除一样,X = V + aT (T=1),叠加到原来的坐标上,就好了!
/ 04 / 根据坐标,渲染一次
有了坐标,就简单了,直接开画。其实三维的渲染绘制没有那么复杂。比你画圆圆圈圈点点就多了个透视,近大远小。没了。
透视的道理很显然,远处的矩形画小一点,近处的画大一点。正比例关系!也没别的可说的。
为什么画两次呢?(也就是,为什么 render() 执行了两次呢?)
很简单,上面那个,是画外表面的,下面那个是画内表面的。而且,两者,颜色也不一样!内部的要暗一点。
其余的,什么事件监听。但凡写过一个迷你的游戏,甚至做过一个小的特效网页过,应该就不用多说了。
大家也可以试试,简单改一下代码,改写成一个 三棱柱 或 立方体 ??? 效果会出乎你的想象!