Skip to content

docs(vr): revise Phase 4 — embed existing OrbitController, add Phase 4b 2D companion#53

Open
Hackshaven wants to merge 28 commits into
mainfrom
claude/xr-phase-4-planning-u891M
Open

docs(vr): revise Phase 4 — embed existing OrbitController, add Phase 4b 2D companion#53
Hackshaven wants to merge 28 commits into
mainfrom
claude/xr-phase-4-planning-u891M

Conversation

@Hackshaven

Copy link
Copy Markdown
Member

Summary

Plan-only change to docs/VR_INVESTIGATION_PLAN.md. No code touched. Rewrites Phase 4 around the procedural Orbit character that already shipped at /orbit, adds a sibling Phase 4b for the 2D main-app embed, and refreshes the Status header to reflect that Phase 3.5 (VR tours) shipped via #41.

Why

The original Phase 4 plan was written before the Orbit character existed. It assumed glTF + Mixamo and a parallel ORBITING/APPROACHING/PRESENTING/RETURNING state vocabulary — none of which match what's in src/services/orbitCharacter/ today (full procedural avatar, six-state behavior machine, four palettes, Bézier flight + scale presets, postMessage bridge, prefers-reduced-motion handling). Building a second character would have been wasted work; what's left is embedding and driving.

Locked decisions in the rewrite

  • Embedding boundary: extract a renderer-agnostic OrbitAvatarNode out of orbitScene.ts so VR can mount the avatar as a child of its scene (no second renderer, no duplicate Earth — VR uses the existing primary globe). OrbitController becomes a thin wrapper for the /orbit page, which stays pixel-stable.
  • Host-agnostic docent bridge: new orbitDocentBridge.ts maps DocentStreamChunk events → setState / playGesture / flyToEarth calls. /orbit, VR, and the future 2D companion all consume the same bridge.
  • State vocabulary: keep what shipped (IDLE / LISTENING / THINKING / TALKING / CHATTING / CONFUSED + flyToEarth / flyHome); drop the original ORBITING/APPROACHING/PRESENTING/RETURNING.
  • VR scale: companion ~0.6–1.0 m at ~15 cm apparent size; idle orbit at ~0.7 m around the primary globe; present pose at the surface lat/lng. Headset is the camera — no FOV/dolly tricks.
  • Triggers: tour POIs + <<LOAD:...>> markers + HUD button. Voice / VR chat panel stay in Phase 5.

Phase 4b — 2D companion

Renderer-agnostic node + host-agnostic bridge land us, almost for free, the ability to put Orbit in the flat web/desktop main app. Two modes picked by one config flag:

  • mascot (augment): small canvas in the chat panel header, Earth hidden / wireframe, existing chat UX intact.
  • replacement: larger canvas where the chat panel sits, Orbit becomes the speaker with a DOM bubble anchored to the character.

Phase 4b ships mascot first; ~4 commits, mostly UI mounting, can land any time after Phase 4 commits 2–3.

Commit sequence (Phase 4)

  1. (this PR) docs(vr): revise Phase 4 — plan only
  2. orbitCharacter: extract OrbitAvatarNode — manual visual A/B before merge
  3. orbitDocentBridge — wired on /orbit first
  4. vr(4): mount OrbitAvatarNode in vrScene — idle orbitpause for on-Quest scale check
  5. vr(4): summon → companion posepause
  6. vr(4): present → flyToEarth + follow-me
  7. docentService: location chunks + fly_to_location tool
  8. vr(4): wire orbitDocentBridge into vrSessionpause
  9. vr(4): tour POI integration
  10. vr(4): polish — flight curves, multi-globe, prefers-reduced-motion in XR

Test plan

  • Doc-only diff: +317 / −188 in docs/VR_INVESTIGATION_PLAN.md
  • No source files modified — no type-check or test changes needed
  • Markdown structure preserved: Phase 4 → Phase 4b → Phase 5 ordering correct, no orphan headings, no broken table syntax
  • DCO-signed commit
  • Reviewer: confirm the embedding boundary (renderer-agnostic node + thin wrapper) and the dual-mode 2D companion plan match the direction we discussed before any code lands

https://claude.ai/code/session_01LnxWuSAAHNPngjXpXEahHy


Generated by Claude Code

…4b 2D companion

The original Phase 4 plan was written before the procedural Orbit
character shipped at /orbit. It assumed glTF + Mixamo + a separate
ORBITING/APPROACHING/PRESENTING/RETURNING state vocabulary — none of
which match what we actually have.

Rewrite Phase 4 around the existing src/services/orbitCharacter
module:

- Drop the glTF/Mixamo/Kenney path; the character is built.
- Lock the embedding boundary: extract a renderer-agnostic
  OrbitAvatarNode out of orbitScene.ts so vrSession can mount the
  avatar as a child of its scene without a second renderer or
  duplicate Earth. OrbitController becomes a thin wrapper for the
  /orbit page; the standalone page stays pixel-stable.
- Lock the host-agnostic docent bridge: a new
  orbitDocentBridge.ts maps DocentStreamChunk events to controller
  setState/playGesture/flyToEarth calls. VR, the standalone page,
  and the future 2D companion all consume the same bridge.
- Use the shipped state vocabulary (IDLE / LISTENING / THINKING /
  TALKING / CHATTING / CONFUSED + flyToEarth/flyHome flight modes)
  instead of inventing a parallel one.
