@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,105 @@
1
+ // POI site selection: score the terrain per archetype (flatness, dryness,
2
+ // shore/hilltop affinities from the archetype table), then greedy-pick
3
+ // mutually distant winners. Deterministic per seed.
4
+
5
+ import { POI_ARCHETYPES } from './villageArchetypes.js';
6
+
7
+ function mulberry32(seed) {
8
+ let state = (Math.trunc(seed) || 1) >>> 0;
9
+ return () => {
10
+ state = (state + 0x6d2b79f5) >>> 0;
11
+ let t = state;
12
+ t = Math.imul(t ^ (t >>> 15), t | 1);
13
+ t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
14
+ return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
15
+ };
16
+ }
17
+
18
+ /**
19
+ * @param {Object} options
20
+ * @param {Function} options.heightAt
21
+ * @param {number} [options.waterLevel]
22
+ * @param {number|{x,z}} [options.size]
23
+ * @param {number} [options.seed]
24
+ * @param {Array<{archetype: string, count: number, radius?: number}>} options.requests
25
+ * @returns {Array<{archetype, seed, x, z, radius}>}
26
+ */
27
+ export function pickPoiSites({
28
+ heightAt,
29
+ waterLevel = 0,
30
+ size = 1000,
31
+ seed = 1,
32
+ requests = [],
33
+ } = {}) {
34
+ const width = Number(size?.width ?? size?.x ?? size) || 1000;
35
+ const depth = Number(size?.depth ?? size?.z ?? size) || 1000;
36
+ const random = mulberry32(seed * 22695477 + 3);
37
+ const innerX = width * 0.4;
38
+ const innerZ = depth * 0.4;
39
+
40
+ const slopeAt = (x, z) => {
41
+ const step = 3;
42
+ const dx = (heightAt(x + step, z) - heightAt(x - step, z)) / (2 * step);
43
+ const dz = (heightAt(x, z + step) - heightAt(x, z - step)) / (2 * step);
44
+ return Math.hypot(dx, dz);
45
+ };
46
+ const shoreDistance = (x, z) => {
47
+ for (const reach of [6, 12, 20, 28]) {
48
+ for (let probe = 0; probe < 8; probe += 1) {
49
+ const angle = (probe / 8) * Math.PI * 2;
50
+ if (heightAt(x + Math.cos(angle) * reach, z + Math.sin(angle) * reach) <= waterLevel) {
51
+ return reach;
52
+ }
53
+ }
54
+ }
55
+ return 99;
56
+ };
57
+ const context = { heightAt, shoreDistance, slopeAt, waterLevel };
58
+
59
+ // Site flatness must hold across the whole radius, not just the center —
60
+ // sample a ring too.
61
+ const areaScore = (archetypeSpec, x, z, radius) => {
62
+ let score = archetypeSpec.siteScore(x, z, context);
63
+ if (score <= 0) return 0;
64
+ for (let probe = 0; probe < 4; probe += 1) {
65
+ const angle = (probe / 4) * Math.PI * 2 + 0.5;
66
+ const rx = x + Math.cos(angle) * radius * 0.6;
67
+ const rz = z + Math.sin(angle) * radius * 0.6;
68
+ const ringScore = archetypeSpec.siteScore(rx, rz, context);
69
+ if (ringScore <= 0) return 0;
70
+ score += ringScore * 0.25;
71
+ }
72
+ return score;
73
+ };
74
+
75
+ const picked = [];
76
+ for (const request of requests) {
77
+ const archetypeSpec = POI_ARCHETYPES[request.archetype];
78
+ if (!archetypeSpec) continue;
79
+ const radius = request.radius ?? Math.max(archetypeSpec.minRadius, 30);
80
+ for (let instance = 0; instance < (request.count ?? 1); instance += 1) {
81
+ let best = null;
82
+ for (let attempt = 0; attempt < 220; attempt += 1) {
83
+ const x = (random() * 2 - 1) * innerX;
84
+ const z = (random() * 2 - 1) * innerZ;
85
+ let clear = true;
86
+ for (const site of picked) {
87
+ const minDistance = site.radius + radius + 40;
88
+ if ((site.x - x) ** 2 + (site.z - z) ** 2 < minDistance ** 2) { clear = false; break; }
89
+ }
90
+ if (!clear) continue;
91
+ const score = areaScore(archetypeSpec, x, z, radius);
92
+ if (score > 0 && (!best || score > best.score)) best = { score, x, z };
93
+ }
94
+ if (!best) continue; // terrain had no room — honest miss, not a crash
95
+ picked.push({
96
+ archetype: request.archetype,
97
+ radius,
98
+ seed: Math.floor(random() * 0xffffffff),
99
+ x: best.x,
100
+ z: best.z,
101
+ });
102
+ }
103
+ }
104
+ return picked;
105
+ }
@@ -0,0 +1,23 @@
1
+ /**
2
+ * Ordered transient layers for active water. Lower priorities establish a
3
+ * base that higher priorities can derive from or replace. Runtime policy is
4
+ * never serialized into a portable water preset.
5
+ */
6
+ export const WATER_SCENE_OVERRIDE_PRIORITIES = Object.freeze({
7
+ lighting: 100,
8
+ weather: 200,
9
+ scene: 300,
10
+ });
11
+
12
+ /**
13
+ * Authored fallback fields that a live scene may replace without editing the
14
+ * portable water asset. Palette, wave structure, foam, and interaction
15
+ * response remain authored settings.
16
+ */
17
+ export const WATER_SCENE_OVERRIDE_KEYS = Object.freeze([
18
+ 'waveIntensity',
19
+ 'sunDirection',
20
+ 'sunColor',
21
+ 'skyZenithColor',
22
+ 'skyHorizonColor',
23
+ ]);
@@ -9,12 +9,17 @@
9
9
  // water.update(renderer, scene, camera, delta);
