着色器伪随机:可以重复的混乱
Shader Pseudorandom
代码里哪来的随机
上一课的图案全是规则:每一格都按同样的规矩画。这一课要往里加一点熵。随机是熵最极端的样子,可代码本身是完全确定的,同样的输入总会算出同样的结果。在这样的环境里,怎么造出「看起来很乱」的数?
把正弦波剁碎
从一个熟悉的函数出发:fract(sin(x))。sin 在 −1 到 1 之间起伏,fract 只留下小数部分,结果全落在 0 到 1 之间。
现在把 sin(x) 乘上一个越来越大的数。拖动滑块,一个零一个零地加上去:
直接改上面的表达式就能重画:x 是 0 到 1 的横坐标,可以用 sin、pow、smoothstep 等函数。
乘上的数越大,sin 的一点点变化在小数位上被放得越大,平滑的正弦就被撕成了毛刺。加到 100000.0 时,就得到了书里 rand() 的骨架:
float rand(float x) {
return fract(sin(x) * 100000.0);
}
确定性:同一个输入,永远同一个输出
这个 rand 和编程语言里常见的 Math.random() 有一个根本区别:
着色器里的 rand(x) | 常见的 Math.random() | |
|---|---|---|
| 确定性 | 是:rand(1.0) 永远返回同一个数 | 否:每次调用都可能不同 |
| 典型用途 | 程序纹理、给格子分配属性、可复现的生成 | 游戏逻辑里的抽奖、非图形的蒙特卡洛 |
它够不够「随机」?
用随机数很难拿捏:它有时太乱,有时又不够随机。书里把 rand(x) 画成曲线后指出两个痕迹:
- 在 ±1.5707(也就是 ±π/2)附近,能看出正弦波峰的影子。那里是
sin的最大值和最小值,曲线在波峰处最平缓,乘上大数之后变化也最慢,结构就露了出来。 - 数值的分布在中间比两端略密一些。
Pixelero 写过一篇文章,讨论怎么用塑形函数改变随机数的分布。下面把 600 个随机数分进 24 个桶里,柱子越高,说明落在这一段的数越多。拖动「分布」滑块,换几种塑形方式:
也可以直接改右边的代码:停手约 0.3 秒会自动重新运行;改坏了点「还原代码」。
二维随机:先压成一个数
想对 st 的 x 和 y 都随机,就得先把 vec2 变成一个 float。常用的办法是和一个常数向量做点积:dot(st, vec2(12.9898, 78.233))。两个向量的朝向越接近,点积越大,不同的位置就得到了不同的数,再交给一维的老办法。
也可以直接改右边的代码:停手约 0.3 秒会自动重新运行;改坏了点「还原代码」。
用格子驯服雪花
二维随机很像电视雪花,太碎了,很难直接拿来构图。办法是请出上一课的格子:
- 把空间放大,比如乘以 10;
i = floor(st)取出格子的整数坐标,f = fract(st)留下格子里的局部坐标;- 用
rand(i)给整格分配一个随机值。因为函数是确定的,同一格里所有像素算出来的都是同一个值; - 再用
f在格子里画线、画形状。
也可以直接改右边的代码:停手约 0.3 秒会自动重新运行;改坏了点「还原代码」。
一行 BASIC 的迷宫
有一句很有名的 BASIC 程序:10 PRINT CHR$(205.5+RND(1)); : GOTO 10。它每次随机打印 ╱ 或 ╲,满屏的斜线就拼成了一座迷宫。
着色器版本几乎一模一样:每一格掷一次硬币,正面画 /,反面画 \。这就是上一课的 Truchet 砖,只是朝向不再按规则挑,而是交给了随机:
也可以直接改右边的代码:停手约 0.3 秒会自动重新运行;改坏了点「还原代码」。
随机的大师:池田亮司
日本电子音乐人、视觉艺术家池田亮司 (Ryoji Ikeda) 把随机用到了极致。在他的作品里,随机不是刺眼的混乱,而像是一面镜子,映出技术文化的复杂。
书里受他启发出了几道练习:做几行随机的格子,相邻的行朝相反方向移动,只显示比较亮的格子;让每行的速度随时间起伏;用鼠标控制显示的门槛。下面是一个版本:
也可以直接改右边的代码:停手约 0.3 秒会自动重新运行;改坏了点「还原代码」。
随机的局限
纯随机很难做出自然的效果。看看雨落下的纹路,或者一张股价走势图,它们都相当随机,却一点也不像这一课开头的那团毛刺。
原因在于:随机值之间毫无关联,而大多数自然现象都「记得」前一刻的状态。下一课的噪声就是为此而来的:它是平滑的、看起来自然的混乱。
在游戏里怎么用
取舍与常见的坑
小结与练习
- 着色器里的随机是确定性伪随机:
fract(sin(x) * 大数),同一输入永远同一输出。 - 二维随机先用
dot把vec2压成一个数,常数不同,图案也不同。 floor给整格分配同一个随机值,fract保留格内坐标:前者带来变化,后者保留秩序。
练习:把上一课的砖墙和这一课结合:每块砖的颜色深浅随机,并且随机挑出几块砖做成「破损」的样子,比如缺一个角或者颜色明显更暗。
片元着色器里没有真正的「每次调用都不同」的随机:rand(x) 必须是确定性伪随机——同一输入永远同一输出。常用手法是 fract(sin(x)*大数);二维则用 dot 把 vec2 压成标量再喂给同一套路。把空间 floor 成格子后,格子共享一个随机值,就能在秩序与混沌之间做生成设计。
为什么需要「可控的混沌」
图案章把可复用单元推到极致之后,《着色器之书》引入熵。随机是熵的极端表现,但代码环境看似可预测——如何在 GPU 上造出「看起来乱」的数?
一维:打碎正弦的小数部分
sin(x) 在 波动;fract 只留下小数部分,得到 正值。再把 sin 结果乘上很大的数(例如 100000.0),波形在小数尺度上被撕碎,肉眼再也认不出正弦,只剩伪随机毛刺:
y = fract(sin(x)*100000.0)
这就是书中 rand() 的典型实现骨架。
控制混沌:分布与确定性
直接用这种 rand 画曲线,会在正弦波峰附近(约 )看到结构痕迹;直方图也常中间更密、两端更稀。Pixelero 等讨论过用塑形函数改分布;重要区分是:
着色器 rand(x) | 典型 Math.random() | |
|---|---|---|
| 确定性 | 是:rand(1.0) 永远相同 | 否:每次调用可不同 |
| 用途 | 程序纹理、格子属性、可复现生成 | 游戏逻辑抽奖、非图形蒙特卡洛等 |
本库 伪随机数生成 从算法族与统计质量讲 PRNG;本页是 BoS 路径下的 GLSL 一招式:快、可哈希位置、适合片元。
二维:dot 压维
要对 st 的 、 都随机,先把 vec2 变成一个 float。dot(st, vec2(12.9898, 78.233)) 一类常数向量很常用:对齐程度不同则点积不同,再 fract(sin(...)*...)。改这两个常数会整体换一套噪声图案;接到 u_mouse / u_time 可直观感到「输入一变,整场确定性重映射」。
用格子驯服电视雪花
二维 rand(st) 很像电视雪花,难直接当构图材料。做法:
- 放大空间(如 ×10);
i = floor(st)得到格子整数坐标,f = fract(st)保留格内 局部坐标;- 用
rand(i)给整格一个常数随机值(确定性 → 格内所有像素相同); - 仍用
f在格内画线、形状或 Truchet。
整数部分提供「跨格变化」,小数部分提供「格内秩序」——前一章的 truchetPattern 可按格随机朝向,得到 10 PRINT 迷宫一类效果。
审美与局限(通向噪声)
Ryoji Ikeda 等工作展示:随机可以做成文化复杂度的镜子,而不只是刺眼噪声。但要模拟雨纹、股价曲线等「自然随机」时,纯 random() 往往失败——相邻样本彼此无关,而自然过程通常记得前一状态。下一站是平滑的 噪声函数。
工具箱:LYGIA generative 分区。
在游戏开发中的意义
- UI / VFX:格子抖动、损坏贴图、程序化碎屑。
- 可复现:同一种子(或同一世界坐标)在客户端与工具链得到同一图案。
- 性能:避免在片元里跑重型 PRNG;BoS 式哈希足够多数装饰用途。
原书问题
- ch.10「2D Random」称
dot()「returns a single float value between 0.0 and 1.0 depending on the alignment of two vectors」。一般点积无此值域约束(两任意向量的点积可为任意实数);后文示例依赖fract(sin(dot(...))*大数)才把结果压回 。本页不复述该值域断言。