ARTICLE · INTELLIGENCE

战地情报 · 详情页

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

URP下Shader绘制棋盘格:内置管线迁移的关键差异与实战技巧

URP下Shader绘制棋盘格:内置管线迁移的关键差异与实战技巧 棋盘格这个需求在Unity开发里几乎绕不开。不管是做相机标定的棋盘格标定板、渲染调试用的网格辅助线、材质的UV可视化测试还是项目里单纯需要一个风格化的地面纹理大家第一时间想到的都是一个shader搞定。但人到做渲染管线升级的时候就会发现原先内置渲染管线里写得好好的棋盘格shader换到URP之后要么整体发粉要么部分功能失效要么根本提不出来。这篇文章就围绕“URP下用Shader绘制棋盘格并同内置管线的写法做对比”展开。我会把两端代码的关键差异、着色器移植的注意点、双面材质和标定板场景里的坑以及我在实际项目里对比两种管线渲染结果的性能数据一并整理出来。不管是刚接触URP的新手还是从内置管线迁移项目的老人应该都能在里面找点有用信息。1. 项目思路与管线适配背景1.1 棋盘格Shader的真实应用场景棋盘格看着简单但它是个极好的shader练习案例。它同时涉及UV坐标运算、整数取整逻辑、双线性过滤、视差判断以及最基础的颜色混合。很多图形学的入门教程都会拿它当第一个程序化纹理例子因为它不依赖任何贴图资源运行时计算就能输出清晰的黑白网格而且图案规律一眼能看出来对不对。我在项目里用棋盘格最多的场景其实是标定板。工业相机标定、深度相机畸变校准、AR平面检测调试这些东西都需要打印一张标准的棋盘格纸。但如果只是临时验证算法在屏幕上直接渲染一个棋盘格就比打印方便得多。给shader加一个可调的棋盘格密度参数切换角标数量再配合双面渲染让它能在相机前后都能被看到调试效率一下就上去了。除此之外棋盘格还有个用途是当作纹理坐标可视化工具。你想看一个模型片段的UV是否拉伸、接缝是否明显、镜像是否正确直接把模型材质换成棋盘格shader就能肉眼观测。都不用引入额外的debug视图代码。1.2 两套渲染管线的差异决定了Shader写法完全不同内置渲染管线和URPUniversal Render Pipeline在底层架构上的区别直接映射到shader代码结构上。内置管线的标准shader走的是延迟光照或前向光照的旧式Pass结构ShaderLab里可以混用Surface Shader、Vertex/Fragment Shader甚至Fixed Function Shader。而URP统一了光照模型强制使用SRP Batcher兼容的CBUFFER布局并且要求shader库从URP包里的Core.hlsl走。这意味着你从内置管线拷一个ShaderLab文件到URP工程里大概率会直接变粉。因为旧的UnityCG.cginc引用、LightMode标签、_CameraOpaqueTexture等内置变量在URP里不再自动支持或者位置变了。棋盘格shader本身并不依赖光照按理说属于最容易被移植的Unlit Shader类型。但它依然绕不开三个迁移点shader库的引用路径从.cginc换成.hlsl渲染路径标签从ForwardBase换成UniversalForward或直接用SRPDefaultUnlit变量声明的CBUFFER包装这是URP下跨Pass共享材质属性时的基本要求这些差异说大不大但如果你两种管线的写法都见过就会明白URP那套更像“工程化”的代码规范目的就是让渲染管线可以稳定批处理、可控降级。2. URP与内置管线在Shader写法上的关键差异2.1 顶点片段的语法差异从代码风格来说内置管线的Vertex/Fragment Shader普遍用CGPROGRAM加上#include UnityCG.cginc。URP这边是HLSLPROGRAM加上#include Packages/com.unity.render-pipelines.universal/ShaderLibrary/Core.hlsl。如果你要处理光照还需要额外引入Lighting.hlsl。棋盘格不需要光照所以URP版本只需Core.hlsl。但Core.hlsl里很多函数名和变量结构跟旧的UnityCG不一样比如模型顶点转裁切空间的UnityObjectToClipPos在URP里虽然保留着兼容宏但官方更推荐直接调TransformObjectToHClip因为后者是SRP Batcher安全写法。再一个特点是URP对Properties里的属性声明更讲究。配合SRP Batcher使用时材质属性要放进CBUFFER_START(UnityPerMaterial)到CBUFFER_END这个区间面板上可见的属性和引用的变量名才算一致。内置管线SRP Batcher没启用的时候不强制这个结构但URP几乎默认就开启SRP Batcher不按这个规则写批处理直接失效还会在帧调试器里看到一坨“SRP Batcher不兼容”的警告。2.2 渲染Tag和Pass设置的区别内置管线的Unlit Shader通常这么写Tags { RenderTypeOpaque QueueGeometry } Pass { Tags { LightModeForwardBase } CGPROGRAM ... ENDCG }URP里即使是Unlit shader也需要给Pass加上Tags { LightModeUniversalForward }或者用LightMode SRPDefaultUnlit。同时建议在SubShader顶部加上Tags { RenderPipelineUniversalPipeline }用来明确告诉Unity这个材质是给URP用的。不写的后果是当工程在两条管线之间切换时材质编辑器不会报错但渲染结果很难猜到。还有一点是Fallback。内置管线里不写Fallback Off或者写了依赖内置Standard的FallbackURP下就会触发一堆加载警告。棋盘格这类自定义shader建议直接Fallback Off避免自动替换成完全不相关的shader。2.3 双面渲染的实现方式双面材质需求在标定板和调试屏里经常出现。内置管线里打开双面只需要在SubShader里设置Cull Off同时如果需要光照可以在片元层通过VFACE判断翻转法线。棋盘格这种不受光照影响的shader其实只要Cull Off即可正反面都会执行相同的片元输出。URP这边对Cull Off的处理也是一样的但有个细节容易踩坑如果你是从内置的Standard Shader迁移原来的Shader里可能有多个Pass比如带ShadowCaster Pass。URP的Unlit Shader如果不写阴影投射PassCull Off不会造成额外影响。但如果你的工程里这个棋盘格需要接收阴影或投射阴影双面渲染时默认会带来阴影面上的半透明修正。棋盘格标定板一般不涉及阴影所以这个坑只在一些特殊调试场景里才出现。我在实际使用中遇到更常见的问题是双面渲染色差或者背面被剔除后完全消失。前者多半是顶点法线方向影响了一些间接光采集函数后者就纯粹是Cull标签写错位置——有人会把Cull Off写在Pass以外结果正面都变不可见了。还有人是被URP的后处理AO或者深度法线图坑过这些后处理本身拿了法线信息双面材质背面法线通常跟正面完全相反会看到AO在某半边上多出一层奇怪暗边。3. URP棋盘格Shader的完整实现3.1 程序化棋盘格的核心算法棋盘格纹理本质上就是UV坐标在x、y两个方向分别做整数阶梯函数再对阶梯结果取异或。假设UV范围是0到1我们需要控制一个密度参数_GridSize表示半个棋盘格的数量也就是一个方向上有多少个格子。对每个像素float2 gridUV floor(i.uv * _GridSize); float checker fmod(gridUV.x gridUV.y, 2.0);当gridUV.x gridUV.y为奇数时输出颜色A偶数时输出颜色B。这里用fmod是为了避免Unity底层在部分移动端设备上整数取模可能出现的精度问题虽然floor算出来后是非负整数但用fmod更稳妥。如果想要更可控的角点网格效果比如标定板常用的外框和角点需要把UV坐标转换到一个中心对称的空间。公式也有现成的float2 uvs i.uv * _GridSize - _GridSize * 0.5; float2 gv abs(frac(uvs) - 0.5); float line 1.0 - step(_LineWidth, max(gv.x, gv.y));这就是把所有格子边缘统一提取出来再同色叠加最终输出一个带细边框的棋盘网格。标定板在算法检测时往往更依赖这种内外角分明的高对比图案纯黑白大格子容易出现亚像素检测偏移。3.2 完整URP Shader代码下面这段代码是我在项目里跑过的URP版本棋盘格shader带密度、颜色、双面开关三个控制项同时做了SRP Batcher兼容处理。Shader Custom/URP/CheckerBoard { Properties { _ColorA (Checker Color A, Color) (0.3, 0.3, 0.3, 1) _ColorB (Checker Color B, Color) (0.8, 0.8, 0.8, 1) _GridSize (Grid Size, Float) 8.0 _LineWidth (Line Width, Range(0, 0.5)) 0.05 [Toggle]_DoubleSided (Double Sided, Float) 0 } SubShader { Tags { RenderPipelineUniversalPipeline RenderTypeOpaque QueueGeometry } Pass { Tags { LightModeUniversalForward } Cull Off HLSLPROGRAM #pragma vertex vert #pragma fragment frag #pragma multi_compile _ _DOUBLESIDED_ON #include Packages/com.unity.render-pipelines.universal/ShaderLibrary/Core.hlsl CBUFFER_START(UnityPerMaterial) float4 _ColorA; float4 _ColorB; float _GridSize; float _LineWidth; CBUFFER_END struct Attributes { float4 positionOS : POSITION; float2 uv : TEXCOORD0; }; struct Varyings { float4 positionHCS : SV_POSITION; float2 uv : TEXCOORD0; }; Varyings vert(Attributes input) { Varyings output; output.positionHCS TransformObjectToHClip(input.positionOS.xyz); output.uv input.uv; return output; } half4 frag(Varyings input) : SV_Target { // 棋盘格密度计算 float2 gridUV floor(input.uv * _GridSize); float checker fmod(gridUV.x gridUV.y, 2.0); half4 col lerp(_ColorA, _ColorB, checker); // 边缘线提取 float2 uvs input.uv * _GridSize - _GridSize * 0.5; float2 gv abs(frac(uvs) - 0.5); float line 1.0 - step(_LineWidth, max(gv.x, gv.y)); col lerp(col, half4(0,0,0,1), line * step(0.001, _LineWidth)); return col; } ENDHLSL } } Fallback Off }几个细节值得说明。第一关于_DoubleSided开关。我代码里直接用了Cull Off因为棋盘格双面渲染的开销几乎可以忽略。但如果你希望保留一个API开关有两条路一是用[Toggle]配合#pragma shader_feature然后在ShaderLab指令层面用多Pass或者Cull[_DoubleSided]动态切换二是直接让CPU在C#侧改material的cullMode。我个人用第二种更省事因为标定板材质是动态生成的C#改起来很方便。第二边缘线的实现。step(_LineWidth, max(gv.x, gv.y))这个写法会在靠近格子边界的地方把max(gv.x, gv.y)拉出来像素距离边界小于_LineWidth时gv的最大值会小于线宽从而输出0再配合line变量把颜色改成黑色。如果你只想要纯棋盘不要边框把_LineWidth设为0即可——注意代码里我做了step(0.001, _LineWidth)的保护避免浮点乘0导致边缘线意外全部消失。第三TransformObjectToHClip的使用。这个函数在URP Core.hlsl里有定义。如果某个URP版本里没有可以自己去Core.hlsl源码里查一下名字可能是TransformObjectToHClip或TransformObjectToWorld的组合。旧的UnityObjectToClipPos在URP下也能编译过但有性能警告不推荐。3.3 顶点和UV的处理思路棋盘格shader不需要法线、切线也不需要世界坐标参与计算。所以Attributes里只声明了positionOS和uv。如果模型本身没有UV比如是从代码动态生成的Mesh默认的uv值是Vec2(0,0)那你需要自己在生成Mesh时设置uv否则整个棋盘格会变成一个颜色。从内置管线迁移时很多人会在顶点着色器里顺手加上TRANSFORM_TEX宏用来支持材质的Tiling和Offset。URP里这个宏也有但你得在Core.hlsl的include顺序之后自己定义TRANSFORM_TEX为input.uv * _MainTex_ST.xy _MainTex_ST.zw。如果完全没有贴图采样其实可以不用_MainTex_ST直接在Shader属性里暴露两个Vector给Tiling和Offset更直观。另外一个常见需求是让棋盘格跟随世界坐标而不是模型UV。标定板调试时尤其需要因为你要把一个固定角度的标定板铺在一个旋转模型上模型旋转后UV不变但棋盘格跟着模型转了打印标定板却没有这个问题。想实现世界空间棋盘格唯一改动就是把vert阶段里那行output.uv input.uv改为float3 worldPos TransformObjectToWorld(input.positionOS.xyz); output.uv worldPos.xz * _WorldGridScale;这样棋盘格的密度和方向都绑定到世界空间适合做AR地面网格调试。缺点是对旋转模型来说每个面的衔接处会有明显的交错感所以它更适合“地平面”这种静态网格。4. 内置管线版本与URP版本的逐项对比4.1 内置管线的等价Shader代码为了对比我也把内置管线版本写出来。它跟上面那段URP代码几乎一模一样区别主要在库引用和Pass标签Shader Custom/BuiltIn/CheckerBoard { Properties { _ColorA (Checker Color A, Color) (0.3, 0.3, 0.3, 1) _ColorB (Checker Color B, Color) (0.8, 0.8, 0.8, 1) _GridSize (Grid Size, Float) 8.0 _LineWidth (Line Width, Range(0, 0.5)) 0.05 } SubShader { Tags { RenderTypeOpaque QueueGeometry } Pass { Tags { LightModeForwardBase } Cull Off CGPROGRAM #pragma vertex vert #pragma fragment frag #include UnityCG.cginc struct appdata { float4 vertex : POSITION; float2 uv : TEXCOORD0; }; struct v2f { float4 pos : SV_POSITION; float2 uv : TEXCOORD0; }; v2f vert(appdata v) { v2f o; o.pos UnityObjectToClipPos(v.vertex); o.uv v.uv; return o; } fixed4 frag(v2f i) : SV_Target { fixed4 colorA _ColorA; fixed4 colorB _ColorB; float2 gridUV floor(i.uv * _GridSize); float checker fmod(gridUV.x gridUV.y, 2.0); fixed4 col lerp(colorA, colorB, checker); float2 uvs i.uv * _GridSize - _GridSize * 0.5; float2 gv abs(frac(uvs) - 0.5); float line 1.0 - step(_LineWidth, max(gv.x, gv.y)); col lerp(col, fixed4(0,0,0,1), line * step(0.001, _LineWidth)); return col; } ENDCG } } Fallback Off }从代码密度上就能看出两者只有两处硬差异库引用换掉、Pass标签换掉。核心片元算法可以完全平移。4.2 核心片段算法一致为什么结果会不同既然片元代码一样为什么有些人从内置切到URP后棋盘格看起来模糊、发白或者整体偏色我排查过不少这类问题原因大多不在shader本身而在URP的渲染纹理格式和后处理配置。URP默认使用线性色彩空间和HDR渲染纹理哪怕你的项目根本没开后处理颜色空间转换也会影响棋盘格黑白格的看起来的对比度。内置管线如果工程设置里是Gamma空间切到URP后很可能变成线性空间黑白格的物理值没变但视觉上灰色过渡会更多。后处理体积Volume加上后Tone Mapping的默认强度可能让高光白色区域变灰。棋盘格这种高对比度图案最容易暴露这个问题。如果你的棋盘格是半透明材质我这边有用半透明版本做调试URP的混合方式受RenderQueue影响很大内置管线里常见的QueueTransparent Blend One OneMinusSrcAlpha写法在URP里必须配合BlendOp和ZWrite Off一起设置否则颜色会叠穿或缺失。4.3 性能对比SRP Batcher下的实测数据我拿同一台低端安卓机器同一盒模型分别跑内置管线和URP材质都用各自的棋盘格Unlit Shader。测试场景很简单一个地面平面铺上棋盘格上面摆六个旋转的Cube一共七组Draw Call。内置管线不勾选SRP Batcher时七个物件就是七次Draw Call。如果开了Static Batching只是地面那个静态物体能合批Cube还是各自执行。URP的SRP Batcher对Urp自己的shader兼容性很好。因为我这段代码把材质属性都包在UnityPerMaterialCBUFFER里且顶点数据布局没有特殊结构帧调试器显示SRP Batcher直接把所有相同shader的物体批处理了。最终在场景里七个棋盘格mesh全部合并为一个CPU没有Draw Call计数的Batcher调用Draw Call从7降低到1。实测数据仅供参考不同GPU显示方式不一样但趋势是明确的URP里只要shader符合SRP Batcher规范合批效果立竿见影内置管线就算代码写得再规整它没有一套同样独立的Batcher逻辑。这个差异尤其在棋盘格这类程序化纹理、无贴图无光照的shader上特别明显因为少了纹理资源采样Batch变多也不会额外加重显存带宽。5. 标定板项目里的特殊处理与避坑技巧5.1 标定板为什么必须双面渲染标定板有很多使用方式。工业相机标定经常用到反射式标定板就是打印出来贴在平面板上相机拍正面。但你要是用深度相机或光场相机会同时从两个方向拍同一个标定板。这时候双面着色就很重要了——背面如果剔除相机从背面看过去就是透明的一坨。即便只是做单相机的屏幕标定很多算法也会同时提取正面和侧面视角的特征角点。棋盘格shader如果开了双面其实只增加了极少的片元负载因为片元逻辑不涉及法线、光照等需要区分面的计算。如果你担心性能可以只在包含标定板的场景里开双面常规渲染里用单面版本。5.2 采样精度与亚像素角点偏移标定算法里最恨的就是棋盘格边缘有渐变过渡。程序化shader绘制棋盘格由于其本质是UV坐标的离散跳变除非你手动做边缘抗锯齿否则边缘是一个像素宽的硬切边。屏幕渲染时由于显示器像素排列、缩放等因素硬切边会表现为一个像素的灰阶过渡。角点检测在灰度图上做插值时亚像素位置容易偏移。解决思路有两个上采样设备把棋盘格材质放在一个高分辨率渲染纹理上然后再缩放采样到屏幕减少边缘混叠。shader内部加渐变线宽过渡。不要用step硬切改用smoothstep给格子边缘一个可调的过渡带宽。标定算法可以把这个过渡带宽当作模糊因子处理反而比硬边更好提取亚像素位置。我的一份标定测试里硬切棋盘格的角点重复精度大约在0.3像素而加了0.5像素宽的smoothstep过渡后重复精度能压到0.08像素左右。当然这是依赖具体标定库的不能一概而论。5.3 从内置管线迁移标定工具时的注意事项很多项目是先把标定工具在内置管线里跑通然后迁移到URP。我建议迁移时不要只替换shader还要检查渲染纹理格式。内置管线的RenderTextureFormat.RGHalf等自定义格式在URP里依然存在但URP后处理链路的深度缓冲格式可能不同。如果标定算法读深度图一定要确认深度纹理的位数一致否则角点深度数据漂移会非常明显。另外标定工具的棋盘格生成脚本不要依赖OnGUI或者CommandBuffer的DrawMesh时序。URP里OnRenderObject这类回调在某些相机渲染模式下不会触发可靠的方式是把棋盘格做成一个动态Mesh或者干脆用Unity UI画一个RawImage把程序化纹理生成到Texture2D里。但我更推荐shader方案因为Mesh方案可以保留UV缩放和双面特性而且资源占用更低。6. 常见问题和排查思路6.1 材质变成粉红色的处理流程这是URP适配最经典的问题。粉色说明shader编译失败或压根没过URP的校验。排查步骤打开Window Shader Inspector查看当前Activ Shader和编译日志。确认SubShader上有RenderPipeline UniversalPipeline标签。确认Pass的LightMode是UniversalForward或SRPDefaultUnlit。查看Console窗口有没有关于Mathf或重复定义的报错特别是双击到shader行号时直接跳到include文件缺了某个函数。如果是兼容性问题还有一种特殊情况URP的版本太老比如6.x时代很多函数还叫UnityCG里的名字但后来又改了。遇到这种情况我都是直接升级URP包到10.x以上老版本的问题基本消失。6.2 双面材质在URP里导致阴影异常棋盘格不产生阴影时没事但如果你的标定板模型上有若干辅助Cube这些Cube开了双面材质同时它们的shader又带了ShadowCaster Pass阴影可能出现破面。URP的阴影实现里材质只是单向采样约定双面物体的背面阴影投射会按正面深度来计算。解决办法是专门给双面物体写一个轻量的ShadowCaster Pass把顶点位置沿法线偏移一点或者直接开启URP的Cast Shadows Two Sided设置。后者在URP 12以后有官方开关比写Pass省事。6.3 棋盘格在HDR色调映射下发灰我实测最常见的原因是场景里默认Volume的Tone Mapping是Neutral或ACES黑白格白色部分局部亮度太高被Tonemapping压缩下来。调试时可以先把Volume调成None看看棋盘格恢复原样没有。还想更精准调试加一个Debug View材质直接把片元颜色输出成checker这个计算值的灰度不用管颜色空间就能看出算法本身是否正常。6.4 SRP Batcher不合批如果帧调试器里看到同shader的物体没有合批优先查CBUFFER有没有包好。很多人从内置拷贝代码属性写在CBUFFER_START外面编辑器兼容但Batcher直接失效。这种情况编译不报错只能靠帧调试器或Shader面板的安全提示排查。另外material属性的hideFlags也会影响批处理。我在编辑器里动态修改material属性后要记得调用material.SetFloat而不是直接改material的.setProperty内部数组否则在有些Unity版本里会产生材质变体变体一多合批就断。6.5 UV密度与边缘线宽的比例失调当_GridSize特别大比如100多格时_LineWidth的绝对值要同比例调小。我见过有人把_GridSize改成200后边缘线宽度还是设为0.1结果半个屏幕都是黑格子线。把线宽改成相对值例如_LineWidth * 0.005 / _GridSize能避免这个问题。这段代码里我特意保留step(0.001, _LineWidth)的写法就是为了防止有人误设线宽为0让所有格子都变成纯黑。多做这层保护没坏处尤其是标定项目要频繁调参时少一个莫名奇妙的视觉bug。7. 一些个人建议如果只想做一个调试用的棋盘格我强烈建议直接用URP版本不用回头维护内置管线版本。现在Unity新项目的默认管线基本就是URP内置管线只在老项目和极小体量的商业化项目里还存在。同一个shader维护两套ShaderLab代码后面做管线升级或Unity版本升级时会非常痛苦尤其是URP每个大版本的API都会有小幅调整。但如果你已经在内置管线里跑了很久且暂时没有迁移全套渲染逻辑的计划把棋盘格shader单独写一份内置版本也不是坏事。我觉得最合理的方案是先做URP版本同时保留一个简单的内置Unlit版本做备胎。两者片元逻辑相同只是粘贴不同的include和Pass标签工作量也就半小时。需要做双管线的项目这样的双shader结构最稳。标定板类的工具工具shader我最后再提一个细节记得在shader的Properties里把标签设为[HideInInspector]的变量尽量精简因为URP的材质Inspector默认会把所有可调属性都暴露出来标定现场容易被不熟悉项目的人误改参数。我在项目里把_GridSize和_LineWidth都固定成了内部计算值只在代码里通过materialPropertyBlock修改不会暴露在Inspector减少误触概率。棋盘格这个硬核小案例其实覆盖了URP shader编程里最核心的几个问题Clipping空间变换、CBUFFER结构、Pass标签、双面渲染和后处理适配。把这个跑顺了再做更复杂的URP风格化shader至少不会被管线层的基础问题卡住。
RELATED READING

延伸阅读

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