@yali-engine/water 0.11.0 → 0.13.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.
package/dist/water.js CHANGED
@@ -1,8 +1,40 @@
1
+ import { bakeShore, toHalf } from "./shore.js";
1
2
  import { waterSurface } from "./water-shaders.gen.js";
2
3
  import { WaterWaves } from "./waves.js";
4
+ /**
5
+ * The surface grid's index buffer (ADR-0222): quad (r, s) of the (rings + 1) × (segments + 1) lattice as (r, s) (r+1, s)
6
+ * (r+1, s+1) and (r, s) (r+1, s+1) (r, s+1).
7
+ */
8
+ function gridIndices(device, rings, segments) {
9
+ const w = segments + 1;
10
+ const idx = new Uint32Array(rings * segments * 6);
11
+ let k = 0;
12
+ for (let r = 0; r < rings; r++) {
13
+ for (let s = 0; s < segments; s++) {
14
+ const a = r * w + s;
15
+ const b = (r + 1) * w + s;
16
+ idx[k++] = a;
17
+ idx[k++] = b;
18
+ idx[k++] = b + 1;
19
+ idx[k++] = a;
20
+ idx[k++] = b + 1;
21
+ idx[k++] = a + 1;
22
+ }
23
+ }
24
+ const buf = device.createBuffer({ label: 'yali:water:grid', size: idx.byteLength, usage: 0x10 | 0x08 });
25
+ device.queue.writeBuffer(buf, 0, idx);
26
+ return buf;
27
+ }
28
+ const TIERS = { high: 2, medium: 1, low: 0 };
3
29
  /** Bytes of `SurfaceParams` (`shaders/water/surface.wesl`): 4 cascades + 8 vectors. */
4
- const PARAMS_BYTES = (4 + 8) * 16;
30
+ const PARAMS_BYTES = (4 + 8 + 4) * 16;
31
+ /** The motion uniform (ADR-0210): this frame's and last frame's view-projections (unjittered), the eye's move, a flag. */
32
+ const MOTION_BYTES = 16 * 4 * 2 + 16;
5
33
  /** The first ring's spacing (m) and how much wider each next ring is. */
34
+ /** The surf field's texels a side (ADR-0223): 0.5 m over the demo's 256 m shore field. */
35
+ const SURF_TEXELS = 512;
36
+ /** Spray sprites (`SPRAY_SIDE`² in surface.wesl, ADR-0218). */
37
+ const SPRAY_SPRITES = 192 * 192;
6
38
  const FIRST_RING = 0.12;
7
39
  const RING_GROWTH = 0.036;
8
40
  /**
@@ -39,18 +71,91 @@ export class Water {
39
71
  debugMirror = false;
40
72
  /** Off: nothing is drawn or computed (live). */
41
73
  enabled = true;
74
+ /** The waves' own motion for TAA (ADR-0210; live): off, TAA sees the surface as still (the camera's motion only). */
75
+ motion = true;
76
+ /** The surf's height in deep water, metres (ADR-0213; live). */
77
+ surfHeight;
78
+ /** The breaking ratio γ: a wave breaks when it is taller than γ × the depth (0.78, live). */
79
+ surfBreaking;
80
+ /** How far crests lean shoreward before they break (Gerstner's Q, 0–1, live). */
81
+ surfLean;
82
+ /** The depth (m) at which the surf rises out of the sea, and over which the open sea's waves calm (live). */
83
+ surfStart;
84
+ surfCalm;
85
+ /** The surf's period, seconds: set before `setSeabed` (the crests' phase is baked for it). */
86
+ surfPeriod;
87
+ /**
88
+ * How much waves differ (ADR-0218; live, 0–1): 1 = swell in sets, each crest's height varying along the shore so it breaks
89
+ * in sections; 0 = every wave alike.
90
+ */
91
+ surfVariety;
92
+ /** How far a crest that starts to break throws its lip forward (ADR-0218; live, 0–1). */
93
+ surfLip;
94
+ /** Spray thrown up by breaking waves (ADR-0218; live, 0 = none, 1 = default). */
95
+ spray;
96
+ /** The ripples finer than the cascades (ADR-0223; live, 0 none, 1 Cox & Munk's slope for the wind). */
97
+ ripples = 1;
98
+ #quality = 'high';
99
+ /**
100
+ * The quality tier (ADR-0220; live): `high` everything; `medium` the coarse foam pattern only, 16 reflection steps, half
101
+ * the spray; `low` no caustics, no scene reflections, no spray. Setting it sets those knobs (they stay live after).
102
+ */
103
+ get quality() {
104
+ return this.#quality;
105
+ }
106
+ set quality(q) {
107
+ if (!(q in TIERS))
108
+ throw Object.assign(new Error(`WATER_INVALID: quality ${q} is not high, medium or low`), { code: 'WATER_INVALID', path: 'quality' });
109
+ this.#quality = q;
110
+ this.reflectionSteps = q === 'high' ? 24 : 16;
111
+ this.reflections = q === 'low' ? 0 : 40;
112
+ this.spray = q === 'high' ? 1 : q === 'medium' ? 0.5 : 0;
113
+ }
114
+ #prevSurfTime = 0;
115
+ #shoreField = null;
116
+ #shoreTexture;
117
+ #spillTexture;
118
+ #prevClock = 0;
42
119
  rings;
