PHASE 1 · 用数学作画

1.4色彩与动画

IQ 调色板、HSV 与时间缓动

形状你已经会雕了,但一幅 Shader 作品是「高级感拉满」还是「一眼廉价」,八成取决于两件事:颜色怎么选运动怎么缓。好消息是,这两件事各有一个万能公式,这一章就把它们讲透。

1别再 RGB 瞎调:IQ 调色板

直接在两个 RGB 颜色之间 mix,中段经常会路过一片死气沉沉的灰紫色——因为 RGB 空间里的直线会穿过低饱和区。Inigo Quilez 给出了一个只有一行、却能生成无穷种和谐渐变的公式,整个 Shadertoy 社区用它上色:

GLSL · IQ 余弦调色板
// t 是 0~1 的参数(位置、时间、距离都行)
// a=基准色, b=振幅, c=频率, d=相位 —— 四个 vec3 定义一整套配色
vec3 palette(float t, vec3 a, vec3 b, vec3 c, vec3 d) {
    return a + b * cos(6.28318 * (c * t + d));
}

// 最常用的一组参数("彩虹但不刺眼"):
vec3 col = palette(t, vec3(0.5), vec3(0.5), vec3(1.0), vec3(0.0, 0.33, 0.67));

原理:让 R、G、B 各自沿一条余弦波起伏,三条波的相位(d)错开,颜色就在色轮上流畅地转,永不塌成灰。改 d 换色系、改 c 控制色彩循环快慢、b 调饱和、a 调明暗——比调 RGB 直觉多了:

2HSV:按人类直觉选色

另一条路是 HSV(色相 Hue / 饱和度 Saturation / 明度 Value):「要一个更亮一点的蓝」这种人话,在 HSV 里就是改一个分量。GLSL 没有内置转换,这个函数值得放进你的代码片段库:

GLSL · HSV → RGB(社区标准实现)
vec3 hsv2rgb(vec3 c) {
    vec3 rgb = clamp(abs(mod(c.x * 6.0 + vec3(0.0, 4.0, 2.0), 6.0) - 3.0) - 1.0,
                     0.0, 1.0);
    rgb = rgb * rgb * (3.0 - 2.0 * rgb);          // 平滑一下过渡
    return c.z * mix(vec3(1.0), rgb, c.y);
}

// 例:色相环 —— 角度当色相
vec3 col = hsv2rgb(vec3(atan(p.y, p.x) / 6.28318 + 0.5, 0.8, 1.0));

经验法则:做「彩虹流动」用 IQ 调色板,做「精确指定某个颜色」用 HSV,做「两色过渡」直接 mix 但两端颜色选饱和一点。三招够用一辈子。

3伽马:为什么我的渐变发灰

一个五分钟就能懂、但 90% 教程不讲的问题:你在 Shader 里算出的 0.5,显示器并不会显示成「一半亮」。屏幕的物理亮度约等于数值的 2.2 次方(这叫伽马),0.5 实际显示成约 0.22 的亮度——所以线性渐变看起来「暗部糊成一团」。

GLSL · 输出前做伽马校正
// 所有光照/混合按线性算,最后一步再校正
color = pow(color, vec3(1.0 / 2.2));   // 线性 → sRGB,约等于 sqrt
fragColor = vec4(color, 1.0);

不是每个效果都需要它(纯图形玩票可以不管,处理照片时输入已经是 sRGB 更要小心别双重校正),但当你觉得「发光太死、渐变发灰、暗部没层次」时,第一件事就是试试输出前加这行 pow。很多 Shadertoy 作品结尾那行神秘的 sqrt(col) 就是它的快捷近似。

4时间的形状:缓动

动画的一切来自 uTime,但直接用时间的动画都很难看——匀速运动毫无生命力。Android 里你靠 Interpolator/Easing 解决,Shader 里同一件事叫缓动函数:把 0~1 的线性进度,弯成有加速减速的曲线。常用的几条:

GLSL · 缓动词典
float t = fract(uTime * 0.5);           // 0~1 循环进度,先拿到"线性时间"

float easeInOut = t * t * (3.0 - 2.0 * t);      // = smoothstep(0.,1.,t)
float easeIn    = t * t * t;                     // 起步慢,适合蓄力
float easeOut   = 1.0 - pow(1.0 - t, 3.0);      // 收尾慢,适合入场
float bounce    = sin(t * 3.14159);              // 0→1→0,去而复返
float elastic   = 1.0 - pow(1.0 - t, 3.0) * cos(t * 12.0); // 弹性过冲

5循环动画与相位错开

最后两个让动画「活起来」的惯用手法:

  • 完美循环。录屏、做壁纸都需要动画首尾相接。诀窍:一切时间参数都走 sin/cos(uTime)fract(uTime/T),保证 T 秒后所有状态精确归位。顺带解决 0.3 章的精度坑——uTime 无限增大时 mediump 会炸,先 mod(uTime, 6.28318 * n) 就安全了。
  • 相位错开(stagger)。一群东西同步动 = 机械;每个元素的时间加上一点由位置/编号决定的偏移 = 生命。1.3 章圆点阵的波浪感,正是 sin(id.x + id.y - uTime) 里那个 id 造成的相位差。Compose 里做列表的 staggered 入场动画,同一个思想。
Android 视角 分工

实战中动画进度不一定要在 Shader 里算:更常见的架构是 Compose 的 animateFloatAsState / Animatable 算好带缓动的 0~1 进度,作为 uniform 传进 Shader(3.5 章的转场就这么做)。Shader 管「像素怎么变」,动画框架管「进度怎么走」——各干擅长的事,还能白嫖弹簧物理。

本章小结

  • IQ 调色板 a + b·cos(2π(c·t+d)):一行生成不塌灰的流动配色,改相位 d 换色系。
  • HSV 适合「按人话选色」,hsv2rgb 存进片段库;两色 mix 时两端选饱和色。
  • 渐变发灰、发光发死 → 输出前 pow(color, vec3(1.0/2.2)) 伽马校正。
  • 缓动 = 把线性时间弯成曲线;smoothstep 是万金油,pow 族做 in/out,cos 叠乘做弹性。
  • 循环动画用 sin/fract 保证归位;相位按位置错开,群体动画立刻有生命。

动手练习

  1. 给 1.3 章的极坐标玫瑰换上 IQ 调色板:用 palette(r + uTime*0.1, …) 按半径上色,做一朵会流光的花。
  2. 在缓动 Demo 里加第五个球,实现「回弹」:目标是冲过头再弹回来(提示:elastic 那条已经很接近,调 cos 的频率与衰减)。
  3. 做一个「呼吸灯」壁纸:全屏用调色板缓慢流动,叠加一个 smoothstep 渐晕,并保证 10 秒完美循环(检查所有时间项的周期)。