GameTech/URP 渲染实战
CHAPTER 04第 4 章

Shader 里的数学工具箱与程序化图案

不用一张贴图,只用几行数学,就能画出棋盘、进度条、花瓣、云、大理石和图标。这一章把后面所有章节都会反复用到的数学工具一次讲清楚,并用十个程序化图案案例把它们练熟。

本章你会学到
点乘、叉乘在着色里的几何含义
lerp / step / smoothstep / saturate / 重映射
用 frac 与 floor 把平面切成重复的格子
用 fwidth 给程序化图案抗锯齿
极坐标与二维旋转矩阵
哈希、Value / Gradient / Simplex 噪声
Voronoi、fBm 与域扭曲
2D SDF:基本形状、布尔运算、描边与发光、Bayer 抖动

第 3 章的 Shader 都在“搬运数据”:把贴图、顶点色、雾色组合起来输出。从这一章开始,片元着色器要自己“算”出颜色。程序化图案(Procedural Pattern)有三个现实的好处:分辨率无关(放多大都不糊)、参数可调(进度、颜色、密度随时改,还能做动画)、省贴图内存。代价是计算量,以及——需要一点数学。

本章所有可复用的函数都放在一个 include 文件 Assets/Cookbook/Ch04/ProceduralLib.hlsl 里,十个案例 Shader 共用一个只传位置和 UV 的顶点函数(ProceduralCommon.hlsl)。配图都是用正交相机正对一张铺满画面的面片拍的,你看到的就是片元着色器的原始输出。为了突出图案本身,这些 Shader 只有颜色 Pass;要用在会投影的不透明物体上,按第 3 章的方法补 ShadowCaster 与 DepthOnly Pass 即可。

Assets/Cookbook/Ch04/ProceduralCommon.hlsl
// 第 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) = |a|·|b|·cos θ b(单位向量) a dot(a, b) = a 在 b 上的投影长度 θ >0 同向,=0 垂直,<0 反向 叉乘 cross(a, b):垂直于 a、b 的向量 a b a × b 面积 = |a|·|b|·sin θ长度 = 平行四边形面积;交换顺序方向相反
图 4-1点乘是“投影”,叉乘是“垂直方向 + 面积”

点乘 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):向下取整,产生阶梯,是“格子编号”的来源。
101step(0.5, x)101smoothstep(0.2, 0.8, x)101saturate(2x − 0.5)101lerp(0.2, 0.9, x)101frac(3x)101floor(4x) / 4101abs(frac(2x) − 0.5)·2101smoothstep 脉冲
图 4-2常用塑形函数的曲线(横轴 x ∈ [0,1],纵轴 [0,1])。阶跃、平滑阶跃、夹取、插值、锯齿、阶梯、三角波与脉冲——几乎所有程序化图案都由它们组合而成

smoothstep 的完整定义是:

t=saturate(x−e0e1−e0),smoothstep=t2(3−2t)

它的导数 6t(1−t) 在 t=0 和 t=1 处都是 0,所以接在常数段之后不会出现“折角”——这正是它比线性过渡看起来柔和的原因。把 t²(3−2t) 这一步去掉,就是线性的 “linear step”,我们在库里写成了 LinearStep:

Assets/Cookbook/Ch04/ProceduralLib.hlsl(节选:remap)
// ---------------------------------------------------------------- 重映射与插值
// 把 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 相减 = 一个脉冲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;  // 以格子中心为原点,方便画圆、画方
uv=(0.7,0.7)原始 UV [0,1] ×3 → (0,0)(1,0)(0,1)(2,2)floor(q) = 格子编号 → (0.1,0.1)frac(q) = 格内坐标减 0.5 后中心为原点
图 4-3uv × 3 之后,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 画抗锯齿线条”的第一个练习。

Assets/Cookbook/Ch04/C04_01_FunctionPlot.shader(节选:curves)
// 第 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。真正的技巧在“怎么把曲线画成等宽的线”:

Assets/Cookbook/Ch04/C04_01_FunctionPlot.shader(节选:plot)
// 抗锯齿画线:把曲线在当前像素附近近似成一小段折线(左右各一个像素),
// 求到这段线段的距离,再换算成“像素单位”。跳变处线段几乎竖直,竖线也能自然连上
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),得到“离曲线几个像素”。这样任何斜率下线宽都一致,跳变处的竖线也自然连上了。