43
120
  segments;
44
121
  #pipeline;
122
+ #sprayPipeline = null;
123
+ #sprayGroups = [null, null];
124
+ #sprayKey = '';
125
+ /**
126
+ * The surf field (ADR-0223): A (displacement, broken) and D (front, phase, height factor, carried whitewater) in
127
+ * ping-pong pairs, B (slope, face, onset) and C (fresh foam, swash cover, wet, film); `SURF_TEXELS`² over the shore field.
128
+ */
129
+ #surfA = [];
130
+ #surfB = null;
131
+ #surfC = null;
132
+ #surfD = [];
133
+ #surfPipeline = null;
134
+ #surfGroups = [null, null];
135
+ /** Which half of each pair this frame's surf field wrote. */
136
+ #surfParity = 0;
137
+ #motionPipeline = null;
138
+ #motionGroups = [null, null];
139
+ #motionParams;
140
+ #motionData = new Float32Array(MOTION_BYTES / 4);
141
+ #prevVP = new Float32Array(16);
142
+ #prevEye = new Float64Array(3);
143
+ /** Frames with last frame's waves kept (motion needs two). */
144
+ #motionFrames = 0;
145
+ /** Last frame's displacement, for the waves' motion (ADR-0210); made when TAA asks for motion. */
146
+ #prevDisplacement = null;
147
+ #scene = null;
45
148
  #copyDepth;
46
149
  #depthGroup = null;
47
150
  #under = false;
48
151
  #inv = 1;
49
152
  #params;
153
+ /** The grid's triangles (ADR-0222): two a quad over the (rings + 1) × (segments + 1) lattice of points. */
154
+ #indices;
50
155
  #data = new Float32Array(PARAMS_BYTES / 4);
51
156
  #sampler;
52
157
  #clamp;
53
- #group = null;
158
+ #groups = [null, null];
54
159
  #groupKey = '';
55
160
  #depthKey = -1;
56
161
  #seconds = 0;
@@ -70,9 +175,26 @@ export class Water {
70
175
  this.shoreFoam = o.shoreFoam ?? 0.12;
71
176
  this.reflections = o.reflections ?? 40;
72
177
  this.reflectionSteps = o.reflectionSteps ?? 24;
178
+ this.surfHeight = o.surf?.height ?? 0.9;
179
+ this.surfBreaking = o.surf?.breaking ?? 0.78;
180
+ this.surfLean = o.surf?.lean ?? 0.55;
181
+ this.surfStart = o.surf?.start ?? 7;
182
+ this.surfCalm = o.surf?.calm ?? 4;
183
+ this.surfPeriod = o.surf?.period ?? 8;
184
+ this.surfVariety = o.surf?.variety ?? 1;
185
+ this.surfLip = o.surf?.lip ?? 1;
186
+ this.spray = o.surf?.spray ?? 1;
187
+ this.#shoreTexture = device.createTexture({ label: 'yali:water:shore', size: [1, 1], format: 'rgba16float', usage: 0x04 | 0x02 });
188
+ this.#spillTexture = device.createTexture({ label: 'yali:water:spill', size: [1, 1], format: 'rg16float', usage: 0x04 | 0x02 });
189
+ if (o.quality)
190
+ this.quality = o.quality;
73
191
  this.rings = o.rings ?? 240;
74
192
  this.segments = o.segments ?? 384;
75
193
  this.#params = device.createBuffer({ label: 'yali:water:surface', size: PARAMS_BYTES, usage: 0x40 | 0x08 });
194
+ this.#indices = gridIndices(device, this.rings, this.segments);
195
+ // Stand-ins until a seabed brings the surf (the shaders name the field's bindings either way).
196
+ this.#makeSurfField(1);
197
+ this.#motionParams = device.createBuffer({ label: 'yali:water:motion', size: MOTION_BYTES, usage: 0x40 | 0x08 });
76
198
  this.#clamp = device.createSampler({ label: 'yali:water:clamp', magFilter: 'linear', minFilter: 'linear' });
77
199
  this.#sampler = device.createSampler({ label: 'yali:water:wrap', addressModeU: 'repeat', addressModeV: 'repeat', magFilter: 'linear', minFilter: 'linear', mipmapFilter: 'linear' });
78
200
  }
@@ -88,12 +210,35 @@ export class Water {
88
210
  }),
89
211
  this.waves.init(cache),
