プログラミング
デプスによる除算で即座に3Dを
Divide by Depth for Instant 3D (gabrieloc.com)
要約
この記事では、3Dグラフィックスにおけるカメラの基本的な数学的原理、特にデプス(奥行き)による除算を用いた2Dへの投影方法を解説しています。単純な数式 (x/z, y/z) を用いて3D座標を2D平面にマッピングする手法や、それを応用したシェーダーコードの例が示されています。さらに、より現実的な3Dレンダリングに必要な透視投影行列の概念と、その構築方法についても触れています。
全文翻訳
私が最初にゲーム開発を始めた頃は、高レベルなフレームワークを使用していました。それらは、まあまあ機能するカメラを提供してくれました。
しかし、より多くのことを学び、よりクリエイティブなテクニックを適用したいと思うようになると、自分がやりたいことを検索するための語彙さえ持っていないことに苦労しました。そして、低レベルのグラフィックスコードを書くようになって初めて、カメラが非常に単純な数学に基づいていることを理解しました。
優れた「One Formula That Demystifies 3D Graphics」(@tsodingより)では、以下の式が提示されています。
(x, y, z) x' = x/z y' = y/z
本質的に、yを上、zを前方とすると、3D座標 (x, y, z) は、xとyをzで割ることによって2D座標 (x', y') に投影できます。
例えば、奥行きのみが変化する一連の3D点があるとすると、奥行きが増加するにつれて、それらの投影位置は消失点 (0,0) に近づきます。
| (x,y,z) | (x',y') |
|---|---|
| (2,1,2) | (1,0.5) |
| (2,1,4) | (0.5,0.25) |
| (2,1,8) | (0.25,0.125) |
これは、カメラのアップ軸を中心に周回し、前方へz軸にオフセットされたボールが、奥行きとともに移動・スケーリングすることで実証できます。
```glsl
// 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);
}
```
同じ原理を使用すると、さらに洗練された「ジオメトリ」を描画することもできます!
```glsl
// 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;
float sdSegment(vec2 point, vec2 start, vec2 end) {
vec2 offset = point - start;
vec2 segment = end - start;
float along = clamp(dot(offset, segment) / max(dot(segment, segment), 1e-8), 0.0, 1.0);
return length(offset - segment * along);
}
float line(vec2 point, vec2 start, vec2 end) {
float width = fwidth(point.y) * 1.5;
return smoothstep(width, 0.0, sdSegment(point, start, end));
}
void mainImage(out vec4 fragColor, in vec2 fragCoord) {
vec2 uv = (fragCoord * 2.0 - iResolution.xy) / iResolution.y;
const vec3 c[8] = vec3[](
vec3(-1., -1., -1.), vec3(-1., -1., 1.), vec3(-1., 1., -1.), vec3(-1., 1., 1.),
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(0, 4), ivec2(1, 3), ivec2(1, 5),
ivec2(2, 3), ivec2(2, 6), ivec2(3, 7), ivec2(4, 5), ivec2(4, 6),
ivec2(5, 7), ivec2(6, 7)
);
mat3 r = mat3(
cos(iTime), 0., sin(iTime),
0., 1., 0.,
-sin(iTime), 0., cos(iTime)
);
const vec3 fwd = vec3(0.0, 0.0, 1.0);
// 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作業を行う場合、通常はカメラの向き、位置、視野角などの要素を考慮する必要があります。
理論的にはこれらのシェーダーをこれらの機能に対応するようにハックすることもできますが、より少ない労力でより実用的な方法があります。
それは透視投影行列と呼ばれ、強力なカメラの背後にある魔法です。
透視投影
使用例に応じて、透視投影行列を構築する方法はいくつかあります。
例えば、コンピュータビジョンとグラフィックスでは、レイアウトの慣例がわずかに異なるため、単一のWikipediaの記事を参照して普遍的な形式を期待するのは困難です。
とはいえ、三角形ベースのグラフィックスでは、しばしば従われる一般的な慣例には、視野角、アスペクト比、ニアおよびファー・クリッピング・プレーンのパラメータ化が含まれます。
これらの値はすべて非常に重要です。なぜなら、それらはフレーム内にあるものとないものを簡単に知る方法としても機能し、カリング(オフスクリーンのジオメトリを削除)や可視なもののみをレンダリングするなど、パフォーマンスの高い方法でシーンをレンダリングできるからです。
これはさまざまな座標系の慣例(上、右、前方に対応する軸に関するコンセンサスが少ない)によって異なりますが、一般的な構造はほとんど一貫しています。
`P = [[f/a, 0, 0, 0], [0, f, 0, 0], [0, 0, A, B], [0, 0, 1, 0]]`
これは、焦点スケール f(垂直視野角 θ から導出)とアスペクト比 a を持つカメラを表します。
`f = 1 / tan(θ/2), a = width / height`
ここで、A と B は、ニアおよびファー・クリッピング・プレーン n と F からそれぞれ導出されるデプスマッピングを表します。
`A = (F+n) / (F-n), B = -2Fn / (F-n)`
```glsl
// 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.
);
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 =