图 4-4Shader 画出的曲线(x、y 范围都是 −0.2~1.2,网格间隔 0.25)。左: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 节的“编号 + 局部坐标”结构做三种最常见的重复图案,并支持旋转和抗锯齿。

Assets/Cookbook/Ch04/C04_02_Patterns.shader(节选:checker)
float Checker(float2 q)
{
    // floor 得到格子编号,横纵编号之和的奇偶决定颜色;abs 处理负数格子
    return abs(fmod(floor(q.x) + floor(q.y), 2.0));
}
float Checker(vec2 q)
{
    // floor 得到格子编号,横纵编号之和的奇偶决定颜色;abs 处理负数格子
    return abs(fmod(floor(q.x) + floor(q.y), 2.0));
}

棋盘格:横纵格子编号之和为偶数取一种颜色,奇数取另一种。fmod 对负数会返回负值,所以外面套一层 abs——当 UV 被旋转或平移到负数区域时,这一点就很重要了。

Assets/Cookbook/Ch04/C04_02_Patterns.shader(节选:stripes)
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);
}
float Stripes(vec2 q, float duty)
{
    // 三角波:在条纹中心为 0、两条纹交界处为 1,宽度用阈值 duty 控制
    float tri = abs(fract(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 做抗锯齿。

Assets/Cookbook/Ch04/C04_02_Patterns.shader(节选:dots)
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);
}
float Dots(vec2 q, float radius)
{
    // 奇数行错开半格,得到更紧凑的“蜂窝”排布
    float row = floor(q.y);
    q.x += 0.5 * abs(fmod(row, 2.0));
    vec2 cell = fract(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°。

图 4-5左:棋盘格(8 格);中:条纹(9 条,旋转 30°,占空比 0.45);右:交错波点(7 格,半径 0.62 格)。边缘都经过 fwidth 抗锯齿
贴到 3D 物体上这些图案只依赖 UV,可以直接用在任何有 UV 的网格上;也可以把 input.uv 换成世界坐标的某两个分量(第 3 章案例 3.8 的思路),让图案不随物体缩放而拉伸。远处的高频条纹即便有 fwidth 也会因为“一个像素内包含多条条纹”而闪烁,这时可以根据 fwidth 的大小把条纹逐渐淡出成平均色——这是程序化纹理版的 Mip。

4.4 极坐标与旋转矩阵

很多图案天然是“绕着中心”的:表盘、进度环、花瓣、旋涡、放射光。处理它们,最方便的坐标系是极坐标:用到原点的距离 r 和相对 +X 轴的角度 θ 描述一个点。

xy p = (x, y) θ r r = length(p),θ = atan2(y, x) ∈ (−π, π] p p′ = R(α)·p α(逆时针) 旋转只改变角度,不改变长度
图 4-6左:笛卡尔坐标与极坐标的换算;右:二维旋转

HLSL 的 atan2(y, x) 返回 (−π, π] 的角度(注意参数顺序是 y 在前)。我们通常把它归一化到 [0, 1),这样“角度方向上重复 N 次”就是 frac(a01 * N),与 4.3 节完全同构——极坐标图案就是“在 (r, θ) 平面上画重复图案”。

二维旋转矩阵把点绕原点逆时针转 α:

(x′y′)=(cosα−sinαsinαcosα)(xy)

推导很简单:设 p 的极坐标是 (r, θ),旋转后是 (r, θ+α),用和角公式展开 r·cos(θ+α) 与 r·sin(θ+α) 即得。代码:

Assets/Cookbook/Ch04/ProceduralLib.hlsl(节选:rotate)
// ---------------------------------------------------------------- 旋转与极坐标
// 绕原点逆时针旋转 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 传进来,省掉每像素的三角函数。

atan2 的接缝θ 在 −X 轴处(归一化后在 +X 轴处)从最大值跳回最小值。对 θ 本身做 step、frac 没问题,但对它求 fwidth 时,接缝两侧的像素差接近一整圈,会在那条射线上画出一道“裂缝”。案例 4.4 的放射光给出了处理方法。

案例 4.3圆形进度条与分段血条进阶单独打开 ↗

目标:做两个游戏 UI 里最常见的程序化控件:带圆头和外发光的环形进度条;带受伤拖尾、分段刻度和低血量闪烁的血条。全部由材质参数驱动,不需要任何贴图。

环形进度条

Assets/Cookbook/Ch04/C04_03_RingProgress.shader(节选:arc)
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 时什么都不画

三步:

  1. 角度从 12 点开始顺时针。标准的 atan2(y, x) 从 +X 轴逆时针,把参数换成 atan2(p.x, p.y),相当于交换了 x、y 轴,起点就变成 +Y 轴、方向变成顺时针。
  2. 整圈环带的距离是 abs(r − R) − 半宽:r − R 是到中心圆的有向距离,取绝对值再减半宽,就是一条有宽度的环(4.6 节的“描边”运算)。
  3. 只保留已完成的部分,并画圆头。角度在进度之内的像素用环带距离;之外的像素改用“到两个端点圆心的距离 − 半宽”,即两个圆头。这样得到的是一段带圆头的弧的精确距离场,后面可以拿它做抗锯齿和外发光。

着色时,颜色沿弧长从起点色渐变到终点色;外发光用 exp(−k·d):离弧越远越暗,指数衰减的光晕比线性衰减自然。

分段血条

Assets/Cookbook/Ch04/C04_03_HealthBar.shader(节选:layout)
// 乘以宽高比,让 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。这是在非正方形面片上画任何形状都要做的一步。

Assets/Cookbook/Ch04/C04_03_HealthBar.shader(节选:fill)
// 血量颜色:低于阈值后快速变红
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 的统一做法。
图 4-7三组参数:进度 0.25 / 0.60 / 0.92,血量 0.85 / 0.50 / 0.18(拖尾分别到 0.93 / 0.68 / 0.42)。右图血量低于阈值 0.3,变红并处于闪烁的高亮相位
用在 UGUI 上这两个 Shader 可以直接给世界空间的面片(头顶血条)使用。若要用在 UGUI 的 Image 上,需要额外处理 UI 的模板测试(Stencil)、裁剪矩形(RectMask2D 的 _ClipRect)和顶点色,最简单的做法是以 Unity 内置的 UI/Default Shader 为蓝本,把这里的片元逻辑移植进去。

案例 4.4极坐标图案:花瓣、螺旋与放射光进阶单独打开 ↗

目标:在极坐标下画三个经典图案,并处理 atan2 接缝带来的抗锯齿问题。

Assets/Cookbook/Ch04/C04_04_Polar.shader(节选:flower)
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、或几个不同频率的叠加,就能得到星形、齿轮、雪花等变化。

Assets/Cookbook/Ch04/C04_04_Polar.shader(节选:spiral)
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) 决定。沿半径方向每放大同样的倍数,条纹转过同样的角度,这就是对数螺线——鹦鹉螺、台风云图的形状。中心处条纹无限变细,必然出现摩尔纹,所以用一个小圆盖住。

Assets/Cookbook/Ch04/C04_04_Polar.shader(节选:sunburst)
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-8左:6 瓣花(旋转 90° 让一片花瓣朝上);中:4 臂对数螺旋;右:14 道放射光,+X 方向没有接缝裂缝

4.5 哈希与噪声

自然界的纹理——云、石头、火焰、水面——都带有“有规律的随机”。在 Shader 里制造随机性要满足一个苛刻的条件:同样的输入必须得到同样的输出(否则每帧闪烁),而且不能依赖任何状态(每个像素独立计算)。满足这个条件的“随机”函数叫哈希(Hash):输入一个坐标,输出一个看起来毫无规律、但完全确定的数。

Assets/Cookbook/Ch04/ProceduralLib.hlsl(节选: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 左一)。要得到平滑起伏的噪声,就要在格点之间插值。插值“什么”,决定了噪声的类型:

0.20.90.60.1 p Value Noise格点存“值”,双线性插值 p Gradient Noise格点存“方向”,贡献 = dot(梯度, p−格点) p Simplex Noise三角形网格,只用 3 个顶点
图 4-9三种噪声的格点结构。Value 在格点放随机值;Gradient 放随机梯度方向;Simplex 改用三角形(高维时是单纯形)网格,每个点只受更少的顶点影响
Assets/Cookbook/Ch04/ProceduralLib.hlsl(节选:noise)
// ---------------------------------------------------------------- 噪声
// 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 实现保留了这个思路的全部关键步骤,注释里写明了两个斜切常数的来历。

Shader Graph 里的同名节点Shader Graph 自带 Simple Noise(一种 Value Noise)、Gradient Noise、Voronoi 节点,它们生成的 HLSL 就在 shadergraph 包的节点源码里。本章的实现是独立编写的,数值与那些节点并不相同;理解了原理,你可以按需要在质量与开销之间自己取舍。

案例 4.5白噪声、Value、Gradient、Simplex 并排对比进阶单独打开 ↗

目标:用同一个 Shader、同样的缩放,把四种噪声并排画出来,叠加整数格线,直观看到它们的区别。

Assets/Cookbook/Ch04/C04_05_Noise.shader(节选:select)
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;
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,一半的区域会超出范围。

图 4-10缩放均为 6,橙线是整数格线。从左到右:哈希白噪声(每格一个值);Value Noise(亮暗块常以格点为中心,能看出网格感);Gradient Noise(格点处都是中间灰,起伏在格内,更自然);Simplex Noise(三角形网格,特征尺寸比同缩放的方格噪声小,且没有轴向偏好)
线性空间下的灰度本书工程使用线性色彩空间,Shader 输出的 0.5 在屏幕上显示为约 73% 亮度的浅灰,而不是“中灰”。所以直接显示噪声值时,画面会整体偏亮、对比偏低。本章把噪声值作为 lerp 的权重在两种颜色之间混合,颜色属性本身会被正确转换;更系统的色彩空间讨论在第 9 章和第 13 章。

案例 4.6Voronoi 细胞进阶单独打开 ↗

目标:生成 Voronoi(又称 Worley、细胞噪声)图案,分别展示“到最近点的距离”“细胞边界”和“按细胞上色的鹅卵石”。它是石板路、龟裂土地、细胞、鳞片、第 18 章护盾、第 19 章水下焦散的基础。

pF1F2● 每格一个随机特征点(哈希决定位置)蓝框:p 所在格及 8 个邻居 = 搜索范围F1:到最近特征点的距离F2:到次近特征点的距离色块:离哪个特征点最近 = 一个细胞F2 − F1 ≈ 0 的地方就是细胞边界
图 4-11Voronoi 的构造(细胞边界按真实几何绘制)。每个虚线格子里有一个随机特征点;查询点 p 只需在蓝框的 3×3 邻域里寻找最近点(F1)与次近点(F2)

做法是 4.3 节“编号 + 局部坐标”结构的延伸:每个格子用哈希决定一个特征点的位置,平面上每个点都属于离它最近的那个特征点,形成一个个凸多边形细胞。由于特征点留在自己的格子里,最近的点一定在当前格及其邻居之中,不需要遍历所有点。

Assets/Cookbook/Ch04/ProceduralLib.hlsl(节选:voronoi)
// 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 介绍过的两遍算法)是:先找到最近点,再对周围每个点求当前位置到“两点连线的中垂线”的距离,取最小值。中垂线正是两个细胞的分界线,这个距离才是真正的边界距离。
Assets/Cookbook/Ch04/C04_06_Voronoi.shader(节选:shade)
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));
}
图 4-12左:F1 距离(加了等高线,黑点是特征点);中:用 F2 − F1 画边界,注意右上方和中部有几处两个特征点挨得很近,边界糊成了一片;右:用精确的边界距离描边,并按细胞编号哈希上色、按边界距离做中心亮边缘暗,边宽处处一致
性能图 4-12 右图的着色每像素要调用一次 Voronoi(25 次哈希)和一次 VoronoiBorder(9 + 25 次),合计约 60 次哈希与距离计算,在移动端整屏使用时开销可观。静态的 Voronoi 图案最好烘焙成贴图;需要动画时,考虑降低分辨率渲染到 RenderTexture,或只在必要的区域(比如护盾的命中点附近)计算。

