@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,320 @@
1
+ // CPU-baked nearshore phase field for the primary Gerstner swell and its
2
+ // optional same-direction set-beat partner.
3
+ //
4
+ // A spatial phase coordinate q(x,z) replaces the deep-water dot(direction,
5
+ // position) term close to bathymetry. Its gradient is the local wave vector
6
+ // expressed as a multiple of the offshore wave number:
7
+ //
8
+ // theta_i = k_i * q(x,z) - omega_i * time + phase_i
9
+ // grad(theta_i) = k_i * grad(q)
10
+ //
11
+ // Keeping omega fixed preserves the incident period. Increasing |grad(q)| in
12
+ // shallow water therefore lowers phase speed and shortens wavelength, while
13
+ // the direction of grad(q) bends crests around depth contours. The field is
14
+ // baked once when the static bed atlas is baked; WaterSurface packs it into
15
+ // one vec4 attribute (q, dq/dx, dq/dz, slot mask), avoiding another texture
16
+ // pass or per-wave root solve in the already-large WebGPU material.
17
+
18
+ const EPSILON = 1e-8;
19
+
20
+ function finitePositive(value, fallback) {
21
+ const number = Number(value);
22
+ return Number.isFinite(number) && number > 0 ? number : fallback;
23
+ }
24
+
25
+ function normalizedDirection(x, z) {
26
+ const length = Math.hypot(Number(x) || 0, Number(z) || 0);
27
+ return length > EPSILON
28
+ ? { x: x / length, z: z / length }
29
+ : { x: 0, z: 1 };
30
+ }
31
+
32
+ /**
33
+ * Solves the finite-depth dispersion relation for a fixed incident period.
34
+ *
35
+ * buildGerstnerWaves authors omega from the deep-water k0 and then applies a
36
+ * user speed multiplier. Treating that multiplier as an effective gravity
37
+ * reduces the usual omega^2 = g*k*tanh(k*h) equation to:
38
+ *
39
+ * k * tanh(k*h) = k0
40
+ *
41
+ * This keeps the speed dial a uniform multiplier while retaining the correct
42
+ * deep- and shallow-water wavelength ratios.
43
+ */
44
+ export function solveFiniteDepthWaveNumber(deepWaveNumber, waterDepth, iterations = 6) {
45
+ const k0 = finitePositive(deepWaveNumber, 1);
46
+ if (waterDepth === Infinity) return k0;
47
+ const h = finitePositive(waterDepth, 1e-4);
48
+ let k = Math.max(k0, Math.sqrt(k0 / h));
49
+ const count = Math.max(1, Math.min(Math.trunc(iterations) || 6, 12));
50
+
51
+ for (let i = 0; i < count; i += 1) {
52
+ const kh = k * h;
53
+ const tanhKh = Math.tanh(kh);
54
+ const derivative = tanhKh + kh * (1 - tanhKh * tanhKh);
55
+ if (!(derivative > EPSILON)) break;
56
+ k -= (k * tanhKh - k0) / derivative;
57
+ k = Math.max(k, k0);
58
+ }
59
+ return k;
60
+ }
61
+
62
+ export function finiteDepthWaveNumberRatio(
63
+ deepWaveNumber,
64
+ waterDepth,
65
+ { minDepth = 0.05, maxRatio = 4 } = {},
66
+ ) {
67
+ const k0 = finitePositive(deepWaveNumber, 1);
68
+ const h = Math.max(Number(waterDepth) || 0, finitePositive(minDepth, 0.05));
69
+ const cap = Math.max(Number(maxRatio) || 1, 1);
70
+ return Math.min(solveFiniteDepthWaveNumber(k0, h) / k0, cap);
71
+ }
72
+
73
+ function assertGrid(restDepths, columns, rows) {
74
+ const nx = Math.trunc(columns);
75
+ const nz = Math.trunc(rows);
76
+ if (nx < 2 || nz < 2) {
77
+ throw new RangeError('Nearshore phase field requires at least a 2 x 2 grid.');
78
+ }
79
+ if (!restDepths || restDepths.length !== nx * nz) {
80
+ throw new RangeError(`Nearshore phase depth grid must contain ${nx * nz} samples.`);
81
+ }
82
+ return { nx, nz };
83
+ }
84
+
85
+ /**
86
+ * Builds a one-way upwind eikonal field on a regular world-space XZ grid. The
87
+ * incident edge retains the offshore plane-wave phase, and each successive
88
+ * slice solves the propagation component of |grad(q)| = k/k0 from the prior
89
+ * slice's transverse derivative. This is a mild-slope/ray approximation: it
90
+ * handles refraction but deliberately does not model diffraction or a ray
91
+ * turning back toward the incident boundary.
92
+ *
93
+ * Grid storage is row-major: index = zIndex * columns + xIndex.
94
+ */
95
+ export function buildNearshorePhaseField({
96
+ restDepths,
97
+ columns,
98
+ rows,
99
+ originX,
100
+ originZ,
101
+ stepX,
102
+ stepZ,
103
+ directionX,
104
+ directionZ,
105
+ deepWaveNumber,
106
+ incidentAxis = null,
107
+ minDepth = 0.05,
108
+ maxWavenumberRatio = 4,
109
+ } = {}) {
110
+ const { nx, nz } = assertGrid(restDepths, columns, rows);
111
+ const x0 = Number(originX) || 0;
112
+ const z0 = Number(originZ) || 0;
113
+ const dxGrid = finitePositive(stepX, 1);
114
+ const dzGrid = finitePositive(stepZ, 1);
115
+ const direction = normalizedDirection(directionX, directionZ);
116
+ const k0 = finitePositive(deepWaveNumber, 1);
117
+ const ratioCap = Math.max(Number(maxWavenumberRatio) || 1, 1);
118
+
119
+ const ratios = new Float32Array(nx * nz);
120
+ for (let index = 0; index < ratios.length; index += 1) {
121
+ ratios[index] = finiteDepthWaveNumberRatio(k0, restDepths[index], {
122
+ minDepth,
123
+ maxRatio: ratioCap,
124
+ });
125
+ }
126
+
127
+ // Keep the integration in Float64. On a 100m+ water body, Float32 phase
128
+ // accumulation otherwise leaves small grid-aligned velocity changes.
129
+ const phase64 = new Float64Array(nx * nz);
130
+ const basePhase = (x, z) => direction.x * x + direction.z * z;
131
+ const indexOf = (ix, iz) => iz * nx + ix;
132
+
133
+ // Pick one incident boundary. A raw plane-phase condition on *both* inflow
134
+ // edges is wrong when a side edge already crosses changing bathymetry: that
135
+ // edge would inject undelayed shallow phase into the interior. Prefer the
136
+ // deepest plausible incoming edge. This choice deliberately does not vary
137
+ // with incidence angle: an angle-weighted score moved the old 45-degree pop
138
+ // to a different threshold. Callers that know their offshore axis should
139
+ // pass incidentAxis ('x' or 'z') and keep it stable across direction edits.
140
+ const setBoundary = (ix, iz) => {
141
+ const index = indexOf(ix, iz);
142
+ phase64[index] = basePhase(x0 + ix * dxGrid, z0 + iz * dzGrid);
143
+ };
144
+ const candidates = [];
145
+ if (Math.abs(direction.x) > EPSILON) {
146
+ const ix = direction.x > 0 ? 0 : nx - 1;
147
+ let averageRatio = 0;
148
+ for (let iz = 0; iz < nz; iz += 1) averageRatio += ratios[indexOf(ix, iz)];
149
+ candidates.push({ axis: 'x', index: ix, score: averageRatio / nz });
150
+ }
151
+ if (Math.abs(direction.z) > EPSILON) {
152
+ const iz = direction.z > 0 ? 0 : nz - 1;
153
+ let averageRatio = 0;
154
+ for (let ix = 0; ix < nx; ix += 1) averageRatio += ratios[indexOf(ix, iz)];
155
+ candidates.push({ axis: 'z', index: iz, score: averageRatio / nx });
156
+ }
157
+ candidates.sort((a, b) => a.score - b.score ||
158
+ Math.abs(direction[b.axis]) - Math.abs(direction[a.axis]) ||
159
+ a.axis.localeCompare(b.axis));
160
+ const requestedAxis = incidentAxis === 'x' || incidentAxis === 'z' ? incidentAxis : null;
161
+ const incident = candidates.find((candidate) => candidate.axis === requestedAxis) ?? candidates[0];
162
+ const valid = new Uint8Array(nx * nz);
163
+ valid.fill(1);
164
+ let invalidCount = 0;
165
+ const invalidate = (index) => {
166
+ if (valid[index]) {
167
+ valid[index] = 0;
168
+ invalidCount += 1;
169
+ }
170
+ };
171
+
172
+ if (incident.axis === 'x') {
173
+ for (let iz = 0; iz < nz; iz += 1) setBoundary(incident.index, iz);
174
+ } else {
175
+ for (let ix = 0; ix < nx; ix += 1) setBoundary(ix, incident.index);
176
+ }
177
+
178
+ if (incident.axis === 'x') {
179
+ const stepSign = incident.index === 0 ? 1 : -1;
180
+ for (let offset = 1; offset < nx; offset += 1) {
181
+ const ix = incident.index + offset * stepSign;
182
+ const previousX = ix - stepSign;
183
+ for (let iz = 0; iz < nz; iz += 1) {
184
+ const zBefore = Math.max(iz - 1, 0);
185
+ const zAfter = Math.min(iz + 1, nz - 1);
186
+ const transverse = (
187
+ phase64[indexOf(previousX, zAfter)] - phase64[indexOf(previousX, zBefore)]
188
+ ) / Math.max((zAfter - zBefore) * dzGrid, EPSILON);
189
+ const previousIndex = indexOf(previousX, iz);
190
+ const index = indexOf(ix, iz);
191
+ if (!valid[previousIndex]) invalidate(index);
192
+ const previousAxisSquared = ratios[previousIndex] * ratios[previousIndex] -
193
+ transverse * transverse;
194
+ const currentAxisSquared = ratios[index] * ratios[index] - transverse * transverse;
195
+ if (previousAxisSquared < 0 || currentAxisSquared < 0) invalidate(index);
196
+ const previousAxis = stepSign * Math.sqrt(Math.max(previousAxisSquared, EPSILON));
197
+ const currentAxis = stepSign * Math.sqrt(Math.max(currentAxisSquared, EPSILON));
198
+ phase64[index] = phase64[previousIndex] +
199
+ 0.5 * (previousAxis + currentAxis) * stepSign * dxGrid;
200
+ }
201
+ }
202
+ } else {
203
+ const stepSign = incident.index === 0 ? 1 : -1;
204
+ for (let offset = 1; offset < nz; offset += 1) {
205
+ const iz = incident.index + offset * stepSign;
206
+ const previousZ = iz - stepSign;
207
+ for (let ix = 0; ix < nx; ix += 1) {
208
+ const xBefore = Math.max(ix - 1, 0);
209
+ const xAfter = Math.min(ix + 1, nx - 1);
210
+ const transverse = (
211
+ phase64[indexOf(xAfter, previousZ)] - phase64[indexOf(xBefore, previousZ)]
212
+ ) / Math.max((xAfter - xBefore) * dxGrid, EPSILON);
213
+ const previousIndex = indexOf(ix, previousZ);
214
+ const index = indexOf(ix, iz);
215
+ if (!valid[previousIndex]) invalidate(index);
216
+ const previousAxisSquared = ratios[previousIndex] * ratios[previousIndex] -
217
+ transverse * transverse;
218
+ const currentAxisSquared = ratios[index] * ratios[index] - transverse * transverse;
219
+ if (previousAxisSquared < 0 || currentAxisSquared < 0) invalidate(index);
220
+ const previousAxis = stepSign * Math.sqrt(Math.max(previousAxisSquared, EPSILON));
221
+ const currentAxis = stepSign * Math.sqrt(Math.max(currentAxisSquared, EPSILON));
222
+ phase64[index] = phase64[previousIndex] +
223
+ 0.5 * (previousAxis + currentAxis) * stepSign * dzGrid;
224
+ }
225
+ }
226
+ }
227
+
228
+ const phaseCoordinate = new Float32Array(phase64);
229
+ const waveVector = new Float32Array(nx * nz * 2);
230
+ for (let iz = 0; iz < nz; iz += 1) {
231
+ const zBefore = Math.max(iz - 1, 0);
232
+ const zAfter = Math.min(iz + 1, nz - 1);
233
+ const dz = Math.max((zAfter - zBefore) * dzGrid, EPSILON);
234
+ for (let ix = 0; ix < nx; ix += 1) {
235
+ const xBefore = Math.max(ix - 1, 0);
236
+ const xAfter = Math.min(ix + 1, nx - 1);
237
+ const dx = Math.max((xAfter - xBefore) * dxGrid, EPSILON);
238
+ const index = indexOf(ix, iz);
239
+ let gx = (phase64[indexOf(xAfter, iz)] - phase64[indexOf(xBefore, iz)]) / dx;
240
+ let gz = (phase64[indexOf(ix, zAfter)] - phase64[indexOf(ix, zBefore)]) / dz;
241
+ let magnitude = Math.hypot(gx, gz);
242
+ if (!(magnitude > EPSILON) || !Number.isFinite(magnitude)) {
243
+ invalidate(index);
244
+ magnitude = ratios[index];
245
+ gx = direction.x * magnitude;
246
+ gz = direction.z * magnitude;
247
+ } else if (gx * direction.x + gz * direction.z <= 0) {
248
+ // Preserve the actual phase gradient for analytic Gerstner normals;
249
+ // replacing it independently would make q and grad(q) disagree.
250
+ invalidate(index);
251
+ }
252
+ waveVector[index * 2] = gx;
253
+ waveVector[index * 2 + 1] = gz;
254
+ }
255
+ }
256
+
257
+ return {
258
+ columns: nx,
259
+ rows: nz,
260
+ originX: x0,
261
+ originZ: z0,
262
+ stepX: dxGrid,
263
+ stepZ: dzGrid,
264
+ directionX: direction.x,
265
+ directionZ: direction.z,
266
+ deepWaveNumber: k0,
267
+ incidentAxis: incident.axis,
268
+ invalidCount,
269
+ invalidFraction: invalidCount / (nx * nz),
270
+ phaseCoordinate,
271
+ validity: valid,
272
+ waveVector,
273
+ waveNumberRatio: ratios,
274
+ };
275
+ }
276
+
277
+ /** Bilinearly samples the baked field for the CPU water-height mirror. */
278
+ export function sampleNearshorePhaseField(field, x, z, out = {}) {
279
+ const nx = field?.columns ?? 0;
280
+ const nz = field?.rows ?? 0;
281
+ if (nx < 2 || nz < 2) {
282
+ const direction = normalizedDirection(field?.directionX ?? 1, field?.directionZ ?? 0);
283
+ const worldX = Number(x) || 0;
284
+ const worldZ = Number(z) || 0;
285
+ out.phaseCoordinate = direction.x * worldX + direction.z * worldZ;
286
+ out.waveVectorX = direction.x;
287
+ out.waveVectorZ = direction.z;
288
+ return out;
289
+ }
290
+ const worldX = Number(x) || 0;
291
+ const worldZ = Number(z) || 0;
292
+ const unclampedX = (worldX - field.originX) / field.stepX;
293
+ const unclampedZ = (worldZ - field.originZ) / field.stepZ;
294
+ const fx = Math.min(Math.max(unclampedX, 0), nx - 1);
295
+ const fz = Math.min(Math.max(unclampedZ, 0), nz - 1);
296
+ const ix = Math.min(Math.floor(fx), nx - 2);
297
+ const iz = Math.min(Math.floor(fz), nz - 2);
298
+ const tx = fx - ix;
299
+ const tz = fz - iz;
300
+ const indices = [iz * nx + ix, iz * nx + ix + 1, (iz + 1) * nx + ix, (iz + 1) * nx + ix + 1];
301
+ const weights = [(1 - tx) * (1 - tz), tx * (1 - tz), (1 - tx) * tz, tx * tz];
302
+ let phase = 0;
303
+ let vectorX = 0;
304
+ let vectorZ = 0;
305
+ for (let i = 0; i < 4; i += 1) {
306
+ phase += field.phaseCoordinate[indices[i]] * weights[i];
307
+ vectorX += field.waveVector[indices[i] * 2] * weights[i];
308
+ vectorZ += field.waveVector[indices[i] * 2 + 1] * weights[i];
309
+ }
310
+ const clampedWorldX = field.originX + fx * field.stepX;
311
+ const clampedWorldZ = field.originZ + fz * field.stepZ;
312
+ // CPU callers can query just outside the finite mesh (wide splash rings,
313
+ // interaction margins). Continue the edge phase linearly instead of
314
+ // clamping q to a spatially constant value and stopping the wave there.
315
+ out.phaseCoordinate = phase + vectorX * (worldX - clampedWorldX) +
316
+ vectorZ * (worldZ - clampedWorldZ);
317
+ out.waveVectorX = vectorX;
318
+ out.waveVectorZ = vectorZ;
319
+ return out;
320
+ }
@@ -9,6 +9,7 @@ import { createPassDepthColorMaterial } from '../shaders-tsl/chunks/pass-depth-c
9
9
  //
