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
32 changes: 26 additions & 6 deletions src/components/hud/FpsCounter.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -2,19 +2,30 @@
// 31 K3 G3 — the desktop FPS counter: this game's "Show FPS" setting, while playing it.
// The headset shows the same reading on the top strip and the wrist card (vrGamePanel).
// LOCAL chrome, pointer-events none, over the game's HUD.
// 33 Q1: the SAME counter answers the app-wide "Show FPS + draw calls" preference — in
// any scene, in any mode — and then colours the draw calls against the Quest budget
// (amber past 120, red past 150). One meter, one implementation: the headset's half is
// vrPerfStrip.js, reading the same `fpsReading`.
import { isLocked, isVRMode, editorMode } from '../../stores/sceneStore';
import { gameSettingValues } from '$lib/gameSettings';
import { fpsReading, fpsText } from '$lib/fpsMeter';
import { fpsReading, perfParts, perfStatsShown } from '$lib/fpsMeter';

const playing = $derived($isLocked === true);
const shown = $derived(!!$gameSettingValues.showFps && !$isVRMode && (playing || $editorMode === 'interact'));
const text = $derived(fpsText($fpsReading));
const gameShown = $derived(!!$gameSettingValues.showFps && (playing || $editorMode === 'interact'));
const shown = $derived(!$isVRMode && ($perfStatsShown || gameShown));
const parts = $derived(perfParts($fpsReading));
</script>

