跳到主要内容
腾讯题库 · Q10

原题 Q10 · 当前收录 9 / 49

学习状态 未开始

ShaderLab 法线 Shader

Shader ShaderLab · code 对应课程:Shader 与材质

面试官问

PDF 第 22 页

请手写一个 Unity ShaderLab 实现的简单法线贴图 Shader,包含顶点着色器与片元着色器结构。(必问题,直接背熟)

学习进度

0/9 已勾

只保存在这台浏览器;勾满 9 项即视为本题完成。

先把题目说成人话

一块砖墙的网格其实是平的,但灯照过来时,我们仍希望砖缝显得凹、砖面显得凸;模型旋转以后,这些明暗还必须牢牢贴在墙上。现实要解决的是把纹理里保存的细小朝向取出来,换到灯光使用的坐标里,再与光线比较。少了其中任何一步,纹理虽被读到,凹凸却可能翻转或钉在错误方向。

控制屏幕像素怎样着色的程序叫着色程序(Shader),保存细小朝向的纹理叫法线贴图(Normal Map)。每个网格角点先准备位置和局部坐标的阶段叫顶点着色器(Vertex Shader),每个片元读取纹理并算颜色的阶段叫片元着色器(Fragment Shader)。把局部朝向搬到光照空间的三根轴叫局部三轴(TBN)。包住这些程序、用来声明材质属性、子着色器、绘制阶段、标签和渲染状态的外层格式叫着色器声明格式(ShaderLab);它不是另一个执行逐像素计算的程序,也不应按英文名称直译成“实验室语法”。

这是虚构教学故事,不是个人项目经历。

场景:先让问题变得看得见

一块有砖缝法线的平面在主光下旋转。正确结果是凹凸跟着表面走;如果 TBN 少一根轴、切线手性错误或法线没解码,砖缝会翻转、发黑,或像固定在世界里。

整条链只有一支箭:

贴图中的 Tangent Space 法线 → TBN → World Space 法线 → 与 World Space 光方向点乘。

(右脑)画面:拿一盏手电照着一面有砖缝的软橡胶墙。转动整面墙时,砖缝的亮暗应该牢牢贴着墙一起转;再把墙弯起来,凸起朝向改变,亮暗也应自然跟着变化。如果墙已经转走,光影却仍停在旧方向,砖缝就像一张投影在表面滑动,而不再像真正长在墙上的凹凸。

(左脑)对表:本题对象是顶点属性、插值后的 TBN、纹理采样值、解码后的 nTSn_{TS}、世界法线与世界光方向。纹理采样到颜色是观察事实,“它就是可用于光照的 World Space 法线”是假设;证据必须来自中间 debug 输出与物体旋转。UV 无量纲,方向向量无长度单位但必须归一化;RGB 需由 Unity 的 Normal Map 导入/UnpackNormal 解码,镜像 UV 要使用 tangent.w 与变换手性,所有点乘两端必须同空间。流水线类比不等于代码正确。

小明:先形成一个完整猜测

小明在片元阶段直接把纹理的 RGB 当 World Space 法线去点乘光线。贴图能让颜色变化,看起来“起作用了”,但 RGB 仍在 0–1,空间也不一致。

把物体旋转后,砖缝的明暗没有随表面正确旋转。这个反例证明问题不在贴图有没有采样,而在解码和坐标空间没有接上。

小红:把猜测放回现实检查

小红依次检查数据链:

  • 顶点输入必须有 position、normal、tangent 和 uv。
  • tangent.xyz、normal 生成 T 与 N;B 由叉积和 tangent.w 的手性符号得到。
  • 法线贴图按 Unity 的法线类型导入,由 UnpackNormal 解码到约 -1–1。
  • T、B、N 和光方向必须落在同一空间后再点乘。

她先输出解码后的法线颜色,再输出变换后的 World Space 法线,最后接入主光。每一步只验证一段,因此能区分“贴图错”“TBN 错”和“光照错”。

我:用检查结果修正模型

我保留一份可读的最小实现,不假装它已经是生产材质。它只有主方向光的 Lambert 漫反射,没有阴影、附加灯、GI、雾、法线强度、PBR 镜面或 SRP Batcher 配置。

真正项目应优先使用对应管线的 ShaderLibrary 与 Light API,并检查非均匀缩放、插值后归一化、镜像 UV、平台法线通道和阴影变体。烘焙器、网格导入器与运行时 Shader 还必须采用一致的切线基约定,否则同一套 TBN 数学也会还原出不同细节。最小代码的任务是证明空间链路,不是替代 URP/HDRP Lit。

(右脑)画面修正:小明把贴图里的颜色直接拿去和灯光计算,墙一转,砖缝的明暗便留在了错误方向。小红把中间过程逐层显示出来,发现贴图颜色还没有还原成局部法线,也没有借助 TBN 转到灯光所在的共同空间。补回这两步以后,光影终于贴住墙面;这仍只是一条最小的主光路径,不代表阴影、间接光和完整 PBR 已经同时完成。

