这是一篇线性串讲文档:跟着一个网格资产走一遍 Nanite 的完整数据流——离线怎么被处理成 Nanite 格式(资源处理),运行时这份数据怎么变成屏幕上的像素(渲染流程)。
深度为实现级,关键处标
文件:行号(基于E:/Project/UE的 UE 5.7 source build,行号随版本可能漂移)。想逐点深挖某个机制,可配套看《Nanite 技术问答》的对应题(文中标 Qn)。
Nanite 的一句话定位:把渲染成本从”场景有多少三角形”改写成”屏幕有多少像素”。要兑现这个承诺,它把工作劈成两半——一半放在离线(把高模预处理成一套可流送、可连续 LOD 的压缩数据),一半放在运行时 GPU(自驱动地剔除、选 LOD、光栅、着色)。这篇就按这两半的先后顺序走。
Nanite 的管线分两段,各给一张总图。先看离线段——把高模处理成可流送、连续 LOD 的压缩数据,并备好运行时选 LOD 所需的全部信息:

运行时段的总图放在第二部分开头。
入口在 NaniteBuilder.cpp:导入的网格数据先进 FClusterDAG,逐网格调 ReduceMesh(:680)构建 LOD 层级,再 BuildFallbackMesh(:724)出回退网格,最后 Encode(:936)压缩落盘。下面拆开每一步。
① 切簇:把网格剁成 ~128 三角形的簇

第一步是把整个网格切成簇(cluster),每簇约 128 个三角形(FCluster::ClusterSize = 128,Cluster.h:243)。为什么是 128:它是 GPU 友好粒度,且和 VisBuffer 里 7 位三角形索引硬绑定(2^7=128,见 Q1/Q12)。
切法不是按空间网格切(那会把一片连续表面切到多个簇、边界碎掉),而是把三角形邻接建成图再做图分割:三角形是节点,共享边是连接边,目标是”每块 ≤128 个节点、被切断的边权之和最小”。源码里 ClusterDAG.cpp 的初始划分有两条 partitioner 路径二选一(:113/:132):
FBVHCluster:基于空间 BVH 的划分;FGraphPartitioner:基于图分割,调 METIS。
主路径走图分割。建图时,相邻三角形之间加边、边权 4 * 65(ClusterDAG.cpp:160,共享边的代价),再 AddLocalityLinks 掺入 Morton 空间局部性链接(:164),最后 PartitionStrict(:170)递归二分到所有块 ≤128。
这里有个常被误解的点(Q25):METIS 只是被调用做”单次 2-way 最小割边二分”的底层零件,外层”递归切到 ≤128 + 确定性排序 + locality”都是 Epic 自己包的(GraphPartitioner.cpp 的 RecursiveBisectGraph、Ranges.Sort() 确定性、BuildLocalityLinks)。不用 meshoptimizer 是因为它的 meshlet 划分目标不同(顶点复用+锥剔除,不服务后续简化的最小割边诉求)。
切簇质量为什么重要:簇边界在后续简化时要被冻结(下一步),边界越短越少,可简化的内部就越多——切分质量直接决定 LOD 能不能往上爬。
② DAG 逐层简化:连续 LOD 的数据结构

切好叶簇(LOD0)后,ReduceMesh(ClusterDAG.cpp:639-860)迭代向上构建层级:
叶簇(LOD0) → 把相邻 8~32 簇组成一个 group → 整组简化到约一半三角形 → 再切成 4~16 新簇(上一级) → 重复到根
为什么是 DAG 不是树:一个父簇是多个子簇合并简化的产物,而分组边界每层重新洗牌,父子是多对多关系(多对多就是有向无环图)。源码里 FClusterRef 可带 InstanceIndex 支持 Assembly 多实例,RootClusterGroup(Groups.Last())是唯一根(Q4)。
简化用 QEM(二次误差度量):FMeshSimplifier::Simplify 返回 MaxErrorSqr(Cluster.cpp:549-743)——最坏情况下任意点到简化网格的平方距离,sqrt 逆尺度回原坐标系即该簇的 LODError。属性带权重:法线 1.0、UV 按面积、骨骼权重 0。

两条保证(Q13)让这套 LOD 在运行时正确:
- 边界锁定(crack-free):简化一个 group 时,遍历
ExternalEdges[i]≠0的边(与其他簇共享),对两端点LockPosition()冻结(Cluster.cpp:660-675)。相邻簇各自独立简化,但共享边逐顶点对齐,不裂。且每层错开分组——这层的边界下层落到 group 内部被简化,所以”所有边界最终都能简化掉”又”任意单层不裂”。 - 误差单调性:强制
ParentLODError = max(ParentLODError, SimplifyError)(ClusterDAG.cpp:1070),保证沿 DAG 越往上误差越大。否则运行时选 LOD 的”切口”会自相矛盾(父子同选 → 重叠/空洞)。
这一步的产物是一整棵 DAG,每个簇带几何误差和包围球——为运行时”按屏幕误差选 LOD”备好了所有信息。
③ 量化编码:位置怎么压、精度怎么自动定

几何的真瓶颈是数据量(内存/带宽/流送),所以 Nanite 的总哲学是”能量化就不存浮点、能推导就不存”。位置走统一量化格(NaniteEncode.cpp:191-345 CalculateQuantizedPositionsUniformGrid):
IntPosition = round(Position × 2^P) // P = Position Precision,步长 2^-P cm
每簇存整数最小角 QuantizedPosStart + 逐轴位宽 QuantizedPosBits(取值范围的 CeilLogTwo),顶点只存相对最小角的无符号偏移——全局精度统一,局部位宽自适应。
精度 P 默认 Auto(PositionPrecision = MIN_int32),四步自动定(Q24):
- 密度启发式:叶簇包围盒尺寸 log2 平均 →
P = 7 - round(AvgLogSize)(:224)。叶簇恒 128 三角,叶簇越小=越密=步长越细。 - 自动下限
AUTO_MIN_PRECISION = 4(步长 1/16 cm,:231)——更低精度省不了多少包体却会暴露量化台阶。 - 总范围 clamp 到 [-20, 43]。
- 超位宽降级:任一簇任一轴超 2^21-1 就把尺度减半、P 自减,循环到全部可编码(
:250-271)。
为什么每轴 21 位:21*3 = 63 < 64(NaniteDefinitions.h:165)——三轴量化坐标要塞进 64 位预算。工程含义:第 4 步的降级是全网格统一的,一个尺度异常巨大的簇会把整网格精度拉低——所以尺度悬殊的几何不要合进一个资产。
法线同样量化存(八面体两分量、每分量上限 15 位,NaniteDefinitions.h:168);切线默认不存,运行时像素端推导(见下文渲染部分 / Q16)。
④ 顶点 strip 编码:不是 indirect index

簇内三角形索引不是朴素的”每三角形 3 个 32 位绝对索引”,而是 triangle strip + Ref/New 顶点变长编码(NaniteEncodeTriStrip.cpp:141 UnpackTriangleIndices):
- New vertex:新顶点,顺序隐式编号,不花索引位;
- Ref vertex:回引前序顶点,存 5-bit 相对回退量(
BaseVertex - (IndexData & 31u),最多回引前 32 个)。
FStripDesc.NumPrevRefVerticesBeforeDwords / NumPrevNewVerticesBeforeDwords(Cluster.h:195)是每 dword 前缀计数,配合 CountBits 现场定位。构建期还有 FVertexArray::FindOrAddHash(Cluster.h:103)做簇内顶点去重。
省在哪:strip 推进一个三角形通常只新增 1 个 New 顶点、另 2 个回引,所以大多数顶点 0 索引位、只有复用顶点花 5 bit。这套编码运行时由 GPU transcode 用同一套 unpack 逻辑还原(Q25)。
⑤ 分页:Root 常驻 + Streaming 按需

编码后的簇按 Morton 序 + LOD 层级排进页(NaniteEncodePageAssignment.cpp),分两类(NaniteDefinitions.h:49):
- Root Page:常驻 GPU 显存,32KB、≤64 簇/页。不依赖任何其他页,资产一加载就能渲染一个粗几何近似;
- Streaming Page:按需流入,128KB、≤512 簇/页。细节页,引用 Root 做增量。
为什么分两类:全按需的话资产进视野第一帧没几何可画(弹出/空洞);Root 常驻保证”先有粗形、细节随后补”,这是 Nanite “任意距离都有东西渲染”的基础。磁盘上页是进一步压缩的位流(无损,约 50%),到 GPU 再解(省的是流送带宽这个真瓶颈)。
⑥ Fallback Mesh + Cook

构建还会出一份 fallback mesh(NaniteBuilder.cpp:724 BuildFallbackMesh)——从源网格自动简化的传统网格,给不支持 Nanite 的平台顶替渲染,并默认承担复杂碰撞和光照烘焙。质量由 FallbackTarget / FallbackPercentTriangles / FallbackRelativeError(FMeshNaniteSettings,EngineTypes.h)控制(Q22)。

Cook 时双向 strip(Q23):支持 Nanite 的平台剥掉 fallback 数据省包体(ShouldStripNaniteFallbackMesh,StaticMesh.cpp:203);不支持的平台反向剥掉 Nanite 数据。每个平台只背自己要用的数据,双形态不等于双倍包体。
至此,离线产物是:一套压缩分页的 cluster DAG(Nanite 数据)+ 一个 fallback mesh,存进 .uasset。
运行时全程 GPU 自驱动——剔除、选 LOD、光栅、着色全在 GPU 上靠 ExecuteIndirect 串起来,CPU 提交一次后不回读。调度主流程在 NaniteCullRaster.cpp 的 DrawGeometry(:6300 附近)。先给运行时段总图(深色框是 Nanite 三大核心创新;底部回指它消费的是离线哪一步备好的数据):

⑦ 流送 + Transcode:把压缩页变成可用几何
承接离线的分页:运行时按相机需求发流送请求(FStreamingRequest,NaniteStreaming.ush),把需要的 Streaming 页从磁盘拉进显存。进显存的是压缩位流,要 GPU 解码(Transcode,NaniteTranscode.usf),分两 pass(Q14):
- Independent pass:处理不依赖父页的数据——原始拷贝、Strip 索引解码、位置用 ZigZag 位流 +
WaveInclusivePrefixSum还原; - ParentDependent pass:处理跨页引用顶点,从父页解码(通过
PageClusterMap)。
两 pass 分离是为了依赖解耦——Root 页全在 Independent pass 解完(它要能独立工作),Streaming 的引用顶点等父页就位再解,避免在单 pass 内做 GPU 同步。每组 64 线程处理一个 cluster。
这一步衔接了离线的 ④(strip 编码)和 ③(量化):strip 索引、ZigZag delta 在这里被还原成运行时可用的顶点。
⑧⑨ 实例剔除:PrimitiveFilter + InstanceCull
GPUScene 里常驻所有实例的 transform。DrawGeometry 五阶段调度的前两步(NaniteCullRaster.cpp):
- PrimitiveFilter(
:3969):按 HiddenPrimitives / ShowFlags 生成 GPU 侧过滤掩码; - InstanceCull(
:4364):逐实例视锥 + HZB 遮挡剔除(InstanceCull_CS,64 线程/组),写候选队列;被遮挡的实例记入OccludedInstances(延迟复核,见 ⑬)。大世界可选 Instance Hierarchy(5.7)做层级剔除。
⑩ persistent threads 遍历 DAG:选 LOD cut

这是 Nanite 运行时的核心创新。选 LOD 的本质是在 DAG 上找一条”切口”——切口经过的那圈簇就是本帧要画的 LOD。判据是把簇的几何误差投影成屏幕像素(NaniteClusterCulling.usf:322):
bool bVisible = ProjectedEdgeScale > UniformScale * LODError * NaniteView.LODScale;
投影误差小于约 1 像素就停(够精细了),否则往 DAG 下层展开更细的簇。
遍历靠 persistent threads + 工作队列(NaniteCullRaster.cpp:4238):启动约等于所有 CU 的线程组,每组用 InterlockedAdd 从 QueueState 原子偷候选节点——不满足 SmallEnoughToDraw 就把子节点 push 回队列让别的线程继续展开,满足就写入可见簇缓冲。全程 GPU 自驱动、CPU 不回读(Q5)。
为什么没有 popping:判据是连续函数(随距离平滑)、一次只换一个簇(~128 三角)、新旧簇边界因 LockPosition 严格对齐(②)——是连续 LOD,不是离散档位切换。
选 LOD 用的
LODError(来自离线 ②的 QEM)和包围球,正是离线构建时备好的;单调性(②)保证这条切口良定义。
⑪⑫ 软硬混合光栅 → VisBuffer64

选出的可见簇要光栅化。Nanite 按三角形屏幕尺寸分流(判据在剔除阶段算好,NaniteClusterCulling.usf:330):
- 小三角形 → 软光栅(compute):绕开硬件 2×2 quad 的 overshading(微小三角形 quad 利用率最低只 1/4);边函数全增量(纯加减无乘除);单三角形包围盒上限 64×64 像素(
NaniteRasterizer.ush:73); - 大三角形 → 硬光栅(
FHWRasterizeVS/MS/PS,5.7 走 Mesh Shader):固定功能单元并行填充更快。
RasterBinning(NaniteCullRaster.cpp:5619)按材质标志/尺寸/深度把簇分箱,分流到软/硬两路。

软光栅自己负责水密性:顶点用 4.8 定点数、半边常数 8.16 定点数(NaniteRasterizer.ush:88-106),整数运算精确,相邻三角形共享边的边函数逐位一致,不裂不双写;再用 Top-Left 填充规则裁定压线像素(Q3)。

两路光栅都不着色,只往每像素 64 位的 VisBuffer64 写”谁可见”(NaniteWritePixel.ush:30):
const UlongType Pixel = PackUlongType(uint2(PixelValue, DepthInt)); // 高32位深度 + 低32位可见性ID
ImageInterlockedMaxUInt64(OutBuffer, PixelPos, Pixel); // 一条原子=深度测试+写入
深度放高位,一条 InterlockedMax 同时完成深度测试和写入(inverted-Z 下大者即近者)。必须用 64 位原子,因为簇内多三角形跨线程并发、软硬光栅 async 并发写同一像素(Q1)。这条原子操作也是 Nanite 硬依赖 64 位图像原子、移动端基本不可用的根源(Q23)。
⑬ 两阶段遮挡剔除

遮挡剔除要 HZB(深度金字塔),HZB 又要先光栅——循环依赖。Nanite 用两阶段打破(Q6):
- Main pass:用上一帧的 HZB 剔除并光栅可见部分;被判遮挡的实例不丢弃,记入
OccludedInstances延迟复核; - 重建 HZB:用本帧光栅出的深度建新 HZB(
BuildHZBFurthest); - Post pass:只对延迟复核的实例用本帧新 HZB 重测,翻案的补光栅一次。
靠帧间连贯性,多数物体一帧 HZB 就剔对了,只少数遮挡边缘进 Post pass。镜头突切时上一帧 HZB 失效会出光栅尖峰,5.7 用 HZB priming 缓解。
⑭ ShadeBinning → GBuffer:每像素只着色一次

所有几何写完、每像素唯一可见三角形定案后才着色。这是和传统延迟的本质区别:传统延迟在几何 pass 就着色(被遮挡片元也着了,有 overdraw),Nanite 把着色推迟到可见性定案之后,被遮挡像素根本不进着色(Q15)。
ShadeBinning 三阶段(NaniteShadeBinning.usf):
- COUNT:读 VisBuffer + ShadingMask 取每像素的材质 bin,
WaveActiveCountBits统计各 bin 像素数; - RESERVE:逐 bin 分配连续像素存储 + 生成 indirect dispatch 参数(
bNoDerivativeOps决定按像素还是按 2×2 quad); - SCATTER:每像素算精确写入偏移,散写进所属 bin。
结果:每材质一个 compute shader、只对它覆盖的可见像素 dispatch,着色开销 = 屏幕像素数 × 常数,与几何三角形数、材质数的乘积彻底解耦。

着色阶段从 VisBuffer 反查回三角形、解码属性。切线在这里现场推导(离线不存,③提过):用法线 + 三角形边 + UV 梯度叉乘组合算出切线基(Christian Schüler 法,NaniteAttributeDecode.ush:610)——用算力换内存。法线则是解码量化值(UnpackNormal)。
着色产物写进标准 GBuffer,从这里接入 UE 的延迟渲染管线。
接入 UE5 渲染:VSM / Lumen

Nanite 的 GBuffer 不是终点,它供给整个 UE5 光影管线(Q10):
- VSM(虚拟阴影贴图):页式高分辨率阴影,正是为 Nanite 的几何密度设计;也复用 Nanite 的两阶段遮挡机制(HZB 页式);
- Lumen(全局光照):5.6/5.7 转向 HWRT,需要真实 BLAS。

但 Nanite 几何是压缩的动态 LOD,光追 BLAS 要确定三角形——矛盾。解法是 StreamOut:FNaniteStreamOutTraversalCS(NaniteStreamOut.cpp:65)按独立 cut error 在 DAG 上选簇,导出确定的 VB/IB 构建 BLAS,光追 LOD 与光栅 LOD 解耦(通常更粗)。这是离线 DAG 数据在运行时的另一种用途——同一棵 DAG,光栅走一条切口、光追走另一条更粗的切口。5.7 新增 RT Streaming、CLAS 等(Q19)。
回看整条管线,离线和运行时不是割裂的两段,而是离线备料、运行时取用的一套:
- cluster DAG + 每簇 QEM 误差(②) → persistent threads 把误差投影成屏幕像素选 LOD cut(⑩)
- 边界锁定 + 误差单调(②) → 保证选出的切口不裂、不重叠(⑩ 无 popping 的前提)
- 量化位置 + strip 编码(③④) → Transcode 在 GPU 还原成可用顶点(⑦)
- Root/Streaming 分页(⑤) → 流送按需拉页,Root 保证首帧有粗几何(⑦)
- fallback mesh(⑥) → 不支持平台降级渲染(⑦ 之外的传统路径)
- 同一棵 DAG → 光栅走一条 LOD 切口、光追 StreamOut 走另一条更粗的切口(⑩ / Lumen)
一句话收尾:Nanite 把”几何复杂度管理”从运行时的人工难题,变成了一套”离线压缩 + 运行时 GPU 自驱动取用”的自动化管线——离线把高模拆成可流送、可连续 LOD 的压缩数据并备好选 LOD 所需的全部信息,运行时 GPU 自己遍历、剔除、光栅、着色,最终让渲染成本只跟屏幕像素数走。
下半篇《逐点深挖》把上面每个环节的”为什么这么设计”拆成 25 道问答(前文标注的 Qn 即对应题号),每题含成文答案、源码佐证、采分要点、雷区与追问链。
底层机制(Q1–Q6)
<a name=”q1″></a>
Q1. VisBuffer64 里装了什么?为什么是这个布局?为什么必须用 atomic?

满分答案:
Nanite 的光栅化阶段不做任何材质着色,它只回答一个问题:屏幕上每个像素,最终可见的是哪个三角形。这个答案存在一张叫 VisBuffer64 的缓冲里,每个像素 64 位。
布局是(NaniteWritePixel.ush:30):
const UlongType Pixel = PackUlongType(uint2(PixelValue, DepthInt));
- 高 32 位 = 深度(DepthInt):浮点深度
asuint(saturate(DeviceZ))重新解释为整数。 - 低 32 位 = 可见性(PixelValue):
(VisibleClusterIndex+1)<<7 | TriIndex,即高 25 位是可见 cluster 索引、低 7 位是三角形索引(一簇最多 128 三角形,正好 7 位)。
为什么深度放高位:这样比较两个 64 位整数的大小,等价于比较它们的深度。于是写入用一条原子操作就够了(NaniteWritePixel.ush:31):
ImageInterlockedMaxUInt64(OutBuffer, PixelPos, Pixel);
InterlockedMax 取较大者——因为 UE 用 inverted-Z(近大远小),深度大的就是更近的。这一条 atomic 同时完成了深度测试和写入:如果新片元更近,它的 64 位值更大,max 之后整个值(深度+可见性ID)一起被替换;否则原值保留。深度测试和可见性写入合二为一,没有单独的 depth pass。
为什么必须 atomic:一个 cluster 的所有三角形是跨多个 compute 线程同时光栅化的,它们可能同时命中同一个像素;而且 Nanite 的软光栅和硬光栅可以走 async compute 并发写同一张 buffer(Platform.ush:1350-1364 定义了 native uint64 或 uint2 模拟两种实现)。没有 64 位原子,多个线程的 read-modify-write 会相互覆盖,结果错乱。
这个设计的本质收益:光栅化阶段被遮挡的像素根本不会进入材质着色——它在 VisBuffer 里被更近的片元覆盖掉,而着色发生在所有几何写完之后(见 Q15)。这就根除了 overdraw 的着色浪费,是 Nanite “渲染成本与几何复杂度解耦” 的基石之一。
✅ 采分要点(按层级)
- 〔及格〕 候选人大致会说:*”VisBuffer 每像素存深度和一个 ID,记录哪个三角形可见,光栅化阶段不做着色。”*
→ 给分理由:抓住了”延迟着色 + 只存可见性”的核心意图,方向对。
- 〔良好〕 *”深度放在高 32 位,所以用一条
InterlockedMax比较 64 位整数就同时完成了深度测试和写入,inverted-Z 下 max 就是更近的胜出。”*
→ 给分理由:理解了”位布局服务于原子合并”,不是随便排的——关键的”知其所以然”。
- 〔优秀〕 *”必须 atomic,因为一个 cluster 的多个三角形跨多个 compute 线程并行光栅,软/硬光栅还能 async 并发写同一像素;而且整条设计让被遮挡像素根本不进材质着色,根除了 overdraw 的着色浪费。”*
→ 给分理由:能把”并发正确性”和”为什么这样设计省了什么”都讲透。
- 〔加分〕 *”低 7 位是三角索引(一簇 ≤128 三角,正好 7 位),高 25 位 cluster 索引;
+1是为了留 0 当空像素哨兵;不支持原生 64 位 atomic 的平台用 uint2 模拟。”*
→ 给分理由:能拆到位级 + 知道平台降级,说明真读过源码或做过类似系统。
⛔ 雷区(错在哪 / 为什么错 / 正确是什么):
- “Nanite 用传统 depth buffer + 多次 draw call 画几何”
– 错在哪:Nanite 完全绕开传统图元管线,走 GPU-driven 的 compute 光栅 + 单一 VisBuffer,根本没有”逐 draw call”这回事。
– 正确:一个 dispatch 把所有可见 cluster 光栅进 VisBuffer。
- “光栅化时就把材质算了写进去”
– 错在哪:这就退化成传统延迟渲染了,被遮挡片元也会着色(overdraw)。
– 正确:光栅只写”谁可见”,着色推迟到所有几何定案后按材质分箱做一次(Q15)。混淆 VisBuffer 与 GBuffer 是最常见的”半懂”信号。
- “深度一张图、可见性 ID 另一张图,分别写”
– 错在哪:两张图的写入无法原子绑定——线程 A 的深度赢了、线程 B 的 ID 却写进去了,就会深度和 ID 不一致(错误的可见性)。
– 正确:必须打包进同一个 64 位值做一次 atomic,才能保证”赢的深度”和”它的 ID”绑定写入。
🕳️ 似是而非的陷阱(听起来对,实则半懂):
- “用 atomicMax 做深度测试,很常见啊(早期 GPU particle 也这么干)。”——半懂点:atomicMax 做纯深度不稀奇,Nanite 的精髓是把可见性 ID 塞进同一个 64 位整数的低位、深度放高位,让一次 atomicMax 同时解决”深度比较”和”可见性绑定”。没说出”打包”就是没到点。
- “VisBuffer 就是 G-Buffer 的一种。”——半懂点:方向反了。G-Buffer 存的是着色所需的材质属性(已经算过一部分),VisBuffer 存的是可见性引用(谁可见,还没着色)。VisBuffer→(解码+ShadeBinning)→G-Buffer 是两个阶段。
- “深度放高位是为了精度。”——半懂点:不是精度问题,是让整数比较的序 == 深度的序,使 atomicMax 的语义正好等于深度测试。放低位就没这个性质了。
🔍 追问链(面试官逐层下探):
- Q:为什么深度一定要放高位,放低位行不行? A:不行。
InterlockedMax比较的是整个 64 位整数;只有深度在高位,整数大小的序才等于深度的序,max 才等价于深度测试。深度放低位的话,cluster 索引会主导比较,结果错乱。 - Q:一簇为什么是 ≤128 三角形,和这里的 7 位有关系吗? A:有。低 7 位编码三角索引,2^7=128,正好对应构建期一簇 ≤128 三角形(Q12)——位布局和簇大小是呼应设计的。
- Q:那同时可见的 cluster 数有没有上限? A:有,高 25 位 → 约 3300 万 cluster 上限,超大场景靠流送把不可见的换出。
- Q:移动端 / 不支持 64 位 atomic 的硬件怎么办? A:
UlongType退化成 uint2 模拟(Platform.ush:1352),但编译器仍需支持 64 位 atomic 指令——这是 Nanite 移动端支持迟迟受限的底层原因之一。
追问准备:如果问”这个设计有什么代价”——见 Q20,它锁死了”每像素单值”,所以不支持半透明、深度函数只能是 Greater-Or-Equal。
🔧 资深视角(增补)

宏观个人观点:VisBuffer 不是 Epic 发明的——visibility buffer / id-buffer 渲染的思想 2013 年 Burns & Hunt 就提出了(”The Visibility Buffer”)。Nanite 的真正贡献不是”想出存 triangle ID”,而是把「64 位 atomic 合并深度测试 + 软硬混合光栅 + 延迟材质着色」三件事凑成一个能跑生产的闭环。单看每一点都不新,组合后的系统效应才是质变。我个人认为这道题真正想考的是这种判断力——能识别”哪些是已有想法的工程化、哪些是原创”,比背出位布局值钱得多。趋势上,id-buffer 渲染正在成为高密度几何的事实标准。
容易忽略的细节:
- 低 7 位是三角形索引,正好对应”一簇 ≤128 三角形”(Q12)——位布局和构建期的簇大小是呼应设计的,不是巧合。
- 高 25 位 cluster 索引意味着同时可见的 cluster 数有上限(约 3300 万),超大场景要靠流送把不可见的换出。
(VisibleClusterIndex+1)那个 +1:保留 0 作为”空像素”哨兵,避免 cluster 0 和空像素混淆。
特殊 / 不支持平台的做法(源码实证):
- 无原生 64 位 atomic 的平台:
UlongType退化成uint2模拟(Platform.ush:1352),但编译器仍需支持 64 位 atomic 指令——所以真正的老硬件 / 部分移动 GPU 上 Nanite 受限,这是 Nanite 移动端支持迟迟受限的底层原因之一。 - Depth-Only 路径:阴影 / VSM 深度只需要深度、不需要可见性 ID 时,走
#if DEPTH_ONLY → InterlockedMax(普通 32 位),省掉 64 位打包开销(NaniteWritePixel.ush:27)。
如何优化 / 排查:用 r.Nanite.Visualize 的 overdraw 模式确认 VisBuffer 写入压力;如果某区域 overdraw 高,多半是大量重叠的 masked 材质(masked 不能 early-out,要跑 EvaluatePixel)。r.Nanite.VisualizeComplexity 看 cluster/材质复杂度。
横向对比:见上图——前向/传统延迟(几何 pass 就着色、有 overdraw、MSAA 显存爆)vs Nanite VisBuffer(延迟到可见性定案、零 overdraw、但每像素单值)vs 学术 visibility buffer(同源思路,Nanite 加了 64 位 atomic 把深度测试也合并进去)。
<a name=”q2″></a>
Q2. 软光栅为什么能比硬件光栅快?什么时候反而更慢?

