From e1ae74c74ab560bd864f474030b0678677796502 Mon Sep 17 00:00:00 2001 From: Martin Valigursky Date: Fri, 1 May 2026 13:18:02 +0100 Subject: [PATCH] fix(gsplat): align projector projection with WebGPU shader uniforms Apply the same flip-Y and WebGPU clip-depth projection transform as matrix_projection so hybrid raster depth matches opaque geometry. Centralize transforms on Camera; pass flipY into hybrid projector; remove throttled matrix debug logs. --- src/scene/camera.js | 43 +++++++++++++++++++ .../gsplat-compute-local-renderer.js | 11 +++-- .../gsplat-unified/gsplat-hybrid-renderer.js | 10 +++-- src/scene/gsplat-unified/gsplat-manager.js | 1 + src/scene/gsplat-unified/gsplat-projector.js | 13 ++++-- src/scene/renderer/renderer.js | 27 ++---------- 6 files changed, 71 insertions(+), 34 deletions(-) diff --git a/src/scene/camera.js b/src/scene/camera.js index 2108dc50f20..de11e794795 100644 --- a/src/scene/camera.js +++ b/src/scene/camera.js @@ -34,6 +34,49 @@ const _frustumPoints = [new Vec3(), new Vec3(), new Vec3(), new Vec3(), new Vec3 * @ignore */ class Camera { + /** @private */ + static _flipYProjectionMatrix = new Mat4().setScale(1, -1, 1); + + /** @private */ + static _webGpuDepthRangeMatrix = new Mat4().set([ + 1, 0, 0, 0, + 0, 1, 0, 0, + 0, 0, 0.5, 0, + 0, 0, 0.5, 1 + ]); + + /** @private */ + static _applyShaderProjectionScratch = new Mat4(); + + /** + * Builds the projection matrix matching shader `matrix_projection` after optional flip-Y + * for render targets and optional WebGPU clip-depth range adjustment. + * + * @param {Mat4} projection - Source projection ({@link Camera#projectionMatrix}). + * @param {Mat4} out - Receives the transformed matrix. + * @param {boolean} flipY - When true, apply render-target Y flip first. + * @param {boolean} applyWebGpuDepthRange - When true, map clip Z from -1..1 to 0..1. + * @returns {Mat4} out + */ + static applyShaderProjectionTransform(projection, out, flipY, applyWebGpuDepthRange) { + if (!flipY && !applyWebGpuDepthRange) { + out.copy(projection); + return out; + } + if (flipY && applyWebGpuDepthRange) { + const scratch = Camera._applyShaderProjectionScratch; + scratch.mul2(Camera._flipYProjectionMatrix, projection); + out.mul2(Camera._webGpuDepthRangeMatrix, scratch); + return out; + } + if (flipY) { + out.mul2(Camera._flipYProjectionMatrix, projection); + return out; + } + out.mul2(Camera._webGpuDepthRangeMatrix, projection); + return out; + } + /** * @type {ShaderPassInfo|null} */ diff --git a/src/scene/gsplat-unified/gsplat-compute-local-renderer.js b/src/scene/gsplat-unified/gsplat-compute-local-renderer.js index 9e3d73e2d32..cbbe889353f 100644 --- a/src/scene/gsplat-unified/gsplat-compute-local-renderer.js +++ b/src/scene/gsplat-unified/gsplat-compute-local-renderer.js @@ -18,6 +18,7 @@ import { GSPLAT_FORWARD, PROJECTION_ORTHOGRAPHIC, FOG_NONE, GSPLAT_DEBUG_HEATMAP import { Debug } from '../../core/debug.js'; import { Color } from '../../core/math/color.js'; import { Mat4 } from '../../core/math/mat4.js'; +import { Camera } from '../camera.js'; import { GSplatRenderer } from './gsplat-renderer.js'; import { FramePassGSplatComputeLocal } from './frame-pass-gsplat-compute-local.js'; import { computeGsplatLocalDispatchPrepSource } from '../shader-lib/wgsl/chunks/gsplat/compute-gsplat-local-dispatch-prep.js'; @@ -85,6 +86,7 @@ const MAX_CHUNKS_PER_TILE = 8; const _viewProjMat = new Mat4(); const _viewProjData = new Float32Array(16); const _viewData = new Float32Array(16); +const _shaderProjMat = new Mat4(); const _fogColorLinear = new Color(); const _fogColorArray = new Float32Array(3); @@ -640,16 +642,17 @@ class GSplatComputeLocalRenderer extends GSplatRenderer { const cam = camera.camera; const view = cam.viewMatrix; - const proj = cam.projectionMatrix; - _viewProjMat.mul2(proj, view); + const flipY = !!camera.renderTarget?.flipY; + const webgpu = device.isWebGPU; + _viewProjMat.mul2(Camera.applyShaderProjectionTransform(cam.projectionMatrix, _shaderProjMat, flipY, webgpu), view); _viewProjData.set(_viewProjMat.data); _viewData.set(view.data); - const focal = width * proj.data[0]; + const focal = width * _shaderProjMat.data[0]; const alphaClip = pickMode ? this._alphaClip : (1.0 / 255.0); // Ensure fisheyeProj is up-to-date (culling may not have run this frame) - this.fisheyeProj.update(this._fisheye, camera.fov, proj); + this.fisheyeProj.update(this._fisheye, camera.fov, cam.projectionMatrix); const fisheyeEnabled = this.fisheyeProj.enabled; const createCountShader = (pick, fisheye) => this._createCountShaderAndFormat(pick, fisheye); diff --git a/src/scene/gsplat-unified/gsplat-hybrid-renderer.js b/src/scene/gsplat-unified/gsplat-hybrid-renderer.js index 3de74084e0a..45dc0d244d4 100644 --- a/src/scene/gsplat-unified/gsplat-hybrid-renderer.js +++ b/src/scene/gsplat-unified/gsplat-hybrid-renderer.js @@ -9,12 +9,14 @@ import { GSplatResourceBase } from '../gsplat/gsplat-resource-base.js'; import { MeshInstance } from '../mesh-instance.js'; import { GSplatRenderer } from './gsplat-renderer.js'; import { CACHE_STRIDE } from './gsplat-projector-constants.js'; +import { Camera } from '../camera.js'; -// Module-scope scratch matrix used only inside `_computeClipToViewZ`. The output +// Module-scope scratch matrices used only inside `_computeClipToViewZ`. The output // (`Float32Array`) lives on each renderer instance because it must remain valid // until the GPU upload happens at draw time, and multiple renderer instances may // render concurrently with different cameras. const _invProjMat = new Mat4(); +const _shaderProjMat = new Mat4(); /** * @import { StorageBuffer } from '../../platform/graphics/storage-buffer.js' @@ -277,7 +279,8 @@ class GSplatHybridRenderer extends GSplatRenderer { * @private */ _computeClipToViewZ(cameraNode, dst) { - const cam = cameraNode.camera; + const camComp = cameraNode.camera; + const cam = camComp.camera; if (this.fisheyeProj.enabled) { const near = cam.nearClip; const far = cam.farClip; @@ -287,7 +290,8 @@ class GSplatHybridRenderer extends GSplatRenderer { dst[3] = near; return; } - _invProjMat.copy(cam.projectionMatrix).invert(); + const flipY = !!camComp.renderTarget?.flipY; + _invProjMat.copy(Camera.applyShaderProjectionTransform(cam.projectionMatrix, _shaderProjMat, flipY, this.device.isWebGPU)).invert(); const d = _invProjMat.data; dst[0] = -d[2]; dst[1] = -d[6]; diff --git a/src/scene/gsplat-unified/gsplat-manager.js b/src/scene/gsplat-unified/gsplat-manager.js index 3e36f5ec65f..242ed0dc696 100644 --- a/src/scene/gsplat-unified/gsplat-manager.js +++ b/src/scene/gsplat-unified/gsplat-manager.js @@ -1804,6 +1804,7 @@ class GSplatManager { minContribution: gsplat.minContribution, viewportWidth, viewportHeight, + flipY: !!cameraNode.camera.renderTarget?.flipY, pickMode, fisheyeProj }); diff --git a/src/scene/gsplat-unified/gsplat-projector.js b/src/scene/gsplat-unified/gsplat-projector.js index d5e296af925..2ec675e0934 100644 --- a/src/scene/gsplat-unified/gsplat-projector.js +++ b/src/scene/gsplat-unified/gsplat-projector.js @@ -27,6 +27,7 @@ import { UNIFORMTYPE_VEC3 } from '../../platform/graphics/constants.js'; import { PROJECTION_ORTHOGRAPHIC } from '../constants.js'; +import { Camera } from '../camera.js'; import { GSplatResourceBase } from '../gsplat/gsplat-resource-base.js'; import { GSplatSortBinWeights } from './gsplat-sort-bin-weights.js'; import { CACHE_STRIDE } from './gsplat-projector-constants.js'; @@ -51,6 +52,7 @@ const _dispatchSize = new Vec2(); const _viewProjMat = new Mat4(); const _viewProjData = new Float32Array(16); const _viewData = new Float32Array(16); +const _shaderProjMat = new Mat4(); /** * Owns the per-splat compute pass for the hybrid GSplat renderer (Pass B in the pipeline): @@ -449,6 +451,8 @@ class GSplatProjector { * @param {number} params.minContribution - Minimum total contribution before culling. * @param {number} params.viewportWidth - Render viewport width in pixels. * @param {number} params.viewportHeight - Render viewport height in pixels. + * @param {boolean} params.flipY - Whether the active render target uses `flipY` (must match + * {@link Renderer#setCameraUniforms}). * @param {boolean} [params.pickMode] - Whether to write picking IDs into the cache. * @param {import('../graphics/fisheye-projection.js').FisheyeProjection} [params.fisheyeProj] - * Fisheye projection state. When `fisheyeProj.enabled` is true the projector picks the @@ -459,7 +463,8 @@ class GSplatProjector { workBuffer, cameraNode, compactedSplatIds, sortElementCountBuffer, totalCapacity, radialSort, numBits, minDist, maxDist, alphaClip, minPixelSize, minContribution, - viewportWidth, viewportHeight, pickMode = false, + viewportWidth, viewportHeight, flipY, + pickMode = false, fisheyeProj } = params; @@ -508,12 +513,12 @@ class GSplatProjector { const cameraComponent = cameraNode.camera; const cam = cameraComponent.camera; const view = cam.viewMatrix; - const proj = cam.projectionMatrix; - _viewProjMat.mul2(proj, view); + const webgpu = this.device.isWebGPU; + _viewProjMat.mul2(Camera.applyShaderProjectionTransform(cam.projectionMatrix, _shaderProjMat, flipY, webgpu), view); _viewProjData.set(_viewProjMat.data); _viewData.set(view.data); - const focal = viewportWidth * proj.data[0]; + const focal = viewportWidth * _shaderProjMat.data[0]; this.cameraPositionData[0] = cameraPos.x; this.cameraPositionData[1] = cameraPos.y; diff --git a/src/scene/renderer/renderer.js b/src/scene/renderer/renderer.js index 5222f91e963..8ed97594f2e 100644 --- a/src/scene/renderer/renderer.js +++ b/src/scene/renderer/renderer.js @@ -38,9 +38,9 @@ import { ShadowRendererDirectional } from './shadow-renderer-directional.js'; import { ShadowRenderer } from './shadow-renderer.js'; import { WorldClustersAllocator } from './world-clusters-allocator.js'; import { FramePassUpdateClustered } from './frame-pass-update-clustered.js'; +import { Camera } from '../camera.js'; /** - * @import { Camera } from '../camera.js' * @import { CulledInstances } from '../layer.js' * @import { GraphicsDevice } from '../../platform/graphics/graphics-device.js' * @import { LayerComposition } from '../composition/layer-composition.js' @@ -58,19 +58,10 @@ const viewMat = new Mat4(); const viewMat3 = new Mat3(); const tempSphere = new BoundingSphere(); const tempFrustum = new Frustum(); -const _flipYMat = new Mat4().setScale(1, -1, 1); const _tempLightSet = new Set(); const _tempLayerSet = new Set(); const _dynamicBindGroup = new DynamicBindGroup(); -// Converts a projection matrix in OpenGL style (depth range of -1..1) to a DirectX style (depth range of 0..1). -const _fixProjRangeMat = new Mat4().set([ - 1, 0, 0, 0, - 0, 1, 0, 0, - 0, 0, 0.5, 0, - 0, 0, 0.5, 1 -]); - // helton sequence of 2d offsets for jittering const _haltonSequence = [ new Vec2(0.5, 0.333333), @@ -93,8 +84,6 @@ const _haltonSequence = [ const _tempProjMat0 = new Mat4(); const _tempProjMat1 = new Mat4(); -const _tempProjMat2 = new Mat4(); -const _tempProjMat3 = new Mat4(); const _tempProjMat4 = new Mat4(); const _tempProjMat5 = new Mat4(); const _tempSet = new Set(); @@ -335,17 +324,9 @@ class Renderer { } let projMatSkybox = camera.getProjectionMatrixSkybox(); - // flip projection matrices - if (flipY) { - projMat = _tempProjMat0.mul2(_flipYMat, projMat); - projMatSkybox = _tempProjMat1.mul2(_flipYMat, projMatSkybox); - } - - // update depth range of projection matrices (-1..1 to 0..1) - if (this.device.isWebGPU) { - projMat = _tempProjMat2.mul2(_fixProjRangeMat, projMat); - projMatSkybox = _tempProjMat3.mul2(_fixProjRangeMat, projMatSkybox); - } + const webgpu = this.device.isWebGPU; + projMat = Camera.applyShaderProjectionTransform(projMat, _tempProjMat0, flipY, webgpu); + projMatSkybox = Camera.applyShaderProjectionTransform(projMatSkybox, _tempProjMat1, flipY, webgpu); // camera jitter const { jitter } = camera;