@driftengine/core 4.13.6 → 4.14.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (91) hide show
  1. package/CHANGELOG.json +51 -0
  2. package/README.md +1 -1
  3. package/dist/index.d.ts +3 -0
  4. package/dist/index.js +1 -0
  5. package/dist/render/backend/api.d.ts +19 -3
  6. package/dist/render/backend/webgl2/motionTarget.d.ts +39 -0
  7. package/dist/render/backend/webgl2/motionTarget.js +254 -0
  8. package/dist/render/backend/webgl2/pointSkinFeedback.d.ts +13 -3
  9. package/dist/render/backend/webgl2/pointSkinFeedback.js +45 -12
  10. package/dist/render/backend/webgl2/renderer.d.ts +101 -7
  11. package/dist/render/backend/webgl2/renderer.js +337 -9
  12. package/dist/render/backend/webgpu/pointSkinPass.d.ts +16 -3
  13. package/dist/render/backend/webgpu/pointSkinPass.js +79 -21
  14. package/dist/render/backend/webgpu/postPass.d.ts +6 -1
  15. package/dist/render/backend/webgpu/renderer.d.ts +41 -0
  16. package/dist/render/backend/webgpu/renderer.js +143 -5
  17. package/dist/render/baseDecals.d.ts +48 -0
  18. package/dist/render/baseDecals.js +71 -0
  19. package/dist/render/emissiveFlow.d.ts +40 -0
  20. package/dist/render/emissiveFlow.js +51 -0
  21. package/dist/render/fixtureAtlas.d.ts +8 -3
  22. package/dist/render/fixtureAtlas.js +17 -6
  23. package/dist/render/globalMedium.d.ts +17 -0
  24. package/dist/render/globalMedium.js +14 -0
  25. package/dist/render/globalMediumPass.js +3 -2
  26. package/dist/render/iridescentOverlay.d.ts +78 -0
  27. package/dist/render/iridescentOverlay.js +102 -0
  28. package/dist/render/mesh.d.ts +24 -0
  29. package/dist/render/mesh.js +79 -0
  30. package/dist/render/pointSkin.d.ts +18 -0
  31. package/dist/render/pointSkin.js +50 -5
  32. package/dist/render/recon/mover.d.ts +3 -2
  33. package/dist/render/recon/mover.js +3 -2
  34. package/dist/render/sceneTarget.d.ts +2 -0
  35. package/dist/render/sceneTarget.js +1 -1
  36. package/dist/render/shaders/flat/baseDecals.d.ts +17 -0
  37. package/dist/render/shaders/flat/baseDecals.js +71 -0
  38. package/dist/render/shaders/flat/emissiveFlow.d.ts +15 -0
  39. package/dist/render/shaders/flat/emissiveFlow.js +45 -0
  40. package/dist/render/shaders/flat/glassTint.d.ts +1 -1
  41. package/dist/render/shaders/flat/glassTint.js +3 -0
  42. package/dist/render/shaders/flat/index.d.ts +16 -0
  43. package/dist/render/shaders/flat/index.js +9 -0
  44. package/dist/render/shaders/flat/iridescentOverlay.d.ts +15 -0
  45. package/dist/render/shaders/flat/iridescentOverlay.js +65 -0
  46. package/dist/render/shaders/flat/layered.js +4 -4
  47. package/dist/render/shaders/flat/main.d.ts +1 -1
  48. package/dist/render/shaders/flat/main.js +12 -1
  49. package/dist/render/shaders/flat/mapReads.d.ts +1 -1
  50. package/dist/render/shaders/flat/mapReads.js +7 -3
  51. package/dist/render/shaders/flat/worldUv.js +6 -5
  52. package/dist/render/shaders/generated/flat.wgsl.d.ts +480 -16
  53. package/dist/render/shaders/generated/flat.wgsl.js +519 -55
  54. package/dist/render/shaders/generated/temporalResolve.wgsl.d.ts +15 -5
  55. package/dist/render/shaders/generated/temporalResolve.wgsl.js +15 -5
  56. package/dist/render/shaders/temporalResolve.js +25 -9
  57. package/dist/render/surfaceTexture.d.ts +32 -0
  58. package/dist/render/temporalPass.d.ts +3 -1
  59. package/dist/render/temporalPass.js +9 -1
  60. package/package.json +6 -6
  61. package/src/index.ts +3 -0
  62. package/src/render/backend/api.ts +19 -2
  63. package/src/render/backend/webgl2/motionTarget.ts +299 -0
  64. package/src/render/backend/webgl2/pointSkinFeedback.ts +49 -14
  65. package/src/render/backend/webgl2/renderer.ts +411 -9
  66. package/src/render/backend/webgpu/pointSkinPass.ts +90 -23
  67. package/src/render/backend/webgpu/renderer.ts +163 -3
  68. package/src/render/baseDecals.ts +93 -0
  69. package/src/render/emissiveFlow.ts +66 -0
  70. package/src/render/fixtureAtlas.ts +26 -7
  71. package/src/render/globalMedium.ts +31 -0
  72. package/src/render/globalMediumPass.ts +3 -7
  73. package/src/render/iridescentOverlay.ts +177 -0
  74. package/src/render/mesh.ts +82 -0
  75. package/src/render/pointSkin.ts +64 -5
  76. package/src/render/recon/mover.ts +3 -2
  77. package/src/render/sceneTarget.ts +3 -0
  78. package/src/render/shaders/flat/baseDecals.ts +71 -0
  79. package/src/render/shaders/flat/emissiveFlow.ts +45 -0
  80. package/src/render/shaders/flat/glassTint.ts +7 -1
  81. package/src/render/shaders/flat/index.ts +25 -0
  82. package/src/render/shaders/flat/iridescentOverlay.ts +65 -0
  83. package/src/render/shaders/flat/layered.ts +4 -4
  84. package/src/render/shaders/flat/main.ts +12 -1
  85. package/src/render/shaders/flat/mapReads.ts +7 -3
  86. package/src/render/shaders/flat/worldUv.ts +6 -5
  87. package/src/render/shaders/generated/flat.wgsl.ts +519 -55
  88. package/src/render/shaders/generated/temporalResolve.wgsl.ts +15 -5
  89. package/src/render/shaders/temporalResolve.ts +25 -9
  90. package/src/render/surfaceTexture.ts +32 -0
  91. package/src/render/temporalPass.ts +8 -0
