Repository: playcanvas/engine
Pull request #5941 (2024-01-15 13:44:50)

Updated dithered transparency to support blue noise
Update to transparency dithering introduced here: #5903, which is not released yet and so API changes are fine.

New API (booleans were changed to enums to allow different options)
```
StandardMaterial.opacityDither = DITHER_BAYER8 / DITHER_BLUENOISE / DITHER_NONE;
StandardMaterial.opacityShadowDither = DITHER_BAYER8 / DITHER_BLUENOISE / DITHER_NONE;
```

- added internal data storing 32x32 4 channel blue noise, which will be used for multiple effects together with TAA
- the data gets also uploaded as a texture (initially used for blue noise dithering only)
- when the camera jitter is enabled (TAA), both bayer and blue noise pattern are randomly per frame offset by a blue noise based offset

Existing bayer dithering:
![Screenshot 2024-01-15 at 13 50 21](https://github.com/playcanvas/engine/assets/59932779/74e971a0-4b4b-4625-80b7-b61627d79f35)

Blue noise based (better with TAA):
![Screenshot 2024-01-15 at 13 50 37](https://github.com/playcanvas/engine/assets/59932779/3e01ac8b-bd07-4069-9a8c-1183d2a96821)

Related: https://github.com/playcanvas/engine/issues/5943


Patch:
diff --git a/examples/src/examples/graphics/dithered-transparency.mjs b/examples/src/examples/graphics/dithered-transparency.mjs
index c954d252e..b73527c0b 100644
--- a/examples/src/examples/graphics/dithered-transparency.mjs
+++ b/examples/src/examples/graphics/dithered-transparency.mjs
@@ -5,7 +5,7 @@ import * as pc from 'playcanvas';
  * @returns {JSX.Element} The returned JSX Element.
  */
 function controls({ observer, ReactPCUI, React, jsx, fragment }) {
-    const { BindingTwoWay, LabelGroup, Panel, SliderInput, BooleanInput } = ReactPCUI;
+    const { BindingTwoWay, LabelGroup, Panel, SliderInput, BooleanInput, SelectInput } = ReactPCUI;
     return fragment(
         jsx(Panel, { headerText: 'Settings' },
             jsx(LabelGroup, { text: 'Opacity' },
@@ -18,19 +18,27 @@ function controls({ observer, ReactPCUI, React, jsx, fragment }) {
                 })
             ),
             jsx(LabelGroup, { text: 'Dither Color' },
-                jsx(BooleanInput, {
-                    type: 'toggle',
+                jsx(SelectInput, {
                     binding: new BindingTwoWay(),
                     link: { observer, path: 'data.opacityDither' },
-                    value: true
+                    type: "string",
+                    options: [
+                        { v: pc.DITHER_NONE, t: 'None' },
+                        { v: pc.DITHER_BAYER8, t: 'Bayer8' },
+                        { v: pc.DITHER_BLUENOISE, t: 'BlueNoise' }
+                    ]
                 })
             ),
             jsx(LabelGroup, { text: 'Dither Shadow' },
-                jsx(BooleanInput, {
-                    type: 'toggle',
+                jsx(SelectInput, {
                     binding: new BindingTwoWay(),
                     link: { observer, path: 'data.opacityShadowDither' },
-                    value: true
+                    type: "string",
+                    options: [
+                        { v: pc.DITHER_NONE, t: 'None' },
+                        { v: pc.DITHER_BAYER8, t: 'Bayer8' },
+                        { v: pc.DITHER_BLUENOISE, t: 'BlueNoise' }
+                    ]
                 })
             )
         )
@@ -62,6 +70,9 @@ async function example({ canvas, deviceType, assetPath, glslangPath, twgslPath,
     createOptions.touch = new pc.TouchDevice(document.body);
     createOptions.keyboard = new pc.Keyboard(document.body);
 
+    // render at full native resolution
+    device.maxPixelRatio = window.devicePixelRatio;
+
     createOptions.componentSystems = [
         pc.RenderComponentSystem,
         pc.CameraComponentSystem,
@@ -197,7 +208,7 @@ async function example({ canvas, deviceType, assetPath, glslangPath, twgslPath,
 
                 // turn on / off blending depending on the dithering of the color
                 if (propertyName === 'opacityDither') {
-                    material.blendType = value ? pc.BLEND_NONE : pc.BLEND_NORMAL;
+                    material.blendType = value === pc.DITHER_NONE ? pc.BLEND_NORMAL : pc.BLEND_NONE;
                 }
                 material.update();
             });
@@ -206,8 +217,8 @@ async function example({ canvas, deviceType, assetPath, glslangPath, twgslPath,
         // initial values
         data.set('data', {
             opacity: 0.5,
-            opacityDither: true,
-            opacityShadowDither: true
+            opacityDither: pc.DITHER_BAYER8,
+            opacityShadowDither: pc.DITHER_BAYER8
         });
     });
     return app;
diff --git a/extras/splat/shader-generator-splat.js b/extras/splat/shader-generator-splat.js
index 5d45b5e30..0c18a6968 100644
--- a/extras/splat/shader-generator-splat.js
+++ b/extras/splat/shader-generator-splat.js
@@ -1,5 +1,6 @@
 import {
     ShaderUtils,
+    DITHER_NONE,
     SEMANTIC_ATTR13,
     SEMANTIC_POSITION,
     ShaderGenerator,
@@ -28,6 +29,7 @@ const splatCoreVS = `
 
     varying vec2 texCoord;
     varying vec4 color;
+    varying float id;
 
     mat3 quatToMat3(vec3 R)
     {
@@ -233,12 +235,15 @@ const splatCoreVS = `
                 vec4((vertex_position.x * v1 + vertex_position.y * v2) / viewport * 2.0,
                     0.0, 0.0) * splat_proj.w;
         #endif
+
+        id = float(vertex_id);
     }
 `;
 
 const splatCoreFS = /* glsl_ */ `
     varying vec2 texCoord;
     varying vec4 color;
+    varying float id;
 
     vec4 evalSplat() {
 
@@ -253,6 +258,10 @@ const splatCoreFS = /* glsl_ */ `
             if (A < -4.0) discard;
             float B = exp(A) * color.a;
 
+            #ifndef DITHER_NONE
+                opacityDither(B, id * 0.013);
+            #endif
+
             // the color here is in gamma space, so bring it to linear
             vec3 diffuse = decodeGamma(color.rgb);
 
@@ -260,10 +269,6 @@ const splatCoreFS = /* glsl_ */ `
             diffuse = toneMap(diffuse);
             diffuse = gammaCorrectOutput(diffuse);
 
-            #ifdef DITHER
-                opacityDither(B);
-            #endif
-
             return vec4(diffuse, B);
 
         #endif
@@ -282,11 +287,11 @@ class ShaderGeneratorSplat {
         const defines =
             (options.debugRender ? '#define DEBUG_RENDER\n' : '') +
             (device.isWebGL1 ? '' : '#define INT_INDICES\n') +
-            (options.dither ? '#define DITHER\n' : '');
+            `#define DITHER_${options.dither.toUpperCase()}\n`;
 
         const vs = defines + splatCoreVS + options.vertex;
         const fs = defines + shaderChunks.decodePS +
-            (options.dither ? shaderChunks.bayerPS + shaderChunks.opacityDitherPS : '') +
+            (options.dither === DITHER_NONE ? '' : shaderChunks.bayerPS + shaderChunks.opacityDitherPS) +
             ShaderGenerator.tonemapCode(options.toneMapping) +
             ShaderGenerator.gammaCode(options.gamma) +
             splatCoreFS + options.fragment;
diff --git a/extras/splat/splat-instance.js b/extras/splat/splat-instance.js
index 0991bd42a..316fa563b 100644
--- a/extras/splat/splat-instance.js
+++ b/extras/splat/splat-instance.js
@@ -5,6 +5,7 @@ import {
     Mat4,
     createBox,
     BUFFER_DYNAMIC,
+    DITHER_NONE,
     VertexBuffer
 } from "playcanvas";
 
@@ -96,7 +97,7 @@ class SplatInstance {
         // clone centers to allow multiple instancing of sorter
         this.centers = new Float32Array(splat.centers);
 
-        if (!options.dither) {
+        if (options.dither !== DITHER_NONE) {
             this.sorter = new SplatSorter();
             this.sorter.init(this.vb, this.centers, !this.splat.device.isWebGL1);
 
diff --git a/extras/splat/splat-material.js b/extras/splat/splat-material.js
index db5d9cd89..891289c89 100644
--- a/extras/splat/splat-material.js
+++ b/extras/splat/splat-material.js
@@ -5,6 +5,7 @@ import {
     CULLFACE_BACK,
     CULLFACE_NONE,
     Material,
+    DITHER_NONE,
     SHADER_FORWARDHDR,
     TONEMAP_LINEAR,
     GAMMA_SRGBHDR,
@@ -45,12 +46,13 @@ const splatMainFS = `
  */
 const createSplatMaterial = (options = {}) => {
 
-    const { debugRender, dither } = options;
+    const { debugRender } = options;
+    const dither = options.dither ?? DITHER_NONE;
 
     const material = new Material();
     material.name = 'splatMaterial';
     material.cull = debugRender ? CULLFACE_BACK : CULLFACE_NONE;
-    material.blendType = dither ? BLEND_NONE : BLEND_NORMAL;
+    material.blendType = dither === DITHER_NONE ? BLEND_NORMAL : BLEND_NONE;
     material.depthWrite = dither;
 
     material.getShaderVariant = function (device, scene, defs, unused, pass, sortedLights, viewUniformFormat, viewBindGroupFormat) {
@@ -62,7 +64,7 @@ const createSplatMaterial = (options = {}) => {
             vertex: options.vertex ?? splatMainVS,
             fragment: options.fragment ?? splatMainFS,
             debugRender: debugRender,
-            dither: !!dither
+            dither: dither
         };
 
         const processingOptions = new ShaderProcessorOptions(viewUniformFormat, viewBindGroupFormat);
diff --git a/src/core/math/blue-noise.js b/src/core/math/blue-noise.js
new file mode 100644
index 000000000..8b0ed6adb
--- /dev/null
+++ b/src/core/math/blue-noise.js
@@ -0,0 +1,50 @@
+import { Vec4 } from "./vec4.js";
+
+// this is a 32x32 blue noise texture LDR_RGBA_53.png from the database mentioned here: https://momentsingraphics.de/BlueNoise.html
+const base64String = "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";
+
+let data = null;
+const initData = () => {
+    if (!data) {
+        const binaryString = atob(base64String);
+
+        // the data stores an array of RGBA 8-bit values, each of the color channels represents
+        // a separate blue noise stream
+        data = Uint8Array.from(binaryString, char => char.charCodeAt(0));
+    }
+};
+
+const blueNoiseData = () => {
+    initData();
+    return data;
+};
+
+/**
+ * Blue noise based random numbers API.
+ *
+ * @ignore
+ */
+class BlueNoise {
+    seed = 0;
+
+    constructor(seed = 0) {
+        this.seed = seed * 4;
+        initData();
+    }
+
+    _next() {
+        this.seed = (this.seed + 4) % data.length;
+    }
+
+    value() {
+        this._next();
+        return data[this.seed] / 255;
+    }
+
+    vec4(dest = new Vec4()) {
+        this._next();
+        return dest.set(data[this.seed], data[this.seed + 1], data[this.seed + 2], data[this.seed + 3]).mulScalar(1 / 255);
+    }
+}
+
+export { BlueNoise, blueNoiseData };
diff --git a/src/scene/constants.js b/src/scene/constants.js
index 656efeb39..6d2ac1cc6 100644
--- a/src/scene/constants.js
+++ b/src/scene/constants.js
@@ -887,3 +887,24 @@ export const SKYTYPE_BOX = 'box';
  * @type {string}
  */
 export const SKYTYPE_DOME = 'dome';
+
+/**
+ * Opacity dithering is disabled.
+ *
+ * @type {string}
+ */
+export const DITHER_NONE = 'none';
+
+/**
+ * Opacity is dithered using a Bayer 8 matrix.
+ *
+ * @type {string}
+ */
+export const DITHER_BAYER8 = 'bayer8';
+
+/**
+ * Opacity is dithered using a blue noise texture.
+ *
+ * @type {string}
+ */
+export const DITHER_BLUENOISE = 'bluenoise';
diff --git a/src/scene/graphics/blue-noise-texture.js b/src/scene/graphics/blue-noise-texture.js
new file mode 100644
index 000000000..dda32a6c6
--- /dev/null
+++ b/src/scene/graphics/blue-noise-texture.js
@@ -0,0 +1,37 @@
+import { blueNoiseData } from "../../core/math/blue-noise.js";
+import { ADDRESS_REPEAT, FILTER_NEAREST, PIXELFORMAT_RGBA8, TEXTURETYPE_DEFAULT } from "../../platform/graphics/constants.js";
+import { DeviceCache } from "../../platform/graphics/device-cache.js";
+import { Texture } from "../../platform/graphics/texture.js";
+
+// device cache storing the blue noise texture for the device
+const deviceCache = new DeviceCache();
+
+function getBlueNoiseTexture(device) {
+
+    return deviceCache.get(device, () => {
+
+        const data = blueNoiseData();
+        const size = Math.sqrt(data.length / 4);
+
+        const texture = new Texture(device, {
+            name: `BlueNoise${size}`,
+            width: size,
+            height: size,
+            format: PIXELFORMAT_RGBA8,
+            addressU: ADDRESS_REPEAT,
+            addressV: ADDRESS_REPEAT,
+            type: TEXTURETYPE_DEFAULT,
+            magFilter: FILTER_NEAREST,
+            minFilter: FILTER_NEAREST,
+            anisotropy: 1,
+            mipmaps: false
+        });
+
+        texture.lock().set(data);
+        texture.unlock();
+
+        return texture;
+    });
+}
+
+export { getBlueNoiseTexture };
diff --git a/src/scene/materials/lit-material.js b/src/scene/materials/lit-material.js
index a18e0f408..d926550f2 100644
--- a/src/scene/materials/lit-material.js
+++ b/src/scene/materials/lit-material.js
@@ -1,5 +1,5 @@
 import { ShaderProcessorOptions } from '../../platform/graphics/shader-processor-options.js';
-import { FRESNEL_SCHLICK, SPECOCC_AO, SPECULAR_BLINN } from "../constants.js";
+import { DITHER_NONE, FRESNEL_SCHLICK, SPECOCC_AO, SPECULAR_BLINN } from "../constants.js";
 import { Material } from './material.js';
 import { LitMaterialOptions } from './lit-material-options.js';
 import { LitMaterialOptionsBuilder } from './lit-material-options-builder.js';
@@ -53,9 +53,9 @@ class LitMaterial extends Material {
 
     opacityFadesSpecular = true;
 
-    opacityDither = false;
+    opacityDither = DITHER_NONE;
 
-    opacityShadowDither = false;
+    opacityShadowDither = DITHER_NONE;
 
     conserveEnergy = true;
 
diff --git a/src/scene/materials/standard-material-options-builder.js b/src/scene/materials/standard-material-options-builder.js
index a395bb648..ee14c3c9a 100644
--- a/src/scene/materials/standard-material-options-builder.js
+++ b/src/scene/materials/standard-material-options-builder.js
@@ -11,7 +11,8 @@ import {
     SHADERDEF_DIRLM, SHADERDEF_INSTANCING, SHADERDEF_LM, SHADERDEF_MORPH_POSITION, SHADERDEF_MORPH_NORMAL, SHADERDEF_NOSHADOW, SHADERDEF_MORPH_TEXTURE_BASED,
     SHADERDEF_SCREENSPACE, SHADERDEF_SKIN, SHADERDEF_TANGENTS, SHADERDEF_UV0, SHADERDEF_UV1, SHADERDEF_VCOLOR, SHADERDEF_LMAMBIENT,
     TONEMAP_LINEAR,
-    SPECULAR_PHONG
+    SPECULAR_PHONG,
+    DITHER_NONE
 } from '../constants.js';
 import { _matTex2D } from '../shader-lib/programs/standard.js';
 import { LitMaterialOptionsBuilder } from './lit-material-options-builder.js';
@@ -150,7 +151,7 @@ class StandardMaterialOptionsBuilder {
             options[vname] = false;
             options[vcname] = '';
 
-            if (isOpacity && stdMat.blendType === BLEND_NONE && stdMat.alphaTest === 0.0 && !stdMat.alphaToCoverage && !stdMat.opacityDither) {
+            if (isOpacity && stdMat.blendType === BLEND_NONE && stdMat.alphaTest === 0.0 && !stdMat.alphaToCoverage && stdMat.opacityDither === DITHER_NONE) {
                 return;
             }
 
@@ -188,7 +189,7 @@ class StandardMaterialOptionsBuilder {
     }
 
     _updateMinOptions(options, stdMat) {
-        options.opacityTint = stdMat.opacity !== 1 && (stdMat.blendType !== BLEND_NONE || stdMat.opacityShadowDither);
+        options.opacityTint = stdMat.opacity !== 1 && (stdMat.blendType !== BLEND_NONE || stdMat.opacityShadowDither !== DITHER_NONE);
         options.litOptions.opacityShadowDither = stdMat.opacityShadowDither;
         options.litOptions.lights = [];
     }
@@ -215,7 +216,7 @@ class StandardMaterialOptionsBuilder {
 
         const isPackedNormalMap = stdMat.normalMap ? (stdMat.normalMap.format === PIXELFORMAT_DXT5 || stdMat.normalMap.type === TEXTURETYPE_SWIZZLEGGGR) : false;
 
-        options.opacityTint = (stdMat.opacity !== 1 && (stdMat.blendType !== BLEND_NONE || stdMat.alphaTest > 0 || stdMat.opacityDither)) ? 1 : 0;
+        options.opacityTint = (stdMat.opacity !== 1 && (stdMat.blendType !== BLEND_NONE || stdMat.alphaTest > 0 || stdMat.opacityDither !== DITHER_NONE)) ? 1 : 0;
         options.ambientTint = stdMat.ambientTint;
         options.diffuseTint = diffuseTint ? 2 : 0;
         options.specularTint = specularTint ? 2 : 0;
diff --git a/src/scene/materials/standard-material-parameters.js b/src/scene/materials/standard-material-parameters.js
index 604474a12..c805d949c 100644
--- a/src/scene/materials/standard-material-parameters.js
+++ b/src/scene/materials/standard-material-parameters.js
@@ -95,8 +95,8 @@ const standardMaterialParameterTypes = {
     opacity: 'number',
     ..._textureParameter('opacity'),
     opacityFadesSpecular: 'boolean',
-    opacityDither: 'boolean',
-    opacityShadowDither: 'boolean',
+    opacityDither: 'string',
+    opacityShadowDither: 'string',
 
     reflectivity: 'number',
     refraction: 'number',
diff --git a/src/scene/materials/standard-material.js b/src/scene/materials/standard-material.js
index f7cf5caab..e456ac81f 100644
--- a/src/scene/materials/standard-material.js
+++ b/src/scene/materials/standard-material.js
@@ -8,6 +8,7 @@ import { ShaderProcessorOptions } from '../../platform/graphics/shader-processor
 import {
     CUBEPROJ_BOX, CUBEPROJ_NONE,
     DETAILMODE_MUL,
+    DITHER_NONE,
     FRESNEL_SCHLICK,
     SHADER_DEPTH, SHADER_PICK,
     SPECOCC_AO,
@@ -375,10 +376,22 @@ let _params = new Set();
  * @property {boolean} opacityFadesSpecular Used to specify whether specular and reflections are
  * faded out using {@link StandardMaterial#opacity}. Default is true. When set to false use
  * {@link Material#alphaFade} to fade out materials.
- * @property {boolean} opacityDither Used to specify whether opacity is dithered, which allows
- * transparency without alpha blending. Defaults to false.
+ * @property {string} opacityDither Used to specify whether opacity is dithered, which allows
+ * transparency without alpha blending. Can be:
+ *
+ * - {@link DITHER_NONE}: Opacity dithering is disabled.
+ * - {@link DITHER_BAYER8}: Opacity is dithered using a Bayer 8 matrix.
+ * - {@link DITHER_BLUENOISE}: Opacity is dithered using a blue noise texture.
+ *
+ * Defaults to {@link DITHER_NONE}.
  * @property {boolean} opacityShadowDither Used to specify whether shadow opacity is dithered, which
- * allows shadow transparency without alpha blending. Defaults to false.
+ * allows shadow transparency without alpha blending.  Can be:
+ *
+ * - {@link DITHER_NONE}: Opacity dithering is disabled.
+ * - {@link DITHER_BAYER8}: Opacity is dithered using a Bayer 8 matrix.
+ * - {@link DITHER_BLUENOISE}: Opacity is dithered using a blue noise texture.
+ *
+ * Defaults to {@link DITHER_NONE}.
  * @property {number} alphaFade Used to fade out materials when
  * {@link StandardMaterial#opacityFadesSpecular} is set to false.
  * @property {import('../../platform/graphics/texture.js').Texture|null} normalMap The main
@@ -1232,8 +1245,8 @@ function _defineMaterialProps() {
     _defineFlag('glossInvert', false);
     _defineFlag('sheenGlossInvert', false);
     _defineFlag('clearCoatGlossInvert', false);
-    _defineFlag('opacityDither', false);
-    _defineFlag('opacityShadowDither', false);
+    _defineFlag('opacityDither', DITHER_NONE);
+    _defineFlag('opacityShadowDither', DITHER_NONE);
 
     _defineTex2D('diffuse');
     _defineTex2D('specular');
diff --git a/src/scene/renderer/renderer.js b/src/scene/renderer/renderer.js
index 1911d7f7e..1c1165f99 100644
--- a/src/scene/renderer/renderer.js
+++ b/src/scene/renderer/renderer.js
@@ -2,6 +2,7 @@ import { Debug, DebugHelper } from '../../core/debug.js';
 import { now } from '../../core/time.js';
 import { Vec2 } from '../../core/math/vec2.js';
 import { Vec3 } from '../../core/math/vec3.js';
+import { Vec4 } from '../../core/math/vec4.js';
 import { Mat3 } from '../../core/math/mat3.js';
 import { Mat4 } from '../../core/math/mat4.js';
 import { BoundingSphere } from '../../core/shape/bounding-sphere.js';
@@ -37,6 +38,8 @@ import { ShadowRendererDirectional } from './shadow-renderer-directional.js';
 import { ShadowRenderer } from './shadow-renderer.js';
 import { WorldClustersAllocator } from './world-clusters-allocator.js';
 import { RenderPassUpdateClustered } from './render-pass-update-clustered.js';
+import { getBlueNoiseTexture } from '../graphics/blue-noise-texture.js';
+import { BlueNoise } from '../../core/math/blue-noise.js';
 
 let _skinUpdateIndex = 0;
 const boneTextureSize = [0, 0, 0, 0];
@@ -48,6 +51,7 @@ const tempSphere = new BoundingSphere();
 const _flipYMat = new Mat4().setScale(1, -1, 1);
 const _tempLightSet = new Set();
 const _tempLayerSet = new Set();
+const _tempVec4 = new Vec4();
 
 // Converts a projection matrix in OpenGL style (depth range of -1..1) to a DirectX style (depth range of 0..1).
 const _fixProjRangeMat = new Mat4().set([
@@ -143,6 +147,8 @@ class Renderer {
      */
     dirLightShadows = new Map();
 
+    blueNoise = new BlueNoise(123);
+
     /**
      * Create a new instance.
      *
@@ -215,6 +221,9 @@ class Renderer {
         this.cameraParams = new Float32Array(4);
         this.cameraParamsId = scope.resolve('camera_params');
 
+        this.blueNoiseJitterId = scope.resolve('blueNoiseJitter');
+        this.blueNoiseTextureId = scope.resolve('blueNoiseTex32');
+
         this.alphaTestId = scope.resolve('alpha_ref');
         this.opacityMapId = scope.resolve('texture_opacityMap');
 
@@ -366,6 +375,7 @@ class Renderer {
 
             // camera jitter
             const { jitter } = camera;
+            let noise = Vec4.ZERO;
             if (jitter > 0) {
 
                 // render target size
@@ -386,8 +396,13 @@ class Renderer {
                 projMatSkybox = _tempProjMat5.copy(projMatSkybox);
                 projMatSkybox.data[8] = offsetX;
                 projMatSkybox.data[9] = offsetY;
+
+                // blue noise vec4 - only set when jitter is enabled
+                noise = this.blueNoise.vec4(_tempVec4);
             }
 
+            this.blueNoiseJitterId.setValue([noise.x, noise.y, noise.z, noise.w]);
+
             this.projId.setValue(projMat.data);
             this.projSkyboxId.setValue(projMatSkybox.data);
 
@@ -1207,6 +1222,11 @@ class Renderer {
         _tempSet.clear();
     }
 
+    updateFrameUniforms() {
+        // blue noise texture
+        this.blueNoiseTextureId.setValue(getBlueNoiseTexture(this.device));
+    }
+
     /**
      * @param {import('../composition/layer-composition.js').LayerComposition} comp - The layer
      * composition to update.
@@ -1257,6 +1277,8 @@ class Renderer {
             scene._shaderVersion++;
         }
 
+        this.updateFrameUniforms();
+
         // Update all skin matrices to properly cull skinned objects (but don't update rendering data yet)
         this.updateCpuSkinMatrices(_tempMeshInstancesSkinned);
 
diff --git a/src/scene/shader-lib/chunks/standard/frag/opacity-dither.js b/src/scene/shader-lib/chunks/standard/frag/opacity-dither.js
index 4bf70daee..0ef93d0dc 100644
--- a/src/scene/shader-lib/chunks/standard/frag/opacity-dither.js
+++ b/src/scene/shader-lib/chunks/standard/frag/opacity-dither.js
@@ -1,6 +1,24 @@
 export default /* glsl */`
-void opacityDither(float alpha) {
-    if (alpha <= bayer8(floor(mod(gl_FragCoord.xy, 8.0))) / 64.0)
+
+uniform vec4 blueNoiseJitter;
+
+#ifndef DITHER_BAYER8
+    uniform sampler2D blueNoiseTex32;
+#endif
+
+void opacityDither(float alpha, float id) {
+    #ifdef DITHER_BAYER8
+
+        float noise = bayer8(floor(mod(gl_FragCoord.xy + blueNoiseJitter.xy + id, 8.0))) / 64.0;
+
+    #else   // blue noise
+
+        vec2 uv = fract(gl_FragCoord.xy / 32.0 + blueNoiseJitter.xy + id);
+        float noise = texture2DLodEXT(blueNoiseTex32, uv, 0.0).y;
+
+    #endif
+
+    if (alpha < noise)
         discard;
 }
 `;
diff --git a/src/scene/shader-lib/programs/lit-shader-options.js b/src/scene/shader-lib/programs/lit-shader-options.js
index 7fcc6d9c7..e8aa8ac40 100644
--- a/src/scene/shader-lib/programs/lit-shader-options.js
+++ b/src/scene/shader-lib/programs/lit-shader-options.js
@@ -1,4 +1,4 @@
-import { BLEND_NONE, FOG_NONE, GAMMA_NONE } from '../../constants.js';
+import { BLEND_NONE, DITHER_NONE, FOG_NONE, GAMMA_NONE } from '../../constants.js';
 
 /**
  * The lit shader options determines how the lit-shader gets generated. It specifies a set of
@@ -175,16 +175,16 @@ class LitShaderOptions {
     /**
      * Enable opacity dithering. See {@link StandardMaterial#opacityDither}.
      *
-     * @type {boolean}
+     * @type {string}
      */
-    opacityDither = false;
+    opacityDither = DITHER_NONE;
 
     /**
      * Enable opacity shadow dithering. See {@link StandardMaterial#opacityShadowDither}.
      *
-     * @type {boolean}
+     * @type {string}
      */
-    opacityShadowDither = false;
+    opacityShadowDither = DITHER_NONE;
 
     /**
      * The value of {@link StandardMaterial#cubeMapProjection}.
diff --git a/src/scene/shader-lib/programs/standard.js b/src/scene/shader-lib/programs/standard.js
index d32f85ba8..90a0951ad 100644
--- a/src/scene/shader-lib/programs/standard.js
+++ b/src/scene/shader-lib/programs/standard.js
@@ -1,7 +1,7 @@
 import { Debug } from '../../../core/debug.js';
 
 import {
-    BLEND_NONE, FRESNEL_SCHLICK,
+    BLEND_NONE, DITHER_BAYER8, DITHER_NONE, FRESNEL_SCHLICK,
     SHADER_FORWARD, SHADER_FORWARDHDR,
     SPECULAR_PHONG,
     SPRITE_RENDERMODE_SLICED, SPRITE_RENDERMODE_TILED
@@ -292,7 +292,7 @@ class ShaderGeneratorStandard extends ShaderGenerator {
             }
 
             // opacity
-            if (options.litOptions.blendType !== BLEND_NONE || options.litOptions.alphaTest || options.litOptions.alphaToCoverage || options.litOptions.opacityDither) {
+            if (options.litOptions.blendType !== BLEND_NONE || options.litOptions.alphaTest || options.litOptions.alphaToCoverage || options.litOptions.opacityDither !== DITHER_NONE) {
                 decl.append("float dAlpha;");
                 code.append(this._addMap("opacity", "opacityPS", options, litShader.chunks, textureMapping));
                 func.append("getOpacity();");
@@ -303,10 +303,13 @@ class ShaderGeneratorStandard extends ShaderGenerator {
                     func.append("alphaTest(dAlpha);");
                 }
 
-                if (options.litOptions.opacityDither) {
-                    decl.append(litShader.chunks.bayerPS);
+                const opacityDither = options.litOptions.opacityDither;
+                if (opacityDither !== DITHER_NONE) {
+                    if (opacityDither === DITHER_BAYER8)
+                        decl.append(litShader.chunks.bayerPS);
+                    decl.append(`#define DITHER_${opacityDither.toUpperCase()}\n`);
                     decl.append(litShader.chunks.opacityDitherPS);
-                    func.append("opacityDither(dAlpha);");
+                    func.append("opacityDither(dAlpha, 0.0);");
                 }
             } else {
                 decl.append("float dAlpha = 1.0;");
@@ -489,7 +492,8 @@ class ShaderGeneratorStandard extends ShaderGenerator {
             }
         } else {
             // all other passes require only opacity
-            if (options.litOptions.alphaTest || options.litOptions.opacityShadowDither) {
+            const opacityShadowDither = options.litOptions.opacityShadowDither;
+            if (options.litOptions.alphaTest || opacityShadowDither) {
                 decl.append("float dAlpha;");
                 code.append(this._addMap("opacity", "opacityPS", options, litShader.chunks, textureMapping));
                 func.append("getOpacity();");
@@ -498,10 +502,12 @@ class ShaderGeneratorStandard extends ShaderGenerator {
                     code.append(litShader.chunks.alphaTestPS);
                     func.append("alphaTest(dAlpha);");
                 }
-                if (options.litOptions.opacityShadowDither) {
-                    decl.append(litShader.chunks.bayerPS);
+                if (opacityShadowDither) {
+                    if (opacityShadowDither === DITHER_BAYER8)
+                        decl.append(litShader.chunks.bayerPS);
+                    decl.append(`#define DITHER_${opacityShadowDither.toUpperCase()}\n`);
                     decl.append(litShader.chunks.opacityDitherPS);
-                    func.append("opacityDither(dAlpha);");
+                    func.append("opacityDither(dAlpha, 0.0);");
                 }
             }
         }
diff --git a/utils/types-fixup.mjs b/utils/types-fixup.mjs
index 5a876b572..68654b011 100644
--- a/utils/types-fixup.mjs
+++ b/utils/types-fixup.mjs
@@ -460,8 +460,8 @@ const standardMaterialProps = [
     ['occludeSpecularIntensity', 'number'],
     ['onUpdateShader', 'UpdateShaderCallback'],
     ['opacity', 'number'],
-    ['opacityDither', 'boolean'],
-    ['opacityShadowDither', 'boolean'],
+    ['opacityDither', 'string'],
+    ['opacityShadowDither', 'string'],
     ['opacityFadesSpecular', 'boolean'],
     ['opacityMap', 'Texture|null'],
     ['opacityMapChannel', 'string'],


Test patch:
diff --git a/test/scene/materials/standard-material.test.mjs b/test/scene/materials/standard-material.test.mjs
index 3971eeec7..f7bb14259 100644
--- a/test/scene/materials/standard-material.test.mjs
+++ b/test/scene/materials/standard-material.test.mjs
@@ -1,4 +1,4 @@
-import { CUBEPROJ_NONE, DETAILMODE_MUL, FRESNEL_SCHLICK, SPECOCC_AO, SPECULAR_BLINN } from '../../../src/scene/constants.js';
+import { CUBEPROJ_NONE, DETAILMODE_MUL, DITHER_NONE, FRESNEL_SCHLICK, SPECOCC_AO, SPECULAR_BLINN } from '../../../src/scene/constants.js';
 import { Color } from '../../../src/core/math/color.js';
 import { Material } from '../../../src/scene/materials/material.js';
 import { StandardMaterial } from '../../../src/scene/materials/standard-material.js';
@@ -221,8 +221,8 @@ describe('StandardMaterial', function () {
 
         expect(material.opacity).to.equal(1);
         expect(material.opacityFadesSpecular).to.equal(true);
-        expect(material.opacityDither).to.equal(false);
-        expect(material.opacityShadowDither).to.equal(false);
+        expect(material.opacityDither).to.equal(DITHER_NONE);
+        expect(material.opacityShadowDither).to.equal(DITHER_NONE);
         expect(material.opacityMap).to.be.null;
         expect(material.opacityMapChannel).to.equal('a');
         expect(material.opacityMapOffset).to.be.an.instanceof(Vec2);