Skip to content

Commit d3df703

Browse files
lanmowerclaude
andcommitted
fix(vegetation): TerrainField was stripping anchor kind/biome, so VegetationPlacement saw kind-0 everywhere
Root cause of "trees look like clones / no species variety": createTerrainField re-maps input anchors to carry a per-anchor height sampler but dropped kind and biome fields. VegetationPlacement reads anchors[i].kind from field.anchors — got undefined at every cell — fell back to BIOME_KIND[undefined] ?? 0. Every biome in the world was treated as rolling_hills regardless of layout. Willow/fir/palm never spawned even when standing on a swamp/alpine/beach anchor. After fix: survivor world 441-chunk probe shows 5/5 tree species (33% oak / 28% pine / 18% willow / 18% fir / 3% palm) vs. previously oak+pine only. Also in this commit: - scale/tilt variation: wider scale range 0.75-1.45 (trees), up to ~4.6deg tilt; rand01 upgraded to proper avalanche mixer because the raw 24-bit extraction correlated narrowly for certain XOR keys. - SPECIES_WEIGHTS boosted for non-oak trees so IDW-diluted biome weights still cross the spawn gate. - Three new regressions in test.js covering each of these. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 22d5a45 commit d3df703

6 files changed

Lines changed: 141 additions & 47 deletions

File tree

AGENTS.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,8 @@ This file collects facts that are **not derivable by reading the code** and woul
2828

2929
- **ez-tree cannot run in Node.** The library's ESM bundle references `document` at import time (used for its texture-loading path). Any Node-side probe of vegetation must either use jsdom or run only the placement sampler (pure math, no ez-tree dep). test.js exercises `VegetationPlacement.js` + `VegetationPhysics.js` only for this reason — `VegetationSystem.js` is browser-only.
3030

31+
- **`TerrainField` must preserve anchor `kind` and `biome` when re-mapping.** `createTerrainField` in `src/terrain/TerrainField.js` re-maps input anchors into an internal shape carrying a per-anchor `sampler` for height blending. The mapping MUST carry `kind` and `biome` forward — `VegetationPlacement.speciesFromAnchors` reads `anchors[i].kind` from `field.anchors` (not from the original `generateContinent` output) and silently falls back to `BIOME_KIND[undefined] ?? 0` if the field is missing. Symptom: every biome in the world produces only rolling_hills-weighted species (oak/bush/grass), and mountains/swamp/beach/alpine areas never get pine/fir/willow/palm. test.js has a regression for this; don't skimp on it when refactoring the field.
32+
3133
- **Client and server vegetation must use identical placement inputs.** Both sides run `createVegetationPlacement` with the same `seed + terrain config`. If the server has collision capsules but the client's trees are elsewhere, the first thing to check is whether the world's `vegetation.seed` and the `terrain` block are getting through to both `AppRuntime.setVegetationConfig` AND `createVegetationSystem(scene, { ...wd.terrain, ...wd.vegetation })`. Mismatch = invisible trees with capsules in empty ground, or visible trees with no collision.
3234

3335
## Rendering

CHANGELOG.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,8 @@
11
## Unreleased
22

