
我最早产生“干脆自己写一个引擎”的念头是因为手头某个网页互动项目需要小范围3D物理模拟但引入商业级现成引擎后光是把整个构建产物塞进页面、调配置、适配移动端就折腾了两天。后来我咬咬牙用纯JavaScript加WebGL2从零手搓了一个轻量级的HTML5游戏引擎核心就两块3D渲染系统和3D物理系统。写完那版之后我明显感觉自己的图形学底子和物理模拟直觉都上了一个台阶。这篇东西不打算写成教程式的API文档而是站在一个“手搓过一遍的人”的视角把拆模块、搭渲染、写物理、通联调、踩坑排查这整条路线捋出来。适合的人很明确已经写过一些前端对三维渲染或物理模拟感兴趣想搞懂“引擎到底怎么跑起来”的开发者。1. 手搓引擎的动机与整体架构拆解1.1 用现成引擎还是自己写先回答一个绕不开的问题市面上好的引擎多得是自己写图什么我的答案分两层。第一层是需求侧当时我需要的功能很克制大概就是几十个动态刚体、基础光照、模型加载再加一个给用户拖动交互的窗口。搓一套刚好匹配需求的轻量引擎构建产物能压在百来KB级别任何页面都能直接塞移动端也不卡。第二层是学习侧渲染和物理里最晦涩的部分什么投影矩阵、刚体碰撞冲量、空间八叉树只有亲手实现过一遍以后再读任何引擎源码才不会心虚。就算工程上未必划算这笔学习账是永远划算的。不过要泼一盆冷水如果你的项目目标是做复杂完整、有大量美术资产和联机逻辑的产品请务必用成熟的商业引擎或开源WebGL类库。手搓引擎的正确应用场景是学习自用、轻量级交互展示、或者给特殊运行时定制一个微型渲染器。1.2 架构划分渲染和物理从一开始就物理隔离写引擎最容易失控的地方是让渲染逻辑和物理逻辑互相纠缠。比如在渲染循环里去更新物理位置、在碰撞回调用到渲染资源。我一开始设计时就把整个引擎拆成了三层渲染层Renderer只负责摄像机、对象列表、GPU状态拿模型和网格数据生成绘制调用。物理层PhysicsWorld只维护刚体、碰撞体、碰撞检测与约束求解。场景管理层Scene持有每个游戏对象的渲染组件和物理组件引用负责把物理坐标同步给渲染层。这三个模块之间的依赖是单向的Scene 依赖 PhysicsWorld 与 Renderer但 PhysicsWorld 不依赖 RendererRenderer 也不依赖 PhysicsWorld。这样做的直接好处是我可以把物理模拟单独放在一个 worker 线程里跑而渲染只管每帧从共享状态取快照。2. 渲染核心实现WebGL2与着色器管线2.1 初始化WebGL2上下文与着色器编译整个渲染大梁还是得靠WebGL。我选的是WebGL2原因很实在着色器可以用GLSL 3.00支持显式的in/out语法还能用顶点数组对象管理顶点属性比WebGL1清爽很多。如果目标浏览器不支持再退回WebGL1也不难改。拿到上下文后第一件麻烦事是初始化默认状态。我踩过的坑是深度测试和背面剔除默认都是关闭的不显式开启的话物体模型会显示得乱七八糟前后穿透一目了然。所以我的初始化大致是这样const gl canvas.getContext(webgl2, { alpha: false, antialias: true, premultipliedAlpha: true }); gl.enable(gl.DEPTH_TEST); gl.enable(gl.CULL_FACE); gl.cullFace(gl.BACK); gl.enable(gl.BLEND); gl.blendFunc(gl.SRC_ALPHA, gl.ONE_MINUS_SRC_ALPHA); let pixelRatio window.devicePixelRatio || 1; canvas.width canvas.clientWidth * pixelRatio; canvas.height canvas.clientHeight * pixelRatio; gl.viewport(0, 0, canvas.width, canvas.height);注意devicePixelRatio的处理特别关键。很多引擎在普通桌面上看着正常放到高分屏Mac上一片模糊就是因为忘了用物理像素设置canvas尺寸。这个细节属于那种不写出来就永远记不住的坑。着色器编译没什么捷径就是把顶点着色器和片元着色器的源码逐个compileShader再linkProgram。我会把编译错误和链接错误完整打到控制台定位的时候有奇效。调试时强烈建议在开发环境里打开浏览器开发者工具的WebGL警告开关很多冗余状态切换一眼就能看到。2.2 从“建模坐标系”到“屏幕坐标系”的矩阵链条新手看渲染代码最头大的就是矩阵那一坨。其实矩阵世界的故事没那么玄每个物体自带一套模型矩阵相机有一套视图矩阵投影矩阵再把视锥体映射到裁剪空间。三者合起来就是常说的MVP矩阵。模型矩阵决定物体摆在哪里、朝向哪里、放多大。视图矩阵固定相机位置后把世界坐标换成以相机为原点的坐标。投影矩阵把视锥内的点映射到一个长宽高均为2的裁剪立方体里。矩阵乘法的顺序是无数人吃亏的地方。正确写法是projection * view * model * position也就是先对顶点施加模型变换再让相机去看它最后投影。反过来的话物体就会像在错误的世界里乱飞。用矩阵库实现的时候每一步都值得写注释以防三个月后自己看代码也发懵。我用的矩阵库是手写的Mat4类只有乘法和逆矩阵用到了认真实现。逆矩阵主要给相机和碰撞法线用虽然每次求逆开销不小但对小场景没问题。要是场景大了逆矩阵就该考虑做缓存。2.3 光照模型里的“首次上色”体验光照部分我用了一个相当经典而实用的方案环境光加方向光的漫反射再加一点高光。方向光的光照计算可以完全放在顶点着色器然后在片元着色器里乘上纹理颜色或物体颜色。这样性能开销很小视觉上又比纯色平涂舒服得多。片元着色器大概长这样#version 300 es precision mediump float; in vec2 vUv; in vec3 vNormal; in vec3 vWorldPos; uniform vec3 uCameraPos; uniform vec3 uLightDir; uniform vec3 uLightColor; uniform sampler2D uTexture; out vec4 outColor; void main() { vec3 albedo texture(uTexture, vUv).rgb; vec3 norm normalize(vNormal); vec3 lightDir normalize(-uLightDir); float diff max(dot(norm, lightDir), 0.0); vec3 viewDir normalize(uCameraPos - vWorldPos); vec3 reflectDir reflect(-lightDir, norm); float spec pow(max(dot(viewDir, reflectDir), 0.0), 32.0); vec3 final albedo * (0.2 0.8 * diff * uLightColor) vec3(spec) * uLightColor; outColor vec4(final, 1.0); }这段代码里的关键是法线方向。法线用normalMatrix从世界矩阵的逆矩阵转置变换如果物体没有非均匀缩放普通模型矩阵的旋转部分也能直接用。但我在调试时曾经因为没有正确处理法线变换导致光照看起来像被狗啃过一样斑驳排查了很久才意识到是非均匀缩放捣的鬼。2.4 渲染循环与相机的可交互方案渲染循环用的就是requestAnimationFrame。每一帧先处理相机输入再遍历场景里的可见物体逐个上传Uniform并绘制。物体的可见性剔除放在CPU侧做判断包围球是否在视锥内能挡掉相当一部分本来就看不见的绘制调用。相机这部分我用的是“第一人称视角加鼠标拖拽旋转”的交互方案。旋转相机不是直接修改位置而是维护相机的偏航角、俯仰角和距离然后通过球面坐标算出相机位置和视线方向。这样交互手感稳定也永远不会出现“相机滚翻”的万向锁问题。const yaw this.rotationY; const pitch this.rotationX; const cosP Math.cos(pitch); this.eye.set( this.target.x this.distance * cosP * Math.sin(yaw), this.target.y this.distance * Math.sin(pitch), this.target.z this.distance * cosP * Math.cos(yaw) );相机数据最后要生成视图矩阵。手搓时最容易出问题的是矩阵乘错了顺序导致相机“看了后面”或者“上下颠倒”。在调试阶段我经常放一个带纹理的立方体在原点做参照物相机一转就知道方程对不对。3. 物理系统的设计与实现细节3.1 碰撞体建模从球体到盒体的工程简化物理引擎真正的重头戏在碰撞。做碰撞之前必须先给所有物体一个可计算的“壳”。复杂网格的三角面片级碰撞在浏览器里跑起来代价太高引擎里基本都是用凸包、球体和盒体这类简化模型。我的做法是动态物体一律用球体或多面体包围盒做碰撞代理渲染用完整的网格。代理碰撞体不直接可见但只在调试阶段渲染成线框方便我观察碰撞是否正确。球体碰撞检测就一个距离判断非常便宜盒体则用分离轴定理检测逻辑稍多但依然高效。为了统一管理所有碰撞体都实现了getCenter()、getBoundingVolume()和testCollision(frustum)这几个接口。这样上层逻辑不关心底层是球还是盒只管调用接口后面扩展胶囊体或者凸包也不用破坏逻辑。3.2 物理积分器为什么我选了Verlet而不是欧拉物理模拟中物体位置的更新方式称为积分。最朴素的办法是欧拉积分用当前速度预估下一帧位置。但欧拉积分的毛病是容易产生能量积累弹簧效果越弹越F飞也就是“数值发散”。我在做布娃娃和弹簧约束时吃过这个亏。最终我给引擎的默认积分器换成了Verlet积分。Verlet的核心思路是新位置由当前位置和上一帧位置共同推导而不是依赖显式速度。它天然具有良好的能量守恒特性不需要额外阻尼就能稳定很多。更新位置的一行代码极其简洁// 需要记录 prevPos 和 curPos const nextPos curPos (curPos - prevPos) * damping acceleration * dt * dt;这条公式里(curPos - prevPos)相当于隐式速度damping系数每次可以让运动缓慢衰减避免物体永远晃下去。Verlet配合距离约束做布料、绳索和简单的刚体约束动画效果都非常稳定。虽然严格物理意义上它不代表完美刚体但做网页交互已经足够踏实。3.3 碰撞响应的正确打开方式分离与速度更新发现碰撞之后要做的有两步先修正位置重叠再修正速度方向。这两者的顺序不能颠倒。如果只改速度不改位置物体仍然嵌在墙里下一帧检测又会继续报碰撞。分离重叠最简单有效的方法是投影分离轴。两个球碰撞时直接把两个球中心连线方向偏移一个重叠量球碰平面时则把球沿着平面法线推出。实现代码如下function resolveSphereSphere(a, b) { const normal b.center.clone().sub(a.center); const dist normal.length(); const minDist a.radius b.radius; if (dist minDist || dist 0) return; // 不碰撞或无意义 normal.normalize(); const overlap minDist - dist; a.center.sub(normal.clone().multiplyScalar(overlap / 2)); b.center.add(normal.clone().multiplyScalar(overlap / 2)); // 速度分量更新 const relativeVelocity b.velocity.clone().sub(a.velocity); const velAlongNormal relativeVelocity.dot(normal); if (velAlongNormal 0) return; // 相互远离不处理 const restitution Math.min(a.restitution, b.restitution); const impulse -(1 restitution) * velAlongNormal / (1 / a.mass 1 / b.mass); a.velocity.add(normal.clone().multiplyScalar(-impulse / a.mass)); b.velocity.add(normal.clone().multiplyScalar(impulse / b.mass)); }这里每个刚体还要带质量和恢复系数弹性。把两个物体的质量设成一样就是对称分离设成悬殊比例就模拟出墙壁不动、小球弹走的效果。我在调这个函数的时候最深刻的教训是归一化零向量之前一定要判长度不然会出现一堆NaN让所有物体瞬间飞向原点。3.4 场景规模变大后空间哈希与Broad Phase当场景里的物体数量超过一百个两两检测的复杂度就变成瓶颈。这时就需要加Broad Phase宽阶段来提前滤掉八竿子打不着的配对。我用的是空间哈希网格把整个场景按固定边长划分成格子。物体的包围球落在哪些格子就把它的ID塞进那些格子的链表里只有共享格子的物体对才会进入精确碰撞检测。这个方案实现简单动态物体移动时只需要维护哈希表里的少量增删性能非常稳定。相比于“每次扫描所有配对再剔除”的传统遍历方式空间哈希在物体分布均匀的场景里可以把碰撞检测的开销降低一个数量级。我在一千个随机飞行的球上实测帧率依然能稳定在60帧左右。4. 渲染与物理的整合一帧内的分工流程4.1 固定时间步长与插值让物理不随帧率漂移把物理和渲染接在一起的经典难题是帧率不稳定。假如物理每次都跟渲染同一帧推进显示器刷新率一变物体的运动速度也就跟着变了。短时间内看不出来一对比就会显得有的机器上“世界慢半拍”。我的做法是固定时间步长。规定物理模拟每步固定推进1/60秒渲染循环里累积时间差达到一个步长就执行一次物理更新。为了避免帧率极低时“螺旋追赶”还要设一个最大累积次数上限超出部分直接丢弃。let accumulator 0; const fixedDelta 1 / 60; function frame(now) { const frameDelta Math.min((now - lastTime) / 1000, 0.1); accumulator frameDelta; while (accumulator fixedDelta) { world.step(fixedDelta); accumulator - fixedDelta; } // 用 alpha 对上一帧和这一帧的物理位置做线性插值渲染更平滑 const alpha accumulator / fixedDelta; render(world.getInterpolatedTransforms(alpha)); requestAnimationFrame(frame); }用alpha做位置插值后即使显示器是120Hz渲染的物体运动轨迹依然丝滑不会看到一卡一顿的台阶效果。这个设计是整合阶段最划算的一笔投资。4.2 场景组件系统渲染位置从物理位置同步场景层的核心是一个对象池加组件映射。每个游戏对象有一个Transform组件里面存位置、旋转和缩放。物理组件同步时物理引擎更新完刚体位置后把结果写回Transform渲染系统绘制时再用Transform生成模型矩阵。这样物理和渲染之间不存在直接引用后续想加更多逻辑组件比如音效、脚本也方便。组件系统的一个好处是可以做批量更新。每帧结束后物理世界会输出一个“变换列表”场景层一次性遍历这个列表更新对象而不是让每个对象各自去找物理世界要数据。这种批处理思路在浏览器里格外重要因为散乱的属性访问会拖慢JavaScript引擎的优化效果。4.3 性能监控我把帧率打到了屏幕上优化不能靠猜要在界面上随时看到指标。我写了一个极简的HUD组件直接在canvas角落显示当前帧率、物理步长时间、渲染绘制调用数和碰撞检测对数。这个HUD是我排查性能问题的最大帮手。比如有一次网格模型一多帧率就掉到30帧通过HUD发现瓶颈不是物理碰撞对太多而是绘制调用数冲破了1000。于是我把静态物体的网格合并成一张大缓冲区一次性绘制帧率立刻回到60。没有数据显示的话这种定位要花好几倍的时间。5. 我踩过的坑与排查技巧5.1 黑屏问题的定位套路渲染黑屏是新手碰到的第一个超级大坑。根因一般就几种着色器编译失败、MVP矩阵初值全零、深度缓冲未启用、相机锥体裁剪把物体全剔除了。我的排查策略是逐步降级先用固定颜色清屏确认画布、上下文没问题然后画一个不考虑变换的三角形确认着色器能跑再放一个坐标在原点的小立方体确认矩阵和相机方向。每加一层要素问题就收敛一层。5.2 矩阵乘法方向搞反的经典症状矩阵乘法顺序错误的表现是物体位置看起来完全随机或者说移动一个物体其他物体也跟着变形。这个症状跟顶点数据损坏特别像但只要你检查一遍MVP的计算顺序基本就能确认。我的习惯是在模型矩阵、视图矩阵和投影矩阵边上各加一行注释标明“已经被谁乘过了”避免在多个函数之间传递导致状态混乱。5.3 物理穿透和弹簧发散的调参方向物理物体经常高速穿过碰撞体表面原因是移动步长太大物体跨过了薄碰撞体。解决手段有三板斧一是调小固定步长让每步位移更短二是碰撞体厚度加一点用连续碰撞检测三是限制最大速度。弹簧发散则几乎总是恢复系数和阻尼参数设置不当造成的把恢复系数调到0.1以下阻尼调到0.98左右发散问题基本能压住。5.4 常见问题速查表症状可能原因排查手段画面全黑MVP矩阵不对/着色器编译失败先用固定颜色清理再加简单三角形测试物体乱飞矩阵乘法顺序错误/法线未归一化检查MVP顺序打印单物体变换碰撞穿透步长过大/碰撞体过薄减小固定步长、增大碰撞厚度物体抖动插值alpha异常/物理位置跳变检查固定步长累积逻辑帧率上不去绘制调用过多/碰撞对太多看HUD报告做网格合并与空间哈希高分屏模糊忘了设置devicePixelRatio按物理像素设置canvas尺寸这些坑里几乎每一个都有对应的HUD指标可以直接定位把所有指标先列出来再动手是最快的路径。5.5 调试工具与打印日志的布局我给引擎加了三个调试窗口一个是场景面板显示所有物体的坐标和速度一个是物理面板显示碰撞对数量和穿透深度还有一个是渲染面板显示绘制调用数和每帧Uniform上传次数。这些数据用简单数组累计每帧刷新一次不会造成明显的性能损失。这算是我整个手搓过程里最值回票价的一笔开发投入。6. 手搓引擎之后的一些体会到现在这个引擎已经能稳定支撑三五百个动态物体互撞的演示包含方向光、纹理采样和第一人称相机交互渲染加物理的整体逻辑不到两千行编译后大概几十KB。回看整个过程最大的收获不是“自己造了一个轮子”而是彻底弄懂了一个游戏帧循环里每个环节的因果链条。如果你也想手搓一个我给的建议是按阶段来第一阶段只做渲染画一个带纹理、能转视角的立方体第二阶段再加物理让立方体从空中落到地面弹跳第三阶段才做场景组件化和性能优化。每一步稳住了再往前走比一口气想做个大而全的东西要快得多。真卡住时把问题拆成“渲染的锅还是物理的锅”往往解决速度会快一倍。