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
46 changes: 46 additions & 0 deletions src/core/lib/colorParser.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
import { describe, expect, it } from 'vitest';
import { parseColor } from './colorParser.js';

describe('parseColor', () => {
it('returns the white singleton for opaque white', () => {
const first = parseColor(0xffffffff);
const second = parseColor(0xffffffff);

expect(first).toBe(second);
expect(first.isWhite).toBe(true);
expect(first.a).toBe(1);
expect(first.r).toBe(0xff);
expect(first.g).toBe(0xff);
expect(first.b).toBe(0xff);
});

it('extracts abgr components', () => {
const color = parseColor(0x80102030);

expect(color.isWhite).toBe(false);
expect(color.a).toBe(0x80 / 255);
expect(color.b).toBe(0x10);
expect(color.g).toBe(0x20);
expect(color.r).toBe(0x30);
});

it('reuses one scratch object across calls', () => {
const first = parseColor(0x80102030);
const second = parseColor(0xff405060);

expect(second).toBe(first);
expect(second.a).toBe(1);
expect(second.b).toBe(0x40);
expect(second.g).toBe(0x50);
expect(second.r).toBe(0x60);
});

it('never returns the white singleton as the scratch object', () => {
const white = parseColor(0xffffffff);
const tinted = parseColor(0x80102030);

expect(tinted).not.toBe(white);
expect(white.isWhite).toBe(true);
expect(white.r).toBe(0xff);
});
});
23 changes: 17 additions & 6 deletions src/core/lib/colorParser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,18 +14,29 @@ const WHITE: IParsedColor = {
b: 0xff,
};

const SCRATCH: IParsedColor = {
isWhite: false,
a: 1,
r: 0,
g: 0,
b: 0,
};

/**
* Extract color components
* Extract color components.
*
* Returns a shared scratch object to avoid a per-call allocation in the
* canvas frame loop — consume it synchronously, never retain it.
*/
export function parseColor(abgr: number): IParsedColor {
if (abgr === 0xffffffff) {
return WHITE;
}
const a = ((abgr >>> 24) & 0xff) / 255;
const b = (abgr >>> 16) & 0xff & 0xff;
const g = (abgr >>> 8) & 0xff & 0xff;
const r = abgr & 0xff & 0xff;
return { isWhite: false, a, r, g, b };
SCRATCH.a = ((abgr >>> 24) & 0xff) / 255;
SCRATCH.b = (abgr >>> 16) & 0xff;
SCRATCH.g = (abgr >>> 8) & 0xff;
SCRATCH.r = abgr & 0xff;
return SCRATCH;
}

export function parseToAbgrString(abgr: number): string {
Expand Down
154 changes: 154 additions & 0 deletions src/core/renderers/canvas/CanvasRenderer.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,160 @@ describe('CanvasRenderer.addQuad transform detection', () => {
});
});

describe('CanvasRenderer.reset', () => {
const makeRenderer = (clearColorAlpha: number) => {
const renderer = Object.create(CanvasRenderer.prototype) as CanvasRenderer;
const context = {
setTransform: vi.fn(),
clearRect: vi.fn(),
fillRect: vi.fn(),
scale: vi.fn(),
fillStyle: '',
};
let widthWrites = 0;
const canvas = {
height: 100,
get width() {
return 200;
},
set width(_v: number) {
widthWrites++;
},
};
(renderer as any).context = context;
(renderer as any).canvas = canvas;
(renderer as any).pixelRatio = 2;
(renderer as any).clearColor = 'rgba(0,0,0,0)';
(renderer as any).clearColorAlpha = clearColorAlpha;
return { renderer, context, getWidthWrites: () => widthWrites };
};

it('clears via setTransform + clearRect without reallocating the canvas', () => {
const { renderer, context, getWidthWrites } = makeRenderer(0);

renderer.reset();

expect(getWidthWrites()).toBe(0);
expect(context.setTransform).toHaveBeenCalledWith(1, 0, 0, 1, 0, 0);
expect(context.clearRect).toHaveBeenCalledWith(0, 0, 200, 100);
expect(context.scale).toHaveBeenCalledWith(2, 2);
});

it('skips the clear-color fill when the clear color is fully transparent', () => {
const { renderer, context } = makeRenderer(0);

renderer.reset();

expect(context.fillRect).not.toHaveBeenCalled();
});

it('fills with the clear color when its alpha is non-zero', () => {
const { renderer, context } = makeRenderer(0xff);
(renderer as any).clearColor = 'rgba(16,32,48,1)';

renderer.reset();

expect(context.fillRect).toHaveBeenCalledWith(0, 0, 200, 100);
expect(context.fillStyle).toBe('rgba(16,32,48,1)');
});

it('updateClearColor keeps the alpha skip in sync', () => {
const { renderer, context } = makeRenderer(0xff);

renderer.updateClearColor(0x00000000);
renderer.reset();

expect(context.fillRect).not.toHaveBeenCalled();
});
});

