From bc42a2f13be828a430d509007de86c7aeeb5640e Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 26 Oct 2026 10:46:36 +0000 Subject: [PATCH 1/5] #542 WebGPU scenery: draw each GLB primitive in its own colour MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The scenery-mesh pass collapsed every primitive of a GLB into one mesh and painted the whole thing in primitive-0's baseColor, so multi-material models (trunk/leaves, walls/roof/windows) rendered as a single pale block. glTF splits a model into one primitive per material; bake each primitive's colour per vertex and carry it through sceneryMesh.wgsl as a vertex attribute, so parts stay distinct — matching how the React scene binds per-primitive materials. Single-colour callers (boat primitives) omit `colors` and the pass fills every vertex from `baseColor`. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01Q5D9BTXmu7SeMa3LghJD8b --- .../__tests__/sceneryMeshBuffers.test.ts | 76 +++++++++++++++++++ src/lib/renderer/engine/rideScene.ts | 37 +++------ src/lib/renderer/engine/sceneryMeshBuffers.ts | 74 ++++++++++++++++++ .../passes/__tests__/sceneryMesh.test.ts | 43 ++++++++++- src/lib/renderer/passes/sceneryMesh.ts | 46 ++++++++--- src/lib/renderer/passes/sceneryMesh.wgsl | 17 ++--- 6 files changed, 243 insertions(+), 50 deletions(-) create mode 100644 src/lib/renderer/engine/__tests__/sceneryMeshBuffers.test.ts create mode 100644 src/lib/renderer/engine/sceneryMeshBuffers.ts diff --git a/src/lib/renderer/engine/__tests__/sceneryMeshBuffers.test.ts b/src/lib/renderer/engine/__tests__/sceneryMeshBuffers.test.ts new file mode 100644 index 00000000..37678b0d --- /dev/null +++ b/src/lib/renderer/engine/__tests__/sceneryMeshBuffers.test.ts @@ -0,0 +1,76 @@ +import { describe, expect, it } from 'vitest'; +import { mergeSceneryPrimitives } from '../sceneryMeshBuffers'; +import type { GlbPrimitive } from '../../../scenery/glbLoader'; + +const prim = (over: Partial): GlbPrimitive => ({ + positions: over.positions ?? new Float32Array([0, 0, 0]), + normals: over.normals ?? new Float32Array([0, 1, 0]), + indices: over.indices ?? new Uint16Array([0]), + baseColor: over.baseColor ?? [1, 1, 1, 1], +}); + +describe('mergeSceneryPrimitives', () => { + it('keeps each primitive its own colour (per-vertex), not primitive-0 for all', () => { + // Two triangles: primitive 0 is "trunk brown", primitive 1 is "leaves green". + const trunk = prim({ + positions: new Float32Array([0, 0, 0, 1, 0, 0, 0, 1, 0]), // 3 verts + normals: new Float32Array([0, 1, 0, 0, 1, 0, 0, 1, 0]), + indices: new Uint16Array([0, 1, 2]), + baseColor: [0.4, 0.26, 0.13, 1], + }); + const leaves = prim({ + positions: new Float32Array([0, 2, 0, 1, 2, 0, 0, 3, 0]), // 3 verts + normals: new Float32Array([0, 1, 0, 0, 1, 0, 0, 1, 0]), + indices: new Uint16Array([0, 1, 2]), + baseColor: [0.1, 0.6, 0.15, 1], + }); + + const merged = mergeSceneryPrimitives([trunk, leaves]); + + // 6 vertices total → 24 colour floats (vec4 each). + expect(merged.colors.length).toBe(24); + // Expected values round-tripped through Float32 so the comparison is exact. + const trunkRgba = Array.from(new Float32Array([0.4, 0.26, 0.13, 1])); + const leavesRgba = Array.from(new Float32Array([0.1, 0.6, 0.15, 1])); + // First 3 verts carry the trunk colour … + expect(Array.from(merged.colors.slice(0, 4))).toEqual(trunkRgba); + expect(Array.from(merged.colors.slice(4, 8))).toEqual(trunkRgba); + expect(Array.from(merged.colors.slice(8, 12))).toEqual(trunkRgba); + // … the next 3 carry the leaves colour (regression: the old code painted + // all 6 with primitive-0's trunk colour). + expect(Array.from(merged.colors.slice(12, 16))).toEqual(leavesRgba); + expect(Array.from(merged.colors.slice(20, 24))).toEqual(leavesRgba); + }); + + it('concatenates positions/normals and offsets indices by each primitive vertex base', () => { + const a = prim({ + positions: new Float32Array([0, 0, 0, 1, 0, 0, 0, 1, 0]), + indices: new Uint16Array([0, 1, 2]), + }); + const b = prim({ + positions: new Float32Array([5, 0, 0, 6, 0, 0, 5, 1, 0]), + indices: new Uint16Array([0, 1, 2]), + }); + const merged = mergeSceneryPrimitives([a, b]); + expect(merged.positions.length).toBe(18); + // Second primitive's indices are offset by the 3 vertices of the first. + expect(Array.from(merged.indices)).toEqual([0, 1, 2, 3, 4, 5]); + }); + + it('widens to Uint32 indices when a primitive already uses them', () => { + const a = prim({ indices: new Uint16Array([0]) }); + const b = prim({ indices: new Uint32Array([0]) }); + const merged = mergeSceneryPrimitives([a, b]); + expect(merged.indices).toBeInstanceOf(Uint32Array); + }); + + it('handles a single primitive', () => { + const merged = mergeSceneryPrimitives([prim({ + positions: new Float32Array([0, 0, 0, 1, 0, 0, 0, 1, 0]), + indices: new Uint16Array([0, 1, 2]), + baseColor: [0.2, 0.2, 0.9, 1], + })]); + expect(merged.colors.length).toBe(12); + expect(Array.from(merged.colors.slice(0, 4))).toEqual(Array.from(new Float32Array([0.2, 0.2, 0.9, 1]))); + }); +}); diff --git a/src/lib/renderer/engine/rideScene.ts b/src/lib/renderer/engine/rideScene.ts index 50056276..fbbbe7a8 100644 --- a/src/lib/renderer/engine/rideScene.ts +++ b/src/lib/renderer/engine/rideScene.ts @@ -58,6 +58,7 @@ import { RouteCurve } from '../math/curve'; import { GHOST_LANE_OFFSET_M } from '../../../components/rower3d/ghost'; import { assetUrl } from '../../utils/assetUrl'; import { createSceneryPass } from './sceneryPass'; +import { mergeSceneryPrimitives } from './sceneryMeshBuffers'; import { paintFoliage, FOLIAGE_SHAPES } from '../../scenery/foliageTexture'; import { createSceneryCuller } from '../passes/sceneryCull'; import { createFloraSolidStage } from './floraSolidStage'; @@ -431,36 +432,16 @@ export const createRideScene = async ( 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; - } + // Merge the model's primitives, baking each primitive's base colour per + // vertex so a multi-material model (trunk/leaves, walls/roof/windows) + // keeps its parts distinct instead of collapsing to primitive-0's colour. + const merged = mergeSceneryPrimitives(model.primitives); sceneryMeshPass.register({ id, - positions, - normals, - indices, + positions: merged.positions, + normals: merged.normals, + indices: merged.indices, + colors: merged.colors, baseColor: model.primitives[0]?.baseColor ?? [1, 1, 1, 1], maxInstances: COURSE_FURNITURE_MAX_INSTANCES, }); diff --git a/src/lib/renderer/engine/sceneryMeshBuffers.ts b/src/lib/renderer/engine/sceneryMeshBuffers.ts new file mode 100644 index 00000000..82dfa59e --- /dev/null +++ b/src/lib/renderer/engine/sceneryMeshBuffers.ts @@ -0,0 +1,74 @@ +/** + * Merge a GLB model's primitives into the single vertex/index/colour set the + * scenery-mesh pass draws per instance. + * + * The GLB kit is flat-colour PBR (one `baseColor` per primitive, no textures). + * glTF splits a model into one primitive per material — trunk vs leaves, walls + * vs roof vs windows — so a faithful render needs each primitive's own colour. + * The engine draws a model as one instanced mesh, so we bake the per-primitive + * colour into a per-VERTEX colour attribute here rather than carrying a single + * uniform colour (which collapsed every part to primitive-0's colour and made + * buildings/landform read as one pale block). + */ + +import type { GlbPrimitive } from '../../scenery/glbLoader'; + +export interface MergedSceneryMesh { + /** Interleaved vec3, all primitives concatenated. */ + positions: Float32Array; + /** Interleaved vec3, matching `positions`. */ + normals: Float32Array; + /** Indices offset per primitive by its vertex base. */ + indices: Uint16Array | Uint32Array; + /** vec4 per vertex: the owning primitive's `baseColor`. */ + colors: Float32Array; +} + +export const mergeSceneryPrimitives = ( + primitives: readonly GlbPrimitive[], +): MergedSceneryMesh => { + let posTotal = 0; + let nrmTotal = 0; + let idxTotal = 0; + for (const p of primitives) { + posTotal += p.positions.length; + nrmTotal += p.normals.length; + idxTotal += p.indices.length; + } + const vertexCount = posTotal / 3; + + const positions = new Float32Array(posTotal); + const normals = new Float32Array(nrmTotal); + const colors = new Float32Array(vertexCount * 4); + // 16-bit indices overflow once the merged model passes 65 536 vertices, so + // widen if any primitive already needs 32-bit or the merged count demands it. + const needs32 = + vertexCount > 65535 || 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 cOff = 0; + let vertBase = 0; + for (const p of 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; + const primVerts = p.positions.length / 3; + const [r, g, b, a] = p.baseColor; + for (let v = 0; v < primVerts; v += 1) { + colors[cOff] = r; + colors[cOff + 1] = g; + colors[cOff + 2] = b; + colors[cOff + 3] = a; + cOff += 4; + } + pOff += p.positions.length; + nOff += p.normals.length; + iOff += p.indices.length; + vertBase += primVerts; + } + + return { positions, normals, indices, colors }; +}; diff --git a/src/lib/renderer/passes/__tests__/sceneryMesh.test.ts b/src/lib/renderer/passes/__tests__/sceneryMesh.test.ts index 6478d9a6..98426fef 100644 --- a/src/lib/renderer/passes/__tests__/sceneryMesh.test.ts +++ b/src/lib/renderer/passes/__tests__/sceneryMesh.test.ts @@ -34,7 +34,7 @@ const makePass = () => { const drawIndexed = vi.fn(); const end = vi.fn(); const pass = { setBindGroup, setPipeline, setVertexBuffer, setIndexBuffer, drawIndexed, end } as unknown as GPURenderPassEncoder; - return { pass, setBindGroup, setPipeline, drawIndexed, setIndexBuffer, end }; + return { pass, setBindGroup, setPipeline, setVertexBuffer, drawIndexed, setIndexBuffer, end }; }; const makeDevice = () => { @@ -61,6 +61,7 @@ const makeInput = (over: Partial = {}): SceneryMeshInput => ({ normals: over.normals ?? new Float32Array([0, 1, 0, 0, 1, 0, 0, 1, 0]), indices: over.indices ?? new Uint16Array([0, 1, 2]), baseColor: over.baseColor ?? [0.3, 0.5, 0.4, 1], + ...(over.colors ? { colors: over.colors } : {}), maxInstances: over.maxInstances ?? 4, }); @@ -143,6 +144,46 @@ describe('createSceneryMeshPass', () => { expect(end).toHaveBeenCalled(); }); + it('uploads a supplied per-vertex colour buffer verbatim', () => { + const { device, queue } = makeDevice(); + const p = createSceneryMeshPass({ device, format: 'bgra8unorm', frameUniforms: makeBuffer() }); + // 3 vertices → 12 colour floats: part A brown, parts B/C green. + const colors = new Float32Array([0.4, 0.26, 0.13, 1, 0.1, 0.6, 0.15, 1, 0.1, 0.6, 0.15, 1]); + p.register(makeInput({ id: 'tree', colors })); + const calls = (queue.writeBuffer as unknown as { mock: { calls: unknown[][] } }).mock.calls; + // The colour buffer is written with the exact array we supplied. + expect(calls.some((c) => c[2] === colors)).toBe(true); + }); + + it('synthesizes a per-vertex colour buffer from baseColor when colors omitted', () => { + const { device, queue } = makeDevice(); + const p = createSceneryMeshPass({ device, format: 'bgra8unorm', frameUniforms: makeBuffer() }); + // Default input has 3 vertices (9 position floats) and no colors. + p.register(makeInput({ id: 'block', baseColor: [0.5, 0.25, 0.75, 1] })); + const calls = (queue.writeBuffer as unknown as { mock: { calls: unknown[][] } }).mock.calls; + // The only length-12 Float32Array write is the synthesized colour buffer. + const colourWrite = calls.find((c) => c[2] instanceof Float32Array && (c[2] as Float32Array).length === 12); + expect(colourWrite).toBeTruthy(); + const arr = colourWrite![2] as Float32Array; + expect(Array.from(arr.slice(0, 4))).toEqual([0.5, 0.25, 0.75, 1]); + expect(Array.from(arr.slice(8, 12))).toEqual([0.5, 0.25, 0.75, 1]); + }); + + it('encode binds the colour vertex buffer at slot 2', () => { + const { device } = makeDevice(); + const p = createSceneryMeshPass({ device, format: 'bgra8unorm', frameUniforms: makeBuffer() }); + p.register(makeInput({ id: 'buoy' })); + p.setInstances('buoy', [mat4Identity(mat4())]); + const { pass, setVertexBuffer } = makePass(); + const beginRenderPass = vi.fn(() => pass); + p.encode({ + encoder: { beginRenderPass }, + swapView: {}, + depthView: {}, + } as unknown as Parameters[0]); + expect(setVertexBuffer.mock.calls.some((c) => c[0] === 2)).toBe(true); + }); + it('dispose destroys every registered mesh buffer and clears the registry', () => { const { device } = makeDevice(); const p = createSceneryMeshPass({ device, format: 'bgra8unorm', frameUniforms: makeBuffer() }); diff --git a/src/lib/renderer/passes/sceneryMesh.ts b/src/lib/renderer/passes/sceneryMesh.ts index a97a78d1..dc0f2755 100644 --- a/src/lib/renderer/passes/sceneryMesh.ts +++ b/src/lib/renderer/passes/sceneryMesh.ts @@ -14,8 +14,6 @@ import sceneryMeshWgsl from './sceneryMesh.wgsl?raw'; /** One mat4 (16 floats = 64 B) per instance. */ const BYTES_PER_INSTANCE = 64; -/** Per-mesh uniform: `baseColor: vec4`, 16 bytes. */ -const MESH_UNIFORM_BYTES = 16; export interface SceneryMeshInput { /** Stable id so the registry can be queried + instance buffers updated by id. */ @@ -23,7 +21,16 @@ export interface SceneryMeshInput { positions: Float32Array; // interleaved vec3 normals: Float32Array; // interleaved vec3 indices: Uint16Array | Uint32Array; + /** Fallback colour used for every vertex when `colors` is omitted. */ baseColor: [number, number, number, number]; + /** + * Per-vertex colour (vec4 each, matching `positions`). glTF splits a model + * into one primitive per material, so this carries each primitive's own + * `baseColor` baked per vertex — otherwise the whole mesh would render in a + * single colour. Omit it for a genuinely single-colour mesh (e.g. one boat + * primitive); the pass then fills every vertex with `baseColor`. + */ + colors?: Float32Array; /** Upper bound on the per-mesh instance count, so the buffer is allocated once. */ maxInstances: number; } @@ -32,13 +39,13 @@ interface RegisteredMesh { id: string; positionBuffer: GPUBuffer; normalBuffer: GPUBuffer; + colorBuffer: GPUBuffer; indexBuffer: GPUBuffer; indexFormat: 'uint16' | 'uint32'; indexCount: number; instanceBuffer: GPUBuffer; maxInstances: number; instanceCount: number; - meshUniforms: GPUBuffer; bindGroup: GPUBindGroup; } @@ -70,6 +77,7 @@ export const createSceneryMeshPass = ({ buffers: [ { arrayStride: 12, attributes: [{ shaderLocation: 0, offset: 0, format: 'float32x3' }] }, { arrayStride: 12, attributes: [{ shaderLocation: 1, offset: 0, format: 'float32x3' }] }, + { arrayStride: 16, attributes: [{ shaderLocation: 2, offset: 0, format: 'float32x4' }] }, ], }, fragment: { module, entryPoint: 'fs_main', targets: [{ format }] }, @@ -102,6 +110,26 @@ export const createSceneryMeshPass = ({ usage: GPUBufferUsage.VERTEX | GPUBufferUsage.COPY_DST, }); device.queue.writeBuffer(normalBuffer, 0, input.normals as BufferSource); + const vertexCount = input.positions.length / 3; + // Per-vertex colour: use the supplied buffer when it matches the vertex + // count, otherwise fill every vertex with the single `baseColor`. + let colors = input.colors; + if (!colors || colors.length !== vertexCount * 4) { + colors = new Float32Array(vertexCount * 4); + const [r, g, b, a] = input.baseColor; + for (let v = 0; v < vertexCount; v += 1) { + colors[v * 4] = r; + colors[v * 4 + 1] = g; + colors[v * 4 + 2] = b; + colors[v * 4 + 3] = a; + } + } + const colorBuffer = device.createBuffer({ + label: `sceneryMesh:${input.id}-col`, + size: Math.max(16, colors.byteLength), + usage: GPUBufferUsage.VERTEX | GPUBufferUsage.COPY_DST, + }); + device.queue.writeBuffer(colorBuffer, 0, colors as BufferSource); const indexBuffer = device.createBuffer({ label: `sceneryMesh:${input.id}-idx`, size: input.indices.byteLength, @@ -113,17 +141,10 @@ export const createSceneryMeshPass = ({ size: Math.max(1, input.maxInstances) * BYTES_PER_INSTANCE, usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST, }); - const meshUniforms = device.createBuffer({ - label: `sceneryMesh:${input.id}-u`, - size: MESH_UNIFORM_BYTES, - usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST, - }); - device.queue.writeBuffer(meshUniforms, 0, new Float32Array(input.baseColor) as BufferSource); const bindGroup = device.createBindGroup({ label: `sceneryMesh:${input.id}-bg`, layout: meshLayout, entries: [ - { binding: 0, resource: { buffer: meshUniforms } }, { binding: 1, resource: { buffer: instanceBuffer } }, ], }); @@ -131,13 +152,13 @@ export const createSceneryMeshPass = ({ id: input.id, positionBuffer, normalBuffer, + colorBuffer, indexBuffer, indexFormat: input.indices instanceof Uint32Array ? 'uint32' : 'uint16', indexCount: input.indices.length, instanceBuffer, maxInstances: Math.max(1, input.maxInstances), instanceCount: 0, - meshUniforms, bindGroup, }); }; @@ -182,6 +203,7 @@ export const createSceneryMeshPass = ({ pass.setBindGroup(1, mesh.bindGroup); pass.setVertexBuffer(0, mesh.positionBuffer); pass.setVertexBuffer(1, mesh.normalBuffer); + pass.setVertexBuffer(2, mesh.colorBuffer); pass.setIndexBuffer(mesh.indexBuffer, mesh.indexFormat); pass.drawIndexed(mesh.indexCount, mesh.instanceCount); } @@ -193,9 +215,9 @@ export const createSceneryMeshPass = ({ for (const mesh of registry.values()) { mesh.positionBuffer.destroy(); mesh.normalBuffer.destroy(); + mesh.colorBuffer.destroy(); mesh.indexBuffer.destroy(); mesh.instanceBuffer.destroy(); - mesh.meshUniforms.destroy(); } registry.clear(); }, diff --git a/src/lib/renderer/passes/sceneryMesh.wgsl b/src/lib/renderer/passes/sceneryMesh.wgsl index 2daae7e4..79cd60b8 100644 --- a/src/lib/renderer/passes/sceneryMesh.wgsl +++ b/src/lib/renderer/passes/sceneryMesh.wgsl @@ -1,6 +1,7 @@ // Instanced static-mesh scenery pass. Draws GLB-loaded meshes (course -// furniture, structures, …) at per-instance mat4 transforms. The per-mesh -// base colour comes from the GLB material; sun shade from the frame uniform. +// furniture, structures, …) at per-instance mat4 transforms. Each vertex +// carries its glTF primitive's base colour (so a multi-material model keeps +// trunk/leaves, walls/roof/windows distinct); sun shade from the frame uniform. struct Frame { view: mat4x4, @@ -15,24 +16,21 @@ struct Frame { _pad2: vec3, }; -struct MeshUniforms { - baseColor: vec4, -}; - @group(0) @binding(0) var frame: Frame; -@group(1) @binding(0) var mesh: MeshUniforms; // Per-instance mat4 (column-major, 4 × vec4). @group(1) @binding(1) var instances: array>; struct VsIn { @location(0) position: vec3, @location(1) normal: vec3, + @location(2) color: vec4, }; struct VsOut { @builtin(position) clip: vec4, @location(0) worldNormal: vec3, @location(1) worldDist: f32, + @location(2) color: vec4, }; @vertex @@ -46,6 +44,7 @@ fn vs_main(in: VsIn, @builtin(instance_index) ii: u32) -> VsOut { out.worldNormal = normalize(n); let d = world.xyz - frame.camPos; out.worldDist = length(vec3(d.x, 0.0, d.z)); + out.color = in.color; return out; } @@ -55,10 +54,10 @@ fn fs_main(in: VsOut) -> @location(0) vec4 { let n = normalize(in.worldNormal); let diffuse = max(dot(n, sun), 0.0); let shade = 0.35 + 0.65 * diffuse; - let col = mesh.baseColor.rgb * shade; + let col = in.color.rgb * shade; // Fog with the frame's exponential density. let density = frame.fog.x + frame.fog.y * in.worldDist; let f = clamp(1.0 - exp(-density * in.worldDist), 0.0, 1.0); let fogCol = vec3(0.72, 0.78, 0.82); - return vec4(mix(col, fogCol, f), mesh.baseColor.a); + return vec4(mix(col, fogCol, f), in.color.a); } From bc34d75f683e5b5bdf5886f9f9e27a745b59abdf Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 26 Oct 2026 10:54:00 +0000 Subject: [PATCH 2/5] #542 WebGPU scenery: stream GLBs lazily as the route progresses MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Scenery GLBs were all fetched up-front at attach (a Promise.all over every model), which blocks the first frame and can trip a static-host 503 — the same burst the React scene avoids by chunking its loads. Defer them: plan where each model first appears along the route, then load models from the frame loop as the boat comes within a look-ahead window, capped a few per tick. Course furniture and GLB scenery share one instance-transform map; a model's instances are set the moment its GLB registers, so nothing draws before it is ready and distant scenery streams in as the row progresses. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01Q5D9BTXmu7SeMa3LghJD8b --- .../__tests__/sceneryStreamPlan.test.ts | 70 ++++++++++++++ src/lib/renderer/engine/rideScene.ts | 78 ++++++++++++++-- src/lib/renderer/engine/sceneryStreamPlan.ts | 92 +++++++++++++++++++ 3 files changed, 232 insertions(+), 8 deletions(-) create mode 100644 src/lib/renderer/engine/__tests__/sceneryStreamPlan.test.ts create mode 100644 src/lib/renderer/engine/sceneryStreamPlan.ts diff --git a/src/lib/renderer/engine/__tests__/sceneryStreamPlan.test.ts b/src/lib/renderer/engine/__tests__/sceneryStreamPlan.test.ts new file mode 100644 index 00000000..91b878ed --- /dev/null +++ b/src/lib/renderer/engine/__tests__/sceneryStreamPlan.test.ts @@ -0,0 +1,70 @@ +import { describe, expect, it } from 'vitest'; +import { + planSceneryStream, + sceneryModelsToLoad, + type CurveDistanceSample, +} from '../sceneryStreamPlan'; + +// A straight 1000 m route sampled every 100 m along +Z... actually -Z, but the +// planner only cares about horizontal distance to each sample, so lay them on Z. +const samples: CurveDistanceSample[] = Array.from({ length: 11 }, (_, i) => ({ + position: [0, 0, -i * 100] as [number, number, number], + distanceMeters: i * 100, +})); + +describe('planSceneryStream', () => { + it('sets each model trigger distance to its nearest instance along the route, sorted ascending', () => { + const plan = planSceneryStream( + [ + { id: 'finish-tower', instancePositions: [[5, 0, -980]] }, // near the end + { id: 'bank-edge', instancePositions: [[3, 0, -20], [3, 0, -700]] }, // nearest is ~0 + { id: 'mid-dock', instancePositions: [[2, 0, -500]] }, + ], + samples, + ); + expect(plan.map((e) => e.id)).toEqual(['bank-edge', 'mid-dock', 'finish-tower']); + expect(plan[0].triggerDistanceMeters).toBe(0); + expect(plan[1].triggerDistanceMeters).toBe(500); + expect(plan[2].triggerDistanceMeters).toBe(1000); + }); + + it('measures horizontal distance only (ignores Y height)', () => { + const plan = planSceneryStream( + [{ id: 'hilltop', instancePositions: [[0, 999, -300]] }], + samples, + ); + expect(plan[0].triggerDistanceMeters).toBe(300); + }); + + it('with no curve samples, everything triggers at 0 so nothing is deferred', () => { + const plan = planSceneryStream([{ id: 'x', instancePositions: [[1, 2, 3]] }], []); + expect(plan[0].triggerDistanceMeters).toBe(0); + }); +}); + +describe('sceneryModelsToLoad', () => { + const plan = [ + { id: 'a', triggerDistanceMeters: 0 }, + { id: 'b', triggerDistanceMeters: 200 }, + { id: 'c', triggerDistanceMeters: 500 }, + { id: 'd', triggerDistanceMeters: 900 }, + ]; + + it('loads models within boatDistance + lookahead, nearest first, capped', () => { + // boat at 0, lookahead 400 → a(0) and b(200) are in range; c(500) is not. + expect(sceneryModelsToLoad(plan, new Set(), 0, 400, 10)).toEqual(['a', 'b']); + }); + + it('excludes already-loaded models', () => { + expect(sceneryModelsToLoad(plan, new Set(['a']), 0, 400, 10)).toEqual(['b']); + }); + + it('caps to maxCount, taking the nearest', () => { + // at 600 with lookahead 400 → window reaches 1000: a,b,c,d all eligible; cap 2. + expect(sceneryModelsToLoad(plan, new Set(), 600, 400, 2)).toEqual(['a', 'b']); + }); + + it('loads more as the boat advances', () => { + expect(sceneryModelsToLoad(plan, new Set(['a', 'b']), 600, 400, 10)).toEqual(['c', 'd']); + }); +}); diff --git a/src/lib/renderer/engine/rideScene.ts b/src/lib/renderer/engine/rideScene.ts index fbbbe7a8..37792e2a 100644 --- a/src/lib/renderer/engine/rideScene.ts +++ b/src/lib/renderer/engine/rideScene.ts @@ -59,6 +59,11 @@ import { GHOST_LANE_OFFSET_M } from '../../../components/rower3d/ghost'; import { assetUrl } from '../../utils/assetUrl'; import { createSceneryPass } from './sceneryPass'; import { mergeSceneryPrimitives } from './sceneryMeshBuffers'; +import { + planSceneryStream, + sceneryModelsToLoad, + type CurveDistanceSample, +} from './sceneryStreamPlan'; import { paintFoliage, FOLIAGE_SHAPES } from '../../scenery/foliageTexture'; import { createSceneryCuller } from '../passes/sceneryCull'; import { createFloraSolidStage } from './floraSolidStage'; @@ -378,8 +383,6 @@ export const createRideScene = async ( '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'; @@ -425,11 +428,16 @@ export const createRideScene = async ( } } catch { /* scull GLB load failed — the old baked boat remains as fallback */ } - await Promise.all([...allModelIds].map(async (id) => { + // Scenery GLBs load lazily as the route progresses (see sceneryStreamPlan): + // this fetches + registers one model on demand, returning whether it + // registered. The streaming controller built after placement drives it from + // the boat's distance, so distant scenery isn't fetched until the boat nears + // it — no up-front burst that blocks the first frame or trips a host 503. + const loadSceneryModel = async (id: string): Promise => { try { const url = assetUrl(`/assets/scenery/${tierFolderFor(id)}/${id}.glb`); const res = await fetch(url); - if (!res.ok) return; + if (!res.ok) return false; const bytes = new Uint8Array(await res.arrayBuffer()); const model = await loadGlb(bytes); // Merge the model's primitives, baking each primitive's base colour per @@ -445,11 +453,13 @@ export const createRideScene = async ( baseColor: model.primitives[0]?.baseColor ?? [1, 1, 1, 1], maxInstances: COURSE_FURNITURE_MAX_INSTANCES, }); + return true; } catch { // A fetch or decode failure leaves the model unregistered; the pass // silently skips it rather than taking the engine down. + return false; } - })); + }; // Build the water/bank curve + edges fn FIRST so every geometry pass // that follows the river (water strip, bank strips lit + depth, boat @@ -591,10 +601,13 @@ export const createRideScene = async ( mat4Multiply(trs, t, rot); byModel.get(place.id)?.push(trs); } + // Combine course-furniture + GLB-scenery instance transforms into one map, + // keyed by model id. The streaming controller calls `setInstances` for a + // model only once its GLB has registered. + const sceneryInstanceMats = new Map(); for (const model of COURSE_FURNITURE_MODELS) { - sceneryMeshPass.setInstances(model, byModel.get(model) ?? []); + sceneryInstanceMats.set(model, byModel.get(model) ?? []); } - for (const group of sceneryGlbList) { const mats: Mat4[] = []; for (const inst of group.instances) { @@ -612,8 +625,54 @@ export const createRideScene = async ( mat4Multiply(trs, t, rot); mats.push(trs); } - sceneryMeshPass.setInstances(group.id, mats); + sceneryInstanceMats.set(group.id, mats); + } + + // Progressive loading plan: sample the route for along-route distances, work + // out where each model first appears, and load models as the boat comes + // within the look-ahead window (driven from the frame loop below). + const SCENERY_STREAM_SAMPLES = 128; + const SCENERY_LOOKAHEAD_M = 400; + const SCENERY_MAX_PER_TICK = 3; + const streamSamples: CurveDistanceSample[] = []; + for (let i = 0; i <= SCENERY_STREAM_SAMPLES; i += 1) { + const p = i / SCENERY_STREAM_SAMPLES; + const cp = routeCurve.sample(p); + streamSamples.push({ + position: [cp.position[0], cp.position[1], cp.position[2]], + distanceMeters: p * totalDistanceMeters, + }); } + const sceneryPlan = planSceneryStream( + [...sceneryInstanceMats].map(([id, mats]) => ({ + id, + instancePositions: mats.map((m) => [m[12], m[13], m[14]] as [number, number, number]), + })), + streamSamples, + ); + const sceneryLoaded = new Set(); + const sceneryLoading = new Set(); + const streamScenery = (boatDistanceMeters: number): void => { + const pending = sceneryModelsToLoad( + sceneryPlan, + new Set([...sceneryLoaded, ...sceneryLoading]), + boatDistanceMeters, + SCENERY_LOOKAHEAD_M, + SCENERY_MAX_PER_TICK, + ); + for (const id of pending) { + sceneryLoading.add(id); + void loadSceneryModel(id) + .then((ok) => { + sceneryLoading.delete(id); + sceneryLoaded.add(id); + if (ok) sceneryMeshPass.setInstances(id, sceneryInstanceMats.get(id) ?? []); + }) + .catch(() => { + sceneryLoading.delete(id); + }); + } + }; const cameraRig = createRigTarget(); const boatPlacement = createPlacement(); @@ -699,6 +758,9 @@ export const createRideScene = async ( : null; placeBoat(boatPlacement, routeCurve, smoothedFrame, totalDistanceMeters, curveLength, 0); + // Stream scenery that has come within the look-ahead window as the boat + // advances; already-loaded / in-flight models are skipped. + streamScenery(boatPlacement.progress * totalDistanceMeters); const ghostInfo = placeGhost( ghostPlacement, routeCurve, diff --git a/src/lib/renderer/engine/sceneryStreamPlan.ts b/src/lib/renderer/engine/sceneryStreamPlan.ts new file mode 100644 index 00000000..eecb9a03 --- /dev/null +++ b/src/lib/renderer/engine/sceneryStreamPlan.ts @@ -0,0 +1,92 @@ +/** + * Progressive scenery streaming plan. + * + * The engine used to fetch every scenery GLB up-front at attach — a burst that + * can earn a 503 from a static host and blocks the first frame. Instead, each + * model is loaded only as the boat nears the first of its instances along the + * route, and more stream in as the row progresses (mirroring the React scene's + * chunked `readyCount` loading). This module is the pure, GPU-free planning: + * work out how far along the route each model first appears, then, given the + * boat's distance and a look-ahead window, decide which models to load next. + */ + +export interface CurveDistanceSample { + /** A point on the route curve in world space. */ + position: readonly [number, number, number]; + /** Distance along the route to this sample, in metres. */ + distanceMeters: number; +} + +export interface SceneryStreamGroup { + id: string; + instancePositions: readonly (readonly [number, number, number])[]; +} + +export interface SceneryStreamEntry { + id: string; + /** Route distance (m) at which this model's nearest instance sits. */ + triggerDistanceMeters: number; +} + +/** Horizontal (x,z) distance-along-route of the sample nearest `pos`. */ +const nearestDistanceMeters = ( + pos: readonly [number, number, number], + samples: readonly CurveDistanceSample[], +): number => { + if (samples.length === 0) return 0; + let best = Infinity; + let bestDist = 0; + for (const s of samples) { + const dx = s.position[0] - pos[0]; + const dz = s.position[2] - pos[2]; + const d = dx * dx + dz * dz; + if (d < best) { + best = d; + bestDist = s.distanceMeters; + } + } + return bestDist; +}; + +/** + * Build the per-model trigger distances, ascending. A model with no instances + * never triggers (Infinity) since there is nothing to draw. + */ +export const planSceneryStream = ( + groups: readonly SceneryStreamGroup[], + samples: readonly CurveDistanceSample[], +): SceneryStreamEntry[] => { + const entries = groups.map((g) => { + let trigger = Infinity; + for (const p of g.instancePositions) { + const d = nearestDistanceMeters(p, samples); + if (d < trigger) trigger = d; + } + return { id: g.id, triggerDistanceMeters: trigger }; + }); + entries.sort((a, b) => a.triggerDistanceMeters - b.triggerDistanceMeters); + return entries; +}; + +/** + * The not-yet-loaded models whose trigger distance is within + * `boatDistanceMeters + lookaheadMeters`, nearest first, capped at `maxCount`. + * `plan` is assumed ascending (as {@link planSceneryStream} returns it). + */ +export const sceneryModelsToLoad = ( + plan: readonly SceneryStreamEntry[], + loaded: ReadonlySet, + boatDistanceMeters: number, + lookaheadMeters: number, + maxCount: number, +): string[] => { + const horizon = boatDistanceMeters + lookaheadMeters; + const out: string[] = []; + for (const entry of plan) { + if (out.length >= maxCount) break; + if (loaded.has(entry.id)) continue; + if (entry.triggerDistanceMeters > horizon) break; // ascending → nothing further qualifies + out.push(entry.id); + } + return out; +}; From b154bb6794269363d576c4fa8249f6baf4cb3df0 Mon Sep 17 00:00:00 2001 From: MaximumTrainer Date: Mon, 26 Oct 2026 12:49:04 +0000 Subject: [PATCH 3/5] #542 WebGPU scenery: denser kit trees + per-species plant colour + less flat shading MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The WebGPU engine was rendering the river banks noticeably sparser and flatter than the React scene it ports. Four changes close most of the visible gap on an Intel Gen-9 (parity captures in #591 follow-up): - toEngineRoute: the mode was hard-wired to `medium`, which both ran the placement loop at 0.7 budget AND suppressed GLB kit trees entirely (keepsKitTrees is true only on high/extra-high). React used the viewer's own PerformanceMode, so most sessions got `high`. Add an optional `tier` parameter defaulted to `high` — no caller change required. - floraSolid.wgsl: the near-tree solid pool had one mono-green crown with no species variation and the sprite pool (scenery.wgsl) had three species palettes, so the near/far handoff never matched colour. Decode the species index from the centres.w metadata already packed by foliagePlan.ts and look up the same BASE_PAL/CROWN_PAL the sprite pass uses. The shading model moves from a `0.5*dot+0.6` flat clamp to half-lambert plus a sky/ground hemisphere ambient so leaves pick up some depth cue instead of reading as painted cardboard. - sceneryMesh.wgsl: same hemisphere ambient + half-lambert replaces the flat `0.35 + 0.65*diffuse` so GLB kit trees and OSM buildings carry depth under the sun. - uniforms.ts sproutWindowFor: the per-tier fade `far` was 6-15 m, which left most of the mid-range as sprite-only cards. Push each tier ~60% farther (medium 10 -> 16 m, high 12 -> 20 m) so the solid mesh carries the mid where silhouette matters most; the sprite pass still owns beyond. floraLod.test.ts picks up the new numbers. Co-Authored-By: Claude Opus 4.7 --- src/lib/renderer/__tests__/floraLod.test.ts | 15 ++++---- src/lib/renderer/engine/uniforms.ts | 17 +++++---- src/lib/renderer/passes/floraSolid.wgsl | 39 ++++++++++++++++++--- src/lib/renderer/passes/sceneryMesh.wgsl | 11 ++++-- src/lib/ui/sceneryRoute.ts | 12 +++++-- 5 files changed, 73 insertions(+), 21 deletions(-) diff --git a/src/lib/renderer/__tests__/floraLod.test.ts b/src/lib/renderer/__tests__/floraLod.test.ts index ea7f3e64..8b19ee1b 100644 --- a/src/lib/renderer/__tests__/floraLod.test.ts +++ b/src/lib/renderer/__tests__/floraLod.test.ts @@ -3,12 +3,15 @@ import { packSproutUniforms, sproutWindowFor } from '../engine/uniforms'; describe('sprout fade window (#562 FR6 / D6-b)', () => { it('selects per-tier fade distances in metres', () => { - expect(sproutWindowFor('basic').far).toBe(6); - expect(sproutWindowFor('low').far).toBe(8); - expect(sproutWindowFor('medium').far).toBe(10); - expect(sproutWindowFor('high').far).toBe(12); - expect(sproutWindowFor('extra-high').far).toBe(15); - expect(sproutWindowFor(undefined).far).toBe(10); + // Widened in #591-followup: narrow 6-15 m windows made the whole + // mid-range read as flat sprite cards. The new values keep the + // monotonic-with-tier shape asserted below. + expect(sproutWindowFor('basic').far).toBe(10); + expect(sproutWindowFor('low').far).toBe(12); + expect(sproutWindowFor('medium').far).toBe(16); + expect(sproutWindowFor('high').far).toBe(20); + expect(sproutWindowFor('extra-high').far).toBe(26); + expect(sproutWindowFor(undefined).far).toBe(16); }); it('fades near is zero at every tier (sprites fade in from the camera)', () => { diff --git a/src/lib/renderer/engine/uniforms.ts b/src/lib/renderer/engine/uniforms.ts index b5da2243..ad68b1b7 100644 --- a/src/lib/renderer/engine/uniforms.ts +++ b/src/lib/renderer/engine/uniforms.ts @@ -113,20 +113,25 @@ export const packSproutUniforms = ( return out; }; -/** Per-tier sprout fade window metres (D6-b). */ +/** Per-tier sprout fade window metres (D6-b). Pre-#591 the solid near-tree + * mesh faded out between ~6 and 15 m, which made the frame mostly sprite + * cards past arm's length — a near-bank treeline on medium was visibly + * card-flat. Pushing `far` out ~60% keeps the hybrid LOD but lets the + * low-poly solid mesh carry the mid-range where silhouette matters most; + * the sprite pass still owns everything beyond. */ export const sproutWindowFor = (tier?: string): { near: number; far: number } => { switch (tier) { case 'basic': - return { near: 0, far: 6 }; + return { near: 0, far: 10 }; case 'low': - return { near: 0, far: 8 }; - case 'high': return { near: 0, far: 12 }; + case 'high': + return { near: 0, far: 20 }; case 'extra-high': - return { near: 0, far: 15 }; + return { near: 0, far: 26 }; case 'medium': default: - return { near: 0, far: 10 }; + return { near: 0, far: 16 }; } }; diff --git a/src/lib/renderer/passes/floraSolid.wgsl b/src/lib/renderer/passes/floraSolid.wgsl index 40003d83..094d6e27 100644 --- a/src/lib/renderer/passes/floraSolid.wgsl +++ b/src/lib/renderer/passes/floraSolid.wgsl @@ -35,6 +35,7 @@ struct VSOut { @location(1) mix: vec2, @location(2) worldDist: f32, @location(3) fade: f32, + @location(4) @interpolate(flat) species: u32, }; @vertex @@ -52,6 +53,11 @@ fn vs_main( // Solid fades to zero ABOVE `sproutFar`; peak at `sproutNear`. let span = max(1.0, sprout.far - sprout.near); let fade = clamp(1.0 - (worldDist - sprout.near) / span, 0.0, 1.0); + // Species index rides in the bottom 16 bits of the centres.w metadata the + // foliage planner packs (see `foliagePlan.ts` `batchFoliagePools`), so we + // can tint each pool's trees without an extra bind group. + let metaBits = bitcast(instance.w); + let species = metaBits & 0xffffu; var out: VSOut; out.pos = frame.viewProj * vec4(world, 1.0); @@ -59,6 +65,7 @@ fn vs_main( out.mix = mixAttr; out.worldDist = worldDist; out.fade = fade; + out.species = species; return out; } @@ -66,12 +73,34 @@ fn vs_main( fn fs_main(in: VSOut) -> @location(0) vec4 { if (in.fade <= 0.001) { discard; } let sun = normalize(frame.sunDir); - let shade = clamp(dot(in.normal, sun) * 0.5 + 0.6, 0.3, 1.0); - let trunk = vec3(0.33, 0.22, 0.14); - let base = vec3(0.26, 0.42, 0.22); - let crown = vec3(0.42, 0.56, 0.26); + // Half-lambert + sky/ground ambient: a sun-lit face reads bright, a + // down-facing face picks up a touch of ground bounce, and up-facing + // leaves pick up sky — the old `*0.5+0.6` clamp was flat enough that + // trees looked like painted cardboard. + let halfLambert = clamp(dot(in.normal, sun) * 0.5 + 0.5, 0.0, 1.0); + let sky = clamp(in.normal.y * 0.5 + 0.5, 0.0, 1.0); + let ambient = mix(vec3(0.20, 0.19, 0.17), vec3(0.42, 0.46, 0.52), sky); + let shade = ambient + halfLambert * halfLambert * 0.9; + + // Per-species palettes mirror the sprite pass's BASE_PAL/CROWN_PAL in + // `scenery.wgsl` so the near-tree solid handoff keeps the same green as + // the distant billboards. Trunk is a shared warm brown. + const TRUNK = vec3(0.33, 0.22, 0.14); + const BASE_PAL = array, 3>( + vec3(0.20, 0.34, 0.18), + vec3(0.18, 0.26, 0.16), + vec3(0.32, 0.45, 0.22), + ); + const CROWN_PAL = array, 3>( + vec3(0.42, 0.60, 0.30), + vec3(0.30, 0.52, 0.28), + vec3(0.60, 0.74, 0.38), + ); + let sIdx = min(in.species, 2u); + let base = BASE_PAL[sIdx]; + let crown = CROWN_PAL[sIdx]; let foliage = mix(base, crown, clamp(in.mix.y, 0.0, 1.0)); - let col = mix(trunk, foliage, clamp(in.mix.x, 0.0, 1.0)) * shade; + let col = mix(TRUNK, foliage, clamp(in.mix.x, 0.0, 1.0)) * shade; let near = frame.fog.x; let far = frame.fog.y; let density = near + far * in.worldDist; diff --git a/src/lib/renderer/passes/sceneryMesh.wgsl b/src/lib/renderer/passes/sceneryMesh.wgsl index 79cd60b8..2adb944c 100644 --- a/src/lib/renderer/passes/sceneryMesh.wgsl +++ b/src/lib/renderer/passes/sceneryMesh.wgsl @@ -52,8 +52,15 @@ fn vs_main(in: VsIn, @builtin(instance_index) ii: u32) -> VsOut { fn fs_main(in: VsOut) -> @location(0) vec4 { let sun = normalize(frame.sunDir); let n = normalize(in.worldNormal); - let diffuse = max(dot(n, sun), 0.0); - let shade = 0.35 + 0.65 * diffuse; + // Hemisphere ambient (sky above, ground below) + half-lambert for the + // sun: the old `0.35 + 0.65*diffuse` had no sky/ground bias, so GLB + // kit trees and buildings read as uniformly flat and foliage lost its + // depth. The ambient magnitudes keep an unlit face above the old 0.35 + // floor so nothing goes muddy. + let halfLambert = clamp(dot(n, sun) * 0.5 + 0.5, 0.0, 1.0); + let sky = clamp(n.y * 0.5 + 0.5, 0.0, 1.0); + let ambient = mix(vec3(0.22, 0.20, 0.18), vec3(0.42, 0.46, 0.52), sky); + let shade = ambient + halfLambert * halfLambert * 0.75; let col = in.color.rgb * shade; // Fog with the frame's exponential density. let density = frame.fog.x + frame.fog.y * in.worldDist; diff --git a/src/lib/ui/sceneryRoute.ts b/src/lib/ui/sceneryRoute.ts index 33d15aa3..2bfdf5a5 100644 --- a/src/lib/ui/sceneryRoute.ts +++ b/src/lib/ui/sceneryRoute.ts @@ -54,6 +54,7 @@ import { import { SCENERY_PROFILES } from '../../components/rower3d/sceneryConfig'; import type { BuildingSpec } from '../renderer/plan/buildings'; import { + budgetFor, computePlacements, distinctProfiles, keepsKitTrees, @@ -193,6 +194,7 @@ const detectClosedLoop = (route: WaterRoute): boolean => { export const toEngineRoute = ( route: WaterRoute | null | undefined, enrichment?: RouteEnrichmentData | null, + tier: PerformanceMode = 'high', ): EngineRoute | null => { if (!route) return null; @@ -252,8 +254,14 @@ export const toEngineRoute = ( // bridges, landmarks, finish-area furniture). The `/row` adapter is where // the layout runs so the engine stays about rendering; this is pure maths // on the curve + enrichment + track, matching the React scene's pipeline. - const mode: PerformanceMode = 'medium'; - const budget = 0.7; + // Prior to this change the mode was hard-wired to 'medium', which + // suppressed GLB kit trees entirely (`keepsKitTrees` is true only on + // high/extra-high) and ran the per-step placement loop at a 0.7 budget. + // The React scene used the viewer's own `PerformanceMode`, so most users + // actually got 'high'. Making this a parameter (defaulted to 'high') + // restores that behaviour without breaking the no-arg callers in tests. + const mode: PerformanceMode = tier; + const budget = budgetFor(mode); const profiles: SceneryProfile[] = distinctProfiles(enrichment ?? null, track); const region = resolveRegion(route.coordinates ?? null); const resolvedByProfile = new Map(); From 95324b3a8bb1a0d6993e2c742db6d1a639c2e619 Mon Sep 17 00:00:00 2001 From: MaximumTrainer Date: Mon, 26 Oct 2026 13:37:42 +0000 Subject: [PATCH 4/5] #542 WebGPU scenery: real-tier plumbing + biome-density grass colour MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Follow-up to b154bb6. Two pieces. PIECE 1 — Tier plumbing. `toEngineRoute`'s mode was pinned to a sensible default ('high' after the first follow-up, 'medium' before), which meant the scenery builder had no idea what the device could handle and couldn't match React's density curve. React resolves the real tier through `resolvePerformanceMode()` (honoring `__VIRTUALROW_PERFORMANCE_MODE` and the Playwright flag), so `/row/+page.svelte` now calls the same resolver and threads the result into BOTH `toEngineRoute(route, enrichment, tier)` and `Canvas3DHost`'s `tier` prop. The two can't diverge. `DEFAULT_SCENERY_TIER` ('medium') keeps the no-arg callers in tests deterministic. A new test asserts `sceneryGlbs` count follows `low <= medium <= high` and that `foliagePlan` pools stay deterministic across tiers. PIECE 2 — Biome-density ground colour (#496 Phase 2, ported). React's "shader grass" is a biome density texture modulating the bank's grass albedo, not standing blade geometry (`bankTriplanar.glsl` `grassDensityModulation`). The WebGPU bank pass now ports that chunk: `bank.wgsl` samples an `r8unorm` density tile on a 32 m plan-view grid and modulates the ground colour by `grass.strength`, with `strength = 0` leaving the colour pass-through. A new `biomeDensity.ts` paints the tile from a seeded noise + biome-mean bias byte-for-byte like `foliageTexture.ts`'s `paintBiomeDensityTile`; `biomeDensity.test.ts` asserts the ±0.15 mean invariant React's own tile test asserts. A new `shaderGrass.ts` ports `drawsShaderGrass` / `isShaderGrassEnabled` and adds `shaderGrassStrengthFor`, so the pass tier-scales (0 on basic/low, 0.6/0.85/1.0 on medium/high/extra-high) and honors the global `__VIRTUALROW_SHADER_GRASS = false` kill switch. `rideScene.ts` wires them, picking the route's dominant biome profile via a new optional `EngineRoute.dominantProfile` the adapter now carries. Follow-up (NOT in this change, per coordinator): reeds + rocks + background cone-tree scatter — the other three vegetation layers React has that the WebGPU engine doesn't. Also lands: - `_mockWebGPU.ts` adds a `writeTexture` stub on the mock queue; the bank pass now uploads one on construction so the stub keeps the existing attach / AC4 tests honest. Co-Authored-By: Claude Opus 4.7 --- src/__tests__/sceneryRoute.test.ts | 20 ++++ src/lib/renderer/__tests__/_mockWebGPU.ts | 1 + .../engine/__tests__/shaderGrass.test.ts | 81 +++++++++++++ src/lib/renderer/engine/rideScene.ts | 18 +++ src/lib/renderer/engine/shaderGrass.ts | 46 ++++++++ src/lib/renderer/passes/bank.ts | 68 +++++++++++ src/lib/renderer/passes/bank.wgsl | 38 +++++- .../scenery/__tests__/biomeDensity.test.ts | 67 +++++++++++ src/lib/scenery/biomeDensity.ts | 110 ++++++++++++++++++ src/lib/ui/sceneryRoute.ts | 39 +++++-- src/routes/row/+page.svelte | 17 ++- 11 files changed, 493 insertions(+), 12 deletions(-) create mode 100644 src/lib/renderer/engine/__tests__/shaderGrass.test.ts create mode 100644 src/lib/renderer/engine/shaderGrass.ts create mode 100644 src/lib/scenery/__tests__/biomeDensity.test.ts create mode 100644 src/lib/scenery/biomeDensity.ts diff --git a/src/__tests__/sceneryRoute.test.ts b/src/__tests__/sceneryRoute.test.ts index aa2302bf..25cc6fef 100644 --- a/src/__tests__/sceneryRoute.test.ts +++ b/src/__tests__/sceneryRoute.test.ts @@ -50,6 +50,26 @@ describe('toEngineRoute (#570 AC2)', () => { expect(route?.pools).toHaveLength(0); }); + it('scales GLB scenery with the device tier (kit trees arrive at high)', () => { + // Low/basic/medium: `keepsKitTrees` is false, so the GLB kit-tree species + // are suppressed in `computePlacements`. High/extra-high flips both the + // budget (0.85 vs 0.5-0.7) and `kitTrees` to true, giving a larger + // sceneryGlbs set. The invariant we assert is "high >= medium >= low" so + // the test keeps working if the scenery catalogue grows. + const base = makeRoute(); + const low = toEngineRoute(base, undefined, 'low'); + const medium = toEngineRoute(base, undefined, 'medium'); + const high = toEngineRoute(base, undefined, 'high'); + const count = (r: ReturnType) => + (r?.sceneryGlbs ?? []).reduce((n, g) => n + g.instances.length, 0); + expect(count(high)).toBeGreaterThanOrEqual(count(medium)); + expect(count(medium)).toBeGreaterThanOrEqual(count(low)); + expect(count(high)).toBeGreaterThan(count(low)); + // Foliage pools (sprite + solid tree layout) come from `foliagePlan` + // which is deterministic regardless of tier, so pool counts match. + expect((high?.pools ?? []).length).toBe((low?.pools ?? []).length); + }); + it('places more foliage on a longer route (deterministic density scales with length)', () => { const short = toEngineRoute(makeRoute({ coordinates: [ diff --git a/src/lib/renderer/__tests__/_mockWebGPU.ts b/src/lib/renderer/__tests__/_mockWebGPU.ts index 51da237b..83365f78 100644 --- a/src/lib/renderer/__tests__/_mockWebGPU.ts +++ b/src/lib/renderer/__tests__/_mockWebGPU.ts @@ -177,6 +177,7 @@ export const makeMockDevice = (): MockDevice => { destroy: () => undefined, queue: { writeBuffer: () => undefined, + writeTexture: () => undefined, submit: () => { log.submits += 1; }, diff --git a/src/lib/renderer/engine/__tests__/shaderGrass.test.ts b/src/lib/renderer/engine/__tests__/shaderGrass.test.ts new file mode 100644 index 00000000..575013cb --- /dev/null +++ b/src/lib/renderer/engine/__tests__/shaderGrass.test.ts @@ -0,0 +1,81 @@ +/** + * Shader-grass enable predicate parity (#496 Phase 2). Mirror of the React + * `canvasSurface.ts` tests so a future flip of the default (or a tier + * reshuffle) catches both engines at once. + */ + +import { afterEach, describe, expect, it } from 'vitest'; + +import { + drawsShaderGrass, + isShaderGrassEnabled, + shaderGrassStrengthFor, +} from '../shaderGrass'; +import type { PerformanceMode } from '../../../scenery/sceneryPlacement'; + +const TIERS: PerformanceMode[] = ['basic', 'low', 'medium', 'high', 'extra-high']; + +afterEach(() => { + // Each case resets the global kill switch, since the predicate reads it + // via `window.__VIRTUALROW_SHADER_GRASS` and leaking state is painful. + delete (globalThis as unknown as { __VIRTUALROW_SHADER_GRASS?: boolean }).__VIRTUALROW_SHADER_GRASS; +}); + +describe('drawsShaderGrass — tier predicate', () => { + it('is off on the two lowest tiers', () => { + expect(drawsShaderGrass('basic')).toBe(false); + expect(drawsShaderGrass('low')).toBe(false); + }); + + it('is on for medium, high and extra-high', () => { + for (const t of ['medium', 'high', 'extra-high'] as const) { + expect(drawsShaderGrass(t), t).toBe(true); + } + }); +}); + +describe('isShaderGrassEnabled — kill switch', () => { + it('defaults on for medium+ (undefined reads as on)', () => { + expect(isShaderGrassEnabled('medium')).toBe(true); + expect(isShaderGrassEnabled('high')).toBe(true); + expect(isShaderGrassEnabled('extra-high')).toBe(true); + expect(isShaderGrassEnabled('low')).toBe(false); + }); + + it('an explicit true keeps grass on, matching undefined', () => { + (globalThis as unknown as { __VIRTUALROW_SHADER_GRASS?: boolean }).__VIRTUALROW_SHADER_GRASS = true; + expect(isShaderGrassEnabled('medium')).toBe(true); + expect(isShaderGrassEnabled('low')).toBe(false); + }); + + it('an explicit false turns grass off even on medium+', () => { + (globalThis as unknown as { __VIRTUALROW_SHADER_GRASS?: boolean }).__VIRTUALROW_SHADER_GRASS = false; + for (const t of TIERS) expect(isShaderGrassEnabled(t), t).toBe(false); + }); +}); + +describe('shaderGrassStrengthFor — tier scaling', () => { + it('zero when the chunk is disabled, nonzero when it is on', () => { + expect(shaderGrassStrengthFor('basic')).toBe(0); + expect(shaderGrassStrengthFor('low')).toBe(0); + expect(shaderGrassStrengthFor('medium')).toBeGreaterThan(0); + }); + + it('scales monotonically with tier where it is enabled', () => { + expect(shaderGrassStrengthFor('high')).toBeGreaterThan(shaderGrassStrengthFor('medium')); + expect(shaderGrassStrengthFor('extra-high')).toBeGreaterThan(shaderGrassStrengthFor('high')); + }); + + it('tops out at 1 so the shader never over-boosts', () => { + for (const t of TIERS) { + const s = shaderGrassStrengthFor(t); + expect(s).toBeGreaterThanOrEqual(0); + expect(s).toBeLessThanOrEqual(1); + } + }); + + it('kill switch forces zero on every tier', () => { + (globalThis as unknown as { __VIRTUALROW_SHADER_GRASS?: boolean }).__VIRTUALROW_SHADER_GRASS = false; + for (const t of TIERS) expect(shaderGrassStrengthFor(t), t).toBe(0); + }); +}); diff --git a/src/lib/renderer/engine/rideScene.ts b/src/lib/renderer/engine/rideScene.ts index 37792e2a..7c10cc33 100644 --- a/src/lib/renderer/engine/rideScene.ts +++ b/src/lib/renderer/engine/rideScene.ts @@ -72,6 +72,9 @@ import { textureBytes } from './textures'; import { createFrameGraph, type FrameGraph, type FrameUniforms, type Pass } from './frameGraph'; import { createShadowAtlas } from './shadowAtlas'; import { shadowConfigFor } from './shadowConfig'; +import { shaderGrassStrengthFor } from './shaderGrass'; +import type { PerformanceMode } from '../../scenery/sceneryPlacement'; +import type { SceneryProfile } from '../../services/routeEnrichmentService'; import { createShadowStage, type ShadowTelemetry } from './shadowStage'; import { tierCapsFor } from './tierCaps'; import { publishSpeedMps, type TelemetryHandles } from './telemetry'; @@ -135,6 +138,12 @@ export interface SceneryRoute { * per frame. Omitted → symmetric `WATER_CHANNEL_HALF_WIDTH`. */ waterEdgesForProgress?: (progress: number) => { left: number; right: number }; + /** + * Dominant biome profile along the route — the bank pass's grass-density + * tile (#496 Phase 2) is baked from this. One profile per route for now; + * per-segment banks are a follow-up. + */ + dominantProfile?: string; } export const isSceneryRoute = (route: unknown): route is SceneryRoute => @@ -491,6 +500,14 @@ export const createRideScene = async ( atlasView: atlas.view, cmpSampler: shadowCmpSampler, }; + // Biome-density grass modulation (#496 Phase 2). Default to the route's + // dominant profile when the adapter supplied one; strength scales with + // the actual device tier, respecting the `__VIRTUALROW_SHADER_GRASS` + // kill switch the React scene also honours — see `shaderGrass.ts`. + const bankGrassProfile = ( + isSceneryRoute(opts.route) ? (opts.route.dominantProfile as SceneryProfile | undefined) : undefined + ) ?? 'fallback'; + const bankGrassStrength = shaderGrassStrengthFor((opts.tier ?? 'medium') as PerformanceMode); const bankPass = createBankPass({ device, format, @@ -498,6 +515,7 @@ export const createRideScene = async ( shadow: shadowResources, curve: waterCurve, edgesForProgress: waterEdgesForProgress, + grass: { profile: bankGrassProfile, strength: bankGrassStrength }, }); const waterPass = createWaterPass({ device, diff --git a/src/lib/renderer/engine/shaderGrass.ts b/src/lib/renderer/engine/shaderGrass.ts new file mode 100644 index 00000000..c73e8ed3 --- /dev/null +++ b/src/lib/renderer/engine/shaderGrass.ts @@ -0,0 +1,46 @@ +/** + * Shader-grass enable predicate (#496 Phase 2) ported from + * `src/components/rower3d/canvasSurface.ts` `drawsShaderGrass` / + * `isShaderGrassEnabled`. Decides whether the bank pass should mount its + * biome-density grass modulation on a given tier, honouring the global + * `window.__VIRTUALROW_SHADER_GRASS = false` kill switch. + * + * Three-free so the renderer can import it directly, and separated from the + * React copy so the two engines can't drift. The predicate shape (medium+, + * default on) matches React's Phase 2 state. + */ +import type { PerformanceMode } from '../../scenery/sceneryPlacement'; + +/** Which tiers run the biome-density grass contribution at all. */ +export const drawsShaderGrass = (mode: PerformanceMode): boolean => + mode === 'medium' || mode === 'high' || mode === 'extra-high'; + +/** + * Whether this tier actually mounts shader grass right now. True unless the + * tier predicate rules it out OR the global kill switch has been set to + * `false`; `undefined` reads as on (Phase 2 flipped the default). + */ +export const isShaderGrassEnabled = (mode: PerformanceMode): boolean => { + if (!drawsShaderGrass(mode)) return false; + if (typeof window === 'undefined') return true; + const g = (window as unknown as { __VIRTUALROW_SHADER_GRASS?: boolean }).__VIRTUALROW_SHADER_GRASS; + return g !== false; +}; + +/** + * The modulation strength for the bank's density chunk at this tier. Zero on + * the two lowest tiers / when the kill switch is set, so the pass can keep + * its pipeline but skip the chunk at the shader level; a smaller value on + * medium than on high so a weaker rig gets a lighter touch of the biome + * tint, matching the tier scaling React's #496 Phase 2 rolls out through + * `densityScale`. + */ +export const shaderGrassStrengthFor = (mode: PerformanceMode): number => { + if (!isShaderGrassEnabled(mode)) return 0; + switch (mode) { + case 'medium': return 0.6; + case 'high': return 0.85; + case 'extra-high': return 1; + default: return 0; + } +}; diff --git a/src/lib/renderer/passes/bank.ts b/src/lib/renderer/passes/bank.ts index 6d284ad0..1246ebe3 100644 --- a/src/lib/renderer/passes/bank.ts +++ b/src/lib/renderer/passes/bank.ts @@ -5,6 +5,8 @@ import type { RouteCurve } from '../math/curve'; import { SAMPLE_SHADOW_WGSL, SHADOW_UNIFORMS_WGSL } from '../engine/shaderSnippets'; import { buildBankStrips } from '../plan/bankStrip'; import type { WaterEdges } from '../plan/waterStrip'; +import { paintBiomeDensityR8 } from '../../scenery/biomeDensity'; +import type { SceneryProfile } from '../../services/routeEnrichmentService'; import bankWgsl from './bank.wgsl?raw'; export interface ShadowResources { @@ -13,6 +15,17 @@ export interface ShadowResources { cmpSampler: GPUSampler; } +/** The biome whose density tile drives the bank's grass modulation (#496). */ +export interface BankGrassConfig { + profile: SceneryProfile; + /** 0 disables the chunk at the shader level; 1 is React's full strength. */ + strength: number; + /** Plan-view metres covered by one wrap of the tile. React uses 32. */ + tileMetres?: number; + /** Density tile edge in texels; 256 matches `createBiomeDensityTile`'s default. */ + tileSize?: number; +} + export interface BankPassOptions { device: GPUDevice; format: GPUTextureFormat; @@ -22,6 +35,8 @@ export interface BankPassOptions { curve?: RouteCurve | null; /** Same per-progress shore widths the water pass uses. */ edgesForProgress?: (progress: number) => WaterEdges; + /** Biome-density grass modulation. Omitted → chunk off (strength 0, fallback tile). */ + grass?: BankGrassConfig; } export const createBankPass = ({ @@ -31,6 +46,7 @@ export const createBankPass = ({ shadow, curve = null, edgesForProgress, + grass, }: BankPassOptions): Pass => { const code = SHADOW_UNIFORMS_WGSL + SAMPLE_SHADOW_WGSL + bankWgsl; const module = device.createShaderModule({ label: 'bank', code }); @@ -73,6 +89,55 @@ export const createBankPass = ({ ], }); + // Grass density chunk resources (#496 Phase 2). The texture + sampler are + // mounted even when `strength === 0` so the pipeline+bind-group graph + // stays identical regardless of tier: a toggle only needs to rewrite the + // uniform. The tile is `r8unorm` because the shader reads only `.r`, + // which is 4× cheaper to upload than RGBA. + const grassProfile: SceneryProfile = grass?.profile ?? 'fallback'; + const grassStrength = Math.max(0, Math.min(1, grass?.strength ?? 0)); + const grassTileMetres = grass?.tileMetres ?? 32; + const grassTileSize = grass?.tileSize ?? 256; + const grassPixels = paintBiomeDensityR8(grassProfile, grassTileSize); + const grassTexture = device.createTexture({ + label: `bank-grass-tile:${grassProfile}`, + size: { width: grassTileSize, height: grassTileSize }, + format: 'r8unorm', + usage: GPUTextureUsage.TEXTURE_BINDING | GPUTextureUsage.COPY_DST, + }); + device.queue.writeTexture( + { texture: grassTexture }, + grassPixels as BufferSource, + { bytesPerRow: grassTileSize, rowsPerImage: grassTileSize }, + { width: grassTileSize, height: grassTileSize }, + ); + const grassSampler = device.createSampler({ + label: 'bank-grass-sampler', + magFilter: 'linear', + minFilter: 'linear', + addressModeU: 'repeat', + addressModeV: 'repeat', + }); + const grassUniforms = device.createBuffer({ + label: 'bank-grass-uniforms', + size: 16, + usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST, + }); + device.queue.writeBuffer( + grassUniforms, + 0, + new Float32Array([grassStrength, grassTileMetres, 0, 0]), + ); + const grassBindGroup = device.createBindGroup({ + label: 'bank-grass', + layout: pipeline.getBindGroupLayout(2), + entries: [ + { binding: 0, resource: { buffer: grassUniforms } }, + { binding: 1, resource: grassTexture.createView() }, + { binding: 2, resource: grassSampler }, + ], + }); + const geom = buildBankStrips({ curve, edgesForProgress }); const positionBuffer = device.createBuffer({ label: 'bank-pos', @@ -116,6 +181,7 @@ export const createBankPass = ({ pass.setPipeline(pipeline); pass.setBindGroup(0, bindGroup); pass.setBindGroup(1, shadowBindGroup); + pass.setBindGroup(2, grassBindGroup); pass.setVertexBuffer(0, positionBuffer); pass.setVertexBuffer(1, sideBuffer); pass.setIndexBuffer(indexBuffer, 'uint16'); @@ -128,6 +194,8 @@ export const createBankPass = ({ positionBuffer.destroy(); sideBuffer.destroy(); indexBuffer.destroy(); + grassTexture.destroy(); + grassUniforms.destroy(); }, }; }; diff --git a/src/lib/renderer/passes/bank.wgsl b/src/lib/renderer/passes/bank.wgsl index 67140cf7..829224f2 100644 --- a/src/lib/renderer/passes/bank.wgsl +++ b/src/lib/renderer/passes/bank.wgsl @@ -1,8 +1,13 @@ // Bank pass (#550 FR7, #559 lit receiver): draws the two riverside strips. // P2 ports the ground *colour* of `bankMaterial.ts` — procedural ground -// with a mild noise band along the water edge. Grass density (#496 -// model) is P5. P4 adds a shadow receiver via the shared `sampleShadow` -// snippet (injected from `shaderSnippets.ts` at pipeline-build time). +// with a mild noise band along the water edge. P5 ported the per-biome +// density tile from React's `bankTriplanar.glsl` `grassDensityModulation` +// chunk: `grassTile` carries a baked density field (R channel) and +// `grass.strength` scales how much it biases the colour, so a lusher +// biome reads lusher at the ride-view camera; `strength = 0` on tiers +// where `isShaderGrassEnabled` is false. P4 adds a shadow receiver via +// the shared `sampleShadow` snippet (injected from `shaderSnippets.ts` +// at pipeline-build time). struct Frame { view: mat4x4, @@ -17,7 +22,16 @@ struct Frame { _pad2: vec3, }; +struct Grass { + strength: f32, + tileMetres: f32, + _pad: vec2, +}; + @group(0) @binding(0) var frame: Frame; +@group(2) @binding(0) var grass: Grass; +@group(2) @binding(1) var grassTile: texture_2d; +@group(2) @binding(2) var grassSampler: sampler; struct VSOut { @builtin(position) pos: vec4, @@ -53,7 +67,23 @@ fn fs_main(in: VSOut) -> @location(0) vec4 { let base = vec3(0.32, 0.42, 0.22); // damp grass let dry = vec3(0.52, 0.49, 0.33); let n = hash12(floor(in.worldXZ * 2.0)); - let col = mix(base, dry, n * 0.4); + var col = mix(base, dry, n * 0.4); + + // Biome-density grass modulation (#496 Phase 2, ported from + // `bankTriplanar.glsl`'s `grassDensityModulation` chunk). The red + // channel of the baked tile is sampled on a 32 m plan-view grid, then + // biased from 0.5 to (density - 0.5) × 1.12 so a wetter biome tints + // the bank greener and a drier one leaves it as the base noise + // already paints it. `grass.strength` scales the whole chunk so a + // tier with `isShaderGrassEnabled = false` sends 0 and the colour + // passes through unchanged (the pass still runs, which keeps the + // pipeline+bind-group graph fixed across tier changes). + let tileMetres = max(grass.tileMetres, 1.0); + let densityUv = in.worldXZ / tileMetres; + let density = textureSample(grassTile, grassSampler, densityUv).r; + let boost = mix(1.0, 0.72 + 0.56 * density, grass.strength); + col = col * boost; + let lit = sampleShadow(in.worldPos, in.viewZ); // Soft ambient so a fully-shadowed fragment still reads as ground. let shaded = col * (0.45 + 0.55 * lit); diff --git a/src/lib/scenery/__tests__/biomeDensity.test.ts b/src/lib/scenery/__tests__/biomeDensity.test.ts new file mode 100644 index 00000000..c5927c59 --- /dev/null +++ b/src/lib/scenery/__tests__/biomeDensity.test.ts @@ -0,0 +1,67 @@ +/** + * Parity test for the WebGPU port of #496 Phase 2's biome density tile. The + * React painter lives at `src/components/rower3d/foliageTexture.ts`; this + * test keeps the WebGPU port honest by asserting the same two properties + * the React `biomeDensityTile.test.ts` asserts: deterministic bytes, and + * mean density within ±0.15 of the biome's `groundCover.density`. + */ + +import { describe, expect, it } from 'vitest'; + +import { paintBiomeDensityR8, paintBiomeDensityTile, meanDensity } from '../biomeDensity'; +import { SCENERY_PROFILES } from '../../../components/rower3d/sceneryConfig'; +import type { SceneryProfile } from '../../services/routeEnrichmentService'; + +const PROFILES: SceneryProfile[] = [ + 'forest', 'residential', 'commercial', 'farmland', 'beach', 'wetland', 'fallback', +]; + +describe('paintBiomeDensityTile — ported from foliageTexture.ts', () => { + it('produces the right pixel count per tile size', () => { + for (const size of [16, 64, 256]) { + expect(paintBiomeDensityTile('forest', size).length).toBe(size * size * 4); + expect(paintBiomeDensityR8('forest', size).length).toBe(size * size); + } + }); + + it('writes the density in R, copies to G and B, alpha 255', () => { + const tile = paintBiomeDensityTile('wetland', 16); + for (let i = 0; i < tile.length; i += 4) { + expect(tile[i + 1]).toBe(tile[i]); + expect(tile[i + 2]).toBe(tile[i]); + expect(tile[i + 3]).toBe(255); + } + }); + + it('matches the RGBA R channel with the R8 fast-path, byte-for-byte', () => { + const rgba = paintBiomeDensityTile('forest', 64); + const r8 = paintBiomeDensityR8('forest', 64); + for (let i = 0; i < r8.length; i += 1) expect(r8[i]).toBe(rgba[i * 4]); + }); + + it('is deterministic for a given (profile, size)', () => { + const a = paintBiomeDensityR8('wetland', 128); + const b = paintBiomeDensityR8('wetland', 128); + expect(Array.from(a).slice(0, 32)).toEqual(Array.from(b).slice(0, 32)); + expect(a).toEqual(b); + }); + + it('mean density lands within ±0.15 of the biome target (React invariant)', () => { + for (const profile of PROFILES) { + const target = SCENERY_PROFILES[profile].groundCover.density; + const mean = meanDensity(paintBiomeDensityR8(profile, 128), 128); + expect(Math.abs(mean - target)).toBeLessThanOrEqual(0.15); + } + }); + + it('two different biomes paint different tiles (phase seed does its job)', () => { + const forest = paintBiomeDensityR8('forest', 64); + const commercial = paintBiomeDensityR8('commercial', 64); + // Pixel-by-pixel equality would be too strong — biome means differ by + // design, so distinct phases plus distinct target biases guarantee a + // large fraction of differing pixels. + let diff = 0; + for (let i = 0; i < forest.length; i += 1) if (forest[i] !== commercial[i]) diff += 1; + expect(diff).toBeGreaterThan(forest.length * 0.5); + }); +}); diff --git a/src/lib/scenery/biomeDensity.ts b/src/lib/scenery/biomeDensity.ts new file mode 100644 index 00000000..474e39f3 --- /dev/null +++ b/src/lib/scenery/biomeDensity.ts @@ -0,0 +1,110 @@ +/** + * Three-free port of `src/components/rower3d/foliageTexture.ts`'s + * `paintBiomeDensityTile` / `createBiomeDensityTile` (#496 Phase 2). The + * WebGPU bank pass samples the resulting R8 tile and modulates the grass + * contribution so a lusher biome reads lusher at the ride-view camera — + * same job the React scene's `bankTriplanar.glsl` `grassDensityModulation` + * chunk does, but with no DOM `` or `THREE.CanvasTexture` so it + * runs under JSDOM and (importantly) in the shader test suite. + * + * The noise function, seeds, biome-mean bias and target-density mapping + * are deliberately byte-identical to the React painter so the WebGPU and + * React banks paint the same patches of lushness for the same biome. The + * `biomeDensityTile.test.ts` on main (sibling worktree) asserts the mean + * matches `groundCover.density` within ±0.15; the ported test here keeps + * that invariant. + */ + +import { SCENERY_PROFILES } from '../../components/rower3d/sceneryConfig'; +import type { SceneryProfile } from '../services/routeEnrichmentService'; +import { seededRandom } from './seededRandom'; + +/** Per-profile phase seed; identical to React `DENSITY_SEEDS`. */ +const DENSITY_SEEDS: Record = { + forest: 101, + residential: 211, + commercial: 317, + farmland: 433, + beach: 541, + wetland: 647, + fallback: 751, +}; + +/** A smoothed [0, 1] noise sample at `(x, y)` from the seeded `biomeNoise`. */ +const biomeNoise = (seed: number, x: number, y: number): number => { + // Two cosine octaves at perpendicular frequencies make a continuous field + // without a 2-D permutation table; `seededRandom` gives each biome a + // distinct phase so no two biomes paint the same patches. + const phase = seededRandom(seed) * Math.PI * 2; + const a = 0.5 + 0.5 * Math.cos(x * 0.75 + phase) * Math.cos(y * 0.75 + phase); + const b = 0.5 + 0.5 * Math.cos(x * 2.1 + phase * 1.3) * Math.cos(y * 2.1 - phase * 0.7); + return 0.6 * a + 0.4 * b; +}; + +const clamp01 = (v: number): number => (v < 0 ? 0 : v > 1 ? 1 : v); + +/** + * RGBA pixels of a biome's grass density tile, `size` square, top row first. + * Red carries the density; green and blue copy it (so canvas-tool inspection + * reads the tile as greyscale); alpha is always 255. + */ +export const paintBiomeDensityTile = ( + profile: SceneryProfile, + size: number, +): Uint8ClampedArray => { + const pixels = new Uint8ClampedArray(size * size * 4); + const seed = DENSITY_SEEDS[profile]; + const target = SCENERY_PROFILES[profile]?.groundCover.density ?? 0.5; + for (let y = 0; y < size; y += 1) { + for (let x = 0; x < size; x += 1) { + const u = x / size; + const v = y / size; + const n = biomeNoise(seed, u * 4, v * 4); + // Bias the field so its mean lands on the biome's target density: `n` + // is centred on 0.5, so `(target - 0.5)` shifts the whole tile toward + // the target and the contrast around it is left to the noise. + const d = clamp01(n + (target - 0.5)); + const value = Math.round(d * 255); + const i = (y * size + x) * 4; + pixels[i] = value; + pixels[i + 1] = value; + pixels[i + 2] = value; + pixels[i + 3] = 255; + } + } + return pixels; +}; + +/** + * The R channel only, as a flat `Uint8Array` of size × size bytes. Suitable + * for upload as an `r8unorm` WebGPU texture — the bank pass only reads + * `.r`, so uploading the alpha-less variant saves 4× the bytes and skips + * a texture format the renderer doesn't otherwise need. + */ +export const paintBiomeDensityR8 = ( + profile: SceneryProfile, + size: number, +): Uint8Array => { + const out = new Uint8Array(size * size); + const seed = DENSITY_SEEDS[profile]; + const target = SCENERY_PROFILES[profile]?.groundCover.density ?? 0.5; + for (let y = 0; y < size; y += 1) { + for (let x = 0; x < size; x += 1) { + const u = x / size; + const v = y / size; + const n = biomeNoise(seed, u * 4, v * 4); + const d = clamp01(n + (target - 0.5)); + out[y * size + x] = Math.round(d * 255); + } + } + return out; +}; + +/** Mean density of a painted tile, used by the parity test. */ +export const meanDensity = (tile: Uint8Array | Uint8ClampedArray, size: number): number => { + let sum = 0; + const stride = tile.length === size * size ? 1 : 4; + const count = size * size; + for (let i = 0; i < count; i += 1) sum += tile[i * stride]; + return sum / 255 / count; +}; diff --git a/src/lib/ui/sceneryRoute.ts b/src/lib/ui/sceneryRoute.ts index 2bfdf5a5..e170d112 100644 --- a/src/lib/ui/sceneryRoute.ts +++ b/src/lib/ui/sceneryRoute.ts @@ -127,6 +127,13 @@ export interface EngineRoute { * then applies its symmetric fallback. */ waterEdgesForProgress?: (progress: number) => WaterEdges; + /** + * Dominant biome profile along the route — the first profile the enrichment + * mentions, or 'fallback' when there is none. The bank pass uses it to pick + * the baked density tile for its grass modulation (#496). One profile per + * route for now; the per-segment React approach is a follow-up. + */ + dominantProfile?: SceneryProfile; } /** Default OSM building height when the tags carry neither `height` nor levels. */ @@ -191,10 +198,19 @@ const detectClosedLoop = (route: WaterRoute): boolean => { * omitted for a zero-length route so the engine falls back to the curve's own * chord length rather than snapping progress to a 1 m course. */ +/** + * Default tier used when no caller passes one through. Matches React's + * `resolvePerformanceMode()` fallback for a browser-less or probe-free + * invocation: real users get `'medium'`, which is what unit tests exercise. + * The `/row` page always passes a real tier so this only applies to tests + * and other callers that don't care about scenery density. + */ +export const DEFAULT_SCENERY_TIER: PerformanceMode = 'medium'; + export const toEngineRoute = ( route: WaterRoute | null | undefined, enrichment?: RouteEnrichmentData | null, - tier: PerformanceMode = 'high', + tier: PerformanceMode = DEFAULT_SCENERY_TIER, ): EngineRoute | null => { if (!route) return null; @@ -254,12 +270,13 @@ export const toEngineRoute = ( // bridges, landmarks, finish-area furniture). The `/row` adapter is where // the layout runs so the engine stays about rendering; this is pure maths // on the curve + enrichment + track, matching the React scene's pipeline. - // Prior to this change the mode was hard-wired to 'medium', which - // suppressed GLB kit trees entirely (`keepsKitTrees` is true only on - // high/extra-high) and ran the per-step placement loop at a 0.7 budget. - // The React scene used the viewer's own `PerformanceMode`, so most users - // actually got 'high'. Making this a parameter (defaulted to 'high') - // restores that behaviour without breaking the no-arg callers in tests. + // Prior to #591-fu the mode was hard-wired to 'medium', which suppressed + // GLB kit trees entirely (`keepsKitTrees` is true only on high/extra-high) + // and pinned the per-step placement loop at a 0.7 budget. React resolves + // the real device tier via `resolvePerformanceMode()` and hands it to the + // scene, so a given rig gets the matching density on both engines. The + // `/row` page now plumbs that same tier here; `DEFAULT_SCENERY_TIER` keeps + // tests and other no-arg callers deterministic at `'medium'`. const mode: PerformanceMode = tier; const budget = budgetFor(mode); const profiles: SceneryProfile[] = distinctProfiles(enrichment ?? null, track); @@ -291,6 +308,13 @@ export const toEngineRoute = ( sceneryGlbsResult = [...grouped.entries()].map(([id, instances]) => ({ id, instances })); } + // Pick the dominant biome profile the bank pass's grass-density chunk + // (#496) will key its density tile off. A route may cover several profiles; + // one tile per route is good enough for the current single-mesh bank, and + // matches the single `fallback` the pass used before this change. + const routeProfiles = distinctProfiles(enrichment ?? null, getRouteSceneryTrack(route.id)); + const dominantProfile: SceneryProfile = routeProfiles[0] ?? 'fallback'; + return { pools, ...(buildings.length > 0 ? { buildings } : {}), @@ -299,5 +323,6 @@ export const toEngineRoute = ( ...(totalDistanceMeters !== undefined ? { totalDistanceMeters } : {}), ...(isClosedLoop ? { isClosedLoop } : {}), waterEdgesForProgress, + dominantProfile, }; }; diff --git a/src/routes/row/+page.svelte b/src/routes/row/+page.svelte index ecef066a..28320d85 100644 --- a/src/routes/row/+page.svelte +++ b/src/routes/row/+page.svelte @@ -16,6 +16,7 @@