@@ -116,6 +116,16 @@ export interface GlobalMediumOptions {
116
116
  readonly sunScatter?: number;
117
117
  /** The same for the sky's fill, which lights a shadowed medium from every side. Default 1. */
118
118
  readonly skyScatter?: number;
119
+ /**
120
+ * The sky's fill the medium scatters, a colour of its own in the units of
121
+ * `Environment.ambient`, which `skyScatter` then scales. Absent or null, the frame's ambient, as
122
+ * it always was. **For a scene whose surfaces are lit by more than its flat ambient**, a sky's
123
+ * cubemap, probes and a bake, which keeps that ambient for what falls outside them: the ambient
124
+ * is then the wrong colour for the sky's light in the air, and setting it to the right one would
125
+ * relight those surfaces too. What it gives up is the sky's direction, as the ambient does: one
126
+ * colour from every side.
127
+ */
128
+ readonly skyFill?: readonly [number, number, number] | null;
119
129
  /**
120
130
  * The same for the lamps, each through its own shadow and times its own `lightMediumScatter`.
121
131
  * **Default 0**: a medium was the sun's and the sky's until there was this, and a scene that
@@ -142,6 +152,8 @@ export interface ResolvedGlobalMedium {
142
152
  readonly sunScatter: number;
143
153
  readonly skyScatter: number;
144
154
  readonly lampScatter: number;
155
+ /** The sky's fill of the medium's own, or null for the frame's ambient: see `mediumSkyFill`. */
156
+ readonly skyFill: readonly [number, number, number] | null;
145
157
  }
146
158
 
147
159
  /**
@@ -192,9 +204,28 @@ export function resolveGlobalMedium(options: Partial<GlobalMediumOptions>): Reso
192
204
  sunScatter: Math.max(0, finite(options.sunScatter, 1)),
193
205
  skyScatter: Math.max(0, finite(options.skyScatter, 1)),
194
206
  lampScatter: Math.max(0, finite(options.lampScatter, 0)),
207
+ skyFill:
208
+ options.skyFill === undefined || options.skyFill === null
209
+ ? null
210
+ : [
211
+ Math.max(0, finite(options.skyFill[0], 0)),
212
+ Math.max(0, finite(options.skyFill[1], 0)),
213
+ Math.max(0, finite(options.skyFill[2], 0)),
214
+ ],
195
215
  };
196
216
  }
197
217
 
218
+ /**
219
+ * The colour the medium scatters as the sky's fill: its own where it has one, else the frame's
220
+ * ambient. One decision both backends upload, so the two cannot disagree about which.
221
+ */
222
+ export function mediumSkyFill(
223
+ medium: Pick<ResolvedGlobalMedium, 'skyFill'>,
224
+ ambient: ArrayLike<number>,
225
+ ): ArrayLike<number> {
226
+ return medium.skyFill ?? ambient;
227
+ }
228
+
198
229
  /** `setGlobalMedium`'s object form: a density, and any of the rest. */
199
230
  export type GlobalMediumSettings = Partial<GlobalMediumOptions> & { readonly density: number };
200
231
 
@@ -1,7 +1,7 @@
1
1
  import { mat4 } from 'gl-matrix';
2
2
  import type { ReadonlyMat4 } from 'gl-matrix';
3
3
 
4
- import { mediumTargetSize } from './globalMedium.ts';
4
+ import { mediumSkyFill, mediumTargetSize } from './globalMedium.ts';
5
5
  import type { ResolvedGlobalMedium, ResolvedMediumLamps } from './globalMedium.ts';
6
6
  import { DEPTH_01_TO_CLIP } from './lightVolumeDraw.ts';
7
7
  import { compileProgram, uniformLocations } from './shader.ts';
@@ -236,12 +236,8 @@ export class GlobalMediumPass {
236
236
  light.sunColor[1] ?? 0,
237
237
  light.sunColor[2] ?? 0,
238
238
  );
239
- gl.uniform3f(
240
- u['uAmbient'] ?? null,
241
- light.ambient[0] ?? 0,
242
- light.ambient[1] ?? 0,
243
- light.ambient[2] ?? 0,
244
- );
239
+ const sky = mediumSkyFill(options, light.ambient);
240
+ gl.uniform3f(u['uAmbient'] ?? null, sky[0] ?? 0, sky[1] ?? 0, sky[2] ?? 0);
245
241
  gl.uniform1f(u['uDensity'] ?? null, options.density);
246
242
  gl.uniform2f(u['uHeight'] ?? null, options.heightFalloff, options.baseY);
247
243
  gl.uniform3f(u['uLayer2'] ?? null, options.density2, options.heightFalloff2, options.baseY2);
