Skip to content

Commit 292e86f

Browse files
RaananWCopilot
andcommitted
[WebGPU-XR][Phase 3] Range-correct XR projection matrix for half-Z (WebGPU) engines
On a WebGPU engine (isNDCHalfZRange === true) the clip-space depth range is [0, 1], while WebGL/OpenGL uses [-1, 1]. WebXRCamera copies the XR binding's projection matrix verbatim and the rig cameras freeze their projection, so the engine's range-aware projection builders are bypassed. If the binding returns a [-1, 1]-convention matrix while the engine clips at [0, 1], every fragment with NDC z in [-1, 0) is clipped and all world geometry disappears (clear color still shows) - matching the in-headset "meshes flash a frame then vanish" symptom. Fix (WebGPU-gated, WebGL2 byte-identical, zero net public API): - Add @internal Matrix.convertProjectionToHalfZRangeInPlace(), reusing the engine's existing mtxConvertNDCToHalfZRange remap (the same conversion the perspective/orthographic builders apply when halfZRange is set). - In WebXRCamera._updateFromXRSession, on a half-Z engine, empirically detect the convention the binding used (project the view-space near point through the raw matrix: NDC z ~= -1 => [-1, 1], ~= 0 => [0, 1]) and convert only a [-1, 1] matrix, so a spec-compliant [0, 1] binding is never double-converted. Guards minZ <= 0 (avoids divide-by-zero) and assumes non-reverse-Z. WebGL2 (isNDCHalfZRange === false) never enters the block. Adds 6 unit tests: [-1,1]->[0,1] conversion, [0,1] left unchanged, WebGL2 byte-identical, asymmetric off-center frusta, minZ<=0 guard, and converter equivalence to the engine's half-Z builder. Refs #18639 Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
1 parent 2058ed7 commit 292e86f

3 files changed

Lines changed: 172 additions & 1 deletion

File tree

packages/dev/core/src/Maths/math.vector.pure.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7315,6 +7315,20 @@ export class Matrix implements Tensor<Tuple<Tuple<number, 4>, 4>, Matrix>, IMatr
73157315
return this;
73167316
}
73177317

