«

很有意思!不到 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 控制前后。

202508210031487N8OZn.gif

首先说明一下,三维的效果是纯 canvas 画的。我之前以为三维只能用 webgl 画,其实 canvas 也能画的不错,理论上性能会差很多,实际上也就那样儿。

代码其实很少,下面是代码的正文,全部代码就这点儿:

<canvas id="m" style="display: block; width: 992px; height: 827px;" width="12" height="10"></canvas>
<script>
    _='var a‡getElementById("m"_b‡body,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“;7vf,ug,qh,xv*,yu*,zq*,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.clearŠH_cCG2H_C=20cCG3W/4_C=#1)0_B=B<1?B+.1:1},15ŽkeyMap={|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=x„†„y„z,allPotŒStyle="hsla("+i=;i--;)0,0*T+"%,"+B+")`cŒŠu/=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~downSpeed€measureLen(i,groundY‚itPotData(ƒreturn„-e[j].…frontZ†x,v=y‡=document.ˆelasticity‰long|[@++]ŠRect(W,‹leftŒ.fill,w=zŽ);const +=elasCoe‘render(’a.width“,1+/2';for(Y in _$='“’‘ŽŒ‹Š‰ˆ‡†…„ƒ‚€~|`_^XOKJG@7$#')with(_.split(_$[Y]))_=join(pop());eval(_)
</script>

把 JS 用 crash 压缩后,体积只有 1.9kb 大小。

img

但是,却可以实现一个复杂的3D物理引擎,外加一个 3D 渲染。

现在,我就将可读的源代码贴出来,然后为大家讲解一下,这个物理引擎是怎么实现的。

在核心代码上,一共由四部分组成:

用到的公式,一共就两个。

虎克定理,计算弹力 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() 执行了两次呢?)

很简单,上面那个,是画外表面的,下面那个是画内表面的。而且,两者,颜色也不一样!内部的要暗一点。

其余的,什么事件监听。但凡写过一个迷你的游戏,甚至做过一个小的特效网页过,应该就不用多说了。


大家也可以试试,简单改一下代码,改写成一个 三棱柱 或 立方体 ??? 效果会出乎你的想象!

标签: 原创 三维 webgl

评论:
avatar
XX 3 个月前
你好
avatar
Lvtu 3 个月前
技术盲的我只是看看而已。。。膜拜~
avatar
Lucky 3 个月前
还得优化一下,移出屏幕后,得很久才能回来
commentator
独元殇 3 个月前
@Lucky:按键,是给它一个力,一个加速度。力让它的平移是指数级的,所以按的时间长了,它会移动的非常快。
avatar
GoodBoyboy 3 个月前
博主好厉害!(崇拜)
avatar
2broear 3 个月前
挺有意思