恒美微站
首页
关于我们
建站服务
主题模板
案例展示
资讯中心
联系我们
从零手搓3D引擎:WebGL物理渲染与碰撞检测实战
首页
资讯中心
/
从零手搓3D引擎:WebGL物理渲染与碰撞检测实战
从零手搓3D引擎:WebGL物理渲染与碰撞检测实战
发布时间:2026/10/15 23:01:55
直接需要 Markdown 正文不添加其他内容。 ## 1. 先聊聊为什么要手搓一套3D引擎近两年市面上能拿到的3D引擎越来越多WebGPU的推进也让浏览器里的游戏质量上了一个台阶。但说实话如果你真想理解3D游戏是怎么跑起来的找一个封好的引擎拖几个物体进去和亲手把一个方块从顶点数据写到屏幕上体验完全不一样。这个项目标题里最关键的两个词是“手搓”和“3D物理与渲染”说白了就是抛开引擎编辑器用原生HTML5技术栈从零实现一套能承载3D渲染和物理模拟的引擎骨架。我在动手前先问了自己三个问题为什么要写写来做什么写到什么程度算完答案很直接为了搞清楚坐标变换、刚体碰撞、着色器这些概念在底层到底怎么串起来。功能上不追求引擎大而全只要能加载简单网格、跑方向光、处理物体掉落和碰撞反弹就算达到预期。最适合参考这套思路的人是那些已经能看懂WebGL基础代码但被各种框架语法糖绕晕想知道背后逻辑的同学。我还特别想强调的是这套东西写完以后你再看任何商业引擎的文档那些概念的颗粒度感会完全不同特别有利于后续深入。我选择的整体路线是渲染层直接裸写WebGL 1.0物理层自己实现刚体、球体碰撞和AABB、以及简单的OBB然后通过一个场景管理器把两部分拼在一起。这套组合不是最现代的但它是复杂度最低、最容易看清全貌的组合。如果一上来就上WebGPU加物理引擎库那又变成读别人代码的轮子了。我需要的是能一行行写出来的、属于自己的3D物理与渲染系统。2. 渲染系统的架构与核心实现2.1 从坐标变换到MVP矩阵3D渲染里最绕不开的是MVP矩阵模型、视图、投影。绝大多数人在这里翻车都是因为搞不清坐标系和矩阵乘法顺序。我的做法是每个网格对象保存三个变体模型矩阵Model、视图矩阵View、投影矩阵Projection。在顶点着色器里统一相乘// vertex shader attribute vec3 aPosition; uniform mat4 uMVP; void main() { gl_Position uMVP * vec4(aPosition, 1.0); }为什么这么简单因为我把矩阵相乘放在了JavaScript侧每次渲染时用mat4.multiply把三个矩阵按M * V * P的顺序乘成一个uMVP传给GLSL。这里的关键坑是矩阵乘法的方向和直觉相反。你希望先做模型变换再做视图变换最后做投影那么着色器里得到顶点最终位置的顺序其实是P * V * M * 顶点坐标。如果你在JS里写multiply(P, multiply(V, M))最终传给Shader的矩阵就是正确的MVP。如果顺序反了会出现物体绕错误轴旋转、相机一动画面就飞走之类的诡异现象。视图矩阵我用的还是经典的lookAt实现。不依赖现成库的情况下自己实现一次会很有收获。大致逻辑是根据眼睛位置、目标点、上向量构造三个互相垂直的基向量然后组装成4x4矩阵。要注意上向量如果和视线方向平行基向量会退化出现矩阵不可逆的情况。实际测试中相机从正上方往下看时容易触发这个问题所以我加了经验判断如果上向量和视线方向的点积接近1或者-1就调整上向量为(0, 0, 1)或其负方向。投影矩阵分透视和正交两种。透视投影矩阵的手写公式网上很多但我的建议是别背公式理解几个关键参数就好视场角、宽高比、近裁剪面、远裁剪面。近裁剪面选得太小会导致深度缓冲精度浪费太大又会把靠近相机的物体切掉。我实测近裁剪面设为0.1、远裁剪面设为1000视觉精度和场景规模都能接受。2.2 着色器与光照模型让立体感出来渲染系统里如果没有光照所有物体都是扁平的剪影。我实现的第一版光照是经典的Blinn-Phong模型它在效果和性能之间比较平衡。顶点着色器主要算世界空间的法线和位置片元着色器负责光照计算。我用的是方向光加环境光// fragment shader (simplified) uniform vec3 uLightDir; uniform vec3 uLightColor; uniform vec3 uAmbientColor; varying vec3 vNormal; varying vec3 vWorldPos; void main() { vec3 norm normalize(vNormal); float diff max(dot(norm, -normalize(uLightDir)), 0.0); vec3 viewDir normalize(uCameraPos - vWorldPos); vec3 halfDir normalize(normalize(uLightDir) viewDir); float spec pow(max(dot(norm, halfDir), 0.0), 32.0); vec3 result uAmbientColor diff * uLightColor spec * uLightColor; gl_FragColor vec4(result, 1.0); }这个着色器有个容易忽略的细节法线必须归一化。如果网格数据里的法线长度不统一光照会出现亮暗条纹有些面特别亮有些面特别暗。我调试时用自定义立方体测试因为每个面法线是常量一旦有问题立刻能发现是法线数据的问题还是归一化的问题。另外一个容易出问题的点光照方向到底应该反着传还是正着传。真实用法里uLightDir表示光传播方向从光源指向物体而法线点积需要的是“表面指向光源”的方向所以代码里要取反。我一开始弄反了整个场景是反的看上去就像背光的物体反而亮排查花了不少时间。点光源我后面也加上了逻辑不复杂主要是距离衰减。衰减系数我用的是1/(1 k * d * d)k可以根据效果调整。这个公式的好处是距离为0时值不会爆炸而且远处衰减平滑。手机浏览器上性能比较紧张时建议单个片元里最多算4个点光源再多的要么用光探测纹理烘焙要么就只能忍痛砍掉。我实测在低端安卓设备上同时放6个点光源帧率明显掉下来这个数量级控制是必须的。2.3 网格渲染与纹理映射的工程细节网格渲染绕不开三个东西顶点缓冲、索引缓冲、纹理。我用WebGL 1.0的API创建ARRAY_BUFFER和ELEMENT_ARRAY_BUFFER然后通过vertexAttribPointer告诉GPU这些数据怎么解析。顶点布局我设计得比较直接每个顶点包含位置(vec3)、法线(vec3)、UV坐标(vec2)也就是8个float。为什么要把UV和法线放一起而不是拆开因为一个物体可能要用多套纹理可能要做顶点动画拆开管理会增加状态切换。这个布局对调试最友好你不会在排查问题时分不清哪段数据是法线哪段是UV。纹理加载这部分坑最多。浏览器加载图片是异步的如果你在纹理还没加载完就用texImage2DWebGL会报错或生成空白纹理。我的处理方式是在加载完成后才把这个材质对应的渲染对象加入可渲染队列或者用一个默认白色纹理占位加载完成后替换。texParameteri的参数也值得注意。纹理过滤我用LINEAR_MIPMAP_LINEAR但必须先调用gl.generateMipmap否则采样会失败。还有一个坑是纹理坐标的翻转WebGL默认纹理坐标原点在左下而图片加载后的数据坐标系是左上。很多小白因为没做UNPACK_FLIP_Y_WEBGL设置纹理显示出来上下颠倒。我的代码里设了gl.pixelStorei(gl.UNPACK_FLIP_Y_WEBGL, 1);这行是必须的。如果你不用这个设置就得在UV坐标上做手脚手动把v变成1.0 - v那样的话后续做图集就会很麻烦。2.4 相机系统的实现思路刚开始写引擎时我天真地以为相机就是视图矩阵加投影矩阵。实际上一个完整的相机系统还需要处理视锥体、控制逻辑、以及后续做射线拾取时的射线生成。我实现的相机是个典型的轨道相机鼠标拖拽旋转滚轮缩放。核心控制逻辑是维护三个参数球坐标系下的仰角、方位角、半径。然后通过球坐标公式换算成相机位置再用lookAt生成视图矩阵。这样做的好处是相机绕着目标点转不会漂移。如果你直接累加旋转矩阵用不了几次欧拉角万向节锁的问题就冒出来了。相机还要提供射线支持用于拾取物体。原理很简单屏幕坐标通过逆变换映射到世界空间的一条射线然后和场景里的物理碰撞体求交。我一开始觉得射线就是数学上的直线直接从相机位置沿方向射出即可。实际做完后发现问题在于如何把屏幕坐标映射到NDC坐标再映射到世界坐标要逆MVP矩阵。Matrix库自带invert函数但每帧调用会有一点开销我做的优化是只在鼠标点击或移动时计算而不是每帧都算。3. 物理系统的分层设计与核心算法3.1 刚体模型状态量怎么定义物理系统的第一个设计决策是用粒子系统还是刚体系统。粒子系统只要位置和速度刚体系统还需要朝向、角速度和转动惯量。既然目标是3D物理那必须走刚体路线。刚体对象的数据结构大概长这样class RigidBody { constructor(mass) { this.mass mass; this.inverseMass mass 0 ? 1 / mass : 0; // 0表示静态物体 this.position new Vec3(); this.velocity new Vec3(); this.orientation new Quaternion(); this.angularVelocity new Vec3(); this.forceAccum new Vec3(); this.torqueAccum new Vec3(); this.restitution 0.5; // 恢复系数 this.friction 0.3; this.collider null; } }为什么要用四元数表示朝向而不是欧拉角或矩阵因为四元数做插值平滑、避免万向节锁、合成旋转都方便。但四元数对新手很不友好需要理解它是一个单位四维向量。我踩过的一个坑是写物理积分时把四元数和角速度结合要小心。角速度是三维向量表示绕某个轴旋转的角速度四元数对时间的导数不是一个简单的乘法而是dq/dt 0.5 * q * (0, wx, wy, wz)其中乘法是四元数乘法。这个公式我刚开始写成0.5 * (w) * q顺序全是反的结果物体旋转方向完全不对。排查的时候我把角速度设为绕Y轴旋转结果显示物体绕Z轴旋转这才发现顺序问题。刚体状态更新采用半隐式欧拉积分。为什么用半隐式而不是显式因为显式欧拉在速度很大的时候容易让物体螺旋式发散半隐式会稳定很多。实现上就是先用重力更新速度再用速度更新位置body.velocity body.forceAccum * body.inverseMass * dt; body.position body.velocity * dt;力累积器每帧要清零否则上一帧的力会污染下一帧。另一个细节静态物体即inverseMass 0的那种应该跳过所有积分计算否则质量无穷大导致位移变成NaN。我在update循环里第一行就判断if (body.inverseMass 0) return;这个小优化避免了至少十次NaN灾难。3.2 碰撞检测从包围盒到离散碰撞对物理系统最核心也最头疼的部分是碰撞检测。我用的是分层策略先做空间粗筛再做精确碰撞检测。粗筛用的是简单的空间网格划分。把场景划分成格子每个物体根据AABB所在的格子范围被分配到多个格子。然后只检测同一个格子或相邻格子里的物体对。如果你场景只有几十个物体这个优化效果不明显但一旦上百个箱子堆在一起粗筛能把碰撞检测从O(n^2)降到接近O(n)。精确检测根据碰撞体的类型分三种情况球体对球体检查两个球心距离是否小于半径之和。这是最简单也最稳定的适合做粒子和小球。球体对AABB找到球心在AABB上的最近点算距离。这个最近点算法其实就是把球心的每个分量clamp到AABB的最小最大值之间。AABB对AABB分别检查三个轴上的投影是否重叠都重叠则碰撞。我一开始觉得用AABB就够了后来发现游戏里很多物体是旋转过的AABB会膨胀得很夸张碰撞手感很差。于是又实现了OBB有向包围盒——用分离轴定理检测。分离轴定理的思路如果两个凸体没有碰撞那么一定能找到一个轴让两个凸体在这个轴上的投影不重叠。对于OBB来说候选轴就是两个盒子的6个面法线方向加上叉积产生的额外轴。我在实现时和撤销了这一层后直接上线对比了一下发现有了OBB以后桌面上木箱与木箱之间的叠放稳定程度提升明显斜歪的箱子不会因为AABB膨胀而悬空。还有一个碰撞检测的“时序”问题。离散检测只比较当前帧的状态如果物体速度太快可能从薄墙的一边直接穿到另一边完全没触发碰撞。解决方法是连续碰撞检测最简单的实现是计算每个物体在当前帧的扫掠体。但这个开销比较大我对每个物体加了一个“最大速度限制”超过一定速度就用子步进积分处理。比如一帧内把时间步长拆成4小步每步都做碰撞检测这样既能防穿透又不会显著拖慢性能。有一个例外要注意你在调高物理步进次数时碰撞响应本身也会叠加多次可能导致物体弹跳过高。解决的办法不是减步数而是降低每次响应时的冲量权重。3.3 碰撞响应冲量加摩擦让物体“弹”起来检测到碰撞之后下一步是把物体推开并改变速度否则物体会陷入地面里发抖。我的首版实现用位置修正加冲量。位置修正很简单计算两个物体的穿透深度然后按质量分配将物体沿法线方向推开。这个技巧在稳定堆叠物体时特别重要。冲量响应的核心公式是j -(1 e) * (v_rel · n) / (invMass1 invMass2)其中v_rel是相对速度n是碰撞法线e是恢复系数。最后用j乘以法线加到两个物体的速度上注意方向要相反。这里要特别留意公式中的v_rel应该是撞击位置处的相对速度如果物体有旋转需要把角速度和圆心位置的影响算进来v_rel (v1 ω1 × r1) - (v2 ω2 × r2)其中r1是从物体1质心到碰撞点的向量。这个公式在刚体动力学里叫接触点速度。最初我偷懒直接用质心速度代替结果发现物体之间的碰撞像两颗子弹对撞没有任何旋转效果箱子掉到地上不会打转缺少真实的物理味道。加上角速度参与后物体的翻滚感马上就出来了。摩擦力的处理我用的是库仑摩擦模型在切向方向施加一个冲量但上限是法向冲量乘以摩擦系数。如果不加限制摩擦力会产生超大反向速度物体变成“一碰就停”的奇怪状态。碰撞后还有一个非常实际的细节物体和地面接触时会有很多持续的接触点如果每帧当作碰撞处理会造成物体微小的振动。我最后加了一个接触缓存机制——记录物体和地面/其他物体之间的接触状态如果接触点在连续多帧内都存在就减少重复施加的冲量只做位置修正。这个问题在堆叠箱子时尤其致命不处理的话地面上的箱子会像果冻一样震个不停。3.4 时间步长策略固定步长加插值物理系统的时间步长选择直接影响稳定性和确定性。我用的是固定时间步长加累计器渲染帧率可以是60也可以是120但物理固定按1/60秒步进。为什么不用可变步长直接把deltaTime传给物理系统因为可变步长会导致不同的穿越深度、不同大小的冲量结果就是物理表现不稳定。同一个场景有时候能堆住箱子有时候箱子会自己爆开全看帧率波动。固定步长下物理系统每一帧看到的都是相同的时间间隔数值行为可预测。累计器的实现逻辑let accumulator 0; let lastTime performance.now(); function loop(now) { let frameTime (now - lastTime) / 1000; lastTime now; frameTime Math.min(frameTime, 0.1); // 防止切后台回来时空走一步 accumulator frameTime; while (accumulator PHYSICS_DT) { physicsStep(PHYSICS_DT); accumulator - PHYSICS_DT; } const alpha accumulator / PHYSICS_DT; render(alpha); requestAnimationFrame(loop); }alpha就是这次渲染帧处于两次物理步之间的插值比例。渲染时用prevPosition和currentPosition做线性混合这个技巧叫“固定时间步长加渲染插值”。它最大的作用是即使在60Hz刷新率下物体运动也不会因为物理步进与渲染帧边界错开而显得一卡一卡的。我觉得这一步是整个引擎手感提升最大的一处比很多花哨的后处理都有效。代价是多存一份上一帧的状态内存开销很小但代码里到处都要改一下很多初学者会在这一步写漏导致物体出现微小的位置回退。4. 让物理和渲染在一个循环里跑起来4.1 场景对象模型一套数据驱动两套系统有了渲染系统和物理系统之后最需要的是一个对象模型把他们结合起来也就是场景图。我不会像大型引擎那样做复杂节点继承场景对象统称Entity每个Entity可以挂一个渲染组件和一个物理组件。组件模式让数据访问很安全渲染系统只关心网格和材质物理系统只关心碰撞体和刚体。如果某个Entity没有物理组件它就纯粹静态渲染没有渲染组件它就纯物理。你也可以做纯逻辑物体比如触发区域。实体管理用数组加ID。每个Entity分配一个整数ID通过ID查询对象而不是直接持有对象引用。为什么不用引用因为在实体删除后物理系统里可能还存着指向这个实体的碰撞记录如果直接引用会造成悬空指针。用ID加一个代际计数可以避免这个问题。我过程中有几次因为删除物体后没有清理渲染队列导致渲染出来的物体在物理世界里根本不存在看起来像鬼影。用ID方案后只要删除时把ID标记为失效所有系统统一检查就不会有这种问题了。场景加载我直接写死一个JSON描述列出物体的位置、旋转、缩放、网格路径、材质、物理体类型和质量。解析这个JSON后创建对应组件。这样带来的好处是想要一个新的关卡只需要改数据文件不用重新编译。做某种跨平台的小型Demo时这套数据驱动方案非常有用后续如果要接编辑器工作也能顺利很多。4.2 主循环与帧率控制主循环是HTML5游戏的心跳我用requestAnimationFrame驱动。之前也尝试过用setInterval做16.7ms循环但浏览器切后台时定时器会被强制降频甚至暂停恢复后会产生时间跳跃。requestAnimationFrame天然和显示器刷新率同步是更好选择。渲染部分比较消耗性能的调用包括读取缓冲区数据、创建Shader、切换Program等。我在循环里只做必要的状态切换——如果渲染两个相同材质的物体先排序再调用绘制减少Program切换。绘制顺序也有讲究如果场景有透明物体必须把透明和不透明分开渲染。不透明物体先按深度排序从近到远透明物体从远到近画。否则透明物体后面的物体被深度缓冲提前剔除会出现一片片奇怪的重影。这里最容易犯的错误是漏掉gl.depthMask(true)的设置渲染完半透明物体后忘记重新开启深度写入后续物体全会出现错乱。我因为这个bug调试了一整个晚上最后发现就是一行代码的问题。主循环里我还加入了一个可选的HUD显示用2D Canvas画FPS和物理步进耗时。这个手段真的有用调物理参数时如果帧率和物理步进时间有波动立刻就能看到哪部分代码是性能瓶颈。4.3 调试渲染物理线框与可视化没有调试渲染的物理系统是寸步难行的。碰撞体是否贴合、法线方向对不对、穿透是否发生这些如果全靠脑补会白白消耗大量调试时间。我的做法是所有碰撞体都有对应的线框渲染模式。球体画一个线框球体AABB画一个线框盒OBB画一个旋转过的线框盒。线框渲染通过gl.LINES绘制边线只需要顶点和索引不复杂。关键在于线框数据要跟随物理体位置实时更新而且调试状态下它们应该无视光照直接显示纯色。此外我给每个刚体显示速度和角速度向量。速度向量画一条从物体质心出发的线段角速度画一个旋转箭头。这个可视化对判断物理行为是否合理帮助非常大。比如一个球从桌子上掉下来如果速度箭头方向明显朝向地面但位置纹丝不动可能说明碰撞响应的位置修正被跳过了如果角速度箭头极长说明角速度数值在累积爆炸往往是积分步长或力矩方向的问题。另一个实用的调试手段是碰撞接触可视化——把当前帧所有碰撞接触点用黄色小球标记出来持续显示几帧。我能清楚看到箱子接触地面时接触点的分布是否合理也方便发现那些“夹缝中的穿透”。这个功能初版实现很简陋但帮助巨大帮我抓到一个重要bug在堆叠多个箱子时接触点每次都在变化导致箱子不断抖动。后来在接触缓存里增加了接触点稳定化的处理即将穿透修正分摊到几帧执行抖动问题才彻底稳住。5. 实操中踩过的坑与排查经验5.1 WebGL上下文丢失与恢复WebGL有一个别的图形API不太常见的设定当显存不足或标签页切到后台再回来时GPU可能主动释放渲染上下文此时所有资源都会失效。如果你重新激活页面时代码还傻乎乎地按原有对象调用画面上就是一片黑或者卡成PPT。浏览器会派发webglcontextlost和webglcontextrestored事件。我的处理策略是在contextlost时设置一个标志暂停所有渲染循环在contextrestored时重新编译所有Shader、重新上传所有纹理和网格数据。这个工作量不小所以我设计成“资源注册表”模式渲染层把所有资源都记录到一个统一列表里恢复时遍历列表重建。千万别在恢复时偷懒只重新创建程序而不重新创建纹理那会让你带到屏幕上的东西出乎意料地花屏。另一个相关问题是不要在绘制循环里动态创建ShaderProgram那太耗性能了。我一开始就创建好所有Program后续只是切换使用。如果后续想支持多材质也是提前编译好运行时只做切换和参数更新。5.2 矩阵顺序和坐标系不一致导致的“鬼影”渲染系统里坐标系必须统一。我遇到过一次物体出现在一个完全奇妙位置的问题场景里物体渲染的结果和物理位置对不上看起来物体在天上飘但物理计算明明说在地面上。原因最终锁定在Y轴和Z轴的选择上。WebGL本身是右手坐标系但没有强制哪个轴向上。我物理代码里用了Y轴向上渲染用的模型数据却是Z轴向上的程序标准格式。结果就是渲染时模型在Y轴位移但在物理里被解释为Z轴位移物体的视觉位置和刚体位置错开了一整个轴。排查时我徒手在关键位置加了一堆日志把物理位置和渲染的模型矩阵一一打印出来对比后才发现错轴。修好以后我在所有坐标输入入口加了统一约定世界坐标一律Y轴向上模型导出时转换一次。这个教训让我养成了写“坐标系约定”注释的习惯项目里所有新增代码都遵守同一个规范。// 坐标系统一约定 // X 向右Y 向上Z 向屏幕外右手坐标系 // 旋转角正方向为逆时针5.3 碰撞穿透与抖动问题碰撞穿透在跑快球和薄墙时特别容易出现。我第一版物理只做了一次检测球速快一点就直接穿墙。后来加了子步进处理每帧物理固定步长但在步长大于某个阈值时内部再拆分N个子步。子步数量由当前帧位移和物体AABB的最小边长度决定大概思路是子步数要保证每一步移动不超过碰撞体最小尺寸的一半。抖动问题则是另一类“脏活”。最常见的是两个物体刚接触时位置修正矫枉过正导致物体被推向另一个方向下一帧又因为碰撞修正回来看起来就像在高频抖动。我处理办法是给位置修正加一个“最大修正距离”一旦需要超过这个值就说明穿透过于严重可能已经发生了穿越需要走更重的恢复逻辑而不是简单推开。另外还有一个细节物体堆叠时底部物体会受到来自上方的持续压力如果恢复系数不为0物体永远在弹跳。这时可以把物体间的接触标记为“静止接触”仅当相对速度超过阈值时才施加冲量否则只做位置修正。这个优化让箱子堆叠从“豆腐渣工程”变得坚固很多。5.4 材质法线问题表面纹理发黑有一次给地面铺了一张纹理地板渲染完成后发现所有光照正常但地板却有一半阴影。排查了半天最终确认是法线没有归一化。渲染系统里如果某些顶点的法线数据没有归一化点积运算会得出偏大的值或偏小的值让部分区域被误判成背面。修复方式很简单加载网格数据后统一做一次法线归一化并且确保每一个面的法线朝向一致。如果只有一个顶点法线方向错了光照就会在网格上画出奇怪的黑斑。更隐蔽的情况是模型里存在重复顶点一个顶点对应多个面法线导致平滑着色还是平面着色表现混乱。我做的处理是在模型加载阶段做顶点去重并把同一位置的多条法线按面积加权平均。这个操作对美术模型特别重要否则你模型表面会出现一条条“缝合线”。另一个发黑原因是纹理坐标重复采样导致边缘黑边在放大纹理时出现。解决办法是确保纹理参数设置成CLAMP_TO_EDGE不要用REPEAT除非你真的想让纹理平铺。5.5 性能优化的几个实战手段引擎写出来后我开始往场景里塞大量方块测试性能。第一次塞了500个方块帧率直接掉了三成。优化从哪里下手第一刀减少Draw Call。把材质相同的静态物体合并成一个大体用顶点索引一次性绘制。这个操作不复杂但效果极其明显。动态物体无法合批但数量通常不多影响有限。第二刀做简单的视锥裁剪。相机视锥体之外的物体不进入渲染队列。这个优化在场景物体向两侧铺开时效果很大。我的实现是把每个物体AABB的八个顶点变换到相机空间判断是否和视锥相交。如果八个点全在视锥同一侧面之外直接跳过。第三刀物理的粗筛优化前面已经提到。空间网格划分的格子边长我根据场景平均物体尺寸动态调整目标是从网格中取出的候选碰撞对数量尽量少而错检率不要太低。调参时要反复测试格子太大会退化成O(n^2)格子太小会重复分配很多内存。我最终采用的是格子边长设为最大物体尺寸的两倍在这种尺寸下内存开销和粗筛效果平衡得最好。第四刀避免每帧分配新对象。JavaScript虽然没有像C那样显式的内存管理但频繁的new Vec3()造成大量堆分配和GC停顿。我实现了一个向量对象池碰撞检测时从池子里借临时向量用完归还。实测帧时间稳定了很多GC造成的卡顿几乎消失。6. 后续扩展与最后想说的话6.1 从引擎骨架到完整游戏项目引擎骨架跑通以后可以做很多有意思的扩展。常见的方向是加一个简单的第一人称或第三人称控制器把相机绑定到物理体上让角色能走、能跳、能碰撞。这需要你让刚体做角色控制时不能走完全依赖物理的路子否则一个小台阶就会卡住。业界常见做法是角色控制器单独处理水平移动物理系统只负责垂直方向的重力和碰撞。我试了纯物理方式角色在斜坡和台阶上表现很糟油滑且难以控制后来改成了“半物理”模式手感才算正常。渲染方面可以加后处理管线发光模糊、景深、阴影贴图。阴影贴图的第一步要离线渲染一张深度贴图然后把相机移动到光源位置。这一步实施难度适中但效果提升明显。如果你项目还想要粒子系统可以考虑用独立的粒子渲染器顶点数据在CPU端更新后上传适合做爆炸和烟雾效果先做几百个粒子就能看到不错的效果。6.2 一些值得尝试的扩展方向物理系统后续可以往更复杂的方向走凸分解、铰链约束、刚体破碎。约束系统其实就是把碰撞响应抽象成一组约束方程用迭代求解器求解。一旦你理解了约束求解就可以做出布娃娃系统、车辆悬架、绳索等。渲染系统则可以考虑WebGPU的移植——把WebGL 1.0代码抽象成渲染接口层后面接WebGL 2.0和WebGPU后端。其实引擎架构的最大价值就在这个接口层设计得好不好决定了后面接后端的痛苦程度。音频、输入映射、UI系统这些也可以逐步加入。我自己后续打算做一个简易的二进制场景格式把JSON解析性能再优化一轮。6.3 我个人的一点体会这个项目做下来我最深刻的体会是写引擎的过程其实是在逼自己把每个概念都理解到能“教别人”的程度。看似简单的“物体掉落再弹起”背后牵扯了向量运算、刚体动力学、离散碰撞检测、矩阵变换、着色器、渲染管线。每一步我都走错过但每走错一次对那个概念的印象就深一分。如果你也想尝试我给的建议是第一次不要追求完美先让一个球掉下来弹一弹、一个方块转一转。全链路跑通以后再把设计优雅化。千万别一开始就想做“通用引擎”你会在无限功能里迷失。如果这套基础框架和踩坑经验能帮你少走一些弯路目的就达到了。