前言
水,是自然界中最迷人也最難模擬的元素之一。它同時具備透明、折射、反射、散射等光學特性,而且表面還不斷起伏變化。在即時渲染的世界裡,我們當然不可能做物理精確的模擬,但利用 noise 產生波紋、加上折射和反射的計算,就能得到相當有說服力的水面效果。
這篇文章會帶你一步一步打造一個 2D 的水面效果,涵蓋:波紋 noise、折射向量計算、Fresnel 效應、以及環境反射。如果你之前看過 fBm 和 noise 的文章,這篇會把那些知識直接應用在一個具體的場景中。
水面波紋:用 Noise 模擬
水面的起伏本質上就是一個隨時間變化的高度場(height field)。我們可以用多層 noise 疊加來生成。
基礎波紋函數
float hash(vec2 p) {
p = fract(p * vec2(123.34, 456.21));
p += dot(p, p + 45.32);
return fract(p.x * p.y);
}
float noise(vec2 p) {
vec2 i = floor(p);
vec2 f = fract(p);
vec2 u = f f (3.0 - 2.0 * f);
float a = hash(i);
float b = hash(i + vec2(1.0, 0.0));
float c = hash(i + vec2(0.0, 1.0));
float d = hash(i + vec2(1.0, 1.0));
return mix(mix(a, b, u.x), mix(c, d, u.x), u.y);
}
float waterHeight(vec2 p, float time) {
float h = 0.0;
float amplitude = 0.5;
float frequency = 1.0;
mat2 rot = mat2(0.8, 0.6, -0.6, 0.8);
for (int i = 0; i < 5; i++) {
h += amplitude noise(p frequency + time 0.5 float(i + 1) * 0.3);
p = rot * p;
frequency *= 2.0;
amplitude *= 0.5;
}
return h;
}
用正弦波疊加(Gerstner Wave 簡化版)
真正的水面物理通常用 Gerstner wave。簡化版可以用多個方向的正弦波疊加:
float waterWaves(vec2 p, float time) {
float h = 0.0;
// 多個方向的波
h += sin(p.x 1.0 + time 1.2) * 0.3;
h += sin(p.y 1.3 + time 0.8) * 0.25;
h += sin((p.x + p.y) 2.1 + time 1.5) * 0.15;
h += sin((p.x - p.y) 2.7 + time 1.1) * 0.1;
// 加上高頻 noise 當作細波紋
h += noise(p 8.0 + time) 0.05;
return h;
}
從高度場計算法線
水面法線是所有光學效果的基礎。我們用有限差分法從高度場計算法線:
vec3 waterNormal(vec2 p, float time) {
float eps = 0.01;
float h = waterHeight(p, time);
float hx = waterHeight(p + vec2(eps, 0.0), time);
float hy = waterHeight(p + vec2(0.0, eps), time);
// 偏導數
float dx = (hx - h) / eps;
float dy = (hy - h) / eps;
// 法線(假設水面在 xz 平面上,高度沿 y 軸)
return normalize(vec3(-dx, 1.0, -dy));
}
1.0 的位置控制法線的「陡峭度」——把它換成較大的值(如 2.0)會讓波紋看起來比較平緩。
折射(Refraction)
折射是光線從一種介質進入另一種介質時方向改變的現象。水的折射率約為 1.33。
GLSL 內建的 refract 函數
vec3 refracted = refract(viewDir, normal, eta);
viewDir:入射光方向(要正規化)normal:表面法線eta:折射率比值(入射介質 / 折射介質)。空氣到水是1.0 / 1.33 ≈ 0.75
2D 水面折射效果
在 2D 的情境下(比如在一張圖片上模擬水面),折射可以簡化為 UV 偏移:
void mainImage(out vec4 fragColor, in vec2 fragCoord) {
vec2 uv = fragCoord / iResolution.xy;
// 水面法線(只用 xz 分量當作 UV 偏移)
vec3 normal = waterNormal(uv * 10.0, iTime);
// 折射 UV 偏移
float refractionStrength = 0.03;
vec2 refractedUV = uv + normal.xz * refractionStrength;
// 讀取「水底」的影像(假設 iChannel0 是場景紋理)
vec3 underwater = texture(iChannel0, refractedUV).rgb;
// 水的顏色調制
vec3 waterTint = vec3(0.1, 0.3, 0.5);
vec3 col = mix(underwater, waterTint, 0.3);
fragColor = vec4(col, 1.0);
}
深度相關的折射
真實的水面,透過水看到的東西會隨深度而模糊和變色:
// 假設 depth 是水底的深度
float depth = 0.5; // 可以用紋理儲存深度資訊
// 深水區折射更明顯
vec2 refractedUV = uv + normal.xz refractionStrength depth;
// 深水區更藍更暗
vec3 underwater = texture(iChannel0, refractedUV).rgb;
vec3 deepColor = vec3(0.0, 0.05, 0.15);
underwater = mix(underwater, deepColor, smoothstep(0.0, 2.0, depth));
Fresnel 效應
Fresnel 效應描述的是:當你正面看水面(視線接近垂直),你主要看到折射(水底的東西);當你側面看水面(視線接近平行),你主要看到反射(天空和周圍環境)。
Schlick 近似
float fresnel(vec3 viewDir, vec3 normal, float ior) {
float f0 = pow((1.0 - ior) / (1.0 + ior), 2.0);
float cosTheta = max(dot(viewDir, normal), 0.0);
return f0 + (1.0 - f0) * pow(1.0 - cosTheta, 5.0);
}
ior 是折射率(水 = 1.33),f0 是正面反射率。
簡化版
在很多 shader 效果中,我們用更簡單的近似:
float fresnelSimple(float NdotV) {
return pow(1.0 - NdotV, 3.0);
}
// 使用
float NdotV = max(dot(normal, viewDir), 0.0);
float fres = fresnelSimple(NdotV);
// fres 接近 1:看到反射
// fres 接近 0:看到折射(水底)
vec3 col = mix(refractionColor, reflectionColor, fres);
反射
反射需要一個「環境」來反射。在 2D shader 中,我們可以用簡單的天空漸變或環境紋理。
簡單天空反射
vec3 skyColor(vec3 reflDir) {
// 簡單的天空漸變
float t = reflDir.y * 0.5 + 0.5;
vec3 sky = mix(vec3(0.5, 0.6, 0.7), vec3(0.2, 0.4, 0.9), t);
// 加一個太陽
vec3 sunDir = normalize(vec3(0.5, 0.3, -1.0));
float sun = pow(max(dot(reflDir, sunDir), 0.0), 128.0);
sky += vec3(1.0, 0.9, 0.7) * sun;
return sky;
}
使用 cubemap 做環境反射
如果你有 cubemap 紋理:
vec3 reflDir = reflect(-viewDir, normal);
vec3 reflColor = texture(iChannel1, reflDir).rgb; // cubemap
完整水面效果
把所有元素組合起來:
// === Utility functions ===
float hash(vec2 p) {
p = fract(p * vec2(123.34, 456.21));
p += dot(p, p + 45.32);
return fract(p.x * p.y);
}
float noise(vec2 p) {
vec2 i = floor(p);
vec2 f = fract(p);
vec2 u = f f (3.0 - 2.0 * f);
return mix(
mix(hash(i), hash(i + vec2(1.0, 0.0)), u.x),
mix(hash(i + vec2(0.0, 1.0)), hash(i + vec2(1.0, 1.0)), u.x),
u.y
);
}
float waterHeight(vec2 p, float t) {
float h = 0.0;
float amp = 0.5;
mat2 rot = mat2(0.8, 0.6, -0.6, 0.8);
for (int i = 0; i < 5; i++) {
h += amp noise(p + t 0.3 * float(i + 1));
p = rot p 2.0;
amp *= 0.5;
}
return h;
}
vec3 waterNormal(vec2 p, float t) {
float e = 0.01;
float h = waterHeight(p, t);
float hx = waterHeight(p + vec2(e, 0.0), t);
float hy = waterHeight(p + vec2(0.0, e), t);
return normalize(vec3(-(hx - h) / e, 1.5, -(hy - h) / e));
}
vec3 skyColor(vec3 rd) {
float t = rd.y * 0.5 + 0.5;
vec3 sky = mix(vec3(0.7, 0.75, 0.8), vec3(0.2, 0.45, 0.9), t);
vec3 sunDir = normalize(vec3(0.3, 0.2, -1.0));
sky += vec3(1.0, 0.9, 0.7) * pow(max(dot(rd, sunDir), 0.0), 64.0);
return sky;
}
// === Main ===
void mainImage(out vec4 fragColor, in vec2 fragCoord) {
vec2 uv = (fragCoord * 2.0 - iResolution.xy) / iResolution.y;
// 攝影機
vec3 ro = vec3(0.0, 2.0, -3.0);
vec3 rd = normalize(vec3(uv, 1.5));
// 簡單的射線-平面相交(水面在 y=0)
float t = -ro.y / rd.y;
if (t > 0.0 && rd.y < 0.0) {
vec3 hitPos = ro + rd * t;
vec2 waterUV = hitPos.xz;
// 水面法線
vec3 normal = waterNormal(waterUV * 2.0, iTime);
// 視線方向
vec3 viewDir = normalize(ro - hitPos);
// Fresnel
float NdotV = max(dot(normal, viewDir), 0.0);
float fres = pow(1.0 - NdotV, 4.0);
fres = mix(0.02, 1.0, fres);
// 反射
vec3 reflDir = reflect(-viewDir, normal);
vec3 reflColor = skyColor(reflDir);
// 折射(UV 偏移模擬水底)
vec2 refractOffset = normal.xz * 0.05;
vec3 underwaterColor = vec3(0.05, 0.15, 0.3); // 深色水底
// 混合
vec3 col = mix(underwaterColor, reflColor, fres);
// Specular highlight
vec3 sunDir = normalize(vec3(0.3, 0.2, -1.0));
vec3 halfDir = normalize(sunDir + viewDir);
float spec = pow(max(dot(normal, halfDir), 0.0), 256.0);
col += vec3(1.0, 0.95, 0.8) * spec;
// 距離衰減
col = mix(col, vec3(0.3, 0.5, 0.7), 1.0 - exp(-0.01 * t));
fragColor = vec4(col, 1.0);
} else {
// 天空
fragColor = vec4(skyColor(rd), 1.0);
}
}
進階技巧
焦散(Caustics)
水面焦散是光線透過波動水面折射後在水底形成的光斑圖案。簡單模擬:
float caustics(vec2 p, float time) {
float c = 0.0;
// 兩組不同方向的波紋取 min 模擬交叉光紋
float a = noise(p 3.0 + time 0.5);
float b = noise(p 3.0 - time 0.3 + 5.0);
c = min(a, b);
c = pow(c, 3.0) * 5.0; // 增強對比
return c;
}
水面泡沫
在波峰處加上白色泡沫:
float foam(vec2 p, float time) {
float h = waterHeight(p * 2.0, time);
float foamLine = smoothstep(0.6, 0.65, h); // 波峰
foamLine = noise(p 20.0 + time); // 泡沫紋理
return foamLine;
}
// 使用
col += vec3(0.9) foam(waterUV, iTime) 0.5;
小結
模擬水面需要把好幾個技術組合在一起:
- 波紋 noise:多層 noise 或正弦波疊加產生水面高度場
- 法線計算:有限差分法從高度場求法線
- 折射:利用法線偏移 UV 或使用
refract()函數 - Fresnel 效應:控制反射和折射的混合比例
- 反射:天空漸變或環境紋理
- 額外細節:焦散、泡沫、高光
每個單獨的步驟都不算太難,但組合起來就能產生非常有說服力的水面效果。這也是 shader 程式設計的精髓——把簡單的數學組合成視覺上豐富的結果。
延伸閱讀
- iquilezles.org — water:iq 的各種水面相關文章
- Shadertoy — water shader:大量水面效果的 shader 範例
- GPU Gems — Water Simulation:更深入的水面物理模擬
下一篇我們要來玩 Domain Warping——扭曲空間本身來產生迷幻的有機紋理。