满分答案:
要理解这点,得先知道 GPU 硬件光栅化器的工作单位是 2×2 像素的 quad,而不是单个像素。这是硬件设计决定的——因为像素着色器需要算 ddx/ddy(屏幕空间导数,用于纹理 mip 选择、各向异性过滤),而导数是靠同一个 quad 内相邻像素的差分得到的。所以硬件光栅器哪怕只覆盖 1 个像素,也要把整个 quad 的 4 个像素都拉起来跑。
对微小三角形这是灾难:Nanite 的资产是 film-quality 的,一个三角形投影到屏幕上经常只有几个像素、甚至亚像素。这时 quad 利用率极低——一个三角形可能只覆盖 quad 里 1 个像素,另外 3 个是浪费的(quad overshading)。三角形越小,浪费比例越高,且每个三角形的固定开销(图元装配、属性插值 setup)摊不掉。
Nanite 软光栅的做法(NaniteRasterizer.usf 的 ClusterRasterize):用一个 monolithic compute shader,自己做顶点拉取、变换、光栅化,逐像素精确覆盖,绕开 quad 限制。而且它用边函数(edge function)做半空间测试,循环内是纯增量加减法(NaniteRasterizer.ush:165-177,CX -= Edge.y / CY += Edge.x),没有乘除——对小三角形极其高效。
什么时候软光栅反而更慢:三角形大的时候。大三角形覆盖很多像素,硬件光栅器的并行填充和固定功能单元(ROP、插值器)反而更快,软光栅逐像素 compute 的优势消失。所以 Nanite 按三角形屏幕尺寸分流:
- 小三角形 → 软光栅(compute);
- 大三角形 → 硬光栅(
FHWRasterizeVS/MS/PS,5.7 在支持的硬件上走 Mesh Shader 路径)。
软光栅 setup 里还有个硬证据:单个三角形包围盒被限制在 64×64 像素(NaniteRasterizer.ush:73,MaxPixel = min(MaxPixel, MinPixel+63)),超过这个尺寸的三角形本就该走硬光栅。分流判据在剔除阶段就算好了(NaniteClusterCulling.usf:330,ProjectedEdgeScale < HWEdgeScale*|EdgeLength|*LODScaleHW)。
✅ 采分要点(按层级)
- 〔及格〕 *”硬件光栅以 2×2 quad 为单位,微小三角形覆盖一个像素却要跑四个,浪费严重;软光栅逐像素精确覆盖。”*
→ 给分理由:抓住”quad 粒度导致 overshading”这个核心矛盾。
- 〔良好〕 *”quad 存在的根因是要算
ddx/ddy屏幕空间导数——纹理 mip / 各向异性都靠同一 quad 内相邻像素差分。”*
→ 给分理由:知道 quad 不是随便定的,是硬件为导数付的代价,理解到根上。
- 〔优秀〕 *”大三角形软光栅反而慢——固定功能单元(ROP、插值器)并行填充更快。所以 Nanite 按三角形屏幕尺寸分流,软/硬混合,不是二选一。”*
→ 给分理由:能讲”什么时候软光栅输”,说明是辩证理解而非记结论。
- 〔加分〕 *”边函数全增量(循环内纯加减无乘除);软光栅单三角形包围盒上限 64×64,超过走硬光栅;5.7 硬光栅走 Mesh Shader 路径。”*
→ 给分理由:能点出实现级证据(64×64 上限)和版本细节,真看过。
⛔ 雷区(错在哪 / 为什么错 / 正确是什么)
- “软件光栅一定比硬件快”(或反过来”一定慢”)
– 错在哪:任何绝对化都错,软光栅只在小三角形赢、大三角形输。
– 正确:关键是按尺寸分流,两者混合。
- “硬件光栅也是逐像素处理的”
– 错在哪:硬件最小单位是 2×2 quad(为算导数),不是单像素;这正是微小三角形 overshading 的来源。
– 正确:quad 粒度 → 小三角形 4 个像素只 1 个有效。
- “Nanite 全部用软光栅,抛弃了硬件管线”
– 错在哪:大三角形仍走硬件光栅(FHWRasterizeVS/MS/PS)。
– 正确:软/硬两条路径并存,按屏幕尺寸选。
🕳️ 似是而非陷阱(听起来对,实则半懂)
- “软光栅快是因为 compute shader 比图形管线快。” —— 半懂点:不是”compute 天生快”,是软光栅绕开了 quad 的固定浪费 + 边函数全增量无乘除。换个大三角形场景,同样 compute 也比不过硬件 ROP。
- “Nanite 不用硬件光栅了。” —— 半懂点:恰恰相反,它保留硬件光栅给大三角形,5.7 还升级到 Mesh Shader。软光栅只是补上了硬件不擅长的微小三角形那一段。
- “quad 是为了抗锯齿(MSAA)。” —— 半懂点:quad 是为了导数计算,和 MSAA 是两回事;Nanite 反而不走 MSAA(每像素单值)。
🔍 追问链(面试官逐层下探)
- quad 到底为什么存在,去掉行不行? → 不行。像素着色器要
ddx/ddy,靠 2×2 内相邻像素差分;去掉 quad 就没法算导数,纹理采样会失去 mip 选择。 - 软光栅怎么算导数? → Nanite 在软光栅里自己用解析方式算重心坐标的屏幕导数(
Barycentrics_dx/dy,SetupTriangle 阶段预算),不依赖 quad。 - 分流的判据具体是什么? → 剔除阶段按投影边长判断(
ProjectedEdgeScale < HWEdgeScale*|EdgeLength|*LODScaleHW),小走软、大走硬。 - 软光栅为什么有 64×64 上限? → 超过这个尺寸软光栅的逐像素扫描已不划算,本就该交给硬件并行填充——上限是分流兜底。
<a name=”q3″></a>
Q3. 软光栅为什么用定点数而不是浮点?不这么做会怎样?

满分答案:
核心是水密性(watertight rasterization)——相邻三角形共享一条边时,边上的像素必须严格归属其中一个,不重不漏。
如果用浮点做光栅化坐标和边函数,舍入误差会让边上某个像素的归属变得不确定:两个共享这条边的三角形,各自算出的边函数值因浮点误差略有不同,可能都判定”这个像素在我这边”(→ 双写 double-write),或者都判定”不在我这边”(→ 裂缝 crack,像素没被任何三角形覆盖,露出背景)。
Nanite 的解法是定点数。在 SetupTriangle(NaniteRasterizer.ush:28-130)里:
- 顶点坐标转成 4.8 定点数(4 位整数 + 8 位小数,
:80注释),子像素精度 = 1/256 像素; - 半边常数
C0/C1/C2是 8.16 定点数(:88-91):
“`hlsl
Tri.C0 = Tri.Edge12.y * Vert1.x – Tri.Edge12.x * Vert1.y;
“`
定点数运算是精确的整数运算,没有舍入误差——同一条共享边,两个三角形算出的边函数值在边上逐位一致,从根本上消除了归属的不确定性。
光有定点数还不够,还需要填充规则(fill convention)解决像素正好落在边上的归属。Nanite 用 Top-Left 规则(NaniteRasterizer.ush:98-106):
Tri.C0 -= saturate( Tri.Edge12.y + saturate( 1.0f - Tri.Edge12.x ) );
对每条边按斜率方向做 ±1 的偏置,约定”像素中心落在左边界或上边界算在内,落在右/下边界算在外”。这样共享边的像素只会归属一个三角形。
不这么做的后果:双写会让半透明叠加变色、深度值竞争出错;裂缝会在网格表面出现一像素宽的破洞,尤其在高密度几何下满屏闪烁。定点数 + Top-Left 规则是 Nanite 能”无缝拼接相邻 cluster”的微观保证(宏观层面的无缝靠 DAG 边界锁定,见 Q4/Q13)。
✅ 采分要点(按层级)
- 〔及格〕 *”核心是水密性——共享边上的像素归属必须确定。浮点的舍入误差会让边上像素归属不定,造成裂缝或双写。”*
→ 给分理由:说出”水密性”这个关键词,且知道浮点的问题在归属不确定。
- 〔良好〕 *”定点数是精确整数运算,没有舍入误差,同一条共享边两个三角形算出的边函数值逐位一致。顶点 4.8、半边常数 8.16。”*
→ 给分理由:讲清定点数为什么能解决问题(精确=逐位一致),还能给出格式。
- 〔优秀〕 *”光定点数不够,还要 Top-Left 填充规则解决’像素正好落在边上’的归属(左/上算内、右/下算外);否则边上像素仍会被两个三角形都写。”*
→ 给分理由:补上填充规则这层——很多人只记得定点数,漏了它。
- 〔加分〕 *”这是微观像素级的水密;宏观层面相邻 cluster 不裂靠的是 DAG 边界锁定(Q4),两个层次。”*
→ 给分理由:能把”像素级水密”和”几何级无缝”分清,体系感强。
⛔ 雷区(错在哪 / 为什么错 / 正确是什么)
- “浮点已经够精确了,用浮点也没问题”
– 错在哪:这不是精度够不够的问题,光栅化对”边上像素归谁”是 0/1 的一致性要求,浮点哪怕误差极小,也会让两侧三角形判定不一致。
– 正确:必须精确运算(定点数)保证两侧逐位一致。
- “水密性就是防止 z-fighting”
– 错在哪:方向错了。z-fighting 是深度精度问题;这里说的是覆盖归属(哪个三角形盖这个像素),是 2D 光栅层面的事。
– 正确:水密 = 无裂缝无双写,与深度无关。
- “用定点数就够了”
– 错在哪:定点数解决了”逐位一致”,但像素中心正好落在边上时仍需规则裁定归属,否则双写。
– 正确:定点数 + Top-Left 规则两者缺一不可。
🕳️ 似是而非陷阱(听起来对,实则半懂)
- “定点数是为了省内存 / 加速。” —— 半懂点:定点数在这里的首要目的是精确性(水密),不是省空间或更快。它顺带让边函数全增量(Q2)更高效,但那是副产物。
- “裂缝是 LOD 切换时才有的。” —— 半懂点:这里说的裂缝是同一帧、相邻三角形共享边的像素级裂缝(光栅问题);LOD 切换的裂缝是另一回事(靠 Q4 边界锁定解决)。别混。
- “Top-Left 规则是 Nanite 独创的。” —— 半懂点:Top-Left 是 D3D/GPU 光栅化的标准填充约定,Nanite 只是在软光栅里自己实现了它,不是发明。
🔍 追问链(面试官逐层下探)
- 浮点误差具体怎么导致双写? → 共享边两侧的三角形各自算边函数,浮点下两个值可能一正一负附近抖动,导致边上像素被两侧都判”在内”。
- 4.8、8.16 这些数字什么意思? → 整数位.小数位。4.8 = 顶点坐标 8 位小数(1/256 子像素精度);8.16 = 半边常数更高精度避免中间溢出。
- Top-Left 规则具体怎么实现? → 对每条边的边函数按斜率方向做 ±1 偏置(
NaniteRasterizer.ush:98),把”边上”判定偏向左/上三角形。 - 这和宏观的 cluster 不裂是一回事吗? → 不是。这是像素级(光栅),cluster 间不裂是几何级(DAG 简化时 LockPosition 锁边界,Q4/Q13)。
<a name=”q4″></a>
Q4. LOD 为什么用 DAG 不是树?相邻 LOD 为什么不裂?

满分答案:
先讲 DAG。 Nanite 的 LOD 不是传统的离散档位,而是离线构建的一棵有向无环图(DAG)。构建过程是迭代的(ClusterDAG.cpp:639-860,ReduceMesh):
原始三角形 → 切成 ~128 三角形/簇(叶簇,LOD0)
循环:
把相邻的 8~32 个簇组成一个 Cluster Group
整个 group 简化到约一半三角形
再切成 4~16 个新簇 → 上一级 LOD
为什么是 DAG 而不是树:一个 group 简化后产生的新簇,是多个子簇合并简化的结果;而这些子簇在下一层又会被不同的 group 划分重新分组。所以”父簇 ← 子簇”是多对多关系——一个子簇的内容会流向某个父簇,但分组边界每层都重新洗牌。多对多就是有向无环图,不是树(树要求每个节点只有一个父)。源码里 FClusterRef 可以带 InstanceIndex 支持 Assembly 多实例,RootClusterGroup(Groups.Last())是唯一的根。
再讲为什么不裂。 这是构建时的核心技巧——边界锁定(LockPosition)。
简化一个 group 时,如果直接简化所有顶点,group 的外边界会被移动,那它和相邻 group(在同一 LOD 级)的拼接处就对不上了,产生裂缝。Nanite 的做法(Cluster.cpp:660-675):
遍历 FCluster.ExternalEdges[i] ≠ 0 的边(即与其他簇共享的边)
对每条外部边,Simplifier.LockPosition(两个端点) // 冻结,简化中不许动
这样 group 内部尽情简化,但外边界顶点钉死不动。两个相邻 group 共享的那条边界,两边都锁定了同样的端点位置(这些位置由原始网格的邻接关系唯一确定),所以简化后仍然逐顶点对齐,无裂缝。
关键的递进设计:如果每一层都锁死同样的边界,那这些边界永远简化不掉,LOD 上不去。所以 Nanite 每一层用错开的 group 划分——这一层是边界的边,下一层就可能落在 group 内部,从而被简化。这样”所有边界最终都能被简化掉”,又”任意单层内相邻簇无裂缝”。
✅ 采分要点(按层级)
- 〔及格〕 *”Nanite 的 LOD 是离线逐层简化构建出来的层级结构,不是美术手工摆的 LOD0/1/2。”*
→ 给分理由:知道 LOD 是自动构建的连续层级,而非离散档位。
- 〔良好〕 *”一个父簇是多个子簇合并简化的结果,子簇下一层又被不同的 group 重新分组,所以父子是多对多——多对多就是 DAG,不是树。”*
→ 给分理由:答出”为什么是 DAG”的拓扑根因(多对多),不是只背名词。
- 〔优秀〕 *”相邻簇不裂靠边界锁定:简化时把共享边的端点 LockPosition 冻结,两侧独立简化但边界逐顶点对齐。而且每层用错开的分组,让这一层的边界下一层落到内部被简化。”*
→ 给分理由:讲清 crack-free 机制 + “错开分组”的递进设计——后者很多人想不到。
- 〔加分〕 *”误差用 QEM 度量,且 parentError ≥ childError 保证单调(Q13);簇约 128 三角、group 8~32 簇。”*
→ 给分理由:能把构建期误差和运行期选 LOD 串起来,体系完整。
⛔ 雷区(错在哪 / 为什么错 / 正确是什么)
- “Nanite LOD 就是传统 LOD0/1/2,只是自动生成的”
– 错在哪:传统 LOD 是离散档位、切换时整模型换网格(有 popping);Nanite 是 DAG 上的连续切口、逐簇平滑过渡。
– 正确:本质是连续 LOD(Q5),不是几个固定档。
- “DAG 是为了节省存储 / 去重”
– 错在哪:方向偏了。DAG 的根因是合并简化导致的多对多拓扑,压缩是另一回事(量化编码,Q14)。
– 正确:DAG 描述的是父子簇的依赖关系。
- “简化后产生的裂缝靠运行时缝合”
– 错在哪:裂缝是在构建期就避免的(LockPosition 锁边界),运行时不做缝合。
– 正确:crack-free 是离线保证的,运行时只是选切口。
🕳️ 似是而非陷阱(听起来对,实则半懂)
- “Nanite 用八叉树 / BVH 管理 LOD。” —— 半懂点:空间加速结构(BVH)用于剔除遍历,和 LOD 的 DAG 是两个东西。LOD 层级是按”简化谁来自谁”建的有向无环图,不是按空间位置。
- “DAG 就是把树允许多个父节点。” —— 字面对,但要说出为什么需要多父:因为一个父簇由多个子簇合并而来、且分组每层重洗,单父结构无法表达,否则边界没法干净简化。
- “边界锁定会让边界永远简化不掉。” —— 半懂点:如果每层锁同样的边界确实如此,但 Nanite 每层错开分组,所以边界会轮流落到内部被简化——这正是设计的精妙处。
🔍 追问链(面试官逐层下探)
- 为什么不能用树? → 父簇是多个子簇合并简化的产物,且分组每层重新划分,父子是多对多,树(单父)表达不了。
- 边界锁定具体锁什么? → 遍历
ExternalEdges[i]≠0(与其他簇共享)的边,对两端点LockPosition(),简化中不许移动(Cluster.cpp:660)。 - 都锁死了 LOD 怎么还能升? → 每一层用错开的 group 划分,这层的边界下层落到 group 内部,从而被简化。
- 误差怎么和这套配合? → 每簇带 QEM 误差,且父误差 ≥ 子误差(单调),运行时把误差投影成屏幕像素来选切口(Q5/Q13)。
追问准备:若问”误差怎么保证选 LOD 正确”——见 Q13 的单调性(
ParentLODError = max(...))。
<a name=”q5″></a>
Q5. 运行时怎么选 LOD?为什么没有 popping?

满分答案:
选 LOD 的本质:在那棵 DAG 上找一条”切口(cut)”——切口经过的那一圈簇,就是本帧要光栅化的 LOD。判据是把每个簇的世界空间几何误差投影到屏幕,看它等于多少像素。
核心代码(NaniteClusterCulling.usf:322):
bool bVisible = ProjectedEdgeScale > UniformScale * LODError * NaniteView.LODScale;
LODError是这个簇离线算出的几何误差(见 Q13,QEM);ProjectedEdgeScale由GetProjectedEdgeScales算出——把簇的包围球中心变换到裁剪空间、透视除法、再把半径投影到屏幕,得到这个簇在屏幕上的投影尺度;- 判断逻辑:如果簇投影到屏幕的误差已经 小于约 1 个像素(误差 × LODScale 低于阈值),说明这一级足够精细、再细看不出差别,就用它;否则继续往 DAG 下层展开更精细的簇。
整个遍历是 GPU 自驱动的(见 Q12 persistent threads):每个簇做这个判断,不满足就把子节点 push 回工作队列让别的线程继续展开,CPU 完全不参与、不回读。
为什么没有 popping(LOD 跳变):传统 LOD 是离散档位(LOD0/1/2),切换时整个模型瞬间换一套网格,肉眼能看到”突变”。Nanite 不一样:
- 判据是连续函数。
ProjectedEdgeScale随摄像机距离平滑变化,不是阶梯。摄像机推近时,切口在 DAG 上一簇一簇地、平滑地下移——这一帧某个簇刚好越过阈值被它的子簇替换,下一帧另一个簇替换,永远不会整个模型一起跳。 - 替换粒度极细。一次只换一个 cluster(~128 三角形),而且新旧簇在边界处因为 LockPosition 是严格对齐的(Q4),替换瞬间几何连续,看不出接缝。
- 误差单调性兜底(Q13):
parentError ≥ childError保证切口良定义,不会出现”父簇和子簇同时被选中”导致的重叠/空洞闪烁。
所以 Nanite 是连续 LOD(continuous LOD)——细节随距离平滑流动,这是它和传统离散 LOD 最直观的区别。
✅ 采分要点(按层级)
- 〔及格〕 *”把每个簇的几何误差投影到屏幕,看它等于多少像素;误差小于约 1 像素就用粗的这一级,否则往下展开更细的。”*
→ 给分理由:抓住”误差投影成屏幕像素”这个判据核心。
- 〔良好〕 *”这其实是在 DAG 上找一条’切口’——切口经过的那一圈簇就是本帧 LOD,不是选某个固定档。”*
→ 给分理由:理解 LOD 选择的几何本质(DAG cut),而非”挑一档”。
- 〔优秀〕 *”没有 popping 的根因:判据是连续函数(随距离平滑),一次只换一个簇(~128 三角),且新旧簇边界因 LockPosition 严格对齐。”*
→ 给分理由:能把”无 popping”归到三个具体机制,而非笼统说”过渡平滑”。
- 〔加分〕 *”整个遍历是 GPU 自驱动的(persistent threads + 工作队列,CPU 不回读);误差单调性保证切口良定义(Q13)。”*
→ 给分理由:点出 GPU-driven 和单调性兜底,理解到系统层面。
⛔ 雷区(错在哪 / 为什么错 / 正确是什么)
- “CPU 每帧算好每个物体的 LOD,再发给 GPU 渲染”
– 错在哪:Nanite 的 DAG 遍历和 LOD 选择全在 GPU 上自驱动(persistent threads + ExecuteIndirect),CPU 不参与、不回读。
– 正确:CPU 只提交一次,GPU 自己遍历选切口。
- “无 popping 是因为做了淡入淡出 / dithering 过渡”
– 错在哪:那是传统离散 LOD 用来掩盖跳变的手段;Nanite 压根没有”两档之间过渡”,因为判据本身连续、一次只换一簇。
– 正确:连续判据 + 细粒度替换,从机制上就没有跳变。
- “给整个物体选一个 LOD 级别”
– 错在哪:Nanite 是逐簇(per-cluster)选 LOD,同一物体近的部位细、远的部位粗,可以同时存在不同细节。
– 正确:切口在 DAG 上是一圈不同深度的簇,不是一个全局档。
🕳️ 似是而非陷阱(听起来对,实则半懂)
- “Nanite 用屏幕占比 / 距离选 LOD。” —— 半懂点:方向对但不精确。判据是几何误差投影到屏幕的像素大小(误差驱动),不是单纯按距离或包围盒屏占比——这样才能做到”误差<1像素就停”的精确控制。
- “LOD 切换在 CPU 上根据相机距离触发。” —— 半懂点:这是传统引擎做法;Nanite 是 GPU 每帧逐簇重算切口,没有 CPU 侧的”切换事件”。
- “连续 LOD 就是 LOD 档位特别多。” —— 半懂点:不是”档多到看不出”,是判据本身是连续函数、替换粒度是单簇,本质上没有”档”的概念。
🔍 追问链(面试官逐层下探)
- “误差投影成屏幕像素”具体怎么算? → 把簇的包围球中心变换到裁剪空间、透视除法,再把几何误差按这个比例投影(
GetProjectedEdgeScales),与阈值比(NaniteClusterCulling.usf:322)。 - 切口是什么?为什么叫切口? → DAG 从粗(上)到细(下),切口是一条把”够精细的”和”还太粗的”分开的横切线,切口上的那圈簇就是本帧要画的。
- 遍历怎么在 GPU 上自驱动? → persistent threads 从工作队列原子取候选节点,不满足就把子节点 push 回队列让别的线程继续,靠 ExecuteIndirect,无 CPU 回读(Q5 正文 / 运行时管线)。
- 为什么必须误差单调? → 否则切口可能同时选中父簇和子簇,导致几何重叠或空洞(Q13)。
<a name=”q6″></a>
Q6. 两阶段遮挡剔除是什么?为什么要两阶段?camera cut 会怎样,5.7 怎么缓解?

满分答案:
先说为什么需要两阶段——这是一个循环依赖问题。 遮挡剔除(occlusion culling)要判断一个物体是否被前面的几何遮挡,需要一张深度图(HZB,Hierarchical Z-Buffer,层级化的最近/最远深度金字塔)。但深度图本身要先把场景光栅化才能得到——而光栅化恰恰是遮挡剔除想要减少的工作,二者互为前提,形成循环依赖。
Nanite 的解法(NaniteCullRaster.cpp,Configuration.bTwoPassOcclusion):
Pass 1(Main)—— 用上一帧的 HZB:
- 拿上一帧构建的 HZB(
PrevHZB,:6354)做遮挡测试(FBoxCull::HZB(),NaniteCullingCommon.ush:474-542)。把实例 AABB 投影到屏幕,采样 HZB 对应 mip 的最近深度,inverted-Z 下Rect.Depth >= MinDepth则判定可见。 - 通过的簇正常光栅化;被判定遮挡的实例不直接丢弃,而是记入
OccludedInstances缓冲(延迟复核),因为上一帧的遮挡信息对本帧不一定准。
中间步骤——构建本帧 HZB:BuildHZBFurthest(:6618)用 Pass 1 光栅出的本帧深度(融合场景深度)构建一张新的、本帧的 HZB。
Pass 2(Post)—— 用本帧的 HZB:
- 只处理
OccludedInstances里那些延迟复核的实例,用本帧新 HZB 重新测一遍。 - 新 HZB 下仍被遮挡的,最终剔除;露出来的(实际可见)升级,写
PostRasterizeArgsSWHW,再补一次光栅化。
为什么有效:得益于帧间连贯性——绝大多数物体的可见性在相邻帧基本不变,所以 Pass 1 用上一帧 HZB 就剔对了,只有少数处于遮挡边缘的实例需要进 Pass 2 复核。开销主要花在 Pass 1,Pass 2 只处理小部分。
camera cut 的问题:当摄像机瞬间切镜头(cut,不是平滑移动)时,上一帧的 HZB 对应的是完全不同的视角,彻底失效。Pass 1 用这张失效的 HZB 几乎剔不掉任何东西 → 第一帧大量本应被遮挡的物体被误判为可见,全部进入光栅化 → 光栅化负载暴涨,出现明显的卡顿尖峰(hitch)。
5.7 的缓解 —— HZB priming:在 camera cut 这种 HZB 不可信的场景,提前用一个粗略 pass 预热(prime)HZB,给 Pass 1 一个大致正确的遮挡信息,避免首帧因缺乏有效 HZB 而全量光栅导致的尖峰。相关逻辑在 5.7 新增的剔除路径里(配合 NaniteHZBCull.ush 的采样策略,HZB 采样用 4×4 gather、MipLevelForRect 按投影尺寸自动选 mip)。
✅ 采分要点(按层级)
- 〔及格〕 *”遮挡剔除需要深度图 HZB,但 HZB 又得先光栅化才有——所以先用上一帧的 HZB 起步。”*
→ 给分理由:点出 HZB 与光栅化互为前提的循环依赖,并知道用上一帧 HZB 打破它。
- 〔良好〕 *”两个 pass:Pass1 用上帧 HZB 剔除、被判遮挡的实例先延迟复核记下来;中间用本帧深度 BuildHZB;Pass2 用本帧 HZB 把延迟复核的复核一遍。”*
→ 给分理由:完整还原两阶段流程,且知道延迟复核不是丢弃。
- 〔优秀〕 *”它有效是因为帧间连贯——多数物体可见性相邻帧不变,Pass1 用上帧 HZB 就剔对了,只有遮挡边缘的进 Pass2。camera cut 时上帧 HZB 失效,会出光栅化尖峰。”*
→ 给分理由:能解释”为什么这套近似是对的”,并意识到 cut 的破绽。
- 〔加分〕 *”5.7 用 HZB priming 缓解 cut 尖峰;HZB 采样用 4×4 gather、MipLevelForRect 按投影尺寸选 mip;VSM 也用这套但 HZB 是页式的。”*
→ 给分理由:知道 5.7 的补丁和采样细节,覆盖到 VSM 变体。
⛔ 雷区(错在哪 / 为什么错 / 正确是什么)
- “直接拿上一帧的深度图剔除就行,不用两 pass”
– 错在哪:上帧 HZB 对本帧不完全准(物体移动/相机移动),单 pass 会误剔该显示的东西。
– 正确:被上帧判遮挡的要延迟复核,再用本帧 HZB 复核(Pass2)才不漏。
- “用上一帧 HZB 会导致渲染错误”
– 错在哪:忽略了 Pass2 复核这一步——延迟复核机制正是为了纠正上帧 HZB 的不准。
– 正确:Pass1 只是粗筛,Pass2 用本帧 HZB 兜底,最终结果正确。
- “延迟复核的实例就是被剔除了 / 就是可见的”
– 错在哪:延迟复核 = 暂不下结论,延迟到 Pass2 用新 HZB 再判。
– 正确:它既没被剔也没被确认,是待复核状态。
🕳️ 似是而非陷阱(听起来对,实则半懂)
- “Nanite 用 HZB 做遮挡剔除,和传统引擎一样。” —— 半懂点:HZB 遮挡剔除不新鲜,Nanite 的关键是两阶段 + 延迟复核机制解决”HZB 来自本帧光栅、又要驱动本帧光栅”的循环依赖,并用本帧 HZB 复核纠错。
- “camera cut 卡顿是因为加载资源。” —— 半懂点:这里的尖峰是遮挡剔除失效导致光栅化负载暴涨,不是 IO/流送。上帧 HZB 失效 → Pass1 剔不掉东西 → 全量光栅。
- “Pass2 把整个场景重新剔一遍。” —— 半懂点:Pass2 只处理 Pass1 里延迟复核的那一小部分,不是全量重来,否则就失去两阶段的意义了。
🔍 追问链(面试官逐层下探)
- 为什么不能只用一帧、一个 pass? → 遮挡剔除要 HZB,HZB 要先光栅化;单 pass 没有本帧 HZB 可用,只能拿上帧的,而上帧不准会误剔。
- 中间的 BuildHZB 用什么数据? → Pass1 光栅出的本帧深度(融合场景深度),构建本帧的 HZB 金字塔(
BuildHZBFurthest)。 - camera cut 具体为什么失效? → 上帧 HZB 是旧视角的深度,cut 后视角全变,它对新视角的遮挡关系毫无参考价值 → Pass1 几乎剔不掉 → 光栅暴涨。
- HZB priming 怎么缓解? → cut 帧提前用一个粗 pass 预热出一张大致可用的 HZB,给 Pass1 使用,避免首帧因无有效 HZB 而全量光栅。
追问准备:VSM(虚拟阴影贴图)也用这套两阶段,但 HZB 是页式的(
CacheManager::GetPrevBuffers()取上帧页表),且 5.7 区分静态/动态几何的遮挡缓存。
架构权衡 + 渲染架构视野(Q7–Q11)
<a name=”q7″></a>
Q7. UE4 老项目升级到 UE5 启用 Nanite,哪些资产不该开?你会制定什么资产规范?