- Express scale in VR as world-space distance + size around the
  primary globe, not FOV/dolly moves (the headset is the camera).
- 10-commit sequence with mid-cycle Quest-validation pauses.

Add Phase 4b — 2D Orbit companion in the main app:

- Mascot (augment) and replacement modes for the chat panel,
  picked by a single config flag. Both reuse the same controller
  API and docent bridge from Phase 4, so 4b is mostly UI mounting.
- Lazy-loaded on first chat-open so the main bundle is unchanged
  for non-chat users; ~4-commit sequence.

Also refresh the Status header (Phase 3.5 shipped via PR #41) and
mark the Phase 3.5 section as shipped with the 8-commit breakdown
landing on claude/vr-tours-phase-3.5.

Plan-only commit. No code changes.

Signed-off-by: Claude <noreply@anthropic.com>
Phase 4 / 4b commit 2 of 9 (per docs/VR_INVESTIGATION_PLAN.md). Splits
the construction primitives in orbitScene.ts so the same character
mesh + material + animation logic can be hosted either by the
standalone /orbit page (existing OrbitController; unchanged behaviour)
or by a host that already owns a renderer + scene + globe (VR scene,
future 2D companion).

Changes:

- orbitScene.ts: BuildSceneOptions gains a `mode: 'standalone' |
  'embedded'` flag. In embedded mode the container is a Group instead
  of a Scene, no sky background, no internal PerspectiveCamera, no
  internal photoreal Earth — host supplies camera + sun direction
  per frame. OrbitSceneHandles widens `scene: Object3D`, makes
  `camera` and `earth` nullable. UpdateInput gains optional
  `camera` + `sunDir` overrides. updateCharacter consults inputs
  first and falls back to handles for the standalone path.
  earth.update() hoisted out of updateCharacter (caller's job).

- orbitCharacter/index.ts (OrbitController): passes `mode:
  'standalone'` explicitly, calls handles.earth.update() before
  updateCharacter, narrows handles.scene/camera/earth via non-null
  assertions where standalone mode guarantees them. /orbit page
  renders identically — no logic changes, only call-site moves.

- orbitAvatarNode.ts (new): OrbitAvatarNode class. Owns embedded-mode
  handles, anim state, flight, time rebase, prefers-reduced-motion
  subscription. Public group: Object3D for the host to parent.
  update(dt, ctx: { camera, sunDir, mouse?, cursorActivityTime? })
  drives a frame. Mirrors OrbitController's state/gesture/palette/
  flight API so the future docent bridge can target either host.

- orbitTrails.ts buildTrails + photorealEarth.ts addTo/removeFrom:
  parameter widened from THREE.Scene to THREE.Object3D so embedded
  callers can pass Groups. Implementations only use Object3D.add /
  .remove; widening is a no-op for existing Scene callers.

Tests + type-check + tsc emit + vite build: all green (938 tests
pass, no type errors, no new warnings). The /orbit page is on the
exact same code path as before this commit; manual visual A/B is
the recommended pre-merge check on the standalone page.

https://claude.ai/code/session_01LnxWuSAAHNPngjXpXEahHy
Signed-off-by: Claude <noreply@anthropic.com>
Phase 4 / 4b commit 3 of 9 (per docs/VR_INVESTIGATION_PLAN.md). Adds
the connector that the future VR session and 2D companion will both
plug their docent stream into. Wired on the standalone /orbit page
first to prove the mapping end-to-end without any LLM / network
dependency.

Module: src/services/orbitDocentBridge.ts. Maps DocentStreamChunk
events onto the duck-typed `OrbitDocentTarget` shared by
OrbitController and OrbitAvatarNode (setState / playGesture /
getState):

  - onUserSubmit() → setState('LISTENING'), arms a thinkingDelayMs
    timer that fires setState('THINKING') if no first-delta arrives
    in time (default 800 ms).
  - First {type:'delta'} chunk → setState('TALKING') and cancels the
    THINKING timer.
  - {type:'action'} chunk → playGesture('beckon').
  - {type:'auto-load'} chunk → playGesture('affirm').
  - {type:'rewrite'} chunk → no state change (visual stays put while
    the underlying text is revised).
  - {type:'done', fallback:false} → setState('CHATTING').
  - {type:'done', fallback:true} → setState('CONFUSED'), then
    setState('CHATTING') after confusedHoldMs (default 1500 ms).
  - onAbort() (network error / manual abort) → same as fallback done.

Edge cases covered by tests: chunks before any submit are ignored
(no orphan transitions), late-tail chunks after a done are ignored
(no avatar yank-back), a new submit cancels any pending CONFUSED
timer (no stomping), dispose() cancels timers and silences future
chunks. 15 unit tests via vitest fake timers; the bridge takes a
pluggable scheduler so non-vitest hosts can drive it deterministically
too.

/orbit demo wiring:

  - orbit.html: new "Docent" row in the debug panel, hosting the
    step buttons next to the existing State / Gesture / Palette /
    Scale / Flight rows.
  - orbitDebugPanel.ts: builds Submit / Delta / Action / Auto-load /
    Done ✓ / Done ✗ / Abort buttons that drive an OrbitDocentBridge
    instance live against the page's controller. A designer can
    click through and watch the avatar transition through every
    docent path without spinning up the LLM stack. The bridge is
    disposed alongside the panel.

No host-specific imports — orbitDocentBridge.ts touches only
DocentStreamChunk (type-only), GestureKind, and StateKey. Same
instance will drive vrSession's docent connection (commit 7) and
the future 2D companion mount (Phase 4b) without a second mapping.

Tests + type-check + tsc emit + vite build: all green (953 tests
pass, no type errors). Manual /orbit verification: every step
button drives the expected on-screen transition.

https://claude.ai/code/session_01LnxWuSAAHNPngjXpXEahHy
Signed-off-by: Claude <noreply@anthropic.com>
…lobe

Phase 4 / 4b commit 4 of 9 (per docs/VR_INVESTIGATION_PLAN.md). First
VR-side commit: the docent character now rides an idle orbit around
the primary globe whenever a WebXR session is active. No state-machine
plumbing yet — that lands in commit 7 (vrSession ↔ orbitDocentBridge).
This commit verifies the embedding boundary works on-device: avatar
renders, lights itself coherently with Earth's sun, follows the globe
through pinch-zoom and AR re-anchor.

Plan calls for an on-Quest validation pause after this commit before
moving on, primarily to tune three knobs:

  AVATAR_ORBIT_RADIUS_FACTOR = 1.4   // × GLOBE_RADIUS = 0.7 m at scale 1
  AVATAR_ORBIT_PERIOD_SECONDS = 30   // one revolution
  AVATAR_ORBIT_TILT_RADIANS  = 0.26  // ≈ 15° lift above the horizontal

Wiring:

- orbitScene.ts: ORBIT_LAYER promoted from module-private to public
  export. Hosts embedding the avatar must enable this layer on their
  camera (and per-eye sub-cameras for ArrayCamera) so the avatar
  renders. The barrel re-exports it.

- vrScene.ts: constructs an OrbitAvatarNode in 'embedded' mode and
  parents avatar.group into the scene. The handle gains an `avatar`
  field so later commits can drive its state from outside vrScene.
  Per-frame update():

    * Advances an internal orbit phase by clamped dt (matching the
      controller's clamp; a paused tab won't fast-forward the orbit).
    * Computes a tilted-ellipse position relative to globe.position,
      scaled by globe.scale.x — pinch-zoom and AR re-anchor flow in
      via the existing globe transform without extra plumbing.
    * Orients the avatar's +Z (its eye-facing axis under the vinyl
      redesign) toward the globe by mirroring its position through
      globe.position and calling lookAt() on the mirror — vanilla
      lookAt() would put the BACK of the head at the globe.
    * Enables ORBIT_LAYER on the active camera and on any sub-cameras
      (idempotent, single bitwise OR per eye), so layer culling lets
      the avatar render without the host having to flip the layer at
      session start.
    * Forwards earth.sunDir + the host camera into avatar.update so
      the avatar's body shading, sub-shadow direction, and eye-dome
      streak track the same subsolar direction the photoreal globe is
      lit by — body shading and Earth's terminator stay in lockstep.

  update()'s signature widens to (dt, camera). Single call site
  (vrSession.ts:~1201) so the change is contained. dispose() pulls
  avatar.group out of the scene and disposes the embedded handles.

- vrSession.ts: the existing scene.update() call now forwards
  `delta` (already computed up-thread for the rest of the loop) and
  `active.camera`. Position in the loop is unchanged — runs after
  flight tween / before tour overlay update, the same slot the prior
  scene.update() occupied.

Tests + type-check + tsc emit + vite build: all green (953 tests
pass, no type errors). Behavioral surface unchanged for non-VR users
(the entire avatar stack is reachable only from within an active
WebXR session). Manual on-Quest verification recommended before
commit 5 to size the orbit radius / period / tilt against the actual
headset framing.

https://claude.ai/code/session_01LnxWuSAAHNPngjXpXEahHy
Signed-off-by: Claude <noreply@anthropic.com>
Two fixes from on-Quest validation of commit 99d447e.

1. Eye stencil buffer. Quest reported the avatar's eyes as "two
   detached torus rings" with the inner stack (socket disc, iris,
   pupil, stars, glassdome, lids) missing. Root cause: the eye rig
   uses a stencil-buffered socket mask — `createSocketMaskMaterial`
   writes a per-eye stencilRef at renderOrder -2, then the lid
   material clips against it via `stencilFunc` so any dome geometry
   that swings outside the socket rim gets discarded by the GPU.
   The bezel torus sits outside that pipeline (plain
   MeshStandardMaterial) so it kept rendering, hence the
   "rings-only" silhouette.

   The standalone OrbitController constructs its WebGLRenderer with
   `stencil: true`. vrSession's renderer didn't, so on Quest the
   stencil ops fall through to undefined behaviour and the inner
   eye stack drops out. Enabling stencil costs ~9 MB per eye on
   Quest 3 framebuffers — negligible. AR alpha + stencil coexist
   fine on the same context.

