Repository: playcanvas/engine
Pull request #7129 (2024-11-21 14:40:57)

CameraFrame API in the engine
Fixes https://github.com/playcanvas/engine/pull/7129
(separate improvements / changes can be tracked separately)

This adds a new CameraFrame class as part of the engine, which is a public API for the currently exposed render pass based rendering.
The script which exposes this to the Editor now uses an instance of this CameraFrame, and simply passes attributes to it, without containing any logic. This allows it to not access any private engine functionality.

This is the new public API (too long to copy here)
https://github.com/playcanvas/engine/blob/mc-engine-camera-frame/src/extras/render-passes/camera-frame.js

- also updated engine examples to use CameraFrame module directly, without using the script.
- updated eslint config to ignore some custom tags we added to MJS scripts




Patch:
diff --git a/eslint.config.mjs b/eslint.config.mjs
index 42b1f2f2e..b68415bc8 100644
--- a/eslint.config.mjs
+++ b/eslint.config.mjs
@@ -2,6 +2,12 @@ import playcanvasConfig from '@playcanvas/eslint-config';
 import babelParser from '@babel/eslint-parser';
 import globals from 'globals';
 
+// Extract or preserve existing JSDoc tags
+const jsdocRule = playcanvasConfig.find(
+    config => config.rules && config.rules['jsdoc/check-tag-names']
+);
+const existingTags = jsdocRule?.rules['jsdoc/check-tag-names'][1]?.definedTags || [];
+
 export default [
     ...playcanvasConfig,
     {
@@ -27,7 +33,14 @@ export default [
             }
         },
         rules: {
-            'import/order': 'off'
+            'import/order': 'off',
+            'jsdoc/check-tag-names': [
+                'error',
+                {
+                    // custom mjs script tags to not error on, add them to those from parent config
+                    definedTags: [...new Set([...existingTags, 'range', 'step', 'precision'])]
+                }
+            ]
         }
     },
     {
diff --git a/examples/src/examples/animation/events.example.mjs b/examples/src/examples/animation/events.example.mjs
index 74f14bcb0..8d3c856d2 100644
--- a/examples/src/examples/animation/events.example.mjs
+++ b/examples/src/examples/animation/events.example.mjs
@@ -1,6 +1,5 @@
-import { deviceType, rootPath, fileImport } from 'examples/utils';
+import { deviceType, rootPath } from 'examples/utils';
 import * as pc from 'playcanvas';
-const { CameraFrame } = await fileImport(`${rootPath}/static/assets/scripts/misc/camera-frame.mjs`);
 
 const canvas = /** @type {HTMLCanvasElement} */ (document.getElementById('application-canvas'));
 window.focus();
@@ -34,13 +33,11 @@ createOptions.componentSystems = [
     pc.RenderComponentSystem,
     pc.CameraComponentSystem,
     pc.LightComponentSystem,
-    pc.ScriptComponentSystem,
     pc.AnimComponentSystem
 ];
 createOptions.resourceHandlers = [
     pc.TextureHandler,
     pc.ContainerHandler,
-    pc.ScriptHandler,
     pc.AnimClipHandler,
     pc.AnimStateGraphHandler
 ];
@@ -76,12 +73,12 @@ assetListLoader.load(() => {
 
     // ------ Custom render passes set up ------
 
-    cameraEntity.addComponent('script');
-    const cameraFrame = cameraEntity.script.create(CameraFrame);
+    const cameraFrame = new pc.CameraFrame(app, cameraEntity.camera);
     cameraFrame.rendering.toneMapping = pc.TONEMAP_NEUTRAL;
     cameraFrame.rendering.samples = 4;
     cameraFrame.bloom.enabled = true;
     cameraFrame.bloom.intensity = 0.01;
+    cameraFrame.update();
 
     // ------------------------------------------
 
diff --git a/examples/src/examples/camera/first-person.example.mjs b/examples/src/examples/camera/first-person.example.mjs
index 8dd6c65fd..e8e7eee74 100644
--- a/examples/src/examples/camera/first-person.example.mjs
+++ b/examples/src/examples/camera/first-person.example.mjs
@@ -1,9 +1,7 @@
 // @config DESCRIPTION <div style='text-align:center'><div>(<b>WASD</b>) Move</div><div>(<b>Space</b>) Jump</div><div>(<b>Mouse</b>) Look</div></div>
-import { deviceType, rootPath, fileImport } from 'examples/utils';
+import { deviceType, rootPath } from 'examples/utils';
 import * as pc from 'playcanvas';
 
-const { CameraFrame } = await fileImport(`${rootPath}/static/assets/scripts/misc/camera-frame.mjs`);
-
 const canvas = /** @type {HTMLCanvasElement} */ (document.getElementById('application-canvas'));
 window.focus();
 
@@ -148,15 +146,12 @@ cameraEntity.setLocalPosition(0, 0.5, 0);
 
 // ------ Custom render passes set up ------
 
-cameraEntity.addComponent('script');
-/** @type { CameraFrame } */
-const cameraFrame = cameraEntity.script.create(CameraFrame);
-
+const cameraFrame = new pc.CameraFrame(app, cameraEntity.camera);
 cameraFrame.rendering.samples = 4;
 cameraFrame.rendering.toneMapping = pc.TONEMAP_ACES2;
-
 cameraFrame.bloom.enabled = true;
 cameraFrame.bloom.intensity = 0.01;
+cameraFrame.update();
 
 // ------------------------------------------
 
diff --git a/examples/src/examples/graphics/ambient-occlusion.example.mjs b/examples/src/examples/graphics/ambient-occlusion.example.mjs
index b01146cb3..a3e59ca99 100644
--- a/examples/src/examples/graphics/ambient-occlusion.example.mjs
+++ b/examples/src/examples/graphics/ambient-occlusion.example.mjs
@@ -1,7 +1,6 @@
 import { data } from 'examples/observer';
-import { deviceType, rootPath, fileImport } from 'examples/utils';
+import { deviceType, rootPath } from 'examples/utils';
 import * as pc from 'playcanvas';
-const { CameraFrame } = await fileImport(`${rootPath}/static/assets/scripts/misc/camera-frame.mjs`);
 
 const canvas = /** @type {HTMLCanvasElement} */ (document.getElementById('application-canvas'));
 window.focus();
@@ -168,8 +167,7 @@ assetListLoader.load(() => {
 
     // ------ Custom render passes set up ------
 
-    /** @type { CameraFrame } */
-    const cameraFrame = cameraEntity.script.create(CameraFrame);
+    const cameraFrame = new pc.CameraFrame(app, cameraEntity.camera);
     cameraFrame.rendering.samples = 4;
     cameraFrame.rendering.toneMapping = pc.TONEMAP_NEUTRAL;
 
@@ -183,6 +181,8 @@ assetListLoader.load(() => {
         cameraFrame.ssao.samples = data.get('data.ssao.samples');
         cameraFrame.ssao.minAngle = data.get('data.ssao.minAngle');
         cameraFrame.ssao.scale = data.get('data.ssao.scale');
+
+        cameraFrame.update();
     };
 
     // apply UI changes
diff --git a/examples/src/examples/graphics/clustered-area-lights.example.mjs b/examples/src/examples/graphics/clustered-area-lights.example.mjs
index 5c1ab2dd9..42af210d9 100644
--- a/examples/src/examples/graphics/clustered-area-lights.example.mjs
+++ b/examples/src/examples/graphics/clustered-area-lights.example.mjs
@@ -1,7 +1,6 @@
 import { data } from 'examples/observer';
-import { deviceType, rootPath, fileImport } from 'examples/utils';
+import { deviceType, rootPath } from 'examples/utils';
 import * as pc from 'playcanvas';
-const { CameraFrame } = await fileImport(`${rootPath}/static/assets/scripts/misc/camera-frame.mjs`);
 
 const canvas = /** @type {HTMLCanvasElement} */ (document.getElementById('application-canvas'));
 window.focus();
@@ -228,11 +227,11 @@ assetListLoader.load(() => {
     app.root.addChild(camera);
 
     // custom render passes
-    const cameraFrame = camera.script.create(CameraFrame);
+    const cameraFrame = new pc.CameraFrame(app, camera.camera);
     cameraFrame.rendering.samples = 4;
-    cameraFrame.bloom.enabled = true;
     cameraFrame.bloom.intensity = 0.01;
-    cameraFrame.bloom.lastMipLevel = 4;
+    cameraFrame.bloom.blurLevel = 4;
+    cameraFrame.update();
 
     // if the device renders in HDR mode, disable tone mapping to output HDR values without any processing
     cameraFrame.rendering.toneMapping = device.isHdr ? pc.TONEMAP_NONE : pc.TONEMAP_NEUTRAL;
diff --git a/examples/src/examples/graphics/dithered-transparency.example.mjs b/examples/src/examples/graphics/dithered-transparency.example.mjs
index ae3077b0d..3997f706c 100644
--- a/examples/src/examples/graphics/dithered-transparency.example.mjs
+++ b/examples/src/examples/graphics/dithered-transparency.example.mjs
@@ -1,7 +1,6 @@
 import { data } from 'examples/observer';
-import { deviceType, rootPath, fileImport } from 'examples/utils';
+import { deviceType, rootPath } from 'examples/utils';
 import * as pc from 'playcanvas';
-const { CameraFrame } = await fileImport(`${rootPath}/static/assets/scripts/misc/camera-frame.mjs`);
 
 const canvas = /** @type {HTMLCanvasElement} */ (document.getElementById('application-canvas'));
 window.focus();
@@ -159,15 +158,16 @@ assetListLoader.load(() => {
 
     // ------ Custom render passes set up ------
 
-    /** @type { CameraFrame } */
-    const cameraFrame = cameraEntity.script.create(CameraFrame);
+    const cameraFrame = new pc.CameraFrame(app, cameraEntity.camera);
     cameraFrame.rendering.toneMapping = pc.TONEMAP_ACES;
     cameraFrame.rendering.sceneColorMap = true;
     cameraFrame.taa.jitter = 1;
+    cameraFrame.update();
 
     const applySettings = () => {
         cameraFrame.taa.enabled = data.get('data.taa');
         cameraFrame.rendering.sharpness = cameraFrame.taa.enabled ? 1 : 0;
+        cameraFrame.update();
     };
 
     // ------
diff --git a/examples/src/examples/graphics/portal.example.mjs b/examples/src/examples/graphics/portal.example.mjs
index 3b4256164..95802f848 100644
--- a/examples/src/examples/graphics/portal.example.mjs
+++ b/examples/src/examples/graphics/portal.example.mjs
@@ -1,6 +1,5 @@
-import { deviceType, rootPath, fileImport } from 'examples/utils';
+import { deviceType, rootPath } from 'examples/utils';
 import * as pc from 'playcanvas';
-const { CameraFrame } = await fileImport(`${rootPath}/static/assets/scripts/misc/camera-frame.mjs`);
 
 const canvas = /** @type {HTMLCanvasElement} */ (document.getElementById('application-canvas'));
 window.focus();
@@ -159,13 +158,11 @@ assetListLoader.load(() => {
 
     // ------ Custom render passes set up ------
 
-    camera.addComponent('script');
-    /** @type { CameraFrame } */
-    const cameraFrame = camera.script.create(CameraFrame);
-
+    const cameraFrame = new pc.CameraFrame(app, camera.camera);
     cameraFrame.rendering.stencil = true;
     cameraFrame.rendering.samples = 4;
     cameraFrame.rendering.toneMapping = pc.TONEMAP_ACES2;
+    cameraFrame.update();
 
     // ------------------------------------------
 
diff --git a/examples/src/examples/graphics/post-processing.controls.mjs b/examples/src/examples/graphics/post-processing.controls.mjs
index 4bac61b5a..da87e2cc6 100644
--- a/examples/src/examples/graphics/post-processing.controls.mjs
+++ b/examples/src/examples/graphics/post-processing.controls.mjs
@@ -86,12 +86,12 @@ export const controls = ({ observer, ReactPCUI, React, jsx, fragment }) => {
             ),
             jsx(
                 LabelGroup,
-                { text: 'last mip level' },
+                { text: 'blur level' },
                 jsx(SliderInput, {
                     binding: new BindingTwoWay(),
-                    link: { observer, path: 'data.bloom.lastMipLevel' },
+                    link: { observer, path: 'data.bloom.blurLevel' },
                     min: 1,
-                    max: 10,
+                    max: 16,
                     precision: 0
                 })
             )
diff --git a/examples/src/examples/graphics/post-processing.example.mjs b/examples/src/examples/graphics/post-processing.example.mjs
index 9b73ca371..fe0872eea 100644
--- a/examples/src/examples/graphics/post-processing.example.mjs
+++ b/examples/src/examples/graphics/post-processing.example.mjs
@@ -1,7 +1,6 @@
 import { data } from 'examples/observer';
-import { deviceType, rootPath, fileImport } from 'examples/utils';
+import { deviceType, rootPath } from 'examples/utils';
 import * as pc from 'playcanvas';
-const { CameraFrame } = await fileImport(`${rootPath}/static/assets/scripts/misc/camera-frame.mjs`);
 
 const canvas = /** @type {HTMLCanvasElement} */ (document.getElementById('application-canvas'));
 window.focus();
@@ -216,9 +215,9 @@ assetListLoader.load(() => {
 
     // ------ Custom render passes set up ------
 
-    /** @type { CameraFrame } */
-    const cameraFrame = cameraEntity.script.create(CameraFrame);
+    const cameraFrame = new pc.CameraFrame(app, cameraEntity.camera);
     cameraFrame.rendering.sceneColorMap = true;
+    cameraFrame.update();
 
     const applySettings = () => {
 
@@ -247,9 +246,8 @@ assetListLoader.load(() => {
         cameraFrame.taa.jitter = data.get('data.taa.jitter');
 
         // Bloom
-        cameraFrame.bloom.enabled = data.get('data.bloom.enabled');
-        cameraFrame.bloom.intensity = pc.math.lerp(0, 0.1, data.get('data.bloom.intensity') / 100);
-        cameraFrame.bloom.lastMipLevel = data.get('data.bloom.lastMipLevel');
+        cameraFrame.bloom.intensity = data.get('data.bloom.enabled') ? pc.math.lerp(0, 0.1, data.get('data.bloom.intensity') / 100) : 0;
+        cameraFrame.bloom.blurLevel = data.get('data.bloom.blurLevel');
 
         // grading
         cameraFrame.grading.enabled = data.get('data.grading.enabled');
@@ -258,15 +256,16 @@ assetListLoader.load(() => {
         cameraFrame.grading.contrast = data.get('data.grading.contrast');
 
         // vignette
-        cameraFrame.vignette.enabled = data.get('data.vignette.enabled');
         cameraFrame.vignette.inner = data.get('data.vignette.inner');
         cameraFrame.vignette.outer = data.get('data.vignette.outer');
         cameraFrame.vignette.curvature = data.get('data.vignette.curvature');
-        cameraFrame.vignette.intensity = data.get('data.vignette.intensity');
+        cameraFrame.vignette.intensity = data.get('data.vignette.enabled') ? data.get('data.vignette.intensity') : 0;
 
         // fringing
-        cameraFrame.fringing.enabled = data.get('data.fringing.enabled');
-        cameraFrame.fringing.intensity = data.get('data.fringing.intensity');
+        cameraFrame.fringing.intensity = data.get('data.fringing.enabled') ? data.get('data.fringing.intensity') : 0;
+
+        // apply all settings
+        cameraFrame.update();
     };
 
     // apply UI changes
@@ -285,7 +284,7 @@ assetListLoader.load(() => {
         bloom: {
             enabled: true,
             intensity: 5,
-            lastMipLevel: 1
+            blurLevel: 16
         },
         grading: {
             enabled: false,
diff --git a/examples/src/examples/graphics/taa.example.mjs b/examples/src/examples/graphics/taa.example.mjs
index df8c9e35c..a794b78f6 100644
--- a/examples/src/examples/graphics/taa.example.mjs
+++ b/examples/src/examples/graphics/taa.example.mjs
@@ -1,7 +1,6 @@
 import { data } from 'examples/observer';
-import { deviceType, rootPath, fileImport } from 'examples/utils';
+import { deviceType, rootPath } from 'examples/utils';
 import * as pc from 'playcanvas';
-const { CameraFrame } = await fileImport(`${rootPath}/static/assets/scripts/misc/camera-frame.mjs`);
 
 const canvas = /** @type {HTMLCanvasElement} */ (document.getElementById('application-canvas'));
 window.focus();
@@ -118,20 +117,21 @@ assetListLoader.load(() => {
 
     // ------ Custom render passes set up ------
 
-    /** @type { CameraFrame } */
-    const cameraFrame = cameraEntity.script.create(CameraFrame);
+    const cameraFrame = new pc.CameraFrame(app, cameraEntity.camera);
     cameraFrame.rendering.toneMapping = pc.TONEMAP_ACES;
     cameraFrame.bloom.intensity = 0.02;
+    cameraFrame.update();
 
     // ------
 
     const applySettings = () => {
 
-        cameraFrame.bloom.enabled = data.get('data.scene.bloom');
+        cameraFrame.bloom.intensity = data.get('data.scene.bloom') ? 0.02 : 0;
         cameraFrame.taa.enabled = data.get('data.taa.enabled');
         cameraFrame.taa.jitter = data.get('data.taa.jitter');
         cameraFrame.rendering.renderTargetScale = data.get('data.scene.scale');
         cameraFrame.rendering.sharpness = data.get('data.scene.sharpness');
+        cameraFrame.update();
     };
 
     // apply UI changes
diff --git a/examples/assets/scripts/misc/camera-frame.mjs b/scripts/utils/camera-frame.mjs
similarity index 56%
rename from examples/assets/scripts/misc/camera-frame.mjs
rename to scripts/utils/camera-frame.mjs
index 622a1828e..24497a176 100644
--- a/examples/assets/scripts/misc/camera-frame.mjs
+++ b/scripts/utils/camera-frame.mjs
@@ -1,11 +1,4 @@
-import {
-    Script,
-    Color,
-    math,
-    CameraFrameOptions,
-    RenderPassCameraFrame,
-    SSAOTYPE_NONE
-} from 'playcanvas';
+import { CameraFrame as EngineCameraFrame, Script, Color } from 'playcanvas';
 
 /** @enum {number} */
 const ToneMapping = {
@@ -154,11 +147,11 @@ class Bloom {
 
     /**
      * @attribute
-     * @range [0, 12]
+     * @range [0, 16]
      * @precision 0
      * @step 0
      */
-    lastMipLevel = 1;
+    blurLevel = 1;
 }
 
 /** @interface */
@@ -292,110 +285,91 @@ class CameraFrame extends Script {
      */
     fringing = new Fringing();
 
-    options = new CameraFrameOptions();
+    engineCameraFrame = new EngineCameraFrame(this.app, this.entity.camera);
 
     initialize() {
 
-        this.updateOptions();
-        this.createRenderPass();
-
         this.on('enable', () => {
-            this.createRenderPass();
+            this.engineCameraFrame.enabled = true;
         });
 
         this.on('disable', () => {
-            this.destroyRenderPass();
+            this.engineCameraFrame.enabled = false;
         });
 
         this.on('destroy', () => {
-            this.destroyRenderPass();
+            this.engineCameraFrame.destroy();
         });
     }
 
-    createRenderPass() {
-        const cameraComponent = this.entity.camera;
-        this.renderPassCamera = new RenderPassCameraFrame(this.app, cameraComponent, this.options);
-        cameraComponent.renderPasses = [this.renderPassCamera];
-    }
-
-    destroyRenderPass() {
-        const cameraComponent = this.entity.camera;
-        cameraComponent.renderPasses?.forEach((renderPass) => {
-            renderPass.destroy();
-        });
-        cameraComponent.renderPasses = [];
-        cameraComponent.rendering = null;
-
-        cameraComponent.jitter = 0;
-    }
-
-    updateOptions() {
-
-        const { options, rendering, bloom, taa, ssao } = this;
-        options.stencil = rendering.stencil;
-        options.samples = rendering.samples;
-        options.sceneColorMap = rendering.sceneColorMap;
-        options.prepassEnabled = rendering.sceneDepthMap;
-        options.bloomEnabled = bloom.enabled;
-        options.taaEnabled = taa.enabled;
-        options.ssaoType = ssao.type;
-        options.ssaoBlurEnabled = ssao.blurEnabled;
-        options.formats = [rendering.renderFormat, rendering.renderFormatFallback0, rendering.renderFormatFallback1];
-    }
-
     postUpdate(dt) {
 
-        const cameraComponent = this.entity.camera;
-        const { options, renderPassCamera, rendering, bloom, grading, vignette, fringing, taa, ssao } = this;
-
-        // options that can cause the passes to be re-created
-        this.updateOptions();
-        renderPassCamera.update(options);
-
-        // update parameters of individual render passes
-        const { composePass, bloomPass, ssaoPass } = renderPassCamera;
-
-        renderPassCamera.renderTargetScale = math.clamp(rendering.renderTargetScale, 0.1, 1);
-        composePass.toneMapping = rendering.toneMapping;
-        composePass.sharpness = rendering.sharpness;
-
-        if (options.bloomEnabled && bloomPass) {
-            composePass.bloomIntensity = bloom.intensity;
-            bloomPass.lastMipLevel = bloom.lastMipLevel;
+        const cf = this.engineCameraFrame;
+        const { rendering, bloom, grading, vignette, fringing, taa, ssao } = this;
+
+        const dstRendering = cf.rendering;
+        dstRendering.renderFormats.length = 0;
+        dstRendering.renderFormats.push(rendering.renderFormat);
+        dstRendering.renderFormats.push(rendering.renderFormatFallback0);
+        dstRendering.renderFormats.push(rendering.renderFormatFallback1);
+        dstRendering.stencil = rendering.stencil;
+        dstRendering.renderTargetScale = rendering.renderTargetScale;
+        dstRendering.samples = rendering.samples;
+        dstRendering.sceneColorMap = rendering.sceneColorMap;
+        dstRendering.sceneDepthMap = rendering.sceneDepthMap;
+        dstRendering.toneMapping = rendering.toneMapping;
+        dstRendering.sharpness = rendering.sharpness;
+
+        // ssao
+        const dstSsao = cf.ssao;
+        dstSsao.type = ssao.type;
+        if (ssao.type !== SsaoType.NONE) {
+            dstSsao.intensity = ssao.intensity;
+            dstSsao.radius = ssao.radius;
+            dstSsao.samples = ssao.samples;
+            dstSsao.power = ssao.power;
+            dstSsao.minAngle = ssao.minAngle;
+            dstSsao.scale = ssao.scale;
         }
 
-        if (options.ssaoType !== SSAOTYPE_NONE) {
-            ssaoPass.intensity = ssao.intensity;
-            ssaoPass.power = ssao.power;
-            ssaoPass.radius = ssao.radius;
-            ssaoPass.sampleCount = ssao.samples;
-            ssaoPass.minAngle = ssao.minAngle;
-            ssaoPass.scale = ssao.scale;
+        // bloom
+        const dstBloom = cf.bloom;
+        dstBloom.intensity = bloom.enabled ? bloom.intensity : 0;
+        if (bloom.enabled) {
+            dstBloom.blurLevel = bloom.blurLevel;
         }
 
-        composePass.gradingEnabled = grading.enabled;
+        // grading
+        const dstGrading = cf.grading;
+        dstGrading.enabled = grading.enabled;
         if (grading.enabled) {
-            composePass.gradingSaturation = grading.saturation;
-            composePass.gradingBrightness = grading.brightness;
-            composePass.gradingContrast = grading.contrast;
-            composePass.gradingTint = grading.tint;
+            dstGrading.brightness = grading.brightness;
+            dstGrading.contrast = grading.contrast;
+            dstGrading.saturation = grading.saturation;
+            dstGrading.tint.copy(grading.tint);
         }
 
-        composePass.vignetteEnabled = vignette.enabled;
+        // vignette
+        const dstVignette = cf.vignette;
+        dstVignette.intensity = vignette.enabled ? vignette.intensity : 0;
         if (vignette.enabled) {
-            composePass.vignetteInner = vignette.inner;
-            composePass.vignetteOuter = vignette.outer;
-            composePass.vignetteCurvature = vignette.curvature;
-            composePass.vignetteIntensity = vignette.intensity;
+            dstVignette.inner = vignette.inner;
+            dstVignette.outer = vignette.outer;
+            dstVignette.curvature = vignette.curvature;
         }
 
-        composePass.fringingEnabled = fringing.enabled;
-        if (fringing.enabled) {
-            composePass.fringingIntensity = fringing.intensity;
+        // taa
+        const dstTaa = cf.taa;
+        dstTaa.enabled = taa.enabled;
+        if (taa.enabled) {
+            dstTaa.jitter = taa.jitter;
         }
 
-        // enable camera jitter if taa is enabled
-        cameraComponent.jitter = taa.enabled ? taa.jitter : 0;
+        // fringing
+        const dstFringing = cf.fringing;
+        dstFringing.intensity = fringing.enabled ? fringing.intensity : 0;
+
+        cf.update();
     }
 }
 
diff --git a/src/core/math/float-packing.js b/src/core/math/float-packing.js
index 167824e1a..82550b9cc 100644
--- a/src/core/math/float-packing.js
+++ b/src/core/math/float-packing.js
@@ -119,29 +119,6 @@ class FloatPacking {
         value = math.clamp((value - min) / (max - min), 0, 1);
         FloatPacking.float2Bytes(value, array, offset, numBytes);
     }
-
-    /**
-     * Packs a float into specified number of bytes, using 1 byte for exponent and the remaining
-     * bytes for the mantissa.
-     *
-     * @param {number} value - The float value to pack.
-     * @param {Uint8ClampedArray} array - The array to store the packed value in.
-     * @param {number} offset - The start offset in the array to store the packed value at.
-     * @param {number} numBytes - The number of bytes to pack the value to.
-     *
-     * @ignore
-     */
-    static float2MantissaExponent(value, array, offset, numBytes) {
-        // exponent is increased by one, so that 2^exponent is larger than the value
-        const exponent = Math.floor(Math.log2(Math.abs(value))) + 1;
-        value /= Math.pow(2, exponent);
-
-        // value is now in -1..1 range, store it using specified number of bytes less one
-        FloatPacking.float2BytesRange(value, array, offset, -1, 1, numBytes - 1);
-
-        // last byte for the exponent (positive or negative)
-        array[offset + numBytes - 1] = Math.round(exponent + 127);
-    }
 }
 
 export { FloatPacking };
diff --git a/src/core/preprocessor.js b/src/core/preprocessor.js
index 3f5f271e6..bd8871a36 100644
--- a/src/core/preprocessor.js
+++ b/src/core/preprocessor.js
@@ -30,8 +30,11 @@ const IDENTIFIER = /([\w-]+)/;
 // [!]defined(EXPRESSION)
 const DEFINED = /(!|\s)?defined\(([\w-]+)\)/;
 
+// Matches comparison operators like ==, !=, <, <=, >, >=
+const COMPARISON = /([a-z_]\w*)\s*(==|!=|<|<=|>|>=)\s*([\w"']+)/i;
+
 // currently unsupported characters in the expression: | & < > = + -
-const INVALID = /[><=|&+-]/g;
+const INVALID = /[|&+-]/g;
 
 // #include "identifier"
 const INCLUDE = /include[ \t]+"([\w-]+)"\r?(?:\n|$)/g;
@@ -374,6 +377,7 @@ class Preprocessor {
      * - expression
      * - defined(expression)
      * - !defined(expression)
+     * - simple comparisons like "XX == 3" or "XX != test"
      *
      * But does not handle more complex cases, which would require more complex system:
      *
@@ -388,7 +392,7 @@ class Preprocessor {
         const correct = INVALID.exec(expression) === null;
         Debug.assert(correct, `Resolving expression like this is not supported: ${expression}`);
 
-        // if the format is defined(expression), extract expression
+        // if the format is 'defined(expression)', extract expression
         let invert = false;
         const defined = DEFINED.exec(expression);
         if (defined) {
@@ -396,6 +400,29 @@ class Preprocessor {
             expression = defined[2];
         }
 
+        // if the expression is a comparison, evaluate it
+        const comparison = COMPARISON.exec(expression);
+        if (comparison) {
+            const left = defines.get(comparison[1]) ?? comparison[1];
+            const right = defines.get(comparison[3]) ?? comparison[3];
+            const operator = comparison[2];
+
+            let result = false;
+            switch (operator) {
+                case '==': result = left === right; break;
+                case '!=': result = left !== right; break;
+                case '<': result = left < right; break;
+                case '<=': result = left <= right; break;
+                case '>': result = left > right; break;
+                case '>=': result = left >= right; break;
+            }
+
+            return {
+                result,
+                error: !correct
+            };
+        }
+
         // test if expression define exists
         expression = expression.trim();
         let exists = defines.has(expression);
diff --git a/src/extras/index.js b/src/extras/index.js
index c6df3f316..09b9c1759 100644
--- a/src/extras/index.js
+++ b/src/extras/index.js
@@ -14,7 +14,7 @@ export { UsdzExporter } from './exporters/usdz-exporter.js';
 export { GltfExporter } from './exporters/gltf-exporter.js';
 
 // RENDER PASSES
-export { SSAOTYPE_NONE, SSAOTYPE_LIGHTING, SSAOTYPE_COMBINE } from './render-passes/render-pass-camera-frame.js';
+export { SSAOTYPE_NONE, SSAOTYPE_LIGHTING, SSAOTYPE_COMBINE } from './render-passes/constants.js';
 export { RenderPassCameraFrame, CameraFrameOptions } from './render-passes/render-pass-camera-frame.js';
 export { RenderPassCompose } from './render-passes/render-pass-compose.js';
 export { RenderPassDepthAwareBlur } from './render-passes/render-pass-depth-aware-blur.js';
@@ -23,6 +23,7 @@ export { RenderPassUpsample } from './render-passes/render-pass-upsample.js';
 export { RenderPassBloom } from './render-passes/render-pass-bloom.js';
 export { RenderPassSsao } from './render-passes/render-pass-ssao.js';
 export { RenderPassTAA } from './render-passes/render-pass-taa.js';
+export { CameraFrame } from './render-passes/camera-frame.js';
 
 // GIZMOS
 export {
diff --git a/src/extras/render-passes/camera-frame.js b/src/extras/render-passes/camera-frame.js
new file mode 100644
index 000000000..eca69368f
--- /dev/null
+++ b/src/extras/render-passes/camera-frame.js
@@ -0,0 +1,388 @@
+import { Debug } from '../../core/debug.js';
+import { Color } from '../../core/math/color.js';
+import { math } from '../../core/math/math.js';
+import { PIXELFORMAT_111110F, PIXELFORMAT_RGBA16F, PIXELFORMAT_RGBA32F } from '../../platform/graphics/constants.js';
+import { SSAOTYPE_NONE } from './constants.js';
+import { CameraFrameOptions, RenderPassCameraFrame } from './render-pass-camera-frame.js';
+
+/**
+ * @import { AppBase } from '../../framework/app-base.js'
+ * @import { CameraComponent } from '../../framework/components/camera/component.js'
+ */
+
+/**
+ * Properties related to scene rendering, encompassing settings that control the rendering resolution,
+ * pixel format, multi-sampling for anti-aliasing, tone-mapping and similar.
+ *
+ * @typedef {Object} Rendering
+ * @property {number[]} renderFormats - The preferred render formats of the frame buffer, in order of
+ * preference. First format from this list that is supported by the hardware is used. When none of
+ * the formats are supported, {@link PIXELFORMAT_RGBA8} is used, but this automatically disables
+ * bloom effect, which requires HDR format. The list can contain the following formats:
+ * {@link PIXELFORMAT_111110F}, {@link PIXELFORMAT_RGBA16F}, {@link PIXELFORMAT_RGBA32F} and {@link
+ * PIXELFORMAT_RGBA8}. Typically the default option should be used, which prefers the faster formats,
+ * but if higher dynamic range is needed, the list can be adjusted to prefer higher precision formats.
+ * Defaults to [{@link PIXELFORMAT_111110F}, {@link PIXELFORMAT_RGBA16F}, {@link PIXELFORMAT_RGBA32F}].
+ * @property {boolean} stencil - Whether the render buffer has a stencil buffer. Defaults to false.
+ * @property {number} renderTargetScale - The scale of the render target, 0.1-1 range. This allows the
+ * scene to be rendered to a lower resolution render target as an optimization. The post-processing
+ * is also applied at this lower resolution. The image is then up-scaled to the full resolution and
+ * any UI rendering that follows is applied at the full resolution. Defaults to 1 which represents
+ * full resolution rendering.
+ * @property {number} samples - The number of samples of the {@link RenderTarget} used for the scene
+ * rendering, in 1-4 range. Value of 1 disables multisample anti-aliasing, other values enable
+ * anti-aliasing, Typically set to 1 when TAA is used, even though both anti-aliasing options can be
+ * used together at a higher cost. Defaults to 1.
+ * @property {boolean} sceneColorMap - Whether rendering generates a scene color map. Defaults to false.
+ * @property {boolean} sceneDepthMap - Whether rendering generates a scene depth map. Defaults to false.
+ * @property {number} toneMapping - The tone mapping. Defaults to {@link ToneMapping.LINEAR}. Can be:
+ *
+ * - {@link TONEMAP_LINEAR}
+ * - {@link TONEMAP_FILMIC}
+ * - {@link TONEMAP_HEJL}
+ * - {@link TONEMAP_ACES}
+ * - {@link TONEMAP_ACES2}
+ * - {@link TONEMAP_NEUTRAL}
+ *
+ * @property {number} sharpness - The sharpening intensity, 0-1 range. This can be used to increase
+ * the sharpness of the rendered image. Often used to counteract the blurriness of the TAA effect,
+ * but also blurriness caused by rendering to a lower resolution render target by using
+ * rendering.renderTargetScale property. Defaults to 0.
+ */
+
+/**
+ * Properties related to the Screen Space Ambient Occlusion (SSAO) effect, a postprocessing technique
+ * that approximates ambient occlusion by calculating how exposed each point in the screen space is
+ * to ambient light, enhancing depth perception and adding subtle shadowing in crevices and between
+ * objects.
+ *
+ * @typedef {Object} Ssao
+ * @property {string} type - The type of the SSAO determines how it is applied in the rendering
+ * process. Defaults to {@link SSAOTYPE_NONE}. Can be:
+ *
+ * - {@link SSAOTYPE_NONE}
+ * - {@link SSAOTYPE_LIGHTING}
+ * - {@link SSAOTYPE_COMBINE}
+ *
+ * @property {boolean} blurEnabled - Whether the SSAO effect is blurred. Defaults to true.
+ * @property {number} intensity - The intensity of the SSAO effect, 0-1 range. Defaults to 0.5.
+ * @property {number} radius - The radius of the SSAO effect, 0-100 range. Defaults to 30.
+ * @property {number} samples - The number of samples of the SSAO effect, 1-64 range. Defaults to 12.
+ * @property {number} power - The power of the SSAO effect, 0.1-10 range. Defaults to 6.
+ * @property {number} minAngle - The minimum angle of the SSAO effect, 1-90 range. Defaults to 10.
+ * @property {number} scale - The scale of the SSAO effect, 0.5-1 range. Defaults to 1.
+ */
+
+/**
+ * Properties related to the HDR bloom effect, a postprocessing technique that simulates the natural
+ * glow of bright light sources by spreading their intensity beyond their boundaries, creating a soft
+ * and realistic blooming effect.
+ *
+ * @typedef {Object} Bloom
+ * @property {number} intensity - The intensity of the bloom effect, 0-0.1 range. Defaults to 0,
+ * making it disabled.
+ * @property {number} blurLevel - The number of iterations for blurring the bloom effect, with each
+ * level doubling the blur size. Once the blur size matches the dimensions of the render target,
+ * further blur passes are skipped. The default value is 16.
+ */
+
+/**
+ * Properties related to the color grading effect, a postprocessing technique used to adjust and the
+ * visual tone of an image. This effect modifies brightness, contrast, saturation, and overall color
+ * balance to achieve a specific aesthetic or mood.
+ *
+ * @typedef {Object} Grading
+ * @property {boolean} enabled - Whether grading is enabled. Defaults to false.
+ * @property {number} brightness - The brightness of the grading effect, 0-3 range. Defaults to 1.
+ * @property {number} contrast - The contrast of the grading effect, 0.5-1.5 range. Defaults to 1.
+ * @property {number} saturation - The saturation of the grading effect, 0-2 range. Defaults to 1.
+ * @property {Color} tint - The tint color of the grading effect. Defaults to white.
+ */
+
+/**
+ * Properties related to the vignette effect, a postprocessing technique that darkens the image
+ * edges, creating a gradual falloff in brightness from the center outward. The effect can be also
+ * reversed, making the center of the image darker than the edges, by specifying the outer distance
+ * smaller than the inner distance.
+ *
+ * @typedef {Object} Vignette
+ * @property {number} intensity - The intensity of the vignette effect, 0-1 range. Defaults to 0,
+ * making it disabled.
+ * @property {number} inner - The inner distance of the vignette effect measured from the center of
+ * the screen, 0-3 range. This is where the vignette effect starts. Value larger than 1 represents
+ * the value off screen, which allows more control. Defaults to 0.5, representing half the distance
+ * from center.
+ * @property {number} outer - The outer distance of the vignette effect measured from the center of
+ * the screen, 0-3 range. This is where the vignette reaches full intensity. Value larger than 1
+ * represents the value off screen, which allows more control. Defaults to 1, representing the full
+ * screen.
+ * @property {number} curvature - The curvature of the vignette effect, 0.01-10 range. The vignette
+ * is rendered using a rectangle with rounded corners, and this parameter controls the curvature of
+ * the corners. Value of 1 represents a circle. Smaller values make the corners more square, while
+ * larger values make them more rounded. Defaults to 0.5.
+ */
+
+/**
+ * Properties related to the fringing effect, a chromatic aberration phenomenon where the red, green,
+ * and blue color channels diverge increasingly with greater distance from the center of the screen.
+ *
+ * @typedef {Object} Fringing
+ * @property {number} intensity - The intensity of the fringing effect, 0-100 range. Defaults to 0,
+ * making it disabled.
+ */
+
+/**
+ * Properties related to temporal anti-aliasing (TAA), which is a technique used to reduce aliasing
+ * in the rendered image by blending multiple frames together over time.
+ *
+ * @typedef {Object} Taa
+ * @property {boolean} enabled - Whether Taa is enabled. Defaults to false.
+ * @property {number} jitter - The intensity of the camera jitter, 0-1 range. The larger the value,
+ * the more jitter is applied to the camera, making the anti-aliasing effect more pronounced. This
+ * also makes the image more blurry, and rendering.sharpness parameter can be used to counteract.
+ * Defaults to 1.
+ */
+
+/**
+ * Implementation of a simple to use camera rendering pass, which supports SSAO, Bloom and
+ * other rendering effects.
+ *
+ * @category Render Pass
+ */
+class CameraFrame {
+    /**
+     * Rendering settings.
+     *
+     * @type {Rendering}
+     */
+    rendering = {
+        renderFormats: [PIXELFORMAT_111110F, PIXELFORMAT_RGBA16F, PIXELFORMAT_RGBA32F],
+        stencil: false,
+        renderTargetScale: 1.0,
+        samples: 1,
+        sceneColorMap: false,
+        sceneDepthMap: false,
+        toneMapping: 0,
+        sharpness: 0.0
+    };
+
+    /**
+     * SSAO settings.
+     *
+     * @type {Ssao}
+     */
+    ssao = {
+        type: SSAOTYPE_NONE,
+        blurEnabled: true,
+        intensity: 0.5,
+        radius: 30,
+        samples: 12,
+        power: 6,
+        minAngle: 10,
+        scale: 1
+    };
+
+    /**
+     * Bloom settings.
+     *
+     * @type {Bloom}
+     */
+    bloom = {
+        intensity: 0,
+        blurLevel: 16
+    };
+
+    /**
+     * Grading settings.
+     *
+     * @type {Grading}
+     */
+    grading = {
+        enabled: false,
+        brightness: 1,
+        contrast: 1,
+        saturation: 1,
+        tint: new Color(1, 1, 1, 1)
+    };
+
+    /**
+     * Vignette settings.
+     *
+     * @type {Vignette}
+     */
+    vignette = {
+        intensity: 0,
+        inner: 0.5,
+        outer: 1,
+        curvature: 0.5
+    };
+
+    /**
+     * Taa settings.
+     *
+     * @type {Taa}
+     */
+    taa = {
+        enabled: false,
+        jitter: 1
+    };
+
+    /**
+     * Fringing settings.
+     *
+     * @type {Fringing}
+     */
+    fringing = {
+        intensity: 0
+    };
+
+    options = new CameraFrameOptions();
+
+    /**
+     * @type {RenderPassCameraFrame|null}
+     * @private
+     */
+    renderPassCamera = null;
+
+    /**
+     * Creates a new CameraFrame instance.
+     *
+     * @param {AppBase} app - The application.
+     * @param {CameraComponent} cameraComponent - The camera component.
+     */
+    constructor(app, cameraComponent) {
+        this.app = app;
+        this.cameraComponent = cameraComponent;
+        Debug.assert(cameraComponent, 'CameraFrame: cameraComponent must be defined');
+
+        this.updateOptions();
+        this.enabled = true;
+    }
+
+    /**
+     * Destroys the camera frame, removing all render passes.
+     */
+    destroy() {
+        this.disable();
+    }
+
+    enable() {
+        if (!this.renderPassCamera) {
+            const cameraComponent = this.cameraComponent;
+            this.renderPassCamera = new RenderPassCameraFrame(this.app, cameraComponent, this.options);
+            cameraComponent.renderPasses = [this.renderPassCamera];
+        }
+    }
+
+    disable() {
+        if (this.renderPassCamera) {
+            const cameraComponent = this.cameraComponent;
+            cameraComponent.renderPasses?.forEach((renderPass) => {
+                renderPass.destroy();
+            });
+            cameraComponent.renderPasses = [];
+            cameraComponent.rendering = null;
+
+            cameraComponent.jitter = 0;
+        }
+    }
+
+    /**
+     * Sets the enabled state of the camera frame. This disabled the render passes, and releases
+     * any resources.
+     *
+     * @type {boolean}
+     */
+    set enabled(value) {
+        if (value) {
+            this.enable();
+        } else {
+            this.disable();
+        }
+    }
+
+    /**
+     * Gets the enabled state of the camera frame.
+     *
+     * @type {boolean}
+     */
+    get enabled() {
+        return this.renderPassCamera !== null;
+    }
+
+    updateOptions() {
+
+        const { options, rendering, bloom, taa, ssao } = this;
+        options.stencil = rendering.stencil;
+        options.samples = rendering.samples;
+        options.sceneColorMap = rendering.sceneColorMap;
+        options.prepassEnabled = rendering.sceneDepthMap;
+        options.bloomEnabled = bloom.intensity > 0;
+        options.taaEnabled = taa.enabled;
+        options.ssaoType = ssao.type;
+        options.ssaoBlurEnabled = ssao.blurEnabled;
+        options.formats = rendering.renderFormats.slice();
+    }
+
+    /**
+     * Applies any changes made to the properties of this instance.
+     */
+    update() {
+
+        if (!this.enabled) return;
+
+        const cameraComponent = this.cameraComponent;
+        const { options, renderPassCamera, rendering, bloom, grading, vignette, fringing, taa, ssao } = this;
+
+        // options that can cause the passes to be re-created
+        this.updateOptions();
+        renderPassCamera.update(options);
+
+        // update parameters of individual render passes
+        const { composePass, bloomPass, ssaoPass } = renderPassCamera;
+
+        renderPassCamera.renderTargetScale = math.clamp(rendering.renderTargetScale, 0.1, 1);
+        composePass.toneMapping = rendering.toneMapping;
+        composePass.sharpness = rendering.sharpness;
+
+        if (options.bloomEnabled && bloomPass) {
+            composePass.bloomIntensity = bloom.intensity;
+            bloomPass.blurLevel = bloom.blurLevel;
+        }
+
+        if (options.ssaoType !== SSAOTYPE_NONE) {
+            ssaoPass.intensity = ssao.intensity;
+            ssaoPass.power = ssao.power;
+            ssaoPass.radius = ssao.radius;
+            ssaoPass.sampleCount = ssao.samples;
+            ssaoPass.minAngle = ssao.minAngle;
+            ssaoPass.scale = ssao.scale;
+        }
+
+        composePass.gradingEnabled = grading.enabled;
+        if (grading.enabled) {
+            composePass.gradingSaturation = grading.saturation;
+            composePass.gradingBrightness = grading.brightness;
+            composePass.gradingContrast = grading.contrast;
+            composePass.gradingTint = grading.tint;
+        }
+
+        composePass.vignetteEnabled = vignette.intensity > 0;
+        if (composePass.vignetteEnabled) {
+            composePass.vignetteInner = vignette.inner;
+            composePass.vignetteOuter = vignette.outer;
+            composePass.vignetteCurvature = vignette.curvature;
+            composePass.vignetteIntensity = vignette.intensity;
+        }
+
+        composePass.fringingEnabled = fringing.intensity > 0;
+        if (composePass.fringingEnabled) {
+            composePass.fringingIntensity = fringing.intensity;
+        }
+
+        // enable camera jitter if taa is enabled
+        cameraComponent.jitter = taa.enabled ? taa.jitter : 0;
+    }
+}
+
+export { CameraFrame };
diff --git a/src/extras/render-passes/constants.js b/src/extras/render-passes/constants.js
new file mode 100644
index 000000000..8626ddbe0
--- /dev/null
+++ b/src/extras/render-passes/constants.js
@@ -0,0 +1,24 @@
+/**
+ * SSAO is disabled.
+ *
+ * @type {string}
+ */
+export const SSAOTYPE_NONE = 'none';
+
+/**
+ * SSAO is applied during the lighting calculation stage, allowing it to blend seamlessly with scene
+ * lighting. This results in ambient occlusion being more pronounced in areas where direct light is
+ * obstructed, enhancing realism.
+ *
+ * @type {string}
+ */
+export const SSAOTYPE_LIGHTING = 'lighting';
+
+/**
+ * SSAO is applied as a standalone effect after the scene is rendered. This method uniformly
+ * overlays ambient occlusion across the image, disregarding direct lighting interactions. While
+ * this may sacrifice some realism, it can be advantageous for achieving specific artistic styles.
+ *
+ * @type {string}
+ */
+export const SSAOTYPE_COMBINE = 'combine';
diff --git a/src/extras/render-passes/render-pass-bloom.js b/src/extras/render-passes/render-pass-bloom.js
index 6f85693b7..ae91d8668 100644
--- a/src/extras/render-passes/render-pass-bloom.js
+++ b/src/extras/render-passes/render-pass-bloom.js
@@ -7,6 +7,7 @@ import { FILTER_LINEAR, ADDRESS_CLAMP_TO_EDGE } from '../../platform/graphics/co
 
 import { RenderPassDownsample } from './render-pass-downsample.js';
 import { RenderPassUpsample } from './render-pass-upsample.js';
+import { math } from '../../core/math/math.js';
 
 // based on https://learnopengl.com/Guest-Articles/2022/Phys.-Based-Bloom
 /**
@@ -18,7 +19,7 @@ import { RenderPassUpsample } from './render-pass-upsample.js';
 class RenderPassBloom extends RenderPass {
     bloomTexture;
 
-    lastMipLevel = 1;
+    blurLevel = 16;
 
     bloomRenderTarget;
 
@@ -151,8 +152,8 @@ class RenderPassBloom extends RenderPass {
         super.frameUpdate();
 
         // create an appropriate amount of render passes
-        let numPasses = this.calcMipLevels(this._sourceTexture.width, this._sourceTexture.height, 2 ** this.lastMipLevel);
-        numPasses = Math.max(1, numPasses);
+        const maxNumPasses = this.calcMipLevels(this._sourceTexture.width, this._sourceTexture.height, 1);
+        const numPasses = math.clamp(maxNumPasses, 1, this.blurLevel);
 
         if (this.renderTargets.length !== numPasses) {
 
diff --git a/src/extras/render-passes/render-pass-camera-frame.js b/src/extras/render-passes/render-pass-camera-frame.js
index 34ea9a27e..7e539c4c8 100644
--- a/src/extras/render-passes/render-pass-camera-frame.js
+++ b/src/extras/render-passes/render-pass-camera-frame.js
@@ -11,10 +11,8 @@ import { RenderPassCompose } from './render-pass-compose.js';
 import { RenderPassTAA } from './render-pass-taa.js';
 import { RenderPassPrepass } from './render-pass-prepass.js';
 import { RenderPassSsao } from './render-pass-ssao.js';
-
-export const SSAOTYPE_NONE = 'none';
-export const SSAOTYPE_LIGHTING = 'lighting';
-export const SSAOTYPE_COMBINE = 'combine';
+import { SSAOTYPE_COMBINE, SSAOTYPE_LIGHTING, SSAOTYPE_NONE } from './constants.js';
+import { Debug } from '../../core/debug.js';
 
 class CameraFrameOptions {
     formats;
@@ -82,6 +80,7 @@ class RenderPassCameraFrame extends RenderPass {
     rt = null;
 
     constructor(app, cameraComponent, options = {}) {
+        Debug.assert(app);
         super(app.graphicsDevice);
         this.app = app;
         this.cameraComponent = cameraComponent;
@@ -211,8 +210,8 @@ class RenderPassCameraFrame extends RenderPass {
 
         this.rt = new RenderTarget({
             colorBuffer: this.sceneTexture,
-            // depthBuffer: this.sceneDepth,
             depth: true,
+            stencil: options.stencil,
             samples: options.samples,
             flipY: !!targetRenderTarget?.flipY  // flipY is inherited from the target renderTarget
         });
diff --git a/src/framework/xr/xr-joint.js b/src/framework/xr/xr-joint.js
index 35d980042..4d98aa89b 100644
--- a/src/framework/xr/xr-joint.js
+++ b/src/framework/xr/xr-joint.js
@@ -35,7 +35,7 @@ class XrJoint {
     _index;
 
     /**
-     * @type {string}
+     * @type {XRHandJoint}
      * @private
      */
     _id;
@@ -47,7 +47,7 @@ class XrJoint {
     _hand;
 
     /**
-     * @type {XrFinger}
+     * @type {XrFinger|null}
      * @private
      */
     _finger;
@@ -65,7 +65,7 @@ class XrJoint {
     _tip;
 
     /**
-     * @type {number}
+     * @type {number|null}
      * @private
      */
     _radius = null;
@@ -116,7 +116,7 @@ class XrJoint {
      * Create an XrJoint instance.
      *
      * @param {number} index - Index of a joint within a finger.
-     * @param {string} id - Id of a joint based on WebXR Hand Input Specs.
+     * @param {XRHandJoint} id - Id of a joint based on WebXR Hand Input Specs.
      * @param {XrHand} hand - Hand that joint relates to.
      * @param {XrFinger|null} finger - Finger that joint is related to. Can be null in the case of
      * the wrist joint.
@@ -181,6 +181,15 @@ class XrJoint {
         return this._rotation;
     }
 
+    /**
+     * Id of a joint based on WebXR Hand Input Specs.
+     *
+     * @type {XRHandJoint}
+     */
+    get id() {
+        return this._id;
+    }
+
     /**
      * Index of a joint within a finger, starting from 0 (root of a finger) all the way to tip of
      * the finger.
diff --git a/src/platform/graphics/graphics-device.js b/src/platform/graphics/graphics-device.js
index 11abc4388..6e9bc869b 100644
--- a/src/platform/graphics/graphics-device.js
+++ b/src/platform/graphics/graphics-device.js
@@ -275,6 +275,14 @@ class GraphicsDevice extends EventHandler {
      */
     supportsUniformBuffers = false;
 
+    /**
+     * True if the device supports clip distances (WebGPU only). Clip distances allow you to restrict
+     * primitives' clip volume with user-defined half-spaces in the output of vertex stage.
+     *
+     * @type {boolean}
+     */
+    supportsClipDistances = false;
+
     /**
      * True if 32-bit floating-point textures can be used as a frame buffer.
      *
diff --git a/src/platform/graphics/webgpu/webgpu-graphics-device.js b/src/platform/graphics/webgpu/webgpu-graphics-device.js
index 1841c9c8f..449b0b090 100644
--- a/src/platform/graphics/webgpu/webgpu-graphics-device.js
+++ b/src/platform/graphics/webgpu/webgpu-graphics-device.js
@@ -210,7 +210,6 @@ class WebgpuGraphicsDevice extends GraphicsDevice {
          */
         this.gpuAdapter = await window.navigator.gpu.requestAdapter(adapterOptions);
 
-
         // request optional features
         const requiredFeatures = [];
         const requireFeature = (feature) => {
@@ -231,6 +230,7 @@ class WebgpuGraphicsDevice extends GraphicsDevice {
         this.supportsShaderF16 = requireFeature('shader-f16');
         this.supportsStorageRGBA8 = requireFeature('bgra8unorm-storage');
         this.textureRG11B10Renderable = requireFeature('rg11b10ufloat-renderable');
+        this.supportsClipDistances = requireFeature('clip-distances');
         Debug.log(`WEBGPU features: ${requiredFeatures.join(', ')}`);
 
         // copy all adapter limits to the requiredLimits object - to created a device with the best feature sets available


Test patch:
diff --git a/test/core/preprocessor.test.mjs b/test/core/preprocessor.test.mjs
index 79503ca4b..d722980cc 100644
--- a/test/core/preprocessor.test.mjs
+++ b/test/core/preprocessor.test.mjs
@@ -73,6 +73,28 @@ describe('Preprocessor', function () {
         #ifdef (UNKNOWN)
             #define TEST14  // this should not be defined
         #endif
+
+        #define INDEX 3
+        #if INDEX == 3
+            CMP1
+        #endif
+
+        #if INDEX != 3
+            CMP2
+        #endif
+
+        #if INDEX > 2
+            CMP3
+        #endif
+
+        #define NAME hello
+        #if NAME == hello
+            CMP4
+        #endif
+
+        #if NAME != hello
+            CMP5
+        #endif
     `;
 
     it('returns false for MORPH_A', function () {
@@ -154,4 +176,25 @@ describe('Preprocessor', function () {
     it('returns true for nested', function () {
         expect(Preprocessor.run(srcData, includes).includes('nested')).to.equal(true);
     });
+
+    it('returns true for CMP1', function () {
+        expect(Preprocessor.run(srcData, includes).includes('CMP1')).to.equal(true);
+    });
+
+    it('returns false for CMP2', function () {
+        expect(Preprocessor.run(srcData, includes).includes('CMP2')).to.equal(false);
+    });
+
+    it('returns true for CMP3', function () {
+        expect(Preprocessor.run(srcData, includes).includes('CMP3')).to.equal(true);
+    });
+
+    it('returns true for CMP4', function () {
+        expect(Preprocessor.run(srcData, includes).includes('CMP4')).to.equal(true);
+    });
+
+    it('returns false for CMP5', function () {
+        expect(Preprocessor.run(srcData, includes).includes('CMP5')).to.equal(false);
+    });
+
 });