Skip to content
Merged
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
56 changes: 50 additions & 6 deletions docs/guide/forest-zone.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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**
Expand Down
3 changes: 3 additions & 0 deletions docs/verification/flo482-forest-conifer-field.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
73 changes: 73 additions & 0 deletions src/game/streaming/forestTreeField.test.ts
Original file line number Diff line number Diff line change
@@ -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 }),
)
})
})
113 changes: 113 additions & 0 deletions src/game/streaming/forestTreeField.ts
Original file line number Diff line number Diff line change
@@ -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
}
10 changes: 10 additions & 0 deletions src/game/streaming/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down
4 changes: 3 additions & 1 deletion src/game/streaming/zoneManifests.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 })
Expand Down
24 changes: 8 additions & 16 deletions src/game/streaming/zoneManifests.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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][] = [
Expand Down Expand Up @@ -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,
],
Expand Down
20 changes: 20 additions & 0 deletions src/scenes/forestScene.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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<LoadedModel>) | null
}

export interface ForestScene {
Expand Down Expand Up @@ -442,6 +451,7 @@ export function createForestScene(
onMinimapTick,
onStaminaChange,
caravanVisualUrl = heroUrl === null ? null : undefined,
forestTreeLoader,
} = options

const engine = createEngine(canvas)
Expand All @@ -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
Expand Down Expand Up @@ -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()
Expand Down
Loading
Loading