Skip to content

Shader 手写题

写一个纯色 Unlit Shader

纯色 Unlit Shader

Unlit 的意思是:不参与光照计算。 所以它不会受灯光、阴影、法线、高光影响,片元阶段直接输出一个颜色。

unity-solid-color-unlit-shader

Built-in 管线版本

c
Shader "Custom/SolidColorUnlit" // 定义 Shader 名字,在 Unity 材质面板里会显示为 Custom/SolidColorUnlit
{ // Shader 开始
    Properties // 定义材质面板可以编辑的属性
    { // Properties 开始
        _Color ("Color", Color) = (1, 1, 1, 1) // 定义一个颜色属性,默认是白色 RGBA
    } // Properties 结束

    SubShader // 定义一个子着色器,Unity 会选择当前平台支持的 SubShader
    { // SubShader 开始
        Tags { "RenderType"="Opaque" "Queue"="Geometry" } // 设置为不透明物体,并放在普通几何队列渲染

        Pass // 定义一次渲染 Pass
        { // Pass 开始
            CGPROGRAM // 开始写 CG/HLSL 代码
            #pragma vertex vert // 指定顶点着色器函数名是 vert
            #pragma fragment frag // 指定片元着色器函数名是 frag
            #include "UnityCG.cginc" // 引入 Unity 常用工具函数,比如 UnityObjectToClipPos

            fixed4 _Color; // 接收 Properties 里定义的 _Color 颜色值

            struct appdata // 定义顶点输入结构
            { // appdata 开始
                float4 vertex : POSITION; // 接收模型顶点位置,语义是 POSITION
            }; // appdata 结束

            struct v2f // 定义顶点着色器传给片元着色器的数据结构
            { // v2f 开始
                float4 pos : SV_POSITION; // 存储裁剪空间坐标,语义是 SV_POSITION
            }; // v2f 结束

            v2f vert(appdata v) // 顶点着色器函数,输入模型顶点数据
            { // vert 函数开始
                v2f o; // 创建输出结构体变量
                o.pos = UnityObjectToClipPos(v.vertex); // 把模型空间顶点转换到裁剪空间
                return o; // 返回给 GPU 光栅化阶段使用
            } // vert 函数结束

            fixed4 frag(v2f i) : SV_Target // 片元着色器函数,输出屏幕像素颜色
            { // frag 函数开始
                return _Color; // 直接返回材质颜色,不计算灯光
            } // frag 函数结束

            ENDCG // 结束 CG/HLSL 代码
        } // Pass 结束
    } // SubShader 结束

    Fallback Off // 不使用备用 Shader
} // Shader 结束

面试解释

NOTE

这个 Shader 分两步:

顶点着色器 vert: 把模型顶点从模型空间转换到裁剪空间,让 GPU 知道这个模型应该画到屏幕哪里。

片元着色器 frag: 直接返回 _Color,不采样贴图,不算光照,不算阴影,不算法线。

一句话记忆:

纯色 Unlit Shader = 顶点负责位置,片元直接返回颜色。

写一个纹理采样 Shader

纹理采样 Shader

纹理采样 Shader 的核心就是:

模型提供 UV,片元着色器用 UV 去贴图里取颜色。

unity-texture-sampling-shader

Built-in 管线版本

c
Shader "Custom/TextureSampleUnlit" // 定义 Shader 名字,材质面板里会显示为 Custom/TextureSampleUnlit
{ // Shader 开始
    Properties // 定义材质面板可编辑的属性
    { // Properties 开始
        _MainTex ("Main Texture", 2D) = "white" {} // 定义一张主纹理,默认使用白色贴图
        _Color ("Tint Color", Color) = (1, 1, 1, 1) // 定义颜色,用来给贴图做整体染色
    } // Properties 结束
    SubShader // 定义 SubShader,Unity 会选择当前平台支持的 SubShader
    { // SubShader 开始
        Tags { "RenderType"="Opaque" "Queue"="Geometry" } // 设置为不透明渲染类型,并放在普通几何队列
        Pass // 定义一次渲染 Pass
        { // Pass 开始
            CGPROGRAM // 开始 CG/HLSL 代码
            #pragma vertex vert // 指定顶点着色器函数是 vert
            #pragma fragment frag // 指定片元着色器函数是 frag
            #include "UnityCG.cginc" // 引入 Unity 内置工具函数和宏
            sampler2D _MainTex; // 声明主纹理采样器,用来读取 _MainTex
            float4 _MainTex_ST; // 声明纹理缩放和偏移,Unity 会自动传入 Tiling 和 Offset
            fixed4 _Color; // 声明颜色属性,用来给采样结果染色
            struct appdata // 定义顶点输入结构
            { // appdata 开始
                float4 vertex : POSITION; // 接收模型空间顶点坐标
                float2 uv : TEXCOORD0; // 接收模型的第一套 UV 坐标
            }; // appdata 结束
            struct v2f // 定义顶点传给片元的数据结构
            { // v2f 开始
                float4 pos : SV_POSITION; // 存储裁剪空间坐标,GPU 用它决定像素位置
                float2 uv : TEXCOORD0; // 存储传给片元着色器的 UV 坐标
            }; // v2f 结束
            v2f vert(appdata v) // 顶点着色器函数,输入模型顶点数据
            { // vert 函数开始
                v2f o; // 创建输出结构体变量
                o.pos = UnityObjectToClipPos(v.vertex); // 把模型空间顶点转换到裁剪空间
                o.uv = TRANSFORM_TEX(v.uv, _MainTex); // 根据材质的 Tiling 和 Offset 变换 UV
                return o; // 返回顶点处理结果给光栅化阶段
            } // vert 函数结束
            fixed4 frag(v2f i) : SV_Target // 片元着色器函数,输出最终像素颜色
            { // frag 函数开始
                fixed4 texColor = tex2D(_MainTex, i.uv); // 使用 UV 从主纹理中采样颜色
                fixed4 finalColor = texColor * _Color; // 把贴图颜色和染色颜色相乘
                return finalColor; // 返回最终颜色到屏幕
            } // frag 函数结束
            ENDCG // 结束 CG/HLSL 代码
        } // Pass 结束
    } // SubShader 结束
    Fallback Off // 不使用备用 Shader
} // Shader 结束