3+
- fix(vegetation): `TerrainField` was stripping `kind`/`biome` fields when re-mapping anchors for per-anchor samplers, so `VegetationPlacement` read `anchor.kind === undefined` at every cell and fell back to `BIOME_KIND[undefined] ?? 0` — every cell saw only kind-0 (rolling_hills) weights, so willow/fir/palm never spawned regardless of biome layout. Preserved `kind`/`biome` in `createTerrainField`. After fix: survivor world 441-chunk probe yields 5/5 tree species (33% oak / 28% pine / 18% willow / 18% fir / 3% palm), vs. previously oak/pine only.
4+
- feat(vegetation): per-tree scale + per-tree tilt variation. Per-species scale jitter widened to 0.75–1.45 (was 0.85–1.25) with tilt up to ~4.6° around Y-axis for organic look. `rand01` upgraded to a proper avalanche mixer because the raw 24-bit extraction correlated narrowly for small XOR keys — previously produced scale min=0.925 max=1.098 despite nominal 0.85–1.25 range. New scale buckets uniform across 0.75–1.45.
5+
- feat(vegetation): SPECIES_WEIGHTS boosted for non-oak trees — at IDW blend boundaries with dilute biome weights, willow/fir/palm weight × densityScale was falling below the per-cell spawn gate even at the biome's own anchor. Weights in kinds 2/3/4/5/6/7 now produce fir/willow/palm at their respective biome anchors.
36
- fix(vegetation): ez-tree API fix — trees now actually render. Previously tree.options.type = TreeType.Oak was producing undefined (TreeType has only Deciduous/Evergreen; species flavor is bark.type + leaves.type). The cascading undefined caused a 'Cannot set properties of undefined (setting length)' in bakeTree that silently failed init. Validated live in browser: 29 chunks, 7/7 species baked.
47
- feat(vegetation): ground cover billboard cards (bush/grass) + `setConfig` hot-reload + `WORLD=survivor` env var for multiplayer server. Fixes "no plants visible" — server was loading default world without terrain/vegetation; set `WORLD=survivor` to enable. Client-side `continent: 'default'` string is now expanded to anchors in `VegetationSystem.buildHeightSampler` for placement parity with server. Bushes render as crossed double-plane billboards; grass as single planes with procedural canvas-painted alpha-streak textures.
58
- feat(vegetation): procedural vegetation system. `src/terrain/VegetationPlacement.js` = deterministic jittered-grid placement with IDW-blended biome weighting for species picks (oak/pine/fir/willow/palm/bush/grass). `src/physics/VegetationPhysics.js` = chunked Jolt capsule streaming (trees only; bushes/grass are visual only). `client/VegetationSystem.js` = client rendering via ez-tree bake at startup + per-chunk `THREE.InstancedMesh` per species part (trunk/foliage separately). Wired into AppRuntime (`setVegetationConfig`), ServerAPI, WorkerEntry singleplayer, and survivor world. Adds `@dgreenheck/ez-tree` dependency + importmap entry.

client/VegetationSystem.js

Lines changed: 73 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -6,20 +6,21 @@ import { createTerrainField } from '../src/terrain/TerrainField.js'
66
import { generateContinent, CONTINENT_PRESETS } from '../src/terrain/ContinentGen.js'
77

88
const TREE_CONFIG = {
9-
oak: { type: TreeType.Deciduous, bark: BarkType.Oak, leaves: LeafType.Oak, seed: 1, trunkLen: 12, levels: 3, scale: 1.0 },
10-
pine: { type: TreeType.Evergreen, bark: BarkType.Pine, leaves: LeafType.Pine, seed: 2, trunkLen: 14, levels: 3, scale: 1.1 },
11-
fir: { type: TreeType.Evergreen, bark: BarkType.Pine, leaves: LeafType.Pine, seed: 3, trunkLen: 16, levels: 3, scale: 1.2 },
12-
willow: { type: TreeType.Deciduous, bark: BarkType.Willow, leaves: LeafType.Aspen, seed: 4, trunkLen: 8, levels: 4, scale: 0.9 },
13-
palm: { type: TreeType.Deciduous, bark: BarkType.Birch, leaves: LeafType.Ash, seed: 5, trunkLen: 14, levels: 2, scale: 1.0 }
9+
oak: { type: TreeType.Deciduous, bark: BarkType.Oak, leaf: LeafType.Oak, trunkLen: 12, levels: 3, leafCount: 28, leafSize: 2.6, scale: 1.0 },
10+
pine: { type: TreeType.Evergreen, bark: BarkType.Pine, leaf: LeafType.Pine, trunkLen: 14, levels: 3, leafCount: 42, leafSize: 1.9, scale: 1.1 },
11+
fir: { type: TreeType.Evergreen, bark: BarkType.Pine, leaf: LeafType.Pine, trunkLen: 16, levels: 3, leafCount: 48, leafSize: 1.7, scale: 1.2 },
12+
willow: { type: TreeType.Deciduous, bark: BarkType.Willow, leaf: LeafType.Aspen, trunkLen: 8, levels: 4, leafCount: 34, leafSize: 2.2, scale: 0.9 },
13+
palm: { type: TreeType.Deciduous, bark: BarkType.Birch, leaf: LeafType.Ash, trunkLen: 14, levels: 2, leafCount: 26, leafSize: 2.8, scale: 1.0 }
1414
}
1515

