@driftengine/texture 4.0.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 (99) hide show
  1. package/LICENSE +202 -0
  2. package/NOTICE +29 -0
  3. package/README.md +106 -0
  4. package/dist/decodeCpu.d.ts +59 -0
  5. package/dist/decodeCpu.js +234 -0
  6. package/dist/decodeGraph.d.ts +105 -0
  7. package/dist/decodeGraph.js +180 -0
  8. package/dist/half.d.ts +24 -0
  9. package/dist/half.js +86 -0
  10. package/dist/index.d.ts +66 -0
  11. package/dist/index.js +55 -0
  12. package/dist/inference.d.ts +53 -0
  13. package/dist/inference.js +243 -0
  14. package/dist/materialArray.d.ts +38 -0
  15. package/dist/materialArray.js +40 -0
  16. package/dist/mipNdf.d.ts +29 -0
  17. package/dist/mipNdf.js +53 -0
  18. package/dist/overlay/journal.d.ts +78 -0
  19. package/dist/overlay/journal.js +171 -0
  20. package/dist/overlay/sparse.d.ts +68 -0
  21. package/dist/overlay/sparse.js +212 -0
  22. package/dist/progressive.d.ts +30 -0
  23. package/dist/progressive.js +56 -0
  24. package/dist/residency/pageCache.d.ts +103 -0
  25. package/dist/residency/pageCache.js +184 -0
  26. package/dist/residency/predict.d.ts +55 -0
  27. package/dist/residency/predict.js +51 -0
  28. package/dist/residency/predictor.d.ts +16 -0
  29. package/dist/residency/predictor.js +44 -0
  30. package/dist/residency/queue.d.ts +26 -0
  31. package/dist/residency/queue.js +52 -0
  32. package/dist/residency/stream.d.ts +66 -0
  33. package/dist/residency/stream.js +142 -0
  34. package/dist/residency/table.d.ts +36 -0
  35. package/dist/residency/table.js +72 -0
  36. package/dist/residency/viewTiles.d.ts +108 -0
  37. package/dist/residency/viewTiles.js +419 -0
  38. package/dist/semantics.d.ts +52 -0
  39. package/dist/semantics.js +76 -0
  40. package/dist/tensor/architecture.d.ts +53 -0
  41. package/dist/tensor/architecture.js +96 -0
  42. package/dist/tensor/attention.d.ts +5 -0
  43. package/dist/tensor/attention.js +62 -0
  44. package/dist/tensor/denseOperators.d.ts +2 -0
  45. package/dist/tensor/denseOperators.js +136 -0
  46. package/dist/tensor/graph.d.ts +83 -0
  47. package/dist/tensor/graph.js +175 -0
  48. package/dist/tensor/linear.d.ts +49 -0
  49. package/dist/tensor/linear.js +136 -0
  50. package/dist/tensor/operatorKit.d.ts +27 -0
  51. package/dist/tensor/operatorKit.js +45 -0
  52. package/dist/tensor/operators.d.ts +3 -0
  53. package/dist/tensor/operators.js +24 -0
  54. package/dist/tensor/resize.d.ts +6 -0
  55. package/dist/tensor/resize.js +107 -0
  56. package/dist/tensor/reuse.d.ts +33 -0
  57. package/dist/tensor/reuse.js +59 -0
  58. package/dist/tensor/shapeOperators.d.ts +3 -0
  59. package/dist/tensor/shapeOperators.js +173 -0
  60. package/dist/tensor/spatial.d.ts +34 -0
  61. package/dist/tensor/spatial.js +131 -0
  62. package/dist/tensor/spatialOperators.d.ts +2 -0
  63. package/dist/tensor/spatialOperators.js +138 -0
  64. package/dist/tileHash.d.ts +29 -0
  65. package/dist/tileHash.js +50 -0
  66. package/dist/timeNodes.d.ts +26 -0
  67. package/dist/timeNodes.js +48 -0
  68. package/package.json +59 -0
  69. package/src/decodeCpu.ts +308 -0
  70. package/src/decodeGraph.ts +214 -0
  71. package/src/half.ts +86 -0
  72. package/src/index.ts +175 -0
  73. package/src/inference.ts +278 -0
  74. package/src/materialArray.ts +67 -0
  75. package/src/mipNdf.ts +63 -0
  76. package/src/overlay/journal.ts +218 -0
  77. package/src/overlay/sparse.ts +275 -0
  78. package/src/progressive.ts +60 -0
  79. package/src/residency/pageCache.ts +233 -0
  80. package/src/residency/predict.ts +74 -0
  81. package/src/residency/predictor.ts +62 -0
  82. package/src/residency/queue.ts +78 -0
  83. package/src/residency/stream.ts +194 -0
  84. package/src/residency/table.ts +89 -0
  85. package/src/residency/viewTiles.ts +553 -0
  86. package/src/semantics.ts +114 -0
  87. package/src/tensor/architecture.ts +140 -0
  88. package/src/tensor/attention.ts +75 -0
  89. package/src/tensor/denseOperators.ts +153 -0
  90. package/src/tensor/graph.ts +244 -0
  91. package/src/tensor/linear.ts +153 -0
  92. package/src/tensor/operatorKit.ts +76 -0
  93. package/src/tensor/operators.ts +28 -0
  94. package/src/tensor/resize.ts +140 -0
  95. package/src/tensor/shapeOperators.ts +173 -0
  96. package/src/tensor/spatial.ts +178 -0
  97. package/src/tensor/spatialOperators.ts +182 -0
  98. package/src/tileHash.ts +60 -0
  99. package/src/timeNodes.ts +60 -0
