diff --git a/examples/src/examples/gaussian-splatting-xr/vr-lod.controls.mjs b/examples/src/examples/gaussian-splatting-xr/vr-lod.controls.mjs index 8e0fb93b028..062b01b8709 100644 --- a/examples/src/examples/gaussian-splatting-xr/vr-lod.controls.mjs +++ b/examples/src/examples/gaussian-splatting-xr/vr-lod.controls.mjs @@ -3,39 +3,9 @@ * @returns {JSX.Element} The returned JSX Element. */ export const controls = ({ observer, ReactPCUI, React, jsx, fragment }) => { - const { BindingTwoWay, Button, LabelGroup, Panel, SelectInput, SliderInput, Label } = ReactPCUI; - const { useEffect, useState } = React; - - /** - * XR enter/exit toggle for the control panel. - * - * @returns {import('react').ReactElement} The button element. - */ - const XrToggleButton = () => { - const [xrActive, setXrActive] = useState(!!observer.get('xrActive')); - useEffect(() => { - const handler = () => setXrActive(!!observer.get('xrActive')); - const evt = observer.on('xrActive:set', handler); - return () => evt.unbind(); - }, [observer]); - return jsx(Button, { - text: xrActive ? 'Exit VR' : 'Enter VR', - onClick: () => { - if (observer.get('xrActive')) { - observer.emit('xr:exit'); - } else { - observer.emit('vr:enter'); - } - } - }); - }; + const { BindingTwoWay, LabelGroup, Panel, SelectInput, SliderInput, Label } = ReactPCUI; return fragment( - jsx( - Panel, - { headerText: 'XR' }, - jsx(XrToggleButton, null) - ), jsx( Panel, { headerText: 'Settings' }, diff --git a/examples/src/examples/gaussian-splatting-xr/vr-lod.example.mjs b/examples/src/examples/gaussian-splatting-xr/vr-lod.example.mjs index 02d6e27bb2f..e8c18dbde63 100644 --- a/examples/src/examples/gaussian-splatting-xr/vr-lod.example.mjs +++ b/examples/src/examples/gaussian-splatting-xr/vr-lod.example.mjs @@ -43,7 +43,7 @@ app.setCanvasFillMode(pc.FILLMODE_FILL_WINDOW); app.setCanvasResolution(pc.RESOLUTION_AUTO); const dpr = window.devicePixelRatio || 1; -device.maxPixelRatio = Math.min(dpr, 2); +device.maxPixelRatio = dpr >= 2 ? dpr * 0.5 : dpr; const resize = () => app.resizeCanvas(); window.addEventListener('resize', resize); @@ -108,6 +108,43 @@ const setMessage = (msg) => { el.textContent = msg; }; +/** + * Create a DOM Enter VR button inside the example iframe. WebXR requires transient user + * activation from the same document as the requestSession call, so the controls-panel button + * (which lives in the parent page) cannot start a session. This button lives next to the canvas. + * + * @param {() => void} onClick - Click handler that starts VR. + * @returns {HTMLButtonElement} The created button element. + */ +const createEnterVrButton = (onClick) => { + const btn = document.createElement('button'); + btn.textContent = 'Enter VR'; + Object.assign(btn.style, { + position: 'fixed', + top: '12px', + left: '50%', + transform: 'translateX(-50%)', + zIndex: '10', + padding: '10px 20px', + font: '600 14px/1 sans-serif', + color: '#fff', + background: 'rgba(0, 0, 0, 0.7)', + border: '1px solid rgba(255, 255, 255, 0.4)', + borderRadius: '6px', + cursor: 'pointer', + display: 'none' + }); + btn.onmouseenter = () => { + btn.style.background = 'rgba(0, 0, 0, 0.85)'; + }; + btn.onmouseleave = () => { + btn.style.background = 'rgba(0, 0, 0, 0.7)'; + }; + btn.addEventListener('click', onClick); + document.body.append(btn); + return btn; +}; + const assetListLoader = new pc.AssetListLoader(Object.values(assets), app.assets); assetListLoader.load(() => { app.start(); @@ -275,12 +312,14 @@ assetListLoader.load(() => { } }; - data.on('vr:enter', tryStartVr); - data.on('xr:exit', () => { - app.fire('xr:end'); - }); + // DOM button in the iframe document so the click carries transient activation into the + // WebXR requestSession call. Hidden while VR is active. + const enterVrButton = createEnterVrButton(tryStartVr); - data.set('xrActive', false); + const updateEnterVrButton = () => { + const show = app.xr.supported && app.xr.isAvailable(pc.XRTYPE_VR) && !app.xr.active; + enterVrButton.style.display = show ? 'block' : 'none'; + }; if (app.xr.supported) { if (app.touch) { @@ -294,23 +333,30 @@ assetListLoader.load(() => { } app.xr.on('start', () => { - data.set('xrActive', true); setCameraControlsForXr(); - setMessage('VR active — left thumbstick: move, right: snap turn; tap to exit or use Exit VR in the XR panel'); + updateEnterVrButton(); + setMessage('VR active — left thumbstick: move, right: snap turn; tap to exit'); }); app.xr.on('end', () => { - data.set('xrActive', false); - setCameraControlsForXr(); - setMessage('VR ended — use Enter VR in the XR panel to re-enter'); + setMessage('VR ended — click Enter VR to re-enter'); + // XrManager fires 'end' *before* clearing its internal session reference, so + // `app.xr.active` still reads true at this point. Defer state-dependent updates to + // the next microtask so they observe the cleared session. + Promise.resolve().then(() => { + setCameraControlsForXr(); + updateEnterVrButton(); + }); }); app.xr.on(`available:${pc.XRTYPE_VR}`, (available) => { - setMessage(available ? 'Use Enter VR in the XR panel' : 'Immersive VR is unavailable'); + updateEnterVrButton(); + setMessage(available ? 'Click Enter VR to start' : 'Immersive VR is unavailable'); }); + updateEnterVrButton(); if (!app.xr.isAvailable(pc.XRTYPE_VR)) { setMessage('Immersive VR is not available'); } else { - setMessage('Use Enter VR in the XR panel'); + setMessage('Click Enter VR to start'); } } else { setMessage('WebXR is not supported'); diff --git a/src/scene/gsplat-unified/gsplat-manager.js b/src/scene/gsplat-unified/gsplat-manager.js index b513023978e..4a3f0e7b40b 100644 --- a/src/scene/gsplat-unified/gsplat-manager.js +++ b/src/scene/gsplat-unified/gsplat-manager.js @@ -1613,15 +1613,14 @@ class GSplatManager { const cam = this.cameraNode.camera; const sceneCam = cam.camera; const rt = cam.renderTarget; - const rtWidth = rt ? rt.width : this.device.width; - const rtHeight = rt ? rt.height : this.device.height; const rect = cam.rect; - let viewportWidth = Math.floor(rtWidth * rect.z); - const viewportHeight = Math.floor(rtHeight * rect.w); - // Match Renderer#setCameraUniforms: per-eye width for stereo XR (two views). - if (sceneCam.xr?.session && sceneCam.xr.views?.list?.length === 2) { - viewportWidth = Math.floor(viewportWidth * 0.5); - } + + // Match Renderer#setCameraUniforms: in stereo XR the XR session reports the per-eye + // viewport directly, which is correct for both side-by-side single-texture and + // multi-pass per-eye-view layouts — preferred over inferring from target.width. + const xrView = sceneCam.xr?.session ? sceneCam.xr.views.list[0] : null; + const viewportWidth = Math.floor((xrView ? xrView.viewport.z : (rt ? rt.width : this.device.width)) * rect.z); + const viewportHeight = Math.floor((xrView ? xrView.viewport.w : (rt ? rt.height : this.device.height)) * rect.w); const sortedIndices = this.sortGpuHybridForCamera( worldState, diff --git a/src/scene/renderer/renderer.js b/src/scene/renderer/renderer.js index 8ed97594f2e..68bcb15209f 100644 --- a/src/scene/renderer/renderer.js +++ b/src/scene/renderer/renderer.js @@ -408,17 +408,15 @@ class Renderer { // camera params this.cameraParamsId.setValue(camera.fillShaderParams(this.cameraParams)); - // viewport size - let viewportWidth = target ? target.width : this.device.width; - let viewportHeight = target ? target.height : this.device.height; + // viewport size. In stereo XR the XR session reports the per-eye viewport directly, + // which is correct for both side-by-side single-texture and multi-pass per-eye-view + // layouts — preferred over inferring from target.width. + const xrView = camera.xr?.session ? camera.xr.views.list[0] : null; + let viewportWidth = xrView ? xrView.viewport.z : (target ? target.width : this.device.width); + let viewportHeight = xrView ? xrView.viewport.w : (target ? target.height : this.device.height); viewportWidth *= camera.rect.z; viewportHeight *= camera.rect.w; - // adjust viewport for stereoscopic VR sessions - if (camera.xr?.session && camera.xr.views.list.length === 2) { - viewportWidth *= 0.5; - } - this.viewportSize[0] = viewportWidth; this.viewportSize[1] = viewportHeight; this.viewportSize[2] = 1 / viewportWidth;