Shader 世界里没有画笔,只有函数。所谓「画一个东西」,本质是构造一个函数:输入坐标,输出 0~1 之间的一个数——0 是「不在这」,1 是「在这」,中间是柔和的过渡。这类函数有个专门的名字:造型函数(Shaping Functions)。这一章的四个函数,就是你以后每天要用几十次的画笔。
1用数学代替 if
想把画面左半涂黑、右半涂白,CPU 思维会写 if (uv.x < 0.5)。0.1 章说过分支在 GPU 上代价高,但更深层的问题是:if 只能给出 0 或 1,给不出 0.37。而视觉效果的精髓恰恰在中间值——柔和的边缘、渐变的过渡、可调的混合比例。
所以 Shader 的套路是:先构造一个 0~1 的遮罩值(mask),再用它去混合两种颜色。分支消失了,取而代之的是一条连续曲线。这个思路转换是 Phase 1 最重要的一步:
// CPU 思维(能跑,但边缘永远是硬的,而且不可调):
if (uv.x < 0.5) { color = black; } else { color = white; }
// Shader 思维:mask 是坐标的函数,颜色是 mask 的函数
float mask = smoothstep(0.45, 0.55, uv.x); // 0~1 连续过渡
vec3 color = mix(black, white, mask); // 按比例混合
2step 与 smoothstep
step(edge, x) 是最简单的造型函数:x 小于阈值返回 0,否则返回 1——一道垂直的悬崖。smoothstep(e0, e1, x) 是它的柔和版:在 e0 到 e1 之间用一条 S 形曲线(Hermite 插值)平滑爬升,两端之外钳制为 0 和 1。看曲线:
S 形曲线在两端的斜率是 0,这意味着过渡的起点和终点都「软着陆」——这正是它看起来比线性渐变高级的原因。两个必须知道的用法:
- 参数反转 = 曲线翻转。
smoothstep(0.75, 0.25, x)(大的在前)得到从 1 降到 0 的曲线,画「中心亮、边缘暗」时非常顺手,不用再写1.0 - …。 - 窄窗口 = 抗锯齿。把过渡窗口收窄到一两个像素宽,比如
smoothstep(r, r - 0.005, d),得到的就是「看起来是硬边、放大看是柔边」的完美抗锯齿轮廓。硬 step 的锯齿和它一比就露馅了。
3mix:万物皆可插值
mix(a, b, t) 做线性插值:a * (1.0 - t) + b * t。t 是 0 取 a,t 是 1 取 b,中间按比例调和。它的威力在于 a、b 可以是任何东西:两种颜色、两个坐标、两张图(2.1 章)、两个完整效果的结果。「造 mask → mix 混合」是本书出现频率最高的代码对。
vec3 color = mix(night, day, dayness); // 混颜色:昼夜过渡
vec2 uv2 = mix(uv, distortedUV, strength); // 混坐标:效果强度可调
vec4 frame = mix(sceneA, sceneB, progress); // 混画面:转场动画(3.5 章)
顺带认识它的亲戚 clamp(x, lo, hi):把值钳进区间。clamp(x, 0.0, 1.0) 太常用了,行话叫 saturate——任何要当 mask 用的值,心里没底就先 clamp 一下,负数颜色和大于 1 的 mask 都是常见 bug 源。
4fract:重复的引擎
fract(x) 返回小数部分:x - floor(x)。曲线是无限重复的锯齿波——就是这个不起眼的锯齿,撑起了 Shader 世界里一切「重复」:条纹、网格、瓷砖、阵列。
核心思想:fract(x * n) 把 0~1 的坐标轴切成 n 段,每段内部都重新经历一次 0→1。于是「在一个格子里画的任何东西」自动复制到所有格子。它的孪生兄弟 floor(x * n) 则告诉你「现在是第几个格子」——一个管格子内坐标,一个管格子编号,2.2 章的马赛克全家桶就靠这对兄弟。
以后见到 fract(坐标 * n),脑子里立刻翻译成「空间被切成了 n 份重复」;见到 fract(uTime * n),翻译成「时间上每 1/n 秒循环一次」。fract 作用在什么轴上,就在什么轴上造重复。
5组合拳:条纹、脉冲与渐晕
四个函数各自简单,组合起来就是完整的图案语言。下面这个 Demo 同时用到了全部四个,一行行读注释,确认每一步你都能在脑内画出它的曲线:
注意第②步的小技巧:两个方向相反的 smoothstep 相乘,得到一个「凸起」——先升后降,像一座小山。这是从锯齿波里雕出「一根有宽度的线」的标准手法,以后画扫描线、画能量条、画声波都用它。
| 函数 | 曲线形状 | 一句话职责 |
|---|---|---|
| step(e, x) | 悬崖 | 硬切割,划分区域 |
| smoothstep(a, b, x) | S 坡 | 柔边、抗锯齿、渐晕、一切过渡 |
| mix(a, b, t) | 直线 | 按 mask 混合两个任意值 |
| fract(x) | 锯齿波 | 制造空间/时间上的重复 |
| clamp(x, 0.0, 1.0) | 压平两端 | 把任何值驯服成合法 mask |
这套「0~1 之间做插值」的思维你其实很熟:mix 就是 lerp,smoothstep 就是自带 ease-in-out 的 Interpolator,mask 就是 animatedFraction。区别只是:动画框架沿时间插值,Shader 沿空间插值——把「进度」这个概念从时间轴搬到坐标轴,你就已经会写 Shader 了。
本章小结
- Shader 里「画东西」= 构造 0~1 的 mask 函数,再用
mix上色;if 换成连续曲线。 step硬切,smoothstep柔切;参数反转即曲线翻转;窗口收窄到像素级就是抗锯齿。fract(x * n)制造 n 份重复,floor(x * n)给出格子编号,一对兄弟一起用。- 两个反向 smoothstep 相乘 = 一根有宽度的软线(凸起),图案雕刻的基本刀法。
- 心里没底就
clamp(x, 0.0, 1.0),驯服一切 mask。
动手练习
- 用「凸起」刀法画一个同心圆环:对
length(p)应用两个反向 smoothstep,做出一个空心圆环,再叠加fract让它变成一组等距同心环。 - 做斑马纹进度条:横向条纹 + 用 uTime 平移,再用 step(uv.x, 0.7) 把右侧 30% 遮成灰色,像一个加载中的进度条。
- 在组合拳 Demo 里,把条纹方向从
p.x + p.y改成只用length(p)——你会得到什么?先预测再运行。