From 4bc92de1227123b9c73ab66a936fbe9bc4b513d2 Mon Sep 17 00:00:00 2001 From: Chris Lorenzo Date: Fri, 17 Jul 2026 07:33:58 -0400 Subject: [PATCH 1/2] perf(textures): bound main-thread image decode concurrency MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit On devices without image workers (numImageWorkers === 0, e.g. LG WebOS 3.x where createImageBitmap can't be polyfilled inside a Worker), every image fetch+decode runs on the main thread. Since 1.6.0, getTextureData runs eagerly and independently per node inside loadTexture, with no concurrency ceiling — so a scroll that makes many image nodes renderable in one tick fires dozens of decodes back-to-back and starves the render loop. The existing upload queue only throttles the GPU upload step, which is downstream of the expensive decode. Add a ConcurrencyGate (small FIFO semaphore) that caps how many fetch+decode operations run at once, applied only when there is no image worker manager (with workers, the pool already bounds concurrency). Priority/on-screen textures bypass the gate so they never wait behind off-screen prefetch. The gate wraps only the decode; the per-frame upload budget is untouched. Exposed as the `imageDecodeConcurrency` renderer setting (default 4; 0 disables). Inert on worker-enabled devices. Co-Authored-By: Claude Opus 4.8 --- src/core/CoreTextureManager.test.ts | 147 ++++++++++++++++++++++++++++ src/core/CoreTextureManager.ts | 97 ++++++++++++++++-- src/core/Stage.ts | 2 + src/main-api/Renderer.ts | 25 +++++ 4 files changed, 263 insertions(+), 8 deletions(-) create mode 100644 src/core/CoreTextureManager.test.ts diff --git a/src/core/CoreTextureManager.test.ts b/src/core/CoreTextureManager.test.ts new file mode 100644 index 0000000..b347264 --- /dev/null +++ b/src/core/CoreTextureManager.test.ts @@ -0,0 +1,147 @@ +import { describe, it, expect } from 'vitest'; +import { ConcurrencyGate } from './CoreTextureManager.js'; + +/** + * Flush the microtask queue a few times so acquire()/release() continuations + * (which resolve across several `await` hops) have all settled before we + * assert. Deliberately microtask-only — no timers. + */ +const tick = async (): Promise => { + for (let i = 0; i < 5; i++) { + await Promise.resolve(); + } +}; + +describe('ConcurrencyGate', () => { + it('resolves acquisitions immediately up to the limit', async () => { + const gate = new ConcurrencyGate(2); + let resolved = 0; + + void gate.acquire().then(() => { + resolved++; + }); + void gate.acquire().then(() => { + resolved++; + }); + + await tick(); + expect(resolved).toBe(2); + }); + + it('blocks acquisitions beyond the limit until a slot is released', async () => { + const gate = new ConcurrencyGate(1); + let secondResolved = false; + + // Fast path takes the only slot. + await gate.acquire(); + + void gate.acquire().then(() => { + secondResolved = true; + }); + + await tick(); + expect(secondResolved).toBe(false); + + gate.release(); + await tick(); + expect(secondResolved).toBe(true); + }); + + it('hands released slots to waiters in FIFO order', async () => { + const gate = new ConcurrencyGate(1); + const order: number[] = []; + + // Hold the only slot, then queue three waiters. + await gate.acquire(); + void gate.acquire().then(() => order.push(1)); + void gate.acquire().then(() => order.push(2)); + void gate.acquire().then(() => order.push(3)); + + await tick(); + expect(order).toEqual([]); + + gate.release(); + await tick(); + expect(order).toEqual([1]); + + gate.release(); + await tick(); + expect(order).toEqual([1, 2]); + + gate.release(); + await tick(); + expect(order).toEqual([1, 2, 3]); + }); + + it('frees a slot when releasing with no waiters queued', async () => { + const gate = new ConcurrencyGate(1); + let resolved = false; + + await gate.acquire(); + gate.release(); + + void gate.acquire().then(() => { + resolved = true; + }); + + await tick(); + expect(resolved).toBe(true); + }); + + it('never runs more than the limit concurrently under a burst', async () => { + const limit = 3; + const gate = new ConcurrencyGate(limit); + const holds: Array<() => void> = []; + let active = 0; + let peak = 0; + + const run = async (): Promise => { + await gate.acquire(); + active++; + if (active > peak) { + peak = active; + } + // Hold the slot until externally released. + await new Promise((resolve) => { + holds.push(resolve); + }); + active--; + gate.release(); + }; + + const tasks: Array> = []; + for (let i = 0; i < 10; i++) { + tasks.push(run()); + } + + await tick(); + expect(active).toBe(limit); + expect(peak).toBe(limit); + + // Drain holds one at a time; each release lets exactly one waiter in. + while (holds.length > 0) { + const releaseHold = holds.shift()!; + releaseHold(); + await tick(); + expect(active).toBeLessThanOrEqual(limit); + } + + await Promise.all(tasks); + expect(peak).toBe(limit); + expect(active).toBe(0); + }); + + it('applies no ceiling below its limit', async () => { + const gate = new ConcurrencyGate(4); + let resolved = 0; + + for (let i = 0; i < 4; i++) { + void gate.acquire().then(() => { + resolved++; + }); + } + + await tick(); + expect(resolved).toBe(4); + }); +}); diff --git a/src/core/CoreTextureManager.ts b/src/core/CoreTextureManager.ts index ea48935..1f66ea6 100644 --- a/src/core/CoreTextureManager.ts +++ b/src/core/CoreTextureManager.ts @@ -6,6 +6,7 @@ import { NoiseTexture } from './textures/NoiseTexture.js'; import { SubTexture } from './textures/SubTexture.js'; import { RenderTexture } from './textures/RenderTexture.js'; import { Texture, TextureType } from './textures/Texture.js'; +import type { TextureData } from './textures/Texture.js'; import { EventEmitter } from '../common/EventEmitter.js'; import type { Stage } from './Stage.js'; import { @@ -51,6 +52,10 @@ export interface TextureManagerSettings { // 'auto' = detect via probe; boolean = force the value and skip the probe. premultiplyAlphaHonored: boolean | 'auto'; maxRetryCount: number; + // Upper bound on concurrent main-thread fetch+decode operations when no + // image worker manager exists (numImageWorkers === 0). See CoreTextureManager + // for how the gate is applied. `0` disables the gate (unbounded). + imageDecodeConcurrency: number; } export type ResizeModeOptions = @@ -230,6 +235,49 @@ class TextureUploadQueue { } } +/** + * Bounds how many async operations run concurrently. + * + * Used to cap main-thread fetch+decode (`getTextureData`) when there are no + * image workers: on such devices every `createImageBitmap`/decode runs on the + * main thread, so a scroll that makes dozens of image nodes renderable at once + * would otherwise fire dozens of decodes back-to-back and starve the render + * loop. A caller `await`s {@link acquire} before the work and calls + * {@link release} in a `finally` after it. + * + * The in-flight slot is handed straight from a releaser to the next waiter, so + * the active count never dips below `limit` while work is pending. + * + * Exported for unit testing; not part of the public renderer surface. + */ +export class ConcurrencyGate { + private inFlight = 0; + private readonly waiters: Array<() => void> = []; + + constructor(private readonly limit: number) {} + + acquire(): Promise { + if (this.inFlight < this.limit) { + this.inFlight++; + return Promise.resolve(); + } + return new Promise((resolve) => { + this.waiters.push(resolve); + }); + } + + release(): void { + const next = this.waiters.shift(); + if (next !== undefined) { + // Transfer the slot to the next waiter without touching the count — + // one operation left, one is starting, so `inFlight` is unchanged. + next(); + return; + } + this.inFlight--; + } +} + export class CoreTextureManager extends EventEmitter { /** * Map of textures by cache key @@ -246,6 +294,13 @@ export class CoreTextureManager extends EventEmitter { private initialized = false; private stage: Stage; private numImageWorkers: number; + private imageDecodeConcurrency: number; + /** + * Caps concurrent main-thread fetch+decode. Non-null only when there is no + * image worker manager (workers already bound concurrency by pool size). + * Assigned in {@link initialize} once worker availability is known. + */ + private decodeGate: ConcurrencyGate | null = null; public platform: Platform; @@ -291,12 +346,14 @@ export class CoreTextureManager extends EventEmitter { createImageBitmapSupport, premultiplyAlphaHonored, maxRetryCount, + imageDecodeConcurrency, } = settings; this.stage = stage; this.platform = stage.platform; this.numImageWorkers = numImageWorkers; this.maxRetryCount = maxRetryCount; + this.imageDecodeConcurrency = imageDecodeConcurrency; if (createImageBitmapSupport === 'auto') { validateCreateImageBitmap(this.platform) @@ -404,6 +461,15 @@ export class CoreTextureManager extends EventEmitter { ); } + // Without an image worker manager, every fetch+decode runs on the main + // thread. Bound how many run at once so a burst (e.g. a scroll that makes + // many image nodes renderable in one tick) can't serialize dozens of + // decodes and starve the render loop. With workers, the pool already caps + // concurrency, so the gate stays null (no main-thread ceiling needed). + if (this.imageWorkerManager === null && this.imageDecodeConcurrency > 0) { + this.decodeGate = new ConcurrencyGate(this.imageDecodeConcurrency); + } + this.initialized = true; this.emit('initialized'); @@ -494,15 +560,30 @@ export class CoreTextureManager extends EventEmitter { texture.setState('loading'); + // Bound concurrent main-thread fetch+decode. Priority (on-screen) textures + // bypass the gate so they never wait behind off-screen prefetch decodes. + // `decodeGate` is null when image workers handle decoding off-thread. + const gate = priority === true ? null : this.decodeGate; + if (gate !== null) { + await gate.acquire(); + } + // Get texture data - early return on failure - const textureDataResult = await texture.getTextureData().catch((err) => { - console.error(err); - texture.setState( - 'failed', - new TextureError(TextureErrorCode.TEXTURE_DATA_NULL), - ); - return null; - }); + let textureDataResult: TextureData | null; + try { + textureDataResult = await texture.getTextureData().catch((err) => { + console.error(err); + texture.setState( + 'failed', + new TextureError(TextureErrorCode.TEXTURE_DATA_NULL), + ); + return null; + }); + } finally { + if (gate !== null) { + gate.release(); + } + } // Early return if texture data fetch failed if (textureDataResult === null || texture.state === 'failed') { diff --git a/src/core/Stage.ts b/src/core/Stage.ts index 2e7a385..d1826a3 100644 --- a/src/core/Stage.ts +++ b/src/core/Stage.ts @@ -200,6 +200,7 @@ export class Stage { premultiplyAlphaHonored, platform, maxRetryCount, + imageDecodeConcurrency, } = options; assertTruthy( @@ -229,6 +230,7 @@ export class Stage { // undefined -> true (default: assume honored, no probe) premultiplyAlphaHonored: premultiplyAlphaHonored ?? true, maxRetryCount, + imageDecodeConcurrency, }); // Wait for the Texture Manager to initialize diff --git a/src/main-api/Renderer.ts b/src/main-api/Renderer.ts index aa54f7b..7dfb020 100644 --- a/src/main-api/Renderer.ts +++ b/src/main-api/Renderer.ts @@ -481,6 +481,26 @@ export type RendererMainSettings = RendererRuntimeSettings & { */ numImageWorkers: number; + /** + * Maximum number of image fetch+decode operations allowed to run at once + * when image workers are unavailable + * + * @remarks + * Only applies when there is no image worker manager (i.e. + * `numImageWorkers === 0`, or workers/`createImageBitmap` are unsupported). + * In that mode every image decode runs on the main thread, so a burst — such + * as a scroll that makes many image nodes renderable in a single tick — can + * fire dozens of decodes back-to-back and starve the render loop. This caps + * how many run concurrently; on-screen (priority) textures bypass the cap so + * they never wait behind off-screen prefetch. + * + * Has no effect when image workers are active — the worker pool already + * bounds concurrency by its size. Set to `0` to disable the cap (unbounded). + * + * @defaultValue `4` + */ + imageDecodeConcurrency: number; + /** * Renderer Engine * @@ -781,6 +801,10 @@ export class RendererMain extends EventEmitter { textLayoutCacheSize: settings.textLayoutCacheSize ?? 250, numImageWorkers: settings.numImageWorkers !== undefined ? settings.numImageWorkers : 2, + imageDecodeConcurrency: + settings.imageDecodeConcurrency !== undefined + ? settings.imageDecodeConcurrency + : 4, enableContextSpy: settings.enableContextSpy ?? false, forceWebGL2: settings.forceWebGL2 ?? false, disableVertexArrayObject: settings.disableVertexArrayObject ?? false, @@ -854,6 +878,7 @@ export class RendererMain extends EventEmitter { fpsUpdateInterval: settings.fpsUpdateInterval!, enableClear: settings.enableClear!, numImageWorkers: settings.numImageWorkers!, + imageDecodeConcurrency: settings.imageDecodeConcurrency!, renderEngine: settings.renderEngine!, textureMemory: resolvedTxSettings, eventBus: this, From 2d93d094bf374d27b61cb5b44a289e23eb5d5d91 Mon Sep 17 00:00:00 2001 From: Chris Lorenzo Date: Fri, 17 Jul 2026 08:32:19 -0400 Subject: [PATCH 2/2] docs(textures): clarify decode gate scope vs the fallback path Note that the concurrency gate bounds the createImageBitmap decode inside getTextureData; on the fallback the real main-thread cost is the synchronous texImage2D at upload, which the per-frame upload budget paces instead. Prevents a future reader from expecting the gate to help that path. Co-Authored-By: Claude Opus 4.8 --- src/core/CoreTextureManager.ts | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/src/core/CoreTextureManager.ts b/src/core/CoreTextureManager.ts index 1f66ea6..57dbe2c 100644 --- a/src/core/CoreTextureManager.ts +++ b/src/core/CoreTextureManager.ts @@ -466,6 +466,14 @@ export class CoreTextureManager extends EventEmitter { // many image nodes renderable in one tick) can't serialize dozens of // decodes and starve the render loop. With workers, the pool already caps // concurrency, so the gate stays null (no main-thread ceiling needed). + // + // Scope: this bounds the `createImageBitmap` decode, which happens inside + // `getTextureData` (see `loadTexture`). On the `` fallback path + // (`hasCreateImageBitmap === false`) the image decodes lazily and the real + // main-thread cost is the synchronous `texImage2D` at upload, which the + // gate does not cover — the per-frame upload budget (`processUntil`) paces + // that instead. The gate is therefore most effective on the + // createImageBitmap(-polyfill) path. if (this.imageWorkerManager === null && this.imageDecodeConcurrency > 0) { this.decodeGate = new ConcurrencyGate(this.imageDecodeConcurrency); }