Skip to content

feat: settle on the browser's wovenState, SDK-owned rect cover, same-task resize, double-attach warning, a frame every frame (1.38.0) - #147

Merged
dfattal merged 9 commits into
mainfrom
feat/woven-state-and-guards
Oct 9, 2026
Merged

dfattal merged 9 commits into
mainfrom
feat/woven-state-and-guards

Conversation

@dfattal

@dfattal dfattal commented Oct 8, 2026 •

Copy link
Copy Markdown
Contributor

Stacked on #146 (base is its branch; retarget to main once it merges). Five items from a kiosk demo that keeps one woven canvas across screens and hit black boxes after rect moves, a stretched last frame on resize, a stage attached twice, and covers released on timers. Browser side: DisplayXR/displayxr-browser-pvt#256 (join signal, draft PR #258), #255 (transparent frame weaves black).

A. firstWoven / rewoven() settle on XRDisplayLayer.wovenState where the browser reports it (core, additive). Feature-detected on the prototype; absent → the timer path, unchanged. Present → firstWoven settles { woven: true, confirmed: true, reason: 'woven' } on the first "woven" read after a stereo frame, no hold. rewoven() settles on a "woven" read after a "withheld"/"pending" read seen after the call, or three frames after a box change, never on a stale "woven" the frame the change is seen. Hold cap kept (4 holds, never under 4.8 s): a layer stuck "withheld" settles woven: false, confirmed: true, reason: <withheldReason>. New read-only TileHandle.wovenState / withheldReason, types WovenState, WovenWithheldReason.

B. rectCover on every add*(): 'auto' | 'off' | { color?, snapshot? }. A 2D sibling cover goes up when the canvas's CSS box changes (size, dpr, now also position: core rewoven() restarts on a pure move, since the browser re-registers a moved rect), with a one-time left-eye snapshot of the last frame, and comes down when rewoven() settles. Default 'off' in the core, 'auto' in ./splat and ./model (preview-tier default change, flagged in the CHANGELOG). Pages with their own cover set 'off'.

C. Resize and draw in the same task. The PlayCanvas viewer resizes its backing store and replays the last frame inside the ResizeObserver callback, before paint, with the projection corrected for the new aspect. Core addScene gains optional onResize({ width, height, dpr }) in the same callback for page-drawn windows.

D. Double-attach warning (warning only, no behavior change). add*() on a canvas still registered, including a second addSplat/addModel during the first one's load, warns once per canvas naming the method and the live registration, then proceeds as before.

E. A frame every session frame. Both viewers drew nothing on a bad frame before their first good one; they now draw the mono camera into both halves. Rules doc: page-drawn windows must draw every frame; a transparent frame weaving black is browser-pvt #255, listed as a known limitation.

Tests 849 → 887, pnpm api:check snapshots match, pnpm check:snippets passes. Nothing has run on a browser build with wovenState or on a panel; all against mocks. Panel checks to do: the WebGL snapshot in B reads back (else the cover is solid), the three-frame lag in A matches the browser, the one-frame move window in B is acceptable.

🤖 Generated with Claude Code

F. Display reads wait for the layer's first frame (core, additive, no new API; commit b7f463c). getDisplayInfo(), getRenderingModes() and requestRenderingMode() called before the layer has framed now wait for the first stereo frame (or the first non-pending wovenState read where the browser reports it), capped at the firstWoven cap (4.8 s by default), then ask the layer; null/empty is returned only as a real absence. Calls after the first frame pass straight through; no-session, failed-layer, removed and session-ended cases settle at once as today. Closes the bug behind the display-modes sample (#151) for every page. Tests 887 → 895.

Rebased onto main after #146 and #151 merged; changelog entries sit under Unreleased, no version bump in the PR.

Panel pass 1 (Chromium 156 dev build with wovenState, 9 Oct): confirmed: true path works (46/46 rewoven(), firstWoven 2.97 s vs 4.18 s hold-elapsed), Show fast-switching with the branch and rectCover: 'off' matches the shipped SDK, no double. Two defects found and fixed on the branch: display reads released on the first non-pending read ('withheld' right after layer creation) and answered null early (0748845: release only on 'woven', retry on empty until the cap); rewoven() fell to the 1200 ms steady-state hold when the rect changed before the call (9591a7d: settle on a 'woven' read three frames after the call). Tests 895 → 900. Second panel pass pending.

dfattal and others added 6 commits October 8, 2026 14:54
…ere the browser reports it (1.38.0)

Feature-detected on the prototype; absent attribute keeps the hold path unchanged.
firstWoven: confirmed:true, reason 'woven' on the first woven read after a stereo frame.
rewoven: needs a withheld/pending read after the call, or 3 frames after a box change, so a
stale woven from the old rect never settles it. The cap stays as a safety: withheld forever
settles woven:false, confirmed:true, reason <withheldReason>. New read-only handle.wovenState /
handle.withheldReason; WovenState / WovenWithheldReason types.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…' on ./splat and ./model (1.38.0)

