Skip to content
Closed
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
14 changes: 10 additions & 4 deletions docs/design/realm-renderer-recovery.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,16 +17,22 @@ wheel, label-click, and camera input are synchronously suspended while the
context is lost so a partially disposed scene cannot consume a gesture. The
restored event starts a bounded scene rebuild and records loss/restore counts on
the canvas for DOM diagnostics. If the browser does not restore the context in
time, the user sees an explicit retry surface. No code intentionally calls
`WEBGL_lose_context` during capability probing; the probe is WebGL2-only.
time, the user sees an explicit retry surface. All renderer surfaces share one
cached, non-destructive WebGL2 capability probe. No capability check calls
`WEBGL_lose_context` or otherwise tears down a context; a probe only reads the
optional texture-size limit.

Castle loading is staged: Compact is mandatory and retried once for transient
transport failures after a deterministic short yield; Balanced and High are
optional upgrades. A missing optional LOD records the active quality in
`data-realm-castle-active-lod` and continues with Compact. Pairing, integrity,
and Compact failures are reported with stable failure codes for telemetry and
QA. Each controlled load is assigned a monotonic renderer generation, and a
ready renderer can never transition into static compatibility mode.
QA. Each controlled load is assigned a monotonic renderer generation. Scene
callbacks carry that generation and stale callbacks from a disposed scene are
ignored by both the React boundary and the pure lifecycle reducer. The DOM
exposes the active generation and the last generation that rendered a
successful frame, making recovery assertions deterministic. A ready renderer
can never transition into static compatibility mode.

