Repository: playcanvas/engine
Pull request #5150 (2023-03-15 16:08:21)

[BREAKING] DepthState implementation for WebGL and WebGPU
- introductes `DepthState`, which encapsulates all depth state for both platforms. Data is currently stored as a bitfield, storing func and write only, test can be inferred from this (WebGPU does not expose it either).
- removed some WebGPU only hacks to work around missing depth state implementation
- similar implementation to recently added `BlendState`.

## Removed APIs:
```
GraphicsDevice#getDepthTest
GraphicsDevice#getDepthWrite
```
Note that `GraphicsDevice#getDepthFunc` didn't exist, otherwise it would have been removed too.

## Deprecated APIs:
```
GraphicsDevice#setDepthTest
GraphicsDevice#setDepthWrite
GraphicsDevice#setDepthFunc
```

## New APIs:
```
GraphicsDevice#setDepthState
class DepthState
```

## Docs

![Screenshot 2023-03-16 at 09 42 49](https://user-images.githubusercontent.com/59932779/225577938-2d0a8e42-1b26-4cc5-86ec-8359232b59b8.png)
![Screenshot 2023-03-16 at 09 43 03](https://user-images.githubusercontent.com/59932779/225577981-e2fd3605-6eed-4d67-838d-741bf9d68ee4.png)
![Screenshot 2023-03-16 at 09 43 14](https://user-images.githubusercontent.com/59932779/225578011-eda43e8c-6cca-4467-89e4-8b689ade7cf3.png)



Patch:
diff --git a/examples/src/examples/graphics/batching-dynamic.tsx b/examples/src/examples/graphics/batching-dynamic.tsx
index f8e13958b..dd8f449d1 100644
--- a/examples/src/examples/graphics/batching-dynamic.tsx
+++ b/examples/src/examples/graphics/batching-dynamic.tsx
@@ -3,6 +3,7 @@ import * as pc from '../../../../';
 class BatchingDynamicExample {
     static CATEGORY = 'Graphics';
     static NAME = 'Batching Dynamic';
+    static WEBGPU_ENABLED = true;
 
     example(canvas: HTMLCanvasElement, deviceType: string): void {
 
diff --git a/examples/src/examples/graphics/box-reflection.tsx b/examples/src/examples/graphics/box-reflection.tsx
index 11442d773..ba8e0bea3 100644
--- a/examples/src/examples/graphics/box-reflection.tsx
+++ b/examples/src/examples/graphics/box-reflection.tsx
@@ -7,6 +7,7 @@ import { Observer } from '@playcanvas/observer';
 class BoxReflectionExample {
     static CATEGORY = 'Graphics';
     static NAME = 'Box Reflection';
+    static WEBGPU_ENABLED = true;
 
     controls(data: Observer) {
         return <>
diff --git a/examples/src/examples/graphics/clustered-lighting.tsx b/examples/src/examples/graphics/clustered-lighting.tsx
index 721990e2e..5047edfac 100644
--- a/examples/src/examples/graphics/clustered-lighting.tsx
+++ b/examples/src/examples/graphics/clustered-lighting.tsx
@@ -4,6 +4,7 @@ class ClusteredLightingExample {
     static CATEGORY = 'Graphics';
     static NAME = 'Clustered Lighting';
     static ENGINE = 'PERFORMANCE';
+    static WEBGPU_ENABLED = true;
 
     example(canvas: HTMLCanvasElement, deviceType: string): void {
 
diff --git a/examples/src/examples/graphics/clustered-spot-shadows.tsx b/examples/src/examples/graphics/clustered-spot-shadows.tsx
index 53d2f910a..cbe5c863d 100644
--- a/examples/src/examples/graphics/clustered-spot-shadows.tsx
+++ b/examples/src/examples/graphics/clustered-spot-shadows.tsx
@@ -8,7 +8,7 @@ class ClusteredSpotShadowsExample {
     static CATEGORY = 'Graphics';
     static NAME = 'Clustered Spot Shadows';
     static ENGINE = 'DEBUG';
-
+    static WEBGPU_ENABLED = true;
 
     controls(data: Observer) {
         return <>
diff --git a/examples/src/examples/graphics/grab-pass.tsx b/examples/src/examples/graphics/grab-pass.tsx
index 78c79fa06..663d8be30 100644
--- a/examples/src/examples/graphics/grab-pass.tsx
+++ b/examples/src/examples/graphics/grab-pass.tsx
@@ -3,6 +3,8 @@ import * as pc from '../../../../';
 class GrabPassExample {
     static CATEGORY = 'Graphics';
     static NAME = 'Grab Pass';
+    static WEBGPU_ENABLED = true;
+
     static FILES = {
         'shader.vert': /* glsl */`
             attribute vec3 vertex_position;
diff --git a/examples/src/examples/graphics/hardware-instancing.tsx b/examples/src/examples/graphics/hardware-instancing.tsx
index 2736e3715..5f4975771 100644
--- a/examples/src/examples/graphics/hardware-instancing.tsx
+++ b/examples/src/examples/graphics/hardware-instancing.tsx
@@ -3,6 +3,7 @@ import * as pc from '../../../../';
 class HardwareInstancingExample {
     static CATEGORY = 'Graphics';
     static NAME = 'Hardware Instancing';
+    static WEBGPU_ENABLED = true;
 
     example(canvas: HTMLCanvasElement, deviceType: string): void {
 
diff --git a/examples/src/examples/graphics/hierarchy.tsx b/examples/src/examples/graphics/hierarchy.tsx
index 3644cafe7..c51c00a8d 100644
--- a/examples/src/examples/graphics/hierarchy.tsx
+++ b/examples/src/examples/graphics/hierarchy.tsx
@@ -3,6 +3,7 @@ import * as pc from '../../../../';
 class HierarchyExample {
     static CATEGORY = 'Graphics';
     static NAME = 'Hierarchy';
+    static WEBGPU_ENABLED = true;
 
     example(canvas: HTMLCanvasElement, deviceType: string): void {
 
diff --git a/examples/src/examples/graphics/layers.tsx b/examples/src/examples/graphics/layers.tsx
index 8ad7941d5..0007e27bc 100644
--- a/examples/src/examples/graphics/layers.tsx
+++ b/examples/src/examples/graphics/layers.tsx
@@ -3,6 +3,7 @@ import * as pc from '../../../../';
 class LayersExample {
     static CATEGORY = 'Graphics';
     static NAME = 'Layers';
+    static WEBGPU_ENABLED = true;
 
     example(canvas: HTMLCanvasElement, deviceType: string): void {
 
diff --git a/examples/src/examples/graphics/lines.tsx b/examples/src/examples/graphics/lines.tsx
index daa3d694e..f6f7c0ae1 100644
--- a/examples/src/examples/graphics/lines.tsx
+++ b/examples/src/examples/graphics/lines.tsx
@@ -3,6 +3,7 @@ import * as pc from '../../../../';
 class LinesExample {
     static CATEGORY = 'Graphics';
     static NAME = 'Lines';
+    static WEBGPU_ENABLED = true;
 
     example(canvas: HTMLCanvasElement, deviceType: string): void {
 
diff --git a/examples/src/examples/graphics/material-anisotropic.tsx b/examples/src/examples/graphics/material-anisotropic.tsx
index fa85fc9c2..5fb5b9c01 100644
--- a/examples/src/examples/graphics/material-anisotropic.tsx
+++ b/examples/src/examples/graphics/material-anisotropic.tsx
@@ -3,6 +3,7 @@ import * as pc from '../../../../';
 class LightsExample {
     static CATEGORY = 'Graphics';
     static NAME = 'Material Anisotropic';
+    static WEBGPU_ENABLED = true;
 
     example(canvas: HTMLCanvasElement, deviceType: string): void {
 
diff --git a/examples/src/examples/graphics/material-basic.tsx b/examples/src/examples/graphics/material-basic.tsx
index a2c5f6da5..c325aef7e 100644
--- a/examples/src/examples/graphics/material-basic.tsx
+++ b/examples/src/examples/graphics/material-basic.tsx
@@ -3,6 +3,7 @@ import * as pc from '../../../../';
 class MaterialBasicExample {
     static CATEGORY = 'Graphics';
     static NAME = 'Material Basic';
+    static WEBGPU_ENABLED = true;
 
     example(canvas: HTMLCanvasElement, deviceType: string): void {
 
diff --git a/examples/src/examples/graphics/material-clear-coat.tsx b/examples/src/examples/graphics/material-clear-coat.tsx
index b9ef725ef..2f43746a7 100644
--- a/examples/src/examples/graphics/material-clear-coat.tsx
+++ b/examples/src/examples/graphics/material-clear-coat.tsx
@@ -3,6 +3,7 @@ import * as pc from '../../../../';
 class MaterialClearCoatExample {
     static CATEGORY = 'Graphics';
     static NAME = 'Material Clear Coat';
+    static WEBGPU_ENABLED = true;
 
     example(canvas: HTMLCanvasElement, deviceType: string): void {
 
diff --git a/examples/src/examples/graphics/material-physical.tsx b/examples/src/examples/graphics/material-physical.tsx
index 77b72a5df..2f1079c1a 100644
--- a/examples/src/examples/graphics/material-physical.tsx
+++ b/examples/src/examples/graphics/material-physical.tsx
@@ -3,6 +3,7 @@ import * as pc from '../../../../';
 class MaterialPhysicalExample {
     static CATEGORY = 'Graphics';
     static NAME = 'Material Physical';
+    static WEBGPU_ENABLED = true;
 
     example(canvas: HTMLCanvasElement, deviceType: string): void {
 
diff --git a/examples/src/examples/graphics/material-translucent-specular.tsx b/examples/src/examples/graphics/material-translucent-specular.tsx
index feca0c04c..0ca0f00db 100644
--- a/examples/src/examples/graphics/material-translucent-specular.tsx
+++ b/examples/src/examples/graphics/material-translucent-specular.tsx
@@ -3,6 +3,7 @@ import * as pc from '../../../../';
 class MaterialTranslucentSpecularExample {
     static CATEGORY = 'Graphics';
     static NAME = 'Material Translucent Specular';
+    static WEBGPU_ENABLED = true;
 
     example(canvas: HTMLCanvasElement, deviceType: string): void {
 
diff --git a/examples/src/examples/graphics/mesh-decals.tsx b/examples/src/examples/graphics/mesh-decals.tsx
index 02d42d3bc..ee2422119 100644
--- a/examples/src/examples/graphics/mesh-decals.tsx
+++ b/examples/src/examples/graphics/mesh-decals.tsx
@@ -3,6 +3,7 @@ import * as pc from '../../../../';
 class MeshDecalsExample {
     static CATEGORY = 'Graphics';
     static NAME = 'Mesh Decals';
+    static WEBGPU_ENABLED = true;
 
     example(canvas: HTMLCanvasElement, deviceType: string): void {
 
diff --git a/examples/src/examples/graphics/mesh-deformation.tsx b/examples/src/examples/graphics/mesh-deformation.tsx
index c2ee00be4..ef728d6f7 100644
--- a/examples/src/examples/graphics/mesh-deformation.tsx
+++ b/examples/src/examples/graphics/mesh-deformation.tsx
@@ -3,6 +3,7 @@ import * as pc from '../../../../';
 class MeshDeformationExample {
     static CATEGORY = 'Graphics';
     static NAME = 'Mesh Deformation';
+    static WEBGPU_ENABLED = true;
 
     example(canvas: HTMLCanvasElement, deviceType: string): void {
 
diff --git a/examples/src/examples/graphics/mesh-generation.tsx b/examples/src/examples/graphics/mesh-generation.tsx
index 800ea36f5..73cf1ee79 100644
--- a/examples/src/examples/graphics/mesh-generation.tsx
+++ b/examples/src/examples/graphics/mesh-generation.tsx
@@ -3,6 +3,7 @@ import * as pc from '../../../../';
 class MeshGenerationExample {
     static CATEGORY = 'Graphics';
     static NAME = 'Mesh Generation';
+    static WEBGPU_ENABLED = true;
 
     example(canvas: HTMLCanvasElement, deviceType: string): void {
 
diff --git a/examples/src/examples/graphics/mesh-morph-many.tsx b/examples/src/examples/graphics/mesh-morph-many.tsx
index d78a562c0..69e836d03 100644
--- a/examples/src/examples/graphics/mesh-morph-many.tsx
+++ b/examples/src/examples/graphics/mesh-morph-many.tsx
@@ -3,6 +3,7 @@ import * as pc from '../../../../';
 class MeshMorphManyExample {
     static CATEGORY = 'Graphics';
     static NAME = 'Mesh Morph Many';
+    static WEBGPU_ENABLED = true;
 
     example(canvas: HTMLCanvasElement, deviceType: string): void {
 
diff --git a/examples/src/examples/graphics/mesh-morph.tsx b/examples/src/examples/graphics/mesh-morph.tsx
index 5a4b4f928..a72b7196d 100644
--- a/examples/src/examples/graphics/mesh-morph.tsx
+++ b/examples/src/examples/graphics/mesh-morph.tsx
@@ -3,6 +3,7 @@ import * as pc from '../../../../';
 class MeshMorphExample {
     static CATEGORY = 'Graphics';
     static NAME = 'Mesh Morph';
+    static WEBGPU_ENABLED = true;
 
     example(canvas: HTMLCanvasElement, deviceType: string): void {
 
diff --git a/examples/src/examples/graphics/model-asset.tsx b/examples/src/examples/graphics/model-asset.tsx
index cfa7f4eee..ffb4d0228 100644
--- a/examples/src/examples/graphics/model-asset.tsx
+++ b/examples/src/examples/graphics/model-asset.tsx
@@ -3,6 +3,7 @@ import * as pc from '../../../../';
 class ModelAssetExample {
     static CATEGORY = 'Graphics';
     static NAME = 'Model Asset';
+    static WEBGPU_ENABLED = true;
 
     example(canvas: HTMLCanvasElement, deviceType: string): void {
 
diff --git a/examples/src/examples/graphics/model-textured-box.tsx b/examples/src/examples/graphics/model-textured-box.tsx
index 410d80422..aa57b3272 100644
--- a/examples/src/examples/graphics/model-textured-box.tsx
+++ b/examples/src/examples/graphics/model-textured-box.tsx
@@ -3,6 +3,7 @@ import * as pc from '../../../../';
 class ModelTexturedBoxExample {
     static CATEGORY = 'Graphics';
     static NAME = 'Model Textured Box';
+    static WEBGPU_ENABLED = true;
 
     example(canvas: HTMLCanvasElement, deviceType: string): void {
 
diff --git a/examples/src/examples/graphics/multi-view.tsx b/examples/src/examples/graphics/multi-view.tsx
index 372c5450a..f0e0352eb 100644
--- a/examples/src/examples/graphics/multi-view.tsx
+++ b/examples/src/examples/graphics/multi-view.tsx
@@ -3,6 +3,7 @@ import * as pc from '../../../../';
 class MultiViewExample {
     static CATEGORY = 'Graphics';
     static NAME = 'Multi View';
+    static WEBGPU_ENABLED = true;
 
     example(canvas: HTMLCanvasElement, deviceType: string): void {
 
@@ -23,12 +24,12 @@ class MultiViewExample {
             };
 
             const gfxOptions = {
-            deviceTypes: [deviceType],
-            glslangUrl: '/static/lib/glslang/glslang.js',
-            twgslUrl: '/static/lib/twgsl/twgsl.js'
-        };
+                deviceTypes: [deviceType],
+                glslangUrl: '/static/lib/glslang/glslang.js',
+                twgslUrl: '/static/lib/twgsl/twgsl.js'
+            };
 
-        pc.createGraphicsDevice(canvas, gfxOptions).then((device: pc.GraphicsDevice) => {
+            pc.createGraphicsDevice(canvas, gfxOptions).then((device: pc.GraphicsDevice) => {
 
                 const createOptions = new pc.AppOptions();
                 createOptions.graphicsDevice = device;
diff --git a/examples/src/examples/graphics/render-asset.tsx b/examples/src/examples/graphics/render-asset.tsx
index cfbdd45a2..ca0dd3c1a 100644
--- a/examples/src/examples/graphics/render-asset.tsx
+++ b/examples/src/examples/graphics/render-asset.tsx
@@ -3,6 +3,7 @@ import * as pc from '../../../../';
 class RenderAssetExample {
     static CATEGORY = 'Graphics';
     static NAME = 'Render Asset';
+    static WEBGPU_ENABLED = true;
 
     example(canvas: HTMLCanvasElement, deviceType: string): void {
 
diff --git a/examples/src/examples/graphics/render-cubemap.tsx b/examples/src/examples/graphics/render-cubemap.tsx
index 3609a2c51..c4babd45a 100644
--- a/examples/src/examples/graphics/render-cubemap.tsx
+++ b/examples/src/examples/graphics/render-cubemap.tsx
@@ -3,6 +3,7 @@ import * as pc from '../../../../';
 class RenderCubemapExample {
     static CATEGORY = 'Graphics';
     static NAME = 'Render Cubemap';
+    static WEBGPU_ENABLED = true;
 
     example(canvas: HTMLCanvasElement, deviceType: string): void {
 
diff --git a/examples/src/examples/graphics/shader-burn.tsx b/examples/src/examples/graphics/shader-burn.tsx
index 6db921dc1..384ef8ed3 100644
--- a/examples/src/examples/graphics/shader-burn.tsx
+++ b/examples/src/examples/graphics/shader-burn.tsx
@@ -3,6 +3,8 @@ import * as pc from '../../../../';
 class ShaderBurnExample {
     static CATEGORY = 'Graphics';
     static NAME = 'Shader Burn';
+    static WEBGPU_ENABLED = true;
+
     static FILES = {
         'shader.vert': /* glsl */`
 attribute vec3 aPosition;
diff --git a/examples/src/examples/graphics/shader-toon.tsx b/examples/src/examples/graphics/shader-toon.tsx
index ee379dce0..a8f23ac02 100644
--- a/examples/src/examples/graphics/shader-toon.tsx
+++ b/examples/src/examples/graphics/shader-toon.tsx
@@ -3,6 +3,8 @@ import * as pc from '../../../../';
 class ShaderToonExample {
     static CATEGORY = 'Graphics';
     static NAME = 'Shader Toon';
+    static WEBGPU_ENABLED = true;
+
     static FILES = {
         'shader.vert': /* glsl */`
 // Attributes per vertex: position, normal and texture coordinates
diff --git a/examples/src/examples/graphics/shader-wobble.tsx b/examples/src/examples/graphics/shader-wobble.tsx
index a4f03c80f..03ebdbb72 100644
--- a/examples/src/examples/graphics/shader-wobble.tsx
+++ b/examples/src/examples/graphics/shader-wobble.tsx
@@ -3,6 +3,8 @@ import * as pc from '../../../../';
 class ShaderWobbleExample {
     static CATEGORY = 'Graphics';
     static NAME = 'Shader Wobble';
+    static WEBGPU_ENABLED = true;
+
     static FILES = {
         'shader.vert': /* glsl */`
 attribute vec3 aPosition;
diff --git a/examples/src/examples/graphics/shadow-cascades.tsx b/examples/src/examples/graphics/shadow-cascades.tsx
index d6f1548b2..6e0a1fe18 100644
--- a/examples/src/examples/graphics/shadow-cascades.tsx
+++ b/examples/src/examples/graphics/shadow-cascades.tsx
@@ -7,6 +7,7 @@ import { Observer } from '@playcanvas/observer';
 class ShadowCascadesExample {
     static CATEGORY = 'Graphics';
     static NAME = 'Shadow Cascades';
+    static WEBGPU_ENABLED = true;
 
     controls(data: Observer) {
         return <>
diff --git a/examples/src/examples/graphics/shapes.tsx b/examples/src/examples/graphics/shapes.tsx
index 00121a586..f821a296d 100644
--- a/examples/src/examples/graphics/shapes.tsx
+++ b/examples/src/examples/graphics/shapes.tsx
@@ -3,6 +3,7 @@ import * as pc from '../../../../';
 class ShapesExample {
     static CATEGORY = 'Graphics';
     static NAME = 'Shapes';
+    static WEBGPU_ENABLED = true;
 
     example(canvas: HTMLCanvasElement, deviceType: string): void {
 
diff --git a/extras/mini-stats/render2d.js b/extras/mini-stats/render2d.js
index 701429bbf..90bebe3f5 100644
--- a/extras/mini-stats/render2d.js
+++ b/extras/mini-stats/render2d.js
@@ -15,7 +15,8 @@ import {
     IndexBuffer,
     VertexBuffer,
     VertexFormat,
-    BlendState
+    BlendState,
+    DepthState
 } from 'playcanvas';
 
 // render 2d textured quads
@@ -175,10 +176,9 @@ class Render2d {
         buffer.setData(this.data.buffer);
 
         device.updateBegin();
-        device.setDepthTest(false);
-        device.setDepthWrite(false);
         device.setCullMode(CULLFACE_NONE);
         device.setBlendState(this.blendState);
+        device.setDepthState(DepthState.NODEPTH);
 
         device.setVertexBuffer(buffer, 0);
         device.setIndexBuffer(this.indexBuffer);
diff --git a/src/deprecated/deprecated.js b/src/deprecated/deprecated.js
index d4c8ec658..56af8ad7c 100644
--- a/src/deprecated/deprecated.js
+++ b/src/deprecated/deprecated.js
@@ -55,6 +55,7 @@ import { VertexIterator } from '../platform/graphics/vertex-iterator.js';
 import { ShaderUtils } from '../platform/graphics/shader-utils.js';
 import { GraphicsDeviceAccess } from '../platform/graphics/graphics-device-access.js';
 import { BlendState } from '../platform/graphics/blend-state.js';
+import { DepthState } from '../platform/graphics/depth-state.js';
 
 import { PROJECTION_ORTHOGRAPHIC, PROJECTION_PERSPECTIVE, LAYERID_IMMEDIATE, LINEBATCH_OVERLAY, LAYERID_WORLD } from '../scene/constants.js';
 import { calculateTangents, createBox, createCapsule, createCone, createCylinder, createMesh, createPlane, createSphere, createTorus } from '../scene/procedural.js';
@@ -580,6 +581,7 @@ GraphicsDevice.prototype.removeShaderFromCache = function (shader) {
 };
 
 const _tempBlendState = new BlendState();
+const _tempDepthState = new DepthState();
 
 GraphicsDevice.prototype.setBlendFunction = function (blendSrc, blendDst) {
     Debug.deprecated(`pc.GraphicsDevice#setBlendFunction is deprecated, use pc.GraphicsDevice.setBlendState instead.`);
@@ -636,6 +638,27 @@ GraphicsDevice.prototype.setBlending = function (blending) {
     this.setBlendState(_tempBlendState);
 };
 
+GraphicsDevice.prototype.setDepthWrite = function (write) {
+    Debug.deprecated(`pc.GraphicsDevice#setDepthWrite is deprecated, use pc.GraphicsDevice.setDepthState instead.`);
+    _tempDepthState.copy(this.depthState);
+    _tempDepthState.write = write;
+    this.setDepthState(_tempDepthState);
+};
+
+GraphicsDevice.prototype.setDepthFunc = function (func) {
+    Debug.deprecated(`pc.GraphicsDevice#setDepthFunc is deprecated, use pc.GraphicsDevice.setDepthState instead.`);
+    _tempDepthState.copy(this.depthState);
+    _tempDepthState.func = func;
+    this.setDepthState(_tempDepthState);
+};
+
+GraphicsDevice.prototype.setDepthTest = function (test) {
+    Debug.deprecated(`pc.GraphicsDevice#setDepthTest is deprecated, use pc.GraphicsDevice.setDepthState instead.`);
+    _tempDepthState.copy(this.depthState);
+    _tempDepthState.test = test;
+    this.setDepthState(_tempDepthState);
+};
+
 // SCENE
 
 export const PhongMaterial = StandardMaterial;
diff --git a/src/framework/lightmapper/lightmapper.js b/src/framework/lightmapper/lightmapper.js
index 397a42df4..c8de30cef 100644
--- a/src/framework/lightmapper/lightmapper.js
+++ b/src/framework/lightmapper/lightmapper.js
@@ -45,6 +45,7 @@ import { BakeMeshNode } from './bake-mesh-node.js';
 import { LightmapCache } from '../../scene/graphics/lightmap-cache.js';
 import { LightmapFilters } from './lightmap-filters.js';
 import { BlendState } from '../../platform/graphics/blend-state.js';
+import { DepthState } from '../../platform/graphics/depth-state.js';
 
 const MAX_LIGHTMAP_SIZE = 2048;
 
@@ -879,6 +880,7 @@ class Lightmapper {
         }
 
         device.setBlendState(BlendState.DEFAULT);
+        device.setDepthState(DepthState.NODEPTH);
 
         for (let node = 0; node < bakeNodes.length; node++) {
             const bakeNode = bakeNodes[node];
diff --git a/src/index.js b/src/index.js
index 73d59bc7a..2d97c71cb 100644
--- a/src/index.js
+++ b/src/index.js
@@ -58,6 +58,7 @@ export * from './platform/audio/constants.js';
 export * from './platform/graphics/constants.js';
 export { createGraphicsDevice } from './platform/graphics/graphics-device-create.js';
 export { BlendState } from './platform/graphics/blend-state.js';
+export { DepthState } from './platform/graphics/depth-state.js';
 export { GraphicsDevice } from './platform/graphics/graphics-device.js';
 export { IndexBuffer } from './platform/graphics/index-buffer.js';
 export { RenderTarget } from './platform/graphics/render-target.js';
diff --git a/src/platform/graphics/blend-state.js b/src/platform/graphics/blend-state.js
index 8fe576f3d..82109371d 100644
--- a/src/platform/graphics/blend-state.js
+++ b/src/platform/graphics/blend-state.js
@@ -31,7 +31,7 @@ const allWriteShift = redWriteShift;
  */
 class BlendState {
     /**
-     * Number bits of which represent the blend state for render target 0.
+     * Bitfield representing the blend state for render target 0.
      *
      * @private
      */
@@ -191,6 +191,12 @@ class BlendState {
         return BitPacking.get(this.target0, allWriteShift, allWriteMasks);
     }
 
+    /**
+     * Copies the contents of a source blend state to this blend state.
+     *
+     * @param {BlendState} rhs - A blend state to copy from.
+     * @returns {BlendState} Self for chaining.
+     */
     copy(rhs) {
         this.target0 = rhs.target0;
         return this;
diff --git a/src/platform/graphics/depth-state.js b/src/platform/graphics/depth-state.js
new file mode 100644
index 000000000..fe870b0c3
--- /dev/null
+++ b/src/platform/graphics/depth-state.js
@@ -0,0 +1,155 @@
+import { BitPacking } from "../../core/math/bit-packing.js";
+import {
+    FUNC_LESSEQUAL, FUNC_ALWAYS
+} from './constants.js';
+
+// masks (to only keep relevant bits)
+const funcMask = 0b111;
+
+// shifts values to where individual parts are stored
+const funcShift = 0;       // 00 - 02 (3bits)
+const writeShift = 3;      // 03 - 03 (1bit)
+
+/**
+ * DepthState is a descriptor that defines how the depth value of the fragment is used by the
+ * rendering pipeline. A depth state can be set on a material using {@link Material#depthState},
+ * or in some cases on the graphics device using {@link GraphicsDevice#setDepthState}.
+ *
+ * For the best performance, do not modify depth state after it has been created, but create
+ * multiple depth states and assign them to the material or graphics device as needed.
+ */
+class DepthState {
+    /**
+     * Bitfield representing the depth state.
+     *
+     * @private
+     */
+    data = 0;
+
+    /**
+     * Create a new Depth State instance.
+     *
+     * @param {number} func - Controls how the depth of the fragment is compared against the
+     * current depth contained in the depth buffer. See {@link DepthState#func} for details.
+     * Defaults to {@link FUNC_LESSEQUAL}.
+     * @param {boolean} write - If true, depth values are written to the depth buffer of the
+     * currently active render target. Defaults to true.
+     */
+    constructor(func = FUNC_LESSEQUAL, write = true) {
+        this.func = func;
+        this.write = write;
+    }
+
+    /**
+     * If true, a shader fragment is only written to the current render target if it passes the depth
+     * test. If false, it is written regardless of what is in the depth buffer. Note that when depth
+     * testing is disabled, writes to the depth buffer are also disabled. Defaults to true.
+     *
+     * @type {boolean}
+     */
+    set test(value) {
+        this.func = value ? FUNC_LESSEQUAL : FUNC_ALWAYS;
+    }
+
+    get test() {
+        return this.func !== FUNC_ALWAYS;
+    }
+
+    /**
+     * If true, shader write a depth value to the depth buffer of the currently active render
+     * target. If false, no depth value is written.
+     *
+     * @type {boolean}
+     */
+    set write(value) {
+        this.data = BitPacking.set(this.data, value ? 1 : 0, writeShift);
+    }
+
+    get write() {
+        return BitPacking.all(this.data, writeShift);
+    }
+
+    /**
+     * Controls how the depth of the fragment is compared against the current depth contained in
+     * the depth buffer. Can be:
+     *
+     * - {@link FUNC_NEVER}: don't draw
+     * - {@link FUNC_LESS}: draw if new depth < depth buffer
+     * - {@link FUNC_EQUAL}: draw if new depth == depth buffer
+     * - {@link FUNC_LESSEQUAL}: draw if new depth <= depth buffer
+     * - {@link FUNC_GREATER}: draw if new depth > depth buffer
+     * - {@link FUNC_NOTEQUAL}: draw if new depth != depth buffer
+     * - {@link FUNC_GREATEREQUAL}: draw if new depth >= depth buffer
+     * - {@link FUNC_ALWAYS}: always draw
+     *
+     * @type {number}
+     */
+    set func(value) {
+        this.data = BitPacking.set(this.data, value, funcShift, funcMask);
+    }
+
+    get func() {
+        return BitPacking.get(this.data, funcShift, funcMask);
+    }
+
+    /**
+     * Copies the contents of a source depth state to this depth state.
+     *
+     * @param {DepthState} rhs - A depth state to copy from.
+     * @returns {DepthState} Self for chaining.
+     */
+    copy(rhs) {
+        this.data = rhs.data;
+        return this;
+    }
+
+    /**
+     * Returns an identical copy of the specified depth state.
+     *
+     * @returns {this} The result of the cloning.
+     */
+    clone() {
+        const clone = new this.constructor();
+        return clone.copy(this);
+    }
+
+    get key() {
+        return this.data;
+    }
+
+    /**
+     * Reports whether two DepthStates are equal.
+     *
+     * @param {DepthState} rhs - The depth state to compare to.
+     * @returns {boolean} True if the depth states are equal and false otherwise.
+     */
+    equals(rhs) {
+        return this.data === rhs.data;
+    }
+
+    /**
+     * A default depth state that has the depth testing function set to {@link FUNC_LESSEQUAL} and depth writes enabled.
+     *
+     * @type {DepthState}
+     * @readonly
+     */
+    static DEFAULT = Object.freeze(new DepthState());
+
+    /**
+     * A depth state that always passes the fragment but does not write depth to the depth buffer.
+     *
+     * @type {DepthState}
+     * @readonly
+     */
+    static NODEPTH = Object.freeze(new DepthState(FUNC_ALWAYS, false));
+
+    /**
+     * A depth state that always passes the fragment and writes depth to the depth buffer.
+     *
+     * @type {DepthState}
+     * @readonly
+     */
+    static WRITEDEPTH = Object.freeze(new DepthState(FUNC_ALWAYS, true));
+}
+
+export { DepthState };
diff --git a/src/platform/graphics/graphics-device.js b/src/platform/graphics/graphics-device.js
index 25fe037a6..ca0614124 100644
--- a/src/platform/graphics/graphics-device.js
+++ b/src/platform/graphics/graphics-device.js
@@ -2,7 +2,6 @@ import { Debug } from '../../core/debug.js';
 import { EventHandler } from '../../core/event-handler.js';
 import { platform } from '../../core/platform.js';
 import { now } from '../../core/time.js';
-import { BlendState } from './blend-state.js';
 
 import {
     BUFFER_STATIC,
@@ -10,6 +9,8 @@ import {
     CLEARFLAG_DEPTH,
     PRIMITIVE_POINTS, PRIMITIVE_TRIFAN, SEMANTIC_POSITION, TYPE_FLOAT32
 } from './constants.js';
+import { BlendState } from './blend-state.js';
+import { DepthState } from './depth-state.js';
 import { ScopeSpace } from './scope-space.js';
 import { VertexBuffer } from './vertex-buffer.js';
 import { VertexFormat } from './vertex-format.js';
@@ -142,6 +143,13 @@ class GraphicsDevice extends EventHandler {
      */
     blendState = new BlendState();
 
+    /**
+     * The current depth state.
+     *
+     * @ignore
+     */
+    depthState = new DepthState();
+
     defaultClearOptions = {
         color: [0, 0, 0, 1],
         depth: 1,
@@ -278,6 +286,7 @@ class GraphicsDevice extends EventHandler {
     initializeRenderState() {
 
         this.blendState = new BlendState();
+        this.depthState = new DepthState();
 
         // Cached viewport and scissor dimensions
         this.vx = this.vy = this.vw = this.vh = 0;
@@ -293,6 +302,15 @@ class GraphicsDevice extends EventHandler {
         Debug.assert(false);
     }
 
+    /**
+     * Sets the specified depth state.
+     *
+     * @param {DepthState} depthState - New depth state.
+     */
+    setDepthState(depthState) {
+        Debug.assert(false);
+    }
+
     /**
      * Sets the specified render target on the device. If null is passed as a parameter, the back
      * buffer becomes the current target for all rendering operations.
diff --git a/src/platform/graphics/webgl/webgl-graphics-device.js b/src/platform/graphics/webgl/webgl-graphics-device.js
index eb2f1d9f2..e7d1a6d86 100644
--- a/src/platform/graphics/webgl/webgl-graphics-device.js
+++ b/src/platform/graphics/webgl/webgl-graphics-device.js
@@ -9,7 +9,7 @@ import {
     CULLFACE_BACK, CULLFACE_NONE,
     FILTER_NEAREST, FILTER_LINEAR, FILTER_NEAREST_MIPMAP_NEAREST, FILTER_NEAREST_MIPMAP_LINEAR,
     FILTER_LINEAR_MIPMAP_NEAREST, FILTER_LINEAR_MIPMAP_LINEAR,
-    FUNC_ALWAYS, FUNC_LESSEQUAL,
+    FUNC_ALWAYS,
     PIXELFORMAT_RGB8, PIXELFORMAT_RGBA8, PIXELFORMAT_RGBA16F, PIXELFORMAT_RGBA32F,
     STENCILOP_KEEP,
     UNIFORMTYPE_BOOL, UNIFORMTYPE_INT, UNIFORMTYPE_FLOAT, UNIFORMTYPE_VEC2, UNIFORMTYPE_VEC3,
@@ -36,6 +36,7 @@ import { WebglRenderTarget } from './webgl-render-target.js';
 import { ShaderUtils } from '../shader-utils.js';
 import { Shader } from '../shader.js';
 import { BlendState } from '../blend-state.js';
+import { DepthState } from '../depth-state.js';
 
 const invalidateAttachments = [];
 
@@ -87,13 +88,9 @@ function quadWithShader(device, target, shader) {
     device.setRenderTarget(target);
     device.updateBegin();
 
-    const oldDepthTest = device.getDepthTest();
-    const oldDepthWrite = device.getDepthWrite();
-    const oldCullMode = device.getCullMode();
-    device.setDepthTest(false);
-    device.setDepthWrite(false);
     device.setCullMode(CULLFACE_NONE);
     device.setBlendState(BlendState.DEFAULT);
+    device.setDepthState(DepthState.NODEPTH);
 
     device.setVertexBuffer(device.quadVertexBuffer, 0);
     device.setShader(shader);
@@ -105,10 +102,6 @@ function quadWithShader(device, target, shader) {
         indexed: false
     });
 
-    device.setDepthTest(oldDepthTest);
-    device.setDepthWrite(oldDepthWrite);
-    device.setCullMode(oldCullMode);
-
     device.updateEnd();
 
     device.setRenderTarget(oldRt);
@@ -1061,13 +1054,9 @@ class WebglGraphicsDevice extends GraphicsDevice {
         gl.enable(gl.CULL_FACE);
         gl.cullFace(gl.BACK);
 
-        this.depthTest = true;
+        // default depth state
         gl.enable(gl.DEPTH_TEST);
-
-        this.depthFunc = FUNC_LESSEQUAL;
         gl.depthFunc(gl.LEQUAL);
-
-        this.depthWrite = true;
         gl.depthMask(true);
 
         this.stencil = false;
@@ -2072,21 +2061,39 @@ class WebglGraphicsDevice extends GraphicsDevice {
             // Set the clear color
             if (flags & CLEARFLAG_COLOR) {
                 const color = options.color ?? defaultOptions.color;
-                this.setClearColor(color[0], color[1], color[2], color[3]);
+                const r = color[0];
+                const g = color[1];
+                const b = color[2];
+                const a = color[3];
+
+                const c = this.clearColor;
+                if ((r !== c.r) || (g !== c.g) || (b !== c.b) || (a !== c.a)) {
+                    this.gl.clearColor(r, g, b, a);
+                    this.clearColor.set(r, g, b, a);
+                }
+
                 this.setBlendState(BlendState.DEFAULT);
             }
 
             if (flags & CLEARFLAG_DEPTH) {
                 // Set the clear depth
                 const depth = options.depth ?? defaultOptions.depth;
-                this.setClearDepth(depth);
-                this.setDepthWrite(true);
+
+                if (depth !== this.clearDepth) {
+                    this.gl.clearDepth(depth);
+                    this.clearDepth = depth;
+                }
+
+                this.setDepthState(DepthState.WRITEDEPTH);
             }
 
             if (flags & CLEARFLAG_STENCIL) {
                 // Set the clear stencil
                 const stencil = options.stencil ?? defaultOptions.stencil;
-                this.setClearStencil(stencil);
+                if (stencil !== this.clearStencil) {
+                    this.gl.clearStencil(stencil);
+                    this.clearStencil = stencil;
+                }
             }
 
             // Clear the frame buffer
@@ -2111,129 +2118,6 @@ class WebglGraphicsDevice extends GraphicsDevice {
         gl.readPixels(x, y, w, h, gl.RGBA, gl.UNSIGNED_BYTE, pixels);
     }
 
-    /**
-     * Set the depth value used when the depth buffer is cleared.
-     *
-     * @param {number} depth - The depth value to clear the depth buffer to in the range 0.0
-     * to 1.0.
-     * @ignore
-     */
-    setClearDepth(depth) {
-        if (depth !== this.clearDepth) {
-            this.gl.clearDepth(depth);
-            this.clearDepth = depth;
-        }
-    }
-
-    /**
-     * Set the clear color used when the frame buffer is cleared.
-     *
-     * @param {number} r - The red component of the color in the range 0.0 to 1.0.
-     * @param {number} g - The green component of the color in the range 0.0 to 1.0.
-     * @param {number} b - The blue component of the color in the range 0.0 to 1.0.
-     * @param {number} a - The alpha component of the color in the range 0.0 to 1.0.
-     * @ignore
-     */
-    setClearColor(r, g, b, a) {
-        const c = this.clearColor;
-        if ((r !== c.r) || (g !== c.g) || (b !== c.b) || (a !== c.a)) {
-            this.gl.clearColor(r, g, b, a);
-            this.clearColor.set(r, g, b, a);
-        }
-    }
-
-    /**
-     * Set the stencil clear value used when the stencil buffer is cleared.
-     *
-     * @param {number} value - The stencil value to clear the stencil buffer to.
-     */
-    setClearStencil(value) {
-        if (value !== this.clearStencil) {
-            this.gl.clearStencil(value);
-            this.clearStencil = value;
-        }
-    }
-
-    /**
-     * Queries whether depth testing is enabled.
-     *
-     * @returns {boolean} True if depth testing is enabled and false otherwise.
-     * @example
-     * var depthTest = device.getDepthTest();
-     * console.log('Depth testing is ' + depthTest ? 'enabled' : 'disabled');
-     */
-    getDepthTest() {
-        return this.depthTest;
-    }
-
-    /**
-     * Enables or disables depth testing of fragments. Once this state is set, it persists until it
-     * is changed. By default, depth testing is enabled.
-     *
-     * @param {boolean} depthTest - True to enable depth testing and false otherwise.
-     * @example
-     * device.setDepthTest(true);
-     */
-    setDepthTest(depthTest) {
-        if (this.depthTest !== depthTest) {
-            const gl = this.gl;
-            if (depthTest) {
-                gl.enable(gl.DEPTH_TEST);
-            } else {
-                gl.disable(gl.DEPTH_TEST);
-            }
-            this.depthTest = depthTest;
-        }
-    }
-
-    /**
-     * Configures the depth test.
-     *
-     * @param {number} func - A function to compare a new depth value with an existing z-buffer
-     * value and decide if to write a pixel. Can be:
-     *
-     * - {@link FUNC_NEVER}: don't draw
-     * - {@link FUNC_LESS}: draw if new depth < depth buffer
-     * - {@link FUNC_EQUAL}: draw if new depth == depth buffer
-     * - {@link FUNC_LESSEQUAL}: draw if new depth <= depth buffer
-     * - {@link FUNC_GREATER}: draw if new depth > depth buffer
-     * - {@link FUNC_NOTEQUAL}: draw if new depth != depth buffer
-     * - {@link FUNC_GREATEREQUAL}: draw if new depth >= depth buffer
-     * - {@link FUNC_ALWAYS}: always draw
-     */
-    setDepthFunc(func) {
-        if (this.depthFunc === func) return;
-        this.gl.depthFunc(this.glComparison[func]);
-        this.depthFunc = func;
-    }
-
-    /**
-     * Queries whether writes to the depth buffer are enabled.
-     *
-     * @returns {boolean} True if depth writing is enabled and false otherwise.
-     * @example
-     * var depthWrite = device.getDepthWrite();
-     * console.log('Depth writing is ' + depthWrite ? 'enabled' : 'disabled');
-     */
-    getDepthWrite() {
-        return this.depthWrite;
-    }
-
-    /**
-     * Enables or disables writes to the depth buffer. Once this state is set, it persists until it
-     * is changed. By default, depth writes are enabled.
-     *
-     * @param {boolean} writeDepth - True to enable depth writing and false otherwise.
-     * @example
-     * device.setDepthWrite(true);
-     */
-    setDepthWrite(writeDepth) {
-        if (this.depthWrite !== writeDepth) {
-            this.gl.depthMask(writeDepth);
-            this.depthWrite = writeDepth;
-        }
-    }
-
     /**
      * Enables or disables alpha to coverage (WebGL2 only).
      *
@@ -2601,6 +2485,42 @@ class WebglGraphicsDevice extends GraphicsDevice {
         }
     }
 
+    setDepthState(depthState) {
+        const currentDepthState = this.depthState;
+        if (!currentDepthState.equals(depthState)) {
+            const gl = this.gl;
+
+            // write
+            const write = depthState.write;
+            if (currentDepthState.write !== write) {
+                gl.depthMask(write);
+            }
+
+            // handle case where depth testing is off, but depth write is on => enable always test to depth write
+            // Note on WebGL API behavior: When depth testing is disabled, writes to the depth buffer are also disabled.
+            let { func, test } = depthState;
+            if (!test && write) {
+                test = true;
+                func = FUNC_ALWAYS;
+            }
+
+            if (currentDepthState.func !== func) {
+                gl.depthFunc(this.glComparison[func]);
+            }
+
+            if (currentDepthState.test !== test) {
+                if (test) {
+                    gl.enable(gl.DEPTH_TEST);
+                } else {
+                    gl.disable(gl.DEPTH_TEST);
+                }
+            }
+
+            // update internal state
+            currentDepthState.copy(depthState);
+        }
+    }
+
     /**
      * Controls how triangles are culled based on their face direction. The default cull mode is
      * {@link CULLFACE_BACK}.
diff --git a/src/platform/graphics/webgpu/webgpu-clear-renderer.js b/src/platform/graphics/webgpu/webgpu-clear-renderer.js
index 74d3b3cfb..8a2c20638 100644
--- a/src/platform/graphics/webgpu/webgpu-clear-renderer.js
+++ b/src/platform/graphics/webgpu/webgpu-clear-renderer.js
@@ -10,6 +10,7 @@ import { Shader } from "../shader.js";
 import { BindGroup } from "../bind-group.js";
 import { UniformBuffer } from "../uniform-buffer.js";
 import { DebugGraphics } from "../debug-graphics.js";
+import { DepthState } from "../depth-state.js";
 
 const primitive = {
     type: PRIMITIVE_TRISTRIP,
@@ -88,11 +89,6 @@ class WebgpuClearRenderer {
         this.colorData = new Float32Array(4);
         this.colorId = device.scope.resolve('color');
         this.depthId = device.scope.resolve('depth');
-
-
-        // TODO: WebGPU does not handle depth state, and so we pass this hack to render pipeline creation
-        // to avoid depth test (always write)
-        this.shader.impl.hackAlwaysWrite = true;
     }
 
     clear(device, renderTarget, options, defaultOptions) {
@@ -118,11 +114,11 @@ class WebgpuClearRenderer {
             if ((flags & CLEARFLAG_DEPTH) && renderTarget.depth) {
                 const depth = options.depth ?? defaultOptions.depth;
                 this.depthId.setValue(depth);
-
-                // TODO: set up depth state to write / not write to depth
+                device.setDepthState(DepthState.WRITEDEPTH);
 
             } else {
                 this.depthId.setValue(1);
+                device.setDepthState(DepthState.NODEPTH);
             }
 
             // setup stencil clear
diff --git a/src/platform/graphics/webgpu/webgpu-graphics-device.js b/src/platform/graphics/webgpu/webgpu-graphics-device.js
index d070495df..b9f0d5668 100644
--- a/src/platform/graphics/webgpu/webgpu-graphics-device.js
+++ b/src/platform/graphics/webgpu/webgpu-graphics-device.js
@@ -332,7 +332,7 @@ class WebgpuGraphicsDevice extends GraphicsDevice {
 
             // render pipeline
             const pipeline = this.renderPipeline.get(primitive, vb0?.format, vb1?.format, this.shader, this.renderTarget,
-                                                     this.bindGroupFormats, this.blendState);
+                                                     this.bindGroupFormats, this.blendState, this.depthState);
             Debug.assert(pipeline);
 
             if (this.pipeline !== pipeline) {
@@ -368,6 +368,10 @@ class WebgpuGraphicsDevice extends GraphicsDevice {
         this.blendState.copy(blendState);
     }
 
+    setDepthState(depthState) {
+        this.depthState.copy(depthState);
+    }
+
     setBlendColor(r, g, b, a) {
         // TODO: this should use passEncoder.setBlendConstant(color)
     }
@@ -378,10 +382,6 @@ class WebgpuGraphicsDevice extends GraphicsDevice {
     setDepthTest(depthTest) {
     }
 
-    getDepthTest() {
-        return true;
-    }
-
     setCullMode(cullMode) {
     }
 
@@ -395,10 +395,6 @@ class WebgpuGraphicsDevice extends GraphicsDevice {
     setDepthWrite(writeDepth) {
     }
 
-    getDepthWrite() {
-        return true;
-    }
-
     initializeContextCaches() {
         super.initializeContextCaches();
     }
diff --git a/src/platform/graphics/webgpu/webgpu-render-pipeline.js b/src/platform/graphics/webgpu/webgpu-render-pipeline.js
index 971fe022e..191be6f53 100644
--- a/src/platform/graphics/webgpu/webgpu-render-pipeline.js
+++ b/src/platform/graphics/webgpu/webgpu-render-pipeline.js
@@ -40,6 +40,17 @@ const _blendFactor = [
     'one-minus-constant'    // BLENDMODE_ONE_MINUS_CONSTANT
 ];
 
+const _depthCompareFunction = [
+    'never',                // FUNC_NEVER
+    'less',                 // FUNC_LESS
+    'equal',                // FUNC_EQUAL
+    'less-equal',           // FUNC_LESSEQUAL
+    'greater',              // FUNC_GREATER
+    'not-equal',            // FUNC_NOTEQUAL
+    'greater-equal',        // FUNC_GREATEREQUAL
+    'always'                // FUNC_ALWAYS
+];
+
 // temp array to avoid allocation
 const _bindGroupLayouts = [];
 
@@ -66,10 +77,10 @@ class WebgpuRenderPipeline {
         this.cache = new Map();
     }
 
-    get(primitive, vertexFormat0, vertexFormat1, shader, renderTarget, bindGroupFormats, blendState) {
+    get(primitive, vertexFormat0, vertexFormat1, shader, renderTarget, bindGroupFormats, blendState, depthState) {
 
         // render pipeline unique key
-        const key = this.getKey(primitive, vertexFormat0, vertexFormat1, shader, renderTarget, bindGroupFormats, blendState);
+        const key = this.getKey(primitive, vertexFormat0, vertexFormat1, shader, renderTarget, bindGroupFormats, blendState, depthState);
 
         // cached pipeline
         let pipeline = this.cache.get(key);
@@ -85,7 +96,7 @@ class WebgpuRenderPipeline {
             const vertexBufferLayout = this.vertexBufferLayout.get(vertexFormat0, vertexFormat1);
 
             // pipeline
-            pipeline = this.create(primitiveTopology, shader.impl, renderTarget, pipelineLayout, blendState, vertexBufferLayout);
+            pipeline = this.create(primitiveTopology, shader.impl, renderTarget, pipelineLayout, blendState, depthState, vertexBufferLayout);
             this.cache.set(key, pipeline);
         }
 
@@ -96,7 +107,7 @@ class WebgpuRenderPipeline {
      * Generate a unique key for the render pipeline. Keep this function as lean as possible,
      * as it executes for each draw call.
      */
-    getKey(primitive, vertexFormat0, vertexFormat1, shader, renderTarget, bindGroupFormats, blendState) {
+    getKey(primitive, vertexFormat0, vertexFormat1, shader, renderTarget, bindGroupFormats, blendState, depthState) {
 
         let bindGroupKey = '';
         for (let i = 0; i < bindGroupFormats.length; i++) {
@@ -107,7 +118,7 @@ class WebgpuRenderPipeline {
         const renderTargetKey = renderTarget.impl.key;
 
         return vertexBufferLayoutKey + shader.impl.vertexCode + shader.impl.fragmentCode +
-            renderTargetKey + primitive.type + bindGroupKey + blendState.key;
+            renderTargetKey + primitive.type + bindGroupKey + blendState.key + depthState.key;
     }
 
     // TODO: this could be cached using bindGroupKey
@@ -171,16 +182,33 @@ class WebgpuRenderPipeline {
         return blend;
     }
 
-    create(primitiveTopology, webgpuShader, renderTarget, pipelineLayout, blendState, vertexBufferLayout) {
-
-        const wgpu = this.device.wgpu;
+    /** @private */
+    getDepthStencil(depthState, renderTarget) {
 
         /** @type {GPUDepthStencilState} */
-        const depthStencil = renderTarget.depth ? {
-            depthWriteEnabled: true,
-            depthCompare: webgpuShader.hackAlwaysWrite ? 'always' : 'less',
-            format: renderTarget.impl.depthFormat
-        } : undefined;
+        let depthStencil;
+        const { depth, stencil } = renderTarget;
+        if (depth || stencil) {
+            depthStencil = {
+                format: renderTarget.impl.depthFormat
+            };
+
+            if (depth) {
+                depthStencil.depthWriteEnabled = depthState.write;
+                depthStencil.depthCompare = _depthCompareFunction[depthState.func];
+            } else {
+                // if render target does not have depth buffer
+                depthStencil.depthWriteEnabled = false;
+                depthStencil.depthCompare = 'always';
+            }
+        }
+
+        return depthStencil;
+    }
+
+    create(primitiveTopology, webgpuShader, renderTarget, pipelineLayout, blendState, depthState, vertexBufferLayout) {
+
+        const wgpu = this.device.wgpu;
 
         /** @type {GPURenderPipelineDescriptor} */
         const descr = {
@@ -194,7 +222,7 @@ class WebgpuRenderPipeline {
                 cullMode: "none"
             },
 
-            depthStencil,
+            depthStencil: this.getDepthStencil(depthState, renderTarget),
 
             multisample: {
                 count: renderTarget.samples
diff --git a/src/scene/graphics/quad-render-utils.js b/src/scene/graphics/quad-render-utils.js
index 11ae7d837..8fe09cf7e 100644
--- a/src/scene/graphics/quad-render-utils.js
+++ b/src/scene/graphics/quad-render-utils.js
@@ -3,6 +3,7 @@ import { Vec4 } from '../../core/math/vec4.js';
 
 import { CULLFACE_NONE } from '../../platform/graphics/constants.js';
 import { DebugGraphics } from '../../platform/graphics/debug-graphics.js';
+import { DepthState } from '../../platform/graphics/depth-state.js';
 import { RenderPass } from '../../platform/graphics/render-pass.js';
 import { QuadRender } from './quad-render.js';
 
@@ -36,14 +37,11 @@ function drawQuadWithShader(device, target, shader, rect, scissorRect) {
 
     DebugGraphics.pushGpuMarker(device, "drawQuadWithShader");
 
-    const oldDepthTest = device.getDepthTest();
-    const oldDepthWrite = device.getDepthWrite();
     const oldCullMode = device.getCullMode();
-
-    device.setDepthTest(false);
-    device.setDepthWrite(false);
     device.setCullMode(CULLFACE_NONE);
 
+    device.setDepthState(DepthState.NODEPTH);
+
     // prepare the quad for rendering with the shader
     const quad = new QuadRender(shader);
 
@@ -73,8 +71,6 @@ function drawQuadWithShader(device, target, shader, rect, scissorRect) {
     renderPass.render();
     quad.destroy();
 
-    device.setDepthTest(oldDepthTest);
-    device.setDepthWrite(oldDepthWrite);
     device.setCullMode(oldCullMode);
 
     DebugGraphics.popGpuMarker(device);
diff --git a/src/scene/materials/material.js b/src/scene/materials/material.js
index d727be106..77dae7255 100644
--- a/src/scene/materials/material.js
+++ b/src/scene/materials/material.js
@@ -1,5 +1,4 @@
 import { Debug } from '../../core/debug.js';
-import { BlendState } from '../../platform/graphics/blend-state.js';
 
 import {
     BLENDMODE_ZERO, BLENDMODE_ONE, BLENDMODE_SRC_COLOR,
@@ -7,9 +6,10 @@ import {
     BLENDMODE_ONE_MINUS_SRC_ALPHA,
     BLENDEQUATION_ADD, BLENDEQUATION_REVERSE_SUBTRACT,
     BLENDEQUATION_MIN, BLENDEQUATION_MAX,
-    CULLFACE_BACK,
-    FUNC_LESSEQUAL
+    CULLFACE_BACK
 } from '../../platform/graphics/constants.js';
+import { BlendState } from '../../platform/graphics/blend-state.js';
+import { DepthState } from '../../platform/graphics/depth-state.js';
 import { ShaderProcessorOptions } from '../../platform/graphics/shader-processor-options.js';
 
 import {
@@ -97,6 +97,9 @@ class Material {
     /** @ignore */
     _blendState = new BlendState();
 
+    /** @ignore */
+    _depthState = new DepthState();
+
     /**
      * Controls how triangles are culled based on their face direction with respect to the
      * viewpoint. Can be:
@@ -115,46 +118,6 @@ class Material {
      */
     cull = CULLFACE_BACK;
 
-    // TODO: When setDepthTest is implemented, a webgpu only workaround should be removed in drawQuadWithShader function.
-
-    /**
-     * If true, fragments generated by the shader of this material are only written to the current
-     * render target if they pass the depth test. If false, fragments generated by the shader of
-     * this material are written to the current render target regardless of what is in the depth
-     * buffer. Defaults to true.
-     *
-     * @type {boolean}
-     */
-    depthTest = true;
-
-    /**
-     * Controls how the depth of new fragments is compared against the current depth contained in
-     * the depth buffer. Can be:
-     *
-     * - {@link FUNC_NEVER}: don't draw
-     * - {@link FUNC_LESS}: draw if new depth < depth buffer
-     * - {@link FUNC_EQUAL}: draw if new depth == depth buffer
-     * - {@link FUNC_LESSEQUAL}: draw if new depth <= depth buffer
-     * - {@link FUNC_GREATER}: draw if new depth > depth buffer
-     * - {@link FUNC_NOTEQUAL}: draw if new depth != depth buffer
-     * - {@link FUNC_GREATEREQUAL}: draw if new depth >= depth buffer
-     * - {@link FUNC_ALWAYS}: always draw
-     *
-     * Defaults to {@link FUNC_LESSEQUAL}.
-     *
-     * @type {number}
-     */
-    depthFunc = FUNC_LESSEQUAL;
-
-    /**
-     * If true, fragments generated by the shader of this material write a depth value to the depth
-     * buffer of the currently active render target. If false, no depth value is written. Defaults
-     * to true.
-     *
-     * @type {boolean}
-     */
-    depthWrite = true;
-
     /**
      * Stencil parameters for front faces (default is null).
      *
@@ -298,7 +261,6 @@ class Material {
         return this._blendState;
     }
 
-
     /**
      * Controls how fragment shader outputs are blended when being written to the currently active
      * render target. Can be:
@@ -359,6 +321,76 @@ class Material {
         return BLEND_NORMAL;
     }
 
+    /**
+     * Sets the depth state. Note that this can also be done by using {@link Material#depthTest},
+     * {@link Material#depthFunc} and {@link Material#depthWrite}.
+     *
+     * @type { DepthState }
+     */
+    set depthState(value) {
+        this._depthState.copy(value);
+    }
+
+    get depthState() {
+        return this._depthState;
+    }
+
+    /**
+     * If true, fragments generated by the shader of this material are only written to the current
+     * render target if they pass the depth test. If false, fragments generated by the shader of
+     * this material are written to the current render target regardless of what is in the depth
+     * buffer. Defaults to true.
+     *
+     * @type {boolean}
+     */
+    set depthTest(value) {
+        this._depthState.test = value;
+    }
+
+    get depthTest() {
+        return this._depthState.test;
+    }
+
+    /**
+     * Controls how the depth of new fragments is compared against the current depth contained in
+     * the depth buffer. Can be:
+     *
+     * - {@link FUNC_NEVER}: don't draw
+     * - {@link FUNC_LESS}: draw if new depth < depth buffer
+     * - {@link FUNC_EQUAL}: draw if new depth == depth buffer
+     * - {@link FUNC_LESSEQUAL}: draw if new depth <= depth buffer
+     * - {@link FUNC_GREATER}: draw if new depth > depth buffer
+     * - {@link FUNC_NOTEQUAL}: draw if new depth != depth buffer
+     * - {@link FUNC_GREATEREQUAL}: draw if new depth >= depth buffer
+     * - {@link FUNC_ALWAYS}: always draw
+     *
+     * Defaults to {@link FUNC_LESSEQUAL}.
+     *
+     * @type {number}
+     */
+    set depthFunc(value) {
+        this._depthState.func = value;
+    }
+
+    get depthFunc() {
+        return this._depthState.func;
+    }
+
+    /**
+     * If true, fragments generated by the shader of this material write a depth value to the depth
+     * buffer of the currently active render target. If false, no depth value is written. Defaults
+     * to true.
+     *
+     * @type {boolean}
+     */
+    set depthWrite(value) {
+        this._depthState.write = value;
+    }
+
+    get depthWrite() {
+        return this._depthState.write;
+    }
+
     /**
      * Copy a material.
      *
@@ -374,12 +406,10 @@ class Material {
         this.alphaToCoverage = source.alphaToCoverage;
 
         this._blendState.copy(source._blendState);
+        this._depthState.copy(source._depthState);
 
         this.cull = source.cull;
 
-        this.depthTest = source.depthTest;
-        this.depthFunc = source.depthFunc;
-        this.depthWrite = source.depthWrite;
         this.depthBias = source.depthBias;
         this.slopeDepthBias = source.slopeDepthBias;
         if (source.stencilFront) this.stencilFront = source.stencilFront.clone();
diff --git a/src/scene/particle-system/gpu-updater.js b/src/scene/particle-system/gpu-updater.js
index f7ddfc7ef..3f430d4bf 100644
--- a/src/scene/particle-system/gpu-updater.js
+++ b/src/scene/particle-system/gpu-updater.js
@@ -6,6 +6,7 @@ import { Vec3 } from '../../core/math/vec3.js';
 import { CULLFACE_NONE } from '../../platform/graphics/constants.js';
 import { DebugGraphics } from '../../platform/graphics/debug-graphics.js';
 import { BlendState } from '../../platform/graphics/blend-state.js';
+import { DepthState } from '../../platform/graphics/depth-state.js';
 
 import { drawQuadWithShader } from '../graphics/quad-render-utils.js';
 
@@ -92,9 +93,8 @@ class ParticleGPUUpdater {
         const emitter = this._emitter;
 
         device.setBlendState(BlendState.DEFAULT);
+        device.setDepthState(DepthState.NODEPTH);
         device.setCullMode(CULLFACE_NONE);
-        device.setDepthTest(false);
-        device.setDepthWrite(false);
 
         this.randomize();
 
@@ -184,9 +184,6 @@ class ParticleGPUUpdater {
 
         emitter.swapTex = !emitter.swapTex;
 
-        device.setDepthTest(true);
-        device.setDepthWrite(true);
-
         emitter.prevWorldBoundsSize.copy(emitter.worldBoundsSize);
         emitter.prevWorldBoundsCenter.copy(emitter.worldBounds.center);
         if (emitter.pack8)
diff --git a/src/scene/renderer/forward-renderer.js b/src/scene/renderer/forward-renderer.js
index 1b92bb8cb..704007caf 100644
--- a/src/scene/renderer/forward-renderer.js
+++ b/src/scene/renderer/forward-renderer.js
@@ -588,16 +588,7 @@ class ForwardRenderer extends Renderer {
                     this.alphaTestId.setValue(material.alphaTest);
 
                     device.setBlendState(material.blendState);
-                    device.setDepthWrite(material.depthWrite);
-
-                    // this fixes the case where the user wishes to turn off depth testing but wants to write depth
-                    if (material.depthWrite && !material.depthTest) {
-                        device.setDepthFunc(FUNC_ALWAYS);
-                        device.setDepthTest(true);
-                    } else {
-                        device.setDepthFunc(material.depthFunc);
-                        device.setDepthTest(material.depthTest);
-                    }
+                    device.setDepthState(material.depthState);
 
                     device.setAlphaToCoverage(material.alphaToCoverage);
 
diff --git a/src/scene/renderer/shadow-renderer.js b/src/scene/renderer/shadow-renderer.js
index c0c9267ae..213397c91 100644
--- a/src/scene/renderer/shadow-renderer.js
+++ b/src/scene/renderer/shadow-renderer.js
@@ -5,7 +5,7 @@ import { Mat4 } from '../../core/math/mat4.js';
 import { Vec3 } from '../../core/math/vec3.js';
 import { Vec4 } from '../../core/math/vec4.js';
 
-import { FUNC_LESSEQUAL, SHADERSTAGE_FRAGMENT, SHADERSTAGE_VERTEX, UNIFORMTYPE_MAT4, UNIFORM_BUFFER_DEFAULT_SLOT_NAME } from '../../platform/graphics/constants.js';
+import { SHADERSTAGE_FRAGMENT, SHADERSTAGE_VERTEX, UNIFORMTYPE_MAT4, UNIFORM_BUFFER_DEFAULT_SLOT_NAME } from '../../platform/graphics/constants.js';
 import { DebugGraphics } from '../../platform/graphics/debug-graphics.js';
 import { drawQuadWithShader } from '../graphics/quad-render-utils.js';
 
@@ -24,6 +24,7 @@ import { LightCamera } from './light-camera.js';
 import { UniformBufferFormat, UniformFormat } from '../../platform/graphics/uniform-buffer-format.js';
 import { BindBufferFormat, BindGroupFormat } from '../../platform/graphics/bind-group-format.js';
 import { BlendState } from '../../platform/graphics/blend-state.js';
+import { DepthState } from '../../platform/graphics/depth-state.js';
 
 function gauss(x, sigma) {
     return Math.exp(-(x * x) / (2.0 * sigma * sigma));
@@ -198,15 +199,12 @@ class ShadowRenderer {
         }
 
         // Set standard shadowmap states
-        device.setDepthWrite(true);
-        device.setDepthTest(true);
-        device.setDepthFunc(FUNC_LESSEQUAL);
-
         const useShadowSampler = isClustered ?
             light._isPcf && device.webgl2 :     // both spot and omni light are using shadow sampler on webgl2 when clustered
             light._isPcf && device.webgl2 && light._type !== LIGHTTYPE_OMNI;    // for non-clustered, point light is using depth encoded in color buffer (should change to shadow sampler)
 
         device.setBlendState(useShadowSampler ? this.blendStateNoWrite : this.blendStateWrite);
+        device.setDepthState(DepthState.DEFAULT);
     }
 
     restoreRenderState(device) {


Test patch:
diff --git a/test/platform/graphics/depth-state.test.mjs b/test/platform/graphics/depth-state.test.mjs
new file mode 100644
index 000000000..62a99ae76
--- /dev/null
+++ b/test/platform/graphics/depth-state.test.mjs
@@ -0,0 +1,44 @@
+import { DepthState } from '../../../src/platform/graphics/depth-state.js';
+import {
+    FUNC_LESSEQUAL, FUNC_ALWAYS, FUNC_NOTEQUAL
+} from '../../../src/platform/graphics/constants.js';
+
+import { expect } from 'chai';
+
+describe('DepthState', function () {
+
+    describe('#constructor', function () {
+
+        it('empty', function () {
+            const ds = new DepthState();
+            expect(ds.func).to.equal(FUNC_LESSEQUAL);
+            expect(ds.write).to.equal(true);
+        });
+
+        it('full parameters', function () {
+            const ds = new DepthState(FUNC_NOTEQUAL, false);
+            expect(ds.func).to.equal(FUNC_NOTEQUAL);
+            expect(ds.write).to.equal(false);
+        });
+
+    });
+
+    describe('#test property', function () {
+
+        it('test enabled', function () {
+            const ds = new DepthState();
+            ds.test = true;
+            expect(ds.func).to.equal(FUNC_LESSEQUAL);
+            expect(ds.test).to.equal(true);
+        });
+
+        it('test disabled', function () {
+            const ds = new DepthState();
+            ds.test = false;
+            expect(ds.func).to.equal(FUNC_ALWAYS);
+            expect(ds.test).to.equal(false);
+        });
+
+    });
+
+});