Ocean Shader I: Noise, Height Fields & Intersection
画面是 GPU 算出来的。CPU 和 GPU 的设计哲学根本不同:
| CPU | GPU | |
|---|---|---|
| 核心数 | 几个到几十个 | 数千个(流处理器) |
| 单核复杂度 | 高(乱序执行、分支预测、大缓存) | 低(顺序执行、小缓存) |
| 适合任务 | 复杂串行逻辑、低延迟 | 大规模数据并行、高吞吐 |
| 内存带宽 | 通用 DRAM | 高带宽显存(GDDR/HBM) |
一张 1080p 画面有 个像素,每个像素要独立算颜色——这是** embarrassingly parallel**(天然并行)任务,正是 GPU 的拿手好戏。
GPU 的核心执行模式是 SIMD(Single Instruction, Multiple Data):一个指令同时对一组数据(如 32 个线程组成的 warp/wavefront)执行。所有线程跑同一份代码,只是处理不同数据:
指令: load | add | mul | store
线程 0: p[0] | ... | ... | out[0]
线程 1: p[1] | ... | ... | out[1]
...
线程 31: p[31] | ... | ... | out[31]
↑ 所有线程同时执行同一指令,处理不同像素
这就是为什么 shader 没有”for 每个像素”——GPU 硬件调度让成千上万个线程(每个对应一个像素)并行跑同一份 shader 代码,每个线程用自己的 (x, y) 坐标算自己的结果。
如果 shader 里有 if (cond) A else B,warp 内的 32 个线程可能有的走 A 有的走 B(条件不同)——这就是分支发散(branch divergence)。GPU 的处理方式是串行执行两条分支:先让走 A 的线程执行 A(其他线程闲置),再让走 B 的执行 B。性能直接减半。
所以 shader 优化极力避免分支:用 mix、step、clamp、min/max 等无分支运算代替 if。这是为什么 SDF 的 box SDF(Raymarching B06)用 length(max(q,0)) + min(max(q.x,q.y),0) 而不是 if (在外) ... else ...。
Shader 是 GPU 执行的程序,用 GLSL(OpenGL)、HLSL(DirectX)、MSL(Metal)等类 C 语言写。这片海用的是 fragment shader(B04),每个像素一次调用。
shader 的特点:
SDF 海面(B05)的本质是”给定坐标算颜色”,纯数学,无 mesh、无纹理——这种计算密集、无内存依赖的任务正是 GPU shader 的理想场景。200 万像素同时算海面函数,每帧几毫秒完成,这就是”几百行代码渲染整片海”的硬件基础。
Fragment shader(片段着色器,也叫像素着色器)是渲染管线的最后一段,任务只有一个:输入像素坐标,输出该像素颜色。
// fragment shader 的最小骨架
#version 330
out vec4 fragColor;
uniform vec2 resolution;
uniform float time;
void main() {
vec2 uv = gl_FragCoord.xy / resolution; // 像素坐标归一化
vec3 col = vec3(uv, 0.5); // 算颜色(这里只是渐变)
fragColor = vec4(col, 1.0); // 输出 RGBA
}
每个像素独立调用一次 main(),输入是 gl_FragCoord(像素坐标),输出写入帧缓冲。200 万像素 = 200 万次并行调用(B03 SIMD)。
C++ 宿主几乎不做几何处理,只画一个覆盖全屏的三角形。这个三角形经过光栅化,对覆盖的每个像素触发一次 fragment shader:
全屏三角形(3 个顶点)
↓ 光栅化
覆盖 2M 个像素
↓ 每像素触发
fragment shader 执行 × 2M 次(并行)
↓
帧缓冲(2M 个颜色)
↓ 显示
为什么用一个三角形而不是两个(矩形)?一个三角形的顶点 覆盖 ,屏幕 完全在内,光栅化裁掉超出的部分。比矩形少一个顶点,少一条共享边——Raymarching B17 详述这个 trick。
shader 的输入有两类:
gl_FragCoord(built-in):当前像素的坐标 ,每个像素不同。这是 shader 区分像素的唯一线索。resolution(窗口大小)、time(动画时间)、mouse(交互)。C++ 每帧通过 glUniform* 上传 uniform,shader 据此算动画(time 变化让画面动)和响应交互(mouse 控制相机)。
fragColor(或 out 变量)是写入帧缓冲的颜色,RGBA 四分量,每分量 。GPU 把它转成 8-bit 像素值显示。
注意:颜色计算在线性空间(光照物理正确),输出前要做 gamma 校正(Raymarching B18)——pow(col, 1/2.2)。不做会让画面偏暗。
每个像素的 shader 调用独立——无法直接读取相邻像素的 shader 结果。这是渲染管线的设计约束:
所以海面 shader 的每个像素只能用 gl_FragCoord、uniform、和自己算的中间值,不能问”旁边的像素是什么颜色”。
gl_FragCoord 单位是像素(不是归一化)。原点在左下角,y 朝上。要归一化得自己除 resolution。main() 不能有早期返回影响其他像素。每个像素独立返回,没有跨像素控制流。传统 3D 渲染里,水面是一张网格 mesh——成千上万个三角形,顶点逐帧动。但这片海一个三角形都没有,形状完全由一个数学函数定义:
给函数一个水平位置 ,它返回那里的水面高度 。整个海面就是函数图象 ——一个高度场(height field,B06)。
mesh 表示把曲面离散成有限个三角形:精度受限于三角形数量(多边形预算),细节多需要更多三角形(更贵)。函数表示解析定义,理论上无限精度:
| 表示 | 精度 | 内存 | 适合 |
|---|---|---|---|
| Mesh(三角形) | 离散,受多边形预算 | 存顶点 + 索引 | 离散建模物体(角色、建筑) |
| 函数(高度场/SDF) | 解析,理论无限 | 零(代码即几何) | 程序化、有机、可微场景 |
海面的”程序化”特性(每个像素独立算)天然适合函数表示:fragment shader 拿到像素坐标 → 算射线 → 用函数求交(B12)→ 算颜色。整个过程无需 mesh。
每个像素的 shader 执行:
1. gl_FragCoord → 像素坐标 → 相机射线方向 rd
2. rayMarch(ro, rd, f): 沿射线找 f(x,z) = p.y 的交点 p
3. 在 p 处算法线 n(高度梯度,B13)
4. 用 n 算光照(漫反射 + 高光 + 菲涅尔,下集)
5. 输出颜色
整个流程里 f 是核心——它定义了几何(高度)、法线(梯度)、间接定义了光照。改 f 就改海面,无需触碰渲染管线。
f 可以简单(如单个 sin 波)或复杂(多 octave 噪声叠加,B11)。函数越复杂,海面细节越丰富,但每个像素的求值越慢。这是精度 vs 性能的权衡,由 OCTAVES(B09)等参数控制。
f,若它有副作用或状态,画面会闪烁。“随机”要用 hash(B07)的确定性伪随机。“高度由水平位置决定”的曲面叫高度场(height field / height map)。数学上它是二元函数的图象:
想象海面是一张水平网格,每个网格点 上函数给出高度 ,把这些高度连起来就是起伏的水面。
高度场是函数图象(graph of function),关键性质是”每个 对应唯一高度”。这给出两个推论:
“一个位置一个高度”是函数图象的硬约束。卷折浪(plunging breaker,浪头翻卷盖下来)的浪头会出现同一 对应多个 (浪头悬在空中、下方还有水面)——这破坏了函数性质,高度场表示不了。
表示卷浪需要:
但对开阔海面的涌浪(spilling breaker,浪头圆滑不卷),高度场足够好,且极快(每像素一次函数求值)。这就是为什么 Seascape 和很多实时海洋渲染用高度场——视觉接近且性能远超体积方法。
球、平面等简单曲面有解析求交公式(如射线-球 smallpt1 B11)。但高度场 含多个 octave 噪声(B09 FBM + B10 sea_octave),方程 无法闭式求解。必须用数值迭代(B12 的二分式 raymarching)沿射线试探。
这是高度场的代价——求交比解析曲面慢,但场景复杂度(多 octave)让解析求解本就不可能。
| 表示 | 维度 | 卷浪 | 求交 |
|---|---|---|---|
| 高度场 | 2.5D | 不能 | raymarching 二分 |
| 体素网格 | 3D 离散 | 能 | 三线性采样 |
| SDF(体积) | 3D 解析 | 能 | Sphere Tracing |
Seascape 选高度场是因为海面以涌浪为主、追求性能。如果要做破碎浪(如近岸冲浪场景),通常转用 Gerstner 波叠加或粒子方法。
map 返回有符号距离不是高度本身。B11 里 map(p) = p.y - f(p.xz) 是 SDF 形式(正外负内),用于 raymarching;法线算法用的是 (高度),符号约定要区分。shader 里的随机数不能像 CPU 的 rand()——每次调用结果不同会让画面每帧闪烁。shader 需要确定性随机(deterministic):同一个输入坐标,永远返回同一个值。这就是 hash 函数。
float hash(vec2 p) {
float h = dot(p, vec2(127.1, 311.7)); // 点积魔法常数
return fract(sin(h) * 43758.5453); // sin 后放大取小数部分
}
原理: 函数在远离零点处变化剧烈。h = dot(p, magic) 把 2D 坐标映射到一个大数,sin(h) 在该尺度下对 p 的微小变化极敏感——相邻整数坐标的 值几乎不相关。再乘 43758.5453 放大,fract() 只取小数部分,结果落到 。
形式上:
为什么有效:
GPU 有硬件加速的整数 hash(PCG、wang hash),但 GLSL 的 float 实现要 workaround(早期 GLSL 不支持位运算)。sin hash 是兼容性最好的写法,几乎所有 GPU/版本都能跑,代价是 比位运算慢一点——但 demo 场景里够用。
127.1, 311.7, 43758.5453 这些”魔法常数”是经验值,通过实验选择让 hash 分布均匀(无明显图案)。选不好会出现规律性条纹。Patricio Gonzalez Vivo 的《The Book of Shaders》和 Inigo Quilez 的网站都收录了这套常数。
hash 是噪声的”原子”。如果 hash 分布不均(如某些值聚集),上层 value noise(B08)会显现图案。验证方法:把 hash(p) 直接作为灰度输出,应该看到均匀的雪花点;若有条纹或斑块说明常数要换。
mod 或更复杂混合可缓解。floor 到格点再 hash。直接用 hash(B07)画出来是雪花噪点——相邻像素值毫无关联,不是水面那种平滑起伏。Value noise 把离散的 hash 值”连”起来:只在整数格点放随机值,格点之间用插值过渡。
float valueNoise(vec2 p) {
vec2 i = floor(p); // 整数格点
vec2 f = fract(p); // 格内位置 [0,1)
// 四个角点的 hash
float a = hash(i + vec2(0,0));
float b = hash(i + vec2(1,0));
float c = hash(i + vec2(0,1));
float d = hash(i + vec2(1,1));
// 平滑插值权重
vec2 u = f * f * (3.0 - 2.0 * f); // smoothstep 权重
return mix(mix(a, b, u.x), mix(c, d, u.x), u.y);
}
朴素双线性插值 u = f(线性)在格点处导数不连续(C⁰),产生的噪声在格点边界有”折”——水面看起来有方块感。
u = f*f*(3-2*f) 是 smoothstep 函数,在 和 处导数为 0:
这让格点过渡处导数连续(C¹),噪声在格边界不再有折——视觉上平滑。更高级用 quintic Perlin 权重 (C² 连续,二阶导也连续),但 smoothstep 在视觉上已足够。
value noise 在格点放随机值,平滑连接后形成连绵的”丘陵”——值在 之间连续变化,峰谷位置随机但过渡平滑。这对应自然界很多现象(地形、云、海面)的统计特征:低频、平滑、随机。
对比 Perlin noise(在格点放随机梯度而非值):Perlin 视觉更”有机”(零交叉更自然),但 value noise 实现更简单,对海面足够。
float h = valueNoise(p * 4.0); // 频率 4:4 个格点/单位
p 乘频率 让噪声在空间上”压缩”或”拉伸”:
单一频率只能模拟一个尺度的起伏,真实海面是多尺度叠加(B09 FBM)。
floor 和 fract 必须配对:i = floor(p), f = fract(p) 保证 p = i + f,否则插值越界。u = f 会有方块感。务必用 smoothstep 或 quintic。hash(i + offset) 的 i 是整数坐标,不要传 p(浮点)——浮点抖动会让相邻像素 hash 跳变。单一频率的 value noise 只能画出平缓的”大包”,缺乏细节。真实海面在每个尺度上都有起伏——大涌浪(米级)、中波(分米级)、细涟漪(厘米级)。FBM(Fractional Brownian Motion,分形布朗运动)通过多层噪声叠加模拟这种多尺度结构。
float fbm(vec2 p) {
float v = 0.0; // 累加值
float a = 0.5; // 初始振幅
float f = 1.0; // 初始频率
for (int i = 0; i < OCTAVES; i++) { // 通常 4-8 层
v += a * noise(p * f);
f *= 2.0; // 频率翻倍(细节加倍)
a *= 0.5; // 振幅减半(贡献减半)
}
return v;
}
形式化:
每层频率 、振幅 。这就是倍频程(octave)结构——每加一层,细节密度翻倍但幅度减半。
自然界很多现象具有自相似性(self-similarity):放大看,结构和整体相似。海岸线、山脉、云、海面都是如此——这是分形(fractal)的标志。FBM 数学上构造的就是分形噪声:每层的形状是上一层的缩小拷贝,叠加后呈现多尺度细节。
统计学上,FBM 的功率谱密度遵循幂律(power law):
低频能量大(大尺度起伏),高频能量小(细节)。这与真实海面的海浪谱(wave spectrum,如 Pierson-Moskowitz、JONSWAP)同构——能量随频率按幂律衰减。所以 FBM 在视觉上”像海”,不是巧合,而是抓住了海浪的统计特征。
a *= 0.5(衰减率 0.5)不是唯一选择:
这就是控制海面”粗糙度”(roughness)的核心参数。Seascape shader 用 0.22 的衰减率(比 0.5 更陡),让海面以大浪为主、细节少——像开阔海洋的涌浪而非近岸碎浪。
#define OCTAVES 6
经验:近处需要多 octaves(细节可见),远处少 octaves(细节低于像素分辨率,奈奎斯特极限)。高级实现按距离自适应 octaves——近处 8 层、远处 3 层。
普通 value noise 起伏是”圆丘”——浪谷和浪峰都圆。真实海浪浪谷圆、浪峰尖(被风吹陡、重力拉平)。Seascape 的 sea_octave 函数通过两步变换把圆噪声变成尖波浪。
float sea_octave(vec2 uv) {
uv += noise(uv); // 加噪声扰动(避免规则)
vec2 wv = 1.0 - abs(sin(uv)); // 关键变换
// ...
}
abs(sin(x)) 的几何效果:
1.0 - abs(sin(x)) 翻转:V 形变成尖峰,圆处变成圆谷。这就是把平滑振荡变成尖峰序列的技巧。视觉上:浪谷(原 的峰)圆、浪峰(原 的零点)尖,符合真实海浪形状。
float choppy = clamp(choppy_param, 0.0, 1.0); // 锐化强度
vec2 wv = pow(1.0 - abs(sin(uv)), vec2(choppy));
// 返回 (wv.x + wv.y) * 0.5 之类
pow(基底, choppy) 进一步锐化:
choppy = 1:不变()。choppy > 1:基底 的部分被压低,尖峰更尖、更突出(风大、浪陡)。choppy < 1:基底被抬高,峰变圆(风小、浪缓)。这是控制海况(sea state)的关键参数——大风天 choppy 大,浪峰尖得快翻卷;无风天 choppy 小,浪圆缓。
这其实是 Gerstner 波(Gerstner wave)的廉价近似。Gerstner 波是真实海浪的物理模型——水质点做圆周运动,波峰处质点向前甩形成尖峰、波谷处堆积形成圆谷。完整 Gerstner 要解参数方程:
Seascape 用 abs(sin) + pow(choppy) 的解析技巧,在 fragment shader 里单步计算近似 Gerstner 的形状,避免了完整 Gerstner 的多次三角函数。这是实时渲染的典型”视觉等价”(visual equivalence)策略——不追求物理精确,追求视觉接近且计算便宜。
vec2 wv = pow(1.0 - abs(sin(uv)), vec2(choppy));
return dot(wv, vec2(0.5)); // 或加权
uv 是 2D(水平坐标),sin(uv) 对两个分量分别算,abs + pow 让 x 和 z 方向都有 V 形波纹。两个方向的波纹叠加形成十字交叉的波浪模式——比单一方向更像开阔海面(多向浪叠加)。
pow 的基底必须非负。1.0 - abs(sin) 结果在 ,pow 安全。但若基底意外为负,pow 在 GLSL 返回 NaN(分数指数)。choppy 太大会让峰过尖,甚至数值上溢(pow 接近 0 的基底被压到极小)。clamp 到 或更窄范围。uv += noise(uv) 不要循环依赖。noise(uv) 用扰动前的 uv 算,扰动后的 uv 用于 sin——顺序错了会递归。map() 是整个渲染器的核心——给定空间点 ,返回它到水面的有符号距离。结合 B09 FBM 和 B10 sea_octave:
float map(vec3 p) {
float freq = 0.16; // 基础频率
float amp = 0.6; // 初始振幅
float accum = 0.0; // 高度累加
for (int i = 0; i < ITER; i++) {
accum += amp * sea_octave(p.xz * freq + time); // 累加每层
freq *= 1.9; // 频率增(B09)
amp *= 0.22; // 振幅减(衰减率 0.22,比 0.5 陡)
}
return p.y - accum; // 有符号距离:正=水面之上,负=之下
}
p.y - accum:
这就是高度场的有符号距离形式——和 SDF(Raymarching B04)同构。后续 raymarching(B12)正是基于这个 map 函数做求交。
这些是 Seascape shader 的调参经验:
调小衰减率(如 0.5)会让海面细节爆炸,看起来像近岸碎浪;调大(如 0.1)会让海面几乎只剩一层大波,太平。0.22 是”开阔涌浪”的视觉甜点。
accum += amp * sea_octave(p.xz * freq + time * waveSpeed);
time 让噪声的输入坐标随时间偏移——等价于波形在空间上传播。每帧 time 增加,波形向前流动,产生”海浪在动”的效果。
具体地:noise 的输入是 ,时间增加等价于水平坐标减小(波形向 +x 方向移动)。多个 octave 的 time 项可以让不同层以不同速度流动(多向浪),更接近真实海面。
#define ITER 8 // 通常 6-8
层数越多细节越丰富,但 map 每次调用要跑完整循环——而 raymarching(B12)每像素要调几十次 map,所以 ITER 直接乘进性能预算。8 层是视觉/性能的平衡点。
返回 p.y - waterHeight 而非 waterHeight,让 map 直接成为 SDF(符号约定:正外负内)。这样 B12 的 raymarching 求交算法可以直接用——查 map(p) < 0 判断点在水面下,map(p) = 0 是水面。
注意:严格说这不是真 SDF(梯度模长不一定为 1),因为高度场在陡坡处梯度可能 >1。但对水面求交(沿射线找零点)足够——B12 用二分法而非 Sphere Tracing 正是这个原因。
p.xz 是水平坐标。海面是高度场,只依赖水平位置(x, z),不依赖查询点高度 y。误用 p.xy 或 p.xyz 会让水面跟着相机高度变。p.xz * freq + time 不能写成 (p.xz + time) * freq——后者时间偏移被频率放大,流速异常。有了高度场 map()(B11),相机射线要在哪里撞上水面?由于 map 含多个 octave 的噪声,方程无法解析求解。Seascape 用一种二分式逼近:
float rayMarch(vec3 ro, vec3 rd) {
float tmin = 0.8; // 近点
float tmax = 30.0; // 远点
// 1. 先找到"穿过水面"的一对 (近点在水面上, 远点在水面下)
for (int i = 0; i < STEPS; i++) {
vec3 p = ro + rd * tmin;
if (p.y > map(p)) tmin = ...; // 还在水面之上,继续
else break;
}
// 2. 二分:在 [tmin, tmax] 之间缩小,直到收敛
float tl = tmin, tr = tmax;
for (int i = 0; i < BISECT; i++) {
float tm = 0.5 * (tl + tr);
vec3 p = ro + rd * tm;
if (p.y > map(p)) tl = tm; // 中点在水面上
else tr = tm; // 中点在水面下
}
return 0.5 * (tl + tr); // 收敛点
}
Raymarching(B09)用 Sphere Tracing,每步跳 SDF 距离。但高度场的 map(p) = p.y - h(p.xz) 不是真 SDF——它的梯度在陡坡处 >1(水面斜率大时 p.y 变化比真距离快)。Sphere Tracing 的安全步长保证失效,可能跳过水面。
二分法不需要”距离”语义,只要”在水面之上/之下”的符号判断,对任意高度场都成立。代价是收敛慢(线性而非 Sphere Tracing 的几何收敛),但水面场景一般几十步够。
第 1 步:找到跨水面的区间。从近点开始沿射线步进,每步检查 map(p) 的符号。一旦符号反转(点进入水面下),说明这段 [t_prev, t_curr] 穿过了水面。
第 2 步:二分缩小。在已知包含交点的区间 里取中点,判断中点在水面之上还是下,相应缩小区间。每次迭代区间减半, 收敛——10 次迭代精度达 。
Seascape 实际实现里,第 1 步用距离自适应步长:
float t = tmin;
for (int i = 0; i < 64; i++) {
vec3 p = ro + rd * t;
float h = map(p); // 水面高度(实际是有符号距离)
if (p.y < h) break; // 已穿水面
t += max(0.01, p.y - h); // 步长 = 离水面的高度差(近水面小步)
}
t += max(0.01, p.y - h):点离水面越远步长越大(快速接近),越近步长越小(精确定位)。这借鉴了 Sphere Tracing 的”安全步长”思想,但用高度差代替真距离(虽然不严格 1-Lipschitz,但水面场景斜率有界,实践可行)。
射线接近水平(掠射)时,可能 64 步还没碰到水面——射线掠过地平线。这时 shader 用最后一次的 t 作为”近似交点”或标记”未命中”(背景天空)。海平面地平线就是这种极限——远处水面被天空取代。
rd.y 必须为正(射线朝上才能撞水面)。若相机俯视且 rd 朝下,符号约定要反过来。Seascape 假设相机在海面上方、射线大致朝远处水平。max(0.01, ...) 防止步长过小卡住(在水面附近反复小步)。0.01 是经验下限。找到了水面交点 ,还需要法线 (水面在该点的朝向)——光照、反射都依赖它。高度场只给高度 ,法线从梯度(坡度)算出。
水面是高度场 。法线是高度函数梯度的负方向,归一化:
推导:曲面 的法线是 ,归一化即可。
shader 里用中心差分估偏导:
vec3 getNormal(vec3 p, float eps) {
vec2 e = vec2(eps, 0.0);
float dx = map(p + e.xyy).x_or_y - map(p - e.xyy).x_or_y; // 高度差 ∂h/∂x
float dz = map(p + e.yyx).x_or_y - map(p - e.yyx).x_or_y; // 高度差 ∂h/∂z
return normalize(vec3(dx, 1.0, dz)); // 注意:返回值要和公式符号对应
}
(注:map 返回有符号距离,所以这里取 p.y - map 的差分等价于 的负值。具体符号取决于实现,最终归一化时调整。)
每次偏移 eps 距离,查两次 map( 方向和 方向),共 4 次 map 调用。中心差分 是 精度,比前向差分 准。
float eps = 0.1 * dist; // 距离越大 eps 越大
远处的水面在屏幕上少于一个像素——亚像素细节低于采样频率,会触发混叠(aliasing),表现为水面闪烁。增大 eps 等价于低通滤波:在更大空间范围平均高度差,过滤掉高频细节,让远处法线平滑。
数学上这是屏幕空间频率的考虑:远处相同水平距离对应屏幕像素少,所以要在更大的世界距离上采样才能匹配像素分辨率。eps ∝ dist 是经验近似(严格要做投影校正)。
近处 eps 小保留细节(能看到细微波纹),远处 eps 大避免闪烁——这是 shader 抗锯齿的核心技巧。
得到法线 后,光照(B12 smallpt2 / Raymarching B12)依赖它:
法线质量直接决定水面”看起来对不对”。法线噪声表现为水面颗粒感、闪烁;法线太平(eps 太大)表现为水面”糊”、失去波纹。
eps 不能太小。eps 太小会让两次 map 的高度差进入浮点噪声区(差值 < ),法线抖动。和水面的尺度协调(水面高度量级 ~1,eps ~0.01-0.1)。