@@ -0,0 +1,177 @@
1
+ /**
2
+ * A surface's iridescent overlay: a hue that shifts with the view, laid over the base colour before
3
+ * it is lit. `SurfaceMaterial.iridescentOverlay`, packed into `uIridescence` for the lit stage's
4
+ * `IRIDESCENT_OVERLAY` switch, and the same arithmetic on the CPU, which the tests hold the shader to.
5
+ *
6
+ * Per pixel, with `N` the mapped normal, `V` toward the eye and `uv` the mesh's own first
7
+ * coordinates, as many material systems shade a sheen on cloth or a coat:
8
+ *
9
+ * h = saturate((N·H)^contrast), 0 where N·H ≤ 0, H = normalize(V + direction)
10
+ * x = N·V + uv.x + blend · (h − N·V)
11
+ * t = saturate(fract(x · tiling))
12
+ * base' = base + (overlay(base, ramp(t)) − base) · x · mask · weight
13
+ *
14
+ * where `overlay` is the usual blend, `a < ½ ? 2ao : 1 − 2(1 − a)(1 − o)` per channel.
15
+ *
16
+ * **The ramp is uniforms, thirty-two colours, and not a texture**, because the lit stage has no
17
+ * texture binding left (`textureUnitBudget.test.ts`): a caller's ramp of any length is resampled to
18
+ * thirty-two on the way in and read back between neighbours. What that gives up is a ramp with
19
+ * detail finer than a thirty-second of its length, which a smooth spectrum does not have. **The
20
+ * mask is a channel of a layer of one of the material's own arrays**, for the same reason, read at
21
+ * the material's coordinates; alpha is linear in every array, and a colour channel of an sRGB array
22
+ * is decoded before it is read.
23
+ *
24
+ * Thirty-six vectors, so WebGL2 refuses it, in words, on a part whose lit stage has no room, as it
25
+ * refuses a material's tiled detail.
26
+ */
27
+ /** Three numbers, read and never written. */
28
+ type Three = readonly [number, number, number];
29
+
30
+ /** Colours the ramp is resampled to. */
31
+ export const IRIDESCENT_RAMP = 32;
32
+ /** Floats in `uIridescence`: four vectors of numbers, then the ramp a vector a colour. */
33
+ export const IRIDESCENT_FLOATS = (4 + IRIDESCENT_RAMP) * 4;
34
+
35
+ /** Which of a material's arrays holds the mask. */
36
+ export type IridescentMaskMap = 'orm' | 'model' | 'albedo' | 'emissive';
37
+
38
+ /** `SurfaceMaterial.iridescentOverlay`. */
39
+ export interface IridescentOverlay {
40
+ /**
41
+ * The overlay colour along `t`, linear, three floats a colour, at least two colours, the first at
42
+ * `t = 0` and the last at `t = 1`. Resampled to thirty-two.
43
+ */
44
+ readonly ramp: ArrayLike<number>;
45
+ /** A fixed direction in the world the sheen turns toward, `S`. Need not be unit length. */
46
+ readonly direction: Three;
47
+ /** How far the view's term is turned toward the sheen, 0 to 1. Default 0.6. */
48
+ readonly blend?: number;
49
+ /** The sheen's exponent. Default 1. */
50
+ readonly contrast?: number;
51
+ /** How many times the ramp repeats across `x`. Default 2. */
52
+ readonly tiling?: number;
53
+ /** How much of the overlay is laid on. Default 0.25. */
54
+ readonly weight?: number;
55
+ /** Where it is laid: a channel of a layer of one of the material's arrays. Absent, everywhere. */
56
+ readonly mask?: {
57
+ readonly map: IridescentMaskMap;
58
+ readonly layer: number;
59
+ /** 0 red, 1 green, 2 blue, 3 alpha. */
60
+ readonly channel: 0 | 1 | 2 | 3;
61
+ } | null;
62
+ }
63
+
64
+ const MASK_SOURCE: Readonly<Record<IridescentMaskMap, number>> = {
65
+ orm: 1,
66
+ model: 2,
67
+ albedo: 3,
68
+ emissive: 4,
69
+ };
70
+
71
+ /** Why an overlay cannot be drawn, or null where it can. */
72
+ export function iridescentRefusal(overlay: IridescentOverlay): string | null {
73
+ const values = overlay.ramp.length;
74
+ if (values < 6 || values % 3 !== 0) {
75
+ return `a ramp of ${values} numbers; it is three a colour, at least two colours`;
76
+ }
77
+ const [x, y, z] = overlay.direction;
78
+ if (
79
+ !(Number.isFinite(x) && Number.isFinite(y) && Number.isFinite(z)) ||
80
+ x * x + y * y + z * z === 0
81
+ ) {
82
+ return 'a direction that is not one';
83
+ }
84
+ const mask = overlay.mask ?? null;
85
+ if (mask !== null && !(Number.isInteger(mask.layer) && mask.layer >= 0)) {
86
+ return `a mask layer of ${mask.layer}; a layer is a whole number from 0`;
87
+ }
88
+ return null;
89
+ }
90
+
91
+ /**
92
+ * Pack an overlay into `out`, with the scale and offset that take the material's coordinates back
93
+ * to the mesh's own; all zeros, which the shader reads as none, for null. Refuse what
94
+ * `iridescentRefusal` refuses before calling this.
95
+ */
96
+ export function packIridescentOverlay(
97
+ overlay: IridescentOverlay | null,
98
+ uv: { uScale?: number; vScale?: number; uOffset?: number; vOffset?: number },
99
+ out: Float32Array,
100
+ ): void {
101
+ out.fill(0);
102
+ if (overlay === null) return;
103
+ const [x, y, z] = overlay.direction;
104
+ const length = Math.hypot(x, y, z);
105
+ out[0] = x / length;
106
+ out[1] = y / length;
107
+ out[2] = z / length;
108
+ out[3] = overlay.blend ?? 0.6;
109
+ out[4] = overlay.contrast ?? 1;
110
+ out[5] = overlay.tiling ?? 2;
111
+ out[6] = overlay.weight ?? 0.25;
112
+ /* On: a zero here, which an unwritten block is, is none. */
113
+ out[7] = 1;
114
+ const mask = overlay.mask ?? null;
115
+ out[8] = mask === null ? 0 : MASK_SOURCE[mask.map];
116
+ out[9] = mask === null ? 0 : mask.layer;
117
+ out[10] = mask === null ? 0 : mask.channel;
118
+ const uScale = uv.uScale ?? 1;
119
+ const vScale = uv.vScale ?? 1;
120
+ out[12] = uScale === 0 ? 0 : 1 / uScale;
121
+ out[13] = vScale === 0 ? 0 : 1 / vScale;
122
+ out[14] = uScale === 0 ? 0 : -(uv.uOffset ?? 0) / uScale;
123
+ out[15] = vScale === 0 ? 0 : -(uv.vOffset ?? 0) / vScale;
124
+ /* The ramp, resampled to thirty-two colours between its own neighbours. */
125
+ const colours = overlay.ramp.length / 3;
126
+ for (let i = 0; i < IRIDESCENT_RAMP; i++) {
127
+ const at = (i / (IRIDESCENT_RAMP - 1)) * (colours - 1);
128
+ const below = Math.min(Math.floor(at), colours - 2);
129
+ const share = at - below;
130
+ for (let c = 0; c < 3; c++) {
131
+ const a = overlay.ramp[below * 3 + c] as number;
132
+ const b = overlay.ramp[(below + 1) * 3 + c] as number;
133
+ out[16 + i * 4 + c] = a + (b - a) * share;
134
+ }
135
+ }
136
+ }
137
+
138
+ /**
139
+ * The base colour with the overlay laid on, as the lit stage computes it from a packed block: `n`
140
+ * and `toEye` unit length, `uv` the mesh's own coordinates, `mask` the texel's channel. Into `out`.
141
+ */
142
+ export function iridescentBase(
143
+ packed: ArrayLike<number>,
144
+ base: Three,
145
+ n: Three,
146
+ toEye: Three,
147
+ uv: readonly [number, number],
148
+ mask: number,
149
+ out: [number, number, number],
150
+ ): [number, number, number] {
151
+ const p = (i: number): number => packed[i] as number;
152
+ let hx = toEye[0] + p(0);
153
+ let hy = toEye[1] + p(1);
154
+ let hz = toEye[2] + p(2);
155
+ const hl = Math.hypot(hx, hy, hz) || 1;
156
+ hx /= hl;
157
+ hy /= hl;
158
+ hz /= hl;
159
+ const nh = n[0] * hx + n[1] * hy + n[2] * hz;
160
+ const h = nh > 0 ? Math.min(1, Math.max(0, nh ** p(4))) : 0;
161
+ const nv = n[0] * toEye[0] + n[1] * toEye[1] + n[2] * toEye[2];
162
+ const x = nv + uv[0] + p(3) * (h - nv);
163
+ const scaled = x * p(5);
164
+ const t = Math.min(1, Math.max(0, scaled - Math.floor(scaled)));
165
+ const at = t * (IRIDESCENT_RAMP - 1);
166
+ const below = Math.min(Math.floor(at), IRIDESCENT_RAMP - 2);
167
+ const share = at - below;
168
+ const amount = x * mask * p(6);
169
+ for (let c = 0; c < 3; c++) {
170
+ const o0 = p(16 + below * 4 + c);
171
+ const o = o0 + (p(16 + (below + 1) * 4 + c) - o0) * share;
172
+ const a = base[c] as number;
173
+ const blended = a < 0.5 ? 2 * a * o : 1 - 2 * (1 - a) * (1 - o);
174
+ out[c] = a + (blended - a) * amount;
175
+ }
176
+ return out;
177
+ }
@@ -40,6 +40,8 @@ import { validateMeshData } from '@driftengine/drft';
40
40
 
