@call-me-sensei/toonlab 0.2.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (205) hide show
  1. package/AGENTS.md +127 -4
  2. package/ATTRIBUTION.md +41 -3
  3. package/README.md +234 -34
  4. package/docs/characters.md +144 -0
  5. package/docs/debug-panel.md +126 -0
  6. package/docs/docs.css +589 -0
  7. package/docs/environment.md +186 -0
  8. package/docs/getting-started.md +180 -0
  9. package/docs/index.html +14 -0
  10. package/docs/lab-architecture.md +100 -0
  11. package/docs/lighting.md +825 -0
  12. package/docs/main.jsx +739 -0
  13. package/docs/mcp.md +97 -0
  14. package/docs/post-processing.md +98 -0
  15. package/docs/settings-reference.md +1878 -0
  16. package/docs/shader-constants.md +77 -0
  17. package/docs/sky.md +182 -0
  18. package/docs/style-labs.md +309 -0
  19. package/docs/texture-lab.md +135 -0
  20. package/docs/toon-shading.md +183 -0
  21. package/docs/tsl-conventions.md +167 -0
  22. package/docs/vegetation-sky.md +275 -0
  23. package/docs/water.md +430 -0
  24. package/docs/weather.md +200 -0
  25. package/docs/world-scale.md +111 -0
  26. package/mcp/server.mjs +610 -0
  27. package/mcp/style-lab-tools.mjs +371 -0
  28. package/mcp/vite-plugin.mjs +174 -0
  29. package/mcp/workspace.mjs +397 -0
  30. package/package.json +64 -5
  31. package/src/ambientfx/INTEGRATION.md +164 -0
  32. package/src/ambientfx/ambientFxPresets.js +83 -0
  33. package/src/ambientfx/ambientFxSettings.js +368 -0
  34. package/src/ambientfx/emitters.js +169 -0
  35. package/src/ambientfx/index.js +5 -0
  36. package/src/ambientfx/particleBackbone.js +493 -0
  37. package/src/ambientfx/stylizedAmbientFx.js +450 -0
  38. package/src/assetlib/ambientcg.js +117 -0
  39. package/src/assetlib/assetRef.js +91 -0
  40. package/src/assetlib/importedEntry.js +40 -0
  41. package/src/assetlib/index.js +23 -0
  42. package/src/assetlib/kaykit.js +192 -0
  43. package/src/assetlib/kaykitStaticIndex.js +700 -0
  44. package/src/assetlib/loadImported.js +143 -0
  45. package/src/assetlib/opensource3d.js +113 -0
  46. package/src/assetlib/polyhaven.js +182 -0
  47. package/src/assetlib/polypizza.js +115 -0
  48. package/src/assetlib/smithsonian.js +214 -0
  49. package/src/assetlib/sources.js +279 -0
  50. package/src/assetlib/zip.js +58 -0
  51. package/src/biome/biomeGenerator.js +385 -0
  52. package/src/biome/biomeRuntime.js +299 -0
  53. package/src/biome/index.js +2 -0
  54. package/src/buildinggen/buildingAsset.js +44 -0
  55. package/src/buildinggen/buildingGrammar.js +311 -0
  56. package/src/buildinggen/buildingMesh.js +451 -0
  57. package/src/buildinggen/buildingPresets.js +46 -0
  58. package/src/buildinggen/buildingRecipe.js +100 -0
  59. package/src/buildinggen/buildingSettings.js +238 -0
  60. package/src/buildinggen/index.js +6 -0
  61. package/src/camera/cameraDirector.js +157 -0
  62. package/src/camera/cameraGenerator.js +367 -0
  63. package/src/camera/cameraRig.js +570 -0
  64. package/src/camera/cameraSettings.js +236 -0
  65. package/src/camera/index.js +7 -0
  66. package/src/catalog/builtinEntries.js +245 -0
  67. package/src/catalog/catalog.js +210 -0
  68. package/src/catalog/index.js +3 -0
  69. package/src/catalog/manifest.js +84 -0
  70. package/src/core/generation.js +529 -0
  71. package/src/environment/environmentRigs.js +21 -1
  72. package/src/environment/environmentSettings.js +4 -0
  73. package/src/environment/environmentSunShadowPass.js +8 -0
  74. package/src/fauna/INTEGRATION.md +174 -0
  75. package/src/fauna/boids.js +861 -0
  76. package/src/fauna/faunaBodies.js +492 -0
  77. package/src/fauna/faunaPresets.js +52 -0
  78. package/src/fauna/faunaSettings.js +525 -0
  79. package/src/fauna/index.js +5 -0
  80. package/src/fauna/stylizedFauna.js +395 -0
  81. package/src/game-feel/gameFeelGenerator.js +402 -0
  82. package/src/game-feel/gameFeelRuntime.js +549 -0
  83. package/src/game-feel/index.js +2 -0
  84. package/src/index.js +17 -6
  85. package/src/lighting/colorIntensity.js +177 -0
  86. package/src/lighting/index.js +161 -0
  87. package/src/lighting/lightDescriptors.js +249 -0
  88. package/src/lighting/lightingCapabilities.js +79 -0
  89. package/src/lighting/lightingDocuments.js +247 -0
  90. package/src/lighting/lightingFixtures.js +446 -0
  91. package/src/lighting/lightingGenerator.js +449 -0
  92. package/src/lighting/lightingPresets.js +319 -0
  93. package/src/lighting/lightingRuntime.js +723 -0
  94. package/src/lighting/lightingStyle.js +386 -0
  95. package/src/lighting/lightingSystem.js +774 -0
  96. package/src/lighting/unrealExport.js +186 -0
  97. package/src/lighting/utils.js +87 -0
  98. package/src/motion/index.js +5 -0
  99. package/src/motion/motionClip.js +441 -0
  100. package/src/motion/motionController.js +628 -0
  101. package/src/motion/motionDocuments.js +225 -0
  102. package/src/motion/motionGraph.js +307 -0
  103. package/src/motion/motionSettings.js +222 -0
  104. package/src/pathgen/index.js +7 -0
  105. package/src/pathgen/pathBridge.js +232 -0
  106. package/src/pathgen/pathPresets.js +35 -0
  107. package/src/pathgen/pathRibbon.js +410 -0
  108. package/src/pathgen/pathRouter.js +380 -0
  109. package/src/pathgen/pathSettings.js +335 -0
  110. package/src/pathgen/pathTextures.js +123 -0
  111. package/src/pathgen/stylizedPaths.js +453 -0
  112. package/src/post/index.js +1 -0
  113. package/src/post/postGenerator.js +177 -0
  114. package/src/post/postProcessing.js +41 -0
  115. package/src/propgen/generatorsWave1.js +379 -0
  116. package/src/propgen/generatorsWave2.js +462 -0
  117. package/src/propgen/index.js +5 -0
  118. package/src/propgen/propAsset.js +323 -0
  119. package/src/propgen/propParts.js +170 -0
  120. package/src/propgen/propPlacement.js +459 -0
  121. package/src/propgen/propPresets.js +82 -0
  122. package/src/propgen/propSettings.js +395 -0
  123. package/src/shaders-tsl/chunks/projected-water-caustics.js +242 -0
  124. package/src/shaders-tsl/chunks/vegetation-style.js +360 -0
  125. package/src/shaders-tsl/chunks/water-shore-state.js +31 -0
  126. package/src/shaders-tsl/chunks/water-waves.js +90 -11
  127. package/src/shaders-tsl/environment.js +15 -1
  128. package/src/shaders-tsl/flower.js +279 -30
  129. package/src/shaders-tsl/grass.js +60 -33
  130. package/src/shaders-tsl/sky.js +125 -31
  131. package/src/shaders-tsl/tree-leaf.js +61 -25
  132. package/src/shaders-tsl/water-breaker.js +7 -4
  133. package/src/shaders-tsl/water-shore-state-simulation.js +523 -0
  134. package/src/shaders-tsl/water.js +439 -49
  135. package/src/shaders-tsl/woody-surface.js +154 -0
  136. package/src/sky/sceneOverrideLayers.js +10 -0
  137. package/src/sky/skyQuality.js +26 -0
  138. package/src/sky/stylizedSky.js +753 -45
  139. package/src/soundscape/index.js +4 -0
  140. package/src/soundscape/soundscapeGenerator.js +179 -0
  141. package/src/soundscape/soundscapeRuntime.js +806 -0
  142. package/src/soundscape/soundscapeSettings.js +292 -0
  143. package/src/styles/index.js +13 -0
  144. package/src/styles/styleBundle.js +325 -0
  145. package/src/stylizedTerrain.js +32 -2
  146. package/src/stylizedWorld.js +423 -20
  147. package/src/texgen/evaluateTexture.js +675 -0
  148. package/src/texgen/index.js +60 -0
  149. package/src/texgen/noise2.js +210 -0
  150. package/src/texgen/textureAi.js +436 -0
  151. package/src/texgen/textureGenerators.js +516 -0
  152. package/src/texgen/texturePresets.js +490 -0
  153. package/src/texgen/textureSettings.js +342 -0
  154. package/src/texgen/textureThree.js +59 -0
  155. package/src/vegetation/flowerSpecies.js +15 -3
  156. package/src/vegetation/grassPalettes.js +153 -0
  157. package/src/vegetation/index.js +6 -0
  158. package/src/vegetation/stylizedBush.js +2 -0
  159. package/src/vegetation/stylizedFlower.js +82 -0
  160. package/src/vegetation/stylizedFlowers.js +48 -7
  161. package/src/vegetation/stylizedForest.js +29 -1
  162. package/src/vegetation/stylizedGrass.js +291 -56
  163. package/src/vegetation/stylizedTree.js +38 -2
  164. package/src/vegetation/stylizedTreeFoliage.js +2 -1
  165. package/src/vegetation/vegetationShaders.js +1110 -0
  166. package/src/vfxgen/INTEGRATION.md +145 -0
  167. package/src/vfxgen/core/burstBackbone.js +380 -0
  168. package/src/vfxgen/core/projectileCore.js +92 -0
  169. package/src/vfxgen/core/spriteShapes.js +98 -0
  170. package/src/vfxgen/core/trailRibbon.js +272 -0
  171. package/src/vfxgen/core/vfxRandom.js +29 -0
  172. package/src/vfxgen/effects/emitHelpers.js +37 -0
  173. package/src/vfxgen/effects/magicEffects.js +162 -0
  174. package/src/vfxgen/effects/movementEffects.js +87 -0
  175. package/src/vfxgen/effects/weaponEffects.js +118 -0
  176. package/src/vfxgen/index.js +18 -0
  177. package/src/vfxgen/moves/moveController.js +146 -0
  178. package/src/vfxgen/moves/moveLibrary.js +335 -0
  179. package/src/vfxgen/vfxPresets.js +98 -0
  180. package/src/vfxgen/vfxSettings.js +384 -0
  181. package/src/vfxgen/vfxSystem.js +449 -0
  182. package/src/vfxgen/weapons/stylizedWeapons.js +137 -0
  183. package/src/villagegen/index.js +4 -0
  184. package/src/villagegen/stylizedVillage.js +490 -0
  185. package/src/villagegen/villageArchetypes.js +160 -0
  186. package/src/villagegen/villageNames.js +40 -0
  187. package/src/villagegen/villageSites.js +105 -0
  188. package/src/water/sceneOverrideLayers.js +23 -0
  189. package/src/water/water.js +5 -0
  190. package/src/water/waterBreakerSystem.js +15 -1
  191. package/src/water/waterCurrentField.js +447 -0
  192. package/src/water/waterMaterial.js +12 -0
  193. package/src/water/waterNearshorePhase.js +320 -0
  194. package/src/water/waterScenePasses.js +83 -30
  195. package/src/water/waterSettings.js +325 -13
  196. package/src/water/waterShoreMaterial.js +322 -0
  197. package/src/water/waterShoreStateField.js +605 -0
  198. package/src/water/waterSurface.js +797 -28
  199. package/src/weather/index.js +6 -0
  200. package/src/weather/weatherPrecipitation.js +221 -0
  201. package/src/weather/weatherPresets.js +258 -0
  202. package/src/weather/weatherSettings.js +269 -0
  203. package/src/weather/weatherSystem.js +871 -0
  204. package/src/worldMinimap.js +62 -0
  205. package/src/worldPresets.js +4 -1