1616
const CARD_CONFIG = {
1717
bush: { color: 0x3a6b2e, w: 1.2, h: 0.9, crossed: true },
18-
grass: { color: 0x4d8a3a, w: 0.35, h: 0.5, crossed: false }
18+
grass: { color: 0x5fa03a, w: 0.4, h: 0.55, crossed: false }
1919
}
2020

21-
const MAX_INSTANCES_PER_CHUNK = 64
22-
const MAX_GROUND_COVER_PER_CHUNK = 256
21+
const VARIANTS_PER_SPECIES = 3
22+
const MAX_INSTANCES_PER_CHUNK = 96
23+
const MAX_GROUND_COVER_PER_CHUNK = 384
2324

2425
function makeCardTexture(color) {
2526
if (typeof document === 'undefined') return null
@@ -28,8 +29,8 @@ function makeCardTexture(color) {
2829
const rgb = new THREE.Color(color)
2930
const grad = ctx.createLinearGradient(0, 0, 0, 64)
3031
grad.addColorStop(0, `rgba(${rgb.r*255|0},${rgb.g*255|0},${rgb.b*255|0},0.0)`)
31-
grad.addColorStop(0.35, `rgba(${rgb.r*180|0},${rgb.g*180|0},${rgb.b*180|0},0.95)`)
32-
grad.addColorStop(1.0, `rgba(${rgb.r*90|0},${rgb.g*90|0},${rgb.b*90|0},1.0)`)
32+
grad.addColorStop(0.35, `rgba(${rgb.r*220|0},${rgb.g*220|0},${rgb.b*220|0},0.95)`)
33+
grad.addColorStop(1.0, `rgba(${rgb.r*110|0},${rgb.g*110|0},${rgb.b*110|0},1.0)`)
3334
ctx.fillStyle = grad; ctx.fillRect(0, 0, 64, 64)
3435
ctx.globalCompositeOperation = 'destination-in'
3536
for (let i = 0; i < 14; i++) {
@@ -39,31 +40,42 @@ function makeCardTexture(color) {
3940
const t = new THREE.CanvasTexture(c); t.colorSpace = THREE.SRGBColorSpace; return t
4041
}
4142

42-
function bakeTree(name) {
43+
function bakeTreeVariant(name, variantSeed) {
4344
const cfg = TREE_CONFIG[name]; if (!cfg) return null
4445
const t = new Tree()
45-
t.options.seed = cfg.seed
46+
t.options.seed = variantSeed
4647
if (cfg.type) t.options.type = cfg.type
4748
if (cfg.bark && t.options.bark) t.options.bark.type = cfg.bark
48-
if (cfg.leaves && t.options.leaves) t.options.leaves.type = cfg.leaves
49+
if (cfg.leaf && t.options.leaves) {
50+
t.options.leaves.type = cfg.leaf
51+
if (cfg.leafCount != null) t.options.leaves.count = cfg.leafCount
52+
if (cfg.leafSize != null) t.options.leaves.size = cfg.leafSize
53+
t.options.leaves.alphaTest = 0.5
54+
}
4955
if (t.options.branch) {
5056
t.options.branch.levels = cfg.levels
5157
if (t.options.branch.length && typeof t.options.branch.length === 'object') {
5258
t.options.branch.length[0] = cfg.trunkLen
5359
}
5460
}
55-
try { t.generate() } catch (e) { console.error(`[Vegetation] bake ${name}:`, e.message); return null }
61+
try { t.generate() } catch (e) { console.error(`[Vegetation] bake ${name}#${variantSeed}:`, e.message); return null }
5662
const parts = { trunk: null, foliage: null }
5763
t.traverse(o => {
5864
if (!o.isMesh || !o.geometry) return
5965
const mat = o.material
60-
const isLeaves = mat?.name?.toLowerCase().includes('leaf') || mat?.name?.toLowerCase().includes('foliage') || mat?.transparent
66+
const lower = (mat?.name || '').toLowerCase()
67+
const isLeaves = lower.includes('leaf') || lower.includes('leav') || lower.includes('foliage') || (mat?.alphaTest && mat.alphaTest > 0)
6168
const slot = isLeaves ? 'foliage' : 'trunk'
6269
if (!parts[slot]) parts[slot] = { geo: o.geometry.clone(), mat: mat ? mat.clone() : null }
6370
})
6471
if (parts.trunk && !parts.trunk.mat) parts.trunk.mat = new THREE.MeshStandardMaterial({ color: 0x5a4433, roughness: 0.9 })
6572
if (parts.foliage && !parts.foliage.mat) parts.foliage.mat = new THREE.MeshStandardMaterial({ color: 0x2d5a2d, roughness: 0.8 })
66-
if (parts.foliage?.mat) parts.foliage.mat.side = THREE.DoubleSide
73+
if (parts.foliage?.mat) {
74+
parts.foliage.mat.side = THREE.DoubleSide
75+
parts.foliage.mat.transparent = true
76+
parts.foliage.mat.alphaTest = parts.foliage.mat.alphaTest || 0.5
77+
parts.foliage.mat.depthWrite = true
78+
}
6779
for (const p of Object.values(parts)) if (p?.mat) p.mat.shadowSide = THREE.FrontSide
6880
return { kind: 'tree', parts, baseScale: cfg.scale }
6981
}
@@ -94,7 +106,7 @@ function bakeCard(name) {
94106
merge.setIndex(new THREE.BufferAttribute(idx, 1))
95107
a.dispose(); b.dispose(); geo = merge
96108
}
97-
return { kind: 'card', parts: { card: { geo, mat } }, baseScale: 1.0 }
109+
return { kind: 'card', parts: { card: { geo, mat } }, baseScale: 1.0, variantCount: 1 }
98110
}
99111

100112
function buildHeightSampler(args) {
@@ -120,42 +132,61 @@ export function createVegetationSystem(scene, args = {}) {
120132
const { sampleHeight, field } = buildHeightSampler(args)
121133
const placement = createVegetationPlacement({ field, sampleHeight: field ? null : sampleHeight, chunkSize, seed, densityScale, cellSize, slopeLimit: args.slopeLimit ?? 0.75, minY: args.minY ?? -1.0 })
122134
const species = new Map()
135+
let trees = 0, cards = 0
123136
for (const name of SPECIES_LIST) {
124-
const baked = TREE_SPECIES.has(name) ? bakeTree(name) : bakeCard(name)
125-
if (baked) species.set(name, baked)
137+
if (TREE_SPECIES.has(name)) {
138+
const variants = []
139+
for (let vi = 0; vi < VARIANTS_PER_SPECIES; vi++) {
140+
const baked = bakeTreeVariant(name, (name.charCodeAt(0) * 101 + vi * 4099) | 0)
141+
if (baked) variants.push(baked)
142+
}
143+
if (variants.length) {
144+
species.set(name, { kind: 'tree', variants, baseScale: variants[0].baseScale, variantCount: variants.length })
145+
trees += variants.length
146+
}
147+
} else {
148+
const baked = bakeCard(name)
149+
if (baked) { species.set(name, baked); cards++ }
150+
}
126151
}
127-
console.log(`[Vegetation] baked ${species.size}/${SPECIES_LIST.length} species`)
152+
console.log(`[Vegetation] baked ${species.size} species (${trees} tree variants + ${cards} cards)`)
128153

129154
const chunks = new Map()
130-
const _m = new THREE.Matrix4(), _q = new THREE.Quaternion(), _v = new THREE.Vector3(), _s = new THREE.Vector3()
131-
const _ay = new THREE.Vector3(0, 1, 0)
155+
const _m = new THREE.Matrix4(), _q = new THREE.Quaternion(), _qt = new THREE.Quaternion(), _v = new THREE.Vector3(), _s = new THREE.Vector3()
156+
const _ay = new THREE.Vector3(0, 1, 0), _ax = new THREE.Vector3(1, 0, 0), _az = new THREE.Vector3(0, 0, 1)
132157

133158
function buildChunk(cx, cz) {
134159
const placements = placement.placementsForChunk(cx, cz)
135160
if (!placements.length) return null
136-
const bySpecies = new Map()
161+
const byBucket = new Map()
137162
for (const p of placements) {
138-
if (!species.has(p.species)) continue
139-
const sp = species.get(p.species)
163+
const sp = species.get(p.species); if (!sp) continue
140164
const cap = sp.kind === 'card' ? MAX_GROUND_COVER_PER_CHUNK : MAX_INSTANCES_PER_CHUNK
141-
let arr = bySpecies.get(p.species)
142-
if (!arr) { arr = []; bySpecies.set(p.species, arr) }
143-
if (arr.length >= cap) continue
144-
arr.push(p)
165+
const variantCount = sp.kind === 'tree' ? sp.variants.length : 1
166+
const variantIdx = variantCount > 1 ? ((p.seed >>> 16) % variantCount) : 0
167+
const key = p.species + '#' + variantIdx
168+
let arr = byBucket.get(key)
169+
if (!arr) { arr = { sp, variantIdx, list: [] }; byBucket.set(key, arr) }
170+
if (arr.list.length >= cap) continue
171+
arr.list.push(p)
145172
}
146173
const group = new THREE.Group()
147174
const meshes = []
148-
for (const [name, arr] of bySpecies) {
149-
const sp = species.get(name)
150-
for (const [partName, part] of Object.entries(sp.parts)) {
175+
for (const { sp, variantIdx, list } of byBucket.values()) {
176+
const parts = sp.kind === 'tree' ? sp.variants[variantIdx].parts : sp.parts
177+
for (const [partName, part] of Object.entries(parts)) {
151178
if (!part?.geo || !part?.mat) continue
152-
const im = new THREE.InstancedMesh(part.geo, part.mat, arr.length)
179+
const im = new THREE.InstancedMesh(part.geo, part.mat, list.length)
153180
im.castShadow = sp.kind === 'tree' && partName === 'trunk'
154181
im.receiveShadow = sp.kind === 'tree'
155182
im.frustumCulled = true
156-
for (let i = 0; i < arr.length; i++) {
157-
const p = arr[i]
183+
for (let i = 0; i < list.length; i++) {
184+
const p = list[i]
158185
_q.setFromAxisAngle(_ay, p.rot)
186+
if (p.tiltX || p.tiltZ) {
187+
_qt.setFromAxisAngle(_ax, p.tiltZ || 0); _q.multiply(_qt)
188+
_qt.setFromAxisAngle(_az, -(p.tiltX || 0)); _q.multiply(_qt)
189+
}
159190
const s = p.scale * sp.baseScale
160191
_s.set(s, s, s); _v.set(p.x, p.y, p.z); _m.compose(_v, _q, _s)
161192
im.setMatrixAt(i, _m)
@@ -172,7 +203,7 @@ export function createVegetationSystem(scene, args = {}) {
172203
function disposeChunk(rec) {
173204
if (!rec?.group) return
174205
scene.remove(rec.group)
175-
for (const m of rec.meshes) { m.geometry.dispose(); m.dispose?.() }
206+
for (const m of rec.meshes) m.dispose?.()
176207
}
177208

178209
function update(cameraPosition) {
@@ -197,7 +228,12 @@ export function createVegetationSystem(scene, args = {}) {
197228
function dispose() {
198229
for (const [, rec] of chunks) disposeChunk(rec)
199230
chunks.clear()
200-
for (const [, sp] of species) for (const p of Object.values(sp.parts)) { p?.geo?.dispose(); p?.mat?.map?.dispose?.(); p?.mat?.dispose?.() }
231+
for (const [, sp] of species) {
232+
const variantList = sp.kind === 'tree' ? sp.variants : [sp]
233+
for (const v of variantList) {
234+
for (const p of Object.values(v.parts)) { p?.geo?.dispose(); p?.mat?.map?.dispose?.(); p?.mat?.dispose?.() }
235+
}
236+
}
201237
species.clear()
202238
}
203239

src/terrain/TerrainField.js

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,8 @@ export function createTerrainField(args = {}) {
77
return {
88
pos: a.pos || [0, 0],
99
elevation: a.elevation ?? 0,
10+
kind: a.kind,
11+
biome: a.biome,
1012
cfg,
1113
sampler: createHeightSampler(cfg)
1214
}

src/terrain/VegetationPlacement.js

Lines changed: 20 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,14 @@
11
import { BIOME_KIND } from './BiomePresets.js'
22

33
const SPECIES_WEIGHTS = {
4-
0: { oak: 0.35, bush: 0.4, grass: 2.0 },
5-
1: { oak: 0.1, bush: 0.3, grass: 3.0 },
6-
2: { pine: 0.4, bush: 0.2, grass: 1.0 },
7-
3: { pine: 0.08, bush: 0.05, grass: 0.2 },
8-
4: { fir: 0.05, bush: 0.0, grass: 0.0 },
9-
5: { willow: 0.3, bush: 0.5, grass: 1.5 },
10-
6: { palm: 0.08, bush: 0.1, grass: 0.2 },
11-
7: { oak: 0.7, pine: 0.5, bush: 0.8, grass: 2.5 }
4+
0: { oak: 0.45, pine: 0.1, bush: 0.5, grass: 2.0 },
5+
1: { oak: 0.15, bush: 0.35, grass: 3.0 },
6+
2: { pine: 0.55, fir: 0.2, bush: 0.2, grass: 1.0 },
7+
3: { pine: 0.35, fir: 0.25, bush: 0.08, grass: 0.3 },
8+
4: { fir: 0.3, pine: 0.1, bush: 0.02, grass: 0.05 },
9+
5: { willow: 0.5, oak: 0.1, bush: 0.6, grass: 1.5 },
10+
6: { palm: 0.35, bush: 0.15, grass: 0.25 },
11+
7: { oak: 0.55, pine: 0.35, fir: 0.1, willow: 0.1, bush: 0.7, grass: 2.2 }
1212
}
1313

1414
export const SPECIES_LIST = ['oak', 'pine', 'fir', 'willow', 'palm', 'bush', 'grass']
@@ -23,7 +23,11 @@ function hash2(x, z, seed) {
2323
}
2424

2525
function rand01(hash) {
26-
return (hash & 0xffffff) / 0x1000000
26+
let h = hash >>> 0
27+
h = Math.imul(h ^ (h >>> 16), 2246822507) >>> 0
28+
h = Math.imul(h ^ (h >>> 13), 3266489909) >>> 0
29+
h = (h ^ (h >>> 16)) >>> 0
30+
return (h & 0xffffff) / 0x1000000
2731
}
2832

2933
function speciesFromAnchors(anchors, weights, localWeights) {
@@ -117,7 +121,11 @@ export function createVegetationPlacement(args = {}) {
117121
const slope = Math.max(Math.abs(yx - y), Math.abs(yz - y)) / eps
118122
if (slope > slopeLimit) continue
119123
const rot = rand01(cellHash ^ 0xc2b2ae35) * Math.PI * 2
120-
const scale = 0.85 + rand01(cellHash ^ 0x27d4eb2f) * 0.4
124+
const scaleRange = TREE_SPECIES.has(sp) ? 0.7 : 0.4
125+
const scaleMin = TREE_SPECIES.has(sp) ? 0.75 : 0.85
126+
const scale = scaleMin + rand01(cellHash ^ 0x27d4eb2f) * scaleRange
127+
const tiltAngle = rand01(cellHash ^ 0x165667b1) * 2 * Math.PI
128+
const tiltAmt = TREE_SPECIES.has(sp) ? rand01(cellHash ^ 0xd1b2ae35) * 0.08 : 0
121129
out.push({
122130
species: sp,
123131
speciesId: SPECIES_INDEX[sp],
@@ -126,6 +134,8 @@ export function createVegetationPlacement(args = {}) {
126134
z: Math.round(wz * 100) / 100,
127135
rot: Math.round(rot * 1000) / 1000,
128136
scale: Math.round(scale * 1000) / 1000,
137+
tiltX: Math.round(Math.cos(tiltAngle) * tiltAmt * 1000) / 1000,
138+
tiltZ: Math.round(Math.sin(tiltAngle) * tiltAmt * 1000) / 1000,
129139
seed: cellHash >>> 0
130140
})
131141
}

0 commit comments

Comments
 (0)