案例 4.7fBm 云纹进阶单独打开 ↗

目标:用分形布朗运动(fractional Brownian motion,fBm)叠加多层噪声,做出一片有大轮廓也有小细节的云。

单层噪声只有一种“尺度”的起伏:要么是大团块,要么是小碎点。自然界的形状在各个尺度上都有细节——远看是山的轮廓,近看是岩石的起伏,再近看是砂砾。fBm 就是把同一种噪声以不同频率叠加:

fbm(p)=∑i=0n−1gaini·noise(lacunarityi·p)

每一层叫一个八度(octave)。lacunarity(间隙度)是相邻两层的频率比,通常取 2,即每层细节缩小一半;gain(增益,也叫 persistence)是振幅比,通常取 0.5,即每层强度减半。gain 越大,小尺度细节越突出,画面越“粗糙”;gain 越小越平滑。

Assets/Cookbook/Ch04/ProceduralLib.hlsl(节选:fbm)
// 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 弧度)可以打散这种对齐。

Assets/Cookbook/Ch04/C04_07_FbmClouds.shader(节选:clouds)
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 章的体积光会看到更正式的做法。

图 4-13同样的覆盖率与软边,八度数分别为 1、3、8。1 层只有软绵绵的大团块;3 层出现了云的轮廓;8 层边缘有了细碎的絮状细节
动起来_Wind 让采样坐标随时间平移,云就飘起来了。只平移整体会显得像一张贴图在滑动;让不同八度以不同速度移动(例如在 Fbm 的循环里给每层加上 _Time.y * 速度 * 层号),云会有“翻滚”的感觉。