这段 Shader 做了什么?

_MainTex 是材质面板里拖进去的贴图。

uv 是模型自带的纹理坐标。 它告诉 Shader:当前这个像素应该去贴图的哪个位置取颜色。

tex2D(_MainTex, i.uv) 就是采样纹理。

简单说:

贴图颜色 = tex2D(贴图, UV)

为什么需要 _MainTex_ST

Unity 材质面板里的 TilingOffset 会存在 _MainTex_ST 里面。

它通常长这样:

c
_MainTex_ST.xy = Tiling
_MainTex_ST.zw = Offset

TRANSFORM_TEX(v.uv, _MainTex) 会把缩放和偏移应用到 UV 上。

所以你在材质面板里改 Tiling、Offset,贴图显示就会跟着变。

面试高分回答

TIP

纹理采样 Shader 的核心是顶点数据提供 UV 坐标,顶点着色器把 UV 传递给片元着色器,片元着色器再通过 tex2D 使用这个 UV 从纹理中读取颜色。UV 相当于模型表面到贴图空间的映射关系,_MainTex_ST 则负责处理材质里的 Tiling 和 Offset。

一句话记忆:

纹理采样 = 用 UV 坐标去贴图里查颜色,然后把查到的颜色输出到像素。

写一个半透明 Shader

半透明 Shader

半透明 Shader 的核心是:

把当前物体颜色和背景颜色按 Alpha 混合。

unity-transparent-alpha-shader

Built-in 管线版本

c
Shader "Custom/TransparentAlphaUnlit" // 定义 Shader 名字,材质面板里显示为 Custom/TransparentAlphaUnlit
{ // Shader 开始
    Properties // 定义材质面板可编辑属性
    { // Properties 开始
        _MainTex ("Main Texture", 2D) = "white" {} // 定义主纹理,默认是白色贴图
        _Color ("Tint Color", Color) = (1, 1, 1, 0.5) // 定义颜色和透明度,默认 alpha 是 0.5
    } // Properties 结束

    SubShader // 定义 SubShader
    { // SubShader 开始
        Tags { "Queue"="Transparent" "RenderType"="Transparent" } // 放入透明队列,并标记为透明渲染类型
        ZWrite Off // 关闭深度写入,避免透明物体错误挡住后面的透明物体
        Blend SrcAlpha OneMinusSrcAlpha // 使用常见 Alpha 混合公式:源颜色乘 alpha,背景颜色乘 1-alpha

        Pass // 定义一次渲染 Pass
        { // Pass 开始
            CGPROGRAM // 开始 CG/HLSL 代码
            #pragma vertex vert // 指定顶点着色器函数名为 vert
            #pragma fragment frag // 指定片元着色器函数名为 frag
            #include "UnityCG.cginc" // 引入 Unity 常用宏和工具函数

            sampler2D _MainTex; // 声明主纹理采样器
            float4 _MainTex_ST; // 声明主纹理的 Tiling 和 Offset
            fixed4 _Color; // 声明颜色属性,包含透明度 alpha

            struct appdata // 定义顶点输入结构
            { // appdata 开始
                float4 vertex : POSITION; // 接收模型空间顶点位置
                float2 uv : TEXCOORD0; // 接收模型第一套 UV 坐标
            }; // appdata 结束

            struct v2f // 定义顶点传给片元的数据结构
            { // v2f 开始
                float4 pos : SV_POSITION; // 存储裁剪空间位置
                float2 uv : TEXCOORD0; // 存储传给片元着色器的 UV 坐标
            }; // v2f 结束

            v2f vert(appdata v) // 顶点着色器函数
            { // vert 函数开始
                v2f o; // 创建输出结构体
                o.pos = UnityObjectToClipPos(v.vertex); // 把模型空间顶点转换到裁剪空间
                o.uv = TRANSFORM_TEX(v.uv, _MainTex); // 根据材质 Tiling 和 Offset 变换 UV
                return o; // 返回顶点处理结果
            } // vert 函数结束

            fixed4 frag(v2f i) : SV_Target // 片元着色器函数
            { // frag 函数开始
                fixed4 texColor = tex2D(_MainTex, i.uv); // 使用 UV 采样主纹理颜色
                fixed4 finalColor = texColor * _Color; // 纹理颜色乘以材质颜色,得到最终源颜色
                return finalColor; // 返回带 alpha 的颜色,交给 Blend 阶段和背景混合
            } // frag 函数结束

            ENDCG // 结束 CG/HLSL 代码
        } // Pass 结束
    } // SubShader 结束

    Fallback Off // 不使用备用 Shader
} // Shader 结束