(左脑)严格对表:最小实现按顺序满足:appdata 提供 POSITION/NORMAL/TANGENT/TEXCOORD;顶点阶段计算裁剪位置、两套 UV、worldPos,并把 T、N 及 v.tangent.w * unity_WorldTransformParams.w 得到的手性传给片元;片元阶段用 UnpackNormal 得到 nTSn_{TS},先归一化插值 NN,再用 T=normalize(T−N dot(T,N))T=normalize(T-N\,dot(T,N)) 把插值切线对 NN 正交化,按该手性重建 B=normalize(cross(N,T)) signB=normalize(cross(N,T))\,sign,组合出 NWSN_{WS},最后与归一化的 LWSL_{WS} 点乘并乘线性 Albedo 与光色。验收用平面旋转、镜像 UV 和非均匀缩放检查砖缝方向,同时确认 Pass 明确限定 Built-in ForwardBase;缺少阴影、GI、附加灯等功能必须保持为已声明边界。

老师解惑

下面代码可表达 Built-in ForwardBase 下的最小结构:

Shader "Interview/MinimalNormalMapped"
{
    Properties
    {
        _MainTex ("Base Color", 2D) = "white" {}
        _NormalMap ("Normal Map", 2D) = "bump" {}
    }

    SubShader
    {
        Tags { "RenderType"="Opaque" "Queue"="Geometry" }

        Pass
        {
            Tags { "LightMode"="ForwardBase" }

            CGPROGRAM
            #pragma vertex vert
            #pragma fragment frag
            #pragma target 3.0

            #include "UnityCG.cginc"
            #include "Lighting.cginc"

            sampler2D _MainTex;
            sampler2D _NormalMap;
            float4 _MainTex_ST;
            float4 _NormalMap_ST;

            struct appdata
            {
                float4 vertex  : POSITION;
                float3 normal  : NORMAL;
                float4 tangent : TANGENT;
                float2 uv      : TEXCOORD0;
            };

            struct v2f
            {
                float4 pos       : SV_POSITION;
                float4 uv        : TEXCOORD0;
                float3 worldPos  : TEXCOORD1;
                float4 tangentWS : TEXCOORD2;
                float3 normalWS  : TEXCOORD3;
            };

            v2f vert(appdata v)
            {
                v2f o;
                o.pos = UnityObjectToClipPos(v.vertex);
                o.uv.xy = TRANSFORM_TEX(v.uv, _MainTex);
                o.uv.zw = TRANSFORM_TEX(v.uv, _NormalMap);
                o.worldPos = mul(unity_ObjectToWorld, v.vertex).xyz;

                o.normalWS = UnityObjectToWorldNormal(v.normal);
                float3 tangentWS = normalize(
                    UnityObjectToWorldDir(v.tangent.xyz));
                float sign = v.tangent.w * unity_WorldTransformParams.w;
                o.tangentWS = float4(tangentWS, sign);
                return o;
            }

            half4 frag(v2f i) : SV_Target
            {
                float3 normalTS = UnpackNormal(
                    tex2D(_NormalMap, i.uv.zw));

                float3 N = normalize(i.normalWS);
                float3 T = normalize(
                    i.tangentWS.xyz - N * dot(i.tangentWS.xyz, N));
                float3 B = normalize(cross(N, T)) * i.tangentWS.w;
                N = normalize(
                    T * normalTS.x +
                    B * normalTS.y +
                    N * normalTS.z);

                float3 L = normalize(UnityWorldSpaceLightDir(i.worldPos));
                float NoL = saturate(dot(N, L));
                half3 albedo = tex2D(_MainTex, i.uv.xy).rgb;
                half3 color = albedo * _LightColor0.rgb * NoL;
                return half4(color, 1.0);
            }
            ENDCG
        }
    }
}
  • ShaderLab 定义 Properties、SubShader、Pass 与渲染标签。
  • 顶点着色器 负责裁剪空间位置、UV、世界位置和 TBN。
  • 片元着色器 解码每像素法线,并完成同空间光照。

工具分工

  • Unity Mesh / Texture Importer:确保网格有 Tangent,纹理类型为 Normal map。
  • Frame Debugger:确认实际走的是 ForwardBase Pass 及预期 Shader。
  • RenderDoc:检查采样值、TBN 和最终法线;用输出中间颜色定位断点。
  • shaderlab-pipeline Lab:把顶点到片元的数据链逐段可视化。

记忆钩子

顶点送 TBN,片元拆法线,同空间再点光;空间没对齐,公式再漂亮也会照歪。

这是教学场景,不是目标项目的真实捕获或个人经历。

下一步:验证这条因果

Normal Map 解码、OS→WS→CS 数据、TBN 手性与 N·L 怎样接成同一条 Unity Shader 链。

教学边界:它只演示最小主光 Lambert,不替代 Unity 编译、阴影、完整 PBR 或目标平台捕获。

进入教学 Lab:「Unity 法线贴图:跟着一支箭头走完 Shader」