90
212
  cache.compute({ label: 'yali:water:copy-depth', compute: { shader: waterSurface, entryPoint: 'copy_depth' } }).then((p) => (this.#copyDepth = p)),
213
+ // The surf field (ADR-0223).
214
+ cache.compute({ label: 'yali:water:surf-field', compute: { shader: waterSurface, entryPoint: 'surf_field' } }).then((p) => (this.#surfPipeline = p)),
215
+ // Spray (ADR-0218): soft sprites over the surface, premultiplied, depth-tested, never written.
216
+ cache
217
+ .render({
218
+ label: 'yali:water:spray',
219
+ vertex: { shader: waterSurface, entryPoint: 'vs_spray' },
220
+ fragment: { shader: waterSurface, entryPoint: 'fs_spray', targets: [{ format, blend: { color: { srcFactor: 'one', dstFactor: 'one-minus-src-alpha' }, alpha: { srcFactor: 'one', dstFactor: 'one-minus-src-alpha' } } }] },
221
+ primitive: { topology: 'triangle-list', cullMode: 'none' },
222
+ depthStencil: { format: 'depth32float', depthWriteEnabled: false, depthCompare: 'greater' },
223
+ })
224
+ .then((p) => (this.#sprayPipeline = p)),
225
+ // The waves' motion for TAA (ADR-0210): the same grid drawn into the motion target.
226
+ cache
227
+ .render({
228
+ label: 'yali:water:motion',
229
+ vertex: { shader: waterSurface, entryPoint: 'vs_motion' },
230
+ fragment: { shader: waterSurface, entryPoint: 'fs_motion', targets: [{ format: 'rgba16float' }] },
231
+ primitive: { topology: 'triangle-list', cullMode: 'none' },
232
+ depthStencil: { format: 'depth32float', depthWriteEnabled: true, depthCompare: 'greater' },
233
+ })
234
+ .then((p) => (this.#motionPipeline = p)),
91
235
  ]);
92
236
  this.#pipeline = pipeline;
93
237
  }
94
238
  prepare(f) {
95
239
  this.#seconds = this.frozenAt ?? f.seconds;
96
240
  this.#env = f.environment;
241
+ this.waves.foamThreshold = this.foamThreshold;
97
242
  const d = this.#data;
98
243
  const eye = f.camera.eye;
99
244
  const n = this.waves.tiles.length;
@@ -111,17 +256,239 @@ export class Water {
111
256
  d.set([this.level - eye[1], n, this.foamThreshold, this.foam], 20);
112
257
  d.set([...this.bodyColor, this.roughness], 24);
113
258
  d.set([...this.subsurfaceColor, this.subsurface], 28);
114
- d.set([this.waves.size, 0, 0, 0], 32);
259
+ d.set([this.waves.size, this.spray, f.environment.cloudPanorama ? 1 : 0, TIERS[this.#quality]], 32);
115
260
  d.set([...this.absorption, this.refraction], 36);
116
261
  // The scene behind is read at the pre-exposure it was written with (this frame's).
117
262
  this.#inv = 1 / Math.max(1e-6, f.scene.preExposure);
118
- d.set([this.shoreFoam, this.#under ? 1 : 0, this.#inv, 0], 40);
263
+ // The ripples finer than the cascades (ADR-0223) make up the slope variance Cox & Munk measured for this wind
264
+ // (0.003 + 0.00512 U in all, half an axis) that the cascades leave out; their gain over the finest cascade's own.
265
+ const sv = this.waves.slopeVariance;
266
+ const have = sv.reduce((a, b) => a + b, 0);
267
+ const missing = Math.max(0, (0.003 + 0.00512 * this.waves.wind) / 2 - have);
268
+ const ripples = this.ripples * Math.min(8, Math.sqrt(missing / Math.max(1e-9, sv[sv.length - 1] * 0.74)));
269
+ d.set([this.shoreFoam, this.#under ? 1 : 0, this.#inv, ripples], 40);
119
270
  d.set([this.reflections, this.reflectionSteps, 0.6, this.debugMirror ? 1 : 0], 44);
271
+ // The surf (ADR-0213): the eye on the shore field (f64 here, small after), the crest clock now and last frame.
272
+ const sf = this.#shoreField;
273
+ const period = this.surfPeriod;
274
+ const clock = 2 * Math.PI * ((((this.#seconds % period) + period) % period) / period);
275
+ if (sf) {
276
+ const extent = sf.size * sf.cell;
277
+ d.set([(eye[0] - sf.origin[0] + sf.cell / 2) / extent, (eye[2] - sf.origin[1] + sf.cell / 2) / extent, 1 / extent, 1], 48);
278
+ }
279
+ else
280
+ d.set([0, 0, 0, 0], 48);
281
+ d.set([this.surfHeight, period, this.surfBreaking, this.surfLean], 52);
282
+ d.set([clock, this.surfCalm, this.surfStart, this.#prevClock], 56);
283
+ this.#prevClock = clock;
284
+ // The surf's time (ADR-0218), wrapped at 4096 periods: with the arrival time it numbers each wave.
285
+ const span = period * 4096;
286
+ const surfTime = ((this.#seconds % span) + span) % span;
287
+ d.set([surfTime, this.surfVariety, this.surfLip, this.#prevSurfTime], 60);
288
+ this.#prevSurfTime = surfTime;
120
289
  this.device.queue.writeBuffer(this.#params, 0, d);
290
+ if (this.enabled)
291
+ this.#runSurfField();
292
+ if (this.#prevDisplacement) {
293
+ // Motion: this frame's view-projection (camera-relative, unjittered: `camera.uniform`), last frame's, and how far
294
+ // the eye moved (last frame's camera-relative points are this frame's plus it).
295
+ const m = this.#motionData;
296
+ const vp = f.camera.uniform;
297
+ for (let i = 0; i < 16; i++)
298
+ m[i] = vp[i];
299
+ m.set(this.#prevVP, 16);
300
+ for (let i = 0; i < 3; i++)
301
+ m[32 + i] = eye[i] - this.#prevEye[i];
302
+ // Until last frame's waves exist (two frames), the waves count as still: the camera's motion only.
303
+ m[35] = this.motion && this.#motionFrames >= 2 && this.frozenAt === null ? 1 : 0;
304
+ this.device.queue.writeBuffer(this.#motionParams, 0, m);
305
+ for (let i = 0; i < 16; i++)
306
+ this.#prevVP[i] = vp[i];
307
+ for (let i = 0; i < 3; i++)
308
+ this.#prevEye[i] = eye[i];
309
+ }
310
+ }
311
+ /** The surf field's textures (ADR-0223), made with the seabed: four rgba16float targets, A and D in pairs. */
312
+ #makeSurfField(texels) {
313
+ for (const t of [...this.#surfA, ...this.#surfD, this.#surfB, this.#surfC])
314
+ t?.destroy();
315
+ const make = (label) => this.device.createTexture({ label, size: [texels, texels], format: 'rgba16float', usage: 0x08 | 0x04 });
316
+ this.#surfA = [make('yali:water:surf-a0'), make('yali:water:surf-a1')];
317
+ this.#surfB = make('yali:water:surf-b');
318
+ this.#surfC = make('yali:water:surf-c');
319
+ this.#surfD = [make('yali:water:surf-d0'), make('yali:water:surf-d1')];
320
+ }
321
+ /** The surf field as the surface, motion and spray passes read it: this frame's half `p` and last frame's. */
322
+ #surfEntries(p) {
323
+ if (!this.#surfB)
324
+ return [];
325
+ return [
326
+ { binding: 17, resource: this.#surfA[p].createView() },
327
+ { binding: 18, resource: this.#surfB.createView() },
328
+ { binding: 19, resource: this.#surfC.createView() },
329
+ { binding: 20, resource: this.#surfD[p].createView() },
330
+ // Last frame's displacement (the motion pass's; auto layouts keep it wherever `surf_tex` is compiled in). Last
331
+ // frame's whitewater (22) only the surf field reads.
332
+ { binding: 21, resource: this.#surfA[1 - p].createView() },
333
+ ];
334
+ }
335
+ /**
336
+ * The surf field for this frame (ADR-0223), in its own submission before the frame's passes (TAA's motion pass may come
337
+ * before a feature's own): `surf_field` writes A and D's other half from last frame's D.
338
+ */
339
+ #runSurfField() {
340
+ if (!this.#surfPipeline || !this.#surfB || !this.#shoreField)
341
+ return;
342
+ const p = 1 - this.#surfParity;
343
+ this.#surfParity = p;
344
+ this.#surfGroups[p] ??= this.device.createBindGroup({
345
+ label: 'yali:water:surf-field',
346
+ layout: this.#surfPipeline.getBindGroupLayout(0),
347
+ entries: [
348
+ { binding: 2, resource: { buffer: this.#params } },
349
+ { binding: 9, resource: this.#clamp },
350
+ { binding: 14, resource: this.#shoreTexture.createView() },
351
+ { binding: 15, resource: this.#spillTexture.createView() },
352
+ { binding: 22, resource: this.#surfD[1 - p].createView() },
353
+ { binding: 23, resource: this.#surfA[p].createView() },
354
+ { binding: 24, resource: this.#surfB.createView() },
355
+ { binding: 25, resource: this.#surfC.createView() },
356
+ { binding: 26, resource: this.#surfD[p].createView() },
357
+ ],
358
+ });
359
+ const enc = this.device.createCommandEncoder({ label: 'yali:water:surf-field' });
360
+ const pass = enc.beginComputePass({ label: 'yali:water:surf-field' });
361
+ pass.setPipeline(this.#surfPipeline);
362
+ pass.setBindGroup(0, this.#surfGroups[p]);
363
+ pass.dispatchWorkgroups(SURF_TEXELS / 8, SURF_TEXELS / 8);
364
+ pass.end();
365
+ this.device.queue.submit([enc.finish()]);
366
+ }
367
+ /**
368
+ * The surf field's GPU cost, ms a frame (ADR-0223): one submission of `frames` + 1 dispatches less one of 1, over
369
+ * `frames`, each timed to `onSubmittedWorkDone` (the render loop's own frames land in either window, and the difference
370
+ * cancels them; the field runs in its own submission, which the frame graph's timers do not see). Null without a seabed.
371
+ *
372
+ * @example
373
+ * const ms = await ocean.measureSurf();
374
+ */
375
+ async measureSurf(frames = 20) {
376
+ const p = this.#surfParity;
377
+ const group = this.#surfGroups[p];
378
+ if (!this.#surfPipeline || !group)
379
+ return null;
380
+ const run = async (n) => {
381
+ const enc = this.device.createCommandEncoder({ label: 'yali:water:surf-measure' });
382
+ const pass = enc.beginComputePass();
383
+ pass.setPipeline(this.#surfPipeline);
384
+ pass.setBindGroup(0, group);
385
+ for (let i = 0; i < n; i++)
386
+ pass.dispatchWorkgroups(SURF_TEXELS / 8, SURF_TEXELS / 8);
387
+ pass.end();
388
+ await this.device.queue.onSubmittedWorkDone();
389
+ const t0 = performance.now();
390
+ this.device.queue.submit([enc.finish()]);
391
+ await this.device.queue.onSubmittedWorkDone();
392
+ return performance.now() - t0;
393
+ };
394
+ await run(2);
395
+ const small = await run(1);
396
+ return Math.max(0, (await run(frames + 1)) - small) / frames;
397
+ }
398
+ /**
399
+ * The seabed under the water (ADR-0213): bakes the shore field (`bakeShore`: depth, the crests' arrival phase for
400
+ * `surfPeriod`, their direction) and uploads it; the surf then rises, bends to the beach and breaks. Call again after
401
+ * changing `surfPeriod` or the seabed.
402
+ *
403
+ * @example
404
+ * ocean.setSeabed({ heights, size: 256, cell: 1, origin: [-128, -198] });
405
+ */
406
+ setSeabed(seabed) {
407
+ const field = bakeShore(seabed, this.level, this.surfPeriod);
408
+ const half = new Uint16Array(field.data.length);
409
+ for (let i = 0; i < half.length; i++)
410
+ half[i] = toHalf(field.data[i]);
411
+ this.#shoreTexture.destroy();
412
+ this.#shoreTexture = this.device.createTexture({ label: 'yali:water:shore', size: [field.size, field.size], format: 'rgba16float', usage: 0x04 | 0x02 });
413
+ this.device.queue.writeTexture({ texture: this.#shoreTexture }, half, { bytesPerRow: field.size * 8 }, [field.size, field.size]);
414
+ // The lowest passes to the sea (ADR-0216), clamped to what a half float holds, and the crests' arrival time (s): with
415
+ // the exact phase it numbers each wave, so waves come in sets and break in sections (ADR-0218).
416
+ const spill = new Uint16Array(field.spill.length * 2);
417
+ for (let i = 0; i < field.spill.length; i++) {
418
+ spill[i * 2] = toHalf(Math.min(field.spill[i], 6e4));
419
+ const t = field.arrival[i];
420
+ spill[i * 2 + 1] = toHalf(Number.isFinite(t) ? Math.min(t, 6e4) : 0);
421
+ }
422
+ this.#spillTexture.destroy();
423
+ this.#spillTexture = this.device.createTexture({ label: 'yali:water:spill', size: [field.size, field.size], format: 'rg16float', usage: 0x04 | 0x02 });
424
+ this.device.queue.writeTexture({ texture: this.#spillTexture }, spill, { bytesPerRow: field.size * 4 }, [field.size, field.size]);
425
+ this.#shoreField = field;
426
+ this.#groups = [null, null];
427
+ this.#groupKey = '';
428
+ this.#motionGroups = [null, null];
429
+ this.#sprayGroups = [null, null];
430
+ this.#surfGroups = [null, null];
431
+ this.#makeSurfField(SURF_TEXELS);
432
+ return field;
433
+ }
434
+ /** The baked shore field, or null before `setSeabed`. */
435
+ get shoreField() {
436
+ return this.#shoreField;
437
+ }
438
+ addMotionPasses(graph, motion, depth) {
439
+ const disp = this.waves.displacement;
440
+ this.#prevDisplacement ??= this.device.createTexture({ label: 'yali:water:displacement-prev', size: [disp.width, disp.height, disp.depthOrArrayLayers], format: disp.format, usage: 0x04 | 0x02 });
441
+ this.#motionGroups = [null, null];
442
+ graph.addPass({
443
+ name: 'water.motion',
444
+ kind: 'render',
445
+ reads: ['water.waves'],
446
+ color: { target: motion, clear: { r: 0, g: 0, b: 0, a: 0 }, load: true },
447
+ depth: { target: depth, clear: 0, load: true, store: true },
448
+ execute: (pass) => {
449
+ if (!this.enabled || !this.#motionPipeline || !this.#scene)
450
+ return;
451
+ const mp = this.#surfParity;
452
+ this.#motionGroups[mp] ??= this.device.createBindGroup({
453
+ label: 'yali:water:motion',
454
+ layout: this.#motionPipeline.getBindGroupLayout(0),
455
+ entries: [
456
+ { binding: 0, resource: { buffer: this.#scene.cameraBuffer } },
457
+ { binding: 2, resource: { buffer: this.#params } },
458
+ { binding: 3, resource: disp.createView({ dimension: '2d-array' }) },
459
+ { binding: 5, resource: this.#sampler },
460
+ { binding: 12, resource: this.#prevDisplacement.createView({ dimension: '2d-array' }) },
461
+ { binding: 13, resource: { buffer: this.#motionParams } },
462
+ { binding: 9, resource: this.#clamp },
463
+ { binding: 14, resource: this.#shoreTexture.createView() },
464
+ { binding: 15, resource: this.#spillTexture.createView() },
465
+ ...this.#surfEntries(mp),
466
+ ],
467
+ });
468
+ pass.setPipeline(this.#motionPipeline);
469
+ pass.setBindGroup(0, this.#motionGroups[mp]);
470
+ pass.setIndexBuffer(this.#indices, 'uint32');
471
+ pass.drawIndexed(this.rings * this.segments * 6);
472
+ },
473
+ });
474
+ // After TAA has read this frame's motion, keep this frame's waves for the next frame's.
475
+ graph.addPass({
476
+ name: 'water.keepWaves',
477
+ kind: 'encoder',
478
+ reads: ['water.motion'],
479
+ writes: [motion],
480
+ execute: (encoder) => {
481
+ if (!this.enabled)
482
+ return;
483
+ encoder.copyTextureToTexture({ texture: disp }, { texture: this.#prevDisplacement }, [disp.width, disp.height, disp.depthOrArrayLayers]);
484
+ this.#motionFrames++;
485
+ },
486
+ });
121
487
  }
122
488
  addPasses(graph, color, depth, scene) {
123
489
  // What the surface looks through (ADR-0199): the opaque scene's colour and depth, copied before it draws.
124
490
  this.#under = true;
491
+ this.#scene = scene;
125
492
  graph.transientTexture('water.under', { format: 'rgba16float', usage: 0x02 | 0x04 });
126
493
  graph.transientTexture('water.depth', { format: 'r32float', usage: 0x08 | 0x04 });
127
494
  graph.addPass({
@@ -171,10 +538,15 @@ export class Water {
171
538
  const env = this.#env;
172
539
  if (!this.enabled || !env)
173
540
  return;
174
- const key = `${env.version}:${ctx.generation}`;
175
- if (!this.#group || key !== this.#groupKey) {
541
+ const pano = env.cloudPanorama;
542
+ const key = `${env.version}:${ctx.generation}:${pano ? pano.label : '-'}`;
543
+ const gp = this.#surfParity;
544
+ if (key !== this.#groupKey) {
176
545
  this.#groupKey = key;
177
- this.#group = this.device.createBindGroup({
546
+ this.#groups = [null, null];
547
+ }
548
+ if (!this.#groups[gp]) {
549
+ this.#groups[gp] = this.device.createBindGroup({
178
550
  label: 'yali:water:surface',
179
551
  layout: this.#pipeline.getBindGroupLayout(0),
180
552
  entries: [
@@ -188,15 +560,185 @@ export class Water {
188
560
  { binding: 7, resource: ctx.view('water.under') },
189
561
  { binding: 8, resource: ctx.view('water.depth') },
190
562
  { binding: 9, resource: this.#clamp },
563
+ { binding: 14, resource: this.#shoreTexture.createView() },
564
+ { binding: 15, resource: this.#spillTexture.createView() },
565
+ // The clouds' panorama (ADR-0219), or a stand-in the shader never reads (size.z = 0).
566
+ { binding: 16, resource: (pano ?? this.#spillTexture).createView() },
567
+ ...this.#surfEntries(gp),
191
568
  ],
192
569
  });
193
570
  }
194
571
  pass.setPipeline(this.#pipeline);
195
- pass.setBindGroup(0, this.#group);
196
- pass.draw(this.rings * this.segments * 6);
572
+ pass.setBindGroup(0, this.#groups[gp]);
573
+ pass.setIndexBuffer(this.#indices, 'uint32');
574
+ pass.drawIndexed(this.rings * this.segments * 6);
575
+ },
576
+ });
577
+ // Spray over the surface (ADR-0218): drawn only with a seabed (the surf), one instance per sprite.
578
+ graph.addPass({
579
+ name: 'water.spray',
580
+ kind: 'render',
581
+ reads: ['water.waves', 'water.depth'],
582
+ color: { target: color, clear: { r: 0, g: 0, b: 0, a: 0 }, load: true },
583
+ depth: { target: depth, clear: 0, load: true, store: true },
584
+ execute: (pass, ctx) => {
585
+ if (!this.enabled || this.spray <= 0 || !this.#shoreField || !this.#sprayPipeline)
586
+ return;
587
+ const key = `${ctx.generation}`;
588
+ const sq = this.#surfParity;
589
+ if (key !== this.#sprayKey) {
590
+ this.#sprayKey = key;
591
+ this.#sprayGroups = [null, null];
592
+ }
593
+ if (!this.#sprayGroups[sq]) {
594
+ this.#sprayGroups[sq] = this.device.createBindGroup({
595
+ label: 'yali:water:spray',
596
+ layout: this.#sprayPipeline.getBindGroupLayout(0),
597
+ entries: [
598
+ { binding: 0, resource: { buffer: scene.cameraBuffer } },
599
+ { binding: 1, resource: { buffer: scene.lightingBuffer } },
600
+ { binding: 2, resource: { buffer: this.#params } },
601
+ { binding: 8, resource: ctx.view('water.depth') },
602
+ { binding: 9, resource: this.#clamp },
603
+ { binding: 14, resource: this.#shoreTexture.createView() },
604
+ { binding: 15, resource: this.#spillTexture.createView() },
605
+ ...this.#surfEntries(sq),
606
+ ],
607
+ });
608
+ }
609
+ pass.setPipeline(this.#sprayPipeline);
610
+ pass.setBindGroup(0, this.#sprayGroups[sq]);
611
+ pass.draw(6, SPRAY_SPRITES);
197
612
  },
198
613
  });
199
614
  }
615
+ /**
616
+ * Its agent tools (ADR-0204): `water.state` reads the sea, `water.set` turns its knobs, `water.sea` makes a new sea.
617
+ * Errors carry the argument's `path`.
618
+ */
619
+ tools() {
620
+ const bad = (path, message) => Object.assign(new Error(`WATER_INVALID: ${path} ${message}`), { code: 'WATER_INVALID', path, message: `${path} ${message}` });
621
+ const num = (args, k, min, max = Number.POSITIVE_INFINITY) => {
622
+ const v = args[k];
623
+ if (v === undefined)
624
+ return undefined;
625
+ if (typeof v !== 'number' || !Number.isFinite(v) || v < min || v > max)
626
+ throw bad(k, `must be a number from ${min}${Number.isFinite(max) ? ` to ${max}` : ''}`);
627
+ return v;
628
+ };
629
+ const state = () => ({
630
+ enabled: this.enabled,
631
+ quality: this.#quality,
632
+ level: this.level,
633
+ choppiness: this.waves.choppiness,
634
+ foam: this.foam,
635
+ refraction: this.refraction,
636
+ shoreFoam: this.shoreFoam,
637
+ reflections: this.reflections,
638
+ reflectionSteps: this.reflectionSteps,
639
+ frozenAt: this.frozenAt,
640
+ cascades: this.waves.tiles.length,
641
+ tiles: [...this.waves.tiles],
642
+ size: this.waves.size,
643
+ gpuBytes: this.gpuBytes,
644
+ surf: { seabed: !!this.#shoreField, height: this.surfHeight, period: this.surfPeriod, breaking: this.surfBreaking, lean: this.surfLean, start: this.surfStart, calm: this.surfCalm, variety: this.surfVariety, lip: this.surfLip, spray: this.spray },
645
+ });
646
+ return [
647
+ { name: 'water.state', description: 'The water: on or off, its level, knobs (choppiness, foam, refraction, shore foam, reflections), whether its waves are held still, its cascades and GPU bytes.', input: { type: 'object', properties: {} }, run: state },
648
+ {
649
+ name: 'water.set',
650
+ description: "Turns the water's live knobs; only the arguments given change. Returns the new state.",
651
+ input: {
652
+ type: 'object',
653
+ properties: {
654
+ enabled: { type: 'boolean', description: 'Draw and compute the water at all.' },
655
+ quality: { type: 'string', description: 'The quality tier, high, medium or low: high everything, medium for laptops, low the cheapest (no caustics, reflections or spray).' },
656
+ choppiness: { type: 'number', description: 'Horizontal crest sharpening, 0 to 1.5.' },
657
+ foam: { type: 'number', description: 'Whitecap amount, 0 or more.' },
658
+ refraction: { type: 'number', description: 'How far the bed is bent by the waves, pixels, 0 or more.' },
659
+ shoreFoam: { type: 'number', description: 'Water depth under which the shore foams, metres, 0 or more.' },
660
+ reflections: { type: 'number', description: 'How far screen-space reflections reach, metres, 0 (off) or more.' },
661
+ freeze: { type: 'number', description: 'Hold the waves at this time, seconds (0 or more).' },
662
+ surfHeight: { type: 'number', description: 'The height of the surf in deep water, metres, 0 to 4 (needs a seabed: setSeabed).' },
663
+ surfBreaking: { type: 'number', description: 'Breaking ratio: a wave breaks when taller than this × the depth, 0.3 to 1.2.' },
664
+ surfLean: { type: 'number', description: 'How far crests lean shoreward before breaking, 0 to 1.' },
665
+ surfVariety: { type: 'number', description: 'How much waves differ: sets and sections along the shore, 0 (all alike) to 1.' },
666
+ surfLip: { type: 'number', description: 'How far a breaking crest throws its lip forward, 0 to 1.' },
667
+ spray: { type: 'number', description: 'Spray thrown up by breaking waves, 0 (none) to 4; 1 is the default.' },
668
+ follow: { type: 'boolean', description: 'true: the waves follow the app clock again.' },
669
+ },
670
+ },
671
+ run: (args) => {
672
+ if (args.enabled !== undefined)
673
+ this.enabled = !!args.enabled;
674
+ if (args.quality !== undefined) {
675
+ if (args.quality !== 'high' && args.quality !== 'medium' && args.quality !== 'low')
676
+ throw bad('quality', 'must be high, medium or low');
677
+ this.quality = args.quality;
678
+ }
679
+ const c = num(args, 'choppiness', 0, 1.5);
680
+ if (c !== undefined)
681
+ this.waves.choppiness = c;
682
+ const f = num(args, 'foam', 0);
683
+ if (f !== undefined)
684
+ this.foam = f;
685
+ const r = num(args, 'refraction', 0);
686
+ if (r !== undefined)
687
+ this.refraction = r;
688
+ const s = num(args, 'shoreFoam', 0);
689
+ if (s !== undefined)
690
+ this.shoreFoam = s;
691
+ const m = num(args, 'reflections', 0);
692
+ if (m !== undefined)
693
+ this.reflections = m;
694
+ const t = num(args, 'freeze', 0);
695
+ if (t !== undefined)
696
+ this.frozenAt = t;
697
+ if (args.follow === true)
698
+ this.frozenAt = null;
699
+ const sh = num(args, 'surfHeight', 0, 4);
700
+ if (sh !== undefined)
701
+ this.surfHeight = sh;
702
+ const sb = num(args, 'surfBreaking', 0.3, 1.2);
703
+ if (sb !== undefined)
704
+ this.surfBreaking = sb;
705
+ const sl = num(args, 'surfLean', 0, 1);
706
+ if (sl !== undefined)
707
+ this.surfLean = sl;
708
+ const sv = num(args, 'surfVariety', 0, 1);
709
+ if (sv !== undefined)
710
+ this.surfVariety = sv;
711
+ const sp = num(args, 'surfLip', 0, 1);
712
+ if (sp !== undefined)
713
+ this.surfLip = sp;
714
+ const sr = num(args, 'spray', 0, 4);
715
+ if (sr !== undefined)
716
+ this.spray = sr;
717
+ return state();
718
+ },
719
+ },
720
+ {
721
+ name: 'water.sea',
722
+ description: 'A new sea on the same cascades: the wind speed (m/s), its fetch (m) and direction (radians from +x). The waves change next frame.',
723
+ input: {
724
+ type: 'object',
725
+ properties: {
726
+ wind: { type: 'number', description: 'Wind speed 10 m up, m/s, above 0 (5 calm, 11 breeze, 19 gale).' },
727
+ fetch: { type: 'number', description: 'Distance the wind has blown over water, m, above 0 (default 200,000).' },
728
+ direction: { type: 'number', description: 'Wind direction, radians from +x (default 0).' },
729
+ },
730
+ required: ['wind'],
731
+ },
732
+ run: (args) => {
733
+ const wind = num(args, 'wind', 0.1, 60);
734
+ const fetch = num(args, 'fetch', 1) ?? 200_000;
735
+ const direction = num(args, 'direction', -Math.PI * 4, Math.PI * 4) ?? 0;
736
+ this.waves.reseed({ wind, fetch, direction });
737
+ return { wind, fetch, direction, ...state() };
738
+ },
739
+ },
740
+ ];
741
+ }
200
742
  /** What the sea holds on the GPU (its waves; the surface has no buffers). */
201
743
  get gpuBytes() {
202
744
  return this.waves.gpuBytes;
@@ -204,6 +746,13 @@ export class Water {
204
746
  destroy() {
205
747
  this.waves.destroy();
206
748
  this.#params.destroy();
749
+ this.#indices.destroy();
750
+ for (const t of [...this.#surfA, ...this.#surfD, this.#surfB, this.#surfC])
751
+ t?.destroy();
752
+ this.#motionParams.destroy();
753
+ this.#shoreTexture.destroy();
754
+ this.#spillTexture.destroy();
755
+ this.#prevDisplacement?.destroy();
207
756
  }
208
757
  }
209
758
  //# sourceMappingURL=water.js.map