41
41
  /** Attribute locations are fixed engine-wide (`layout(location = n)` in GLSL). */
42
42
  const ATTR_POSITION = 0;
43
+ /** What the motion pass reads: positions, and a rig's two sets of joints and weights. */
44
+ const MOTION_LOCATIONS: readonly number[] = [ATTR_POSITION, 11, 12, 14, 15];
43
45
  const ATTR_NORMAL = 1;
44
46
  const ATTR_COLOR = 2;
45
47
  const ATTR_EMISSIVE = 3;
@@ -119,6 +121,16 @@ export class Mesh {
119
121
  get streams(): { readonly positions: WebGLBuffer; readonly normals: WebGLBuffer } | null {
120
122
  return this.deformable;
121
123
  }
124
+ /**
125
+ * A dynamic mesh's positions as they stood before this frame's first rewrite, for the motion
126
+ * WebGL2's temporal resolve follows (`backend/webgl2/motionTarget.ts`). Made the first time one is
127
+ * kept, so a mesh that never moves under the resolve pays nothing.
128
+ */
129
+ private previousPositions: WebGLBuffer | null = null;
130
+ /** The frame that rewrite belongs to (`frameOfChange`), or -1 for none yet. */
131
+ changedFrame = -1;
132
+ /** A vertex array for the motion pass, without and with last frame's positions, made on demand. */
133
+ private motionArrays: (WebGLVertexArrayObject | null)[] = [null, null];
122
134
  /** The colour buffer, kept so `updateColors` can rewrite it. Null unless `dynamicColors`. */
123
135
  private readonly recolourable: WebGLBuffer | null = null;
124
136
  /** How many floats the position buffer holds, so an update of the wrong size is refused. */
@@ -475,6 +487,76 @@ export class Mesh {
475
487
  this.morph?.dispose(gl);
476
488
  for (const buffer of this.buffers) gl.deleteBuffer(buffer);
477
489
  gl.deleteVertexArray(this.vao);
490
+ for (const vao of this.motionArrays) if (vao !== null) gl.deleteVertexArray(vao);
491
+ if (this.previousPositions !== null) gl.deleteBuffer(this.previousPositions);
492
+ }
493
+
494
+ /**
495
+ * Keep this dynamic mesh's positions as they stand, before a rewrite: one buffer copy on the
496
+ * device. The caller decides whether this rewrite is the frame's first (`keepsPrevious`).
497
+ */
498
+ keepPrevious(gl: WebGL2RenderingContext): void {
499
+ const deformable = this.deformable;
500
+ if (deformable === null) return;
501
+ const bytes = this.positionFloats * 4;
502
+ if (this.previousPositions === null) {
503
+ const buffer = gl.createBuffer();
504
+ if (buffer === null) throw new Error('createBuffer failed');
505
+ gl.bindBuffer(gl.COPY_WRITE_BUFFER, buffer);
506
+ gl.bufferData(gl.COPY_WRITE_BUFFER, bytes, gl.DYNAMIC_COPY);
507
+ this.previousPositions = buffer;
508
+ }
509
+ gl.bindBuffer(gl.COPY_READ_BUFFER, deformable.positions);
510
+ gl.bindBuffer(gl.COPY_WRITE_BUFFER, this.previousPositions);
511
+ gl.copyBufferSubData(gl.COPY_READ_BUFFER, gl.COPY_WRITE_BUFFER, 0, 0, bytes);
512
+ gl.bindBuffer(gl.COPY_READ_BUFFER, null);
513
+ gl.bindBuffer(gl.COPY_WRITE_BUFFER, null);
514
+ }
515
+
516
+ /** Whether last frame's positions have been kept: a dynamic mesh rewritten under the resolve. */
517
+ get keptPrevious(): boolean {
518
+ return this.previousPositions !== null;
519
+ }
520
+
521
+ /**
522
+ * Draw this mesh for the motion pass: its positions at 0, last frame's at 1 where `previous` asks
523
+ * and they were kept, and its rig's influences where `flat`'s skinning reads them, 11, 12, 14 and
524
+ * 15. A vertex array of its own, made once a variant, so the one the lit pass draws with is never
525
+ * touched.
526
+ */
527
+ drawMotion(gl: WebGL2RenderingContext, previous: boolean): void {
528
+ const at = previous && this.previousPositions !== null ? 1 : 0;
529
+ let vao = this.motionArrays[at] ?? null;
530
+ if (vao === null) {
531
+ vao = gl.createVertexArray();
532
+ if (vao === null) throw new Error('createVertexArray failed');
533
+ gl.bindVertexArray(vao);
534
+ for (const attribute of this.layout) {
535
+ if (!MOTION_LOCATIONS.includes(attribute.location)) continue;
536
+ gl.bindBuffer(gl.ARRAY_BUFFER, attribute.buffer);
537
+ gl.enableVertexAttribArray(attribute.location);
538
+ gl.vertexAttribPointer(
539
+ attribute.location,
540
+ attribute.size,
541
+ attribute.type,
542
+ attribute.normalized,
543
+ 0,
544
+ 0,
545
+ );
546
+ }
547
+ if (at === 1 && this.previousPositions !== null) {
548
+ gl.bindBuffer(gl.ARRAY_BUFFER, this.previousPositions);
549
+ gl.enableVertexAttribArray(1);
550
+ gl.vertexAttribPointer(1, 3, gl.FLOAT, false, 0, 0);
551
+ }
552
+ gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER, this.indexBuffer);
553
+ gl.bindVertexArray(null);
554
+ gl.bindBuffer(gl.ARRAY_BUFFER, null);
555
+ this.motionArrays[at] = vao;
556
+ }
557
+ gl.bindVertexArray(vao);
558
+ gl.drawElements(gl.TRIANGLES, this.indexCount, this.indexType, 0);
559
+ gl.bindVertexArray(null);
478
560
  }
