直接需要 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。
纹理加载这部分坑最多。浏览器加载图片是异步的,如果你在纹理还没加载完就用texImage2D,WebGL会报错或生成空白纹理。我的处理方式是在加载完成后才把这个材质对应的渲染对象加入可渲染队列,或者用一个默认白色纹理占位,加载完成后替换。
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 我个人的一点体会
这个项目做下来,我最深刻的体会是:写引擎的过程其实是在逼自己把每个概念都理解到能“教别人”的程度。看似简单的“物体掉落再弹起”,背后牵扯了向量运算、刚体动力学、离散碰撞检测、矩阵变换、着色器、渲染管线。每一步我都走错过,但每走错一次,对那个概念的印象就深一分。
如果你也想尝试,我给的建议是:第一次不要追求完美,先让一个球掉下来弹一弹、一个方块转一转。全链路跑通以后,再把设计优雅化。千万别一开始就想做“通用引擎”,你会在无限功能里迷失。
如果这套基础框架和踩坑经验能帮你少走一些弯路,目的就达到了。