Skip to content

Commit b752f7f

Browse files
committed
inscription atlas: procedural loading spinner + rename "saturation" → "atlas full"
Loading indicator ----------------- TxSprite's render state is now tristate: 0 = flat colour, 1 = procedural loading spinner, 2 = atlas texture. The spinner is shader-only — no asset, no second sampler — drawn in slot-local UV space (vCorner) using the existing `now` uniform: a rotating arc inside an annulus, ~10 GPU instructions per fragment. New tx-sprite.setLoading() flips the flag the moment the atlas takes a slot, atlas calls setTexture() once the image arrives, clearTexture() on terminal failure or release. So the spinner keeps turning while waiting on slow fetches, the atlas exposes hasPendingFetches() and the render loop's continuation condition adds it alongside the existing scene-animation gate. Naming ------ Replaced "saturation" / "saturated" wording with "atlas full" / "runs out of room" everywhere — "saturation" reads as colour-saturation in image-rendering code. saturationLogged → atlasFullLogged, logSaturationOnce → logAtlasFullOnce, plus matching docstring + comment updates in the atlas, the quadtree, and the spec.
1 parent cb98468 commit b752f7f

6 files changed

Lines changed: 142 additions & 69 deletions

File tree

frontend/src/app/components/block-overview-graph/_ordpool/ordpool-inscription-atlas.ts

