From 1fbd43bb0772e96a6a5cb72c032a9e923e534c24 Mon Sep 17 00:00:00 2001 From: AlexZ005 Date: Thu, 1 Oct 2026 19:54:24 +0300 Subject: [PATCH 1/4] [fix] vr grabs are per hand: two controllers hold two things (33 G4) - vrControls kept ONE grab slot. A second hand's grab overwrote it, so the first hand's piece stayed in its kinematic physics hold with nothing updating or releasing it: it hung in mid-air until a hand took it again (Towers report). - grabs[] is indexed by controller slot; each hand updates and releases its own, a released body is dynamic at once. Two hands on the SAME object still scale it in Edit (and still do nothing extra in Interact). - vrGrabbedHand reads 'both' while both hands hold; the two stick gates honour it. - vrGrabbedUuids(): the knock probe skips everything the hands hold. - an input-source rebind now RELEASES what the hands held instead of dropping the grab with the body still kinematic. - new suite vr-two-hand-grab (19, fake XR + a running rapier sim). CF: clearing the other hand's slot on grab (the old single-slot shape) -> 6 red (1.1, 1.2, 2.2, 2.3, 2.5 hold user/bodyType 2 = the frozen crate, 2.12). - vr-grab 7, vr-grips-by-mode 18, vr-source-rebind 5, knock-physics 77, vr-sleeve 29 green. svelte-check 333/47 unchanged. Co-Authored-By: Claude Opus 5.5 (1M context) --- src/components/Scene.svelte | 4 +- src/components/play/VRControls.svelte | 2 +- src/lib/vrControls.js | 78 ++++++++--- tests/e2e/vr-two-hand-grab.test.cjs | 192 ++++++++++++++++++++++++++ 4 files changed, 250 insertions(+), 26 deletions(-) create mode 100644 tests/e2e/vr-two-hand-grab.test.cjs diff --git a/src/components/Scene.svelte b/src/components/Scene.svelte index b47e1714..3d355368 100644 --- a/src/components/Scene.svelte +++ b/src/components/Scene.svelte @@ -56,7 +56,7 @@ // the annotation is TS syntax — a JSDoc @type cast is ignored here (the documented trap). let knifeFrom: number[] | null = null; import { peerScenes } from '$lib/peerScenes'; - import { initVRControls, updateVRControls, raycastMenu, radialStickSelection, raycastPanel, raycastPalette, raycastProps, raycastPrefabs, raycastKeyboard, raycastChat, raycastEdit, raycastSnap, raycastSettings, raycastApprove, placePrefabGhost, vrFaceTrigger, vrVertexTrigger, vrVertexGrabStart, vrVertexGrabEnd, beginStretchSliderDrag, endStretchSliderDrag, executeVRMenuAction, resetWorldRig, onInputSourcesChange, worldToContentPose, boxSelectStart, boxSelectEnd, boxSelectActive, applyVRFrameRate, shouldSendHands, onHandPinchStart, onHandPinchEnd, pinchMenuToggledAt, firePingIfArmed, vrModuleTriggerStart, vrModuleTriggerEnd, vrModuleSelectSwallowed, handSnapshot, vrGrabbedUuid, hapticKnock, hapticPulse, onVRSessionStart } from '$lib/vrControls'; + import { initVRControls, updateVRControls, raycastMenu, radialStickSelection, raycastPanel, raycastPalette, raycastProps, raycastPrefabs, raycastKeyboard, raycastChat, raycastEdit, raycastSnap, raycastSettings, raycastApprove, placePrefabGhost, vrFaceTrigger, vrVertexTrigger, vrVertexGrabStart, vrVertexGrabEnd, beginStretchSliderDrag, endStretchSliderDrag, executeVRMenuAction, resetWorldRig, onInputSourcesChange, worldToContentPose, boxSelectStart, boxSelectEnd, boxSelectActive, applyVRFrameRate, shouldSendHands, onHandPinchStart, onHandPinchEnd, pinchMenuToggledAt, firePingIfArmed, vrModuleTriggerStart, vrModuleTriggerEnd, vrModuleSelectSwallowed, handSnapshot, vrGrabbedUuids, hapticKnock, hapticPulse, onVRSessionStart } from '$lib/vrControls'; // 30b (vr-play): the game in your hands — hover/press haptics (P1), the sweep (P4) import { startVrGameInput, stopVrGameInput } from '$lib/vrGameInput'; import { gameFeelActive } from '$lib/gameFeel'; @@ -1433,7 +1433,7 @@ // this seam rather than an import (knock.js stays off vrControls' 3500 lines), // and the two "what am I holding" reads keep a probe off its own carried object. // A2: the hand that hit gets a buzz — LOCAL, the same seam shape as the hand poses - startKnock({ hands: handSnapshot, heldUuids: () => [carriedUuid(), vrGrabbedUuid()], haptic: hapticKnock }); + startKnock({ hands: handSnapshot, heldUuids: () => [carriedUuid(), ...vrGrabbedUuids()], haptic: hapticKnock }); startClap({ hands: handSnapshot }); // 31: the same hand seam // 30b: game feel in VR (a frame hook + a trigger hook through vrControls' registries) startVrGameInput(); diff --git a/src/components/play/VRControls.svelte b/src/components/play/VRControls.svelte index 4a190adb..915867f0 100644 --- a/src/components/play/VRControls.svelte +++ b/src/components/play/VRControls.svelte @@ -38,7 +38,7 @@ // 30b P3: INTERACT walks like a game (collision, gravity, step-up; no fly or teleport // unless the play block allows them) — a held object does not stop your feet if (tickVRInteractLocomotion(delta, session)) return; - if ($vrGrabbedHand === 'left') return; // a left-hand grab owns its stick (100) + if ($vrGrabbedHand === 'left' || $vrGrabbedHand === 'both') return; // a left-hand grab owns its stick (100) if (twoGripStretchActive()) return; // 186: both grips + sticks stretch, not move // D9: world pan/grab write reference-space offsets themselves, and the // mesh-edit gestures read the sticks for reel/scale — never also move diff --git a/src/lib/vrControls.js b/src/lib/vrControls.js index ac5ae97c..01fe6877 100644 --- a/src/lib/vrControls.js +++ b/src/lib/vrControls.js @@ -262,14 +262,27 @@ const raycaster = new THREE.Raycaster(); const tempMatrix = new THREE.Matrix4(); const tempVector = new THREE.Vector3(); -/** @type {any} single-hand grab: { object, index, prevPos, prevQuat, before } */ -let grab = null; +/** @type {any[]} per-hand grabs, indexed by controller SLOT: each is { object, index, prevPos, + * prevQuat, before, ... }. 33 G4: one per hand, so two hands hold two things at once — a single + * shared slot let the second hand's grab overwrite the first, and the first hand's piece sat + * in its kinematic hold in mid-air until it was grabbed again (the Towers report). */ +const grabs = [null, null]; /** @type {any} two-hand scale: { object, startDistance, startScale, before } */ let scaleGrab = null; /** 24-A A1: the object a VR hand is holding right now, or null — the knock probe * skips it (a hand knocking the crate it is carrying would fight its own hold). */ export function vrGrabbedUuid() { - return grab?.object?.uuid ?? scaleGrab?.object?.uuid ?? null; + return grabs[0]?.object?.uuid ?? grabs[1]?.object?.uuid ?? scaleGrab?.object?.uuid ?? null; +} +/** 33 G4: EVERY object a VR hand holds right now (both hands may each hold one) */ +export function vrGrabbedUuids() { + return [grabs[0]?.object?.uuid, grabs[1]?.object?.uuid, scaleGrab?.object?.uuid].filter(Boolean); +} +/** 33 G4: vrGrabbedHand = the hand holding something, or 'both' — the stick gates read it */ +function syncGrabbedHand() { + /** @type {any[]} */ + const hands = grabs.filter(Boolean).map((g) => renderer?.xr?.getController(g.index)?.userData?.handedness ?? null); + vrGrabbedHand.set(hands.length === 0 ? null : hands.length === 1 ? hands[0] : 'both'); } let lastMoveSent = 0; @@ -1687,7 +1700,12 @@ export function onInputSourcesChange() { vrEndHandleDrag(); vertexTriggerGrab = null; } - grab = null; + // a dropped grab RELEASES its body (it used to leave it in its kinematic hold) + for (let i = 0; i < grabs.length; i++) { + const held = grabs[i]; + grabs[i] = null; + if (held) endGrab(held.object, held.interact ? null : held.before); + } scaleGrab = null; worldGrab = null; worldPan = null; @@ -2188,8 +2206,10 @@ let worldPan = null; /** 30b P2: test/debug view of what the grips are doing right now */ export function vrGripDebug() { return { - grab: grab?.object?.uuid ?? null, - grabInteract: !!grab?.interact, + grab: (grabs[0] ?? grabs[1])?.object?.uuid ?? null, + grabInteract: !!(grabs[0] ?? grabs[1])?.interact, + grabs: grabs.map((g) => g?.object?.uuid ?? null), + scaleGrab: scaleGrab?.object?.uuid ?? null, worldGrab: !!worldGrab, worldPan: !!worldPan, emptyAir: [...emptyAirSqueeze] @@ -2862,7 +2882,8 @@ function onSqueezeStart(index) { } if (get(lockedObjects).find((lock) => lock[1] === object.uuid)) return; - if (grab && grab.object === object && grab.index !== index) { + const other = grabs[1 - index]; + if (other && other.object === object) { // 30b P2: a player's second hand does not resize the thing it is holding if (mode === 'interact') return; // second hand on the same object -> two-hand scale @@ -2871,12 +2892,18 @@ function onSqueezeStart(index) { object, startDistance: Math.max(distance, 0.05), startScale: object.scale.clone(), - before: grab.before + before: other.before }; - grab = null; - vrGrabbedHand.set(null); + grabs[1 - index] = null; + syncGrabbedHand(); return; } + // 33 G4: the OTHER hand keeps whatever it holds; only this hand's slot changes + const previous = grabs[index]; + if (previous) { + grabs[index] = null; + endGrab(previous.object, previous.interact ? null : previous.before); + } const interact = mode === 'interact'; suspendAnimation(object.uuid); // animated objects park at their base while held @@ -2894,7 +2921,7 @@ function onSqueezeStart(index) { const parentInv = object.parent.matrixWorld.clone().invert(); const pPos = cPos.clone().applyMatrix4(parentInv); const pQuat = parentQuat.clone().invert().multiply(cQuat); - grab = { + grabs[index] = { object, index, // 30b P2: a player's hand is RIGID (no gizmo-style move/rotate), and `interact` @@ -2909,7 +2936,7 @@ function onSqueezeStart(index) { prevQuat: cQuat, before: transformStateOf(object) }; - vrGrabbedHand.set(renderer.xr.getController(index)?.userData?.handedness ?? null); + syncGrabbedHand(); // 30b (C4): a grab lands with a `hit` (a gated no-op in Edit, like every pulse) hapticPattern('hit', renderer.xr.getController(index)?.userData?.handedness ?? undefined); // 30b P2: a player picking something up is not SELECTING it — no lock broadcast, no @@ -3040,7 +3067,8 @@ function onSqueezeEnd(index) { scaleGrab = null; return; } - if (grab && grab.index === index) { + const grab = grabs[index]; + if (grab) { // K2: a hook may consume the release (drop onto the sleeve = capture a // slot; the hook restores the object's pose + animation itself, so no // move commits — but the physics hold must still release) @@ -3055,14 +3083,14 @@ function onSqueezeEnd(index) { }); if (consumed) { import('./physics').then((m) => m.releaseBody(object.uuid)); - grab = null; - vrGrabbedHand.set(null); + grabs[index] = null; + syncGrabbedHand(); hapticPulse(0.4, 60); return; } + grabs[index] = null; endGrab(object, grab.interact ? null : grab.before); - grab = null; - vrGrabbedHand.set(null); + syncGrabbedHand(); hapticPulse(0.18, 24); } } @@ -3105,7 +3133,8 @@ export function grabStickAdjust({ length, scale, x, y }) { }; } -function updateGrab() { +/** @param {any} grab one hand's grab (33 G4: each hand updates its own) */ +function updateGrab(grab) { const controller = renderer.xr.getController(grab.index); const position = controller.getWorldPosition(new THREE.Vector3()); const quaternion = controller.getWorldQuaternion(new THREE.Quaternion()); @@ -3839,6 +3868,7 @@ export function updateVRControls() { !get(vrChatPanelOpen) && !get(vrKeyboardTarget) && get(vrGrabbedHand) !== 'right' && + get(vrGrabbedHand) !== 'both' && !vrNavigationSuppressed({ grips: true }) ) { updateTeleport(session); @@ -3997,7 +4027,7 @@ export function updateVRControls() { }); if (scaleGrab) updateScaleGrab(); - else if (grab) updateGrab(); + else for (const held of grabs) if (held) updateGrab(held); // window grab (111): the hold timer arms, then the grip drives the window if (windowGrabPending && Date.now() - windowGrabPending.startedAt >= HOLD_MS) @@ -4308,11 +4338,13 @@ function enterInteractVR() { worldGrab = null; worldPan = null; emptyAirSqueeze[0] = emptyAirSqueeze[1] = false; - if (grab && !grab.interact) { - endGrab(grab.object, grab.before); - grab = null; - vrGrabbedHand.set(null); + for (let i = 0; i < grabs.length; i++) { + const held = grabs[i]; + if (!held || held.interact) continue; + grabs[i] = null; + endGrab(held.object, held.before); } + syncGrabbedHand(); resetWorldRig(); // at sessionstart no XR frame exists yet (so no viewer pose to move FROM): the spawn // waits for the first frame that has one (updateVRControls) diff --git a/tests/e2e/vr-two-hand-grab.test.cjs b/tests/e2e/vr-two-hand-grab.test.cjs new file mode 100644 index 00000000..2c181e8b --- /dev/null +++ b/tests/e2e/vr-two-hand-grab.test.cjs @@ -0,0 +1,192 @@ +// 33 G4: TWO HANDS, TWO THINGS. The Quest report (Towers): "when I grab another box for some +// reason the other one stuck in space until I take it again (only then physics applied to +// it)". vrControls kept ONE grab slot: the second hand's grab overwrote the first, the first +// piece stayed in its kinematic physics hold, nothing ever updated or released it, and it hung +// in mid-air until a hand took it again. Grabs are per HAND now. +// +// Driven through the REAL per-frame path (Scene's useTask -> updateVRControls) with a fake XR +// session (fakeXR.cjs) and a running rapier simulation: two emulated controllers each hold a +// different dynamic crate, carry both, and release them one at a time. +const h = require('./helpers.cjs'); +const xr = require('./fakeXR.cjs'); + +const grips = (page) => page.evaluate(() => window.__stores.vrControls.vrGripDebug()); +const posOf = (page, uuid) => + page.evaluate((uuid) => { + let g; + window.__stores.objectsGroup.subscribe((x) => (g = x))(); + return g.getObjectByProperty('uuid', uuid).position.toArray(); + }, uuid); +const bodyOf = (page, uuid) => + page.evaluate((uuid) => { + const b = window.__stores.physics.physicsDebug().find((e) => e.uuid === uuid); + return b ? { hold: b.hold, bodyType: b.bodyType } : null; + }, uuid); +const grip = async (page, hand, down) => { + await xr.button(page, hand, 1, down); + await page.waitForTimeout(250); +}; + +h.run(async () => { + // GPU: software GL runs a few fps and the fixed-step accumulator then lags real time + const browser = await h.launch({ args: h.GPU_ARGS }); + const A = await h.setupPage(browser, 'A'); + await A.page.evaluate(() => window.__stores.physics.warmup().catch(() => {})); + await A.page.waitForTimeout(2000); + + // ---- the fixture: a floor and two dynamic crates side by side --------------------------- + const ids = await A.page.evaluate(async () => { + const s = window.__stores; + const get = (store) => { let v; store.subscribe((x) => (v = x))(); return v; }; + for (let i = 0; i < 3; i++) s.commandsHandler.sceneCommand('/create box'); + await new Promise((r) => setTimeout(r, 1200)); + const [floor, a, b] = get(s.objectsGroup).children.filter((c) => c.name === 'Box'); + floor.scale.set(20, 0.5, 20); + floor.position.set(0, -0.25, 0); + floor.userData.physics = { mode: 'static' }; + for (const [box, x] of [[a, 0.7], [b, -0.7]]) { + box.scale.setScalar(0.4); + box.position.set(x, 0.2, -1.5); + box.rotation.set(0, 0, 0); + box.userData.physics = { mode: 'dynamic', mass: 1 }; + } + for (const o of [floor, a, b]) o.updateMatrixWorld(true); + s.objectActions.deselectObject(); + s.isVRMode.set(true); + return { floor: floor.uuid, a: a.uuid, b: b.uuid }; + }); + await A.page.waitForTimeout(600); + await xr.install(A.page); + // park both hands high and aimed at nothing (straight up) + const park = async () => { + await xr.pose(A.page, 'left', [-0.3, 1.6, 0], { pitch: Math.PI / 2 }); + await xr.pose(A.page, 'right', [0.3, 1.6, 0], { pitch: Math.PI / 2 }); + }; + await park(); + + // ================================================================ 1. EDIT, no simulation + console.log('\n=== 1. Edit: each hand holds its own crate ==='); + { + const a0 = await posOf(A.page, ids.a); + const b0 = await posOf(A.page, ids.b); + await xr.pose(A.page, 'right', [a0[0], a0[1], 0]); // aims -Z through crate A + await grip(A.page, 'right', true); + await xr.pose(A.page, 'left', [b0[0], b0[1], 0]); // aims -Z through crate B + await grip(A.page, 'left', true); + const g = await grips(A.page); + h.check(g.grabs[1] === ids.a && g.grabs[0] === ids.b, `1.1 the right hand holds A, the left holds B (${JSON.stringify(g.grabs)})`); + // lift both hands half a metre: BOTH crates follow their own hand + await xr.pose(A.page, 'right', [a0[0] + 0.3, a0[1] + 0.5, 0]); + await xr.pose(A.page, 'left', [b0[0] - 0.3, b0[1] + 0.5, 0]); + await A.page.waitForTimeout(300); + const a1 = await posOf(A.page, ids.a); + const b1 = await posOf(A.page, ids.b); + h.check(Math.abs(a1[1] - (a0[1] + 0.5)) < 0.05 && Math.abs(a1[0] - (a0[0] + 0.3)) < 0.05, `1.2 crate A follows the right hand (y ${a0[1].toFixed(2)} -> ${a1[1].toFixed(2)})`); + h.check(Math.abs(b1[1] - (b0[1] + 0.5)) < 0.05 && Math.abs(b1[0] - (b0[0] - 0.3)) < 0.05, `1.3 crate B follows the left hand (y ${b0[1].toFixed(2)} -> ${b1[1].toFixed(2)})`); + await grip(A.page, 'right', false); + await grip(A.page, 'left', false); + const g2 = await grips(A.page); + h.check(g2.grabs.every((u) => u === null), `1.4 both releases end both grabs (${JSON.stringify(g2.grabs)})`); + // the editor's two-hand SCALE still happens when both hands take the SAME object + const a2 = await posOf(A.page, ids.a); + await xr.pose(A.page, 'right', [a2[0], a2[1], 0]); + await grip(A.page, 'right', true); + await xr.pose(A.page, 'left', [a2[0] - 0.05, a2[1], 0]); + await grip(A.page, 'left', true); + const g3 = await grips(A.page); + h.check(g3.scaleGrab === ids.a && g3.grabs.every((u) => u === null), `1.5 two hands on ONE object still scale it in Edit (${g3.scaleGrab === ids.a})`); + await grip(A.page, 'left', false); + await grip(A.page, 'right', false); + // put the crates back on the floor for the simulation + await A.page.evaluate((ids) => { + let g; + window.__stores.objectsGroup.subscribe((x) => (g = x))(); + for (const [u, x] of [[ids.a, 0.7], [ids.b, -0.7]]) { + const o = g.getObjectByProperty('uuid', u); + o.position.set(x, 0.2, -1.5); + o.rotation.set(0, 0, 0); + o.scale.setScalar(0.4); + o.updateMatrixWorld(true); + } + window.__stores.objectActions.deselectObject(); + }, ids); + await park(); + } + + // ================================================================ 2. INTERACT + physics + console.log('\n=== 2. Interact + a running simulation: two crates, two hands ==='); + await A.page.evaluate(() => window.__stores.objectActions.setEditorMode('interact')); + await A.page.evaluate(() => window.__stores.physics.toggleSimulation()); + await A.page.waitForTimeout(1500); + const running = await A.page.evaluate(() => window.__stores.physics.physicsDebug().length); + h.check(running >= 2, `2.0 (premise) the simulation is running (${running} dynamic bodies)`); + const a0 = await posOf(A.page, ids.a); + const b0 = await posOf(A.page, ids.b); + h.check(a0[1] < 0.4 && b0[1] < 0.4, `2.0b (premise) both crates rest on the floor (y ${a0[1].toFixed(2)}, ${b0[1].toFixed(2)})`); + + await xr.pose(A.page, 'right', [a0[0], a0[1], 0]); + await grip(A.page, 'right', true); + let ba = await bodyOf(A.page, ids.a); + h.check(ba?.hold === 'user', `2.1 the right hand holds crate A (hold ${ba?.hold})`); + await xr.pose(A.page, 'left', [b0[0], b0[1], 0]); + await grip(A.page, 'left', true); + let g = await grips(A.page); + h.check(g.grabs[1] === ids.a && g.grabs[0] === ids.b, `2.2 the second hand takes B and the first keeps A (${JSON.stringify(g.grabs)})`); + + // carry both up to 1.3 m: each crate rides ITS hand (the bug: A hung where B's grab began) + await xr.pose(A.page, 'right', [a0[0], 1.3, 0]); + await xr.pose(A.page, 'left', [b0[0], 1.3, 0]); + await A.page.waitForTimeout(400); + const a1 = await posOf(A.page, ids.a); + const b1 = await posOf(A.page, ids.b); + h.check(Math.abs(a1[1] - 1.3) < 0.08, `2.3 crate A rises with the right hand while the left holds B (y ${a1[1].toFixed(2)})`); + h.check(Math.abs(b1[1] - 1.3) < 0.08, `2.4 crate B rises with the left hand (y ${b1[1].toFixed(2)})`); + + // release A only: it is a dynamic body AT ONCE and falls; B stays in the left hand + await xr.button(A.page, 'right', 1, false); + await A.page.waitForTimeout(120); + ba = await bodyOf(A.page, ids.a); + let bb = await bodyOf(A.page, ids.b); + h.check(ba?.hold === null && ba?.bodyType === 0, `2.5 released crate A gets physics immediately (hold ${ba?.hold}, bodyType ${ba?.bodyType})`); + h.check(bb?.hold === 'user', `2.6 crate B is still held by the left hand (hold ${bb?.hold})`); + await A.page.waitForTimeout(900); + const a2 = await posOf(A.page, ids.a); + const b2 = await posOf(A.page, ids.b); + h.check(a2[1] < 0.6, `2.7 crate A fell (y ${a1[1].toFixed(2)} -> ${a2[1].toFixed(2)})`); + h.check(Math.abs(b2[1] - 1.3) < 0.08, `2.8 crate B did not fall while held (y ${b2[1].toFixed(2)})`); + // the left hand still carries B + await xr.pose(A.page, 'left', [b0[0] - 0.4, 1.3, 0]); + await A.page.waitForTimeout(300); + const b3 = await posOf(A.page, ids.b); + h.check(Math.abs(b3[0] - (b0[0] - 0.4)) < 0.08, `2.9 crate B still follows the left hand (x ${b3[0].toFixed(2)})`); + + // release B: it falls too + await xr.button(A.page, 'left', 1, false); + await A.page.waitForTimeout(1000); + bb = await bodyOf(A.page, ids.b); + const b4 = await posOf(A.page, ids.b); + h.check(bb?.hold === null && b4[1] < 0.6, `2.10 released crate B falls (hold ${bb?.hold}, y ${b4[1].toFixed(2)})`); + g = await grips(A.page); + h.check(g.grabs.every((u) => u === null), `2.11 no hand holds anything (${JSON.stringify(g.grabs)})`); + + // a re-grab in the SAME hand of another crate releases the first (a hand holds one thing) + const a5 = await posOf(A.page, ids.a); + const b5 = await posOf(A.page, ids.b); + await xr.pose(A.page, 'right', [a5[0], a5[1], 0]); + await grip(A.page, 'right', true); + await xr.pose(A.page, 'right', [a5[0], 1.2, 0]); + await A.page.waitForTimeout(300); + // the knock probe skips EVERYTHING the hands hold (both of them) + await xr.pose(A.page, 'left', [b5[0], b5[1], 0]); + await grip(A.page, 'left', true); + const held = await A.page.evaluate(() => window.__stores.vrControls.vrGrabbedUuids()); + h.check(held.includes(ids.a) && held.includes(ids.b), `2.12 vrGrabbedUuids names both held crates (${held.length})`); + await grip(A.page, 'left', false); + await grip(A.page, 'right', false); + + await A.page.evaluate(() => window.__stores.physics.stopSimulation()); + await A.page.evaluate(() => window.__stores.objectActions.setEditorMode('edit')); + await xr.uninstall(A.page); + await A.page.evaluate(() => window.__stores.isVRMode.set(false)); + await h.finish(browser); +}); From deb41cff93878dcb1a6b5de8077a6514feb5ca08 Mon Sep 17 00:00:00 2001 From: AlexZ005 Date: Thu, 1 Oct 2026 20:46:38 +0300 Subject: [PATCH 2/4] [fix] vr game board: the footer fits the board, labels fit their buttons; menu layout audit (33 G5) - The report: "Menu" and "Top strip" too close / outside the menu's border. Three 240-stage-px footer buttons need 760 px while a cropped game menu board is 720 wide, so the row hung past BOTH edges (Menu at x -30, Top strip ending at 1110 on a 1080 px canvas) in all seven games. - footerLayout(W, k, n): natural width when it fits, else the buttons share the board inside a side margin. fitLabel(): shrink a button label to fit, ellipsize only as a last resort and report it (collectOverflow -> vrGamePanelDebug().overflow). Used by the footer, the wrist card, the shell pages and a game screen's own buttons on the board. - new suite game-menu-layout: the seven games (real .tpscene + real zips) x desktop HUD in every game state (inside the view, not half off a panel, 4 px apart), the pause menu's pages (inside the card, no overlap, no sideways scroll), the VR board per state + the shell pages on the board + the wrist (inside with a 10 px margin, 8 px apart, labels fit). BEFORE (HEAD's panel code): 7 red, exactly the reported footer (log + shots lanes-30/after-33/33-games/layout-before). AFTER run: see the handover. - svelte-check 333/47 unchanged. Co-Authored-By: Claude Opus 5.5 (1M context) --- src/lib/shellPanelDraw.js | 52 +++++- src/lib/vrGamePanel.js | 68 +++++--- tests/e2e/game-menu-layout.test.cjs | 243 ++++++++++++++++++++++++++++ 3 files changed, 338 insertions(+), 25 deletions(-) create mode 100644 tests/e2e/game-menu-layout.test.cjs diff --git a/src/lib/shellPanelDraw.js b/src/lib/shellPanelDraw.js index edc8fcce..b76abfee 100644 --- a/src/lib/shellPanelDraw.js +++ b/src/lib/shellPanelDraw.js @@ -10,6 +10,54 @@ /** The board's stage size while the menu is up, in stage pixels (the panel scales it). */ export const SHELL_STAGE = { x: 0, y: 0, w: 1100, h: 640 }; +/** 33 G5: the labels that did not fit their button while `collectOverflow` ran (a suite reads + * them; a label that has to be cut is a layout defect even when the cut keeps it legible) + * @type {string[] | null} */ +let overflowSink = null; + +/** + * Run a draw and return [its result, the labels it had to cut]. @template T + * @param {() => T} draw @returns {[T, string[]]} + */ +export function collectOverflow(draw) { + const outer = overflowSink; + /** @type {string[]} */ + const sink = []; + overflowSink = sink; + try { + return [draw(), sink]; + } finally { + overflowSink = outer; + } +} + +/** + * 33 G5: set a font that makes `text` FIT `maxW` canvas px — shrink the size down to `min`, + * and only then cut it with an ellipsis (and report the cut). Returns the text to draw. + * A button label drawn at a fixed size ran past its button on the narrow boards. + * @param {CanvasRenderingContext2D} g @param {string} text @param {number} maxW + * @param {string | number} weight @param {number} size px @param {number} [min] px + */ +export function fitLabel(g, text, maxW, weight, size, min = Math.max(10, size * 0.7)) { + let px = size; + const font = () => (g.font = `${weight} ${Math.round(px)}px system-ui, sans-serif`); + font(); + while (px > min && g.measureText(text).width > maxW) { + px -= Math.max(1, size * 0.04); + font(); + } + if (g.measureText(text).width <= maxW) return text; + overflowSink?.push(text); + let lo = 0; + let hi = text.length; + while (lo < hi) { + const mid = (lo + hi + 1) >> 1; + if (g.measureText(text.slice(0, mid) + '…').width <= maxW) lo = mid; + else hi = mid - 1; + } + return text.slice(0, lo).trimEnd() + '…'; +} + /** @param {CanvasRenderingContext2D} g @param {number} x @param {number} y @param {number} w @param {number} h @param {number} r */ function roundRect(g, x, y, w, h, r) { const rr = Math.max(0, Math.min(r, w / 2, h / 2)); @@ -56,8 +104,8 @@ export function drawShellPage(g, model, opts) { g.fillStyle = o.disabled ? 'rgba(243,244,246,0.5)' : '#f3f4f6'; g.textAlign = 'center'; g.textBaseline = 'middle'; - g.font = `600 ${Math.round(26 * k)}px system-ui, sans-serif`; - g.fillText(label, (x + w / 2) * k, (y + (o.sub ? h * 0.4 : h / 2)) * k); + const shown = fitLabel(g, label, (w - 20) * k, 600, 26 * k); + g.fillText(shown, (x + w / 2) * k, (y + (o.sub ? h * 0.4 : h / 2)) * k); if (o.sub) { g.font = `500 ${Math.round(18 * k)}px system-ui, sans-serif`; g.fillStyle = '#fbbf24'; diff --git a/src/lib/vrGamePanel.js b/src/lib/vrGamePanel.js index e6b62cc0..630f6025 100644 --- a/src/lib/vrGamePanel.js +++ b/src/lib/vrGamePanel.js @@ -58,7 +58,7 @@ import { toggleShellMenu } from './gameShell'; import { gameSettingValues } from './gameSettings'; -import { drawShellPage, SHELL_STAGE } from './shellPanelDraw'; +import { drawShellPage, SHELL_STAGE, fitLabel, collectOverflow } from './shellPanelDraw'; // 31 K3 G3: Show FPS in the headset (the strip + the wrist) import { fpsReading, fpsText, noteXrFrame } from './fpsMeter'; import { vignetteFrame } from './comfortVignette'; @@ -345,8 +345,11 @@ export function drawPanel(g, entry, runtime, opts = {}) { } } else if (el.kind === 'crosshair' || el.kind === 'minimap' || el.kind === 'debug' || el.kind === 'custom' || el.kind === 'damageflash') { // a crosshair, a plot, a debug pill and a module's own DOM have no VR form here + } else if (text && el.kind === 'button' && !el.wrap) { + // 33 G5: a button's label fits the button (the desktop's CSS box never overflows) + g.fillText(fitLabel(g, text, w - 10 * k, weight, size), tx, y + h / 2); } else if (text) { - // text, timer, button, panel, richtext and anything else that says something + // text, timer, panel, richtext and anything else that says something const lines = el.wrap || el.kind === 'richtext' ? wrapText(g, text.replace(/\[[^\]]*\]/g, ''), w - 8 * k) : [text]; const lh = size * 1.25; let yy = y + h / 2 - ((lines.length - 1) * lh) / 2; @@ -377,6 +380,23 @@ export function drawPanel(g, entry, runtime, opts = {}) { return hits; } +/** + * 33 G5: where the footer's n buttons go on a board W canvas px wide — "Menu" and "Top strip" + * ran OFF the board: three 240-stage-px buttons need 760 while a cropped game menu can be + * 720 wide, so the row hung past both edges. The buttons keep their natural width when it + * fits and share the board (inside a side margin) when it does not. Pure; exported. + * @param {number} W @param {number} k canvas px per stage px @param {number} n + * @returns {{x: number, w: number}[]} + */ +export function footerLayout(W, k, n) { + const margin = 32 * k; + const gap = 20 * k; + const natural = (n > 2 ? 240 : 300) * k; + const bw = Math.max(0, Math.min(natural, (W - 2 * margin - (n - 1) * gap) / n)); + const x0 = (W - (n * bw + (n - 1) * gap)) / 2; + return Array.from({ length: n }, (_, i) => ({ x: x0 + i * (bw + gap), w: bw })); +} + /** * The footer: OUR buttons, always there — 31 K3's Menu (while a game offers the pause * menu), Edit mode and Top strip. @param {CanvasRenderingContext2D} g @param {number} fy @@ -392,12 +412,10 @@ function drawFooter(g, fy, k, opts, hits) { { id: 'edit', label: 'Edit mode' }, { id: 'strip', label: opts.strip === false ? 'Top strip: off' : 'Top strip: on' } ]; - const bw = (buttons.length > 2 ? 240 : 300) * k; const bh = 60 * k; - const gap = 20 * k; - const x0 = (W - (buttons.length * bw + (buttons.length - 1) * gap)) / 2; + const slots = footerLayout(W, k, buttons.length); buttons.forEach((b, i) => { - const bx = x0 + i * (bw + gap); + const { x: bx, w: bw } = slots[i]; const by = fy + (FOOTER_H * k - bh) / 2; roundRect(g, bx, by, bw, bh, 12 * k); g.fillStyle = opts.hover === 'footer:' + b.id ? '#1e4a63' : b.id === 'edit' ? '#1f2937' : '#111827'; @@ -406,10 +424,9 @@ function drawFooter(g, fy, k, opts, hits) { g.strokeStyle = opts.hover === 'footer:' + b.id ? '#5fd0ff' : 'rgba(148,163,184,0.6)'; g.stroke(); g.fillStyle = '#f3f4f6'; - g.font = `600 ${26 * k}px system-ui, sans-serif`; g.textAlign = 'center'; g.textBaseline = 'middle'; - g.fillText(b.label, bx + bw / 2, by + bh / 2); + g.fillText(fitLabel(g, b.label, bw - 24 * k, 600, 26 * k), bx + bw / 2, by + bh / 2); hits.push({ id: 'footer:' + b.id, key: '', kind: 'footer', footer: b.id, x: bx, y: by, w: bw, h: bh }); }); } @@ -562,9 +579,8 @@ export function drawCard(g, lines, opts = {}) { g.strokeStyle = opts.hover === 'footer:' + b.id ? '#5fd0ff' : 'rgba(148,163,184,0.6)'; g.stroke(); g.fillStyle = '#f3f4f6'; - g.font = `600 ${labels.length > 2 ? 17 : 22}px system-ui, sans-serif`; g.textAlign = 'center'; - g.fillText(b.label, bx + bw / 2, by + (footer - 22) / 2); + g.fillText(fitLabel(g, b.label, bw - 12, 600, labels.length > 2 ? 18 : 22, 12), bx + bw / 2, by + (footer - 22) / 2); hits.push({ id: 'footer:' + b.id, key: '', kind: 'footer', footer: b.id, x: bx, y: by, w: bw, h: footer - 22 }); }); } @@ -575,7 +591,7 @@ export function drawCard(g, lines, opts = {}) { /** * @typedef {{name: string, mesh: THREE.Mesh, canvas: HTMLCanvasElement, g: CanvasRenderingContext2D, - * texture: THREE.CanvasTexture, w: number, h: number, hits: HitRect[], sig: string}} Surface + * texture: THREE.CanvasTexture, w: number, h: number, hits: HitRect[], sig: string, overflow?: string[]}} Surface */ /** @type {Record} */ @@ -726,9 +742,11 @@ export function vrGamePanelFrame(opts = {}) { if (sig !== board.sig) { board.sig = sig; resizeSurface(board, Math.round(crop.w * SCALE), Math.round((crop.h + FOOTER_H) * SCALE), crop.w * METRES_PER_PX); - board.hits = shellOpen - ? drawShellPanel(board.g, { hover: hover[0] ?? hover[1], strip }) - : drawPanel(board.g, entry, runtime, { hover: hover[0] ?? hover[1], strip, crop, menu: menuOffer }); + [board.hits, board.overflow] = collectOverflow(() => + shellOpen + ? drawShellPanel(board.g, { hover: hover[0] ?? hover[1], strip }) + : drawPanel(board.g, entry, runtime, { hover: hover[0] ?? hover[1], strip, crop, menu: menuOffer }) + ); board.texture.needsUpdate = true; } const headYaw = yawOf(head.quaternion); @@ -754,13 +772,15 @@ export function vrGamePanelFrame(opts = {}) { if (sig !== wrist.sig) { // a line count change re-sizes the card wrist.sig = sig; - wrist.hits = drawCard(wrist.g, lines.length ? lines : ['No score yet'], { - title: 'Game', - buttons: true, - strip, - hover: hover[0] ?? hover[1], - menu: menuOffer ? (shellOpen ? 'open' : 'available') : null - }); + [wrist.hits, wrist.overflow] = collectOverflow(() => + drawCard(wrist.g, lines.length ? lines : ['No score yet'], { + title: 'Game', + buttons: true, + strip, + hover: hover[0] ?? hover[1], + menu: menuOffer ? (shellOpen ? 'open' : 'available') : null + }) + ); wrist.texture.needsUpdate = true; } // up the forearm, facing up off it — clear of 30b-vr-modes' mode label, which sits on @@ -976,12 +996,14 @@ export function pokeFrame(index, tip) { return pressed; } -/** @returns {{presses: number, pokes: number, lastPress: string | null, edits: number, hover: (string | null)[], strip: boolean, hits: Record}} */ +/** @returns {{presses: number, pokes: number, lastPress: string | null, edits: number, hover: (string | null)[], strip: boolean, hits: Record, overflow: string[]}} */ export function vrGamePanelDebug() { /** @type {Record} */ const hits = {}; for (const [name, s] of Object.entries(surfaces)) hits[name] = s.hits.map((h) => ({ ...h })); - return { ...debug, hover: [...hover], strip: stripOn(), hits }; + // 33 G5: the labels the visible surfaces had to cut to fit (a layout defect) + const overflow = Object.values(surfaces).flatMap((s) => (s.mesh.visible ? (s.overflow ?? []).map((t) => s.name + ': ' + t) : [])); + return { ...debug, hover: [...hover], strip: stripOn(), hits, overflow }; } /** the surfaces' meshes (suites read visibility, poses and canvases) @param {string} name */ diff --git a/tests/e2e/game-menu-layout.test.cjs b/tests/e2e/game-menu-layout.test.cjs new file mode 100644 index 00000000..b9498d08 --- /dev/null +++ b/tests/e2e/game-menu-layout.test.cjs @@ -0,0 +1,243 @@ +// 33 G5: EVERY GAME MENU IS LAID OUT. The Quest report: "When game menu/options shows 'Menu' +// and 'Top strip' buttons are too close or out of menu table/borders (gui thing, fix +// placement). Ensure the rest game menus also placed correctly." +// +// An automated BOUNDS AUDIT over the seven Games-tab games, loaded from their real .tpscene +// files with their real module zips: +// · desktop — the game's own HUD screens in every game state (menu / playing / paused / +// over) and the pause menu's pages (main, settings, levels, how to play): every control +// inside its card / the viewport, no control sticking out of a HUD panel it sits on, and +// no two controls closer than a minimum gap; +// · VR (emulated) — the game panel's board for every state, the pause menu's pages on the +// board, and the wrist card: every pressable rect inside its canvas with a margin, no two +// closer than the gap, and no label wider than its button (the drawing code reports +// those itself, `vrGamePanelDebug().overflow`). +// Shots of every board and page land in EVIDENCE_DIR when set. +// +// Scenes: SHELL_SCENES_DIR=/scene.tpscene>, else the sibling scenes checkout. +// A game whose scene or zip is missing is SKIPPED (named), never failed. +const h = require('./helpers.cjs'); +const xr = require('./fakeXR.cjs'); +const fs = require('fs'); +const path = require('path'); + +const SCENES_REPO = [path.resolve(__dirname, '../../../theprototype.app-scenes'), path.resolve(__dirname, '../../../scenes')].find((p) => fs.existsSync(p)); +const sceneOf = (slug) => { + const dir = process.env.SHELL_SCENES_DIR; + const p = dir ? path.join(dir, slug, 'scene.tpscene') : SCENES_REPO && path.join(SCENES_REPO, 'games', slug, 'scene.tpscene'); + return p && fs.existsSync(p) ? p : null; +}; +const GAMES = [ + { slug: 'towers', modules: [] }, + { slug: 'stars-room', modules: [] }, + { slug: 'football', modules: ['football'] }, + { slug: 'jam-room', modules: ['music-lab', 'music-fx'] }, + { slug: 'dungeon-realms', modules: ['dungeon', 'dungeon-realms'] }, + { slug: 'untangle', modules: ['untangle'] }, + { slug: 'waves', modules: ['health', 'waves'] } +].filter((g) => !process.env.ONLY || process.env.ONLY.split(',').includes(g.slug)); +const EVIDENCE = process.env.EVIDENCE_DIR || ''; +const DESKTOP_GAP = 4; // px between two controls +const VR_MARGIN = 10; // canvas px from the board edge +const VR_GAP = 8; // canvas px between two pressables + +/** the separation of two rects (negative = they overlap on both axes) */ +const separation = (a, b) => Math.max(a.x - (b.x + b.w), b.x - (a.x + a.w), a.y - (b.y + b.h), b.y - (a.y + a.h)); +const inside = (r, box, margin = 0) => + r.x >= box.x + margin - 0.5 && r.y >= box.y + margin - 0.5 && r.x + r.w <= box.x + box.w - margin + 0.5 && r.y + r.h <= box.y + box.h - margin + 0.5; +const fmt = (r) => `${r.id ?? ''}@${Math.round(r.x)},${Math.round(r.y)} ${Math.round(r.w)}x${Math.round(r.h)}`; + +/** every bounds problem of a set of controls in a box: [] when the layout is clean */ +function audit(items, box, { margin = 0, gap = 0 } = {}) { + const out = []; + for (const r of items) if (!inside(r, box, margin)) out.push('outside: ' + fmt(r) + ' box ' + fmt(box)); + for (let i = 0; i < items.length; i++) + for (let j = i + 1; j < items.length; j++) { + const s = separation(items[i], items[j]); + if (s < gap) out.push(`too close (${s.toFixed(1)} px): ${fmt(items[i])} / ${fmt(items[j])}`); + } + return out; +} + +h.run(async () => { + const browser = await h.launch({ args: h.GPU_ARGS }); + const A = await h.setupPage(browser, 'A', { context: { viewport: { width: 1280, height: 720 } } }); + const page = A.page; + const installed = new Set(); + for (const id of [...new Set(GAMES.flatMap((g) => g.modules))]) if (await h.installModule(A, id)) installed.add(id); + await page.evaluate(() => window.__stores.modulesOpen.set(false)); + await page.waitForTimeout(300); + + /** the desktop HUD on show: pressables + the panels they sit on */ + const hudRects = () => + page.evaluate(() => { + const s = window.__stores; + const rect = (el) => { + const r = el.getBoundingClientRect(); + return { x: r.left, y: r.top, w: r.width, h: r.height }; + }; + const slots = [...document.querySelectorAll('#hud-layer .hud-slot')].filter((el) => el.getBoundingClientRect().width > 0); + const pressable = slots + .filter((el) => s.hudKinds.isInteractiveKind(el.dataset.hudKind) && getComputedStyle(el).visibility !== 'hidden') + .map((el) => ({ id: el.dataset.hudId, ...rect(el) })); + const panels = slots.filter((el) => el.dataset.hudKind === 'panel').map((el) => ({ id: el.dataset.hudId, ...rect(el) })); + return { pressable, panels, vw: innerWidth, vh: innerHeight }; + }); + + /** the pause card's controls (a SCROLLING card counts its scroll height) */ + const shellRects = () => + page.evaluate(() => { + const card = document.querySelector('#game-shell-menu .gs-card'); + if (!card) return null; + const c = card.getBoundingClientRect(); + const box = { x: c.left, y: c.top, w: c.width, h: Math.max(c.height, card.scrollHeight) }; + const controls = [...card.querySelectorAll('button, input')] + .filter((el) => el.getBoundingClientRect().width > 0) + .map((el) => { + const r = el.getBoundingClientRect(); + return { id: el.dataset.shellItem || el.dataset.shellLevel || el.getAttribute('aria-label') || el.className, x: r.left, y: r.top + card.scrollTop, w: r.width, h: r.height }; + }); + // a choice row's ‹ › and a range sit IN one row: audit gaps between ROWS, and + // within a row only that nothing overlaps + return { box, controls, overflowX: card.scrollWidth > card.clientWidth + 1 }; + }); + + /** draw the VR surfaces once and read their hits (+ a PNG of the board) */ + const vrFrame = (shot) => + page.evaluate(async (shot) => { + const s = window.__stores; + const T = s.THREE; + const k = s.gameKit.vrGamePanel; + const head = { position: new T.Vector3(0, 1.6, 0), quaternion: new T.Quaternion() }; + // the left wrist turned toward the face so the card is drawn + const left = { position: new T.Vector3(-0.15, 1.2, -0.35), quaternion: new T.Quaternion().setFromEuler(new T.Euler(-1.1, 0, 0)) }; + k.vrGamePanelFrame({ head, hands: [left, null], force: true }); + const d = k.vrGamePanelDebug(); + const board = k.vrGameSurface('vr-game-panel'); + const wrist = k.vrGameSurface('vr-game-wrist'); + return { + board: board?.mesh.visible ? { w: board.canvas.width, h: board.canvas.height, hits: d.hits['vr-game-panel'], png: shot ? board.canvas.toDataURL('image/png') : null } : null, + wrist: wrist ? { w: wrist.canvas.width, h: wrist.canvas.height, hits: d.hits['vr-game-wrist'], png: shot ? wrist.canvas.toDataURL('image/png') : null } : null, + overflow: d.overflow ?? [] + }; + }, shot); + const save = (name, dataUrl) => { + if (!EVIDENCE || !dataUrl) return; + fs.writeFileSync(path.join(EVIDENCE, name), Buffer.from(dataUrl.split(',')[1], 'base64')); + }; + const setState = (state) => page.evaluate((st) => window.__stores.gameState.setGameState(st), state); + + for (const game of GAMES) { + const file = sceneOf(game.slug); + const missing = game.modules.filter((m) => !installed.has(m)); + if (!file || missing.length) { + console.log('SKIP ' + game.slug + ': ' + (file ? 'no ' + missing.join(', ') + '.zip' : 'no scene.tpscene')); + continue; + } + console.log('\n=== ' + game.slug + ' ==='); + const bytes = Array.from(fs.readFileSync(file)); + await page.evaluate(async (arr) => { + const s = window.__stores; + s.templatesModalOpen.set(false); + const payload = await s.sessions.readSessionZip(new Uint8Array(arr).buffer); + await s.sessions.applySession(payload, { backup: false }); + }, bytes); + await page.waitForTimeout(3000); + + // ================================================= desktop: the game's own screens + await page.evaluate(() => window.__stores.playMode.requestPlay()); + await page.waitForTimeout(800); + const desk = []; + for (const state of ['menu', 'playing', 'paused', 'over']) { + await setState(state); + await page.waitForTimeout(400); + const r = await hudRects(); + const view = { x: 0, y: 0, w: r.vw, h: r.vh }; + for (const p of audit(r.pressable, view, { margin: 2, gap: DESKTOP_GAP })) desk.push(state + ': ' + p); + // a control that sits ON a panel must sit INSIDE it (no half-in, half-out) + for (const b of r.pressable) + for (const p of r.panels) { + const touching = separation(b, p) < 0; + if (touching && !inside(b, p)) desk.push(`${state}: ${b.id} sticks out of panel ${p.id}`); + } + if (EVIDENCE) await page.screenshot({ path: path.join(EVIDENCE, `hud-${game.slug}-${state}.png`) }); + } + h.check(desk.length === 0, `${game.slug} desktop HUD: every control inside the view and its panel, ${DESKTOP_GAP}px apart${desk.length ? ' — ' + desk.slice(0, 4).join(' | ') : ''}`); + + // ================================================= desktop: the pause menu's pages + await setState('playing'); + await page.evaluate(() => window.__stores.gameKit.gameShell.openShellMenu('main')); + await page.waitForTimeout(300); + const pages = await page.evaluate(() => { + const s = window.__stores; + let lv; + s.gameKit.gameShell.gameLevels.subscribe((v) => (lv = v))(); + return ['main', 'settings', ...(lv?.list?.length ? ['levels'] : []), 'help']; + }); + const shell = []; + for (const pg of pages) { + await page.evaluate((pg) => window.__stores.gameKit.gameShell.openShellMenu(pg), pg); + await page.waitForTimeout(250); + const r = await shellRects(); + if (!r) { + shell.push(pg + ': no card'); + continue; + } + if (r.overflowX) shell.push(pg + ': the card scrolls sideways'); + for (const p of audit(r.controls, r.box, { margin: 4, gap: pg === 'settings' ? 0 : DESKTOP_GAP })) shell.push(pg + ': ' + p); + if (EVIDENCE) await page.screenshot({ path: path.join(EVIDENCE, `shell-${game.slug}-${pg}.png`) }); + } + h.check(shell.length === 0, `${game.slug} desktop pause menu (${pages.join(', ')}): every control inside the card, none overlapping${shell.length ? ' — ' + shell.slice(0, 4).join(' | ') : ''}`); + await page.evaluate(() => window.__stores.gameKit.gameShell.closeShellMenu()); + await h.leavePlay(page); + await page.waitForTimeout(400); + + // ================================================= VR: the board, the wrist + await page.evaluate(() => { + window.__stores.isVRMode.set(true); + window.__stores.objectActions.setEditorMode('interact'); + }); + await xr.install(page); + await xr.setOn(page, true); + await page.waitForTimeout(300); + const vr = []; + let boards = 0; + for (const state of ['menu', 'playing', 'paused', 'over']) { + await setState(state); + await page.waitForTimeout(250); + const f = await vrFrame(!!EVIDENCE); + if (f.board) { + boards += 1; + for (const p of audit(f.board.hits, { x: 0, y: 0, w: f.board.w, h: f.board.h }, { margin: VR_MARGIN, gap: VR_GAP })) vr.push('board ' + state + ': ' + p); + save(`vr-board-${game.slug}-${state}.png`, f.board.png); + } + if (f.wrist?.hits?.length) for (const p of audit(f.wrist.hits, { x: 0, y: 0, w: f.wrist.w, h: f.wrist.h }, { margin: 6, gap: 6 })) vr.push('wrist ' + state + ': ' + p); + if (state === 'menu') save(`vr-wrist-${game.slug}.png`, f.wrist?.png); + for (const o of f.overflow) vr.push(`${state}: label wider than its button: ${o}`); + } + h.check(boards > 0, `${game.slug} VR: the board showed at least one menu screen (${boards})`); + await setState('playing'); + for (const pg of pages) { + await page.evaluate((pg) => window.__stores.gameKit.gameShell.openShellMenu(pg), pg); + await page.waitForTimeout(150); + const f = await vrFrame(!!EVIDENCE); + if (!f.board) { + vr.push('shell ' + pg + ': no board'); + continue; + } + for (const p of audit(f.board.hits, { x: 0, y: 0, w: f.board.w, h: f.board.h }, { margin: VR_MARGIN, gap: VR_GAP })) vr.push('shell ' + pg + ': ' + p); + for (const o of f.overflow) vr.push(`shell ${pg}: label wider than its button: ${o}`); + save(`vr-shell-${game.slug}-${pg}.png`, f.board.png); + } + await page.evaluate(() => window.__stores.gameKit.gameShell.closeShellMenu()); + h.check(vr.length === 0, `${game.slug} VR: every pressable inside its board/wrist with a ${VR_MARGIN}px margin, ${VR_GAP}px apart, labels fit${vr.length ? ' — ' + vr.slice(0, 5).join(' | ') : ''}`); + await xr.uninstall(page); + await page.evaluate(() => { + window.__stores.isVRMode.set(false); + window.__stores.objectActions.setEditorMode('edit'); + window.__stores.gameState.setGameState('menu'); + }); + await page.waitForTimeout(300); + } + await h.finish(browser); +}); From b7ec061dc6f6fd782bcbd7649329d44940161123 Mon Sep 17 00:00:00 2001 From: AlexZ005 Date: Fri, 2 Oct 2026 01:19:51 +0300 Subject: [PATCH 3/4] [fix] a phone starts lighter and is always governed; two O(edges) hot paths indexed (33 G1) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - The report: "Stars room is too heavy for mobile users, it lags". Measured on a phone profile (412x915 @2.625, isMobile + hasTouch, CPU x6, scripts/perf-games.cjs --phone): the governor sat at level 0 — a 100-call game is LIGHT by the desktop budgets and a light scene is never governed — so a phone drew 32 render passes a frame at 2.6 Mpx each (shadows, the authored AO, bloom, SMAA). - qualityGovernorCore: PHONE_START_LEVEL = 4 (shadows off, 72 % resolution, AO off; the look stays) + pure isPhoneLike (coarse pointer, no hover, short side <= 820 CSS px). qualityGovernor: decided once at load; a phone starts there in auto mode (no floor - a phone that holds its frames walks back up) and context() judges it heavy, the headset's rule, so slow frames step it further. Auto off and a game's Quality pin still win. - flowRuntime handleStamp/triggerStampFor read the existing edgesInto index instead of scanning every edge per call (24 latches x 3 handles x ~700 edges a tick); physics collectParams indexes nodes once (it was O(edges x nodes) on every flowGraphs change mid-sim). Both were top app frames in the Stars Room profile; gone after. - Stars Room, phone profile before -> after: calls 101 -> 47, render passes 32.2 -> 15.3 a frame, drawing buffer 2.60 -> 0.65 Mpx, p50 50 -> 33 ms, p99 200 -> 83 ms (lanes-30/after-33/33-games/perf-phone-{before,after}.md; Towers 83 -> 48 calls, p50 66.7 -> 33.3). A desktop GPU stands in for a phone's: a real phone reading is OWED. - scripts/perf-games.cjs --phone (+ renders/frame, Mpx (dpr), physical/transparent columns). - NEW suite quality-phone 10/0 (phone vs desktop page, the real Stars Room); CF (phone detection off) 5 red. vitest +3 (381). game-stars-room 72/0, logic-nodes 77/0. flow-physics-actions 8.5 is red IDENTICALLY on HEAD (A/B, both 35/2) - pre-existing. svelte-check 333/47. Co-Authored-By: Claude Opus 5.5 (1M context) --- scripts/perf-games.cjs | 43 ++++++++++---- src/lib/flowRuntime.js | 8 +-- src/lib/physics.js | 20 ++++--- src/lib/qualityGovernor.js | 37 +++++++++++- src/lib/qualityGovernorCore.js | 24 ++++++++ tests/e2e/quality-phone.test.cjs | 91 ++++++++++++++++++++++++++++++ tests/unit/qualityGovernor.test.js | 37 ++++++++++++ 7 files changed, 238 insertions(+), 22 deletions(-) create mode 100644 tests/e2e/quality-phone.test.cjs diff --git a/scripts/perf-games.cjs b/scripts/perf-games.cjs index b4d01ff0..cbaee51d 100644 --- a/scripts/perf-games.cjs +++ b/scripts/perf-games.cjs @@ -3,7 +3,13 @@ // Games-tab games, measured the same way every time so a before/after means something. // // node scripts/perf-games.cjs [--label baseline-1.17] [--only waves,football] [--vr] -// [--seconds 10] [--throttle 4] [--out ] +// [--seconds 10] [--throttle 4] [--out ] [--phone] +// +// 33 G1 `--phone`: a PHONE profile instead of the desktop one — a 412x915 viewport at +// deviceScaleFactor 2.625, isMobile + hasTouch (what reproduces a phone's page scale and +// `(pointer: coarse)`), and CPU x6 unless --throttle says otherwise. The GPU is still this +// machine's, so the fill-rate half of a phone shows up only as the PIXELS it would push: +// the table adds the drawing-buffer size and the post passes per frame. // // Per game, on a FRESH page against a running dev server (APP_URL, default this lane's // https://theprototype.app:5263/): @@ -46,7 +52,8 @@ const flag = (name) => argv.includes('--' + name); const LABEL = arg('label', 'run'); const SECONDS = Number(arg('seconds', '10')); -const THROTTLE = Number(arg('throttle', '4')); +const PHONE = flag('phone'); +const THROTTLE = Number(arg('throttle', PHONE ? '6' : '4')); const VR = flag('vr'); const PROFILE = flag('profile'); const OUT = arg('out', '/home/deck/.code/lanes-30/after-31/31-perf'); @@ -181,6 +188,16 @@ function readScene() { }); if (scene?.background?.isTexture) textures.add(scene.background); if (scene?.environment?.isTexture) textures.add(scene.environment); + // 33 G1: what a PHONE's GPU pays for — pixels per frame, and what shades them + let physical = 0; + let transparent = 0; + scene?.traverseVisible?.((o) => { + if (!o.isMesh) return; + const mats = Array.isArray(o.material) ? o.material : [o.material]; + if (mats.some((m) => m?.isMeshPhysicalMaterial)) physical++; + if (mats.some((m) => m?.transparent)) transparent++; + }); + const buffer = r.getDrawingBufferSize(new s.THREE.Vector2()); let bytes = 0; for (const t of textures) { const img = t.image; @@ -222,7 +239,10 @@ function readScene() { unculled, points, skinned, - pixelRatio: r.getPixelRatio(), + pixelRatio: Math.round(r.getPixelRatio() * 100) / 100, + bufferPx: Math.round((buffer.x * buffer.y) / 1000) / 1000, + physical, + transparent, heapMB: performance.memory ? Math.round((performance.memory.usedJSHeapSize / 1048576) * 10) / 10 : null, heapDeltaMB: performance.memory && rec.heap0 != null ? Math.round(((performance.memory.usedJSHeapSize - rec.heap0) / 1048576) * 10) / 10 : null, lodMeshes: lod ? lod.entries : null, @@ -238,7 +258,10 @@ async function probeGame(browser, game) { const missing = (game.modules || []).filter((m) => !zipFor(m.id)).map((m) => m.id); if (missing.length) return { slug: game.slug, skipped: 'no zip for ' + missing.join(', ') }; - const peer = await h.setupPage(browser, game.slug, { context: { viewport: { width: 1280, height: 720 } } }); + const context = PHONE + ? { viewport: { width: 412, height: 915 }, deviceScaleFactor: 2.625, isMobile: true, hasTouch: true } + : { viewport: { width: 1280, height: 720 } }; + const peer = await h.setupPage(browser, game.slug, { context }); const page = peer.page; try { for (const m of game.modules || []) await installZip(page, m.id, zipFor(m.id)); @@ -391,12 +414,12 @@ const over = (v, cap) => (v != null && v > cap ? ' ⚠' : ''); function table(rows) { const head = - '| game | calls/frame | tris/frame | geos | tex (MB) | lights (shadow) | cast meshes | meshes / inst / unculled | LOD (coarse) | quality | p50 ms | p95 ms | p99 ms | heap Δ MB | started |\n' + - '|---|---:|---:|---:|---:|---:|---:|---|---:|---:|---:|---:|---:|---:|---|'; + '| game | calls/frame | tris/frame | geos | tex (MB) | lights (shadow) | cast meshes | meshes / inst / unculled | LOD (coarse) | quality | renders/frame | Mpx (dpr) | physical / transparent | p50 ms | p95 ms | p99 ms | heap Δ MB | started |\n' + + '|---|---:|---:|---:|---:|---:|---:|---|---:|---:|---:|---:|---|---:|---:|---:|---:|---|'; const lines = rows.map((r) => r.skipped || r.error - ? `| ${r.slug} | ${r.skipped ? 'SKIP: ' + r.skipped : 'ERROR: ' + r.error} |||||||||||||| |` - : `| ${r.slug} | ${fmt(r.calls)}${over(r.calls, BUDGET.calls)} | ${fmt(r.triangles)}${over(r.triangles, BUDGET.triangles)} | ${fmt(r.geometries)} | ${fmt(r.textures)} (${fmt(r.textureMB, 1)}) | ${fmt(r.lights)}${over(r.lights, BUDGET.lights)} (${fmt(r.shadowLights)}) | ${fmt(r.castMeshes)} | ${fmt(r.meshes)} / ${fmt(r.instanced)} / ${fmt(r.unculled)} | ${fmt(r.lodMeshes)} (${fmt(r.lodCoarse)}) | ${fmt(r.quality)} | ${fmt(r.p50, 1)} | ${fmt(r.p95, 1)} | ${fmt(r.p99, 1)} | ${fmt(r.heapDeltaMB, 1)} | ${r.started}${r.state ? ' → ' + r.state : ''} |` + ? `| ${r.slug} | ${r.skipped ? 'SKIP: ' + r.skipped : 'ERROR: ' + r.error} ||||||||||||||||| |` + : `| ${r.slug} | ${fmt(r.calls)}${over(r.calls, BUDGET.calls)} | ${fmt(r.triangles)}${over(r.triangles, BUDGET.triangles)} | ${fmt(r.geometries)} | ${fmt(r.textures)} (${fmt(r.textureMB, 1)}) | ${fmt(r.lights)}${over(r.lights, BUDGET.lights)} (${fmt(r.shadowLights)}) | ${fmt(r.castMeshes)} | ${fmt(r.meshes)} / ${fmt(r.instanced)} / ${fmt(r.unculled)} | ${fmt(r.lodMeshes)} (${fmt(r.lodCoarse)}) | ${fmt(r.quality)} | ${fmt(r.rendersPerFrame, 1)} | ${fmt(r.bufferPx, 2)} (${fmt(r.pixelRatio, 2)}) | ${fmt(r.physical)} / ${fmt(r.transparent)} | ${fmt(r.p50, 1)} | ${fmt(r.p95, 1)} | ${fmt(r.p99, 1)} | ${fmt(r.heapDeltaMB, 1)} | ${r.started}${r.state ? ' → ' + r.state : ''} |` ); return head + '\n' + lines.join('\n'); } @@ -440,12 +463,12 @@ function table(rows) { seconds: SECONDS, cpuThrottle: THROTTLE, vr: VR, - viewport: '1280x720', + viewport: PHONE ? '412x915@2.625 (phone)' : '1280x720', budget: BUDGET }; const md = `## perf-games — ${LABEL}${VR ? ' (VR-emulated, walking)' : ''}\n\n` + - `${meta.at} · ${meta.app} · GPU: ${meta.gpu} · scenes ${meta.scenes} · ${SECONDS} s of Play at CPU x${THROTTLE} · 1280x720\n` + + `${meta.at} · ${meta.app} · GPU: ${meta.gpu} · scenes ${meta.scenes} · ${SECONDS} s of Play at CPU x${THROTTLE} · ${meta.viewport}\n` + `Quest budget: ≤ ${BUDGET.calls} calls, ≤ ${BUDGET.triangles / 1000}k tris, ≤ ${BUDGET.lights} lights (⚠ = over). calls/tris are per display frame, every render() pass summed (shadow pass included).\n\n` + table(rows) + '\n'; diff --git a/src/lib/flowRuntime.js b/src/lib/flowRuntime.js index 7d848674..0e36fe7b 100644 --- a/src/lib/flowRuntime.js +++ b/src/lib/flowRuntime.js @@ -1742,8 +1742,9 @@ function stampOfSource(edge, ctx, seen) { function handleStamp(node, handle, ctx, seen = new Set()) { if (!ctx?.triggers || !node) return null; let newest = null; - for (const edge of edges) { - if (edge.target !== node.id) continue; + // 33 G1: the indexed edges INTO the node — a scan of every edge per call was the Stars + // Room's top app frame on a phone profile (24 latches x 3 handles x ~700 edges a tick) + for (const edge of edgesInto(edges, node.id)) { if ((edge.targetHandle ?? null) !== handle) continue; const stamp = stampOfSource(edge, ctx, seen); if (typeof stamp === 'number' && (newest === null || stamp > newest)) newest = stamp; @@ -1881,8 +1882,7 @@ function updateDerivedPulses(ctx) { function triggerStampFor(nodeId, ctx) { if (!ctx?.triggers) return null; let newest = null; - for (const edge of edges) { - if (edge.target !== nodeId) continue; + for (const edge of edgesInto(edges, nodeId)) { if (edge.targetHandle && edge.targetHandle !== 'trigger') continue; // 21-E4: a Delay/Sequence source has NO entry in the trigger log - its stamp is // derived - so reading the log directly here left those nodes unable to drive diff --git a/src/lib/physics.js b/src/lib/physics.js index 14f0268b..9a25353b 100644 --- a/src/lib/physics.js +++ b/src/lib/physics.js @@ -210,6 +210,15 @@ function collectParams(group) { // H1: physics nodes live in ANY graph (scene or per-object documents) const nodes = allNodes(); const edges = allEdges(); + // 33 G1: indexed once per call — the nested finds below were O(edges x nodes), and this + // runs on EVERY flowGraphs change mid-sim (onGraphChange); measured as the second app + // frame of the Stars Room on a phone profile + /** @type {Map} */ + const byId = new Map(); + for (const n of nodes) if (!byId.has(n.id)) byId.set(n.id, n); + /** @type {Set} edge sources that reach an Object Selector */ + const toSelector = new Set(); + for (const e of edges) if (byId.get(e.target)?.type === 'objectselector') toSelector.add(e.source); /** apply one physics node's params onto an object's entry @param {any} source @param {string} uuid */ const applyPhysicsNode = (source, uuid) => { map[uuid] ??= {}; @@ -243,15 +252,15 @@ function collectParams(group) { if (source.data?.scale != null) map[uuid].colliderScale = source.data.scale; if (source.data?.sensor) map[uuid].sensor = true; const sourceEdge = edges.find((e) => e.target === source.id && e.targetHandle === 'source'); - const sourceNode = sourceEdge ? nodes.find((n) => n.id === sourceEdge.source) : null; + const sourceNode = sourceEdge ? byId.get(sourceEdge.source) : null; const sourceUuid = sourceNode?.type === 'objectselector' ? sourceNode.data?.selected : null; if (sourceUuid && sourceUuid !== '-None-') map[uuid].colliderSource = sourceUuid; } }; edges.forEach((edge) => { - const source = nodes.find((n) => n.id === edge.source); + const source = byId.get(edge.source); if (!source || !PHYSICS_TYPES.includes(source.type)) return; - const target = nodes.find((n) => n.id === edge.target); + const target = byId.get(edge.target); if (target?.type !== 'objectselector') return; const uuid = target.data?.selected; if (!uuid || uuid === '-None-') return; @@ -263,10 +272,7 @@ function collectParams(group) { if (!PHYSICS_TYPES.includes(source.type)) return; const graph = source.__graph; if (!graph || graph === SCENE_GRAPH) return; - const wired = edges.some( - (e) => e.source === source.id && nodes.find((n) => n.id === e.target)?.type === 'objectselector' - ); - if (!wired) applyPhysicsNode(source, graph); + if (!toSelector.has(source.id)) applyPhysicsNode(source, graph); }); return map; } diff --git a/src/lib/qualityGovernor.js b/src/lib/qualityGovernor.js index e4337e14..46f70177 100644 --- a/src/lib/qualityGovernor.js +++ b/src/lib/qualityGovernor.js @@ -17,7 +17,7 @@ import { import { renderPaused } from './overloadGuard'; import { sceneBatchOpen } from '../stores/sceneStore'; import { safeStorage } from './safeStorage'; -import { xrThresholds, XR_START_LEVEL, xrScaleAfter } from './qualityGovernorCore'; +import { xrThresholds, XR_START_LEVEL, xrScaleAfter, PHONE_START_LEVEL, isPhoneLike } from './qualityGovernorCore'; import { globalRenderer } from '../stores/sceneStore'; import { lodBias } from './lod'; import { lodBiasFor } from './lodCore'; @@ -89,6 +89,8 @@ let snoozedUntil = 0; let drawGapEngaged = false; /** @type {string} */ let lastReason = ''; +/** 33 G1: this device is a phone (decided once at load, see the bottom of the file) */ +let phone = false; function now() { return typeof performance !== 'undefined' ? performance.now() : Date.now(); @@ -132,6 +134,9 @@ function context() { // always worth governing — a missed headset frame is judder, not a slow chart, and the // light-scene exemption (26-G's ruling) exists for a desktop that is merely slow if (xr.active) return { metrics, profile: /** @type {'desktop'|'vr'} */ ('vr'), heavy: true }; + // 33 G1: a phone is fill-bound — a scene "light" by the desktop's call budget still misses + // frames there, so it is always worth governing (the headset's rule, desktop thresholds) + if (phone) return { metrics, profile: /** @type {'desktop'|'vr'} */ ('desktop'), heavy: true }; const profile = metrics?.profile === 'vr' ? 'vr' : 'desktop'; return { metrics, profile: /** @type {'desktop'|'vr'} */ (profile), heavy: isHeavy(metrics, profile, get(qualityBaseline)) }; } @@ -394,3 +399,33 @@ export function endXRQuality(r = get(globalRenderer)) { export function xrQualityDebug() { return { active: xr.active, hz: xr.hz, raised: xr.raised, minScale: xr.minScale, nextScale: xr.nextScale, floor: governor.floor(), thresholds: governor.thresholds(), level: governor.level() }; } + +// ---- 33 G1: A PHONE ------------------------------------------------------------------------ +// Decided once: a coarse pointer, no hover, a short screen side (isPhoneLike). Auto mode then +// starts at PHONE_START_LEVEL — shadows off, 72 % resolution, AO off — instead of drawing the +// first seconds of every scene at a phone's full pixel ratio and waiting to be told. With auto +// quality off nothing changes; a game's pinned Quality preset still wins (applyGameQuality). + +/** @returns {boolean} */ +function detectPhone() { + try { + if (typeof window === 'undefined' || typeof window.matchMedia !== 'function') return false; + return isPhoneLike({ + coarse: window.matchMedia('(pointer: coarse)').matches, + hover: window.matchMedia('(hover: hover)').matches, + minSide: Math.min(window.screen?.width ?? 0, window.screen?.height ?? 0) + }); + } catch { + return false; + } +} +phone = detectPhone(); +if (phone && get(autoQuality)) { + governor.setLevel(PHONE_START_LEVEL, now()); + publish(PHONE_START_LEVEL, 'phone: a lighter start'); +} + +/** 33 G1: is this device judged as a phone (for the suite and the chip) */ +export function phoneQuality() { + return phone; +} diff --git a/src/lib/qualityGovernorCore.js b/src/lib/qualityGovernorCore.js index b7099636..12fac12a 100644 --- a/src/lib/qualityGovernorCore.js +++ b/src/lib/qualityGovernorCore.js @@ -102,6 +102,30 @@ export function xrThresholds(hz) { * caster, per eye, and a headset has a third of a desktop's frame time. */ export const XR_START_LEVEL = 1; +/** + * 33 G1 — A PHONE STARTS LIGHTER, AND IS ALWAYS WORTH GOVERNING. The report: "Stars room is + * too heavy for mobile users, it lags". The governor never acted on a phone at all: it judges + * by the desktop budgets, where a game of 100 draw calls is LIGHT, and a light scene is never + * governed (and is walked back to full quality). So a phone drew the Stars Room at its native + * pixel ratio (2.6 measured on the phone profile = 2.6 Mpx per pass) through 32 render passes + * a frame — shadows, the authored AO, bloom, SMAA. A phone is bound by FILL, not by calls, + * so the size of the scene says nothing about whether stepping helps. + * The start = shadows off, resolution 72 %, AO off (the first four steps). No floor: a phone + * that holds its frames walks back up by the ordinary recovery rule. + */ +export const PHONE_START_LEVEL = 4; + +/** + * A phone or a small tablet: a coarse pointer, no hover, and a short screen side (CSS px). + * Pure; the wiring reads the three from matchMedia/screen. A desktop with a touch screen has + * hover; a laptop's short side is over the line. + * @param {{coarse?: boolean, hover?: boolean, minSide?: number}} d + */ +export function isPhoneLike(d) { + const side = Number(d?.minSide); + return !!d?.coarse && !d?.hover && Number.isFinite(side) && side > 0 && side <= 820; +} + /** * The XR framebuffer scale for the NEXT session, from the deepest level the last one needed. * `setPixelRatio` does nothing to an XR framebuffer and three's foveation already defaults to diff --git a/tests/e2e/quality-phone.test.cjs b/tests/e2e/quality-phone.test.cjs new file mode 100644 index 00000000..10bddcad --- /dev/null +++ b/tests/e2e/quality-phone.test.cjs @@ -0,0 +1,91 @@ +// 33 G1: A PHONE STARTS LIGHTER. The report: "Stars room is too heavy for mobile users, it +// lags". The quality governor never acted on a phone: it judged by the desktop budgets, where a +// 100-call game is LIGHT, and a light scene is never governed — so a phone drew every scene at +// its full pixel ratio through shadows, the authored AO, bloom and SMAA (32 passes a frame on +// the Stars Room, measured with scripts/perf-games.cjs --phone). +// +// A phone-profile page (412x915 @2.625, isMobile + hasTouch = a coarse pointer, no hover) +// against a desktop page: the phone boots at PHONE_START_LEVEL (shadows off, 72 % resolution, +// AO off), its drawing buffer shrinks with it, and slow frames on a LIGHT scene still step it +// further — which the desktop correctly refuses. Then the real Stars Room on the phone. +const h = require('./helpers.cjs'); +const fs = require('fs'); +const path = require('path'); + +const SCENES = process.env.SHELL_SCENES_DIR || [path.resolve(__dirname, '../../../scenes/games')].find((p) => fs.existsSync(p)); + +h.run(async () => { + const browser = await h.launch({ args: h.GPU_ARGS }); + const P = await h.setupPage(browser, 'phone', { context: { viewport: { width: 412, height: 915 }, deviceScaleFactor: 2.625, isMobile: true, hasTouch: true } }); + const D = await h.setupPage(browser, 'desk', { context: { viewport: { width: 1280, height: 720 } } }); + + const read = (page) => + page.evaluate(() => { + const s = window.__stores; + const get = (store) => { let v; store.subscribe((x) => (v = x))(); return v; }; + const r = get(s.globalRenderer); + return { + phone: s.qualityGovernor.phoneQuality(), + state: get(s.qualityGovernor.qualityState), + overrides: get(s.qualityGovernor.qualityOverrides), + dpr: window.devicePixelRatio, + pixelRatio: r.getPixelRatio(), + shadowMap: r.shadowMap.enabled, + coarse: matchMedia('(pointer: coarse)').matches + }; + }); + await P.page.waitForTimeout(1500); + const p0 = await read(P.page); + const d0 = await read(D.page); + h.check(p0.coarse && p0.dpr > 2.5, `premise: the phone page has a coarse pointer at dpr ${p0.dpr}`); + h.check(p0.phone === true && d0.phone === false, `the phone is judged a phone, the desktop is not (${p0.phone}/${d0.phone})`); + h.check(p0.state.level === 4 && /phone/.test(p0.state.reason), `the phone boots at the phone start level (${p0.state.level}, "${p0.state.reason}")`); + h.check(p0.overrides.shadowsOff && p0.overrides.aoOff && p0.overrides.dprScale === 0.72 && !p0.overrides.postOff, `phone start = shadows off, AO off, 72 % resolution, the look kept (${JSON.stringify(p0.overrides)})`); + h.check(Math.abs(p0.pixelRatio - p0.dpr * 0.72) < 0.02, `the phone draws at 72 % of its pixel ratio (${p0.pixelRatio.toFixed(2)} of ${p0.dpr})`); + h.check(d0.state.level === 0 && d0.pixelRatio === d0.dpr, `the desktop boots at full quality (${d0.state.level}, dpr ${d0.pixelRatio})`); + + // ---- slow frames on a LIGHT scene: the phone steps further, the desktop never does + const slowLight = (page) => + page.evaluate(() => { + const s = window.__stores; + s.sceneBudget.stopSceneMetrics(); + s.sceneBudget.sceneMetrics.set({ at: Date.now(), profile: 'desktop', objects: 30, meshes: 30, triangles: 6000, calls: 100 }); + const q = s.qualityGovernor.governorForTest; + let t = 1e7; + // the start level is the level we have: let its hold pass, then 3 s of 50 ms frames + for (let i = 0; i < 80; i++) q.frame(50, (t += 50)); + const out = s.qualityGovernor.decideNow(t); + return { moved: out.moved, level: out.level }; + }); + const ps = await slowLight(P.page); + h.check(ps.moved === 'up' && ps.level === 5, `a phone missing frames on a light scene steps further (${JSON.stringify(ps)})`); + const ds = await slowLight(D.page); + h.check(ds.moved !== 'up' && ds.level === 0, `the desktop never governs a light scene (${JSON.stringify(ds)})`); + await D.ctx.close(); + + // ---- the real Stars Room on the phone: AO is gone from the stack, shadows off + const file = SCENES && path.join(SCENES, 'stars-room', 'scene.tpscene'); + if (file && fs.existsSync(file)) { + await P.page.evaluate(() => { + const s = window.__stores; + s.qualityGovernor.governorForTest.setLevel(4); + }); + await P.page.evaluate(async (arr) => { + const s = window.__stores; + s.templatesModalOpen.set(false); + const payload = await s.sessions.readSessionZip(new Uint8Array(arr).buffer); + await s.sessions.applySession(payload, { backup: false }); + }, Array.from(fs.readFileSync(file))); + await P.page.waitForTimeout(3000); + const stars = await P.page.evaluate(() => { + const s = window.__stores; + const get = (store) => { let v; store.subscribe((x) => (v = x))(); return v; }; + const r = get(s.globalRenderer); + const post = window.__postDebug ? window.__postDebug() : null; + return { kinds: post?.kinds ?? null, shadowMap: r.shadowMap.enabled, level: get(s.qualityGovernor.qualityState).level }; + }); + h.check(stars.level >= 4 && stars.shadowMap === false, `Stars Room on the phone: level ${stars.level}, shadows off (${stars.shadowMap})`); + h.check(Array.isArray(stars.kinds) && !stars.kinds.includes('ao'), `Stars Room on the phone: the authored AO is off, the rest of the look stays (${JSON.stringify(stars.kinds)})`); + } else console.log('SKIP the Stars Room section: no scene at ' + SCENES); + await h.finish(browser); +}); diff --git a/tests/unit/qualityGovernor.test.js b/tests/unit/qualityGovernor.test.js index 699b821b..a005ec3d 100644 --- a/tests/unit/qualityGovernor.test.js +++ b/tests/unit/qualityGovernor.test.js @@ -251,3 +251,40 @@ describe('31-perf: XR thresholds', () => { expect(xrScaleAfter(NaN)).toBe(1); }); }); + +// 33 G1 — a phone starts lighter and is always governed. +import { PHONE_START_LEVEL, isPhoneLike, overridesAt as phoneOverridesAt } from '../../src/lib/qualityGovernorCore.js'; + +describe('33 G1: phones', () => { + it('a phone is a coarse pointer, no hover and a short screen side', () => { + expect(isPhoneLike({ coarse: true, hover: false, minSide: 412 })).toBe(true); + expect(isPhoneLike({ coarse: true, hover: false, minSide: 820 })).toBe(true); // a small tablet + expect(isPhoneLike({ coarse: true, hover: true, minSide: 412 })).toBe(false); // a touch laptop has hover + expect(isPhoneLike({ coarse: false, hover: false, minSide: 412 })).toBe(false); + expect(isPhoneLike({ coarse: true, hover: false, minSide: 1080 })).toBe(false); + expect(isPhoneLike({ coarse: true, hover: false, minSide: 0 })).toBe(false); + expect(isPhoneLike({})).toBe(false); + }); + it('the phone start is shadows off, 72 % resolution and AO off — the post look stays', () => { + expect(GOVERNOR_STEPS.slice(0, PHONE_START_LEVEL).map((s) => s.key)).toEqual(['shadows', 'res85', 'res72', 'ao']); + const o = phoneOverridesAt(PHONE_START_LEVEL); + expect(o).toMatchObject({ shadowsOff: true, dprScale: 0.72, aoOff: true, postOff: false }); + }); + it('a LIGHT scene on slow frames: governed when judged heavy (the phone rule), never when not', () => { + const run = (/** @type {boolean} */ heavy) => { + const g = createGovernor(); + let t = 1000; + g.setLevel(PHONE_START_LEVEL, t); + t += 700; + for (let i = 0; i < 100; i++) { + t += 50; + g.noteFrame(50, t); + } + return g.decide(t, { heavy }); + }; + expect(run(true).moved).toBe('up'); + // the desktop rule: a light scene is never governed — and is walked back to full quality + const light = run(false); + expect(light.moved).not.toBe('up'); + }); +}); From d33f1360593987c312f636aeb677a6ec1eb3588a Mon Sep 17 00:00:00 2001 From: AlexZ005 Date: Fri, 2 Oct 2026 02:21:46 +0300 Subject: [PATCH 4/4] [fix] every VR canvas panel is crisp: board at 2x, wrist card at 2x; all-panels sharpness probe (33 X1) - 1.18 (merged) keeps the headset eye buffer full (b8a785f, vr-panel-sharpness 6/0 here). Extending the probe to EVERY canvas panel: text in a canvas is only as sharp as the canvas, so a texture stretched over more headset pixels than it has texels reads soft on a sharp eye buffer. Measured as texels per headset pixel (texelRatio, a Quest 3's ~25 px/deg at the panel's real distance): board 0.78x and wrist card 0.60x were magnified; strip 1.07x and banner 1.13x already fine. - vrGamePanel: SCALE 1.5 -> 2 (board + pause menu 1.06x at 1.2 m) and WRIST_RES 2 (the 320x300 wrist layout drawn at 640x600 through drawCard's new `res`, hits returned in canvas px; 1.31x at 0.44 m). Layouts unchanged. - NEW suite vr-panel-sharpness-all 10/0: board, pause menu, wrist, strip, banner at their real poses + every visible vr-* canvas mesh is in the probe + a rendered check (the wrist's text edge energy from the eye, full vs half its texels: x1.40). CF (SCALE 1.5, WRIST_RES 1) -> 3 red (0.80x / 0.65x / 0.80x). troika-Text panels (radial, objects, props, keyboard) are SDF and resolution independent. - vr-game-panel 35, vr-laser-panels 25, vr-two-hand-grab 19, game-towers 21, game-shell-games 71, game-menu-layout 36 (re-run at the new scale) all green. svelte-check 333/47. Co-Authored-By: Claude Opus 5.5 (1M context) --- src/lib/vrGamePanel.js | 48 +++++- tests/e2e/vr-panel-sharpness-all.test.cjs | 196 ++++++++++++++++++++++ 2 files changed, 236 insertions(+), 8 deletions(-) create mode 100644 tests/e2e/vr-panel-sharpness-all.test.cjs diff --git a/src/lib/vrGamePanel.js b/src/lib/vrGamePanel.js index 630f6025..0a952a63 100644 --- a/src/lib/vrGamePanel.js +++ b/src/lib/vrGamePanel.js @@ -75,9 +75,27 @@ const SETTLE_DEG = 2; const WRIST_W = 0.15; const STRIP_KEY = 'vr:gameStrip'; -/** the board canvas's pixels per stage pixel: ~1000 px across a metre-wide board, what a - * headset's ~20 px per degree needs at 1.2 m */ -const SCALE = 1.5; +/** the board canvas's pixels per stage pixel. 33 X1: 2, i.e. 1250 texels per metre — a Quest + * 3's ~25 px per degree at 1.2 m wants ~1190 (`texelRatio`); the 1.5 this was (937 per metre) + * magnified every board texel 1.27x, a soft board on a sharp eye buffer */ +const SCALE = 2; +/** 33 X1: the wrist card is drawn at this many canvas px per layout px (320x300 layout): at + * ~0.4 m a Quest 3 wants ~3600 texels per metre and 320 px over 0.15 m was 2133 (0.6x) */ +const WRIST_RES = 2; +/** a Quest 3's display density at the centre of the lens, px per degree (Meta's figure) */ +export const HEADSET_PPD = 25; + +/** + * 33 X1: how many canvas texels fall on one headset pixel at `distance` — 1 or more is crisp + * (no magnification), under 1 is a texture stretched over more pixels than it has. Pure. + * @param {number} canvasPx texels across the surface @param {number} worldW metres across + * @param {number} distance metres from the eye @param {number} [ppd] + */ +export function texelRatio(canvasPx, worldW, distance, ppd = HEADSET_PPD) { + const texelsPerMetre = canvasPx / worldW; + const pixelsPerMetre = (ppd * 180) / Math.PI / Math.max(0.05, distance); + return texelsPerMetre / pixelsPerMetre; +} /* ------------------------------------------------------------ the classification --- */ @@ -510,12 +528,25 @@ function wrapText(g, text, max) { * A compact card of lines (+ optional footer buttons) — the wrist, the top strip and the * banner are all this. Returns the pressable rects. * @param {CanvasRenderingContext2D} g @param {string[]} lines - * @param {{title?: string, row?: boolean, buttons?: boolean, strip?: boolean, hover?: string | null, big?: boolean, color?: string, menu?: 'open' | 'available' | null}} [opts] + * @param {{title?: string, row?: boolean, buttons?: boolean, strip?: boolean, hover?: string | null, big?: boolean, color?: string, menu?: 'open' | 'available' | null, res?: number}} [opts] * @returns {HitRect[]} */ export function drawCard(g, lines, opts = {}) { - const W = g.canvas.width; - const H = g.canvas.height; + // 33 X1: `res` draws the same LAYOUT at res canvas px per layout px (the wrist card), hits + // returned in canvas px like every other surface + const res = Math.max(1, Number(opts.res) || 1); + g.setTransform(res, 0, 0, res, 0, 0); + try { + const hits = drawCardAt(g, g.canvas.width / res, g.canvas.height / res, lines, opts); + return res === 1 ? hits : hits.map((r) => ({ ...r, x: r.x * res, y: r.y * res, w: r.w * res, h: r.h * res })); + } finally { + g.setTransform(1, 0, 0, 1, 0, 0); + } +} + +/** drawCard in layout units W x H @param {CanvasRenderingContext2D} g @param {number} W + * @param {number} H @param {string[]} lines @param {any} opts @returns {HitRect[]} */ +function drawCardAt(g, W, H, lines, opts) { g.clearRect(0, 0, W, H); roundRect(g, 0, 0, W, H, Math.min(24, H / 4)); g.fillStyle = 'rgba(12, 16, 26, 0.86)'; @@ -765,7 +796,7 @@ export function vrGamePanelFrame(opts = {}) { ); // 31 K3 G3: this game's Show FPS puts the counter FIRST on the strip and the wrist if (live && get(gameSettingValues).showFps) lines.unshift(fpsText(get(fpsReading)).main); - const wrist = surface('vr-game-wrist', 320, 300, WRIST_W); + const wrist = surface('vr-game-wrist', 320 * WRIST_RES, 300 * WRIST_RES, WRIST_W); const leftHand = opts.hands?.[0] ?? null; if (live && leftHand) { const sig = JSON.stringify([lines, strip, hover, menuOffer, shellOpen]); @@ -778,7 +809,8 @@ export function vrGamePanelFrame(opts = {}) { buttons: true, strip, hover: hover[0] ?? hover[1], - menu: menuOffer ? (shellOpen ? 'open' : 'available') : null + menu: menuOffer ? (shellOpen ? 'open' : 'available') : null, + res: WRIST_RES }) ); wrist.texture.needsUpdate = true; diff --git a/tests/e2e/vr-panel-sharpness-all.test.cjs b/tests/e2e/vr-panel-sharpness-all.test.cjs new file mode 100644 index 00000000..5cc56c71 --- /dev/null +++ b/tests/e2e/vr-panel-sharpness-all.test.cjs @@ -0,0 +1,196 @@ +// 33 X1: EVERY VR PANEL IS CRISP. 1.18 fixed the eye buffer (vr-panel-sharpness: a headset entry +// always gets a FULL framebuffer). This extends the probe to every canvas-drawn panel: text drawn +// into a canvas is only as sharp as the canvas, so a panel whose texture is STRETCHED over more +// headset pixels than it has texels reads soft on a perfectly sharp eye buffer. +// +// THE PROBE: for each surface the VR game UI draws — the game board (a game's own menu), the +// pause menu on the board, the left-wrist card, the top strip and the announce banner — at the +// pose it is really drawn at (vrGamePanelFrame from a standing head, the wrist turned to read), +// texels per metre (canvas px / world width) against a Quest 3's display density at that +// distance (~25 px per degree): `texelRatio` >= 1 is crisp. MEASURED before this lane: board +// 0.78, wrist 0.60 (magnified 1.3x / 1.7x); strip and banner already >= 1. +// A RENDERED check backs the number: the wrist card drawn from the eye at its real distance has +// at least as much text edge energy (mean squared Laplacian) as the same card at half its texels. +// Panels drawn with troika Text (radial menu, objects/properties panels, keyboard) are signed +// distance fields — resolution independent, crisp at any eye buffer — so only canvases are here. +const h = require('./helpers.cjs'); + +const GAME = { + active: '', + screens: [ + { + id: 'menu', + name: 'Menu', + showWhile: 'menu', + input: 'menu', + elements: [ + { id: 'title', kind: 'text', anchor: 'top-center', x: 0, y: 80, w: 600, h: 90, label: 'SHARP TEXT', style: { size: 64, align: 'center' } }, + { id: 'start', kind: 'button', anchor: 'center', x: 0, y: 0, w: 320, h: 80, label: 'Start', style: { size: 32 } } + ] + }, + { + id: 'hud', + name: 'Round', + showWhile: 'playing', + input: 'game', + elements: [ + { id: 'score', kind: 'text', anchor: 'top-center', x: 0, y: 14, w: 280, h: 30, label: 'Score 12', style: { size: 20 } }, + { id: 'time', kind: 'text', anchor: 'top-center', x: 0, y: 48, w: 280, h: 30, label: '42s left', style: { size: 16 } } + ] + } + ] +}; + +h.run(async () => { + const browser = await h.launch({ args: h.GPU_ARGS }); + const A = await h.setupPage(browser, 'A', { context: { viewport: { width: 1280, height: 720 } } }); + const page = A.page; + await page.evaluate(async (doc) => { + const s = window.__stores; + await s.moduleSDK.initModules([{ id: 'sharpall', name: 'Sharpness fixture', version: '1.0.0', description: '33-games', register() {} }]); + s.gameKit.gameSettings.forceGameId('sharp-all'); + s.hudDocs.setHudDocFor('scene', doc); + s.gameState.setGameState('menu'); + s.commandsHandler.sceneCommand('/create box'); + s.isVRMode.set(true); + s.objectActions.setEditorMode('interact'); + }, GAME); + await page.waitForTimeout(800); + + /** one frame from a standing head, the left wrist turned up to read, every surface measured */ + const measure = (what) => + page.evaluate(async (what) => { + const s = window.__stores; + const T = s.THREE; + const k = s.gameKit.vrGamePanel; + const head = { position: new T.Vector3(0, 1.6, 0), quaternion: new T.Quaternion() }; + // the wrist where a player reads it: ~0.4 m below-ahead, the card facing the eyes + const left = { position: new T.Vector3(-0.08, 1.33, -0.28), quaternion: new T.Quaternion().setFromEuler(new T.Euler(0.9, 0, 0)) }; + if (what === 'banner') s.gameKit.gameAnnounce.announce('Level 3', { sub: 'Light every star', ms: 4000 }); + for (let i = 0; i < 4; i++) k.vrGamePanelFrame({ head, hands: [left, null], force: true }); + const out = {}; + for (const name of ['vr-game-panel', 'vr-game-wrist', 'vr-game-strip', 'vr-game-announce']) { + const surf = k.vrGameSurface(name); + if (!surf?.mesh?.visible) continue; + const worldW = surf.mesh.geometry.parameters.width; + const centre = surf.mesh.getWorldPosition(new T.Vector3()); + const dist = centre.distanceTo(head.position); + out[name] = { px: surf.canvas.width, worldW, dist, ratio: k.texelRatio(surf.canvas.width, worldW, dist) }; + } + return out; + }, what); + + const fmt = (m) => `${m.px} px / ${m.worldW.toFixed(3)} m at ${m.dist.toFixed(2)} m = ${m.ratio.toFixed(2)}x`; + + // ---- the game's own menu on the board, the wrist card + const menu = await measure('menu'); + h.check(!!menu['vr-game-panel'], '(premise) the board shows the game menu'); + h.check(!!menu['vr-game-wrist'], '(premise) the wrist card is drawn'); + if (menu['vr-game-panel']) h.check(menu['vr-game-panel'].ratio >= 1, `the game board is crisp: ${fmt(menu['vr-game-panel'])}`); + if (menu['vr-game-wrist']) h.check(menu['vr-game-wrist'].dist < 0.6 && menu['vr-game-wrist'].ratio >= 1, `the wrist card is crisp where it is read: ${fmt(menu['vr-game-wrist'])}`); + + // ---- the pause menu's pages on the board + await page.evaluate(() => window.__stores.gameKit.gameShell.openShellMenu('settings')); + const shell = await measure('shell'); + h.check(!!shell['vr-game-panel'] && shell['vr-game-panel'].ratio >= 1, `the pause menu on the board is crisp: ${shell['vr-game-panel'] ? fmt(shell['vr-game-panel']) : 'not drawn'}`); + await page.evaluate(() => window.__stores.gameKit.gameShell.closeShellMenu()); + + // ---- in a round: the top strip, and the announce banner + await page.evaluate(() => window.__stores.gameState.setGameState('playing')); + await page.waitForTimeout(300); + const round = await measure('banner'); + h.check(!!round['vr-game-strip'] && round['vr-game-strip'].ratio >= 1, `the top strip is crisp: ${round['vr-game-strip'] ? fmt(round['vr-game-strip']) : 'not drawn'}`); + h.check(!!round['vr-game-announce'] && round['vr-game-announce'].ratio >= 1, `the announce banner is crisp: ${round['vr-game-announce'] ? fmt(round['vr-game-announce']) : 'not drawn'}`); + + // ---- every canvas-textured surface the VR game UI draws was measured above + const canvases = await page.evaluate(() => { + const s = window.__stores; + let scene; + s.globalScene.subscribe((v) => (scene = v))(); + const names = []; + scene.traverse((o) => { + if (o.isMesh && o.visible && o.material?.map?.isCanvasTexture && /^vr-/.test(o.name)) names.push(o.name); + }); + return names; + }); + const measured = ['vr-game-panel', 'vr-game-wrist', 'vr-game-strip', 'vr-game-announce']; + const unmeasured = canvases.filter((n) => !measured.includes(n)); + h.check(unmeasured.length === 0, `every visible VR canvas panel is in the probe (${canvases.join(', ')}${unmeasured.length ? ' — NOT measured: ' + unmeasured.join(', ') : ''})`); + + // ---- the rendered check: the wrist card's text edges, from the eye, at its real distance + await page.evaluate(() => window.__stores.gameState.setGameState('menu')); + const render = await page.evaluate(async () => { + const s = window.__stores; + const T = s.THREE; + const k = s.gameKit.vrGamePanel; + let renderer, scene; + s.globalRenderer.subscribe((v) => (renderer = v))(); + s.globalScene.subscribe((v) => (scene = v))(); + const head = { position: new T.Vector3(0, 1.6, 0), quaternion: new T.Quaternion() }; + const left = { position: new T.Vector3(-0.08, 1.33, -0.28), quaternion: new T.Quaternion().setFromEuler(new T.Euler(0.9, 0, 0)) }; + k.vrGamePanelFrame({ head, hands: [left, null], force: true }); + const wrist = k.vrGameSurface('vr-game-wrist'); + // a camera at the eye looking at the card, a field of view of a headset eye (~100 deg + // across 2064 px = Quest 3) rendered at 2064 px wide: one render px = one headset px + const W = 1032; + const H = 1032; + const cam = new T.PerspectiveCamera(50, 1, 0.02, 50); + cam.position.copy(head.position); + cam.lookAt(wrist.mesh.getWorldPosition(new T.Vector3())); + cam.updateMatrixWorld(true); + const box = new T.Box3().setFromObject(wrist.mesh); + const pts = []; + for (const x of [box.min.x, box.max.x]) for (const y of [box.min.y, box.max.y]) for (const z of [box.min.z, box.max.z]) pts.push(new T.Vector3(x, y, z).project(cam)); + const rx0 = Math.max(1, Math.floor(((Math.min(...pts.map((p) => p.x)) + 1) / 2) * W)); + const rx1 = Math.min(W - 1, Math.ceil(((Math.max(...pts.map((p) => p.x)) + 1) / 2) * W)); + const ry0 = Math.max(1, Math.floor(((1 - Math.max(...pts.map((p) => p.y))) / 2) * H)); + const ry1 = Math.min(H - 1, Math.ceil(((1 - Math.min(...pts.map((p) => p.y))) / 2) * H)); + const energy = () => { + const rt = new T.WebGLRenderTarget(W, H); + rt.texture.colorSpace = T.SRGBColorSpace; + const xrOn = renderer.xr.enabled; + renderer.xr.enabled = false; + renderer.setRenderTarget(rt); + renderer.render(scene, cam); + const px = new Uint8Array(W * H * 4); + renderer.readRenderTargetPixels(rt, 0, 0, W, H, px); + renderer.setRenderTarget(null); + renderer.xr.enabled = xrOn; + rt.dispose(); + const lum = new Float32Array(W * H); + for (let i = 0; i < W * H; i++) lum[i] = 0.2126 * px[i * 4] + 0.7152 * px[i * 4 + 1] + 0.0722 * px[i * 4 + 2]; + let sum = 0; + let n = 0; + for (let y = ry0; y < ry1; y++) + for (let x = rx0; x < rx1; x++) { + const i = y * W + x; + const lap = 4 * lum[i] - lum[i - 1] - lum[i + 1] - lum[i - W] - lum[i + W]; + sum += lap * lap; + n++; + } + return { e: n ? sum / n : 0, n }; + }; + const full = energy(); + // the same card with HALF its texels: drawn small, stretched back by the texture + const c = wrist.canvas; + const half = document.createElement('canvas'); + half.width = c.width / 2; + half.height = c.height / 2; + half.getContext('2d').drawImage(c, 0, 0, half.width, half.height); + const g = c.getContext('2d'); + const keep = g.getImageData(0, 0, c.width, c.height); + g.clearRect(0, 0, c.width, c.height); + g.imageSmoothingEnabled = true; + g.drawImage(half, 0, 0, c.width, c.height); + wrist.mesh.material.map.needsUpdate = true; + const soft = energy(); + g.putImageData(keep, 0, 0); + wrist.mesh.material.map.needsUpdate = true; + return { full: full.e, soft: soft.e, n: full.n, rect: [rx0, ry0, rx1, ry1] }; + }); + h.check(render.n > 2000 && render.full > 20, `(premise) the wrist card covers ${render.n} eye pixels with text edges to measure (energy ${render.full.toFixed(1)})`); + h.check(render.full > render.soft * 1.15, `the wrist card's texels show at the eye: full ${render.full.toFixed(1)} vs half the texels ${render.soft.toFixed(1)} (x${(render.full / Math.max(1e-6, render.soft)).toFixed(2)})`); + + await page.evaluate(() => window.__stores.isVRMode.set(false)); + await h.finish(browser); +});