满分答案:
这道题的关键不是背 Nanite 的限制清单,而是把它转化成一份美术能照着执行的逐资产规范。我会先明确”绝对不开”的硬性排除项,再划”评估区”,最后给验收手段。
绝对不开(硬限制,开了要么报错要么明显不划算):
- 半透明材质:Nanite 只支持 Opaque / Masked,Translucent 会被直接拒绝。玻璃、水、很多 alpha 混合的旧植被叶片都在此列。
- Morph Target:需要面部表情 / blend shape 的角色头部网格不能用 Nanite(不支持 morph)。
- 大三角形 + 极少实例:典型是天空球(sky sphere)、大地面 decal 平面——三角形本就少,Nanite 的固定开销(剔除/VisBuffer/ShadeBinning,见 Q8)摊不掉,纯亏。
- 需要超出平移/旋转/缩放的复杂顶点形变的网格(除非走 5.7 的 Skinning/Tessellation 专门路径)。
评估区(看场景,不能一刀切):
- Masked 材质:虽然支持,但有 per-pixel 的
EvaluatePixel开销(alpha test / PDO),大面积草地这种旧 alpha 做法要实测,可能不如改用 5.7 Nanite Foliage 体素路径。 - 低模简单场景:见 Q8 的盈亏点,可能亏。
规范该包含的东西(这是 Tech Lead 的活):
- 给美术一张开/关判据表(按上面的决策树),而不是口头”差不多都开”。
- 说明迁移成本:资产要重新烘焙成 Nanite 格式,
.uasset会变大,运行时依赖 SSD 流送——要提前和发行/QA 对齐最低配置。 - 提协同收益:开了 Nanite 才能充分发挥 VSM(虚拟阴影贴图)和 Lumen HWRT(见 Q10),所以主场景几何尽量开。
- 验收手段:用
r.Nanite.Visualize(triangles / clusters / overdraw 等可视化模式)逐场景检查,重点看 overdraw 视图确认没有意外的高开销区。
✅ 采分要点(按层级)
- 〔及格〕 *”半透明的肯定不能开,Nanite 只支持不透明的;还有天空球那种大三角形的也别开。”*
→ 给分理由:抓住了最核心的两条硬约束(半透明不支持、大三角无收益),方向完全正确,知道 Nanite 不是万能开关,这是及格线。
- 〔良好〕 *”为啥天空球不开?因为 Nanite 有一笔跟几何复杂度无关的固定开销,天空球就几个三角形,光栅本来就便宜,开了反而白白付那笔固定成本摊不平;Morph Target 也不行,因为 Nanite 走的是预烘焙的 cluster DAG,逐顶点的 Morph 形变跟它的静态簇模型对不上。”*
→ 给分理由:不只是背清单,能讲出”为什么不开”的机制——固定开销摊销逻辑、cluster DAG 与动态形变的冲突,说明理解了 Nanite 的工作前提而非死记。
- 〔优秀〕 *”我会分三档:绝对不开(半透明/Morph/大三角少实例/复杂顶点形变)、需评估(Masked 因为有 per-pixel EvaluatePixel 开销、低模场景要看盈亏交叉点)、推荐开(高密度静态网格)。而且要算迁移成本——重烘焙、.uasset 变大占 SSD,但收益是开了 Nanite 才能真正发挥 VSM 和 Lumen,这是协同账不是单点账。”*
→ 给分理由:把问题升级成”决策框架”而非”黑白名单”,引入了灰色评估区(Masked/低模盈亏点),还做了成本-收益的辩证权衡,并指出 Nanite 的价值要放在整条管线里看,这是资深视角。
- 〔加分〕 *”复杂顶点形变默认不开,但 5.7 之后 Nanite Skinning 和 Tessellation 落地了,蒙皮和曲面细分这类可以重新评估;验收我会用 r.Nanite.Visualize overdraw 看实际 overdraw,而不是拍脑袋判断该不该开。”*
→ 给分理由:给出了版本演进的精确边界(5.7 Skinning/Tessellation 改变了”复杂形变不开”的结论)和工程化验收手段(r.Nanite.Visualize),是实现级、可落地的证据,超出纯理论。
⛔ 雷区(错在哪 / 为什么错 / 正确是什么)
- “Nanite 是无脑全开的,UE5 官方就是让你所有静态网格都开 Nanite。”
– 错在哪:Nanite 有与几何无关的固定开销和内存代价,低模/大三角/少实例场景开了是净亏;而且半透明、Morph 等根本不支持。
– 正确:要按”绝对不开/需评估/推荐开”分档判断,低模场景存在盈亏交叉点,不是一刀切全开。
- “半透明开 Nanite 没问题,只要材质调一下混合模式就行。”
– 错在哪:Nanite 只支持 Opaque 和 Masked,半透明(Translucent)根本不在支持范围内,不是调混合模式能解决的,强行赋给 Nanite 网格的半透明材质会被回退或报错。
– 正确:半透明物体保持传统网格管线,Nanite 处理不了 Translucent 排序与混合。
- “开 Nanite 不用重新烘焙,运行时自动转换就好,资产也不会变大。”
– 错在哪:Nanite 需要离线构建 cluster 层级和压缩分页数据,是要重烘焙的,且 .uasset 会显著变大(多了几何流送数据),对存储和 SSD 有要求。
– 正确:开 Nanite 有实打实的迁移成本——重烘焙 + 资产膨胀 + 依赖 SSD 流送,必须纳入升级评估。
🕳️ 似是而非陷阱(听起来对,实则半懂)
- “Masked 材质不能开 Nanite,因为 Nanite 只支持 Opaque。” —— 半懂点:把 Masked 误归到”绝对不开”。实际 Nanite 是支持 Masked 的,只是 Masked 有 per-pixel 的 EvaluatePixel(裁剪测试)开销,所以它属于”需评估”档而不是”不支持”档,区分在于”有额外开销”≠”不支持”。
- “天空球不开是因为它是背景,背景不重要所以不开。” —— 半懂点:理由错了。不开天空球的真正原因是它三角形极少、几何简单,Nanite 的固定开销摊不平,光栅成本本来就低;跟”是不是背景/重不重要”无关,一个高密度的背景山体照样该开。
- “Morph Target 不能开是因为 Nanite 不支持动画。” —— 半懂点:说法过宽。Nanite 在 5.7 已支持 Skinning(骨骼蒙皮动画),并不是”不支持动画”;Morph 不行的精确原因是逐顶点形变与 Nanite 预烘焙的静态 cluster 结构不兼容,不能笼统说成”不支持动画”。
🔍 追问链(面试官逐层下探)
- 那笔”固定开销”具体指什么,为什么它让大三角少实例的物体不划算? → 指与屏幕分辨率相关、与几何无关的 pass(剔除、DAG 遍历、VisBuffer 等);大三角少实例本身光栅就便宜,省不下多少,却要照付这笔固定成本,所以净亏。
- Masked 既然能开,为什么还要单独评估?开销来自哪? → Masked 有 per-pixel 的 EvaluatePixel(执行裁剪/clip 测试)开销,在 Nanite 的延迟管线里这部分不能像 Opaque 那样完全省掉,密集 Masked(如植被叶片)要算 overdraw 账,所以归到评估档。
- 你说低模有盈亏交叉点,怎么界定一个低模到底该不该开? → 看几何密度、实例数、overdraw 以及是否吃 VSM/Lumen;密度低于交叉点左侧传统管线划算,右侧 Nanite 碾压,可用 r.Nanite.Visualize overdraw 和实测帧时间定位交叉点。
- 升级时你怎么向团队论证开 Nanite 值得,哪怕单看某个资产是亏的? → 算协同账:开 Nanite 才能让 VSM 高分辨率阴影、Lumen 全局光照真正发挥,几何密度是这两者的设计前提;单资产的固定开销要放在整条管线的收益里摊,而不是孤立看一个网格的盈亏。
追问准备:若问”怎么量化判断某资产开 Nanite 是赚是亏”——引到 Q8 的成本结构 + 用
stat GPU/ Visualize overdraw 实测对比。
<a name=”q8″></a>
Q8. Nanite 的固定开销和内存代价来自哪里?全是低模的场景开 Nanite 是赚还是亏?

满分答案:
Nanite “渲染成本与三角形数解耦”是真的,但解耦不等于免费——它把成本从”随三角形数线性增长”换成了”一笔与几何无关的固定开销 + 极平缓的增长”。要辩证看。
固定开销来自哪里:Nanite 的管线里有好几个 pass 是与屏幕分辨率相关、与几何复杂度几乎无关的固定成本:
- GPU 驱动的剔除/DAG 遍历 pass(persistent threads);
- 写 VisBuffer64(全屏 atomic);
- 延迟着色的 ShadeBinning(按屏幕像素分箱、散写)。
这些 pass 不管场景里是 1 万还是 10 亿三角形,开销基本恒定(取决于分辨率)。
内存代价:压缩的 cluster 层级数据 + 流送 page 池。.uasset 会比传统网格大,而且 Nanite 假设存储是 SSD(低延迟随机读)才能流送顺畅。
全是低模的场景开 Nanite——大概率亏。因为:低模场景三角形本来就少,传统光栅化已经很便宜,Nanite 省不下多少光栅开销,却要白白多付那笔固定 pass + 内存。如图:传统管线成本曲线起点低、随几何陡升;Nanite 起点高(固定开销)、几乎平坦。两条线有个盈亏交叉点,交叉点左边(低密度)传统更划算,右边(高密度)Nanite 碾压。
判断维度(决定一个场景/资产落在交叉点哪边):
- 几何密度(三角形/像素)、实例数、overdraw 程度、是否吃 VSM。
甜区:film-quality 高模资产、密集植被、海量实例——几何越复杂,Nanite 相对传统的优势越大。这才是它被设计出来要解决的场景。
✅ 采分要点(按层级)
- 〔及格〕 *”Nanite 有一笔固定开销,跟你模型有多少面没关系,主要是那些剔除、光栅的 pass;内存上是它压缩后的几何数据,资产会变大。”*
→ 给分理由:抓住了两个核心结论——固定开销与几何无关、内存来自压缩几何数据,方向对,知道这笔开销的存在性,及格。
- 〔良好〕 *”那笔固定开销是跟屏幕分辨率走的、跟几何无关,比如剔除、DAG 遍历、VisBuffer 的 atomic 写、还有 ShadeBinning,这些不管你模型几面都要跑;内存是因为它要存压缩的 cluster 层级,还要流送 page,所以 .uasset 比传统的大。”*
→ 给分理由:能把固定开销拆成具体的 pass(剔除/DAG/VisBuffer atomic/ShadeBinning),并点明”与分辨率相关、与几何无关”这个关键性质,内存侧也讲到了 cluster 层级 + page 流送,知其所以然。
- 〔优秀〕 *”低模大概率是亏的——它本来光栅就便宜,你省不下那点光栅成本,却要白白付一整套固定 pass 加内存。这里有个盈亏交叉点:横轴是几何密度,交叉点左边低密度传统管线划算,右边高密度 Nanite 直接碾压。判断要看几何密度、实例数、overdraw、还有是不是吃 VSM。”*
→ 给分理由:把”低模是赚是亏”讲成了一条盈亏曲线 + 交叉点的辩证模型,而不是简单回答亏或赚,还给出了多维判断标准,说明真正理解了 Nanite 的成本结构在什么条件下被摊平。
- 〔加分〕 *”VisBuffer64 用的是 64 位 atomic 把深度和可见性 ID 一次原子写进去,这就是为什么 Nanite 硬性依赖 64 位 atomic;ShadeBinning 是把像素按材质分箱再着色,避免延迟着色阶段的发散。甜区是 film-quality 资产、密集植被、海量实例——这些场景几何密度高到固定开销完全被摊平。”*
→ 给分理由:能讲到 VisBuffer64 用 64 位 atomic 的实现细节并关联到硬件依赖,解释 ShadeBinning 的目的(减少着色发散),还准确圈出 Nanite 的甜区场景,是实现级证据。
⛔ 雷区(错在哪 / 为什么错 / 正确是什么)
- “Nanite 的开销是跟三角形数量成正比的,面越多越贵。”
– 错在哪:恰恰相反,Nanite 的设计目标就是让开销与几何复杂度近乎解耦——它的主要 pass 是与屏幕分辨率相关、与几何无关的,几亿面和几百万面的固定 pass 成本接近。
– 正确:固定开销随分辨率走而非随三角数走,这正是 Nanite 能”海量三角形几乎免费”的根因。
- “低模开 Nanite 肯定更快,因为 Nanite 比传统光栅先进。”
– 错在哪:低模本身传统光栅就极便宜,Nanite 省不下这点光栅成本,反而要付那套固定 pass 和额外内存,所以低模大概率更慢。
– 正确:存在盈亏交叉点,只有几何密度高过交叉点,Nanite 才划算,低模通常落在传统管线划算的一侧。
- “Nanite 不占额外内存,反而因为压缩更省内存。”
– 错在哪:虽然几何被压缩,但 Nanite 要额外存 cluster 层级结构和流送 page 池,.uasset 整体是变大的,且依赖 SSD 做流送。
– 正确:Nanite 内存代价是真实存在的(压缩 cluster + page 池 + 资产膨胀),它是用磁盘和流送换运行时的几何吞吐,不是净省内存。
🕳️ 似是而非陷阱(听起来对,实则半懂)
- “固定开销主要是 cluster 的剔除,面越多 cluster 越多剔除就越贵。” —— 半懂点:把固定开销又偷偷绑回了几何量。Nanite 的剔除是 GPU 上分层、persistent threads 批处理的,开销主要跟屏幕/分辨率和可见 cluster 规模相关,整体被设计成与总几何量近乎解耦,不是”面越多线性越贵”。
- “内存变大是因为 Nanite 不做 LOD,要把所有 LOD 都存下来。” —— 半懂点:因果反了。Nanite 是连续 LOD,不存离散 LOD 链;.uasset 变大是因为要存压缩的 cluster DAG 层级和流送 page 数据,恰恰是因为它统一了 LOD 才需要这套层级结构。
- “ShadeBinning 是用来做剔除的,把看不见的像素剔掉。” —— 半懂点:搞混了职责。ShadeBinning 是延迟着色阶段把像素按材质分箱、减少着色发散提升 GPU 效率,不是可见性剔除;剔除是更早的 cluster/三角阶段做的事。
🔍 追问链(面试官逐层下探)
- 你说固定开销与几何无关、与分辨率相关,那把渲染分辨率翻倍,Nanite 开销大概怎么变? → 与分辨率相关的 pass(VisBuffer 写、ShadeBinning、逐像素部分)会随像素数大致线性上升,而 DAG 遍历/剔除这类与几何相关的部分变化不大,所以总体随分辨率涨而对加面不敏感。
- VisBuffer64 为什么必须用 64 位 atomic,拆成两个 32 位行不行? → 它要把深度和可见性 ID 打包进一个 64 位值做原子 min/写,保证深度竞争和 ID 写入是同一次原子操作不被撕裂;拆成两个 32 位无法保证两者原子一致,会出现深度和 ID 错配,所以硬依赖 64 位 atomic。
- 同样一个低模,放一个和实例化十万个,结论会不会变? → 会。单个低模亏,但海量实例时 Nanite 的 GPU 实例剔除和共享几何数据让每实例边际成本极低,固定开销被巨量实例摊平,可能反超传统管线,所以判断维度里实例数和 overdraw 很关键。
- 为什么说密集植被是甜区,它不是有很多 Masked 和 overdraw 吗? → 密集植被几何密度和实例数都极高,正好把固定开销摊平;虽然 Masked 有 EvaluatePixel 开销和 overdraw,但 Nanite 的 cluster 剔除 + 连续 LOD 能大幅削减实际着色的三角和过度绘制,综合下来仍然是 Nanite 碾压传统的场景。
<a name=”q9″></a>
Q9. Nanite Skinning 的实现思路?和传统骨骼网格的根本区别?Foliage 为什么用骨骼模拟风而非 WPO?

满分答案:
Skinning 的实现思路:Nanite Skinning 在 GPU 上、在剔除和光栅之前对顶点做骨骼蒙皮变换——每帧 deform,把骨骼矩阵作用到顶点位置/法线/切线上(DecodeVertexBoneInfluence 从位流读骨骼索引+权重,MAX_CLUSTER_BONE_INFLUENCES=16)。变形后的顶点再进入 Nanite 标准的 cluster culling → 光栅 → VisBuffer → 延迟着色管线。
和传统骨骼网格渲染的根本区别:传统是”skinning 出顶点 buffer → 走普通网格管线 + 固定 LOD”。Nanite 是”deform 后的顶点复用 Nanite 全套能力“——连续 LOD、VisBuffer 延迟着色、海量实例都还在。还有个关键细节:它维护双帧 transform 缓冲(当前帧 + 前一帧),前一帧用于算速度/运动矢量(TSR、运动模糊需要)。
Foliage 为什么用骨骼模拟风,不用 WPO:
- WPO(World Position Offset,顶点偏移)在 Nanite 里代价高且受限——Nanite 的几何形变本就受限;
- 改用 bone hierarchy 驱动风,直接复用 Skinning 路径,统一、省;
- 更本质的:Nanite Foliage 走 Voxel 路径时,体素是即时从深度重构的,根本没有”顶点阶段”,WPO 无从施加 → 只能用骨骼。
配套要点:
- Voxels / SVO:亚像素三角形切换成近像素体素,每体素存法线分布保证光照合理、保留树叶体积感。
- 实例化:唯一几何存一份引用上千次(一棵树 3.5GB → ~29MB)。
- 距离剔除蒙皮:
AnimationMinScreenSize控制——bActiveSkinning = bSkinning && bIsDeforming && bEnableSkinning,远处的树/角色不做蒙皮(省开销),用静态体素或几何 LOD;蒙皮激活和剔除是解耦的(NaniteSkinningUpdateViewData.usf)。 - ⚠ 成熟度:整个 Nanite Foliage 在 5.7 仍是 Experimental,物理/碰撞/风动画支持不完整,生产慎用(这点见 Q21 的森林系统设计陷阱)。
✅ 采分要点(按层级)
- 〔及格〕 *”Nanite Skinning 就是在 GPU 上对顶点做蒙皮形变,变形完再走 Nanite 那套;植被用骨骼模拟风是因为 WPO 在 Nanite 里不好用。”*
→ 给分理由:抓住了两个核心——蒙皮在 GPU 上 deform 后复用 Nanite 管线、植被走骨骼而非 WPO,方向对,及格。
- 〔良好〕 *”它是在剔除和光栅之前,每帧在 GPU 上把顶点 deform 掉,读骨骼索引和权重做蒙皮,变形后的顶点再进 Nanite 标准管线;跟传统的区别是传统蒙皮出一个顶点 buffer 走普通管线、还是固定离散 LOD,Nanite deform 完能复用连续 LOD、VisBuffer、海量实例那一整套。”*
→ 给分理由:讲清了 Skinning 在管线中的位置(剔除/光栅之前、逐帧 GPU deform),并准确对比了传统蒙皮(顶点 buffer + 固定 LOD)与 Nanite(复用全套连续 LOD/VisBuffer),知其所以然。
- 〔优秀〕 *”植被为什么不用 WPO 而用骨骼?因为 WPO 在 Nanite 里代价高、还受限,干脆复用已经做好的 Skinning 形变路径;而且对体素化的远处植被来说,体素路径根本就没有顶点阶段,WPO 这种逐顶点的东西无从谈起,骨骼蒙皮反而是统一的形变入口。还有为了算运动矢量,它存了当前帧和前一帧两套 transform。”*
→ 给分理由:把”为何用骨骼非 WPO”讲透到三层——WPO 代价高、复用 Skinning 路径、体素路径无顶点阶段,还补了双帧 transform 做速度/运动矢量的机制,体现了辩证和系统理解。
- 〔加分〕 *”实现上 DecodeVertexBoneInfluence 读每个顶点的骨骼索引和权重,每簇骨骼影响数有上限 MAX_CLUSTER_BONE_INFLUENCES=16;配套优化有体素每个体素存法线分布、实例化把 3.5GB 压到 29MB、还有 AnimationMinScreenSize 在远处按屏幕尺寸把蒙皮剔掉省算力。不过 Foliage 在 5.7 还是 Experimental,物理碰撞不完整。”*
→ 给分理由:给出了实现级符号(DecodeVertexBoneInfluence、MAX_CLUSTER_BONE_INFLUENCES=16)、量化的内存收益(3.5GB→29MB)、距离剔除蒙皮的具体机制(AnimationMinScreenSize),还点明 5.7 的 Experimental 状态与碰撞短板,证据扎实且有版本边界。
⛔ 雷区(错在哪 / 为什么错 / 正确是什么)
- “Nanite Skinning 是在 CPU 上算好蒙皮,再把变形顶点传给 GPU。”
– 错在哪:Nanite Skinning 的关键就是在 GPU 上、剔除和光栅之前每帧做顶点 deform,不走 CPU 蒙皮再上传那条老路。
– 正确:GPU 逐帧 deform(DecodeVertexBoneInfluence 读索引权重),变形后直接进 Nanite 管线,避免 CPU-GPU 的顶点回传。
- “Nanite 蒙皮后还是用固定的几档 LOD,跟传统骨骼网格 LOD 一样。”
– 错在哪:传统蒙皮才是固定离散 LOD;Nanite 的价值正是 deform 后复用连续 LOD(cluster 级),不存在手摆的几档 LOD。
– 正确:变形后的顶点进 Nanite 标准管线,享受连续 LOD、VisBuffer、海量实例,这是它和传统蒙皮的根本差异。
- “植被随风摆动当然该用 WPO,骨骼是给角色用的,植被用骨骼是舍近求远。”
– 错在哪:在 Nanite 体系里 WPO 代价高且受限,而骨骼能复用现成的 Skinning 形变路径;更关键的是远处体素化植被根本没有顶点阶段,WPO 无处施加。
– 正确:用骨骼模拟风是为了统一形变入口、复用 Skinning,并兼容体素路径,不是舍近求远,而是更贴合 Nanite 架构的选择。
🕳️ 似是而非陷阱(听起来对,实则半懂)
- “Nanite Skinning 就是把传统的 GPU Skinning 直接搬过来用。” —— 半懂点:传统 GPU Skinning 输出的是一个顶点 buffer 然后走普通光栅、固定 LOD;Nanite 的区别在于 deform 之后接的是整套 Nanite 管线(连续 LOD/VisBuffer/海量实例),形变只是入口,复用的下游才是关键,不是简单搬运。
- “存当前帧和前一帧两套 transform 是为了做动画插值,让动作更平滑。” —— 半懂点:双帧 transform 不是为插值平滑,而是为了算运动矢量(速度),供 TAA、运动模糊这些时域/后处理用;蒙皮物体每帧顶点都在动,没有前一帧位置就算不出正确的 motion vector。
- “植被远处用体素是为了省面,体素里照样可以挂 WPO 让它摆动。” —— 半懂点:体素路径根本没有顶点阶段,WPO 是逐顶点偏移的,在没有顶点的体素表示上无从施加;远处植被的”风”是靠复用骨骼蒙皮的形变路径,不是在体素上挂 WPO。
🔍 追问链(面试官逐层下探)
- 蒙皮形变插在管线哪个位置,为什么必须在那之前做? → 在剔除和光栅之前每帧 GPU deform;因为剔除、连续 LOD 选择、光栅都要基于变形后的真实顶点位置,如果蒙皮在后面,剔除和 LOD 就用错了几何,所以必须先 deform 再进标准管线。
- DecodeVertexBoneInfluence 读什么,MAX_CLUSTER_BONE_INFLUENCES=16 这个上限意味着什么? → 读每个顶点的骨骼索引和蒙皮权重;16 是每个 cluster 内允许的骨骼影响上限,限制了单簇能引用的骨骼数量,便于打包和 GPU 上高效解码,超过就要拆簇或裁剪影响。
- 双帧 transform 具体喂给谁、解决什么问题? → 喂给运动矢量计算,输出 motion vector 给 TAA/TSR 和运动模糊;蒙皮顶点每帧都在动,必须用当前帧和前一帧的 transform 差求出屏幕空间速度,否则时域抗锯齿会拖影或闪烁。
- 远处植被体素化为什么能从 3.5GB 压到 29MB,体素里怎么表现光照细节? → 靠实例化共享 + 体素表示替代海量三角,几何数据量骤降;体素每个体素存法线分布来近似表面朝向,从而在没有原始顶点法线的情况下仍能给出合理的着色,所以省内存又不丢光照大致表现。
追问准备:深问体素实现细节引到 Q17,深问蒙皮管线引到 Q18。
<a name=”q10″></a>
Q10. Nanite、Lumen、VSM 三者什么关系?为什么”配套”?Lumen 在 5.6/5.7 走向?

