除以深度:即时渲染3D图形的核心原理
初学游戏开发时,我总在用高级框架,它们会直接给你一个看起来很好用的 Camera。但随着深入学习,我想尝试更多创意手法,却发现连该用什么术语搜索目标功能都搞不定。直到我亲自写底层图形代码后,才恍然大悟:摄像机其实建立在极其简单的数学原理之上。
在优质视频《一个公式揭开 3D 图形的神秘面纱》(由 @tsoding 推荐)中,我们看到了以下内容:
(x, y, z)
x' = x/z
y' = y/z
本质上,如果定义 \(y\) 轴向上、\(z\) 轴向前,那么通过把 \(x\) 和 \(y\) 除以 \(z\),就能将三维坐标 \((x, y, z)\) 投影为二维坐标 \((x', y')\)。举个例子,假设有一组仅深度不同的三维点,随着深度增加,它们的投影位置会越来越靠近消失点 \((0,0)\):
\[\begin{array}{c|c} (x,y,z) & (x',y') \\ \hline (2,1,2) & (1,0.5) \\ (2,1,4) & (0.5,0.25) \\ (2,1,8) & (0.25,0.125) \end{array}\]下图展示了一个球体,它沿摄像机的向上轴做圆周运动,并沿 \(z\) 轴偏移 forward 距离。球体的位置和大小会随深度变化而动态缩放:
// slider:1 5
const float forward = 2.0;
vec3 camera_pos = vec3(sin(iTime), 0., cos(iTime) + forward);
vec2 projected_pos = camera_pos.xy / camera_pos.z;
float radius = 0.5 / camera_pos.z;void mainImage(out vec4 c, vec2 p) {
vec2 uv = (p * 2.0 - iResolution.xy) / iResolution.y;
// slider:1 5
const float forward = 2.0;
vec3 camera_pos = vec3(sin(iTime), 0., cos(iTime) + forward);
vec2 projected_pos = camera_pos.xy / camera_pos.z;
float radius = 0.5 / camera_pos.z;
c = iForeground * step(length(uv - projected_pos), radius);
}利用同样的原理,我们甚至能用这种方式绘制出更复杂的“几何图形”!
// slider: 2 5
const float z = 2.5;
float d = 0.0;
for (int i = 0; i < 12; i++) {
vec3 a = r * c[e[i].x] + fwd * z, b = r * c[e[i].y] + fwd * z;
d = max(d, line(uv, a.xy / a.z, b.xy / b.z));
}
fragColor = iForeground * d;显然,这些例子都非常受限且简陋,除了最简单的场景外基本不实用。实际做 3D 开发时,我们通常需要考虑相机的朝向、位置、视场角等参数。理论上我们可以通过 hack 那些着色器来支持这些功能,但有一种方式更省事、也更实用——它叫透视投影矩阵(perspective projection matrix),正是强大的 Camera 背后的魔法所在。
透视投影
构造透视投影矩阵的方法有很多,具体取决于使用场景。例如,计算机视觉和图形学在矩阵布局上就存在细微的约定差异,因此很难指望某一篇维基百科条目能涵盖所有情况。不过,在基于三角形的图形渲染中,有一种通用的参数化约定被广泛采用,主要涉及视场角(field of view)、宽高比以及近远裁剪面。这些参数至关重要,因为它们不仅决定了投影特性,还能让我们轻松判断场景中的哪些部分位于视锥体内,从而以高效的方式渲染场景,例如通过剔除屏幕外的几何体,只渲染可见部分。
虽然不同的坐标系约定之间存在差异(例如,关于哪个轴对应上、右、前方,业界并没有统一共识),但矩阵的整体结构基本一致:
\[P = \begin{bmatrix} \frac{f}{a} & 0 & 0 & 0 \\ 0 & f & 0 & 0 \\ 0 & 0 & A & B \\ 0 & 0 & 1 & 0 \end{bmatrix}\]这描述了一个相机,其焦距尺度为 \(f\)(由垂直视场角 \(\theta\) 推导得出),宽高比为 \(a\):
\[f = \frac{1}{\tan(\theta/2)},\quad a = \frac{width}{height}\]其中 \(A\) 和 \(B\) 代表深度映射,分别由近裁剪面 \(n\) 和远裁剪面 \(F\) 推导得出:
\[A = \frac{F+n}{F-n}, \quad B = -\frac{2Fn}{F-n}\] // slider:0 10
const float z = 3.5;
// slider: 20 120
const float fov = 60.0;
// slider: 0.5 2.5
const float aspect = 0.8;
// slider: 0.25 2.5
const float nearClip = 1.0;
// slider: 2.0 8.0
const float farClip = 5.0;
float f = 1.0 / tan(radians(fov) * 0.5);
mat4 p = mat4(
f / aspect, 0., 0., 0.,
0., f, 0., 0.,
0., 0., (farClip + nearClip) / (farClip - nearClip), 1.,
0., 0., -2. * farClip * nearClip / (farClip - nearClip), 0.
);
这段代码是纯GLSL源码,不涉及自然语言文本,因此无需翻译代码逻辑、变量名或注释(除非注释中含有需要翻译的自然语言句子,但此处注释均为"// slider:..."这种配置说明,属于代码的一部分,应保持原样)。
根据翻译要求,我需要保留所有HTML标签和代码结构,只翻译标签之间的文字。由于这是一段纯代码块,没有需要翻译的自然语言文本,我将原样输出。
注意:输入中包含了 `...`。
我将原样保留所有标签和代码内容。
```html
float sdSegment(vec2 p, vec2 a, vec2 b) {
vec2 pa = p - a, ba = b - a;
float h = clamp(dot(pa, ba) / max(dot(ba, ba), 1e-8), 0.0, 1.0);
return length(pa - ba * h);
}
vec2 project(mat4 m, vec3 p) {
vec4 q = m * vec4(p, 1.0);
return q.xy / q.w;
}
vec2 iso(vec3 p, float s) {
return vec2(dot(p, normalize(vec3(1., 0., 1.))),
dot(p, normalize(vec3(-.5, 1., .5)))) * s;
}
vec3 frustumPoint(vec3 c, vec3 near, vec3 far) {
vec3 s = mix(near, far, c.z);
return vec3(c.xy * s.xy, s.z);
}
void mainImage(out vec4 fragColor, in vec2 fragCoord) {
vec2 uv = (fragCoord * 2.0 - iResolution.xy) / iResolution.y;
float t = tan(radians(fov) * 0.5);
float screenAspect = iResolution.x / iResolution.y;
float s = 1.65 / (8.0 * max(t * aspect, 1.0));
// slider:0 10
const float z = 3.5;
// slider: 20 120
const float fov = 60.0;
// slider: 0.5 2.5
const float aspect = 0.8;
// slider: 0.25 2.5
const float nearClip = 1.0;
// slider: 2.0 8.0
const float farClip = 5.0;
float f = 1.0 / tan(radians(fov) * 0.5);
mat4 p = mat4(
f / aspect, 0., 0., 0.,
0., f, 0., 0.,
0., 0., (farClip + nearClip) / (farClip - nearClip), 1.,
0., 0., -2. * farClip * nearClip / (farClip - nearClip), 0.
);
const vec3 c[8] = vec3[](
vec3(-1., -1., 0.), vec3(1., -1., 0.),
vec3(-1., 1., 0.), vec3(1., 1., 0.),
vec3(-1., -1., 1.), vec3(1., -1., 1.),
vec3(-1., 1., 1.), vec3(1., 1., 1.)
);
const ivec2 e[12] = ivec2[](
ivec2(0, 1), ivec2(0, 2), ivec2(1, 3), ivec2(2, 3),
ivec2(4, 5), ivec2(4, 6), ivec2(5, 7), ivec2(6, 7),
ivec2(0, 4), ivec2(1, 5), ivec2(2, 6), ivec2(3, 7)
);
vec3 n = vec3(nearClip * t * aspect, nearClip * t, nearClip);
vec3 F = vec3(farClip * t * aspect, farClip * t, farClip);
vec3 ball = vec3(sin(iTime), 0.0, z + cos(iTime));
const float ballRadius = 0.35;
vec4 clip = p * vec4(ball, 1.0);
vec3 q = clip.xyz / clip.w;
float visible = step(-1.0, q.z) * step(q.z, 1.0);
bool diagram = uv.x < 0.0;
vec2 lo = vec2(1e6), hi = -lo;
for (int i = 0; i < 8; i++) {
vec2 corner = iso(frustumPoint(c[i], n, F), s);
lo = min(lo, corner); hi = max(hi, corner);
}
vec2 offset = vec2(-screenAspect * 0.5, 0.0) - (lo + hi) * 0.5;
vec2 viewport = vec2(screenAspect * 0.5, 0.0);
vec2 size = 0.72 * vec2(aspect, 1.0) / max(aspect, 1.0);
float d = 0.0;
for (int i = 0; i < 12; i++) {
vec3 a = frustumPoint(c[e[i].x], n, F), b = frustumPoint(c[e[i].y], n, F);
vec2 pa = diagram ? iso(a, s) + offset : viewport + project(p, a) * size;
vec2 pb = diagram ? iso(b, s) + offset : viewport + project(p, b) * size;
float opacity = diagram ? mix(1.0, 0.4, (a.z + b.z) * 0.5 / farClip) : 0.25;
d = max(d, smoothstep(fwidth(uv.y) * 1.5, 0.0, sdSegment(uv, pa, pb)) * opacity);
}
float w = fwidth(uv.y) * 1.5;
if (diagram) {
vec2 center = iso(ball, s) + offset;
float radius = length(iso(ball + vec3(ballRadius, 0., 0.), s) - iso(ball, s));
float outline = smoothstep(w * 1.5, w, abs(length(uv - center) - radius));
d = max(d, mix(outline, smoothstep(radius + w, radius, length(uv - center)), visible));
} else {
vec2 center = viewport + q.xy * size;
float radius = length((project(p, ball + vec3(ballRadius, 0., 0.)) - q.xy) * size);
d = max(d, smoothstep(radius + w, radius, length(uv - center)) * visible);
}
d = max(d, (1.0 - smoothstep(0.0, fwidth(uv.x), abs(uv.x))) * 0.2);
fragColor = iForeground * d;
}
```
那么这与前文的深度除法技巧有什么关系呢?原来它正是透视投影矩阵的一种特殊形式。
给定 3D 空间中的某个点,我们先描述它相对于相机的位置。这一步把坐标从整个场景共享的世界空间(world space)转换到以相机为原点的视图空间(view space)。接着再乘以透视投影矩阵,得到准备好做透视除法的裁剪空间(clip-space)坐标。
\[\begin{bmatrix} \frac{f}{a} & 0 & 0 & 0 \\ 0 & f & 0 & 0 \\ 0 & 0 & A & B \\ 0 & 0 & 1 & 0 \end{bmatrix} \begin{bmatrix} x \\ y \\ z \\ 1 \end{bmatrix} = \begin{bmatrix} \frac{f}{a}x + 0y + 0z + 0(1) \\ 0x + fy + 0z + 0(1) \\ 0x + 0y + Az + B(1) \\ 0x + 0y + z + 0(1) \end{bmatrix} = \begin{bmatrix} \frac{f}{a}x \\ fy \\ Az + B \\ z \end{bmatrix}\]把裁剪空间坐标的前三个分量除以 \(w\) 之后,坐标不再表示场景中的距离,而是表示这个点落在相机可视范围内的位置。这就是归一化设备坐标(normalized device coordinates,简称 NDC),之后才能把点映射到屏幕的实际输出分辨率上。
\[x_{ndc} = \frac{\frac{f}{a}x}{z}, \qquad y_{ndc} = \frac{fy}{z}\]在前面的例子中,焦距缩放 \(f\) 和宽高比 \(a\) 都被省略了。把它们都替换成 1,就正好得到最初的深度除法技巧:
\[x_{ndc} = \frac{\frac{1}{1}x}{z} = \frac{x}{z}, \qquad y_{ndc} = \frac{1y}{z} = \frac{y}{z}\]深度除法技巧虽然能处理简单场景中相对于相机的点,但它其实只是完整图形管线中的一步。现在这些术语都有了名字,整个流程看起来是这样的:
\[\mathrm{world} \rightarrow \mathrm{view} \rightarrow \mathrm{clip} \xrightarrow{\,/w\,} \mathrm{NDC} \rightarrow \mathrm{pixels} \rightarrow \mathrm{rasterization}\]把所有内容串起来
如果这篇文章只能让你记住一件事,那就是:Camera 做的不过是一些简单的变换。只要你理解每种变换的用途,就可以根据自己的需求和约束,自行选择实现哪些部分。有时你需要完整的管线,有时只需要那一次深度除法。