10
10
 
11
11
  export * from './waterSettings.js';
12
+ export * from './sceneOverrideLayers.js';
12
13
  export * from './waterMaterial.js';
14
+ export * from './waterCurrentField.js';
15
+ export * from './waterNearshorePhase.js';
13
16
  export * from './waterRippleSimulation.js';
14
17
  export * from './waterSplashSystem.js';
15
18
  export * from './waterBreakerSystem.js';
16
19
  export * from './waterScenePasses.js';
17
20
  export * from './waterInteraction.js';
18
21
  export * from './waterSurface.js';
22
+ export * from './waterShoreStateField.js';
23
+ export * from './waterShoreMaterial.js';
19
24
  export * from './waterRain.js';
20
25
  export * from './waterVegetation.js';
@@ -54,6 +54,20 @@ export function computeBreakingDepth(settings, waveEnergy) {
54
54
  return depth;
55
55
  }
56
56
 
57
+ // The overhanging shell is a second water surface. It works in a substantial
58
+ // shore/open-water column, but cannot be welded convincingly to the few-cm
59
+ // film used by an explicit calibration swash; there it produced detached
60
+ // foam ribbons. The main heightfield still performs depth capping and breaker
61
+ // foam, controlled by breakerEnabled/breakerAmount.
62
+ export function shouldUseDedicatedBreakerShell(settings, hasBedSampler = true) {
63
+ return Boolean(
64
+ hasBedSampler
65
+ && settings?.breakerEnabled !== false
66
+ && (settings?.breakerAmount ?? 0) > 0.001
67
+ && (settings?.runupDistance ?? 0) <= 0.01,
68
+ );
69
+ }
70
+
57
71
  function pointKey(x, z) {
58
72
  return `${Math.round(x * 512)},${Math.round(z * 512)}`;
59
73
  }
