1. 从“2. 3D图形”这个标题说起为什么它值得单独拎出来讲看到“2. 3D图形”这个标题很多人第一反应可能是这不就是计算机图形学里最基础的一章吗教科书上从坐标系讲到投影矩阵从顶点变换讲到光栅化翻来覆去就那么些东西。但如果你真的动手写过渲染器或者用Three.js、Unity、Blender做过实际项目就会发现一个尴尬的事实能画出三角形和能做出让人愿意多看两眼的3D画面中间隔着一整条鸿沟。这个标题里的“2.”其实透露了一个信息——它大概率是某个系列内容中的第二篇前面应该已经铺垫了基础概念比如什么是顶点、什么是网格、什么是坐标系。而“3D图形”这四个字涵盖的范围又极其宽泛从最底层的GPU管线到上层的场景组织、材质系统、光照模型再到性能优化和跨平台适配每一块都能单独写一本书。我写这篇东西的出发点很简单把“3D图形”这个看似人尽皆知的话题拆成真正能落地的东西。不管你是刚学完线性代数想找个方向练手的学生还是从后端转过来做可视化、数字孪生、小游戏开发的工程师或者是做工业仿真、建筑漫游、电商3D展示的技术负责人这篇文章都会给你一套完整的认知框架和实操路径。先明确一下范围。这里不打算从“什么是向量”开始讲那是另一篇文章的事。我们聚焦在当你已经知道3D图形的基本概念之后真正动手做一个3D场景时会遇到哪些绕不开的问题以及这些问题背后的原理和解决方案。关键词就三个渲染管线、场景组织、性能与效果平衡。这三个词贯穿了所有3D图形项目的生命周期。我见过太多项目前期Demo跑得飞快一到真实数据量就卡成幻灯片也见过不少团队为了追求“电影级画质”把帧率压到20以下用户点两下就关掉了。这些问题的根源往往不是某个API用错了而是对3D图形系统的整体运作方式缺乏系统性的理解。接下来的内容我会按照一个3D场景从无到有的实际构建顺序来展开每一块都配上我踩过的坑和验证过的方案。2. 渲染管线数据从内存到屏幕的完整旅程2.1 顶点处理阶段到底在算什么很多人对渲染管线的理解停留在“顶点着色器处理顶点片元着色器处理像素”这个层面。这话没错但太粗了。真正写代码的时候你需要知道的是一个顶点从进入GPU到变成屏幕上的颜色中间经历了哪些坐标空间的变换每个空间存在的意义是什么。拿一个最简单的场景举例你在Blender里建了一个立方体导出成glTF格式然后用Three.js加载进来。这个立方体的8个顶点在模型文件里存的是模型空间坐标通常以物体自身的中心为原点。当你把它放到场景里设置position为(3, 0, 0)这时候顶点需要乘以模型矩阵变换到世界空间。世界空间是整个场景的统一坐标系所有物体都在这个空间里定位。接下来是观察空间。摄像机有位置和朝向观察矩阵的作用就是把世界空间的坐标转换到以摄像机为原点的坐标系里。这一步很关键因为后续的投影和裁剪都依赖这个空间。然后是裁剪空间通过投影矩阵透视或正交把视锥体外的顶点裁掉同时为透视除法做准备。最后经过屏幕映射把归一化设备坐标NDC转换成屏幕像素坐标。这一套流程听起来线性但实际写Shader的时候很多人会在法线变换上翻车。法线不能直接用模型矩阵变换因为模型矩阵可能包含非均匀缩放。比如你把一个球体在Y轴方向压扁法线如果还用同一个矩阵变换方向就错了。正确做法是用模型矩阵的逆转置矩阵来变换法线。这个坑我在早期做角色换装系统时踩过当时角色一缩放光照就全乱了排查了大半天才发现是法线变换的问题。提示如果你用的是Three.js或Babylon.js这类引擎它们的内置材质会自动处理法线变换。但一旦你开始写自定义Shader这个细节就必须自己管。2.2 光栅化与片元着色像素是怎么被“填色”的顶点处理完之后GPU拿到的是屏幕空间中的三角形顶点。接下来的光栅化阶段就是把这三个顶点构成的三角形转换成一个个具体的像素位置。这个过程不是简单的“填充”而是会计算每个像素的重心坐标用来插值顶点属性——颜色、法线、UV坐标等等。这里有一个容易被忽略的点插值是在透视校正下进行的。什么意思假设一个三角形一边离摄像机近一边离得远那么靠近摄像机的那部分像素在插值时权重会更大。如果不做透视校正纹理贴图在斜面上就会出现扭曲。这个机制是硬件自动处理的但理解它有助于你明白为什么UV坐标在远处会“挤在一起”。片元着色器是真正决定每个像素颜色的地方。你可以在这里采样纹理、计算光照、做各种效果。但要注意片元着色器的执行次数等于屏幕上的像素数乘以重叠绘制的层数。一个覆盖全屏的物体在1080p下就是200多万个片元。如果场景里有多个半透明物体叠加这个数字会成倍增长。这就是为什么过度绘制Overdraw是移动端3D应用的头号性能杀手。我做过一个数据可视化项目最初为了效果好看给每个数据点都加了一个半透明的光晕面片。结果在手机上当数据点超过500个时帧率直接从60掉到15。后来把光晕改成不透明的公告板纹理配合Alpha Test而不是Alpha Blend帧率立刻回到50以上。这个经验告诉我在移动端能不透明就不透明能用Alpha Test就不用Alpha Blend。2.3 深度测试与混合谁在前谁在后深度测试解决的是“遮挡”问题。每个片元在写入颜色缓冲区之前会先比较它的深度值和深度缓冲区中已有的值。如果更近就通过测试并更新深度如果更远就被丢弃。这个机制让GPU可以以任意顺序绘制不透明物体最终结果都是正确的。但深度测试有个前提你不能在片元着色器里随意丢弃片元。如果你用了discard语句比如做Alpha Test那么在某些GPU架构上深度写入会被禁用导致后续的遮挡关系出错。这就是为什么很多引擎建议把Alpha Test的材质放在不透明物体之后、半透明物体之前绘制。混合则是另一回事。当你要做半透明效果时深度测试仍然进行但深度写入通常要关闭同时开启混合。混合的公式决定了源颜色和目标颜色如何组合。最常见的Alpha Blend是srcAlpha * srcColor (1 - srcAlpha) * dstColor。但混合的代价是必须按从远到近的顺序绘制否则半透明物体之间的遮挡关系就会错乱。这里有一个实操中的经典问题半透明物体互相穿插怎么办。比如两个半透明的玻璃球交叠在一起无论你怎么排序总有一个球的部分会被错误地遮挡。解决方案通常有三种一是用深度剥离Depth Peeling但性能开销大二是用顺序无关的透明OIT需要硬件支持三是干脆接受这个瑕疵或者用抖动透明Dithering来模拟。我在做建筑可视化时遇到玻璃幕墙互相交叠的情况最后选的是第三种方案——用屏幕空间抖动在视觉上几乎看不出问题性能却好了很多。3. 场景组织从一堆模型到一棵有逻辑的树3.1 场景图不是万能药但没有它万万不能场景图Scene Graph是3D应用中最基础的数据结构。它本质上是一棵树每个节点有自己的局部变换子节点的世界变换等于父节点世界变换乘以自身局部变换。这个设计的好处是当你移动一个父节点时所有子节点自动跟着移动。比如一辆车车轮是车的子节点车开动时车轮自然跟着走不需要单独更新每个轮子的位置。但场景图也有它的代价。每次渲染前你需要遍历整棵树计算每个节点的世界矩阵。如果树很深或者节点很多这个遍历本身就会成为瓶颈。我见过一个项目场景里有上万个独立的小零件每个零件都是一个单独的节点结果光是更新世界矩阵就占了每帧30%的时间。后来把这些零件合并成几个大的网格性能立刻翻倍。另一个常见误区是把所有东西都塞进场景图。比如天空盒、全屏后期效果、UI元素这些东西其实不需要参与场景图的变换计算。把它们单独拿出来管理反而更清晰。Three.js里就有Scene和Camera分离的设计后期效果通常挂在EffectComposer上而不是场景节点里。3.2 空间划分让GPU只画该画的东西场景图解决了“物体在哪”的问题但没解决“哪些物体需要画”的问题。一个复杂的场景可能有几十万个三角形但摄像机视野里可能只有几千个。**视锥体剔除Frustum Culling**就是用来做这个筛选的把每个物体的包围盒和摄像机的视锥体做相交测试不相交的直接跳过。但视锥体剔除的粒度是物体级别的。如果一个大物体横跨整个场景比如地面它的包围盒永远和视锥体相交那就永远剔不掉。这时候就需要遮挡剔除Occlusion Culling。它的思路是先画那些肯定可见的大物体然后用它们的深度信息去测试其他物体是否被挡住。这个技术在室内场景特别有效比如你在一个房间里墙外的所有东西都可以被剔除掉。不过遮挡剔除的实现复杂度很高而且需要额外的CPU和GPU开销。我的经验是如果场景是室外开阔环境视锥体剔除就够了如果是室内或城市密集场景才值得上遮挡剔除。而且现在很多引擎如Unity、Unreal都内置了烘焙好的遮挡数据直接用就行没必要自己从头写。还有一个更细粒度的优化是细节层次LOD。同一个物体距离远的时候用低模距离近的时候用高模。这个切换距离需要根据屏幕上的像素大小来定而不是简单的世界距离。因为一个巨大的物体即使离得远在屏幕上也可能占很大面积。我通常会把LOD切换阈值设在“物体在屏幕上投影高度小于屏幕高度5%”的时候。3.3 实例化与合批减少Draw Call的艺术Draw Call是CPU向GPU发送的绘制命令。每次Draw Call都有固定的CPU开销包括状态切换、参数设置等。如果场景里有1000个相同的树模型每个都单独Draw CallCPU就会成为瓶颈。**实例化Instancing**允许你用一次Draw Call画出所有树每棵树的位置、旋转、缩放通过顶点属性传入。实例化在植被、粒子、人群这类重复元素多的场景里效果极其明显。我做过一个森林场景用实例化之前1000棵树要1000次Draw Call帧率只有20改成实例化后1次Draw Call搞定帧率直接飙到120。但实例化也有局限所有实例必须共享同一个网格和材质。如果每棵树需要不同的颜色或形状就得用顶点属性来传递差异或者退回到合批。合批Batching是另一种思路把多个小网格合并成一个大网格一次性画出来。静态合批在加载时就把网格合并好适合不会移动的物体比如建筑、地形装饰。动态合批则在每帧运行时合并适合会移动但顶点数很少的物体。Unity里对动态合批的顶点数有限制通常300个顶点以内超过就不合了。这里有一个我踩过的坑合批会破坏视锥体剔除的粒度。如果你把整个城市的建筑合并成一个网格那么即使你只看一栋楼GPU也得把整个城市的三角形都处理一遍。所以合批和剔除需要权衡。我的做法是按空间区域分组合批比如每100米见方作为一个合批单元这样既能减少Draw Call又保留了剔除的灵活性。4. 光照与材质让3D物体看起来“像那么回事”4.1 从Lambert到PBR光照模型的演进逻辑最早的光照模型是Lambert漫反射只考虑光线方向和表面法线的夹角。它简单、快但看起来像塑料。后来加了Phong高光能模拟光滑表面的反光但高光形状是圆的不够真实。再后来是Blinn-Phong把高光计算简化了一步效果差不多但更快。现在的主流是基于物理的渲染PBR。PBR的核心思想是用物理参数来描述材质而不是用经验参数。比如金属度Metalness和粗糙度Roughness这两个参数直接对应现实世界中的材质属性。金属度高就是金属粗糙度低就是光滑。PBR的好处是在不同光照环境下材质的表现是一致的不会出现“在这个场景里好看换个场景就崩了”的情况。但PBR的计算量比Lambert大得多。一个完整的PBR着色器需要计算直接光照、间接光照、环境反射、菲涅尔效应等等。在移动端通常会用简化版的PBR比如只计算直接光照环境光用一张预计算的球谐函数SH来近似。我在做移动端电商3D展示时用的就是这种方案产品模型用PBR材质但环境光只用一个简单的SH效果足够好性能也扛得住。注意PBR材质的效果高度依赖环境贴图。如果你只给了一个纯色环境光PBR材质看起来会和Lambert差不多。所以做PBR的时候一定要配一张HDR环境贴图哪怕分辨率低一点。4.2 阴影最影响真实感也最吃性能的部分阴影是3D图形里“真实感”的最大来源之一。没有阴影物体就像飘在空中。但阴影也是性能开销最大的部分之一。主流的阴影技术是阴影贴图Shadow Map从光源的角度渲染一遍场景把深度信息存到一张纹理里然后在主渲染时把每个像素的世界坐标转换到光源空间和阴影贴图比较深度判断是否在阴影中。阴影贴图的质量取决于分辨率。分辨率越高阴影边缘越锐利但显存和带宽开销也越大。一个2048x2048的阴影贴图在移动端可能就占用了好几MB的显存。而且如果场景很大一张阴影贴图覆盖整个场景每个物体分到的像素就很少阴影会变得很糊。解决方案是级联阴影贴图CSM把视锥体分成几个层级近处用高分辨率阴影贴图远处用低分辨率。这样既保证了近处阴影的清晰度又控制了总体开销。CSM在Unity和Unreal里都是默认开启的但参数需要根据场景调整。我通常会把级联数量设为4近处分辨率2048远处1024过渡区域用淡入淡出避免接缝。另一个常见问题是阴影偏移Shadow Bias。由于阴影贴图的分辨率有限直接比较深度会出现“阴影痤疮”Shadow Acne——物体表面出现条纹状的自我阴影。解决办法是给深度比较加一个偏移量。但偏移量太大会导致“彼得潘效应”Peter Panning——阴影和物体分离看起来像飘在空中。这个偏移量需要根据场景尺度和光照角度来调没有万能值。我的经验是从0.005开始试如果还有痤疮就加大如果阴影飘了就减小。4.3 材质系统设计如何管理成百上千种材质一个稍微复杂点的3D项目材质数量很容易上百。如果每个材质都单独一个Shader编译和切换的开销会很大。所以需要一套材质系统来管理。常见的做法是基于Shader变体Shader Variant。一个基础Shader通过宏定义来开启或关闭不同的功能比如是否用纹理、是否用光照贴图、是否用雾效。这样GPU只需要编译有限几个变体而不是每个材质一个独立Shader。Unity的Standard Shader就是这种思路它有一个庞大的变体集合但实际运行时只会编译用到的那些。但变体太多也会导致编译时间过长和包体膨胀。我见过一个项目Standard Shader的变体有上万种打包时光编译Shader就花了半小时。后来通过剥离未使用的变体把数量降到了几百种打包时间缩短到几分钟。具体做法是在项目设置里只保留实际用到的关键字组合其他的全部剔除。另一个思路是材质实例化。同一个Shader不同的参数值颜色、纹理、数值可以共享同一个编译好的Shader程序只是Uniform不同。这样切换材质时不需要重新编译Shader只需要更新Uniform。Three.js里的ShaderMaterial和RawShaderMaterial就支持这种模式。我在做参数化产品配置器时就是用一套Shader加不同的Uniform让用户实时调整颜色、材质、纹理完全没有卡顿。5. 性能优化从能跑到跑得爽的实战路径5.1 先定位瓶颈CPU还是GPU性能优化最忌讳的就是“凭感觉猜”。你觉得是GPU太慢结果加了半天优化发现是CPU在提交Draw Call时卡住了。所以第一步永远是定位瓶颈。在浏览器里可以用Chrome的Performance面板看每一帧的时间分布。如果Scripting和Rendering占比高说明CPU是瓶颈如果GPU占比高或者帧率低但CPU很闲那就是GPU瓶颈。在移动端可以用Xcode的GPU Capture或Android的GPU Inspector看每个Draw Call的耗时。一个简单的判断方法把渲染分辨率降到原来的一半。如果帧率翻倍说明是GPU的像素填充率瓶颈如果帧率没变说明是CPU的Draw Call或逻辑瓶颈。这个方法我用了很多年几乎百试百灵。5.2 GPU瓶颈的常见解法如果是GPU瓶颈首先要看是顶点瓶颈还是像素瓶颈。把场景里的模型换成最简单的立方体如果帧率大幅提升说明是顶点处理或几何复杂度的问题如果帧率没变说明是像素着色或过度绘制的问题。顶点瓶颈的解法包括减面、用LOD、用实例化、减少骨骼数量。像素瓶颈的解法包括降低分辨率、减少半透明物体、简化片元着色器、用更高效的纹理压缩格式。这里重点说一下纹理压缩。一张2048x2048的RGBA纹理未压缩时占16MB显存。如果换成ASTC或ETC2压缩格式可以降到2MB左右而且GPU采样时带宽占用也大幅降低。但压缩纹理是有损的对于法线贴图这种对精度要求高的纹理需要选择高质量的压缩格式或者干脆不压缩。我的经验是颜色贴图用ASTC 6x6或ETC2法线贴图用ASTC 4x4或未压缩。5.3 CPU瓶颈的常见解法CPU瓶颈通常来自三个方面Draw Call太多、物理计算太重、逻辑更新太频繁。Draw Call的优化前面已经说了实例化和合批是主要手段。但还有一个容易被忽略的点状态切换。每次切换Shader、纹理、渲染目标都有CPU开销。所以要把使用相同状态的物体放在一起画。比如所有不透明物体先画所有半透明物体后画所有用同一张纹理的物体连续画。物理计算方面如果用了物理引擎要确保碰撞体尽量简单。一个复杂的网格碰撞体物理引擎需要处理成千上万个三角形而一个盒体碰撞体只需要处理12个。我在做VR交互时所有可抓取的物体都用盒体或球体碰撞体近似只有需要精确碰撞的地方才用网格碰撞体。逻辑更新方面不要每帧更新所有东西。比如远处的NPC可以每几帧更新一次AI不活跃的粒子系统可以暂停更新。Unity里的Update和FixedUpdate要合理使用物理相关的放FixedUpdate视觉相关的放Update。5.4 内存与加载优化3D应用的内存占用往往被低估。一个未压缩的2048x2048纹理就是16MB十个就是160MB。再加上网格数据、动画数据、音频很容易就超过移动端的内存限制。纹理方面除了压缩还要注意Mipmap。Mipmap会让纹理内存增加33%但能大幅提升渲染性能和画质。对于远处物体没有Mipmap会导致严重的闪烁。所以除了UI纹理其他纹理都应该生成Mipmap。网格方面顶点属性要精简。如果不需要切线空间就不要存切线和副切线。如果不需要顶点色就不要存颜色。每个顶点属性都是内存和带宽的开销。我见过一个项目所有模型都带着顶点色但实际上根本没用白白浪费了25%的顶点内存。加载方面异步加载和分帧加载是关键。不要在某一帧加载所有资源而是分散到多帧或者用Web Worker在后台加载。Three.js的LoadingManager和GLTFLoader都支持异步加载但要注意加载完成后的回调时机避免在渲染中途插入大量计算。6. 跨平台适配一套代码跑在手机、PC和网页上6.1 精度问题highp、mediump和lowp的选择在移动端GPU上浮点精度是有限制的。highp在顶点着色器里通常没问题但在片元着色器里有些老设备不支持。mediump精度较低但对于颜色计算、UV插值通常够用。lowp精度最低适合做简单的开关判断。我的经验是顶点着色器用highp片元着色器默认用mediump只有确实需要高精度的地方比如世界坐标计算才用highp。在Three.js里可以通过precision参数来设置默认精度。如果设置成mediump所有Shader默认都是mediump需要highp的地方再单独声明。精度问题最典型的症状是画面出现色带或抖动。比如一个渐变背景在mediump下可能出现明显的条纹。这时候要么提高精度要么用抖动Dithering来掩盖。我在做移动端H5时遇到过天空盒渐变出现色带的问题后来在片元着色器里加了一点随机噪声色带就消失了。6.2 渲染API差异WebGL、WebGL2和WebGPUWebGL 1.0基于OpenGL ES 2.0功能有限不支持实例化、多重采样、浮点纹理等特性。WebGL 2.0基于OpenGL ES 3.0支持这些特性但兼容性稍差。WebGPU是最新的API性能更好但支持度还在普及中。如果要做跨平台优先用WebGL 2.0降级到WebGL 1.0。Three.js会自动检测并选择合适的渲染器。但要注意WebGL 1.0下有些特性不可用比如InstancedMesh在WebGL 1.0下需要扩展支持。如果目标设备包括老手机就要做好降级方案。WebGPU目前主要在Chrome和Edge上可用Safari和Firefox还在推进中。如果项目面向的是最新浏览器可以考虑用WebGPU但要做好回退到WebGL的准备。Babylon.js和Three.js都在逐步支持WebGPU但生态还在完善中。6.3 输入方式适配鼠标、触摸和手柄PC上用鼠标手机上用触摸VR用手柄这是三种完全不同的交互方式。鼠标有悬停Hover状态触摸没有触摸有多点触控鼠标没有手柄有扳机和摇杆鼠标触摸都没有。做跨平台3D应用时交互逻辑要抽象成统一的接口。比如“选择物体”这个操作鼠标是点击触摸是轻触手柄是扳机键。底层用同一套射线检测Raycasting逻辑上层根据输入设备分发不同的事件。触摸操作还有一个特殊问题手指遮挡。在手机上用户的手指会挡住屏幕的一部分如果交互元素在手指下方用户就看不见了。解决方案是把交互元素放在手指上方或者用偏移量把射线检测的位置往上移。我在做移动端3D配置器时把拖拽旋转的灵敏度调低了一些同时把确认按钮放在屏幕底部避免被手指挡住。7. 我踩过的那些坑几个真实案例的复盘7.1 案例一法线贴图在移动端失效有一次做移动端产品展示PC上法线贴图效果很好到了手机上却完全没效果模型看起来像塑料。排查后发现移动端GPU对法线贴图的压缩格式支持不同。PC上用的BC5压缩手机上不支持自动降级成了RGB格式但法线贴图的RGB通道被压缩后精度损失严重导致法线方向错误。解决方案是移动端用法线贴图时不要压缩或者用ASTC格式并选择高质量模式。如果显存实在紧张可以把法线贴图的分辨率降一半但保持未压缩。后来我把法线贴图从2048降到1024未压缩效果和PC上基本一致显存占用也在可接受范围内。7.2 案例二阴影在远处出现锯齿和闪烁一个室外场景阴影在近处还好远处就出现严重的锯齿和闪烁。原因是阴影贴图的分辨率不够远处一个像素覆盖了很大的世界空间。解决方案是级联阴影贴图CSM把视锥体分成近、中、远三层每层用不同的阴影贴图。近处用2048中处用1024远处用512。这样近处阴影清晰远处虽然分辨率低但因为距离远视觉上不明显。但CSM也有坑层级之间的过渡区域会出现接缝。如果直接切换阴影会突然变化。解决办法是在过渡区域做淡入淡出让两个层级的阴影按距离混合。Unity的CSM默认就带这个功能但参数需要调。我通常会把过渡区域设为层级范围的10%左右太窄了接缝明显太宽了性能开销大。7.3 案例三大量半透明粒子导致帧率暴跌一个烟花效果用了上千个半透明粒子。PC上还好手机上直接卡成PPT。原因是过度绘制每个粒子都覆盖屏幕的一部分半透明混合需要读取目标颜色带宽开销巨大。而且粒子没有排序混合顺序错误看起来也乱。解决方案分三步第一把粒子纹理改成Alpha Test而不是Alpha Blend这样不需要混合也不需要排序。第二用GPU粒子代替CPU粒子把粒子模拟放到顶点着色器里减少CPU开销。第三限制粒子的最大数量根据设备性能动态调整。改完之后手机上也能跑到40帧以上效果虽然比PC上稍差但完全可接受。7.4 案例四场景加载时的卡顿和内存峰值一个大型场景加载时卡顿严重内存峰值也很高。排查发现所有纹理和网格都在同一帧加载导致CPU和GPU都忙不过来。解决方案是分帧加载把资源分成多个批次每帧加载一批加载完一批再加载下一批。同时用占位符先显示等真实资源加载完再替换。另一个问题是纹理没有及时释放。切换场景时旧场景的纹理没有销毁内存一直涨。后来加了引用计数每个纹理被引用时计数加一不再引用时减一减到零就销毁。这个机制在Three.js里需要手动管理texture.dispose()要记得调用。8. 工具链与调试怎么快速定位3D图形问题8.1 浏览器端的调试利器Chrome的DevTools里有WebGL Inspector可以查看每一帧的Draw Call、纹理、Shader。但更强大的是Spector.js它是一个浏览器扩展能捕获一整帧的所有WebGL调用包括每个Draw Call的顶点数据、纹理绑定、Uniform值。我每次遇到渲染结果不对第一件事就是用Spector.js抓一帧看看实际传给GPU的数据是什么。另一个常用工具是RenderDoc它支持WebGL和WebGPU能捕获GPU管线的完整状态。但RenderDoc对WebGL的支持有限更适合桌面端的OpenGL和Vulkan。如果是Unity或Unreal项目直接用引擎自带的Profiler和Frame Debugger就够了。8.2 移动端的调试方法移动端调试3D图形比较麻烦因为不能直接连DevTools。常用的方法有Safari的Web Inspector需要Mac和iOS设备Chrome的Remote Debugging需要Android设备和USB连接。连接后可以在桌面浏览器里查看手机上的页面用同样的DevTools调试。如果问题只在特定设备上出现可以用远程日志在代码里把关键信息帧率、Draw Call数、内存占用输出到页面上或者发送到服务器。我在做移动端项目时会在角落放一个小的性能面板显示FPS、Draw Call、三角形数量方便快速判断性能状况。8.3 性能监控的常态化不要等到出问题了才去优化。在开发阶段就加入性能监控每帧记录FPS、Draw Call、三角形数、内存占用。设置阈值超过就报警。这样可以在问题恶化之前就发现并解决。我通常会在项目里加一个Stats面板Three.js有现成的Stats.js显示实时帧率。同时用performance.now()记录关键函数的耗时比如场景更新、物理计算、渲染提交。这些数据在优化时非常有用能直接告诉你时间花在哪里了。9. 写给不同阶段开发者的建议9.1 刚入门的新手先跑通再优化如果你刚开始学3D图形不要一上来就追求PBR、阴影、后期效果。先用最简单的Lambert材质画几个立方体和球体理解坐标系、相机、光照的基本概念。然后逐步加纹理、加法线贴图、加阴影。每加一个特性都观察帧率的变化理解这个特性的性能代价。我见过很多新手跟着教程做了一个很炫的效果但完全不知道背后的原理换个场景就不会用了。理解比复制更重要。每个效果都要问自己这个效果是怎么实现的用了哪些数据计算量在哪里能不能简化9.2 有经验的开发者关注架构和工具链如果你已经能熟练使用Three.js或Unity下一步应该关注架构设计和工具链建设。比如如何设计一套可扩展的材质系统如何管理场景的加载和卸载如何做性能监控和自动化测试工具链方面自动化构建和部署能节省大量时间。比如用Webpack或Vite打包用CI/CD自动部署到测试环境。资源管线也很重要模型从DCC工具导出时自动做减面、压缩纹理、生成LOD。这些自动化流程能避免很多手动操作的错误。9.3 技术负责人平衡效果、性能和开发成本如果你负责一个3D项目的技术决策最重要的能力是权衡。老板想要电影级画质但用户手机只有千元机设计师想要实时全局光照但开发周期只有两个月。这时候你需要给出分级的方案高端设备开全效果中端设备降分辨率关阴影低端设备用最简化的渲染。我的经验是先保证核心体验流畅再逐步提升画质。一个流畅的简单场景比一个卡顿的复杂场景用户留存率高得多。而且性能优化不是一次性的工作应该贯穿整个开发周期。每加一个特性都要评估它的性能影响及时调整。10. 最后分享几个我常用的性能检查清单每次项目上线前我都会过一遍这个清单。虽然简单但能避免很多低级问题Draw Call数量移动端控制在100以内PC端控制在1000以内。超过就要考虑合批或实例化。三角形数量移动端单帧不超过10万PC端不超过100万。超过就要减面或LOD。纹理内存移动端不超过100MBPC端不超过500MB。超过就要压缩或降分辨率。半透明物体数量尽量控制在10个以内。超过就要考虑用Alpha Test或抖动透明。Shader变体数量打包前检查剥离未使用的变体。超过1000个就要警惕。帧率稳定性不要只看平均帧率要看最低帧率。最低帧率低于30就会明显卡顿。还有一个我个人的习惯在低端设备上测试。不要只在开发机上跑找一台几年前的手机或者用Chrome的CPU Throttling模拟低性能设备。很多问题只有在低端设备上才会暴露出来。3D图形这个领域说深很深说浅也很浅。核心概念就那么几个但每个概念背后的细节和权衡需要大量的实践才能体会。我写这篇东西不是想覆盖所有内容而是想把那些教科书上不会写、但实际项目中一定会遇到的东西分享出来。如果你在做3D项目的过程中遇到了什么问题或者有更好的解决方案欢迎一起交流。这个领域变化很快新技术层出不穷保持学习和动手实践比什么都重要。