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
11 changes: 11 additions & 0 deletions src/core/Stage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -247,6 +247,17 @@ export class Stage {

const renderMode = this.renderer.mode || 'webgl';

// Canvas2D textures are plain JS heap objects managed by the browser GC.
// Threshold-based upload blocking makes no sense for JS heap — disable it
// by setting criticalThreshold to 0 while keeping the eviction machinery.
if (renderMode === 'canvas') {
this.txMemManager.updateSettings({
...textureMemory,
criticalThreshold: 0,
doNotExceedCriticalThreshold: false,
});
}

this.createDefaultTexture();
setPremultiplyMode(renderMode);

Expand Down
7 changes: 4 additions & 3 deletions src/core/TextureMemoryManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -148,21 +148,22 @@ export class TextureMemoryManager {
this.loadedTextures.add(texture);
}

if (this.memUsed > this.criticalThreshold) {
if (this.criticalThreshold > 0 && this.memUsed > this.criticalThreshold) {
this.criticalCleanupRequested = true;
}
}

checkCleanup() {
return (
this.criticalCleanupRequested ||
(this.memUsed > this.targetThreshold &&
(this.criticalThreshold > 0 &&
this.memUsed > this.targetThreshold &&
this.frameTime - this.lastCleanupTime >= this.cleanupInterval)
);
}

checkCriticalCleanup() {
return this.memUsed > this.criticalThreshold;
return this.criticalThreshold > 0 && this.memUsed > this.criticalThreshold;
}

/**
Expand Down
22 changes: 20 additions & 2 deletions src/core/renderers/canvas/CanvasRenderer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,19 @@ export class CanvasRenderer extends CoreRenderer {
}

const hasTransform = ta !== 1;
const hasClipping = clippingRect.w !== 0 && clippingRect.h !== 0;
const clippingValid = clippingRect.valid === true;

// If the clipping rect is valid but zero-area, the node is fully clipped — skip rendering
if (
clippingValid === true &&
clippingRect.w === 0 &&
clippingRect.h === 0
) {
return;
}

const hasClipping =
clippingValid === true && clippingRect.w !== 0 && clippingRect.h !== 0;
const shader = node.props.shader;
const hasShader = shader !== null;

Expand Down Expand Up @@ -124,7 +136,7 @@ export class CanvasRenderer extends CoreRenderer {

if (textureType !== TextureType.color) {
const tintColor = parseColor(color);
let image: ImageBitmap | HTMLCanvasElement | HTMLImageElement;
let image: ImageBitmap | HTMLCanvasElement | HTMLImageElement | null;

if (textureType === TextureType.subTexture) {
image = (
Expand All @@ -134,6 +146,12 @@ export class CanvasRenderer extends CoreRenderer {
image = (texture.ctxTexture as CanvasTexture).getImage(tintColor);
}

// getImage returns null when the underlying image was freed mid-load;
// skip drawing this frame rather than crashing.
if (image === null) {
return;
}

this.context.globalAlpha = tintColor.a ?? node.worldAlpha;

const txCoords = node.textureCoords;
Expand Down
38 changes: 31 additions & 7 deletions src/core/renderers/canvas/CanvasTexture.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import type { Dimensions } from '../../../common/CommonTypes.js';
import { assertTruthy } from '../../../utils.js';
import { formatRgba, type IParsedColor } from '../../lib/colorParser.js';
import { CoreContextTexture } from '../CoreContextTexture.js';
import type { Texture } from '../../textures/Texture.js';

export class CanvasTexture extends CoreContextTexture {
protected image:
Expand All @@ -17,10 +18,23 @@ export class CanvasTexture extends CoreContextTexture {
| undefined;

async load(): Promise<void> {
// Capture textureData synchronously before any await - a pending
// freeTextureDataTask microtask could null textureSource.textureData
// during the first async suspension, causing onLoadRequest to fail.
const textureData = this.textureSource.textureData;
assertTruthy(textureData?.data, 'Texture data is null before load');

this.textureSource.setState('loading');

try {
const size = await this.onLoadRequest();
const size = await this.onLoadRequest(textureData.data);

// Guard against the texture being freed while the load was in flight
if (this.textureSource.state === 'freed') {
this.image = undefined;
return;
}

this.textureSource.setState('loaded', size);
this.textureSource.freeTextureData();
this.updateMemSize();
Expand Down Expand Up @@ -63,9 +77,11 @@ export class CanvasTexture extends CoreContextTexture {

getImage(
color: IParsedColor,
): ImageBitmap | HTMLCanvasElement | HTMLImageElement {
): ImageBitmap | HTMLCanvasElement | HTMLImageElement | null {
const image = this.image;
assertTruthy(image, 'Attempt to get unloaded image texture');
if (image === undefined) {
return null;
}

if (color.isWhite) {
if (this.tintCache) {
Expand Down Expand Up @@ -114,9 +130,17 @@ export class CanvasTexture extends CoreContextTexture {
return canvas;
}

private async onLoadRequest(): Promise<Dimensions> {
assertTruthy(this.textureSource?.textureData?.data, 'Texture data is null');
const { data } = this.textureSource.textureData;
private async onLoadRequest(
data: NonNullable<NonNullable<Texture['textureData']>['data']>,
): Promise<Dimensions> {
// CompressedData objects (KTX, PVR, ASTC) carry GPU-format mipmap buffers
// that cannot be decoded by Canvas2D. Reject explicitly rather than falling
// through silently and leaving this.image unassigned.
if (typeof data === 'object' && 'mipmaps' in data) {
throw new Error(
'CanvasTexture: Compressed texture data is not supported in Canvas2D render mode',
);
}

// TODO: canvas from text renderer should be able to provide the canvas directly
// instead of having to re-draw it into a new canvas...
Expand All @@ -125,7 +149,7 @@ export class CanvasTexture extends CoreContextTexture {
canvas.width = data.width;
canvas.height = data.height;
const ctx = canvas.getContext('2d');
if (ctx) ctx.putImageData(data, 0, 0);
if (ctx !== null) ctx.putImageData(data, 0, 0);
this.image = canvas;
return { w: data.width, h: data.height };
} else if (
Expand Down
18 changes: 18 additions & 0 deletions src/core/textures/ImageTexture.ts
Original file line number Diff line number Diff line change
Expand Up @@ -269,6 +269,24 @@ export class ImageTexture extends Texture {
}

override async getTextureSource(): Promise<TextureData> {
// Compressed textures are not supported by the Canvas2D renderer.
// Fail fast here before incurring a network fetch or binary decode.
if (this.txManager.renderer?.mode === 'canvas') {
const { src, type } = this.props;
if (
type === 'compressed' ||
(typeof src === 'string' && isCompressedTextureContainer(src) === true)
) {
const err = new Error(
`ImageTexture: Compressed textures are not supported in Canvas2D render mode (src: ${String(
src,
)})`,
);
this.setState('failed', err);
return { data: null };
}
}

let resp: TextureData;
try {
resp = await this.determineImageTypeAndLoadImage();
Expand Down
8 changes: 8 additions & 0 deletions src/core/textures/Texture.ts
Original file line number Diff line number Diff line change
Expand Up @@ -240,6 +240,11 @@ export abstract class Texture extends EventEmitter {
return false;
}

// Don't cleanup a texture that is in the process of loading
if (this.state === 'loading') {
return false;
}

// Don't cleanup if not renderable
if (this.renderable === true) {
return false;
Expand Down Expand Up @@ -341,6 +346,9 @@ export abstract class Texture extends EventEmitter {
*/
free(): void {
this.ctxTexture?.free();
// Null out the freed ctxTexture so a subsequent reload re-creates it via
// getCtxTexture() instead of reusing a stale, already-freed reference.
this.ctxTexture = undefined;
}

/**
Expand Down
36 changes: 32 additions & 4 deletions visual-regression/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,17 @@ const argv = yargs(hideBin(process.argv))
description:
'Number of parallel browser pages used to run tests (sharded round-robin)',
},
renderMode: {
type: 'string',
alias: 'r',
// Defaults to webgl-only. Switch to 'all' (here or via --renderMode)
// once canvas baselines have been captured and committed, otherwise
// canvas compare runs fail for lack of reference snapshots.
default: 'webgl',
choices: ['webgl', 'canvas', 'all'],
description:
'Renderer mode to test ("webgl", "canvas", or "all" for both)',
},
})
.parseSync();

Expand Down Expand Up @@ -137,6 +148,7 @@ async function dockerCiMode(): Promise<number> {
argv.port ? `--port ${argv.port}` : '',
argv.filter ? `--filter "${argv.filter}"` : '',
argv.workers > 1 ? `--workers ${argv.workers}` : '',
argv.renderMode ? `--renderMode ${argv.renderMode}` : '',
].join(' ');

// Get the directory of the current file
Expand Down Expand Up @@ -209,7 +221,17 @@ async function compareCaptureMode(): Promise<number> {
}

// Run the tests
exitCode = await runTest('chromium');
const renderModes: ('webgl' | 'canvas')[] =
argv.renderMode === 'all'
? ['webgl', 'canvas']
: [argv.renderMode as 'webgl' | 'canvas'];
exitCode = 0;
for (const mode of renderModes) {
const result = await runTest('chromium', mode);
if (result !== 0) {
exitCode = result;
}
}
} finally {
// Kill the serve-examples process
serveExamplesChildProc.kill();
Expand All @@ -221,9 +243,13 @@ async function compareCaptureMode(): Promise<number> {
* Run the tests in capture or compare mode depending on the `argv.capture` flag
* for a specific browser type.
*/
async function runTest(browserType: 'chromium') {
async function runTest(
browserType: 'chromium',
renderMode: 'webgl' | 'canvas',
) {
const paramString = Object.entries({
browser: browserType,
renderMode,
overwrite: argv.overwrite,
filter: argv.filter,
RUNTIME_ENV: runtimeEnv,
Expand All @@ -238,7 +264,9 @@ async function runTest(browserType: 'chromium') {
),
);

const snapshotSubDirName = `${browserType}-${runtimeEnv}`;
const snapshotSubDirName = `${browserType}-${runtimeEnv}${
renderMode === 'canvas' ? '-canvas' : ''
}`;

const snapshotSubDir = path.join(certifiedSnapshotDir, snapshotSubDirName);

Expand Down Expand Up @@ -370,7 +398,7 @@ async function runTest(browserType: 'chromium') {
const shardParam =
workerCount > 1 ? `&shard=${shardIndex}/${workerCount}` : '';
await page.goto(
`http://localhost:${argv.port}/?automation=true&test=${argv.filter}${shardParam}`,
`http://localhost:${argv.port}/?automation=true&test=${argv.filter}&renderMode=${renderMode}${shardParam}`,
);

await donePromise;
Expand Down
Loading