@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,1929 @@
|
|
|
1
|
+
// Deterministic, texture-free procedural debris meshes. The output uses
|
|
2
|
+
// MeshStandardMaterial + baked vertex colors so host apps can either export
|
|
3
|
+
// it directly or pass it through ToonLab's environment material adapter.
|
|
4
|
+
|
|
5
|
+
import * as THREE from 'three';
|
|
6
|
+
import { mergeGeometries } from 'three/examples/jsm/utils/BufferGeometryUtils.js';
|
|
7
|
+
|
|
8
|
+
import { createDebrisSettings } from './debrisSettings.js';
|
|
9
|
+
import {
|
|
10
|
+
createJawField,
|
|
11
|
+
createLongBoneField,
|
|
12
|
+
createMasonryChunkField,
|
|
13
|
+
createSkullField,
|
|
14
|
+
createStoneField,
|
|
15
|
+
meshDebrisField,
|
|
16
|
+
} from './debrisFields.js';
|
|
17
|
+
import {
|
|
18
|
+
debrisTextureAuto,
|
|
19
|
+
ensureDebrisUvs,
|
|
20
|
+
getCustomDebrisTexture,
|
|
21
|
+
getDebrisDetailTexture,
|
|
22
|
+
} from './debrisTextures.js';
|
|
23
|
+
import { hashCombine } from '../rockgen/noise/prng.js';
|
|
24
|
+
import { simplexNoise3 } from '../rockgen/noise/simplexNoise3.js';
|
|
25
|
+
import { fbm3 } from '../rockgen/noise/valueNoise3.js';
|
|
26
|
+
import {
|
|
27
|
+
createBranchingTreeSkeleton,
|
|
28
|
+
createBranchTubeGeometry,
|
|
29
|
+
} from '../vegetation/stylizedTree.js';
|
|
30
|
+
|
|
31
|
+
function lowbias32(value) {
|
|
32
|
+
let hash = value >>> 0;
|
|
33
|
+
hash = Math.imul(hash ^ (hash >>> 16), 0x7feb352d) >>> 0;
|
|
34
|
+
hash = Math.imul(hash ^ (hash >>> 15), 0x846ca68b) >>> 0;
|
|
35
|
+
return (hash ^ (hash >>> 16)) >>> 0;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function createDebrisRandom(seed) {
|
|
39
|
+
let a = lowbias32(seed);
|
|
40
|
+
let b = lowbias32(seed + 1);
|
|
41
|
+
let c = lowbias32(seed + 2);
|
|
42
|
+
let d = lowbias32(seed + 3);
|
|
43
|
+
for (let warm = 0; warm < 8; warm += 1) {
|
|
44
|
+
const t = (a + b + d) >>> 0;
|
|
45
|
+
d = (d + 1) >>> 0;
|
|
46
|
+
a = b ^ (b >>> 9);
|
|
47
|
+
b = (c + (c << 3)) >>> 0;
|
|
48
|
+
c = ((c << 21) | (c >>> 11)) >>> 0;
|
|
49
|
+
c = (c + t) >>> 0;
|
|
50
|
+
}
|
|
51
|
+
return () => {
|
|
52
|
+
const t = (a + b + d) >>> 0;
|
|
53
|
+
d = (d + 1) >>> 0;
|
|
54
|
+
a = b ^ (b >>> 9);
|
|
55
|
+
b = (c + (c << 3)) >>> 0;
|
|
56
|
+
c = ((c << 21) | (c >>> 11)) >>> 0;
|
|
57
|
+
c = (c + t) >>> 0;
|
|
58
|
+
return t / 4294967296;
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const signed = (random) => random() * 2 - 1;
|
|
63
|
+
const mix = (a, b, amount) => a + (b - a) * amount;
|
|
64
|
+
|
|
65
|
+
function paletteColor(settings, slot) {
|
|
66
|
+
const values = slot === 2
|
|
67
|
+
? settings.surface.accentColor
|
|
68
|
+
: slot === 1
|
|
69
|
+
? settings.surface.secondaryColor
|
|
70
|
+
: settings.surface.primaryColor;
|
|
71
|
+
return new THREE.Color(values[0], values[1], values[2]);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function paintGeometry(geometry, settings, random, slot = 0) {
|
|
75
|
+
if (!geometry.attributes.normal) geometry.computeVertexNormals();
|
|
76
|
+
const normal = geometry.attributes.normal;
|
|
77
|
+
const colors = new Float32Array(geometry.attributes.position.count * 3);
|
|
78
|
+
const base = paletteColor(settings, slot);
|
|
79
|
+
for (let index = 0; index < geometry.attributes.position.count; index += 1) {
|
|
80
|
+
const drift = 1 + signed(random) * settings.surface.variation;
|
|
81
|
+
const upward = Math.max(0, normal.getY(index));
|
|
82
|
+
const edge = 1 + upward * settings.surface.edgeLight * 0.28;
|
|
83
|
+
const color = base.clone().multiplyScalar(drift * edge);
|
|
84
|
+
colors[index * 3] = Math.min(color.r, 1);
|
|
85
|
+
colors[index * 3 + 1] = Math.min(color.g, 1);
|
|
86
|
+
colors[index * 3 + 2] = Math.min(color.b, 1);
|
|
87
|
+
}
|
|
88
|
+
geometry.setAttribute('color', new THREE.BufferAttribute(colors, 3));
|
|
89
|
+
return geometry;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function createMaterial(settings, { doubleSided = false, metalness = 0 } = {}) {
|
|
93
|
+
const material = new THREE.MeshStandardMaterial({
|
|
94
|
+
color: 0xffffff,
|
|
95
|
+
metalness,
|
|
96
|
+
roughness: settings.surface.roughness,
|
|
97
|
+
side: doubleSided ? THREE.DoubleSide : THREE.FrontSide,
|
|
98
|
+
vertexColors: true,
|
|
99
|
+
});
|
|
100
|
+
material.name = 'Debris Toon Source';
|
|
101
|
+
material.userData.envRole = 'standard';
|
|
102
|
+
return material;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function addMesh(parent, geometry, settings, random, {
|
|
106
|
+
doubleSided = false, metalness = 0, name = 'Debris piece', slot = 0,
|
|
107
|
+
} = {}) {
|
|
108
|
+
paintGeometry(geometry, settings, random, slot);
|
|
109
|
+
const mesh = new THREE.Mesh(geometry, createMaterial(settings, { doubleSided, metalness }));
|
|
110
|
+
mesh.name = name;
|
|
111
|
+
mesh.castShadow = true;
|
|
112
|
+
mesh.receiveShadow = true;
|
|
113
|
+
parent.add(mesh);
|
|
114
|
+
return mesh;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// Piece placement by arrangement mode. Pieces build along the X axis, so
|
|
118
|
+
// 'bundle' aligns them side by side like gathered firewood (the Megascans
|
|
119
|
+
// "bunch of sticks" read), 'heap' stacks a dense center-biased mound,
|
|
120
|
+
// 'patch' spreads a flat ground layer, and 'scatter' keeps the loose
|
|
121
|
+
// natural throw.
|
|
122
|
+
function scatterObject(object, settings, random, index, { stacked = 0, tilt = 0.28 } = {}) {
|
|
123
|
+
const { arrangement, messiness, spread } = settings.asset;
|
|
124
|
+
// Messiness scales every chaos source; 0.5 reproduces the base look.
|
|
125
|
+
const chaos = mix(0.35, 1.65, messiness);
|
|
126
|
+
const rotation = settings.asset.rotationJitter * chaos;
|
|
127
|
+
object.scale.multiplyScalar(mix(1 - 0.3 * messiness, 1 + 0.35 * messiness, random()));
|
|
128
|
+
if (arrangement === 'bundle') {
|
|
129
|
+
const gauge = Math.max(stacked, 0.05);
|
|
130
|
+
const row = index % 3;
|
|
131
|
+
const layer = Math.floor(index / 3);
|
|
132
|
+
object.position.set(
|
|
133
|
+
signed(random) * Math.max(spread, 0.2) * 0.18,
|
|
134
|
+
layer * gauge * 0.85,
|
|
135
|
+
(row - 1) * gauge * 1.05 + (layer % 2) * gauge * 0.5 + signed(random) * gauge * 0.2,
|
|
136
|
+
);
|
|
137
|
+
// Mostly parallel; the odd piece thrown across sells "gathered".
|
|
138
|
+
const crossing = random() < 0.14 ? (random() < 0.5 ? 1 : -1) * mix(0.35, 0.8, random()) : 0;
|
|
139
|
+
object.rotation.set(
|
|
140
|
+
signed(random) * 0.04 * rotation,
|
|
141
|
+
signed(random) * 0.09 * rotation + crossing,
|
|
142
|
+
signed(random) * 0.05 * rotation,
|
|
143
|
+
);
|
|
144
|
+
return;
|
|
145
|
+
}
|
|
146
|
+
if (arrangement === 'heap') {
|
|
147
|
+
// Mild center bias over a real footprint: too tight and the support
|
|
148
|
+
// settle stacks everything into a tower instead of a mound.
|
|
149
|
+
const radius = random() ** 0.9 * spread * 0.6;
|
|
150
|
+
const angle = random() * Math.PI * 2;
|
|
151
|
+
// Layered fill (3 pieces per layer) keeps late pieces from spawning
|
|
152
|
+
// high over empty air; per-piece lift-only settle handles sinkers.
|
|
153
|
+
const layer = Math.floor(index / 3);
|
|
154
|
+
object.position.set(
|
|
155
|
+
Math.cos(angle) * radius,
|
|
156
|
+
layer * Math.max(stacked, 0.03) * 0.5 + random() * Math.max(stacked, 0.03) * 0.2,
|
|
157
|
+
Math.sin(angle) * radius,
|
|
158
|
+
);
|
|
159
|
+
// Tumble respects the piece's tilt hint: chunks roll freely, but
|
|
160
|
+
// elongated pieces (logs, planks, bones) stay near-horizontal — real
|
|
161
|
+
// piles don't balance logs on end.
|
|
162
|
+
const tumble = Math.min(tilt * 2.2, 1.2) * rotation;
|
|
163
|
+
object.rotation.set(
|
|
164
|
+
signed(random) * tumble,
|
|
165
|
+
random() * Math.PI * 2,
|
|
166
|
+
signed(random) * tumble,
|
|
167
|
+
);
|
|
168
|
+
return;
|
|
169
|
+
}
|
|
170
|
+
if (arrangement === 'patch') {
|
|
171
|
+
const radius = Math.sqrt(random()) * spread;
|
|
172
|
+
const angle = random() * Math.PI * 2;
|
|
173
|
+
object.position.set(Math.cos(angle) * radius, 0, Math.sin(angle) * radius);
|
|
174
|
+
object.rotation.set(
|
|
175
|
+
signed(random) * tilt * 0.4 * rotation,
|
|
176
|
+
random() * Math.PI * 2,
|
|
177
|
+
signed(random) * tilt * 0.4 * rotation,
|
|
178
|
+
);
|
|
179
|
+
object.scale.multiplyScalar(mix(0.6, 1.3, random()));
|
|
180
|
+
return;
|
|
181
|
+
}
|
|
182
|
+
const radius = Math.sqrt(random()) * spread;
|
|
183
|
+
const angle = random() * Math.PI * 2;
|
|
184
|
+
object.position.set(
|
|
185
|
+
Math.cos(angle) * radius,
|
|
186
|
+
index === 0 ? 0 : random() * stacked,
|
|
187
|
+
Math.sin(angle) * radius,
|
|
188
|
+
);
|
|
189
|
+
object.rotation.set(
|
|
190
|
+
signed(random) * tilt * rotation,
|
|
191
|
+
random() * Math.PI * 2 * rotation,
|
|
192
|
+
signed(random) * tilt * rotation,
|
|
193
|
+
);
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
// Rests a piece on the ground plane. `drop` also pulls floating pieces
|
|
197
|
+
// DOWN to contact (not just lifting sinkers) — pieces float otherwise,
|
|
198
|
+
// because spines/joints rarely put the geometry's lowest point at y=0.
|
|
199
|
+
function settleOnGround(object, clearance = 0.012, { drop = false } = {}) {
|
|
200
|
+
object.updateMatrixWorld(true);
|
|
201
|
+
const bounds = new THREE.Box3().setFromObject(object, true);
|
|
202
|
+
if (!Number.isFinite(bounds.min.y)) return;
|
|
203
|
+
if (drop || bounds.min.y < clearance) {
|
|
204
|
+
object.position.y += clearance - bounds.min.y;
|
|
205
|
+
object.updateMatrixWorld(true);
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
// Physical plausibility pass: flat and elongated pieces (planks, chips,
|
|
210
|
+
// sheets, sticks, leaves, bones) cannot balance on a corner — dropped on
|
|
211
|
+
// flat ground they rotate until they LIE FLAT. Pieces are measured level
|
|
212
|
+
// (yaw only): if the piece is much wider than tall, its pitch/roll gets
|
|
213
|
+
// clamped to a few degrees; chunky pieces (rocks, skulls) keep their
|
|
214
|
+
// tumble. Without this, corner-grazing AABB contact reads as floating.
|
|
215
|
+
function stabilizeFlatPieces(pieces) {
|
|
216
|
+
const box = new THREE.Box3();
|
|
217
|
+
for (const piece of pieces) {
|
|
218
|
+
const pitch = piece.rotation.x;
|
|
219
|
+
const roll = piece.rotation.z;
|
|
220
|
+
piece.rotation.x = 0;
|
|
221
|
+
piece.rotation.z = 0;
|
|
222
|
+
piece.updateMatrixWorld(true);
|
|
223
|
+
box.setFromObject(piece, true);
|
|
224
|
+
if (!Number.isFinite(box.min.y)) continue;
|
|
225
|
+
const height = box.max.y - box.min.y;
|
|
226
|
+
// Judge against the LONGEST plan axis: a 3-unit branch with bushy
|
|
227
|
+
// prongs is still a stick and must lie flat — only pieces that are
|
|
228
|
+
// genuinely as tall as they are long (rocks, skulls, cones) may rest
|
|
229
|
+
// tilted.
|
|
230
|
+
const footprint = Math.max(box.max.x - box.min.x, box.max.z - box.min.z);
|
|
231
|
+
if (height < footprint * 0.45) {
|
|
232
|
+
piece.rotation.x = Math.min(Math.max(pitch, -0.08), 0.08);
|
|
233
|
+
piece.rotation.z = Math.min(Math.max(roll, -0.08), 0.08);
|
|
234
|
+
} else {
|
|
235
|
+
piece.rotation.x = pitch;
|
|
236
|
+
piece.rotation.z = roll;
|
|
237
|
+
}
|
|
238
|
+
piece.updateMatrixWorld(true);
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
// Ground-contact settling for a whole composition. Scatter/patch pieces
|
|
243
|
+
// ALWAYS drop to the ground plane — mutual interpenetration reads as
|
|
244
|
+
// natural ground clutter, whereas resting pieces on each other's mostly-
|
|
245
|
+
// empty bounding boxes reads as floating. Heaps and bundles stack, but
|
|
246
|
+
// only when the CENTERS of two pieces overlap (boxes shrunk 30% toward
|
|
247
|
+
// their middles), so nothing hovers on a phantom AABB corner.
|
|
248
|
+
const SETTLE_UP = new THREE.Vector3(0, 1, 0);
|
|
249
|
+
const SETTLE_DOWN = new THREE.Vector3(0, -1, 0);
|
|
250
|
+
|
|
251
|
+
function settleComposition(pieces, { clearance = 0.012, stacking = false } = {}) {
|
|
252
|
+
const raycaster = new THREE.Raycaster();
|
|
253
|
+
const origin = new THREE.Vector3();
|
|
254
|
+
const placedMeshes = [];
|
|
255
|
+
const entries = pieces
|
|
256
|
+
.map((piece) => {
|
|
257
|
+
piece.updateMatrixWorld(true);
|
|
258
|
+
return { box: new THREE.Box3().setFromObject(piece, true), piece };
|
|
259
|
+
})
|
|
260
|
+
.filter((entry) => Number.isFinite(entry.box.min.y))
|
|
261
|
+
.sort((a, b) => a.box.min.y - b.box.min.y);
|
|
262
|
+
|
|
263
|
+
for (const { box, piece } of entries) {
|
|
264
|
+
let drop = null;
|
|
265
|
+
if (stacking && placedMeshes.length > 0) {
|
|
266
|
+
// Real contact: sample a grid of columns over the piece footprint.
|
|
267
|
+
// In each column, find the piece's actual UNDERSIDE (upward ray into
|
|
268
|
+
// itself) and the actual TOP SURFACE of whatever lies below
|
|
269
|
+
// (downward ray onto placed pieces). The piece drops by the smallest
|
|
270
|
+
// gap — first true geometric contact, never a phantom AABB corner.
|
|
271
|
+
const samples = 4;
|
|
272
|
+
const insetX = (box.max.x - box.min.x) * 0.12;
|
|
273
|
+
const insetZ = (box.max.z - box.min.z) * 0.12;
|
|
274
|
+
for (let gx = 0; gx < samples; gx += 1) {
|
|
275
|
+
for (let gz = 0; gz < samples; gz += 1) {
|
|
276
|
+
const x = box.min.x + insetX + (gx / (samples - 1)) * (box.max.x - box.min.x - insetX * 2);
|
|
277
|
+
const z = box.min.z + insetZ + (gz / (samples - 1)) * (box.max.z - box.min.z - insetZ * 2);
|
|
278
|
+
origin.set(x, box.min.y - 0.5, z);
|
|
279
|
+
raycaster.set(origin, SETTLE_UP);
|
|
280
|
+
const selfHit = raycaster.intersectObject(piece, true)[0];
|
|
281
|
+
if (!selfHit) continue;
|
|
282
|
+
const underside = selfHit.point.y;
|
|
283
|
+
origin.set(x, box.max.y + 0.5, z);
|
|
284
|
+
raycaster.set(origin, SETTLE_DOWN);
|
|
285
|
+
const supportHit = raycaster.intersectObjects(placedMeshes, true)[0];
|
|
286
|
+
const support = supportHit && supportHit.point.y <= underside + 1e-4
|
|
287
|
+
? supportHit.point.y
|
|
288
|
+
: supportHit
|
|
289
|
+
? underside // already interpenetrating at this column: can't drop
|
|
290
|
+
: 0;
|
|
291
|
+
const gap = underside - Math.max(support, 0) - clearance;
|
|
292
|
+
drop = drop === null ? gap : Math.min(drop, gap);
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
if (drop !== null) {
|
|
297
|
+
// Drop-only: settling never pushes a piece upward.
|
|
298
|
+
piece.position.y -= Math.max(drop, 0);
|
|
299
|
+
} else {
|
|
300
|
+
// Ground contact for scatter/patch (and the first stacked piece).
|
|
301
|
+
piece.position.y += clearance - box.min.y;
|
|
302
|
+
}
|
|
303
|
+
piece.updateMatrixWorld(true);
|
|
304
|
+
piece.traverse((object) => {
|
|
305
|
+
if (object.isMesh) placedMeshes.push(object);
|
|
306
|
+
});
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
function orientAlong(mesh, start, end) {
|
|
311
|
+
const direction = new THREE.Vector3().subVectors(end, start);
|
|
312
|
+
mesh.position.copy(start).addScaledVector(direction, 0.5);
|
|
313
|
+
mesh.quaternion.setFromUnitVectors(new THREE.Vector3(0, 1, 0), direction.clone().normalize());
|
|
314
|
+
return direction.length();
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
function tube(points, radius, radialSegments = 7) {
|
|
318
|
+
const curve = new THREE.CatmullRomCurve3(points, false, 'centripetal');
|
|
319
|
+
return new THREE.TubeGeometry(curve, Math.max(6, points.length * 3), radius, radialSegments, false);
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
// Coherent lumpy solid: vertices displace by simplex noise sampled at
|
|
323
|
+
// their direction, so neighbouring vertices move together and the result
|
|
324
|
+
// reads as one weathered chunk. Uncorrelated per-vertex jitter (the old
|
|
325
|
+
// approach) reads as crumpled tinfoil at any detail level.
|
|
326
|
+
function irregularGeometry(radius, random, angularity = 0.7, detail = 1, seed = 0) {
|
|
327
|
+
const geometry = new THREE.IcosahedronGeometry(radius, detail);
|
|
328
|
+
const noiseSeed = hashCombine(seed >>> 0, Math.floor(random() * 0xffff));
|
|
329
|
+
const position = geometry.attributes.position;
|
|
330
|
+
for (let index = 0; index < position.count; index += 1) {
|
|
331
|
+
const length = new THREE.Vector3().fromBufferAttribute(position, index).length() || 1;
|
|
332
|
+
const dx = position.getX(index) / length;
|
|
333
|
+
const dy = position.getY(index) / length;
|
|
334
|
+
const dz = position.getZ(index) / length;
|
|
335
|
+
const lump = 0.65 * simplexNoise3(noiseSeed, dx * 1.35, dy * 1.35, dz * 1.35)
|
|
336
|
+
+ 0.35 * simplexNoise3(hashCombine(noiseSeed, 7), dx * 3.1, dy * 3.1, dz * 3.1);
|
|
337
|
+
const factor = 1 + lump * mix(0.1, 0.34, angularity);
|
|
338
|
+
position.setXYZ(index, dx * radius * factor, dy * radius * factor, dz * radius * factor);
|
|
339
|
+
}
|
|
340
|
+
geometry.computeVertexNormals();
|
|
341
|
+
return geometry;
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
function addSplinters(parent, point, direction, settings, random, amount, radius) {
|
|
345
|
+
const count = Math.round(amount * 4);
|
|
346
|
+
for (let index = 0; index < count; index += 1) {
|
|
347
|
+
const length = radius * mix(1.8, 4.2, random());
|
|
348
|
+
const start = point.clone().add(new THREE.Vector3(
|
|
349
|
+
signed(random) * radius * 0.34,
|
|
350
|
+
signed(random) * radius * 0.34,
|
|
351
|
+
signed(random) * radius * 0.34,
|
|
352
|
+
));
|
|
353
|
+
const end = start.clone().add(direction.clone().normalize().multiplyScalar(length));
|
|
354
|
+
end.add(new THREE.Vector3(signed(random), signed(random) * 0.35, signed(random)).multiplyScalar(radius * 0.8));
|
|
355
|
+
const middle = start.clone().lerp(end, 0.55).add(new THREE.Vector3(
|
|
356
|
+
signed(random) * radius * 0.28,
|
|
357
|
+
random() * radius * 0.24,
|
|
358
|
+
signed(random) * radius * 0.28,
|
|
359
|
+
));
|
|
360
|
+
const swept = createBranchTubeGeometry({
|
|
361
|
+
irregularity: 0.12,
|
|
362
|
+
points: [start.toArray(), middle.toArray(), end.toArray()],
|
|
363
|
+
radialSegments: 5,
|
|
364
|
+
radiusEnd: radius * 0.018,
|
|
365
|
+
radiusStart: radius * mix(0.1, 0.18, random()),
|
|
366
|
+
ringSpacing: Math.max(radius * 0.35, 0.025),
|
|
367
|
+
seed: settings.asset.seed + index * 23 + Math.floor(random() * 997),
|
|
368
|
+
});
|
|
369
|
+
if (swept) addMesh(parent, swept.geometry, settings, random, {
|
|
370
|
+
name: 'Tree Lab broken wood fiber', slot: index % 3 === 0 ? 2 : 1,
|
|
371
|
+
});
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
// Sharp elbow breaks at old fork points: rotate the polyline tail around
|
|
376
|
+
// a joint by a hard angle. Smooth `crookedness` wander can never produce
|
|
377
|
+
// the angular direction breaks that make scanned dead branches read.
|
|
378
|
+
function applyKinks(points, kinks, random) {
|
|
379
|
+
if (kinks <= 0.02) return points;
|
|
380
|
+
const axis = new THREE.Vector3();
|
|
381
|
+
const pivot = new THREE.Vector3();
|
|
382
|
+
const tail = new THREE.Vector3();
|
|
383
|
+
for (let joint = 1; joint < points.length - 1; joint += 1) {
|
|
384
|
+
if (random() > kinks * 0.55) continue;
|
|
385
|
+
const angle = (random() < 0.5 ? -1 : 1) * mix(0.25, 0.95, random()) * kinks;
|
|
386
|
+
axis.set(signed(random) * 0.4, 1, signed(random) * 0.4).normalize();
|
|
387
|
+
pivot.copy(points[joint]);
|
|
388
|
+
for (let after = joint + 1; after < points.length; after += 1) {
|
|
389
|
+
tail.copy(points[after]).sub(pivot).applyAxisAngle(axis, angle);
|
|
390
|
+
points[after].copy(tail).add(pivot);
|
|
391
|
+
}
|
|
392
|
+
}
|
|
393
|
+
return points;
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
// Bark condition: coherent lengthwise mask splits each piece into
|
|
397
|
+
// bark-dark and weathered-pale zones (bark-on vs bark-stripped is most of
|
|
398
|
+
// the visual variety in scanned driftwood). Runs on baked vertex colors,
|
|
399
|
+
// so pooled/shared geometry stays untouched elsewhere.
|
|
400
|
+
function applyBarkStripping(geometry, settings, seed, amount) {
|
|
401
|
+
if (amount <= 0.02 || !geometry.attributes.color) return;
|
|
402
|
+
const position = geometry.attributes.position;
|
|
403
|
+
const colors = geometry.attributes.color;
|
|
404
|
+
const pale = paletteColor(settings, 2).lerp(new THREE.Color(1, 1, 1), 0.25);
|
|
405
|
+
const maskSeed = hashCombine(seed >>> 0, 57);
|
|
406
|
+
const swatch = new THREE.Color();
|
|
407
|
+
for (let index = 0; index < position.count; index += 1) {
|
|
408
|
+
const mask = 0.5 + 0.5 * simplexNoise3(
|
|
409
|
+
maskSeed,
|
|
410
|
+
position.getX(index) * 1.6,
|
|
411
|
+
position.getY(index) * 3.2,
|
|
412
|
+
position.getZ(index) * 3.2,
|
|
413
|
+
);
|
|
414
|
+
if (mask >= amount) continue;
|
|
415
|
+
const strength = Math.min((amount - mask) / 0.18, 1);
|
|
416
|
+
swatch.setRGB(colors.getX(index), colors.getY(index), colors.getZ(index)).lerp(pale, strength * 0.85);
|
|
417
|
+
colors.setXYZ(index, swatch.r, swatch.g, swatch.b);
|
|
418
|
+
}
|
|
419
|
+
colors.needsUpdate = true;
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
// Root stump: central mass with radiating twisted roots — the Megascans
|
|
423
|
+
// "Coast Rauk Root Dead" silhouette. Roots are Tree Lab sweeps curling
|
|
424
|
+
// down and outward, a few upturned at the tips.
|
|
425
|
+
function createRootStumpPiece(settings, random, index) {
|
|
426
|
+
const piece = new THREE.Group();
|
|
427
|
+
piece.name = 'Procedural root stump';
|
|
428
|
+
const { barkStripped, branchiness, crookedness, kinks, length, thickness } = settings.shape;
|
|
429
|
+
const size = length * 0.5;
|
|
430
|
+
const seed = settings.asset.seed + index * 97;
|
|
431
|
+
|
|
432
|
+
const core = addMesh(piece, irregularGeometry(size * 0.3, random, 0.55, 2, seed), settings, random, {
|
|
433
|
+
name: 'Root core', slot: 0,
|
|
434
|
+
});
|
|
435
|
+
core.position.y = size * 0.26;
|
|
436
|
+
core.scale.set(1.15, 0.72, 1.05);
|
|
437
|
+
|
|
438
|
+
// Snapped trunk stub rising from the core.
|
|
439
|
+
const stub = createBranchTubeGeometry({
|
|
440
|
+
irregularity: 0.3,
|
|
441
|
+
points: [
|
|
442
|
+
[0, size * 0.2, 0],
|
|
443
|
+
[signed(random) * size * 0.1, size * mix(0.5, 0.75, random()), signed(random) * size * 0.1],
|
|
444
|
+
],
|
|
445
|
+
radialSegments: 8,
|
|
446
|
+
radiusEnd: thickness * mix(0.9, 1.2, random()),
|
|
447
|
+
radiusStart: thickness * 1.6,
|
|
448
|
+
ringSpacing: size / 8,
|
|
449
|
+
seed: seed + 1,
|
|
450
|
+
});
|
|
451
|
+
if (stub) {
|
|
452
|
+
const mesh = addMesh(piece, stub.geometry, settings, random, { name: 'Trunk stub', slot: 0 });
|
|
453
|
+
applyBarkStripping(mesh.geometry, settings, seed + 1, barkStripped);
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
const rootCount = 7 + Math.round(branchiness * 6);
|
|
457
|
+
for (let root = 0; root < rootCount; root += 1) {
|
|
458
|
+
const azimuth = (root / rootCount) * Math.PI * 2 + signed(random) * 0.6;
|
|
459
|
+
const dirX = Math.cos(azimuth);
|
|
460
|
+
const dirZ = Math.sin(azimuth);
|
|
461
|
+
const reach = size * mix(0.5, 1.35, random());
|
|
462
|
+
const upturn = random() < 0.3 ? size * mix(0.15, 0.4, random()) : 0;
|
|
463
|
+
const points = [
|
|
464
|
+
new THREE.Vector3(dirX * size * 0.16, size * mix(0.16, 0.3, random()), dirZ * size * 0.16),
|
|
465
|
+
new THREE.Vector3(
|
|
466
|
+
dirX * reach * 0.45 + signed(random) * crookedness * size * 0.2,
|
|
467
|
+
size * mix(0.04, 0.14, random()),
|
|
468
|
+
dirZ * reach * 0.45 + signed(random) * crookedness * size * 0.2,
|
|
469
|
+
),
|
|
470
|
+
new THREE.Vector3(
|
|
471
|
+
dirX * reach + signed(random) * crookedness * size * 0.15,
|
|
472
|
+
0.012 + upturn,
|
|
473
|
+
dirZ * reach + signed(random) * crookedness * size * 0.15,
|
|
474
|
+
),
|
|
475
|
+
];
|
|
476
|
+
applyKinks(points, kinks, random);
|
|
477
|
+
const swept = createBranchTubeGeometry({
|
|
478
|
+
irregularity: 0.24 + crookedness * 0.14,
|
|
479
|
+
points: points.map((point) => point.toArray()),
|
|
480
|
+
radialSegments: 6,
|
|
481
|
+
radiusEnd: thickness * 0.06,
|
|
482
|
+
radiusStart: thickness * mix(0.5, 0.75, random()),
|
|
483
|
+
ringSpacing: Math.max(reach / 9, 0.03),
|
|
484
|
+
seed: seed + 3 + root * 11,
|
|
485
|
+
});
|
|
486
|
+
if (!swept) continue;
|
|
487
|
+
const mesh = addMesh(piece, swept.geometry, settings, random, {
|
|
488
|
+
name: 'Dead root', slot: root % 3 === 0 ? 1 : 0,
|
|
489
|
+
});
|
|
490
|
+
applyBarkStripping(mesh.geometry, settings, seed + 3 + root * 11, barkStripped);
|
|
491
|
+
// Rootlets on some roots.
|
|
492
|
+
if (random() < 0.4) {
|
|
493
|
+
const at = points[1];
|
|
494
|
+
const rootlet = createBranchTubeGeometry({
|
|
495
|
+
irregularity: 0.2,
|
|
496
|
+
points: [
|
|
497
|
+
at.toArray(),
|
|
498
|
+
[at.x + signed(random) * reach * 0.3, Math.max(at.y * 0.5, 0.01), at.z + signed(random) * reach * 0.3],
|
|
499
|
+
],
|
|
500
|
+
radialSegments: 5,
|
|
501
|
+
radiusEnd: thickness * 0.04,
|
|
502
|
+
radiusStart: thickness * 0.22,
|
|
503
|
+
ringSpacing: Math.max(reach / 10, 0.03),
|
|
504
|
+
seed: seed + 200 + root,
|
|
505
|
+
});
|
|
506
|
+
if (rootlet) addMesh(piece, rootlet.geometry, settings, random, { name: 'Rootlet', slot: 1 });
|
|
507
|
+
}
|
|
508
|
+
}
|
|
509
|
+
scatterObject(piece, settings, random, index, { stacked: size * 0.4, tilt: 0.25 });
|
|
510
|
+
settleOnGround(piece);
|
|
511
|
+
return piece;
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
// Bark chip: a small curved ragged slab — bark side dark, inner wood
|
|
515
|
+
// pale — matching the scanned bark collections. Cylindrical curl across
|
|
516
|
+
// the width remembers the trunk it flaked off.
|
|
517
|
+
function createBarkChipPiece(settings, random, index) {
|
|
518
|
+
const piece = new THREE.Group();
|
|
519
|
+
piece.name = 'Procedural bark chip';
|
|
520
|
+
const { length, thickness } = settings.shape;
|
|
521
|
+
const chipLength = length * mix(0.16, 0.32, random());
|
|
522
|
+
const chipWidth = chipLength * mix(0.35, 0.6, random());
|
|
523
|
+
const chipThickness = Math.max(thickness * 0.22, 0.008);
|
|
524
|
+
const seed = hashCombine(settings.asset.seed, 1300 + index * 53);
|
|
525
|
+
const curlRadius = chipWidth / mix(0.5, 1.6, random());
|
|
526
|
+
const edgeSeed = hashCombine(seed, 3);
|
|
527
|
+
const cells = 10;
|
|
528
|
+
|
|
529
|
+
const inside = (x, z) => {
|
|
530
|
+
const u = x / chipLength;
|
|
531
|
+
const v = z / chipWidth;
|
|
532
|
+
const radial = Math.max(Math.abs(u), Math.abs(v) * 1.15) * 2;
|
|
533
|
+
const wobble = 0.78 + 0.3 * simplexNoise3(edgeSeed, u * 3.2, 0, v * 3.2);
|
|
534
|
+
return radial < wobble;
|
|
535
|
+
};
|
|
536
|
+
const bark = paletteColor(settings, 0);
|
|
537
|
+
const inner = paletteColor(settings, 2).lerp(new THREE.Color(1, 1, 1), 0.18);
|
|
538
|
+
const positions = [];
|
|
539
|
+
const colors = [];
|
|
540
|
+
const deform = (x, z) => {
|
|
541
|
+
const arc = z / curlRadius;
|
|
542
|
+
return [x, (1 - Math.cos(arc)) * curlRadius + chipThickness, Math.sin(arc) * curlRadius];
|
|
543
|
+
};
|
|
544
|
+
const pushVertex = (x, z, lift, color) => {
|
|
545
|
+
const [dx, dy, dz] = deform(x, z);
|
|
546
|
+
positions.push(dx, dy + lift, dz);
|
|
547
|
+
const drift = 1 + signed(random) * settings.surface.variation;
|
|
548
|
+
colors.push(
|
|
549
|
+
Math.min(color.r * drift, 1),
|
|
550
|
+
Math.min(color.g * drift, 1),
|
|
551
|
+
Math.min(color.b * drift, 1),
|
|
552
|
+
);
|
|
553
|
+
};
|
|
554
|
+
for (let cz = 0; cz < cells; cz += 1) {
|
|
555
|
+
for (let cx = 0; cx < cells; cx += 1) {
|
|
556
|
+
const x0 = (cx / cells - 0.5) * chipLength;
|
|
557
|
+
const x1 = ((cx + 1) / cells - 0.5) * chipLength;
|
|
558
|
+
const z0 = (cz / cells - 0.5) * chipWidth;
|
|
559
|
+
const z1 = ((cz + 1) / cells - 0.5) * chipWidth;
|
|
560
|
+
if (!inside((x0 + x1) / 2, (z0 + z1) / 2)) continue;
|
|
561
|
+
// Bark top, pale inner-wood bottom.
|
|
562
|
+
pushVertex(x0, z0, chipThickness, bark); pushVertex(x0, z1, chipThickness, bark); pushVertex(x1, z1, chipThickness, bark);
|
|
563
|
+
pushVertex(x0, z0, chipThickness, bark); pushVertex(x1, z1, chipThickness, bark); pushVertex(x1, z0, chipThickness, bark);
|
|
564
|
+
pushVertex(x0, z0, 0, inner); pushVertex(x1, z0, 0, inner); pushVertex(x1, z1, 0, inner);
|
|
565
|
+
pushVertex(x0, z0, 0, inner); pushVertex(x1, z1, 0, inner); pushVertex(x0, z1, 0, inner);
|
|
566
|
+
}
|
|
567
|
+
}
|
|
568
|
+
if (positions.length === 0) return piece;
|
|
569
|
+
const geometry = new THREE.BufferGeometry();
|
|
570
|
+
geometry.setAttribute('position', new THREE.BufferAttribute(new Float32Array(positions), 3));
|
|
571
|
+
geometry.setAttribute('color', new THREE.BufferAttribute(new Float32Array(colors), 3));
|
|
572
|
+
geometry.computeVertexNormals();
|
|
573
|
+
const mesh = new THREE.Mesh(geometry, createMaterial(settings, { doubleSided: true }));
|
|
574
|
+
mesh.name = 'Bark chip';
|
|
575
|
+
mesh.castShadow = true;
|
|
576
|
+
mesh.receiveShadow = true;
|
|
577
|
+
piece.add(mesh);
|
|
578
|
+
scatterObject(piece, settings, random, index, { stacked: chipThickness * 6, tilt: 0.5 });
|
|
579
|
+
settleOnGround(piece);
|
|
580
|
+
return piece;
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
// Broken plank, per DebrisMaker2's recipe: a thick slab whose ragged ends
|
|
584
|
+
// are CARVED (cells discarded past a noisy break line — raggedness only
|
|
585
|
+
// along the length, like grain snapping), knotholes punched through,
|
|
586
|
+
// then lengthwise warp + bend + twist. Emitted as one slab mesh: top and
|
|
587
|
+
// bottom vertex layers plus side skirts around every boundary cell.
|
|
588
|
+
function createPlankPiece(settings, random, index) {
|
|
589
|
+
const piece = new THREE.Group();
|
|
590
|
+
piece.name = 'Procedural broken plank';
|
|
591
|
+
const { branchiness, crookedness, length, splinters, thickness } = settings.shape;
|
|
592
|
+
const plankLength = length * mix(0.8, 1.15, random());
|
|
593
|
+
const plankWidth = Math.max(thickness * mix(2.2, 3.2, random()), plankLength * 0.08);
|
|
594
|
+
const plankThickness = Math.max(thickness * 0.55, 0.02);
|
|
595
|
+
const seed = hashCombine(settings.asset.seed, 1000 + index * 43);
|
|
596
|
+
const cellsX = 36;
|
|
597
|
+
const cellsZ = 8;
|
|
598
|
+
|
|
599
|
+
// Break lines: each end snaps along a jagged noise curve; `splinters`
|
|
600
|
+
// deepens the bite. Knotholes: 0-2 grain-elongated discs.
|
|
601
|
+
const breakSeed = hashCombine(seed, 3);
|
|
602
|
+
const endBite = [mix(0.02, 0.16, splinters) * (0.4 + random()), mix(0.02, 0.16, splinters) * (0.4 + random())];
|
|
603
|
+
const holes = [];
|
|
604
|
+
const holeCount = Math.round(branchiness * 2 * random());
|
|
605
|
+
for (let i = 0; i < holeCount; i += 1) {
|
|
606
|
+
holes.push([
|
|
607
|
+
signed(random) * plankLength * 0.3,
|
|
608
|
+
signed(random) * plankWidth * 0.22,
|
|
609
|
+
plankWidth * mix(0.1, 0.2, random()),
|
|
610
|
+
]);
|
|
611
|
+
}
|
|
612
|
+
const inside = (x, z) => {
|
|
613
|
+
const across = z / plankWidth;
|
|
614
|
+
const jag0 = simplexNoise3(breakSeed, 0.5, across * 6, 0) * 0.5 + 0.5;
|
|
615
|
+
if (x < -plankLength * (0.5 - endBite[0] * jag0)) return false;
|
|
616
|
+
const jag1 = simplexNoise3(breakSeed, 7.5, across * 6, 0) * 0.5 + 0.5;
|
|
617
|
+
if (x > plankLength * (0.5 - endBite[1] * jag1)) return false;
|
|
618
|
+
for (const [hx, hz, radius] of holes) {
|
|
619
|
+
if (((x - hx) / 1.6) ** 2 + (z - hz) ** 2 < radius * radius) return false;
|
|
620
|
+
}
|
|
621
|
+
return true;
|
|
622
|
+
};
|
|
623
|
+
|
|
624
|
+
// Warp/bend/twist. Boards are STIFF: DM2's warp is ~0.5% of length —
|
|
625
|
+
// anything stronger reads as rubber, not lumber.
|
|
626
|
+
const warpSeed = hashCombine(seed, 11);
|
|
627
|
+
const bendAngle = signed(random) * crookedness * 0.2;
|
|
628
|
+
const twistAngle = signed(random) * crookedness * 0.55;
|
|
629
|
+
const deform = (x, y, z) => {
|
|
630
|
+
let py = y + crookedness * plankThickness * 0.6
|
|
631
|
+
* simplexNoise3(warpSeed, x * 1.2 / plankLength, 0, z * 0.6 / plankWidth);
|
|
632
|
+
py += Math.sin((x / plankLength) * Math.PI) * bendAngle * plankLength * 0.1;
|
|
633
|
+
const angle = twistAngle * (x / plankLength);
|
|
634
|
+
const cos = Math.cos(angle);
|
|
635
|
+
const sin = Math.sin(angle);
|
|
636
|
+
return [x, py * cos - z * sin + plankThickness, py * sin + z * cos];
|
|
637
|
+
};
|
|
638
|
+
|
|
639
|
+
const positions = [];
|
|
640
|
+
const cellAt = new Array(cellsX * cellsZ).fill(false);
|
|
641
|
+
const cellCorner = (cx, cz) => [
|
|
642
|
+
(cx / cellsX - 0.5) * plankLength,
|
|
643
|
+
(cz / cellsZ - 0.5) * plankWidth,
|
|
644
|
+
];
|
|
645
|
+
for (let cz = 0; cz < cellsZ; cz += 1) {
|
|
646
|
+
for (let cx = 0; cx < cellsX; cx += 1) {
|
|
647
|
+
const [x0, z0] = cellCorner(cx, cz);
|
|
648
|
+
const [x1, z1] = cellCorner(cx + 1, cz + 1);
|
|
649
|
+
if (!inside((x0 + x1) / 2, (z0 + z1) / 2)) continue;
|
|
650
|
+
cellAt[cz * cellsX + cx] = true;
|
|
651
|
+
const half = plankThickness / 2;
|
|
652
|
+
const quad = (a, b, c, d) => positions.push(...a, ...b, ...c, ...a, ...c, ...d);
|
|
653
|
+
quad(deform(x0, half, z0), deform(x0, half, z1), deform(x1, half, z1), deform(x1, half, z0));
|
|
654
|
+
quad(deform(x0, -half, z0), deform(x1, -half, z0), deform(x1, -half, z1), deform(x0, -half, z1));
|
|
655
|
+
}
|
|
656
|
+
}
|
|
657
|
+
// Side skirts wherever a kept cell borders a dropped one.
|
|
658
|
+
const has = (cx, cz) => cx >= 0 && cz >= 0 && cx < cellsX && cz < cellsZ && cellAt[cz * cellsX + cx];
|
|
659
|
+
for (let cz = 0; cz < cellsZ; cz += 1) {
|
|
660
|
+
for (let cx = 0; cx < cellsX; cx += 1) {
|
|
661
|
+
if (!has(cx, cz)) continue;
|
|
662
|
+
const [x0, z0] = cellCorner(cx, cz);
|
|
663
|
+
const [x1, z1] = cellCorner(cx + 1, cz + 1);
|
|
664
|
+
const half = plankThickness / 2;
|
|
665
|
+
const wall = (ax, az, bx, bz) => {
|
|
666
|
+
positions.push(
|
|
667
|
+
...deform(ax, half, az), ...deform(bx, half, bz), ...deform(bx, -half, bz),
|
|
668
|
+
...deform(ax, half, az), ...deform(bx, -half, bz), ...deform(ax, -half, az),
|
|
669
|
+
);
|
|
670
|
+
};
|
|
671
|
+
if (!has(cx - 1, cz)) wall(x0, z1, x0, z0);
|
|
672
|
+
if (!has(cx + 1, cz)) wall(x1, z0, x1, z1);
|
|
673
|
+
if (!has(cx, cz - 1)) wall(x0, z0, x1, z0);
|
|
674
|
+
if (!has(cx, cz + 1)) wall(x1, z1, x0, z1);
|
|
675
|
+
}
|
|
676
|
+
}
|
|
677
|
+
if (positions.length === 0) return piece;
|
|
678
|
+
const geometry = new THREE.BufferGeometry();
|
|
679
|
+
geometry.setAttribute('position', new THREE.BufferAttribute(new Float32Array(positions), 3));
|
|
680
|
+
geometry.computeVertexNormals();
|
|
681
|
+
addMesh(piece, geometry, settings, random, { name: 'Broken plank', slot: index % 3 === 0 ? 1 : 0 });
|
|
682
|
+
scatterObject(piece, settings, random, index, { stacked: plankThickness * 3, tilt: 0.35 });
|
|
683
|
+
settleOnGround(piece);
|
|
684
|
+
return piece;
|
|
685
|
+
}
|
|
686
|
+
|
|
687
|
+
// Sawn-end disc with baked growth rings: pale cut face, darker concentric
|
|
688
|
+
// rings, bark-colored rim. This is THE firewood read — a log without ring
|
|
689
|
+
// ends is just a fat lumpy branch.
|
|
690
|
+
function buildLogEndCapGeometry(radius, settings, random) {
|
|
691
|
+
const rings = 4;
|
|
692
|
+
const sectors = 14;
|
|
693
|
+
const face = paletteColor(settings, 2).multiplyScalar(1.1);
|
|
694
|
+
const ringLine = paletteColor(settings, 1).multiplyScalar(0.85);
|
|
695
|
+
const bark = paletteColor(settings, 0);
|
|
696
|
+
const positions = new Float32Array((rings * sectors + 1) * 3);
|
|
697
|
+
const colors = new Float32Array((rings * sectors + 1) * 3);
|
|
698
|
+
const swatch = new THREE.Color();
|
|
699
|
+
const writeColor = (offset, color) => {
|
|
700
|
+
colors[offset] = Math.min(color.r, 1);
|
|
701
|
+
colors[offset + 1] = Math.min(color.g, 1);
|
|
702
|
+
colors[offset + 2] = Math.min(color.b, 1);
|
|
703
|
+
};
|
|
704
|
+
positions[2] = radius * 0.06;
|
|
705
|
+
writeColor(0, face);
|
|
706
|
+
const wobbleSeed = Math.floor(random() * 0xffff);
|
|
707
|
+
for (let ring = 1; ring <= rings; ring += 1) {
|
|
708
|
+
const t = ring / rings;
|
|
709
|
+
for (let sector = 0; sector < sectors; sector += 1) {
|
|
710
|
+
const angle = (sector / sectors) * Math.PI * 2;
|
|
711
|
+
const wobble = 1 + 0.05 * simplexNoise3(wobbleSeed, Math.cos(angle) * 1.2, t, Math.sin(angle) * 1.2);
|
|
712
|
+
const r = radius * t * wobble;
|
|
713
|
+
const vertex = 1 + (ring - 1) * sectors + sector;
|
|
714
|
+
positions[vertex * 3] = Math.cos(angle) * r;
|
|
715
|
+
positions[vertex * 3 + 1] = Math.sin(angle) * r;
|
|
716
|
+
positions[vertex * 3 + 2] = radius * 0.06 * (1 - t * t);
|
|
717
|
+
swatch.copy(face);
|
|
718
|
+
if (ring === rings) swatch.copy(bark);
|
|
719
|
+
else if (ring % 2 === 0) swatch.lerp(ringLine, 0.65);
|
|
720
|
+
writeColor(vertex * 3, swatch);
|
|
721
|
+
}
|
|
722
|
+
}
|
|
723
|
+
const indices = [];
|
|
724
|
+
for (let sector = 0; sector < sectors; sector += 1) {
|
|
725
|
+
indices.push(0, 1 + sector, 1 + ((sector + 1) % sectors));
|
|
726
|
+
}
|
|
727
|
+
for (let ring = 0; ring < rings - 1; ring += 1) {
|
|
728
|
+
for (let sector = 0; sector < sectors; sector += 1) {
|
|
729
|
+
const a = 1 + ring * sectors + sector;
|
|
730
|
+
const b = 1 + ring * sectors + ((sector + 1) % sectors);
|
|
731
|
+
indices.push(a, a + sectors, b, b, a + sectors, b + sectors);
|
|
732
|
+
}
|
|
733
|
+
}
|
|
734
|
+
const geometry = new THREE.BufferGeometry();
|
|
735
|
+
geometry.setAttribute('position', new THREE.BufferAttribute(positions, 3));
|
|
736
|
+
geometry.setAttribute('color', new THREE.BufferAttribute(colors, 3));
|
|
737
|
+
geometry.setIndex(indices);
|
|
738
|
+
geometry.computeVertexNormals();
|
|
739
|
+
return geometry;
|
|
740
|
+
}
|
|
741
|
+
|
|
742
|
+
// Sawn firewood log: slim straight core (length ~4.5x diameter — fat
|
|
743
|
+
// stubby sweeps read as crumpled blobs under the toon shader), bark as
|
|
744
|
+
// lengthwise ridges via the sweep's cross-section profile (DM2's
|
|
745
|
+
// stretched bark noise), and growth-ring caps aligned to the true end
|
|
746
|
+
// tangents.
|
|
747
|
+
function createLogPiece(settings, random, index) {
|
|
748
|
+
const piece = new THREE.Group();
|
|
749
|
+
piece.name = 'Procedural sawn log';
|
|
750
|
+
const { branchiness, crookedness, length, thickness } = settings.shape;
|
|
751
|
+
const radius = thickness * mix(1.15, 1.45, random());
|
|
752
|
+
const logLength = Math.max(length * mix(0.55, 0.75, random()), radius * 3.6);
|
|
753
|
+
const bow = crookedness * logLength * 0.03;
|
|
754
|
+
const points = [
|
|
755
|
+
[-logLength / 2, radius, 0],
|
|
756
|
+
[0, radius + bow, signed(random) * crookedness * radius * 0.15],
|
|
757
|
+
[logLength / 2, radius, 0],
|
|
758
|
+
];
|
|
759
|
+
// Bark ridges: a wavy radius profile constant along the run.
|
|
760
|
+
const profileSeed = hashCombine(settings.asset.seed, 1100 + index * 71);
|
|
761
|
+
const ridgePhase = random() * Math.PI * 2;
|
|
762
|
+
const profile = [];
|
|
763
|
+
for (let k = 0; k < 16; k += 1) {
|
|
764
|
+
const angle = (k / 16) * Math.PI * 2;
|
|
765
|
+
profile.push(
|
|
766
|
+
1
|
|
767
|
+
+ 0.05 * Math.sin(angle * 5 + ridgePhase)
|
|
768
|
+
+ 0.05 * simplexNoise3(profileSeed, Math.cos(angle) * 1.4, 0, Math.sin(angle) * 1.4),
|
|
769
|
+
);
|
|
770
|
+
}
|
|
771
|
+
const swept = createBranchTubeGeometry({
|
|
772
|
+
irregularity: 0.05,
|
|
773
|
+
points,
|
|
774
|
+
profile,
|
|
775
|
+
radialSegments: 12,
|
|
776
|
+
radiusEnd: radius * 0.97,
|
|
777
|
+
radiusStart: radius,
|
|
778
|
+
ringSpacing: Math.max(logLength / 6, 0.05),
|
|
779
|
+
seed: settings.asset.seed + index * 67,
|
|
780
|
+
});
|
|
781
|
+
if (swept) addMesh(piece, swept.geometry, settings, random, { name: 'Log bark', slot: 0 });
|
|
782
|
+
for (const side of [-1, 1]) {
|
|
783
|
+
const capGeometry = buildLogEndCapGeometry(radius * 1.02, settings, random);
|
|
784
|
+
const cap = new THREE.Mesh(capGeometry, createMaterial(settings, { doubleSided: true }));
|
|
785
|
+
cap.name = 'Sawn end';
|
|
786
|
+
cap.castShadow = true;
|
|
787
|
+
cap.receiveShadow = true;
|
|
788
|
+
const end = points[side < 0 ? 0 : 2];
|
|
789
|
+
const inner = points[1];
|
|
790
|
+
const tangent = new THREE.Vector3(end[0] - inner[0], end[1] - inner[1], end[2] - inner[2]).normalize();
|
|
791
|
+
cap.position.set(end[0], end[1], end[2]).addScaledVector(tangent, -radius * 0.02);
|
|
792
|
+
cap.quaternion.setFromUnitVectors(new THREE.Vector3(0, 0, 1), tangent);
|
|
793
|
+
piece.add(cap);
|
|
794
|
+
}
|
|
795
|
+
const knotCount = Math.round(branchiness * 2 * random());
|
|
796
|
+
for (let knot = 0; knot < knotCount; knot += 1) {
|
|
797
|
+
const stubLength = radius * mix(0.3, 0.5, random());
|
|
798
|
+
const stub = addMesh(piece, new THREE.CylinderGeometry(radius * 0.13, radius * 0.2, stubLength, 6), settings, random, {
|
|
799
|
+
name: 'Branch stub', slot: 1,
|
|
800
|
+
});
|
|
801
|
+
const t = mix(0.25, 0.75, random());
|
|
802
|
+
const angle = random() * Math.PI;
|
|
803
|
+
stub.position.set(
|
|
804
|
+
(t - 0.5) * logLength,
|
|
805
|
+
radius + Math.cos(angle) * radius,
|
|
806
|
+
Math.sin(angle) * radius * (random() < 0.5 ? -1 : 1),
|
|
807
|
+
);
|
|
808
|
+
stub.rotation.x = Math.PI / 2 - angle;
|
|
809
|
+
}
|
|
810
|
+
scatterObject(piece, settings, random, index, { stacked: radius * 1.1, tilt: 0.2 });
|
|
811
|
+
settleOnGround(piece);
|
|
812
|
+
return piece;
|
|
813
|
+
}
|
|
814
|
+
|
|
815
|
+
function createWoodPiece(settings, random, index) {
|
|
816
|
+
if (settings.asset.variant === 'planks') return createPlankPiece(settings, random, index);
|
|
817
|
+
if (settings.asset.variant === 'logs') return createLogPiece(settings, random, index);
|
|
818
|
+
if (settings.asset.variant === 'rootStump') return createRootStumpPiece(settings, random, index);
|
|
819
|
+
if (settings.asset.variant === 'barkChips') return createBarkChipPiece(settings, random, index);
|
|
820
|
+
const piece = new THREE.Group();
|
|
821
|
+
piece.name = 'Procedural wood piece';
|
|
822
|
+
const { branchiness, crookedness, kinks, length, splinters, thickness } = settings.shape;
|
|
823
|
+
const twig = settings.asset.variant === 'twigPile';
|
|
824
|
+
const pieceLength = length * (twig ? mix(0.55, 1, random()) : mix(0.82, 1.12, random()));
|
|
825
|
+
const radius = thickness * (twig ? mix(0.55, 0.9, random()) : mix(0.85, 1.15, random()));
|
|
826
|
+
const points = [];
|
|
827
|
+
const segments = twig ? 4 : 6;
|
|
828
|
+
for (let step = 0; step < segments; step += 1) {
|
|
829
|
+
const t = step / (segments - 1);
|
|
830
|
+
points.push(new THREE.Vector3(
|
|
831
|
+
(t - 0.5) * pieceLength,
|
|
832
|
+
radius + Math.sin(t * Math.PI) * crookedness * pieceLength * 0.08 + signed(random) * crookedness * radius,
|
|
833
|
+
signed(random) * crookedness * pieceLength * 0.12,
|
|
834
|
+
));
|
|
835
|
+
}
|
|
836
|
+
applyKinks(points, kinks, random);
|
|
837
|
+
if (twig) {
|
|
838
|
+
const swept = createBranchTubeGeometry({
|
|
839
|
+
irregularity: 0.18 + crookedness * 0.12,
|
|
840
|
+
points: points.map((point) => point.toArray()),
|
|
841
|
+
radialSegments: 6,
|
|
842
|
+
radiusEnd: radius * 0.22,
|
|
843
|
+
radiusStart: radius * 1.08,
|
|
844
|
+
ringSpacing: Math.max(0.035, radius * 0.7),
|
|
845
|
+
seed: settings.asset.seed + index * 19,
|
|
846
|
+
});
|
|
847
|
+
if (swept) addMesh(piece, swept.geometry, settings, random, { name: 'Tree Lab twig sweep', slot: 0 });
|
|
848
|
+
} else {
|
|
849
|
+
// This is the same central-leader branch builder Tree Lab uses. A
|
|
850
|
+
// horizontal hand-drawn spine becomes the fallen limb; Tree Lab grows
|
|
851
|
+
// radius-continuous child forks and tapered terminal twigs from it.
|
|
852
|
+
const grown = createBranchingTreeSkeleton({
|
|
853
|
+
branchAngle: mix(48, 76, branchiness),
|
|
854
|
+
branchStart: mix(0.18, 0.32, random()),
|
|
855
|
+
canopyScale: 1,
|
|
856
|
+
childrenCount: Math.max(1, Math.round(1 + branchiness * 4)),
|
|
857
|
+
forceStrength: 0,
|
|
858
|
+
gnarliness: 0.06 + crookedness * 0.22,
|
|
859
|
+
leafSpacing: 10,
|
|
860
|
+
lengthRatio: 0.26 + branchiness * 0.16,
|
|
861
|
+
levels: branchiness > 0.67 ? 2 : 1,
|
|
862
|
+
maxAttachments: 1,
|
|
863
|
+
maxBranches: branchiness > 0.67 ? 18 : Math.max(3, Math.round(2 + branchiness * 5)),
|
|
864
|
+
radialSegments: 8,
|
|
865
|
+
radiusRatio: 0.58,
|
|
866
|
+
seed: settings.asset.seed + index * 37,
|
|
867
|
+
tipRadius: Math.max(radius * 0.075, 0.006),
|
|
868
|
+
trunk: { gnarl: crookedness * 0.25, radiusBottom: radius * 1.08 },
|
|
869
|
+
trunkSpine: points.map((point) => point.toArray()),
|
|
870
|
+
});
|
|
871
|
+
const grownMesh = addMesh(piece, grown.geometry, settings, random, {
|
|
872
|
+
name: settings.asset.variant === 'driftwood' ? 'Tree Lab driftwood branch' : 'Tree Lab fallen branch',
|
|
873
|
+
slot: 0,
|
|
874
|
+
});
|
|
875
|
+
applyBarkStripping(grownMesh.geometry, settings, settings.asset.seed + index * 37, settings.shape.barkStripped);
|
|
876
|
+
|
|
877
|
+
// Knuckles use the same embedded-bulge language as Tree Lab wood
|
|
878
|
+
// details. They break the silhouette without faking branches as pipes.
|
|
879
|
+
const knotCount = Math.round(branchiness * 3);
|
|
880
|
+
for (let knotIndex = 0; knotIndex < knotCount; knotIndex += 1) {
|
|
881
|
+
const pointIndex = 1 + Math.floor(random() * (points.length - 2));
|
|
882
|
+
const knot = addMesh(piece, new THREE.IcosahedronGeometry(radius * mix(0.55, 0.82, random()), 1), settings, random, {
|
|
883
|
+
name: 'Branch knuckle', slot: knotIndex % 3 === 0 ? 1 : 0,
|
|
884
|
+
});
|
|
885
|
+
knot.position.copy(points[pointIndex]);
|
|
886
|
+
knot.scale.set(1.25, 0.82, 1.05);
|
|
887
|
+
}
|
|
888
|
+
}
|
|
889
|
+
if (splinters > 0.08) {
|
|
890
|
+
// The detached base is the visibly broken end; terminal forks keep the
|
|
891
|
+
// natural taper produced by Tree Lab's branch builder.
|
|
892
|
+
const brokenDirection = points[0].clone().sub(points[1]);
|
|
893
|
+
if (!twig) {
|
|
894
|
+
const cap = addMesh(piece, new THREE.CircleGeometry(radius * 1.07, 8), settings, random, {
|
|
895
|
+
doubleSided: true, name: 'Broken branch end', slot: 2,
|
|
896
|
+
});
|
|
897
|
+
cap.position.copy(points[0]);
|
|
898
|
+
cap.quaternion.setFromUnitVectors(new THREE.Vector3(0, 0, 1), brokenDirection.clone().normalize());
|
|
899
|
+
}
|
|
900
|
+
addSplinters(piece, points[0], brokenDirection, settings, random, splinters, radius);
|
|
901
|
+
// Structural prongs: big tapered forks at the break, not just fibers —
|
|
902
|
+
// the splayed multi-prong driftwood read.
|
|
903
|
+
if (!twig && splinters > 0.35) {
|
|
904
|
+
const prongCount = 1 + Math.round(splinters * 2);
|
|
905
|
+
const direction = brokenDirection.clone().normalize();
|
|
906
|
+
for (let prong = 0; prong < prongCount; prong += 1) {
|
|
907
|
+
const prongLength = radius * mix(3, 6, random());
|
|
908
|
+
const cone = new THREE.Vector3(
|
|
909
|
+
signed(random) * 0.5, mix(0.1, 0.5, random()), signed(random) * 0.5,
|
|
910
|
+
).multiplyScalar(0.6);
|
|
911
|
+
const end = points[0].clone()
|
|
912
|
+
.addScaledVector(direction, prongLength)
|
|
913
|
+
.addScaledVector(cone, prongLength * 0.6);
|
|
914
|
+
const middle = points[0].clone().lerp(end, 0.5).add(new THREE.Vector3(
|
|
915
|
+
signed(random) * radius * 0.5, random() * radius * 0.6, signed(random) * radius * 0.5,
|
|
916
|
+
));
|
|
917
|
+
const sweptProng = createBranchTubeGeometry({
|
|
918
|
+
irregularity: 0.2,
|
|
919
|
+
points: [points[0].toArray(), middle.toArray(), end.toArray()],
|
|
920
|
+
radialSegments: 6,
|
|
921
|
+
radiusEnd: radius * 0.03,
|
|
922
|
+
radiusStart: radius * mix(0.28, 0.42, random()),
|
|
923
|
+
ringSpacing: Math.max(prongLength / 8, 0.03),
|
|
924
|
+
seed: settings.asset.seed + index * 37 + prong * 13 + 5,
|
|
925
|
+
});
|
|
926
|
+
if (!sweptProng) continue;
|
|
927
|
+
const prongMesh = addMesh(piece, sweptProng.geometry, settings, random, {
|
|
928
|
+
name: 'Broken prong', slot: prong % 2 === 0 ? 1 : 0,
|
|
929
|
+
});
|
|
930
|
+
applyBarkStripping(prongMesh.geometry, settings, settings.asset.seed + prong * 91, settings.shape.barkStripped);
|
|
931
|
+
}
|
|
932
|
+
}
|
|
933
|
+
}
|
|
934
|
+
scatterObject(piece, settings, random, index, { stacked: twig ? radius * 5 : radius * 2, tilt: 0.4 });
|
|
935
|
+
settleOnGround(piece);
|
|
936
|
+
return piece;
|
|
937
|
+
}
|
|
938
|
+
|
|
939
|
+
// SDF bone forms are meshed once per unique variant and shared across
|
|
940
|
+
// instances (Quixel packs ship 2-4 scan variants and scatter them; nobody
|
|
941
|
+
// notices at debris scale, and it keeps slider drags interactive). The
|
|
942
|
+
// per-instance read comes from rotation, mirroring, scale, and a small
|
|
943
|
+
// material tint.
|
|
944
|
+
const BONE_FIELD_BUILDERS = Object.freeze({
|
|
945
|
+
jawBone: { builder: createJawField, name: 'SDF jaw bone', unique: 2 },
|
|
946
|
+
longBone: { builder: createLongBoneField, name: 'SDF long bone', unique: 3 },
|
|
947
|
+
skull: { builder: createSkullField, name: 'SDF skull', unique: 2 },
|
|
948
|
+
});
|
|
949
|
+
|
|
950
|
+
function buildBonePool(settings, count) {
|
|
951
|
+
const entry = BONE_FIELD_BUILDERS[settings.asset.variant] ?? BONE_FIELD_BUILDERS.longBone;
|
|
952
|
+
const geometries = [];
|
|
953
|
+
const uniqueCount = Math.min(count, entry.unique);
|
|
954
|
+
for (let unique = 0; unique < uniqueCount; unique += 1) {
|
|
955
|
+
const fieldSeed = hashCombine(settings.asset.seed, 977 + unique * 131);
|
|
956
|
+
const field = entry.builder(settings.shape, fieldSeed);
|
|
957
|
+
// High cavity tint: toon banding compresses shading, so sockets and
|
|
958
|
+
// grooves need their darkening baked strongly into vertex color.
|
|
959
|
+
const geometry = meshDebrisField(field, settings.surface, fieldSeed, { cavityTint: 0.95 });
|
|
960
|
+
if (geometry) geometries.push(geometry);
|
|
961
|
+
}
|
|
962
|
+
return geometries.length > 0 ? { geometries, name: entry.name } : null;
|
|
963
|
+
}
|
|
964
|
+
|
|
965
|
+
function createPooledPiece(pool, geometry, settings, random, index, { mirror = false, stacked, tilt }) {
|
|
966
|
+
const piece = new THREE.Group();
|
|
967
|
+
piece.name = pool.name;
|
|
968
|
+
const mesh = new THREE.Mesh(geometry, createMaterial(settings));
|
|
969
|
+
mesh.name = pool.name;
|
|
970
|
+
mesh.castShadow = true;
|
|
971
|
+
mesh.receiveShadow = true;
|
|
972
|
+
// Per-instance variation without re-meshing: value tint, size jitter,
|
|
973
|
+
// and Z-mirroring (chiral bones come from both sides of the animal).
|
|
974
|
+
mesh.material.color.setScalar(1 + signed(random) * settings.surface.variation * 0.4);
|
|
975
|
+
mesh.scale.setScalar(mix(0.86, 1.14, random()));
|
|
976
|
+
if (mirror && random() < 0.5) mesh.scale.z *= -1;
|
|
977
|
+
piece.add(mesh);
|
|
978
|
+
scatterObject(piece, settings, random, index, { stacked, tilt });
|
|
979
|
+
settleOnGround(piece);
|
|
980
|
+
return piece;
|
|
981
|
+
}
|
|
982
|
+
|
|
983
|
+
function samplePolyline(points, t) {
|
|
984
|
+
const scaled = Math.min(Math.max(t, 0), 1) * (points.length - 1);
|
|
985
|
+
const index = Math.min(Math.floor(scaled), points.length - 2);
|
|
986
|
+
const local = scaled - index;
|
|
987
|
+
const a = points[index];
|
|
988
|
+
const b = points[index + 1];
|
|
989
|
+
return [
|
|
990
|
+
a[0] + (b[0] - a[0]) * local,
|
|
991
|
+
a[1] + (b[1] - a[1]) * local,
|
|
992
|
+
a[2] + (b[2] - a[2]) * local,
|
|
993
|
+
];
|
|
994
|
+
}
|
|
995
|
+
|
|
996
|
+
// Antlers are branches: a curling main beam swept with Tree Lab's tube
|
|
997
|
+
// builder (organic cross-section, taper) and forward-curving tines that
|
|
998
|
+
// shorten toward the tip.
|
|
999
|
+
function createAntler(settings, random, index) {
|
|
1000
|
+
const piece = new THREE.Group();
|
|
1001
|
+
piece.name = 'Procedural antler';
|
|
1002
|
+
const { curvature, jointSize, length, thickness } = settings.shape;
|
|
1003
|
+
const sweepAngle = 0.7 + curvature * 1.6;
|
|
1004
|
+
const arcRadius = length / sweepAngle;
|
|
1005
|
+
const wobblePhase = random() * Math.PI * 2;
|
|
1006
|
+
const beamPoints = [];
|
|
1007
|
+
for (let step = 0; step < 7; step += 1) {
|
|
1008
|
+
const t = step / 6;
|
|
1009
|
+
const angle = sweepAngle * t;
|
|
1010
|
+
beamPoints.push([
|
|
1011
|
+
arcRadius * Math.sin(angle),
|
|
1012
|
+
thickness + arcRadius * (1 - Math.cos(angle)),
|
|
1013
|
+
length * 0.05 * (0.4 + curvature) * Math.sin(t * Math.PI * 2 + wobblePhase),
|
|
1014
|
+
]);
|
|
1015
|
+
}
|
|
1016
|
+
const beam = createBranchTubeGeometry({
|
|
1017
|
+
irregularity: 0.18,
|
|
1018
|
+
points: beamPoints,
|
|
1019
|
+
radialSegments: 7,
|
|
1020
|
+
radiusEnd: thickness * 0.2,
|
|
1021
|
+
radiusStart: thickness * 1.05,
|
|
1022
|
+
ringSpacing: Math.max(length / 26, 0.03),
|
|
1023
|
+
seed: settings.asset.seed + index * 53,
|
|
1024
|
+
});
|
|
1025
|
+
if (beam) addMesh(piece, beam.geometry, settings, random, { name: 'Antler beam', slot: 0 });
|
|
1026
|
+
const burr = addMesh(piece, irregularGeometry(thickness * 1.45, random, 0.5, 1, settings.asset.seed + index), settings, random, {
|
|
1027
|
+
name: 'Antler burr', slot: 1,
|
|
1028
|
+
});
|
|
1029
|
+
burr.position.set(beamPoints[0][0], beamPoints[0][1], beamPoints[0][2]);
|
|
1030
|
+
burr.scale.set(1, 0.55, 1);
|
|
1031
|
+
const tineCount = 2 + Math.round(jointSize * 2.5);
|
|
1032
|
+
for (let tine = 0; tine < tineCount; tine += 1) {
|
|
1033
|
+
const along = 0.16 + 0.62 * (tine / Math.max(tineCount - 1, 1));
|
|
1034
|
+
const base = samplePolyline(beamPoints, along);
|
|
1035
|
+
const tineLength = length * mix(0.2, 0.34, random()) * (1 - along * 0.45);
|
|
1036
|
+
const side = (tine % 2 ? -1 : 1) * mix(0.2, 0.5, random());
|
|
1037
|
+
const mid = [
|
|
1038
|
+
base[0] - tineLength * 0.12,
|
|
1039
|
+
base[1] + tineLength * 0.5,
|
|
1040
|
+
base[2] + side * tineLength * 0.4,
|
|
1041
|
+
];
|
|
1042
|
+
const end = [
|
|
1043
|
+
base[0] - tineLength * 0.38,
|
|
1044
|
+
base[1] + tineLength * 0.92,
|
|
1045
|
+
base[2] + side * tineLength * 0.55,
|
|
1046
|
+
];
|
|
1047
|
+
const swept = createBranchTubeGeometry({
|
|
1048
|
+
irregularity: 0.14,
|
|
1049
|
+
points: [base, mid, end],
|
|
1050
|
+
radialSegments: 6,
|
|
1051
|
+
radiusEnd: thickness * 0.07,
|
|
1052
|
+
radiusStart: thickness * mix(0.45, 0.62, 1 - along),
|
|
1053
|
+
ringSpacing: Math.max(tineLength / 8, 0.02),
|
|
1054
|
+
seed: settings.asset.seed + index * 53 + tine * 7 + 1,
|
|
1055
|
+
});
|
|
1056
|
+
if (swept) addMesh(piece, swept.geometry, settings, random, {
|
|
1057
|
+
name: 'Antler tine', slot: 1,
|
|
1058
|
+
});
|
|
1059
|
+
}
|
|
1060
|
+
scatterObject(piece, settings, random, index, { stacked: thickness, tilt: 0.6 });
|
|
1061
|
+
settleOnGround(piece);
|
|
1062
|
+
return piece;
|
|
1063
|
+
}
|
|
1064
|
+
|
|
1065
|
+
// Masonry fragments are mini-rocks: base solid + planar fracture cuts +
|
|
1066
|
+
// cellular facet creases, meshed once per unique variant and shared.
|
|
1067
|
+
function buildStonePool(settings, count) {
|
|
1068
|
+
const geometries = [];
|
|
1069
|
+
const uniqueCount = Math.min(count, settings.asset.variant === 'gems' ? 3 : 4);
|
|
1070
|
+
for (let unique = 0; unique < uniqueCount; unique += 1) {
|
|
1071
|
+
const fieldSeed = hashCombine(settings.asset.seed, 1201 + unique * 157);
|
|
1072
|
+
const field = createStoneField(settings.shape, settings.asset.variant, fieldSeed);
|
|
1073
|
+
const geometry = meshDebrisField(field, settings.surface, fieldSeed, {
|
|
1074
|
+
cavityTint: settings.asset.variant === 'gems' ? 0.2 : 0.5,
|
|
1075
|
+
});
|
|
1076
|
+
if (geometry) geometries.push(geometry);
|
|
1077
|
+
}
|
|
1078
|
+
return geometries.length > 0 ? { geometries, name: `SDF ${settings.asset.variant}` } : null;
|
|
1079
|
+
}
|
|
1080
|
+
|
|
1081
|
+
const MASONRY_VARIANTS = new Set(['rubble', 'bricks', 'shards']);
|
|
1082
|
+
|
|
1083
|
+
// The merged stone type exposes unified sliders; masonry builders keep
|
|
1084
|
+
// their historical parameter names via this adapter.
|
|
1085
|
+
function masonryShapeAdapter(shape) {
|
|
1086
|
+
return { ...shape, angularity: shape.sharpness, fracture: shape.detail };
|
|
1087
|
+
}
|
|
1088
|
+
|
|
1089
|
+
function buildMasonryPool(settings, count) {
|
|
1090
|
+
const variant = settings.asset.variant;
|
|
1091
|
+
const kinds = variant === 'bricks'
|
|
1092
|
+
? [['brick', 3]]
|
|
1093
|
+
: variant === 'shards'
|
|
1094
|
+
? [['shard', Math.min(count, 4)]]
|
|
1095
|
+
: [['chunk', Math.min(count, 3)], ['brick', 2]];
|
|
1096
|
+
const pool = { brick: [], chunk: [], shard: [] };
|
|
1097
|
+
for (const [kind, uniqueCount] of kinds) {
|
|
1098
|
+
for (let unique = 0; unique < uniqueCount; unique += 1) {
|
|
1099
|
+
const fieldSeed = hashCombine(settings.asset.seed, 449 + unique * 173 + kind.length);
|
|
1100
|
+
const field = createMasonryChunkField(masonryShapeAdapter(settings.shape), kind, fieldSeed);
|
|
1101
|
+
const geometry = meshDebrisField(field, settings.surface, fieldSeed, { cavityTint: 0.45 });
|
|
1102
|
+
if (geometry) pool[kind].push(geometry);
|
|
1103
|
+
}
|
|
1104
|
+
}
|
|
1105
|
+
return pool.brick.length + pool.chunk.length + pool.shard.length > 0 ? pool : null;
|
|
1106
|
+
}
|
|
1107
|
+
|
|
1108
|
+
function createMasonryPiece(settings, random, index, pool) {
|
|
1109
|
+
const { brickRatio, chunkSize, stacking } = settings.shape;
|
|
1110
|
+
const variant = settings.asset.variant;
|
|
1111
|
+
const wantBrick = variant === 'bricks' || (variant === 'rubble' && random() < brickRatio);
|
|
1112
|
+
const kind = variant === 'shards' ? 'shard' : wantBrick && pool.brick.length > 0 ? 'brick' : 'chunk';
|
|
1113
|
+
const geometries = pool[kind].length > 0 ? pool[kind] : pool.chunk.length > 0 ? pool.chunk : pool.brick;
|
|
1114
|
+
const geometry = geometries[(index + Math.floor(random() * geometries.length)) % geometries.length];
|
|
1115
|
+
return createPooledPiece(
|
|
1116
|
+
{ name: kind === 'brick' ? 'Broken brick' : kind === 'shard' ? 'Stone shard' : 'Concrete chunk' },
|
|
1117
|
+
geometry, settings, random, index,
|
|
1118
|
+
{ mirror: true, stacked: chunkSize * stacking * 1.5, tilt: 0.8 },
|
|
1119
|
+
);
|
|
1120
|
+
}
|
|
1121
|
+
|
|
1122
|
+
// Torn metal sheet, following DebrisMaker2's corrugated-metal recipe:
|
|
1123
|
+
// the silhouette is CARVED (corner slices + noisy edge bites + punched
|
|
1124
|
+
// rust-hole clusters remove triangles, leaving raw jagged borders), the
|
|
1125
|
+
// corrugation is a rounded sine with amplitude ~= 0.27x wavelength, and
|
|
1126
|
+
// crumpling is layered AFTER corrugation as low-frequency coherent noise
|
|
1127
|
+
// plus a few straight crease folds. A rectangle with sine bumps reads as
|
|
1128
|
+
// a rubber mat; a torn outline is what says "sheet metal".
|
|
1129
|
+
function buildTornSheetGeometry(settings, random, seed) {
|
|
1130
|
+
const { bend, corrugation, rust, sheetSize } = settings.shape;
|
|
1131
|
+
const width = sheetSize * mix(0.8, 1.25, random());
|
|
1132
|
+
const length = width * mix(1.25, 1.85, random());
|
|
1133
|
+
const cellsX = 30;
|
|
1134
|
+
const cellsZ = 42;
|
|
1135
|
+
|
|
1136
|
+
// Silhouette cutters, all in the sheet's XZ plane.
|
|
1137
|
+
const slices = [];
|
|
1138
|
+
const sliceCount = 1 + Math.floor(random() * 3);
|
|
1139
|
+
for (let i = 0; i < sliceCount; i += 1) {
|
|
1140
|
+
const theta = random() * Math.PI * 2;
|
|
1141
|
+
const nx = Math.cos(theta);
|
|
1142
|
+
const nz = Math.sin(theta);
|
|
1143
|
+
const support = Math.abs(nx) * width * 0.5 + Math.abs(nz) * length * 0.5;
|
|
1144
|
+
slices.push([nx, nz, support * (1 - mix(0.1, 0.4, random()))]);
|
|
1145
|
+
}
|
|
1146
|
+
const bites = [];
|
|
1147
|
+
const biteCount = 1 + Math.floor(random() * 2);
|
|
1148
|
+
for (let i = 0; i < biteCount; i += 1) {
|
|
1149
|
+
const edge = random() * Math.PI * 2;
|
|
1150
|
+
bites.push([
|
|
1151
|
+
Math.cos(edge) * width * 0.5,
|
|
1152
|
+
Math.sin(edge) * length * 0.5,
|
|
1153
|
+
width * mix(0.14, 0.32, random()),
|
|
1154
|
+
hashCombine(seed, 41 + i),
|
|
1155
|
+
]);
|
|
1156
|
+
}
|
|
1157
|
+
const holes = [];
|
|
1158
|
+
const clusterCount = Math.round(rust * 3);
|
|
1159
|
+
for (let cluster = 0; cluster < clusterCount; cluster += 1) {
|
|
1160
|
+
const cx = signed(random) * width * 0.32;
|
|
1161
|
+
const cz = signed(random) * length * 0.34;
|
|
1162
|
+
const count = 3 + Math.floor(random() * 5);
|
|
1163
|
+
for (let i = 0; i < count; i += 1) {
|
|
1164
|
+
holes.push([
|
|
1165
|
+
cx + signed(random) * width * 0.1,
|
|
1166
|
+
cz + signed(random) * width * 0.1,
|
|
1167
|
+
width * mix(0.02, 0.055, random()),
|
|
1168
|
+
]);
|
|
1169
|
+
}
|
|
1170
|
+
}
|
|
1171
|
+
const inside = (x, z) => {
|
|
1172
|
+
for (const [nx, nz, d] of slices) if (nx * x + nz * z > d) return false;
|
|
1173
|
+
for (const [bx, bz, radius, biteSeed] of bites) {
|
|
1174
|
+
const wobble = 1 + 0.45 * simplexNoise3(biteSeed, x * 3 / width, 0, z * 3 / width);
|
|
1175
|
+
if ((x - bx) ** 2 + (z - bz) ** 2 < (radius * wobble) ** 2) return false;
|
|
1176
|
+
}
|
|
1177
|
+
for (const [hx, hz, radius] of holes) {
|
|
1178
|
+
if ((x - hx) ** 2 + (z - hz) ** 2 < radius * radius) return false;
|
|
1179
|
+
}
|
|
1180
|
+
return true;
|
|
1181
|
+
};
|
|
1182
|
+
|
|
1183
|
+
// Height field: rounded-sine corrugation + coherent crumple + creases.
|
|
1184
|
+
const waveCount = 4 + corrugation * 8;
|
|
1185
|
+
const wavelength = width / waveCount;
|
|
1186
|
+
const amplitude = corrugation * wavelength * 0.27;
|
|
1187
|
+
const crumpleSeed = hashCombine(seed, 7);
|
|
1188
|
+
const creases = [];
|
|
1189
|
+
const creaseCount = 1 + Math.round(bend * 2);
|
|
1190
|
+
for (let i = 0; i < creaseCount; i += 1) {
|
|
1191
|
+
const theta = random() * Math.PI * 2;
|
|
1192
|
+
creases.push([
|
|
1193
|
+
Math.cos(theta), Math.sin(theta),
|
|
1194
|
+
signed(random) * width * 0.25,
|
|
1195
|
+
(i % 2 ? -1 : 1) * bend * sheetSize * mix(0.04, 0.09, random()),
|
|
1196
|
+
]);
|
|
1197
|
+
}
|
|
1198
|
+
const heightAt = (x, z) => {
|
|
1199
|
+
let y = amplitude * Math.sin((x / wavelength) * Math.PI * 2);
|
|
1200
|
+
y += bend * sheetSize * 0.1 * fbm3(crumpleSeed, x * 1.5 / sheetSize, 0, z * 1.5 / sheetSize, 2, 2, 0.5);
|
|
1201
|
+
for (const [nx, nz, offset, lift] of creases) {
|
|
1202
|
+
const distance = Math.abs(nx * x + nz * z - offset);
|
|
1203
|
+
y += lift * Math.max(0, 1 - distance / (sheetSize * 0.45));
|
|
1204
|
+
}
|
|
1205
|
+
return y;
|
|
1206
|
+
};
|
|
1207
|
+
|
|
1208
|
+
const positions = [];
|
|
1209
|
+
const colors = [];
|
|
1210
|
+
const primary = paletteColor(settings, 1);
|
|
1211
|
+
const rustColor = paletteColor(settings, 2);
|
|
1212
|
+
const rustSeed = hashCombine(seed, 19);
|
|
1213
|
+
const swatch = new THREE.Color();
|
|
1214
|
+
const pushVertex = (x, z) => {
|
|
1215
|
+
positions.push(x, heightAt(x, z), z);
|
|
1216
|
+
// Rust patches: coherent-noise mask blending toward the oxidized
|
|
1217
|
+
// accent, denser as `rust` rises (DM2 pairs holes with rust zones).
|
|
1218
|
+
const mask = 0.5 + 0.5 * simplexNoise3(rustSeed, x * 2.6 / width, 0, z * 2.6 / width);
|
|
1219
|
+
swatch.copy(primary);
|
|
1220
|
+
if (mask < rust * 0.62) swatch.lerp(rustColor, mix(0.45, 0.9, 1 - mask / Math.max(rust * 0.62, 0.001)));
|
|
1221
|
+
swatch.multiplyScalar(1 + signed(random) * settings.surface.variation * 0.5);
|
|
1222
|
+
colors.push(Math.min(swatch.r, 1), Math.min(swatch.g, 1), Math.min(swatch.b, 1));
|
|
1223
|
+
};
|
|
1224
|
+
for (let cz = 0; cz < cellsZ; cz += 1) {
|
|
1225
|
+
for (let cx = 0; cx < cellsX; cx += 1) {
|
|
1226
|
+
const x0 = (cx / cellsX - 0.5) * width;
|
|
1227
|
+
const x1 = ((cx + 1) / cellsX - 0.5) * width;
|
|
1228
|
+
const z0 = (cz / cellsZ - 0.5) * length;
|
|
1229
|
+
const z1 = ((cz + 1) / cellsZ - 0.5) * length;
|
|
1230
|
+
const xm = (x0 + x1) / 2;
|
|
1231
|
+
const zm = (z0 + z1) / 2;
|
|
1232
|
+
if (!inside(xm, zm)) continue;
|
|
1233
|
+
pushVertex(x0, z0); pushVertex(x0, z1); pushVertex(x1, z1);
|
|
1234
|
+
pushVertex(x0, z0); pushVertex(x1, z1); pushVertex(x1, z0);
|
|
1235
|
+
}
|
|
1236
|
+
}
|
|
1237
|
+
if (positions.length === 0) return null;
|
|
1238
|
+
const geometry = new THREE.BufferGeometry();
|
|
1239
|
+
geometry.setAttribute('position', new THREE.BufferAttribute(new Float32Array(positions), 3));
|
|
1240
|
+
geometry.setAttribute('color', new THREE.BufferAttribute(new Float32Array(colors), 3));
|
|
1241
|
+
geometry.computeVertexNormals();
|
|
1242
|
+
return geometry;
|
|
1243
|
+
}
|
|
1244
|
+
|
|
1245
|
+
function createSheet(settings, random, index) {
|
|
1246
|
+
const piece = new THREE.Group();
|
|
1247
|
+
const geometry = buildTornSheetGeometry(settings, random, hashCombine(settings.asset.seed, 600 + index * 37));
|
|
1248
|
+
if (!geometry) return piece;
|
|
1249
|
+
const mesh = new THREE.Mesh(geometry, createMaterial(settings, { doubleSided: true, metalness: 0.72 }));
|
|
1250
|
+
mesh.name = 'Torn sheet metal';
|
|
1251
|
+
mesh.castShadow = true;
|
|
1252
|
+
mesh.receiveShadow = true;
|
|
1253
|
+
piece.add(mesh);
|
|
1254
|
+
scatterObject(piece, settings, random, index, { stacked: settings.shape.sheetSize * 0.12, tilt: 0.48 });
|
|
1255
|
+
settleOnGround(piece);
|
|
1256
|
+
return piece;
|
|
1257
|
+
}
|
|
1258
|
+
|
|
1259
|
+
// Shrapnel fragment, following DebrisMaker2's recipe: a flat plate whose
|
|
1260
|
+
// silhouette is carved by a noisy blob, then folded hard (30-200 deg)
|
|
1261
|
+
// around an arbitrary in-plane axis — the fold is what reads as torn,
|
|
1262
|
+
// twisted metal.
|
|
1263
|
+
function createShrapnelPiece(settings, random, index) {
|
|
1264
|
+
const piece = new THREE.Group();
|
|
1265
|
+
const { sheetSize } = settings.shape;
|
|
1266
|
+
const size = sheetSize * mix(0.3, 0.55, random());
|
|
1267
|
+
const seed = hashCombine(settings.asset.seed, 700 + index * 41);
|
|
1268
|
+
const cells = 16;
|
|
1269
|
+
const blobSeed = hashCombine(seed, 3);
|
|
1270
|
+
const inside = (x, z) => {
|
|
1271
|
+
const angle = Math.atan2(z, x);
|
|
1272
|
+
const edge = size * 0.5 * (1 + 0.55 * simplexNoise3(blobSeed, Math.cos(angle) * 1.3, 0, Math.sin(angle) * 1.3));
|
|
1273
|
+
return Math.sqrt(x * x + z * z) < edge;
|
|
1274
|
+
};
|
|
1275
|
+
const foldTheta = random() * Math.PI * 2;
|
|
1276
|
+
const foldN = [Math.cos(foldTheta), Math.sin(foldTheta)];
|
|
1277
|
+
const foldAngle = mix(0.5, 2.6, random());
|
|
1278
|
+
const foldRadius = size / foldAngle;
|
|
1279
|
+
const noiseSeed = hashCombine(seed, 11);
|
|
1280
|
+
const positions = [];
|
|
1281
|
+
const pushVertex = (x, z) => {
|
|
1282
|
+
// Cylindrical bend about the fold axis.
|
|
1283
|
+
const along = foldN[0] * x + foldN[1] * z;
|
|
1284
|
+
const arc = along / foldRadius;
|
|
1285
|
+
const bentAlong = Math.sin(arc) * foldRadius;
|
|
1286
|
+
const y = (1 - Math.cos(arc)) * foldRadius
|
|
1287
|
+
+ size * 0.05 * simplexNoise3(noiseSeed, x * 3 / size, 0, z * 3 / size);
|
|
1288
|
+
const across = -foldN[1] * x + foldN[0] * z;
|
|
1289
|
+
positions.push(
|
|
1290
|
+
foldN[0] * bentAlong - foldN[1] * across,
|
|
1291
|
+
y,
|
|
1292
|
+
foldN[1] * bentAlong + foldN[0] * across,
|
|
1293
|
+
);
|
|
1294
|
+
};
|
|
1295
|
+
for (let cz = 0; cz < cells; cz += 1) {
|
|
1296
|
+
for (let cx = 0; cx < cells; cx += 1) {
|
|
1297
|
+
const x0 = (cx / cells - 0.5) * size;
|
|
1298
|
+
const x1 = ((cx + 1) / cells - 0.5) * size;
|
|
1299
|
+
const z0 = (cz / cells - 0.5) * size;
|
|
1300
|
+
const z1 = ((cz + 1) / cells - 0.5) * size;
|
|
1301
|
+
if (!inside((x0 + x1) / 2, (z0 + z1) / 2)) continue;
|
|
1302
|
+
pushVertex(x0, z0); pushVertex(x0, z1); pushVertex(x1, z1);
|
|
1303
|
+
pushVertex(x0, z0); pushVertex(x1, z1); pushVertex(x1, z0);
|
|
1304
|
+
}
|
|
1305
|
+
}
|
|
1306
|
+
if (positions.length === 0) return piece;
|
|
1307
|
+
const geometry = new THREE.BufferGeometry();
|
|
1308
|
+
geometry.setAttribute('position', new THREE.BufferAttribute(new Float32Array(positions), 3));
|
|
1309
|
+
geometry.computeVertexNormals();
|
|
1310
|
+
addMesh(piece, geometry, settings, random, {
|
|
1311
|
+
doubleSided: true,
|
|
1312
|
+
metalness: 0.78,
|
|
1313
|
+
name: 'Shrapnel fragment',
|
|
1314
|
+
slot: random() < settings.shape.rust ? 2 : 1,
|
|
1315
|
+
});
|
|
1316
|
+
scatterObject(piece, settings, random, index, { stacked: size * 0.2, tilt: 0.85 });
|
|
1317
|
+
settleOnGround(piece);
|
|
1318
|
+
return piece;
|
|
1319
|
+
}
|
|
1320
|
+
|
|
1321
|
+
function createCan(settings, random, index) {
|
|
1322
|
+
const piece = new THREE.Group();
|
|
1323
|
+
const size = settings.shape.sheetSize * mix(0.7, 1.05, random());
|
|
1324
|
+
const height = size * mix(0.8, 1.35, random());
|
|
1325
|
+
const radius = size * 0.28;
|
|
1326
|
+
// Crush dents: coherent radial noise displacement (uniform squash alone
|
|
1327
|
+
// reads as a clean cylinder, not a kicked can).
|
|
1328
|
+
const bodyGeometry = new THREE.CylinderGeometry(radius, radius * mix(0.9, 1.05, random()), height, 10, 4);
|
|
1329
|
+
const dentSeed = hashCombine(settings.asset.seed, 900 + index * 31);
|
|
1330
|
+
const bodyPosition = bodyGeometry.attributes.position;
|
|
1331
|
+
for (let vertex = 0; vertex < bodyPosition.count; vertex += 1) {
|
|
1332
|
+
const px = bodyPosition.getX(vertex);
|
|
1333
|
+
const py = bodyPosition.getY(vertex);
|
|
1334
|
+
const pz = bodyPosition.getZ(vertex);
|
|
1335
|
+
const radial = Math.hypot(px, pz) || 1;
|
|
1336
|
+
const dent = settings.shape.bend * 0.3
|
|
1337
|
+
* Math.min(simplexNoise3(dentSeed, px * 2.4 / radius, py * 1.2 / radius, pz * 2.4 / radius), 0.15);
|
|
1338
|
+
bodyPosition.setX(vertex, px * (1 + dent * radius / radial));
|
|
1339
|
+
bodyPosition.setZ(vertex, pz * (1 + dent * radius / radial));
|
|
1340
|
+
}
|
|
1341
|
+
bodyGeometry.computeVertexNormals();
|
|
1342
|
+
const body = addMesh(piece, bodyGeometry, settings, random, {
|
|
1343
|
+
metalness: 0.74, name: 'Crushed can', slot: random() < settings.shape.rust ? 2 : 1,
|
|
1344
|
+
});
|
|
1345
|
+
body.position.y = radius;
|
|
1346
|
+
body.rotation.z = Math.PI / 2 + signed(random) * settings.shape.bend * 0.35;
|
|
1347
|
+
body.scale.y = 1 - settings.shape.bend * mix(0.08, 0.35, random());
|
|
1348
|
+
for (const side of [-1, 1]) {
|
|
1349
|
+
const ring = addMesh(piece, new THREE.TorusGeometry(radius, radius * 0.07, 5, 10), settings, random, {
|
|
1350
|
+
metalness: 0.8, name: 'Can rim', slot: 1,
|
|
1351
|
+
});
|
|
1352
|
+
ring.rotation.y = Math.PI / 2;
|
|
1353
|
+
ring.position.x = side * height * body.scale.y * 0.5;
|
|
1354
|
+
ring.position.y = radius;
|
|
1355
|
+
}
|
|
1356
|
+
scatterObject(piece, settings, random, index, { stacked: radius * 0.7, tilt: 0.75 });
|
|
1357
|
+
return piece;
|
|
1358
|
+
}
|
|
1359
|
+
|
|
1360
|
+
function createWire(settings, random, index) {
|
|
1361
|
+
const piece = new THREE.Group();
|
|
1362
|
+
const size = settings.shape.sheetSize;
|
|
1363
|
+
const points = [];
|
|
1364
|
+
for (let step = 0; step < 10; step += 1) {
|
|
1365
|
+
const t = step / 9;
|
|
1366
|
+
const angle = t * Math.PI * mix(2.5, 5.5, random());
|
|
1367
|
+
points.push(new THREE.Vector3(
|
|
1368
|
+
(t - 0.5) * size,
|
|
1369
|
+
0.04 + Math.sin(angle) * size * 0.12,
|
|
1370
|
+
Math.cos(angle) * size * 0.2,
|
|
1371
|
+
));
|
|
1372
|
+
}
|
|
1373
|
+
addMesh(piece, tube(points, size * 0.025, 5), settings, random, {
|
|
1374
|
+
metalness: 0.8, name: 'Curled wire', slot: random() < settings.shape.rust ? 2 : 0,
|
|
1375
|
+
});
|
|
1376
|
+
scatterObject(piece, settings, random, index, { stacked: size * 0.08, tilt: 0.7 });
|
|
1377
|
+
return piece;
|
|
1378
|
+
}
|
|
1379
|
+
|
|
1380
|
+
function createMetalPiece(settings, random, index) {
|
|
1381
|
+
if (settings.asset.variant === 'sheets') return createSheet(settings, random, index);
|
|
1382
|
+
if (settings.asset.variant === 'cans') return createCan(settings, random, index);
|
|
1383
|
+
if (random() < settings.shape.wireChance) return createWire(settings, random, index);
|
|
1384
|
+
const roll = random();
|
|
1385
|
+
if (roll < 0.4) return createShrapnelPiece(settings, random, index);
|
|
1386
|
+
if (roll < 0.72) return createSheet(settings, random, index);
|
|
1387
|
+
return createCan(settings, random, index);
|
|
1388
|
+
}
|
|
1389
|
+
|
|
1390
|
+
// Leaf silhouettes as width profiles along the mid-vein (t = stem -> tip).
|
|
1391
|
+
// DebrisMaker2 ships scanned meshes per species; at toon scale a lobed
|
|
1392
|
+
// width profile carries the same read for free.
|
|
1393
|
+
const LEAF_PROFILES = [
|
|
1394
|
+
{ lobeDepth: 0, lobes: 0, tip: 0.85 }, // ovate (birch/elm)
|
|
1395
|
+
{ lobeDepth: 0.38, lobes: 3, tip: 0.7 }, // maple-like
|
|
1396
|
+
{ lobeDepth: 0.26, lobes: 5, tip: 0.95 }, // oak-like
|
|
1397
|
+
];
|
|
1398
|
+
|
|
1399
|
+
// Deform pipeline order copied from DebrisMaker2 Leaves: crease (V-fold
|
|
1400
|
+
// along the mid-vein), skew, twist around the stem axis, then coherent
|
|
1401
|
+
// noise. Flat fans read as paper cutouts; the crease+twist combination is
|
|
1402
|
+
// what makes a dry curled leaf.
|
|
1403
|
+
function buildLeafGeometry(settings, random, seed) {
|
|
1404
|
+
const { curl, dryness, leafSize } = settings.shape;
|
|
1405
|
+
const size = leafSize * mix(0.7, 1.25, random());
|
|
1406
|
+
const length = size * mix(1.5, 1.9, random());
|
|
1407
|
+
const profile = LEAF_PROFILES[Math.floor(random() * LEAF_PROFILES.length)];
|
|
1408
|
+
const segsL = 9;
|
|
1409
|
+
const segsW = 4;
|
|
1410
|
+
|
|
1411
|
+
const crease = Math.tan(mix(0.25, 0.85, curl * mix(0.6, 1.3, random())));
|
|
1412
|
+
const twist = signed(random) * mix(0.4, 1.6, curl) * mix(0.5, 1.1, dryness);
|
|
1413
|
+
const skew = signed(random) * 0.22;
|
|
1414
|
+
const tipCurl = curl * size * mix(0.3, 0.6, random());
|
|
1415
|
+
const noiseSeed = hashCombine(seed, 5);
|
|
1416
|
+
|
|
1417
|
+
const positions = new Float32Array((segsL + 1) * (segsW + 1) * 3);
|
|
1418
|
+
let write = 0;
|
|
1419
|
+
for (let i = 0; i <= segsL; i += 1) {
|
|
1420
|
+
const t = i / segsL;
|
|
1421
|
+
const reach = Math.min(t / profile.tip, 1);
|
|
1422
|
+
let halfWidth = size * 0.5 * Math.sin(Math.PI * reach ** 0.85) ** 0.9;
|
|
1423
|
+
if (profile.lobes > 0) {
|
|
1424
|
+
halfWidth *= 1 + profile.lobeDepth * Math.sin(profile.lobes * Math.PI * t);
|
|
1425
|
+
}
|
|
1426
|
+
for (let j = 0; j <= segsW; j += 1) {
|
|
1427
|
+
const u = j / segsW - 0.5;
|
|
1428
|
+
let x = u * 2 * halfWidth;
|
|
1429
|
+
let y = Math.abs(x) * crease
|
|
1430
|
+
+ tipCurl * t * t
|
|
1431
|
+
+ size * 0.08 * dryness * simplexNoise3(noiseSeed, x * 2.4 / size, 0, t * 2.4);
|
|
1432
|
+
const z = (t - 0.2) * length;
|
|
1433
|
+
// Twist around the stem axis grows toward the tip.
|
|
1434
|
+
const angle = twist * t;
|
|
1435
|
+
const cos = Math.cos(angle);
|
|
1436
|
+
const sin = Math.sin(angle);
|
|
1437
|
+
const tx = x * cos - y * sin;
|
|
1438
|
+
y = x * sin + y * cos;
|
|
1439
|
+
x = tx + t * length * skew;
|
|
1440
|
+
positions[write] = x;
|
|
1441
|
+
positions[write + 1] = y + 0.012;
|
|
1442
|
+
positions[write + 2] = z;
|
|
1443
|
+
write += 3;
|
|
1444
|
+
}
|
|
1445
|
+
}
|
|
1446
|
+
const indices = [];
|
|
1447
|
+
for (let i = 0; i < segsL; i += 1) {
|
|
1448
|
+
for (let j = 0; j < segsW; j += 1) {
|
|
1449
|
+
const a = i * (segsW + 1) + j;
|
|
1450
|
+
const b = a + segsW + 1;
|
|
1451
|
+
indices.push(a, b, a + 1, a + 1, b, b + 1);
|
|
1452
|
+
}
|
|
1453
|
+
}
|
|
1454
|
+
const geometry = new THREE.BufferGeometry();
|
|
1455
|
+
geometry.setAttribute('position', new THREE.BufferAttribute(positions, 3));
|
|
1456
|
+
geometry.setIndex(indices);
|
|
1457
|
+
geometry.computeVertexNormals();
|
|
1458
|
+
return geometry;
|
|
1459
|
+
}
|
|
1460
|
+
|
|
1461
|
+
function createLeaf(settings, random, index) {
|
|
1462
|
+
const piece = new THREE.Group();
|
|
1463
|
+
const size = settings.shape.leafSize;
|
|
1464
|
+
const geometry = buildLeafGeometry(settings, random, hashCombine(settings.asset.seed, 800 + index * 29));
|
|
1465
|
+
addMesh(piece, geometry, settings, random, {
|
|
1466
|
+
doubleSided: true, name: 'Dry leaf', slot: random() < settings.shape.dryness ? (random() < 0.45 ? 2 : 1) : 0,
|
|
1467
|
+
});
|
|
1468
|
+
scatterObject(piece, settings, random, index, { stacked: size * 0.15, tilt: 0.38 });
|
|
1469
|
+
return piece;
|
|
1470
|
+
}
|
|
1471
|
+
|
|
1472
|
+
// Real pinecone construction: woody scales wind around a spindle core in
|
|
1473
|
+
// golden-angle spiral phyllotaxis (each scale ~137.5deg around and a step
|
|
1474
|
+
// up from the last), tilting from downswept at the base to closed at the
|
|
1475
|
+
// tip. Dozens of overlapping shields make the read; neat sparse rings of
|
|
1476
|
+
// cones never will. Everything merges into ONE geometry so a cone is one
|
|
1477
|
+
// draw call and can be pooled/instanced.
|
|
1478
|
+
const GOLDEN_ANGLE = Math.PI * (3 - Math.sqrt(5));
|
|
1479
|
+
|
|
1480
|
+
function buildPineconeGeometry(settings, random) {
|
|
1481
|
+
const size = settings.shape.leafSize;
|
|
1482
|
+
const openness = mix(0.55, 1, settings.shape.curl);
|
|
1483
|
+
const coneLength = size * 1.6;
|
|
1484
|
+
const coreRadius = size * 0.34;
|
|
1485
|
+
const profileAt = (t) => 0.22 + 0.78 * Math.sin(Math.PI * t ** 0.75) ** 0.9;
|
|
1486
|
+
|
|
1487
|
+
const parts = [];
|
|
1488
|
+
const corePoints = [];
|
|
1489
|
+
for (let step = 0; step <= 8; step += 1) {
|
|
1490
|
+
const t = step / 8;
|
|
1491
|
+
corePoints.push(new THREE.Vector2(
|
|
1492
|
+
Math.max(coreRadius * profileAt(t) * 0.85, 0.001),
|
|
1493
|
+
(t - 0.5) * coneLength,
|
|
1494
|
+
));
|
|
1495
|
+
}
|
|
1496
|
+
const core = new THREE.LatheGeometry(corePoints, 8);
|
|
1497
|
+
paintGeometry(core, settings, random, 0);
|
|
1498
|
+
parts.push(core);
|
|
1499
|
+
|
|
1500
|
+
const scaleCount = 52;
|
|
1501
|
+
const secondary = paletteColor(settings, 1);
|
|
1502
|
+
const apexDir = new THREE.Vector3();
|
|
1503
|
+
const quaternion = new THREE.Quaternion();
|
|
1504
|
+
const twist = new THREE.Quaternion();
|
|
1505
|
+
const matrix = new THREE.Matrix4();
|
|
1506
|
+
const position = new THREE.Vector3();
|
|
1507
|
+
const scaleVec = new THREE.Vector3();
|
|
1508
|
+
for (let index = 0; index < scaleCount; index += 1) {
|
|
1509
|
+
const t = 0.04 + 0.92 * (index / (scaleCount - 1));
|
|
1510
|
+
const theta = index * GOLDEN_ANGLE + random() * 0.07;
|
|
1511
|
+
const ringRadius = coreRadius * profileAt(t);
|
|
1512
|
+
const scaleLength = size * 0.34 * (0.6 + 0.5 * Math.sin(Math.PI * t ** 0.9));
|
|
1513
|
+
// Tilt from the axis: base scales sweep down and open, tip scales
|
|
1514
|
+
// close up. `openness` (curl) flares the whole cone.
|
|
1515
|
+
const tilt = mix(1.5, 0.55, t) * openness;
|
|
1516
|
+
|
|
1517
|
+
const scale = new THREE.ConeGeometry(scaleLength * 0.56, scaleLength, 4, 1);
|
|
1518
|
+
paintGeometry(scale, settings, random, 0);
|
|
1519
|
+
// Woody apophysis read: lighten the outer half of each scale toward
|
|
1520
|
+
// the secondary tan, brightest at the very tip.
|
|
1521
|
+
const colors = scale.attributes.color;
|
|
1522
|
+
const verts = scale.attributes.position;
|
|
1523
|
+
for (let v = 0; v < verts.count; v += 1) {
|
|
1524
|
+
const along = verts.getY(v) / scaleLength + 0.5;
|
|
1525
|
+
if (along > 0.45) {
|
|
1526
|
+
const lightness = (along - 0.45) / 0.55;
|
|
1527
|
+
colors.setXYZ(
|
|
1528
|
+
v,
|
|
1529
|
+
mix(colors.getX(v), secondary.r * 1.08, lightness),
|
|
1530
|
+
mix(colors.getY(v), secondary.g * 1.08, lightness),
|
|
1531
|
+
mix(colors.getZ(v), secondary.b * 1.08, lightness),
|
|
1532
|
+
);
|
|
1533
|
+
}
|
|
1534
|
+
}
|
|
1535
|
+
|
|
1536
|
+
apexDir.set(Math.sin(tilt) * Math.cos(theta), Math.cos(tilt), -Math.sin(tilt) * Math.sin(theta));
|
|
1537
|
+
quaternion.setFromUnitVectors(new THREE.Vector3(0, 1, 0), apexDir);
|
|
1538
|
+
twist.setFromAxisAngle(apexDir, signed(random) * 0.15);
|
|
1539
|
+
quaternion.premultiply(twist);
|
|
1540
|
+
position
|
|
1541
|
+
.set(Math.cos(theta) * ringRadius * 0.7, (t - 0.5) * coneLength, -Math.sin(theta) * ringRadius * 0.7)
|
|
1542
|
+
.addScaledVector(apexDir, scaleLength * 0.42);
|
|
1543
|
+
scaleVec.set(1, 1, 0.28);
|
|
1544
|
+
matrix.compose(position, quaternion, scaleVec);
|
|
1545
|
+
scale.applyMatrix4(matrix);
|
|
1546
|
+
parts.push(scale);
|
|
1547
|
+
}
|
|
1548
|
+
|
|
1549
|
+
const merged = mergeGeometries(parts, false);
|
|
1550
|
+
for (const part of parts) part.dispose();
|
|
1551
|
+
merged.computeBoundingBox();
|
|
1552
|
+
return merged;
|
|
1553
|
+
}
|
|
1554
|
+
|
|
1555
|
+
function buildPineconePool(settings, count) {
|
|
1556
|
+
const geometries = [];
|
|
1557
|
+
const uniqueCount = Math.min(Math.max(count, 1), 3);
|
|
1558
|
+
for (let unique = 0; unique < uniqueCount; unique += 1) {
|
|
1559
|
+
const rng = createDebrisRandom(hashCombine(settings.asset.seed, 503 + unique * 149));
|
|
1560
|
+
const scaled = createDebrisSettings(settings);
|
|
1561
|
+
scaled.shape.leafSize = settings.shape.leafSize * mix(0.78, 1.2, rng());
|
|
1562
|
+
geometries.push(buildPineconeGeometry(scaled, rng));
|
|
1563
|
+
}
|
|
1564
|
+
return { geometries, name: 'Pinecone' };
|
|
1565
|
+
}
|
|
1566
|
+
|
|
1567
|
+
// Scallop fan shell as ONE parametric surface: a domed fan whose radial
|
|
1568
|
+
// ribs and scalloped rim are baked into the surface itself (ridges glued
|
|
1569
|
+
// onto a hemisphere read as a toy). Umbo (hinge) at the origin, fan
|
|
1570
|
+
// opening toward +Z.
|
|
1571
|
+
function createShell(settings, random, index) {
|
|
1572
|
+
const piece = new THREE.Group();
|
|
1573
|
+
const size = settings.shape.leafSize * mix(0.78, 1.2, random());
|
|
1574
|
+
const ribCount = 8 + Math.floor(random() * 4);
|
|
1575
|
+
const arc = Math.PI * mix(0.62, 0.74, random());
|
|
1576
|
+
const domeHeight = size * (0.24 + settings.shape.curl * 0.2);
|
|
1577
|
+
const ribDepth = mix(0.045, 0.08, random());
|
|
1578
|
+
const angularSegs = 26;
|
|
1579
|
+
const radialSegs = 7;
|
|
1580
|
+
|
|
1581
|
+
const positions = new Float32Array((angularSegs + 1) * (radialSegs + 1) * 3);
|
|
1582
|
+
let write = 0;
|
|
1583
|
+
for (let a = 0; a <= angularSegs; a += 1) {
|
|
1584
|
+
const theta = (a / angularSegs - 0.5) * arc;
|
|
1585
|
+
const rib = Math.cos(ribCount * (theta / arc) * Math.PI * 2);
|
|
1586
|
+
const rimRadius = size * (1 + ribDepth * 0.7 * rib);
|
|
1587
|
+
for (let k = 0; k <= radialSegs; k += 1) {
|
|
1588
|
+
const t = k / radialSegs;
|
|
1589
|
+
const radius = t * rimRadius;
|
|
1590
|
+
// Dome profile with ribs growing toward the rim.
|
|
1591
|
+
const y = domeHeight * Math.sin((Math.PI / 2) * t) * (1 - t * 0.25)
|
|
1592
|
+
* (1 + ribDepth * 2.6 * rib * t)
|
|
1593
|
+
+ 0.01;
|
|
1594
|
+
positions[write] = Math.sin(theta) * radius;
|
|
1595
|
+
positions[write + 1] = y;
|
|
1596
|
+
positions[write + 2] = Math.cos(theta) * radius - size * 0.12;
|
|
1597
|
+
write += 3;
|
|
1598
|
+
}
|
|
1599
|
+
}
|
|
1600
|
+
const indices = [];
|
|
1601
|
+
for (let a = 0; a < angularSegs; a += 1) {
|
|
1602
|
+
for (let k = 0; k < radialSegs; k += 1) {
|
|
1603
|
+
const i0 = a * (radialSegs + 1) + k;
|
|
1604
|
+
const i1 = i0 + radialSegs + 1;
|
|
1605
|
+
indices.push(i0, i1, i0 + 1, i0 + 1, i1, i1 + 1);
|
|
1606
|
+
}
|
|
1607
|
+
}
|
|
1608
|
+
const geometry = new THREE.BufferGeometry();
|
|
1609
|
+
geometry.setAttribute('position', new THREE.BufferAttribute(positions, 3));
|
|
1610
|
+
geometry.setIndex(indices);
|
|
1611
|
+
geometry.computeVertexNormals();
|
|
1612
|
+
addMesh(piece, geometry, settings, random, {
|
|
1613
|
+
doubleSided: true, name: 'Fan shell', slot: random() < 0.3 ? 2 : 1,
|
|
1614
|
+
});
|
|
1615
|
+
scatterObject(piece, settings, random, index, { stacked: size * 0.12, tilt: 0.32 });
|
|
1616
|
+
return piece;
|
|
1617
|
+
}
|
|
1618
|
+
|
|
1619
|
+
function createOrganicPiece(settings, random, index, pineconePool) {
|
|
1620
|
+
const size = settings.shape.leafSize;
|
|
1621
|
+
const wantCone = settings.asset.variant === 'pinecones'
|
|
1622
|
+
|| (settings.asset.variant === 'leafLitter' && random() < settings.shape.coneRatio * 0.28);
|
|
1623
|
+
if (wantCone && pineconePool) {
|
|
1624
|
+
const geometry = pineconePool.geometries[index % pineconePool.geometries.length];
|
|
1625
|
+
return createPooledPiece(pineconePool, geometry, settings, random, index, {
|
|
1626
|
+
mirror: true, stacked: size * 0.2, tilt: 0.85,
|
|
1627
|
+
});
|
|
1628
|
+
}
|
|
1629
|
+
if (settings.asset.variant === 'shells') return createShell(settings, random, index);
|
|
1630
|
+
return createLeaf(settings, random, index);
|
|
1631
|
+
}
|
|
1632
|
+
|
|
1633
|
+
function createCharcoalChunk(parent, settings, random, index, footprint, moundHeight = 0) {
|
|
1634
|
+
const radius = footprint * mix(0.055, 0.15, random());
|
|
1635
|
+
const geometry = irregularGeometry(radius, random, 0.92, 1, settings.asset.seed + index * 17);
|
|
1636
|
+
geometry.scale(mix(0.8, 1.8, random()), mix(0.35, 0.8, random()), mix(0.65, 1.25, random()));
|
|
1637
|
+
const mesh = addMesh(parent, geometry, settings, random, {
|
|
1638
|
+
name: 'Charcoal fragment', slot: random() < settings.shape.embers ? 2 : 0,
|
|
1639
|
+
});
|
|
1640
|
+
const radiusFromCenter = Math.sqrt(random()) * footprint;
|
|
1641
|
+
const angle = random() * Math.PI * 2;
|
|
1642
|
+
// Rest chunks ON the mound (matching its falloff profile) so they read
|
|
1643
|
+
// as half-sunk remains instead of vanishing inside the ash.
|
|
1644
|
+
const radial = radiusFromCenter / Math.max(footprint, 0.01);
|
|
1645
|
+
const moundSurface = moundHeight * Math.max(0, 1 - radial * radial) ** 1.4 * 0.85;
|
|
1646
|
+
mesh.position.set(
|
|
1647
|
+
Math.cos(angle) * radiusFromCenter,
|
|
1648
|
+
moundSurface + radius * 0.3 + index * 0.0001,
|
|
1649
|
+
Math.sin(angle) * radiusFromCenter,
|
|
1650
|
+
);
|
|
1651
|
+
mesh.rotation.set(signed(random), random() * Math.PI * 2, signed(random));
|
|
1652
|
+
}
|
|
1653
|
+
|
|
1654
|
+
// Ash mound: a polar heightfield — squashed-cosine falloff profile with
|
|
1655
|
+
// coherent lumps and a wobbled rim, like a real poured pile — instead of
|
|
1656
|
+
// the old scaled hemisphere (which reads as a lens, not powder). Vertex
|
|
1657
|
+
// colors bake fine light ash at the crown, base grey on the slopes, and
|
|
1658
|
+
// the scorched-rim darkening the `rim` setting always promised.
|
|
1659
|
+
function createAshMoundGeometry(settings, seed) {
|
|
1660
|
+
const { footprint, moundHeight, rim } = settings.shape;
|
|
1661
|
+
const { accentColor, edgeLight, primaryColor, secondaryColor } = settings.surface;
|
|
1662
|
+
const rings = 18;
|
|
1663
|
+
const sectors = 44;
|
|
1664
|
+
const lumpSeed = hashCombine(seed, 313);
|
|
1665
|
+
const rimSeed = hashCombine(seed, 631);
|
|
1666
|
+
const vertexCount = rings * sectors + 1;
|
|
1667
|
+
const positions = new Float32Array(vertexCount * 3);
|
|
1668
|
+
const colors = new Float32Array(vertexCount * 3);
|
|
1669
|
+
const primary = new THREE.Color(...primaryColor);
|
|
1670
|
+
const secondary = new THREE.Color(...secondaryColor);
|
|
1671
|
+
// Scorch is carbon, not ember: a fixed near-black, never the accent red.
|
|
1672
|
+
const char = new THREE.Color(0.02, 0.018, 0.016);
|
|
1673
|
+
const swatch = new THREE.Color();
|
|
1674
|
+
|
|
1675
|
+
const heightAt = (x, z, radial) => {
|
|
1676
|
+
const profile = Math.max(0, 1 - radial * radial) ** 1.4;
|
|
1677
|
+
const lumps = 1 + 0.4 * fbm3(lumpSeed, (x / footprint) * 1.8, 0, (z / footprint) * 1.8, 3, 2, 0.5);
|
|
1678
|
+
return Math.max(moundHeight * profile * lumps, 0.004);
|
|
1679
|
+
};
|
|
1680
|
+
|
|
1681
|
+
const writeVertex = (vertex, x, y, z, radial) => {
|
|
1682
|
+
positions[vertex * 3] = x;
|
|
1683
|
+
positions[vertex * 3 + 1] = y;
|
|
1684
|
+
positions[vertex * 3 + 2] = z;
|
|
1685
|
+
// Crown = fine pale ash, slopes = base grey, rim = scorch band.
|
|
1686
|
+
swatch.copy(secondary).lerp(primary, Math.min(radial * 1.35, 1));
|
|
1687
|
+
const scorch = rim * Math.max(0, radial - 0.68) / 0.32;
|
|
1688
|
+
swatch.lerp(char, Math.min(scorch, 1));
|
|
1689
|
+
const crest = 1 + Math.max(0, 1 - radial) * edgeLight * 0.3;
|
|
1690
|
+
colors[vertex * 3] = Math.min(swatch.r * crest, 1);
|
|
1691
|
+
colors[vertex * 3 + 1] = Math.min(swatch.g * crest, 1);
|
|
1692
|
+
colors[vertex * 3 + 2] = Math.min(swatch.b * crest, 1);
|
|
1693
|
+
};
|
|
1694
|
+
|
|
1695
|
+
writeVertex(0, 0, heightAt(0, 0, 0), 0, 0);
|
|
1696
|
+
for (let ring = 0; ring < rings; ring += 1) {
|
|
1697
|
+
const radial = (ring + 1) / rings;
|
|
1698
|
+
for (let sector = 0; sector < sectors; sector += 1) {
|
|
1699
|
+
const angle = (sector / sectors) * Math.PI * 2;
|
|
1700
|
+
const cos = Math.cos(angle);
|
|
1701
|
+
const sin = Math.sin(angle);
|
|
1702
|
+
const wobble = 1 + 0.09 * simplexNoise3(rimSeed, cos * 1.4, radial * 0.8, sin * 1.4);
|
|
1703
|
+
const radius = radial * footprint * wobble;
|
|
1704
|
+
const x = cos * radius;
|
|
1705
|
+
const z = sin * radius;
|
|
1706
|
+
const y = ring === rings - 1 ? 0.003 : heightAt(x, z, radial);
|
|
1707
|
+
writeVertex(1 + ring * sectors + sector, x, y, z, radial);
|
|
1708
|
+
}
|
|
1709
|
+
}
|
|
1710
|
+
|
|
1711
|
+
const indices = [];
|
|
1712
|
+
for (let sector = 0; sector < sectors; sector += 1) {
|
|
1713
|
+
indices.push(0, 1 + sector, 1 + ((sector + 1) % sectors));
|
|
1714
|
+
}
|
|
1715
|
+
for (let ring = 0; ring < rings - 1; ring += 1) {
|
|
1716
|
+
for (let sector = 0; sector < sectors; sector += 1) {
|
|
1717
|
+
const a = 1 + ring * sectors + sector;
|
|
1718
|
+
const b = 1 + ring * sectors + ((sector + 1) % sectors);
|
|
1719
|
+
const c = a + sectors;
|
|
1720
|
+
const d = b + sectors;
|
|
1721
|
+
indices.push(a, c, b, b, c, d);
|
|
1722
|
+
}
|
|
1723
|
+
}
|
|
1724
|
+
|
|
1725
|
+
const geometry = new THREE.BufferGeometry();
|
|
1726
|
+
geometry.setAttribute('position', new THREE.BufferAttribute(positions, 3));
|
|
1727
|
+
geometry.setAttribute('color', new THREE.BufferAttribute(colors, 3));
|
|
1728
|
+
geometry.setIndex(indices);
|
|
1729
|
+
geometry.computeVertexNormals();
|
|
1730
|
+
return geometry;
|
|
1731
|
+
}
|
|
1732
|
+
|
|
1733
|
+
function createAshAsset(settings, random) {
|
|
1734
|
+
const root = new THREE.Group();
|
|
1735
|
+
root.name = 'Procedural ash and char';
|
|
1736
|
+
const { footprint, moundHeight } = settings.shape;
|
|
1737
|
+
if (settings.asset.variant !== 'charcoal') {
|
|
1738
|
+
const mound = new THREE.Mesh(
|
|
1739
|
+
createAshMoundGeometry(settings, settings.asset.seed),
|
|
1740
|
+
createMaterial(settings),
|
|
1741
|
+
);
|
|
1742
|
+
mound.name = 'Ash mound';
|
|
1743
|
+
mound.castShadow = true;
|
|
1744
|
+
mound.receiveShadow = true;
|
|
1745
|
+
root.add(mound);
|
|
1746
|
+
}
|
|
1747
|
+
if (settings.asset.variant === 'campfire') {
|
|
1748
|
+
// Half-burnt firewood: crooked tapered sweeps (Tree Lab tube builder)
|
|
1749
|
+
// leaned across the mound at irregular angles, ends poking out of the
|
|
1750
|
+
// ash — not four identical arcs on a carousel.
|
|
1751
|
+
for (let logIndex = 0; logIndex < 4; logIndex += 1) {
|
|
1752
|
+
const across = footprint * mix(0.5, 0.72, random());
|
|
1753
|
+
const sag = moundHeight * mix(0.35, 0.65, random());
|
|
1754
|
+
const drift = signed(random) * footprint * 0.14;
|
|
1755
|
+
const swept = createBranchTubeGeometry({
|
|
1756
|
+
irregularity: 0.24,
|
|
1757
|
+
points: [
|
|
1758
|
+
[-across, moundHeight * mix(0.1, 0.3, random()), drift],
|
|
1759
|
+
[signed(random) * footprint * 0.12, sag + moundHeight * 0.35, -drift * 0.6],
|
|
1760
|
+
[across * mix(0.75, 1, random()), moundHeight * mix(0.15, 0.45, random()), signed(random) * footprint * 0.1],
|
|
1761
|
+
],
|
|
1762
|
+
radialSegments: 6,
|
|
1763
|
+
radiusEnd: footprint * mix(0.045, 0.07, random()),
|
|
1764
|
+
radiusStart: footprint * mix(0.075, 0.1, random()),
|
|
1765
|
+
ringSpacing: Math.max(footprint / 14, 0.03),
|
|
1766
|
+
seed: settings.asset.seed + logIndex * 61,
|
|
1767
|
+
});
|
|
1768
|
+
if (!swept) continue;
|
|
1769
|
+
// Logs stay char-dark; ember accents belong to the charcoal chunks,
|
|
1770
|
+
// not to a whole glowing log.
|
|
1771
|
+
const log = addMesh(root, swept.geometry, settings, random, {
|
|
1772
|
+
name: 'Charred firewood', slot: 0,
|
|
1773
|
+
});
|
|
1774
|
+
log.rotation.y = (logIndex / 4) * Math.PI * 2 + signed(random) * 0.5;
|
|
1775
|
+
}
|
|
1776
|
+
}
|
|
1777
|
+
// Sawdust reads as powder with a few embedded slivers, not a char bed.
|
|
1778
|
+
const chunkShare = settings.asset.variant === 'sawdust' ? 0.35 : 1;
|
|
1779
|
+
const chunkCount = Math.max(2, Math.round(settings.asset.count * mix(0.45, 1.15, settings.shape.charcoal) * chunkShare));
|
|
1780
|
+
const chunkFootprint = footprint * (settings.asset.variant === 'charcoal' ? 1 : 0.75);
|
|
1781
|
+
const chunkMound = settings.asset.variant === 'charcoal' ? 0 : moundHeight;
|
|
1782
|
+
for (let index = 0; index < chunkCount; index += 1) {
|
|
1783
|
+
createCharcoalChunk(root, settings, random, index, chunkFootprint, chunkMound);
|
|
1784
|
+
}
|
|
1785
|
+
return root;
|
|
1786
|
+
}
|
|
1787
|
+
|
|
1788
|
+
function countStats(root) {
|
|
1789
|
+
let meshCount = 0;
|
|
1790
|
+
let triangleCount = 0;
|
|
1791
|
+
let vertexCount = 0;
|
|
1792
|
+
root.traverse((object) => {
|
|
1793
|
+
if (!object.isMesh) return;
|
|
1794
|
+
meshCount += 1;
|
|
1795
|
+
const geometry = object.geometry;
|
|
1796
|
+
vertexCount += geometry.attributes.position?.count ?? 0;
|
|
1797
|
+
triangleCount += geometry.index
|
|
1798
|
+
? geometry.index.count / 3
|
|
1799
|
+
: (geometry.attributes.position?.count ?? 0) / 3;
|
|
1800
|
+
});
|
|
1801
|
+
return { meshCount, triangleCount: Math.round(triangleCount), vertexCount };
|
|
1802
|
+
}
|
|
1803
|
+
|
|
1804
|
+
// Universal Damage dial: shifts each type's own wear parameters around
|
|
1805
|
+
// their authored values (0.5 = neutral, 0 = pristine, 1 = wrecked). The
|
|
1806
|
+
// recipe keeps the user's per-field values; this derives the effective
|
|
1807
|
+
// shape fed to the builders.
|
|
1808
|
+
const DAMAGE_KEYS = Object.freeze({
|
|
1809
|
+
ash: ['charcoal', 'rim'],
|
|
1810
|
+
bone: ['damage'],
|
|
1811
|
+
metal: ['bend', 'rust'],
|
|
1812
|
+
organic: ['curl', 'dryness'],
|
|
1813
|
+
stone: ['detail'],
|
|
1814
|
+
wood: ['barkStripped', 'splinters'],
|
|
1815
|
+
});
|
|
1816
|
+
|
|
1817
|
+
function applyDamageDial(settings) {
|
|
1818
|
+
const shift = (settings.asset.damage - 0.5) * 0.8;
|
|
1819
|
+
if (Math.abs(shift) < 0.01) return settings;
|
|
1820
|
+
const keys = DAMAGE_KEYS[settings.asset.type] ?? [];
|
|
1821
|
+
for (const key of keys) {
|
|
1822
|
+
if (typeof settings.shape[key] !== 'number') continue;
|
|
1823
|
+
settings.shape[key] = Math.min(Math.max(settings.shape[key] + shift, 0), 1);
|
|
1824
|
+
}
|
|
1825
|
+
return settings;
|
|
1826
|
+
}
|
|
1827
|
+
|
|
1828
|
+
export function createDebrisAsset(inputSettings = {}) {
|
|
1829
|
+
const settings = applyDamageDial(createDebrisSettings(inputSettings));
|
|
1830
|
+
const random = createDebrisRandom(settings.asset.seed);
|
|
1831
|
+
const root = new THREE.Group();
|
|
1832
|
+
root.name = `Debris ${settings.asset.type} ${settings.asset.variant}`;
|
|
1833
|
+
root.userData.debrisRecipe = settings;
|
|
1834
|
+
|
|
1835
|
+
if (settings.asset.type === 'ash') {
|
|
1836
|
+
root.add(createAshAsset(settings, random));
|
|
1837
|
+
} else {
|
|
1838
|
+
const pieces = [];
|
|
1839
|
+
const effectiveCount = settings.asset.type === 'organic'
|
|
1840
|
+
? Math.min(32, Math.max(1, Math.round(settings.asset.count * settings.shape.coverage)))
|
|
1841
|
+
: settings.asset.count;
|
|
1842
|
+
// Unique SDF geometries are meshed once up front and shared across
|
|
1843
|
+
// instances; per-piece variation comes from the scatter transform.
|
|
1844
|
+
const bonePool = settings.asset.type === 'bone' && settings.asset.variant !== 'antler'
|
|
1845
|
+
? buildBonePool(settings, effectiveCount)
|
|
1846
|
+
: null;
|
|
1847
|
+
const isMasonryVariant = settings.asset.type === 'stone' && MASONRY_VARIANTS.has(settings.asset.variant);
|
|
1848
|
+
const masonryPool = isMasonryVariant ? buildMasonryPool(settings, effectiveCount) : null;
|
|
1849
|
+
const stonePool = settings.asset.type === 'stone' && !isMasonryVariant
|
|
1850
|
+
? buildStonePool(settings, effectiveCount)
|
|
1851
|
+
: null;
|
|
1852
|
+
const pineconePool = settings.asset.type === 'organic'
|
|
1853
|
+
&& (settings.asset.variant === 'pinecones'
|
|
1854
|
+
|| (settings.asset.variant === 'leafLitter' && settings.shape.coneRatio > 0))
|
|
1855
|
+
? buildPineconePool(settings, effectiveCount)
|
|
1856
|
+
: null;
|
|
1857
|
+
for (let index = 0; index < effectiveCount; index += 1) {
|
|
1858
|
+
let piece;
|
|
1859
|
+
if (settings.asset.type === 'wood') piece = createWoodPiece(settings, random, index);
|
|
1860
|
+
else if (settings.asset.type === 'bone') {
|
|
1861
|
+
if (settings.asset.variant === 'antler') piece = createAntler(settings, random, index);
|
|
1862
|
+
else if (bonePool) {
|
|
1863
|
+
const geometry = bonePool.geometries[index % bonePool.geometries.length];
|
|
1864
|
+
piece = createPooledPiece(bonePool, geometry, settings, random, index, {
|
|
1865
|
+
mirror: true,
|
|
1866
|
+
stacked: settings.shape.thickness * 1.5,
|
|
1867
|
+
tilt: settings.asset.variant === 'longBone' ? 0.65 : 0.3,
|
|
1868
|
+
});
|
|
1869
|
+
} else piece = createAntler(settings, random, index);
|
|
1870
|
+
} else if (masonryPool) {
|
|
1871
|
+
piece = createMasonryPiece(settings, random, index, masonryPool);
|
|
1872
|
+
} else if (stonePool) {
|
|
1873
|
+
const geometry = stonePool.geometries[index % stonePool.geometries.length];
|
|
1874
|
+
piece = createPooledPiece(stonePool, geometry, settings, random, index, {
|
|
1875
|
+
mirror: true,
|
|
1876
|
+
stacked: settings.shape.chunkSize * 0.8,
|
|
1877
|
+
tilt: 0.8,
|
|
1878
|
+
});
|
|
1879
|
+
} else if (settings.asset.type === 'metal') piece = createMetalPiece(settings, random, index);
|
|
1880
|
+
else piece = createOrganicPiece(settings, random, index, pineconePool);
|
|
1881
|
+
root.add(piece);
|
|
1882
|
+
pieces.push(piece);
|
|
1883
|
+
}
|
|
1884
|
+
// Universal ground contact: flat/elongated pieces lie flat (no
|
|
1885
|
+
// corner-balancing), then every piece rests on the ground
|
|
1886
|
+
// (scatter/patch) or on the ground/another piece (heap/bundle).
|
|
1887
|
+
const stacking = settings.asset.arrangement === 'heap' || settings.asset.arrangement === 'bundle';
|
|
1888
|
+
if (!stacking) stabilizeFlatPieces(pieces);
|
|
1889
|
+
settleComposition(pieces, { stacking });
|
|
1890
|
+
}
|
|
1891
|
+
|
|
1892
|
+
// Procedural detail texture pass: every mesh gets UVs (box projection
|
|
1893
|
+
// where the builder didn't provide them) and the type's tileable detail
|
|
1894
|
+
// map. The map multiplies baked vertex colors, so the palette sliders
|
|
1895
|
+
// stay authoritative; textures are cache-owned — dispose must not
|
|
1896
|
+
// touch material.map.
|
|
1897
|
+
const auto = debrisTextureAuto(settings.asset.type, settings.asset.variant);
|
|
1898
|
+
const style = settings.surface.textureStyle === 'auto' ? auto.style : settings.surface.textureStyle;
|
|
1899
|
+
const scale = settings.surface.textureScale;
|
|
1900
|
+
// A user-uploaded texture overrides the procedural detail map entirely.
|
|
1901
|
+
const customTexture = getCustomDebrisTexture(settings.surface.customTexture, scale);
|
|
1902
|
+
const detailTexture = customTexture
|
|
1903
|
+
?? getDebrisDetailTexture(auto.kind, settings.asset.seed, style, scale);
|
|
1904
|
+
const fiberTexture = !customTexture && auto.kind === 'leaf'
|
|
1905
|
+
? getDebrisDetailTexture('organicFiber', settings.asset.seed, 'fiber', scale)
|
|
1906
|
+
: null;
|
|
1907
|
+
root.traverse((object) => {
|
|
1908
|
+
if (!object.isMesh) return;
|
|
1909
|
+
ensureDebrisUvs(object.geometry);
|
|
1910
|
+
object.material.map = fiberTexture && object.name === 'Pinecone' ? fiberTexture : detailTexture;
|
|
1911
|
+
object.material.needsUpdate = true;
|
|
1912
|
+
});
|
|
1913
|
+
|
|
1914
|
+
root.scale.setScalar(settings.asset.scale);
|
|
1915
|
+
root.userData.stats = countStats(root);
|
|
1916
|
+
return root;
|
|
1917
|
+
}
|
|
1918
|
+
|
|
1919
|
+
export function disposeDebrisAsset(root) {
|
|
1920
|
+
const materials = new Set();
|
|
1921
|
+
root?.traverse((object) => {
|
|
1922
|
+
if (!object.isMesh) return;
|
|
1923
|
+
object.geometry?.dispose();
|
|
1924
|
+
for (const material of Array.isArray(object.material) ? object.material : [object.material]) {
|
|
1925
|
+
if (material) materials.add(material);
|
|
1926
|
+
}
|
|
1927
|
+
});
|
|
1928
|
+
for (const material of materials) material.dispose?.();
|
|
1929
|
+
}
|