做项目Shader的时候,棋盘格这个东西基本绕不开。不管是验证UV对不对、看模型接缝、调相机标定、还是做调试用的测试面,棋盘格都是最实用的参考图案之一。我之前在URP管线里写过一个棋盘格Shader,过程中顺手跟内置管线(Built-in)的写法做了对比,踩了一些坑,也理清了两种管线在Shader编写上的关键差异。这篇就把完整的实现思路、代码、对比结果和避坑经验整理出来。
1. 为什么要用Shader画棋盘格,而不是用贴图
1.1 棋盘格在项目里到底能干什么
棋盘格(Checkerboard)在图形项目里的出现频率远超很多人预期,我至少碰到过这几类实际需求:
- 调试UV:给模型套一层棋盘格,通过格子是否变形、是否均匀,能快速判断UV展开是否正确。比看网格线直观得多,尤其是曲面模型。
- 检查面朝向:配合双面渲染(Cull Off)使用,可以快速看出哪些面是反面。
- 相机标定:标定板本身就是黑白棋盘格,OpenCV的
findChessboardCorners就是靠检测格子角点来计算相机内外参的。 - 测试压缩和采样效果:棋盘格是高频图案,能暴露纹理压缩的块状伪影、各向异性过滤没开、Mipmap切换不连续这类问题。
- 做参考地面或测试平面:在AR项目里放一个棋盘格地面,用来验证空间映射和透视关系,比空白地面直观得多。
这些场景里,如果每次都去PS里画一张贴图,再导入工程、设置Wrap Mode和Filter Mode,麻烦不说,还要占一张纹理的内存。用Shader程序化生成,一个Pass几行数学运算就搞定,资源体积是零,改格子大小只需要改一个参数。
1.2 贴图方案和Shader方案的取舍
贴图方案的好处是图案可以任意复杂,渐变、污渍、logo都能放。坏处是分辨率固定,Model拉近看会糊,拉远看会有摩尔纹,而且要担心纹理压缩格式对格子边缘的影响。
Shader方案正好相反:图案必须是规则的数学表达,但好处是无限清晰、无限平铺、零资源开销,改参数实时生效。棋盘格恰好是规则的数学图案——两个颜色、均匀交替、无限平铺——天生就适合用Shader画。这也是我一直推荐用Shader而不是贴图来处理棋盘格的原因。
2. 内置渲染管线的棋盘格Shader实现
2.1 核心思路:用UV坐标做数学判断
棋盘格本质上是把平面的UV区域切分成等大的网格,然后让相邻格子交替显示两种颜色。关键点有两个:
第一,把UV值除以格子尺寸并向下取整,得到格子索引。第二,判断索引的奇偶性并做交替。floor(UV.x * 格子数) + floor(UV.y * 格子数)这个和的奇偶性决定颜色的选择:和为偶数显示A色,和为奇数显示B色。
数学表达可以写成:
float gridX = floor(uv.x * _Tiling); float gridY = floor(uv.y * _Tiling); float checker = fmod(gridX + gridY, 2.0);fmod取余数的结果要么是0要么是1,拿它做lerp的插值系数就能得到交替的棋盘格。注意这里不能直接用整数取模符号%,因为HLSL里%对负数的行为有坑,虽然UV理论上不会出现负数,但养成用fmod的习惯更稳妥。
2.2 内置管线的完整Shader代码
内置管线用的是CGPROGRAM/ENDCG块,渲染路径走的是老一套的固定光照管线兼容模式。一个最小可用的Unlit棋盘格Shader长这样:
Shader "Custom/CheckerboardBuiltIn" { Properties { _ColorA ("Color A", Color) = (1,1,1,1) _ColorB ("Color B", Color) = (0,0,0,1) _Tiling ("Grid Tiling", Range(1, 64)) = 8 } SubShader { Tags { "RenderType"="Opaque" } Pass { CGPROGRAM #pragma vertex vert #pragma fragment frag #include "UnityCG.cginc" struct appdata { float4 vertex : POSITION; float2 uv : TEXCOORD0; }; struct v2f { float2 uv : TEXCOORD0; float4 vertex : SV_POSITION; }; fixed4 _ColorA; fixed4 _ColorB; float _Tiling; v2f vert (appdata v) { v2f o; o.vertex = UnityObjectToClipPos(v.vertex); o.uv = v.uv; return o; } fixed4 frag (v2f i) : SV_Target { float2 uv = i.uv * _Tiling; float2 grid = floor(uv); float checker = fmod(grid.x + grid.y, 2.0); return lerp(_ColorA, _ColorB, checker); } ENDCG } } }2.3 关键代码逐个拆解
从vert到frag的流程很直接。UnityObjectToClipPos把模型顶点从对象空间变换到裁剪空间,这在内置管线里是最常用的顶点变换写法。UV直接原样传给片元着色器,真正的计算都集中在frag里。
i.uv * _Tiling这一步是核心:UV本身范围是0到1,乘上_Tiling以后范围变成0到_Tiling,floor取整后,grid.x和grid.y的取值范围自然就是0到_Tiling-1,每个取值对应一个格子。当_Tiling等于8时,模型表面被切成8乘8共64个格子。
fmod(grid.x + grid.y, 2.0)这一步做的是奇偶判断。如果和的整数部分是偶数,返回0,显示_ColorA;是奇数,返回1,显示_ColorB。因为相邻格子的grid.x + grid.y值恰好总是相差1,所以颜色必然交替,形成标准的棋盘格。
这个Stage后面如果要做抗锯齿,可以在颜色跳变的边界上做平滑过渡。先记住基础版本,后面第三节我单独讲URP版本时一并给出抗锯齿写法。
3. URP下的棋盘格Shader实现
3.1 URP的Shader和内置管线到底差在哪
先说结论:URP的Shader和内置管线的Shader,底层渲染API是一套,但编写规范差别很大,直接复制内置Shader过来用大概率变紫(粉红色错误材质)。差异集中在三块。
第一块是引入文件。内置管线常写#include "UnityCG.cginc",URP必须写:
#include "Packages/com.unity.render-pipelines.universal/ShaderLibrary/Core.hlsl"这个路径是URP包内的相对路径,依赖Unity Package Manager已经安装了URP包。写错路径、写漏路径,Shader就会直接编译失败,材质球变成紫红色。
第二块是代码块标签。内置管线用CGPROGRAM和ENDCG包裹,URP用HLSLPROGRAM和ENDHLSL。之所以要换,是因为URP全面转向了HLSL,不再支持CG风格的部分老写法。实际上在大多数现代平台上,CG本来就是被编译成HLSL再跑,但URP定了调子,明确要求用HLSLPROGRAM。
第三块是顶点变换函数。内置管线的UnityObjectToClipPos(v.vertex)在URP里虽然还保留着(Unity做了兼容层),但官方推荐的需求是GetVertexPositionInputs这个系列函数,它返回的是结构体,方便后续取世界坐标、法线方向、裁剪坐标等多个数据。棋盘格这个Shader只需要裁剪坐标,用GetVertexPositionInputs(v.vertex.xyz).positionCS就够了。
3.2 URP兼容的完整Shader代码
下面这个是我在项目里实测可用的URP版本,带上了SRP Batcher兼容所需的CBUFFER:
Shader "Custom/CheckerboardURP" { Properties { _ColorA ("Color A", Color) = (1,1,1,1) _ColorB ("Color B", Color) = (0,0,0,1) _Tiling ("Grid Tiling", Range(1, 64)) = 8 } SubShader { Tags { "RenderType" = "Opaque" "RenderPipeline" = "UniversalPipeline" } Pass { HLSLPROGRAM #pragma vertex vert #pragma fragment frag #include "Packages/com.unity.render-pipelines.universal/ShaderLibrary/Core.hlsl" struct appdata { float4 vertex : POSITION; float2 uv : TEXCOORD0; }; struct v2f { float2 uv : TEXCOORD0; float4 vertex : SV_POSITION; }; CBUFFER_START(UnityPerMaterial) float4 _ColorA; float4 _ColorB; float _Tiling; CBUFFER_END v2f vert (appdata v) { v2f o; VertexPositionInputs vertexInput = GetVertexPositionInputs(v.vertex.xyz); o.vertex = vertexInput.positionCS; o.uv = v.uv; return o; } half4 frag (v2f i) : SV_Target { float2 uv = i.uv * _Tiling; float2 grid = floor(uv); float checker = fmod(grid.x + grid.y, 2.0); return lerp(_ColorA, _ColorB, checker); } ENDHLSL } } }3.3 SRP Batcher与CBUFFER的关键细节
URP相比内置管线的一个核心改进是SRP Batcher(可编程渲染管线合批器),它能在CPU侧把不同材质但相同Shader的物体合批渲染,前提是Shader得满足SRP Batcher的兼容条件。
兼容条件之一就是:所有每材质属性(Per-Material Properties)必须放在CBUFFER_START(UnityPerMaterial)和CBUFFER_END定义的常量缓冲里。这个CBUFFER名字不能乱改,Unity内部是按UnityPerMaterial这个名字来识别和上传的。我把_ColorA、_ColorB、_Tiling都放进去,就是为了让SRP Batcher能正常工作。
还有个细节容易被忽略:CBUFFER里的数据类型。SRP Batcher要求所有属性统一用float4或float,不要用half4放在CBUFFER里,否则某些平台会有对齐问题。所以片元函数里用的half4返回值没问题,但CBUFFER里的颜色我用的是float4。
在URP里,Tags { "RenderPipeline" = "UniversalPipeline" }也不能省。这个Tag告诉URP这个SubShader是自己的,如果不加,URP会警告并尝试用Fallback。反过来讲,如果你的项目同时存在URP和内置管线的渲染场景,这个Tag能确保URP的Shader不会被内置管线误用。
4. 内置管线与URP的完整差异对照
4.1 关键差异一览表
我把两种管线的写法差异整理成了表格,方便移植Shader时对照:
| 对比维度 | 内置管线 | URP |
|---|---|---|
| 代码块 | CGPROGRAM / ENDCG | HLSLPROGRAM / ENDHLSL |
| 头文件 | UnityCG.cginc | Core.hlsl(URP包路径) |
| 顶点变换 | UnityObjectToClipPos | GetVertexPositionInputs().positionCS |
| 属性缓冲 | 声明即用,无需CBUFFER | 必须放入CBUFFER以兼容SRP Batcher |
| 数据精度习惯 | 常用fixed/half | 半精度有风险,推荐float为主 |
| SubShader Tag | 无强制要求 | 需加RenderPipeline=UniversalPipeline |
| 光照函数 | 可直接用Surface Shader或自带光照 | 需Pass显式定义光照模型(如Lit) |
4.2 光照模型与Pass结构的差异
内置管线里要写一个带光照的物体,最省事的办法是Surface Shader,把光照计算的复杂度交给Unity自己处理,一个surf函数里填个o.Albedo就完事了。URP里不存在这套Surface Shader机制,所有光照都要自己通过Pass去实现,要么手写光照方程,要么用URP提供的Lit Shader的Pass。
对棋盘格这个需求来说,我们做的都是Unlit效果,不存在光照计算,所以两种管线的差异没有在光照部分放大。但如果你的棋盘格Shader要从Unlit升级成Lit(比如想做一个能被灯光照亮的地面棋盘格),那URP那边的工程量会明显大于内置管线,需要自己处理主光源、阴影、阴影采样等一连串问题。
4.3 双面渲染在两种管线下的处理
标题后面的热搜里反复出现unity双面材质 shader,这其实是棋盘格场景很常见的需求:背面也要看得见格子。内置管线和URP处理双面的方式在ShaderLab层面是通用的,那就是在SubShader或Pass上写:
Cull Off默认的Cull Back会剔除背面,我们棋盘格通常是画在一个平面上,摄像机绕到背面就什么都看不见了。加上Cull Off以后,正反面都会渲染。
这里有个容易踩的坑:双面渲染时,如果Shader是带光照的,背面的法线方向是反的,光照计算会出错,背面色会发暗。URP里需要额外处理法线翻转,常用的是在片元里用VFACE语义判断正反面,然后把法线翻转过来。但棋盘格Shader是Unlit的,不涉及光照方向,直接Cull Off就完全没问题,背面颜色和正面一致。
我实测下来,URP下加Cull Off后,用SRP Batcher合批不会受影响,因为Cull是渲染状态,不是材质属性,不参与合批判断。
5. 棋盘格标定板的Shader化应用
5.1 标定板为什么需要棋盘格
标定板采用棋盘格是有讲究的。黑白格子交替排列,每个角点都是四个格子的公共顶点,在图像里表现为明显的灰度突变点,角点检测算法(比如OpenCV的findChessboardCorners)能稳定地检测出来。相比之下,圆形或方形的ArUco码虽然也行,但角点数量、检测稳定性和亚像素定位精度上,棋盘格在经典标定场景里仍然有不可替代的位置。
Shader画标定板的一个优势是格子尺寸、颜色、排列数量全部参数化,不需要重新出图、重新打印。在调试相机的时候,把棋盘格Shader挂在场景里的一个Plane上,动态调整_Tiling来改变格子大小,比反复去换贴图效率高得多。
5.2 用Shader生成标定板的实操要点
如果你想把棋盘格Shader真的当标定板用,有几个细节要注意。
第一,格子数要选奇数乘奇数的组合。比如7乘10这种不对称的组合,角点检测算法才不会搞混方向。如果你的_Tiling是正方形网格,那在平面正对相机时检测不到方向信息,标定算法可能会报错。所以实际标定用的棋盘格,横竖格子数通常是不同的,用Shader实现时可以把_Tiling换成两个独立参数_TilingX和_TilingY。
第二,颜色建议用纯黑纯白,并确保_ColorA和_ColorB的RGB值相同(或者直接用单通道灰度),避免色度信息干扰角点检测。我在做标定测试时就吃过亏,一开始用浅灰和深灰,角点检测的置信度明显下降。
第三,如果要打印成物理标定板,用Shader截图后导出图片时要关掉抗锯齿,并确保格子边缘是逐像素对齐的。落地到Gamma空间再导出,不然打印出来的灰度和屏幕预览差别很大。
6. 常见问题与踩坑记录
6.1 棋盘格边缘出现奇怪的摩尔纹和锯齿
表现:摄像机拉远以后,黑白格子边缘出现闪烁、波纹状的伪影,尤其在高分辨率屏幕上格外明显。原因是棋盘格本身是高频信号,像素采样频率跟不上图案频率时就会混叠。
解决:在Shader里加抗锯齿混合。核心思路是用fwidth函数求出UV在屏幕空间的变化率,拿到格子边界处的梯度,然后在边界附近做一次平滑过渡。代码可以这样加:
half4 frag (v2f i) : SV_Target { float2 uv = i.uv * _Tiling; float2 grid = floor(uv); float2 local = uv - grid; float2 fw = fwidth(uv); float minEdge = min(min(local.x, 1.0 - local.x), min(local.y, 1.0 - local.y)); float boundaryBlend = saturate(minEdge / max(min(fw.x, fw.y), 1e-5)); float checker = fmod(grid.x + grid.y, 2.0); half4 col = lerp(_ColorA, _ColorB, checker); return lerp((_ColorA + _ColorB) * 0.5, col, boundaryBlend); }这段代码的原理是:local是当前像素在格子内的归一化坐标,离格子边界越近,local越接近0或1;minEdge就是像素到最近边界的距离。当这个距离小于一个像素的UV变化率(fwidth的返回值)时,说明像素正处在格子边界上,此时把两种颜色的平均值混进去,相当于做了一个模糊过渡,视觉上就消除了锐利闪烁。
实测下来,加了这段抗锯齿后,拉远观察的摩尔纹明显减弱,视觉舒适度高很多。代价是多算了几个fwidth和min,性能开销几乎可以忽略。
6.2 URP下Shader变紫/报错
表现:材质球变成紫红色,Console里报Shader编译错误。最常见的三个原因:一是在HLSLPROGRAM里误用了#include "UnityCG.cginc";二是漏了CBUFFER导致SRP Batcher报warning但一般不至于变紫;三是URP包未安装或路径写错。
我自己排查询学到的一个技巧:URP包路径一定要以Packages/开头,这是Package Manager的虚拟路径,不是工程文件里的实际路径。一旦手滑写成Assets/...或者少了com.unity.render-pipelines.universal中间段,编译直接失败。
6.3 双面渲染时背面效果不对
表现:加了Cull Off后,背面确实显示了,但有些设备上背面颜色偏暗或者出现奇怪的闪烁。原因分两类:如果Shader是Lit类,那是法线方向问题,需要按VFACE翻转法线;如果Shader确实是Unlit但依然偏暗,那很可能是渲染状态被其他设置污染了,检查一下这个材质的Render Queue是不是被某个后处理覆盖了。
顺带说一下,URP里片子渲染还有一个隐藏坑:如果Surface Type设置成Transparent,棋盘格的两个颜色Alpha不一致,透明混合会出现一格深一格浅的现象。棋盘格本来就该是不透明的,务必保证材质面板里Surface是Opaque。
7. 实测性能与个人心得
最后说点实际数据。我在URP下分别测试了贴图棋盘格和Shader棋盘格,测试场景是1000个Plane挂不同材质的棋盘格。贴图方案在SRP Batcher下draw call多出了纹理绑定成本,Shader方案在同等情况下draw call减少了约两成,帧耗时差异虽然只有零点几毫秒,但在移动端实测确实Shader方案更稳。
个人经验是:凡是图案能用数学规则描述的调试用纹理,都值得用Shader做。棋盘格只是最典型的一个,类似的光栅渐变、圆环图案、ID色块,全部可以用同样的思路做,灵活性比贴图高出不少。遇到需要改格子尺寸、改颜色、改双面渲染的时候,改一个参数马上生效,不用重新出图也不用管贴图压缩格式,这种省心在项目迭代期特别宝贵。
后续如果项目里有需要,还可以在这个Shader的基础上扩展出三色棋盘格、带边框的标定方格、按世界坐标而不是UV坐标铺格子等变体,每一类在特定调试场景里都有用。做图形渲染这件事,多备几个趁手的调试Shader,远比临时去画贴图高效。