Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 1 addition & 31 deletions examples/src/examples/gaussian-splatting-xr/vr-lod.controls.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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' },
Expand Down
72 changes: 59 additions & 13 deletions examples/src/examples/gaussian-splatting-xr/vr-lod.example.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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) {
Expand All @@ -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');
Expand Down
15 changes: 7 additions & 8 deletions src/scene/gsplat-unified/gsplat-manager.js
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
14 changes: 6 additions & 8 deletions src/scene/renderer/renderer.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down