mapspinner 1.1.1

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.
Files changed (49) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +91 -0
  3. package/package.json +47 -0
  4. package/src/lib/assets/bark/README.md +8 -0
  5. package/src/lib/assets/bark/birch_ao_1k.jpg +0 -0
  6. package/src/lib/assets/bark/birch_color_1k.jpg +0 -0
  7. package/src/lib/assets/bark/birch_normal_1k.jpg +0 -0
  8. package/src/lib/assets/bark/birch_roughness_1k.jpg +0 -0
  9. package/src/lib/assets/bark/oak_ao_1k.jpg +0 -0
  10. package/src/lib/assets/bark/oak_color_1k.jpg +0 -0
  11. package/src/lib/assets/bark/oak_normal_1k.jpg +0 -0
  12. package/src/lib/assets/bark/oak_roughness_1k.jpg +0 -0
  13. package/src/lib/assets/bark/pine_ao_1k.jpg +0 -0
  14. package/src/lib/assets/bark/pine_color_1k.jpg +0 -0
  15. package/src/lib/assets/bark/pine_normal_1k.jpg +0 -0
  16. package/src/lib/assets/bark/pine_roughness_1k.jpg +0 -0
  17. package/src/lib/assets/bark/willow_ao_1k.jpg +0 -0
  18. package/src/lib/assets/bark/willow_color_1k.jpg +0 -0
  19. package/src/lib/assets/bark/willow_normal_1k.jpg +0 -0
  20. package/src/lib/assets/bark/willow_roughness_1k.jpg +0 -0
  21. package/src/lib/assets/leaves/ash_color.png +0 -0
  22. package/src/lib/assets/leaves/aspen_color.png +0 -0
  23. package/src/lib/assets/leaves/oak_color.png +0 -0
  24. package/src/lib/assets/leaves/pine_color.png +0 -0
  25. package/src/lib/branch.js +28 -0
  26. package/src/lib/enums.js +23 -0
  27. package/src/lib/index.js +4 -0
  28. package/src/lib/options.js +198 -0
  29. package/src/lib/presets/ash_large.json +96 -0
  30. package/src/lib/presets/ash_medium.json +96 -0
  31. package/src/lib/presets/ash_small.json +96 -0
  32. package/src/lib/presets/aspen_large.json +96 -0
  33. package/src/lib/presets/aspen_medium.json +96 -0
  34. package/src/lib/presets/aspen_small.json +96 -0
  35. package/src/lib/presets/bush_1.json +96 -0
  36. package/src/lib/presets/bush_2.json +96 -0
  37. package/src/lib/presets/bush_3.json +96 -0
  38. package/src/lib/presets/index.js +47 -0
  39. package/src/lib/presets/oak_large.json +96 -0
  40. package/src/lib/presets/oak_medium.json +96 -0
  41. package/src/lib/presets/oak_small.json +96 -0
  42. package/src/lib/presets/pine_large.json +96 -0
  43. package/src/lib/presets/pine_medium.json +96 -0
  44. package/src/lib/presets/pine_small.json +96 -0
  45. package/src/lib/presets/trellis.json +112 -0
  46. package/src/lib/rng.js +22 -0
  47. package/src/lib/textures.js +105 -0
  48. package/src/lib/tree.js +1062 -0
  49. package/src/lib/trellis.js +135 -0
