diff --git a/docs/guide/forest-zone.md b/docs/guide/forest-zone.md index 26e0e620..d94a7255 100644 --- a/docs/guide/forest-zone.md +++ b/docs/guide/forest-zone.md @@ -3,7 +3,7 @@ The forest zone (E1.3) is the first playable environment in the Phase-1 vertical slice. It lives in `src/scenes/forestScene.ts` and wires together the full gameplay spine — streaming assets, third-person controller, follow camera — over -a grassy ground plane with a sparse scatter of low-poly trees, huts, and +a grassy ground plane with a thin-instanced conifer field, streamed huts, and lightweight stump/log/rock/shrub clutter around the spawn clearing. ## Try it @@ -61,17 +61,61 @@ conform before placement. `seedForestAssets` registers the tree and hut URLs. Each prop calls `spawnStreamedInstance` which shows a placeholder box immediately and swaps to the GLB on load. -3. **Props** — 12 streamed trees, 3 streamed huts, 4 healing chests, and the - leftover cargo/wagon/elf decor placed at hard-coded (x, z) coordinates, plus - cheap procedural stumps, logs, rocks, and shrubs around the spawn clearing. - The inner 3.5-unit radius stays clear so the first combat beats have readable - movement space. +3. **Props** — 3 streamed huts, 4 healing chests, and the leftover + cargo/wagon/elf decor placed at hard-coded (x, z) coordinates, plus cheap + procedural stumps, logs, rocks, and shrubs around the spawn clearing. The + inner 3.5-unit radius stays clear so the first combat beats have readable + movement space. The conifers themselves are now a thin-instanced field + (see **Conifer field** below), not streamed placements. 4. **Healing chests** — `FOREST_HEALING_CHEST_SPECS` mirrors the streamed chest placements. Standing within 2.25 m of a chest restores 5 HP every 0.5 s via `onPlayerHealed`; chests are reusable cooldown stations, not consumed pickups. 5. **Controller + camera** — the same `CharacterController` and `ThirdPersonCamera` from E1.1, spawned at `(0, 2, 0)` above the ground. +## Conifer field (FLO-482) + +The forest's trees are a **thin-instanced conifer field**, not streamed +placements. Two reasons drove the change: + +- **The streamed trees collapsed to one.** The `AssetStreamLoader` ref-counts a + single cached model per asset id, so every `env.forest-tree` placement in the + zone manifest shared the same root — the old "12 trees" only ever rendered as + one. The tree placements were removed from `FOREST_MANIFEST`; `forest-tree.glb` + is now loaded directly by the field. +- **The GLB ships white.** `forest-tree.glb` is a single mesh with no material + and no textures, so it rendered white — which the board flagged as wrong. + +`scenes/forestTrees.ts` owns the field: + +- `mountForestTreeField(scene, options)` — fire-and-forget (mirrors + `survivorAvatar`): loads the GLB once, then builds the field. A failed fetch + (headless tests) is swallowed; `forestTreeLoader: null` on `createForestScene` + skips it entirely. +- `applyForestTreeFoliage` — hard-facets the mesh (`convertToFlatShadedMesh`, + v1.2 read) and paints it a **flat matte green** (`Color3(0.13, 0.4, 0.16)`, + near-zero specular, **no texture**). This is what stops the trees rendering + white. The GLB is a single mesh, so there is no separate trunk submesh to + colour — the whole conifer is green. +- `buildForestTreeField` → `createInstancedVegetation` — thin-instances the + greened prototype across the scatter. The whole field costs **one draw call per + submesh regardless of tree count**, so the higher count carries no perf + regression. + +The scatter is a pure, seeded generator — +`generateForestTreePlacements` in `game/streaming/forestTreeField.ts`: + +- **Count** — `FOREST_TREE_FIELD_COUNT` (64), comfortably more than the old dozen. +- **Layout** — a non-grid ring from `FOREST_TREE_FIELD_CLEARING_RADIUS` (7, spawn + stays clear) to `FOREST_TREE_FIELD_OUTER_RADIUS` (58), `sqrt`-weighted so + density is even across the disc. +- **Size** — each tree gets a uniform random scale in **×1…×3** + (`FOREST_TREE_FIELD_MIN_SCALE`…`FOREST_TREE_FIELD_MAX_SCALE`) of the ×1 authored + size (`FOREST_TREE_BASE_SIZE`, 4 units), plus a random yaw. +- **Deterministic** — seeded by the fixed `FOREST_TREE_FIELD_SEED` (never + `Date.now()`), so the forest is reproducible and the Vitest scatter/scale/ + material assertions never flake. + ## Avatar & ground feel The player is rendered by the **flat-albedo survivor GLB** diff --git a/docs/verification/flo482-forest-conifer-field.png b/docs/verification/flo482-forest-conifer-field.png new file mode 100644 index 00000000..68d7dd2b --- /dev/null +++ b/docs/verification/flo482-forest-conifer-field.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:a35733f5ad7386607f6098079dba004de140eff177fa14ec89ace500aa836750 +size 238262 diff --git a/src/game/streaming/forestTreeField.test.ts b/src/game/streaming/forestTreeField.test.ts new file mode 100644 index 00000000..86ef7131 --- /dev/null +++ b/src/game/streaming/forestTreeField.test.ts @@ -0,0 +1,73 @@ +import { describe, expect, it } from 'vitest' +import { + FOREST_TREE_FIELD_CLEARING_RADIUS, + FOREST_TREE_FIELD_COUNT, + FOREST_TREE_FIELD_MAX_SCALE, + FOREST_TREE_FIELD_MIN_SCALE, + FOREST_TREE_FIELD_OUTER_RADIUS, + generateForestTreePlacements, +} from './forestTreeField' + +const radius = (p: { position: { x: number; z: number } }) => + Math.hypot(p.position.x, p.position.z) + +describe('generateForestTreePlacements', () => { + it('scatters the configured number of trees', () => { + expect(generateForestTreePlacements()).toHaveLength(FOREST_TREE_FIELD_COUNT) + expect(generateForestTreePlacements({ count: 30 })).toHaveLength(30) + }) + + it('places noticeably more trees than the 12 the streamed manifest used to', () => { + // FLO-482 acceptance: "noticeably more trees" than the old hand-placed dozen. + expect(FOREST_TREE_FIELD_COUNT).toBeGreaterThan(12 * 2) + }) + + it('keeps the spawn clearing clear and stays within the outer ring', () => { + for (const p of generateForestTreePlacements()) { + const r = radius(p) + expect(r).toBeGreaterThanOrEqual(FOREST_TREE_FIELD_CLEARING_RADIUS - 1e-6) + expect(r).toBeLessThanOrEqual(FOREST_TREE_FIELD_OUTER_RADIUS + 1e-6) + } + }) + + it('grounds every tree at y = 0', () => { + for (const p of generateForestTreePlacements()) { + expect(p.position.y).toBe(0) + } + }) + + it('gives each tree a random scale inside the ×1…×3 band', () => { + for (const p of generateForestTreePlacements()) { + expect(p.scale).toBeGreaterThanOrEqual(FOREST_TREE_FIELD_MIN_SCALE) + expect(p.scale).toBeLessThan(FOREST_TREE_FIELD_MAX_SCALE) + } + expect(FOREST_TREE_FIELD_MIN_SCALE).toBe(1) + expect(FOREST_TREE_FIELD_MAX_SCALE).toBe(3) + }) + + it('actually varies the sizes (not all the same scale)', () => { + const scales = generateForestTreePlacements().map((p) => p.scale ?? 1) + const distinct = new Set(scales.map((s) => s.toFixed(3))) + // A real spread of sizes, not a single repeated value. + expect(distinct.size).toBeGreaterThan(10) + expect(Math.max(...scales) - Math.min(...scales)).toBeGreaterThan(1) + }) + + it('varies yaw so the scatter does not read as a uniform grid', () => { + const yaws = generateForestTreePlacements().map((p) => p.rotationY ?? 0) + expect(new Set(yaws.map((y) => y.toFixed(3))).size).toBeGreaterThan(10) + }) + + it('is deterministic: the same seed yields the identical forest (no flake)', () => { + expect(generateForestTreePlacements()).toEqual(generateForestTreePlacements()) + expect(generateForestTreePlacements({ seed: 7 })).toEqual( + generateForestTreePlacements({ seed: 7 }), + ) + }) + + it('produces a different forest for a different seed', () => { + expect(generateForestTreePlacements({ seed: 1 })).not.toEqual( + generateForestTreePlacements({ seed: 2 }), + ) + }) +}) diff --git a/src/game/streaming/forestTreeField.ts b/src/game/streaming/forestTreeField.ts new file mode 100644 index 00000000..1c8e0087 --- /dev/null +++ b/src/game/streaming/forestTreeField.ts @@ -0,0 +1,113 @@ +import { createRng, type Rng } from '../util' +import type { VegetationPlacement } from './instancedVegetation' + +/** + * Deterministic forest-tree scatter (FLO-482). + * + * The forest's conifers are a single GLB (`forest-tree.glb`) thin-instanced + * across the start zone via {@link createInstancedVegetation}. This module owns + * the *placement data* — a pure, engine-free function that scatters many trees in + * a ring around the spawn clearing with a per-tree random size and yaw. + * + * Why a seeded scatter instead of hand-placed positions: the board asked for + * "noticeably more trees, random sizes ×1–×3, not a grid" (FLO-482). A seeded + * PRNG ({@link createRng}, mulberry32) gives an organic, non-grid spread that is + * still **deterministic** — the same seed always yields the same forest — so the + * scatter is unit-testable and never flakes (no `Date.now()` seeding). + * + * The streamed single-tree manifest entry it replaces collapsed every placement + * onto one shared cached root (the loader ref-counts one model per asset id), so + * the old forest really only ever showed one tree. Thin-instancing draws the + * whole field in one draw call per submesh regardless of count — no perf + * regression from the higher tree count. + */ + +/** Tunables for {@link generateForestTreePlacements}. All distances in scene units. */ +export interface ForestTreeFieldOptions { + /** How many trees to scatter. */ + readonly count?: number + /** No tree is placed within this radius of the origin (keeps the spawn/combat clearing open). */ + readonly clearingRadius?: number + /** Outer radius of the scatter ring. */ + readonly outerRadius?: number + /** Minimum per-tree scale multiplier (×1 = the prototype's authored size). */ + readonly minScale?: number + /** Maximum per-tree scale multiplier (×3 of the prototype's authored size). */ + readonly maxScale?: number + /** PRNG seed — fixed for a reproducible forest; never `Date.now()` (flake-free tests). */ + readonly seed?: number +} + +/** + * Number of conifers scattered into the forest start zone. Comfortably more than + * the 12 hand-placed positions the streamed manifest used to declare, so the + * zone reads as a thicket (FLO-482 acceptance: "noticeably more trees"). + */ +export const FOREST_TREE_FIELD_COUNT = 64 + +/** Radius kept clear of trees around the player spawn so combat/spawn props stay open. */ +export const FOREST_TREE_FIELD_CLEARING_RADIUS = 7 + +/** Outer edge of the scatter ring — densifies the playable forest around spawn without filling the 600 m world. */ +export const FOREST_TREE_FIELD_OUTER_RADIUS = 58 + +/** Smallest tree = the prototype's authored size (×1). */ +export const FOREST_TREE_FIELD_MIN_SCALE = 1 + +/** Largest tree = ×3 of the prototype's authored size (FLO-482: "random ×1…×3"). */ +export const FOREST_TREE_FIELD_MAX_SCALE = 3 + +/** Fixed seed so the scatter is reproducible across runs, builds, and tests. */ +export const FOREST_TREE_FIELD_SEED = 0x0f0_4_82 + +/** Random float in `[min, max)` drawn from `rng`. */ +function randRange(rng: Rng, min: number, max: number): number { + return min + rng() * (max - min) +} + +/** + * Generate a deterministic scatter of forest trees as {@link VegetationPlacement}s. + * + * Each tree gets a uniformly random angle, a radius in + * `[clearingRadius, outerRadius)` weighted by `sqrt` so density is even across the + * disc (not bunched near the centre), a random yaw, and a random uniform scale in + * `[minScale, maxScale)`. Pure + seeded: the same options always produce the same + * array, so callers (and tests) get a stable forest. + */ +export function generateForestTreePlacements( + options: ForestTreeFieldOptions = {}, +): VegetationPlacement[] { + const { + count = FOREST_TREE_FIELD_COUNT, + clearingRadius = FOREST_TREE_FIELD_CLEARING_RADIUS, + outerRadius = FOREST_TREE_FIELD_OUTER_RADIUS, + minScale = FOREST_TREE_FIELD_MIN_SCALE, + maxScale = FOREST_TREE_FIELD_MAX_SCALE, + seed = FOREST_TREE_FIELD_SEED, + } = options + + const rng = createRng(seed) + const placements: VegetationPlacement[] = [] + + for (let i = 0; i < count; i++) { + const angle = randRange(rng, 0, Math.PI * 2) + // sqrt-weighted radius → uniform areal density across the annulus (an + // un-weighted radius clumps trees toward the inner clearing edge). + const t = rng() + const radius = Math.sqrt( + clearingRadius * clearingRadius + + t * (outerRadius * outerRadius - clearingRadius * clearingRadius), + ) + placements.push({ + position: { + x: Math.cos(angle) * radius, + y: 0, + z: Math.sin(angle) * radius, + }, + rotationY: randRange(rng, 0, Math.PI * 2), + scale: randRange(rng, minScale, maxScale), + }) + } + + return placements +} diff --git a/src/game/streaming/index.ts b/src/game/streaming/index.ts index 6cb0811b..be3d0235 100644 --- a/src/game/streaming/index.ts +++ b/src/game/streaming/index.ts @@ -40,6 +40,16 @@ export { type VegetationPlacement, type VegetationDrawCallStats, } from './instancedVegetation' +export { + generateForestTreePlacements, + FOREST_TREE_FIELD_COUNT, + FOREST_TREE_FIELD_CLEARING_RADIUS, + FOREST_TREE_FIELD_OUTER_RADIUS, + FOREST_TREE_FIELD_MIN_SCALE, + FOREST_TREE_FIELD_MAX_SCALE, + FOREST_TREE_FIELD_SEED, + type ForestTreeFieldOptions, +} from './forestTreeField' export type { AssetLoadPhase, AssetMetadata, AssetRecord } from './types' import type { Scene } from '@babylonjs/core' diff --git a/src/game/streaming/zoneManifests.test.ts b/src/game/streaming/zoneManifests.test.ts index 99c4bf17..b66be776 100644 --- a/src/game/streaming/zoneManifests.test.ts +++ b/src/game/streaming/zoneManifests.test.ts @@ -23,9 +23,11 @@ describe('zone manifests', () => { it('streams the forest props plus healing chests and leftover decor', () => { const forest = getZoneManifest('forest') + // Conifers moved off the manifest to a thin-instanced field (FLO-482) — the + // streamed placements collapsed onto one shared cached root. No tree streams. const trees = forest.placements.filter((p) => p.assetId === FOREST_TREE_ASSET_ID) const huts = forest.placements.filter((p) => p.assetId === WOODEN_HUT_ASSET_ID) - expect(trees).toHaveLength(12) + expect(trees).toHaveLength(0) expect(huts).toHaveLength(3) const watchtower = forest.placements.find((p) => p.assetId === FOREST_WATCHTOWER_ASSET_ID) expect(watchtower?.position).toEqual({ x: 12.5, y: 0, z: -13.5 }) diff --git a/src/game/streaming/zoneManifests.ts b/src/game/streaming/zoneManifests.ts index cc28adda..f11a9e20 100644 --- a/src/game/streaming/zoneManifests.ts +++ b/src/game/streaming/zoneManifests.ts @@ -32,21 +32,14 @@ export const FOREST_STATIC_ELF_ASSET_ID = 'npc.forest-static-elf' /** Ruined watchtower placed in the first forest map as a landmark (FLO-476). */ export const FOREST_WATCHTOWER_ASSET_ID = 'landmark.forest-watchtower' -/** Tree positions: (x, z) pairs in scene units. Keeps a 4-unit clearing. */ -const FOREST_TREE_POSITIONS: readonly [number, number][] = [ - [8, 2], - [-7, 3], - [5, -9], - [-9, -5], - [12, -6], - [-11, 7], - [3, 11], - [-4, -12], - [9, 9], - [-13, -2], - [14, 3], - [-6, 13], -] +// Forest conifers are no longer streamed via this manifest (FLO-482). The +// streaming loader ref-counts ONE cached model per asset id, so every +// `FOREST_TREE_ASSET_ID` placement collapsed onto a single shared root and the +// forest only ever showed one (white, material-less) tree. The trees are now a +// greened, thin-instanced ×1–×3 scatter — see `scenes/forestTrees.ts` and +// `generateForestTreePlacements` — drawn in one call per submesh regardless of +// count. `FOREST_TREE_ASSET_ID` stays registered in `seedForestAssets` as the +// canonical asset id (the field loads the same GLB directly). /** Hut positions: a small settlement at one edge of the clearing. */ const FOREST_HUT_POSITIONS: readonly [number, number][] = [ @@ -115,7 +108,6 @@ function ground([x, z]: readonly [number, number]): Vec3 { const FOREST_MANIFEST: ZoneManifest = { id: 'forest', placements: [ - ...FOREST_TREE_POSITIONS.map((p) => ({ assetId: FOREST_TREE_ASSET_ID, position: ground(p) })), ...FOREST_HUT_POSITIONS.map((p) => ({ assetId: WOODEN_HUT_ASSET_ID, position: ground(p) })), ...FOREST_LEFTOVER_PLACEMENTS, ], diff --git a/src/scenes/forestScene.ts b/src/scenes/forestScene.ts index b287fd4d..31931f65 100644 --- a/src/scenes/forestScene.ts +++ b/src/scenes/forestScene.ts @@ -56,6 +56,8 @@ import { CorpseManager } from './corpseManager' import { getZoneContent, type EncounterKind } from '../game/world' import { ZONE_MAPS } from '../game/world/mapProps' import { renderMapProps } from './mapPropsRenderer' +import { mountForestTreeField } from './forestTrees' +import type { LoadedModel } from './modelLoader' import { createHealingChestStates, tickHealingChests, @@ -320,6 +322,13 @@ export interface ForestSceneOptions { onStaminaChange?: (current: number, max: number) => void /** Caravan visual GLB. `null` skips it for headless scene tests. */ caravanVisualUrl?: string | null + /** + * Forest conifer-field loader seam (FLO-482). Loads the tree GLB that gets + * greened + thin-instanced across the deterministic scatter. `null` skips the + * field entirely; a function injects a prototype for tests; `undefined` + * (default) fetches the real `forest-tree.glb`. + */ + forestTreeLoader?: ((scene: Scene) => Promise) | null } export interface ForestScene { @@ -442,6 +451,7 @@ export function createForestScene( onMinimapTick, onStaminaChange, caravanVisualUrl = heroUrl === null ? null : undefined, + forestTreeLoader, } = options const engine = createEngine(canvas) @@ -467,6 +477,15 @@ export function createForestScene( // non-pickable so the player passes through; swap to streamed GLBs later. const mapProps = renderMapProps(scene, ZONE_MAPS[FOREST_ZONE_ID]) + // Conifer field (FLO-482): the forest tree GLB is greened (it ships white/ + // material-less) and thin-instanced across a deterministic ×1–×3 scatter — one + // draw call per submesh regardless of count. `forestTreeLoader: null` skips it + // for headless scene tests that don't exercise the field. + const treeField = + forestTreeLoader === null + ? null + : mountForestTreeField(scene, { loadTree: forestTreeLoader ?? undefined }) + // ------------------------------------------------------------------ // Streaming — the zone's environment is streamed via the // ZoneStreamingManager (E3.2 / FLO-345). Entering the forest manifest loads @@ -777,6 +796,7 @@ export function createForestScene( `[zone] dispose ${FOREST_ZONE_ID} (${zoneManager.residentInstanceCount} streamed instances)`, ) zoneManager.dispose() + treeField?.dispose() // dispose the conifer field (or cancel a pending load) mapProps.dispose(false, true) // disposes the thin-instanced map + its materials for (const s of soldiers) s.dispose() for (const a of archers) a.dispose() diff --git a/src/scenes/forestTrees.test.ts b/src/scenes/forestTrees.test.ts new file mode 100644 index 00000000..f2b7d206 --- /dev/null +++ b/src/scenes/forestTrees.test.ts @@ -0,0 +1,107 @@ +import { + MeshBuilder, + NullEngine, + Scene, + StandardMaterial, + TransformNode, +} from '@babylonjs/core' +import { describe, expect, it } from 'vitest' +import { + FOREST_TREE_FOLIAGE_COLOR, + applyForestTreeFoliage, + buildForestTreeField, + mountForestTreeField, +} from './forestTrees' +import type { LoadedModel } from './modelLoader' + +/** A minimal stand-in for a loaded forest-tree GLB: a material-less box under a root. */ +function fakePrototype(scene: Scene): LoadedModel { + const root = new TransformNode('model:forest-tree.glb', scene) + const trunk = MeshBuilder.CreateBox('trunk', { size: 1 }, scene) + trunk.parent = root + // Mirror the shipped GLB: no material at all (it renders white). + return { root, meshes: [trunk] } +} + +describe('applyForestTreeFoliage', () => { + it('paints the material-less tree a flat matte green (no texture)', () => { + const scene = new Scene(new NullEngine()) + const proto = fakePrototype(scene) + const mat = applyForestTreeFoliage(scene, proto.meshes) + + expect(mat).toBeInstanceOf(StandardMaterial) + expect(mat.diffuseColor.equals(FOREST_TREE_FOLIAGE_COLOR)).toBe(true) + // Matte v1.2 read: near-zero specular, and crucially NO diffuse texture. + expect(mat.diffuseTexture).toBeFalsy() + expect(mat.specularColor.r).toBeLessThan(0.1) + + // The (formerly white) mesh now carries the green material and is non-pickable. + expect(scene.getMeshByName('trunk')!.material).toBe(mat) + expect(scene.getMeshByName('trunk')!.isPickable).toBe(false) + scene.dispose() + }) + + it('reads green, not white', () => { + // Guard the board's actual complaint: needles must not be white. + const { r, g, b } = FOREST_TREE_FOLIAGE_COLOR + expect(g).toBeGreaterThan(r) + expect(g).toBeGreaterThan(b) + expect(r + g + b).toBeLessThan(2.5) // nowhere near white (3.0) + }) +}) + +describe('buildForestTreeField', () => { + it('thin-instances the greened prototype across the scatter', () => { + const scene = new Scene(new NullEngine()) + const field = buildForestTreeField(scene, fakePrototype(scene), { count: 20 }) + + expect(field.instanceCount).toBe(20) + // One draw call per geometry submesh regardless of the 20 trees (no perf regression). + expect(field.drawCalls).toBe(1) + for (const mesh of field.meshes) { + expect(mesh.thinInstanceCount).toBe(20) + expect(mesh.material).toBeInstanceOf(StandardMaterial) + } + field.dispose() + scene.dispose() + }) +}) + +describe('mountForestTreeField', () => { + it('fills in the vegetation once the injected loader resolves', async () => { + const scene = new Scene(new NullEngine()) + let resolveLoad!: (m: LoadedModel) => void + const loaded = new Promise((res) => { + resolveLoad = res + }) + + const mount = mountForestTreeField(scene, { + loadTree: () => loaded, + field: { count: 8 }, + }) + expect(mount.vegetation).toBeNull() // not loaded yet + + resolveLoad(fakePrototype(scene)) + await loaded + await Promise.resolve() // let the .then() chain settle + + expect(mount.vegetation).not.toBeNull() + expect(mount.vegetation!.instanceCount).toBe(8) + + mount.dispose() + expect(() => mount.dispose()).not.toThrow() // idempotent + scene.dispose() + }) + + it('swallows a failed load and leaves the field empty', async () => { + const scene = new Scene(new NullEngine()) + const mount = mountForestTreeField(scene, { + loadTree: () => Promise.reject(new Error('no GLB in headless tests')), + }) + await Promise.resolve() + await Promise.resolve() + expect(mount.vegetation).toBeNull() + mount.dispose() + scene.dispose() + }) +}) diff --git a/src/scenes/forestTrees.ts b/src/scenes/forestTrees.ts new file mode 100644 index 00000000..e0836556 --- /dev/null +++ b/src/scenes/forestTrees.ts @@ -0,0 +1,133 @@ +import { type AbstractMesh, Color3, type Scene, StandardMaterial } from '@babylonjs/core' +import { facetMeshes } from '../game/util' +import { + createInstancedVegetation, + generateForestTreePlacements, + type ForestTreeFieldOptions, + type InstancedVegetation, +} from '../game/streaming' +import { type LoadedModel, loadModel } from './modelLoader' + +/** + * Forest conifer field (FLO-482). + * + * The forest tree GLB (`forest-tree.glb`) ships as a single mesh with **no + * material and no textures**, so it renders white — which the board flagged as + * "совсем странно" (just weird). This module loads the GLB once, paints its + * needles a flat matte green (no textures, v1.2 faceted band), and thin-instances + * it across a deterministic scatter ({@link generateForestTreePlacements}) with + * per-tree random size ×1–×3. + * + * It supersedes the old streamed single-tree manifest entry, which collapsed all + * its placements onto one shared cached root (the loader ref-counts one model per + * asset id) and so only ever showed one tree. Thin-instancing keeps the whole + * field at one draw call per submesh regardless of count — no perf regression. + * + * Mount is **fire-and-forget**, mirroring `survivorAvatar.ts`: the scene factory + * stays synchronous, the field pops in once the GLB resolves, and a failed fetch + * (headless tests) is swallowed. + */ + +/** Flat matte conifer green for the needle foliage — no textures (FLO-482). */ +export const FOREST_TREE_FOLIAGE_COLOR = new Color3(0.13, 0.4, 0.16) + +/** Authored size (longest extent, scene units) of a ×1 tree before per-instance scaling. */ +export const FOREST_TREE_BASE_SIZE = 4 + +/** + * Paint the loaded tree meshes a flat matte green and hard-facet them for the + * v1.2 low-poly read. The GLB has no material of its own, so this is what stops + * the trees rendering white. Returns the shared material (disposed with the + * meshes). Exported for direct unit testing under a headless `NullEngine`. + */ +export function applyForestTreeFoliage( + scene: Scene, + meshes: readonly AbstractMesh[], +): StandardMaterial { + const mat = new StandardMaterial('forestTreeFoliageMat', scene) + mat.diffuseColor = FOREST_TREE_FOLIAGE_COLOR + mat.specularColor = new Color3(0.03, 0.03, 0.03) // matte, near-zero specular (v1.2) + // Facet first (rebuilds geometry to hard edges), then paint so no white shows. + facetMeshes(meshes) + for (const mesh of meshes) { + mesh.material = mat + mesh.isPickable = false + } + return mat +} + +/** + * Green + facet a loaded tree prototype, then thin-instance it across the + * deterministic forest scatter. Synchronous engine wiring split out from the + * async load so it is unit-testable with a hand-built prototype under `NullEngine`. + */ +export function buildForestTreeField( + scene: Scene, + prototype: LoadedModel, + options: ForestTreeFieldOptions = {}, +): InstancedVegetation { + applyForestTreeFoliage(scene, prototype.meshes) + // Centre the prototype so per-placement matrices are pure world transforms. + prototype.root.position.set(0, 0, 0) + const placements = generateForestTreePlacements(options) + return createInstancedVegetation(prototype.root, prototype.meshes, placements) +} + +/** A live forest-tree field handle. `vegetation` is `null` until the GLB resolves. */ +export interface ForestTreeFieldMount { + /** The instanced batch, or `null` before the GLB load resolves (or if it failed). */ + readonly vegetation: InstancedVegetation | null + /** Dispose the instanced field (idempotent); also cancels a still-pending load. */ + dispose(): void +} + +export interface MountForestTreeFieldOptions { + /** GLB loader seam — defaults to the real `forest-tree.glb` fetch. Inject in tests. */ + loadTree?: (scene: Scene) => Promise + /** Scatter tunables forwarded to {@link generateForestTreePlacements}. */ + field?: ForestTreeFieldOptions +} + +/** + * Fire-and-forget: load the forest tree GLB, green it, and thin-instance the + * scatter into `scene`. Returns immediately with a handle whose `vegetation` + * fills in once the load resolves. A failed fetch (headless tests) is swallowed, + * leaving an empty field. + */ +export function mountForestTreeField( + scene: Scene, + options: MountForestTreeFieldOptions = {}, +): ForestTreeFieldMount { + const { + loadTree = (s: Scene) => loadModel(s, '/models/forest-tree.glb', { targetSize: FOREST_TREE_BASE_SIZE }), + field, + } = options + + let vegetation: InstancedVegetation | null = null + let disposed = false + + void loadTree(scene) + .then((prototype) => { + if (disposed) { + // Mount was disposed before the load resolved — drop the orphan prototype. + for (const mesh of prototype.meshes) mesh.dispose(false, true) + prototype.root.dispose() + return + } + vegetation = buildForestTreeField(scene, prototype, field) + }) + .catch(() => { + /* keep the forest empty if the GLB cannot load (headless tests) */ + }) + + return { + get vegetation() { + return vegetation + }, + dispose() { + if (disposed) return + disposed = true + vegetation?.dispose() + }, + } +}