Skip to content
Open
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
20 changes: 20 additions & 0 deletions src/__tests__/sceneryRoute.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<typeof toEngineRoute>) =>
(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: [
Expand Down
1 change: 1 addition & 0 deletions src/lib/renderer/__tests__/_mockWebGPU.ts
Original file line number Diff line number Diff line change
Expand Up @@ -177,6 +177,7 @@ export const makeMockDevice = (): MockDevice => {
destroy: () => undefined,
queue: {
writeBuffer: () => undefined,
writeTexture: () => undefined,
submit: () => {
log.submits += 1;
},
Expand Down
17 changes: 11 additions & 6 deletions src/lib/renderer/__tests__/floraLod.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)', () => {
Expand Down
27 changes: 23 additions & 4 deletions src/lib/renderer/__tests__/floraSolidPool.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
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']);
});
});
81 changes: 81 additions & 0 deletions src/lib/renderer/engine/__tests__/shaderGrass.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
Loading