@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,493 @@
1
+ // The ONE shared GPU particle system behind every ambient effect. Same
2
+ // architecture as StylizedGrassField: an InstancedBufferGeometry over a unit
3
+ // quad, per-instance attributes filled once per (rare) window re-emission,
4
+ // and ALL motion computed in the TSL vertex shader as a pure function of
5
+ // time — zero per-frame CPU work per particle, works on WebGPU and the
6
+ // WebGL2 fallback alike.
7
+ //
8
+ // Draw calls: the five effects share THREE meshes, grouped by blend state —
9
+ // that is the real GPU boundary, not the effect:
10
+ // cutout — petals + leaves (alpha-test, depth-writing, double-sided)
11
+ // glow — fireflies + pollen (additive, no depth write, emissive/unlit)
12
+ // soft — mist (alpha blend, no depth write)
13
+ // Within a group the per-instance `kind` attribute selects the motion
14
+ // program (flutter-fall / tumble-fall / hover-blink / curl-drift / scroll),
15
+ // so adding an effect never adds a draw. A single backbone draw for all five
16
+ // was rejected: cutout needs depth writes that would occlude through the
17
+ // additive glows, and mist's alpha blend cannot share either state.
18
+ //
19
+ // Budget: ≤ 20k instances of 2 triangles with trig-only vertex work and
20
+ // mostly-discard flat fragments — far under the 0.5 ms cluster budget.
21
+
22
+ import * as THREE from 'three';
23
+ import { NodeMaterial } from 'three/webgpu';
24
+ import {
25
+ abs, attribute, cameraPosition, cameraProjectionMatrix, cameraViewMatrix, clamp, cos, Discard,
26
+ distance, exp, float, Fn, fract, length, max, mix, modelWorldMatrix, normalize, positionLocal,
27
+ pow, rotate, sin, smoothstep, step, uniform, uv, varying, vec2, vec3, vec4,
28
+ } from 'three/tsl';
29
+
30
+ // --- deterministic randomness (shared with emitters + verify script) --------
31
+
32
+ /** Chris Wellons' lowbias32: uint32 -> well-mixed uint32. */
33
+ export function lowbias32(x) {
34
+ let h = x >>> 0;
35
+ h = Math.imul(h ^ (h >>> 16), 0x7feb352d) >>> 0;
36
+ h = Math.imul(h ^ (h >>> 15), 0x846ca68b) >>> 0;
37
+ return (h ^ (h >>> 16)) >>> 0;
38
+ }
39
+
40
+ /** Mixes two uint32 seeds into one (order-sensitive). */
41
+ export function hashCombine(a, b) {
42
+ return lowbias32((a >>> 0) ^ (Math.imul(b >>> 0, 0x9e3779b9) >>> 0));
43
+ }
44
+
45
+ /** mulberry32 PRNG: uint32 seed -> () => [0, 1). No Math.random anywhere. */
46
+ export function mulberry32(seed) {
47
+ let state = (Math.trunc(seed) || 1) >>> 0;
48
+ return () => {
49
+ state = (state + 0x6d2b79f5) >>> 0;
50
+ let t = state;
51
+ t = Math.imul(t ^ (t >>> 15), t | 1);
52
+ t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
53
+ return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
54
+ };
55
+ }
56
+
57
+ // --- effect/window constants -------------------------------------------------
58
+
59
+ /** Backbone kind ids — the per-instance `iData.w` attribute. */
60
+ export const EFFECT_KIND = Object.freeze({ petals: 0, leaves: 1, fireflies: 2, pollen: 3, mist: 4 });
61
+
62
+ /** Which blend-group mesh each kind renders in. */
63
+ export const GROUP_FOR_KIND = Object.freeze({
64
+ petals: 'cutout', leaves: 'cutout', fireflies: 'glow', pollen: 'glow', mist: 'soft',
65
+ });
66
+
67
+ // Follow-window geometry. Emission fills the full windowRadius disk; the
68
+ // visual fade ends before (radius − recenter·radius), so by the time the
69
+ // window re-centers, every cell that appears or drops is already past the
70
+ // fade — mathematically no pop is possible (see stylizedAmbientFx).
71
+ export const FADE_START_FRACTION = 0.55;
72
+ export const FADE_END_FRACTION = 0.8;
73
+ export const RECENTER_FRACTION = 0.18;
74
+
75
+ /** CPU mirror of the shader's wind drift heading (for hosts and verify). */
76
+ export function windDriftVector(windDirection) {
77
+ const x = Number(windDirection?.[0]) || 0;
78
+ const z = Number(windDirection?.[1]) || 0;
79
+ const len = Math.hypot(x, z) || 1;
80
+ return [x / len, z / len];
81
+ }
82
+
83
+ // --- geometry helpers --------------------------------------------------------
84
+
85
+ const FLOATS_PER_INSTANCE = { iSpawn: 3, iData: 4, iColor: 3, iAux: 4 };
86
+
87
+ function createGroupGeometry(capacity) {
88
+ const quad = new THREE.PlaneGeometry(1, 1);
89
+ const geometry = new THREE.InstancedBufferGeometry();
90
+ geometry.index = quad.index;
91
+ geometry.setAttribute('position', quad.attributes.position);
92
+ geometry.setAttribute('uv', quad.attributes.uv);
93
+ for (const [name, itemSize] of Object.entries(FLOATS_PER_INSTANCE)) {
94
+ geometry.setAttribute(name, new THREE.InstancedBufferAttribute(
95
+ new Float32Array(Math.max(capacity, 1) * itemSize), itemSize));
96
+ }
97
+ geometry.instanceCount = 0;
98
+ // Instances span the whole follow window wherever it goes.
99
+ geometry.boundingSphere = new THREE.Sphere(new THREE.Vector3(), 1e5);
100
+ return geometry;
101
+ }
102
+
103
+ /**
104
+ * Creates the three blend-group meshes plus their shared uniforms.
105
+ * `settings` is a createAmbientFxSettings() result — only uniform-backed
106
+ * values are read here; emission is the caller's job (see emitters.js).
107
+ *
108
+ * Returned groups expose `setInstances(records)` where each record is
109
+ * `{ x, y, z, seed, phase, size, kind, r, g, b, range, rate, gateJitter,
110
+ * windResponse }` — the emitters produce exactly this shape.
111
+ */
112
+ export function createParticleBackbone(settings) {
113
+ const shared = settings.shared;
114
+ const u = {
115
+ uCamRight: uniform(new THREE.Vector3(1, 0, 0)),
116
+ uCamUp: uniform(new THREE.Vector3(0, 1, 0)),
117
+ uCenter: uniform(new THREE.Vector2(0, 0)),
118
+ uFireflyIntensity: uniform(settings.fireflies.intensity),
119
+ uFogColor: uniform(new THREE.Color(0.66, 0.8, 0.94)),
120
+ uFogDensity: uniform(0),
121
+ uFogFalloff: uniform(400),
122
+ uFogFloorY: uniform(0),
123
+ uGateFireflies: uniform(1),
124
+ uGateLeaves: uniform(1),
125
+ uGateMist: uniform(1),
126
+ uGatePetals: uniform(1),
127
+ uGatePollen: uniform(1),
128
+ uMistOpacity: uniform(settings.mist.opacity),
129
+ uPollenBacklit: uniform(settings.pollen.backlitStrength),
130
+ uSunDirection: uniform(new THREE.Vector3(...shared.sunDirection).normalize()),
131
+ uTime: uniform(0),
132
+ uWindDir: uniform(new THREE.Vector2(shared.windDirection[0], shared.windDirection[1])),
133
+ uWindSpeed: uniform(shared.windSpeed),
134
+ uWindStrength: uniform(shared.windStrength),
135
+ uWindowRadius: uniform(shared.windowRadius),
136
+ };
137
+
138
+ // Mirror of the environment shader's world-height fog (same formula as the
139
+ // forest impostors' _fogUniforms): dense near the world floor, thinning
140
+ // with altitude, exponential in view distance. Density 0 disables.
141
+ const heightFogFactor = (worldPos) => {
142
+ const heightFalloff = exp(
143
+ max(worldPos.y.sub(u.uFogFloorY), 0.0).div(max(u.uFogFalloff, 0.001)).negate());
144
+ const depthTerm = exp(distance(worldPos, cameraPosition).mul(u.uFogDensity).negate()).oneMinus();
145
+ return clamp(depthTerm.mul(heightFalloff), 0.0, 1.0);
146
+ };
147
+
148
+ // Shared per-instance plumbing: window edge fade around the live follow
149
+ // center + staggered time-gate fade (each particle owns a threshold, so a
150
+ // gate ramping 0→1 pops particles in one by one instead of all at once).
151
+ const windowFade = (worldSpawnXZ) => {
152
+ const dist = length(worldSpawnXZ.sub(u.uCenter));
153
+ return smoothstep(
154
+ u.uWindowRadius.mul(FADE_START_FRACTION),
155
+ u.uWindowRadius.mul(FADE_END_FRACTION),
156
+ dist,
157
+ ).oneMinus();
158
+ };
159
+ const gateFade = (weight, jitter) =>
160
+ clamp(weight.mul(1.15).sub(jitter.mul(0.96)).div(0.12), 0.0, 1.0);
161
+
162
+ const buildMaterial = (name, build) => {
163
+ const material = new NodeMaterial();
164
+ material.name = name;
165
+ build(material);
166
+ material.uniforms = u; // GLSL-style named access, matching the grass idiom
167
+ return material;
168
+ };
169
+
170
+ // ---- cutout group: petals (flutter-fall) + leaves (tumble-fall) ----------
171
+ const cutoutMaterial = buildMaterial('AmbientFxCutout', (material) => {
172
+ material.side = THREE.DoubleSide;
173
+ material.fog = true; // scene.fog linear layer; height fog added below
174
+
175
+ const vUv = uv();
176
+ const vColor = varying(vec3(), 'vFxColor');
177
+ const vSeed = varying(float(), 'vFxSeed');
178
+ const vKind = varying(float(), 'vFxKind');
179
+ const vWorldPos = varying(vec3(), 'vFxWorldPos');
180
+
181
+ material.vertexNode = Fn(() => {
182
+ const iSpawn = attribute('iSpawn', 'vec3');
183
+ const iData = attribute('iData', 'vec4');
184
+ const iAux = attribute('iAux', 'vec4');
185
+ const seed = iData.x;
186
+ const isLeaf = step(0.5, iData.w).toVar();
187
+ vSeed.assign(seed);
188
+ vKind.assign(iData.w);
189
+ vColor.assign(attribute('iColor', 'vec3'));
190
+
191
+ const t = u.uTime;
192
+ const windDir = normalize(u.uWindDir.add(vec2(1e-4, 0.0))).toVar();
193
+ const windAmp = u.uWindStrength.mul(iAux.w).toVar();
194
+
195
+ // Looping fall: each particle forever re-falls its own span (spawn
196
+ // height → ground, precomputed by the emitter). The loop ends collapse
197
+ // the quad so respawns at the top are invisible, never a pop.
198
+ const fallSpan = max(iAux.x, 0.5);
199
+ const fallSpeed = mix(0.5, 1.0, fract(seed.mul(19.7))).mul(mix(1.0, 1.4, isLeaf));
200
+ const progress = fract(iData.y.add(t.mul(fallSpeed).div(fallSpan))).toVar();
201
+ const drop = progress.mul(fallSpan).toVar();
202
+
203
+ // Side-to-side flutter (petals rock, leaves slide) + downwind advection
204
+ // that accumulates over the fall + a traveling gust kick for leaves.
205
+ const ph = t.mul(mix(1.4, 2.3, fract(seed.mul(7.7)))).add(seed.mul(41.0)).toVar();
206
+ const amp = iAux.y.mul(mix(0.32, 0.5, fract(seed.mul(3.3)))).toVar();
207
+ const perp = vec2(windDir.y.negate(), windDir.x);
208
+ const sway = perp.mul(sin(ph).mul(amp))
209
+ .add(windDir.mul(sin(ph.mul(0.77).add(1.3)).mul(amp).mul(0.6)))
210
+ .toVar();
211
+ const gust = sin(iSpawn.xz.dot(windDir).mul(0.15).sub(t.mul(u.uWindSpeed).mul(1.5)))
212
+ .mul(0.5).add(0.5);
213
+ const advect = windDir.mul(
214
+ windAmp.mul(drop).mul(1.6).add(gust.mul(windAmp).mul(isLeaf).mul(1.4)),
215
+ );
216
+
217
+ // Orientation: petals rock around a lazy yaw; leaves tumble end over
218
+ // end — this rotation is what makes tiny quads read as leaves/petals.
219
+ const yaw = seed.mul(6.2831).add(t.mul(iAux.y).mul(mix(0.6, 1.1, isLeaf)));
220
+ const pitch = mix(
221
+ sin(ph.mul(1.13)).mul(0.8).mul(iAux.y).add(1.1),
222
+ t.mul(iAux.y).mul(2.4).add(seed.mul(6.2831)),
223
+ isLeaf,
224
+ );
225
+ const roll = sin(ph.mul(0.71).add(seed.mul(9.0))).mul(0.6);
226
+
227
+ // Fades multiply into the quad SIZE: a degenerate quad rasterizes
228
+ // nothing, so gated/out-of-window particles cost zero fragments.
229
+ const worldSpawn = modelWorldMatrix.mul(vec4(iSpawn, 1.0)).xyz.toVar();
230
+ const endFade = smoothstep(0.0, 0.06, progress)
231
+ .mul(smoothstep(0.92, 1.0, progress).oneMinus());
232
+ const gateW = mix(u.uGatePetals, u.uGateLeaves, isLeaf);
233
+ const scale = iData.z
234
+ .mul(endFade)
235
+ .mul(windowFade(worldSpawn.xz))
236
+ .mul(gateFade(gateW, iAux.z))
237
+ .toVar();
238
+
239
+ // PlaneGeometry corners live in local XY; rotate the scaled quad.
240
+ const offset = rotate(positionLocal.mul(scale), vec3(pitch, yaw, roll));
241
+ const position = vec3(
242
+ iSpawn.x.add(sway.x).add(advect.x),
243
+ iSpawn.y.sub(drop),
244
+ iSpawn.z.add(sway.y).add(advect.y),
245
+ ).add(offset);
246
+ const worldPosition = modelWorldMatrix.mul(vec4(position, 1.0));
247
+ vWorldPos.assign(worldPosition.xyz);
248
+ return cameraProjectionMatrix.mul(cameraViewMatrix).mul(worldPosition);
249
+ })();
250
+
251
+ material.fragmentNode = Fn(() => {
252
+ const isLeaf = step(0.5, vKind);
253
+ const p = vUv.mul(2.0).sub(1.0).toVar();
254
+ // Petal: ellipse with a notch at the outer edge (the classic sakura
255
+ // dimple). Leaf: narrower pointed ellipse with a midrib.
256
+ const petalMask = length(p.mul(vec2(1.0, 1.12)))
257
+ .add(smoothstep(0.35, 0.0, abs(p.x)).mul(max(p.y, 0.0)).mul(0.45));
258
+ const leafMask = length(p.mul(vec2(1.5, 1.0)));
259
+ Discard(mix(petalMask, leafMask, isLeaf).greaterThan(1.0));
260
+
261
+ const color = vColor.mul(fract(vSeed.mul(5.1)).mul(0.16).add(0.92)).toVar();
262
+ color.mulAssign(smoothstep(0.12, 0.0, abs(p.x)).mul(isLeaf).mul(0.16).oneMinus());
263
+ // Backlit translucency toward the sun — same trick as the grass tips.
264
+ const viewDirection = normalize(cameraPosition.sub(vWorldPos));
265
+ const backlit = pow(clamp(viewDirection.dot(normalize(u.uSunDirection).negate()), 0.0, 1.0), 3.0);
266
+ color.addAssign(color.mul(backlit).mul(0.3));
267
+
268
+ const fogged = mix(color, u.uFogColor, heightFogFactor(vWorldPos));
269
+ return vec4(fogged, 1.0);
270
+ })();
271
+ });
272
+
273
+ // ---- glow group: fireflies (hover-blink) + pollen (curl drift) ----------
274
+ // Emissive/unlit by design (fireflies ARE light sources) and additive, so
275
+ // scene fog must not tint them toward the fog color — a fog-colored glow
276
+ // quad ADDS fog-colored light. Instead both fog layers only DIM the glow.
277
+ const glowMaterial = buildMaterial('AmbientFxGlow', (material) => {
278
+ material.transparent = true;
279
+ material.blending = THREE.AdditiveBlending;
280
+ material.depthWrite = false;
281
+ material.fog = false;
282
+
283
+ const vUv = uv();
284
+ const vColor = varying(vec3(), 'vFxColor');
285
+ const vSeed = varying(float(), 'vFxSeed');
286
+ const vKind = varying(float(), 'vFxKind');
287
+ const vRate = varying(float(), 'vFxRate');
288
+ const vFade = varying(float(), 'vFxFade');
289
+ const vWorldPos = varying(vec3(), 'vFxWorldPos');
290
+
291
+ material.vertexNode = Fn(() => {
292
+ const iSpawn = attribute('iSpawn', 'vec3');
293
+ const iData = attribute('iData', 'vec4');
294
+ const iAux = attribute('iAux', 'vec4');
295
+ const seed = iData.x;
296
+ const isPollen = step(2.5, iData.w).toVar();
297
+ vSeed.assign(seed);
298
+ vKind.assign(iData.w);
299
+ vRate.assign(iAux.y);
300
+ vColor.assign(attribute('iColor', 'vec3'));
301
+
302
+ const t = u.uTime;
303
+ // Layered-sine wander: fireflies patrol a small pocket of air, pollen
304
+ // swirls in a lazier, larger orbit keyed to its position (reads as a
305
+ // shared drift field without paying for real curl noise).
306
+ const a = mix(0.7, 1.3, fract(seed.mul(3.1)));
307
+ const b = mix(0.7, 1.3, fract(seed.mul(5.7)));
308
+ const c = mix(0.7, 1.3, fract(seed.mul(9.3)));
309
+ const speed = mix(0.5, 0.24, isPollen);
310
+ const wander = vec3(
311
+ sin(t.mul(speed).mul(a).add(seed.mul(40.0)).add(iSpawn.z.mul(isPollen).mul(0.4))),
312
+ sin(t.mul(speed).mul(0.8).mul(b).add(seed.mul(71.0))).mul(0.55),
313
+ cos(t.mul(speed).mul(0.9).mul(c).add(seed.mul(23.0)).add(iSpawn.x.mul(isPollen).mul(0.35))),
314
+ ).mul(iAux.x).toVar();
315
+ // Bounded, oscillating downwind slide — never walks off the window.
316
+ const windDir = normalize(u.uWindDir.add(vec2(1e-4, 0.0)));
317
+ const slide = sin(t.mul(0.22).add(iData.y.mul(6.2831))).mul(0.5).add(0.5)
318
+ .mul(u.uWindStrength).mul(iAux.w).mul(6.0);
319
+ wander.x.addAssign(windDir.x.mul(slide));
320
+ wander.z.addAssign(windDir.y.mul(slide));
321
+
322
+ const anchor = iSpawn.add(wander).toVar();
323
+ const worldSpawn = modelWorldMatrix.mul(vec4(iSpawn, 1.0)).xyz;
324
+ const gateW = mix(u.uGateFireflies, u.uGatePollen, isPollen);
325
+ const fade = windowFade(worldSpawn.xz).mul(gateFade(gateW, iAux.z)).toVar();
326
+ vFade.assign(fade);
327
+
328
+ // Camera-facing billboard from CPU-fed basis vectors (backend-safe).
329
+ const scale = iData.z.mul(step(0.001, fade)).mul(fade.mul(0.35).add(0.65));
330
+ const position = anchor
331
+ .add(u.uCamRight.mul(positionLocal.x).mul(scale))
332
+ .add(u.uCamUp.mul(positionLocal.y).mul(scale));
333
+ const worldPosition = modelWorldMatrix.mul(vec4(position, 1.0));
334
+ vWorldPos.assign(worldPosition.xyz);
335
+ return cameraProjectionMatrix.mul(cameraViewMatrix).mul(worldPosition);
336
+ })();
337
+
338
+ material.fragmentNode = Fn(() => {
339
+ const isPollen = step(2.5, vKind);
340
+ const t = u.uTime;
341
+ const d = length(vUv.mul(2.0).sub(1.0)).toVar();
342
+ const halo = pow(clamp(d.oneMinus(), 0.0, 1.0), 1.8).toVar();
343
+ const core = pow(clamp(d.oneMinus(), 0.0, 1.0), 6.0).toVar();
344
+
345
+ // Blink: the product of two incommensurate sines thresholded gives the
346
+ // irregular firefly pulse; a small floor keeps a faint ember between
347
+ // blinks so motes never teleport visually.
348
+ const blinkT = sin(t.mul(vRate).mul(1.1).add(vSeed.mul(251.0))).mul(0.5).add(0.5)
349
+ .mul(sin(t.mul(vRate).mul(0.37).add(vSeed.mul(97.0))).mul(0.5).add(0.5));
350
+ const blink = smoothstep(0.22, 0.55, blinkT).mul(0.94).add(0.06);
351
+
352
+ // Pollen: steady soft twinkle + strong boost looking toward the sun.
353
+ const viewDirection = normalize(cameraPosition.sub(vWorldPos));
354
+ const backlit = pow(clamp(viewDirection.dot(normalize(u.uSunDirection).negate()), 0.0, 1.0), 2.5);
355
+ const twinkle = sin(t.mul(vRate).add(vSeed.mul(151.0))).mul(0.18).add(0.82);
356
+ // Base bright enough to sparkle over sunlit grass; backlit doubles it.
357
+ const pollenBrightness = twinkle.mul(backlit.mul(u.uPollenBacklit).mul(2.2).add(0.85));
358
+
359
+ const brightness = mix(blink.mul(u.uFireflyIntensity).mul(2.0), pollenBrightness, isPollen);
360
+ const rgb = vColor.mul(halo).add(vec3(1.0).mul(core).mul(mix(0.8, 0.25, isPollen)))
361
+ .mul(brightness)
362
+ .mul(vFade)
363
+ .mul(heightFogFactor(vWorldPos).oneMinus());
364
+ return vec4(rgb, halo);
365
+ })();
366
+ });
367
+
368
+ // ---- soft group: mist wisps (horizontal scroll) ---------------------------
369
+ const softMaterial = buildMaterial('AmbientFxSoft', (material) => {
370
+ material.transparent = true;
371
+ material.depthWrite = false;
372
+ material.fog = true;
373
+
374
+ const vUv = uv();
375
+ const vColor = varying(vec3(), 'vFxColor');
376
+ const vSeed = varying(float(), 'vFxSeed');
377
+ const vFade = varying(float(), 'vFxFade');
378
+ const vWorldPos = varying(vec3(), 'vFxWorldPos');
379
+
380
+ material.vertexNode = Fn(() => {
381
+ const iSpawn = attribute('iSpawn', 'vec3');
382
+ const iData = attribute('iData', 'vec4');
383
+ const iAux = attribute('iAux', 'vec4');
384
+ const seed = iData.x;
385
+ vSeed.assign(seed);
386
+ vColor.assign(attribute('iColor', 'vec3'));
387
+
388
+ const t = u.uTime;
389
+ const windDir = normalize(u.uWindDir.add(vec2(1e-4, 0.0)));
390
+ // Scroll a wisp downwind across its span, wrap, fade at both ends.
391
+ const s = fract(iData.y.add(t.mul(u.uWindSpeed).mul(iAux.w).mul(0.02))).toVar();
392
+ const travel = s.sub(0.5).mul(iAux.x);
393
+ const bob = sin(t.mul(iAux.y).add(seed.mul(37.0))).mul(0.22);
394
+ const anchor = vec3(
395
+ iSpawn.x.add(windDir.x.mul(travel)),
396
+ iSpawn.y.add(bob),
397
+ iSpawn.z.add(windDir.y.mul(travel)),
398
+ ).toVar();
399
+
400
+ const worldSpawn = modelWorldMatrix.mul(vec4(iSpawn, 1.0)).xyz;
401
+ const endFade = smoothstep(0.0, 0.18, s).mul(smoothstep(0.82, 1.0, s).oneMinus());
402
+ const fade = windowFade(worldSpawn.xz)
403
+ .mul(gateFade(u.uGateMist, iAux.z))
404
+ .mul(endFade)
405
+ .toVar();
406
+ vFade.assign(fade);
407
+
408
+ // Y-axis billboard: wisps stay upright and face the camera in plan
409
+ // only, so they read as layers of ground haze from any walk-height view.
410
+ const toCam = cameraPosition.sub(anchor);
411
+ const right = normalize(vec3(toCam.z, 0.0, toCam.x.negate()).add(vec3(1e-4, 0.0, 0.0)));
412
+ const width = iData.z.mul(fract(seed.mul(2.7)).mul(2.6).add(4.0));
413
+ const grow = step(0.001, fade);
414
+ const position = anchor
415
+ .add(right.mul(positionLocal.x).mul(width).mul(grow))
416
+ .add(vec3(0.0, 1.0, 0.0).mul(positionLocal.y.add(0.35)).mul(iData.z).mul(grow));
417
+ const worldPosition = modelWorldMatrix.mul(vec4(position, 1.0));
418
+ vWorldPos.assign(worldPosition.xyz);
419
+ return cameraProjectionMatrix.mul(cameraViewMatrix).mul(worldPosition);
420
+ })();
421
+
422
+ material.fragmentNode = Fn(() => {
423
+ const t = u.uTime;
424
+ const p = vUv.mul(2.0).sub(1.0);
425
+ const body = pow(clamp(length(p.mul(vec2(1.0, 1.2))).oneMinus(), 0.0, 1.0), 1.5);
426
+ // Two drifting ripples across the quad break the "airbrushed ellipse"
427
+ // read into something wispy without a noise texture.
428
+ const ripple = sin(vUv.x.mul(9.0).add(t.mul(0.4)).add(vSeed.mul(63.0))).mul(0.5).add(0.5)
429
+ .mul(sin(vUv.x.mul(4.0).sub(t.mul(0.23)).add(vSeed.mul(29.0))).mul(0.5).add(0.5));
430
+ const alpha = body
431
+ .mul(ripple.mul(0.5).add(0.62))
432
+ .mul(u.uMistOpacity)
433
+ .mul(fract(vSeed.mul(9.3)).mul(0.3).add(0.7))
434
+ .mul(vFade);
435
+
436
+ const lit = mix(vColor.mul(0.94), vColor.mul(1.08), vUv.y);
437
+ const fogged = mix(lit, u.uFogColor, heightFogFactor(vWorldPos));
438
+ return vec4(fogged, alpha);
439
+ })();
440
+ });
441
+
442
+ const buildGroup = (id, material, capacity, renderOrder) => {
443
+ const mesh = new THREE.Mesh(createGroupGeometry(capacity), material);
444
+ mesh.name = `AmbientFx${id[0].toUpperCase()}${id.slice(1)}`;
445
+ mesh.frustumCulled = false;
446
+ mesh.renderOrder = renderOrder;
447
+ const group = {
448
+ capacity: Math.max(capacity, 1),
449
+ count: 0,
450
+ id,
451
+ mesh,
452
+ setInstances(records) {
453
+ if (records.length > group.capacity) {
454
+ group.capacity = Math.ceil(records.length * 1.3);
455
+ for (const [name, itemSize] of Object.entries(FLOATS_PER_INSTANCE)) {
456
+ mesh.geometry.setAttribute(name, new THREE.InstancedBufferAttribute(
457
+ new Float32Array(group.capacity * itemSize), itemSize));
458
+ }
459
+ }
460
+ const spawn = mesh.geometry.attributes.iSpawn;
461
+ const data = mesh.geometry.attributes.iData;
462
+ const color = mesh.geometry.attributes.iColor;
463
+ const aux = mesh.geometry.attributes.iAux;
464
+ records.forEach((r, i) => {
465
+ spawn.array[i * 3] = r.x; spawn.array[i * 3 + 1] = r.y; spawn.array[i * 3 + 2] = r.z;
466
+ data.array[i * 4] = r.seed; data.array[i * 4 + 1] = r.phase;
467
+ data.array[i * 4 + 2] = r.size; data.array[i * 4 + 3] = r.kind;
468
+ color.array[i * 3] = r.r; color.array[i * 3 + 1] = r.g; color.array[i * 3 + 2] = r.b;
469
+ aux.array[i * 4] = r.range; aux.array[i * 4 + 1] = r.rate;
470
+ aux.array[i * 4 + 2] = r.gateJitter; aux.array[i * 4 + 3] = r.windResponse;
471
+ });
472
+ spawn.needsUpdate = data.needsUpdate = color.needsUpdate = aux.needsUpdate = true;
473
+ mesh.geometry.instanceCount = records.length;
474
+ group.count = records.length;
475
+ },
476
+ dispose() {
477
+ mesh.geometry.dispose();
478
+ material.dispose();
479
+ },
480
+ };
481
+ return group;
482
+ };
483
+
484
+ return {
485
+ groups: {
486
+ cutout: buildGroup('cutout', cutoutMaterial, 2048, 1),
487
+ // Mist under the glows so fireflies shine THROUGH the haze layer.
488
+ soft: buildGroup('soft', softMaterial, 64, 2),
489
+ glow: buildGroup('glow', glowMaterial, 1024, 3),
490
+ },
491
+ uniforms: u,
492
+ };
493
+ }