@zephyr3d/scene 0.9.26 → 0.9.27

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 (95) hide show
  1. package/dist/animation/animationset.js +92 -30
  2. package/dist/animation/animationset.js.map +1 -1
  3. package/dist/animation/joint_dynamics/convex_collider.js +320 -0
  4. package/dist/animation/joint_dynamics/convex_collider.js.map +1 -0
  5. package/dist/animation/skeleton.js +21 -1
  6. package/dist/animation/skeleton.js.map +1 -1
  7. package/dist/asset/model.js +6 -2
  8. package/dist/asset/model.js.map +1 -1
  9. package/dist/camera/camera.js +75 -23
  10. package/dist/camera/camera.js.map +1 -1
  11. package/dist/index.d.ts +4764 -3600
  12. package/dist/index.js +2 -1
  13. package/dist/index.js.map +1 -1
  14. package/dist/material/hairstrand.js +235 -235
  15. package/dist/material/hairstrand.js.map +1 -1
  16. package/dist/material/material.js +296 -296
  17. package/dist/material/material.js.map +1 -1
  18. package/dist/material/mixins/lit.js +33 -1
  19. package/dist/material/mixins/lit.js.map +1 -1
  20. package/dist/material/shader/helper.js +404 -3
  21. package/dist/material/shader/helper.js.map +1 -1
  22. package/dist/material/water.js +2057 -72
  23. package/dist/material/water.js.map +1 -1
  24. package/dist/posteffect/motionblur.js +663 -58
  25. package/dist/posteffect/motionblur.js.map +1 -1
  26. package/dist/posteffect/posteffect.js +12 -1
  27. package/dist/posteffect/posteffect.js.map +1 -1
  28. package/dist/posteffect/sao.js +46 -44
  29. package/dist/posteffect/sao.js.map +1 -1
  30. package/dist/posteffect/sss.js +30 -2
  31. package/dist/posteffect/sss.js.map +1 -1
  32. package/dist/render/abuffer_oit.js +37 -37
  33. package/dist/render/abuffer_oit.js.map +1 -1
  34. package/dist/render/clipmap.js +156 -5
  35. package/dist/render/clipmap.js.map +1 -1
  36. package/dist/render/cull_visitor.js +1 -0
  37. package/dist/render/cull_visitor.js.map +1 -1
  38. package/dist/render/dualdepthpeeling_oit.js +49 -49
  39. package/dist/render/dualdepthpeeling_oit.js.map +1 -1
  40. package/dist/render/fbm_wavegenerator.js +3 -3
  41. package/dist/render/fbm_wavegenerator.js.map +1 -1
  42. package/dist/render/fft_wavegenerator.js +57 -15
  43. package/dist/render/fft_wavegenerator.js.map +1 -1
  44. package/dist/render/gerstner_wavegenerator.js +127 -20
  45. package/dist/render/gerstner_wavegenerator.js.map +1 -1
  46. package/dist/render/hzb.js +80 -22
  47. package/dist/render/hzb.js.map +1 -1
  48. package/dist/render/lightpass.js +6 -3
  49. package/dist/render/lightpass.js.map +1 -1
  50. package/dist/render/oit.js +11 -11
  51. package/dist/render/oit.js.map +1 -1
  52. package/dist/render/render_queue.js +36 -0
  53. package/dist/render/render_queue.js.map +1 -1
  54. package/dist/render/renderer.js +4 -0
  55. package/dist/render/renderer.js.map +1 -1
  56. package/dist/render/rendergraph/blackboard.js +1 -0
  57. package/dist/render/rendergraph/blackboard.js.map +1 -1
  58. package/dist/render/rendergraph/forward_plus_builder.js +318 -9
  59. package/dist/render/rendergraph/forward_plus_builder.js.map +1 -1
  60. package/dist/render/rendergraph/frame_resource_requirements.js +1 -0
  61. package/dist/render/rendergraph/frame_resource_requirements.js.map +1 -1
  62. package/dist/render/rendergraph/history_resources.js +8 -1
  63. package/dist/render/rendergraph/history_resources.js.map +1 -1
  64. package/dist/render/underwater.js +624 -0
  65. package/dist/render/underwater.js.map +1 -0
  66. package/dist/render/water_caustics.js +1380 -0
  67. package/dist/render/water_caustics.js.map +1 -0
  68. package/dist/render/weightedblended_oit.js +33 -33
  69. package/dist/render/weightedblended_oit.js.map +1 -1
  70. package/dist/scene/water.js +540 -4
  71. package/dist/scene/water.js.map +1 -1
  72. package/dist/shaders/water.js +22 -19
  73. package/dist/shaders/water.js.map +1 -1
  74. package/dist/shaders/water_medium.js +56 -0
  75. package/dist/shaders/water_medium.js.map +1 -0
  76. package/dist/shadow/shadow_region.js +49 -49
  77. package/dist/shadow/shadow_region.js.map +1 -1
  78. package/dist/utility/serialization/manager.js +3 -1
  79. package/dist/utility/serialization/manager.js.map +1 -1
  80. package/dist/utility/serialization/scene/animation.js +31 -2
  81. package/dist/utility/serialization/scene/animation.js.map +1 -1
  82. package/dist/utility/serialization/scene/camera.js +53 -25
  83. package/dist/utility/serialization/scene/camera.js.map +1 -1
  84. package/dist/utility/serialization/scene/light.js.map +1 -1
  85. package/dist/utility/serialization/scene/water.js +1101 -21
  86. package/dist/utility/serialization/scene/water.js.map +1 -1
  87. package/dist/utility/shprojector.js +87 -87
  88. package/dist/utility/shprojector.js.map +1 -1
  89. package/package.json +1 -1
  90. package/dist/render/rendergraph/frame_graph_context.js +0 -41
  91. package/dist/render/rendergraph/frame_graph_context.js.map +0 -1
  92. package/dist/shaders/ssgi.js +0 -63
  93. package/dist/shaders/ssgi.js.map +0 -1
  94. package/dist/shaders/ssgi_reprojection.js +0 -118
  95. package/dist/shaders/ssgi_reprojection.js.map +0 -1