满分答案:
这三者不是三个独立勾选框,而是 UE5 渲染管线互相设计的一套基座——核心是 Nanite 提供了前所未有的几何密度,VSM 和 Lumen 都是为”消化这种密度”而生的。
- Nanite:海量几何 + 连续 LOD + VisBuffer 延迟着色。
- VSM(Virtual Shadow Maps):页式的高分辨率阴影。传统 shadow map 分辨率撑不起 Nanite 的几何细节,VSM 的虚拟分页机制正是为 Nanite 的密度设计的——所以开了 Nanite 才能充分发挥 VSM,反之 VSM 的开销也只有在 Nanite 几何密度下才划算。
- Lumen:动态全局光照 / 反射。
Lumen 5.6/5.7 的技术走向(这是判断知识新鲜度的点):
- 从软件光追(SWRT)转向硬件光追(HWRT);
- 5.7 弃用 SWRT detail traces 路径,目标让 HWRT 跑到 60Hz;
- HWRT 需要真实的 BLAS(加速结构),而 Nanite 几何是压缩的动态 LOD——所以要靠 Nanite StreamOut 导出代理几何来构建 BLAS(见 Q19)。这就是三者在光追层面的耦合点:Lumen HWRT ← Nanite 提供几何。
关联:MegaLights(5.7 Beta)改变了多动态光源的成本模型(海量可投影光源 + 区域光软阴影),和 Nanite/VSM 一起构成 UE5 渲染的新基座。
✅ 采分要点(按层级)
- 〔及格〕 *”它们是配套的,Nanite 出几何,VSM 出阴影,Lumen 出全局光照,一般都一起开。”*
→ 给分理由:抓住了三者协同、各司其职(几何/阴影/GI)的大框架,知道它们是一套而不是孤立功能,及格。
- 〔良好〕 *”核心是 Nanite 提供了超高的几何密度,VSM 和 Lumen 都是为了消化这个密度而生的。VSM 是页式的高分辨率阴影,正是冲着 Nanite 那种密集几何设计的,所以你开了 Nanite 才能真正发挥 VSM。”*
→ 给分理由:能讲出”几何密度”是这条关系链的核心驱动,VSM 是为密度而生的页式阴影,并点明 Nanite 与 VSM 的相互成就关系,知其所以然。
- 〔优秀〕 *”它们是互相为对方设计的基座,不是各自独立的功能拼一起。Lumen 这边 5.6 到 5.7 在从软件光追转硬件光追,5.7 要弃用 SWRT 的 detail traces 去冲 60Hz;但 HWRT 需要真实的 BLAS,而 Nanite 的几何是高度压缩、连续 LOD 的,没法直接拿来做 BLAS,所以要靠 Nanite StreamOut 导出一份代理几何喂给硬件光追。这就是为什么三者必须配套——一个的输出正好是另一个的输入。”*
→ 给分理由:把”为何配套”上升到”互为基座、输出即输入”的系统观,并准确串起 Lumen 走向 HWRT → 需要 BLAS → Nanite StreamOut 导代理几何这条因果链,体现了对版本演进和数据流的深度理解。
- 〔加分〕 *”5.7 里 Lumen 弃 SWRT detail traces 是为了在主机上稳 60Hz;HWRT 的 BLAS 来源就是 Nanite StreamOut 导出的代理几何,这也是 Q19 那条线。另外 5.7 还有 MegaLights 进 Beta,配合这套密集几何和多光源场景。”*
→ 给分理由:给出了精确的版本细节(5.7 SWRT detail traces 弃用、目标 60Hz、MegaLights Beta)和具体机制名(StreamOut 导 BLAS 代理几何),是实现级、可追溯的证据。
⛔ 雷区(错在哪 / 为什么错 / 正确是什么)
- “Nanite、Lumen、VSM 是三个独立功能,可以随便单独开,互相没什么关系。”
– 错在哪:它们是互相为对方设计的基座,VSM 的页式高分辨率阴影是冲着 Nanite 的几何密度设计的,Lumen 走 HWRT 又要靠 Nanite StreamOut 导出 BLAS 代理几何,关系是强耦合的。
– 正确:三者协同设计,几何密度是核心驱动,单开能用但发挥不出设计意图(如不开 Nanite,VSM 的密度优势无从体现)。
- “Lumen 在 5.7 是从硬件光追转回软件光追,因为软件光追更省。”
– 错在哪:方向反了,5.6/5.7 是从 SWRT 转向 HWRT,5.7 还要弃用 SWRT 的 detail traces 去冲 60Hz。
– 正确:Lumen 的走向是软件光追 → 硬件光追,HWRT 需要真实 BLAS,这反过来要求 Nanite 导出代理几何。
- “HWRT 可以直接拿 Nanite 的几何当 BLAS,不用额外处理。”
– 错在哪:Nanite 的几何是高度压缩、连续 LOD 的运行时表示,硬件光追的 BLAS 需要的是常规三角几何,两者结构不同,不能直接用。
– 正确:要通过 Nanite StreamOut 导出一份代理几何来构建 BLAS(即 Q19 那条线),这正是 Nanite 为 Lumen HWRT 提供输入的关键一环。
🕳️ 似是而非陷阱(听起来对,实则半懂)
- “VSM 是新的阴影技术,它和 Nanite 没绑定,传统网格也一样用 VSM 就够好。” —— 半懂点:VSM 当然能给传统网格出阴影,但它的页式高分辨率设计是专门为了消化 Nanite 那种几何密度而做的;不开 Nanite、几何密度上不去时,VSM 那套页式分配的优势体现不充分,说”没绑定一样够好”是没理解它的设计动机。
- “Lumen 转 HWRT 就是为了画质更好、更真实。” —— 半懂点:画质只是一方面,5.7 弃 SWRT detail traces 的直接目标是性能——在主机上稳住 60Hz;HWRT 也带来对 Nanite StreamOut 代理几何的依赖。只说”更真实”漏掉了 60Hz 性能目标和几何数据流这两条关键动机。
- “三者配套就是 UE5 默认模板把它们都勾上了,所以一起用。” —— 半懂点:把”默认勾选”当成了配套的原因。真正的配套是架构层面互为输入输出——Nanite 提供密度、VSM 为密度设计、Lumen HWRT 吃 Nanite StreamOut 的 BLAS,默认模板勾上只是这个设计意图的结果,不是原因。
🔍 追问链(面试官逐层下探)
- 为什么说”开了 Nanite 才能真正发挥 VSM”,不开 Nanite 时 VSM 差在哪? → VSM 是页式的、按需分配高分辨率阴影页,正是为高几何密度场景设计的;几何密度低时大部分页面用不满,那套页式开销摊不平,优势体现不出来,所以 VSM 的设计收益和 Nanite 的几何密度是绑定的。
- Lumen 从 SWRT 转 HWRT,5.7 具体动了什么、目标是什么? → 5.7 弃用 SWRT 的 detail traces,主要目标是在主机上稳定 60Hz;转向 HWRT 后细节追踪交给硬件光追,这就引入了对真实 BLAS 的需求。
- HWRT 要的 BLAS 从哪来,为什么不能直接用 Nanite 的几何? → 来自 Nanite StreamOut 导出的代理几何(Q19 那条线);Nanite 运行时几何是高度压缩、连续 LOD 的,不是 BLAS 能直接消费的常规三角表示,所以要 StreamOut 出一份代理几何来 build BLAS。
- 除了这三个,5.7 还有什么和这套密集几何/光照配套的东西? → MegaLights 在 5.7 进 Beta,面向多光源、密集几何场景做高效光照采样,和 Nanite 的几何密度 + VSM/Lumen 这套配套,进一步消化”很多光 + 很多几何”的场景。
<a name=”q11″></a>
Q11.(开放题)给自研引擎做一套类 Nanite 虚拟几何系统,怎么规划?最大风险在哪?

满分答案:
这题没有标准答案,看的是能不能把整条链路讲清楚 + 识别真正的硬骨头 + 有成本判断。我会分离线、运行时、依赖、风险四块讲。
离线(构建):
原始 mesh → 切 cluster(图分割,如 METIS)→ DAG 简化(网格简化器 + 边界锁定保 crack-free)
→ 误差度量(QEM,且 parentError 单调)→ 压缩量化编码 + 分页 → Root 页(常驻) + Streaming 页(按需)
运行时(GPU 驱动):
GPU 剔除(persistent threads + work queue + ExecuteIndirect)→ LOD cut 选择(几何误差投影成屏幕像素)
→ 软/硬混合光栅 → VisBuffer → 延迟着色(ShadeBinning,每像素只着一次)
硬件/API 依赖(这些不满足就做不了):
- 64-bit atomic(VisBuffer 的核心)、async compute、Mesh Shader、ExecuteIndirect(GPU-driven)、SSD(流送随机读)。
最大技术风险(这是这题真正的考点):
- 网格简化器的鲁棒性——这是最容易低估的。要稳健处理非流形几何、UV 接缝、退化三角形、自相交……一个不稳健的简化器会在某些资产上生成裂缝或爆三角形。这是 Nanite 数年打磨的核心资产之一。
- 水密性 / 裂缝——定点数光栅 + 边界锁定,任何一处不严谨就满屏闪烁。
- 流送延迟与卡顿——page 调度、camera cut 尖峰(Nanite 都要 HZB priming 来缓解)。
- 与现有管线整合——材质系统、光照、阴影、半透明回退,全要适配。
- 平台兼容——移动端可能没有 64-bit atomic / Mesh Shader,整套打法要降级方案。
务实判断(Tech Lead/Principal 该有的):自研一套生产级虚拟几何 = 数人年投入,简化器 + 水密性 + 管线整合是真正的硬骨头。多数团队的正确答案是”直接用 UE5“或”针对特定场景做轻量裁剪版”,而不是从零复刻 Nanite。能说出这句成本判断,比把技术方案背得再全都重要。
✅ 采分要点(按层级)
- 〔及格〕 *”分离线和运行时两块。离线把网格切成 cluster、做简化和 LOD、压缩存起来;运行时在 GPU 上剔除、选 LOD、光栅。最大风险是网格简化做不好。”*
→ 给分理由:抓住了”离线构建 + 运行时管线”两段式骨架,并点出最大风险在网格简化器,主线方向正确,及格。
- 〔良好〕 *”离线流程是:先切 cluster,可以用图分割比如 METIS;然后用简化器做 DAG 简化、边界要锁定;误差度量用 QEM 保证单调;最后压缩分页,分成 Root 页和 Streaming 页。运行时是 GPU 剔除、LOD cut、软硬混合光栅、VisBuffer、最后延迟着色。硬件上依赖 64 位 atomic、async compute、Mesh Shader 和 SSD。”*
→ 给分理由:能把离线五步(切簇/DAG 简化/误差度量/压缩分页/Root+Streaming 页)和运行时链路完整串起来,还点到 METIS、QEM、边界锁定、软硬混合光栅这些关键机制和硬件依赖,知其所以然。
- 〔优秀〕 *”最大风险我会排个序:第一是网格简化器的鲁棒性,非流形、UV 接缝、退化三角这些最容易被低估,简化器一崩整条管线就废;第二是水密性和裂缝,简化后 cluster 边界对不上会漏光;第三是流送卡顿,camera cut 时会有尖峰;然后才是管线整合和平台兼容——移动端没有 64 位 atomic 这套就跑不起来。为什么 DAG 简化时要锁边界、误差度量要单调,就是为了保证相邻 LOD 之间不裂、cut 的时候不跳变。”*
→ 给分理由:不只列风险,还做了优先级排序并说明”为什么简化器最易低估”,把边界锁定、QEM 单调与”不裂/不跳变”的因果讲透,体现了工程辩证和对失败模式的真实预判。
- 〔加分〕 *”运行时剔除我会用 persistent threads 加 ExecuteIndirect 让 GPU 自驱动;分页设计成 Root 页常驻、Streaming 页按需流送,Root 页保证最低可见质量。误差度量必须满足单调性,否则 LOD cut 在 DAG 上选不出一致的切面会破面。务实地讲,做到生产级是数人年的投入,UV 接缝和非流形处理就能耗掉大半,多数团队的正确选择其实是直接用 UE5 而不是自研。”*
→ 给分理由:给出了实现级机制(persistent threads + ExecuteIndirect 的 GPU 自驱动、Root 页常驻保底质量、单调性与 DAG cut 一致性的关系)和务实的工程判断(数人年成本、多数团队应直接用 UE5),既有深度又有落地清醒度。
⛔ 雷区(错在哪 / 为什么错 / 正确是什么)
- “切 cluster 随便按空间网格切一刀就行,怎么切无所谓,反正后面会简化。”
– 错在哪:cluster 切分质量直接影响后续简化的边界锁定、误差累积和剔除粒度,乱切会导致边界过多、简化时裂缝和误差爆炸。
– 正确:用图分割(如 METIS)按连通性/拓扑切,让 cluster 内聚、边界最少,才能支撑后面 DAG 简化和无裂缝的 LOD。
- “误差度量随便用个数值估一下就好,不用管单调不单调。”
– 错在哪:误差度量若不单调,LOD cut 在 DAG 上就选不出一致的切面,相邻 LOD 之间会跳变、破面。
– 正确:误差度量必须单调(如 QEM 保证沿 DAG 误差单调递增),这样运行时按误差阈值切 LOD 才能得到连续、无跳变的结果。
- “自研这套不难,几个人几个月照着论文写一版就能上生产。”
– 错在哪:严重低估了简化器鲁棒性(非流形/UV 接缝/退化三角)、水密性、流送卡顿、平台兼容这些坑,任何一处不过关都无法上生产。
– 正确:生产级虚拟几何是数人年的投入,光 UV 接缝和非流形处理就能耗掉大半,多数团队务实的选择是直接用 UE5。
🕳️ 似是而非陷阱(听起来对,实则半懂)
- “用 QEM 做简化就够了,QEM 本身就能保证不裂。” —— 半懂点:QEM 解决的是误差度量和单调性,并不自动保证 cluster 之间水密;不裂还要靠 DAG 简化时显式锁定 cluster 边界(边界顶点不被 collapse),QEM 和边界锁定是两件事,只说 QEM 是半懂。
- “运行时就是 GPU 上做剔除选 LOD,跟传统 LOD 没本质区别,只是搬到 GPU。” —— 半懂点:传统 LOD 是离散整模型切换、CPU 决策;这套是 cluster 级连续 LOD,靠 persistent threads + ExecuteIndirect 让 GPU 自驱动地在 DAG 上 cut,粒度、决策位置、连续性都不同,不是简单”搬到 GPU”。
- “切 cluster 用 METIS 是为了切得快,图分割算法效率高。” —— 半懂点:用 METIS 不是图它快,而是要按图连通性切出内聚、边界最少的 cluster,为后续边界锁定简化和剔除粒度服务;切分质量(边界少、内聚高)才是目的,速度不是核心诉求。
🔍 追问链(面试官逐层下探)
- 为什么 DAG 简化时要锁定 cluster 边界,不锁会怎样? → 不锁边界的话相邻 cluster 各自简化后共享边上的顶点会被独立 collapse,导致两侧对不上、出现裂缝漏光;锁定边界让边界顶点在简化时保持一致,保证 cluster 间水密。
- 误差度量的”单调性”具体在运行时解决什么问题? → 保证沿 DAG 从精到粗误差单调,这样运行时按误差阈值做 LOD cut 时能在 DAG 上选出一条一致的切面,相邻 LOD 衔接不跳变、不破面;不单调就可能选出自相矛盾的切割导致裂缝或闪烁。
- Root 页和 Streaming 页为什么要分开,Root 页保证什么? → Root 页常驻内存,保证每个网格都有一个最低可见质量随时可用,避免还没流送到就显示不出来;Streaming 页按相机和屏幕需求流送高细节,分开是为了在保底质量和按需细节之间平衡内存与带宽。
- 为什么移动端这套基本跑不起来,缺的是什么? → 主要缺 64 位 atomic(VisBuffer 那套深度+ID 原子写依赖它),很多移动 GPU 还缺成熟的 async compute 和 Mesh Shader 支持,加上没有 SSD 级流送带宽,所以这套硬件依赖在移动端不成立,跑不起来。
追问准备:若深问”网格简化器具体怎么保证 crack-free 和误差单调”——引到 Q13。
构建 / 着色源码级(Q12–Q16)
<a name=”q12″></a>
Q12. 切簇用什么分割算法?为什么不能按空间网格切?簇大小为什么是 ~128?

满分答案:
Nanite 构建时把 mesh 切成簇,用的是图分割(graph partitioning),不是按空间网格切。
具体说:把三角形的邻接关系建成一张图(三角形是节点,共享边是连接),再对这张图做分割。源码用的是 METIS 库(FGraphPartitioner),约束 MinPartitionSize≈124, Max=128(ClusterDAG.cpp:132-174)。
为什么不能按空间网格切:简单空间网格(比如把包围盒切成格子,三角形落哪格归哪簇)会割裂表面的连通性——同一片连续表面会被网格线切到不同簇里,导致簇边界又多又碎。而后续 LOD 简化时,所有跨簇的边界都要锁定(见 Q4/Q13),边界一多一碎,就几乎没有可简化的内部空间了,LOD 上不去。
图分割不一样:它按拓扑邻接 + 空间局部性来分,让一个簇内的三角形既空间相邻、又拓扑连通,且簇之间的割边最少。源码里还用 Morton code 建立空间局部性链接(每个元素最多 5 条邻近链接),把空间近的三角形优先分到一起。这样每个簇是一块”完整的小表面片”,边界干净,内部能充分简化。
簇为什么是 ~128 三角形:这是个 GPU 友好的粒度——匹配 wave / threadgroup 的大小,也是剔除、光栅化批处理的基本单位(VisBuffer 里 7 位三角形索引正好编码 ≤128,见 Q1)。太大则剔除粒度粗、浪费;太小则管理开销和数据冗余上升。
一个细节:多边共享边的情况要排序确保确定性(ClusterDAG.cpp:72-101),否则构建结果不可复现——这对资产的可重复烘焙很重要。
✅ 采分要点(按层级)
- 〔及格〕 *”切簇是把网格切成一块块, 大概一百多个三角形一簇, 不是随便切的, 要按面之间挨着的关系切。”*
→ 给分理由:方向对, 抓住了”按邻接切而非空间切”和”~128″这两个核心结论, 但没讲为什么。
- 〔良好〕 *”它是把三角形的邻接关系建成一张图, 然后用图分割算法去切, 这样切出来每一簇是一小片连续的表面, 边界比较干净; 如果按空间网格切, 一簇里可能是好几片不连的面, 边界就碎了。”*
→ 给分理由:知其所以然, 说清了图分割保连通性、空间切会割裂表面, 已经能区分两种切法的后果。
- 〔优秀〕 *”关键是后续简化。簇边界上的边在简化时要锁住对齐相邻簇, 边界越碎锁定的边越多, 到最后一簇里全是锁死的边界、没有内部可简化的三角形, LOD 就上不去了。所以必须按拓扑邻接切让边界尽量短、尽量少。128 是 GPU 友好粒度, 对齐 wave/threadgroup, 而且 VisBuffer 里三角形索引留了 7 位, 2^7 正好 128。”*
→ 给分理由:讲透了”为什么不能边界碎”的下游因果链(边界锁定→无内部可简化→LOD 上不去), 并且把 128 同时归因到 GPU 粒度和 VisBuffer 位宽两个角度, 是辩证而非死记。
- 〔加分〕 *”实现上是 FGraphPartitioner, 用的 METIS 那套图分割, MinPartitionSize 大概 124、Max 128, 代码在 ClusterDAG.cpp 一百三十几行。图里还会掺 Morton 码保持空间局部性, 不是纯拓扑。多边共享时按边排序保证确定性, 这样烘焙可复现。”*
→ 给分理由:给到实现级证据(FGraphPartitioner/METIS/MinPartitionSize≈124/ClusterDAG.cpp:132), 并补出 Morton 局部性和确定性排序两个细节, 说明真读过源码而非背要点。
⛔ 雷区(错在哪 / 为什么错 / 正确是什么)
- “按空间八叉树/均匀网格把模型切块, 每块当一个簇, 简单又均匀。”
– 错在哪:空间切会把同一片连续表面切到不同簇、又把不相连的面塞进同一簇, 表面连通性被割裂, 簇边界又多又碎。
– 正确:按三角形邻接建图做图分割, 每簇是一片边界干净的完整小表面。
- “128 就是个经验值, 拍脑袋定的, 改成 256 或 64 也无所谓。”
– 错在哪:128 不是随意值, 它要对齐 GPU 的 wave/threadgroup 粒度, 还和 VisBuffer 中三角形索引的 7 位宽度强绑定(2^7=128)。
– 正确:128 是 GPU 友好且与 VisBuffer 位宽呼应的刻意选择, 动它要连带改索引编码。
- “图分割只看拓扑邻接就行, 空间位置不重要。”
– 错在哪:纯拓扑可能切出空间上很狭长、不紧凑的簇, 不利于包围盒和 culling。
– 正确:分割同时掺入 Morton 码引入空间局部性, 是拓扑邻接 + 空间局部性两者结合。
🕳️ 似是而非陷阱(听起来对,实则半懂)
- “图分割就是为了让每簇三角形数量均匀, 凑够 128。” —— 半懂点:数量均匀只是 GPU 调度的次要诉求, 图分割的首要目的是保表面连通性、让簇边界短而干净, 服务于后续可简化性, 不是为了凑数。
- “簇边界干净是为了渲染时画得快。” —— 半懂点:边界干净的真正收益在简化阶段(锁定的边少→有内部可简化→LOD 能往上走), 不是渲染速度; 把收益安到渲染上是没理解 LOD 构建链路。
- “128 是因为一个 wave 是 128 线程。” —— 半懂点:wave 宽度本身是 32/64, 128 是 threadgroup 量级的匹配, 而真正的硬约束来自 VisBuffer 三角索引 7 位; 只背”wave=128″是把粒度对齐和位宽约束混为一谈。
🔍 追问链(面试官逐层下探)
- 图分割里”图”的节点和边分别是什么? → 节点是三角形, 边是两个三角形共享一条网格边(邻接关系), 边权可结合共享边长度/法线差, 目标是最小化被切断的边即簇边界总长。
- 既然按邻接切, 为什么还要掺 Morton 码? → 纯拓扑会切出空间上狭长的簇, 包围盒松、culling 差; Morton 引入空间局部性让簇既连通又紧凑, 利于 BVH 和遮挡剔除。
- MinPartitionSize 为什么是 124 而不是直接 128? → 留余量, 让分割器在 124~128 区间有自由度去找更干净的切口; 硬卡 128 会逼出更碎的边界, 上限 128 是 VisBuffer 索引位宽决定的天花板。
- “多边共享边排序保确定性”为什么对烘焙重要? → 同一网格在不同机器/不同次构建要得到逐字节一致的簇划分, 否则烘焙结果不可复现、增量构建和缓存全失效; 按边的稳定排序消除哈希遍历顺序带来的不确定性。
<a name=”q13″></a>
Q13. LOD 误差怎么算?简化时如何不裂?为什么父误差必须 ≥ 子误差?

满分答案:
这是三连问,三个点环环相扣,我分开讲再讲它们怎么咬合。
① 误差怎么算 —— QEM(二次误差度量):简化由 FMeshSimplifier 做,Simplify(NumVerts, TargetNumTris, MaxErrorSqr) 返回 MaxErrorSqr(Cluster.cpp:549-743)。这个值是最坏情况下「任意点到简化网格」的平方距离——也就是简化丢了多少几何细节的量化。sqrt 后再逆尺度回原坐标系就是该簇的 LODError。简化时各属性有权重:法线 1.0、UV 按面积、骨骼权重 0。
② 为什么不裂 —— 边界锁定:简化一个 group 时,遍历 ExternalEdges[i]≠0 的边(与其他簇共享的边),对两端点调 LockPosition() 冻结,简化中不许动(Cluster.cpp:660-675)。这样相邻簇各自独立简化,但共享边的顶点位置逐顶点对齐,不产生裂缝。(注意:这是宏观层面的无缝;微观像素级的水密靠 Q3 的定点数光栅。)
③ 为什么单调 —— parentError ≥ childError:源码强制 ParentLODError = max(ParentLODError, SimplifyError)(ClusterDAG.cpp:1070)。原因要从运行时选 LOD 说起:选 LOD 是在 DAG 上找一条”切口”(Q5),靠把误差投影成屏幕像素来判断。如果父误差 < 子误差,这个判断会自相矛盾——可能同时选中父簇和它的子簇,导致几何重叠或空洞。单调性保证了”越往 DAG 上层误差越大”,切口的选择才良定义。
三者怎么咬合(满分要讲的因果链):
- QEM 给出每簇的几何误差 → 运行时把它投影成屏幕像素来选 LOD;
- 边界锁定保证同一 LOD 级相邻簇不裂;
- 单调性保证跨 LOD 级的选择良定义。
- 缺任何一个:误差不准→LOD 抖动 / 不锁边界→裂缝 / 不单调→重叠空洞。
✅ 采分要点(按层级)
- 〔及格〕 *”误差是简化前后几何差多少, 用 QEM 算; 不裂是因为簇边界上的点被锁住了不动; 父的误差肯定比子大, 因为父更粗。”*
→ 给分理由:三个点的结论都对(QEM/边界锁定/父≥子), 抓住了核心机制名称, 但停在”是什么”没讲”为什么必须这样”。
- 〔良好〕 *”误差是 QEM 给的最坏情况平方距离, 开根号再按尺度还原到原始坐标系。不裂是遍历那些 ExternalEdges 不为零的边界边, 把两端点位置 Lock 住, 相邻簇各自简化但共享边逐顶点对齐。父误差取 max(父, 本次简化误差), 保证单调不减。”*
→ 给分理由:知其所以然, 准确描述了三套机制的运作方式(MaxErrorSqr→sqrt 逆尺度、ExternalEdges 判定锁定、max 取单调), 细节到位。
- 〔优秀〕 *”三者是咬合的: QEM 给每个簇一个误差→运行时把误差投影到屏幕选 LOD; 锁定保证同级相邻簇不裂; 单调保证跨级良定义。单调这条最关键——运行时选 LOD 本质是在 DAG 上找一条切口, 如果父误差比子还小, 投影判断就自相矛盾, 会同时把父和子都选中, 渲染出来要么几何重叠要么空洞。所以 ParentLODError 必须强制 ≥ 子。”*
→ 给分理由:讲透了三机制如何协同, 尤其把”父<子”的危害推到 DAG 切口层面(自相矛盾→父子同选→重叠/空洞), 这是辩证理解而非孤立记忆。
- 〔加分〕 *”QEM 是 FMeshSimplifier::Simplify 返回 MaxErrorSqr, 在 Cluster.cpp 大概 549 行 sqrt 逆尺度。属性带权重: 法线 1.0、UV 按面积、骨骼权重 0。锁定在 Cluster.cpp 660 行 LockPosition。单调那行是 ClusterDAG.cpp:1070 的 ParentLODError = max(ParentLODError, SimplifyError)。”*
→ 给分理由:实现级符号全部点到(FMeshSimplifier::Simplify/MaxErrorSqr/Cluster.cpp:549/660/ClusterDAG.cpp:1070), 还给了属性权重的具体数值, 证明读到了代码行级。
⛔ 雷区(错在哪 / 为什么错 / 正确是什么)
- “LOD 误差就是顶点数减少的百分比, 删得越多误差越大。”
– 错在哪:删点数量和几何偏差不是一回事, 平坦区域删一堆点几何几乎不变, 曲面删一个点可能偏差很大。
– 正确:误差是 QEM 给的最坏情况下任意点到简化网格的平方距离, 度量的是几何形状偏差, 不是拓扑删除量。
- “相邻簇简化后会裂, 得在运行时检测裂缝再缝合/补三角形。”
– 错在哪:Nanite 不做运行时缝合, 那样既慢又不可靠。
– 正确:在构建期就把簇边界边的端点 LockPosition 冻结, 相邻簇各自独立简化但共享边的顶点天然逐点对齐, 从源头不产生裂缝。
- “父节点误差直接等于子节点误差之和(或平均)就行。”
– 错在哪:求和/平均都不能保证单调不减, 一旦出现父<子, 运行时 LOD 投影判断会自相矛盾, 父子被同时选中导致重叠或空洞。
– 正确:父误差 = max(父累计误差, 本次简化误差), 用 max 强制单调单增。
🕳️ 似是而非陷阱(听起来对,实则半懂)
- “QEM 算的是简化后顶点到原始网格的平均距离。” —— 半懂点:Nanite 用的是 MaxErrorSqr 即最坏情况距离, 不是平均; 用平均会低估极端偏差, 选 LOD 时该切的地方没切住, 平均和最坏在保守性上完全不同。
- “边界锁定就是把边界顶点删掉不参与简化。” —— 半懂点:是 Lock 住位置不让它移动/被坍缩, 顶点还在、边还在, 只是不动; 删掉边界点反而会破坏与相邻簇的对齐, 恰恰制造裂缝。
- “父误差≥子误差是简化算法自然产生的, 不用特意保证。” —— 半懂点:相邻簇是各自独立简化的, 父级某次简化的实际误差完全可能偶发地小于某个子, 单调性不会自动成立, 必须显式 max 钳制, 否则 DAG 选择就坏掉。
🔍 追问链(面试官逐层下探)
- QEM 返回的 MaxErrorSqr 为什么要 sqrt 再逆尺度? → 内部计算在归一化/缩放后的空间做且是平方量, 运行时需要的是原始世界坐标系下的线性距离才能和屏幕投影阈值比较, 所以开根号还原量纲、按缩放因子还原尺度(Cluster.cpp:549)。
- 法线权重 1.0、UV 按面积、骨骼 0, 为什么这样设? → 法线偏差直接影响着色必须重罚; UV 误差按三角面积加权避免小三角主导; 骨骼权重在静态简化阶段设 0 是因为蒙皮权重不参与几何误差度量, 否则会污染位置误差。
- ExternalEdges[i]≠0 是怎么判定一条边是不是簇边界的? → 该计数记录这条边被簇内多少三角形共享/是否有跨簇引用, 不为零表示它是对外暴露的边界边(相邻簇也持有), 因此两端点要 LockPosition(Cluster.cpp:660)。
- 运行时在 DAG 上”找切口”具体是怎么选 LOD 的? → 自顶向下遍历 DAG, 对每个簇把其 LODError 投影到屏幕像素, 误差小于阈值就用该簇(切口在此), 否则下钻到子簇; 单调性保证这条切口是良定义的一条反链——同一表面区域不会既选父又选子。
<a name=”q14″></a>
Q14. Root Page / Streaming Page 区别?为什么分两类?Transcode 为什么两 pass?