10
10
  // Exclusion flags on object.userData:
11
11
  // waterExclude — hidden from both passes
12
+ // waterGrabExclude — hidden from the above-water grab only
12
13
  // waterReflectionExclude — hidden from the reflection pass only
13
14
  // skipWaterReflection — legacy alias of waterReflectionExclude
14
15
  //
@@ -70,10 +71,12 @@ export class WaterScenePasses {
70
71
 
71
72
  this.grabTarget = null;
72
73
  this.reflectionTarget = null;
74
+ this.transmissionCamera = new THREE.PerspectiveCamera();
73
75
  this.reflectionCamera = new THREE.PerspectiveCamera();
74
76
  this.reflectionMatrix = new THREE.Matrix4();
75
77
  this.reflectionValid = false;
76
78
  this.grabValid = false;
79
+ this.depthValid = false;
77
80
 
78
81
  // Scene-depth pass state for the TSL renderer path.
79
82
  this.depthTarget = null;
@@ -89,6 +92,7 @@ export class WaterScenePasses {
89
92
  drawingBufferSize: new THREE.Vector2(),
90
93
  cameraPosition: new THREE.Vector3(),
91
94
  cameraDirection: new THREE.Vector3(),
95
+ cameraQuaternion: new THREE.Quaternion(),
92
96
  target: new THREE.Vector3(),
93
97
  plane: new THREE.Plane(),
94
98
  clipPlane: new THREE.Vector4(),
@@ -265,11 +269,25 @@ export class WaterScenePasses {
265
269
 
266
270
  renderGrabPass(renderer, scene, camera, waterMesh) {
267
271
  this.ensureGrabTarget(renderer);
272
+ this.scratch.waterWorldPosition.setFromMatrixPosition(waterMesh.matrixWorld);
273
+ camera.getWorldPosition(this.scratch.cameraPosition);
274
+ const cameraBelow = this.scratch.cameraPosition.y < this.scratch.waterWorldPosition.y;
275
+ // Above water the grab sees the submerged scene. Below water it becomes a
276
+ // same-pose transmission camera clipped to the air side of the surface,
277
+ // so clouds and above-water objects are genuinely visible through it.
278
+ const grabCamera = cameraBelow
279
+ ? this.updateTransmissionCamera(
280
+ renderer,
281
+ camera,
282
+ this.scratch.waterWorldPosition.y,
283
+ )
284
+ : camera;
268
285
  // waterGrabExclude skips the refraction grab/depth renders only — for
269
286
  // above-water set dressing (cliffs, large rocks) whose reflection
270
- // matters but whose refracted contribution is invisible.
287
+ // matters but whose refracted contribution is invisible. From below,
288
+ // that set dressing belongs in the transmitted above-water view.
271
289
  const restoreFlagged = hideFlagged(scene, (object) => object.userData?.waterExclude
272
- || object.userData?.waterGrabExclude);
290
+ || (!cameraBelow && object.userData?.waterGrabExclude));
273
291
  const waterWasVisible = waterMesh.visible;
274
292
  waterMesh.visible = false;
275
293
  const previousTarget = renderer.getRenderTarget();
@@ -280,8 +298,13 @@ export class WaterScenePasses {
280
298
  renderer.shadowMap.autoUpdate = false;
281
299
  renderer.setRenderTarget(this.grabTarget);
282
300
  renderer.clear();
283
- renderer.render(scene, camera);
284
- this.renderDepthPass(renderer, scene, camera);
301
+ renderer.render(scene, grabCamera);
302
+ if (cameraBelow) {
303
+ this.depthValid = false;
304
+ } else {
305
+ this.renderDepthPass(renderer, scene, grabCamera);
306
+ this.depthValid = true;
307
+ }
285
308
  this.grabValid = true;
286
309
  } finally {
287
310
  renderer.setRenderTarget(previousTarget);
@@ -292,6 +315,52 @@ export class WaterScenePasses {
292
315
  }
293
316
  }
294
317
 
318
+ applyWaterClipPlane(renderer, camera, waterY) {
319
+ // Oblique near-plane clipping (Lengyel), matching THREE.Reflector's
320
+ // coordinate-system-aware construction. The positive side of this plane
321
+ // is the air volume, even when the virtual camera itself is underwater.
322
+ const { plane, clipPlane, q } = this.scratch;
323
+ plane.normal.set(0, 1, 0);
324
+ plane.constant = -waterY;
325
+ plane.applyMatrix4(camera.matrixWorldInverse);
326
+ clipPlane.set(plane.normal.x, plane.normal.y, plane.normal.z, plane.constant);
327
+
328
+ const projectionMatrix = camera.projectionMatrix;
329
+ q.x = (Math.sign(clipPlane.x) + projectionMatrix.elements[8]) / projectionMatrix.elements[0];
330
+ q.y = (Math.sign(clipPlane.y) + projectionMatrix.elements[9]) / projectionMatrix.elements[5];
331
+ q.z = -1.0;
332
+ q.w = (1.0 + projectionMatrix.elements[10]) / projectionMatrix.elements[14];
333
+ clipPlane.multiplyScalar(1.0 / clipPlane.dot(q));
334
+ projectionMatrix.elements[2] = clipPlane.x;
335
+ projectionMatrix.elements[6] = clipPlane.y;
336
+ projectionMatrix.elements[10] = renderer.coordinateSystem === THREE.WebGPUCoordinateSystem
337
+ ? clipPlane.z - this.clipBias
338
+ : clipPlane.z + 1.0 - this.clipBias;
339
+ projectionMatrix.elements[14] = clipPlane.w;
340
+ camera.projectionMatrixInverse.copy(projectionMatrix).invert();
341
+ }
342
+
343
+ updateTransmissionCamera(renderer, camera, waterY) {
344
+ const { cameraPosition, cameraQuaternion } = this.scratch;
345
+ camera.getWorldPosition(cameraPosition);
346
+ camera.getWorldQuaternion(cameraQuaternion);
347
+
348
+ const transmissionCamera = this.transmissionCamera;
349
+ transmissionCamera.coordinateSystem = renderer.coordinateSystem;
350
+ transmissionCamera.position.copy(cameraPosition);
351
+ transmissionCamera.quaternion.copy(cameraQuaternion);
352
+ transmissionCamera.scale.set(1, 1, 1);
353
+ transmissionCamera.near = camera.near;
354
+ transmissionCamera.far = camera.far;
355
+ transmissionCamera.layers.mask = camera.layers.mask;
356
+ transmissionCamera.projectionMatrix.copy(camera.projectionMatrix);
357
+ transmissionCamera.projectionMatrixInverse.copy(camera.projectionMatrixInverse);
358
+ transmissionCamera.updateMatrixWorld();
359
+ transmissionCamera.matrixWorldInverse.copy(transmissionCamera.matrixWorld).invert();
360
+ this.applyWaterClipPlane(renderer, transmissionCamera, waterY);
361
+ return transmissionCamera;
362
+ }
363
+
295
364
  updateReflectionCamera(renderer, camera, waterY) {
296
365
  const { cameraPosition, cameraDirection, target } = this.scratch;
297
366
  camera.getWorldPosition(cameraPosition);
@@ -315,29 +384,7 @@ export class WaterScenePasses {
315
384
  reflectionCamera.updateMatrixWorld();
316
385
  reflectionCamera.matrixWorldInverse.copy(reflectionCamera.matrixWorld).invert();
317
386
 
318
- // Oblique near-plane clipping (Lengyel), same construction as
319
- // THREE.Reflector: geometry below the water plane never leaks into the
320
- // mirrored image. The node backends use ReflectorNode's coordinate-
321
- // system-aware variant of the same construction.
322
- const { plane, clipPlane, q } = this.scratch;
323
- plane.setFromNormalAndCoplanarPoint(
324
- new THREE.Vector3(0, 1, 0),
325
- new THREE.Vector3(0, waterY, 0));
326
- plane.applyMatrix4(reflectionCamera.matrixWorldInverse);
327
- clipPlane.set(plane.normal.x, plane.normal.y, plane.normal.z, plane.constant);
328
-
329
- const projectionMatrix = reflectionCamera.projectionMatrix;
330
- q.x = (Math.sign(clipPlane.x) + projectionMatrix.elements[8]) / projectionMatrix.elements[0];
331
- q.y = (Math.sign(clipPlane.y) + projectionMatrix.elements[9]) / projectionMatrix.elements[5];
332
- q.z = -1.0;
333
- q.w = (1.0 + projectionMatrix.elements[10]) / projectionMatrix.elements[14];
334
- clipPlane.multiplyScalar(1.0 / clipPlane.dot(q));
335
- projectionMatrix.elements[2] = clipPlane.x;
336
- projectionMatrix.elements[6] = clipPlane.y;
337
- projectionMatrix.elements[10] = renderer.coordinateSystem === THREE.WebGPUCoordinateSystem
338
- ? clipPlane.z - this.clipBias
339
- : clipPlane.z + 1.0 - this.clipBias;
340
- projectionMatrix.elements[14] = clipPlane.w;
387
+ this.applyWaterClipPlane(renderer, reflectionCamera, waterY);
341
388
 
342
389
  this.reflectionMatrix
343
390
  .copy(BIAS_MATRIX)
@@ -382,7 +429,10 @@ export class WaterScenePasses {
382
429
 
383
430
  render(renderer, scene, camera, waterMesh) {
384
431
  if (this.sceneColorEnabled) this.renderGrabPass(renderer, scene, camera, waterMesh);
385
- else this.grabValid = false;
432
+ else {
433
+ this.grabValid = false;
434
+ this.depthValid = false;
435
+ }
386
436
  if (this.reflectionEnabled) this.renderReflectionPass(renderer, scene, camera, waterMesh);
387
437
  else this.reflectionValid = false;
388
438
  }
@@ -392,11 +442,14 @@ export class WaterScenePasses {
392
442
  if (!uniforms) return;
393
443
  if (this.grabValid && this.grabTarget) {
394
444
  uniforms.uSceneColor.value = this.grabTarget.texture;
395
- uniforms.uSceneDepth.value = this.depthTarget.texture;
396
445
  uniforms.uUseSceneColor.value = 1;
397
- uniforms.uUseSceneDepth.value = 1;
398
446
  } else {
399
447
  uniforms.uUseSceneColor.value = 0;
448
+ }
449
+ if (this.depthValid && this.depthTarget) {
450
+ uniforms.uSceneDepth.value = this.depthTarget.texture;
451
+ uniforms.uUseSceneDepth.value = 1;
452
+ } else {
400
453
  uniforms.uUseSceneDepth.value = 0;
401
454
  }
402
455
  if (this.reflectionValid && this.reflectionTarget) {