案例 4.8域扭曲:大理石与流体纹理高阶单独打开 ↗

目标:用噪声扭曲噪声的输入坐标,从普通的 fBm 得到大理石、玛瑙、烟雾般的流动纹理。

域扭曲(Domain Warping)的思想很简单:与其对 f(p) 采样,不如对 f(p + h(p)) 采样,其中 h 是另一个噪声生成的二维位移场。原本规整的噪声被“揉皮”一样地拉扯,就出现了流体般的卷曲。这个技巧可以嵌套:

q=(F(p),F(p+o1)),r=(F(p+kq+o2),F(p+kq+o3)),f=F(p+kr)

F 是 fBm,o₁、o₂、o₃ 是任意的固定偏移(让两个分量互不相关),k 是扭曲强度。这一系列做法因 Inigo Quilez 的介绍而广为人知。

Assets/Cookbook/Ch04/C04_08_DomainWarp.shader(节选:warp)
// 第 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;
Assets/Cookbook/Ch04/C04_08_DomainWarp.shader(节选:color)
// 用 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;
// 用 f、|q|、r.x 三个量混色:位移场本身也是很好的“着色依据”
vec3 col = mix(_Color1.rgb, _Color2.rgb, saturate(f * f * 2.2));
col = mix(col, _Color4.rgb, saturate(length(q) * 0.9));
col = mix(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-14扭曲层数 0、1、2(强度 4,5 个八度)。0 层就是普通的 fBm;1 层开始出现流动的卷曲;2 层像抛光的玛瑙或大理石切面
贵两层扭曲要调用 5 次 fBm(q 两次、r 两次、最终一次),每次 5 个八度,一共 25 次 Gradient Noise、100 次哈希,每个像素都要算。这样的 Shader 适合离线生成贴图、做加载界面背景或低分辨率的特效层;用作大面积的实时材质前,请先在目标机型上测试。

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)。
d d < 0黑:d = 0 橙:d = 20、40(圆角越来越大)蓝虚线:d = −20 矩形 SDF(中心在原点,半宽高 b) d = abs(p) − b 外部:length(max(d, 0)) 内部:max(d.x, d.y)(≤ 0) abs 把四个象限折叠成一个, 只需考虑右上角;角区外的点 到角点的距离就是欧氏长度
图 4-15矩形的距离场。外部的等值线是圆角矩形(离角点等距的点构成圆弧),内部的等值线是缩小的直角矩形
Assets/Cookbook/Ch04/ProceduralLib.hlsl(节选:sdf)
// ---------------------------------------------------------------- 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 图标:定位图钉,带描边、发光与投影进阶单独打开 ↗