满分答案:
Root vs Streaming Page:
- Root Page:常驻 GPU 显存,32KB,≤64 簇/页。作用是提供立即可渲染的粗几何近似——不依赖任何其他页,资产一加载就能渲染出一个大致形状。
- Streaming Page:按需加载,128KB,≤512 簇/页。细节页,在后台流入,引用 Root 页做增量细节(
NaniteDefinitions.h:49)。
为什么分两类:如果全部按需流送,那么资产刚进视野的第一帧没有任何几何可渲染(还在等流送),会有明显的”弹出”或空洞。Root 常驻解决了这个问题——先用粗几何保证可渲染,细节随后流入补齐。这也是 Nanite 能做到”任意距离都有东西渲染、平滑过渡”的基础之一。
Transcode 为什么分两 pass:页从磁盘流入时是压缩格式(~50%),需要 GPU 解码(Transcode)成运行时格式。分两 pass 的本质是依赖解耦:
- Pass 1 · Independent:处理不依赖父页的数据——
FPackedCluster原始拷贝、Strip 索引解码、位置用 ZigZag 压缩位流 +WaveInclusivePrefixSum恢复(NaniteTranscode.usf:401-574)。 - Pass 2 · ParentDependent:处理引用顶点——从父 page/cluster 解码(通过
PageClusterMap,:576-667)。
为什么必须分开:Root page 必须能独立工作(它没有父数据),所以它的内容全在 Independent pass 就能解完;而 Streaming page 的引用顶点要等父页就位才能解。两 pass 分离避免了 GPU 内的同步(不用在一个 pass 里等依赖),每组 64 线程处理一个 cluster。
为什么 GPU 解码而不是直接 memcpy:磁盘存的是压缩格式,运行时格式不同,把解码放 GPU 上省磁盘/总线带宽(传压缩的,到 GPU 再展开)。
✅ 采分要点(按层级)
- 〔及格〕 *”Root 页是常驻显存的, 比较小、簇少, 一直在; Streaming 页是按需流进来的, 大一些、簇多。分两类是为了不一次全加载。”*
→ 给分理由:方向对, 抓住”Root 常驻 vs Streaming 按需”和大小/簇数差异, 但没说清两 pass 和依赖关系。
- 〔良好〕 *”Root 大概 32KB、不超过 64 簇, 提供一份立即能渲染的粗几何, 不依赖别的页; Streaming 128KB、不超过 512 簇, 后台流进来引用 Root 做增量细化。全按需的话首帧没几何可画会弹出, 有 Root 顶着先画粗的, 细节随后补。”*
→ 给分理由:知其所以然, 量化了两类页规格(32KB/64 vs 128KB/512), 并讲清分两类是为了消除首帧弹出, Root 自包含这点抓到了。
- 〔优秀〕 *”Transcode 两 pass 本质是依赖解耦。Independent pass 处理不依赖父页的数据——原始拷贝、Strip 索引解码、ZigZag 位流用 WaveInclusivePrefixSum 恢复位置; ParentDependent pass 处理引用顶点, 要从父 page/cluster 解码, 走 PageClusterMap。Root 必须独立工作所以全在 Independent 解完, Streaming 的引用顶点得等父页就绪。两 pass 分开是为了避免在一个 pass 内做 GPU 同步, 一个 cluster 64 线程。”*
→ 给分理由:讲透了两 pass = 依赖解耦的本质, 把”哪些数据进哪个 pass”和”为什么要分”(避免 GPU 同步)都说清, 并补出 PageClusterMap、WaveInclusivePrefixSum 等机制细节。
- 〔加分〕 *”规格常量在 NaniteDefinitions.h 49 行附近。Transcode 是 GPU 解码不是 memcpy——磁盘上存的是压缩格式(大概压到 50%), 运行时格式不一样, 放 GPU 上解能省传输带宽。ZigZag 是把有符号增量映射成无符号好做位流打包, 前缀和是因为位置存的是 delta 要累加还原。”*
→ 给分理由:给到符号(NaniteDefinitions.h:49)、压缩比(~50%)和”GPU 解码省带宽”的设计动机, 并解释了 ZigZag/前缀和的存在理由, 是实现级证据。
⛔ 雷区(错在哪 / 为什么错 / 正确是什么)
- “Root 和 Streaming 就是大小不同, 功能一样, 都按需流。”
– 错在哪:Root 是常驻不流的, 且必须自包含(不引用其他页), 这是它能在首帧立即渲染的前提, 与 Streaming 的按需引用语义根本不同。
– 正确:Root 常驻 GPU 提供立即可渲染的粗近似, Streaming 按需流入并引用 Root 做增量, 两者职责不同而非只是尺寸不同。
- “Transcode 就是把磁盘数据 memcpy 到显存。”
– 错在哪:磁盘存的是压缩的运行时不可直接用的格式(位流/Strip/delta), 直接 memcpy 显存读不了。
– 正确:Transcode 是 GPU 上的解码过程(解压、索引解码、位流前缀和还原位置), 存压缩格式是为了省磁盘和传输带宽。
- “两个 pass 是为了并行, 同时跑更快。”
– 错在哪:两 pass 不是并行而是有先后依赖, ParentDependent 必须等 Independent(尤其父页)先解完。
– 正确:分两 pass 是为了依赖解耦——把无依赖的数据和需等父页的数据分开, 从而避免在单个 pass 内做昂贵的 GPU 同步。
🕳️ 似是而非陷阱(听起来对,实则半懂)
- “Root 页就是 LOD0 的高模, 一开始就把最细的加载好。” —— 半懂点:Root 恰恰是粗几何近似(LOD 链顶端的低细节), 目的是立即有东西可画, 细节靠 Streaming 后补; 把 Root 当高模理解反了细节方向。
- “Independent 和 ParentDependent 的区别是一个处理顶点一个处理索引。” —— 半懂点:划分依据是”是否依赖父页”, 不是顶点/索引的数据类型; Independent 里也解索引(Strip)、也恢复位置, ParentDependent 处理的是跨页引用的顶点。
- “压缩存储主要是为了省磁盘空间。” —— 半懂点:省磁盘只是一面, 更关键是运行时省 PCIe/显存传输带宽——压缩态传到 GPU 再解, 带宽是 Nanite 流送的真瓶颈; 只说省磁盘没抓住”放 GPU 解码”的设计意图。
🔍 追问链(面试官逐层下探)
- Root 页为什么必须”不依赖其他页”? → 因为它要在首帧、在任何 Streaming 页到达之前就能独立渲染; 一旦它引用了尚未流入的页就无法立即出图, 自包含是”立即可渲染粗近似”的硬前提。
- ParentDependent pass 里”引用顶点”具体怎么从父取? → 通过 PageClusterMap 定位父 page/cluster, 子簇只存对父顶点的引用/增量, 解码时回到父页取基准顶点再叠加 delta, 所以必须等父页先 transcode 完。
- 位置为什么用 ZigZag + 前缀和而不是直接存浮点? → 位置以相邻量化增量(delta)存, delta 小且有符号, ZigZag 把有符号映射成无符号便于变长位流打包, 运行时用 WaveInclusivePrefixSum 把增量累加还原成绝对位置, 整体压缩率远高于裸浮点。
- 一个 cluster 配 64 线程是怎么和数据结构对应的? → 簇内顶点/三角规模在百级, 64 线程一波正好覆盖一个 cluster 的解码工作, 配合 wave 内前缀和等 wave intrinsics 高效协作, 也匹配 transcode 按 cluster 粒度并行的划分。
<a name=”q24″></a>
Q24. Nanite 的位置量化精度怎么自动确定?为什么 21 位/轴?尺度悬殊的几何为什么不能合进一个资产?

满分答案:
这题往构建器(NaniteBuilder)的离线编码里钻,考的是有没有真读过精度决策那段代码,而不是只知道”Nanite 会压缩几何”。
先讲量化的结构。 整个网格共用一张统一量化格(uniform grid):IntPosition = round(Position × 2^P),P 就是 Position Precision,量化步长 = 2^-P cm。每个簇存自己的整数最小角 QuantizedPosStart 和逐轴位宽 QuantizedPosBits(取值范围的 CeilLogTwo),顶点只存相对最小角的无符号偏移——全局精度统一,局部位宽自适应,小簇自然用更少的位。
精度怎么自动定(默认 PositionPrecision = MIN_int32 即 Auto)。 决策在 CalculateQuantizedPositionsUniformGrid(NaniteEncode.cpp:191-345),四步:
- 密度启发式:遍历所有叶簇(MipLevel == 0),取包围盒尺寸 log2 的平均 AvgLogSize,然后
PositionPrecision = 7 - round(AvgLogSize)(NaniteEncode.cpp:224)。直觉是:簇恒为约 128 三角形,叶簇越小说明网格越密,越值得更细的步长。平均叶簇 128 cm(2^7)的网格得 P=0(步长 1 cm),叶簇尺寸每减半,P 加 1、步长减半。源码注释还给了等价解读:这相当于”按各簇所需精度取平均”。 - 自动模式下限:
AUTO_MIN_PRECISION = 4,即步长 1/16 cm(:231)。注释里的理由很工程化——更低精度对包体贡献微乎其微(测试项目约 0.4%)却容易出问题:一个孤立看没问题的低精度路面/建筑框架,放进场景后会被摆在它上面的小物件暴露量化台阶。要更低必须显式 opt-in,自动模式不替你冒险。 - 总范围 clamp:P 最终钳到 [-20, 43](
NaniteDefinitions.h)——步长从 2^20 cm(约 10.5 km,行星尺度)到 2^-43 cm(亚原子尺度)。实践中 Auto 已覆盖绝大多数资产。 - 超位宽自动降级:定好 P 后逐簇校验,任何一个簇量化后任一轴的整数范围超过 2^21-1,就把量化尺度减半、P 自减,循环到所有簇可编码(
NaniteEncode.cpp:250-271)。
为什么是 21 位/轴。 源码注释直白:21*3 = 63 < 64(NaniteDefinitions.h)——三轴量化坐标必须能塞进一个 64 位预算,这是解码端效率决定的硬约束。
尺度悬殊为什么不能合资产。 这正是第 4 步的工程含义:降级是全网格统一的——一个尺度异常巨大的簇,会触发降级把整个网格的精度一起拉下来。所以把一座大地形和它上面的小螺丝合进同一个 Nanite 资产,螺丝的精度会被地形拖垮。这条”尺度悬殊几何分开建资产”的规范,是直接从精度降级逻辑推出来的。
一致的设计哲学:位置是量化存(不是浮点),法线也是量化存(八面体两分量、每分量上限 15 位),切线默认干脆不存、靠像素端推导(见 Q16)。因为 Nanite 的瓶颈在几何数据量(内存/带宽/流送),不在像素 ALU,所以”能量化就不存浮点、能推导就不存”是贯穿的。
✅ 采分要点(按层级)
- 〔及格〕 *”Nanite 会把顶点位置量化成整数存,精度默认是自动的,按网格疏密定,密的精度高。”*
→ 给分理由:抓住了”量化存储 + 精度自动 + 跟密度走”三个核心结论,方向对,及格。
- 〔良好〕 *”是统一量化格 round(Position×2^P),P 是精度,步长 2^-P cm。自动模式按叶簇尺寸的 log2 平均算 P=7-round(AvgLogSize),叶簇越小越密、精度越高。每簇存自己的位宽,小簇用更少位。”*
→ 给分理由:能写出量化公式和自动精度的启发式,并理解”叶簇尺寸代表密度””逐簇位宽自适应”,知其所以然。
- 〔优秀〕 *”四步:密度启发式定 P、AUTO_MIN_PRECISION=4 兜底(更低精度省不了多少包体却会暴露量化台阶)、clamp 到 [-20,43]、最后逐簇校验超 2^21-1 就把尺度减半 P 自减。21 位是因为 21×3=63<64,三轴要塞进 64 位预算。最关键的工程结论是:降级是全网格统一的,一个尺度巨大的簇会把整个网格精度拉低,所以尺度悬殊的几何不能合进一个资产。”*
→ 给分理由:四步讲全,21 位的来由准确,还能把”超位宽降级”推出”尺度悬殊不能合资产”这条可落地的规范——从源码细节到资产组织建议,是真读过且会用。
- 〔加分〕 *”代码在 NaniteEncode.cpp 的 CalculateQuantizedPositionsUniformGrid 191 行附近,P=7-round(AvgLogSize) 在 224 行,AUTO_MIN_PRECISION=4 在 231,降级循环在 250-271。位置是 ZigZag delta + WaveInclusivePrefixSum 还原(Q14),法线八面体两分量每分量≤15 位,切线默认不存靠隐式推导(Q16)——位置/法线/切线三套都贯彻’能量化/推导就不存浮点’。”*
→ 给分理由:给到函数名和具体行号,并把位置量化串到法线量化、切线推导的统一压缩哲学,证明读到了代码行级且有体系。
⛔ 雷区(错在哪 / 为什么错 / 正确是什么)
- “Nanite 顶点位置还是存浮点的,只是压缩了一下。”
– 错在哪:位置是量化成整数存的(round(Position×2^P)),不是压缩的浮点;每簇存整数最小角 + 逐轴位宽 + 无符号偏移。
– 正确:统一量化格上的整数编码,精度由 P 决定,运行时再还原近似浮点位置。
- “精度是用户必须手动设的,不设就用一个固定默认值。”
– 错在哪:默认是 Auto(MIN_int32),引擎按叶簇密度自动算 P,不是固定常数;手动只在拼接敏感件/尺度异常件等少数情况才需要。
– 正确:Auto 走密度启发式 + 下限 + clamp + 降级四步自动决定,绝大多数资产不用手设。
- “把大地形和上面的小物件合成一个 Nanite 资产没关系,反正各簇位宽自适应。”
– 错在哪:位宽是逐簇自适应,但精度 P 的”超位宽降级”是全网格统一的——尺度巨大的地形簇会触发降级把整个资产(含小物件)的精度拉低。
– 正确:尺度悬殊的几何要分开建资产,避免大尺度簇拖垮小物件精度。
🕳️ 似是而非陷阱(听起来对,实则半懂)
- “精度跟着三角形数量走,三角形越多精度越高。” —— 半懂点:启发式看的是叶簇包围盒尺寸(log2 平均),不是三角形数量。因为叶簇恒为约 128 三角,簇尺寸才反映局部密度;直接说”三角形越多越精”没抓到”尺寸=密度代理”这层。
- “21 位是某种精度上的最优选择。” —— 半懂点:21 不是精度最优,是 64 位预算除以三轴的结果(21×3=63<64)。它是解码端的容量约束,不是”21 位精度刚好够用”。
- “自动精度下限设 4 是因为再低画质就崩了。” —— 半懂点:下限 4 的理由是更低精度省不了多少包体(约 0.4%)却会在多物件场景暴露量化台阶,是收益/风险权衡,不是单纯”再低就崩”。
🔍 追问链(面试官逐层下探)
- 自动精度那个
7 - round(AvgLogSize)里的 7 是什么意思? → 它让平均叶簇尺寸为 2^7=128 cm 的网格得到 P=0(步长 1 cm)作为基准点;叶簇尺寸每减半 AvgLogSize 减 1,P 就加 1、步长减半,所以 7 是把”128 cm 叶簇↔1 cm 步长”锚定起来的常数。 - 为什么用叶簇尺寸而不是直接用三角形数当密度指标? → 叶簇恒为约 128 三角,所以叶簇的包围盒尺寸直接反映”这么多三角形占多大空间”即局部密度;用尺寸比用计数更直接地表达”该多细的量化步长”。
- 超位宽降级为什么要影响整个网格,不能只降那个大簇? → 因为 P 是全网格统一的量化指数(统一量化格的前提),单独给某簇换 P 会破坏统一格;所以遇到放不下的簇只能整体降 P,代价就是尺度悬殊时小物件被拖累——这反过来要求资产层面避免混合尺度。
- 这套精度和 Q14 的 Transcode 压缩是什么关系? → 量化(本题,定 P 和位宽、存整数偏移)是编码的第一层,决定几何的数值精度;Transcode(Q14)是把量化后的数据再做 ZigZag delta + 位流打包并在 GPU 解码,是第二层的无损压缩与还原。精度定多少位是量化层的事,怎么把这些位高效传输/解码是 Transcode 层的事。
<a name=”q25″></a>
Q25. 切簇的图分割为什么用 METIS,而不是 meshoptimizer / nvcluster 这类库?簇内顶点复制走的是 indirect index 吗?