Lines changed: 55 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@
1414
*
1515
* # Pipeline
1616
*
17-
* 1. CPU side: HTMLCanvasElement (1024² → 2048² after first saturation).
17+
* 1. CPU side: HTMLCanvasElement (1024² → 2048² when the smaller atlas runs out of room).
1818
* `requestSlot()` allocates a power-of-two pixel rectangle via the
1919
* quadtree allocator and kicks off a fetch for `/content/<txid>` (or
2020
* `/stamp-content/<txid>`, `/atomical-content/<txid>` depending on
@@ -40,13 +40,15 @@
4040
* fetches retry twice (500 ms / 2 s) before being added to a FIFO failure
4141
* cache so transient flaps don't permanently blacklist a txid.
4242
*
43-
* # Saturation
43+
* # Running out of room ("atlas full")
4444
*
45-
* On first full, the atlas expands once from 1024² to 2048² (4× capacity)
46-
* by allocating a larger canvas, blitting the existing pixel buffer at
47-
* (0, 0) — slot positions are preserved — and recreating the GPU texture.
48-
* The second saturation is logged once via `console.error` and the
49-
* affected sprite stays at flat colour; further saturations are silent.
45+
* On the first allocation that doesn't fit, the atlas expands once from
46+
* 1024² to 2048² (4× capacity) by allocating a larger canvas, blitting the
47+
* existing pixel buffer at (0, 0) — slot positions are preserved — and
48+
* recreating the GPU texture. If the 2048² atlas also fills, one
49+
* `console.error` reports it and the affected sprite stays at flat colour;
50+
* further "atlas full" events are silent so the console doesn't flood when
51+
* a single scene rebuild fires the same condition for many sprites.
5052
*
5153
* # No spinner
5254
*
@@ -61,10 +63,11 @@ import * as quadtree from './ordpool-quadtree-allocator';
6163
/** Initial atlas edge length in pixels. Used by the component as the default
6264
* uniform value before any allocation has happened. */
6365
export const ATLAS_SIZE = 1024;
64-
/** Maximum atlas edge length. The atlas doubles once on first saturation;
65-
* any further fills emit a one-shot console.error and fall back to the
66-
* flat-colour rendering path. 2048² stays well below `gl.MAX_TEXTURE_SIZE`
67-
* (≥ 4096 universally, typically 8192–16384) and uses ~16 MB of GPU memory. */
66+
/** Maximum atlas edge length. The atlas doubles once when it first runs out
67+
* of room; any further "atlas full" event emits a one-shot console.error and
68+
* the sprite falls back to flat colour. 2048² stays well below
69+
* `gl.MAX_TEXTURE_SIZE` (≥ 4096 universally, typically 8192–16384) and uses
70+
* ~16 MB of GPU memory. */
6871
export const MAX_ATLAS_SIZE = 2048;
6972
const SLOT_QUANTUM = 32;
7073
const MIN_SLOT = 32;
@@ -114,7 +117,7 @@ export class OrdpoolInscriptionAtlas {
114117
private dirtyTexture = false;
115118
/** True after we've run out of room at MAX_ATLAS_SIZE. Used to suppress
116119
* repeat console.error spam when many sprites can't fit in the same scene. */
117-
private saturationLogged = false;
120+
private atlasFullLogged = false;
118121

119122
constructor() {
120123
this.canvas = document.createElement('canvas');
@@ -125,8 +128,9 @@ export class OrdpoolInscriptionAtlas {
125128
}
126129

127130
/** Current atlas edge length in pixels. Starts at `ATLAS_SIZE`, doubles to
128-
* `MAX_ATLAS_SIZE` on first saturation. The shader's `atlasSize` uniform
129-
* must track this — the component reads it every frame in the run loop. */
131+
* `MAX_ATLAS_SIZE` when the smaller atlas runs out of room. The shader's
132+
* `atlasSize` uniform must track this — the component reads it every frame
133+
* in the run loop. */
130134
get size(): number {
131135
return this.currentSize;
132136
}
@@ -197,20 +201,24 @@ export class OrdpoolInscriptionAtlas {
197201
existing.sprite = sprite;
198202
if (existing.status === 'loaded') {
199203
sprite.setTexture(quadtree.packSlot(existing.node));
204+
} else {
205+
// status === 'pending' (failed entries are deleted, not parked here).
206+
// Match the new-entry path so the rejoiner sees a spinner, not a flat tile.
207+
sprite.setLoading();
200208
}
201209
return true;
202210
}
203211
const slotPx = computeSlotSize(vsize);
204212
let node = quadtree.insert(this.root, slotPx);
205213
if (!node && this.currentSize < MAX_ATLAS_SIZE) {
206-
// First saturation: double the atlas (1024 → 2048) and try again.
207-
// The expansion preserves every existing slot, so already-loaded
208-
// textures stay correctly addressed by their existing packed slots.
214+
// First time we run out of room: double the atlas (1024 → 2048) and
215+
// try again. The expansion preserves every existing slot, so already-
216+
// loaded textures stay correctly addressed by their existing packed slots.
209217
this.expand();
210218
node = quadtree.insert(this.root, slotPx);
211219
}
212220
if (!node) {
213-
this.logSaturationOnce(slotPx);
221+
this.logAtlasFullOnce(slotPx);
214222
return false;
215223
}
216224
const entry: AtlasEntry = {
@@ -224,10 +232,29 @@ export class OrdpoolInscriptionAtlas {
224232
abort: () => undefined,
225233
};
226234
this.entries.set(txid, entry);
235+
// Show the loading spinner immediately. Stays visible through retry
236+
// backoffs until either onload (→ setTexture) or final onerror (→ clearTexture).
237+
sprite.setLoading();
227238
this.queueFetch(entry);
228239
return true;
229240
}
230241

242+
/**
243+
* True iff at least one entry is still pending (in flight, queued, or
244+
* waiting on a retry backoff). The component reads this every frame in
245+
* its render loop so the procedural spinner keeps animating while images
246+
* are loading — without it the loop would settle and the spinner would
247+
* freeze mid-rotation.
248+
*/
249+
hasPendingFetches(): boolean {
250+
for (const entry of this.entries.values()) {
251+
if (entry.status === 'pending') {
252+
return true;
253+
}
254+
}
255+
return false;
256+
}
257+
231258
releaseSlot(txid: string): void {
232259
const entry = this.entries.get(txid);
233260
if (!entry) {
@@ -248,7 +275,7 @@ export class OrdpoolInscriptionAtlas {
248275

249276
/**
250277
* Tear down: cancel in-flight fetches, drop the GPU texture, reset the
251-
* saturation log gate. Called from the component's `ngOnDestroy`.
278+
* atlas-full log gate. Called from the component's `ngOnDestroy`.
252279
*/
253280
destroy(): void {
254281
for (const entry of this.entries.values()) {
@@ -258,7 +285,7 @@ export class OrdpoolInscriptionAtlas {
258285
this.failed.clear();
259286
this.fetchQueue = [];
260287
this.inFlight = 0;
261-
this.saturationLogged = false;
288+
this.atlasFullLogged = false;
262289
if (this.gl && this.texture) {
263290
this.gl.deleteTexture(this.texture);
264291
}
@@ -339,9 +366,9 @@ export class OrdpoolInscriptionAtlas {
339366
* since the quadtree expansion makes the old root the top-left child of
340367
* the new root — and recreates the GPU texture.
341368
*
342-
* Called once per atlas instance, on first saturation in `requestSlot`.
343-
* Beyond `MAX_ATLAS_SIZE` we don't expand further; further full conditions
344-
* are reported by `logSaturationOnce`.
369+
* Called once per atlas instance, the first time `requestSlot` runs out
370+
* of room. Beyond `MAX_ATLAS_SIZE` we don't expand further; further
371+
* "atlas full" conditions are reported by `logAtlasFullOnce`.
345372
*/
346373
private expand(): void {
347374
const newSize = this.currentSize * 2;
@@ -360,16 +387,16 @@ export class OrdpoolInscriptionAtlas {
360387
this.allocateTexture();
361388
}
362389

363-
private logSaturationOnce(slotPx: number): void {
364-
if (this.saturationLogged) {
390+
private logAtlasFullOnce(slotPx: number): void {
391+
if (this.atlasFullLogged) {
365392
return;
366393
}
367-
this.saturationLogged = true;
394+
this.atlasFullLogged = true;
368395
// eslint-disable-next-line no-console
369396
console.error(
370-
`[ordpool-atlas] saturated at ${this.currentSize}×${this.currentSize}px ` +
397+
`[ordpool-atlas] atlas full at ${this.currentSize}×${this.currentSize}px ` +
371398
`(${this.entries.size} slots in flight); refusing slot of ${slotPx}px. ` +
372-
`Sprite will fall back to flat colour. Subsequent saturations are silent.`
399+
`Sprite will fall back to flat colour. Subsequent atlas-full events are silent.`
373400
);
374401
}
375402

frontend/src/app/components/block-overview-graph/_ordpool/ordpool-quadtree-allocator.spec.ts

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -123,12 +123,12 @@ describe('ordpool-quadtree-allocator', () => {
123123
expect(root.full).toBe(true);
124124
});
125125

126-
it('skips fully-saturated subtrees on subsequent inserts', () => {
126+
it('skips fully-allocated subtrees on subsequent inserts', () => {
127127
const root = createRoot(ATLAS);
128128
const half = ATLAS / 2;
129129
const quarter = ATLAS / 4;
130130

131-
// Saturate the first quadrant with quarter-size slots.
131+
// Fill the first quadrant with quarter-size slots.
132132
insert(root, quarter);
133133
insert(root, quarter);
134134
insert(root, quarter);
@@ -139,7 +139,7 @@ describe('ordpool-quadtree-allocator', () => {
139139
expect(firstQuadrant.full).toBe(true);
140140
expect(root.full).toBe(false);
141141

142-
// Next half-size insert must skip the saturated first quadrant
142+
// Next half-size insert must skip the now-full first quadrant
143143
// and land in the next available quadrant (top-right at half,0).
144144
const next = insert(root, half)!;
145145
expect(next.x).toBe(half);
@@ -186,12 +186,12 @@ describe('ordpool-quadtree-allocator', () => {
186186
const half = ATLAS / 2;
187187
const quarter = ATLAS / 4;
188188

189-
// Saturate the first quadrant with quarter-size slots.
189+
// Fill the first quadrant with quarter-size slots.
190190
const q1 = insert(root, quarter)!;
191191
insert(root, quarter);
192192
insert(root, quarter);
193193
insert(root, quarter);
194-
// Saturate the rest of the atlas with half-size slots.
194+
// Fill the rest of the atlas with half-size slots.
195195
insert(root, half);
196196
insert(root, half);
197197
insert(root, half);
@@ -314,7 +314,7 @@ describe('ordpool-quadtree-allocator', () => {
314314

315315
it('makes ~3/4 of the new atlas available for fresh allocations', () => {
316316
const root = createRoot(1024);
317-
// Saturate the original 1024² with 512² slots (4 slots = full)
317+
// Fill the original 1024² with 512² slots (4 slots = full)
318318
insert(root, 512);
319319
insert(root, 512);
320320
insert(root, 512);

frontend/src/app/components/block-overview-graph/_ordpool/ordpool-quadtree-allocator.ts

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -7,12 +7,12 @@
77
* # Layout
88
*
99
* The atlas is a square of `size` pixels (currently 1024, expandable to 2048
10-
* on first saturation). Allocation requests are power-of-two slot sizes
11-
* (32, 64, 128, 256, 512). Free space is tracked as a tree of nodes; when a
12-
* request lands deeper than the current subdivision, the parent node is
13-
* "budded" into 4 equal quadrants and recursion continues into them. The
14-
* `full` flag propagates upward so we can short-circuit search through
15-
* saturated subtrees.
10+
* when the smaller atlas runs out of room). Allocation requests are
11+
* power-of-two slot sizes (32, 64, 128, 256, 512). Free space is tracked as
12+
* a tree of nodes; when a request lands deeper than the current
13+
* subdivision, the parent node is "budded" into 4 equal quadrants and
14+
* recursion continues into them. The `full` flag propagates upward so we
15+
* can short-circuit search through fully-allocated subtrees.
1616
*
1717
* # Coordinate convention
1818
*
@@ -63,7 +63,7 @@ export interface QuadNode {
6363
budded: boolean;
6464
/** True when the node holds an allocation (only meaningful on leaves). */
6565
filled: boolean;
66-
/** Saturation flag — true when this subtree has zero free leaves. Used to short-circuit insert(). */
66+
/** True when this subtree has zero free leaves. Used to short-circuit insert(). */
6767
full: boolean;
6868
/** Four equal-size sub-quadrants (top-left, top-right, bottom-left, bottom-right) when budded. */
6969
children: QuadNode[] | null;

frontend/src/app/components/block-overview-graph/_ordpool/ordpool-shaders.ts

Lines changed: 41 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -13,10 +13,14 @@
1313
coordinate is flipped to match the un-flipped texImage2D upload of a
1414
canvas2d (which has top-left origin, while WebGL textures default to
1515
bottom-left).
16-
- The fragment shader has a two-way branch: sample the atlas when
17-
`vIsTexture > 0.5`, fall back to the flat colour otherwise. The atlas
18-
sample is composited over the colour using the texture's own alpha,
19-
so transparent pixels in the inscription show the underlying tx tint.
16+
- The fragment shader has a tristate branch on `vIsTexture`:
17+
`> 1.5` — sample the atlas. The texture is composited over the flat
18+
colour using the image's own alpha, so transparent pixels in the
19+
inscription show the underlying tx tint.
20+
`> 0.5` — render a procedural rotating-arc spinner over the flat
21+
colour, driven by the existing `now` uniform. No texture asset
22+
required; arc + ring math runs in ~10 GPU instructions per pixel.
23+
`else` — flat colour fallback.
2024
2125
Atlas size and slot quantum match `OrdpoolInscriptionAtlas` (1024 px /
2226
32 px). The numeric constants in the shader match `packSlot()`'s
@@ -27,6 +31,7 @@ export const ordpoolVertexShaderSrc = `
2731
varying lowp vec4 vColor;
2832
varying lowp float vIsTexture;
2933
varying mediump vec2 vCoord;
34+
varying mediump vec2 vCorner;
3035
3136
attribute vec4 offset;
3237
attribute vec4 posX;
@@ -70,6 +75,9 @@ void main() {
7075
vColor = vec4(red, green, blue, alpha);
7176
7277
vIsTexture = offset.z;
78+
// Slot-local UV. Interpolates linearly to (0..1, 0..1) across the quad,
79+
// used by the loading-spinner branch in the fragment shader.
80+
vCorner = corner;
7381
float spriteX = mod(offset.w, 512.0) * 32.0;
7482
float spriteY = mod(floor(offset.w / 512.0), 512.0) * 32.0;
7583
float pxSize = floor(offset.w / 262144.0) * 32.0;
@@ -84,17 +92,41 @@ precision mediump float;
8492
varying lowp vec4 vColor;
8593
varying lowp float vIsTexture;
8694
varying mediump vec2 vCoord;
95+
varying mediump vec2 vCorner;
8796
8897
uniform sampler2D uSampler;
98+
uniform float now;
99+
100+
// Procedural rotating-arc spinner drawn in slot-local UV space (vCorner).
101+
// Returns a [0..1] intensity; 1 = full white sweep highlight, 0 = leave the
102+
// underlying color alone. Width and rotation speed are tuned for visibility
103+
// at small (32–64 px) slot sizes without dominating large slots.
104+
float ordpoolSpinnerIntensity(vec2 vc, float t) {
105+
vec2 p = vc - 0.5;
106+
float dist = length(p);
107+
// Annulus between 0.30 and 0.45 of the slot, anti-aliased on both edges.
108+
float ring = smoothstep(0.45, 0.42, dist) * (1.0 - smoothstep(0.30, 0.33, dist));
109+
// Rotating sweep: bright at the leading edge of an arc, fading away
110+
// around the rest of the circle. now is in ms, 0.003 ≈ one rev / 2 s.
111+
float angle = atan(p.y, p.x);
112+
float rotation = t * 0.003;
113+
float arc = mod(angle + rotation, 6.283185);
114+
float sweep = smoothstep(2.5, 0.0, arc);
115+
return ring * sweep;
116+
}
89117
90118
void main() {
91-
if (vIsTexture > 0.5) {
119+
vec4 base = vColor;
120+
if (vIsTexture > 1.5) {
92121
vec4 tex = texture2D(uSampler, vCoord);
93-
gl_FragColor.rgb = tex.rgb * tex.a + vColor.rgb * (1.0 - tex.a);
94-
gl_FragColor.a = vColor.a;
95-
} else {
96-
gl_FragColor = vColor;
122+
base.rgb = tex.rgb * tex.a + vColor.rgb * (1.0 - tex.a);
123+
base.a = vColor.a;
124+
} else if (vIsTexture > 0.5) {
125+
float lit = ordpoolSpinnerIntensity(vCorner, now);
126+
base.rgb = mix(vColor.rgb, vec3(1.0), lit * 0.85);
127+
base.a = vColor.a;
97128
}
129+
gl_FragColor = base;
98130
gl_FragColor.rgb *= gl_FragColor.a;
99131
}
100132
`;

frontend/src/app/components/block-overview-graph/block-overview-graph.component.ts

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -83,7 +83,7 @@ export class BlockOverviewGraphComponent implements AfterViewInit, OnDestroy, On
8383
// HACK -- Ordpool artifact image previews: atlas owns the inscription/stamp/atomical texture.
8484
ordpoolAtlas: OrdpoolInscriptionAtlas;
8585
// HACK -- Ordpool: cached atlasSize uniform location. Pushed every frame
86-
// because the atlas can expand at runtime (1024 → 2048 on first saturation).
86+
// because the atlas can expand at runtime (1024 → 2048 when it first runs out of room).
8787
ordpoolAtlasSizeUniform: WebGLUniformLocation | null = null;
8888
running: boolean;
8989
scene: BlockScene;
@@ -544,7 +544,13 @@ export class BlockOverviewGraphComponent implements AfterViewInit, OnDestroy, On
544544
}
545545

546546
/* LOOP */
547-
if (this.running && this.scene && now <= (this.scene.animateUntil + 500)) {
547+
// HACK -- Ordpool artifact image previews: the procedural loading spinner
548+
// is driven by the `now` uniform, so the render loop must keep firing
549+
// while any fetch is in flight. Without this the loop would settle to
550+
// the 1s heartbeat as soon as scene animation ended, freezing the spinner.
551+
const stillAnimating = this.scene && now <= (this.scene.animateUntil + 500);
552+
const stillLoading = this.ordpoolAtlas?.hasPendingFetches() === true;
553+
if (this.running && (stillAnimating || stillLoading)) {
548554
this.doRun();
549555
} else {
550556
clearTimeout(this.animationHeartBeat);

0 commit comments

Comments
 (0)