From b9e0413623476dfb4e4ae74f933e28edde5fa3d4 Mon Sep 17 00:00:00 2001 From: Vincent Fretin Date: Sat, 4 Jul 2026 16:42:32 +0200 Subject: [PATCH 1/3] Adapt reflection and screenshot components to WebGPURenderer - Use CubeRenderTarget with WebGPURenderer (WebGLCubeRenderTarget is not exported by the three.webgpu.js build), selected at runtime like the renderer implementation in a-scene. This also removes a webpack warning when building with WEBGPU=true. - WebGPURenderer does not support RawShaderMaterial; the equirectangular cubemap-to-equirect projection is implemented with TSL nodes when THREE.TSL is available. - WebGPURenderer only supports asynchronous pixels read back, use readRenderTargetPixelsAsync when readRenderTargetPixels is not available. getCanvas() returns a Promise resolving to the canvas with WebGPURenderer; it still returns the canvas synchronously with WebGLRenderer. - Use RGBAFormat instead of RGBFormat for the cube render target; RGBFormat is not color-renderable with the WebGL 2 backend of WebGPURenderer. Co-Authored-By: Claude Fable 5 --- docs/components/screenshot.md | 4 ++ src/components/scene/reflection.js | 9 ++- src/components/scene/screenshot.js | 94 ++++++++++++++++++++++++------ 3 files changed, 86 insertions(+), 21 deletions(-) diff --git a/docs/components/screenshot.md b/docs/components/screenshot.md index 942b5028ba7..ddfcf2c82c1 100644 --- a/docs/components/screenshot.md +++ b/docs/components/screenshot.md @@ -43,6 +43,10 @@ To take a screenshot programmatically and get a canvas, call `getCanvas()`: document.querySelector('a-scene').components.screenshot.getCanvas('equirectangular'); ``` +> **NOTE:** When using `THREE.WebGPURenderer`, pixels are read back from the GPU +> asynchronously, so `getCanvas()` returns a `Promise` resolving to the canvas +> instead of the canvas itself. + To take a screenshot programmatically and automatically save the file, call `capture()`: ```js diff --git a/src/components/scene/reflection.js b/src/components/scene/reflection.js index b1cff18a303..93ef7aed1c0 100644 --- a/src/components/scene/reflection.js +++ b/src/components/scene/reflection.js @@ -2,6 +2,11 @@ import * as THREE from 'three'; import { registerComponent as register } from '../../core/component.js'; +// WebGLRenderer uses WebGLCubeRenderTarget, WebGPURenderer uses CubeRenderTarget. +// Written so that Webpack can't statically determine the export used; +// only one of the two exists depending on the three.js build. +var cubeRenderTargetImpl = ['WebGLCubeRenderTarget', 'CubeRenderTarget'].find(function (x) { return THREE[x]; }); + // source: view-source:https://storage.googleapis.com/chromium-webxr-test/r886480/proposals/lighting-estimation.html function updateLights (estimate, probeLight, directionalLight, directionalLightPosition) { var intensityScalar = @@ -30,9 +35,9 @@ export var Component = register('reflection', { sceneOnly: true, init: function () { var self = this; - this.cubeRenderTarget = new THREE.WebGLCubeRenderTarget(16); + this.cubeRenderTarget = new THREE[cubeRenderTargetImpl](16); this.cubeCamera = new THREE.CubeCamera(0.1, 1000, this.cubeRenderTarget); - this.lightingEstimationTexture = (new THREE.WebGLCubeRenderTarget(16)).texture; + this.lightingEstimationTexture = (new THREE[cubeRenderTargetImpl](16)).texture; this.needsVREnvironmentUpdate = true; // Update WebXR to support light-estimation diff --git a/src/components/scene/screenshot.js b/src/components/scene/screenshot.js index 0ce36cff28b..b8ed74dc06e 100644 --- a/src/components/scene/screenshot.js +++ b/src/components/scene/screenshot.js @@ -2,6 +2,11 @@ import { registerComponent } from '../../core/component.js'; import * as THREE from 'three'; +// WebGLRenderer uses WebGLCubeRenderTarget, WebGPURenderer uses CubeRenderTarget. +// Written so that Webpack can't statically determine the export used; +// only one of the two exists depending on the three.js build. +var cubeRenderTargetImpl = ['WebGLCubeRenderTarget', 'CubeRenderTarget'].find(function (x) { return THREE[x]; }); + var VERTEX_SHADER = [ 'attribute vec3 position;', 'attribute vec2 uv;', @@ -57,13 +62,18 @@ export var Component = registerComponent('screenshot', { if (this.canvas) { return; } var gl = el.renderer.getContext(); if (!gl) { return; } - this.cubeMapSize = gl.getParameter(gl.MAX_CUBE_MAP_TEXTURE_SIZE); - this.material = new THREE.RawShaderMaterial({ - uniforms: {map: {type: 't', value: null}}, - vertexShader: VERTEX_SHADER, - fragmentShader: FRAGMENT_SHADER, - side: THREE.DoubleSide - }); + this.cubeMapSize = gl.getParameter ? gl.getParameter(gl.MAX_CUBE_MAP_TEXTURE_SIZE) : 2048; + // WebGPURenderer does not support RawShaderMaterial, use a node material with TSL. + if (THREE.TSL) { + this.material = this.createNodeMaterial(); + } else { + this.material = new THREE.RawShaderMaterial({ + uniforms: {map: {type: 't', value: null}}, + vertexShader: VERTEX_SHADER, + fragmentShader: FRAGMENT_SHADER, + side: THREE.DoubleSide + }); + } this.quad = new THREE.Mesh( new THREE.PlaneGeometry(1, 1), this.material @@ -76,6 +86,26 @@ export var Component = registerComponent('screenshot', { this.onKeyDown = this.onKeyDown.bind(this); }, + /** + * TSL (node material) equivalent of the raw GLSL shaders above, used with + * WebGPURenderer which does not support RawShaderMaterial. + */ + createNodeMaterial: function () { + var TSL = THREE.TSL; + var material = new THREE.MeshBasicNodeMaterial({side: THREE.DoubleSide}); + var uv = TSL.uv(); + var longitude = TSL.float(1).sub(uv.x).mul(2 * Math.PI).sub(Math.PI).add(Math.PI / 2); + var latitude = uv.y.mul(Math.PI); + var dir = TSL.vec3( + TSL.sin(longitude).mul(TSL.sin(latitude)).negate(), + TSL.cos(latitude), + TSL.cos(longitude).mul(TSL.sin(latitude)).negate() + ); + this.cubeTextureNode = TSL.cubeTexture(new THREE.CubeTexture(), dir); + material.colorNode = TSL.vec4(this.cubeTextureNode.rgb, 1); + return material; + }, + getRenderTarget: function (width, height) { return new THREE.WebGLRenderTarget(width, height, { colorSpace: this.el.sceneEl.renderer.outputColorSpace, @@ -140,10 +170,10 @@ export var Component = registerComponent('screenshot', { } else { // Use ortho camera. camera = this.camera; - cubeRenderTarget = new THREE.WebGLCubeRenderTarget( + cubeRenderTarget = new THREE[cubeRenderTargetImpl]( Math.min(this.cubeMapSize, 2048), { - format: THREE.RGBFormat, + format: THREE.RGBAFormat, generateMipmaps: true, minFilter: THREE.LinearMipmapLinearFilter, colorSpace: THREE.SRGBColorSpace @@ -155,7 +185,11 @@ export var Component = registerComponent('screenshot', { el.camera.getWorldQuaternion(cubeCamera.quaternion); // Render scene with cube camera. cubeCamera.update(el.renderer, el.object3D); - this.quad.material.uniforms.map.value = cubeCamera.renderTarget.texture; + if (this.cubeTextureNode) { + this.cubeTextureNode.value = cubeCamera.renderTarget.texture; + } else { + this.quad.material.uniforms.map.value = cubeCamera.renderTarget.texture; + } size = {width: this.data.width, height: this.data.height}; // Use quad to project image taken by the cube camera. this.quad.visible = true; @@ -174,19 +208,25 @@ export var Component = registerComponent('screenshot', { var isVREnabled = this.el.renderer.xr.enabled; var renderer = this.el.renderer; var params; + var self = this; this.setup(); // Disable VR. renderer.xr.enabled = false; params = this.setCapture(projection); - this.renderCapture(params.camera, params.size, params.projection); - // Trigger file download. - this.saveCapture(); + this.renderCapture(params.camera, params.size, params.projection) + .then(function () { + // Trigger file download. + self.saveCapture(); + }); // Restore VR. renderer.xr.enabled = isVREnabled; }, /** * Return canvas instead of triggering download (e.g., for uploading blob to server). + * With WebGLRenderer the canvas is returned synchronously. + * With WebGPURenderer pixels are read back asynchronously, so a Promise + * resolving to the canvas is returned instead. */ getCanvas: function (projection) { var isVREnabled = this.el.renderer.xr.enabled; @@ -195,22 +235,22 @@ export var Component = registerComponent('screenshot', { // Disable VR. var params = this.setCapture(projection); renderer.xr.enabled = false; - this.renderCapture(params.camera, params.size, params.projection); + var promise = this.renderCapture(params.camera, params.size, params.projection); // Restore VR. renderer.xr.enabled = isVREnabled; - return this.canvas; + if (renderer.readRenderTargetPixels) { return this.canvas; } + return promise; }, renderCapture: function (camera, size, projection) { var autoClear = this.el.renderer.autoClear; var el = this.el; - var imageData; var output; var pixels; var renderer = el.renderer; + var self = this; // Create rendering target and buffer to store the read pixels. output = this.getRenderTarget(size.width, size.height); - pixels = new Uint8Array(4 * size.width * size.height); // Resize quad, camera, and canvas. this.resize(size.width, size.height); // Render scene to render target. @@ -220,8 +260,24 @@ export var Component = registerComponent('screenshot', { renderer.render(el.object3D, camera); renderer.autoClear = autoClear; // Read image pixels back. - renderer.readRenderTargetPixels(output, 0, 0, size.width, size.height, pixels); - renderer.setRenderTarget(null); + if (renderer.readRenderTargetPixels) { + pixels = new Uint8Array(4 * size.width * size.height); + renderer.readRenderTargetPixels(output, 0, 0, size.width, size.height, pixels); + renderer.setRenderTarget(null); + this.copyCapture(pixels, size, projection); + return Promise.resolve(this.canvas); + } + // WebGPURenderer only supports reading pixels back asynchronously. + return renderer.readRenderTargetPixelsAsync(output, 0, 0, size.width, size.height) + .then(function (pixels) { + renderer.setRenderTarget(null); + self.copyCapture(pixels, size, projection); + return self.canvas; + }); + }, + + copyCapture: function (pixels, size, projection) { + var imageData; if (projection === 'perspective') { pixels = this.flipPixelsVertically(pixels, size.width, size.height); } From 2a9ab7de7b924f1c85beafdd236975dfaa67bf50 Mon Sep 17 00:00:00 2001 From: Vincent Fretin Date: Sun, 5 Jul 2026 17:33:03 +0200 Subject: [PATCH 2/3] Fix screenshot orientation with the WebGPU backend, address review comments - Pixels read back from WebGL are bottom-up but top-down with the WebGPU backend of WebGPURenderer, so the vertical flip is now based on the renderer coordinate system. The equirectangular projection quad renders vertically inverted so it needs the opposite flip of the perspective projection. Verified on an actual WebGPU backend (NVIDIA, Vulkan), on the WebGL 2 fallback backend and on WebGLRenderer. - Query maxTextureDimension2D from the WebGPU device limits for cubeMapSize instead of hardcoding 2048. - Rename gl to ctx in setup() since it can be a GPUCanvasContext. - Store this.cubeTextureUniform for both the ShaderMaterial uniform and the TSL cubeTexture node, both expose the texture as a value property. Co-Authored-By: Claude Fable 5 --- src/components/scene/screenshot.js | 30 +++++++++++++++++++----------- 1 file changed, 19 insertions(+), 11 deletions(-) diff --git a/src/components/scene/screenshot.js b/src/components/scene/screenshot.js index b8ed74dc06e..9128737131f 100644 --- a/src/components/scene/screenshot.js +++ b/src/components/scene/screenshot.js @@ -60,9 +60,15 @@ export var Component = registerComponent('screenshot', { setup: function () { var el = this.el; if (this.canvas) { return; } - var gl = el.renderer.getContext(); - if (!gl) { return; } - this.cubeMapSize = gl.getParameter ? gl.getParameter(gl.MAX_CUBE_MAP_TEXTURE_SIZE) : 2048; + var ctx = el.renderer.getContext(); + if (!ctx) { return; } + if (ctx.getParameter) { + this.cubeMapSize = ctx.getParameter(ctx.MAX_CUBE_MAP_TEXTURE_SIZE); + } else { + // ctx is a GPUCanvasContext; cube map faces are limited by maxTextureDimension2D. + var device = el.renderer.backend.device; + this.cubeMapSize = device ? device.limits.maxTextureDimension2D : 2048; + } // WebGPURenderer does not support RawShaderMaterial, use a node material with TSL. if (THREE.TSL) { this.material = this.createNodeMaterial(); @@ -73,6 +79,7 @@ export var Component = registerComponent('screenshot', { fragmentShader: FRAGMENT_SHADER, side: THREE.DoubleSide }); + this.cubeTextureUniform = this.material.uniforms.map; } this.quad = new THREE.Mesh( new THREE.PlaneGeometry(1, 1), @@ -101,8 +108,8 @@ export var Component = registerComponent('screenshot', { TSL.cos(latitude), TSL.cos(longitude).mul(TSL.sin(latitude)).negate() ); - this.cubeTextureNode = TSL.cubeTexture(new THREE.CubeTexture(), dir); - material.colorNode = TSL.vec4(this.cubeTextureNode.rgb, 1); + this.cubeTextureUniform = TSL.cubeTexture(new THREE.CubeTexture(), dir); + material.colorNode = TSL.vec4(this.cubeTextureUniform.rgb, 1); return material; }, @@ -185,11 +192,7 @@ export var Component = registerComponent('screenshot', { el.camera.getWorldQuaternion(cubeCamera.quaternion); // Render scene with cube camera. cubeCamera.update(el.renderer, el.object3D); - if (this.cubeTextureNode) { - this.cubeTextureNode.value = cubeCamera.renderTarget.texture; - } else { - this.quad.material.uniforms.map.value = cubeCamera.renderTarget.texture; - } + this.cubeTextureUniform.value = cubeCamera.renderTarget.texture; size = {width: this.data.width, height: this.data.height}; // Use quad to project image taken by the cube camera. this.quad.visible = true; @@ -278,7 +281,12 @@ export var Component = registerComponent('screenshot', { copyCapture: function (pixels, size, projection) { var imageData; - if (projection === 'perspective') { + // Pixels read back from WebGL are bottom-up, they are top-down with the + // WebGPU backend of WebGPURenderer. The equirectangular projection quad + // renders vertically inverted, so it needs the opposite flip of the + // perspective projection. + var topDown = this.el.renderer.coordinateSystem === THREE.WebGPUCoordinateSystem; + if ((projection === 'perspective') !== topDown) { pixels = this.flipPixelsVertically(pixels, size.width, size.height); } imageData = new ImageData(new Uint8ClampedArray(pixels), size.width, size.height); From f4452818d849bd575bec0921fa717ed76b23676b Mon Sep 17 00:00:00 2001 From: Vincent Fretin Date: Sun, 5 Jul 2026 18:08:31 +0200 Subject: [PATCH 3/3] Fix webpack warning about TSL export with the default three.js build TSL and MeshBasicNodeMaterial only exist in the three.js build with WebGPURenderer, access them so that Webpack can't statically determine the export used, like for CubeRenderTarget. Co-Authored-By: Claude Fable 5 --- src/components/scene/screenshot.js | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/src/components/scene/screenshot.js b/src/components/scene/screenshot.js index 9128737131f..101f4e7edaf 100644 --- a/src/components/scene/screenshot.js +++ b/src/components/scene/screenshot.js @@ -6,6 +6,10 @@ import * as THREE from 'three'; // Written so that Webpack can't statically determine the export used; // only one of the two exists depending on the three.js build. var cubeRenderTargetImpl = ['WebGLCubeRenderTarget', 'CubeRenderTarget'].find(function (x) { return THREE[x]; }); +// TSL and MeshBasicNodeMaterial only exist in the three.js build with +// WebGPURenderer, same Webpack trick as above. +var TSL = ['TSL'].map(function (x) { return THREE[x]; })[0]; +var MeshBasicNodeMaterial = ['MeshBasicNodeMaterial'].map(function (x) { return THREE[x]; })[0]; var VERTEX_SHADER = [ 'attribute vec3 position;', @@ -70,7 +74,7 @@ export var Component = registerComponent('screenshot', { this.cubeMapSize = device ? device.limits.maxTextureDimension2D : 2048; } // WebGPURenderer does not support RawShaderMaterial, use a node material with TSL. - if (THREE.TSL) { + if (TSL) { this.material = this.createNodeMaterial(); } else { this.material = new THREE.RawShaderMaterial({ @@ -98,8 +102,7 @@ export var Component = registerComponent('screenshot', { * WebGPURenderer which does not support RawShaderMaterial. */ createNodeMaterial: function () { - var TSL = THREE.TSL; - var material = new THREE.MeshBasicNodeMaterial({side: THREE.DoubleSide}); + var material = new MeshBasicNodeMaterial({side: THREE.DoubleSide}); var uv = TSL.uv(); var longitude = TSL.float(1).sub(uv.x).mul(2 * Math.PI).sub(Math.PI).add(Math.PI / 2); var latitude = uv.y.mul(Math.PI);