Add WebGPU XR rendering pipeline via XRGPUBinding
All checks were successful
Build / build (push) Successful in 1m42s

Create a parallel WebGPU XR path that uses XRGPUBinding and
XRProjectionLayer instead of the WebGL-only XRWebGLLayer, while
preserving the existing WebGL XR path as the default fallback.

New files in src/core/xr-webgpu/:
- xrGpuTypes.ts: TypeScript declarations for XRGPUBinding spec types
- xrGpuSessionSetup.ts: GPUDevice access and session init helpers
- xrGpuTextureProvider.ts: Per-frame GPUTexture swap via hwTex.set()
- xrGpuLayerWrapper.ts: WebXRLayerWrapper subclass for projection layers
- xrGpuRenderTarget.ts: WebXRRenderTarget using XRGPUBinding
- xrGpuEntryPoint.ts: Public API for availability check and creation

Modified xrEntryHandler.ts to conditionally route through WebGPU or
WebGL XR entry based on XRGPUBinding availability.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Michael Mainguy 2026-03-04 08:29:31 -06:00
parent 25286c56b0
commit 0bb27691fe
9 changed files with 329 additions and 20 deletions

View File

@ -1,6 +1,7 @@
import { AbstractEngine, FreeCamera, Vector3 } from "@babylonjs/core"; import { AbstractEngine, FreeCamera, Vector3, WebGPUEngine } from "@babylonjs/core";
import { DefaultScene } from "../defaultScene"; import { DefaultScene } from "../defaultScene";
import { LevelConfig } from "../../levels/config/levelConfig"; import { LevelConfig } from "../../levels/config/levelConfig";
import { isWebGPUXRAvailable, createWebGPURenderTarget, getWebGPUSessionInit } from "../xr-webgpu/xrGpuEntryPoint";
import log from '../logger'; import log from '../logger';
/** /**
@ -17,10 +18,9 @@ export async function enterXRMode(
try { try {
prePositionCamera(config); prePositionCamera(config);
const session = await DefaultScene.XR.baseExperience.enterXRAsync( const session = isWebGPUXRAvailable(engine)
'immersive-vr', ? await enterWebGPUXR(engine as WebGPUEngine)
'local-floor' : await enterWebGLXR();
);
log.debug('XR session started successfully'); log.debug('XR session started successfully');
return session; return session;
} catch (error) { } catch (error) {
@ -30,6 +30,16 @@ export async function enterXRMode(
} }
} }
async function enterWebGPUXR(engine: WebGPUEngine): Promise<any> {
const base = DefaultScene.XR!.baseExperience;
const gpuTarget = createWebGPURenderTarget(base.sessionManager, engine, DefaultScene.MainScene);
return base.enterXRAsync('immersive-vr', 'local-floor', gpuTarget, getWebGPUSessionInit());
}
async function enterWebGLXR(): Promise<any> {
return DefaultScene.XR!.baseExperience.enterXRAsync('immersive-vr', 'local-floor');
}
function prePositionCamera(config: LevelConfig): void { function prePositionCamera(config: LevelConfig): void {
const spawnPos = config.ship?.position || [0, 0, 0]; const spawnPos = config.ship?.position || [0, 0, 0];
const cockpitPosition = new Vector3(spawnPos[0], spawnPos[1] + 1.2, spawnPos[2]); const cockpitPosition = new Vector3(spawnPos[0], spawnPos[1] + 1.2, spawnPos[2]);

View File

@ -49,18 +49,40 @@ export async function setupScene(
} }
async function createEngine(canvas: HTMLCanvasElement): Promise<AbstractEngine> { async function createEngine(canvas: HTMLCanvasElement): Promise<AbstractEngine> {
let engine: AbstractEngine; const engine = useWebGPU
if (useWebGPU) { ? await tryCreateWebGPUEngine(canvas)
log.info('[Engine] Creating WebGPU engine'); : null;
log.warn('[Engine] WebXR/VR is still experimental with WebGPU engine'); const finalEngine = engine ?? createWebGLEngine(canvas);
engine = await WebGPUEngine.CreateAsync(canvas, { antialias: true }); finalEngine.setHardwareScalingLevel(1 / window.devicePixelRatio);
} else { window.onresize = () => finalEngine.resize();
log.info('[Engine] Creating WebGL engine'); return finalEngine;
engine = new Engine(canvas, true); }
async function tryCreateWebGPUEngine(canvas: HTMLCanvasElement): Promise<AbstractEngine | null> {
if (!navigator.gpu) {
log.warn('[Engine] WebGPU requested but navigator.gpu not available');
return null;
} }
engine.setHardwareScalingLevel(1 / window.devicePixelRatio); const adapter = await navigator.gpu.requestAdapter();
window.onresize = () => engine.resize(); if (!adapter) {
return engine; log.warn('[Engine] No WebGPU adapter found');
return null;
}
log.info(`[Engine] WebGPU adapter: ${adapter.info?.vendor ?? 'unknown'}`);
try {
const gpuEngine = new WebGPUEngine(canvas, { antialias: true });
await gpuEngine.initAsync();
log.info('[Engine] WebGPU engine ready — WebXR will use XRGPUBinding if available');
return gpuEngine;
} catch (e) {
log.error('[Engine] WebGPU initialization failed', e);
return null;
}
}
function createWebGLEngine(canvas: HTMLCanvasElement): AbstractEngine {
log.info('[Engine] Creating WebGL engine');
return new Engine(canvas, true);
} }
function createMainScene(engine: AbstractEngine): void { function createMainScene(engine: AbstractEngine): void {

View File

@ -0,0 +1,28 @@
/**
* Public API for WebGPU XR support.
* Consumed by xrEntryHandler.ts to conditionally use the WebGPU XR path.
*/
import { WebGPUEngine } from "@babylonjs/core";
import type { AbstractEngine, Scene, WebXRSessionManager } from "@babylonjs/core";
import { XRGPURenderTarget } from "./xrGpuRenderTarget";
import { buildWebGPUSessionInit } from "./xrGpuSessionSetup";
import "./xrGpuTypes"; // ensure global XRGPUBinding augmentation is loaded
export function isWebGPUXRAvailable(engine: AbstractEngine): boolean {
return (
engine instanceof WebGPUEngine &&
typeof globalThis.XRGPUBinding !== 'undefined'
);
}
export function createWebGPURenderTarget(
sessionManager: WebXRSessionManager,
engine: WebGPUEngine,
scene: Scene
): XRGPURenderTarget {
return new XRGPURenderTarget(sessionManager, engine, scene);
}
export function getWebGPUSessionInit(): XRSessionInit {
return buildWebGPUSessionInit();
}

