From 8e6db845c92f24167fd24181c2c0f254f71f1045 Mon Sep 17 00:00:00 2001 From: KPal <48248865+kpal81xd@users.noreply.github.com> Date: Fri, 23 Oct 2026 21:15:28 +0100 Subject: [PATCH 01/22] fix: Gizmo fixes for editor integration: disabled components, centered icons and flat boxes (#9817) * fix: Draw disabled cameras and lights, center the directional light icon, and add script and anim icons * feat: Add iconOcclusion to show or fade each gizmo icon by the scene depth at its center * fix: Keep flat box axes in place on corner drags and drop their face handles * refactor: Draw attached cameras and lights whether their component is enabled, dropping drawDisabled * Revert "feat: Add iconOcclusion to show or fade each gizmo icon by the scene depth at its center" This reverts commit e35cceff537c22baaa35d631419b0429f9d87c0a. * chore: Remove stray blank lines in light and icon gizmos * fix: Draw the directional light icon over its arrow and rays * feat: Show the anim and script icons in the icon gizmo example * fix: Redraw the sound icon as one speaker outline with rounded waves --- .../src/examples/gizmos/icon.controls.jsx | 4 ++- examples/src/examples/gizmos/icon.example.mjs | 8 +++-- src/extras/gizmo/gizmo.js | 8 +++-- src/extras/gizmo/handle/camera-gizmo.js | 5 +-- src/extras/gizmo/handle/handle-gizmo.js | 31 +++++++++++++++++-- src/extras/gizmo/handle/light-gizmo.js | 12 ++++--- src/extras/gizmo/icon-gizmo.js | 5 +-- src/extras/gizmo/overlay/icon-glyphs.js | 26 ++++++++++++---- src/extras/gizmo/overlay/overlay-renderer.js | 3 +- 9 files changed, 79 insertions(+), 23 deletions(-) diff --git a/examples/src/examples/gizmos/icon.controls.jsx b/examples/src/examples/gizmos/icon.controls.jsx index 208dab72778..63e48182446 100644 --- a/examples/src/examples/gizmos/icon.controls.jsx +++ b/examples/src/examples/gizmos/icon.controls.jsx @@ -33,7 +33,9 @@ export function Controls({ observer }) { { v: 'light', t: 'Light' }, { v: 'audiolistener', t: 'Audio Listener' }, { v: 'sound', t: 'Sound' }, - { v: 'particlesystem', t: 'Particle System' } + { v: 'particlesystem', t: 'Particle System' }, + { v: 'anim', t: 'Anim' }, + { v: 'script', t: 'Script' } ]} binding={new BindingTwoWay()} link={{ observer, path: 'icon' }} diff --git a/examples/src/examples/gizmos/icon.example.mjs b/examples/src/examples/gizmos/icon.example.mjs index 364b67d5464..4b152361fd7 100644 --- a/examples/src/examples/gizmos/icon.example.mjs +++ b/examples/src/examples/gizmos/icon.example.mjs @@ -3,6 +3,7 @@ // Icons mark entities that have nothing else to see or click, such as lights, cameras and sounds; click one to focus on it import { + AnimComponentSystem, AppBase, AppOptions, AudioListenerComponentSystem, @@ -59,7 +60,8 @@ createOptions.componentSystems = [ ScriptComponentSystem, AudioListenerComponentSystem, SoundComponentSystem, - ParticleSystemComponentSystem + ParticleSystemComponentSystem, + AnimComponentSystem ]; createOptions.resourceHandlers = [ScriptHandler]; @@ -109,7 +111,9 @@ const subjects = { light: { light: { type: 'omni', range: 2, color: new Color(1, 0.85, 0.6) } }, audiolistener: { audiolistener: {} }, sound: { sound: {} }, - particlesystem: { particlesystem: { autoPlay: false } } + particlesystem: { particlesystem: { autoPlay: false } }, + anim: { anim: {} }, + script: { script: {} } }; /** * @type {Record} diff --git a/src/extras/gizmo/gizmo.js b/src/extras/gizmo/gizmo.js index bd0705b082f..823c71443e5 100644 --- a/src/extras/gizmo/gizmo.js +++ b/src/extras/gizmo/gizmo.js @@ -335,6 +335,7 @@ class Gizmo extends EventHandler { this._device.canvas.addEventListener('pointerdown', this._onPointerDown); this._device.canvas.addEventListener('pointermove', this._onPointerMove); this._device.canvas.addEventListener('pointerup', this._onPointerUp); + this._device.canvas.addEventListener('pointercancel', this._onPointerUp); this._handles.push(this._app.on('prerender', () => this.prerender())); this._handles.push(this._app.on('update', () => this.update())); @@ -571,12 +572,14 @@ class Gizmo extends EventHandler { * @private */ _onPointerUp(e) { - if (!this.mouseButtons[e.button] || e.pointerId !== this._pointerId) { + // a cancelled pointer has no button, and ends the drag without a hit + const cancel = e.type === 'pointercancel'; + if ((!cancel && !this.mouseButtons[e.button]) || e.pointerId !== this._pointerId) { return; } // end the drag even if disabled or pointer locked mid-drag - const active = this.enabled && !document.pointerLockElement; + const active = !cancel && this.enabled && !document.pointerLockElement; const selection = active ? this._getSelection(e.offsetX, e.offsetY) : []; if (selection[0]) { if (this.preventDefault) { @@ -861,6 +864,7 @@ class Gizmo extends EventHandler { this._device.canvas.removeEventListener('pointerdown', this._onPointerDown); this._device.canvas.removeEventListener('pointermove', this._onPointerMove); this._device.canvas.removeEventListener('pointerup', this._onPointerUp); + this._device.canvas.removeEventListener('pointercancel', this._onPointerUp); this._handles.forEach(handle => handle.off()); diff --git a/src/extras/gizmo/handle/camera-gizmo.js b/src/extras/gizmo/handle/camera-gizmo.js index c35a4a9ecae..4e38af23531 100644 --- a/src/extras/gizmo/handle/camera-gizmo.js +++ b/src/extras/gizmo/handle/camera-gizmo.js @@ -37,7 +37,8 @@ const MIN_CLIP = 1e-3; /** * Draws the view frustum of each attached camera entity, with a triangle marking its up direction. * Handles drag the near and far clip planes and the field of view or orthographic height. The - * camera viewing the gizmo is skipped. + * camera viewing the gizmo and disabled entities are skipped, but a disabled camera component is + * still drawn. * * ```javascript * const layer = Gizmo.createLayer(app, 'Overlays', undefined, true); @@ -156,7 +157,7 @@ class CameraGizmo extends HandleGizmo { for (let i = 0; i < this.nodes.length; i++) { const entity = /** @type {Entity} */ (this.nodes[i]); const { camera } = entity; - if (entity.enabled && camera?.enabled && camera !== this._camera) { + if (entity.enabled && camera && camera !== this._camera) { this._draw(entity); } } diff --git a/src/extras/gizmo/handle/handle-gizmo.js b/src/extras/gizmo/handle/handle-gizmo.js index 602a462de16..78897dafbd5 100644 --- a/src/extras/gizmo/handle/handle-gizmo.js +++ b/src/extras/gizmo/handle/handle-gizmo.js @@ -89,6 +89,9 @@ const BOX_FACES = 6; const BOX_MIN_HALF = 0.005; const BOX_MIN_FACTOR = 0.01; +// a box axis no longer than this, padding left out, is flat and keeps its size and center +const BOX_FLAT_HALF = 1e-6; + const RINGS_SIZE = 40; const DETAIL_FADE_START = 60; @@ -739,6 +742,9 @@ class HandleGizmo extends DragGizmo { if (options.faceHandles) { for (let i = 0; i < BOX_FACES; i++) { const k = i >> 1; + if (halfExtents[BOX_AXES[k]] - pad <= BOX_FLAT_HALF) { + continue; + } const dir = tmpV2.copy(boxAxes[k]).mulScalar(i & 1 ? -1 : 1); const pos = tmpV1.copy(dir).mulScalar(halfExtents[BOX_AXES[k]]).add(center); if (this._handle(pos, this._faceColors[k], null, 'size', i, center, dir)) { @@ -821,11 +827,20 @@ class HandleGizmo extends DragGizmo { const mid = tmpMid.set(0, 0, 0); const { centered } = options; + // a corner moves in the view plane, so a flat axis ignores its drag + const corner = handle.index - BOX_FACES; + if (corner >= 0) { + for (let k = 0; k < 3; k++) { + const a = BOX_AXES[k]; + if (start[a] - pad <= BOX_FLAT_HALF) { + local[a] = boxSide(corner, k) * start[a]; + } + } + } + if (handle.index < BOX_FACES) { this._resizeBoxAxis(handle.index >> 1, handle.index & 1 ? -1 : 1, local, start, centered, pad, half, mid); } else if (options.uniform) { - - const corner = handle.index - BOX_FACES; const anchor = tmpAnchor.set(-boxSide(corner, 0) * start.x, -boxSide(corner, 1) * start.y, -boxSide(corner, 2) * start.z); if (centered) { anchor.set(0, 0, 0); @@ -840,12 +855,22 @@ class HandleGizmo extends DragGizmo { half.mulScalar(factor); mid.copy(anchor).mulScalar(1 - factor); } else { - const corner = handle.index - BOX_FACES; for (let k = 0; k < 3; k++) { this._resizeBoxAxis(k, boxSide(corner, k), local, start, centered, pad, half, mid); } } + // a flat axis can't scale, so it keeps its size and center + if (corner >= 0) { + for (let k = 0; k < 3; k++) { + const a = BOX_AXES[k]; + if (start[a] - pad <= BOX_FLAT_HALF) { + half[a] = start[a]; + mid[a] = 0; + } + } + } + halfExtents.copy(half); position.copy(rotation.transformVector(mid, mid)).add(startPosition); } diff --git a/src/extras/gizmo/handle/light-gizmo.js b/src/extras/gizmo/handle/light-gizmo.js index 312873897ae..522d5689c0b 100644 --- a/src/extras/gizmo/handle/light-gizmo.js +++ b/src/extras/gizmo/handle/light-gizmo.js @@ -1,7 +1,7 @@ import { math } from '../../../core/math/math.js'; import { Quat } from '../../../core/math/quat.js'; import { Vec3 } from '../../../core/math/vec3.js'; -import { LIGHTSHAPE_DISK, LIGHTSHAPE_PUNCTUAL, LIGHTSHAPE_RECT, LIGHTSHAPE_SPHERE } from '../../../scene/constants.js'; +import { LIGHTSHAPE_DISK, LIGHTSHAPE_PUNCTUAL, LIGHTSHAPE_RECT, LIGHTSHAPE_SPHERE, PROJECTION_PERSPECTIVE } from '../../../scene/constants.js'; import { HandleGizmo } from './handle-gizmo.js'; /** @@ -41,6 +41,7 @@ const MIN_SIZE = 0.01; * Draws the shape of each attached light entity: an arrow for a directional light, sized by * {@link Gizmo#size}, the range of an omni light, the cone of a spot light, and the shape of an area * light. Handles drag the range, cone angles or area size, which sets the entity's local scale. + * Disabled entities are skipped, but a disabled light component is still drawn. * * ```javascript * const layer = Gizmo.createLayer(app, 'Overlays', undefined, true); @@ -354,8 +355,11 @@ class LightGizmo extends HandleGizmo { this._rays(entity, rays, RAYS, 0.58 * scale, 0.7, 0.75); renderer.arrow(pos, tmpQ1.copy(rot).mul(FLIP), scale); - // keep the icon clear of the arrow - this._icon('light', tmpV3.copy(entity.up).mulScalar(0.18 * scale).add(pos), entity); + // the icon sits on the light, moved toward the camera past the arrow so it draws on top + const camera = this._camera.entity; + const toward = this._camera.projection === PROJECTION_PERSPECTIVE ? + tmpV3.sub2(camera.getPosition(), pos).normalize() : tmpV3.copy(camera.forward).mulScalar(-1); + this._icon('light', toward.mulScalar(scale).add(pos), entity); } /** @@ -385,7 +389,7 @@ class LightGizmo extends HandleGizmo { for (let i = 0; i < this.nodes.length; i++) { const entity = /** @type {Entity} */ (this.nodes[i]); const light = entity.light; - if (!entity.enabled || !light?.enabled) { + if (!entity.enabled || !light) { continue; } this._renderer.tint = this.useLightColor ? light.color : null; diff --git a/src/extras/gizmo/icon-gizmo.js b/src/extras/gizmo/icon-gizmo.js index 9c5fe5ba748..f058e8f9731 100644 --- a/src/extras/gizmo/icon-gizmo.js +++ b/src/extras/gizmo/icon-gizmo.js @@ -20,7 +20,8 @@ import { OverlayRenderer } from './overlay/overlay-renderer.js'; /** * Draws an icon at each attached entity with nothing else to see, such as a light, camera or sound. - * Entities with an enabled render, model, sprite or element component show none. Clicking an icon + * Entities with an enabled render, model, sprite or element component show none, and a disabled + * component still shows its icon. Clicking an icon * fires {@link IconGizmo.EVENT_ICONSELECT}. The icons are {@link IconGizmo#iconSize} CSS pixels * square. * @@ -171,7 +172,7 @@ class IconGizmo extends Gizmo { return null; } for (const kind in ICON_GLYPHS) { - if (entity.c[kind]?.enabled) { + if (entity.c[kind]) { return /** @type {OverlayIcon} */ (kind); } } diff --git a/src/extras/gizmo/overlay/icon-glyphs.js b/src/extras/gizmo/overlay/icon-glyphs.js index 3ec4265a1f5..9d894d28910 100644 --- a/src/extras/gizmo/overlay/icon-glyphs.js +++ b/src/extras/gizmo/overlay/icon-glyphs.js @@ -1,9 +1,14 @@ /** * The glyph of an icon, named for the component it marks. * - * @typedef {'camera' | 'light' | 'audiolistener' | 'sound' | 'particlesystem'} OverlayIcon + * @typedef {'camera' | 'light' | 'audiolistener' | 'sound' | 'particlesystem' | 'anim' | 'animation' | 'script'} OverlayIcon */ +// the glyph of the anim and animation components +const PLAY = ` + + `; + /** * The icon glyphs as SVG content in a 24 by 24 CSS pixel box centered on the origin, y down. They * are stroked white, 1.6 pixels wide, and drawn in the theme color by their alpha. An entity with @@ -29,11 +34,10 @@ const ICON_GLYPHS = { `, - // a speaker, a box and its cone, with two sound waves + // a speaker, a box and its cone in one outline, with two sound waves sound: ` - - - `, + + `, // a cluster of dots around a larger one particlesystem: ` @@ -45,7 +49,17 @@ const ICON_GLYPHS = { - ` + `, + + // a play button, a triangle in a circle + anim: PLAY, + + // the legacy animation component, drawn as anim + animation: PLAY, + + // code brackets around a slash + script: ` + ` }; export { ICON_GLYPHS }; diff --git a/src/extras/gizmo/overlay/overlay-renderer.js b/src/extras/gizmo/overlay/overlay-renderer.js index 3f2014f64bc..04598d45092 100644 --- a/src/extras/gizmo/overlay/overlay-renderer.js +++ b/src/extras/gizmo/overlay/overlay-renderer.js @@ -297,9 +297,10 @@ class OverlayRenderer { begin() { const camera = this._gizmo.camera; const { width, height } = camera.system.app.graphicsDevice.clientRect; + const { z, w } = camera.rect; this._perspective = camera.projection === PROJECTION_PERSPECTIVE; const span = this._perspective ? 2 * Math.tan(0.5 * camera.fov * math.DEG_TO_RAD) : 2 * camera.orthoHeight; - this._pixel = span / Math.max(this._perspective && camera.horizontalFov ? width : height, 1); + this._pixel = span / Math.max(this._perspective && camera.horizontalFov ? width * z : height * w, 1); const layer = this._fixedLayer ?? this._gizmo.layer; if (layer !== this._layer) { From f66b33d15a4bfd92b695f7ac742e356f46b66227 Mon Sep 17 00:00:00 2001 From: "PlayCanvas [bot]" Date: Fri, 23 Oct 2026 20:17:29 +0000 Subject: [PATCH 02/22] 2.24.0-beta.21 --- package-lock.json | 4 ++-- package.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/package-lock.json b/package-lock.json index 1db0e1e64f5..fad93057a01 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "playcanvas", - "version": "2.24.0-beta.20", + "version": "2.24.0-beta.21", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "playcanvas", - "version": "2.24.0-beta.20", + "version": "2.24.0-beta.21", "license": "MIT", "dependencies": { "@types/webxr": "^0.5.24", diff --git a/package.json b/package.json index e7cf68e8180..9e888b3eb24 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "playcanvas", - "version": "2.24.0-beta.20", + "version": "2.24.0-beta.21", "packageManager": "npm@11.19.1", "author": "PlayCanvas ", "homepage": "https://playcanvas.com", From 57987e365a6dab3d5f5a69163ee3a6b936f082ba Mon Sep 17 00:00:00 2001 From: KPal <48248865+kpal81xd@users.noreply.github.com> Date: Fri, 23 Oct 2026 23:44:29 +0100 Subject: [PATCH 03/22] fix: Gizmo fixes for far icons, picking, frustum fill, flat box corners, lines and handle redraws (#9818) * fix: Stop icon backdrops writing depth so far icons don't flicker * fix: Size the camera frustum fill to small near-far ratios * fix: Drag flat box corners in their plane and leave the flat axis out of the readout * fix: Request a render when a handle's hover changes or its drag ends * fix: Stop icon glyphs writing depth so overlapping far icons don't flicker * feat: Let a Picker find gizmo shapes and add IconGizmo#getIconNode * fix: Draw gizmo lines over the surfaces they lie on * fix: Pull gizmo lines toward the camera in the shader so edges on a surface show * fix: Keep the bounding box fill from hiding the gizmo shapes inside it --- src/extras/gizmo/handle/bounding-box-gizmo.js | 3 +- src/extras/gizmo/handle/handle-gizmo.js | 24 +++++++-- src/extras/gizmo/icon-gizmo.js | 14 +++++ src/extras/gizmo/overlay/overlay-icons.js | 14 ++--- src/extras/gizmo/overlay/overlay-renderer.js | 16 +++++- src/extras/gizmo/shaders.js | 51 ++++++++++++++++--- src/extras/gizmo/shape/shape.js | 3 ++ 7 files changed, 104 insertions(+), 21 deletions(-) diff --git a/src/extras/gizmo/handle/bounding-box-gizmo.js b/src/extras/gizmo/handle/bounding-box-gizmo.js index 664f1f5bfd2..f3ba77db60f 100644 --- a/src/extras/gizmo/handle/bounding-box-gizmo.js +++ b/src/extras/gizmo/handle/bounding-box-gizmo.js @@ -270,7 +270,8 @@ class BoundingBoxGizmo extends HandleGizmo { const corners = this._boxCorners(c, Quat.IDENTITY, h); this._drawBrackets(corners); if (this._theme.fill.a > 0) { - renderer.box(c, Quat.IDENTITY, tmpV1.copy(h).mulScalar(2)); + // the bounds enclose other gizmos' shapes, so the fill leaves the depth to them + renderer.box(c, Quat.IDENTITY, tmpV1.copy(h).mulScalar(2)).depthWrite = false; } this._boxHandles(c, h, this, this.padding); } diff --git a/src/extras/gizmo/handle/handle-gizmo.js b/src/extras/gizmo/handle/handle-gizmo.js index 78897dafbd5..c1eb8ca1545 100644 --- a/src/extras/gizmo/handle/handle-gizmo.js +++ b/src/extras/gizmo/handle/handle-gizmo.js @@ -316,10 +316,15 @@ class HandleGizmo extends DragGizmo { this.on(Gizmo.EVENT_POINTERMOVE, (x, y, meshInstance) => { if (!this._dragging) { const handle = this._find(meshInstance); + const hovered = this._hovered; + if (handle ? !this._matches(hovered, handle.node, handle.name, handle.index) : hovered.name !== '') { + // the highlight changes, so a render-on-demand app draws a frame + this._renderUpdate = true; + } if (handle) { - this._copy(handle, this._hovered); + this._copy(handle, hovered); } else { - this._hovered.name = ''; + hovered.name = ''; } return; } @@ -521,6 +526,7 @@ class HandleGizmo extends DragGizmo { return; } this._dragging = false; + this._renderUpdate = true; this.fire(HandleGizmo.EVENT_HANDLEEND, this._drag.node, this._drag.name); this._drag.name = ''; } @@ -753,14 +759,22 @@ class HandleGizmo extends DragGizmo { } } if (options.cornerHandles) { - const forward = this._camera.entity.forward; + // a flat box's corners move in its plane, so they stay under the pointer + const flat = BOX_AXES.findIndex(a => halfExtents[a] - pad <= BOX_FLAT_HALF); + const normal = flat < 0 ? this._camera.entity.forward : boxAxes[flat]; for (let i = 0; i < boxCorners.length; i++) { const pos = boxCorners[i]; const hovered = options.uniform ? this._handle(pos, this._theme.corner, null, 'size', BOX_FACES + i, center, tmpV2.sub2(pos, center).normalize()) : - this._handle(pos, this._theme.corner, null, 'size', BOX_FACES + i, pos, forward, true); + this._handle(pos, this._theme.corner, null, 'size', BOX_FACES + i, pos, normal, true); if (hovered) { - const html = BOX_FACE_LABELS.map((label, k) => this._readoutValue(label, this._faceColors[k], 2 * (halfExtents[BOX_AXES[k]] - pad))); + const html = []; + for (let k = 0; k < 3; k++) { + const half = halfExtents[BOX_AXES[k]] - pad; + if (half > BOX_FLAT_HALF) { + html.push(this._readoutValue(BOX_FACE_LABELS[k], this._faceColors[k], 2 * half)); + } + } this._readoutHtmlAt(pos, html.join(' ')); } } diff --git a/src/extras/gizmo/icon-gizmo.js b/src/extras/gizmo/icon-gizmo.js index f058e8f9731..1a14e1435bd 100644 --- a/src/extras/gizmo/icon-gizmo.js +++ b/src/extras/gizmo/icon-gizmo.js @@ -7,7 +7,10 @@ import { OverlayRenderer } from './overlay/overlay-renderer.js'; /** * @import { CameraComponent } from '../../framework/components/camera/component.js' * @import { Entity } from '../../framework/entity.js' + * @import { GraphNode } from '../../scene/graph-node.js' * @import { Layer } from '../../scene/layer.js' + * @import { MeshInstance } from '../../scene/mesh-instance.js' + * @import { Picker } from '../../framework/graphics/picker.js' * @import { OverlayIcon } from './overlay/icon-glyphs.js' */ @@ -111,6 +114,17 @@ class IconGizmo extends Gizmo { return this._theme; } + /** + * Gets the entity whose icon a mesh instance draws, such as one a {@link Picker} returns, so + * an app can pick icons with the rest of its scene. + * + * @param {MeshInstance} meshInstance - The mesh instance. + * @returns {GraphNode|null} The entity, or null when the mesh instance draws no icon. + */ + getIconNode(meshInstance) { + return this._renderer.icons.pick(meshInstance); + } + /** * Sets the icon size in CSS pixels. Defaults to 24. * diff --git a/src/extras/gizmo/overlay/overlay-icons.js b/src/extras/gizmo/overlay/overlay-icons.js index 72b0e7e500a..ca03a674737 100644 --- a/src/extras/gizmo/overlay/overlay-icons.js +++ b/src/extras/gizmo/overlay/overlay-icons.js @@ -44,10 +44,10 @@ class OverlayIcons { /** * The icons placed this frame, with the nodes they mark. * - * @type {Pool<{ shape: Shape|null, node: GraphNode|null }>} + * @type {Pool<{ shape: Shape|null, glyph: Shape|null, node: GraphNode|null }>} * @private */ - _placed = new Pool(() => ({ shape: null, node: null })); + _placed = new Pool(() => ({ shape: null, glyph: null, node: null })); /** * @param {OverlayRenderer} renderer - The renderer the icons are drawn with. @@ -89,15 +89,16 @@ class OverlayIcons { const color = renderer.theme.backdrop ?? COLOR_BACKDROP; const backdrop = renderer.disc(tmpV, tmpQ.setFromDirections(Vec3.UP, entity.forward), 2 * ICON_BACKDROP_RADIUS * size, color.a, color); - renderer.glyph(kind, position, tmpQ.copy(entity.getRotation()).mul(GLYPH_TO_VIEW), size); + const glyph = renderer.glyph(kind, position, tmpQ.copy(entity.getRotation()).mul(GLYPH_TO_VIEW), size); const record = this._placed.next(); record.shape = backdrop; + record.glyph = glyph; record.node = node; } /** - * Gets the node whose icon, placed this frame, was picked. + * Gets the node whose icon, placed this frame, was picked by its backdrop or glyph. * * @param {MeshInstance} [meshInstance] - The picked mesh instance. * @returns {GraphNode|null} The node, or null when no icon was picked. @@ -108,8 +109,9 @@ class OverlayIcons { } const placed = this._placed; for (let i = 0; i < placed.count; i++) { - if (placed.items[i].shape?.meshInstances[0] === meshInstance) { - return placed.items[i].node; + const { shape, glyph, node } = placed.items[i]; + if (shape?.meshInstances[0] === meshInstance || glyph?.meshInstances[0] === meshInstance) { + return node; } } return null; diff --git a/src/extras/gizmo/overlay/overlay-renderer.js b/src/extras/gizmo/overlay/overlay-renderer.js index 04598d45092..da71ef0e9c6 100644 --- a/src/extras/gizmo/overlay/overlay-renderer.js +++ b/src/extras/gizmo/overlay/overlay-renderer.js @@ -259,6 +259,10 @@ class OverlayRenderer { }); this._pickDiscs = pool((base, occluded) => { const disc = new ConeShape(device, { ...args(base, occluded), ...CONES.disk }); + + // icons face the camera a pixel or so apart, finer than the depth buffer resolves far + // away, so they don't write depth and draw in placement order instead + disc.depthWrite = false; gizmo.intersectShapes.push(disc); return disc; }); @@ -287,7 +291,11 @@ class OverlayRenderer { */ this._glyphs = /** @type {Record} */ ({}); for (const [kind, texture] of Object.entries(textures)) { - this._glyphs[/** @type {OverlayIcon} */ (kind)] = pool((base, occluded) => new GlyphShape(device, { ...args(base, occluded), texture })); + this._glyphs[/** @type {OverlayIcon} */ (kind)] = pool((base, occluded) => { + const glyph = new GlyphShape(device, { ...args(base, occluded), texture }); + glyph.depthWrite = false; + return glyph; + }); } } @@ -798,6 +806,7 @@ class OverlayRenderer { * @param {Vec3} size - The size along each axis of the rotation. * @param {number} [alpha] - The alpha factor. * @param {boolean} [volume] - Whether to use the theme fill color. + * @returns {Shape} The box. */ box(center, rotation, size, alpha = 1, volume = true) { this._colors(alpha, volume); @@ -805,6 +814,7 @@ class OverlayRenderer { box.entity.setLocalPosition(this._local(center, tmpV1)); box.entity.setLocalRotation(rotation); box.entity.setLocalScale(size); + return box; } /** @@ -858,7 +868,7 @@ class OverlayRenderer { cone(kind, center, rotation, size, alpha = 1, volume = true, peak = 0) { this._colors(alpha, volume); const cone = /** @type {ConeShape} */ (this._next(this._cones[kind])); - if (kind === 'frustum' && Math.abs(cone.peakRadius - peak) > PEAK_TOLERANCE) { + if (kind === 'frustum' && Math.abs(cone.peakRadius - peak) > PEAK_TOLERANCE * peak) { cone.peakRadius = peak; } cone.entity.setLocalPosition(this._local(center, tmpV1)); @@ -892,6 +902,7 @@ class OverlayRenderer { * @param {Vec3} center - The world center. * @param {Quat} rotation - The world rotation. * @param {number} size - The width and height. + * @returns {Shape} The glyph. */ glyph(kind, center, rotation, size) { this._colors(1); @@ -899,6 +910,7 @@ class OverlayRenderer { glyph.entity.setLocalPosition(this._local(center, tmpV1)); glyph.entity.setLocalRotation(rotation); glyph.entity.setLocalScale(size, 1, size); + return glyph; } /** diff --git a/src/extras/gizmo/shaders.js b/src/extras/gizmo/shaders.js index 0195264b1e7..1e507c2c431 100644 --- a/src/extras/gizmo/shaders.js +++ b/src/extras/gizmo/shaders.js @@ -1,11 +1,13 @@ import { SEMANTIC_ATTR8, SEMANTIC_ATTR9, SEMANTIC_ATTR10, SEMANTIC_ATTR11, SEMANTIC_ATTR12, SEMANTIC_POSITION, SEMANTIC_TEXCOORD0 } from '../../platform/graphics/constants.js'; /** + * @import { Picker } from '../../framework/graphics/picker.js' * @import { ShaderDesc } from '../../scene/materials/shader-material.js' */ /** - * The unlit shader of a shape. TEXTURE scales the alpha by the coverage of a texture. + * The unlit shader of a shape. TEXTURE scales the alpha by the coverage of a texture. The pick + * pass writes the mesh instance id, so a {@link Picker} can find the shape. * * @type {ShaderDesc} */ @@ -44,6 +46,9 @@ export const unlitShader = { uniform sampler2D uTexture; varying vec2 vUv; #endif + #ifdef PICK_PASS + #include "pickPS" + #endif void main(void) { vec4 color = uColor; @@ -55,7 +60,11 @@ export const unlitShader = { if (color.a < 1.0 / 255.0) { discard; } - gl_FragColor = vec4(gammaCorrectOutput(decodeGamma(color)), color.a); + #ifdef PICK_PASS + gl_FragColor = getPickOutput(); + #else + gl_FragColor = vec4(gammaCorrectOutput(decodeGamma(color)), color.a); + #endif #if DEPTH_WRITE == 1 gl_FragDepth = uDepth; #endif @@ -93,6 +102,9 @@ export const unlitShader = { var uTextureSampler: sampler; varying vUv: vec2f; #endif + #ifdef PICK_PASS + #include "pickPS" + #endif @fragment fn fragmentMain(input: FragmentInput) -> FragmentOutput { @@ -106,7 +118,11 @@ export const unlitShader = { if (color.a < 1.0 / 255.0) { discard; } - output.color = vec4f(gammaCorrectOutput(decodeGamma(color)), color.a); + #ifdef PICK_PASS + output.color = getPickOutput(); + #else + output.color = vec4f(gammaCorrectOutput(decodeGamma(color)), color.a); + #endif #if DEPTH_WRITE == 1 output.fragDepth = uniform.uDepth; #endif @@ -138,7 +154,11 @@ export const lineBatchShader = { attribute vec4 instance_color; attribute vec4 instance_occludedColor; - uniform mat4 matrix_viewProjection; + uniform mat4 matrix_view; + uniform mat4 matrix_projection; + + // the fraction of its distance a line is pulled toward the camera, past the surface it lies on + const float LINE_PULL = 0.004; flat varying vec4 vColor; @@ -149,7 +169,13 @@ export const lineBatchShader = { instance_row0.z, instance_row1.z, instance_row2.z, 0.0, instance_row0.w, instance_row1.w, instance_row2.w, 1.0 ); - gl_Position = matrix_viewProjection * model * vec4(vertex_position, 1.0); + vec4 view = matrix_view * model * vec4(vertex_position, 1.0); + #ifndef OCCLUDED + // pull toward the camera without moving on screen, so a line on a surface shows + bool ortho = matrix_projection[3][3] == 1.0; + view.xyz *= ortho ? vec3(1.0, 1.0, 1.0 - LINE_PULL) : vec3(1.0 - LINE_PULL); + #endif + gl_Position = matrix_projection * view; gl_Position.z = clamp(gl_Position.z, -abs(gl_Position.w), abs(gl_Position.w)); #ifdef OCCLUDED vColor = instance_occludedColor; @@ -180,7 +206,11 @@ export const lineBatchShader = { attribute instance_color: vec4f; attribute instance_occludedColor: vec4f; - uniform matrix_viewProjection: mat4x4f; + uniform matrix_view: mat4x4f; + uniform matrix_projection: mat4x4f; + + // the fraction of its distance a line is pulled toward the camera, past the surface it lies on + const LINE_PULL: f32 = 0.004; varying @interpolate(flat, either) vColor: vec4f; @@ -196,7 +226,14 @@ export const lineBatchShader = { vec4f(r0.z, r1.z, r2.z, 0.0), vec4f(r0.w, r1.w, r2.w, 1.0) ); - output.position = uniform.matrix_viewProjection * model * vec4f(input.vertex_position, 1.0); + var view = uniform.matrix_view * model * vec4f(input.vertex_position, 1.0); + #ifndef OCCLUDED + // pull toward the camera without moving on screen, so a line on a surface shows + let ortho = uniform.matrix_projection[3][3] == 1.0; + let pull = select(vec3f(1.0 - LINE_PULL), vec3f(1.0, 1.0, 1.0 - LINE_PULL), ortho); + view = vec4f(view.xyz * pull, view.w); + #endif + output.position = uniform.matrix_projection * view; output.position.z = clamp(output.position.z, -abs(output.position.w), abs(output.position.w)); #ifdef OCCLUDED output.vColor = input.instance_occludedColor; diff --git a/src/extras/gizmo/shape/shape.js b/src/extras/gizmo/shape/shape.js index 923d5c191ba..e6dadd66eab 100644 --- a/src/extras/gizmo/shape/shape.js +++ b/src/extras/gizmo/shape/shape.js @@ -426,6 +426,9 @@ class Shape { if (this._occludedMaterial) { const occluded = new MeshInstance(meshes[i], this._occludedMaterial); occluded.cull = false; + + // drawn through the scene, so a picker would find shapes behind it + occluded.pick = false; meshInstances.push(occluded); this.occludedMeshInstances.push(occluded); } From 209ce5752527ab5050ec9632ca6dc1272542b70d Mon Sep 17 00:00:00 2001 From: "PlayCanvas [bot]" Date: Fri, 23 Oct 2026 22:45:10 +0000 Subject: [PATCH 04/22] 2.24.0-beta.22 --- package-lock.json | 4 ++-- package.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/package-lock.json b/package-lock.json index fad93057a01..193c097f322 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "playcanvas", - "version": "2.24.0-beta.21", + "version": "2.24.0-beta.22", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "playcanvas", - "version": "2.24.0-beta.21", + "version": "2.24.0-beta.22", "license": "MIT", "dependencies": { "@types/webxr": "^0.5.24", diff --git a/package.json b/package.json index 9e888b3eb24..3f0a6fdd777 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "playcanvas", - "version": "2.24.0-beta.21", + "version": "2.24.0-beta.22", "packageManager": "npm@11.19.1", "author": "PlayCanvas ", "homepage": "https://playcanvas.com", From 734d236d185cc6147799891f56d1965e6d138a8f Mon Sep 17 00:00:00 2001 From: Curtis Gibson Date: Mon, 26 Oct 2026 08:38:51 -0400 Subject: [PATCH 05/22] Fix WebGPU creation for cross-document canvases (#9814) * fix: Use canvas window for WebGPU device Fixed #9813 * refactor: Read the canvas WebGPU entry point once, fall back to the global scope - initFromGpuDevice looks up the canvas's GPU entry point once and passes its WGSL language features to initDeviceCaps, instead of repeating the lookup with inconsistent guards - getCanvasNavigatorGpu uses the global scope's navigator when there is no window, so an OffscreenCanvas in a worker finds WebGPU instead of throwing a ReferenceError * fix: Keep the WebGPU WGSL features type out of the bundled declarations initDeviceCaps is now private, so tsc emits it without the WGSLLanguageFeatures parameter type, which the default lib used to compile build/playcanvas.d.ts does not provide. --------- Co-authored-by: Martin Valigursky --- .../graphics/graphics-device-create.js | 3 +- src/platform/graphics/webgpu/navigator-gpu.js | 24 +++++++ .../graphics/webgpu/webgpu-graphics-device.js | 23 ++++--- .../graphics/webgpu/navigator-gpu.test.mjs | 67 +++++++++++++++++++ 4 files changed, 108 insertions(+), 9 deletions(-) create mode 100644 src/platform/graphics/webgpu/navigator-gpu.js create mode 100644 test/platform/graphics/webgpu/navigator-gpu.test.mjs diff --git a/src/platform/graphics/graphics-device-create.js b/src/platform/graphics/graphics-device-create.js index c87a762907f..7fb531d3139 100644 --- a/src/platform/graphics/graphics-device-create.js +++ b/src/platform/graphics/graphics-device-create.js @@ -1,4 +1,5 @@ import { DEVICETYPE_WEBGL2, DEVICETYPE_WEBGL2_BARE, DEVICETYPE_WEBGPU, DEVICETYPE_WEBGPU_BARE, DEVICETYPE_NULL } from './constants.js'; +import { getCanvasNavigatorGpu } from './webgpu/navigator-gpu.js'; import { WebgpuGraphicsDevice } from './webgpu/webgpu-graphics-device.js'; import { WebglGraphicsDevice } from './webgl/webgl-graphics-device.js'; import { NullGraphicsDevice } from './null/null-graphics-device.js'; @@ -104,7 +105,7 @@ function createGraphicsDevice(canvas, options = {}) { for (let i = 0; i < deviceTypes.length; i++) { const deviceType = deviceTypes[i]; - if ((deviceType === DEVICETYPE_WEBGPU || deviceType === DEVICETYPE_WEBGPU_BARE) && window?.navigator?.gpu) { + if ((deviceType === DEVICETYPE_WEBGPU || deviceType === DEVICETYPE_WEBGPU_BARE) && getCanvasNavigatorGpu(canvas)) { const featureLevel = deviceType === DEVICETYPE_WEBGPU_BARE ? 'bare' : undefined; deviceCreateFuncs.push(() => { const device = new WebgpuGraphicsDevice(canvas, { ...options, featureLevel }); diff --git a/src/platform/graphics/webgpu/navigator-gpu.js b/src/platform/graphics/webgpu/navigator-gpu.js new file mode 100644 index 00000000000..8aeeddd5d0f --- /dev/null +++ b/src/platform/graphics/webgpu/navigator-gpu.js @@ -0,0 +1,24 @@ +/** + * Gets the WebGPU entry point for the browsing context that owns the canvas. + * + * A GPU device used to configure a canvas must be created by the canvas's owning context. In + * WebKit, configuring a canvas with a device from another context terminates the page instead of + * throwing, which also prevents fallback to WebGL2. If the canvas has an associated window, this + * deliberately returns that window's `navigator.gpu` even when it is undefined. + * + * Canvases without an associated window, such as an `OffscreenCanvas` in a worker, a canvas + * created using `createHTMLDocument()` or one supplied by a headless host, use the WebGPU entry + * point of the current window, or of the global scope when there is no window. + * + * @param {HTMLCanvasElement} canvas - The canvas the device will draw to. + * @returns {GPU|undefined} The WebGPU entry point to use for the canvas, if available. + */ +const getCanvasNavigatorGpu = (canvas) => { + const view = canvas?.ownerDocument?.defaultView; + if (view) { + return view.navigator?.gpu; + } + return (typeof window !== 'undefined' ? window : globalThis).navigator?.gpu; +}; + +export { getCanvasNavigatorGpu }; diff --git a/src/platform/graphics/webgpu/webgpu-graphics-device.js b/src/platform/graphics/webgpu/webgpu-graphics-device.js index ef0792fd810..0498e465e43 100644 --- a/src/platform/graphics/webgpu/webgpu-graphics-device.js +++ b/src/platform/graphics/webgpu/webgpu-graphics-device.js @@ -14,6 +14,7 @@ import { GraphicsDevice } from '../graphics-device.js'; import { getPrimitiveCount } from '../primitive-utils.js'; import { RenderTarget } from '../render-target.js'; import { StencilParameters } from '../stencil-parameters.js'; +import { getCanvasNavigatorGpu } from './navigator-gpu.js'; import { WebgpuBindGroup } from './webgpu-bind-group.js'; import { WebgpuBindGroupFormat } from './webgpu-bind-group-format.js'; import { WebgpuIndexBuffer } from './webgpu-index-buffer.js'; @@ -539,7 +540,12 @@ class WebgpuGraphicsDevice extends GraphicsDevice { } } - initDeviceCaps() { + /** + * @param {WGSLLanguageFeatures} [wgslFeatures] - The WGSL language features reported by the + * canvas's WebGPU entry point, if the browser reports them. + * @private + */ + initDeviceCaps(wgslFeatures) { const limits = this.wgpu?.limits; this.limits = limits; @@ -577,7 +583,6 @@ class WebgpuGraphicsDevice extends GraphicsDevice { this.samples = this.backBufferAntialias ? 4 : 1; // WGSL features - const wgslFeatures = window.navigator.gpu.wgslLanguageFeatures; this.supportsStorageTextureRead = wgslFeatures?.has('readonly_and_readwrite_storage_textures'); this.supportsSubgroupUniformity = wgslFeatures?.has('subgroup_uniformity'); this.supportsSubgroupId = wgslFeatures?.has('subgroup_id'); @@ -592,7 +597,7 @@ class WebgpuGraphicsDevice extends GraphicsDevice { async initWebGpu(glslangUrl, twgslUrl) { - if (!window.navigator.gpu) { + if (!getCanvasNavigatorGpu(this.canvas)) { warnInsecureContext('WebGPU'); throw new Error('Unable to retrieve GPU. Ensure you are using a browser that supports WebGPU rendering.'); } @@ -639,7 +644,7 @@ class WebgpuGraphicsDevice extends GraphicsDevice { xrCompatible: !!this.initOptions.xrCompatible }; - const gpuAdapter = await window.navigator.gpu.requestAdapter(adapterOptions); + const gpuAdapter = await getCanvasNavigatorGpu(this.canvas).requestAdapter(adapterOptions); if (this._destroyed) { return null; } @@ -774,8 +779,10 @@ class WebgpuGraphicsDevice extends GraphicsDevice { this.maxSubgroupSize = gpuAdapter?.info?.subgroupMaxSize ?? 0; this.minSubgroupSize = gpuAdapter?.info?.subgroupMinSize ?? 0; - const wgslFeatureNames = window.navigator.gpu.wgslLanguageFeatures ? - Array.from(window.navigator.gpu.wgslLanguageFeatures) : []; + // the WebGPU entry point of the canvas, which reports the WGSL features and canvas format + const gpu = getCanvasNavigatorGpu(this.canvas); + const wgslFeatures = gpu.wgslLanguageFeatures; + const wgslFeatureNames = wgslFeatures ? Array.from(wgslFeatures) : []; Debug.log( `WEBGPU${gpuAdapter?.info ? ` (${gpuAdapter.info.vendor || '?'} / ${gpuAdapter.info.architecture || gpuAdapter.info.device || '?'})` : @@ -801,7 +808,7 @@ class WebgpuGraphicsDevice extends GraphicsDevice { }; this.wgpu.addEventListener?.('uncapturederror', this._uncapturedErrorHandler); - this.initDeviceCaps(); + this.initDeviceCaps(wgslFeatures); this.gpuContext = this.canvas.getContext('webgpu'); @@ -809,7 +816,7 @@ class WebgpuGraphicsDevice extends GraphicsDevice { let canvasToneMapping = 'standard'; // pixel format of the framebuffer that is the most efficient one on the system - let preferredCanvasFormat = window.navigator.gpu.getPreferredCanvasFormat(); + let preferredCanvasFormat = gpu.getPreferredCanvasFormat(); // display format the user asked for const displayFormat = this.initOptions.displayFormat; diff --git a/test/platform/graphics/webgpu/navigator-gpu.test.mjs b/test/platform/graphics/webgpu/navigator-gpu.test.mjs new file mode 100644 index 00000000000..7a18927c24a --- /dev/null +++ b/test/platform/graphics/webgpu/navigator-gpu.test.mjs @@ -0,0 +1,67 @@ +import { expect } from 'chai'; + +import { getCanvasNavigatorGpu } from '../../../../src/platform/graphics/webgpu/navigator-gpu.js'; + +describe('getCanvasNavigatorGpu', function () { + let windowDescriptor; + let navigatorDescriptor; + + beforeEach(function () { + windowDescriptor = Object.getOwnPropertyDescriptor(globalThis, 'window'); + navigatorDescriptor = Object.getOwnPropertyDescriptor(globalThis, 'navigator'); + }); + + afterEach(function () { + if (windowDescriptor) { + Object.defineProperty(globalThis, 'window', windowDescriptor); + } else { + delete globalThis.window; + } + if (navigatorDescriptor) { + Object.defineProperty(globalThis, 'navigator', navigatorDescriptor); + } else { + delete globalThis.navigator; + } + }); + + it('returns the GPU from the canvas window', function () { + const gpu = {}; + const canvas = { ownerDocument: { defaultView: { navigator: { gpu } } } }; + + expect(getCanvasNavigatorGpu(canvas)).to.equal(gpu); + }); + + it('does not use the current window when WebGPU is unavailable in the canvas window', function () { + Object.defineProperty(globalThis, 'window', { + configurable: true, + value: { navigator: { gpu: {} } } + }); + const canvas = { ownerDocument: { defaultView: { navigator: {} } } }; + + expect(getCanvasNavigatorGpu(canvas)).to.equal(undefined); + }); + + it('uses the current window when the canvas has no window', function () { + const gpu = {}; + Object.defineProperty(globalThis, 'window', { + configurable: true, + value: { navigator: { gpu } } + }); + const canvas = { ownerDocument: { defaultView: null } }; + + expect(getCanvasNavigatorGpu(canvas)).to.equal(gpu); + }); + + it('uses the global navigator for a canvas without a document when there is no window', function () { + // as in a worker, where an OffscreenCanvas has no owner document and there is no window + delete globalThis.window; + const gpu = {}; + Object.defineProperty(globalThis, 'navigator', { + configurable: true, + value: { gpu } + }); + const canvas = { width: 1, height: 1 }; + + expect(getCanvasNavigatorGpu(canvas)).to.equal(gpu); + }); +}); From 093bd54b09ac7372578e7df9d2edbfb22b33fd14 Mon Sep 17 00:00:00 2001 From: Martin Valigursky <59932779+mvaligursky@users.noreply.github.com> Date: Mon, 26 Oct 2026 13:52:45 +0000 Subject: [PATCH 06/22] fix: Update the mip count when Texture.setSource changes the size (#9821) setSource adopts the size of the source, or falls back to 4x4 when the source is rejected, but kept the mip level count of the previous size. On WebGPU, which creates the texture with that count, a default sized texture given an image got a truncated mip chain, and a mipmapped texture given a smaller or rejected source failed validation with too many mip levels. Co-authored-by: Martin Valigursky --- src/platform/graphics/texture.js | 7 ++++ test/platform/graphics/texture.test.mjs | 54 +++++++++++++++++++++++++ 2 files changed, 61 insertions(+) diff --git a/src/platform/graphics/texture.js b/src/platform/graphics/texture.js index 7bff1c9dcd6..d36fe09be16 100644 --- a/src/platform/graphics/texture.js +++ b/src/platform/graphics/texture.js @@ -1273,6 +1273,8 @@ class Texture { let invalid = false; let width, height; + const prevWidth = this._width; + const prevHeight = this._height; if (this._cubemap) { if (source[0]) { @@ -1357,6 +1359,11 @@ class Texture { this._levels[mipLevel] = source; } + // the mip chain follows the size of the base level, which the source may have changed + if (this._width !== prevWidth || this._height !== prevHeight) { + this._updateNumLevels(); + } + // valid or changed state of validity if (this._invalid !== invalid || !invalid) { this._invalid = invalid; diff --git a/test/platform/graphics/texture.test.mjs b/test/platform/graphics/texture.test.mjs index 3b89fa462e8..22fa2aca963 100644 --- a/test/platform/graphics/texture.test.mjs +++ b/test/platform/graphics/texture.test.mjs @@ -199,6 +199,60 @@ describe('Texture', function () { }); }); + describe('#setSource', function () { + + let hadImageBitmap; + + before(function () { + // Node has no ImageBitmap: the WebGPU test harness installs a stand-in, else provide one + hadImageBitmap = typeof globalThis.ImageBitmap !== 'undefined'; + if (!hadImageBitmap) { + globalThis.ImageBitmap = class { + constructor(width, height) { + this.width = width; + this.height = height; + } + }; + } + }); + + after(function () { + if (!hadImageBitmap) { + delete globalThis.ImageBitmap; + } + }); + + const createBitmap = (width, height) => new globalThis.ImageBitmap(width, height, new Uint8ClampedArray(width * height * 4)); + + it('sizes the mip chain to a source larger than the texture', function () { + // created at the default 4x4 size, with 3 mip levels + const texture = new Texture(device, { format: PIXELFORMAT_RGBA8 }); + texture.setSource(createBitmap(64, 64)); + + expect(texture.width).to.equal(64); + expect(texture.numLevels).to.equal(7); + texture.destroy(); + }); + + it('sizes the mip chain to a source smaller than the texture', function () { + const texture = new Texture(device, { format: PIXELFORMAT_RGBA8, width: 64, height: 64 }); + texture.setSource(createBitmap(16, 16)); + + expect(texture.width).to.equal(16); + expect(texture.numLevels).to.equal(5); + texture.destroy(); + }); + + it('sizes the mip chain to the 4x4 fallback when the source is rejected', function () { + const texture = new Texture(device, { format: PIXELFORMAT_RGBA8, width: 64, height: 64 }); + texture.setSource(/** @type {any} */ ({ width: 64, height: 64 })); + + expect(texture.width).to.equal(4); + expect(texture.numLevels).to.equal(3); + texture.destroy(); + }); + }); + describe('#lock: cubemap', function () { const createCubemap = (options = {}) => new Texture(device, { From f56d06b706dacddbcf47c2aad2869a7139244f71 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Mon, 26 Oct 2026 14:05:33 +0000 Subject: [PATCH 07/22] chore(deps): update dependency vite to v8.3.3 [security] (#9805) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- examples/package-lock.json | 146 ++++++++++++++++++------------------- examples/package.json | 2 +- 2 files changed, 74 insertions(+), 74 deletions(-) diff --git a/examples/package-lock.json b/examples/package-lock.json index 7f9dff1c363..320c4d6c382 100644 --- a/examples/package-lock.json +++ b/examples/package-lock.json @@ -36,12 +36,12 @@ "react-router-dom": "7.18.4", "sharp": "0.35.5", "typescript": "5.9.3", - "vite": "8.3.0" + "vite": "8.3.3" } }, "..": { "name": "playcanvas", - "version": "2.24.0-beta.6", + "version": "2.24.0-beta.22", "dev": true, "license": "MIT", "dependencies": { @@ -1993,9 +1993,9 @@ } }, "node_modules/@oxc-project/types": { - "version": "0.150.0", - "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.150.0.tgz", - "integrity": "sha512-rDS5/31E9HfPl/CIzGrn0DOlvBbXFseQ5URJ9sYMfstbKLD/c6Gm9vmRzRGDdAXyOIL4zmO37lc9RIwYqVruZw==", + "version": "0.155.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.155.0.tgz", + "integrity": "sha512-SeE8JPwW1xs1tHeNycjUtUBdhorRa8o4jlvjwk49R7/Z291i+4DQa7JJIgOm/TaYQZx+QrPQbVJ/9c8dMIZN8Q==", "dev": true, "license": "MIT", "funding": { @@ -2108,9 +2108,9 @@ } }, "node_modules/@rolldown/binding-android-arm-eabi": { - "version": "1.2.9", - "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm-eabi/-/binding-android-arm-eabi-1.2.9.tgz", - "integrity": "sha512-tNISae1QEf/vkb3xkRcjV5SEdzPE97We5IVaa2Z8jSszQPZ8U60B/YCYpw4QI7VidYsBtKavczXf+DyDs9WGxw==", + "version": "1.2.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm-eabi/-/binding-android-arm-eabi-1.2.15.tgz", + "integrity": "sha512-aLcgK9gIJ3YRvrrtfSBbRa27F5WrpiTaPulnFpsHPxpUHgFlSgnU4854MHwoRGEBhfRJDWCS2WQ7lIjskE9Olw==", "cpu": [ "arm" ], @@ -2125,9 +2125,9 @@ } }, "node_modules/@rolldown/binding-android-arm64": { - "version": "1.2.9", - "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.2.9.tgz", - "integrity": "sha512-YC8YsI30o606GTZi0VyzYlsDKFP8W61i/QzayHDkLbNEz/IShqAmTa+hsJRj13xTHA0H+6fk4b2UmGn+Q/cMlg==", + "version": "1.2.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.2.15.tgz", + "integrity": "sha512-xD7DdZCrbThKuxNg7ooQmvp07ys4J/pnSEMTG3/OdOWl6daC67m/Q4fL78sRHMxTH/N4fGUbYhcPM0MF5WFHkg==", "cpu": [ "arm64" ], @@ -2142,9 +2142,9 @@ } }, "node_modules/@rolldown/binding-darwin-arm64": { - "version": "1.2.9", - "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.2.9.tgz", - "integrity": "sha512-IwhlH3qK5urrY8hZiEgGkHKEFN901p/p2bjxCxJlr4GyNnF7wYpUvK+Y43uaRYuC4hpfjzbR3SJC3arX1jGvmw==", + "version": "1.2.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.2.15.tgz", + "integrity": "sha512-m2WuQ6r0B4OTYxyPpNLtXaMRbRDUbG8YQV2Q3KTdSSK8Th6KHCnC8jVCJt8bvYeSB5WivkkNbc85mKadaSdOxA==", "cpu": [ "arm64" ], @@ -2159,9 +2159,9 @@ } }, "node_modules/@rolldown/binding-darwin-x64": { - "version": "1.2.9", - "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.2.9.tgz", - "integrity": "sha512-XxpJfVzFh+jilRxIXUqcfYAYcunIc/XEzIizsOL1fcJee5Sf7H3mH8WlLmfHfluz5amqR88QQo9izKtmMlavAw==", + "version": "1.2.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.2.15.tgz", + "integrity": "sha512-5RtnsHM36nodM9WbMy+zip0LGS/G+y5wro5RHrpOlr4ac24+1zSi9kiV2MP1myZeZ/ifEHfulQcWjnAXzc2C2A==", "cpu": [ "x64" ], @@ -2176,9 +2176,9 @@ } }, "node_modules/@rolldown/binding-freebsd-x64": { - "version": "1.2.9", - "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.2.9.tgz", - "integrity": "sha512-kSfvhmgeWyfkbT3p/1s5vSgboogoah2zkm9fX2zjg2hHxSV7T4KhMWRUUaRk4OXNqoD3QAUeRqLcs1aZOK4U1g==", + "version": "1.2.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.2.15.tgz", + "integrity": "sha512-g3l09pyFqhu+135JKkbkwiKGQmwU5hUMUPJ4VdBADvuqR8d33L6H578rh8m7rtxbdp+VmX/+nzI7nvhSZlzpUQ==", "cpu": [ "x64" ], @@ -2193,9 +2193,9 @@ } }, "node_modules/@rolldown/binding-linux-arm-gnueabihf": { - "version": "1.2.9", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.2.9.tgz", - "integrity": "sha512-1RVzG17pxqbTfYLC352JlLt6kKLG+6Hr30n8DlIJqsnV5luUDd2Qdx9Ayw1Cabfyb1K9k0jXEZ7evxkRoT+uiw==", + "version": "1.2.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.2.15.tgz", + "integrity": "sha512-nIe3VugI97nGQtspPxDqA4sXMUpeiocKIP/CqpY/u/vgbT+alX+cVxu1C/oVBPKg4feNTmYovE91v4DgXvXNwQ==", "cpu": [ "arm" ], @@ -2210,9 +2210,9 @@ } }, "node_modules/@rolldown/binding-linux-arm64-gnu": { - "version": "1.2.9", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.2.9.tgz", - "integrity": "sha512-BXqPvZ2drqVD+/Z8UpKwcs4Mp7grM+eGFku4CAEKrEtcbAsUpzREphK1sogCRZGreVPiMkiiBtw0n3TPteuqvw==", + "version": "1.2.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.2.15.tgz", + "integrity": "sha512-iTBjXx+B5QSvgI89lJfmqh4wTh8NabR1YZIIN3hNx4zSG0EVfVqkOvLulNpG4251pbd6/IAmZ/LefVk1Y4rPhw==", "cpu": [ "arm64" ], @@ -2230,9 +2230,9 @@ } }, "node_modules/@rolldown/binding-linux-arm64-musl": { - "version": "1.2.9", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.2.9.tgz", - "integrity": "sha512-11vWvo8YDwLzukt27J3aYDWU+gg2P7J+ZOmiJ0hkF5BXZDW7pVya7r40MXDy6ya0i9KamoENSVKIugvJNgFXIA==", + "version": "1.2.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.2.15.tgz", + "integrity": "sha512-uN26XOnVR7rqQupG3V9oAG2EGGatWjcJHOeDOHUFCh4TBWmP2tHTFoVN13aYbwfm0aHqJAEmcwhAu6sooIjfqQ==", "cpu": [ "arm64" ], @@ -2250,9 +2250,9 @@ } }, "node_modules/@rolldown/binding-linux-ppc64-gnu": { - "version": "1.2.9", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.2.9.tgz", - "integrity": "sha512-a1tijMkdwsIARtc0F39ApURROkf3NwqinI6TOiSSWCTR7dT96dffNvMUtDHnq64wKNTIZOIlzKrFvvFUznJiyw==", + "version": "1.2.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.2.15.tgz", + "integrity": "sha512-9nWkcdd+H+XhaA3fQIwpHnKIrfx/PQ6rO0pivr78Og07tXlUlctBBJoEYLPfhTmCR/QxEVdn6uPjHV5SymPhdw==", "cpu": [ "ppc64" ], @@ -2270,9 +2270,9 @@ } }, "node_modules/@rolldown/binding-linux-s390x-gnu": { - "version": "1.2.9", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.2.9.tgz", - "integrity": "sha512-x6SQNdAvv4c3hWqTMaWuawzMX9myaCs/yEmlGsxJzkdClnHW7FbrjQuSiRDhuSYzEYoEMhsaJy9qHG/XNemJPQ==", + "version": "1.2.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.2.15.tgz", + "integrity": "sha512-Ggh0ciFjLwIusLSLz7LZYEaGdxOAUJXI+Qu5JCbhPJJHEt8vP+IjRATTJheiJsdzzTYgUiHUiw3X1Ea3ZpMBdA==", "cpu": [ "s390x" ], @@ -2290,9 +2290,9 @@ } }, "node_modules/@rolldown/binding-linux-x64-gnu": { - "version": "1.2.9", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.2.9.tgz", - "integrity": "sha512-9s0AZ8BFK5/n7B/TBoa2yJE3gI3KURrbXcPBlsAsvjU4VeJKgE90y1YtNxyEUIcHPQkg6/yfF3qihUrcM/Kf0Q==", + "version": "1.2.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.2.15.tgz", + "integrity": "sha512-FHg60rS6DBePAYZ9X9F67x+JObFdmgn6tupA1C/a3B+CufFmTD4mX+e8M75tCiAO3foR/6xwVBI0KQiI7sn/rw==", "cpu": [ "x64" ], @@ -2310,9 +2310,9 @@ } }, "node_modules/@rolldown/binding-linux-x64-musl": { - "version": "1.2.9", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.2.9.tgz", - "integrity": "sha512-P7VWAmV+WdJluH7ovnRGoiv2i8To7GAZ+kGzfGup635cyL7SyYl3lSUaA3Gp5THf0n/Co5EyEqb2zbqq+nMOHQ==", + "version": "1.2.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.2.15.tgz", + "integrity": "sha512-/cvV/7v5VGz/moiz0XKRj2dE8DzsKyhIu1KpOvJGPjRywg3yscON889UGvAe8zfiJLHup7VLjBduoGWi3HusWQ==", "cpu": [ "x64" ], @@ -2330,9 +2330,9 @@ } }, "node_modules/@rolldown/binding-openharmony-arm64": { - "version": "1.2.9", - "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.2.9.tgz", - "integrity": "sha512-1qixtsE4BK8h+yS3BfmZ09UhA7O/N4IACva6YBr7EBvCJraByTuRcgOTaiA62Tm0vey3UcKXLOaoGHtYmNGEVg==", + "version": "1.2.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.2.15.tgz", + "integrity": "sha512-MyzC5Hrv/sm+Z7TSGKP/j8001RO2xQm1zOpCNVL4j0cY/VhBZviludA2YFQ7i4lH6ZAXxQm0lcMdBzM+99mE7w==", "cpu": [ "arm64" ], @@ -2347,9 +2347,9 @@ } }, "node_modules/@rolldown/binding-win32-arm64-msvc": { - "version": "1.2.9", - "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.2.9.tgz", - "integrity": "sha512-ok8IQjcEPs1AKZfuEUznVBrJw+gK4soq+bx8b1X2XoMqVClarc1q5JDmVtWXY1xfr6ZuHTAsPXHTgTrqKTZeww==", + "version": "1.2.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.2.15.tgz", + "integrity": "sha512-+PgYiS+AH5omy6L45ZLxybasauJaj3M8buoT83D+nzPmCayXlxUV9hydhciCKVyIg0B/v0BtXPDE1uGqNwCPpg==", "cpu": [ "arm64" ], @@ -2364,9 +2364,9 @@ } }, "node_modules/@rolldown/binding-win32-x64-msvc": { - "version": "1.2.9", - "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.2.9.tgz", - "integrity": "sha512-Ip2mXoU0hM0boq3Rf+ekuT653OROSo6aSYcPT1VHE4q52KvyxgFkQgrgb/IEsxOuvQ2fZZbs8khJAyCEPM24/g==", + "version": "1.2.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.2.15.tgz", + "integrity": "sha512-HFZd2QNQYn1JbIi+Hpd7H+r/lkWxcfIu28nirZ6eCn2LafuvmExMlb/P96Sfx5tf7pPArw39IdhmCF8vmV+NhQ==", "cpu": [ "x64" ], @@ -7125,13 +7125,13 @@ } }, "node_modules/rolldown": { - "version": "1.2.9", - "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.2.9.tgz", - "integrity": "sha512-hx/Pv0N1haXRb11qkfnK5MXB/iqr7i0yjWQqmO9uHqZpBgQSqzc8UsSnEpalsh+j1I8qQ2CkXAkJC8Br3dKSlg==", + "version": "1.2.15", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.2.15.tgz", + "integrity": "sha512-6odND+fel5zVw3I4Yc2e69gvguDnX0CAAGiNDZSq4ik5WUcaLrkD2GLuog0XcODbor31hZ5ayEMgVDno+KPV5g==", "dev": true, "license": "MIT", "dependencies": { - "@oxc-project/types": "=0.150.0", + "@oxc-project/types": "=0.155.0", "@rolldown/pluginutils": "^1.0.0" }, "bin": { @@ -7141,21 +7141,21 @@ "node": "^20.19.0 || >=22.12.0" }, "optionalDependencies": { - "@rolldown/binding-android-arm-eabi": "1.2.9", - "@rolldown/binding-android-arm64": "1.2.9", - "@rolldown/binding-darwin-arm64": "1.2.9", - "@rolldown/binding-darwin-x64": "1.2.9", - "@rolldown/binding-freebsd-x64": "1.2.9", - "@rolldown/binding-linux-arm-gnueabihf": "1.2.9", - "@rolldown/binding-linux-arm64-gnu": "1.2.9", - "@rolldown/binding-linux-arm64-musl": "1.2.9", - "@rolldown/binding-linux-ppc64-gnu": "1.2.9", - "@rolldown/binding-linux-s390x-gnu": "1.2.9", - "@rolldown/binding-linux-x64-gnu": "1.2.9", - "@rolldown/binding-linux-x64-musl": "1.2.9", - "@rolldown/binding-openharmony-arm64": "1.2.9", - "@rolldown/binding-win32-arm64-msvc": "1.2.9", - "@rolldown/binding-win32-x64-msvc": "1.2.9" + "@rolldown/binding-android-arm-eabi": "1.2.15", + "@rolldown/binding-android-arm64": "1.2.15", + "@rolldown/binding-darwin-arm64": "1.2.15", + "@rolldown/binding-darwin-x64": "1.2.15", + "@rolldown/binding-freebsd-x64": "1.2.15", + "@rolldown/binding-linux-arm-gnueabihf": "1.2.15", + "@rolldown/binding-linux-arm64-gnu": "1.2.15", + "@rolldown/binding-linux-arm64-musl": "1.2.15", + "@rolldown/binding-linux-ppc64-gnu": "1.2.15", + "@rolldown/binding-linux-s390x-gnu": "1.2.15", + "@rolldown/binding-linux-x64-gnu": "1.2.15", + "@rolldown/binding-linux-x64-musl": "1.2.15", + "@rolldown/binding-openharmony-arm64": "1.2.15", + "@rolldown/binding-win32-arm64-msvc": "1.2.15", + "@rolldown/binding-win32-x64-msvc": "1.2.15" } }, "node_modules/safe-array-concat": { @@ -8089,16 +8089,16 @@ } }, "node_modules/vite": { - "version": "8.3.0", - "resolved": "https://registry.npmjs.org/vite/-/vite-8.3.0.tgz", - "integrity": "sha512-lhZBVvEHefgE+HQZC9O7EBJgCU/nVzFNl7vkS4RE0APtWLP02/8QVIkQtzBxPquh7lq5/78NHipTj7ODQ6XuyQ==", + "version": "8.3.3", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.3.3.tgz", + "integrity": "sha512-cTAldKPImjg6c+gk48U19POPn3GCBzZwpdsN8ZMEEcbpes+6/wvfqUd0C2y3qYY4wsj8PwgFwrZ/1jBPVttMSg==", "dev": true, "license": "MIT", "dependencies": { "lightningcss": "^1.33.0", "picomatch": "^4.0.7", "postcss": "^8.5.28", - "rolldown": "~1.2.6", + "rolldown": "~1.2.11", "tinyglobby": "^0.2.17" }, "bin": { diff --git a/examples/package.json b/examples/package.json index 400c1d56d94..a0f9645c78a 100644 --- a/examples/package.json +++ b/examples/package.json @@ -51,7 +51,7 @@ "react-router-dom": "7.18.4", "sharp": "0.35.5", "typescript": "5.9.3", - "vite": "8.3.0" + "vite": "8.3.3" }, "overrides": { "@playcanvas/inspector": { From 1d6227bbd36ba1537b90758ae9b1c693392f486b Mon Sep 17 00:00:00 2001 From: Martin Valigursky <59932779+mvaligursky@users.noreply.github.com> Date: Mon, 26 Oct 2026 14:44:32 +0000 Subject: [PATCH 08/22] feat: Make the frame pass classes public (#9822) Make FramePass, RenderPass, ComputePass and RenderPassShaderQuad part of the public API, so applications can write their own passes. - Document the pass lifecycle: what runs while the frame is built and while it renders, and rendering a pass on its own with render(). - Class docs with examples for RenderPass, RenderPassShaderQuad and ComputePass; drawQuadWithShader documents its immediate draw and points to RenderPassShaderQuad. - Add RenderPass#setStoreColor, #setStoreDepth, #setStoreStencil and #setGenerateMipmaps. The attachment operations and samples stay internal; clears go through setClearColor/Depth/Stencil. - A RenderPass is initialized with init() before it renders: debug builds report rendering it, or calling its setters, before init. - Make CameraComponent#beforePasses and #afterPasses public. - GraphicsDevice#setStencilState and #setDrawStates accept null stencil parameters. - Examples: render-pass-shader-quad uses setGenerateMipmaps, and the histogram sets its dispatch size in onBefore. Co-authored-by: Martin Valigursky --- .../examples/compute/histogram.example.mjs | 11 +- .../render-pass-shader-quad.example.mjs | 2 +- src/framework/components/camera/component.js | 2 - src/platform/graphics/compute-pass.js | 79 +++- src/platform/graphics/frame-pass.js | 89 ++++- src/platform/graphics/graphics-device.js | 18 +- src/platform/graphics/render-pass.js | 351 ++++++++++++++++-- src/scene/graphics/quad-render-utils.js | 25 +- src/scene/graphics/render-pass-shader-quad.js | 115 +++++- test/platform/graphics/render-pass.test.mjs | 118 ++++++ 10 files changed, 744 insertions(+), 66 deletions(-) diff --git a/examples/src/examples/compute/histogram.example.mjs b/examples/src/examples/compute/histogram.example.mjs index 783a8c58b58..21a8ad71763 100644 --- a/examples/src/examples/compute/histogram.example.mjs +++ b/examples/src/examples/compute/histogram.example.mjs @@ -187,8 +187,10 @@ app.on('destroy', () => { onDeviceLost.off(); }); -// Before the compute pass dispatches, clear the storage buffer, to avoid the accumulation buildup +// Right before the compute pass dispatches, set up the size of the dispatch, a thread for each +// pixel, and clear the storage buffer, to avoid the accumulation buildup computePass.onBefore = () => { + compute.setupDispatchThreads(device.width, device.height); histogramStorageBuffer.clear(); }; @@ -221,12 +223,7 @@ computePass.onAfter = () => { }); }; -app.on('update', (/** @type {number} */ _dt) => { - if (device.supportsCompute) { - // The size of the dispatch, a thread for each pixel, which the compute pass uses - compute.setupDispatchThreads(app.graphicsDevice.width, app.graphicsDevice.height); - } - +app.on('update', () => { // Render the most recently read histogram using lines if (histogramPositions) { app.drawLineArrays(histogramPositions, Color.YELLOW); diff --git a/examples/src/examples/render-targets/render-pass-shader-quad.example.mjs b/examples/src/examples/render-targets/render-pass-shader-quad.example.mjs index 3bf99fb1d81..94e42f5807a 100644 --- a/examples/src/examples/render-targets/render-pass-shader-quad.example.mjs +++ b/examples/src/examples/render-targets/render-pass-shader-quad.example.mjs @@ -249,7 +249,7 @@ const canvasTarget = new RenderTarget({ // brush pass, which renders last. const fadePass = new RenderPassFade(device); fadePass.init(canvasTarget); -fadePass.colorOps.genMipmaps = false; +fadePass.setGenerateMipmaps(false); // the canvas is cleared when the fade pass renders for the first time fadePass.setClearColor(Color.BLACK); diff --git a/src/framework/components/camera/component.js b/src/framework/components/camera/component.js index cd6e54a0b65..bb4132918fb 100644 --- a/src/framework/components/camera/component.js +++ b/src/framework/components/camera/component.js @@ -262,7 +262,6 @@ class CameraComponent extends Component { * them: whoever adds a pass destroys it. * * @type {FramePass[]} - * @ignore */ get beforePasses() { return this._camera.beforePasses; @@ -276,7 +275,6 @@ class CameraComponent extends Component { * adds a pass destroys it. * * @type {FramePass[]} - * @ignore */ get afterPasses() { return this._camera.afterPasses; diff --git a/src/platform/graphics/compute-pass.js b/src/platform/graphics/compute-pass.js index 8117456139d..e48f0f048c5 100644 --- a/src/platform/graphics/compute-pass.js +++ b/src/platform/graphics/compute-pass.js @@ -8,15 +8,69 @@ import { FramePass } from './frame-pass.js'; /** * A frame pass which dispatches a list of compute instances, in order, in a single compute pass. * It schedules compute work at a position in the frame, for example after a camera renders, to - * process its scene depth and color maps in the same frame. + * process its scene depth and color maps in the same frame. To dispatch compute instances right + * away instead, when called, use {@link GraphicsDevice#computeDispatch}. Compute shaders are only + * supported on WebGPU, see {@link GraphicsDevice#supportsCompute}. On other devices the pass + * executes without dispatching anything. + * + * ## Adding the pass to a frame + * + * Add the pass to the {@link CameraComponent#beforePasses} or the + * {@link CameraComponent#afterPasses} of a camera, to dispatch it before or after the camera + * renders, or to the {@link FramePass#beforePasses} or {@link FramePass#afterPasses} of another + * pass. It can also be rendered on its own, with {@link FramePass#render}. Set its + * {@link FramePass#name}, which labels the dispatch in GPU profiling. The pass does not destroy + * its compute instances, and the camera does not destroy the pass: whoever creates them destroys + * them. + * + * ## The dispatch * * Each compute instance is dispatched with the dispatch size and parameters set on it, typically - * set before the frame renders, or in {@link FramePass#onBefore}. The pass executes at most once - * in a frame. When the frame renders multiple XR views, {@link ComputePass#xrViewIndex} selects - * the view it executes in. As a compute instance is dispatched at most once in a frame, dispatching - * for several views needs a compute pass for each, with separate compute instances. + * set before the frame renders, or in {@link FramePass#onBefore}. Use onBefore also to prepare the + * buffers the shaders write, for example to clear them, and {@link FramePass#onAfter} to read the + * results back, as a read requested there is recorded right after the dispatch. The pass executes + * at most once in a frame. + * + * ## XR views + * + * When the frame renders multiple XR views, {@link ComputePass#xrViewIndex} selects the view the + * pass executes in. As a compute instance is dispatched at most once in a frame, dispatching for + * several views needs a compute pass for each, with separate compute instances. + * + * @example + * // A compute shader building a histogram of what a camera renders, into a storage buffer + * cameraComponent.requestSceneColorMap(true); + * + * const compute = new Compute(device, shader, 'Histogram'); + * compute.setSceneColorMap(cameraComponent.sceneColorMapHandle); + * compute.setParameter('bins', histogramBuffer); + * + * const pass = new ComputePass(device, [compute]); + * pass.name = 'HistogramPass'; + * + * // right before the dispatch: a thread for each pixel, and an empty buffer + * pass.onBefore = () => { + * compute.setupDispatchThreads(device.width, device.height); + * histogramBuffer.clear(); + * }; * - * @ignore + * // read the result back right after the dispatch + * pass.onAfter = () => { + * histogramBuffer.read().then((data) => { + * // the histogram, which arrives a few frames after it was computed + * }); + * }; + * + * // dispatch after the camera renders, so the shader reads the scene color of the same frame + * cameraComponent.afterPasses.push(pass); + * + * // when no longer needed, remove the pass, and destroy it and the compute instance + * const passes = cameraComponent.afterPasses; + * passes.splice(passes.indexOf(pass), 1); + * pass.destroy(); + * compute.destroy(); + * + * @category Graphics */ class ComputePass extends FramePass { /** @@ -73,6 +127,11 @@ class ComputePass extends FramePass { return this._xrViewIndex; } + /** + * Renders the pass, as {@link FramePass#render} describes. When the frame renders multiple XR + * views, it executes only in the view selected by {@link ComputePass#xrViewIndex}, and is + * skipped in the others. + */ render() { // the index of the XR view being rendered, or -1 outside of the views const viewIndex = this.device.xrCurrentViewIndex ?? -1; @@ -81,6 +140,10 @@ class ComputePass extends FramePass { } } + /** + * Dispatches the compute instances in {@link ComputePass#computes}, in order, in a single + * compute pass labelled with {@link FramePass#name}. Does nothing when there are none. + */ execute() { const { computes } = this; if (computes.length) { @@ -88,6 +151,10 @@ class ComputePass extends FramePass { } } + /** + * Releases the pass. It empties {@link ComputePass#computes}, without destroying the compute + * instances, which are destroyed by whoever created them. + */ destroy() { this.computes = []; diff --git a/src/platform/graphics/frame-pass.js b/src/platform/graphics/frame-pass.js index 48d962fd997..eae3e18fa05 100644 --- a/src/platform/graphics/frame-pass.js +++ b/src/platform/graphics/frame-pass.js @@ -11,10 +11,25 @@ import { TRACEID_RENDER_PASS } from '../../core/constants.js'; * executes during frame rendering. Subclasses include {@link RenderPass} for GPU render passes * with render targets, and non-rendering passes for compute dispatches or other tasks. * - * @ignore + * In each frame a pass is part of, for example as a frame pass of a camera, or as a before or + * after pass of another pass, it goes through these steps: + * + * - While the frame is built, before anything renders: {@link FramePass#frameUpdate} is called, + * and then the {@link FramePass#beforePasses} and {@link FramePass#afterPasses} of the pass are + * added to the frame around it. A disabled pass is skipped, together with those passes. + * - While the frame renders: {@link FramePass#before} and {@link FramePass#onBefore} are called, + * then {@link FramePass#execute} while {@link FramePass#executeEnabled} is true, and then + * {@link FramePass#after} and {@link FramePass#onAfter}. + * + * A pass can also be rendered on its own, outside of a frame, by calling {@link FramePass#render}. + * + * @category Graphics */ class FramePass { - /** @type {string} */ + /** + * @type {string} + * @private + */ _name; /** @@ -128,10 +143,22 @@ class FramePass { this.device = graphicsDevice; } + /** + * Sets the name of the pass, which identifies it in debug output, GPU profiling and graphics + * debuggers. Defaults to the name of its class, which minified builds can shorten, so set it + * explicitly for a readable name. + * + * @type {string} + */ set name(value) { this._name = value; } + /** + * Gets the name of the pass. + * + * @type {string} + */ get name() { if (!this._name) { this._name = this.constructor.name; @@ -169,28 +196,54 @@ class FramePass { return this._enabled; } + /** + * Called when the pass is enabled, by setting {@link FramePass#enabled} to true while it is + * false. Override to recreate what {@link FramePass#onDisable} released. + */ onEnable() { } + /** + * Called when the pass is disabled, by setting {@link FramePass#enabled} to false while it is + * true. Override to release what the pass does not need while it is disabled, such as its + * render targets. + */ onDisable() { } + /** + * Called each frame the pass is added to the frame, while the frame is built and before + * anything renders, and before its {@link FramePass#beforePasses} and + * {@link FramePass#afterPasses} are added. Override to update the pass for the frame, for + * example to resize its render targets, to set the textures it reads, to add or remove its + * before and after passes and enable or disable them, or to set its + * {@link FramePass#executeEnabled}. It cannot disable the pass itself. It is not called for a + * disabled pass, nor when the pass is rendered on its own with {@link FramePass#render}. + */ frameUpdate() { } /** - * Called before execution when the pass is enabled, even if {@link executeEnabled} is false. - * Overrides that require execution must check {@link executeEnabled} themselves. + * Called while the pass renders, before {@link FramePass#execute}. It is called even when + * {@link FramePass#executeEnabled} is false and the pass does not execute, so an override + * preparing only for the execution can skip that work while executeEnabled is false. */ before() { } + /** + * Records the work of the pass. Called while the frame renders, between + * {@link FramePass#before} and {@link FramePass#after}, and only while + * {@link FramePass#executeEnabled} is true. For a {@link RenderPass}, it runs inside the GPU + * render pass, which starts right before it. + */ execute() { } /** - * Called after execution when the pass is enabled, even if {@link executeEnabled} is false. - * Overrides that require execution must check {@link executeEnabled} themselves. + * Called while the pass renders, after {@link FramePass#execute}. It is called even when + * {@link FramePass#executeEnabled} is false and the pass did not execute, so an override + * using the results of the execution can skip that work while executeEnabled is false. */ after() { } @@ -208,6 +261,23 @@ class FramePass { DebugHelper.setDestroyed(this); } + /** + * Renders the pass: calls {@link FramePass#before} and {@link FramePass#onBefore}, then + * {@link FramePass#execute} while {@link FramePass#executeEnabled} is true, and then + * {@link FramePass#after} and {@link FramePass#onAfter}. It does nothing while the pass is + * disabled. The frame graph calls this for each pass in the frame. + * + * It can also be called directly, to render the pass on its own, outside of a frame, for + * example to render into a texture once. The pass is then not prepared the way a frame + * prepares it: its {@link FramePass#frameUpdate} is not called, its before and after passes do + * not render, and the frame graph does not merge it with neighbouring passes, nor ask it to + * store the attachments a later pass loads. A {@link RenderPass} rendered on its own keeps the + * textures of its render target: its color buffers, resolved when multisampled, and its depth + * buffer texture. It does not keep the samples of a multisampled attachment, the stencil, or a + * depth buffer its render target creates internally, unless it is set to store them with + * {@link RenderPass#setStoreColor}, {@link RenderPass#setStoreDepth} and + * {@link RenderPass#setStoreStencil}. + */ render() { if (this.enabled) { @@ -227,6 +297,13 @@ class FramePass { } // #if _DEBUG + /** + * Logs the pass for the frame trace, in debug builds. + * + * @param {GraphicsDevice} device - The graphics device. + * @param {number} [index] - The index of the pass in the frame. + * @ignore + */ log(device, index = 0) { if (Tracing.get(TRACEID_RENDER_PASS)) { const indexString = index.toString().padEnd(2, ' '); diff --git a/src/platform/graphics/graphics-device.js b/src/platform/graphics/graphics-device.js index 39503d26d47..de0a7725579 100644 --- a/src/platform/graphics/graphics-device.js +++ b/src/platform/graphics/graphics-device.js @@ -1362,13 +1362,13 @@ class GraphicsDevice extends EventHandler { // ---- deprecated block end ---- /** - * Sets the specified stencil state. If both stencilFront and stencilBack are null, stencil - * operation is disabled. + * Sets the specified stencil state. If both stencilFront and stencilBack are null or not + * specified, stencil operation is disabled. * - * @param {StencilParameters} [stencilFront] - The front stencil parameters. Defaults to - * {@link StencilParameters.DEFAULT} if not specified. - * @param {StencilParameters} [stencilBack] - The back stencil parameters. Defaults to - * {@link StencilParameters.DEFAULT} if not specified. + * @param {StencilParameters|null} [stencilFront] - The front stencil parameters. Defaults to + * {@link StencilParameters.DEFAULT} if not specified, while stencilBack is. + * @param {StencilParameters|null} [stencilBack] - The back stencil parameters. Defaults to + * {@link StencilParameters.DEFAULT} if not specified, while stencilFront is. */ setStencilState(stencilFront, stencilBack) { Debug.assert(false); @@ -1442,8 +1442,10 @@ class GraphicsDevice extends EventHandler { * @param {DepthState} [depthState] - Depth state. Defaults to {@link DepthState.NODEPTH}. * @param {number} [cullMode] - Cull mode. Defaults to {@link CULLFACE_NONE}. * @param {number} [frontFace] - Front face winding. Defaults to {@link FRONTFACE_CCW}. - * @param {StencilParameters} [stencilFront] - Front stencil parameters. - * @param {StencilParameters} [stencilBack] - Back stencil parameters. + * @param {StencilParameters|null} [stencilFront] - Front stencil parameters. When neither the + * front nor the back stencil parameters are specified, stencil operation is disabled, see + * {@link GraphicsDevice#setStencilState}. + * @param {StencilParameters|null} [stencilBack] - Back stencil parameters. */ setDrawStates(blendState = BlendState.NOBLEND, depthState = DepthState.NODEPTH, cullMode = CULLFACE_NONE, frontFace = FRONTFACE_CCW, diff --git a/src/platform/graphics/render-pass.js b/src/platform/graphics/render-pass.js index 86a9d84973d..d5884a0c223 100644 --- a/src/platform/graphics/render-pass.js +++ b/src/platform/graphics/render-pass.js @@ -46,15 +46,25 @@ const validateClearValues = (renderPass) => { }); }; +/** + * The operations a {@link RenderPass} performs on one of its color attachments: whether it is + * cleared before the pass renders, and whether it is stored, resolved and has its mipmaps + * generated after. {@link RenderPass#init} allocates them with defaults matching the render + * target. + * + * @ignore + */ class ColorAttachmentOps { /** - * A color used to clear the color attachment when the clear is enabled, specified in sRGB space. + * A color used to clear the color attachment when the clear is enabled, specified in sRGB + * space. Set it with {@link RenderPass#setClearColor}, which also updates the linear value + * used to clear sRGB attachments. */ clearValue = new Color(0, 0, 0, 1); /** * A color used to clear the color attachment when the clear is enabled, specified in linear - * space. + * space. Kept in sync with the clearValue by {@link RenderPass#setClearColor}. */ clearValueLinear = new Color(0, 0, 0, 1); @@ -84,6 +94,13 @@ class ColorAttachmentOps { genMipmaps = false; } +/** + * The operations a {@link RenderPass} performs on its depth and stencil attachment: whether they + * are cleared before the pass renders, and whether they are stored and resolved after. + * {@link RenderPass#init} allocates them with defaults matching the render target. + * + * @ignore + */ class DepthStencilAttachmentOps { /** * A depth value used to clear the depth attachment when the clear is enabled. @@ -126,21 +143,111 @@ class DepthStencilAttachmentOps { } /** - * A render pass represents a node in the frame graph that renders to a render target using a GPU - * render pass. It extends {@link FramePass} with render target management, color/depth/stencil - * attachment operations, and GPU render pass lifecycle (start/end). + * A render pass is a {@link FramePass} which draws into a render target. Its draw calls are + * recorded inside a GPU render pass, which starts right before the pass executes and ends right + * after. It is the base class for passes which draw, for example a pass rendering a quad with a + * shader, see {@link RenderPassShaderQuad}. For work which does not draw, such as compute + * dispatches, use {@link FramePass} or {@link ComputePass} instead. * - * @ignore + * ## Setting up the pass + * + * Once the pass is created, call {@link RenderPass#init} with the {@link RenderTarget} to draw + * into, or with null to draw into the back buffer. The pass does not own its render target, and + * does not destroy it. When init is given options, the pass also resizes its render target each + * frame, to the size of a texture, a render target or the back buffer, scaled by + * {@link RenderPass#scaleX} and {@link RenderPass#scaleY}. + * + * ## Loading and clearing the attachments + * + * The attachments of the render target are its color buffers, and its depth and stencil buffers. + * By default, the GPU render pass loads their content, and the pass draws over it. To start from a + * single value instead, clear them with {@link RenderPass#setClearColor}, + * {@link RenderPass#setClearDepth} and {@link RenderPass#setClearStencil}. When the pass covers an + * attachment completely, clearing it is also faster than loading it on tiled mobile GPUs, which + * then do not read the previous content from memory. + * + * ## Storing the attachments + * + * When the GPU render pass ends, the pass keeps what it rendered into the textures of its render + * target, so they can be sampled, or loaded by a later pass: its color buffers, resolved when the + * render target is multisampled, and its depth buffer texture. Other attachments are discarded: + * the samples of a multisampled color buffer which is resolved, the stencil, and a depth buffer + * the render target creates internally. When a later pass of the same frame draws into the same + * render target and loads them, the frame graph stores them automatically. Otherwise, store them + * with {@link RenderPass#setStoreColor}, + * {@link RenderPass#setStoreDepth} and {@link RenderPass#setStoreStencil}. The mipmaps of color + * buffers with mipmaps are generated at this point too, which + * {@link RenderPass#setGenerateMipmaps} can turn off, for example when another pass draws into + * the same render target after this one. + * + * ## Writing a pass + * + * A subclass records its draw calls in {@link FramePass#execute}, which is called while the GPU + * render pass is active. It can also override {@link RenderPass#postInit} to set up its clears + * once the pass is initialized, and {@link RenderPass#frameUpdate} to update the pass before each + * frame renders, calling `super.frameUpdate()` to keep the resize. It releases what it created in + * {@link FramePass#destroy}, calling `super.destroy()`. + * + * ## Rendering the pass + * + * A pass renders in one of two ways: + * + * - As part of a frame, for example as a frame pass of a camera, or as a before or after pass of + * another pass. The frame graph then calls its {@link RenderPass#frameUpdate}, and merges + * consecutive passes drawing into the same render target into a single GPU render pass, unless + * something separates them, for example a clear of the later pass. + * - On its own, by calling {@link RenderPass#render}, for example to render into a texture each + * time its content needs to change. See {@link FramePass#render} for the steps a frame performs, + * which are skipped then. + * + * @example + * // A pass drawing a quad with a shader, covering its whole render target + * class MyPass extends RenderPass { + * constructor(device, shader) { + * super(device); + * this.quadRender = new QuadRender(shader); + * } + * + * execute() { + * // the default draw states suit a quad: no blending, and no depth test or write + * this.device.setDrawStates(); + * this.quadRender.render(); + * } + * + * destroy() { + * this.quadRender.destroy(); + * super.destroy(); + * } + * } + * + * const renderTarget = new RenderTarget({ colorBuffer: texture, depth: false }); + * const pass = new MyPass(device, shader); + * pass.name = 'MyPass'; + * pass.init(renderTarget); + * + * // the quad covers the whole target, so clear it instead of loading its content + * pass.setClearColor(Color.BLACK); + * + * // draw into the texture + * pass.render(); + * + * // destroy the pass, and then the render target, which the pass does not own + * pass.destroy(); + * renderTarget.destroy(); + * + * @category Graphics */ class RenderPass extends FramePass { /** - * The render target for this render pass: - * - * - `undefined`: render pass does not render to any render target - * - `null`: render pass renders to the backbuffer - * - Otherwise, renders to the provided RT. - * - * The render pass does not own the render target, and does not destroy it. + * The render target the pass draws into, or null when it draws into the back buffer. It is set + * by {@link RenderPass#init}, which sets up the pass for the attachments of the render target, + * and which is called again to switch the pass to a render target with different attachments. + * Assigning a render target directly is only supported for a render target with the same + * layout: the same color buffers and their formats, the same depth and stencil buffers, and the + * same number of samples. For example, a pass can alternate between two such render targets + * each frame. For work which does not draw into a render target, use {@link FramePass} + * instead. It is undefined until the pass is initialized. The render pass does not own the + * render target, and does not destroy it. * * @type {RenderTarget|null|undefined} */ @@ -148,12 +255,17 @@ class RenderPass extends FramePass { /** * The options specified when the render target was initialized. + * + * @type {object|undefined} + * @private */ _options; /** * Number of samples. 0 if no render target, otherwise number of samples from the render target, * or the main framebuffer if render target is null. + * + * @ignore */ samples = 0; @@ -162,6 +274,7 @@ class RenderPass extends FramePass { * 0, and so on. * * @type {Array} + * @ignore */ colorArrayOps = []; @@ -169,37 +282,73 @@ class RenderPass extends FramePass { * Color attachment operations for the first color attachment. * * @type {ColorAttachmentOps} + * @ignore */ get colorOps() { return this.colorArrayOps[0]; } - /** @type {DepthStencilAttachmentOps} */ + /** + * Depth and stencil attachment operations. + * + * @type {DepthStencilAttachmentOps} + * @ignore + */ depthStencilOps; /** * True if the render pass uses the full viewport / scissor for rendering into the render target. + * + * @ignore */ fullSizeClearRect = true; + /** + * Sets the scale factor of the width of the render target, relative to the size of the resize + * source, see {@link RenderPass#init}. Only valid for a pass initialized with options. + * + * @type {number} + */ set scaleX(value) { Debug.assert(this._options, 'The render pass needs to be initialized first.'); this._options.scaleX = value; } + /** + * Gets the scale factor of the width of the render target. + * + * @type {number} + */ get scaleX() { return this._options.scaleX; } + /** + * Sets the scale factor of the height of the render target, relative to the size of the resize + * source, see {@link RenderPass#init}. Only valid for a pass initialized with options. + * + * @type {number} + */ set scaleY(value) { Debug.assert(this._options, 'The render pass needs to be initialized first.'); this._options.scaleY = value; } + /** + * Gets the scale factor of the height of the render target. + * + * @type {number} + */ get scaleY() { return this._options.scaleY; } + /** + * Sets the options the pass was initialized with. + * + * @type {object|undefined} + * @ignore + */ set options(value) { this._options = value; @@ -210,18 +359,37 @@ class RenderPass extends FramePass { } } + /** + * Gets the options the pass was initialized with. + * + * @type {object|undefined} + * @ignore + */ get options() { return this._options; } /** - * @param {RenderTarget|null} [renderTarget] - The render target to render into (output). This - * function should be called only for render passes which use render target, or passes which - * render directly into the default framebuffer, in which case a null or undefined render - * target is expected. The render pass does not own the render target. + * Initializes the pass to draw into a render target, or into the back buffer. A render pass + * needs to be initialized before it renders, and before its clears, stores and mipmaps are set + * with {@link RenderPass#setClearColor}, {@link RenderPass#setStoreColor}, + * {@link RenderPass#setGenerateMipmaps} and the related setters. It sets up the operations of + * the attachments of the render target with their defaults, so the attachments are not + * cleared until a clear is set. + * + * Call it again to switch the pass to a render target with different attachments, which resets + * its clears, stores and mipmaps to the defaults. A render target with the same layout can + * also be assigned to {@link RenderPass#renderTarget} directly. + * + * When options are provided, the render target is resized each frame, in + * {@link RenderPass#frameUpdate}, to the size of the resize source scaled by + * {@link RenderPass#scaleX} and {@link RenderPass#scaleY}. Without options, it is not resized. + * + * @param {RenderTarget|null} [renderTarget] - The render target to draw into, or null to draw + * into the back buffer. Defaults to null. The render pass does not own the render target. * @param {object} [options] - Object for passing optional arguments. - * @param {Texture} [options.resizeSource] - A texture to use as a source for the automatic - * render target resize operation. If not provided, no automatic resizing takes place. + * @param {Texture|RenderTarget} [options.resizeSource] - The texture or render target whose + * size the render target is resized to. Defaults to the back buffer. * @param {number} [options.scaleX] - The scale factor for the render target width. Defaults to 1. * @param {number} [options.scaleY] - The scale factor for the render target height. Defaults to 1. */ @@ -242,6 +410,11 @@ class RenderPass extends FramePass { this.postInit(); } + /** + * Allocates the operations of the attachments of the render target, with their defaults. + * + * @ignore + */ allocateAttachments() { const rt = this.renderTarget; @@ -293,9 +466,19 @@ class RenderPass extends FramePass { } } + /** + * Called at the end of {@link RenderPass#init}, once the pass is set up for its render + * target. Override to finish the setup, for example to set the clears the pass uses. + */ postInit() { } + /** + * Resizes the render target to the size of the resize source, scaled by + * {@link RenderPass#scaleX} and {@link RenderPass#scaleY}, when the pass was initialized with + * options. See {@link FramePass#frameUpdate}. Subclasses overriding it call + * `super.frameUpdate()` to keep the resize. + */ frameUpdate() { // resize the render target if needed if (this._options && this.renderTarget) { @@ -316,6 +499,7 @@ class RenderPass extends FramePass { */ setClearColor(color, index) { + Debug.assert(this.renderTarget !== undefined, `RenderPass#setClearColor is called on the render pass '${this.name}' before it is initialized with init.`, this); const count = this.colorArrayOps.length; Debug.assert(index === undefined || (index >= 0 && index < count), `setClearColor index ${index} is out of range, the render pass has ${count} color attachments.`); @@ -341,10 +525,14 @@ class RenderPass extends FramePass { * the existing content. */ setClearDepth(depthValue) { - if (depthValue !== undefined) { - this.depthStencilOps.clearDepthValue = depthValue; + Debug.assert(this.renderTarget !== undefined, `RenderPass#setClearDepth is called on the render pass '${this.name}' before it is initialized with init.`, this); + const ops = this.depthStencilOps; + if (ops) { + if (depthValue !== undefined) { + ops.clearDepthValue = depthValue; + } + ops.clearDepth = depthValue !== undefined; } - this.depthStencilOps.clearDepth = depthValue !== undefined; } /** @@ -354,21 +542,130 @@ class RenderPass extends FramePass { * preserve the existing content. */ setClearStencil(stencilValue) { - if (stencilValue !== undefined) { - this.depthStencilOps.clearStencilValue = stencilValue; + Debug.assert(this.renderTarget !== undefined, `RenderPass#setClearStencil is called on the render pass '${this.name}' before it is initialized with init.`, this); + const ops = this.depthStencilOps; + if (ops) { + if (stencilValue !== undefined) { + ops.clearStencilValue = stencilValue; + } + ops.clearStencil = stencilValue !== undefined; } - this.depthStencilOps.clearStencil = stencilValue !== undefined; } /** - * Render the render pass + * Sets whether the pass stores its color attachments once it renders, so that a later pass + * can load them. By default, single-sampled color buffers are stored, while the samples of a + * multisampled color buffer are stored only when it has no resolve buffer, as the resolved + * color is the output of the pass. Within a frame, the frame graph also stores the attachments + * a later pass on the same render target loads, so this is needed for passes rendered on + * their own, see {@link FramePass#render}. Not storing a single-sampled color buffer discards + * what the pass renders into it. Set it once the pass is initialized, as + * {@link RenderPass#init} resets it. + * + * @param {boolean} store - True to store the attachments, false to discard them. + * @param {number} [index] - The index of the color attachment to modify. When not specified, + * all color attachments are modified. + */ + setStoreColor(store, index) { + + Debug.assert(this.renderTarget !== undefined, `RenderPass#setStoreColor is called on the render pass '${this.name}' before it is initialized with init.`, this); + const count = this.colorArrayOps.length; + Debug.assert(index === undefined || (index >= 0 && index < count), + `setStoreColor index ${index} is out of range, the render pass has ${count} color attachments.`); + + const start = index ?? 0; + const end = index === undefined ? count : index + 1; + for (let i = start; i < end; i++) { + const colorOps = this.colorArrayOps[i]; + if (colorOps) { + colorOps.store = store; + } + } + } + + /** + * Sets whether the pass stores its depth attachment once it renders, so that a later pass can + * load it. By default, the depth buffer texture of the render target is stored, while a depth + * buffer the render target creates internally is discarded. Within a frame, the frame graph + * also stores the depth a later pass on the same render target loads, so this is needed for + * passes rendered on their own, see {@link FramePass#render}. Set it once the pass is + * initialized, as {@link RenderPass#init} resets it. + * + * @param {boolean} store - True to store the depth, false to discard it. + */ + setStoreDepth(store) { + Debug.assert(this.renderTarget !== undefined, `RenderPass#setStoreDepth is called on the render pass '${this.name}' before it is initialized with init.`, this); + if (this.depthStencilOps) { + this.depthStencilOps.storeDepth = store; + } + } + + /** + * Sets whether the pass stores its stencil attachment once it renders, so that a later pass + * can load it. By default, the stencil is discarded. Within a frame, the frame graph also + * stores the stencil a later pass on the same render target loads, so this is needed for + * passes rendered on their own, see {@link FramePass#render}. Set it once the pass is + * initialized, as {@link RenderPass#init} resets it. + * + * @param {boolean} store - True to store the stencil, false to discard it. + */ + setStoreStencil(store) { + Debug.assert(this.renderTarget !== undefined, `RenderPass#setStoreStencil is called on the render pass '${this.name}' before it is initialized with init.`, this); + if (this.depthStencilOps) { + this.depthStencilOps.storeStencil = store; + } + } + + /** + * Sets whether the pass generates the mipmaps of its color attachments once it renders. By + * default, they are generated for color buffers with mipmaps, unless the render target renders + * into a single mip level, see {@link RenderTarget#mipLevel}, or the buffer uses an integer + * format. When several passes render to the same render target, the mipmaps are only needed + * after the last of them, so disabling them for the others avoids generating them more than + * once. Set it once the pass is initialized, as {@link RenderPass#init} resets it. + * + * @param {boolean} generate - True to generate the mipmaps of the color buffers which support + * them, false to not generate them. + * @param {number} [index] - The index of the color attachment to modify. When not specified, + * all color attachments are modified. + */ + setGenerateMipmaps(generate, index) { + + Debug.assert(this.renderTarget !== undefined, `RenderPass#setGenerateMipmaps is called on the render pass '${this.name}' before it is initialized with init.`, this); + const count = this.colorArrayOps.length; + Debug.assert(index === undefined || (index >= 0 && index < count), + `setGenerateMipmaps index ${index} is out of range, the render pass has ${count} color attachments.`); + + const rt = this.renderTarget; + const start = index ?? 0; + const end = index === undefined ? count : index + 1; + for (let i = start; i < end; i++) { + const colorOps = this.colorArrayOps[i]; + if (colorOps) { + const colorBuffer = rt?.getColorBuffer(i); + const supported = !!(rt?.mipmaps && colorBuffer?.mipmaps && !isIntegerPixelFormat(colorBuffer.format)); + Debug.assert(!generate || supported || index === undefined, + `Render pass '${this.name}' cannot generate the mipmaps of the color attachment ${i} of render target '${rt?.name}', as it has no mipmaps or uses an integer format.`, this); + colorOps.genMipmaps = generate && supported; + } + } + } + + /** + * Renders the pass, as {@link FramePass#render} describes, starting its GPU render pass right + * before {@link FramePass#execute} and ending it right after. A pass the frame graph merged + * with a neighbouring pass on the same render target shares a single GPU render pass with it. + * The pass needs to be initialized with {@link RenderPass#init} before it renders. */ render() { if (this.enabled) { const device = this.device; + + // a pass which was not initialized executes without a GPU render pass const realPass = this.renderTarget !== undefined; + Debug.assert(realPass, `The render pass '${this.name}' renders before it is initialized. Call its init with the render target to draw into, or with null for the back buffer. For work which does not draw into a render target, use a FramePass instead.`, this); Debug.call(() => { this.log(device, device.renderPassIndex); diff --git a/src/scene/graphics/quad-render-utils.js b/src/scene/graphics/quad-render-utils.js index efb18bc605f..4c15d475f4a 100644 --- a/src/scene/graphics/quad-render-utils.js +++ b/src/scene/graphics/quad-render-utils.js @@ -12,19 +12,34 @@ import { RenderPassQuad } from './render-pass-quad.js'; const _tempRect = new Vec4(); /** - * Draws a screen-space quad using a specific shader. + * Draws a quad with a shader into a render target, covering the whole render target, or the + * viewport rectangle. It draws immediately, outside of the frame graph: each call creates a render + * pass and the quad, renders the pass, and destroys the quad, so it allocates on every call. This + * suits a single draw, for example to generate a texture once. To draw a quad every frame, as part + * of a frame, or with its own render states, use a {@link RenderPassShaderQuad} instead, which is + * created once and reused. + * + * The content of the render target is loaded, and not cleared. The quad is drawn with the blend + * state currently set on the graphics device, see {@link GraphicsDevice#setBlendState}, and + * without depth test, face culling or stencil. The parameters of the shader are set on the scope + * of the graphics device before the call, see {@link GraphicsDevice#scope}. * * @param {GraphicsDevice} device - The graphics device used to draw the quad. - * @param {RenderTarget|null} target - The destination render target. If undefined, target is the - * frame buffer. - * @param {Shader} shader - The shader used for rendering the quad. Vertex shader should contain - * `attribute vec2 vertex_position`. + * @param {RenderTarget|null} target - The render target to draw into, or null to draw into the + * back buffer. + * @param {Shader} shader - The shader used for rendering the quad. Its vertex shader receives the + * corners of the quad in an attribute using {@link SEMANTIC_POSITION}, which the `quadVS` shader + * chunk implements, see {@link RenderPassShaderQuad}. * @param {Vec4} [rect] - The viewport rectangle of the quad, in pixels. Defaults to fullscreen: * `[0, 0, target.width, target.height]`. * @param {Vec4} [scissorRect] - The scissor rectangle of the quad, in pixels. Defaults to fullscreen: * `[0, 0, target.width, target.height]`. * @param {string} [name] - The render pass name used for GPU profiling and debugging in debug * builds. Defaults to 'RenderPassQuad'. + * @example + * // generate a texture once, by drawing a shader into its render target + * device.scope.resolve('uScale').setValue(4); + * drawQuadWithShader(device, renderTarget, shader); * @category Graphics */ function drawQuadWithShader(device, target, shader, rect, scissorRect, name) { diff --git a/src/scene/graphics/render-pass-shader-quad.js b/src/scene/graphics/render-pass-shader-quad.js index 17e2035ece2..094bc68f4ee 100644 --- a/src/scene/graphics/render-pass-shader-quad.js +++ b/src/scene/graphics/render-pass-shader-quad.js @@ -11,11 +11,118 @@ import { RenderPass } from '../../platform/graphics/render-pass.js'; */ /** - * A render pass that implements rendering a quad with a shader, and exposes controls over the - * render state. This is typically used as a base class for render passes that render a quad with - * a shader, but can be used directly as well by specifying a shader. + * A render pass which draws a quad with a shader into its render target. This is how most image + * processing is done, such as post effects, blurs, or generating a texture, as the shader then + * runs once for each pixel the quad covers. The pass creates the quad for its + * {@link RenderPassShaderQuad#shader}, sets the render states, and draws the quad when it executes. + * It can be used directly, by setting its shader, or as a base class of a pass which creates the + * shader and sets its parameters. For a single draw, such as generating a texture once, + * {@link drawQuadWithShader} draws a quad with a shader without setting up a pass. * - * @ignore + * ## The shader + * + * The vertex shader receives the corners of the quad in an attribute using + * {@link SEMANTIC_POSITION}. The `quadVS` shader chunk implements it, and passes the uv + * coordinates of the quad to the fragment shader in the `uv0` varying. Create the shader with + * {@link ShaderUtils.createShader}, with the fragment shader in both GLSL and WGSL, so the pass + * works on both WebGL2 and WebGPU. The pass does not destroy its shader, as shaders are shared. + * + * ## The shader parameters + * + * The textures and uniforms of the shader are set on the scope of the graphics device, see + * {@link GraphicsDevice#scope}. Resolve them once, when the pass is created, and set their values + * in an override of {@link FramePass#execute}, right before the quad is drawn by calling + * `super.execute()`. Other passes can set parameters with the same names, so values set earlier + * might not be the values the quad is drawn with. + * + * ## Render states and the drawn area + * + * By default, the quad covers the whole render target, and is drawn without blending, depth test + * or face culling, replacing what the render target contains. As the content is then not needed, + * clearing the render target with {@link RenderPass#setClearColor} saves loading it. To combine + * the quad with the content instead, set a {@link RenderPassShaderQuad#blendState}, and to test + * it against the depth or the stencil of the render target, set a + * {@link RenderPassShaderQuad#depthState} or the stencil parameters. To draw into a part of the + * render target only, set its {@link RenderPassShaderQuad#viewport}. + * + * @example + * ```javascript + * // A pass drawing a texture into its render target, multiplied by a color + * class TintPass extends RenderPassShaderQuad { + * constructor(device, sourceTexture) { + * super(device); + * this.name = 'TintPass'; + * this.sourceTexture = sourceTexture; + * this.tint = new Float32Array([1, 0.5, 0.5]); + * + * this.shader = ShaderUtils.createShader(device, { + * uniqueName: 'TintShader', + * attributes: { aPosition: SEMANTIC_POSITION }, + * vertexChunk: 'quadVS', + * fragmentGLSL: ` + * uniform sampler2D sourceTexture; + * uniform vec3 tint; + * varying vec2 uv0; + * + * void main() { + * vec4 color = texture2D(sourceTexture, uv0); + * gl_FragColor = vec4(color.rgb * tint, color.a); + * } + * `, + * fragmentWGSL: ` + * var sourceTexture: texture_2d; + * var sourceTextureSampler: sampler; + * uniform tint: vec3f; + * varying uv0: vec2f; + * + * @fragment fn fragmentMain(input: FragmentInput) -> FragmentOutput { + * var output: FragmentOutput; + * let color = textureSample(sourceTexture, sourceTextureSampler, input.uv0); + * output.color = vec4f(color.rgb * uniform.tint, color.a); + * return output; + * } + * ` + * }); + * + * // resolve the shader parameters once + * this.sourceTextureId = device.scope.resolve('sourceTexture'); + * this.tintId = device.scope.resolve('tint'); + * } + * + * execute() { + * // set the parameters right before the quad is drawn + * this.sourceTextureId.setValue(this.sourceTexture); + * this.tintId.setValue(this.tint); + * super.execute(); + * } + * } + * + * const pass = new TintPass(device, sourceTexture); + * pass.init(renderTarget); + * + * // the quad covers the whole render target, so clear it instead of loading its content + * pass.setClearColor(Color.BLACK); + * pass.render(); + * ``` + * @example + * // Use the pass directly, to draw a shader into a 256x256 tile of a texture atlas. The atlas + * // is loaded and not cleared, so its other tiles keep their content. + * const pass = new RenderPassShaderQuad(device); + * pass.name = 'AtlasTile'; + * pass.shader = tileShader; + * pass.init(atlasRenderTarget); + * + * pass.viewport = new Vec4(256, 0, 256, 256); + * pass.render(); + * + * // blend a second shader over the same tile + * pass.shader = overlayShader; + * pass.blendState = BlendState.ALPHABLEND; + * pass.render(); + * + * pass.destroy(); + * + * @category Graphics */ class RenderPassShaderQuad extends RenderPass { /** diff --git a/test/platform/graphics/render-pass.test.mjs b/test/platform/graphics/render-pass.test.mjs index 5b4c4bdffac..dfbfb5f7c71 100644 --- a/test/platform/graphics/render-pass.test.mjs +++ b/test/platform/graphics/render-pass.test.mjs @@ -1,6 +1,7 @@ import { expect } from 'chai'; import { restore, stub } from 'sinon'; +import { Debug } from '../../../src/core/debug.js'; import { Color } from '../../../src/core/math/color.js'; import { PIXELFORMAT_DEPTH, PIXELFORMAT_R32F, PIXELFORMAT_RGBA8 } from '../../../src/platform/graphics/constants.js'; import { RenderPass } from '../../../src/platform/graphics/render-pass.js'; @@ -109,6 +110,69 @@ describe('RenderPass', function () { }); + describe('#setStoreColor', function () { + + it('sets the store of all attachments when no index is specified', function () { + renderPass.setStoreColor(false); + expect(renderPass.colorArrayOps[0].store).to.equal(false); + expect(renderPass.colorArrayOps[1].store).to.equal(false); + }); + + it('sets the store of a single attachment when an index is specified', function () { + renderPass.setStoreColor(false, 1); + expect(renderPass.colorArrayOps[0].store).to.equal(true); + expect(renderPass.colorArrayOps[1].store).to.equal(false); + }); + + it('is reset to the default by init', function () { + renderPass.setStoreColor(false); + renderPass.init(renderTarget); + expect(renderPass.colorArrayOps[0].store).to.equal(true); + }); + + }); + + describe('#setStoreDepth and #setStoreStencil', function () { + + it('set the store of the depth and the stencil', function () { + expect(renderPass.depthStencilOps.storeDepth).to.equal(false); + expect(renderPass.depthStencilOps.storeStencil).to.equal(false); + + renderPass.setStoreDepth(true); + renderPass.setStoreStencil(true); + expect(renderPass.depthStencilOps.storeDepth).to.equal(true); + expect(renderPass.depthStencilOps.storeStencil).to.equal(true); + }); + + }); + + describe('#setGenerateMipmaps', function () { + + it('disables and restores the mipmaps of a color buffer with mipmaps', function () { + const texture = new Texture(device, { name: 'mips', width: 4, height: 4, format: PIXELFORMAT_RGBA8, mipmaps: true }); + const rt = new RenderTarget({ colorBuffer: texture, depth: false }); + const pass = new RenderPass(device); + pass.init(rt); + expect(pass.colorOps.genMipmaps).to.equal(true); + + pass.setGenerateMipmaps(false); + expect(pass.colorOps.genMipmaps).to.equal(false); + + pass.setGenerateMipmaps(true); + expect(pass.colorOps.genMipmaps).to.equal(true); + + rt.destroyTextureBuffers(); + rt.destroy(); + }); + + it('does not generate the mipmaps of color buffers without mipmaps', function () { + renderPass.setGenerateMipmaps(true); + expect(renderPass.colorArrayOps[0].genMipmaps).to.equal(false); + expect(renderPass.colorArrayOps[1].genMipmaps).to.equal(false); + }); + + }); + describe('#allocateAttachments: explicit multisampled attachments', function () { const createMsTexture = (name, format = PIXELFORMAT_RGBA8) => { @@ -246,4 +310,58 @@ describe('RenderPass', function () { }); }); + describe('before #init', function () { + + afterEach(function () { + restore(); + }); + + const setters = { + setClearColor: pass => pass.setClearColor(new Color(1, 0, 0, 1)), + setClearDepth: pass => pass.setClearDepth(1), + setClearStencil: pass => pass.setClearStencil(0), + setStoreColor: pass => pass.setStoreColor(true), + setStoreDepth: pass => pass.setStoreDepth(true), + setStoreStencil: pass => pass.setStoreStencil(true), + setGenerateMipmaps: pass => pass.setGenerateMipmaps(false) + }; + + Object.entries(setters).forEach(([name, call]) => { + it(`asserts, and does not throw, when ${name} is called`, function () { + const pass = new RenderPass(device); + const assertion = stub(Debug, 'assert'); + + expect(() => call(pass)).to.not.throw(); + expect(assertion.getCalls().some(c => !c.args[0])).to.equal(true); + }); + }); + + it('asserts when the pass renders, and executes it without a GPU render pass', function () { + const pass = new RenderPass(device); + const start = stub(device, 'startRenderPass'); + const execute = stub(pass, 'execute'); + const assertion = stub(Debug, 'assert'); + + pass.render(); + + expect(assertion.getCalls().some(c => !c.args[0])).to.equal(true); + expect(execute.calledOnce).to.equal(true); + expect(start.called).to.equal(false); + }); + + it('does not assert once the pass is initialized for the back buffer', function () { + const pass = new RenderPass(device); + pass.init(null); + stub(device, 'startRenderPass'); + stub(device, 'endRenderPass'); + const assertion = stub(Debug, 'assert'); + + pass.setClearColor(new Color(1, 0, 0, 1)); + pass.setClearDepth(1); + pass.render(); + + expect(assertion.getCalls().some(c => !c.args[0])).to.equal(false); + }); + }); + }); From a9f5510a5679cce019e5489ef0cfe9c1e0072e9d Mon Sep 17 00:00:00 2001 From: Martin Valigursky <59932779+mvaligursky@users.noreply.github.com> Date: Mon, 26 Oct 2026 14:56:18 +0000 Subject: [PATCH 09/22] fix: Destroy the WebGPU indirect buffers when the device is destroyed (#9823) WebgpuGraphicsDevice.destroy() never released the lazily allocated indirect draw and dispatch buffers. When the engine owns the GPUDevice, destroying it freed them anyway, but a GPUDevice supplied by the host through initFromGpuDevice() kept both buffers alive after the engine was torn down. They are now destroyed after the destroy event, so its listeners can still use them. Fixed #9806 Co-authored-by: Martin Valigursky --- .../graphics/webgpu/webgpu-graphics-device.js | 7 +++ .../graphics/webgpu/webgpu-destroy.test.mjs | 55 +++++++++++++++++++ 2 files changed, 62 insertions(+) diff --git a/src/platform/graphics/webgpu/webgpu-graphics-device.js b/src/platform/graphics/webgpu/webgpu-graphics-device.js index 0498e465e43..b0d1ff89bdf 100644 --- a/src/platform/graphics/webgpu/webgpu-graphics-device.js +++ b/src/platform/graphics/webgpu/webgpu-graphics-device.js @@ -449,6 +449,13 @@ class WebgpuGraphicsDevice extends GraphicsDevice { super.destroy(); + // Released after the destroy event, so a listener reaching the lazy getters cannot + // allocate them again. A device supplied by the host would otherwise keep them alive. + this._indirectDrawBuffer?.destroy(); + this._indirectDrawBuffer = null; + this._indirectDispatchBuffer?.destroy(); + this._indirectDispatchBuffer = null; + // Destroy listeners can enqueue more resources, and no further submit will drain them. this.destroyDeferredResources(); this.clearDeviceState(); diff --git a/test/platform/graphics/webgpu/webgpu-destroy.test.mjs b/test/platform/graphics/webgpu/webgpu-destroy.test.mjs index 4ab04127e00..b237a29f7dc 100644 --- a/test/platform/graphics/webgpu/webgpu-destroy.test.mjs +++ b/test/platform/graphics/webgpu/webgpu-destroy.test.mjs @@ -48,6 +48,61 @@ describe('WebGPU device destruction', function () { expect(createDevice.called).to.be.false; }); + describe('with indirect buffers allocated', function () { + // buffer allocation reads the WebGPU usage flags, which Node does not define + let shimUsage; + + before(function () { + shimUsage = globalThis.GPUBufferUsage === undefined; + if (shimUsage) { + globalThis.GPUBufferUsage = { COPY_DST: 8, INDEX: 16, VERTEX: 32, UNIFORM: 64, STORAGE: 128 }; + } + }); + + after(function () { + if (shimUsage) { + delete globalThis.GPUBufferUsage; + } + }); + + it('destroys them after the destroy event when the host owns the native device', function () { + const device = new WebgpuGraphicsDevice({ width: 1, height: 1 }); + const natives = []; + const wgpu = { + createBuffer: (desc) => { + const native = { size: desc.size, destroy: sinon.spy() }; + natives.push(native); + return native; + }, + pushErrorScope() {}, + popErrorScope: () => Promise.resolve(null), + destroy: sinon.spy() + }; + device.wgpu = wgpu; + device._ownsGpuDevice = false; + device.allocateIndirectDrawBuffer(); + device.allocateIndirectDispatchBuffer(); + expect(natives).to.have.lengthOf(2); + + // destroy listeners can still use the buffers + device.on('destroy', () => { + for (const native of natives) { + expect(native.destroy.called).to.be.false; + } + }); + + device.destroy(); + + for (const native of natives) { + expect(native.destroy.calledOnce).to.be.true; + } + expect(device._indirectDrawBuffer).to.be.null; + expect(device._indirectDispatchBuffer).to.be.null; + expect(device.buffers.size).to.equal(0); + expect(wgpu.destroy.called).to.be.false; + }); + }); + it('can destroy a device before native initialization', function () { const device = new WebgpuGraphicsDevice({ width: 1, height: 1 }); expect(() => device.destroy()).not.to.throw(); From d45f19db714b7e46d5cddaaacc7fc03cdddc17c0 Mon Sep 17 00:00:00 2001 From: Martin Valigursky <59932779+mvaligursky@users.noreply.github.com> Date: Mon, 26 Oct 2026 15:16:25 +0000 Subject: [PATCH 10/22] refactor: Draw drawQuadWithShader with a RenderPassShaderQuad (#9825) drawQuadWithShader now renders through a RenderPassShaderQuad, and the internal RenderPassQuad, its only user, is removed, leaving a single quad pass in the engine. The behaviour is unchanged: the quad is drawn with the blend state set on the device, without depth test, culling or stencil, into the whole render target or the given viewport and scissor, and the WebGPU multisampled back buffer workaround stores the color through setStoreColor. The pass name is now set in all builds. RenderPassShaderQuad wraps its draw in a debug GPU marker with the pass and shader names, as RenderPassQuad did. Co-authored-by: Martin Valigursky --- src/scene/graphics/quad-render-utils.js | 51 +++++++++---------- src/scene/graphics/render-pass-quad.js | 27 ---------- src/scene/graphics/render-pass-shader-quad.js | 11 +++- 3 files changed, 32 insertions(+), 57 deletions(-) delete mode 100644 src/scene/graphics/render-pass-quad.js diff --git a/src/scene/graphics/quad-render-utils.js b/src/scene/graphics/quad-render-utils.js index 4c15d475f4a..e042284dc81 100644 --- a/src/scene/graphics/quad-render-utils.js +++ b/src/scene/graphics/quad-render-utils.js @@ -1,7 +1,6 @@ -import { Debug, DebugHelper } from '../../core/debug.js'; +import { Debug } from '../../core/debug.js'; import { Vec4 } from '../../core/math/vec4.js'; -import { QuadRender } from './quad-render.js'; -import { RenderPassQuad } from './render-pass-quad.js'; +import { RenderPassShaderQuad } from './render-pass-shader-quad.js'; /** * @import { GraphicsDevice } from '../../platform/graphics/graphics-device.js' @@ -9,12 +8,12 @@ import { RenderPassQuad } from './render-pass-quad.js'; * @import { Shader } from '../../platform/graphics/shader.js' */ -const _tempRect = new Vec4(); +const _viewport = new Vec4(); /** * Draws a quad with a shader into a render target, covering the whole render target, or the - * viewport rectangle. It draws immediately, outside of the frame graph: each call creates a render - * pass and the quad, renders the pass, and destroys the quad, so it allocates on every call. This + * viewport rectangle. It draws immediately, outside of the frame graph: each call creates a + * {@link RenderPassShaderQuad}, renders it, and destroys it, so it allocates on every call. This * suits a single draw, for example to generate a texture once. To draw a quad every frame, as part * of a frame, or with its own render states, use a {@link RenderPassShaderQuad} instead, which is * created once and reused. @@ -32,10 +31,10 @@ const _tempRect = new Vec4(); * chunk implements, see {@link RenderPassShaderQuad}. * @param {Vec4} [rect] - The viewport rectangle of the quad, in pixels. Defaults to fullscreen: * `[0, 0, target.width, target.height]`. - * @param {Vec4} [scissorRect] - The scissor rectangle of the quad, in pixels. Defaults to fullscreen: - * `[0, 0, target.width, target.height]`. - * @param {string} [name] - The render pass name used for GPU profiling and debugging in debug - * builds. Defaults to 'RenderPassQuad'. + * @param {Vec4} [scissorRect] - The scissor rectangle of the quad, in pixels. Defaults to the + * viewport rectangle. + * @param {string} [name] - The render pass name used for GPU profiling and debugging. Defaults to + * 'RenderPassQuad'. * @example * // generate a texture once, by drawing a shader into its render target * device.scope.resolve('uScale').setValue(4); @@ -47,24 +46,21 @@ function drawQuadWithShader(device, target, shader, rect, scissorRect, name) { // a valid target or a null target (framebuffer) are supported Debug.assert(target !== undefined); - // prepare the quad for rendering with the shader - const quad = new QuadRender(shader); + const renderPass = new RenderPassShaderQuad(device); + renderPass.name = name ?? 'RenderPassQuad'; + renderPass.shader = shader; + renderPass.init(target); + + // the quad is drawn with the blend state currently set on the device + renderPass.blendState = device.blendState; - // by default render to the whole render target + // by default render to the whole render target. The viewport is always set, as the pass uses the + // scissor rectangle only together with it if (!rect) { - rect = _tempRect; - rect.x = 0; - rect.y = 0; - rect.z = target ? target.width : device.width; - rect.w = target ? target.height : device.height; + rect = _viewport.set(0, 0, target ? target.width : device.width, target ? target.height : device.height); } - - // prepare a render pass to render the quad to the render target - const renderPass = new RenderPassQuad(device, quad, rect, scissorRect); - DebugHelper.setName(renderPass, name ?? 'RenderPassQuad'); - renderPass.init(target); - renderPass.colorOps.clear = false; - renderPass.depthStencilOps.clearDepth = false; + renderPass.viewport = rect; + renderPass.scissor = scissorRect; // TODO: This is a workaround for the case where post-effects are used together with multi-sampled framebuffer. Last post-effect // renders into multi-sampled framebuffer (render pass A), which is typically followed by further rendering to this framebuffer, @@ -72,12 +68,11 @@ function drawQuadWithShader(device, target, shader, rect, scissorRect, name) { // the same framebuffer. The workaround here is to store multi-sampled color buffer, instead of only resolving it, which is wasted // memory bandwidth. Without this we end up with a black result (or just UI), as multi-sampled color buffer is never written to. if (device.isWebGPU && target === null && device.samples > 1) { - renderPass.colorOps.store = true; + renderPass.setStoreColor(true); } renderPass.render(); - - quad.destroy(); + renderPass.destroy(); } export { drawQuadWithShader }; diff --git a/src/scene/graphics/render-pass-quad.js b/src/scene/graphics/render-pass-quad.js deleted file mode 100644 index d9968421ba6..00000000000 --- a/src/scene/graphics/render-pass-quad.js +++ /dev/null @@ -1,27 +0,0 @@ -import { DebugGraphics } from '../../platform/graphics/debug-graphics.js'; -import { RenderPass } from '../../platform/graphics/render-pass.js'; - -/** - * A render pass implementing rendering of a QuadRender. - */ -class RenderPassQuad extends RenderPass { - constructor(device, quad, rect, scissorRect) { - super(device); - - this.quad = quad; - this.rect = rect; - this.scissorRect = scissorRect; - } - - execute() { - const { device } = this; - DebugGraphics.pushGpuMarker(device, `${this.name}:${this.quad.shader.name}`); - - device.setDrawStates(device.blendState); - - this.quad.render(this.rect, this.scissorRect); - DebugGraphics.popGpuMarker(device); - } -} - -export { RenderPassQuad }; diff --git a/src/scene/graphics/render-pass-shader-quad.js b/src/scene/graphics/render-pass-shader-quad.js index 094bc68f4ee..ff0250deb9a 100644 --- a/src/scene/graphics/render-pass-shader-quad.js +++ b/src/scene/graphics/render-pass-shader-quad.js @@ -1,6 +1,7 @@ import { QuadRender } from './quad-render.js'; import { BlendState } from '../../platform/graphics/blend-state.js'; import { CULLFACE_NONE, FRONTFACE_CCW } from '../../platform/graphics/constants.js'; +import { DebugGraphics } from '../../platform/graphics/debug-graphics.js'; import { DepthState } from '../../platform/graphics/depth-state.js'; import { RenderPass } from '../../platform/graphics/render-pass.js'; @@ -232,11 +233,17 @@ class RenderPassShaderQuad extends RenderPass { } execute() { + const device = this.device; // render state - this.device.setDrawStates(this.blendState, this.depthState, this.cullMode, this.frontFace, this.stencilFront, this.stencilBack); + device.setDrawStates(this.blendState, this.depthState, this.cullMode, this.frontFace, this.stencilFront, this.stencilBack); - this.quadRender?.render(this.viewport, this.scissor); + const quadRender = this.quadRender; + if (quadRender) { + DebugGraphics.pushGpuMarker(device, `${this.name}:${this._shader?.name}`); + quadRender.render(this.viewport, this.scissor); + DebugGraphics.popGpuMarker(device); + } } } From 92115e726813a144d8ac60bacec17b3445fc79fe Mon Sep 17 00:00:00 2001 From: Oba Yang <195147269+zzoraemon043@users.noreply.github.com> Date: Mon, 26 Oct 2026 23:22:33 +0800 Subject: [PATCH 11/22] Fix math.smoothstep and smootherstep when min is greater than max (#9798) Fixes #9767 --- src/core/math/math.js | 20 +++++++++---------- test/core/math/math.test.mjs | 38 ++++++++++++++++++++++++++++++++++++ 2 files changed, 48 insertions(+), 10 deletions(-) diff --git a/src/core/math/math.js b/src/core/math/math.js index f4e364c197e..c2eb1195629 100644 --- a/src/core/math/math.js +++ b/src/core/math/math.js @@ -253,18 +253,18 @@ const math = { * * See https://en.wikipedia.org/wiki/Smoothstep for more details. * - * @param {number} min - The lower bound of the interpolation range. - * @param {number} max - The upper bound of the interpolation range. + * @param {number} min - The value of x at which the result is 0. Can be greater than max for a + * falling ramp. + * @param {number} max - The value of x at which the result is 1. * @param {number} x - The value to interpolate. * @returns {number} The smoothly interpolated value clamped between zero and one. * @example * math.smoothstep(0, 10, 5); // returns 0.5 */ smoothstep(min, max, x) { - if (x <= min) return 0; - if (x >= max) return 1; + if (min === max) return x <= min ? 0 : 1; - x = (x - min) / (max - min); + x = math.clamp((x - min) / (max - min), 0, 1); return x * x * (3 - 2 * x); }, @@ -275,18 +275,18 @@ const math = { * * See https://en.wikipedia.org/wiki/Smoothstep#Variations for more details. * - * @param {number} min - The lower bound of the interpolation range. - * @param {number} max - The upper bound of the interpolation range. + * @param {number} min - The value of x at which the result is 0. Can be greater than max for a + * falling ramp. + * @param {number} max - The value of x at which the result is 1. * @param {number} x - The value to interpolate. * @returns {number} The smoothly interpolated value clamped between zero and one. * @example * math.smootherstep(0, 10, 5); // returns 0.5 */ smootherstep(min, max, x) { - if (x <= min) return 0; - if (x >= max) return 1; + if (min === max) return x <= min ? 0 : 1; - x = (x - min) / (max - min); + x = math.clamp((x - min) / (max - min), 0, 1); return x * x * x * (x * (x * 6 - 15) + 10); }, diff --git a/test/core/math/math.test.mjs b/test/core/math/math.test.mjs index a2d91a28a34..459501ce4f9 100644 --- a/test/core/math/math.test.mjs +++ b/test/core/math/math.test.mjs @@ -324,6 +324,25 @@ describe('math', function () { expect(math.smootherstep(0, 10, 10)).to.equal(1); }); + it('clamps x outside the range', function () { + expect(math.smootherstep(0, 10, -5)).to.equal(0); + expect(math.smootherstep(0, 10, 15)).to.equal(1); + }); + + it('returns a falling ramp when min is greater than max', function () { + expect(math.smootherstep(10, 0, 15)).to.equal(0); + expect(math.smootherstep(10, 0, 5)).to.equal(0.5); + expect(math.smootherstep(10, 0, -5)).to.equal(1); + expect(math.smootherstep(-0.3, -0.6, -0.2)).to.equal(0); + expect(math.smootherstep(-0.3, -0.6, -0.7)).to.equal(1); + }); + + it('returns a step when min equals max', function () { + expect(math.smootherstep(5, 5, 4)).to.equal(0); + expect(math.smootherstep(5, 5, 5)).to.equal(0); + expect(math.smootherstep(5, 5, 6)).to.equal(1); + }); + }); describe('#smoothstep', function () { @@ -340,6 +359,25 @@ describe('math', function () { expect(math.smoothstep(0, 10, 10)).to.equal(1); }); + it('clamps x outside the range', function () { + expect(math.smoothstep(0, 10, -5)).to.equal(0); + expect(math.smoothstep(0, 10, 15)).to.equal(1); + }); + + it('returns a falling ramp when min is greater than max', function () { + expect(math.smoothstep(10, 0, 15)).to.equal(0); + expect(math.smoothstep(10, 0, 5)).to.equal(0.5); + expect(math.smoothstep(10, 0, -5)).to.equal(1); + expect(math.smoothstep(-0.3, -0.6, -0.2)).to.equal(0); + expect(math.smoothstep(-0.3, -0.6, -0.7)).to.equal(1); + }); + + it('returns a step when min equals max', function () { + expect(math.smoothstep(5, 5, 4)).to.equal(0); + expect(math.smoothstep(5, 5, 5)).to.equal(0); + expect(math.smoothstep(5, 5, 6)).to.equal(1); + }); + }); }); From 94b9d159f650a2d9b80482749b71703f9efbb5d0 Mon Sep 17 00:00:00 2001 From: Martin Valigursky <59932779+mvaligursky@users.noreply.github.com> Date: Mon, 26 Oct 2026 15:51:16 +0000 Subject: [PATCH 12/22] fix: Support the HTML-in-Canvas API names shipping in Chrome 157 (#9824) * fix: Support the HTML-in-Canvas API names shipping in Chrome 157 Chrome 157 enables HTML-in-Canvas by default and removes texElementImage2D, copyElementImageToTexture and the layoutsubtree attribute. Use texElementSubImage2D and drawElementImageToTexture, keeping the old names as a fallback for Chrome 150-154, and update the HTML texture examples. Fixed #9820 * fix: Reallocate the WebGL storage when a resized HTML source is set again Setting the same element as the source after it resized left the storage at the old size, as setSource had already updated the texture size the upload compared against. Track the allocated size instead. * test: Run the WebGL HTML element upload tests on a null device under test:webgpu --------- Co-authored-by: Martin Valigursky --- .../html-texture-configurator.example.mjs | 104 +++++++------- .../examples/misc/html-texture.example.mjs | 24 ++-- src/platform/graphics/graphics-device.js | 7 +- .../graphics/webgl/webgl-graphics-device.js | 7 +- src/platform/graphics/webgl/webgl-texture.js | 51 ++++++- .../graphics/webgpu/webgpu-graphics-device.js | 7 +- .../graphics/webgpu/webgpu-texture.js | 11 +- test/platform/graphics/webgl-bare.test.mjs | 2 +- test/platform/graphics/webgl-texture.test.mjs | 128 ++++++++++++++++++ 9 files changed, 263 insertions(+), 78 deletions(-) diff --git a/examples/src/examples/misc/html-texture-configurator.example.mjs b/examples/src/examples/misc/html-texture-configurator.example.mjs index 8ce6b74c206..0a38eb784c2 100644 --- a/examples/src/examples/misc/html-texture-configurator.example.mjs +++ b/examples/src/examples/misc/html-texture-configurator.example.mjs @@ -2,7 +2,7 @@ // // 3D product configurator with an HTML UI panel rendered as a GPU texture via // the {accent:HTML-in-Canvas} API, supported on both {accent:WebGL} and -// {accent:WebGPU}. Uses {accent:getElementTransform} for interactive hit +// {accent:WebGPU}. Uses {accent:updateElementGeometry} for interactive hit // testing — clicks and hovers on the 3D panel are handled by the browser's // native DOM event system. {accent:Click} to switch shoe material variants. @@ -10,8 +10,8 @@ // This example demonstrates a 3D product configurator where an interactive HTML // panel (styled with CSS glassmorphism) is rendered as a GPU texture on a 3D // plane next to a shoe model with glTF KHR_materials_variants. The HtmlSync -// class keeps the DOM element's CSS transform in sync with the 3D projection so -// the browser can hit-test clicks and hovers on the HTML buttons. +// class keeps the DOM element's geometry in sync with the 3D projection so the +// browser can hit-test clicks and hovers on the HTML buttons. // // Fallback: when device.supportsHtmlTextures is false, the HTML panel is shown // as a fixed DOM overlay on top of the canvas instead of a 3D textured plane. @@ -59,8 +59,7 @@ import { deviceType } from 'examples/context'; // --------------------------------------------------------------------------- // HtmlSync — self-contained helper class for HTML-in-Canvas hit testing. // Copy this class into any PlayCanvas project that uses the HTML-in-Canvas -// proposal (layoutsubtree + getElementTransform) to render interactive HTML -// elements on 3D plane entities. +// API to render interactive HTML elements on 3D plane entities. // // Usage: // const sync = new HtmlSync(canvas, htmlElement, planeEntity, width, height); @@ -69,8 +68,8 @@ import { deviceType } from 'examples/context'; // --------------------------------------------------------------------------- class HtmlSync { /** - * @param {HTMLCanvasElement} canvas - The canvas with layoutsubtree="true". - * @param {HTMLElement} element - The HTML element appended to the canvas. + * @param {HTMLCanvasElement} canvas - The canvas with content="drawable". + * @param {HTMLElement} element - The drawable HTML element appended to the canvas. * @param {Entity} planeEntity - The plane entity displaying the texture. * @param {number} width - HTML element width in CSS pixels. * @param {number} height - HTML element height in CSS pixels. @@ -90,10 +89,14 @@ class HtmlSync { this._t1 = new Mat4(); this._t2 = new Mat4(); this._drawTransform = new Mat4(); + + // Chrome 156 and earlier, which still have the layoutSubtree property, + // hit-test the element at its CSS transform + this._cssHitTest = 'layoutSubtree' in canvas; } /** - * Recompute the draw_transform and sync the element's CSS transform so the + * Recompute the draw_transform and sync the element's geometry so the * browser can hit-test it at the correct screen position. * * @param {CameraComponent} cameraComponent - The active camera component. @@ -116,34 +119,11 @@ class HtmlSync { this._t2.mul2(this._t1, this._drawTransform); const d = this._t2.data; - const domDrawTransform = new DOMMatrix([ - d[0], - d[1], - d[2], - d[3], - d[4], - d[5], - d[6], - d[7], - d[8], - d[9], - d[10], - d[11], - d[12], - d[13], - d[14], - d[15] - ]); - - // Register the element for hit testing via the browser API. - /** @type {any} */ (canvas).getElementTransform(this.element, domDrawTransform); - // Apply the CSS transform ourselves. The browser's internal S^-1·T·S - // formula doesn't handle perspective projections correctly — it scales - // the w row via S, distorting the perspective divide for non-origin - // points. The fix: scale only the output x,y rows by 1/DPR. + // Map the element to the canvas in CSS pixels. Scaling only the output + // x,y rows by 1/DPR keeps the perspective divide intact. const dpr = w / canvas.clientWidth; - this.element.style.transform = new DOMMatrix([ + const cssTransform = new DOMMatrix([ d[0] / dpr, d[1] / dpr, d[2], @@ -160,14 +140,34 @@ class HtmlSync { d[13] / dpr, d[14], d[15] - ]).toString(); + ]); + + // Register the element for hit testing. Chrome 157 and later hit-test + // it at this canvas transform. + if (/** @type {any} */ (canvas).updateElementGeometry) { + /** @type {any} */ (canvas).updateElementGeometry(this.element, { canvasTransform: cssTransform }); + } else { + // Chrome 154 and earlier + /** @type {any} */ (canvas).getElementTransform(this.element, new DOMMatrix(Array.from(d))); + } + + // Older versions hit-test the element at its CSS transform instead. + // Apply it ourselves, as the browser's internal S^-1·T·S + // formula doesn't handle perspective projections correctly — it scales + // the w row via S, distorting the perspective divide for non-origin + // points. + if (this._cssHitTest) { + this.element.style.transform = cssTransform.toString(); + } } } -// Enable HTML-in-Canvas: the "layoutsubtree" attribute tells the browser to -// composite any child HTML elements into the canvas, making them available as -// texture sources and enabling hit testing through getElementTransform. +// Enable HTML-in-Canvas: the content="drawable" attribute tells the browser to +// lay out child HTML elements of the canvas, making them available as texture +// sources and enabling hit testing through updateElementGeometry. Chrome 154 and +// earlier use the layoutsubtree attribute instead. const canvas = /** @type {HTMLCanvasElement} */ (document.getElementById('application-canvas')); +canvas.setAttribute('content', 'drawable'); canvas.setAttribute('layoutsubtree', 'true'); window.focus(); @@ -233,12 +233,11 @@ const variantColors = { const supportsHtmlInCanvas = device.supportsHtmlTextures; const htmlPanel = document.createElement('div'); +htmlPanel.setAttribute('drawable', ''); htmlPanel.style.width = `${PANEL_WIDTH}px`; htmlPanel.style.height = `${PANEL_HEIGHT}px`; htmlPanel.style.padding = '20px'; htmlPanel.style.background = 'rgba(15, 15, 25, 0.375)'; -htmlPanel.style.backdropFilter = 'blur(12px)'; -htmlPanel.style.webkitBackdropFilter = 'blur(12px)'; htmlPanel.style.borderRadius = '20px'; htmlPanel.style.fontFamily = "'Segoe UI', Arial, sans-serif"; htmlPanel.style.color = 'white'; @@ -264,6 +263,11 @@ if (supportsHtmlInCanvas) { htmlPanel.style.top = '50%'; htmlPanel.style.transform = 'translateY(-50%)'; htmlPanel.style.zIndex = '100'; + + // Blur the scene behind the overlay. A drawable element has no backdrop, + // and Chrome 157 blurs its content instead. + htmlPanel.style.backdropFilter = 'blur(12px)'; + htmlPanel.style.webkitBackdropFilter = 'blur(12px)'; } const updatePanel = () => { @@ -321,11 +325,11 @@ updatePanel(); // --- HTML-to-GPU texture pipeline --- // When HTML-in-Canvas is available, the HTML panel is appended as a child of -// the canvas and captured into a GPU texture (texElementImage2D on WebGL, -// copyElementImageToTexture on WebGPU). The -// browser fires a "paint" event whenever the panel's visual content changes; -// we respond by re-uploading the texture. The first paint uses setSource() to -// bind the element, subsequent paints just call upload(). +// the canvas and captured into a GPU texture (texElementSubImage2D on WebGL, +// drawElementImageToTexture on WebGPU). The browser fires a "paint" event +// whenever the panel's visual content changes; we respond by re-uploading the +// texture. The first paint uses setSource() to bind the element, subsequent +// paints just call upload(). /** @type {Texture|null} */ let panelTexture = null; @@ -519,7 +523,7 @@ light.setEulerAngles(45, 30, 0); app.root.addChild(light); // Click handling — the HTML panel receives real DOM click events in both -// modes: via getElementTransform hit testing (HTML-in-Canvas) or via +// modes: via updateElementGeometry hit testing (HTML-in-Canvas) or via // standard DOM events (overlay fallback). When a variant button is clicked // we apply the glTF KHR_materials_variants extension and repaint. htmlPanel.addEventListener('click', (e) => { @@ -537,11 +541,11 @@ htmlPanel.addEventListener('click', (e) => { }); // Per-frame sync: HtmlSync projects the 3D panel position into screen -// space and sets the HTML element's CSS transform so the browser's hit +// space and updates the HTML element's geometry so the browser's hit // testing aligns with where the panel appears in the 3D scene. -const supportsGetElementTransform = typeof canvas.getElementTransform === 'function'; -const htmlSync = - panel && supportsGetElementTransform ? new HtmlSync(canvas, htmlPanel, panel, PANEL_WIDTH, PANEL_HEIGHT) : null; +const supportsHitTesting = + typeof canvas.updateElementGeometry === 'function' || typeof canvas.getElementTransform === 'function'; +const htmlSync = panel && supportsHitTesting ? new HtmlSync(canvas, htmlPanel, panel, PANEL_WIDTH, PANEL_HEIGHT) : null; app.on('update', (/** @type {number} */ dt) => { // Smooth camera sway — orbit at constant radius diff --git a/examples/src/examples/misc/html-texture.example.mjs b/examples/src/examples/misc/html-texture.example.mjs index 4902f061141..d94f19d7c24 100644 --- a/examples/src/examples/misc/html-texture.example.mjs +++ b/examples/src/examples/misc/html-texture.example.mjs @@ -7,9 +7,9 @@ // // This example demonstrates the HTML-in-Canvas API: a styled HTML element with -// CSS animations is appended to a canvas marked with the "layoutsubtree" -// Attribute, then captured into a GPU texture (texElementImage2D on WebGL, -// copyElementImageToTexture on WebGPU). +// CSS animations is appended to a canvas marked with content="drawable", then +// captured into a GPU texture (texElementSubImage2D on WebGL, +// drawElementImageToTexture on WebGPU). // // Fallback: when device.supportsHtmlTextures is false, a static 2D canvas with // hand-drawn placeholder graphics is used as the texture source instead. @@ -39,7 +39,9 @@ import { deviceType } from 'examples/context'; const canvas = /** @type {HTMLCanvasElement} */ (document.getElementById('application-canvas')); -// Enable layoutsubtree for HTML-in-Canvas support +// Enable HTML-in-Canvas support. Chrome 154 and earlier use the layoutsubtree +// attribute instead of content="drawable". +canvas.setAttribute('content', 'drawable'); canvas.setAttribute('layoutsubtree', 'true'); window.focus(); @@ -75,11 +77,13 @@ app.setCanvasResolution(RESOLUTION_AUTO); const resize = () => app.resizeCanvas(); window.addEventListener('resize', resize); -// Create an HTML element to use as texture source. -// Per the HTML-in-Canvas proposal, the element must be a direct child of the canvas. +// Create an HTML element to use as texture source. It must be a child of the +// canvas and have the 'drawable' attribute to be drawn into the texture. // The 'inert' attribute prevents hit testing on the element. const htmlElement = document.createElement('div'); +htmlElement.setAttribute('drawable', ''); htmlElement.setAttribute('inert', ''); +htmlElement.style.boxSizing = 'border-box'; htmlElement.style.width = '512px'; htmlElement.style.height = '512px'; htmlElement.style.padding = '10px'; @@ -98,7 +102,7 @@ htmlElement.style.justifyContent = 'center'; htmlElement.style.alignItems = 'center'; htmlElement.innerHTML = `

HTML in Canvas!

-

This texture is rendered from HTML using texElementImage2D

+

This texture is rendered live from HTML

`; @@ -133,7 +137,7 @@ const htmlTexture = new Texture(device, { name: 'htmlTexture' }); -// Fallback canvas texture for browsers without texElementImage2D support +// Fallback canvas texture for browsers without HTML-in-Canvas support const createFallbackTexture = () => { const fallbackCanvas = document.createElement('canvas'); fallbackCanvas.width = 512; @@ -160,7 +164,7 @@ const createFallbackTexture = () => { ctx.font = '20px Arial'; ctx.fillText('(Canvas Fallback)', 256, 220); - ctx.fillText('texElementImage2D not available', 256, 260); + ctx.fillText('HTML-in-Canvas not available', 256, 260); ctx.beginPath(); ctx.arc(256, 320, 25, 0, 2 * Math.PI); @@ -189,7 +193,7 @@ app.on('destroy', () => { }); if (device.supportsHtmlTextures) { - // The browser must paint the HTML element before texElementImage2D can use it. + // The browser must paint the HTML element before it can be uploaded. // Wait for the 'paint' event, then set the HTML element as the texture source. canvas.addEventListener( 'paint', diff --git a/src/platform/graphics/graphics-device.js b/src/platform/graphics/graphics-device.js index de0a7725579..9db0cdd3e71 100644 --- a/src/platform/graphics/graphics-device.js +++ b/src/platform/graphics/graphics-device.js @@ -610,8 +610,9 @@ class GraphicsDevice extends EventHandler { /** * True if HTML elements (e.g. `
`) can be used as texture sources via the HTML-in-Canvas - * API. When supported, an HTML element appended to a canvas with the `layoutsubtree` attribute - * can be passed to {@link Texture#setSource} and rendered as a live texture in the 3D scene. + * API. When supported, an HTML element with the `drawable` attribute, appended to a canvas with + * the `content="drawable"` attribute, can be passed to {@link Texture#setSource} and rendered as + * a live texture in the 3D scene. * * @type {boolean} * @readonly @@ -1675,7 +1676,7 @@ class GraphicsDevice extends EventHandler { /** * Reports whether a texture source is a generic HTML element (not image, canvas, or video). - * Used for the HTML-in-Canvas proposal (texElementImage2D). + * Used for the HTML-in-Canvas API. * * @param {*} texture - Texture source data. * @returns {boolean} True if the texture is an HTMLElement that is not an image, canvas, or diff --git a/src/platform/graphics/webgl/webgl-graphics-device.js b/src/platform/graphics/webgl/webgl-graphics-device.js index bb62a002ab5..5205e1fef10 100644 --- a/src/platform/graphics/webgl/webgl-graphics-device.js +++ b/src/platform/graphics/webgl/webgl-graphics-device.js @@ -998,9 +998,10 @@ class WebglGraphicsDevice extends GraphicsDevice { this.extCompressedTextureASTC = this.getExtension('WEBGL_compressed_texture_astc'); this.extTextureCompressionBPTC = this.getExtension('EXT_texture_compression_bptc'); - // HTML-in-Canvas support (texElementImage2D). Not an extension, so it needs hiding - // explicitly on a bare device - it is still experimental and behind an origin trial. - this.supportsHtmlTextures = !this.bare && typeof gl.texElementImage2D === 'function'; + // HTML-in-Canvas support (texElementSubImage2D, or texElementImage2D before Chrome 155). + // Not an extension, so it needs hiding explicitly on a bare device. + this.supportsHtmlTextures = !this.bare && (typeof gl.texElementSubImage2D === 'function' || + typeof gl.texElementImage2D === 'function'); } /** diff --git a/src/platform/graphics/webgl/webgl-texture.js b/src/platform/graphics/webgl/webgl-texture.js index eb487810c20..1bea03e5f1b 100644 --- a/src/platform/graphics/webgl/webgl-texture.js +++ b/src/platform/graphics/webgl/webgl-texture.js @@ -72,6 +72,11 @@ class WebglTexture { _glCreated; + // size of the storage allocated for an HTML element source, which texElementSubImage2D draws into + _elementWidth = 0; + + _elementHeight = 0; + dirtyParameterFlags = 0; constructor(texture) { @@ -700,7 +705,7 @@ class WebglTexture { // ----- 2D ----- if (device._isBrowserInterface(mipObject)) { - // Handle HTML elements via texElementImage2D if supported + // Handle HTML elements via the HTML-in-Canvas API if supported if (device._isHTMLElementInterface(mipObject) && device.supportsHtmlTextures) { device.setUnpackFlipY(texture._flipY); device.setUnpackPremultiplyAlpha(texture._premultiplyAlpha); @@ -709,11 +714,45 @@ class WebglTexture { const w = Math.floor(rect.width) || texture._width; const h = Math.floor(rect.height) || texture._height; - gl.texElementImage2D( - gl.TEXTURE_2D, - this._glInternalFormat, - mipObject - ); + if (gl.texElementSubImage2D) { + // the element is drawn into existing storage, so allocate it for a new + // source or size. Compare against the allocated size, as setSource has + // already set the texture size to the element's new size. + if (!this._glCreated || texture._levelsUpdated[0] || this._elementWidth !== w || this._elementHeight !== h) { + Debug.assert(this._glFormat === gl.RGBA && this._glPixelType === gl.UNSIGNED_BYTE, + `Texture '${texture.name}' has a format texElementSubImage2D cannot write. Use an 8-bit RGBA format for an HTML element source.`, texture); + + gl.texImage2D( + gl.TEXTURE_2D, + mipLevel, + this._glInternalFormat, + w, h, + 0, + this._glFormat, + this._glPixelType, + null + ); + this._elementWidth = w; + this._elementHeight = h; + } + + // draw the element at the texture size, not its default canvas grid + // size, which differs with the device pixel ratio + gl.texElementSubImage2D( + gl.TEXTURE_2D, + mipLevel, + 0, 0, + mipObject, + { width: w, height: h } + ); + } else { + // Chrome 154 and earlier + gl.texElementImage2D( + gl.TEXTURE_2D, + this._glInternalFormat, + mipObject + ); + } if (mipLevel === 0) { texture._width = w; diff --git a/src/platform/graphics/webgpu/webgpu-graphics-device.js b/src/platform/graphics/webgpu/webgpu-graphics-device.js index b0d1ff89bdf..08c45eb21c7 100644 --- a/src/platform/graphics/webgpu/webgpu-graphics-device.js +++ b/src/platform/graphics/webgpu/webgpu-graphics-device.js @@ -797,8 +797,11 @@ class WebgpuGraphicsDevice extends GraphicsDevice { } features [${this.initOptions.featureLevel === 'bare' ? 'bare' : 'full'}]: ${enabledFeatures.join(', ') || 'none'}, wgslFeatures(${wgslFeatureNames.join(', ') || 'none'})` ); - // HTML-in-Canvas support (copyElementImageToTexture) - this.supportsHtmlTextures = typeof this.wgpu.queue?.copyElementImageToTexture === 'function'; + // HTML-in-Canvas support (drawElementImageToTexture, or copyElementImageToTexture before + // Chrome 155) + const queue = this.wgpu.queue; + this.supportsHtmlTextures = typeof queue?.drawElementImageToTexture === 'function' || + typeof queue?.copyElementImageToTexture === 'function'; // transient (memoryless) attachment support (GPUTextureUsage.TRANSIENT_ATTACHMENT) this.supportsTransientAttachments = typeof GPUTextureUsage !== 'undefined' && 'TRANSIENT_ATTACHMENT' in GPUTextureUsage; diff --git a/src/platform/graphics/webgpu/webgpu-texture.js b/src/platform/graphics/webgpu/webgpu-texture.js index 9afb55f92d8..7f92d546c3c 100644 --- a/src/platform/graphics/webgpu/webgpu-texture.js +++ b/src/platform/graphics/webgpu/webgpu-texture.js @@ -562,7 +562,7 @@ class WebgpuTexture { device.wgpu.queue.copyExternalImageToTexture(src, dst, copySize); } - // upload a generic HTML element via the HTML-in-Canvas API (copyElementImageToTexture) + // upload a generic HTML element via the HTML-in-Canvas API uploadElementImage(device, element, mipLevel, index) { Debug.assert(mipLevel < this.desc.mipLevelCount, `Accessing mip level ${mipLevel} of texture with ${this.desc.mipLevelCount} mip levels`, this); @@ -586,8 +586,13 @@ class WebgpuTexture { // scale the element's rendered image to the mip level's dimensions const source = { source: element }; - const destination = { destination: dst, width, height }; - device.wgpu.queue.copyElementImageToTexture(source, destination); + const queue = device.wgpu.queue; + if (queue.drawElementImageToTexture) { + queue.drawElementImageToTexture(source, { ...dst, size: { width, height } }); + } else { + // Chrome 154 and earlier + queue.copyElementImageToTexture(source, { destination: dst, width, height }); + } } uploadTypedArrayData(device, data, mipLevel, index) { diff --git a/test/platform/graphics/webgl-bare.test.mjs b/test/platform/graphics/webgl-bare.test.mjs index 8350a1be227..7f34d315f7e 100644 --- a/test/platform/graphics/webgl-bare.test.mjs +++ b/test/platform/graphics/webgl-bare.test.mjs @@ -65,7 +65,7 @@ const createGl = (params) => { return { name }; }, // HTML-in-Canvas, which is not an extension - texElementImage2D: () => {} + texElementSubImage2D: () => {} }; for (const name in params) { diff --git a/test/platform/graphics/webgl-texture.test.mjs b/test/platform/graphics/webgl-texture.test.mjs index 106e9dd3fca..2b121be9730 100644 --- a/test/platform/graphics/webgl-texture.test.mjs +++ b/test/platform/graphics/webgl-texture.test.mjs @@ -1,8 +1,12 @@ import { expect } from 'chai'; import sinon from 'sinon'; +import { PIXELFORMAT_RGBA8 } from '../../../src/platform/graphics/constants.js'; +import { NullGraphicsDevice } from '../../../src/platform/graphics/null/null-graphics-device.js'; +import { Texture } from '../../../src/platform/graphics/texture.js'; import { WebglGraphicsDevice } from '../../../src/platform/graphics/webgl/webgl-graphics-device.js'; import { WebglTexture } from '../../../src/platform/graphics/webgl/webgl-texture.js'; +import { jsdomSetup, jsdomTeardown } from '../../jsdom.mjs'; describe('WebglTexture upload during context loss', function () { it('skips uploads after the device has been destroyed', function () { @@ -56,3 +60,127 @@ describe('WebglTexture upload during context loss', function () { expect(texture._needsMipmapsUpload).to.be.false; }); }); + +describe('WebglTexture upload of an HTML element', function () { + + /** @type {NullGraphicsDevice} */ + let device; + + let hadHTMLElement; + + beforeEach(function () { + jsdomSetup(); + // jsdom does not expose HTMLElement globally, provide a stand-in the engine detects + hadHTMLElement = typeof globalThis.HTMLElement !== 'undefined'; + if (!hadHTMLElement) { + globalThis.HTMLElement = class {}; + } + // a null device on every backend, so the texture's own upload does not reach a real GPU + // backend before the WebglTexture under test + device = new NullGraphicsDevice(document.createElement('canvas')); + device.supportsHtmlTextures = true; + }); + + afterEach(function () { + device.destroy(); + device = null; + if (!hadHTMLElement) { + delete globalThis.HTMLElement; + } + jsdomTeardown(); + }); + + // an HTML element whose layout size can be changed + const createElement = (width, height) => { + const element = Object.create(HTMLElement.prototype); + element.size = { width, height }; + element.getBoundingClientRect = () => element.size; + return element; + }; + + // a texture uploaded by a WebglTexture to a WebGL context recording its calls, which has the + // texElementSubImage2D API + const createTexture = () => { + const gl = { + TEXTURE_2D: 0x0DE1, + RGBA: 0x1908, + RGBA8: 0x8058, + UNSIGNED_BYTE: 0x1401, + texImage2D: sinon.spy(), + texElementSubImage2D: sinon.spy() + }; + const glDevice = Object.assign(Object.create(device), { + gl, + setUnpackFlipY() {}, + setUnpackPremultiplyAlpha() {} + }); + + const texture = new Texture(device, { format: PIXELFORMAT_RGBA8, mipmaps: false }); + const impl = Object.assign(new WebglTexture(texture), { + _glTarget: gl.TEXTURE_2D, + _glFormat: gl.RGBA, + _glInternalFormat: gl.RGBA8, + _glPixelType: gl.UNSIGNED_BYTE, + _glCreated: false + }); + + return { + texture, + upload: () => impl.upload(glDevice, texture), + allocations: () => gl.texImage2D.getCalls().map(call => [call.args[3], call.args[4]]), + draws: () => gl.texElementSubImage2D.getCalls().map(call => [call.args[5].width, call.args[5].height]) + }; + }; + + it('allocates the storage at the element size and draws the element at that size', function () { + const { texture, upload, allocations, draws } = createTexture(); + texture.setSource(createElement(32, 32)); + upload(); + + expect(allocations()).to.deep.equal([[32, 32]]); + expect(draws()).to.deep.equal([[32, 32]]); + texture.destroy(); + }); + + it('keeps the storage when the element repaints at the same size', function () { + const { texture, upload, allocations, draws } = createTexture(); + texture.setSource(createElement(32, 32)); + upload(); + texture.upload(); + upload(); + + expect(allocations()).to.deep.equal([[32, 32]]); + expect(draws()).to.deep.equal([[32, 32], [32, 32]]); + texture.destroy(); + }); + + it('allocates the storage again when the element resizes between paints', function () { + const { texture, upload, allocations, draws } = createTexture(); + const element = createElement(32, 32); + texture.setSource(element); + upload(); + element.size = { width: 64, height: 16 }; + texture.upload(); + upload(); + + expect(allocations()).to.deep.equal([[32, 32], [64, 16]]); + expect(draws()).to.deep.equal([[32, 32], [64, 16]]); + texture.destroy(); + }); + + it('allocates the storage again when the resized element is set as the source again', function () { + // setSource updates the texture size before the upload, and does not mark the level as + // updated for the same element + const { texture, upload, allocations, draws } = createTexture(); + const element = createElement(32, 32); + texture.setSource(element); + upload(); + element.size = { width: 64, height: 16 }; + texture.setSource(element); + upload(); + + expect(allocations()).to.deep.equal([[32, 32], [64, 16]]); + expect(draws()).to.deep.equal([[32, 32], [64, 16]]); + texture.destroy(); + }); +}); From ba1fadf320048a2bd3f227893969a106b0307e88 Mon Sep 17 00:00:00 2001 From: Martin Valigursky <59932779+mvaligursky@users.noreply.github.com> Date: Mon, 26 Oct 2026 15:52:34 +0000 Subject: [PATCH 13/22] perf: Reuse quad render passes in per-frame engine rendering (#9827) The engine code drawing a quad every frame used drawQuadWithShader, which creates and destroys a render pass and its quad on each call - on WebGPU a uniform buffer and a bind group per draw. Each of them now owns a RenderPassShaderQuad reused every frame: - the GPU particle updater - MorphInstance - the VSM shadow blur, with a pass for each blur shader, released by ShadowRenderer#destroy - the outline renderer - the dynamic sky lighting bake and blend RenderPass#init resets and reuses the attachment operations of a previous initialization instead of allocating new ones, so a pass switching render targets every frame does not allocate. This also applies to the shadow passes and the picker, which are initialized every frame. Setting the same shader on a RenderPassShaderQuad keeps its quad. Co-authored-by: Martin Valigursky --- .../effects/dynamic-sky/dynamic-sky-effect.js | 44 ++++++++++++++-- src/extras/renderers/outline-renderer.js | 21 ++++++-- src/platform/graphics/render-pass.js | 45 +++++++++++++--- src/scene/graphics/render-pass-shader-quad.js | 5 ++ src/scene/morph-instance.js | 24 +++++++-- src/scene/particle-system/gpu-updater.js | 25 ++++++--- src/scene/particle-system/particle-emitter.js | 3 ++ src/scene/renderer/renderer.js | 1 + src/scene/renderer/shadow-renderer.js | 52 +++++++++++++++++-- test/platform/graphics/render-pass.test.mjs | 47 +++++++++++++++++ .../graphics/render-pass-shader-quad.test.mjs | 16 ++++++ 11 files changed, 253 insertions(+), 30 deletions(-) diff --git a/src/extras/effects/dynamic-sky/dynamic-sky-effect.js b/src/extras/effects/dynamic-sky/dynamic-sky-effect.js index d1c6054d1f4..390a997c664 100644 --- a/src/extras/effects/dynamic-sky/dynamic-sky-effect.js +++ b/src/extras/effects/dynamic-sky/dynamic-sky-effect.js @@ -16,7 +16,7 @@ import { LAYERID_SKYBOX, PROJECTION_PERSPECTIVE, SKYTYPE_INFINITE } from '../../ import { GraphNode } from '../../../scene/graph-node.js'; import { EnvLighting } from '../../../scene/graphics/env-lighting.js'; import { getBlueNoiseTexture } from '../../../scene/graphics/noise-textures.js'; -import { drawQuadWithShader } from '../../../scene/graphics/quad-render-utils.js'; +import { RenderPassShaderQuad } from '../../../scene/graphics/render-pass-shader-quad.js'; import { ShaderMaterial } from '../../../scene/materials/shader-material.js'; import { MeshInstance } from '../../../scene/mesh-instance.js'; import { ChunkUtils } from '../../../scene/shader-lib/chunk-utils.js'; @@ -728,6 +728,23 @@ class DynamicSkyEffect extends CameraFrameEffect { */ _envAtlasTarget = null; + /** + * The pass rendering the sky to the faces of the lighting cubemap, created when first needed. + * + * @type {RenderPassShaderQuad|null} + * @private + */ + _bakePass = null; + + /** + * The pass blending the lighting into the environment atlas, every frame of a blend, created + * when first needed. + * + * @type {RenderPassShaderQuad|null} + * @private + */ + _blendPass = null; + /** * The progress of the blend of the lighting, 0-1, where 1 has it done, and its duration in * seconds. @@ -881,6 +898,11 @@ class DynamicSkyEffect extends CameraFrameEffect { // releases the passes super.destroy(); + this._bakePass?.destroy(); + this._bakePass = null; + this._blendPass?.destroy(); + this._blendPass = null; + this._meshInstance?.destroy(); this._meshInstance = null; this._material?.destroy(); @@ -1717,7 +1739,15 @@ class DynamicSkyEffect extends CameraFrameEffect { device.scope.resolve('dynSkyBlendPrevious').setValue(this._atlasPrevious); device.scope.resolve('dynSkyBlendLatest').setValue(this._atlasLatest); device.scope.resolve('dynSkyBlendWeight').setValue(this._blendWeight); - drawQuadWithShader(device, this._envAtlasTarget, this._getBlendShader()); + + let blendPass = this._blendPass; + if (!blendPass) { + blendPass = this._blendPass = new RenderPassShaderQuad(device); + blendPass.name = 'DynamicSkyLightingBlend'; + } + blendPass.shader = this._getBlendShader(); + blendPass.init(this._envAtlasTarget); + blendPass.render(); } /** @@ -1745,10 +1775,16 @@ class DynamicSkyEffect extends CameraFrameEffect { device.setBlendState(BlendState.NOBLEND); device.scope.resolve('uvMod').setValue(UV_MOD); const faceId = device.scope.resolve('dynSkyFace'); - const shader = this._getBakeShader(); + let bakePass = this._bakePass; + if (!bakePass) { + bakePass = this._bakePass = new RenderPassShaderQuad(device); + bakePass.name = 'DynamicSkyLightingBake'; + } + bakePass.shader = this._getBakeShader(); for (let face = 0; face < 6; face++) { faceId.setValue(face); - drawQuadWithShader(device, this._faceTargets[face], shader); + bakePass.init(this._faceTargets[face]); + bakePass.render(); } EnvLighting.generateAtlas(this._lightingSource, { diff --git a/src/extras/renderers/outline-renderer.js b/src/extras/renderers/outline-renderer.js index 846512e4234..87c2f5bd494 100644 --- a/src/extras/renderers/outline-renderer.js +++ b/src/extras/renderers/outline-renderer.js @@ -11,8 +11,8 @@ import { } from '../../platform/graphics/constants.js'; import { RenderTarget } from '../../platform/graphics/render-target.js'; import { Texture } from '../../platform/graphics/texture.js'; -import { drawQuadWithShader } from '../../scene/graphics/quad-render-utils.js'; import { QuadRender } from '../../scene/graphics/quad-render.js'; +import { RenderPassShaderQuad } from '../../scene/graphics/render-pass-shader-quad.js'; import { ShaderUtils } from '../../scene/shader-lib/shader-utils.js'; /** @@ -246,6 +246,11 @@ class OutlineRenderer { }); this.quadRenderer = new QuadRender(this.shaderBlend); + + // the pass extending the outlines, reused for both of its passes every frame + this.extendPass = new RenderPassShaderQuad(device); + this.extendPass.name = 'OutlineExpand'; + this.extendPass.shader = this.shaderExtend; } /** @@ -275,6 +280,9 @@ class OutlineRenderer { this.quadRenderer?.destroy(); this.quadRenderer = null; + + this.extendPass.destroy(); + this.extendPass = null; } /** @@ -424,16 +432,20 @@ class OutlineRenderer { const uOffset = device.scope.resolve('uOffset'); const uColorBuffer = device.scope.resolve('source'); const uSrcMultiplier = device.scope.resolve('uSrcMultiplier'); - const { rt, tempRt, shaderExtend } = this; + const { rt, tempRt, extendPass } = this; const { width, height } = rt; + // the passes render with the blend state set on the device, as the quads they replaced did + extendPass.blendState = device.blendState; + // horizontal extend pass _tempFloatArray[0] = 1.0 / width / 2.0; _tempFloatArray[1] = 0; uOffset.setValue(_tempFloatArray); uColorBuffer.setValue(rt.colorBuffer); uSrcMultiplier.setValue(0.0); - drawQuadWithShader(device, tempRt, shaderExtend, undefined, undefined, 'OutlineExpand'); + extendPass.init(tempRt); + extendPass.render(); // vertical extend pass _tempFloatArray[0] = 0; @@ -441,7 +453,8 @@ class OutlineRenderer { uOffset.setValue(_tempFloatArray); uColorBuffer.setValue(tempRt.colorBuffer); uSrcMultiplier.setValue(1.0); - drawQuadWithShader(device, rt, shaderExtend, undefined, undefined, 'OutlineExpand'); + extendPass.init(rt); + extendPass.render(); } createRenderTarget(name, width, height, depth) { diff --git a/src/platform/graphics/render-pass.js b/src/platform/graphics/render-pass.js index d5884a0c223..d2d1d2fb9ae 100644 --- a/src/platform/graphics/render-pass.js +++ b/src/platform/graphics/render-pass.js @@ -92,6 +92,18 @@ class ColorAttachmentOps { * True if the attachment needs to have mipmaps generated. */ genMipmaps = false; + + /** + * Resets the operations to their defaults, the values the properties are initialized with. + */ + reset() { + this.clearValue.set(0, 0, 0, 1); + this.clearValueLinear.set(0, 0, 0, 1); + this.clear = false; + this.store = false; + this.resolve = true; + this.genMipmaps = false; + } } /** @@ -140,6 +152,19 @@ class DepthStencilAttachmentOps { * if it can be discarded. */ storeStencil = false; + + /** + * Resets the operations to their defaults, the values the properties are initialized with. + */ + reset() { + this.clearDepthValue = 1; + this.clearStencilValue = 0; + this.clearDepth = false; + this.clearStencil = false; + this.storeDepth = false; + this.resolveDepth = false; + this.storeStencil = false; + } } /** @@ -411,7 +436,9 @@ class RenderPass extends FramePass { } /** - * Allocates the operations of the attachments of the render target, with their defaults. + * Allocates the operations of the attachments of the render target, with their defaults. The + * operations allocated by a previous initialization are reset and reused, as a pass can be + * initialized every frame, for example to render to a different render target. * * @ignore */ @@ -420,27 +447,31 @@ class RenderPass extends FramePass { const rt = this.renderTarget; // depth - this.depthStencilOps = new DepthStencilAttachmentOps(); + const depthStencilOps = this.depthStencilOps ?? new DepthStencilAttachmentOps(); + depthStencilOps.reset(); + this.depthStencilOps = depthStencilOps; // if an RT is used (so not a backbuffer) that was created with a user supplied depth buffer, // assume the user wants to use its content, and so store it by default if (rt?.depthBuffer) { - this.depthStencilOps.storeDepth = true; + depthStencilOps.storeDepth = true; // a depth resolve buffer is the output of the shader-based depth resolve, which runs // after the pass - resolve by default (the multisampled depth is stored either way, as // the resolve reads it after the pass has finished) if (rt.depthResolveBuffer) { - this.depthStencilOps.resolveDepth = true; + depthStencilOps.resolveDepth = true; } } // color const numColorOps = rt ? (rt._colorBuffers?.length ?? 0) : 1; - this.colorArrayOps.length = 0; + const colorArrayOps = this.colorArrayOps; + colorArrayOps.length = numColorOps; for (let i = 0; i < numColorOps; i++) { - const colorOps = new ColorAttachmentOps(); - this.colorArrayOps[i] = colorOps; + const colorOps = colorArrayOps[i] ?? new ColorAttachmentOps(); + colorOps.reset(); + colorArrayOps[i] = colorOps; const colorBuffer = rt?._colorBuffers?.[i]; diff --git a/src/scene/graphics/render-pass-shader-quad.js b/src/scene/graphics/render-pass-shader-quad.js index ff0250deb9a..5b0dca9580c 100644 --- a/src/scene/graphics/render-pass-shader-quad.js +++ b/src/scene/graphics/render-pass-shader-quad.js @@ -203,6 +203,11 @@ class RenderPassShaderQuad extends RenderPass { */ set shader(shader) { + // keep the quad of the shader, as a pass rendering repeatedly can set it each time + if (shader === this._shader) { + return; + } + // destroy old this.quadRender?.destroy(); this.quadRender = null; diff --git a/src/scene/morph-instance.js b/src/scene/morph-instance.js index 91d7cce6f1e..60778367f58 100644 --- a/src/scene/morph-instance.js +++ b/src/scene/morph-instance.js @@ -1,6 +1,6 @@ import { Debug } from '../core/debug.js'; import { SEMANTIC_POSITION } from '../platform/graphics/constants.js'; -import { drawQuadWithShader } from './graphics/quad-render-utils.js'; +import { RenderPassShaderQuad } from './graphics/render-pass-shader-quad.js'; import { RenderTarget } from '../platform/graphics/render-target.js'; import { DebugGraphics } from '../platform/graphics/debug-graphics.js'; import { ShaderUtils } from './shader-lib/shader-utils.js'; @@ -18,6 +18,14 @@ import { BlendState } from '../platform/graphics/blend-state.js'; * @category Graphics */ class MorphInstance { + /** + * The pass blending the morph targets into the render targets, created when first needed. + * + * @type {RenderPassShaderQuad|null} + * @private + */ + _morphPass = null; + /** * Create a new MorphInstance instance. * @@ -107,6 +115,9 @@ class MorphInstance { // don't destroy shader as it's in the cache and can be used by other materials this.shader = null; + this._morphPass?.destroy(); + this._morphPass = null; + const morph = this.morph; if (morph) { @@ -218,8 +229,15 @@ class MorphInstance { this.morphFactor.setValue(this._shaderMorphWeights); this.morphIndex.setValue(this._shaderMorphIndex); - // render quad with shader - drawQuadWithShader(device, renderTarget, this.shader, undefined, undefined, 'MorphBlend'); + // render quad with shader, using a pass reused for both render targets every frame + let morphPass = this._morphPass; + if (!morphPass) { + morphPass = this._morphPass = new RenderPassShaderQuad(device); + morphPass.name = 'MorphBlend'; + } + morphPass.shader = this.shader; + morphPass.init(renderTarget); + morphPass.render(); } _updateTextureMorph(activeCount) { diff --git a/src/scene/particle-system/gpu-updater.js b/src/scene/particle-system/gpu-updater.js index 3916c8a29c7..9f42bb56b35 100644 --- a/src/scene/particle-system/gpu-updater.js +++ b/src/scene/particle-system/gpu-updater.js @@ -5,7 +5,7 @@ import { Vec3 } from '../../core/math/vec3.js'; import { DebugGraphics } from '../../platform/graphics/debug-graphics.js'; -import { drawQuadWithShader } from '../graphics/quad-render-utils.js'; +import { RenderPassShaderQuad } from '../graphics/render-pass-shader-quad.js'; import { EMITTERSHAPE_BOX } from '../constants.js'; @@ -18,6 +18,10 @@ class ParticleGPUUpdater { constructor(emitter, gd) { this._emitter = emitter; + // the pass rendering the updated particle state, reused every frame + this.updatePass = new RenderPassShaderQuad(gd); + this.updatePass.name = 'ParticleUpdate'; + this.frameRandomUniform = new Float32Array(3); this.emitterPosUniform = new Float32Array(3); this.emitterScaleUniform = new Float32Array([1, 1, 1]); @@ -57,6 +61,11 @@ class ParticleGPUUpdater { this.constantRadialSpeedDivMult = gd.scope.resolve('radialSpeedDivMult'); } + destroy() { + this.updatePass.destroy(); + this.updatePass = null; + } + randomize() { this.frameRandomUniform[0] = Math.random(); this.frameRandomUniform[1] = Math.random(); @@ -130,13 +139,13 @@ class ParticleGPUUpdater { texIN = emitter.beenReset ? emitter.particleTexStart : texIN; const texOUT = emitter.swapTex ? emitter.particleTexIN : emitter.particleTexOUT; this.constantParticleTexIN.setValue(texIN); - drawQuadWithShader( - device, - emitter.swapTex ? emitter.rtParticleTexIN : emitter.rtParticleTexOUT, - !isOnStop ? - (emitter.loop ? emitter.shaderParticleUpdateRespawn : emitter.shaderParticleUpdateNoRespawn) : - emitter.shaderParticleUpdateOnStop, - undefined, undefined, 'ParticleUpdate'); + + const updatePass = this.updatePass; + updatePass.shader = !isOnStop ? + (emitter.loop ? emitter.shaderParticleUpdateRespawn : emitter.shaderParticleUpdateNoRespawn) : + emitter.shaderParticleUpdateOnStop; + updatePass.init(emitter.swapTex ? emitter.rtParticleTexIN : emitter.rtParticleTexOUT); + updatePass.render(); // this.constantParticleTexOUT.setValue(texOUT); diff --git a/src/scene/particle-system/particle-emitter.js b/src/scene/particle-system/particle-emitter.js index 65c962a14e4..3d1ebea330e 100644 --- a/src/scene/particle-system/particle-emitter.js +++ b/src/scene/particle-system/particle-emitter.js @@ -1162,6 +1162,9 @@ class ParticleEmitter { this.camera = null; this._destroyResources(); + + this._gpuUpdater.destroy(); + this._gpuUpdater = null; } } diff --git a/src/scene/renderer/renderer.js b/src/scene/renderer/renderer.js index ba40a294121..7becedf32db 100644 --- a/src/scene/renderer/renderer.js +++ b/src/scene/renderer/renderer.js @@ -439,6 +439,7 @@ class Renderer { } destroy() { + this.shadowRenderer.destroy(); this.shadowRenderer = null; this._shadowRendererLocal = null; this._shadowRendererDirectional = null; diff --git a/src/scene/renderer/shadow-renderer.js b/src/scene/renderer/shadow-renderer.js index fa7c8b61b24..1fe32d82943 100644 --- a/src/scene/renderer/shadow-renderer.js +++ b/src/scene/renderer/shadow-renderer.js @@ -11,7 +11,7 @@ import { UNIFORMTYPE_FLOAT, UNIFORMTYPE_MAT4, UNIFORMTYPE_VEC3, UNIFORMTYPE_VEC4 } from '../../platform/graphics/constants.js'; import { DebugGraphics } from '../../platform/graphics/debug-graphics.js'; -import { drawQuadWithShader } from '../graphics/quad-render-utils.js'; +import { RenderPassShaderQuad } from '../graphics/render-pass-shader-quad.js'; import { BLUR_GAUSSIAN, EVENT_POSTCULL, @@ -37,6 +37,8 @@ import { BlendState } from '../../platform/graphics/blend-state.js'; * @import { Light } from '../light.js' * @import { MeshInstance } from '../mesh-instance.js' * @import { Renderer } from './renderer.js' + * @import { RenderTarget } from '../../platform/graphics/render-target.js' + * @import { Shader } from '../../platform/graphics/shader.js' * @import { ShaderPassInfo } from '../shader-pass.js' */ @@ -109,6 +111,14 @@ class ShadowRenderer { */ _casterLists = []; + /** + * The passes blurring VSM shadow maps, one for each blur shader, reused every frame. + * + * @type {Map} + * @private + */ + _vsmBlurPasses = new Map(); + /** * @param {Renderer} renderer - The renderer. * @param {LightTextureAtlas} lightTextureAtlas - The shadow map atlas. @@ -801,6 +811,40 @@ class ShadowRenderer { return blurShader; } + /** + * Releases the resources of the shadow renderer. + */ + destroy() { + this._vsmBlurPasses.forEach(pass => pass.destroy()); + this._vsmBlurPasses.clear(); + } + + /** + * Renders the VSM blur of a shadow map into a render target, within the blur scissor rectangle. + * + * @param {Shader} blurShader - The blur shader. + * @param {RenderTarget} target - The render target to render into. + * @private + */ + _renderVsmBlur(blurShader, target) { + let pass = this._vsmBlurPasses.get(blurShader); + if (!pass) { + pass = new RenderPassShaderQuad(this.device); + pass.name = 'VSMShadowBlur'; + pass.shader = blurShader; + + // the viewport is set to the whole render target, as the pass uses the scissor rectangle + // only together with it + pass.viewport = new Vec4(); + pass.scissor = blurScissorRect; + this._vsmBlurPasses.set(blurShader, pass); + } + + pass.init(target); + pass.viewport.set(0, 0, target.width, target.height); + pass.render(); + } + applyVsmBlur(light, camera, cascadeMask) { const device = this.device; @@ -833,7 +877,7 @@ class ShadowRenderer { pixelOffset[1] = 0; this.pixelOffsetId.setValue(pixelOffset); if (blurMode === BLUR_GAUSSIAN) this.weightId.setValue(this.blurVsmWeights[filterSize]); - drawQuadWithShader(device, tempRt, blurShader, null, blurScissorRect, 'VSMShadowBlur'); + this._renderVsmBlur(blurShader, tempRt); // Blur vertical this.sourceId.setValue(tempRt.colorBuffer); @@ -852,10 +896,10 @@ class ShadowRenderer { const right = Math.min(resolution - 1, (viewport.x + viewport.z) * resolution); const top = Math.min(resolution - 1, (viewport.y + viewport.w) * resolution); blurScissorRect.set(x, y, right - x, top - y); - drawQuadWithShader(device, origShadowMap, blurShader, null, blurScissorRect, 'VSMShadowBlur'); + this._renderVsmBlur(blurShader, origShadowMap); } } else { - drawQuadWithShader(device, origShadowMap, blurShader, null, blurScissorRect, 'VSMShadowBlur'); + this._renderVsmBlur(blurShader, origShadowMap); } // return the temporary shadow map back to the cache diff --git a/test/platform/graphics/render-pass.test.mjs b/test/platform/graphics/render-pass.test.mjs index dfbfb5f7c71..a9387acb1ab 100644 --- a/test/platform/graphics/render-pass.test.mjs +++ b/test/platform/graphics/render-pass.test.mjs @@ -173,6 +173,53 @@ describe('RenderPass', function () { }); + describe('#init again', function () { + + it('reuses the attachment operations, reset to their defaults', function () { + const colorOps = renderPass.colorArrayOps.slice(); + const depthStencilOps = renderPass.depthStencilOps; + const defaults = new RenderPass(device); + defaults.init(renderTarget); + + renderPass.setClearColor(new Color(1, 0.5, 0.25, 1)); + renderPass.setClearDepth(0.5); + renderPass.setClearStencil(3); + renderPass.setStoreColor(false); + renderPass.setStoreDepth(true); + renderPass.setStoreStencil(true); + renderPass.colorArrayOps[1].resolve = false; + renderPass.colorArrayOps[1].genMipmaps = true; + renderPass.depthStencilOps.resolveDepth = true; + + renderPass.init(renderTarget); + + expect(renderPass.colorArrayOps).to.have.lengthOf(2); + expect(renderPass.colorArrayOps[0]).to.equal(colorOps[0]); + expect(renderPass.colorArrayOps[1]).to.equal(colorOps[1]); + expect(renderPass.depthStencilOps).to.equal(depthStencilOps); + expect(renderPass.colorArrayOps).to.deep.equal(defaults.colorArrayOps); + expect(renderPass.depthStencilOps).to.deep.equal(defaults.depthStencilOps); + }); + + it('matches the number of color attachments of the new render target', function () { + const first = renderPass.colorArrayOps[0]; + const single = new RenderTarget({ colorBuffer: createTexture('single'), depth: false }); + + renderPass.init(single); + expect(renderPass.colorArrayOps).to.have.lengthOf(1); + expect(renderPass.colorArrayOps[0]).to.equal(first); + + renderPass.init(renderTarget); + expect(renderPass.colorArrayOps).to.have.lengthOf(2); + expect(renderPass.colorArrayOps[0]).to.equal(first); + expect(renderPass.colorArrayOps[1].store).to.equal(true); + + single.destroyTextureBuffers(); + single.destroy(); + }); + + }); + describe('#allocateAttachments: explicit multisampled attachments', function () { const createMsTexture = (name, format = PIXELFORMAT_RGBA8) => { diff --git a/test/scene/graphics/render-pass-shader-quad.test.mjs b/test/scene/graphics/render-pass-shader-quad.test.mjs index 1bdc0594920..bacd41c65d6 100644 --- a/test/scene/graphics/render-pass-shader-quad.test.mjs +++ b/test/scene/graphics/render-pass-shader-quad.test.mjs @@ -51,6 +51,22 @@ describe('RenderPassShaderQuad', function () { expect(pass.quadRender).to.equal(null); }); + it('keeps the quad when the same shader is set again', function () { + const device = app.graphicsDevice; + const pass = new RenderPassShaderQuad(device); + const shader = createShader(device); + + pass.shader = shader; + const quadRender = pass.quadRender; + const quadDestroy = sinon.spy(quadRender, 'destroy'); + + pass.shader = shader; + expect(pass.quadRender).to.equal(quadRender); + expect(quadDestroy.called).to.equal(false); + + pass.destroy(); + }); + }); describe('#destroy', function () { From d7e50869a3978d2b943b1c080a6d9a144ca115d5 Mon Sep 17 00:00:00 2001 From: Martin Valigursky <59932779+mvaligursky@users.noreply.github.com> Date: Mon, 26 Oct 2026 16:03:02 +0000 Subject: [PATCH 14/22] refactor: Move the SMAA and volumetric fog render passes to their own files (#9828) RenderPassSmaa, RenderPassVolumetricFog, RenderPassVolumetricFogLocal and RenderPassVolumetricFogCombine lived in the frame-pass-*.js files of the frame passes using them. Each now lives in a render-pass-*.js file matching its class name, with the imports and the module scratch values it uses. The classes are unchanged. Also correct the FramePassColorGrab class doc, which called it a render pass. Co-authored-by: Martin Valigursky --- .../effects/smaa/passes/frame-pass-smaa.js | 78 +- .../effects/smaa/passes/render-pass-smaa.js | 82 +++ .../passes/frame-pass-volumetric-fog.js | 675 +----------------- .../render-pass-volumetric-fog-combine.js | 54 ++ .../render-pass-volumetric-fog-local.js | 415 +++++++++++ .../passes/render-pass-volumetric-fog.js | 227 ++++++ src/scene/graphics/frame-pass-color-grab.js | 3 +- 7 files changed, 790 insertions(+), 744 deletions(-) create mode 100644 src/extras/effects/smaa/passes/render-pass-smaa.js create mode 100644 src/extras/effects/volumetric-fog/passes/render-pass-volumetric-fog-combine.js create mode 100644 src/extras/effects/volumetric-fog/passes/render-pass-volumetric-fog-local.js create mode 100644 src/extras/effects/volumetric-fog/passes/render-pass-volumetric-fog.js diff --git a/src/extras/effects/smaa/passes/frame-pass-smaa.js b/src/extras/effects/smaa/passes/frame-pass-smaa.js index c612b07131a..7259f257c33 100644 --- a/src/extras/effects/smaa/passes/frame-pass-smaa.js +++ b/src/extras/effects/smaa/passes/frame-pass-smaa.js @@ -1,100 +1,28 @@ import { Color } from '../../../../core/math/color.js'; import { - ADDRESS_CLAMP_TO_EDGE, FILTER_LINEAR, PIXELFORMAT_RG8, PIXELFORMAT_RGBA8, SEMANTIC_POSITION, - SHADERLANGUAGE_GLSL, SHADERLANGUAGE_WGSL + ADDRESS_CLAMP_TO_EDGE, FILTER_LINEAR, PIXELFORMAT_RG8, PIXELFORMAT_RGBA8, SHADERLANGUAGE_GLSL, + SHADERLANGUAGE_WGSL } from '../../../../platform/graphics/constants.js'; import { FramePass } from '../../../../platform/graphics/frame-pass.js'; import { RenderTarget } from '../../../../platform/graphics/render-target.js'; import { Texture } from '../../../../platform/graphics/texture.js'; import { TONEMAP_LINEAR, TONEMAP_NONE } from '../../../../scene/constants.js'; -import { RenderPassShaderQuad } from '../../../../scene/graphics/render-pass-shader-quad.js'; import { ShaderChunks } from '../../../../scene/shader-lib/shader-chunks.js'; -import { ShaderUtils } from '../../../../scene/shader-lib/shader-utils.js'; import glslBlendPS from '../glsl/blend-ps.js'; import glslEdgePS from '../glsl/edge-ps.js'; import glslWeightsPS from '../glsl/weights-ps.js'; import wgslBlendPS from '../wgsl/blend-ps.js'; import wgslEdgePS from '../wgsl/edge-ps.js'; import wgslWeightsPS from '../wgsl/weights-ps.js'; +import { RenderPassSmaa } from './render-pass-smaa.js'; /** * @import { GraphicsDevice } from '../../../../platform/graphics/graphics-device.js' - * @import { ScopeId } from '../../../../platform/graphics/scope-id.js' */ // the edge and the weight targets are cleared, the pixels without an edge left as they are const clearColor = new Color(0, 0, 0, 0); -/** - * One of the passes of SMAA, rendering a full screen quad with the textures and the parameters it - * reads. - * - * @ignore - */ -class RenderPassSmaa extends RenderPassShaderQuad { - /** - * The frame pass the pass belongs to, holding the scene and the parameters. - * - * @type {FramePassSmaa} - */ - smaa; - - /** - * The textures the pass reads, and the uniforms they are bound to. - * - * @type {{ scopeId: ScopeId, texture: Texture|null }[]} - * @private - */ - _textures = []; - - /** - * @param {GraphicsDevice} device - The graphics device. - * @param {FramePassSmaa} smaa - The frame pass the pass belongs to. - * @param {string} name - The name of the pass, which its shader is named after. - * @param {string} chunk - The name of the fragment shader chunk. - */ - constructor(device, smaa, name, chunk) { - super(device); - this.name = `RenderPassSmaa${name}`; - this.smaa = smaa; - this.shader = ShaderUtils.createShader(device, { - uniqueName: `SmaaShader${name}`, - attributes: { aPosition: SEMANTIC_POSITION }, - vertexChunk: 'quadVS', - fragmentChunk: chunk - }); - } - - /** - * Sets the texture bound to a uniform when the pass renders. - * - * @param {string} name - The name of the uniform. - * @param {Texture|null} texture - The texture. - */ - setTexture(name, texture) { - let entry = this._textures.find(item => item.scopeId.name === name); - if (!entry) { - entry = { scopeId: this.device.scope.resolve(name), texture: null }; - this._textures.push(entry); - } - entry.texture = texture; - } - - execute() { - const textures = this._textures; - for (let i = 0; i < textures.length; i++) { - textures[i].scopeId.setValue(textures[i].texture); - } - this.smaa.bindParameters(); - super.execute(); - } - - destroy() { - this._textures.length = 0; - super.destroy(); - } -} - /** * Subpixel Morphological Anti-Aliasing (SMAA 1x), in three passes over the scene: the edges are * detected, the area each pixel on an edge would be covered by the edge before it was aliased is diff --git a/src/extras/effects/smaa/passes/render-pass-smaa.js b/src/extras/effects/smaa/passes/render-pass-smaa.js new file mode 100644 index 00000000000..d64504be62b --- /dev/null +++ b/src/extras/effects/smaa/passes/render-pass-smaa.js @@ -0,0 +1,82 @@ +import { SEMANTIC_POSITION } from '../../../../platform/graphics/constants.js'; +import { RenderPassShaderQuad } from '../../../../scene/graphics/render-pass-shader-quad.js'; +import { ShaderUtils } from '../../../../scene/shader-lib/shader-utils.js'; + +/** + * @import { GraphicsDevice } from '../../../../platform/graphics/graphics-device.js' + * @import { ScopeId } from '../../../../platform/graphics/scope-id.js' + * @import { Texture } from '../../../../platform/graphics/texture.js' + * @import { FramePassSmaa } from './frame-pass-smaa.js' + */ + +/** + * One of the passes of SMAA, rendering a full screen quad with the textures and the parameters it + * reads. + * + * @ignore + */ +class RenderPassSmaa extends RenderPassShaderQuad { + /** + * The frame pass the pass belongs to, holding the scene and the parameters. + * + * @type {FramePassSmaa} + */ + smaa; + + /** + * The textures the pass reads, and the uniforms they are bound to. + * + * @type {{ scopeId: ScopeId, texture: Texture|null }[]} + * @private + */ + _textures = []; + + /** + * @param {GraphicsDevice} device - The graphics device. + * @param {FramePassSmaa} smaa - The frame pass the pass belongs to. + * @param {string} name - The name of the pass, which its shader is named after. + * @param {string} chunk - The name of the fragment shader chunk. + */ + constructor(device, smaa, name, chunk) { + super(device); + this.name = `RenderPassSmaa${name}`; + this.smaa = smaa; + this.shader = ShaderUtils.createShader(device, { + uniqueName: `SmaaShader${name}`, + attributes: { aPosition: SEMANTIC_POSITION }, + vertexChunk: 'quadVS', + fragmentChunk: chunk + }); + } + + /** + * Sets the texture bound to a uniform when the pass renders. + * + * @param {string} name - The name of the uniform. + * @param {Texture|null} texture - The texture. + */ + setTexture(name, texture) { + let entry = this._textures.find(item => item.scopeId.name === name); + if (!entry) { + entry = { scopeId: this.device.scope.resolve(name), texture: null }; + this._textures.push(entry); + } + entry.texture = texture; + } + + execute() { + const textures = this._textures; + for (let i = 0; i < textures.length; i++) { + textures[i].scopeId.setValue(textures[i].texture); + } + this.smaa.bindParameters(); + super.execute(); + } + + destroy() { + this._textures.length = 0; + super.destroy(); + } +} + +export { RenderPassSmaa }; diff --git a/src/extras/effects/volumetric-fog/passes/frame-pass-volumetric-fog.js b/src/extras/effects/volumetric-fog/passes/frame-pass-volumetric-fog.js index e2d6cfac228..195ea8093b2 100644 --- a/src/extras/effects/volumetric-fog/passes/frame-pass-volumetric-fog.js +++ b/src/extras/effects/volumetric-fog/passes/frame-pass-volumetric-fog.js @@ -1,22 +1,13 @@ import { Color } from '../../../../core/math/color.js'; -import { Mat4 } from '../../../../core/math/mat4.js'; -import { Vec3 } from '../../../../core/math/vec3.js'; -import { Vec4 } from '../../../../core/math/vec4.js'; -import { BoundingSphere } from '../../../../core/shape/bounding-sphere.js'; import { - ADDRESS_CLAMP_TO_EDGE, BLENDEQUATION_ADD, BLENDMODE_ONE, BLENDMODE_SRC_ALPHA, BLENDMODE_ZERO, - FILTER_LINEAR, PIXELFORMAT_RGBA16F, PIXELFORMAT_RGBA32F, PIXELFORMAT_RGBA8, - SEMANTIC_POSITION, SHADERLANGUAGE_GLSL, SHADERLANGUAGE_WGSL + ADDRESS_CLAMP_TO_EDGE, FILTER_LINEAR, PIXELFORMAT_RGBA16F, PIXELFORMAT_RGBA32F, PIXELFORMAT_RGBA8, + SHADERLANGUAGE_GLSL, SHADERLANGUAGE_WGSL } from '../../../../platform/graphics/constants.js'; -import { BlendState } from '../../../../platform/graphics/blend-state.js'; import { FramePass } from '../../../../platform/graphics/frame-pass.js'; import { RenderTarget } from '../../../../platform/graphics/render-target.js'; import { Texture } from '../../../../platform/graphics/texture.js'; -import { RenderPassShaderQuad } from '../../../../scene/graphics/render-pass-shader-quad.js'; -import { LIGHTFALLOFF_LINEAR, LIGHTTYPE_SPOT, shadowTypeInfo } from '../../../../scene/constants.js'; -import { LightCamera } from '../../../../scene/renderer/light-camera.js'; +import { shadowTypeInfo } from '../../../../scene/constants.js'; import { ShaderChunks } from '../../../../scene/shader-lib/shader-chunks.js'; -import { ShaderUtils } from '../../../../scene/shader-lib/shader-utils.js'; import glslVolumetricFogPS from '../glsl/fog-ps.js'; import wgslVolumetricFogPS from '../wgsl/fog-ps.js'; import glslVolumetricFogCombinePS from '../glsl/combine-ps.js'; @@ -25,669 +16,17 @@ import glslVolumetricFogLocalPS from '../glsl/local-ps.js'; import wgslVolumetricFogLocalPS from '../wgsl/local-ps.js'; import glslVolumetricFogLocalVS from '../glsl/local-vs.js'; import wgslVolumetricFogLocalVS from '../wgsl/local-vs.js'; +import { RenderPassVolumetricFog } from './render-pass-volumetric-fog.js'; +import { RenderPassVolumetricFogCombine } from './render-pass-volumetric-fog-combine.js'; +import { RenderPassVolumetricFogLocal } from './render-pass-volumetric-fog-local.js'; /** * @import { CameraComponent } from '../../../../framework/components/camera/component.js' * @import { GraphicsDevice } from '../../../../platform/graphics/graphics-device.js' - * @import { Light, LightRenderData } from '../../../../scene/light.js' + * @import { Light } from '../../../../scene/light.js' * @import { RenderPassForward } from '../../../../scene/renderer/render-pass-forward.js' */ -const _tempDir = new Vec3(); -const _tempVec3 = new Vec3(); -const _tempSphere = new BoundingSphere(); -const _tempCorner = new Vec4(); -const _tempProjected = new Vec4(); -const _tempRect = new Vec4(); - -// cookie channel mask matching the channel selection of the light -const _cookieChannelMasks = { - 'rrr': [1, 0, 0, 0], - 'ggg': [0, 1, 0, 0], - 'bbb': [0, 0, 1, 0], - 'aaa': [0, 0, 0, 1], - 'rgb': [1, 1, 1, 0] -}; - -// mask assigned for a light which does not use a cookie, as the uniform is still declared -const _cookieChannelMaskNone = [0, 0, 0, 0]; - -// the cascade distances of a directional shadow with shadow bounds, which covers the bounds at any -// distance from the camera -const _boundsCascadeDistances = new Float32Array([1e30, 1e30, 1e30, 1e30]); - -/** - * Render pass implementing the volumetric fog raymarch. Renders in-scattered light (rgb) and - * transmittance (a) into a reduced resolution texture, sampling the directional light's cascaded - * shadow map along the ray. - * - * @ignore - */ -class RenderPassVolumetricFog extends RenderPassShaderQuad { - /** @type {Light|null} */ - light = null; - - shadowsEnabled = false; - - /** - * The shadow data of the light for the fog camera, when the shadow map is available. - * - * @type {LightRenderData|null} - */ - lightRenderData = null; - - tint = new Color(1, 1, 1); - - density = 0.01; - - heightBase = 0; - - heightFalloff = 0.05; - - extinction = 1; - - anisotropy = 0.6; - - intensity = 1; - - ambientColor = new Color(1, 1, 1); - - ambientIntensity = 0.02; - - maxDistance = 300; - - steps = 24; - - noiseOffset = 0; - - exposure = 1; - - /** @type {string|null} */ - _variantKey = null; - - constructor(device, cameraComponent) { - super(device); - this.cameraComponent = cameraComponent; - - const scope = device.scope; - this.cameraPosId = scope.resolve('uFogCameraPos'); - this.cameraFwdId = scope.resolve('uFogCameraFwd'); - this.invViewId = scope.resolve('uFogInvView'); - this.projScaleId = scope.resolve('uFogProjScale'); - this.tintId = scope.resolve('uFogTint'); - this.lightColorId = scope.resolve('uFogLightColor'); - this.lightDirId = scope.resolve('uFogLightDir'); - this.ambientId = scope.resolve('uFogAmbient'); - this.fogParamsId = scope.resolve('uFogParams'); - this.scatterParamsId = scope.resolve('uFogScatterParams'); - this.extinctionId = scope.resolve('uFogExtinction'); - this.shadowMapId = scope.resolve('uFogShadowMap'); - this.shadowMatrixPaletteId = scope.resolve('uFogShadowMatrixPalette[0]'); - this.shadowCascadeDistancesId = scope.resolve('uFogShadowCascadeDistances'); - this.shadowParamsId = scope.resolve('uFogShadowParams'); - - this._cameraPos = new Float32Array(3); - this._cameraFwd = new Float32Array(3); - this._projScale = new Float32Array(2); - this._tint = new Float32Array(3); - this._lightColor = new Float32Array(3); - this._lightDir = new Float32Array(3); - this._ambient = new Float32Array(3); - this._fogParams = new Float32Array(4); - this._scatterParams = new Float32Array(4); - this._shadowParams = new Float32Array(4); - } - - /** - * Creates the shader matching the shadow sampling requirements, when those change. - * - * @param {boolean} shadows - True if the shadow map should be sampled. - * @param {boolean} pcf - True if the shadow map is a depth format texture using hardware - * comparison, false when it stores depth in a color texture (PCSS / VSM). - */ - updateShaderVariant(shadows, pcf) { - - const depthKey = ShaderUtils.getScreenDepthChunkKey(this.cameraComponent.shaderParams); - const key = `${shadows}-${pcf}${depthKey}`; - if (this._variantKey !== key) { - this._variantKey = key; - - const defines = new Map(); - ShaderUtils.addScreenDepthChunkDefines(this.cameraComponent.shaderParams, defines); - if (shadows) defines.set('FOG_SHADOWS', ''); - if (pcf) defines.set('FOG_SHADOW_PCF', ''); - - this.shader = ShaderUtils.createShader(this.device, { - uniqueName: `VolumetricFogShader-${key}`, - attributes: { aPosition: SEMANTIC_POSITION }, - vertexChunk: 'quadVS', - fragmentChunk: 'volumetricFogPS', - fragmentDefines: defines - }); - } - } - - execute() { - - const { light } = this; - const camera = this.cameraComponent.camera; - const node = camera._node; - - // camera ray reconstruction - this.invViewId.setValue(node.getWorldTransform().data); - - const pos = node.getPosition(); - this._cameraPos[0] = pos.x; - this._cameraPos[1] = pos.y; - this._cameraPos[2] = pos.z; - this.cameraPosId.setValue(this._cameraPos); - - const fwd = node.forward; - this._cameraFwd[0] = fwd.x; - this._cameraFwd[1] = fwd.y; - this._cameraFwd[2] = fwd.z; - this.cameraFwdId.setValue(this._cameraFwd); - - const projData = camera.projectionMatrix.data; - this._projScale[0] = 1 / projData[0]; - this._projScale[1] = 1 / projData[5]; - this.projScaleId.setValue(this._projScale); - - // light, scaled by the scene exposure to match the lit scene - const lightScale = this.intensity * this.exposure; - if (light) { - // direction towards the light - directional lights shine down their negative Y axis - light._node.getWorldTransform().getY(_tempDir).normalize(); - this._lightDir[0] = _tempDir.x; - this._lightDir[1] = _tempDir.y; - this._lightDir[2] = _tempDir.z; - - const color = light._colorLinear; - this._lightColor[0] = color[0] * lightScale; - this._lightColor[1] = color[1] * lightScale; - this._lightColor[2] = color[2] * lightScale; - } else { - this._lightDir[0] = 0; - this._lightDir[1] = 1; - this._lightDir[2] = 0; - this._lightColor[0] = 0; - this._lightColor[1] = 0; - this._lightColor[2] = 0; - } - this.lightDirId.setValue(this._lightDir); - this.lightColorId.setValue(this._lightColor); - - if (this.shadowsEnabled && light && this.lightRenderData) { - const lightRenderData = this.lightRenderData; - const biases = light._getUniformBiasValues(lightRenderData); - - // a shadow with shadow bounds covers the bounds at any distance from the camera - const bounds = light.shadowBounds; - - this.shadowMapId.setValue(lightRenderData.shadowBuffer); - this.shadowMatrixPaletteId.setValue(light._shadowMatrixPalette); - this.shadowCascadeDistancesId.setValue(bounds ? _boundsCascadeDistances : light._shadowCascadeDistances); - - this._shadowParams[0] = light.shadowCascadeCount; - this._shadowParams[1] = biases.bias; - this._shadowParams[2] = 0; - this._shadowParams[3] = bounds ? 1e30 : light.shadowDistance; - this.shadowParamsId.setValue(this._shadowParams); - } - - // fog parameters - const { tint, ambientColor, ambientIntensity } = this; - this._tint[0] = tint.r; - this._tint[1] = tint.g; - this._tint[2] = tint.b; - this.tintId.setValue(this._tint); - - const ambientScale = ambientIntensity * this.exposure; - this._ambient[0] = ambientColor.r * ambientScale; - this._ambient[1] = ambientColor.g * ambientScale; - this._ambient[2] = ambientColor.b * ambientScale; - this.ambientId.setValue(this._ambient); - - this._fogParams[0] = this.density; - this._fogParams[1] = this.heightBase; - this._fogParams[2] = this.heightFalloff; - this._fogParams[3] = this.maxDistance; - this.fogParamsId.setValue(this._fogParams); - - this.extinctionId.setValue(this.extinction); - - this._scatterParams[0] = this.anisotropy; - this._scatterParams[1] = this.steps; - this._scatterParams[2] = this.noiseOffset; - this._scatterParams[3] = light ? light.shadowIntensity : 1; - this.scatterParamsId.setValue(this._scatterParams); - - super.execute(); - } -} - -/** - * Render pass adding the light scattered by the clustered omni and spot lights into the volumetric - * fog texture. Each light is rendered as a separate quad covering the screen space bounds of its - * volume, and the ray of each of its pixels is marched only over the part of it which is inside the - * light volume. - * - * @ignore - */ -class RenderPassVolumetricFogLocal extends RenderPassShaderQuad { - /** - * The forward pass rendering the scene, used to obtain the light clusters of the layers the - * camera renders, and with it the list of the lights lighting the scene. - * - * @type {RenderPassForward|null} - */ - scenePass = null; - - /** - * True when the omni lights of the cluster scatter light in the fog. - */ - omniLights = true; - - /** - * True when the spot lights of the cluster scatter light in the fog. - */ - spotLights = true; - - tint = new Color(1, 1, 1); - - density = 0.01; - - heightBase = 0; - - heightFalloff = 0.05; - - extinction = 1; - - anisotropy = 0.6; - - intensity = 1; - - maxDistance = 300; - - steps = 12; - - noiseOffset = 0; - - exposure = 1; - - /** @type {string|null} */ - _variantKey = null; - - constructor(device, cameraComponent) { - super(device); - this.cameraComponent = cameraComponent; - - // in-scattered light is added to the fog texture, the transmittance it stores in alpha is - // written by the directional pass and is preserved - this.blendState = new BlendState(true, BLENDEQUATION_ADD, BLENDMODE_ONE, BLENDMODE_ONE, - BLENDEQUATION_ADD, BLENDMODE_ZERO, BLENDMODE_ONE); - - const scope = device.scope; - this.cameraPosId = scope.resolve('uVolCameraPos'); - this.cameraFwdId = scope.resolve('uVolCameraFwd'); - this.invViewId = scope.resolve('uVolInvView'); - this.projScaleId = scope.resolve('uVolProjScale'); - this.tintId = scope.resolve('uVolTint'); - this.fogParamsId = scope.resolve('uVolFogParams'); - this.marchParamsId = scope.resolve('uVolMarchParams'); - - this.lightRectId = scope.resolve('uVolLightRect'); - this.lightPosRangeId = scope.resolve('uVolLightPosRange'); - this.lightSphereId = scope.resolve('uVolLightSphere'); - this.lightColorId = scope.resolve('uVolLightColor'); - this.lightDirId = scope.resolve('uVolLightDir'); - this.lightSpotId = scope.resolve('uVolLightSpot'); - this.lightAttenId = scope.resolve('uVolLightAtten'); - this.lightProjMatrixId = scope.resolve('uVolLightProjMatrix'); - this.lightAtlasId = scope.resolve('uVolLightAtlas'); - this.lightCookieChannelId = scope.resolve('uVolLightCookieChannel'); - - this._cameraPos = new Float32Array(3); - this._cameraFwd = new Float32Array(3); - this._projScale = new Float32Array(2); - this._tint = new Float32Array(3); - this._fogParams = new Float32Array(4); - this._marchParams = new Float32Array(4); - this._lightRect = new Float32Array(4); - this._lightPosRange = new Float32Array(4); - this._lightSphere = new Float32Array(4); - this._lightColor = new Float32Array(3); - this._lightDir = new Float32Array(4); - this._lightSpot = new Float32Array(4); - this._lightAtten = new Float32Array(4); - this._lightAtlas = new Float32Array(4); - } - - /** - * Creates the shader matching the enabled clustered lighting features, when those change. - * - * @param {boolean} shadows - True if the lights can sample the shadow atlas. - * @param {boolean} cookies - True if the lights can sample the cookie atlas. - */ - updateShaderVariant(shadows, cookies) { - - const depthKey = ShaderUtils.getScreenDepthChunkKey(this.cameraComponent.shaderParams); - const key = `${shadows}-${cookies}${depthKey}`; - if (this._variantKey !== key) { - this._variantKey = key; - - const defines = new Map(); - ShaderUtils.addScreenDepthChunkDefines(this.cameraComponent.shaderParams, defines); - if (shadows) defines.set('VOL_SHADOWS', ''); - if (cookies) defines.set('VOL_COOKIES', ''); - - this.shader = ShaderUtils.createShader(this.device, { - uniqueName: `VolumetricFogLocalShader-${key}`, - attributes: { aPosition: SEMANTIC_POSITION }, - vertexChunk: 'volumetricFogLocalVS', - fragmentChunk: 'volumetricFogLocalPS', - fragmentDefines: defines - }); - } - } - - /** - * Returns the light clusters used by the layers the camera renders, or null when the camera - * renders no layer with clustered lights. - * - * @returns {import('../../../../scene/lighting/world-clusters.js').WorldClusters|null} The clusters. - * @private - */ - _getLightClusters() { - const steps = this.scenePass?.layerRenderSteps; - if (steps) { - for (let i = 0; i < steps.length; i++) { - const clusters = steps[i].lightClusters; - if (clusters && clusters.usedLights.length > 1) { - return clusters; - } - } - } - return null; - } - - /** - * Evaluates the normalized device coordinates the light volume projects to, to limit the - * rendering to the part of the screen the light can affect. - * - * @param {Light} light - The light. - * @param {import('../../../../scene/camera.js').Camera} camera - The camera. - * @param {Vec4} rect - The rectangle to fill in, as min.xy and max.xy. - * @returns {boolean} True if the light volume is visible. - * @private - */ - _evalLightRect(light, camera, rect) { - - // the bounding sphere of the light volume in the view space - light.getBoundingSphere(_tempSphere); - const radius = _tempSphere.radius; - camera.viewMatrix.transformPoint(_tempSphere.center, _tempVec3); - - // the projection of a volume crossing the near plane is not bounded, use the whole screen - if (-_tempVec3.z - radius <= camera.nearClip) { - rect.set(-1, -1, 1, 1); - return true; - } - - // project the corners of the view space bounds of the sphere - all of them are in front of - // the near plane, and so the perspective divide is safe - const projection = camera.projectionMatrix; - let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity; - for (let i = 0; i < 8; i++) { - _tempCorner.set( - _tempVec3.x + ((i & 1) ? radius : -radius), - _tempVec3.y + ((i & 2) ? radius : -radius), - _tempVec3.z + ((i & 4) ? radius : -radius), - 1 - ); - - projection.transformVec4(_tempCorner, _tempProjected); - const x = _tempProjected.x / _tempProjected.w; - const y = _tempProjected.y / _tempProjected.w; - minX = Math.min(minX, x); - minY = Math.min(minY, y); - maxX = Math.max(maxX, x); - maxY = Math.max(maxY, y); - } - - rect.set(Math.max(minX, -1), Math.max(minY, -1), Math.min(maxX, 1), Math.min(maxY, 1)); - return rect.x < rect.z && rect.y < rect.w; - } - - /** - * Assigns the uniforms of a single light, and returns false when the light does not need to be - * rendered. - * - * @param {Light} light - The light. - * @param {boolean} shadows - True if the shader samples the shadow atlas. - * @param {boolean} cookies - True if the shader samples the cookie atlas. - * @returns {boolean} True if the light was set up for rendering. - * @private - */ - _setupLight(light, shadows, cookies) { - - const camera = this.cameraComponent.camera; - const isSpot = light._type === LIGHTTYPE_SPOT; - - // screen space bounds of the light volume - if (!this._evalLightRect(light, camera, _tempRect)) { - return false; - } - this._lightRect[0] = _tempRect.x; - this._lightRect[1] = _tempRect.y; - this._lightRect[2] = _tempRect.z; - this._lightRect[3] = _tempRect.w; - this.lightRectId.setValue(this._lightRect); - - // the bounding sphere the ray is marched over, evaluated by _evalLightRect - this._lightSphere[0] = _tempSphere.center.x; - this._lightSphere[1] = _tempSphere.center.y; - this._lightSphere[2] = _tempSphere.center.z; - this._lightSphere[3] = _tempSphere.radius; - this.lightSphereId.setValue(this._lightSphere); - - const pos = light._node.getPosition(); - this._lightPosRange[0] = pos.x; - this._lightPosRange[1] = pos.y; - this._lightPosRange[2] = pos.z; - this._lightPosRange[3] = light.attenuationEnd; - this.lightPosRangeId.setValue(this._lightPosRange); - - // light color, scaled to match the lit scene - const scale = this.intensity * this.exposure * light.volumetricScattering; - const color = light._colorLinear; - this._lightColor[0] = color[0] * scale; - this._lightColor[1] = color[1] * scale; - this._lightColor[2] = color[2] * scale; - this.lightColorId.setValue(this._lightColor); - - // spot lights shine down the negative Y axis of their node - if (isSpot) { - light._node.getWorldTransform().getY(_tempDir).mulScalar(-1).normalize(); - } - this._lightDir[0] = isSpot ? _tempDir.x : 0; - this._lightDir[1] = isSpot ? _tempDir.y : 0; - this._lightDir[2] = isSpot ? _tempDir.z : 0; - this._lightDir[3] = isSpot ? 1 : 0; - this.lightDirId.setValue(this._lightDir); - - this._lightAtten[0] = light._falloffMode === LIGHTFALLOFF_LINEAR ? 1 : 0; - this.lightAttenId.setValue(this._lightAtten); - - // a light only samples the atlases when it was allocated a slot in them. The shadow map - // condition matches the one the clustered lighting uses to select the projection matrix of - // a spot light, so the fog and the surfaces sample the atlases the same way. - const hasAtlasSlot = light.atlasViewportAllocated; - const hasShadowMap = shadows && hasAtlasSlot && light.castShadows; - const useShadow = hasShadowMap && light.shadowIntensity > 0; - const useCookie = cookies && hasAtlasSlot && !!light._cookie && light.cookieIntensity > 0; - - this._lightSpot[0] = light._innerConeAngleCos; - this._lightSpot[1] = light._outerConeAngleCos; - this._lightSpot[2] = useShadow ? light.shadowIntensity : 0; - this._lightSpot[3] = useCookie ? light.cookieIntensity : 0; - this.lightSpotId.setValue(this._lightSpot); - - // The atlas uniforms below are declared by the shader whenever the shadow or the cookie - // sampling is compiled in, and so they are assigned for every light, even one which samples - // neither - the shader ignores the values in that case, but leaving a declared uniform unset - // is reported as an error. - if (shadows || cookies) { - - const lightRenderData = hasShadowMap ? light.getRenderData(null, 0) : null; - - // the projection matrix of a spot light transforms the world space position to the - // atlas slot of the light, and is shared by the shadow map and the cookie - if (isSpot) { - const matrix = lightRenderData?.shadowMatrix ?? - (useCookie ? LightCamera.evalSpotCookieMatrix(light) : Mat4.IDENTITY); - this.lightProjMatrixId.setValue(matrix.data); - } else { - this.lightProjMatrixId.setValue(Mat4.IDENTITY.data); - } - - // an omni light uses a cube face stored in the atlas viewport of the light, subdivided - // into a 3x3 grid of faces - const viewport = light.atlasViewport; - this._lightAtlas[0] = viewport.x; - this._lightAtlas[1] = viewport.y; - this._lightAtlas[2] = viewport.z / 3; - this._lightAtlas[3] = lightRenderData ? light._getUniformBiasValues(lightRenderData).bias : 0; - this.lightAtlasId.setValue(this._lightAtlas); - } - - if (cookies) { - this.lightCookieChannelId.setValue(useCookie ? - (_cookieChannelMasks[light._cookieChannel] ?? _cookieChannelMasks.rgb) : - _cookieChannelMaskNone); - } - - return true; - } - - execute() { - - const clusters = this._getLightClusters(); - if (!clusters || !this.quadRender) { - return; - } - - const camera = this.cameraComponent.camera; - const node = camera._node; - const { lighting } = this.cameraComponent.system.app.scene; - - // camera ray reconstruction, shared by all the lights - this.invViewId.setValue(node.getWorldTransform().data); - - const pos = node.getPosition(); - this._cameraPos[0] = pos.x; - this._cameraPos[1] = pos.y; - this._cameraPos[2] = pos.z; - this.cameraPosId.setValue(this._cameraPos); - - const fwd = node.forward; - this._cameraFwd[0] = fwd.x; - this._cameraFwd[1] = fwd.y; - this._cameraFwd[2] = fwd.z; - this.cameraFwdId.setValue(this._cameraFwd); - - const projData = camera.projectionMatrix.data; - this._projScale[0] = 1 / projData[0]; - this._projScale[1] = 1 / projData[5]; - this.projScaleId.setValue(this._projScale); - - const { tint } = this; - this._tint[0] = tint.r; - this._tint[1] = tint.g; - this._tint[2] = tint.b; - this.tintId.setValue(this._tint); - - this._fogParams[0] = this.density; - this._fogParams[1] = this.heightBase; - this._fogParams[2] = this.heightFalloff; - this._fogParams[3] = this.maxDistance; - this.fogParamsId.setValue(this._fogParams); - - this._marchParams[0] = this.anisotropy; - this._marchParams[1] = this.steps; - this._marchParams[2] = this.noiseOffset; - this._marchParams[3] = this.extinction; - this.marchParamsId.setValue(this._marchParams); - - // render state, shared by all the lights - this.device.setDrawStates(this.blendState, this.depthState, this.cullMode, this.frontFace, - this.stencilFront, this.stencilBack); - - // a quad per light, covering the screen space bounds of its volume. Index 0 of the cluster - // lights is reserved for 'no light'. - const { shadowsEnabled, cookiesEnabled } = lighting; - const { omniLights, spotLights } = this; - const lights = clusters.usedLights; - for (let i = 1; i < lights.length; i++) { - const light = lights[i].light; - - // the two light types are enabled separately, and a light can opt out of the fog - const enabled = light?.volumetricScattering > 0 && - (light._type === LIGHTTYPE_SPOT ? spotLights : omniLights); - - if (enabled && this._setupLight(light, shadowsEnabled, cookiesEnabled)) { - this.quadRender.render(); - } - } - } -} - -/** - * Render pass which composites the volumetric fog texture over the scene render target, using a - * depth-aware upsample, blending as scene * transmittance + inscatter. - * - * @ignore - */ -class RenderPassVolumetricFogCombine extends RenderPassShaderQuad { - constructor(device, cameraComponent, fogTexture) { - super(device); - this.fogTexture = fogTexture; - - const defines = new Map(); - const depthKey = ShaderUtils.addScreenDepthChunkDefines(cameraComponent.shaderParams, defines); - - this.shader = ShaderUtils.createShader(device, { - uniqueName: `VolumetricFogCombineShader${depthKey}`, - attributes: { aPosition: SEMANTIC_POSITION }, - vertexChunk: 'quadVS', - fragmentChunk: 'volumetricFogCombinePS', - fragmentDefines: defines - }); - - // scene.rgb * fog.a + fog.rgb, scene alpha is preserved - this.blendState = new BlendState(true, BLENDEQUATION_ADD, BLENDMODE_ONE, BLENDMODE_SRC_ALPHA, - BLENDEQUATION_ADD, BLENDMODE_ZERO, BLENDMODE_ONE); - - this.fogTextureId = device.scope.resolve('uFogTexture'); - this.fogTextureSizeId = device.scope.resolve('uFogTextureSize'); - this._fogTextureSize = new Float32Array(4); - } - - execute() { - - const { fogTexture } = this; - this.fogTextureId.setValue(fogTexture); - - this._fogTextureSize[0] = fogTexture.width; - this._fogTextureSize[1] = fogTexture.height; - this._fogTextureSize[2] = 1 / fogTexture.width; - this._fogTextureSize[3] = 1 / fogTexture.height; - this.fogTextureSizeId.setValue(this._fogTextureSize); - - super.execute(); - } -} - /** * Frame pass implementation of volumetric fog lit by a directional light. A reduced resolution * raymarch pass accumulates in-scattered light and transmittance along each view ray, sampling diff --git a/src/extras/effects/volumetric-fog/passes/render-pass-volumetric-fog-combine.js b/src/extras/effects/volumetric-fog/passes/render-pass-volumetric-fog-combine.js new file mode 100644 index 00000000000..3ee90927405 --- /dev/null +++ b/src/extras/effects/volumetric-fog/passes/render-pass-volumetric-fog-combine.js @@ -0,0 +1,54 @@ +import { + BLENDEQUATION_ADD, BLENDMODE_ONE, BLENDMODE_SRC_ALPHA, BLENDMODE_ZERO, SEMANTIC_POSITION +} from '../../../../platform/graphics/constants.js'; +import { BlendState } from '../../../../platform/graphics/blend-state.js'; +import { RenderPassShaderQuad } from '../../../../scene/graphics/render-pass-shader-quad.js'; +import { ShaderUtils } from '../../../../scene/shader-lib/shader-utils.js'; + +/** + * Render pass which composites the volumetric fog texture over the scene render target, using a + * depth-aware upsample, blending as scene * transmittance + inscatter. + * + * @ignore + */ +class RenderPassVolumetricFogCombine extends RenderPassShaderQuad { + constructor(device, cameraComponent, fogTexture) { + super(device); + this.fogTexture = fogTexture; + + const defines = new Map(); + const depthKey = ShaderUtils.addScreenDepthChunkDefines(cameraComponent.shaderParams, defines); + + this.shader = ShaderUtils.createShader(device, { + uniqueName: `VolumetricFogCombineShader${depthKey}`, + attributes: { aPosition: SEMANTIC_POSITION }, + vertexChunk: 'quadVS', + fragmentChunk: 'volumetricFogCombinePS', + fragmentDefines: defines + }); + + // scene.rgb * fog.a + fog.rgb, scene alpha is preserved + this.blendState = new BlendState(true, BLENDEQUATION_ADD, BLENDMODE_ONE, BLENDMODE_SRC_ALPHA, + BLENDEQUATION_ADD, BLENDMODE_ZERO, BLENDMODE_ONE); + + this.fogTextureId = device.scope.resolve('uFogTexture'); + this.fogTextureSizeId = device.scope.resolve('uFogTextureSize'); + this._fogTextureSize = new Float32Array(4); + } + + execute() { + + const { fogTexture } = this; + this.fogTextureId.setValue(fogTexture); + + this._fogTextureSize[0] = fogTexture.width; + this._fogTextureSize[1] = fogTexture.height; + this._fogTextureSize[2] = 1 / fogTexture.width; + this._fogTextureSize[3] = 1 / fogTexture.height; + this.fogTextureSizeId.setValue(this._fogTextureSize); + + super.execute(); + } +} + +export { RenderPassVolumetricFogCombine }; diff --git a/src/extras/effects/volumetric-fog/passes/render-pass-volumetric-fog-local.js b/src/extras/effects/volumetric-fog/passes/render-pass-volumetric-fog-local.js new file mode 100644 index 00000000000..91647a1352a --- /dev/null +++ b/src/extras/effects/volumetric-fog/passes/render-pass-volumetric-fog-local.js @@ -0,0 +1,415 @@ +import { Color } from '../../../../core/math/color.js'; +import { Mat4 } from '../../../../core/math/mat4.js'; +import { Vec3 } from '../../../../core/math/vec3.js'; +import { Vec4 } from '../../../../core/math/vec4.js'; +import { BoundingSphere } from '../../../../core/shape/bounding-sphere.js'; +import { + BLENDEQUATION_ADD, BLENDMODE_ONE, BLENDMODE_ZERO, SEMANTIC_POSITION +} from '../../../../platform/graphics/constants.js'; +import { BlendState } from '../../../../platform/graphics/blend-state.js'; +import { RenderPassShaderQuad } from '../../../../scene/graphics/render-pass-shader-quad.js'; +import { LIGHTFALLOFF_LINEAR, LIGHTTYPE_SPOT } from '../../../../scene/constants.js'; +import { LightCamera } from '../../../../scene/renderer/light-camera.js'; +import { ShaderUtils } from '../../../../scene/shader-lib/shader-utils.js'; + +/** + * @import { Light } from '../../../../scene/light.js' + * @import { RenderPassForward } from '../../../../scene/renderer/render-pass-forward.js' + */ + +const _tempDir = new Vec3(); +const _tempVec3 = new Vec3(); +const _tempSphere = new BoundingSphere(); +const _tempCorner = new Vec4(); +const _tempProjected = new Vec4(); +const _tempRect = new Vec4(); + +// cookie channel mask matching the channel selection of the light +const _cookieChannelMasks = { + 'rrr': [1, 0, 0, 0], + 'ggg': [0, 1, 0, 0], + 'bbb': [0, 0, 1, 0], + 'aaa': [0, 0, 0, 1], + 'rgb': [1, 1, 1, 0] +}; + +// mask assigned for a light which does not use a cookie, as the uniform is still declared +const _cookieChannelMaskNone = [0, 0, 0, 0]; + +/** + * Render pass adding the light scattered by the clustered omni and spot lights into the volumetric + * fog texture. Each light is rendered as a separate quad covering the screen space bounds of its + * volume, and the ray of each of its pixels is marched only over the part of it which is inside the + * light volume. + * + * @ignore + */ +class RenderPassVolumetricFogLocal extends RenderPassShaderQuad { + /** + * The forward pass rendering the scene, used to obtain the light clusters of the layers the + * camera renders, and with it the list of the lights lighting the scene. + * + * @type {RenderPassForward|null} + */ + scenePass = null; + + /** + * True when the omni lights of the cluster scatter light in the fog. + */ + omniLights = true; + + /** + * True when the spot lights of the cluster scatter light in the fog. + */ + spotLights = true; + + tint = new Color(1, 1, 1); + + density = 0.01; + + heightBase = 0; + + heightFalloff = 0.05; + + extinction = 1; + + anisotropy = 0.6; + + intensity = 1; + + maxDistance = 300; + + steps = 12; + + noiseOffset = 0; + + exposure = 1; + + /** @type {string|null} */ + _variantKey = null; + + constructor(device, cameraComponent) { + super(device); + this.cameraComponent = cameraComponent; + + // in-scattered light is added to the fog texture, the transmittance it stores in alpha is + // written by the directional pass and is preserved + this.blendState = new BlendState(true, BLENDEQUATION_ADD, BLENDMODE_ONE, BLENDMODE_ONE, + BLENDEQUATION_ADD, BLENDMODE_ZERO, BLENDMODE_ONE); + + const scope = device.scope; + this.cameraPosId = scope.resolve('uVolCameraPos'); + this.cameraFwdId = scope.resolve('uVolCameraFwd'); + this.invViewId = scope.resolve('uVolInvView'); + this.projScaleId = scope.resolve('uVolProjScale'); + this.tintId = scope.resolve('uVolTint'); + this.fogParamsId = scope.resolve('uVolFogParams'); + this.marchParamsId = scope.resolve('uVolMarchParams'); + + this.lightRectId = scope.resolve('uVolLightRect'); + this.lightPosRangeId = scope.resolve('uVolLightPosRange'); + this.lightSphereId = scope.resolve('uVolLightSphere'); + this.lightColorId = scope.resolve('uVolLightColor'); + this.lightDirId = scope.resolve('uVolLightDir'); + this.lightSpotId = scope.resolve('uVolLightSpot'); + this.lightAttenId = scope.resolve('uVolLightAtten'); + this.lightProjMatrixId = scope.resolve('uVolLightProjMatrix'); + this.lightAtlasId = scope.resolve('uVolLightAtlas'); + this.lightCookieChannelId = scope.resolve('uVolLightCookieChannel'); + + this._cameraPos = new Float32Array(3); + this._cameraFwd = new Float32Array(3); + this._projScale = new Float32Array(2); + this._tint = new Float32Array(3); + this._fogParams = new Float32Array(4); + this._marchParams = new Float32Array(4); + this._lightRect = new Float32Array(4); + this._lightPosRange = new Float32Array(4); + this._lightSphere = new Float32Array(4); + this._lightColor = new Float32Array(3); + this._lightDir = new Float32Array(4); + this._lightSpot = new Float32Array(4); + this._lightAtten = new Float32Array(4); + this._lightAtlas = new Float32Array(4); + } + + /** + * Creates the shader matching the enabled clustered lighting features, when those change. + * + * @param {boolean} shadows - True if the lights can sample the shadow atlas. + * @param {boolean} cookies - True if the lights can sample the cookie atlas. + */ + updateShaderVariant(shadows, cookies) { + + const depthKey = ShaderUtils.getScreenDepthChunkKey(this.cameraComponent.shaderParams); + const key = `${shadows}-${cookies}${depthKey}`; + if (this._variantKey !== key) { + this._variantKey = key; + + const defines = new Map(); + ShaderUtils.addScreenDepthChunkDefines(this.cameraComponent.shaderParams, defines); + if (shadows) defines.set('VOL_SHADOWS', ''); + if (cookies) defines.set('VOL_COOKIES', ''); + + this.shader = ShaderUtils.createShader(this.device, { + uniqueName: `VolumetricFogLocalShader-${key}`, + attributes: { aPosition: SEMANTIC_POSITION }, + vertexChunk: 'volumetricFogLocalVS', + fragmentChunk: 'volumetricFogLocalPS', + fragmentDefines: defines + }); + } + } + + /** + * Returns the light clusters used by the layers the camera renders, or null when the camera + * renders no layer with clustered lights. + * + * @returns {import('../../../../scene/lighting/world-clusters.js').WorldClusters|null} The clusters. + * @private + */ + _getLightClusters() { + const steps = this.scenePass?.layerRenderSteps; + if (steps) { + for (let i = 0; i < steps.length; i++) { + const clusters = steps[i].lightClusters; + if (clusters && clusters.usedLights.length > 1) { + return clusters; + } + } + } + return null; + } + + /** + * Evaluates the normalized device coordinates the light volume projects to, to limit the + * rendering to the part of the screen the light can affect. + * + * @param {Light} light - The light. + * @param {import('../../../../scene/camera.js').Camera} camera - The camera. + * @param {Vec4} rect - The rectangle to fill in, as min.xy and max.xy. + * @returns {boolean} True if the light volume is visible. + * @private + */ + _evalLightRect(light, camera, rect) { + + // the bounding sphere of the light volume in the view space + light.getBoundingSphere(_tempSphere); + const radius = _tempSphere.radius; + camera.viewMatrix.transformPoint(_tempSphere.center, _tempVec3); + + // the projection of a volume crossing the near plane is not bounded, use the whole screen + if (-_tempVec3.z - radius <= camera.nearClip) { + rect.set(-1, -1, 1, 1); + return true; + } + + // project the corners of the view space bounds of the sphere - all of them are in front of + // the near plane, and so the perspective divide is safe + const projection = camera.projectionMatrix; + let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity; + for (let i = 0; i < 8; i++) { + _tempCorner.set( + _tempVec3.x + ((i & 1) ? radius : -radius), + _tempVec3.y + ((i & 2) ? radius : -radius), + _tempVec3.z + ((i & 4) ? radius : -radius), + 1 + ); + + projection.transformVec4(_tempCorner, _tempProjected); + const x = _tempProjected.x / _tempProjected.w; + const y = _tempProjected.y / _tempProjected.w; + minX = Math.min(minX, x); + minY = Math.min(minY, y); + maxX = Math.max(maxX, x); + maxY = Math.max(maxY, y); + } + + rect.set(Math.max(minX, -1), Math.max(minY, -1), Math.min(maxX, 1), Math.min(maxY, 1)); + return rect.x < rect.z && rect.y < rect.w; + } + + /** + * Assigns the uniforms of a single light, and returns false when the light does not need to be + * rendered. + * + * @param {Light} light - The light. + * @param {boolean} shadows - True if the shader samples the shadow atlas. + * @param {boolean} cookies - True if the shader samples the cookie atlas. + * @returns {boolean} True if the light was set up for rendering. + * @private + */ + _setupLight(light, shadows, cookies) { + + const camera = this.cameraComponent.camera; + const isSpot = light._type === LIGHTTYPE_SPOT; + + // screen space bounds of the light volume + if (!this._evalLightRect(light, camera, _tempRect)) { + return false; + } + this._lightRect[0] = _tempRect.x; + this._lightRect[1] = _tempRect.y; + this._lightRect[2] = _tempRect.z; + this._lightRect[3] = _tempRect.w; + this.lightRectId.setValue(this._lightRect); + + // the bounding sphere the ray is marched over, evaluated by _evalLightRect + this._lightSphere[0] = _tempSphere.center.x; + this._lightSphere[1] = _tempSphere.center.y; + this._lightSphere[2] = _tempSphere.center.z; + this._lightSphere[3] = _tempSphere.radius; + this.lightSphereId.setValue(this._lightSphere); + + const pos = light._node.getPosition(); + this._lightPosRange[0] = pos.x; + this._lightPosRange[1] = pos.y; + this._lightPosRange[2] = pos.z; + this._lightPosRange[3] = light.attenuationEnd; + this.lightPosRangeId.setValue(this._lightPosRange); + + // light color, scaled to match the lit scene + const scale = this.intensity * this.exposure * light.volumetricScattering; + const color = light._colorLinear; + this._lightColor[0] = color[0] * scale; + this._lightColor[1] = color[1] * scale; + this._lightColor[2] = color[2] * scale; + this.lightColorId.setValue(this._lightColor); + + // spot lights shine down the negative Y axis of their node + if (isSpot) { + light._node.getWorldTransform().getY(_tempDir).mulScalar(-1).normalize(); + } + this._lightDir[0] = isSpot ? _tempDir.x : 0; + this._lightDir[1] = isSpot ? _tempDir.y : 0; + this._lightDir[2] = isSpot ? _tempDir.z : 0; + this._lightDir[3] = isSpot ? 1 : 0; + this.lightDirId.setValue(this._lightDir); + + this._lightAtten[0] = light._falloffMode === LIGHTFALLOFF_LINEAR ? 1 : 0; + this.lightAttenId.setValue(this._lightAtten); + + // a light only samples the atlases when it was allocated a slot in them. The shadow map + // condition matches the one the clustered lighting uses to select the projection matrix of + // a spot light, so the fog and the surfaces sample the atlases the same way. + const hasAtlasSlot = light.atlasViewportAllocated; + const hasShadowMap = shadows && hasAtlasSlot && light.castShadows; + const useShadow = hasShadowMap && light.shadowIntensity > 0; + const useCookie = cookies && hasAtlasSlot && !!light._cookie && light.cookieIntensity > 0; + + this._lightSpot[0] = light._innerConeAngleCos; + this._lightSpot[1] = light._outerConeAngleCos; + this._lightSpot[2] = useShadow ? light.shadowIntensity : 0; + this._lightSpot[3] = useCookie ? light.cookieIntensity : 0; + this.lightSpotId.setValue(this._lightSpot); + + // The atlas uniforms below are declared by the shader whenever the shadow or the cookie + // sampling is compiled in, and so they are assigned for every light, even one which samples + // neither - the shader ignores the values in that case, but leaving a declared uniform unset + // is reported as an error. + if (shadows || cookies) { + + const lightRenderData = hasShadowMap ? light.getRenderData(null, 0) : null; + + // the projection matrix of a spot light transforms the world space position to the + // atlas slot of the light, and is shared by the shadow map and the cookie + if (isSpot) { + const matrix = lightRenderData?.shadowMatrix ?? + (useCookie ? LightCamera.evalSpotCookieMatrix(light) : Mat4.IDENTITY); + this.lightProjMatrixId.setValue(matrix.data); + } else { + this.lightProjMatrixId.setValue(Mat4.IDENTITY.data); + } + + // an omni light uses a cube face stored in the atlas viewport of the light, subdivided + // into a 3x3 grid of faces + const viewport = light.atlasViewport; + this._lightAtlas[0] = viewport.x; + this._lightAtlas[1] = viewport.y; + this._lightAtlas[2] = viewport.z / 3; + this._lightAtlas[3] = lightRenderData ? light._getUniformBiasValues(lightRenderData).bias : 0; + this.lightAtlasId.setValue(this._lightAtlas); + } + + if (cookies) { + this.lightCookieChannelId.setValue(useCookie ? + (_cookieChannelMasks[light._cookieChannel] ?? _cookieChannelMasks.rgb) : + _cookieChannelMaskNone); + } + + return true; + } + + execute() { + + const clusters = this._getLightClusters(); + if (!clusters || !this.quadRender) { + return; + } + + const camera = this.cameraComponent.camera; + const node = camera._node; + const { lighting } = this.cameraComponent.system.app.scene; + + // camera ray reconstruction, shared by all the lights + this.invViewId.setValue(node.getWorldTransform().data); + + const pos = node.getPosition(); + this._cameraPos[0] = pos.x; + this._cameraPos[1] = pos.y; + this._cameraPos[2] = pos.z; + this.cameraPosId.setValue(this._cameraPos); + + const fwd = node.forward; + this._cameraFwd[0] = fwd.x; + this._cameraFwd[1] = fwd.y; + this._cameraFwd[2] = fwd.z; + this.cameraFwdId.setValue(this._cameraFwd); + + const projData = camera.projectionMatrix.data; + this._projScale[0] = 1 / projData[0]; + this._projScale[1] = 1 / projData[5]; + this.projScaleId.setValue(this._projScale); + + const { tint } = this; + this._tint[0] = tint.r; + this._tint[1] = tint.g; + this._tint[2] = tint.b; + this.tintId.setValue(this._tint); + + this._fogParams[0] = this.density; + this._fogParams[1] = this.heightBase; + this._fogParams[2] = this.heightFalloff; + this._fogParams[3] = this.maxDistance; + this.fogParamsId.setValue(this._fogParams); + + this._marchParams[0] = this.anisotropy; + this._marchParams[1] = this.steps; + this._marchParams[2] = this.noiseOffset; + this._marchParams[3] = this.extinction; + this.marchParamsId.setValue(this._marchParams); + + // render state, shared by all the lights + this.device.setDrawStates(this.blendState, this.depthState, this.cullMode, this.frontFace, + this.stencilFront, this.stencilBack); + + // a quad per light, covering the screen space bounds of its volume. Index 0 of the cluster + // lights is reserved for 'no light'. + const { shadowsEnabled, cookiesEnabled } = lighting; + const { omniLights, spotLights } = this; + const lights = clusters.usedLights; + for (let i = 1; i < lights.length; i++) { + const light = lights[i].light; + + // the two light types are enabled separately, and a light can opt out of the fog + const enabled = light?.volumetricScattering > 0 && + (light._type === LIGHTTYPE_SPOT ? spotLights : omniLights); + + if (enabled && this._setupLight(light, shadowsEnabled, cookiesEnabled)) { + this.quadRender.render(); + } + } + } +} + +export { RenderPassVolumetricFogLocal }; diff --git a/src/extras/effects/volumetric-fog/passes/render-pass-volumetric-fog.js b/src/extras/effects/volumetric-fog/passes/render-pass-volumetric-fog.js new file mode 100644 index 00000000000..3a28aca2dd2 --- /dev/null +++ b/src/extras/effects/volumetric-fog/passes/render-pass-volumetric-fog.js @@ -0,0 +1,227 @@ +import { Color } from '../../../../core/math/color.js'; +import { Vec3 } from '../../../../core/math/vec3.js'; +import { SEMANTIC_POSITION } from '../../../../platform/graphics/constants.js'; +import { RenderPassShaderQuad } from '../../../../scene/graphics/render-pass-shader-quad.js'; +import { ShaderUtils } from '../../../../scene/shader-lib/shader-utils.js'; + +/** + * @import { Light, LightRenderData } from '../../../../scene/light.js' + */ + +const _tempDir = new Vec3(); + +// the cascade distances of a directional shadow with shadow bounds, which covers the bounds at any +// distance from the camera +const _boundsCascadeDistances = new Float32Array([1e30, 1e30, 1e30, 1e30]); + +/** + * Render pass implementing the volumetric fog raymarch. Renders in-scattered light (rgb) and + * transmittance (a) into a reduced resolution texture, sampling the directional light's cascaded + * shadow map along the ray. + * + * @ignore + */ +class RenderPassVolumetricFog extends RenderPassShaderQuad { + /** @type {Light|null} */ + light = null; + + shadowsEnabled = false; + + /** + * The shadow data of the light for the fog camera, when the shadow map is available. + * + * @type {LightRenderData|null} + */ + lightRenderData = null; + + tint = new Color(1, 1, 1); + + density = 0.01; + + heightBase = 0; + + heightFalloff = 0.05; + + extinction = 1; + + anisotropy = 0.6; + + intensity = 1; + + ambientColor = new Color(1, 1, 1); + + ambientIntensity = 0.02; + + maxDistance = 300; + + steps = 24; + + noiseOffset = 0; + + exposure = 1; + + /** @type {string|null} */ + _variantKey = null; + + constructor(device, cameraComponent) { + super(device); + this.cameraComponent = cameraComponent; + + const scope = device.scope; + this.cameraPosId = scope.resolve('uFogCameraPos'); + this.cameraFwdId = scope.resolve('uFogCameraFwd'); + this.invViewId = scope.resolve('uFogInvView'); + this.projScaleId = scope.resolve('uFogProjScale'); + this.tintId = scope.resolve('uFogTint'); + this.lightColorId = scope.resolve('uFogLightColor'); + this.lightDirId = scope.resolve('uFogLightDir'); + this.ambientId = scope.resolve('uFogAmbient'); + this.fogParamsId = scope.resolve('uFogParams'); + this.scatterParamsId = scope.resolve('uFogScatterParams'); + this.extinctionId = scope.resolve('uFogExtinction'); + this.shadowMapId = scope.resolve('uFogShadowMap'); + this.shadowMatrixPaletteId = scope.resolve('uFogShadowMatrixPalette[0]'); + this.shadowCascadeDistancesId = scope.resolve('uFogShadowCascadeDistances'); + this.shadowParamsId = scope.resolve('uFogShadowParams'); + + this._cameraPos = new Float32Array(3); + this._cameraFwd = new Float32Array(3); + this._projScale = new Float32Array(2); + this._tint = new Float32Array(3); + this._lightColor = new Float32Array(3); + this._lightDir = new Float32Array(3); + this._ambient = new Float32Array(3); + this._fogParams = new Float32Array(4); + this._scatterParams = new Float32Array(4); + this._shadowParams = new Float32Array(4); + } + + /** + * Creates the shader matching the shadow sampling requirements, when those change. + * + * @param {boolean} shadows - True if the shadow map should be sampled. + * @param {boolean} pcf - True if the shadow map is a depth format texture using hardware + * comparison, false when it stores depth in a color texture (PCSS / VSM). + */ + updateShaderVariant(shadows, pcf) { + + const depthKey = ShaderUtils.getScreenDepthChunkKey(this.cameraComponent.shaderParams); + const key = `${shadows}-${pcf}${depthKey}`; + if (this._variantKey !== key) { + this._variantKey = key; + + const defines = new Map(); + ShaderUtils.addScreenDepthChunkDefines(this.cameraComponent.shaderParams, defines); + if (shadows) defines.set('FOG_SHADOWS', ''); + if (pcf) defines.set('FOG_SHADOW_PCF', ''); + + this.shader = ShaderUtils.createShader(this.device, { + uniqueName: `VolumetricFogShader-${key}`, + attributes: { aPosition: SEMANTIC_POSITION }, + vertexChunk: 'quadVS', + fragmentChunk: 'volumetricFogPS', + fragmentDefines: defines + }); + } + } + + execute() { + + const { light } = this; + const camera = this.cameraComponent.camera; + const node = camera._node; + + // camera ray reconstruction + this.invViewId.setValue(node.getWorldTransform().data); + + const pos = node.getPosition(); + this._cameraPos[0] = pos.x; + this._cameraPos[1] = pos.y; + this._cameraPos[2] = pos.z; + this.cameraPosId.setValue(this._cameraPos); + + const fwd = node.forward; + this._cameraFwd[0] = fwd.x; + this._cameraFwd[1] = fwd.y; + this._cameraFwd[2] = fwd.z; + this.cameraFwdId.setValue(this._cameraFwd); + + const projData = camera.projectionMatrix.data; + this._projScale[0] = 1 / projData[0]; + this._projScale[1] = 1 / projData[5]; + this.projScaleId.setValue(this._projScale); + + // light, scaled by the scene exposure to match the lit scene + const lightScale = this.intensity * this.exposure; + if (light) { + // direction towards the light - directional lights shine down their negative Y axis + light._node.getWorldTransform().getY(_tempDir).normalize(); + this._lightDir[0] = _tempDir.x; + this._lightDir[1] = _tempDir.y; + this._lightDir[2] = _tempDir.z; + + const color = light._colorLinear; + this._lightColor[0] = color[0] * lightScale; + this._lightColor[1] = color[1] * lightScale; + this._lightColor[2] = color[2] * lightScale; + } else { + this._lightDir[0] = 0; + this._lightDir[1] = 1; + this._lightDir[2] = 0; + this._lightColor[0] = 0; + this._lightColor[1] = 0; + this._lightColor[2] = 0; + } + this.lightDirId.setValue(this._lightDir); + this.lightColorId.setValue(this._lightColor); + + if (this.shadowsEnabled && light && this.lightRenderData) { + const lightRenderData = this.lightRenderData; + const biases = light._getUniformBiasValues(lightRenderData); + + // a shadow with shadow bounds covers the bounds at any distance from the camera + const bounds = light.shadowBounds; + + this.shadowMapId.setValue(lightRenderData.shadowBuffer); + this.shadowMatrixPaletteId.setValue(light._shadowMatrixPalette); + this.shadowCascadeDistancesId.setValue(bounds ? _boundsCascadeDistances : light._shadowCascadeDistances); + + this._shadowParams[0] = light.shadowCascadeCount; + this._shadowParams[1] = biases.bias; + this._shadowParams[2] = 0; + this._shadowParams[3] = bounds ? 1e30 : light.shadowDistance; + this.shadowParamsId.setValue(this._shadowParams); + } + + // fog parameters + const { tint, ambientColor, ambientIntensity } = this; + this._tint[0] = tint.r; + this._tint[1] = tint.g; + this._tint[2] = tint.b; + this.tintId.setValue(this._tint); + + const ambientScale = ambientIntensity * this.exposure; + this._ambient[0] = ambientColor.r * ambientScale; + this._ambient[1] = ambientColor.g * ambientScale; + this._ambient[2] = ambientColor.b * ambientScale; + this.ambientId.setValue(this._ambient); + + this._fogParams[0] = this.density; + this._fogParams[1] = this.heightBase; + this._fogParams[2] = this.heightFalloff; + this._fogParams[3] = this.maxDistance; + this.fogParamsId.setValue(this._fogParams); + + this.extinctionId.setValue(this.extinction); + + this._scatterParams[0] = this.anisotropy; + this._scatterParams[1] = this.steps; + this._scatterParams[2] = this.noiseOffset; + this._scatterParams[3] = light ? light.shadowIntensity : 1; + this.scatterParamsId.setValue(this._scatterParams); + + super.execute(); + } +} + +export { RenderPassVolumetricFog }; diff --git a/src/scene/graphics/frame-pass-color-grab.js b/src/scene/graphics/frame-pass-color-grab.js index a2573085bb1..8a579319723 100644 --- a/src/scene/graphics/frame-pass-color-grab.js +++ b/src/scene/graphics/frame-pass-color-grab.js @@ -12,7 +12,8 @@ import { GAMMA_SRGB } from '../constants.js'; */ /** - * A render pass implementing grab of a color buffer. + * A frame pass grabbing the color buffer a camera renders into: it copies the color buffer to a + * texture with mipmaps, and publishes the texture as the scene color map of the camera. * * @ignore */ From fcc1368d710e13e3dbf80edd5a8079518fb99408 Mon Sep 17 00:00:00 2001 From: Martin Valigursky <59932779+mvaligursky@users.noreply.github.com> Date: Mon, 26 Oct 2026 16:11:04 +0000 Subject: [PATCH 15/22] refactor: Set render pass clears and stores through the setters (#9829) The engine code setting up render passes wrote the attachment operations directly: the shadow renderer and the lightmapper copied the clear color into clearValue, bypassing setClearColor, which also updates the linear clear value used for sRGB targets; the shadow renderer and the picker set the depth clear flags and value directly, and the shadow renderer and the camera frame set the depth stores. These now use setClearColor, setClearDepth and setStoreDepth. The gsplat processor, the SOG data, the gsplat work buffer pass and the cookie renderer cleared the clear flags right after init, which leaves the attachments uncleared already - those writes are removed. The frame graph still writes the stores directly, as the stores of the passes in a frame are its own to manage. Co-authored-by: Martin Valigursky --- src/extras/render-passes/frame-pass-camera-frame.js | 2 +- src/framework/graphics/picker.js | 2 +- src/framework/gsplat/gsplat-processor.js | 2 -- src/framework/lightmapper/lightmapper.js | 3 +-- .../gsplat-unified/gsplat-work-buffer-render-pass.js | 12 ------------ src/scene/gsplat/gsplat-sog-data.js | 2 -- src/scene/renderer/render-pass-cookie-renderer.js | 2 -- src/scene/renderer/shadow-renderer.js | 10 ++++------ 8 files changed, 7 insertions(+), 28 deletions(-) diff --git a/src/extras/render-passes/frame-pass-camera-frame.js b/src/extras/render-passes/frame-pass-camera-frame.js index 84db49f14d7..9db324a3d57 100644 --- a/src/extras/render-passes/frame-pass-camera-frame.js +++ b/src/extras/render-passes/frame-pass-camera-frame.js @@ -777,7 +777,7 @@ class FramePassCameraFrame extends FramePass { if (this.scenePassTransparent) { // if prepass is enabled, we need to store the depth, as by default it gets discarded if (options.prepassEnabled) { - this.scenePassTransparent.depthStencilOps.storeDepth = true; + this.scenePassTransparent.setStoreDepth(true); } } } diff --git a/src/framework/graphics/picker.js b/src/framework/graphics/picker.js index 7c205b2ef56..c2342d63670 100644 --- a/src/framework/graphics/picker.js +++ b/src/framework/graphics/picker.js @@ -524,7 +524,7 @@ class Picker { // set up clears - setClearColor handles MRT and clears all color buffers renderPass.setClearColor(Color.WHITE); - renderPass.depthStencilOps.clearDepth = true; + renderPass.setClearDepth(1); // render the pass to update the render target renderPass.update(camera, scene, layers, this.mapping, this.depth); diff --git a/src/framework/gsplat/gsplat-processor.js b/src/framework/gsplat/gsplat-processor.js index 954cd241d3e..9a08f8d412d 100644 --- a/src/framework/gsplat/gsplat-processor.js +++ b/src/framework/gsplat/gsplat-processor.js @@ -267,8 +267,6 @@ class GSplatProcessor { this._renderPass = new RenderPassShaderQuad(device); this._renderPass.quadRender = this._quadRender; this._renderPass.init(this._renderTarget); - this._renderPass.colorOps.clear = false; - this._renderPass.depthStencilOps.clearDepth = false; } /** diff --git a/src/framework/lightmapper/lightmapper.js b/src/framework/lightmapper/lightmapper.js index 7eee311ace6..f223be7fa7a 100644 --- a/src/framework/lightmapper/lightmapper.js +++ b/src/framework/lightmapper/lightmapper.js @@ -1111,8 +1111,7 @@ class Lightmapper { clusteredLightingEnabled ? this.worldClusters : null, rcv, lightList); renderPass.init(tempRT); - renderPass.colorOps.clear = true; - renderPass.colorOps.clearValue.copy(this.camera.clearColor); + renderPass.setClearColor(this.camera.clearColor); renderPass.render(); renderPass.destroy(); diff --git a/src/scene/gsplat-unified/gsplat-work-buffer-render-pass.js b/src/scene/gsplat-unified/gsplat-work-buffer-render-pass.js index c27ae25c308..6bac47127d3 100644 --- a/src/scene/gsplat-unified/gsplat-work-buffer-render-pass.js +++ b/src/scene/gsplat-unified/gsplat-work-buffer-render-pass.js @@ -14,7 +14,6 @@ import { lodDebugColorIndex } from './gsplat-lod-colors.js'; /** * @import { GSplatInfo } from './gsplat-info.js' * @import { GraphNode } from '../graph-node.js' - * @import { RenderTarget } from '../../platform/graphics/render-target.js' * @import { GSplatWorkBuffer } from './gsplat-work-buffer.js' */ @@ -105,17 +104,6 @@ class GSplatWorkBufferRenderPass extends RenderPass { super.destroy(); } - /** - * Initialize the render pass with the specified render target. - * - * @param {RenderTarget} renderTarget - The target to render to. - */ - init(renderTarget) { - super.init(renderTarget); - this.colorOps.clear = false; - this.depthStencilOps.clearDepth = false; - } - /** * Update the render pass with splats to render and camera. * diff --git a/src/scene/gsplat/gsplat-sog-data.js b/src/scene/gsplat/gsplat-sog-data.js index 389403a83a2..8b70d58d7bf 100644 --- a/src/scene/gsplat/gsplat-sog-data.js +++ b/src/scene/gsplat/gsplat-sog-data.js @@ -411,8 +411,6 @@ class GSplatSogData { renderPass.quadRender = quad; renderPass.name = 'SogGenerateCenters'; renderPass.init(renderTarget); - renderPass.colorOps.clear = false; - renderPass.depthStencilOps.clearDepth = false; renderPass.render(); quad.destroy(); diff --git a/src/scene/renderer/render-pass-cookie-renderer.js b/src/scene/renderer/render-pass-cookie-renderer.js index db2afdc324e..18bf750d985 100644 --- a/src/scene/renderer/render-pass-cookie-renderer.js +++ b/src/scene/renderer/render-pass-cookie-renderer.js @@ -72,8 +72,6 @@ class RenderPassCookieRenderer extends RenderPass { // prepare a single render pass to render all quads to the render target const renderPass = new RenderPassCookieRenderer(renderTarget.device, cubeSlotsOffsets); renderPass.init(renderTarget); - renderPass.colorOps.clear = false; - renderPass.depthStencilOps.clearDepth = false; return renderPass; } diff --git a/src/scene/renderer/shadow-renderer.js b/src/scene/renderer/shadow-renderer.js index 1fe32d82943..6ddc75c8e7c 100644 --- a/src/scene/renderer/shadow-renderer.js +++ b/src/scene/renderer/shadow-renderer.js @@ -702,19 +702,17 @@ class ShadowRenderer { const rt = shadowCamera.renderTarget; renderPass.init(rt); - renderPass.depthStencilOps.clearDepthValue = 1; - renderPass.depthStencilOps.clearDepth = clearRenderTarget; + renderPass.setClearDepth(clearRenderTarget ? 1 : undefined); // if rendering to depth buffer if (rt.depthBuffer) { - renderPass.depthStencilOps.storeDepth = true; + renderPass.setStoreDepth(true); } else { // rendering to color buffer - renderPass.colorOps.clearValue.copy(shadowCamera.clearColor); - renderPass.colorOps.clear = clearRenderTarget; - renderPass.depthStencilOps.storeDepth = false; + renderPass.setClearColor(clearRenderTarget ? shadowCamera.clearColor : undefined); + renderPass.setStoreDepth(false); } } From a0f86767267775475da32a8bb8778fe312a842fe Mon Sep 17 00:00:00 2001 From: Martin Valigursky <59932779+mvaligursky@users.noreply.github.com> Date: Mon, 26 Oct 2026 16:36:20 +0000 Subject: [PATCH 16/22] feat: Add RenderPass#resizeSource and copy the resize options of init (#9830) A render pass resizes its render target each frame to the size of its resize source, scaled, as set by the options of init. The options object was kept by reference and changed later through the internal options property, and there was no public way to change what the render target follows. - RenderPass#resizeSource sets what the render target is resized to: a texture or a render target, null for the back buffer, or undefined to not resize it. - init copies its options, so later changes to the object passed in have no effect. - scaleX and scaleY return 1 while the render target is not resized, and setting them then asserts instead of throwing. - null is allowed for resizeSource in the types. - The downsample, SMAA and TAA passes and the camera frame use resizeSource. The camera frame sets the scale of its prepass explicitly, which followed the scene pass only by sharing its options object. - The docs describe that the render target is resized in frameUpdate, so the passes updated before it in a frame see the previous size. Co-authored-by: Martin Valigursky --- .../effects/smaa/passes/frame-pass-smaa.js | 2 +- .../effects/taa/passes/render-pass-taa.js | 2 +- .../render-passes/frame-pass-camera-frame.js | 8 +- .../render-passes/render-pass-downsample.js | 2 +- src/platform/graphics/render-pass.js | 118 ++++++++++++------ test/platform/graphics/render-pass.test.mjs | 74 +++++++++++ 6 files changed, 166 insertions(+), 40 deletions(-) diff --git a/src/extras/effects/smaa/passes/frame-pass-smaa.js b/src/extras/effects/smaa/passes/frame-pass-smaa.js index 7259f257c33..e40273aa565 100644 --- a/src/extras/effects/smaa/passes/frame-pass-smaa.js +++ b/src/extras/effects/smaa/passes/frame-pass-smaa.js @@ -209,7 +209,7 @@ class FramePassSmaa extends FramePass { this.edgePass.setTexture('smaaColorTexture', texture); this.blendPass.setTexture('smaaColorTexture', texture); for (let i = 0; i < this.beforePasses.length; i++) { - this.beforePasses[i].options.resizeSource = texture; + this.beforePasses[i].resizeSource = texture; } } diff --git a/src/extras/effects/taa/passes/render-pass-taa.js b/src/extras/effects/taa/passes/render-pass-taa.js index 41ff11781b5..b4215566f0c 100644 --- a/src/extras/effects/taa/passes/render-pass-taa.js +++ b/src/extras/effects/taa/passes/render-pass-taa.js @@ -210,7 +210,7 @@ class RenderPassTAA extends RenderPassShaderQuad { setSourceTexture(texture) { this.sourceTexture = texture; if (!this.upscale) { - this.options.resizeSource = texture; + this.resizeSource = texture; } } diff --git a/src/extras/render-passes/frame-pass-camera-frame.js b/src/extras/render-passes/frame-pass-camera-frame.js index 9db324a3d57..d62904f671e 100644 --- a/src/extras/render-passes/frame-pass-camera-frame.js +++ b/src/extras/render-passes/frame-pass-camera-frame.js @@ -460,6 +460,12 @@ class FramePassCameraFrame extends FramePass { this.scenePass.scaleX = value; this.scenePass.scaleY = value; } + + // the prepass renders at the size of the scene pass + if (this.prePass) { + this.prePass.scaleX = value; + this.prePass.scaleY = value; + } } get renderTargetScale() { @@ -979,7 +985,7 @@ class FramePassCameraFrame extends FramePass { // the render target still has the previous size - this evaluates the size the same way the // scene pass does when it resizes it, for what needs it before that pass has updated. const { scenePass } = this; - const resizeSource = scenePass.options.resizeSource ?? this.device.backBuffer; + const resizeSource = scenePass.resizeSource ?? this.device.backBuffer; const renderWidth = Math.floor(resizeSource.width * scenePass.scaleX); const renderHeight = Math.floor(resizeSource.height * scenePass.scaleY); diff --git a/src/extras/render-passes/render-pass-downsample.js b/src/extras/render-passes/render-pass-downsample.js index ba9ccbdacb1..0131b7ed323 100644 --- a/src/extras/render-passes/render-pass-downsample.js +++ b/src/extras/render-passes/render-pass-downsample.js @@ -105,7 +105,7 @@ class RenderPassDownsample extends RenderPassShaderQuad { this.sourceTexture = value; // change resize source - this.options.resizeSource = value; + this.resizeSource = value; } execute() { diff --git a/src/platform/graphics/render-pass.js b/src/platform/graphics/render-pass.js index d2d1d2fb9ae..35460d83201 100644 --- a/src/platform/graphics/render-pass.js +++ b/src/platform/graphics/render-pass.js @@ -180,7 +180,7 @@ class DepthStencilAttachmentOps { * into, or with null to draw into the back buffer. The pass does not own its render target, and * does not destroy it. When init is given options, the pass also resizes its render target each * frame, to the size of a texture, a render target or the back buffer, scaled by - * {@link RenderPass#scaleX} and {@link RenderPass#scaleY}. + * {@link RenderPass#scaleX} and {@link RenderPass#scaleY}, see {@link RenderPass#resizeSource}. * * ## Loading and clearing the attachments * @@ -279,9 +279,10 @@ class RenderPass extends FramePass { renderTarget; /** - * The options specified when the render target was initialized. + * How the render target is resized: what to, null for the back buffer, and the scale factors. + * Undefined when the render target is not resized. * - * @type {object|undefined} + * @type {{ resizeSource: Texture|RenderTarget|null, scaleX: number, scaleY: number }|undefined} * @private */ _options; @@ -329,65 +330,107 @@ class RenderPass extends FramePass { fullSizeClearRect = true; /** - * Sets the scale factor of the width of the render target, relative to the size of the resize - * source, see {@link RenderPass#init}. Only valid for a pass initialized with options. + * Sets what the render target of the pass is resized to each frame: a texture or a render + * target, whose size, scaled by {@link RenderPass#scaleX} and {@link RenderPass#scaleY}, the + * render target takes, or null to take the size of the back buffer. Undefined, the default, + * leaves the size of the render target as it is. The options of {@link RenderPass#init} set + * it, and init resets it, so set it once the pass is initialized. Change it when the size is to + * follow another texture, for example when the texture a pass processes is replaced. + * + * The render target is resized while the frame is built, in {@link RenderPass#frameUpdate}, and + * has the new size only once the pass is updated for the frame. On a frame the size changes, + * the passes updated before it in the frame still see the previous size, including the pass it + * is a before pass of, which is updated before its before passes. + * + * @type {Texture|RenderTarget|null|undefined} + */ + set resizeSource(value) { + if (value === undefined) { + this._options = undefined; + } else if (this._options) { + this._options.resizeSource = value; + } else { + this._options = { resizeSource: value, scaleX: 1, scaleY: 1 }; + } + } + + /** + * Gets what the render target of the pass is resized to. + * + * @type {Texture|RenderTarget|null|undefined} + */ + get resizeSource() { + return this._options ? this._options.resizeSource : undefined; + } + + /** + * Sets the scale factor of the width of the render target, relative to the size of its resize + * source, see {@link RenderPass#resizeSource}. Only valid while the render target is resized. + * Defaults to 1. * * @type {number} */ set scaleX(value) { - Debug.assert(this._options, 'The render pass needs to be initialized first.'); - this._options.scaleX = value; + Debug.assert(this._options, `The scaleX of the render pass '${this.name}' is set, but the pass does not resize its render target. Set its resizeSource first.`, this); + if (this._options) { + this._options.scaleX = value; + } } /** - * Gets the scale factor of the width of the render target. + * Gets the scale factor of the width of the render target. It is 1 while the render target is + * not resized. * * @type {number} */ get scaleX() { - return this._options.scaleX; + return this._options?.scaleX ?? 1; } /** - * Sets the scale factor of the height of the render target, relative to the size of the resize - * source, see {@link RenderPass#init}. Only valid for a pass initialized with options. + * Sets the scale factor of the height of the render target, relative to the size of its resize + * source, see {@link RenderPass#resizeSource}. Only valid while the render target is resized. + * Defaults to 1. * * @type {number} */ set scaleY(value) { - Debug.assert(this._options, 'The render pass needs to be initialized first.'); - this._options.scaleY = value; + Debug.assert(this._options, `The scaleY of the render pass '${this.name}' is set, but the pass does not resize its render target. Set its resizeSource first.`, this); + if (this._options) { + this._options.scaleY = value; + } } /** - * Gets the scale factor of the height of the render target. + * Gets the scale factor of the height of the render target. It is 1 while the render target + * is not resized. * * @type {number} */ get scaleY() { - return this._options.scaleY; + return this._options?.scaleY ?? 1; } /** - * Sets the options the pass was initialized with. + * Sets how the render target is resized, from the options of {@link RenderPass#init}, which + * are copied, so the object passed in is not kept. * - * @type {object|undefined} + * @param {{ resizeSource?: Texture|RenderTarget|null, scaleX?: number, scaleY?: number }|undefined} value - The + * options, or undefined to not resize the render target. * @ignore */ set options(value) { - this._options = value; - - // sanitize options - if (value) { - this.scaleX = this.scaleX ?? 1; - this.scaleY = this.scaleY ?? 1; - } + this._options = value ? { + resizeSource: value.resizeSource ?? null, + scaleX: value.scaleX ?? 1, + scaleY: value.scaleY ?? 1 + } : undefined; } /** - * Gets the options the pass was initialized with. + * Gets how the render target is resized. * - * @type {object|undefined} + * @type {{ resizeSource: Texture|RenderTarget|null, scaleX: number, scaleY: number }|undefined} * @ignore */ get options() { @@ -409,12 +452,14 @@ class RenderPass extends FramePass { * When options are provided, the render target is resized each frame, in * {@link RenderPass#frameUpdate}, to the size of the resize source scaled by * {@link RenderPass#scaleX} and {@link RenderPass#scaleY}. Without options, it is not resized. + * The options are copied, so later changes to the object passed in have no effect - change + * them with {@link RenderPass#resizeSource}, scaleX and scaleY instead. * * @param {RenderTarget|null} [renderTarget] - The render target to draw into, or null to draw * into the back buffer. Defaults to null. The render pass does not own the render target. * @param {object} [options] - Object for passing optional arguments. - * @param {Texture|RenderTarget} [options.resizeSource] - The texture or render target whose - * size the render target is resized to. Defaults to the back buffer. + * @param {Texture|RenderTarget|null} [options.resizeSource] - The texture or render target whose + * size the render target is resized to, or null for the back buffer. Defaults to null. * @param {number} [options.scaleX] - The scale factor for the render target width. Defaults to 1. * @param {number} [options.scaleY] - The scale factor for the render target height. Defaults to 1. */ @@ -505,17 +550,18 @@ class RenderPass extends FramePass { } /** - * Resizes the render target to the size of the resize source, scaled by - * {@link RenderPass#scaleX} and {@link RenderPass#scaleY}, when the pass was initialized with - * options. See {@link FramePass#frameUpdate}. Subclasses overriding it call - * `super.frameUpdate()` to keep the resize. + * Resizes the render target to the size of its resize source, scaled by + * {@link RenderPass#scaleX} and {@link RenderPass#scaleY}, while the pass resizes its render + * target, see {@link RenderPass#resizeSource}. See {@link FramePass#frameUpdate}. Subclasses + * overriding it call `super.frameUpdate()` to keep the resize. */ frameUpdate() { // resize the render target if needed - if (this._options && this.renderTarget) { - const resizeSource = this._options.resizeSource ?? this.device.backBuffer; - const width = Math.floor(resizeSource.width * this.scaleX); - const height = Math.floor(resizeSource.height * this.scaleY); + const options = this._options; + if (options && this.renderTarget) { + const resizeSource = /** @type {Texture|RenderTarget} */ (options.resizeSource ?? this.device.backBuffer); + const width = Math.floor(resizeSource.width * options.scaleX); + const height = Math.floor(resizeSource.height * options.scaleY); this.renderTarget.resize(width, height); } } diff --git a/test/platform/graphics/render-pass.test.mjs b/test/platform/graphics/render-pass.test.mjs index a9387acb1ab..a565b35166f 100644 --- a/test/platform/graphics/render-pass.test.mjs +++ b/test/platform/graphics/render-pass.test.mjs @@ -173,6 +173,80 @@ describe('RenderPass', function () { }); + describe('#resizeSource', function () { + + afterEach(function () { + restore(); + }); + + const sizeOf = (width, height) => ({ width, height }); + + it('does not resize the render target by default', function () { + const resize = stub(renderTarget, 'resize'); + renderPass.frameUpdate(); + + expect(renderPass.resizeSource).to.equal(undefined); + expect(resize.called).to.equal(false); + }); + + it('resizes the render target to the resize source, scaled', function () { + const resize = stub(renderTarget, 'resize'); + renderPass.init(renderTarget, { resizeSource: sizeOf(200, 100), scaleX: 0.5, scaleY: 0.25 }); + renderPass.frameUpdate(); + + expect(resize.calledOnceWith(100, 25)).to.equal(true); + }); + + it('resizes the render target to the back buffer when null', function () { + const resize = stub(renderTarget, 'resize'); + renderPass.resizeSource = null; + renderPass.frameUpdate(); + + expect(renderPass.resizeSource).to.equal(null); + expect(resize.calledOnceWith(device.backBuffer.width, device.backBuffer.height)).to.equal(true); + }); + + it('follows a changed resize source, and stops resizing when undefined', function () { + const resize = stub(renderTarget, 'resize'); + renderPass.init(renderTarget, { resizeSource: sizeOf(200, 100), scaleX: 0.5 }); + + renderPass.resizeSource = sizeOf(64, 32); + renderPass.frameUpdate(); + expect(resize.calledOnceWith(32, 32)).to.equal(true); + + renderPass.resizeSource = undefined; + renderPass.frameUpdate(); + expect(resize.calledOnce).to.equal(true); + expect(renderPass.scaleX).to.equal(1); + }); + + it('copies the options of init', function () { + const resize = stub(renderTarget, 'resize'); + const options = { resizeSource: sizeOf(200, 100), scaleX: 0.5, scaleY: 0.5 }; + renderPass.init(renderTarget, options); + + options.resizeSource = sizeOf(8, 8); + options.scaleX = 1; + renderPass.frameUpdate(); + + expect(renderPass.scaleX).to.equal(0.5); + expect(resize.calledOnceWith(100, 50)).to.equal(true); + }); + + it('has scale factors of 1 while not resizing, and asserts when they are set', function () { + expect(renderPass.scaleX).to.equal(1); + expect(renderPass.scaleY).to.equal(1); + + const assertion = stub(Debug, 'assert'); + expect(() => { + renderPass.scaleX = 0.5; + }).to.not.throw(); + expect(assertion.getCalls().some(c => !c.args[0])).to.equal(true); + expect(renderPass.scaleX).to.equal(1); + }); + + }); + describe('#init again', function () { it('reuses the attachment operations, reset to their defaults', function () { From 58b2b9e0b8a993be641abe5d0ab9d4fa3ba48dba Mon Sep 17 00:00:00 2001 From: Martin Valigursky <59932779+mvaligursky@users.noreply.github.com> Date: Mon, 26 Oct 2026 16:40:06 +0000 Subject: [PATCH 17/22] fix: Use each camera's own cascade split distances for directional shadows (#9831) The cull of every camera wrote the split distances to the light, and all cameras are culled before any of them renders, so every camera rendered with the split distances of the camera culled last. The cull now stores them per camera, and the camera's shadow pass copies them to the light when it renders the shared shadow map, as with the cascade matrices. Fixed #9790 Co-authored-by: Martin Valigursky --- src/scene/light.js | 6 ++++ .../render-pass-shadow-directional.js | 7 ++++ .../renderer/shadow-renderer-directional.js | 17 +++++----- .../directional-shadow-cull-requests.test.mjs | 34 +++++++++++++++++++ 4 files changed, 56 insertions(+), 8 deletions(-) diff --git a/src/scene/light.js b/src/scene/light.js index 3f321beb1a5..b430d86c79e 100644 --- a/src/scene/light.js +++ b/src/scene/light.js @@ -110,6 +110,12 @@ class LightRenderData { // On directional face 0, the cascades requested by this camera's scheduled shadow passes. this.shadowCascadeMask = 0; + // On directional face 0, the cascade split distances the cull fitted to this camera. All + // cameras are culled before any of them renders, so the camera's shadow pass copies them to + // the light when it renders the shared shadow map, as it does with the cascade matrices. + /** @type {Float32Array|null} */ + this.shadowCascadeDistances = null; + // Retain PCSS caster bounds so cached cascades remain part of the depth-fitting union. /** @type {BoundingBox|null} */ this.shadowCasterAabb = null; diff --git a/src/scene/renderer/render-pass-shadow-directional.js b/src/scene/renderer/render-pass-shadow-directional.js index 6892c5cbb5e..e9a0508bc35 100644 --- a/src/scene/renderer/render-pass-shadow-directional.js +++ b/src/scene/renderer/render-pass-shadow-directional.js @@ -35,6 +35,13 @@ class RenderPassShadowDirectional extends RenderPass { const faceCount = light.numShadowFaces; const shadowUpdateOverrides = light.shadowUpdateOverrides; + // the shadow map is shared by the cameras, which are all culled before any of them renders, + // so the split distances this camera's cascades are fitted to are set as the map renders. + // A light with shadow bounds has no camera and no split distances + if (camera) { + light._shadowCascadeDistances.set(light.getRenderData(camera, 0).shadowCascadeDistances); + } + // Use the scheduled cascades even if an earlier camera consumed their one-shot overrides. for (let face = 0; face < faceCount; face++) { if (cascadeMask & (1 << face)) { diff --git a/src/scene/renderer/shadow-renderer-directional.js b/src/scene/renderer/shadow-renderer-directional.js index 43f6c8948d8..d805393e533 100644 --- a/src/scene/renderer/shadow-renderer-directional.js +++ b/src/scene/renderer/shadow-renderer-directional.js @@ -221,9 +221,10 @@ class ShadowRendererDirectional { Debug.assert(light._shadowMap, 'ShadowRendererDirectional.cull requires prepareShadowMap() to have been called for the light first.'); - // generate splits for the cascades + // generate splits for the cascades, stored per camera const nearDist = camera._nearClip; - this.generateSplitDistances(light, nearDist, Math.min(camera._farClip, light.shadowDistance)); + const splitDistances = light.getRenderData(camera, 0).shadowCascadeDistances ??= new Float32Array(4); + this.generateSplitDistances(light, nearDist, Math.min(camera._farClip, light.shadowDistance), splitDistances); // PASS 1: per-cascade culling + per-cascade visible-caster AABB building. Defers the depth-range // tightening (which moves the shadow camera and sets farClip) until pass 2 so that PCSS @@ -253,8 +254,8 @@ class ShadowRendererDirectional { shadowCamNode.rotateLocal(-90, 0, 0); // get camera's frustum corners for the cascade, convert them to world space and find their center - const frustumNearDist = cascade === 0 ? nearDist : light._shadowCascadeDistances[cascade - 1]; - const frustumFarDist = light._shadowCascadeDistances[cascade]; + const frustumNearDist = cascade === 0 ? nearDist : splitDistances[cascade - 1]; + const frustumFarDist = splitDistances[cascade]; const frustumPoints = camera.getFrustumCorners(frustumNearDist, frustumFarDist); center.set(0, 0, 0); const cameraWorldMat = camera.node.getWorldTransform(); @@ -389,10 +390,10 @@ class ShadowRendererDirectional { } } - // function to generate frustum split distances - generateSplitDistances(light, nearDist, farDist) { + // function to generate frustum split distances into the array of 4 distances + generateSplitDistances(light, nearDist, farDist, distances) { - light._shadowCascadeDistances.fill(farDist); + distances.fill(farDist); for (let i = 1; i < light.numCascades; i++) { // lerp between linear and logarithmic distance, called practical split distance @@ -400,7 +401,7 @@ class ShadowRendererDirectional { const linearDist = nearDist + (farDist - nearDist) * fraction; const logDist = nearDist * (farDist / nearDist) ** fraction; const dist = math.lerp(linearDist, logDist, light.cascadeDistribution); - light._shadowCascadeDistances[i - 1] = dist; + distances[i - 1] = dist; } } diff --git a/test/scene/renderer/directional-shadow-cull-requests.test.mjs b/test/scene/renderer/directional-shadow-cull-requests.test.mjs index b246e27b2af..37cff41b391 100644 --- a/test/scene/renderer/directional-shadow-cull-requests.test.mjs +++ b/test/scene/renderer/directional-shadow-cull-requests.test.mjs @@ -4,6 +4,7 @@ import sinon from 'sinon'; import { Entity } from '../../../src/framework/entity.js'; import { FramePass } from '../../../src/platform/graphics/frame-pass.js'; import { SHADOWUPDATE_NONE, SHADOWUPDATE_THISFRAME, SHADOWUPDATE_REALTIME, SHADOW_PCF3_32F, SHADOW_PCSS_32F, SHADOW_PCF5_32F } from '../../../src/scene/constants.js'; +import { LightSlotUniforms } from '../../../src/scene/lighting/light-slot-uniforms.js'; import { createApp } from '../../app.mjs'; import { jsdomSetup, jsdomTeardown } from '../../jsdom.mjs'; @@ -157,6 +158,39 @@ describe('Directional shadow cull requests', function () { expect(renderFace.callCount).to.equal(4); }); + [1, 4].forEach((numCascades) => { + it(`renders each camera with its own split distances with ${numCascades} cascades`, function () { + light.numCascades = numCascades; + light.shadowDistance = 40; + camera.nearClip = 0.1; + camera.farClip = 1000; + const second = createCamera(); + second.nearClip = 1; + second.farClip = 15; + + // the split distances the forward passes of each camera render with + const dispatched = new Map(); + const dispatch = LightSlotUniforms.prototype._dispatchDirectional; + sinon.stub(LightSlotUniforms.prototype, '_dispatchDirectional').callsFake(function (directional, cam) { + if (!dispatched.has(cam)) dispatched.set(cam, []); + dispatched.get(cam).push(Array.from(directional._shadowCascadeDistances)); + return dispatch.call(this, directional, cam); + }); + + app.render(); + + // the last split is the shadow distance of the camera: min(farClip, shadowDistance) + for (const [cam, shadowDistance] of [[camera.camera, 40], [second.camera, 15]]) { + const own = light.getRenderData(cam, 0).shadowCascadeDistances; + expect(dispatched.get(cam)).to.not.be.empty; + dispatched.get(cam).forEach((distances) => { + expect(distances[3]).to.be.closeTo(shadowDistance, 1e-5); + expect(distances).to.eql(Array.from(own)); + }); + } + }); + }); + it('binds cached shadows for a newly added camera without requesting a cull', function () { light.shadowUpdateMode = SHADOWUPDATE_THISFRAME; app.render(); From f46413832ffc5687cd8c900b335169999186580d Mon Sep 17 00:00:00 2001 From: Martin Valigursky <59932779+mvaligursky@users.noreply.github.com> Date: Mon, 26 Oct 2026 17:20:40 +0000 Subject: [PATCH 18/22] perf: Resolve render pass uniforms once instead of every frame (#9833) RenderPassSsao resolved its uniforms on the device scope and created arrays for its vector uniforms every frame. It now resolves them once, when it is created, and reuses the arrays. The render-pass-camera and xr-views examples follow the same pattern, and the render-pass-camera example names its passes. Co-authored-by: Martin Valigursky --- .../render-pass-camera.example.mjs | 17 +++- .../src/examples/test/xr-views.example.mjs | 3 +- .../effects/ssao/passes/render-pass-ssao.js | 77 ++++++++++++++----- 3 files changed, 75 insertions(+), 22 deletions(-) diff --git a/examples/src/examples/render-targets/render-pass-camera.example.mjs b/examples/src/examples/render-targets/render-pass-camera.example.mjs index 104861f5b0c..82415809d1e 100644 --- a/examples/src/examples/render-targets/render-pass-camera.example.mjs +++ b/examples/src/examples/render-targets/render-pass-camera.example.mjs @@ -50,6 +50,7 @@ window.focus(); class RenderPassTint extends RenderPassShaderQuad { constructor(device, sourceTexture) { super(device); + this.name = 'RenderPassTint'; this.sourceTexture = sourceTexture; this.tint = Color.WHITE.clone(); @@ -84,11 +85,21 @@ class RenderPassTint extends RenderPassShaderQuad { } ` }); + + // resolve the parameters once, and reuse the array the tint is passed in + this.sourceTextureId = device.scope.resolve('sourceTexture'); + this.tintId = device.scope.resolve('tint'); + this.tintValue = new Float32Array(3); } execute() { - this.device.scope.resolve('sourceTexture').setValue(this.sourceTexture); - this.device.scope.resolve('tint').setValue([this.tint.r, this.tint.g, this.tint.b]); + // set the parameters right before the quad is drawn + this.sourceTextureId.setValue(this.sourceTexture); + const { tint, tintValue } = this; + tintValue[0] = tint.r; + tintValue[1] = tint.g; + tintValue[2] = tint.b; + this.tintId.setValue(tintValue); super.execute(); } } @@ -199,6 +210,8 @@ const renderPass = new RenderPassForward( cameraEntity.camera ); +renderPass.name = 'RenderPassScene'; + // This render pass resizes the texture to match the size of are on the scene we render to renderPass.init(rt, { resizeSource: null diff --git a/examples/src/examples/test/xr-views.example.mjs b/examples/src/examples/test/xr-views.example.mjs index 2a6db7bd42e..9a11b768892 100644 --- a/examples/src/examples/test/xr-views.example.mjs +++ b/examples/src/examples/test/xr-views.example.mjs @@ -87,6 +87,7 @@ class CompositeArrayPass extends RenderPassShaderQuad { super(graphicsDevice); this.name = 'CompositeArrayPass'; this.sourceTexture = sourceTexture; + this.sourceTextureId = graphicsDevice.scope.resolve('sourceTexture'); this.numViews = numViews; this.shader = ShaderUtils.createShader(graphicsDevice, { @@ -153,7 +154,7 @@ class CompositeArrayPass extends RenderPassShaderQuad { } execute() { - this.device.scope.resolve('sourceTexture').setValue(this.sourceTexture); + this.sourceTextureId.setValue(this.sourceTexture); super.execute(); } } diff --git a/src/extras/effects/ssao/passes/render-pass-ssao.js b/src/extras/effects/ssao/passes/render-pass-ssao.js index ecf276221a5..727cf33bee1 100644 --- a/src/extras/effects/ssao/passes/render-pass-ssao.js +++ b/src/extras/effects/ssao/passes/render-pass-ssao.js @@ -67,6 +67,15 @@ class RenderPassSsao extends RenderPassShaderQuad { _blueNoise = new BlueNoise(19); + // the values of the vector uniforms, reused every frame + _invResolution = new Float32Array(2); + + _sampleCount = new Float32Array(2); + + _angleIncCosSin = new Float32Array(2); + + _ssaoTextureSizeInv = new Float32Array(2); + constructor(device, sourceTexture, cameraComponent, blurEnabled) { super(device); this.sourceTexture = sourceTexture; @@ -123,8 +132,24 @@ class RenderPassSsao extends RenderPassShaderQuad { this.afterPasses.push(blurPassVertical); } - this.ssaoTextureId = device.scope.resolve('ssaoTexture'); - this.ssaoTextureSizeInvId = device.scope.resolve('ssaoTextureSizeInv'); + // the uniforms, resolved once + const scope = device.scope; + this.ssaoTextureId = scope.resolve('ssaoTexture'); + this.ssaoTextureSizeInvId = scope.resolve('ssaoTextureSizeInv'); + this.aspectId = scope.resolve('uAspect'); + this.invResolutionId = scope.resolve('uInvResolution'); + this.sampleCountId = scope.resolve('uSampleCount'); + this.minHorizonAngleSineSquaredId = scope.resolve('uMinHorizonAngleSineSquared'); + this.spiralTurnsId = scope.resolve('uSpiralTurns'); + this.angleIncCosSinId = scope.resolve('uAngleIncCosSin'); + this.maxLevelId = scope.resolve('uMaxLevel'); + this.invRadiusSquaredId = scope.resolve('uInvRadiusSquared'); + this.biasId = scope.resolve('uBias'); + this.peak2Id = scope.resolve('uPeak2'); + this.intensityId = scope.resolve('uIntensity'); + this.powerId = scope.resolve('uPower'); + this.projectionScaleRadiusId = scope.resolve('uProjectionScaleRadius'); + this.randomizeId = scope.resolve('uRandomize'); } destroy() { @@ -169,17 +194,23 @@ class RenderPassSsao extends RenderPassShaderQuad { execute() { - const { device, sampleCount, minAngle } = this; + const { sampleCount, minAngle } = this; const { width, height } = this.renderTarget.colorBuffer; - const scope = device.scope; - scope.resolve('uAspect').setValue(width / height); - scope.resolve('uInvResolution').setValue([1.0 / width, 1.0 / height]); + this.aspectId.setValue(width / height); + + const invResolution = this._invResolution; + invResolution[0] = 1.0 / width; + invResolution[1] = 1.0 / height; + this.invResolutionId.setValue(invResolution); - scope.resolve('uSampleCount').setValue([sampleCount, 1.0 / sampleCount]); + const sampleCountValue = this._sampleCount; + sampleCountValue[0] = sampleCount; + sampleCountValue[1] = 1.0 / sampleCount; + this.sampleCountId.setValue(sampleCountValue); const minAngleSin = Math.sin(minAngle * math.DEG_TO_RAD); - scope.resolve('uMinHorizonAngleSineSquared').setValue(minAngleSin * minAngleSin); + this.minHorizonAngleSineSquaredId.setValue(minAngleSin * minAngleSin); const spiralTurns = 10.0; const step = (1.0 / (sampleCount - 0.5)) * spiralTurns * 2.0 * 3.141; @@ -195,16 +226,21 @@ class RenderPassSsao extends RenderPassShaderQuad { // scale the projection based on the actual (possibly scaled) render target height, so the // sampling disk covers the same screen area regardless of the scale const projectionScale = 0.5 * height; - scope.resolve('uSpiralTurns').setValue(spiralTurns); - scope.resolve('uAngleIncCosSin').setValue([Math.cos(step), Math.sin(step)]); - scope.resolve('uMaxLevel').setValue(0.0); - scope.resolve('uInvRadiusSquared').setValue(1.0 / (radius * radius)); - scope.resolve('uBias').setValue(bias); - scope.resolve('uPeak2').setValue(peak * peak); - scope.resolve('uIntensity').setValue(intensity); - scope.resolve('uPower').setValue(this.power); - scope.resolve('uProjectionScaleRadius').setValue(projectionScale * radius); - scope.resolve('uRandomize').setValue(this.randomize ? this._blueNoise.value() : 0); + this.spiralTurnsId.setValue(spiralTurns); + + const angleIncCosSin = this._angleIncCosSin; + angleIncCosSin[0] = Math.cos(step); + angleIncCosSin[1] = Math.sin(step); + this.angleIncCosSinId.setValue(angleIncCosSin); + + this.maxLevelId.setValue(0.0); + this.invRadiusSquaredId.setValue(1.0 / (radius * radius)); + this.biasId.setValue(bias); + this.peak2Id.setValue(peak * peak); + this.intensityId.setValue(intensity); + this.powerId.setValue(this.power); + this.projectionScaleRadiusId.setValue(projectionScale * radius); + this.randomizeId.setValue(this.randomize ? this._blueNoise.value() : 0); super.execute(); } @@ -213,7 +249,10 @@ class RenderPassSsao extends RenderPassShaderQuad { this.ssaoTextureId.setValue(this.ssaoTexture); const srcTexture = this.sourceTexture; - this.ssaoTextureSizeInvId.setValue([1.0 / srcTexture.width, 1.0 / srcTexture.height]); + const sizeInv = this._ssaoTextureSizeInv; + sizeInv[0] = 1.0 / srcTexture.width; + sizeInv[1] = 1.0 / srcTexture.height; + this.ssaoTextureSizeInvId.setValue(sizeInv); } } From bbf90dfe0d141a0776d2557459b91851200d27eb Mon Sep 17 00:00:00 2001 From: Martin Valigursky <59932779+mvaligursky@users.noreply.github.com> Date: Mon, 26 Oct 2026 17:30:57 +0000 Subject: [PATCH 19/22] Add RenderPassShaderQuad#numInstances (#9832) * feat: Add RenderPassShaderQuad#numInstances RenderPassShaderQuad drew a single quad, so passes needing instancing or several draws copied its execute: the lens flare sprites drew their elements instanced, and the volumetric fog local pass drew a quad for each light. - RenderPassShaderQuad#numInstances sets the number of instances of the quad to draw, undefined by default for a single quad without instancing. - The class doc describes drawing several quads: instances for quads which differ only by their index, or super.execute() once for each draw. - The lens flare sprites and the volumetric fog local passes use super.execute() instead of copying it. * fix: Draw nothing for zero instances in RenderPassShaderQuad Passing zero instances through to the device drew a single quad without instancing on WebGL2, while WebGPU drew nothing. The pass now skips the draw when numInstances is zero, and still renders, so it clears its render target. --------- Co-authored-by: Martin Valigursky --- .../passes/render-pass-lens-flare-sprites.js | 4 +- .../render-pass-volumetric-fog-local.js | 6 +-- src/scene/graphics/render-pass-shader-quad.js | 24 +++++++++++- .../graphics/render-pass-shader-quad.test.mjs | 38 +++++++++++++++++++ 4 files changed, 63 insertions(+), 9 deletions(-) diff --git a/src/extras/effects/lens-flare/passes/render-pass-lens-flare-sprites.js b/src/extras/effects/lens-flare/passes/render-pass-lens-flare-sprites.js index e440d42b275..3854326ac10 100644 --- a/src/extras/effects/lens-flare/passes/render-pass-lens-flare-sprites.js +++ b/src/extras/effects/lens-flare/passes/render-pass-lens-flare-sprites.js @@ -101,8 +101,8 @@ class RenderPassLensFlareSprites extends RenderPassShaderQuad { this.visibilityId.setValue(visibilityTexture); this.dataId.setValue(flareData); - this.device.setDrawStates(this.blendState, this.depthState, this.cullMode, this.frontFace, this.stencilFront, this.stencilBack); - this.quadRender.render(undefined, undefined, numInstances); + // the quads of the elements, one instance each, see numInstances + super.execute(); } } diff --git a/src/extras/effects/volumetric-fog/passes/render-pass-volumetric-fog-local.js b/src/extras/effects/volumetric-fog/passes/render-pass-volumetric-fog-local.js index 91647a1352a..4441261f1ef 100644 --- a/src/extras/effects/volumetric-fog/passes/render-pass-volumetric-fog-local.js +++ b/src/extras/effects/volumetric-fog/passes/render-pass-volumetric-fog-local.js @@ -389,10 +389,6 @@ class RenderPassVolumetricFogLocal extends RenderPassShaderQuad { this._marchParams[3] = this.extinction; this.marchParamsId.setValue(this._marchParams); - // render state, shared by all the lights - this.device.setDrawStates(this.blendState, this.depthState, this.cullMode, this.frontFace, - this.stencilFront, this.stencilBack); - // a quad per light, covering the screen space bounds of its volume. Index 0 of the cluster // lights is reserved for 'no light'. const { shadowsEnabled, cookiesEnabled } = lighting; @@ -406,7 +402,7 @@ class RenderPassVolumetricFogLocal extends RenderPassShaderQuad { (light._type === LIGHTTYPE_SPOT ? spotLights : omniLights); if (enabled && this._setupLight(light, shadowsEnabled, cookiesEnabled)) { - this.quadRender.render(); + super.execute(); } } } diff --git a/src/scene/graphics/render-pass-shader-quad.js b/src/scene/graphics/render-pass-shader-quad.js index 5b0dca9580c..004a4e74f1d 100644 --- a/src/scene/graphics/render-pass-shader-quad.js +++ b/src/scene/graphics/render-pass-shader-quad.js @@ -46,6 +46,14 @@ import { RenderPass } from '../../platform/graphics/render-pass.js'; * {@link RenderPassShaderQuad#depthState} or the stencil parameters. To draw into a part of the * render target only, set its {@link RenderPassShaderQuad#viewport}. * + * ## Drawing several quads + * + * To draw many quads which differ only by the data each reads for its index, such as sprites, set + * {@link RenderPassShaderQuad#numInstances} to draw them in a single instanced draw. To draw + * several quads with different parameters, for example one for each light, override + * {@link FramePass#execute}, and for each quad set its parameters, or its viewport, and call + * `super.execute()`, which draws it with the render states of the pass. + * * @example * ```javascript * // A pass drawing a texture into its render target, multiplied by a color @@ -194,6 +202,17 @@ class RenderPassShaderQuad extends RenderPass { */ scissor; + /** + * The number of instances of the quad to draw, using instanced drawing, or undefined to draw a + * single quad without instancing. Each instance can read its index, `gl_InstanceID` in GLSL + * and `pcInstanceIndex` in WGSL, to fetch the data of its quad, for example to place it. Zero + * draws nothing, while the pass still renders, so it still clears its render target. + * Defaults to undefined. + * + * @type {number|undefined} + */ + numInstances; + /** * Sets the shader used to render the quad, or null to render nothing. The vertex shader * receives the quad corners in an attribute using {@link SEMANTIC_POSITION}, and the `quadVS` @@ -243,10 +262,11 @@ class RenderPassShaderQuad extends RenderPass { // render state device.setDrawStates(this.blendState, this.depthState, this.cullMode, this.frontFace, this.stencilFront, this.stencilBack); + // zero instances draws nothing - the device would draw a single quad without instancing const quadRender = this.quadRender; - if (quadRender) { + if (quadRender && this.numInstances !== 0) { DebugGraphics.pushGpuMarker(device, `${this.name}:${this._shader?.name}`); - quadRender.render(this.viewport, this.scissor); + quadRender.render(this.viewport, this.scissor, this.numInstances); DebugGraphics.popGpuMarker(device); } } diff --git a/test/scene/graphics/render-pass-shader-quad.test.mjs b/test/scene/graphics/render-pass-shader-quad.test.mjs index bacd41c65d6..660645615b3 100644 --- a/test/scene/graphics/render-pass-shader-quad.test.mjs +++ b/test/scene/graphics/render-pass-shader-quad.test.mjs @@ -69,6 +69,44 @@ describe('RenderPassShaderQuad', function () { }); + describe('#numInstances', function () { + + it('draws a single quad by default, and the instances when set', function () { + const device = app.graphicsDevice; + const pass = new RenderPassShaderQuad(device); + pass.shader = createShader(device); + const render = sinon.stub(pass.quadRender, 'render'); + + expect(pass.numInstances).to.equal(undefined); + pass.execute(); + expect(render.lastCall.args[2]).to.equal(undefined); + + pass.numInstances = 5; + pass.execute(); + expect(render.lastCall.args[2]).to.equal(5); + + pass.destroy(); + }); + + it('draws nothing for zero instances', function () { + const device = app.graphicsDevice; + const pass = new RenderPassShaderQuad(device); + pass.shader = createShader(device); + const render = sinon.stub(pass.quadRender, 'render'); + + pass.numInstances = 0; + pass.execute(); + expect(render.called).to.equal(false); + + pass.numInstances = undefined; + pass.execute(); + expect(render.calledOnce).to.equal(true); + + pass.destroy(); + }); + + }); + describe('#destroy', function () { it('releases the quad, but not the shader', function () { From c35a3de1e0f3ef4ed80acd6057099e4913f7b70f Mon Sep 17 00:00:00 2001 From: Martin Valigursky <59932779+mvaligursky@users.noreply.github.com> Date: Mon, 26 Oct 2026 17:32:14 +0000 Subject: [PATCH 20/22] fix: Draw nothing for zero instances on WebGL, matching WebGPU (#9834) GraphicsDevice#draw with numInstances of zero drew the primitive once without instancing on WebGL2, while WebGPU drew no instances. A single draw with zero instances now draws nothing on WebGL2 too, and counts no primitives. An instance count left undefined still draws once without instancing, and the multi-draw path with draw commands is unchanged. Co-authored-by: Martin Valigursky --- src/platform/graphics/graphics-device.js | 2 +- .../graphics/webgl/webgl-graphics-device.js | 4 ++-- .../platform/graphics/primitive-count.test.mjs | 18 +++++++++++++++--- 3 files changed, 18 insertions(+), 6 deletions(-) diff --git a/src/platform/graphics/graphics-device.js b/src/platform/graphics/graphics-device.js index 9db0cdd3e71..8b1e31355a7 100644 --- a/src/platform/graphics/graphics-device.js +++ b/src/platform/graphics/graphics-device.js @@ -1623,7 +1623,7 @@ class GraphicsDevice extends EventHandler { * using the currently set index buffer and false otherwise. * @param {IndexBuffer} [indexBuffer] - The index buffer to use for the draw call. * @param {number} [numInstances] - The number of instances to render when using instancing. - * Defaults to 1. + * Zero renders nothing, unless draw commands are provided. Defaults to 1. * @param {DrawCommands} [drawCommands] - The draw commands to use for the draw call. * @param {boolean} [first] - True if this is the first draw call in a sequence of draw calls. * When set to true, vertex and index buffers related state is set up. Defaults to true. diff --git a/src/platform/graphics/webgl/webgl-graphics-device.js b/src/platform/graphics/webgl/webgl-graphics-device.js index 5205e1fef10..2523ce9c5bf 100644 --- a/src/platform/graphics/webgl/webgl-graphics-device.js +++ b/src/platform/graphics/webgl/webgl-graphics-device.js @@ -2280,7 +2280,7 @@ class WebglGraphicsDevice extends GraphicsDevice { // multi-draw extension is not supported, use fallback loop this._multiDrawLoopFallback(mode, primitive, indexBuffer, numInstances, drawCommands); } - } else { + } else if (numInstances !== 0) { // single draw - zero instances draws nothing, as on WebGPU if (primitive.indexed) { Debug.assert(indexBuffer.device === this, 'The IndexBuffer was not created using current GraphicsDevice'); @@ -2319,7 +2319,7 @@ class WebglGraphicsDevice extends GraphicsDevice { this._primitiveCount += drawCommands.getPrimitiveCount(primitive.type, numInstances > 0); } else { // single draw - this._primitiveCount += getPrimitiveCount(primitive.type, primitive.count) * (numInstances > 0 ? numInstances : 1); + this._primitiveCount += getPrimitiveCount(primitive.type, primitive.count) * (numInstances ?? 1); } // #endif } diff --git a/test/platform/graphics/primitive-count.test.mjs b/test/platform/graphics/primitive-count.test.mjs index 89ad0f91416..71a4a48743f 100644 --- a/test/platform/graphics/primitive-count.test.mjs +++ b/test/platform/graphics/primitive-count.test.mjs @@ -89,10 +89,22 @@ describe('Primitive counting', function () { expect(device._primitiveCount).to.equal(17); }); - it('matches the backend behavior for a zero instance argument', function () { + it('draws nothing for a zero instance argument', function () { + const glDraws = [spy(device.gl, 'drawArrays'), spy(device.gl, 'drawArraysInstanced')]; draw(PRIMITIVE_TRIANGLES, 6, 0); - // WebGL uses zero as its legacy non-instanced draw path; WebGPU draws no instances. - expect(device._primitiveCount).to.equal(backend === 'WebGL' ? 2 : 0); + expect(device._primitiveCount).to.equal(0); + if (backend === 'WebGL') { + expect(glDraws.some(glDraw => glDraw.called)).to.equal(false); + } + }); + + it('draws once without instancing when the instance count is not specified', function () { + const glDraw = spy(device.gl, 'drawArrays'); + draw(PRIMITIVE_TRIANGLES, 6, undefined); + expect(device._primitiveCount).to.equal(2); + if (backend === 'WebGL') { + expect(glDraw.calledOnce).to.equal(true); + } }); it('counts each multi-draw command independently and ignores zero instances', function () { From 550a4ff235408224d4b639a92a5e93fd857d2e0d Mon Sep 17 00:00:00 2001 From: Martin Valigursky <59932779+mvaligursky@users.noreply.github.com> Date: Mon, 26 Oct 2026 17:51:26 +0000 Subject: [PATCH 21/22] refactor: Rename FrameGraph addRenderPass and renderPasses to addPass and passes (#9835) The frame graph schedules every FramePass, including compute passes and passes which do not render, so its members drop the render pass naming. Co-authored-by: Martin Valigursky --- src/scene/frame-graph.js | 80 +++++++++---------- src/scene/renderer/forward-renderer.js | 14 ++-- src/scene/renderer/frame-pass-multi-view.js | 2 +- src/scene/renderer/shadow-renderer-local.js | 2 +- .../frame-pass-camera-frame.test.mjs | 2 +- test/platform/graphics/compute-pass.test.mjs | 16 ++-- test/scene/frame-graph-multiview.test.mjs | 48 +++++------ test/scene/frame-graph.test.mjs | 54 ++++++------- .../renderer/camera-after-passes.test.mjs | 2 +- .../local-shadow-cull-requests.test.mjs | 6 +- 10 files changed, 113 insertions(+), 113 deletions(-) diff --git a/src/scene/frame-graph.js b/src/scene/frame-graph.js index 2e1a25c328b..aa32caa7ce4 100644 --- a/src/scene/frame-graph.js +++ b/src/scene/frame-graph.js @@ -15,7 +15,7 @@ import { FramePassMultiView } from './renderer/frame-pass-multi-view.js'; */ class FrameGraph { /** @type {FramePass[]} */ - renderPasses = []; + passes = []; /** * Map used during frame graph compilation. It maps a render target to its previous occurrence. @@ -26,8 +26,8 @@ class FrameGraph { /** * Active multi-view capture wrapper. When non-null, passes scheduled via - * {@link FrameGraph#addRenderPass} are appended as children of this wrapper instead of being - * pushed directly into {@link FrameGraph#renderPasses}. Set/cleared via + * {@link FrameGraph#addPass} are appended as children of this wrapper instead of being + * pushed directly into {@link FrameGraph#passes}. Set/cleared via * {@link FrameGraph#beginMultiView} / {@link FrameGraph#endMultiView}. * * @type {FramePassMultiView|null} @@ -36,7 +36,7 @@ class FrameGraph { /** * Open a multi-view capture scope. Subsequent passes added through - * {@link FrameGraph#addRenderPass} are captured as children of a single + * {@link FrameGraph#addPass} are captured as children of a single * {@link FramePassMultiView} until {@link FrameGraph#endMultiView} is called. * * @param {GraphicsDevice} device - The graphics device used to construct the wrapper. @@ -47,7 +47,7 @@ class FrameGraph { } /** - * Close the multi-view capture scope. Pushes the wrapper into the frame graph render passes + * Close the multi-view capture scope. Pushes the wrapper into the frame graph passes * unless it captured no children (in which case it is dropped). * * A captured pass which does not render per view (see {@link FramePass#perView}), such as the @@ -72,7 +72,7 @@ class FrameGraph { for (let i = 0; i < children.length; i++) { const pass = children[i]; if (!pass.perView && !this._sharesTarget(children, pass)) { - this.renderPasses.push(pass); + this.passes.push(pass); } else { children[kept++] = pass; } @@ -81,7 +81,7 @@ class FrameGraph { // drop the wrapper when no pass renders per view if (kept) { - this.renderPasses.push(wrap); + this.passes.push(wrap); } } @@ -113,63 +113,63 @@ class FrameGraph { * Add a frame pass to the frame, together with its before and after passes. A disabled pass * is skipped with all of them, and its {@link FramePass#frameUpdate} is not called. * - * @param {FramePass} renderPass - The frame pass to add. + * @param {FramePass} pass - The frame pass to add. */ - addRenderPass(renderPass) { - Debug.assert(renderPass); + addPass(pass) { + Debug.assert(pass); - if (!renderPass.enabled) { + if (!pass.enabled) { return; } // a destroyed pass is not used again, see FramePass#destroy - Debug.assertDestroyed(renderPass); + Debug.assertDestroyed(pass); // can add or remove before and after passes, and enable or disable them, but not disable the // pass itself - renderPass.frameUpdate(); + pass.frameUpdate(); // the passes the engine added for the frame execute first, as the passes set up by the user // can depend on them - const internalBeforePasses = renderPass.internalBeforePasses; + const internalBeforePasses = pass.internalBeforePasses; for (let i = 0; i < internalBeforePasses.length; i++) { - this.addRenderPass(internalBeforePasses[i]); + this.addPass(internalBeforePasses[i]); } - const beforePasses = renderPass.beforePasses; + const beforePasses = pass.beforePasses; for (let i = 0; i < beforePasses.length; i++) { - this.addRenderPass(beforePasses[i]); + this.addPass(beforePasses[i]); } // a pass disabled while it is scheduled, by its frameUpdate or by its before-passes, is not // supported, as it has already set itself up for the frame, for example as the first or the // last pass rendering a camera. It stays out of the compile step - Debug.assert(renderPass.enabled, `The frame pass '${renderPass.name}' was disabled while it was added to the frame, by its frameUpdate or by its before-passes, which is not supported. Disable it before the frame is built, or set its executeEnabled to false instead.`, renderPass); - if (renderPass.enabled) { + Debug.assert(pass.enabled, `The frame pass '${pass.name}' was disabled while it was added to the frame, by its frameUpdate or by its before-passes, which is not supported. Disable it before the frame is built, or set its executeEnabled to false instead.`, pass); + if (pass.enabled) { if (this.multiview) { - this.multiview.addChild(renderPass); + this.multiview.addChild(pass); } else { - this.renderPasses.push(renderPass); + this.passes.push(pass); } } - const afterPasses = renderPass.afterPasses; + const afterPasses = pass.afterPasses; for (let i = 0; i < afterPasses.length; i++) { - this.addRenderPass(afterPasses[i]); + this.addPass(afterPasses[i]); } } reset() { - this.renderPasses.length = 0; + this.passes.length = 0; } compile() { - this._compilePasses(this.renderPasses); + this._compilePasses(this.passes); // apply the same pass-merging optimizations to each multi-view wrapper's children so // within-eye sequences benefit from the same optimizations as top-level passes - for (let i = 0; i < this.renderPasses.length; i++) { - const pass = this.renderPasses[i]; + for (let i = 0; i < this.passes.length; i++) { + const pass = this.passes[i]; if (pass instanceof FramePassMultiView) { this._compilePasses(pass.children); } @@ -188,39 +188,39 @@ class FrameGraph { const renderTargetMap = this.renderTargetMap; for (let i = 0; i < passes.length; i++) { - const renderPass = passes[i]; - renderPass._skipStart = false; - renderPass._skipEnd = false; + const pass = passes[i]; + pass._skipStart = false; + pass._skipEnd = false; - const renderTarget = renderPass.renderTarget; + const renderTarget = pass.renderTarget; // if using a target, or null which represents the default back-buffer. A pass which does // not execute does not touch its target, so the store request of the next pass using it // goes to the last pass which writes it - if (renderTarget !== undefined && renderPass.executeEnabled) { + if (renderTarget !== undefined && pass.executeEnabled) { // previous pass using the same render target const prevPass = renderTargetMap.get(renderTarget); if (prevPass) { // if we use the RT without clearing, make sure the previous pass stores data - const count = renderPass.colorArrayOps.length; + const count = pass.colorArrayOps.length; for (let j = 0; j < count; j++) { - const colorOps = renderPass.colorArrayOps[j]; + const colorOps = pass.colorArrayOps[j]; if (!colorOps.clear) { prevPass.colorArrayOps[j].store = true; } } - if (!renderPass.depthStencilOps.clearDepth) { + if (!pass.depthStencilOps.clearDepth) { prevPass.depthStencilOps.storeDepth = true; } - if (!renderPass.depthStencilOps.clearStencil) { + if (!pass.depthStencilOps.clearStencil) { prevPass.depthStencilOps.storeStencil = true; } } // add the pass to the map - renderTargetMap.set(renderTarget, renderPass); + renderTargetMap.set(renderTarget, pass); } } @@ -278,9 +278,9 @@ class FrameGraph { this.compile(); - const renderPasses = this.renderPasses; - for (let i = 0; i < renderPasses.length; i++) { - renderPasses[i].render(); + const passes = this.passes; + for (let i = 0; i < passes.length; i++) { + passes[i].render(); } } } diff --git a/src/scene/renderer/forward-renderer.js b/src/scene/renderer/forward-renderer.js index bedf4f85c24..0d07bdd4597 100644 --- a/src/scene/renderer/forward-renderer.js +++ b/src/scene/renderer/forward-renderer.js @@ -672,7 +672,7 @@ class ForwardRenderer extends Renderer { // clustered lighting passes const { shadowsEnabled, cookiesEnabled } = scene.lighting; this._renderPassUpdateClustered.update(shadowsEnabled, cookiesEnabled, this.lights, this.localLights); - frameGraph.addRenderPass(this._renderPassUpdateClustered); + frameGraph.addPass(this._renderPassUpdateClustered); } else { @@ -704,7 +704,7 @@ class ForwardRenderer extends Renderer { // wrapper if the camera needs per-view replication if (mv) frameGraph.beginMultiView(this.device); camera.camera.framePasses.forEach((renderPass) => { - frameGraph.addRenderPass(renderPass); + frameGraph.addPass(renderPass); }); if (mv) frameGraph.endMultiView(); @@ -754,18 +754,18 @@ class ForwardRenderer extends Renderer { if (camera.renderSceneColorMap) { const colorGrabPass = camera.camera.renderPassColorGrab; colorGrabPass.source = camera.renderTarget; - frameGraph.addRenderPass(colorGrabPass); + frameGraph.addPass(colorGrabPass); } if (camera.renderSceneDepthMap) { - frameGraph.addRenderPass(camera.camera.renderPassDepthGrab); + frameGraph.addPass(camera.camera.renderPassDepthGrab); } } // postprocessing if (renderAction.triggerPostprocess && camera?.onPostprocessing) { const renderPass = new FramePassPostprocessing(this.device, this, renderAction); - frameGraph.addRenderPass(renderPass); + frameGraph.addPass(renderPass); } if (mv) frameGraph.endMultiView(); @@ -793,7 +793,7 @@ class ForwardRenderer extends Renderer { addCameraAfterPasses(frameGraph, camera) { const { afterPasses } = camera.camera; for (let i = 0; i < afterPasses.length; i++) { - frameGraph.addRenderPass(afterPasses[i]); + frameGraph.addPass(afterPasses[i]); } } @@ -827,7 +827,7 @@ class ForwardRenderer extends Renderer { renderPass.addLayerRenderStep(this._layerRenderStepFromRenderAction(renderActions[i])); } - frameGraph.addRenderPass(renderPass); + frameGraph.addPass(renderPass); } /** diff --git a/src/scene/renderer/frame-pass-multi-view.js b/src/scene/renderer/frame-pass-multi-view.js index 284b3f9ad6a..93f888c3f18 100644 --- a/src/scene/renderer/frame-pass-multi-view.js +++ b/src/scene/renderer/frame-pass-multi-view.js @@ -10,7 +10,7 @@ import { FramePass } from '../../platform/graphics/frame-pass.js'; * graphics device, swaps the backbuffer color view to the matching XR sub-image view descriptor, * and invokes each child's `render()`. * - * The children are not added to {@link FrameGraph#renderPasses} - they are owned by the wrapper + * The children are not added to {@link FrameGraph#passes} - they are owned by the wrapper * and invoked from {@link FramePassMultiView#render}. This guarantees the frame graph's * pass-merging cannot accidentally merge eye-N's last pass with eye-(N+1)'s first pass. * diff --git a/src/scene/renderer/shadow-renderer-local.js b/src/scene/renderer/shadow-renderer-local.js index a7971dc3e99..ef1f75c5c1e 100644 --- a/src/scene/renderer/shadow-renderer-local.js +++ b/src/scene/renderer/shadow-renderer-local.js @@ -147,7 +147,7 @@ class ShadowRendererLocal { const faceCount = light.numShadowFaces; for (let face = 0; face < faceCount; face++) { const renderPass = new RenderPassShadowLocalNonClustered(this.device, this.shadowRenderer, light, face, applyVsm); - frameGraph.addRenderPass(renderPass); + frameGraph.addPass(renderPass); } } } diff --git a/test/extras/render-passes/frame-pass-camera-frame.test.mjs b/test/extras/render-passes/frame-pass-camera-frame.test.mjs index e311da23a61..7c758cacf84 100644 --- a/test/extras/render-passes/frame-pass-camera-frame.test.mjs +++ b/test/extras/render-passes/frame-pass-camera-frame.test.mjs @@ -856,7 +856,7 @@ describe('FramePassCameraFrame', function () { postrender.resetHistory(); app.render(); - const passes = app.frameGraph.renderPasses; + const passes = app.frameGraph.passes; const { scenePass, scenePassTransparent } = cameraFrame.renderPassCamera; expect(scenePassTransparent).to.exist; expect(passes.filter(pass => pass === before)).to.have.lengthOf(1); diff --git a/test/platform/graphics/compute-pass.test.mjs b/test/platform/graphics/compute-pass.test.mjs index ad672b9bcd9..2990da6c288 100644 --- a/test/platform/graphics/compute-pass.test.mjs +++ b/test/platform/graphics/compute-pass.test.mjs @@ -218,8 +218,8 @@ describe('ComputePass', function () { const renderViews = (xrDevice, passes) => { const frameGraph = new FrameGraph(); frameGraph.beginMultiView(xrDevice); - frameGraph.addRenderPass(new FramePass(xrDevice)); - passes.forEach(pass => frameGraph.addRenderPass(pass)); + frameGraph.addPass(new FramePass(xrDevice)); + passes.forEach(pass => frameGraph.addPass(pass)); frameGraph.endMultiView(); frameGraph.render(xrDevice); return frameGraph; @@ -266,7 +266,7 @@ describe('ComputePass', function () { const frameGraph = renderViews(xrDevice, [first, second]); // both are moved ahead of the passes rendering the views, in order - const [movedFirst, movedSecond, wrapper] = frameGraph.renderPasses; + const [movedFirst, movedSecond, wrapper] = frameGraph.passes; expect(movedFirst).to.equal(first); expect(movedSecond).to.equal(second); expect(wrapper.children).to.have.lengthOf(1); @@ -295,9 +295,9 @@ describe('ComputePass', function () { const frameGraph = new FrameGraph(); const before = new TargetPass(device, renderTarget); const after = new TargetPass(device, renderTarget); - frameGraph.addRenderPass(before); - if (between) frameGraph.addRenderPass(between); - frameGraph.addRenderPass(after); + frameGraph.addPass(before); + if (between) frameGraph.addPass(between); + frameGraph.addPass(after); frameGraph.compile(); return { before, after }; }; @@ -325,7 +325,7 @@ describe('ComputePass', function () { app.render(); expect(dispatch.calledWith([compute])).to.equal(true); - const passes = app.frameGraph.renderPasses; + const passes = app.frameGraph.passes; const lastForward = passes.findLastIndex(p => p instanceof RenderPassForward); expect(lastForward).to.be.at.least(0); expect(passes.indexOf(pass)).to.be.greaterThan(lastForward); @@ -346,7 +346,7 @@ describe('ComputePass', function () { pass.destroy(); const error = sinon.stub(console, 'error'); - new FrameGraph().addRenderPass(pass); + new FrameGraph().addPass(pass); expect(error.callCount).to.equal(1); }); }); diff --git a/test/scene/frame-graph-multiview.test.mjs b/test/scene/frame-graph-multiview.test.mjs index ce0bde000f8..744b07f5948 100644 --- a/test/scene/frame-graph-multiview.test.mjs +++ b/test/scene/frame-graph-multiview.test.mjs @@ -65,12 +65,12 @@ describe('FrameGraph', function () { const second = new CountingPass(device, 'Second'); frameGraph.beginMultiView(device); - frameGraph.addRenderPass(first); - frameGraph.addRenderPass(second); + frameGraph.addPass(first); + frameGraph.addPass(second); frameGraph.endMultiView(); - expect(frameGraph.renderPasses).to.have.lengthOf(1); - const wrapper = frameGraph.renderPasses[0]; + expect(frameGraph.passes).to.have.lengthOf(1); + const wrapper = frameGraph.passes[0]; expect(wrapper).to.be.an.instanceOf(FramePassMultiView); expect(wrapper.children).to.deep.equal([first, second]); }); @@ -81,14 +81,14 @@ describe('FrameGraph', function () { const view = new CountingPass(device, 'View'); const once = new CountingPass(device, 'Once', false); - frameGraph.addRenderPass(earlier); + frameGraph.addPass(earlier); frameGraph.beginMultiView(device); - frameGraph.addRenderPass(view); - frameGraph.addRenderPass(once); + frameGraph.addPass(view); + frameGraph.addPass(once); frameGraph.endMultiView(); - const [first, second, wrapper] = frameGraph.renderPasses; - expect(frameGraph.renderPasses).to.have.lengthOf(3); + const [first, second, wrapper] = frameGraph.passes; + expect(frameGraph.passes).to.have.lengthOf(3); expect(first).to.equal(earlier); expect(second).to.equal(once); expect(wrapper.children).to.deep.equal([view]); @@ -101,13 +101,13 @@ describe('FrameGraph', function () { const second = new CountingPass(device, 'Second', false); frameGraph.beginMultiView(device); - frameGraph.addRenderPass(view); - frameGraph.addRenderPass(first); - frameGraph.addRenderPass(second); + frameGraph.addPass(view); + frameGraph.addPass(first); + frameGraph.addPass(second); frameGraph.endMultiView(); - const [movedFirst, movedSecond, wrapper] = frameGraph.renderPasses; - expect(frameGraph.renderPasses).to.have.lengthOf(3); + const [movedFirst, movedSecond, wrapper] = frameGraph.passes; + expect(frameGraph.passes).to.have.lengthOf(3); expect(movedFirst).to.equal(first); expect(movedSecond).to.equal(second); expect(wrapper.children).to.deep.equal([view]); @@ -122,11 +122,11 @@ describe('FrameGraph', function () { forward.beforePasses.push(shadow, cameraBefore); frameGraph.beginMultiView(device); - frameGraph.addRenderPass(forward); + frameGraph.addPass(forward); frameGraph.endMultiView(); - const [first, wrapper] = frameGraph.renderPasses; - expect(frameGraph.renderPasses).to.have.lengthOf(2); + const [first, wrapper] = frameGraph.passes; + expect(frameGraph.passes).to.have.lengthOf(2); expect(first).to.equal(shadow); expect(wrapper.children).to.deep.equal([cameraBefore, forward]); }); @@ -143,7 +143,7 @@ describe('FrameGraph', function () { const xrDevice = { xrSubImages: [view, view], backBuffer: null }; frameGraph.beginMultiView(xrDevice); - frameGraph.addRenderPass(forward); + frameGraph.addPass(forward); frameGraph.endMultiView(); frameGraph.render(device); @@ -164,8 +164,8 @@ describe('FrameGraph', function () { const shadowB = new CountingTargetPass(device, 'ShadowB', shadowMapB); forwardB.beforePasses.push(shadowB); - frameGraph.addRenderPass(forwardA); - frameGraph.addRenderPass(forwardB); + frameGraph.addPass(forwardA); + frameGraph.addPass(forwardB); return { forwardA, shadowA, forwardB, shadowB }; }; @@ -178,8 +178,8 @@ describe('FrameGraph', function () { const { forwardA, shadowA, forwardB, shadowB } = addCameras(frameGraph, shadowMap, shadowMap); frameGraph.endMultiView(); - expect(frameGraph.renderPasses).to.have.lengthOf(1); - expect(frameGraph.renderPasses[0].children).to.deep.equal([shadowA, forwardA, shadowB, forwardB]); + expect(frameGraph.passes).to.have.lengthOf(1); + expect(frameGraph.passes[0].children).to.deep.equal([shadowA, forwardA, shadowB, forwardB]); }); it('moves the shadow passes of different lights ahead of the captured passes', function () { @@ -189,8 +189,8 @@ describe('FrameGraph', function () { const { forwardA, shadowA, forwardB, shadowB } = addCameras(frameGraph, {}, {}); frameGraph.endMultiView(); - const [first, second, wrapper] = frameGraph.renderPasses; - expect(frameGraph.renderPasses).to.have.lengthOf(3); + const [first, second, wrapper] = frameGraph.passes; + expect(frameGraph.passes).to.have.lengthOf(3); expect(first).to.equal(shadowA); expect(second).to.equal(shadowB); expect(wrapper.children).to.deep.equal([forwardA, forwardB]); diff --git a/test/scene/frame-graph.test.mjs b/test/scene/frame-graph.test.mjs index 02c821466e7..123095b55d6 100644 --- a/test/scene/frame-graph.test.mjs +++ b/test/scene/frame-graph.test.mjs @@ -61,7 +61,7 @@ describe('FrameGraph', function () { jsdomTeardown(); }); - describe('#addRenderPass', function () { + describe('#addPass', function () { it('schedules the before-passes, the pass and the after-passes in order', function () { const frameGraph = new FrameGraph(); @@ -69,9 +69,9 @@ describe('FrameGraph', function () { const after = new TestPass(device, 'After'); const pass = new TestPass(device, 'Pass', { before: [before], after: [after] }); - frameGraph.addRenderPass(pass); + frameGraph.addPass(pass); - expect(frameGraph.renderPasses).to.deep.equal([before, pass, after]); + expect(frameGraph.passes).to.deep.equal([before, pass, after]); expect([before, pass, after].map(p => p.frameUpdateCount)).to.deep.equal([1, 1, 1]); }); @@ -81,9 +81,9 @@ describe('FrameGraph', function () { const after = new TestPass(device, 'After'); const pass = new TestPass(device, 'Pass', { enabled: false, before: [before], after: [after] }); - frameGraph.addRenderPass(pass); + frameGraph.addPass(pass); - expect(frameGraph.renderPasses).to.be.empty; + expect(frameGraph.passes).to.be.empty; expect([before, pass, after].map(p => p.frameUpdateCount)).to.deep.equal([0, 0, 0]); }); @@ -93,9 +93,9 @@ describe('FrameGraph', function () { const before = new TestPass(device, 'Before'); const pass = new TestPass(device, 'Pass', { internalBefore: [internal], before: [before] }); - frameGraph.addRenderPass(pass); + frameGraph.addPass(pass); - expect(frameGraph.renderPasses).to.deep.equal([internal, before, pass]); + expect(frameGraph.passes).to.deep.equal([internal, before, pass]); }); it('skips the internal before-passes of a disabled pass', function () { @@ -103,9 +103,9 @@ describe('FrameGraph', function () { const internal = new TestPass(device, 'Internal'); const pass = new TestPass(device, 'Pass', { enabled: false, internalBefore: [internal] }); - frameGraph.addRenderPass(pass); + frameGraph.addPass(pass); - expect(frameGraph.renderPasses).to.be.empty; + expect(frameGraph.passes).to.be.empty; expect(internal.frameUpdateCount).to.equal(0); }); @@ -117,9 +117,9 @@ describe('FrameGraph', function () { const sibling = new TestPass(device, 'Sibling'); const pass = new TestPass(device, 'Pass', { before: [child, sibling] }); - frameGraph.addRenderPass(pass); + frameGraph.addPass(pass); - expect(frameGraph.renderPasses).to.deep.equal([sibling, pass]); + expect(frameGraph.passes).to.deep.equal([sibling, pass]); expect([nestedBefore, child, nestedAfter].map(p => p.frameUpdateCount)).to.deep.equal([0, 0, 0]); }); @@ -131,9 +131,9 @@ describe('FrameGraph', function () { child.enabled = true; }; - frameGraph.addRenderPass(pass); + frameGraph.addPass(pass); - expect(frameGraph.renderPasses).to.deep.equal([child, pass]); + expect(frameGraph.passes).to.deep.equal([child, pass]); }); it('skips a child the pass disables in its frameUpdate', function () { @@ -144,9 +144,9 @@ describe('FrameGraph', function () { child.enabled = false; }; - frameGraph.addRenderPass(pass); + frameGraph.addPass(pass); - expect(frameGraph.renderPasses).to.deep.equal([pass]); + expect(frameGraph.passes).to.deep.equal([pass]); expect(child.frameUpdateCount).to.equal(0); }); @@ -158,10 +158,10 @@ describe('FrameGraph', function () { }; const assertion = stub(Debug, 'assert'); - frameGraph.addRenderPass(pass); + frameGraph.addPass(pass); expect(assertion.getCalls().some(call => !call.args[0])).to.equal(true); - expect(frameGraph.renderPasses).to.be.empty; + expect(frameGraph.passes).to.be.empty; expect(pass.frameUpdateCount).to.equal(1); }); @@ -174,10 +174,10 @@ describe('FrameGraph', function () { }; const assertion = stub(Debug, 'assert'); - frameGraph.addRenderPass(pass); + frameGraph.addPass(pass); expect(assertion.getCalls().some(call => !call.args[0])).to.equal(true); - expect(frameGraph.renderPasses).to.deep.equal([before]); + expect(frameGraph.passes).to.deep.equal([before]); }); it('does not assert a pass whose frameUpdate disables its before-passes', function () { @@ -189,10 +189,10 @@ describe('FrameGraph', function () { }; const assertion = stub(Debug, 'assert'); - frameGraph.addRenderPass(pass); + frameGraph.addPass(pass); expect(assertion.getCalls().some(call => !call.args[0])).to.equal(false); - expect(frameGraph.renderPasses).to.deep.equal([pass]); + expect(frameGraph.passes).to.deep.equal([pass]); }); it('reports a destroyed pass added to the frame, once', function () { @@ -200,8 +200,8 @@ describe('FrameGraph', function () { pass.destroy(); const error = stub(console, 'error'); - new FrameGraph().addRenderPass(pass); - new FrameGraph().addRenderPass(pass); + new FrameGraph().addPass(pass); + new FrameGraph().addPass(pass); expect(error.callCount).to.equal(1); expect(error.firstCall.args.join(' ')).to.include('DestroyedPassAddedToFrame'); @@ -214,11 +214,11 @@ describe('FrameGraph', function () { const error = stub(console, 'error'); const frameGraph = new FrameGraph(); - frameGraph.addRenderPass(live); - frameGraph.addRenderPass(disabled); + frameGraph.addPass(live); + frameGraph.addPass(disabled); expect(error.called).to.equal(false); - expect(frameGraph.renderPasses).to.deep.equal([live]); + expect(frameGraph.passes).to.deep.equal([live]); }); }); @@ -240,7 +240,7 @@ describe('FrameGraph', function () { const compile = (...passes) => { const frameGraph = new FrameGraph(); - passes.forEach(pass => frameGraph.addRenderPass(pass)); + passes.forEach(pass => frameGraph.addPass(pass)); frameGraph.compile(); return frameGraph; }; diff --git a/test/scene/renderer/camera-after-passes.test.mjs b/test/scene/renderer/camera-after-passes.test.mjs index a10f481991f..f4832c939d8 100644 --- a/test/scene/renderer/camera-after-passes.test.mjs +++ b/test/scene/renderer/camera-after-passes.test.mjs @@ -43,7 +43,7 @@ describe('Camera passes', function () { // the passes the frame rendered const renderFrame = () => { app.render(); - return app.frameGraph.renderPasses; + return app.frameGraph.passes; }; // whether the pass renders the camera diff --git a/test/scene/renderer/local-shadow-cull-requests.test.mjs b/test/scene/renderer/local-shadow-cull-requests.test.mjs index 91c6e89a2c1..620a5207c3f 100644 --- a/test/scene/renderer/local-shadow-cull-requests.test.mjs +++ b/test/scene/renderer/local-shadow-cull-requests.test.mjs @@ -105,12 +105,12 @@ describe('Local shadow cull requests', function () { it(`keeps THISFRAME pending when the shadow pass has ${property} disabled`, function () { // disable the shadow pass as it is added to the frame, before its frameUpdate const ShadowPass = clustered ? RenderPassShadowLocalClustered : RenderPassShadowLocalNonClustered; - const addRenderPass = FrameGraph.prototype.addRenderPass; - sinon.stub(FrameGraph.prototype, 'addRenderPass').callsFake(function (pass) { + const addPass = FrameGraph.prototype.addPass; + sinon.stub(FrameGraph.prototype, 'addPass').callsFake(function (pass) { if (pass instanceof ShadowPass) { pass[property] = false; } - addRenderPass.call(this, pass); + addPass.call(this, pass); }); light.shadowUpdateMode = SHADOWUPDATE_THISFRAME; app.render(); From 8c40ec6c6842a58901ab063ba874548342661c43 Mon Sep 17 00:00:00 2001 From: "PlayCanvas [bot]" Date: Tue, 27 Oct 2026 02:22:16 +0000 Subject: [PATCH 22/22] 2.24.0-beta.23 --- package-lock.json | 4 ++-- package.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/package-lock.json b/package-lock.json index 193c097f322..a455b28a3f6 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "playcanvas", - "version": "2.24.0-beta.22", + "version": "2.24.0-beta.23", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "playcanvas", - "version": "2.24.0-beta.22", + "version": "2.24.0-beta.23", "license": "MIT", "dependencies": { "@types/webxr": "^0.5.24", diff --git a/package.json b/package.json index 3f0a6fdd777..334bfc9fbde 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "playcanvas", - "version": "2.24.0-beta.22", + "version": "2.24.0-beta.23", "packageManager": "npm@11.19.1", "author": "PlayCanvas ", "homepage": "https://playcanvas.com",