关键点解释

Queue = Transparent: 让这个物体在透明队列里渲染。透明物体通常要在不透明物体之后画。

ZWrite Off: 关闭深度写入。因为半透明物体不能完全挡住后面的东西,如果写深度,后面的透明层可能会被错误挡掉。

Blend SrcAlpha OneMinusSrcAlpha: 这是最常见的 Alpha 混合方式。

公式可以理解成:

最终颜色 = 当前物体颜色 * alpha + 背景颜色 * (1 - alpha)

比如 alpha 是 0.3,就表示当前物体贡献 30%,背景贡献 70%。

面试高分回答

IMPORTANT

半透明 Shader 的核心是开启透明渲染队列,关闭深度写入,并使用 Alpha Blend 把源颜色和目标颜色混合。源颜色就是当前 Shader 输出的颜色,目标颜色就是屏幕上已经存在的背景颜色。常见混合方式是 Blend SrcAlpha OneMinusSrcAlpha,也就是当前颜色乘以 alpha,背景颜色乘以 1 - alpha

它的难点不在代码,而在排序和性能:透明物体通常需要从远到近绘制,而且因为不能很好利用深度剔除,容易产生 Overdraw。

一句话记忆:

半透明 Shader = Transparent 队列 + ZWrite Off + Alpha Blend。

写一个顶点颜色 Shader

顶点颜色 Shader

顶点颜色 Shader 的核心是:

Mesh 的每个顶点自带一个颜色,Shader 把这个颜色传到片元阶段并输出。

unity-vertex-color-shader

Built-in 管线版本

c
Shader "Custom/VertexColorUnlit" // 定义 Shader 名字,材质面板里显示为 Custom/VertexColorUnlit
{ // Shader 开始
    Properties // 定义材质面板可编辑属性
    { // Properties 开始
        _Color ("Tint Color", Color) = (1, 1, 1, 1) // 定义整体染色颜色,默认不改变顶点颜色
    } // Properties 结束

    SubShader // 定义 SubShader
    { // SubShader 开始
        Tags { "RenderType"="Opaque" "Queue"="Geometry" } // 设置为不透明物体,并放入普通几何渲染队列

        Pass // 定义一次渲染 Pass
        { // Pass 开始
            CGPROGRAM // 开始 CG/HLSL 代码
            #pragma vertex vert // 指定顶点着色器函数名为 vert
            #pragma fragment frag // 指定片元着色器函数名为 frag
            #include "UnityCG.cginc" // 引入 Unity 常用宏和工具函数

            fixed4 _Color; // 接收材质面板里的整体染色颜色

            struct appdata // 定义顶点输入结构
            { // appdata 开始
                float4 vertex : POSITION; // 接收模型空间顶点位置
                fixed4 color : COLOR; // 接收 Mesh 顶点颜色,语义是 COLOR
            }; // appdata 结束

            struct v2f // 定义顶点传给片元的数据结构
            { // v2f 开始
                float4 pos : SV_POSITION; // 存储裁剪空间位置,GPU 用它决定像素位置
                fixed4 color : COLOR; // 存储传给片元阶段的顶点颜色
            }; // v2f 结束

            v2f vert(appdata v) // 顶点着色器函数,输入 Mesh 顶点数据
            { // vert 函数开始
                v2f o; // 创建输出结构体变量
                o.pos = UnityObjectToClipPos(v.vertex); // 把模型空间顶点转换到裁剪空间
                o.color = v.color; // 把顶点颜色传给片元阶段
                return o; // 返回顶点处理结果
            } // vert 函数结束

            fixed4 frag(v2f i) : SV_Target // 片元着色器函数,输出最终像素颜色
            { // frag 函数开始
                fixed4 finalColor = i.color * _Color; // 用插值后的顶点颜色乘以材质染色颜色
                return finalColor; // 返回最终像素颜色
            } // frag 函数结束

            ENDCG // 结束 CG/HLSL 代码
        } // Pass 结束
    } // SubShader 结束

    Fallback Off // 不使用备用 Shader
} // Shader 结束

底层怎么理解?

顶点颜色不是贴图。 它是存在 Mesh 顶点数据里的颜色通道。

比如一个三角形三个顶点分别是:

A 顶点:红色
B 顶点:绿色
C 顶点:蓝色

光栅化时,GPU 会在三角形内部自动插值。 所以片元着色器拿到的 i.color,不是只有红绿蓝三个值,而是每个像素插值后的颜色。

常见用途

顶点颜色常用于:

  • 调试 Mesh 数据。
  • 低成本给模型染色。
  • 美术顶点绘制。
  • 草地、地形颜色变化。
  • 粒子颜色控制。
  • 特效遮罩。
  • 溶解权重。
  • 区域混合权重。
  • 不想额外加贴图时存简单数据。

