前言

上一篇我們聊了 2D SDF——用數學函數定義平面上的形狀。但 SDF 真正大放異彩的舞台是在 3D。想像一下:你不需要任何 3D 建模軟體、不需要頂點資料、不需要三角面,只用一段 fragment shader 就能渲染出完整的 3D 場景。這一切的關鍵就是 Ray Marching

Ray marching(光線步進)是一種渲染技術,它沿著每條光線一步一步地前進,直到碰到物體表面為止。和傳統的 ray tracing 不同,ray marching 不需要解析求交——它直接利用 SDF 的距離資訊來決定每一步走多遠。今天我們就來完整走過這個流程。


Ray Marching 演算法

核心概念非常直觀:

  1. 從攝影機位置射出一條光線
  2. 沿著光線方向前進,每一步詢問 SDF:「我離最近的表面有多遠?」
  3. 前進那個距離(因為 SDF 保證在這個距離內不會碰到任何東西)
  4. 重複,直到距離小到足以認為「碰到了」,或者走太遠放棄
float rayMarch(vec3 ro, vec3 rd) {
    float t = 0.0; // 目前沿光線走的總距離

for (int i = 0; i < 100; i++) { vec3 p = ro + rd * t; // 目前位置 float d = sceneSDF(p); // 到場景最近表面的距離

if (d < 0.001) break; // 夠近了,視為命中 if (t > 100.0) break; // 太遠了,放棄

t += d; // 前進 d 的距離 }

return t; }

這就是整個演算法。每一步都利用 SDF 的「安全距離」特性來加速——離物體遠的時候大步走,靠近時小步走。


攝影機設定

要做 3D 渲染,首先需要為每個像素建立一條光線。我們需要定義:

  • ro(ray origin):攝影機位置
  • rd(ray direction):光線方向
void mainImage(out vec4 fragColor, in vec2 fragCoord) {
    // 正規化 UV,中心為原點,y 範圍約 -1 到 1
    vec2 uv = (fragCoord * 2.0 - iResolution.xy) / iResolution.y;

// 攝影機位置 vec3 ro = vec3(0.0, 1.0, -3.0);

// 光線方向:uv.x 和 uv.y 控制水平和垂直角度 // 1.0 是焦距(field of view 的倒數) vec3 rd = normalize(vec3(uv, 1.0));

// 進行 ray marching float t = rayMarch(ro, rd);

// 暫時用距離來上色 vec3 col = vec3(1.0 / (1.0 + t t 0.1));

fragColor = vec4(col, 1.0); }

可旋轉的攝影機

更進階一點,你可以用 lookAt 矩陣來自由控制攝影機朝向:

mat3 lookAt(vec3 eye, vec3 target, vec3 up) {
    vec3 f = normalize(target - eye);
    vec3 r = normalize(cross(f, up));
    vec3 u = cross(r, f);
    return mat3(r, u, f);
}

void mainImage(out vec4 fragColor, in vec2 fragCoord) { vec2 uv = (fragCoord * 2.0 - iResolution.xy) / iResolution.y;

vec3 ro = vec3(3.0 sin(iTime), 2.0, 3.0 cos(iTime)); // 繞場景旋轉 vec3 target = vec3(0.0, 0.5, 0.0);

mat3 cam = lookAt(ro, target, vec3(0.0, 1.0, 0.0)); vec3 rd = cam * normalize(vec3(uv, 1.5)); // 1.5 = focal length

float t = rayMarch(ro, rd); // ... }


3D SDF 函數

和 2D 一樣,3D SDF 也是回傳「點到表面的有符號距離」。

球體

float sdSphere(vec3 p, float r) {
    return length(p) - r;
}

方塊

float sdBox(vec3 p, vec3 b) {
    vec3 q = abs(p) - b;
    return length(max(q, 0.0)) + min(max(q.x, max(q.y, q.z)), 0.0);
}

圓柱

float sdCylinder(vec3 p, float r, float h) {
    vec2 d = vec2(length(p.xz) - r, abs(p.y) - h);
    return min(max(d.x, d.y), 0.0) + length(max(d, 0.0));
}

平面(無限大的地板)

float sdPlane(vec3 p, float h) {
    return p.y - h;
}

組合成場景

float sceneSDF(vec3 p) {
    float sphere = sdSphere(p - vec3(0.0, 1.0, 0.0), 0.8);
    float box = sdBox(p - vec3(2.0, 0.8, 0.0), vec3(0.6));
    float ground = sdPlane(p, 0.0);

float d = min(sphere, box); d = min(d, ground);

return d; }


計算法線(Normal)

有了表面位置之後,我們需要法線來計算光照。SDF 的法線就是梯度(gradient)方向,可以用差分法近似:

vec3 calcNormal(vec3 p) {
    const float h = 0.0001;
    const vec2 k = vec2(1.0, -1.0);
    return normalize(
        k.xyy  sceneSDF(p + k.xyy  h) +
        k.yyx  sceneSDF(p + k.yyx  h) +
        k.yxy  sceneSDF(p + k.yxy  h) +
        k.xxx  sceneSDF(p + k.xxx  h)
    );
}

這個四面體差分法(tetrahedron technique)比傳統的六次取樣更有效率,只需要四次 SDF 呼叫。原理是利用 (1,1,-1), (1,-1,1), (-1,1,1), (-1,-1,-1) 四個方向來近似梯度。


基礎光照模型

有了法線,就可以實作基礎的 Lambertian 漫射光照:

vec3 render(vec3 ro, vec3 rd) {
    float t = rayMarch(ro, rd);

if (t > 100.0) { // 沒碰到任何東西,回傳背景色 return vec3(0.1, 0.1, 0.15); }

vec3 p = ro + rd * t; // 交點位置 vec3 n = calcNormal(p); // 法線

// 光源方向 vec3 lightDir = normalize(vec3(1.0, 1.0, -1.0));

// Lambertian 漫射 float diff = max(dot(n, lightDir), 0.0);

// 環境光 float amb = 0.1;

vec3 col = vec3(0.8, 0.4, 0.2) * (diff + amb);

return col; }

加上 Phong 鏡面反射

vec3 render(vec3 ro, vec3 rd) {
    float t = rayMarch(ro, rd);

if (t > 100.0) return vec3(0.1, 0.1, 0.15);

vec3 p = ro + rd * t; vec3 n = calcNormal(p);

vec3 lightDir = normalize(vec3(1.0, 1.0, -1.0)); vec3 viewDir = -rd; vec3 reflDir = reflect(-lightDir, n);

// 漫射 float diff = max(dot(n, lightDir), 0.0);

// 鏡面反射(Phong) float spec = pow(max(dot(viewDir, reflDir), 0.0), 32.0);

// 環境光 float amb = 0.05 + 0.05 * n.y; // 天空方向稍亮

vec3 col = vec3(0.8, 0.4, 0.2) (diff + amb) + vec3(1.0) spec * 0.5;

return col; }


軟陰影

Ray marching 的另一個優勢是——陰影計算也可以用 marching 來做。從交點往光源方向 march,如果碰到物體就表示在陰影中:

float softShadow(vec3 ro, vec3 rd, float tmin, float tmax, float k) {
    float res = 1.0;
    float t = tmin;

for (int i = 0; i < 64; i++) { float d = sceneSDF(ro + rd * t); if (d < 0.001) return 0.0; // 完全在陰影中

res = min(res, k * d / t); t += d;

if (t > tmax) break; }

return clamp(res, 0.0, 1.0); }

參數 k 控制陰影的軟硬度——越大越銳利。使用方式:

float shadow = softShadow(p + n * 0.01, lightDir, 0.01, 10.0, 16.0);
col *= shadow;

注意我們把起點稍微往法線方向偏移(p + n * 0.01),避免自我遮蔽。


環境遮蔽(Ambient Occlusion)

AO 用來模擬物體凹陷處光線較少的效果。在 ray marching 中,我們可以用一個簡單的技巧:沿著法線方向取幾個樣本,看看 SDF 值跟預期的距離差多少。

float calcAO(vec3 p, vec3 n) {
    float occ = 0.0;
    float sca = 1.0;

for (int i = 0; i < 5; i++) { float h = 0.01 + 0.12 * float(i); float d = sceneSDF(p + h * n); occ += (h - d) * sca; sca *= 0.95; }

return clamp(1.0 - 3.0 * occ, 0.0, 1.0); }

如果法線方向上的 SDF 值比預期的小,代表附近有東西擋著,AO 值就低。


完整範例

把所有東西組合起來:

float sceneSDF(vec3 p) {
    float sphere = sdSphere(p - vec3(0.0, 1.0, 0.0), 0.8);
    float box = sdBox(p - vec3(2.0, 0.7, 0.0), vec3(0.5));
    float ground = p.y;

// smooth union 球和方塊 float d = smin(sphere, box, 0.5); d = min(d, ground);

return d; }

vec3 render(vec3 ro, vec3 rd) { float t = rayMarch(ro, rd); if (t > 100.0) return vec3(0.4, 0.5, 0.7); // 天空色

vec3 p = ro + rd * t; vec3 n = calcNormal(p);

vec3 lightDir = normalize(vec3(0.5, 0.8, -0.3));

float diff = max(dot(n, lightDir), 0.0); float spec = pow(max(dot(reflect(-lightDir, n), -rd), 0.0), 32.0); float shadow = softShadow(p + n * 0.01, lightDir, 0.01, 10.0, 16.0); float ao = calcAO(p, n);

vec3 col = vec3(0.7, 0.5, 0.3) (diff shadow + 0.1) * ao; col += spec shadow 0.3;

// 霧效果(遠處漸變到天空色) col = mix(col, vec3(0.4, 0.5, 0.7), 1.0 - exp(-0.005 t t));

return col; }

void mainImage(out vec4 fragColor, in vec2 fragCoord) { vec2 uv = (fragCoord * 2.0 - iResolution.xy) / iResolution.y;

vec3 ro = vec3(3.0 sin(iTime 0.3), 2.0, 3.0 cos(iTime 0.3)); mat3 cam = lookAt(ro, vec3(0.0, 0.5, 0.0), vec3(0.0, 1.0, 0.0)); vec3 rd = cam * normalize(vec3(uv, 1.5));

vec3 col = render(ro, rd);

// gamma 校正 col = pow(col, vec3(1.0 / 2.2));

fragColor = vec4(col, 1.0); }


小結

今天我們從 2D SDF 跨越到了 3D 的世界:

  1. Ray Marching 演算法:沿光線步進,利用 SDF 的距離保證來加速
  2. 攝影機設定:lookAt 矩陣建構觀看方向
  3. 3D SDF:球體、方塊、圓柱、平面
  4. 法線計算:四面體差分法
  5. 光照模型:Lambertian + Phong + 軟陰影 + AO

Ray marching 的美妙之處在於:所有這些效果——形狀、光照、陰影、AO——全部在同一個 fragment shader 裡完成。沒有前置的幾何處理,沒有 shadow map,一切都是數學。

延伸閱讀

下一篇我們來聊聊後處理特效——在渲染之後加上模糊、色差、暈影等效果,讓畫面更有質感。