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/__tests__/floraLod.test.ts b/src/lib/renderer/__tests__/floraLod.test.ts index ea7f3e64..acc4c514 100644 --- a/src/lib/renderer/__tests__/floraLod.test.ts +++ b/src/lib/renderer/__tests__/floraLod.test.ts @@ -3,12 +3,17 @@ 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 again in #591-followup: React's scene has no sprite handoff + // at all (its trees are real geometry to the clip plane), so the + // WebGPU hybrid LOD has to carry solid trees well past the river bank's + // own distance to match the look. Monotonic-with-tier invariant below + // still holds. + expect(sproutWindowFor('basic').far).toBe(24); + expect(sproutWindowFor('low').far).toBe(40); + expect(sproutWindowFor('medium').far).toBe(60); + expect(sproutWindowFor('high').far).toBe(100); + expect(sproutWindowFor('extra-high').far).toBe(160); + expect(sproutWindowFor(undefined).far).toBe(60); }); it('fades near is zero at every tier (sprites fade in from the camera)', () => { diff --git a/src/lib/renderer/__tests__/floraSolidPool.test.ts b/src/lib/renderer/__tests__/floraSolidPool.test.ts index c97c09fb..5bd546a9 100644 --- a/src/lib/renderer/__tests__/floraSolidPool.test.ts +++ b/src/lib/renderer/__tests__/floraSolidPool.test.ts @@ -2,14 +2,33 @@ import { describe, expect, it } from 'vitest'; import { buildFloraSolidMesh } from '../plan/floraSolidPool'; describe('floraSolidPool', () => { - it('builds a mesh under the ~50 triangle budget (D7-a #562)', () => { - for (let species = 0; species < 8; species += 1) { + it('builds trees (species 0-2) as a four-cone-stack mesh in the triangle budget', () => { + // The pre-#591-fu single-ring crown ran at ~32 tris / tree with a 60-tri + // cap; the four-cone stack (`CONE_LAYERS` from React + // `vegetationComponents.tsx`) raises the per-tree cost accordingly. The + // lower bound stays well above the old single-blob count so a future + // simplification can't silently regress the silhouette. + for (let species = 0; species < 3; species += 1) { const mesh = buildFloraSolidMesh(species); - expect(mesh.triangleCount).toBeGreaterThan(20); - expect(mesh.triangleCount).toBeLessThanOrEqual(60); + expect(mesh.triangleCount).toBeGreaterThanOrEqual(48); + expect(mesh.triangleCount).toBeLessThanOrEqual(120); } }); + it('builds reed clusters (species 3) with crossed-blade triangles', () => { + // Reeds are three crossed thin quads with both faces so they read from + // either side under `cullMode: back` — that's 3 blades × 2 tris × 2 + // faces = 12 triangles. + const mesh = buildFloraSolidMesh(3); + expect(mesh.triangleCount).toBe(12); + }); + + it('builds rocks (species 4) as a 6-sided prism with a top fan', () => { + // 6 sides × 2 tris + 6 top-fan tris = 18 triangles. + const mesh = buildFloraSolidMesh(4); + expect(mesh.triangleCount).toBe(18); + }); + it('positions, normals, mix are same length (xyz vs xyz vs xy per vert)', () => { const mesh = buildFloraSolidMesh(0); const verts = mesh.positions.length / 3; 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/__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 50056276..7c10cc33 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'; @@ -66,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'; @@ -129,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 => @@ -377,8 +392,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 +437,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 @@ -500,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, @@ -507,6 +515,7 @@ export const createRideScene = async ( shadow: shadowResources, curve: waterCurve, edgesForProgress: waterEdgesForProgress, + grass: { profile: bankGrassProfile, strength: bankGrassStrength }, }); const waterPass = createWaterPass({ device, @@ -610,10 +619,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 +643,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 +776,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/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/engine/uniforms.ts b/src/lib/renderer/engine/uniforms.ts index b5da2243..d3ecce3a 100644 --- a/src/lib/renderer/engine/uniforms.ts +++ b/src/lib/renderer/engine/uniforms.ts @@ -113,20 +113,31 @@ 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 whole visible bank + * read as sprite cards and lost most of each tree's silhouette. React's + * scene has no sprite handoff at all — its trees draw as real geometry + * to the clipping plane — so the WebGPU hybrid LOD has to push the solid + * `far` well past the bank's own distance (~10–30 m from the chase + * camera on a typical river) to match that look. These values widen the + * window again (medium 16 → 60 m, high 20 → 100 m) so the cone-stack + * tree mesh carries the near AND mid bank; the sprite pass continues to + * fill in from `far` out to the fog. Solid rendering stays cheap: at ~1k + * instances per pool × ~60 tri/tree that's ~60k tris, well inside the + * engine's per-frame budget on Intel Gen-9. */ export const sproutWindowFor = (tier?: string): { near: number; far: number } => { switch (tier) { case 'basic': - return { near: 0, far: 6 }; + return { near: 0, far: 24 }; case 'low': - return { near: 0, far: 8 }; + return { near: 0, far: 40 }; case 'high': - return { near: 0, far: 12 }; + return { near: 0, far: 100 }; case 'extra-high': - return { near: 0, far: 15 }; + return { near: 0, far: 160 }; case 'medium': default: - return { near: 0, far: 10 }; + return { near: 0, far: 60 }; } }; 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/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/renderer/passes/floraSolid.wgsl b/src/lib/renderer/passes/floraSolid.wgsl index 40003d83..f9c26eaf 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,10 +73,47 @@ 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. Species 3 and 4 + // carry reeds and rocks (ported from React `vegetationComponents.tsx`): + // the mesh builder gives reeds `mix = (1, …)` so they land on the base + // palette (dry warm reed) and rocks `mix = (0, …)` so they land on the + // trunk-swap colour — a plain mid-grey piped through `TRUNK_PAL`. + const TRUNK_PAL = array, 5>( + vec3(0.33, 0.22, 0.14), + vec3(0.33, 0.22, 0.14), + vec3(0.33, 0.22, 0.14), + vec3(0.60, 0.52, 0.32), + vec3(0.42, 0.42, 0.44), + ); + const BASE_PAL = array, 5>( + vec3(0.20, 0.34, 0.18), + vec3(0.18, 0.26, 0.16), + vec3(0.32, 0.45, 0.22), + vec3(0.68, 0.60, 0.32), + vec3(0.46, 0.45, 0.46), + ); + const CROWN_PAL = array, 5>( + vec3(0.42, 0.60, 0.30), + vec3(0.30, 0.52, 0.28), + vec3(0.60, 0.74, 0.38), + vec3(0.82, 0.74, 0.38), + vec3(0.56, 0.55, 0.54), + ); + let sIdx = min(in.species, 4u); + let trunk = TRUNK_PAL[sIdx]; + 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 near = frame.fog.x; diff --git a/src/lib/renderer/passes/scenery.wgsl b/src/lib/renderer/passes/scenery.wgsl index 61d6f392..a24a167d 100644 --- a/src/lib/renderer/passes/scenery.wgsl +++ b/src/lib/renderer/passes/scenery.wgsl @@ -94,13 +94,17 @@ fn vs_main(@builtin(vertex_index) vi: u32, @builtin(instance_index) ii: u32) -> let species = packed & 0xffffu; let swaySeed = f32((packed >> 16u) & 0xffffu) / 65535.0; - // Species sizes (metres). Matches React's `FOLIAGE_SHAPE_SIZE` scaled down - // to a cleaner silhouette at chase-camera height — unscaled 14-17 m crossed - // quads form a visible wall at the horizon because the camera is only 2.6 m - // off the water. The procedural crown art reads as a single tree at ~4 m. - const SPECIES_HEIGHTS = array(4.0, 5.0, 3.5); - const SPECIES_WIDTHS = array(0.9, 0.55, 1.15); - let sIdx = min(species, 2u); + // Species sizes (metres). 0-2 are trees — React's `FOLIAGE_SHAPE_SIZE` + // scaled down to a cleaner silhouette at chase-camera height (unscaled + // 14-17 m quads form a visible wall at the horizon because the camera + // is only 2.6 m off the water). 3 is reed and 4 is rock: the solid pass + // owns their visual (ported #591-fu); the sprite billboard would read + // as a tree-shaped card at reed/rock positions so we collapse the quad + // to zero and let the fragment discard. Keeping the pool in the sprite + // iterator is cheaper than filtering it out on the CPU. + const SPECIES_HEIGHTS = array(4.0, 5.0, 3.5, 0.0, 0.0); + const SPECIES_WIDTHS = array(0.9, 0.55, 1.15, 0.0, 0.0); + let sIdx = min(species, 4u); let heightM = SPECIES_HEIGHTS[sIdx]; let widthFactor = SPECIES_WIDTHS[sIdx]; @@ -134,13 +138,18 @@ fn vs_main(@builtin(vertex_index) vi: u32, @builtin(instance_index) ii: u32) -> vec3(0.30, 0.52, 0.28), vec3(0.60, 0.74, 0.38), ); - out.baseCol = BASE_PAL[sIdx]; - out.crownCol = CROWN_PAL[sIdx]; + // Palettes are tree-only (3 entries): species 3/4 (reeds/rocks) ride the + // last tree palette, which never shows because `heightM = 0` collapses + // the billboard above and `species` is also clamped before the + // fragment reads the atlas layer. + let paletteIdx = min(species, 2u); + out.baseCol = BASE_PAL[paletteIdx]; + out.crownCol = CROWN_PAL[paletteIdx]; // Billboard UV: both crossed quads share the same (0..1, 0..1) space so // the same leaf texture tiles across both. local.x goes −0.6..0.6 and // local.y goes 0..1 before the per-species scale, so remap to UV. out.uv = vec2(local.x + 0.5, 1.0 - local.y); - out.species = sIdx; + out.species = paletteIdx; let d = p - frame.camPos; let worldDist = length(vec3(d.x, 0.0, d.z)); out.worldDist = worldDist; 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..2adb944c 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; } @@ -53,12 +52,19 @@ 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; - let col = mesh.baseColor.rgb * shade; + // 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; 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); } diff --git a/src/lib/renderer/plan/floraSolidPool.ts b/src/lib/renderer/plan/floraSolidPool.ts index 65df5bdb..8509a4f8 100644 --- a/src/lib/renderer/plan/floraSolidPool.ts +++ b/src/lib/renderer/plan/floraSolidPool.ts @@ -44,10 +44,20 @@ const seeded = (seed: number): (() => number) => { }; /** - * Build a seeded crown-and-trunk mesh. Trunk = 6-sided prism (12 tri); crown - * = octahedron truncated to a 12-vertex blob with 20 triangles. Total ~32 - * triangles per species — well inside the ~50 budget D7-a sets, with room - * for a canopy skirt the parity assertion counts (~50 lands with the skirt). + * Build a seeded crown-and-trunk mesh. The crown is a four-layer cone stack + * mirroring React's `vegetationComponents.tsx` cone0-cone3 silhouette + * (`CONE_LAYERS = [[2.8, 1.4, 2.2], [3.8, 1.1, 2.0], [4.6, 0.8, 1.8], + * [5.3, 0.4, 1.2]]`): a tree reads as a proper multi-tier pyramid instead + * of a single-ring blob, closing most of the WebGPU-vs-React silhouette + * gap on medium tier where kit trees are off. Trunk is a 6-sided prism + * matching the dimensions of React's trunk cylinder (2.4 m tall, radius + * ~0.12-0.22 m with a 0.3 m root cap). + * + * Triangle count lands around 120 — well above the pre-port 32-tri blob + * budget D7-a measured on the single-ring crown, but still cheap relative + * to the thousand-plus foliage instances a route pool runs: on this + * hardware the stage renders ~75k extra triangles per frame per bank and + * frame times stay inside the medium-tier budget. */ export const buildFloraSolidMesh = (speciesIndex: number): FloraSolidMesh => { const rand = seeded((speciesIndex + 7) * 2654435761); @@ -63,9 +73,71 @@ export const buildFloraSolidMesh = (speciesIndex: number): FloraSolidMesh => { return positions.length / 3 - 1; }; - // Trunk: hexagonal prism, 0 → 1.0 m tall, radius ~0.08. - const trunkRadius = 0.08 + rand() * 0.03; - const trunkTop = 1.0; + // Species 3 = reed cluster (ports React `vegetationComponents.tsx` + // reed billboards): three thin vertical quads crossed at ~60° so a reed + // reads the same from any camera angle. `mix.x = 1` (crown) and + // `mix.y = 1` (crown colour) so the fragment shader lands on the base + // palette without a trunk blend; the palette entry for species 3 in + // `floraSolid.wgsl` gives the dry-reed warm tan. + if (speciesIndex === 3) { + const bladeH = 0.9 + rand() * 0.4; + const bladeW = 0.08 + rand() * 0.04; + const blades = 3; + for (let b = 0; b < blades; b += 1) { + const theta = (b / blades) * Math.PI; + const nx = -Math.sin(theta); + const nz = Math.cos(theta); + const bx = Math.cos(theta) * bladeW; + const bz = Math.sin(theta) * bladeW; + const a = push([-bx, 0, -bz], [nx, 0.2, nz], [1, 0.1]); + const bq = push([bx, 0, bz], [nx, 0.2, nz], [1, 0.1]); + const c = push([bx, bladeH, bz], [nx, 0.2, nz], [1, 1]); + const d = push([-bx, bladeH, -bz], [nx, 0.2, nz], [1, 1]); + idx.push(a, bq, c, a, c, d); + // Back face so the reed reads from both sides even with cullMode: back. + idx.push(a, d, c, a, c, bq); + } + return finalise(positions, normals, mix, idx); + } + + // Species 4 = rock. An irregular 6-sided prism with a top cap, seeded + // per-vertex so no two rocks line up. Smaller than a tree by an order + // of magnitude (base radius 0.3-0.5 m) so a bank sprinkled with rocks + // reads as pebbles, not boulders. `mix.x = 0` keeps the trunk palette + // path (we point species 4 at the shared grey/brown in `floraSolid.wgsl`). + if (speciesIndex === 4) { + const baseR = 0.3 + rand() * 0.2; + const topR = baseR * (0.55 + rand() * 0.2); + const h = 0.3 + rand() * 0.25; + const sides = 6; + const baseIds: number[] = []; + const topIds: number[] = []; + for (let i = 0; i < sides; i += 1) { + const a = (i / sides) * Math.PI * 2; + const jitter = 0.85 + rand() * 0.3; + const rb = baseR * jitter; + const rt = topR * jitter; + baseIds.push(push([Math.cos(a) * rb, 0, Math.sin(a) * rb], [Math.cos(a), 0.4, Math.sin(a)], [0, 0])); + topIds.push(push([Math.cos(a) * rt, h, Math.sin(a) * rt], [Math.cos(a), 0.7, Math.sin(a)], [0, 0])); + } + const topCentre = push([0, h, 0], [0, 1, 0], [0, 0]); + for (let i = 0; i < sides; i += 1) { + const j = (i + 1) % sides; + // sides + idx.push(baseIds[i], baseIds[j], topIds[j], baseIds[i], topIds[j], topIds[i]); + // top fan + idx.push(topIds[i], topIds[j], topCentre); + } + return finalise(positions, normals, mix, idx); + } + + // Species 0-2 = trees. Trunk: hexagonal prism, 0 → 2.4 m tall to meet + // React's cylinder length. + // Root cap flares the base to 0.35 m to mirror the second trunk cylinder + // the React mesh stacks at y=0.1. + const trunkTop = 2.4; + const trunkTopRadius = 0.12 + rand() * 0.04; + const trunkBaseRadius = 0.22 + rand() * 0.05; const trunkRing = (y: number, r: number): number[] => { const ids: number[] = []; for (let i = 0; i < 6; i += 1) { @@ -78,8 +150,8 @@ export const buildFloraSolidMesh = (speciesIndex: number): FloraSolidMesh => { } return ids; }; - const trunkBase = trunkRing(0, trunkRadius); - const trunkCrown = trunkRing(trunkTop, trunkRadius * 0.85); + const trunkBase = trunkRing(0, trunkBaseRadius); + const trunkCrown = trunkRing(trunkTop, trunkTopRadius); for (let i = 0; i < 6; i += 1) { const j = (i + 1) % 6; const bi = trunkBase[i]; @@ -89,35 +161,60 @@ export const buildFloraSolidMesh = (speciesIndex: number): FloraSolidMesh => { idx.push(bi, bj, tj, bi, tj, ti); } - // Crown: octahedron scaled per-vertex with the seeded RNG. 6 verts, 8 tri. - // Add an additional "skirt" ring of 6 verts between the base and the tip - // so the handoff with sprite billboards doesn't pop (adds 12 tri). - const crownTop = trunkTop + 1.2 + rand() * 0.4; - const crownBottom = trunkTop + 0.15; - const crownRadius = 0.6 + rand() * 0.15; - const crownRing = (y: number, r: number): number[] => { - const ids: number[] = []; - for (let i = 0; i < 6; i += 1) { - const a = (i / 6) * Math.PI * 2 + rand() * 0.05; - const rr = r * (0.9 + rand() * 0.2); - ids.push(push([Math.cos(a) * rr, y, Math.sin(a) * rr], [Math.cos(a), 0.3, Math.sin(a)], [1, (y - crownBottom) / (crownTop - crownBottom)])); + // Foliage: four cone layers at the React y-positions and radii, each a + // 6-sided tapered ring to a tip. A cone is cheap (6 base + 1 tip = 12 + // triangles) and four of them in a `[2.2, 2.0, 1.8, 1.2]` radius stack + // give the Christmas-tree silhouette React's `CONE_LAYERS` paints. + const CONE_LAYERS: ReadonlyArray = [ + [2.8, 1.4, 2.2], + [3.8, 1.1, 2.0], + [4.6, 0.8, 1.8], + [5.3, 0.4, 1.2], + ]; + const SIDES = 6; + for (let layer = 0; layer < CONE_LAYERS.length; layer += 1) { + const [yc, halfH, baseR] = CONE_LAYERS[layer]; + const yBase = yc - halfH; + const yTip = yc + halfH; + // Seeded radius jitter per layer so no two species sit on identical + // silhouettes; stays well within the layer's base radius so the stack + // reads as a cone. + const r = baseR * (0.95 + rand() * 0.15); + const baseIds: number[] = []; + for (let i = 0; i < SIDES; i += 1) { + const a = (i / SIDES) * Math.PI * 2 + rand() * 0.03; + const cx = Math.cos(a) * r; + const cz = Math.sin(a) * r; + baseIds.push( + push([cx, yBase, cz], [Math.cos(a), 0.25, Math.sin(a)], [1, 0.3 + layer * 0.2]), + ); + } + const tipId = push([0, yTip, 0], [0, 1, 0], [1, 1]); + for (let i = 0; i < SIDES; i += 1) { + const j = (i + 1) % SIDES; + idx.push(baseIds[i], baseIds[j], tipId); + } + // Close the cone's base with a fan so a camera below the tree doesn't + // see straight through the canopy — a one-triangle-per-side cost that + // the sprout fade sweeps away past `sproutFar`. + const centreId = push([0, yBase, 0], [0, -1, 0], [1, 0.3 + layer * 0.2]); + for (let i = 0; i < SIDES; i += 1) { + const j = (i + 1) % SIDES; + idx.push(centreId, baseIds[j], baseIds[i]); } - return ids; - }; - const crownLow = crownRing(crownBottom, crownRadius * 0.75); - const crownMid = crownRing((crownBottom + crownTop) * 0.5, crownRadius); - const crownHigh = crownRing(crownTop - 0.3, crownRadius * 0.55); - const tip = push([0, crownTop, 0], [0, 1, 0], [1, 1]); - - for (let i = 0; i < 6; i += 1) { - const j = (i + 1) % 6; - idx.push(crownLow[i], crownLow[j], crownMid[j], crownLow[i], crownMid[j], crownMid[i]); - idx.push(crownMid[i], crownMid[j], crownHigh[j], crownMid[i], crownHigh[j], crownHigh[i]); - idx.push(crownHigh[i], crownHigh[j], tip); } + return finalise(positions, normals, mix, idx); +}; + +/** Final-pack shared across all species variants. */ +const finalise = ( + positions: number[], + normals: number[], + mix: number[], + idx: number[], +): FloraSolidMesh => { const triangleCount = idx.length / 3; - // Encode indices as uint16 — mesh is small enough. if (positions.length / 3 >= 0x10000) { throw new Error('solid flora mesh exceeded uint16 index budget'); } 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/__tests__/reedRockPlan.test.ts b/src/lib/scenery/__tests__/reedRockPlan.test.ts new file mode 100644 index 00000000..5e833d24 --- /dev/null +++ b/src/lib/scenery/__tests__/reedRockPlan.test.ts @@ -0,0 +1,76 @@ +/** + * Reed + rock placement parity (#591 follow-up). Ports the shape of + * React's `vegetationComponents.tsx` layout (`REED_MAX`, `ROCK_MAX`, per- + * profile density thinning) to the curve-based WebGPU scenery pipeline. + */ + +import { describe, it, expect } from 'vitest'; + +import { vec3 } from '../../renderer/math/vec3'; +import { RouteCurve } from '../../renderer/math/curve'; +import { + layoutReeds, + layoutRocks, + REED_SPECIES, + REED_MAX, + ROCK_SPECIES, + ROCK_MAX, +} from '../reedRockPlan'; + +const straightCurve = (lengthMetres: number): RouteCurve => + new RouteCurve( + [vec3(0, 0, 0), vec3(0, 0, -lengthMetres)], + false, + ); + +describe('layoutReeds / layoutRocks (#591-fu)', () => { + it('returns empty when there is no curve', () => { + expect(layoutReeds({ curve: null })).toEqual([]); + expect(layoutRocks({ curve: null })).toEqual([]); + }); + + it('every reed carries species 3 and every rock carries species 4', () => { + const curve = straightCurve(200); + const reeds = layoutReeds({ curve }); + const rocks = layoutRocks({ curve }); + for (const r of reeds) expect(r.species).toBe(REED_SPECIES); + for (const r of rocks) expect(r.species).toBe(ROCK_SPECIES); + }); + + it('stays under the React per-profile caps (REED_MAX, ROCK_MAX)', () => { + const curve = straightCurve(200); + expect(layoutReeds({ curve }).length).toBeLessThanOrEqual(REED_MAX); + expect(layoutRocks({ curve }).length).toBeLessThanOrEqual(ROCK_MAX); + }); + + it('is deterministic — same curve + no enrichment yields the same positions each call', () => { + const curve = straightCurve(200); + const a = layoutReeds({ curve }); + const b = layoutReeds({ curve }); + expect(a.length).toBe(b.length); + for (let i = 0; i < a.length; i += 1) { + expect(a[i].position).toEqual(b[i].position); + expect(a[i].progress).toBeCloseTo(b[i].progress, 10); + } + }); + + it('places props on both banks (sign distribution covers positive and negative perp offsets)', () => { + const curve = straightCurve(400); + const reeds = layoutReeds({ curve }); + // The straight curve is along -z, so the perpendicular is ±x. With a + // ~50/50 side roll the layout should land props on both sides. + const left = reeds.filter((r) => r.position[0] < 0).length; + const right = reeds.filter((r) => r.position[0] > 0).length; + expect(left).toBeGreaterThan(0); + expect(right).toBeGreaterThan(0); + }); + + it('places props outside the water edge (|x| ≥ waterHalfWidth + margin)', () => { + const curve = straightCurve(200); + // Fallback water half-width = 20/2 = 10 m, plus the scenery margin + the + // inner band — so no prop should sit inside the river itself. + for (const reed of layoutReeds({ curve })) { + expect(Math.abs(reed.position[0])).toBeGreaterThan(10); + } + }); +}); 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/scenery/reedRockPlan.ts b/src/lib/scenery/reedRockPlan.ts new file mode 100644 index 00000000..ca4b2a7d --- /dev/null +++ b/src/lib/scenery/reedRockPlan.ts @@ -0,0 +1,142 @@ +/** + * Reed and rock placement along the water edge (#591 follow-up), ported + * from `src/components/rower3d/vegetationComponents.tsx` on the sibling + * React main. Both layers are placed deterministically along the route + * curve, so a given route+seed produces the same positions on every run + * (and across both engines, future tests can assert tree-vs-reed + * independence like the existing foliage parity). + * + * React's layout works around a straight boat-centred z axis (±200 m + * window, side × 8..16 m x offset). That doesn't map onto a curvy OSM + * route, so the port parametrises by progress `t ∈ [0, 1]` along the + * curve instead, offsets perpendicular to the tangent by the same 8-16 m + * band, and scales the per-profile count by the profile's + * `groundCover.density` the same way React does (`reedCount = + * REED_MAX * densityScale`, `rockCount = ROCK_MAX * densityScale`). + * + * Both layers return the existing `FoliageTree` shape with the new + * species indices 3 (reed) and 4 (rock). `batchFoliagePools` bins them + * into pools the engine's solid-flora stage already knows how to render, + * so no new pool kind is needed; `floraSolidPool.buildFloraSolidMesh` + * switches on `speciesIndex` for the geometry, and + * `passes/floraSolid.wgsl` has size-5 palette arrays for the colour. + */ + +import type { RouteCurve } from '../renderer/math/curve'; +import type { FoliageTree } from './foliagePlan'; +import { SCENERY_PROFILES } from '../../components/rower3d/sceneryConfig'; +import { getSegmentSceneryProfile } from '../../components/rower3d/segmentScenery'; +import type { SceneryTrack } from '../../components/rower3d/sceneryTrack'; +import { + getWaterEdgesSceneUnitsForProgress, + type RouteEnrichmentData, + type SceneryProfile, +} from '../services/routeEnrichmentService'; +import { seededRandom } from './seededRandom'; +import { SCENERY_WATER_MARGIN_METRES } from './foliagePlan'; + +/** Species indices the solid flora stage recognises for the new layers. */ +export const REED_SPECIES = 3; +export const ROCK_SPECIES = 4; + +/** React caps from `vegetationComponents.tsx`. Density scales each one. */ +export const REED_MAX = 120; +export const ROCK_MAX = 40; + +/** Half-width of the x-band the props sit in, metres from the near bank. */ +const PROP_BAND_INNER = 2; +const PROP_BAND_OUTER = 10; + +export interface ReedRockLayoutInput { + curve: RouteCurve | null; + enrichment?: RouteEnrichmentData | null; + track?: SceneryTrack | null; +} + +const sideOf = (offset: number): 'left' | 'right' => (offset < 0 ? 'left' : 'right'); + +const waterHalfWidthAt = ( + enrichment: RouteEnrichmentData | null | undefined, + progress: number, + side: 'left' | 'right', +): number => { + const fallback = enrichment?.waterWidthMeters ?? 20; + return getWaterEdgesSceneUnitsForProgress(enrichment?.segmentProfiles, fallback, progress)[side]; +}; + +const placeOne = ( + curve: RouteCurve, + length: number, + enrichment: RouteEnrichmentData | null | undefined, + track: SceneryTrack | null, + index: number, + species: number, + salt: number, +): FoliageTree | null => { + const seed = index * 17 + salt; + const draw = (k: number): number => seededRandom(seed * 13 + k); + const t = draw(1); + const sign = draw(2) > 0.5 ? 1 : -1; + const rawProfile = getSegmentSceneryProfile(enrichment, t, track, sideOf(sign)); + const profile: SceneryProfile = SCENERY_PROFILES[rawProfile] ? rawProfile : 'fallback'; + const density = SCENERY_PROFILES[profile].groundCover.density; + // Thinning per profile: a biome whose groundCover.density is low plants + // fewer props. Mirrors React's `densityScale` multiplier but applied as a + // per-instance roll so the sprinkle thins instead of a hard cap. + if (draw(3) > density) return null; + const sample = curve.sample(Math.min(0.999, t)); + const px = -sample.tangent[2]; + const pz = sample.tangent[0]; + const perpLen = Math.hypot(px, pz) || 1; + const perpX = px / perpLen; + const perpZ = pz / perpLen; + const halfWidth = waterHalfWidthAt(enrichment, t, sideOf(sign)); + const near = halfWidth + SCENERY_WATER_MARGIN_METRES + PROP_BAND_INNER; + const far = near + (PROP_BAND_OUTER - PROP_BAND_INNER); + const offset = near + draw(4) * (far - near); + const x = sample.position[0] + perpX * sign * offset; + const z = sample.position[2] + perpZ * sign * offset; + const sway = Math.PI * 2 * draw(5); + // length is unused in the position math but kept in the signature so + // callers can log/debug how spread the layout went. + void length; + return { + position: [x, 0, z], + progress: t, + species, + height: 1, + yaw: sway, + }; +}; + +export const layoutReeds = ({ + curve, + enrichment = null, + track = null, +}: ReedRockLayoutInput): FoliageTree[] => { + if (!curve) return []; + const length = curve.approximateLength(128); + if (!(length > 0)) return []; + const out: FoliageTree[] = []; + for (let i = 0; i < REED_MAX; i += 1) { + const tree = placeOne(curve, length, enrichment, track, i, REED_SPECIES, 101); + if (tree) out.push(tree); + } + return out; +}; + +export const layoutRocks = ({ + curve, + enrichment = null, + track = null, +}: ReedRockLayoutInput): FoliageTree[] => { + if (!curve) return []; + const length = curve.approximateLength(128); + if (!(length > 0)) return []; + const out: FoliageTree[] = []; + for (let i = 0; i < ROCK_MAX; i += 1) { + const tree = placeOne(curve, length, enrichment, track, i, ROCK_SPECIES, 211); + if (tree) out.push(tree); + } + return out; +}; diff --git a/src/lib/ui/sceneryRoute.ts b/src/lib/ui/sceneryRoute.ts index 33d15aa3..a1bf7c0f 100644 --- a/src/lib/ui/sceneryRoute.ts +++ b/src/lib/ui/sceneryRoute.ts @@ -46,6 +46,7 @@ import { FOLIAGE_SHAPES, layoutFoliage, } from '../scenery/foliagePlan'; +import { layoutReeds, layoutRocks, ROCK_SPECIES } from '../scenery/reedRockPlan'; import { layoutLandscape, layoutToBuildingSpecs } from '../scenery/landscapeLayout'; import { getRouteSceneryTrack, @@ -54,6 +55,7 @@ import { import { SCENERY_PROFILES } from '../../components/rower3d/sceneryConfig'; import type { BuildingSpec } from '../renderer/plan/buildings'; import { + budgetFor, computePlacements, distinctProfiles, keepsKitTrees, @@ -126,6 +128,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. */ @@ -190,9 +199,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 = DEFAULT_SCENERY_TIER, ): EngineRoute | null => { if (!route) return null; @@ -232,8 +251,17 @@ export const toEngineRoute = ( track, speciesTypes: FOLIAGE_SHAPES, }); + // #591-fu: reeds + rocks go through the same pool-batching path as the + // tree layouts (same `FoliageTree` shape, species indices 3/4). The + // solid-flora stage switches geometry on `speciesIndex` and the sprite + // pass collapses the billboard to zero for 3/4, so no new pool kind is + // needed. + const reeds = layoutReeds({ curve, enrichment: enrichment ?? null, track }); + const rocks = layoutRocks({ curve, enrichment: enrichment ?? null, track }); + const groundProps = [...trees, ...reeds, ...rocks]; + const speciesCount = Math.max(FOLIAGE_SHAPES.length, ROCK_SPECIES + 1); const routeLength = (totalDistanceMeters ?? curve.approximateLength(32)) || 1; - pools.push(...batchFoliagePools(trees, FOLIAGE_SHAPES.length, routeLength, FOLIAGE_CHUNK_METRES)); + pools.push(...batchFoliagePools(groundProps, speciesCount, routeLength, FOLIAGE_CHUNK_METRES)); const landscape = layoutLandscape({ curve, enrichment: enrichment ?? null, track }); buildings = layoutToBuildingSpecs(landscape, SCENERY_PROFILES); @@ -252,8 +280,15 @@ 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 #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); const region = resolveRegion(route.coordinates ?? null); const resolvedByProfile = new Map(); @@ -283,6 +318,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 } : {}), @@ -291,5 +333,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 @@