Skip to content

Decouple WebXR from the render loop (fixes WebGPURenderer XR)#58

Closed
bigmistqke wants to merge 28 commits into
solidjs-community:nextfrom
bigmistqke:xr-frameloop-decoupling
Closed

Decouple WebXR from the render loop (fixes WebGPURenderer XR)#58
bigmistqke wants to merge 28 commits into
solidjs-community:nextfrom
bigmistqke:xr-frameloop-decoupling

Conversation

@bigmistqke
Copy link
Copy Markdown
Contributor

Summary

solid-three internally drove the WebXR frame loop: on the renderer's sessionstart event it toggled renderer.setAnimationLoop(...) and set xr.enabled. That breaks WebGPURenderer — three's WebGPU XRManager snapshots the renderer's animation loop at setSession time (before sessionstart fires), so toggling on sessionstart is too late and overwrites the manager's own frame driver. Result on a Quest with WebGPURenderer: gl.getContextAttributes is not a function (when on the WebGPU backend), and on forceWebGL a hung headset with endless "Not all layers submitted" warnings.

This PR hands XR loop ownership to the consumer and reduces core to one responsibility: keep its own window loop out of the way while a session is presenting. Core now depends only on the stable, cross-renderer contract — gl.xr.isPresenting (read) and the consumer installing setAnimationLoop(render) before setSession — with no WebGL-vs-WebGPU branching. This also fixes a latent frameloop="always" + XR double-render.

What changed

  • Deleted the internal XR driver (handleXRFrame, handleSessionChange, the xr connect/disconnect object, warnNonXR, the auto-connect effect, and the canDriveXR util).
  • Window-initiated renders yield while presenting: a single isPresenting() predicate guards loop, requestRender, and the resize render in canvas.tsx. render itself is intentionally not guarded (the XR session calls it).
  • One sessionend listener revives the window loop after exit (self-stopping guard handles the stop, so no sessionstart listener is needed). The post-XR repaint fires only in frameloop="demand", not "never".
  • Context.render type corrected to (timestamp: number, frame?: XRFrame) => void so the XRFrame flows to useFrame.

Consumer migration (XR is now wired by you)

const { gl, render } = useThree()
// enter XR — install the loop BEFORE setSession:
gl.setAnimationLoop(render)
gl.xr.enabled = true
await gl.xr.setSession(session)   // e.g. three's AR/VR button
// exit:
gl.setAnimationLoop(null); gl.xr.enabled = false

Same code for WebGLRenderer and WebGPURenderer. For XR with WebGPURenderer on three ≤ r184, construct it with { forceWebGL: true } (WebGPU-backend XR is unreleased upstream).

Breaking

  • Removed Context.xr ({ connect, disconnect }) — wire gl.xr directly.
  • Context.render signature changed (deltatimestamp, added frame).