View File

@ -0,0 +1,32 @@
/**
* WebXRLayerWrapper subclass for XRProjectionLayer created via XRGPUBinding.
*/
import { WebGPUEngine } from "@babylonjs/core";
import { WebXRLayerWrapper } from "@babylonjs/core/XR/webXRLayerWrapper";
import type { Scene } from "@babylonjs/core";
import { XRGPUTextureProvider } from "./xrGpuTextureProvider";
import type { XRGPUBinding } from "./xrGpuTypes";
export class XRGPUProjectionLayerWrapper extends WebXRLayerWrapper {
constructor(
public override readonly layer: XRProjectionLayer,
private readonly _gpuBinding: XRGPUBinding,
private readonly _gpuEngine: WebGPUEngine,
private readonly _scene: Scene
) {
super(
() => layer.textureWidth,
() => layer.textureHeight,
layer,
"XRProjectionLayer",
(sessionManager) =>
new XRGPUTextureProvider(
sessionManager.scene,
this,
_gpuBinding,
layer,
_gpuEngine
)
);
}
}

View File

@ -0,0 +1,54 @@
/**
* WebXRRenderTarget implementation for WebGPU.
* Creates XRGPUBinding + projection layer instead of XRWebGLLayer.
*/
import { WebGPUEngine } from "@babylonjs/core";
import type { Scene, Nullable, WebXRSessionManager, WebXRRenderTarget } from "@babylonjs/core";
import { XRGPUProjectionLayerWrapper } from "./xrGpuLayerWrapper";
import { getGpuDeviceFromEngine } from "./xrGpuSessionSetup";
import type { XRGPUBinding, XRGPUBindingConstructor } from "./xrGpuTypes";
import log from '../logger';
export class XRGPURenderTarget implements WebXRRenderTarget {
/** Unused in WebGPU path — required by interface */
public canvasContext: WebGLRenderingContext = null as any;
/** Unused in WebGPU path — required by interface */
public xrLayer: Nullable<XRWebGLLayer> = null;
private _sessionManager: WebXRSessionManager;
private _engine: WebGPUEngine;
private _scene: Scene;
constructor(sessionManager: WebXRSessionManager, engine: WebGPUEngine, scene: Scene) {
this._sessionManager = sessionManager;
this._engine = engine;
this._scene = scene;
}
public async initializeXRLayerAsync(xrSession: XRSession): Promise<XRWebGLLayer> {
const device = getGpuDeviceFromEngine(this._engine);
const Binding = globalThis.XRGPUBinding as XRGPUBindingConstructor;
const gpuBinding: XRGPUBinding = new Binding(xrSession, device);
const projectionLayer = gpuBinding.createProjectionLayer({
textureFormat: 'rgba8unorm',
depthStencilFormat: 'depth24plus-stencil8',
});
log.info('[XR-WebGPU] Projection layer created:', projectionLayer.textureWidth, 'x', projectionLayer.textureHeight);
xrSession.updateRenderState({ layers: [projectionLayer] } as any);
const wrapper = new XRGPUProjectionLayerWrapper(
projectionLayer, gpuBinding, this._engine, this._scene
);
this._sessionManager._setBaseLayerWrapper(wrapper);
log.info('[XR-WebGPU] Layer wrapper set on session manager');
return null as any;
}
public dispose(): void {
/* nothing to clean up — layer lifetime is tied to XR session */
}
}

