From fbeb0b67dfe477867755cfe5336250bba322cfba Mon Sep 17 00:00:00 2001 From: Vincent Fretin Date: Sat, 4 Jul 2026 16:03:34 +0200 Subject: [PATCH 1/4] Add renderer backend and reversedDepthBuffer properties, pass multiview to WebGPURenderer - multiviewStereo is now also passed as the multiview option used by WebGPURenderer (fixes #5749) - new reversedDepthBuffer property passed to both WebGLRenderer and WebGPURenderer - new backend property (webgpu or webgl) mapped to the forceWebGL option of WebGPURenderer; ignored by WebGLRenderer Co-Authored-By: Claude Fable 5 --- docs/components/renderer.md | 21 +++++++++++++++++++++ src/core/scene/a-scene.js | 11 +++++++++++ src/systems/renderer.js | 2 ++ 3 files changed, 34 insertions(+) diff --git a/docs/components/renderer.md b/docs/components/renderer.md index b636637cfc1..98ef0d74bdb 100644 --- a/docs/components/renderer.md +++ b/docs/components/renderer.md @@ -28,6 +28,7 @@ It also configures presentation attributes when entering WebVR/WebXR. | Property | Description | Default Value | |-------------------------|---------------------------------------------------------------------------------|---------------| | antialias | Whether to perform antialiasing. If `auto`, antialiasing is disabled on mobile. | auto | +| backend | Backend used by `THREE.WebGPURenderer`: webgpu or webgl. Ignored with the default build using `THREE.WebGLRenderer`. | webgpu | | colorManagement | Whether to use a color-managed linear workflow. | true | | highRefreshRate | Increases frame rate from the default (for browsers that support control of frame rate). | false | | foveationLevel | Amount of foveation used in VR to improve perf, from 0 (min) to 1 (max). | 1 | @@ -36,6 +37,7 @@ It also configures presentation attributes when entering WebVR/WebXR. | maxCanvasHeight | Maximum canvas height. Behaves the same as maxCanvasWidth. | -1 | | multiviewStereo | Enables the use of the OCULUS_multiview extension. | false | | logarithmicDepthBuffer | Whether to use a logarithmic depth buffer. | auto | +| reversedDepthBuffer | Whether to use a reversed depth buffer. | false | | precision | Fragment shader [precision][precision] : low, medium or high. | high | | alpha | Whether the canvas should contain an alpha buffer. | true | | stencil | Whether the canvas should contain a stencil buffer. | false | @@ -50,6 +52,16 @@ It also configures presentation attributes when entering WebVR/WebXR. When enabled, smooths jagged edges on curved lines and diagonals at moderate performance cost. By default, antialiasing is disabled on mobile devices. +### backend + +Only applies when A-Frame runs with a three.js build that exposes `THREE.WebGPURenderer` +(for example by aliasing `three` to `three.webgpu.js` in an importmap, see the source of the +[webgpu example](https://aframe.io/aframe/examples/showcase/webgpu/)). By default the WebGPU +backend is used when available, falling back to WebGL 2 otherwise. Set +`renderer="backend: webgl"` to force the WebGL 2 backend of `THREE.WebGPURenderer` +(the `forceWebGL` option in three.js). This property is ignored with the default A-Frame +build that uses `THREE.WebGLRenderer`. + ### colorManagement Color management provides more accurate rendering and reduces the likelihood that scenes @@ -100,6 +112,13 @@ Some more background on how A-Frame sorts objects for rendering can be found [he A logarithmic depth buffer may provide better sorting and rendering in scenes containing very large differences of scale and distance. +### reversedDepthBuffer + +A reversed depth buffer distributes depth precision more evenly and can eliminate z-fighting +in scenes with large differences of scale and distance, with better performance than a +logarithmic depth buffer. With `THREE.WebGLRenderer` this requires the `EXT_clip_control` +WebGL extension. It is also supported by `THREE.WebGPURenderer` on both backends. + ### Precision Set precision in fragment shaders. Main use is to address issues in older hardware / drivers. Adreno 300 series GPU based phones are [particularly problematic](https://github.com/mrdoob/three.js/issues/14137). You can set to `mediump` as a workaround. It will improve performance, in mobile in particular but be aware that might cause visual artifacts in shaders / textures. @@ -110,4 +129,6 @@ Whether the canvas should contain an alpha buffer. If this is true the renderer ### multiviewStereo +When using `THREE.WebGPURenderer`, this property is passed as the `multiview` option of the renderer. + Performance improvement for applications that are CPU limited and draw count bound. Most experiences will get a free perf gain from this extension at not visual cost but there are limitations to consider. multiview builds on the multisampled render to texture extension that discards the frame buffer if there are other texture operations during rendering. Problem outlined in https://github.com/KhronosGroup/WebGL/issues/2912. Until browsers and drivers allow more control of when multisample is resolved we have a workaround with some drawbacks. As a temporary solution when enabling multiview the upload of texture data is deferred until the rendering of the main scene has ended, adding one extra frame of latency to texture uploads. Scenarios affected are for example skeletal meshes that upload bone textures with TexImage. With the workadound in place all bone animations will lag by one frame. Another issue is rendering mirror reflexions or rendering another view in the middle of the scene. The logic would have to move to the beginning of the frame to make sure it's not interrupted by the multiview frame. Because of the limitations this flag is disabled by default so developers can address any issues before enabling. diff --git a/src/core/scene/a-scene.js b/src/core/scene/a-scene.js index 5eb16658301..b1d39f4087c 100644 --- a/src/core/scene/a-scene.js +++ b/src/core/scene/a-scene.js @@ -567,7 +567,18 @@ export class AScene extends AEntity { } if (rendererAttr.multiviewStereo) { + // WebGLRenderer names this option multiviewStereo, WebGPURenderer names it multiview. rendererConfig.multiviewStereo = rendererAttr.multiviewStereo === 'true'; + rendererConfig.multiview = rendererConfig.multiviewStereo; + } + + if (rendererAttr.reversedDepthBuffer) { + rendererConfig.reversedDepthBuffer = rendererAttr.reversedDepthBuffer === 'true'; + } + + if (rendererAttr.backend) { + // Only used by WebGPURenderer; webgl forces the WebGL 2 backend. + rendererConfig.forceWebGL = rendererAttr.backend === 'webgl'; } this.maxCanvasSize = { diff --git a/src/systems/renderer.js b/src/systems/renderer.js index cd3ed0ed320..0d0d0254474 100644 --- a/src/systems/renderer.js +++ b/src/systems/renderer.js @@ -11,11 +11,13 @@ var warn = debug('components:renderer:warn'); export var System = registerSystem('renderer', { schema: { antialias: {default: 'auto', oneOf: ['true', 'false', 'auto']}, + backend: {default: 'webgpu', oneOf: ['webgl', 'webgpu']}, highRefreshRate: {default: utils.device.isOculusBrowser()}, logarithmicDepthBuffer: {default: 'auto', oneOf: ['true', 'false', 'auto']}, maxCanvasWidth: {default: -1}, maxCanvasHeight: {default: -1}, multiviewStereo: {default: false}, + reversedDepthBuffer: {default: false}, exposure: {default: 1, if: {toneMapping: ['ACESFilmic', 'linear', 'reinhard', 'cineon', 'AgX', 'neutral']}}, toneMapping: {default: 'no', oneOf: ['no', 'ACESFilmic', 'linear', 'reinhard', 'cineon', 'AgX', 'neutral']}, precision: {default: 'high', oneOf: ['high', 'medium', 'low']}, From 4994837a1d110a93ebe246eb253dfe5287770e60 Mon Sep 17 00:00:00 2001 From: Vincent Fretin Date: Sat, 4 Jul 2026 16:15:00 +0200 Subject: [PATCH 2/4] Wait for async renderer init before starting the render loop, add renderer config tests WebGPURenderer initializes its backend asynchronously via init(). Previously renderStarted was set and renderstart emitted while the backend was still initializing (three.js setAnimationLoop recovers by awaiting init internally, but anything reacting to renderstart could call renderer methods that throw before initialization). The render loop kickoff now awaits renderer.init() when it exists; WebGLRenderer has no init method and keeps the synchronous path. Extract getRendererConfig() from setupRenderer() so the renderer attribute parsing is unit-testable without creating a WebGL context, and add tests for multiviewStereo/multiview, reversedDepthBuffer, backend and the async init behavior. Co-Authored-By: Claude Fable 5 --- src/core/scene/a-scene.js | 46 ++++++++++------ tests/core/scene/a-scene.test.js | 90 ++++++++++++++++++++++++++++++++ 2 files changed, 121 insertions(+), 15 deletions(-) diff --git a/src/core/scene/a-scene.js b/src/core/scene/a-scene.js index b1d39f4087c..5fc759eca0a 100644 --- a/src/core/scene/a-scene.js +++ b/src/core/scene/a-scene.js @@ -528,6 +528,25 @@ export class AScene extends AEntity { setupRenderer () { var self = this; var renderer; + var rendererConfig = this.getRendererConfig(); + + // Trick Webpack so that it can't statically determine the exact export used. + // Otherwise it will conclude that one of the two exports can't be found in THREE. + // Only one needs to exist, and this should be determined at runtime. + var rendererImpl = ['WebGLRenderer', 'WebGPURenderer'].find(function (x) { return THREE[x]; }); + renderer = this.renderer = new THREE[rendererImpl](rendererConfig); + if (!renderer.xr.setPoseTarget) { + renderer.xr.setPoseTarget = function () {}; + } + renderer.setPixelRatio(window.devicePixelRatio); + + if (this.camera) { renderer.xr.setPoseTarget(this.camera.el.object3D); } + this.addEventListener('camera-set-active', function () { + renderer.xr.setPoseTarget(self.camera.el.object3D); + }); + } + + getRendererConfig () { var rendererAttr; var rendererAttrString; var rendererConfig; @@ -591,20 +610,7 @@ export class AScene extends AEntity { }; } - // Trick Webpack so that it can't statically determine the exact export used. - // Otherwise it will conclude that one of the two exports can't be found in THREE. - // Only one needs to exist, and this should be determined at runtime. - var rendererImpl = ['WebGLRenderer', 'WebGPURenderer'].find(function (x) { return THREE[x]; }); - renderer = this.renderer = new THREE[rendererImpl](rendererConfig); - if (!renderer.xr.setPoseTarget) { - renderer.xr.setPoseTarget = function () {}; - } - renderer.setPixelRatio(window.devicePixelRatio); - - if (this.camera) { renderer.xr.setPoseTarget(this.camera.el.object3D); } - this.addEventListener('camera-set-active', function () { - renderer.xr.setPoseTarget(self.camera.el.object3D); - }); + return rendererConfig; } /** @@ -629,9 +635,19 @@ export class AScene extends AEntity { // Kick off render loop. if (sceneEl.renderer) { + if (renderer.init) { + // WebGPURenderer initializes its backend asynchronously, + // wait for it before starting the render loop. + renderer.init().then(startRenderLoop); + } else { + startRenderLoop(); + } + } + + function startRenderLoop () { if (window.performance) { window.performance.mark('render-started'); } loadingScreen.remove(); - renderer.setAnimationLoop(this.render); + renderer.setAnimationLoop(sceneEl.render); sceneEl.renderStarted = true; sceneEl.emit('renderstart'); } diff --git a/tests/core/scene/a-scene.test.js b/tests/core/scene/a-scene.test.js index 7938979f3f7..960736e23ed 100644 --- a/tests/core/scene/a-scene.test.js +++ b/tests/core/scene/a-scene.test.js @@ -718,6 +718,96 @@ helpers.getSkipCISuite()('a-scene (with renderer)', function () { }); }); +suite('getRendererConfig', function () { + var sceneEl; + + setup(function () { + sceneEl = document.createElement('a-scene'); + }); + + test('default configuration', function () { + var config = sceneEl.getRendererConfig(); + assert.strictEqual(config.alpha, true); + assert.strictEqual(config.logarithmicDepthBuffer, false); + assert.strictEqual(config.powerPreference, 'high-performance'); + assert.notOk('multiviewStereo' in config); + assert.notOk('multiview' in config); + assert.notOk('reversedDepthBuffer' in config); + assert.notOk('forceWebGL' in config); + }); + + test('multiviewStereo is also passed as the multiview option used by WebGPURenderer', function () { + sceneEl.setAttribute('renderer', 'multiviewStereo: true'); + var config = sceneEl.getRendererConfig(); + assert.strictEqual(config.multiviewStereo, true); + assert.strictEqual(config.multiview, true); + }); + + test('multiviewStereo false', function () { + sceneEl.setAttribute('renderer', 'multiviewStereo: false'); + var config = sceneEl.getRendererConfig(); + assert.strictEqual(config.multiviewStereo, false); + assert.strictEqual(config.multiview, false); + }); + + test('reversedDepthBuffer true', function () { + sceneEl.setAttribute('renderer', 'reversedDepthBuffer: true'); + var config = sceneEl.getRendererConfig(); + assert.strictEqual(config.reversedDepthBuffer, true); + }); + + test('reversedDepthBuffer false', function () { + sceneEl.setAttribute('renderer', 'reversedDepthBuffer: false'); + var config = sceneEl.getRendererConfig(); + assert.strictEqual(config.reversedDepthBuffer, false); + }); + + test('backend webgl forces the WebGL backend of WebGPURenderer', function () { + sceneEl.setAttribute('renderer', 'backend: webgl'); + var config = sceneEl.getRendererConfig(); + assert.strictEqual(config.forceWebGL, true); + }); + + test('backend webgpu does not force the WebGL backend', function () { + sceneEl.setAttribute('renderer', 'backend: webgpu'); + var config = sceneEl.getRendererConfig(); + assert.strictEqual(config.forceWebGL, false); + }); +}); + +suite('render loop start', function () { + test('starts the render loop synchronously with WebGLRenderer', function (done) { + var el = document.createElement('a-scene'); + el.addEventListener('renderstart', function () { + assert.ok(el.renderStarted); + done(); + }); + document.body.appendChild(el); + }); + + test('waits for async renderer init (WebGPURenderer) before starting the render loop', function (done) { + var el = document.createElement('a-scene'); + var resolveInit; + // Mimic WebGPURenderer which initializes its backend asynchronously via init(). + el.renderer = Object.create(AScene.prototype.renderer); + el.renderer.init = function () { + return new Promise(function (resolve) { resolveInit = resolve; }); + }; + el.addEventListener('loaded', function () { + setTimeout(function () { + assert.ok(resolveInit, 'renderer init was called'); + assert.notOk(el.renderStarted, 'render loop does not start before init resolves'); + resolveInit(); + }); + }); + el.addEventListener('renderstart', function () { + assert.ok(el.renderStarted); + done(); + }); + document.body.appendChild(el); + }); +}); + suite('scenes', function () { var sceneEl; From 4791e65dfa41c12cd6a42042c8e24bea5c9aa006 Mon Sep 17 00:00:00 2001 From: Vincent Fretin Date: Sat, 4 Jul 2026 16:22:14 +0200 Subject: [PATCH 3/4] Add note about setPoseTarget not implemented for WebGPURenderer setPoseTarget is an A-Frame specific patch in super-three to the WebXRManager used by WebGLRenderer. An equivalent will need to be implemented in super-three for the XRManager used by WebGPURenderer when WebXR support for WebGPURenderer is added. Co-Authored-By: Claude Fable 5 --- src/core/scene/a-scene.js | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/core/scene/a-scene.js b/src/core/scene/a-scene.js index 5fc759eca0a..12ceedbc507 100644 --- a/src/core/scene/a-scene.js +++ b/src/core/scene/a-scene.js @@ -536,6 +536,10 @@ export class AScene extends AEntity { var rendererImpl = ['WebGLRenderer', 'WebGPURenderer'].find(function (x) { return THREE[x]; }); renderer = this.renderer = new THREE[rendererImpl](rendererConfig); if (!renderer.xr.setPoseTarget) { + // setPoseTarget is an A-Frame specific patch in super-three to the WebXRManager + // used by WebGLRenderer. It will need to be implemented similarly in super-three + // for the XRManager used by WebGPURenderer when WebXR support for WebGPURenderer + // is added. renderer.xr.setPoseTarget = function () {}; } renderer.setPixelRatio(window.devicePixelRatio); From 5dbd5b577f81abc6a6df646f58e823bccaca6f88 Mon Sep 17 00:00:00 2001 From: Vincent Fretin Date: Sun, 5 Jul 2026 16:47:51 +0200 Subject: [PATCH 4/4] Rename renderer backend property value webgpu to auto As suggested in PR review, the default value is auto (WebGPU when available, falling back to WebGL 2) since the WebGPU backend is not guaranteed. Co-Authored-By: Claude Fable 5 --- docs/components/renderer.md | 8 ++++---- src/systems/renderer.js | 2 +- tests/core/scene/a-scene.test.js | 4 ++-- 3 files changed, 7 insertions(+), 7 deletions(-) diff --git a/docs/components/renderer.md b/docs/components/renderer.md index 98ef0d74bdb..de2dc0b555c 100644 --- a/docs/components/renderer.md +++ b/docs/components/renderer.md @@ -28,7 +28,7 @@ It also configures presentation attributes when entering WebVR/WebXR. | Property | Description | Default Value | |-------------------------|---------------------------------------------------------------------------------|---------------| | antialias | Whether to perform antialiasing. If `auto`, antialiasing is disabled on mobile. | auto | -| backend | Backend used by `THREE.WebGPURenderer`: webgpu or webgl. Ignored with the default build using `THREE.WebGLRenderer`. | webgpu | +| backend | Backend used by `THREE.WebGPURenderer`: auto (WebGPU if available, WebGL 2 otherwise) or webgl. Ignored with the default build using `THREE.WebGLRenderer`. | auto | | colorManagement | Whether to use a color-managed linear workflow. | true | | highRefreshRate | Increases frame rate from the default (for browsers that support control of frame rate). | false | | foveationLevel | Amount of foveation used in VR to improve perf, from 0 (min) to 1 (max). | 1 | @@ -56,9 +56,9 @@ By default, antialiasing is disabled on mobile devices. Only applies when A-Frame runs with a three.js build that exposes `THREE.WebGPURenderer` (for example by aliasing `three` to `three.webgpu.js` in an importmap, see the source of the -[webgpu example](https://aframe.io/aframe/examples/showcase/webgpu/)). By default the WebGPU -backend is used when available, falling back to WebGL 2 otherwise. Set -`renderer="backend: webgl"` to force the WebGL 2 backend of `THREE.WebGPURenderer` +[webgpu example](https://aframe.io/aframe/examples/showcase/webgpu/)). With the default +`auto` value, the WebGPU backend is used when available, falling back to WebGL 2 otherwise. +Set `renderer="backend: webgl"` to force the WebGL 2 backend of `THREE.WebGPURenderer` (the `forceWebGL` option in three.js). This property is ignored with the default A-Frame build that uses `THREE.WebGLRenderer`. diff --git a/src/systems/renderer.js b/src/systems/renderer.js index 0d0d0254474..11da89da7b3 100644 --- a/src/systems/renderer.js +++ b/src/systems/renderer.js @@ -11,7 +11,7 @@ var warn = debug('components:renderer:warn'); export var System = registerSystem('renderer', { schema: { antialias: {default: 'auto', oneOf: ['true', 'false', 'auto']}, - backend: {default: 'webgpu', oneOf: ['webgl', 'webgpu']}, + backend: {default: 'auto', oneOf: ['auto', 'webgl']}, highRefreshRate: {default: utils.device.isOculusBrowser()}, logarithmicDepthBuffer: {default: 'auto', oneOf: ['true', 'false', 'auto']}, maxCanvasWidth: {default: -1}, diff --git a/tests/core/scene/a-scene.test.js b/tests/core/scene/a-scene.test.js index 960736e23ed..f3b4a82680d 100644 --- a/tests/core/scene/a-scene.test.js +++ b/tests/core/scene/a-scene.test.js @@ -768,8 +768,8 @@ suite('getRendererConfig', function () { assert.strictEqual(config.forceWebGL, true); }); - test('backend webgpu does not force the WebGL backend', function () { - sceneEl.setAttribute('renderer', 'backend: webgpu'); + test('backend auto does not force the WebGL backend', function () { + sceneEl.setAttribute('renderer', 'backend: auto'); var config = sceneEl.getRendererConfig(); assert.strictEqual(config.forceWebGL, false); });