spoint 0.1.662 → 0.1.664
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/client/core/Vegetation.js +1 -198
- package/client/core/VegetationBuild.js +206 -0
- package/package.json +1 -1
|
@@ -1,41 +1,6 @@
|
|
|
1
1
|
// Client visual layer for the ez-tree forest. Reads the SAME deterministic placement as the server (VegPlacement.js) so the visual trunk matches the collided trunk. Per species: one InstancedMesh2 for branches + one for leaves, 3 mesh LODs + a cheap shadow LOD + BVH per-instance frustum culling. window.__veg / window.__vegProfile.
|
|
2
2
|
import * as THREE from 'three'
|
|
3
3
|
import { InstancedMesh2 } from '@three.ez/instanced-mesh'
|
|
4
|
-
import { MeshoptSimplifier } from 'meshoptimizer'
|
|
5
|
-
// '@dgreenheck/ez-tree' is intentionally NOT a static import here (was: `import { Tree } from
|
|
6
|
-
// '@dgreenheck/ez-tree'`). A static top-level import is resolved during ES module GRAPH LINKING,
|
|
7
|
-
// before any module body executes -- a 404/missing-package on this one import poisons the ENTIRE
|
|
8
|
-
// graph rooted at app.js (app.js statically imports Vegetation.js), aborting client boot with zero
|
|
9
|
-
// console output, even from systems that textually import before this one and would otherwise have
|
|
10
|
-
// succeeded (confirmed live: a module-graph reproduction with this exact static-import shape showed
|
|
11
|
-
// sibling static imports before the failing one never evaluate either -- graph linking is atomic,
|
|
12
|
-
// see eztree-fix-live-reproduce-and-verify PRD row for the harness + output).
|
|
13
|
-
// Root cause of the underlying absence: single-writer contention on the shared main checkout's
|
|
14
|
-
// node_modules directory when multiple concurrent worktree sessions run npm-install-adjacent
|
|
15
|
-
// operations against the SAME junction target at once (see scripts/worktree-setup.mjs and
|
|
16
|
-
// AGENTS.md's main-node-modules-missing-ez-tree-package-intermittent history) -- this can still
|
|
17
|
-
// transiently 404 under contention even after worktree-setup.mjs's own torn-install guard, since a
|
|
18
|
-
// junction always resolves LIVE to whatever main's node_modules currently contains at request time.
|
|
19
|
-
// A dynamic import, awaited once and cached, keeps the failure LOCAL to createVegetation() (which
|
|
20
|
-
// already has a per-species try/catch and whose own caller in app.js already .catch()es) instead of
|
|
21
|
-
// poisoning module linking -- the rest of the client (terrain, physics, HUD, netcode, other apps)
|
|
22
|
-
// boots fine and only vegetation degrades to "skipped" with a clear console warning.
|
|
23
|
-
// RESIDUAL RISK (not fully eliminated by this fix): a package going missing/corrupt AFTER this
|
|
24
|
-
// dynamic import has already resolved once per page load (e.g. torn mid-way through THIS page's own
|
|
25
|
-
// boot) is not retried -- the cached rejection/resolution is final for that page session's lifetime,
|
|
26
|
-
// matching how a static import would behave too (a page load either gets a working module graph or
|
|
27
|
-
// it doesn't; there is no live hot-swap of an already-linked/already-resolved import). The dynamic
|
|
28
|
-
// import only prevents the SPECIFIC failure mode of "vegetation being unavailable" from cascading
|
|
29
|
-
// into "nothing at all is available" -- it does not make ez-tree loading retry-resilient within a
|
|
30
|
-
// single page load, and it does nothing for any OTHER package that is statically imported elsewhere
|
|
31
|
-
// in the graph (this fix is scoped to ez-tree, the one package this task's report named; a fully
|
|
32
|
-
// general fix would need every client entry-adjacent static import audited the same way, which is a
|
|
33
|
-
// larger cross-cutting change than this task's reported symptom asked for).
|
|
34
|
-
let _ezTreeModPromise = null
|
|
35
|
-
function loadEzTree() {
|
|
36
|
-
if (!_ezTreeModPromise) _ezTreeModPromise = import('@dgreenheck/ez-tree')
|
|
37
|
-
return _ezTreeModPromise
|
|
38
|
-
}
|
|
39
4
|
// streaming-gltf's octahedral impostor (FULL-sphere octahedron - works from any angle, incl ground
|
|
40
5
|
// level; the agargaro vendored lib only implemented HEMI so ground views rendered nothing). Plain
|
|
41
6
|
// Single canonical impostor implementation (packages/streaming-gltf/src/octahedral-impostor-ez.js,
|
|
@@ -48,179 +13,17 @@ import { createCachedAnchorField } from '/src/terrain/ClimateCache.js'
|
|
|
48
13
|
import { createBiomeOverride } from '/src/terrain/BiomeOverride.js'
|
|
49
14
|
import { dbg } from './debug-log.js'
|
|
50
15
|
import { RenderControls } from './RenderControls.js'
|
|
16
|
+
import { loadEzTree, makeWindUniforms, applyWind, awaitMatTextures, capGeo, simplifyGeo, buildSpecies, makeEmptyGeo, TARGET_H } from './VegetationBuild.js'
|
|
51
17
|
|
|
52
18
|
const _dbgVeg = dbg('vegetation')
|
|
53
19
|
const _occBoxGeo = new THREE.BoxGeometry(1, 1, 1) // shared, never-rendered proxy geo for occlusion candidates
|
|
54
20
|
const _occBoxMat = new THREE.MeshBasicMaterial()
|
|
55
21
|
|
|
56
|
-
// species (parity wire-id contract) -> ez-tree preset names; 'Bush' has no exact preset (lib ships 'Bush 1/2/3'), mapped explicitly so a missing preset never silently diverges from the collider table
|
|
57
|
-
const PRESET = {
|
|
58
|
-
'Oak Large': 'Oak Large', 'Pine Medium': 'Pine Medium', 'Aspen Medium': 'Aspen Medium', 'Ash Medium': 'Ash Medium', 'Bush': 'Bush 1',
|
|
59
|
-
'Ash Small': 'Ash Small', 'Ash Large': 'Ash Large', 'Aspen Small': 'Aspen Small', 'Aspen Large': 'Aspen Large', 'Bush 2': 'Bush 2',
|
|
60
|
-
'Bush 3': 'Bush 3', 'Oak Small': 'Oak Small', 'Oak Medium': 'Oak Medium', 'Pine Small': 'Pine Small', 'Pine Large': 'Pine Large',
|
|
61
|
-
}
|
|
62
|
-
|
|
63
|
-
// target real-world full-tree height (m) per species: ez-tree's native ~100-unit scale mismatches the trunk collider table, LOD cutovers, and impostor size; normalizing makes visual==collider==LOD==impostor size
|
|
64
|
-
const TARGET_H = {
|
|
65
|
-
'Oak Large': 9, 'Pine Medium': 12, 'Aspen Medium': 9.5, 'Ash Medium': 10, 'Bush': 2.8,
|
|
66
|
-
'Ash Small': 6.5, 'Ash Large': 13, 'Aspen Small': 6, 'Aspen Large': 13, 'Bush 2': 3.0,
|
|
67
|
-
'Bush 3': 3.2, 'Oak Small': 5.5, 'Oak Medium': 7, 'Pine Small': 8, 'Pine Large': 16,
|
|
68
|
-
}
|
|
69
|
-
|
|
70
22
|
const DROP_MARGIN = 64 // metres past the ring before a chunk is dropped (hysteresis)
|
|
71
23
|
|
|
72
24
|
const _v = new THREE.Vector3(), _q = new THREE.Quaternion(), _camPos = new THREE.Vector3()
|
|
73
25
|
const _vanMat = new THREE.Matrix4(), _vanProj = new THREE.Matrix4(), _vanFrustum = new THREE.Frustum() // scratch for window.__vegVanishProbe
|
|
74
26
|
|
|
75
|
-
// shared wind uniform (one per veg system); advancing one .value per frame sways all LODs of all species with zero per-instance JS
|
|
76
|
-
function makeWindUniforms() { return { uVegTime: { value: 0 }, uVegWind: { value: 1 } } }
|
|
77
|
-
|
|
78
|
-
function _dissolveBoundaryDistanceShaderChunk(boundaries) {
|
|
79
|
-
const uniforms = boundaries.map((_, i) => `uniform float uVegLodB${i};`).join('\n')
|
|
80
|
-
const mindist = boundaries.map((_, i) => `_vegDistToB = min(_vegDistToB, abs(_vegCamDist - uVegLodB${i}));`).join('\n ')
|
|
81
|
-
return { uniforms, mindist }
|
|
82
|
-
}
|
|
83
|
-
|
|
84
|
-
const VEG_DISSOLVE_FADE_BAND_M = 3.0
|
|
85
|
-
|
|
86
|
-
function applyWind(material, wind, lodBoundaries) {
|
|
87
|
-
const chunk = lodBoundaries && lodBoundaries.length ? _dissolveBoundaryDistanceShaderChunk(lodBoundaries) : null
|
|
88
|
-
material.onBeforeCompile = (shader) => {
|
|
89
|
-
shader.uniforms.uVegTime = wind.uVegTime
|
|
90
|
-
shader.uniforms.uVegWind = wind.uVegWind
|
|
91
|
-
shader.vertexShader = 'uniform float uVegTime;\nuniform float uVegWind;\n' + shader.vertexShader
|
|
92
|
-
// windPhase/tint are per-instance uniforms @three.ez declares; its injection runs after this base
|
|
93
|
-
shader.vertexShader = shader.vertexShader.replace('#include <begin_vertex>',
|
|
94
|
-
'#include <begin_vertex>\n' +
|
|
95
|
-
'float _wsway = (position.y) * 0.06;\n' +
|
|
96
|
-
'float _wph = uVegTime * 1.3 + windPhase;\n' +
|
|
97
|
-
'transformed.x += sin(_wph) * _wsway * uVegWind;\n' +
|
|
98
|
-
'transformed.z += cos(_wph * 0.8) * _wsway * 0.6 * uVegWind;')
|
|
99
|
-
// per-instance shade: multiply the lit diffuse by the instance tint (brightness variation).
|
|
100
|
-
shader.fragmentShader = shader.fragmentShader.replace('#include <color_fragment>',
|
|
101
|
-
'#include <color_fragment>\n diffuseColor.rgb *= tint;')
|
|
102
|
-
if (chunk) {
|
|
103
|
-
lodBoundaries.forEach((d, i) => { shader.uniforms['uVegLodB' + i] = { value: d } })
|
|
104
|
-
shader.vertexShader = chunk.uniforms + '\nvarying float vVegCamDist;\n' + shader.vertexShader
|
|
105
|
-
shader.vertexShader = shader.vertexShader.replace('#include <begin_vertex>',
|
|
106
|
-
'#include <begin_vertex>\nvVegCamDist = distance(cameraPosition, (modelMatrix * instanceMatrix * vec4(0.0, 0.0, 0.0, 1.0)).xyz);')
|
|
107
|
-
shader.fragmentShader = chunk.uniforms + '\nvarying float vVegCamDist;\n' + shader.fragmentShader
|
|
108
|
-
shader.fragmentShader = shader.fragmentShader.replace('#include <dithering_fragment>',
|
|
109
|
-
'#include <dithering_fragment>\n' +
|
|
110
|
-
`float _vegCamDist = vVegCamDist;\n` +
|
|
111
|
-
`float _vegDistToB = 1e9;\n` +
|
|
112
|
-
` ${chunk.mindist}\n` +
|
|
113
|
-
`float _vegFade = clamp(_vegDistToB / ${VEG_DISSOLVE_FADE_BAND_M.toFixed(1)}, 0.0, 1.0);\n` +
|
|
114
|
-
'float _vegDither = fract(52.9829189 * fract(dot(gl_FragCoord.xy, vec2(0.06711056, 0.00583715))));\n' +
|
|
115
|
-
'if (_vegDither > _vegFade) discard;')
|
|
116
|
-
}
|
|
117
|
-
}
|
|
118
|
-
material.customProgramCacheKey = () => 'vegwind3' + (chunk ? '_fade' + lodBoundaries.join('_') : '')
|
|
119
|
-
return material
|
|
120
|
-
}
|
|
121
|
-
|
|
122
|
-
// Must await texture decode before sampling: ez-tree loads bark/leaf maps async, so baking the impostor atlas in the same tick would sample undefined images -> blank atlas.
|
|
123
|
-
async function awaitMatTextures(mats) {
|
|
124
|
-
const texes = []
|
|
125
|
-
for (const m of mats) {
|
|
126
|
-
if (!m) continue
|
|
127
|
-
for (const k of ['map', 'normalMap', 'roughnessMap', 'aoMap', 'alphaMap', 'bumpMap']) {
|
|
128
|
-
const t = m[k]; if (t && t.isTexture) texes.push(t)
|
|
129
|
-
}
|
|
130
|
-
}
|
|
131
|
-
await Promise.all(texes.map(async (t) => {
|
|
132
|
-
const img = t.image
|
|
133
|
-
try {
|
|
134
|
-
if (img && typeof img.decode === 'function') { await img.decode(); t.needsUpdate = true; return }
|
|
135
|
-
} catch (_) {}
|
|
136
|
-
// fallback: poll until the image has dimensions, ~1s cap
|
|
137
|
-
for (let i = 0; i < 60; i++) {
|
|
138
|
-
if (t.image && (t.image.width > 0 || t.image.videoWidth > 0)) { t.needsUpdate = true; return }
|
|
139
|
-
await new Promise(r => setTimeout(r, 16))
|
|
140
|
-
}
|
|
141
|
-
}))
|
|
142
|
-
}
|
|
143
|
-
|
|
144
|
-
// Caps a geometry to at most maxTris triangles (meshopt); the LOD0 budget bounding the worst near-tree.
|
|
145
|
-
async function capGeo(geo, maxTris) {
|
|
146
|
-
try {
|
|
147
|
-
const idx = geo.index ? geo.index.array : null
|
|
148
|
-
if (!idx) return geo
|
|
149
|
-
const tris = idx.length / 3
|
|
150
|
-
if (tris <= maxTris) return geo
|
|
151
|
-
return await simplifyGeo(geo, maxTris / tris, false)
|
|
152
|
-
} catch (_) { return geo }
|
|
153
|
-
}
|
|
154
|
-
|
|
155
|
-
// Meshopt-simplifies to `ratio` of triangles. Must ALWAYS return a NEW distinct geometry object (never the source) -- @three.ez's addLevel reuses the LOD object when geometry===existing, so two LOD levels sharing one object alias one instanceIndex array and stomp each other's drawn slots (a tree vanishing in a band at the cutover).
|
|
156
|
-
async function simplifyGeo(geo, ratio, sloppy) {
|
|
157
|
-
try {
|
|
158
|
-
await MeshoptSimplifier.ready
|
|
159
|
-
const idx = geo.index ? geo.index.array : null
|
|
160
|
-
const pos = geo.attributes.position.array
|
|
161
|
-
if (!idx || !pos) return geo.clone()
|
|
162
|
-
const target = Math.max(12, Math.floor((idx.length / 3) * ratio) * 3)
|
|
163
|
-
const fn = sloppy && MeshoptSimplifier.simplifySloppy ? 'simplifySloppy' : 'simplify'
|
|
164
|
-
const args = sloppy ? [idx, pos, 3, target, 0.05] : [idx, pos, 3, target, 0.02, ['Sparse']]
|
|
165
|
-
const [newIdx] = MeshoptSimplifier[fn](...args)
|
|
166
|
-
const out = geo.clone()
|
|
167
|
-
if (newIdx && newIdx.length >= 3) out.setIndex(new THREE.BufferAttribute(newIdx, 1))
|
|
168
|
-
return out
|
|
169
|
-
} catch (_) { return geo.clone() }
|
|
170
|
-
}
|
|
171
|
-
|
|
172
|
-
function buildSpecies(name, Tree) {
|
|
173
|
-
const tree = new Tree()
|
|
174
|
-
tree.loadPreset(PRESET[name] || name)
|
|
175
|
-
const branchGeo = tree.branchesMesh.geometry
|
|
176
|
-
const leafGeo = tree.leavesMesh.geometry
|
|
177
|
-
const branchMat = tree.branchesMesh.material
|
|
178
|
-
const leafMat = tree.leavesMesh.material
|
|
179
|
-
branchMat.shadowSide = THREE.FrontSide
|
|
180
|
-
leafMat.alphaTest = Math.max(leafMat.alphaTest || 0, 0.5) // no-MSAA fallback (A2C needs samples)
|
|
181
|
-
leafMat.transparent = false
|
|
182
|
-
leafMat.side = THREE.DoubleSide
|
|
183
|
-
// Apply depth bias to mesh LOD to match impostor bias, preventing flicker at LOD boundary.
|
|
184
|
-
// units -8 -> -32 (2026-07-10, live A/B via GL readPixels flicker-score harness against a real
|
|
185
|
-
// close-range trunk, world ~3-8m from camera): -8 was proven LIVE-INSUFFICIENT -- trunk mesh vs
|
|
186
|
-
// mapspinner's independently-rendered terrain depth z-fight every single frame at that range
|
|
187
|
-
// (strict alternation between trunk color and terrain color on 18-23 of 20-24 sampled frames),
|
|
188
|
-
// even though occlusion/LOD-swap/per-instance-visibility were all confirmed INERT for this exact
|
|
189
|
-
// symptom (occludedKeys.size===0 always, instancesCount constant, getVisibilityAt always true --
|
|
190
|
-
// the flicker is a raw GPU depth-test tie-break flip, not a game-logic visibility toggle). A/B
|
|
191
|
-
// tested -20/-50/-100 all fully eliminated it (0/20 frames changed, vs 18/20 baseline); -32 keeps
|
|
192
|
-
// a safety margin above the smallest working value without over-biasing.
|
|
193
|
-
branchMat.polygonOffset = true
|
|
194
|
-
branchMat.polygonOffsetFactor = -4
|
|
195
|
-
branchMat.polygonOffsetUnits = -32
|
|
196
|
-
leafMat.polygonOffset = true
|
|
197
|
-
leafMat.polygonOffsetFactor = -4
|
|
198
|
-
leafMat.polygonOffsetUnits = -32
|
|
199
|
-
// normalize to a real-world height: scale branch+leaf by the same factor so visual size agrees with the trunk collider + LOD + impostor
|
|
200
|
-
branchGeo.computeBoundingBox(); leafGeo.computeBoundingBox()
|
|
201
|
-
const minY = Math.min(branchGeo.boundingBox.min.y, leafGeo.boundingBox.min.y)
|
|
202
|
-
const maxY = Math.max(branchGeo.boundingBox.max.y, leafGeo.boundingBox.max.y)
|
|
203
|
-
const nativeH = maxY - minY
|
|
204
|
-
const target = TARGET_H[name] || 9
|
|
205
|
-
const s = (Number.isFinite(nativeH) && nativeH > 1e-3) ? target / nativeH : 1
|
|
206
|
-
for (const g of [branchGeo, leafGeo]) {
|
|
207
|
-
g.scale(s, s, s)
|
|
208
|
-
g.translate(0, -minY * s, 0) // drop base to y=0 so the trunk rests on the ground
|
|
209
|
-
g.computeBoundingBox(); g.computeBoundingSphere()
|
|
210
|
-
}
|
|
211
|
-
const bb = branchGeo.boundingBox, lb = leafGeo.boundingBox
|
|
212
|
-
const width = Math.max(bb.max.x - bb.min.x, bb.max.z - bb.min.z, lb.max.x - lb.min.x, lb.max.z - lb.min.z) || target * 0.7
|
|
213
|
-
const dims = { width, height: target }
|
|
214
|
-
return { branchGeo, leafGeo, branchMat, leafMat, tree, dims }
|
|
215
|
-
}
|
|
216
|
-
|
|
217
|
-
// zero-area geo: the far leaf LOD swaps to this so leaf cards vanish where the impostor takes over (no double-draw)
|
|
218
|
-
function makeEmptyGeo() {
|
|
219
|
-
const g = new THREE.BufferGeometry()
|
|
220
|
-
g.setAttribute('position', new THREE.BufferAttribute(new Float32Array(9), 3))
|
|
221
|
-
g.setIndex([0, 1, 2])
|
|
222
|
-
return g
|
|
223
|
-
}
|
|
224
27
|
|
|
225
28
|
export async function createVegetation(opts = {}) {
|
|
226
29
|
const { renderer, scene, frame } = opts
|
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
// Species-mesh build helpers for Vegetation.js's createVegetation: ez-tree dynamic loader, wind-sway
|
|
2
|
+
// shader injection, texture-decode/geometry-simplify async helpers, and per-species mesh construction
|
|
3
|
+
// (buildSpecies normalizes ez-tree's native scale to this project's real-world TARGET_H per species,
|
|
4
|
+
// applying the polygonOffset trunk-flicker fix -- see that function's own comment). Split out as
|
|
5
|
+
// Vegetation.js's largest stateless block -- each function here only touches its own module-scoped
|
|
6
|
+
// constants/caches (_ezTreeModPromise, PRESET, TARGET_H), never createVegetation's own closure state
|
|
7
|
+
// (camera/chunk/instance scratch objects stay in Vegetation.js, only used there).
|
|
8
|
+
|
|
9
|
+
import * as THREE from 'three'
|
|
10
|
+
import { MeshoptSimplifier } from 'meshoptimizer'
|
|
11
|
+
|
|
12
|
+
let _ezTreeModPromise = null
|
|
13
|
+
function loadEzTree() {
|
|
14
|
+
if (!_ezTreeModPromise) _ezTreeModPromise = import('@dgreenheck/ez-tree')
|
|
15
|
+
return _ezTreeModPromise
|
|
16
|
+
}
|
|
17
|
+
// streaming-gltf's octahedral impostor (FULL-sphere octahedron - works from any angle, incl ground
|
|
18
|
+
// level; the agargaro vendored lib only implemented HEMI so ground views rendered nothing). Plain
|
|
19
|
+
// Single canonical impostor implementation (packages/streaming-gltf/src/octahedral-impostor-ez.js,
|
|
20
|
+
// shared with ModelPool's OctahedralImpostorEzTier via the same package import elsewhere -- no more
|
|
21
|
+
// client/vendor duplicate, see AGENTS.md draw-call-audit-impostor-system-unification).
|
|
22
|
+
import { createOctahedralImpostorMaterial, computeObjectBoundingSphere } from 'streaming-gltf/octahedral-impostor-ez' // full-sphere octahedron (works at ground level, unlike hemi-only variants)
|
|
23
|
+
import { buildSharedImpostorAtlas, createSharedImpostorMesh } from './VegImpostorTier.js'
|
|
24
|
+
import { placementsForChunk, VEG, SPECIES } from '/src/terrain/VegPlacement.js'
|
|
25
|
+
import { createCachedAnchorField } from '/src/terrain/ClimateCache.js'
|
|
26
|
+
import { createBiomeOverride } from '/src/terrain/BiomeOverride.js'
|
|
27
|
+
import { dbg } from './debug-log.js'
|
|
28
|
+
import { RenderControls } from './RenderControls.js'
|
|
29
|
+
|
|
30
|
+
const _dbgVeg = dbg('vegetation')
|
|
31
|
+
const _occBoxGeo = new THREE.BoxGeometry(1, 1, 1) // shared, never-rendered proxy geo for occlusion candidates
|
|
32
|
+
const _occBoxMat = new THREE.MeshBasicMaterial()
|
|
33
|
+
|
|
34
|
+
// species (parity wire-id contract) -> ez-tree preset names; 'Bush' has no exact preset (lib ships 'Bush 1/2/3'), mapped explicitly so a missing preset never silently diverges from the collider table
|
|
35
|
+
const PRESET = {
|
|
36
|
+
'Oak Large': 'Oak Large', 'Pine Medium': 'Pine Medium', 'Aspen Medium': 'Aspen Medium', 'Ash Medium': 'Ash Medium', 'Bush': 'Bush 1',
|
|
37
|
+
'Ash Small': 'Ash Small', 'Ash Large': 'Ash Large', 'Aspen Small': 'Aspen Small', 'Aspen Large': 'Aspen Large', 'Bush 2': 'Bush 2',
|
|
38
|
+
'Bush 3': 'Bush 3', 'Oak Small': 'Oak Small', 'Oak Medium': 'Oak Medium', 'Pine Small': 'Pine Small', 'Pine Large': 'Pine Large',
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// target real-world full-tree height (m) per species: ez-tree's native ~100-unit scale mismatches the trunk collider table, LOD cutovers, and impostor size; normalizing makes visual==collider==LOD==impostor size
|
|
42
|
+
const TARGET_H = {
|
|
43
|
+
'Oak Large': 9, 'Pine Medium': 12, 'Aspen Medium': 9.5, 'Ash Medium': 10, 'Bush': 2.8,
|
|
44
|
+
'Ash Small': 6.5, 'Ash Large': 13, 'Aspen Small': 6, 'Aspen Large': 13, 'Bush 2': 3.0,
|
|
45
|
+
'Bush 3': 3.2, 'Oak Small': 5.5, 'Oak Medium': 7, 'Pine Small': 8, 'Pine Large': 16,
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const DROP_MARGIN = 64 // metres past the ring before a chunk is dropped (hysteresis)
|
|
49
|
+
|
|
50
|
+
const _v = new THREE.Vector3(), _q = new THREE.Quaternion(), _camPos = new THREE.Vector3()
|
|
51
|
+
const _vanMat = new THREE.Matrix4(), _vanProj = new THREE.Matrix4(), _vanFrustum = new THREE.Frustum() // scratch for window.__vegVanishProbe
|
|
52
|
+
|
|
53
|
+
// shared wind uniform (one per veg system); advancing one .value per frame sways all LODs of all species with zero per-instance JS
|
|
54
|
+
function makeWindUniforms() { return { uVegTime: { value: 0 }, uVegWind: { value: 1 } } }
|
|
55
|
+
|
|
56
|
+
function _dissolveBoundaryDistanceShaderChunk(boundaries) {
|
|
57
|
+
const uniforms = boundaries.map((_, i) => `uniform float uVegLodB${i};`).join('\n')
|
|
58
|
+
const mindist = boundaries.map((_, i) => `_vegDistToB = min(_vegDistToB, abs(_vegCamDist - uVegLodB${i}));`).join('\n ')
|
|
59
|
+
return { uniforms, mindist }
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const VEG_DISSOLVE_FADE_BAND_M = 3.0
|
|
63
|
+
|
|
64
|
+
function applyWind(material, wind, lodBoundaries) {
|
|
65
|
+
const chunk = lodBoundaries && lodBoundaries.length ? _dissolveBoundaryDistanceShaderChunk(lodBoundaries) : null
|
|
66
|
+
material.onBeforeCompile = (shader) => {
|
|
67
|
+
shader.uniforms.uVegTime = wind.uVegTime
|
|
68
|
+
shader.uniforms.uVegWind = wind.uVegWind
|
|
69
|
+
shader.vertexShader = 'uniform float uVegTime;\nuniform float uVegWind;\n' + shader.vertexShader
|
|
70
|
+
// windPhase/tint are per-instance uniforms @three.ez declares; its injection runs after this base
|
|
71
|
+
shader.vertexShader = shader.vertexShader.replace('#include <begin_vertex>',
|
|
72
|
+
'#include <begin_vertex>\n' +
|
|
73
|
+
'float _wsway = (position.y) * 0.06;\n' +
|
|
74
|
+
'float _wph = uVegTime * 1.3 + windPhase;\n' +
|
|
75
|
+
'transformed.x += sin(_wph) * _wsway * uVegWind;\n' +
|
|
76
|
+
'transformed.z += cos(_wph * 0.8) * _wsway * 0.6 * uVegWind;')
|
|
77
|
+
// per-instance shade: multiply the lit diffuse by the instance tint (brightness variation).
|
|
78
|
+
shader.fragmentShader = shader.fragmentShader.replace('#include <color_fragment>',
|
|
79
|
+
'#include <color_fragment>\n diffuseColor.rgb *= tint;')
|
|
80
|
+
if (chunk) {
|
|
81
|
+
lodBoundaries.forEach((d, i) => { shader.uniforms['uVegLodB' + i] = { value: d } })
|
|
82
|
+
shader.vertexShader = chunk.uniforms + '\nvarying float vVegCamDist;\n' + shader.vertexShader
|
|
83
|
+
shader.vertexShader = shader.vertexShader.replace('#include <begin_vertex>',
|
|
84
|
+
'#include <begin_vertex>\nvVegCamDist = distance(cameraPosition, (modelMatrix * instanceMatrix * vec4(0.0, 0.0, 0.0, 1.0)).xyz);')
|
|
85
|
+
shader.fragmentShader = chunk.uniforms + '\nvarying float vVegCamDist;\n' + shader.fragmentShader
|
|
86
|
+
shader.fragmentShader = shader.fragmentShader.replace('#include <dithering_fragment>',
|
|
87
|
+
'#include <dithering_fragment>\n' +
|
|
88
|
+
`float _vegCamDist = vVegCamDist;\n` +
|
|
89
|
+
`float _vegDistToB = 1e9;\n` +
|
|
90
|
+
` ${chunk.mindist}\n` +
|
|
91
|
+
`float _vegFade = clamp(_vegDistToB / ${VEG_DISSOLVE_FADE_BAND_M.toFixed(1)}, 0.0, 1.0);\n` +
|
|
92
|
+
'float _vegDither = fract(52.9829189 * fract(dot(gl_FragCoord.xy, vec2(0.06711056, 0.00583715))));\n' +
|
|
93
|
+
'if (_vegDither > _vegFade) discard;')
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
material.customProgramCacheKey = () => 'vegwind3' + (chunk ? '_fade' + lodBoundaries.join('_') : '')
|
|
97
|
+
return material
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// Must await texture decode before sampling: ez-tree loads bark/leaf maps async, so baking the impostor atlas in the same tick would sample undefined images -> blank atlas.
|
|
101
|
+
async function awaitMatTextures(mats) {
|
|
102
|
+
const texes = []
|
|
103
|
+
for (const m of mats) {
|
|
104
|
+
if (!m) continue
|
|
105
|
+
for (const k of ['map', 'normalMap', 'roughnessMap', 'aoMap', 'alphaMap', 'bumpMap']) {
|
|
106
|
+
const t = m[k]; if (t && t.isTexture) texes.push(t)
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
await Promise.all(texes.map(async (t) => {
|
|
110
|
+
const img = t.image
|
|
111
|
+
try {
|
|
112
|
+
if (img && typeof img.decode === 'function') { await img.decode(); t.needsUpdate = true; return }
|
|
113
|
+
} catch (_) {}
|
|
114
|
+
// fallback: poll until the image has dimensions, ~1s cap
|
|
115
|
+
for (let i = 0; i < 60; i++) {
|
|
116
|
+
if (t.image && (t.image.width > 0 || t.image.videoWidth > 0)) { t.needsUpdate = true; return }
|
|
117
|
+
await new Promise(r => setTimeout(r, 16))
|
|
118
|
+
}
|
|
119
|
+
}))
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// Caps a geometry to at most maxTris triangles (meshopt); the LOD0 budget bounding the worst near-tree.
|
|
123
|
+
async function capGeo(geo, maxTris) {
|
|
124
|
+
try {
|
|
125
|
+
const idx = geo.index ? geo.index.array : null
|
|
126
|
+
if (!idx) return geo
|
|
127
|
+
const tris = idx.length / 3
|
|
128
|
+
if (tris <= maxTris) return geo
|
|
129
|
+
return await simplifyGeo(geo, maxTris / tris, false)
|
|
130
|
+
} catch (_) { return geo }
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// Meshopt-simplifies to `ratio` of triangles. Must ALWAYS return a NEW distinct geometry object (never the source) -- @three.ez's addLevel reuses the LOD object when geometry===existing, so two LOD levels sharing one object alias one instanceIndex array and stomp each other's drawn slots (a tree vanishing in a band at the cutover).
|
|
134
|
+
async function simplifyGeo(geo, ratio, sloppy) {
|
|
135
|
+
try {
|
|
136
|
+
await MeshoptSimplifier.ready
|
|
137
|
+
const idx = geo.index ? geo.index.array : null
|
|
138
|
+
const pos = geo.attributes.position.array
|
|
139
|
+
if (!idx || !pos) return geo.clone()
|
|
140
|
+
const target = Math.max(12, Math.floor((idx.length / 3) * ratio) * 3)
|
|
141
|
+
const fn = sloppy && MeshoptSimplifier.simplifySloppy ? 'simplifySloppy' : 'simplify'
|
|
142
|
+
const args = sloppy ? [idx, pos, 3, target, 0.05] : [idx, pos, 3, target, 0.02, ['Sparse']]
|
|
143
|
+
const [newIdx] = MeshoptSimplifier[fn](...args)
|
|
144
|
+
const out = geo.clone()
|
|
145
|
+
if (newIdx && newIdx.length >= 3) out.setIndex(new THREE.BufferAttribute(newIdx, 1))
|
|
146
|
+
return out
|
|
147
|
+
} catch (_) { return geo.clone() }
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
function buildSpecies(name, Tree) {
|
|
151
|
+
const tree = new Tree()
|
|
152
|
+
tree.loadPreset(PRESET[name] || name)
|
|
153
|
+
const branchGeo = tree.branchesMesh.geometry
|
|
154
|
+
const leafGeo = tree.leavesMesh.geometry
|
|
155
|
+
const branchMat = tree.branchesMesh.material
|
|
156
|
+
const leafMat = tree.leavesMesh.material
|
|
157
|
+
branchMat.shadowSide = THREE.FrontSide
|
|
158
|
+
leafMat.alphaTest = Math.max(leafMat.alphaTest || 0, 0.5) // no-MSAA fallback (A2C needs samples)
|
|
159
|
+
leafMat.transparent = false
|
|
160
|
+
leafMat.side = THREE.DoubleSide
|
|
161
|
+
// Apply depth bias to mesh LOD to match impostor bias, preventing flicker at LOD boundary.
|
|
162
|
+
// units -8 -> -32 (2026-07-10, live A/B via GL readPixels flicker-score harness against a real
|
|
163
|
+
// close-range trunk, world ~3-8m from camera): -8 was proven LIVE-INSUFFICIENT -- trunk mesh vs
|
|
164
|
+
// mapspinner's independently-rendered terrain depth z-fight every single frame at that range
|
|
165
|
+
// (strict alternation between trunk color and terrain color on 18-23 of 20-24 sampled frames),
|
|
166
|
+
// even though occlusion/LOD-swap/per-instance-visibility were all confirmed INERT for this exact
|
|
167
|
+
// symptom (occludedKeys.size===0 always, instancesCount constant, getVisibilityAt always true --
|
|
168
|
+
// the flicker is a raw GPU depth-test tie-break flip, not a game-logic visibility toggle). A/B
|
|
169
|
+
// tested -20/-50/-100 all fully eliminated it (0/20 frames changed, vs 18/20 baseline); -32 keeps
|
|
170
|
+
// a safety margin above the smallest working value without over-biasing.
|
|
171
|
+
branchMat.polygonOffset = true
|
|
172
|
+
branchMat.polygonOffsetFactor = -4
|
|
173
|
+
branchMat.polygonOffsetUnits = -32
|
|
174
|
+
leafMat.polygonOffset = true
|
|
175
|
+
leafMat.polygonOffsetFactor = -4
|
|
176
|
+
leafMat.polygonOffsetUnits = -32
|
|
177
|
+
// normalize to a real-world height: scale branch+leaf by the same factor so visual size agrees with the trunk collider + LOD + impostor
|
|
178
|
+
branchGeo.computeBoundingBox(); leafGeo.computeBoundingBox()
|
|
179
|
+
const minY = Math.min(branchGeo.boundingBox.min.y, leafGeo.boundingBox.min.y)
|
|
180
|
+
const maxY = Math.max(branchGeo.boundingBox.max.y, leafGeo.boundingBox.max.y)
|
|
181
|
+
const nativeH = maxY - minY
|
|
182
|
+
const target = TARGET_H[name] || 9
|
|
183
|
+
const s = (Number.isFinite(nativeH) && nativeH > 1e-3) ? target / nativeH : 1
|
|
184
|
+
for (const g of [branchGeo, leafGeo]) {
|
|
185
|
+
g.scale(s, s, s)
|
|
186
|
+
g.translate(0, -minY * s, 0) // drop base to y=0 so the trunk rests on the ground
|
|
187
|
+
g.computeBoundingBox(); g.computeBoundingSphere()
|
|
188
|
+
}
|
|
189
|
+
const bb = branchGeo.boundingBox, lb = leafGeo.boundingBox
|
|
190
|
+
const width = Math.max(bb.max.x - bb.min.x, bb.max.z - bb.min.z, lb.max.x - lb.min.x, lb.max.z - lb.min.z) || target * 0.7
|
|
191
|
+
const dims = { width, height: target }
|
|
192
|
+
return { branchGeo, leafGeo, branchMat, leafMat, tree, dims }
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
// zero-area geo: the far leaf LOD swaps to this so leaf cards vanish where the impostor takes over (no double-draw)
|
|
196
|
+
function makeEmptyGeo() {
|
|
197
|
+
const g = new THREE.BufferGeometry()
|
|
198
|
+
g.setAttribute('position', new THREE.BufferAttribute(new Float32Array(9), 3))
|
|
199
|
+
g.setIndex([0, 1, 2])
|
|
200
|
+
return g
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
export {
|
|
204
|
+
loadEzTree, makeWindUniforms, applyWind, awaitMatTextures, capGeo, simplifyGeo,
|
|
205
|
+
buildSpecies, makeEmptyGeo, PRESET, TARGET_H, VEG_DISSOLVE_FADE_BAND_M
|
|
206
|
+
}
|