Test plan

  • pnpm test — 151 passed, 2 todo (incl. new tests: window loop yields while presenting + resumes on sessionend; core leaves gl.xr.enabled alone; XRFrame forwarded to useFrame; listener detaches on renderer swap; renderer whose xr lacks addEventListener doesn't crash)
  • pnpm lint:types — clean
  • pnpm lint:code — clean
  • On-device: verify WebGPURenderer({ forceWebGL: true }) + WebXR presents on Quest 3 (the original report)

Design + plan: docs/superpowers/specs/2026-05-29-xr-frameloop-decoupling-design.md.

🤖 Generated with Claude Code

Hand XR loop ownership to the consumer so core depends only on the
stable cross-renderer contract (setAnimationLoop-before-setSession +
xr.isPresenting), insulating it from the diverging, still-moving
WebGLRenderer vs WebGPURenderer XR semantics.
Spec: one sessionend resume listener + self-stopping isPresenting guard
(was two listeners); add moving-target motivation and per-renderer
consumer contract. Plan: TDD task breakdown.
Core no longer drives the WebXR frame loop. It keeps its own window loop
out of the way via an isPresenting guard + one sessionend resume listener,
and depends only on the stable cross-renderer contract
(setAnimationLoop-before-setSession + xr.isPresenting). The consumer wires
setAnimationLoop/enabled/setSession. Fixes WebGPURenderer XR (the old
sessionstart toggle ran too late for the WebGPU manager's setSession
snapshot) and the latent frameloop=always + XR double-render.
Code-review follow-ups:
- canvas.tsx resize no longer issues a window-driven render while an XR
  session is presenting (the third window driver that bypassed the guard).
- extract a single isPresenting() predicate used by loop + requestRender.
- sessionend resume repaints only in frameloop="demand", not "never".
- useThree docs: correct render signature, drop removed xr member, add a
  consumer-driven WebXR section.
- add coverage for a renderer whose xr manager lacks addEventListener.
@pkg-pr-new
Copy link
Copy Markdown

pkg-pr-new Bot commented May 29, 2026

commit: 64f5500

Design for a createXR() primitive that encapsulates the three sharp edges
of the decoupled XR contract (setAnimationLoop-before-setSession snapshot
order, the post-exit double-drive, and transient-activation request-first)
plus the structural Canvas-context-to-DOM-button gap. Also wires and widens
Canvas's currently-dead ref<Context> to support a cleanup return.
@bigmistqke bigmistqke force-pushed the xr-frameloop-decoupling branch from 4a26968 to cb61f7a Compare June 2, 2026 14:05
- New API page site/src/routes/api/hooks/create-xr.mdx + sidebar entry
- README: createXR section, feature bullet, TOC; fix stale Context.render
  signature and remove the removed Context.xr field
- Canvas ref documented as a cleanup-returning Context ref (README + canvas.mdx)
- use-three WebXR section now points at createXR as the recommended path
Cut author-facing internals (snapshot timing, double-drive, window-loop
handoff, WebGL-vs-WebGPU parity) from the createXR page and README — those
live in the design spec and the useThree manual-driving escape hatch. Keep
the one rule a user must follow (call enter from the click handler) as an
aside by the VR example. Collapse the Behavior section into the Returns
table + a two-line Notes block; trim redundant parentheticals.
…text

connect only ever reads gl and render off the Context, so type its parameter
as XRContext = Pick<Context, 'gl' | 'render'> and export it. A full Context
still satisfies it, so <Canvas ref={xr.connect}> is unchanged (locked by a
compile-time assertion), but the signature now states the real dependency and
a bare { gl, render } works — createXR is tied to neither <Canvas> nor its ref.
Docs + spec reworded to match.
@bigmistqke
Copy link
Copy Markdown
Contributor Author

createXR — the consumer-facing half of this change

Now that core no longer drives the XR loop, createXR is how you actually enter a session. It lives outside <Canvas> (next to your DOM "Enter XR" button) and wires to the renderer via <Canvas ref={xr.connect}>. Same code for WebGLRenderer and WebGPURenderer.

const xr = createXR()

<button onClick={() => xr.enter("immersive-vr")}>Enter VR</button>
<Show when={xr.isPresenting()}>
  <button onClick={() => xr.exit()}>Exit VR</button>
</Show>

<Canvas ref={xr.connect}></Canvas>

It absorbs the three ordering edges that this decoupling exposes, so consumers don't have to: setAnimationLoop(render) before setSession (WebGPU snapshots the loop there), setAnimationLoop(null) on sessionend (avoids the double-render), and requestSession first with nothing awaited before it (keeps user activation). Returns connect / enter / exit / isPresenting / session / isSupported. For controller/hand poses, read the XRFrame from useFrame's third arg — no need for createXR inside the scene.

WebGPURenderer note: the native WebGPU backend can't present WebXR yet — that integration is landing in r185 (tracked in mrdoob/three.js#32858). Until then, construct the renderer with { forceWebGL: true } to present over the WebGL2 fallback. createXR itself is backend-agnostic.

@bigmistqke
Copy link
Copy Markdown
Contributor Author

continue work at #62 from a clean next branch

@bigmistqke bigmistqke closed this Jun 3, 2026
bigmistqke added a commit that referenced this pull request Jun 3, 2026
…add createXR/useXR (#62)

Reworks solid-three's WebXR integration so the consumer owns the session
and core stays out of the frame loop while a session presents. Continues
#58 on top of a cleaned-up next branch.

## Summary

Previously core drove the WebXR loop itself: on the renderer's
`sessionstart` it toggled `setAnimationLoop(...)` and set `xr.enabled`.
That breaks `WebGPURenderer` — three's WebGPU `XRManager` snapshots the
renderer's animation loop at `setSession` time, before `sessionstart`
fires, so toggling on `sessionstart` is too late and clobbers the
manager's own frame driver. On a Quest this surfaced as
`gl.getContextAttributes is not a function` on the WebGPU backend, and a
hung headset with endless "Not all layers submitted" warnings under
`forceWebGL`. The same internal driver also caused a latent
`frameloop="always"` + XR double-render.

Core now depends only on the stable, cross-renderer contract — the
consumer installs `setAnimationLoop(render)` before `setSession`, and
core reads the read-only `renderer.xr.isPresenting` to yield its window
loop while presenting, resuming via a single `sessionend` listener. No
WebGL-vs-WebGPU branching. The session wiring then lives behind one
small primitive so consumers can't trip the sharp edges.

## What changed

### `createXR()` — consumer-owned XR entry

A Solid primitive called in a component body (it owns one reactive
effect), built only on `context.gl`, `context.render`, and the
renderer's standard `xr` event target — no core internals, no
renderer-family branching. Members: `connect` (a cleanup-returning
`Ref<XRContext>` for `<Canvas ref={xr.connect}>`), `enter(mode, init?)`
/ `enter(session)`, `exit()`, `isSupported(mode)`, `isPresenting()`,
`session()`, and `Provider`. It absorbs the three edges of the contract
so the consumer never has to: snapshot order (`setAnimationLoop(render)`
before `setSession`), post-exit double-drive (`setAnimationLoop(null)`
on `sessionend`), and transient activation (`requestSession` first,
nothing awaited before it).

### `useXR()` + `createXR().Provider` — in-scene state

Wrap the subtree (Canvas + its button) in `<xr.Provider>`; scene
components read `{ isPresenting, session, exit }` with `useXR()` (which
throws outside a provider). This serves in-world UI — e.g. a mesh whose
`onClick` calls `exit()`, since the DOM exit button is not rendered
while an immersive session presents. `useXR` is deliberately not the
entry API: `use*` hooks read a provider from inside Canvas, whereas
`createXR()` is created outside it.

### Cleanup-returning refs

`useRef` now runs a callback ref's returned cleanup via `onCleanup` (the
React-19 cleanup-ref shape), and `<Canvas>`'s `ref` type is widened to
allow it (`RefWithCleanup<T>`). This is what makes `xr.connect` a
one-liner that returns its own disconnect.

### API surface

New exports: `createXR`, `useXR`, and types `XRContext` (`Pick<Context,
"gl" | "render">`) and `XRState`. `connect` is typed `XRContext`, not
the full `Context`, so its real dependency is explicit and any `{ gl,
render }` works — it is tied to neither `<Canvas>` nor its ref.

### Docs

New API pages `create-xr.mdx` and `use-xr.mdx`; `use-three.mdx`
documents the manual escape hatch and points at `createXR` as the
recommended path; `canvas.mdx` documents the cleanup-returning `ref`.
README gains `createXR`/`useXR` sections, a feature bullet, and TOC
entries. Design specs and TDD plans live under `docs/superpowers/`.

## Breaking

- Removed `Context.xr` (`{ connect, disconnect }`) — XR is now
consumer-wired (via `createXR`, or `gl.xr` directly).
- `Context.render` signature changed (`delta` → `timestamp`, added
`frame`) so the `XRFrame` flows through to `useFrame`.

## Manual escape hatch

`createXR` is the recommended path, but the raw contract is small enough
to wire by hand for both renderers: `gl.setAnimationLoop(render)` before
`gl.xr.setSession(session)` (with `gl.xr.enabled = true`), and
`gl.setAnimationLoop(null); gl.xr.enabled = false` to exit. For XR with
`WebGPURenderer` on three ≤ r184, construct it with `{ forceWebGL: true
}` (WebGPU-backend XR is unreleased upstream).

## Test plan

- `pnpm test` — 175 passed, 2 todo (12 files): core yields the window
loop while presenting and resumes on `sessionend`; core leaves
`gl.xr.enabled` alone; `XRFrame` forwarded to `useFrame`; `createXR`
state & wiring (snapshot order, post-exit loop-null, renderer-swap
re-attach, no-crash on an `xr` lacking `addEventListener`,
connect/disconnect); `enter` (request-first, provided-session overload,
error paths); `exit`/`isSupported`; `useXR` throws outside provider;
`Provider` bridges state across the Canvas boundary; cleanup-returning
`useRef`.
- `pnpm lint:types` — clean
- `pnpm lint:code` — clean
- Pending: on-device `WebGPURenderer({ forceWebGL: true })` + WebXR on
Quest 3 (the original report).
@bigmistqke bigmistqke deleted the xr-frameloop-decoupling branch June 3, 2026 10:00
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant