尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

Unity Shader双色纹理平铺:程序化生成与性能优化实践

Unity Shader双色纹理平铺:程序化生成与性能优化实践 1. 项目概述双色纹理平铺的独特价值在Unity3D的Shader开发中纹理平铺Tiling是一个基础到几乎被忽视的功能大多数开发者都满足于材质面板上那个简单的“Tiling”滑块。但当你需要实现一些更精细、更具艺术表现力的效果时比如一个由两种颜色周期性交替构成的棋盘格、砖墙的错缝拼接或者带有两种不同磨损程度的金属表面单纯依赖一张纹理和Unity内置的平铺参数就显得捉襟见肘了。这正是“纯Shader的双色纹理平铺计算与实现”这个项目要解决的核心问题。这个项目的本质是摆脱对预制纹理图片的依赖完全在Shader片段着色器中通过数学计算动态生成一个由两种纯色或简单颜色构成的、可无限平铺的图案。它解决的不仅仅是“重复贴图”的问题更是“程序化生成可控图案”的需求。想象一下你需要一个可实时调整格子大小、颜色和旋转角度的棋盘如果为此制作无数张不同规格的纹理图不仅浪费内存更无法实现运行时的动态变化。而纯Shader计算方案则能以近乎零的成本提供无限的分辨率和极致的灵活性。它非常适合那些对渲染效率和动态效果有要求的场景比如策略游戏的动态网格、UI界面的背景图案、 stylized风格化游戏中的程序化材质或是任何需要高频、无缝平铺基础图案的地方。无论你是刚接触Shader编程的新手想理解UV操作的数学本质还是经验丰富的TA技术美术寻求更优雅的性能解决方案这个项目都能提供一条从原理到实践的清晰路径。2. 核心思路从UV到图案的数学映射实现双色纹理平铺其核心思路是将连续的UV坐标空间通过一系列数学变换离散化为一个二值或有限值的图案。这个过程不依赖任何纹理采样纯粹是数学计算。2.1 UV空间的离散化floor与frac的妙用我们首先需要理解两个关键函数floor(x)和frac(x)。floor(x)返回小于或等于x的最大整数它像一个“向下取整”的格子将连续的数值划分到不同的整数区间。frac(x)则返回x的小数部分即x - floor(x)它的结果永远在[0, 1)范围内。双色平铺的计算正是基于这两个函数。假设我们想要在U方向平铺4次在V方向平铺3次。我们不会直接去修改纹理的Tiling参数而是对输入的UV坐标进行如下操作float2 tiledUV IN.uv * float2(4.0, 3.0);此时tiledUV的每个分量范围从原来的[0, 1)变成了[0, 4)和[0, 3)。接下来我们使用floor函数float2 cellIndex floor(tiledUV);cellIndex.x和cellIndex.y现在变成了整数它们标识了当前片段位于第几个“格子”里。例如cellIndex (1.0, 2.0)表示这个片段在U方向第2个格子V方向第3个格子里从0开始计数。而如果我们取frac(tiledUV)得到的就是当前片段在其所属格子内部的、归一化后的局部坐标范围依然是[0, 1)这常用于在格子内进行二次图案绘制。注意这里有一个非常重要的细节。当我们用IN.uv * scale进行缩放时我们实际上是在对“纹理空间”进行缩放而不是对“世界空间”的网格进行缩放。这意味着如果模型的UV本身被拉伸过例如一个非正方形的面我们计算出的格子也会随之拉伸。如果你需要无论模型UV如何都保持方格子的纵横比可能需要更复杂的处理比如基于屏幕空间或世界空间坐标进行计算。2.2 双色判定的核心算法奇偶校验与模运算得到了每个片段的“格子索引”cellIndex后如何决定它该显示颜色A还是颜色B呢最经典和高效的方法是使用奇偶校验。我们可以检查cellIndex.x和cellIndex.y的和或分别检查的奇偶性。在Shader中由于没有直接的取模运算符%用于浮点数HLSL的%是求余对整数有效我们通常利用frac函数和乘以0.5再取整的技巧来实现。// 方法一检查 (xy) 的奇偶性 - 生成斜条纹或棋盘格变体 float checker frac((cellIndex.x cellIndex.y) * 0.5); // 如果 checker 0.5可以认为是“奇数”格否则是“偶数”格 float isColorA step(0.5, checker); // step(a, x): xa 则返回1否则返回0 // 方法二分别检查 x 和 y 的奇偶性然后进行异或操作 - 生成标准棋盘格 float isOddX frac(cellIndex.x * 0.5) * 2.0; // 将奇偶性映射到0或1 float isOddY frac(cellIndex.y * 0.5) * 2.0; float checkerStandard isOddX ^ isOddY; // 异或操作相同为0偶数偶数或奇数奇数不同为1step函数在这里充当了一个二值化开关。isColorA为1时使用颜色A为0时使用颜色B。通过lerp线性插值函数可以优雅地实现这个选择float3 finalColor lerp(colorB.rgb, colorA.rgb, isColorA);这种方法的性能开销极低一次乘法和一次frac或step操作在现代GPU上几乎可以忽略不计。2.3 融入标准材质工作流与 _MainTex_ST 的协同一个健壮的双色平铺Shader不应该是一个孤立的特效而应该能够融入Unity的标准材质工作流。这意味着我们需要支持材质面板上的Tiling和Offset参数即_MainTex_ST让美术人员可以用他们熟悉的方式控制图案的密度和位置。关键在于计算顺序。标准的纹理采样流程是uv * _MainTex_ST.xy _MainTex_ST.zw。在我们的纯计算Shader中流程需要稍作调整首先应用材质的Offset和Tilingfloat2 baseUV IN.uv * _MainTex_ST.xy _MainTex_ST.zw;。这确保了美术在材质面板上调整的平铺倍数和偏移能首先生效控制整个图案的密度和起始位置。然后进行我们的离散化计算float2 tiledUV baseUV * _CustomTiling;。这里的_CustomTiling是我们Shader暴露的一个额外属性用于控制双色格子本身的密度。它是在材质Tiling基础上的进一步缩放。最后进行奇偶判定和颜色选择。这样的设计提供了两层控制美术可以用熟悉的_MainTex_ST做整体调整而程序或TA则可以通过_CustomTiling来精确控制图案本身的频率两者互不干扰且效果叠加。3. 完整Shader实现与逐行解析下面我们将构建一个完整的、适用于URPUniversal Render Pipeline管线的双色平铺Shader。这个Shader将包含完整的属性块、CBuffer声明、顶点/片段着色器并详细解释每一部分的作用。3.1 Shader属性与变量声明首先我们定义Shader在材质面板上可调节的参数。Shader Custom/PureShader_DualColorTiling { Properties { // 颜色A和颜色B _ColorA (Color A, Color) (1, 1, 1, 1) _ColorB (Color B, Color) (0, 0, 0, 1) // 双色图案的平铺密度在材质Tiling基础上叠加 _PatternTiling (Pattern Tiling, Vector) (4, 4, 0, 0) // 图案旋转角度度 _PatternRotation (Pattern Rotation, Range(0, 360)) 0 // 是否使用棋盘格模式否则为横条纹 [Toggle]_CheckerboardMode (Checkerboard Mode, Float) 1 // 保留与标准材质工作流的兼容性 [MainTexture] _MainTex (Fake Texture (Unused), 2D) white {} }这里有几个关键点_ColorA和_ColorB定义了双色的颜色。使用Color类型方便在面板上通过拾色器选择。_PatternTiling一个Vector类型我们用它的xy分量分别控制U和V方向的图案密度。注意它不同于_MainTex_ST.xy它是额外的控制参数。_PatternRotation一个进阶功能允许图案旋转。这对于打破纯粹的轴向对齐感创造更自然的效果很有用。[Toggle]_CheckerboardMode一个开关属性。当开启时使用棋盘格判定逻辑当关闭时可以退化为简单的横条纹或竖条纹增加了Shader的灵活性。[MainTexture] _MainTex这里声明一个虚拟的_MainTex并标记为[MainTexture]是一个重要技巧。这确保了材质Inspector面板上会出现熟悉的“Tiling”和“Offset”参数滑块这些滑块会自动关联到_MainTex_ST这个内置变量上即使我们根本不采样这张纹理。这保持了与美术工作习惯的一致性。3.2 CBuffer与纹理采样器声明在URP中为了支持SRP Batcher一种提升渲染合批效率的技术所有材质属性必须声明在CBUFFER_START(UnityPerMaterial)和CBUFFER_END之间。SubShader { Tags { RenderTypeOpaque RenderPipelineUniversalPipeline QueueGeometry} Pass { HLSLPROGRAM #pragma vertex vert #pragma fragment frag #include Packages/com.unity.render-pipelines.universal/ShaderLibrary/Core.hlsl // 为了使用旋转函数 #include Packages/com.unity.render-pipelines.core/ShaderLibrary/SpaceTransforms.hlsl // --- 核心UnityPerMaterial CBUFFER --- CBUFFER_START(UnityPerMaterial) // 标准纹理的Tiling和Offset由材质面板的_MainTex参数驱动 float4 _MainTex_ST; // 我们的自定义属性 float4 _ColorA; float4 _ColorB; float2 _PatternTiling; float _PatternRotation; float _CheckerboardMode; CBUFFER_END // 注意我们声明了_MainTex但只是为了兼容性实际上不会采样它。 // 不过为了通过Shader编译仍需声明纹理和采样器。 TEXTURE2D(_MainTex); SAMPLER(sampler_MainTex);CBUFFER的声明是URP Shader的强制要求它告诉Unity哪些属性是“每材质”的可以被高效地打包和批处理。遗漏这一步会导致SRP Batcher失效在移动平台或大量使用该材质的场景中造成严重的性能下降。_MainTex_ST必须包含在内即使我们不使用_MainTex纹理本身。3.3 顶点着色器传递变换后的UV顶点着色器的任务很简单计算裁剪空间位置并准备UV坐标。struct Attributes { float4 positionOS : POSITION; // 对象空间顶点位置 float2 uv : TEXCOORD0; // 第一套UV }; struct Varyings { float4 positionHCS : SV_POSITION; // 齐次裁剪空间位置 float2 uv : TEXCOORD0; // 传递给片段的UV }; Varyings vert(Attributes IN) { Varyings OUT; // 将对象空间顶点位置转换到齐次裁剪空间 OUT.positionHCS TransformObjectToHClip(IN.positionOS.xyz); // 关键步骤应用材质面板上的Tiling和Offset。 // TRANSFORM_TEX 宏等价于IN.uv * _MainTex_ST.xy _MainTex_ST.zw OUT.uv TRANSFORM_TEX(IN.uv, _MainTex); return OUT; }这里唯一的关键操作是TRANSFORM_TEX。这个宏是Unity内置的它自动应用了_MainTex_ST中存储的缩放和平移。这意味着美术在材质Inspector里调整Tiling为(2,2)Offset为(0.5,0)时传入片段着色器的OUT.uv就已经是从(0.5, 0)开始每单位距离重复两次的UV了。这为我们后续的图案计算提供了正确的基础坐标系。3.4 片段着色器核心计算逻辑片段着色器是整个Shader的灵魂所有图案生成逻辑都在这里。half4 frag(Varyings IN) : SV_TARGET { // 步骤1应用自定义图案平铺密度 float2 tiledUV IN.uv * _PatternTiling; // 步骤2可选应用图案旋转 // 将角度转换为弧度 float rad _PatternRotation * PI / 180.0; // 构建2D旋转矩阵 float2x2 rotMatrix float2x2(cos(rad), -sin(rad), sin(rad), cos(rad)); // 旋转UV坐标。注意旋转中心是(0,0)。如需绕图案中心旋转需先平移再旋转再平移回来。 tiledUV mul(rotMatrix, tiledUV); // 步骤3离散化UV获取当前片段所在的“格子索引” float2 cellIndex floor(tiledUV); // 步骤4基于格子索引进行双色判定 float patternMask; if (_CheckerboardMode 0.5) // 棋盘格模式 { // 经典棋盘格判定检查 (cellIndex.x cellIndex.y) 的奇偶性 // frac(... * 0.5) 的结果会在0.0和0.5之间交替 // step(0.25, ...) 将其二值化为0或1。这里用0.25作为阈值比0.5更稳健。 patternMask step(0.25, frac((cellIndex.x cellIndex.y) * 0.5)); } else // 横条纹模式基于V方向的索引 { // 只根据V方向的格子索引奇偶性决定颜色 patternMask step(0.25, frac(cellIndex.y * 0.5)); } // 步骤5根据mask值在ColorA和ColorB之间线性插值 half4 finalColor lerp(_ColorB, _ColorA, patternMask); // 步骤6输出最终颜色 return finalColor; } ENDHLSL } } FallBack Universal Render Pipeline/Unlit }让我们拆解这个片段着色器基础缩放tiledUV IN.uv * _PatternTiling;这是图案频率控制的核心。_PatternTiling越大格子就越小图案越密集。旋转处理这是一个进阶特性。我们构建了一个2D旋转矩阵并对tiledUV进行变换。这里有一个重要细节旋转是绕UV原点(0,0)进行的。如果你希望图案绕自身每个格子的中心旋转或者绕整个UV空间中心旋转需要更复杂的变换先平移至中心旋转再平移回去。当前实现提供了基础的旋转能力足以产生斜向条纹等效果。离散化floor(tiledUV)是通往离散世界的大门。它将连续的UV坐标“拍扁”到整数网格上。模式判定根据_CheckerboardMode开关选择不同的判定逻辑。棋盘格模式使用cellIndex.x cellIndex.y的和的奇偶性这是生成国际象棋棋盘样式的标准方法。横条纹模式则只依赖cellIndex.y生成水平条纹。你可以很容易地扩展这个else分支添加竖条纹、点状等其它模式。颜色插值lerp函数是Shader中的瑞士军刀。lerp(a, b, t)当t0时返回at1时返回bt在中间时进行线性插值。我们的patternMask恰好是0或1因此它完美地在_ColorA和_ColorB之间做二选一开关。性能考量整个计算过程没有纹理采样SAMPLE_TEXTURE2D只有一些简单的算术运算、矩阵乘法和条件判断。在现代GPU上这比一次纹理采样还要快得多尤其是在移动平台上能有效减少带宽压力和提升帧率。4. 高级技巧与效果扩展基础的棋盘格和条纹只是开始。基于这个框架我们可以通过修改判定逻辑和引入更多计算创造出丰富多样的效果。4.1 实现非均匀平铺与随机化标准的floor操作创建的是均匀的网格。但有时我们需要不均匀的、更有机的平铺。我们可以引入一个“扰动”函数来修改cellIndex的计算。// 假设有一个简单的伪随机函数输入一个二维坐标输出一个0-1之间的“随机”值 float hash(float2 p) { return frac(sin(dot(p, float2(127.1, 311.7))) * 43758.5453); } // 在片段着色器中计算格子索引前加入扰动 float2 randomOffset float2(hash(cellIndex), hash(cellIndex.yx)) * 0.3; // 扰动强度0.3 float2 perturbedTiledUV tiledUV randomOffset; float2 perturbedCellIndex floor(perturbedTiledUV); // 使用 perturbedCellIndex 进行后续的颜色判定这个hash函数是一个经典的伪随机函数它为每个整数格子生成一个固定的“随机”值。将其加到tiledUV上会导致格子边界发生扭曲从而产生不规则但确定性的图案。调整扰动强度可以控制不规则的程度。注意这里的“随机”是基于格子索引的所以同一个格子内的所有片段扰动值相同保证了图案的连续性不会产生噪点。4.2 创建平滑过渡与抗锯齿当前实现的图案边缘是锐利的因为step函数是二值的。在低分辨率或图案密度很高时可能会产生锯齿。我们可以用smoothstep函数替代step在格子边界创建一个平滑的过渡区域。// 替代原来的 step(0.25, frac(...)) 判定 float fractionalPart frac((cellIndex.x cellIndex.y) * 0.5); // 使用 smoothstep 创建从0.2到0.3的平滑过渡 float patternMask smoothstep(0.2, 0.3, fractionalPart);smoothstep(a, b, x)在x a时返回0x b时返回1在a和b之间时返回一个平滑的Hermite插值。这样颜色会在格子边界处有一个柔和的渐变视觉上更平滑也能有效减少锯齿。a和b的差值决定了过渡带的宽度。4.3 融入光照模型URP Lit Shader目前我们的Shader是Unlit无光照的。要让它与场景光照交互需要将其改造成一个Lit Shader。这涉及到使用URP的Lit着色器模板并在计算基础色后交给URP的照明计算函数。// 在Properties中添加一些PBR属性 _Glossiness (Smoothness, Range(0,1)) 0.5 _Metallic (Metallic, Range(0,1)) 0.0 // 在片元着色器中计算基础色Albedo后将其赋给SurfaceData结构 SurfaceData surfaceData; surfaceData.albedo finalColor.rgb; // 我们计算出的双色 surfaceData.specular half3(0.0h, 0.0h, 0.0h); // 或根据需求计算 surfaceData.metallic _Metallic; surfaceData.smoothness _Glossiness; surfaceData.normalTS half3(0.0h, 0.0h, 1.0h); // 切线空间法线默认朝上 surfaceData.emission half3(0.0h, 0.0h, 0.0h); surfaceData.occlusion 1.0; surfaceData.alpha finalColor.a; // 然后调用URP的照明函数 InputData inputData (InputData)0; // ... 填充inputData世界空间位置、法线、视角方向等... half4 color UniversalFragmentPBR(inputData, surfaceData); return color;改造为Lit Shader后我们的双色图案就会对场景中的灯光、反射探针等产生正确的反应呈现出金属感、光滑度等物理属性从而无缝集成到复杂的场景中。4.4 在Shader Graph中可视化构建对于偏好可视化编程的技术美术或艺术家在Shader Graph中实现同样的逻辑是完全可行的而且更加直观。创建节点新建一个Unlit或Lit Shader Graph。UV处理链使用Tiling And Offset节点应用_MainTex_ST对应材质面板的Tiling/Offset。其后连接一个Multiply节点输入_PatternTiling属性实现自定义密度缩放。使用Rotate节点如果需要旋转。离散化与判定将上一步的结果分别输入两个Split节点获取U和V分量。对每个分量使用Fraction节点和Multiply乘以_PatternTiling对应分量再Floor得到cellIndex.x和cellIndex.y。或者更简单直接用Fraction节点获取小数部分用于判定。使用Add节点计算cellIndex.x cellIndex.y然后Multiply0.5再Fraction。使用Step节点将上一步结果与0.25比较输出0或1的Mask。颜色混合创建两个Color属性节点对应_ColorA和_ColorB用Lerp节点以Step输出的Mask为Alpha进行混合。输出将Lerp的结果连接到Fragment的Base Color端口。Shader Graph的优势在于实时预览和参数调节的即时反馈非常适合快速迭代和艺术创作。5. 性能优化与常见问题排查即使是一个简单的计算Shader也有性能陷阱和实现细节需要注意。5.1 性能考量计算 vs 采样优势零带宽消耗这是最大的优势。纹理采样需要从显存读取数据而计算只在ALU算术逻辑单元中进行避免了内存带宽瓶颈在移动端和集成显卡上优势明显。无限分辨率图案是数学定义的因此可以无限放大而不会出现像素锯齿虽然可能有几何锯齿。你永远不需要担心纹理分辨率不够。极小存储开销Shader代码本身很小材质球只存储几个浮点数参数相比一张纹理贴图尤其是高分辨率RGBA贴图节省了大量内存和磁盘空间。极致动态化所有参数颜色、密度、旋转都可以在运行时通过脚本MaterialPropertyBlock实时修改且无任何额外开销。潜在开销ALU指令数如果计算非常复杂例如包含多个sin、cos、noise函数调用其开销可能会超过一次简单的双线性纹理采样。但对于floor、frac、step和少量算术运算开销远低于采样。条件分支Shader中的if语句在某些GPU架构上可能导致性能波动。在我们的例子中if (_CheckerboardMode 0.5)是一个基于 uniform 变量每材质常量的分支现代GPU的编译器通常能很好地优化它或者将其编译为两个不同的Shader变体。如果非常担心可以写两个独立的Shader或者使用lerp和计算来消除分支。结论对于双色平铺这种简单图案纯计算方案在几乎所有场景下都比纹理采样方案性能更优。5.2 常见问题与解决方案以下是在实现和使用过程中可能遇到的典型问题及其解决方法。问题现象可能原因解决方案图案拉伸或变形模型的原始UV本身不均匀例如一个长条形面的UV。floor操作是基于UV空间的UV变形会导致图案变形。1.修改模型UV在3D软件中展平UV。2.使用其他空间在Shader中改用世界空间XZ坐标或屏幕空间坐标来计算图案。例如float2 worldXZ IN.positionWS.xz;然后用它替代IN.uv进行后续计算。旋转中心不对旋转是绕UV原点(0,0)进行的而非图案中心或模型中心。在旋转前先将坐标平移到想要的中心点旋转后再平移回去。例如绕UV中心(0.5,0.5)旋转uv - 0.5;uv mul(rotMatrix, uv);uv 0.5;在物体边缘出现接缝如果使用世界空间坐标当物体移动时图案会“滑动”。对于需要固定在物体表面的图案这可能不是问题但对于需要世界空间连续性的图案如地面网格这就是问题。确保计算是基于世界空间位置并且与物体变换无关。使用IN.positionWS世界空间顶点位置即可。图案会固定在世界中物体移动就像在固定的图案上移动。图案闪烁或抖动UV Jitter在片段着色器中直接使用_Time.y等持续增大的值做UV偏移长时间运行后浮点数精度不足。对动画偏移量使用frac()函数将其值限制在[0, 1)周期内。例如float2 animOffset frac(_Time.y * _Speed) * _Direction;SRP Batcher失效在URP中Shader属性没有正确声明在CBUFFER_START(UnityPerMaterial)中。确保所有在材质面板暴露的、需要每材质变化的属性如_ColorA,_PatternTiling等都包含在CBUFFER块内。_MainTex_ST也必须在内。在Shader Graph中预览正常但材质球上不显示Tiling/Offset滑块没有声明[MainTexture]属性的纹理或者属性名不是_MainTex。在Properties块中确保有一行[MainTexture] _MainTex(Albedo, 2D) white {}。Unity依赖这个特定的属性名和标签来生成标准的Tiling/Offset UI。5.3 实战心得MaterialPropertyBlock 的正确使用如果你需要通过C#脚本在运行时频繁修改图案参数比如随着游戏状态改变格子颜色或密度强烈建议使用MaterialPropertyBlock而不是直接修改Material。错误做法会产生材质实例破坏合批void Update() { GetComponentRenderer().material.SetVector(_PatternTiling, new Vector2(tilingX, tilingY)); }每次调用GetComponentRenderer().material都会在第一次时创建一个该材质的独立实例如果还没有的话。大量物体这样做会产生成千上万个材质实例彻底破坏GPU的静态/动态合批导致DrawCall激增。正确做法使用MaterialPropertyBlock性能最优using UnityEngine; public class DynamicPatternController : MonoBehaviour { public Vector2 patternTiling Vector2.one; public Color colorA Color.white; public Color colorB Color.black; private Renderer _renderer; private MaterialPropertyBlock _mpb; // 属性名的ID提前计算好以提高效率 static readonly int PatternTilingID Shader.PropertyToID(_PatternTiling); static readonly int ColorAID Shader.PropertyToID(_ColorA); static readonly int ColorBID Shader.PropertyToID(_ColorB); void Awake() { _renderer GetComponentRenderer(); _mpb new MaterialPropertyBlock(); } void Update() { // 从MPB中获取当前值避免覆盖其他脚本设置的属性 _renderer.GetPropertyBlock(_mpb); // 设置新的属性值 _mpb.SetVector(PatternTilingID, new Vector4(patternTiling.x, patternTiling.y, 0, 0)); _mpb.SetColor(ColorAID, colorA); _mpb.SetColor(ColorBID, colorB); // 将MPB应用回渲染器 _renderer.SetPropertyBlock(_mpb); } }MaterialPropertyBlock允许你覆盖材质的某些属性而无需创建新的材质实例。所有使用同一材质并设置不同MaterialPropertyBlock的物体仍然可以被SRP Batcher合批这对性能有巨大好处。Shader.PropertyToID将属性名称字符串转换为整数ID这是一个小的优化避免在每帧的SetVector调用中进行字符串查找。
返回列表