做Unity渲染这些年从照着官方文档抄Shader到后来敢在项目里动URP管线的RenderFeature最深的感受是Shader入门其实不算难真正卡住人的往往不是复杂的BRDF推导而是一些非常细小、容易被几乎所有教程跳过的小知识点。有些问题在编辑器里死活看不出来一上真机就开始花屏、抖线、断层。今天这篇就是这样一个《UnityShader进阶小知识点》系列的第一期把我在实际项目里反复踩坑的几个点拆开聊聊。这一期先讲四个方向浮点精度的选择逻辑、法线贴图切线空间的手工构造、从深度纹理重建世界坐标的两种常见做法及它们的精度陷阱以及v2f插值器资源不够用时的打包思路。适合已经会用ShaderLab写基础Shader、想在真实项目里把渲染效果调稳的人。1. 浮点精度三兄弟fixed、half与float的选择逻辑1.1 先搞清楚三兄弟的实际范围和精度很多人在入门阶段就知道Shader里能用fixed、half、float三种浮点类型但基本不怎么在意区别。这其实是个隐患。这三种类型本质上是给不同计算精度用的范围和精度差异非常明显。类型范围尾数精度典型用途移动端代价fixed大致-2到2约10位部分硬件8位颜色、普通UV偏移低精度运算快但极容易漂移half约±6550410位尾数UV、颜色、短距离向量带宽省一半但累加误差可见float约±3.4e3823位尾数世界坐标、深度、矩阵、大范围UV带宽和计算开销最大注意fixed很早就不建议主动用了因为现代移动GPU的fp16管线已经很成熟half并不比fixed慢多少而fixed的范围实在太小稍微算个超过2的值就溢出。很多老教程里的fixed4 color写法在今天的硬件上并不一定是最高效的选择。1.2 桌面端验证真机必炸的经典场景最坑的地方在于在PC的DX和GL后端上half和float大多数情况下会被当成同一个精度处理而fixed在桌面端基本会被编译器当float用。也就是说你在编辑器里所有精度问题都是“隐身”的只有上真机才爆发。我碰到过最典型的例子是一个涉及半透明物体多层叠加的场景。当时想把粒子颜色的累加改到half精度来省带宽编辑器里看完全正常结果在部分移动GPU上直接出现大面积色阶断层——尤其在半透明区域重叠四层以上的地方颜色一段一段地“阶梯化”。排查到最后就是累加过程中half精度不够每一层都在丢尾数越叠越明显。另一个更隐蔽的场景是地形高度采样。用half存世界高度坐标远处地形会出现缓慢的“上下浮动”感类似热浪扭曲。这是因为远处顶点坐标的数值很大half的10位尾数已经不足以表达相邻顶点之间的高度差值导致顶点位置被量化。提示世界坐标、矩阵、深度、AO累计、ShadowMap采样这类计算老老实实用float。颜色乘法、一般的UV运算、法线贴图采样后的向量可以用half。一旦涉及多个值的累加循环优先保持float。1.3 一条实用的精度自检清单我现在写Shader时会固定遵守几条规则基本能避开大部分精度坑顶点位置positionWS、positionOS、positionCS必须float相机方向、视角方向、光线方向参与点积时就差还好但涉及重建世界坐标时必须float雾效参数、深度相关计算float法线贴图采样后的TBN变换half在大多数移动平台也够用但遇到大面积法线贴图远景时改成float更稳UV动画如果范围很大比如整个地形的世界坐标映射到UV用float粒子/透明物体的颜色累加优先float若确实要省带宽half必须测试分层叠加效果。这个方法的核心思路是带宽能省则省但涉及坐标重建、累加、跨数量级的运算一律保留float。别在编辑器里判断精度真机截图才是唯一标准。2. 手写法线贴图的切线空间绕不开的坐标系手工活2.1 为什么法线贴图非要住进“切线空间”原理上很多人知道法线贴图存的不是世界空间法线而是相对于模型表面的偏移法线。换句话说每个纹素里的(r,g,b)表示“把原本朝上的法线朝右、朝上、朝外各偏了多少”。这就带来一个好处同一个法线贴图可以贴在球面、墙面、地面上而不受物体朝向影响就像贴纸可以贴在任意角度的表面上。但代价是你必须在渲染时把切线空间里的法线转换回世界空间。这个转换依赖每个顶点提供的切线方向。如果直接采样法线贴图然后用世界空间法线去计算光照结果大概率是错的——这就是很多人“照着教程写却怎么都不对”的常见原因之一。2.2 TBN矩阵的手工构造与正交化构建TBN矩阵的常规思路是// 从顶点数据里拿到的三个向量 float3 worldNormal normalize(TransformObjectToWorldNormal(v.normalOS)); float3 worldTangent normalize(TransformObjectToWorldDir(v.tangentOS.xyz)); // tangent.w 存的是副切线方向的正负标记必须乘上 float3 worldBinormal normalize(cross(worldNormal, worldTangent) * v.tangentOS.w); // 手写一个从切线空间到世界空间的矩阵 float3x3 tbn float3x3(worldTangent, worldBinormal, worldNormal); // 采样法线贴图并解包 half3 normalTS UnpackNormal(tex2D(_NormalMap, uv)); half3 normalWS normalize(mul(tbn, normalTS));这段代码不复杂但有个容易被忽略的数学细节从模型数据里得到的tangent向量在被引擎导入时并不保证是单位向量。尤其在Unity里如果模型经过了某些自动处理或者是从DCC工具导出时没规范化直接使用会出现拉伸变形。所以动手写之前先normalize一次。还有个细节是正交化。TBN矩阵的三条轴理论上应该互相垂直但实际建模时UV可能被不均匀拉伸导致切线、副切线、法线之间并不严格正交。直接用来转换法线会让法线贴图在UV拉伸区域产生轻微偏差。解决办法是用Gram-Schmidt正交化worldTangent normalize(worldTangent - dot(worldTangent, worldNormal) * worldNormal); worldBinormal normalize(cross(worldNormal, worldTangent) * v.tangentOS.w);这样能保证TBN近似正交减少噪声。但需要注意正交化会轻微改变切线在UV空间中的权重如果模型本身是精确平面、UV拉伸很小不一定要做如果是带骨骼蒙皮的软体模型顶点插值后三条轴的方向会发生偏移建议做。2.3 镜像UV和硬边两个最容易翻车的情况镜像UV是法线贴图的大坑。很多对称物体比如人物的左右手臂、车辆的左右反光镜会共用同一套UV其中一侧做了镜像。此时UV的x方向是反的副切线方向也随之反转。tangent.w分量就是干这个用的。它记录副切线是否反转在手动构造TBN时通过cross再乘tangent.w来处理。如果不乘镜像侧的法线贴图会出现左右颠倒的花纹——表现是物体左右两侧高光方向不一致甚至出现凹变凸、凸变凹的错觉。硬边Hard Edge是另一个坑。法线贴图的连续性依赖切线空间的连续性而切线空间的连续性依赖UV的连续性。如果你在特定顶点处劈开了UV比如一个方块的两个面用了不同的UV连续那这个顶点就必须被拆成多个顶点各自的切线也需要断开。否则切线在硬边处被插值后法线方向会变得莫名其妙。大部分美术模型经过Unity导入时会自动计算切线所以这些坑被藏起来了。但当你用代码动态生成Mesh、或者从外部加载自定义Mesh数据时切线常常没有正确生成就要手动处理这两件事。2.4 什么时候用内置宏什么时候自己算如果是普通静态模型没必要自己手写全套TBN直接使用Shader内建的宏和矩阵在UnityShader里是TANGENT_SPACE_ROTATION等就行它们处理了大部分约定俗成的细节。但一旦涉及自定义顶点动画情况就变了。举个实际例子一棵树的树叶随风摆动。顶点位置在Shader里已被修改但是法线、切线还是原始模型空间的它们不会跟着顶点一起摆动。此时如果仍然用模型自带的切线去构建TBN光照方向就会跟变形后的表面完全对不上树叶看起来像一层贴图糊在错误的朝向里。这种情况下必须根据变形后的顶点重新计算法线甚至重新构造切线空间也就是用手写TBN的时机。提示用URP等现代管线开发时内置辅助宏和矩阵名称在不同版本里略有差异但数学思路完全一致。建议先把手写流程吃透再去看管线里的封装否则遇到报错会很难定位问题。3. 从深度纹理重建世界坐标那条“又快又险”的路3.1 为什么都在盯着深度纹理不放做后处理、半透明物体、粒子交互、扫描效果时经常需要知道屏幕上某个像素对应的世界坐标。最直接的做法是把世界坐标单独渲染到一张RT上但这个方案带宽开销非常吓人等于每个像素额外存一个float3再加一个深度值。相比之下深度纹理在很多管线中已经是现成的阴影、SSAO、屏幕空间效果都要用从深度纹理反推世界坐标只需要一个采样加若干次运算成本低得多。这就是世界坐标重建技术在后处理领域这么普及的根本原因。3.2 深度值到线性距离平台差异严重超标深度纹理里存的值并不是我们直觉里的“距离”而是经过投影变换后的非线性深度并且不同平台的范围定义完全不同。传统OpenGL平台里深度值是近处0、远处1而DX以及新版主流移动图形API普遍采用Reverse-Z近处是1、远处是0。如果直接拿深度值去做线性运算很容易得到完全错误的距离。Unity提供了Linear01Depth和LinearEyeDepth这类内置函数它们会读取_ZBufferParams等参数自动适配平台。底层本质就是把非线性的深度通过一次除法转换成一维线性深度float linear01 1.0 / (_ZBufferParams.z * rawDepth _ZBufferParams.w); float eyeDepth 1.0 / (_ZBufferParams.x * rawDepth _ZBufferParams.y);一个非常实用的判断标准是所有跟相机距离、雾效、水面深浅相关的计算都不要直接用原始深度值先用这两个函数转换成线性深度再继续。别嫌多一步缺了这一步任何平台差异都会在你最不想出问题的时候冒出来。3.3 逆VP矩阵法代码最简洁但误差最隐蔽逆VP矩阵法的思路很简单把屏幕UV变换到齐次裁剪空间补上深度和w分量乘以逆VP矩阵再除w得到世界坐标。// 示意代码不同管线中矩阵名称和获取方式略有差异 float4 clipPos float4(uv * 2.0 - 1.0, depth, 1.0); float4 worldPos mul(_InvViewProj, clipPos); worldPos.xyz / worldPos.w;这种方式代码量很少但在大世界里精度问题非常隐蔽。VP矩阵里包含了相机的位置信息视矩阵的平移部分当相机离原点非常远时矩阵元素本身的数值会非常大单精度float的23位尾数不足以同时表达“大坐标”和“微小变化”顶点就会出现肉眼可见的抖动或者带状闪烁。我实际测过的场景中相机飞到离原点几千单位以外时逆VP重建的世界坐标边缘已经开始跟着相机的前进方向“飘动”。这种问题很难以算法本身定位最后只能换方案。3.4 射线插值法稳但要领会z归一化这一层另一个思路是射线插值法也是我现在用得最多的方法。它在顶点阶段为每个顶点计算一条从相机出发、经过该像素的射线方向然后传到像素阶段用线性深度把射线延伸到目标点。核心代码思路如下// Vertex阶段用NDC坐标构造视方向保证z分量为1 float2 uvNDC positionCS.xy * 2.0 - 1.0; float3 rayDirVS float3(uvNDC * _FocalLengthXY, 1.0); // 在Fragment阶段 float rawDepth tex2D(_CameraDepthTexture, screenUV).r; float eyeDepth LinearEyeDepth(rawDepth); float3 viewPos rayDirVS * eyeDepth; float3 worldPos mul(_InvViewM, float4(viewPos, 1.0)).xyz;这里必须强调一个很容易被忽略的点rayDirVS的z分量不是随便取的必须归一化到1。因为LinearEyeDepth得到的深度值是“相机平面的垂直距离”而不是到像素点的欧氏距离。如果直接把整个三维方向向量乘以eyeDepth距离越靠近屏幕边缘就越会被拉长重建出的位置会严重偏向边缘出现明显的圆弧扭曲。这也是网上各种世界坐标重建教程最常讲错的一个细节。很多人写worldPos cameraPos normalize(rayDir) * eyeDepth表面看差不多实际上边缘误差会随着视野变大越来越严重。正确处理是让rayDir的z分量等于1后再乘以eyeDepth或者用eyeDepth / rayDir.z。3.5 平台差异实测与选择建议方案带宽成本精度风险推荐场景逆VP矩阵法低大世界/远距离时抖动近景可用小场景、室内后处理射线插值法低边缘需z归一化整体较稳大世界、移动端通用如果项目是大世界建议再叠加一个相对坐标技巧把相机位置单独传入Shader管线里所有重建都基于“相对相机原点”的坐标系计算最后再还原成世界坐标。这样能彻底规避大数值下的浮点精度问题。4. 插值器v2f的“蜗居”艺术在有限的TEXCOORD里腾挪4.1 为什么会限制插值器数量顶点Shader计算完每个顶点后要把位置、法线、UV等数据通过“插值器”传给像素Shader。GPU会在三角形内部按重心坐标对插值器里的数据进行线性插值这本身就是一份很宝贵的硬件资源。不同图形API和硬件平台对插值器数量的限制差异很大。老的移动GLES 2.0平台可能只能保证8个插值器今天的GLES 3.x平台一般能支持16个vec4桌面平台通常更宽松。但手游要兼容低端设备时8个vec4就是一条硬红线。一旦v2f结构体里的字段超过限制编译就直接报错而不是自动压缩。这就是为什么“在PC上能跑但打包到真机后报interpolator limit exceeded”的现象如此常见。编辑器里显卡资源管够低端真机上立刻现形。4.2 v2f里最容易被浪费的字节很多人的v2f长这样struct Varyings { float4 positionCS : SV_POSITION; float2 uvMain : TEXCOORD0; float2 uvDetail : TEXCOORD1; float3 positionWS : TEXCOORD2; float3 normalWS : TEXCOORD3; float3 viewDirWS : TEXCOORD4; float fogFactor : TEXCOORD5; };这看着挺正常但仔细算一下一个TEXCOORD槽位能塞4个分量你却只用了2个fogFactor只用1个却独占整整4个分量。加起来占用了6个插入器实际有效数据可能只要一半多一点。这就是插值器“蜗居”艺术要解决的问题。4.3 同类数据打包从“占槽”到“填满”打包思路本质上跟收拾行李箱一样不是把每件衣服单独装一个包而是把不同尺寸的东西拼进同一个空间。常见做法把fogFactor塞进主UV的剩余分量把两个UV的x和y拆开分别跟其他数据共用槽位法线等方向向量改用half存储屏幕空间UV、顶点色等数据尽量拼进已占用的槽位。优化后的v2f可以压成这样struct Varyings { float4 positionCS : SV_POSITION; half4 uvAndFog : TEXCOORD0; // uv.xy fogFactor 一个预留分量 float4 positionWS : TEXCOORD1; // 世界坐标xyzw分量可以塞额外数据 half4 normalAndAO : TEXCOORD2; // 法线xyz AO值 half2 screenUV : TEXCOORD3; // 屏幕UV占两个分量 };原则上每个TEXCOORD槽几乎都被“填满”了需要传递的信息没有变少插值器占用却从6个压到了4个。当粒子系统、描边系统、半透明材质扎堆渲染时省下来的带宽和寄存器开销会让低端机的帧率曲线好看不少。4.4 从报错信息里反推硬件底牌遇到插值器超限的报错时先别急着删字段而是先确认自己正在针对哪个平台编译。Unity的Shader编译面板一般能看到目标图形API和Shader Model报错文本里往往直接写着类似“maximum number of vertex shader outputs exceeded”的信息。有个实用技巧打开Shader Inspector里的“Compiled Code”查看编译后的实际代码你能直接看到顶点Shader到底输出几个寄存器变量。真实项目里我见过不少情况自己在结构体里只声明了8个插值器但编译出来却占用了10个甚至更多——因为像雾化坐标、实例化ID、多光源数据这些会被引擎自动添加进v2f没有体现在你写的结构体里。只看自己的声明去排查就会一头雾水。4.5 一个压到极致的例子与后续优化方向把打包做到极致之后再搭配Unity的SRP Batcher使用效果比我预期好很多。之前在某个大量使用半透明粒子的Demo里光是把v2f从“每字段各占一槽”改成“填满每槽”低端机上的GPU渲染耗时就有了可感知的下降。具体数值受场景影响太大我一般不给大家报假数但原理是确定的插值器数量越少顶点到像素阶段的带宽就越小寄存器压力也越小。这一期先聊到这里。像浮点精度、切线空间手工构造、深度重建、插值器压缩这些问题都属于“看起来小、影响起来巨大”的典型每一个都在真实项目里坑过我。后续这个系列会继续写URP RenderFeature的常见误用、Shader变体膨胀的处理、以及移动端Overdraw优化这类工程向知识点咱们保持更新频率慢慢展开。如果有新的坑踩到我会第一时间来更新这篇。