注意点

如果模型没有顶点颜色,Shader 可能显示默认白色,或者看起来没有效果。

Unity 里可以通过代码写:

mesh.colors = colors;

也可以在建模软件、DCC 工具或编辑器工具里刷顶点色。

面试高分回答

IMPORTANT

顶点颜色 Shader 是通过 COLOR 语义读取 Mesh 顶点数据中的颜色通道。顶点着色器把顶点颜色传给片元阶段,光栅化阶段会自动对三角形内部的颜色进行插值,所以片元着色器拿到的是当前像素对应的插值颜色。最后直接输出这个颜色,或者乘一个材质上的 Tint Color 做整体调整。

一句话记忆:

顶点颜色 = Mesh 顶点自带颜色,通过 COLOR 语义输入,经过插值后在片元阶段输出。

写一个简单 Rim Light

简单 Rim Light Shader

Rim Light 就是边缘光。 核心公式是:表面法线和视线方向越垂直,边缘光越强。

unity-rim-light-shader

Built-in 管线版本

c
Shader "Custom/SimpleRimLight" // 定义 Shader 名字,Unity 材质面板里会显示为 Custom/SimpleRimLight
{ // Shader 开始
    Properties // 定义材质面板可编辑属性
    { // Properties 开始
        _MainTex ("Main Texture", 2D) = "white" {} // 定义主纹理,默认是白色贴图
        _Color ("Base Color", Color) = (1, 1, 1, 1) // 定义基础颜色,用来给贴图整体染色
        _RimColor ("Rim Color", Color) = (0, 1, 1, 1) // 定义边缘光颜色,默认是青色
        _RimPower ("Rim Power", Range(0.5, 8)) = 3 // 定义边缘光收窄程度,值越大边缘越细
        _RimIntensity ("Rim Intensity", Range(0, 5)) = 1 // 定义边缘光强度,值越大边缘越亮
    } // Properties 结束
    SubShader // 定义 SubShader
    { // SubShader 开始
        Tags { "RenderType"="Opaque" "Queue"="Geometry" } // 设置为不透明物体,并放在普通几何队列
        Pass // 定义一次渲染 Pass
        { // Pass 开始
            CGPROGRAM // 开始 CG/HLSL 代码
            #pragma vertex vert // 指定顶点着色器函数名为 vert
            #pragma fragment frag // 指定片元着色器函数名为 frag
            #include "UnityCG.cginc" // 引入 Unity 常用宏和函数
            sampler2D _MainTex; // 声明主纹理采样器
            float4 _MainTex_ST; // 声明主纹理的缩放和偏移数据
            fixed4 _Color; // 声明基础颜色变量
            fixed4 _RimColor; // 声明边缘光颜色变量
            float _RimPower; // 声明边缘光幂次变量
            float _RimIntensity; // 声明边缘光强度变量
            struct appdata // 定义顶点输入结构
            { // appdata 开始
                float4 vertex : POSITION; // 接收模型空间顶点位置
                float3 normal : NORMAL; // 接收模型空间法线方向
                float2 uv : TEXCOORD0; // 接收模型第一套 UV 坐标
            }; // appdata 结束
            struct v2f // 定义顶点传给片元的数据结构
            { // v2f 开始
                float4 pos : SV_POSITION; // 存储裁剪空间位置
                float2 uv : TEXCOORD0; // 存储传给片元的 UV 坐标
                float3 worldNormal : TEXCOORD1; // 存储世界空间法线
                float3 worldPos : TEXCOORD2; // 存储世界空间位置
            }; // v2f 结束
            v2f vert(appdata v) // 顶点着色器函数
            { // vert 函数开始
                v2f o; // 创建输出结构体变量
                o.pos = UnityObjectToClipPos(v.vertex); // 把模型空间顶点转换到裁剪空间
                o.uv = TRANSFORM_TEX(v.uv, _MainTex); // 根据 Tiling 和 Offset 变换 UV
                o.worldNormal = UnityObjectToWorldNormal(v.normal); // 把模型空间法线转换到世界空间
                o.worldPos = mul(unity_ObjectToWorld, v.vertex).xyz; // 把模型空间位置转换到世界空间
                return o; // 返回顶点处理结果
            } // vert 函数结束
            fixed4 frag(v2f i) : SV_Target // 片元着色器函数
            { // frag 函数开始
                fixed4 baseColor = tex2D(_MainTex, i.uv) * _Color; // 采样贴图并乘以基础颜色
                float3 normalDir = normalize(i.worldNormal); // 归一化世界空间法线
                float3 viewDir = normalize(_WorldSpaceCameraPos.xyz - i.worldPos); // 计算从当前表面点指向摄像机的方向
                float ndotv = saturate(dot(normalDir, viewDir)); // 计算法线和视线方向点乘,并限制到 0 到 1
                float rim = pow(1.0 - ndotv, _RimPower); // 用 1 减点乘得到边缘强度,并用幂次控制宽度
                fixed4 rimColor = _RimColor * rim * _RimIntensity; // 计算最终边缘光颜色
                fixed4 finalColor = baseColor + rimColor; // 把基础颜色和边缘光颜色相加
                finalColor.a = baseColor.a; // 保持透明度使用基础颜色的 alpha
                return finalColor; // 返回最终像素颜色
            } // frag 函数结束
            ENDCG // 结束 CG/HLSL 代码
        } // Pass 结束
    } // SubShader 结束
    Fallback Off // 不使用备用 Shader
} // Shader 结束

