fix(canvas): render raw noise effect fallback

This commit is contained in:
Danila Poyarkov 2026-05-27 10:53:12 +03:00
parent 431ab2247f
commit 033326cffb
3 changed files with 127 additions and 0 deletions

View file

@ -1,6 +1,8 @@
import type { Canvas, Path } from 'canvaskit-wasm'
import { BLACK } from '#core/constants'
import type { SceneNode } from '#core/scene-graph'
import type { Color, Vector } from '#core/types'
import { figmaBlendModeToSkia } from './blend'
import type { SkiaRenderer } from './renderer'
@ -11,12 +13,74 @@ import {
nodeHasSmoothCorners
} from './shapes'
interface RawNoiseEffect {
type: 'NOISE'
visible?: boolean
color?: Color
secondaryColor?: Color
opacity?: number
density?: number
noiseSize?: Vector
noiseType?: 'MONOTONE' | 'DUOTONE' | 'MULTITONE'
}
function resetEffectLayerPaint(r: SkiaRenderer): void {
r.effectLayerPaint.setImageFilter(null)
r.effectLayerPaint.setColorFilter(null)
r.effectLayerPaint.setBlendMode(r.ck.BlendMode.SrcOver)
}
function rawNoiseEffects(node: SceneNode): RawNoiseEffect[] {
const source = (node as Partial<SceneNode>).source
const effects = source?.fig.rawNodeFields.effects
if (!Array.isArray(effects)) return []
return effects.filter(
(effect): effect is RawNoiseEffect =>
effect !== null &&
typeof effect === 'object' &&
'type' in effect &&
effect.type === 'NOISE' &&
('visible' in effect ? effect.visible !== false : true)
)
}
function seededNoise(seed: number): number {
const x = Math.sin(seed * 12.9898) * 43758.5453
return x - Math.floor(x)
}
function renderNoiseEffect(
r: SkiaRenderer,
canvas: Canvas,
node: SceneNode,
rect: Float32Array,
hasRadius: boolean,
effect: RawNoiseEffect
): void {
const density = Math.max(0, Math.min(1, effect.density ?? 0.3))
const step = Math.max(2, Math.round((effect.noiseSize?.x ?? 0.5) * 8))
const color = effect.color ?? BLACK
const opacity = effect.opacity ?? color.a
const paint = new r.ck.Paint()
try {
paint.setAntiAlias(false)
canvas.save()
r.clipNodeShape(canvas, node, rect, hasRadius)
for (let y = 0; y < node.height; y += step) {
for (let x = 0; x < node.width; x += step) {
const value = seededNoise((x + 1) * 73856093 + (y + 1) * 19349663)
if (value > density) continue
const alpha = Math.max(0, Math.min(1, opacity * (0.35 + value * 0.65)))
paint.setColor(r.ck.Color4f(color.r, color.g, color.b, alpha))
canvas.drawRect(r.ck.LTRBRect(x, y, Math.min(node.width, x + 1), Math.min(node.height, y + 1)), paint)
}
}
canvas.restore()
} finally {
paint.delete()
}
}
function drawChildTransform(canvas: Canvas, child: SceneNode, offset = { x: 0, y: 0 }): void {
canvas.translate(child.x + offset.x, child.y + offset.y)
if (child.rotation !== 0) {
@ -458,6 +522,12 @@ export function renderEffects(
pass: 'behind' | 'front',
shadowShapeChild?: SceneNode | null
): void {
if (pass === 'front') {
for (const effect of rawNoiseEffects(node)) {
renderNoiseEffect(r, canvas, node, rect, hasRadius, effect)
}
}
for (const effect of node.effects) {
if (!effect.visible) continue

View file

@ -33,6 +33,7 @@ export function createMockRenderer(overrides: Partial<SkiaRenderer> = {}): SkiaR
StrokeJoin: { Round: 0 },
Matrix: { translated: mock(() => new Float32Array(9)) },
BlendMode: { SrcOver: 0, SrcIn: 1, DstOut: 2, Screen: 3, Multiply: 4 },
PaintStyle: { Fill: 0, Stroke: 1 },
ColorType: { RGBA_8888: 0 },
AlphaType: { Premul: 0, Unpremul: 1 },
ColorSpace: { SRGB: 0 },
@ -43,8 +44,10 @@ export function createMockRenderer(overrides: Partial<SkiaRenderer> = {}): SkiaR
WHITE: new Float32Array([1, 1, 1, 1]),
TRANSPARENT: new Float32Array([0, 0, 0, 0]),
Paint: class {
setAntiAlias = mock(() => undefined)
setColor = mock(() => undefined)
setShader = mock(() => undefined)
setStyle = mock(() => undefined)
delete = mock(() => undefined)
},
ColorFilter: {
@ -117,6 +120,7 @@ export function createMockRenderer(overrides: Partial<SkiaRenderer> = {}): SkiaR
}),
renderText: mock(() => undefined),
applyClippedBlur: mock(() => undefined),
clipNodeShape: mock(() => undefined),
applyFill: mock(() => true),
renderShape: mock(() => undefined),
renderSection: mock(() => undefined),
@ -145,6 +149,7 @@ export function createMockCanvas() {
scale: mock(() => undefined),
drawOval: mock(() => undefined),
drawRRect: mock(() => undefined),
drawCircle: mock(() => undefined),
drawRect: mock(() => undefined),
drawPath: mock(() => undefined),
saveLayer: mock(() => undefined),

View file

@ -132,6 +132,58 @@ describe('Renderer handles all effect types (Behavioral)', () => {
expect(canvas.drawPath).toHaveBeenCalled()
})
test('renders raw Figma noise effects as a bounded visual fallback', () => {
const r = createMockRenderer()
const canvas = createMockCanvas()
const node: Partial<SceneNode> = {
type: 'RECTANGLE',
width: 20,
height: 20,
fills: [],
childIds: [],
effects: [],
source: {
format: 'fig',
id: '1:1',
orderKey: null,
fig: {
rawSize: null,
rawTransform: null,
rawNodeFields: {
effects: [
{
type: 'NOISE',
visible: true,
color: { r: 0, g: 0, b: 0, a: 1 },
density: 1,
noiseSize: { x: 0.25, y: 0.25 },
noiseType: 'MONOTONE'
}
]
},
layout: null,
symbolOverrides: [],
componentPropAssignments: [],
derivedSymbolData: [],
derivedSymbolDataLayoutVersion: null,
uniformScaleFactor: null
}
}
}
renderEffects(
r,
canvas as Canvas,
node as SceneNode,
new Float32Array([0, 0, 20, 20]),
false,
'front'
)
expect(r.clipNodeShape).toHaveBeenCalled()
expect(canvas.drawRect).toHaveBeenCalled()
})
test('handles BACKGROUND_BLUR', () => {
const r = createMockRenderer()
const canvas = createMockCanvas()