From 316337fa29aabd6bb0ba8f67099ae6eb603427d8 Mon Sep 17 00:00:00 2001 From: Raanan Weber Date: Fri, 3 Jul 2026 14:23:17 +0200 Subject: [PATCH 1/4] feat(xr): add WebGPU XR projection layer render target providers Add @internal, un-barrelled WebGPU render target texture providers that mirror the WebGL layer providers but extend the API-agnostic base WebXRLayerRenderTargetTextureProvider. They wrap the per-view XRGPUSubImage color/depth GPUTextures via WebGPUEngine.wrapWebGPUTexture, set the correct Babylon depth format on the wrapped depth texture, repoint per-frame via updateWrappedWebGPUTexture (rebuilding only on size change), and build RTTs via _createRenderTargetTextureInternal. Includes unit tests. Inert until WebXRLayers wiring is activated. Part of Phase 2 (#18640), epic #18635. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../Layers/WebXRWebGPUCompositionLayer.ts | 128 +++++++++++++++ .../Layers/WebXRWebGPUProjectionLayer.ts | 100 ++++++++++++ .../webXRWebGPURenderTargetTextureProvider.ts | 106 +++++++++++++ .../XR/webXRWebGPUProjectionLayer.test.ts | 148 ++++++++++++++++++ 4 files changed, 482 insertions(+) create mode 100644 packages/dev/core/src/XR/features/Layers/WebXRWebGPUCompositionLayer.ts create mode 100644 packages/dev/core/src/XR/features/Layers/WebXRWebGPUProjectionLayer.ts create mode 100644 packages/dev/core/src/XR/webXRWebGPURenderTargetTextureProvider.ts create mode 100644 packages/dev/core/test/unit/XR/webXRWebGPUProjectionLayer.test.ts diff --git a/packages/dev/core/src/XR/features/Layers/WebXRWebGPUCompositionLayer.ts b/packages/dev/core/src/XR/features/Layers/WebXRWebGPUCompositionLayer.ts new file mode 100644 index 00000000000..0d67922a3e4 --- /dev/null +++ b/packages/dev/core/src/XR/features/Layers/WebXRWebGPUCompositionLayer.ts @@ -0,0 +1,128 @@ +import { type RenderTargetTexture } from "core/Materials/Textures/renderTargetTexture"; +import { type Viewport } from "core/Maths/math.viewport"; +import { Observable } from "core/Misc/observable"; +import { type WebXRLayerType, WebXRLayerWrapper } from "core/XR/webXRLayerWrapper"; +import { type WebXRLayerRenderTargetTextureProvider } from "core/XR/webXRRenderTargetTextureProvider"; +import { WebXRWebGPURenderTargetTextureProvider } from "core/XR/webXRWebGPURenderTargetTextureProvider"; +import { type WebXRSessionManager } from "core/XR/webXRSessionManager"; +import { type Nullable } from "core/types"; + +/** + * Wraps xr composition layers for the WebGPU (XRGPUBinding) backend. + * Mirrors {@link WebXRCompositionLayerWrapper} for WebGPU. + * @internal + */ +export class WebXRWebGPUCompositionLayerWrapper extends WebXRLayerWrapper { + constructor( + public override getWidth: () => number, + public override getHeight: () => number, + public override readonly layer: XRCompositionLayer, + public override readonly layerType: WebXRLayerType, + public readonly isMultiview: boolean, + public createRTTProvider: (xrSessionManager: WebXRSessionManager) => WebXRLayerRenderTargetTextureProvider + ) { + super(getWidth, getHeight, layer, layerType, createRTTProvider); + } +} + +/** + * Provides render target textures and other important rendering information for a given XRCompositionLayer + * on the WebGPU backend. Mirrors {@link WebXRCompositionLayerRenderTargetTextureProvider}, but wraps + * {@link XRGPUSubImage} GPUTextures instead of WebGL textures. + * @internal + */ +export class WebXRWebGPUCompositionLayerRenderTargetTextureProvider extends WebXRWebGPURenderTargetTextureProvider { + protected _lastSubImages = new Map(); + private _compositionLayer: XRCompositionLayer; + /** + * Fires every time a new render target texture is created (either for eye, for view, or for the entire frame) + */ + public onRenderTargetTextureCreatedObservable = new Observable<{ texture: RenderTargetTexture; eye?: XREye }>(); + + constructor( + protected readonly _xrSessionManager: WebXRSessionManager, + protected readonly _xrGPUBinding: XRGPUBinding, + public override readonly layerWrapper: WebXRWebGPUCompositionLayerWrapper, + protected readonly _depthStencilFormat?: GPUTextureFormat + ) { + super(_xrSessionManager.scene, layerWrapper); + this._compositionLayer = layerWrapper.layer; + } + + protected _getRenderTargetForSubImage(subImage: XRGPUSubImage, eye: XREye = "none") { + const lastSubImage = this._lastSubImages.get(eye); + const eyeIndex = eye == "right" ? 1 : 0; + + const colorTexture = subImage.colorTexture; + const colorTextureWidth = colorTexture.width; + const colorTextureHeight = colorTexture.height; + + const depthStencilTexture = subImage.depthStencilTexture ?? null; + + const existingRenderTarget = this._renderTargetTextures[eyeIndex]; + const sizeChanged = !existingRenderTarget || lastSubImage?.colorTexture.width !== colorTextureWidth || lastSubImage?.colorTexture.height !== colorTextureHeight; + + if (sizeChanged) { + this._renderTargetTextures[eyeIndex] = this._createRenderTargetTextureFromGPUTextures( + colorTextureWidth, + colorTextureHeight, + colorTexture, + depthStencilTexture, + this._depthStencilFormat, + this.layerWrapper.isMultiview + ); + + this._framebufferDimensions = { + framebufferWidth: colorTextureWidth, + framebufferHeight: colorTextureHeight, + }; + this.onRenderTargetTextureCreatedObservable.notifyObservers({ texture: this._renderTargetTextures[eyeIndex], eye }); + } else { + // Same size: repoint the wrapped textures at this frame's GPUTextures, preserving the + // RenderTargetTexture / InternalTexture identity held by the XR camera's outputRenderTarget. + this._updateRenderTargetTextureFromGPUTextures(existingRenderTarget, colorTexture, depthStencilTexture); + } + + this._lastSubImages.set(eye, subImage); + return this._renderTargetTextures[eyeIndex]; + } + + private _getSubImageForEye(eye?: XREye): Nullable { + const currentFrame = this._xrSessionManager.currentFrame; + if (currentFrame) { + return this._xrGPUBinding.getSubImage(this._compositionLayer, currentFrame, eye); + } + return null; + } + + public getRenderTargetTextureForEye(eye?: XREye): Nullable { + const subImage = this._getSubImageForEye(eye); + if (subImage) { + return this._getRenderTargetForSubImage(subImage, eye); + } + return null; + } + + public getRenderTargetTextureForView(view?: XRView): Nullable { + return this.getRenderTargetTextureForEye(view?.eye); + } + + protected _setViewportForSubImage(viewport: Viewport, subImage: XRGPUSubImage) { + const textureWidth = subImage.colorTexture.width; + const textureHeight = subImage.colorTexture.height; + const xrViewport = subImage.viewport; + viewport.x = xrViewport.x / textureWidth; + viewport.y = xrViewport.y / textureHeight; + viewport.width = xrViewport.width / textureWidth; + viewport.height = xrViewport.height / textureHeight; + } + + public trySetViewportForView(viewport: Viewport, view: XRView): boolean { + const subImage = this._lastSubImages.get(view.eye) || this._getSubImageForEye(view.eye); + if (subImage) { + this._setViewportForSubImage(viewport, subImage); + return true; + } + return false; + } +} diff --git a/packages/dev/core/src/XR/features/Layers/WebXRWebGPUProjectionLayer.ts b/packages/dev/core/src/XR/features/Layers/WebXRWebGPUProjectionLayer.ts new file mode 100644 index 00000000000..862981676eb --- /dev/null +++ b/packages/dev/core/src/XR/features/Layers/WebXRWebGPUProjectionLayer.ts @@ -0,0 +1,100 @@ +import { type WebXRSessionManager } from "core/XR/webXRSessionManager"; +import { WebXRWebGPUCompositionLayerRenderTargetTextureProvider, WebXRWebGPUCompositionLayerWrapper } from "./WebXRWebGPUCompositionLayer"; +import { type Nullable } from "core/types"; +import { type RenderTargetTexture } from "core/Materials/Textures/renderTargetTexture"; +import { type Viewport } from "core/Maths/math.viewport"; + +/** + * Wraps xr projection layers for the WebGPU (XRGPUBinding) backend. + * Mirrors {@link WebXRProjectionLayerWrapper} for WebGPU. + * @internal + */ +export class WebXRWebGPUProjectionLayerWrapper extends WebXRWebGPUCompositionLayerWrapper { + constructor( + public override readonly layer: XRProjectionLayer, + isMultiview: boolean, + xrGPUBinding: XRGPUBinding, + depthStencilFormat?: GPUTextureFormat + ) { + super( + () => layer.textureWidth, + () => layer.textureHeight, + layer, + "XRProjectionLayer", + isMultiview, + (sessionManager) => new WebXRWebGPUProjectionLayerRenderTargetTextureProvider(sessionManager, xrGPUBinding, this, depthStencilFormat) + ); + } +} + +/** + * Provides render target textures and other important rendering information for a given XRProjectionLayer + * on the WebGPU backend. + * @internal + */ +class WebXRWebGPUProjectionLayerRenderTargetTextureProvider extends WebXRWebGPUCompositionLayerRenderTargetTextureProvider { + private readonly _projectionLayer: XRProjectionLayer; + + constructor( + _xrSessionManager: WebXRSessionManager, + _xrGPUBinding: XRGPUBinding, + public override readonly layerWrapper: WebXRWebGPUProjectionLayerWrapper, + depthStencilFormat?: GPUTextureFormat + ) { + super(_xrSessionManager, _xrGPUBinding, layerWrapper, depthStencilFormat); + this._projectionLayer = layerWrapper.layer; + } + + private _getSubImageForView(view: XRView): XRGPUSubImage { + return this._xrGPUBinding.getViewSubImage(this._projectionLayer, view); + } + + public override getRenderTargetTextureForView(view: XRView): Nullable { + return this._getRenderTargetForSubImage(this._getSubImageForView(view), view.eye); + } + + public override getRenderTargetTextureForEye(eye: XREye): Nullable { + const lastSubImage = this._lastSubImages.get(eye); + if (lastSubImage) { + return this._getRenderTargetForSubImage(lastSubImage, eye); + } + return null; + } + + public override trySetViewportForView(viewport: Viewport, view: XRView): boolean { + const subImage = this._lastSubImages.get(view.eye) || this._getSubImageForView(view); + if (subImage) { + this._setViewportForSubImage(viewport, subImage); + return true; + } + return false; + } +} + +/** + * The default depth/stencil format used for a WebGPU projection layer. + * Mirrors the WebGL default (DEPTH24_STENCIL8). + * @internal + */ +export const DefaultXRGPUProjectionLayerDepthStencilFormat: GPUTextureFormat = "depth24plus-stencil8"; + +/** + * Builds the default XRGPUProjectionLayerInit for a WebGPU projection layer. + * The color format must be the binding's preferred color format, so it is provided by the caller. + * @param colorFormat the preferred color format reported by the XRGPUBinding + * @param depthStencilFormat the depth/stencil format to request (defaults to depth24plus-stencil8) + * @returns the projection layer init to pass to XRGPUBinding.createProjectionLayer + * @internal + */ +export function CreateDefaultXRGPUProjectionLayerInit( + colorFormat: GPUTextureFormat, + depthStencilFormat: GPUTextureFormat = DefaultXRGPUProjectionLayerDepthStencilFormat +): XRGPUProjectionLayerInit { + return { + colorFormat, + depthStencilFormat, + // GPUTextureUsage.RENDER_ATTACHMENT (0x10) — the spec default, stated explicitly here. + textureUsage: 0x10, + scaleFactor: 1.0, + }; +} diff --git a/packages/dev/core/src/XR/webXRWebGPURenderTargetTextureProvider.ts b/packages/dev/core/src/XR/webXRWebGPURenderTargetTextureProvider.ts new file mode 100644 index 00000000000..9f4a7f075d9 --- /dev/null +++ b/packages/dev/core/src/XR/webXRWebGPURenderTargetTextureProvider.ts @@ -0,0 +1,106 @@ +import { Constants } from "../Engines/constants"; +import { type WebGPUEngine } from "../Engines/webgpuEngine"; +import { type InternalTexture } from "../Materials/Textures/internalTexture"; +import { type RenderTargetTexture } from "../Materials/Textures/renderTargetTexture.pure"; +import { type Nullable } from "../types"; +import { WebXRLayerRenderTargetTextureProvider } from "./webXRRenderTargetTextureProvider"; + +/** + * Maps a WebGPU depth/stencil {@link GPUTextureFormat} to the matching Babylon `TEXTUREFORMAT_*` constant. + * + * The engine reads `InternalTexture.format` (a Babylon constant) when it builds the render pass depth + * attachment for a render target, so a depth texture wrapped from an external `GPUTexture` must carry the + * correct Babylon format or the depth attachment pipeline format will not match the sub-image's texture. + * @param format the WebGPU depth/stencil format requested when the layer was created + * @returns the matching Babylon texture format constant + */ +function GetBabylonDepthFormat(format: GPUTextureFormat): number { + switch (format) { + case "depth16unorm": + return Constants.TEXTUREFORMAT_DEPTH16; + case "depth24plus": + return Constants.TEXTUREFORMAT_DEPTH24; + case "depth24plus-stencil8": + return Constants.TEXTUREFORMAT_DEPTH24_STENCIL8; + case "depth32float": + return Constants.TEXTUREFORMAT_DEPTH32_FLOAT; + case "depth32float-stencil8": + return Constants.TEXTUREFORMAT_DEPTH32FLOAT_STENCIL8; + case "stencil8": + return Constants.TEXTUREFORMAT_STENCIL8; + default: + // Fall back to a combined depth/stencil format, which is the projection layer default. + return Constants.TEXTUREFORMAT_DEPTH24_STENCIL8; + } +} + +/** + * Provides render target textures for WebGPU-backed XR layers. Owns all WebGPU-specific texture wrapping + * (via {@link WebGPUEngine.wrapWebGPUTexture} / {@link WebGPUEngine.updateWrappedWebGPUTexture}) so the base + * provider can stay graphics-API-agnostic. Mirrors {@link WebXRWebGLRenderTargetTextureProvider} for the + * WebGPU (XRGPUBinding) backend. + * @internal + */ +export abstract class WebXRWebGPURenderTargetTextureProvider extends WebXRLayerRenderTargetTextureProvider { + private get _webgpuEngine(): WebGPUEngine { + return this._engine as WebGPUEngine; + } + + private _wrapColorTexture(texture: GPUTexture): InternalTexture { + return this._webgpuEngine.wrapWebGPUTexture(texture); + } + + private _wrapDepthTexture(texture: GPUTexture, depthStencilFormat: GPUTextureFormat): InternalTexture { + const internalTexture = this._webgpuEngine.wrapWebGPUTexture(texture); + // The engine derives the color attachment format automatically from the wrapped hardware texture, + // but the depth attachment format is read from the Babylon InternalTexture.format, which + // wrapWebGPUTexture does not set. Assign it here from the format the layer was created with. + internalTexture.format = GetBabylonDepthFormat(depthStencilFormat); + return internalTexture; + } + + /** + * Wraps the sub-image's color (and optional depth/stencil) GPUTextures and builds a new render target + * texture around them. Use this on first creation and whenever the sub-image texture size changes. + * @param width the width of the render target + * @param height the height of the render target + * @param colorTexture the sub-image color GPUTexture + * @param depthStencilTexture the sub-image depth/stencil GPUTexture, if any + * @param depthStencilFormat the WebGPU depth/stencil format the layer was created with, if depth is provided + * @param multiview whether the render target should be a multiview render target + * @returns the created render target texture + */ + protected _createRenderTargetTextureFromGPUTextures( + width: number, + height: number, + colorTexture: GPUTexture, + depthStencilTexture: Nullable, + depthStencilFormat: GPUTextureFormat | undefined, + multiview: boolean + ): RenderTargetTexture { + const color = this._wrapColorTexture(colorTexture); + const depth = depthStencilTexture && depthStencilFormat ? this._wrapDepthTexture(depthStencilTexture, depthStencilFormat) : null; + return this._createRenderTargetTextureInternal(width, height, color, depth, multiview); + } + + /** + * Repoints the wrapped color/depth textures of an existing render target at the current frame's + * GPUTextures, preserving the RenderTargetTexture / InternalTexture identity held by the XR camera's + * `outputRenderTarget`. The sub-image textures are only valid for the current frame and may rotate from + * a compositor pool, so this must run every frame. The new GPUTextures must have the same dimensions as + * the wrapped ones (guaranteed by the caller's size-change branch, which rebuilds instead). + * @param renderTargetTexture the render target whose wrapped textures should be repointed + * @param colorTexture the current frame's color GPUTexture + * @param depthStencilTexture the current frame's depth/stencil GPUTexture, if any + */ + protected _updateRenderTargetTextureFromGPUTextures(renderTargetTexture: RenderTargetTexture, colorTexture: GPUTexture, depthStencilTexture: Nullable): void { + const color = renderTargetTexture._texture; + if (color) { + this._webgpuEngine.updateWrappedWebGPUTexture(color, colorTexture); + } + const depth = renderTargetTexture.renderTarget?._depthStencilTexture; + if (depth && depthStencilTexture) { + this._webgpuEngine.updateWrappedWebGPUTexture(depth, depthStencilTexture); + } + } +} diff --git a/packages/dev/core/test/unit/XR/webXRWebGPUProjectionLayer.test.ts b/packages/dev/core/test/unit/XR/webXRWebGPUProjectionLayer.test.ts new file mode 100644 index 00000000000..83a68bd2280 --- /dev/null +++ b/packages/dev/core/test/unit/XR/webXRWebGPUProjectionLayer.test.ts @@ -0,0 +1,148 @@ +/** + * @vitest-environment jsdom + */ + +import { Constants } from "core/Engines/constants"; +import { NullEngine } from "core/Engines/nullEngine"; +import { InternalTexture, InternalTextureSource } from "core/Materials/Textures/internalTexture"; +import { Viewport } from "core/Maths/math.viewport"; +import { Scene } from "core/scene"; +import { CreateDefaultXRGPUProjectionLayerInit, WebXRWebGPUProjectionLayerWrapper } from "core/XR/features/Layers/WebXRWebGPUProjectionLayer"; +import { type WebXRSessionManager } from "core/XR/webXRSessionManager"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +// Build a minimal XRGPUSubImage-shaped object. Typed loosely because the WebGPU-XR ambient types are not +// registered as eslint globals in the test environment. +function createSubImage(width: number, height: number, viewport = { x: 0, y: 0, width, height }): any { + return { + colorTexture: { width, height }, + depthStencilTexture: { width, height }, + viewport, + getViewDescriptor: () => ({}), + }; +} + +describe("WebXRWebGPUProjectionLayer", () => { + describe("CreateDefaultXRGPUProjectionLayerInit", () => { + it("uses the provided color format and sensible defaults", () => { + const init = CreateDefaultXRGPUProjectionLayerInit("rgba8unorm"); + expect(init.colorFormat).toBe("rgba8unorm"); + expect(init.depthStencilFormat).toBe("depth24plus-stencil8"); + // GPUTextureUsage.RENDER_ATTACHMENT + expect(init.textureUsage).toBe(0x10); + expect(init.scaleFactor).toBe(1.0); + }); + + it("allows overriding the depth/stencil format", () => { + const init = CreateDefaultXRGPUProjectionLayerInit("rgba8unorm", "depth24plus"); + expect(init.depthStencilFormat).toBe("depth24plus"); + }); + }); + + describe("WebXRWebGPUProjectionLayerRenderTargetTextureProvider", () => { + let engine: NullEngine; + let scene: Scene; + let wrappedTextures: InternalTexture[]; + let wrapSpy: ReturnType; + let updateSpy: ReturnType; + + function createProvider(subImage: any) { + const binding: any = { + getViewSubImage: vi.fn(() => subImage), + getSubImage: vi.fn(() => subImage), + }; + const layer: any = { textureWidth: 512, textureHeight: 512 }; + const wrapper = new WebXRWebGPUProjectionLayerWrapper(layer, false, binding, "depth24plus-stencil8"); + const sessionManager = { scene } as unknown as WebXRSessionManager; + return wrapper.createRenderTargetTextureProvider(sessionManager); + } + + beforeEach(() => { + engine = new NullEngine({ + renderHeight: 256, + renderWidth: 256, + textureSize: 256, + deterministicLockstep: false, + lockstepMaxSteps: 1, + }); + scene = new Scene(engine); + wrappedTextures = []; + wrapSpy = vi.fn((texture: { width: number; height: number }) => { + const internalTexture = new InternalTexture(engine, InternalTextureSource.External, true); + internalTexture.width = internalTexture.baseWidth = texture.width; + internalTexture.height = internalTexture.baseHeight = texture.height; + wrappedTextures.push(internalTexture); + return internalTexture; + }); + updateSpy = vi.fn(); + (engine as any).wrapWebGPUTexture = wrapSpy; + (engine as any).updateWrappedWebGPUTexture = updateSpy; + }); + + afterEach(() => { + scene.dispose(); + engine.dispose(); + }); + + it("builds a render target sized from the sub-image color texture and wraps color + depth", () => { + const provider = createProvider(createSubImage(512, 512)); + const rtt = provider.getRenderTargetTextureForView({ eye: "left" } as XRView); + + expect(rtt).not.toBeNull(); + expect(rtt!.getRenderWidth()).toBe(512); + expect(rtt!.getRenderHeight()).toBe(512); + // One wrap for color, one for depth. + expect(wrapSpy).toHaveBeenCalledTimes(2); + expect(updateSpy).not.toHaveBeenCalled(); + }); + + it("sets the correct Babylon depth format on the wrapped depth texture", () => { + const provider = createProvider(createSubImage(512, 512)); + provider.getRenderTargetTextureForView({ eye: "left" } as XRView); + + const depthTexture = wrappedTextures.find((texture) => texture.format === Constants.TEXTUREFORMAT_DEPTH24_STENCIL8); + expect(depthTexture).toBeDefined(); + }); + + it("repoints the wrapped textures instead of rebuilding when the size is unchanged", () => { + const provider = createProvider(createSubImage(512, 512)); + const first = provider.getRenderTargetTextureForView({ eye: "left" } as XRView); + const second = provider.getRenderTargetTextureForView({ eye: "left" } as XRView); + + // Same RenderTargetTexture identity is preserved across frames. + expect(second).toBe(first); + // No additional wrapping happened (still just the initial color + depth). + expect(wrapSpy).toHaveBeenCalledTimes(2); + // Color + depth were repointed on the second frame. + expect(updateSpy).toHaveBeenCalledTimes(2); + }); + + it("rebuilds the render target when the sub-image size changes", () => { + const binding: any = { getViewSubImage: vi.fn() }; + binding.getViewSubImage.mockReturnValueOnce(createSubImage(512, 512)).mockReturnValueOnce(createSubImage(256, 256)); + const layer: any = { textureWidth: 512, textureHeight: 512 }; + const wrapper = new WebXRWebGPUProjectionLayerWrapper(layer, false, binding, "depth24plus-stencil8"); + const provider = wrapper.createRenderTargetTextureProvider({ scene } as unknown as WebXRSessionManager); + + const first = provider.getRenderTargetTextureForView({ eye: "left" } as XRView); + const second = provider.getRenderTargetTextureForView({ eye: "left" } as XRView); + + expect(second).not.toBe(first); + expect(second!.getRenderWidth()).toBe(256); + // Two wraps for the first RTT, two more for the rebuilt RTT. + expect(wrapSpy).toHaveBeenCalledTimes(4); + }); + + it("normalizes the viewport by the sub-image color texture dimensions", () => { + const provider = createProvider(createSubImage(512, 512, { x: 128, y: 64, width: 256, height: 128 })); + const viewport = new Viewport(0, 0, 1, 1); + const result = provider.trySetViewportForView(viewport, { eye: "left" } as XRView); + + expect(result).toBe(true); + expect(viewport.x).toBeCloseTo(0.25); + expect(viewport.y).toBeCloseTo(0.125); + expect(viewport.width).toBeCloseTo(0.5); + expect(viewport.height).toBeCloseTo(0.25); + }); + }); +}); From 6d17e12004d870a5e4c403f8df72f96a0478a323 Mon Sep 17 00:00:00 2001 From: Raanan Weber Date: Fri, 3 Jul 2026 14:23:17 +0200 Subject: [PATCH 2/4] feat(xr): activate WebGPU projection layer path in WebXRLayers Branch WebXRLayers attach()/isCompatible() on engine.isWebGPU to create an XRGPUBinding projection layer (via the Phase 1 graphics binding) and instantiate the WebGPU projection layer wrapper. The WebGL2 XR path is unchanged; the new branch is only taken when isWebGPU and an XRGPUBinding is present. Part of Phase 2 (#18640), epic #18635. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../core/src/XR/features/WebXRLayers.pure.ts | 52 ++++++++++++++++--- 1 file changed, 46 insertions(+), 6 deletions(-) diff --git a/packages/dev/core/src/XR/features/WebXRLayers.pure.ts b/packages/dev/core/src/XR/features/WebXRLayers.pure.ts index 9f2258ab20a..9f81b75eac0 100644 --- a/packages/dev/core/src/XR/features/WebXRLayers.pure.ts +++ b/packages/dev/core/src/XR/features/WebXRLayers.pure.ts @@ -7,6 +7,8 @@ import { type WebXRLayerWrapper } from "../webXRLayerWrapper"; import { WebXRWebGLLayerWrapper } from "../webXRWebGLLayer"; import { WebXRProjectionLayerWrapper, DefaultXRProjectionLayerInit } from "./Layers/WebXRProjectionLayer"; import { WebXRCompositionLayerRenderTargetTextureProvider, WebXRCompositionLayerWrapper } from "./Layers/WebXRCompositionLayer"; +import { WebXRWebGPUProjectionLayerWrapper, CreateDefaultXRGPUProjectionLayerInit } from "./Layers/WebXRWebGPUProjectionLayer"; +import { WebXRGraphicsBindingType, type WebXRWebGPUGraphicsBinding } from "../webXRGraphicsBinding"; import { type ThinTexture } from "../../Materials/Textures/thinTexture"; import { type DynamicTexture } from "../../Materials/Textures/dynamicTexture.pure"; import { Color4 } from "../../Maths/math.color.pure"; @@ -52,6 +54,8 @@ export class WebXRLayers extends WebXRAbstractFeature { private _glContext: WebGLRenderingContext | WebGL2RenderingContext; private _xrWebGLBinding: XRWebGLBinding; + private _isWebGPU = false; + private _xrGPUBinding?: XRGPUBinding; private _isMultiviewEnabled = false; private _projectionLayerInitialized = false; @@ -78,13 +82,26 @@ export class WebXRLayers extends WebXRAbstractFeature { } const engine = this._xrSessionManager.scene.getEngine(); - this._glContext = (engine as ThinEngine)._gl; - this._xrWebGLBinding = new XRWebGLBinding(this._xrSessionManager.session, this._glContext); this._existingLayers.length = 0; + this._isWebGPU = engine.isWebGPU; - const projectionLayerInit = { ...DefaultXRProjectionLayerInit, ...this._options.projectionLayerInit }; - this._isMultiviewEnabled = this._options.preferMultiviewOnInit && engine.getCaps().multiview; - this.createProjectionLayer(projectionLayerInit /*, projectionLayerMultiview*/); + if (this._isWebGPU) { + const binding = this._xrSessionManager._getGraphicsBinding(); + if (binding.bindingType !== WebXRGraphicsBindingType.WebGPU) { + throw new Error("Expected a WebGPU graphics binding for a WebGPU engine."); + } + this._xrGPUBinding = (binding as WebXRWebGPUGraphicsBinding).binding; + // Multiview is not yet supported on the WebGPU XR path; force single-view (two sub-images). + this._isMultiviewEnabled = false; + this._createWebGPUProjectionLayer(); + } else { + this._glContext = (engine as ThinEngine)._gl; + this._xrWebGLBinding = new XRWebGLBinding(this._xrSessionManager.session, this._glContext); + + const projectionLayerInit = { ...DefaultXRProjectionLayerInit, ...this._options.projectionLayerInit }; + this._isMultiviewEnabled = this._options.preferMultiviewOnInit && engine.getCaps().multiview; + this.createProjectionLayer(projectionLayerInit /*, projectionLayerMultiview*/); + } this._projectionLayerInitialized = true; return true; @@ -150,6 +167,23 @@ export class WebXRLayers extends WebXRAbstractFeature { return layer; } + /** + * Creates the base projection layer for the WebGPU (XRGPUBinding) backend. + * Single-view only for now (multiview is deferred); the color format is the binding's preferred format. + * @returns the WebGPU projection layer wrapper + */ + private _createWebGPUProjectionLayer(): WebXRWebGPUProjectionLayerWrapper { + if (!this._xrSessionManager.inXRSession) { + throw new Error("Cannot create a layer outside of a WebXR session. Make sure the session has started before creating layers."); + } + const binding = this._xrGPUBinding!; + const init = CreateDefaultXRGPUProjectionLayerInit(binding.getPreferredColorFormat()); + const projLayer = binding.createProjectionLayer(init); + const layer = new WebXRWebGPUProjectionLayerWrapper(projLayer, false, binding, init.depthStencilFormat); + this.addXRSessionLayer(layer); + return layer; + } + /** * Note about making it private - this function will be exposed once I decide on a proper API to support all of the XR layers' options * @param options an object providing configuration options for the new XRQuadLayer. @@ -335,7 +369,13 @@ export class WebXRLayers extends WebXRAbstractFeature { public override isCompatible(): boolean { // TODO (rgerd): Add native support. - return !this._xrSessionManager.isNative && typeof XRWebGLBinding !== "undefined" && !!XRWebGLBinding.prototype.createProjectionLayer; + if (this._xrSessionManager.isNative) { + return false; + } + if (this._xrSessionManager.scene.getEngine().isWebGPU) { + return typeof XRGPUBinding !== "undefined" && !!XRGPUBinding.prototype.createProjectionLayer; + } + return typeof XRWebGLBinding !== "undefined" && !!XRWebGLBinding.prototype.createProjectionLayer; } /** From b651e81dd5a0a6b433b243f320d34477e94b79c1 Mon Sep 17 00:00:00 2001 From: Raanan Weber Date: Fri, 3 Jul 2026 19:02:47 +0200 Subject: [PATCH 3/4] fix(webgpu): wrapWebGPUTexture reports the wrapped texture's real format WebGPUHardwareTexture.format defaulted to RGBA8Unorm and wrapWebGPUTexture never overwrote it. The RTT color-attachment view and pipeline color target are built from hardwareTexture.format (_setColorFormat / bindFramebuffer), so an external texture created with any other format (e.g. bgra8unorm or an *-srgb variant, as returned by XRGPUBinding.getPreferredColorFormat()) got a mismatched view and failed to render (black in WebGPU-XR). Set both format and originalFormat from the wrapped GPUTexture's own format. General correctness for any external-texture wrap; rgba8unorm callers are unchanged. Orthogonal to the depth-format fix (that sets InternalTexture.format). Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../dev/core/src/Engines/webgpuEngine.pure.ts | 7 +++ .../Engines/WebGPU/webgpuWrapTexture.test.ts | 44 +++++++++++++++++++ 2 files changed, 51 insertions(+) create mode 100644 packages/dev/core/test/unit/Engines/WebGPU/webgpuWrapTexture.test.ts diff --git a/packages/dev/core/src/Engines/webgpuEngine.pure.ts b/packages/dev/core/src/Engines/webgpuEngine.pure.ts index f252d64dd37..77d7ded3b19 100644 --- a/packages/dev/core/src/Engines/webgpuEngine.pure.ts +++ b/packages/dev/core/src/Engines/webgpuEngine.pure.ts @@ -2585,6 +2585,13 @@ export class WebGPUEngine extends ThinWebGPUEngine { */ public wrapWebGPUTexture(texture: GPUTexture): InternalTexture { const hardwareTexture = new WebGPUHardwareTexture(this, texture); + // Report the real format of the wrapped texture. The hardware texture otherwise keeps its + // default (RGBA8Unorm); the render-pass color attachment view + pipeline color target are + // built from hardwareTexture.format (see _setColorFormat / bindFramebuffer), so an external + // texture created with any other format (e.g. bgra8unorm or an *-srgb variant, as returned by + // XRGPUBinding.getPreferredColorFormat()) would otherwise get a mismatched view and fail to render. + hardwareTexture.format = texture.format; + hardwareTexture.originalFormat = texture.format; const internalTexture = new InternalTexture(this, InternalTextureSource.External, true); internalTexture._hardwareTexture = hardwareTexture; internalTexture.baseWidth = texture.width; diff --git a/packages/dev/core/test/unit/Engines/WebGPU/webgpuWrapTexture.test.ts b/packages/dev/core/test/unit/Engines/WebGPU/webgpuWrapTexture.test.ts new file mode 100644 index 00000000000..7ae5bc1555b --- /dev/null +++ b/packages/dev/core/test/unit/Engines/WebGPU/webgpuWrapTexture.test.ts @@ -0,0 +1,44 @@ +/** + * @vitest-environment jsdom + */ + +import { NullEngine } from "core/Engines/nullEngine"; +import { WebGPUEngine } from "core/Engines/webgpuEngine"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; + +// wrapWebGPUTexture only constructs a WebGPUHardwareTexture + InternalTexture around the passed GPUTexture; +// it does not touch the GPU device, so we can exercise it via prototype.call with a NullEngine as `this` +// and a minimally-shaped fake GPUTexture. +function wrap(engine: NullEngine, texture: any) { + return (WebGPUEngine.prototype as any).wrapWebGPUTexture.call(engine, texture); +} + +describe("WebGPUEngine.wrapWebGPUTexture", () => { + let engine: NullEngine; + + beforeEach(() => { + engine = new NullEngine(); + }); + + afterEach(() => { + engine.dispose(); + }); + + it("reports the wrapped GPUTexture's real format on the hardware texture (non-default)", () => { + const fakeTexture = { width: 128, height: 128, format: "bgra8unorm" }; + const wrapped = wrap(engine, fakeTexture); + + // Both format and originalFormat must reflect the wrapped texture, not the RGBA8Unorm default. + expect(wrapped._hardwareTexture.format).toBe("bgra8unorm"); + expect(wrapped._hardwareTexture.originalFormat).toBe("bgra8unorm"); + }); + + it("carries the dimensions of the wrapped GPUTexture", () => { + const fakeTexture = { width: 640, height: 480, format: "rgba8unorm" }; + const wrapped = wrap(engine, fakeTexture); + + expect(wrapped.width).toBe(640); + expect(wrapped.height).toBe(480); + expect(wrapped._hardwareTexture.format).toBe("rgba8unorm"); + }); +}); From 43ced4306ae2c6f41cc6b54f79258f0d9ee1dcaa Mon Sep 17 00:00:00 2001 From: Raanan Weber Date: Fri, 3 Jul 2026 19:25:45 +0200 Subject: [PATCH 4/4] fix(WebGPU-XR): route per-eye array layer into projection layer render (GAP 2) WebGPU/Quest projection layers hand out a texture ARRAY with one layer per eye (depthOrArrayLayers=2, baseArrayLayer 0=left / 1=right). The render path hard-coded baseArrayLayer=0, so the right eye's layer was never written and both views composited into layer 0 -> black/broken stereo despite correct per-eye resolution and IN_XR. - RenderTargetTexture: add @internal _bindFrameBufferLayer (default 0) that _bindFrameBuffer defaults its layer arg from. WebGL2 + non-XR paths are byte-identical (field stays 0; explicit-layer callers unchanged). - WebGPU XR provider: set _bindFrameBufferLayer per eye from the authoritative subImage.getViewDescriptor().baseArrayLayer. Depth attachment inherits the same layer via the existing baseArrayLayer=layer wiring. - Add unit test asserting left->layer 0, right->layer 1. Zero net public API (@internal). Confirmed on Quest by RaananW: readout baseArrayLayer 0/1 per eye. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../Textures/renderTargetTexture.pure.ts | 10 +++++++- .../Layers/WebXRWebGPUCompositionLayer.ts | 10 +++++++- .../XR/webXRWebGPUProjectionLayer.test.ts | 25 +++++++++++++++++-- 3 files changed, 41 insertions(+), 4 deletions(-) diff --git a/packages/dev/core/src/Materials/Textures/renderTargetTexture.pure.ts b/packages/dev/core/src/Materials/Textures/renderTargetTexture.pure.ts index 300fb2586d5..0543d271afa 100644 --- a/packages/dev/core/src/Materials/Textures/renderTargetTexture.pure.ts +++ b/packages/dev/core/src/Materials/Textures/renderTargetTexture.pure.ts @@ -1148,12 +1148,20 @@ export class RenderTargetTexture extends Texture implements IRenderTargetTexture return Math.min(FloorPOT(renderDimension), curved); } + /** + * The default array layer index bound by {@link _bindFrameBuffer} when no explicit layer is passed. + * Defaults to 0 (backward compatible). It is set per eye by the WebGPU XR layer provider so that each + * eye renders into its own layer of a layered projection-layer texture array. + * @internal + */ + public _bindFrameBufferLayer = 0; + /** * @internal * @param faceIndex face index to bind to if this is a cubetexture * @param layer defines the index of the texture to bind in the array */ - public _bindFrameBuffer(faceIndex: number = 0, layer = 0) { + public _bindFrameBuffer(faceIndex: number = 0, layer = this._bindFrameBufferLayer) { const scene = this.getScene(); if (!scene) { return; diff --git a/packages/dev/core/src/XR/features/Layers/WebXRWebGPUCompositionLayer.ts b/packages/dev/core/src/XR/features/Layers/WebXRWebGPUCompositionLayer.ts index 0d67922a3e4..3f8eeb18e00 100644 --- a/packages/dev/core/src/XR/features/Layers/WebXRWebGPUCompositionLayer.ts +++ b/packages/dev/core/src/XR/features/Layers/WebXRWebGPUCompositionLayer.ts @@ -84,7 +84,15 @@ export class WebXRWebGPUCompositionLayerRenderTargetTextureProvider extends WebX } this._lastSubImages.set(eye, subImage); - return this._renderTargetTextures[eyeIndex]; + + // The projection-layer color/depth textures may be a texture ARRAY with one layer per eye + // (depthOrArrayLayers > 1). The sub-image's view descriptor carries the authoritative array-layer + // index for this eye, so route it into the render target's bind so each eye renders into its own + // layer. For non-layered textures this is 0 (unchanged behavior). + const renderTargetTexture = this._renderTargetTextures[eyeIndex]; + renderTargetTexture._bindFrameBufferLayer = subImage.getViewDescriptor().baseArrayLayer ?? 0; + + return renderTargetTexture; } private _getSubImageForEye(eye?: XREye): Nullable { diff --git a/packages/dev/core/test/unit/XR/webXRWebGPUProjectionLayer.test.ts b/packages/dev/core/test/unit/XR/webXRWebGPUProjectionLayer.test.ts index 83a68bd2280..13b16910079 100644 --- a/packages/dev/core/test/unit/XR/webXRWebGPUProjectionLayer.test.ts +++ b/packages/dev/core/test/unit/XR/webXRWebGPUProjectionLayer.test.ts @@ -13,12 +13,12 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; // Build a minimal XRGPUSubImage-shaped object. Typed loosely because the WebGPU-XR ambient types are not // registered as eslint globals in the test environment. -function createSubImage(width: number, height: number, viewport = { x: 0, y: 0, width, height }): any { +function createSubImage(width: number, height: number, viewport = { x: 0, y: 0, width, height }, baseArrayLayer = 0): any { return { colorTexture: { width, height }, depthStencilTexture: { width, height }, viewport, - getViewDescriptor: () => ({}), + getViewDescriptor: () => ({ baseArrayLayer }), }; } @@ -144,5 +144,26 @@ describe("WebXRWebGPUProjectionLayer", () => { expect(viewport.width).toBeCloseTo(0.5); expect(viewport.height).toBeCloseTo(0.25); }); + + it("routes the per-eye array layer from the sub-image view descriptor into the render target", () => { + // Projection-layer textures may be a texture array with one layer per eye (left=0, right=1); + // the provider must read baseArrayLayer from getViewDescriptor() and set it on the RTT so each + // eye binds its own layer. + const binding: any = { + getViewSubImage: vi.fn((_layer: unknown, view: XRView) => createSubImage(512, 512, undefined, view.eye === "right" ? 1 : 0)), + }; + const layer: any = { textureWidth: 512, textureHeight: 512 }; + const wrapper = new WebXRWebGPUProjectionLayerWrapper(layer, false, binding, "depth24plus-stencil8"); + const provider = wrapper.createRenderTargetTextureProvider({ scene } as unknown as WebXRSessionManager); + + const leftRtt = provider.getRenderTargetTextureForView({ eye: "left" } as XRView); + const rightRtt = provider.getRenderTargetTextureForView({ eye: "right" } as XRView); + + expect(leftRtt).not.toBeNull(); + expect(rightRtt).not.toBeNull(); + expect(leftRtt).not.toBe(rightRtt); + expect(leftRtt!._bindFrameBufferLayer).toBe(0); + expect(rightRtt!._bindFrameBufferLayer).toBe(1); + }); }); });