7318+
/**
7319+
* Converts this projection matrix's clip-space depth range from the OpenGL/WebGL convention (NDC z in [-1, 1]) to
7320+
* the WebGPU/D3D convention (NDC z in [0, 1]) in place, by post-multiplying with the standard half-Z conversion
7321+
* matrix. This is the same conversion the perspective/orthographic builders apply internally when their
7322+
* `halfZRange` flag is set; it is exposed here for callers (e.g. the WebXR path) that receive a projection matrix
7323+
* verbatim from an external source and must range-correct it for a `isNDCHalfZRange` engine.
7324+
* @internal
7325+
* @returns the current updated matrix
7326+
*/
7327+
public convertProjectionToHalfZRangeInPlace(): this {
7328+
this.multiplyToRef(mtxConvertNDCToHalfZRange, this);
7329+
return this;
7330+
}
7331+
73187332
// Statics
73197333
/**
73207334
* Creates a matrix from an array

packages/dev/core/src/XR/webXRCamera.ts

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -322,6 +322,33 @@ export class WebXRCamera extends FreeCamera {
322322
}
323323
Matrix.FromFloat32ArrayToRefScaled(view.projectionMatrix, 0, 1, currentRig._projectionMatrix);
324324

325+
// WebGPU uses a [0, 1] clip-space depth range (engine.isNDCHalfZRange === true) whereas WebGL/OpenGL uses
326+
// [-1, 1]. The rig cameras freeze their projection (see _updateNumberOfRigCameras) and take the XR binding's
327+
// projection matrix verbatim, bypassing the engine's range-aware projection builders. If the binding hands
328+
// back a [-1, 1]-convention matrix while the engine clips at [0, 1], every fragment with NDC z in [-1, 0)
329+
// is clipped and all geometry disappears. On a half-Z engine we therefore detect the convention the binding
330+
// actually used and convert a [-1, 1] matrix to [0, 1]. Detection is empirical (coefficient inspection is
331+
// provably ambiguous): project the view-space near-plane point through the raw matrix, before the hand
332+
// toggle. WebXR view space is right-handed (-Z forward), so the near point is (0, 0, -near); a [-1, 1] matrix
333+
// maps it to NDC z ~= -1, a [0, 1] matrix to ~= 0. Assumes a non-reverse-Z projection (near -> 0 or -1),
334+
// which UA-provided XR matrices are. WebGL2 (isNDCHalfZRange === false) never enters this block, so its path
335+
// is byte-identical.
336+
if (this._scene.getEngine().isNDCHalfZRange) {
337+
const near = this.minZ;
338+
let needsHalfZConversion: boolean;
339+
if (near > 0) {
340+
const ndc = Vector3.TransformCoordinatesFromFloatsToRef(0, 0, -near, currentRig._projectionMatrix, TmpVectors.Vector3[0]);
341+
needsHalfZConversion = ndc.z < -0.5;
342+
} else {
343+
// depthNear must be > 0 per the WebXR spec; if it is not, the near-plane probe would divide by zero,
344+
// so default to converting (matches the [-1, 1] range current XRGPUBinding implementations return).
345+
needsHalfZConversion = true;
346+
}
347+
if (needsHalfZConversion) {
348+
currentRig._projectionMatrix.convertProjectionToHalfZRangeInPlace();
349+
}
350+
}
351+
325352
if (!this._scene.useRightHandedSystem) {
326353
currentRig._projectionMatrix.toggleProjectionMatrixHandInPlace();
327354
}

packages/dev/core/test/unit/XR/webXRCamera.test.ts

Lines changed: 131 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ import { NullEngine } from "core/Engines";
66
import { Scene } from "core/scene";
77
import { WebXRCamera } from "core/XR/webXRCamera";
88
import { WebXRSessionManager } from "core/XR/webXRSessionManager";
9-
import { Vector3, Quaternion } from "core/Maths/math.vector";
9+
import { Vector3, Quaternion, Matrix } from "core/Maths/math.vector";
1010
import { FreeCamera } from "core/Cameras/freeCamera";
1111
import { Camera } from "core/Cameras/camera";
1212
import { beforeEach, afterEach, describe, it, expect } from "vitest";
@@ -155,4 +155,134 @@ describe("WebXRCamera", () => {
155155
expect(xrCamera.rigCameras[1].outputRenderTarget).toBeNull();
156156
});
157157
});
158+
159+
describe("projection depth-range (WebGPU half-Z) handling", () => {
160+
// Builds a synthetic right-handed XR projection matrix (as an XR binding would provide it) in the requested
161+
// NDC depth convention. halfZRange === false => WebGL/OpenGL [-1, 1]; true => WebGPU/D3D [0, 1].
162+
function buildXRProjection(halfZRange: boolean, near = 0.1, far = 1000): Float32Array {
163+
const m = new Matrix();
164+
Matrix.PerspectiveFovRHToRef(Math.PI / 2, 1, near, far, m, true, halfZRange);
165+
return Float32Array.from(m.asArray());
166+
}
167+
168+
// Drives one XR view update with the given raw binding projection matrix and returns the resulting frozen
169+
// rig-camera projection matrix. Tests set scene.useRightHandedSystem to control whether the LH hand-toggle runs.
170+
function driveViewUpdate(projectionMatrix: Float32Array): Matrix {
171+
const pose = {
172+
emulatedPosition: false,
173+
transform: { position: { x: 0, y: 0, z: 0 }, orientation: { x: 0, y: 0, z: 0, w: 1 } },
174+
views: [
175+
{
176+
eye: "left",
177+
transform: { position: { x: 0, y: 0, z: 0 }, orientation: { x: 0, y: 0, z: 0, w: 1 } },
178+
projectionMatrix,
179+
},
180+
],
181+
};
182+
sessionManager.currentFrame = { getViewerPose: () => pose } as any;
183+
// There is no real XR session in unit tests, so seed the near/far cache to skip the updateRenderState path.
184+
(xrCamera as any)._cache.minZ = xrCamera.minZ;
185+
(xrCamera as any)._cache.maxZ = xrCamera.maxZ;
186+
(xrCamera as any)._updateFromXRSession();
187+
return (xrCamera.rigCameras[0] as any)._projectionMatrix as Matrix;
188+
}
189+
190+
// NDC z of the right-handed view-space point (0, 0, z) after the perspective divide.
191+
function ndcZ(matrix: Matrix, z: number): number {
192+
return Vector3.TransformCoordinatesFromFloatsToRef(0, 0, z, matrix, new Vector3()).z;
193+
}
194+
195+
it("converts a [-1, 1] XR projection to [0, 1] on a half-Z (WebGPU) engine", () => {
196+
scene.useRightHandedSystem = true; // isolate the depth-range conversion from the LH hand-toggle
197+
(engine as any).isNDCHalfZRange = true;
198+
199+
const result = driveViewUpdate(buildXRProjection(false, 0.1, 1000));
200+
201+
expect(ndcZ(result, -0.1)).toBeCloseTo(0, 4); // near plane -> 0
202+
expect(ndcZ(result, -1000)).toBeCloseTo(1, 4); // far plane -> 1
203+
});
204+
205+
it("leaves an already [0, 1] XR projection unchanged on a half-Z engine (no double conversion)", () => {
206+
scene.useRightHandedSystem = true;
207+
(engine as any).isNDCHalfZRange = true;
208+
209+
const raw = buildXRProjection(true, 0.1, 1000);
210+
const result = driveViewUpdate(raw);
211+
212+
// Still maps near -> 0 / far -> 1 (would be ~0.25 / ~1 if double-converted).
213+
expect(ndcZ(result, -0.1)).toBeCloseTo(0, 4);
214+
expect(ndcZ(result, -1000)).toBeCloseTo(1, 4);
215+
// And it is numerically the raw matrix (conversion skipped entirely).
216+
const rawMatrix = Matrix.FromArray(raw);
217+
for (let k = 0; k < 16; k++) {
218+
expect(result.m[k]).toBeCloseTo(rawMatrix.m[k], 6);
219+
}
220+
});
221+
222+
it("never touches the projection on a non-half-Z (WebGL2) engine - byte identical", () => {
223+
// NullEngine defaults isNDCHalfZRange to false; assert explicitly for clarity.
224+
(engine as any).isNDCHalfZRange = false;
225+
226+
const raw = buildXRProjection(false, 0.1, 1000); // WebGL [-1, 1] convention
227+
const result = driveViewUpdate(raw); // default LH scene => hand-toggle applies
228+
229+
// Expected = verbatim load + hand-toggle, with NO range conversion (the WebGPU block is skipped).
230+
const expected = Matrix.FromArray(raw);
231+
expected.toggleProjectionMatrixHandInPlace();
232+
for (let k = 0; k < 16; k++) {
233+
expect(result.m[k]).toBe(expected.m[k]); // exact / byte-identical
234+
}
235+
});
236+
237+
it("converts an asymmetric (off-center) [-1, 1] XR projection - real XR eye frusta", () => {
238+
scene.useRightHandedSystem = true;
239+
(engine as any).isNDCHalfZRange = true;
240+
241+
// Start from a symmetric [-1, 1] projection and introduce off-center x/y projection-plane offsets
242+
// (m[8], m[9]) to mimic a real, asymmetric XR eye frustum. These do not affect the depth mapping.
243+
const raw = buildXRProjection(false, 0.1, 1000);
244+
raw[8] = 0.2;
245+
raw[9] = -0.15;
246+
const result = driveViewUpdate(raw);
247+
248+
// Depth still maps to [0, 1] despite the asymmetry...
249+
expect(ndcZ(result, -0.1)).toBeCloseTo(0, 4);
250+
expect(ndcZ(result, -1000)).toBeCloseTo(1, 4);
251+
// ...and the asymmetry (x/y offset columns) is preserved by the conversion (it only remaps the z column).
252+
expect(result.m[8]).toBeCloseTo(0.2, 6);
253+
expect(result.m[9]).toBeCloseTo(-0.15, 6);
254+
});
255+
256+
it("guards depthNear <= 0: defaults to converting without producing NaN", () => {
257+
scene.useRightHandedSystem = true;
258+
(engine as any).isNDCHalfZRange = true;
259+
xrCamera.minZ = 0; // near-plane probe would divide by zero -> guard path
260+
261+
const raw = buildXRProjection(false, 0.1, 1000);
262+
const result = driveViewUpdate(raw);
263+
264+
for (let k = 0; k < 16; k++) {
265+
expect(Number.isNaN(result.m[k])).toBe(false);
266+
}
267+
// Guard forces conversion; result equals the raw matrix with the half-Z conversion applied.
268+
const expected = Matrix.FromArray(raw).convertProjectionToHalfZRangeInPlace();
269+
for (let k = 0; k < 16; k++) {
270+
expect(result.m[k]).toBeCloseTo(expected.m[k], 6);
271+
}
272+
});
273+
274+
it("convertProjectionToHalfZRangeInPlace matches the engine's own half-Z builder", () => {
275+
// The @internal converter must reproduce exactly what PerspectiveFovRHToRef bakes in when halfZRange is set.
276+
const converted = new Matrix();
277+
Matrix.PerspectiveFovRHToRef(1.2, 1.5, 0.2, 500, converted, true, false); // [-1, 1]
278+
converted.convertProjectionToHalfZRangeInPlace();
279+
280+
const reference = new Matrix();
281+
Matrix.PerspectiveFovRHToRef(1.2, 1.5, 0.2, 500, reference, true, true); // [0, 1]
282+
283+
for (let k = 0; k < 16; k++) {
284+
expect(converted.m[k]).toBeCloseTo(reference.m[k], 6);
285+
}
286+
});
287+
});
158288
});

0 commit comments

Comments
 (0)