diff --git a/src/core/lib/colorParser.test.ts b/src/core/lib/colorParser.test.ts new file mode 100644 index 0000000..977ceb9 --- /dev/null +++ b/src/core/lib/colorParser.test.ts @@ -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); + }); +}); diff --git a/src/core/lib/colorParser.ts b/src/core/lib/colorParser.ts index 71e8a66..bacd08a 100644 --- a/src/core/lib/colorParser.ts +++ b/src/core/lib/colorParser.ts @@ -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 { diff --git a/src/core/renderers/canvas/CanvasRenderer.test.ts b/src/core/renderers/canvas/CanvasRenderer.test.ts index 60aa382..fff9696 100644 --- a/src/core/renderers/canvas/CanvasRenderer.test.ts +++ b/src/core/renderers/canvas/CanvasRenderer.test.ts @@ -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; diff --git a/src/core/renderers/canvas/CanvasRenderer.ts b/src/core/renderers/canvas/CanvasRenderer.ts index 099c644..02cbd62 100644 --- a/src/core/renderers/canvas/CanvasRenderer.ts +++ b/src/core/renderers/canvas/CanvasRenderer.ts @@ -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); @@ -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); @@ -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) { @@ -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); } @@ -345,6 +358,7 @@ export class CanvasRenderer extends CoreRenderer { */ updateClearColor(color: number) { this.clearColor = normalizeCanvasColor(color); + this.clearColorAlpha = (color >>> 24) & 0xff; } override getTextureCoords( diff --git a/src/core/shaders/canvas/Rounded.ts b/src/core/shaders/canvas/Rounded.ts index 04aa431..a1c42c8 100644 --- a/src/core/shaders/canvas/Rounded.ts +++ b/src/core/shaders/canvas/Rounded.ts @@ -22,16 +22,16 @@ export const Rounded: CanvasShaderType = { ); }, 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(); }, diff --git a/src/core/shaders/canvas/RoundedWithShadow.ts b/src/core/shaders/canvas/RoundedWithShadow.ts index a396344..8c3d9e5 100644 --- a/src/core/shaders/canvas/RoundedWithShadow.ts +++ b/src/core/shaders/canvas/RoundedWithShadow.ts @@ -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(); }, }; diff --git a/src/core/shaders/canvas/utils/render.ts b/src/core/shaders/canvas/utils/render.ts index e3ddfa5..52dd96a 100644 --- a/src/core/shaders/canvas/utils/render.ts +++ b/src/core/shaders/canvas/utils/render.ts @@ -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();