Shader 编程的本质不是写代码,而是数学建模。 我们要做的就是把光照、纹理、时间等输入,通过数学公式,映射为屏幕上的每一个像素颜色。
本文档总结了游戏开发(特别是 Roguelike/塔防类)中最常用的 Shader 效果及其背后的理论公式。
在 Shader Graph 或 HLSL 中,以下 5 个函数构成了 90% 的特效基础。
saturate(x) —— 安全钳制lerp(a, b, t) —— 线性插值lerp(BaseColor, White, FlashStrength)).step(edge, x) & smoothstep(min, max, x) —— 边缘与过渡frac(x) —— 周期循环frac(_Time.y * speed)).pow(x, p) —— 强度控制 (Gamma 曲线)视觉: 物体边缘发光(幽灵、能量盾、选中高亮)。 理论: 当视线方向 ($V$) 与表面法线 ($N$) 垂直时,反射最强。
// HLSL 片段
float3 normal = normalize(i.normal);
float3 viewDir = normalize(_WorldSpaceCameraPos - i.worldPos);
float rim = 1.0 - saturate(dot(normal, viewDir));
rim = pow(rim, _RimPower);
return _RimColor * rim;
视觉: 物体像纸烧焦一样消失,边缘有亮光。 理论: 使用一张噪点图 (Noise) 作为高度图,切掉 (Clip) 低于阈值的像素。
视觉: 滚动的岩浆、流动的水面、被热浪扭曲的背景。 理论: 在采样纹理之前,修改 UV 坐标。
视觉: 怪物受击时瞬间变全白。 理论: 简单的颜色插值,不涉及光照。
FlashStrength 通常由 C# 脚本控制协程或 AnimationCurve 来驱动 (0 -> 1 -> 0)。视觉: 技能冷却时图标变黑白。 理论: 人眼对绿色的敏感度最高,对蓝色最低。不能简单平均 RGB。
除了改变颜色(像素着色器),我们还可以改变形状(顶点着色器)。 性能优势: 计算量取决于顶点数,远少于像素数。适合大面积草地、水面波浪。
理论: 将顶点的 Y 轴高度作为输入,用正弦波偏移 X 或 Z 轴。
Vertex.y 作为相位偏移,确保旗帜不同高度的摆动不同步。理论: 沿法线方向移动顶点。
理解这些决定了你的 Shader 能做什么,不能做什么。
| 模式 | 描述 | 深度写入 (ZWrite) | 渲染顺序 | 适用场景 |
|---|---|---|---|---|
| Opaque (不透明) | 最快。从前向后渲染 (利用 Early-Z 剔除)。 | On | 2000 | 角色、墙壁、地面 |
| Cutout (镂空) | 要么全透要么不透。硬边缘。 | On | 2450 | 草丛、铁丝网、溶解效果 |
| Transparent (半透明) | 最慢。从后向前渲染 (画家算法)。不能写深度。 | Off | 3000 | 玻璃、特效粒子、UI |
⚠️ 透明度排序问题: 半透明物体如果不写入深度,经常会出现“在这个角度看是对的,转个角度就穿帮了”的问题。这是计算机图形学的经典难题。解决方法通常是:少用半透明,或者接受瑕疵。
if-else 吗?
step 或 lerp 代替 if。
if (x > 0.5) col = white; else col = black;col = lerp(black, white, step(0.5, x));float (32bit) 比 half (16bit) 慢且费电。float。half 足够。在 2D 或 2.5D 游戏中,Sprite 的处理逻辑与 3D 物体不同。我们通常处理的是 MainTex (Sprite 图集) 和 Color (顶点色)。
视觉: 角色边缘有一圈亮色轮廓(选中效果)。 理论: 检测当前像素周围是否是透明像素。如果我是透明的,但我旁边有不透明的像素,那我就是“外轮廓”。
视觉: 2D 角色脚下有一个倾斜的黑色影子。 理论: 利用顶点着色器,将顶点的 Y 轴映射到 X 轴偏移上。
后处理是在渲染完所有物体后,对整个屏幕图像 (_MainTex) 进行二次处理。
视觉: 屏幕四角变暗,模拟相机镜头或压抑氛围(低血量)。 理论: 计算 UV 坐标距离中心 (0.5, 0.5) 的距离。
视觉: 画面变模糊成大方块(眩晕、复古滤镜)。 理论: 降低 UV 的精度。将连续的 UV 坐标“量化”为台阶状。
Resolution 是 100,那么 0.015 会变成 $floor(1.5)/100 = 0.01$。0.010 到 0.019 之间的所有 UV 都会变成同一个值,采到同一个颜色。视觉: RGB 三色分离,像旧电视或赛博朋克干扰。 理论: 采样三次纹理,但每次给 R、G、B 通道不同的 UV 偏移。
Offset 可以是一个随时间快速变化的随机数 (frac(sin(time)*large_number)).| 想要做… | 用这个函数… |
|---|---|
| 循环/条纹 | frac(x * scale) |
| 硬边缘 | step(edge, x) |
| 软边缘 | smoothstep(min, max, x) |
| 闪烁/脉冲 | sin(_Time.y * speed) |
| 混合/过渡 | lerp(a, b, t) |
| 变亮/变暗 (非线性) | pow(x, power) |
| 距离/圆 | distance(uv, center) 或 length(vec) |
| 旋转 UV | 乘旋转矩阵 [cos -sin; sin cos] |
有时候我们不想用额外的纹理贴图(为了省内存),而是想直接在代码里生成“随机感”。
Shader 里没有 Random.Range。我们利用高频正弦波的 frac 部分来模拟随机。
如果把随机点平滑连接起来,就得到了“云”一样的效果。
在 Loot 类游戏中,如何表现“传说装备”或“稀有卡牌”?全靠 Shader。
视觉: 一道亮光快速划过卡牌表面。 理论: 在 UV 空间定义一条倾斜的线,计算当前像素距离这条线的距离。
smoothstep 或 pow 提取中间亮的两边暗的区域。// 简易流光公式
// 1. 倾斜 UV 坐标 (x + y * tan(angle))
float sheenPos = i.uv.x + i.uv.y * 0.5;
// 2. 让它动起来,并循环 (frac)
float timePos = frac(_Time.y * _Speed) * 2.0; // *2 是为了留出空隙
// 3. 计算距离并边缘虚化
float sheen = smoothstep(0.0, 0.2, 1.0 - abs(sheenPos - timePos));
// 4. 叠加
return col + sheen * _SheenColor;
视觉: 技能 CD 转圈,或者圆环血条。 理论: 笛卡尔坐标 (x, y) 转 极坐标 (Angle, Distance)。
atan2(y, x)
视觉: 科幻风格的 UI,有水平扫描线上下移动。 理论: 利用 WorldPos 或 ScreenPos 的 Y 轴分量,结合正弦波。
手写代码时的快速参考。
float (32位), half (16位), fixed (11位, 仅旧硬件).
float4 v = float4(1, 0, 0, 1);float2 uv = v.xy; (Swizzling: 随意组合分量)float3 color = v.rgb;float3(uv, 1.0)mul(MATRIX, vector) (注意顺序!)
mul(unity_ObjectToWorld, v.vertex): 模型转世界sampler2D _MainTex;fixed4 col = tex2D(_MainTex, i.uv);_MainTex_TexelSize (x=1/w, y=1/h, z=w, w=h)UnityObjectToClipPos(v.vertex): 顶点着色器必须调用的,将模型点转为裁剪空间坐标。TRANSFORM_TEX(v.uv, _MainTex): 应用材质球上的 Tiling & Offset 设置。abs(x): 绝对值ceil(x) / floor(x): 向上/向下取整round(x): 四舍五入min(a, b) / max(a, b): 最小值/最大值clamp(x, min, max): 钳制范围length(v): 向量长度distance(p1, p2): 两点距离normalize(v): 归一化 (变为长度为1的单位向量)dot(a, b): 点积cross(a, b): 叉积reflect(i, n): 计算反射向量这是一个包含 菲涅尔边缘光、受击闪白、透明混合 和 基础光照 的通用 Shader 模板。复制即用。
Shader "Custom/UniversalTemplate"
{
Properties
{
[Header(Base)]
_MainTex ("Texture", 2D) = "white" {}
_Color ("Tint Color", Color) = (1,1,1,1)
[Header(Effects)]
_FlashColor ("Hit Flash Color", Color) = (1,1,1,1)
_FlashAmount ("Flash Amount", Range(0,1)) = 0
[Header(Rim Light)]
[Toggle] _EnableRim ("Enable Rim", Float) = 0
_RimColor ("Rim Color", Color) = (0,1,1,1)
_RimPower ("Rim Power", Range(0.1, 10)) = 3.0
}
SubShader
{
// 透明物体设置: 渲染队列3000, 混合模式 Alpha Blending, 不写深度
Tags { "Queue"="Transparent" "RenderType"="Transparent" "IgnoreProjector"="True" }
LOD 100
// 混合模式: SrcAlpha * Src + OneMinusSrcAlpha * Dst (标准透明)
Blend SrcAlpha OneMinusSrcAlpha
ZWrite Off
Cull Back // 剔除背面
Pass
{
CGPROGRAM
#pragma vertex vert
#pragma fragment frag
// 开启雾效支持
#pragma multi_compile_fog
#include "UnityCG.cginc"
struct appdata
{
float4 vertex : POSITION;
float2 uv : TEXCOORD0;
float3 normal : NORMAL; // 需要法线来计算边缘光
};
struct v2f
{
float2 uv : TEXCOORD0;
UNITY_FOG_COORDS(1)
float4 vertex : SV_POSITION;
float3 viewDir : TEXCOORD3; // 视线方向 (World Space)
float3 normal : TEXCOORD4; // 法线 (World Space)
};
sampler2D _MainTex;
float4 _MainTex_ST; // 用于 Tiling & Offset
fixed4 _Color;
fixed4 _FlashColor;
float _FlashAmount;
float _EnableRim;
fixed4 _RimColor;
float _RimPower;
v2f vert (appdata v)
{
v2f o;
// 1. 顶点变换: 模型 -> 裁剪空间
o.vertex = UnityObjectToClipPos(v.vertex);
// 2. UV 变换
o.uv = TRANSFORM_TEX(v.uv, _MainTex);
// 3. 准备世界空间数据 (用于光照/边缘光)
// 将顶点转到世界空间
float3 worldPos = mul(unity_ObjectToWorld, v.vertex).xyz;
// 计算视线方向: 相机位置 - 像素位置
o.viewDir = normalize(_WorldSpaceCameraPos.xyz - worldPos);
// 计算世界法线
o.normal = UnityObjectToWorldNormal(v.normal);
UNITY_TRANSFER_FOG(o,o.vertex);
return o;
}
fixed4 frag (v2f i) : SV_Target
{
// A. 基础纹理 + 颜色叠加
fixed4 col = tex2D(_MainTex, i.uv) * _Color;
// B. 菲涅尔边缘光 (Fresnel / Rim)
if (_EnableRim > 0.5)
{
float3 normal = normalize(i.normal);
float3 viewDir = normalize(i.viewDir);
// N dot V: 1=中心, 0=边缘
float NdotV = saturate(dot(normal, viewDir));
// 反转并取指数: 边缘亮, 中心暗
float rim = pow(1.0 - NdotV, _RimPower);
// 叠加方式: Additive (加法)
col.rgb += _RimColor.rgb * rim;
}
// C. 受击闪白 (Hit Flash)
// 线性插值: 当前颜色 -> 闪光颜色
col.rgb = lerp(col.rgb, _FlashColor.rgb, _FlashAmount);
// D. 雾效应用
UNITY_APPLY_FOG(i.fogCoord, col);
return col;
}
ENDCG
}
}
}