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/__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 50056276..37792e2a 100644 --- a/src/lib/renderer/engine/rideScene.ts +++ b/src/lib/renderer/engine/rideScene.ts @@ -58,6 +58,12 @@ 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 { + planSceneryStream, + sceneryModelsToLoad, + type CurveDistanceSample, +} from './sceneryStreamPlan'; import { paintFoliage, FOLIAGE_SHAPES } from '../../scenery/foliageTexture'; import { createSceneryCuller } from '../passes/sceneryCull'; import { createFloraSolidStage } from './floraSolidStage'; @@ -377,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'; @@ -424,51 +428,38 @@ 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); - 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, }); + 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 @@ -610,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) { @@ -631,9 +625,55 @@ 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(); const ghostPlacement = createPlacement(); @@ -718,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/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/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; +}; 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); }