☰
从节点编辑器到纯代码Shader:Cg/HLSL实战与性能优化指南
2026/10/2 10:49:25 网站建设 项目流程

图形渲染这块,很多人第一次接触Shader都是从节点编辑器开始的。Unreal的材质编辑器、Unity的Shader Graph、Blender的着色器节点,拖拖拽拽连一连,效果确实出得快。但用久了你会发现一个尴尬的事:节点图一旦复杂起来,连线乱得像蜘蛛网,改一个参数要顺藤摸瓜找半天,而且很多底层逻辑被封装在黑盒里,出了问题根本不知道从哪查。我自己就经历过这个阶段,用Shader Graph调一个卡通描边,节点连了四十多个,帧率掉得厉害,最后发现是某个节点在顶点阶段做了不必要的计算,但节点图里根本看不出来。

所以后来我强迫自己回到纯代码写Shader这条路。Cg/HLSL虽然看起来门槛高一点,但一旦跨过去,你对渲染管线的控制力是完全不一样的。这篇文章就是把我从最基础的着色器结构,到贴图混合、描边、后处理这几个实战环节的经验整理出来。不管你是刚学图形学在做实验的学生,还是想从节点转向代码的引擎开发者,这些内容应该都能直接拿来用。

1. 为什么我建议你尽早放弃节点转向纯代码Shader

1.1 节点编辑器的便利性与隐性代价

节点编辑器最大的优势是可视化。你不需要记住语法,不需要理解寄存器分配,连上就能看到效果。对于美术人员或者快速原型验证来说,这确实是最优解。但问题在于,当你需要做性能优化的时候,节点图给你的信息太少了。

我拿一个实际案例来说。之前在项目里做一个水面效果,用节点图连了折射、反射、法线扰动、泡沫遮罩,看起来很正常。但在移动端跑起来帧率只有二十多。用RenderDoc抓帧分析后发现,节点图自动生成了一大堆冗余的插值器,而且某些本该在顶点阶段算的东西被放到了片元阶段。这些在节点图里完全看不出来,因为编辑器不会告诉你它生成了什么代码。

纯代码写Shader就不一样了。你写的每一行都直接对应到GPU指令,插值器用了几个、哪些计算在顶点阶段、哪些在片元阶段,全部一目了然。优化的时候你可以精确控制每一个环节,而不是靠猜。

1.2 从节点思维切换到代码思维的关键跨越

很多人从节点转代码卡住,不是因为语法难,而是因为思维方式没转过来。节点图是数据流思维,你关心的是数据从A流到B再流到C。但代码Shader是阶段思维,你需要明确知道当前代码运行在管线的哪个阶段,这个阶段能访问什么数据,输出什么数据。

举个简单的例子。在节点图里,你想让一个物体颜色随时间变化,直接连一个Time节点到颜色上就行了。但在代码里,你需要知道Time是Unity内置的uniform变量,需要在CGPROGRAM块里声明,然后在片元着色器里用它来计算最终颜色。这个过程中,你还要考虑Time的精度问题、是否需要在顶点阶段预计算等等。

跨越这个门槛之后,你会发现代码Shader其实更自由。节点图能做的事情代码都能做,但代码能做的事情节点图不一定能做。比如自定义的几何着色器、复杂的光照模型、多Pass渲染,这些在节点图里要么做不了,要么做起来非常别扭。

1.3 纯代码Shader在性能调优上的绝对优势

性能调优是纯代码Shader最大的价值所在。我举几个具体的场景。

第一个是插值器的控制。在节点图里,你连的每一条线只要跨越了顶点和片元阶段,就会自动生成一个插值器。移动端GPU对插值器数量非常敏感,超过一定数量就会导致寄存器溢出,性能断崖式下降。纯代码写的时候,你可以精确控制哪些数据需要插值,哪些可以在片元阶段重新计算。

第二个是分支预测。节点图里做条件判断,生成的代码往往会有大量分支。而GPU对分支的处理效率很低,特别是当同一个warp里的像素走不同分支的时候。纯代码写的时候,你可以用step、lerp、saturate这些函数来避免分支,让代码变成无分支的线性执行。