479
561
 
480
562
  /**
@@ -48,9 +48,27 @@ export interface PointSkinReach {
48
48
 
49
49
  /** Refuse a skin whose arrays disagree with a mesh of `vertices`, or that names a joint past the rest. */
50
50
  export function validatePointSkin(data: PointSkinData, vertices: number): void {
51
- const fail = (what: string): never => {
52
- throw new Error(`point skin: ${what}`);
53
- };
51
+ checkArrays(data, vertices, 'the mesh has');
52
+ checkJoints(data, 0);
53
+ }
54
+
55
+ /**
56
+ * Refuse a rewrite of vertices `first` onward, as many as `data` holds, that does not fit a mesh of
57
+ * `vertices`: arrays that disagree with each other, a range that is not whole vertices inside the
58
+ * mesh, or a joint past the rest. Named by the mesh's own vertex numbers.
59
+ */
60
+ export function validatePointSkinRange(data: PointSkinData, first: number, vertices: number): void {
61
+ const count = data.weights.length / 4;
62
+ checkArrays(data, count, 'the weights hold');
63
+ if (!(Number.isInteger(first) && first >= 0 && first + count <= vertices)) {
64
+ throw new Error(
65
+ `point skin: a rewrite of ${count} vertices from vertex ${first} for a mesh of ${vertices}`,
66
+ );
67
+ }
68
+ checkJoints(data, first);
69
+ }
70
+
71
+ function checkArrays(data: PointSkinData, vertices: number, against: string): void {
54
72
  for (const [name, values, width] of [
55
73
  ['joints', data.joints, 4],
56
74
  ['weights', data.weights, 4],
@@ -58,13 +76,22 @@ export function validatePointSkin(data: PointSkinData, vertices: number): void {
58
76
  ['normals', data.normals, 3],
59
77
  ] as const) {
60
78
  if (values.length !== vertices * width) {
61
- fail(`${name} holds ${values.length / width} vertices and the mesh has ${vertices}`);
79
+ throw new Error(
80
+ `point skin: ${name} holds ${values.length / width} vertices and ${against} ${vertices}`,
81
+ );
62
82
  }
63
83
  }
84
+ }
85
+
86
+ /** Every joint a whole number below `MAX_JOINTS`; a vertex named as `first` plus its place. */
87
+ function checkJoints(data: PointSkinData, first: number): void {
64
88
  for (let at = 0; at < data.joints.length; at++) {
65
89
  const joint = data.joints[at] as number;
66
90
  if (!(Number.isInteger(joint) && joint >= 0 && joint < MAX_JOINTS)) {
67
- fail(`vertex ${Math.floor(at / 4)} names joint ${joint}; a joint is 0 to ${MAX_JOINTS - 1}`);
91
+ throw new Error(
92
+ `point skin: vertex ${first + Math.floor(at / 4)} names joint ${joint}; a joint is 0 to ` +
93
+ `${MAX_JOINTS - 1}`,
94
+ );
68
95
  }
69
96
  }
70
97
  }
@@ -102,6 +129,38 @@ export function reachOf(data: PointSkinData): PointSkinReach {
102
129
  };
103
130
  }