目标:只用圆、圆角矩形和布尔运算拼出一个“定位图钉”图标,再用距离场一次性做出描边、外发光、内部明暗和软投影。

Assets/Cookbook/Ch04/C04_09_SDFIcon.shader(节选:pin)
// 图钉 = 圆(头) ∪ 旋转 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 后轮廓立刻光滑了。平滑并集适合融合有夹角的形状,对本来就相切的形状只会帮倒忙。
Assets/Cookbook/Ch04/C04_09_SDFIcon.shader(节选:field)
// 把距离场画出来:外部暖色、内部冷色,等高线间隔 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 第一格里,图钉内部的等高线在方块和圆的交界处出现了一个折角,这就是布尔运算在内部只给出“下界”的直观体现。

Assets/Cookbook/Ch04/C04_09_SDFIcon.shader(节选:booleans)
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));
}
Assets/Cookbook/Ch04/C04_09_SDFIcon.shader(节选:styled)
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-16左一:图钉的距离场可视化(白线为边界,等高线间隔 0.05);左二:圆与旋转方块的四种布尔运算——并集、差集(圆挖去方块)、交集、平滑并集(k=0.25);右二:纯色填充;右一:投影 + 外发光 + 内部明暗 + 白色描边

案例 4.10Bayer 有序抖动进阶单独打开 ↗

