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
7 changes: 4 additions & 3 deletions src/App.svelte

Large diffs are not rendered by default.

35 changes: 29 additions & 6 deletions src/components/Outline.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@
} from 'postprocessing';
import { onMount, onDestroy, untrack } from 'svelte';
import { renderPaused } from '$lib/overloadGuard';
import { qualityOverrides, ingestDrawGap } from '$lib/qualityGovernor';
// 16-Q4: the camera preview window renders as an inset viewport of THIS renderer
import { pipRect, pipTarget, glRect } from '$lib/cameraPip';
import { buildCamera } from '$lib/cameraObjects';
Expand All @@ -46,7 +47,7 @@
let outlineEffectSelected: OutlineEffect | null = null;
let outlineEffectLocked: OutlineEffect | null = null;

const { scene, renderer, camera, size, autoRender, renderStage } = useThrelte();
const { scene, renderer, camera, size, autoRender, renderStage, dpr } = useThrelte();
const composer = new EffectComposer(renderer);
composer.removeAllPasses();
const renderPass = new RenderPass(scene, camera.current);
Expand Down Expand Up @@ -215,10 +216,13 @@
// displays, so its output was upsampled and read as a shifted "ghost" of the
// shading offset from the objects. The per-kind `resize` hook carries that
// lesson in the registry rather than hardcoded here.
const dpr = renderer.getPixelRatio ? renderer.getPixelRatio() : 1;
// 26-D: the governor changes the pixel ratio WITHOUT changing the CSS size, so the
// composer has to follow the dpr too or its targets stay at the old resolution
void $dpr;
const pixelRatio = renderer.getPixelRatio ? renderer.getPixelRatio() : 1;
composer.setSize($size.width, $size.height);
for (const instance of stackInstances)
instance.def?.resize?.(instance.object, $size.width, $size.height, dpr);
instance.def?.resize?.(instance.object, $size.width, $size.height, pixelRatio);
});
// L4: the capability gate now covers the WHOLE stack, not just AO (see
// viewMode.postSupported for the three-r185 + Chromium<=150 story, why the
Expand Down Expand Up @@ -253,14 +257,18 @@
// changes (measured: setting a camera to No files replaced rendered nothing new).
void $postStacks;
void $lookOverride;
// 26-D: the quality governor's post steps — AO first (the personal chip reads as plain
// shaded, an authored AO entry is dropped), then the whole stack. LOCAL overrides: the
// authored document is never touched, so a peer's look is unchanged
const reduced = $qualityOverrides;
const entries = effectivePostStack({
stack: resolvedDoc(POST_SCENE_KEY),
cameraStack: /** @type {any} */ (throughCamera ? resolvedDoc(throughCamera) : null),
mode: $viewMode,
localEnabled: $postEnabledLocal,
mode: reduced.aoOff && $viewMode === 'shaded-ao' ? 'shaded' : $viewMode,
localEnabled: $postEnabledLocal && !reduced.postOff,
postOk,
postWarm
});
}).filter((entry) => !(reduced.aoOff && entry.kind === 'ao'));
const signature = postStackSignature(entries);
if (signature === stackSignature) return;
stackSignature = signature;
Expand Down Expand Up @@ -330,9 +338,22 @@
let renderIsPaused = false;
const stopPauseWatch = renderPaused.subscribe((value) => (renderIsPaused = !!value));
onDestroy(stopPauseWatch);
// 26-D THE INGEST DRAW GAP (26-E's finding): while a big received scene drains through
// slow frames, draw at most one frame per gap — every object's parse waits for a frame to
// pass, so a joiner redrawing a 2,000-object scene 30 times a second was starving its own
// receive queue (3,000 objects: ~180s drawing, 5.6s not). Never in XR, like the pause.
let drawGapMs = 0;
let lastDrawAt = 0;
const stopGapWatch = ingestDrawGap.subscribe((value) => (drawGapMs = value));
onDestroy(stopGapWatch);
useTask(
(delta) => {
if (renderIsPaused && !renderer.xr.isPresenting) return;
if (drawGapMs > 0 && !renderer.xr.isPresenting) {
const drawNow = performance.now();
if (drawNow - lastDrawAt < drawGapMs) return;
lastDrawAt = drawNow;
}
// In WebXR the EffectComposer can't be used: its passes render to canvas-sized
// targets, not the XR framebuffer, so blitting them mismatches sizes
// (GL_INVALID_FRAMEBUFFER_OPERATION) and nothing reaches the headset (dark
Expand Down Expand Up @@ -460,6 +481,8 @@
return index >= 0 ? 'stack:' + (stackPlan[index]?.kinds ?? []).join('+') : 'other';
}),
composerPasses: ((composer as any).passes ?? []).length,
// 26-D: the composer's own buffer, which must follow a governor dpr change
composerBufferWidth: (composer as any).inputBuffer?.width ?? null,
outlinedSelected: outlineEffectSelected?.selection.size ?? 0,
outlinedLocked: outlineEffectLocked?.selection.size ?? 0,
stackPasses: stackPasses.length,
Expand Down
23 changes: 20 additions & 3 deletions src/components/Scene.svelte
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
<script lang="ts">
import * as THREE from 'three';
import { onMount } from 'svelte';
import { onMount, onDestroy } from 'svelte';
import { qualityOverrides } from '$lib/qualityGovernor';
import { T, useTask, useThrelte } from '@threlte/core';
import { Environment, interactivity, OrbitControls, TransformControls } from '@threlte/extras';
import { XR, Controller, Hand, useHand } from '@threlte/xr'
Expand Down Expand Up @@ -93,7 +94,22 @@
import { Mesh, Vector3 } from 'three'