满分答案:
这是两个钻到 NaniteBuilder 实现里的问题,区分”读过源码”和”背过介绍”。分开答。
① 为什么是 METIS——先纠正一个常见误解:METIS 不是”拆三角形成簇”那一步的全部,它只是被调用来解一个数学子问题。
看 FGraphPartitioner(GraphPartitioner.cpp)的真实结构:外层是 Epic 自研的递归二分框架——PartitionStrict → RecursiveBisectGraph → BisectGraph,每次只让 METIS 做一次 2-way 最小割边二分(METIS_PartGraphRecursive,NumParts=2,:172),二分完 Epic 自己判断”两边是否都 ≤MaxPartitionSize(128)”,没到就继续递归切(:235-280)。METIS 不是一把梭做 k-way 划分(那条 METIS_PartGraphKway 路径 :56 只在整图已经 ≤128 的小图上用)。
换句话说,METIS 在这里只是个底层零件,提供”给定一张图,做一次最小割边的均衡二分”这个原子操作。真正决定 Nanite 簇质量的三件事是 Epic 在 METIS 外面自己包的:
- 硬容量上限 128:METIS 自己只保证”大致均衡”(靠
METIS_OPTION_UFACTOR容忍度,:165),不保证每块严格 ≤128;是外层递归框架卡的死上限。 - 确定性:
PartitionStrict多线程二分后强制Ranges.Sort()(:342,注释// Force a deterministic order)——保证同一网格每次构建逐字节一致,可复现烘焙。 - Morton locality links:
BuildLocalityLinks把空间近但拓扑不相连的元素之间补加边(每元素最多 5 条,MaxLinksPerElement=5),让簇既拓扑连通又空间紧凑。
那为什么不是 meshoptimizer / nvcluster:
- meshoptimizer 的
buildMeshlets解的是meshlet 划分——目标是顶点复用率 + 锥形剔除友好(给 mesh shader 用),它不以”全局最小割边 + 服务后续 DAG 简化”为目标。Nanite 的簇边界要短(后续要 LockPosition 锁定、留出可简化内部,见 Q4/Q13),诉求和 meshlet 不同,拿 meshopt 得自己重写适配层。 - nvcluster(NVIDIA 的 cluster builder) 更接近 Nanite 的簇,但它是 2023+(RTX Mega Geometry 配套)才出现的,而 Nanite 这套 METIS 代码 2021(UE5 EA)就定了——时间上 Nanite 远早于 nvcluster,不存在”当时可选 nvcluster 却选了 METIS”。(这条是时间线推断;源码只能证明”用的是 METIS”,证不了”评估过 nvcluster”。)
一句话:METIS 不是从一堆 cluster 库里”挑”出来的,它是”做一次最小割边图二分”这个原子操作的成熟工业实现;容量上限、确定性、locality 这些 Nanite 特有的诉求全是 Epic 自己在外面包的。
② 顶点复制——不是朴素的 indirect index,是 triangle strip + Ref/New 顶点的变长编码。
朴素 indirect index 是”每个三角形存 3 个绝对顶点索引(各 32 位)”,共享边的顶点会被重复引用,索引数据大。Nanite 不这么干(NaniteEncodeTriStrip.cpp:141 UnpackTriangleIndices,运行时 transcode shader 跑同一套逻辑):
- 三角形 strip 化,每个顶点要么是 New vertex(新顶点,顺序隐式递增编号,
NextVertex++,:178,不花额外索引位),要么是 Ref vertex(回引前面已出现的顶点,存一个 5-bit 的相对回退量BaseVertex - (IndexData & 31u),:178); FStripDesc.NumPrevRefVerticesBeforeDwords/NumPrevNewVerticesBeforeDwords(Cluster.h:195-196)是每 dword 的前缀计数,配合位掩码CountBits现场算出当前三角形顶点在紧凑数组里的绝对位置。
构建期还有一层:FVertexArray::FindOrAddHash(Cluster.h:103)在簇内做顶点哈希去重,重复顶点只存一份。
为什么这么设计:strip 推进一个三角形通常只新增 1 个 New 顶点、另外 2 个回引前序,所以大多数顶点 0 索引位、只有复用顶点花 5 bit——比”每三角形 3 个 32 位绝对索引”省得多。5 bit 上限(回引前 32 个顶点)正好匹配 strip 的局部性。这和 Nanite 全局”能压就压”的哲学一致(呼应 Q14 的 ZigZag/前缀和、Q24 的量化、Q16 的切线推导)。
✅ 采分要点(按层级)
- 〔及格〕 *”图分割用的是 METIS 库;顶点不是每个三角形存绝对索引,是用 strip 把共享顶点复用了。”*
→ 给分理由:方向对,知道 METIS 是图分割库、顶点走 strip 复用而非朴素索引,及格。
- 〔良好〕 *”METIS 只负责做最小割边的图二分,外面是 Epic 自己的递归框架在控制切到 ≤128、还加了确定性排序。顶点是 strip 编码,分 New 顶点和 Ref 顶点,Ref 存的是相对前面顶点的回退偏移,不是绝对索引。”*
→ 给分理由:能说出 METIS 只是二分零件、外层框架控容量+确定性,并讲清 strip 的 New/Ref 区分和”相对回退量”,知其所以然。
- 〔优秀〕 *”METIS 在 GraphPartitioner 里只做单次 2-way 二分(PartGraphRecursive,NumParts=2),Epic 的 RecursiveBisectGraph 递归切到 ≤128,并在多线程后 Ranges.Sort 保证确定性、用 Morton locality links 补空间局部性——这三层都是 METIS 外面包的。不用 meshopt 是因为它的 buildMeshlets 解的是 meshlet(顶点复用+锥剔除),不服务后续 DAG 简化的最小割边诉求;nvcluster 是 2023+ 才有、晚于 Nanite。顶点编码是 strip+Ref/New,New 顶点 0 索引位、Ref 顶点 5-bit 相对回退量,前缀计数定位,大多数顶点不花索引位。”*
→ 给分理由:把 METIS 的”零件 vs 框架”分层讲透(二分粒度+递归+确定性+locality),meshopt/nvcluster 的不选理由准确(目标不同/时间线),顶点编码的 strip+5bit 回引机制完整,是真读过 NaniteBuilder 的实现级理解。
- 〔加分〕 *”具体符号:二分在 METIS_PartGraphRecursive(GraphPartitioner.cpp:172),小图才走 PartGraphKway(:56),UFACTOR 高层放松到 200、接近簇尺寸收紧到 1(:165);确定性是 :342 的 Ranges.Sort,注释写了 Force a deterministic order。顶点 UnpackTriangleIndices 在 NaniteEncodeTriStrip.cpp:141,5-bit 回退是 BaseVertex-(IndexData&31),前缀计数字段在 FStripDesc(Cluster.h:195);构建期去重是 FVertexArray::FindOrAddHash(Cluster.h:103)。运行时 GPU transcode 用同一套 unpack 逻辑。”*
→ 给分理由:函数名+行号+常量全部点到(PartGraphRecursive/Kway、UFACTOR 200↔1、Ranges.Sort、UnpackTriangleIndices、FStripDesc、FindOrAddHash),并指出运行时与构建期共用 unpack 逻辑,是代码行级证据。
⛔ 雷区(错在哪 / 为什么错 / 正确是什么)
- “Nanite 直接用 METIS 一把做完 cluster 划分。”
– 错在哪:METIS 在主路径上只做单次 2-way 二分,是 Epic 的 RecursiveBisectGraph 框架递归调用它、并自己卡 ≤128 容量上限和确定性;k-way 那条只在小图用。
– 正确:METIS 是底层零件(做一次最小割边二分),容量/确定性/locality 三层是 Epic 外面包的。
- “顶点复制就是每个三角形存三个顶点索引(indirect index)。”
– 错在哪:那是朴素方案,索引数据大。Nanite 用 strip + Ref/New,New 顶点不花索引位、Ref 顶点只存 5-bit 相对回退量。
– 正确:strip 化变长编码,大多数顶点隐式顺序编号,仅复用顶点花 5 bit,比绝对索引省得多。
- “meshopt / nvcluster 更好,Nanite 没用是因为没跟上。”
– 错在哪:meshopt 的 meshlet 划分目标不同(顶点复用+锥剔除,不服务最小割边+DAG 简化);nvcluster 是 2023+ 才出现、晚于 Nanite 的 METIS 方案,不是”没跟上”。
– 正确:METIS 提供的正是 Nanite 需要的”最小割边图二分”原子操作,其余诉求 Epic 自己实现;时间线上 nvcluster 也晚于这套代码。
🕳️ 似是而非陷阱(听起来对,实则半懂)
- “用 METIS 是因为图分割算法快。” —— 半懂点:不是为速度。METIS 提供的是”最小割边二分”这个质量算子,Nanite 要的是切出边界短、内聚、可后续简化的簇;速度不是核心诉求(高层二分还故意放松 UFACTOR、底层收紧)。
- “strip 顶点复用和光栅阶段的 wave 顶点去重是一回事。” —— 半懂点:两层不同。strip+Ref/New 是构建/编码期的索引压缩(省存储/带宽);光栅阶段的
WaveActiveMin顶点去重(Q2 提的)是运行时让每个唯一顶点只变换一次(省 ALU),来源和目的都不同。 - “5-bit 回退量意味着一簇最多 32 个顶点。” —— 半懂点:5 bit 限的是”回引能往前够多远“(前 32 个已解出的顶点),不是簇顶点总数上限;簇顶点总数由 128 三角对应的顶点规模决定,strip 顺序保证要回引的顶点都在最近 32 个内。
🔍 追问链(面试官逐层下探)
- METIS 在 Nanite 里到底做几路划分? → 主路径
PartGraphRecursive每次只做 2-way(NumParts=2),靠 Epic 的RecursiveBisectGraph递归二分到所有块 ≤128;只有整图已 ≤MaxPartitionSize 的小图才走一次性的PartGraphKway。 - 既然 METIS 保证均衡,为什么还要外层框架卡 128? → METIS 的
UFACTOR只是”负载不均衡容忍度”,给的是大致均衡不是硬上限;而 Nanite 需要每簇严格 ≤128(VisBuffer 7 位索引硬约束),所以必须外层递归切到都 ≤128 为止。 - New vertex 不存索引,解码时怎么知道它的编号? → New 顶点按出现顺序隐式编号,解码时用前缀计数
NumPrevNewVerticesBeforeDwords+ 当前 dword 内CountBits算出”在此之前有多少 New 顶点”,下一个 New 顶点就是这个数——所以不用显式存。 - 为什么回退量是 5 bit 不是更多? → strip 的局部性保证:要回引的共享顶点几乎总在最近解出的 32 个顶点内,5 bit(0~31)的相对回退量足够覆盖,再多是浪费。这是 strip 顺序和编码位宽的协同设计。
<a name=”q15″></a>
Q15. Nanite 怎么做到每像素只着色一次,传统延迟做不到?ShadeBinning 三 pass 干什么?

满分答案:
先讲和传统延迟渲染的本质区别——这是这题的核心,很多人会混。
传统延迟渲染:几何 pass 时就执行材质 shader 写 GBuffer。问题是这一步会把被遮挡的片元也着色了(它们先写 GBuffer,后面被覆盖),存在 overdraw;而且成本和 triangle × material 的 PSO 组合相关。
Nanite:光栅化阶段完全不着色,只写 VisBuffer64(Q1)。等所有几何写完、每个像素的唯一可见三角形定案之后,再着色。所以被遮挡的像素根本不进材质着色——零 overdraw 着色浪费。
ShadeBinning 三阶段(NaniteShadeBinning.usf):
- COUNT:每线程读 VisBuffer + ShadingMask,取出每像素的 ShadingBin(属于哪个材质),用
WaveActiveCountBits统计各材质 bin 覆盖了多少像素。 - RESERVE:逐 bin 从全局分配器分配像素存储范围。
bNoDerivativeOps材质按像素分配,其他按四边形(2 像素)分配。同时生成 indirect dispatch 参数。 - SCATTER:重新遍历屏幕,每像素算出精确写入偏移,把像素散写到各 bin 的数据区(编码
CoarsePixelTopLeft, VRSShift, WriteMask)。
结果:每种材质只需一个 compute shader,且只对它实际覆盖的可见像素 dispatch:DispatchThreadCount = DivideAndRoundUp(BinPixelCount, GROUP_SIZE)。于是着色开销 = 屏幕像素数 × 常数,和场景的几何三角形数、材质种类数的乘积彻底解耦。这就是 Nanite 能渲染”trillion triangle”还能实时的原因之一(几何再多,每像素也只着一次)。
5.7 新增:Shader Bundle 把多材质 dispatch 聚合成单个 GPU 命令缓冲(降 CPU 提交开销);Work Graph(SF_WorkGraphComputeNode)让 GPU 动态生成 indirect 任务。但三阶段算法本身是 2021 初版。
✅ 采分要点(按层级)
- 〔及格〕 *”Nanite 光栅化时先不着色, 只写一个可见性 buffer, 等确定每个像素最后是哪个三角形了再去着色, 所以一个像素只着一次。传统延迟在几何 pass 就把材质写进 GBuffer 了, 被挡住的也算过了。”*
→ 给分理由:方向对, 抓住”先可见性后着色”和”传统延迟过早着色导致 overdraw”两个核心对比, 但没展开 ShadeBinning 三 pass。
- 〔良好〕 *”传统延迟在几何 pass 就跑材质 shader 写 GBuffer, 被遮挡的片元也着了色, 成本是 triangle×material 的 PSO 切换。Nanite 光栅只写 VisBuffer64 不着色, 几何全定案后才着, 被遮挡像素根本不进着色阶段, 所以着色开销跟几何解耦, 约等于屏幕像素数×常数。”*
→ 给分理由:知其所以然, 把传统延迟的成本结构(triangle×material PSO)和 Nanite 的”开销与几何解耦”讲清了, VisBuffer64 这个落点准确。
- 〔优秀〕 *”关键是 ShadeBinning 三阶段。COUNT: 读 VisBuffer 和 ShadingMask 拿到每像素的 ShadingBin, 用 WaveActiveCountBits 统计每个材质 bin 有多少像素; RESERVE: 给每个 bin 分配像素存储范围, 按像素还是按四边形看 bNoDerivativeOps, 同时生成 indirect dispatch 参数; SCATTER: 每像素算出精确写入偏移散写进去。最后每个材质一个 compute shader, 只对它实际覆盖的可见像素 dispatch, 这就是为什么着色和几何彻底解耦。”*
→ 给分理由:讲透了三 pass 各自做什么、为什么需要(先数→再分配范围→再散写), 并把 bNoDerivativeOps、indirect dispatch 等关键分支点出, 是机制级的透彻。
- 〔加分〕 *”细节上 RESERVE 阶段 bNoDerivativeOps 决定按像素粒度还是按 2×2 四边形(要算导数就得保 quad)。SCATTER 写偏移要处理 CoarsePixelTopLeft 和 VRSShift 这种 VRS/粗着色情况。5.7 还加了 Shader Bundle 把多材质 dispatch 聚合, 以及 Work Graph, 节点类型是 SF_WorkGraphComputeNode。”*
→ 给分理由:给到实现级符号(bNoDerivativeOps/CoarsePixelTopLeft/VRSShift/SF_WorkGraphComputeNode)和 5.7 版本新特性, 证明跟到了最新代码和设计演进。
⛔ 雷区(错在哪 / 为什么错 / 正确是什么)
- “Nanite 每像素只着色一次是靠 Early-Z/深度预通道剔除被遮挡像素。”
– 错在哪:Early-Z 只能减少部分 overdraw, 仍是在几何 pass 内边光栅边着色, 同一像素仍可能被多次着色且依赖绘制顺序。
– 正确:Nanite 把着色完全推迟到 VisBuffer 定案之后, 按可见性结果对每个屏幕像素只 dispatch 一次着色, 与深度预通道是两码事。
- “ShadeBinning 是把三角形按材质排序后批量画。”
– 错在哪:分 bin 的对象是屏幕像素不是三角形, 也不走光栅绘制而是 compute dispatch。
– 正确:ShadeBinning 按每像素的 ShadingBin(材质)把像素归类, 再对每个材质用 compute shader 只着色它覆盖的可见像素。
- “传统延迟渲染也只着色一次, 所以 Nanite 没本质区别。”
– 错在哪:传统延迟的几何 pass 会对被遮挡片元也执行材质 shader 写 GBuffer(overdraw 存在), 成本随三角形和材质数膨胀。
– 正确:Nanite 光栅阶段完全不着色, 着色严格只发生在可见像素上, 开销与几何复杂度解耦, 这是本质差异。
🕳️ 似是而非陷阱(听起来对,实则半懂)
- “VisBuffer 里存的是颜色, 后面直接拿来显示。” —— 半懂点:VisBuffer64 存的是可见性信息(簇 ID + 三角形 ID 等), 不是颜色; 颜色是之后 ShadeBinning 按这个 ID 反查材质再算出来的, 把它当颜色缓冲就误解了”只写可见性不着色”。
- “三个 pass 是 COUNT 数像素、RESERVE 留内存、SCATTER 写颜色, 顺序无所谓。” —— 半懂点:顺序是强依赖的——必须先 COUNT 出每 bin 像素数, 才能 RESERVE 出连续存储范围和 indirect 参数, 才能 SCATTER 算偏移散写; 打乱顺序偏移无从计算。
- “按四边形(quad)处理是为了 MSAA 抗锯齿。” —— 半懂点:按 2×2 quad 是为了能算屏幕空间导数(ddx/ddy 给 mip/各向异性), 由 bNoDerivativeOps 决定; 跟 MSAA 不是一回事, 不需要导数的材质就能退化到按像素更省。
🔍 追问链(面试官逐层下探)
- VisBuffer64 这 64 位里到底编码了什么? → 主要是可见三角形的标识——簇/实例 ID 加上簇内三角形索引(那 7 位)以及深度等, 着色阶段靠它反查到具体三角形和材质, 再重算属性, 整个过程不在光栅阶段着色。
- COUNT 阶段为什么要先数每个 bin 的像素数, 不能边遍历边着色吗? → 要给每个材质 bin 分配一段连续的像素存储和生成 indirect dispatch 的线程数, 必须先知道总数; 边遍历边着色就退回到无法批量、无法精确 dispatch 的状态, 也丢了”每材质一次 compute”的结构。
- bNoDerivativeOps 为真和为假, dispatch 粒度差在哪、为什么? → 为真表示该材质不需要屏幕导数, 可按单像素粒度调度更省; 为假需要 ddx/ddy, 必须以 2×2 quad 为单位保证导数可算, 即使 quad 内有像素不可见也要凑齐 quad。
- 5.7 的 Shader Bundle / Work Graph 解决了三 pass 里的什么瓶颈? → 解决材质数量多时大量小 dispatch 的调度开销和 CPU/GPU 往返——Shader Bundle 把多材质 dispatch 聚合成一次, Work Graph(SF_WorkGraphComputeNode)让 GPU 自己按数据驱动展开着色工作, 减少 indirect 回读和提交次数。
<a name=”q16″></a>
Q16. 不存逐顶点切线,需要法线贴图时怎么办?代价和限制?

满分答案:
Nanite 默认不存逐顶点切线,需要切线(法线贴图)时,在像素着色阶段从三角形顶点位置 + UV 隐式推导。
具体机制(DecodeImplicitTangents,NaniteAttributeDecode.ush:610):
Perp2 = cross(N, edgeB)
Perp1 = cross(edgeA, N)
TangentX = normalize(Perp2*dUV1.x + Perp1*dUV2.x)
这是 Christian Schüler 的屏幕空间推导法——用法线 N、两条边、UV 梯度,现场算出切线 X。所以法线贴图照常能用,只是切线不是查表来的,是算出来的。
为什么这么设计 —— 权衡:
- 省的是:Nanite 资产几何密度极高,逐顶点切线的内存可观(每个顶点一个切线向量)。省掉它能显著降低几何数据量。
- 代价是:每像素多算一点;且隐式推导对镜像 UV / UV 接缝的处理不如显式存储的切线精确(接缝处可能有瑕疵)。
- 兜底:也支持可选存储切线(
cluster.bHasTangents,角度 + 符号编码),对质量敏感的资产可以开。
这背后是一致的设计哲学:Nanite 对高密度几何普遍”压缩/推导而非原样存”——法线也是量化压缩存的(2*NormalPrecision 比特,UnpackNormal 恢复)。因为 Nanite 的瓶颈在几何数据量(内存/带宽/流送),不在像素 ALU,所以用算力换内存是划算的。
✅ 采分要点(按层级)
- 〔及格〕 *”Nanite 不存每个顶点的切线, 但法线贴图照样能用——它在着色时根据三角形的位置和 UV 现场把切线算出来。代价就是每像素多算一点。”*
→ 给分理由:方向对, 抓住”不存切线但运行时从位置+UV 推导, 法线贴图仍可用”和”代价在像素端”这两个核心点, 但没给推导方法和精度限制。
- 〔良好〕 *”切线是在像素着色阶段从三角形顶点位置和 UV 隐式推出来的: 用相邻边和 UV 梯度做叉乘组合, TangentX 大概是 Perp2×dUV1.x 加 Perp1×dUV2.x 再归一化, Perp 是法线和边的叉乘。这样省掉了逐顶点切线的内存, 高密度几何省得可观, 代价是每像素多算, 而且镜像 UV 和 UV 接缝处不如显式存的精确。”*
→ 给分理由:知其所以然, 写出了推导公式形态(Perp 叉乘+UV 梯度加权)和明确的代价(内存省 vs 像素 ALU+接缝精度), 权衡讲到位。
- 〔优秀〕 *”这是 Christian Schüler 那套屏幕空间切线推导法, 在 NaniteAttributeDecode.ush 里 DecodeImplicitTangents。它和 Nanite 整体哲学一致——几何数据量是瓶颈, 像素 ALU 不是, 所以宁可每像素多算也要把逐顶点属性压掉。同理法线本身也是量化压缩存的, 大约 2×NormalPrecision 比特, 着色时 UnpackNormal 解出来。不是无脑省, 兜底有 cluster.bHasTangents 可选显式存切线(角度+符号编码), 镜像 UV/接缝精度敏感的资产可以打开。”*
→ 给分理由:讲透了”为什么这么换”(几何量是瓶颈、ALU 不是)的统一设计哲学, 把法线量化、可选显式切线兜底都纳入同一逻辑, 是辩证而完整的理解。
- 〔加分〕 *”实现是 DecodeImplicitTangents, NaniteAttributeDecode.ush 大概 610 行。法线压缩是 2×NormalPrecision 比特配 UnpackNormal。兜底字段是 cluster.bHasTangents, 显式切线用角度+符号编码而非存整个三维向量, 进一步压。”*
→ 给分理由:实现级符号齐全(DecodeImplicitTangents/NaniteAttributeDecode.ush:610/cluster.bHasTangents/2×NormalPrecision/UnpackNormal), 并指出显式切线也用角度+符号压缩而非裸向量, 是代码级证据。
⛔ 雷区(错在哪 / 为什么错 / 正确是什么)
- “不存切线就没法用法线贴图, 所以 Nanite 模型不能用法线贴图。”
– 错在哪:切线缺失不等于不可恢复, Nanite 在像素阶段从位置+UV 隐式推导切线基, 法线贴图照常工作。
– 正确:法线贴图完全能用, 切线由 DecodeImplicitTangents 运行时推导出来, 只是精度/成本权衡不同。
- “切线是在顶点阶段(VS)算好再插值给像素的。”
– 错在哪:Nanite 没有传统 VS 插值切线的路径, 推导发生在像素着色阶段、基于该像素三角形的位置和 UV 梯度。
– 正确:切线在像素着色时由 DecodeImplicitTangents 现场推导, 不是顶点算好插值下来。
- “隐式推导切线和显式存储精度完全一样, 纯赚。”
– 错在哪:在镜像 UV、UV 接缝等不连续处, 屏幕空间推导会和真实切线基有偏差, 不如显式存的准。
– 正确:大多数情况够用且省内存, 但接缝/镜像处精度下降, 所以保留 cluster.bHasTangents 可选显式存储兜底。
🕳️ 似是而非陷阱(听起来对,实则半懂)
- “用 ddx/ddy 对 UV 求屏幕导数来推切线。” —— 半懂点:Nanite 推导用的是三角形顶点的位置和 UV 梯度(边与 dUV)做叉乘组合(Schüler 法), 不是依赖像素四边形的 ddx/ddy 屏幕导数; 两者来源不同, 混淆会以为切线依赖 quad 导数。
- “省内存主要省的是法线贴图采样开销。” —— 半懂点:省的是逐顶点切线属性的存储/带宽, 跟法线贴图采样开销无关; 法线贴图照样采样, 只是切线基改成现场算, 把收益安到纹理采样上是没理解省在哪。
- “法线也是隐式推导出来的, 跟切线一样不存。” —— 半懂点:法线是显式量化压缩存的(约 2×NormalPrecision 比特, UnpackNormal 解码), 不是推导; 只有切线是隐式推导。把法线和切线都当成推导就混淆了”压缩存储”与”运行时推导”两种不同手段。
🔍 追问链(面试官逐层下探)
- DecodeImplicitTangents 里 Perp1/Perp2 具体怎么来的, 为什么用叉乘? → Perp = cross(N, edge), 即法线与三角形某条边叉乘得到垂直于法线、位于切平面方向的基向量; 再用两条边对应的 UV 增量(dUV1/dUV2)加权组合, 把切线方向对齐到 UV 的 U 方向, 最后归一化(NaniteAttributeDecode.ush:610)。
- 为什么镜像 UV 处隐式切线会出问题? → 镜像 UV 会让 UV 梯度的手性(符号)翻转, 推导出的切线方向/副切线符号在镜像缝两侧不连续, 法线贴图解出的凹凸方向会反, 显式存储能带符号位精确表达, 推导则在缝处出错。
- 法线”2×NormalPrecision 比特”是怎么编码的, 为什么是 2 倍? → 单位法线用两个分量(如八面体映射的两个坐标)即可表示, 每个分量 NormalPrecision 比特, 两个分量合计 2×NormalPrecision, 着色时 UnpackNormal 还原成三维单位向量, 比裸存三个 float 省得多。
- 既然有 cluster.bHasTangents 兜底, 为什么不默认全开显式切线? → 默认推导是为了省内存(几何量才是瓶颈), 全开显式切线会把逐顶点切线的存储/带宽加回来, 与 Nanite 压缩哲学相悖; 只在镜像 UV/接缝精度敏感的资产上按需打开, 是按需付费而非一刀切。
5.7 新特性 + 架构压轴(Q17–Q21)
<a name=”q17″></a>
Q17. Nanite Foliage 密集植被的亚像素三角形怎么处理?Voxel/Brick 数据结构 + 距离驱动 LOD?为什么体素路径不支持 WPO?

满分答案:
密集植被(草、树叶)是典型的亚像素三角形 + 严重 overdraw 场景。Nanite Foliage(5.7)的做法是:当三角形小到亚像素时,切换成接近像素大小的体素来表达。
Voxel / Brick 数据结构(FBrick,NaniteDataDecode.ush:137):
ReverseBrickBits : uint2 // 64位bitmap = 4×4×4 = 64 体素
StartPos / BrickMax : int3
VertOffset : uint
BoneIndex : uint // 5.7 新增,用于体素蒙皮
一个 Brick 就是 4×4×4=64 个体素,用 64 位 bitmap 标记哪些体素存在。两层嵌套体素树(VOXEL_NUM_LEVELS=2),PrefixSum64 对 bitmap 做前缀和算体素索引。每个体素存法线分布(不是单一法线),这样即使变成体素,光照依然合理、保留树叶的体积感。
距离驱动 LOD(AutoVoxel.usf:16):
Level = floor(log2(Distance * DistanceFactor / VoxelSize))
VoxelPos = floor(VoxelPos / 2^Level)
越远 Level 越大、体素越大;越近用三角形或小体素。所以远处的树自动变成一团合理着色的体素,不用光栅化几千个亚像素三角形。
独立光栅管线(Voxel.cpp:179):VisibleBricksHash(hash去重) → AllocBlocks → FillBlocks → RasterizeBricks(ray cast DDA),完全不走三角形光栅,用光线步进(DDA)采样体素。
为什么体素路径不支持 WPO:体素是即时从深度重构的,根本没有”顶点阶段”——WPO(World Position Offset)需要在顶点阶段偏移顶点位置,体素没有顶点可偏移。所以风动改用骨骼蒙皮(FBrick.BoneIndex 存骨骼索引),而不是传统植被的 WPO。
✅ 采分要点(按层级)
- 〔及格〕 *”三角形小到亚像素了再用三角形不划算,Nanite 这时候切到体素来渲染,远处用 voxel 代替密集的三角形。”*
→ 给分理由:抓住了核心动机——亚像素三角形(一个三角形覆盖不到一个像素)是密集 foliage 的退化场景,光栅器在这种情况下效率和质量都崩,改用体素是正确方向。能说出”近像素切体素”就过了。
- 〔良好〕 *”数据结构是 FBrick,一个 brick 是 4×4×4=64 个体素,用一个 64 位 bitmap 记哪些体素被占用,再带 StartPos 和 BrickMax 定位。LOD 是按距离算的,越远 brick 越大。”*
→ 给分理由:能讲出 FBrick 的 64-bit bitmap 正好对应 4×4×4=64 这个数字关系,说明真看过结构而不是背名词;并且知道 LOD 是距离驱动、远处体素变大。知其然且数字自洽。
- 〔优秀〕 *”LOD 公式是 Level=floor(log2(Distance×DistanceFactor/VoxelSize)),本质是按距离取对数分级,然后把体素坐标量化成 floor(VoxelPos/2^Level),所以越远体素越粗、合并得越狠。渲染走的是独立管线不走三角形光栅:VisibleBricksHash 去重→AllocBlocks→FillBlocks→RasterizeBricks 用 DDA ray cast 步进体素,而不是扫描线光栅化。”*
→ 给分理由:能把 LOD 公式的”对数分级 + 坐标量化”两步讲透,并且解释了为什么是 2 的幂次(量化合并);更关键是点明体素有一套和三角形光栅完全不同的渲染路径(hash 去重 + DDA ray cast),这是”voxel 不是塞进原管线而是另起一条线”的本质理解。
- 〔加分〕 *”5.7 给 FBrick 加了 BoneIndex 支持单骨骼体素蒙皮,这就解释了为什么体素不支持 WPO 却还能做风动——WPO 要顶点偏移阶段,但体素是从深度即时重构的、压根没有顶点阶段,所以风动改用 per-brick 单骨骼蒙皮去实现。两层嵌套树 VOXEL_NUM_LEVELS=2,用 PrefixSum64 算体素在 bitmap 里的索引。还有每体素存了法线分布,保证转成体素后光照不会塌、体积感还在。”*
→ 给分理由:到了实现级证据——能引用 BoneIndex 是 5.7 新增、VOXEL_NUM_LEVELS=2、PrefixSum64 这些具体符号,并且把”为什么不支持 WPO”和”为什么风动要走骨骼”这条因果链完整闭环(没有顶点阶段→WPO 无处施加→改骨骼蒙皮)。再补上 per-voxel 法线分布保光照,说明理解了体素化的质量保持手段。
⛔ 雷区(错在哪 / 为什么错 / 正确是什么)
- “体素就是把模型转成 Minecraft 那种方块,是为了省内存才用的。”
– 错在哪:把动机搞反了。体素化的触发条件是”三角形退化到亚像素”这个几何/光栅效率问题,不是为了节省内存;体素在近像素尺度下反而要存 bitmap + 法线分布。
– 正确:体素是亚像素三角形的替代渲染表示,解决的是密集小三角形光栅低效,距离驱动 LOD 顺带降了远处开销。
- “体素当然支持 WPO,给它顶点偏移就行,风吹草动都是 WPO 实现的。”
– 错在哪:体素根本没有顶点阶段,它是从深度即时重构(DDA ray cast)出来的,WPO 这种”在顶点着色阶段偏移顶点位置”的机制无处施加。
– 正确:体素不支持 WPO,风动改用 FBrick.BoneIndex 的单骨骼蒙皮变换来做。
- “FBrick 那个 64 位就是个 ID 或者哈希值。”
– 错在哪:ReverseBrickBits 是占用位图(occupancy bitmap),64 位一一对应 4×4×4=64 个体素槽位,标记每个槽位有没有体素,不是标识符。
– 正确:它是 64-voxel 的 occupancy bitmask,配合 PrefixSum64 才能在 bitmap 里定位某个体素的数据索引。
🕳️ 似是而非陷阱(听起来对,实则半懂)
- “距离越远体素越大,所以远处更省。” —— 半懂点:结论没错但说不出机制。关键是 Level 用 log2 对数分级、VoxelPos 用 floor(/2^Level) 做坐标量化合并,是 2 的幂次量化而非线性缩放;只说”越远越大”没碰到量化这一层。
- “体素也走 Nanite 的光栅管线,只是图元换成了体素。” —— 半懂点:其实是另起的独立管线。VisibleBricksHash→AllocBlocks→FillBlocks→RasterizeBricks 是一套单独的流程,RasterizeBricks 用 DDA ray cast 步进,不是 VisBuffer 那条三角形光栅线,不能混为一谈。
- “体素化会丢光照细节,所以远处画质差点正常。” —— 半懂点:忽略了 Nanite 专门做了保光照的设计。每个体素存了法线分布(normal distribution),就是为了体素化后光照仍然合理、体积感不塌,不是放任画质降级。
🔍 追问链(面试官逐层下探)
- FBrick 里为什么偏偏是 64 位 bitmap,这个 64 是怎么来的? → 因为一个 brick 是 4×4×4 的体素块,4×4×4=64,正好用一个 64 位整数(ReverseBrickBits)每位对应一个体素槽位标记占用。
- 知道某个体素被占用了,怎么找到它对应的数据在哪? → 用 PrefixSum64 对 occupancy bitmap 做前缀和,统计该体素位之前有多少个已占用位,得到它在紧凑存储里的索引;两层嵌套(VOXEL_NUM_LEVELS=2)逐层定位。
- 体素这条渲染线和三角形那条具体差在哪一步? → 三角形走 VisBuffer64 扫描线光栅;体素走 VisibleBricksHash 去重→AllocBlocks 分配→FillBlocks 填充→RasterizeBricks,最后一步是 DDA ray cast 沿光线步进体素求交,不做三角形覆盖测试。
- 既然体素不支持 WPO,那一片草被风吹动是怎么实现的? → 走骨骼蒙皮路径:5.7 给 FBrick 加了 BoneIndex,每个 brick 绑单骨骼,用骨骼变换驱动体素整体偏移来模拟风动,绕开了需要顶点阶段的 WPO。
<a name=”q18″></a>
Q18. Nanite Skinning 怎么实现?在管线哪个阶段蒙皮?为什么远处角色不蒙皮,引擎怎么判断?

满分答案:
怎么实现 + 在哪个阶段:Nanite Skinning 在 GPU 上、在剔除和光栅之前对顶点做骨骼变换——每帧 deform。DecodeVertexBoneInfluence 从位流读出骨骼索引和权重(MAX_CLUSTER_BONE_INFLUENCES=16),对位置/法线/切线分别做加权骨骼变换。变形后的顶点再进入 Nanite 标准的 cluster culling → 光栅 → VisBuffer → 延迟着色。
顺序很关键:蒙皮必须在第一步。如果放到光栅之后,剔除和 LOD 选择拿到的就是未变形的几何,会出错(比如手臂挥出去了但剔除还按 T-pose 包围盒算)。
和传统骨骼网格的区别:传统是”skinning 出顶点 buffer → 走普通网格管线 + 固定 LOD”;Nanite 是”deform 后的顶点复用 Nanite 全套能力“——连续 LOD、VisBuffer 延迟着色、海量实例都还在。
双帧 transform 缓冲:维护当前帧 + 前一帧两组骨骼变换。前一帧位置用来算速度/运动矢量(TSR、运动模糊需要)。
为什么远处角色不蒙皮 + 怎么判断:蒙皮每帧 deform 每个顶点,开销不小。Nanite 用 AnimationMinScreenSize 做距离剔除:
bActiveSkinning = bSkinning && bIsDeforming && bEnableSkinning
屏幕投影够大才激活蒙皮(NaniteSkinningUpdateViewData.usf 做 chunk/instance 级判断);远处角色用静态体素或几何 LOD,不付蒙皮开销。注意蒙皮激活和几何剔除是解耦的两条线——一个判断”要不要 deform”,一个判断”要不要画”。
体素也能蒙皮:per-brick 单骨骼(快,植被风)vs per-cluster 多骨骼(精确)。
✅ 采分要点(按层级)
- 〔及格〕 *”Nanite 蒙皮是放在 GPU 上每帧做的,先把顶点按骨骼变形,然后再走剔除和光栅那一套。”*
→ 给分理由:抓住了最关键的阶段次序——蒙皮发生在剔除/光栅之前,是 GPU per-frame deform,变形后的几何才进标准 Nanite 管线。方向对、核心命中就及格。
- 〔良好〕 *”变形是从压缩位流里解出骨骼索引和权重(DecodeVertexBoneInfluence),然后 SkinnedPos 累加每根骨骼的变换乘权重,最多 16 根骨骼影响一个顶点。远处角色用屏幕尺寸判断要不要蒙皮,太小就不蒙了省开销。”*
→ 给分理由:能讲出蒙皮的实际计算(解码骨骼影响 + 加权累加)和 MAX_CLUSTER_BONE_INFLUENCES=16 的上限,并且知道远处剔除蒙皮是靠屏幕尺寸阈值。知其所以然。
- 〔优秀〕 *”蒙皮和culling是解耦的:要不要蒙皮由 bActiveSkinning 决定(bSkinning && bIsDeforming && bEnableSkinning 三个条件与起来),屏幕测试是 ScreenMultiple×CellRadius² >= AnimationMinScreenSize²×DrawDist² 这种平方比较避免开方。还维护当前帧和前一帧两套 transform 缓冲,专门给 TSR 和运动模糊算速度/运动矢量用,不是只为了显示当前姿势。”*
→ 给分理由:能把”激活条件是三个 bool 与”和”屏幕测试用平方避免 sqrt”讲清楚,并且点出双帧 transform 缓冲的真正用途是运动矢量(TSR/motion blur)而不仅是当前帧渲染——这是容易被忽略但很关键的一环,体现了对动态管线的完整理解。
- 〔加分〕 *”体素也能蒙皮,分两档:per-brick 单骨骼(快,用在植被风动)和 per-cluster 多骨骼(精确,用在角色)。蒙皮激活逻辑和视图剔除是分开更新的,在 NaniteSkinningUpdateViewData.usf 里。判断远处不蒙皮的核心常量是 AnimationMinScreenSize,屏幕投影小于这个就退回静态体素或几何 LOD,一帧的蒙皮开销直接省掉。”*
→ 给分理由:到了实现级——能引用 NaniteSkinningUpdateViewData.usf 这个具体 shader 文件、AnimationMinScreenSize 常量,并且区分体素蒙皮的 per-brick vs per-cluster 两种精度档位及其适用场景。把”蒙皮和剔除解耦”落到具体文件,是真读过代码的证据。
⛔ 雷区(错在哪 / 为什么错 / 正确是什么)
- “Nanite 蒙皮和普通骨骼网格一样,在顶点着色器里做,光栅的时候顺便蒙皮。”
– 错在哪:Nanite 没有传统顶点着色阶段,蒙皮是独立的 GPU compute deform pass,发生在剔除/光栅之前,变形后的顶点才喂给 culling/raster。
– 正确:先 deform(每帧 GPU 全量变形)→ 再进标准 Nanite culling/raster,两者是分离的 pass。
- “远处角色不蒙皮,是因为骨骼数据被 LOD 流送掉了 / 加载不进来。”
– 错在哪:不是数据缺失,是主动的开销决策。通过 AnimationMinScreenSize 做屏幕尺寸测试,投影太小就关掉蒙皮(bActiveSkinning 为假),改用静态体素或几何 LOD。
– 正确:远处不蒙皮是 screen-size 驱动的主动剔除,省的是每帧 deform 的计算开销,不是数据加载问题。
- “双帧 transform 缓冲是为了做插值,让动画更平滑。”
– 错在哪:当前帧+前一帧两套 transform 主要是给运动矢量服务的,TSR 和运动模糊需要知道每个表面这一帧相对上一帧移动了多少。
– 正确:双帧缓冲算的是 velocity/motion vector,供 TSR 重建和 motion blur 用,不是为动画插值。
🕳️ 似是而非陷阱(听起来对,实则半懂)
- “蒙皮就是顶点乘骨骼矩阵加权求和,Nanite 也一样。” —— 半懂点:算法本身对,但漏了 Nanite 的特殊性——骨骼索引和权重是从压缩位流里解码出来的(DecodeVertexBoneInfluence),且每 cluster 骨骼影响上限 MAX_CLUSTER_BONE_INFLUENCES=16,不是任意数量;阶段也不在顶点着色器而在独立 deform pass。
- “远处角色屏幕上很小,蒙皮看不出来所以关掉。” —— 半懂点:方向对但说不出判定机制。具体是 ScreenMultiple×CellRadius² >= AnimationMinScreenSize²×DrawDist² 这个平方形式的屏幕测试(用平方避免 sqrt),阈值常量是 AnimationMinScreenSize,不是凭感觉”看不出来”。
- “蒙皮做完就直接渲染当前帧了。” —— 半懂点:忽略了双帧 transform 缓冲。蒙皮还要维护前一帧的变换,配合当前帧算运动矢量给 TSR/运动模糊,不是变形完就只管当前帧显示。
🔍 追问链(面试官逐层下探)
- Nanite 蒙皮具体在管线哪个阶段?为什么不能放顶点着色器里? → 在剔除/光栅之前的独立 GPU deform pass 里,每帧把顶点变形好再进 culling/raster。因为 Nanite 走的是 compute 软光栅 + VisBuffer,没有传统顶点着色阶段可挂,蒙皮必须前置成单独 pass。
- 一个顶点最多受几根骨骼影响?这个上限定在哪? → MAX_CLUSTER_BONE_INFLUENCES=16,每 cluster 的骨骼影响数上限是 16;骨骼索引和权重通过 DecodeVertexBoneInfluence 从压缩位流解码。
- 怎么判断一个角色远到不需要蒙皮?写出判定形式。 → 屏幕尺寸测试 ScreenMultiple×CellRadius² >= AnimationMinScreenSize²×DrawDist²,用平方比较避免开方;同时 bActiveSkinning = bSkinning && bIsDeforming && bEnableSkinning 三条件都满足才真正蒙皮,否则退静态体素/几何 LOD。
- 为什么要存当前帧和前一帧两套骨骼 transform? → 为了算 velocity/运动矢量。TSR 时间超分和运动模糊都需要知道每个表面相对上一帧的位移,单靠当前帧变换给不出运动信息,所以双帧缓冲。
<a name=”q19″></a>
Q19. Lumen HWRT 要用 Nanite 几何,但 Nanite 是压缩动态 LOD,光追要确定三角形,怎么解决?5.7 新东西?

满分答案:
根本矛盾:光追的 BLAS(底层加速结构)需要确定的三角形 buffer——构建后稳定,射线才能查询;而 Nanite 的几何是压缩位流 + 每帧随视角变化的连续 LOD。一个要静,一个在动,这是结构性冲突。这道题想听的不只是”用 StreamOut 导出代理几何”这一句结论,而是能否数清这个矛盾会派生出哪些具体的坑,以及每个坑对应什么解法。
五个坑:
- 几何不匹配:光追用的代理几何通常比光栅 LOD 粗,主视图表面与光追世界对不上——阴影自相交(acne)、漏光要靠 ray bias 兜底,镜面反射里能看出几何变粗。
- BLAS 构建成本:海量实例 × 逐网格 BLAS,构建时间与显存双爆炸,必须预算化。
- WPO / 动画:BLAS 是几何快照,WPO 或蒙皮让表面每帧动——光追侧要么冻结形变(加剧不匹配),要么 refit 更新,而 refit 本身也是每帧开销。
- Masked any-hit:alpha 测试材质在光追里逐命中执行 any-hit shader 求值,密集植被场景 trace 成本飙升。
- Lumen HWRT 依赖:5.6/5.7 的 Lumen 弃用 SWRT detail traces 转向 HWRT(目标主机 60Hz),HWRT 必须有真实 BLAS——上面四个坑因此从”开光追才面对”变成”开 Lumen 就要面对”。
解法工具箱(逐个对上):
- StreamOut 代理(治坑①的根):Nanite 通过 StreamOut 模块在 DAG 上按一个独立的 cut error(
StreamOutCutError)选一组 cluster,导出确定的 VertexBuffer / IndexBuffer + AuxiliaryData(FNaniteStreamOutTraversalCS,NaniteStreamOut.cpp:65),再用这份代理几何构建 BLAS——光追 LOD 与光栅 LOD 解耦,各取所需。 - 切口粗化 + 构建分帧(治坑②):光追的 cut error 刻意更大(GI/反射对几何精度容忍度高);
MaxBuiltPrimitivesPerFrame限制每帧构建的图元数,超出进PendingBuilds队列分摊到多帧,削平构建尖峰。 - Refit 限量(治坑③):形变几何的 BLAS refit 同样走预算制,限制每帧更新量,接受可控的滞后换稳定帧时间。
- OMM(Opacity Micromap)(治坑④):把 alpha 测试结果烘焙成微三角形不透明掩码,硬件直接跳过全透/全不透区域的 any-hit 求值,专治 Masked 植被;UE 的 RHI 已预留接口(
ERayTracingClusterOperationFlags::ALLOW_OMM,RHIResources.h)。 - 离屏降级(治坑⑤的开销侧):
Offscreen.LODBias(默认 1.0)给离屏几何(反射里看到但主视图外)再降一档质量。
5.7 新动向(两条):
- RT Streaming:
r.RayTracing.Nanite.Streaming让光追实例的遍历也能驱动 Nanite 页面流送——此前只有光栅视图能拉页,光追看到的远处几何质量受限。 - CLAS(Cluster Acceleration Structure,簇加速结构):RHI 层出现了
CLAS_BUILD_TEMPLATES/CLAS_INSTANTIATE_TEMPLATES/ 由 CLAS 组装 BLAS 的整套接口(RHIResources.h,D3D12 RHI 已实现),与 NVIDIA RTX Mega Geometry 同源,方向是让 BLAS 直接以 Nanite 簇为构建单位、跟随连续 LOD 更新。需要说清的边界:5.7 渲染器的 Nanite 光追主路仍是 StreamOut 代理,CLAS 是已铺好的下一步,不是当前默认路径。
大局:这正是 Lumen 5.6/5.7 从 SWRT 转 HWRT 的关键支撑——HWRT 需要真实 BLAS,而 SWRT 用的是 SDF / mesh card(不需要精确几何)。所以”Lumen 转 HWRT”和”Nanite StreamOut”是配套发生的(呼应 Q10)。工程姿势上:光追几何预算(BLAS 构建 + refit + trace)要在项目早期就列进帧预算表,用”粗代理 + 分帧 + 限量”把成本曲线压平,而不是等爆帧再救火。
✅ 采分要点(按层级)
- 〔及格〕 *”Nanite 是动态 LOD 的,但光追要确定的三角形做 BLAS,所以得把某个 LOD 的几何导出来给光追用,不能让它一直变。”*
→ 给分理由:抓住了核心矛盾——Nanite 运行时连续 LOD vs 光追 BLAS 需要稳定确定的三角形集合,并且知道解法方向是”导出一份固定几何”。方向对就及格。
- 〔良好〕 *”这个导出叫 StreamOut,从 Nanite 的 DAG 里按一个 cut error 选出一层 cluster,导出顶点和索引 buffer 去建 BLAS。光追用的 LOD 和屏幕光栅的 LOD 是分开的,光追那份通常更粗,因为反射和 GI 不需要主视图那么细。”*
→ 给分理由:能说出 StreamOut 机制名、从 DAG 按 cut error 切一层导出 VB/IB,并且理解光追 LOD 和光栅 LOD 解耦、光追可以更粗。知其所以然。
- 〔优秀〕 *”具体是 FNaniteStreamOutTraversalCS 这个 compute pass 按独立的 StreamOutCutError 在 DAG 上遍历选 cluster,导出 VertexBuffer/IndexBuffer 加 AuxiliaryData 去构建 BLAS。BLAS 构建有预算控制——MaxBuiltPrimitivesPerFrame 限制每帧建多少图元,超出的进 PendingBuilds 队列分摊到后续帧,避免一帧建爆。光追自己的 cut error 更大所以 cluster 更粗,这是有意的:GI/反射对几何精度容忍度高。”*
→ 给分理由:能引用 FNaniteStreamOutTraversalCS、StreamOutCutError、MaxBuiltPrimitivesPerFrame、PendingBuilds 这些具体符号,把”按独立 cut error 遍历 DAG 导出 + BLAS 预算分帧”讲透,并且解释了光追用粗 LOD 的合理性(GI/反射容忍度高)。从机制到取舍都到位。
- 〔加分〕 *”我会把坑和解法一一对上:几何不匹配靠 StreamOut 导粗代理、Masked 飙 trace 靠 OMM 烘焙不透明掩码跳 any-hit、形变靠 refit 限量、海量 BLAS 靠 MaxBuiltPrimitivesPerFrame 分帧。5.7 还有两条新东西:RT Streaming(r.RayTracing.Nanite.Streaming)让光追遍历也驱动页面流送,以及 RHI 层的 CLAS(簇加速结构,和 NVIDIA RTX Mega Geometry 同源),方向是直接拿 Nanite 簇建 BLAS 跟随连续 LOD——但要说清 5.7 主路还是 StreamOut 代理,CLAS 是铺好的下一步。这整套是 Lumen 从 SWRT(SDF/mesh card)转 HWRT(要真 BLAS)的必要支撑。”*
→ 给分理由:到了版本级实现证据——能把五个坑和解法逐一对应(尤其 OMM 治 Masked any-hit 这种细节),引用 r.RayTracing.Nanite.Streaming、CLAS 这些 5.7 新接口,还准确划出”CLAS 已铺但非当前主路”的边界,避免把未来路径说成现状。从 SWRT→HWRT 的因果到当前实现状态都拿捏准了。
⛔ 雷区(错在哪 / 为什么错 / 正确是什么)
- “光追直接拿 Nanite 当前显示的那份三角形去 trace 就行,反正都在 GPU 上。”
– 错在哪:Nanite 当前显示的几何是每帧随视角连续变化的 LOD,BLAS 需要稳定确定的三角形集合,直接拿动态 LOD 会导致 BLAS 每帧失效/无法构建。
– 正确:必须用 StreamOut 按独立 cut error 导出一份固定的 VB/IB 再建 BLAS,和光栅显示那份解耦。
- “光追用的几何 LOD 应该比主视图更细,这样反射才清晰。”
– 错在哪:反方向了。反射/GI 对几何精度容忍度高,光追刻意用更大的 cut error 选更粗的 cluster 来省 BLAS 构建和 trace 开销,比主视图粗而不是细。
– 正确:光追 LOD 通常更粗(StreamOutCutError 更大),离屏还会再用 Offscreen.LODBias 降一档。
- “BLAS 一次性全建好就行,反正建完能复用。”
– 错在哪:海量 Nanite 实例一帧全建 BLAS 会产生构建尖峰打爆帧时间。
– 正确:有 MaxBuiltPrimitivesPerFrame 预算限制每帧构建量,超出的进 PendingBuilds 队列分摊到多帧,是分期构建不是一次建完。
- “Masked 植被的 alpha 在光追里和光栅一样便宜,建好 BLAS 就行。”
– 错在哪:alpha 测试材质在光追里要逐命中执行 any-hit shader 求值,密集植被场景命中次数巨大,trace 成本飙升,不是建完 BLAS 就没事。
– 正确:用 OMM(Opacity Micromap)把不透明结果烘焙成微三角掩码,让硬件跳过全透/全不透区域的 any-hit 求值——这是专门治 Masked any-hit 的解法。
🕳️ 似是而非陷阱(听起来对,实则半懂)
- “StreamOut 就是把 Nanite 几何导出来建 BLAS。” —— 半懂点:方向对但说不清”导哪一份”。关键是按独立的 StreamOutCutError 在 DAG 上遍历(FNaniteStreamOutTraversalCS),选的是和光栅视图不同的、通常更粗的一层 cluster,不是导出当前显示的那份。
- “光追 LOD 和光栅 LOD 不一样。” —— 半懂点:知道不一样但说不出怎么不一样、为什么。具体是光追用更大的 cut error 选更粗 cluster,因为 GI/反射对几何精度容忍度高;不是随便不一样,是有方向(更粗)有理由(容忍度高)的解耦。
- “5.7 让光追也能用 Nanite 几何了。” —— 半懂点:把时间点和机制说糊了。StreamOut 建 BLAS 不是 5.7 才有;5.7 新增的是 r.RayTracing.Nanite.Streaming(光追遍历驱动页面流送)这些流送优化,以及 RHI 层铺好的 CLAS(簇加速结构)接口。要注意 CLAS 是已铺的下一步,5.7 渲染器主路仍是 StreamOut 代理,把 CLAS 说成”5.7 已经用上的主路”就把未来路径当成现状了。
🔍 追问链(面试官逐层下探)
- Nanite 的 LOD 是连续变化的,光追 BLAS 要稳定三角形,这个矛盾具体怎么解? → 用 StreamOut:FNaniteStreamOutTraversalCS 按独立 StreamOutCutError 在 DAG 上选一层 cluster,导出 VertexBuffer/IndexBuffer + AuxiliaryData 去建 BLAS,这份和每帧变的光栅 LOD 解耦、固定下来。
- 光追那份 LOD 和主视图光栅的 LOD 关系是什么?谁粗谁细,为什么? → 光追通常更粗(cut error 更大)。因为反射和 GI 对几何精度容忍度高,不需要主视图那么细,用粗 cluster 省 BLAS 构建和 trace 开销。
- 几百万个 Nanite 实例都要建 BLAS,一帧建得完吗?怎么控制? → 建不完也不能一帧建。用 MaxBuiltPrimitivesPerFrame 限每帧构建图元数,超出的进 PendingBuilds 队列分摊到后续帧,避免构建尖峰打爆帧时间。
- 5.7 在这块加了什么新东西?解决什么? → r.RayTracing.Nanite.Streaming 让光追实例遍历驱动 Nanite 页面流送(光追也能拉需要的页),Offscreen.LODBias 默认 1.0 降离屏质量;整体是支撑 Lumen 从 SWRT(SDF/mesh card)转 HWRT(需真实 BLAS)的关键,因为只有 HWRT 才需要把 Nanite 几何变成 BLAS。
- 密集植被开光追特别卡,除了降 LOD 还能怎么治? → 根因是 Masked 材质逐命中跑 any-hit。用 OMM(Opacity Micromap)把 alpha 测试结果烘焙成微三角不透明掩码,硬件在全透/全不透区域直接跳过 any-hit 求值,UE 的 RHI 已预留 ALLOW_OMM 接口;这比单纯降 LOD 更对症。
- 听说 5.7 能直接拿 Nanite 簇建 BLAS,是这样吗? → 方向上是——RHI 层已经有 CLAS(簇加速结构)整套接口(CLAS_BUILD_TEMPLATES / INSTANTIATE / 由 CLAS 组 BLAS,D3D12 已实现),和 NVIDIA RTX Mega Geometry 同源,目标是让 BLAS 以 Nanite 簇为构建单位跟随连续 LOD。但 5.7 渲染器的 Nanite 光追主路仍是 StreamOut 代理几何,CLAS 是铺好的下一步而非当前默认。
<a name=”q20″></a>
Q20.(权衡题)VisBuffer64 用 InterlockedMax 一次完成深度测试+写入,强制了什么限制?要支持半透明/自定义深度怎么改?

满分答案:
这题考的是从一个精巧实现反推它的约束边界——”精巧”和”受限”是同一个设计的一体两面。
精巧之处(Q1):深度放高位 → 比较 64 位整数等价于比较深度 → 一条 ImageInterlockedMaxUInt64 同时完成深度测试 + 可见性写入。
它锁死了什么:
锁死① — 深度函数只能 Greater-Or-Equal:atomic 的语义只有 min / max。深度放高位 + InterlockedMax 实现的是”深度大者胜”(inverted-Z 下即更近者胜)。要换成 Less 或任意自定义比较,单条 atomic 表达不了。而且全管线都依赖这个约定——Early-Z、HZB 构建都假设 inverted-Z + max。
锁死② — 不支持半透明:半透明需要保留多层片元按顺序混合(alpha blend)。而 VisBuffer 每像素只用 InterlockedMax 留一个胜者(单层不透明)。所以 Nanite 不支持半透明不是”还没实现”,是架构上的”每像素单值”——多层信息根本塞不进这个 64 位 atomic 槽。
要支持半透明/自定义深度,怎么改(满分要答这层):
- 半透明:必须采用独立的渲染路径,无法纳入 VisBuffer。可选方案:per-pixel linked list(A-buffer)、OIT(顺序无关透明)、或单独的半透明 pass。UE 现状就是:半透明走传统前向/延迟管线,不进 Nanite VisBuffer。
- 自定义深度测试:同样无法用单条 atomic 表达,需要 read-modify-write + 锁(GPU 上代价不可接受)或多 pass。
Principal 思维:能讲清”精巧”和”受限”是一回事——这个 64 位 atomic 的优雅正来自它假设”每像素单层不透明、深度单调比较”——比只会夸 atomic 巧妙高一个层级。
✅ 采分要点(按层级)
- 〔及格〕 *”VisBuffer 用一个 InterlockedMax 原子操作同时做深度测试和写入,所以比较方式只能是’大的赢’,相当于固定成 GE 这一种深度比较。”*
→ 给分理由:抓住了核心约束——InterlockedMax 这个原子操作本身只能实现”取最大”,等价于强制 Greater-or-Equal 一种深度比较,没法自定义比较函数。方向对、命中限制本质就及格。
- 〔良好〕 *”深度被编码进 64 位的高位,所以靠一次 atomic max 就能在比深度的同时把对应的可见性信息写进低位。整条管线都依赖这个约定——inverted-Z 加 max,Early-Z 和 HZB 构建都假设这个。半透明做不了,因为半透明要叠很多层,而 VisBuffer 每个像素只留一个赢家。”*
→ 给分理由:能说出深度编码进高位、所以 InterlockedMax 一次完成”比深度+写可见性”,并且知道全管线(Early-Z/HZB)都绑定 inverted-Z+max 这个约定,还点出半透明冲突在”单像素单赢家 vs 多层混合”。知其所以然。
- 〔优秀〕 *”半透明冲突是架构级的,不是没实现:半透明需要保留每像素多层、按深度顺序混合,而 VisBuffer 的设计就是每像素只存一个 64 位赢家值(单层不透明),这俩从根上对立,所以 Nanite 不支持半透明是这个原子设计的必然结果,不是功能没做。自定义深度同理——atomic 硬件语义只有 min/max,任意比较函数得 read-modify-write 再加锁,GPU 上几百万像素并发加锁代价不可接受。”*
→ 给分理由:能把”不支持半透明”从”功能缺失”上升到”架构必然”——单像素单值 vs 多层混合是根本对立,并且对自定义深度给出硬件层面的理由(atomic 只有 min/max,任意比较要 RMW+锁,GPU 高并发下锁不可行)。辩证地讲透了”为什么这个限制是设计的一体两面”。
- 〔加分〕 *”要支持半透明只能用独立路径——per-pixel linked list、OIT,或者单独的半透明 pass,UE 现状就是半透明仍走传统光栅管线,不塞进 Nanite 的 64-bit atomic。这里的精巧和受限是同一个设计的两面:正因为压成一个 atomic max 才能在百万三角形下做到无锁、高吞吐的深度+可见性合并,代价就是只能单层不透明、只能 GE 比较。想要灵活就失去这个吞吐,想要吞吐就接受这个约束。”*
→ 给分理由:到了设计哲学的闭环——不仅给出半透明的替代方案(per-pixel linked list/OIT/独立 pass,且知道 UE 现状半透明走传统管线),更把”精巧”和”受限”统一成同一设计的一体两面:单 atomic max 换来无锁高吞吐,代价是单层不透明 + GE 比较。这种”性能与灵活性是同一枚硬币两面”的辩证总结是满分视角。
⛔ 雷区(错在哪 / 为什么错 / 正确是什么)
- “Nanite 不支持半透明只是还没实现,以后版本补上就行。”
– 错在哪:这不是实现进度问题,是 VisBuffer 架构的根本约束。每像素只存一个 64 位赢家值,物理上就只能表示单层不透明,半透明要的多层有序混合无处安放。
– 正确:是架构层面的对立(单像素单值 vs 多层混合),要支持半透明必须另起 OIT/linked-list/独立 pass,不是给 VisBuffer 打补丁能解决的。
- “InterlockedMax 既能 max 也能配置成别的比较,改成 InterlockedMin 或自定义就支持其他深度测试了。”
– 错在哪:atomic 硬件只提供 min/max 这种固定语义,任意比较函数需要 read-modify-write 加锁来保证并发正确,GPU 上百万像素并发加锁代价不可接受。
– 正确:受限于 atomic 只有 min/max,VisBuffer 选定 inverted-Z + max(GE)这一种,全管线(Early-Z/HZB)都绑死这个约定,不能随意换比较函数。
- “深度和可见性是分两次写的,先 atomic 比深度再写可见性。”
– 错在哪:分两次就不原子了,两个像素竞争时可能出现深度赢了但可见性被别人覆盖的撕裂。
– 正确:深度编码进 64 位高位、可见性在低位,一次 InterlockedMax 同时完成比较和写入,靠”高位主导排序”保证赢家的可见性和它的深度一起原子落盘。
🕳️ 似是而非陷阱(听起来对,实则半懂)
- “VisBuffer 用 atomic max 做深度测试。” —— 半懂点:只说对了一半。关键不是”做深度测试”,而是深度编进高位后,一次 atomic max 同时完成”深度比较 + 可见性写入”这两件事,是把两步压成一个原子操作,省掉了单独的写可见性步骤。
- “半透明要混合,Nanite 没做混合所以不支持。” —— 半懂点:说成了”没做”,其实是”做不了”。VisBuffer 每像素结构上只有一个赢家槽位,半透明要的是每像素保留多层、按序混合,是数据结构层面容不下,不是少写了一段混合代码。
- “用 InterlockedMin 就能支持正向 Z(near 赢)了。” —— 半懂点:理论上 min 能选近的,但忽略了全管线绑定。整条线(Early-Z、HZB 构建)都按 inverted-Z + max 这个约定写死,单换一个 atomic 方向会和 HZB/Early-Z 的假设全部冲突,不是改一个调用就行。
🔍 追问链(面试官逐层下探)
- 为什么一个 InterlockedMax 就能同时搞定深度测试和可见性写入?数据怎么摆的? → 把深度放 64 位的高位、可见性 ID 放低位,atomic max 比的是整个 64 位值、由高位深度主导,所以最大值的赢家其低位可见性会和深度一起被原子写入,一次完成两件事。
- 为什么 InterlockedMax 决定了只能 GE 一种深度比较?换个比较函数行不行? → 因为 atomic 硬件只有 min/max 固定语义,max 等价于”大者胜”即 GE;任意比较函数得 read-modify-write 加锁,GPU 百万像素并发加锁代价不可接受,所以比较方式被锁死。
- 那为什么这个设计直接导致 Nanite 不支持半透明? → 因为 VisBuffer 每像素只存一个 64 位赢家值(单层不透明),而半透明需要每像素保留多层、按深度顺序混合,单像素单值的结构根本容不下多层,是架构对立不是功能缺失。
- 如果真要在引擎里支持半透明,怎么做?UE 现在怎么处理? → 用独立的一套:per-pixel linked list、OIT,或单独的半透明 pass,不塞进 Nanite 的 64-bit atomic。UE 现状就是半透明继续走传统光栅管线,和 Nanite 不透明 VisBuffer 分开处理。
<a name=”q21″></a>
Q21.(系统设计题)10 万棵树的森林(几百万三角形/棵、风、碰撞、远处 GI),怎么设计?理由和风险?

满分答案:
没有标准答案,看是否能把前面的知识串成一个权衡过的方案,并识别陷阱。我按选型 + 风险 + 务实落地讲。
① 几何 — Nanite Foliage(Voxel + Instancing):唯一几何存一份,引用 10 万次,内存从 TB 级压到 GB 级;远处的树靠距离驱动 LOD 自动变体素团。
② 风 — Nanite Skinning:用骨骼模拟(不是 WPO,体素路径也支持);AnimationMinScreenSize 让远处的树不做蒙皮,省开销。
③ GI — Lumen HWRT:但树的光追 BLAS 用粗 LOD(大 StreamOutCutError),Offscreen.LODBias 降离屏质量,并用 MaxBuiltPrimitivesPerFrame 控制 BLAS 构建预算。
④ 阴影 — VSM:配合 Nanite 几何密度。⚠ 但 10 万投影体 + 树随风动 → VSM 页缓存频繁失效(树一动对应页就 invalidate),这个动态失效开销要纳入预算。
⑤ 碰撞 — 陷阱点!:Nanite Foliage 在 5.7 仍是 Experimental,碰撞/物理支持不完整。所以碰撞不能依赖 Nanite——要用独立的简化碰撞体(凸包/胶囊)。而且 10 万棵不可能每棵都建碰撞体(直接爆),只能对角色附近的树动态启用碰撞,或用程序化的近距离碰撞代理。这是这道题最容易踩的坑。
风险识别(满分必须主动列):Foliage 实验性、碰撞缺失(最大坑)、VSM 动态失效开销、BLAS 构建尖峰、流送带宽(需 SSD)、风的蒙皮成本、海量实例的剔除/Instance Hierarchy 压力。
务实落地:先做 demo 验证 60fps 预算,把碰撞/物理 carve out 单独处理,而不是纸上谈兵全开。能说出这句,比方案列得再全都重要。
✅ 采分要点(按层级)
- 〔及格〕 *”几何用 Nanite Foliage,10 万棵树共享一份几何 instancing 引用,风用 Nanite Skinning 不用 WPO,远处靠 Nanite 自动 LOD 变体素,GI 用 Lumen。”*
→ 给分理由:能把几何/风/LOD/GI 四块各自对应到正确的 Nanite 子系统(Foliage instancing、Skinning 非 WPO、距离体素、Lumen),整体方案骨架搭对就及格。
- 〔良好〕 *”几何关键是唯一几何只存一份被引用 10 万次,把 TB 级降到 GB 级。风动走骨骼蒙皮不是 WPO,因为体素路径不支持 WPO,远处用 AnimationMinScreenSize 让树不蒙皮省开销。GI 用 Lumen HWRT,树的 BLAS 用粗 LOD。碰撞是个坑,不能指望 Nanite,得单独做简化碰撞体。”*
→ 给分理由:能讲出 instancing 的存储量级收益(TB→GB)、风动为何走骨骼(体素不支持 WPO)、远处不蒙皮的常量(AnimationMinScreenSize),并且已经意识到碰撞是陷阱要单独处理。知其所以然,且抓到了碰撞这个关键陷阱。
- 〔优秀〕 *”GI 这块树的光追 BLAS 要用 StreamOut 导粗 LOD(大 cut error),离屏再用 Offscreen.LODBias 降一档,同时控制 BLAS 构建预算别让 10 万棵一帧建爆。碰撞必须 carve out:Nanite Foliage 在 5.7 碰撞物理还是 Experimental 不完整,10 万棵不可能每棵都有碰撞体,只能对角色附近的树动态启用简化凸包/胶囊碰撞。阴影用 VSM,但要警惕 10 万投影体里树一动就 invalidate 页缓存的开销。”*
→ 给分理由:能把每个子系统的”取舍和代价”讲透——GI 的粗 BLAS+离屏 bias+构建预算三件套、碰撞的”只对角色附近动态启用”务实方案、VSM 的页缓存失效风险。不是只堆技术名词,而是每块都点出了风险和对策,体现系统设计的全局权衡能力。
- 〔加分〕 *”风险要分级列清楚:Foliage 碰撞物理实验性(5.7 还不完整)、VSM 动态失效开销、BLAS 构建尖峰、流送带宽吃 SSD。务实落地是先做小 demo 验证 60fps 预算够不够,再把碰撞物理 carve out 成特殊处理而不是假设 Nanite 全包。树一动 VSM 页就 invalidate 这条要特别标注,因为 10 万棵风吹时是持续失效,可能直接吃掉 VSM 的缓存收益。”*
→ 给分理由:到了工程务实层面——能把风险按”实验性功能/动态失效/构建尖峰/带宽”分类列全,并给出”先 demo 验证预算 + carve out 碰撞”的落地路径,还特别指出 VSM 在持续风动下页缓存可能被打穿(10 万棵持续 invalidate 抵消缓存收益)这个隐蔽但致命的点。从纸面方案落到可交付的工程判断,是满分系统设计该有的收尾。
⛔ 雷区(错在哪 / 为什么错 / 正确是什么)
- “10 万棵树每棵都开 Nanite 碰撞,物理直接交给 Nanite 处理就行。”
– 错在哪:Nanite Foliage 在 5.7 的碰撞/物理还是 Experimental、不完整,不能依赖;且 10 万棵每棵都挂碰撞体在内存和物理求解上都不现实。
– 正确:碰撞必须 carve out 独立做简化碰撞体(凸包/胶囊),且只对角色附近的树动态启用,远处不挂碰撞。
- “树的风动用 WPO 顶点偏移做,Nanite 都支持的。”
– 错在哪:森林几何走 Nanite Foliage 的体素路径,体素不支持 WPO(没有顶点阶段)。
– 正确:风动走 Nanite Skinning 骨骼蒙皮(体素路径也支持骨骼),并用 AnimationMinScreenSize 让远处树不蒙皮省开销。
- “用了 VSM 阴影,10 万棵树的阴影就稳了不用担心。”
– 错在哪:VSM 靠页缓存省开销,但树被风吹一动对应的页就 invalidate,10 万棵持续风动会持续触发页缓存失效,开销可能反而很高甚至抵消缓存收益。
– 正确:要把”动态失效开销”列为明确风险,评估风动频率对 VSM 缓存命中的冲击,必要时限制风动幅度或投影范围。
🕳️ 似是而非陷阱(听起来对,实则半懂)
- “Nanite Foliage 把 10 万棵的几何都装下了,所以森林什么都能交给 Nanite。” —— 半懂点:几何和渲染确实搞定了,但碰撞物理没搞定。5.7 Foliage 碰撞还是 Experimental,把碰撞也假设成 Nanite 全包是典型半懂,碰撞必须单独 carve out。
- “远处树变体素团是自动的,不用管。” —— 半懂点:体素化 LOD 是自动的没错,但”远处树要不要蒙皮”得靠 AnimationMinScreenSize 主动控制,且体素风动走的是 per-brick 单骨骼而非 WPO,不是全自动零配置,蒙皮开销要主动剔除。
- “树用 Lumen HWRT 做 GI,BLAS 建好就有全局光照了。” —— 半懂点:忽略了 BLAS 的 LOD 和预算。树的 BLAS 要用 StreamOut 导粗 LOD(大 cut error)省开销,10 万棵还要控 MaxBuiltPrimitivesPerFrame 防构建尖峰、离屏用 Offscreen.LODBias 降级,不是无脑建全精度 BLAS。
🔍 追问链(面试官逐层下探)
- 10 万棵每棵几百万三角形,几何上为什么不会爆内存? → 因为 Nanite Foliage 用 instancing:唯一几何只在显存存一份,10 万棵都是对这份的引用(带各自 transform),存储从理论 TB 级降到 GB 级,不是真存 10 万份几何。
- 风吹树动具体怎么实现?为什么不用 WPO? → 走 Nanite Skinning 骨骼蒙皮,体素路径用 per-brick 单骨骼驱动偏移。不用 WPO 是因为森林走体素路径、体素从深度即时重构没有顶点阶段,WPO 这种顶点偏移无处施加;远处再用 AnimationMinScreenSize 关掉蒙皮省开销。
- 这套方案里你觉得最大的坑是什么? → 碰撞。Nanite Foliage 5.7 碰撞物理是 Experimental 不完整,不能依赖;10 万棵也不可能每棵挂碰撞体。只能 carve out 独立简化碰撞(凸包/胶囊)且只对角色附近动态启用。其次是 VSM 在持续风动下页缓存持续 invalidate 的开销。
- 这方案敢直接上生产吗?怎么落地降风险? → 不敢直接梭哈。先做小规模 demo 实测 60fps 预算(几何/蒙皮/BLAS/VSM 各占多少),把碰撞物理 carve out 成特殊处理,确认流送带宽 SSD 扛得住,再逐步放大规模;Foliage 碰撞实验性、BLAS 构建尖峰、VSM 动态失效这几条要持续盯。
工作流与平台落地(Q22–Q23)
<a name=”q22″></a>
Q22. 团队从 UE4 升级到 UE5 启用 Nanite,美术工作流具体怎么改?新旧流程对比、必设项、验收。

满分答案:
这题考的不是”Nanite 多好用”,而是能否说清工作流的工时是怎么转移的——Nanite 不是把美术的活变没了,是把”为预算减面、烤 LOD”这部分换成了”清理 + 设置 + 验收”。
新旧流程对比:
旧工作流是一条和”面数预算”反复谈判的链:高模雕刻 → 重拓扑减面(为预算妥协)→ 烘焙法线/AO → 手工或半自动出 LOD0~4 → 进引擎调切换距离 → 性能不达标回头继续减面。其中 LOD 链的制作与维护是重复劳动,且随资产数量线性增长——这是开放世界项目内容产能的长期税负。
Nanite 工作流短得多:高模(雕刻/扫描)→ 基础清理(水密性、UV、材质合并)→ 导入勾选 Enable Nanite(LOD 链引擎自动构建)→ 设置 Fallback 与精度参数 → 用可视化工具验收。但要清醒:这不是零工作量,是工作量转移——材质合并仍重要(slot 越少着色分箱越高效)、水密性和 UV 质量仍要管(影响简化质量与隐式切线精度),而且新增了高模源文件巨大带来的 DCC 文件管理与版本库存储压力,这是过去没有的。
两个必设项(这是 Tech Lead 该交代清楚的):
- Fallback Mesh(回退网格):从源网格自动减面生成的传统网格,在不支持 Nanite 的平台顶替渲染,同时默认承担复杂碰撞和光照烘焙。质量由
FallbackTarget/FallbackPercentTriangles/FallbackRelativeError(EngineTypes.h的FMeshNaniteSettings)控制,默认 Auto。跨平台项目必须显式设置并在低端机实测——太粗低端画质崩,太细包体和内存白付。 - Position Precision(位置精度):默认 Auto,引擎按网格密度自动决定量化步长。两类资产建议手动:拼接精度敏感的模块化构件(接缝处量化台阶会放大),以及尺度异常巨大的单体。另有按需项
bExplicitTangents——对 UV 镜像/接缝处法线贴图质量敏感的资产可开,代价是顶点数据变大。
美术该知道的限制: 半透明、Morph 不支持(与传统管线同屏混用没问题,半透明物体继续走老管线);WPO 可用但有代价(受影响的 cluster 走可编程光栅,光栅成本上升,大幅位移还要处理包围盒膨胀);体素路径完全不支持 WPO/Displacement。
验收手段: 用 r.Nanite.Visualize 切到 Overdraw / Triangles / Clusters 逐场景检查,重点看 overdraw 热区(通常来自大面积 Masked 或薄片叠层),配合 stat GPU 看 Nanite 各 pass 耗时。让数据而不是直觉决定开关。
✅ 采分要点(按层级)
- 〔及格〕 *”Nanite 之后美术不用手工做 LOD 了,高模直接进引擎,省了减面和烤 LOD 那些活。”*
→ 给分理由:抓住了最核心的变化——LOD 链消失、高模直入,方向对,及格。
- 〔良好〕 *”旧流程是高模→减面→烤法线→做 LOD→调距离,每步都在和预算谈判,LOD 链还是随资产线性增长的重复劳动。Nanite 是高模→清理→勾 Enable→设 Fallback/精度→验收。但不是零工作量,是把减面换成了清理和设置,材质合并、UV、水密性都还得管。”*
→ 给分理由:能完整对比新旧流程,并准确指出”工作量转移而非消失”,知其所以然。
- 〔优秀〕 *”必设项有两个:Fallback Mesh 控不支持平台的降级和复杂碰撞/光照烘焙,跨平台必须显式设且低端机实测;Position Precision 默认 Auto,但模块化拼接件和尺度异常的单体要手动。还有高模源文件巨大带来的版本库存储压力,是新流程独有的成本。验收不能拍脑袋,要用 r.Nanite.Visualize 看 overdraw 热区。”*
→ 给分理由:把”必设项 + 为什么必设 + 新增成本 + 工程化验收”讲全,体现了从美术到 TA 到程序的全链路视角。
- 〔加分〕 *”Fallback 质量是 FallbackTarget/PercentTriangles/RelativeError 控的,注意它即便全平台支持 Nanite 也不能无视——默认还承担碰撞和光照烘焙。WPO 在 Nanite 里会让 cluster 走可编程光栅路径、抬升光栅成本,所以美术用 WPO 要节制。bExplicitTangents 是给镜像 UV/接缝敏感资产的兜底开关。”*
→ 给分理由:给到字段级证据和 WPO 成本机制,说明理解到实现层而非只背流程。
⛔ 雷区(错在哪 / 为什么错 / 正确是什么)
- “开了 Nanite 美术就彻底不用管优化了,随便堆面。”
– 错在哪:几何面数确实解放了,但材质 slot 数、UV 质量、水密性仍直接影响着色效率和简化质量;半透明/Morph 仍不支持,WPO 仍有代价。
– 正确:是工作量转移——减面/烤 LOD 的活没了,但清理、材质合并、按限制清单避坑、验收的活还在。
- “Fallback Mesh 只有不支持 Nanite 的老平台才用得上,主流平台可以不管。”
– 错在哪:Fallback 默认还承担复杂碰撞和光照烘焙,即便目标平台全支持 Nanite,它的质量也影响碰撞和烘焙结果。
– 正确:fallback 质量参数任何项目都要显式设,不能当成只为老平台存在的角落。
- “高模直接进引擎,所以美术这边存储和文件管理压力反而更小了。”
– 错在哪:高模源文件(雕刻/扫描)体积巨大,DCC 文件和版本库的存储压力是新流程独有的新增成本。
– 正确:运行时 .uasset 也变大,源文件更是巨大,存储/版本管理压力是上升而非下降。
🕳️ 似是而非陷阱(听起来对,实则半懂)
- “Nanite 工作流就是导入时勾个 Enable,别的都不用做。” —— 半懂点:勾选只是触发自动构建,前面的清理(水密/UV/材质合并)和后面的必设项(Fallback/精度)、验收都省不掉;只说”勾一下”是把工作流简化得失真了。
- “Fallback 就是低模 LOD,和传统 LOD 一回事。” —— 半懂点:Fallback 是构建时自动从源网格简化的单一回退网格,服务于”不支持平台降级 + 碰撞 + 烘焙”,不是给运行时按距离切的离散 LOD 链,Nanite 运行时是连续 LOD(Q5)。
- “验收看看画面没穿帮就行。” —— 半懂点:肉眼看不出 overdraw 高开销区,要靠 r.Nanite.Visualize 的 overdraw 视图 + stat GPU 定位,凭直觉验收会漏掉性能陷阱。
🔍 追问链(面试官逐层下探)
- 旧流程里 LOD 链为什么是”长期税负”? → 它是随资产数量线性增长的重复劳动:每个资产都要做一套 LOD0~4,高模改一版下游整条链重做,开放世界资产一多,这部分工时长期摊在内容产能上。
- 既然 Nanite 自动出 LOD,为什么还要管 UV 和水密性? → UV 质量影响隐式切线推导的精度(Q16)和简化时的 UV 误差度量;水密性影响简化质量。这些是构建质量的输入,不会因为 LOD 自动化而免除。
- Fallback 太粗或太细分别什么后果? → 太粗:不支持 Nanite 的平台画质崩塌、碰撞/烘焙失真;太细:包体和内存白付(fallback 数据变大)。所以要按最低规格实测找平衡,不能用默认值蒙混。
- 怎么向美术解释”WPO 能用但要节制”? → WPO 会让受影响的 cluster 走可编程光栅路径,光栅成本上升,大幅位移还要处理包围盒膨胀;不是不能用,是用多了有性能账,密集 WPO(如大片植被旧做法)要评估改走 Foliage 体素+骨骼路径(Q9/Q17)。
追问准备:若问”哪些资产到底该不该开 Nanite”——引到 Q7 的逐资产决策树;若问”不支持平台怎么降级”——引到 Q23。
<a name=”q23″></a>
Q23. Nanite 怎么做多平台兼容?哪些平台不支持、靠什么降级、移动端为什么基本不可用?

满分答案:
Nanite 的多平台策略站在三条腿上:硬件能力 gate 决定能否启用、Fallback Mesh 提供自动降级、cook 时按平台双向裁剪数据。
第一条腿——硬件能力 gate。 运行时是否启用 Nanite 由一组显式 gate 决定(RenderUtils.cpp 的 DoesRuntimeSupportNanite):平台支持(数据驱动的 GetSupportsNanite)+ GPUScene 支持 + 项目级 r.Nanite.ProjectEnabled + 64 位图像原子可用 + 当前非 forward shading。其中 64 位原子是最硬的门槛(NaniteAtomicsSupported):GRHISupportsAtomicUInt64 必须为真;Windows 上默认 r.Nanite.RequireDX12=1,只允许 DX12 或 Vulkan(DX11 被排除);DX12 还要求 SM6.6 原子支持——旧版 Windows 10(1909 之前)或旧驱动直接不支持。这条 gate 正是 VisBuffer64 那条 InterlockedMax(Q1)在硬件层的投影。
平台矩阵(5.7 现状):
- 完整支持:PC(DX12 SM6.6 / 新驱动 Vulkan)、PS5、Xbox Series、桌面 Metal(Apple Silicon);
- 基本不可用:移动端(见下);
- 不支持 → 走 fallback:上代主机 PS4/XB1、DX11/旧驱动。
第二条腿——Fallback Mesh 自动降级。 每个 Nanite 网格携带一份构建时自动简化的传统网格。不支持 Nanite 的平台/RHI 上,渲染自动切到 fallback 走传统管线——同资产双形态,项目无需维护两套内容。注意 fallback 默认还承担复杂碰撞与光照烘焙,所以即便目标平台全支持 Nanite,它的质量参数也不能无视。
第三条腿——Cook 双向 strip。 支持 Nanite 的平台,cook 时可剥离 fallback mesh 数据省包体(ShouldStripNaniteFallbackMesh,StaticMesh.cpp,由平台级 ShouldStripNaniteFallbackMeshes() 与资产级 GenerateFallback 联动);不支持的平台反向剥离 Nanite 数据。每个平台只背自己要用的数据——双形态不等于双倍包体。
移动端为什么基本不可用(这是这题的考点): 64 位图像原子在移动 GPU 上普遍缺失。即便走 UlongType 的 uint2 模拟路径,仍要求编译器支持 64 位原子指令(Platform.ush:1352)——所以 Nanite 在移动端不是”性能差”,是根本跑不起来,这是硬件能力原因,不是 Epic 的产品策略。VisBuffer 那套”深度+ID 一次原子写”的设计从根上依赖 64 位原子,移动端缺这个地基,整套就不成立。
落地清单: 锁定最低规格(要覆盖 DX11/旧驱动用户?要 → fallback 质量是 P0 项);美术验收覆盖 Nanite 形态 + fallback 形态两种;包体预算按平台拆 Nanite 数据 / fallback 数据两栏;支持平台上再用逐平台 scalability(如 r.Nanite.MaxPixelsPerEdge)调质量档。
✅ 采分要点(按层级)
- 〔及格〕 *”Nanite 不是所有平台都支持,不支持的平台用 fallback 的传统网格顶上;移动端基本用不了。”*
→ 给分理由:抓住了”平台分支持/不支持 + fallback 降级 + 移动端受限”三个核心结论,方向对,及格。
- 〔良好〕 *”能不能开由一组 gate 决定,最关键是要 64 位图像原子,Windows 上默认只允许 DX12/Vulkan。不支持的平台自动切 fallback mesh 走传统管线,同一个资产两种形态。移动端因为没有 64 位原子基本跑不起来。”*
→ 给分理由:能讲出 gate 的核心条件(64 位原子 + DX12/Vulkan)和 fallback 的双形态机制,并知道移动端受限的直接原因,知其所以然。
- 〔优秀〕 *”三条腿:硬件 gate(64 位原子是最硬门槛,是 VisBuffer 那条 InterlockedMax 在硬件层的要求)、Fallback Mesh 自动降级(还默认承担碰撞和光照烘焙)、cook 时双向 strip(支持平台剥 fallback、不支持平台剥 Nanite,每平台只背自己的数据)。移动端不可用是因为 64 位图像原子普遍缺失,连 uint2 模拟都要编译器支持 64 位原子指令,是硬件地基问题不是性能问题。”*
→ 给分理由:把三条腿讲全,并把”移动端不可用”精确归因到硬件能力(而非性能),还点出它和 Q1 VisBuffer 设计的因果联系,是系统级理解。
- 〔加分〕 *”gate 在 RenderUtils.cpp 的 DoesRuntimeSupportNanite,64 位原子检查在 NaniteAtomicsSupported——默认 RequireDX12=1 排除 DX11,DX12 还要 SM6.6 原子,旧 Win10 1909 前不行。cook strip 在 StaticMesh.cpp 的 ShouldStripNaniteFallbackMesh,平台级和资产级 GenerateFallback 联动。双形态不等于双倍包体就是因为这个双向 strip。”*
→ 给分理由:给到函数级证据(DoesRuntimeSupportNanite/NaniteAtomicsSupported/ShouldStripNaniteFallbackMesh)和具体 CVar(RequireDX12、SM6.6),证明读到了源码层。
⛔ 雷区(错在哪 / 为什么错 / 正确是什么)
- “Nanite 全平台都能跑,移动端只是慢一点。”
– 错在哪:移动端不是慢,是 64 位图像原子普遍缺失,VisBuffer 那套原子写的地基不成立,基本跑不起来。
– 正确:移动端因硬件能力(缺 64 位原子)基本不可用,是能不能跑的问题,不是快慢问题。
- “双形态(Nanite + fallback)会让包体翻倍。”
– 错在哪:cook 时双向 strip——支持平台只留 Nanite 数据剥掉 fallback,不支持平台只留 fallback 剥掉 Nanite,每个平台只背自己要用的那份。
– 正确:双形态不等于双倍包体,靠 cook 双向 strip 保证每平台只打包它实际用的数据。
- “Fallback Mesh 只是给老平台渲染用的,全支持平台可以直接 strip 掉不管质量。”
– 错在哪:fallback 默认还承担复杂碰撞和光照烘焙,质量太差会影响碰撞和烘焙;而且要 strip 也是平台级/资产级开关联动决定的,不是随便扔。
– 正确:fallback 质量任何项目都要显式管;strip 与否由 ShouldStripNaniteFallbackMeshes 平台策略 + 资产级 GenerateFallback 共同决定。
🕳️ 似是而非陷阱(听起来对,实则半懂)
- “Nanite 要 DX12 是因为 DX12 性能更好。” —— 半懂点:要 DX12/Vulkan 不是为性能,是因为需要 64 位图像原子,DX11 提供不了(默认 RequireDX12=1 排除 DX11);这是能力门槛不是性能优选。
- “移动端以后驱动更新就能支持 Nanite 了。” —— 半懂点:核心卡点是 64 位图像原子的硬件/编译器支持,不是驱动调优能解决的;连 uint2 模拟路径都要编译器支持 64 位原子指令,地基不在就不成立。
- “fallback 是运行时实时简化出来的。” —— 半懂点:fallback 是构建(cook)时离线从源网格简化好的一份网格,不是运行时实时生成;运行时只是按平台能力选择渲染 Nanite 形态还是 fallback 形态。
🔍 追问链(面试官逐层下探)
- 决定能不能启用 Nanite 的 gate 具体查什么? → DoesRuntimeSupportNanite:平台 GetSupportsNanite + GPUScene + r.Nanite.ProjectEnabled + 64 位原子(NaniteAtomicsSupported)+ 非 forward shading,全满足才启用。
- 为什么 64 位原子是最硬的门槛? → 因为 VisBuffer64 用一条 InterlockedMax 把深度+可见性 ID 打包做原子写(Q1),这依赖 64 位图像原子;没有它整套可见性写入无法保证并发正确,所以是地基级硬依赖。
- 双向 strip 具体怎么决定剥哪份? → ShouldStripNaniteFallbackMesh 由平台级 ShouldStripNaniteFallbackMeshes() 和资产级 GenerateFallback 联动:支持 Nanite 的平台 strip fallback、不支持的反向 strip Nanite 数据,cook 出来每平台只含它用的数据。
- 一个项目要同时上 PS5 和 Switch(移动级 GPU),Nanite 怎么办? → PS5 完整支持开 Nanite;Switch 这类移动级 GPU 缺 64 位原子基本不可用,要靠 fallback mesh 走传统管线,且 fallback 质量要按 Switch 最低规格调,包体上 cook 时给 Switch strip 掉 Nanite 数据只留 fallback。
追问准备:若问”为什么 VisBuffer 一定要 64 位原子”——引到 Q1;若问”fallback 怎么设质量”——引到 Q22 的必设项。
结语
24 题按五组递进:
- Q1–Q6 底层机制——VisBuffer / 软光栅 / 定点数 / DAG / 连续 LOD / 两阶段遮挡,考底层功底;
- Q7–Q11 架构权衡 + 渲染视野——资产规范、成本结构、Skinning、三件套协同、自研判断,考 Tech Lead 实战与 Principal 视野;
- Q12–Q16 + Q24/Q25 构建 / 着色源码级——图分割、误差单调性、分页 Transcode、ShadeBinning、隐式切线、位置量化精度自动决策、METIS 选型与顶点 strip 编码,考是否真读过引擎;
- Q17–Q21 5.7 新特性 + 架构压轴——Foliage 体素、Skinning 蒙皮、Lumen HWRT、限制反推、森林系统设计,考知识新鲜度与系统判断;
- Q22–Q23 工作流与平台落地——美术新旧流程适配与必设项、多平台能力分级与 Fallback 降级,考从技术到团队/发行的落地判断。
每题的结构是:题目 → 成文满分答案(源码佐证)→ 采分要点(四档)→ 雷区 → 似是而非陷阱 → 追问链。所有源码引用基于 Unreal Engine 5.7 source build,行号随版本可能漂移;配图为作者自绘。
*本文基于 E:/Project/UE 的 UE 5.7 source build,行号随版本可能漂移;配图为作者自绘。*