Core primitive on every add*(): after the first join, a change of the canvas's CSS size, dpr or
page position raises a sibling 2D cover (color + one left-eye snapshot via drawImage at the
change) and cuts it when rewoven() settles; a second change while up restarts; remove() and
session end tear it down. 'off' in the frozen core, 'auto' on the scene subpaths. rewoven()'s
box key gains the page position (document scroll excluded), so a pure move now restarts it.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…cene onResize (1.38.0)

The PlayCanvas viewer (./splat playcanvas, ./model default) resizes its store and replays the
last frame inside the ResizeObserver callback, before the paint, with the cached frustum's x row
scaled by the aspect change so the replay keeps proportions; a callback during a draw or a
re-entrant one falls back to the one-frame debounce. Core addScene gains an optional
onResize(box) run in the same callback for page-drawn windows.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…turns the existing handle (1.38.0)

addScene/addImage/addVideo and the addSplat/addModel entries: a canvas still registered on the
same wall is answered with its first handle and no second layer/renderer (was: close and rebuild
the layer). The subpath entries claim the canvas at the call (their renderer exists before the
core window) and pass a claim token to their own addScene; remove() releases it.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
… first good one (1.38.0)

PlayCanvas viewer and SceneViewer drew nothing on a bad frame (short view list, missing or
degenerate viewport) before any good frame existed; they now draw the mono camera into both
halves (flat subject, or a cleared frame with no content). Rules doc: page-drawn addScene
windows must draw every onFrame; a transparent woven frame weaving black is browser-pvt #255.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…ds as before (1.38.0)

Review: returning the existing handle changed frozen-core behavior (a page re-adding to swap
pictures would keep the old one). Detection stays (including the subpath entry claim, so a second
addSplat/addModel during the first one's load is caught), warned once per canvas; the call then
closes and rebuilds the layer / attaches a new renderer and returns its own new handle, exactly
as before. Drops the return-existing machinery (win.handle, the refusal's return value).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
@dfattal
dfattal force-pushed the feat/woven-state-and-guards branch from f41798f to 7ca1805 Compare October 8, 2026 21:54
@dfattal
dfattal changed the base branch from feat/splat-finishswap-rewoven to main October 8, 2026 21:54
…ing null/[] early

getDisplayInfo() / getRenderingModes() / requestRenderingMode() called before a window's layer
has delivered its first frame (right after add*()) got null / [] / "not forwarded": the browser
answers only once the layer has a weave session, and null is documented as "no glasses-free
display". The core now waits for that layer's first frame and asks then.

- Released by the layer's first stereo frame (the firstWoven signal, no hold), or where the
  browser reports wovenState, the first non-'pending' read.
- Capped at the first-woven cap (REWOVEN_MAX_HOLDS x max(firstWovenHoldMs, 1200) from the
  layer's construction), checked per session frame and backed by a real timer; a layer that
  never frames resolves with what it answers then.
- No session / no live layer / failed layer: settles at once, as before. Removal or session end
  mid-wait releases the read, which re-checks and rejects as before; a rebuilt layer waits anew.
- index.d.ts documents null/empty as "the layer answered", never "not ready yet"; CHANGELOG
  Unreleased gains the entry and an intro covering the whole Unreleased set.
- test/display-reads.test.mjs (8 tests); display-modes and mode-switch walls now deliver a
  stereo frame before their display calls.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
dfattal and others added 2 commits October 9, 2026 13:16
… an early null

On a browser that reports wovenState the release was "first read that is not
'pending'", but the layer reads 'withheld' right after creation, before the
weave session can answer: getDisplayInfo() asked at 1.41 s answered null at
1.55 s while the layer only read 'woven' at ~3.9 s (Chromium 156 panel build).

- wovenState path: released by the first 'woven' read; 'withheld' does not count.
- Both paths: a getDisplayInfo() / getRenderingModes() answer that is null / []
  after the release and before the cap is not returned; the read waits for the
  next release signal (next 'woven' read, or next session frame once a stereo
  frame has been drawn) and is asked again, resolving with the first non-empty
  answer. Only past the cap (4 x max(fwHoldMs, 1200) from layer construction)
  is null / [] returned. requestRenderingMode() is forwarded once, not retried.
- Once a read on the layer answered for real, calls go straight through.
  No session / no layer / failed layer / removed window: unchanged.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
… after a hold

A page that moves its canvas and THEN calls rewoven() never shows a box change
or a withheld read after the call, so the confirmed path fell to "a hold of
steady 'woven' reads": a kiosk panel run measured 46/46 confirmed at median
1206 ms, the same as the timer path.

The no-change rule is now: settle on a 'woven' read at least
WOVEN_STATE_LAG_FRAMES (3) session frames after the call. The report is a live
per-frame level with at most 3 frames of lag, so such a read reflects the rect
as it was at the call. Unchanged: settle after a withheld/pending read seen
after the call; 3 frames after a box change seen after the call; never on the
frame of the call; a stereo frame since the restart is required; the cap. The
timer path (no wovenState) is untouched. Expected ~50 ms instead of ~1200 ms.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
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