第三个是精度控制。HLSL里可以显式指定float、half、fixed精度。节点图里虽然也有精度选项,但控制粒度很粗。纯代码写的时候,你可以对每个变量单独指定精度,在移动端能省下可观的带宽和计算资源。

2. Cg/HLSL基础着色器的骨架到底长什么样

2.1 一个最小可用的Shader结构拆解

先看一个最基础的Unity Shader结构。这个结构你写一百遍都不嫌多,因为所有复杂的Shader都是从这个骨架长出来的。

Shader "Custom/BasicShader" { Properties { _MainTex ("Main Texture", 2D) = "white" {} _Color ("Tint Color", Color) = (1,1,1,1) } SubShader { Tags { "RenderType"="Opaque" "Queue"="Geometry" } Pass { 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; }; sampler2D _MainTex; float4 _MainTex_ST; fixed4 _Color; v2f vert (appdata v) { v2f o; o.pos = UnityObjectToClipPos(v.vertex); o.uv = TRANSFORM_TEX(v.uv, _MainTex); return o; } fixed4 frag (v2f i) : SV_Target { fixed4 texColor = tex2D(_MainTex, i.uv); return texColor * _Color; } ENDCG } } }

这个结构里几个关键点需要说清楚。Properties块是给材质面板用的,声明了可以在Inspector里调整的属性。SubShader里可以有多个Pass,每个Pass是一次完整的渲染流程。CGPROGRAM到ENDCG之间就是真正的Shader代码。

appdata结构体定义的是从模型顶点数据里拿到的信息,比如位置、法线、UV。v2f结构体定义的是从顶点着色器传给片元着色器的信息。这两个结构体的字段命名可以随意,但语义绑定(比如POSITION、TEXCOORD0)必须正确。

2.2 顶点着色器与片元着色器的职责划分

顶点着色器和片元着色器的分工,是Shader编程里最基础但也最容易搞混的事情。

顶点着色器每个顶点执行一次。一个模型有多少顶点,它就执行多少次。它的主要工作是坐标变换,把模型空间的顶点位置转换到裁剪空间。除此之外,还可以在顶点阶段做一些预计算,比如把一些不随像素变化的数据算好,通过插值器传给片元阶段。

片元着色器每个像素执行一次。屏幕上有多少像素被这个物体覆盖,它就执行多少次。它的主要工作是计算最终颜色。光照计算、纹理采样、特效处理,大部分都发生在这个阶段。

这里有一个重要的优化原则:能在顶点阶段算的,就不要放到片元阶段。因为顶点数量通常远小于像素数量。比如一个简单的菲涅尔效果,如果你在片元阶段计算视线方向和法线的点积,每个像素都要算一次。但如果你在顶点阶段算好,通过插值器传过去,片元阶段只需要做一次插值,性能差异在移动端非常明显。

但也不是所有东西都能放到顶点阶段。比如法线贴图的效果,必须在片元阶段采样,因为法线贴图是逐像素的。再比如屏幕空间的UV计算,也必须在片元阶段做。判断标准很简单:这个数据是否随像素变化?如果是,就必须在片元阶段算。

2.3 语义绑定与插值器的那些坑

语义绑定是HLSL里比较绕的一个概念。简单说,语义就是告诉GPU这个变量是干什么用的。比如POSITION表示顶点位置,SV_POSITION表示裁剪空间位置,TEXCOORD0表示第一套UV。

这里有几个常见的坑。第一个是SV_POSITION和POSITION的区别。在顶点着色器的输入结构体里,用POSITION表示模型空间的顶点位置。在顶点着色器的输出结构体里,用SV_POSITION表示裁剪空间的位置。如果你在输出结构体里写了POSITION而不是SV_POSITION,在某些平台上会报错。

第二个是插值器的数量限制。每个插值器占用一个寄存器,移动端GPU通常只有八个左右的插值器可用。如果你在v2f结构体里定义了太多字段,就会导致寄存器溢出,性能急剧下降。我见过一个Shader在v2f里定义了十二个float4,结果在移动端直接跑不起来。

