diff --git a/examples/src/examples/graphics/reverse-z.example.mjs b/examples/src/examples/graphics/reverse-z.example.mjs new file mode 100644 index 00000000000..be8b92581ee --- /dev/null +++ b/examples/src/examples/graphics/reverse-z.example.mjs @@ -0,0 +1,121 @@ +// @config DESCRIPTION Reverse-Z depth buffering (Only on WebGPU). Maps the camera near plane to depth=1 and far to depth=0, dramatically improving floating-point precision over large view distances. Camera uses near=0.1 / far=1,000,000. Switch the device type to WebGL2 to see the same scene without reverse-z (distant coplanar pairs z-fight) — WebGL2 lacks reverse-z support so it acts as the "feature off" comparison. +import { deviceType } from 'examples/utils'; +import * as pc from 'playcanvas'; + +const canvas = /** @type {HTMLCanvasElement} */ (document.getElementById('application-canvas')); +window.focus(); + +const gfxOptions = { + deviceTypes: [deviceType], + // opt in to reverse-z when running on WebGPU; ignored on WebGL2 + reverseZ: true +}; + +const device = await pc.createGraphicsDevice(canvas, gfxOptions); +device.maxPixelRatio = Math.min(window.devicePixelRatio, 2); + +const createOptions = new pc.AppOptions(); +createOptions.graphicsDevice = device; +createOptions.componentSystems = [ + pc.RenderComponentSystem, + pc.CameraComponentSystem +]; +createOptions.resourceHandlers = [pc.TextureHandler]; + +const app = new pc.AppBase(canvas); +app.init(createOptions); + +app.setCanvasFillMode(pc.FILLMODE_FILL_WINDOW); +app.setCanvasResolution(pc.RESOLUTION_AUTO); + +const resize = () => app.resizeCanvas(); +window.addEventListener('resize', resize); +app.on('destroy', () => window.removeEventListener('resize', resize)); + +app.start(); + +// camera with extreme near/far +const camera = new pc.Entity('camera'); +camera.addComponent('camera', { + clearColor: new pc.Color(0.05, 0.05, 0.08), + fov: 60, + nearClip: 0.1, + farClip: 1000000, + toneMapping: pc.TONEMAP_LINEAR +}); +app.root.addChild(camera); + +// test pattern: at increasing depths along -Z, place a pair of overlapping quads. +// blue back quad (full size), red front quad (smaller, centered, sits a tiny epsilon in front). +// Correct sort: blue frame surrounds red center at every distance. +// With z-fighting (forward-z without enough precision at large distance), the boundary +// shimmers and the red quad is overwritten by blue at the far pairs. +// Distances span 5 orders of magnitude — far pairs would z-fight in forward-z. +const distances = [10, 100, 1_000, 10_000, 100_000]; +const epsilonFactor = 1e-5; // tiny relative offset, hardware-z difference vanishes at distance + +// build a unit plane geometry once +const planeMesh = pc.Mesh.fromGeometry(device, new pc.PlaneGeometry({ halfExtents: new pc.Vec2(0.5, 0.5) })); + +/** + * @param {pc.Color} color - Emissive color. + * @param {pc.Vec3} pos - World position. + * @param {number} screenScale - Plane size at distance. + * @returns {pc.Entity} The created entity. + */ +const makeQuadEntity = (color, pos, screenScale) => { + // unlit-style material via emissive (StandardMaterial diffuse needs lights/IBL to show) + const m = new pc.StandardMaterial(); + m.diffuse = pc.Color.BLACK; + m.emissive = color; + m.emissiveIntensity = 1; + m.useLighting = false; + m.cull = pc.CULLFACE_NONE; + m.update(); + + const e = new pc.Entity(); + e.addComponent('render', { + meshInstances: [new pc.MeshInstance(planeMesh, m)] + }); + + e.setLocalScale(screenScale, screenScale, screenScale); + // orient face towards camera (PlaneGeometry is XZ with normal +Y, so rotate -90 around X + // to put the normal along +Z — towards a camera that looks down -Z) + e.setEulerAngles(-90, 0, 0); + e.setPosition(pos); + return e; +}; + +// arrange pairs in a horizontal row so all 5 distances are simultaneously visible. +// each pair sits at a different angular offset from the camera forward axis so they +// don't occlude each other in screen space. Per-pair size scales with distance so all +// pairs appear roughly the same size on screen. +const angleStepDeg = 12; +const angleStartDeg = -((distances.length - 1) * angleStepDeg) / 2; +const screenScaleFactor = 0.18; // controls on-screen size of each pair + +distances.forEach((d, i) => { + const eps = d * epsilonFactor; + const angleRad = (angleStartDeg + i * angleStepDeg) * pc.math.DEG_TO_RAD; + const cx = Math.sin(angleRad) * d; + const cz = -Math.cos(angleRad) * d; + + const baseSize = d * screenScaleFactor; + + // back quad (blue) — full size + const back = makeQuadEntity(new pc.Color(0.1, 0.3, 0.9), new pc.Vec3(cx, 0, cz - eps), baseSize); + app.root.addChild(back); + + // front quad (red) — 70% size, blue frame visible around it at every distance + const front = makeQuadEntity(new pc.Color(0.95, 0.2, 0.2), new pc.Vec3(cx, 0, cz + eps), baseSize * 0.7); + app.root.addChild(front); +}); + +// camera fixed, slow orbit so we can see depth parallax +app.on('update', () => { + const t = performance.now() * 0.0003; + camera.setPosition(Math.sin(t) * 2, Math.cos(t * 0.7) * 1.5, 0); + camera.lookAt(0, 0, -100); +}); + +export { app }; diff --git a/examples/thumbnails/graphics_reverse-z_large.webp b/examples/thumbnails/graphics_reverse-z_large.webp new file mode 100644 index 00000000000..e4ffc36c0e8 Binary files /dev/null and b/examples/thumbnails/graphics_reverse-z_large.webp differ diff --git a/examples/thumbnails/graphics_reverse-z_small.webp b/examples/thumbnails/graphics_reverse-z_small.webp new file mode 100644 index 00000000000..278ce0a7990 Binary files /dev/null and b/examples/thumbnails/graphics_reverse-z_small.webp differ diff --git a/src/extras/gizmo/mesh-line.js b/src/extras/gizmo/mesh-line.js index ff901cabd8a..c94b2c1098c 100644 --- a/src/extras/gizmo/mesh-line.js +++ b/src/extras/gizmo/mesh-line.js @@ -43,7 +43,7 @@ class MeshLine { this._material.blendState = BlendState.ALPHABLEND; this._material.setDefine('DEPTH_WRITE', '1'); - this._material.setParameter('uDepth', 0); + this._material.setParameter('uDepth', app.graphicsDevice.isReverseZ ? 1 : 0); this._material.update(); const mesh = Mesh.fromGeometry(app.graphicsDevice, new CylinderGeometry()); diff --git a/src/extras/gizmo/shape/shape.js b/src/extras/gizmo/shape/shape.js index ac45d20b129..36df4756729 100644 --- a/src/extras/gizmo/shape/shape.js +++ b/src/extras/gizmo/shape/shape.js @@ -262,7 +262,9 @@ class Shape { _createRenderComponent(entity, meshes) { const color = this._disabled ? this._disabledColor : this._defaultColor; this._material.setDefine('DEPTH_WRITE', this._depth > 0 ? '1' : '0'); - this._material.setParameter('uDepth', this._depth); + // _depth is in standard convention (0=near, 1=far) — flip for reverse-z hardware + const uDepth = this.device.isReverseZ && this._depth >= 0 ? 1 - this._depth : this._depth; + this._material.setParameter('uDepth', uDepth); this._material.setParameter('uColor', color.toArray()); this._material.cull = this._cull; this._material.blendType = BLEND_NORMAL; diff --git a/src/platform/graphics/graphics-device-create.js b/src/platform/graphics/graphics-device-create.js index 95699970198..0f4e95f33b6 100644 --- a/src/platform/graphics/graphics-device-create.js +++ b/src/platform/graphics/graphics-device-create.js @@ -46,6 +46,11 @@ import { NullGraphicsDevice } from './null/null-graphics-device.js'; * - 'low-power': Prioritizes power saving over rendering performance. * * Defaults to 'default'. + * @param {boolean} [options.reverseZ] - Enables reverse-z depth buffering, which maps the + * camera near plane to depth=1 and the far plane to depth=0. This significantly improves + * floating-point depth precision over large view distances and reduces z-fighting at the far + * plane. WebGPU only — ignored on WebGL2. Defaults to false. Custom shaders that sample the + * depth buffer with hard-coded forward-z formulas will need updating before opting in. * @returns {Promise} - Promise object representing the created graphics device. * @category Graphics */ diff --git a/src/platform/graphics/graphics-device.js b/src/platform/graphics/graphics-device.js index 8d051e400cc..216044c1f4c 100644 --- a/src/platform/graphics/graphics-device.js +++ b/src/platform/graphics/graphics-device.js @@ -120,6 +120,17 @@ class GraphicsDevice extends EventHandler { */ isNull = false; + /** + * True if reverse-z (reversed depth buffer) is enabled. When enabled, projection matrices map + * the near plane to z=1 and the far plane to z=0, the depth buffer is cleared to 0, and the + * default depth comparison uses GREATER/GREATEREQUAL. This significantly improves depth + * precision over large view distances. Opt-in via the `reverseZ` create option (WebGPU only). + * + * @type {boolean} + * @readonly + */ + isReverseZ = false; + /** * True if the back-buffer is using HDR format, which means that the browser will display the * rendered images in high dynamic range mode. This is true if the options.displayFormat is set diff --git a/src/platform/graphics/shader-definition-utils.js b/src/platform/graphics/shader-definition-utils.js index 5fb1ecc4dc9..b5916f71e77 100644 --- a/src/platform/graphics/shader-definition-utils.js +++ b/src/platform/graphics/shader-definition-utils.js @@ -111,7 +111,9 @@ class ShaderDefinitionUtils { } } - return attachmentsDefine + deviceIntro; + const reverseZDefine = device.isReverseZ ? '#define REVERSE_Z\n' : ''; + + return attachmentsDefine + reverseZDefine + deviceIntro; }; const getDefinesWgsl = (isVertex, options) => { @@ -132,6 +134,10 @@ class ShaderDefinitionUtils { } } + if (device.isReverseZ) { + code += '#define REVERSE_Z\n'; + } + return code; }; diff --git a/src/platform/graphics/webgpu/webgpu-clear-renderer.js b/src/platform/graphics/webgpu/webgpu-clear-renderer.js index 33531fe7cab..3529f965ed2 100644 --- a/src/platform/graphics/webgpu/webgpu-clear-renderer.js +++ b/src/platform/graphics/webgpu/webgpu-clear-renderer.js @@ -122,7 +122,9 @@ class WebgpuClearRenderer { let depthState; if ((flags & CLEARFLAG_DEPTH) && renderTarget.depth) { const depth = options.depth ?? defaultOptions.depth; - uniformBuffer.set('depth', depth); + // user-facing depth follows standard convention (1=far). Translate to hardware + // convention for reverse-z (0=far) so the clear writes the correct value. + uniformBuffer.set('depth', device.isReverseZ ? (1 - depth) : depth); depthState = DepthState.WRITEDEPTH; } else { uniformBuffer.set('depth', 1); diff --git a/src/platform/graphics/webgpu/webgpu-graphics-device.js b/src/platform/graphics/webgpu/webgpu-graphics-device.js index 5afa0c3d825..c894fa578af 100644 --- a/src/platform/graphics/webgpu/webgpu-graphics-device.js +++ b/src/platform/graphics/webgpu/webgpu-graphics-device.js @@ -191,6 +191,9 @@ class WebgpuGraphicsDevice extends GraphicsDevice { this.backBufferAntialias = options.antialias ?? false; this.isWebGPU = true; + // Pass `reverseZ: true` in the create options to enable reverse-z depth (improves + // precision over large view distances). WebGPU only. + this.isReverseZ = options.reverseZ ?? false; this._deviceType = DEVICETYPE_WEBGPU; this.featureLevel = options.featureLevel; @@ -947,7 +950,7 @@ class WebgpuGraphicsDevice extends GraphicsDevice { } // set up clear / store / load settings - wrt.setupForRenderPass(renderPass, rt); + wrt.setupForRenderPass(renderPass, rt, this.isReverseZ); const renderPassDesc = wrt.renderPassDescriptor; diff --git a/src/platform/graphics/webgpu/webgpu-render-pipeline.js b/src/platform/graphics/webgpu/webgpu-render-pipeline.js index 6fe98e67f4d..4fcb5305b7e 100644 --- a/src/platform/graphics/webgpu/webgpu-render-pipeline.js +++ b/src/platform/graphics/webgpu/webgpu-render-pipeline.js @@ -66,6 +66,19 @@ const _compareFunction = [ 'always' // FUNC_ALWAYS ]; +// reverse-z translation: maps user-facing "closer passes" intent (LESS/LESSEQUAL) to hardware-z +// (GREATER/GREATEREQUAL) when reverse-z is enabled. Symmetric for explicit GREATER/GREATEREQUAL. +const _compareFunctionReverseZ = [ + 'never', // FUNC_NEVER + 'greater', // FUNC_LESS + 'equal', // FUNC_EQUAL + 'greater-equal', // FUNC_LESSEQUAL + 'less', // FUNC_GREATER + 'not-equal', // FUNC_NOTEQUAL + 'less-equal', // FUNC_GREATEREQUAL + 'always' // FUNC_ALWAYS +]; + const _cullModes = [ 'none', // CULLFACE_NONE 'back', // CULLFACE_BACK @@ -282,11 +295,15 @@ class WebgpuRenderPipeline extends WebgpuPipeline { // depth if (depth) { depthStencil.depthWriteEnabled = depthState.write; - depthStencil.depthCompare = _compareFunction[depthState.func]; + const compareTable = this.device.isReverseZ ? _compareFunctionReverseZ : _compareFunction; + depthStencil.depthCompare = compareTable[depthState.func]; const biasAllowed = primitiveTopology === 'triangle-list' || primitiveTopology === 'triangle-strip'; - depthStencil.depthBias = biasAllowed ? depthState.depthBias : 0; - depthStencil.depthBiasSlopeScale = biasAllowed ? depthState.depthBiasSlope : 0; + // depth bias sign flips with reverse-z: a positive bias should still push fragments + // away from camera (towards far plane), which is towards 0 in reverse-z. + const biasSign = this.device.isReverseZ ? -1 : 1; + depthStencil.depthBias = biasAllowed ? biasSign * depthState.depthBias : 0; + depthStencil.depthBiasSlopeScale = biasAllowed ? biasSign * depthState.depthBiasSlope : 0; } else { // if render target does not have depth buffer depthStencil.depthWriteEnabled = false; diff --git a/src/platform/graphics/webgpu/webgpu-render-target.js b/src/platform/graphics/webgpu/webgpu-render-target.js index e6ce3ee05d5..0c0016daa79 100644 --- a/src/platform/graphics/webgpu/webgpu-render-target.js +++ b/src/platform/graphics/webgpu/webgpu-render-target.js @@ -465,8 +465,11 @@ class WebgpuRenderTarget { * * @param {RenderPass} renderPass - The render pass to start. * @param {RenderTarget} renderTarget - The render target to render to. + * @param {boolean} reverseZ - True if reverse-z is enabled on the device. The supplied + * clearDepthValue (in standard convention where 0=near, 1=far) is mapped to hardware-z by + * inverting it. */ - setupForRenderPass(renderPass, renderTarget) { + setupForRenderPass(renderPass, renderTarget, reverseZ) { Debug.assert(this.renderPassDescriptor); @@ -482,7 +485,8 @@ class WebgpuRenderTarget { const depthAttachment = this.renderPassDescriptor.depthStencilAttachment; if (depthAttachment) { - depthAttachment.depthClearValue = renderPass.depthStencilOps.clearDepthValue; + const userClear = renderPass.depthStencilOps.clearDepthValue; + depthAttachment.depthClearValue = reverseZ ? (1 - userClear) : userClear; depthAttachment.depthLoadOp = renderPass.depthStencilOps.clearDepth ? 'clear' : 'load'; depthAttachment.depthStoreOp = renderPass.depthStencilOps.storeDepth ? 'store' : 'discard'; depthAttachment.depthReadOnly = false; diff --git a/src/platform/graphics/webgpu/webgpu-texture.js b/src/platform/graphics/webgpu/webgpu-texture.js index 5a37f14950d..2a625f702c4 100644 --- a/src/platform/graphics/webgpu/webgpu-texture.js +++ b/src/platform/graphics/webgpu/webgpu-texture.js @@ -278,8 +278,8 @@ class WebgpuTexture { if (sampleType === SAMPLETYPE_DEPTH || sampleType === SAMPLETYPE_INT || sampleType === SAMPLETYPE_UINT) { - // depth compare sampling - desc.compare = 'less'; + // depth compare sampling — flips for reverse-z (shadow texels are smaller for further fragments) + desc.compare = device.isReverseZ ? 'greater' : 'less'; desc.magFilter = 'linear'; desc.minFilter = 'linear'; label = 'Compare'; diff --git a/src/scene/camera.js b/src/scene/camera.js index de11e794795..d460c3593db 100644 --- a/src/scene/camera.js +++ b/src/scene/camera.js @@ -45,6 +45,14 @@ class Camera { 0, 0, 0.5, 1 ]); + /** @private */ + static _webGpuDepthRangeMatrixReverseZ = 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(); @@ -55,10 +63,14 @@ class Camera { * @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. + * @param {boolean} applyWebGpuDepthRange - When true, map clip Z from -1..1 to 0..1 + * (or 1..0 when reverseZ is also true). + * @param {boolean} [reverseZ] - When true alongside applyWebGpuDepthRange, map clip Z + * from -1..1 to 1..0 instead of 0..1. * @returns {Mat4} out */ - static applyShaderProjectionTransform(projection, out, flipY, applyWebGpuDepthRange) { + static applyShaderProjectionTransform(projection, out, flipY, applyWebGpuDepthRange, reverseZ = false) { + const depthMat = reverseZ ? Camera._webGpuDepthRangeMatrixReverseZ : Camera._webGpuDepthRangeMatrix; if (!flipY && !applyWebGpuDepthRange) { out.copy(projection); return out; @@ -66,14 +78,14 @@ class Camera { if (flipY && applyWebGpuDepthRange) { const scratch = Camera._applyShaderProjectionScratch; scratch.mul2(Camera._flipYProjectionMatrix, projection); - out.mul2(Camera._webGpuDepthRangeMatrix, scratch); + out.mul2(depthMat, scratch); return out; } if (flipY) { out.mul2(Camera._flipYProjectionMatrix, projection); return out; } - out.mul2(Camera._webGpuDepthRangeMatrix, projection); + out.mul2(depthMat, projection); return out; } diff --git a/src/scene/gsplat-unified/gsplat-compute-local-renderer.js b/src/scene/gsplat-unified/gsplat-compute-local-renderer.js index c5ff6847a30..5a5a94631af 100644 --- a/src/scene/gsplat-unified/gsplat-compute-local-renderer.js +++ b/src/scene/gsplat-unified/gsplat-compute-local-renderer.js @@ -656,7 +656,8 @@ class GSplatComputeLocalRenderer extends GSplatRenderer { const view = cam.viewMatrix; const flipY = !!camera.renderTarget?.flipY; const webgpu = device.isWebGPU; - _viewProjMat.mul2(Camera.applyShaderProjectionTransform(cam.projectionMatrix, _shaderProjMat, flipY, webgpu), view); + const reverseZ = device.isReverseZ; + _viewProjMat.mul2(Camera.applyShaderProjectionTransform(cam.projectionMatrix, _shaderProjMat, flipY, webgpu, reverseZ), view); _viewProjData.set(_viewProjMat.data); _viewData.set(view.data); const focal = width * _shaderProjMat.data[0]; diff --git a/src/scene/gsplat-unified/gsplat-hybrid-renderer.js b/src/scene/gsplat-unified/gsplat-hybrid-renderer.js index 1da41b899b6..2d08d7f1eee 100644 --- a/src/scene/gsplat-unified/gsplat-hybrid-renderer.js +++ b/src/scene/gsplat-unified/gsplat-hybrid-renderer.js @@ -293,7 +293,7 @@ class GSplatHybridRenderer extends GSplatRenderer { return; } const flipY = !!camComp.renderTarget?.flipY; - _invProjMat.copy(Camera.applyShaderProjectionTransform(cam.projectionMatrix, _shaderProjMat, flipY, this.device.isWebGPU)).invert(); + _invProjMat.copy(Camera.applyShaderProjectionTransform(cam.projectionMatrix, _shaderProjMat, flipY, this.device.isWebGPU, this.device.isReverseZ)).invert(); const d = _invProjMat.data; dst[0] = -d[2]; dst[1] = -d[6]; diff --git a/src/scene/gsplat-unified/gsplat-projector.js b/src/scene/gsplat-unified/gsplat-projector.js index 17d973fcb6b..a3937c7df5b 100644 --- a/src/scene/gsplat-unified/gsplat-projector.js +++ b/src/scene/gsplat-unified/gsplat-projector.js @@ -518,7 +518,8 @@ class GSplatProjector { const cam = cameraComponent.camera; const view = cam.viewMatrix; const webgpu = this.device.isWebGPU; - _viewProjMat.mul2(Camera.applyShaderProjectionTransform(cam.projectionMatrix, _shaderProjMat, flipY, webgpu), view); + const reverseZ = this.device.isReverseZ; + _viewProjMat.mul2(Camera.applyShaderProjectionTransform(cam.projectionMatrix, _shaderProjMat, flipY, webgpu, reverseZ), view); _viewProjData.set(_viewProjMat.data); _viewData.set(view.data); diff --git a/src/scene/immediate/immediate.js b/src/scene/immediate/immediate.js index ad81ae47945..29cafa66309 100644 --- a/src/scene/immediate/immediate.js +++ b/src/scene/immediate/immediate.js @@ -107,6 +107,12 @@ class Immediate { varying vec2 uv0; void main(void) { gl_Position = matrix_model * vec4(vertex_position, 0, 1); + // immediate-mode quad bypasses projection — place at near plane so it + // wins depth test against skybox/world (z=0 is near in forward-z, far + // in reverse-z, so flip) + #ifdef REVERSE_Z + gl_Position.z = gl_Position.w; + #endif uv0 = vertex_position.xy + 0.5; } `, @@ -118,6 +124,9 @@ class Immediate { @vertex fn vertexMain(input: VertexInput) -> VertexOutput { var output: VertexOutput; output.position = uniform.matrix_model * vec4f(input.vertex_position, 0.0, 1.0); + #ifdef REVERSE_Z + output.position.z = output.position.w; + #endif output.uv0 = input.vertex_position.xy + vec2f(0.5); return output; } diff --git a/src/scene/renderer/renderer.js b/src/scene/renderer/renderer.js index 8ed97594f2e..23ec9f50257 100644 --- a/src/scene/renderer/renderer.js +++ b/src/scene/renderer/renderer.js @@ -325,8 +325,9 @@ class Renderer { let projMatSkybox = camera.getProjectionMatrixSkybox(); const webgpu = this.device.isWebGPU; - projMat = Camera.applyShaderProjectionTransform(projMat, _tempProjMat0, flipY, webgpu); - projMatSkybox = Camera.applyShaderProjectionTransform(projMatSkybox, _tempProjMat1, flipY, webgpu); + const reverseZ = this.device.isReverseZ; + projMat = Camera.applyShaderProjectionTransform(projMat, _tempProjMat0, flipY, webgpu, reverseZ); + projMatSkybox = Camera.applyShaderProjectionTransform(projMatSkybox, _tempProjMat1, flipY, webgpu, reverseZ); // camera jitter const { jitter } = camera; diff --git a/src/scene/renderer/shadow-renderer.js b/src/scene/renderer/shadow-renderer.js index 892dbe4a7e6..6ae298f08ad 100644 --- a/src/scene/renderer/shadow-renderer.js +++ b/src/scene/renderer/shadow-renderer.js @@ -248,6 +248,11 @@ class ShadowRenderer { shadowCam.scissorRect = lightRenderData.shadowScissor; viewportMatrix.setViewport(rectViewport.x, rectViewport.y, rectViewport.z, rectViewport.w); + // viewport matrix bakes the GL [-1,1] -> [0,1] z remap. For reverse-z, flip the z row + // so shadowMatrix produces hardware-z in [1,0] (matching the reverse-z shadow buffer). + if (this.device.isReverseZ) { + viewportMatrix.data[10] = -0.5; + } lightRenderData.shadowMatrix.mul2(viewportMatrix, shadowCamViewProj); if (light._type === LIGHTTYPE_DIRECTIONAL) { diff --git a/src/scene/shader-lib/glsl/chunks/common/frag/fog.js b/src/scene/shader-lib/glsl/chunks/common/frag/fog.js index 2ac15413d66..e71c933089d 100644 --- a/src/scene/shader-lib/glsl/chunks/common/frag/fog.js +++ b/src/scene/shader-lib/glsl/chunks/common/frag/fog.js @@ -17,7 +17,11 @@ float dBlendModeFogFactor = 1.0; float getFogFactor(float depth) { #else float getFogFactor() { - float depth = gl_FragCoord.z / gl_FragCoord.w; + #ifdef REVERSE_Z + float depth = (1.0 - gl_FragCoord.z) / gl_FragCoord.w; + #else + float depth = gl_FragCoord.z / gl_FragCoord.w; + #endif #endif float fogFactor = 0.0; diff --git a/src/scene/shader-lib/glsl/chunks/common/frag/linearizeDepth.js b/src/scene/shader-lib/glsl/chunks/common/frag/linearizeDepth.js index 34e0284fa73..dca7c623e4c 100644 --- a/src/scene/shader-lib/glsl/chunks/common/frag/linearizeDepth.js +++ b/src/scene/shader-lib/glsl/chunks/common/frag/linearizeDepth.js @@ -4,6 +4,9 @@ export default /* glsl */` #define LINEARIZE_DEPTH float linearizeDepthWithParams(float z, vec4 cameraParams) { + #ifdef REVERSE_Z + z = 1.0 - z; + #endif if (cameraParams.w == 0.0) return (cameraParams.z * cameraParams.y) / (cameraParams.y + z * (cameraParams.z - cameraParams.y)); else diff --git a/src/scene/shader-lib/glsl/chunks/common/frag/pick.js b/src/scene/shader-lib/glsl/chunks/common/frag/pick.js index 1b4bc1bd8bd..9879b286541 100644 --- a/src/scene/shader-lib/glsl/chunks/common/frag/pick.js +++ b/src/scene/shader-lib/glsl/chunks/common/frag/pick.js @@ -23,8 +23,14 @@ vec4 encodePickOutput(uint id) { vec4 getPickDepth() { float linearDepth; if (camera_params.w > 0.5) { - linearDepth = gl_FragCoord.z; + // orthographic: gl_FragCoord.z IS the hardware NDC z, flip for reverse-z + #ifdef REVERSE_Z + linearDepth = 1.0 - gl_FragCoord.z; + #else + linearDepth = gl_FragCoord.z; + #endif } else { + // perspective: 1/w is view distance, independent of z convention float viewDist = 1.0 / gl_FragCoord.w; linearDepth = (viewDist - camera_params.z) / (camera_params.y - camera_params.z); } diff --git a/src/scene/shader-lib/glsl/chunks/common/frag/screenDepth.js b/src/scene/shader-lib/glsl/chunks/common/frag/screenDepth.js index ea4d9b3ee92..eb97dc7cdbb 100644 --- a/src/scene/shader-lib/glsl/chunks/common/frag/screenDepth.js +++ b/src/scene/shader-lib/glsl/chunks/common/frag/screenDepth.js @@ -20,6 +20,9 @@ uniform highp sampler2D uSceneDepthMap; #endif float linearizeDepth(float z) { + #ifdef REVERSE_Z + z = 1.0 - z; + #endif if (camera_params.w == 0.0) return (camera_params.z * camera_params.y) / (camera_params.y + z * (camera_params.z - camera_params.y)); else @@ -28,11 +31,16 @@ uniform highp sampler2D uSceneDepthMap; #endif float delinearizeDepth(float linearDepth) { + float z; if (camera_params.w == 0.0) { - return (camera_params.y * (camera_params.z - linearDepth)) / (linearDepth * (camera_params.z - camera_params.y)); + z = (camera_params.y * (camera_params.z - linearDepth)) / (linearDepth * (camera_params.z - camera_params.y)); } else { - return (linearDepth - camera_params.z) / (camera_params.y - camera_params.z); + z = (linearDepth - camera_params.z) / (camera_params.y - camera_params.z); } + #ifdef REVERSE_Z + z = 1.0 - z; + #endif + return z; } // Retrieves rendered linear camera depth by UV diff --git a/src/scene/shader-lib/glsl/chunks/common/vert/transform.js b/src/scene/shader-lib/glsl/chunks/common/vert/transform.js index 0eed44d9fa1..69421f370a3 100644 --- a/src/scene/shader-lib/glsl/chunks/common/vert/transform.js +++ b/src/scene/shader-lib/glsl/chunks/common/vert/transform.js @@ -30,7 +30,13 @@ vec4 evalWorldPosition(vec3 vertexPosition, mat4 modelMatrix) { vec4 posW = modelMatrix * vec4(localPos, 1.0); #ifdef SCREENSPACE - posW.zw = vec2(0.0, 1.0); + // SCREENSPACE bypasses matrix_viewProjection — clip-space z is set directly. + // Place at near plane: z=0 in forward-z, z=1 in reverse-z (post-divide w=1). + #ifdef REVERSE_Z + posW.zw = vec2(1.0, 1.0); + #else + posW.zw = vec2(0.0, 1.0); + #endif #endif return posW; diff --git a/src/scene/shader-lib/glsl/chunks/lit/frag/clusteredLightShadows.js b/src/scene/shader-lib/glsl/chunks/lit/frag/clusteredLightShadows.js index 9cec10b87ab..154d7e204d2 100644 --- a/src/scene/shader-lib/glsl/chunks/lit/frag/clusteredLightShadows.js +++ b/src/scene/shader-lib/glsl/chunks/lit/frag/clusteredLightShadows.js @@ -28,6 +28,9 @@ float getShadowOmniClusteredPCF1(SHADOWMAP_ACCEPT(shadowMap), vec4 shadowParams, vec2 uv = getCubemapAtlasCoordinates(omniAtlasViewport, shadowEdgePixels, shadowTextureResolution, lightDir); float shadowZ = length(lightDir) * shadowParams.w + shadowParams.z; + #ifdef REVERSE_Z + shadowZ = 1.0 - shadowZ; + #endif return textureShadow(shadowMap, vec3(uv, shadowZ)); } @@ -41,6 +44,9 @@ float getShadowOmniClusteredPCF3(SHADOWMAP_ACCEPT(shadowMap), vec4 shadowParams, vec2 uv = getCubemapAtlasCoordinates(omniAtlasViewport, shadowEdgePixels, shadowTextureResolution, lightDir); float shadowZ = length(lightDir) * shadowParams.w + shadowParams.z; + #ifdef REVERSE_Z + shadowZ = 1.0 - shadowZ; + #endif vec3 shadowCoord = vec3(uv, shadowZ); return getShadowPCF3x3(SHADOWMAP_PASS(shadowMap), shadowCoord, shadowParams); } @@ -55,6 +61,9 @@ float getShadowOmniClusteredPCF5(SHADOWMAP_ACCEPT(shadowMap), vec4 shadowParams, vec2 uv = getCubemapAtlasCoordinates(omniAtlasViewport, shadowEdgePixels, shadowTextureResolution, lightDir); float shadowZ = length(lightDir) * shadowParams.w + shadowParams.z; + #ifdef REVERSE_Z + shadowZ = 1.0 - shadowZ; + #endif vec3 shadowCoord = vec3(uv, shadowZ); return getShadowPCF5x5(SHADOWMAP_PASS(shadowMap), shadowCoord, shadowParams); } diff --git a/src/scene/shader-lib/glsl/chunks/lit/frag/lighting/lightFunctionShadow.js b/src/scene/shader-lib/glsl/chunks/lit/frag/lighting/lightFunctionShadow.js index 632ef2b81c8..024666f07bf 100644 --- a/src/scene/shader-lib/glsl/chunks/lit/frag/lighting/lightFunctionShadow.js +++ b/src/scene/shader-lib/glsl/chunks/lit/frag/lighting/lightFunctionShadow.js @@ -39,13 +39,24 @@ export default /* glsl */` vec4 positionInShadowSpace = shadowTransform * vec4(surfacePosition, 1.0); #ifdef LIGHT{i}_SHADOW_SAMPLE_ORTHO - positionInShadowSpace.z = saturate(positionInShadowSpace.z) - 0.0001; + #ifdef REVERSE_Z + // reverse-z: 1=near light, 0=far. Push receiver TOWARDS light (greater z) + // so the GREATER hardware compare reports lit against its own surface. + positionInShadowSpace.z = saturate(positionInShadowSpace.z) + 0.0001; + #else + positionInShadowSpace.z = saturate(positionInShadowSpace.z) - 0.0001; + #endif #else #ifdef LIGHT{i}_SHADOW_SAMPLE_SOURCE_ZBUFFER positionInShadowSpace.xyz /= positionInShadowSpace.w; #else positionInShadowSpace.xy /= positionInShadowSpace.w; positionInShadowSpace.z = length(lightDir) * shadowParams.w; + #ifdef REVERSE_Z + // stored values are 1 - norm_dist (see modified-depth path in litShadowMain). + // Flip the receiver to the same convention. + positionInShadowSpace.z = 1.0 - positionInShadowSpace.z; + #endif #endif #endif diff --git a/src/scene/shader-lib/glsl/chunks/lit/frag/lighting/shadowSoft.js b/src/scene/shader-lib/glsl/chunks/lit/frag/lighting/shadowSoft.js index 1d6a533dd01..9441bb6023a 100644 --- a/src/scene/shader-lib/glsl/chunks/lit/frag/lighting/shadowSoft.js +++ b/src/scene/shader-lib/glsl/chunks/lit/frag/lighting/shadowSoft.js @@ -49,6 +49,11 @@ void PCSSFindBlocker(TEXTURE_ACCEPT(shadowMap), out float avgBlockerDepth, out i vec2 diskUV = generateDiskSample(diskData); vec2 sampleUV = shadowCoords + diskUV * searchWidth; float shadowMapDepth = texture2DLod(shadowMap, sampleUV, 0.0).r; + #ifdef REVERSE_Z + // shadow map stores reverse-z hardware depth (1=near light, 0=far). Flip to + // forward-z so the existing comparison logic ("smaller = closer to light") holds. + shadowMapDepth = 1.0 - shadowMapDepth; + #endif if ( shadowMapDepth < z ) { blockerSum += shadowMapDepth; numBlockers++; @@ -66,6 +71,9 @@ float PCSSFilter(TEXTURE_ACCEPT(shadowMap), vec2 uv, float receiverDepth, int sh for (int i = 0; i < shadowSamples; i++) { vec2 offsetUV = generateDiskSample(diskData) * filterRadius; float depth = texture2DLod(shadowMap, uv + offsetUV, 0.0).r; + #ifdef REVERSE_Z + depth = 1.0 - depth; + #endif sum += step(receiverDepth, depth); } return sum / float(shadowSamples); @@ -79,7 +87,13 @@ float getPenumbra(float dblocker, float dreceiver, float penumbraSize, float pen float PCSSDirectional(TEXTURE_ACCEPT(shadowMap), vec3 shadowCoords, vec4 cameraParams, vec4 softShadowParams) { + // receiverDepth is the receiver's hardware-z in light-space NDC. With reverse-z it ranges + // 1=near to 0=far; flip into forward-z so the rest of the function operates in a single + // convention ("smaller = closer to light"). float receiverDepth = shadowCoords.z; + #ifdef REVERSE_Z + receiverDepth = 1.0 - receiverDepth; + #endif float randomSeed = fractSinRand(gl_FragCoord.xy); int shadowSamples = int(softShadowParams.x); int shadowBlockerSamples = int(softShadowParams.y); @@ -105,8 +119,9 @@ float PCSSDirectional(TEXTURE_ACCEPT(shadowMap), vec3 shadowCoords, vec4 cameraP if (numBlockers < 1) return 1.0f; - // penumbra size is based on the blocker depth - penumbra = getPenumbra(avgBlockerDepth, shadowCoords.z, penumbraSize, penumbraFalloff); + // penumbra size is based on the blocker depth (use receiverDepth — already in + // forward-z convention to match avgBlockerDepth from the blocker search) + penumbra = getPenumbra(avgBlockerDepth, receiverDepth, penumbraSize, penumbraFalloff); } else { diff --git a/src/scene/shader-lib/glsl/chunks/lit/frag/pass-shadow/litShadowMain.js b/src/scene/shader-lib/glsl/chunks/lit/frag/pass-shadow/litShadowMain.js index 0ef552de431..c0be1b6f152 100644 --- a/src/scene/shader-lib/glsl/chunks/lit/frag/pass-shadow/litShadowMain.js +++ b/src/scene/shader-lib/glsl/chunks/lit/frag/pass-shadow/litShadowMain.js @@ -49,7 +49,11 @@ void main(void) { #else #ifdef MODIFIED_DEPTH // If we end up using modified depth, it needs to be explicitly written to gl_FragDepth - gl_FragDepth = depth; + #ifdef REVERSE_Z + gl_FragDepth = 1.0 - depth; + #else + gl_FragDepth = depth; + #endif #endif // just the simplest code, color is not written anyway diff --git a/src/scene/shader-lib/glsl/chunks/particle/vert/particle_cpu_end.js b/src/scene/shader-lib/glsl/chunks/particle/vert/particle_cpu_end.js index b4804b94f5f..07b8ce67b4a 100644 --- a/src/scene/shader-lib/glsl/chunks/particle/vert/particle_cpu_end.js +++ b/src/scene/shader-lib/glsl/chunks/particle/vert/particle_cpu_end.js @@ -3,7 +3,11 @@ export default /* glsl */` localPos += particlePos; #ifdef SCREEN_SPACE - gl_Position = vec4(localPos.x, localPos.y, 0.0, 1.0); + #ifdef REVERSE_Z + gl_Position = vec4(localPos.x, localPos.y, 1.0, 1.0); + #else + gl_Position = vec4(localPos.x, localPos.y, 0.0, 1.0); + #endif #else gl_Position = matrix_viewProjection * vec4(localPos, 1.0); #endif diff --git a/src/scene/shader-lib/glsl/chunks/particle/vert/particle_end.js b/src/scene/shader-lib/glsl/chunks/particle/vert/particle_end.js index dc6aa57c752..c46c14d9e9c 100644 --- a/src/scene/shader-lib/glsl/chunks/particle/vert/particle_end.js +++ b/src/scene/shader-lib/glsl/chunks/particle/vert/particle_end.js @@ -3,7 +3,11 @@ export default /* glsl */` localPos += particlePos; #ifdef SCREEN_SPACE - gl_Position = vec4(localPos.x, localPos.y, 0.0, 1.0); + #ifdef REVERSE_Z + gl_Position = vec4(localPos.x, localPos.y, 1.0, 1.0); + #else + gl_Position = vec4(localPos.x, localPos.y, 0.0, 1.0); + #endif #else gl_Position = matrix_viewProjection * vec4(localPos.xyz, 1.0); #endif diff --git a/src/scene/shader-lib/glsl/chunks/skybox/vert/skybox.js b/src/scene/shader-lib/glsl/chunks/skybox/vert/skybox.js index 2eb2861c733..1abb6bd3468 100644 --- a/src/scene/shader-lib/glsl/chunks/skybox/vert/skybox.js +++ b/src/scene/shader-lib/glsl/chunks/skybox/vert/skybox.js @@ -64,6 +64,10 @@ void main(void) { // still push pixels beyond far Z. See: // https://community.khronos.org/t/skybox-problem/61857 - gl_Position.z = gl_Position.w - 1.0e-7; + #ifdef REVERSE_Z + gl_Position.z = 0.0; + #else + gl_Position.z = gl_Position.w - 1.0e-7; + #endif } `; diff --git a/src/scene/shader-lib/wgsl/chunks/common/frag/fog.js b/src/scene/shader-lib/wgsl/chunks/common/frag/fog.js index 0d506012645..2103b16b5c8 100644 --- a/src/scene/shader-lib/wgsl/chunks/common/frag/fog.js +++ b/src/scene/shader-lib/wgsl/chunks/common/frag/fog.js @@ -19,7 +19,11 @@ var dBlendModeFogFactor : f32 = 1.0; fn getFogFactor(depth: f32) -> f32 { #else fn getFogFactor() -> f32 { - let depth = pcPosition.z / pcPosition.w; + #ifdef REVERSE_Z + let depth = (1.0 - pcPosition.z) / pcPosition.w; + #else + let depth = pcPosition.z / pcPosition.w; + #endif #endif #if (FOG == LINEAR) diff --git a/src/scene/shader-lib/wgsl/chunks/common/frag/linearizeDepth.js b/src/scene/shader-lib/wgsl/chunks/common/frag/linearizeDepth.js index adc7d9cde1b..0754073903d 100644 --- a/src/scene/shader-lib/wgsl/chunks/common/frag/linearizeDepth.js +++ b/src/scene/shader-lib/wgsl/chunks/common/frag/linearizeDepth.js @@ -3,7 +3,12 @@ export default /* wgsl */` #ifndef LINEARIZE_DEPTH #define LINEARIZE_DEPTH -fn linearizeDepthWithParams(z: f32, cameraParams: vec4f) -> f32 { +fn linearizeDepthWithParams(zIn: f32, cameraParams: vec4f) -> f32 { + #ifdef REVERSE_Z + let z: f32 = 1.0 - zIn; + #else + let z: f32 = zIn; + #endif if (cameraParams.w == 0.0) { return (cameraParams.z * cameraParams.y) / (cameraParams.y + z * (cameraParams.z - cameraParams.y)); } else { diff --git a/src/scene/shader-lib/wgsl/chunks/common/frag/pick.js b/src/scene/shader-lib/wgsl/chunks/common/frag/pick.js index 219e3ed245b..98a7c072771 100644 --- a/src/scene/shader-lib/wgsl/chunks/common/frag/pick.js +++ b/src/scene/shader-lib/wgsl/chunks/common/frag/pick.js @@ -23,8 +23,14 @@ fn encodePickOutput(id: u32) -> vec4f { fn getPickDepth() -> vec4f { var linearDepth: f32; if (uniform.camera_params.w > 0.5) { - linearDepth = pcPosition.z; + // orthographic: pcPosition.z IS the hardware NDC z, flip for reverse-z + #ifdef REVERSE_Z + linearDepth = 1.0 - pcPosition.z; + #else + linearDepth = pcPosition.z; + #endif } else { + // perspective: 1/w is view distance, independent of z convention let viewDist = 1.0 / pcPosition.w; linearDepth = (viewDist - uniform.camera_params.z) / (uniform.camera_params.y - uniform.camera_params.z); } diff --git a/src/scene/shader-lib/wgsl/chunks/common/frag/screenDepth.js b/src/scene/shader-lib/wgsl/chunks/common/frag/screenDepth.js index 118ecb41832..da19adde860 100644 --- a/src/scene/shader-lib/wgsl/chunks/common/frag/screenDepth.js +++ b/src/scene/shader-lib/wgsl/chunks/common/frag/screenDepth.js @@ -20,7 +20,12 @@ var uSceneDepthMap: texture_2d; uniform camera_params: vec4f; // x: 1 / camera_far, y: camera_far, z: camera_near, w: is_ortho #endif - fn linearizeDepth(z: f32) -> f32 { + fn linearizeDepth(zIn: f32) -> f32 { + #ifdef REVERSE_Z + let z: f32 = 1.0 - zIn; + #else + let z: f32 = zIn; + #endif if (uniform.camera_params.w == 0.0) { // Perspective return (uniform.camera_params.z * uniform.camera_params.y) / (uniform.camera_params.y + z * (uniform.camera_params.z - uniform.camera_params.y)); } else { @@ -30,11 +35,16 @@ var uSceneDepthMap: texture_2d; #endif fn delinearizeDepth(linearDepth: f32) -> f32 { + var z: f32; if (uniform.camera_params.w == 0.0) { - return (uniform.camera_params.y * (uniform.camera_params.z - linearDepth)) / (linearDepth * (uniform.camera_params.z - uniform.camera_params.y)); + z = (uniform.camera_params.y * (uniform.camera_params.z - linearDepth)) / (linearDepth * (uniform.camera_params.z - uniform.camera_params.y)); } else { - return (linearDepth - uniform.camera_params.z) / (uniform.camera_params.y - uniform.camera_params.z); + z = (linearDepth - uniform.camera_params.z) / (uniform.camera_params.y - uniform.camera_params.z); } + #ifdef REVERSE_Z + z = 1.0 - z; + #endif + return z; } // Retrieves rendered linear camera depth by UV diff --git a/src/scene/shader-lib/wgsl/chunks/common/vert/transform.js b/src/scene/shader-lib/wgsl/chunks/common/vert/transform.js index 420cc0ffde5..77f43799198 100644 --- a/src/scene/shader-lib/wgsl/chunks/common/vert/transform.js +++ b/src/scene/shader-lib/wgsl/chunks/common/vert/transform.js @@ -31,7 +31,11 @@ fn evalWorldPosition(vertexPosition: vec3f, modelMatrix: mat4x4f) -> vec4f { var posW: vec4f = modelMatrix * vec4f(localPos, 1.0); #ifdef SCREENSPACE - posW = vec4f(posW.xy, 0.0, 1.0); + #ifdef REVERSE_Z + posW = vec4f(posW.xy, 1.0, 1.0); + #else + posW = vec4f(posW.xy, 0.0, 1.0); + #endif #endif return posW; diff --git a/src/scene/shader-lib/wgsl/chunks/lit/frag/clusteredLightShadows.js b/src/scene/shader-lib/wgsl/chunks/lit/frag/clusteredLightShadows.js index 2211245ebcf..8eee98c32cf 100644 --- a/src/scene/shader-lib/wgsl/chunks/lit/frag/clusteredLightShadows.js +++ b/src/scene/shader-lib/wgsl/chunks/lit/frag/clusteredLightShadows.js @@ -26,7 +26,12 @@ fn normalOffsetPointShadow(shadowParams: vec4f, lightPos: vec3f, lightDir: vec3f let shadowTextureResolution: f32 = shadowParams.x; let uv: vec2f = getCubemapAtlasCoordinates(omniAtlasViewport, shadowEdgePixels, shadowTextureResolution, lightDir); - let shadowZ: f32 = length(lightDir) * shadowParams.w + shadowParams.z; + let zForward: f32 = length(lightDir) * shadowParams.w + shadowParams.z; + #ifdef REVERSE_Z + let shadowZ: f32 = 1.0 - zForward; + #else + let shadowZ: f32 = zForward; + #endif return textureSampleCompareLevel(shadowMap, shadowMapSampler, uv, shadowZ); } @@ -39,7 +44,12 @@ fn normalOffsetPointShadow(shadowParams: vec4f, lightPos: vec3f, lightDir: vec3f let shadowTextureResolution: f32 = shadowParams.x; let uv: vec2f = getCubemapAtlasCoordinates(omniAtlasViewport, shadowEdgePixels, shadowTextureResolution, lightDir); - let shadowZ: f32 = length(lightDir) * shadowParams.w + shadowParams.z; + let zForward: f32 = length(lightDir) * shadowParams.w + shadowParams.z; + #ifdef REVERSE_Z + let shadowZ: f32 = 1.0 - zForward; + #else + let shadowZ: f32 = zForward; + #endif let shadowCoord: vec3f = vec3f(uv, shadowZ); return getShadowPCF3x3(shadowMap, shadowMapSampler, shadowCoord, shadowParams); } @@ -53,7 +63,12 @@ fn normalOffsetPointShadow(shadowParams: vec4f, lightPos: vec3f, lightDir: vec3f let shadowTextureResolution: f32 = shadowParams.x; let uv: vec2f = getCubemapAtlasCoordinates(omniAtlasViewport, shadowEdgePixels, shadowTextureResolution, lightDir); - let shadowZ: f32 = length(lightDir) * shadowParams.w + shadowParams.z; + let zForward: f32 = length(lightDir) * shadowParams.w + shadowParams.z; + #ifdef REVERSE_Z + let shadowZ: f32 = 1.0 - zForward; + #else + let shadowZ: f32 = zForward; + #endif let shadowCoord: vec3f = vec3f(uv, shadowZ); return getShadowPCF5x5(shadowMap, shadowMapSampler, shadowCoord, shadowParams); } diff --git a/src/scene/shader-lib/wgsl/chunks/lit/frag/lighting/lightFunctionShadow.js b/src/scene/shader-lib/wgsl/chunks/lit/frag/lighting/lightFunctionShadow.js index 6f076e3a5b8..44c92db453a 100644 --- a/src/scene/shader-lib/wgsl/chunks/lit/frag/lighting/lightFunctionShadow.js +++ b/src/scene/shader-lib/wgsl/chunks/lit/frag/lighting/lightFunctionShadow.js @@ -39,13 +39,24 @@ export default /* wgsl */` var positionInShadowSpace: vec4f = shadowTransform * vec4f(surfacePosition, 1.0); #ifdef LIGHT{i}_SHADOW_SAMPLE_ORTHO - positionInShadowSpace.z = saturate(positionInShadowSpace.z) - 0.0001; + #ifdef REVERSE_Z + // reverse-z: 1=near light, 0=far. Push receiver TOWARDS light (greater z) + // so the GREATER hardware compare reports lit against its own surface. + positionInShadowSpace.z = saturate(positionInShadowSpace.z) + 0.0001; + #else + positionInShadowSpace.z = saturate(positionInShadowSpace.z) - 0.0001; + #endif #else #ifdef LIGHT{i}_SHADOW_SAMPLE_SOURCE_ZBUFFER positionInShadowSpace.xyz = positionInShadowSpace.xyz / positionInShadowSpace.w; #else positionInShadowSpace.xy = positionInShadowSpace.xy / positionInShadowSpace.w; positionInShadowSpace.z = length(*lightDir) * shadowParams.w; + #ifdef REVERSE_Z + // stored values are 1 - norm_dist (see modified-depth path in litShadowMain). + // Flip the receiver to the same convention. + positionInShadowSpace.z = 1.0 - positionInShadowSpace.z; + #endif #endif #endif diff --git a/src/scene/shader-lib/wgsl/chunks/lit/frag/lighting/shadowSoft.js b/src/scene/shader-lib/wgsl/chunks/lit/frag/lighting/shadowSoft.js index 542abbf54e3..83f6bb35ef5 100644 --- a/src/scene/shader-lib/wgsl/chunks/lit/frag/lighting/shadowSoft.js +++ b/src/scene/shader-lib/wgsl/chunks/lit/frag/lighting/shadowSoft.js @@ -49,7 +49,14 @@ fn PCSSFindBlocker(shadowMap: texture_2d, shadowMapSampler: sampler, avgBlo for( var i: i32 = 0; i < shadowBlockerSamples; i = i + 1 ) { let diskUV: vec2f = generateDiskSample(&diskData); let sampleUV: vec2f = shadowCoords + diskUV * searchWidth; - let shadowMapDepth: f32 = textureSampleLevel(shadowMap, shadowMapSampler, sampleUV, 0.0).r; + let raw: f32 = textureSampleLevel(shadowMap, shadowMapSampler, sampleUV, 0.0).r; + // shadow map stores reverse-z hardware depth (1=near light, 0=far). Flip to + // forward-z so the existing comparison logic ("smaller = closer to light") holds. + #ifdef REVERSE_Z + let shadowMapDepth: f32 = 1.0 - raw; + #else + let shadowMapDepth: f32 = raw; + #endif if ( shadowMapDepth < z ) { blockerSum = blockerSum + shadowMapDepth; numBlockers_local = numBlockers_local + 1; @@ -67,7 +74,12 @@ fn PCSSFilter(shadowMap: texture_2d, shadowMapSampler: sampler, uv: vec2f, var sum: f32 = 0.0; for (var i: i32 = 0; i < shadowSamples; i = i + 1) { let offsetUV: vec2f = generateDiskSample(&diskData) * filterRadius; - let depth: f32 = textureSampleLevel(shadowMap, shadowMapSampler, uv + offsetUV, 0.0).r; + let raw: f32 = textureSampleLevel(shadowMap, shadowMapSampler, uv + offsetUV, 0.0).r; + #ifdef REVERSE_Z + let depth: f32 = 1.0 - raw; + #else + let depth: f32 = raw; + #endif sum = sum + step(receiverDepth, depth); } return sum / f32(shadowSamples); @@ -81,7 +93,12 @@ fn getPenumbra(dblocker: f32, dreceiver: f32, penumbraSize: f32, penumbraFalloff fn PCSSDirectional(shadowMap: texture_2d, shadowMapSampler: sampler, shadowCoords: vec3f, cameraParams: vec4f, softShadowParams: vec4f) -> f32 { - let receiverDepth: f32 = shadowCoords.z; + // flip receiver to forward-z so the rest of the function operates in a single convention + #ifdef REVERSE_Z + let receiverDepth: f32 = 1.0 - shadowCoords.z; + #else + let receiverDepth: f32 = shadowCoords.z; + #endif let randomSeed: f32 = fractSinRand(pcPosition.xy); let shadowSamples: i32 = i32(softShadowParams.x); let shadowBlockerSamples: i32 = i32(softShadowParams.y); @@ -107,8 +124,9 @@ fn PCSSDirectional(shadowMap: texture_2d, shadowMapSampler: sampler, shadow return 1.0; } - // penumbra size is based on the blocker depth - penumbra = getPenumbra(avgBlockerDepth, shadowCoords.z, penumbraSize, penumbraFalloff); + // penumbra size is based on the blocker depth (use receiverDepth — already in + // forward-z convention to match avgBlockerDepth from the blocker search) + penumbra = getPenumbra(avgBlockerDepth, receiverDepth, penumbraSize, penumbraFalloff); } else { diff --git a/src/scene/shader-lib/wgsl/chunks/lit/frag/pass-shadow/litShadowMain.js b/src/scene/shader-lib/wgsl/chunks/lit/frag/pass-shadow/litShadowMain.js index 94b531ab5da..91324269101 100644 --- a/src/scene/shader-lib/wgsl/chunks/lit/frag/pass-shadow/litShadowMain.js +++ b/src/scene/shader-lib/wgsl/chunks/lit/frag/pass-shadow/litShadowMain.js @@ -52,7 +52,11 @@ fn fragmentMain(input: FragmentInput) -> FragmentOutput { #else #ifdef MODIFIED_DEPTH // If we end up using modified depth, it needs to be explicitly written to gl_FragDepth - output.fragDepth = depth; + #ifdef REVERSE_Z + output.fragDepth = 1.0 - depth; + #else + output.fragDepth = depth; + #endif #endif // just the simplest code, color is not written anyway diff --git a/src/scene/shader-lib/wgsl/chunks/particle/vert/particle_cpu_end.js b/src/scene/shader-lib/wgsl/chunks/particle/vert/particle_cpu_end.js index 122616319a9..6493ab64644 100644 --- a/src/scene/shader-lib/wgsl/chunks/particle/vert/particle_cpu_end.js +++ b/src/scene/shader-lib/wgsl/chunks/particle/vert/particle_cpu_end.js @@ -3,7 +3,11 @@ export default /* wgsl */` localPos = localPos + particlePos; #ifdef SCREEN_SPACE - output.position = vec4f(localPos.x, localPos.y, 0.0, 1.0); + #ifdef REVERSE_Z + output.position = vec4f(localPos.x, localPos.y, 1.0, 1.0); + #else + output.position = vec4f(localPos.x, localPos.y, 0.0, 1.0); + #endif #else output.position = uniform.matrix_viewProjection * vec4f(localPos, 1.0); #endif diff --git a/src/scene/shader-lib/wgsl/chunks/particle/vert/particle_end.js b/src/scene/shader-lib/wgsl/chunks/particle/vert/particle_end.js index 6f1ad2b6201..4a56b7da639 100644 --- a/src/scene/shader-lib/wgsl/chunks/particle/vert/particle_end.js +++ b/src/scene/shader-lib/wgsl/chunks/particle/vert/particle_end.js @@ -3,7 +3,11 @@ export default /* wgsl */` localPos = localPos + particlePos; #ifdef SCREEN_SPACE - output.position = vec4f(localPos.x, localPos.y, 0.0, 1.0); + #ifdef REVERSE_Z + output.position = vec4f(localPos.x, localPos.y, 1.0, 1.0); + #else + output.position = vec4f(localPos.x, localPos.y, 0.0, 1.0); + #endif #else output.position = uniform.matrix_viewProjection * vec4f(localPos.xyz, 1.0); #endif diff --git a/src/scene/shader-lib/wgsl/chunks/skybox/vert/skybox.js b/src/scene/shader-lib/wgsl/chunks/skybox/vert/skybox.js index 11b6227c1e4..40bd38636e9 100644 --- a/src/scene/shader-lib/wgsl/chunks/skybox/vert/skybox.js +++ b/src/scene/shader-lib/wgsl/chunks/skybox/vert/skybox.js @@ -69,7 +69,11 @@ export default /* wgsl */` // still push pixels beyond far Z. See: // https://community.khronos.org/t/skybox-problem/61857 - output.position.z = output.position.w - 1.0e-7; + #ifdef REVERSE_Z + output.position.z = 0.0; + #else + output.position.z = output.position.w - 1.0e-7; + #endif return output; } diff --git a/test/platform/graphics/graphics-device.test.mjs b/test/platform/graphics/graphics-device.test.mjs new file mode 100644 index 00000000000..6e0313a5cbd --- /dev/null +++ b/test/platform/graphics/graphics-device.test.mjs @@ -0,0 +1,46 @@ +import { expect } from 'chai'; + +import { NullGraphicsDevice } from '../../../src/platform/graphics/null/null-graphics-device.js'; +import { WebgpuGraphicsDevice } from '../../../src/platform/graphics/webgpu/webgpu-graphics-device.js'; +import { jsdomSetup, jsdomTeardown } from '../../jsdom.mjs'; + +describe('GraphicsDevice', function () { + + describe('#isReverseZ', function () { + + beforeEach(function () { + jsdomSetup(); + }); + + afterEach(function () { + jsdomTeardown(); + }); + + it('defaults to false on NullGraphicsDevice', function () { + const canvas = document.createElement('canvas'); + const device = new NullGraphicsDevice(canvas); + expect(device.isReverseZ).to.equal(false); + device.destroy(); + }); + + it('defaults to false on WebgpuGraphicsDevice when option omitted', function () { + const canvas = document.createElement('canvas'); + const device = new WebgpuGraphicsDevice(canvas, {}); + expect(device.isReverseZ).to.equal(false); + }); + + it('is true on WebgpuGraphicsDevice when reverseZ option is true', function () { + const canvas = document.createElement('canvas'); + const device = new WebgpuGraphicsDevice(canvas, { reverseZ: true }); + expect(device.isReverseZ).to.equal(true); + }); + + it('is false on WebgpuGraphicsDevice when reverseZ option is false', function () { + const canvas = document.createElement('canvas'); + const device = new WebgpuGraphicsDevice(canvas, { reverseZ: false }); + expect(device.isReverseZ).to.equal(false); + }); + + }); + +});