104
131
 
132
+ /**
133
+ * A skin's reach after part of it is rewritten with `data`: the farther of the old reach and the
134
+ * new at each joint, the joints the two name between them, and the widest span of weight sums.
135
+ *
136
+ * **It widens and never narrows**, because the vertices the rewrite replaced may have been the
137
+ * ones that set a joint's reach, and finding out would mean keeping every vertex on the CPU and
138
+ * reading them all again. A skin that changes shape back and forth settles at the union of its
139
+ * shapes, which is still a box no posed vertex leaves, and culls a little less than it could. What
140
+ * would make that wrong is a skin rewritten into ever larger shapes and never back; making it
141
+ * again with `createPointSkin` measures it afresh.
142
+ */
143
+ export function widenReach(reach: PointSkinReach, data: PointSkinData): PointSkinReach {
144
+ const added = reachOf(data);
145
+ const joints = Math.max(reach.joints, added.joints);
146
+ const widened = new Float32Array(joints).fill(-1);
147
+ for (let j = 0; j < joints; j++) {
148
+ widened[j] = Math.max(
149
+ j < reach.joints ? (reach.reach[j] as number) : -1,
150
+ j < added.joints ? (added.reach[j] as number) : -1,
151
+ );
152
+ }
153
+ const vertices = data.weights.length / 4;
154
+ return {
155
+ joints,
156
+ reach: widened,
157
+ sums:
158
+ vertices === 0
159
+ ? reach.sums
160
+ : [Math.min(reach.sums[0], added.sums[0]), Math.max(reach.sums[1], added.sums[1])],
161
+ };
162
+ }
163
+
105
164
  /**
106
165
  * The bounds of a pose, into the mesh's own: `matrices` holds each joint's transform into the
107
166
  * mesh's model space, sixteen floats, column-major. Allocates nothing.
@@ -8,8 +8,9 @@
8
8
  * draw, last frame's palette.
9
9
  *
10
10
  * **Plain data, advanced by the renderer.** No renderer is needed to make one, so a consumer holds
11
- * it beside the object it belongs to. Only a reconstructing WebGPU renderer reads or writes it; it
12
- * costs nothing anywhere else. The fields past `kind` are the renderer's and a caller leaves them
11
+ * it beside the object it belongs to. A reconstructing WebGPU renderer reads and writes it, and so
12
+ * does a WebGL2 one resolving temporally (`backend/webgl2/motionTarget.ts`); it costs nothing
13
+ * anywhere else. The fields past `kind` are the renderer's and a caller leaves them
13
14
  * alone.
14
15
  *
15
16
  * **What would make this wrong** is a consumer whose objects are rebuilt every frame with no state
@@ -850,6 +850,8 @@ export class SceneTarget {
850
850
  readonly blend: number;
851
851
  readonly periodStart: boolean;
852
852
  readonly depthToViewZ: Float32Array;
853
+ /** Where the frame's movers stood last frame, or absent for the camera's motion alone. */
854
+ readonly motion?: WebGLTexture | null;
853
855
  },
854
856
  ): void {
855
857
  const { gl } = this;
@@ -993,6 +995,7 @@ export class SceneTarget {
993
995
  temporal.blend,
994
996
  temporal.periodStart,
995
997
  temporal.depthToViewZ,
998
+ temporal.motion ?? null,
996
999
  );
997
1000
  }
998
1001
 
