本节摘要:片元着色器的高频函数其实不多——mix、step、smoothstep、clamp、dot、pow,外加感知屏幕像素尺寸的 fwidth,就构成了特效写作的基本盘。本节逐个讲清这些函数的脾气与组合套路,最后用哈希函数跨进程序化噪声的大门。
mix(a, b, k) 在 k 从 0 走到 1 时,输出从 a 滑向 b,是插值的直接化身。step(edge, x) 是硬边界:x 过线输出 1,不过输出 0。smoothstep(e0, e1, x) 是软边界:在区间内平滑爬坡,区间外钉死在两端。三个函数单独看都平淡无奇,组合起来才是正戏。
// 垂直渐变:UV 的 y 越大越亮 float g = mix(0.1, 0.9, vUv.y); // 硬边圆形:边缘锯齿明显,适合像素风 float d = length(vUv - vec2(0.5)); float hardCircle = step(0.3, 0.5 - d); // 距离小于 0.2 时为 1 // 软边圆形:边缘自然过渡 float softCircle = 1.0 - smoothstep(0.18, 0.22, d);
对照两个圆的写法能看出规律:硬边用 step 一刀切,软边用 smoothstep 给出过渡带。过渡带的位置与宽度就是全部美术变量——起点终点越近越锐利,越远越朦胧。绝大多数"画一个形状"的需求,本质都是"构造一个距离场,再用 smoothstep 切边",距离场(SDF)思维从这里发芽,第 7 章后处理里的软边遮罩同样靠它。
还有一招必学:用 smoothstep 的输出当 mix 的权重,就能把"形状内外"翻译成"颜色 A/B":
vec3 col = mix(colBg, colShape, softCircle); // 权重即遮罩
💡 关键直觉:把一切图案理解为"权重的编排"。形状是 0 到 1 的权重场,颜色是权重与色板的插值结果,动画是权重随时间的偏移。想清楚权重来源,代码自然结构清晰。
软边圆形的过渡带宽度 0.04 是拍脑袋定的:屏幕分辨率一变,同样的 0.04 可能糊成一团也可能锯齿重现。出路是让过渡带宽度自动匹配屏幕像素——fwidth 就是干这个的内置函数:它返回某个值在相邻像素间的变化量(屏幕空间导数),一行代码获得"当前像素尺度"。
float circleAA(vec2 uv, vec2 center, float radius) { float d = length(uv - center); float w = fwidth(d) * 1.5; // 边缘约 1.5 个像素宽 return 1.0 - smoothstep(radius - w, radius + w, d); }
这版圆形在任意分辨率、任意缩放下边缘都干净。fwidth 的实现依赖相邻片元求差,属于"邻域感知"的少数派指令——这也解释了它不能出现在某些控制流内部的限制(发散分支里导数无定义)。同类还有 dFdx、dFdy 两个轴向导数,fwidth 就是两者的绝对值之和,屏幕空间法线、边缘检测都会用到。
clamp(x, lo, hi) 是数值的保险丝:把越界值按回区间。颜色计算前后各夹一次,能挡住绝大多数 NaN 与过曝的蔓延。
dot(a, b) 点积在片元侧有两副面孔:几何意义上是夹角余弦(乘上长度后),第 6 章光照的漫反射就是它;代数意义上是加权求和器,dot(gray, vec3(0.299, 0.587, 0.114)) 一行完成 RGB 转灰度。
pow(x, y) 是形状雕刻刀:对渐变求幂可以拉对比、压暗部。gamma 校正的 pow(col, vec3(1.0/2.2)) 就是它的招牌应用。注意 pow 的第一个参数必须非负,负数进 pow 在不同驱动上行为不一,先 clamp 再 pow 是稳妥写法。
// 三件套联手的例子:中心亮斑 float d = length(vUv - vec2(0.5)); float spot = pow(clamp(1.0 - d * 2.0, 0.0, 1.0), 3.0); vec3 col = baseColor + highlightColor * spot;
程序化图案的下一步是"看起来自然"的随机。GPU 没有真随机,常用的是哈希——把输入坐标打乱到看起来毫无规律:
float hash(vec2 p) { return fract(sin(dot(p, vec2(127.1, 311.7))) * 43758.5453); } // 用法:格点取随机灰度 float r = hash(floor(vUv * 8.0)); // 8x8 的随机噪点马赛克
这个函数的数学外壳是"大数乘法后取小数",直觉是"输入差一点,输出天差地别"。它返回 0 到 1 的伪随机值,同样的输入永远得到同样的输出——这保证了图案在每帧之间稳定不闪。
哈希直接上屏是白色噪点,真正的噪声要再走一步:格点随机值之间做平滑插值(value noise),多个频率的噪声叠加(分形),就能得到云、火焰、大理石纹的基底。全套展开属于另一门功课,但大门已经打开——第 5 章的纹理采样会告诉你,噪声图同样可以作为数据烘焙成贴图喂回管线,两条路线最终汇合。
把本节函数串成一个完整的小作品——进度环,游戏加载界面与技能冷却的经典元素:
// vUv 在 0 到 1 之间,uProgress 是进度 0 到 1 void main() { vec2 p = vUv - 0.5; // 中心化 float r = length(p) * 2.0; // 极径:0 到 1 float ang = atan(p.y, p.x); // 极角:-pi 到 pi float w = fwidth(ang) * 1.5; // 角度方向的抗锯齿宽度 float ring = smoothstep(0.86, 0.86 + w, r) // 外缘切 * (1.0 - smoothstep(0.70, 0.70 + w, r)); // 内缘留:两刀夹出环带 // 角度归一到 0 到 1,起点旋转到 12 点钟方向(极角 -pi/2 处) float t = fract((ang + 3.14159) / 6.28318 + 0.25); float sweep = step(t, uProgress); // 已走过扇区为 1 vec3 col = mix(vec3(0.12), vec3(0.95, 0.6, 0.15), ring * sweep); FragColor = vec4(col, 1.0); }
拆解一下配方:极径方向用两道 smoothstep 夹出环带(外切内留),极角方向归一化后与进度直接比较截出扇区,颜色用环带与扇区两个掩码相乘选出"已点亮部分"。fract 保证角度在正负 pi 跳变处平滑回卷,起点偏移 0.25 把进度零点转到正上方。这个例子里的每一招——中心化、极坐标、双刃 smoothstep、fwidth 抗锯齿——都是本节正文里单独讲过的,组合起来就是一个能进项目的组件。
函数武器就位。下一章引入真正的素材大户——纹理与采样,让数学与贴图合流。