2. Idle pose faces the user, not the globe. Commit 4 oriented the
   avatar with a globe-side lookAt to match the "small companion
   examining Earth" framing, but the on-Quest read was that the
   face is the most expressive part of the character and shouldn't
   be hidden during idle. The "examining" pose is reserved for
   PRESENTING (later commit), where the gaze-at-target is
   intentional and accompanied by a beckon / point gesture.

   New math: mirror the camera's world position through the avatar
   and lookAt the mirror, which puts the avatar's +Z (its
   eye-facing axis under the vinyl redesign) toward the camera. Two
   scratch Vector3s are allocated once at scene construction and
   reused per frame — Quest's mobile GPU is sensitive to per-frame
   GC pressure.

Tests + type-check + tsc emit + vite build: all green (953 tests
pass, no type errors). Recommended to re-validate on Quest:

  - Inner eyes (iris / pupil / glassdome / lid blink) should now
    render correctly, matching what the standalone /orbit page
    shows.
  - Avatar's face should track the headset as it moves around the
    globe — orbit position still circles, but the head reorients
    so the eyes face you from any phase.

https://claude.ai/code/session_01LnxWuSAAHNPngjXpXEahHy
Signed-off-by: Claude <noreply@anthropic.com>
On-Quest follow-up to b21d023. User reported Orbit was now oriented
to look AWAY from them, exactly opposite the intended pose.