let { scene, camera, renderer } = useThrelte();
let { scene, camera, renderer, dpr } = useThrelte();

// 26-D: the quality governor's two knobs that live here. Resolution goes through
// threlte's OWN dpr (renderer.setPixelRatio directly would be undone by threlte's resize
// effect), and only once the governor has asked for something — at full quality this
// never touches the dpr, so threlte keeps following devicePixelRatio as it always did.
// The presence halving is read by the camera send below.
let presenceSlow = false;
let appliedDprScale = 1;
const stopQualityWatch = qualityOverrides.subscribe((o) => {
presenceSlow = o.presenceSlow;
if (o.dprScale === appliedDprScale) return;
appliedDprScale = o.dprScale;
dpr.set((typeof window !== 'undefined' ? window.devicePixelRatio || 1 : 1) * o.dprScale);
});
onDestroy(stopQualityWatch);

$globalScene = scene; // console.log($globalScene)
$globalRenderer = renderer;
Expand Down Expand Up @@ -297,7 +313,8 @@
// receives ~800 a second. The movement threshold is unchanged; this only bounds
// HOW OFTEN, which is the `vrhands` pattern one block below. Golden rule 11: a
// receiver eases between samples, we never raise a send rate to paper over it.
const camGapMs = $isVRMode ? 33 : 50;
// 26-D: the governor's last step halves it again (a receiver eases, rule 11)
const camGapMs = ($isVRMode ? 33 : 50) * (presenceSlow ? 2 : 1);
const nowMs = performance.now();
if ((camContentPos.distanceTo(lastCameraPosition) > ($isVRMode ? 0.0001 : 0.01) ||
camContentQuat.angleTo(lastCameraQuaternion) > THREE.MathUtils.degToRad(1)) &&
Expand Down
51 changes: 49 additions & 2 deletions src/components/menu/Controls.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@
// 24-B2: keyboard navigation in the object list (the Explorer's gridKeydown shape)
import { visibleObjectRows, withExpanded, typeAheadIndex } from '$lib/objectListNav';
import { sceneMetrics, statsOpen, worstTier, budgetRows } from '$lib/sceneBudget';
import { qualityState, pinQuality, releaseQuality } from '$lib/qualityGovernor';
import { showToast as showQualityToast } from '../../stores/appStore';
import { keyOf } from '$lib/keyOf';
import { systemGroupNames } from '$lib/moduleSDK';
import { ENV_ROOT } from '$lib/environment';
Expand All @@ -20,9 +22,9 @@
import { sendPing } from '$lib/ping';
import { buildObjectMenuItems } from '$lib/objectMenu';
import * as THREE from 'three';
import { onMount, setContext, tick } from 'svelte';
import { onMount, setContext, tick, untrack } from 'svelte';
import { createGesture } from '$lib/modalGrab';
import { writable } from 'svelte/store';
import { writable, get } from 'svelte/store';
import { shareObject } from '$lib/objectPermissions';
import Objects from './Objects.svelte';
import LocalObjects from './LocalObjects.svelte';
Expand Down Expand Up @@ -520,6 +522,39 @@
return 'Scene budget: over on ' + over.map((r) => r.label.toLowerCase()).join(', ') + '. Click for statistics.';
});

