Repository: playcanvas/engine
Pull request #5083 (2023-02-21 12:21:49)

[BREAKING] Implementation of BlendState
Related to https://github.com/playcanvas/engine/issues/2254

## Overview

A new BlendState class is implemented, which encapsulates a blending state of rendering pipeline. It handles alpha blending and enabling of per-channel writes. Internally, a bit packing is used to store blend state in a single number (per render target, to allow multiple render targets in the future). This makes blend state setup more costly, but per frame rendering is faster as just a single number needs to be compared to see if blend state needs to be changed. Additionally, it makes it fast to generate a key which is used for render pipeline look up on WebGPU.

### Breaking changes
- although all public functionality has been deprecated and preserved, if some private blending related functionality has been used, it's possible the code needs to be updated to use public functionality.

- To align WebGL and WebGPU:
```
- BLENDMODE_CONSTANT_COLOR and BLENDMODE_CONSTANT_ALPHA has been merged
   to a single BLENDMODE_CONSTANT constant
- BLENDMODE_ONE_MINUS_CONSTANT_COLOR and BLENDMODE_ONE_MINUS_CONSTANT_ALPHA
   has been merged to a single BLENDMODE_ONE_MINUS_CONSTANT constant
- Note that the _COLOR constant is internally used for the color blending, and _ALPHA constant is used
   for alpha blending, so no functionality has been lost, only simplified. This is not exposed on the
   material, and was only useable using private APIs on the material, so the impact is expected to be small.
```
- drawQuadWithShader function signature has changed, and last 'useBlend' parameter has been removed. If value is provided, a warning message is printed out. This parameter was confusing, as it was expected to set all other blending states directly on device apart from this one, which was internally set by the drawQuadWithShader. Now all render state is expected to be set directly on device. Code like this:
```
if (blending) {
    device.setBlendFunction(BLENDMODE_ONE, BLENDMODE_ONE);
    device.setBlendEquation(BLENDEQUATION_ADD);
    drawQuadWithShader(..., blending);
}
```
is now
```
    device.setBlendState(<created state with blend mode fully set up>);
    drawQuadWithShader(...);
}
```

- BLEND_SUBTRACTIVE mode is now supported. The implementation was missing before, so this was behaving like BLEND_NORMAL, but now will function as expected.

### New public API (still breaking change, as old APIs are deprecated)

- BlendState.constructor(blend = false, colorOp = BLENDEQUATION_ADD, colorSrcFactor = BLENDMODE_ONE, colorDstFactor = BLENDMODE_ZERO,
        alphaOp, alphaSrcFactor, alphaDstFactor,
        redWrite = true, greenWrite = true, blueWrite = true, alphaWrite = true)
- BlendState.blend - toggle blending
- BlendState.equals - compares two blend states
- BlendState.DEFAULT - a static access to a blend state with no blending and writing to all channels

- GraphicsDevice.setBlendState(blendState)
	this replaces these properties:
```
blending
blendSrc
blendDst
blendSrcAlpha
blendDstAlpha
separateAlphaBlend
blendEquation
blendAlphaEquation
separateAlphaEquation
writeRed
writeGreen
writeBlue
writeAlpha
```
- Material.blendState property
	this replaces these properties:
```
blend
blendSrc
blendDst
blendEquation
separateAlphaBlend
blendSrcAlpha
blendDstAlpha
blendAlphaEquation
redWrite
greenWrite
blueWrite
alphaWrite
```

### Other changes
- internally, engine has been updated to use blend state for all blending operations, and in some cases, a blend state is set that wasn't before, to enforce correct state to be set up, to avoid leftover state issue
- on WebGl platform, the implementation has been changed to always use separate blending for color & alpha channels, to match WebGPU - this is the same number of API calls anyways, so there is no advantage to use both modes.

### Testing
- tested engine examples against WebGl1 & 2
- tested Editor

### JSDocs for the new class
<img width="761" alt="Screenshot 2023-02-21 at 12 21 04" src="https://user-images.githubusercontent.com/59932779/220344108-db702ee2-f4e5-48f0-b2d7-987f680209cd.png">
<img width="754" alt="Screenshot 2023-02-21 at 12 21 16" src="https://user-images.githubusercontent.com/59932779/220344135-30502978-6a91-4058-83c3-af366498010f.png">



Patch:
diff --git a/extras/mini-stats/render2d.js b/extras/mini-stats/render2d.js
index 0eaba2e98..701429bbf 100644
--- a/extras/mini-stats/render2d.js
+++ b/extras/mini-stats/render2d.js
@@ -14,7 +14,8 @@ import {
     shaderChunks,
     IndexBuffer,
     VertexBuffer,
-    VertexFormat
+    VertexFormat,
+    BlendState
 } from 'playcanvas';
 
 // render 2d textured quads