@@ -0,0 +1,218 @@
1
+ /**
2
+ * A scorch mark is in the input log, so a replay burns the same wall.
3
+ *
4
+ * **Runtime-mutable textures normally break replay.** The marks are not part of the simulation and
5
+ * nothing records them, so a recording plays back a world where the walls are clean — and the
6
+ * failure has the worst possible shape: nothing goes wrong at the time, everything looks right, and
7
+ * the recording is wrong forever. Anybody who later debugs from it is debugging a session that did
8
+ * not happen.
9
+ *
10
+ * Here a write is recorded in the same log the input goes into, so replaying a session reproduces
11
+ * the exact mark in the exact place — and the same mechanism means a rollback un-draws what the
12
+ * rolled-back frames drew, because rolling back an overlay *is* rebuilding it from the journal up
13
+ * to the frame you rolled back to. An overlay cannot be snapshotted the way the world is: it is
14
+ * sparse and unbounded, and a snapshot per frame of something that grows is the cost the sparseness
15
+ * was for.
16
+ *
17
+ * **Applying does not clear, and rewinding does.** A replay that always cleared could not be used
18
+ * to catch up frame by frame, which is what a live session does. `rewindOverlay` is the one that
19
+ * puts the world back, and it says so in its name.
20
+ *
21
+ * **The journal is truncated on rollback, and forgetting that is the bug.** Re-simulating after a
22
+ * rollback records its writes again; the frames that did not happen must not still be in the log,
23
+ * or a replay from the start draws both the mark that happened and the one that was undone.
24
+ */
25
+ import { createOverlay, writeOverlay, type Overlay } from './sparse.ts';
26
+
27
+ /** Bytes an entry takes on the wire: two `u32` and four `f32`. */
28
+ export const JOURNAL_ENTRY_BYTES = 24;
29
+ /** `"DOVJ"`, little-endian, as every other record in this package spells its magic. */
30
+ export const JOURNAL_MAGIC = 0x4a564f44;
31
+ export const JOURNAL_VERSION = 1;
32
+
33
+ export interface OverlayJournal {
34
+ /** One frame number an entry. */
35
+ frames: Int32Array;
36
+ /** One channel index an entry. */
37
+ channels: Int32Array;
38
+ /** Four values an entry: u, v, radius, value. */
39
+ values: Float32Array;
40
+ count: number;
41
+ }
42
+
43
+ export function createOverlayJournal(capacity = 256): OverlayJournal {
44
+ const size = Math.max(1, Math.floor(capacity));
45
+ return {
46
+ frames: new Int32Array(size),
47
+ channels: new Int32Array(size),
48
+ values: new Float32Array(size * 4),
49
+ count: 0,
50
+ };
51
+ }
52
+
53
+ function grow(journal: OverlayJournal): void {
54
+ if (journal.count < journal.frames.length) return;
55
+ const size = journal.frames.length * 2;
56
+ const frames = new Int32Array(size);
57
+ const channels = new Int32Array(size);
58
+ const values = new Float32Array(size * 4);
59
+ frames.set(journal.frames);
60
+ channels.set(journal.channels);
61
+ values.set(journal.values);
62
+ journal.frames = frames;
63
+ journal.channels = channels;
64
+ journal.values = values;
65
+ }
66
+
67
+ /**
68
+ * Record a write. Stored as `f32`, which is what makes the encoding round-trip exactly.
69
+ *
70
+ * A journal of `f64` written out as `f32` reproduces a *nearly* identical mark, and "nearly" in a
71
+ * replay is a divergence that appears at the worst moment — the fingerprint that no longer matches
72
+ * a recording, for a reason nobody would look for in a texture.
73
+ */
74
+ export function recordOverlayWrite(
75
+ journal: OverlayJournal,
76
+ frame: number,
77
+ u: number,
78
+ v: number,
79
+ radius: number,
80
+ channel: number,
81
+ value: number,
82
+ ): void {
83
+ grow(journal);
84
+ const at = journal.count;
85
+ journal.frames[at] = frame;
86
+ journal.channels[at] = channel;
87
+ journal.values[at * 4] = u;
88
+ journal.values[at * 4 + 1] = v;
89
+ journal.values[at * 4 + 2] = radius;
90
+ journal.values[at * 4 + 3] = value;
91
+ journal.count += 1;
92
+ }
93
+
94
+ export function journalLength(journal: OverlayJournal): number {
95
+ return journal.count;
96
+ }
97
+
98
+ /**
99
+ * Forget every entry from `frame` onward. What a rollback owes the journal.
100
+ *
101
+ * Entries are recorded in frame order, so this is a truncation rather than a filter — and where
102
+ * they are not, the scan below still removes exactly the right ones.
103
+ */
104
+ export function truncateJournalFrom(journal: OverlayJournal, frame: number): number {
105
+ let kept = 0;
106
+ for (let at = 0; at < journal.count; at += 1) {
107
+ if ((journal.frames[at] as number) >= frame) continue;
108
+ if (kept !== at) {
109
+ journal.frames[kept] = journal.frames[at] as number;
110
+ journal.channels[kept] = journal.channels[at] as number;
111
+ for (let c = 0; c < 4; c += 1) {
112
+ journal.values[kept * 4 + c] = journal.values[at * 4 + c] as number;
113
+ }
114
+ }
115
+ kept += 1;
116
+ }
117
+ const dropped = journal.count - kept;
118
+ journal.count = kept;
119
+ return dropped;
120
+ }
121
+
122
+ /** Apply every entry up to and including `upToFrame`, onto whatever the overlay already holds. */
123
+ export function applyOverlayJournal(
124
+ overlay: Overlay,
125
+ journal: OverlayJournal,
126
+ upToFrame: number,
127
+ ): number {
128
+ let applied = 0;
129
+ for (let at = 0; at < journal.count; at += 1) {
130
+ if ((journal.frames[at] as number) > upToFrame) continue;
131
+ writeOverlay(
132
+ overlay,
133
+ journal.values[at * 4] as number,
134
+ journal.values[at * 4 + 1] as number,
135
+ journal.values[at * 4 + 2] as number,
136
+ journal.channels[at] as number,
137
+ journal.values[at * 4 + 3] as number,
138
+ );
139
+ applied += 1;
140
+ }
141
+ return applied;
142
+ }
143
+
144
+ /** Throw away every written tile. The overlay keeps its shape and holds nothing. */
145
+ export function clearOverlay(overlay: Overlay): void {
146
+ overlay.tiles.clear();
147
+ }
148
+
149
+ /**
150
+ * Put the overlay back to how it was at `upToFrame`: clear, then replay.
151
+ *
152
+ * Rebuilt rather than undone, because a write is not invertible — two marks on one texel leave no
153
+ * record of what was underneath, and an overlay that tried to undo would need a history per texel,
154
+ * which is the dense layer the whole design exists to avoid.
155
+ */
156
+ export function rewindOverlay(
157
+ overlay: Overlay,
158
+ journal: OverlayJournal,
159
+ upToFrame: number,
160
+ ): number {
161
+ clearOverlay(overlay);
162
+ return applyOverlayJournal(overlay, journal, upToFrame);
163
+ }
164
+
165
+ /** An overlay with the same shape as `like` and nothing written. What a replay starts from. */
166
+ export function emptyLike(like: Overlay): Overlay {
167
+ return createOverlay(like.tileSize, {
168
+ tilesAcross: like.tilesAcross,
169
+ addressMode: like.addressMode,
170
+ });
171
+ }
172
+
173
+ export function encodeOverlayJournal(journal: OverlayJournal): Uint8Array {
174
+ const bytes = new Uint8Array(12 + journal.count * JOURNAL_ENTRY_BYTES);
175
+ const view = new DataView(bytes.buffer);
176
+ view.setUint32(0, JOURNAL_MAGIC, true);
177
+ view.setUint32(4, JOURNAL_VERSION, true);
178
+ view.setUint32(8, journal.count, true);
179
+ for (let at = 0; at < journal.count; at += 1) {
180
+ const base = 12 + at * JOURNAL_ENTRY_BYTES;
181
+ view.setInt32(base, journal.frames[at] as number, true);
182
+ view.setInt32(base + 4, journal.channels[at] as number, true);
183
+ for (let c = 0; c < 4; c += 1) {
184
+ view.setFloat32(base + 8 + c * 4, journal.values[at * 4 + c] as number, true);
185
+ }
186
+ }
187
+ return bytes;
188
+ }
189
+
190
+ /**
191
+ * Read a journal back. Null where the bytes are not one.
192
+ *
193
+ * Null rather than a partial journal: a replay against half a log is a session that diverges partway
194
+ * through for no visible reason, which is worse than one that refuses to start.
195
+ */
196
+ export function decodeOverlayJournal(bytes: Uint8Array): OverlayJournal | null {
197
+ if (bytes.byteLength < 12) return null;
198
+ const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
199
+ if (view.getUint32(0, true) !== JOURNAL_MAGIC) return null;
200
+ if (view.getUint32(4, true) !== JOURNAL_VERSION) return null;
201
+ const count = view.getUint32(8, true);
202
+ if (bytes.byteLength !== 12 + count * JOURNAL_ENTRY_BYTES) return null;
203
+
204
+ const journal = createOverlayJournal(Math.max(1, count));
205
+ for (let at = 0; at < count; at += 1) {
206
+ const base = 12 + at * JOURNAL_ENTRY_BYTES;
207
+ recordOverlayWrite(
208
+ journal,
209
+ view.getInt32(base, true),
210
+ view.getFloat32(base + 8, true),
211
+ view.getFloat32(base + 12, true),
212
+ view.getFloat32(base + 16, true),
213
+ view.getInt32(base + 4, true),
214
+ view.getFloat32(base + 20, true),
215
+ );
216
+ }
217
+ return journal;
218
+ }
@@ -0,0 +1,275 @@
1
+ /**
2
+ * A texture that can be written to at runtime, without allocating a layer for what nobody touched.
3
+ *
4
+ * **Sparse, so that a scorch mark on one wall does not allocate a layer for the building.** Only
5
+ * written tiles exist; an unwritten coordinate reports nothing written and the base is used
6
+ * unchanged. That is the difference between a decal system a game can leave on for a whole level
7
+ * and one somebody turns off because of the memory.
8
+ *
9
+ * **Written is tracked per texel *and* per channel.** A scorch darkens the albedo and leaves the
10
+ * normal alone, so compositing has to know which channels a texel had written rather than treating
11
+ * a written texel as wholly overwritten. One byte a texel, one bit a channel — which caps the
12
+ * channel count at eight and is stated where the array is declared rather than discovered.
13
+ *
14
+ * **Two numbers are needed to turn a UV into a texel, and the plan gave one.** `tileSize` is texels
15
+ * per tile edge and says nothing about how much of the surface a tile covers; `tilesAcross` is what
16
+ * closes it. Without both, a radius in UV has no length in texels and every write is either the
17
+ * whole surface or a single texel depending on which guess was made.
18
+ *
19
+ * **The address mode is the base texture's**, not this file's opinion. `DecodeGraph` carries one —
20
+ * 0 clamps outside the unit square and 1 wraps — and an overlay that disagreed would put a mark on
21
+ * the far side of a wall from where somebody aimed, on exactly the textures that wrap.
22
+ */
23
+ import { hashTile } from '../tileHash.ts';
24
+
25
+ /** How many channels a texel carries. One bit each in the written mask, so this cannot exceed 8. */
26
+ export const OVERLAY_CHANNELS = 4;
27
+
28
+ export const ADDRESS_CLAMP = 0;
29
+ export const ADDRESS_WRAP = 1;
30
+
31
+ export interface OverlayTile {
32
+ readonly tx: number;
33
+ readonly ty: number;
34
+ /** `tileSize * tileSize * OVERLAY_CHANNELS` values. */
35
+ readonly texels: Float32Array;
36
+ /** One byte a texel; bit `c` is set where channel `c` has been written. */
37
+ readonly written: Uint8Array;
38
+ /** Content hash, recomputed lazily after a write. Empty means stale. */
39
+ hash: string;
40
+ }
41
+
42
+ export interface OverlayOptions {
43
+ /** Tiles across the unit square. With `tileSize`, this fixes the texel density. */
44
+ readonly tilesAcross?: number;
45
+ /** `ADDRESS_CLAMP` or `ADDRESS_WRAP`. Take it from the base texture's `DecodeGraph`. */
46
+ readonly addressMode?: number;
47
+ }
48
+
49
+ export interface Overlay {
50
+ /** Texels along a tile edge. */
51
+ readonly tileSize: number;
52
+ /** Tiles along the unit square's edge. */
53
+ readonly tilesAcross: number;
54
+ /** Texels along the whole unit square's edge: `tileSize * tilesAcross`. */
55
+ readonly resolution: number;
56
+ readonly addressMode: number;
57
+ /** Written tiles only, keyed `"tx,ty"`. An untouched tile does not exist. */
58
+ readonly tiles: Map<string, OverlayTile>;
59
+ }
60
+
61
+ export function createOverlay(tileSize: number, options: OverlayOptions = {}): Overlay {
62
+ const size = Math.max(1, Math.floor(tileSize));
63
+ const across = Math.max(1, Math.floor(options.tilesAcross ?? 16));
64
+ return {
65
+ tileSize: size,
66
+ tilesAcross: across,
67
+ resolution: size * across,
68
+ addressMode: options.addressMode === ADDRESS_WRAP ? ADDRESS_WRAP : ADDRESS_CLAMP,
69
+ tiles: new Map<string, OverlayTile>(),
70
+ };
71
+ }
72
+
73
+ function key(tx: number, ty: number): string {
74
+ return `${String(tx)},${String(ty)}`;
75
+ }
76
+
77
+ function tileAt(overlay: Overlay, tx: number, ty: number, make: boolean): OverlayTile | null {
78
+ const at = key(tx, ty);
79
+ const held = overlay.tiles.get(at);
80
+ if (held !== undefined) return held;
81
+ if (!make) return null;
82
+ const texels = overlay.tileSize * overlay.tileSize;
83
+ const tile: OverlayTile = {
84
+ tx,
85
+ ty,
86
+ texels: new Float32Array(texels * OVERLAY_CHANNELS),
87
+ written: new Uint8Array(texels),
88
+ hash: '',
89
+ };
90
+ overlay.tiles.set(at, tile);
91
+ return tile;
92
+ }
93
+
94
+ /**
95
+ * A texel coordinate brought inside the surface, or `-1` where it is outside and the mode clamps.
96
+ *
97
+ * `-1` rather than a clamped edge texel under `ADDRESS_CLAMP`: clamping a *write* would smear the
98
+ * whole edge of the texture with whatever somebody aimed past it, which is a visible band rather
99
+ * than the nothing they expected. Clamping a *read* is right, and `sampleOverlay` does that.
100
+ */
101
+ function wrapTexel(overlay: Overlay, texel: number, forWrite: boolean): number {
102
+ const size = overlay.resolution;
103
+ if (overlay.addressMode === ADDRESS_WRAP) return ((texel % size) + size) % size;
104
+ if (texel >= 0 && texel < size) return texel;
105
+ return forWrite ? -1 : Math.min(size - 1, Math.max(0, texel));
106
+ }
107
+
108
+ /**
109
+ * Write `value` into one channel of every texel within `radius` of `(u, v)`.
110
+ *
111
+ * A disc rather than a square, because a square decal is a square nobody asked for, and the radius
112
+ * is in UV so a caller reasons in surface fractions rather than in whatever resolution this is.
113
+ */
114
+ export function writeOverlay(
115
+ overlay: Overlay,
116
+ u: number,
117
+ v: number,
118
+ radius: number,
119
+ channel: number,
120
+ value: number,
121
+ ): number {
122
+ if (channel < 0 || channel >= OVERLAY_CHANNELS) return 0;
123
+ const size = overlay.resolution;
124
+ const centreX = u * size;
125
+ const centreY = v * size;
126
+ const reach = Math.max(0, radius) * size;
127
+
128
+ const from = Math.floor(centreX - reach);
129
+ const to = Math.ceil(centreX + reach);
130
+ const top = Math.floor(centreY - reach);
131
+ const bottom = Math.ceil(centreY + reach);
132
+
133
+ let touched = 0;
134
+ for (let y = top; y <= bottom; y += 1) {
135
+ for (let x = from; x <= to; x += 1) {
136
+ /* Texel centres, so a radius of half a texel marks one texel rather than none or four. */
137
+ const dx = x + 0.5 - centreX;
138
+ const dy = y + 0.5 - centreY;
139
+ if (dx * dx + dy * dy > reach * reach) continue;
140
+
141
+ const wx = wrapTexel(overlay, x, true);
142
+ const wy = wrapTexel(overlay, y, true);
143
+ if (wx < 0 || wy < 0) continue;
144
+
145
+ /*
146
+ * The tile is found from the *wrapped* texel, which is what makes a write near a boundary
147
+ * land in both tiles: the loop runs over one continuous span and each texel finds its own
148
+ * tile. A version that found one tile and wrote into it leaves a hard edge at every seam —
149
+ * the defect that looks like the decal was clipped and is usually blamed on the mesh.
150
+ */
151
+ const tile = tileAt(
152
+ overlay,
153
+ Math.floor(wx / overlay.tileSize),
154
+ Math.floor(wy / overlay.tileSize),
155
+ true,
156
+ ) as OverlayTile;
157
+ const local = (wy % overlay.tileSize) * overlay.tileSize + (wx % overlay.tileSize);
158
+ tile.texels[local * OVERLAY_CHANNELS + channel] = value;
159
+ tile.written[local] = (tile.written[local] as number) | (1 << channel);
160
+ tile.hash = '';
161
+ touched += 1;
162
+ }
163
+ }
164
+ return touched;
165
+ }
166
+
167
+ /**
168
+ * Read every channel at a coordinate into `out`. False where nothing has been written there.
169
+ *
170
+ * False is the ordinary answer and the important one: it is what tells a sampler to use the base
171
+ * unchanged rather than compositing a zero over it.
172
+ */
173
+ export function sampleOverlay(overlay: Overlay, u: number, v: number, out: Float32Array): boolean {
174
+ const size = overlay.resolution;
175
+ const x = wrapTexel(overlay, Math.floor(u * size), false);
176
+ const y = wrapTexel(overlay, Math.floor(v * size), false);
177
+ const tile = tileAt(
178
+ overlay,
179
+ Math.floor(x / overlay.tileSize),
180
+ Math.floor(y / overlay.tileSize),
181
+ false,
182
+ );
183
+ if (tile === null) return false;
184
+
185
+ const local = (y % overlay.tileSize) * overlay.tileSize + (x % overlay.tileSize);
186
+ if ((tile.written[local] ?? 0) === 0) return false;
187
+ for (let c = 0; c < OVERLAY_CHANNELS; c += 1) {
188
+ out[c] = tile.texels[local * OVERLAY_CHANNELS + c] as number;
189
+ }
190
+ return true;
191
+ }
192
+
193
+ /** Which channels have been written at a coordinate, as a bit per channel. Zero for none. */
194
+ export function writtenMaskAt(overlay: Overlay, u: number, v: number): number {
195
+ const size = overlay.resolution;
196
+ const x = wrapTexel(overlay, Math.floor(u * size), false);
197
+ const y = wrapTexel(overlay, Math.floor(v * size), false);
198
+ const tile = tileAt(
199
+ overlay,
200
+ Math.floor(x / overlay.tileSize),
201
+ Math.floor(y / overlay.tileSize),
202
+ false,
203
+ );
204
+ if (tile === null) return 0;
205
+ return tile.written[(y % overlay.tileSize) * overlay.tileSize + (x % overlay.tileSize)] ?? 0;
206
+ }
207
+
208
+ /**
209
+ * A tile's content hash, computed once per write rather than once per call.
210
+ *
211
+ * **A cost guard, not a correctness one**, and it does not change an answer — a perturbation
212
+ * removing it fails no test and is expected to. It stays because `overlayTiles` is called every
213
+ * frame to decide uploads, and hashing is bytes: four hundred written tiles of 64×64×4 floats took
214
+ * **79 ms uncached and 0.061 ms cached**, measured 2026-09-15. The first of those is a frame
215
+ * budget spent several times over to learn that nothing changed.
216
+ */
217
+ function hashOf(overlay: Overlay, tile: OverlayTile): string {
218
+ if (tile.hash !== '') return tile.hash;
219
+ /* The written mask is hashed with the values: two tiles holding the same numbers where one of
220
+ them wrote a zero and the other did not are different tiles, and compositing proves it. */
221
+ const bytes = new Uint8Array(tile.texels.buffer.byteLength + tile.written.byteLength);
222
+ bytes.set(new Uint8Array(tile.texels.buffer), 0);
223
+ bytes.set(tile.written, tile.texels.buffer.byteLength);
224
+ tile.hash = hashTile(bytes);
225
+ return tile.hash;
226
+ }
227
+
228
+ /**
229
+ * The distinct content hashes of the written tiles, into `out`. Returns how many.
230
+ *
231
+ * **Distinct, because an identical mark in two places is one thing to store.** The same blast
232
+ * scorch on twenty crates is twenty tiles in memory — each is written in place and they diverge the
233
+ * moment anything else touches one — and one page to upload, which is where the cost actually is.
234
+ * Hashed exactly as a base tile is, by `hashTile`, so the two live in one address space.
235
+ */
236
+ export function overlayTiles(overlay: Overlay, out: string[]): number {
237
+ out.length = 0;
238
+ const seen = new Set<string>();
239
+ for (const tile of overlay.tiles.values()) {
240
+ const hash = hashOf(overlay, tile);
241
+ if (seen.has(hash)) continue;
242
+ seen.add(hash);
243
+ out.push(hash);
244
+ }
245
+ out.sort();
246
+ return out.length;
247
+ }
248
+
249
+ /** How many tiles hold a written texel. What a memory readout shows. */
250
+ export function overlayTileCount(overlay: Overlay): number {
251
+ return overlay.tiles.size;
252
+ }
253
+
254
+ /**
255
+ * Put the overlay over the base, channel by channel, into `out`.
256
+ *
257
+ * **`written` is a parameter because the plan's signature had no way to say which texels and
258
+ * channels were touched**, and without it compositing either overwrites everything — so one scorch
259
+ * mark erases the base across the whole tile — or guesses from a sentinel value, which is a value
260
+ * somebody eventually writes on purpose.
261
+ */
262
+ export function compositeOverlay(
263
+ out: Float32Array,
264
+ base: Float32Array,
265
+ overlay: Float32Array,
266
+ written: Uint8Array,
267
+ ): void {
268
+ for (let texel = 0; texel < written.length; texel += 1) {
269
+ const mask = written[texel] as number;
270
+ for (let c = 0; c < OVERLAY_CHANNELS; c += 1) {
271
+ const at = texel * OVERLAY_CHANNELS + c;
272
+ out[at] = (mask & (1 << c)) === 0 ? (base[at] as number) : (overlay[at] as number);
273
+ }
274
+ }
275
+ }
@@ -0,0 +1,60 @@
1
+ /**
2
+ * The order tiles are sent in, so a material is usable long before it is complete.
3
+ *
4
+ * **There is precedent in this repository and this follows it.** `drft/coarseFirst.ts` opens a
5
+ * splat capture in a fourteenth of its bytes through a Morton quantisation and a bit-reversal
6
+ * walk, and the principle is the same: a prefix of the stream should be spread over the whole
7
+ * image rather than filling one corner of it.
8
+ *
9
+ * **Bit-reversal is what spreads it.** Counting 0, 1, 2, 3 fills left to right; counting with the
10
+ * index's bits reversed visits 0, 4, 2, 6, 1, 5, 3, 7 — every prefix roughly uniform over the
11
+ * range, so an interrupted download is a coarse version of the whole material rather than a sharp
12
+ * version of part of it.
13
+ */
14
+
15
+ function reverseBits(value: number, bits: number): number {
16
+ let out = 0;
17
+ for (let i = 0; i < bits; i += 1) out = (out << 1) | ((value >>> i) & 1);
18
+ return out >>> 0;
19
+ }
20
+
21
+ /**
22
+ * Fill `out` with the order to send `tileCount` tiles in. Returns how many were written.
23
+ *
24
+ * A permutation: every tile appears exactly once, which the test asserts because an ordering that
25
+ * drops one is a material that never completes and never says why.
26
+ */
27
+ export function progressiveOrder(tileCount: number, out: Uint32Array): number {
28
+ if (tileCount <= 0) return 0;
29
+ const bits = Math.max(1, Math.ceil(Math.log2(tileCount)));
30
+ const span = 1 << bits;
31
+ let written = 0;
32
+ for (let i = 0; i < span; i += 1) {
33
+ const tile = reverseBits(i, bits);
34
+ /* The reversal walks a power-of-two range; anything past the real count is skipped. */
35
+ if (tile < tileCount) {
36
+ out[written] = tile;
37
+ written += 1;
38
+ }
39
+ }
40
+ return written;
41
+ }
42
+
43
+ /**
44
+ * What level this many received bytes supports.
45
+ *
46
+ * Levels rise monotonically with bytes, which is what lets a loader show something immediately and
47
+ * refine without ever going backwards.
48
+ */
49
+ export function usableAt(
50
+ bytesReceived: number,
51
+ tileBytes: number,
52
+ tileCount: number,
53
+ ): { level: number; complete: boolean } {
54
+ if (tileBytes <= 0 || tileCount <= 0) return { level: 0, complete: true };
55
+ const tiles = Math.min(tileCount, Math.floor(bytesReceived / tileBytes));
56
+ const complete = tiles >= tileCount;
57
+ /* Each doubling of received tiles is one more level of detail. */
58
+ const level = tiles <= 0 ? 0 : Math.floor(Math.log2(tiles)) + 1;
59
+ return { level, complete };
60
+ }