// 26-D: THE QUALITY CHIP. The governor acts on its own, so it has to be SEEN acting and
// be answerable in one click — an automatic change a person cannot see or undo is just a
// different kind of broken (26-G's rule). Two states, one button: reducing on its own
// (click keeps it), or held (click gives full quality back). A direct listener for the
// same reason as openStats.
const qualityTitle = $derived.by(() => {
const q = $qualityState;
const what = q.labels.join(', ').toLowerCase();
return q.pinned
? 'Held at reduced quality (' + what + '). Click to restore full quality.'
: 'This scene is heavy for this device, so drawing was reduced: ' + what + '. It comes back on its own when frames recover. Click to keep it this way.';
});
function qualityChipClick(node: HTMLElement) {
const click = () => (get(qualityState).pinned ? releaseQuality() : pinQuality());
node.addEventListener('click', click);
return { destroy() { node.removeEventListener('click', click); } };
}
// …and once per session, a toast when it FIRST acts, because the chip lives in the object
// list's footer and that window can be closed
let qualityToasted = false;
$effect(() => {
const q = $qualityState;
if (q.level > 0 && !qualityToasted) {
qualityToasted = true;
untrack(() =>
showQualityToast('Quality reduced — this scene is heavy for this device (' + q.labels.join(', ').toLowerCase() + '). It comes back on its own.', [
{ label: 'Restore full quality', action: () => releaseQuality() },
{ label: 'Keep it', action: () => pinQuality() }
])
);
}
});

// bottom status line: totals across the whole tree (N objects · M hidden)
let objectCount = $state(0);
let hiddenCount = $state(0);
Expand Down Expand Up @@ -2328,6 +2363,18 @@
<!-- 26-A: THE BUDGET METER. One dot beside the count that a person can learn in a
second, next to the one number that already says how big the scene is. It opens
the Statistics window, because a warning you cannot act on is a decoration. -->
{#if $qualityState.level > 0 || $qualityState.pinned}
<button
id="quality-chip"
class="shrink-0 bg-amber-100 px-2 py-0.5 text-left text-[10px] text-amber-800 dark:bg-amber-900/60 dark:text-amber-200"
data-level={$qualityState.level}
data-pinned={$qualityState.pinned ? 'true' : 'false'}
title={qualityTitle}
use:qualityChipClick
>
Reduced quality{$qualityState.pinned ? ' · held' : ' (scene is heavy)'}
</button>
{/if}
<button
id="object-count"
class="shrink-0 rounded-bl rounded-br bg-gray-100 px-2 py-0.5 text-left text-[10px] text-gray-500 dark:bg-gray-700 dark:text-gray-300"
Expand Down
5 changes: 5 additions & 0 deletions src/components/menu/Settings.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@
import { syncedAnimations } from '../../stores/flowStore';
import { spatialVoice } from '$lib/voiceChat';
import { shadowQuality } from '$lib/lightParams';
import { autoQuality } from '$lib/qualityGovernor';
import { myHandModel, setMyHandModel } from '$lib/handModels';
import { explorerItems } from '$lib/explorer';
import { pingColor, pingSound } from '$lib/ping';
Expand Down Expand Up @@ -1025,6 +1026,10 @@
</svelte:fragment>
Caps every light's shadow map size on THIS machine (Off disables shadows entirely; per-light sizes still replicate)
</SettingRow>
<SettingRow name="Reduce quality when the scene is heavy">
<svelte:fragment slot="control"><Checkbox id="auto-quality" bind:checked={$autoQuality} /></svelte:fragment>
When a heavy scene cannot keep 30 frames a second on THIS machine, drop shadows, then resolution, then effects, one step at a time, and give each back when frames recover. Never changes the scene for anyone else; the chip beside the object count says when it is active
</SettingRow>
<SettingRow name="Simulation controls">
<svelte:fragment slot="control"><Checkbox bind:checked={$showSimControls} /></svelte:fragment>
Show the physics transport (play/pause/stop/reset) at bottom-right. Off by default to avoid confusion with the main play button; the P key still starts/stops the simulation
Expand Down
7 changes: 7 additions & 0 deletions src/lib/autosave.js
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ import { log, registerDiagnosticsSection } from './diagnostics';
import { captureEditResume, applyEditResume } from './editResume';
import { disposeTree, keepSet } from './disposeTree';
import { safeStorage } from './safeStorage';
import { registerMetricSource } from './sceneBudget';

// Crash safety: snapshots of the scene (GLTF json), the node graph and the
// camera go to IndexedDB — debounced 30s after any change plus a 3-minute
Expand Down Expand Up @@ -61,6 +62,12 @@ const MAX_DEBOUNCE_MS = 300_000;
* debounceMs: number, lastSaveAt: number, writes: number, coalesced: number,
* lastError: string | null}>}
*/
// 26-E (roadmap 26 section 3): what the last snapshot cost, for the budget sampler and
// the stress rig. The status store already held both numbers; nothing sampled them.
// Registered, not imported by sceneBudget — that module stays a leaf.
registerMetricSource('autosaveExportMs', () => get(autosaveStatus).lastExportMs || null);
registerMetricSource('autosaveBytes', () => get(autosaveStatus).lastBytes || null);

