Shader 里的数学工具箱与程序化图案
不用一张贴图,只用几行数学,就能画出棋盘、进度条、花瓣、云、大理石和图标。这一章把后面所有章节都会反复用到的数学工具一次讲清楚,并用十个程序化图案案例把它们练熟。
第 3 章的 Shader 都在“搬运数据”:把贴图、顶点色、雾色组合起来输出。从这一章开始,片元着色器要自己“算”出颜色。程序化图案(Procedural Pattern)有三个现实的好处:分辨率无关(放多大都不糊)、参数可调(进度、颜色、密度随时改,还能做动画)、省贴图内存。代价是计算量,以及——需要一点数学。
本章所有可复用的函数都放在一个 include 文件 Assets/Cookbook/Ch04/ProceduralLib.hlsl 里,十个案例 Shader 共用一个只传位置和 UV 的顶点函数(ProceduralCommon.hlsl)。配图都是用正交相机正对一张铺满画面的面片拍的,你看到的就是片元着色器的原始输出。为了突出图案本身,这些 Shader 只有颜色 Pass;要用在会投影的不透明物体上,按第 3 章的方法补 ShadowCaster 与 DepthOnly Pass 即可。
// 第 4 章所有“图案 Shader”共用的顶点部分:只需要位置和 UV。 #ifndef COOKBOOK_CH04_PROCEDURAL_COMMON_INCLUDED #define COOKBOOK_CH04_PROCEDURAL_COMMON_INCLUDED #include "Packages/com.unity.render-pipelines.universal/ShaderLibrary/Core.hlsl" #include "Assets/Cookbook/Ch04/ProceduralLib.hlsl" struct Attributes { float4 positionOS : POSITION; float2 uv : TEXCOORD0; }; struct Varyings { float4 positionCS : SV_POSITION; float2 uv : TEXCOORD0; }; Varyings ProceduralVert(Attributes input) { Varyings output; output.positionCS = TransformObjectToHClip(input.positionOS.xyz); output.uv = input.uv; return output; } #endif
// 第 4 章所有“图案 Shader”共用的顶点部分:只需要位置和 UV。(GLSL 版,对应 Ch04/ProceduralCommon.hlsl) // 用法:在 Pass 的 GLSLPROGRAM 里 #include 本文件,它提供顶点着色器(ProceduralVert)和插值变量 uv; // 片元着色器(#ifdef FRAGMENT 的 main)由各个 Shader 自己写。 #ifndef COOKBOOK_CH04_PROCEDURAL_COMMON_INCLUDED #define COOKBOOK_CH04_PROCEDURAL_COMMON_INCLUDED #include "Ch04/ProceduralLib.glsl" // HLSL 版的 Varyings 结构体:WebGL 不允许结构体做 varying,拆成单独变量(positionCS 就是 gl_Position) VARYING vec2 uv; #ifdef VERTEX // ProceduralVert void main() { gl_Position = TransformObjectToHClip(positionOS.xyz); uv = texcoord0.xy; } #endif #endif
4.1 向量运算的几何意义
Shader 里最常见的两个向量运算是点乘和叉乘。记住它们的几何意义,比记住公式重要得多。
点乘 dot(a, b) 的结果是一个标量。当 b 是单位向量时,它就是 a 在 b 方向上的投影长度;当两个都是单位向量时,它就是夹角的余弦。在着色里它无处不在:dot(N, L) 是光线入射角的余弦,即 Lambert 漫反射(第 5 章);dot(N, V) 衡量表面是否正对相机,是菲涅尔和边缘光的基础;dot(p, p) 是长度的平方,比 length 少一次开方,适合只比较远近的场合(第 4.5 节的 Voronoi 就这么用)。点乘还能用来“沿某个方向测量”:本章的条纹图案,本质上就是把 UV 投影到一个方向上再取 frac。
叉乘 cross(a, b) 只对三维向量有定义,结果垂直于 a 和 b 所在的平面,长度是两者张成的平行四边形面积。用法主要有两个:已知两个切方向求法线(第 7 章用法线和切线叉乘出副切线;第 17 章顶点动画后用两个偏导方向叉乘重建法线);判断左右/正反(结果的符号或方向)。注意叉乘不满足交换律:cross(a, b) = -cross(b, a),顺序写反了法线就朝里了。
此外常用的还有:length(v) 长度、distance(a, b) 两点距离、normalize(v) 单位化(v 为零向量时结果是 NaN,URP 提供了 SafeNormalize)、reflect(i, n) 反射方向。它们都是逐分量运算组合出来的,第 5 章讲 Phong 时会推导 reflect。
4.2 插值、阈值与重映射
程序化图案的核心工作,是把一个连续的数(距离、坐标、噪声值)变成想要的形状。下面这几个函数就是“塑形”的工具:
lerp(a, b, t):线性插值a + (b − a)·t。t=0 得 a,t=1 得 b。在 Shader 里它最常见的身份是“按遮罩混合两种颜色”。saturate(x):夹到 [0, 1],等价于clamp(x, 0, 1),在 GPU 上通常是免费的(作为指令修饰符)。step(edge, x):x ≥ edge时为 1,否则为 0。注意参数顺序是“阈值在前”,很多人写反。smoothstep(e0, e1, x):在 e0 到 e1 之间平滑地从 0 过渡到 1,两端斜率为 0。frac(x):小数部分x − floor(x),产生锯齿波,是“重复”的来源。floor(x):向下取整,产生阶梯,是“格子编号”的来源。
smoothstep 的完整定义是:
它的导数 6t(1−t) 在 t=0 和 t=1 处都是 0,所以接在常数段之后不会出现“折角”——这正是它比线性过渡看起来柔和的原因。把 t²(3−2t) 这一步去掉,就是线性的 “linear step”,我们在库里写成了 LinearStep:
// ---------------------------------------------------------------- 重映射与插值 // 把 x 从 [inMin, inMax] 线性映射到 [outMin, outMax](不夹取) float Remap(float x, float inMin, float inMax, float outMin, float outMax) { return outMin + (x - inMin) * (outMax - outMin) / (inMax - inMin); } // smoothstep 去掉两端 S 形:纯线性的 0→1 过渡(Unity core 里也有同名思路的 Remap01) float LinearStep(float edge0, float edge1, float x) { return saturate((x - edge0) / (edge1 - edge0)); }
// ---------------------------------------------------------------- 重映射与插值 // 把 x 从 [inMin, inMax] 线性映射到 [outMin, outMax](不夹取) // 注:网页版的 Core.glsl 已经有一个完全相同的 Remap,GLSL 不允许重复定义,这里只留作对照 // float Remap(float x, float inMin, float inMax, float outMin, float outMax) // { // return outMin + (x - inMin) * (outMax - outMin) / (inMax - inMin); // } // smoothstep 去掉两端 S 形:纯线性的 0→1 过渡(Unity core 里也有同名思路的 Remap01) float LinearStep(float edge0, float edge1, float x) { return saturate((x - edge0) / (edge1 - edge0)); }
Remap 把一个区间线性映射到另一个区间,比如把噪声的 [-1, 1] 映射到 [0, 1],或者把“血量 0.2~0.5”映射到“颜色从红到黄”。它其实就是“反向 lerp + 正向 lerp”:先算出 x 在输入区间里的比例 t,再用 t 在输出区间里插值。
smoothstep(0.3, 0.4, x) − smoothstep(0.6, 0.7, x) 在 0.4~0.6 之间为 1、两侧平滑降到 0(图 4-2 最后一格)。画“一条有宽度的带子”——扫描线、圆环、高亮区——基本都靠这个套路。4.3 用 frac 与 floor 做重复,用 fwidth 抗锯齿
把 UV 乘以 N,再拆成整数部分和小数部分,平面就被切成了 N×N 个格子:
float2 q = uv * N; // 放大:[0,1] → [0,N] float2 cellId = floor(q); // 第几个格子(整数):决定“这一格长什么样” float2 local = frac(q); // 格子内的局部坐标 [0,1):决定“格子里画什么” float2 c = local - 0.5; // 以格子中心为原点,方便画圆、画方
floor 给出格子编号 (2,2),frac 给出点在该格内的位置 (0.1, 0.1)。所有“重复图案”都是这个结构:编号决定变化,局部坐标决定形状格子编号可以做奇偶交替(棋盘格)、错位(砖墙、波点的蜂窝排布)、随机化(4.5 节的哈希)。局部坐标可以画任何形状。这个“编号 + 局部坐标”的结构贯穿全章,Voronoi 也只是它的一个推广。
为什么程序化图案会有锯齿
用 step 画出的边缘是绝对锐利的:一个像素要么完全在里面,要么完全在外面。贴图有 Mip 和双线性过滤帮忙“柔化”,程序化图案没有,所以边缘一定有锯齿,远处的细条纹还会出现摩尔纹(Moiré)。解决办法是让边缘恰好跨过一个像素的宽度做平滑过渡——问题是,一个像素在“图案坐标”里有多宽?
答案是屏幕空间偏导数。GPU 总是以 2×2 像素为一组(quad)执行片元着色器,所以任何变量都能和相邻像素的值作差:ddx(v) 是 v 在屏幕横向相邻像素间的差,ddy(v) 是纵向的差,fwidth(v) = abs(ddx(v)) + abs(ddy(v)) 粗略表示“v 在一个像素内变化了多少”。于是:
float d = ...; // 某个“到边缘的距离”,边缘处为 0 float aa = fwidth(d); // 一个像素对应的 d 的变化量 float m = smoothstep(-aa, aa, d); // 恰好在一两个像素内完成过渡
库里的 FillAA 就是这个思路的变体(4.6 节)。偏导数有两个使用限制:只能在片元着色器里用;在“相邻像素可能走不同分支”的 if 里面求导,结果是未定义的——所以要把 fwidth 放在分支外面先算好。
案例 4.1在 Shader 里画函数曲线入门单独打开 ↗
目标:写一个“示波器” Shader,把任意函数 y = f(x) 画成带网格和坐标轴的曲线。调参数学函数时,这比在脑子里想象可靠得多;它同时也是“用距离 + fwidth 画抗锯齿线条”的第一个练习。
// 第 k 条曲线 y = f(x)。改这里就能画任意函数 float Curve(int mode, int k, float x) { if (mode == 0) // 阈值类:step / smoothstep / 线性 step { if (k == 0) return step(0.5, x); if (k == 1) return smoothstep(0.2, 0.8, x); return LinearStep(0.2, 0.8, x); } if (mode == 1) // 重复类:floor 阶梯 / frac 锯齿 / 三角波 { if (k == 0) return floor(x * 4.0) / 4.0; if (k == 1) return frac(x * 2.0); return abs(frac(x) - 0.5) * 2.0; } // 形状类:脉冲(两个 smoothstep 相减)/ 正弦重映射 / 幂函数 if (k == 0) return smoothstep(0.25, 0.35, x) - smoothstep(0.65, 0.75, x); if (k == 1) return 0.5 + 0.5 * sin(x * TWO_PI * 2.0); return x * x * x; }
// 第 k 条曲线 y = f(x)。改这里就能画任意函数 float Curve(int mode, int k, float x) { if (mode == 0) // 阈值类:step / smoothstep / 线性 step { if (k == 0) return step(0.5, x); if (k == 1) return smoothstep(0.2, 0.8, x); return LinearStep(0.2, 0.8, x); } if (mode == 1) // 重复类:floor 阶梯 / frac 锯齿 / 三角波 { if (k == 0) return floor(x * 4.0) / 4.0; if (k == 1) return fract(x * 2.0); return abs(fract(x) - 0.5) * 2.0; } // 形状类:脉冲(两个 smoothstep 相减)/ 正弦重映射 / 幂函数 if (k == 0) return smoothstep(0.25, 0.35, x) - smoothstep(0.65, 0.75, x); if (k == 1) return 0.5 + 0.5 * sin(x * TWO_PI * 2.0); return x * x * x; }
想画自己的函数,只要改 Curve。真正的技巧在“怎么把曲线画成等宽的线”:
// 抗锯齿画线:把曲线在当前像素附近近似成一小段折线(左右各一个像素), // 求到这段线段的距离,再换算成“像素单位”。跳变处线段几乎竖直,竖线也能自然连上 float PlotCurve(int mode, int k, float2 q, float pixel) { float2 a = float2(q.x - pixel, Curve(mode, k, q.x - pixel)); float2 b = float2(q.x + pixel, Curve(mode, k, q.x + pixel)); float dist = SdSegment(q, a, b); return saturate(_LineWidth * 0.5 + 0.5 - dist / pixel); } // 到 d=0 处一条宽 widthPx 像素的线 float LineAt(float d, float pixel, float widthPx) { return saturate(widthPx * 0.5 + 0.5 - abs(d) / pixel); } // 每隔 spacing 一条网格线:frac 把坐标折回到最近网格线附近 float GridLine(float v, float spacing, float pixel, float widthPx) { float d = (frac(v / spacing + 0.5) - 0.5) * spacing; return LineAt(d, pixel, widthPx); }
// 抗锯齿画线:把曲线在当前像素附近近似成一小段折线(左右各一个像素), // 求到这段线段的距离,再换算成“像素单位”。跳变处线段几乎竖直,竖线也能自然连上 float PlotCurve(int mode, int k, vec2 q, float pixel) { vec2 a = vec2(q.x - pixel, Curve(mode, k, q.x - pixel)); vec2 b = vec2(q.x + pixel, Curve(mode, k, q.x + pixel)); float dist = SdSegment(q, a, b); return saturate(_LineWidth * 0.5 + 0.5 - dist / pixel); } // 到 d=0 处一条宽 widthPx 像素的线 float LineAt(float d, float pixel, float widthPx) { return saturate(widthPx * 0.5 + 0.5 - abs(d) / pixel); } // 每隔 spacing 一条网格线:fract 把坐标折回到最近网格线附近 float GridLine(float v, float spacing, float pixel, float widthPx) { float d = (fract(v / spacing + 0.5) - 0.5) * spacing; return LineAt(d, pixel, widthPx); }
最朴素的写法是 abs(q.y − f(q.x)) < 线宽,但它测的是竖直距离:曲线陡的地方,竖直距离变化很快,线就变细;在 step 的跳变处,竖线干脆画不出来。这里改为在当前像素左右各一个像素的位置取两个点,把曲线局部近似成一小段线段,用 SdSegment(4.6 节)求垂直于曲线的真实距离,再除以一个像素的大小 pixel = fwidth(q.x),得到“离曲线几个像素”。这样任何斜率下线宽都一致,跳变处的竖线也自然连上了。

