Appearance
Shader 手写题
写一个纯色 Unlit Shader
纯色 Unlit Shader
Unlit 的意思是:不参与光照计算。 所以它不会受灯光、阴影、法线、高光影响,片元阶段直接输出一个颜色。
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 去贴图里取颜色。
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 材质面板里的 Tiling 和 Offset 会存在 _MainTex_ST 里面。
它通常长这样:
c
_MainTex_ST.xy = Tiling
_MainTex_ST.zw = OffsetTRANSFORM_TEX(v.uv, _MainTex) 会把缩放和偏移应用到 UV 上。
所以你在材质面板里改 Tiling、Offset,贴图显示就会跟着变。
面试高分回答
TIP
纹理采样 Shader 的核心是顶点数据提供 UV 坐标,顶点着色器把 UV 传递给片元着色器,片元着色器再通过 tex2D 使用这个 UV 从纹理中读取颜色。UV 相当于模型表面到贴图空间的映射关系,_MainTex_ST 则负责处理材质里的 Tiling 和 Offset。
一句话记忆:
纹理采样 = 用 UV 坐标去贴图里查颜色,然后把查到的颜色输出到像素。
写一个半透明 Shader
半透明 Shader
半透明 Shader 的核心是:
把当前物体颜色和背景颜色按 Alpha 混合。
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 把这个颜色传到片元阶段并输出。
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 就是边缘光。 核心公式是:表面法线和视线方向越垂直,边缘光越强。
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 加上一个随时间变化的偏移量。
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,不动模型,只让贴图坐标随时间流动。
写一个溶解效果思路
溶解效果思路
溶解效果的核心是:
用一张噪声图控制每个像素什么时候消失。
底层怎么做
先准备三样东西:
- 主贴图
_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 裁剪 + 边缘发光。
写一个描边效果思路
描边效果思路
描边效果的核心是:
找到物体轮廓,然后给轮廓区域输出一种额外颜色。
最常见思路:模型外扩描边
这个是面试最容易讲清楚的版本。
流程是:
- 第一遍 Pass 画一个“放大版模型”。
- 顶点沿法线方向外扩一点。
- 只渲染背面。
- 输出描边颜色。
- 第二遍 Pass 正常渲染原模型。
- 原模型盖住中间,只留下外圈描边。
关键逻辑:
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 // 剔除正面,只画外扩模型的背面因为外扩模型比原模型大,背面露出来的一圈就形成了描边。
为什么只画背面?
如果外扩模型正反面都画,可能会把原模型整个盖住。
只画背面后,外扩模型的外圈会露出来。 再把正常模型画在上面,中间就被盖住,只剩轮廓边缘。
第二种:屏幕空间描边
屏幕空间描边一般是后处理。
思路是:
- 渲染场景深度图和法线图。
- 后处理全屏 Pass 读取当前像素。
- 再读取上下左右邻近像素。
- 比较深度差或法线差。
- 差异很大,就说明那里可能是边缘。
- 在边缘位置输出描边色。
比如:
当前像素深度 = 0.3
旁边像素深度 = 0.8
差异很大,说明中间可能是物体边缘这个方案适合全屏风格化,比如卡通渲染、选中描边、场景统一描边。
第三种:Stencil 辅助描边
Stencil 描边会用模板缓冲做遮罩。
大概思路:
- 先正常画物体,并把物体区域写入 Stencil。
- 再画外扩模型。
- 描边 Pass 只在 Stencil 没有标记的地方画。
- 这样可以避免描边画到物体内部。
这种方式更适合需要精确控制遮罩的情况。
几种方案怎么选?
如果是单个角色、怪物、道具描边: 优先考虑模型外扩描边,简单直观。
如果是全屏卡通风格: 可以考虑屏幕空间深度法线描边。
如果需要选中高亮,并且不能污染内部区域: 可以结合 Stencil。
常见问题
外扩描边的问题:
- 模型硬边处可能断裂。
- 非均匀缩放时描边宽度可能不一致。
- 复杂模型可能有穿插。
- 很细的结构描边容易变粗。
- 法线方向不好的模型,外扩效果会怪。
屏幕空间描边的问题:
- 受分辨率影响。
- 远处边缘可能不稳定。
- 透明物体通常不好处理。
- 只依赖深度可能描不到同深度内部边。
- 只依法线可能产生很多内部线。
面试高分回答
IMPORTANT
描边效果常见有三种做法。最常见的是模型外扩描边:先用一个 Pass 把模型顶点沿法线方向外扩,只渲染背面并输出描边颜色,再用第二个 Pass 正常渲染原模型,原模型盖住中间后,外扩模型露出的边缘就形成描边。另一种是屏幕空间描边,通过后处理读取深度图或法线图,比较相邻像素差异来检测边缘。更精确的方案可以结合 Stencil,只在物体外部区域绘制描边,避免内部污染。
一句话记忆:
描边 = 找轮廓;外扩描边靠放大背面模型,屏幕描边靠深度和法线差异。
写一个屏幕后处理灰度效果
灰度后处理的核心是: 相机先渲染出彩色画面,然后全屏 Pass 读取每个像素颜色,把 RGB 转成灰度值。
后处理 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 改成同一个灰度值。
写一个简单法线贴图光照思路
简单法线贴图光照思路
法线贴图光照的核心是: 不用模型原来的顶点法线,而是每个像素从法线贴图里取一个新的法线,再用这个法线参与光照计算。
核心流程
- 模型提供
Normal和Tangent。 - Shader 采样法线贴图。
- 用
UnpackNormal把贴图颜色还原成法线方向。 - 用
TBN矩阵把切线空间法线转到世界空间。 - 用新法线和光照方向做点乘。
- 得到每个像素自己的明暗变化。
关键代码思路
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 矩阵,把它转换到世界空间。最后用转换后的法线和光照方向做点乘,计算漫反射强度。这样即使模型表面是平的,也能通过每像素法线产生凹凸明暗细节。
一句话记忆:
法线贴图 = 用贴图里的每像素法线替换原来的插值法线,让光照看起来有凹凸。