面试解释

Rim Light 的核心是 dot(N, V)

  • N 是表面法线方向。
  • V 是从表面点指向摄像机的方向。
  • 正面区域:dot(N, V) 接近 1,边缘光弱。
  • 轮廓区域:dot(N, V) 接近 0,边缘光强。

所以用:

c
rim = pow(1 - saturate(dot(N, V)), RimPower)

一句话记忆:

Rim Light = 用 1 减去法线和视线方向的点乘,让模型轮廓边缘发亮。

写一个 UV 滚动 Shader

UV 滚动 Shader

UV 滚动的核心是:

模型不动,只是采样贴图之前,把 UV 加上一个随时间变化的偏移量。

unity-uv-scrolling-shader

Built-in 管线版本

c
Shader "Custom/UVScrollingUnlit" // 定义 Shader 名字,材质面板里显示为 Custom/UVScrollingUnlit
{ // Shader 开始
    Properties // 定义材质面板可编辑属性
    { // Properties 开始
        _MainTex ("Main Texture", 2D) = "white" {} // 定义主纹理,默认是白色贴图
        _Color ("Tint Color", Color) = (1, 1, 1, 1) // 定义整体染色颜色,默认不改变贴图颜色
        _ScrollSpeed ("Scroll Speed XY", Vector) = (0.2, 0, 0, 0) // 定义 UV 滚动速度,x 控制 U 方向,y 控制 V 方向
    } // Properties 结束

    SubShader // 定义 SubShader
    { // SubShader 开始
        Tags { "RenderType"="Opaque" "Queue"="Geometry" } // 设置为不透明物体,并放在普通几何队列
        Pass // 定义一次渲染 Pass
        { // Pass 开始
            CGPROGRAM // 开始 CG/HLSL 代码
            #pragma vertex vert // 指定顶点着色器函数名为 vert
            #pragma fragment frag // 指定片元着色器函数名为 frag
            #include "UnityCG.cginc" // 引入 Unity 常用宏和函数

            sampler2D _MainTex; // 声明主纹理采样器
            float4 _MainTex_ST; // 声明主纹理的 Tiling 和 Offset 数据
            fixed4 _Color; // 声明整体染色颜色
            float4 _ScrollSpeed; // 声明 UV 滚动速度变量

            struct appdata // 定义顶点输入结构
            { // appdata 开始
                float4 vertex : POSITION; // 接收模型空间顶点位置
                float2 uv : TEXCOORD0; // 接收模型第一套 UV 坐标
            }; // appdata 结束

            struct v2f // 定义顶点传给片元的数据结构
            { // v2f 开始
                float4 pos : SV_POSITION; // 存储裁剪空间位置
                float2 uv : TEXCOORD0; // 存储传给片元的 UV 坐标
            }; // v2f 结束

            v2f vert(appdata v) // 顶点着色器函数
            { // vert 函数开始
                v2f o; // 创建输出结构体变量
                o.pos = UnityObjectToClipPos(v.vertex); // 把模型空间顶点转换到裁剪空间
                o.uv = TRANSFORM_TEX(v.uv, _MainTex); // 应用材质面板里的 Tiling 和 Offset
                return o; // 返回顶点处理结果
            } // vert 函数结束

            fixed4 frag(v2f i) : SV_Target // 片元着色器函数
            { // frag 函数开始
                float2 scrolledUV = i.uv + _ScrollSpeed.xy * _Time.y; // 用速度乘时间得到偏移量,再加到原始 UV 上
                fixed4 texColor = tex2D(_MainTex, scrolledUV); // 使用滚动后的 UV 采样主纹理
                fixed4 finalColor = texColor * _Color; // 用材质颜色给贴图做整体染色
                return finalColor; // 返回最终像素颜色
            } // frag 函数结束

            ENDCG // 结束 CG/HLSL 代码
        } // Pass 结束
    } // SubShader 结束

    Fallback Off // 不使用备用 Shader
} // Shader 结束

底层怎么理解?

普通纹理采样是:

tex2D(_MainTex, uv)

UV 滚动是:

tex2D(_MainTex, uv + speed * time)

所以它不是移动模型,也不是移动顶点,而是让采样点在贴图上不断移动。

重要注意点

贴图的 Wrap Mode 最好设成 Repeat。 因为 UV 滚动后可能超过 0 到 1

比如:

0.95 + 0.10 = 1.05

如果是 Repeat,它会继续循环采样,看起来像无缝流动。 如果是 Clamp,它会卡在贴图边缘,效果就不自然。

常见用途

UV 滚动常用于:

  • 水流
  • 岩浆
  • 云层
  • 能量护盾
  • 传送门
  • UI 扫光
  • 流光特效
  • 跑马灯材质
  • 地面流动纹理

面试高分回答

NOTE

