前言

水,是自然界中最迷人也最難模擬的元素之一。它同時具備透明、折射、反射、散射等光學特性,而且表面還不斷起伏變化。在即時渲染的世界裡,我們當然不可能做物理精確的模擬,但利用 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;


小結

模擬水面需要把好幾個技術組合在一起:

  1. 波紋 noise:多層 noise 或正弦波疊加產生水面高度場
  2. 法線計算:有限差分法從高度場求法線
  3. 折射:利用法線偏移 UV 或使用 refract() 函數
  4. Fresnel 效應:控制反射和折射的混合比例
  5. 反射:天空漸變或環境紋理
  6. 額外細節:焦散、泡沫、高光

每個單獨的步驟都不算太難,但組合起來就能產生非常有說服力的水面效果。這也是 shader 程式設計的精髓——把簡單的數學組合成視覺上豐富的結果。

延伸閱讀

下一篇我們要來玩 Domain Warping——扭曲空間本身來產生迷幻的有機紋理。