View File

@ -0,0 +1,30 @@
/**
* Helpers for accessing the GPUDevice from WebGPUEngine
* and building XR session init options for WebGPU.
*/
import { WebGPUEngine } from "@babylonjs/core";
/**
* Extract the GPUDevice from a WebGPUEngine instance.
* WebGPUEngine stores _device privately; this accessor is stable across versions.
*/
export function getGpuDeviceFromEngine(engine: WebGPUEngine): GPUDevice {
const device = (engine as any)._device as GPUDevice | undefined;
if (!device) {
throw new Error('[XR-WebGPU] Could not access GPUDevice from engine');
}
return device;
}
/**
* Build XRSessionInit that adds 'webgpu' to requiredFeatures.
*/
export function buildWebGPUSessionInit(
baseInit: XRSessionInit = {}
): XRSessionInit {
const existing = baseInit.requiredFeatures ?? [];
return {
...baseInit,
requiredFeatures: [...existing, 'webgpu'],
};
}

View File

@ -0,0 +1,85 @@
/**
* WebGPU XR texture provider gets GPUTexture per-view from XRGPUBinding
* and swaps it into the cached RenderTargetTexture each frame.
*/
import { RenderTargetTexture, WebGPUEngine, WebXRLayerRenderTargetTextureProvider } from "@babylonjs/core";
import type { Viewport, Scene, Nullable } from "@babylonjs/core";
import type { WebXRLayerWrapper } from "@babylonjs/core/XR/webXRLayerWrapper";
import type { XRGPUBinding, XRGPUSubImage } from "./xrGpuTypes";
import log from '../logger';
export class XRGPUTextureProvider extends WebXRLayerRenderTargetTextureProvider {
private _gpuBinding: XRGPUBinding;
private _projLayer: XRProjectionLayer;
private _gpuEngine: WebGPUEngine;
private _xrScene: Scene;
constructor(
scene: Scene,
layerWrapper: WebXRLayerWrapper,
gpuBinding: XRGPUBinding,
projectionLayer: XRProjectionLayer,
gpuEngine: WebGPUEngine
) {
super(scene, layerWrapper);
this._gpuBinding = gpuBinding;
this._projLayer = projectionLayer;
this._gpuEngine = gpuEngine;
this._xrScene = scene;
this._framebufferDimensions = {
framebufferWidth: projectionLayer.textureWidth,
framebufferHeight: projectionLayer.textureHeight,
};
}
public getRenderTargetTextureForView(view: XRView): Nullable<RenderTargetTexture> {
const subImage = this._gpuBinding.getViewSubImage(this._projLayer, view);
const idx = view.eye === "right" ? 1 : 0;
if (!this._renderTargetTextures[idx]) {
this._renderTargetTextures[idx] = this._createGpuRTT(subImage);
} else {
this._swapGpuTexture(this._renderTargetTextures[idx], subImage);
}
return this._renderTargetTextures[idx];
}
public getRenderTargetTextureForEye(eye: XREye): Nullable<RenderTargetTexture> {
return this._renderTargetTextures[eye === "right" ? 1 : 0] ?? null;
}
public trySetViewportForView(viewport: Viewport, view: XRView): boolean {
const sub = this._gpuBinding.getViewSubImage(this._projLayer, view);
if (!sub) return false;
const w = this._projLayer.textureWidth;
const h = this._projLayer.textureHeight;
viewport.x = sub.viewport.x / w;
viewport.y = sub.viewport.y / h;
viewport.width = sub.viewport.width / w;
viewport.height = sub.viewport.height / h;
return true;
}
private _createGpuRTT(subImage: XRGPUSubImage): RenderTargetTexture {
const w = this._projLayer.textureWidth;
const h = this._projLayer.textureHeight;
const internalTex = this._gpuEngine.wrapWebGPUTexture(subImage.colorTexture);
internalTex.width = w;
internalTex.height = h;
const rtt = new RenderTargetTexture("xrGpuRTT", { width: w, height: h }, this._xrScene);
const origTex = rtt._texture;
rtt._texture = internalTex;
rtt.renderTarget!.setTexture(internalTex, 0);
origTex?.dispose();
rtt.disableRescaling();
log.debug(`[XR-WebGPU] Created RTT ${w}x${h}`);
return rtt;
}
private _swapGpuTexture(rtt: RenderTargetTexture, subImage: XRGPUSubImage): void {
const hwTex = rtt._texture?._hardwareTexture as any;
if (!hwTex?.set) return;
hwTex.set(subImage.colorTexture);
hwTex.view = null;
hwTex.viewForWriting = null;
}
}