@@ -556,7 +570,7 @@ export class WaterBreakerSystem extends THREE.Group {
556
570
  (1 - smoothstep(delta, 0.38, 0.47));
557
571
 
558
572
  const capDepth = Math.min(col.depth, localDepth);
559
- const breakHeight = 0.9 * capDepth * p.scale * p.amount * col.endFade * setEnvelope;
573
+ const breakHeight = 0.48 * capDepth * p.scale * p.amount * col.endFade * setEnvelope;
560
574
  const H = breakHeight * THREE.MathUtils.lerp(0.12, 1, pulse) * (1 - 0.55 * post);
561
575
  const fade = p.amount * col.endFade * travelFade * setGate *
562
576
  smoothstep(pulse + post, 0.12, 0.35) * smoothstep(H, 0.05, 0.15);
@@ -0,0 +1,447 @@
1
+ import * as THREE from 'three';
2
+
3
+ const DEFAULT_MAX_SPEED = 8;
4
+ const sampleScratch = new THREE.Vector2();
5
+
6
+ function finiteOr(value, fallback) {
7
+ const number = Number(value);
8
+ return Number.isFinite(number) ? number : fallback;
9
+ }
10
+
11
+ function clamp01(value) {
12
+ return THREE.MathUtils.clamp(finiteOr(value, 0), 0, 1);
13
+ }
14
+
15
+ function smoothstep01(value) {
16
+ const x = clamp01(value);
17
+ return x * x * (3 - 2 * x);
18
+ }
19
+
20
+ function vector2Components(value, fallbackX = 0, fallbackZ = 0) {
21
+ if (Array.isArray(value)) {
22
+ return [finiteOr(value[0], fallbackX), finiteOr(value[1], fallbackZ)];
23
+ }
24
+ return [
25
+ finiteOr(value?.x ?? value?.[0], fallbackX),
26
+ finiteOr(value?.z ?? value?.y ?? value?.[1], fallbackZ),
27
+ ];
28
+ }
29
+
30
+ // Constructor regions use full width/depth. Vector4 uses the shader-facing
31
+ // (centerX, centerZ, halfWidth, halfDepth) convention shared by ripple and
32
+ // shoreline atlases.
33
+ function normalizeRegion(region) {
34
+ if (region?.isVector4) {
35
+ return {
36
+ centerX: finiteOr(region.x, 0),
37
+ centerZ: finiteOr(region.y, 0),
38
+ width: Math.max(finiteOr(region.z, 16) * 2, 0.1),
39
+ depth: Math.max(finiteOr(region.w, 16) * 2, 0.1),
40
+ };
41
+ }
42
+ if (Array.isArray(region)) {
43
+ return {
44
+ centerX: finiteOr(region[0], 0),
45
+ centerZ: finiteOr(region[1], 0),
46
+ width: Math.max(finiteOr(region[2], 32), 0.1),
47
+ depth: Math.max(finiteOr(region[3], 32), 0.1),
48
+ };
49
+ }
50
+ if (region?.min && region?.max) {
51
+ const minX = finiteOr(region.min.x, -16);
52
+ const minZ = finiteOr(region.min.z ?? region.min.y, -16);
53
+ const maxX = finiteOr(region.max.x, 16);
54
+ const maxZ = finiteOr(region.max.z ?? region.max.y, 16);
55
+ return {
56
+ centerX: (minX + maxX) * 0.5,
57
+ centerZ: (minZ + maxZ) * 0.5,
58
+ width: Math.max(maxX - minX, 0.1),
59
+ depth: Math.max(maxZ - minZ, 0.1),
60
+ };
61
+ }
62
+ const center = region?.center;
63
+ const size = region?.size;
64
+ return {
65
+ centerX: finiteOr(region?.centerX ?? center?.x ?? region?.x, 0),
66
+ centerZ: finiteOr(region?.centerZ ?? center?.z ?? center?.y ?? region?.z, 0),
67
+ width: Math.max(finiteOr(
68
+ region?.width ?? size?.x ??
69
+ (Number.isFinite(region?.halfWidth) ? region.halfWidth * 2 : undefined),
70
+ 32,
71
+ ), 0.1),
72
+ depth: Math.max(finiteOr(
73
+ region?.depth ?? size?.z ?? size?.y ??
74
+ (Number.isFinite(region?.halfDepth) ? region.halfDepth * 2 : undefined),
75
+ 32,
76
+ ), 0.1),
77
+ };
78
+ }
79
+
80
+ function normalizeResolution(resolution) {
81
+ let x;
82
+ let y;
83
+ if (Number.isFinite(resolution)) {
84
+ x = resolution;
85
+ y = resolution;
86
+ } else if (Array.isArray(resolution)) {
87
+ [x, y] = resolution;
88
+ } else {
89
+ x = resolution?.x ?? resolution?.width;
90
+ y = resolution?.y ?? resolution?.height;
91
+ }
92
+ return {
93
+ x: THREE.MathUtils.clamp(Math.round(finiteOr(x, 128)), 2, 2048),
94
+ y: THREE.MathUtils.clamp(Math.round(finiteOr(y, 128)), 2, 2048),
95
+ };
96
+ }
97
+
98
+ function resolveVelocity(result, out) {
99
+ if (result === false || result === null) return false;
100
+ if (Array.isArray(result)) {
101
+ out.set(finiteOr(result[0], NaN), finiteOr(result[1], NaN));
102
+ } else if (result && result !== out) {
103
+ out.set(
104
+ finiteOr(result.x ?? result[0], NaN),
105
+ finiteOr(result.z ?? result.y ?? result[1], NaN),
106
+ );
107
+ }
108
+ return Number.isFinite(out.x) && Number.isFinite(out.y);
109
+ }
110
+
111
+ /**
112
+ * CPU-authored, world-space horizontal current field.
113
+ *
114
+ * The CPU retains full-precision velocity/mask arrays for gameplay queries.
115
+ * A compact linearly-filtered RGBA8 texture mirrors the field for lightweight
116
+ * GPU consumers such as shoreline-foam advection:
117
+ *
118
+ * R/G = signed X/Z velocity encoded around 0.5
119
+ * B = fluid/obstacle weight
120
+ * A = valid authored domain
121
+ *
122
+ * This is deliberately not a shallow-water or Navier-Stokes solver. The
123
+ * caller authors the large-scale velocity (`velocitySampler`) and optional
124
+ * water-domain mask. A signed-distance sampler adds a deterministic
125
+ * no-penetration projection near banks/rocks, but it cannot infer circulation,
126
+ * pressure, wakes, separation, or downstream turbulence. Those need authored
127
+ * vectors, a flow-map bake, or a real fluid solver upstream.
128
+ *
129
+ * `velocitySampler(x, z, out, context)` may mutate `out` or return a Vector2,
130
+ * `[vx, vz]`, `{ x, z }`, `false`, or `null`. `context.time` lets a caller bake
131
+ * a new tidal phase. For cheap whole-field tidal reversal between bakes, use
132
+ * `setStrength(-1)`; CPU and GPU consumers receive the same multiplier.
133
+ */
134
+ export class WaterCurrentField {
135
+ constructor({
136
+ region = {},
137
+ resolution = { x: 128, y: 128 },
138
+ velocity = [0, 0],
139
+ velocitySampler = null,
140
+ sampler = velocitySampler,
141
+ maskSampler = null,
142
+ domainMaskSampler = maskSampler,
143
+ signedDistanceSampler = null,
144
+ obstacleDistanceSampler = signedDistanceSampler,
145
+ obstacleInfluence = 1,
146
+ obstacleDeflection = 1,
147
+ preserveTangentialSpeed = 0.7,
148
+ obstacleGradientStep = null,
149
+ maxSpeed = DEFAULT_MAX_SPEED,
150
+ strength = 1,
151
+ time = 0,
152
+ } = {}) {
153
+ const normalizedRegion = normalizeRegion(region);
154
+ const normalizedResolution = normalizeResolution(resolution);
155
+ this.isWaterCurrentField = true;
156
+ this.centerX = normalizedRegion.centerX;
157
+ this.centerZ = normalizedRegion.centerZ;
158
+ this.worldWidth = normalizedRegion.width;
159
+ this.worldDepth = normalizedRegion.depth;
160
+ this.resolutionX = normalizedResolution.x;
161
+ this.resolutionY = normalizedResolution.y;
162
+ this.region = new THREE.Vector4(
163
+ this.centerX,
164
+ this.centerZ,
165
+ this.worldWidth * 0.5,
166
+ this.worldDepth * 0.5,
167
+ );
168
+ this.minX = this.centerX - this.worldWidth * 0.5;
169
+ this.minZ = this.centerZ - this.worldDepth * 0.5;
170
+ this.cellSizeX = this.worldWidth / this.resolutionX;
171
+ this.cellSizeZ = this.worldDepth / this.resolutionY;
172
+
173
+ const [constantX, constantZ] = vector2Components(velocity);
174
+ this.constantVelocity = new THREE.Vector2(constantX, constantZ);
175
+ this.velocitySampler = typeof sampler === 'function' ? sampler : null;
176
+ this.maskSampler = typeof domainMaskSampler === 'function' ? domainMaskSampler : null;
177
+ this.obstacleDistanceSampler = typeof obstacleDistanceSampler === 'function'
178
+ ? obstacleDistanceSampler
179
+ : null;
180
+ this.obstacleInfluence = Math.max(finiteOr(obstacleInfluence, 1), 0);
181
+ this.obstacleDeflection = clamp01(obstacleDeflection);
182
+ this.preserveTangentialSpeed = clamp01(preserveTangentialSpeed);
183
+ this.obstacleGradientStep = Math.max(
184
+ finiteOr(obstacleGradientStep, Math.min(this.cellSizeX, this.cellSizeZ) * 0.75),
185
+ 1e-3,
186
+ );
187
+ this.maxSpeed = Math.max(finiteOr(maxSpeed, DEFAULT_MAX_SPEED), 1e-3);
188
+ this.strength = finiteOr(strength, 1);
189
+ this.time = finiteOr(time, 0);
190
+ this.revision = 0;
191
+ this.disposed = false;
192
+ this.sampleContext = { time: this.time, field: this };
193
+
194
+ const count = this.resolutionX * this.resolutionY;
195
+ this.velocities = new Float32Array(count * 2);
196
+ this.weights = new Float32Array(count);
197
+ this.validity = new Float32Array(count);
198
+ this.encodedData = new Uint8Array(count * 4);
199
+ this.texture = new THREE.DataTexture(
200
+ this.encodedData,
201
+ this.resolutionX,
202
+ this.resolutionY,
203
+ THREE.RGBAFormat,
204
+ THREE.UnsignedByteType,
205
+ );
206
+ this.texture.name = 'waterCurrentField';
207
+ this.texture.wrapS = THREE.ClampToEdgeWrapping;
208
+ this.texture.wrapT = THREE.ClampToEdgeWrapping;
209
+ this.texture.minFilter = THREE.LinearFilter;
210
+ this.texture.magFilter = THREE.LinearFilter;
211
+ this.texture.generateMipmaps = false;
212
+ this.texture.flipY = false;
213
+ this.texture.colorSpace = THREE.NoColorSpace;
214
+ this.texture.unpackAlignment = 1;
215
+
216
+ this.rebuild({ time: this.time });
217
+ }
218
+
219
+ getRegion(out = new THREE.Vector4()) {
220
+ return out.copy(this.region);
221
+ }
222
+
223
+ containsPoint(x, z) {
224
+ return Number.isFinite(x) && Number.isFinite(z) &&
225
+ x >= this.minX && x <= this.minX + this.worldWidth &&
226
+ z >= this.minZ && z <= this.minZ + this.worldDepth;
227
+ }
228
+
229
+ setStrength(strength) {
230
+ this.strength = finiteOr(strength, this.strength);
231
+ return this;
232
+ }
233
+
234
+ setTime(time, { rebuild = true } = {}) {
235
+ this.time = finiteOr(time, this.time);
236
+ if (rebuild) this.rebuild({ time: this.time });
237
+ return this;
238
+ }
239
+
240
+ setVelocitySampler(sampler, { rebuild = true } = {}) {
241
+ if (sampler != null && typeof sampler !== 'function') {
242
+ throw new TypeError('WaterCurrentField velocitySampler must be a function or null.');
243
+ }
244
+ this.velocitySampler = sampler ?? null;
245
+ if (rebuild) this.rebuild();
246
+ return this;
247
+ }
248
+
249
+ setMaskSampler(sampler, { rebuild = true } = {}) {
250
+ if (sampler != null && typeof sampler !== 'function') {
251
+ throw new TypeError('WaterCurrentField maskSampler must be a function or null.');
252
+ }
253
+ this.maskSampler = sampler ?? null;
254
+ if (rebuild) this.rebuild();
255
+ return this;
256
+ }
257
+
258
+ setObstacleDistanceSampler(sampler, { rebuild = true } = {}) {
259
+ if (sampler != null && typeof sampler !== 'function') {
260
+ throw new TypeError(
261
+ 'WaterCurrentField obstacleDistanceSampler must be a function or null.',
262
+ );
263
+ }
264
+ this.obstacleDistanceSampler = sampler ?? null;
265
+ if (rebuild) this.rebuild();
266
+ return this;
267
+ }
268
+
269
+ // Applies the local solid-boundary approximation in-place. Positive signed
270
+ // distance is fluid; <= 0 is solid. The gradient points out of the solid,
271
+ // so only velocity aimed into it is removed.
272
+ projectAtObstacle(x, z, velocity, distance) {
273
+ if (!this.obstacleDistanceSampler || !Number.isFinite(distance)) return 1;
274
+ if (distance <= 0) {
275
+ velocity.set(0, 0);
276
+ return 0;
277
+ }
278
+
279
+ const influence = this.obstacleInfluence;
280
+ if (influence <= 0 || distance >= influence) return 1;
281
+ const h = this.obstacleGradientStep;
282
+ const left = Number(this.obstacleDistanceSampler(x - h, z, this.sampleContext));
283
+ const right = Number(this.obstacleDistanceSampler(x + h, z, this.sampleContext));
284
+ const down = Number(this.obstacleDistanceSampler(x, z - h, this.sampleContext));
285
+ const up = Number(this.obstacleDistanceSampler(x, z + h, this.sampleContext));
286
+ const gradientX = right - left;
287
+ const gradientZ = up - down;
288
+ const gradientLength = Math.hypot(gradientX, gradientZ);
289
+ const avoidance = (1 - smoothstep01(distance / influence)) * this.obstacleDeflection;
290
+ if (gradientLength > 1e-6 && avoidance > 0) {
291
+ const normalX = gradientX / gradientLength;
292
+ const normalZ = gradientZ / gradientLength;
293
+ const inward = velocity.x * normalX + velocity.y * normalZ;
294
+ if (inward < 0) {
295
+ const originalSpeed = velocity.length();
296
+ velocity.x -= normalX * inward * avoidance;
297
+ velocity.y -= normalZ * inward * avoidance;
298
+ const projectedSpeed = velocity.length();
299
+ if (projectedSpeed > 1e-5 && originalSpeed > projectedSpeed) {
300
+ const preservedSpeed = THREE.MathUtils.lerp(
301
+ projectedSpeed,
302
+ originalSpeed,
303
+ avoidance * this.preserveTangentialSpeed,
304
+ );
305
+ velocity.multiplyScalar(preservedSpeed / projectedSpeed);
306
+ }
307
+ }
308
+ }
309
+ // Feather the last fraction of a cell so linear GPU sampling does not
310
+ // carry a full-speed texel across a solid boundary.
311
+ const feather = Math.max(Math.min(influence * 0.25, Math.hypot(
312
+ this.cellSizeX,
313
+ this.cellSizeZ,
314
+ )), 1e-3);
315
+ return smoothstep01(distance / feather);
316
+ }
317
+
318
+ rebuild({ time = this.time } = {}) {
319
+ if (this.disposed) return this;
320
+ this.time = finiteOr(time, this.time);
321
+ this.sampleContext.time = this.time;
322
+ const width = this.resolutionX;
323
+ const height = this.resolutionY;
324
+ const maximum = this.maxSpeed;
325
+
326
+ for (let y = 0; y < height; y += 1) {
327
+ const worldZ = this.minZ + (y + 0.5) * this.cellSizeZ;
328
+ for (let x = 0; x < width; x += 1) {
329
+ const worldX = this.minX + (x + 0.5) * this.cellSizeX;
330
+ const index = y * width + x;
331
+ const velocityIndex = index * 2;
332
+ const textureIndex = index * 4;
333
+ sampleScratch.copy(this.constantVelocity);
334
+ let valid = true;
335
+ if (this.velocitySampler) {
336
+ const result = this.velocitySampler(
337
+ worldX,
338
+ worldZ,
339
+ sampleScratch,
340
+ this.sampleContext,
341
+ );
342
+ valid = resolveVelocity(result, sampleScratch);
343
+ }
344
+
345
+ let weight = valid ? 1 : 0;
346
+ if (valid && this.maskSampler) {
347
+ weight *= clamp01(this.maskSampler(worldX, worldZ, this.sampleContext));
348
+ }
349
+ if (valid && this.obstacleDistanceSampler) {
350
+ const distance = Number(
351
+ this.obstacleDistanceSampler(worldX, worldZ, this.sampleContext),
352
+ );
353
+ if (Number.isFinite(distance)) {
354
+ weight *= this.projectAtObstacle(worldX, worldZ, sampleScratch, distance);
355
+ }
356
+ }
357
+
358
+ if (!valid || weight <= 0) sampleScratch.set(0, 0);
359
+ const speed = sampleScratch.length();
360
+ if (speed > maximum) sampleScratch.multiplyScalar(maximum / speed);
361
+ this.velocities[velocityIndex] = sampleScratch.x;
362
+ this.velocities[velocityIndex + 1] = sampleScratch.y;
363
+ this.weights[index] = clamp01(weight);
364
+ this.validity[index] = valid ? 1 : 0;
365
+ // Reserve byte 128 as exact zero. A conventional UNORM 0.5 bias
366
+ // decodes 128/255 to +1/255, which becomes a persistent 3.1 cm/s
367
+ // diagonal drift at the default 8 m/s range even in authored still
368
+ // water. Codes 1..255 provide a symmetric signed 127-step range;
369
+ // code 0 remains unused by valid velocity samples.
370
+ this.encodedData[textureIndex] = Math.round(
371
+ 128 + THREE.MathUtils.clamp(sampleScratch.x / maximum, -1, 1) * 127,
372
+ );
373
+ this.encodedData[textureIndex + 1] = Math.round(
374
+ 128 + THREE.MathUtils.clamp(sampleScratch.y / maximum, -1, 1) * 127,
375
+ );
376
+ this.encodedData[textureIndex + 2] = Math.round(this.weights[index] * 255);
377
+ this.encodedData[textureIndex + 3] = valid ? 255 : 0;
378
+ }
379
+ }
380
+ this.texture.needsUpdate = true;
381
+ this.revision += 1;
382
+ return this;
383
+ }
384
+
385
+ sampleChannelsAt(x, z, out) {
386
+ if (!this.containsPoint(x, z)) {
387
+ out.vx = 0;
388
+ out.vz = 0;
389
+ out.weight = 0;
390
+ out.valid = 0;
391
+ return out;
392
+ }
393
+ const pixelX = (x - this.minX) / this.worldWidth * this.resolutionX - 0.5;
394
+ const pixelY = (z - this.minZ) / this.worldDepth * this.resolutionY - 0.5;
395
+ const baseX = Math.floor(pixelX);
396
+ const baseY = Math.floor(pixelY);
397
+ const x0 = THREE.MathUtils.clamp(baseX, 0, this.resolutionX - 1);
398
+ const y0 = THREE.MathUtils.clamp(baseY, 0, this.resolutionY - 1);
399
+ const x1 = THREE.MathUtils.clamp(baseX + 1, 0, this.resolutionX - 1);
400
+ const y1 = THREE.MathUtils.clamp(baseY + 1, 0, this.resolutionY - 1);
401
+ const tx = THREE.MathUtils.clamp(pixelX - baseX, 0, 1);
402
+ const ty = THREE.MathUtils.clamp(pixelY - baseY, 0, 1);
403
+ const i00 = y0 * this.resolutionX + x0;
404
+ const i10 = y0 * this.resolutionX + x1;
405
+ const i01 = y1 * this.resolutionX + x0;
406
+ const i11 = y1 * this.resolutionX + x1;
407
+ const bilerp = (array, stride = 1, offset = 0) => {
408
+ const a = THREE.MathUtils.lerp(
409
+ array[i00 * stride + offset],
410
+ array[i10 * stride + offset],
411
+ tx,
412
+ );
413
+ const b = THREE.MathUtils.lerp(
414
+ array[i01 * stride + offset],
415
+ array[i11 * stride + offset],
416
+ tx,
417
+ );
418
+ return THREE.MathUtils.lerp(a, b, ty);
419
+ };
420
+ out.vx = bilerp(this.velocities, 2, 0);
421
+ out.vz = bilerp(this.velocities, 2, 1);
422
+ out.weight = bilerp(this.weights);
423
+ out.valid = bilerp(this.validity);
424
+ return out;
425
+ }
426
+
427
+ /** Bilinear CPU sample in metres/second. Outside/solid/invalid = (0, 0). */
428
+ sampleAt(x, z, out = new THREE.Vector2()) {
429
+ const channels = this.sampleChannelsAt(x, z, WaterCurrentField._sampleChannels);
430
+ const scale = channels.weight * channels.valid * this.strength;
431
+ return out.set(channels.vx * scale, channels.vz * scale);
432
+ }
433
+
434
+ /** Fluid-domain weight after obstacle and validity feathering, 0..1. */
435
+ sampleWeightAt(x, z) {
436
+ const channels = this.sampleChannelsAt(x, z, WaterCurrentField._sampleChannels);
437
+ return clamp01(channels.weight * channels.valid);
438
+ }
439
+
440
+ dispose() {
441
+ if (this.disposed) return;
442
+ this.disposed = true;
443
+ this.texture.dispose();
444
+ }
445
+ }
446
+
447
+ WaterCurrentField._sampleChannels = { vx: 0, vz: 0, weight: 0, valid: 0 };
@@ -133,6 +133,9 @@ export function applyWaterSettingsToMaterial(material, options = {}) {
133
133
  const waves = buildGerstnerWaves(settings);
134
134
 
135
135
  writeWaveUniforms(material, waves);
136
+ if (uniforms.uSwashIncidenceX) {
137
+ uniforms.uSwashIncidenceX.value = waves[0]?.dirX ?? 0;
138
+ }
136
139
 
137
140
  uniforms.uDetailNormalStrength.value = settings.detailNormalStrength;
138
141
  uniforms.uDetailScale.value = settings.detailScale;
@@ -146,12 +149,16 @@ export function applyWaterSettingsToMaterial(material, options = {}) {
146
149
  uniforms.uDeepFadeDistance.value = settings.deepFadeDistance;
147
150
  uniforms.uOpacity.value = settings.opacity;
148
151
  uniforms.uRefractionStrength.value = settings.refractionStrength;
152
+ uniforms.uIndexOfRefraction.value = settings.indexOfRefraction;
153
+ uniforms.uUnderwaterTransmission.value = settings.underwaterTransmission;
154
+ uniforms.uUnderwaterTintStrength.value = settings.underwaterTintStrength;
149
155
  uniforms.uCausticsStrength.value = settings.causticsStrength;
150
156
  uniforms.uCausticsScale.value = settings.causticsScale;
151
157
  uniforms.uCausticsSpeed.value = settings.causticsSpeed;
152
158
 
153
159
  setSrgbColorUniform(uniforms.uFoamColor, settings.foamColor);
154
160
  uniforms.uFoamAmount.value = settings.foamAmount;
161
+ uniforms.uSwashFoamAmount.value = settings.swashFoamAmount;
155
162
  uniforms.uFoamContactDistance.value = settings.foamContactDistance;
156
163
  uniforms.uFoamLineSpacing.value = settings.foamLineSpacing;
157
164
  uniforms.uFoamNoiseScale.value = settings.foamNoiseScale;
@@ -182,6 +189,11 @@ export function applyWaterSettingsToMaterial(material, options = {}) {
182
189
  uniforms.uShoalingDepth.value = settings.shoalingDepth;
183
190
  uniforms.uShorelineWaves.value = settings.shorelineWaves;
184
191
  uniforms.uShorelineRunup.value = settings.shorelineRunup;
192
+ uniforms.uRunupDistance.value = settings.runupDistance;
193
+ if (uniforms.uBreakerEnabled) {
194
+ uniforms.uBreakerEnabled.value = settings.breakerEnabled === false ? 0 : 1;
195
+ }
196
+ if (uniforms.uBreakerAmount) uniforms.uBreakerAmount.value = settings.breakerAmount;
185
197
  // Total swell energy — normalizes breaker foam and scales swash run-up.
186
198
  const waveEnergy = waves.reduce((sum, wave) => sum + Math.abs(wave.amplitude), 0);
187
199
  uniforms.uWaveEnergy.value = waveEnergy;