Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
147 changes: 147 additions & 0 deletions src/core/CoreTextureManager.test.ts
Original file line number Diff line number Diff line change
@@ -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<void> => {
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<void> => {
await gate.acquire();
active++;
if (active > peak) {
peak = active;
}
// Hold the slot until externally released.
await new Promise<void>((resolve) => {
holds.push(resolve);
});
active--;
gate.release();
};

const tasks: Array<Promise<void>> = [];
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);
});
});
105 changes: 97 additions & 8 deletions src/core/CoreTextureManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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 =
Expand Down Expand Up @@ -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<void> {
if (this.inFlight < this.limit) {
this.inFlight++;
return Promise.resolve();
}
return new Promise<void>((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
Expand All @@ -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;

Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -404,6 +461,23 @@ 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).
//
// Scope: this bounds the `createImageBitmap` decode, which happens inside
// `getTextureData` (see `loadTexture`). On the `<img>` 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);
}

this.initialized = true;
this.emit('initialized');

Expand Down Expand Up @@ -494,15 +568,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') {
Expand Down
2 changes: 2 additions & 0 deletions src/core/Stage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -200,6 +200,7 @@ export class Stage {
premultiplyAlphaHonored,
platform,
maxRetryCount,
imageDecodeConcurrency,
} = options;

assertTruthy(
Expand Down Expand Up @@ -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
Expand Down
25 changes: 25 additions & 0 deletions src/main-api/Renderer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
*
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down
Loading