1. 从零理解 Meshlet Culling为什么我们需要“简易版 Nanite”1.1 传统渲染管线的瓶颈到底卡在哪里做过大场景渲染的朋友应该都有体会当场景里塞进几百万甚至上千万个三角面时传统管线就开始喘了。不管是 Draw Call 数量爆炸还是顶点着色器阶段对每个顶点做完整变换GPU 的算力被大量浪费在那些最终只占屏幕几个像素、甚至根本不可见的三角形上。问题的本质在于传统管线是“先提交、后裁剪”也就是说顶点数据必须先进入管线光栅化阶段才能判断它是否可见。这个顺序决定了大量无效计算不可避免。我举个直观的例子。假设场景里有一棵高精度树木模型总共 50 万个三角面。当摄像机拉远这棵树在屏幕上只占 100×100 像素区域时理论上只需要几百个三角面就能表达它的轮廓。但传统管线仍然会把 50 万个顶点全部送进顶点着色器做矩阵变换然后光栅化阶段再丢弃掉绝大多数。这种浪费在移动端或者集成显卡上尤其致命帧率直接腰斩。Nanite 的核心思路就是解决这个问题在 GPU 端做几何体的层级化裁剪和动态 LOD 选择只把真正需要的三角形送进光栅化。它依赖的是 Meshlet网格簇这个数据结构把大网格切分成一个个小簇每个簇有独立的包围盒和层级信息然后通过 Compute Shader 做视锥裁剪、遮挡裁剪和 LOD 选择。最终只提交可见簇的索引大幅减少无效计算。1.2 Meshlet 到底是什么为什么它比传统 LOD 更优雅传统 LOD 是离线生成好几套不同精度的模型运行时根据距离切换。这套方案的问题很明显切换时会有跳变Popping不同 LOD 之间的过渡不自然而且每套 LOD 都是独立存储的内存占用成倍增长。Meshlet 则完全不同它把原始网格切成固定大小的小簇通常每个簇 64 到 128 个三角形每个簇内部保持顶点索引的局部性然后对这些簇建立层级结构类似 BVH 或者 Cluster Hierarchy。运行时根据视锥和遮挡关系自顶向下遍历这棵树只展开那些可见的节点。这样做的好处有几个。第一裁剪粒度更细不是整个模型一刀切而是精确到每个小簇可见性判断更准确。第二没有 LOD 跳变因为簇的展开是连续的远处自然只展开高层节点近处展开到叶子节点过渡平滑。第三内存效率高只需要存储一套原始网格加上层级索引不需要多套 LOD 模型。第四非常适合 GPU 并行每个簇的裁剪和 LOD 选择可以独立计算天然适合 Compute Shader 的大规模并行架构。1.3 WebGPU 为什么是落地 Meshlet Culling 的理想平台WebGPU 是新一代的 Web 图形 API相比 WebGL 最大的变化是原生支持 Compute Shader和存储缓冲区Storage Buffer。这两样东西对于 Meshlet Culling 来说缺一不可。WebGL 时代我们只能靠顶点着色器和变换反馈勉强模拟一些 GPU 端计算但灵活性和性能都差得远。WebGPU 的 Compute Shader 让我们可以在 GPU 上做任意粒度的并行计算存储缓冲区则允许我们在 GPU 端读写大量数据不需要频繁回传到 CPU。另一个关键点是 WebGPU 的间接绘制Indirect Draw支持。Meshlet Culling 的最终输出是一个可见簇的列表我们需要根据这个列表来发起绘制命令。如果每次都要把结果读回 CPU 再发起绘制那 GPU 端计算的意义就大打折扣了。WebGPU 的 Indirect Draw 允许我们把绘制参数放在 GPU 缓冲区里由 GPU 自己决定画多少个实例、多少个索引完全不需要 CPU 介入。这就形成了一个完整的 GPU 驱动管线裁剪在 GPU、LOD 选择在 GPU、绘制参数生成在 GPU、最终绘制也在 GPU。还有一点容易被忽略WebGPU 的计算着色器和渲染通道可以在同一帧内交替执行配合存储缓冲区的读写可以实现非常灵活的管线编排。这对于 Meshlet Culling 这种需要“先计算、后绘制”的模式来说是天然契合的。2. 整体方案设计与核心思路拆解2.1 简易版 Nanite 的功能边界定义在动手之前必须先明确“简易版”到底简易在哪里。完整的 Nanite 包含很多高级特性虚拟几何体流式加载、软件光栅化、材质分层、遮挡剔除的硬件加速等等。这些全部实现一遍不现实也没必要。我们的目标是抓住核心链路Meshlet 切分、层级构建、GPU 端视锥裁剪、LOD 选择、间接绘制。把这条链路跑通就能理解 Nanite 的精髓。具体来说我给自己定的功能边界是这样的支持静态网格的离线 Meshlet 切分每个 Meshlet 固定 64 个三角形构建两层结构Cluster 和 Cluster GroupCluster 是基本裁剪单元Cluster Group 是 LOD 切换单元运行时在 Compute Shader 里做视锥裁剪和基于投影误差的 LOD 选择最终通过 Indirect Draw 提交可见 Cluster 的索引。不支持遮挡剔除那需要深度金字塔复杂度太高不支持流式加载不支持软件光栅化。这些取舍在后面会详细解释原因。2.2 为什么选择 Cluster 和 Cluster Group 两层结构Meshlet 的层级结构设计直接决定了裁剪效率和 LOD 质量。我试过几种方案最终选择了 Cluster Cluster Group 的两层结构。Cluster 是最小的裁剪单元包含 64 个三角形和对应的顶点索引有自己的包围球。Cluster Group 是若干个 Cluster 的集合通常 4 到 8 个 Cluster 组成一个 GroupGroup 有自己的包围球和一组 LOD 参数。为什么需要两层因为如果只有 Cluster 一层LOD 选择会非常碎。每个 Cluster 独立选择 LOD 等级相邻 Cluster 之间可能出现精度不一致导致裂缝Crack。而 Cluster Group 作为 LOD 切换单元保证同一个 Group 内的所有 Cluster 使用相同的 LOD 等级视觉上更连贯。同时Group 层级的包围球更大可以更快地做粗粒度裁剪减少需要展开的 Cluster 数量。另一个考虑是计算负载的平衡。如果层级太深遍历开销大如果层级太浅裁剪精度不够。两层结构在实践中被证明是一个比较好的平衡点。Cluster 数量通常在几千到几万个量级Group 数量在几百到几千量级GPU 并行处理起来压力不大。2.3 视锥裁剪和 LOD 选择的协同策略视锥裁剪和 LOD 选择虽然是两个独立的过程但在实现上可以协同进行。我的做法是在 Compute Shader 里每个线程处理一个 Cluster Group先做 Group 级别的视锥裁剪如果 Group 的包围球完全在视锥外直接跳过该 Group 下所有 Cluster 都不需要处理。如果 Group 与视锥相交则进一步遍历 Group 内的每个 Cluster做 Cluster 级别的视锥裁剪。LOD 选择则基于投影误差来计算。具体来说对于每个 Cluster Group计算它的包围球在屏幕空间上的投影大小然后根据预设的误差阈值决定使用哪个 LOD 等级。误差阈值可以理解为一个“允许的最大几何误差”当投影误差小于这个阈值时就可以使用更低精度的 LOD。这个阈值可以根据屏幕分辨率和视场角动态调整保证在不同设备上有一致的视觉质量。这里有个细节需要注意LOD 选择的结果会影响后续的绘制批次组织。不同 LOD 等级的 Cluster 可能需要不同的索引缓冲区所以最终生成的 Indirect Draw 参数需要按 LOD 分组。我在实现时用了多个 Indirect Draw 命令每个 LOD 等级一个这样绘制时不需要额外排序。2.4 数据布局与内存对齐的考量WebGPU 对存储缓冲区的内存对齐有严格要求这一点在数据布局设计时必须提前考虑。比如arrayvec3f32在 WebGPU 里的实际步长是 16 字节而不是 12 字节因为 vec3 需要按 16 字节对齐。如果忽略这一点数据读取会错位调试起来非常痛苦。我的做法是所有结构体显式补齐到 16 字节的倍数。比如 Cluster 的包围球用vec4f32存储xyz 是球心w 是半径而不是vec3 f32分开存。顶点位置也用vec4存储虽然浪费了一个分量但避免了复杂的对齐计算。索引数据用u32数组每个 Cluster 的索引范围用vec2u32表示起始偏移和数量。这些设计看起来浪费了一些内存但换来了代码的简洁和运行时的稳定。3. 核心细节解析与实操要点3.1 Meshlet 切分算法的选择与实现Meshlet 切分是整个管线的基础切分质量直接影响后续裁剪和渲染的效果。常见的切分算法有几种基于贪心增长的、基于图划分的、基于空间聚类的。我最终选择了一种基于贪心增长的简化算法核心思路是从种子三角形开始不断把相邻的、未分配的三角形加入当前 Cluster直到达到 64 个三角形或者没有更多相邻三角形为止。这个算法的关键在于种子三角形的选择顺序。如果随机选种子切分出来的 Cluster 在空间上会很分散包围球很大裁剪效率低。我的做法是先按三角形在网格上的空间位置排序比如按 Morton 码排序然后按顺序取种子。这样相邻的种子在空间上也相邻切分出来的 Cluster 更紧凑。实测下来包围球体积比随机种子方案小了大约 30%裁剪效率提升明显。另一个细节是顶点索引的重映射。每个 Cluster 内部的顶点索引需要重新映射到 0 到 N-1 的范围这样才能用 8 位或 16 位索引来节省带宽。我用了 16 位索引因为 64 个三角形最多涉及 192 个顶点16 位足够。重映射的过程需要维护一个全局顶点到局部顶点的映射表这个表在切分时动态构建。// 简化的 Meshlet 切分伪代码 function buildMeshlets(indices, positions, maxTriangles 64) { const meshlets []; const visited new Uint8Array(indices.length / 3); // 按 Morton 码排序三角形 const sortedTris sortTrianglesByMorton(indices, positions); for (const tri of sortedTris) { if (visited[tri.id]) continue; const meshlet { triangles: [], vertices: new Map() }; const queue [tri]; while (queue.length 0 meshlet.triangles.length maxTriangles) { const current queue.shift(); if (visited[current.id]) continue; visited[current.id] 1; meshlet.triangles.push(current); // 把相邻三角形加入队列 for (const neighbor of current.neighbors) { if (!visited[neighbor.id]) queue.push(neighbor); } } meshlets.push(meshlet); } return meshlets; }3.2 包围球计算与层级构建的注意事项包围球的计算看起来简单但实际做起来有不少坑。最直接的方法是取所有顶点的最小包围盒然后求包围盒的中心和半径。但这样算出来的包围球往往偏大因为包围盒的角点可能离中心很远。更好的做法是用Ritter 算法或者Welzl 算法来求最小包围球。Ritter 算法简单快速虽然不保证最优但结果通常比包围盒方案好很多。层级构建时Cluster Group 的包围球需要包含其下所有 Cluster 的包围球。这里有个技巧不要简单地把所有 Cluster 的包围球合并成一个大的包围球而是先计算所有 Cluster 包围球球心的最小包围球然后半径取“球心到最远 Cluster 球面”的距离。这样算出来的 Group 包围球更紧凑。注意包围球的计算精度会直接影响裁剪的准确性。如果包围球偏小可能导致可见的 Cluster 被错误裁剪掉出现模型缺块。如果偏大裁剪效率降低。建议在计算时留一点余量比如半径乘以 1.05。3.3 Compute Shader 中的裁剪逻辑实现Compute Shader 是 Meshlet Culling 的核心。我的实现里每个线程处理一个 Cluster Group工作流程是这样的首先读取 Group 的包围球数据做视锥裁剪测试如果通过再遍历 Group 内的 Cluster逐个做视锥裁剪和 LOD 选择最后把可见 Cluster 的索引写入输出缓冲区并原子性地增加 Indirect Draw 的实例计数。视锥裁剪的测试方法我用的是球与六个平面的距离测试。对于每个视锥平面计算球心到平面的有符号距离如果距离小于负半径说明球完全在平面外侧直接剔除。如果所有平面都通过说明球在视锥内或与视锥相交。这个方法比逐个顶点测试快得多而且对于包围球来说精度足够。// 视锥裁剪的 WGSL 实现片段 fn isSphereInFrustum(center: vec3f32, radius: f32, frustumPlanes: arrayvec4f32, 6) - bool { for (var i 0u; i 6u; i i 1u) { let plane frustumPlanes[i]; let dist dot(plane.xyz, center) plane.w; if (dist -radius) { return false; } } return true; }LOD 选择的计算稍微复杂一些。我用的公式是projectedError (clusterRadius / distanceToCamera) * screenHeight / (2 * tan(fov/2))。这个公式计算的是 Cluster 包围球在屏幕上的投影半径像素单位。然后根据预设的误差阈值表选择第一个满足projectedError threshold的 LOD 等级。误差阈值表是一个经验值需要根据实际场景调整。3.4 Indirect Draw 参数的组织与更新Indirect Draw 是 WebGPU 里比较容易被忽略但非常关键的部分。它的核心思想是把绘制参数索引数量、实例数量、起始索引位置等放在一个 GPU 缓冲区里绘制命令直接读取这个缓冲区不需要 CPU 知道具体数值。对于 Meshlet Culling 来说这意味着我们可以在 GPU 端动态决定画多少个 Cluster完全不需要回读数据。我的实现里每个 LOD 等级对应一个 Indirect Draw 命令。命令的结构是{ indexCount, instanceCount, firstIndex, baseVertex, firstInstance }。其中indexCount是固定的每个 Cluster 64 个三角形192 个索引instanceCount由 Compute Shader 原子递增firstIndex指向该 LOD 等级的索引缓冲区起始位置。这样绘制时只需要按 LOD 等级依次发起 Indirect Draw 即可。提示WebGPU 的 Indirect Draw 缓冲区需要设置INDIRECT用途标志而且缓冲区大小必须是 4 字节对齐。另外Indirect Draw 的计数更新必须在 Compute Pass 结束后、Render Pass 开始前完成中间不能有冲突的读写。4. 完整实操流程与关键环节实现4.1 离线预处理从 OBJ 到 Meshlet 数据包整个流程的第一步是离线预处理。我写了一个 Node.js 脚本读取 OBJ 文件执行 Meshlet 切分计算包围球构建层级最后输出一个二进制数据包。这个数据包包含了渲染所需的所有信息顶点位置、顶点法线、Cluster 索引、Cluster 包围球、Group 包围球、Group 到 Cluster 的映射关系。数据包的格式我设计得尽量紧凑。顶点位置用 Float32 存储每个顶点 12 字节xyz。法线用 Int8 归一化存储每个顶点 4 字节xyzn。Cluster 索引用 Uint16每个索引 2 字节。包围球用 Float32每个球 16 字节xyzr。整个数据包按 16 字节对齐方便直接上传到 GPU 缓冲区。预处理脚本的核心逻辑是前面提到的贪心切分算法加上包围球计算和层级构建。这里有个优化点切分时可以并行化。因为不同区域的三角形切分互不影响可以用 Worker 线程并行处理。我在处理一个 200 万面的模型时单线程需要大约 8 秒用 4 个 Worker 并行后降到 2 秒左右。4.2 WebGPU 初始化与资源创建WebGPU 的初始化流程比 WebGL 繁琐一些但结构更清晰。首先请求适配器和设备然后配置画布上下文接着创建各种 GPU 资源。对于 Meshlet Culling 来说需要创建的资源包括顶点缓冲区、索引缓冲区、Cluster 数据缓冲区、Group 数据缓冲区、Indirect Draw 缓冲区、可见 Cluster 输出缓冲区。这里有个容易踩坑的地方存储缓冲区的用途标志。Cluster 数据缓冲区和 Group 数据缓冲区需要在 Compute Shader 里读取所以必须设置STORAGE用途。Indirect Draw 缓冲区需要设置INDIRECT和STORAGE用途因为 Compute Shader 要写入计数绘制命令要读取参数。可见 Cluster 输出缓冲区需要设置STORAGE用途因为 Compute Shader 要写入顶点着色器要读取。// WebGPU 资源创建示例 const clusterBuffer device.createBuffer({ size: clusterData.byteLength, usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST, }); const indirectBuffer device.createBuffer({ size: indirectData.byteLength, usage: GPUBufferUsage.INDIRECT | GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST, }); const visibleClusterBuffer device.createBuffer({ size: maxVisibleClusters * 4, usage: GPUBufferUsage.STORAGE | GPUBufferUsage.VERTEX, });4.3 Compute Pass 的编排与执行Compute Pass 是整个管线的核心环节。我把它分成两个阶段第一个阶段做裁剪和 LOD 选择第二个阶段做绘制参数整理。第一个阶段的 Compute Shader 每个线程处理一个 Cluster Group输出可见 Cluster 的列表和每个 LOD 等级的实例计数。第二个阶段的 Compute Shader 根据实例计数生成最终的 Indirect Draw 参数。两个阶段之间需要同步。WebGPU 里可以用多个 Compute Pass 来实现每个 Pass 之间自动有内存屏障。但更高效的做法是用一个 Compute Pass 加workgroupBarrier不过 WebGPU 目前对跨 Workgroup 的同步支持有限所以我还是用了两个 Pass。实测下来性能差异不大因为第二个 Pass 的计算量很小。// Compute Pass 编排 const computePass encoder.beginComputePass(); computePass.setPipeline(cullingPipeline); computePass.setBindGroup(0, cullingBindGroup); computePass.dispatchWorkgroups(Math.ceil(groupCount / 64)); computePass.end(); const computePass2 encoder.beginComputePass(); computePass2.setPipeline(indirectPipeline); computePass2.setBindGroup(0, indirectBindGroup); computePass2.dispatchWorkgroups(1); computePass2.end();4.4 Render Pass 与 Indirect Draw 的对接Render Pass 阶段相对简单因为大部分工作已经在 Compute Pass 里完成了。我只需要设置渲染管线绑定顶点缓冲区和可见 Cluster 缓冲区然后按 LOD 等级依次发起 Indirect Draw。每个 LOD 等级对应一个 Indirect Draw 命令命令的偏移量根据 LOD 等级计算。顶点着色器里需要根据 Cluster 索引和顶点索引来获取实际的顶点位置。具体来说instanceIndex对应可见 Cluster 列表里的索引通过这个索引找到 Cluster 的顶点偏移再加上vertexIndex得到全局顶点索引最后从顶点缓冲区读取位置。这个过程需要两次间接寻址但 GPU 处理起来很快。注意Indirect Draw 的实例顺序是不确定的因为 Compute Shader 的原子递增顺序不确定。如果渲染结果对顺序敏感比如透明物体需要额外处理。对于不透明物体顺序不影响最终画面。5. 常见问题与排查技巧实录5.1 模型缺块或闪烁的排查思路模型缺块是 Meshlet Culling 最常见的问题通常有几个原因。第一个原因是包围球计算错误比如半径偏小导致可见 Cluster 被误裁剪。排查方法是把包围球可视化出来看看是否完全包住了对应的几何体。第二个原因是视锥平面提取错误特别是远裁剪面和近裁剪面的符号容易搞反。排查方法是把视锥平面可视化或者用已知位置的测试点验证。第三个原因是LOD 选择阈值设置不当导致某些 Cluster 选择了不存在的 LOD 等级。排查方法是输出每个 Cluster 的 LOD 选择结果检查是否有超出范围的。第四个原因是Indirect Draw 参数错误比如实例计数没有正确清零导致累积绘制。排查方法是每帧打印 Indirect Draw 缓冲区的数值。我踩过最坑的一次是包围球计算时用了 Float16 精度导致大模型的包围球半径溢出。后来改成 Float32 就正常了。所以精度问题在大场景下一定要重视不要为了省内存因小失大。5.2 性能不达预期的优化方向如果 Meshlet Culling 跑起来但性能不达预期可以从几个方向优化。首先是减少 Compute Shader 的线程浪费。如果 Group 数量不是 64 的倍数最后一组 Workgroup 会有空闲线程。可以把 Group 数量补齐到 64 的倍数或者用更灵活的调度策略。其次是优化数据访问模式。Compute Shader 里读取 Group 和 Cluster 数据时尽量保证连续线程访问连续内存这样能最大化缓存命中率。我的做法是把 Group 数据按线程 ID 顺序排列每个线程读取自己对应的 Group避免跨线程的随机访问。第三是减少原子操作。原子递增虽然快但大量线程同时操作同一个计数器时会有竞争。我的优化是每个 Workgroup 先做局部计数然后每个 Workgroup 只做一次全局原子递增。这样原子操作的数量从“Cluster 数量”降到“Workgroup 数量”提升明显。第四是调整 Workgroup 大小。WebGPU 里 Workgroup 大小可以是 64、128、256 等。我实测下来 64 对于这个场景最合适因为每个线程的工作量比较均匀太大的 Workgroup 反而增加同步开销。5.3 跨平台兼容性避坑指南WebGPU 虽然标准统一但不同浏览器的实现还是有差异。我遇到过的兼容性问题包括某些浏览器对存储缓冲区的最大大小限制更严格某些浏览器对 Compute Shader 的 Workgroup 数量有限制某些浏览器对 Indirect Draw 的支持不完整。应对策略是做能力检测和降级方案。启动时先查询设备的限制参数如果存储缓冲区不够大就减小 Meshlet 的批次大小如果 Workgroup 数量受限就增加每个线程的工作量如果 Indirect Draw 不支持就回退到 CPU 端读取计数再发起普通 Draw。虽然降级方案性能差一些但至少能跑起来。还有一个容易忽略的点是着色器编译时间。WebGPU 的着色器编译是异步的如果着色器很复杂首次加载会有明显卡顿。我的做法是把着色器编译放在加载阶段用createRenderPipelineAsync异步创建避免阻塞主线程。5.4 常见问题速查表问题现象可能原因排查方法解决方案模型缺块包围球偏小可视化包围球增大包围球半径余量模型闪烁LOD 阈值不当输出 LOD 选择结果调整误差阈值表帧率骤降原子操作竞争用性能分析工具改用 Workgroup 局部计数画面错位内存对齐错误检查缓冲区布局所有结构体补齐到 16 字节绘制数量异常Indirect 计数未清零打印缓冲区数值每帧开始时清零计数着色器编译卡顿着色器过于复杂查看编译日志拆分成多个简单着色器6. 实操心得与后续扩展方向6.1 我在这个项目里踩过的三个坑第一个坑是顶点索引重映射时的边界情况。有些三角形的顶点在之前的 Cluster 里已经出现过重映射时需要复用已有的局部索引而不是新建。我一开始没处理这个导致顶点数暴涨每个 Cluster 的顶点数从平均 100 涨到 180索引缓冲区直接翻倍。后来加了一个全局映射表才解决。第二个坑是Compute Shader 的线程组大小和共享内存。我一开始想用共享内存来缓存 Group 数据减少全局内存访问。但 WebGPU 的共享内存大小有限而且不同浏览器的限制不一样。后来放弃了这个优化直接用全局内存加缓存友好的访问模式性能反而更稳定。第三个坑是Indirect Draw 的缓冲区偏移对齐。WebGPU 要求 Indirect Draw 的偏移量必须是 4 的倍数而且不同 LOD 等级的命令之间要有足够的间隔。我一开始把命令紧密排列结果某些设备上读取错位。后来每个命令之间留了 16 字节的填充问题解决。6.2 后续可以继续深挖的方向这个简易版跑通之后有几个方向可以继续深挖。第一个是遮挡剔除。目前只做了视锥裁剪如果加上基于深度金字塔的遮挡剔除可以进一步减少可见 Cluster 数量特别是在复杂场景里效果显著。实现思路是先渲染上一帧的深度图构建深度金字塔然后在 Compute Shader 里用深度金字塔做遮挡测试。第二个是流式加载。目前所有 Meshlet 数据都是一次性加载的对于超大场景内存吃不消。可以改成按需加载根据摄像机位置动态加载附近的 Meshlet 数据远处的卸载。这需要配合虚拟纹理或者稀疏绑定的技术。第三个是软件光栅化。Nanite 的软件光栅化是为了处理极小的三角形避免硬件光栅化的开销。WebGPU 里可以用 Compute Shader 实现简单的软件光栅化对于投影面积小于一个像素的三角形直接用 Compute Shader 写入深度和颜色缓冲区。第四个是多级 LOD 的平滑过渡。目前 LOD 切换是离散的虽然比传统 LOD 好很多但在某些视角下还是能看到轻微的跳变。可以用几何变形Geomorph技术在 LOD 切换时对顶点位置做插值实现完全平滑的过渡。6.3 给准备入坑的朋友几点建议如果你准备自己实现一套 Meshlet Culling我的建议是先从最简单的场景开始。不要一上来就搞几百万面的模型先用一个立方体或者球体把整条链路跑通。确认裁剪、LOD、Indirect Draw 都正常工作了再逐步增加复杂度。另外调试工具非常重要。我写了一个简单的可视化模式可以把包围球、视锥、可见 Cluster 用不同颜色画出来。这个工具帮我省了大量排查时间。WebGPU 里可以用线框模式或者点云模式来可视化这些调试信息。还有一点是不要过早优化。我一开始花了很多时间在共享内存和原子操作优化上后来发现瓶颈其实在数据布局和内存访问模式上。先把功能做对再用性能分析工具找瓶颈针对性地优化效率更高。最后多看别人的实现。虽然 Nanite 的完整实现没有开源但有很多简化版的 Meshlet Culling 实现可以参考。UE5 的源码里也有相关部分虽然不能直接抄但思路可以借鉴。社区里也有不少讨论遇到问题多搜搜通常能找到答案。
