diff --git a/src/lib/renderer/engine/__tests__/ac4PortedBehaviors.test.ts b/src/lib/renderer/engine/__tests__/ac4PortedBehaviors.test.ts index 9a5a1b29..9758f535 100644 --- a/src/lib/renderer/engine/__tests__/ac4PortedBehaviors.test.ts +++ b/src/lib/renderer/engine/__tests__/ac4PortedBehaviors.test.ts @@ -34,8 +34,9 @@ import { releaseTelemetry } from '../telemetry'; type WithTest = { _frameOnce: () => void; + readonly _liveDevice: GPUDevice | undefined; _telemetry: { - frameStats: { frame: number }; + frameStats: { frame: number; cpuMs: number; gpuMs: number }; renderStats: { draws: number; sceneryDraws: number; @@ -46,6 +47,7 @@ type WithTest = { foliage: { trees: number; meshes: number }; contextState: { kind: string; backend: string; reason?: string }; shadows: { cascades: number; atlasSize: number }; + banner: { visible: boolean; message: string; variant: string }; }; dispose(): void; }; @@ -109,11 +111,9 @@ describe('AC4 (A) context budget', () => { /* -------------------------------------------------------------------------- * (B) device-lost — ported from `playwright/tests/webgl-context-recovery.spec.ts`. * - * On real hardware the first loss ticks the health counter but doesn't yet - * trigger a reattach, a `fallbackReason` publish, or a `contextState` flip. - * These assertions encode the *current* state so a change to recovery - * cannot slip in silently; the recovery gap itself is tracked in a separate - * issue and the follow-up test is marked `todo` below. + * `requestDevice`'s contract covers the counter + the one-retry drop; attach's + * on-loss handler now covers the context state flip, the user-facing banner, + * and the one-attempt reattach on the same canvas (#584 FR1 D1-a). * ------------------------------------------------------------------------ */ describe('AC4 (B) device-lost', () => { it('publishes deviceLostCount=1 on the first loss', async () => { @@ -148,21 +148,97 @@ describe('AC4 (B) device-lost', () => { expect(readWebGPUHealth()?.fallbackReason).toBe('renderer-unavailable'); }); - it('contextState goes unavailable only when the retry also fails', async () => { - // First-loss path: the attach-level callback leaves contextState as-is - // because the engine might still recover. Encoded here so the recovery - // fix (tracked in a separate issue) can turn this into a positive - // "contextState.kind === 'lost'" test without surprise. + it('reattaches and flips contextState to lost on first device loss, then back to ready on retry success', async () => { const canvas = makeMockCanvas(); const renderer = (await attach(canvas, { testMode: true })) as unknown as WithTest; expect(renderer._telemetry.contextState.kind).toBe('ready'); + + // Trigger the current device's `lost` promise. The attach-level + // handler should mark the state as 'lost', publish the "restoring" + // banner, and kick off a retry. + const first = renderer._liveDevice; + expect(first, 'expected a live device after the first attach').toBeTruthy(); + (first as unknown as { __triggerLost(): void }).__triggerLost(); + await Promise.resolve(); + + // Wait for the retry's buildScene to finish — it awaits boatMesh + + // the Promise.all over scenery GLBs, so a few microtask ticks are + // enough with the mocks (they all 404 instantly). + for (let i = 0; i < 100; i += 1) await Promise.resolve(); + + // After the retry: contextState is back to ready, banner is clear, + // fallbackReason has recorded the loss. + expect(renderer._telemetry.contextState.kind).toBe('ready'); expect(renderer._telemetry.contextState.backend).toBe('webgpu'); + expect(renderer._telemetry.banner.visible).toBe(false); + expect(renderer._telemetry.banner.message).toBe(''); + expect(readWebGPUHealth()?.fallbackReason).toBe('device-lost'); + expect(readWebGPUHealth()?.deviceLostCount).toBeGreaterThanOrEqual(1); + // The scene now owns a different device than before the loss. + expect(renderer._liveDevice).not.toBe(first); + + renderer.dispose(); + }); + + it('drops to renderer-unavailable + failed banner when the retry device also dies', async () => { + const canvas = makeMockCanvas(); + const renderer = (await attach(canvas, { testMode: true })) as unknown as WithTest; + const first = renderer._liveDevice; + (first as unknown as { __triggerLost(): void }).__triggerLost(); + await Promise.resolve(); + for (let i = 0; i < 100; i += 1) await Promise.resolve(); + // Now the retry's device is live. Kill it too. + const second = renderer._liveDevice; + expect(second, 'expected a retry device after the first loss').toBeTruthy(); + expect(second).not.toBe(first); + (second as unknown as { __triggerLost(): void }).__triggerLost(); + await Promise.resolve(); + for (let i = 0; i < 20; i += 1) await Promise.resolve(); + + expect(renderer._telemetry.contextState.kind).toBe('unavailable'); + expect(renderer._telemetry.contextState.reason).toBe('renderer-unavailable'); + expect(renderer._telemetry.contextState.backend).toBe('none'); + expect(renderer._telemetry.banner.visible).toBe(true); + expect(renderer._telemetry.banner.variant).toBe('failed'); + expect(readWebGPUHealth()?.fallbackReason).toBe('renderer-unavailable'); + renderer.dispose(); }); - // Encoded as `todo` so (c) tracks the gap this test will fill once the - // engine's device-lost handler reattaches and surfaces a user banner. - it.todo('reattaches and flips contextState to lost on first device loss'); + it('exposes __ROWER3D_FORCE_DEVICE_LOST only under __PLAYWRIGHT_TESTING (FR2 D4-a)', async () => { + const win = globalThis as unknown as Record; + try { + // Not set: no seam. + const canvas1 = makeMockCanvas(); + const renderer1 = await attach(canvas1, { testMode: true }); + expect(renderer1).not.toBeNull(); + expect(win.__ROWER3D_FORCE_DEVICE_LOST).toBeUndefined(); + (renderer1 as unknown as WithTest).dispose(); + + // Set: seam exists and is a function. + win.__PLAYWRIGHT_TESTING = true; + const canvas2 = makeMockCanvas(); + const renderer2 = await attach(canvas2, { testMode: true }); + expect(renderer2).not.toBeNull(); + expect(typeof win.__ROWER3D_FORCE_DEVICE_LOST).toBe('function'); + (renderer2 as unknown as WithTest).dispose(); + // Disposed → seam gone. + expect(win.__ROWER3D_FORCE_DEVICE_LOST).toBeUndefined(); + } finally { + delete win.__PLAYWRIGHT_TESTING; + } + }); + + it('writes cpuMs on every frame as the gpuMs fallback (FR3 D6-c)', async () => { + const canvas = makeMockCanvas(); + const renderer = (await attach(canvas, { testMode: true })) as unknown as WithTest; + renderer._frameOnce(); + // cpuMs is always populated and non-negative; gpuMs stays 0 under the + // mock device because no pass writes a timestamp yet. + expect(renderer._telemetry.frameStats.cpuMs).toBeGreaterThanOrEqual(0); + expect(renderer._telemetry.frameStats.gpuMs).toBe(0); + renderer.dispose(); + }); }); /* -------------------------------------------------------------------------- diff --git a/src/lib/renderer/engine/attach.ts b/src/lib/renderer/engine/attach.ts index 6b2c729f..22fa6885 100644 --- a/src/lib/renderer/engine/attach.ts +++ b/src/lib/renderer/engine/attach.ts @@ -8,118 +8,49 @@ * branch merges this file keeps satisfying it structurally without a hard * import. Returning `null` means "renderer unavailable" and the host shows * its placeholder. + * + * Device-lost recovery (#584 FR1, D1-a): the per-device scene lives in + * `rideScene.ts`. `attach` owns the device-session lifecycle, the outer + * render loop, and the one-attempt reattach on the same canvas when the + * current device fires its `lost` promise. On a second loss (the retry + * device also dies) the engine stops drawing and publishes the + * `renderer-unavailable` fallback. */ /// -import { mat4, mat4Identity, mat4LookAt, mat4Multiply, mat4Perspective, mat4FromRotationY, type Mat4 } from '../math/mat4'; -import { vec3 } from '../math/vec3'; -import { cameraTarget, createRigTarget } from './camera'; -import { createBankPass } from '../passes/bank'; -import { createBoatPass } from '../passes/boat'; -import { createBuildingsPass } from '../passes/buildings'; -import { createCloudsPass } from '../passes/clouds'; -import { createSkyPass } from '../passes/sky'; -import { createWaterPass } from '../passes/water'; -import { createSceneryMeshPass } from '../passes/sceneryMesh'; -import { loadGlb } from '../../scenery/glbLoader'; -import { - placeFurniture, - type FurniturePlacement, - type SceneryModelId, -} from '../../scenery/courseFurniture'; -import { createInstancePool, type InstancePool, type PoolInput } from '../plan/instancePools'; -import { createBuildingPool, type BuildingPool, type BuildingSpec } from '../plan/buildings'; -import { BOAT_BONE_COUNT, BOAT_BONE_ARMS, BOAT_BONE_HEAD, BOAT_BONE_HULL, BOAT_BONE_TORSO } from '../plan/boatMesh'; -import { loadBoatMesh } from '../plan/boatMeshLoader'; -import { computeCascades } from './cascadedShadows'; -import { createPlacement, placeBoat, placeGhost, strokePhase, strokeAmplitude } from './characterPlacement'; -import type { RideFrame } from './rideFrame'; -import { RouteCurve } from '../math/curve'; -import { GHOST_LANE_OFFSET_M } from '../../../components/rower3d/ghost'; -import { assetUrl } from '../../utils/assetUrl'; -import { createSceneryPass } from './sceneryPass'; -import { paintFoliage, FOLIAGE_SHAPES } from '../../scenery/foliageTexture'; -import { createSceneryCuller } from '../passes/sceneryCull'; -import { createFloraSolidStage } from './floraSolidStage'; -import { loadFloraAtlas } from './textureLoader'; -import { textureBytes } from './textures'; import { requestDevice, type DeviceSession } from './device'; -import { createFrameGraph, type FrameGraph, type FrameUniforms, type Pass } from './frameGraph'; -import { publishFallback } from './health'; -import { createShadowAtlas } from './shadowAtlas'; -import { shadowConfigFor } from './shadowConfig'; -import { createShadowStage, type ShadowTelemetry } from './shadowStage'; -import { tierCapsFor } from './tierCaps'; -import { createTelemetry, publishSpeedMps, releaseTelemetry, type TelemetryHandles } from './telemetry'; -import { createPostFx, type PostFxCaps } from './postFx'; +import { publishFallback, type WebGPUFallbackReason } from './health'; +import type { RideFrame } from './rideFrame'; import { - createFrameUniformBuffer, - createShadowUniformBuffer, - createSproutUniformBuffer, - FRAME_UNIFORMS_FLOATS, - packFrameUniforms, - packShadowUniforms, - packSproutUniforms, - sproutWindowFor, - type ShadowUniformsCpu, -} from './uniforms'; + createRideScene, + isSceneryRoute, + type RideSceneHandle, + type SceneryRoute, +} from './rideScene'; +import { + createTelemetry, + publishBanner, + releaseTelemetry, + type TelemetryHandles, +} from './telemetry'; export interface RideRenderer { dispose(): void; } +export { isSceneryRoute, type SceneryRoute }; + /** - * A scenery route shape the engine understands: the UI-side `route` is - * `unknown` at the seam (per the agreement with the UI thread). When a route - * is a `SceneryRoute`, the engine builds pools from `pools`; anything else - * mounts an empty scenery pass. + * #584 FR1 D2-a — banner copy ported verbatim from the retired WebGL + * `webgl-context-recovery.spec.ts`'s messages so the ride-view banner has + * one authoritative source. The spec that used to assert against this text + * is now its contract on the engine side (`ac4PortedBehaviors.test.ts`). */ -export interface SceneryRoute { - pools: PoolInput[]; - /** - * Extruded OSM building footprints to draw as prisms (#566 FR1). Absent - * on a route with no mapped buildings — the engine then skips the - * `BuildingsPass` and leaves the sky + clouds + scenery as the only - * horizon. The engine does not call back into enrichment itself; the - * UI shell is expected to extrude footprints before passing the route - * (D1-b). - */ - buildings?: BuildingSpec[]; - /** - * Points of the route curve in world space (#570 D6). Absent on the - * smoke pages — the engine falls back to a short straight-line curve so - * the boat still has a route to run on. - */ - curvePoints?: readonly [number, number, number][]; - /** Metres represented by the full curve (defaults to the chord length). */ - totalDistanceMeters?: number; - /** - * Loop routes wrap at the ends rather than mirroring virtual endpoints; - * omitted or false means an open route. Mirrors `createRouteCurve`'s - * closed-loop detection in the React scene (`src/components/rower3d/curve.ts`). - */ - isClosedLoop?: boolean; - /** - * GLB scenery (bank-edge, scatter, buildings, trees, landform, backdrop, - * course furniture, bridges, landmarks) — one group per model id, with - * per-instance world-space transforms. The adapter computes the layout; - * attach loads each referenced GLB and feeds instances into the registry. - */ - sceneryGlbs?: Array<{ - id: string; - instances: Array<{ position: [number, number, number]; rotationY: number; scale: number }>; - }>; - /** - * Per-progress shore widths (scene units) so the water strip bends with - * the OSM-measured river. Called once per vertex column at build time, not - * per frame. Omitted → symmetric `WATER_CHANNEL_HALF_WIDTH`. - */ - waterEdgesForProgress?: (progress: number) => { left: number; right: number }; -} - -export const isSceneryRoute = (route: unknown): route is SceneryRoute => - !!route && typeof route === 'object' && Array.isArray((route as SceneryRoute).pools); +export const BANNER_LOST_RESTORING = + 'Lost the graphics context — restoring…'; +export const BANNER_LOST_FAILED = + "We couldn't get the graphics context back. Your row is still being recorded."; export type RideAttachOpts = { tier?: string; @@ -146,1025 +77,200 @@ export type RideAttach = ( opts?: RideAttachOpts, ) => RideRenderer | null | Promise; -/** Internal clock used by the test harness. */ -const now = (): number => - typeof performance !== 'undefined' ? performance.now() : Date.now(); +const PLAYWRIGHT_TESTING_KEY = '__PLAYWRIGHT_TESTING' as const; +const FORCE_LOST_KEY = '__ROWER3D_FORCE_DEVICE_LOST' as const; export const attach: RideAttach = async (canvas, opts = {}) => { const telemetry = createTelemetry(() => void frameOnce()); canvas.setAttribute('data-backend', 'webgpu'); const nav = (globalThis as unknown as { navigator?: { gpu?: GPU } }).navigator; - const session: DeviceSession = await requestDevice(nav, { - onDeviceLost(reason) { - if (reason === 'renderer-unavailable') { - telemetry.contextState.kind = 'unavailable'; - telemetry.contextState.reason = reason; - telemetry.contextState.backend = 'none'; - } - }, - }); - - if (session.state.kind !== 'ready') { - publishFallback(session.state.kind === 'unavailable' ? session.state.reason : 'renderer-unavailable'); - telemetry.contextState.kind = 'unavailable'; - telemetry.contextState.reason = - session.state.kind === 'unavailable' ? session.state.reason : 'renderer-unavailable'; - telemetry.contextState.backend = 'none'; - return null; - } - - const { device } = session.state; - const context = canvas.getContext('webgpu') as GPUCanvasContext | null; - if (!context) { - telemetry.contextState.kind = 'unavailable'; - telemetry.contextState.reason = 'webgpu-unsupported'; - telemetry.contextState.backend = 'none'; - session.dispose(); - return null; - } - - const outputFormat: GPUTextureFormat = - (navigator as unknown as { gpu?: GPU }).gpu?.getPreferredCanvasFormat?.() ?? 'bgra8unorm'; - context.configure({ device, format: outputFormat, alphaMode: 'opaque' }); - // #573 P8 — the lit passes render into an HDR offscreen target; PostFxPass - // composites to the real swap-chain. The `format` prop every lit pass takes - // is this HDR format; `outputFormat` is only used by the composite + DOF - // target configs inside PostFxPass. - const format: GPUTextureFormat = 'rgba16float'; - - let width = Math.max(1, Math.floor(canvas.clientWidth || canvas.width || 320)); - let height = Math.max(1, Math.floor(canvas.clientHeight || canvas.height || 240)); - canvas.width = width; - canvas.height = height; - - // Scene HDR colour target (#573 FR1 / D1-a). - let sceneTexture = device.createTexture({ - label: 'scene-hdr', - size: { width, height }, - format, - usage: GPUTextureUsage.RENDER_ATTACHMENT | GPUTextureUsage.TEXTURE_BINDING, - }); - - // Depth is `depth32float` from P8 so SSAO + DOF can sample it (#573 FR9). - let depthTexture = device.createTexture({ - label: 'depth', - size: { width, height }, - format: 'depth32float', - usage: GPUTextureUsage.RENDER_ATTACHMENT | GPUTextureUsage.TEXTURE_BINDING, - }); - const frameUniforms = createFrameUniformBuffer(device); - const uniformScratch = new Float32Array(FRAME_UNIFORMS_FLOATS); - - // #559 P4: shadow atlas + per-tier cascade config. Default tier is medium - // (3 cascades @ 1024). `setTier` is a later-phase ask (#559 FR8); for P2 - // we size on attach from opts.tier. - const shadowCfg = shadowConfigFor(opts.tier); - const atlas = createShadowAtlas(device, shadowCfg.atlasSize, shadowCfg.cascades); - telemetry.shadows.atlasSize = atlas.tileSize; - telemetry.shadows.cascades = atlas.cascades; - telemetry.shadows.atlasBytes = atlas.bytes; - - const shadowUniformsBuf = createShadowUniformBuffer(device); - const shadowScratch = new Float32Array(80); - const shadowCpu: ShadowUniformsCpu = { - cascadeViewProj: [mat4(), mat4(), mat4(), mat4()], - cascadeSplits: new Float32Array(4), - cascadeRadius: new Float32Array(4), - cascadeBlendStartRatio: 0.1, - cascadeCount: atlas.cascades, - tileSize: atlas.tileSize, - atlasWidth: atlas.width, - }; - const shadowCmpSampler = device.createSampler({ - label: 'shadow-cmp', - compare: 'less-equal', - magFilter: 'linear', - minFilter: 'linear', - }); - - // Sprout fade uniform (#562 FR6 / D6-b). The window metres are per-tier. - const sproutUniforms = createSproutUniformBuffer(device); - const sproutScratch = new Float32Array(4); - const sprout = sproutWindowFor(opts.tier); - packSproutUniforms(sprout.near, sprout.far, sproutScratch); - device.queue.writeBuffer(sproutUniforms, 0, sproutScratch as BufferSource); - - // D7-a of #555: wire the scenery pass unconditionally. - const sceneryPoolInputs: PoolInput[] = isSceneryRoute(opts.route) ? opts.route.pools : []; - const scenePools: InstancePool[] = sceneryPoolInputs.map((input) => createInstancePool(device, input)); - - // #566 P6 — buildings pool. Per D5-b we skip the pool entirely when the - // route carries no `buildings`, which is what happens for a fallback - // enrichment. Tier gates then decide whether to build the pass + cast - // shadows (D8). - const tierCaps = tierCapsFor(opts.tier); - const buildingSpecs: BuildingSpec[] = - tierCaps.drawBuildings && isSceneryRoute(opts.route) && opts.route.buildings - ? opts.route.buildings - : []; - const buildingsPool: BuildingPool | null = buildingSpecs.length > 0 - ? createBuildingPool(device, buildingSpecs) - : null; - const buildingsCastingPool = tierCaps.castBuildingShadows ? buildingsPool : null; - telemetry.buildings.routes = buildingsPool ? 1 : 0; - telemetry.buildings.pool = buildingsPool?.instanceCount ?? 0; - telemetry.buildings.footprintM2 = buildingsPool?.footprintM2 ?? 0; - telemetry.buildings.resident = !!buildingsPool; - telemetry.buildings.cast = !!buildingsCastingPool; - // Per-species leaf atlas (#549 slice 3). Three 256×256 RGBA layers (one per - // FoliageShape); `paintFoliage` generates the pixels, the fragment shader - // samples by species index. The pass alpha-tests transparent texels so the - // crossed quads read as individual trees instead of flat cards. - const foliageAtlasSize = 256; - const foliageAtlasLayers = FOLIAGE_SHAPES.length; - const foliageAtlasTex = device.createTexture({ - label: 'foliage-atlas', - size: { width: foliageAtlasSize, height: foliageAtlasSize, depthOrArrayLayers: foliageAtlasLayers }, - format: 'rgba8unorm-srgb', - usage: GPUTextureUsage.TEXTURE_BINDING | GPUTextureUsage.COPY_DST, - }); - // writeTexture is on the real GPUQueue but not on the attach-test mock; - // skipping the upload leaves the atlas blank there, which is fine — no - // test depends on the leaf texture's content. - if (typeof device.queue.writeTexture === 'function') { - for (let layer = 0; layer < foliageAtlasLayers; layer += 1) { - const pixels = paintFoliage(FOLIAGE_SHAPES[layer], foliageAtlasSize); - device.queue.writeTexture( - { texture: foliageAtlasTex, origin: { x: 0, y: 0, z: layer } }, - pixels as BufferSource, - { bytesPerRow: foliageAtlasSize * 4, rowsPerImage: foliageAtlasSize }, - { width: foliageAtlasSize, height: foliageAtlasSize, depthOrArrayLayers: 1 }, - ); - } - } - const foliageAtlasView = foliageAtlasTex.createView({ dimension: '2d-array' }); - const foliageSampler = device.createSampler({ - label: 'foliage', - magFilter: 'linear', - minFilter: 'linear', - mipmapFilter: 'linear', - addressModeU: 'clamp-to-edge', - addressModeV: 'clamp-to-edge', - }); - - const sceneryPass = createSceneryPass({ - device, - format, - frameUniforms, - sproutUniforms, - pools: scenePools, - telemetry: telemetry.scenery, - foliageAtlasView, - foliageSampler, - }); - - // Solid flora stage (#562 FR4 / FR5). Builds a low-poly 3D crown+trunk per - // foliage pool and renders it alongside the sprite pass with its own cull - // + indirect, cross-fading via `sproutUniforms`. - const floraSolidCuller = createSceneryCuller(device); - const floraSolidStage = createFloraSolidStage({ - device, - format, - frameUniforms, - sproutUniforms, - culler: floraSolidCuller, - poolInputs: sceneryPoolInputs, - spritePools: scenePools, - telemetry: telemetry.scenery, - }); - for (const pool of scenePools) { - telemetry.scenery.pools.push({ - kind: pool.kind, - speciesIndex: pool.speciesIndex, - chunkIndex: pool.chunkIndex, - instanceCount: pool.instanceCount, - drawn: -1, - }); - } - - // #570 P7 D1-b — prefer the baked `shell.bin` buffer from - // `scripts/build-boat-mesh.mjs`; the procedural generator in `boatMesh.ts` - // is the deterministic fallback for SSR, tests, and fetch failures so the - // smoke silhouette is identical either way. Telemetry carries which path - // won on `__ROWER3D_BOAT.meshSource`. - const boatMeshHandle = await loadBoatMesh({ url: assetUrl('/assets/boat/shell.bin') }); - const boatMesh = boatMeshHandle.mesh; - telemetry.boat.meshSource = boatMeshHandle.source; - const boatPass = createBoatPass({ device, format, frameUniforms, mesh: boatMesh }); - const charactersMesh = tierCaps.castCharacterShadows ? boatMesh : null; + // Scene state. On a device loss we dispose the current scene and try to + // build a fresh one on the same canvas (D1-a). Everything in `scene` + // lives on one GPUDevice; `attach` outlasts them. + let scene: RideSceneHandle | null = null; + let outerDisposed = false; + let rafId: number | null = null; + // Guards against overlapping reattach attempts: a flap of two `lost` + // events in quick succession must still only trigger one retry. + let reattachInFlight = false; - // Scenery-mesh pass. Loads the course-furniture GLBs and keeps a registry - // keyed by `SceneryModelId`; `placeFurniture` (called below once the route - // curve is known) feeds the per-instance mat4 buffers each attach. The - // same pass also hosts the per-frame boat GLB — scull.glb has 19 primitives - // with distinct materials (hull vs rower vs oars), each registered as its - // own mesh id so the per-primitive base colours survive. - const sceneryMeshPass = createSceneryMeshPass({ device, format, frameUniforms }); - const BOAT_SCULL_PREFIX = 'boat-scull'; - const boatScullMeshIds: string[] = []; - // Per-primitive articulation data for the crewed scull: each primitive's - // local-space geometry needs its own node world matrix as the "static" - // part of its instance transform, and primitives belonging to an - // articulated group (LeftOar / RightOar) also carry a pivot so attach can - // swing the whole oar about it each frame. - interface BoatScullPrimitive { - id: string; - nodeWorld: Mat4; - group?: string; - pivotWorld?: Mat4; - } - const boatScullPrimitives: BoatScullPrimitive[] = []; - const COURSE_FURNITURE_MODELS: SceneryModelId[] = [ - 'a01-buoy-lane-sphere', - 'a06-finish-tower', - 'a07-distance-marker-post', - ]; - // Full set of GLB ids this route references: the fixed course furniture - // plus everything `computePlacements` + `computeStructurePlacements` - // asked for on the adapter side. - const sceneryGlbList = isSceneryRoute(opts.route) ? opts.route.sceneryGlbs ?? [] : []; - const allModelIds: Set = new Set(COURSE_FURNITURE_MODELS); - for (const group of sceneryGlbList) allModelIds.add(group.id); - // Fixed per-GLB ceiling. Routes we ship touch at most ~900 instances per - // bank for the heaviest category; 1024 keeps the GPU buffer size stable - // through a route switch without reallocating. - const COURSE_FURNITURE_MAX_INSTANCES = 1024; - // Where in the asset tree a given model id lives. All scenery GLBs are - // under `public/assets/scenery/tier-{a..g,l}/`; we figure out the folder - // from the id's first character. - const tierFolderFor = (id: string): string => { - const prefix = id[0]?.toLowerCase() ?? 'a'; - const tier = 'abcdefgl'.includes(prefix) ? prefix : 'a'; - return `tier-${tier}`; + const frameOnce = (): void => { + scene?.frameOnce(); }; - // Load the scull GLB (one primitive registered per material so the hull's - // grey, the rower's warmer tone and the oars' tint all survive). Each - // primitive gets the same per-frame instance matrix. The old baked - // `shell.bin` stays loaded as the test-mode / load-failure fallback, - // mirroring React's Suspense fallback shape. - try { - // scull-male.glb has the crewed hero (scull + rower + oars + riggers, - // 155 KB, 45 meshes); scull-female.glb is the mirror. `scull.glb` is the - // empty hull (68 KB, no crew) and stays the fallback via the old baked - // boat. The shell resolves which crewed GLB to load (profile sex + - // stored CrewPreference, #232 / #443) and passes it on `opts.crew`; - // default to male to match the engine's pre-seam behaviour. - const crewGlb = opts.crew === 'female' ? 'scull-female.glb' : 'scull-male.glb'; - const res = await fetch(assetUrl(`/assets/boat/${crewGlb}`)); - if (res.ok) { - const bytes = new Uint8Array(await res.arrayBuffer()); - // Preserve node transforms so LeftOar / RightOar descendants come back - // in LOCAL space with their pivot world matrix attached. The engine - // multiplies through the pivot each frame to swing them. - const model = await loadGlb(bytes, { preserveNodeTransforms: true }); - for (let i = 0; i < model.primitives.length; i += 1) { - const prim = model.primitives[i]; - const id = `${BOAT_SCULL_PREFIX}:${i}`; - sceneryMeshPass.register({ - id, - positions: prim.positions, - normals: prim.normals, - indices: prim.indices, - baseColor: prim.baseColor, - maxInstances: 1, - }); - boatScullMeshIds.push(id); - // Convert the loader's Float32 matrices into the engine's Mat4 type. - const nodeWorld = mat4(); - if (prim.nodeWorldMatrix) { - for (let k = 0; k < 16; k += 1) nodeWorld[k] = prim.nodeWorldMatrix[k]; - } else { - mat4Identity(nodeWorld); - } - let pivotWorld: Mat4 | undefined; - if (prim.pivotWorldMatrix) { - pivotWorld = mat4(); - for (let k = 0; k < 16; k += 1) pivotWorld[k] = prim.pivotWorldMatrix[k]; - } - boatScullPrimitives.push({ - id, - nodeWorld, - group: prim.groupName, - pivotWorld, - }); - } - } - } catch { /* scull GLB load failed — the old baked boat remains as fallback */ } - await Promise.all([...allModelIds].map(async (id) => { - try { - const url = assetUrl(`/assets/scenery/${tierFolderFor(id)}/${id}.glb`); - const res = await fetch(url); - if (!res.ok) return; - const bytes = new Uint8Array(await res.arrayBuffer()); - const model = await loadGlb(bytes); - // Concatenate the model's primitives into one vertex/index set per - // registered id — the pass doesn't need per-primitive materials for - // course furniture, and reusing one id/one bind group keeps the draw - // list short. - let posTotal = 0; - let nrmTotal = 0; - let idxTotal = 0; - for (const p of model.primitives) { - posTotal += p.positions.length; - nrmTotal += p.normals.length; - idxTotal += p.indices.length; - } - const positions = new Float32Array(posTotal); - const normals = new Float32Array(nrmTotal); - const needs32 = model.primitives.some((p) => p.indices instanceof Uint32Array); - const indices = needs32 ? new Uint32Array(idxTotal) : new Uint16Array(idxTotal); - let pOff = 0; - let nOff = 0; - let iOff = 0; - let vertBase = 0; - for (const p of model.primitives) { - positions.set(p.positions, pOff); - normals.set(p.normals, nOff); - for (let i = 0; i < p.indices.length; i += 1) indices[iOff + i] = p.indices[i] + vertBase; - pOff += p.positions.length; - nOff += p.normals.length; - iOff += p.indices.length; - vertBase += p.positions.length / 3; + const startLoop = (): void => { + if (opts.testMode) return; + const loop = (): void => { + if (outerDisposed) return; + frameOnce(); + const raf = (globalThis as unknown as { requestAnimationFrame?: (cb: () => void) => number }) + .requestAnimationFrame; + if (raf) { + rafId = raf(loop); } - sceneryMeshPass.register({ - id, - positions, - normals, - indices, - baseColor: model.primitives[0]?.baseColor ?? [1, 1, 1, 1], - maxInstances: COURSE_FURNITURE_MAX_INSTANCES, - }); - } catch { - // A fetch or decode failure leaves the model unregistered; the pass - // silently skips it rather than taking the engine down. - } - })); - - // Build the water/bank curve + edges fn FIRST so every geometry pass - // that follows the river (water strip, bank strips lit + depth, boat - // placement, course furniture, camera) can share one spline. - const waterCurveSource = isSceneryRoute(opts.route) && opts.route.curvePoints && opts.route.curvePoints.length >= 2 - ? opts.route.curvePoints - : [[0, 0, 0] as [number, number, number], [0, 0, -200] as [number, number, number]]; - const waterCurve = new RouteCurve( - waterCurveSource.map(([x, y, z]) => vec3(x, y, z)), - isSceneryRoute(opts.route) ? !!opts.route.isClosedLoop : false, - ); - const waterEdgesForProgress = isSceneryRoute(opts.route) ? opts.route.waterEdgesForProgress : undefined; - - // Shadow stage before anything lit draws. Its own telemetry writes - // into `telemetry.shadows` so the per-tile atlas size + per-cascade - // draw count are visible on `window.__ROWER3D_SHADOWS`. - const shadowTelemetry: ShadowTelemetry = telemetry.shadows as ShadowTelemetry; - const shadowStage = createShadowStage({ - device, - atlas, - scenePools, - buildingsPool: buildingsCastingPool, - charactersMesh, - boatPass, - telemetry: shadowTelemetry, - bankCurve: waterCurve, - bankEdgesForProgress: waterEdgesForProgress, - }); - - // Lit passes build their own shadow bind groups from - // `pipeline.getBindGroupLayout(1)` (both passes share the atlas + - // uniforms + sampler by resource, not by layout). - const shadowResources = { - uniforms: shadowUniformsBuf, - atlasView: atlas.view, - cmpSampler: shadowCmpSampler, + }; + loop(); }; - const bankPass = createBankPass({ - device, - format, - frameUniforms, - shadow: shadowResources, - curve: waterCurve, - edgesForProgress: waterEdgesForProgress, - }); - const waterPass = createWaterPass({ - device, - format, - frameUniforms, - shadow: shadowResources, - curve: waterCurve, - edgesForProgress: waterEdgesForProgress, - }); - const buildingsPass = createBuildingsPass({ - device, - format, - frameUniforms, - shadow: shadowResources, - pool: buildingsPool, - telemetry: telemetry.buildings, - }); - // Clouds draw after the sky and before the lit passes so the sky's clear - // is still in place and lit depth writes compose over cloud alpha. - const cloudsPass = tierCaps.drawClouds - ? createCloudsPass({ device, format, frameUniforms }) - : null; - // #573 P8 — PostFx. Reads the HDR scene + depth; writes to the swap-chain. - const postCaps: PostFxCaps = { - bloom: tierCaps.drawBloom, - tone: true, - vignette: tierCaps.drawVignette, - lut: tierCaps.drawLut, - ssao: tierCaps.drawSsao, - dof: - tierCaps.drawDof && - !!(globalThis as unknown as { window?: { __VIRTUALROW_POSTFX_DOF?: boolean } }).window - ?.__VIRTUALROW_POSTFX_DOF, + const cancelLoop = (): void => { + if (rafId === null) return; + const cancel = (globalThis as unknown as { cancelAnimationFrame?: (id: number) => void }) + .cancelAnimationFrame; + cancel?.(rafId); + rafId = null; }; - const postFx = createPostFx({ device, sceneFormat: format, outputFormat, caps: postCaps }); - postFx.setInputs({ - sceneView: sceneTexture.createView(), - sceneTexture, - depthView: depthTexture.createView(), - width, - height, - }); - // Pass order (#570 extends #566; #573 appends post-fx): shadowStage → sky - // → clouds? → bank → buildings → scenery → floraSolid → water → boat → - // postFx. The lit passes write to the HDR offscreen (`ctx.swapView` is the - // scene texture view). PostFx reads the HDR scene + depth and composites - // to `ctx.outputView` (the real swap-chain back-buffer). - const passes: Pass[] = [ - shadowStage, - createSkyPass({ device, format, frameUniforms }), - ...(cloudsPass ? [cloudsPass] : []), - bankPass, - buildingsPass, - sceneryPass, - floraSolidStage, - waterPass, - boatPass, - sceneryMeshPass, - postFx.pass, - ]; - const graph: FrameGraph = createFrameGraph(passes); - - const frame: FrameUniforms = { - view: mat4Identity(mat4()), - proj: mat4(), - viewProj: mat4(), - camPos: vec3(0, 2.6, 7), - sunDir: vec3(0.3, 0.8, 0.5), - fog: new Float32Array([0.001, 0.0005, 0, 0]), - time: 0, + // #584 FR2 — expose a page-side helper so a Playwright spec can force a + // device loss (there is no `WEBGL_lose_context` equivalent for WebGPU and + // `GPUDevice.destroy()` must be called from inside the page). Gated on + // `__PLAYWRIGHT_TESTING` (D4-a) so a shipped page can't call it. + const installForceLostSeam = (device: GPUDevice): void => { + const win = (globalThis as unknown as { + window?: Record; + }).window; + if (!win) return; + if (!win[PLAYWRIGHT_TESTING_KEY]) return; + win[FORCE_LOST_KEY] = () => { + try { + device.destroy(); + } catch { + // destroy() can throw on an already-destroyed device; ignore. + } + }; }; - - // Route curve for boat / ghost placement. Reuses `waterCurve` so boat - // and water share one spline, i.e. the boat always rides the water's - // centreline. On a smoke page without a route this is a 200 m straight - // line heading −z so the engine still has a curve to sample - // (`placeBoat` falls back to origin when the curve is null, which it - // never is here). - const routeCurve = waterCurve; - const curveLength = routeCurve.approximateLength(32); - const totalDistanceMeters = - isSceneryRoute(opts.route) && typeof opts.route.totalDistanceMeters === 'number' - ? Math.max(1, opts.route.totalDistanceMeters) - : Math.max(1, curveLength); - telemetry.route.hasCurve = true; - telemetry.route.totalDistance = totalDistanceMeters; - telemetry.route.curveLength = curveLength; - - // Place course furniture along the real route curve and feed the - // per-model instance buffers once at attach — this scene isn't dynamic, - // so there's no reason to re-send the mat4s every frame. - const furniture: FurniturePlacement[] = placeFurniture({ - curve: routeCurve, - routeMeters: totalDistanceMeters, - shoreHalfWidth: (progress, side) => { - if (!waterEdgesForProgress) return 10; - const edges = waterEdgesForProgress(progress); - return side === 'left' ? edges.left : edges.right; - }, - }); - const byModel = new Map(); - for (const model of COURSE_FURNITURE_MODELS) byModel.set(model, []); - // Scenery GLBs are authored in millimetres and the loader bakes the - // node transform into metres-of-the-mesh; `ASSET_SCALE = 0.001` brings - // them back to scene units (matches `sceneryPlacement.ts`'s React path). - const ASSET_SCALE = 0.001; - for (const place of furniture) { - const trs = mat4(); - const rot = mat4(); - const t = mat4(); - mat4FromRotationY(rot, place.rotationY); - // Build TRS = T · R · S by hand so the uniform scale lands on the - // mesh before the rotation applies — the engine's math/ helpers don't - // ship a mat4FromScale yet. - rot[0] *= ASSET_SCALE; rot[1] *= ASSET_SCALE; rot[2] *= ASSET_SCALE; - rot[4] *= ASSET_SCALE; rot[5] *= ASSET_SCALE; rot[6] *= ASSET_SCALE; - rot[8] *= ASSET_SCALE; rot[9] *= ASSET_SCALE; rot[10] *= ASSET_SCALE; - mat4Identity(t); - t[12] = place.position[0]; - t[13] = place.position[1]; - t[14] = place.position[2]; - mat4Multiply(trs, t, rot); - byModel.get(place.id)?.push(trs); - } - for (const model of COURSE_FURNITURE_MODELS) { - sceneryMeshPass.setInstances(model, byModel.get(model) ?? []); - } - - // Full-scene GLB scenery (bank-edge, scatter, buildings, trees, landform, - // backdrop, bridges, landmarks, finish-area furniture). Instances come - // pre-computed from the UI adapter so this is a straight transform-build - // + setInstances per model id. - for (const group of sceneryGlbList) { - const mats: Mat4[] = []; - for (const inst of group.instances) { - const trs = mat4(); - const rot = mat4(); - const t = mat4(); - mat4FromRotationY(rot, inst.rotationY); - // T · (R · S) built by hand — see the course-furniture block above. - rot[0] *= inst.scale; rot[1] *= inst.scale; rot[2] *= inst.scale; - rot[4] *= inst.scale; rot[5] *= inst.scale; rot[6] *= inst.scale; - rot[8] *= inst.scale; rot[9] *= inst.scale; rot[10] *= inst.scale; - mat4Identity(t); - t[12] = inst.position[0]; - t[13] = inst.position[1]; - t[14] = inst.position[2]; - mat4Multiply(trs, t, rot); - mats.push(trs); - } - sceneryMeshPass.setInstances(group.id, mats); - } - - // Chase camera rig state, driven by `cameraTarget` each frame. Allocated - // once so the FOV/position/lookAt don't retain stale per-frame GC pressure. - const cameraRig = createRigTarget(); - const boatPlacement = createPlacement(); - const ghostPlacement = createPlacement(); - const boatBones = [mat4(), mat4(), mat4(), mat4()]; - const ghostBones = [mat4(), mat4(), mat4(), mat4()]; - for (let i = 0; i < BOAT_BONE_COUNT; i++) { - mat4Identity(boatBones[i]); - mat4Identity(ghostBones[i]); - } - const BOAT_TINT = new Float32Array([0.78, 0.78, 0.82, 1.0]); - const GHOST_TINT = new Float32Array([0.35, 0.85, 1.0, 0.5]); - - const start = now(); - let rafId: number | null = null; - let disposed = false; - - const resize = (): void => { - const w = Math.max(1, Math.floor(canvas.clientWidth || canvas.width || width)); - const h = Math.max(1, Math.floor(canvas.clientHeight || canvas.height || height)); - if (w === width && h === height) return; - width = w; - height = h; - canvas.width = w; - canvas.height = h; - depthTexture.destroy(); - depthTexture = device.createTexture({ - label: 'depth', - size: { width: w, height: h }, - format: 'depth32float', - usage: GPUTextureUsage.RENDER_ATTACHMENT | GPUTextureUsage.TEXTURE_BINDING, - }); - sceneTexture.destroy(); - sceneTexture = device.createTexture({ - label: 'scene-hdr', - size: { width: w, height: h }, - format, - usage: GPUTextureUsage.RENDER_ATTACHMENT | GPUTextureUsage.TEXTURE_BINDING, - }); - postFx.setInputs({ - sceneView: sceneTexture.createView(), - sceneTexture, - depthView: depthTexture.createView(), - width: w, - height: h, - }); + const removeForceLostSeam = (): void => { + const win = (globalThis as unknown as { + window?: Record; + }).window; + if (!win) return; + delete win[FORCE_LOST_KEY]; }; - // Per-frame boat speed is derived from the distanceMeters delta, so the - // frame loop keeps the previous reading across calls. - let lastDistanceMeters = 0; - let lastSpeedTime = -1; - - // Smoothed boat distance so a slow-ticking `getFrame` (the Svelte ride - // session updates distanceMeters at ~4 Hz, where rAF runs at 60 Hz) - // doesn't make the boat snap by ~1 m every 250 ms. We track the last - // two getFrame samples, derive an instantaneous speed from their - // deltas, and extrapolate between them each engine frame. - let smoothedDistance = 0; - let lastDistSample = { d: 0, t: now() }; - let prevDistSample = { d: 0, t: now() }; - - const frameOnce = (): void => { - if (disposed) return; - resize(); - frame.time = (now() - start) / 1000; - - // #570 P7 — if the UI shell provided `getFrame`, pull the ride state. - const rideFrame: RideFrame | null = opts.getFrame ? opts.getFrame() : null; - - // Interpolate the boat distance so the shell's discrete samples turn - // into a smooth per-frame position. When the shell's `distanceMeters` - // advances, treat it as the newest truth; between advances, keep - // extrapolating from the last known speed. - if (rideFrame) { - const nowMs = now(); - if (rideFrame.distanceMeters !== lastDistSample.d) { - prevDistSample = lastDistSample; - lastDistSample = { d: rideFrame.distanceMeters, t: nowMs }; - smoothedDistance = rideFrame.distanceMeters; - } else if (rideFrame.isPlaying && !rideFrame.holdBoat && !rideFrame.finished) { - const dtSample = Math.max(1e-3, (lastDistSample.t - prevDistSample.t) / 1000); - const speedMps = (lastDistSample.d - prevDistSample.d) / dtSample; - const dtExtrap = (nowMs - lastDistSample.t) / 1000; - // Cap extrapolation so a stalled getFrame doesn't fling the boat - // off the end of the route — half a sample window is enough to - // smooth 4 Hz up to 60 FPS. - const capped = Math.min(dtExtrap, dtSample * 1.5); - smoothedDistance = lastDistSample.d + speedMps * capped; - } + // Device-loss handler. Called by `requestDevice`'s wiring when the + // current device's `lost` promise resolves. + // + // D1-a (same canvas, rebuild internals) + D3-a (drop to + // `renderer-unavailable` on the first failed retry). The first loss + // disposes the current scene, publishes the "restoring" banner, flips + // `__ROWER3D_CONTEXT_STATE.kind` to `'lost'`, and spins up a retry. A + // second loss (either during retry setup or on the retry's own device) + // publishes `renderer-unavailable` and stops drawing. + const onDeviceLost = (reason: WebGPUFallbackReason | null): void => { + if (outerDisposed) return; + const current = scene; + scene = null; + if (reason === 'renderer-unavailable') { + telemetry.contextState.kind = 'unavailable'; + telemetry.contextState.reason = 'renderer-unavailable'; + telemetry.contextState.backend = 'none'; + publishBanner(telemetry, BANNER_LOST_FAILED, 'failed'); + current?.dispose(); + cancelLoop(); + removeForceLostSeam(); + return; } - const smoothedFrame: RideFrame | null = rideFrame - ? { ...rideFrame, distanceMeters: smoothedDistance } - : null; - - // Boat + ghost placement, run before the camera so the chase camera can - // follow the boat on later phases (P7 keeps the canned camera; the - // follow-camera lands in #556). - placeBoat(boatPlacement, routeCurve, smoothedFrame, totalDistanceMeters, curveLength, 0); - const ghostInfo = placeGhost( - ghostPlacement, - routeCurve, - rideFrame?.ghost ?? null, - rideFrame?.elapsedSeconds ?? frame.time, - totalDistanceMeters, - curveLength, - -GHOST_LANE_OFFSET_M, - ); - - // Analytic sinusoid stroke driver (D3-a). Hull bone stays identity so - // the hull itself does not sway; torso / arms / head rotate about the - // seat with the stroke phase. Oar feather uses rotation.x in YXZ (D4-a) - // which we apply on the arms bone directly. - const phase = strokePhase(rideFrame?.cadence ?? 0, frame.time, rideFrame?.isPlaying ?? false); - const amp = strokeAmplitude(rideFrame); - // Simple torso-lean + arm-pull sinusoid; keep amplitudes small so the - // crew visibly moves at tier basic without looking cartoonish. - const torsoLean = Math.sin(phase * Math.PI * 2) * 0.35 * amp; - const armPull = Math.sin(phase * Math.PI * 2 + 0.5) * 0.6 * amp; - const headNod = Math.sin(phase * Math.PI * 2) * 0.1 * amp; - mat4Identity(boatBones[BOAT_BONE_HULL]); - mat4FromRotationY(boatBones[BOAT_BONE_TORSO], torsoLean); - mat4FromRotationY(boatBones[BOAT_BONE_ARMS], armPull); - mat4FromRotationY(boatBones[BOAT_BONE_HEAD], headNod); - // Ghost stays rigid: its purpose is a silhouette to chase. - for (let i = 0; i < BOAT_BONE_COUNT; i++) mat4Identity(ghostBones[i]); - - // Upload boat / ghost uniforms. Visibility: crew-casting + crew drawing - // are tier-gated inside `boatPass.boatVisible` isn't the right lever - // (that silences the hull too); the crew bone trio stays identity when - // `tierCaps.drawCrew === false`, so basic's "no crew" is "frozen crew" - // rather than invisible — the hull still draws. - if (!tierCaps.drawCrew) { - mat4Identity(boatBones[BOAT_BONE_TORSO]); - mat4Identity(boatBones[BOAT_BONE_ARMS]); - mat4Identity(boatBones[BOAT_BONE_HEAD]); - } - // The GLB scull takes over the hero draw when it loaded successfully; - // the old minimal baked shell stays resident for shadow-casting and the - // ghost chaser. If the GLB didn't load, keep the baked boat visible so - // the user still sees something. - const scullLoaded = boatScullMeshIds.length > 0; - boatPass.boatVisible.value = !scullLoaded; - boatPass.ghostVisible.value = ghostInfo !== null; - - if (scullLoaded) { - // Boat body transform (places the hull on the route): translation at - // the boat's world position * rotation about Y to face the tangent. - // World Y already includes BOAT_HEIGHT_Y so the hull (pivot at the - // waterline per `public/assets/boat/README.md`) rides above the - // surface. - const boatTrs = mat4(); - const scullRot = mat4(); - const scullTrans = mat4(); - mat4FromRotationY(scullRot, boatPlacement.heading); - mat4Identity(scullTrans); - scullTrans[12] = boatPlacement.world[0]; - scullTrans[13] = boatPlacement.world[1]; - scullTrans[14] = boatPlacement.world[2]; - mat4Multiply(boatTrs, scullTrans, scullRot); - - // Oar sweep: phase ∈ [0,1) wraps once per stroke. Map to a sinusoid - // centred on 0 so the oar swings symmetrically through recovery and - // drive. Amplitude follows intensity so a cold stop freezes the oars. - const cadence = rideFrame?.cadence ?? 0; - const elapsed = rideFrame?.elapsedSeconds ?? 0; - const playing = rideFrame?.isPlaying ?? false; - const phase = strokePhase(cadence, elapsed, playing); - const amp = strokeAmplitude(rideFrame); - // ±45° peak-to-peak swing matches React `GltfScull` sweep. Half-span - // because sin(2π·phase) ∈ [−1, 1]. - const SWEEP_PEAK_RAD = Math.PI / 4; - const sweep = Math.sin(phase * Math.PI * 2) * SWEEP_PEAK_RAD * amp; - - // Scratch matrices reused across primitives to avoid allocations. - const nodeAfterSwing = mat4(); - const toPivot = mat4(); - const fromPivot = mat4(); - const rotSweep = mat4(); - const tmp = mat4(); - const perInstance = mat4(); - - for (const prim of boatScullPrimitives) { - if (prim.group && prim.pivotWorld) { - // Articulated: rotate the primitive's local vertices about the - // pivot's world translation. Left and right oars swing in - // opposite phase so the catches alternate — matches a sweep rig - // where port and starboard are mirrored about the hull axis. - const sign = prim.group === 'RightOar' ? -1 : 1; - mat4FromRotationY(rotSweep, sweep * sign); - - const px = prim.pivotWorld[12]; - const py = prim.pivotWorld[13]; - const pz = prim.pivotWorld[14]; - mat4Identity(toPivot); - toPivot[12] = -px; - toPivot[13] = -py; - toPivot[14] = -pz; - mat4Identity(fromPivot); - fromPivot[12] = px; - fromPivot[13] = py; - fromPivot[14] = pz; - - // nodeAfterSwing = fromPivot * rotSweep * toPivot * nodeWorld - mat4Multiply(tmp, toPivot, prim.nodeWorld); - mat4Multiply(nodeAfterSwing, rotSweep, tmp); - mat4Multiply(tmp, fromPivot, nodeAfterSwing); - // perInstance = boatTrs * tmp (apply the hull placement last). - mat4Multiply(perInstance, boatTrs, tmp); - } else { - // Static primitive: boatTrs × nodeWorld. - mat4Multiply(perInstance, boatTrs, prim.nodeWorld); + // First loss. Mark lost, publish, and kick off a retry. + telemetry.contextState.kind = 'lost'; + telemetry.contextState.reason = 'device-lost'; + publishFallback('device-lost'); + publishBanner(telemetry, BANNER_LOST_RESTORING, 'restoring'); + // Stop the outer loop so frameOnce doesn't try to run against a null + // scene (frameOnce handles null anyway, but keeping the loop alive on + // a dead device burns rAF ticks for no reason). + cancelLoop(); + current?.dispose(); + removeForceLostSeam(); + void (async () => { + if (reattachInFlight) return; + reattachInFlight = true; + try { + const next = await buildScene(/* isRetry */ true); + if (outerDisposed) { + next?.dispose(); + return; } - sceneryMeshPass.setInstances(prim.id, [perInstance]); - } - } - boatPass.boat.write(device, { - model: boatPlacement.modelMatrix, - bones: boatBones, - tintRGBA: BOAT_TINT, - ghost: false, - visible: true, - }); - if (ghostInfo) { - boatPass.ghost.write(device, { - model: ghostPlacement.modelMatrix, - bones: ghostBones, - tintRGBA: GHOST_TINT, - ghost: true, - visible: true, - }); - } - - // Publish telemetry for the HUD / specs. - telemetry.boat.x = boatPlacement.world[0]; - telemetry.boat.y = boatPlacement.world[1]; - telemetry.boat.z = boatPlacement.world[2]; - telemetry.boat.heading = boatPlacement.heading; - telemetry.boat.distanceMeters = boatPlacement.distanceMeters; - telemetry.boat.progress = boatPlacement.progress; - telemetry.boat.holding = !!rideFrame?.holdBoat; - telemetry.boat.finished = !!rideFrame?.finished; - telemetry.boat.isPlaying = !!rideFrame?.isPlaying; - - // Boat speed (m/s): the world-scale spec reads __ROWER3D_SPEED_MPS. Compute - // from the distanceMeters delta divided by frame time; hold at 0 while the - // boat is paused or in countdown so the scalar stays flat. - let speedMps = 0; - if (!telemetry.boat.holding && !telemetry.boat.finished && telemetry.boat.isPlaying) { - if (lastSpeedTime >= 0) { - const dt = Math.max(1e-3, frame.time - lastSpeedTime); - const dd = telemetry.boat.distanceMeters - lastDistanceMeters; - speedMps = Math.max(0, dd / dt); + if (next) { + scene = next; + telemetry.contextState.kind = 'ready'; + telemetry.contextState.reason = undefined; + telemetry.contextState.backend = 'webgpu'; + publishBanner(telemetry, '', ''); + installForceLostSeam(next.device); + startLoop(); + } + // On a null `next` the inner `requestDevice({ isRetry: true })` + // has already run `onDeviceLost('renderer-unavailable')`, which + // took the `'renderer-unavailable'` branch above — the banner + // and context state are set there. + } finally { + reattachInFlight = false; } - } - lastDistanceMeters = telemetry.boat.distanceMeters; - lastSpeedTime = frame.time; - publishSpeedMps(telemetry, speedMps); - telemetry.pos.x = boatPlacement.world[0]; - telemetry.pos.y = boatPlacement.world[1]; - telemetry.pos.z = boatPlacement.world[2]; - telemetry.pos.progress = boatPlacement.progress; - telemetry.crew.cadenceSpm = Math.max(0, rideFrame?.cadence ?? 0); - telemetry.crew.strokePhase = phase; - telemetry.crew.amplitude = amp; - telemetry.crew.rowers = 1; - if (ghostInfo) { - telemetry.ghostMeters.value = ghostInfo.metres; - telemetry.ghostMeters.active = true; - } else { - telemetry.ghostMeters.value = 0; - telemetry.ghostMeters.active = false; - } - - // Build view / proj. Chase camera follows the boat along the curve — on - // smoke pages where the boat isn't driven, it falls back to the canned - // over-the-shoulder stand-in (the fallback goes through `cameraTarget` - // too so the rig has one code path). - const camBoat = vec3(boatPlacement.world[0], boatPlacement.world[1], boatPlacement.world[2]); - const camTangent = vec3(); - // Tangent at the boat's progress — the chase camera sits −tangent * back. - const camSample = routeCurve.sample(boatPlacement.progress); - camTangent[0] = camSample.tangent[0]; - camTangent[1] = camSample.tangent[1]; - camTangent[2] = camSample.tangent[2]; - cameraTarget( - { boat: camBoat, tangent: camTangent, velocityMps: speedMps, view: 'chase', bendSign: 0 }, - cameraRig, - ); - const eye = cameraRig.position; - const target = cameraRig.lookAt; - const up = vec3(0, 1, 0); - mat4LookAt(frame.view, eye, target, up); - mat4Perspective(frame.proj, (cameraRig.fov * Math.PI) / 180, width / height, 0.1, 2000); - mat4Multiply(frame.viewProj, frame.proj, frame.view); - frame.camPos[0] = eye[0]; - frame.camPos[1] = eye[1]; - frame.camPos[2] = eye[2]; - - packFrameUniforms(frame, uniformScratch); - device.queue.writeBuffer(frameUniforms, 0, uniformScratch as BufferSource); + })(); + }; - // Compute cascades for this frame and update the shadow stage. - const computed = computeCascades({ - cascades: atlas.cascades, - near: 0.5, - far: 400, - fovY: (58 * Math.PI) / 180, - aspect: width / height, - camPos: eye, - camForward: vec3(target[0] - eye[0], target[1] - eye[1], target[2] - eye[2]), - camUp: up, - lightDirection: vec3(-frame.sunDir[0], -frame.sunDir[1], -frame.sunDir[2]), - lambda: 0.5, - tileTexels: atlas.tileSize, + const buildScene = async (isRetry: boolean): Promise => { + const session: DeviceSession = await requestDevice(nav, { + isRetry, + onDeviceLost, }); - shadowStage.payload.cascadeCount = atlas.cascades; - for (let i = 0; i < 4; i++) { - const dst = shadowStage.payload.cascades[i] ?? { viewProj: mat4() }; - shadowStage.payload.cascades[i] = dst; - if (i < atlas.cascades) { - dst.viewProj.set(computed.cascades[i].viewProj); - shadowCpu.cascadeViewProj[i].set(computed.cascades[i].viewProj); - shadowCpu.cascadeSplits[i] = computed.splits[i] ?? 0; - shadowCpu.cascadeRadius[i] = computed.cascades[i].radius; - } else { - shadowCpu.cascadeViewProj[i].fill(0); - shadowCpu.cascadeSplits[i] = 0; - shadowCpu.cascadeRadius[i] = 0; + if (session.state.kind !== 'ready') { + const reason = + session.state.kind === 'unavailable' ? session.state.reason : 'renderer-unavailable'; + publishFallback(reason); + telemetry.contextState.kind = 'unavailable'; + telemetry.contextState.reason = reason; + telemetry.contextState.backend = 'none'; + if (isRetry) { + publishBanner(telemetry, BANNER_LOST_FAILED, 'failed'); } + return null; } - shadowCpu.cascadeCount = atlas.cascades; - packShadowUniforms(shadowCpu, shadowScratch); - device.queue.writeBuffer(shadowUniformsBuf, 0, shadowScratch as BufferSource); - telemetry.shadows.lastSplits = Array.from(computed.splits); - - // Lit passes write to the HDR scene view; PostFx composites to the - // real swap-chain back-buffer (#573 P8 FR1 + FR2). - const outputView = context.getCurrentTexture().createView(); - const sceneView = sceneTexture.createView(); - const depthView = depthTexture.createView(); - graph.run(device, sceneView, depthView, width, height, frame, outputView); - - telemetry.frameStats.frame += 1; - telemetry.frameStats.dtMs = 0; - // Count scene colour passes as they encode: skip the shadow stage, skip - // the boat pass if neither boat nor ghost drew this frame, and skip the - // post-fx stage (its own sub-pass count is tracked internally; the - // scene `draws` number must stay the "N lit passes" invariant that - // existing specs assert). - let draws = 0; - for (const p of graph.passes) { - if (p.name.startsWith('shadow')) continue; - if (p.name === 'postfx') continue; - if (p.name === 'boat' && !boatPass.boatVisible.value && !boatPass.ghostVisible.value) continue; - draws += 1; - } - telemetry.renderStats.draws = draws; - telemetry.renderStats.sceneryDraws = telemetry.scenery.drawCount; - telemetry.renderStats.sceneryDispatch = telemetry.scenery.dispatchCount; - telemetry.renderStats.sceneryTriangles = - telemetry.scenery.spriteDraws * 4 /* two crossed quads per instance */ - + telemetry.scenery.solidTriangles; - telemetry.renderStats.shadowDraws = telemetry.shadows.draws; - telemetry.renderStats.shadowTriangles = telemetry.shadows.triangles; - telemetry.renderStats.shadowAtlasBytes = telemetry.shadows.atlasBytes; - // Keep `__ROWER3D_FOLIAGE` compatible with today's shape — summarise the - // foliage pool counts rather than reporting per-pool here. - let trees = 0; - let meshes = 0; - for (const p of telemetry.scenery.pools) { - if (p.kind !== 'foliage') continue; - trees += p.instanceCount; - meshes += 1; - } - telemetry.foliage.trees = trees; - telemetry.foliage.meshes = meshes; - telemetry.foliage.drawn = trees; // GPU cull drawn-count isn't read back every frame. - telemetry.camera.x = eye[0]; - telemetry.camera.y = eye[1]; - telemetry.camera.z = eye[2]; - telemetry.camera.lookX = target[0]; - telemetry.camera.lookY = target[1]; - telemetry.camera.lookZ = target[2]; + const handle = await createRideScene(canvas, session.state.device, telemetry, opts, session); + return handle; }; - const loop = (): void => { - if (disposed) return; - frameOnce(); - const raf = (globalThis as unknown as { requestAnimationFrame?: (cb: () => void) => number }) - .requestAnimationFrame; - if (raf) { - rafId = raf(loop); - } - }; - - // Lazy flora atlas load (#562 FR2 / FR3). The decoder lives in its own - // chunk — the engine's main chunk doesn't pull it unless a KTX2 asset is - // actually fetched. The texture itself is published to telemetry so the - // smoke can read atlasKind + atlasBytes without a GPU readback. - type AtlasHandle = Awaited>; - let atlasHandle: AtlasHandle | null = null; - void loadFloraAtlas(device, opts.tier, { - url: assetUrl('/assets/flora/atlas'), - pngWidth: 512, - pngHeight: 512, - }) - .then((handle) => { - atlasHandle = handle; - telemetry.textures.atlasKind = handle.kind; - telemetry.textures.transcodedMs = handle.transcodedMs; - telemetry.textures.mipsGenerated = handle.texture.mipLevelCount; - const bytesFmt = handle.kind === 'ktx2' ? 'bc7-rgba-unorm-srgb' : 'rgba8unorm-srgb'; - telemetry.textures.atlasBytes = textureBytes( - handle.texture.width, - handle.texture.height, - bytesFmt, - handle.texture.mipLevelCount, - ); - }) - .catch(() => { - // Loader guards everything; this is belt-and-braces so a rejected - // dynamic-import can't take the engine down. - telemetry.textures.atlasKind = 'placeholder'; - }); - + const initial = await buildScene(/* isRetry */ false); + if (!initial) { + releaseTelemetry(); + canvas.removeAttribute('data-backend'); + return null; + } + scene = initial; telemetry.contextState.kind = 'ready'; telemetry.contextState.backend = 'webgpu'; + installForceLostSeam(initial.device); if (opts.testMode) { // In test mode we draw one frame and leave scheduling to the caller // via `__ROWER3D_FORCE_RENDER()`. frameOnce(); } else { - loop(); + startLoop(); } - const renderer: RideRenderer & { _frameOnce: () => void; _telemetry: TelemetryHandles } = { + const renderer: RideRenderer & { + _frameOnce: () => void; + _telemetry: TelemetryHandles; + /** Test-only — the current scene's `GPUDevice`, used by the device-lost + * specs to call the mock's `__triggerLost`. Live, not snapshotted: + * reads the current scene each time so a post-reattach test sees + * the retry device, not the dead one. */ + readonly _liveDevice: GPUDevice | undefined; + } = { _frameOnce: frameOnce, _telemetry: telemetry, + get _liveDevice() { + return scene?.device; + }, dispose() { - if (disposed) return; - disposed = true; - if (rafId !== null) { - const cancel = (globalThis as unknown as { cancelAnimationFrame?: (id: number) => void }) - .cancelAnimationFrame; - cancel?.(rafId); - } - graph.dispose(); - for (const pool of scenePools) { - pool.dispose(); - } - buildingsPool?.dispose(); - floraSolidCuller.dispose(); - postFx.dispose(); - sceneTexture.destroy(); - depthTexture.destroy(); - frameUniforms.destroy(); - shadowUniformsBuf.destroy(); - sproutUniforms.destroy(); - atlasHandle?.texture.dispose(); - atlas.dispose(); - session.dispose(); + if (outerDisposed) return; + outerDisposed = true; + cancelLoop(); + scene?.dispose(); + scene = null; + removeForceLostSeam(); releaseTelemetry(); canvas.removeAttribute('data-backend'); }, diff --git a/src/lib/renderer/engine/device.ts b/src/lib/renderer/engine/device.ts index eee0255b..2a004627 100644 --- a/src/lib/renderer/engine/device.ts +++ b/src/lib/renderer/engine/device.ts @@ -102,10 +102,18 @@ export const requestDevice = async ( // tiers, and the loader itself falls through to the uncompressed PNG when // the feature is absent; requesting it is cheap and keeps the device // capable of sampling the BC7 flora atlas on hardware that supports it. + // + // Also opt in to `timestamp-query` when the adapter has it (#584 FR3 / + // D6-c): the engine's frame-stats shim then writes `gpuMs` into + // `__ROWER3D_FRAME_STATS`. Without the feature `gpuMs` stays `0` and the + // engine uses `cpuMs` instead — both match the Rower3DFrameStats shape. const requiredFeatures: GPUFeatureName[] = []; if (adapter.features.has('texture-compression-bc')) { requiredFeatures.push('texture-compression-bc'); } + if (adapter.features.has('timestamp-query')) { + requiredFeatures.push('timestamp-query'); + } device = await adapter.requestDevice({ requiredFeatures }); } catch { publishFallback('device-request-failed'); diff --git a/src/lib/renderer/engine/rideScene.ts b/src/lib/renderer/engine/rideScene.ts new file mode 100644 index 00000000..50056276 --- /dev/null +++ b/src/lib/renderer/engine/rideScene.ts @@ -0,0 +1,1011 @@ +/** + * Per-device scene state for the WebGPU engine. Extracted from `attach.ts` + * so #584 FR1's device-lost reattach loop can rebuild it on a fresh device + * without rewriting `attach`'s orchestration. + * + * The split: `attach` owns the canvas, telemetry, outer render loop, and + * device session lifecycle; `createRideScene` owns everything that lives + * on a given `GPUDevice` — textures, buffers, pipelines, pools, passes, + * the frame graph, and the per-frame encoder. On a loss, `attach` disposes + * the current scene and calls `createRideScene` again with the new device + * (D1-a of #584). + * + * The return value is intentionally small — a `frameOnce` the outer loop + * calls, a `dispose` that releases every GPU resource the scene owns, and + * a few shim refs the test harness reads. + */ + +/// + +import { + mat4, + mat4Identity, + mat4LookAt, + mat4Multiply, + mat4Perspective, + mat4FromRotationY, + type Mat4, +} from '../math/mat4'; +import { vec3 } from '../math/vec3'; +import { cameraTarget, createRigTarget } from './camera'; +import { createBankPass } from '../passes/bank'; +import { createBoatPass } from '../passes/boat'; +import { createBuildingsPass } from '../passes/buildings'; +import { createCloudsPass } from '../passes/clouds'; +import { createSkyPass } from '../passes/sky'; +import { createWaterPass } from '../passes/water'; +import { createSceneryMeshPass } from '../passes/sceneryMesh'; +import { loadGlb } from '../../scenery/glbLoader'; +import { + placeFurniture, + type FurniturePlacement, + type SceneryModelId, +} from '../../scenery/courseFurniture'; +import { createInstancePool, type InstancePool, type PoolInput } from '../plan/instancePools'; +import { createBuildingPool, type BuildingPool, type BuildingSpec } from '../plan/buildings'; +import { + BOAT_BONE_COUNT, + BOAT_BONE_ARMS, + BOAT_BONE_HEAD, + BOAT_BONE_HULL, + BOAT_BONE_TORSO, +} from '../plan/boatMesh'; +import { loadBoatMesh } from '../plan/boatMeshLoader'; +import { computeCascades } from './cascadedShadows'; +import { createPlacement, placeBoat, placeGhost, strokePhase, strokeAmplitude } from './characterPlacement'; +import type { RideFrame } from './rideFrame'; +import { RouteCurve } from '../math/curve'; +import { GHOST_LANE_OFFSET_M } from '../../../components/rower3d/ghost'; +import { assetUrl } from '../../utils/assetUrl'; +import { createSceneryPass } from './sceneryPass'; +import { paintFoliage, FOLIAGE_SHAPES } from '../../scenery/foliageTexture'; +import { createSceneryCuller } from '../passes/sceneryCull'; +import { createFloraSolidStage } from './floraSolidStage'; +import { loadFloraAtlas } from './textureLoader'; +import { textureBytes } from './textures'; +import { createFrameGraph, type FrameGraph, type FrameUniforms, type Pass } from './frameGraph'; +import { createShadowAtlas } from './shadowAtlas'; +import { shadowConfigFor } from './shadowConfig'; +import { createShadowStage, type ShadowTelemetry } from './shadowStage'; +import { tierCapsFor } from './tierCaps'; +import { publishSpeedMps, type TelemetryHandles } from './telemetry'; +import { createPostFx, type PostFxCaps } from './postFx'; +import { + createFrameUniformBuffer, + createShadowUniformBuffer, + createSproutUniformBuffer, + FRAME_UNIFORMS_FLOATS, + packFrameUniforms, + packShadowUniforms, + packSproutUniforms, + sproutWindowFor, + type ShadowUniformsCpu, +} from './uniforms'; + +/** + * A scenery route shape the engine understands: the UI-side `route` is + * `unknown` at the seam (per the agreement with the UI thread). When a route + * is a `SceneryRoute`, the engine builds pools from `pools`; anything else + * mounts an empty scenery pass. + */ +export interface SceneryRoute { + pools: PoolInput[]; + /** + * Extruded OSM building footprints to draw as prisms (#566 FR1). Absent + * on a route with no mapped buildings — the engine then skips the + * `BuildingsPass` and leaves the sky + clouds + scenery as the only + * horizon. The engine does not call back into enrichment itself; the + * UI shell is expected to extrude footprints before passing the route + * (D1-b). + */ + buildings?: BuildingSpec[]; + /** + * Points of the route curve in world space (#570 D6). Absent on the + * smoke pages — the engine falls back to a short straight-line curve so + * the boat still has a route to run on. + */ + curvePoints?: readonly [number, number, number][]; + /** Metres represented by the full curve (defaults to the chord length). */ + totalDistanceMeters?: number; + /** + * Loop routes wrap at the ends rather than mirroring virtual endpoints; + * omitted or false means an open route. Mirrors `createRouteCurve`'s + * closed-loop detection in the React scene (`src/components/rower3d/curve.ts`). + */ + isClosedLoop?: boolean; + /** + * GLB scenery (bank-edge, scatter, buildings, trees, landform, backdrop, + * course furniture, bridges, landmarks) — one group per model id, with + * per-instance world-space transforms. The adapter computes the layout; + * attach loads each referenced GLB and feeds instances into the registry. + */ + sceneryGlbs?: Array<{ + id: string; + instances: Array<{ position: [number, number, number]; rotationY: number; scale: number }>; + }>; + /** + * Per-progress shore widths (scene units) so the water strip bends with + * the OSM-measured river. Called once per vertex column at build time, not + * per frame. Omitted → symmetric `WATER_CHANNEL_HALF_WIDTH`. + */ + waterEdgesForProgress?: (progress: number) => { left: number; right: number }; +} + +export const isSceneryRoute = (route: unknown): route is SceneryRoute => + !!route && typeof route === 'object' && Array.isArray((route as SceneryRoute).pools); + +export interface RideSceneOpts { + tier?: string; + route?: unknown; + getFrame?: () => RideFrame; + crew?: 'male' | 'female'; +} + +export interface RideSceneHandle { + readonly device: GPUDevice; + /** Draw one frame. The outer rAF loop (or the test harness) calls this. */ + frameOnce(): void; + /** + * Release every GPU resource this scene owns, including the device + * session that owns the `GPUDevice`. Safe to call more than once. + */ + dispose(): void; +} + +/** Internal clock used by the test harness. */ +const now = (): number => + typeof performance !== 'undefined' ? performance.now() : Date.now(); + +/** + * Build the per-device scene on `device` and configure `canvas` to render + * it. The scene writes its frame stats into `telemetry` as it draws; the + * outer `attach` reads those from `__ROWER3D_*` globals the same handles + * were published under. + * + * `canvas` is reused across device swaps (D1-a of #584); the engine + * reconfigures its `GPUCanvasContext` for the new device each time. + */ +export const createRideScene = async ( + canvas: HTMLCanvasElement, + device: GPUDevice, + telemetry: TelemetryHandles, + opts: RideSceneOpts, + session: { dispose(): void }, +): Promise => { + const context = canvas.getContext('webgpu') as GPUCanvasContext | null; + if (!context) { + telemetry.contextState.kind = 'unavailable'; + telemetry.contextState.reason = 'webgpu-unsupported'; + telemetry.contextState.backend = 'none'; + session.dispose(); + return null; + } + + const outputFormat: GPUTextureFormat = + (navigator as unknown as { gpu?: GPU }).gpu?.getPreferredCanvasFormat?.() ?? 'bgra8unorm'; + context.configure({ device, format: outputFormat, alphaMode: 'opaque' }); + // #573 P8 — the lit passes render into an HDR offscreen target; PostFxPass + // composites to the real swap-chain. The `format` prop every lit pass takes + // is this HDR format; `outputFormat` is only used by the composite + DOF + // target configs inside PostFxPass. + const format: GPUTextureFormat = 'rgba16float'; + + let width = Math.max(1, Math.floor(canvas.clientWidth || canvas.width || 320)); + let height = Math.max(1, Math.floor(canvas.clientHeight || canvas.height || 240)); + canvas.width = width; + canvas.height = height; + + // Scene HDR colour target (#573 FR1 / D1-a). + let sceneTexture = device.createTexture({ + label: 'scene-hdr', + size: { width, height }, + format, + usage: GPUTextureUsage.RENDER_ATTACHMENT | GPUTextureUsage.TEXTURE_BINDING, + }); + + // Depth is `depth32float` from P8 so SSAO + DOF can sample it (#573 FR9). + let depthTexture = device.createTexture({ + label: 'depth', + size: { width, height }, + format: 'depth32float', + usage: GPUTextureUsage.RENDER_ATTACHMENT | GPUTextureUsage.TEXTURE_BINDING, + }); + + const frameUniforms = createFrameUniformBuffer(device); + const uniformScratch = new Float32Array(FRAME_UNIFORMS_FLOATS); + + // #559 P4: shadow atlas + per-tier cascade config. Default tier is medium + // (3 cascades @ 1024). `setTier` is a later-phase ask (#559 FR8); for P2 + // we size on attach from opts.tier. + const shadowCfg = shadowConfigFor(opts.tier); + const atlas = createShadowAtlas(device, shadowCfg.atlasSize, shadowCfg.cascades); + telemetry.shadows.atlasSize = atlas.tileSize; + telemetry.shadows.cascades = atlas.cascades; + telemetry.shadows.atlasBytes = atlas.bytes; + + const shadowUniformsBuf = createShadowUniformBuffer(device); + const shadowScratch = new Float32Array(80); + const shadowCpu: ShadowUniformsCpu = { + cascadeViewProj: [mat4(), mat4(), mat4(), mat4()], + cascadeSplits: new Float32Array(4), + cascadeRadius: new Float32Array(4), + cascadeBlendStartRatio: 0.1, + cascadeCount: atlas.cascades, + tileSize: atlas.tileSize, + atlasWidth: atlas.width, + }; + const shadowCmpSampler = device.createSampler({ + label: 'shadow-cmp', + compare: 'less-equal', + magFilter: 'linear', + minFilter: 'linear', + }); + + // Sprout fade uniform (#562 FR6 / D6-b). The window metres are per-tier. + const sproutUniforms = createSproutUniformBuffer(device); + const sproutScratch = new Float32Array(4); + const sprout = sproutWindowFor(opts.tier); + packSproutUniforms(sprout.near, sprout.far, sproutScratch); + device.queue.writeBuffer(sproutUniforms, 0, sproutScratch as BufferSource); + + // D7-a of #555: wire the scenery pass unconditionally. + const sceneryPoolInputs: PoolInput[] = isSceneryRoute(opts.route) ? opts.route.pools : []; + // Scenery pool telemetry starts fresh on each scene build so a reattach + // doesn't double-count pools from the dead device. + telemetry.scenery.pools = []; + const scenePools: InstancePool[] = sceneryPoolInputs.map((input) => createInstancePool(device, input)); + + // #566 P6 — buildings pool. Per D5-b we skip the pool entirely when the + // route carries no `buildings`, which is what happens for a fallback + // enrichment. Tier gates then decide whether to build the pass + cast + // shadows (D8). + const tierCaps = tierCapsFor(opts.tier); + const buildingSpecs: BuildingSpec[] = + tierCaps.drawBuildings && isSceneryRoute(opts.route) && opts.route.buildings + ? opts.route.buildings + : []; + const buildingsPool: BuildingPool | null = buildingSpecs.length > 0 + ? createBuildingPool(device, buildingSpecs) + : null; + const buildingsCastingPool = tierCaps.castBuildingShadows ? buildingsPool : null; + telemetry.buildings.routes = buildingsPool ? 1 : 0; + telemetry.buildings.pool = buildingsPool?.instanceCount ?? 0; + telemetry.buildings.footprintM2 = buildingsPool?.footprintM2 ?? 0; + telemetry.buildings.resident = !!buildingsPool; + telemetry.buildings.cast = !!buildingsCastingPool; + // Per-species leaf atlas (#549 slice 3). Three 256×256 RGBA layers (one per + // FoliageShape); `paintFoliage` generates the pixels, the fragment shader + // samples by species index. The pass alpha-tests transparent texels so the + // crossed quads read as individual trees instead of flat cards. + const foliageAtlasSize = 256; + const foliageAtlasLayers = FOLIAGE_SHAPES.length; + const foliageAtlasTex = device.createTexture({ + label: 'foliage-atlas', + size: { width: foliageAtlasSize, height: foliageAtlasSize, depthOrArrayLayers: foliageAtlasLayers }, + format: 'rgba8unorm-srgb', + usage: GPUTextureUsage.TEXTURE_BINDING | GPUTextureUsage.COPY_DST, + }); + // writeTexture is on the real GPUQueue but not on the attach-test mock; + // skipping the upload leaves the atlas blank there, which is fine — no + // test depends on the leaf texture's content. + if (typeof device.queue.writeTexture === 'function') { + for (let layer = 0; layer < foliageAtlasLayers; layer += 1) { + const pixels = paintFoliage(FOLIAGE_SHAPES[layer], foliageAtlasSize); + device.queue.writeTexture( + { texture: foliageAtlasTex, origin: { x: 0, y: 0, z: layer } }, + pixels as BufferSource, + { bytesPerRow: foliageAtlasSize * 4, rowsPerImage: foliageAtlasSize }, + { width: foliageAtlasSize, height: foliageAtlasSize, depthOrArrayLayers: 1 }, + ); + } + } + const foliageAtlasView = foliageAtlasTex.createView({ dimension: '2d-array' }); + const foliageSampler = device.createSampler({ + label: 'foliage', + magFilter: 'linear', + minFilter: 'linear', + mipmapFilter: 'linear', + addressModeU: 'clamp-to-edge', + addressModeV: 'clamp-to-edge', + }); + + const sceneryPass = createSceneryPass({ + device, + format, + frameUniforms, + sproutUniforms, + pools: scenePools, + telemetry: telemetry.scenery, + foliageAtlasView, + foliageSampler, + }); + + // Solid flora stage (#562 FR4 / FR5). Builds a low-poly 3D crown+trunk per + // foliage pool and renders it alongside the sprite pass with its own cull + // + indirect, cross-fading via `sproutUniforms`. + const floraSolidCuller = createSceneryCuller(device); + const floraSolidStage = createFloraSolidStage({ + device, + format, + frameUniforms, + sproutUniforms, + culler: floraSolidCuller, + poolInputs: sceneryPoolInputs, + spritePools: scenePools, + telemetry: telemetry.scenery, + }); + for (const pool of scenePools) { + telemetry.scenery.pools.push({ + kind: pool.kind, + speciesIndex: pool.speciesIndex, + chunkIndex: pool.chunkIndex, + instanceCount: pool.instanceCount, + drawn: -1, + }); + } + + // #570 P7 D1-b — prefer the baked `shell.bin` buffer from + // `scripts/build-boat-mesh.mjs`; the procedural generator in `boatMesh.ts` + // is the deterministic fallback for SSR, tests, and fetch failures so the + // smoke silhouette is identical either way. Telemetry carries which path + // won on `__ROWER3D_BOAT.meshSource`. + const boatMeshHandle = await loadBoatMesh({ url: assetUrl('/assets/boat/shell.bin') }); + const boatMesh = boatMeshHandle.mesh; + telemetry.boat.meshSource = boatMeshHandle.source; + const boatPass = createBoatPass({ device, format, frameUniforms, mesh: boatMesh }); + const charactersMesh = tierCaps.castCharacterShadows ? boatMesh : null; + + // Scenery-mesh pass. Loads the course-furniture GLBs and keeps a registry + // keyed by `SceneryModelId`; `placeFurniture` (called below once the route + // curve is known) feeds the per-instance mat4 buffers each attach. The + // same pass also hosts the per-frame boat GLB — scull.glb has 19 primitives + // with distinct materials (hull vs rower vs oars), each registered as its + // own mesh id so the per-primitive base colours survive. + const sceneryMeshPass = createSceneryMeshPass({ device, format, frameUniforms }); + const BOAT_SCULL_PREFIX = 'boat-scull'; + const boatScullMeshIds: string[] = []; + interface BoatScullPrimitive { + id: string; + nodeWorld: Mat4; + group?: string; + pivotWorld?: Mat4; + } + const boatScullPrimitives: BoatScullPrimitive[] = []; + const COURSE_FURNITURE_MODELS: SceneryModelId[] = [ + 'a01-buoy-lane-sphere', + 'a06-finish-tower', + 'a07-distance-marker-post', + ]; + const sceneryGlbList = isSceneryRoute(opts.route) ? opts.route.sceneryGlbs ?? [] : []; + const allModelIds: Set = new Set(COURSE_FURNITURE_MODELS); + for (const group of sceneryGlbList) allModelIds.add(group.id); + const COURSE_FURNITURE_MAX_INSTANCES = 1024; + const tierFolderFor = (id: string): string => { + const prefix = id[0]?.toLowerCase() ?? 'a'; + const tier = 'abcdefgl'.includes(prefix) ? prefix : 'a'; + return `tier-${tier}`; + }; + try { + const crewGlb = opts.crew === 'female' ? 'scull-female.glb' : 'scull-male.glb'; + const res = await fetch(assetUrl(`/assets/boat/${crewGlb}`)); + if (res.ok) { + const bytes = new Uint8Array(await res.arrayBuffer()); + const model = await loadGlb(bytes, { preserveNodeTransforms: true }); + for (let i = 0; i < model.primitives.length; i += 1) { + const prim = model.primitives[i]; + const id = `${BOAT_SCULL_PREFIX}:${i}`; + sceneryMeshPass.register({ + id, + positions: prim.positions, + normals: prim.normals, + indices: prim.indices, + baseColor: prim.baseColor, + maxInstances: 1, + }); + boatScullMeshIds.push(id); + const nodeWorld = mat4(); + if (prim.nodeWorldMatrix) { + for (let k = 0; k < 16; k += 1) nodeWorld[k] = prim.nodeWorldMatrix[k]; + } else { + mat4Identity(nodeWorld); + } + let pivotWorld: Mat4 | undefined; + if (prim.pivotWorldMatrix) { + pivotWorld = mat4(); + for (let k = 0; k < 16; k += 1) pivotWorld[k] = prim.pivotWorldMatrix[k]; + } + boatScullPrimitives.push({ + id, + nodeWorld, + group: prim.groupName, + pivotWorld, + }); + } + } + } catch { /* scull GLB load failed — the old baked boat remains as fallback */ } + + await Promise.all([...allModelIds].map(async (id) => { + try { + const url = assetUrl(`/assets/scenery/${tierFolderFor(id)}/${id}.glb`); + const res = await fetch(url); + if (!res.ok) return; + const bytes = new Uint8Array(await res.arrayBuffer()); + const model = await loadGlb(bytes); + let posTotal = 0; + let nrmTotal = 0; + let idxTotal = 0; + for (const p of model.primitives) { + posTotal += p.positions.length; + nrmTotal += p.normals.length; + idxTotal += p.indices.length; + } + const positions = new Float32Array(posTotal); + const normals = new Float32Array(nrmTotal); + const needs32 = model.primitives.some((p) => p.indices instanceof Uint32Array); + const indices = needs32 ? new Uint32Array(idxTotal) : new Uint16Array(idxTotal); + let pOff = 0; + let nOff = 0; + let iOff = 0; + let vertBase = 0; + for (const p of model.primitives) { + positions.set(p.positions, pOff); + normals.set(p.normals, nOff); + for (let i = 0; i < p.indices.length; i += 1) indices[iOff + i] = p.indices[i] + vertBase; + pOff += p.positions.length; + nOff += p.normals.length; + iOff += p.indices.length; + vertBase += p.positions.length / 3; + } + sceneryMeshPass.register({ + id, + positions, + normals, + indices, + baseColor: model.primitives[0]?.baseColor ?? [1, 1, 1, 1], + maxInstances: COURSE_FURNITURE_MAX_INSTANCES, + }); + } catch { + // A fetch or decode failure leaves the model unregistered; the pass + // silently skips it rather than taking the engine down. + } + })); + + // Build the water/bank curve + edges fn FIRST so every geometry pass + // that follows the river (water strip, bank strips lit + depth, boat + // placement, course furniture, camera) can share one spline. + const waterCurveSource = isSceneryRoute(opts.route) && opts.route.curvePoints && opts.route.curvePoints.length >= 2 + ? opts.route.curvePoints + : [[0, 0, 0] as [number, number, number], [0, 0, -200] as [number, number, number]]; + const waterCurve = new RouteCurve( + waterCurveSource.map(([x, y, z]) => vec3(x, y, z)), + isSceneryRoute(opts.route) ? !!opts.route.isClosedLoop : false, + ); + const waterEdgesForProgress = isSceneryRoute(opts.route) ? opts.route.waterEdgesForProgress : undefined; + + const shadowTelemetry: ShadowTelemetry = telemetry.shadows as ShadowTelemetry; + const shadowStage = createShadowStage({ + device, + atlas, + scenePools, + buildingsPool: buildingsCastingPool, + charactersMesh, + boatPass, + telemetry: shadowTelemetry, + bankCurve: waterCurve, + bankEdgesForProgress: waterEdgesForProgress, + }); + + const shadowResources = { + uniforms: shadowUniformsBuf, + atlasView: atlas.view, + cmpSampler: shadowCmpSampler, + }; + const bankPass = createBankPass({ + device, + format, + frameUniforms, + shadow: shadowResources, + curve: waterCurve, + edgesForProgress: waterEdgesForProgress, + }); + const waterPass = createWaterPass({ + device, + format, + frameUniforms, + shadow: shadowResources, + curve: waterCurve, + edgesForProgress: waterEdgesForProgress, + }); + const buildingsPass = createBuildingsPass({ + device, + format, + frameUniforms, + shadow: shadowResources, + pool: buildingsPool, + telemetry: telemetry.buildings, + }); + const cloudsPass = tierCaps.drawClouds + ? createCloudsPass({ device, format, frameUniforms }) + : null; + + const postCaps: PostFxCaps = { + bloom: tierCaps.drawBloom, + tone: true, + vignette: tierCaps.drawVignette, + lut: tierCaps.drawLut, + ssao: tierCaps.drawSsao, + dof: + tierCaps.drawDof && + !!(globalThis as unknown as { window?: { __VIRTUALROW_POSTFX_DOF?: boolean } }).window + ?.__VIRTUALROW_POSTFX_DOF, + }; + const postFx = createPostFx({ device, sceneFormat: format, outputFormat, caps: postCaps }); + postFx.setInputs({ + sceneView: sceneTexture.createView(), + sceneTexture, + depthView: depthTexture.createView(), + width, + height, + }); + + const passes: Pass[] = [ + shadowStage, + createSkyPass({ device, format, frameUniforms }), + ...(cloudsPass ? [cloudsPass] : []), + bankPass, + buildingsPass, + sceneryPass, + floraSolidStage, + waterPass, + boatPass, + sceneryMeshPass, + postFx.pass, + ]; + const graph: FrameGraph = createFrameGraph(passes); + + const frame: FrameUniforms = { + view: mat4Identity(mat4()), + proj: mat4(), + viewProj: mat4(), + camPos: vec3(0, 2.6, 7), + sunDir: vec3(0.3, 0.8, 0.5), + fog: new Float32Array([0.001, 0.0005, 0, 0]), + time: 0, + }; + + const routeCurve = waterCurve; + const curveLength = routeCurve.approximateLength(32); + const totalDistanceMeters = + isSceneryRoute(opts.route) && typeof opts.route.totalDistanceMeters === 'number' + ? Math.max(1, opts.route.totalDistanceMeters) + : Math.max(1, curveLength); + telemetry.route.hasCurve = true; + telemetry.route.totalDistance = totalDistanceMeters; + telemetry.route.curveLength = curveLength; + + const furniture: FurniturePlacement[] = placeFurniture({ + curve: routeCurve, + routeMeters: totalDistanceMeters, + shoreHalfWidth: (progress, side) => { + if (!waterEdgesForProgress) return 10; + const edges = waterEdgesForProgress(progress); + return side === 'left' ? edges.left : edges.right; + }, + }); + const byModel = new Map(); + for (const model of COURSE_FURNITURE_MODELS) byModel.set(model, []); + const ASSET_SCALE = 0.001; + for (const place of furniture) { + const trs = mat4(); + const rot = mat4(); + const t = mat4(); + mat4FromRotationY(rot, place.rotationY); + rot[0] *= ASSET_SCALE; rot[1] *= ASSET_SCALE; rot[2] *= ASSET_SCALE; + rot[4] *= ASSET_SCALE; rot[5] *= ASSET_SCALE; rot[6] *= ASSET_SCALE; + rot[8] *= ASSET_SCALE; rot[9] *= ASSET_SCALE; rot[10] *= ASSET_SCALE; + mat4Identity(t); + t[12] = place.position[0]; + t[13] = place.position[1]; + t[14] = place.position[2]; + mat4Multiply(trs, t, rot); + byModel.get(place.id)?.push(trs); + } + for (const model of COURSE_FURNITURE_MODELS) { + sceneryMeshPass.setInstances(model, byModel.get(model) ?? []); + } + + for (const group of sceneryGlbList) { + const mats: Mat4[] = []; + for (const inst of group.instances) { + const trs = mat4(); + const rot = mat4(); + const t = mat4(); + mat4FromRotationY(rot, inst.rotationY); + rot[0] *= inst.scale; rot[1] *= inst.scale; rot[2] *= inst.scale; + rot[4] *= inst.scale; rot[5] *= inst.scale; rot[6] *= inst.scale; + rot[8] *= inst.scale; rot[9] *= inst.scale; rot[10] *= inst.scale; + mat4Identity(t); + t[12] = inst.position[0]; + t[13] = inst.position[1]; + t[14] = inst.position[2]; + mat4Multiply(trs, t, rot); + mats.push(trs); + } + sceneryMeshPass.setInstances(group.id, mats); + } + + const cameraRig = createRigTarget(); + const boatPlacement = createPlacement(); + const ghostPlacement = createPlacement(); + const boatBones = [mat4(), mat4(), mat4(), mat4()]; + const ghostBones = [mat4(), mat4(), mat4(), mat4()]; + for (let i = 0; i < BOAT_BONE_COUNT; i++) { + mat4Identity(boatBones[i]); + mat4Identity(ghostBones[i]); + } + const BOAT_TINT = new Float32Array([0.78, 0.78, 0.82, 1.0]); + const GHOST_TINT = new Float32Array([0.35, 0.85, 1.0, 0.5]); + + const start = now(); + let disposed = false; + + const resize = (): void => { + const w = Math.max(1, Math.floor(canvas.clientWidth || canvas.width || width)); + const h = Math.max(1, Math.floor(canvas.clientHeight || canvas.height || height)); + if (w === width && h === height) return; + width = w; + height = h; + canvas.width = w; + canvas.height = h; + depthTexture.destroy(); + depthTexture = device.createTexture({ + label: 'depth', + size: { width: w, height: h }, + format: 'depth32float', + usage: GPUTextureUsage.RENDER_ATTACHMENT | GPUTextureUsage.TEXTURE_BINDING, + }); + sceneTexture.destroy(); + sceneTexture = device.createTexture({ + label: 'scene-hdr', + size: { width: w, height: h }, + format, + usage: GPUTextureUsage.RENDER_ATTACHMENT | GPUTextureUsage.TEXTURE_BINDING, + }); + postFx.setInputs({ + sceneView: sceneTexture.createView(), + sceneTexture, + depthView: depthTexture.createView(), + width: w, + height: h, + }); + }; + + let lastDistanceMeters = 0; + let lastSpeedTime = -1; + let smoothedDistance = 0; + let lastDistSample = { d: 0, t: now() }; + let prevDistSample = { d: 0, t: now() }; + + const frameOnce = (): void => { + if (disposed) return; + // #584 FR3 — cpuMs is the CPU-side encode time. Measured end-to-end so + // the frame-graph internal work (pass encodes + submit) plus the engine's + // uniform packs are inside the window. Follow-up: when `timestamp-query` + // is live, publish gpuMs from the resolved query buffer; today the device + // asks for the feature (device.ts) but no pass writes a timestamp yet. + const cpuStart = now(); + resize(); + frame.time = (now() - start) / 1000; + + const rideFrame: RideFrame | null = opts.getFrame ? opts.getFrame() : null; + + if (rideFrame) { + const nowMs = now(); + if (rideFrame.distanceMeters !== lastDistSample.d) { + prevDistSample = lastDistSample; + lastDistSample = { d: rideFrame.distanceMeters, t: nowMs }; + smoothedDistance = rideFrame.distanceMeters; + } else if (rideFrame.isPlaying && !rideFrame.holdBoat && !rideFrame.finished) { + const dtSample = Math.max(1e-3, (lastDistSample.t - prevDistSample.t) / 1000); + const speedMps = (lastDistSample.d - prevDistSample.d) / dtSample; + const dtExtrap = (nowMs - lastDistSample.t) / 1000; + const capped = Math.min(dtExtrap, dtSample * 1.5); + smoothedDistance = lastDistSample.d + speedMps * capped; + } + } + const smoothedFrame: RideFrame | null = rideFrame + ? { ...rideFrame, distanceMeters: smoothedDistance } + : null; + + placeBoat(boatPlacement, routeCurve, smoothedFrame, totalDistanceMeters, curveLength, 0); + const ghostInfo = placeGhost( + ghostPlacement, + routeCurve, + rideFrame?.ghost ?? null, + rideFrame?.elapsedSeconds ?? frame.time, + totalDistanceMeters, + curveLength, + -GHOST_LANE_OFFSET_M, + ); + + const phase = strokePhase(rideFrame?.cadence ?? 0, frame.time, rideFrame?.isPlaying ?? false); + const amp = strokeAmplitude(rideFrame); + const torsoLean = Math.sin(phase * Math.PI * 2) * 0.35 * amp; + const armPull = Math.sin(phase * Math.PI * 2 + 0.5) * 0.6 * amp; + const headNod = Math.sin(phase * Math.PI * 2) * 0.1 * amp; + mat4Identity(boatBones[BOAT_BONE_HULL]); + mat4FromRotationY(boatBones[BOAT_BONE_TORSO], torsoLean); + mat4FromRotationY(boatBones[BOAT_BONE_ARMS], armPull); + mat4FromRotationY(boatBones[BOAT_BONE_HEAD], headNod); + for (let i = 0; i < BOAT_BONE_COUNT; i++) mat4Identity(ghostBones[i]); + + if (!tierCaps.drawCrew) { + mat4Identity(boatBones[BOAT_BONE_TORSO]); + mat4Identity(boatBones[BOAT_BONE_ARMS]); + mat4Identity(boatBones[BOAT_BONE_HEAD]); + } + const scullLoaded = boatScullMeshIds.length > 0; + boatPass.boatVisible.value = !scullLoaded; + boatPass.ghostVisible.value = ghostInfo !== null; + + if (scullLoaded) { + const boatTrs = mat4(); + const scullRot = mat4(); + const scullTrans = mat4(); + mat4FromRotationY(scullRot, boatPlacement.heading); + mat4Identity(scullTrans); + scullTrans[12] = boatPlacement.world[0]; + scullTrans[13] = boatPlacement.world[1]; + scullTrans[14] = boatPlacement.world[2]; + mat4Multiply(boatTrs, scullTrans, scullRot); + + const cadence = rideFrame?.cadence ?? 0; + const elapsed = rideFrame?.elapsedSeconds ?? 0; + const playing = rideFrame?.isPlaying ?? false; + const phaseSweep = strokePhase(cadence, elapsed, playing); + const ampSweep = strokeAmplitude(rideFrame); + const SWEEP_PEAK_RAD = Math.PI / 4; + const sweep = Math.sin(phaseSweep * Math.PI * 2) * SWEEP_PEAK_RAD * ampSweep; + + const nodeAfterSwing = mat4(); + const toPivot = mat4(); + const fromPivot = mat4(); + const rotSweep = mat4(); + const tmp = mat4(); + const perInstance = mat4(); + + for (const prim of boatScullPrimitives) { + if (prim.group && prim.pivotWorld) { + const sign = prim.group === 'RightOar' ? -1 : 1; + mat4FromRotationY(rotSweep, sweep * sign); + + const px = prim.pivotWorld[12]; + const py = prim.pivotWorld[13]; + const pz = prim.pivotWorld[14]; + mat4Identity(toPivot); + toPivot[12] = -px; + toPivot[13] = -py; + toPivot[14] = -pz; + mat4Identity(fromPivot); + fromPivot[12] = px; + fromPivot[13] = py; + fromPivot[14] = pz; + + mat4Multiply(tmp, toPivot, prim.nodeWorld); + mat4Multiply(nodeAfterSwing, rotSweep, tmp); + mat4Multiply(tmp, fromPivot, nodeAfterSwing); + mat4Multiply(perInstance, boatTrs, tmp); + } else { + mat4Multiply(perInstance, boatTrs, prim.nodeWorld); + } + sceneryMeshPass.setInstances(prim.id, [perInstance]); + } + } + boatPass.boat.write(device, { + model: boatPlacement.modelMatrix, + bones: boatBones, + tintRGBA: BOAT_TINT, + ghost: false, + visible: true, + }); + if (ghostInfo) { + boatPass.ghost.write(device, { + model: ghostPlacement.modelMatrix, + bones: ghostBones, + tintRGBA: GHOST_TINT, + ghost: true, + visible: true, + }); + } + + telemetry.boat.x = boatPlacement.world[0]; + telemetry.boat.y = boatPlacement.world[1]; + telemetry.boat.z = boatPlacement.world[2]; + telemetry.boat.heading = boatPlacement.heading; + telemetry.boat.distanceMeters = boatPlacement.distanceMeters; + telemetry.boat.progress = boatPlacement.progress; + telemetry.boat.holding = !!rideFrame?.holdBoat; + telemetry.boat.finished = !!rideFrame?.finished; + telemetry.boat.isPlaying = !!rideFrame?.isPlaying; + + let speedMps = 0; + if (!telemetry.boat.holding && !telemetry.boat.finished && telemetry.boat.isPlaying) { + if (lastSpeedTime >= 0) { + const dt = Math.max(1e-3, frame.time - lastSpeedTime); + const dd = telemetry.boat.distanceMeters - lastDistanceMeters; + speedMps = Math.max(0, dd / dt); + } + } + lastDistanceMeters = telemetry.boat.distanceMeters; + lastSpeedTime = frame.time; + publishSpeedMps(telemetry, speedMps); + telemetry.pos.x = boatPlacement.world[0]; + telemetry.pos.y = boatPlacement.world[1]; + telemetry.pos.z = boatPlacement.world[2]; + telemetry.pos.progress = boatPlacement.progress; + telemetry.crew.cadenceSpm = Math.max(0, rideFrame?.cadence ?? 0); + telemetry.crew.strokePhase = phase; + telemetry.crew.amplitude = amp; + telemetry.crew.rowers = 1; + if (ghostInfo) { + telemetry.ghostMeters.value = ghostInfo.metres; + telemetry.ghostMeters.active = true; + } else { + telemetry.ghostMeters.value = 0; + telemetry.ghostMeters.active = false; + } + + const camBoat = vec3(boatPlacement.world[0], boatPlacement.world[1], boatPlacement.world[2]); + const camTangent = vec3(); + const camSample = routeCurve.sample(boatPlacement.progress); + camTangent[0] = camSample.tangent[0]; + camTangent[1] = camSample.tangent[1]; + camTangent[2] = camSample.tangent[2]; + cameraTarget( + { boat: camBoat, tangent: camTangent, velocityMps: speedMps, view: 'chase', bendSign: 0 }, + cameraRig, + ); + const eye = cameraRig.position; + const target = cameraRig.lookAt; + const up = vec3(0, 1, 0); + mat4LookAt(frame.view, eye, target, up); + mat4Perspective(frame.proj, (cameraRig.fov * Math.PI) / 180, width / height, 0.1, 2000); + mat4Multiply(frame.viewProj, frame.proj, frame.view); + frame.camPos[0] = eye[0]; + frame.camPos[1] = eye[1]; + frame.camPos[2] = eye[2]; + + packFrameUniforms(frame, uniformScratch); + device.queue.writeBuffer(frameUniforms, 0, uniformScratch as BufferSource); + + const computed = computeCascades({ + cascades: atlas.cascades, + near: 0.5, + far: 400, + fovY: (58 * Math.PI) / 180, + aspect: width / height, + camPos: eye, + camForward: vec3(target[0] - eye[0], target[1] - eye[1], target[2] - eye[2]), + camUp: up, + lightDirection: vec3(-frame.sunDir[0], -frame.sunDir[1], -frame.sunDir[2]), + lambda: 0.5, + tileTexels: atlas.tileSize, + }); + shadowStage.payload.cascadeCount = atlas.cascades; + for (let i = 0; i < 4; i++) { + const dst = shadowStage.payload.cascades[i] ?? { viewProj: mat4() }; + shadowStage.payload.cascades[i] = dst; + if (i < atlas.cascades) { + dst.viewProj.set(computed.cascades[i].viewProj); + shadowCpu.cascadeViewProj[i].set(computed.cascades[i].viewProj); + shadowCpu.cascadeSplits[i] = computed.splits[i] ?? 0; + shadowCpu.cascadeRadius[i] = computed.cascades[i].radius; + } else { + shadowCpu.cascadeViewProj[i].fill(0); + shadowCpu.cascadeSplits[i] = 0; + shadowCpu.cascadeRadius[i] = 0; + } + } + shadowCpu.cascadeCount = atlas.cascades; + packShadowUniforms(shadowCpu, shadowScratch); + device.queue.writeBuffer(shadowUniformsBuf, 0, shadowScratch as BufferSource); + telemetry.shadows.lastSplits = Array.from(computed.splits); + + const outputView = context.getCurrentTexture().createView(); + const sceneView = sceneTexture.createView(); + const depthView = depthTexture.createView(); + graph.run(device, sceneView, depthView, width, height, frame, outputView); + + telemetry.frameStats.frame += 1; + telemetry.frameStats.dtMs = 0; + telemetry.frameStats.cpuMs = now() - cpuStart; + // gpuMs is set by the timestamp-query readback path once pass instrumenting + // lands (follow-up on #584 AC3); it stays 0 here otherwise. + let draws = 0; + for (const p of graph.passes) { + if (p.name.startsWith('shadow')) continue; + if (p.name === 'postfx') continue; + if (p.name === 'boat' && !boatPass.boatVisible.value && !boatPass.ghostVisible.value) continue; + draws += 1; + } + telemetry.renderStats.draws = draws; + telemetry.renderStats.sceneryDraws = telemetry.scenery.drawCount; + telemetry.renderStats.sceneryDispatch = telemetry.scenery.dispatchCount; + telemetry.renderStats.sceneryTriangles = + telemetry.scenery.spriteDraws * 4 /* two crossed quads per instance */ + + telemetry.scenery.solidTriangles; + telemetry.renderStats.shadowDraws = telemetry.shadows.draws; + telemetry.renderStats.shadowTriangles = telemetry.shadows.triangles; + telemetry.renderStats.shadowAtlasBytes = telemetry.shadows.atlasBytes; + let trees = 0; + let meshes = 0; + for (const p of telemetry.scenery.pools) { + if (p.kind !== 'foliage') continue; + trees += p.instanceCount; + meshes += 1; + } + telemetry.foliage.trees = trees; + telemetry.foliage.meshes = meshes; + telemetry.foliage.drawn = trees; + telemetry.camera.x = eye[0]; + telemetry.camera.y = eye[1]; + telemetry.camera.z = eye[2]; + telemetry.camera.lookX = target[0]; + telemetry.camera.lookY = target[1]; + telemetry.camera.lookZ = target[2]; + }; + + // Lazy flora atlas load (#562 FR2 / FR3). The decoder lives in its own + // chunk — the engine's main chunk doesn't pull it unless a KTX2 asset is + // actually fetched. The texture itself is published to telemetry so the + // smoke can read atlasKind + atlasBytes without a GPU readback. + type AtlasHandle = Awaited>; + let atlasHandle: AtlasHandle | null = null; + void loadFloraAtlas(device, opts.tier, { + url: assetUrl('/assets/flora/atlas'), + pngWidth: 512, + pngHeight: 512, + }) + .then((handle) => { + if (disposed) { + handle.texture.dispose(); + return; + } + atlasHandle = handle; + telemetry.textures.atlasKind = handle.kind; + telemetry.textures.transcodedMs = handle.transcodedMs; + telemetry.textures.mipsGenerated = handle.texture.mipLevelCount; + const bytesFmt = handle.kind === 'ktx2' ? 'bc7-rgba-unorm-srgb' : 'rgba8unorm-srgb'; + telemetry.textures.atlasBytes = textureBytes( + handle.texture.width, + handle.texture.height, + bytesFmt, + handle.texture.mipLevelCount, + ); + }) + .catch(() => { + telemetry.textures.atlasKind = 'placeholder'; + }); + + return { + device, + frameOnce, + dispose() { + if (disposed) return; + disposed = true; + graph.dispose(); + for (const pool of scenePools) pool.dispose(); + buildingsPool?.dispose(); + floraSolidCuller.dispose(); + postFx.dispose(); + sceneTexture.destroy(); + depthTexture.destroy(); + frameUniforms.destroy(); + shadowUniformsBuf.destroy(); + sproutUniforms.destroy(); + atlasHandle?.texture.dispose(); + atlas.dispose(); + session.dispose(); + }, + }; +}; diff --git a/src/lib/renderer/engine/telemetry.ts b/src/lib/renderer/engine/telemetry.ts index 56f36e9d..ddc647fa 100644 --- a/src/lib/renderer/engine/telemetry.ts +++ b/src/lib/renderer/engine/telemetry.ts @@ -28,7 +28,17 @@ export interface Rower3DRenderStats { export interface Rower3DFrameStats { frame: number; dtMs: number; + /** + * GPU-side frame time read back from the `timestamp-query` feature when + * the adapter has it (D6-c of #584 FR3). Zero when the feature is absent. + */ gpuMs: number; + /** + * CPU-side frame-encode time measured via `performance.now()` around the + * engine's command-buffer build + submit. Always populated — the fallback + * for `gpuMs: 0` on an adapter without `timestamp-query` (D6-c of #584 FR3). + */ + cpuMs: number; } export interface Rower3DCamera { @@ -42,11 +52,33 @@ export interface Rower3DCamera { } export interface Rower3DContextState { - kind: 'pending' | 'ready' | 'unavailable'; + /** + * `lost` is #584 FR1: the current device has signalled `device.lost` and + * the engine is attempting a reattach. The frame loop stops drawing to + * the dead device while in `lost`; on retry success it goes back to + * `ready`, on retry failure it goes to `unavailable` with + * reason `renderer-unavailable`. + */ + kind: 'pending' | 'ready' | 'lost' | 'unavailable'; backend: 'webgpu' | 'webgl' | 'none'; reason?: string; } +/** + * #584 FR1 — user-facing banner the Svelte ride view shows while the engine + * is handling a device loss. `visible` + `message` map 1:1 to the retired + * WebGL spec's banner copy (D2-a). + */ +export interface Rower3DBanner { + visible: boolean; + message: string; + /** + * `restoring` while the engine is attempting a reattach; `failed` once the + * reattach has run out of tries; empty string outside a loss event. + */ + variant: '' | 'restoring' | 'failed'; +} + export interface Rower3DPos { x: number; y: number; @@ -206,6 +238,7 @@ export interface TelemetryHandles { crew: Rower3DCrew; ghostMeters: Rower3DGhostMetersHandle; speed: Rower3DSpeedHandle; + banner: Rower3DBanner; } // Keep this a mutable record view of `window`: on `main` these globals carry @@ -244,7 +277,7 @@ export const createTelemetry = (forceRender: () => void): TelemetryHandles => { }); const handles: TelemetryHandles = { renderStats: renderStats as Rower3DRenderStats, - frameStats: { frame: 0, dtMs: 0, gpuMs: 0 }, + frameStats: { frame: 0, dtMs: 0, gpuMs: 0, cpuMs: 0 }, camera: { x: 0, y: 0, z: 0, lookX: 0, lookY: 0, lookZ: 0, fov: 58 }, contextState: { kind: 'pending', backend: 'none' }, pos: { x: 0, y: 0, z: 0, progress: 0 }, @@ -293,6 +326,7 @@ export const createTelemetry = (forceRender: () => void): TelemetryHandles => { crew: { cadenceSpm: 0, strokePhase: 0, amplitude: 0, rowers: 1 }, ghostMeters: { value: 0, active: false }, speed: { value: 0 }, + banner: { visible: false, message: '', variant: '' }, }; const w = win(); if (w) { @@ -317,6 +351,10 @@ export const createTelemetry = (forceRender: () => void): TelemetryHandles => { // read it with a simple compare; the engine re-assigns the global each // frame via `publishSpeedMps` below. w['__ROWER3D_SPEED_MPS'] = 0; + // #584 FR1 — the Svelte ride view reads this to render the user-facing + // "lost the graphics context" / "restoring" / "could not get it back" + // banner. The engine owns the message copy (D2-a). + w['__ROWER3D_BANNER'] = handles.banner; } return handles; }; @@ -331,6 +369,17 @@ export const publishSpeedMps = (handles: TelemetryHandles, speedMps: number): vo if (w) w['__ROWER3D_SPEED_MPS'] = speedMps; }; +/** #584 FR1 — set the user-facing banner on a device-loss transition. */ +export const publishBanner = ( + handles: TelemetryHandles, + message: string, + variant: Rower3DBanner['variant'], +): void => { + handles.banner.visible = message.length > 0; + handles.banner.message = message; + handles.banner.variant = variant; +}; + export const releaseTelemetry = (): void => { const w = win(); if (!w) return; @@ -353,6 +402,7 @@ export const releaseTelemetry = (): void => { '__ROWER3D_CREW', '__ROWER3D_GHOST_METERS', '__ROWER3D_SPEED_MPS', + '__ROWER3D_BANNER', ]) { delete w[key]; } diff --git a/src/routes/row/+page.svelte b/src/routes/row/+page.svelte index 22413595..ecef066a 100644 --- a/src/routes/row/+page.svelte +++ b/src/routes/row/+page.svelte @@ -70,6 +70,35 @@ // rest of the site stays reachable through the Routes link. let canvasBackend = $state('stub'); + // #584 FR1 — on a device loss the engine publishes a user-facing banner on + // `window.__ROWER3D_BANNER`. We poll it at 250 ms so a reader sees the + // "restoring" state as soon as it lands, without the engine having to + // reach into Svelte state. The copy itself lives in attach.ts (D2-a). + let banner = $state<{ visible: boolean; message: string; variant: string }>({ + visible: false, + message: '', + variant: '', + }); + $effect(() => { + if (typeof window === 'undefined') return; + const read = (): void => { + const b = (window as unknown as { + __ROWER3D_BANNER?: { visible: boolean; message: string; variant: string }; + }).__ROWER3D_BANNER; + if (!b) return; + if ( + b.visible !== banner.visible || + b.message !== banner.message || + b.variant !== banner.variant + ) { + banner = { visible: b.visible, message: b.message, variant: b.variant }; + } + }; + read(); + const id = window.setInterval(read, 250); + return () => window.clearInterval(id); + }); + // Crewed-scull selection (#232 / #443): a signed-in profile with a `sex` // field wins, otherwise the stored CrewPreference decides, otherwise male. // The engine stays free of this policy — it just loads the GLB url the @@ -103,6 +132,21 @@ {/if} + + {#if banner.visible} +
+ {banner.message} +
+ {/if} +