@@ -0,0 +1,1062 @@
1
+ import * as THREE from 'three';
2
+ import RNG from './rng.js';
3
+ import { Branch } from './branch.js';
4
+ import { Billboard, TreeType } from './enums.js';
5
+ import TreeOptions from './options.js';
6
+ import { loadPreset } from './presets/index.js';
7
+ import { getBarkTexture, getLeafTexture } from './textures.js';
8
+ import { Trellis } from './trellis.js';
9
+
10
+ // Single shared unit-quad geometry for all leaves across all trees. The
11
+ // per-instance origin/orientation/scale is encoded into instanceMatrix —
12
+ // the geometry itself is just a 4-vertex / 6-index quad.
13
+ let _unitLeafGeo = null;
14
+ function _getUnitLeafGeometry() {
15
+ if (_unitLeafGeo) return _unitLeafGeo;
16
+ const g = new THREE.BufferGeometry();
17
+ // Match the original generateLeaf corner layout (W=L=1):
18
+ // v[0]=(-W/2, L, 0) uv (0,1)
19
+ // v[1]=(-W/2, 0, 0) uv (0,0)
20
+ // v[2]=( W/2, 0, 0) uv (1,0)
21
+ // v[3]=( W/2, L, 0) uv (1,1)
22
+ const positions = new Float32Array([
23
+ -0.5, 1, 0,
24
+ -0.5, 0, 0,
25
+ 0.5, 0, 0,
26
+ 0.5, 1, 0,
27
+ ]);
28
+ const uvs = new Float32Array([0, 1, 0, 0, 1, 0, 1, 1]);
29
+ const indices = new Uint16Array([0, 1, 2, 0, 2, 3]);
30
+ g.setAttribute('position', new THREE.BufferAttribute(positions, 3));
31
+ g.setAttribute('uv', new THREE.BufferAttribute(uvs, 2));
32
+ g.setIndex(new THREE.BufferAttribute(indices, 1));
33
+ // Front-facing normal (z+); shader runs with side: DoubleSide so back faces
34
+ // get flipped lighting via the standard pipeline.
35
+ const normals = new Float32Array([0, 0, 1, 0, 0, 1, 0, 0, 1, 0, 0, 1]);
36
+ g.setAttribute('normal', new THREE.BufferAttribute(normals, 3));
37
+ g.computeBoundingSphere();
38
+ // Boost bounding sphere so frustum culling doesn't drop tilted instances.
39
+ g.boundingSphere.radius = 1.5;
40
+ _unitLeafGeo = g;
41
+ return _unitLeafGeo;
42
+ }
43
+
44
+ // Compose the per-instance leaf transform: rotate by ryRot around local Y,
45
+ // rotate by parent orientation Euler, translate by origin, uniform scale.
46
+ const _tmpEuler = new THREE.Euler();
47
+ const _tmpQ = new THREE.Quaternion();
48
+ const _tmpQy = new THREE.Quaternion();
49
+ const _tmpQfinal = new THREE.Quaternion();
50
+ const _tmpV = new THREE.Vector3();
51
+ const _tmpScale = new THREE.Vector3();
52
+ const _yAxis = new THREE.Vector3(0, 1, 0);
53
+ function _composeLeafMatrix(out, inst) {
54
+ _tmpEuler.set(inst.ex, inst.ey, inst.ez);
55
+ _tmpQ.setFromEuler(_tmpEuler);
56
+ _tmpQy.setFromAxisAngle(_yAxis, inst.ryRot);
57
+ _tmpQfinal.multiplyQuaternions(_tmpQ, _tmpQy);
58
+ _tmpV.set(inst.ox, inst.oy, inst.oz);
59
+ _tmpScale.set(inst.size, inst.size, inst.size);
60
+ out.compose(_tmpV, _tmpQfinal, _tmpScale);
61
+ }
62
+
63
+ // Module-level material caches. Sharing materials across Tree instances cuts
64
+ // allocation cost from O(trees) to O(distinct_visible_configs) and lets the
65
+ // per-frame uTime write target one shader handle per material instead of one
66
+ // per tree. The cache key is the *visible* identity: same key => identical
67
+ // pixel output, so sharing is visually equivalent.
68
+ const _leafMatCache = new Map();
69
+ const _branchMatCache = new Map();
70
+ // Active leaf shader handles (populated on first material compile by Three).
71
+ // Updated en masse via Tree.updateAllShaders(t) — O(distinct_materials).
72
+ const _leafShaders = new Set();
73
+
74
+ function _leafKey(o) {
75
+ return [o.type, o.tint, o.alphaTest, !!o.textured].join('|');
76
+ }
77
+ function _branchKey(o) {
78
+ return [o.type, o.tint, !!o.flatShading, !!o.textured, o.textureScale].join('|');
79
+ }
80
+
81
+ export class Tree extends THREE.Group {
82
+ /** Single per-frame call updates all shared leaf shaders. */
83
+ static updateAllShaders(elapsedTime) {
84
+ for (const sh of _leafShaders) {
85
+ if (sh && sh.uniforms && sh.uniforms.uTime) sh.uniforms.uTime.value = elapsedTime;
86
+ }
87
+ }
88
+ static get sharedLeafMaterialCount() { return _leafMatCache.size; }
89
+ static get sharedLeafShaderCount() { return _leafShaders.size; }
90
+ static get sharedBranchMaterialCount() { return _branchMatCache.size; }
91
+
92
+ /**
93
+ * Consolidate all leaf InstancedMeshes inside `rootGroup` into one big
94
+ * InstancedMesh per leaf material. Cuts per-tree draw call count from
95
+ * O(trees) to O(distinct_leaf_materials). Tree branches stay per-tree
96
+ * (they're unique geometry) but leaves merge across the whole forest.
97
+ * After this call, each tree's leavesMesh is removed from the scene.
98
+ */
99
+ static consolidateLeaves(rootGroup) {
100
+ // Collect (worldMatrix, leavesMesh, instances) for every tree in the group.
101
+ const buckets = new Map(); // material -> Matrix4[] in rootGroup-local space
102
+ rootGroup.updateMatrixWorld(true);
103
+ const rootInverse = new THREE.Matrix4().copy(rootGroup.matrixWorld).invert();
104
+ const treesToClean = [];
105
+ rootGroup.traverse((o) => {
106
+ if (o instanceof Tree) treesToClean.push(o);
107
+ });
108
+ const tmpM = new THREE.Matrix4();
109
+ for (const tree of treesToClean) {
110
+ const leafMesh = tree.leavesMesh;
111
+ if (!leafMesh || !leafMesh.isInstancedMesh) continue;
112
+ const mat = leafMesh.material;
113
+ // Tree-local instance matrix -> world -> rootGroup-local
114
+ tree.updateMatrixWorld(true);
115
+ const treeWorld = tree.matrixWorld;
116
+ const localToRoot = new THREE.Matrix4().multiplyMatrices(rootInverse, treeWorld);
117
+ const list = buckets.get(mat) || [];
118
+ const inst = leafMesh.instanceMatrix;
119
+ const count = leafMesh.count;
120
+ for (let i = 0; i < count; i++) {
121
+ leafMesh.getMatrixAt(i, tmpM);
122
+ const world = new THREE.Matrix4().multiplyMatrices(localToRoot, tmpM);
123
+ list.push(world);
124
+ }
125
+ buckets.set(mat, list);
126
+ // Remove the per-tree leaves entirely.
127
+ tree.remove(leafMesh);
128
+ tree.leavesMesh = null;
129
+ }
130
+ const geom = _getUnitLeafGeometry();
131
+ const consolidated = [];
132
+ for (const [mat, mats] of buckets.entries()) {
133
+ const merged = new THREE.InstancedMesh(geom, mat, mats.length);
134
+ merged.name = 'leaves-merged';
135
+ merged.castShadow = true;
136
+ merged.receiveShadow = true;
137
+ merged.frustumCulled = false;
138
+ for (let i = 0; i < mats.length; i++) merged.setMatrixAt(i, mats[i]);
139
+ merged.instanceMatrix.needsUpdate = true;
140
+ rootGroup.add(merged);
141
+ consolidated.push(merged);
142
+ }
143
+ return { meshes: consolidated, totalInstances: Array.from(buckets.values()).reduce((s,a)=>s+a.length,0) };
144
+ }
145
+
146
+ /**
147
+ * Merge per-tree branch meshes (regular non-instanced THREE.Mesh objects)
148
+ * into a small set of merged BufferGeometries grouped by material. Cuts
149
+ * forest branch draw calls from O(trees) to O(distinct_branch_materials).
150
+ * Each tree's branchesMesh is removed from the scene; its geometry data is
151
+ * baked into world space so the merged mesh sits at the rootGroup origin.
152
+ */
153
+ static consolidateBranches(rootGroup) {
154
+ rootGroup.updateMatrixWorld(true);
155
+ const rootInverse = new THREE.Matrix4().copy(rootGroup.matrixWorld).invert();
156
+ const trees = [];
157
+ rootGroup.traverse((o) => { if (o instanceof Tree) trees.push(o); });
158
+ // material -> { positions:[], normals:[], uvs:[], windFactors:[], indices:[] }
159
+ const buckets = new Map();
160
+ let nextIndexBase = new Map(); // material -> running base
161
+ for (const tree of trees) {
162
+ const bm = tree.branchesMesh;
163
+ if (!bm || !bm.geometry || !bm.geometry.attributes || !bm.geometry.attributes.position) continue;
164
+ const mat = bm.material;
165
+ tree.updateMatrixWorld(true);
166
+ const local = new THREE.Matrix4().multiplyMatrices(rootInverse, tree.matrixWorld);
167
+ const normalLocal = new THREE.Matrix3().getNormalMatrix(local);
168
+ let bucket = buckets.get(mat);
169
+ if (!bucket) { bucket = { pos:[], nrm:[], uvs:[], wind:[], idx:[], base:0 }; buckets.set(mat, bucket); }
170
+ const g = bm.geometry;
171
+ const posAttr = g.attributes.position;
172
+ const nrmAttr = g.attributes.normal;
173
+ const uvAttr = g.attributes.uv;
174
+ const windAttr = g.attributes.windFactor;
175
+ const idxAttr = g.index;
176
+ const v = new THREE.Vector3();
177
+ const n = new THREE.Vector3();
178
+ const base = bucket.base;
179
+ for (let i = 0; i < posAttr.count; i++) {
180
+ v.fromBufferAttribute(posAttr, i).applyMatrix4(local);
181
+ bucket.pos.push(v.x, v.y, v.z);
182
+ if (nrmAttr) {
183
+ n.fromBufferAttribute(nrmAttr, i).applyMatrix3(normalLocal).normalize();
184
+ bucket.nrm.push(n.x, n.y, n.z);
185
+ }
186
+ if (uvAttr) bucket.uvs.push(uvAttr.getX(i), uvAttr.getY(i));
187
+ if (windAttr) bucket.wind.push(windAttr.getX(i));
188
+ }
189
+ if (idxAttr) {
190
+ for (let i = 0; i < idxAttr.count; i++) bucket.idx.push(idxAttr.getX(i) + base);
191
+ } else {
192
+ for (let i = 0; i < posAttr.count; i++) bucket.idx.push(i + base);
193
+ }
194
+ bucket.base = base + posAttr.count;
195
+ // Detach per-tree branch mesh
196
+ tree.remove(bm);
197
+ bm.geometry.dispose();
198
+ tree.branchesMesh = null;
199
+ }
200
+ const merged = [];
201
+ for (const [mat, b] of buckets.entries()) {
202
+ const geom = new THREE.BufferGeometry();
203
+ geom.setAttribute('position', new THREE.Float32BufferAttribute(b.pos, 3));
204
+ if (b.nrm.length) geom.setAttribute('normal', new THREE.Float32BufferAttribute(b.nrm, 3));
205
+ if (b.uvs.length) geom.setAttribute('uv', new THREE.Float32BufferAttribute(b.uvs, 2));
206
+ if (b.wind.length) geom.setAttribute('windFactor', new THREE.Float32BufferAttribute(b.wind, 1));
207
+ geom.setIndex(b.idx.length > 65535 ? new THREE.Uint32BufferAttribute(b.idx, 1) : new THREE.Uint16BufferAttribute(b.idx, 1));
208
+ geom.computeBoundingSphere();
209
+ const m = new THREE.Mesh(geom, mat);
210
+ m.name = 'branches-merged';
211
+ m.castShadow = true;
212
+ m.receiveShadow = true;
213
+ rootGroup.add(m);
214
+ merged.push(m);
215
+ }
216
+ return { meshes: merged, materials: buckets.size };
217
+ }
218
+
219
+ /**
220
+ * @type {RNG}
221
+ */
222
+ rng;
223
+
224
+ /**
225
+ * @type {TreeOptions}
226
+ */
227
+ options;
228
+
229
+ /**
230
+ * @type {Branch[]}
231
+ */
232
+ branchQueue = [];
233
+
234
+ /**
235
+ * @param {TreeOptions} params
236
+ */
237
+ constructor(options = new TreeOptions()) {
238
+ super();
239
+ this.name = 'Tree';
240
+ this.branchesMesh = new THREE.Mesh();
241
+ // leavesMesh is rebuilt as an InstancedMesh on every generate() —
242
+ // start as a placeholder so .remove()/.add() still work uniformly.
243
+ this.leavesMesh = new THREE.Object3D();
244
+ this.trellisMesh = null;
245
+ this.add(this.branchesMesh);
246
+ this.add(this.leavesMesh);
247
+ this.options = options;
248
+ }
249
+
250
+ update(elapsedTime) {
251
+ // Materials are shared across trees; uTime is written once per material
252
+ // via Tree.updateAllShaders. Keep this as a no-op for API compatibility.
253
+ }
254
+
255
+ /**
256
+ * Loads a preset tree from JSON
257
+ * @param {string} preset
258
+ */
259
+ loadPreset(name) {
260
+ const json = loadPreset(name);
261
+ this.loadFromJson(json);
262
+ }
263
+
264
+ /**
265
+ * Loads a tree from JSON
266
+ * @param {TreeOptions} json
267
+ */
268
+ loadFromJson(json) {
269
+ this.options.copy(json);
270
+ this.generate();
271
+ }
272
+
273
+ /**
274
+ * Generate a new tree
275
+ */
276
+ generate() {
277
+ // Clean up old geometry
278
+ this.branches = {
279
+ verts: [],
280
+ normals: [],
281
+ indices: [],
282
+ uvs: [],
283
+ windFactor: []
284
+ };
285
+
286
+ // Instance descriptors: each entry produces one InstancedMesh slot.
287
+ // Two-sided billboard => emit two entries per leaf with rotation offset.
288
+ this.leaves = { instances: [] };
289
+
290
+ this.rng = new RNG(this.options.seed);
291
+
292
+ // Create the trunk of the tree first
293
+ this.branchQueue.push(
294
+ new Branch(
295
+ new THREE.Vector3(),
296
+ new THREE.Euler(),
297
+ this.options.branch.length[0],
298
+ this.options.branch.radius[0],
299
+ 0,
300
+ this.options.branch.sections[0],
301
+ this.options.branch.segments[0],
302
+ ),
303
+ );
304
+
305
+ while (this.branchQueue.length > 0) {
306
+ const branch = this.branchQueue.shift();
307
+ this.generateBranch(branch);
308
+ }
309
+
310
+ this.createBranchesGeometry();
311
+ this.createLeavesGeometry();
312
+ this.createTrellis();
313
+ }
314
+
315
+ /**
316
+ * Generates a new branch
317
+ * @param {Branch} branch
318
+ * @returns
319
+ */
320
+ generateBranch(branch) {
321
+ // Used later for geometry index generation
322
+ const indexOffset = this.branches.verts.length / 3;
323
+
324
+ let sectionOrientation = branch.orientation.clone();
325
+ let sectionOrigin = branch.origin.clone();
326
+ let sectionLength =
327
+ branch.length /
328
+ branch.sectionCount /
329
+ (this.options.type === 'Deciduous' ? this.options.branch.levels - 1 : 1);
330
+
331
+ // This information is used for generating child branches after the branch
332
+ // geometry has been constructed
333
+ let sections = [];
334
+
335
+ for (let i = 0; i <= branch.sectionCount; i++) {
336
+ let sectionRadius = branch.radius;
337
+
338
+ // If final section of final level, set radius to effecively zero
339
+ if (
340
+ i === branch.sectionCount &&
341
+ branch.level === this.options.branch.levels
342
+ ) {
343
+ sectionRadius = 0.001;
344
+ } else if (this.options.type === TreeType.Deciduous) {
345
+ sectionRadius *=
346
+ 1 - this.options.branch.taper[branch.level] * (i / branch.sectionCount);
347
+ } else if (this.options.type === TreeType.Evergreen) {
348
+ // Evergreens do not have a terminal branch so they have a taper of 1
349
+ sectionRadius *= 1 - (i / branch.sectionCount);
350
+ }
351
+
352
+ // Create the segments that make up this section.
353
+ let first;
354
+ for (let j = 0; j < branch.segmentCount; j++) {
355
+ let angle = (2.0 * Math.PI * j) / branch.segmentCount;
356
+
357
+ // Create the segment vertex
358
+ const vertex = new THREE.Vector3(Math.cos(angle), 0, Math.sin(angle))
359
+ .multiplyScalar(sectionRadius)
360
+ .applyEuler(sectionOrientation)
361
+ .add(sectionOrigin);
362
+
363
+ const normal = new THREE.Vector3(Math.cos(angle), 0, Math.sin(angle))
364
+ .applyEuler(sectionOrientation)
365
+ .normalize();
366
+
367
+ const uv = new THREE.Vector2(
368
+ j / branch.segmentCount,
369
+ (i % 2 === 0) ? 0 : 1,
370
+ );
371
+
372
+ this.branches.verts.push(...Object.values(vertex));
373
+ this.branches.normals.push(...Object.values(normal));
374
+ this.branches.uvs.push(...Object.values(uv));
375
+
376
+ if (j === 0) {
377
+ first = { vertex, normal, uv };
378
+ }
379
+ }
380
+
381
+ // Duplicate the first vertex so there is continuity in the UV mapping
382
+ this.branches.verts.push(...Object.values(first.vertex));
383
+ this.branches.normals.push(...Object.values(first.normal));
384
+ this.branches.uvs.push(1, first.uv.y);
385
+
386
+ // Use this information later on when generating child branches
387
+ sections.push({
388
+ origin: sectionOrigin.clone(),
389
+ orientation: sectionOrientation.clone(),
390
+ radius: sectionRadius,
391
+ });
392
+
393
+ sectionOrigin.add(
394
+ new THREE.Vector3(0, sectionLength, 0).applyEuler(sectionOrientation),
395
+ );
396
+
397
+ // Perturb the orientation of the next section randomly. The higher the
398
+ // gnarliness, the larger potential perturbation
399
+ const gnarliness =
400
+ Math.max(1, 1 / Math.sqrt(sectionRadius)) *
401
+ this.options.branch.gnarliness[branch.level];
402
+
403
+ sectionOrientation.x += this.rng.random(gnarliness, -gnarliness);
404
+ sectionOrientation.z += this.rng.random(gnarliness, -gnarliness);
405
+
406
+ // Apply growth force to the branch
407
+ const qSection = new THREE.Quaternion().setFromEuler(sectionOrientation);
408
+
409
+ const qTwist = new THREE.Quaternion().setFromAxisAngle(
410
+ new THREE.Vector3(0, 1, 0),
411
+ this.options.branch.twist[branch.level],
412
+ );
413
+
414
+ const qForce = new THREE.Quaternion().setFromUnitVectors(
415
+ new THREE.Vector3(0, 1, 0),
416
+ new THREE.Vector3().copy(this.options.branch.force.direction),
417
+ );
418
+
419
+ qSection.multiply(qTwist);
420
+ qSection.rotateTowards(
421
+ qForce,
422
+ this.options.branch.force.strength / sectionRadius,
423
+ );
424
+
425
+ // Apply trellis force if enabled
426
+ if (this.options.trellis.enabled) {
427
+ const trellisResult = this.calculateTrellisForce(sectionOrigin, sectionRadius);
428
+ if (trellisResult) {
429
+ const qTrellis = new THREE.Quaternion().setFromUnitVectors(
430
+ new THREE.Vector3(0, 1, 0),
431
+ trellisResult.direction,
432
+ );
433
+ qSection.rotateTowards(qTrellis, trellisResult.strength);
434
+ }
435
+ }
436
+
437
+ sectionOrientation.setFromQuaternion(qSection);
438
+ }
439
+
440
+ this.generateBranchIndices(indexOffset, branch);
441
+
442
+ // Deciduous trees have a terminal branch that grows out of the
443
+ // end of the parent branch
444
+ if (this.options.type === 'deciduous') {
445
+ const lastSection = sections[sections.length - 1];
446
+
447
+ if (branch.level < this.options.branch.levels) {
448
+ this.branchQueue.push(
449
+ new Branch(
450
+ lastSection.origin,
451
+ lastSection.orientation,
452
+ this.options.branch.length[branch.level + 1],
453
+ lastSection.radius,
454
+ branch.level + 1,
455
+ // Section count and segment count must be same as parent branch
456
+ // since the child branch is growing from the end of the parent branch
457
+ branch.sectionCount,
458
+ branch.segmentCount,
459
+ ),
460
+ );
461
+ } else {
462
+ this.generateLeaf(lastSection.origin, lastSection.orientation);
463
+ }
464
+ }
465
+
466
+ // If we are on the last branch level, generate leaves
467
+ if (branch.level === this.options.branch.levels) {
468
+ this.generateLeaves(sections);
469
+ } else if (branch.level < this.options.branch.levels) {
470
+ this.generateChildBranches(
471
+ this.options.branch.children[branch.level],
472
+ branch.level + 1,
473
+ sections);
474
+ }
475
+ }
476
+
477
+ /**
478
+ * Generate branches from a parent branch
479
+ * @param {number} count The number of child branches to generate
480
+ * @param {number} level The level of the child branches
481
+ * @param {{
482
+ * origin: THREE.Vector3,
483
+ * orientation: THREE.Euler,
484
+ * radius: number
485
+ * }[]} sections The parent branch's sections
486
+ * @returns
487
+ */
488
+ generateChildBranches(count, level, sections) {
489
+ const radialOffset = this.rng.random();
490
+
491
+ for (let i = 0; i < count; i++) {
492
+ // Determine how far along the length of the parent branch the child
493
+ // branch should originate from (0 to 1)
494
+ let childBranchStart = this.rng.random(1.0, this.options.branch.start[level]);
495
+
496
+ // Find which sections are on either side of the child branch origin point
497
+ // so we can determine the origin, orientation and radius of the branch
498
+ const sectionIndex = Math.floor(childBranchStart * (sections.length - 1));
499
+ let sectionA, sectionB;
500
+ sectionA = sections[sectionIndex];
501
+ if (sectionIndex === sections.length - 1) {
502
+ sectionB = sectionA;
503
+ } else {
504
+ sectionB = sections[sectionIndex + 1];
505
+ }
506
+
507
+ // Find normalized distance from section A to section B (0 to 1)
508
+ const alpha =
509
+ (childBranchStart - sectionIndex / (sections.length - 1)) /
510
+ (1 / (sections.length - 1));
511
+
512
+ // Linearly interpolate origin from section A to section B
513
+ const childBranchOrigin = new THREE.Vector3().lerpVectors(
514
+ sectionA.origin,
515
+ sectionB.origin,
516
+ alpha,
517
+ );
518
+
519
+ // Linearly interpolate radius
520
+ const childBranchRadius =
521
+ this.options.branch.radius[level] *
522
+ ((1 - alpha) * sectionA.radius + alpha * sectionB.radius);
523
+
524
+ // Linearlly interpolate the orientation
525
+ const qA = new THREE.Quaternion().setFromEuler(sectionA.orientation);
526
+ const qB = new THREE.Quaternion().setFromEuler(sectionB.orientation);
527
+ const parentOrientation = new THREE.Euler().setFromQuaternion(
528
+ qB.slerp(qA, alpha),
529
+ );
530
+
531
+ // Calculate the angle offset from the parent branch and the radial angle
532
+ const radialAngle = 2.0 * Math.PI * (radialOffset + i / count);
533
+ const q1 = new THREE.Quaternion().setFromAxisAngle(
534
+ new THREE.Vector3(1, 0, 0),
535
+ this.options.branch.angle[level] / (180 / Math.PI),
536
+ );
537
+ const q2 = new THREE.Quaternion().setFromAxisAngle(
538
+ new THREE.Vector3(0, 1, 0),
539
+ radialAngle,
540
+ );
541
+ const q3 = new THREE.Quaternion().setFromEuler(parentOrientation);
542
+
543
+ const childBranchOrientation = new THREE.Euler().setFromQuaternion(
544
+ q3.multiply(q2.multiply(q1)),
545
+ );
546
+
547
+ let childBranchLength =
548
+ this.options.branch.length[level] *
549
+ (this.options.type === TreeType.Evergreen
550
+ ? 1.0 - childBranchStart
551
+ : 1.0);
552
+
553
+ this.branchQueue.push(
554
+ new Branch(
555
+ childBranchOrigin,
556
+ childBranchOrientation,
557
+ childBranchLength,
558
+ childBranchRadius,
559
+ level,
560
+ this.options.branch.sections[level],
561
+ this.options.branch.segments[level],
562
+ ),
563
+ );
564
+ }
565
+ }
566
+
567
+ /**
568
+ * Logic for spawning child branches from a parent branch's section
569
+ * @param {{
570
+ * origin: THREE.Vector3,
571
+ * orientation: THREE.Euler,
572
+ * radius: number
573
+ * }[]} sections The parent branch's sections
574
+ * @returns
575
+ */
576
+ generateLeaves(sections) {
577
+ const radialOffset = this.rng.random();
578
+
579
+ for (let i = 0; i < this.options.leaves.count; i++) {
580
+ // Determine how far along the length of the parent
581
+ // branch the leaf should originate from (0 to 1)
582
+ let leafStart = this.rng.random(1.0, this.options.leaves.start);
583
+
584
+ // Find which sections are on either side of the child branch origin point
585
+ // so we can determine the origin, orientation and radius of the branch
586
+ const sectionIndex = Math.floor(leafStart * (sections.length - 1));
587
+ let sectionA, sectionB;
588
+ sectionA = sections[sectionIndex];
589
+ if (sectionIndex === sections.length - 1) {
590
+ sectionB = sectionA;
591
+ } else {
592
+ sectionB = sections[sectionIndex + 1];
593
+ }
594
+
595
+ // Find normalized distance from section A to section B (0 to 1)
596
+ const alpha =
597
+ (leafStart - sectionIndex / (sections.length - 1)) /
598
+ (1 / (sections.length - 1));
599
+
600
+ // Linearly interpolate origin from section A to section B
601
+ const leafOrigin = new THREE.Vector3().lerpVectors(
602
+ sectionA.origin,
603
+ sectionB.origin,
604
+ alpha,
605
+ );
606
+
607
+ // Linearlly interpolate the orientation
608
+ const qA = new THREE.Quaternion().setFromEuler(sectionA.orientation);
609
+ const qB = new THREE.Quaternion().setFromEuler(sectionB.orientation);
610
+ const parentOrientation = new THREE.Euler().setFromQuaternion(
611
+ qB.slerp(qA, alpha),
612
+ );
613
+
614
+ // Calculate the angle offset from the parent branch and the radial angle
615
+ const radialAngle = 2.0 * Math.PI * (radialOffset + i / this.options.leaves.count);
616
+ const q1 = new THREE.Quaternion().setFromAxisAngle(
617
+ new THREE.Vector3(1, 0, 0),
618
+ this.options.leaves.angle / (180 / Math.PI),
619
+ );
620
+ const q2 = new THREE.Quaternion().setFromAxisAngle(
621
+ new THREE.Vector3(0, 1, 0),
622
+ radialAngle,
623
+ );
624
+ const q3 = new THREE.Quaternion().setFromEuler(parentOrientation);
625
+
626
+ const leafOrientation = new THREE.Euler().setFromQuaternion(
627
+ q3.multiply(q2.multiply(q1)),
628
+ );
629
+
630
+ this.generateLeaf(leafOrigin, leafOrientation);
631
+ }
632
+ }
633
+
634
+ /**
635
+ * Generates a leaves
636
+ * @param {THREE.Vector3} origin The starting point of the branch
637
+ * @param {THREE.Euler} orientation The starting orientation of the branch
638
+ */
639
+ generateLeaf(origin, orientation) {
640
+ const leafSize =
641
+ this.options.leaves.size *
642
+ (1 +
643
+ this.rng.random(
644
+ this.options.leaves.sizeVariance,
645
+ -this.options.leaves.sizeVariance,
646
+ ));
647
+
648
+ // Each instance carries origin, orientation Euler, an extra Y rotation
649
+ // (for the two-sided crossed-quad billboard), and uniform scale.
650
+ this.leaves.instances.push({
651
+ ox: origin.x, oy: origin.y, oz: origin.z,
652
+ ex: orientation.x, ey: orientation.y, ez: orientation.z,
653
+ ryRot: 0,
654
+ size: leafSize,
655
+ });
656
+ if (this.options.leaves.billboard === Billboard.Double) {
657
+ this.leaves.instances.push({
658
+ ox: origin.x, oy: origin.y, oz: origin.z,
659
+ ex: orientation.x, ey: orientation.y, ez: orientation.z,
660
+ ryRot: Math.PI / 2,
661
+ size: leafSize,
662
+ });
663
+ }
664
+ }
665
+
666
+ /**
667
+ * Generates the indices for branch geometry
668
+ * @param {Branch} branch
669
+ */
670
+ generateBranchIndices(indexOffset, branch) {
671
+ // Build geometry each section of the branch (cylinder without end caps)
672
+ let v1, v2, v3, v4;
673
+ const N = branch.segmentCount + 1;
674
+ for (let i = 0; i < branch.sectionCount; i++) {
675
+ // Build the quad for each segment of the section
676
+ for (let j = 0; j < branch.segmentCount; j++) {
677
+ v1 = indexOffset + i * N + j;
678
+ // The last segment wraps around back to the starting segment, so omit j + 1 term
679
+ v2 = indexOffset + i * N + (j + 1);
680
+ v3 = v1 + N;
681
+ v4 = v2 + N;
682
+ this.branches.indices.push(v1, v3, v2, v2, v3, v4);
683
+ }
684
+ }
685
+ }
686
+
687
+ /**
688
+ * Generates the geometry for the branches
689
+ */
690
+ createBranchesGeometry() {
691
+ const g = new THREE.BufferGeometry();
692
+ g.setAttribute(
693
+ 'position',
694
+ new THREE.BufferAttribute(new Float32Array(this.branches.verts), 3),
695
+ );
696
+ g.setAttribute(
697
+ 'normal',
698
+ new THREE.BufferAttribute(new Float32Array(this.branches.normals), 3),
699
+ );
700
+ g.setAttribute(
701
+ 'uv',
702
+ new THREE.BufferAttribute(new Float32Array(this.branches.uvs), 2),
703
+ );
704
+ g.setIndex(
705
+ new THREE.BufferAttribute(new Uint16Array(this.branches.indices), 1),
706
+ );
707
+ g.computeBoundingSphere();
708
+
709
+ const bk = _branchKey(this.options.bark);
710
+ let mat = _branchMatCache.get(bk);
711
+ if (!mat) {
712
+ mat = new THREE.MeshPhongMaterial({
713
+ name: 'branches',
714
+ flatShading: this.options.bark.flatShading,
715
+ color: new THREE.Color(this.options.bark.tint),
716
+ });
717
+ if (this.options.bark.textured) {
718
+ mat.aoMap = getBarkTexture(this.options.bark.type, 'ao', this.options.bark.textureScale);
719
+ mat.map = getBarkTexture(this.options.bark.type, 'color', this.options.bark.textureScale);
720
+ mat.normalMap = getBarkTexture(this.options.bark.type, 'normal', this.options.bark.textureScale);
721
+ mat.roughnessMap = getBarkTexture(this.options.bark.type, 'roughness', this.options.bark.textureScale);
722
+ }
723
+ _branchMatCache.set(bk, mat);
724
+ }
725
+
726
+ this.branchesMesh.geometry.dispose();
727
+ this.branchesMesh.geometry = g;
728
+ // Do NOT dispose cached material — it's shared across trees.
729
+ this.branchesMesh.material = mat;
730
+ this.branchesMesh.castShadow = true;
731
+ this.branchesMesh.receiveShadow = true;
732
+ }
733
+
734
+ /**
735
+ * Generates the InstancedMesh for the leaves. One unit-quad geometry,
736
+ * per-instance origin/orientation/scale carried in instanceMatrix.
737
+ */
738
+ createLeavesGeometry() {
739
+ // Single quad in local space — corners match the original generateLeaf
740
+ // layout: bottom-left/right at y=0, top-left/right at y=1, x in [-0.5, 0.5].
741
+ // Per-instance scale lifts these to the leaf's actual world size.
742
+ const g = _getUnitLeafGeometry();
743
+
744
+ const lk = _leafKey(this.options.leaves);
745
+ let mat = _leafMatCache.get(lk);
746
+ if (!mat) {
747
+ mat = this._buildLeafMaterial();
748
+ _leafMatCache.set(lk, mat);
749
+ }
750
+ // Replace the existing leavesMesh with a fresh InstancedMesh sized to
751
+ // current instance count. The old mesh (if any) is removed from this Tree.
752
+ if (this.leavesMesh) {
753
+ this.remove(this.leavesMesh);
754
+ // Don't dispose the unit geometry (shared) and don't dispose the cached
755
+ // material — both are deliberately re-used across all trees.
756
+ }
757
+ const count = this.leaves.instances.length;
758
+ const inst = new THREE.InstancedMesh(g, mat, count);
759
+ inst.name = 'leaves';
760
+ const m = new THREE.Matrix4();
761
+ for (let i = 0; i < count; i++) {
762
+ _composeLeafMatrix(m, this.leaves.instances[i]);
763
+ inst.setMatrixAt(i, m);
764
+ }
765
+ inst.instanceMatrix.needsUpdate = true;
766
+ inst.castShadow = true;
767
+ inst.receiveShadow = true;
768
+ inst.frustumCulled = false; // Wind sway can push verts outside the static bound
769
+ this.leavesMesh = inst;
770
+ this.add(inst);
771
+ }
772
+
773
+ /** Build a fresh leaf material with the wind-sway onBeforeCompile hook. */
774
+ _buildLeafMaterial() {
775
+ const mat = new THREE.MeshPhongMaterial({
776
+ name: 'leaves',
777
+ map: getLeafTexture(this.options.leaves.type),
778
+ color: new THREE.Color(this.options.leaves.tint),
779
+ emissive: new THREE.Color(0x0a0e08),
780
+ emissiveIntensity: 0.10,
781
+ side: THREE.DoubleSide,
782
+ alphaTest: this.options.leaves.alphaTest,
783
+ dithering: true
784
+ });
785
+
786
+ // Add custom shader code for branch swaying
787
+ mat.onBeforeCompile = (shader) => {
788
+ shader.uniforms.uTime = { value: 0 };
789
+ shader.uniforms.uWindStrength = { value: new THREE.Vector3(0.5, 0, 0.5) };
790
+ shader.uniforms.uWindFrequency = { value: 0.5 };
791
+ shader.uniforms.uWindScale = { value: 70 };
792
+
793
+ // Wrap-shading hemisphere term + subsurface transmission so leaf quads
794
+ // catch light from any angle AND glow softly when sun is behind them
795
+ // (light passing through the leaf, like a real translucent leaf in sun).
796
+ const phongPars = THREE.ShaderChunk.lights_phong_pars_fragment.replace(
797
+ 'float dotNL = saturate( dot( geometryNormal, directLight.direction ) );',
798
+ `float dotNLraw = dot(geometryNormal, directLight.direction);
799
+ float dotNL = pow(saturate(dotNLraw*0.5+0.5), 1.6);
800
+ // Translucent transmission: when sun is BEHIND the leaf (dotNLraw<0),
801
+ // add diffuse-tinted contribution proportional to how much sun is
802
+ // hitting the back side. Falls off with view angle so it reads as
803
+ // soft glow, not flat fill.
804
+ float transmit = max(-dotNLraw, 0.0);
805
+ transmit = pow(transmit, 1.5); // softer ramp`
806
+ );
807
+ // Inject transmission into the irradiance accumulation. The Phong RE_Direct
808
+ // multiplies BRDF_Lambert(diffuseColor) by lightColor*dotNL — we add a
809
+ // separate transmission term that adds tinted light passing through.
810
+ const phongFragChunk = THREE.ShaderChunk.lights_phong_pars_fragment.includes('RE_Direct_BlinnPhong')
811
+ ? phongPars.replace(
812
+ 'reflectedLight.directDiffuse += irradiance * BRDF_Lambert( material.diffuseColor );',
813
+ `reflectedLight.directDiffuse += irradiance * BRDF_Lambert( material.diffuseColor );
814
+ // Transmitted light: leaf-color tinted, attenuated, additive
815
+ reflectedLight.directDiffuse += directLight.color * material.diffuseColor * transmit * 0.42;`
816
+ )
817
+ : phongPars;
818
+ shader.fragmentShader = shader.fragmentShader.replace(
819
+ '#include <lights_phong_pars_fragment>',
820
+ phongFragChunk
821
+ );
822
+
823
+ shader.vertexShader = `
824
+ uniform float uTime;
825
+ uniform vec3 uWindStrength;
826
+ uniform float uWindFrequency;
827
+ uniform float uWindScale;
828
+ ` + shader.vertexShader;
829
+
830
+ // Add code for simplex noise
831
+ shader.vertexShader = shader.vertexShader.replace(
832
+ `void main() {`,
833
+ `
834
+ // GLSL Simplex Noise 3D
835
+ // Source: https://github.com/ashima/webgl-noise
836
+
837
+ vec3 mod289(vec3 x) {
838
+ return x - floor(x * (1.0 / 289.0)) * 289.0;
839
+ }
840
+
841
+ vec4 mod289(vec4 x) {
842
+ return x - floor(x * (1.0 / 289.0)) * 289.0;
843
+ }
844
+
845
+ vec4 permute(vec4 x) {
846
+ return mod289(((x*34.0)+1.0)*x);
847
+ }
848
+
849
+ vec4 taylorInvSqrt(vec4 r) {
850
+ return 1.79284291400159 - 0.85373472095314 * r;
851
+ }
852
+
853
+ vec3 fade(vec3 t) {
854
+ return t*t*t*(t*(t*6.0-15.0)+10.0);
855
+ }
856
+
857
+ // Classic Simplex Noise 3D
858
+ float simplex3(vec3 v) {
859
+ const vec2 C = vec2(1.0/6.0, 1.0/3.0);
860
+ const vec4 D = vec4(0.0, 0.5, 1.0, 2.0);
861
+
862
+ // First corner
863
+ vec3 i = floor(v + dot(v, C.yyy) );
864
+ vec3 x0 = v - i + dot(i, C.xxx);
865
+
866
+ // Other corners
867
+ vec3 g = step(x0.yzx, x0.xyz);
868
+ vec3 l = 1.0 - g;
869
+ vec3 i1 = min( g.xyz, l.zxy );
870
+ vec3 i2 = max( g.xyz, l.zxy );
871
+
872
+ // x0 = x0 - 0. + 0.0 * C
873
+ vec3 x1 = x0 - i1 + C.xxx;
874
+ vec3 x2 = x0 - i2 + C.yyy; // 2.0 * C.x = 1/3 = C.y
875
+ vec3 x3 = x0 - D.yyy; // -1.0 + 3.0 * C.x = -0.5
876
+
877
+ // Permutations
878
+ i = mod289(i);
879
+ vec4 p = permute( permute( permute(
880
+ i.z + vec4(0.0, i1.z, i2.z, 1.0 ))
881
+ + i.y + vec4(0.0, i1.y, i2.y, 1.0 ))
882
+ + i.x + vec4(0.0, i1.x, i2.x, 1.0 ));
883
+
884
+ // Gradients: 7x7 points over a square, mapped onto an octahedron.
885
+ // The ring size 17*17 = 289 is close to the mapping's singularity.
886
+ float n_ = 0.142857142857; // 1.0/7.0
887
+ vec3 ns = n_ * D.wyz - D.xzx;
888
+
889
+ vec4 j = p - 49.0 * floor(p * ns.z * ns.z); // mod(p,7*7)
890
+
891
+ vec4 x_ = floor(j * ns.z);
892
+ vec4 y_ = floor(j - 7.0 * x_ ); // mod(j,N)
893
+
894
+ vec4 x = x_ *ns.x + ns.yyyy;
895
+ vec4 y = y_ *ns.x + ns.yyyy;
896
+ vec4 h = 1.0 - abs(x) - abs(y);
897
+
898
+ vec4 b0 = vec4( x.xy, y.xy );
899
+ vec4 b1 = vec4( x.zw, y.zw );
900
+
901
+ vec4 s0 = floor(b0)*2.0 + 1.0;
902
+ vec4 s1 = floor(b1)*2.0 + 1.0;
903
+ vec4 sh = -step(h, vec4(0.0));
904
+
905
+ vec4 a0 = b0.xzyw + s0.xzyw*sh.xxyy ;
906
+ vec4 a1 = b1.xzyw + s1.xzyw*sh.zzww ;
907
+
908
+ vec3 g0 = vec3(a0.xy,h.x);
909
+ vec3 g1 = vec3(a0.zw,h.y);
910
+ vec3 g2 = vec3(a1.xy,h.z);
911
+ vec3 g3 = vec3(a1.zw,h.w);
912
+
913
+ // Normalise gradients
914
+ vec4 norm = taylorInvSqrt(vec4(dot(g0,g0), dot(g1,g1), dot(g2,g2), dot(g3,g3)));
915
+ g0 *= norm.x;
916
+ g1 *= norm.y;
917
+ g2 *= norm.z;
918
+ g3 *= norm.w;
919
+
920
+ // Mix contributions from the four corners
921
+ vec4 m = max(0.6 - vec4(dot(x0,x0), dot(x1,x1), dot(x2,x2), dot(x3,x3)), 0.0);
922
+ m = m * m;
923
+ return 42.0 * dot( m*m, vec4( dot(g0,x0), dot(g1,x1),
924
+ dot(g2,x2), dot(g3,x3) ) );
925
+ }
926
+
927
+ void main() {`,
928
+ );
929
+
930
+ shader.vertexShader = shader.vertexShader.replace(
931
+ `#include <project_vertex>`,
932
+ `
933
+ vec4 mvPosition = vec4(transformed, 1.0);
934
+ #ifdef USE_INSTANCING
935
+ mvPosition = instanceMatrix * mvPosition;
936
+ #endif
937
+
938
+ // Sample wind noise after instance placement so leaves at different
939
+ // origins get different sway phase — matches the world-space layout.
940
+ float windOffset = 2.0 * 3.14 * simplex3(mvPosition.xyz / uWindScale);
941
+ vec3 windSway = uv.y * uWindStrength * (
942
+ 0.5 * sin(uTime * uWindFrequency + windOffset) +
943
+ 0.3 * sin(2.0 * uTime * uWindFrequency + 1.3 * windOffset) +
944
+ 0.2 * sin(5.0 * uTime * uWindFrequency + 1.5 * windOffset)
945
+ );
946
+ mvPosition.xyz += windSway;
947
+
948
+ mvPosition = modelViewMatrix * mvPosition;
949
+ gl_Position = projectionMatrix * mvPosition;
950
+ `
951
+ );
952
+
953
+ mat.userData.shader = shader;
954
+ _leafShaders.add(shader);
955
+ };
956
+
957
+ return mat;
958
+ }
959
+
960
+ /**
961
+ * Create or update the trellis geometry
962
+ */
963
+ createTrellis() {
964
+ // Remove old trellis if exists
965
+ if (this.trellisMesh) {
966
+ this.remove(this.trellisMesh);
967
+ this.trellisMesh.dispose();
968
+ this.trellisMesh = null;
969
+ }
970
+
971
+ // Create new trellis if enabled and visible
972
+ if (this.options.trellis.enabled && this.options.trellis.visible) {
973
+ this.trellisMesh = new Trellis(this.options.trellis);
974
+ this.trellisMesh.generate();
975
+ this.add(this.trellisMesh);
976
+ }
977
+ }
978
+
979
+ /**
980
+ * Find the nearest point on the trellis grid to a given position
981
+ * @param {THREE.Vector3} position
982
+ * @returns {THREE.Vector3}
983
+ */
984
+ getNearestTrellisPoint(position) {
985
+ const t = this.options.trellis;
986
+ const trellisX = t.position.x;
987
+ const trellisY = t.position.y;
988
+ const trellisZ = t.position.z;
989
+
990
+ // Trellis bounds
991
+ const minX = trellisX - t.width / 2;
992
+ const maxX = trellisX + t.width / 2;
993
+ const minY = trellisY;
994
+ const maxY = trellisY + t.height;
995
+
996
+ // Clamp position to trellis bounds for projection
997
+ const clampedX = Math.max(minX, Math.min(maxX, position.x));
998
+ const clampedY = Math.max(minY, Math.min(maxY, position.y));
999
+
1000
+ // Find nearest horizontal line (Y = constant)
1001
+ const nearestHLineY = Math.round((clampedY - minY) / t.spacing) * t.spacing + minY;
1002
+ const finalHLineY = Math.max(minY, Math.min(maxY, nearestHLineY));
1003
+
1004
+ // Find nearest vertical line (X = constant)
1005
+ const nearestVLineX = Math.round((clampedX - minX) / t.spacing) * t.spacing + minX;
1006
+ const finalVLineX = Math.max(minX, Math.min(maxX, nearestVLineX));
1007
+
1008
+ // Point on nearest horizontal line (X can vary along the line)
1009
+ const pointOnHLine = new THREE.Vector3(clampedX, finalHLineY, trellisZ);
1010
+
1011
+ // Point on nearest vertical line (Y can vary along the line)
1012
+ const pointOnVLine = new THREE.Vector3(finalVLineX, clampedY, trellisZ);
1013
+
1014
+ // Return whichever is closer
1015
+ const distH = position.distanceTo(pointOnHLine);
1016
+ const distV = position.distanceTo(pointOnVLine);
1017
+
1018
+ return distH < distV ? pointOnHLine : pointOnVLine;
1019
+ }
1020
+
1021
+ /**
1022
+ * Calculate the force vector toward the nearest trellis point
1023
+ * @param {THREE.Vector3} position Current section position
1024
+ * @param {number} radius Current section radius
1025
+ * @returns {{ direction: THREE.Vector3, strength: number } | null}
1026
+ */
1027
+ calculateTrellisForce(position, radius) {
1028
+ const trellis = this.options.trellis;
1029
+ const nearestPoint = this.getNearestTrellisPoint(position);
1030
+
1031
+ const distance = position.distanceTo(nearestPoint);
1032
+
1033
+ // Only apply force within max distance
1034
+ if (distance > trellis.force.maxDistance) return null;
1035
+ if (distance < 0.001) return null; // Avoid division by zero
1036
+
1037
+ // Calculate direction toward trellis
1038
+ const direction = new THREE.Vector3()
1039
+ .subVectors(nearestPoint, position)
1040
+ .normalize();
1041
+
1042
+ // Calculate strength with distance falloff
1043
+ // Closer = stronger force, scaled by inverse radius (like existing force)
1044
+ const distanceFactor = 1 - Math.pow(
1045
+ distance / trellis.force.maxDistance,
1046
+ trellis.force.falloff,
1047
+ );
1048
+ const strength = trellis.force.strength * distanceFactor / radius;
1049
+
1050
+ return { direction, strength };
1051
+ }
1052
+
1053
+ get vertexCount() {
1054
+ // Leaves are instanced: 4 verts per instance.
1055
+ return this.branches.verts.length / 3 + this.leaves.instances.length * 4;
1056
+ }
1057
+
1058
+ get triangleCount() {
1059
+ // 2 triangles per leaf instance.
1060
+ return this.branches.indices.length / 3 + this.leaves.instances.length * 2;
1061
+ }
1062
+ }