UV 滚动 Shader 的本质是在片元采样纹理之前,对 UV 坐标加上一个随时间变化的偏移量。这个偏移量通常由 _ScrollSpeed.xy * _Time.y 得到。这样模型本身没有移动,但每一帧采样贴图的位置都在变化,所以视觉上贴图会产生流动效果。为了让 UV 超出 0 到 1 后仍然循环,需要把贴图 Wrap Mode 设置为 Repeat。

一句话记忆:

UV 滚动 = 采样前改 UV,不动模型,只让贴图坐标随时间流动。

写一个溶解效果思路

溶解效果思路

溶解效果的核心是:

用一张噪声图控制每个像素什么时候消失。

unity-dissolve-effect-idea

底层怎么做

先准备三样东西:

  • 主贴图 _MainTex:决定模型原本颜色。
  • 噪声图 _DissolveTex:决定溶解形状。
  • 溶解进度 _DissolveAmount:从 0 变到 1

每个像素都会采样一次噪声图,得到一个 0 到 1 的值。

然后比较:

如果 noise 小于 _DissolveAmount,就丢弃这个像素
如果 noise 大于 _DissolveAmount,就保留这个像素

随着 _DissolveAmount 变大,被丢弃的像素越来越多,模型就像慢慢消失。

关键伪代码

c
fixed4 baseColor = tex2D(_MainTex, i.uv); // 采样主贴图,得到模型原本颜色
float noise = tex2D(_DissolveTex, i.uv).r; // 采样噪声图的红色通道,得到当前像素的噪声值
clip(noise - _DissolveAmount); // 如果噪声值小于溶解进度,就丢弃当前像素
float edge = step(noise, _DissolveAmount + _EdgeWidth); // 判断当前像素是否处在溶解边缘范围内
fixed4 finalColor = baseColor + _EdgeColor * edge; // 在溶解边缘叠加边缘颜色
return finalColor; // 返回最终颜色

为什么需要噪声图?

如果不用噪声图,只按高度或固定方向裁剪,溶解边界会很直、很假。

噪声图的作用是制造不规则边界,比如:

  • 燃烧感
  • 腐蚀感
  • 破碎感
  • 魔法消散感
  • 能量消失感

噪声值不同,每个像素消失的时间就不同,边缘就会自然破碎。

边缘发光怎么做?

溶解边缘其实就是:

噪声值刚好接近当前溶解阈值的区域。

比如:

_DissolveAmount = 0.5
_EdgeWidth = 0.05

那么噪声值在 0.5 到 0.55 附近的像素,就可以认为是边缘。

然后给这部分像素加上 _EdgeColor,就会出现燃烧边、能量边、发光边。

面试高分回答

CAUTION

溶解效果通常使用一张噪声贴图作为遮罩。Shader 在片元阶段采样噪声值,并把它和一个溶解进度参数进行比较。噪声值小于阈值的像素通过 clip 丢弃,噪声值大于阈值的像素保留。随着阈值从 0 增加到 1,越来越多像素被裁掉,模型就产生逐渐消失的效果。为了让溶解边缘更有表现力,可以检测噪声值接近阈值的区域,并叠加一圈边缘颜色或发光颜色。

一句话记忆:

溶解 = 噪声图 + 阈值比较 + clip 裁剪 + 边缘发光。

写一个描边效果思路

描边效果思路

描边效果的核心是:

找到物体轮廓,然后给轮廓区域输出一种额外颜色。

unity-outline-effect-idea

最常见思路:模型外扩描边

这个是面试最容易讲清楚的版本。

流程是:

  1. 第一遍 Pass 画一个“放大版模型”。
  2. 顶点沿法线方向外扩一点。
  3. 只渲染背面。
  4. 输出描边颜色。
  5. 第二遍 Pass 正常渲染原模型。
  6. 原模型盖住中间,只留下外圈描边。

关键逻辑:

c
float3 worldNormal = UnityObjectToWorldNormal(v.normal); // 把模型空间法线转换到世界空间
float3 worldPos = mul(unity_ObjectToWorld, v.vertex).xyz; // 把模型空间顶点转换到世界空间
worldPos += normalize(worldNormal) * _OutlineWidth; // 沿法线方向把顶点往外推出一段距离
o.pos = mul(UNITY_MATRIX_VP, float4(worldPos, 1)); // 把外扩后的世界坐标转换到裁剪空间
return o; // 返回外扩后的顶点结果

描边 Pass 通常会用:

c
Cull Front // 剔除正面,只画外扩模型的背面

因为外扩模型比原模型大,背面露出来的一圈就形成了描边。

为什么只画背面?

如果外扩模型正反面都画,可能会把原模型整个盖住。

只画背面后,外扩模型的外圈会露出来。 再把正常模型画在上面,中间就被盖住,只剩轮廓边缘。

第二种:屏幕空间描边

屏幕空间描边一般是后处理。

思路是:

  1. 渲染场景深度图和法线图。
  2. 后处理全屏 Pass 读取当前像素。
  3. 再读取上下左右邻近像素。
  4. 比较深度差或法线差。
  5. 差异很大,就说明那里可能是边缘。
  6. 在边缘位置输出描边色。

比如:

当前像素深度 = 0.3
旁边像素深度 = 0.8
差异很大,说明中间可能是物体边缘

这个方案适合全屏风格化,比如卡通渲染、选中描边、场景统一描边。

第三种:Stencil 辅助描边

Stencil 描边会用模板缓冲做遮罩。

大概思路:

  1. 先正常画物体,并把物体区域写入 Stencil。
  2. 再画外扩模型。
  3. 描边 Pass 只在 Stencil 没有标记的地方画。
  4. 这样可以避免描边画到物体内部。

这种方式更适合需要精确控制遮罩的情况。

几种方案怎么选?

如果是单个角色、怪物、道具描边: 优先考虑模型外扩描边,简单直观。

如果是全屏卡通风格: 可以考虑屏幕空间深度法线描边。

如果需要选中高亮,并且不能污染内部区域: 可以结合 Stencil。

常见问题

外扩描边的问题:

  • 模型硬边处可能断裂。
  • 非均匀缩放时描边宽度可能不一致。
  • 复杂模型可能有穿插。
  • 很细的结构描边容易变粗。
  • 法线方向不好的模型,外扩效果会怪。

屏幕空间描边的问题:

  • 受分辨率影响。
  • 远处边缘可能不稳定。
  • 透明物体通常不好处理。
  • 只依赖深度可能描不到同深度内部边。
  • 只依法线可能产生很多内部线。

面试高分回答

IMPORTANT

描边效果常见有三种做法。最常见的是模型外扩描边:先用一个 Pass 把模型顶点沿法线方向外扩,只渲染背面并输出描边颜色,再用第二个 Pass 正常渲染原模型,原模型盖住中间后,外扩模型露出的边缘就形成描边。另一种是屏幕空间描边,通过后处理读取深度图或法线图,比较相邻像素差异来检测边缘。更精确的方案可以结合 Stencil,只在物体外部区域绘制描边,避免内部污染。

一句话记忆:

描边 = 找轮廓;外扩描边靠放大背面模型,屏幕描边靠深度和法线差异。

写一个屏幕后处理灰度效果

灰度后处理的核心是: 相机先渲染出彩色画面,然后全屏 Pass 读取每个像素颜色,把 RGB 转成灰度值。

unity-screen-grayscale-postprocess

后处理 Shader

c
Shader "Custom/PostProcess/Grayscale" // 定义 Shader 名字,材质里会显示为 Custom/PostProcess/Grayscale
{ // Shader 开始
    Properties // 定义材质面板可编辑属性
    { // Properties 开始
        _MainTex ("Source Texture", 2D) = "white" {} // 定义源图像纹理,Graphics.Blit 会自动传入屏幕图
        _GrayAmount ("Gray Amount", Range(0, 1)) = 1 // 定义灰度强度,0 是原图,1 是全灰度
    } // Properties 结束
    SubShader // 定义 SubShader
    { // SubShader 开始
        Tags { "RenderType"="Opaque" "Queue"="Overlay" } // 标记渲染类型和队列,后处理通常在屏幕阶段执行
        Cull Off // 关闭剔除,因为全屏后处理不需要区分正反面
        ZWrite Off // 关闭深度写入,避免影响场景深度
        ZTest Always // 深度测试永远通过,确保整张屏幕都被处理
        Pass // 定义一次全屏后处理 Pass
        { // Pass 开始
            CGPROGRAM // 开始 CG/HLSL 代码
            #pragma vertex vert // 指定顶点着色器函数名为 vert
            #pragma fragment frag // 指定片元着色器函数名为 frag
            #include "UnityCG.cginc" // 引入 Unity 常用宏和函数
            sampler2D _MainTex; // 声明源图像纹理采样器
            float _GrayAmount; // 声明灰度强度变量
            struct appdata // 定义顶点输入结构
            { // appdata 开始
                float4 vertex : POSITION; // 接收全屏面片的顶点位置
                float2 uv : TEXCOORD0; // 接收全屏面片的 UV 坐标
            }; // appdata 结束
            struct v2f // 定义顶点传给片元的数据结构
            { // v2f 开始
                float4 pos : SV_POSITION; // 存储裁剪空间位置
                float2 uv : TEXCOORD0; // 存储传给片元的 UV 坐标
            }; // v2f 结束
            v2f vert(appdata v) // 顶点着色器函数
            { // vert 函数开始
                v2f o; // 创建输出结构体变量
                o.pos = UnityObjectToClipPos(v.vertex); // 把顶点位置转换到裁剪空间
                o.uv = v.uv; // 把 UV 原样传给片元阶段
                return o; // 返回顶点处理结果
            } // vert 函数结束
            fixed4 frag(v2f i) : SV_Target // 片元着色器函数
            { // frag 函数开始
                fixed4 col = tex2D(_MainTex, i.uv); // 根据 UV 采样屏幕原始颜色
                float gray = dot(col.rgb, float3(0.299, 0.587, 0.114)); // 使用人眼亮度权重把 RGB 转成灰度值
                col.rgb = lerp(col.rgb, float3(gray, gray, gray), _GrayAmount); // 根据灰度强度在原图和灰度图之间插值
                return col; // 返回最终像素颜色
            } // frag 函数结束
            ENDCG // 结束 CG/HLSL 代码
        } // Pass 结束
    } // SubShader 结束
    Fallback Off // 不使用备用 Shader
} // Shader 结束