第三个是插值器的精度。默认情况下,插值器是float精度。但如果你确定某个数据不需要那么高的精度,可以显式声明为half或者fixed。比如UV坐标通常用float2就够了,颜色可以用fixed4。这样能省下不少带宽。

3. 贴图混合:从简单的纹理采样到多图叠加

3.1 单张纹理采样的正确姿势

纹理采样看起来简单,但里面有不少细节。最基本的采样就是tex2D(_MainTex, uv)。但这里的uv需要经过TRANSFORM_TEX处理,否则材质的Tiling和Offset设置不会生效。

o.uv = TRANSFORM_TEX(v.uv, _MainTex);

TRANSFORM_TEX这个宏展开后是v.uv * _MainTex_ST.xy + _MainTex_ST.zw。其中_MainTex_ST是Unity自动传入的,xy是Tiling,zw是Offset。如果你不写这一行,材质面板上调Tiling和Offset就不会有任何效果。

另一个细节是纹理的采样模式。Point、Bilinear、Trilinear,这些在纹理导入设置里可以调。但在Shader里,你也可以用tex2Dlod来手动指定mipmap级别。tex2Dlod的第二个参数是float4,xy是UV,w是mipmap级别。这个在需要精确控制纹理清晰度的时候很有用,比如做UI或者做像素风效果。

还有一个容易忽略的点是纹理的Wrap Mode。Repeat、Clamp、Mirror,这些决定了UV超出0到1范围时的采样行为。做平铺纹理的时候用Repeat,做单张图的时候用Clamp。如果设错了,边缘会出现奇怪的拉伸或者重复。

3.2 多图混合的权重计算与叠加顺序

多图混合是Shader里非常常见的需求。比如地形渲染,通常需要混合四到八张纹理。混合的核心是权重计算,而权重计算的核心是让所有权重加起来等于1。

最简单的混合是线性插值。两张图混合,用一个权重w,结果是lerp(tex1, tex2, w)。三张图混合,可以先用w1混合前两张,再用w2混合结果和第三张。但这样做的问题是,当w1和w2都很大的时候,第三张图会被过度压制。

更好的做法是用归一化权重。假设你有三张图,权重分别是w1、w2、w3,那么最终颜色是(tex1w1 + tex2w2 + tex3*w3) / (w1 + w2 + w3)。这样无论权重怎么变,总和始终是1,混合结果不会过曝或者过暗。

在实际项目里,我通常会用一张遮罩图来控制混合权重。遮罩图的R、G、B通道分别对应三张纹理的权重。这样只需要采样一次遮罩图,就能得到三个权重值。但要注意,遮罩图的通道值加起来不一定等于1,所以还需要做一次归一化。

fixed4 mask = tex2D(_MaskTex, i.uv); fixed total = mask.r + mask.g + mask.b; fixed3 blend = (tex1.rgb * mask.r + tex2.rgb * mask.g + tex3.rgb * mask.b) / max(total, 0.001);

这里的max(total, 0.001)是为了防止除以零。虽然理论上total不会为零,但在浮点运算里,极小的值可能导致数值不稳定。

3.3 混合模式的选择:Alpha Blend、Additive与Multiply

混合模式决定了当前Shader的输出如何与已经渲染在屏幕上的颜色结合。这个在SubShader的Pass里通过Blend命令设置。

Alpha Blend是最常用的混合模式,公式是最终颜色 = 源颜色 * 源Alpha + 目标颜色 * (1 - 源Alpha)。适合做半透明物体,比如玻璃、水面、粒子。

Additive混合的公式是最终颜色 = 源颜色 + 目标颜色。适合做发光效果,比如火焰、激光、爆炸。Additive混合不需要考虑Alpha,所以渲染顺序不重要,性能也更好。

Multiply混合的公式是最终颜色 = 源颜色 * 目标颜色。适合做阴影、暗角、颜色过滤。Multiply混合会让画面变暗,所以通常用来做叠加层。

选择混合模式的时候,有一个原则:能不透明就不透明。因为透明物体需要排序,而且不能写入深度缓冲,会导致Overdraw问题。如果一定要透明,优先考虑Alpha Test而不是Alpha Blend。Alpha Test是通过clip函数直接丢弃像素,不需要排序,性能更好。

clip(texColor.a - _Cutoff);

这一行代码的意思是,如果texColor.a小于_Cutoff,就丢弃这个像素。这样就不需要混合了,直接当不透明物体渲染。

4. 描边效果:从法线外扩到屏幕空间边缘检测

4.1 法线外扩描边的原理与实现细节

法线外扩是最经典的描边方法。原理很简单:在顶点着色器里,把顶点沿着法线方向往外推一点,然后用纯色渲染这个外扩后的模型,放在正常模型后面。这样从外面看,就有一圈轮廓线。

v2f vert (appdata v) { v2f o; float3 normal = normalize(v.normal); float3 pos = v.vertex.xyz + normal * _OutlineWidth; o.pos = UnityObjectToClipPos(float4(pos, 1.0)); return o; }

这段代码看起来简单,但有几个坑。第一个是_OutlineWidth的单位问题。如果直接用法线乘以一个固定值,在模型缩放不一致的时候,描边宽度会不均匀。正确的做法是把_OutlineWidth转换到裁剪空间,或者用屏幕空间的距离来计算。

第二个是硬边模型的问题。如果一个模型有硬边,也就是同一个位置有多个法线方向,外扩后会出现裂缝。解决办法是用平滑后的法线,或者在模型导入设置里开启法线平滑。

第三个是背面剔除的问题。描边Pass通常需要关闭背面剔除,否则在模型凹陷的地方看不到描边。在Pass里加一行Cull Off就可以了。

