ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

Unity URP曲面细分实战:地形几何细节渲染优化

Unity URP曲面细分实战:地形几何细节渲染优化 前阵子处理一个地形渲染的小需求遇到了一个很典型的问题近处的碎石、裂缝和隆起靠法线贴图怎么都骗不过去好几个人蹲在屏幕前看半天最后结论都是“轮廓是平的”。换高模吧内存和构建时间又吃不消。聊到最后发现大家纠结的根本就是几何细节不够而这个需求正好卡在渲染流水线的几何阶段——于是我把曲面细分提了上来以Unity URP为例做了一轮完整的落地验证。这篇文章就把这套思路写清楚从外壳着色器、域着色器的原理到能直接跑的Shader、性能账目再到我实际踩出来的平台坑一次讲完。1. 为什么几何阶段需要一道“细分工序”1.1 顶点着色器只管得了“点”管不了“面”很多刚接触渲染的朋友会有一个误解几何阶段的顶点着色器不是已经处理了顶点吗三角形内部的信息插值一下不就有了吗没错片元着色器确实可以通过顶点数据的重心坐标插值拿到渐变的值。但关键在于插值永远是在“已有的三角形内部”进行它不能凭空产生新的几何转折。举一个最简单的例子一个四边形的平面网格只有四个顶点。你给四个顶点的法线都朝上那这一整块面的光照就是均匀的。如果你想在地面上表现一道沟壑顶点着色器无能为力因为沟壑的位置根本不在顶点上片元着色器也只能在四个顶点之间做线性插值算不出沟壑的阴影轮廓。这时候你需要的不只是“让像素变暗”而是“真的把顶点按下去、隆起或塌陷”让几何轮廓发生改变。曲面细分补上的正是这一环。它在顶点着色器之后、光栅化之前把三角形拆成更多三角形然后每个新顶点都有机会偏移到新的位置。换句话说曲面细分给几何阶段增加了一个“按需加面”的环节让GPU在运行时动态决定一个三角形要拆多细而不是让美术在建模软件里预先做死。1.2 LOD方案解决不了的痛一谈到“根据距离调整细节”很多人第一反应是LOD多层次细节。美术做高模、中模、低模三档运行时根据摄像机距离切换。这套方案成熟是成熟但有几个绕不开的痛点首先是美术成本。一个物件三套模型建模、烘焙、校验、导出工作量直接翻倍。地形这种需要大范围覆盖的东西更麻烦你不可能为每块地形都做三套不同密度的网格。其次是内存和包体。高模的顶点数据、索引数据、切线数据、UV数据都占体积加载进显存之后带宽也是实打实的开销。第三是切换体验。LOD切换点处理不好就会出现明显的“跳变”哪怕做了交叉淡化在边缘轮廓上依然能看出来。曲面细分在这几个点上恰好是反着来的美术只需要维护一套相对低模的Base Mesh细节由GPU在运行时根据距离“长出来”。距离近就多拆距离远就少拆甚至完全不拆。这个过程连续变化配合fractional_odd这类分区方式几乎看不到跳变。当然这并不意味着曲面细分能完全替代LOD。对大尺度场景来说LOD的粗粒度调度仍然有效。更合理的组合是LOD负责“有没有这棵树这个建筑要不要显示”曲面细分负责“这个表面在镜头前需要多细的几何精度”。两者并不冲突只是分工不同。1.3 曲面细分的三个子阶段要真正理解曲面细分需要把它拆成三个连续的小阶段来记外壳着色器Hull Shader负责决定“要细分成什么程度”。它读取一个或多个控制点组成的Patch在三角形模式下通常就是3个顶点计算出细分因子。细分器Tessellator这是GPU硬件固定的一个阶段程序员不能改它的逻辑。它根据外壳着色器给出的细分因子按指定的分区方式在三角形内部生成新的重心坐标和拓扑结构。不同硬件实现细节不同但对上层Shader来说看到的结果是一致的。域着色器Domain Shader可以把它理解为“二次顶点着色”。细分器每生成一个新顶点域着色器就会被调用一次。它收到原始Patch的控制点数据和一个重心坐标然后用重心坐标把控制点插值出新顶点的位置、法线、UV再进行后续的顶点变换或者置换偏移。整个链路放在URP里就是顶点着色器 → 外壳着色器 → 细分器 → 域着色器 → 片元着色器。顶点着色器在这里通常只是透传数据真正让三角形“变多”的是从外壳到域着色器这一段。2. 外壳着色器细分因子不是拍脑袋定的2.1 Hull Shader其实是两个函数外壳着色器这个名字容易让人以为它是一个完整的着色器但在编写时它实际上是两个函数拼在一起的。第一个是常量函数Patch Constant Function。它接收整个Patch的数据比如3个顶点输出一组细分因子。由于它输出的数据在整个Patch内是共享的所以它只执行一次相当于Patch级别的“控制函数”。细分因子就是在这里算出来的。第二个是控制点函数Control Point Function。它只处理单个控制点接收一个索引ID从原始Patch的多个控制点里取一个返回给细分器。对于三角形网格控制点函数一般就是把顶点数据原样返回真正的逻辑几乎为零。很多教程在讲Hull Shader时会把这两个函数混在一起讲导致初学者搞不清楚到底哪段代码在决定“拆多少”。我自己学的时候也被绕了一把后来把数据结构分开看就清楚了常量函数输出的是带SV_TessFactor和SV_InsideTessFactor语义的因子结构体控制点函数输出的则是带位置、法线、UV的顶点结构体。前者管数量后者管内容。2.2 边缘因子与内部因子一个三角形拆成三份账在三角形域下细分因子一共四个三条边各一个三角形内部一个。用HLSL写出来就是struct TessellationFactors { float edge[3] : SV_TessFactor; float inside : SV_InsideTessFactor; };edge[0]对应第一条边edge[1]对应第二条边edge[2]对应第三条边inside是内部细分因子。硬件按edge因子把每条边拆成对应的段数再按inside因子把三角形内部区域做网格化。四条边的因子可以不一样所以相邻两个三角形在共享边上的细分因子只要一致就能保证不产生裂缝而每个三角形内部怎么拆是硬件根据inside因子独立处理的。这里有一个实用经验如果你只想做最简单的均匀细分让四条因子相等就够了三角形会被拆成类似“三份拼起来”的三角网格。但如果想提高自适应程度边缘因子应该基于“对应边的中点”去计算内部因子则基于“整个Patch的中心点”去计算。这样能尽量让相邻三角形共享边上的细分结果一致后面讲裂缝时会详细展开。2.3 距离自适应细分因子的完整推导细分因子最常用的计算策略就是“距离自适应”。简单说就是离摄像机越近的Patch三角形拆得越细越远则拆得越少。计算公式可以这样写float TessellationFactor(float3 worldPos) { float dist distance(worldPos, GetCameraPositionWS()); float t saturate((_TessMaxDist - dist) / (_TessMaxDist - _TessMinDist)); return lerp(_MinTess, _MaxTess, t); }这个公式的意思是当dist小于等于_TessMinDist时t为1返回最大细分因子当dist大于等于_TessMaxDist时t为0返回最小细分因子中间阶段做线性衰减。这里的_MinTess和_MaxTess都是材质参数运行时要留出调节空间。边界情况要注意近距离不要让细分因子无限大硬件对细分因子有上限。DX11体系的硬件上限一般是64但实际项目里通常拉到16就已经非常精细了再高只会在视觉上产生几乎无差别的噪声还会明显拖慢性能。所以我把_MaxTess的Inspector范围设成1到32默认值给8到12之间这样既安全又够用。在常量函数里拿到Patch中心之后先把它从对象空间变换到世界空间再算距离。直接在对象空间算会忽略物体缩放和位移距离完全不对TessellationFactors PatchConstant(InputPatchControlPoint, 3 patch) { float3 centerOS (patch[0].positionOS patch[1].positionOS patch[2].positionOS) / 3.0; float3 centerWS TransformObjectToWorld(centerOS); float tess TessellationFactor(centerWS); TessellationFactors factor; factor.edge[0] tess; factor.edge[1] tess; factor.edge[2] tess; factor.inside tess; return factor; }这一版是“均匀自适应”四条因子值一样。实际项目中如果Patch较大Patch中心和边中点的距离差异会很明显可以考虑把edge因子改成基于边中点计算整体效果更平滑。3. 域着色器重心坐标如何变成新顶点3.1 重心坐标三角形内的任意点外壳着色器输出细分因子之后硬件会为每个细分级别生成一组位置数据这些数据以重心坐标Barycentric Coordinates的形式送给域着色器。对于三角形域重心坐标是一个三维向量三个分量分别对应三个原始控制点的权重且三个分量之和恒等于1。我在初学的时候一直以为重心坐标是一个(x,y)的平面坐标后来才知道它是“三个顶点的权重组”。给你举个例子重心坐标(1, 0, 0)表示新顶点就是第一个控制点(0, 0.5, 0.5)表示新顶点位于第二和第三个控制点连线的中点。域着色器要做的就是用这组权重把控制点的各项属性插值出来float3 positionOS patch[0].positionOS * bary.x patch[1].positionOS * bary.y patch[2].positionOS * bary.z;这个过程和光栅化阶段在片元中的重心坐标插值非常像只是这里发生在几何阶段插值的对象是顶点数据。3.2 置换贴图怎么用细分出来的新顶点如果只是原地插值那和没细分区别不大。真正让曲面细分产生视觉价值的是配合置换贴图Displacement Map把顶点沿法线方向偏移出去。置换贴图本质上是一张高度图灰度值记录每个UV位置的“海拔”。在域着色器中采样这张图然后把采样值作为偏移量叠加到顶点位置float h SAMPLE_TEXTURE2D_LOD(_DisplacementMap, sampler_DisplacementMap, uv, 0).r; float3 positionWS TransformObjectToWorld(positionOS); float3 normalWS TransformObjectToWorldNormal(normalOS); positionWS normalWS * (h - 0.5) * _MaxDisplacement * 2.0;这里(h - 0.5) * _MaxDisplacement * 2的含义是贴图0.5对应的位置保持不变低于0.5的凹陷高于0.5的隆起_MaxDisplacement控制整体偏移幅度。域着色器只能用tex2Dlod这类显式指定LOD的采样函数不能用普通tex2D因为域着色器阶段没有自动计算屏幕空间梯度的机制普通采样会报错或者得到不可预期的结果。置换贴图比法线贴图强的地方在于它改变了真实几何轮廓。法线贴图只能在光照上伪造凹凸但物体边缘的剪影、地形上的真实遮挡关系法线贴图永远伪造不出来。置换贴图配合细分则能真正在几何层面把裂缝和石子做出来。代价也很直接顶点数暴涨片元着色阶段的负载也随之上升。3.3 细分后的法线必须重新算把顶点位置沿着法线方向顶出去之后原来的法线是直接从低模插值来的几何形状已经变了光照却还按旧法线算就会出现“光照方向和几何轮廓对不上”的违和感。我在一个测试地形里就见过这样的情况置换高度已经明显隆起了一块岩石但侧面的光照平得跟平面一样整个石头看起来像贴图贴上去的而不是立体的。原因就是法线没有跟着几何形变更新。严格的做法是在域着色器阶段对置换函数做有限差分用相邻采样点的高度差重建出法线。思路是沿着UV的两个方向分别多采样一次高度值用高度变化量构造近似的梯度再和原始法线方向混合。代码大致长这样float h0 SAMPLE_TEXTURE2D_LOD(_DisplacementMap, sampler_DisplacementMap, uv, 0).r; float h1 SAMPLE_TEXTURE2D_LOD(_DisplacementMap, sampler_DisplacementMap, uv float2(_DisplacementMap_TexelSize.x, 0), 0).r; float h2 SAMPLE_TEXTURE2D_LOD(_DisplacementMap, sampler_DisplacementMap, uv float2(0, _DisplacementMap_TexelSize.y), 0).r; float3 gradient float3((h1 - h0), 0, (h2 - h0)) * _MaxDisplacement; float3 finalNormal normalize(normalWS - gradient);这块是近似算法严格到每个UV空间对世界空间张成的切空间需要额外传递切线向量但对地形类的视觉表现已经足够。如果是角色或硬表面模型我建议在材质面板上加一个“置换法线强度”参数让美术根据镜头距离调整避免细节过碎导致法线闪烁。4. 在URP里写一个能跑的曲面细分Shader4.1 URP与内置渲染管线的差别这里要特别说清楚URP的Shader写法和旧版内置渲染管线有很大区别曲面细分也一样。内置管线里还可以用Surface Shader配合tessellation指令能少写很多样板代码。但URP不支持Surface Shader必须手动写Pass而且光照计算要走URP的ShaderLibrary。这意味着你不能直接把旧的tessellation示例代码搬过来除了Hull和Domain本身围绕它们的框架都要按URP规则重写。URP的Shader两个明显特征是Pass需要声明Tags { LightMode UniversalForward }以及ShadowCaster等对应LightModeHLSL文件的include路径指向com.unity.render-pipelines.universal/ShaderLibrary/。由于URP支持SRP Batcher材质属性必须放在CBUFFER中顶点阶段的属性也尽量遵循UnityPerMaterial的规则。另外曲面细分对Shader Model有要求。稳定做法是打上#pragma target 5.0。如果你在URP默认的#pragma target 2.0或3.0下直接加Hull和Domain编译器会报错或者根本不会进入细分阶段。4.2 Pass结构与核心代码骨架下面给出一个在URP 2022 LTS下能跑通的核心结构。这里我只展示ForwardLit Pass中与曲面细分直接相关的部分光照部分做了简化重点是把Hull和Domain的完整流程看明白。先定义三种结构体Attributes是原始网格数据ControlPoint是外壳着色器要透传的数据TessellationFactors是细化因子struct Attributes { float3 positionOS : POSITION; float3 normalOS : NORMAL; float2 uv : TEXCOORD0; }; struct ControlPoint { float3 positionOS : INTERNALTESSPOS; float3 normalOS : NORMAL; float2 uv : TEXCOORD0; }; struct TessellationFactors { float edge[3] : SV_TessFactor; float inside : SV_InsideTessFactor; };顶点着色器在这里只是做一个透传把原始顶点数据转成控制点数据ControlPoint TessellationVertex(Attributes input) { ControlPoint output; output.positionOS input.positionOS; output.normalOS input.normalOS; output.uv input.uv; return output; }然后是常量函数在这里计算细分因子TessellationFactors PatchConstant(InputPatchControlPoint, 3 patch) { float3 centerOS (patch[0].positionOS patch[1].positionOS patch[2].positionOS) / 3.0; float3 centerWS TransformObjectToWorld(centerOS); float dist distance(centerWS, GetCameraPositionWS()); float tess clamp((_TessMaxDist - dist) / (_TessMaxDist - _TessMinDist) * (_MaxTess - _MinTess) _MinTess, _MinTess, _MaxTess); TessellationFactors factor; factor.edge[0] tess; factor.edge[1] tess; factor.edge[2] tess; factor.inside tess; return factor; }接下来是外壳着色器的控制点函数。它的几个属性很关键[domain(tri)]表示三角形Patch[partitioning(fractional_odd)]表示分区方式[outputtopology(triangle_cw)]表示输出三角形的绕序[outputcontrolpoints(3)]表示输出3个控制点[patchconstantfunc(PatchConstant)]指定常量函数名[domain(tri)] [partitioning(fractional_odd)] [outputtopology(triangle_cw)] [outputcontrolpoints(3)] [patchconstantfunc(PatchConstant)] ControlPoint Hull(InputPatchControlPoint, 3 patch, uint id : SV_OutputControlPointID) { return patch[id]; }最后是域着色器。这一步会把重心坐标插值出位置、法线、UV再上置换[domain(tri)] Varyings Domain(TessellationFactors factors, OutputPatchControlPoint, 3 patch, float3 bary : SV_DomainLocation) { Varyings output; float3 positionOS patch[0].positionOS * bary.x patch[1].positionOS * bary.y patch[2].positionOS * bary.z; float3 normalOS normalize(patch[0].normalOS * bary.x patch[1].normalOS * bary.y patch[2].normalOS * bary.z); float2 uv patch[0].uv * bary.x patch[1].uv * bary.y patch[2].uv * bary.z; float3 positionWS TransformObjectToWorld(positionOS); float3 normalWS TransformObjectToWorldNormal(normalOS); #ifdef _TESSELLATION_ON float h SAMPLE_TEXTURE2D_LOD(_DisplacementMap, sampler_DisplacementMap, uv, 0).r; positionWS normalWS * (h - 0.5) * _MaxDisplacement * 2.0; #endif output.positionCS TransformWorldToHClip(positionWS); output.positionWS positionWS; output.normalWS normalWS; output.uv uv; output.fogCoord ComputeFogFactor(output.positionCS.z); return output; }片元着色器我这边为了演示只做了主光源光照实际项目里建议接URP的UniversalFragmentPBR。不同URP版本这个函数的签名有差异用的时候查一下当前版本的InputData和SurfaceData结构体定义就行。4.3 材质面板参数与调试方法材质面板上的参数设计会直接影响美术调起来是否顺手。我的习惯是暴露五个核心参数_MaxTess细分上限、_MinTess细分下限、_TessMinDist最近距离、_TessMaxDist最远距离、_MaxDisplacement最大置换高度。再加一个[Toggle(_TESSELLATION_ON)]做总体开关性能吃紧时一键退回普通顶点流程。调试的时候最容易遇到的问题就是“明明写了Hull和Domain但画面没有任何变化”。这多半是Shader Model没打上5.0或者PatchConstant函数没有被正确引用。还有一类是“细分了但置换没生效”先检查置换贴图有没有拖到材质槽上再检查采样函数是不是用了tex2Dlod系列普通tex2D在域着色器里根本不能正常跑。如果细分因子开得很大比如超过16容易在近处看到网格的规则噪点。别急着降到1先看是法线方向的问题还是细分密度的问题。网格规则噪点大多是法线没有随置换更新几何密度的噪点通常出现在高度图有高频信息但细分不足的地方把_MaxTess提高几档会缓解。5. 曲面细分真正的性能账平台、开销和降级5.1 不同API的硬件能力曲面细分不是一个所有平台都能用的特性。下面这张表是我在实际验证时整理的也是每次在项目里决定“要不要上细分”的第一道筛子API / 环境曲面细分支持情况工程注意点DirectX 11 / 12完整支持Windows桌面和主机安心用细分因子最高64Metal支持iOS 9细分上限因设备而异实测常用16以内Vulkan需要设备特性支持Android高端机常见中低端必须做降级OpenGL ES 3.0 / 3.1不支持大面积Android中低端设备直接走降级分支OpenGL 4.1支持macOS可用但远不如Metal顺手在URP里做平台判断时不要只依赖API名称。更稳的方式是加一个材质Keyword由脚本在启动时检测设备能力把_TESSELLATION_ON关闭。这样即使某台设备声称支持Vulkan但细分特性不可用你也有退路。5.2 性能开销主要花在哪里曲面细分的性能开销并不是“硬件自动生成三角形所以很便宜”这么简单。开销主要来自三块第一块是Patch Constant函数和Hull函数的脚本开销。这部分每个Patch只执行一次三角形数量不变时开销相对固定。第二块是域着色器这是最大的一块细分因子为8时每个原始三角形大概生成几十个新顶点域着色器要针对每一个新顶点执行一次完整逻辑包括置换采样、坐标变换、法线变换。顶点数暴涨N倍域着色器开销也就跟着涨N倍。第三块是光栅化和片元阶段。三角形变多并不会直接增加屏幕像素数但在几何轮廓变化剧烈的地方会新增很多原本不存在的像素覆盖片元负载依然会上升。我做过一个简单对比一个12000面左右的地形网格_MaxTess为8、最大距离20米时近处顶点的生成数量大概是原始网格的6倍以上帧耗时增加主要发生在域着色器和片元着色器。想要控性能最有效的两个手段是缩小_TessMaxDist和降低_MaxTess。先把距离控制住细分开销就会非常可控。5.3 兼容降级方案没有细分能力的设备也不能直接黑屏。URP里最轻量做法是给Shader加上Keyword开关关闭时让顶点着色器直接把Attributes转成Varyings输出绕过Hull和Domain。我在ForwardLit Pass里做了这样的分支#pragma multi_compile _ _TESSELLATION_ON #ifdef _TESSELLATION_ON #pragma vertex TessellationVertex #pragma hull Hull #pragma domain Domain #else #pragma vertex Vertex #endifVertex函数需要独立的顶点处理逻辑输出结构和Domain函数透传版本一致。这套方案能保证同一份材质在高低端设备上表现逻辑一致只是低端设备没有几何细节增强。在启动脚本里根据硬件能力自动关掉Keyword美术在编辑器里还能手动强制打开来对比效果。6. 实测里最容易翻车的三个地方6.1 裂缝是怎么出现的曲面细分最经典的问题是裂缝。当两个相邻三角形共享同一条边而这条边在两边被分配了不同的细分因子时两边细分出来的顶点数量不一致共享边界上就会出现T型接缝进而产生细线状的裂缝。我一开始按Patch中心计算所有因子的方法跑过一轮地形上隔一段距离就出现一条明显的裂缝。排查后发现正是因为两个相邻Patch的中心到摄像机距离不同导致共享边的细分因子不同。想解决核心是让共享边的细分因子由“边本身”决定而不是由各自Patch的中心决定。实际操作就是把edge因子改成基于边中点计算inside因子才使用Patch中心。代码调整后共享边的两侧拿到的参数来源是一致的小边框裂缝问题基本消失。如果对多块不同密度的网格做拼接还需要额外的“边界锁定”机制强制在接缝区域的细分因子对齐这个就属于网格数据层面的工作了。6.2 阴影和细节对不上换到URP之后如果你只在ForwardLit Pass里写曲面细分而ShadowCaster Pass还是普通顶点流程那阴影就会按照原始低模去生成。你地面上已经出现一块真实的岩石隆起但它的影子还是一条平整的地块线条整体视觉特别出戏。这是URP项目里最容易忽略的一致性坑。ShadowCaster Pass也必须带上同一套Hull和Domain逻辑并且在域着色器阶段对顶点做同样的置换偏移再调用GetShadowPositionHClip得到带阴影偏移的裁剪空间位置。URP的深度法线Pass、深度Pass同理只要项目里开了这些功能都要保持一致否则后面接入SSAO或者其他基于深度的效果时会进一步放大不一致。6.3 法线品质与UV密度最后一个坑来自法线和UV。置换贴图本身是从纹理采样得来的而纹理的UV分布一旦不够均匀细分出来的顶点密度就会跟着纹理走。UV密集的地方三角形多UV稀疏的地方三角形少。表现到画面上可能出现某一侧的岩石细节特别碎另一侧却光滑得离谱。另外当置换幅度较大时法线重建如果做得不够彻底物体转起来角度一变就能发现“光跟着旧法线走轮廓却是新形状”的违和感。我现在的处理习惯是置换幅度超过0.1个物体单位后域着色器一定做有限差分重建法线置换幅度较小或者做远景地形时沿用插值法线靠片元阶段的法线贴图补细节省下两次置换采样。整体跑下来曲面细分在URP里确实不是“写个Hull和Domain就完事”那么简单。真正的功夫在细分因子怎么算、置换怎么采样、法线怎么重建以及各类Pass保持一致的工程细节上。把这些都理顺之后再看那些“近看必须有真实轮廓”的需求底气就足了很多。
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进