相机脚本

c
using UnityEngine; // 引入 UnityEngine 命名空间,使用 MonoBehaviour、Material、RenderTexture 等类型
[ExecuteInEditMode] // 允许脚本在编辑器非运行状态下也尝试执行效果
public class GrayscalePostProcess : MonoBehaviour // 定义灰度后处理脚本,挂到 Camera 上
{ // 类开始
    [Range(0f, 1f)] // 在 Inspector 中把灰度强度限制在 0 到 1
    public float grayAmount = 1f; // 灰度强度,0 表示原图,1 表示完全灰度
    public Material grayscaleMaterial; // 灰度后处理材质,需要使用上面的 Grayscale Shader
    private void OnRenderImage(RenderTexture source, RenderTexture destination) // Built-in 管线相机渲染完成后的后处理入口
    { // OnRenderImage 函数开始
        if (grayscaleMaterial == null) // 如果没有设置材质,就不执行灰度处理
        { // if 开始
            Graphics.Blit(source, destination); // 直接把原图拷贝到目标,避免黑屏
            return; // 提前返回,结束本次后处理
        } // if 结束
        grayscaleMaterial.SetFloat("_GrayAmount", grayAmount); // 把脚本里的灰度强度传给 Shader
        Graphics.Blit(source, destination, grayscaleMaterial); // 使用灰度材质执行一次全屏 Blit
    } // OnRenderImage 函数结束
} // 类结束

怎么用

把 Shader 创建成材质,然后把 GrayscalePostProcess 挂到相机上,把材质拖到 grayscaleMaterial 字段里。

grayAmount = 0:原始彩色画面。 grayAmount = 1:完全灰度画面。

面试记忆句

TIP

屏幕后处理灰度效果就是: 相机渲染完后,用全屏 Pass 采样屏幕颜色,再用 0.299R + 0.587G + 0.114B 算亮度,把 RGB 改成同一个灰度值。

写一个简单法线贴图光照思路

简单法线贴图光照思路

法线贴图光照的核心是: 不用模型原来的顶点法线,而是每个像素从法线贴图里取一个新的法线,再用这个法线参与光照计算。

unity-normal-map-lighting-idea

核心流程

  1. 模型提供 NormalTangent
  2. Shader 采样法线贴图。
  3. UnpackNormal 把贴图颜色还原成法线方向。
  4. TBN 矩阵把切线空间法线转到世界空间。
  5. 用新法线和光照方向做点乘。
  6. 得到每个像素自己的明暗变化。

关键代码思路

c
fixed4 albedo = tex2D(_MainTex, i.uv) * _Color; // 采样主贴图并乘以基础颜色,得到物体原本颜色
float3 tangentNormal = UnpackNormal(tex2D(_NormalMap, i.uv)); // 采样法线贴图,并把 RGB 解包成切线空间法线
float3 worldTangent = normalize(i.worldTangent); // 归一化世界空间切线方向
float3 worldNormal = normalize(i.worldNormal); // 归一化世界空间顶点法线方向
float3 worldBitangent = normalize(cross(worldNormal, worldTangent) * i.tangentSign); // 用法线和切线叉乘得到副切线方向
float3x3 tbn = float3x3(worldTangent, worldBitangent, worldNormal); // 用切线、副切线、法线组成 TBN 矩阵
float3 finalNormal = normalize(mul(tangentNormal, tbn)); // 把切线空间法线转换到世界空间
float3 lightDir = normalize(_WorldSpaceLightPos0.xyz); // 获取主方向光方向
float diffuse = saturate(dot(finalNormal, lightDir)); // 用新法线和光照方向点乘,得到漫反射强度
fixed3 finalRgb = albedo.rgb * _LightColor0.rgb * diffuse; // 用基础颜色乘以光颜色和漫反射强度
return fixed4(finalRgb, albedo.a); // 返回最终像素颜色

为什么需要 TBN?

法线贴图里的法线通常是切线空间法线。 但光照方向通常在世界空间

所以不能直接拿法线贴图里的法线和光方向点乘,空间不一致会错。

TBN 的作用就是把法线贴图里的方向从切线空间转到世界空间。

T = Tangent
B = Bitangent
N = Normal

这三个方向组成一个坐标系,也就是 TBN 矩阵。

面试高分回答

NOTE

法线贴图并不会改变模型真实几何,只是改变每个像素参与光照计算的法线方向。Shader 会先采样法线贴图,把 RGB 解包成切线空间法线,再通过模型的 Tangent、Bitangent、Normal 组成 TBN 矩阵,把它转换到世界空间。最后用转换后的法线和光照方向做点乘,计算漫反射强度。这样即使模型表面是平的,也能通过每像素法线产生凹凸明暗细节。

一句话记忆:

法线贴图 = 用贴图里的每像素法线替换原来的插值法线,让光照看起来有凹凸。

文章评价

读完这篇,留下你的看法

暂无审核通过的评价。

登录账号后才能评价。

本站访客数0总站访问量0本页访问量0