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:
parent
fadb17b108
commit
0b4fcc9f77
|
|
@ -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))
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
110
tests/engine/render/canvas/effects/path-shapes.test.ts
Normal file
110
tests/engine/render/canvas/effects/path-shapes.test.ts
Normal 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()
|
||||
})
|
||||
})
|
||||
Loading…
Reference in a new issue