@@ -120,6 +121,9 @@ class Render2d {
 
         this.screenTextureSizeId = device.scope.resolve('screenAndTextureSize');
         this.screenTextureSize = new Float32Array(4);
+
+        this.blendState = new BlendState(true, BLENDEQUATION_ADD, BLENDMODE_SRC_ALPHA, BLENDMODE_ONE_MINUS_SRC_ALPHA,
+                                         BLENDEQUATION_ADD, BLENDMODE_ONE, BLENDMODE_ONE);
     }
 
     quad(texture, x, y, w, h, u, v, uw, uh, enabled) {
@@ -174,12 +178,8 @@ class Render2d {
         device.setDepthTest(false);
         device.setDepthWrite(false);
         device.setCullMode(CULLFACE_NONE);
-        device.setBlending(true);
-        device.setBlendFunctionSeparate(BLENDMODE_SRC_ALPHA,
-                                        BLENDMODE_ONE_MINUS_SRC_ALPHA,
-                                        BLENDMODE_ONE,
-                                        BLENDMODE_ONE);
-        device.setBlendEquationSeparate(BLENDEQUATION_ADD, BLENDEQUATION_ADD);
+        device.setBlendState(this.blendState);
+
         device.setVertexBuffer(buffer, 0);
         device.setIndexBuffer(this.indexBuffer);
         device.setShader(this.shader);
diff --git a/scripts/posteffects/posteffect-bloom.js b/scripts/posteffects/posteffect-bloom.js
index 3d22ccbf1..74b0586c7 100644
--- a/scripts/posteffects/posteffect-bloom.js
+++ b/scripts/posteffects/posteffect-bloom.js
@@ -295,6 +295,6 @@ Bloom.prototype.initialize = function () {
 
     this.on('destroy', function () {
         queue.removeEffect(this.effect);
-        this._destroy();
+        this.effect._destroy();
     });
 };
diff --git a/src/deprecated/deprecated.js b/src/deprecated/deprecated.js
index ba4a99d8d..d4c8ec658 100644
--- a/src/deprecated/deprecated.js
+++ b/src/deprecated/deprecated.js
@@ -20,6 +20,7 @@ import {
     BLENDMODE_ZERO, BLENDMODE_ONE, BLENDMODE_SRC_COLOR, BLENDMODE_ONE_MINUS_SRC_COLOR,
     BLENDMODE_DST_COLOR, BLENDMODE_ONE_MINUS_DST_COLOR, BLENDMODE_SRC_ALPHA, BLENDMODE_SRC_ALPHA_SATURATE,
     BLENDMODE_ONE_MINUS_SRC_ALPHA, BLENDMODE_DST_ALPHA, BLENDMODE_ONE_MINUS_DST_ALPHA,
+    BLENDMODE_CONSTANT, BLENDMODE_ONE_MINUS_CONSTANT,
     BUFFER_STATIC, BUFFER_DYNAMIC, BUFFER_STREAM,
     CULLFACE_NONE, CULLFACE_BACK, CULLFACE_FRONT, CULLFACE_FRONTANDBACK,
     FILTER_NEAREST, FILTER_LINEAR, FILTER_NEAREST_MIPMAP_NEAREST, FILTER_NEAREST_MIPMAP_LINEAR,
@@ -53,6 +54,7 @@ import { VertexFormat } from '../platform/graphics/vertex-format.js';
 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 { 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';
@@ -346,6 +348,11 @@ export const PIXELFORMAT_R4_G4_B4_A4 = PIXELFORMAT_RGBA4;
 export const PIXELFORMAT_R8_G8_B8 = PIXELFORMAT_RGB8;
 export const PIXELFORMAT_R8_G8_B8_A8 = PIXELFORMAT_RGBA8;
 
+export const BLENDMODE_CONSTANT_COLOR = BLENDMODE_CONSTANT;
+export const BLENDMODE_ONE_MINUS_CONSTANT_COLOR = BLENDMODE_ONE_MINUS_CONSTANT;
+export const BLENDMODE_CONSTANT_ALPHA = BLENDMODE_CONSTANT;
+export const BLENDMODE_ONE_MINUS_CONSTANT_ALPHA = BLENDMODE_ONE_MINUS_CONSTANT;
+
 export function UnsupportedBrowserError(message) {
     this.name = 'UnsupportedBrowserError';
     this.message = (message || '');
@@ -572,6 +579,63 @@ GraphicsDevice.prototype.removeShaderFromCache = function (shader) {
     getProgramLibrary(this).removeFromCache(shader);
 };
 
+const _tempBlendState = new BlendState();
+
+GraphicsDevice.prototype.setBlendFunction = function (blendSrc, blendDst) {
+    Debug.deprecated(`pc.GraphicsDevice#setBlendFunction is deprecated, use pc.GraphicsDevice.setBlendState instead.`);
+    const currentBlendState = this.blendState;
+    _tempBlendState.copy(currentBlendState);
+    _tempBlendState.setColorBlend(currentBlendState.colorOp, blendSrc, blendDst);
+    _tempBlendState.setAlphaBlend(currentBlendState.alphaOp, blendSrc, blendDst);
+    this.setBlendState(_tempBlendState);
+};
+
+GraphicsDevice.prototype.setBlendFunctionSeparate = function (blendSrc, blendDst, blendSrcAlpha, blendDstAlpha) {
+    Debug.deprecated(`pc.GraphicsDevice#setBlendFunctionSeparate is deprecated, use pc.GraphicsDevice.setBlendState instead.`);
+    const currentBlendState = this.blendState;
+    _tempBlendState.copy(currentBlendState);
+    _tempBlendState.setColorBlend(currentBlendState.colorOp, blendSrc, blendDst);
+    _tempBlendState.setAlphaBlend(currentBlendState.alphaOp, blendSrcAlpha, blendDstAlpha);
+    this.setBlendState(_tempBlendState);
+};
+
+GraphicsDevice.prototype.setBlendEquation = function (blendEquation) {
+    Debug.deprecated(`pc.GraphicsDevice#setBlendEquation is deprecated, use pc.GraphicsDevice.setBlendState instead.`);
+    const currentBlendState = this.blendState;
+    _tempBlendState.copy(currentBlendState);
+    _tempBlendState.setColorBlend(blendEquation, currentBlendState.colorSrcFactor, currentBlendState.colorDstFactor);
+    _tempBlendState.setAlphaBlend(blendEquation, currentBlendState.alphaSrcFactor, currentBlendState.alphaDstFactor);
+    this.setBlendState(_tempBlendState);
+};
+
+GraphicsDevice.prototype.setBlendEquationSeparate = function (blendEquation, blendAlphaEquation) {
+    Debug.deprecated(`pc.GraphicsDevice#setBlendEquationSeparate is deprecated, use pc.GraphicsDevice.setBlendState instead.`);
+    const currentBlendState = this.blendState;
+    _tempBlendState.copy(currentBlendState);
+    _tempBlendState.setColorBlend(blendEquation, currentBlendState.colorSrcFactor, currentBlendState.colorDstFactor);
+    _tempBlendState.setAlphaBlend(blendAlphaEquation, currentBlendState.alphaSrcFactor, currentBlendState.alphaDstFactor);
+    this.setBlendState(_tempBlendState);
+};
+
+GraphicsDevice.prototype.setColorWrite = function (redWrite, greenWrite, blueWrite, alphaWrite) {
+    Debug.deprecated(`pc.GraphicsDevice#setColorWrite is deprecated, use pc.GraphicsDevice.setBlendState instead.`);
+    const currentBlendState = this.blendState;
+    _tempBlendState.copy(currentBlendState);
+    _tempBlendState.setColorWrite(redWrite, greenWrite, blueWrite, alphaWrite);
+    this.setBlendState(_tempBlendState);
+};
+
+GraphicsDevice.prototype.getBlending = function () {
+    return this.blendState.blend;
+};
+
+GraphicsDevice.prototype.setBlending = function (blending) {
+    Debug.deprecated(`pc.GraphicsDevice#setBlending is deprecated, use pc.GraphicsDevice.setBlendState instead.`);
+    _tempBlendState.copy(this.blendState);
+    _tempBlendState.blend = blending;
+    this.setBlendState(_tempBlendState);
+};
+
 // SCENE
 
 export const PhongMaterial = StandardMaterial;
@@ -831,6 +895,47 @@ Material.prototype.setShader = function (shader) {
     this.shader = shader;
 };
 
+// Note: this is used by the Editor
+Object.defineProperty(Material.prototype, 'blend', {
+    set: function (value) {
+        Debug.deprecated(`pc.Material#blend is deprecated, use pc.Material.blendState.`);
+        this.blendState.blend = value;
+    },
+    get: function () {
+        return this.blendState.blend;
+    }
+});
+
+// Note: this is used by the Editor
+Object.defineProperty(Material.prototype, 'blendSrc', {
+    set: function (value) {
+        Debug.deprecated(`pc.Material#blendSrc is deprecated, use pc.Material.blendState.`);
+        const currentBlendState = this.blendState;
+        _tempBlendState.copy(currentBlendState);
+        _tempBlendState.setColorBlend(currentBlendState.colorOp, value, currentBlendState.colorDstFactor);
+        _tempBlendState.setAlphaBlend(currentBlendState.alphaOp, value, currentBlendState.alphaDstFactor);
+        this.blendState = _tempBlendState;
+    },
+    get: function () {
+        return this.blendState.colorSrcFactor;
+    }
+});
+
+// Note: this is used by the Editor
+Object.defineProperty(Material.prototype, 'blendDst', {
+    set: function (value) {
+        Debug.deprecated(`pc.Material#blendDst is deprecated, use pc.Material.blendState.`);
+        const currentBlendState = this.blendState;
+        _tempBlendState.copy(currentBlendState);
+        _tempBlendState.setColorBlend(currentBlendState.colorOp, currentBlendState.colorSrcFactor, value);
+        _tempBlendState.setAlphaBlend(currentBlendState.alphaOp, currentBlendState.alphaSrcFactor, value);
+        this.blendState = _tempBlendState;
+    },
+    get: function () {
+        return this.blendState.colorDstFactor;
+    }
+});
+
 function _defineAlias(newName, oldName) {
     Object.defineProperty(StandardMaterial.prototype, oldName, {
         get: function () {
diff --git a/src/framework/app-base.js b/src/framework/app-base.js
index 1b89c418d..a1945b16e 100644
--- a/src/framework/app-base.js
+++ b/src/framework/app-base.js
@@ -1104,6 +1104,12 @@ class AppBase extends EventHandler {
      * app.start();
      */
     start() {
+
+        Debug.call(() => {
+            Debug.assert(!this._alreadyStarted, "The application can be started only one time.");
+            this._alreadyStarted = true;
+        });
+
         this.frame = 0;
 
         this.fire("start", {
diff --git a/src/framework/components/particle-system/component.js b/src/framework/components/particle-system/component.js
index 1f8da8c96..964ae6f54 100644
--- a/src/framework/components/particle-system/component.js
+++ b/src/framework/components/particle-system/component.js
@@ -196,7 +196,7 @@ let depthLayer;
  *
  * @property {Mesh} mesh Triangular mesh to be used as a particle. Only first vertex/index buffer
  * is used. Vertex buffer must contain local position at first 3 floats of each vertex.
- * @property {number} blend Controls how particles are blended when being written to the currently
+ * @property {number} blendType Controls how particles are blended when being written to the currently
  * active render target. Can be:
  *
  * - {@link BLEND_SUBTRACTIVE}: Subtract the color of the source fragment from the destination
diff --git a/src/framework/components/sound/component.js b/src/framework/components/sound/component.js
index c846f26b9..2cbbb47f5 100644
--- a/src/framework/components/sound/component.js
+++ b/src/framework/components/sound/component.js
@@ -414,6 +414,68 @@ class SoundComponent extends Component {
         return this._slots[name];
     }
 
+    /**
+     * Return a property from the slot with the specified name.
+     *
+     * @param {string} name - The name of the {@link SoundSlot} to look for.
+     * @param {string} property - The name of the property to look for.
+     * @returns {*} The value from the looked property inside the slot with specified name. May be undefined if slot does not exist.
+     * @private
+     */
+    _getSlotProperty(name, property) {
+        if (!this.enabled || !this.entity.enabled) {
+            return undefined;
+        }
+
+        const slot = this._slots[name];
+        if (!slot) {
+            Debug.warn(`Trying to get ${property} from sound slot with name ${name} which does not exist`);
+            return undefined;
+        }
+
+        return slot[property];
+    }
+
+    /**
+     * Returns true if the slot with the specified name is currently playing.
+     *
+     * @param {string} name - The name of the {@link SoundSlot} to look for.
+     * @returns {boolean} True if the slot with the specified name exists and is currently playing.
+     */
+    isPlaying(name) {
+        return this._getSlotProperty(name, 'isPlaying') || false;
+    }
+
+    /**
+     * Returns true if the asset of the slot with the specified name is loaded..
+     *
+     * @param {string} name - The name of the {@link SoundSlot} to look for.
+     * @returns {boolean} True if the slot with the specified name exists and its asset is loaded.
+     */
+    isLoaded(name) {
+        return this._getSlotProperty(name, 'isLoaded') || false;
+    }
+
+    /**
+     * Returns true if the slot with the specified name is currently paused.
+     *
+     * @param {string} name - The name of the {@link SoundSlot} to look for.
+     * @returns {boolean} True if the slot with the specified name exists and is currently paused.
+     */
+    isPaused(name) {
+        return this._getSlotProperty(name, 'isPaused') || false;
+    }
+
+    /**
+     * Returns true if the slot with the specified name is currently stopped.
+     *
+     * @param {string} name - The name of the {@link SoundSlot} to look for.
+     * @returns {boolean} True if the slot with the specified name exists and is currently stopped.
+     */
+    isStopped(name) {
+        return this._getSlotProperty(name, 'isStopped') || false;
+    }
+
     /**
      * Begins playing the sound slot with the specified name. The slot will restart playing if it
      * is already playing unless the overlap field is true in which case a new sound will be
diff --git a/src/framework/graphics/picker.js b/src/framework/graphics/picker.js
index 0387a8141..123c7424a 100644
--- a/src/framework/graphics/picker.js
+++ b/src/framework/graphics/picker.js
@@ -15,6 +15,7 @@ import { LayerComposition } from '../../scene/composition/layer-composition.js';
 import { getApplication } from '../globals.js';
 import { Entity } from '../entity.js';
 import { Debug } from '../../core/debug.js';
+import { BlendState } from '../../platform/graphics/blend-state.js';
 
 const tempSet = new Set();
 
@@ -32,6 +33,9 @@ const clearDepthOptions = {
  * (read-only).
  */
 class Picker {
+    // internal render target
+    renderTarget = null;
+
     /**
      * Create a new Picker instance.
      *
@@ -62,9 +66,6 @@ class Picker {
         this.layerComp = null;
         this.initLayerComposition();
 
-        // internal render target
-        this._renderTarget = null;
-
         // clear command user to simulate layer clearing, required due to storing meshes from multiple layers on a singe layer
         const device = this.device;
         this.clearDepthCommand = new Command(0, 0, function () {
@@ -179,10 +180,10 @@ class Picker {
         // unset it from the camera
         this.cameraEntity.camera.renderTarget = null;
 
-        if (this._renderTarget) {
-            this._renderTarget.destroyTextureBuffers();
-            this._renderTarget.destroy();
-            this._renderTarget = null;
+        if (this.renderTarget) {
+            this.renderTarget.destroyTextureBuffers();
+            this.renderTarget.destroy();
+            this.renderTarget = null;
         }
     }
 
@@ -208,7 +209,7 @@ class Picker {
                 self.pickColor[1] = ((index >> 8) & 0xff) / 255;
                 self.pickColor[2] = (index & 0xff) / 255;
                 pickColorId.setValue(self.pickColor);
-                device.setBlending(false);
+                device.setBlendState(BlendState.DEFAULT);
 
                 // keep the index -> meshInstance index mapping
                 self.mapping[index] = meshInstance;
diff --git a/src/framework/lightmapper/lightmapper.js b/src/framework/lightmapper/lightmapper.js
index cd4a5bebb..e3337079e 100644
--- a/src/framework/lightmapper/lightmapper.js
+++ b/src/framework/lightmapper/lightmapper.js
@@ -44,6 +44,7 @@ import { BakeLightAmbient } from './bake-light-ambient.js';
 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';
 
 const MAX_LIGHTMAP_SIZE = 2048;
 
@@ -874,6 +875,8 @@ class Lightmapper {
             this.lightmapFilters.prepareDenoise(this.scene.lightmapFilterRange, this.scene.lightmapFilterSmoothness);
         }
 
+        device.setBlendState(BlendState.DEFAULT);
+
         for (let node = 0; node < bakeNodes.length; node++) {
             const bakeNode = bakeNodes[node];
 
diff --git a/src/index.js b/src/index.js
index 113756ae5..73d59bc7a 100644
--- a/src/index.js
+++ b/src/index.js
@@ -57,6 +57,7 @@ export * from './platform/audio/constants.js';
 // PLATFORM / GRAPHICS
 export * from './platform/graphics/constants.js';
 export { createGraphicsDevice } from './platform/graphics/graphics-device-create.js';
+export { BlendState } from './platform/graphics/blend-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
new file mode 100644
index 000000000..b141561b1
--- /dev/null
+++ b/src/platform/graphics/blend-state.js
@@ -0,0 +1,232 @@
+import { BitPacking } from "../../core/math/bit-packing.js";
+import { BLENDEQUATION_ADD, BLENDMODE_ONE, BLENDMODE_ZERO } from '../../platform/graphics/constants.js';
+
+// masks (to only keep relevant bits)
+const opMask = 0b111;
+const factorMask = 0b1111;
+
+// shifts values to where individual parts are stored
+const colorOpShift = 0;             // 00 - 02 (3bits)
+const colorSrcFactorShift = 3;      // 03 - 06 (4bits)
+const colorDstFactorShift = 7;      // 07 - 10 (4bits)
+const alphaOpShift = 11;            // 11 - 13 (3bits)
+const alphaSrcFactorShift = 14;     // 14 - 17 (4bits)
+const alphaDstFactorShift = 18;     // 18 - 21 (4bits)
+const redWriteShift = 22;           // 22 (1 bit)
+const greenWriteShift = 23;         // 23 (1 bit)
+const blueWriteShift = 24;          // 24 (1 bit)
+const alphaWriteShift = 25;         // 25 (1 bit)
+const blendShift = 26;              // 26 (1 bit)
+
+// combined values access
+const allWriteMasks = 0b1111;
+const allWriteShift = redWriteShift;
+/**
+ * BlendState is a descriptor that defines how output of fragment shader is written and blended
+ * into render target. A blend state can be set on a material using {@link Material#blendState},
+ * or in some cases on the graphics device using {@link GraphicsDevice#setBlendState}.
+ *
+ * For the best performance, do not modify blend state after it has been created, but create
+ * multiple blend states and assign them to the material or graphics device as needed.
+ */
+class BlendState {
+    /**
+     * Number bits of which represent the blend state for render target 0.
+     *
+     * @private
+     */
+    target0 = 0;
+
+    /**
+     * Create a new BlendState instance.
+     *
+     * All factor parameters can take the following values:
+     *
+     * - {@link BLENDMODE_ZERO}
+     * - {@link BLENDMODE_ONE}
+     * - {@link BLENDMODE_SRC_COLOR}
+     * - {@link BLENDMODE_ONE_MINUS_SRC_COLOR}
+     * - {@link BLENDMODE_DST_COLOR}
+     * - {@link BLENDMODE_ONE_MINUS_DST_COLOR}
+     * - {@link BLENDMODE_SRC_ALPHA}
+     * - {@link BLENDMODE_SRC_ALPHA_SATURATE}
+     * - {@link BLENDMODE_ONE_MINUS_SRC_ALPHA}
+     * - {@link BLENDMODE_DST_ALPHA}
+     * - {@link BLENDMODE_ONE_MINUS_DST_ALPHA}
+     * - {@link BLENDMODE_CONSTANT}
+     * - {@link BLENDMODE_ONE_MINUS_CONSTANT}
+     *
+     * All op parameters can take the following values:
+     *
+     * - {@link BLENDEQUATION_ADD}
+     * - {@link BLENDEQUATION_SUBTRACT}
+     * - {@link BLENDEQUATION_REVERSE_SUBTRACT}
+     * - {@link BLENDEQUATION_MIN}
+     * - {@link BLENDEQUATION_MAX}
+     *
+     * Note that MIN and MAX operations on WebGL platform require either EXT_blend_minmax or WebGL2
+     * to work (check device.extBlendMinmax).
+     *
+     * @param {boolean} blend - Enables or disables blending. Defaults to false.
+     * @param {number} colorOp - Configures color blending operation. Defaults to
+     * {@link BLENDEQUATION_ADD}.
+     * @param {number} colorSrcFactor - Configures source color blending factor. Defaults to
+     * {@link BLENDMODE_ONE}.
+     * @param {number} colorDstFactor - Configures destination color blending factor. Defaults to
+     * {@link BLENDMODE_ZERO}.
+     * @param {number} alphaOp - Configures alpha blending operation. Defaults to
+     * {@link BLENDEQUATION_ADD}.
+     * @param {number} alphaSrcFactor - Configures source alpha blending factor. Defaults to
+     * {@link BLENDMODE_ONE}.
+     * @param {number} alphaDstFactor - Configures destination alpha blending factor. Defaults to
+     * {@link BLENDMODE_ZERO}.
+     * @param {boolean} redWrite - True to enable writing of the red channel and false otherwise.
+     * Defaults to true.
+     * @param {boolean} greenWrite - True to enable writing of the green channel and false
+     * otherwise. Defaults to true.
+     * @param {boolean} blueWrite - True to enable writing of the blue channel and false otherwise.
+     * Defaults to true.
+     * @param {boolean} alphaWrite - True to enable writing of the alpha channel and false
+     * otherwise. Defaults to true.
+     */
+    constructor(blend = false, colorOp = BLENDEQUATION_ADD, colorSrcFactor = BLENDMODE_ONE, colorDstFactor = BLENDMODE_ZERO,
+        alphaOp, alphaSrcFactor, alphaDstFactor,
+        redWrite = true, greenWrite = true, blueWrite = true, alphaWrite = true) {
+        this.setColorBlend(colorOp, colorSrcFactor, colorDstFactor);
+        this.setAlphaBlend(alphaOp ?? colorOp, alphaSrcFactor ?? colorSrcFactor, alphaDstFactor ?? colorDstFactor);
+        this.setColorWrite(redWrite, greenWrite, blueWrite, alphaWrite);
+        this.blend = blend;
+    }
+
+    /**
+     * Enables or disables blending.
+     *
+     * @type {boolean}
+     */
+    set blend(value) {
+        this.target0 = BitPacking.set(this.target0, value ? 1 : 0, blendShift);
+    }
+
+    get blend() {
+        return BitPacking.all(this.target0, blendShift);
+    }
+
+    setColorBlend(op, srcFactor, dstFactor) {
+        this.target0 = BitPacking.set(this.target0, op, colorOpShift, opMask);
+        this.target0 = BitPacking.set(this.target0, srcFactor, colorSrcFactorShift, factorMask);
+        this.target0 = BitPacking.set(this.target0, dstFactor, colorDstFactorShift, factorMask);
+    }
+
+    setAlphaBlend(op, srcFactor, dstFactor) {
+        this.target0 = BitPacking.set(this.target0, op, alphaOpShift, opMask);
+        this.target0 = BitPacking.set(this.target0, srcFactor, alphaSrcFactorShift, factorMask);
+        this.target0 = BitPacking.set(this.target0, dstFactor, alphaDstFactorShift, factorMask);
+    }
+
+    setColorWrite(redWrite, greenWrite, blueWrite, alphaWrite) {
+        this.redWrite = redWrite;
+        this.greenWrite = greenWrite;
+        this.blueWrite = blueWrite;
+        this.alphaWrite = alphaWrite;
+    }
+
+    get colorOp() {
+        return BitPacking.get(this.target0, colorOpShift, opMask);
+    }
+
+    get colorSrcFactor() {
+        return BitPacking.get(this.target0, colorSrcFactorShift, factorMask);
+    }
+
+    get colorDstFactor() {
+        return BitPacking.get(this.target0, colorDstFactorShift, factorMask);
+    }
+
+    get alphaOp() {
+        return BitPacking.get(this.target0, alphaOpShift, opMask);
+    }
+
+    get alphaSrcFactor() {
+        return BitPacking.get(this.target0, alphaSrcFactorShift, factorMask);
+    }
+
+    get alphaDstFactor() {
+        return BitPacking.get(this.target0, alphaDstFactorShift, factorMask);
+    }
+
+    set redWrite(value) {
+        this.target0 = BitPacking.set(this.target0, value ? 1 : 0, redWriteShift);
+    }
+
+    get redWrite() {
+        return BitPacking.all(this.target0, redWriteShift);
+    }
+
+    set greenWrite(value) {
+        this.target0 = BitPacking.set(this.target0, value ? 1 : 0, greenWriteShift);
+    }
+
+    get greenWrite() {
+        return BitPacking.all(this.target0, greenWriteShift);
+    }
+
+    set blueWrite(value) {
+        this.target0 = BitPacking.set(this.target0, value ? 1 : 0, blueWriteShift);
+    }
+
+    get blueWrite() {
+        return BitPacking.all(this.target0, blueWriteShift);
+    }
+
+    set alphaWrite(value) {
+        this.target0 = BitPacking.set(this.target0, value ? 1 : 0, alphaWriteShift);
+    }
+
+    get alphaWrite() {
+        return BitPacking.all(this.target0, alphaWriteShift);
+    }
+
+    get allWrite() {
+        // return a number with all 4 bits, for fast compare
+        return BitPacking.get(this.target0, allWriteShift, allWriteMasks);
+    }
+
+    copy(rhs) {
+        this.target0 = rhs.target0;
+        return this;
+    }
+
+    /**
+     * Returns an identical copy of the specified blend state.
+     *
+     * @returns {this} The result of the cloning.
+     */
+    clone() {
+        const clone = new this.constructor();
+        return clone.copy(this);
+    }
+
+    get key() {
+        return this.target0;
+    }
+
+    /**
+     * Reports whether two BlendStates are equal.
+     *
+     * @param {BlendState} rhs - The blend state to compare to.
+     * @returns {boolean} True if the blend states are equal and false otherwise.
+     */
+    equals(rhs) {
+        return this.target0 === rhs.target0;
+    }
+
+    /**
+     * A default blend state, that is not blending and writes to all color channels.
+     *
+     * @type {BlendState}
+     * @readonly
+     */
+    static DEFAULT = Object.freeze(new BlendState());
+}
+
+export { BlendState };
diff --git a/src/platform/graphics/constants.js b/src/platform/graphics/constants.js
index e07267e41..7c041f48e 100644
--- a/src/platform/graphics/constants.js
+++ b/src/platform/graphics/constants.js
@@ -98,32 +98,18 @@ export const BLENDMODE_DST_ALPHA = 9;
 export const BLENDMODE_ONE_MINUS_DST_ALPHA = 10;
 
 /**
- * Multiplies all colors by a constant color.
+ * Multiplies all fragment components by a constant.
  *
  * @type {number}
  */
-export const BLENDMODE_CONSTANT_COLOR = 11;
+export const BLENDMODE_CONSTANT = 11;
 
 /**
- * Multiplies all colors by 1 minus a constant color.
+ * Multiplies all fragment components by 1 minus a constant.
  *
  * @type {number}
  */
-export const BLENDMODE_ONE_MINUS_CONSTANT_COLOR = 12;
-
-/**
- * Multiplies all colors by a constant alpha value.
- *
- * @type {number}
- */
-export const BLENDMODE_CONSTANT_ALPHA = 13;
-
-/**
- * Multiplies all colors by 1 minus a constant alpha value.
- *
- * @type {number}
- */
-export const BLENDMODE_ONE_MINUS_CONSTANT_ALPHA = 14;
+export const BLENDMODE_ONE_MINUS_CONSTANT = 12;
 
 /**
  * Add the results of the source and destination fragment multiplies.
diff --git a/src/platform/graphics/graphics-device.js b/src/platform/graphics/graphics-device.js
index 2f4286c0c..d1b660995 100644
--- a/src/platform/graphics/graphics-device.js
+++ b/src/platform/graphics/graphics-device.js
@@ -2,6 +2,7 @@ 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,
@@ -134,6 +135,13 @@ class GraphicsDevice extends EventHandler {
      */
     quadVertexBuffer;
 
+    /**
+     * An object representing current blend state
+     *
+     * @ignore
+     */
+    blendState = new BlendState();
+
     constructor(canvas) {
         super();
 
@@ -259,11 +267,23 @@ class GraphicsDevice extends EventHandler {
     }
 
     initializeRenderState() {
+
+        this.blendState = new BlendState();
+
         // Cached viewport and scissor dimensions
         this.vx = this.vy = this.vw = this.vh = 0;
         this.sx = this.sy = this.sw = this.sh = 0;
     }
 
+    /**
+     * Sets the specified blend state.
+     *
+     * @param {BlendState} blendState - New blend state.
+     */
+    setBlendState(blendState) {
+        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 d94361800..d8d528142 100644
--- a/src/platform/graphics/webgl/webgl-graphics-device.js
+++ b/src/platform/graphics/webgl/webgl-graphics-device.js
@@ -5,8 +5,6 @@ import { Color } from '../../../core/math/color.js';
 
 import {
     ADDRESS_CLAMP_TO_EDGE,
-    BLENDEQUATION_ADD,
-    BLENDMODE_ZERO, BLENDMODE_ONE,
     CLEARFLAG_COLOR, CLEARFLAG_DEPTH, CLEARFLAG_STENCIL,
     CULLFACE_BACK, CULLFACE_NONE,
     FILTER_NEAREST, FILTER_LINEAR, FILTER_NEAREST_MIPMAP_NEAREST, FILTER_NEAREST_MIPMAP_LINEAR,
@@ -35,6 +33,7 @@ import { WebglTexture } from './webgl-texture.js';
 import { WebglRenderTarget } from './webgl-render-target.js';
 import { ShaderUtils } from '../shader-utils.js';
 import { Shader } from '../shader.js';
+import { BlendState } from '../blend-state.js';
 
 const invalidateAttachments = [];
 
@@ -89,14 +88,10 @@ function quadWithShader(device, target, shader) {
     const oldDepthTest = device.getDepthTest();
     const oldDepthWrite = device.getDepthWrite();
     const oldCullMode = device.getCullMode();
-    const oldWR = device.writeRed;
-    const oldWG = device.writeGreen;
-    const oldWB = device.writeBlue;
-    const oldWA = device.writeAlpha;
     device.setDepthTest(false);
     device.setDepthWrite(false);
     device.setCullMode(CULLFACE_NONE);
-    device.setColorWrite(true, true, true, true);
+    device.setBlendState(BlendState.DEFAULT);
 
     device.setVertexBuffer(device.quadVertexBuffer, 0);
     device.setShader(shader);
@@ -111,7 +106,6 @@ function quadWithShader(device, target, shader) {
     device.setDepthTest(oldDepthTest);
     device.setDepthWrite(oldDepthWrite);
     device.setCullMode(oldCullMode);
-    device.setColorWrite(oldWR, oldWG, oldWB, oldWA);
 
     device.updateEnd();
 
@@ -471,7 +465,7 @@ class WebglGraphicsDevice extends GraphicsDevice {
             this.webgl2 ? gl.MAX : this.extBlendMinmax ? this.extBlendMinmax.MAX_EXT : gl.FUNC_ADD
         ];
 
-        this.glBlendFunction = [
+        this.glBlendFunctionColor = [
             gl.ZERO,
             gl.ONE,
             gl.SRC_COLOR,
@@ -484,7 +478,21 @@ class WebglGraphicsDevice extends GraphicsDevice {
             gl.DST_ALPHA,
             gl.ONE_MINUS_DST_ALPHA,
             gl.CONSTANT_COLOR,
-            gl.ONE_MINUS_CONSTANT_COLOR,
+            gl.ONE_MINUS_CONSTANT_COLOR
+        ];
+
+        this.glBlendFunctionAlpha = [
+            gl.ZERO,
+            gl.ONE,
+            gl.SRC_COLOR,
+            gl.ONE_MINUS_SRC_COLOR,
+            gl.DST_COLOR,
+            gl.ONE_MINUS_DST_COLOR,
+            gl.SRC_ALPHA,
+            gl.SRC_ALPHA_SATURATE,
+            gl.ONE_MINUS_SRC_ALPHA,
+            gl.DST_ALPHA,
+            gl.ONE_MINUS_DST_ALPHA,
             gl.CONSTANT_ALPHA,
             gl.ONE_MINUS_CONSTANT_ALPHA
         ];
@@ -1035,29 +1043,16 @@ class WebglGraphicsDevice extends GraphicsDevice {
         const gl = this.gl;
 
         // Initialize render state to a known start state
-        this.blending = false;
-        gl.disable(gl.BLEND);
 
-        this.blendSrc = BLENDMODE_ONE;
-        this.blendDst = BLENDMODE_ZERO;
-        this.blendSrcAlpha = BLENDMODE_ONE;
-        this.blendDstAlpha = BLENDMODE_ZERO;
-        this.separateAlphaBlend = false;
-        this.blendEquation = BLENDEQUATION_ADD;
-        this.blendAlphaEquation = BLENDEQUATION_ADD;
-        this.separateAlphaEquation = false;
+        // default blend state
+        gl.disable(gl.BLEND);
         gl.blendFunc(gl.ONE, gl.ZERO);
         gl.blendEquation(gl.FUNC_ADD);
+        gl.colorMask(true, true, true, true);
 
         this.blendColor = new Color(0, 0, 0, 0);
         gl.blendColor(0, 0, 0, 0);
 
-        this.writeRed = true;
-        this.writeGreen = true;
-        this.writeBlue = true;
-        this.writeAlpha = true;
-        gl.colorMask(true, true, true, true);
-
         this.cullMode = CULLFACE_BACK;
         gl.enable(gl.CULL_FACE);
         gl.cullFace(gl.BACK);
@@ -2074,7 +2069,7 @@ class WebglGraphicsDevice extends GraphicsDevice {
             if (flags & CLEARFLAG_COLOR) {
                 const color = options.color ?? defaultOptions.color;
                 this.setClearColor(color[0], color[1], color[2], color[3]);
-                this.setColorWrite(true, true, true, true);
+                this.setBlendState(BlendState.DEFAULT);
             }
 
             if (flags & CLEARFLAG_DEPTH) {
@@ -2235,31 +2230,6 @@ class WebglGraphicsDevice extends GraphicsDevice {
         }
     }
 
-    /**
-     * Enables or disables writes to the color buffer. Once this state is set, it persists until it
-     * is changed. By default, color writes are enabled for all color channels.
-     *
-     * @param {boolean} writeRed - True to enable writing of the red channel and false otherwise.
-     * @param {boolean} writeGreen - True to enable writing of the green channel and false otherwise.
-     * @param {boolean} writeBlue - True to enable writing of the blue channel and false otherwise.
-     * @param {boolean} writeAlpha - True to enable writing of the alpha channel and false otherwise.
-     * @example
-     * // Just write alpha into the frame buffer
-     * device.setColorWrite(false, false, false, true);
-     */
-    setColorWrite(writeRed, writeGreen, writeBlue, writeAlpha) {
-        if ((this.writeRed !== writeRed) ||
-            (this.writeGreen !== writeGreen) ||
-            (this.writeBlue !== writeBlue) ||
-            (this.writeAlpha !== writeAlpha)) {
-            this.gl.colorMask(writeRed, writeGreen, writeBlue, writeAlpha);
-            this.writeRed = writeRed;
-            this.writeGreen = writeGreen;
-            this.writeBlue = writeBlue;
-            this.writeAlpha = writeAlpha;
-        }
-    }
-
     /**
      * Enables or disables alpha to coverage (WebGL2 only).
      *
@@ -2356,32 +2326,6 @@ class WebglGraphicsDevice extends GraphicsDevice {
         this.gl.polygonOffset(slopeBias, constBias);
     }
 
-    /**
-     * Queries whether blending is enabled.
-     *
-     * @returns {boolean} True if blending is enabled and false otherwise.
-     */
-    getBlending() {
-        return this.blending;
-    }
-
-    /**
-     * Enables or disables blending.
-     *
-     * @param {boolean} blending - True to enable blending and false to disable it.
-     */
-    setBlending(blending) {
-        if (this.blending !== blending) {
-            const gl = this.gl;
-            if (blending) {
-                gl.enable(gl.BLEND);
-            } else {
-                gl.disable(gl.BLEND);
-            }
-            this.blending = blending;
-        }
-    }
-
     /**
      * Enables or disables stencil test.
      *
@@ -2595,115 +2539,44 @@ class WebglGraphicsDevice extends GraphicsDevice {
         }
     }
 
-    /**
-     * Configures blending operations. Both source and destination blend modes can take the
-     * following values:
-     *
-     * - {@link BLENDMODE_ZERO}
-     * - {@link BLENDMODE_ONE}
-     * - {@link BLENDMODE_SRC_COLOR}
-     * - {@link BLENDMODE_ONE_MINUS_SRC_COLOR}
-     * - {@link BLENDMODE_DST_COLOR}
-     * - {@link BLENDMODE_ONE_MINUS_DST_COLOR}
-     * - {@link BLENDMODE_SRC_ALPHA}
-     * - {@link BLENDMODE_SRC_ALPHA_SATURATE}
-     * - {@link BLENDMODE_ONE_MINUS_SRC_ALPHA}
-     * - {@link BLENDMODE_DST_ALPHA}
-     * - {@link BLENDMODE_ONE_MINUS_DST_ALPHA}
-     * - {@link BLENDMODE_CONSTANT_COLOR}
-     * - {@link BLENDMODE_ONE_MINUS_CONSTANT_COLOR}
-     * - {@link BLENDMODE_CONSTANT_ALPHA}
-     * - {@link BLENDMODE_ONE_MINUS_CONSTANT_ALPHA}
-     *
-     * @param {number} blendSrc - The source blend function.
-     * @param {number} blendDst - The destination blend function.
-     */
-    setBlendFunction(blendSrc, blendDst) {
-        if (this.blendSrc !== blendSrc || this.blendDst !== blendDst || this.separateAlphaBlend) {
-            this.gl.blendFunc(this.glBlendFunction[blendSrc], this.glBlendFunction[blendDst]);
-            this.blendSrc = blendSrc;
-            this.blendDst = blendDst;
-            this.separateAlphaBlend = false;
-        }
-    }
+    setBlendState(blendState) {
+        const currentBlendState = this.blendState;
+        if (!currentBlendState.equals(blendState)) {
+            const gl = this.gl;
 
-    /**
-     * Configures blending operations. Both source and destination blend modes can take the
-     * following values:
-     *
-     * - {@link BLENDMODE_ZERO}
-     * - {@link BLENDMODE_ONE}
-     * - {@link BLENDMODE_SRC_COLOR}
-     * - {@link BLENDMODE_ONE_MINUS_SRC_COLOR}
-     * - {@link BLENDMODE_DST_COLOR}
-     * - {@link BLENDMODE_ONE_MINUS_DST_COLOR}
-     * - {@link BLENDMODE_SRC_ALPHA}
-     * - {@link BLENDMODE_SRC_ALPHA_SATURATE}
-     * - {@link BLENDMODE_ONE_MINUS_SRC_ALPHA}
-     * - {@link BLENDMODE_DST_ALPHA}
-     * - {@link BLENDMODE_ONE_MINUS_DST_ALPHA}
-     *
-     * @param {number} blendSrc - The source blend function.
-     * @param {number} blendDst - The destination blend function.
-     * @param {number} blendSrcAlpha - The separate source blend function for the alpha channel.
-     * @param {number} blendDstAlpha - The separate destination blend function for the alpha channel.
-     */
-    setBlendFunctionSeparate(blendSrc, blendDst, blendSrcAlpha, blendDstAlpha) {
-        if (this.blendSrc !== blendSrc || this.blendDst !== blendDst || this.blendSrcAlpha !== blendSrcAlpha || this.blendDstAlpha !== blendDstAlpha || !this.separateAlphaBlend) {
-            this.gl.blendFuncSeparate(this.glBlendFunction[blendSrc], this.glBlendFunction[blendDst],
-                                      this.glBlendFunction[blendSrcAlpha], this.glBlendFunction[blendDstAlpha]);
-            this.blendSrc = blendSrc;
-            this.blendDst = blendDst;
-            this.blendSrcAlpha = blendSrcAlpha;
-            this.blendDstAlpha = blendDstAlpha;
-            this.separateAlphaBlend = true;
-        }
-    }
+            // state values to set
+            const { blend, colorOp, alphaOp, colorSrcFactor, colorDstFactor, alphaSrcFactor, alphaDstFactor } = blendState;
 
-    /**
-     * Configures the blending equation. The default blend equation is {@link BLENDEQUATION_ADD}.
-     *
-     * @param {number} blendEquation - The blend equation. Can be:
-     *
-     * - {@link BLENDEQUATION_ADD}
-     * - {@link BLENDEQUATION_SUBTRACT}
-     * - {@link BLENDEQUATION_REVERSE_SUBTRACT}
-     * - {@link BLENDEQUATION_MIN}
-     * - {@link BLENDEQUATION_MAX}
-     *
-     * Note that MIN and MAX modes require either EXT_blend_minmax or WebGL2 to work (check
-     * device.extBlendMinmax).
-     */
-    setBlendEquation(blendEquation) {
-        if (this.blendEquation !== blendEquation || this.separateAlphaEquation) {
-            this.gl.blendEquation(this.glBlendEquation[blendEquation]);
-            this.blendEquation = blendEquation;
-            this.separateAlphaEquation = false;
-        }
-    }
+            // enable blend
+            if (currentBlendState.blend !== blend) {
+                if (blend) {
+                    gl.enable(gl.BLEND);
+                } else {
+                    gl.disable(gl.BLEND);
+                }
+            }
 
-    /**
-     * Configures the blending equation. The default blend equation is {@link BLENDEQUATION_ADD}.
-     *
-     * @param {number} blendEquation - The blend equation. Can be:
-     *
-     * - {@link BLENDEQUATION_ADD}
-     * - {@link BLENDEQUATION_SUBTRACT}
-     * - {@link BLENDEQUATION_REVERSE_SUBTRACT}
-     * - {@link BLENDEQUATION_MIN}
-     * - {@link BLENDEQUATION_MAX}
-     *
-     * Note that MIN and MAX modes require either EXT_blend_minmax or WebGL2 to work (check
-     * device.extBlendMinmax).
-     * @param {number} blendAlphaEquation - A separate blend equation for the alpha channel.
-     * Accepts same values as `blendEquation`.
-     */
-    setBlendEquationSeparate(blendEquation, blendAlphaEquation) {
-        if (this.blendEquation !== blendEquation || this.blendAlphaEquation !== blendAlphaEquation || !this.separateAlphaEquation) {
-            this.gl.blendEquationSeparate(this.glBlendEquation[blendEquation], this.glBlendEquation[blendAlphaEquation]);
-            this.blendEquation = blendEquation;
-            this.blendAlphaEquation = blendAlphaEquation;
-            this.separateAlphaEquation = true;
+            // blend ops
+            if (currentBlendState.colorOp !== colorOp || currentBlendState.alphaOp !== alphaOp) {
+                const glBlendEquation = this.glBlendEquation;
+                gl.blendEquationSeparate(glBlendEquation[colorOp], glBlendEquation[alphaOp]);
+            }
+
+            // blend factors
+            if (currentBlendState.colorSrcFactor !== colorSrcFactor || currentBlendState.colorDstFactor !== colorDstFactor ||
+                currentBlendState.alphaSrcFactor !== alphaSrcFactor || currentBlendState.alphaDstFactor !== alphaDstFactor) {
+
+                gl.blendFuncSeparate(this.glBlendFunctionColor[colorSrcFactor], this.glBlendFunctionColor[colorDstFactor],
+                                     this.glBlendFunctionAlpha[alphaSrcFactor], this.glBlendFunctionAlpha[alphaDstFactor]);
+            }
+
+            // color write
+            if (currentBlendState.allWrite !== blendState.allWrite) {
+                this.gl.colorMask(blendState.redWrite, blendState.greenWrite, blendState.blueWrite, blendState.alphaWrite);
+            }
+
+            // update internal state
+            currentBlendState.copy(blendState);
         }
     }
 
diff --git a/src/platform/graphics/webgpu/webgpu-graphics-device.js b/src/platform/graphics/webgpu/webgpu-graphics-device.js
index 9faf7f6f7..eb9f2e25f 100644
--- a/src/platform/graphics/webgpu/webgpu-graphics-device.js
+++ b/src/platform/graphics/webgpu/webgpu-graphics-device.js
@@ -10,7 +10,6 @@ import { WebgpuBindGroup } from './webgpu-bind-group.js';
 import { WebgpuBindGroupFormat } from './webgpu-bind-group-format.js';
 import { WebgpuIndexBuffer } from './webgpu-index-buffer.js';
 import { WebgpuRenderPipeline } from './webgpu-render-pipeline.js';
-import { WebgpuRenderState } from './webgpu-render-state.js';
 import { WebgpuRenderTarget } from './webgpu-render-target.js';
 import { WebgpuShader } from './webgpu-shader.js';
 import { WebgpuTexture } from './webgpu-texture.js';
@@ -27,13 +26,6 @@ class WebgpuGraphicsDevice extends GraphicsDevice {
      */
     frameBuffer;
 
-    /**
-     * Internal representation of the current render state, as requested by the renderer.
-     * In the future this can be completely replaced by a more optimal solution, where
-     * render states are bundled together (DX11 style) and set using a single call.
-     */
-    renderState = new WebgpuRenderState();
-
     /**
      * Object responsible for caching and creation of render pipelines.
      */
@@ -71,12 +63,6 @@ class WebgpuGraphicsDevice extends GraphicsDevice {
         super(canvas);
         this.isWebGPU = true;
 
-        // TODO: refactor as needed
-        this.writeRed = true;
-        this.writeGreen = true;
-        this.writeBlue = true;
-        this.writeAlpha = true;
-
         this.initDeviceCaps();
     }
 
@@ -111,6 +97,7 @@ class WebgpuGraphicsDevice extends GraphicsDevice {
         this.boneLimit = 1024;
         this.supportsImageBitmap = true;
         this.extStandardDerivatives = true;
+        this.extBlendMinmax = true;
         this.areaLightLutFormat = PIXELFORMAT_RGBA32F;
         this.supportsTextureFetch = true;
     }
@@ -286,7 +273,7 @@ class WebgpuGraphicsDevice extends GraphicsDevice {
 
             // render pipeline
             const pipeline = this.renderPipeline.get(primitive, vb0.format, vb1?.format, this.shader, this.renderTarget,
-                                                     this.bindGroupFormats, this.renderState);
+                                                     this.bindGroupFormats, this.blendState);
             Debug.assert(pipeline);
 
             if (this.pipeline !== pipeline) {
@@ -318,16 +305,12 @@ class WebgpuGraphicsDevice extends GraphicsDevice {
         return true;
     }
 
-    setBlending(blending) {
-        this.renderState.setBlending(blending);
+    setBlendState(blendState) {
+        this.blendState.copy(blendState);
     }
 
-    setBlendFunction(blendSrc, blendDst) {
-        this.renderState.setBlendFunction(blendSrc, blendDst);
-    }
-
-    setBlendEquation(blendEquation) {
-        this.renderState.setBlendEquation(blendEquation);
+    setBlendColor(r, g, b, a) {
+        // TODO: this should use passEncoder.setBlendConstant(color)
     }
 
     setDepthFunc(func) {
@@ -350,9 +333,6 @@ class WebgpuGraphicsDevice extends GraphicsDevice {
     setAlphaToCoverage(state) {
     }
 
-    setColorWrite(writeRed, writeGreen, writeBlue, writeAlpha) {
-    }
-
     setDepthWrite(writeDepth) {
     }
 
diff --git a/src/platform/graphics/webgpu/webgpu-render-pipeline.js b/src/platform/graphics/webgpu/webgpu-render-pipeline.js
index b157ed4fe..41cff61fe 100644
--- a/src/platform/graphics/webgpu/webgpu-render-pipeline.js
+++ b/src/platform/graphics/webgpu/webgpu-render-pipeline.js
@@ -36,10 +36,8 @@ const _blendFactor = [
     'one-minus-src-alpha',  // BLENDMODE_ONE_MINUS_SRC_ALPHA
     'dst-alpha',            // BLENDMODE_DST_ALPHA
     'one-minus-dst-alpha',  // BLENDMODE_ONE_MINUS_DST_ALPHA
-    'constant',             // BLENDMODE_CONSTANT_COLOR
-    'one-minus-constant',   // BLENDMODE_ONE_MINUS_CONSTANT_COLOR
-    undefined,              // BLENDMODE_CONSTANT_ALPHA
-    undefined               // BLENDMODE_ONE_MINUS_CONSTANT_ALPHA
+    'constant',             // BLENDMODE_CONSTANT
+    'one-minus-constant'    // BLENDMODE_ONE_MINUS_CONSTANT
 ];
 
 // temp array to avoid allocation
@@ -68,10 +66,10 @@ class WebgpuRenderPipeline {
         this.cache = new Map();
     }
 
-    get(primitive, vertexFormat0, vertexFormat1, shader, renderTarget, bindGroupFormats, renderState) {
+    get(primitive, vertexFormat0, vertexFormat1, shader, renderTarget, bindGroupFormats, blendState) {
 
         // render pipeline unique key
-        const key = this.getKey(primitive, vertexFormat0, vertexFormat1, shader, renderTarget, bindGroupFormats, renderState);
+        const key = this.getKey(primitive, vertexFormat0, vertexFormat1, shader, renderTarget, bindGroupFormats, blendState);
 
         // cached pipeline
         let pipeline = this.cache.get(key);
@@ -87,7 +85,7 @@ class WebgpuRenderPipeline {
             const vertexBufferLayout = this.vertexBufferLayout.get(vertexFormat0, vertexFormat1);
 
             // pipeline
-            pipeline = this.create(primitiveTopology, shader.impl, renderTarget, pipelineLayout, renderState, vertexBufferLayout);
+            pipeline = this.create(primitiveTopology, shader.impl, renderTarget, pipelineLayout, blendState, vertexBufferLayout);
             this.cache.set(key, pipeline);
         }
 
@@ -98,7 +96,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, renderState) {
+    getKey(primitive, vertexFormat0, vertexFormat1, shader, renderTarget, bindGroupFormats, blendState) {
 
         let bindGroupKey = '';
         for (let i = 0; i < bindGroupFormats.length; i++) {
@@ -107,10 +105,9 @@ class WebgpuRenderPipeline {
 
         const vertexBufferLayoutKey = this.vertexBufferLayout.getKey(vertexFormat0, vertexFormat1);
         const renderTargetKey = renderTarget.impl.key;
-        const renderStateKey = renderState.blendKey;
 
         return vertexBufferLayoutKey + shader.impl.vertexCode + shader.impl.fragmentCode +
-            renderTargetKey + renderStateKey + primitive.type + bindGroupKey;
+            renderTargetKey + primitive.type + bindGroupKey + blendState.key;
     }
 
     // TODO: this could be cached using bindGroupKey
@@ -143,24 +140,24 @@ class WebgpuRenderPipeline {
         return pipelineLayout;
     }
 
-    getBlend(renderState) {
+    getBlend(blendState) {
 
         // blend needs to be undefined when blending is disabled
         let blend;
 
-        if (renderState.blending) {
+        if (blendState.blend) {
 
             /** @type {GPUBlendState} */
             blend = {
                 color: {
-                    operation: _blendOperation[renderState.blendEquationColor],
-                    srcFactor: _blendFactor[renderState.blendSrcColor],
-                    dstFactor: _blendFactor[renderState.blendDstColor]
+                    operation: _blendOperation[blendState.colorOp],
+                    srcFactor: _blendFactor[blendState.colorSrcFactor],
+                    dstFactor: _blendFactor[blendState.colorDstFactor]
                 },
                 alpha: {
-                    operation: _blendOperation[renderState.blendEquationAlpha],
-                    srcFactor: _blendFactor[renderState.blendSrcAlpha],
-                    dstFactor: _blendFactor[renderState.blendDstAlpha]
+                    operation: _blendOperation[blendState.alphaOp],
+                    srcFactor: _blendFactor[blendState.alphaSrcFactor],
+                    dstFactor: _blendFactor[blendState.alphaDstFactor]
                 }
             };
 
@@ -174,7 +171,7 @@ class WebgpuRenderPipeline {
         return blend;
     }
 
-    create(primitiveTopology, webgpuShader, renderTarget, pipelineLayout, renderState, vertexBufferLayout) {
+    create(primitiveTopology, webgpuShader, renderTarget, pipelineLayout, blendState, vertexBufferLayout) {
 
         const wgpu = this.device.wgpu;
 
@@ -222,14 +219,20 @@ class WebgpuRenderPipeline {
             });
             DebugHelper.setLabel(fragmentModule, `Fragment ${webgpuShader.shader.label}`);
 
+            let writeMask = 0;
+            if (blendState.redWrite) writeMask |= GPUColorWrite.RED;
+            if (blendState.greenWrite) writeMask |= GPUColorWrite.GREEN;
+            if (blendState.blueWrite) writeMask |= GPUColorWrite.BLUE;
+            if (blendState.alphaWrite) writeMask |= GPUColorWrite.ALPHA;
+
             /** @type {GPUFragmentState} */
             descr.fragment = {
                 module: fragmentModule,
                 entryPoint: 'main',
                 targets: [{
                     format: renderTarget.impl.colorFormat,
-                    writeMask: GPUColorWrite.ALL,
-                    blend: this.getBlend(renderState)
+                    writeMask: writeMask,
+                    blend: this.getBlend(blendState)
                 }]
             };
         }
diff --git a/src/platform/graphics/webgpu/webgpu-render-state.js b/src/platform/graphics/webgpu/webgpu-render-state.js
deleted file mode 100644
index 3d77e1760..000000000
--- a/src/platform/graphics/webgpu/webgpu-render-state.js
+++ /dev/null
@@ -1,76 +0,0 @@
-import {
-    BLENDEQUATION_ADD,
-    BLENDMODE_ZERO, BLENDMODE_ONE
-} from '../constants.js';
-
-class WebgpuRenderState {
-    constructor() {
-        this.reset();
-    }
-
-    // TODO: When setDepthTest is implemented, a webgpu only workaround should be removed in drawQuadWithShader function.
-
-    reset() {
-
-        // blend state
-        this.blendStateDirty = true;
-        this.blendStateKey = '';
-        this.blending = false;
-        this.blendSrcColor = BLENDMODE_ONE;
-        this.blendDstColor = BLENDMODE_ZERO;
-        this.blendEquationColor = BLENDEQUATION_ADD;
-        this.blendEquationAlpha = BLENDEQUATION_ADD;
-        this.blendSrcAlpha = BLENDMODE_ONE;
-        this.blendDstAlpha = BLENDMODE_ZERO;
-//        this.blendColor = new Color(0, 0, 0, 0);
-        this.writeRed = true;
-        this.writeGreen = true;
-        this.writeBlue = true;
-        this.writeAlpha = true;
-    }
-
-    get blendKey() {
-
-        if (this.blendStateDirty) {
-            this.blendStateDirty = false;
-
-            this.blendStateKey = `${this.blending}_${this.blendSrcColor}_${this.blendSrcAlpha}`;
-            this.blendStateKey += `_${this.blendDstColor}_${this.blendDstAlpha}`;
-            this.blendStateKey += `_${this.blendEquationColor}_${this.blendEquationAlpha}`;
-        }
-        return this.blendStateKey;
-
-    }
-
-    setBlending(blending) {
-        if (this.blending !== blending) {
-            this.blending = blending;
-
-            this.blendStateDirty = true;
-        }
-    }
-
-    setBlendFunction(blendSrc, blendDst) {
-        if (this.blendSrcColor !== blendSrc || this.blendDstColor !== blendDst ||
-            this.blendSrcAlpha !== blendSrc || this.blendDstAlpha !== blendDst) {
-
-            this.blendSrcColor = blendSrc;
-            this.blendSrcAlpha = blendSrc;
-            this.blendDstColor = blendDst;
-            this.blendDstAlpha = blendDst;
-
-            this.blendStateDirty = true;
-        }
-    }
-
-    setBlendEquation(blendEquation) {
-        if (this.blendEquationColor !== blendEquation || this.blendEquationAlpha !== blendEquation) {
-            this.blendEquationColor = blendEquation;
-            this.blendEquationAlpha = blendEquation;
-
-            this.blendStateDirty = true;
-        }
-    }
-}
-
-export { WebgpuRenderState };
diff --git a/src/scene/graphics/post-effect.js b/src/scene/graphics/post-effect.js
index e5fb25184..1cd666b2e 100644
--- a/src/scene/graphics/post-effect.js
+++ b/src/scene/graphics/post-effect.js
@@ -1,4 +1,5 @@
 import { Vec4 } from '../../core/math/vec4.js';
+import { BlendState } from '../../platform/graphics/blend-state.js';
 import { drawQuadWithShader } from './quad-render-utils.js';
 
 const _viewport = new Vec4();
@@ -80,6 +81,7 @@ class PostEffect {
             viewport = _viewport.set(rect.x * w, rect.y * h, rect.z * w, rect.w * h);
         }
 
+        this.device.setBlendState(BlendState.DEFAULT);
         drawQuadWithShader(this.device, target, shader, viewport);
     }
 }
diff --git a/src/scene/graphics/prefilter-cubemap.js b/src/scene/graphics/prefilter-cubemap.js
index abd4134cd..78312b7f2 100644
--- a/src/scene/graphics/prefilter-cubemap.js
+++ b/src/scene/graphics/prefilter-cubemap.js
@@ -9,6 +9,7 @@ import { drawQuadWithShader } from './quad-render-utils.js';
 import { shaderChunks } from '../shader-lib/chunks/chunks.js';
 import { RenderTarget } from '../../platform/graphics/render-target.js';
 import { Texture } from '../../platform/graphics/texture.js';
+import { BlendState } from '../../platform/graphics/blend-state.js';
 
 // https://seblagarde.wordpress.com/2012/06/10/amd-cubemapgen-for-physically-based-rendering/
 function areaElement(x, y) {
@@ -95,6 +96,7 @@ function shFromCubemap(device, source, dontFlipX) {
                     depth: false
                 });
                 constantTexSource.setValue(tex);
+                device.setBlendState(BlendState.DEFAULT);
                 drawQuadWithShader(device, targ, shader);
 
                 const gl = device.gl;
diff --git a/src/scene/graphics/quad-render-utils.js b/src/scene/graphics/quad-render-utils.js
index 11c56ae30..11ae7d837 100644
--- a/src/scene/graphics/quad-render-utils.js
+++ b/src/scene/graphics/quad-render-utils.js
@@ -21,28 +21,28 @@ const _tempRect = new Vec4();
  * pixels. Defaults to fullscreen (`0, 0, target.width, target.height`).
  * @param {import('../../core/math/vec4.js').Vec4} [scissorRect] - The scissor rectangle of the
  * quad, in pixels. Defaults to fullscreen (`0, 0, target.width, target.height`).
- * @param {boolean} [useBlend] - True to enable blending. Defaults to false, disabling blending.
  */
-function drawQuadWithShader(device, target, shader, rect, scissorRect, useBlend = false) {
+function drawQuadWithShader(device, target, shader, rect, scissorRect) {
 
     // a valid target or a null target (framebuffer) are supported
     Debug.assert(target !== undefined);
 
+    const useBlend = arguments[5];
+    Debug.call(() => {
+        if (useBlend !== undefined) {
+            Debug.warnOnce('pc.drawQuadWithShader no longer accepts useBlend parameter, and blending state needs to be set up using GraphicsDevice.setBlendState.');
+        }
+    });
+
     DebugGraphics.pushGpuMarker(device, "drawQuadWithShader");
 
     const oldDepthTest = device.getDepthTest();
     const oldDepthWrite = device.getDepthWrite();
     const oldCullMode = device.getCullMode();
-    const oldWR = device.writeRed;
-    const oldWG = device.writeGreen;
-    const oldWB = device.writeBlue;
-    const oldWA = device.writeAlpha;
 
     device.setDepthTest(false);
     device.setDepthWrite(false);
     device.setCullMode(CULLFACE_NONE);
-    device.setColorWrite(true, true, true, true);
-    if (!useBlend) device.setBlending(false);
 
     // prepare the quad for rendering with the shader
     const quad = new QuadRender(shader);
@@ -76,7 +76,6 @@ function drawQuadWithShader(device, target, shader, rect, scissorRect, useBlend
     device.setDepthTest(oldDepthTest);
     device.setDepthWrite(oldDepthWrite);
     device.setCullMode(oldCullMode);
-    device.setColorWrite(oldWR, oldWG, oldWB, oldWA);
 
     DebugGraphics.popGpuMarker(device);
 }
@@ -96,13 +95,20 @@ function drawQuadWithShader(device, target, shader, rect, scissorRect, useBlend
  * texture, in pixels. Defaults to fullscreen (`0, 0, target.width, target.height`).
  * @param {import('../../core/math/vec4.js').Vec4} [scissorRect] - The scissor rectangle to use for
  * the texture, in pixels. Defaults to fullscreen (`0, 0, target.width, target.height`).
- * @param {boolean} [useBlend] - True to enable blending. Defaults to false, disabling blending.
  */
-function drawTexture(device, texture, target, shader, rect, scissorRect, useBlend = false) {
+function drawTexture(device, texture, target, shader, rect, scissorRect) {
     Debug.assert(!device.isWebGPU, 'pc.drawTexture is not currently supported on WebGPU platform.');
+
+    const useBlend = arguments[6];
+    Debug.call(() => {
+        if (useBlend !== undefined) {
+            Debug.warnOnce('pc.drawTexture no longer accepts useBlend parameter, and blending state needs to be set up using GraphicsDevice.setBlendState.');
+        }
+    });
+
     shader = shader || device.getCopyShader();
     device.constantTexSource.setValue(texture);
-    drawQuadWithShader(device, target, shader, rect, scissorRect, useBlend);
+    drawQuadWithShader(device, target, shader, rect, scissorRect);
 }
 
 export { drawQuadWithShader, drawTexture };
diff --git a/src/scene/graphics/reproject-texture.js b/src/scene/graphics/reproject-texture.js
index 6f8743df1..78c1f3028 100644
--- a/src/scene/graphics/reproject-texture.js
+++ b/src/scene/graphics/reproject-texture.js
@@ -17,6 +17,7 @@ import { ChunkUtils } from '../shader-lib/chunk-utils.js';
 import { shaderChunks } from '../shader-lib/chunks/chunks.js';
 import { getProgramLibrary } from '../shader-lib/get-program-library.js';
 import { createShaderFromCode } from '../shader-lib/utils.js';
+import { BlendState } from '../../platform/graphics/blend-state.js';
 
 const getProjectionName = (projection) => {
     switch (projection) {
@@ -462,6 +463,10 @@ function reprojectTexture(source, target, options = {}) {
 
     DebugGraphics.pushGpuMarker(device, "ReprojectTexture");
 
+    // render state
+    // TODO: set up other render state here to expected state
+    device.setBlendState(BlendState.DEFAULT);
+
     const constantSource = device.scope.resolve(source.cubemap ? "sourceCube" : "sourceTex");
     Debug.assert(constantSource);
     constantSource.setValue(source);
diff --git a/src/scene/graphics/scene-grab.js b/src/scene/graphics/scene-grab.js
index 3091089bf..94c98c2f0 100644
--- a/src/scene/graphics/scene-grab.js
+++ b/src/scene/graphics/scene-grab.js
@@ -8,6 +8,7 @@ import {
 
 import { RenderTarget } from '../../platform/graphics/render-target.js';
 import { Texture } from '../../platform/graphics/texture.js';
+import { BlendState } from '../../platform/graphics/blend-state.js';
 import { DebugGraphics } from '../../platform/graphics/debug-graphics.js';
 
 import {
@@ -386,7 +387,8 @@ class SceneGrab {
             },
 
             onDrawCall: function () {
-                device.setColorWrite(true, true, true, true);
+                // writing depth to color render target, force no blending and writing to all channels
+                device.setBlendState(BlendState.DEFAULT);
             },
 
             onPostRenderOpaque: function (cameraPass) {
diff --git a/src/scene/materials/lit-options.js b/src/scene/materials/lit-options.js
index f28c39f3a..739159b69 100644
--- a/src/scene/materials/lit-options.js
+++ b/src/scene/materials/lit-options.js
@@ -71,7 +71,7 @@ class LitOptions {
 
     useMorphTextureBased = false;
 
-    nineSlicedMode = false;
+    nineSlicedMode = 0;
 
     clusteredLightingEnabled = true;
 
@@ -155,9 +155,9 @@ class LitOptions {
     /**
      * The value of {@link StandardMaterial#occludeSpecular}.
      *
-     * @type {boolean}
+     * @type {number}
      */
-    occludeSpecular = false;
+    occludeSpecular = 0;
 
     /**
      * Defines if {@link StandardMaterial#occludeSpecularIntensity} constant should affect specular
diff --git a/src/scene/materials/material.js b/src/scene/materials/material.js
index e74e2510c..d727be106 100644
--- a/src/scene/materials/material.js
+++ b/src/scene/materials/material.js
@@ -1,10 +1,11 @@
 import { Debug } from '../../core/debug.js';
+import { BlendState } from '../../platform/graphics/blend-state.js';
 
 import {
     BLENDMODE_ZERO, BLENDMODE_ONE, BLENDMODE_SRC_COLOR,
     BLENDMODE_DST_COLOR, BLENDMODE_ONE_MINUS_DST_COLOR, BLENDMODE_SRC_ALPHA,
     BLENDMODE_ONE_MINUS_SRC_ALPHA,
-    BLENDEQUATION_ADD,
+    BLENDEQUATION_ADD, BLENDEQUATION_REVERSE_SUBTRACT,
     BLENDEQUATION_MIN, BLENDEQUATION_MAX,
     CULLFACE_BACK,
     FUNC_LESSEQUAL
@@ -14,11 +15,25 @@ import { ShaderProcessorOptions } from '../../platform/graphics/shader-processor
 import {
     BLEND_ADDITIVE, BLEND_NORMAL, BLEND_NONE, BLEND_PREMULTIPLIED,
     BLEND_MULTIPLICATIVE, BLEND_ADDITIVEALPHA, BLEND_MULTIPLICATIVE2X, BLEND_SCREEN,
-    BLEND_MIN, BLEND_MAX
+    BLEND_MIN, BLEND_MAX, BLEND_SUBTRACTIVE
 } from '../constants.js';
 import { processShader } from '../shader-lib/utils.js';
 import { getDefaultMaterial } from './default-material.js';
 
+// blend mode mapping to op, srcBlend and dstBlend
+const blendModes = [];
+blendModes[BLEND_SUBTRACTIVE] = { src: BLENDMODE_ONE, dst: BLENDMODE_ONE, op: BLENDEQUATION_REVERSE_SUBTRACT };
+blendModes[BLEND_NONE] = { src: BLENDMODE_ONE, dst: BLENDMODE_ZERO, op: BLENDEQUATION_ADD };
+blendModes[BLEND_NORMAL] = { src: BLENDMODE_SRC_ALPHA, dst: BLENDMODE_ONE_MINUS_SRC_ALPHA, op: BLENDEQUATION_ADD };
+blendModes[BLEND_PREMULTIPLIED] = { src: BLENDMODE_ONE, dst: BLENDMODE_ONE_MINUS_SRC_ALPHA, op: BLENDEQUATION_ADD };
+blendModes[BLEND_ADDITIVE] = { src: BLENDMODE_ONE, dst: BLENDMODE_ONE, op: BLENDEQUATION_ADD };
+blendModes[BLEND_ADDITIVEALPHA] = { src: BLENDMODE_SRC_ALPHA, dst: BLENDMODE_ONE, op: BLENDEQUATION_ADD };
+blendModes[BLEND_MULTIPLICATIVE2X] = { src: BLENDMODE_DST_COLOR, dst: BLENDMODE_SRC_COLOR, op: BLENDEQUATION_ADD };
+blendModes[BLEND_SCREEN] = { src: BLENDMODE_ONE_MINUS_DST_COLOR, dst: BLENDMODE_ONE, op: BLENDEQUATION_ADD };
+blendModes[BLEND_MULTIPLICATIVE] = { src: BLENDMODE_DST_COLOR, dst: BLENDMODE_ZERO, op: BLENDEQUATION_ADD };
+blendModes[BLEND_MIN] = { src: BLENDMODE_ONE, dst: BLENDMODE_ONE, op: BLENDEQUATION_MIN };
+blendModes[BLEND_MAX] = { src: BLENDMODE_ONE, dst: BLENDMODE_ONE, op: BLENDEQUATION_MAX };
+
 let id = 0;
 
 /**
@@ -79,21 +94,8 @@ class Material {
      */
     alphaToCoverage = false;
 
-    blend = false;
-
-    blendSrc = BLENDMODE_ONE;
-
-    blendDst = BLENDMODE_ZERO;
-
-    blendEquation = BLENDEQUATION_ADD;
-
-    separateAlphaBlend = false;
-
-    blendSrcAlpha = BLENDMODE_ONE;
-
-    blendDstAlpha = BLENDMODE_ZERO;
-
-    blendAlphaEquation = BLENDEQUATION_ADD;
+    /** @ignore */
+    _blendState = new BlendState();
 
     /**
      * Controls how triangles are culled based on their face direction with respect to the
@@ -113,6 +115,8 @@ 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
@@ -180,6 +184,14 @@ class Material {
      */
     slopeDepthBias = 0;
 
+    _shaderVersion = 0;
+
+    _scene = null;
+
+    _dirtyBlend = false;
+
+    dirty = true;
+
     /**
      * If true, the red component of fragments generated by the shader of this material is written
      * to the color buffer of the currently active render target. If false, the red component will
@@ -187,7 +199,13 @@ class Material {
      *
      * @type {boolean}
      */
-    redWrite = true;
+    set redWrite(value) {
+        this._blendState.redWrite = value;
+    }
+
+    get redWrite() {
+        return this._blendState.redWrite;
+    }
 
     /**
      * If true, the green component of fragments generated by the shader of this material is
@@ -196,7 +214,13 @@ class Material {
      *
      * @type {boolean}
      */
-    greenWrite = true;
+    set greenWrite(value) {
+        this._blendState.greenWrite = value;
+    }
+
+    get greenWrite() {
+        return this._blendState.greenWrite;
+    }
 
     /**
      * If true, the blue component of fragments generated by the shader of this material is
@@ -205,7 +229,13 @@ class Material {
      *
      * @type {boolean}
      */
-    blueWrite = true;
+    set blueWrite(value) {
+        this._blendState.blueWrite = value;
+    }
+
+    get blueWrite() {
+        return this._blendState.blueWrite;
+    }
 
     /**
      * If true, the alpha component of fragments generated by the shader of this material is
@@ -214,15 +244,13 @@ class Material {
      *
      * @type {boolean}
      */
-    alphaWrite = true;
-
-    _shaderVersion = 0;
-
-    _scene = null;
-
-    _dirtyBlend = false;
+    set alphaWrite(value) {
+        this._blendState.alphaWrite = value;
+    }
 
-    dirty = true;
+    get alphaWrite() {
+        return this._blendState.alphaWrite;
+    }
 
     /**
      * The shader used by this material to render mesh instances (default is null).
@@ -239,12 +267,41 @@ class Material {
 
     // returns boolean depending on material being transparent
     get transparent() {
-        return this.blend;
+        return this._blendState.blend;
     }
 
+    // called when material changes transparency, for layer composition to add it to appropriate
+    // queue (opaque or transparent)
+    _markBlendDirty() {
+        if (this._scene) {
+            this._scene.layers._dirtyBlend = true;
+        } else {
+            this._dirtyBlend = true;
+        }
+    }
+
+    /**
+     * Controls how fragment shader outputs are blended when being written to the currently active
+     * render target. This overwrites blending type set using {@link pc.Material#blendType}, and
+     * offers more control over blending.
+     *
+     * @type { BlendState }
+     */
+    set blendState(value) {
+        if (this._blendState.blend !== value.blend) {
+            this._markBlendDirty();
+        }
+        this._blendState.copy(value);
+    }
+
+    get blendState() {
+        return this._blendState;
+    }
+
+
     /**
-     * Controls how primitives are blended when being written to the currently active render
-     * target. Can be:
+     * Controls how fragment shader outputs are blended when being written to the currently active
+     * render target. Can be:
      *
      * - {@link BLEND_SUBTRACTIVE}: Subtract the color of the source fragment from the destination
      * fragment and write the result to the frame buffer.
@@ -270,119 +327,33 @@ class Material {
      * @type {number}
      */
     set blendType(type) {
-        let blend = true;
-        switch (type) {
-            case BLEND_NONE:
-                blend = false;
-                this.blendSrc = BLENDMODE_ONE;
-                this.blendDst = BLENDMODE_ZERO;
-                this.blendEquation = BLENDEQUATION_ADD;
-                break;
-            case BLEND_NORMAL:
-                this.blendSrc = BLENDMODE_SRC_ALPHA;
-                this.blendDst = BLENDMODE_ONE_MINUS_SRC_ALPHA;
-                this.blendEquation = BLENDEQUATION_ADD;
-                break;
-            case BLEND_PREMULTIPLIED:
-                this.blendSrc = BLENDMODE_ONE;
-                this.blendDst = BLENDMODE_ONE_MINUS_SRC_ALPHA;
-                this.blendEquation = BLENDEQUATION_ADD;
-                break;
-            case BLEND_ADDITIVE:
-                this.blendSrc = BLENDMODE_ONE;
-                this.blendDst = BLENDMODE_ONE;
-                this.blendEquation = BLENDEQUATION_ADD;
-                break;
-            case BLEND_ADDITIVEALPHA:
-                this.blendSrc = BLENDMODE_SRC_ALPHA;
-                this.blendDst = BLENDMODE_ONE;
-                this.blendEquation = BLENDEQUATION_ADD;
-                break;
-            case BLEND_MULTIPLICATIVE2X:
-                this.blendSrc = BLENDMODE_DST_COLOR;
-                this.blendDst = BLENDMODE_SRC_COLOR;
-                this.blendEquation = BLENDEQUATION_ADD;
-                break;
-            case BLEND_SCREEN:
-                this.blendSrc = BLENDMODE_ONE_MINUS_DST_COLOR;
-                this.blendDst = BLENDMODE_ONE;
-                this.blendEquation = BLENDEQUATION_ADD;
-                break;
-            case BLEND_MULTIPLICATIVE:
-                this.blendSrc = BLENDMODE_DST_COLOR;
-                this.blendDst = BLENDMODE_ZERO;
-                this.blendEquation = BLENDEQUATION_ADD;
-                break;
-            case BLEND_MIN:
-                this.blendSrc = BLENDMODE_ONE;
-                this.blendDst = BLENDMODE_ONE;
-                this.blendEquation = BLENDEQUATION_MIN;
-                break;
-            case BLEND_MAX:
-                this.blendSrc = BLENDMODE_ONE;
-                this.blendDst = BLENDMODE_ONE;
-                this.blendEquation = BLENDEQUATION_MAX;
-                break;
-        }
-        if (this.blend !== blend) {
-            this.blend = blend;
-            if (this._scene) {
-                this._scene.layers._dirtyBlend = true;
-            } else {
-                this._dirtyBlend = true;
-            }
+
+        const blendMode = blendModes[type];
+        Debug.assert(blendMode, `Unknown blend mode ${type}`);
+        this._blendState.setColorBlend(blendMode.op, blendMode.src, blendMode.dst);
+        this._blendState.setAlphaBlend(blendMode.op, blendMode.src, blendMode.dst);
+
+        const blend = type !== BLEND_NONE;
+        if (this._blendState.blend !== blend) {
+            this._blendState.blend = blend;
+            this._markBlendDirty();
         }
         this._updateMeshInstanceKeys();
     }
 
     get blendType() {
-        if (!this.blend) {
+        if (!this.transparent) {
             return BLEND_NONE;
         }
 
-        if ((this.blendSrc === BLENDMODE_SRC_ALPHA) && (this.blendDst === BLENDMODE_ONE_MINUS_SRC_ALPHA) &&
-            (this.blendEquation === BLENDEQUATION_ADD)) {
-            return BLEND_NORMAL;
-        }
+        const { colorOp, colorSrcFactor, colorDstFactor, alphaOp, alphaSrcFactor, alphaDstFactor } = this._blendState;
 
-        if ((this.blendSrc === BLENDMODE_ONE) && (this.blendDst === BLENDMODE_ONE) &&
-            (this.blendEquation === BLENDEQUATION_ADD)) {
-            return BLEND_ADDITIVE;
-        }
-
-        if ((this.blendSrc === BLENDMODE_SRC_ALPHA) && (this.blendDst === BLENDMODE_ONE) &&
-            (this.blendEquation === BLENDEQUATION_ADD)) {
-            return BLEND_ADDITIVEALPHA;
-        }
-
-        if ((this.blendSrc === BLENDMODE_DST_COLOR) && (this.blendDst === BLENDMODE_SRC_COLOR) &&
-            (this.blendEquation === BLENDEQUATION_ADD)) {
-            return BLEND_MULTIPLICATIVE2X;
-        }
-
-        if ((this.blendSrc === BLENDMODE_ONE_MINUS_DST_COLOR) && (this.blendDst === BLENDMODE_ONE) &&
-            (this.blendEquation === BLENDEQUATION_ADD)) {
-            return BLEND_SCREEN;
-        }
-
-        if ((this.blendSrc === BLENDMODE_ONE) && (this.blendDst === BLENDMODE_ONE) &&
-            (this.blendEquation === BLENDEQUATION_MIN)) {
-            return BLEND_MIN;
-        }
-
-        if ((this.blendSrc === BLENDMODE_ONE) && (this.blendDst === BLENDMODE_ONE) &&
-            (this.blendEquation === BLENDEQUATION_MAX)) {
-            return BLEND_MAX;
-        }
-
-        if ((this.blendSrc === BLENDMODE_DST_COLOR) && (this.blendDst === BLENDMODE_ZERO) &&
-            (this.blendEquation === BLENDEQUATION_ADD)) {
-            return BLEND_MULTIPLICATIVE;
-        }
-
-        if ((this.blendSrc === BLENDMODE_ONE) && (this.blendDst === BLENDMODE_ONE_MINUS_SRC_ALPHA) &&
-            (this.blendEquation === BLENDEQUATION_ADD)) {
-            return BLEND_PREMULTIPLIED;
+        for (let i = 0; i < blendModes.length; i++) {
+            const blendMode = blendModes[i];
+            if (blendMode.src === colorSrcFactor && blendMode.dst === colorDstFactor && blendMode.op === colorOp &&
+                blendMode.src === alphaSrcFactor && blendMode.dst === alphaDstFactor && blendMode.op === alphaOp) {
+                return i;
+            }
         }
 
         return BLEND_NORMAL;
@@ -402,15 +373,7 @@ class Material {
         this.alphaTest = source.alphaTest;
         this.alphaToCoverage = source.alphaToCoverage;
 
-        this.blend = source.blend;
-        this.blendSrc = source.blendSrc;
-        this.blendDst = source.blendDst;
-        this.blendEquation = source.blendEquation;
-
-        this.separateAlphaBlend = source.separateAlphaBlend;
-        this.blendSrcAlpha = source.blendSrcAlpha;
-        this.blendDstAlpha = source.blendDstAlpha;
-        this.blendAlphaEquation = source.blendAlphaEquation;
+        this._blendState.copy(source._blendState);
 
         this.cull = source.cull;
 
@@ -428,11 +391,6 @@ class Material {
             }
         }
 
-        this.redWrite = source.redWrite;
-        this.greenWrite = source.greenWrite;
-        this.blueWrite = source.blueWrite;
-        this.alphaWrite = source.alphaWrite;
-
         return this;
     }
 
diff --git a/src/scene/morph-instance.js b/src/scene/morph-instance.js
index d9a3a5da2..03e33346a 100644
--- a/src/scene/morph-instance.js
+++ b/src/scene/morph-instance.js
@@ -6,6 +6,7 @@ import { RenderTarget } from '../platform/graphics/render-target.js';
 import { DebugGraphics } from '../platform/graphics/debug-graphics.js';
 
 import { createShaderFromCode } from './shader-lib/utils.js';
+import { BlendState } from '../platform/graphics/blend-state.js';
 
 // vertex shader used to add morph targets from textures into render target
 const textureMorphVertexShader = `
@@ -17,6 +18,8 @@ const textureMorphVertexShader = `
     }
     `;
 
+const blendStateAdditive = new BlendState(true, BLENDEQUATION_ADD, BLENDMODE_ONE, BLENDMODE_ONE);
+
 /**
  * An instance of {@link Morph}. Contains weights to assign to every {@link MorphTarget}, manages
  * selection of active morph targets.
@@ -264,15 +267,11 @@ class MorphInstance {
             this.morphFactor.setValue(this._shaderMorphWeights);
 
             // alpha blending - first pass gets none, following passes are additive
-            device.setBlending(blending);
-            if (blending) {
-                device.setBlendFunction(BLENDMODE_ONE, BLENDMODE_ONE);
-                device.setBlendEquation(BLENDEQUATION_ADD);
-            }
+            device.setBlendState(blending ? blendStateAdditive : BlendState.DEFAULT);
 
             // render quad with shader for required number of textures
             const shader = this._getShader(usedCount);
-            drawQuadWithShader(device, renderTarget, shader, undefined, undefined, blending);
+            drawQuadWithShader(device, renderTarget, shader);
         };
 
         // set up parameters for active blend targets
diff --git a/src/scene/particle-system/gpu-updater.js b/src/scene/particle-system/gpu-updater.js
index d2e406e88..f7ddfc7ef 100644
--- a/src/scene/particle-system/gpu-updater.js
+++ b/src/scene/particle-system/gpu-updater.js
@@ -5,6 +5,8 @@ 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 { drawQuadWithShader } from '../graphics/quad-render-utils.js';
 
 import { EMITTERSHAPE_BOX } from '../constants.js';
@@ -89,8 +91,7 @@ class ParticleGPUUpdater {
 
         const emitter = this._emitter;
 
-        device.setBlending(false);
-        device.setColorWrite(true, true, true, true);
+        device.setBlendState(BlendState.DEFAULT);
         device.setCullMode(CULLFACE_NONE);
         device.setDepthTest(false);
         device.setDepthWrite(false);
diff --git a/src/scene/renderer/cookie-renderer.js b/src/scene/renderer/cookie-renderer.js
index 53af410c2..0f46bbf04 100644
--- a/src/scene/renderer/cookie-renderer.js
+++ b/src/scene/renderer/cookie-renderer.js
@@ -9,6 +9,7 @@ import { Texture } from '../../platform/graphics/texture.js';
 import { LIGHTTYPE_OMNI } from '../constants.js';
 import { createShaderFromCode } from '../shader-lib/utils.js';
 import { LightCamera } from './light-camera.js';
+import { BlendState } from '../../platform/graphics/blend-state.js';
 
 const textureBlitVertexShader = `
     attribute vec2 vertex_position;
@@ -126,6 +127,9 @@ class CookieRenderer {
             // source texture
             this.blitTextureId.setValue(light.cookie);
 
+            // render state
+            device.setBlendState(BlendState.DEFAULT);
+
             // render it to a viewport of the target
             for (let face = 0; face < faceCount; face++) {
 
diff --git a/src/scene/renderer/forward-renderer.js b/src/scene/renderer/forward-renderer.js
index 0f8c46a90..677cf86cc 100644
--- a/src/scene/renderer/forward-renderer.js
+++ b/src/scene/renderer/forward-renderer.js
@@ -24,6 +24,7 @@ import { Renderer } from './renderer.js';
 import { LightCamera } from './light-camera.js';
 import { WorldClustersDebug } from '../lighting/world-clusters-debug.js';
 import { SceneGrab } from '../graphics/scene-grab.js';
+import { BlendState } from '../../platform/graphics/blend-state.js';
 
 const webgl1DepthClearColor = new Color(254.0 / 255, 254.0 / 255, 254.0 / 255, 254.0 / 255);
 
@@ -601,17 +602,7 @@ class ForwardRenderer extends Renderer {
 
                     this.alphaTestId.setValue(material.alphaTest);
 
-                    device.setBlending(material.blend);
-                    if (material.blend) {
-                        if (material.separateAlphaBlend) {
-                            device.setBlendFunctionSeparate(material.blendSrc, material.blendDst, material.blendSrcAlpha, material.blendDstAlpha);
-                            device.setBlendEquationSeparate(material.blendEquation, material.blendAlphaEquation);
-                        } else {
-                            device.setBlendFunction(material.blendSrc, material.blendDst);
-                            device.setBlendEquation(material.blendEquation);
-                        }
-                    }
-                    device.setColorWrite(material.redWrite, material.greenWrite, material.blueWrite, material.alphaWrite);
+                    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
@@ -1162,7 +1153,7 @@ class ForwardRenderer extends Renderer {
             // Revert temp frame stuff
             // TODO: this should not be here, as each rendering / clearing should explicitly set up what
             // it requires (the properties are part of render pipeline on WebGPU anyways)
-            device.setColorWrite(true, true, true, true);
+            device.setBlendState(BlendState.DEFAULT);
             device.setStencilTest(false); // don't leak stencil state
             device.setAlphaToCoverage(false); // don't leak a2c state
             device.setDepthBias(false);
diff --git a/src/scene/renderer/shadow-renderer.js b/src/scene/renderer/shadow-renderer.js
index 5f1009953..a20e48dfa 100644
--- a/src/scene/renderer/shadow-renderer.js
+++ b/src/scene/renderer/shadow-renderer.js
@@ -23,6 +23,7 @@ import { createShaderFromCode } from '../shader-lib/utils.js';
 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';
 
 function gauss(x, sigma) {
     return Math.exp(-(x * x) / (2.0 * sigma * sigma));
@@ -112,6 +113,11 @@ class ShadowRenderer {
         // view bind group format with its uniform buffer format
         this.viewUniformFormat = null;
         this.viewBindGroupFormat = null;
+
+        // blend states
+        this.blendStateWrite = new BlendState();
+        this.blendStateNoWrite = new BlendState();
+        this.blendStateNoWrite.setColorWrite(false, false, false, false);
     }
 
     // creates shadow camera for a light and sets up its constant properties
@@ -190,7 +196,6 @@ class ShadowRenderer {
         }
 
         // Set standard shadowmap states
-        device.setBlending(false);
         device.setDepthWrite(true);
         device.setDepthTest(true);
         device.setDepthFunc(FUNC_LESSEQUAL);
@@ -198,11 +203,8 @@ class ShadowRenderer {
         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)
-        if (useShadowSampler) {
-            device.setColorWrite(false, false, false, false);
-        } else {
-            device.setColorWrite(true, true, true, true);
-        }
+
+        device.setBlendState(useShadowSampler ? this.blendStateNoWrite : this.blendStateWrite);
     }
 
     restoreRenderState(device) {
@@ -483,6 +485,9 @@ class ShadowRenderer {
 
         DebugGraphics.pushGpuMarker(device, `VSM ${light._node.name}`);
 
+        // render state
+        device.setBlendState(BlendState.DEFAULT);
+
         const lightRenderData = light.getRenderData(light._type === LIGHTTYPE_DIRECTIONAL ? camera : null, 0);
         const shadowCam = lightRenderData.shadowCamera;
         const origShadowMap = shadowCam.renderTarget;
diff --git a/src/scene/shader-lib/chunks/lit/frag/clusteredLight.js b/src/scene/shader-lib/chunks/lit/frag/clusteredLight.js
index c2bc53476..5254a7ba3 100644
--- a/src/scene/shader-lib/chunks/lit/frag/clusteredLight.js
+++ b/src/scene/shader-lib/chunks/lit/frag/clusteredLight.js
@@ -1,6 +1,6 @@
 export default /* glsl */`
-uniform sampler2D clusterWorldTexture;
-uniform sampler2D lightsTexture8;
+uniform highp sampler2D clusterWorldTexture;
+uniform highp sampler2D lightsTexture8;
 uniform highp sampler2D lightsTextureFloat;
 
 // complex ifdef expression are not supported, handle it here
@@ -594,6 +594,10 @@ void addClusteredLights() {
 
                 // using a single channel texture with data in alpha channel
                 float lightIndex = texelFetch(clusterWorldTexture, ivec2(int(clusterU) + lightCellIndex, clusterV), 0).x;
+
+                if (lightIndex <= 0.0)
+                        return;
+
                 evaluateClusterLight(lightIndex * 255.0); 
             }
 
diff --git a/src/scene/shader-lib/programs/lit-shader.js b/src/scene/shader-lib/programs/lit-shader.js
index 00019b799..3be4d8143 100644
--- a/src/scene/shader-lib/programs/lit-shader.js
+++ b/src/scene/shader-lib/programs/lit-shader.js
@@ -50,6 +50,13 @@ const builtinVaryings = {
 };
 
 class LitShader {
+    /**
+     * @param {import('../../../platform/graphics/graphics-device.js').GraphicsDevice} device - The
+     * graphics device.
+     * @param {import('../../../scene/materials/lit-options.js').LitOptions} options - The
+     * lit options.
+     * @ignore
+     */
     constructor(device, options) {
         this.device = device;
         this.options = options;
diff --git a/src/scene/shader-lib/programs/standard.js b/src/scene/shader-lib/programs/standard.js
index 84a8e94af..4509d5328 100644
--- a/src/scene/shader-lib/programs/standard.js
+++ b/src/scene/shader-lib/programs/standard.js
@@ -232,7 +232,13 @@ const standard = {
         }
     },
 
-    /** @type { Function } */
+    /**
+     * @param {import('../../../platform/graphics/graphics-device.js').GraphicsDevice} device - The
+     * graphics device.
+     * @param {StandardMaterialOptions} options - The create options.
+     * @returns {object} Returns the created shader definition.
+     * @ignore
+     */
     createShaderDefinition: function (device, options) {
         const litShader = new LitShader(device, options.litOptions);
 
@@ -450,8 +456,8 @@ const standard = {
             }
 
             // lightmap
-            if (options.litOptions.lightMapEnabled || options.lightMapVertexColors) {
-                const lightmapDir = (options.litOptions.dirLightMapEnabled && options.litOptions.useSpecular);
+            if (options.lightMap || options.lightVertexColor) {
+                const lightmapDir = (options.dirLightMap && options.litOptions.useSpecular);
                 const lightmapChunkPropName = lightmapDir ? 'lightmapDirPS' : 'lightmapSinglePS';
                 decl.append("vec3 dLightmap;");
                 if (lightmapDir) {


Test patch:
diff --git a/test/platform/graphics/blend-state.test.mjs b/test/platform/graphics/blend-state.test.mjs
new file mode 100644
index 000000000..f036d1225
--- /dev/null
+++ b/test/platform/graphics/blend-state.test.mjs
@@ -0,0 +1,62 @@
+import { BlendState } from '../../../src/platform/graphics/blend-state.js';
+import {
+    BLENDEQUATION_ADD, BLENDEQUATION_MAX, BLENDEQUATION_MIN, BLENDMODE_ONE, BLENDMODE_ZERO,
+    BLENDMODE_ONE_MINUS_DST_COLOR, BLENDMODE_SRC_ALPHA_SATURATE
+} from '../../../src/platform/graphics/constants.js';
+
+import { expect } from 'chai';
+
+describe('BlendState', function () {
+
+    describe('#constructor', function () {
+
+        it('empty', function () {
+            const bs = new BlendState();
+            expect(bs.blend).to.equal(false);
+            expect(bs.colorOp).to.equal(BLENDEQUATION_ADD);
+            expect(bs.colorSrcFactor).to.equal(BLENDMODE_ONE);
+            expect(bs.colorDstFactor).to.equal(BLENDMODE_ZERO);
+            expect(bs.alphaOp).to.equal(BLENDEQUATION_ADD);
+            expect(bs.alphaSrcFactor).to.equal(BLENDMODE_ONE);
+            expect(bs.alphaDstFactor).to.equal(BLENDMODE_ZERO);
+            expect(bs.redWrite).to.equal(true);
+            expect(bs.greenWrite).to.equal(true);
+            expect(bs.blueWrite).to.equal(true);
+            expect(bs.alphaWrite).to.equal(true);
+        });
+
+        it('minimal parameters', function () {
+            const bs = new BlendState(true, BLENDEQUATION_ADD, BLENDMODE_ONE, BLENDMODE_ZERO);
+            expect(bs.blend).to.equal(true);
+            expect(bs.colorOp).to.equal(BLENDEQUATION_ADD);
+            expect(bs.colorSrcFactor).to.equal(BLENDMODE_ONE);
+            expect(bs.colorDstFactor).to.equal(BLENDMODE_ZERO);
+            expect(bs.alphaOp).to.equal(BLENDEQUATION_ADD);
+            expect(bs.alphaSrcFactor).to.equal(BLENDMODE_ONE);
+            expect(bs.alphaDstFactor).to.equal(BLENDMODE_ZERO);
+            expect(bs.redWrite).to.equal(true);
+            expect(bs.greenWrite).to.equal(true);
+            expect(bs.blueWrite).to.equal(true);
+            expect(bs.alphaWrite).to.equal(true);
+        });
+
+        it('full parameters', function () {
+            const bs = new BlendState(true, BLENDEQUATION_MIN, BLENDMODE_ONE, BLENDMODE_ZERO,
+                                      BLENDEQUATION_MAX, BLENDMODE_ONE_MINUS_DST_COLOR, BLENDMODE_SRC_ALPHA_SATURATE,
+                                      false, false, false, false);
+            expect(bs.blend).to.equal(true);
+            expect(bs.colorOp).to.equal(BLENDEQUATION_MIN);
+            expect(bs.colorSrcFactor).to.equal(BLENDMODE_ONE);
+            expect(bs.colorDstFactor).to.equal(BLENDMODE_ZERO);
+            expect(bs.alphaOp).to.equal(BLENDEQUATION_MAX);
+            expect(bs.alphaSrcFactor).to.equal(BLENDMODE_ONE_MINUS_DST_COLOR);
+            expect(bs.alphaDstFactor).to.equal(BLENDMODE_SRC_ALPHA_SATURATE);
+            expect(bs.redWrite).to.equal(false);
+            expect(bs.greenWrite).to.equal(false);
+            expect(bs.blueWrite).to.equal(false);
+            expect(bs.alphaWrite).to.equal(false);
+        });
+
+    });
+
+});