{#if shown}
<div id="game-fps-counter" class="fps" class:fps-play={playing} aria-live="off">
<span class="fps-main">{text.main}</span>
{#if text.detail}<span class="fps-detail">{text.detail}</span>{/if}
<div id="game-fps-counter" class="fps" class:fps-play={playing} data-tier={parts.tier} aria-live="off">
<span class="fps-main">{parts.fps}</span>
{#if parts.ms || parts.calls || parts.tris}
<span class="fps-detail">
{#if parts.ms}<span>{parts.ms}</span>{/if}
{#if parts.calls}{#if parts.ms}{' · '}{/if}<span id="fps-calls" class="fps-calls" data-tier={parts.tier}>{parts.calls}</span>{/if}
{#if parts.tris}{#if parts.ms || parts.calls}{' · '}{/if}<span>{parts.tris}</span>{/if}
</span>
{/if}
</div>
{/if}

Expand Down Expand Up @@ -49,4 +60,13 @@
font-weight: 500;
font-size: 11px;
}
/* 33 Q1: the draw-call budget — TIER_COLORS in fpsMeter.js holds the same three */
.fps-calls[data-tier='warn'] {
color: #fbbf24;
font-weight: 700;
}
.fps-calls[data-tier='over'] {
color: #f87171;
font-weight: 700;
}
</style>
68 changes: 55 additions & 13 deletions src/components/menu/Controls.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -647,6 +647,9 @@
});
let classActive =
'group inline-flex items-center justify-center hover:bg-primary-700 focus:outline-hidden focus:ring-4 focus:ring-primary-300';
// 33 E2: the toggle cell's twin of classActive — same hover, no click-focus ring
const cellToggleClass =
'group inline-flex items-center justify-center hover:bg-primary-700 focus:outline-hidden focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-primary-300';

// 18-B: object-list window size limits, shared with the clamp helpers
const OBJ_WIN_MIN = { minW: 250, minH: 200 };
Expand Down Expand Up @@ -981,11 +984,25 @@
* the FAB's own right-click menu (plus Settings' Reset window positions, which
* is the hatch for iOS Safari, where a long press fires no `contextmenu`). */
const SPACER = '__spacer';
// 30 P1: the Edit/Interact toggle ('mode') joins the default bar at the END — the same
// place loadLayout already appends a default id an older record has never heard of, so
// a fresh profile and an upgraded one agree, and the well keeps its slot after Scale
const DEFAULT_ORDER = ['move', 'rotate', 'scale', 'objects', 'flow', 'explorer', 'mode'];
const DEFAULT_SPACER = 3;
// 33 E1: THE USER'S ORDER — the transforms, then Interact, then Play (the well), then
// the views they open most: object list, node editor, Explorer, Animation. Interact
// sits beside Play because the two answer one question ("how am I touching the scene
// right now"), and Animation joined the default bar (it was an opt-in view before).
const DEFAULT_ORDER = ['move', 'rotate', 'scale', 'mode', 'objects', 'flow', 'explorer', 'animation'];
const DEFAULT_SPACER = 4;
/** 33 E1: the bars a profile could hold WITHOUT ever customizing — the default rows the
* app has shipped, read as the VISUAL row (the well as `__spacer`). A stored record
* that still IS one of these is a default nobody chose, so it migrates to the new
* default (keeping where the bar sits and whether it is collapsed); anything else is
* a custom bar and wins as saved. Pre-30 had no 'mode'; 30-31 appended it last. */
const LEGACY_DEFAULT_ROWS = [
'move,rotate,scale,__spacer,objects,flow,explorer',
'move,rotate,scale,__spacer,objects,flow,explorer,mode'
];
/** 33 E1: ids that became DEFAULT after having been opt-in. A custom record that does
* not list one LEFT it off on purpose (it was unticked), so it is not appended there —
* the append rule is for buttons new to the app, and these are not. */
const PROMOTED_DEFAULTS = ['animation'];

/** W8b: the roster is bigger than the bar. `DEFAULT_ORDER` is what a fresh profile
* puts ON the bar and has NOT changed — the same six ids, the same order, the same
Expand All @@ -999,7 +1016,10 @@
* Explorer (already a default button) — so the roster and the "+" cannot disagree
* about which views exist. Titles come from `DOCK_TITLES`, the dock's own names, so
* a button and its tab read the same word. */
const OPTIONAL_VIEWS = DOCK_VIEWS.filter((view) => view.key !== 'explorer').map((view) => view.key);
const VIEW_BUTTONS = DOCK_VIEWS.filter((view) => view.key !== 'explorer').map((view) => view.key);
// 33 E1: a view on the DEFAULT bar (Animation) is not also an optional one, or the
// Customize and Swap-with lists would carry it twice
const OPTIONAL_VIEWS = VIEW_BUTTONS.filter((key) => !DEFAULT_ORDER.includes(key));

/** the glyph for each optional view. Chosen from a rendered 18px sheet against the
* six already on the bar, not from the names — which is what caught the one real
Expand Down Expand Up @@ -1077,7 +1097,7 @@
// when it is the one on screen — which is the whole reason these are worth
// having as buttons: the "+" list can only ever open them.
...Object.fromEntries(
OPTIONAL_VIEWS.map((key) => [
VIEW_BUTTONS.map((key) => [
key,
{
title: DOCK_TITLES[key] ?? key,
Expand Down Expand Up @@ -1105,6 +1125,16 @@
};
}

/** 33 E1: is this stored record one of the default bars the app used to ship? */
function isLegacyDefault(order: string[], saved: any): boolean {
const hidden: string[] = Array.isArray(saved.hidden) ? saved.hidden : [];
if (hidden.some((id) => order.includes(id))) return false;
const row = [...order];
const at = Number.isFinite(saved.spacerIndex) ? Math.max(0, Math.min(saved.spacerIndex, row.length)) : 3;
row.splice(at, 0, SPACER);
return LEGACY_DEFAULT_ROWS.includes(row.join(','));
}

function defaultLayout(): ControlsLayout {
return { order: [...DEFAULT_ORDER], hidden: [], spacerIndex: DEFAULT_SPACER, collapsed: false, posX: null };
}
Expand Down Expand Up @@ -1132,7 +1162,11 @@
(id: any, at: number) => BUTTONS[id] && saved.order.indexOf(id) === at
)
: [];
for (const id of DEFAULT_ORDER) if (!order.includes(id)) order.push(id);
const posX0 = typeof saved.posX === 'number' && Number.isFinite(saved.posX) ? Math.max(0, Math.min(1, saved.posX)) : null;
// 33 E1: a record that is still a SHIPPED default migrates to today's default
if (isLegacyDefault(order, saved))
return { ...defaultLayout(), collapsed: saved.collapsed === true, posX: posX0 };
for (const id of DEFAULT_ORDER) if (!order.includes(id) && !PROMOTED_DEFAULTS.includes(id)) order.push(id);
const hidden: string[] = Array.isArray(saved.hidden)
? saved.hidden.filter((id: any) => order.includes(id))
: [];
Expand Down Expand Up @@ -1978,13 +2012,16 @@
4b: the FAB is the well's own THIRD child now (see below), which is
why the right half is addressed as `:nth-child(2)` rather than
`:last-child` — the FAB would otherwise steal that position and the
right-hand hover paint would silently stop appearing. -->
right-hand hover paint would silently stop appearing.
33 E3: the neighbour test is `*:hover`, never `p:hover` — the Interact
toggle is a <button> (it carries aria-pressed), so beside the well it lit
itself and left the two pill-coloured corners this paint exists to fill. -->
<div class="relative flex h-full w-10 items-stretch justify-center">
<div
class={'h-full w-5 [p:hover+div>&:first-child]:bg-primary-700' + (i === 0 ? ' rounded-l-full' : '')}
class={'h-full w-5 [*:hover+div>&:first-child]:bg-primary-700' + (i === 0 ? ' rounded-l-full' : '')}
></div>
<div
class={'h-full w-5 [div:has(+p:hover)>&:nth-child(2)]:bg-primary-700' +
class={'h-full w-5 [div:has(+*:hover)>&:nth-child(2)]:bg-primary-700' +
(i === visibleCells.length - 1 ? ' rounded-r-full' : '')}
></div>
<!-- QW (Controls Option A): the WHOLE button scales on hover anywhere on
Expand Down Expand Up @@ -2061,11 +2098,16 @@
{@const btn = BUTTONS[cell.id]}
{@const Glyph = btn.icon}
{#if btn.pressed}
<!-- 30 P1: a TOGGLE cell is a real button, so it can say aria-pressed -->
<!-- 30 P1: a TOGGLE cell is a real button, so it can say aria-pressed.
33 E2: a <button> TAKES FOCUS on click where the <p> cells cannot, so
classActive's `focus:ring-4` drew the theme's red ring on every press —
the only cell that ever showed one. The ring is keyboard-only here
(`focus-visible`, which a mouse press never matches), so a click looks
like every other cell's. -->
<button
type="button"
id={btn.slot}
class={classActive +
class={cellToggleClass +
' w-10' +
(i === 0 ? ' rounded-l-full' : '') +
(i === visibleCells.length - 1 ? ' rounded-r-full' : '')}
Expand Down
7 changes: 7 additions & 0 deletions src/components/menu/Settings.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
import { lightHelperLength } from '$lib/lightHelpers';
import { flowMouseBindings, FLOW_MOUSE_BINDINGS } from '$lib/flowPrefs';
import { helpersInPlay } from '$lib/helperLayer';
import { perfStatsShown } from '$lib/fpsMeter';
import { gamepadPrefs, setGamepadPrefs, DEADZONE_RANGE, SENSITIVITY_RANGE } from '$lib/gamepadPrefs';
import { drawerSlot, cloudPluginInfo } from '$lib/cloudHooks';
import { versionString } from '$lib/version.js';
Expand Down Expand Up @@ -841,6 +842,12 @@
Add a "Search objects…" entry to the viewport right-click menu — find a scene object and fly the camera to it
</SettingRow>
<p class="ui-section-label">Viewport</p>
<SettingRow name="Show FPS + draw calls">
<svelte:fragment slot="control"><Toggle id="show-perf-stats" bind:checked={$perfStatsShown} /></svelte:fragment>
A small counter with the frame rate, frame time, draw calls and triangles — in the
corner of the viewport and, in a headset, on a strip at the top of the view. The draw
calls turn amber past 120 and red past 150, the practical limit on a Quest
</SettingRow>
<SettingRow name="Dock resizes the viewport">
<svelte:fragment slot="control">
<Toggle
Expand Down
2 changes: 2 additions & 0 deletions src/components/play/VRSettingsPanel.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
} from '../../stores/sceneStore'
import { vrHovered, vrSettingsGroup, controllerIndexFor } from '$lib/vrControls'
import { applyWindowPose } from '$lib/vrWindowPoses'
import { perfStatsShown } from '$lib/fpsMeter'
import { menuPoseFromController } from '$lib/vrRadialMenu'

// VR Settings panel (187): a controller-stuck replica of the desktop VR
Expand All @@ -43,6 +44,7 @@
{ action: 'settings:mirror', label: 'Mirror snap turn', toggle: true, active: $vrMirrorSnapTurn },
{ action: 'settings:angle', label: 'Snap turn: ' + ($vrSnapAngle ? $vrSnapAngle + ' deg' : 'Off') },
{ action: 'settings:vertexhold', label: 'Hold to move vertex', toggle: true, active: $vrVertexHold },
{ action: 'settings:perf', label: 'FPS + draw calls', toggle: true, active: $perfStatsShown },
{ action: 'settings:hz', label: 'Refresh: ' + ($vrTargetHz === 'auto' ? 'Max' : $vrTargetHz + ' Hz') },
{ action: 'settings:handstyle', label: 'Peer hands: ' + ($peerHandStyle === 'model' ? 'Model' : $peerHandStyle === 'hands' ? 'Hands' : 'Spheres') },
{ action: 'settings:passthrough', label: 'Passthrough', toggle: true, active: $vrPassthrough },
Expand Down
50 changes: 49 additions & 1 deletion src/lib/fpsMeter.js
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,52 @@
// The draw calls / triangles are sceneBudget's per-display-frame totals (sampled ~1/s).
// A LEAF: svelte/store + sceneBudget (itself a leaf) + sceneStore.
import { writable, get } from 'svelte/store';
import { registerFrameObserver, sceneMetrics } from './sceneBudget';
import { registerFrameObserver, sceneMetrics, noteExternalFrame } from './sceneBudget';
import { globalRenderer } from '../stores/sceneStore';
import { safeStorage } from './safeStorage';

// 33 Q1 — "Show amount of fps and draw calls within quest as an option in settings (150 is
// limit for quest)". An APP-WIDE preference (Settings ▸ Interface ▸ Viewport, and the VR
// settings panel), separate from a game's own Show FPS row: that one is a per-game choice
// that only shows while you play, this one is a debugging lens on any scene in any mode.
// Both draw THIS reading through the same counter (FpsCounter.svelte on the desktop; in a
// headset vrPerfStrip.js), so there is one meter and one number. LOCAL, never replicated.

const PERF_KEY = 'perfStats:show';
/** the app-wide "Show FPS + draw calls" preference */
export const perfStatsShown = writable(safeStorage.getItem(PERF_KEY) === 'true');
perfStatsShown.subscribe((on) => {
safeStorage.setItem(PERF_KEY, on ? 'true' : 'false');
});

/** draw calls per frame past which the counter warns (amber) */
export const CALLS_WARN = 120;
/** the Quest's practical draw-call ceiling — past it the counter is RED */
export const CALLS_LIMIT = 150;

/** How a draw-call count reads against the Quest budget. Pure; exported for the unit layer.
* @param {number | null | undefined} calls @returns {'ok' | 'warn' | 'over' | null} */
export function callsTier(calls) {
if (calls === null || calls === undefined || !Number.isFinite(calls)) return null;
if (calls > CALLS_LIMIT) return 'over';
if (calls > CALLS_WARN) return 'warn';
return 'ok';
}

/** the colour each tier is drawn in (desktop CSS and the VR canvas share it) */
export const TIER_COLORS = { ok: '#d1d5db', warn: '#fbbf24', over: '#f87171' };

/** A reading as its parts, for a counter that colours the calls. @param {FpsReading} r */
export function perfParts(r) {
const tris = !r || r.tris === null ? null : r.tris >= 1000 ? Math.round(r.tris / 1000) + 'k' : String(r.tris);
return {
fps: !r || r.fps === null ? '— fps' : r.fps + ' fps',
ms: !r || r.ms === null ? null : r.ms + ' ms',
calls: !r || r.calls === null ? null : r.calls + ' calls',
tris: tris === null ? null : tris + ' tris',
tier: callsTier(r?.calls)
};
}

/** @typedef {{fps: number | null, ms: number | null, calls: number | null, tris: number | null, source: 'desktop' | 'xr' | null}} FpsReading */

Expand Down Expand Up @@ -62,6 +106,10 @@ function publish() {
export function noteXrFrame(now = typeof performance !== 'undefined' ? performance.now() : Date.now()) {
if (lastXrAt) note(now - lastXrAt, 'xr');
lastXrAt = now;
// 33 Q1: the draw calls/triangles are averaged over DISPLAY frames, which sceneBudget
// counts off the window's rAF — and that does not run inside an immersive session, so
// the headset read a frozen (or empty) calls figure. An XR frame counts as one there.
noteExternalFrame(now);
}

registerFrameObserver((ms) => {
Expand Down
1 change: 1 addition & 0 deletions src/lib/gameKit.js
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ export * as gameSettings from './gameSettings';
export * as gameShellWire from './gameShellWire';
export * as shellPanelDraw from './shellPanelDraw';
export * as fpsMeter from './fpsMeter';
export * as vrPerfStrip from './vrPerfStrip';
export * as comfortVignette from './comfortVignette';
export * as clap from './clap'; // 31 (Stars Room S3): the clap runtime + its test hook
export * as clapGesture from './clapGesture'; // 31: the pure detector
Expand Down
Loading
Loading