Pass { Cull Off ZWrite On ZTest LEqual // ... }

4.2 屏幕空间边缘检测的适用场景

屏幕空间边缘检测是另一种描边思路。它的原理是,先正常渲染场景,然后用一个后处理Pass,对深度图或者法线图做边缘检测。如果相邻像素的深度或者法线差异超过阈值,就认为这里是边缘,画上描边颜色。

这种方法的优点是描边宽度均匀,不受模型复杂度影响。缺点是依赖深度图和法线图,需要额外的渲染Target,性能开销比法线外扩大。而且对于内部细节多的模型,可能会产生过多的描边。

屏幕空间边缘检测适合的场景是:场景中有大量模型需要统一描边,或者描边宽度需要精确控制。比如卡通渲染的整个场景,用屏幕空间边缘检测会比逐个模型法线外扩更统一。

实现上,通常用Sobel算子或者Roberts算子来做边缘检测。Sobel算子需要采样八个相邻像素,计算量稍大但效果更好。Roberts算子只需要四个像素,速度快但边缘可能不够精细。

fixed4 frag (v2f i) : SV_Target { float depth = tex2D(_CameraDepthTexture, i.uv).r; float depthRight = tex2D(_CameraDepthTexture, i.uv + float2(_TexelSize.x, 0)).r; float depthUp = tex2D(_CameraDepthTexture, i.uv + float2(0, _TexelSize.y)).r; float edge = abs(depth - depthRight) + abs(depth - depthUp); edge = step(_Threshold, edge); return fixed4(edge, edge, edge, 1); }

4.3 两种描边方案的性能对比与选型建议

法线外扩和屏幕空间边缘检测,选哪个取决于具体需求。

法线外扩的性能开销主要在顶点阶段,因为每个顶点都要做一次外扩计算。对于顶点数少的模型,开销可以忽略。但对于顶点数多的模型,比如高模,顶点阶段的压力会比较大。另外,法线外扩需要额外的Pass,也就是模型要渲染两遍,Draw Call翻倍。

屏幕空间边缘检测的性能开销主要在片元阶段,因为每个像素都要采样多次深度图或法线图。对于分辨率高的场景,片元阶段的压力会比较大。但它不需要额外的Pass,Draw Call不增加。

我的选型建议是:如果场景里模型数量少、顶点数少,用法线外扩。如果场景里模型数量多、顶点数多,用屏幕空间边缘检测。如果对描边宽度的一致性要求很高,用屏幕空间边缘检测。如果对性能要求极致,用法线外扩。

还有一个折中方案:用法线外扩做主体描边,用屏幕空间边缘检测做补充。这样既能保证性能,又能保证效果。

5. 后处理:从全屏模糊到自定义颜色分级

5.1 后处理的基本流程与RenderTexture配置

后处理的核心思路是:先把场景渲染到一张RenderTexture上,然后对这张RenderTexture做各种处理,最后再显示到屏幕上。

在Unity里,后处理通常通过OnRenderImage回调来实现。

void OnRenderImage(RenderTexture src, RenderTexture dest) { Graphics.Blit(src, dest, _PostProcessMaterial); }

Graphics.Blit会把src拷贝到dest,同时用_PostProcessMaterial里的Shader进行处理。如果Shader里只有一个Pass,就直接用那个Pass。如果有多个Pass,可以通过Blit的第三个参数指定用哪个Pass。

RenderTexture的配置有几个关键点。第一个是格式,通常用RenderTextureFormat.Default或者RenderTextureFormat.ARGB32。如果需要HDR效果,用RenderTextureFormat.ARGBHalf。第二个是滤波模式,通常用FilterMode.Bilinear。第三个是深度缓冲,如果后处理需要深度信息,要设置depthBuffer为24或者16。

RenderTexture rt = RenderTexture.GetTemporary(src.width, src.height, 24, RenderTextureFormat.Default);

这里用GetTemporary而不是new RenderTexture,是为了利用Unity的RenderTexture池,避免频繁分配和释放导致的内存碎片。

5.2 高斯模糊的分离式实现与性能优化

高斯模糊是后处理里最常用的效果之一。它的原理是用一个高斯核卷积图像。但直接做二维卷积,复杂度是O(n^2)。比如一个5x5的高斯核,每个像素要采样25次。

优化的方法是分离式卷积。因为高斯核是可分离的,二维卷积可以拆成先水平后垂直两次一维卷积。这样复杂度降到O(2n)。5x5的高斯核,每个像素只需要采样5+5=10次。

// 水平Pass fixed4 frag_h (v2f i) : SV_Target { fixed4 col = fixed4(0,0,0,0); col += tex2D(_MainTex, i.uv + float2(-2*_TexelSize.x, 0)) * 0.06136; col += tex2D(_MainTex, i.uv + float2(-1*_TexelSize.x, 0)) * 0.24477; col += tex2D(_MainTex, i.uv) * 0.38774; col += tex2D(_MainTex, i.uv + float2(1*_TexelSize.x, 0)) * 0.24477; col += tex2D(_MainTex, i.uv + float2(2*_TexelSize.x, 0)) * 0.06136; return col; }

这里的权重是标准的高斯核权重,加起来等于1。_TexelSize是纹理的像素大小,通常是1/width和1/height。

性能优化方面,有几个技巧。第一个是降采样。如果模糊半径很大,可以先把图像降采样到一半或者四分之一分辨率,模糊完再升采样回来。这样能大幅减少采样次数。第二个是用线性采样。GPU的纹理采样器支持线性插值,所以你可以把两次采样合并成一次。比如采样位置在-1.5和-0.5,可以合并成在-1.0采样一次,权重相加。第三个是控制迭代次数。高斯模糊通常需要多次迭代才能达到好的效果,但迭代次数越多性能越差。一般两到三次迭代就够了。

5.3 颜色分级LUT的生成与采样

颜色分级是后处理里提升画面质感的重要手段。它的原理是,把原始颜色通过一个查找表映射到新的颜色。这个查找表通常是一张LUT纹理。

LUT的生成方式有几种。一种是用Photoshop或者DaVinci Resolve调好颜色,导出LUT图。另一种是用代码生成,比如用曲线调整、色相偏移等算法。

LUT纹理的布局通常是这样的:一张1024x32的纹理,水平方向是R通道,垂直方向是G通道,每32个像素是一个B通道的切片。采样的时候,先用R和G计算出UV,然后用B计算出切片的索引。

fixed4 frag (v2f i) : SV_Target { fixed4 col = tex2D(_MainTex, i.uv); float3 lutUV; lutUV.x = col.r * (1 - 1.0/32.0) + 1.0/64.0; lutUV.y = col.g * (1 - 1.0/32.0) + 1.0/64.0; lutUV.z = col.b * (32.0 - 1.0); float slice = floor(lutUV.z); float sliceNext = min(slice + 1, 31); float t = lutUV.z - slice; fixed4 col1 = tex2D(_LUTTex, float2(lutUV.x, (slice + lutUV.y) / 32.0)); fixed4 col2 = tex2D(_LUTTex, float2(lutUV.x, (sliceNext + lutUV.y) / 32.0)); return lerp(col1, col2, t); }

这段代码里,lutUV.x和lutUV.y的计算是为了让采样点落在像素中心,避免边缘采样问题。lutUV.z的计算是为了得到B通道对应的切片位置。然后对相邻两个切片做插值,得到平滑的过渡。

LUT的精度取决于纹理大小。32x32x32的LUT通常够用,如果要求更高可以用64x64x64。但纹理越大,内存占用和采样开销也越大。实际项目里,32的LUT配合线性插值,效果已经足够好了。

6. 调试与优化:那些文档里不会写的实战经验

6.1 用Frame Debugger定位渲染问题的完整流程

Frame Debugger是Unity里最实用的渲染调试工具。它可以让你逐Draw Call地查看渲染过程,看到每一步的输入输出。

我遇到过一个典型问题:场景里有一个半透明物体,渲染出来颜色不对,偏暗。用Frame Debugger一看,发现这个物体在渲染的时候,深度测试失败了。原因是它前面的一个不透明物体写入了深度,但半透明物体没有正确处理深度测试。

排查流程是这样的:先打开Frame Debugger,找到渲染这个半透明物体的Draw Call。然后查看它的渲染状态,包括深度测试、混合模式、剔除模式。再查看它的输入纹理和输出结果。对比预期和实际,就能定位问题。

Frame Debugger还能看到每个Draw Call的Shader属性。比如你可以看到_MainTex实际绑定的是哪张纹理,_Color实际是什么值。这个在排查材质属性错误的时候非常有用。

6.2 Shader变体爆炸的成因与裁剪策略

Shader变体爆炸是Unity项目里常见的性能问题。它的成因是,Shader里用了大量的#pragma multi_compile和#pragma shader_feature,导致编译出成百上千个变体。

比如一个Shader里用了四个shader_feature,每个feature有两个状态,那就是2^4=16个变体。如果再加上multi_compile,变体数量会指数级增长。这些变体在打包的时候全部会被编译进去,导致包体增大、加载变慢。

裁剪策略有几个。第一个是尽量用shader_feature而不是multi_compile。shader_feature只编译实际用到的变体,multi_compile会编译所有变体。第二个是合并feature。比如两个feature是互斥的,可以合并成一个。第三个是用#pragma skip_variants手动排除不需要的变体。

#pragma shader_feature _NORMALMAP #pragma shader_feature _EMISSION #pragma multi_compile _ _SHADOWS_ON #pragma skip_variants _SHADOWS_ON

这里skip_variants的意思是,即使_SHADOWS_ON被定义了,也不编译这个变体。这个在调试的时候很有用,可以快速排除某些变体。

6.3 移动端Shader优化的几个硬核技巧

移动端Shader优化和PC端有很大不同。移动端GPU是Tile-Based架构,对带宽和Overdraw特别敏感。

第一个技巧是减少插值器数量。移动端GPU的插值器寄存器很少,通常只有八个。如果你在v2f里定义了超过八个float4,就会导致寄存器溢出。解决办法是合并插值器,比如把两个half2合并成一个half4,或者把一些数据在片元阶段重新计算。

第二个技巧是避免Overdraw。Overdraw是指同一个像素被多次渲染。移动端GPU的填充率有限,Overdraw会直接导致帧率下降。减少Overdraw的方法有:用Alpha Test代替Alpha Blend,用ZWrite On让不透明物体正确写入深度,用Early-Z让深度测试提前。

第三个技巧是控制纹理采样次数。每次纹理采样都会消耗带宽,移动端带宽很宝贵。能合并的采样就合并,能用顶点颜色代替的就用顶点颜色。比如光照贴图,可以用顶点颜色存储低频光照信息,减少一次纹理采样。

第四个技巧是精度控制。移动端GPU对half精度有硬件加速,float精度没有。所以能用half的地方就用half。但要注意,half的精度范围是-65504到65504,超出这个范围会溢出。位置坐标、UV坐标通常用float,颜色、法线可以用half。

struct v2f { float4 pos : SV_POSITION; half2 uv : TEXCOORD0; half3 normal : TEXCOORD1; half3 worldPos : TEXCOORD2; };

这个结构体里,除了pos是float4,其他都是half精度。这样能省下不少寄存器。

6.4 常见编译错误与运行时异常的排查清单

Shader编译错误和运行时异常,排查起来有时候很头疼。我整理了一份常见问题的排查清单。

编译错误方面,最常见的是语义绑定错误。比如在顶点着色器输出结构体里写了POSITION而不是SV_POSITION。另一个常见的是变量未声明,比如用了_Time但没有在CGPROGRAM块里声明。还有是函数签名不匹配,比如tex2D的参数类型不对。

运行时异常方面,最常见的是纹理未绑定。如果Shader里用了_MainTex,但材质面板上没有指定纹理,就会采样到默认的白色纹理。另一个常见的是数值溢出,比如在计算法线的时候没有归一化,导致点积结果超出-1到1的范围。还有是精度问题,在移动端用float精度做大量计算,导致性能下降。

排查的时候,我通常先用Unity的Shader编译器看错误信息。如果错误信息不明确,就把Shader简化到最小可复现的版本,然后逐步添加功能,直到找到出问题的那一行。这个方法虽然笨,但很有效。

还有一个技巧是用#error和#pragma message来输出调试信息。比如在某个条件分支里加一行#error "This branch is taken",编译的时候就会报错,告诉你这个分支被走到了。这个在排查条件编译问题的时候特别有用。

#if defined(_USE_NORMAL_MAP) #pragma message "Normal map is enabled" #endif

#pragma message会在编译的时候输出一条信息,不会中断编译。这个可以用来确认某个宏是否被定义。

7. 从实验到项目:把Shader知识串起来的几个建议

深圳大学图形学实验一那种课程作业,通常要求实现一个基础的光照模型或者纹理映射。很多同学做完就完了,没有把知识点串起来。我的建议是,每做完一个实验,就把它改造成一个可以复用的Shader模块。

比如你做了一个Blinn-Phong光照,那就把它封装成一个cginc文件,以后写其他Shader的时候直接include。你做了一个描边效果,那就把它做成一个独立的Pass,可以挂到任何Shader后面。你做了一个后处理,那就把它做成一个通用的后处理框架,支持多个效果叠加。

这样积累下来,你手里就有一套自己的Shader库。下次做新项目的时候,不用从头写,直接拼装就行。这个习惯我从学生时代保持到现在,受益很大。

另外,多看看Unity内置Shader的源码。Unity的Standard Shader、Terrain Shader、Particle Shader,都是很好的学习材料。看它们怎么组织代码、怎么处理变体、怎么优化性能。这些经验,比看十篇教程都有用。

最后说一个心态问题。Shader编程入门确实有门槛,一开始写出来的东西可能跑不起来,或者效果不对。这很正常。我刚开始写Shader的时候,一个简单的纹理映射调了一整天,最后发现是UV的语义绑定写错了。但正是这些踩坑的经历,让你对渲染管线的理解越来越深。坚持写下去,你会发现Shader其实是一门很讲道理的语言,你给它什么,它就还你什么。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询