この記事は、3D数学について学ぶことを目的とした一連の記事の6番目です。各記事は前のレッスンを基にしているので、順番に読むと最も理解しやすいかもしれません。
前回の投稿では、3Dを行う方法について説明しましたが、その3Dには遠近感がありませんでした。それは「正射影」ビューと呼ばれるものを使用していましたが、それには用途がありますが、一般的に人々が「3D」と言うときに望むものではありません。
代わりに、遠近感を追加する必要があります。遠近感とは何でしょうか?基本的には、遠くにあるものが小さく見えるという特徴です。
上の例を見ると、遠くにあるものが小さく描かれていることがわかります。現在のサンプルを考えると、遠くにあるものが小さく見えるようにする簡単な方法の1つは、クリップ空間のXとYをZで割ることです。
このように考えてみてください:(10, 15)から(20,15)までの線がある場合、それは10単位の長さです。現在のサンプルでは、10ピクセルの長さで描画されます。しかし、Zで割ると、たとえばZが1の場合、
10 / 1 = 10 20 / 1 = 20 abs(10-20) = 10
10ピクセルの長さになります。Zが2の場合、
10 / 2 = 5 20 / 2 = 10 abs(5 - 10) = 5
5ピクセルの長さになります。Z = 3の場合、
10 / 3 = 3.333 20 / 3 = 6.666 abs(3.333 - 6.666) = 3.333
Zが大きくなるにつれて、小さくなるにつれて、最終的には小さく描画され、したがって、より遠くに見えることがわかります。クリップ空間で除算すると、Zがより小さい数値(0から+1)になるため、より良い結果が得られる可能性があります。除算する前にZに乗算するfudgeFactorを追加すると、特定の距離に対して物がどれだけ小さくなるかを調整できます。
試してみましょう。まず、頂点シェーダーを変更して、「fudgeFactor」で乗算した後にZで除算するようにします。
struct Uniforms {
matrix: mat4x4f,
+ fudgeFactor: f32,
};
struct Vertex {
@location(0) position: vec4f,
@location(1) color: vec4f,
};
struct VSOutput {
@builtin(position) position: vec4f,
@location(0) color: vec4f,
};
@group(0) @binding(0) var<uniform> uni: Uniforms;
@vertex fn vs(vert: Vertex) -> VSOutput {
var vsOut: VSOutput;
- vsOut.position = uni.matrix * vert.position;
+ let position = uni.matrix * vert.position;
+
+ let zToDivideBy = 1.0 + position.z * uni.fudgeFactor;
+
+ vsOut.position = vec4f(
+ position.xy / zToDivideBy,
+ position.zw);
vsOut.color = vert.color;
return vsOut;
}
注:1を追加することで、fudgeFactorを0に設定し、1に等しいzToDivideByを取得できます。これにより、Zで除算しない場合と比較できます。なぜなら、1で除算しても何も起こらないからです。
また、fudgeFactorを設定できるようにコードを更新する必要があります。
- // 行列
- const uniformBufferSize = (16) * 4;
+ // 行列、fudgeFactor、パディング
+ const uniformBufferSize = (16 + 1 + 3) * 4;
const uniformBuffer = device.createBuffer({
label: 'uniforms',
size: uniformBufferSize,
usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,
});
const uniformValues = new Float32Array(uniformBufferSize / 4);
// float32インデックスでのさまざまなユニフォーム値へのオフセット
const kMatrixOffset = 0;
+ const kFudgeFactorOffset = 16;
const matrixValue = uniformValues.subarray(kMatrixOffset, kMatrixOffset + 16);
+ const fudgeFactorValue = uniformValues.subarray(kFudgeFactorOffset, kFudgeFactorOffset + 1);
...
const settings = {
translation: [canvas.clientWidth / 2 - 200, canvas.clientHeight / 2 - 75, -1000],
rotation: [degToRad(40), degToRad(25), degToRad(325)],
scale: [3, 3, 3],
+ fudgeFactor: 0.5,
};
...
const gui = new GUI();
gui.onChange(render);
gui.add(settings.translation, '0', 0, 1000).name('translation.x');
gui.add(settings.translation, '1', 0, 1000).name('translation.y');
gui.add(settings.translation, '2', -1000, 1000).name('translation.z');
gui.add(settings.rotation, '0', radToDegOptions).name('rotation.x');
gui.add(settings.rotation, '1', radToDegOptions).name('rotation.y');
gui.add(settings.rotation, '2', radToDegOptions).name('rotation.z');
gui.add(settings.scale, '0', -5, 5).name('scale.x');
gui.add(settings.scale, '1', -5, 5).name('scale.y');
gui.add(settings.scale, '2', -5, 5).name('scale.z');
+ gui.add(settings, 'fudgeFactor', 0, 50);
...
function render() {
...
mat4.ortho(
0, // left
canvas.clientWidth, // right
canvas.clientHeight, // bottom
0, // top
1200, // near
-1000, // far
matrixValue, // dst
);
mat4.translate(matrixValue, settings.translation, matrixValue);
mat4.rotateX(matrixValue, settings.rotation[0], matrixValue);
mat4.rotateY(matrixValue, settings.rotation[1], matrixValue);
mat4.rotateZ(matrixValue, settings.rotation[2], matrixValue);
mat4.scale(matrixValue, settings.scale, matrixValue);
+ fudgeFactorValue[0] = settings.fudgeFactor;
また、結果が見やすくなるようにsettingsを調整しました。
const settings = {
- translation: [45, 100, 0],
+ translation: [canvas.clientWidth / 2 - 200, canvas.clientHeight / 2 - 75, -1000],
rotation: [degToRad(40), degToRad(25), degToRad(325)],
- scale: [1, 1, 1],
+ scale: [3, 3, 3],
fudgeFactor: 10,
};
そして、これが結果です。
明確でない場合は、「fudgeFactor」スライダーを10.0から0.0にドラッグして、Zで除算するコードを追加する前の様子を確認してください。
WebGPUは、頂点シェーダーの@builtin(position)に割り当てたx、y、z、wの値を取得し、それをwで自動的に除算することがわかりました。
これを非常に簡単に証明するには、シェーダーを変更し、自分で除算を行う代わりに、zToDivideByをvsOut.position.wに入れます。
@vertex fn vs(vert: Vertex) -> VSOutput {
var vsOut: VSOutput;
let position = uni.matrix * vert.position;
let zToDivideBy = 1.0 + position.z * uni.fudgeFactor;
- vsOut.position = vec4f(
- position.xy / zToDivideBy,
- position.zw);
+ vsOut.position = vec4f(position.xyz, zToDivideBy);
vsOut.color = vert.color;
return vsOut;
}
そして、それがまったく同じであることがわかります。
WebGPUが自動的にWで除算するという事実はなぜ便利なのでしょうか?なぜなら、今では、さらに多くの行列の魔法を使用して、zをwにコピーするための別の行列を使用するだけで済むからです。
このような行列
1 0 0 0 0 1 0 0 0 0 1 0 0 0 1 0
zをwにコピーします。これらの各行を次のように見ることができます。
x_out = x_in * 1 +
y_in * 0 +
z_in * 0 +
w_in * 0 ;
y_out = x_in * 0 +
y_in * 1 +
z_in * 0 +
w_in * 0 ;
z_out = x_in * 0 +
y_in * 0 +
z_in * 1 +
w_in * 0 ;
w_out = x_in * 0 +
y_in * 0 +
z_in * 1 +
w_in * 0 ;
単純化すると、次のようになります。
x_out = x_in; y_out = y_in; z_out = z_in; w_out = z_in;
w_inが常に1.0であることがわかっているので、この行列で以前にあったプラス1を追加できます。
1 0 0 0 0 1 0 0 0 0 1 0 0 0 1 1
これにより、Wの計算が次のように変更されます。
w_out = x_in * 0 +
y_in * 0 +
z_in * 1 +
w_in * 1 ;
そして、w_in = 1.0であることがわかっているので、実際には
w_out = z_in + 1;
最後に、行列がこれである場合、fudgeFactorを元に戻すことができます。
1 0 0 0 0 1 0 0 0 0 1 0 0 0 fudgeFactor 1
つまり
w_out = x_in * 0 +
y_in * 0 +
z_in * fudgeFactor +
w_in * 1 ;
そして、単純化すると、次のようになります。
w_out = z_in * fudgeFactor + 1;
では、プログラムを再度変更して、行列のみを使用するようにしましょう。
まず、頂点シェーダーを元に戻して、再び単純にしましょう。
struct Uniforms {
matrix: mat4x4f,
- fudgeFactor: f32,
};
struct Vertex {
@location(0) position: vec4f,
@location(1) color: vec4f,
};
struct VSOutput {
@builtin(position) position: vec4f,
@location(0) color: vec4f,
};
@group(0) @binding(0) var<uniform> uni: Uniforms;
@vertex fn vs(vert: Vertex) -> VSOutput {
var vsOut: VSOutput;
- let position = uni.matrix * vert.position;
-
- let zToDivideBy = 1.0 + position.z * uni.fudgeFactor;
-
- vsOut.position = vec4f(
- position.xy / zToDivideBy,
- position.zw);
vsOut position = uni.matrix * vert.position;
vsOut.color = vert.color;
return vsOut;
}
@fragment fn fs(vsOut: VSOutput) -> @location(0) vec4f {
return vsOut.color;
}
次に、Z→W行列を作成する関数を作成しましょう。
function makeZToWMatrix(fudgeFactor) {
return [
1, 0, 0, 0,
0, 1, 0, 0,
0, 0, 1, fudgeFactor,
0, 0, 0, 1,
];
}
そして、それを使用するようにコードを変更します。
- mat4.ortho(
+ const projection = mat4.ortho(
0, // left
canvas.clientWidth, // right
canvas.clientHeight, // bottom
0, // top
1200, // near
-1000, // far
- matrixValue, // dst
);
+ mat4.multiply(makeZToWMatrix(settings.fudgeFactor), projection, matrixValue);
mat4.translate(matrixValue, settings.translation, matrixValue);
mat4.rotateX(matrixValue, settings.rotation[0], matrixValue);
mat4.rotateY(matrixValue, settings.rotation[1], matrixValue);
mat4.rotateZ(matrixValue, settings.rotation[2], matrixValue);
mat4.scale(matrixValue, settings.scale, matrixValue);
そして、繰り返しになりますが、まったく同じであることに注意してください。
これらすべては、基本的に、Zで除算すると遠近感が得られ、WebGPUがこのZによる除算を便利に行ってくれることを示すためだけのものでした。
しかし、まだいくつかの問題があります。たとえば、Zを-1100あたりに設定すると、下のアニメーションのようなものが表示されます。
どうしたのでしょうか?なぜFが早く消えるのでしょうか?WebGPUがXとYを+1から-1にクリップするのと同じように、Zもクリップします。XとYとは異なり、Zは0から+1にクリップします。ここで見ているのは、クリップ空間でZ < 0です。
Wによる除算が適用されると、行列演算+Wによる除算は錐台を定義します。錐台の前面はZ = 0、背面はZ = 1です。その外側にあるものはすべてクリップされます。
錐台
名詞:
- 円錐または角錐の上部が底面に平行な平面で切り取られたもの
それを修正するための数学について詳しく説明することもできますが、2D射影を行ったのと同じ方法で導出できます。Zを取得し、ある量(平行移動)を加え、ある量をスケーリングする必要があり、目的の範囲を-1から+1に再マッピングできます。
クールなのは、これらすべてのステップを1つの行列で実行できることです。さらに良いことに、fudgeFactorの代わりに、fieldOfViewを決定し、それを実現するための適切な値を計算します。
行列を作成する関数は次のとおりです。
const mat4 = {
...
perspective(fieldOfViewYInRadians, aspect, zNear, zFar, dst) {
dst = dst || new Float32Array(16);
const f = Math.tan(Math.PI * 0.5 - 0.5 * fieldOfViewYInRadians);
const rangeInv = 1 / (zNear - zFar);
dst[0] = f / aspect;
dst[1] = 0;
dst[2] = 0;
dst[3] = 0;
dst[4] = 0;
dst[5] = f;
dst[6] = 0;
dst[7] = 0;
dst[8] = 0;
dst[9] = 0;
dst[10] = zFar * rangeInv;
dst[11] = -1;
dst[12] = 0;
dst[13] = 0;
dst[14] = zNear * zFar * rangeInv;
dst[15] = 0;
return dst;
}
この行列は、すべての変換を自動的に行います。単位をクリップ空間に調整し、角度で視野を選択できるように数学を行い、Zクリッピング空間を選択できるようにします。原点(0、0、0)に目またはカメラがあり、zNearとfieldOfViewが与えられると、zNearにあるものがZ = 0になり、zNearにあるものが中心の上下にfieldOfViewの半分であるものがそれぞれY = -1とY = 1になるように計算します。渡されたaspectで乗算するだけでXに使用するものを計算します。通常、これを表示領域のwidth / heightに設定します。最後に、zFarにあるものがZ = 1になるようにZで物をどれだけスケーリングするかを計算します。
これは、動作中の行列の図です。
行列は、錐台内の空間を取得し、それをクリップ空間に変換します。zNearは、物が前面でクリップされる場所を定義し、zFarは、物が背面でクリップされる場所を定義します。zNearを23に設定すると、回転するキューブの前面がクリップされるのがわかります。zFarを24に設定すると、キューブの背面がクリップされるのがわかります。
この関数を例で使用しましょう。
const settings = {
fieldOfView: degToRad(100),
translation: [canvas.clientWidth / 2 - 200, canvas.clientHeight / 2 - 75, -1000],
rotation: [degToRad(40), degToRad(25), degToRad(325)],
scale: [3, 3, 3],
- fudgeFactor: 10,
};
const radToDegOptions = { min: -360, max: 360, step: 1, converters: GUI.converters.radToDeg };
const gui = new GUI();
gui.onChange(render);
gui.add(settings, 'fieldOfView', {min: 1, max: 179, converters: GUI.converters.radToDeg});
- gui.add(settings.translation, '0', 0, 1000).name('translation.x');
- gui.add(settings.translation, '1', 0, 1000).name('translation.y');
- gui.add(settings.translation, '2', -1400, 1000).name('translation.z');
+ gui.add(settings.translation, '0', -1000, 1000).name('translation.x');
+ gui.add(settings.translation, '1', -1000, 1000).name('translation.y');
+ gui.add(settings.translation, '2', -1400, -100).name('translation.z');
gui.add(settings.rotation, '0', radToDegOptions).name('rotation.x');
gui.add(settings.rotation, '1', radToDegOptions).name('rotation.y');
gui.add(settings.rotation, '2', radToDegOptions).name('rotation.z');
- gui.add(settings.scale, '0', -5, 5).name('scale.x');
- gui.add(settings.scale, '1', -5, 5).name('scale.y');
- gui.add(settings.scale, '2', -5, 5).name('scale.z');
...
function render() {
....
- const projection = mat4.ortho(
- 0, // left
- canvas.clientWidth, // right
- canvas.clientHeight, // bottom
- 0, // top
- 1200, // near
- -1000, // far
- );
- mat4.multiply(makeZToWMatrix(settings.fudgeFactor), projection, matrixValue);
+ const aspect = canvas.clientWidth / canvas.clientHeight;
+ mat4.perspective(
+ settings.fieldOfView,
+ aspect,
+ 1, // zNear
+ 2000, // zFar
+ matrixValue,
+ );
mat4.translate(matrixValue, settings.translation, matrixValue);
mat4.rotateX(matrixValue, settings.rotation[0], matrixValue);
mat4.rotateY(matrixValue, settings.rotation[1], matrixValue);
mat4.rotateZ(matrixValue, settings.rotation[2], matrixValue);
mat4.scale(matrixValue, settings.scale, matrixValue);
まだ1つ問題があります。この射影行列は、0,0,0にビューアがあり、負のZ方向を見ていて、正のYが上であると仮定しています。これまでの行列は、異なる方法で物事を行ってきました。高さ150単位、幅100単位、厚さ30単位のFを、ある-Z位置に配置する必要があり、錐台の内側に収まるように十分に離れている必要があります。上記で定義した錐台は、zNear = 1で、オブジェクトが1単位離れている場合、上から下まで約2.4単位しか表示されないため、Fは画面の98%オフになります。
いくつかの数値をいじってみたところ、これらの設定になりました。
const settings = {
fieldOfView: degToRad(100),
- translation: [canvas.clientWidth / 2 - 200, canvas.clientHeight / 2 - 75, -1000],
- rotation: [degToRad(40), degToRad(25), degToRad(325)],
- scale: [3, 3, 3],
+ translation: [-65, 0, -120],
+ rotation: [degToRad(220), degToRad(25), degToRad(325)],
+ scale: [1, 1, 1],
};
そして、ついでに、UI設定をより適切なものに調整しましょう。また、UIを少しすっきりさせるためにスケールを削除しましょう。
const gui = new GUI();
gui.onChange(render);
gui.add(settings, 'fieldOfView', {min: 1, max: 179, converters: GUI.converters.radToDeg});
- gui.add(settings.translation, '0', 0, 1000).name('translation.x');
- gui.add(settings.translation, '1', 0, 1000).name('translation.y');
- gui.add(settings.translation, '2', -1400, 1000).name('translation.z');
+ gui.add(settings.translation, '0', -1000, 1000).name('translation.x');
+ gui.add(settings.translation, '1', -1000, 1000).name('translation.y');
+ gui.add(settings.translation, '2', -1400, -100).name('translation.z');
gui.add(settings.rotation, '0', radToDegOptions).name('rotation.x');
gui.add(settings.rotation, '1', radToDegOptions).name('rotation.y');
gui.add(settings.rotation, '2', radToDegOptions).name('rotation.z');
- gui.add(settings.scale, '0', -5, 5).name('scale.x');
- gui.add(settings.scale, '1', -5, 5).name('scale.y');
- gui.add(settings.scale, '2', -5, 5).name('scale.z');
「ピクセル空間」ではなくなったので、グリッドも削除しましょう。
:root {
--bg-color: #fff;
}
@media (prefers-color-scheme: dark) {
:root {
--bg-color: #000;
}
}
canvas {
display: block; /* canvasをブロックのように動作させる */
width: 100%; /* canvasがコンテナを埋めるようにする */
height: 100%;
}
そして、これがそれです。
シェーダーで1つの行列乗算に戻り、視野とZ空間の両方を選択できるようになりました。
次は、カメラです。