export const autosaveStatus = writable({
lastExportMs: 0,
lastBytes: 0,
Expand Down
63 changes: 62 additions & 1 deletion src/lib/commandsHandler.svelte.js
Original file line number Diff line number Diff line change
Expand Up @@ -417,7 +417,20 @@ let loadingStallTimer = null;
* rejects. Nothing cleared it: the only writer was the Toasts effect, which removes a
* uuid when its object APPEARS, and an object that never arrives never appears. */
const LOADING_STALL_MS = 60000;
let loadingStallMs = LOADING_STALL_MS;
/** TEST-ONLY: shorten the stall so "silence, not duration" is provable in seconds.
* @param {number} [ms] omit to restore the real value */
export function setLoadingStallMsForTest(ms) {
loadingStallMs = Number.isFinite(ms) && /** @type {number} */ (ms) > 0 ? /** @type {number} */ (ms) : LOADING_STALL_MS;
}

// 26-E: THE STALL IS SILENCE, NOT DURATION. The timer was armed once, at the announcement,
// and never again — so any transfer that simply took longer than a minute was declared
// dead while it was still arriving. The stress rig measured exactly that: a joiner
// receiving 3,000 boxes on a real GPU was still landing ~10 objects a second at 63s when
// the bar cleared and the toast said "1085 objects never arrived"; all 3,000 arrived by
// 180s. Every uuid that lands now re-arms it (the `loading` subscription below), so the
// 60s is measured from the LAST sign of life, which is what M2 meant by a stall.
function armLoadingStall() {
clearTimeout(loadingStallTimer);
loadingStallTimer = setTimeout(() => {
Expand All @@ -426,11 +439,56 @@ function armLoadingStall() {
console.log('Receiving objects: giving up on ' + left.length + ' that never arrived');
clearLoadingBatch();
showToast(left.length + ' object' + (left.length === 1 ? '' : 's') + ' never arrived.');
}, LOADING_STALL_MS);
}, loadingStallMs);
}

// 26-E (roadmap 26 section 3, "handshake time-to-synced"): how long the last RECEIVED
// batch took, from its `loading` announcement to the last object landing. The receive
// side is where the cost is felt, and it is the one moment both ends of the interval
// are known locally — no clock is compared across peers. LOCAL, never sent.
/** @type {number} */
let loadingStartedAt = 0;
/** @type {number} */
let loadingAnnounced = 0;
/** uuids still outstanding when the batch was CLOSED rather than finished (a stall, a
* departed sender, a cleared scene) — so a batch that never finished cannot report a
* sync time as though it had. */
let loadingLeftAtClear = 0;
/** @type {{ms: number, objects: number, complete: boolean, at: number} | null} */
let lastSync = null;
/** The last batch that ENDED (finished or closed), or null before one has run. */
export function lastSyncStats() {
return lastSync;
}
// Both ends of a batch pass through the store: the Toasts reconcile empties it as the
// last object appears, and `clearLoadingBatch` empties it on every other way out.
// Subscribing here sees both without touching either writer.
/** outstanding count at the last notification, so only PROGRESS re-arms the stall */
let loadingLastLeft = 0;
loading.subscribe((/** @type {any} */ left) => {
const count = Array.isArray(left) ? left.length : 0;
// progress on an open batch: re-arm — but only a timer that is running, never one the
// ingest fork parked on purpose while its question is open
if (loadingStartedAt && count > 0 && count < loadingLastLeft && loadingStallTimer) armLoadingStall();
loadingLastLeft = count;
if (!loadingStartedAt || count) return;
lastSync = {
ms: Math.round(performance.now() - loadingStartedAt),
objects: loadingAnnounced,
complete: loadingLeftAtClear === 0,
at: Date.now()
};
loadingStartedAt = 0;
loadingLeftAtClear = 0;
});
// only a batch that FINISHED has a sync time; a closed one says null rather than a
// number that would read as a fast join
registerMetricSource('syncMs', () => (lastSync?.complete ? lastSync.ms : null));
registerMetricSource('syncObjects', () => (lastSync?.complete ? lastSync.objects : null));

/** Close the batch: the bar goes away, the stall timer disarms. Idempotent. */
export function clearLoadingBatch() {
if (loadingStartedAt) loadingLeftAtClear = /** @type {string[]} */ (get(loading)).length;
clearTimeout(loadingStallTimer);
loadingStallTimer = null;
loadingSender = null;
Expand All @@ -453,6 +511,9 @@ export async function createLoader(count, uuids, senderId) {
loading.set(Array.isArray(uuids) ? uuids : []);
loadingcount.set(count);
loadingSender = senderId ?? null;
// an empty announcement opens nothing to finish, so it starts no clock
loadingStartedAt = Array.isArray(uuids) && uuids.length ? performance.now() : 0;
loadingAnnounced = Number(count) || 0;
// 26-C: THE ONE MOMENT the size is known and nothing has been applied. Past it a
// 4,000-object scene is simply happening to you.
const verdict = ingestVerdict(liveObjectCount(), count, profileFor(get(globalRenderer)));
Expand Down
7 changes: 4 additions & 3 deletions src/lib/environment.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import { peers } from '../stores/appStore';
import { sceneRadius } from './sceneBounds';
import { registerSystemGroup } from './moduleSDK';
import { createLight } from './geometries.svelte';
import { cappedShadowSize, shadowQuality } from './lightParams';
import { cappedShadowSize, shadowsDisabled } from './lightParams';
import { wireframeActive } from './viewMode';
import { idbGet, idbPut, idbDelete, idbKeys } from './idb';
import { safeStorage } from './safeStorage';
Expand Down Expand Up @@ -240,7 +240,8 @@ export function applyEnvironment() {
// honor a persisted 'off' shadow pref here too: the renderer arrives
// after lightParams' first subscribe fires (which would no-op on a null
// renderer), so re-assert it on every apply
if (renderer.shadowMap) renderer.shadowMap.enabled = get(shadowQuality) !== 'off';
// (26-D: through shadowsDisabled, so the quality governor's override survives an apply)
if (renderer.shadowMap) renderer.shadowMap.enabled = !shadowsDisabled();
}

const { hemi, sun } = rigLights(scene, !!preset.hemi);
Expand Down Expand Up @@ -280,7 +281,7 @@ export function applyEnvironment() {
// correctly over the camera feed, and that darkening is what glues a virtual
// object to a real table (the sky/fog lift above is the whole AR stand-down;
// the sun rig keeps casting untouched)
const shadowsOff = get(shadowQuality) === 'off';
const shadowsOff = shadowsDisabled();
const catcher = shadowCatcher(scene, !!(preset.sun && !shadowsOff));
if (catcher) {
catcher.visible = !!(preset.sun && !shadowsOff) && !wireframeActive();
Expand Down
Loading
Loading