@@ -0,0 +1,71 @@
1
+ /**
2
+ * Decals laid into a surface's base colour before it is lit: `setBaseDecals`, packed into
3
+ * `uBaseDecals` by `baseDecals.ts`, where what they are for and what they give up is said, and the
4
+ * material's own refusal in `uDecalReceiver` (`SurfaceMaterial.receivesDecals`).
5
+ *
6
+ * **Each decal is a test and a fetch**: the world point taken into the box by three rows, outside
7
+ * it or turned too far from the projection and nothing, else the image read across the box's face
8
+ * from its square in the fixture atlas, its colour decoded from sRGB and laid over the base colour
9
+ * by its alpha and the decal's opacity. The geometric normal decides the facing, so a normal map's
10
+ * bump does not cut a mark. The loop is over a count in a uniform and the fetch is `textureLod`, as
11
+ * every read of the atlas is, so neither needs uniform control flow: AGENTS.md 2026-08-07.
12
+ *
13
+ * **A lit switch, `BASE_DECALS`**, off until a frame first lays one, declared after
14
+ * `IRIDESCENT_OVERLAY` so each switch before it keeps its id. Compiled out, its thirty-four vectors
15
+ * are not declared and the call is a constant.
16
+ */
17
+ export function baseDecalsGlsl(on: boolean): string {
18
+ if (!on) {
19
+ return /* glsl */ `
20
+ const bool BASE_DECALS = false; // wgsl:override
21
+ vec3 baseDecals(vec3 base, vec3 world, vec3 geometric) { return base; }
22
+ `;
23
+ }
24
+ return /* glsl */ `
25
+ /* Whether this program lays decals into base colours. Off but where a frame has laid one. */
26
+ const bool BASE_DECALS = true; // wgsl:override
27
+ /*
28
+ * A header, how many decals, the atlas row their band starts at and its squares' side (0); then
29
+ * four vectors a decal, eight decals: the three rows taking a world point into its box, x across,
30
+ * y up the image, z along the projection, and its image, opacity and facing cosine.
31
+ */
32
+ uniform vec4 uBaseDecals[33]; // wgsl:material
33
+ /* x 1 where the material refuses decals, so an unwritten zero receives them. */
34
+ uniform vec4 uDecalReceiver; // wgsl:material
35
+
36
+ vec3 decalLinear(vec3 c) {
37
+ return mix(c / 12.92, pow((c + 0.055) / 1.055, vec3(2.4)), step(0.04045, c));
38
+ }
39
+
40
+ vec3 baseDecals(vec3 base, vec3 world, vec3 geometric) {
41
+ if (uDecalReceiver.x > 0.5) return base;
42
+ int count = int(uBaseDecals[0].x + 0.5);
43
+ vec2 atlas = vec2(textureSize(uFixtureAtlas, 0));
44
+ float span = uBaseDecals[0].z;
45
+ /* Half a texel of a square, so a bilinear read at its edge cannot reach the next one. */
46
+ float inset = 0.5 / span;
47
+ for (int i = 0; i < 8; i++) {
48
+ if (i >= count) break;
49
+ vec4 across = uBaseDecals[1 + i * 4];
50
+ vec4 up = uBaseDecals[2 + i * 4];
51
+ vec4 along = uBaseDecals[3 + i * 4];
52
+ vec4 laid = uBaseDecals[4 + i * 4];
53
+ vec3 local = vec3(
54
+ dot(across.xyz, world) + across.w,
55
+ dot(up.xyz, world) + up.w,
56
+ dot(along.xyz, world) + along.w
57
+ );
58
+ if (any(greaterThan(abs(local), vec3(1.0)))) continue;
59
+ if (dot(geometric, -normalize(along.xyz)) < laid.z) continue;
60
+ vec2 face = vec2(local.x, -local.y) * 0.5 + 0.5;
61
+ vec2 at = vec2(
62
+ (laid.x + clamp(face.x, inset, 1.0 - inset)) * span / atlas.x,
63
+ (uBaseDecals[0].y + clamp(face.y, inset, 1.0 - inset) * span) / atlas.y
64
+ );
65
+ vec4 texel = textureLod(uFixtureAtlas, at, 0.0);
66
+ base = mix(base, decalLinear(texel.rgb), clamp(texel.a * laid.y, 0.0, 1.0));
67
+ }
68
+ return base;
69
+ }
70
+ `;
71
+ }
@@ -0,0 +1,45 @@
1
+ /**
2
+ * A material's glow moved and masked: `SurfaceMaterial.emissiveScroll` and `emissiveMaskLayer`,
3
+ * packed into `uEmissiveFlow` by `emissiveFlow.ts`, where what each gives up is said.
4
+ *
5
+ * **The scroll** is added to wherever the emissive map is read, the mesh's coordinates, a layer's
6
+ * place or a projection's planes, as its own repeats a second times the surface clock, wrapped to
7
+ * one repeat. **The mask** is the alpha of a layer of the emissive map's own array at the mesh's
8
+ * first coordinates as they are: the vertex stage has already laid the material's repeat and offset
9
+ * on `vUv`, so the second vector holds their inverse, which gives the mesh's own back.
10
+ *
11
+ * **A lit switch, `EMISSIVE_FLOW`**, off until a material first asks, declared after
12
+ * `SURFACE_DETAIL` so each switch before it keeps its id. Compiled out, its two vectors are not
13
+ * declared and the shift and the mask are constants.
14
+ */
15
+ export function emissiveFlowGlsl(on: boolean): string {
16
+ if (!on) {
17
+ return /* glsl */ `
18
+ const bool EMISSIVE_FLOW = false; // wgsl:override
19
+ vec2 emissiveShift() { return vec2(0.0); }
20
+ float emissiveMask() { return 1.0; }
21
+ `;
22
+ }
23
+ return /* glsl */ `
24
+ /* Whether this program moves and masks a material's glow. Off but where one has asked. */
25
+ const bool EMISSIVE_FLOW = true; // wgsl:override
26
+ /*
27
+ * Two vectors, as emissiveFlow.ts packs them: the scroll in repeats a second on u and v (x y) and
28
+ * the mask's layer of the emissive array plus one, 0 for none (z); then what takes the material's
29
+ * coordinates back to the mesh's own, a scale (x y) and an offset (z w).
30
+ */
31
+ uniform vec4 uEmissiveFlow[2]; // wgsl:material
32
+
33
+ /* The emissive map's coordinates moved by the clock, wrapped to one repeat for precision. */
34
+ vec2 emissiveShift() {
35
+ return fract(uEmissiveFlow[0].xy * uSurfaceScene.x);
36
+ }
37
+
38
+ /* The mask's alpha at the mesh's own coordinates, under the branch on a bound emissive map. */
39
+ float emissiveMask() {
40
+ if (uEmissiveFlow[0].z < 0.5) return 1.0;
41
+ vec2 own = vUv.xy * uEmissiveFlow[1].xy + uEmissiveFlow[1].zw;
42
+ return texture(uEmissiveMap, vec3(own, uEmissiveFlow[0].z - 1.0)).a;
43
+ }
44
+ `;
45
+ }
@@ -41,7 +41,10 @@ export type LitSwitch =
41
41
  | 'MOVING_SUN'