@@ -0,0 +1,395 @@
1
+ // The living layer — instanced, GPU-animated ambient creatures. Import from
2
+ // '@call-me-sensei/toonlab/fauna'.
3
+ //
4
+ // const fauna = createFauna({
5
+ // seed, heightAt, waterLevel, bounds,
6
+ // followTarget, // flush/degrade reference
7
+ // species: { birds: 40, butterflies: 60, fish: 80 }, // budgets, not guesses
8
+ // });
9
+ // scene.add(fauna.root);
10
+ // fauna.update(delta); // each frame
11
+ // fauna.addPerchPoints(points); // rooftops, rocks, posts
12
+ // fauna.setDistanceFog({ color, density, falloff, floorY });
13
+ //
14
+ // Rendering discipline (mirrors the grass/forest clusters):
15
+ // - One InstancedMesh per species-variant. Steering runs on staggered CPU
16
+ // ticks (boids.js); every frame only integrates positions and rewrites
17
+ // the instance matrices — wing flap, wing fold, hover bob, and fish tail
18
+ // sway all happen in the TSL vertex stage from per-instance
19
+ // (phase, speed, amplitude, heading) attributes, so a flock costs one
20
+ // matrix compose per bird, never per feather.
21
+ // - Materials are UNLIT with the anime shading baked into vertex colors
22
+ // (see faunaBodies.js) — the same reasoning as the forest billboard
23
+ // impostors: distant creatures must never depend on scene lights, and
24
+ // they join the environment height fog through setDistanceFog so they
25
+ // haze out with the terrain instead of floating on it.
26
+ // - Nothing casts or receives shadows (plan budget: birds cast NO shadows;
27
+ // the toon style reads fine without, and fauna crossing the shadow
28
+ // cascade would be pure cost).
29
+ // - Water pass flags: fish are excluded from the REFLECTION pass only
30
+ // (userData.waterReflectionExclude) — the mirror render clips at the
31
+ // water plane anyway, so drawing fish there is waste, while the grab
32
+ // (refraction) pass MUST keep them: that is exactly how they are seen
33
+ // from above. Airborne species get userData.waterGrabExclude — their
34
+ // refracted contribution is invisible (the documented above-water
35
+ // dressing rule in waterScenePasses.js) but their reflection over a lake
36
+ // is kept, which is the whole point of dragonflies.
37
+
38
+ import * as THREE from 'three';
39
+ import { MeshBasicNodeMaterial } from 'three/webgpu';
40
+ import {
41
+ abs, attribute, clamp, cos, exp, float, Fn, length, max, mix, positionLocal, positionView,
42
+ positionWorld, sin, uniform, vec3, vec4, vertexColor,
43
+ } from 'three/tsl';
44
+
45
+ import { stylizedCloudShadow } from '../shaders-tsl/chunks/stylized-cloud-shadow.js';
46
+ import { createFaunaSimulation, FAUNA_STATE, hashCombine } from './boids.js';
47
+ import { buildFaunaGeometry, getFaunaVariantCount } from './faunaBodies.js';
48
+ import { createFaunaSettings, FAUNA_SPECIES, normalizeFaunaPopulations } from './faunaSettings.js';
49
+ import { resolveFaunaPreset } from './faunaPresets.js';
50
+
51
+ // Per-species GPU animation constants (radians / meters at scale 1). These
52
+ // are look constants, not settings: they are tuned against the body
53
+ // geometry proportions in faunaBodies.js.
54
+ const MOTION = Object.freeze({
55
+ birds: { flapAmp: 0.9, flapBias: 0.1, bobAmp: 0, bobHz: 0, kind: 'flap', fold: true },
56
+ // Bias keeps butterfly wings raised through the whole stroke — a flat or
57
+ // drooped pose reads as a disc, not an insect.
58
+ butterflies: { flapAmp: 0.85, flapBias: 0.55, bobAmp: 0.05, bobHz: 4.2, kind: 'flap', fold: false },
59
+ dragonflies: { flapAmp: 0.24, flapBias: 0.16, bobAmp: 0.028, bobHz: 2.1, kind: 'flap', fold: false },
60
+ fish: { swayAmp: 0.05, kind: 'sway' },
61
+ });
62
+
63
+ const matrixScratch = new THREE.Matrix4();
64
+ const quaternionScratch = new THREE.Quaternion();
65
+ const eulerScratch = new THREE.Euler();
66
+ const positionScratch = new THREE.Vector3();
67
+ const scaleScratch = new THREE.Vector3();
68
+ const followScratch = new THREE.Vector3();
69
+
70
+ function resolveFollow(target) {
71
+ if (!target) return null;
72
+ if (typeof target === 'function') {
73
+ const value = target(followScratch);
74
+ return value && Number.isFinite(value.x) ? { x: value.x, y: value.y ?? 0, z: value.z ?? 0 } : null;
75
+ }
76
+ if (target.isObject3D) {
77
+ target.getWorldPosition(followScratch);
78
+ return { x: followScratch.x, y: followScratch.y, z: followScratch.z };
79
+ }
80
+ if (Number.isFinite(target.x)) return { x: target.x, y: target.y ?? 0, z: target.z ?? 0 };
81
+ return null;
82
+ }
83
+
84
+ /**
85
+ * Builds the fauna system. `heightAt(x, z) → meters` is the only terrain
86
+ * contract (same as everything else in the kit); `waterLevel` is the world-y
87
+ * of the water surface. Species counts are hard budgets — see
88
+ * FAUNA_POPULATION_CAPS.
89
+ */
90
+ export function createFauna({
91
+ seed = 1,
92
+ heightAt = null,
93
+ waterLevel = 0,
94
+ bounds = 240,
95
+ followTarget = null,
96
+ species = {},
97
+ masks = {},
98
+ settings = {},
99
+ preset = null,
100
+ perchPoints = [],
101
+ } = {}) {
102
+ const presetEntry = resolveFaunaPreset(preset ?? settings?.preset);
103
+ const mergedSettings = presetEntry
104
+ ? mergeGrouped(presetEntry.settings, settings)
105
+ : settings;
106
+ const cfg = createFaunaSettings(mergedSettings);
107
+ const populations = normalizeFaunaPopulations(
108
+ presetEntry ? { ...presetEntry.species, ...cleanObject(species) } : species,
109
+ );
110
+
111
+ const variantCounts = Object.fromEntries(FAUNA_SPECIES.map((name) => [
112
+ name, getFaunaVariantCount(name, cfg[name].palette),
113
+ ]));
114
+
115
+ const sim = createFaunaSimulation({
116
+ bounds,
117
+ heightAt,
118
+ masks,
119
+ perchPoints,
120
+ seed,
121
+ settings: cfg,
122
+ species: populations,
123
+ variantCounts,
124
+ waterLevel,
125
+ });
126
+
127
+ const root = new THREE.Group();
128
+ root.name = 'StylizedFauna';
129
+
130
+ // Shared uniforms: one clock, one height-fog mirror, one cloud-shadow rig
131
+ // across every fauna material (same contract as StylizedForest).
132
+ const uTime = uniform(0);
133
+ const fogUniforms = {
134
+ color: uniform(new THREE.Color(0.66, 0.8, 0.94)),
135
+ density: uniform(0),
136
+ falloff: uniform(400),
137
+ floorY: uniform(0),
138
+ };
139
+ const cloudUniforms = {
140
+ coverage: uniform(0.45),
141
+ scale: uniform(0.012),
142
+ strength: uniform(0),
143
+ velocity: uniform(new THREE.Vector2(0.02, 0.006)),
144
+ };
145
+
146
+ function finishColor(material, base) {
147
+ // Drifting cloud shadows tie creatures to the same light field as the
148
+ // grass/terrain (strength 0 skips the fbm entirely).
149
+ const cloud = stylizedCloudShadow(
150
+ positionWorld.xz, uTime,
151
+ cloudUniforms.strength, cloudUniforms.coverage, cloudUniforms.scale, cloudUniforms.velocity,
152
+ );
153
+ const shaded = base.mul(mix(vec3(0.6, 0.66, 0.82), vec3(1.0), cloud));
154
+ // Mirror of environment.js world-height fog (see stylizedForest.js):
155
+ // without it, birds stay sharp saturated specks on hazed mountains.
156
+ const heightFalloff = exp(
157
+ max(positionWorld.y.sub(fogUniforms.floorY), 0.0).div(max(fogUniforms.falloff, 0.001)).negate(),
158
+ );
159
+ const depthTerm = exp(length(positionView).mul(fogUniforms.density).negate()).oneMinus();
160
+ material.colorNode = vec4(
161
+ mix(shaded, fogUniforms.color, clamp(depthTerm.mul(heightFalloff), 0.0, 1.0)),
162
+ 1.0,
163
+ );
164
+ }
165
+
166
+ // Vertex-stage animation. Instanced attributes (written per frame):
167
+ // iFauna = (phase, speed rad/s, amplitude, heading)
168
+ // iFauna2 = (scale, fold, bobAmp, bobHz)
169
+ // positionLocal here is already instance-transformed; the flap is a
170
+ // pseudo-rotation of wing vertices about the spine reconstructed from the
171
+ // heading attribute (yaw-only — bank error on a turning bird is a few
172
+ // degrees at wingtip scale, invisible for ambient creatures).
173
+ function makeMaterial(name, motion) {
174
+ const material = new MeshBasicNodeMaterial({
175
+ fog: true,
176
+ name: `StylizedFauna${name}`,
177
+ side: THREE.DoubleSide, // wings/fins are single-surface sheets
178
+ vertexColors: true,
179
+ });
180
+ material.positionNode = Fn(() => {
181
+ const iA = attribute('iFauna', 'vec4');
182
+ const iB = attribute('iFauna2', 'vec4');
183
+ const side = vec3(cos(iA.w), 0.0, sin(iA.w).negate());
184
+ const animated = positionLocal.toVar();
185
+ if (motion.kind === 'flap') {
186
+ const ext = attribute('aWing', 'float').mul(iB.x);
187
+ const extAbs = abs(ext);
188
+ const flap = sin(uTime.mul(iA.y).add(iA.x)).mul(iA.z).add(float(motion.flapBias));
189
+ animated.addAssign(side.mul(ext.mul(cos(flap).sub(1.0))));
190
+ animated.y.addAssign(extAbs.mul(sin(flap)));
191
+ if (motion.fold) {
192
+ // Perched wings hug the body: tips pulled inward and swept to the
193
+ // tail, barely lifted — a raised fold reads as a butterfly.
194
+ const forward = vec3(sin(iA.w), 0.0, cos(iA.w));
195
+ animated.addAssign(side.mul(ext.mul(iB.y.mul(-0.85))));
196
+ animated.addAssign(forward.mul(extAbs.mul(iB.y.mul(-0.62))));
197
+ animated.y.addAssign(extAbs.mul(iB.y.mul(0.03)));
198
+ }
199
+ if (motion.bobAmp > 0) {
200
+ animated.y.addAssign(sin(uTime.mul(iB.w).add(iA.x.mul(1.7))).mul(iB.z));
201
+ }
202
+ } else {
203
+ // Fish: traveling lateral wave, nose barely, tail fully.
204
+ const tail = attribute('aTail', 'float');
205
+ const wave = sin(uTime.mul(iA.y).add(iA.x).sub(tail.mul(2.4)));
206
+ animated.addAssign(side.mul(wave.mul(iA.z).mul(tail.mul(0.85).add(0.15)).mul(iB.x)));
207
+ }
208
+ return animated;
209
+ })();
210
+ finishColor(material, vertexColor().rgb);
211
+ return material;
212
+ }
213
+
214
+ // --- meshes: one InstancedMesh per species-variant -------------------------
215
+
216
+ const materials = {};
217
+ const meshes = [];
218
+ const perSpecies = {};
219
+ let triangles = 0;
220
+
221
+ for (const name of FAUNA_SPECIES) {
222
+ const state = sim.species[name];
223
+ if (state.count === 0) continue;
224
+ materials[name] = makeMaterial(name, MOTION[name]);
225
+
226
+ // Stable agent → (mesh, slot) assignment from the deterministic variant
227
+ // array the simulation already produced.
228
+ const groups = Array.from({ length: variantCounts[name] }, () => []);
229
+ for (let i = 0; i < state.count; i += 1) groups[state.arrays.variant[i] % groups.length].push(i);
230
+
231
+ const speciesMeshes = [];
232
+ for (let v = 0; v < groups.length; v += 1) {
233
+ const agents = groups[v];
234
+ if (agents.length === 0) continue;
235
+ const geometry = buildFaunaGeometry(name, {
236
+ palette: cfg[name].palette,
237
+ seed: hashCombine(seed, 0x5eed + v),
238
+ variant: v,
239
+ });
240
+ const iA = new THREE.InstancedBufferAttribute(new Float32Array(agents.length * 4), 4);
241
+ const iB = new THREE.InstancedBufferAttribute(new Float32Array(agents.length * 4), 4);
242
+ iA.setUsage(THREE.DynamicDrawUsage);
243
+ iB.setUsage(THREE.DynamicDrawUsage);
244
+ geometry.setAttribute('iFauna', iA);
245
+ geometry.setAttribute('iFauna2', iB);
246
+ const mesh = new THREE.InstancedMesh(geometry, materials[name], agents.length);
247
+ mesh.name = `${geometry.name}Instances`;
248
+ mesh.castShadow = false;
249
+ mesh.receiveShadow = false;
250
+ mesh.frustumCulled = false; // instances roam the whole bounds
251
+ mesh.instanceMatrix.setUsage(THREE.DynamicDrawUsage);
252
+ if (name === 'fish') {
253
+ mesh.userData.waterReflectionExclude = true; // keep the grab pass: refraction IS fish visibility
254
+ } else {
255
+ mesh.userData.waterGrabExclude = true; // invisible refracted, reflection kept
256
+ }
257
+ root.add(mesh);
258
+ meshes.push(mesh);
259
+ speciesMeshes.push({ agents, iA, iB, mesh });
260
+ triangles += (geometry.index.count / 3) * agents.length;
261
+ }
262
+ perSpecies[name] = speciesMeshes;
263
+ }
264
+
265
+ // --- per-frame write-back ---------------------------------------------------
266
+
267
+ const TWO_PI = Math.PI * 2;
268
+ function writeSpecies(name) {
269
+ const entries = perSpecies[name];
270
+ if (!entries) return;
271
+ const state = sim.species[name];
272
+ const a = state.arrays;
273
+ const motion = MOTION[name];
274
+ const flapHz = motion.kind === 'sway' ? cfg.fish.swayHz : cfg[name].flapHz;
275
+ const isFish = motion.kind === 'sway';
276
+ const isBird = name === 'birds';
277
+ for (const entry of entries) {
278
+ const { agents, iA, iB, mesh } = entry;
279
+ const arrA = iA.array;
280
+ const arrB = iB.array;
281
+ for (let slot = 0; slot < agents.length; slot += 1) {
282
+ const i = agents[slot];
283
+ const hSpeed = Math.hypot(a.vx[i], a.vz[i]);
284
+ let pitch = 0;
285
+ if (isFish) {
286
+ pitch = Math.min(Math.max(Math.atan2(-a.vy[i], Math.max(hSpeed, 0.2)), -0.7), 0.7);
287
+ } else if (isBird && a.state[i] !== FAUNA_STATE.PERCHED) {
288
+ pitch = Math.min(Math.max(Math.atan2(-a.vy[i], Math.max(hSpeed, 0.5)) * 0.55, -0.55), 0.55);
289
+ }
290
+ positionScratch.set(a.px[i], a.py[i], a.pz[i]);
291
+ eulerScratch.set(pitch, a.heading[i], a.bank[i], 'YXZ');
292
+ quaternionScratch.setFromEuler(eulerScratch);
293
+ scaleScratch.setScalar(a.scale[i]);
294
+ matrixScratch.compose(positionScratch, quaternionScratch, scaleScratch);
295
+ mesh.setMatrixAt(slot, matrixScratch);
296
+
297
+ const o = slot * 4;
298
+ arrA[o] = a.phase[i];
299
+ arrA[o + 1] = TWO_PI * flapHz * a.speedMul[i]
300
+ * (isFish ? 0.55 + (hSpeed / Math.max(cfg.fish.cruiseSpeed, 0.1)) * 0.6 : 1);
301
+ arrA[o + 2] = (motion.kind === 'sway' ? motion.swayAmp : motion.flapAmp)
302
+ * a.amp[i] * (motion.fold ? 1 - a.fold[i] : 1);
303
+ arrA[o + 3] = a.heading[i];
304
+ arrB[o] = a.scale[i];
305
+ arrB[o + 1] = a.fold[i];
306
+ arrB[o + 2] = motion.bobAmp ?? 0;
307
+ arrB[o + 3] = motion.bobHz ?? 0;
308
+ }
309
+ mesh.instanceMatrix.needsUpdate = true;
310
+ iA.needsUpdate = true;
311
+ iB.needsUpdate = true;
312
+ }
313
+ }
314
+
315
+ const fauna = {
316
+ root,
317
+ settings: cfg,
318
+ simulation: sim,
319
+
320
+ update(delta) {
321
+ const dt = Math.min(Math.max(Number(delta) || 0.016, 0), 0.1);
322
+ uTime.value += dt;
323
+ sim.update(dt, resolveFollow(followTarget));
324
+ for (const name of FAUNA_SPECIES) writeSpecies(name);
325
+ return fauna;
326
+ },
327
+
328
+ /** Registers roost points `[{ x, y, z }]` (rooftops, rocks, posts). */
329
+ addPerchPoints(points) {
330
+ sim.addPerchPoints(points);
331
+ return fauna;
332
+ },
333
+
334
+ /**
335
+ * Matches the fauna to the environment shader's height fog — pass the
336
+ * same heightFogColor/Density/Falloff the terrain uses plus the world
337
+ * floor height. Density 0 disables the layer.
338
+ */
339
+ setDistanceFog({ color, density, falloff, floorY } = {}) {
340
+ if (density !== undefined) fogUniforms.density.value = Math.max(Number(density) || 0, 0);
341
+ if (falloff !== undefined) fogUniforms.falloff.value = Math.max(Number(falloff) || 0, 0.001);
342
+ if (floorY !== undefined) fogUniforms.floorY.value = Number(floorY) || 0;
343
+ if (color !== undefined) {
344
+ const next = Array.isArray(color) ? new THREE.Color(...color) : new THREE.Color(color);
345
+ fogUniforms.color.value.copy(next);
346
+ }
347
+ return fauna;
348
+ },
349
+
350
+ /** Drifting procedural cloud shadows (same knobs as grass). strength 0 disables. */
351
+ setCloudShadow({ strength, coverage, scale, velocity } = {}) {
352
+ if (strength !== undefined) cloudUniforms.strength.value = Math.min(Math.max(Number(strength) || 0, 0), 1);
353
+ if (coverage !== undefined) cloudUniforms.coverage.value = Math.min(Math.max(Number(coverage) || 0, 0), 1);
354
+ if (scale !== undefined) cloudUniforms.scale.value = Math.max(Number(scale) || 0.012, 0.0001);
355
+ if (velocity !== undefined && Array.isArray(velocity)) {
356
+ cloudUniforms.velocity.value.set(Number(velocity[0]) || 0, Number(velocity[1]) || 0);
357
+ }
358
+ return fauna;
359
+ },
360
+
361
+ get stats() {
362
+ return {
363
+ ...sim.stats,
364
+ drawCalls: meshes.length,
365
+ triangles: Math.round(triangles),
366
+ };
367
+ },
368
+
369
+ dispose() {
370
+ for (const mesh of meshes) {
371
+ mesh.geometry.dispose();
372
+ mesh.dispose();
373
+ }
374
+ for (const material of Object.values(materials)) material.dispose();
375
+ root.parent?.remove(root);
376
+ },
377
+ };
378
+
379
+ return fauna;
380
+ }
381
+
382
+ function cleanObject(value) {
383
+ return value && typeof value === 'object' && !Array.isArray(value) ? value : {};
384
+ }
385
+
386
+ // Grouped settings merge: preset values under host overrides, group by group.
387
+ function mergeGrouped(base, overrides) {
388
+ const result = {};
389
+ const groups = new Set([...Object.keys(cleanObject(base)), ...Object.keys(cleanObject(overrides))]);
390
+ for (const group of groups) {
391
+ if (group === 'preset') continue;
392
+ result[group] = { ...cleanObject(base)[group], ...cleanObject(overrides)[group] };
393
+ }
394
+ return result;
395
+ }