The recovery contract is intentionally frontend-only. Durable world state,
authorization, and SpacetimeDB subscriptions remain outside the renderer and
Expand Down
65 changes: 50 additions & 15 deletions src/components/realm/RealmMapScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -612,6 +612,11 @@ function CanonicalRealmMapScreen({
rendererModeRef.current = rendererMode;
const rendererLifecycleRef = useRef(rendererLifecycle);
rendererLifecycleRef.current = rendererLifecycle;
// Scene callbacks can arrive after React has already retired their effect.
// Keep the active generation in a synchronous ref so stale scenes cannot
// publish ready/failure state into a newer renderer.
const activeRendererGenerationRef = useRef(0);
const lastSuccessfulRendererGenerationRef = useRef(0);
const rendererRecoveryTimerRef = useRef<number | null>(null);
const rendererRecoveryNonceRef = useRef(0);
const [rendererRecoveryNonce, setRendererRecoveryNonce] = useState(0);
Expand Down Expand Up @@ -899,10 +904,12 @@ function CanonicalRealmMapScreen({
: classifyRealmRendererFailure(failureInput, current.state);
if (failure.code === 'webgl-unavailable') {
rendererModeRef.current = current.everReady ? 'loading' : 'fallback';
setRendererLifecycle(transitionRealmRendererLifecycle(current, {
const nextLifecycle = transitionRealmRendererLifecycle(current, {
type: 'webgl-unsupported',
failure
}));
});
rendererLifecycleRef.current = nextLifecycle;
setRendererLifecycle(nextLifecycle);
return;
}
// Stop accepting pointer/camera mutations synchronously, before React has
Expand All @@ -911,11 +918,13 @@ function CanonicalRealmMapScreen({
rendererModeRef.current = 'loading';
if (shouldRetryRealmRenderer(current, failure)) {
const nextAttempt = current.attempt + 1;
setRendererLifecycle(transitionRealmRendererLifecycle(current, {
const nextLifecycle = transitionRealmRendererLifecycle(current, {
type: 'recover',
failure,
attempt: nextAttempt
}));
});
rendererLifecycleRef.current = nextLifecycle;
setRendererLifecycle(nextLifecycle);
if (failure.code !== 'context-lost') {
rendererRecoveryNonceRef.current += 1;
setRendererRecoveryNonce(rendererRecoveryNonceRef.current);
Expand All @@ -931,26 +940,32 @@ function CanonicalRealmMapScreen({
phase: latest.state,
message: 'The browser did not restore the Realm graphics context in time.'
};
setRendererLifecycle(transitionRealmRendererLifecycle(latest, {
const failedLifecycle = transitionRealmRendererLifecycle(latest, {
type: 'failed',
failure: timeoutFailure
}));
});
rendererLifecycleRef.current = failedLifecycle;
setRendererLifecycle(failedLifecycle);
}, REALM_RENDERER_CONTEXT_RESTORE_TIMEOUT_MS);
}
return;
}
setRendererLifecycle(transitionRealmRendererLifecycle(current, { type: 'failed', failure }));
const failedLifecycle = transitionRealmRendererLifecycle(current, { type: 'failed', failure });
rendererLifecycleRef.current = failedLifecycle;
setRendererLifecycle(failedLifecycle);
}, []);

const retryRenderer = useCallback(() => {
if (rendererRecoveryTimerRef.current !== null) {
window.clearTimeout(rendererRecoveryTimerRef.current);
rendererRecoveryTimerRef.current = null;
}
setRendererLifecycle(transitionRealmRendererLifecycle(rendererLifecycleRef.current, {
const loadingLifecycle = transitionRealmRendererLifecycle(rendererLifecycleRef.current, {
type: 'load-start',
attempt: 0
}));
});
rendererLifecycleRef.current = loadingLifecycle;
setRendererLifecycle(loadingLifecycle);
rendererModeRef.current = 'loading';
rendererRecoveryNonceRef.current += 1;
setRendererRecoveryNonce(rendererRecoveryNonceRef.current);
Expand Down Expand Up @@ -1284,11 +1299,16 @@ function CanonicalRealmMapScreen({
}

let scene: RealmSceneHandle | null = null;
const rendererGeneration = rendererLifecycleRef.current.generation + 1;
const loadingLifecycle = transitionRealmRendererLifecycle(rendererLifecycleRef.current, {
type: 'load-start',
attempt: rendererLifecycleRef.current.attempt,
generation: rendererGeneration
});
activeRendererGenerationRef.current = rendererGeneration;
rendererLifecycleRef.current = loadingLifecycle;
try {
setRendererLifecycle(transitionRealmRendererLifecycle(rendererLifecycleRef.current, {
type: 'load-start',
attempt: rendererLifecycleRef.current.attempt
}));
setRendererLifecycle(loadingLifecycle);
latestProjectionRef.current = { width: 0, height: 0, castles: [] };
labelMembershipSignatureRef.current = '';
latestVisibleCastleLabelsRef.current = [];
Expand Down Expand Up @@ -1363,6 +1383,7 @@ function CanonicalRealmMapScreen({
sharedForestTrees: sharedForestProjection.trees,
waterCells,
realmId: snapshot.realm.realmId,
rendererGeneration,
// The retired local planner is exposed only to the synthetic dev
// observer. Player scenes wait for the paired shared public tables.
allowLegacyForestFallback: observerMode,
Expand All @@ -1376,6 +1397,7 @@ function CanonicalRealmMapScreen({
onTargetHover: handleSceneTargetHover,
onKeepStatusChange: () => undefined,
onCastlesReady: (castleCount) => {
if (activeRendererGenerationRef.current !== rendererGeneration) return;
if (castleCount !== expectedCastleCountRef.current) {
markRendererFailure({
code: 'castle-count-mismatch',
Expand All @@ -1387,8 +1409,10 @@ function CanonicalRealmMapScreen({
}
rendererModeRef.current = 'webgl';
const activeLod = canvas.dataset.realmCastleActiveLod;
lastSuccessfulRendererGenerationRef.current = rendererGeneration;
setRendererLifecycle(transitionRealmRendererLifecycle(rendererLifecycleRef.current, {
type: 'ready',
generation: rendererGeneration,
degradedQuality: activeLod === 'compact' || activeLod === 'balanced'
? activeLod
: undefined
Expand All @@ -1402,8 +1426,13 @@ function CanonicalRealmMapScreen({
onStoneNodePresentationTelemetry: updateStoneNodePresentationTelemetry,
onTerrainPresentationTelemetry: updateTerrainPresentationTelemetry,
onCastleProjection: updateCastleProjection,
onRendererFailure: markRendererFailure,
onRendererFailure: (failure) => {
if (activeRendererGenerationRef.current === rendererGeneration) {
markRendererFailure(failure);
}
},
onRendererContextRestored: () => {
if (activeRendererGenerationRef.current !== rendererGeneration) return;
if (rendererRecoveryTimerRef.current !== null) {
window.clearTimeout(rendererRecoveryTimerRef.current);
rendererRecoveryTimerRef.current = null;
Expand Down Expand Up @@ -1459,7 +1488,9 @@ function CanonicalRealmMapScreen({
scene.restoreCameraAttestation?.(attestation);
}
} catch (error) {
markRendererFailure(classifyRealmRendererFailure(error, 'loading'));
if (activeRendererGenerationRef.current === rendererGeneration) {
markRendererFailure(classifyRealmRendererFailure(error, 'loading'));
}
}

return () => {
Expand All @@ -1472,6 +1503,9 @@ function CanonicalRealmMapScreen({
}
scene?.dispose();
if (sceneRef.current === scene) sceneRef.current = null;
if (activeRendererGenerationRef.current === rendererGeneration) {
activeRendererGenerationRef.current = 0;
}
if (rendererRecoveryTimerRef.current !== null) {
window.clearTimeout(rendererRecoveryTimerRef.current);
rendererRecoveryTimerRef.current = null;
Expand Down Expand Up @@ -1620,6 +1654,7 @@ function CanonicalRealmMapScreen({
data-renderer-failure={rendererLifecycle.failure?.code ?? 'none'}
data-renderer-failure-code={rendererLifecycle.failure?.code ?? 'none'}
data-renderer-generation={String(rendererLifecycle.generation)}
data-renderer-last-successful-generation={String(lastSuccessfulRendererGenerationRef.current)}
data-renderer-context-loss-count={canvasTelemetry?.realmRendererContextLossCount ?? '0'}
data-renderer-context-restore-count={canvasTelemetry?.realmRendererContextRestoreCount ?? '0'}
data-renderer-degraded-quality={rendererLifecycle.degradedQuality ?? 'none'}
Expand Down
6 changes: 6 additions & 0 deletions src/components/realm/createRealmScene.ts
Original file line number Diff line number Diff line change
Expand Up @@ -523,6 +523,8 @@ export type CreateRealmSceneOptions = Readonly<{
sharedForestLayout?: unknown;
/** Canonical realm id used to bind the shared forest table to this scene. */
realmId?: string;
/** React-owned monotonic generation for stale callback suppression. */
rendererGeneration?: number;
/** Complete, digest-validated public water projection; absent means water is unavailable. */
waterCells?: readonly GenesisWaterCellV1[];
/**
Expand Down Expand Up @@ -952,6 +954,7 @@ function initializeRealmScene(
cleanup: RealmSceneCleanup
): RealmSceneHandle {
const sceneBuildSequence = nextRealmSceneBuildSequence++;
const rendererGeneration = options.rendererGeneration ?? sceneBuildSequence;
const noLakeRevisionActive = realmNoLakeRevisionActive(options.waterCells);
const landRenderMap = realmLandPresentationMap(
options.surface.renderMap,
Expand Down Expand Up @@ -1002,6 +1005,8 @@ function initializeRealmScene(
options.canvas.dataset.realmCanvasIdentity = canvasId;
options.canvas.dataset.realmSceneBuildSequence = String(sceneBuildSequence);
options.canvas.dataset.realmSceneIdentity = scene.uuid;
options.canvas.dataset.realmRendererGeneration = String(rendererGeneration);
options.canvas.dataset.realmLastSuccessfulRenderedGeneration = '0';
scene.background = new THREE.Color(REALM_SKY_FALLBACK_COLOR);
const fog = new THREE.Fog(
REALM_SKY_FALLBACK_COLOR,
Expand Down Expand Up @@ -1950,6 +1955,7 @@ function initializeRealmScene(
// only the grass layer if its pinned shader chunk contract has changed.
renderer.render(scene, cameraController.camera);
}
options.canvas.dataset.realmLastSuccessfulRenderedGeneration = String(rendererGeneration);
projectCastleLabels();
if (pendingCastlesReadyCount !== null) {
const castleCount = pendingCastlesReadyCount;
Expand Down
26 changes: 6 additions & 20 deletions src/components/realm/realmMapPresentationHelpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,10 @@ import { useEffect, useState } from 'react';

import { axialToWorld, type HexCoord } from '../../game/map/hexCoordinates';
import { terrainCellByCoord } from '../../game/map/generateTerrainMap';
import {
probeWebGL2Capability,
resetWebGL2CapabilityForTests
} from '../../settings/graphicsPreference';
import type { RealmTerrainSurface } from '../../game/map/realmTerrainSurface';
import type { TerrainCell } from '../../game/map/terrainTypes';
import {
Expand Down Expand Up @@ -76,30 +80,12 @@ export function directionForKey(key: string): HexCoord | null {
}
}

let cachedWebGlCapability: boolean | undefined;

/**
* Probe once per document without deliberately destroying a context. The old
* probe used WEBGL_lose_context as a feature test, which could leave the next
* real canvas in the exact terminal state we are trying to recover from.
*/
export function canUseWebGL() {
if (cachedWebGlCapability !== undefined) return cachedWebGlCapability;
try {
const canvas = document.createElement('canvas');
// WebGL2 is the renderer's authoritative capability. WebGL1 can report a
// usable context while still lacking the instancing, texture, and shader
// contracts required by the Realm scene, so it must not enter the 3D path.
const context = canvas.getContext('webgl2');
cachedWebGlCapability = Boolean(context);
} catch {
cachedWebGlCapability = false;
}
return cachedWebGlCapability;
return probeWebGL2Capability().available;
}

export function resetWebGLCapabilityForTests() {
cachedWebGlCapability = undefined;
resetWebGL2CapabilityForTests();
}

export function pointsForSvg(coord: HexCoord) {
Expand Down
18 changes: 15 additions & 3 deletions src/components/realm/realmRendererRecovery.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,9 +50,9 @@ export function transitionRealmRendererLifecycle(
| { type: 'probe-start' }
| { type: 'webgl-unsupported'; failure?: RealmRendererFailure }
| { type: 'load-start'; attempt?: number; generation?: number }
| { type: 'ready'; degradedQuality?: 'compact' | 'balanced' }
| { type: 'recover'; failure: RealmRendererFailure; attempt?: number }
| { type: 'failed'; failure: RealmRendererFailure }
| { type: 'ready'; degradedQuality?: 'compact' | 'balanced'; generation?: number }
| { type: 'recover'; failure: RealmRendererFailure; attempt?: number; generation?: number }
| { type: 'failed'; failure: RealmRendererFailure; generation?: number }
): RealmRendererLifecycle {
switch (event.type) {
case 'probe-start':
Expand Down Expand Up @@ -88,6 +88,12 @@ export function transitionRealmRendererLifecycle(
failure: undefined
});
case 'ready':
// Late callbacks from a disposed scene must never publish readiness for
// a newer scene generation. Omitting generation keeps the reducer
// convenient for pure callers and legacy integrations.
if (event.generation !== undefined && event.generation !== current.generation) {
return current;
}
return Object.freeze({
...current,
state: 'ready',
Expand All @@ -96,13 +102,19 @@ export function transitionRealmRendererLifecycle(
degradedQuality: event.degradedQuality
});
case 'recover':
if (event.generation !== undefined && event.generation !== current.generation) {
return current;
}
return Object.freeze({
...current,
state: 'recovering',
attempt: event.attempt ?? current.attempt + 1,
failure: event.failure
});
case 'failed':
if (event.generation !== undefined && event.generation !== current.generation) {
return current;
}
return Object.freeze({ ...current, state: 'failed', failure: event.failure });
default:
return current;
Expand Down
10 changes: 2 additions & 8 deletions src/components/title/WarpkeepTitleScreen3D.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@ import {
type WarpkeepTitleScreenHandle,
type WarpkeepTitleScreenProps
} from './titleScreenTypes';
import { probeWebGL2Capability } from '../../settings/graphicsPreference';
import './WarpkeepTitleScreen.css';

type PointLayer = {
Expand All @@ -72,14 +73,7 @@ type GalaxyAssembly = {
};

function canUseWebGL() {
try {
const canvas = document.createElement('canvas');
const context = canvas.getContext('webgl2');
context?.getExtension('WEBGL_lose_context')?.loseContext();
return Boolean(context);
} catch {
return false;
}
return probeWebGL2Capability().available;
}

function createRandom(seed: number) {
Expand Down
Loading