Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
76 changes: 76 additions & 0 deletions src/lib/renderer/engine/__tests__/sceneryMeshBuffers.test.ts
Original file line number Diff line number Diff line change
@@ -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>): 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])));
});
});
70 changes: 70 additions & 0 deletions src/lib/renderer/engine/__tests__/sceneryStreamPlan.test.ts
Original file line number Diff line number Diff line change
@@ -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']);
});
});
115 changes: 79 additions & 36 deletions src/lib/renderer/engine/rideScene.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -377,8 +383,6 @@ export const createRideScene = async (
'a07-distance-marker-post',
];
const sceneryGlbList = isSceneryRoute(opts.route) ? opts.route.sceneryGlbs ?? [] : [];
const allModelIds: Set<string> = new Set<string>(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';
Expand Down Expand Up @@ -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<boolean> => {
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
Expand Down Expand Up @@ -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<string, Mat4[]>();
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) {
Expand All @@ -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<string>();
const sceneryLoading = new Set<string>();
const streamScenery = (boatDistanceMeters: number): void => {
const pending = sceneryModelsToLoad(
sceneryPlan,
new Set<string>([...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();
Expand Down Expand Up @@ -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,
Expand Down
74 changes: 74 additions & 0 deletions src/lib/renderer/engine/sceneryMeshBuffers.ts
Original file line number Diff line number Diff line change
@@ -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 };
};
Loading
Loading