Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
121 changes: 121 additions & 0 deletions examples/src/examples/graphics/reverse-z.example.mjs
Original file line number Diff line number Diff line change
@@ -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 };
Binary file added examples/thumbnails/graphics_reverse-z_large.webp
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added examples/thumbnails/graphics_reverse-z_small.webp
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
2 changes: 1 addition & 1 deletion src/extras/gizmo/mesh-line.js
Original file line number Diff line number Diff line change
Expand Up @@ -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());
Expand Down
4 changes: 3 additions & 1 deletion src/extras/gizmo/shape/shape.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
5 changes: 5 additions & 0 deletions src/platform/graphics/graphics-device-create.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
*/
Expand Down
11 changes: 11 additions & 0 deletions src/platform/graphics/graphics-device.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
8 changes: 7 additions & 1 deletion src/platform/graphics/shader-definition-utils.js
Original file line number Diff line number Diff line change
Expand Up @@ -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) => {
Expand All @@ -132,6 +134,10 @@ class ShaderDefinitionUtils {
}
}

if (device.isReverseZ) {
code += '#define REVERSE_Z\n';
}

return code;
};

Expand Down
4 changes: 3 additions & 1 deletion src/platform/graphics/webgpu/webgpu-clear-renderer.js
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
5 changes: 4 additions & 1 deletion src/platform/graphics/webgpu/webgpu-graphics-device.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -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;

Expand Down
23 changes: 20 additions & 3 deletions src/platform/graphics/webgpu/webgpu-render-pipeline.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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;
Expand Down
8 changes: 6 additions & 2 deletions src/platform/graphics/webgpu/webgpu-render-target.js
Original file line number Diff line number Diff line change
Expand Up @@ -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);

Expand All @@ -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;
Expand Down
4 changes: 2 additions & 2 deletions src/platform/graphics/webgpu/webgpu-texture.js
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down
20 changes: 16 additions & 4 deletions src/scene/camera.js
Original file line number Diff line number Diff line change
Expand Up @@ -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();

Expand All @@ -55,25 +63,29 @@ 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) {
Comment thread
MAG-AdrianMeredith marked this conversation as resolved.
out.copy(projection);
return out;
}
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;
}

Expand Down
3 changes: 2 additions & 1 deletion src/scene/gsplat-unified/gsplat-compute-local-renderer.js
Original file line number Diff line number Diff line change
Expand Up @@ -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];
Expand Down
2 changes: 1 addition & 1 deletion src/scene/gsplat-unified/gsplat-hybrid-renderer.js
Original file line number Diff line number Diff line change
Expand Up @@ -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];
Expand Down
3 changes: 2 additions & 1 deletion src/scene/gsplat-unified/gsplat-projector.js
Original file line number Diff line number Diff line change
Expand Up @@ -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);

Expand Down
9 changes: 9 additions & 0 deletions src/scene/immediate/immediate.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
`,
Expand All @@ -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;
}
Expand Down
Loading