Root cause: Three.js's `Object3D.lookAt(target)` aims the object's
local +Z axis at the target, NOT -Z. The convention only matches the
camera/light "-Z faces target" rule for Camera + Light objects;
non-camera Object3Ds get an inverted call into `Matrix4.lookAt` (see
three/src/core/Object3D.js — `_m1.lookAt(_target, _position,
this.up)` swaps the eye and target arguments). The previous commit
mirrored the camera position through the avatar to "make +Z face the
camera," but that math was correct only under the camera convention;
applied to an Object3D it rotated the avatar a full 180° away from
the user.

Fix: drop the mirror trick and call `avatar.group.lookAt(camera
world position)` directly. The avatar's eye-forward axis is its
local +Z (eye stack at SOCKET_Z_* = BODY_RADIUS + 0.003 to 0.0056),
so a direct lookAt aims the eyes at the user. Drops the
`_avatarLookMirror` scratch since the second Vector3 is no longer
needed.

Tests + type-check + tsc emit + vite build: all green (953 tests
pass). Recommended on-Quest re-validation: avatar's face should now
track you as you move around the globe; eyes should face the
headset from any orbit phase, with iris / pupil / glassdome / lid
blinks all visible (the stencil fix from b21d023 still applies).

https://claude.ai/code/session_01LnxWuSAAHNPngjXpXEahHy
Signed-off-by: Claude <noreply@anthropic.com>
On-Quest follow-up to b21d023's `stencil: true` request — the eye
internals (pupil, iris, stars, catchlights, lid blinks) were still
missing while the bezel torus + glass-dome glint kept rendering. The
flag is plumbed correctly to Three.js's WebGLRenderer and onward to
WebXRManager's XRWebGLLayer init, but Quest's WebXR baseLayer
evidently doesn't honour the request — the stencil tests
(NotEqualStencilFunc on the pupil group, EqualStencilFunc on the
lids) read against an all-zero buffer and discard everything. The
bezel + glass dome are stencil-free so they kept rendering, hence
the "deep blackness" surrounded by the glint.

Fix: add a `disableStencilClip` flag to BuildSceneOptions /
OrbitAvatarNodeOptions. When set, buildScene walks the container
after construction and turns off `stencilWrite` on every material
that opted in (socket masks, lids, pupil-group). vrScene flips it
on for the embedded avatar; the standalone /orbit page leaves it
off and keeps stencil-precise rendering on hardware where it works.

Trade-off: the lid spherical cap is 20% wider than the socket disc,
so a small sliver can poke past the bezel torus at extreme blink
angles. The bezel hides most of it; the visual cost is far smaller
than an empty socket. Pupil-group materials sit tightly inside the
socket so dropping their clip has no visible effect — they could
have left their stencil on, but disabling them all uniformly via a
single scene-traverse pass keeps the code simple and future-proofs
against new stencil-using materials being added without remembering
to gate them.

Diagnostic: vrSession now logs `gl.getContextAttributes()` after
renderer construction (info level). On a tester's headset, the
output will show whether `stencil` came back true or false, which
tells us whether the disable-clip workaround can be relaxed once
the underlying browser ships a fix.

Tests + type-check + tsc emit + vite build: all green (953 tests
pass). On-Quest expectation: pupils, iris colour, gaze tracking,
lid blinks, and catchlights should all render now. There may be a
tiny lid edge artifact at the corners; report if it's visible
enough to need a shader-based clip follow-up.

https://claude.ai/code/session_01LnxWuSAAHNPngjXpXEahHy
Signed-off-by: Claude <noreply@anthropic.com>
Eyes were still rendering as a black socket on Quest after b16b87f's
`disableStencilClip` pass. Two contributing causes addressed here:

1. Lid mesh covers the pupil stack at the parked-open rotation. The
   lid spherical cap is sized 20% wider than the socket disc and its
   parked rotation (-π/2) sweeps the dome through the socket plane
   relying on the stencil mask to clip "everything outside the socket
   silhouette." With the stencil clip off, the lid's full geometry
   blocks the pupil / iris / catchlight stack from most viewpoints —
   not just a thin sliver at the bezel edge as the previous commit
   message implied. Hide both lids outright when the clip is
   disabled. The avatar loses blink animations but the face is
   legible; a shader-based lid clip (compute distance to socket
   centre in the fragment shader, discard outside) is the proper
   follow-up tracked in the Phase 4 polish list.

