@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.
- package/LICENSE +202 -0
- package/NOTICE +29 -0
- package/README.md +106 -0
- package/dist/decodeCpu.d.ts +59 -0
- package/dist/decodeCpu.js +234 -0
- package/dist/decodeGraph.d.ts +105 -0
- package/dist/decodeGraph.js +180 -0
- package/dist/half.d.ts +24 -0
- package/dist/half.js +86 -0
- package/dist/index.d.ts +66 -0
- package/dist/index.js +55 -0
- package/dist/inference.d.ts +53 -0
- package/dist/inference.js +243 -0
- package/dist/materialArray.d.ts +38 -0
- package/dist/materialArray.js +40 -0
- package/dist/mipNdf.d.ts +29 -0
- package/dist/mipNdf.js +53 -0
- package/dist/overlay/journal.d.ts +78 -0
- package/dist/overlay/journal.js +171 -0
- package/dist/overlay/sparse.d.ts +68 -0
- package/dist/overlay/sparse.js +212 -0
- package/dist/progressive.d.ts +30 -0
- package/dist/progressive.js +56 -0
- package/dist/residency/pageCache.d.ts +103 -0
- package/dist/residency/pageCache.js +184 -0
- package/dist/residency/predict.d.ts +55 -0
- package/dist/residency/predict.js +51 -0
- package/dist/residency/predictor.d.ts +16 -0
- package/dist/residency/predictor.js +44 -0
- package/dist/residency/queue.d.ts +26 -0
- package/dist/residency/queue.js +52 -0
- package/dist/residency/stream.d.ts +66 -0
- package/dist/residency/stream.js +142 -0
- package/dist/residency/table.d.ts +36 -0
- package/dist/residency/table.js +72 -0
- package/dist/residency/viewTiles.d.ts +108 -0
- package/dist/residency/viewTiles.js +419 -0
- package/dist/semantics.d.ts +52 -0
- package/dist/semantics.js +76 -0
- package/dist/tensor/architecture.d.ts +53 -0
- package/dist/tensor/architecture.js +96 -0
- package/dist/tensor/attention.d.ts +5 -0
- package/dist/tensor/attention.js +62 -0
- package/dist/tensor/denseOperators.d.ts +2 -0
- package/dist/tensor/denseOperators.js +136 -0
- package/dist/tensor/graph.d.ts +83 -0
- package/dist/tensor/graph.js +175 -0
- package/dist/tensor/linear.d.ts +49 -0
- package/dist/tensor/linear.js +136 -0
- package/dist/tensor/operatorKit.d.ts +27 -0
- package/dist/tensor/operatorKit.js +45 -0
- package/dist/tensor/operators.d.ts +3 -0
- package/dist/tensor/operators.js +24 -0
- package/dist/tensor/resize.d.ts +6 -0
- package/dist/tensor/resize.js +107 -0
- package/dist/tensor/reuse.d.ts +33 -0
- package/dist/tensor/reuse.js +59 -0
- package/dist/tensor/shapeOperators.d.ts +3 -0
- package/dist/tensor/shapeOperators.js +173 -0
- package/dist/tensor/spatial.d.ts +34 -0
- package/dist/tensor/spatial.js +131 -0
- package/dist/tensor/spatialOperators.d.ts +2 -0
- package/dist/tensor/spatialOperators.js +138 -0
- package/dist/tileHash.d.ts +29 -0
- package/dist/tileHash.js +50 -0
- package/dist/timeNodes.d.ts +26 -0
- package/dist/timeNodes.js +48 -0
- package/package.json +59 -0
- package/src/decodeCpu.ts +308 -0
- package/src/decodeGraph.ts +214 -0
- package/src/half.ts +86 -0
- package/src/index.ts +175 -0
- package/src/inference.ts +278 -0
- package/src/materialArray.ts +67 -0
- package/src/mipNdf.ts +63 -0
- package/src/overlay/journal.ts +218 -0
- package/src/overlay/sparse.ts +275 -0
- package/src/progressive.ts +60 -0
- package/src/residency/pageCache.ts +233 -0
- package/src/residency/predict.ts +74 -0
- package/src/residency/predictor.ts +62 -0
- package/src/residency/queue.ts +78 -0
- package/src/residency/stream.ts +194 -0
- package/src/residency/table.ts +89 -0
- package/src/residency/viewTiles.ts +553 -0
- package/src/semantics.ts +114 -0
- package/src/tensor/architecture.ts +140 -0
- package/src/tensor/attention.ts +75 -0
- package/src/tensor/denseOperators.ts +153 -0
- package/src/tensor/graph.ts +244 -0
- package/src/tensor/linear.ts +153 -0
- package/src/tensor/operatorKit.ts +76 -0
- package/src/tensor/operators.ts +28 -0
- package/src/tensor/resize.ts +140 -0
- package/src/tensor/shapeOperators.ts +173 -0
- package/src/tensor/spatial.ts +178 -0
- package/src/tensor/spatialOperators.ts +182 -0
- package/src/tileHash.ts +60 -0
- package/src/timeNodes.ts +60 -0
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
/** How many channels a texel carries. One bit each in the written mask, so this cannot exceed 8. */
|
|
2
|
+
export declare const OVERLAY_CHANNELS = 4;
|
|
3
|
+
export declare const ADDRESS_CLAMP = 0;
|
|
4
|
+
export declare const ADDRESS_WRAP = 1;
|
|
5
|
+
export interface OverlayTile {
|
|
6
|
+
readonly tx: number;
|
|
7
|
+
readonly ty: number;
|
|
8
|
+
/** `tileSize * tileSize * OVERLAY_CHANNELS` values. */
|
|
9
|
+
readonly texels: Float32Array;
|
|
10
|
+
/** One byte a texel; bit `c` is set where channel `c` has been written. */
|
|
11
|
+
readonly written: Uint8Array;
|
|
12
|
+
/** Content hash, recomputed lazily after a write. Empty means stale. */
|
|
13
|
+
hash: string;
|
|
14
|
+
}
|
|
15
|
+
export interface OverlayOptions {
|
|
16
|
+
/** Tiles across the unit square. With `tileSize`, this fixes the texel density. */
|
|
17
|
+
readonly tilesAcross?: number;
|
|
18
|
+
/** `ADDRESS_CLAMP` or `ADDRESS_WRAP`. Take it from the base texture's `DecodeGraph`. */
|
|
19
|
+
readonly addressMode?: number;
|
|
20
|
+
}
|
|
21
|
+
export interface Overlay {
|
|
22
|
+
/** Texels along a tile edge. */
|
|
23
|
+
readonly tileSize: number;
|
|
24
|
+
/** Tiles along the unit square's edge. */
|
|
25
|
+
readonly tilesAcross: number;
|
|
26
|
+
/** Texels along the whole unit square's edge: `tileSize * tilesAcross`. */
|
|
27
|
+
readonly resolution: number;
|
|
28
|
+
readonly addressMode: number;
|
|
29
|
+
/** Written tiles only, keyed `"tx,ty"`. An untouched tile does not exist. */
|
|
30
|
+
readonly tiles: Map<string, OverlayTile>;
|
|
31
|
+
}
|
|
32
|
+
export declare function createOverlay(tileSize: number, options?: OverlayOptions): Overlay;
|
|
33
|
+
/**
|
|
34
|
+
* Write `value` into one channel of every texel within `radius` of `(u, v)`.
|
|
35
|
+
*
|
|
36
|
+
* A disc rather than a square, because a square decal is a square nobody asked for, and the radius
|
|
37
|
+
* is in UV so a caller reasons in surface fractions rather than in whatever resolution this is.
|
|
38
|
+
*/
|
|
39
|
+
export declare function writeOverlay(overlay: Overlay, u: number, v: number, radius: number, channel: number, value: number): number;
|
|
40
|
+
/**
|
|
41
|
+
* Read every channel at a coordinate into `out`. False where nothing has been written there.
|
|
42
|
+
*
|
|
43
|
+
* False is the ordinary answer and the important one: it is what tells a sampler to use the base
|
|
44
|
+
* unchanged rather than compositing a zero over it.
|
|
45
|
+
*/
|
|
46
|
+
export declare function sampleOverlay(overlay: Overlay, u: number, v: number, out: Float32Array): boolean;
|
|
47
|
+
/** Which channels have been written at a coordinate, as a bit per channel. Zero for none. */
|
|
48
|
+
export declare function writtenMaskAt(overlay: Overlay, u: number, v: number): number;
|
|
49
|
+
/**
|
|
50
|
+
* The distinct content hashes of the written tiles, into `out`. Returns how many.
|
|
51
|
+
*
|
|
52
|
+
* **Distinct, because an identical mark in two places is one thing to store.** The same blast
|
|
53
|
+
* scorch on twenty crates is twenty tiles in memory — each is written in place and they diverge the
|
|
54
|
+
* moment anything else touches one — and one page to upload, which is where the cost actually is.
|
|
55
|
+
* Hashed exactly as a base tile is, by `hashTile`, so the two live in one address space.
|
|
56
|
+
*/
|
|
57
|
+
export declare function overlayTiles(overlay: Overlay, out: string[]): number;
|
|
58
|
+
/** How many tiles hold a written texel. What a memory readout shows. */
|
|
59
|
+
export declare function overlayTileCount(overlay: Overlay): number;
|
|
60
|
+
/**
|
|
61
|
+
* Put the overlay over the base, channel by channel, into `out`.
|
|
62
|
+
*
|
|
63
|
+
* **`written` is a parameter because the plan's signature had no way to say which texels and
|
|
64
|
+
* channels were touched**, and without it compositing either overwrites everything — so one scorch
|
|
65
|
+
* mark erases the base across the whole tile — or guesses from a sentinel value, which is a value
|
|
66
|
+
* somebody eventually writes on purpose.
|
|
67
|
+
*/
|
|
68
|
+
export declare function compositeOverlay(out: Float32Array, base: Float32Array, overlay: Float32Array, written: Uint8Array): void;
|
|
@@ -0,0 +1,212 @@
|
|
|
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.js';
|
|
24
|
+
/** How many channels a texel carries. One bit each in the written mask, so this cannot exceed 8. */
|
|
25
|
+
export const OVERLAY_CHANNELS = 4;
|
|
26
|
+
export const ADDRESS_CLAMP = 0;
|
|
27
|
+
export const ADDRESS_WRAP = 1;
|
|
28
|
+
export function createOverlay(tileSize, options = {}) {
|
|
29
|
+
const size = Math.max(1, Math.floor(tileSize));
|
|
30
|
+
const across = Math.max(1, Math.floor(options.tilesAcross ?? 16));
|
|
31
|
+
return {
|
|
32
|
+
tileSize: size,
|
|
33
|
+
tilesAcross: across,
|
|
34
|
+
resolution: size * across,
|
|
35
|
+
addressMode: options.addressMode === ADDRESS_WRAP ? ADDRESS_WRAP : ADDRESS_CLAMP,
|
|
36
|
+
tiles: new Map(),
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
function key(tx, ty) {
|
|
40
|
+
return `${String(tx)},${String(ty)}`;
|
|
41
|
+
}
|
|
42
|
+
function tileAt(overlay, tx, ty, make) {
|
|
43
|
+
const at = key(tx, ty);
|
|
44
|
+
const held = overlay.tiles.get(at);
|
|
45
|
+
if (held !== undefined)
|
|
46
|
+
return held;
|
|
47
|
+
if (!make)
|
|
48
|
+
return null;
|
|
49
|
+
const texels = overlay.tileSize * overlay.tileSize;
|
|
50
|
+
const tile = {
|
|
51
|
+
tx,
|
|
52
|
+
ty,
|
|
53
|
+
texels: new Float32Array(texels * OVERLAY_CHANNELS),
|
|
54
|
+
written: new Uint8Array(texels),
|
|
55
|
+
hash: '',
|
|
56
|
+
};
|
|
57
|
+
overlay.tiles.set(at, tile);
|
|
58
|
+
return tile;
|
|
59
|
+
}
|
|
60
|
+
/**
|
|
61
|
+
* A texel coordinate brought inside the surface, or `-1` where it is outside and the mode clamps.
|
|
62
|
+
*
|
|
63
|
+
* `-1` rather than a clamped edge texel under `ADDRESS_CLAMP`: clamping a *write* would smear the
|
|
64
|
+
* whole edge of the texture with whatever somebody aimed past it, which is a visible band rather
|
|
65
|
+
* than the nothing they expected. Clamping a *read* is right, and `sampleOverlay` does that.
|
|
66
|
+
*/
|
|
67
|
+
function wrapTexel(overlay, texel, forWrite) {
|
|
68
|
+
const size = overlay.resolution;
|
|
69
|
+
if (overlay.addressMode === ADDRESS_WRAP)
|
|
70
|
+
return ((texel % size) + size) % size;
|
|
71
|
+
if (texel >= 0 && texel < size)
|
|
72
|
+
return texel;
|
|
73
|
+
return forWrite ? -1 : Math.min(size - 1, Math.max(0, texel));
|
|
74
|
+
}
|
|
75
|
+
/**
|
|
76
|
+
* Write `value` into one channel of every texel within `radius` of `(u, v)`.
|
|
77
|
+
*
|
|
78
|
+
* A disc rather than a square, because a square decal is a square nobody asked for, and the radius
|
|
79
|
+
* is in UV so a caller reasons in surface fractions rather than in whatever resolution this is.
|
|
80
|
+
*/
|
|
81
|
+
export function writeOverlay(overlay, u, v, radius, channel, value) {
|
|
82
|
+
if (channel < 0 || channel >= OVERLAY_CHANNELS)
|
|
83
|
+
return 0;
|
|
84
|
+
const size = overlay.resolution;
|
|
85
|
+
const centreX = u * size;
|
|
86
|
+
const centreY = v * size;
|
|
87
|
+
const reach = Math.max(0, radius) * size;
|
|
88
|
+
const from = Math.floor(centreX - reach);
|
|
89
|
+
const to = Math.ceil(centreX + reach);
|
|
90
|
+
const top = Math.floor(centreY - reach);
|
|
91
|
+
const bottom = Math.ceil(centreY + reach);
|
|
92
|
+
let touched = 0;
|
|
93
|
+
for (let y = top; y <= bottom; y += 1) {
|
|
94
|
+
for (let x = from; x <= to; x += 1) {
|
|
95
|
+
/* Texel centres, so a radius of half a texel marks one texel rather than none or four. */
|
|
96
|
+
const dx = x + 0.5 - centreX;
|
|
97
|
+
const dy = y + 0.5 - centreY;
|
|
98
|
+
if (dx * dx + dy * dy > reach * reach)
|
|
99
|
+
continue;
|
|
100
|
+
const wx = wrapTexel(overlay, x, true);
|
|
101
|
+
const wy = wrapTexel(overlay, y, true);
|
|
102
|
+
if (wx < 0 || wy < 0)
|
|
103
|
+
continue;
|
|
104
|
+
/*
|
|
105
|
+
* The tile is found from the *wrapped* texel, which is what makes a write near a boundary
|
|
106
|
+
* land in both tiles: the loop runs over one continuous span and each texel finds its own
|
|
107
|
+
* tile. A version that found one tile and wrote into it leaves a hard edge at every seam —
|
|
108
|
+
* the defect that looks like the decal was clipped and is usually blamed on the mesh.
|
|
109
|
+
*/
|
|
110
|
+
const tile = tileAt(overlay, Math.floor(wx / overlay.tileSize), Math.floor(wy / overlay.tileSize), true);
|
|
111
|
+
const local = (wy % overlay.tileSize) * overlay.tileSize + (wx % overlay.tileSize);
|
|
112
|
+
tile.texels[local * OVERLAY_CHANNELS + channel] = value;
|
|
113
|
+
tile.written[local] = tile.written[local] | (1 << channel);
|
|
114
|
+
tile.hash = '';
|
|
115
|
+
touched += 1;
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
return touched;
|
|
119
|
+
}
|
|
120
|
+
/**
|
|
121
|
+
* Read every channel at a coordinate into `out`. False where nothing has been written there.
|
|
122
|
+
*
|
|
123
|
+
* False is the ordinary answer and the important one: it is what tells a sampler to use the base
|
|
124
|
+
* unchanged rather than compositing a zero over it.
|
|
125
|
+
*/
|
|
126
|
+
export function sampleOverlay(overlay, u, v, out) {
|
|
127
|
+
const size = overlay.resolution;
|
|
128
|
+
const x = wrapTexel(overlay, Math.floor(u * size), false);
|
|
129
|
+
const y = wrapTexel(overlay, Math.floor(v * size), false);
|
|
130
|
+
const tile = tileAt(overlay, Math.floor(x / overlay.tileSize), Math.floor(y / overlay.tileSize), false);
|
|
131
|
+
if (tile === null)
|
|
132
|
+
return false;
|
|
133
|
+
const local = (y % overlay.tileSize) * overlay.tileSize + (x % overlay.tileSize);
|
|
134
|
+
if ((tile.written[local] ?? 0) === 0)
|
|
135
|
+
return false;
|
|
136
|
+
for (let c = 0; c < OVERLAY_CHANNELS; c += 1) {
|
|
137
|
+
out[c] = tile.texels[local * OVERLAY_CHANNELS + c];
|
|
138
|
+
}
|
|
139
|
+
return true;
|
|
140
|
+
}
|
|
141
|
+
/** Which channels have been written at a coordinate, as a bit per channel. Zero for none. */
|
|
142
|
+
export function writtenMaskAt(overlay, u, v) {
|
|
143
|
+
const size = overlay.resolution;
|
|
144
|
+
const x = wrapTexel(overlay, Math.floor(u * size), false);
|
|
145
|
+
const y = wrapTexel(overlay, Math.floor(v * size), false);
|
|
146
|
+
const tile = tileAt(overlay, Math.floor(x / overlay.tileSize), Math.floor(y / overlay.tileSize), false);
|
|
147
|
+
if (tile === null)
|
|
148
|
+
return 0;
|
|
149
|
+
return tile.written[(y % overlay.tileSize) * overlay.tileSize + (x % overlay.tileSize)] ?? 0;
|
|
150
|
+
}
|
|
151
|
+
/**
|
|
152
|
+
* A tile's content hash, computed once per write rather than once per call.
|
|
153
|
+
*
|
|
154
|
+
* **A cost guard, not a correctness one**, and it does not change an answer — a perturbation
|
|
155
|
+
* removing it fails no test and is expected to. It stays because `overlayTiles` is called every
|
|
156
|
+
* frame to decide uploads, and hashing is bytes: four hundred written tiles of 64×64×4 floats took
|
|
157
|
+
* **79 ms uncached and 0.061 ms cached**, measured 2026-09-15. The first of those is a frame
|
|
158
|
+
* budget spent several times over to learn that nothing changed.
|
|
159
|
+
*/
|
|
160
|
+
function hashOf(overlay, tile) {
|
|
161
|
+
if (tile.hash !== '')
|
|
162
|
+
return tile.hash;
|
|
163
|
+
/* The written mask is hashed with the values: two tiles holding the same numbers where one of
|
|
164
|
+
them wrote a zero and the other did not are different tiles, and compositing proves it. */
|
|
165
|
+
const bytes = new Uint8Array(tile.texels.buffer.byteLength + tile.written.byteLength);
|
|
166
|
+
bytes.set(new Uint8Array(tile.texels.buffer), 0);
|
|
167
|
+
bytes.set(tile.written, tile.texels.buffer.byteLength);
|
|
168
|
+
tile.hash = hashTile(bytes);
|
|
169
|
+
return tile.hash;
|
|
170
|
+
}
|
|
171
|
+
/**
|
|
172
|
+
* The distinct content hashes of the written tiles, into `out`. Returns how many.
|
|
173
|
+
*
|
|
174
|
+
* **Distinct, because an identical mark in two places is one thing to store.** The same blast
|
|
175
|
+
* scorch on twenty crates is twenty tiles in memory — each is written in place and they diverge the
|
|
176
|
+
* moment anything else touches one — and one page to upload, which is where the cost actually is.
|
|
177
|
+
* Hashed exactly as a base tile is, by `hashTile`, so the two live in one address space.
|
|
178
|
+
*/
|
|
179
|
+
export function overlayTiles(overlay, out) {
|
|
180
|
+
out.length = 0;
|
|
181
|
+
const seen = new Set();
|
|
182
|
+
for (const tile of overlay.tiles.values()) {
|
|
183
|
+
const hash = hashOf(overlay, tile);
|
|
184
|
+
if (seen.has(hash))
|
|
185
|
+
continue;
|
|
186
|
+
seen.add(hash);
|
|
187
|
+
out.push(hash);
|
|
188
|
+
}
|
|
189
|
+
out.sort();
|
|
190
|
+
return out.length;
|
|
191
|
+
}
|
|
192
|
+
/** How many tiles hold a written texel. What a memory readout shows. */
|
|
193
|
+
export function overlayTileCount(overlay) {
|
|
194
|
+
return overlay.tiles.size;
|
|
195
|
+
}
|
|
196
|
+
/**
|
|
197
|
+
* Put the overlay over the base, channel by channel, into `out`.
|
|
198
|
+
*
|
|
199
|
+
* **`written` is a parameter because the plan's signature had no way to say which texels and
|
|
200
|
+
* channels were touched**, and without it compositing either overwrites everything — so one scorch
|
|
201
|
+
* mark erases the base across the whole tile — or guesses from a sentinel value, which is a value
|
|
202
|
+
* somebody eventually writes on purpose.
|
|
203
|
+
*/
|
|
204
|
+
export function compositeOverlay(out, base, overlay, written) {
|
|
205
|
+
for (let texel = 0; texel < written.length; texel += 1) {
|
|
206
|
+
const mask = written[texel];
|
|
207
|
+
for (let c = 0; c < OVERLAY_CHANNELS; c += 1) {
|
|
208
|
+
const at = texel * OVERLAY_CHANNELS + c;
|
|
209
|
+
out[at] = (mask & (1 << c)) === 0 ? base[at] : overlay[at];
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
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
|
+
* Fill `out` with the order to send `tileCount` tiles in. Returns how many were written.
|
|
16
|
+
*
|
|
17
|
+
* A permutation: every tile appears exactly once, which the test asserts because an ordering that
|
|
18
|
+
* drops one is a material that never completes and never says why.
|
|
19
|
+
*/
|
|
20
|
+
export declare function progressiveOrder(tileCount: number, out: Uint32Array): number;
|
|
21
|
+
/**
|
|
22
|
+
* What level this many received bytes supports.
|
|
23
|
+
*
|
|
24
|
+
* Levels rise monotonically with bytes, which is what lets a loader show something immediately and
|
|
25
|
+
* refine without ever going backwards.
|
|
26
|
+
*/
|
|
27
|
+
export declare function usableAt(bytesReceived: number, tileBytes: number, tileCount: number): {
|
|
28
|
+
level: number;
|
|
29
|
+
complete: boolean;
|
|
30
|
+
};
|
|
@@ -0,0 +1,56 @@
|
|
|
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
|
+
function reverseBits(value, bits) {
|
|
15
|
+
let out = 0;
|
|
16
|
+
for (let i = 0; i < bits; i += 1)
|
|
17
|
+
out = (out << 1) | ((value >>> i) & 1);
|
|
18
|
+
return out >>> 0;
|
|
19
|
+
}
|
|
20
|
+
/**
|
|
21
|
+
* Fill `out` with the order to send `tileCount` tiles in. Returns how many were written.
|
|
22
|
+
*
|
|
23
|
+
* A permutation: every tile appears exactly once, which the test asserts because an ordering that
|
|
24
|
+
* drops one is a material that never completes and never says why.
|
|
25
|
+
*/
|
|
26
|
+
export function progressiveOrder(tileCount, out) {
|
|
27
|
+
if (tileCount <= 0)
|
|
28
|
+
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
|
+
* What level this many received bytes supports.
|
|
44
|
+
*
|
|
45
|
+
* Levels rise monotonically with bytes, which is what lets a loader show something immediately and
|
|
46
|
+
* refine without ever going backwards.
|
|
47
|
+
*/
|
|
48
|
+
export function usableAt(bytesReceived, tileBytes, tileCount) {
|
|
49
|
+
if (tileBytes <= 0 || tileCount <= 0)
|
|
50
|
+
return { level: 0, complete: true };
|
|
51
|
+
const tiles = Math.min(tileCount, Math.floor(bytesReceived / tileBytes));
|
|
52
|
+
const complete = tiles >= tileCount;
|
|
53
|
+
/* Each doubling of received tiles is one more level of detail. */
|
|
54
|
+
const level = tiles <= 0 ? 0 : Math.floor(Math.log2(tiles)) + 1;
|
|
55
|
+
return { level, complete };
|
|
56
|
+
}
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Decoded pages, and the switch that decides when the decoding happens.
|
|
3
|
+
*
|
|
4
|
+
* **This carries a mode because the spec's §9 names the risk rather than hoping about it.** If
|
|
5
|
+
* per-sample neural decode turns out too slow in the general case, the fallback is to decode once
|
|
6
|
+
* when a tile becomes resident and sample it as an ordinary texture thereafter — which loses
|
|
7
|
+
* procedural and per-sample parameterisation for the affected materials and keeps the compression,
|
|
8
|
+
* the joint channels and the streaming. Building the switch now costs a field and a branch.
|
|
9
|
+
* Retrofitting it into a pipeline that assumes per-sample decode everywhere costs a great deal,
|
|
10
|
+
* and would be attempted on the day somebody discovered the frame time, which is the worst day.
|
|
11
|
+
*
|
|
12
|
+
* **A page acquired this frame is never evicted.** The same rule `table.ts` exists to enforce, one
|
|
13
|
+
* level down and for the same reason: reclaiming a page the current frame is sampling produces a
|
|
14
|
+
* frame drawn against bytes somebody else is writing, which is a corruption rather than a stall
|
|
15
|
+
* and does not look like a streaming problem at all. A cache with nothing else to give refuses —
|
|
16
|
+
* `acquirePage` returns `-1` — because a wrong page is worse than no page.
|
|
17
|
+
*
|
|
18
|
+
* **What was evicted is reported, and the plan's signature had nowhere to say it.** A residency
|
|
19
|
+
* table that still calls a tile resident after its page has been reused points at a slot another
|
|
20
|
+
* tile now owns. The eviction has to reach the table, so it is drained rather than dropped.
|
|
21
|
+
*/
|
|
22
|
+
/** Where the decode happens. See the header for which risk the second one is the answer to. */
|
|
23
|
+
export type DecodeMode = 'per-sample' | 'on-residency';
|
|
24
|
+
/** How a page's bytes are produced. Supplied by the caller: a cache cannot know a format. */
|
|
25
|
+
export type PageDecode = (hash: string, into: Uint8Array) => void;
|
|
26
|
+
export interface PageCacheOptions {
|
|
27
|
+
readonly mode?: DecodeMode;
|
|
28
|
+
/**
|
|
29
|
+
* What fills a page in `on-residency` mode.
|
|
30
|
+
*
|
|
31
|
+
* Optional, because a cache in `per-sample` mode never decodes and a test of the eviction policy
|
|
32
|
+
* has nothing to decode. Absent in `on-residency` mode, a page is acquired undecoded and
|
|
33
|
+
* `pageDecoded` says so rather than the cache pretending.
|
|
34
|
+
*/
|
|
35
|
+
readonly decode?: PageDecode;
|
|
36
|
+
}
|
|
37
|
+
export interface PageCache {
|
|
38
|
+
readonly pages: number;
|
|
39
|
+
readonly pageBytes: number;
|
|
40
|
+
/** Every page, back to back. Slot `n` is `[n * pageBytes, (n + 1) * pageBytes)`. */
|
|
41
|
+
readonly bytes: Uint8Array;
|
|
42
|
+
/** Which hash holds each slot, or `''` where it is free. */
|
|
43
|
+
readonly holder: string[];
|
|
44
|
+
/** Whether each slot's bytes have been decoded into. */
|
|
45
|
+
readonly decoded: boolean[];
|
|
46
|
+
slotOf: Map<string, number>;
|
|
47
|
+
/** Monotonic, standing in for time, so eviction needs no clock. As `table.ts` does. */
|
|
48
|
+
touchedAt: Map<string, number>;
|
|
49
|
+
clock: number;
|
|
50
|
+
/** The clock at the start of the frame. Nothing touched after it may be evicted. */
|
|
51
|
+
frameStart: number;
|
|
52
|
+
mode: DecodeMode;
|
|
53
|
+
decode: PageDecode | null;
|
|
54
|
+
/** Hashes whose pages have been taken since the caller last drained this. */
|
|
55
|
+
evicted: string[];
|
|
56
|
+
}
|
|
57
|
+
export declare function createPageCache(pages: number, pageBytes: number, options?: PageCacheOptions): PageCache;
|
|
58
|
+
/**
|
|
59
|
+
* Say a new frame has begun, so everything acquired from here is protected from eviction.
|
|
60
|
+
*
|
|
61
|
+
* A caller that never calls this gets a cache that protects everything and fills up, which is a
|
|
62
|
+
* visible stall rather than a silent corruption — the right way round for a mistake to fail.
|
|
63
|
+
*/
|
|
64
|
+
export declare function beginCacheFrame(cache: PageCache): void;
|
|
65
|
+
/**
|
|
66
|
+
* The slot holding this tile's page, acquiring one if it is not held. `-1` where none can be given.
|
|
67
|
+
*
|
|
68
|
+
* Acquiring a hash that is already here returns the same slot and takes no page from anybody: the
|
|
69
|
+
* commonest call by far, and the one that must not evict.
|
|
70
|
+
*/
|
|
71
|
+
export declare function acquirePage(cache: PageCache, hash: string): number;
|
|
72
|
+
/** Give a page back. Its slot is free for the next acquisition, and its bytes are not kept. */
|
|
73
|
+
export declare function releasePage(cache: PageCache, hash: string): void;
|
|
74
|
+
export declare function pageSlot(cache: PageCache, hash: string): number;
|
|
75
|
+
export declare function pageDecoded(cache: PageCache, hash: string): boolean;
|
|
76
|
+
/**
|
|
77
|
+
* Decode a held page if the mode says the cache owns that, and it has not been done.
|
|
78
|
+
*
|
|
79
|
+
* Returns whether the page's bytes are now the decoded ones. `false` in `per-sample` mode is the
|
|
80
|
+
* ordinary answer rather than a failure: the bytes are the compressed page and whoever samples
|
|
81
|
+
* decodes them.
|
|
82
|
+
*/
|
|
83
|
+
export declare function ensurePageDecoded(cache: PageCache, hash: string): boolean;
|
|
84
|
+
export declare function decodeMode(cache: PageCache): DecodeMode;
|
|
85
|
+
/**
|
|
86
|
+
* Change where the decode happens, invalidating every page that was decoded under the old rule.
|
|
87
|
+
*
|
|
88
|
+
* **Invalidating rather than converting.** A page decoded per-sample and one decoded on residency
|
|
89
|
+
* hold different bytes, so keeping them and changing the flag serves a sampler the wrong ones —
|
|
90
|
+
* silently, and only for the pages that happened to be resident when somebody flipped the switch.
|
|
91
|
+
* The pages stay held; what is lost is the claim that their contents are current.
|
|
92
|
+
*/
|
|
93
|
+
export declare function setDecodeMode(cache: PageCache, mode: DecodeMode): void;
|
|
94
|
+
/** How many pages are held. What a profiler panel shows beside the resident tile count. */
|
|
95
|
+
export declare function occupiedPages(cache: PageCache): number;
|
|
96
|
+
/**
|
|
97
|
+
* Drain the hashes whose pages have been taken, into `out`. Returns how many.
|
|
98
|
+
*
|
|
99
|
+
* A caller that never drains this leaks a string per eviction, which is why it is a drain rather
|
|
100
|
+
* than a log: the list is a message to the residency table, and a message nobody reads is a bug in
|
|
101
|
+
* the caller that should grow rather than hide.
|
|
102
|
+
*/
|
|
103
|
+
export declare function takeEvicted(cache: PageCache, out: string[]): number;
|
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Decoded pages, and the switch that decides when the decoding happens.
|
|
3
|
+
*
|
|
4
|
+
* **This carries a mode because the spec's §9 names the risk rather than hoping about it.** If
|
|
5
|
+
* per-sample neural decode turns out too slow in the general case, the fallback is to decode once
|
|
6
|
+
* when a tile becomes resident and sample it as an ordinary texture thereafter — which loses
|
|
7
|
+
* procedural and per-sample parameterisation for the affected materials and keeps the compression,
|
|
8
|
+
* the joint channels and the streaming. Building the switch now costs a field and a branch.
|
|
9
|
+
* Retrofitting it into a pipeline that assumes per-sample decode everywhere costs a great deal,
|
|
10
|
+
* and would be attempted on the day somebody discovered the frame time, which is the worst day.
|
|
11
|
+
*
|
|
12
|
+
* **A page acquired this frame is never evicted.** The same rule `table.ts` exists to enforce, one
|
|
13
|
+
* level down and for the same reason: reclaiming a page the current frame is sampling produces a
|
|
14
|
+
* frame drawn against bytes somebody else is writing, which is a corruption rather than a stall
|
|
15
|
+
* and does not look like a streaming problem at all. A cache with nothing else to give refuses —
|
|
16
|
+
* `acquirePage` returns `-1` — because a wrong page is worse than no page.
|
|
17
|
+
*
|
|
18
|
+
* **What was evicted is reported, and the plan's signature had nowhere to say it.** A residency
|
|
19
|
+
* table that still calls a tile resident after its page has been reused points at a slot another
|
|
20
|
+
* tile now owns. The eviction has to reach the table, so it is drained rather than dropped.
|
|
21
|
+
*/
|
|
22
|
+
export function createPageCache(pages, pageBytes, options = {}) {
|
|
23
|
+
const count = Math.max(1, Math.floor(pages));
|
|
24
|
+
const size = Math.max(1, Math.floor(pageBytes));
|
|
25
|
+
return {
|
|
26
|
+
pages: count,
|
|
27
|
+
pageBytes: size,
|
|
28
|
+
bytes: new Uint8Array(count * size),
|
|
29
|
+
holder: new Array(count).fill(''),
|
|
30
|
+
decoded: new Array(count).fill(false),
|
|
31
|
+
slotOf: new Map(),
|
|
32
|
+
touchedAt: new Map(),
|
|
33
|
+
clock: 0,
|
|
34
|
+
frameStart: 0,
|
|
35
|
+
mode: options.mode ?? 'per-sample',
|
|
36
|
+
decode: options.decode ?? null,
|
|
37
|
+
evicted: [],
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* Say a new frame has begun, so everything acquired from here is protected from eviction.
|
|
42
|
+
*
|
|
43
|
+
* A caller that never calls this gets a cache that protects everything and fills up, which is a
|
|
44
|
+
* visible stall rather than a silent corruption — the right way round for a mistake to fail.
|
|
45
|
+
*/
|
|
46
|
+
export function beginCacheFrame(cache) {
|
|
47
|
+
cache.frameStart = cache.clock;
|
|
48
|
+
}
|
|
49
|
+
function touch(cache, hash) {
|
|
50
|
+
cache.clock += 1;
|
|
51
|
+
cache.touchedAt.set(hash, cache.clock);
|
|
52
|
+
}
|
|
53
|
+
/**
|
|
54
|
+
* The slot holding this tile's page, acquiring one if it is not held. `-1` where none can be given.
|
|
55
|
+
*
|
|
56
|
+
* Acquiring a hash that is already here returns the same slot and takes no page from anybody: the
|
|
57
|
+
* commonest call by far, and the one that must not evict.
|
|
58
|
+
*/
|
|
59
|
+
export function acquirePage(cache, hash) {
|
|
60
|
+
const held = cache.slotOf.get(hash);
|
|
61
|
+
if (held !== undefined) {
|
|
62
|
+
touch(cache, hash);
|
|
63
|
+
return held;
|
|
64
|
+
}
|
|
65
|
+
let slot = cache.holder.indexOf('');
|
|
66
|
+
if (slot < 0) {
|
|
67
|
+
const victim = evictable(cache);
|
|
68
|
+
if (victim === null)
|
|
69
|
+
return -1;
|
|
70
|
+
slot = cache.slotOf.get(victim);
|
|
71
|
+
release(cache, victim, slot);
|
|
72
|
+
cache.evicted.push(victim);
|
|
73
|
+
}
|
|
74
|
+
cache.holder[slot] = hash;
|
|
75
|
+
cache.slotOf.set(hash, slot);
|
|
76
|
+
/* No `decoded[slot] = false` here: `release` owns that, and every slot reaching this point came
|
|
77
|
+
either from `release` or from never having been used. A second reset changed nothing, which a
|
|
78
|
+
perturbation showed by failing no test. */
|
|
79
|
+
cache.bytes.fill(0, slot * cache.pageBytes, (slot + 1) * cache.pageBytes);
|
|
80
|
+
touch(cache, hash);
|
|
81
|
+
/* The whole of the mode switch: in one, the bytes are produced now; in the other, at sample
|
|
82
|
+
time by whoever samples, and this cache holds the compressed page unchanged. */
|
|
83
|
+
if (cache.mode === 'on-residency')
|
|
84
|
+
ensurePageDecoded(cache, hash);
|
|
85
|
+
return slot;
|
|
86
|
+
}
|
|
87
|
+
/** The least recently used page that this frame has not touched, or null. */
|
|
88
|
+
function evictable(cache) {
|
|
89
|
+
let worst = null;
|
|
90
|
+
let at = Infinity;
|
|
91
|
+
for (const hash of cache.slotOf.keys()) {
|
|
92
|
+
const when = cache.touchedAt.get(hash) ?? 0;
|
|
93
|
+
if (when > cache.frameStart)
|
|
94
|
+
continue;
|
|
95
|
+
if (when >= at)
|
|
96
|
+
continue;
|
|
97
|
+
at = when;
|
|
98
|
+
worst = hash;
|
|
99
|
+
}
|
|
100
|
+
return worst;
|
|
101
|
+
}
|
|
102
|
+
/**
|
|
103
|
+
* Take a page back, leaving the slot free and claimed by nobody.
|
|
104
|
+
*
|
|
105
|
+
* **`touchedAt` is deleted as well**, which is not tidiness: without it the map grows by one entry
|
|
106
|
+
* per tile the cache has ever seen, so a three-page cache that streamed a level holds three pages
|
|
107
|
+
* and thousands of timestamps. There is a test that counts them.
|
|
108
|
+
*/
|
|
109
|
+
function release(cache, hash, slot) {
|
|
110
|
+
cache.holder[slot] = '';
|
|
111
|
+
cache.decoded[slot] = false;
|
|
112
|
+
cache.slotOf.delete(hash);
|
|
113
|
+
cache.touchedAt.delete(hash);
|
|
114
|
+
}
|
|
115
|
+
/** Give a page back. Its slot is free for the next acquisition, and its bytes are not kept. */
|
|
116
|
+
export function releasePage(cache, hash) {
|
|
117
|
+
const slot = cache.slotOf.get(hash);
|
|
118
|
+
if (slot === undefined)
|
|
119
|
+
return;
|
|
120
|
+
release(cache, hash, slot);
|
|
121
|
+
}
|
|
122
|
+
export function pageSlot(cache, hash) {
|
|
123
|
+
return cache.slotOf.get(hash) ?? -1;
|
|
124
|
+
}
|
|
125
|
+
export function pageDecoded(cache, hash) {
|
|
126
|
+
const slot = cache.slotOf.get(hash);
|
|
127
|
+
return slot === undefined ? false : (cache.decoded[slot] ?? false);
|
|
128
|
+
}
|
|
129
|
+
/**
|
|
130
|
+
* Decode a held page if the mode says the cache owns that, and it has not been done.
|
|
131
|
+
*
|
|
132
|
+
* Returns whether the page's bytes are now the decoded ones. `false` in `per-sample` mode is the
|
|
133
|
+
* ordinary answer rather than a failure: the bytes are the compressed page and whoever samples
|
|
134
|
+
* decodes them.
|
|
135
|
+
*/
|
|
136
|
+
export function ensurePageDecoded(cache, hash) {
|
|
137
|
+
if (cache.mode !== 'on-residency')
|
|
138
|
+
return false;
|
|
139
|
+
const slot = cache.slotOf.get(hash);
|
|
140
|
+
if (slot === undefined)
|
|
141
|
+
return false;
|
|
142
|
+
if (cache.decoded[slot] === true)
|
|
143
|
+
return true;
|
|
144
|
+
if (cache.decode === null)
|
|
145
|
+
return false;
|
|
146
|
+
cache.decode(hash, cache.bytes.subarray(slot * cache.pageBytes, (slot + 1) * cache.pageBytes));
|
|
147
|
+
cache.decoded[slot] = true;
|
|
148
|
+
return true;
|
|
149
|
+
}
|
|
150
|
+
export function decodeMode(cache) {
|
|
151
|
+
return cache.mode;
|
|
152
|
+
}
|
|
153
|
+
/**
|
|
154
|
+
* Change where the decode happens, invalidating every page that was decoded under the old rule.
|
|
155
|
+
*
|
|
156
|
+
* **Invalidating rather than converting.** A page decoded per-sample and one decoded on residency
|
|
157
|
+
* hold different bytes, so keeping them and changing the flag serves a sampler the wrong ones —
|
|
158
|
+
* silently, and only for the pages that happened to be resident when somebody flipped the switch.
|
|
159
|
+
* The pages stay held; what is lost is the claim that their contents are current.
|
|
160
|
+
*/
|
|
161
|
+
export function setDecodeMode(cache, mode) {
|
|
162
|
+
if (mode === cache.mode)
|
|
163
|
+
return;
|
|
164
|
+
cache.mode = mode;
|
|
165
|
+
cache.decoded.fill(false);
|
|
166
|
+
}
|
|
167
|
+
/** How many pages are held. What a profiler panel shows beside the resident tile count. */
|
|
168
|
+
export function occupiedPages(cache) {
|
|
169
|
+
return cache.slotOf.size;
|
|
170
|
+
}
|
|
171
|
+
/**
|
|
172
|
+
* Drain the hashes whose pages have been taken, into `out`. Returns how many.
|
|
173
|
+
*
|
|
174
|
+
* A caller that never drains this leaks a string per eviction, which is why it is a drain rather
|
|
175
|
+
* than a log: the list is a message to the residency table, and a message nobody reads is a bug in
|
|
176
|
+
* the caller that should grow rather than hide.
|
|
177
|
+
*/
|
|
178
|
+
export function takeEvicted(cache, out) {
|
|
179
|
+
out.length = 0;
|
|
180
|
+
for (const hash of cache.evicted)
|
|
181
|
+
out.push(hash);
|
|
182
|
+
cache.evicted.length = 0;
|
|
183
|
+
return out.length;
|
|
184
|
+
}
|