@call-me-sensei/toonlab 0.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.
- package/ATTRIBUTION.md +40 -0
- package/LICENSE +21 -0
- package/README.md +125 -0
- package/package.json +97 -0
- package/src/character/characterRig.js +353 -0
- package/src/character/freestyleSwimClip.js +541 -0
- package/src/character/index.js +4 -0
- package/src/character/modelLoader.js +433 -0
- package/src/core/materialRoles.js +438 -0
- package/src/core/presetDocuments.js +110 -0
- package/src/core/shaderBackend.js +24 -0
- package/src/debrisgen/debrisFields.js +544 -0
- package/src/debrisgen/debrisGenerator.js +1929 -0
- package/src/debrisgen/debrisPalettes.js +71 -0
- package/src/debrisgen/debrisPhysics.js +198 -0
- package/src/debrisgen/debrisPresets.js +155 -0
- package/src/debrisgen/debrisSettings.js +333 -0
- package/src/debrisgen/debrisTextures.js +380 -0
- package/src/debrisgen/index.js +5 -0
- package/src/debug/fieldValues.js +95 -0
- package/src/debug/index.js +7 -0
- package/src/debug/settingsPanel.js +170 -0
- package/src/environment/environmentAmbientProbe.js +160 -0
- package/src/environment/environmentMaterialAdapter.js +336 -0
- package/src/environment/environmentMaterialClassifier.js +178 -0
- package/src/environment/environmentPlanarReflection.js +201 -0
- package/src/environment/environmentPresets.js +443 -0
- package/src/environment/environmentRigs.js +684 -0
- package/src/environment/environmentSettings.js +504 -0
- package/src/environment/environmentShaderMaterials.js +318 -0
- package/src/environment/environmentSunShadowPass.js +268 -0
- package/src/environment/environmentTextureResolver.js +161 -0
- package/src/environment/environmentTimeOfDay.js +167 -0
- package/src/environment/environmentVertexAo.js +188 -0
- package/src/environment/index.js +8 -0
- package/src/environment/scanAssetStylize.js +103 -0
- package/src/index.js +21 -0
- package/src/loaders/index.js +1 -0
- package/src/post/index.js +2 -0
- package/src/post/postProcessing.js +1087 -0
- package/src/rockgen/export/glbExport.js +177 -0
- package/src/rockgen/heightfield/heightfieldErosion.js +7 -0
- package/src/rockgen/heightfield/heightfieldPatch.js +183 -0
- package/src/rockgen/heightfield/stylizedErosionSim.js +356 -0
- package/src/rockgen/index.js +14 -0
- package/src/rockgen/mesh/meshAttributes.js +374 -0
- package/src/rockgen/mesh/meshDocument.js +158 -0
- package/src/rockgen/mesh/surfaceNets.js +329 -0
- package/src/rockgen/noise/cellularNoise3.js +92 -0
- package/src/rockgen/noise/prng.js +62 -0
- package/src/rockgen/noise/simplexNoise3.js +89 -0
- package/src/rockgen/noise/valueNoise3.js +87 -0
- package/src/rockgen/rockDocument.js +255 -0
- package/src/rockgen/rockHelpers.js +8 -0
- package/src/rockgen/rockgenPresets.js +559 -0
- package/src/rockgen/rockgenSettings.js +756 -0
- package/src/rockgen/sdf/fieldCompiler.js +544 -0
- package/src/rockgen/sdf/sculptEdits.js +66 -0
- package/src/rockgen/sdf/sdfModifiers.js +45 -0
- package/src/rockgen/sdf/sdfOps.js +37 -0
- package/src/rockgen/sdf/sdfPrimitives.js +88 -0
- package/src/shaders-tsl/anime.js +963 -0
- package/src/shaders-tsl/chunks/character-color.js +64 -0
- package/src/shaders-tsl/chunks/character-highlights.js +174 -0
- package/src/shaders-tsl/chunks/character-lighting.js +309 -0
- package/src/shaders-tsl/chunks/character-material-maps.js +146 -0
- package/src/shaders-tsl/chunks/character-roles.js +52 -0
- package/src/shaders-tsl/chunks/character-scene-lights.js +270 -0
- package/src/shaders-tsl/chunks/character-shadow-color.js +61 -0
- package/src/shaders-tsl/chunks/character-skinning.js +140 -0
- package/src/shaders-tsl/chunks/environment-color.js +47 -0
- package/src/shaders-tsl/chunks/environment-debug.js +79 -0
- package/src/shaders-tsl/chunks/environment-lighting.js +260 -0
- package/src/shaders-tsl/chunks/environment-sun-shadow.js +87 -0
- package/src/shaders-tsl/chunks/foliage-fog.js +60 -0
- package/src/shaders-tsl/chunks/pass-depth-color.js +89 -0
- package/src/shaders-tsl/chunks/stylized-cloud-shadow.js +70 -0
- package/src/shaders-tsl/chunks/water-color.js +190 -0
- package/src/shaders-tsl/chunks/water-common.js +124 -0
- package/src/shaders-tsl/chunks/water-foam.js +93 -0
- package/src/shaders-tsl/chunks/water-lighting.js +116 -0
- package/src/shaders-tsl/chunks/water-ripple.js +64 -0
- package/src/shaders-tsl/chunks/water-waves.js +100 -0
- package/src/shaders-tsl/environment-ao-overlay.js +60 -0
- package/src/shaders-tsl/environment.js +680 -0
- package/src/shaders-tsl/flower.js +267 -0
- package/src/shaders-tsl/grass.js +193 -0
- package/src/shaders-tsl/post-composite.js +461 -0
- package/src/shaders-tsl/sky.js +175 -0
- package/src/shaders-tsl/tree-leaf.js +264 -0
- package/src/shaders-tsl/water-breaker.js +423 -0
- package/src/shaders-tsl/water-kelp.js +171 -0
- package/src/shaders-tsl/water-rain.js +107 -0
- package/src/shaders-tsl/water-simulation.js +140 -0
- package/src/shaders-tsl/water-splash.js +298 -0
- package/src/shaders-tsl/water.js +509 -0
- package/src/sky/index.js +2 -0
- package/src/sky/stylizedSky.js +341 -0
- package/src/toon/characterRenderPasses.js +646 -0
- package/src/toon/index.js +4 -0
- package/src/toon/settings/alphaSettings.js +170 -0
- package/src/toon/settings/averageShadowSettings.js +181 -0
- package/src/toon/settings/baseTextureSettings.js +133 -0
- package/src/toon/settings/celShadeSettings.js +96 -0
- package/src/toon/settings/contactShadowSettings.js +114 -0
- package/src/toon/settings/eyeHighlightSettings.js +187 -0
- package/src/toon/settings/faceLightingSettings.js +135 -0
- package/src/toon/settings/furSettings.js +112 -0
- package/src/toon/settings/glitterSettings.js +102 -0
- package/src/toon/settings/hairHighlightSettings.js +325 -0
- package/src/toon/settings/indirectLightSettings.js +244 -0
- package/src/toon/settings/localLightSettings.js +176 -0
- package/src/toon/settings/materialMapSettings.js +299 -0
- package/src/toon/settings/outlineSettings.js +263 -0
- package/src/toon/settings/perspectiveRemovalSettings.js +60 -0
- package/src/toon/settings/rimLightSettings.js +268 -0
- package/src/toon/settings/sceneShadowSettings.js +147 -0
- package/src/toon/settings/selfShadowSettings.js +221 -0
- package/src/toon/settings/shadowColorSettings.js +238 -0
- package/src/toon/settings/skinToneSettings.js +150 -0
- package/src/toon/settings/specularSettings.js +314 -0
- package/src/toon/settings/stickerSettings.js +102 -0
- package/src/toon/toonMaterialAdapter.js +2235 -0
- package/src/toon/toonSettings.js +860 -0
- package/src/vegetation/flowerSpecies.js +260 -0
- package/src/vegetation/index.js +10 -0
- package/src/vegetation/stylizedBush.js +136 -0
- package/src/vegetation/stylizedFlower.js +213 -0
- package/src/vegetation/stylizedFlowers.js +297 -0
- package/src/vegetation/stylizedGrass.js +491 -0
- package/src/vegetation/stylizedTree.js +2449 -0
- package/src/vegetation/stylizedTreeFoliage.js +691 -0
- package/src/vegetation/treeExport.js +300 -0
- package/src/vegetation/treeRecipe.js +647 -0
- package/src/water/index.js +2 -0
- package/src/water/water.js +20 -0
- package/src/water/waterBreakerSystem.js +591 -0
- package/src/water/waterInteraction.js +193 -0
- package/src/water/waterMaterial.js +263 -0
- package/src/water/waterRain.js +136 -0
- package/src/water/waterRippleSimulation.js +227 -0
- package/src/water/waterScenePasses.js +415 -0
- package/src/water/waterSettings.js +1138 -0
- package/src/water/waterSplashSystem.js +251 -0
- package/src/water/waterSurface.js +475 -0
- package/src/water/waterVegetation.js +83 -0
|
@@ -0,0 +1,2449 @@
|
|
|
1
|
+
import * as THREE from 'three';
|
|
2
|
+
import { mergeGeometries } from 'three/examples/jsm/utils/BufferGeometryUtils.js';
|
|
3
|
+
|
|
4
|
+
import {
|
|
5
|
+
TREE_FOLIAGE_BLOBS,
|
|
6
|
+
createCanopyBlobs,
|
|
7
|
+
createLeafSpriteTexture,
|
|
8
|
+
createTreeFoliageGeometry,
|
|
9
|
+
createTreeFoliageMaterials,
|
|
10
|
+
deriveCanopyPalette,
|
|
11
|
+
resolveCanopyColor,
|
|
12
|
+
setCanopyCloudShadow,
|
|
13
|
+
setCanopySceneShadow,
|
|
14
|
+
setCanopySun,
|
|
15
|
+
setCanopyWind,
|
|
16
|
+
tickCanopyTime,
|
|
17
|
+
} from './stylizedTreeFoliage.js';
|
|
18
|
+
|
|
19
|
+
// Modern anime-style stylized trees, fully parameterized for drop-in use:
|
|
20
|
+
//
|
|
21
|
+
// const tree = new StylizedTree({ size: 2, canopyColor: 0x4da258 });
|
|
22
|
+
// scene.add(tree);
|
|
23
|
+
// tree.update(delta); // each frame (wind/flutter)
|
|
24
|
+
// tree.setSun({ direction, color, sky }); // match your lighting
|
|
25
|
+
//
|
|
26
|
+
// Every visual knob is exposed with tuned defaults: trunk shape (bend, lean,
|
|
27
|
+
// twist, gnarl — crank gnarl/twist for bonsai-like trunks), branches, canopy
|
|
28
|
+
// blobs/cards/density, palette, wind. Trunk generation is deterministic per
|
|
29
|
+
// seed, so the same options always produce the same tree.
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Converts a drawn closed outline (normalized -0.5..0.5 points) into a
|
|
33
|
+
* radius-per-angle profile for createBranchTubeGeometry: 24 samples of
|
|
34
|
+
* centroid distance, normalized to mean 1 and softly clamped so drawn
|
|
35
|
+
* trunk cross-sections stay buildable.
|
|
36
|
+
*/
|
|
37
|
+
export function polarProfileFromOutline(outline, samples = 24) {
|
|
38
|
+
if (!Array.isArray(outline) || outline.length < 3) return null;
|
|
39
|
+
let cx = 0;
|
|
40
|
+
let cy = 0;
|
|
41
|
+
for (const [x, y] of outline) {
|
|
42
|
+
cx += x;
|
|
43
|
+
cy += y;
|
|
44
|
+
}
|
|
45
|
+
cx /= outline.length;
|
|
46
|
+
cy /= outline.length;
|
|
47
|
+
// Max centroid distance per angular slot (handles concave outlines by
|
|
48
|
+
// taking the reachable silhouette).
|
|
49
|
+
const slots = new Array(samples).fill(0);
|
|
50
|
+
for (const [x, y] of outline) {
|
|
51
|
+
const angle = Math.atan2(y - cy, x - cx);
|
|
52
|
+
const slot = Math.floor(((angle + Math.PI * 2) % (Math.PI * 2)) / (Math.PI * 2) * samples) % samples;
|
|
53
|
+
slots[slot] = Math.max(slots[slot], Math.hypot(x - cx, y - cy));
|
|
54
|
+
}
|
|
55
|
+
// Fill empty slots from neighbors, then normalize to mean 1.
|
|
56
|
+
for (let i = 0; i < samples; i += 1) {
|
|
57
|
+
if (slots[i] === 0) {
|
|
58
|
+
slots[i] = slots[(i + samples - 1) % samples] || slots[(i + 1) % samples] || 0.3;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
const mean = slots.reduce((sum, value) => sum + value, 0) / samples;
|
|
62
|
+
return slots.map((value) => Math.min(Math.max(value / mean, 0.45), 1.9));
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// Leaf-sprite cache per silhouette: rebuilds are debounced-frequent in the
|
|
66
|
+
// designer and the sprite is deterministic per shape, so one texture per
|
|
67
|
+
// distinct shape/outline is plenty.
|
|
68
|
+
const leafSpriteCache = new Map();
|
|
69
|
+
function leafSpriteForShape(leafShape) {
|
|
70
|
+
const shape = leafShape?.preset ?? 'teardrop';
|
|
71
|
+
const outline = shape === 'custom' ? leafShape?.outline ?? null : null;
|
|
72
|
+
const key = shape + (outline ? JSON.stringify(outline) : '');
|
|
73
|
+
if (!leafSpriteCache.has(key)) {
|
|
74
|
+
leafSpriteCache.set(key, createLeafSpriteTexture({ customOutline: outline, shape }));
|
|
75
|
+
}
|
|
76
|
+
return leafSpriteCache.get(key);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function seededRandom(seed) {
|
|
80
|
+
return (k) => {
|
|
81
|
+
const value = Math.sin(seed * 12.9898 + k * 78.233) * 43758.5453;
|
|
82
|
+
return value - Math.floor(value);
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// Curved trunk along a seeded 3D spine. Returns { geometry, canopyAnchor }:
|
|
87
|
+
// the anchor is the spine's top, so the crown follows the trunk's lean.
|
|
88
|
+
//
|
|
89
|
+
// Shape parameters (all in meters / radians, defaults give a gentle lean):
|
|
90
|
+
// bend — mid-trunk bow that returns toward center (S-curve amplitude)
|
|
91
|
+
// lean — drift that accumulates toward the top (tree grows off vertical)
|
|
92
|
+
// bendDirection — world heading of the bow in radians (default: seeded)
|
|
93
|
+
// leanOffset — lean heading relative to the bow; Math.PI pulls the top
|
|
94
|
+
// exactly opposite the mid-bow, guaranteeing a serpentine S-trunk
|
|
95
|
+
// (the Liyue gingko silhouette) instead of leaving it to seed luck
|
|
96
|
+
// twist — Y-rotation of the cross-section over the full height; spirals
|
|
97
|
+
// the bark texture like wrung wood
|
|
98
|
+
// gnarl — extra high-frequency 3D wiggle + radius bulges; 0 is a clean
|
|
99
|
+
// park tree, 1+ reads like an old bonsai
|
|
100
|
+
// gnarlFrequencyXRange / gnarlFrequencyZRange — seeded min/max wave count
|
|
101
|
+
// of the gnarl wiggle over the trunk height, per horizontal axis
|
|
102
|
+
// gnarlAmplitude — meters of wiggle (and radius bulge fraction) per unit
|
|
103
|
+
// of gnarl
|
|
104
|
+
// radialGnarlFrequency — wave count of the radius bulges (old-wood
|
|
105
|
+
// knuckles) over the trunk height
|
|
106
|
+
export function createTreeTrunkGeometry({
|
|
107
|
+
height = 1.55,
|
|
108
|
+
radiusBottom = 0.19,
|
|
109
|
+
radiusTop = 0.085,
|
|
110
|
+
radialSegments = 10,
|
|
111
|
+
heightSegments = 14,
|
|
112
|
+
bend = 0.12,
|
|
113
|
+
lean = 0.16,
|
|
114
|
+
twist = 0,
|
|
115
|
+
gnarl = 0,
|
|
116
|
+
gnarlFrequencyXRange = [4.2, 7.6],
|
|
117
|
+
gnarlFrequencyZRange = [3.1, 6.7],
|
|
118
|
+
gnarlAmplitude = 0.16,
|
|
119
|
+
radialGnarlFrequency = 9.3,
|
|
120
|
+
bendDirection = null,
|
|
121
|
+
leanOffset = null,
|
|
122
|
+
branchCount = 2,
|
|
123
|
+
branchLength = 0.55,
|
|
124
|
+
branchRadius = 0.055,
|
|
125
|
+
seed = 1,
|
|
126
|
+
} = {}) {
|
|
127
|
+
const rand = seededRandom(seed);
|
|
128
|
+
const bendHeading = bendDirection ?? rand(1) * Math.PI * 2;
|
|
129
|
+
const leanHeading = bendHeading + (leanOffset ?? (rand(3) - 0.5) * 2.6);
|
|
130
|
+
const bendX = Math.cos(bendHeading);
|
|
131
|
+
const bendZ = Math.sin(bendHeading);
|
|
132
|
+
const leanX = Math.cos(leanHeading);
|
|
133
|
+
const leanZ = Math.sin(leanHeading);
|
|
134
|
+
const gnarlPhaseX = rand(7) * Math.PI * 2;
|
|
135
|
+
const gnarlPhaseZ = rand(8) * Math.PI * 2;
|
|
136
|
+
const gnarlFreqX = THREE.MathUtils.lerp(
|
|
137
|
+
gnarlFrequencyXRange[0], gnarlFrequencyXRange[1], rand(9));
|
|
138
|
+
const gnarlFreqZ = THREE.MathUtils.lerp(
|
|
139
|
+
gnarlFrequencyZRange[0], gnarlFrequencyZRange[1], rand(10));
|
|
140
|
+
|
|
141
|
+
// Mid-trunk bow returning toward center + accumulating lean + gnarl
|
|
142
|
+
// wiggle on two independent horizontal axes (a 3D snake, not a 2D arc).
|
|
143
|
+
// sin^2 bow: zero slope at the ground, so the base always leaves the dirt
|
|
144
|
+
// vertical and the curve builds above the root flare.
|
|
145
|
+
const bow = (t) => Math.sin(t * Math.PI) ** 2;
|
|
146
|
+
const spineX = (t) => bendX * bow(t) * bend +
|
|
147
|
+
leanX * t * t * lean +
|
|
148
|
+
Math.sin(t * gnarlFreqX + gnarlPhaseX) * gnarl * gnarlAmplitude * Math.min(1, t * 3);
|
|
149
|
+
const spineZ = (t) => bendZ * bow(t) * bend +
|
|
150
|
+
leanZ * t * t * lean +
|
|
151
|
+
Math.sin(t * gnarlFreqZ + gnarlPhaseZ) * gnarl * gnarlAmplitude * Math.min(1, t * 3);
|
|
152
|
+
// Taper with optional gnarl bulges (old-wood knuckles).
|
|
153
|
+
const radiusAt = (t) => THREE.MathUtils.lerp(radiusBottom, radiusTop, t) *
|
|
154
|
+
(1 + Math.sin(t * radialGnarlFrequency + gnarlPhaseX) * gnarl * gnarlAmplitude);
|
|
155
|
+
|
|
156
|
+
const trunk = new THREE.CylinderGeometry(1, 1, height, radialSegments, heightSegments);
|
|
157
|
+
trunk.translate(0, height / 2, 0);
|
|
158
|
+
const positions = trunk.attributes.position;
|
|
159
|
+
const vertex = new THREE.Vector3();
|
|
160
|
+
for (let i = 0; i < positions.count; i += 1) {
|
|
161
|
+
vertex.fromBufferAttribute(positions, i);
|
|
162
|
+
const t = THREE.MathUtils.clamp(vertex.y / height, 0, 1);
|
|
163
|
+
const radius = radiusAt(t);
|
|
164
|
+
const spin = twist * t;
|
|
165
|
+
const x = vertex.x * radius;
|
|
166
|
+
const z = vertex.z * radius;
|
|
167
|
+
vertex.x = x * Math.cos(spin) - z * Math.sin(spin) + spineX(t);
|
|
168
|
+
vertex.z = x * Math.sin(spin) + z * Math.cos(spin) + spineZ(t);
|
|
169
|
+
positions.setXYZ(i, vertex.x, vertex.y, vertex.z);
|
|
170
|
+
}
|
|
171
|
+
trunk.computeVertexNormals();
|
|
172
|
+
|
|
173
|
+
const pieces = [trunk];
|
|
174
|
+
for (let i = 0; i < branchCount; i += 1) {
|
|
175
|
+
const t = 0.6 + (i / Math.max(branchCount - 1, 1)) * 0.24;
|
|
176
|
+
const length = branchLength * (0.8 + rand(20 + i) * 0.4);
|
|
177
|
+
const radius = branchRadius * (0.85 + rand(30 + i) * 0.3);
|
|
178
|
+
const tilt = (i % 2 === 0 ? 1 : -1) * (0.7 + (rand(40 + i) - 0.5) * 0.3);
|
|
179
|
+
const branch = new THREE.CylinderGeometry(radius * 0.55, radius, length, 7);
|
|
180
|
+
branch.translate(0, length / 2, 0);
|
|
181
|
+
branch.rotateZ(tilt);
|
|
182
|
+
branch.rotateY(seed * 2.1 + i * 2.4);
|
|
183
|
+
branch.translate(spineX(t), t * height, spineZ(t));
|
|
184
|
+
pieces.push(branch);
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
const merged = mergeGeometries(pieces);
|
|
188
|
+
pieces.forEach((piece) => piece.dispose());
|
|
189
|
+
return {
|
|
190
|
+
geometry: merged,
|
|
191
|
+
canopyAnchor: new THREE.Vector3(spineX(1), height + 0.42, spineZ(1)),
|
|
192
|
+
};
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
// Tree skeleton grown by SPACE COLONIZATION (Runions et al. 2007) — the same
|
|
196
|
+
// family of growth algorithms behind SpeedTree/UE5-style foliage. Attraction
|
|
197
|
+
// points fill the crown volume (the blob layout); the trunk grows toward
|
|
198
|
+
// them, forking and curving organically wherever points pull in different
|
|
199
|
+
// directions; branch radii follow the pipe model (a parent's cross-section
|
|
200
|
+
// carries its children's), so the trunk tapers into limbs into twigs instead
|
|
201
|
+
// of a straight pole with stubs. Returns:
|
|
202
|
+
// geometry — merged bark mesh (trunk + every limb and twig)
|
|
203
|
+
// canopyAnchor — crown center in trunk space (where the canopy mesh goes)
|
|
204
|
+
// attachments — twig tips in CANOPY-LOCAL space; feed to
|
|
205
|
+
// createTreeFoliageGeometry so each leaf tuft grows off wood
|
|
206
|
+
// Trunk style (bend/lean/twist/gnarl from TREE_TRUNK_STYLES) is applied as a
|
|
207
|
+
// post-growth deform of the whole skeleton, so bonsai twists carry the crown
|
|
208
|
+
// with them.
|
|
209
|
+
export function createTreeSkeleton({
|
|
210
|
+
trunk = {},
|
|
211
|
+
blobs = TREE_FOLIAGE_BLOBS,
|
|
212
|
+
canopyScale = 0.85,
|
|
213
|
+
// Growth controls (trunk-space meters). Tuned for MAJOR LIMBS ONLY:
|
|
214
|
+
// Modern anime-style trees show a trunk forking into a few clean limbs that
|
|
215
|
+
// vanish into a solid leaf mass — never an interior twig lattice (interior
|
|
216
|
+
// twigs only poke through the foliage as dark clutter).
|
|
217
|
+
attractionCount = 90,
|
|
218
|
+
segmentLength = 0.3,
|
|
219
|
+
influenceRadius = 1.2,
|
|
220
|
+
killRadius = 0.42,
|
|
221
|
+
maxSteps = 48,
|
|
222
|
+
maxNodes = 140,
|
|
223
|
+
// Bark mesh controls.
|
|
224
|
+
radialSegments = 8,
|
|
225
|
+
tipRadius = 0.03,
|
|
226
|
+
minLimbRadius = 0.028, // twigs thinner than this are left to the leaves
|
|
227
|
+
attachmentTwigRadius = 0.09, // nodes thinner than this sprout leaf tufts
|
|
228
|
+
// 'canopy' (default) dresses every crown-interior limb so the crown reads
|
|
229
|
+
// as one solid leaf mass. 'tips' puts leaves ONLY at the branch ends —
|
|
230
|
+
// limbs grow farther out and stay bare, ending in bushes (the Sumeru
|
|
231
|
+
// bare-branch silhouette). Pair with the canopy's shellFill: false.
|
|
232
|
+
leafPlacement = 'canopy',
|
|
233
|
+
// How deep into each blob attraction points sample (fraction of blob
|
|
234
|
+
// radius). Default keeps limbs buried in the leaf mass; 'tips' reaches
|
|
235
|
+
// near the shell so bare limbs stretch visibly before their end bush.
|
|
236
|
+
attractionReach = null,
|
|
237
|
+
seed = 1,
|
|
238
|
+
} = {}) {
|
|
239
|
+
const {
|
|
240
|
+
height = 1.55,
|
|
241
|
+
radiusBottom = 0.19,
|
|
242
|
+
bend = 0.12,
|
|
243
|
+
lean = 0.16,
|
|
244
|
+
twist = 0,
|
|
245
|
+
gnarl = 0,
|
|
246
|
+
bendDirection: bendHeadingOption = null,
|
|
247
|
+
leanOffset = null,
|
|
248
|
+
} = trunk;
|
|
249
|
+
const rand = seededRandom(seed * 1.93 + 4.7);
|
|
250
|
+
const anchorY = height + 0.42;
|
|
251
|
+
|
|
252
|
+
// Attraction points: uniform inside the blob volumes, in trunk space.
|
|
253
|
+
const blobWeights = blobs.map((blob) => blob.radius ** 3);
|
|
254
|
+
const totalWeight = blobWeights.reduce((sum, w) => sum + w, 0);
|
|
255
|
+
const points = [];
|
|
256
|
+
for (let i = 0; i < attractionCount; i += 1) {
|
|
257
|
+
let pick = rand(i * 3.1) * totalWeight;
|
|
258
|
+
let blobIndex = 0;
|
|
259
|
+
while (pick > blobWeights[blobIndex] && blobIndex < blobs.length - 1) {
|
|
260
|
+
pick -= blobWeights[blobIndex];
|
|
261
|
+
blobIndex += 1;
|
|
262
|
+
}
|
|
263
|
+
const blob = blobs[blobIndex];
|
|
264
|
+
const theta = rand(i * 3.1 + 1) * Math.PI * 2;
|
|
265
|
+
const cosPhi = rand(i * 3.1 + 2) * 2 - 1;
|
|
266
|
+
const sinPhi = Math.sqrt(Math.max(0, 1 - cosPhi * cosPhi));
|
|
267
|
+
// Sample well inside the blob so limbs never poke out of the leaf mass
|
|
268
|
+
// (near the shell in tips mode, so bare limbs reach out to their bush).
|
|
269
|
+
const reach = attractionReach ?? (leafPlacement === 'tips' ? 0.92 : 0.65);
|
|
270
|
+
const radius = blob.radius * reach * Math.cbrt(rand(i * 7.7));
|
|
271
|
+
points.push(new THREE.Vector3(
|
|
272
|
+
(Math.cos(theta) * sinPhi * radius + blob.offset[0]) * canopyScale,
|
|
273
|
+
(cosPhi * radius + blob.offset[1]) * canopyScale + anchorY,
|
|
274
|
+
(Math.sin(theta) * sinPhi * radius + blob.offset[2]) * canopyScale,
|
|
275
|
+
));
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
// Grow. Each node: { position, direction, parent, childCount }.
|
|
279
|
+
const nodes = [{
|
|
280
|
+
position: new THREE.Vector3(0, 0, 0),
|
|
281
|
+
direction: new THREE.Vector3(0, 1, 0),
|
|
282
|
+
parent: -1,
|
|
283
|
+
childCount: 0,
|
|
284
|
+
}];
|
|
285
|
+
const centroid = new THREE.Vector3();
|
|
286
|
+
const accumulator = nodes.map(() => new THREE.Vector3());
|
|
287
|
+
const spawn = (parentIndex, direction, stepKey) => {
|
|
288
|
+
const parent = nodes[parentIndex];
|
|
289
|
+
// Smoothness is a style choice: gnarl 0 grows clean elegant curves
|
|
290
|
+
// (swooping Liyue-style trunks), higher gnarl grows knotted wood.
|
|
291
|
+
const jitter = new THREE.Vector3(
|
|
292
|
+
rand(stepKey) - 0.5, (rand(stepKey + 1) - 0.5) * 0.5, rand(stepKey + 2) - 0.5,
|
|
293
|
+
).multiplyScalar(0.08 + gnarl * 0.55);
|
|
294
|
+
const grown = direction.clone().add(jitter).normalize();
|
|
295
|
+
nodes.push({
|
|
296
|
+
position: parent.position.clone().addScaledVector(grown, segmentLength),
|
|
297
|
+
direction: grown,
|
|
298
|
+
parent: parentIndex,
|
|
299
|
+
childCount: 0,
|
|
300
|
+
});
|
|
301
|
+
parent.childCount += 1;
|
|
302
|
+
accumulator.push(new THREE.Vector3());
|
|
303
|
+
};
|
|
304
|
+
|
|
305
|
+
for (let step = 0; step < maxSteps && points.length > 6 && nodes.length < maxNodes; step += 1) {
|
|
306
|
+
accumulator.forEach((a) => a.set(0, 0, 0));
|
|
307
|
+
const influenced = new Array(nodes.length).fill(0);
|
|
308
|
+
let anyInfluence = false;
|
|
309
|
+
for (const point of points) {
|
|
310
|
+
let nearest = -1;
|
|
311
|
+
let nearestDistance = influenceRadius;
|
|
312
|
+
for (let n = 0; n < nodes.length; n += 1) {
|
|
313
|
+
const distance = point.distanceTo(nodes[n].position);
|
|
314
|
+
if (distance < nearestDistance) {
|
|
315
|
+
nearestDistance = distance;
|
|
316
|
+
nearest = n;
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
if (nearest >= 0) {
|
|
320
|
+
accumulator[nearest].add(
|
|
321
|
+
point.clone().sub(nodes[nearest].position).normalize());
|
|
322
|
+
influenced[nearest] += 1;
|
|
323
|
+
anyInfluence = true;
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
if (!anyInfluence) {
|
|
328
|
+
// Bootstrap: no point in reach yet — extend the highest tip toward the
|
|
329
|
+
// remaining crown mass (this is what forms the lower bole).
|
|
330
|
+
centroid.set(0, 0, 0);
|
|
331
|
+
points.forEach((p) => centroid.add(p));
|
|
332
|
+
centroid.divideScalar(points.length);
|
|
333
|
+
let top = 0;
|
|
334
|
+
nodes.forEach((node, index) => {
|
|
335
|
+
if (node.position.y > nodes[top].position.y) top = index;
|
|
336
|
+
});
|
|
337
|
+
spawn(top, centroid.clone().sub(nodes[top].position).normalize(), step * 13.7);
|
|
338
|
+
continue;
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
const nodeCount = nodes.length;
|
|
342
|
+
for (let n = 0; n < nodeCount && nodes.length < maxNodes; n += 1) {
|
|
343
|
+
if (!influenced[n]) continue;
|
|
344
|
+
// Slight upward bias keeps growth arching instead of drooping.
|
|
345
|
+
const direction = accumulator[n].divideScalar(influenced[n])
|
|
346
|
+
.addScaledVector(nodes[n].direction, 0.35)
|
|
347
|
+
.add(new THREE.Vector3(0, 0.08, 0));
|
|
348
|
+
spawn(n, direction.normalize(), step * 13.7 + n * 3.3);
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
for (let p = points.length - 1; p >= 0; p -= 1) {
|
|
352
|
+
for (let n = nodeCount; n < nodes.length; n += 1) {
|
|
353
|
+
if (points[p].distanceTo(nodes[n].position) < killRadius) {
|
|
354
|
+
points.splice(p, 1);
|
|
355
|
+
break;
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
// Pipe-model radii: tips are thin; a parent's cross-section carries the
|
|
362
|
+
// sum of its children's. Normalized so the root hits radiusBottom.
|
|
363
|
+
const radii = new Float32Array(nodes.length).fill(0);
|
|
364
|
+
for (let n = nodes.length - 1; n >= 0; n -= 1) {
|
|
365
|
+
if (radii[n] === 0) radii[n] = tipRadius;
|
|
366
|
+
const parent = nodes[n].parent;
|
|
367
|
+
if (parent >= 0) {
|
|
368
|
+
radii[parent] = (radii[parent] ** 2.4 + radii[n] ** 2.4) ** (1 / 2.4);
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
const rootScale = radiusBottom / Math.max(radii[0], 1e-4);
|
|
372
|
+
radii[0] = radiusBottom;
|
|
373
|
+
// Strict taper: every child is thinner than its parent, so the trunk
|
|
374
|
+
// always narrows toward the top and no branch ends in a thick stump.
|
|
375
|
+
// (nodes are topologically ordered — a parent always precedes its children)
|
|
376
|
+
for (let n = 1; n < nodes.length; n += 1) {
|
|
377
|
+
radii[n] = THREE.MathUtils.clamp(
|
|
378
|
+
radii[n] * rootScale, tipRadius * 0.8, radii[nodes[n].parent] * 0.9);
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
// Post-growth style deform: bow + lean + twist over height, so bonsai
|
|
382
|
+
// trunks corkscrew and the crown rides along.
|
|
383
|
+
const bendDirection = bendHeadingOption ?? rand(1) * Math.PI * 2;
|
|
384
|
+
const leanDirection = bendDirection + (leanOffset ?? (rand(3) - 0.5) * 2.6);
|
|
385
|
+
const deform = (position) => {
|
|
386
|
+
// Clamped at the anchor height: nodes inside the crown displace exactly
|
|
387
|
+
// like the crown itself, so limbs can never shear out of the leaf mass.
|
|
388
|
+
const t = THREE.MathUtils.clamp(position.y / anchorY, 0, 1);
|
|
389
|
+
const spin = twist * t;
|
|
390
|
+
const x = position.x * Math.cos(spin) - position.z * Math.sin(spin);
|
|
391
|
+
const z = position.x * Math.sin(spin) + position.z * Math.cos(spin);
|
|
392
|
+
// sin^2 bow (vertical at the ground) + accumulating lean.
|
|
393
|
+
const bowAmount = Math.sin(t * Math.PI) ** 2 * bend;
|
|
394
|
+
position.x = x + Math.cos(bendDirection) * bowAmount +
|
|
395
|
+
Math.cos(leanDirection) * t * t * lean;
|
|
396
|
+
position.z = z + Math.sin(bendDirection) * bowAmount +
|
|
397
|
+
Math.sin(leanDirection) * t * t * lean;
|
|
398
|
+
return position;
|
|
399
|
+
};
|
|
400
|
+
const anchor = deform(new THREE.Vector3(0, anchorY, 0));
|
|
401
|
+
|
|
402
|
+
// Bark mesh: one tapered tube per edge, ends overlapped to hide joints.
|
|
403
|
+
// Built in growth space, then every VERTEX runs through the style deform —
|
|
404
|
+
// with several rings per tube a strong swooping S bends the wood smoothly
|
|
405
|
+
// instead of kinking a polyline at the node joints.
|
|
406
|
+
const up = new THREE.Vector3(0, 1, 0);
|
|
407
|
+
// Where does VISIBLE wood end? A node whose children are all below
|
|
408
|
+
// minLimbRadius is a wood tip even though the skeleton continues — its
|
|
409
|
+
// subtree gets no tubes. Those ends must taper (not stop at a sawn-off
|
|
410
|
+
// cap) and, in tips mode, they are where the bushes belong.
|
|
411
|
+
const hasWood = nodes.map((_, n) => n > 0 && radii[n] >= minLimbRadius);
|
|
412
|
+
const hasWoodenChild = new Array(nodes.length).fill(false);
|
|
413
|
+
for (let n = 1; n < nodes.length; n += 1) {
|
|
414
|
+
if (hasWood[n]) hasWoodenChild[nodes[n].parent] = true;
|
|
415
|
+
}
|
|
416
|
+
const pieces = [];
|
|
417
|
+
for (let n = 1; n < nodes.length; n += 1) {
|
|
418
|
+
const node = nodes[n];
|
|
419
|
+
const parent = nodes[node.parent];
|
|
420
|
+
const direction = node.position.clone().sub(parent.position);
|
|
421
|
+
const length = direction.length();
|
|
422
|
+
if (length < 1e-4) continue;
|
|
423
|
+
direction.divideScalar(length);
|
|
424
|
+
|
|
425
|
+
if (hasWood[n]) {
|
|
426
|
+
const overlap = radii[n] * 0.8;
|
|
427
|
+
// Wood ends (no tube continues past this node) taper to a point
|
|
428
|
+
// instead of a sawn-off cap.
|
|
429
|
+
const topRadius = hasWoodenChild[n] ? radii[n] : tipRadius * 0.4;
|
|
430
|
+
const heightSegments = THREE.MathUtils.clamp(
|
|
431
|
+
Math.round((length + overlap) / 0.1), 2, 6);
|
|
432
|
+
const segment = new THREE.CylinderGeometry(
|
|
433
|
+
topRadius, radii[node.parent], length + overlap, radialSegments, heightSegments);
|
|
434
|
+
segment.translate(0, (length + overlap) / 2, 0);
|
|
435
|
+
// Tile bark along the branch instead of stretching one texture per
|
|
436
|
+
// segment (keeps bark grain consistent from bole to twig).
|
|
437
|
+
const uv = segment.attributes.uv;
|
|
438
|
+
for (let i = 0; i < uv.count; i += 1) {
|
|
439
|
+
uv.setY(i, uv.getY(i) * (length + overlap) * 2.2);
|
|
440
|
+
}
|
|
441
|
+
segment.applyQuaternion(new THREE.Quaternion().setFromUnitVectors(up, direction));
|
|
442
|
+
segment.translate(parent.position.x, parent.position.y, parent.position.z);
|
|
443
|
+
pieces.push(segment);
|
|
444
|
+
}
|
|
445
|
+
}
|
|
446
|
+
const geometry = mergeGeometries(pieces);
|
|
447
|
+
pieces.forEach((piece) => piece.dispose());
|
|
448
|
+
const barkPositions = geometry.attributes.position;
|
|
449
|
+
const barkVertex = new THREE.Vector3();
|
|
450
|
+
for (let i = 0; i < barkPositions.count; i += 1) {
|
|
451
|
+
barkVertex.fromBufferAttribute(barkPositions, i);
|
|
452
|
+
deform(barkVertex);
|
|
453
|
+
barkPositions.setXYZ(i, barkVertex.x, barkVertex.y, barkVertex.z);
|
|
454
|
+
}
|
|
455
|
+
geometry.computeVertexNormals();
|
|
456
|
+
|
|
457
|
+
// Leaves dress (in deformed space): thin limb sections, every terminal
|
|
458
|
+
// node (a thick limb that stops growing must still end in a puff, never a
|
|
459
|
+
// bare stub), and ANY wood inside the crown volume — a thick limb arcing
|
|
460
|
+
// through the canopy gets covered like everything else. Deduped so puffs
|
|
461
|
+
// stay chunky rather than smeared.
|
|
462
|
+
nodes.forEach((node) => deform(node.position));
|
|
463
|
+
const attachments = [];
|
|
464
|
+
const blobLocals = blobs.map((blob) => ({
|
|
465
|
+
center: new THREE.Vector3(blob.offset[0], blob.offset[1], blob.offset[2]),
|
|
466
|
+
radius: blob.radius,
|
|
467
|
+
}));
|
|
468
|
+
for (let n = 1; n < nodes.length; n += 1) {
|
|
469
|
+
const node = nodes[n];
|
|
470
|
+
const parent = nodes[node.parent];
|
|
471
|
+
const direction = node.position.clone().sub(parent.position);
|
|
472
|
+
if (direction.lengthSq() < 1e-8) continue;
|
|
473
|
+
direction.normalize();
|
|
474
|
+
const local = node.position.clone().sub(anchor).divideScalar(canopyScale);
|
|
475
|
+
const insideCrown = blobLocals.some(
|
|
476
|
+
(blob) => local.distanceTo(blob.center) < blob.radius * 0.85);
|
|
477
|
+
// 'tips': branch ends carry leaves and mid-limb wood stays bare — but
|
|
478
|
+
// only OUTSIDE the crown volume. Interior limb runs cresting through the
|
|
479
|
+
// upper canopy read as floating debris between the clouds, so they get
|
|
480
|
+
// dressed like canopy mode; the low sitting limbs stay naked. The end
|
|
481
|
+
// that matters is the end of VISIBLE wood: a bush on a culled
|
|
482
|
+
// (tube-less) twig node would float a full segment past the bark.
|
|
483
|
+
const wantsLeaves = leafPlacement === 'tips'
|
|
484
|
+
? hasWood[n] && (!hasWoodenChild[n] || insideCrown)
|
|
485
|
+
: (radii[n] <= attachmentTwigRadius || node.childCount === 0 || insideCrown);
|
|
486
|
+
if (wantsLeaves) {
|
|
487
|
+
// Tips mode spaces bushes far apart so bare limb runs stay visible
|
|
488
|
+
// between them instead of the clouds merging into one solid crown.
|
|
489
|
+
const spacing = leafPlacement === 'tips' ? 0.85 : 0.4;
|
|
490
|
+
const near = attachments.find((a) => a.position.distanceTo(local) < spacing);
|
|
491
|
+
if (!near) {
|
|
492
|
+
attachments.push({ position: local, direction, merged: 1 });
|
|
493
|
+
} else if (leafPlacement === 'tips' && !hasWoodenChild[n]) {
|
|
494
|
+
// A crowded tip must still end inside foliage — dropping it leaves
|
|
495
|
+
// its bark stub floating bare past the neighbor's cloud. Merge it
|
|
496
|
+
// into that cloud by re-centering on the running average of tips.
|
|
497
|
+
// (Crowded INTERIOR nodes are simply dropped — pulling a shared
|
|
498
|
+
// bush off the tips it guards would expose them instead.)
|
|
499
|
+
near.merged += 1;
|
|
500
|
+
near.position.lerp(local, 1 / near.merged);
|
|
501
|
+
near.direction.lerp(direction, 1 / near.merged).normalize();
|
|
502
|
+
}
|
|
503
|
+
}
|
|
504
|
+
}
|
|
505
|
+
// Degenerate seeds can theoretically end twigless; keep the contract.
|
|
506
|
+
if (!attachments.length) {
|
|
507
|
+
attachments.push({ position: new THREE.Vector3(0, 0, 0), direction: up.clone() });
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
return { geometry, canopyAnchor: anchor, attachments };
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
// Tapered bark tube swept along an arbitrary 3D polyline (hand-drawn branch
|
|
514
|
+
// strokes from Tree Lab, scripted limbs, roots). Returns
|
|
515
|
+
// { geometry, tip, tipTangent } — tip/tipTangent are where a leaf tuft
|
|
516
|
+
// belongs — or null for degenerate input.
|
|
517
|
+
// points — [[x, y, z], ...] in tree-local space (pre-`size` scale)
|
|
518
|
+
export function createBranchTubeGeometry({
|
|
519
|
+
points = [],
|
|
520
|
+
radiusStart = 0.07,
|
|
521
|
+
radiusEnd = 0.02,
|
|
522
|
+
radialSegments = 7,
|
|
523
|
+
ringSpacing = 0.09,
|
|
524
|
+
// Organic cross-section: two low harmonics warp each ring away from a
|
|
525
|
+
// perfect circle (real trunks never are), drifting in phase along the
|
|
526
|
+
// run for a gentle twist. 0 restores exact circles.
|
|
527
|
+
irregularity = 0.14,
|
|
528
|
+
// Grounded stems: widen the first rings into a root flare so the base
|
|
529
|
+
// reads as growing FROM the ground instead of resting on it.
|
|
530
|
+
flareBase = false,
|
|
531
|
+
// Custom cross-section: radius multiplier per ring angle (from a drawn
|
|
532
|
+
// outline via polarProfileFromOutline). Overrides the circular profile;
|
|
533
|
+
// the organic warp still applies on top.
|
|
534
|
+
profile = null,
|
|
535
|
+
seed = 5,
|
|
536
|
+
} = {}) {
|
|
537
|
+
// Near-duplicate consecutive points produce degenerate tangents (the real
|
|
538
|
+
// sweep failure mode) — drop them before curve construction.
|
|
539
|
+
const filtered = [];
|
|
540
|
+
for (const point of points) {
|
|
541
|
+
const vector = Array.isArray(point)
|
|
542
|
+
? new THREE.Vector3(point[0], point[1], point[2])
|
|
543
|
+
: new THREE.Vector3(point.x, point.y, point.z);
|
|
544
|
+
if (!filtered.length || filtered[filtered.length - 1].distanceToSquared(vector) > 1e-6) {
|
|
545
|
+
filtered.push(vector);
|
|
546
|
+
}
|
|
547
|
+
}
|
|
548
|
+
if (filtered.length < 2) return null;
|
|
549
|
+
|
|
550
|
+
// Centripetal parameterization avoids the cusps/loops uniform Catmull-Rom
|
|
551
|
+
// produces on unevenly spaced sketch points; three's computeFrenetFrames
|
|
552
|
+
// already minimizes rotation between rings, so no hand-rolled RMF.
|
|
553
|
+
const curve = new THREE.CatmullRomCurve3(filtered, false, 'centripetal');
|
|
554
|
+
const length = curve.getLength();
|
|
555
|
+
if (length < 1e-3) return null;
|
|
556
|
+
const segments = Math.max(3, Math.ceil(length / ringSpacing));
|
|
557
|
+
const frames = curve.computeFrenetFrames(segments, false);
|
|
558
|
+
|
|
559
|
+
const ringVertices = radialSegments + 1; // duplicated seam column for UVs
|
|
560
|
+
// Grid vertices + 2 cap centers (base + tip): tubes must read as SOLID
|
|
561
|
+
// wood — an open base ring shows the hollow interior the moment the
|
|
562
|
+
// camera looks up the trunk.
|
|
563
|
+
const gridCount = (segments + 1) * ringVertices;
|
|
564
|
+
const positions = new Float32Array((gridCount + 2) * 3);
|
|
565
|
+
const normals = new Float32Array((gridCount + 2) * 3);
|
|
566
|
+
const uvs = new Float32Array((gridCount + 2) * 2);
|
|
567
|
+
|
|
568
|
+
const phase1 = seed * 1.7;
|
|
569
|
+
const phase2 = seed * 2.9 + 1.1;
|
|
570
|
+
const center = new THREE.Vector3();
|
|
571
|
+
const radial = new THREE.Vector3();
|
|
572
|
+
for (let i = 0; i <= segments; i += 1) {
|
|
573
|
+
const t = i / segments;
|
|
574
|
+
curve.getPointAt(t, center);
|
|
575
|
+
// Taper along the run, closing to a near-point tip (the trunk-tip
|
|
576
|
+
// convention) instead of a sawn-off cap.
|
|
577
|
+
let radius = i === segments
|
|
578
|
+
? radiusEnd * 0.3
|
|
579
|
+
: THREE.MathUtils.lerp(radiusStart, radiusEnd, t);
|
|
580
|
+
if (flareBase) {
|
|
581
|
+
const flareT = Math.min(t / 0.16, 1);
|
|
582
|
+
radius *= 1 + 0.45 * (1 - flareT * flareT * (3 - 2 * flareT));
|
|
583
|
+
}
|
|
584
|
+
for (let j = 0; j <= radialSegments; j += 1) {
|
|
585
|
+
const theta = (j / radialSegments) * Math.PI * 2;
|
|
586
|
+
// Seam column (j === radialSegments) must warp exactly like j === 0.
|
|
587
|
+
const warpTheta = (j % radialSegments) / radialSegments * Math.PI * 2;
|
|
588
|
+
let warp = 1 + irregularity * (
|
|
589
|
+
0.6 * Math.sin(3 * warpTheta + phase1 + t * 2.1)
|
|
590
|
+
+ 0.4 * Math.sin(5 * warpTheta + phase2 - t * 1.4));
|
|
591
|
+
if (profile) {
|
|
592
|
+
const slot = (warpTheta / (Math.PI * 2)) * profile.length;
|
|
593
|
+
const i0 = Math.floor(slot) % profile.length;
|
|
594
|
+
const i1 = (i0 + 1) % profile.length;
|
|
595
|
+
warp *= THREE.MathUtils.lerp(profile[i0], profile[i1], slot - Math.floor(slot));
|
|
596
|
+
}
|
|
597
|
+
radial.copy(frames.normals[i]).multiplyScalar(Math.cos(theta))
|
|
598
|
+
.addScaledVector(frames.binormals[i], Math.sin(theta));
|
|
599
|
+
const out = (i * ringVertices + j) * 3;
|
|
600
|
+
const r = radius * warp;
|
|
601
|
+
positions[out] = center.x + radial.x * r;
|
|
602
|
+
positions[out + 1] = center.y + radial.y * r;
|
|
603
|
+
positions[out + 2] = center.z + radial.z * r;
|
|
604
|
+
normals[out] = radial.x;
|
|
605
|
+
normals[out + 1] = radial.y;
|
|
606
|
+
normals[out + 2] = radial.z;
|
|
607
|
+
const uvOut = (i * ringVertices + j) * 2;
|
|
608
|
+
uvs[uvOut] = j / radialSegments;
|
|
609
|
+
// Tile bark along the branch (same 2.2/meter convention as the
|
|
610
|
+
// skeleton's limb tubes) instead of stretching one texture per branch.
|
|
611
|
+
uvs[uvOut + 1] = t * length * 2.2;
|
|
612
|
+
}
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
// Cap centers: base (t=0) and tip (t=1), normals along ∓tangent.
|
|
616
|
+
const baseCenterIndex = gridCount;
|
|
617
|
+
const tipCenterIndex = gridCount + 1;
|
|
618
|
+
const baseCenter = curve.getPointAt(0);
|
|
619
|
+
const tipCenter = curve.getPointAt(1);
|
|
620
|
+
const baseTangent = curve.getTangentAt(0);
|
|
621
|
+
const tipTangentVector = curve.getTangentAt(1);
|
|
622
|
+
positions.set([baseCenter.x, baseCenter.y, baseCenter.z], baseCenterIndex * 3);
|
|
623
|
+
positions.set([tipCenter.x, tipCenter.y, tipCenter.z], tipCenterIndex * 3);
|
|
624
|
+
normals.set([-baseTangent.x, -baseTangent.y, -baseTangent.z], baseCenterIndex * 3);
|
|
625
|
+
normals.set([tipTangentVector.x, tipTangentVector.y, tipTangentVector.z], tipCenterIndex * 3);
|
|
626
|
+
uvs.set([0.5, 0], baseCenterIndex * 2);
|
|
627
|
+
uvs.set([0.5, 1], tipCenterIndex * 2);
|
|
628
|
+
|
|
629
|
+
const indexList = [];
|
|
630
|
+
for (let i = 0; i < segments; i += 1) {
|
|
631
|
+
for (let j = 0; j < radialSegments; j += 1) {
|
|
632
|
+
const a = i * ringVertices + j;
|
|
633
|
+
const b = a + ringVertices;
|
|
634
|
+
indexList.push(a, b, a + 1, b, b + 1, a + 1);
|
|
635
|
+
}
|
|
636
|
+
}
|
|
637
|
+
// Cap fans (base winds toward -tangent, tip toward +tangent).
|
|
638
|
+
for (let j = 0; j < radialSegments; j += 1) {
|
|
639
|
+
indexList.push(baseCenterIndex, j + 1, j);
|
|
640
|
+
const tipRing = segments * ringVertices;
|
|
641
|
+
indexList.push(tipCenterIndex, tipRing + j, tipRing + j + 1);
|
|
642
|
+
}
|
|
643
|
+
|
|
644
|
+
const geometry = new THREE.BufferGeometry();
|
|
645
|
+
geometry.setIndex(new THREE.BufferAttribute(new Uint32Array(indexList), 1));
|
|
646
|
+
geometry.setAttribute('position', new THREE.BufferAttribute(positions, 3));
|
|
647
|
+
geometry.setAttribute('normal', new THREE.BufferAttribute(normals, 3));
|
|
648
|
+
geometry.setAttribute('uv', new THREE.BufferAttribute(uvs, 2));
|
|
649
|
+
return {
|
|
650
|
+
geometry,
|
|
651
|
+
tip: curve.getPointAt(1),
|
|
652
|
+
tipTangent: curve.getTangentAt(1),
|
|
653
|
+
};
|
|
654
|
+
}
|
|
655
|
+
|
|
656
|
+
// Recursive central-leader tree skeleton (open broadleaf silhouettes):
|
|
657
|
+
// the trunk runs the full height as a leader, child branches sprout along
|
|
658
|
+
// it at golden-angle azimuths, and each level subdivides into shorter,
|
|
659
|
+
// thinner children. Foliage attachments are distributed along the OUTER
|
|
660
|
+
// branches (not one puff per crown), giving the open, airy broadleaf
|
|
661
|
+
// silhouette that blob crowns can't make. Same return contract as
|
|
662
|
+
// createTreeSkeleton — { geometry, canopyAnchor, attachments } — so
|
|
663
|
+
// StylizedTree swaps generators via skeleton.generator.
|
|
664
|
+
// levels — recursion depth (1..4)
|
|
665
|
+
// childrenPerBranch— average children sprouting along each parent
|
|
666
|
+
// lengthRatio — child length as a fraction of its parent
|
|
667
|
+
// radiusRatio — child radius as a fraction of its parent
|
|
668
|
+
// spreadAngle — radians a child angles away from its parent
|
|
669
|
+
// upBias — gravitropism per growth step (negative droops)
|
|
670
|
+
// Recursive branching skeleton for open, realistic silhouettes, built on
|
|
671
|
+
// classic procedural-botany techniques (random-walk branch wander, tropism
|
|
672
|
+
// growth forces, stratified attachment sampling — the Weber & Penn lineage)
|
|
673
|
+
// and restyled for toon foliage. Branches
|
|
674
|
+
// are tubes of rings whose orientation evolves per section, which is where
|
|
675
|
+
// the organic quality comes from:
|
|
676
|
+
// 1. gnarliness — a random walk on the ring orientation whose amplitude
|
|
677
|
+
// grows as branches thin (max(1, sqrt(r0/r))): trunks stay stately,
|
|
678
|
+
// twigs wander.
|
|
679
|
+
// 2. growth force — every section steers toward a global direction with
|
|
680
|
+
// compliance 1/radius, clamped so it never overshoots. Positive
|
|
681
|
+
// strength sweeps tips skyward into a rounded broadleaf crown;
|
|
682
|
+
// negative droops them (pines, willows).
|
|
683
|
+
// 3. terminal continuation — a parent's last ring spawns the next level
|
|
684
|
+
// IN PLACE with the same segment count, so the trunk flows into a
|
|
685
|
+
// leader instead of ending in a stump; only the deepest level pinches.
|
|
686
|
+
// 4. stratified children/leaves — attach points are jittered within even
|
|
687
|
+
// slots along the parent (and around it, with a shuffled azimuth
|
|
688
|
+
// permutation), starting at a bare `branchStart` fraction: even
|
|
689
|
+
// coverage, no clumps, no spirals.
|
|
690
|
+
// Foliage is OUR leaf-card system: attachments stratified along the deepest
|
|
691
|
+
// branches plus every terminal tip.
|
|
692
|
+
// conifer — evergreen behavior: full taper, child length scaled by
|
|
693
|
+
// (1 - attach fraction) → the layered cone silhouette
|
|
694
|
+
// trunkSpine — optional hand-drawn trunk polyline (tree-local): level-0
|
|
695
|
+
// rings follow the doodle, children grow off it procedurally
|
|
696
|
+
export function createBranchingTreeSkeleton({
|
|
697
|
+
trunk = {},
|
|
698
|
+
seed = 1,
|
|
699
|
+
canopyScale = 0.85,
|
|
700
|
+
levels = 3,
|
|
701
|
+
childrenCount = 6,
|
|
702
|
+
branchAngle = 55,
|
|
703
|
+
branchStart = 0.4,
|
|
704
|
+
lengthRatio = 0.45,
|
|
705
|
+
radiusRatio = 0.7,
|
|
706
|
+
gnarliness = 0.15,
|
|
707
|
+
forceStrength = 0.02,
|
|
708
|
+
conifer = false,
|
|
709
|
+
trunkSpine = null,
|
|
710
|
+
radialSegments = 8,
|
|
711
|
+
tipRadius = 0.012,
|
|
712
|
+
maxBranches = 420,
|
|
713
|
+
leafSpacing = 0.3,
|
|
714
|
+
leafStart = 0.15,
|
|
715
|
+
maxAttachments = 380,
|
|
716
|
+
} = {}) {
|
|
717
|
+
const { height = 1.55, radiusBottom = 0.19, gnarl = 0, lean = 0 } = trunk;
|
|
718
|
+
const rand = seededRandom(seed * 4.87 + 2.3);
|
|
719
|
+
let randKey = 0;
|
|
720
|
+
const next = () => rand((randKey += 1) * 1.93);
|
|
721
|
+
const range = (max, min = 0) => min + next() * (max - min);
|
|
722
|
+
|
|
723
|
+
const maxLevel = THREE.MathUtils.clamp(Math.round(levels), 1, 4);
|
|
724
|
+
const spinePoints = Array.isArray(trunkSpine) && trunkSpine.length >= 2
|
|
725
|
+
? trunkSpine.map((p) => new THREE.Vector3(p[0], p[1], p[2]))
|
|
726
|
+
: null;
|
|
727
|
+
const trunkLength = spinePoints
|
|
728
|
+
? spinePoints.reduce((sum, p, i) => (i ? sum + p.distanceTo(spinePoints[i - 1]) : 0), 0)
|
|
729
|
+
: height;
|
|
730
|
+
|
|
731
|
+
// Per-level tables derived from the flat sliders (recipes stay compact;
|
|
732
|
+
// the curves echo real broadleaf proportions).
|
|
733
|
+
const degToRad = THREE.MathUtils.degToRad;
|
|
734
|
+
const lengths = [trunkLength, trunkLength * lengthRatio,
|
|
735
|
+
trunkLength * lengthRatio * 0.75, trunkLength * lengthRatio * 0.3];
|
|
736
|
+
const childCounts = [Math.max(1, Math.round(childrenCount)),
|
|
737
|
+
Math.max(2, Math.round(childrenCount * 0.6)), 3, 0];
|
|
738
|
+
const angles = [0, degToRad(branchAngle),
|
|
739
|
+
degToRad(Math.min(85, branchAngle * 1.05)), degToRad(branchAngle * 0.6)];
|
|
740
|
+
const starts = [0, THREE.MathUtils.clamp(branchStart, 0, 0.9), 0.25, 0.15];
|
|
741
|
+
const gnarlLevels = [gnarliness * 0.25 + gnarl * 0.08, gnarliness,
|
|
742
|
+
gnarliness * 1.2, gnarliness * 0.7];
|
|
743
|
+
const tapers = conifer ? [1, 1, 1, 1] : [0.72, 0.68, 0.78, 0.88];
|
|
744
|
+
const sectionCounts = [10, 7, 5, 4];
|
|
745
|
+
const segmentCounts = [Math.max(6, radialSegments),
|
|
746
|
+
Math.max(5, radialSegments - 2), 4, 4];
|
|
747
|
+
|
|
748
|
+
// Shared tube builder state (one merged geometry for all branches).
|
|
749
|
+
const positions = [];
|
|
750
|
+
const normals = [];
|
|
751
|
+
const uvs = [];
|
|
752
|
+
const indices = [];
|
|
753
|
+
const rawAttachments = [];
|
|
754
|
+
let branchBudget = maxBranches;
|
|
755
|
+
|
|
756
|
+
const UP = new THREE.Vector3(0, 1, 0);
|
|
757
|
+
const workVector = new THREE.Vector3();
|
|
758
|
+
const workAxis = new THREE.Vector3();
|
|
759
|
+
const workQuaternion = new THREE.Quaternion();
|
|
760
|
+
|
|
761
|
+
// { origin, quaternion, length, radius, level, segments } — BFS like EZ.
|
|
762
|
+
const queue = [];
|
|
763
|
+
|
|
764
|
+
const buildBranch = (branch) => {
|
|
765
|
+
const level = branch.level;
|
|
766
|
+
const table = Math.min(level, 3);
|
|
767
|
+
const sectionCount = sectionCounts[table];
|
|
768
|
+
const segments = branch.segments ?? segmentCounts[table];
|
|
769
|
+
const taper = tapers[table];
|
|
770
|
+
const sectionLength = branch.length / sectionCount;
|
|
771
|
+
const isLeafLevel = level >= maxLevel;
|
|
772
|
+
|
|
773
|
+
const origin = branch.origin.clone();
|
|
774
|
+
const orientation = branch.quaternion.clone();
|
|
775
|
+
const rings = [];
|
|
776
|
+
const vertexBase = positions.length / 3;
|
|
777
|
+
let travelled = 0;
|
|
778
|
+
|
|
779
|
+
// Hand-drawn trunk: rings follow the doodle polyline instead of the
|
|
780
|
+
// procedural walk; children still attach along it like any branch.
|
|
781
|
+
const spine = level === 0 ? spinePoints : null;
|
|
782
|
+
const spineSampler = spine ? (fraction) => {
|
|
783
|
+
const total = trunkLength * fraction;
|
|
784
|
+
let acc = 0;
|
|
785
|
+
for (let i = 1; i < spine.length; i += 1) {
|
|
786
|
+
const span = spine[i].distanceTo(spine[i - 1]);
|
|
787
|
+
if (acc + span >= total || i === spine.length - 1) {
|
|
788
|
+
const local = THREE.MathUtils.clamp((total - acc) / Math.max(span, 1e-6), 0, 1);
|
|
789
|
+
return {
|
|
790
|
+
point: spine[i - 1].clone().lerp(spine[i], local),
|
|
791
|
+
tangent: spine[i].clone().sub(spine[i - 1]).normalize(),
|
|
792
|
+
};
|
|
793
|
+
}
|
|
794
|
+
acc += span;
|
|
795
|
+
}
|
|
796
|
+
return { point: spine[spine.length - 1].clone(), tangent: UP.clone() };
|
|
797
|
+
} : null;
|
|
798
|
+
|
|
799
|
+
for (let i = 0; i <= sectionCount; i += 1) {
|
|
800
|
+
const t = i / sectionCount;
|
|
801
|
+
let ringRadius = i === sectionCount && isLeafLevel
|
|
802
|
+
? tipRadius * 0.3
|
|
803
|
+
: Math.max(tipRadius * 0.5, branch.radius * (1 - taper * t));
|
|
804
|
+
|
|
805
|
+
if (spineSampler) {
|
|
806
|
+
const sample = spineSampler(t);
|
|
807
|
+
origin.copy(sample.point);
|
|
808
|
+
workQuaternion.setFromUnitVectors(UP, sample.tangent);
|
|
809
|
+
orientation.copy(workQuaternion);
|
|
810
|
+
}
|
|
811
|
+
rings.push({
|
|
812
|
+
origin: origin.clone(),
|
|
813
|
+
quaternion: orientation.clone(),
|
|
814
|
+
radius: ringRadius,
|
|
815
|
+
});
|
|
816
|
+
|
|
817
|
+
// Ring vertices: pure radial normals, bark v tiles with arc length.
|
|
818
|
+
for (let j = 0; j <= segments; j += 1) {
|
|
819
|
+
const angle = (j / segments) * Math.PI * 2;
|
|
820
|
+
workVector.set(Math.cos(angle), 0, Math.sin(angle));
|
|
821
|
+
workVector.applyQuaternion(orientation);
|
|
822
|
+
normals.push(workVector.x, workVector.y, workVector.z);
|
|
823
|
+
positions.push(
|
|
824
|
+
origin.x + workVector.x * ringRadius,
|
|
825
|
+
origin.y + workVector.y * ringRadius,
|
|
826
|
+
origin.z + workVector.z * ringRadius,
|
|
827
|
+
);
|
|
828
|
+
uvs.push(j / segments, travelled * 2.2);
|
|
829
|
+
}
|
|
830
|
+
|
|
831
|
+
if (i === sectionCount) break;
|
|
832
|
+
travelled += sectionLength;
|
|
833
|
+
|
|
834
|
+
if (!spineSampler) {
|
|
835
|
+
// Advance the growth state — the core growth loop.
|
|
836
|
+
workVector.set(0, sectionLength, 0).applyQuaternion(orientation);
|
|
837
|
+
origin.add(workVector);
|
|
838
|
+
|
|
839
|
+
// 1. Gnarliness random walk, amplified as the branch thins.
|
|
840
|
+
const wobble = gnarlLevels[table] *
|
|
841
|
+
Math.max(1, Math.sqrt(radiusBottom / Math.max(ringRadius, 1e-4)));
|
|
842
|
+
workQuaternion.setFromAxisAngle(
|
|
843
|
+
workAxis.set(1, 0, 0), range(wobble, -wobble));
|
|
844
|
+
orientation.multiply(workQuaternion);
|
|
845
|
+
workQuaternion.setFromAxisAngle(
|
|
846
|
+
workAxis.set(0, 0, 1), range(wobble, -wobble));
|
|
847
|
+
orientation.multiply(workQuaternion);
|
|
848
|
+
|
|
849
|
+
// 2. Growth force: steer toward straight up with 1/radius
|
|
850
|
+
// compliance, clamped so thin twigs never overshoot. Negative
|
|
851
|
+
// strength pushes away (droop).
|
|
852
|
+
workVector.copy(UP).applyQuaternion(orientation);
|
|
853
|
+
workAxis.crossVectors(workVector, UP);
|
|
854
|
+
const sinFull = workAxis.length();
|
|
855
|
+
if (sinFull > 1e-6) {
|
|
856
|
+
const fullAngle = Math.atan2(sinFull, workVector.dot(UP));
|
|
857
|
+
const step = THREE.MathUtils.clamp(
|
|
858
|
+
forceStrength * (radiusBottom / Math.max(ringRadius, 1e-3)),
|
|
859
|
+
-fullAngle, fullAngle);
|
|
860
|
+
workQuaternion.setFromAxisAngle(workAxis.divideScalar(sinFull), step);
|
|
861
|
+
orientation.premultiply(workQuaternion);
|
|
862
|
+
}
|
|
863
|
+
}
|
|
864
|
+
}
|
|
865
|
+
|
|
866
|
+
// Quad strips between consecutive rings.
|
|
867
|
+
for (let i = 0; i < sectionCount; i += 1) {
|
|
868
|
+
for (let j = 0; j < segments; j += 1) {
|
|
869
|
+
const a = vertexBase + i * (segments + 1) + j;
|
|
870
|
+
const b = a + segments + 1;
|
|
871
|
+
indices.push(a, b, a + 1, b, b + 1, a + 1);
|
|
872
|
+
}
|
|
873
|
+
}
|
|
874
|
+
|
|
875
|
+
// Ring sampling for children/leaves (position + orientation + radius
|
|
876
|
+
// interpolated between the bracketing rings).
|
|
877
|
+
const ringAt = (fraction) => {
|
|
878
|
+
const scaled = fraction * sectionCount;
|
|
879
|
+
const index = Math.min(Math.floor(scaled), sectionCount - 1);
|
|
880
|
+
const alpha = scaled - index;
|
|
881
|
+
const a = rings[index];
|
|
882
|
+
const b = rings[index + 1];
|
|
883
|
+
return {
|
|
884
|
+
origin: a.origin.clone().lerp(b.origin, alpha),
|
|
885
|
+
quaternion: a.quaternion.clone().slerp(b.quaternion, alpha),
|
|
886
|
+
radius: THREE.MathUtils.lerp(a.radius, b.radius, alpha),
|
|
887
|
+
};
|
|
888
|
+
};
|
|
889
|
+
|
|
890
|
+
if (isLeafLevel) {
|
|
891
|
+
// Leaves live on the deepest level: stratified along [leafStart, 1]
|
|
892
|
+
// plus one at the pinched tip — the open along-the-branch foliage.
|
|
893
|
+
const usable = branch.length * (1 - leafStart);
|
|
894
|
+
const leafCount = THREE.MathUtils.clamp(Math.round(usable / leafSpacing), 1, 4);
|
|
895
|
+
for (let i = 0; i < leafCount; i += 1) {
|
|
896
|
+
if (rawAttachments.length >= maxAttachments) break;
|
|
897
|
+
const fraction = leafStart + ((i + next()) / leafCount) * (1 - leafStart);
|
|
898
|
+
const ring = ringAt(Math.min(fraction, 1));
|
|
899
|
+
rawAttachments.push({
|
|
900
|
+
position: ring.origin,
|
|
901
|
+
direction: UP.clone().applyQuaternion(ring.quaternion),
|
|
902
|
+
});
|
|
903
|
+
}
|
|
904
|
+
if (rawAttachments.length < maxAttachments) {
|
|
905
|
+
const tip = rings[rings.length - 1];
|
|
906
|
+
rawAttachments.push({
|
|
907
|
+
position: tip.origin.clone(),
|
|
908
|
+
direction: UP.clone().applyQuaternion(tip.quaternion),
|
|
909
|
+
});
|
|
910
|
+
}
|
|
911
|
+
return;
|
|
912
|
+
}
|
|
913
|
+
|
|
914
|
+
// Terminal continuation: the next level takes over from the last ring
|
|
915
|
+
// in place, same segment count — the trunk flows into a leader.
|
|
916
|
+
const last = rings[rings.length - 1];
|
|
917
|
+
if (branchBudget > 0) {
|
|
918
|
+
branchBudget -= 1;
|
|
919
|
+
queue.push({
|
|
920
|
+
origin: last.origin.clone(),
|
|
921
|
+
quaternion: last.quaternion.clone(),
|
|
922
|
+
length: lengths[Math.min(level + 1, 3)] * (conifer ? 0.5 : 1),
|
|
923
|
+
radius: last.radius,
|
|
924
|
+
level: level + 1,
|
|
925
|
+
segments,
|
|
926
|
+
});
|
|
927
|
+
}
|
|
928
|
+
|
|
929
|
+
// Lateral children: stratified heights along [start, 1], stratified
|
|
930
|
+
// azimuth slots decorrelated by a Fisher-Yates shuffle.
|
|
931
|
+
const childLevel = level + 1;
|
|
932
|
+
const count = childCounts[Math.min(level, 3)];
|
|
933
|
+
if (!count) return;
|
|
934
|
+
const startFraction = starts[Math.min(childLevel, 3)];
|
|
935
|
+
const heightStep = (1 - startFraction) / count;
|
|
936
|
+
const radialOffset = next();
|
|
937
|
+
const slots = Array.from({ length: count }, (_, i) => i);
|
|
938
|
+
for (let i = slots.length - 1; i > 0; i -= 1) {
|
|
939
|
+
const j = Math.floor(next() * (i + 1));
|
|
940
|
+
[slots[i], slots[j]] = [slots[j], slots[i]];
|
|
941
|
+
}
|
|
942
|
+
for (let i = 0; i < count; i += 1) {
|
|
943
|
+
if (branchBudget <= 0) break;
|
|
944
|
+
const attachFraction = startFraction + (i + next()) * heightStep;
|
|
945
|
+
const ring = ringAt(Math.min(attachFraction, 1));
|
|
946
|
+
const azimuth = Math.PI * 2 *
|
|
947
|
+
(radialOffset + (slots[i] + range(0.5, -0.5)) / count);
|
|
948
|
+
const pitch = angles[Math.min(childLevel, 3)] * (0.9 + next() * 0.2);
|
|
949
|
+
const childQuaternion = ring.quaternion.clone()
|
|
950
|
+
.multiply(new THREE.Quaternion().setFromAxisAngle(UP, azimuth))
|
|
951
|
+
.multiply(new THREE.Quaternion().setFromAxisAngle(
|
|
952
|
+
workAxis.set(1, 0, 0), pitch));
|
|
953
|
+
// Conifer crowns: children shorten toward the top → layered cone.
|
|
954
|
+
const childLength = lengths[Math.min(childLevel, 3)] *
|
|
955
|
+
(conifer ? (1 - attachFraction) : 1) * (0.85 + next() * 0.3);
|
|
956
|
+
if (childLength < 0.08) continue;
|
|
957
|
+
branchBudget -= 1;
|
|
958
|
+
queue.push({
|
|
959
|
+
origin: ring.origin,
|
|
960
|
+
quaternion: childQuaternion,
|
|
961
|
+
length: childLength,
|
|
962
|
+
radius: Math.max(tipRadius, ring.radius * radiusRatio),
|
|
963
|
+
level: childLevel,
|
|
964
|
+
segments: null,
|
|
965
|
+
});
|
|
966
|
+
}
|
|
967
|
+
};
|
|
968
|
+
|
|
969
|
+
// Trunk: optional initial lean carries the classic trunk styles over.
|
|
970
|
+
const rootQuaternion = new THREE.Quaternion();
|
|
971
|
+
if (!spinePoints && lean) {
|
|
972
|
+
const heading = next() * Math.PI * 2;
|
|
973
|
+
rootQuaternion.setFromAxisAngle(
|
|
974
|
+
new THREE.Vector3(Math.cos(heading), 0, Math.sin(heading)), lean * 0.45);
|
|
975
|
+
}
|
|
976
|
+
queue.push({
|
|
977
|
+
origin: new THREE.Vector3(0, 0, 0),
|
|
978
|
+
quaternion: rootQuaternion,
|
|
979
|
+
length: trunkLength,
|
|
980
|
+
radius: radiusBottom,
|
|
981
|
+
level: 0,
|
|
982
|
+
segments: null,
|
|
983
|
+
});
|
|
984
|
+
while (queue.length) buildBranch(queue.shift());
|
|
985
|
+
|
|
986
|
+
const geometry = new THREE.BufferGeometry();
|
|
987
|
+
geometry.setIndex(indices);
|
|
988
|
+
geometry.setAttribute('position', new THREE.Float32BufferAttribute(positions, 3));
|
|
989
|
+
geometry.setAttribute('normal', new THREE.Float32BufferAttribute(normals, 3));
|
|
990
|
+
geometry.setAttribute('uv', new THREE.Float32BufferAttribute(uvs, 2));
|
|
991
|
+
|
|
992
|
+
const anchor = new THREE.Vector3();
|
|
993
|
+
rawAttachments.forEach((attachment) => anchor.add(attachment.position));
|
|
994
|
+
anchor.divideScalar(Math.max(rawAttachments.length, 1));
|
|
995
|
+
const attachments = rawAttachments.map((attachment) => ({
|
|
996
|
+
position: attachment.position.clone().sub(anchor).divideScalar(canopyScale),
|
|
997
|
+
direction: attachment.direction,
|
|
998
|
+
}));
|
|
999
|
+
if (!attachments.length) {
|
|
1000
|
+
attachments.push({ position: new THREE.Vector3(), direction: new THREE.Vector3(0, 1, 0) });
|
|
1001
|
+
}
|
|
1002
|
+
return { geometry, canopyAnchor: anchor, attachments };
|
|
1003
|
+
}
|
|
1004
|
+
|
|
1005
|
+
// A few ready-made trunk personalities. Spread one into trunk options and
|
|
1006
|
+
// override from there: { ...TREE_TRUNK_STYLES.gnarled, seed: 4 }.
|
|
1007
|
+
export const TREE_TRUNK_STYLES = Object.freeze({
|
|
1008
|
+
straight: { bend: 0.04, lean: 0.05, twist: 0, gnarl: 0 },
|
|
1009
|
+
leaning: { bend: 0.12, lean: 0.22, twist: 0, gnarl: 0 },
|
|
1010
|
+
curved: { bend: 0.2, lean: 0.12, twist: 0.4, gnarl: 0.25 },
|
|
1011
|
+
gnarled: { bend: 0.16, lean: 0.18, twist: 1.2, gnarl: 0.8 },
|
|
1012
|
+
bonsai: { bend: 0.26, lean: 0.3, twist: 2.2, gnarl: 1.25, height: 1.2, radiusBottom: 0.24 },
|
|
1013
|
+
// The dramatic Liyue silhouette: one smooth serpentine trunk — a hard
|
|
1014
|
+
// mid-bow one way, the top swept far back the other (leanOffset PI pins
|
|
1015
|
+
// the reversal), crown carried well off the base.
|
|
1016
|
+
swooping: { bend: 0.6, lean: 0.85, twist: 0.5, gnarl: 0, leanOffset: Math.PI,
|
|
1017
|
+
height: 1.8, radiusBottom: 0.24 },
|
|
1018
|
+
});
|
|
1019
|
+
|
|
1020
|
+
// Ready-made example recipes, ordered least → most complex configuration.
|
|
1021
|
+
// Used by Tree Lab and the playground scene's showcase row; each is a
|
|
1022
|
+
// complete options object for `new StylizedTree(...)`.
|
|
1023
|
+
export const STYLIZED_TREE_EXAMPLES = Object.freeze([
|
|
1024
|
+
// 1. Baseline: straight trunk, default crown, one flat color.
|
|
1025
|
+
{ seed: 3, size: 1.7, canopyColor: 0x4da258, leafDensity: 1,
|
|
1026
|
+
trunk: TREE_TRUNK_STYLES.straight },
|
|
1027
|
+
// 2. Leaning trunk, same simple crown.
|
|
1028
|
+
{ seed: 8, size: 1.8, canopyColor: 0x54a85e, leafDensity: 1,
|
|
1029
|
+
trunk: TREE_TRUNK_STYLES.leaning },
|
|
1030
|
+
// 3. Slight see-through: gap pockets open, branches peek through.
|
|
1031
|
+
{ seed: 5, size: 1.9, canopyColor: 0x5eb063, leafDensity: 0.85,
|
|
1032
|
+
trunk: TREE_TRUNK_STYLES.leaning },
|
|
1033
|
+
// 4. Curved trunk + its own irregular crown layout.
|
|
1034
|
+
{ seed: 11, size: 2.0, canopyColor: 0x58ab5c, leafDensity: 0.95,
|
|
1035
|
+
trunk: TREE_TRUNK_STYLES.curved },
|
|
1036
|
+
// 5. Color picked from a list, per-seed (forest variation from one spec).
|
|
1037
|
+
{ seed: 17, size: 2.0, canopyColor: [0x4da258, 0x7fb84e, 0x9cbf46], leafDensity: 0.95,
|
|
1038
|
+
trunk: TREE_TRUNK_STYLES.curved },
|
|
1039
|
+
// 6. Wide-and-shallow crown (X reach 1.6, Z reach 0.7).
|
|
1040
|
+
{ seed: 9, size: 2.0, canopyColor: 0x6db54f, leafDensity: 0.95,
|
|
1041
|
+
canopyWidth: 1.6, canopyDepth: 0.7, trunk: TREE_TRUNK_STYLES.leaning },
|
|
1042
|
+
// 7. Autumn blend: seeded mix between two colors.
|
|
1043
|
+
{ seed: 21, size: 2.1, canopyColor: { from: 0xe8a33c, to: 0xd96f29 }, leafDensity: 0.9,
|
|
1044
|
+
trunk: TREE_TRUNK_STYLES.curved },
|
|
1045
|
+
// 8. Gnarled old tree: knotted growth, sparser crown shows the wood.
|
|
1046
|
+
{ seed: 14, size: 2.0, canopyColor: 0x8f9e44, leafDensity: 0.72,
|
|
1047
|
+
trunk: TREE_TRUNK_STYLES.gnarled },
|
|
1048
|
+
// 9. Bonsai: corkscrew twist, flat wide pads, HSL-range blossom color.
|
|
1049
|
+
{ seed: 26, size: 1.7, leafDensity: 0.8, canopyWidth: 1.4, canopyDepth: 1.2,
|
|
1050
|
+
canopyColor: { hue: [0.9, 1.0], saturation: [0.45, 0.6], lightness: [0.62, 0.72] },
|
|
1051
|
+
trunk: TREE_TRUNK_STYLES.bonsai },
|
|
1052
|
+
// 10. The Liyue golden gingko: fat-based serpentine trunk (bow right,
|
|
1053
|
+
// top swept hard left, S locked by leanOffset), extra-wide crown,
|
|
1054
|
+
// pinned pale-gold highlight tone. bendDirection 0 keeps the S in the
|
|
1055
|
+
// X-Y plane so the silhouette reads head-on in the showcase row.
|
|
1056
|
+
{ seed: 12, size: 2.4, canopyColor: 0xf5c531, canopyPalette: { crown: 0xffe98a },
|
|
1057
|
+
leafDensity: 0.95, canopyWidth: 1.5,
|
|
1058
|
+
skeleton: { radialSegments: 10 },
|
|
1059
|
+
trunk: { ...TREE_TRUNK_STYLES.swooping, bend: 0.5, lean: 0.95,
|
|
1060
|
+
bendDirection: 0, height: 2.0, radiusBottom: 0.28 } },
|
|
1061
|
+
// 11. Sumeru-style: long bare pale limbs reaching out of the crown with
|
|
1062
|
+
// violet leaf bushes only at the branch ends (leafPlacement 'tips').
|
|
1063
|
+
{ seed: 31, size: 2.3, pale: true, canopyColor: 0x8578e6,
|
|
1064
|
+
canopyPalette: { crown: 0xbdb2ff },
|
|
1065
|
+
leafDensity: 0.9, canopyWidth: 1.45, leafPlacement: 'tips',
|
|
1066
|
+
trunkReceiveShadow: false,
|
|
1067
|
+
skeleton: { attractionCount: 70, influenceRadius: 1.35 },
|
|
1068
|
+
trunk: { ...TREE_TRUNK_STYLES.curved, bend: 0.3, lean: 0.35, gnarl: 0.45,
|
|
1069
|
+
height: 1.9, radiusBottom: 0.26 } },
|
|
1070
|
+
// 12. MASSIVE climbable Sumeru tree: thick bare limbs long and low enough
|
|
1071
|
+
// to stand or sit on (scenes read `climbable: true` and collide the
|
|
1072
|
+
// wood as a trimesh instead of a trunk capsule), sparse skeleton so
|
|
1073
|
+
// the pale limbs stay on show, foliage clouds only at the limb ends.
|
|
1074
|
+
{ seed: 46, size: 4.0, pale: true, climbable: true,
|
|
1075
|
+
canopyColor: 0x8578e6, canopyPalette: { crown: 0xbdb2ff },
|
|
1076
|
+
leafDensity: 0.92, canopyWidth: 1.75, canopyDepth: 1.2,
|
|
1077
|
+
leafPlacement: 'tips', trunkReceiveShadow: false,
|
|
1078
|
+
skeleton: { attractionCount: 55, influenceRadius: 1.7, killRadius: 0.55,
|
|
1079
|
+
segmentLength: 0.36, attractionReach: 0.95, radialSegments: 14,
|
|
1080
|
+
tipRadius: 0.05, minLimbRadius: 0.04, maxNodes: 130 },
|
|
1081
|
+
canopy: { cardsPerCluster: 12, clusterRadius: 0.62 },
|
|
1082
|
+
trunk: { ...TREE_TRUNK_STYLES.leaning, bend: 0.24, lean: 0.42,
|
|
1083
|
+
height: 1.4, radiusBottom: 0.48 } },
|
|
1084
|
+
]);
|
|
1085
|
+
|
|
1086
|
+
// Centered X offsets for a showcase row: cumulative spacing from each tree's
|
|
1087
|
+
// approximate crown footprint, so a massive example doesn't swallow its
|
|
1088
|
+
// neighbors the way fixed spacing would.
|
|
1089
|
+
export function layoutTreeRow(configs, { margin = 1.6 } = {}) {
|
|
1090
|
+
const footprints = configs.map((config) =>
|
|
1091
|
+
(config.size ?? 1) * (config.canopyWidth ?? 1) * 2.3 + 1.4);
|
|
1092
|
+
const offsets = [];
|
|
1093
|
+
let cursor = 0;
|
|
1094
|
+
footprints.forEach((footprint, index) => {
|
|
1095
|
+
if (index > 0) cursor += (footprints[index - 1] + footprint) / 2 + margin;
|
|
1096
|
+
offsets.push(cursor);
|
|
1097
|
+
});
|
|
1098
|
+
const center = cursor / 2;
|
|
1099
|
+
return offsets.map((offset) => offset - center);
|
|
1100
|
+
}
|
|
1101
|
+
|
|
1102
|
+
// Recipe documents: a plant serialized as { schema, version, type, options }.
|
|
1103
|
+
// The options are exactly what the constructor takes, so a recipe rebuilds
|
|
1104
|
+
// the identical plant (generation is deterministic per seed). Defined here —
|
|
1105
|
+
// not in treeRecipe.js — so toJSON() below has no circular import.
|
|
1106
|
+
export const TREE_RECIPE_SCHEMA = 'treeRecipe';
|
|
1107
|
+
export const TREE_RECIPE_VERSION = 1;
|
|
1108
|
+
|
|
1109
|
+
// Recursively convert constructor options to plain JSON data: THREE.Color →
|
|
1110
|
+
// '#hex' string (resolveCanopyColor accepts it back), vectors → arrays,
|
|
1111
|
+
// functions dropped.
|
|
1112
|
+
function toSerializable(value) {
|
|
1113
|
+
if (value === null || typeof value !== 'object') {
|
|
1114
|
+
return typeof value === 'function' ? undefined : value;
|
|
1115
|
+
}
|
|
1116
|
+
if (value.isColor) return `#${value.getHexString(THREE.SRGBColorSpace)}`;
|
|
1117
|
+
if (value.isVector2 || value.isVector3 || value.isVector4) return value.toArray();
|
|
1118
|
+
if (Array.isArray(value)) return value.map((entry) => toSerializable(entry));
|
|
1119
|
+
const out = {};
|
|
1120
|
+
for (const [key, entry] of Object.entries(value)) {
|
|
1121
|
+
const converted = toSerializable(entry);
|
|
1122
|
+
if (converted !== undefined) out[key] = converted;
|
|
1123
|
+
}
|
|
1124
|
+
return out;
|
|
1125
|
+
}
|
|
1126
|
+
|
|
1127
|
+
// Whitelist copy of StylizedTree/StylizedBush constructor options with the
|
|
1128
|
+
// live objects stripped (trunkMaterial, foliage.leafMap/sharedUniforms) —
|
|
1129
|
+
// everything a recipe file may carry.
|
|
1130
|
+
export function serializableTreeOptions(options = {}) {
|
|
1131
|
+
const { trunkMaterial, foliage, ...rest } = options;
|
|
1132
|
+
void trunkMaterial;
|
|
1133
|
+
const out = toSerializable(rest);
|
|
1134
|
+
if (foliage) {
|
|
1135
|
+
const { leafMap, sharedUniforms, ...foliageRest } = foliage;
|
|
1136
|
+
void leafMap;
|
|
1137
|
+
void sharedUniforms;
|
|
1138
|
+
const serializedFoliage = toSerializable(foliageRest);
|
|
1139
|
+
if (Object.keys(serializedFoliage).length) out.foliage = serializedFoliage;
|
|
1140
|
+
}
|
|
1141
|
+
return out;
|
|
1142
|
+
}
|
|
1143
|
+
|
|
1144
|
+
// ---------------------------------------------------------------------------
|
|
1145
|
+
// Tree settings: DEFAULT_STYLIZED_TREE_SETTINGS / createStylizedTreeSettings mirror the
|
|
1146
|
+
// StylizedTree constructor options as a grouped settings object (tree, trunk,
|
|
1147
|
+
// skeleton, canopy, foliage), following the toonSettings pattern. Values not
|
|
1148
|
+
// listed in the defaults (canopy.blobs, foliage.leafMap, foliage.
|
|
1149
|
+
// sharedUniforms, ...) pass through createStylizedTreeSettings untouched, so every
|
|
1150
|
+
// legacy option keeps working.
|
|
1151
|
+
|
|
1152
|
+
function cleanObject(value) {
|
|
1153
|
+
return value && typeof value === 'object' && !Array.isArray(value) ? value : {};
|
|
1154
|
+
}
|
|
1155
|
+
|
|
1156
|
+
function finiteNumber(value, fallback, { min = -Infinity, max = Infinity } = {}) {
|
|
1157
|
+
const number = Number(value);
|
|
1158
|
+
if (!Number.isFinite(number)) return fallback;
|
|
1159
|
+
return Math.min(max, Math.max(min, number));
|
|
1160
|
+
}
|
|
1161
|
+
|
|
1162
|
+
function integerNumber(value, fallback, options) {
|
|
1163
|
+
return Math.round(finiteNumber(value, fallback, options));
|
|
1164
|
+
}
|
|
1165
|
+
|
|
1166
|
+
// Numbers where `null` is a meaningful "seeded / automatic" default.
|
|
1167
|
+
function nullableNumber(value, fallback, options) {
|
|
1168
|
+
if (value === undefined) return fallback;
|
|
1169
|
+
if (value === null) return null;
|
|
1170
|
+
const number = Number(value);
|
|
1171
|
+
return Number.isFinite(number) ? finiteNumber(number, fallback, options) : fallback;
|
|
1172
|
+
}
|
|
1173
|
+
|
|
1174
|
+
function booleanOption(value, fallback) {
|
|
1175
|
+
return value === undefined ? fallback : Boolean(value);
|
|
1176
|
+
}
|
|
1177
|
+
|
|
1178
|
+
function colorArray(value, fallback) {
|
|
1179
|
+
if (value?.isColor) return [value.r, value.g, value.b];
|
|
1180
|
+
if (Array.isArray(value) && value.length >= 3) {
|
|
1181
|
+
const next = value.slice(0, 3).map(Number);
|
|
1182
|
+
return next.every(Number.isFinite) ? next : fallback.slice();
|
|
1183
|
+
}
|
|
1184
|
+
if (typeof value === 'number' || typeof value === 'string') {
|
|
1185
|
+
try {
|
|
1186
|
+
const color = new THREE.Color(value);
|
|
1187
|
+
return [color.r, color.g, color.b];
|
|
1188
|
+
} catch {
|
|
1189
|
+
return fallback.slice();
|
|
1190
|
+
}
|
|
1191
|
+
}
|
|
1192
|
+
return fallback.slice();
|
|
1193
|
+
}
|
|
1194
|
+
|
|
1195
|
+
function vectorArray(value, fallback, size) {
|
|
1196
|
+
const keys = ['x', 'y', 'z', 'w'];
|
|
1197
|
+
const read = (index) => {
|
|
1198
|
+
if (Array.isArray(value)) return Number(value[index]);
|
|
1199
|
+
if (value && typeof value === 'object') return Number(value[keys[index]]);
|
|
1200
|
+
return NaN;
|
|
1201
|
+
};
|
|
1202
|
+
const next = Array.from({ length: size }, (_, index) => read(index));
|
|
1203
|
+
return next.every(Number.isFinite) ? next : fallback.slice(0, size);
|
|
1204
|
+
}
|
|
1205
|
+
|
|
1206
|
+
/**
|
|
1207
|
+
* Default StylizedTree settings, grouped as { tree, trunk, skeleton, canopy,
|
|
1208
|
+
* foliage }. Every value equals the historical hardcoded/parameter default,
|
|
1209
|
+
* so `new StylizedTree()` renders identically to previous releases.
|
|
1210
|
+
*
|
|
1211
|
+
* Groups `trunk`, `skeleton`, and `canopy` bake geometry and are
|
|
1212
|
+
* construction-only; the `foliage` group (and the tree palette/
|
|
1213
|
+
* trunkReceiveShadow) can be re-applied at runtime via
|
|
1214
|
+
* `StylizedTree#applySettings`.
|
|
1215
|
+
*/
|
|
1216
|
+
export const DEFAULT_STYLIZED_TREE_SETTINGS = Object.freeze({
|
|
1217
|
+
tree: Object.freeze({
|
|
1218
|
+
// 0x4da258 as an sRGB triplet; accepts any resolveCanopyColor spec.
|
|
1219
|
+
canopyColor: Object.freeze([0x4d / 255, 0xa2 / 255, 0x58 / 255]),
|
|
1220
|
+
canopyDepth: 1,
|
|
1221
|
+
canopyLayout: Object.freeze({}),
|
|
1222
|
+
canopyPalette: Object.freeze({}),
|
|
1223
|
+
canopyScale: 1,
|
|
1224
|
+
canopyWidth: 1,
|
|
1225
|
+
leafDensity: 1,
|
|
1226
|
+
leafPlacement: 'canopy',
|
|
1227
|
+
seed: 1,
|
|
1228
|
+
size: 1,
|
|
1229
|
+
trunkReceiveShadow: true,
|
|
1230
|
+
}),
|
|
1231
|
+
trunk: Object.freeze({
|
|
1232
|
+
bend: 0.12,
|
|
1233
|
+
bendDirection: null,
|
|
1234
|
+
branchCount: 2,
|
|
1235
|
+
branchLength: 0.55,
|
|
1236
|
+
branchRadius: 0.055,
|
|
1237
|
+
gnarl: 0,
|
|
1238
|
+
gnarlAmplitude: 0.16,
|
|
1239
|
+
gnarlFrequencyXRange: Object.freeze([4.2, 7.6]),
|
|
1240
|
+
gnarlFrequencyZRange: Object.freeze([3.1, 6.7]),
|
|
1241
|
+
height: 1.55,
|
|
1242
|
+
heightSegments: 14,
|
|
1243
|
+
lean: 0.16,
|
|
1244
|
+
leanOffset: null,
|
|
1245
|
+
radialGnarlFrequency: 9.3,
|
|
1246
|
+
radialSegments: 10,
|
|
1247
|
+
radiusBottom: 0.19,
|
|
1248
|
+
radiusTop: 0.085,
|
|
1249
|
+
twist: 0,
|
|
1250
|
+
}),
|
|
1251
|
+
skeleton: Object.freeze({
|
|
1252
|
+
attachmentTwigRadius: 0.09,
|
|
1253
|
+
attractionCount: 90,
|
|
1254
|
+
attractionReach: null,
|
|
1255
|
+
branchAngle: 55,
|
|
1256
|
+
branchStart: 0.4,
|
|
1257
|
+
childrenCount: 6,
|
|
1258
|
+
conifer: false,
|
|
1259
|
+
generator: 'limbs',
|
|
1260
|
+
influenceRadius: 1.2,
|
|
1261
|
+
killRadius: 0.42,
|
|
1262
|
+
forceStrength: 0.02,
|
|
1263
|
+
gnarliness: 0.15,
|
|
1264
|
+
lengthRatio: 0.45,
|
|
1265
|
+
levels: 3,
|
|
1266
|
+
maxNodes: 140,
|
|
1267
|
+
maxSteps: 48,
|
|
1268
|
+
minLimbRadius: 0.028,
|
|
1269
|
+
radialSegments: 8,
|
|
1270
|
+
radiusRatio: 0.7,
|
|
1271
|
+
segmentLength: 0.3,
|
|
1272
|
+
tipRadius: 0.03,
|
|
1273
|
+
}),
|
|
1274
|
+
canopy: Object.freeze({
|
|
1275
|
+
cardCount: 170,
|
|
1276
|
+
cardSizeRange: Object.freeze([1.0, 1.6]),
|
|
1277
|
+
cardsPerCluster: 5,
|
|
1278
|
+
clusterRadius: 0.48,
|
|
1279
|
+
shellFill: true,
|
|
1280
|
+
}),
|
|
1281
|
+
foliage: Object.freeze({
|
|
1282
|
+
alphaCutoff: 0.3,
|
|
1283
|
+
backlitStrength: 0.35,
|
|
1284
|
+
cloudShadowCoverage: 0.45,
|
|
1285
|
+
cloudShadowScale: 0.012,
|
|
1286
|
+
cloudShadowStrength: 0,
|
|
1287
|
+
cloudShadowVelocity: Object.freeze([0.02, 0.006]),
|
|
1288
|
+
sceneShadowStrength: 0.55,
|
|
1289
|
+
skyColor: Object.freeze([0.62, 0.78, 0.95]),
|
|
1290
|
+
sunColor: Object.freeze([1.0, 0.96, 0.84]),
|
|
1291
|
+
sunDirection: Object.freeze([0.35, 0.72, 0.42]),
|
|
1292
|
+
windDirection: Object.freeze([1, 0.3]),
|
|
1293
|
+
windSpeed: 1.0,
|
|
1294
|
+
windStrength: 0.05,
|
|
1295
|
+
}),
|
|
1296
|
+
});
|
|
1297
|
+
|
|
1298
|
+
/**
|
|
1299
|
+
* Validates and merges partial tree options over
|
|
1300
|
+
* {@link DEFAULT_STYLIZED_TREE_SETTINGS}. Accepts both the legacy flat constructor
|
|
1301
|
+
* shape (`{ size, seed, trunk: {...}, ... }`) and the grouped settings shape
|
|
1302
|
+
* (`{ tree: { size, seed }, trunk: {...}, ... }`); flat keys and the `tree`
|
|
1303
|
+
* group are the same fields. Unknown keys inside trunk/skeleton/canopy/
|
|
1304
|
+
* foliage pass through untouched (blobs, leafMap, sharedUniforms, ...), so
|
|
1305
|
+
* existing callers keep working. `createStylizedTreeSettings()` deep-equals the
|
|
1306
|
+
* defaults object.
|
|
1307
|
+
*
|
|
1308
|
+
* @param {Object} [options] Partial settings or legacy constructor options.
|
|
1309
|
+
* @returns {Object} A complete, plain grouped tree settings object.
|
|
1310
|
+
*/
|
|
1311
|
+
export function createStylizedTreeSettings(options = {}) {
|
|
1312
|
+
const source = cleanObject(options);
|
|
1313
|
+
const treeSource = { ...source, ...cleanObject(source.tree) };
|
|
1314
|
+
const trunkSource = cleanObject(source.trunk);
|
|
1315
|
+
const skeletonSource = cleanObject(source.skeleton);
|
|
1316
|
+
const canopySource = cleanObject(source.canopy);
|
|
1317
|
+
const foliageSource = cleanObject(source.foliage);
|
|
1318
|
+
const base = DEFAULT_STYLIZED_TREE_SETTINGS;
|
|
1319
|
+
|
|
1320
|
+
return {
|
|
1321
|
+
tree: {
|
|
1322
|
+
canopyColor: treeSource.canopyColor !== undefined
|
|
1323
|
+
? treeSource.canopyColor
|
|
1324
|
+
: [...base.tree.canopyColor],
|
|
1325
|
+
canopyDepth: finiteNumber(treeSource.canopyDepth, base.tree.canopyDepth, { min: 0.01 }),
|
|
1326
|
+
canopyLayout: { ...cleanObject(treeSource.canopyLayout) },
|
|
1327
|
+
canopyPalette: { ...cleanObject(treeSource.canopyPalette) },
|
|
1328
|
+
canopyScale: finiteNumber(treeSource.canopyScale, base.tree.canopyScale, { min: 0.01 }),
|
|
1329
|
+
canopyWidth: finiteNumber(treeSource.canopyWidth, base.tree.canopyWidth, { min: 0.01 }),
|
|
1330
|
+
leafDensity: finiteNumber(treeSource.leafDensity, base.tree.leafDensity, { min: 0.05, max: 2 }),
|
|
1331
|
+
leafPlacement: treeSource.leafPlacement === 'tips' ? 'tips' : base.tree.leafPlacement,
|
|
1332
|
+
seed: finiteNumber(treeSource.seed, base.tree.seed),
|
|
1333
|
+
size: finiteNumber(treeSource.size, base.tree.size, { min: 0.01 }),
|
|
1334
|
+
trunkReceiveShadow: booleanOption(treeSource.trunkReceiveShadow, base.tree.trunkReceiveShadow),
|
|
1335
|
+
},
|
|
1336
|
+
trunk: {
|
|
1337
|
+
...trunkSource,
|
|
1338
|
+
bend: finiteNumber(trunkSource.bend, base.trunk.bend),
|
|
1339
|
+
bendDirection: nullableNumber(trunkSource.bendDirection, base.trunk.bendDirection),
|
|
1340
|
+
branchCount: integerNumber(trunkSource.branchCount, base.trunk.branchCount, { min: 0 }),
|
|
1341
|
+
branchLength: finiteNumber(trunkSource.branchLength, base.trunk.branchLength, { min: 0 }),
|
|
1342
|
+
branchRadius: finiteNumber(trunkSource.branchRadius, base.trunk.branchRadius, { min: 0 }),
|
|
1343
|
+
gnarl: finiteNumber(trunkSource.gnarl, base.trunk.gnarl, { min: 0 }),
|
|
1344
|
+
gnarlAmplitude: finiteNumber(trunkSource.gnarlAmplitude, base.trunk.gnarlAmplitude, { min: 0 }),
|
|
1345
|
+
gnarlFrequencyXRange: vectorArray(trunkSource.gnarlFrequencyXRange, base.trunk.gnarlFrequencyXRange, 2),
|
|
1346
|
+
gnarlFrequencyZRange: vectorArray(trunkSource.gnarlFrequencyZRange, base.trunk.gnarlFrequencyZRange, 2),
|
|
1347
|
+
height: finiteNumber(trunkSource.height, base.trunk.height, { min: 0.01 }),
|
|
1348
|
+
heightSegments: integerNumber(trunkSource.heightSegments, base.trunk.heightSegments, { min: 1 }),
|
|
1349
|
+
lean: finiteNumber(trunkSource.lean, base.trunk.lean),
|
|
1350
|
+
leanOffset: nullableNumber(trunkSource.leanOffset, base.trunk.leanOffset),
|
|
1351
|
+
radialGnarlFrequency: finiteNumber(trunkSource.radialGnarlFrequency, base.trunk.radialGnarlFrequency, { min: 0 }),
|
|
1352
|
+
radialSegments: integerNumber(trunkSource.radialSegments, base.trunk.radialSegments, { min: 3 }),
|
|
1353
|
+
radiusBottom: finiteNumber(trunkSource.radiusBottom, base.trunk.radiusBottom, { min: 0.001 }),
|
|
1354
|
+
radiusTop: finiteNumber(trunkSource.radiusTop, base.trunk.radiusTop, { min: 0.001 }),
|
|
1355
|
+
twist: finiteNumber(trunkSource.twist, base.trunk.twist),
|
|
1356
|
+
},
|
|
1357
|
+
skeleton: {
|
|
1358
|
+
...skeletonSource,
|
|
1359
|
+
attachmentTwigRadius: finiteNumber(skeletonSource.attachmentTwigRadius, base.skeleton.attachmentTwigRadius, { min: 0 }),
|
|
1360
|
+
attractionCount: integerNumber(skeletonSource.attractionCount, base.skeleton.attractionCount, { min: 1 }),
|
|
1361
|
+
attractionReach: nullableNumber(skeletonSource.attractionReach, base.skeleton.attractionReach, { min: 0, max: 1 }),
|
|
1362
|
+
branchAngle: finiteNumber(skeletonSource.branchAngle, base.skeleton.branchAngle, { min: 10, max: 130 }),
|
|
1363
|
+
branchStart: finiteNumber(skeletonSource.branchStart, base.skeleton.branchStart, { min: 0, max: 0.9 }),
|
|
1364
|
+
childrenCount: finiteNumber(skeletonSource.childrenCount, base.skeleton.childrenCount, { min: 1, max: 90 }),
|
|
1365
|
+
conifer: booleanOption(skeletonSource.conifer, base.skeleton.conifer),
|
|
1366
|
+
forceStrength: finiteNumber(skeletonSource.forceStrength, base.skeleton.forceStrength, { min: -0.08, max: 0.15 }),
|
|
1367
|
+
gnarliness: finiteNumber(skeletonSource.gnarliness, base.skeleton.gnarliness, { min: 0, max: 0.6 }),
|
|
1368
|
+
generator: ['branching', 'drawn'].includes(skeletonSource.generator)
|
|
1369
|
+
? skeletonSource.generator : base.skeleton.generator,
|
|
1370
|
+
lengthRatio: finiteNumber(skeletonSource.lengthRatio, base.skeleton.lengthRatio, { min: 0.15, max: 0.95 }),
|
|
1371
|
+
levels: integerNumber(skeletonSource.levels, base.skeleton.levels, { min: 1, max: 4 }),
|
|
1372
|
+
radiusRatio: finiteNumber(skeletonSource.radiusRatio, base.skeleton.radiusRatio, { min: 0.3, max: 0.9 }),
|
|
1373
|
+
influenceRadius: finiteNumber(skeletonSource.influenceRadius, base.skeleton.influenceRadius, { min: 0.01 }),
|
|
1374
|
+
killRadius: finiteNumber(skeletonSource.killRadius, base.skeleton.killRadius, { min: 0.01 }),
|
|
1375
|
+
maxNodes: integerNumber(skeletonSource.maxNodes, base.skeleton.maxNodes, { min: 2 }),
|
|
1376
|
+
maxSteps: integerNumber(skeletonSource.maxSteps, base.skeleton.maxSteps, { min: 1 }),
|
|
1377
|
+
minLimbRadius: finiteNumber(skeletonSource.minLimbRadius, base.skeleton.minLimbRadius, { min: 0 }),
|
|
1378
|
+
radialSegments: integerNumber(skeletonSource.radialSegments, base.skeleton.radialSegments, { min: 3 }),
|
|
1379
|
+
segmentLength: finiteNumber(skeletonSource.segmentLength, base.skeleton.segmentLength, { min: 0.01 }),
|
|
1380
|
+
tipRadius: finiteNumber(skeletonSource.tipRadius, base.skeleton.tipRadius, { min: 0.001 }),
|
|
1381
|
+
},
|
|
1382
|
+
canopy: {
|
|
1383
|
+
...canopySource,
|
|
1384
|
+
cardCount: integerNumber(canopySource.cardCount, base.canopy.cardCount, { min: 0 }),
|
|
1385
|
+
cardSizeRange: vectorArray(canopySource.cardSizeRange, base.canopy.cardSizeRange, 2),
|
|
1386
|
+
cardsPerCluster: integerNumber(canopySource.cardsPerCluster, base.canopy.cardsPerCluster, { min: 1 }),
|
|
1387
|
+
clusterRadius: finiteNumber(canopySource.clusterRadius, base.canopy.clusterRadius, { min: 0.01 }),
|
|
1388
|
+
shellFill: booleanOption(canopySource.shellFill, base.canopy.shellFill),
|
|
1389
|
+
},
|
|
1390
|
+
foliage: {
|
|
1391
|
+
...foliageSource,
|
|
1392
|
+
alphaCutoff: finiteNumber(foliageSource.alphaCutoff, base.foliage.alphaCutoff, { min: 0, max: 1 }),
|
|
1393
|
+
backlitStrength: finiteNumber(foliageSource.backlitStrength, base.foliage.backlitStrength, { min: 0 }),
|
|
1394
|
+
cloudShadowCoverage: finiteNumber(foliageSource.cloudShadowCoverage, base.foliage.cloudShadowCoverage, { min: 0, max: 1 }),
|
|
1395
|
+
cloudShadowScale: finiteNumber(foliageSource.cloudShadowScale, base.foliage.cloudShadowScale, { min: 0.0001 }),
|
|
1396
|
+
cloudShadowStrength: finiteNumber(foliageSource.cloudShadowStrength, base.foliage.cloudShadowStrength, { min: 0, max: 1 }),
|
|
1397
|
+
cloudShadowVelocity: vectorArray(foliageSource.cloudShadowVelocity, base.foliage.cloudShadowVelocity, 2),
|
|
1398
|
+
sceneShadowStrength: finiteNumber(foliageSource.sceneShadowStrength, base.foliage.sceneShadowStrength, { min: 0, max: 1 }),
|
|
1399
|
+
skyColor: colorArray(foliageSource.skyColor, base.foliage.skyColor),
|
|
1400
|
+
sunColor: colorArray(foliageSource.sunColor, base.foliage.sunColor),
|
|
1401
|
+
sunDirection: vectorArray(foliageSource.sunDirection, base.foliage.sunDirection, 3),
|
|
1402
|
+
windDirection: vectorArray(foliageSource.windDirection, base.foliage.windDirection, 2),
|
|
1403
|
+
windSpeed: finiteNumber(foliageSource.windSpeed, base.foliage.windSpeed),
|
|
1404
|
+
windStrength: finiteNumber(foliageSource.windStrength, base.foliage.windStrength, { min: 0 }),
|
|
1405
|
+
},
|
|
1406
|
+
};
|
|
1407
|
+
}
|
|
1408
|
+
|
|
1409
|
+
/**
|
|
1410
|
+
* Panel group metadata for the tree settings, in display order. Group ids
|
|
1411
|
+
* match the {@link DEFAULT_STYLIZED_TREE_SETTINGS} top-level keys.
|
|
1412
|
+
*/
|
|
1413
|
+
export const STYLIZED_TREE_SETTING_GROUPS = Object.freeze([
|
|
1414
|
+
Object.freeze({
|
|
1415
|
+
description: 'Overall scale, seed, crown reach, leaf coverage, and canopy palette. Everything except the palette and trunk shadow flag bakes geometry at construction.',
|
|
1416
|
+
id: 'tree',
|
|
1417
|
+
label: 'Tree',
|
|
1418
|
+
}),
|
|
1419
|
+
Object.freeze({
|
|
1420
|
+
description: 'Trunk silhouette (bend, lean, twist, gnarl) shared by the skeleton grower and the classic curved-trunk generator. Construction-only.',
|
|
1421
|
+
id: 'trunk',
|
|
1422
|
+
label: 'Trunk',
|
|
1423
|
+
}),
|
|
1424
|
+
Object.freeze({
|
|
1425
|
+
description: 'Space-colonization limb growth and bark mesh controls. Construction-only.',
|
|
1426
|
+
id: 'skeleton',
|
|
1427
|
+
label: 'Skeleton',
|
|
1428
|
+
}),
|
|
1429
|
+
Object.freeze({
|
|
1430
|
+
description: 'Leaf-card canopy geometry: card counts, tuft clusters, and shell fill. Construction-only.',
|
|
1431
|
+
id: 'canopy',
|
|
1432
|
+
label: 'Canopy Cards',
|
|
1433
|
+
}),
|
|
1434
|
+
Object.freeze({
|
|
1435
|
+
description: 'Leaf material response: wind, sun, alpha cutout, scene and cloud shadows. Applies at runtime via applySettings.',
|
|
1436
|
+
id: 'foliage',
|
|
1437
|
+
label: 'Foliage Material',
|
|
1438
|
+
}),
|
|
1439
|
+
]);
|
|
1440
|
+
|
|
1441
|
+
const STYLIZED_TREE_FIELD_DEFINITIONS = Object.freeze({
|
|
1442
|
+
tree: {
|
|
1443
|
+
size: {
|
|
1444
|
+
description: 'Overall tree multiplier (1 ≈ 3 m tree, 2 ≈ 6 m, 3+ large). Construction-only: also densifies canopy cards so leaves stay leaf-sized.',
|
|
1445
|
+
label: 'Size',
|
|
1446
|
+
range: { max: 6, min: 0.2, step: 0.05 },
|
|
1447
|
+
type: 'number',
|
|
1448
|
+
},
|
|
1449
|
+
seed: {
|
|
1450
|
+
description: 'Deterministic generation seed; the same options and seed always grow the same tree. Construction-only.',
|
|
1451
|
+
label: 'Seed',
|
|
1452
|
+
range: { max: 999, min: 1, step: 1 },
|
|
1453
|
+
type: 'number',
|
|
1454
|
+
},
|
|
1455
|
+
canopyColor: {
|
|
1456
|
+
description: 'Canopy base color; the lit/shadow/crown palette derives from it. Also accepts richer resolveCanopyColor specs (color lists, {from,to} blends, HSL ranges) resolved per seed.',
|
|
1457
|
+
label: 'Canopy Color',
|
|
1458
|
+
type: 'color',
|
|
1459
|
+
},
|
|
1460
|
+
canopyPalette: {
|
|
1461
|
+
description: 'Optional explicit { lit, shadow, crown } tone overrides; unset tones derive from the canopy color.',
|
|
1462
|
+
label: 'Canopy Palette',
|
|
1463
|
+
serializable: false,
|
|
1464
|
+
type: 'object',
|
|
1465
|
+
},
|
|
1466
|
+
canopyWidth: {
|
|
1467
|
+
description: 'X-axis crown reach multiplier. Construction-only: shapes the blob layout.',
|
|
1468
|
+
label: 'Canopy Width',
|
|
1469
|
+
range: { max: 2.5, min: 0.3, step: 0.05 },
|
|
1470
|
+
type: 'number',
|
|
1471
|
+
},
|
|
1472
|
+
canopyDepth: {
|
|
1473
|
+
description: 'Z-axis crown reach multiplier. Construction-only: shapes the blob layout.',
|
|
1474
|
+
label: 'Canopy Depth',
|
|
1475
|
+
range: { max: 2.5, min: 0.3, step: 0.05 },
|
|
1476
|
+
type: 'number',
|
|
1477
|
+
},
|
|
1478
|
+
canopyLayout: {
|
|
1479
|
+
description: 'Optional createCanopyBlobs overrides (lobeCount, spread, flatten, coreRadius, ...). Construction-only.',
|
|
1480
|
+
label: 'Canopy Layout',
|
|
1481
|
+
serializable: false,
|
|
1482
|
+
type: 'object',
|
|
1483
|
+
},
|
|
1484
|
+
leafDensity: {
|
|
1485
|
+
description: 'Crown leaf coverage. Below ~0.9 see-through gap pockets open and branches read through; above 1 packs extra cards (and fatter tufts) for lush crowns. Construction-only.',
|
|
1486
|
+
label: 'Leaf Density',
|
|
1487
|
+
range: { max: 2, min: 0.05, step: 0.01 },
|
|
1488
|
+
type: 'number',
|
|
1489
|
+
},
|
|
1490
|
+
canopyScale: {
|
|
1491
|
+
description: 'Canopy-only scale relative to the trunk. Construction-only.',
|
|
1492
|
+
label: 'Canopy Scale',
|
|
1493
|
+
range: { max: 3, min: 0.2, step: 0.05 },
|
|
1494
|
+
type: 'number',
|
|
1495
|
+
},
|
|
1496
|
+
leafPlacement: {
|
|
1497
|
+
description: 'canopy: solid leaf mass hiding interior wood. tips: bushes only at branch ends with bare limbs between them (Sumeru silhouette). Construction-only.',
|
|
1498
|
+
label: 'Leaf Placement',
|
|
1499
|
+
optionLabels: Object.freeze({ canopy: 'Solid Canopy', tips: 'Branch Tips' }),
|
|
1500
|
+
options: Object.freeze(['canopy', 'tips']),
|
|
1501
|
+
type: 'select',
|
|
1502
|
+
},
|
|
1503
|
+
trunkReceiveShadow: {
|
|
1504
|
+
description: 'Whether the bark receives shadow maps. Massive pale-limbed trees read better with this off.',
|
|
1505
|
+
label: 'Trunk Receive Shadow',
|
|
1506
|
+
type: 'boolean',
|
|
1507
|
+
},
|
|
1508
|
+
},
|
|
1509
|
+
trunk: {
|
|
1510
|
+
height: {
|
|
1511
|
+
description: 'Trunk height in meters (before the overall size multiplier). Construction-only.',
|
|
1512
|
+
label: 'Height',
|
|
1513
|
+
range: { max: 3, min: 0.4, step: 0.05 },
|
|
1514
|
+
type: 'number',
|
|
1515
|
+
},
|
|
1516
|
+
radiusBottom: {
|
|
1517
|
+
description: 'Trunk radius at the root flare in meters. Construction-only.',
|
|
1518
|
+
label: 'Radius Bottom',
|
|
1519
|
+
range: { max: 0.6, min: 0.05, step: 0.005 },
|
|
1520
|
+
type: 'number',
|
|
1521
|
+
},
|
|
1522
|
+
radiusTop: {
|
|
1523
|
+
description: 'Trunk radius at the top in meters. Classic trunk generator (createTreeTrunkGeometry) only. Construction-only.',
|
|
1524
|
+
label: 'Radius Top',
|
|
1525
|
+
range: { max: 0.3, min: 0.02, step: 0.005 },
|
|
1526
|
+
type: 'number',
|
|
1527
|
+
},
|
|
1528
|
+
bend: {
|
|
1529
|
+
description: 'Mid-trunk bow amplitude that returns toward center (S-curve) in meters. Construction-only.',
|
|
1530
|
+
label: 'Bend',
|
|
1531
|
+
range: { max: 0.8, min: 0, step: 0.01 },
|
|
1532
|
+
type: 'number',
|
|
1533
|
+
},
|
|
1534
|
+
lean: {
|
|
1535
|
+
description: 'Off-vertical drift that accumulates toward the top, in meters. Construction-only.',
|
|
1536
|
+
label: 'Lean',
|
|
1537
|
+
range: { max: 1.2, min: 0, step: 0.01 },
|
|
1538
|
+
type: 'number',
|
|
1539
|
+
},
|
|
1540
|
+
twist: {
|
|
1541
|
+
description: 'Y-rotation of the cross-section over the full height in radians; spirals the bark like wrung wood. Construction-only.',
|
|
1542
|
+
label: 'Twist',
|
|
1543
|
+
range: { max: 4, min: -4, step: 0.05 },
|
|
1544
|
+
type: 'number',
|
|
1545
|
+
},
|
|
1546
|
+
gnarl: {
|
|
1547
|
+
description: 'High-frequency wiggle and radius bulges: 0 is a clean park tree, 1+ reads like an old bonsai. Construction-only.',
|
|
1548
|
+
label: 'Gnarl',
|
|
1549
|
+
range: { max: 2, min: 0, step: 0.01 },
|
|
1550
|
+
type: 'number',
|
|
1551
|
+
},
|
|
1552
|
+
gnarlFrequencyXRange: {
|
|
1553
|
+
description: 'Seeded min/max wave count of the gnarl wiggle over the trunk height on the X axis. Classic trunk generator only. Construction-only.',
|
|
1554
|
+
label: 'Gnarl Frequency X Range',
|
|
1555
|
+
type: 'vector2',
|
|
1556
|
+
},
|
|
1557
|
+
gnarlFrequencyZRange: {
|
|
1558
|
+
description: 'Seeded min/max wave count of the gnarl wiggle over the trunk height on the Z axis. Classic trunk generator only. Construction-only.',
|
|
1559
|
+
label: 'Gnarl Frequency Z Range',
|
|
1560
|
+
type: 'vector2',
|
|
1561
|
+
},
|
|
1562
|
+
gnarlAmplitude: {
|
|
1563
|
+
description: 'Meters of gnarl wiggle (and radius bulge fraction) per unit of gnarl. Classic trunk generator only. Construction-only.',
|
|
1564
|
+
label: 'Gnarl Amplitude',
|
|
1565
|
+
range: { max: 0.5, min: 0, step: 0.005 },
|
|
1566
|
+
type: 'number',
|
|
1567
|
+
},
|
|
1568
|
+
radialGnarlFrequency: {
|
|
1569
|
+
description: 'Wave count of the gnarl radius bulges (old-wood knuckles) over the trunk height. Classic trunk generator only. Construction-only.',
|
|
1570
|
+
label: 'Radial Gnarl Frequency',
|
|
1571
|
+
range: { max: 20, min: 0, step: 0.1 },
|
|
1572
|
+
type: 'number',
|
|
1573
|
+
},
|
|
1574
|
+
bendDirection: {
|
|
1575
|
+
description: 'World heading of the bow in radians; null/unset picks a seeded heading. Construction-only.',
|
|
1576
|
+
label: 'Bend Direction',
|
|
1577
|
+
range: { max: 6.283, min: -6.283, step: 0.01 },
|
|
1578
|
+
type: 'number',
|
|
1579
|
+
},
|
|
1580
|
+
leanOffset: {
|
|
1581
|
+
description: 'Lean heading relative to the bow in radians (PI pins a serpentine S-trunk); null/unset picks a seeded offset. Construction-only.',
|
|
1582
|
+
label: 'Lean Offset',
|
|
1583
|
+
range: { max: 6.283, min: -6.283, step: 0.01 },
|
|
1584
|
+
type: 'number',
|
|
1585
|
+
},
|
|
1586
|
+
radialSegments: {
|
|
1587
|
+
description: 'Cross-section segment count of the trunk tube. Classic trunk generator only. Construction-only.',
|
|
1588
|
+
label: 'Radial Segments',
|
|
1589
|
+
range: { max: 16, min: 3, step: 1 },
|
|
1590
|
+
type: 'number',
|
|
1591
|
+
},
|
|
1592
|
+
heightSegments: {
|
|
1593
|
+
description: 'Vertical segment count of the trunk tube. Classic trunk generator only. Construction-only.',
|
|
1594
|
+
label: 'Height Segments',
|
|
1595
|
+
range: { max: 24, min: 2, step: 1 },
|
|
1596
|
+
type: 'number',
|
|
1597
|
+
},
|
|
1598
|
+
branchCount: {
|
|
1599
|
+
description: 'Number of stub branches near the top. Classic trunk generator only. Construction-only.',
|
|
1600
|
+
label: 'Branch Count',
|
|
1601
|
+
range: { max: 6, min: 0, step: 1 },
|
|
1602
|
+
type: 'number',
|
|
1603
|
+
},
|
|
1604
|
+
branchLength: {
|
|
1605
|
+
description: 'Base branch length in meters. Classic trunk generator only. Construction-only.',
|
|
1606
|
+
label: 'Branch Length',
|
|
1607
|
+
range: { max: 1.5, min: 0, step: 0.01 },
|
|
1608
|
+
type: 'number',
|
|
1609
|
+
},
|
|
1610
|
+
branchRadius: {
|
|
1611
|
+
description: 'Base branch radius in meters. Classic trunk generator only. Construction-only.',
|
|
1612
|
+
label: 'Branch Radius',
|
|
1613
|
+
range: { max: 0.2, min: 0, step: 0.005 },
|
|
1614
|
+
type: 'number',
|
|
1615
|
+
},
|
|
1616
|
+
},
|
|
1617
|
+
skeleton: {
|
|
1618
|
+
generator: {
|
|
1619
|
+
description: 'limbs: space-colonization growth toward the crown blobs (solid anime-style crowns). branching: recursive central-leader branching (open, realistic broadleaf/conifer silhouettes). drawn: no procedural wood at all — the tree is exactly the hand-drawn branchSpines (Tree Lab sketch mode). Construction-only.',
|
|
1620
|
+
label: 'Generator',
|
|
1621
|
+
optionLabels: Object.freeze({ limbs: 'Grown Limbs', branching: 'Recursive Branching', drawn: 'Hand-Drawn' }),
|
|
1622
|
+
options: Object.freeze(['limbs', 'branching', 'drawn']),
|
|
1623
|
+
type: 'select',
|
|
1624
|
+
},
|
|
1625
|
+
levels: {
|
|
1626
|
+
description: 'Recursion depth of the branching generator; each level subdivides into thinner children. Branching generator only. Construction-only.',
|
|
1627
|
+
label: 'Branch Levels',
|
|
1628
|
+
range: { max: 4, min: 1, step: 1 },
|
|
1629
|
+
type: 'number',
|
|
1630
|
+
},
|
|
1631
|
+
childrenCount: {
|
|
1632
|
+
description: 'Child branches sprouting along the trunk (deeper levels derive from it). Conifers use high counts (60-90) for dense whorled fronds. Branching generator only. Construction-only.',
|
|
1633
|
+
label: 'Children',
|
|
1634
|
+
range: { max: 90, min: 1, step: 1 },
|
|
1635
|
+
type: 'number',
|
|
1636
|
+
},
|
|
1637
|
+
branchAngle: {
|
|
1638
|
+
description: 'Child pitch away from the parent axis, in degrees. Past 90 points branches below horizontal (conifer fronds ~110). Branching generator only. Construction-only.',
|
|
1639
|
+
label: 'Branch Angle',
|
|
1640
|
+
range: { max: 130, min: 10, step: 1 },
|
|
1641
|
+
type: 'number',
|
|
1642
|
+
},
|
|
1643
|
+
branchStart: {
|
|
1644
|
+
description: 'Fraction of the trunk kept bare before children begin — real trees hold their crown off the ground. Branching generator only. Construction-only.',
|
|
1645
|
+
label: 'Branch Start',
|
|
1646
|
+
range: { max: 0.9, min: 0, step: 0.01 },
|
|
1647
|
+
type: 'number',
|
|
1648
|
+
},
|
|
1649
|
+
lengthRatio: {
|
|
1650
|
+
description: 'Child branch length as a fraction of the trunk (deeper levels shorten from it). Branching generator only. Construction-only.',
|
|
1651
|
+
label: 'Length Ratio',
|
|
1652
|
+
range: { max: 0.95, min: 0.15, step: 0.01 },
|
|
1653
|
+
type: 'number',
|
|
1654
|
+
},
|
|
1655
|
+
radiusRatio: {
|
|
1656
|
+
description: 'Child radius as a fraction of the parent\\u2019s radius at the attach point — radius continuity is what makes forks read as one tree. Branching generator only. Construction-only.',
|
|
1657
|
+
label: 'Radius Ratio',
|
|
1658
|
+
range: { max: 0.9, min: 0.3, step: 0.01 },
|
|
1659
|
+
type: 'number',
|
|
1660
|
+
},
|
|
1661
|
+
gnarliness: {
|
|
1662
|
+
description: 'Random-walk curvature per growth section, amplified as branches thin: trunks stay stately, twigs wander. Branching generator only. Construction-only.',
|
|
1663
|
+
label: 'Gnarliness',
|
|
1664
|
+
range: { max: 0.6, min: 0, step: 0.01 },
|
|
1665
|
+
type: 'number',
|
|
1666
|
+
},
|
|
1667
|
+
forceStrength: {
|
|
1668
|
+
description: 'Growth force: every section steers toward vertical with 1/radius compliance. Positive sweeps tips skyward (broadleaf crowns); negative droops them (pines, willows). Branching generator only. Construction-only.',
|
|
1669
|
+
label: 'Growth Force',
|
|
1670
|
+
range: { max: 0.15, min: -0.08, step: 0.005 },
|
|
1671
|
+
type: 'number',
|
|
1672
|
+
},
|
|
1673
|
+
conifer: {
|
|
1674
|
+
description: 'Evergreen behavior: branches taper fully and children shorten toward the top \\u2014 the layered cone silhouette. Pair with high Children, Branch Angle ~110, negative Growth Force. Branching generator only. Construction-only.',
|
|
1675
|
+
label: 'Conifer',
|
|
1676
|
+
type: 'boolean',
|
|
1677
|
+
},
|
|
1678
|
+
attractionCount: {
|
|
1679
|
+
description: 'Number of crown attraction points the limbs grow toward; more points grow more, finer limbs. Construction-only.',
|
|
1680
|
+
label: 'Attraction Count',
|
|
1681
|
+
range: { max: 200, min: 10, step: 1 },
|
|
1682
|
+
type: 'number',
|
|
1683
|
+
},
|
|
1684
|
+
segmentLength: {
|
|
1685
|
+
description: 'Growth step length in meters; shorter steps grow smoother, curvier limbs. Construction-only.',
|
|
1686
|
+
label: 'Segment Length',
|
|
1687
|
+
range: { max: 0.8, min: 0.1, step: 0.01 },
|
|
1688
|
+
type: 'number',
|
|
1689
|
+
},
|
|
1690
|
+
influenceRadius: {
|
|
1691
|
+
description: 'How far an attraction point can pull on a growing limb, in meters. Construction-only.',
|
|
1692
|
+
label: 'Influence Radius',
|
|
1693
|
+
range: { max: 2.5, min: 0.3, step: 0.05 },
|
|
1694
|
+
type: 'number',
|
|
1695
|
+
},
|
|
1696
|
+
killRadius: {
|
|
1697
|
+
description: 'Distance at which a limb consumes an attraction point and stops growing toward it. Construction-only.',
|
|
1698
|
+
label: 'Kill Radius',
|
|
1699
|
+
range: { max: 1, min: 0.1, step: 0.01 },
|
|
1700
|
+
type: 'number',
|
|
1701
|
+
},
|
|
1702
|
+
maxSteps: {
|
|
1703
|
+
description: 'Growth iteration cap. Construction-only.',
|
|
1704
|
+
label: 'Max Steps',
|
|
1705
|
+
range: { max: 96, min: 4, step: 1 },
|
|
1706
|
+
type: 'number',
|
|
1707
|
+
},
|
|
1708
|
+
maxNodes: {
|
|
1709
|
+
description: 'Skeleton node cap; lower keeps trees to a few clean limbs. Construction-only.',
|
|
1710
|
+
label: 'Max Nodes',
|
|
1711
|
+
range: { max: 400, min: 20, step: 1 },
|
|
1712
|
+
type: 'number',
|
|
1713
|
+
},
|
|
1714
|
+
radialSegments: {
|
|
1715
|
+
description: 'Cross-section segment count of each bark tube. Construction-only.',
|
|
1716
|
+
label: 'Radial Segments',
|
|
1717
|
+
range: { max: 16, min: 3, step: 1 },
|
|
1718
|
+
type: 'number',
|
|
1719
|
+
},
|
|
1720
|
+
tipRadius: {
|
|
1721
|
+
description: 'Radius of the thinnest twigs in meters; pipe-model radii grow from here toward the root. Construction-only.',
|
|
1722
|
+
label: 'Tip Radius',
|
|
1723
|
+
range: { max: 0.15, min: 0.005, step: 0.001 },
|
|
1724
|
+
type: 'number',
|
|
1725
|
+
},
|
|
1726
|
+
minLimbRadius: {
|
|
1727
|
+
description: 'Limbs thinner than this get no bark tube and are left to the leaves. Construction-only.',
|
|
1728
|
+
label: 'Min Limb Radius',
|
|
1729
|
+
range: { max: 0.15, min: 0, step: 0.001 },
|
|
1730
|
+
type: 'number',
|
|
1731
|
+
},
|
|
1732
|
+
attachmentTwigRadius: {
|
|
1733
|
+
description: 'Wood thinner than this sprouts leaf tufts in canopy mode. Construction-only.',
|
|
1734
|
+
label: 'Attachment Twig Radius',
|
|
1735
|
+
range: { max: 0.3, min: 0, step: 0.005 },
|
|
1736
|
+
type: 'number',
|
|
1737
|
+
},
|
|
1738
|
+
attractionReach: {
|
|
1739
|
+
description: 'How deep into each crown blob attraction points sample (fraction of blob radius); null/unset is automatic (0.65 canopy mode, 0.92 tips mode). Construction-only.',
|
|
1740
|
+
label: 'Attraction Reach',
|
|
1741
|
+
range: { max: 1, min: 0, step: 0.01 },
|
|
1742
|
+
type: 'number',
|
|
1743
|
+
},
|
|
1744
|
+
},
|
|
1745
|
+
canopy: {
|
|
1746
|
+
cardCount: {
|
|
1747
|
+
description: 'Base leaf-card count before density and coverage scaling; few LARGE overlapping cards keep the crown one fluffy mass. Construction-only.',
|
|
1748
|
+
label: 'Card Count',
|
|
1749
|
+
range: { max: 600, min: 20, step: 1 },
|
|
1750
|
+
type: 'number',
|
|
1751
|
+
},
|
|
1752
|
+
cardSizeRange: {
|
|
1753
|
+
description: 'Min/max leaf-cluster card size in meters. Construction-only.',
|
|
1754
|
+
label: 'Card Size Range',
|
|
1755
|
+
type: 'vector2',
|
|
1756
|
+
},
|
|
1757
|
+
cardsPerCluster: {
|
|
1758
|
+
description: 'Cards per leaf tuft around each branch attachment. Construction-only. (In tips placement the built-in default becomes 9.)',
|
|
1759
|
+
label: 'Cards Per Cluster',
|
|
1760
|
+
range: { max: 20, min: 1, step: 1 },
|
|
1761
|
+
type: 'number',
|
|
1762
|
+
},
|
|
1763
|
+
clusterRadius: {
|
|
1764
|
+
description: 'Radius in meters of each leaf tuft around its branch end. Construction-only. (In tips placement the built-in default becomes 0.62.)',
|
|
1765
|
+
label: 'Cluster Radius',
|
|
1766
|
+
range: { max: 1.5, min: 0.1, step: 0.01 },
|
|
1767
|
+
type: 'number',
|
|
1768
|
+
},
|
|
1769
|
+
shellFill: {
|
|
1770
|
+
description: 'Fill the blob shells between tufts so the crown reads as one solid mass; off leaves bare wood between end bushes. Construction-only. (Tips placement turns this off by default.)',
|
|
1771
|
+
label: 'Shell Fill',
|
|
1772
|
+
type: 'boolean',
|
|
1773
|
+
},
|
|
1774
|
+
},
|
|
1775
|
+
foliage: {
|
|
1776
|
+
alphaCutoff: {
|
|
1777
|
+
description: 'Alpha-cutout threshold for the leaf sprite; low enough that mipmap-averaged alpha does not erode distant crowns.',
|
|
1778
|
+
label: 'Alpha Cutoff',
|
|
1779
|
+
range: { max: 1, min: 0, step: 0.01 },
|
|
1780
|
+
type: 'number',
|
|
1781
|
+
},
|
|
1782
|
+
windDirection: {
|
|
1783
|
+
description: 'Horizontal (XZ) heading the canopy flutter drifts toward.',
|
|
1784
|
+
label: 'Wind Direction',
|
|
1785
|
+
type: 'vector2',
|
|
1786
|
+
},
|
|
1787
|
+
windSpeed: {
|
|
1788
|
+
description: 'How fast the leaf-card flutter oscillates.',
|
|
1789
|
+
label: 'Wind Speed',
|
|
1790
|
+
range: { max: 4, min: 0, step: 0.01 },
|
|
1791
|
+
type: 'number',
|
|
1792
|
+
},
|
|
1793
|
+
windStrength: {
|
|
1794
|
+
description: 'How far leaf cards sway with the wind.',
|
|
1795
|
+
label: 'Wind Strength',
|
|
1796
|
+
range: { max: 0.5, min: 0, step: 0.005 },
|
|
1797
|
+
type: 'number',
|
|
1798
|
+
},
|
|
1799
|
+
sunDirection: {
|
|
1800
|
+
description: 'World-space direction toward the sun. Match your main directional light.',
|
|
1801
|
+
label: 'Sun Direction',
|
|
1802
|
+
type: 'vector3',
|
|
1803
|
+
},
|
|
1804
|
+
sunColor: {
|
|
1805
|
+
description: 'Sunlight tint applied to lit leaf cards.',
|
|
1806
|
+
label: 'Sun Color',
|
|
1807
|
+
type: 'color',
|
|
1808
|
+
},
|
|
1809
|
+
skyColor: {
|
|
1810
|
+
description: 'Ambient sky tint mixed into shaded leaf cards.',
|
|
1811
|
+
label: 'Sky Color',
|
|
1812
|
+
type: 'color',
|
|
1813
|
+
},
|
|
1814
|
+
sceneShadowStrength: {
|
|
1815
|
+
description: 'How strongly renderer shadow maps shift the crown toward its shadow palette. 0 disables.',
|
|
1816
|
+
label: 'Scene Shadow Strength',
|
|
1817
|
+
range: { max: 1, min: 0, step: 0.01 },
|
|
1818
|
+
type: 'number',
|
|
1819
|
+
},
|
|
1820
|
+
backlitStrength: {
|
|
1821
|
+
description: 'Translucent glow on leaves between the camera and the sun.',
|
|
1822
|
+
label: 'Backlit Strength',
|
|
1823
|
+
range: { max: 2, min: 0, step: 0.01 },
|
|
1824
|
+
type: 'number',
|
|
1825
|
+
},
|
|
1826
|
+
cloudShadowStrength: {
|
|
1827
|
+
description: 'How strongly drifting procedural cloud shadows darken the crown. 0 disables the effect.',
|
|
1828
|
+
label: 'Cloud Shadow Strength',
|
|
1829
|
+
range: { max: 1, min: 0, step: 0.01 },
|
|
1830
|
+
type: 'number',
|
|
1831
|
+
},
|
|
1832
|
+
cloudShadowCoverage: {
|
|
1833
|
+
description: 'Fraction of the world covered by cloud shadow at any moment.',
|
|
1834
|
+
label: 'Cloud Shadow Coverage',
|
|
1835
|
+
range: { max: 1, min: 0, step: 0.01 },
|
|
1836
|
+
type: 'number',
|
|
1837
|
+
},
|
|
1838
|
+
cloudShadowScale: {
|
|
1839
|
+
description: 'World-to-noise scale of the cloud shadow pattern; smaller values give larger cloud shapes.',
|
|
1840
|
+
label: 'Cloud Shadow Scale',
|
|
1841
|
+
range: { max: 0.1, min: 0.001, step: 0.001 },
|
|
1842
|
+
type: 'number',
|
|
1843
|
+
},
|
|
1844
|
+
cloudShadowVelocity: {
|
|
1845
|
+
description: 'Cloud shadow drift in noise-space units per second (world drift = velocity / scale).',
|
|
1846
|
+
label: 'Cloud Shadow Velocity',
|
|
1847
|
+
type: 'vector2',
|
|
1848
|
+
},
|
|
1849
|
+
},
|
|
1850
|
+
});
|
|
1851
|
+
|
|
1852
|
+
function createTreeFieldMetadata(group, key, field) {
|
|
1853
|
+
const defaultValue = DEFAULT_STYLIZED_TREE_SETTINGS[group.id][key];
|
|
1854
|
+
return Object.freeze({
|
|
1855
|
+
defaultValue: Array.isArray(defaultValue) ? [...defaultValue] : defaultValue,
|
|
1856
|
+
description: field.description,
|
|
1857
|
+
group: group.id,
|
|
1858
|
+
id: `${group.id}.${key}`,
|
|
1859
|
+
key,
|
|
1860
|
+
label: field.label,
|
|
1861
|
+
optionLabels: field.optionLabels ?? null,
|
|
1862
|
+
options: field.options ?? null,
|
|
1863
|
+
range: field.range ?? null,
|
|
1864
|
+
serializable: field.serializable ?? true,
|
|
1865
|
+
type: field.type,
|
|
1866
|
+
});
|
|
1867
|
+
}
|
|
1868
|
+
|
|
1869
|
+
/**
|
|
1870
|
+
* Field metadata (id/group/key/label/description/type/range/defaultValue/
|
|
1871
|
+
* serializable) per settings group, in the shape consumed by
|
|
1872
|
+
* `createSettingsPanel`. Group ids and keys match
|
|
1873
|
+
* {@link DEFAULT_STYLIZED_TREE_SETTINGS}.
|
|
1874
|
+
*/
|
|
1875
|
+
export const STYLIZED_TREE_SETTING_FIELD_SCHEMA = Object.freeze(
|
|
1876
|
+
Object.fromEntries(
|
|
1877
|
+
STYLIZED_TREE_SETTING_GROUPS.map((group) => [
|
|
1878
|
+
group.id,
|
|
1879
|
+
Object.freeze(
|
|
1880
|
+
Object.fromEntries(
|
|
1881
|
+
Object.entries(STYLIZED_TREE_FIELD_DEFINITIONS[group.id] ?? {})
|
|
1882
|
+
.map(([key, field]) => [key, createTreeFieldMetadata(group, key, field)]),
|
|
1883
|
+
),
|
|
1884
|
+
),
|
|
1885
|
+
]),
|
|
1886
|
+
),
|
|
1887
|
+
);
|
|
1888
|
+
|
|
1889
|
+
function sameSettingValue(a, b) {
|
|
1890
|
+
if (Array.isArray(a) && Array.isArray(b)) {
|
|
1891
|
+
return a.length === b.length && a.every((value, index) => value === b[index]);
|
|
1892
|
+
}
|
|
1893
|
+
return a === b;
|
|
1894
|
+
}
|
|
1895
|
+
|
|
1896
|
+
// Complete tree: curved trunk + leaf-card canopy in one Object3D.
|
|
1897
|
+
// All options forwarded, all optional:
|
|
1898
|
+
// size — overall multiplier (1 ≈ 3 m tree, 2 ≈ 6 m, 3+ large)
|
|
1899
|
+
// canopyColor — any resolveCanopyColor spec: one color, a list to pick
|
|
1900
|
+
// from, { from, to } blend, or HSL ranges — resolved
|
|
1901
|
+
// deterministically per seed, so a forest built from one
|
|
1902
|
+
// spec gets stable per-tree variation
|
|
1903
|
+
// canopyPalette— pin { lit, shadow, crown } tones explicitly (partial ok)
|
|
1904
|
+
// canopyWidth / canopyDepth — X / Z crown reach multipliers (a 1.6 / 0.7
|
|
1905
|
+
// tree is wide from the front but shallow from the side)
|
|
1906
|
+
// leafDensity — 0..1 crown coverage; below ~0.9 see-through gaps open
|
|
1907
|
+
// leafPlacement— 'canopy' (default): solid leaf mass hiding interior wood;
|
|
1908
|
+
// 'tips': bushes only at the branch ends, long bare limbs
|
|
1909
|
+
// on show between them (Sumeru-style silhouettes)
|
|
1910
|
+
// trunk — createTreeTrunkGeometry options (or a TREE_TRUNK_STYLES spread)
|
|
1911
|
+
// skeleton — createTreeSkeleton options (limbsPerBlob, limbRadius, ...)
|
|
1912
|
+
// canopy — createTreeFoliageGeometry options (blobs, cardCount, ...)
|
|
1913
|
+
// foliage — createTreeFoliageMaterials options (cutoff, wind, sun, ...)
|
|
1914
|
+
// trunkMaterial— any THREE material; default is a warm MeshToonMaterial
|
|
1915
|
+
export class StylizedTree extends THREE.Group {
|
|
1916
|
+
constructor(options = {}) {
|
|
1917
|
+
super();
|
|
1918
|
+
// Sketch-tool data rides beside the grouped settings (all plain JSON,
|
|
1919
|
+
// serialized by toJSON, so hand-drawn recipes stay deterministic):
|
|
1920
|
+
// branchSpines — [{ points, radiusStart, radiusEnd, leafTip }]:
|
|
1921
|
+
// bark tubes swept along drawn curves (tree-local,
|
|
1922
|
+
// pre-size); leafTip !== false grows a leaf tuft at
|
|
1923
|
+
// the end via the mandatory-attachment pass
|
|
1924
|
+
// extraBlobs — [{ offset, radius }] canopy-local blobs appended
|
|
1925
|
+
// to the layout (closed-silhouette fills)
|
|
1926
|
+
// extraAttachments — [{ position, direction }] canopy-local leaf tufts
|
|
1927
|
+
// along an open drawn stroke
|
|
1928
|
+
// branchOverrides — { [attachmentIndex]: { cardsPerCluster?,
|
|
1929
|
+
// clusterRadius?, densityScale? } }: per-branch
|
|
1930
|
+
// foliage tuft overrides (Tree Lab's branch
|
|
1931
|
+
// inspector). Indices follow generation order, so
|
|
1932
|
+
// they are stable per seed + skeleton settings.
|
|
1933
|
+
// leafShape — { preset: 'teardrop'|'round'|'maple'|'gingko'|
|
|
1934
|
+
// 'needle'|'custom', outline?: [[x,y],...] }: the
|
|
1935
|
+
// single-leaf silhouette the crown sprite (and the
|
|
1936
|
+
// designer's falling-leaf particles) stamp from.
|
|
1937
|
+
// Plain JSON; an explicit foliage.leafMap wins.
|
|
1938
|
+
// roots — { preset: 'none'|'small'|'medium'|'large' }:
|
|
1939
|
+
// procedural surface roots radiating from the base
|
|
1940
|
+
// collar (a complete tree meets the ground).
|
|
1941
|
+
const {
|
|
1942
|
+
trunkMaterial = null,
|
|
1943
|
+
branchSpines = [],
|
|
1944
|
+
extraBlobs = [],
|
|
1945
|
+
extraAttachments = [],
|
|
1946
|
+
branchOverrides = null,
|
|
1947
|
+
leafShape = null,
|
|
1948
|
+
roots = null,
|
|
1949
|
+
trunkProfile = null,
|
|
1950
|
+
woodDetails = null,
|
|
1951
|
+
} = cleanObject(options);
|
|
1952
|
+
const settings = createStylizedTreeSettings(options);
|
|
1953
|
+
const {
|
|
1954
|
+
size,
|
|
1955
|
+
seed,
|
|
1956
|
+
canopyColor,
|
|
1957
|
+
canopyPalette,
|
|
1958
|
+
canopyWidth,
|
|
1959
|
+
canopyDepth,
|
|
1960
|
+
leafDensity,
|
|
1961
|
+
canopyScale,
|
|
1962
|
+
leafPlacement,
|
|
1963
|
+
// Massive pale-limbed trees read better without the canopy shadow-mapping
|
|
1964
|
+
// onto their own wood (the anime look keeps exposed limbs bright).
|
|
1965
|
+
trunkReceiveShadow,
|
|
1966
|
+
// createCanopyBlobs options (lobeCount, spread, flatten, coreRadius, ...)
|
|
1967
|
+
// so the crown layout stays declarative in recipes; pinning explicit
|
|
1968
|
+
// blobs via canopy.blobs still wins.
|
|
1969
|
+
canopyLayout,
|
|
1970
|
+
} = settings.tree;
|
|
1971
|
+
this.name = 'StylizedTree';
|
|
1972
|
+
// Kept for toJSON() so the tree can serialize itself into a recipe.
|
|
1973
|
+
// Not cloned; mutating it after construction is undefined behavior.
|
|
1974
|
+
this.config = options;
|
|
1975
|
+
// Fully-resolved grouped settings (see DEFAULT_STYLIZED_TREE_SETTINGS); the
|
|
1976
|
+
// runtime-applicable slice can be re-tuned via applySettings().
|
|
1977
|
+
this.settings = settings;
|
|
1978
|
+
|
|
1979
|
+
// Every tree gets its own irregular, wider-than-tall crown layout unless
|
|
1980
|
+
// the caller pins an explicit blob set. Sketch blobs extend the layout,
|
|
1981
|
+
// steering both the skeleton growth and the leaf-card fill.
|
|
1982
|
+
const blobs = [
|
|
1983
|
+
...(settings.canopy.blobs ??
|
|
1984
|
+
createCanopyBlobs({ seed, width: canopyWidth, depth: canopyDepth, ...canopyLayout })),
|
|
1985
|
+
...extraBlobs,
|
|
1986
|
+
];
|
|
1987
|
+
// skeleton.generator picks the wood: 'limbs' grows toward the crown
|
|
1988
|
+
// blobs (solid anime crowns), 'branching' recurses a central leader
|
|
1989
|
+
// (open realistic silhouettes), 'drawn' skips procedural wood
|
|
1990
|
+
// entirely — the tree is exactly the hand-drawn branchSpines.
|
|
1991
|
+
const generator = settings.skeleton.generator;
|
|
1992
|
+
const trunkResult = generator === 'branching'
|
|
1993
|
+
? createBranchingTreeSkeleton({
|
|
1994
|
+
seed,
|
|
1995
|
+
trunk: settings.trunk,
|
|
1996
|
+
canopyScale,
|
|
1997
|
+
...settings.skeleton,
|
|
1998
|
+
})
|
|
1999
|
+
: generator === 'drawn'
|
|
2000
|
+
? { geometry: null, canopyAnchor: null, attachments: [] }
|
|
2001
|
+
: createTreeSkeleton({
|
|
2002
|
+
seed,
|
|
2003
|
+
trunk: settings.trunk,
|
|
2004
|
+
canopyScale,
|
|
2005
|
+
blobs,
|
|
2006
|
+
leafPlacement,
|
|
2007
|
+
...settings.skeleton,
|
|
2008
|
+
});
|
|
2009
|
+
|
|
2010
|
+
// Hand-drawn branches: sweep a bark tube along each spine; leaf-tipped
|
|
2011
|
+
// spines contribute a mandatory tuft attachment so leaves engulf the
|
|
2012
|
+
// drawn branch end automatically. Swept before attachment conversion
|
|
2013
|
+
// because in drawn mode the crown anchor derives from the spine tips.
|
|
2014
|
+
const spineTubes = [];
|
|
2015
|
+
// grow:true spines (Grow from Doodle on an existing tree) sprout a mini
|
|
2016
|
+
// EZ-style skeleton ALONG the stroke — sub-branches + foliage — instead
|
|
2017
|
+
// of a bare tube, composing with whatever generator built the trunk.
|
|
2018
|
+
const grownSpines = [];
|
|
2019
|
+
for (const spine of branchSpines) {
|
|
2020
|
+
if (spine.grow) {
|
|
2021
|
+
grownSpines.push(createBranchingTreeSkeleton({
|
|
2022
|
+
trunk: { radiusBottom: Math.max(spine.radiusStart ?? 0.06, 0.02) },
|
|
2023
|
+
trunkSpine: spine.points,
|
|
2024
|
+
seed: seed * 3.7 + grownSpines.length * 11.3,
|
|
2025
|
+
canopyScale: 1,
|
|
2026
|
+
levels: 2,
|
|
2027
|
+
childrenCount: 3,
|
|
2028
|
+
branchStart: 0.3,
|
|
2029
|
+
branchAngle: settings.skeleton.branchAngle,
|
|
2030
|
+
lengthRatio: settings.skeleton.lengthRatio,
|
|
2031
|
+
radiusRatio: settings.skeleton.radiusRatio,
|
|
2032
|
+
gnarliness: settings.skeleton.gnarliness,
|
|
2033
|
+
forceStrength: settings.skeleton.forceStrength,
|
|
2034
|
+
radialSegments: settings.skeleton.radialSegments,
|
|
2035
|
+
tipRadius: settings.skeleton.tipRadius,
|
|
2036
|
+
maxBranches: 60,
|
|
2037
|
+
maxAttachments: 70,
|
|
2038
|
+
}));
|
|
2039
|
+
continue;
|
|
2040
|
+
}
|
|
2041
|
+
// Grounded stems grow FROM the earth: sink the base below grade (no
|
|
2042
|
+
// visible cap, ever) and flare the first rings like a real root
|
|
2043
|
+
// collar. Branch spines starting on wood are untouched.
|
|
2044
|
+
const grounded = spine.points[0][1] <= 0.02;
|
|
2045
|
+
const points = grounded
|
|
2046
|
+
? [
|
|
2047
|
+
[spine.points[0][0], -0.12, spine.points[0][2]],
|
|
2048
|
+
...spine.points,
|
|
2049
|
+
]
|
|
2050
|
+
: spine.points;
|
|
2051
|
+
const tube = createBranchTubeGeometry({
|
|
2052
|
+
radialSegments: settings.skeleton.radialSegments,
|
|
2053
|
+
...spine,
|
|
2054
|
+
flareBase: grounded,
|
|
2055
|
+
points,
|
|
2056
|
+
// A drawn trunk cross-section applies to grounded stems only.
|
|
2057
|
+
...(grounded && trunkProfile?.outline
|
|
2058
|
+
? { profile: polarProfileFromOutline(trunkProfile.outline) }
|
|
2059
|
+
: {}),
|
|
2060
|
+
});
|
|
2061
|
+
if (tube) spineTubes.push({ spine, tube });
|
|
2062
|
+
}
|
|
2063
|
+
// Drawn mode anchors the canopy at the ORIGIN (canopy-local equals
|
|
2064
|
+
// tree-local): scribbled foliage and tuft positions stay stable no
|
|
2065
|
+
// matter what wood is drawn later.
|
|
2066
|
+
const canopyAnchor = trunkResult.canopyAnchor ?? new THREE.Vector3(0, 0, 0);
|
|
2067
|
+
const attachments = [...trunkResult.attachments];
|
|
2068
|
+
for (const { spine, tube } of spineTubes) {
|
|
2069
|
+
if (spine.leafTip !== false) {
|
|
2070
|
+
attachments.push({
|
|
2071
|
+
position: tube.tip.clone().sub(canopyAnchor).divideScalar(canopyScale),
|
|
2072
|
+
direction: tube.tipTangent,
|
|
2073
|
+
});
|
|
2074
|
+
}
|
|
2075
|
+
}
|
|
2076
|
+
for (const grown of grownSpines) {
|
|
2077
|
+
for (const attachment of grown.attachments) {
|
|
2078
|
+
attachments.push({
|
|
2079
|
+
position: attachment.position.clone().add(grown.canopyAnchor)
|
|
2080
|
+
.sub(canopyAnchor).divideScalar(canopyScale),
|
|
2081
|
+
direction: attachment.direction,
|
|
2082
|
+
});
|
|
2083
|
+
}
|
|
2084
|
+
}
|
|
2085
|
+
for (const extra of extraAttachments) {
|
|
2086
|
+
attachments.push({
|
|
2087
|
+
position: new THREE.Vector3(...extra.position),
|
|
2088
|
+
direction: extra.direction
|
|
2089
|
+
? new THREE.Vector3(...extra.direction).normalize()
|
|
2090
|
+
: new THREE.Vector3(0, 1, 0),
|
|
2091
|
+
});
|
|
2092
|
+
}
|
|
2093
|
+
|
|
2094
|
+
const woodPieces = [
|
|
2095
|
+
...(trunkResult.geometry ? [trunkResult.geometry] : []),
|
|
2096
|
+
...spineTubes.map(({ tube }) => tube.geometry),
|
|
2097
|
+
...grownSpines.map((grown) => grown.geometry),
|
|
2098
|
+
];
|
|
2099
|
+
|
|
2100
|
+
// Surface roots: seeded tubes arcing out from the base collar and
|
|
2101
|
+
// burying their tips. Scaled off the trunk radius so they stay
|
|
2102
|
+
// proportionate for every tree size.
|
|
2103
|
+
const ROOT_PRESETS = {
|
|
2104
|
+
large: { count: 7, length: 5.5, radius: 0.62 },
|
|
2105
|
+
medium: { count: 5, length: 4.2, radius: 0.52 },
|
|
2106
|
+
small: { count: 4, length: 3, radius: 0.42 },
|
|
2107
|
+
};
|
|
2108
|
+
// Hand-drawn top-down layout: each drawn path IS one root — direction,
|
|
2109
|
+
// bend, and length exactly as authored ([-1,1] plan space around the
|
|
2110
|
+
// trunk; 1.0 spans ~6 trunk radii, matching the preset footprint).
|
|
2111
|
+
if (roots?.preset === 'custom' && Array.isArray(roots.paths) && roots.paths.length) {
|
|
2112
|
+
const groundedSpines = branchSpines.filter((spine) => spine.points[0][1] <= 0.02);
|
|
2113
|
+
const baseRadius = generator === 'drawn'
|
|
2114
|
+
? Math.max(0.08, ...groundedSpines.map((spine) => spine.radiusStart), 0.08)
|
|
2115
|
+
: settings.trunk.radiusBottom;
|
|
2116
|
+
const baseX = generator === 'drawn' && groundedSpines.length
|
|
2117
|
+
? groundedSpines[0].points[0][0] : 0;
|
|
2118
|
+
const baseZ = generator === 'drawn' && groundedSpines.length
|
|
2119
|
+
? groundedSpines[0].points[0][2] : 0;
|
|
2120
|
+
const collarY = Math.min(baseRadius * 0.5, 0.14);
|
|
2121
|
+
const planScale = baseRadius * 6;
|
|
2122
|
+
for (let i = 0; i < roots.paths.length; i += 1) {
|
|
2123
|
+
const path = roots.paths[i];
|
|
2124
|
+
// Anchor at the collar regardless of where the stroke began, then
|
|
2125
|
+
// follow the drawn plan; height eases collar -> grade -> buried tip.
|
|
2126
|
+
const points = [[baseX, collarY, baseZ]];
|
|
2127
|
+
for (let j = 0; j < path.length; j += 1) {
|
|
2128
|
+
const t = (j + 1) / path.length;
|
|
2129
|
+
points.push([
|
|
2130
|
+
baseX + path[j][0] * planScale,
|
|
2131
|
+
collarY * Math.max(0, 1 - t * 2.2) - 0.18 * Math.max(0, t - 0.55) / 0.45,
|
|
2132
|
+
baseZ + path[j][1] * planScale,
|
|
2133
|
+
]);
|
|
2134
|
+
}
|
|
2135
|
+
const tube = createBranchTubeGeometry({
|
|
2136
|
+
flareBase: false,
|
|
2137
|
+
irregularity: 0.2,
|
|
2138
|
+
points,
|
|
2139
|
+
radialSegments: 7,
|
|
2140
|
+
radiusEnd: baseRadius * 0.5 * 0.4,
|
|
2141
|
+
radiusStart: baseRadius * 0.5,
|
|
2142
|
+
seed: seed + i * 13,
|
|
2143
|
+
});
|
|
2144
|
+
if (tube) woodPieces.push(tube.geometry);
|
|
2145
|
+
}
|
|
2146
|
+
}
|
|
2147
|
+
const rootSpec = ROOT_PRESETS[roots?.preset];
|
|
2148
|
+
if (rootSpec) {
|
|
2149
|
+
const groundedSpines = branchSpines.filter((spine) => spine.points[0][1] <= 0.02);
|
|
2150
|
+
const baseRadius = generator === 'drawn'
|
|
2151
|
+
? Math.max(0.08, ...groundedSpines.map((spine) => spine.radiusStart))
|
|
2152
|
+
: settings.trunk.radiusBottom;
|
|
2153
|
+
const baseX = generator === 'drawn' && groundedSpines.length
|
|
2154
|
+
? groundedSpines[0].points[0][0] : 0;
|
|
2155
|
+
const baseZ = generator === 'drawn' && groundedSpines.length
|
|
2156
|
+
? groundedSpines[0].points[0][2] : 0;
|
|
2157
|
+
const rootRng = seededRandom(seed + 31);
|
|
2158
|
+
// Roots emerge AT THE BOTTOM of the trunk (the collar sits just above
|
|
2159
|
+
// grade regardless of trunk thickness) and hug the ground on the way
|
|
2160
|
+
// out, tips buried.
|
|
2161
|
+
const collarY = Math.min(baseRadius * 0.5, 0.14);
|
|
2162
|
+
for (let i = 0; i < rootSpec.count; i += 1) {
|
|
2163
|
+
const azimuth = (i / rootSpec.count) * Math.PI * 2 + rootRng(i) * 0.8;
|
|
2164
|
+
const length = baseRadius * rootSpec.length * (0.75 + rootRng(i + 40) * 0.5);
|
|
2165
|
+
// Serpentine drift so roots snake instead of radiating like spokes.
|
|
2166
|
+
const drift = (rootRng(i + 70) - 0.5) * 0.9;
|
|
2167
|
+
const pointAt = (t, y) => {
|
|
2168
|
+
const bend = azimuth + drift * t * t;
|
|
2169
|
+
return [
|
|
2170
|
+
baseX + Math.cos(bend) * (baseRadius * 0.2 + length * t),
|
|
2171
|
+
y,
|
|
2172
|
+
baseZ + Math.sin(bend) * (baseRadius * 0.2 + length * t),
|
|
2173
|
+
];
|
|
2174
|
+
};
|
|
2175
|
+
const tube = createBranchTubeGeometry({
|
|
2176
|
+
flareBase: false,
|
|
2177
|
+
irregularity: 0.2,
|
|
2178
|
+
points: [
|
|
2179
|
+
pointAt(0, collarY),
|
|
2180
|
+
pointAt(0.2, collarY * 0.45),
|
|
2181
|
+
pointAt(0.4, 0.0),
|
|
2182
|
+
pointAt(0.6, -0.03),
|
|
2183
|
+
pointAt(0.8, -0.07),
|
|
2184
|
+
pointAt(1, -0.18),
|
|
2185
|
+
],
|
|
2186
|
+
radialSegments: 7,
|
|
2187
|
+
radiusEnd: baseRadius * rootSpec.radius * 0.4,
|
|
2188
|
+
radiusStart: baseRadius * rootSpec.radius,
|
|
2189
|
+
seed: seed + i * 13,
|
|
2190
|
+
});
|
|
2191
|
+
if (tube) woodPieces.push(tube.geometry);
|
|
2192
|
+
}
|
|
2193
|
+
}
|
|
2194
|
+
|
|
2195
|
+
// Wood details: seeded knots (squashed bulges) and scar welts (tall thin
|
|
2196
|
+
// ridges) embedded in the lower trunk, so trunks never look
|
|
2197
|
+
// factory-perfect. Centers sit inside the trunk surface — details can
|
|
2198
|
+
// bulge, never float.
|
|
2199
|
+
const knotAmount = Math.max(0, Math.min(1, woodDetails?.knots ?? 0));
|
|
2200
|
+
const scarAmount = Math.max(0, Math.min(1, woodDetails?.scars ?? 0));
|
|
2201
|
+
if ((knotAmount > 0 || scarAmount > 0) && woodPieces.length) {
|
|
2202
|
+
const groundedSpines = branchSpines.filter((spine) => spine.points[0][1] <= 0.02);
|
|
2203
|
+
const detailBaseRadius = generator === 'drawn'
|
|
2204
|
+
? Math.max(0.08, ...groundedSpines.map((spine) => spine.radiusStart), 0.08)
|
|
2205
|
+
: settings.trunk.radiusBottom;
|
|
2206
|
+
const detailBaseX = generator === 'drawn' && groundedSpines.length
|
|
2207
|
+
? groundedSpines[0].points[0][0] : 0;
|
|
2208
|
+
const detailBaseZ = generator === 'drawn' && groundedSpines.length
|
|
2209
|
+
? groundedSpines[0].points[0][2] : 0;
|
|
2210
|
+
const trunkSpan = generator === 'drawn' && groundedSpines.length
|
|
2211
|
+
? Math.max(0.6, ...groundedSpines[0].points.map((point) => point[1]))
|
|
2212
|
+
: settings.trunk.height;
|
|
2213
|
+
const detailRng = seededRandom(seed + 57);
|
|
2214
|
+
const placeDetail = (index, scaleVec) => {
|
|
2215
|
+
const azimuth = detailRng(index) * Math.PI * 2;
|
|
2216
|
+
const t = 0.08 + detailRng(index + 100) * 0.45;
|
|
2217
|
+
const y = detailBaseRadius * 0.4 + t * trunkSpan * 0.5;
|
|
2218
|
+
// Local trunk radius estimate (taper + base flare); embed the center
|
|
2219
|
+
// at 80% of it so the bump always pokes out of, and stays glued to,
|
|
2220
|
+
// the bark.
|
|
2221
|
+
const localRadius = detailBaseRadius * (1 - 0.3 * t)
|
|
2222
|
+
* (y < detailBaseRadius ? 1.18 : 1);
|
|
2223
|
+
const size = localRadius * (0.35 + detailRng(index + 200) * 0.3);
|
|
2224
|
+
const bump = new THREE.SphereGeometry(size, 8, 6);
|
|
2225
|
+
bump.scale(...scaleVec);
|
|
2226
|
+
const normal = new THREE.Vector3(Math.cos(azimuth), 0.12, Math.sin(azimuth)).normalize();
|
|
2227
|
+
bump.lookAt(normal);
|
|
2228
|
+
bump.rotateZ(detailRng(index + 300) * Math.PI);
|
|
2229
|
+
bump.translate(
|
|
2230
|
+
detailBaseX + Math.cos(azimuth) * localRadius * 0.8,
|
|
2231
|
+
y,
|
|
2232
|
+
detailBaseZ + Math.sin(azimuth) * localRadius * 0.8,
|
|
2233
|
+
);
|
|
2234
|
+
woodPieces.push(bump);
|
|
2235
|
+
};
|
|
2236
|
+
const knotCount = Math.round(knotAmount * 8);
|
|
2237
|
+
for (let i = 0; i < knotCount; i += 1) placeDetail(i, [1, 1, 0.75]);
|
|
2238
|
+
const scarCount = Math.round(scarAmount * 5);
|
|
2239
|
+
for (let i = 0; i < scarCount; i += 1) placeDetail(i + 50, [0.35, 2.4, 0.4]);
|
|
2240
|
+
}
|
|
2241
|
+
let trunkGeometry;
|
|
2242
|
+
if (!woodPieces.length) {
|
|
2243
|
+
// Blank hand-drawn tree: a sapling stub so there's something to see
|
|
2244
|
+
// before the first stroke.
|
|
2245
|
+
trunkGeometry = new THREE.CylinderGeometry(0.02, 0.035, 0.3, 6);
|
|
2246
|
+
trunkGeometry.translate(0, 0.15, 0);
|
|
2247
|
+
} else if (woodPieces.length === 1) {
|
|
2248
|
+
trunkGeometry = woodPieces[0];
|
|
2249
|
+
} else {
|
|
2250
|
+
trunkGeometry = mergeGeometries(woodPieces);
|
|
2251
|
+
woodPieces.forEach((piece) => piece.dispose());
|
|
2252
|
+
}
|
|
2253
|
+
// Only forward canopy values that differ from the defaults, so the
|
|
2254
|
+
// tips-placement geometry presets below keep winning unless the caller
|
|
2255
|
+
// explicitly overrides them (exactly the legacy sparse-options behavior).
|
|
2256
|
+
const canopyOverrides = {};
|
|
2257
|
+
for (const [key, value] of Object.entries(settings.canopy)) {
|
|
2258
|
+
if (!(key in DEFAULT_STYLIZED_TREE_SETTINGS.canopy) ||
|
|
2259
|
+
!sameSettingValue(value, DEFAULT_STYLIZED_TREE_SETTINGS.canopy[key])) {
|
|
2260
|
+
canopyOverrides[key] = value;
|
|
2261
|
+
}
|
|
2262
|
+
}
|
|
2263
|
+
const canopyGeometry = createTreeFoliageGeometry({
|
|
2264
|
+
seed: seed * 7.31 + 1.7,
|
|
2265
|
+
leafDensity,
|
|
2266
|
+
attachments,
|
|
2267
|
+
// The group is scaled by `size`; cards must stay leaf-sized, so the
|
|
2268
|
+
// geometry densifies instead (see coverageScale).
|
|
2269
|
+
coverageScale: size * canopyScale,
|
|
2270
|
+
...(leafPlacement === 'tips'
|
|
2271
|
+
? { shellFill: false, cardsPerCluster: 9, clusterRadius: 0.62 }
|
|
2272
|
+
: {}),
|
|
2273
|
+
// Branching trees carry their foliage on the branches:
|
|
2274
|
+
// no blob-shell fill, small tufts. Explicit canopy overrides still win.
|
|
2275
|
+
// Branching trees carry their foliage on the branches (no blob-shell
|
|
2276
|
+
// crown) — EXCEPT scribbled foliage areas (extraBlobs, e.g. converted
|
|
2277
|
+
// leaf doodles), which become the fill layout so painted leaves render.
|
|
2278
|
+
...(generator === 'branching'
|
|
2279
|
+
? {
|
|
2280
|
+
shellFill: extraBlobs.length > 0,
|
|
2281
|
+
shellBudget: extraBlobs.length ? extraBlobs.length * 8 : null,
|
|
2282
|
+
cardsPerCluster: 2, clusterRadius: 0.17, cardSizeRange: [0.27, 0.45],
|
|
2283
|
+
}
|
|
2284
|
+
: {}),
|
|
2285
|
+
...canopyOverrides,
|
|
2286
|
+
// Hand-drawn trees have no generated crown. Scribbled foliage blobs
|
|
2287
|
+
// (extraBlobs) become the whole layout and are shell-filled — leaves
|
|
2288
|
+
// appear exactly where scribbled. Without scribbles, leaves grow only
|
|
2289
|
+
// at drawn branch tips, and not at all before any strokes.
|
|
2290
|
+
...(generator === 'drawn'
|
|
2291
|
+
? (extraBlobs.length
|
|
2292
|
+
? { shellFill: true, shellBudget: extraBlobs.length * 8 }
|
|
2293
|
+
: { shellFill: false, ...(attachments.length ? {} : { cardCount: 0 }) })
|
|
2294
|
+
: {}),
|
|
2295
|
+
attachmentOverrides: branchOverrides,
|
|
2296
|
+
blobs: (generator === 'drawn' || generator === 'branching') && extraBlobs.length
|
|
2297
|
+
? extraBlobs : blobs,
|
|
2298
|
+
});
|
|
2299
|
+
const materials = createTreeFoliageMaterials({
|
|
2300
|
+
color: canopyColor,
|
|
2301
|
+
palette: canopyPalette,
|
|
2302
|
+
seed,
|
|
2303
|
+
...(leafShape && !settings.foliage.leafMap
|
|
2304
|
+
? { leafMap: leafSpriteForShape(leafShape) }
|
|
2305
|
+
: {}),
|
|
2306
|
+
...settings.foliage,
|
|
2307
|
+
});
|
|
2308
|
+
|
|
2309
|
+
this.trunkMesh = new THREE.Mesh(
|
|
2310
|
+
trunkGeometry,
|
|
2311
|
+
trunkMaterial ?? new THREE.MeshToonMaterial({ color: 0xc9ab8a }),
|
|
2312
|
+
);
|
|
2313
|
+
this.trunkMesh.castShadow = true;
|
|
2314
|
+
this.trunkMesh.receiveShadow = trunkReceiveShadow;
|
|
2315
|
+
|
|
2316
|
+
this.canopyMesh = new THREE.Mesh(canopyGeometry, materials.material);
|
|
2317
|
+
this.canopyMesh.customDepthMaterial = materials.depthMaterial;
|
|
2318
|
+
this.canopyMesh.castShadow = true;
|
|
2319
|
+
this.canopyMesh.receiveShadow = true;
|
|
2320
|
+
this.canopyMesh.frustumCulled = false;
|
|
2321
|
+
this.canopyMesh.position.copy(canopyAnchor);
|
|
2322
|
+
this.canopyMesh.scale.setScalar(canopyScale);
|
|
2323
|
+
this.canopyMesh.userData.environmentShaderExclude = true;
|
|
2324
|
+
// Branch-end tuft anchors in canopyMesh-local space, index-aligned with
|
|
2325
|
+
// the aAttachment card attribute — the designer's branch picker uses
|
|
2326
|
+
// both to map a clicked leaf back to its branch.
|
|
2327
|
+
this.foliageAttachments = attachments;
|
|
2328
|
+
|
|
2329
|
+
this.add(this.trunkMesh, this.canopyMesh);
|
|
2330
|
+
this.scale.setScalar(size);
|
|
2331
|
+
}
|
|
2332
|
+
|
|
2333
|
+
/**
|
|
2334
|
+
* Runtime re-tune: merges partial grouped settings ({ tree, trunk,
|
|
2335
|
+
* skeleton, canopy, foliage }) into the current settings and pushes every
|
|
2336
|
+
* runtime-applicable value into the live materials:
|
|
2337
|
+
*
|
|
2338
|
+
* - `foliage.*` — wind, sun, alpha cutoff, backlit, scene/cloud shadows
|
|
2339
|
+
* (shared with the shadow-depth material).
|
|
2340
|
+
* - `tree.canopyColor` / `tree.canopyPalette` — the lit/shadow/crown
|
|
2341
|
+
* palette is re-derived and written into the canopy uniforms.
|
|
2342
|
+
* - `tree.trunkReceiveShadow` — toggles bark shadow receiving.
|
|
2343
|
+
*
|
|
2344
|
+
* Geometry-baked groups (`trunk`, `skeleton`, `canopy`) and the tree
|
|
2345
|
+
* topology fields (`size`, `seed`, `canopyWidth/Depth/Scale/Layout`,
|
|
2346
|
+
* `leafDensity`, `leafPlacement`) are construction-only: new values are
|
|
2347
|
+
* stored on `this.settings` but the meshes are not regrown — build a new
|
|
2348
|
+
* StylizedTree for those. Note that the convenience setters (setWind,
|
|
2349
|
+
* setSun, ...) write uniforms directly without updating `this.settings`.
|
|
2350
|
+
*
|
|
2351
|
+
* @param {Object} [options] Partial grouped settings, same shape as
|
|
2352
|
+
* {@link DEFAULT_STYLIZED_TREE_SETTINGS}.
|
|
2353
|
+
* @returns {Object} The updated settings object.
|
|
2354
|
+
*/
|
|
2355
|
+
applySettings(options = {}) {
|
|
2356
|
+
const source = cleanObject(options);
|
|
2357
|
+
const merged = {};
|
|
2358
|
+
for (const groupId of Object.keys(DEFAULT_STYLIZED_TREE_SETTINGS)) {
|
|
2359
|
+
merged[groupId] = { ...this.settings[groupId] };
|
|
2360
|
+
for (const [key, value] of Object.entries(cleanObject(source[groupId]))) {
|
|
2361
|
+
if (value !== undefined) merged[groupId][key] = value;
|
|
2362
|
+
}
|
|
2363
|
+
}
|
|
2364
|
+
const settings = createStylizedTreeSettings(merged);
|
|
2365
|
+
this.settings = settings;
|
|
2366
|
+
|
|
2367
|
+
const foliage = settings.foliage;
|
|
2368
|
+
const uniforms = this.canopyMesh.material.uniforms;
|
|
2369
|
+
uniforms.uWindDirection.value.set(foliage.windDirection[0], foliage.windDirection[1]);
|
|
2370
|
+
uniforms.uWindSpeed.value = foliage.windSpeed;
|
|
2371
|
+
uniforms.uWindStrength.value = foliage.windStrength;
|
|
2372
|
+
uniforms.uAlphaCutoff.value = foliage.alphaCutoff;
|
|
2373
|
+
uniforms.uSunDirection.value.set(...foliage.sunDirection);
|
|
2374
|
+
uniforms.uSunColor.value.setRGB(...foliage.sunColor, THREE.SRGBColorSpace);
|
|
2375
|
+
uniforms.uSkyColor.value.setRGB(...foliage.skyColor, THREE.SRGBColorSpace);
|
|
2376
|
+
uniforms.uSceneShadowStrength.value = foliage.sceneShadowStrength;
|
|
2377
|
+
uniforms.uBacklitStrength.value = foliage.backlitStrength;
|
|
2378
|
+
uniforms.uCloudShadowStrength.value = foliage.cloudShadowStrength;
|
|
2379
|
+
uniforms.uCloudShadowCoverage.value = foliage.cloudShadowCoverage;
|
|
2380
|
+
uniforms.uCloudShadowScale.value = foliage.cloudShadowScale;
|
|
2381
|
+
uniforms.uCloudShadowVelocity.value.set(
|
|
2382
|
+
foliage.cloudShadowVelocity[0], foliage.cloudShadowVelocity[1]);
|
|
2383
|
+
|
|
2384
|
+
// Re-derive the three-tone palette from the (possibly updated) canopy
|
|
2385
|
+
// color/palette pins. Constructor-only foliage.color/palette overrides
|
|
2386
|
+
// are not tracked here; prefer tree.canopyColor / tree.canopyPalette.
|
|
2387
|
+
const palette = deriveCanopyPalette(
|
|
2388
|
+
resolveCanopyColor(settings.tree.canopyColor, settings.tree.seed),
|
|
2389
|
+
settings.tree.canopyPalette,
|
|
2390
|
+
);
|
|
2391
|
+
uniforms.uLitColor.value.copy(palette.lit);
|
|
2392
|
+
uniforms.uShadowColor.value.copy(palette.shadow);
|
|
2393
|
+
uniforms.uCrownColor.value.copy(palette.crown);
|
|
2394
|
+
|
|
2395
|
+
if (this.trunkMesh.receiveShadow !== settings.tree.trunkReceiveShadow) {
|
|
2396
|
+
this.trunkMesh.receiveShadow = settings.tree.trunkReceiveShadow;
|
|
2397
|
+
this.trunkMesh.material.needsUpdate = true;
|
|
2398
|
+
}
|
|
2399
|
+
return this.settings;
|
|
2400
|
+
}
|
|
2401
|
+
|
|
2402
|
+
setSun(options) {
|
|
2403
|
+
setCanopySun(this.canopyMesh.material.uniforms, options ?? {});
|
|
2404
|
+
return this;
|
|
2405
|
+
}
|
|
2406
|
+
|
|
2407
|
+
setWind(options) {
|
|
2408
|
+
setCanopyWind(this.canopyMesh.material.uniforms, options);
|
|
2409
|
+
return this;
|
|
2410
|
+
}
|
|
2411
|
+
|
|
2412
|
+
// How strongly scene shadows shift the crown toward its shadow palette.
|
|
2413
|
+
setSceneShadow(options) {
|
|
2414
|
+
setCanopySceneShadow(this.canopyMesh.material.uniforms, options);
|
|
2415
|
+
return this;
|
|
2416
|
+
}
|
|
2417
|
+
|
|
2418
|
+
// Drifting procedural cloud shadows across the crown. strength 0 disables.
|
|
2419
|
+
setCloudShadow(options) {
|
|
2420
|
+
setCanopyCloudShadow(this.canopyMesh.material.uniforms, options);
|
|
2421
|
+
return this;
|
|
2422
|
+
}
|
|
2423
|
+
|
|
2424
|
+
update(delta) {
|
|
2425
|
+
tickCanopyTime(this.canopyMesh.material.uniforms, delta);
|
|
2426
|
+
return this;
|
|
2427
|
+
}
|
|
2428
|
+
|
|
2429
|
+
// Recipe document that rebuilds this exact tree (generation is
|
|
2430
|
+
// deterministic per seed): new StylizedTree(tree.toJSON().options).
|
|
2431
|
+
// Deliberately shadows Object3D.toJSON — the recipe IS the serialization
|
|
2432
|
+
// for procedural plants; ObjectLoader round-trips are not supported.
|
|
2433
|
+
toJSON() {
|
|
2434
|
+
return {
|
|
2435
|
+
schema: TREE_RECIPE_SCHEMA,
|
|
2436
|
+
version: TREE_RECIPE_VERSION,
|
|
2437
|
+
type: 'tree',
|
|
2438
|
+
options: serializableTreeOptions(this.config),
|
|
2439
|
+
};
|
|
2440
|
+
}
|
|
2441
|
+
|
|
2442
|
+
dispose() {
|
|
2443
|
+
this.trunkMesh.geometry.dispose();
|
|
2444
|
+
this.trunkMesh.material.dispose();
|
|
2445
|
+
this.canopyMesh.geometry.dispose();
|
|
2446
|
+
this.canopyMesh.material.dispose();
|
|
2447
|
+
this.canopyMesh.customDepthMaterial?.dispose();
|
|
2448
|
+
}
|
|
2449
|
+
}
|