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 <joeycumines@gmail.com>
This commit is contained in:
Danila Poyarkov 2026-05-22 12:37:00 +03:00
parent fadb17b108
commit 0b4fcc9f77
3 changed files with 223 additions and 17 deletions

View file

@ -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))

View file

@ -18,7 +18,9 @@ export function createMockRenderer(overrides: Partial<SkiaRenderer> = {}): 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<SkiaRenderer> = {}): 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<SkiaRenderer> = {}): 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),

View file

@ -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<SceneNode> = {
...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<SceneNode> = {
...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<SceneNode> = {
...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()
})
})