2. Defensive: also force `stencilFunc = AlwaysStencilFunc` on every
   material we disable. Three.js's docs say `stencilWrite = false`
   suppresses stencil-test-and-write entirely, but with no obvious
   reason for the previous fix to leave eyes blank, it's worth
   eliminating "stale stencil test state from an adjacent draw" as a
   variable. With Always + write-off the test cannot discard
   regardless of what the stencil buffer holds.

Tests + type-check + tsc emit + vite build: all green (953 tests
pass). On-Quest expectation: pupils, iris colour, gaze tracking,
sparkle stars, and catchlights all visible. No blinks until a
shader-based lid clip lands. Standalone /orbit page is unaffected
(it leaves disableStencilClip off, so lids stay visible and stencil
clip stays active).

https://claude.ai/code/session_01LnxWuSAAHNPngjXpXEahHy
Signed-off-by: Claude <noreply@anthropic.com>
Reverts the previous commit's lid-hide. The user (correctly) wants
to keep the blink animation; "kill the lids" was a regression on
character expressiveness even though it fixed the visibility problem.

New approach: replace the stencil clip with a fragment-shader discard
that runs against the eye group's local XY frame. The lid spherical
cap stays in scene; fragments outside the socket silhouette are
discarded by the shader instead of by the GPU's stencil unit.

Implementation lives in a new `attachLidSocketClip(lid, pivot,
socketRadius)` helper invoked from `buildScene` when
`disableStencilClip` is on:

  1. Wraps the lid material's `onBeforeCompile` (preserving the body-
     vinyl gradient + procedural-noise patches) to inject:
       - `uLidToEyeGroup` (mat4) and `uSocketRadius` (float) uniforms
       - vertex-side: `vOrbitSocketXY = (uLidToEyeGroup * position).xy`
       - fragment-side: `if (length(vOrbitSocketXY) > uSocketRadius)
         discard;` at the top of `main()`
     Also tags `customProgramCacheKey` so Three.js doesn't reuse a
     non-clipped compile from another instance.

  2. Sets `lid.onBeforeRender` to recompose `pivot.matrix * lid.matrix`
     into the shared material's uniform right before each draw. The
     same material is reused across upper and lower lids of an eye —
     Three.js uploads uniforms after `onBeforeRender` and before each
     draw, so a per-mesh write takes effect for that mesh's draw with
     no cross-talk. One scratch Matrix4 captured in the closure
     keeps allocations off the per-frame path.

The eye-group local frame is the right frame: the socket lives at
its origin, the pivot has constant local position there, and `lid →
eyeGroup` collapses to `pivot.matrix * lid.matrix` regardless of the
avatar's world transform. So we don't need an inverse-world-matrix
uniform; the per-frame matrix multiply is two constant translations
plus one rotation.

Combined with the existing `disableStencilClip` traverse (turns off
stencilWrite + stencilFunc on every eye material), the result is:

  - Pupil-group materials render unconditionally — fine, they sit
    inside the socket and never extend past.
  - Lids render unconditionally too BUT the shader discards outside
    the socket disc — same visual as the stencil clip, no stencil
    buffer required.
  - Standalone /orbit page is unchanged: `disableStencilClip` stays
    off, lid materials don't get patched, stencil clip works as
    before.

Tests + type-check + tsc emit + vite build: all green (953 tests
pass). On-Quest expectation: pupils + iris + stars + catchlight
visible; blinks animate the lids without the cap visibly poking
past the bezel.

https://claude.ai/code/session_01LnxWuSAAHNPngjXpXEahHy
Signed-off-by: Claude <noreply@anthropic.com>
The shader-based lid clip (d358395) didn't fix the on-Quest "black
eyes" symptom — the user still reports the inner eye stack
(iris / pupil / catchlight / stars) is invisible while the bezel
torus + glass-dome glint render normally. Static analysis is
ambiguous about whether the disable-stencil traverse is reaching
the right materials, so this commit adds runtime instrumentation
that can confirm or deny it on the actual hardware:

  - Each material that opted out of stencil during the traverse is
    counted and named (Mesh type + material type + optional name).
  - A `console.info` line emits `disabledCount` + a 6-name sample
    after the traverse completes.
  - Each disabled material gets `mat.needsUpdate = true` so any
    cached compile is invalidated and the next frame's setMaterial
    rebuilds state from the post-disable values — defensive in case
    Three.js's WebXR render path was using a stale program with the
    stencil ops baked in.

Expected count on a healthy run: 9-11 (2 socket masks + 2 lid
materials + 5 pupil-group materials + 2 catchlight materials,
shared instances counted once across visits). If the count is much
lower or zero, the traverse isn't reaching the materials — that's
a different bug than what we've been chasing. If the count is
right but the eyes are still black, the issue is downstream of
stencil and we go look elsewhere.

Tests + type-check + build: green (953 tests pass).