@@ -0,0 +1,1380 @@
1
+ import { Vector4, Vector3, Matrix4x4, REVERSE_Z } from '@zephyr3d/base';
2
+ import { drawFullscreenQuad } from './fullscreenquad.js';
3
+ import { fetchSampler } from '../utility/misc.js';
4
+ import { MAX_CAUSTIC_WATERS, ShaderHelper, CAUSTIC_HEIGHT_BIAS } from '../material/shader/helper.js';
5
+
6
+ /** Index of refraction of water relative to air. */ const WATER_IOR = 1.333;
7
+ /** Air-to-water eta used when refracting the sun ray at the surface. */ const WATER_ETA = 1 / WATER_IOR;
8
+ /**
9
+ * Minimum |sin(sun elevation)| for caustics to be generated. The photon sweep
10
+ * degenerates below this, and real caustics vanish there too, so the pass
11
+ * simply switches off.
12
+ */ const MIN_SUN_ELEVATION = 0.15;
13
+ /**
14
+ * Fixed-point steps taken to land a photon on the scene rather than on the
15
+ * focal plane. Two suffice for a receiver flat over the distance the guess
16
+ * moves; the third covers a steep slope. Beyond that the sequence has either
17
+ * converged or is oscillating between two surfaces.
18
+ */ const SCENE_DEPTH_ITERATIONS = 3;
19
+ /**
20
+ * Photons per covered map texel the automatic grid size solves for.
21
+ *
22
+ * Measured against a converged (16x denser) map: four lands within about 4% of
23
+ * it, for a quarter of the photons full convergence would need.
24
+ */ const PHOTONS_PER_TEXEL = 4;
25
+ /** Automatic edge fade width, as a fraction of the map half-extent. */ const AUTO_FADE_FRACTION = 0.2;
26
+ /** Floor in meters under the automatic fade width. */ const AUTO_FADE_MIN_DISTANCE = 3;
27
+ /** Bounds on the share of the map half-extent the fade may consume. */ const MIN_FADE_FRACTION = 0.001;
28
+ const MAX_FADE_FRACTION = 0.9;
29
+ /**
30
+ * Largest ratio between the fitted slice's two half-extents. The map is square,
31
+ * so an anisotropic slice resolves the pattern at different frequencies along
32
+ * each axis.
33
+ */ const MAX_SLICE_ASPECT = 2;
34
+ /**
35
+ * Steps per octave the fitted half-extents are quantised to. Four costs at most
36
+ * 19% of linear resolution against a perfect fit, and keeps a level change small
37
+ * enough to pass as a slight change in sharpness rather than a pop.
38
+ */ const SLICE_QUANTISE_STEPS = 4;
39
+ /** Smallest half-extent in meters a fitted slice may shrink to. */ const MIN_SLICE_EXTENT = 0.5;
40
+ /**
41
+ * Divisions of the half-extent the slice centre snaps to under a warp.
42
+ *
43
+ * A whole-texel shift is exact only on a linear map; under a warp it is a
44
+ * different number of texels at each position, so the map has to be resampled
45
+ * whenever the centre moves. Snapping coarsely holds the centre still for many
46
+ * frames and pays for one resample, which the resolve's neighbourhood clamp
47
+ * absorbs.
48
+ */ const WARPED_CENTER_SNAP_DIVISIONS = 8;
49
+ /**
50
+ * How far past the map border the photon grid is laid out, as a fraction of the
51
+ * half-extent, on the sides where the range - not the water - is what bounds it.
52
+ *
53
+ * Horizontal displacement carries a photon across the border in either
54
+ * direction, so a grid cut exactly at the border loses the photons that would
55
+ * have arrived from outside while still losing the ones that leave; the border
56
+ * ring then holds a density no interior texel does. Launching from a margin
57
+ * outside feeds the border from both sides.
58
+ *
59
+ * Only the launch area grows - `_gridFraction` scales the per-photon weight and
60
+ * the solved grid size by it - so density per texel is unchanged and the cost is
61
+ * photon count. At the default warp this margin buys about 11% more photons.
62
+ */ const GRID_BORDER_MARGIN = 0.15;
63
+ /**
64
+ * Concentrates a map coordinate towards the centre. CPU twin of
65
+ * `ShaderHelper.warpCausticNDC`; see it for the reasoning.
66
+ *
67
+ * @internal
68
+ */ function warpCausticNDC(ndc, strength) {
69
+ return ndc * (1 + strength) / (1 + strength * Math.abs(ndc));
70
+ }
71
+ /**
72
+ * Rounds a fitted half-extent up onto a discrete ladder below `range`.
73
+ *
74
+ * The ladder is geometric and anchored at `range`, so water that fills the range
75
+ * lands exactly on a level.
76
+ *
77
+ * @internal
78
+ */ function quantiseSliceExtent(half, range) {
79
+ if (!(half > 0) || half >= range) {
80
+ return range;
81
+ }
82
+ const steps = Math.floor(Math.log2(range / half) * SLICE_QUANTISE_STEPS);
83
+ return range / Math.pow(2, steps / SLICE_QUANTISE_STEPS);
84
+ }
85
+ /**
86
+ * Vertices per side of the grid the height map rasterises the surface with.
87
+ *
88
+ * Laid out in warped map space over the body's footprint, so each cell covers a
89
+ * few map texels at most and the linear height across a cell is well inside what
90
+ * a wave of any visible wavelength needs.
91
+ */ const HEIGHT_GRID_SIZE = 256;
92
+ /**
93
+ * Shader for the photon splat pass.
94
+ *
95
+ * Everything happens in the vertex stage: one vertex is one photon, and the
96
+ * position it emits is where that photon lands. The fragment stage only deposits
97
+ * the weight, which additive blending accumulates.
98
+ *
99
+ * Returned as a descriptor rather than a built program so it can be compiled
100
+ * against a bare {@link ProgramBuilder} in tests, without a device.
101
+ *
102
+ * @param waveGenerator - Supplies the surface displacement and normal.
103
+ * @internal
104
+ */ function createCausticSplatShader(waveGenerator, sceneDepth) {
105
+ const setupUniforms = (scope)=>{
106
+ const pb = scope.$builder;
107
+ scope.causticFrameX = pb.vec4().uniform(0);
108
+ scope.causticFrameY = pb.vec4().uniform(0);
109
+ scope.causticCenter = pb.vec4().uniform(0);
110
+ scope.causticLightDir = pb.vec4().uniform(0);
111
+ scope.causticRegion = pb.vec4().uniform(0);
112
+ /** Map-NDC rectangle the photon grid is laid out over: (minX, minY, maxX, maxY). */ scope.causticGridBounds = pb.vec4().uniform(0);
113
+ scope.causticSplatParams = pb.vec4().uniform(0);
114
+ /** (grid size, 1 / grid size, 0, 0) */ scope.causticPhotonParams = pb.vec4().uniform(0);
115
+ if (sceneDepth) {
116
+ // The sun's own shadow cascade, reused as a light-space depth map of the
117
+ // scene: already orthographic, along the light, covering what the light
118
+ // reaches, and costing no extra geometry pass.
119
+ scope.causticSceneMatrix = pb.mat4().uniform(0);
120
+ scope.causticSceneMatrixInv = pb.mat4().uniform(0);
121
+ /** (cascade layer, 0, 0, 0) */ scope.causticSceneParams = pb.vec4().uniform(0);
122
+ // Sampled for its stored depth, not compared against a reference, so this
123
+ // takes an ordinary sampler rather than the comparison one the lighting
124
+ // pass binds to the same texture.
125
+ scope.causticSceneDepth = pb.tex2DArrayShadow().uniform(0);
126
+ }
127
+ // FBM and Gerstner animate from camera.elapsedTime, which this pass has to
128
+ // supply itself - it is not one of the engine's camera passes.
129
+ ShaderHelper.declareStandaloneCameraTime(scope, 0);
130
+ waveGenerator.setupUniforms(scope, 0);
131
+ };
132
+ return {
133
+ vertex (pb) {
134
+ // Photon ordinal, one vertex per photon. The grid coordinate is derived
135
+ // from it here rather than stored, so the buffer outlives any one grid
136
+ // size - see _updatePhotonLayout.
137
+ this.$inputs.photonIndex = pb.float().attrib('position');
138
+ this.$outputs.photonWeight = pb.float();
139
+ setupUniforms(this);
140
+ pb.main(function() {
141
+ // Per-axis: the slice is fitted to the water, so its two half-extents
142
+ // are independent and the map is only square in texels.
143
+ this.$l.invRadius = pb.vec2(this.causticFrameX.w, this.causticFrameY.w);
144
+ this.$l.radius = pb.div(pb.vec2(1), this.invRadius);
145
+ this.$l.focalDepth = this.causticSplatParams.y;
146
+ this.$l.eta = this.causticSplatParams.z;
147
+ this.$l.L = this.causticLightDir.xyz;
148
+ this.$l.waterLevel = this.causticCenter.w;
149
+ // Grid position on the orthographic slice through the map centre.
150
+ //
151
+ // The grid spans only the part of the slice the water can actually cast
152
+ // through, not the whole map: spread over the whole map, a pool covering
153
+ // a tenth of it would leave most of the lit area with no photon at all,
154
+ // and a caustic value of zero puts the sun out.
155
+ //
156
+ // Laid out in warped map space and unwarped to reach the plane, so the
157
+ // grid is uniform in texels rather than in meters. That is what keeps
158
+ // photon (i,j) on texel (i,j) for calm water under any warp strength,
159
+ // and with it the map's normalisation to 1.0.
160
+ //
161
+ // Ordinal to grid coordinate: row is the quotient, column the remainder.
162
+ this.$l.gridSize = this.causticPhotonParams.x;
163
+ this.$l.invGrid = this.causticPhotonParams.y;
164
+ this.$l.photonRow = pb.floor(pb.mul(this.$inputs.photonIndex, this.invGrid));
165
+ this.$l.photonCol = pb.sub(this.$inputs.photonIndex, pb.mul(this.photonRow, this.gridSize));
166
+ this.$l.photonUV = pb.mul(pb.add(pb.vec2(this.photonCol, this.photonRow), pb.vec2(0.5)), this.invGrid);
167
+ this.$l.warpedNDC = pb.mix(this.causticGridBounds.xy, this.causticGridBounds.zw, this.photonUV);
168
+ this.$l.ndc = ShaderHelper.unwarpCausticNDC(this, this.warpedNDC, this.causticSplatParams.x);
169
+ this.$l.planePos = pb.add(this.causticCenter.xyz, pb.mul(this.causticFrameX.xyz, pb.mul(this.ndc.x, this.radius.x)), pb.mul(this.causticFrameY.xyz, pb.mul(this.ndc.y, this.radius.y)));
170
+ // Sweep along the light direction onto the undisturbed water plane.
171
+ this.$l.sweep = pb.div(pb.sub(this.waterLevel, this.planePos.y), this.L.y);
172
+ this.$l.surfaceXZ = pb.add(this.planePos, pb.mul(this.L, this.sweep));
173
+ this.$l.outside = pb.or(pb.any(pb.lessThan(this.surfaceXZ.xz, this.causticRegion.xy)), pb.any(pb.greaterThan(this.surfaceXZ.xz, this.causticRegion.zw)));
174
+ // Displaced surface point and the detail normal that bends the ray. Both
175
+ // wave generators sample with an explicit LOD, which is what makes this
176
+ // legal in a vertex shader.
177
+ //
178
+ // The photon is launched from the rest point and lands wherever the
179
+ // surface carried it, so the refraction is right. What it is not is
180
+ // uniform in world xz - the horizontal displacement bunches the launch
181
+ // points by the surface Jacobian, over-weighting compressed crests by
182
+ // 1/J. Inverting the displacement per photon would fix that, but the
183
+ // inversion only converges where the surface does not fold, and a choppy
184
+ // FFT folds exactly at the crests it would matter for.
185
+ this.$l.surfacePos = pb.vec3();
186
+ this.$l.coarseNormal = pb.vec3();
187
+ waveGenerator.calcVertexPositionAndNormal(this, this.surfaceXZ, this.surfacePos, this.coarseNormal);
188
+ this.$l.normal = waveGenerator.calcFragmentNormal(this, this.surfaceXZ.xz, this.coarseNormal);
189
+ // Refract, then subtract the deflection a flat surface would have
190
+ // produced. Calm water then leaves the ray parallel to L, which lands
191
+ // photon (i,j) back on texel (i,j) and normalises the map to 1.
192
+ this.$l.refracted = pb.refract(this.L, this.normal, this.eta);
193
+ this.$l.refractedFlat = pb.refract(this.L, pb.vec3(0, 1, 0), this.eta);
194
+ this.$l.dir = pb.normalize(pb.add(pb.sub(this.refracted, this.refractedFlat), this.L));
195
+ // Intersect the horizontal focal plane below the surface. The ray always
196
+ // travels downwards here, but clamp anyway so a grazing ray cannot
197
+ // produce an enormous or negative step.
198
+ this.$l.invDirY = pb.min(this.dir.y, -1e-4);
199
+ this.$l.targetY = pb.sub(this.waterLevel, this.focalDepth);
200
+ this.$l.hitT = pb.div(pb.sub(this.targetY, this.surfacePos.y), this.invDirY);
201
+ this.$l.hitPos = pb.add(this.surfacePos, pb.mul(this.dir, pb.max(this.hitT, 0)));
202
+ if (sceneDepth) {
203
+ // Land the photon on the scene rather than on a plane through it.
204
+ //
205
+ // Fixed-point iteration, not a ray march: read the height of the scene
206
+ // under the current guess, re-intersect the ray with a horizontal
207
+ // plane at that height, repeat. Each step is one texture fetch and the
208
+ // sequence converges in two or three for any receiver that is smooth
209
+ // over the distance the guess moves. A march would need an order of
210
+ // magnitude more fetches, which the photon count cannot afford - at
211
+ // the default density a full-coverage 1024 map launches four million.
212
+ //
213
+ // Where the assumption fails - a silhouette, an overhang - the
214
+ // iteration lands on one of the two surfaces rather than diverging,
215
+ // because every step is still a valid intersection of the ray with
216
+ // some horizontal plane.
217
+ this.$l.probe = this.hitPos;
218
+ this.$l.layer = pb.int(this.causticSceneParams.x);
219
+ for(let i = 0; i < SCENE_DEPTH_ITERATIONS; i++){
220
+ this.$l[`clip${i}`] = pb.mul(this.causticSceneMatrix, pb.vec4(this.probe, 1));
221
+ this.$l[`ndc${i}`] = pb.div(this[`clip${i}`].xyz, this[`clip${i}`].w);
222
+ this.$l[`uv${i}`] = pb.add(pb.mul(this[`ndc${i}`].xy, 0.5), pb.vec2(0.5));
223
+ // Outside the cascade there is nothing to intersect against; keep
224
+ // whatever the previous step produced, which for the first step is
225
+ // the focal plane hit the flat path would have used.
226
+ this.$l[`inside${i}`] = pb.and(pb.all(pb.greaterThanEqual(this[`uv${i}`], pb.vec2(0))), pb.all(pb.lessThanEqual(this[`uv${i}`], pb.vec2(1))));
227
+ // texelFetch rather than a filtered sample: the value is a depth,
228
+ // and averaging two depths across a silhouette names a surface that
229
+ // is not there. WebGPU only - GLSL ES 3.0 forbids texelFetch on a
230
+ // shadow-sampler texture, which is what gates this whole path.
231
+ this.$l[`depth${i}`] = pb.textureArrayLoad(this.causticSceneDepth, pb.ivec2(pb.mul(this[`uv${i}`], this.causticSceneParams.y)), this.layer, pb.int(0));
232
+ // Back to a world position. The cascade is orthographic, so the
233
+ // stored depth and the sample's uv are enough to place the point.
234
+ this.$l[`sceneNDC${i}`] = pb.vec3(this[`ndc${i}`].xy, REVERSE_Z ? this[`depth${i}`] : pb.sub(pb.mul(this[`depth${i}`], 2), 1));
235
+ this.$l[`sceneClip${i}`] = pb.mul(this.causticSceneMatrixInv, pb.vec4(this[`sceneNDC${i}`], 1));
236
+ this.$l[`scenePos${i}`] = pb.div(this[`sceneClip${i}`].xyz, this[`sceneClip${i}`].w);
237
+ // Re-intersect at the height just read. Only accept it while it is
238
+ // below the *displaced* surface at this photon's entry: a scene
239
+ // point above that is not something the photon passed through. The
240
+ // rest level is the wrong bar - it would drop every photon landing
241
+ // on a receiver that pokes above the rest plane under a passing
242
+ // crest, cutting the caustics off along a flat line.
243
+ this.$l[`t${i}`] = pb.div(pb.sub(this[`scenePos${i}`].y, this.surfacePos.y), this.invDirY);
244
+ this.$l[`next${i}`] = pb.add(this.surfacePos, pb.mul(this.dir, pb.max(this[`t${i}`], 0)));
245
+ // mix rather than select: the builder has no vec3 select.
246
+ this.probe = pb.mix(this.probe, this[`next${i}`], pb.float(pb.and(this[`inside${i}`], pb.lessThan(this[`scenePos${i}`].y, this.surfacePos.y))));
247
+ }
248
+ this.hitPos = this.probe;
249
+ }
250
+ // Project the hit back into the map. frameX/frameY are perpendicular to
251
+ // L and to each other, so this is just two dot products.
252
+ this.$l.rel = pb.sub(this.hitPos, this.causticCenter.xyz);
253
+ this.$l.hitNDC = pb.mul(pb.vec2(pb.dot(this.rel, this.causticFrameX.xyz), pb.dot(this.rel, this.causticFrameY.xyz)), this.invRadius);
254
+ // Into warped space, where the map's texels live.
255
+ this.$l.hitWarped = ShaderHelper.warpCausticNDC(this, this.hitNDC, this.causticSplatParams.x);
256
+ this.$outputs.photonWeight = this.causticSplatParams.w;
257
+ // No per-backend y flip. A fragment written at clip y lands on the same
258
+ // texture row on both backends, so the map the receiver samples with
259
+ // `ndc * 0.5 + 0.5` is the one this writes at clip `ndc`.
260
+ this.$builtins.position = pb.vec4(this.hitWarped, 0, 1);
261
+ if (pb.getDevice().type !== 'webgpu') {
262
+ // GLSL leaves gl_PointSize undefined unless it is written, and an
263
+ // undefined size rasterises nothing at all. WebGPU points are always
264
+ // one pixel.
265
+ this.$builtins.pointSize = 1;
266
+ }
267
+ this.$if(this.outside, function() {
268
+ // Park the photon outside the clip volume so it is discarded. Keep
269
+ // w at 1: a zero w would divide to NaN instead of clipping.
270
+ this.$builtins.position = pb.vec4(-2, -2, 0, 1);
271
+ });
272
+ });
273
+ },
274
+ fragment (pb) {
275
+ this.$outputs.outColor = pb.vec4();
276
+ pb.main(function() {
277
+ this.$outputs.outColor = pb.vec4(this.$inputs.photonWeight, 0, 0, 1);
278
+ });
279
+ }
280
+ };
281
+ }
282
+ /**
283
+ * Shader for the displaced surface height map.
284
+ *
285
+ * Rasterises the water surface itself into the light-space slice, one body per
286
+ * draw. A grid is laid out over the body's footprint in warped map space; each
287
+ * vertex sweeps its grid point along the light onto the rest plane, lets the
288
+ * wave generator carry it to where that bit of surface is, and projects the
289
+ * displaced point back into the map as its clip position. The fragment writes
290
+ * its height above the rest plane, offset by `CAUSTIC_HEIGHT_BIAS`, into this
291
+ * body's channel - selected with a colour write mask, so where the surface
292
+ * overlaps itself in the light's view the last triangle wins rather than the two
293
+ * heights adding. The target is cleared first, and a texel the surface never
294
+ * reaches stays at zero, which the receiver reads as "no water on this ray".
295
+ *
296
+ * Rasterising is what makes the horizontal displacement a non-issue: sampling
297
+ * the generator at the map position would return the surface that *started*
298
+ * there, and inverting that displacement only converges where the surface does
299
+ * not fold. It also makes the footprint edge right - a receiver whose sun ray
300
+ * crosses the rest plane outside the footprint finds an empty texel and stays
301
+ * dry, where a flat stored height would put it under water that does not exist.
302
+ *
303
+ * The receiver looks this up with the very uv it uses for the caustic pattern:
304
+ * its fragment and the ray's crossing of the rest plane lie on one light ray,
305
+ * and the map's projection is invariant along the light.
306
+ *
307
+ * Everything runs in the vertex stage; the generators sample with an explicit
308
+ * LOD, which is what makes that legal.
309
+ *
310
+ * @param waveGenerator - Supplies the surface displacement of this body.
311
+ * @internal
312
+ */ function createCausticHeightShader(waveGenerator) {
313
+ return {
314
+ vertex (pb) {
315
+ // Vertex ordinal; the grid coordinate is derived from it.
316
+ this.$inputs.vertexIndex = pb.float().attrib('position');
317
+ this.$outputs.height = pb.float();
318
+ this.$outputs.inside = pb.float();
319
+ this.causticFrameX = pb.vec4().uniform(0);
320
+ this.causticFrameY = pb.vec4().uniform(0);
321
+ /** (slice centre.xyz, this body's rest level) */ this.causticCenter = pb.vec4().uniform(0);
322
+ this.causticLightDir = pb.vec4().uniform(0);
323
+ this.causticRegion = pb.vec4().uniform(0);
324
+ /** Map-NDC rectangle the grid is laid out over: (minX, minY, maxX, maxY). */ this.causticGridBounds = pb.vec4().uniform(0);
325
+ /** (warp strength, grid size, 1 / (grid size + 1), 1 / grid size) */ this.causticHeightParams = pb.vec4().uniform(0);
326
+ ShaderHelper.declareStandaloneCameraTime(this, 0);
327
+ waveGenerator.setupUniforms(this, 0);
328
+ pb.main(function() {
329
+ this.$l.invRadius = pb.vec2(this.causticFrameX.w, this.causticFrameY.w);
330
+ this.$l.radius = pb.div(pb.vec2(1), this.invRadius);
331
+ this.$l.L = this.causticLightDir.xyz;
332
+ this.$l.waterLevel = this.causticCenter.w;
333
+ // Ordinal to grid coordinate, (gridSize + 1) vertices per side. Half a
334
+ // vertex up before the floor: the reciprocal is inexact, and an ordinal
335
+ // that is an exact multiple of the row length must not round down into
336
+ // the previous row.
337
+ this.$l.row = pb.floor(pb.mul(pb.add(this.$inputs.vertexIndex, 0.5), this.causticHeightParams.z));
338
+ this.$l.col = pb.sub(this.$inputs.vertexIndex, pb.mul(this.row, pb.add(this.causticHeightParams.y, 1)));
339
+ this.$l.gridUV = pb.mul(pb.vec2(this.col, this.row), this.causticHeightParams.w);
340
+ // Grid point to the slice, through the warp the map's texels live under.
341
+ this.$l.warpedNDC = pb.mix(this.causticGridBounds.xy, this.causticGridBounds.zw, this.gridUV);
342
+ this.$l.ndc = ShaderHelper.unwarpCausticNDC(this, this.warpedNDC, this.causticHeightParams.x);
343
+ this.$l.planePos = pb.add(this.causticCenter.xyz, pb.mul(this.causticFrameX.xyz, pb.mul(this.ndc.x, this.radius.x)), pb.mul(this.causticFrameY.xyz, pb.mul(this.ndc.y, this.radius.y)));
344
+ // Along the light onto the rest plane: the rest point of this vertex.
345
+ this.$l.sweep = pb.div(pb.sub(this.waterLevel, this.planePos.y), this.L.y);
346
+ this.$l.surfaceXZ = pb.add(this.planePos, pb.mul(this.L, this.sweep));
347
+ // Outside the footprint there is no surface. Resolved per vertex and
348
+ // interpolated, which fades the edge over one grid cell.
349
+ this.$outputs.inside = pb.float(pb.and(pb.all(pb.greaterThanEqual(this.surfaceXZ.xz, this.causticRegion.xy)), pb.all(pb.lessThanEqual(this.surfaceXZ.xz, this.causticRegion.zw))));
350
+ // Where the surface carried this rest point, and how high it is there.
351
+ this.$l.surfacePos = pb.vec3();
352
+ this.$l.surfaceNormal = pb.vec3();
353
+ waveGenerator.calcVertexPositionAndNormal(this, this.surfaceXZ, this.surfacePos, this.surfaceNormal);
354
+ this.$outputs.height = pb.sub(this.surfacePos.y, this.waterLevel);
355
+ // The displaced point back into the map, exactly as the splat projects a
356
+ // photon hit: two dot products, then the warp.
357
+ this.$l.rel = pb.sub(this.surfacePos, this.causticCenter.xyz);
358
+ this.$l.hitNDC = pb.mul(pb.vec2(pb.dot(this.rel, this.causticFrameX.xyz), pb.dot(this.rel, this.causticFrameY.xyz)), this.invRadius);
359
+ this.$l.hitWarped = ShaderHelper.warpCausticNDC(this, this.hitNDC, this.causticHeightParams.x);
360
+ // The receiver reads the map at `v = 0.5 + 0.5 * ndc.y`. On WebGL a
361
+ // fragment at clip y lands on that row; on WebGPU clip +1 is the first
362
+ // row, so the surface is drawn upside down to land where the receiver
363
+ // looks. The splat needs no such flip because its map reaches the
364
+ // receiver through the blur and resolve quads, each of which flips it
365
+ // once more on WebGPU; this map goes straight.
366
+ this.$builtins.position = pb.getDevice().type === 'webgpu' ? pb.vec4(this.hitWarped.x, pb.neg(this.hitWarped.y), 0, 1) : pb.vec4(this.hitWarped, 0, 1);
367
+ });
368
+ },
369
+ fragment (pb) {
370
+ this.$outputs.outColor = pb.vec4();
371
+ pb.main(function() {
372
+ // Outside the footprint nothing is written at all, so the texel keeps
373
+ // reading as "no surface" rather than as a surface at height zero.
374
+ this.$if(pb.lessThan(this.$inputs.inside, 0.5), function() {
375
+ pb.discard();
376
+ });
377
+ // Every channel; the colour mask keeps all but this body's.
378
+ this.$outputs.outColor = pb.vec4(pb.add(this.$inputs.height, CAUSTIC_HEIGHT_BIAS));
379
+ });
380
+ }
381
+ };
382
+ }
383
+ /**
384
+ * Shader for the caustic map blur.
385
+ *
386
+ * Four bilinear taps at the texel corners average a 2x2 neighbourhood. The
387
+ * weights sum to one, so the "calm water reads 1.0" normalisation established by
388
+ * the accumulation pass survives any number of iterations.
389
+ *
390
+ * @internal
391
+ */ function createCausticBlurShader() {
392
+ return {
393
+ vertex (pb) {
394
+ this.$inputs.pos = pb.vec2().attrib('position');
395
+ this.$outputs.uv = pb.vec2();
396
+ pb.main(function() {
397
+ this.$builtins.position = pb.vec4(this.$inputs.pos, 0, 1);
398
+ this.$outputs.uv = pb.add(pb.mul(this.$inputs.pos, 0.5), pb.vec2(0.5));
399
+ });
400
+ },
401
+ fragment (pb) {
402
+ this.causticSrc = pb.tex2D().uniform(0);
403
+ this.causticTexelSize = pb.vec4().uniform(0);
404
+ this.$outputs.outColor = pb.vec4();
405
+ pb.main(function() {
406
+ this.$l.o = pb.mul(this.causticTexelSize.xy, 0.5);
407
+ this.$l.sum = pb.add(pb.textureSampleLevel(this.causticSrc, pb.add(this.$inputs.uv, pb.vec2(this.o.x, this.o.y)), 0), pb.textureSampleLevel(this.causticSrc, pb.add(this.$inputs.uv, pb.vec2(pb.neg(this.o.x), this.o.y)), 0), pb.textureSampleLevel(this.causticSrc, pb.add(this.$inputs.uv, pb.vec2(this.o.x, pb.neg(this.o.y))), 0), pb.textureSampleLevel(this.causticSrc, pb.add(this.$inputs.uv, pb.vec2(pb.neg(this.o.x), pb.neg(this.o.y))), 0));
408
+ this.$outputs.outColor = pb.vec4(pb.mul(this.sum.x, 0.25), 0, 0, 1);
409
+ });
410
+ }
411
+ };
412
+ }
413
+ /**
414
+ * Shader for the temporal resolve.
415
+ *
416
+ * Reprojection is exact and matrix-free. Both frames parameterise the map as an
417
+ * orthographic slice perpendicular to the light, so a texel maps to a world
418
+ * point with two scaled axes, and that point maps into the previous frame with
419
+ * two dot products. Photons travel along the light and the slice is normal to
420
+ * it, so the plane the point is taken on does not matter.
421
+ *
422
+ * Map NDC relates to texture coordinate by the receiver's `ndc * 0.5 + 0.5`
423
+ * (see `ShaderHelper.calculateWaterCaustic`), which is what makes this
424
+ * independent of the clip-space y flip the splat has to apply.
425
+ *
426
+ * @internal
427
+ */ function createCausticResolveShader() {
428
+ return {
429
+ vertex (pb) {
430
+ this.$inputs.pos = pb.vec2().attrib('position');
431
+ this.$outputs.uv = pb.vec2();
432
+ pb.main(function() {
433
+ this.$builtins.position = pb.vec4(this.$inputs.pos, 0, 1);
434
+ this.$outputs.uv = pb.add(pb.mul(this.$inputs.pos, 0.5), pb.vec2(0.5));
435
+ });
436
+ },
437
+ fragment (pb) {
438
+ this.causticCurrent = pb.tex2D().uniform(0);
439
+ this.causticHistory = pb.tex2D().uniform(0);
440
+ this.causticFrameX = pb.vec4().uniform(0);
441
+ this.causticFrameY = pb.vec4().uniform(0);
442
+ this.causticCenter = pb.vec4().uniform(0);
443
+ this.causticPrevFrameX = pb.vec4().uniform(0);
444
+ this.causticPrevFrameY = pb.vec4().uniform(0);
445
+ this.causticPrevCenter = pb.vec4().uniform(0);
446
+ /** (blend weight, texel size, warp, previous frame's warp) */ this.causticResolveParams = pb.vec4().uniform(0);
447
+ this.$outputs.outColor = pb.vec4();
448
+ pb.main(function() {
449
+ this.$l.texel = this.causticResolveParams.y;
450
+ this.$l.current = pb.textureSampleLevel(this.causticCurrent, this.$inputs.uv, 0).x;
451
+ // Range the current frame supports around this texel. The reprojected
452
+ // value is clamped into it, which is what lets a long blend coexist with
453
+ // an animated pattern: where the waves have moved on, the neighbourhood
454
+ // no longer covers the old value.
455
+ this.$l.mn = this.current;
456
+ this.$l.mx = this.current;
457
+ for(let dy = -1; dy <= 1; dy++){
458
+ for(let dx = -1; dx <= 1; dx++){
459
+ if (dx === 0 && dy === 0) {
460
+ continue;
461
+ }
462
+ this.$l.s = pb.textureSampleLevel(this.causticCurrent, pb.add(this.$inputs.uv, pb.mul(pb.vec2(dx, dy), this.texel)), 0).x;
463
+ this.mn = pb.min(this.mn, this.s);
464
+ this.mx = pb.max(this.mx, this.s);
465
+ }
466
+ }
467
+ // This texel's world point on the slice, then where the previous frame
468
+ // put that same point. Texels are laid out in warped space, so this
469
+ // leaves it to reach the world and re-enters it to land on the history:
470
+ // the two frames may differ in slice and in warp strength alike.
471
+ this.$l.warpedNDC = pb.sub(pb.mul(this.$inputs.uv, 2), pb.vec2(1));
472
+ this.$l.ndc = ShaderHelper.unwarpCausticNDC(this, this.warpedNDC, this.causticResolveParams.z);
473
+ this.$l.radius = pb.div(pb.vec2(1), pb.vec2(this.causticFrameX.w, this.causticFrameY.w));
474
+ this.$l.world = pb.add(this.causticCenter.xyz, pb.mul(this.causticFrameX.xyz, pb.mul(this.ndc.x, this.radius.x)), pb.mul(this.causticFrameY.xyz, pb.mul(this.ndc.y, this.radius.y)));
475
+ this.$l.rel = pb.sub(this.world, this.causticPrevCenter.xyz);
476
+ this.$l.prevNDC = pb.mul(pb.vec2(pb.dot(this.rel, this.causticPrevFrameX.xyz), pb.dot(this.rel, this.causticPrevFrameY.xyz)), pb.vec2(this.causticPrevFrameX.w, this.causticPrevFrameY.w));
477
+ this.$l.prevWarped = ShaderHelper.warpCausticNDC(this, this.prevNDC, this.causticResolveParams.w);
478
+ this.$l.prevUV = pb.add(pb.mul(this.prevWarped, 0.5), pb.vec2(0.5));
479
+ // Ground the camera has just scrolled into has no history at all.
480
+ this.$l.inside = pb.and(pb.all(pb.greaterThanEqual(this.prevUV, pb.vec2(0))), pb.all(pb.lessThanEqual(this.prevUV, pb.vec2(1))));
481
+ this.$l.history = pb.textureSampleLevel(this.causticHistory, this.prevUV, 0).x;
482
+ this.$l.blend = pb.mul(this.causticResolveParams.x, pb.float(this.inside));
483
+ this.$outputs.outColor = pb.vec4(pb.mix(this.current, pb.clamp(this.history, this.mn, this.mx), this.blend), 0, 0, 1);
484
+ });
485
+ }
486
+ };
487
+ }
488
+ /**
489
+ * Renders the water caustic map.
490
+ *
491
+ * One photon is emitted per texel of a uniform grid laid out on the same
492
+ * orthographic light-space slice the map covers. Each photon is refracted at the
493
+ * water surface, intersected with the scene (or a horizontal focal plane) below
494
+ * it, and splatted additively at the hit position. Because the grid and the map
495
+ * share a parameterisation, and the mean deflection of a flat surface is removed
496
+ * from the refracted direction, calm water maps photon `(i,j)` onto texel
497
+ * `(i,j)` and the map converges to a uniform 1.0 - which the receiver treats as
498
+ * "no caustics".
499
+ *
500
+ * @internal
501
+ */ class WaterCausticsRenderer {
502
+ /** Splat programs keyed by the wave generator's shader hash. */ _splatPrograms;
503
+ /** Height-map programs keyed by the wave generator's shader hash. */ _heightPrograms;
504
+ /** One per water slot, differing only in which channel they let through. */ _heightStates;
505
+ _heightParams;
506
+ /** The surface grid the height map is rasterised with; built once. */ _heightLayout;
507
+ _heightVertexBuffer;
508
+ _heightIndexBuffer;
509
+ _heightIndexCount;
510
+ _blurProgram;
511
+ _blurBindGroup;
512
+ _resolveProgram;
513
+ _resolveBindGroup;
514
+ _resolveStates;
515
+ /**
516
+ * Slice parameters the last map committed as history was built with, per
517
+ * camera.
518
+ *
519
+ * Per camera because the history textures are, and this has to describe the
520
+ * exact map the resolve is about to reproject; one shared entry would make a
521
+ * second viewport reproject through the first one's slice.
522
+ */ _prevSlices;
523
+ _resolveParams;
524
+ _photonLayout;
525
+ /** Photons the buffer can serve; it grows and is never rebuilt smaller. */ _photonCapacity;
526
+ _photonBuffer;
527
+ _photonParams;
528
+ _photonCount;
529
+ _splatStates;
530
+ _blurStates;
531
+ _uniforms;
532
+ _tmpRight;
533
+ _tmpUp;
534
+ _tmpDir;
535
+ _tmpCenter;
536
+ _splatParams;
537
+ _blurTexelSize;
538
+ /** Map-NDC rectangle the photon grid covers, and the map fraction it is. */ _gridBounds;
539
+ _gridFraction;
540
+ /** Furthest the fitted slice reaches from the camera, for cascade selection. */ _sliceReach;
541
+ /** Slice frame and extents, kept for the per-body grid fit. */ _sliceRight;
542
+ _sliceUp;
543
+ _sliceCenterR;
544
+ _sliceCenterU;
545
+ _sliceHalfR;
546
+ _sliceHalfU;
547
+ _splatCenter;
548
+ _sceneMatrixT;
549
+ _sceneMatrix;
550
+ _sceneMatrixInv;
551
+ _sceneDepthParams;
552
+ /** Warp strength the current slice was built with; 0 when the map is linear. */ _warp;
553
+ constructor(){
554
+ this._splatPrograms = new Map();
555
+ this._heightPrograms = new Map();
556
+ this._heightStates = [];
557
+ this._heightParams = new Vector4();
558
+ this._heightLayout = null;
559
+ this._heightVertexBuffer = null;
560
+ this._heightIndexBuffer = null;
561
+ this._heightIndexCount = 0;
562
+ this._blurProgram = null;
563
+ this._blurBindGroup = null;
564
+ this._resolveProgram = null;
565
+ this._resolveBindGroup = null;
566
+ this._resolveStates = null;
567
+ this._prevSlices = new WeakMap();
568
+ this._resolveParams = new Vector4();
569
+ this._photonLayout = null;
570
+ this._photonCapacity = 0;
571
+ this._photonBuffer = null;
572
+ this._photonParams = new Vector4();
573
+ this._photonCount = 0;
574
+ this._splatStates = null;
575
+ this._blurStates = null;
576
+ this._uniforms = {
577
+ frameX: new Vector4(),
578
+ frameY: new Vector4(),
579
+ center: new Vector4(),
580
+ lightDir: new Vector4(),
581
+ params: new Vector4(),
582
+ extinction: new Vector4(),
583
+ waterCount: 0,
584
+ waterRegions: new Float32Array(MAX_CAUSTIC_WATERS * 4),
585
+ waterMedia: new Float32Array(MAX_CAUSTIC_WATERS * 4)
586
+ };
587
+ this._tmpRight = new Vector3();
588
+ this._tmpUp = new Vector3();
589
+ this._tmpDir = new Vector3();
590
+ this._tmpCenter = new Vector3();
591
+ this._splatParams = new Vector4();
592
+ this._blurTexelSize = new Vector4();
593
+ this._gridBounds = new Vector4(-1, -1, 1, 1);
594
+ this._gridFraction = 1;
595
+ this._sliceReach = 1;
596
+ this._sliceRight = new Vector3();
597
+ this._sliceUp = new Vector3();
598
+ this._sliceCenterR = 0;
599
+ this._sliceCenterU = 0;
600
+ this._sliceHalfR = 1;
601
+ this._sliceHalfU = 1;
602
+ this._splatCenter = new Vector4();
603
+ this._sceneMatrixT = new Matrix4x4();
604
+ this._sceneMatrix = new Matrix4x4();
605
+ this._sceneMatrixInv = new Matrix4x4();
606
+ this._sceneDepthParams = new Vector4();
607
+ this._warp = 0;
608
+ }
609
+ /** Parameters of the map produced by the last successful {@link render}. */ get uniforms() {
610
+ return this._uniforms;
611
+ }
612
+ /**
613
+ * Storage format for the caustic map. Single channel when the device can both
614
+ * render to and filter it, which the additive accumulation and the bilinear
615
+ * sampling need.
616
+ */ static getMapFormat(device) {
617
+ const info = device.getDeviceCaps().textureCaps.getTextureFormatInfo('r16f');
618
+ return info.renderable && info.filterable ? 'r16f' : 'rgba16f';
619
+ }
620
+ /**
621
+ * Storage format for the displaced surface height map. One channel per water
622
+ * slot, so always four wide; half floats carry a wave height to well under a
623
+ * millimetre at any plausible level.
624
+ */ static getHeightMapFormat() {
625
+ return 'rgba16f';
626
+ }
627
+ /**
628
+ * Whether caustics can be produced for this water surface and light.
629
+ *
630
+ * @param water - Water surface the photons are refracted through.
631
+ * @param light - Directional light the photons come from.
632
+ * @returns True when {@link render} would produce a usable map.
633
+ */ static canRender(water, light) {
634
+ const material = water.material;
635
+ if (!material?.causticsEnabled || !material.waveGenerator || !light.isDirectionLight()) {
636
+ return false;
637
+ }
638
+ // Sun too close to the horizon: the swept photon grid degenerates.
639
+ return -light.directionAndCutoff.y >= MIN_SUN_ELEVATION;
640
+ }
641
+ /**
642
+ * Builds the caustic map for one water surface.
643
+ *
644
+ * @param ctx - Draw context; supplies the device and the camera the map is centred on.
645
+ * @param water - Water surface to refract through.
646
+ * @param light - Directional light acting as the photon source.
647
+ * @param map - Accumulation target; also the result when nothing resolves after it.
648
+ * @param scratch - Ping-pong target for the blur, and the temporal resolve's
649
+ * output. Same size and format as `map`.
650
+ * @param heights - Target for the displaced surface height map, in
651
+ * {@link getHeightMapFormat} and the same size as `map`. Filled by this call
652
+ * and read by the light pass alongside the map.
653
+ * @param history - Previous frame's resolved map, or null when there is none
654
+ * to reproject. Ignored unless the material asks for temporal accumulation.
655
+ * @param createFramebuffer - Wraps a target texture into a framebuffer.
656
+ * @returns The texture holding the finished map, which is `map` or `scratch`
657
+ * depending on whether the temporal resolve ran.
658
+ */ render(ctx, waters, light, map, scratch, heights, history, createFramebuffer) {
659
+ const device = ctx.device;
660
+ this._updateUniforms(ctx, waters, light, map.width);
661
+ const sceneDepth = this._anySceneDepth(waters) ? this._resolveSceneDepth(ctx, light) : null;
662
+ // The height map first: it depends on the slice alone, and the receiver
663
+ // needs it whatever the splat produces.
664
+ device.pushDeviceStates();
665
+ device.setFramebuffer(createFramebuffer(heights));
666
+ device.setViewport(null);
667
+ device.setScissor(null);
668
+ device.clearFrameBuffer(Vector4.zero(), null, null);
669
+ this._ensureHeightGrid(device);
670
+ for(let i = 0; i < waters.length; i++){
671
+ this._renderHeightsOne(device, ctx, waters[i], i);
672
+ }
673
+ device.popDeviceStates();
674
+ device.pushDeviceStates();
675
+ device.setFramebuffer(createFramebuffer(map));
676
+ // The viewport and scissor survive a framebuffer change, and this pass runs
677
+ // straight after the shadow maps, whose targets are a different size.
678
+ device.setViewport(null);
679
+ device.setScissor(null);
680
+ device.clearFrameBuffer(Vector4.zero(), null, null);
681
+ device.setRenderStates(this._getSplatStates(device));
682
+ // One draw per water body, accumulating into the one map. They share the
683
+ // slice, so a receiver still resolves the pattern with a single lookup; the
684
+ // footprint, surface height and wave generator are per-draw state.
685
+ for(let i = 0; i < waters.length; i++){
686
+ this._splatOne(device, ctx, waters[i], i, map, sceneDepth);
687
+ }
688
+ device.popDeviceStates();
689
+ return this._postProcess(device, waters[0].material, map, scratch, history, ctx, createFramebuffer);
690
+ }
691
+ /**
692
+ * Writes one body's displaced surface height into its channel of the height
693
+ * map. Per body for the same reason the splat is: each has its own wave
694
+ * generator, rest level and footprint.
695
+ * @internal
696
+ */ _renderHeightsOne(device, ctx, water, index) {
697
+ const material = water.material;
698
+ const waveGenerator = material.waveGenerator;
699
+ // The grid covers this body's footprint, like the photon grid does; a body
700
+ // the map does not reach has nothing to draw.
701
+ this._updateGridBounds(water);
702
+ if (!(this._gridFraction > 0)) {
703
+ return;
704
+ }
705
+ const info = this._getHeightProgram(device, waveGenerator);
706
+ // A bind group per slot; see _getSplatBindGroup.
707
+ const bindGroup = this._getSplatBindGroup(device, info, index);
708
+ bindGroup.setValue('causticFrameX', this._uniforms.frameX);
709
+ bindGroup.setValue('causticFrameY', this._uniforms.frameY);
710
+ this._splatCenter.setXYZW(this._uniforms.center.x, this._uniforms.center.y, this._uniforms.center.z, water.worldMatrix.m13);
711
+ bindGroup.setValue('causticCenter', this._splatCenter);
712
+ bindGroup.setValue('causticLightDir', this._uniforms.lightDir);
713
+ bindGroup.setValue('causticRegion', material.region);
714
+ bindGroup.setValue('causticGridBounds', this._gridBounds);
715
+ this._heightParams.setXYZW(this._warp, HEIGHT_GRID_SIZE, 1 / (HEIGHT_GRID_SIZE + 1), 1 / HEIGHT_GRID_SIZE);
716
+ bindGroup.setValue('causticHeightParams', this._heightParams);
717
+ ShaderHelper.setStandaloneCameraTime(bindGroup, ctx);
718
+ waveGenerator.applyWaterBindGroup(bindGroup);
719
+ // Channel i for slot i, which is how the receiver picks it back out.
720
+ device.setRenderStates(this._getHeightStates(device, index));
721
+ device.setProgram(info.program);
722
+ device.setBindGroup(0, bindGroup);
723
+ device.setVertexLayout(this._heightLayout);
724
+ device.draw('triangle-list', 0, this._heightIndexCount);
725
+ }
726
+ /**
727
+ * Builds the surface grid once: vertex ordinals, and the index list that
728
+ * stitches a (HEIGHT_GRID_SIZE + 1)^2 lattice of them into triangles.
729
+ * @internal
730
+ */ _ensureHeightGrid(device) {
731
+ if (this._heightLayout) {
732
+ return;
733
+ }
734
+ const side = HEIGHT_GRID_SIZE + 1;
735
+ const ordinals = new Float32Array(side * side);
736
+ for(let i = 0; i < ordinals.length; i++){
737
+ ordinals[i] = i;
738
+ }
739
+ const indices = new Uint32Array(HEIGHT_GRID_SIZE * HEIGHT_GRID_SIZE * 6);
740
+ let n = 0;
741
+ for(let row = 0; row < HEIGHT_GRID_SIZE; row++){
742
+ for(let col = 0; col < HEIGHT_GRID_SIZE; col++){
743
+ const a = row * side + col;
744
+ const b = a + 1;
745
+ const c = a + side;
746
+ const d = c + 1;
747
+ indices[n++] = a;
748
+ indices[n++] = b;
749
+ indices[n++] = d;
750
+ indices[n++] = a;
751
+ indices[n++] = d;
752
+ indices[n++] = c;
753
+ }
754
+ }
755
+ const vertexBuffer = device.createVertexBuffer('position_f32', ordinals);
756
+ const indexBuffer = device.createIndexBuffer(indices);
757
+ this._heightVertexBuffer = vertexBuffer;
758
+ this._heightIndexBuffer = indexBuffer;
759
+ this._heightLayout = device.createVertexLayout({
760
+ vertexBuffers: [
761
+ {
762
+ buffer: vertexBuffer
763
+ }
764
+ ],
765
+ indexBuffer
766
+ });
767
+ this._heightIndexCount = indices.length;
768
+ }
769
+ /** @internal */ _splatOne(device, ctx, water, index, map, sceneDepth) {
770
+ const material = water.material;
771
+ const waveGenerator = material.waveGenerator;
772
+ // Photons are laid out over this body's own footprint, so the grid bounds
773
+ // and the fraction of the map they cover are per body, not per map.
774
+ this._updateGridBounds(water);
775
+ if (!(this._gridFraction > 0)) {
776
+ return;
777
+ }
778
+ const photonGrid = this._resolvePhotonGrid(material.causticsPhotonResolution, map.width);
779
+ this._updatePhotonLayout(device, photonGrid);
780
+ // Scene-depth intersection is per material, but the program is per draw, so
781
+ // a body that has it off simply compiles the flat variant.
782
+ const useSceneDepth = !!sceneDepth && material.causticsSceneDepth;
783
+ const splat = this._getSplatProgram(device, waveGenerator, useSceneDepth);
784
+ // A bind group per slot; see _getSplatBindGroup.
785
+ const bindGroup = this._getSplatBindGroup(device, splat, index);
786
+ if (useSceneDepth) {
787
+ bindGroup.setValue('causticSceneMatrix', sceneDepth.matrix);
788
+ bindGroup.setValue('causticSceneMatrixInv', sceneDepth.matrixInv);
789
+ this._sceneDepthParams.setXYZW(sceneDepth.layer, sceneDepth.size, 0, 0);
790
+ bindGroup.setValue('causticSceneParams', this._sceneDepthParams);
791
+ // Point sampling: the stored value is a depth, and averaging two depths
792
+ // across a silhouette names a surface that is not there.
793
+ bindGroup.setTexture('causticSceneDepth', sceneDepth.texture, fetchSampler('clamp_nearest_nomip'));
794
+ }
795
+ bindGroup.setValue('causticFrameX', this._uniforms.frameX);
796
+ bindGroup.setValue('causticFrameY', this._uniforms.frameY);
797
+ // The slice centre is shared, but the water level in w is this body's.
798
+ this._splatCenter.setXYZW(this._uniforms.center.x, this._uniforms.center.y, this._uniforms.center.z, water.worldMatrix.m13);
799
+ bindGroup.setValue('causticCenter', this._splatCenter);
800
+ bindGroup.setValue('causticLightDir', this._uniforms.lightDir);
801
+ bindGroup.setValue('causticRegion', material.region);
802
+ bindGroup.setValue('causticGridBounds', this._gridBounds);
803
+ // A photon grid denser than the map deposits more than one photon per texel;
804
+ // scale the deposit so a flat surface still integrates to exactly 1. The
805
+ // grid fraction is part of that: concentrating the same photons onto a
806
+ // smaller part of the map raises the density there by exactly its inverse.
807
+ const photonWeight = this._gridFraction * map.width * map.height / (photonGrid * photonGrid);
808
+ this._splatParams.setXYZW(this._warp, material.causticsDepth, WATER_ETA, photonWeight);
809
+ bindGroup.setValue('causticSplatParams', this._splatParams);
810
+ this._photonParams.setXYZW(photonGrid, 1 / photonGrid, 0, 0);
811
+ bindGroup.setValue('causticPhotonParams', this._photonParams);
812
+ ShaderHelper.setStandaloneCameraTime(bindGroup, ctx);
813
+ waveGenerator.applyWaterBindGroup(bindGroup);
814
+ device.setProgram(splat.program);
815
+ device.setBindGroup(0, bindGroup);
816
+ device.setVertexLayout(this._photonLayout);
817
+ device.draw('point-list', 0, this._photonCount);
818
+ }
819
+ /**
820
+ * Blur and temporal resolve, which run once over the finished map rather than
821
+ * per water body.
822
+ *
823
+ * The settings come from one body's material because the map is one texture.
824
+ * The largest body supplies them, which is the one the viewer is most likely
825
+ * under.
826
+ * @internal
827
+ */ _postProcess(device, material, map, scratch, history, ctx, createFramebuffer) {
828
+ // Ping-pong between the two targets. The count is rounded up to an even
829
+ // number by the material so the last pass always lands back in `map`.
830
+ const blurPasses = Math.max(0, Math.min(material.causticsBlurPasses, 4));
831
+ for(let i = 0; i < blurPasses; i++){
832
+ const src = i % 2 === 0 ? map : scratch;
833
+ const dst = i % 2 === 0 ? scratch : map;
834
+ this._blur(device, src, createFramebuffer(dst));
835
+ }
836
+ // The blur always lands back in `map`, so `scratch` is free to take the
837
+ // resolve. Doing it the other way round would make the resolve read and
838
+ // write one texture.
839
+ const strength = material.causticsTemporalStrength;
840
+ if (strength <= 0) {
841
+ // Nothing is committed as history this frame, so the recorded slice has to
842
+ // keep describing whichever map the history slot still holds.
843
+ return map;
844
+ }
845
+ const camera = ctx.camera;
846
+ const previous = this._prevSlices.get(camera);
847
+ // No history on the first frame, after a resize, or after the pass was off
848
+ // for a while. The current map stands on its own and becomes the history.
849
+ const resolved = history && previous ? scratch : map;
850
+ if (resolved === scratch) {
851
+ this._resolve(device, map, history, previous, strength, createFramebuffer(scratch));
852
+ }
853
+ this._rememberSlice(camera);
854
+ return resolved;
855
+ }
856
+ /**
857
+ * Shadow cascade to read the scene depth from, or null when this backend or
858
+ * this light cannot supply one.
859
+ * @internal
860
+ */ _resolveSceneDepth(ctx, light) {
861
+ // WebGPU only. Reading a raw depth out of the shadow map needs a texel
862
+ // fetch, and GLSL ES 3.0 does not allow one on a shadow-sampler texture.
863
+ // WebGL2 keeps the focal plane.
864
+ if (ctx.device.type !== 'webgpu') {
865
+ return null;
866
+ }
867
+ const params = ctx.shadowMapInfo?.get(light);
868
+ const shadowMap = params?.shadowMap;
869
+ // Only the cascaded native-depth path. A packed RGBA map stores an encoded
870
+ // distance rather than a sampled depth, and a non-array map has no cascade
871
+ // to select; both fall back to the plane.
872
+ if (!params || !shadowMap?.isTexture2DArray() || !shadowMap.isDepth()) {
873
+ return null;
874
+ }
875
+ // The cascade that covers the slice most tightly: the smallest one whose
876
+ // split distance clears the slice's reach. Falling off the end means the
877
+ // slice outruns the shadow map and the last cascade is all there is.
878
+ const count = Math.max(1, Math.min(params.numShadowCascades, 4));
879
+ const reach = Math.max(1, this._sliceReach);
880
+ let layer = count - 1;
881
+ for(let i = 0; i < count; i++){
882
+ if (params.cascadeDistances[i] >= reach) {
883
+ layer = i;
884
+ break;
885
+ }
886
+ }
887
+ // shadowMatrices holds each cascade's view-projection transposed, four vec4
888
+ // rows per cascade, which is the layout the lighting pass dots against.
889
+ this._sceneMatrixT.set(params.shadowMatrices.subarray(layer * 16, layer * 16 + 16));
890
+ Matrix4x4.transpose(this._sceneMatrixT, this._sceneMatrix);
891
+ Matrix4x4.invert(this._sceneMatrix, this._sceneMatrixInv);
892
+ return {
893
+ texture: shadowMap,
894
+ layer,
895
+ size: shadowMap.width,
896
+ matrix: this._sceneMatrix,
897
+ matrixInv: this._sceneMatrixInv
898
+ };
899
+ }
900
+ /**
901
+ * Photon grid edge length for this frame.
902
+ *
903
+ * Auto solves the grid for a fixed density in photons per covered map texel:
904
+ * the grid covers `gridFraction` of the map, so the target is reached at
905
+ * `grid = size * sqrt(target * gridFraction)`. Fixing the grid instead leaves
906
+ * the density swinging with the water's footprint.
907
+ *
908
+ * @param requested - Material setting; 0 asks for the automatic size.
909
+ * @param mapSize - Edge length of the caustic map.
910
+ * @internal
911
+ */ _resolvePhotonGrid(requested, mapSize) {
912
+ if (requested > 0) {
913
+ return Math.max(8, Math.min(requested, 4096));
914
+ }
915
+ const grid = Math.round(mapSize * Math.sqrt(PHOTONS_PER_TEXEL * this._gridFraction));
916
+ return Math.max(8, Math.min(grid, 4096));
917
+ }
918
+ /**
919
+ * Records the slice the map just produced was built with, for the next frame's
920
+ * reprojection.
921
+ * @internal
922
+ */ _rememberSlice(camera) {
923
+ let slice = this._prevSlices.get(camera);
924
+ if (!slice) {
925
+ slice = {
926
+ frameX: new Vector4(),
927
+ frameY: new Vector4(),
928
+ center: new Vector4(),
929
+ warp: 0
930
+ };
931
+ this._prevSlices.set(camera, slice);
932
+ }
933
+ slice.frameX.set(this._uniforms.frameX);
934
+ slice.frameY.set(this._uniforms.frameY);
935
+ slice.center.set(this._uniforms.center);
936
+ slice.warp = this._warp;
937
+ }
938
+ /** @internal */ _resolve(device, current, history, previous, strength, dst) {
939
+ const program = this._getResolveProgram(device);
940
+ const bindGroup = this._resolveBindGroup;
941
+ device.pushDeviceStates();
942
+ device.setFramebuffer(dst);
943
+ device.setViewport(null);
944
+ device.setScissor(null);
945
+ device.setProgram(program);
946
+ bindGroup.setTexture('causticCurrent', current, fetchSampler('clamp_linear_nomip'));
947
+ bindGroup.setTexture('causticHistory', history, fetchSampler('clamp_linear_nomip'));
948
+ bindGroup.setValue('causticFrameX', this._uniforms.frameX);
949
+ bindGroup.setValue('causticFrameY', this._uniforms.frameY);
950
+ bindGroup.setValue('causticCenter', this._uniforms.center);
951
+ bindGroup.setValue('causticPrevFrameX', previous.frameX);
952
+ bindGroup.setValue('causticPrevFrameY', previous.frameY);
953
+ bindGroup.setValue('causticPrevCenter', previous.center);
954
+ this._resolveParams.setXYZW(strength, 1 / current.width, this._warp, previous.warp);
955
+ bindGroup.setValue('causticResolveParams', this._resolveParams);
956
+ device.setBindGroup(0, bindGroup);
957
+ drawFullscreenQuad(this._getResolveStates(device));
958
+ device.popDeviceStates();
959
+ }
960
+ /** @internal */ _getResolveProgram(device) {
961
+ if (!this._resolveProgram) {
962
+ this._resolveProgram = device.buildRenderProgram(createCausticResolveShader());
963
+ this._resolveProgram.name = '@Water_CausticResolve';
964
+ this._resolveBindGroup = device.createBindGroup(this._resolveProgram.bindGroupLayouts[0]);
965
+ }
966
+ return this._resolveProgram;
967
+ }
968
+ /** @internal */ _getResolveStates(device) {
969
+ if (!this._resolveStates) {
970
+ this._resolveStates = device.createRenderStateSet();
971
+ this._resolveStates.useRasterizerState().setCullMode('none');
972
+ this._resolveStates.useDepthState().enableTest(false).enableWrite(false);
973
+ }
974
+ return this._resolveStates;
975
+ }
976
+ /** Releases the GPU objects owned by this renderer. */ dispose() {
977
+ for (const info of this._splatPrograms.values()){
978
+ for (const bindGroup of info.bindGroups){
979
+ bindGroup?.dispose();
980
+ }
981
+ info.program.dispose();
982
+ }
983
+ this._splatPrograms.clear();
984
+ for (const info of this._heightPrograms.values()){
985
+ for (const bindGroup of info.bindGroups){
986
+ bindGroup?.dispose();
987
+ }
988
+ info.program.dispose();
989
+ }
990
+ this._heightPrograms.clear();
991
+ this._heightStates.length = 0;
992
+ // The layout does not own its buffers; see _updatePhotonLayout.
993
+ this._heightLayout?.dispose();
994
+ this._heightLayout = null;
995
+ this._heightVertexBuffer?.dispose();
996
+ this._heightVertexBuffer = null;
997
+ this._heightIndexBuffer?.dispose();
998
+ this._heightIndexBuffer = null;
999
+ this._heightIndexCount = 0;
1000
+ this._blurBindGroup?.dispose();
1001
+ this._blurBindGroup = null;
1002
+ this._resolveProgram?.dispose();
1003
+ this._resolveProgram = null;
1004
+ this._resolveBindGroup?.dispose();
1005
+ this._resolveBindGroup = null;
1006
+ this._resolveStates = null;
1007
+ this._blurProgram?.dispose();
1008
+ this._blurProgram = null;
1009
+ this._photonLayout?.dispose();
1010
+ this._photonLayout = null;
1011
+ // Disposing the layout releases its vertex array object, not the buffer it
1012
+ // was built over.
1013
+ this._photonBuffer?.dispose();
1014
+ this._photonBuffer = null;
1015
+ this._photonCapacity = 0;
1016
+ this._photonCount = 0;
1017
+ this._splatStates = null;
1018
+ this._blurStates = null;
1019
+ }
1020
+ /** @internal */ _blur(device, src, dst) {
1021
+ const program = this._getBlurProgram(device);
1022
+ device.pushDeviceStates();
1023
+ device.setFramebuffer(dst);
1024
+ device.setProgram(program);
1025
+ this._blurBindGroup.setTexture('causticSrc', src, fetchSampler('clamp_linear_nomip'));
1026
+ this._blurTexelSize.setXYZW(1 / src.width, 1 / src.height, 0, 0);
1027
+ this._blurBindGroup.setValue('causticTexelSize', this._blurTexelSize);
1028
+ device.setBindGroup(0, this._blurBindGroup);
1029
+ drawFullscreenQuad(this._getBlurStates(device));
1030
+ device.popDeviceStates();
1031
+ }
1032
+ /**
1033
+ * Recomputes the orthographic light-space slice the map covers.
1034
+ * @internal
1035
+ */ _updateUniforms(ctx, waters, light, mapSize) {
1036
+ // Slice-wide settings come from the first body, which the caller has sorted
1037
+ // largest first. They describe the map, not the water, so one body has to
1038
+ // win: a range or a warp per body would ask one texture for two resolutions.
1039
+ const material = waters[0].material;
1040
+ const dir = this._tmpDir;
1041
+ dir.set(light.directionAndCutoff.xyz());
1042
+ dir.inplaceNormalize();
1043
+ // Two axes perpendicular to the light direction. Any pair will do; picking a
1044
+ // world axis that is not parallel to the light keeps the cross products stable.
1045
+ const seed = Math.abs(dir.y) > 0.99 ? Vector3.axisPZ() : Vector3.axisPY();
1046
+ Vector3.cross(seed, dir, this._tmpRight);
1047
+ this._tmpRight.inplaceNormalize();
1048
+ Vector3.cross(dir, this._tmpRight, this._tmpUp);
1049
+ this._tmpUp.inplaceNormalize();
1050
+ const right = this._tmpRight;
1051
+ const up = this._tmpUp;
1052
+ const waterLevel = waters[0].worldMatrix.m13;
1053
+ const range = Math.max(1, material.causticsRange);
1054
+ const camera = ctx.camera.getWorldPosition();
1055
+ const center = this._tmpCenter.setXYZ(camera.x, waterLevel, camera.z);
1056
+ const cameraRight = Vector3.dot(center, right);
1057
+ const cameraUp = Vector3.dot(center, up);
1058
+ // The component along the light direction is arbitrary: both the projection
1059
+ // into the map and the photon sweep are invariant along it.
1060
+ const alongDir = Vector3.dot(center, dir);
1061
+ // Water region in light space. A world-axis-aligned rectangle projects to a
1062
+ // parallelogram here, so its bounds come from the four corners; the map is a
1063
+ // box in these coordinates, which is why the fit is against the AABB.
1064
+ //
1065
+ // Over every body at once, so the slice covers all of them. Bodies far
1066
+ // apart make the union mostly empty, but `causticsRange` already bounds how
1067
+ // much of that reaches the map.
1068
+ let regionMinR = Infinity;
1069
+ let regionMaxR = -Infinity;
1070
+ let regionMinU = Infinity;
1071
+ let regionMaxU = -Infinity;
1072
+ for (const body of waters){
1073
+ const region = body.material.region;
1074
+ // Each body's own height, since the projection is taken on its surface.
1075
+ const level = body.worldMatrix.m13;
1076
+ for (const [x, z] of [
1077
+ [
1078
+ region.x,
1079
+ region.y
1080
+ ],
1081
+ [
1082
+ region.z,
1083
+ region.y
1084
+ ],
1085
+ [
1086
+ region.x,
1087
+ region.w
1088
+ ],
1089
+ [
1090
+ region.z,
1091
+ region.w
1092
+ ]
1093
+ ]){
1094
+ const r = x * right.x + level * right.y + z * right.z;
1095
+ const v = x * up.x + level * up.y + z * up.z;
1096
+ regionMinR = Math.min(regionMinR, r);
1097
+ regionMaxR = Math.max(regionMaxR, r);
1098
+ regionMinU = Math.min(regionMinU, v);
1099
+ regionMaxU = Math.max(regionMaxU, v);
1100
+ }
1101
+ }
1102
+ // Fit the slice to what the water can actually cast into, instead of always
1103
+ // spending the map on a camera-centred square of `range`, which leaves most
1104
+ // of it permanently zero for a pool far smaller than the range.
1105
+ // `causticsRange` becomes a cap on how far from the camera the map reaches
1106
+ // rather than its literal half-extent.
1107
+ let loR = Math.max(regionMinR, cameraRight - range);
1108
+ let hiR = Math.min(regionMaxR, cameraRight + range);
1109
+ let loU = Math.max(regionMinU, cameraUp - range);
1110
+ let hiU = Math.min(regionMaxU, cameraUp + range);
1111
+ if (hiR <= loR || hiU <= loU) {
1112
+ // The water casts nowhere within range. Nothing will be drawn, but the
1113
+ // slice still has to be finite: the receiver projects through it whatever
1114
+ // the map holds, and the grid bounds below have to come out empty rather
1115
+ // than inverted.
1116
+ loR = cameraRight - range;
1117
+ hiR = cameraRight + range;
1118
+ loU = cameraUp - range;
1119
+ hiU = cameraUp + range;
1120
+ }
1121
+ let halfR = Math.max(MIN_SLICE_EXTENT, (hiR - loR) * 0.5);
1122
+ let halfU = Math.max(MIN_SLICE_EXTENT, (hiU - loU) * 0.5);
1123
+ // The map is square, so an anisotropic slice gives its two axes different
1124
+ // world texel sizes, which reads as directional smearing. Capping the ratio
1125
+ // keeps most of the fit while bounding that, and bounds the same asymmetry
1126
+ // in the edge fade, whose band is a share of each axis.
1127
+ halfR = Math.max(halfR, halfU / MAX_SLICE_ASPECT);
1128
+ halfU = Math.max(halfU, halfR / MAX_SLICE_ASPECT);
1129
+ // Quantise so the extent holds still while the camera moves: texel snapping
1130
+ // below only stops the map crawling if the texel size itself is stable, and
1131
+ // a continuously shrinking slice resamples the temporal history every frame.
1132
+ // The two common cases - a pool wholly inside the range, a sea wholly
1133
+ // covering it - sit on a fixed level and never change at all.
1134
+ halfR = Math.min(range, quantiseSliceExtent(halfR, range));
1135
+ halfU = Math.min(range, quantiseSliceExtent(halfU, range));
1136
+ // Warping only pays where the range, not the water, is what bounds the map.
1137
+ // A slice already fitted to a pool has every texel on water the camera can
1138
+ // see, and concentrating them further would just blur its far side.
1139
+ const rangeLimited = regionMinR < cameraRight - range || regionMaxR > cameraRight + range || regionMinU < cameraUp - range || regionMaxU > cameraUp + range;
1140
+ const warp = rangeLimited ? material.causticsWarp : 0;
1141
+ // Snap the centre so the map does not crawl as the camera moves. Whole
1142
+ // texels when the map is linear, which is exact; a coarse ladder under a
1143
+ // warp, where no shift is exact and the goal is instead to shift rarely.
1144
+ const snapR = warp > 0 ? halfR / WARPED_CENTER_SNAP_DIVISIONS : 2 * halfR / mapSize;
1145
+ const snapU = warp > 0 ? halfU / WARPED_CENTER_SNAP_DIVISIONS : 2 * halfU / mapSize;
1146
+ const centerR = Math.round((loR + hiR) * 0.5 / snapR) * snapR;
1147
+ const centerU = Math.round((loU + hiU) * 0.5 / snapU) * snapU;
1148
+ center.setXYZ(right.x * centerR + up.x * centerU + dir.x * alongDir, right.y * centerR + up.y * centerU + dir.y * alongDir, right.z * centerR + up.z * centerU + dir.z * alongDir);
1149
+ const u = this._uniforms;
1150
+ u.frameX.setXYZW(right.x, right.y, right.z, 1 / halfR);
1151
+ u.frameY.setXYZW(up.x, up.y, up.z, 1 / halfU);
1152
+ u.center.setXYZW(center.x, center.y, center.z, waterLevel);
1153
+ u.lightDir.setXYZW(dir.x, dir.y, dir.z, 1 / Math.max(-dir.y, MIN_SUN_ELEVATION));
1154
+ // Edge fade, expressed to the receiver as the distance from the map centre
1155
+ // the fade begins at, in units of the map half-extent. Resolving it here
1156
+ // keeps the shader free of the extents and lets the auto width put a floor
1157
+ // in meters under a band the map fraction alone would collapse.
1158
+ //
1159
+ // Only a border the range put there needs fading. Where the fit stopped at
1160
+ // the water instead, the border already coincides with the region the
1161
+ // receiver gates on, so a band there is at best a no-op and at worst eats
1162
+ // caustics from the last few meters of a pool the map now fits exactly.
1163
+ const minHalf = Math.min(halfR, halfU);
1164
+ const fadeDistance = !rangeLimited ? 0 : material.causticsFadeDistance > 0 ? material.causticsFadeDistance : Math.max(AUTO_FADE_MIN_DISTANCE, AUTO_FADE_FRACTION * minHalf);
1165
+ const fadeFraction = Math.max(MIN_FADE_FRACTION, Math.min(MAX_FADE_FRACTION, fadeDistance / minHalf));
1166
+ u.params.setXYZW(material.causticsIntensity, material.causticsDepth, material.causticsDefocus, 1 - fadeFraction);
1167
+ u.extinction.setXYZW(0, 0, 0, warp);
1168
+ this._warp = warp;
1169
+ // Retained for the per-body grid fit, which runs per draw rather than here.
1170
+ this._sliceRight = right;
1171
+ this._sliceUp = up;
1172
+ this._sliceCenterR = centerR;
1173
+ this._sliceCenterU = centerU;
1174
+ this._sliceHalfR = halfR;
1175
+ this._sliceHalfU = halfU;
1176
+ // How far the slice reaches from the camera, which is what decides how
1177
+ // coarse a shadow cascade has to be to contain it.
1178
+ this._sliceReach = Math.hypot(halfR, halfU);
1179
+ // The per-body table the receiver searches. Footprint and surface height
1180
+ // decide which body is above a given point; the medium is what attenuates
1181
+ // the light once one is.
1182
+ u.waterCount = Math.min(waters.length, MAX_CAUSTIC_WATERS);
1183
+ for(let i = 0; i < u.waterCount; i++){
1184
+ const body = waters[i];
1185
+ const region = body.material.region;
1186
+ u.waterRegions[i * 4 + 0] = region.x;
1187
+ u.waterRegions[i * 4 + 1] = region.y;
1188
+ u.waterRegions[i * 4 + 2] = region.z;
1189
+ u.waterRegions[i * 4 + 3] = region.w;
1190
+ const ext = body.material.extinction;
1191
+ u.waterMedia[i * 4 + 0] = ext.x;
1192
+ u.waterMedia[i * 4 + 1] = ext.y;
1193
+ u.waterMedia[i * 4 + 2] = ext.z;
1194
+ u.waterMedia[i * 4 + 3] = body.worldMatrix.m13;
1195
+ }
1196
+ }
1197
+ /**
1198
+ * Map-NDC bounds of one body's footprint, so its photon grid covers just that.
1199
+ *
1200
+ * Projecting into the slice is invariant along the light and the slice is
1201
+ * normal to it, so a point on the water plane and the photon it launches share
1202
+ * a map position: the body's light-space bounds carry over directly. Clamped
1203
+ * to the map, because photons outside it are rasterised away and the fraction
1204
+ * has to describe what is left. Warped, because the grid is laid out in the
1205
+ * space the map's texels live in - the warp is monotone and separable, so
1206
+ * warping the interval's ends gives the warped interval exactly.
1207
+ * @internal
1208
+ */ _updateGridBounds(water) {
1209
+ const region = water.material.region;
1210
+ const level = water.worldMatrix.m13;
1211
+ const right = this._sliceRight;
1212
+ const up = this._sliceUp;
1213
+ let minR = Infinity;
1214
+ let maxR = -Infinity;
1215
+ let minU = Infinity;
1216
+ let maxU = -Infinity;
1217
+ for (const [x, z] of [
1218
+ [
1219
+ region.x,
1220
+ region.y
1221
+ ],
1222
+ [
1223
+ region.z,
1224
+ region.y
1225
+ ],
1226
+ [
1227
+ region.x,
1228
+ region.w
1229
+ ],
1230
+ [
1231
+ region.z,
1232
+ region.w
1233
+ ]
1234
+ ]){
1235
+ const r = x * right.x + level * right.y + z * right.z;
1236
+ const v = x * up.x + level * up.y + z * up.z;
1237
+ minR = Math.min(minR, r);
1238
+ maxR = Math.max(maxR, r);
1239
+ minU = Math.min(minU, v);
1240
+ maxU = Math.max(maxU, v);
1241
+ }
1242
+ const warp = this._warp;
1243
+ // Clamped to the map plus a margin, but never past the water itself. Where
1244
+ // the footprint ends inside the map the bound stays on the water, because a
1245
+ // photon launched off the water is killed by the region test and would only
1246
+ // dilute the weight the fraction below hands out. Where the water carries on
1247
+ // past the border, the margin lets the displaced surface throw photons back
1248
+ // in across it - see GRID_BORDER_MARGIN.
1249
+ const m = 1 + GRID_BORDER_MARGIN;
1250
+ const lo = (v)=>Math.max(-1.15, Math.min(m, v));
1251
+ const minX = warpCausticNDC(lo((minR - this._sliceCenterR) / this._sliceHalfR), warp);
1252
+ const maxX = warpCausticNDC(lo((maxR - this._sliceCenterR) / this._sliceHalfR), warp);
1253
+ const minY = warpCausticNDC(lo((minU - this._sliceCenterU) / this._sliceHalfU), warp);
1254
+ const maxY = warpCausticNDC(lo((maxU - this._sliceCenterU) / this._sliceHalfU), warp);
1255
+ this._gridBounds.setXYZW(minX, minY, maxX, maxY);
1256
+ // Share of the map the grid covers, which is what keeps calm water at 1.0:
1257
+ // the same photon count spread over a smaller area has to deposit
1258
+ // proportionally less each.
1259
+ this._gridFraction = Math.max(0, (maxX - minX) * (maxY - minY) / 4);
1260
+ }
1261
+ /** @internal */ _updatePhotonLayout(device, gridSize) {
1262
+ this._photonCount = gridSize * gridSize;
1263
+ if (this._photonCapacity >= this._photonCount) {
1264
+ return;
1265
+ }
1266
+ // Grow only, and never rebuild for a size change.
1267
+ //
1268
+ // The buffer holds each photon's ordinal, and the shader divides that
1269
+ // by the grid size to place it.
1270
+ const capacity = Math.max(this._photonCount, this._photonCapacity * 2);
1271
+ const indices = new Float32Array(capacity);
1272
+ for(let i = 0; i < capacity; i++){
1273
+ indices[i] = i;
1274
+ }
1275
+ // WebGL2 refuses to draw without a bound vertex layout, so the ordinal is
1276
+ // materialised as an attribute rather than read from the vertex index.
1277
+ const buffer = device.createVertexBuffer('position_f32', indices);
1278
+ this._photonLayout?.dispose();
1279
+ this._photonBuffer?.dispose();
1280
+ this._photonBuffer = buffer;
1281
+ this._photonLayout = device.createVertexLayout({
1282
+ vertexBuffers: [
1283
+ {
1284
+ buffer
1285
+ }
1286
+ ]
1287
+ });
1288
+ this._photonCapacity = capacity;
1289
+ }
1290
+ /** @internal */ _getSplatStates(device) {
1291
+ if (!this._splatStates) {
1292
+ this._splatStates = device.createRenderStateSet();
1293
+ this._splatStates.useRasterizerState().setCullMode('none');
1294
+ this._splatStates.useDepthState().enableTest(false).enableWrite(false);
1295
+ // Photon accumulation: every splat adds to whatever is already there.
1296
+ this._splatStates.useBlendingState().enable(true).setBlendFunc('one', 'one').setBlendEquation('add', 'add');
1297
+ }
1298
+ return this._splatStates;
1299
+ }
1300
+ /** @internal */ _getHeightStates(device, slot) {
1301
+ let states = this._heightStates[slot];
1302
+ if (!states) {
1303
+ states = device.createRenderStateSet();
1304
+ states.useRasterizerState().setCullMode('none');
1305
+ states.useDepthState().enableTest(false).enableWrite(false);
1306
+ // Plain overwrite into this slot's channel only. Blending would add the
1307
+ // heights wherever the surface overlaps itself in the light's view.
1308
+ states.useColorState().setColorMask(slot === 0, slot === 1, slot === 2, slot === 3);
1309
+ this._heightStates[slot] = states;
1310
+ }
1311
+ return states;
1312
+ }
1313
+ /** @internal */ _getHeightProgram(device, waveGenerator) {
1314
+ const key = waveGenerator.getHash();
1315
+ let info = this._heightPrograms.get(key);
1316
+ if (info) {
1317
+ return info;
1318
+ }
1319
+ const program = device.buildRenderProgram(createCausticHeightShader(waveGenerator));
1320
+ program.name = '@Water_CausticHeight';
1321
+ info = {
1322
+ program,
1323
+ bindGroups: []
1324
+ };
1325
+ this._heightPrograms.set(key, info);
1326
+ return info;
1327
+ }
1328
+ /** @internal */ _getBlurStates(device) {
1329
+ if (!this._blurStates) {
1330
+ this._blurStates = device.createRenderStateSet();
1331
+ this._blurStates.useRasterizerState().setCullMode('none');
1332
+ this._blurStates.useDepthState().enableTest(false).enableWrite(false);
1333
+ }
1334
+ return this._blurStates;
1335
+ }
1336
+ /** @internal */ _getSplatProgram(device, waveGenerator, sceneDepth) {
1337
+ const key = `${waveGenerator.getHash()}:${sceneDepth ? 1 : 0}`;
1338
+ let info = this._splatPrograms.get(key);
1339
+ if (info) {
1340
+ return info;
1341
+ }
1342
+ const program = device.buildRenderProgram(createCausticSplatShader(waveGenerator, sceneDepth));
1343
+ program.name = `@Water_CausticSplat${sceneDepth ? '_SceneDepth' : ''}`;
1344
+ info = {
1345
+ program,
1346
+ bindGroups: []
1347
+ };
1348
+ this._splatPrograms.set(key, info);
1349
+ return info;
1350
+ }
1351
+ /**
1352
+ * The bind group for one water slot of a splat program.
1353
+ * @internal
1354
+ */ _getSplatBindGroup(device, info, slot) {
1355
+ let bindGroup = info.bindGroups[slot];
1356
+ if (!bindGroup) {
1357
+ bindGroup = device.createBindGroup(info.program.bindGroupLayouts[0]);
1358
+ info.bindGroups[slot] = bindGroup;
1359
+ }
1360
+ return bindGroup;
1361
+ }
1362
+ /**
1363
+ * Whether any body asks for the scene-depth intersection, which decides
1364
+ * whether the shadow cascade needs resolving at all.
1365
+ * @internal
1366
+ */ _anySceneDepth(waters) {
1367
+ return waters.some((w)=>w.material.causticsSceneDepth);
1368
+ }
1369
+ /** @internal */ _getBlurProgram(device) {
1370
+ if (!this._blurProgram) {
1371
+ this._blurProgram = device.buildRenderProgram(createCausticBlurShader());
1372
+ this._blurProgram.name = '@Water_CausticBlur';
1373
+ this._blurBindGroup = device.createBindGroup(this._blurProgram.bindGroupLayouts[0]);
1374
+ }
1375
+ return this._blurProgram;
1376
+ }
1377
+ }
1378
+
1379
+ export { WaterCausticsRenderer, createCausticBlurShader, createCausticHeightShader, createCausticResolveShader, createCausticSplatShader };
1380
+ //# sourceMappingURL=water_caustics.js.map