step(0.5,x) 橙、smoothstep(0.2,0.8,x) 蓝、线性 step 绿,可以看到 smoothstep 两端更“软”;中:floor(4x)/4 阶梯、frac(2x) 锯齿、三角波;右:smoothstep 脉冲、0.5+0.5sin(4πx)、x³案例 4.2棋盘格、条纹与波点入门单独打开 ↗
目标:用 4.3 节的“编号 + 局部坐标”结构做三种最常见的重复图案,并支持旋转和抗锯齿。
float Checker(float2 q) { // floor 得到格子编号,横纵编号之和的奇偶决定颜色;abs 处理负数格子 return abs(fmod(floor(q.x) + floor(q.y), 2.0)); }
棋盘格:横纵格子编号之和为偶数取一种颜色,奇数取另一种。fmod 对负数会返回负值,所以外面套一层 abs——当 UV 被旋转或平移到负数区域时,这一点就很重要了。
float Stripes(float2 q, float duty) { // 三角波:在条纹中心为 0、两条纹交界处为 1,宽度用阈值 duty 控制 float tri = abs(frac(q.x) - 0.5) * 2.0; float aa = fwidth(tri); // 一个像素内 tri 变化了多少 return 1.0 - smoothstep(duty - aa, duty + aa, tri); }
条纹:只用 x 坐标。abs(frac(x) − 0.5) * 2 把锯齿波变成三角波:条纹中心为 0,两条纹的分界处为 1,用一个阈值 duty 切开,就得到占空比可调的条纹。这里没有直接 step(0.5, frac(x)),正是为了得到一个“到边缘的距离”,好用 fwidth 做抗锯齿。
float Dots(float2 q, float radius) { // 奇数行错开半格,得到更紧凑的“蜂窝”排布 float row = floor(q.y); q.x += 0.5 * abs(fmod(row, 2.0)); float2 cell = frac(q) - 0.5; // 每格内以中心为原点的局部坐标 float d = SdCircle(cell, radius * 0.5); return FillAA(d); }
波点:先用行号把奇数行向右错开半格,得到更紧凑的交错排布,再在每格中心画一个圆。圆用的是 SDF(4.6 节):length(c) − r,配合 FillAA 抗锯齿。
三种图案共用一个入口:先把 UV 以中心为原点旋转,再放大 _Scale 倍。旋转用的是 4.4 节的 Rotate2D。要注意“旋转坐标”与“旋转图案”方向相反:把坐标逆时针转 30°,看到的图案是顺时针转了 30°。

input.uv 换成世界坐标的某两个分量(第 3 章案例 3.8 的思路),让图案不随物体缩放而拉伸。远处的高频条纹即便有 fwidth 也会因为“一个像素内包含多条条纹”而闪烁,这时可以根据 fwidth 的大小把条纹逐渐淡出成平均色——这是程序化纹理版的 Mip。4.4 极坐标与旋转矩阵
很多图案天然是“绕着中心”的:表盘、进度环、花瓣、旋涡、放射光。处理它们,最方便的坐标系是极坐标:用到原点的距离 r 和相对 +X 轴的角度 θ 描述一个点。
HLSL 的 atan2(y, x) 返回 (−π, π] 的角度(注意参数顺序是 y 在前)。我们通常把它归一化到 [0, 1),这样“角度方向上重复 N 次”就是 frac(a01 * N),与 4.3 节完全同构——极坐标图案就是“在 (r, θ) 平面上画重复图案”。
二维旋转矩阵把点绕原点逆时针转 α:
推导很简单:设 p 的极坐标是 (r, θ),旋转后是 (r, θ+α),用和角公式展开 r·cos(θ+α) 与 r·sin(θ+α) 即得。代码:
// ---------------------------------------------------------------- 旋转与极坐标 // 绕原点逆时针旋转 angle 弧度 float2 Rotate2D(float2 p, float angle) { float s, c; sincos(angle, s, c); return mul(float2x2(c, -s, s, c), p); } // 笛卡尔 → 极坐标:x = 半径 r,y = 角度归一化到 [0,1)(从 +X 轴逆时针) float2 ToPolar(float2 p) { float r = length(p); float a = atan2(p.y, p.x); // (-PI, PI] return float2(r, frac(a / TWO_PI + 1.0)); // 映射到 [0,1) }
// ---------------------------------------------------------------- 旋转与极坐标 // 绕原点逆时针旋转 angle 弧度 vec2 Rotate2D(vec2 p, float angle) { float s = sin(angle), c = cos(angle); // HLSL 的 float2x2(c, -s, s, c) 按行构造;GLSL 的 mat2 按列构造,所以写成 mat2(c, s, -s, c) return mat2(c, s, -s, c) * p; } // 笛卡尔 → 极坐标:x = 半径 r,y = 角度归一化到 [0,1)(从 +X 轴逆时针) vec2 ToPolar(vec2 p) { float r = length(p); float a = atan(p.y, p.x); // (-PI, PI] return vec2(r, fract(a / TWO_PI + 1.0)); // 映射到 [0,1) }
float2x2(c, −s, s, c) 按行填写,mul(M, p) 是矩阵左乘列向量,与上面的公式一一对应。sincos 一次求出正弦和余弦,在部分硬件上比分别调用更快。旋转角度若在一帧内对所有像素相同,也可以在 C# 里算好 sin/cos 传进来,省掉每像素的三角函数。
step、frac 没问题,但对它求 fwidth 时,接缝两侧的像素差接近一整圈,会在那条射线上画出一道“裂缝”。案例 4.4 的放射光给出了处理方法。案例 4.3圆形进度条与分段血条进阶单独打开 ↗
目标:做两个游戏 UI 里最常见的程序化控件:带圆头和外发光的环形进度条;带受伤拖尾、分段刻度和低血量闪烁的血条。全部由材质参数驱动,不需要任何贴图。
环形进度条
float r = length(p); // 从 12 点方向开始、顺时针增加的角度,归一化到 [0,1) float a01 = frac(atan2(p.x, p.y) / TWO_PI + 1.0); float hw = _Thickness * 0.5; float ring = abs(r - _Radius) - hw; // 整圈环带的 SDF // 已走过的部分:角度在进度内就用环带距离,否则用到两个“圆头”的距离 float endAngle = _Progress * TWO_PI; float2 capStart = float2(0.0, _Radius); float2 capEnd = _Radius * float2(sin(endAngle), cos(endAngle)); float caps = min(length(p - capStart), length(p - capEnd)) - hw; float arc = (a01 <= _Progress) ? ring : caps; if (_Progress <= 0.0) arc = 1.0; // 进度为 0 时什么都不画
float r = length(p); // 从 12 点方向开始、顺时针增加的角度,归一化到 [0,1) float a01 = fract(atan(p.x, p.y) / TWO_PI + 1.0); float hw = _Thickness * 0.5; float ring = abs(r - _Radius) - hw; // 整圈环带的 SDF // 已走过的部分:角度在进度内就用环带距离,否则用到两个“圆头”的距离 float endAngle = _Progress * TWO_PI; vec2 capStart = vec2(0.0, _Radius); vec2 capEnd = _Radius * vec2(sin(endAngle), cos(endAngle)); float caps = min(length(p - capStart), length(p - capEnd)) - hw; float arc = (a01 <= _Progress) ? ring : caps; if (_Progress <= 0.0) arc = 1.0; // 进度为 0 时什么都不画
三步:
- 角度从 12 点开始顺时针。标准的
atan2(y, x)从 +X 轴逆时针,把参数换成atan2(p.x, p.y),相当于交换了 x、y 轴,起点就变成 +Y 轴、方向变成顺时针。 - 整圈环带的距离是
abs(r − R) − 半宽:r − R是到中心圆的有向距离,取绝对值再减半宽,就是一条有宽度的环(4.6 节的“描边”运算)。 - 只保留已完成的部分,并画圆头。角度在进度之内的像素用环带距离;之外的像素改用“到两个端点圆心的距离 − 半宽”,即两个圆头。这样得到的是一段带圆头的弧的精确距离场,后面可以拿它做抗锯齿和外发光。
着色时,颜色沿弧长从起点色渐变到终点色;外发光用 exp(−k·d):离弧越远越暗,指数衰减的光晕比线性衰减自然。
分段血条
// 乘以宽高比,让 SDF 在拉长的面片上仍然是“正”的(圆角不会被压扁) float2 p = (input.uv - 0.5) * float2(_Aspect, 1.0); float2 halfSize = float2(_Aspect * 0.5, 0.5); float outer = SdRoundBox(p, halfSize - 0.04, 0.22); // 外框 float inner = SdRoundBox(p, halfSize - 0.14, 0.13); // 槽 // 槽内的横向进度 0..1 float innerHalfW = halfSize.x - 0.14; float x01 = saturate((p.x + innerHalfW) / (2.0 * innerHalfW));
// 乘以宽高比,让 SDF 在拉长的面片上仍然是“正”的(圆角不会被压扁) vec2 p = (uv - 0.5) * vec2(_Aspect, 1.0); vec2 halfSize = vec2(_Aspect * 0.5, 0.5); float outer = SdRoundBox(p, halfSize - 0.04, 0.22); // 外框 float inner = SdRoundBox(p, halfSize - 0.14, 0.13); // 槽 // 槽内的横向进度 0..1 float innerHalfW = halfSize.x - 0.14; float x01 = saturate((p.x + innerHalfW) / (2.0 * innerHalfW));
血条的面片是 6:1 的长条,UV 却在两个方向上都是 0~1。如果直接在 UV 里画圆角矩形,圆角会被横向拉成椭圆。解决办法是先把坐标乘以宽高比,回到“各向同性”的空间里再求 SDF。这是在非正方形面片上画任何形状都要做的一步。
// 血量颜色:低于阈值后快速变红 half3 hpCol = lerp(_ColorLow.rgb, _ColorFull.rgb, smoothstep(_LowThreshold * 0.6, _LowThreshold * 1.6, _Health)); // 低血量闪烁:_Time 驱动;静帧时用 _FlashPhase float flash = 0.5 + 0.5 * sin(_Time.y * _FlashSpeed + _FlashPhase); hpCol *= (_Health < _LowThreshold) ? lerp(0.7, 1.4, flash) : 1.0; float px = fwidth(x01); float hpMask = 1.0 - smoothstep(_Health - px, _Health + px, x01); float trailMask = 1.0 - smoothstep(_Trail - px, _Trail + px, x01); half3 col = _EmptyColor.rgb; col = lerp(col, _TrailColor.rgb, trailMask); // 先画拖尾,再盖上当前血量 col = lerp(col, hpCol, hpMask); // 上亮下暗,做出一点体积感 col *= lerp(0.75, 1.15, smoothstep(-0.35, 0.35, p.y)); // 分段刻度:frac 重复出竖线 float seg = abs(frac(x01 * _Segments + 0.5) - 0.5) / _Segments; // 到最近刻度的距离(x01 单位) col *= lerp(0.55, 1.0, smoothstep(px * 0.5, px * 1.5, seg));
// 血量颜色:低于阈值后快速变红 vec3 hpCol = mix(_ColorLow.rgb, _ColorFull.rgb, smoothstep(_LowThreshold * 0.6, _LowThreshold * 1.6, _Health)); // 低血量闪烁:_Time 驱动;静帧时用 _FlashPhase float flash = 0.5 + 0.5 * sin(_Time.y * _FlashSpeed + _FlashPhase); hpCol *= (_Health < _LowThreshold) ? mix(0.7, 1.4, flash) : 1.0; float px = fwidth(x01); float hpMask = 1.0 - smoothstep(_Health - px, _Health + px, x01); float trailMask = 1.0 - smoothstep(_Trail - px, _Trail + px, x01); vec3 col = _EmptyColor.rgb; col = mix(col, _TrailColor.rgb, trailMask); // 先画拖尾,再盖上当前血量 col = mix(col, hpCol, hpMask); // 上亮下暗,做出一点体积感 col *= mix(0.75, 1.15, smoothstep(-0.35, 0.35, p.y)); // 分段刻度:fract 重复出竖线 float seg = abs(fract(x01 * _Segments + 0.5) - 0.5) / _Segments; // 到最近刻度的距离(x01 单位) col *= mix(0.55, 1.0, smoothstep(px * 0.5, px * 1.5, seg));
这里有三个值得学的小技巧:
- 受伤拖尾:游戏里掉血时,常见做法是血量立即减少,而一条浅色的“拖尾”慢慢追上来,让玩家看清掉了多少。Shader 只负责画:先画到
_Trail的浅色,再盖上到_Health的血量色;拖尾的“慢慢追”由脚本对_Trail做插值。 - 分段刻度:
frac(x01 * _Segments)把进度条切成若干段,到最近刻度的距离小于一个像素的地方压暗。 - 低血量闪烁:
sin(_Time.y * 速度)驱动亮度起伏。_Time在编辑器截图时几乎为 0,所以额外留了一个_FlashPhase参数,静帧时手动指定相位——这是本书所有动画类 Shader 的统一做法。

_ClipRect)和顶点色,最简单的做法是以 Unity 内置的 UI/Default Shader 为蓝本,把这里的片元逻辑移植进去。案例 4.4极坐标图案:花瓣、螺旋与放射光进阶单独打开 ↗
目标:在极坐标下画三个经典图案,并处理 atan2 接缝带来的抗锯齿问题。
half3 Flower(float2 p) { float2 pol = ToPolar(p); float theta = pol.y * TWO_PI; // 半径随角度起伏:r(θ) = 基础半径 + 振幅·cos(Nθ),N 片花瓣 float edge = 0.55 + 0.25 * cos(_Count * theta); float petal = FillAA(pol.x - edge); // 花瓣内部再按半径做一层渐变,中心是一颗小圆 half3 col = lerp(_BgColor.rgb, lerp(_ColorA.rgb, _ColorA.rgb * 0.55, pol.x / edge), petal); col = lerp(col, _ColorB.rgb, FillAA(SdCircle(p, 0.2))); return col; }
vec3 Flower(vec2 p) { vec2 pol = ToPolar(p); float theta = pol.y * TWO_PI; // 半径随角度起伏:r(θ) = 基础半径 + 振幅·cos(Nθ),N 片花瓣 float edge = 0.55 + 0.25 * cos(_Count * theta); float petal = FillAA(pol.x - edge); // 花瓣内部再按半径做一层渐变,中心是一颗小圆 vec3 col = mix(_BgColor.rgb, mix(_ColorA.rgb, _ColorA.rgb * 0.55, pol.x / edge), petal); col = mix(col, _ColorB.rgb, FillAA(SdCircle(p, 0.2))); return col; }
花瓣:让“边界半径”随角度起伏:edge(θ) = 0.55 + 0.25·cos(Nθ),N 是花瓣数。像素的 r 小于 edge(θ) 就在花里。r − edge(θ) 不是严格的距离场(它沿径向测量,花瓣侧面会偏大),但用作 FillAA 的输入已经足够平滑。把 cos 换成 abs(cos)、pow、或几个不同频率的叠加,就能得到星形、齿轮、雪花等变化。
half3 Spiral(float2 p) { float2 pol = ToPolar(p); // 角度 + log(半径):沿径向每走 e 倍,条纹转过 _Twist 圈 → 对数螺线 float s = frac(pol.y * _Count + log(max(pol.x, 1e-4)) * _Twist); float band = abs(s - 0.5) * 2.0; float aa = fwidth(band); float m = smoothstep(0.5 - aa, 0.5 + aa, band); half3 col = lerp(_ColorA.rgb, _ColorB.rgb, m); // 中心处条纹无限变细,必然出摩尔纹;用一个圆盖住 return lerp(col, _BgColor.rgb, FillAA(SdCircle(p, 0.06))); }
vec3 Spiral(vec2 p) { vec2 pol = ToPolar(p); // 角度 + log(半径):沿径向每走 e 倍,条纹转过 _Twist 圈 → 对数螺线 float s = fract(pol.y * _Count + log(max(pol.x, 1e-4)) * _Twist); float band = abs(s - 0.5) * 2.0; float aa = fwidth(band); float m = smoothstep(0.5 - aa, 0.5 + aa, band); vec3 col = mix(_ColorA.rgb, _ColorB.rgb, m); // 中心处条纹无限变细,必然出摩尔纹;用一个圆盖住 return mix(col, _BgColor.rgb, FillAA(SdCircle(p, 0.06))); }
螺旋:条纹的“相位”同时由角度和 log(r) 决定。沿半径方向每放大同样的倍数,条纹转过同样的角度,这就是对数螺线——鹦鹉螺、台风云图的形状。中心处条纹无限变细,必然出现摩尔纹,所以用一个小圆盖住。
half3 Sunburst(float2 p) { float2 pol = ToPolar(p); float t = pol.y * _Count; // 每条光芒占 1 个单位 float tri = abs(frac(t) - 0.5) * 2.0; // 坑:pol.y 在 +X 轴处从 1 跳回 0,fwidth 会在那条线上暴涨,画出一道“裂缝”。 // 解决:用另一个在别处断开的角度(偏移半圈)求导,两者取小。 float a2 = frac(pol.y + 0.5) * _Count; float aa = min(fwidth(t), fwidth(a2)) * 2.0; float ray = smoothstep(0.5 - aa, 0.5 + aa, tri); half3 col = lerp(_ColorA.rgb, _ColorB.rgb, ray); // 向外渐暗 + 中心太阳 col *= lerp(1.15, 0.55, saturate(pol.x)); return lerp(col, half3(1.0, 0.85, 0.45), FillAA(SdCircle(p, 0.22))); }
vec3 Sunburst(vec2 p) { vec2 pol = ToPolar(p); float t = pol.y * _Count; // 每条光芒占 1 个单位 float tri = abs(fract(t) - 0.5) * 2.0; // 坑:pol.y 在 +X 轴处从 1 跳回 0,fwidth 会在那条线上暴涨,画出一道“裂缝”。 // 解决:用另一个在别处断开的角度(偏移半圈)求导,两者取小。 float a2 = fract(pol.y + 0.5) * _Count; float aa = min(fwidth(t), fwidth(a2)) * 2.0; float ray = smoothstep(0.5 - aa, 0.5 + aa, tri); vec3 col = mix(_ColorA.rgb, _ColorB.rgb, ray); // 向外渐暗 + 中心太阳 col *= mix(1.15, 0.55, saturate(pol.x)); return mix(col, vec3(1.0, 0.85, 0.45), FillAA(SdCircle(p, 0.22))); }
放射光:frac(a01 * N) 在角度方向重复 N 次。因为 N 是整数,图案本身在接缝处是连续的;但 fwidth(t) 在接缝处会因 a01 从 1 跳到 0 而暴涨,抗锯齿宽度变得极大,画出一条模糊的“裂缝”。修法是再造一个在另一处断开的角度(加半圈再取 frac),两个 fwidth 取较小者——任何一个像素最多只靠近其中一条接缝。这个技巧对所有“对周期坐标求导”的情形都适用。
三个图案在最后都乘了一个旋转:Rotate2D(p, radians(_Angle + _Speed * _Time.y))。运行时它们会自己转起来,静帧时由 _Angle 控制朝向。

4.5 哈希与噪声
自然界的纹理——云、石头、火焰、水面——都带有“有规律的随机”。在 Shader 里制造随机性要满足一个苛刻的条件:同样的输入必须得到同样的输出(否则每帧闪烁),而且不能依赖任何状态(每个像素独立计算)。满足这个条件的“随机”函数叫哈希(Hash):输入一个坐标,输出一个看起来毫无规律、但完全确定的数。
// ---------------------------------------------------------------- 哈希:坐标 → 伪随机数 // PCG 风格的整数哈希:输入相同输出必相同,相邻输入的输出看起来毫无关联 uint PcgHash(uint v) { uint state = v * 747796405u + 2891336453u; uint word = ((state >> ((state >> 28u) + 4u)) ^ state) * 277803737u; return (word >> 22u) ^ word; } // 2D 整数格子 → [0,1) 浮点 float Hash21(float2 cell) { int2 i = (int2)floor(cell); uint h = PcgHash((uint)i.x + PcgHash((uint)i.y)); return h * (1.0 / 4294967296.0); } // 2D 整数格子 → 两个 [0,1) 浮点 float2 Hash22(float2 cell) { int2 i = (int2)floor(cell); uint h1 = PcgHash((uint)i.x + PcgHash((uint)i.y)); uint h2 = PcgHash(h1 ^ 0x9E3779B9u); return float2(h1, h2) * (1.0 / 4294967296.0); }
// ---------------------------------------------------------------- 哈希:坐标 → 伪随机数 // PCG 风格的整数哈希:输入相同输出必相同,相邻输入的输出看起来毫无关联 uint PcgHash(uint v) { uint state = v * 747796405u + 2891336453u; uint word = ((state >> ((state >> 28u) + 4u)) ^ state) * 277803737u; return (word >> 22u) ^ word; } // 2D 整数格子 → [0,1) 浮点 float Hash21(vec2 cell) { ivec2 i = ivec2(floor(cell)); uint h = PcgHash(uint(i.x) + PcgHash(uint(i.y))); // int → uint 保留位模式,负数格子也没问题 return float(h) * (1.0 / 4294967296.0); } // 2D 整数格子 → 两个 [0,1) 浮点 vec2 Hash22(vec2 cell) { ivec2 i = ivec2(floor(cell)); uint h1 = PcgHash(uint(i.x) + PcgHash(uint(i.y))); uint h2 = PcgHash(h1 ^ 0x9E3779B9u); return vec2(float(h1), float(h2)) * (1.0 / 4294967296.0); }
我们用的是一种 PCG 风格的整数哈希:乘一个大奇数、加一个常数,再用移位和异或把高低位充分搅拌。整数运算的好处是结果在所有 GPU 上逐位一致,不像网上流传的 frac(sin(dot(p, k)) * 43758.5) 那样依赖 sin 在大参数下的精度——后者在不同平台、甚至同一平台的不同精度下会出现明显的条纹或格子。整数运算需要 GPU 支持(OpenGL ES 3.0、Metal、Vulkan、D3D11 及以上都没问题)。core 包的 Random.hlsl 和 Hashes.hlsl 里也有 JenkinsHash、Hash_Tchou_2_1_float 等现成的哈希,可以互换。
哈希本身是“白噪声”:相邻格子的值毫无关联,直接显示就是一格一格的马赛克(图 4-10 左一)。要得到平滑起伏的噪声,就要在格点之间插值。插值“什么”,决定了噪声的类型:
// ---------------------------------------------------------------- 噪声 // Value Noise:格点上放随机“值”,格内平滑插值。结果在 [0,1] float ValueNoise(float2 p) { float2 i = floor(p); float2 f = frac(p); float a = Hash21(i); float b = Hash21(i + float2(1, 0)); float c = Hash21(i + float2(0, 1)); float d = Hash21(i + float2(1, 1)); float2 u = f * f * (3.0 - 2.0 * f); // smoothstep 曲线 3t²-2t³ return lerp(lerp(a, b, u.x), lerp(c, d, u.x), u.y); } // 格点上的随机单位梯度方向 float2 RandomGradient(float2 cell) { float a = Hash21(cell) * TWO_PI; return float2(cos(a), sin(a)); } // Gradient Noise(Perlin 思路):格点上放随机“方向”,每个格点贡献 dot(梯度, 到该点的偏移)。结果约在 [-1,1] float GradientNoise(float2 p) { float2 i = floor(p); float2 f = frac(p); float2 u = f * f * f * (f * (f * 6.0 - 15.0) + 10.0); // 五次曲线 6t⁵-15t⁴+10t³,二阶导连续 float n00 = dot(RandomGradient(i), f); float n10 = dot(RandomGradient(i + float2(1, 0)), f - float2(1, 0)); float n01 = dot(RandomGradient(i + float2(0, 1)), f - float2(0, 1)); float n11 = dot(RandomGradient(i + float2(1, 1)), f - float2(1, 1)); return lerp(lerp(n00, n10, u.x), lerp(n01, n11, u.x), u.y) * 1.41421356; } // Simplex Noise(2D):把平面切成等边三角形,每个点只受所在三角形 3 个顶点影响。结果约在 [-1,1] float SimplexNoise(float2 p) { const float F2 = 0.36602540378; // (√3 - 1) / 2:把平面“斜切”成方格 const float G2 = 0.21132486540; // (3 - √3) / 6:再切回来 float2 i = floor(p + (p.x + p.y) * F2); // 所在斜切格 float2 x0 = p - i + (i.x + i.y) * G2; // 到第 1 个顶点的偏移 float2 o = (x0.x > x0.y) ? float2(1, 0) : float2(0, 1); // 在上三角还是下三角 float2 x1 = x0 - o + G2; float2 x2 = x0 - 1.0 + 2.0 * G2; float3 w = max(0.5 - float3(dot(x0, x0), dot(x1, x1), dot(x2, x2)), 0.0); w = w * w; w = w * w; // (0.5 - r²)⁴ 的径向衰减核 float3 n = float3(dot(RandomGradient(i), x0), dot(RandomGradient(i + o), x1), dot(RandomGradient(i + 1.0), x2)); return dot(w, n) * 70.0; }
// ---------------------------------------------------------------- 噪声 // Value Noise:格点上放随机“值”,格内平滑插值。结果在 [0,1] float ValueNoise(vec2 p) { vec2 i = floor(p); vec2 f = fract(p); float a = Hash21(i); float b = Hash21(i + vec2(1, 0)); float c = Hash21(i + vec2(0, 1)); float d = Hash21(i + vec2(1, 1)); vec2 u = f * f * (3.0 - 2.0 * f); // smoothstep 曲线 3t²-2t³ return mix(mix(a, b, u.x), mix(c, d, u.x), u.y); } // 格点上的随机单位梯度方向 vec2 RandomGradient(vec2 cell) { float a = Hash21(cell) * TWO_PI; return vec2(cos(a), sin(a)); } // Gradient Noise(Perlin 思路):格点上放随机“方向”,每个格点贡献 dot(梯度, 到该点的偏移)。结果约在 [-1,1] float GradientNoise(vec2 p) { vec2 i = floor(p); vec2 f = fract(p); vec2 u = f * f * f * (f * (f * 6.0 - 15.0) + 10.0); // 五次曲线 6t⁵-15t⁴+10t³,二阶导连续 float n00 = dot(RandomGradient(i), f); float n10 = dot(RandomGradient(i + vec2(1, 0)), f - vec2(1, 0)); float n01 = dot(RandomGradient(i + vec2(0, 1)), f - vec2(0, 1)); float n11 = dot(RandomGradient(i + vec2(1, 1)), f - vec2(1, 1)); return mix(mix(n00, n10, u.x), mix(n01, n11, u.x), u.y) * 1.41421356; } // Simplex Noise(2D):把平面切成等边三角形,每个点只受所在三角形 3 个顶点影响。结果约在 [-1,1] float SimplexNoise(vec2 p) { const float F2 = 0.36602540378; // (√3 - 1) / 2:把平面“斜切”成方格 const float G2 = 0.21132486540; // (3 - √3) / 6:再切回来 vec2 i = floor(p + (p.x + p.y) * F2); // 所在斜切格 vec2 x0 = p - i + (i.x + i.y) * G2; // 到第 1 个顶点的偏移 vec2 o = (x0.x > x0.y) ? vec2(1, 0) : vec2(0, 1); // 在上三角还是下三角 vec2 x1 = x0 - o + G2; vec2 x2 = x0 - 1.0 + 2.0 * G2; vec3 w = max(0.5 - vec3(dot(x0, x0), dot(x1, x1), dot(x2, x2)), 0.0); w = w * w; w = w * w; // (0.5 - r²)⁴ 的径向衰减核 vec3 n = vec3(dot(RandomGradient(i), x0), dot(RandomGradient(i + o), x1), dot(RandomGradient(i + 1.0), x2)); return dot(w, n) * 70.0; }
Value Noise 最直观:四个格点各取一个随机值,按点在格内的位置双线性插值。直接用线性权重会在格线上留下明显的折痕,所以权重先过一遍 smoothstep 曲线 3t² − 2t³。它的缺点是格点处常是局部极值,整体看起来“块状”,能隐约看出网格方向。
Gradient Noise(Ken Perlin 在 1980 年代提出的经典 Perlin 噪声即属此类)在格点上放的不是值,而是随机的梯度方向。每个格点的贡献是“梯度 · 从格点指向当前点的向量”,即在格点处值为 0、沿梯度方向线性增长的一个斜坡;再把四个斜坡平滑地插值起来。因为格点处的值都是 0,起伏的峰谷落在格子内部,网格感比 Value Noise 弱得多。权重曲线换成了五次多项式 6t⁵ − 15t⁴ + 10t³,它的一阶和二阶导数在格线上都连续——当噪声被用来扰动法线或做位移时,二阶导数不连续会表现为光照上的折痕。
Simplex Noise 是 Perlin 后来提出的改进思路:把方格换成单纯形(2D 是三角形、3D 是四面体),先把坐标“斜切”到方便查找所在单纯形的空间,再切回来。每个点只受所在单纯形的 n+1 个顶点影响(2D 时 3 个,方格要 4 个;3D 时 4 个,立方体要 8 个),并用径向衰减核 (0.5 − r²)⁴ 代替插值。维度越高,它相对方格噪声的优势越明显。我们的 2D 实现保留了这个思路的全部关键步骤,注释里写明了两个斜切常数的来历。
案例 4.5白噪声、Value、Gradient、Simplex 并排对比进阶单独打开 ↗
目标:用同一个 Shader、同样的缩放,把四种噪声并排画出来,叠加整数格线,直观看到它们的区别。
if (_Mode < 0.5) n = Hash21(p); // 每格一个随机值,格内不变 else if (_Mode < 1.5) n = ValueNoise(p); // [0,1] else if (_Mode < 2.5) n = GradientNoise(p) * 0.5 + 0.5; // [-1,1] → [0,1] else n = SimplexNoise(p) * 0.5 + 0.5;
Gradient 和 Simplex 的输出大致在 [−1, 1],显示前要用 * 0.5 + 0.5 映射到 [0, 1]。在 Shader 里用噪声时也要时刻记得它的值域:拿 [−1, 1] 的噪声直接当 lerp 的 t,一半的区域会超出范围。

lerp 的权重在两种颜色之间混合,颜色属性本身会被正确转换;更系统的色彩空间讨论在第 9 章和第 13 章。案例 4.6Voronoi 细胞进阶单独打开 ↗
目标:生成 Voronoi(又称 Worley、细胞噪声)图案,分别展示“到最近点的距离”“细胞边界”和“按细胞上色的鹅卵石”。它是石板路、龟裂土地、细胞、鳞片、第 18 章护盾、第 19 章水下焦散的基础。
做法是 4.3 节“编号 + 局部坐标”结构的延伸:每个格子用哈希决定一个特征点的位置,平面上每个点都属于离它最近的那个特征点,形成一个个凸多边形细胞。由于特征点留在自己的格子里,最近的点一定在当前格及其邻居之中,不需要遍历所有点。
// Worley / Voronoi:每格放一个随机特征点,求到最近(F1)和次近(F2)特征点的距离 // phase 让特征点在格子内绕圈移动,用来做动画 float2 VoronoiPoint(float2 cell, float phase) { float2 h = Hash22(cell); return 0.5 + 0.4 * sin(phase + TWO_PI * h); // 特征点在格内的位置,始终留在 [0.1,0.9] } void Voronoi(float2 p, float phase, out float f1, out float f2, out float2 nearestCell) { float2 i = floor(p); float2 f = frac(p); f1 = 8.0; f2 = 8.0; nearestCell = i; // F1 用 3×3 邻域就够;F2(次近点)可能在更远的格子里,所以搜 5×5 [unroll] for (int y = -2; y <= 2; y++) [unroll] for (int x = -2; x <= 2; x++) { float2 g = float2(x, y); // 邻居格子相对偏移 float d = length(g + VoronoiPoint(i + g, phase) - f); if (d < f1) { f2 = f1; f1 = d; nearestCell = i + g; } else if (d < f2) { f2 = d; } } } // 到细胞边界的“真实”距离:边界是两个特征点连线的中垂线, // 点到中垂线的距离 = dot(两点中点 - 当前点, 两点连线方向) float VoronoiBorder(float2 p, float phase) { float2 i = floor(p); float2 f = frac(p); // 第一遍:找最近点(相对当前点的向量 mr) float2 mg = 0, mr = 0; float md = 8.0; [unroll] for (int y = -1; y <= 1; y++) [unroll] for (int x = -1; x <= 1; x++) { float2 g = float2(x, y); float2 r = g + VoronoiPoint(i + g, phase) - f; float d = dot(r, r); if (d < md) { md = d; mr = r; mg = g; } } // 第二遍:以最近点所在格为中心,对其余每个点求到中垂线的距离,取最小 md = 8.0; [unroll] for (int y2 = -2; y2 <= 2; y2++) [unroll] for (int x2 = -2; x2 <= 2; x2++) { float2 g = mg + float2(x2, y2); float2 r = g + VoronoiPoint(i + g, phase) - f; float2 diff = r - mr; if (dot(diff, diff) > 1e-5) md = min(md, dot(0.5 * (mr + r), normalize(diff))); } return md; }
// Worley / Voronoi:每格放一个随机特征点,求到最近(F1)和次近(F2)特征点的距离 // phase 让特征点在格子内绕圈移动,用来做动画 vec2 VoronoiPoint(vec2 cell, float phase) { vec2 h = Hash22(cell); return 0.5 + 0.4 * sin(phase + TWO_PI * h); // 特征点在格内的位置,始终留在 [0.1,0.9] } void Voronoi(vec2 p, float phase, out float f1, out float f2, out vec2 nearestCell) { vec2 i = floor(p); vec2 f = fract(p); f1 = 8.0; f2 = 8.0; nearestCell = i; // F1 用 3×3 邻域就够;F2(次近点)可能在更远的格子里,所以搜 5×5 for (int y = -2; y <= 2; y++) for (int x = -2; x <= 2; x++) { vec2 g = vec2(x, y); // 邻居格子相对偏移 float d = length(g + VoronoiPoint(i + g, phase) - f); if (d < f1) { f2 = f1; f1 = d; nearestCell = i + g; } else if (d < f2) { f2 = d; } } } // 到细胞边界的“真实”距离:边界是两个特征点连线的中垂线, // 点到中垂线的距离 = dot(两点中点 - 当前点, 两点连线方向) float VoronoiBorder(vec2 p, float phase) { vec2 i = floor(p); vec2 f = fract(p); // 第一遍:找最近点(相对当前点的向量 mr) vec2 mg = vec2(0.0), mr = vec2(0.0); float md = 8.0; for (int y = -1; y <= 1; y++) for (int x = -1; x <= 1; x++) { vec2 g = vec2(x, y); vec2 r = g + VoronoiPoint(i + g, phase) - f; float d = dot(r, r); if (d < md) { md = d; mr = r; mg = g; } } // 第二遍:以最近点所在格为中心,对其余每个点求到中垂线的距离,取最小 md = 8.0; for (int y2 = -2; y2 <= 2; y2++) for (int x2 = -2; x2 <= 2; x2++) { vec2 g = mg + vec2(x2, y2); vec2 r = g + VoronoiPoint(i + g, phase) - f; vec2 diff = r - mr; if (dot(diff, diff) > 1e-5) md = min(md, dot(0.5 * (mr + r), normalize(diff))); } return md; }
函数里有三个值得注意的地方:
- 动画:特征点位置是
0.5 + 0.4·sin(phase + 2π·hash),每个点以自己的随机相位在格内往复,整体看起来像细胞在蠕动。0.4 的幅度保证点始终留在 [0.1, 0.9],搜索范围才可靠。 - 搜索范围:只求 F1(最近点)时 3×3 邻域就够了;但 F2(次近点)在特征点分布不均时可能落在更外一圈的格子里,所以这里保险地搜索 5×5,代价是每像素 25 次哈希。在本例的参数下,3×3 与 5×5 的结果肉眼几乎看不出差别——图 4-12 中间那几处糊边另有原因,见下一条。
- 真实边界距离:
F2 − F1常被当作“到边界的距离”,但它只是近似:两个特征点靠得很近时,F2 − F1 在整个区域都不会超过两点间距,边界会糊成一大片;三个细胞交汇处也会变宽。精确的做法(Inigo Quilez 介绍过的两遍算法)是:先找到最近点,再对周围每个点求当前位置到“两点连线的中垂线”的距离,取最小值。中垂线正是两个细胞的分界线,这个距离才是真正的边界距离。
half3 col; if (_Mode < 0.5) { // F1:到最近特征点的距离 → 一个个“圆锥”,加等高线更容易看出形状 col = lerp(_ColorA.rgb, _ColorB.rgb, saturate(f1 * 1.4)); col *= 0.85 + 0.15 * step(0.5, frac(f1 * 8.0)); col = lerp(col, _EdgeColor.rgb, 1.0 - smoothstep(0.03, 0.05, f1)); // 特征点 } else if (_Mode < 1.5) { // F2 - F1:两个最近点距离相等的地方就是细胞边界,那里差值趋于 0 float e = f2 - f1; col = lerp(_EdgeColor.rgb, _ColorB.rgb, smoothstep(0.0, _EdgeWidth * 2.0, e)); } else { // 用最近格子的编号再哈希一次,每个细胞一个固定颜色 float h = Hash21(cell + 31.7); col = lerp(_ColorA.rgb, _ColorB.rgb, h); // 用到边界的真实距离做明暗和描边:中心亮、边缘暗,像鹅卵石 float border = VoronoiBorder(p, _Phase + _Time.y * _Speed); col *= lerp(0.7, 1.1, saturate(border * 2.5)); float aa = fwidth(border); col = lerp(_EdgeColor.rgb, col, smoothstep(_EdgeWidth * 0.5 - aa, _EdgeWidth * 0.5 + aa, border)); }
vec3 col; if (_Mode < 0.5) { // F1:到最近特征点的距离 → 一个个“圆锥”,加等高线更容易看出形状 col = mix(_ColorA.rgb, _ColorB.rgb, saturate(f1 * 1.4)); col *= 0.85 + 0.15 * step(0.5, fract(f1 * 8.0)); col = mix(col, _EdgeColor.rgb, 1.0 - smoothstep(0.03, 0.05, f1)); // 特征点 } else if (_Mode < 1.5) { // F2 - F1:两个最近点距离相等的地方就是细胞边界,那里差值趋于 0 float e = f2 - f1; col = mix(_EdgeColor.rgb, _ColorB.rgb, smoothstep(0.0, _EdgeWidth * 2.0, e)); } else { // 用最近格子的编号再哈希一次,每个细胞一个固定颜色 float h = Hash21(cell + 31.7); col = mix(_ColorA.rgb, _ColorB.rgb, h); // 用到边界的真实距离做明暗和描边:中心亮、边缘暗,像鹅卵石 float border = VoronoiBorder(p, _Phase + _Time.y * _Speed); col *= mix(0.7, 1.1, saturate(border * 2.5)); float aa = fwidth(border); col = mix(_EdgeColor.rgb, col, smoothstep(_EdgeWidth * 0.5 - aa, _EdgeWidth * 0.5 + aa, border)); }

Voronoi(25 次哈希)和一次 VoronoiBorder(9 + 25 次),合计约 60 次哈希与距离计算,在移动端整屏使用时开销可观。静态的 Voronoi 图案最好烘焙成贴图;需要动画时,考虑降低分辨率渲染到 RenderTexture,或只在必要的区域(比如护盾的命中点附近)计算。案例 4.7fBm 云纹进阶单独打开 ↗
目标:用分形布朗运动(fractional Brownian motion,fBm)叠加多层噪声,做出一片有大轮廓也有小细节的云。
单层噪声只有一种“尺度”的起伏:要么是大团块,要么是小碎点。自然界的形状在各个尺度上都有细节——远看是山的轮廓,近看是岩石的起伏,再近看是砂砾。fBm 就是把同一种噪声以不同频率叠加:
每一层叫一个八度(octave)。lacunarity(间隙度)是相邻两层的频率比,通常取 2,即每层细节缩小一半;gain(增益,也叫 persistence)是振幅比,通常取 0.5,即每层强度减半。gain 越大,小尺度细节越突出,画面越“粗糙”;gain 越小越平滑。
// fBm(分形布朗运动):把同一种噪声以“频率 ×lacunarity、振幅 ×gain”叠加 octaves 层。结果约在 [-1,1] float Fbm(float2 p, int octaves, float lacunarity, float gain) { float sum = 0.0, amp = 0.5, norm = 0.0; [loop] for (int k = 0; k < 10; k++) { if (k >= octaves) break; sum += amp * GradientNoise(p); norm += amp; // 每层旋转一点再平移,避免各层格子方向对齐出现“十字纹” p = Rotate2D(p, 0.6) * lacunarity + float2(17.3, -9.1); amp *= gain; } return sum / norm; }
// fBm(分形布朗运动):把同一种噪声以“频率 ×lacunarity、振幅 ×gain”叠加 octaves 层。结果约在 [-1,1] float Fbm(vec2 p, int octaves, float lacunarity, float gain) { float sum = 0.0, amp = 0.5, norm = 0.0; for (int k = 0; k < 10; k++) { if (k >= octaves) break; sum += amp * GradientNoise(p); norm += amp; // 每层旋转一点再平移,避免各层格子方向对齐出现“十字纹” p = Rotate2D(p, 0.6) * lacunarity + vec2(17.3, -9.1); amp *= gain; } return sum / norm; }
实现上有两个细节。一是除以振幅总和 norm,让不同层数的结果都保持在噪声原本的值域内,调层数时整体亮度不会跳变。二是每层先旋转再平移:如果各层都对齐在同一套方格上,它们的格线会在同样的位置叠加,出现隐约的横竖纹理;旋转一个不规则的角度(这里 0.6 弧度)可以打散这种对齐。
float n = Fbm(p, oct, _Lacunarity, _Gain) * 0.5 + 0.5; // → [0,1] // 覆盖率:把阈值往下推,云就变多;smoothstep 的宽度决定边缘软硬 float density = smoothstep(1.0 - _Cover, 1.0 - _Cover + _Softness, n); // 简单的“自阴影”:朝光源方向(左上)偏移一点再采一次,差值当作明暗 float n2 = Fbm(p + float2(-0.06, 0.06), oct, _Lacunarity, _Gain) * 0.5 + 0.5; float lit = saturate(0.5 + (n - n2) * 6.0);
float n = Fbm(p, oct, _Lacunarity, _Gain) * 0.5 + 0.5; // → [0,1] // 覆盖率:把阈值往下推,云就变多;smoothstep 的宽度决定边缘软硬 float density = smoothstep(1.0 - _Cover, 1.0 - _Cover + _Softness, n); // 简单的“自阴影”:朝光源方向(左上)偏移一点再采一次,差值当作明暗 float n2 = Fbm(p + vec2(-0.06, 0.06), oct, _Lacunarity, _Gain) * 0.5 + 0.5; float lit = saturate(0.5 + (n - n2) * 6.0);
把 fBm 变成云还需要两步“塑形”。覆盖率:用 smoothstep(1 − cover, 1 − cover + softness, n) 把噪声值截出一部分作为云,cover 越大阈值越低、云越多,softness 控制云边的软硬。体积感:朝光源方向偏移一点再采样一次,两次的差近似表示“噪声沿光方向的变化率”,正值说明朝光的一面,提亮;负值是背光面,压暗。这是一种很便宜的伪光照,第 28 章的体积光会看到更正式的做法。

_Wind 让采样坐标随时间平移,云就飘起来了。只平移整体会显得像一张贴图在滑动;让不同八度以不同速度移动(例如在 Fbm 的循环里给每层加上 _Time.y * 速度 * 层号),云会有“翻滚”的感觉。案例 4.8域扭曲:大理石与流体纹理高阶单独打开 ↗
目标:用噪声扭曲噪声的输入坐标,从普通的 fBm 得到大理石、玛瑙、烟雾般的流动纹理。
域扭曲(Domain Warping)的思想很简单:与其对 f(p) 采样,不如对 f(p + h(p)) 采样,其中 h 是另一个噪声生成的二维位移场。原本规整的噪声被“揉皮”一样地拉扯,就出现了流体般的卷曲。这个技巧可以嵌套:
F 是 fBm,o₁、o₂、o₃ 是任意的固定偏移(让两个分量互不相关),k 是扭曲强度。这一系列做法因 Inigo Quilez 的介绍而广为人知。
// 第 1 层:q 是一个二维“位移场”,两个分量用两个不同偏移的 fBm float2 q = 0; if (_Levels >= 1) q = float2(F(p + float2(0.0, 0.0) + t), F(p + float2(5.2, 1.3) - t)); // 第 2 层:拿被 q 扭过的坐标再求一次位移场 r float2 r = 0; if (_Levels >= 2) r = float2(F(p + _Strength * q + float2(1.7, 9.2)), F(p + _Strength * q + float2(8.3, 2.8))); // 最终:在被扭曲的坐标上采样 float2 warped = p + _Strength * ((_Levels >= 2) ? r : q); float f = F(warped) * 0.5 + 0.5;
// 第 1 层:q 是一个二维“位移场”,两个分量用两个不同偏移的 fBm vec2 q = vec2(0.0); if (_Levels >= 1.0) q = vec2(F(p + vec2(0.0, 0.0) + t), F(p + vec2(5.2, 1.3) - t)); // 第 2 层:拿被 q 扭过的坐标再求一次位移场 r vec2 r = vec2(0.0); if (_Levels >= 2.0) r = vec2(F(p + _Strength * q + vec2(1.7, 9.2)), F(p + _Strength * q + vec2(8.3, 2.8))); // 最终:在被扭曲的坐标上采样 vec2 warped = p + _Strength * ((_Levels >= 2.0) ? r : q); float f = F(warped) * 0.5 + 0.5;
// 用 f、|q|、r.x 三个量混色:位移场本身也是很好的“着色依据” half3 col = lerp(_Color1.rgb, _Color2.rgb, saturate(f * f * 2.2)); col = lerp(col, _Color4.rgb, saturate(length(q) * 0.9)); col = lerp(col, _Color3.rgb, saturate(abs(r.x) * 1.2) * step(1.5, _Levels)); col *= 0.6 + 0.6 * f;
着色部分体现了域扭曲的另一个妙处:中间产物 q、r 本身就是很好的“着色依据”。用最终值 f 决定基础色,用 |q| 混入第二种颜色,用 r.x 混入高光色,同一个位置的几个量彼此相关又不完全相同,颜色层次就自然地“缠绕”在一起,比只用一个噪声值映射颜色丰富得多。

4.6 2D 有向距离场(SDF)
有向距离场(Signed Distance Field,SDF)是一个函数:输入一个点,输出它到某个形状边界的距离,形状外为正、内为负、边界上为 0。前面画圆、圆角矩形、进度环时我们已经在用它了。为什么要绕一个弯去算“距离”,而不是直接判断“在不在里面”?因为距离携带了大量信息:
- 抗锯齿:边界附近 d 的变化率已知(就是 fwidth(d)),一行代码就能做出一个像素宽的柔和边缘,放大多少倍都清晰。
- 描边:
abs(d) − w是“沿边界的一条宽 2w 的带子”。 - 外发光与投影:
exp(−k·max(d, 0))是光晕;对偏移后的位置求 d 再做宽过渡就是软投影。 - 布尔运算:用 min、max 组合多个形状。
- 动画与形变:给 d 加减一个值就是整体膨胀/收缩;两个 SDF 插值就是形状渐变(morph)。
// ---------------------------------------------------------------- 2D 有向距离场(SDF):外部为正、内部为负、边界为 0 float SdCircle(float2 p, float r) { return length(p) - r; } // 轴对齐矩形,b = 半宽高 float SdBox(float2 p, float2 b) { float2 d = abs(p) - b; return length(max(d, 0.0)) + min(max(d.x, d.y), 0.0); } // 圆角矩形:先把矩形缩小 r,再整体“膨胀” r float SdRoundBox(float2 p, float2 b, float r) { return SdBox(p, b - r) - r; } // 线段 ab(无粗细);减去半宽就是胶囊 float SdSegment(float2 p, float2 a, float2 b) { float2 pa = p - a, ba = b - a; float h = saturate(dot(pa, ba) / dot(ba, ba)); return length(pa - ba * h); } float OpUnion(float a, float b) { return min(a, b); } float OpSubtract(float a, float b) { return max(a, -b); } // a 挖掉 b float OpIntersect(float a, float b) { return max(a, b); } // 平滑并集:k 是融合半径,两个形状在 k 范围内“长”到一起 float OpSmoothUnion(float a, float b, float k) { float h = saturate(0.5 + 0.5 * (b - a) / k); return lerp(b, a, h) - k * h * (1.0 - h); } // 抗锯齿填充:d<0 为 1,d>0 为 0,在边界一个像素宽度内平滑过渡 float FillAA(float d) { float w = fwidth(d); return saturate(0.5 - d / max(w, 1e-6)); }
// ---------------------------------------------------------------- 2D 有向距离场(SDF):外部为正、内部为负、边界为 0 float SdCircle(vec2 p, float r) { return length(p) - r; } // 轴对齐矩形,b = 半宽高 float SdBox(vec2 p, vec2 b) { vec2 d = abs(p) - b; return length(max(d, 0.0)) + min(max(d.x, d.y), 0.0); } // 圆角矩形:先把矩形缩小 r,再整体“膨胀” r float SdRoundBox(vec2 p, vec2 b, float r) { return SdBox(p, b - r) - r; } // 线段 ab(无粗细);减去半宽就是胶囊 float SdSegment(vec2 p, vec2 a, vec2 b) { vec2 pa = p - a, ba = b - a; float h = saturate(dot(pa, ba) / dot(ba, ba)); return length(pa - ba * h); } float OpUnion(float a, float b) { return min(a, b); } float OpSubtract(float a, float b) { return max(a, -b); } // a 挖掉 b float OpIntersect(float a, float b) { return max(a, b); } // 平滑并集:k 是融合半径,两个形状在 k 范围内“长”到一起 float OpSmoothUnion(float a, float b, float k) { float h = saturate(0.5 + 0.5 * (b - a) / k); return mix(b, a, h) - k * h * (1.0 - h); } // 抗锯齿填充:d<0 为 1,d>0 为 0,在边界一个像素宽度内平滑过渡 // (GLSL 的 fwidth 只能在片元着色器里用,顶点阶段即使没调用也会编译报错,所以加 #ifdef FRAGMENT) #ifdef FRAGMENT float FillAA(float d) { float w = fwidth(d); return saturate(0.5 - d / max(w, 1e-6)); } #endif
逐个看:
- 圆:到圆心的距离减半径,最简单也最精确。
- 矩形:见图 4-15。
abs(p)利用对称性把问题折叠到第一象限;d = abs(p) − b的两个分量分别是“超出右边界多少”和“超出上边界多少”。两个都为正时点在角区,距离是到角点的长度;只有一个为正时距离就是那一个分量;都为负时点在内部,距离是两者中较大(离得较近)的那个。length(max(d,0)) + min(max(d.x,d.y),0)一行把三种情况都包含了。core 包的SDF2D.hlsl里的RectangleSDF也是这个公式。 - 圆角矩形:先把矩形缩小 r,再把距离场整体减 r——“膨胀”操作会自动把直角变成半径为 r 的圆角,正是图 4-15 外圈等值线的原理。
- 线段:把点投影到线段所在直线上,投影参数夹到 [0, 1],再求到投影点的距离。减去半宽就是胶囊形。案例 4.1 画曲线用的就是它。
布尔运算
两个形状的距离分别是 a、b:并集 min(a, b)(离哪个近就是哪个),交集 max(a, b),差集(a 挖去 b)max(a, −b)——把 b 取反等于“b 的外部”,再和 a 求交。需要知道的是,除了并集在形状外部的部分,其余情况得到的都只是真实距离的下界:用来判断内外、做细描边完全没问题,但用于很宽的发光或描边时,凹角附近会出现轻微的不均匀。
平滑并集让两个形状像液滴一样融合:在 a、b 相差小于 k 的区域,用一个多项式把 min 的“尖角”磨圆,并额外向外鼓出最多 k/4。融合半径 k 越大,过渡越宽。它会让形状整体略微变大,下面的图钉案例就遇到了这个副作用。
从距离到像素
库里的 FillAA(d) = saturate(0.5 − d / fwidth(d)):在 d = 0 处取 0.5,内外各约半个像素完成从 1 到 0 的过渡。它只依赖“距离在屏幕上的变化率”,所以无论图标缩放多大、面片怎么倾斜,边缘都保持一个像素的柔和度——这正是 SDF 字体(TextMeshPro)能任意缩放的原理。
案例 4.9SDF 图标:定位图钉,带描边、发光与投影进阶单独打开 ↗
目标:只用圆、圆角矩形和布尔运算拼出一个“定位图钉”图标,再用距离场一次性做出描边、外发光、内部明暗和软投影。
// 图钉 = 圆(头) ∪ 旋转 45° 的方块(尖) − 小圆(孔) // 方块的两条下边恰好与圆相切:尖端到圆心距离 = R·√2 float PinSDF(float2 p) { const float R = 0.30; float2 headCenter = float2(0.0, 0.15); float head = SdCircle(p - headCenter, R); float halfDiag = R * 0.70710678; // 方块半对角线 = R/√2 float2 tipCenter = headCenter - float2(0.0, halfDiag); // 方块中心在圆心正下方 float2 tp = Rotate2D(p - tipCenter, radians(45.0)); float tip = SdRoundBox(tp, halfDiag * 0.70710678, 0.012); // 半边长 = 半对角线/√2,小圆角让尖端不扎手 float body = OpUnion(head, tip); // 两者相切,直接取 min 就是光滑的;这里用平滑并集反而会鼓出一个包 float hole = SdCircle(p - headCenter, 0.12); return OpSubtract(body, hole); }
// 图钉 = 圆(头) ∪ 旋转 45° 的方块(尖) − 小圆(孔) // 方块的两条下边恰好与圆相切:尖端到圆心距离 = R·√2 float PinSDF(vec2 p) { const float R = 0.30; vec2 headCenter = vec2(0.0, 0.15); float head = SdCircle(p - headCenter, R); float halfDiag = R * 0.70710678; // 方块半对角线 = R/√2 vec2 tipCenter = headCenter - vec2(0.0, halfDiag); // 方块中心在圆心正下方 vec2 tp = Rotate2D(p - tipCenter, radians(45.0)); float tip = SdRoundBox(tp, vec2(halfDiag * 0.70710678), 0.012); // 半边长 = 半对角线/√2,小圆角让尖端不扎手 float body = OpUnion(head, tip); // 两者相切,直接取 min 就是光滑的;这里用平滑并集反而会鼓出一个包 float hole = SdCircle(p - headCenter, 0.12); return OpSubtract(body, hole); }
图钉 = 圆(头)∪ 旋转 45° 的方块(尖)− 小圆(孔)。关键是让方块的两条下边与圆相切,轮廓才是一条光滑的曲线:设圆半径 R、圆心为 C,要让从尖端出发、与竖直方向成 45° 的两条直线与圆相切,尖端到圆心的距离必须是 R·√2;方块中心放在圆心正下方 R/√2 处、半对角线 R/√2,它的上角恰好在圆心,两个侧角恰好落在圆上的切点。几何关系对了,直接用 min 并集就是光滑的。
OpSmoothUnion(head, tip, 0.06),想把相切处“磨圆”。结果图标的两侧各鼓出一个小包:两个形状在切点附近的距离几乎相等,平滑并集恰好在这里向外膨胀了约 k/4。改回 min 后轮廓立刻光滑了。平滑并集适合融合有夹角的形状,对本来就相切的形状只会帮倒忙。// 把距离场画出来:外部暖色、内部冷色,等高线间隔 0.05,零等值线为白 half3 VisualizeField(float d) { half3 col = (d > 0.0) ? half3(0.90, 0.55, 0.30) : half3(0.25, 0.55, 0.85); col *= 1.0 - exp(-5.0 * abs(d)); // 离边界越远越亮 col *= 0.75 + 0.25 * cos(TWO_PI * d / 0.05); // 等高线 return lerp(col, half3(1, 1, 1), 1.0 - smoothstep(0.0, 0.012, abs(d))); }
// 把距离场画出来:外部暖色、内部冷色,等高线间隔 0.05,零等值线为白 vec3 VisualizeField(float d) { vec3 col = (d > 0.0) ? vec3(0.90, 0.55, 0.30) : vec3(0.25, 0.55, 0.85); col *= 1.0 - exp(-5.0 * abs(d)); // 离边界越远越亮 col *= 0.75 + 0.25 * cos(TWO_PI * d / 0.05); // 等高线 return mix(col, vec3(1.0), 1.0 - smoothstep(0.0, 0.012, abs(d))); }
调试 SDF 最好的办法是把它画出来:外部暖色、内部冷色,颜色随 |d| 增大,每隔固定距离画一圈等高线,零等值线用白色。看等高线是否均匀,就知道这个距离场是否“健康”。图 4-16 第一格里,图钉内部的等高线在方块和圆的交界处出现了一个折角,这就是布尔运算在内部只给出“下界”的直观体现。
half3 Booleans(float2 uv) { // 2×2 网格,每格一种布尔运算 float2 cellId = floor(uv * 2.0); float2 p = (frac(uv * 2.0) - 0.5) * 2.0; // 每格内 [-1,1] float a = SdCircle(p - float2(-0.2, 0.05), 0.45); float b = SdBox(Rotate2D(p - float2(0.28, -0.08), 0.4), float2(0.32, 0.32)); int op = (int)(cellId.x + (1.0 - cellId.y) * 2.0); // 左上 0 右上 1 左下 2 右下 3 float d = (op == 0) ? OpUnion(a, b) : (op == 1) ? OpSubtract(a, b) : (op == 2) ? OpIntersect(a, b) : OpSmoothUnion(a, b, 0.25); half3 col = _BgColor.rgb; // 淡淡画出两个原始形状的轮廓 col = lerp(col, half3(0.45, 0.5, 0.56), 1.0 - smoothstep(0.0, fwidth(a) * 1.5, abs(a))); col = lerp(col, half3(0.45, 0.5, 0.56), 1.0 - smoothstep(0.0, fwidth(b) * 1.5, abs(b))); col = lerp(col, _FillColor.rgb, FillAA(d) * 0.9); // 格子分隔线 float2 g = abs(frac(uv * 2.0 + 0.5) - 0.5) / fwidth(uv * 2.0); return lerp(col, half3(0.9, 0.92, 0.95), 1.0 - saturate(min(g.x, g.y) - 0.5)); }
vec3 Booleans(vec2 uv) { // 2×2 网格,每格一种布尔运算 vec2 cellId = floor(uv * 2.0); vec2 p = (fract(uv * 2.0) - 0.5) * 2.0; // 每格内 [-1,1] float a = SdCircle(p - vec2(-0.2, 0.05), 0.45); float b = SdBox(Rotate2D(p - vec2(0.28, -0.08), 0.4), vec2(0.32, 0.32)); int op = int(cellId.x + (1.0 - cellId.y) * 2.0); // 左上 0 右上 1 左下 2 右下 3 float d = (op == 0) ? OpUnion(a, b) : (op == 1) ? OpSubtract(a, b) : (op == 2) ? OpIntersect(a, b) : OpSmoothUnion(a, b, 0.25); vec3 col = _BgColor.rgb; // 淡淡画出两个原始形状的轮廓 col = mix(col, vec3(0.45, 0.5, 0.56), 1.0 - smoothstep(0.0, fwidth(a) * 1.5, abs(a))); col = mix(col, vec3(0.45, 0.5, 0.56), 1.0 - smoothstep(0.0, fwidth(b) * 1.5, abs(b))); col = mix(col, _FillColor.rgb, FillAA(d) * 0.9); // 格子分隔线 vec2 g = abs(fract(uv * 2.0 + 0.5) - 0.5) / fwidth(uv * 2.0); return mix(col, vec3(0.9, 0.92, 0.95), 1.0 - saturate(min(g.x, g.y) - 0.5)); }
half3 col = _BgColor.rgb; // 1) 投影:在偏移后的位置再求一次距离,用宽 smoothstep 得到软边 float ds = PinSDF(p - _ShadowOffset.xy); col *= 1.0 - 0.6 * (1.0 - smoothstep(-_ShadowSoftness, _ShadowSoftness, ds)); // 2) 外发光:只在形状外面,按距离指数衰减 col += _GlowColor.rgb * exp(-max(d, 0.0) * _GlowFalloff) * 0.35; // 3) 填充:内部按距离做一点明暗,越靠近边越暗 half3 fill = _FillColor.rgb * lerp(0.75, 1.15, saturate(-d * 8.0)); col = lerp(col, fill, FillAA(d)); // 4) 描边:|d| - 半宽 是“沿边界的一条带子”的 SDF float stroke = abs(d) - _StrokeWidth * 0.5; col = lerp(col, _StrokeColor.rgb, FillAA(stroke));
vec3 col = _BgColor.rgb; // 1) 投影:在偏移后的位置再求一次距离,用宽 smoothstep 得到软边 float ds = PinSDF(p - _ShadowOffset.xy); col *= 1.0 - 0.6 * (1.0 - smoothstep(-_ShadowSoftness, _ShadowSoftness, ds)); // 2) 外发光:只在形状外面,按距离指数衰减 col += _GlowColor.rgb * exp(-max(d, 0.0) * _GlowFalloff) * 0.35; // 3) 填充:内部按距离做一点明暗,越靠近边越暗 vec3 fill = _FillColor.rgb * mix(0.75, 1.15, saturate(-d * 8.0)); col = mix(col, fill, FillAA(d)); // 4) 描边:|d| - 半宽 是“沿边界的一条带子”的 SDF float stroke = abs(d) - _StrokeWidth * 0.5; col = mix(col, _StrokeColor.rgb, FillAA(stroke));
最终效果按“从下往上”的顺序叠加四层,每一层都只是对 d 做一次简单变换:投影是对偏移后的点重新求距离,再用一个很宽的 smoothstep 得到软边;外发光只作用在 d > 0 的区域;填充色按 −d 做由边缘向内的明暗;描边是 abs(d) − 半宽。改一个参数就能调整一层,不需要重新出图——这就是 SDF 图标在 UI 动效里大受欢迎的原因。

案例 4.10Bayer 有序抖动进阶单独打开 ↗
目标:只用两种(或少数几种)颜色表现连续的渐变,理解有序抖动(Ordered Dithering)的原理与 Bayer 矩阵的构造。抖动在实时渲染里用途很广:复古像素风格的后处理、抖动透明(第 16 章)、LOD 交叉淡化(URP 的 LOD_FADE_CROSSFADE)、给体积光的步进加随机起点以消除条带(第 28 章)、以及在 8 位输出前消除色带。
把灰度量化成黑白最简单的方法是阈值:大于 0.5 的变白,否则变黑。结果是大块的纯黑纯白,渐变信息全丢了(图 4-18 左一)。抖动的思路是让每个像素用不同的阈值:在一小块区域里,阈值均匀分布在 0~1 之间,于是灰度为 0.25 的区域恰好有约四分之一的像素超过阈值、被点亮,远看平均亮度就是 0.25。
阈值怎么排列决定了抖动的质感。Bayer 矩阵的特点是相邻阈值尽可能不同,点亮的像素在空间上尽量分散,所以图案是均匀的网点而不是成团的斑块。它可以递归构造:
常见做法是把 4×4 或 8×8 矩阵写成常量数组查表。我们换一个角度:观察递推式可以发现,像素坐标的最低位决定了它在最小 2×2 块里的位置,而这个位置贡献的是最高的权重(4 的最高次幂)。所以只要逐位取出 x、y,按 2×2 的规则 2·(x⊕y) + y 算出每一级的值,再按“低位高权”拼起来即可,任意尺寸都是同一段代码:
// ---------------------------------------------------------------- 有序抖动:Bayer 矩阵 // levels = 1 → 2×2,2 → 4×4,3 → 8×8。返回 (0,1) 内均匀分布的阈值 // 递推关系 M(2n) = [4M+0, 4M+2; 4M+3, 4M+1]:越低的位权重越大 float BayerThreshold(uint2 pixel, uint levels) { uint v = 0u; [unroll] for (uint i = 0u; i < 3u; i++) { if (i >= levels) break; uint x = (pixel.x >> i) & 1u; uint y = (pixel.y >> i) & 1u; uint m2 = ((x ^ y) << 1u) | y; // 2×2 矩阵 [0 2; 3 1] v |= m2 << (2u * (levels - 1u - i)); } float n = (float)(1u << (2u * levels)); // 4^levels 个阈值 return (v + 0.5) / n; }
// ---------------------------------------------------------------- 有序抖动:Bayer 矩阵 // levels = 1 → 2×2,2 → 4×4,3 → 8×8。返回 (0,1) 内均匀分布的阈值 // 递推关系 M(2n) = [4M+0, 4M+2; 4M+3, 4M+1]:越低的位权重越大 float BayerThreshold(uvec2 pixel, uint levels) { uint v = 0u; for (uint i = 0u; i < 3u; i++) { if (i >= levels) break; uint x = (pixel.x >> i) & 1u; uint y = (pixel.y >> i) & 1u; uint m2 = ((x ^ y) << 1u) | y; // 2×2 矩阵 [0 2; 3 1] v |= m2 << (2u * (levels - 1u - i)); } float n = float(1u << (2u * levels)); // 4^levels 个阈值 return (float(v) + 0.5) / n; }
可以手算验证:坐标 (1, 0) 的最低位是 x=1、y=0,2×2 值为 2,乘以 4 得 8;更高位都是 0,结果 8,与图 4-17 中 4×4 矩阵第一行第二个数一致。
uint2 pixel = (uint2)cellPx; float threshold = 0.5; // 模式 0:所有像素同一个阈值 if (_Mode > 0.5) threshold = BayerThreshold(pixel, (uint)_Mode); // 1→2×2, 2→4×4, 3→8×8 // 量化到 _Levels 级:先放大到 [0, L-1],加上阈值后向下取整 float L = _Levels - 1.0; float q = floor(v * L + threshold) / L;
多级量化的写法是 floor(v·(L−1) + threshold) / (L−1):把值放大到 [0, L−1],加上 [0,1) 的阈值后向下取整。L=2 时退化为二值抖动;L 越大,相邻两级之间的“台阶”越小,抖动图案也越不明显。
抖动图案通常要贴在屏幕像素上(用 positionCS.xy,即 SV_POSITION 在片元里的像素坐标),这样无论物体怎么移动,网点都对齐像素、不会游走闪烁。本案例为了在书里看清网点结构,把面片划成 96×96 的“虚拟像素”,每个虚拟像素占屏幕上约 4 个像素。

Random.hlsl 里的 InterleavedGradientNoise,计算便宜,配合 TAA 时逐帧变化效果很好)、蓝噪声贴图(Blue Noise,视觉上最“干净”,需要一张预生成的贴图)。三者可以按“开销—质量”自由替换,接口都是“每个像素给一个 [0,1) 的阈值”。本章小结
- 点乘是投影与夹角余弦,叉乘给出垂直方向与面积;它们是后续所有光照计算的基础。
lerp/saturate/step/smoothstep/Remap是塑形工具;两个 smoothstep 相减得到脉冲带。floor给格子编号、frac给格内坐标,“编号决定变化、局部坐标决定形状”是一切重复图案的骨架;fwidth告诉我们一个像素有多宽,用来抗锯齿。- 极坐标把“绕中心”的图案变成普通的重复图案;二维旋转矩阵
[c −s; s c];注意 atan2 接缝处的导数。 - 哈希把坐标变成确定的随机数;Value、Gradient、Simplex 噪声的区别在于格点上存什么、用什么网格;fBm 叠加多尺度细节,域扭曲用噪声扭曲噪声。
- Voronoi 在 3×3(F2 需 5×5)邻域里找最近特征点;精确的边界距离要用到中垂线。
- 2D SDF 以距离为中心:
FillAA抗锯齿,abs(d)−w描边,exp(−kd)发光,min/max 布尔运算;平滑并集会让形状略微膨胀。 - 有序抖动给每个像素不同的阈值,Bayer 矩阵让阈值在空间上尽量分散,可按位递推计算。
练习
- 在案例 4.2 中加入第四种图案“砖墙”:奇数行错开半格,砖缝宽度可调,每块砖用格子编号的哈希值稍微改变颜色。
- 修改案例 4.3 的环形进度条,让它由 12 个分离的圆角小段组成(提示:在角度方向
frac(a01 * 12)得到段内坐标,再在“段内角度 × 半径”的局部坐标系里画圆角矩形)。 - 在案例 4.7 中把 GradientNoise 换成 ValueNoise 和 SimplexNoise,比较云的形态;再尝试“ridged fBm”:每层用
1 − abs(noise)代替 noise,看看山脊状的纹理。 - 为案例 4.9 的图标增加“按下”动画:一个
_Press参数让图标整体收缩 0.02、投影偏移减小、发光增强,全部只改 d 和几个系数。 - 把案例 4.10 改成屏幕空间版本:用
input.positionCS.xy计算 Bayer 阈值,套在第 3 章案例 3.8 的 Shader 上,用clip(alpha − threshold)做一个“抖动半透明”,并与第 16 章的做法对照。