describe('CanvasRenderer.addQuad allocation-free paths', () => {
const makeRenderer = () => {
const renderer = Object.create(CanvasRenderer.prototype) as CanvasRenderer;
const context = {
save: vi.fn(),
restore: vi.fn(),
setTransform: vi.fn(),
scale: vi.fn(),
translate: vi.fn(),
fillRect: vi.fn(),
beginPath: vi.fn(),
rect: vi.fn(),
clip: vi.fn(),
fillStyle: '',
globalAlpha: 1,
};
(renderer as any).context = context;
(renderer as any).pixelRatio = 1;
(renderer as any).stage = { defaultTexture: null };
return { renderer, context };
};

const makeColorNode = (shader: unknown = null) =>
({
globalTransform: { tx: 5, ty: 10, ta: 1, tb: 0, tc: 0, td: 1 },
clippingRect: { valid: true, x: 0, y: 0, w: 300, h: 200 },
placeholderActive: false,
premultipliedColorTl: 0xff0000ff,
premultipliedColorTr: 0xff0000ff,
premultipliedColorBr: 0xff0000ff,
worldAlpha: 1,
props: {
texture: { type: TextureType.color },
shader,
w: 100,
h: 50,
},
} as any);

it('clips with beginPath/rect/clip instead of a Path2D', () => {
const { renderer, context } = makeRenderer();

renderer.addQuad(makeColorNode());

expect(context.beginPath).toHaveBeenCalledTimes(1);
expect(context.rect).toHaveBeenCalledWith(0, 0, 300, 200);
expect(context.clip).toHaveBeenCalledWith();
expect(context.fillRect).toHaveBeenCalledWith(5, 10, 100, 50);
});

it('passes the same preallocated renderContext callback to every shader', () => {
// Real construction (not Object.create) so class-field initializers run —
// the preallocated callback is a field arrow function.
const context = {
save: vi.fn(),
restore: vi.fn(),
fillRect: vi.fn(),
beginPath: vi.fn(),
rect: vi.fn(),
clip: vi.fn(),
fillStyle: '',
globalAlpha: 1,
};
const renderer = new CanvasRenderer({
stage: { pixelRatio: 1, clearColor: 0x00000000, defaultTexture: null },
canvas: { getContext: () => context },
} as any);
const seen: Array<() => void> = [];
const shader = {
applySNR: false,
render: vi.fn((_ctx: unknown, _node: unknown, rc: () => void) => {
seen.push(rc);
rc();
}),
};

renderer.addQuad(makeColorNode(shader));
renderer.addQuad(makeColorNode(shader));

expect(seen.length).toBe(2);
expect(seen[0]).toBe(seen[1]);
// The callback drew each node's content when invoked
expect(context.fillRect).toHaveBeenCalledTimes(2);
expect(context.fillRect).toHaveBeenCalledWith(5, 10, 100, 50);
});
});

