math: add well-tested, reverse-z 2D projection matrix

Helps hexops/mach#1103

Signed-off-by: Stephen Gutekanst <stephen@hexops.com>
This commit is contained in:
Stephen Gutekanst 2024-01-06 13:08:57 -07:00
parent fb69b0cda7
commit aa2435e10e

View file

@ -342,42 +342,51 @@ pub fn Mat(
); );
} }
// TODO: mandate negative-Z https://github.com/hexops/mach/issues/1103 /// Constructs a 2D projection matrix, aka. an orthographic projection matrix.
// /// Constructs an orthographic projection matrix; an orthogonal transformation matrix ///
// /// which transforms from the given left, right, bottom, and top dimensions into /// First, a cuboid is defined with the parameters:
// /// `(-1, +1)` in `(x, y)`, and `(0, +1)` in `z`. ///
// /// /// * (right - left) defining the distance between the left and right faces of the cube
// /// The near/far parameters denotes the depth (z coordinate) of the near/far clipping /// * (top - bottom) defining the distance between the top and bottom faces of the cube
// /// plane. /// * (near - far) defining the distance between the back (near) and front (far) faces of the cube
// /// ///
// /// Returns an orthographic projection matrix. /// We then need to construct a projection matrix which converts points in that
// // TODO: needs tests /// cuboid's space into clip space:
// pub inline fn ortho( ///
// /// The sides of the near clipping plane viewport /// https://machengine.org/engine/math/traversing-coordinate-systems/#view---clip-space
// left: f32, ///
// right: f32, /// Normally, in sysgpu/webgpu the depth buffer of floating point values would
// bottom: f32, /// have the range [0, 1] representing [near, far], i.e. a pixel very close to the
// top: f32, /// viewer would have a depth value of 0.0, and a pixel very far from the viewer
// /// The depth (z coordinate) of the near/far clipping plane. /// would have a depth value of 1.0. But this is an ineffective use of floating
// near: f32, /// point precision, a better approach is a reversed depth buffer:
// far: f32, ///
// ) Matrix { /// * https://webgpu.github.io/webgpu-samples/samples/reversedZ
// const xx = 2 / (right - left); /// * https://developer.nvidia.com/content/depth-precision-visualized
// const yy = 2 / (top - bottom); ///
// const zz = 1 / (near - far); /// Mach mandates the use of a reversed depth buffer, so the returned transformation
// const tx = (right + left) / (left - right); /// matrix maps to near=1 and far=0.
// const ty = (top + bottom) / (bottom - top); pub inline fn projection2D(v: struct {
// const tz = near / (near - far); left: f32,
// return init( right: f32,
// &RowVec.init(xx, 0, 0, tx), bottom: f32,
// &RowVec.init(0, yy, 0, ty), top: f32,
// &RowVec.init(0, 0, zz, tz), near: f32,
// &RowVec.init(0, 0, 0, 1), far: f32,
// ); }) Matrix {
// } var p = Matrix.ident;
p = p.mul(&Matrix.translate(math.vec3(
// TODO: add perspective projection matrix (v.right + v.left) / (v.left - v.right), // translate X so that the middle of (left, right) maps to x=0 in clip space
(v.top + v.bottom) / (v.bottom - v.top), // translate Y so that the middle of (bottom, top) maps to y=0 in clip space
v.near / (v.near - v.far), // translate Z so that far maps to z=0
)));
p = p.mul(&Matrix.scale(math.vec3(
2 / (v.right - v.left), // scale X so that [left, right] has a 2 unit range, e.g. [-1, +1]
2 / (v.top - v.bottom), // scale Y so that [bottom, top] has a 2 unit range, e.g. [-1, +1]
1 / (v.near - v.far), // scale Z so that [near, far] has a 1 unit range, e.g. [0, -1]
)));
return p;
}
}, },
else => @compileError("Expected Mat3x3, Mat4x4 found '" ++ @typeName(Matrix) ++ "'"), else => @compileError("Expected Mat3x3, Mat4x4 found '" ++ @typeName(Matrix) ++ "'"),
}; };
@ -764,7 +773,7 @@ test "Mat3x3_mulVec_vec3_ident" {
const v = math.Vec3.splat(1); const v = math.Vec3.splat(1);
const ident = math.Mat3x3.ident; const ident = math.Mat3x3.ident;
const expected = v; const expected = v;
var m = math.Mat3x3.mulVec(&ident, &v); const m = math.Mat3x3.mulVec(&ident, &v);
try testing.expect(math.Vec3, expected).eql(m); try testing.expect(math.Vec3, expected).eql(m);
} }
@ -858,3 +867,129 @@ test "Mat4x4_mul" {
); );
try testing.expect(math.Mat4x4, expected).eql(c); try testing.expect(math.Mat4x4, expected).eql(c);
} }
test "projection2D_xy_centered" {
const v = .{
.left = -400,
.right = 400,
.bottom = -200,
.top = 200,
.near = 0,
.far = 100,
};
const m = math.Mat4x4.projection2D(v);
// Calculate some reference points
const width = v.right - v.left;
const height = v.top - v.bottom;
const width_mid = v.left + (width / 2.0);
const height_mid = v.bottom + (height / 2.0);
try testing.expect(f32, 800).eql(width);
try testing.expect(f32, 400).eql(height);
try testing.expect(f32, 0).eql(width_mid);
try testing.expect(f32, 0).eql(height_mid);
// Probe some points on the X axis from beyond the left face, all the way to beyond the right face.
try testing.expect(math.Vec4, math.vec4(-2, 0, 0, 1)).eql(m.mulVec(&math.vec4(v.left - (width / 2), height_mid, 0, 1)));
try testing.expect(math.Vec4, math.vec4(-1, 0, 0, 1)).eql(m.mulVec(&math.vec4(v.left, height_mid, 0, 1)));
try testing.expect(math.Vec4, math.vec4(-0.5, 0, 0, 1)).eql(m.mulVec(&math.vec4(v.left + (width / 4.0), height_mid, 0, 1)));
try testing.expect(math.Vec4, math.vec4(0, 0, 0, 1)).eql(m.mulVec(&math.vec4(width_mid, height_mid, 0, 1)));
try testing.expect(math.Vec4, math.vec4(0.5, 0, 0, 1)).eql(m.mulVec(&math.vec4(v.right - (width / 4.0), height_mid, 0, 1)));
try testing.expect(math.Vec4, math.vec4(1, 0, 0, 1)).eql(m.mulVec(&math.vec4(v.right, height_mid, 0, 1)));
try testing.expect(math.Vec4, math.vec4(2, 0, 0, 1)).eql(m.mulVec(&math.vec4(v.right + (width / 2), height_mid, 0, 1)));
// Probe some points on the Y axis from beyond the bottom face, all the way to beyond the top face.
try testing.expect(math.Vec4, math.vec4(0, -2, 0, 1)).eql(m.mulVec(&math.vec4(width_mid, v.bottom - (height / 2), 0, 1)));
try testing.expect(math.Vec4, math.vec4(0, -1, 0, 1)).eql(m.mulVec(&math.vec4(width_mid, v.bottom, 0, 1)));
try testing.expect(math.Vec4, math.vec4(0, -0.5, 0, 1)).eql(m.mulVec(&math.vec4(width_mid, v.bottom + (height / 4.0), 0, 1)));
try testing.expect(math.Vec4, math.vec4(0, 0, 0, 1)).eql(m.mulVec(&math.vec4(width_mid, height_mid, 0, 1)));
try testing.expect(math.Vec4, math.vec4(0, 0.5, 0, 1)).eql(m.mulVec(&math.vec4(width_mid, v.top - (height / 4.0), 0, 1)));
try testing.expect(math.Vec4, math.vec4(0, 1, 0, 1)).eql(m.mulVec(&math.vec4(width_mid, v.top, 0, 1)));
try testing.expect(math.Vec4, math.vec4(0, 2, 0, 1)).eql(m.mulVec(&math.vec4(width_mid, v.top + (height / 2), 0, 1)));
}
test "projection2D_xy_offcenter" {
const v = .{
.left = 100,
.right = 500,
.bottom = 100,
.top = 500,
.near = 0,
.far = 100,
};
const m = math.Mat4x4.projection2D(v);
// Calculate some reference points
const width = v.right - v.left;
const height = v.top - v.bottom;
const width_mid = v.left + (width / 2.0);
const height_mid = v.bottom + (height / 2.0);
try testing.expect(f32, 400).eql(width);
try testing.expect(f32, 400).eql(height);
try testing.expect(f32, 300).eql(width_mid);
try testing.expect(f32, 300).eql(height_mid);
// Probe some points on the X axis from beyond the left face, all the way to beyond the right face.
try testing.expect(math.Vec4, math.vec4(-2, 0, 0, 1)).eql(m.mulVec(&math.vec4(v.left - (width / 2), height_mid, 0, 1)));
try testing.expect(math.Vec4, math.vec4(-1, 0, 0, 1)).eql(m.mulVec(&math.vec4(v.left, height_mid, 0, 1)));
try testing.expect(math.Vec4, math.vec4(-0.5, 0, 0, 1)).eql(m.mulVec(&math.vec4(v.left + (width / 4.0), height_mid, 0, 1)));
try testing.expect(math.Vec4, math.vec4(0, 0, 0, 1)).eql(m.mulVec(&math.vec4(width_mid, height_mid, 0, 1)));
try testing.expect(math.Vec4, math.vec4(0.5, 0, 0, 1)).eql(m.mulVec(&math.vec4(v.right - (width / 4.0), height_mid, 0, 1)));
try testing.expect(math.Vec4, math.vec4(1, 0, 0, 1)).eql(m.mulVec(&math.vec4(v.right, height_mid, 0, 1)));
try testing.expect(math.Vec4, math.vec4(2, 0, 0, 1)).eql(m.mulVec(&math.vec4(v.right + (width / 2), height_mid, 0, 1)));
// Probe some points on the Y axis from beyond the bottom face, all the way to beyond the top face.
try testing.expect(math.Vec4, math.vec4(0, -2, 0, 1)).eql(m.mulVec(&math.vec4(width_mid, v.bottom - (height / 2), 0, 1)));
try testing.expect(math.Vec4, math.vec4(0, -1, 0, 1)).eql(m.mulVec(&math.vec4(width_mid, v.bottom, 0, 1)));
try testing.expect(math.Vec4, math.vec4(0, -0.5, 0, 1)).eql(m.mulVec(&math.vec4(width_mid, v.bottom + (height / 4.0), 0, 1)));
try testing.expect(math.Vec4, math.vec4(0, 0, 0, 1)).eql(m.mulVec(&math.vec4(width_mid, height_mid, 0, 1)));
try testing.expect(math.Vec4, math.vec4(0, 0.5, 0, 1)).eql(m.mulVec(&math.vec4(width_mid, v.top - (height / 4.0), 0, 1)));
try testing.expect(math.Vec4, math.vec4(0, 1, 0, 1)).eql(m.mulVec(&math.vec4(width_mid, v.top, 0, 1)));
try testing.expect(math.Vec4, math.vec4(0, 2, 0, 1)).eql(m.mulVec(&math.vec4(width_mid, v.top + (height / 2), 0, 1)));
}
test "projection2D_z" {
const m = math.Mat4x4.projection2D(.{
// Set x=0 and y=0 as centers, so we can specify 0 centers in our testing.expects below
.left = -400,
.right = 400,
.bottom = -200,
.top = 200,
// Choose some near/far plane values that we can easily test against
// We'll have [near, far] == [-100, 100] == [0, 1]
.near = -100,
.far = 100,
});
// Probe some points on the Z axis from the near plane, all the way to the far plane.
try testing.expect(math.Vec4, math.vec4(0, 0, 1, 1)).eql(m.mulVec(&math.vec4(0, 0, -100, 1)));
try testing.expect(math.Vec4, math.vec4(0, 0, 0.75, 1)).eql(m.mulVec(&math.vec4(0, 0, -50, 1)));
try testing.expect(math.Vec4, math.vec4(0, 0, 0.5, 1)).eql(m.mulVec(&math.vec4(0, 0, 0, 1)));
try testing.expect(math.Vec4, math.vec4(0, 0, 0.25, 1)).eql(m.mulVec(&math.vec4(0, 0, 50, 1)));
try testing.expect(math.Vec4, math.vec4(0, 0, 0, 1)).eql(m.mulVec(&math.vec4(0, 0, 100, 1)));
// Probe some points outside the near/far planes
try testing.expect(math.Vec4, math.vec4(0, 0, 2, 1)).eql(m.mulVec(&math.vec4(0, 0, -100 - 200, 1)));
try testing.expect(math.Vec4, math.vec4(0, 0, -1, 1)).eql(m.mulVec(&math.vec4(0, 0, 100 + 200, 1)));
}
test "projection2D_model_to_clip_space" {
const model = math.Mat4x4.ident;
const view = math.Mat4x4.ident;
const proj = math.Mat4x4.projection2D(.{
.left = -50,
.right = 50,
.bottom = -50,
.top = 50,
.near = 0,
.far = 100,
});
const mvp = model.mul(&view).mul(&proj);
try testing.expect(math.Vec4, math.vec4(0, 0, 0, 1)).eql(mvp.mulVec(&math.vec4(0, 0, 0, 1)));
try testing.expect(math.Vec4, math.vec4(0, 0, -0.5, 1)).eql(mvp.mulVec(&math.vec4(0, 0, 50, 1)));
try testing.expect(math.Vec4, math.vec4(0, -1, 0, 1)).eql(mvp.mul(&math.Mat4x4.rotateX(math.degreesToRadians(f32, 90))).mulVec(&math.vec4(0, 0, 50, 1)));
try testing.expect(math.Vec4, math.vec4(1, 0, 0, 1)).eql(mvp.mul(&math.Mat4x4.rotateY(math.degreesToRadians(f32, 90))).mulVec(&math.vec4(0, 0, 50, 1)));
try testing.expect(math.Vec4, math.vec4(0, 0, -0.5, 1)).eql(mvp.mul(&math.Mat4x4.rotateZ(math.degreesToRadians(f32, 90))).mulVec(&math.vec4(0, 0, 50, 1)));
}