目标:只用两种(或少数几种)颜色表现连续的渐变,理解有序抖动(Ordered Dithering)的原理与 Bayer 矩阵的构造。抖动在实时渲染里用途很广:复古像素风格的后处理、抖动透明(第 16 章)、LOD 交叉淡化(URP 的 LOD_FADE_CROSSFADE)、给体积光的步进加随机起点以消除条带(第 28 章)、以及在 8 位输出前消除色带。

把灰度量化成黑白最简单的方法是阈值:大于 0.5 的变白,否则变黑。结果是大块的纯黑纯白,渐变信息全丢了(图 4-18 左一)。抖动的思路是让每个像素用不同的阈值:在一小块区域里,阈值均匀分布在 0~1 之间,于是灰度为 0.25 的区域恰好有约四分之一的像素超过阈值、被点亮,远看平均亮度就是 0.25。

0821012414631119157135Bayer 4×4:阈值 = (M + 0.5) / 16输入 0.25亮 4/16输入 0.5亮 8/16输入 0.75亮 12/16输入灰度 > 阈值 的像素点亮(2 色量化)
图 4-17左:4×4 Bayer 矩阵,数字是阈值的排序,颜色越深阈值越大;右:输入为 0.25、0.5、0.75 的平坦区域被点亮的像素,分别是 4、8、12 个,分布均匀

阈值怎么排列决定了抖动的质感。Bayer 矩阵的特点是相邻阈值尽可能不同,点亮的像素在空间上尽量分散,所以图案是均匀的网点而不是成团的斑块。它可以递归构造:

M2=[0231],M2n=[4Mn4Mn+24Mn+34Mn+1],threshold=M+0.5n2

常见做法是把 4×4 或 8×8 矩阵写成常量数组查表。我们换一个角度:观察递推式可以发现,像素坐标的最低位决定了它在最小 2×2 块里的位置,而这个位置贡献的是最高的权重(4 的最高次幂)。所以只要逐位取出 x、y,按 2×2 的规则 2·(x⊕y) + y 算出每一级的值,再按“低位高权”拼起来即可,任意尺寸都是同一段代码:

Assets/Cookbook/Ch04/ProceduralLib.hlsl(节选:bayer)
// ---------------------------------------------------------------- 有序抖动: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 矩阵第一行第二个数一致。

Assets/Cookbook/Ch04/C04_10_BayerDither.shader(节选:dither)
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;
uvec2 pixel = uvec2(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 个像素。

图 4-18“原图”是左右灰阶渐变 + 一个假光照的球。左一:固定阈值 0.5,两色;左二:Bayer 4×4,两色;右二:Bayer 8×8,两色,可表现的灰阶更多(64 级),网点更细腻;右一:Bayer 4×4,四级颜色,球的明暗已经相当平滑
Bayer 之外Bayer 抖动的规则网格在大面积平坦区域会显出明显的“十字纹”。其他常见的选择有:交错梯度噪声(Interleaved Gradient Noise,core 包 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 矩阵让阈值在空间上尽量分散,可按位递推计算。

练习

  1. 在案例 4.2 中加入第四种图案“砖墙”:奇数行错开半格,砖缝宽度可调,每块砖用格子编号的哈希值稍微改变颜色。
  2. 修改案例 4.3 的环形进度条,让它由 12 个分离的圆角小段组成(提示:在角度方向 frac(a01 * 12) 得到段内坐标,再在“段内角度 × 半径”的局部坐标系里画圆角矩形)。
  3. 在案例 4.7 中把 GradientNoise 换成 ValueNoise 和 SimplexNoise,比较云的形态;再尝试“ridged fBm”:每层用 1 − abs(noise) 代替 noise,看看山脊状的纹理。
  4. 为案例 4.9 的图标增加“按下”动画:一个 _Press 参数让图标整体收缩 0.02、投影偏移减小、发光增强,全部只改 d 和几个系数。
  5. 把案例 4.10 改成屏幕空间版本:用 input.positionCS.xy 计算 Bayer 阈值,套在第 3 章案例 3.8 的 Shader 上,用 clip(alpha − threshold) 做一个“抖动半透明”,并与第 16 章的做法对照。