42
42
  | 'LAYERED'
43
43
  | 'LAYER_LOOKS'
44
- | 'SURFACE_DETAIL';
44
+ | 'SURFACE_DETAIL'
45
+ | 'EMISSIVE_FLOW'
46
+ | 'IRIDESCENT_OVERLAY'
47
+ | 'BASE_DECALS';
45
48
 
46
49
  /** Every switch off: what a renderer starts from before its profile and its content say otherwise. */
47
50
  export function noLitSwitches(): Record<LitSwitch, boolean> {
@@ -59,6 +62,9 @@ export function noLitSwitches(): Record<LitSwitch, boolean> {
59
62
  LAYERED: false,
60
63
  LAYER_LOOKS: false,
61
64
  SURFACE_DETAIL: false,
65
+ EMISSIVE_FLOW: false,
66
+ IRIDESCENT_OVERLAY: false,
67
+ BASE_DECALS: false,
62
68
  };
63
69
  }
64
70
 
@@ -25,6 +25,9 @@ import { reflectionSurfaceGlsl } from './reflectionSurface.ts';
25
25
  import { worldUvGlsl } from './worldUv.ts';
26
26
  import { movingSunGlsl } from './movingSun.ts';
27
27
  import { detailGlsl } from './detail.ts';
28
+ import { emissiveFlowGlsl } from './emissiveFlow.ts';
29
+ import { iridescentOverlayGlsl } from './iridescentOverlay.ts';
30
+ import { baseDecalsGlsl } from './baseDecals.ts';
28
31
  import { layeredGlsl } from './layered.ts';
29
32
  import { MAP_READS_GLSL } from './mapReads.ts';
30
33
  import { CHANNEL_BEND, LIT_CHANNEL_ATTRIBUTE } from '../vertexChannel.ts';
@@ -545,6 +548,22 @@ export interface FlatShaderOptions {
545
548
  * (\`SurfaceMaterial.detail\`). True when absent. See \`detailGlsl\`.
546
549
  */
547
550
  readonly surfaceDetail?: boolean;
551
+ /**
552
+ * Whether it moves and masks a material's glow, `uEmissiveFlow` and all
553
+ * (`SurfaceMaterial.emissiveScroll` and `emissiveMaskLayer`). True when absent. See
554
+ * `emissiveFlowGlsl`.
555
+ */
556
+ readonly emissiveFlow?: boolean;
557
+ /**
558
+ * Whether it lays a material's iridescent overlay on the base colour, `uIridescence` and all
559
+ * (`SurfaceMaterial.iridescentOverlay`). True when absent. See `iridescentOverlayGlsl`.
560
+ */
561
+ readonly iridescentOverlay?: boolean;
562
+ /**
563
+ * Whether it lays the frame's decals into base colours, `uBaseDecals` and all
564
+ * (`setBaseDecals`). True when absent. See `baseDecalsGlsl`.
565
+ */
566
+ readonly baseDecals?: boolean;
548
567
  /**
549
568
  * How many point lights this build declares room for. `MAX_POINT_LIGHTS` when absent.
550
569
  *
@@ -700,6 +719,12 @@ export function flatFrag(options: FlatShaderOptions): string {
700
719
  layeredGlsl(options.layered ?? true, options.layerLooks ?? true),
701
720
  /* After the layers, so its switch takes the next id; main calls it. See detail.ts. */
702
721
  detailGlsl(options.surfaceDetail ?? true),
722
+ /* After the detail, so its switch takes the next id; the map reads call it. emissiveFlow.ts. */
723
+ emissiveFlowGlsl(options.emissiveFlow ?? true),
724
+ /* After the glow's flow, so its switch takes the next id; main calls it. iridescentOverlay.ts. */
725
+ iridescentOverlayGlsl(options.iridescentOverlay ?? true),
726
+ /* After the overlay, so its switch takes the next id; main calls it. baseDecals.ts. */
727
+ baseDecalsGlsl(options.baseDecals ?? true),
703
728
  /* Where each map is read, after both of the ways it can be read otherwise. See mapReads.ts. */
704
729
  MAP_READS_GLSL,
705
730
  /* After the frame and the projection it calls, unconditional for the same reason as the