diff --git a/examples/src/examples/test/xr-views.example.mjs b/examples/src/examples/test/xr-views.example.mjs index b8e66596669..dc6e6045231 100644 --- a/examples/src/examples/test/xr-views.example.mjs +++ b/examples/src/examples/test/xr-views.example.mjs @@ -43,6 +43,85 @@ createOptions.resourceHandlers = [ const app = new pc.AppBase(canvas); app.init(createOptions); +// Composite pass that samples a 4-layer texture array and writes the layers into a 2x2 grid on +// the canvas backbuffer. Used by the WebGPU branch to visualise the per-eye renders produced by +// FramePassMultiView. +class CompositeArrayPass extends pc.RenderPassShaderQuad { + constructor(graphicsDevice, sourceTexture, numViews) { + super(graphicsDevice); + this.name = 'CompositeArrayPass'; + this.sourceTexture = sourceTexture; + this.numViews = numViews; + + this.shader = pc.ShaderUtils.createShader(graphicsDevice, { + uniqueName: 'XrViewsCompositeShader', + attributes: { aPosition: pc.SEMANTIC_POSITION }, + vertexChunk: 'quadVS', + + fragmentWGSL: /* wgsl */ ` + var sourceTexture: texture_2d_array; + var sourceTextureSampler: sampler; + varying uv0: vec2f; + + @fragment fn fragmentMain(input: FragmentInput) -> FragmentOutput { + var output: FragmentOutput; + let q = floor(input.uv0 * 2.0); + // clamp layer: at uv edge (1,1) q can be 2, giving layer 4 for a 4-layer array + let layer = clamp(i32(q.x + q.y * 2.0), 0, 3); + let localUV = input.uv0 * 2.0 - q; + output.color = textureSample(sourceTexture, sourceTextureSampler, localUV, layer); + return output; + } + ` + }); + } + + // Called once per frame during frame graph construction, after frameStart() but before any + // GPU commands are recorded. This is the safe point to resize the array texture: the previous + // frame's GPU commands have already been submitted and deferred-destroys flushed, so + // destroying the old GPU texture here won't conflict with any pending submit. + frameUpdate() { + super.frameUpdate(); + + const tex = this.sourceTexture; + if (!tex) return; + + // resize to match the current backbuffer dimensions + const { width, height } = this.device.backBuffer; + if (width > 0 && height > 0) { + tex.resize(width, height); + } + + // re-populate device.xrSubImages with the current (possibly new) GPU texture reference. + // this must happen after resize() so the GPU texture handle is up-to-date. + const gpuTexture = tex.impl?.gpuTexture; + if (gpuTexture) { + const viewFormat = gpuTexture.format; + const subImages = []; + for (let i = 0; i < this.numViews; i++) { + subImages.push({ + colorTexture: gpuTexture, + viewDescriptor: { + dimension: '2d', + baseArrayLayer: i, + arrayLayerCount: 1, + baseMipLevel: 0, + mipLevelCount: 1 + }, + viewport: { x: 0, y: 0, width, height }, + viewFormat + }); + } + this.device.xrSubImages = subImages; + } + } + + execute() { + this.device.scope.resolve('sourceTexture').setValue(this.sourceTexture); + super.execute(); + } +} + const assetListLoader = new pc.AssetListLoader(Object.values(assets), app.assets); assetListLoader.load(() => { app.start(); @@ -77,8 +156,6 @@ assetListLoader.load(() => { shadowBias: 0.3, normalOffsetBias: 0.2, intensity: 1.0, - - // enable shadow casting castShadows: false, shadowDistance: 1000 }); @@ -109,10 +186,13 @@ assetListLoader.load(() => { camera.script.create('orbitCameraInputTouch'); app.root.addChild(camera); - // Create XR views using a loop + // Create mock XR views using a loop. The number of views differs between backends because + // each backend uses a different visualisation: + // - WebGL: a single canvas-sized backbuffer with 4 sub-rect viewports (2x2 grid). + // - WebGPU: a 4-layer array texture, one full-canvas-size view per layer, then composited + // into a 2x2 grid as a separate post-render pass. + const numViews = 4; const viewsList = []; - const numViews = 4; // 2x2 grid - for (let i = 0; i < numViews; i++) { viewsList.push({ updateTransforms(transform) { @@ -136,11 +216,53 @@ assetListLoader.load(() => { } }; + // ---------------------------------------------------------------------------------------- + // WebGPU-only setup: drive FramePassMultiView via a fake bridge - we provide the per-view + // sub-image entries on the device that the wrapper consumes (mirroring what + // WebgpuXrBridge.beginFrame does on a real headset). + // ---------------------------------------------------------------------------------------- + let arrayTex = null; + let compositeCamera = null; + if (device.isWebGPU) { + + const createArrayTexture = (w, h) => new pc.Texture(device, { + name: 'XrViewsArrayTexture', + format: device.backBufferFormat, + arrayLength: numViews, + width: w, + height: h, + mipmaps: false, + addressU: pc.ADDRESS_CLAMP_TO_EDGE, + addressV: pc.ADDRESS_CLAMP_TO_EDGE, + minFilter: pc.FILTER_LINEAR, + magFilter: pc.FILTER_LINEAR + }); + + arrayTex = createArrayTexture(Math.max(canvas.width, 1), Math.max(canvas.height, 1)); + + // composite camera renders second (higher priority) and only runs the composite pass that + // samples the four rendered layers and lays them out as a 2x2 grid on the canvas + compositeCamera = new pc.Entity('XrViewsCompositeCamera'); + compositeCamera.addComponent('camera', { + priority: 1, + clearColor: new pc.Color(0, 0, 0, 0), + clearColorBuffer: false, + clearDepthBuffer: false, + clearStencilBuffer: false + }); + app.root.addChild(compositeCamera); + + const compositePass = new CompositeArrayPass(device, arrayTex, numViews); + compositePass.init(null); + compositeCamera.camera.framePasses = [compositePass]; + } + const cameraComponent = camera.camera; app.on('update', (/** @type {number} */ dt) => { const width = canvas.width; const height = canvas.height; + const isWebgpu = device.isWebGPU; // update all views - supply some matrices to make pre view rendering possible // note that this is not complete set up, view frustum does not get updated and so @@ -166,13 +288,25 @@ assetListLoader.load(() => { view.projViewOffMat.mul2(view.projMat, viewMat); - // adjust viewport for a 2x2 grid layout const viewport = view.viewport; - viewport.x = (view.viewIndex % 2 === 0) ? 0 : width / 2; - viewport.y = (view.viewIndex < 2) ? 0 : height / 2; - viewport.z = width / 2; - viewport.w = height / 2; + if (isWebgpu) { + // each view writes into its own array layer at full size; the composite pass + // arranges the four layers into a 2x2 grid on the canvas + viewport.x = 0; + viewport.y = 0; + viewport.z = width; + viewport.w = height; + } else { + // WebGL: 4 sub-viewports of a single canvas-sized backbuffer (2x2 grid). + // WebGL viewport y=0 is the bottom of the canvas, so views 0,1 go in the top + // row (y = height/2) to match the WebGPU composite's top-down layout. + viewport.x = (view.viewIndex % 2 === 0) ? 0 : width / 2; + viewport.y = (view.viewIndex < 2) ? height / 2 : 0; + viewport.z = width / 2; + viewport.w = height / 2; + } }); + }); }); diff --git a/src/platform/graphics/webgpu/webgpu-graphics-device.js b/src/platform/graphics/webgpu/webgpu-graphics-device.js index a124060eadb..e29df713e0a 100644 --- a/src/platform/graphics/webgpu/webgpu-graphics-device.js +++ b/src/platform/graphics/webgpu/webgpu-graphics-device.js @@ -170,6 +170,36 @@ class WebgpuGraphicsDevice extends GraphicsDevice { */ xrColorTextureViewFormat = null; + /** + * Optional `GPUTextureViewDescriptor` describing how the framebuffer's color attachment view + * should be created from {@link WebgpuGraphicsDevice#xrColorTexture}. Used to pick the right + * array layer / mip when XR provides a layered (texture array) projection layer. Set per eye + * by {@link FramePassMultiView}; cleared back to `null` outside the per-view loop. + * + * @type {any} // `GPUTextureViewDescriptor | null`; using `any` to avoid exporting WebGPU types in published typings. + * @ignore + */ + xrColorTextureViewDescriptor = null; + + /** + * Per-view XR sub-image entries populated each frame by the WebGPU XR bridge. Each entry + * describes one XR view: the underlying GPU color texture, the view descriptor that selects the + * right slice, the viewport, and the view's GPU format. Empty outside immersive WebGPU XR. + * + * @type {{ colorTexture: any, viewDescriptor: any, viewport: any, viewFormat: any }[]} + * @ignore + */ + xrSubImages = []; + + /** + * Active XR view index for the multi-view rendering wrapper, or `-1` when not iterating views. + * Read by the forward renderer's per-view inner loop to render only the active eye. + * + * @type {number} + * @ignore + */ + xrCurrentViewIndex = -1; + /** * When set, used as the main color attachment in {@link WebgpuGraphicsDevice#frameStart} if there is * no XR color texture and no canvas {@link GPUCanvasContext#getCurrentTexture} (for example headless @@ -238,14 +268,27 @@ class WebgpuGraphicsDevice extends GraphicsDevice { this.resolver.destroy(); this.resolver = null; - this.xrColorTexture = null; - this.xrColorTextureViewFormat = null; + this._clearXrState(); this.externalBackbuffer = null; super.destroy(); } + /** + * Reset all per-frame WebGPU XR render state to its inactive defaults. Called by the XR bridge + * at the end of each XR frame and on session teardown, and by the graphics device on destroy. + * + * @ignore + */ + _clearXrState() { + this.xrColorTexture = null; + this.xrColorTextureViewFormat = null; + this.xrColorTextureViewDescriptor = null; + this.xrSubImages.length = 0; + this.xrCurrentViewIndex = -1; + } + initDeviceCaps() { const limits = this.wgpu?.limits; diff --git a/src/platform/graphics/webgpu/webgpu-render-target.js b/src/platform/graphics/webgpu/webgpu-render-target.js index c70f22eee5e..ff95e47344c 100644 --- a/src/platform/graphics/webgpu/webgpu-render-target.js +++ b/src/platform/graphics/webgpu/webgpu-render-target.js @@ -196,9 +196,15 @@ class WebgpuRenderTarget { Debug.assert(gpuTexture); this.assignedColorTexture = gpuTexture; - // create view (optionally handles srgb conversion) - const view = gpuTexture.createView({ format: viewFormat }); - DebugHelper.setLabel(view, 'Framebuffer.assignedColor'); + const wgpuDevice = /** @type {WebgpuGraphicsDevice} */ (this.renderTarget.device); + const xrViewDesc = wgpuDevice?.xrColorTextureViewDescriptor; + // WebXR may supply a per-eye view descriptor (e.g. array layer); merge in our view format + // for sRGB / reinterpret when it matches the session color texture. + const xrSlice = xrViewDesc && gpuTexture === wgpuDevice.xrColorTexture; + const view = gpuTexture.createView( + xrSlice ? { ...xrViewDesc, format: viewFormat } : { format: viewFormat } + ); + DebugHelper.setLabel(view, xrSlice ? 'Framebuffer.xrColorTextureView' : 'Framebuffer.contextColorTextureView'); // use it as render buffer or resolve target const colorAttachment = this.renderPassDescriptor.colorAttachments[0]; diff --git a/src/platform/graphics/webgpu/webgpu-xr-bridge.js b/src/platform/graphics/webgpu/webgpu-xr-bridge.js index fbb8002a03f..ef2b793ba25 100644 --- a/src/platform/graphics/webgpu/webgpu-xr-bridge.js +++ b/src/platform/graphics/webgpu/webgpu-xr-bridge.js @@ -48,8 +48,7 @@ class WebgpuXrBridge { this._binding = null; this._layer = null; this._cachedFramebufferSize.set(0, 0); - device.xrColorTexture = null; - device.xrColorTextureViewFormat = null; + device._clearXrState(); } /** @@ -59,8 +58,7 @@ class WebgpuXrBridge { beginFrame(frame, referenceSpace) { const device = this.xrBridge.device; - device.xrColorTexture = null; - device.xrColorTextureViewFormat = null; + device._clearXrState(); if (!this._binding || !this._layer || !referenceSpace) { return; @@ -71,35 +69,55 @@ class WebgpuXrBridge { return; } - /** @type {XRGPUSubImage|null} */ - let firstSub = null; + const subImages = device.xrSubImages; for (let i = 0; i < pose.views.length; i++) { + let sub; try { - const sub = this._binding.getViewSubImage(this._layer, pose.views[i]); - // TODO: stereo / multiview WebGPU — drive per-eye color (e.g. texture-array projection layer); - // v1 only keeps the first view for xrColorTexture (see createProjectionLayer TODO). - if (i === 0) { - firstSub = sub; - } + sub = this._binding.getViewSubImage(this._layer, pose.views[i]); } catch (e) { this.xrBridge._onBindingError?.(e); return; } + + const colorTexture = sub?.colorTexture; + if (!colorTexture) continue; + + // pull the per-view GPUTextureViewDescriptor from the sub-image when available; this is + // required when the runtime returns a layered texture (texture-array) for stereo so each + // eye binds the right slice + let viewDescriptor = null; + if (typeof sub.getViewDescriptor === 'function') { + try { + const desc = sub.getViewDescriptor(); + if (desc) { + viewDescriptor = { ...desc }; + } + } catch (e) { + // descriptor optional - fall through to default view + } + } + + subImages.push({ + colorTexture, + viewDescriptor, + viewport: sub.viewport, + viewFormat: colorTexture.format + }); } - if (firstSub?.colorTexture) { - device.xrColorTexture = firstSub.colorTexture; - device.xrColorTextureViewFormat = firstSub.colorTexture.format; - this._cachedFramebufferSize.set(firstSub.colorTexture.width, firstSub.colorTexture.height); + // First view: seeds device.xr* for WebGPU frameStart (runs before per-eye rendering) and + // caches texture size for getFramebufferSize when the projection layer omits dimensions. + const first = subImages[0]; + if (first) { + device.xrColorTexture = first.colorTexture; + device.xrColorTextureViewFormat = first.viewFormat; + this._cachedFramebufferSize.set(first.colorTexture.width, first.colorTexture.height); } } endFrame() { const device = this.xrBridge.device; - if (device) { - device.xrColorTexture = null; - device.xrColorTextureViewFormat = null; - } + device?._clearXrState(); } /** @@ -270,8 +288,7 @@ class WebgpuXrBridge { this._binding = null; this._layer = null; this._cachedFramebufferSize.set(0, 0); - device.xrColorTexture = null; - device.xrColorTextureViewFormat = null; + device._clearXrState(); session.updateRenderState({ layers: [], diff --git a/src/scene/frame-graph.js b/src/scene/frame-graph.js index 71237bfcfbe..3f7a6ef0427 100644 --- a/src/scene/frame-graph.js +++ b/src/scene/frame-graph.js @@ -1,7 +1,9 @@ import { Debug } from '../core/debug.js'; +import { FramePassMultiView } from './renderer/frame-pass-multi-view.js'; /** * @import { FramePass } from '../platform/graphics/frame-pass.js' + * @import { GraphicsDevice } from '../platform/graphics/graphics-device.js' * @import { RenderPass } from '../platform/graphics/render-pass.js' * @import { RenderTarget } from '../platform/graphics/render-target.js' * @import { Texture } from '../platform/graphics/texture.js' @@ -23,6 +25,40 @@ class FrameGraph { */ renderTargetMap = new Map(); + /** + * 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#beginMultiView} / {@link FrameGraph#endMultiView}. + * + * @type {FramePassMultiView|null} + */ + multiview = null; + + /** + * Open a multi-view capture scope. Subsequent passes added through + * {@link FrameGraph#addRenderPass} 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. + */ + beginMultiView(device) { + Debug.assert(!this.multiview, 'FrameGraph.beginMultiView called while a scope is already open'); + this.multiview = new FramePassMultiView(device); + } + + /** + * Close the multi-view capture scope. Pushes the wrapper into the frame graph render passes + * unless it captured no children (in which case it is dropped). + */ + endMultiView() { + const wrap = this.multiview; + this.multiview = null; + if (wrap?.children.length) { + this.renderPasses.push(wrap); + } + } + /** * Add a frame pass to the frame. * @@ -41,7 +77,11 @@ class FrameGraph { } if (renderPass.enabled) { - this.renderPasses.push(renderPass); + if (this.multiview) { + this.multiview.addChild(renderPass); + } else { + this.renderPasses.push(renderPass); + } } const afterPasses = renderPass.afterPasses; @@ -58,11 +98,31 @@ class FrameGraph { } compile() { + this._compilePasses(this.renderPasses); + + // apply the same pass-merging / cube-mipmap optimisations to each multi-view wrapper's + // children so within-eye sequences benefit from the same optimisations as top-level passes + for (let i = 0; i < this.renderPasses.length; i++) { + const pass = this.renderPasses[i]; + if (pass instanceof FramePassMultiView) { + this._compilePasses(pass.children); + } + } + } + + /** + * Run the frame-graph compile optimisations (store-on-no-clear, pass merging, cube mipmap + * skipping) over a flat list of passes. + * + * @param {FramePass[]} passes - Passes to optimise. + * @private + */ + _compilePasses(passes) { const renderTargetMap = this.renderTargetMap; - const renderPasses = this.renderPasses; - for (let i = 0; i < renderPasses.length; i++) { - const renderPass = renderPasses[i]; + + for (let i = 0; i < passes.length; i++) { + const renderPass = passes[i]; renderPass._skipStart = false; renderPass._skipEnd = false; @@ -97,10 +157,10 @@ class FrameGraph { } // merge passes if possible - for (let i = 0; i < renderPasses.length - 1; i++) { - const firstPass = renderPasses[i]; + for (let i = 0; i < passes.length - 1; i++) { + const firstPass = passes[i]; const firstRT = firstPass.renderTarget; - const secondPass = renderPasses[i + 1]; + const secondPass = passes[i + 1]; const secondRT = secondPass.renderTarget; // if the render targets are different, we can't merge the passes @@ -139,8 +199,8 @@ class FrameGraph { let lastCubeTexture = null; /** @type {RenderPass|null} */ let lastCubeRenderPass = null; - for (let i = 0; i < renderPasses.length; i++) { - const renderPass = renderPasses[i]; + for (let i = 0; i < passes.length; i++) { + const renderPass = passes[i]; const renderTarget = renderPass.renderTarget; const thisTexture = renderTarget?.colorBuffer; diff --git a/src/scene/renderer/forward-renderer.js b/src/scene/renderer/forward-renderer.js index af8f8e96922..bc99b6e216c 100644 --- a/src/scene/renderer/forward-renderer.js +++ b/src/scene/renderer/forward-renderer.js @@ -584,6 +584,13 @@ class ForwardRenderer extends Renderer { // multiview xr rendering const viewList = camera.xr?.session && camera.xr.views.list.length ? camera.xr.views.list : null; + // when the FramePassMultiView wrapper is iterating XR views, render only the active one + // (xrCurrentViewIndex === -1 means "no wrapper active": fall back to the default behaviour + // of rendering all views or the single non-XR view) + const activeView = device.xrCurrentViewIndex ?? -1; + const viewListStart = (viewList && activeView >= 0) ? activeView : 0; + const viewListEnd = (viewList && activeView >= 0) ? activeView + 1 : (viewList ? viewList.length : 0); + // Render the scene const preparedCallsCount = preparedCalls.drawCalls.length; for (let i = 0; i < preparedCallsCount; i++) { @@ -660,7 +667,7 @@ class ForwardRenderer extends Renderer { const indirectData = drawCall.getDrawCommands(camera); if (viewList) { - for (let v = 0; v < viewList.length; v++) { + for (let v = viewListStart; v < viewListEnd; v++) { const view = viewList[v]; device.setViewport(view.viewport.x, view.viewport.y, view.viewport.z, view.viewport.w); @@ -675,8 +682,8 @@ class ForwardRenderer extends Renderer { this.setupViewUniforms(view, v); } - const first = v === 0; - const last = v === viewList.length - 1; + const first = v === viewListStart; + const last = v === viewListEnd - 1; device.draw(mesh.primitive[style], indexBuffer, instancingData?.count, indirectData, first, last); this._forwardDrawCalls++; @@ -910,6 +917,7 @@ class ForwardRenderer extends Renderer { const renderAction = renderActions[i]; const { layer, camera } = renderAction; + const mv = this._isMultiview(camera); if (renderAction.useCameraPasses) { @@ -919,10 +927,13 @@ class ForwardRenderer extends Renderer { } }); - // schedule frame passes from the camera + // schedule frame passes from the camera, capturing them into a FramePassMultiView + // wrapper if the camera needs per-view replication + if (mv) frameGraph.beginMultiView(this.device); camera.camera.framePasses.forEach((renderPass) => { frameGraph.addRenderPass(renderPass); }); + if (mv) frameGraph.endMultiView(); } else { @@ -942,13 +953,21 @@ class ForwardRenderer extends Renderer { const isNextLayerGrabPass = isNextLayerDepth && (camera.renderSceneColorMap || camera.renderSceneDepthMap); const nextNeedDirShadows = nextRenderAction ? (nextRenderAction.firstCameraUse && this.cameraDirShadowLights.has(nextRenderAction.camera.camera)) : false; - // end of the block using the same render target if the next render action uses a different render target, or needs directional shadows - // rendered before it or similar or needs other pass before it. + // end of the block using the same render target if the next render action uses a different render target, + // a different camera, or needs directional shadows rendered before it or similar. if (!nextRenderAction || nextRenderAction.renderTarget !== renderTarget || + nextRenderAction.camera !== camera || nextNeedDirShadows || isNextLayerGrabPass || isGrabPass) { // render the render actions in the range const isDepthOnly = isDepthLayer && startIndex === i; + + if (mv && (camera.renderSceneColorMap || camera.renderSceneDepthMap || (renderAction.triggerPostprocess && camera?.onPostprocessing))) { + Debug.errorOnce('FramePassMultiView: depth/color grab passes and per-camera postprocessing are not yet supported with WebGPU stereo XR; rendering may be incorrect.'); + } + + if (mv) frameGraph.beginMultiView(this.device); + if (!isDepthOnly) { this.addMainRenderPass(frameGraph, layerComposition, renderTarget, startIndex, i); } @@ -973,12 +992,29 @@ class ForwardRenderer extends Renderer { frameGraph.addRenderPass(renderPass); } + if (mv) frameGraph.endMultiView(); + newStart = true; } } } } + /** + * @param {any} camera - The camera component for the current render action. The XR data lives on + * the underlying `Camera` (`CameraComponent.camera.xr`), not on the component itself, so we + * dereference it before checking. + * @returns {boolean} True if the camera should have its passes replicated per XR view (currently + * gated to the WebGPU backend; other backends keep the existing single-pass multi-viewport flow). + * @private + */ + _isMultiview(camera) { + const xr = camera.camera?.xr; + return this.device.isWebGPU && + !!xr?.session && + xr.views.list.length >= 2; + } + /** * @param {FrameGraph} frameGraph - The frame graph. * @param {LayerComposition} layerComposition - The layer composition. diff --git a/src/scene/renderer/frame-pass-multi-view.js b/src/scene/renderer/frame-pass-multi-view.js new file mode 100644 index 00000000000..284b3f9ad6a --- /dev/null +++ b/src/scene/renderer/frame-pass-multi-view.js @@ -0,0 +1,131 @@ +import { FramePass } from '../../platform/graphics/frame-pass.js'; + +/** + * @import { GraphicsDevice } from '../../platform/graphics/graphics-device.js' + */ + +/** + * A frame pass that wraps an ordered list of child frame passes and runs them once per XR view. + * Currently used by the WebGPU XR path: per eye, the wrapper sets the active view index on the + * 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 + * 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. + * + * ## Future extension paths + * + * ### GPU-native multiview (single-pass stereo) + * Both WebGL (`OVR_multiview2`) and a future WebGPU multiview extension allow the GPU to render + * all views in **one draw call**, writing to each array layer simultaneously via + * `gl_ViewID_OVR` (WebGL) or `@builtin(view_index)` (WGSL). When those APIs become available + * this class is the right place to switch strategy: instead of looping N times, `render()` would + * configure a single multiview render pass targeting an array render target, upload all N view + * matrices as an array UBO, and issue children once. The serial-iteration path would remain as a + * fallback when the extension is absent. + * + * ### WebGL stereo + * WebGL XR currently uses a single framebuffer with per-eye viewports (no wrapper needed). + * If `OVR_multiview2` support is added, `ForwardRenderer._isMultiview` could be extended to + * return `true` for WebGL when the extension is present, allowing this wrapper to orchestrate + * the multiview setup on both backends with a shared code path. + * + * @ignore + */ +class FramePassMultiView extends FramePass { + /** + * Ordered list of child passes executed once per XR view. + * + * @type {FramePass[]} + */ + children = []; + + /** + * @param {GraphicsDevice} graphicsDevice - The graphics device. + */ + constructor(graphicsDevice) { + super(graphicsDevice); + this.name = 'FramePassMultiView'; + } + + /** + * Append a child pass to be replayed per view. + * + * @param {FramePass} pass - The pass to add. + */ + addChild(pass) { + this.children.push(pass); + } + + render() { + if (!this.enabled) return; + + const device = this.device; + const subs = device.xrSubImages; + const numViews = subs?.length ?? 0; + const children = this.children; + const childCount = children.length; + + // fall back to running children once if no per-view sub-images are available; this lets the + // wrapper degrade gracefully (rendering into whatever the device already has bound) instead + // of dropping the frame entirely + if (numViews === 0) { + for (let c = 0; c < childCount; c++) { + children[c].render(); + } + return; + } + + const backBufferImpl = device.backBuffer?.impl; + + // snapshot device-level XR state and the backbuffer's color texture before the per-eye + // loop so we can restore everything once we're done: + // + // - xrColorTexture: if not restored, frameStart on the *next* frame picks up the last + // eye's sub-image texture as the output color buffer instead of the canvas swapchain. + // In real WebGPU XR the bridge sets and clears this itself; saving/restoring here is + // a no-op in that case because saved == per-eye == XR projection texture. + // + // - backbuffer assignedColorTexture: lets passes after the wrapper (composite camera, + // HUD, …) keep targeting the original backbuffer instead of the last eye's sub-image. + const savedXrColorTexture = device.xrColorTexture; + const savedColorTexture = backBufferImpl?.assignedColorTexture ?? null; + const savedViewFormat = backBufferImpl?.colorAttachments?.[0]?.format ?? null; + + for (let v = 0; v < numViews; v++) { + const sub = subs[v]; + + device.xrCurrentViewIndex = v; + device.xrColorTexture = sub.colorTexture; + device.xrColorTextureViewDescriptor = sub.viewDescriptor; + + // refresh the backbuffer's color attachment view to point to the active eye's sub-image + backBufferImpl?.assignColorTexture?.(sub.colorTexture, sub.viewFormat); + + for (let c = 0; c < childCount; c++) { + children[c].render(); + } + } + + // Clear only wrapper-owned XR device fields. Do not call _clearXrState() here: that also + // clears xrSubImages / xrColorTextureViewFormat for the frame, which would break a second + // FramePassMultiView in the same frame (numViews would read as 0). The XR bridge clears + // full state at endFrame. + device.xrCurrentViewIndex = -1; + device.xrColorTextureViewDescriptor = null; + device.xrColorTexture = savedXrColorTexture ?? null; + + // restore the backbuffer to whatever it was bound to before the per-eye loop, but only if + // it actually changed - skips the cost of a redundant view re-creation in the common XR + // case where every sub-image targets the same projection-layer texture + if ( + backBufferImpl && savedColorTexture && savedViewFormat && + backBufferImpl.assignedColorTexture !== savedColorTexture + ) { + backBufferImpl.assignColorTexture(savedColorTexture, savedViewFormat); + } + } +} + +export { FramePassMultiView }; diff --git a/src/scene/renderer/world-clusters-allocator.js b/src/scene/renderer/world-clusters-allocator.js index a3a90374389..dad69778180 100644 --- a/src/scene/renderer/world-clusters-allocator.js +++ b/src/scene/renderer/world-clusters-allocator.js @@ -1,5 +1,6 @@ import { DebugHelper } from '../../core/debug.js'; import { WorldClusters } from '../lighting/world-clusters.js'; +import { FramePassMultiView } from './frame-pass-multi-view.js'; /** * @import { GraphicsDevice } from '../../platform/graphics/graphics-device.js' @@ -81,56 +82,75 @@ class WorldClustersAllocator { return this._empty; } - // assign light clusters to render actions that need it - assign(renderPasses) { + /** + * Assign clusters for one frame pass that owns {@link RenderPass#renderActions}. + * No-op when the pass has no render actions. + * + * @param {import('../../platform/graphics/frame-pass.js').FramePass} renderPass - Render pass + * (not a {@link FramePassMultiView} wrapper; those are unwrapped in {@link WorldClustersAllocator#assign}). + * @private + */ + _assignClustersForPass(renderPass) { + const renderActions = renderPass.renderActions; + if (!renderActions) { + return; + } - // reuse previously allocated clusters - tempClusterArray.push(...this._allocated); - this._allocated.length = 0; - this._clusters.clear(); + const count = renderActions.length; + for (let i = 0; i < count; i++) { + const ra = renderActions[i]; + ra.lightClusters = null; - // update render actions in passes that use them - const passCount = renderPasses.length; - for (let p = 0; p < passCount; p++) { + // if the layer has lights used by clusters, and meshes + const layer = ra.layer; + if (layer.hasClusteredLights && layer.meshInstances.length) { - const renderPass = renderPasses[p]; - const renderActions = renderPass.renderActions; - if (renderActions) { + // use existing clusters if the lights on the layer are the same + const hash = layer.getLightIdHash(); + const existingRenderAction = this._clusters.get(hash); + let clusters = existingRenderAction?.lightClusters; - // process all render actions - const count = renderActions.length; - for (let i = 0; i < count; i++) { - const ra = renderActions[i]; - ra.lightClusters = null; + // no match, needs new clusters + if (!clusters) { - // if the layer has lights used by clusters, and meshes - const layer = ra.layer; - if (layer.hasClusteredLights && layer.meshInstances.length) { + // use already allocated cluster from last frame, or create a new one + clusters = tempClusterArray.pop() ?? new WorldClusters(this.device); + DebugHelper.setName(clusters, `Cluster-${this._allocated.length}`); - // use existing clusters if the lights on the layer are the same - const hash = layer.getLightIdHash(); - const existingRenderAction = this._clusters.get(hash); - let clusters = existingRenderAction?.lightClusters; + this._allocated.push(clusters); + this._clusters.set(hash, ra); + } - // no match, needs new clusters - if (!clusters) { + ra.lightClusters = clusters; + } - // use already allocated cluster from last frame, or create a new one - clusters = tempClusterArray.pop() ?? new WorldClusters(this.device); - DebugHelper.setName(clusters, `Cluster-${this._allocated.length}`); + // no clustered lights, use the cluster with no lights + if (!ra.lightClusters) { + ra.lightClusters = this.empty; + } + } + } - this._allocated.push(clusters); - this._clusters.set(hash, ra); - } + // assign light clusters to render actions that need it + assign(renderPasses) { - ra.lightClusters = clusters; - } + // reuse previously allocated clusters + tempClusterArray.push(...this._allocated); + this._allocated.length = 0; + this._clusters.clear(); - // no clustered lights, use the cluster with no lights - if (!ra.lightClusters) { - ra.lightClusters = this.empty; - } + // FramePassMultiView children are not on the frame graph list (merge safety); still assign + // clusters to their render actions before those passes run. + const passCount = renderPasses.length; + for (let p = 0; p < passCount; p++) { + const pass = renderPasses[p]; + if (pass instanceof FramePassMultiView) { + const children = pass.children; + for (let c = 0; c < children.length; c++) { + this._assignClustersForPass(children[c]); } + } else { + this._assignClustersForPass(pass); } }