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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
43 changes: 33 additions & 10 deletions scripts/perf-games.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -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 <dir>]
// [--seconds 10] [--throttle 4] [--out <dir>] [--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/):
Expand Down Expand Up @@ -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');
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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,
Expand All @@ -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));
Expand Down Expand Up @@ -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');
}
Expand Down Expand Up @@ -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';
Expand Down
4 changes: 2 additions & 2 deletions src/components/Scene.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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();
Expand Down
2 changes: 1 addition & 1 deletion src/components/play/VRControls.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
8 changes: 4 additions & 4 deletions src/lib/flowRuntime.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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
Expand Down
20 changes: 13 additions & 7 deletions src/lib/physics.js
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, any>} */
const byId = new Map();
for (const n of nodes) if (!byId.has(n.id)) byId.set(n.id, n);
/** @type {Set<string>} 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] ??= {};
Expand Down Expand Up @@ -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;
Expand All @@ -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;
}
Expand Down
37 changes: 36 additions & 1 deletion src/lib/qualityGovernor.js
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ import {
import { renderPaused } from './overloadGuard';
import { sceneBatchOpen } from '../stores/sceneStore';
import { safeStorage } from './safeStorage';
import { xrThresholds, XR_START_LEVEL, XR_FRAMEBUFFER_SCALE } from './qualityGovernorCore';
import { xrThresholds, XR_START_LEVEL, XR_FRAMEBUFFER_SCALE, PHONE_START_LEVEL, isPhoneLike } from './qualityGovernorCore';
import { globalRenderer } from '../stores/sceneStore';
import { lodBias } from './lod';
import { lodBiasFor } from './lodCore';
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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)) };
}
Expand Down Expand Up @@ -396,3 +401,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;
}
24 changes: 24 additions & 0 deletions src/lib/qualityGovernorCore.js
Original file line number Diff line number Diff line change
Expand Up @@ -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, ALWAYS full. 31-perf first carried the resolution half of the ladder
* into the headset through `setFramebufferScaleFactor` (applied to the NEXT session, since three
Expand Down
Loading
Loading