View File

@ -0,0 +1,33 @@
/**
* TypeScript declarations for WebXR-WebGPU Binding spec types.
* These are not yet in lib.dom or BabylonJS type definitions.
* @see https://github.com/immersive-web/WebXR-WebGPU-Binding/blob/main/explainer.md
*/
export interface XRGPUProjectionLayerInit {
textureFormat?: GPUTextureFormat;
depthStencilFormat?: GPUTextureFormat;
scaleFactor?: number;
}
export interface XRGPUSubImage {
colorTexture: GPUTexture;
depthStencilTexture?: GPUTexture;
imageIndex: number;
viewport: { x: number; y: number; width: number; height: number };
}
export interface XRGPUBinding {
createProjectionLayer(init?: XRGPUProjectionLayerInit): XRProjectionLayer;
getViewSubImage(layer: XRProjectionLayer, view: XRView): XRGPUSubImage;
}
export interface XRGPUBindingConstructor {
new (session: XRSession, device: GPUDevice): XRGPUBinding;
}
/** Global augmentation for XRGPUBinding constructor */
declare global {
// eslint-disable-next-line no-var
var XRGPUBinding: XRGPUBindingConstructor | undefined;
}

View File

@ -26,21 +26,21 @@ export default defineConfig({
// Shaders must be explicitly included to avoid dynamic import failures through CloudFlare proxy // Shaders must be explicitly included to avoid dynamic import failures through CloudFlare proxy
include: [ include: [
'@babylonjs/core', '@babylonjs/core',
// Core shaders // Core shaders (WebGL)
'@babylonjs/core/Shaders/default.vertex', '@babylonjs/core/Shaders/default.vertex',
'@babylonjs/core/Shaders/default.fragment', '@babylonjs/core/Shaders/default.fragment',
'@babylonjs/core/Shaders/rgbdDecode.fragment', '@babylonjs/core/Shaders/rgbdDecode.fragment',
'@babylonjs/core/Shaders/procedural.vertex', '@babylonjs/core/Shaders/procedural.vertex',
// PBR shaders // PBR shaders (WebGL)
'@babylonjs/core/Shaders/pbr.vertex', '@babylonjs/core/Shaders/pbr.vertex',
'@babylonjs/core/Shaders/pbr.fragment', '@babylonjs/core/Shaders/pbr.fragment',
'@babylonjs/core/Shaders/pbrDebug.fragment', '@babylonjs/core/Shaders/pbrDebug.fragment',
// Particle shaders // Particle shaders (WebGL)
'@babylonjs/core/Shaders/particles.vertex', '@babylonjs/core/Shaders/particles.vertex',
'@babylonjs/core/Shaders/particles.fragment', '@babylonjs/core/Shaders/particles.fragment',
'@babylonjs/core/Shaders/gpuRenderParticles.vertex', '@babylonjs/core/Shaders/gpuRenderParticles.vertex',
'@babylonjs/core/Shaders/gpuRenderParticles.fragment', '@babylonjs/core/Shaders/gpuRenderParticles.fragment',
// Other common shaders // Other common shaders (WebGL)
'@babylonjs/core/Shaders/standard.fragment', '@babylonjs/core/Shaders/standard.fragment',
'@babylonjs/core/Shaders/postprocess.vertex', '@babylonjs/core/Shaders/postprocess.vertex',
'@babylonjs/core/Shaders/pass.fragment', '@babylonjs/core/Shaders/pass.fragment',
@ -48,6 +48,21 @@ export default defineConfig({
'@babylonjs/core/Shaders/shadowMap.fragment', '@babylonjs/core/Shaders/shadowMap.fragment',
'@babylonjs/core/Shaders/depth.vertex', '@babylonjs/core/Shaders/depth.vertex',
'@babylonjs/core/Shaders/depth.fragment', '@babylonjs/core/Shaders/depth.fragment',
// WGSL shaders (WebGPU equivalents)
'@babylonjs/core/ShadersWGSL/default.vertex',
'@babylonjs/core/ShadersWGSL/default.fragment',
'@babylonjs/core/ShadersWGSL/rgbdDecode.fragment',
'@babylonjs/core/ShadersWGSL/procedural.vertex',
'@babylonjs/core/ShadersWGSL/pbr.vertex',
'@babylonjs/core/ShadersWGSL/pbr.fragment',
'@babylonjs/core/ShadersWGSL/particles.vertex',
'@babylonjs/core/ShadersWGSL/particles.fragment',
'@babylonjs/core/ShadersWGSL/postprocess.vertex',
'@babylonjs/core/ShadersWGSL/pass.fragment',
'@babylonjs/core/ShadersWGSL/shadowMap.vertex',
'@babylonjs/core/ShadersWGSL/shadowMap.fragment',
'@babylonjs/core/ShadersWGSL/depth.vertex',
'@babylonjs/core/ShadersWGSL/depth.fragment',
'@babylonjs/loaders', '@babylonjs/loaders',
'@babylonjs/havok', '@babylonjs/havok',
'@babylonjs/materials', '@babylonjs/materials',