Skip to content
Back to Examples

Skia

GPU-accelerated 2D vector graphics via Skia WASM with WebGPU and WebGL.

import { WebGPURenderer } from 'three/webgpu'
import {
Scene, PerspectiveCamera, OrthographicCamera, Fog,
Mesh, PlaneGeometry, MeshBasicMaterial,
AmbientLight, DirectionalLight, Color, DoubleSide,
} from 'three'
import { OrbitControls } from 'three/addons/controls/OrbitControls.js'
import { reflector, color as tslColor, positionWorld, cameraPosition, uv, vec2, hash, float as tslFloat, mx_worley_noise_float, smoothstep as tslSmoothstep, length as tslLength } from 'three/tsl'
import { gaussianBlur } from 'three/addons/tsl/display/GaussianBlurNode.js'
import { MeshBasicNodeMaterial, MeshStandardNodeMaterial } from 'three/webgpu'
import { Skia, SkiaPaint, SkiaPath } from '@three-flatland/skia'
import { gemGradientNode } from './GemBackground'
import { GEM } from './gem'
import {
SkiaCanvas,
SkiaRect,
SkiaCircle,
SkiaLine,
SkiaPathNode,
SkiaTextNode,
SkiaGroup,
SkiaFontLoader,
} from '@three-flatland/skia/three'
import { createPane } from '@three-flatland/devtools'
import { createDevtoolsProvider } from 'three-flatland'
function setStatus(msg: string, ok: boolean) {
console.log(`[skia] ${msg}`)
const el = document.getElementById('status')
if (!el) return
if (ok) {
el.style.display = 'none'
return
}
el.textContent = msg
el.classList.add('error')
el.style.display = ''
}
const SKIA_WASM_BUILD_HINT =
'packages/skia/dist/ is gitignored — build it first: pnpm --filter @three-flatland/skia build'
/** True when `err` is the WASM instantiate failure that fires when packages/skia/dist/ hasn't been built. */
function isSkiaWasmLoadError(err: unknown): boolean {
const message = err instanceof Error ? err.message : String(err)
return /webassembly\.instantiate/i.test(message) || /buffersource argument is empty/i.test(message)
}
function hslToArgb(h: number, s: number, l: number): number {
const c = (1 - Math.abs(2 * l - 1)) * s
const x = c * (1 - Math.abs((h / 60) % 2 - 1))
const m = l - c / 2
let r: number, g: number, b: number
if (h < 60) { r = c; g = x; b = 0 }
else if (h < 120) { r = x; g = c; b = 0 }
else if (h < 180) { r = 0; g = c; b = x }
else if (h < 240) { r = 0; g = x; b = c }
else if (h < 300) { r = x; g = 0; b = c }
else { r = c; g = 0; b = x }
const ri = Math.round((r + m) * 255), gi = Math.round((g + m) * 255), bi = Math.round((b + m) * 255)
return (0xFF000000 | (ri << 16) | (gi << 8) | bi) >>> 0
}
function smoothstep(t: number): number { return t * t * (3 - 2 * t) }
/* HMR-tracked teardown state. Without this, every dev save accumulates
* a fresh renderer + animate() loop while the previous one keeps
* RAFing forever. Dev-only — `import.meta.hot` is undefined in prod. */
let rafId = 0
let activeRenderer: WebGPURenderer | null = null
async function main() {
const dpr = Math.min(devicePixelRatio, 2)
// ── Three.js setup (3D perspective) ──
const renderer = new WebGPURenderer({ antialias: true })
activeRenderer = renderer
renderer.setSize(window.innerWidth, window.innerHeight)
renderer.setPixelRatio(dpr)
document.body.appendChild(renderer.domElement)
await renderer.init()
// Black clear so alpha-faded plane edges reveal the void at the
// horizon. Fog is also black, fading foreground 3D meshes (floor,
// panel) toward the same void as the bg plane fades to. Result:
// smooth atmospheric blend across the whole scene, no hard
// backdrop cutoff.
renderer.setClearColor(new Color(0x000000))
const scene = new Scene()
scene.background = new Color(0x000000)
scene.fog = new Fog(0x000000, 6, 18)
const camera = new PerspectiveCamera(40, window.innerWidth / window.innerHeight, 0.1, 100)
camera.position.set(0, 0.9, 4.5)
camera.lookAt(0, 0.9, 0)
// Orbit controls
const controls = new OrbitControls(camera, renderer.domElement)
controls.enableDamping = true
controls.dampingFactor = 0.05
controls.target.set(0, 0.9, 0)
controls.minDistance = 2
controls.maxDistance = 10
controls.maxPolarAngle = Math.PI * 0.85
// Lighting
scene.add(new AmbientLight(0x404060, 0.5))
const dirLight = new DirectionalLight(0xffffff, 0.8)
dirLight.position.set(2, 4, 3)
scene.add(dirLight)
// ── Gem-gradient backdrop (L2 as a real 3D plane, not scene.background) ──
// Plane positioned behind the panel/floor at z=-15, sized to fill the
// camera frustum from there. Color: gem gradient with a tight radius
// (0.4) so the gem identity stays in the upper-left and the visible
// top portion of the screen reads mostly as outer-ring BG (dark).
// Opaque — atmospheric blend into the floor is handled by the
// horizontal FloorEdgeFade mask below, not by alpha on this plane.
const bgGeo = new PlaneGeometry(40, 25)
const bgMat = new MeshBasicNodeMaterial({ depthWrite: false, fog: false })
bgMat.colorNode = gemGradientNode({ gem: GEM, radius: 0.4 })
const bgPlane = new Mesh(bgGeo, bgMat)
bgPlane.position.set(0, 0.9, -15)
bgPlane.renderOrder = -100 // before everything else in the scene
scene.add(bgPlane)
// ── Initialize Skia ──
setStatus('Loading Skia WASM...', true)
const skia = await Skia.init(renderer)
setStatus(`Skia ready (${skia.backend})`, true)
// ── Skia texture canvas (shapes → texture for 3D mesh) ──
const TEX_W = 1024
const TEX_H = 880
const shapesCanvas = new SkiaCanvas({ renderer, width: TEX_W, height: TEX_H })
// Don't add to scene — it's not a visible 3D object, just produces a texture
// ── Skia overlay canvas (title/FPS → screen) ──
const pw = window.innerWidth * dpr
const ph = window.innerHeight * dpr
const overlayCanvas = new SkiaCanvas({ renderer, width: pw, height: ph, overlay: true })
// ── Build shapes scene (renders to texture) ──
const REF = 512
const scale = TEX_W / REF
const bg = new SkiaRect()
bg.x = 0; bg.y = 0; bg.width = TEX_W; bg.height = TEX_H
bg.fill = [0.06, 0.06, 0.1, 0]
shapesCanvas.add(bg)
const shapeGroup = new SkiaGroup()
shapeGroup.scale.set(scale, scale, 1)
shapesCanvas.add(shapeGroup)
// Colored rectangles
const sqCols = [
[0.9, 0.3, 0.3], [0.3, 0.9, 0.4], [0.3, 0.5, 0.9],
[0.9, 0.7, 0.2], [0.7, 0.3, 0.9], [0.2, 0.8, 0.8],
]
const squares: SkiaRect[] = []
for (let i = 0; i < sqCols.length; i++) {
const rect = new SkiaRect()
rect.x = 30 + i * 78; rect.y = 30; rect.width = 68; rect.height = 68
rect.cornerRadius = 10
rect.fill = [...sqCols[i]!, 1] as [number, number, number, number]
shapeGroup.add(rect)
squares.push(rect)
}
let sqSwapA = 0, sqSwapB = 1, sqSwapStart = 0
let sqFromA = [...sqCols[0]!], sqFromB = [...sqCols[1]!]
let sqToA = [...sqCols[1]!], sqToB = [...sqCols[0]!]
// Circles
const circles: SkiaCircle[] = []
for (let i = 0; i < 8; i++) {
const circle = new SkiaCircle()
circle.cx = 64 + i * 56; circle.cy = 140; circle.r = 18
circle.fill = [0.4, 0.4, 0.6, 0.7]
shapeGroup.add(circle)
circles.push(circle)
}
// Stroked frame
const strokedFrame = new SkiaRect()
strokedFrame.x = 20; strokedFrame.y = 170; strokedFrame.width = 472; strokedFrame.height = 100
strokedFrame.stroke = [1, 1, 1, 0.3]; strokedFrame.strokeWidth = 2
shapeGroup.add(strokedFrame)
const innerFrame = new SkiaRect()
innerFrame.x = 28; innerFrame.y = 176; innerFrame.width = 456; innerFrame.height = 88
innerFrame.cornerRadius = 10; innerFrame.stroke = [1, 1, 1, 0.3]; innerFrame.strokeWidth = 2
shapeGroup.add(innerFrame)
// Scanner lines
const lines: SkiaLine[] = []
for (let i = 0; i < 9; i++) {
const line = new SkiaLine()
line.x1 = 36; line.y1 = 184 + i * 9; line.x2 = 472; line.y2 = 184 + i * 9
line.strokeWidth = 1
line.stroke = [0.5, 0.8, 0.3, 0.6]
shapeGroup.add(line)
lines.push(line)
}
// Gradient bars
const gradColors: [number, number][] = [
[0xFFFF4444, 0xFF4444FF], [0xFF44FF44, 0xFFFF44FF],
[0xFFFFAA00, 0xFF00AAFF], [0xFF4488FF, 0xFFFF8844],
]
const gradRects: SkiaRect[] = []
const gradPaints: SkiaPaint[] = []
for (let i = 0; i < gradColors.length; i++) {
const y = 284 + i * 22
const rect = new SkiaRect()
rect.x = 30; rect.y = y; rect.width = 452; rect.height = 16; rect.cornerRadius = 4
const paint = new SkiaPaint(skia).setFill()
rect.paint = paint
shapeGroup.add(rect)
gradRects.push(rect)
gradPaints.push(paint)
}
// PathOps
const pathOpColors: [number, number, number][] = [
[0.9, 0.3, 0.3], [0.3, 0.9, 0.4], [0.3, 0.5, 0.9], [0.9, 0.7, 0.2],
]
const pathOpNames: Array<'difference' | 'intersect' | 'union' | 'xor'> = [
'difference', 'intersect', 'union', 'xor',
]
const pathOpData = pathOpNames.map((_, pi) => {
const resultNode = new SkiaPathNode()
resultNode.fill = [...pathOpColors[pi]!, 0.9]
shapeGroup.add(resultNode)
const ghostANode = new SkiaPathNode()
ghostANode.stroke = [1, 1, 1, 0.15]; ghostANode.strokeWidth = 1
shapeGroup.add(ghostANode)
const ghostBNode = new SkiaPathNode()
ghostBNode.stroke = [1, 1, 1, 0.15]; ghostBNode.strokeWidth = 1
shapeGroup.add(ghostBNode)
return {
resultNode, ghostANode, ghostBNode,
pathA: new SkiaPath(skia), pathB: new SkiaPath(skia), resultPath: new SkiaPath(skia),
}
})
// ── Build overlay scene (title/FPS → screen) ──
const titleText = new SkiaTextNode()
titleText.text = '@three-flatland/skia'
titleText.fill = [1, 1, 1, 1]
titleText.y = ph - 90
overlayCanvas.add(titleText)
const subtitleText = new SkiaTextNode()
subtitleText.text = 'GPU-accelerated vector graphics in the browser'
subtitleText.y = ph - 40
overlayCanvas.add(subtitleText)
let subtitlePaint: SkiaPaint | null = null
const backendText = new SkiaTextNode()
backendText.x = 20; backendText.y = 30
backendText.fill = [0.6, 0.6, 0.8, 0.6]
overlayCanvas.add(backendText)
// ── Load fonts ──
const FONT_URL = 'https://cdn.jsdelivr.net/gh/rsms/inter@v4.1/docs/font-files/InterVariable.ttf'
SkiaFontLoader.load(FONT_URL, skia).then((typeface) => {
const titleF = typeface.atSize(32 * dpr)
const subF = typeface.atSize(14 * dpr)
const fpsF = typeface.atSize(11 * dpr)
titleText.font = titleF
titleText.x = (pw - titleF.measureText(titleText.text)) / 2
subtitleText.font = subF
subtitleText.x = (pw - subF.measureText(subtitleText.text)) / 2
subtitlePaint = new SkiaPaint(skia).setFill()
subtitleText.paint = subtitlePaint
backendText.font = fpsF
backendText.text = `Backend: ${skia.backend.toUpperCase()}`
}).catch(e => console.warn('Font load failed:', e))
// ── 3D scene: floating panel with Skia texture ──
const panelW = 2.8
const panelH = 2.8 * (880 / 1024) // match texture aspect ratio
const panelGeo = new PlaneGeometry(panelW, panelH)
// Panels — center + two flanking, rotated inward
const panelMat = new MeshBasicMaterial({
color: 0xffffff,
side: DoubleSide,
transparent: true,
premultipliedAlpha: true,
})
const panelCenter = new Mesh(panelGeo, panelMat)
panelCenter.position.set(0, 1.2, -0.4)
// renderOrder=10 forces the skia panels to draw LAST (after bgPlane,
// ground, and floorEdge). When the panel's transparent skia regions
// composite, the framebuffer already contains the gem-tinted bg
// plane behind them — so transparency reveals the gradient instead
// of opaque black.
panelCenter.renderOrder = 10
scene.add(panelCenter)
const panelMatL = new MeshBasicMaterial({ color: 0xffffff, side: DoubleSide, transparent: true, premultipliedAlpha: true })
const panelLeft = new Mesh(panelGeo, panelMatL)
panelLeft.position.set(-2.6, 1.2, 0.3)
panelLeft.rotation.y = 0.35
panelLeft.renderOrder = 10
scene.add(panelLeft)
const panelMatR = new MeshBasicMaterial({ color: 0xffffff, side: DoubleSide, transparent: true, premultipliedAlpha: true })
const panelRight = new Mesh(panelGeo, panelMatR)
panelRight.position.set(2.6, 1.2, 0.3)
panelRight.rotation.y = -0.35
panelRight.renderOrder = 10
scene.add(panelRight)
// Reflective ground (TSL reflector + blur + distance fade)
const groundMat = new MeshStandardNodeMaterial()
const groundReflector = reflector({ resolutionScale: 1.0 })
// Blur the reflection
const blurredReflection = gaussianBlur(groundReflector, null, 6)
// Fade based on distance from panel (xz plane), not from camera
// Strongest directly under the panel, fades outward
const dist = positionWorld.xz.length() // distance from origin on ground
const fadeFactor = dist.div(3.0).clamp(0.0, 1.0).oneMinus()
const fadeSharp = fadeFactor.mul(fadeFactor).mul(fadeFactor) // cubic
// Floor base color stays dark — the gem identity now lives on the
// L2 background plane (scene.backgroundNode above) rather than
// composed into the floor surface itself. Reflections of the lit
// foreground (sprite panel, etc.) carry the surface character; the
// dark base lets those reflections pop without competing with a
// gem-tinted floor.
groundMat.colorNode = tslColor(new Color(0x050505))
.add((blurredReflection as any).rgb.mul(fadeSharp).mul(0.5))
// Roughness: sharper under panel, rougher outward
groundMat.roughnessNode = tslFloat(0.5)
.add(dist.div(5.0).clamp(0.0, 0.5))
groundMat.metalness = 0.5
const ground = new Mesh(new PlaneGeometry(50, 50), groundMat)
ground.rotation.x = -Math.PI / 2
ground.position.y = -0.01
ground.add(groundReflector.target)
scene.add(ground)
// ── Floor edge fade ──
// Horizontal sibling quad just above the ground plane that paints the
// SAME gem gradient as the bg plane over the floor's hard rectangular
// silhouette — opaque at the corners, transparent in the center
// where reflections show through. Because gemGradientNode is
// screen-space, the floor-edge quad and the bg plane sample the same
// colors at any given screen pixel — the floor's edges dissolve
// seamlessly into the bg plane (no color discontinuity).
// Ported from remotion-studio-monorepo's FloorEdgeFade.tsx (GLSL → TSL),
// adapted: their version paints scene.background color (a flat color);
// ours paints the same screen-space gem gradient as the bg plane so
// the match holds against a non-flat backdrop.
const edgeGeo = new PlaneGeometry(50, 50)
const edgeMat = new MeshBasicNodeMaterial({
transparent: true,
depthWrite: false,
fog: false,
})
edgeMat.colorNode = gemGradientNode({ gem: GEM, radius: 0.4 })
const edgeDist = tslLength(uv().sub(vec2(0.5))).mul(tslFloat(2))
edgeMat.opacityNode = tslSmoothstep(tslFloat(0.35), tslFloat(0.7), edgeDist)
const floorEdge = new Mesh(edgeGeo, edgeMat)
floorEdge.rotation.x = -Math.PI / 2
floorEdge.position.y = 0 // just above ground (at -0.01)
floorEdge.renderOrder = 1 // after the floor (default 0)
scene.add(floorEdge)
// ── TweakPane debug controls ──
const { update: updateDevtools } = createPane({ driver: 'manual' })
const devtools = createDevtoolsProvider({ name: 'skia' })
// ── Animation loop ──
function animate(t: number) {
// ── Animate squares ──
const sqDur = 3000
let sqT = Math.min((t - sqSwapStart) / sqDur, 1.0)
sqT = smoothstep(sqT)
if (sqT >= 1.0 && t > 0) {
sqCols[sqSwapA] = [...sqToA]
sqCols[sqSwapB] = [...sqToB]
const a = Math.floor(Math.random() * 6)
const b2 = (a + 1 + Math.floor(Math.random() * 5)) % 6
sqSwapA = a; sqSwapB = b2; sqSwapStart = t
sqFromA = [...sqCols[a]!]; sqFromB = [...sqCols[b2]!]
sqToA = [...sqCols[b2]!]; sqToB = [...sqCols[a]!]
sqT = 0
}
for (let i = 0; i < squares.length; i++) {
let c = sqCols[i]!
if (i === sqSwapA) c = sqFromA.map((v, j) => v + (sqToA[j]! - v) * sqT)
else if (i === sqSwapB) c = sqFromB.map((v, j) => v + (sqToB[j]! - v) * sqT)
squares[i]!.fill = [c[0]!, c[1]!, c[2]!, 1]
}
// ── Animate circles ──
for (let i = 0; i < circles.length; i++) {
const ct = ((i / 8) + t * 0.0002) % 1.0
circles[i]!.fill = [0.15 + 0.35 * ct, 0.2 + 0.25 * (1 - ct), 0.3 + 0.4 * Math.sin(ct * Math.PI * 2 + 4), 0.7]
}
// ── Animate scanner lines ──
const linePhase = Math.floor(t / 83) % 9
for (let i = 0; i < lines.length; i++) {
const lt = ((i + linePhase) % 9) / 9
lines[i]!.stroke = [0.5 + 0.5 * lt, 0.8 - 0.3 * lt, 0.3 + 0.5 * lt, 0.6]
}
// ── Animate gradient bars ──
for (let i = 0; i < gradPaints.length; i++) {
const y = 284 + i * 22
const dir = (i % 2 === 0) ? 1 : -1
const speed = 0.15 + i * 0.03
const mid = 0.5 + 0.4 * Math.sin(t * speed * 0.001 * dir)
const [cA, cB] = gradColors[i]!
gradPaints[i]!.setLinearGradient(30, y, 482, y, [cA!, cB!, cA!], [0, mid, 1])
}
// ── Animate PathOps ──
for (let pi = 0; pi < pathOpNames.length; pi++) {
const cx = 75 + pi * 120
const spread = 12 + 6 * Math.sin(t * 0.002 + pi)
const d = pathOpData[pi]!
d.pathA.reset().addCircle(cx - spread, 404, 26)
d.pathB.reset().addCircle(cx + spread, 404, 26)
const ok = d.pathA.opInto(d.pathB, pathOpNames[pi]!, d.resultPath)
d.resultNode.path = ok ? d.resultPath : undefined
d.resultNode.visible = ok
d.ghostANode.path = d.pathA
d.ghostBNode.path = d.pathB
}
// ── Animate subtitle gradient ──
if (subtitlePaint && subtitleText.font) {
const hue1 = (t * 0.05) % 360
const hue2 = (hue1 + 120) % 360
const subW = subtitleText.font.measureText(subtitleText.text)
subtitlePaint.setLinearGradient(
subtitleText.x, 0, subtitleText.x + subW, 0,
[hslToArgb(hue1, 0.8, 0.6), hslToArgb(hue2, 0.8, 0.6)], [0, 1],
)
}
// ── Gentle panel hover ──
const hover = Math.sin(t * 0.001) * 0.05
panelCenter.position.y = 1.2 + hover
panelLeft.position.y = 1.2 + hover
panelRight.position.y = 1.2 + hover
// ── Update controls ──
controls.update()
// ── Render pipeline ──
// 1. Skia shapes → texture
shapesCanvas.render(true)
// 2. Apply Skia texture to 3D meshes (once available)
const skiaTex = shapesCanvas.texture
if (skiaTex && panelMat.map !== skiaTex) {
panelMat.map = skiaTex; panelMat.needsUpdate = true
panelMatL.map = skiaTex; panelMatL.needsUpdate = true
panelMatR.map = skiaTex; panelMatR.needsUpdate = true
}
// 3. Three.js renders 3D scene (panel with Skia texture + reflection + ground)
devtools.beginFrame(performance.now(), renderer)
renderer.render(scene, camera)
devtools.endFrame(renderer)
// 4. Skia overlay on top of 3D
overlayCanvas.render(true)
updateDevtools()
rafId = requestAnimationFrame(animate)
}
rafId = requestAnimationFrame(animate)
// ── Resize ──
window.addEventListener('resize', () => {
const nw = window.innerWidth
const nh = window.innerHeight
renderer.setSize(nw, nh)
camera.aspect = nw / nh
camera.updateProjectionMatrix()
const npw = nw * dpr
const nph = nh * dpr
overlayCanvas.setSize(npw, nph)
titleText.y = nph - 90
subtitleText.y = nph - 40
})
}
if (import.meta.hot) {
import.meta.hot.dispose(() => {
if (rafId) {
cancelAnimationFrame(rafId)
rafId = 0
}
if (activeRenderer) {
activeRenderer.dispose?.()
activeRenderer.domElement.remove()
activeRenderer = null
}
})
}
main().catch((err) => {
console.error(err)
if (isSkiaWasmLoadError(err)) {
console.error(`[skia] Skia WASM failed to load. ${SKIA_WASM_BUILD_HINT}`)
setStatus('Skia WASM not built — run: pnpm --filter @three-flatland/skia build', false)
return
}
setStatus(`Error: ${err.message}`, false)
})
import { Canvas, extend, useFrame, useThree, useLoader } from '@react-three/fiber/webgpu'
import { Component, useRef, useEffect, useMemo, useState, Suspense, type ReactNode } from 'react'
import {
SkiaCanvas,
SkiaRect,
SkiaCircle,
SkiaLine,
SkiaPathNode,
SkiaTextNode,
SkiaGroup,
SkiaFontLoader,
useSkiaContext,
attachSkiaTexture,
} from '@three-flatland/skia/react'
import { SkiaPaint, SkiaPath } from '@three-flatland/skia'
import type { SkiaCanvas as SkiaCanvasInstance } from '@three-flatland/skia/three'
import { OrbitControls } from 'three/addons/controls/OrbitControls.js'
import {
reflector, color as tslColor, positionWorld, float as tslFloat, smoothstep as tslSmoothstep, uv, vec2, length as tslLength,
} from 'three/tsl'
import { gaussianBlur } from 'three/addons/tsl/display/GaussianBlurNode.js'
import { Color, DoubleSide, Fog, Mesh, PlaneGeometry, type MeshBasicMaterial } from 'three'
import { MeshBasicNodeMaterial, MeshStandardNodeMaterial } from 'three/webgpu'
import { DevtoolsProvider, usePane } from '@three-flatland/devtools/react'
import { gemGradientNode } from './GemBackground'
import { GEM } from './gem'
extend({ SkiaRect, SkiaCircle, SkiaLine, SkiaPathNode, SkiaTextNode, SkiaGroup })
// ── Constants ──
const TEX_W = 1024
const TEX_H = 880
const SCALE = TEX_W / 512
const FONT_URL = 'https://cdn.jsdelivr.net/gh/rsms/inter@v4.1/docs/font-files/InterVariable.ttf'
const GRAD_COLORS: [number, number][] = [
[0xFFFF4444, 0xFF4444FF], [0xFF44FF44, 0xFFFF44FF],
[0xFFFFAA00, 0xFF00AAFF], [0xFF4488FF, 0xFFFF8844],
]
const PATH_OP_NAMES: Array<'difference' | 'intersect' | 'union' | 'xor'> = [
'difference', 'intersect', 'union', 'xor',
]
const PATH_OP_COLORS: [number, number, number, number][] = [
[0.9, 0.3, 0.3, 0.9], [0.3, 0.9, 0.4, 0.9], [0.3, 0.5, 0.9, 0.9], [0.9, 0.7, 0.2, 0.9],
]
// ── Utilities ──
const SKIA_WASM_BUILD_HINT =
'packages/skia/dist/ is gitignored — build it first: pnpm --filter @three-flatland/skia build'
/** True when `err` is the WASM instantiate failure that fires when packages/skia/dist/ hasn't been built. */
function isSkiaWasmLoadError(err: unknown): boolean {
const message = err instanceof Error ? err.message : String(err)
return /webassembly\.instantiate/i.test(message) || /buffersource argument is empty/i.test(message)
}
/**
* Catches `useSkiaContext()`'s suspended `Skia.init()` rejecting — most
* commonly because packages/skia/dist/ (gitignored) hasn't been built
* in a fresh checkout/worktree — and reports an actionable message
* instead of the default "Uncaught Error" render crash.
*/
class SkiaErrorBoundary extends Component<{ children: ReactNode }, { error: Error | null }> {
state: { error: Error | null } = { error: null }
static getDerivedStateFromError(error: Error) {
return { error }
}
componentDidCatch(error: Error) {
if (isSkiaWasmLoadError(error)) {
console.error(`[skia example] Skia WASM failed to load. ${SKIA_WASM_BUILD_HINT}`)
} else {
console.error(error)
}
}
render() {
const { error } = this.state
if (!error) return this.props.children
return (
<div
style={{
position: 'fixed', top: 12, left: 12, zIndex: 100,
padding: '8px 14px', background: 'rgba(0, 0, 0, 0.75)',
borderRadius: 6, fontSize: 13, fontFamily: 'ui-monospace, monospace',
color: '#ff6b6b',
}}
>
{isSkiaWasmLoadError(error)
? 'Skia WASM not built — run: pnpm --filter @three-flatland/skia build'
: `Error: ${error.message}`}
</div>
)
}
}
function hslToArgb(h: number, s: number, l: number): number {
const c = (1 - Math.abs(2 * l - 1)) * s
const x = c * (1 - Math.abs((h / 60) % 2 - 1))
const m = l - c / 2
let r: number, g: number, b: number
if (h < 60) { r = c; g = x; b = 0 }
else if (h < 120) { r = x; g = c; b = 0 }
else if (h < 180) { r = 0; g = c; b = x }
else if (h < 240) { r = 0; g = x; b = c }
else if (h < 300) { r = x; g = 0; b = c }
else { r = c; g = 0; b = x }
const ri = Math.round((r + m) * 255), gi = Math.round((g + m) * 255), bi = Math.round((b + m) * 255)
return (0xFF000000 | (ri << 16) | (gi << 8) | bi) >>> 0
}
function smoothstep(t: number): number { return t * t * (3 - 2 * t) }
// ── Self-Animating Shape Components ──
const SQ_INIT = [
[0.9, 0.3, 0.3], [0.3, 0.9, 0.4], [0.3, 0.5, 0.9],
[0.9, 0.7, 0.2], [0.7, 0.3, 0.9], [0.2, 0.8, 0.8],
]
function Squares() {
const refs = useRef<(SkiaRect | null)[]>([])
const state = useRef({
cols: SQ_INIT.map(c => [...c]),
swapA: 0, swapB: 1, swapStart: 0,
fromA: [...SQ_INIT[0]!], fromB: [...SQ_INIT[1]!],
toA: [...SQ_INIT[1]!], toB: [...SQ_INIT[0]!],
})
useFrame(({ elapsed }) => {
const t = elapsed * 1000
const s = state.current
const sqDur = 3000
let sqT = Math.min((t - s.swapStart) / sqDur, 1.0)
sqT = smoothstep(sqT)
if (sqT >= 1.0 && t > 0) {
s.cols[s.swapA] = [...s.toA]; s.cols[s.swapB] = [...s.toB]
const a = Math.floor(Math.random() * 6)
const b = (a + 1 + Math.floor(Math.random() * 5)) % 6
s.swapA = a; s.swapB = b; s.swapStart = t
s.fromA = [...s.cols[a]!]; s.fromB = [...s.cols[b]!]
s.toA = [...s.cols[b]!]; s.toB = [...s.cols[a]!]
sqT = 0
}
for (let i = 0; i < 6; i++) {
const node = refs.current[i]
if (!node) continue
let c = s.cols[i]!
if (i === s.swapA) c = s.fromA.map((v, j) => v + (s.toA[j]! - v) * sqT)
else if (i === s.swapB) c = s.fromB.map((v, j) => v + (s.toB[j]! - v) * sqT)
node.fill = [c[0]!, c[1]!, c[2]!, 1]
}
})
return <>
{SQ_INIT.map((col, i) => (
<skiaRect key={i} ref={(el: SkiaRect | null) => { refs.current[i] = el }}
x={30 + i * 78} y={30} width={68} height={68} cornerRadius={10}
fill={[col[0]!, col[1]!, col[2]!, 1] as [number, number, number, number]}
/>
))}
</>
}
function Circles() {
const refs = useRef<(SkiaCircle | null)[]>([])
useFrame(({ elapsed }) => {
const t = elapsed * 1000
for (let i = 0; i < 8; i++) {
const node = refs.current[i]
if (!node) continue
const ct = ((i / 8) + t * 0.0002) % 1.0
node.fill = [0.15 + 0.35 * ct, 0.2 + 0.25 * (1 - ct), 0.3 + 0.4 * Math.sin(ct * Math.PI * 2 + 4), 0.7]
}
})
return <>
{Array.from({ length: 8 }, (_, i) => (
<skiaCircle key={i} ref={(el: SkiaCircle | null) => { refs.current[i] = el }}
cx={64 + i * 56} cy={140} r={18}
fill={[0.4, 0.4, 0.6, 0.7] as [number, number, number, number]}
/>
))}
</>
}
function Frames() {
return <>
<skiaRect x={20} y={170} width={472} height={100}
stroke={[1, 1, 1, 0.3] as [number, number, number, number]} strokeWidth={2} />
<skiaRect x={28} y={176} width={456} height={88} cornerRadius={10}
stroke={[1, 1, 1, 0.3] as [number, number, number, number]} strokeWidth={2} />
</>
}
function ScannerLines() {
const refs = useRef<(SkiaLine | null)[]>([])
useFrame(({ elapsed }) => {
const t = elapsed * 1000
const phase = Math.floor(t / 83) % 9
for (let i = 0; i < 9; i++) {
const node = refs.current[i]
if (!node) continue
const lt = ((i + phase) % 9) / 9
node.stroke = [0.5 + 0.5 * lt, 0.8 - 0.3 * lt, 0.3 + 0.5 * lt, 0.6]
}
})
return <>
{Array.from({ length: 9 }, (_, i) => (
<skiaLine key={i} ref={(el: SkiaLine | null) => { refs.current[i] = el }}
x1={36} y1={184 + i * 9} x2={472} y2={184 + i * 9}
strokeWidth={1} stroke={[0.5, 0.8, 0.3, 0.6] as [number, number, number, number]}
/>
))}
</>
}
function GradientBars() {
const skia = useSkiaContext()!
const refs = useRef<(SkiaRect | null)[]>([])
const [paints] = useState(() =>
GRAD_COLORS.map(() => new SkiaPaint(skia).setFill()))
// Wire paints to nodes once refs are available
useFrame(({ elapsed }) => {
const t = elapsed * 1000
for (let i = 0; i < paints.length; i++) {
const node = refs.current[i]
if (!node) continue
if (!node.paint) node.paint = paints[i]
const y = 284 + i * 22
const dir = (i % 2 === 0) ? 1 : -1
const speed = 0.15 + i * 0.03
const mid = 0.5 + 0.4 * Math.sin(t * speed * 0.001 * dir)
const [cA, cB] = GRAD_COLORS[i]!
paints[i]!.setLinearGradient(30, y, 482, y, [cA!, cB!, cA!], [0, mid, 1])
}
})
return <>
{GRAD_COLORS.map((_, i) => (
<skiaRect key={i} ref={(el: SkiaRect | null) => { refs.current[i] = el }}
x={30} y={284 + i * 22} width={452} height={16} cornerRadius={4}
/>
))}
</>
}
function PathOpGroup({ index }: { index: number }) {
const skia = useSkiaContext()!
const resultRef = useRef<SkiaPathNode>(null)
const ghostARef = useRef<SkiaPathNode>(null)
const ghostBRef = useRef<SkiaPathNode>(null)
const [paths] = useState(() => ({
a: new SkiaPath(skia), b: new SkiaPath(skia), result: new SkiaPath(skia),
}))
useFrame(({ elapsed }) => {
const t = elapsed * 1000
const cx = 75 + index * 120
const spread = 12 + 6 * Math.sin(t * 0.002 + index)
paths.a.reset().addCircle(cx - spread, 404, 26)
paths.b.reset().addCircle(cx + spread, 404, 26)
const ok = paths.a.opInto(paths.b, PATH_OP_NAMES[index]!, paths.result)
if (resultRef.current) {
resultRef.current.path = ok ? paths.result : undefined
resultRef.current.visible = ok
}
if (ghostARef.current) ghostARef.current.path = paths.a
if (ghostBRef.current) ghostBRef.current.path = paths.b
})
return (
<skiaGroup>
<skiaPathNode ref={resultRef} fill={PATH_OP_COLORS[index]} />
<skiaPathNode ref={ghostARef} stroke={[1, 1, 1, 0.15] as [number, number, number, number]} strokeWidth={1} />
<skiaPathNode ref={ghostBRef} stroke={[1, 1, 1, 0.15] as [number, number, number, number]} strokeWidth={1} />
</skiaGroup>
)
}
// ── Overlay Text ──
function OverlayText() {
const skia = useSkiaContext()
const typeface = useLoader(SkiaFontLoader, FONT_URL)
const size = useThree((s) => s.size)
const dpr = Math.min(devicePixelRatio, 2)
const pw = size.width * dpr
const ph = size.height * dpr
const titleFont = typeface.atSize(Math.round(32 * dpr))
const subFont = typeface.atSize(Math.round(14 * dpr))
const fpsFont = typeface.atSize(Math.round(11 * dpr))
const subtitleRef = useRef<SkiaTextNode>(null)
const [subtitlePaint] = useState(() => new SkiaPaint(skia).setFill())
const titleX = (pw - titleFont.measureText('@three-flatland/skia')) / 2
const subtitleX = (pw - subFont.measureText('GPU-accelerated vector graphics in the browser')) / 2
useFrame(({ elapsed }) => {
// Subtitle rainbow gradient
const sub = subtitleRef.current
if (sub) {
sub.paint = subtitlePaint
const t = elapsed * 1000
const hue1 = (t * 0.05) % 360
const hue2 = (hue1 + 120) % 360
const subW = subFont.measureText(sub.text)
subtitlePaint.setLinearGradient(
sub.x, 0, sub.x + subW, 0,
[hslToArgb(hue1, 0.8, 0.6), hslToArgb(hue2, 0.8, 0.6)], [0, 1],
)
}
})
return <>
<skiaTextNode text="@three-flatland/skia" font={titleFont}
fill={[1, 1, 1, 1]} x={titleX} y={ph - 90} />
<skiaTextNode ref={subtitleRef} text="GPU-accelerated vector graphics in the browser"
font={subFont} paint={subtitlePaint} x={subtitleX} y={ph - 40} />
<skiaTextNode text={`Backend: ${skia.backend.toUpperCase()}`} font={fpsFont}
fill={[0.6, 0.6, 0.8, 0.6]} x={20} y={30} />
</>
}
// ── 3D Scene Components ──
function ReflectiveGround() {
const { mat, groundReflector } = useMemo(() => {
const mat = new MeshStandardNodeMaterial()
const groundReflector = reflector({ resolutionScale: 1.0 })
const blurredReflection = gaussianBlur(groundReflector, null, 6)
const dist = positionWorld.xz.length()
const fadeFactor = dist.div(3.0).clamp(0.0, 1.0).oneMinus()
const fadeSharp = fadeFactor.mul(fadeFactor).mul(fadeFactor)
// Floor base color stays dark — the gem identity now lives on the
// L2 background plane (set in the Canvas onCreated) rather than
// composed into the floor surface itself. Reflections of the lit
// foreground panel carry the surface character; the dark base
// lets those reflections pop without competing with the floor.
mat.colorNode = tslColor(new Color(0x050505))
.add((blurredReflection as any).rgb.mul(fadeSharp).mul(0.5))
mat.roughnessNode = tslFloat(0.5).add(dist.div(5.0).clamp(0.0, 0.5))
mat.metalness = 0.5
return { mat, groundReflector }
}, [])
const meshRef = useRef<Mesh>(null)
useEffect(() => {
const mesh = meshRef.current
if (!mesh) return
mesh.add(groundReflector.target)
return () => { mesh.remove(groundReflector.target) }
}, [groundReflector])
return (
<mesh ref={meshRef} rotation-x={-Math.PI / 2} position-y={-0.01} material={mat}>
<planeGeometry args={[50, 50]} />
</mesh>
)
}
function Controls({ dampingFactor, minDistance, maxDistance }: {
dampingFactor: number
minDistance: number
maxDistance: number
}) {
const gl = useThree((s) => s.gl)
const camera = useThree((s) => s.camera)
const controlsRef = useRef<OrbitControls | null>(null)
useEffect(() => {
const controls = new OrbitControls(camera, gl.domElement)
controls.enableDamping = true
controls.dampingFactor = dampingFactor
controls.target.set(0, 0.9, 0)
controls.minDistance = minDistance
controls.maxDistance = maxDistance
controls.maxPolarAngle = Math.PI * 0.85
controlsRef.current = controls
return () => controls.dispose()
}, [camera, gl]) // eslint-disable-line react-hooks/exhaustive-deps
useEffect(() => {
const c = controlsRef.current
if (!c) return
c.dampingFactor = dampingFactor
c.minDistance = minDistance
c.maxDistance = maxDistance
}, [dampingFactor, minDistance, maxDistance])
useFrame(() => controlsRef.current?.update())
return null
}
function Panels() {
const panelW = 2.8
const panelH = 2.8 * (TEX_H / TEX_W)
const centerRef = useRef<Mesh>(null)
const leftRef = useRef<Mesh>(null)
const rightRef = useRef<Mesh>(null)
const leftMatRef = useRef<MeshBasicMaterial>(null)
const rightMatRef = useRef<MeshBasicMaterial>(null)
const renderer = useThree((s) => s.renderer)
const canvasRef = useRef<SkiaCanvasInstance>(null)
useFrame(({ elapsed }) => {
// Hover animation
const hover = Math.sin(elapsed) * 0.05
if (centerRef.current) centerRef.current.position.y = 1.2 + hover
if (leftRef.current) leftRef.current.position.y = 1.2 + hover
if (rightRef.current) rightRef.current.position.y = 1.2 + hover
// Share texture with side panels
const texture = canvasRef.current?.texture ?? null;
if (texture) {
if (leftMatRef.current && leftMatRef.current.map !== texture) {
leftMatRef.current.map = texture; leftMatRef.current.needsUpdate = true
}
if (rightMatRef.current && rightMatRef.current.map !== texture) {
rightMatRef.current.map = texture; rightMatRef.current.needsUpdate = true
}
}
})
useFrame(() => {
canvasRef.current?.render(true)
}, { before: 'render' })
// renderOrder=10 forces the skia panels to draw LAST (after bgPlane,
// ground, and floorEdge). When the panel's transparent skia regions
// composite, the framebuffer already contains the gem-tinted bg
// plane behind them — so transparency reveals the gradient instead
// of opaque black.
return <>
<mesh ref={centerRef} position={[0, 1.2, -0.4]} renderOrder={10}>
<planeGeometry args={[panelW, panelH]} />
<meshBasicMaterial color={0xffffff} side={DoubleSide} transparent premultipliedAlpha>
<SkiaCanvas ref={canvasRef as any} attach={attachSkiaTexture} renderer={renderer} width={TEX_W} height={TEX_H}>
<skiaRect x={0} y={0} width={TEX_W} height={TEX_H} fill={[0.06, 0.06, 0.1, 0] as [number, number, number, number]} />
<skiaGroup scale={[SCALE, SCALE, 1]}>
<Squares />
<Circles />
<Frames />
<ScannerLines />
<GradientBars />
{PATH_OP_NAMES.map((_, i) => (
<PathOpGroup key={i} index={i} />
))}
</skiaGroup>
</SkiaCanvas>
</meshBasicMaterial>
</mesh>
<mesh ref={leftRef} position={[-2.6, 1.2, 0.3]} rotation-y={0.35} renderOrder={10}>
<planeGeometry args={[panelW, panelH]} />
<meshBasicMaterial ref={leftMatRef} color={0xffffff} side={DoubleSide} transparent premultipliedAlpha />
</mesh>
<mesh ref={rightRef} position={[2.6, 1.2, 0.3]} rotation-y={-0.35} renderOrder={10}>
<planeGeometry args={[panelW, panelH]} />
<meshBasicMaterial ref={rightMatRef} color={0xffffff} side={DoubleSide} transparent premultipliedAlpha />
</mesh>
</>
}
// ── Main Demo ──
function SkiaDemo() {
const renderer = useThree((s) => s.renderer)
const size = useThree((s) => s.size)
const skia = useSkiaContext()
const dpr = Math.min(devicePixelRatio, 2)
const pw = size.width * dpr
const ph = size.height * dpr
const overlayRef = useRef<SkiaCanvasInstance>(null)
// ── TweakPane debug controls ──
usePane()
// Render overlay after Three.js render
useFrame(() => {
overlayRef.current?.render(true)
}, { after: 'render' })
if (!skia) return null
return <>
<ambientLight color={0x404060} intensity={0.5} />
<directionalLight color={0xffffff} intensity={0.8} position={[2, 4, 3]} />
<Controls dampingFactor={0.05} minDistance={2} maxDistance={10} />
<Panels />
<ReflectiveGround />
<SkiaCanvas ref={overlayRef as any} renderer={renderer} width={pw} height={ph} overlay>
<Suspense fallback={null}>
<OverlayText />
</Suspense>
</SkiaCanvas>
</>
}
// ── App Root ──
export default function App() {
return (
// Wraps <Canvas>, not its children: R3F's own internal wiring
// re-throws captured render errors (including a suspended
// Skia.init() rejection) during Canvas's own DOM-tree render, so an
// ancestor boundary out here is what actually catches it — and it's
// the only safe place for a DOM-rendering fallback, since Canvas's
// children render through R3F's own reconciler, which can't host
// plain HTML elements.
<SkiaErrorBoundary>
<Canvas
camera={{ position: [0, 0.9, 4.5], fov: 40, near: 0.1, far: 100 }}
renderer={{ antialias: true }}
onCreated={({ scene, gl }) => {
// Pure-black clear + fog so foreground 3D meshes (floor, panels)
// fade to the same void the floor's distant edges dissolve into.
gl.setClearColor(new Color(0x000000))
scene.background = new Color(0x000000)
scene.fog = new Fog(0x000000, 6, 18)
// Gem-gradient backdrop as a real 3D plane (L2). Sits at z=-15
// behind the panel/floor; opaque, with a tightened gradient
// radius (0.4) so the gem identity stays in the upper-left
// and the visible top portion of the screen reads mostly as
// outer-ring BG (dark). Atmospheric blend into the floor is
// handled by the FloorEdgeFade quad below, not by alpha here.
const bgMat = new MeshBasicNodeMaterial({ depthWrite: false, fog: false })
bgMat.colorNode = gemGradientNode({ gem: GEM, radius: 0.4 })
const bgPlane = new Mesh(new PlaneGeometry(40, 25), bgMat)
bgPlane.position.set(0, 0.9, -15)
bgPlane.renderOrder = -100
scene.add(bgPlane)
// Floor edge fade — horizontal sibling quad just above the
// ground plane that paints the SAME gem gradient as the bg
// plane over the floor's hard rectangular silhouette via a
// radial alpha smoothstep — opaque at the corners, transparent
// in the center where reflections show through. Because
// gemGradientNode is screen-space, the floor-edge quad and the
// bg plane sample the same colors at any given screen pixel —
// the floor's edges dissolve seamlessly into the bg plane (no
// color discontinuity).
// Ported from remotion-studio-monorepo's FloorEdgeFade.tsx,
// adapted: their version paints scene.background (flat color);
// ours paints the same screen-space gem gradient as the bg
// plane so the match holds against a non-flat backdrop.
const edgeMat = new MeshBasicNodeMaterial({
transparent: true,
depthWrite: false,
fog: false,
})
edgeMat.colorNode = gemGradientNode({ gem: GEM, radius: 0.4 })
const edgeDist = tslLength(uv().sub(vec2(0.5))).mul(tslFloat(2))
edgeMat.opacityNode = tslSmoothstep(tslFloat(0.35), tslFloat(0.7), edgeDist)
const floorEdge = new Mesh(new PlaneGeometry(50, 50), edgeMat)
floorEdge.rotation.x = -Math.PI / 2
floorEdge.position.y = 0 // just above ground (at -0.01)
floorEdge.renderOrder = 1 // after the floor (default 0)
scene.add(floorEdge)
}}
>
<DevtoolsProvider name="skia" />
<Suspense fallback={null}>
<SkiaDemo />
</Suspense>
</Canvas>
</SkiaErrorBoundary>
)
}
  • SkiaCanvas — off-screen texture rendering and screen-space overlay modes
  • Drawing nodesSkiaRect, SkiaCircle, SkiaLine, SkiaPathNode, SkiaTextNode, SkiaGroup
  • SkiaPaint — custom gradient shaders applied to drawing nodes
  • SkiaPath — programmatic paths with boolean operations (difference, intersect, union, xor)
  • SkiaTypeface / SkiaFontLoader — font loading with useLoader, sized fonts via atSize()
  • attachSkiaTexture — R3F attach helper for compositing Skia output onto 3D mesh materials
  • useFrame phases{ before: 'render' } and { after: 'render' } for render pipeline control

Render Skia shapes to a texture and apply it to a 3D mesh:

import { Skia, SkiaPaint } from '@three-flatland/skia'
import { SkiaCanvas, SkiaRect, SkiaGroup } from '@three-flatland/skia/three'
const skia = await Skia.init(renderer)
const canvas = new SkiaCanvas({ renderer, width: 1024, height: 880 })
canvas.add(new SkiaRect()) // add shapes
// In animation loop:
canvas.render()
material.map = canvas.texture

Draw directly on top of the 3D scene (HUD, debug text):

const overlay = new SkiaCanvas({
renderer,
width: window.innerWidth * dpr,
height: window.innerHeight * dpr,
overlay: true,
})
// Render AFTER the 3D scene:
renderer.render(scene, camera)
overlay.render()

For effects beyond flat fill/stroke, create a SkiaPaint and assign it to a node's paint prop:

import { SkiaPaint } from '@three-flatland/skia'
const paint = new SkiaPaint(skia).setFill()
paint.setLinearGradient(0, 0, 200, 0,
[0xFFFF0000, 0xFF0000FF], [0, 1])
rect.paint = paint