https://claude.ai/code/session_01LnxWuSAAHNPngjXpXEahHy
Signed-off-by: Claude <noreply@anthropic.com>
Replaces the post-construction "disable stencil" traverse +
\`needsUpdate\` with a constructor-time skip flag. When OrbitAvatarNode
is built with \`disableStencilClip: true\`, the eye materials are
constructed without ever opting into stencil. No mutation, no cache
invalidation, no order-of-operation dependency on Three.js's program
caching — the materials never had stencil to begin with.

Threaded through every material constructor that previously called
\`applyPupilStencilClip\` or set stencil flags directly:

  - createPupilMaterials(palette, skipStencilClip = false) — gates
    the five applyPupilStencilClip calls on the iris / iris glow /
    pupil field / pupil dot / star materials.
  - createLidMaterial(palette, stencilRef, skipStencilClip = false)
    — the per-eye lid bundle skips the EqualStencilFunc setup.
  - createCatchlightMaterial(opacity, skipStencilClip = false) —
    drops the trailing applyPupilStencilClip call.
  - createSocketMaskMaterial(stencilRef, skipStencilWrite = false) —
    the mask itself becomes a true no-op; still invisible
    (colorWrite + depthWrite = false), still depth-test-disabled,
    just no longer writing stencil. Cheaper than the original mask
    and harmless when nothing reads stencil downstream.
  - buildPairedEye gains the same flag and forwards it to the two
    eye-internal constructors.

buildScene captures \`skipStencilClip = options.disableStencilClip ===
true\` once and passes it through. The disableStencilClip block is
now reduced to one job — calling \`attachLidSocketClip\` so the lid
spherical cap still gets clipped to the socket silhouette via the
fragment-shader discard from d358395 (which doesn't depend on
stencil being on or off).

Why this matters for the on-Quest "black eyes": every commit since
b16b87f has tried to fix the eye visibility by mutating live
materials after they'd already been associated with Three.js
programs. If Three.js's WebXR render path was holding any state
derived from the original (stencil-enabled) compile — even
indirectly — the post-hoc \`stencilWrite = false\` could've been
ignored. Constructing clean from the start removes that variable
entirely. The materials don't have stencil flags, can't have
stencil flags, and Three.js has nothing to cache that needs
invalidating.

Tests + type-check + tsc emit + vite build: green (953 tests pass).
Standalone /orbit page is unaffected — it leaves disableStencilClip
unset, the flags default to false, and material construction is
byte-for-byte identical to before.

https://claude.ai/code/session_01LnxWuSAAHNPngjXpXEahHy
Signed-off-by: Claude <noreply@anthropic.com>
User reports AR transition no longer completes loading on Quest
after 708ac81. Cause: the per-frame catchlight uniform write in
updateCharacter unconditionally accessed `.uniforms.uOpacity` —
that's a ShaderMaterial property, but 708ac81 swapped the
catchlight to a fresh MeshBasicMaterial in embedded mode, which
has no `.uniforms`. The TypeScript `as ShaderMaterial` cast is
purely a type assertion at compile time; at runtime
`MeshBasicMaterial.uniforms` is `undefined` and `.uOpacity` throws
a TypeError every frame.

Three.js doesn't crash on the first frame error — it logs and
keeps trying — but the loading-scene state machine appears to wait
on a clean update tick to advance from "loading" → "ready," so
repeated update exceptions stall the transition indefinitely.

Fix: optional-chain the uniforms access. When the catchlight
material is the standalone ShaderMaterial, the write proceeds; when
it's the embedded MeshBasicMaterial, the write is skipped (the
embedded path already has a static catchlight at fixed opacity, no
animation needed). One-line change.

The other per-frame shared-material writes (irisMat.color,
pupilFieldUniforms.uOpacity, etc.) don't throw because they target
the SHARED material/uniforms struct that still exists; the embedded
path replaced the meshes' material to a fresh per-rig instance, so
the writes go to the unused shared instance — no-op, no exception.

Tests + type-check + tsc emit + vite build: green (953 tests).

https://claude.ai/code/session_01LnxWuSAAHNPngjXpXEahHy
Signed-off-by: Claude <noreply@anthropic.com>
Two on-Quest fixes after b46b101 unstuck AR loading:

1. Re-enable depth testing on the fresh per-rig pupil materials.
   The original depthTest:false workaround was added when we thought
   depth-occlusion by the body was causing the "black eyes" symptom.
   Turned out the real culprit was shared-material-mutation under
   Quest's WebXR, which the per-rig fresh-material replacement
   already fixed. With depth testing back on, the iris / pupil
   field / pupil dot / catchlight / stars get correctly occluded
   by the planet when Orbit passes behind it — eliminating the
   "see eyes through the planet from the far side" artifact the
   user reported. depthWrite stays off so the pupil-stack draws
   don't conflict with each other within the transparent pass.

2. Drop the `attachLidSocketClip` shader-patch attempt. The patch
   was wrapping the lid material's onBeforeCompile to inject a
   fragment-shader discard against the socket silhouette, but
   on-Quest the lids were missing entirely after it landed. Without
   DevTools the failure mode (shader compile error vs.
   over-aggressive discard) can't be distinguished, and the
   diagnostic budget for this commit is exhausted. Removing the
   patch lets the lids render with their original body-vinyl
   material — the spherical cap can sweep slightly past the bezel
   rim at the parked-open rotation, but the bezel torus + glass
   dome cover most of the over-extension visually.

Tracked as Phase 4 polish: a geometry-level fix that rebuilds the
lid as a partial cap shape that physically cannot extend past
EYE_PAIR_DISC_RADIUS would solve both the over-extension and the
shader-patch fragility in one pass.

Tests + type-check + tsc emit + vite build: green (953 tests).
Standalone /orbit page is unaffected.

https://claude.ai/code/session_01LnxWuSAAHNPngjXpXEahHy
Signed-off-by: Claude <noreply@anthropic.com>
User reports no eyelids visible, but ALSO that the catchlight
occasionally vanishes in the upper-right of the eye. The
"catchlight vanishing" pattern is exactly what an opaque lid
sweeping over that quadrant during a blink would produce — which
implies the lid IS rendering and IS occluding things, but its
material (the body's vinyl-gradient MeshStandardMaterial)
visually blends with the surrounding body skin so the lid doesn't
read as a distinct element.

To confirm or refute: temporarily replace the lid material with a
bright magenta-pink MeshBasicMaterial in embedded mode. If lids
appear in that colour, the diagnosis is confirmed and the
follow-up commit restores the body palette colour. If they're
still invisible, the lid mesh is being dropped at a deeper level
(material-mutation, transform issue, draw order, etc.) and we go
look there next.

Both upper + lower lids per eye get the same instance — a single
fresh MeshBasicMaterial reused across the two meshes. Default
depth test + depth write so lid occlusion behaves the same as the
original body material.

Tests + type-check + tsc emit + vite build: green (953 tests).

https://claude.ai/code/session_01LnxWuSAAHNPngjXpXEahHy
Signed-off-by: Claude <noreply@anthropic.com>
Magenta lid diag (4902c3e) was still invisible on Quest. Adding two
defensive overrides to rule out two more candidate causes:

  - side = THREE.DoubleSide. The lid is a partial spherical cap.
    At the parked-open rotation (-π/2 around X), the cap's convex
    face may end up pointing away from the camera; the default
    FrontSide culling would then drop every fragment of the
    camera-facing concave side. DoubleSide renders both sides so
    the cap is visible regardless of orientation.

  - frustumCulled = false on each lid mesh. The cap's bounding
    sphere is computed once at geometry construction; if the
    per-frame pivot rotation pushes the world bounding sphere out
    of the WebXR per-eye visible set in some edge case, the lid
    gets dropped. frustumCulled = false sidesteps the check.

If pink lids show up after this lands → one of these was the cause.
Then a follow-up commit restores the body-palette colour and we're
done with this diagnostic chain.

If lids are STILL missing → neither the side nor the frustum check
is to blame, and the lid mesh is being dropped at a deeper level
(material-swap not propagating, transform corrupted, etc). Next
move would be checking the lid mesh's worldMatrix on Quest via a
visible-output-only side-channel (e.g. mounting an additional
marker that follows the lid's world position).

Tests + type-check + tsc emit + vite build: green (953 tests).

https://claude.ai/code/session_01LnxWuSAAHNPngjXpXEahHy
Signed-off-by: Claude <noreply@anthropic.com>
After all the lid-material overrides (bright pink + DoubleSide +
frustumCulled:false) STILL produce no visible lids on Quest, the
lid mesh is being dropped at a deeper level than material settings.
Two suspects remain:

  1. The upperLidPivot transform chain (pivot rotation -π/2 + lid
     mesh position offset under the eye_group's Y-scale of 1.18)
     produces some matrix-state quirk that drops the mesh in
     WebXR's render path.
  2. SphereGeometry partial-cap geometry itself has a problem in
     this rendering context.

Test: add a fresh sphere — same SphereGeometry partial-cap
parameters as the real lid — parented DIRECTLY to head, bypassing
both the upperLidPivot's rotation and the eye_group's scale.
Positioned at the lid pivot's expected eye-group-local position
relative to head, in cyan to differentiate from the magenta lid
diag. frustumCulled:false + DoubleSide + ORBIT_LAYER.

Three outcomes when the user runs this on Quest:

  - Cyan dome visible at each eye location (NOT the lids
    themselves) → the pivot rotation chain is the culprit; the
    real lids inherit a transform that breaks them in WebXR.
  - Cyan + magenta both visible → I missed something else; both
    rendering means the pipeline does work.
  - Neither cyan nor magenta visible → SphereGeometry has a
    fundamental issue in this render path; fall back to using
    flat CircleGeometry approximations for lids.

Tests + type-check + tsc emit + vite build: green (953 tests).

https://claude.ai/code/session_01LnxWuSAAHNPngjXpXEahHy
Signed-off-by: Claude <noreply@anthropic.com>
Diagnostic outcome from a06ec50: the bypass-pivot cyan domes
parented directly to `head` render correctly on Quest, but the
magenta-tinted lid meshes parented through the
eye_group → upperLidPivot → upperLid chain still don't.

Sphere geometry isn't at fault. The pivot rotation by itself can't
be the issue (the `head` itself is rotated per-frame and renders
fine). The remaining suspect is the non-uniform Y scale on the
eye_group: `group.scale.set(1, EYE_SHAPE_Y_SCALE, 1)` with
EYE_SHAPE_Y_SCALE = 1.18. Combining a non-uniform parent scale
with a 90° child rotation produces a non-orthonormal world matrix —
documented as unsafe for various Three.js operations
(`Object3D.lookAt` explicitly forbids it) and a known cause of
edge cases in face-culling and bounding-sphere computation.

Test: zero out the non-uniform scale in embedded mode by setting
`rig.group.scale.set(1, 1, 1)` after the avatar is built. Trade-
off: eyes lose the slight neotenous Y stretch, look more
circular than oval. If lids appear after this, the
non-uniform-scale + rotation interaction is the bug. We can then
move the eye-shape stretch elsewhere (e.g. compose it directly
into pupil-group meshes' geometry/scale, sidestepping the parent
scale path entirely).

If lids STILL don't appear, the bug is somewhere else in the
chain and we keep digging.

Tests + type-check + tsc emit + vite build: green (953 tests).

https://claude.ai/code/session_01LnxWuSAAHNPngjXpXEahHy
Signed-off-by: Claude <noreply@anthropic.com>
e70bec3's uniform-scale theory didn't pan out — magenta lids still
not rendering even with the eye_group's Y-scale flattened. The
remaining differences between the working cyan bypass (parented
directly to head) and the failing real lid (parented through
upperLidPivot under the eye_group) are now narrowed to:

  - The lid is a child of upperLidPivot (Object3D), not head/Group
  - The lid's local position has a non-zero Y offset from pivot
  - The lid still inherits eye_group's per-frame `position` write
    (none) and head's per-frame rotation/sway

Final diagnostic: reparent the lid meshes directly to `head`,
clear their position to the cyan bypass position with identity
rotation/scale. If THIS renders, the upperLidPivot itself (or
something specific to being its child) is the cause. If it
doesn't, the lid mesh is being uniquely targeted by something
else in the rendering path that I haven't accounted for.

Same setup as cyan bypass — just using rig.upperLid /
rig.lowerLid (which carry the magenta DoubleSide diag material
from earlier) instead of a fresh mesh.

Loses the blink animation entirely for this diagnostic; tracked
as Phase 4 polish to wire it back up once we know the cause.

Tests + type-check + tsc emit + vite build: green (953 tests).

https://claude.ai/code/session_01LnxWuSAAHNPngjXpXEahHy
Signed-off-by: Claude <noreply@anthropic.com>
Lid-rendering bug localised across a06ec50e70bec31f44c32:
SphereGeometry partial-cap renders fine when parented directly to
\`head\`, but disappears when parented through
\`eye_group → upperLidPivot\`. Doesn't matter whether the eye_group's
Y-scale is uniform; doesn't matter what frustum / face-culling /
material settings the lid mesh has. Something specific to that
hierarchy depth + the -π/2 pivot rotation breaks Quest's WebGL
rendering of the mesh. Standalone /orbit handles it fine.

Permanent workaround: reparent the lid meshes directly to \`head\`
and compute their head-local pose each frame via \`onBeforeRender\`,
reading the orphaned pivot's rotation as the animation source.
updateCharacter keeps writing \`upperLidPivot.rotation.x\` unchanged
— the pivot is now just a state holder, not a parent transform.
The lid's pose is composed analytically:

  lid.position.y = offsetY + pivotY + LID_MESH_Y_OFFSET * cos(angle)
  lid.position.z = LID_PIVOT_Z + LID_MESH_Y_OFFSET * sin(angle)
  lid.position.x = offsetX
  lid.rotation.x = angle

This reproduces the original parent-pivot transform exactly: the
hinge axis sits at \`(0, pivotY, LID_PIVOT_Z)\` in eye_group local,
the lid mesh swings around it, and the rendered position +
orientation match the standalone path frame-for-frame. Blinks
animate correctly; the cap is visible at IDLE state.

Material restored to a fresh MeshBasicMaterial with the palette's
"warm" color (cyan palette → #f7c9d6 = soft pink). Same approach
as the iris / pupil / catchlight per-rig replacements (avoids the
shared body-vinyl material that has its own WebXR rendering
issues). Visual: slightly different from the standalone vinyl
gradient, but the lid reads as a distinct skin-tone arc above
the socket.

Diagnostic cyan-bypass meshes removed — they served their
purpose, no longer needed. Eye_group's non-uniform Y-scale stays
flattened (1,1,1) for now; restoring the 1.18 stretch is Phase 4
polish.

Tests + type-check + tsc emit + vite build: green (953 tests).

https://claude.ai/code/session_01LnxWuSAAHNPngjXpXEahHy
Signed-off-by: Claude <noreply@anthropic.com>
Last commit (91b593d) wrote \`lid.rotation.x = angle\` from
onBeforeRender — and the lid disappeared again. The 1f44c32 diag
that DID render the lid used \`lid.quaternion.identity()\` (no
rotation present). The single differentiator between visible-lid
and invisible-lid runs is "rotation present" vs. "rotation
absent."

Switch to explicit quaternion via \`setFromAxisAngle\`. Three.js's
\`Object3D.rotation\` is a Euler proxy that lazily updates the
quaternion via an \`_onChangeCallback\`, and there's documented
edge-case behaviour around the ±π/2 gimbal-lock boundary in
several Euler orders. Bypassing the Euler proxy with a direct
quaternion write rules that out as the cause:

  lid.quaternion.setFromAxisAngle((1,0,0), angle)

If lids render with this, the Euler proxy was the bug. If they
still don't, rotation itself (regardless of representation) is
breaking the lid mesh's render path on Quest, and we redesign the
lid as a flat half-disc that translates instead of rotates.

Tests + type-check + tsc emit + vite build: green (953 tests).

https://claude.ai/code/session_01LnxWuSAAHNPngjXpXEahHy
Signed-off-by: Claude <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.

2 participants