前言
上一篇我們聊了 2D SDF——用數學函數定義平面上的形狀。但 SDF 真正大放異彩的舞台是在 3D。想像一下:你不需要任何 3D 建模軟體、不需要頂點資料、不需要三角面,只用一段 fragment shader 就能渲染出完整的 3D 場景。這一切的關鍵就是 Ray Marching。
Ray marching(光線步進)是一種渲染技術,它沿著每條光線一步一步地前進,直到碰到物體表面為止。和傳統的 ray tracing 不同,ray marching 不需要解析求交——它直接利用 SDF 的距離資訊來決定每一步走多遠。今天我們就來完整走過這個流程。
Ray Marching 演算法
核心概念非常直觀:
- 從攝影機位置射出一條光線
- 沿著光線方向前進,每一步詢問 SDF:「我離最近的表面有多遠?」
- 前進那個距離(因為 SDF 保證在這個距離內不會碰到任何東西)
- 重複,直到距離小到足以認為「碰到了」,或者走太遠放棄
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 的世界:
- Ray Marching 演算法:沿光線步進,利用 SDF 的距離保證來加速
- 攝影機設定:lookAt 矩陣建構觀看方向
- 3D SDF:球體、方塊、圓柱、平面
- 法線計算:四面體差分法
- 光照模型:Lambertian + Phong + 軟陰影 + AO
Ray marching 的美妙之處在於:所有這些效果——形狀、光照、陰影、AO——全部在同一個 fragment shader 裡完成。沒有前置的幾何處理,沒有 shadow map,一切都是數學。
延伸閱讀
- iquilezles.org — 3D distance functions:完整的 3D SDF 收藏
- iquilezles.org — soft shadows:改進版軟陰影演算法
- Shadertoy:直接在瀏覽器裡實驗你的 ray marching 場景
下一篇我們來聊聊後處理特效——在渲染之後加上模糊、色差、暈影等效果,讓畫面更有質感。