From 0b4fcc9f77cf5ba49a0aea7b5cb89cf8028d6b9e Mon Sep 17 00:00:00 2001 From: Danila Poyarkov Date: Fri, 22 May 2026 12:37:00 +0300 Subject: [PATCH] fix(canvas): render shadows from path shapes - Use polygon, star, and vector paths for shadows when geometry blobs are unavailable - Apply shadow spread to copied geometry paths without mutating caches - Clip and cut out inner shadows with the same path geometry Co-authored-by: Joseph Cumines --- packages/core/src/canvas/shadows.ts | 119 +++++++++++++++--- tests/engine/render/canvas/effects/helpers.ts | 11 +- .../render/canvas/effects/path-shapes.test.ts | 110 ++++++++++++++++ 3 files changed, 223 insertions(+), 17 deletions(-) create mode 100644 tests/engine/render/canvas/effects/path-shapes.test.ts diff --git a/packages/core/src/canvas/shadows.ts b/packages/core/src/canvas/shadows.ts index b985e0818..4e98917df 100644 --- a/packages/core/src/canvas/shadows.ts +++ b/packages/core/src/canvas/shadows.ts @@ -3,7 +3,7 @@ import type { Canvas, Path } from 'canvaskit-wasm' import type { SceneNode } from '#core/scene-graph' import type { SkiaRenderer } from './renderer' -import { nodeHasRadius } from './shapes' +import { makeNodeShapePath, nodeHasRadius } from './shapes' function drawChildTransform(canvas: Canvas, child: SceneNode, offset = { x: 0, y: 0 }): void { canvas.translate(child.x + offset.x, child.y + offset.y) @@ -35,6 +35,89 @@ function localEffectOffset(effect: SceneNode['effects'][number], child?: SceneNo return { x, y } } +function isPathShape(node: SceneNode): boolean { + return node.type === 'POLYGON' || node.type === 'STAR' || node.type === 'VECTOR' +} + +function applySpreadToPath(r: SkiaRenderer, path: Path, spread: number): boolean { + if (spread === 0) return true + const ring = path.copy() + try { + if (!ring.stroke({ width: Math.abs(spread) * 2, join: r.ck.StrokeJoin.Round })) return false + return path.op(ring, spread > 0 ? r.ck.PathOp.Union : r.ck.PathOp.Difference) + } finally { + ring.delete() + } +} + +function drawPathShape( + r: SkiaRenderer, + canvas: Canvas, + node: SceneNode, + hasRadius: boolean, + spread = 0 +): void { + const path = makeNodeShapePath(r, node, r.ltrb(0, 0, node.width, node.height), hasRadius) + try { + applySpreadToPath(r, path, spread) + canvas.drawPath(path, r.auxFill) + } finally { + path.delete() + } +} + +function drawShadowGeometryPath( + r: SkiaRenderer, + canvas: Canvas, + path: Path, + spread: number +): void { + if (spread === 0) { + canvas.drawPath(path, r.auxFill) + return + } + const copy = path.copy() + try { + applySpreadToPath(r, copy, spread) + canvas.drawPath(copy, r.auxFill) + } finally { + copy.delete() + } +} + +function drawShadowCutout( + r: SkiaRenderer, + canvas: Canvas, + node: SceneNode, + effect: SceneNode['effects'][number], + shapeNode: SceneNode, + shapeHasRadius: boolean, + geometryShadow: Path[] | null +): void { + r.auxFill.setMaskFilter(null) + r.auxFill.setColor(r.ck.BLACK) + r.auxFill.setBlendMode(r.ck.BlendMode.DstOut) + canvas.save() + try { + canvas.translate(-effect.offset.x, -effect.offset.y) + if (geometryShadow) { + const fillGeometry = r.getFillGeometry(node) + if (fillGeometry) for (const path of fillGeometry) canvas.drawPath(path, r.auxFill) + } else if (shapeNode.type === 'ELLIPSE') { + canvas.drawOval(r.ltrb(0, 0, shapeNode.width, shapeNode.height), r.auxFill) + } else if (isPathShape(shapeNode)) { + drawPathShape(r, canvas, shapeNode, shapeHasRadius) + } else if (shapeHasRadius) { + canvas.drawRRect(r.makeRRect(shapeNode), r.auxFill) + } else { + canvas.drawRect(r.ltrb(0, 0, shapeNode.width, shapeNode.height), r.auxFill) + } + } finally { + canvas.restore() + r.auxFill.setBlendMode(r.ck.BlendMode.SrcOver) + } +} + function drawShapeDropShadow( r: SkiaRenderer, canvas: Canvas, @@ -74,28 +157,18 @@ function drawShapeDropShadow( } if (geometryShadow) { - for (const path of geometryShadow) canvas.drawPath(path, r.auxFill) - if (shouldHideShadowBehindUnfilledNode) { - const fillGeometry = r.getFillGeometry(node) - if (fillGeometry) { - r.auxFill.setMaskFilter(null) - r.auxFill.setColor(r.ck.BLACK) - r.auxFill.setBlendMode(r.ck.BlendMode.DstOut) - canvas.save() - canvas.translate(-effect.offset.x, -effect.offset.y) - for (const path of fillGeometry) canvas.drawPath(path, r.auxFill) - canvas.restore() - r.auxFill.setBlendMode(r.ck.BlendMode.SrcOver) - } - } + for (const path of geometryShadow) drawShadowGeometryPath(r, canvas, path, sp) } else if (shapeNode.type === 'ELLIPSE') { canvas.drawOval(r.ltrb(-sp, -sp, shapeNode.width + sp, shapeNode.height + sp), r.auxFill) + } else if (isPathShape(shapeNode)) { + drawPathShape(r, canvas, shapeNode, shapeHasRadius, sp) } else if (shapeHasRadius) { canvas.drawRRect(r.makeRRectWithSpread(shapeNode, sp), r.auxFill) } else { canvas.drawRect(r.ltrb(-sp, -sp, shapeNode.width + sp, shapeNode.height + sp), r.auxFill) } if (shouldHideShadowBehindUnfilledNode) { + drawShadowCutout(r, canvas, node, effect, shapeNode, shapeHasRadius, geometryShadow) canvas.restore() r.effectLayerPaint.setImageFilter(null) r.effectLayerPaint.setColorFilter(null) @@ -264,6 +337,13 @@ function drawShapeInnerShadow( path.addOval(shapeRect) canvas.clipPath(path, r.ck.ClipOp.Intersect, true) path.delete() + } else if (isPathShape(shapeNode)) { + const path = makeNodeShapePath(r, shapeNode, shapeRect, shapeHasRadius) + try { + canvas.clipPath(path, r.ck.ClipOp.Intersect, true) + } finally { + path.delete() + } } else if (shapeHasRadius) { canvas.clipRRect(r.makeRRect(shapeNode), r.ck.ClipOp.Intersect, true) } else { @@ -293,6 +373,15 @@ function drawShapeInnerShadow( innerPath.addOval(offsetRect) bigPath.op(innerPath, r.ck.PathOp.Difference) innerPath.delete() + } else if (isPathShape(shapeNode)) { + const innerPath = makeNodeShapePath(r, shapeNode, shapeRect, shapeHasRadius) + try { + innerPath.transform(r.ck.Matrix.translated(localOffsetX, localOffsetY)) + applySpreadToPath(r, innerPath, -sp) + bigPath.op(innerPath, r.ck.PathOp.Difference) + } finally { + innerPath.delete() + } } else if (shapeHasRadius) { const innerPath = new r.ck.Path() innerPath.addRRect(r.makeRRectWithOffset(shapeNode, localOffsetX, localOffsetY, sp)) diff --git a/tests/engine/render/canvas/effects/helpers.ts b/tests/engine/render/canvas/effects/helpers.ts index 1e9afd62a..210bca072 100644 --- a/tests/engine/render/canvas/effects/helpers.ts +++ b/tests/engine/render/canvas/effects/helpers.ts @@ -18,7 +18,9 @@ export function createMockRenderer(overrides: Partial = {}): SkiaR addOval = mock(() => undefined) addRect = mock(() => undefined) addRRect = mock(() => undefined) - op = mock(() => undefined) + addPath = mock(() => undefined) + op = mock(() => true) + transform = mock(() => undefined) delete = mock(() => undefined) copy = mock(() => this) stroke = mock(() => this) @@ -27,7 +29,9 @@ export function createMockRenderer(overrides: Partial = {}): SkiaR cubicTo = mock(() => undefined) close = mock(() => undefined) }, - PathOp: { Difference: 0 }, + PathOp: { Difference: 0, Union: 1 }, + StrokeJoin: { Round: 0 }, + Matrix: { translated: mock(() => new Float32Array(9)) }, BlendMode: { SrcOver: 0, SrcIn: 1, DstOut: 2 }, ColorType: { RGBA_8888: 0 }, AlphaType: { Premul: 0, Unpremul: 1 }, @@ -108,6 +112,9 @@ export function createMockRenderer(overrides: Partial = {}): SkiaR makeRRect: mock(() => new Float32Array(12)), makeRRectWithSpread: mock(() => new Float32Array(12)), makeRRectWithOffset: mock(() => new Float32Array(12)), + makePolygonPath: mock(function (this: SkiaRenderer) { + return new this.ck.Path() + }), renderText: mock(() => undefined), applyClippedBlur: mock(() => undefined), applyFill: mock(() => true), diff --git a/tests/engine/render/canvas/effects/path-shapes.test.ts b/tests/engine/render/canvas/effects/path-shapes.test.ts new file mode 100644 index 000000000..9b3df6e2a --- /dev/null +++ b/tests/engine/render/canvas/effects/path-shapes.test.ts @@ -0,0 +1,110 @@ +import { describe, expect, mock, test } from 'bun:test' + +import type { Canvas } from 'canvaskit-wasm' + +import { renderEffects } from '#core/canvas/shadows' +import type { SceneNode } from '#core/scene-graph' + +import { createMockCanvas, createMockRenderer, mockCalls } from './helpers' + +const requiredNodeFields = { childIds: [] as string[], strokeGeometry: [] } + +describe('path shape shadows', () => { + test('drop shadow for a star without imported geometry uses the star path', () => { + const r = createMockRenderer() + const canvas = createMockCanvas() + const node: Partial = { + ...requiredNodeFields, + type: 'STAR', + width: 100, + height: 100, + fills: [], + effects: [ + { + type: 'DROP_SHADOW', + visible: true, + color: { r: 0, g: 0, b: 0, a: 1 }, + offset: { x: 0, y: 0 }, + radius: 10, + spread: 5 + } + ] + } + + renderEffects(r, canvas as Canvas, node as SceneNode, new Float32Array(4), false, 'behind') + + expect(r.makePolygonPath).toHaveBeenCalled() + expect(canvas.drawPath).toHaveBeenCalled() + expect(canvas.drawRect).not.toHaveBeenCalled() + }) + + test('drop shadow spread copies imported geometry instead of mutating cache paths', () => { + const r = createMockRenderer() + const canvas = createMockCanvas() + const cachedPath = new r.ck.Path() + const spreadPath = new r.ck.Path() + cachedPath.copy = mock(() => spreadPath) + r.getFillGeometry = mock(() => [cachedPath]) + + const node: Partial = { + ...requiredNodeFields, + type: 'STAR', + width: 100, + height: 100, + fills: [{ visible: true, type: 'SOLID', color: { r: 1, g: 1, b: 1, a: 1 }, opacity: 1 }], + effects: [ + { + type: 'DROP_SHADOW', + visible: true, + color: { r: 0, g: 0, b: 0, a: 1 }, + offset: { x: 0, y: 0 }, + radius: 10, + spread: 10 + } + ] + } + + renderEffects(r, canvas as Canvas, node as SceneNode, new Float32Array(4), false, 'behind') + + expect(cachedPath.copy).toHaveBeenCalled() + expect(cachedPath.op).not.toHaveBeenCalled() + expect(spreadPath.op).toHaveBeenCalledWith(expect.anything(), r.ck.PathOp.Union) + expect(spreadPath.delete).toHaveBeenCalled() + }) + + test('inner shadow for a polygon clips and cuts out with polygon paths', () => { + const r = createMockRenderer() + const canvas = createMockCanvas() + const node: Partial = { + ...requiredNodeFields, + type: 'POLYGON', + width: 100, + height: 100, + fills: [], + effects: [ + { + type: 'INNER_SHADOW', + visible: true, + color: { r: 0, g: 0, b: 0, a: 1 }, + offset: { x: 5, y: 5 }, + radius: 10, + spread: 3 + } + ] + } + + renderEffects( + r, + canvas as Canvas, + node as SceneNode, + new Float32Array([0, 0, 100, 100]), + false, + 'front' + ) + + expect(mockCalls(r.makePolygonPath).length).toBeGreaterThanOrEqual(2) + expect(canvas.clipPath).toHaveBeenCalled() + expect(canvas.drawPath).toHaveBeenCalled() + expect(canvas.clipRect).not.toHaveBeenCalled() + }) +})