describe('CanvasRenderer.getCapabilities', () => {
it('reports the canvas backend with no WebGL or VAO support', () => {
const renderer = Object.create(CanvasRenderer.prototype) as CanvasRenderer;
Expand Down
42 changes: 28 additions & 14 deletions src/core/renderers/canvas/CanvasRenderer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,8 +17,16 @@ export class CanvasRenderer extends CoreRenderer {
private canvas: HTMLCanvasElement;
private pixelRatio: number;
private clearColor: string;
private clearColorAlpha: number;
public renderToTextureActive = false;
activeRttNode: CoreNode | null = null;
// Scratch state for the preallocated shader renderContext callback below —
// valid only for the duration of the shader render() call in addQuad.
private shaderContextNode: CoreNode | null = null;
private shaderContextTexture: Texture | null = null;
private shaderRenderContext = () => {
this.renderContext(this.shaderContextNode!, this.shaderContextTexture!);
};

constructor(options: CoreRendererOptions) {
super(options);
Expand All @@ -29,16 +37,23 @@ export class CanvasRenderer extends CoreRenderer {
this.context = canvas.getContext('2d') as CanvasRenderingContext2D;
this.pixelRatio = this.stage.pixelRatio;
this.clearColor = normalizeCanvasColor(this.stage.clearColor);
this.clearColorAlpha = (this.stage.clearColor >>> 24) & 0xff;
}

reset(): void {
this.canvas.width = this.canvas.width; // quick reset canvas

const ctx = this.context;
const w = this.canvas.width;
const h = this.canvas.height;

// The frame's save/restore pairs are balanced, so resetting the transform
// and clearing is equivalent to the `canvas.width = canvas.width` trick
// without tearing down the backing store every frame.
ctx.setTransform(1, 0, 0, 1, 0, 0);
ctx.clearRect(0, 0, w, h);

if (this.clearColor) {
if (this.clearColorAlpha !== 0) {
ctx.fillStyle = this.clearColor;
ctx.fillRect(0, 0, this.canvas.width, this.canvas.height);
ctx.fillRect(0, 0, w, h);
}

ctx.scale(this.pixelRatio, this.pixelRatio);
Expand Down Expand Up @@ -99,10 +114,9 @@ export class CanvasRenderer extends CoreRenderer {
}

if (hasClipping === true) {
const path = new Path2D();
const { x, y, w, h } = clippingRect;
path.rect(x, y, w, h);
ctx.clip(path);
ctx.beginPath();
ctx.rect(clippingRect.x, clippingRect.y, clippingRect.w, clippingRect.h);
ctx.clip();
}

if (hasTransform === true) {
Expand All @@ -121,12 +135,11 @@ export class CanvasRenderer extends CoreRenderer {
}

if (hasShader === true) {
let renderContext: (() => void) | null = () => {
this.renderContext(node, texture);
};

(shader as CanvasShaderNode).render(ctx, node, renderContext);
renderContext = null;
this.shaderContextNode = node;
this.shaderContextTexture = texture;
(shader as CanvasShaderNode).render(ctx, node, this.shaderRenderContext);
this.shaderContextNode = null;
this.shaderContextTexture = null;
} else {
this.renderContext(node, texture);
}
Expand Down Expand Up @@ -345,6 +358,7 @@ export class CanvasRenderer extends CoreRenderer {
*/
updateClearColor(color: number) {
this.clearColor = normalizeCanvasColor(color);
this.clearColorAlpha = (color >>> 24) & 0xff;
}

override getTextureCoords(
Expand Down
6 changes: 3 additions & 3 deletions src/core/shaders/canvas/Rounded.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,16 +22,16 @@ export const Rounded: CanvasShaderType<RoundedProps, ComputedRoundedValues> = {
);
},
render(ctx, node, renderContext) {
const path = new Path2D();
ctx.beginPath();
roundRect(
path,
ctx,
node.globalTransform!.tx,
node.globalTransform!.ty,
node.props.w,
node.props.h,
this.computed.radius!,
);
ctx.clip(path);
ctx.clip();

renderContext();
},
Expand Down
6 changes: 3 additions & 3 deletions src/core/shaders/canvas/RoundedWithShadow.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,9 +49,9 @@ export const RoundedWithShadow: CanvasShaderType<
);
}

const path = new Path2D();
render.roundRect(path, tx, ty, w, h, computed.radius);
ctx.clip(path);
ctx.beginPath();
render.roundRect(ctx, tx, ty, w, h, computed.radius);
ctx.clip();
renderContext();
},
};
6 changes: 3 additions & 3 deletions src/core/shaders/canvas/utils/render.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,9 +57,9 @@ export function roundedRectWithBorder(

//draw node content first
ctx.save();
const path = new Path2D();
roundRect(path, x, y, width, height, radius);
ctx.clip(path);
ctx.beginPath();
roundRect(ctx, x, y, width, height, radius);
ctx.clip();
renderContext();
ctx.restore();

Expand Down
Loading