From 2f39ecaa4dbfea6a366c30c68875ff0916c9d38b Mon Sep 17 00:00:00 2001 From: Danila Poyarkov Date: Wed, 13 May 2026 03:56:04 +0300 Subject: [PATCH] fix(canvas): cull nested nodes in world space --- packages/core/src/canvas/renderer.ts | 4 +- packages/core/src/canvas/renderer/methods.ts | 11 ++- packages/core/src/canvas/scene.ts | 10 ++- tests/engine/render/canvas/culling.test.ts | 85 ++++++++++++++++++++ 4 files changed, 103 insertions(+), 7 deletions(-) create mode 100644 tests/engine/render/canvas/culling.test.ts diff --git a/packages/core/src/canvas/renderer.ts b/packages/core/src/canvas/renderer.ts index f02716377..168e18ee9 100644 --- a/packages/core/src/canvas/renderer.ts +++ b/packages/core/src/canvas/renderer.ts @@ -187,7 +187,9 @@ export class SkiaRenderer { canvas: Canvas, graph: SceneGraph, nodeId: string, - overlays: RenderOverlays + overlays: RenderOverlays, + parentAbsX?: number, + parentAbsY?: number ) => void declare renderSection: (canvas: Canvas, node: SceneNode, graph: SceneGraph) => void declare renderComponentSet: (canvas: Canvas, node: SceneNode, graph: SceneGraph) => void diff --git a/packages/core/src/canvas/renderer/methods.ts b/packages/core/src/canvas/renderer/methods.ts index ec10fa861..ffa6eab1b 100644 --- a/packages/core/src/canvas/renderer/methods.ts +++ b/packages/core/src/canvas/renderer/methods.ts @@ -136,8 +136,15 @@ const rendererMethods: ThisType = { Labels.drawComponentLabels(this, canvas, graph) }, - renderNode(canvas: Canvas, graph: SceneGraph, nodeId: string, overlays: RenderOverlays): void { - SceneRender.renderNode(this, canvas, graph, nodeId, overlays) + renderNode( + canvas: Canvas, + graph: SceneGraph, + nodeId: string, + overlays: RenderOverlays, + parentAbsX?: number, + parentAbsY?: number + ): void { + SceneRender.renderNode(this, canvas, graph, nodeId, overlays, parentAbsX, parentAbsY) }, renderSection(canvas: Canvas, node: SceneNode, graph: SceneGraph): void { diff --git a/packages/core/src/canvas/scene.ts b/packages/core/src/canvas/scene.ts index 80bef6eb0..c260b1e00 100644 --- a/packages/core/src/canvas/scene.ts +++ b/packages/core/src/canvas/scene.ts @@ -99,7 +99,9 @@ function renderChildren( canvas: Canvas, graph: SceneGraph, node: SceneNode, - overlays: RenderOverlays + overlays: RenderOverlays, + absX: number, + absY: number ): void { const isClippableContainer = node.type === 'FRAME' || node.type === 'COMPONENT' || node.type === 'INSTANCE' @@ -111,12 +113,12 @@ function renderChildren( canvas.clipRect(r.ck.LTRBRect(0, 0, node.width, node.height), r.ck.ClipOp.Intersect, true) } for (const childId of node.childIds) { - r.renderNode(canvas, graph, childId, overlays) + r.renderNode(canvas, graph, childId, overlays, absX, absY) } canvas.restore() } else { for (const childId of node.childIds) { - r.renderNode(canvas, graph, childId, overlays) + r.renderNode(canvas, graph, childId, overlays, absX, absY) } } } @@ -169,7 +171,7 @@ export function renderNode( applyNodeTransforms(r, canvas, node, nodeId, overlays) renderNodeContent(r, canvas, graph, node, nodeId, overlays) - renderChildren(r, canvas, graph, node, overlays) + renderChildren(r, canvas, graph, node, overlays, absX, absY) if (layerBlur) { canvas.restore() diff --git a/tests/engine/render/canvas/culling.test.ts b/tests/engine/render/canvas/culling.test.ts new file mode 100644 index 000000000..d8bf0f29c --- /dev/null +++ b/tests/engine/render/canvas/culling.test.ts @@ -0,0 +1,85 @@ +import { describe, expect, mock, test } from 'bun:test' + +import { renderNode } from '#core/canvas/scene' +import type { SkiaRenderer } from '#core/canvas/renderer' +import { SceneGraph } from '#core/scene-graph' + +function pageId(graph: SceneGraph) { + return graph.getPages()[0].id +} + +function createCanvas() { + return { + save: mock(() => undefined), + restore: mock(() => undefined), + translate: mock(() => undefined), + saveLayer: mock(() => undefined), + clipRect: mock(() => undefined), + clipRRect: mock(() => undefined) + } +} + +function createRenderer() { + const rendered: string[] = [] + const renderer = { + _nodeCount: 0, + _culledCount: 0, + worldViewport: { x: 900, y: 900, w: 300, h: 300 }, + opacityPaint: { setAlphaf: mock(() => undefined) }, + effectLayerPaint: { + setImageFilter: mock(() => undefined), + setColorFilter: mock(() => undefined), + setBlendMode: mock(() => undefined) + }, + ck: { + BlendMode: { SrcOver: 'SrcOver' }, + LTRBRect: mock((left: number, top: number, right: number, bottom: number) => [ + left, + top, + right, + bottom + ]), + ClipOp: { Intersect: 'Intersect' } + }, + getCachedBlur: mock(() => null), + renderShape: mock((_canvas, node) => { + rendered.push(node.id) + }), + renderSection: mock((_canvas, node) => { + rendered.push(node.id) + }), + renderComponentSet: mock((_canvas, node) => { + rendered.push(node.id) + }), + renderNode(canvas, graph, nodeId, overlays, parentAbsX, parentAbsY) { + renderNode(this as SkiaRenderer, canvas, graph, nodeId, overlays, parentAbsX, parentAbsY) + } + } + return { renderer: renderer as SkiaRenderer, rendered } +} + +describe('canvas culling', () => { + test('uses accumulated absolute position for nested children', () => { + const graph = new SceneGraph() + const frame = graph.createNode('FRAME', pageId(graph), { + x: 1000, + y: 1000, + width: 200, + height: 200 + }) + const text = graph.createNode('TEXT', frame.id, { + x: 0, + y: 0, + width: 100, + height: 24, + text: 'Visible nested text' + }) + const { renderer, rendered } = createRenderer() + + renderNode(renderer, createCanvas(), graph, frame.id, {}) + + expect(rendered).toContain(frame.id) + expect(rendered).toContain(text.id) + expect(renderer._culledCount).toBe(0) + }) +})