Render mathematically-faithful Turkish marbling (Ebru) and paper-marbling with closed-form coordinate-deformation maps — a drop is a radial outward push, a comb/tine is a decaying shear, all composed as homeomorphisms over colored region boundaries. No fluid solver, runs 60fps on a 2D canvas. Use when building a marbling/Ebru toy or generative-art renderer, implementing drop/comb/wave/swirl deformations, scripting traditional patterns (Battal, Gel-Git, Şal, Bülbül Yuvası), keeping combed edges crisp via adaptive resampling, modeling ox-gall shouldering for crisp ring-in-ring drops, or exporting high-DPI marbled prints. NOT for real fluid / Navier-Stokes simulation, metaball / alpha-blended "smoke" blob effects, raster image-processing filters, or non-marbling generative art.
Installer avec Codex ou Claude Copiez ce prompt, collez-le dans Codex, Claude ou un autre assistant, puis laissez-le vérifier la page du skill et l'installer pour vous.
Une commande directe contourne le prompt de vérification. Examinez la source avant de l'exécuter.
Render mathematically-faithful Turkish marbling (Ebru) and paper-marbling with closed-form coordinate-deformation maps — a drop is a radial outward push, a comb/tine is a decaying shear, all composed as homeomorphisms over colored region boundaries. No fluid solver, runs 60fps on a 2D canvas. Use when building a marbling/Ebru toy or generative-art renderer, implementing drop/comb/wave/swirl deformations, scripting traditional patterns (Battal, Gel-Git, Şal, Bülbül Yuvası), keeping combed edges crisp via adaptive resampling, modeling ox-gall shouldering for crisp ring-in-ring drops, or exporting high-DPI marbled prints. NOT for real fluid / Navier-Stokes simulation, metaball / alpha-blended "smoke" blob effects, raster image-processing filters, or non-marbling generative art.
{"category":"Creative & Visual","tags":["marbling","ebru","canvas","generative-art","closed-form-deformation"],"pairs-with":[{"skill":"physics-rendering-expert","reason":"Frame budget, transform math, and high-DPI canvas plumbing under the deformation algebra"},{"skill":"color-theory-palette-harmony-expert","reason":"Tuning period pigment hexes so they read as ground minerals, not electric CSS colors"}]}
Render real marbling the way Lu & Jaffer proved you can (IEEE CG&A 32(6), 2012):
not by simulating fluid, but by treating the marbling tray as a set of colored
region boundaries and applying each tool as a closed-form homeomorphism of the
plane to every boundary point at once. A finished marble is just a composition
Tn ∘ … ∘ T1 of these maps. Pure arithmetic, O(points) per op, 60fps.
When to use / when NOT
Use to implement drop / tine / comb / wave / swirl ops, to script the four
canonical Ebru patterns, to keep combed lines crisp, to model ox-gall, or to
lift a high-res print off the tray.
Do NOT reach for a fluid solver, reaction-diffusion, metaballs, or
alpha-blended translucent disks. Those produce blurry "smoke goo," not the crisp
veined cells of real Ebru, and they don't compose, replay, or scale to print.
Core mental model
flowchart LR
S["State<br/>ops[] + regions[]"] --> D{Tool}
D -->|drop| P["push ALL prior verts<br/>radially, then add<br/>fresh color circle"]
D -->|tine / wave / swirl| W["warp ALL verts<br/>(closed form)"]
W --> R["adaptive resample<br/>(keep edges crisp)"]
P --> M["live regions"]
R --> M
M -->|painter's algo| C["Canvas2D (60fps)"]
S -->|replay at print res| X["High-DPI PNG export"]
Four invariants that make this faithful instead of generic:
Boundaries, not pixels. Each color region is a closed polyline; tools move
vertices. Smooth maps applied to dense polylines look like smooth dye.
Drops shoulder, they don't blend. A new drop physically pushes earlier ink
aside (the radial map), so fills stay opaque and you get crisp ring-in-ring.
Everything composes. State is an ordered op list; you can replay, undo,
serialize, and re-render at any resolution from the same script.
Resample under combing. Comb maps stretch segments a lot; subdivide or edges
go faceted.
The deformation algebra (copy-paste)
All functions take/return {x, y} and are pure. regions = [{ pts:[{x,y}…], color }],
oldest first (paint order). Coordinates are in logical tray pixels.
1. Ink drop — radial outward push (area-preserving)
P' = C + (P − C) · sqrt( 1 + r² / ‖P − C‖² )
This is why nested drops form perfect concentric bullseyes: it maps circles to
circles and is area-preserving away from . The only singularity is a vertex
exactly at — guard with epsilon.
C
C
// Push one point away from drop center C with disk radius r.functiondropPush(P, C, r) {
const dx = P.x - C.x, dy = P.y - C.y;
const d2 = dx * dx + dy * dy;
const s = Math.sqrt(1 + (r * r) / Math.max(d2, 1e-6)); // epsilon guards Creturn { x: C.x + dx * s, y: C.y + dy * s };
}
// A full drop op: shoulder every prior vertex aside, then float a new color disk.functionapplyDrop(regions, C, r, color, segs = 128) {
for (const reg of regions) reg.pts = reg.pts.map((P) =>dropPush(P, C, r));
const ring = [];
for (let i = 0; i < segs; i++) {
const a = (i / segs) * 2 * Math.PI;
ring.push({ x: C.x + r * Math.cos(a), y: C.y + r * Math.sin(a) });
}
regions.push({ pts: ring, color }); // newest on top (painter order)
}
2. Tine / comb — drag parallel to a line, decaying with perpendicular distance
general: P' = P + t̂ · z · u^|d|, where d = (P − L) · n̂
vertical tine at xL: (x, y + z·u^|x−xL|)
horizontal tine at yL: (x + z·u^|y−yL|, y)
t̂ = unit drag direction, n̂ = its normal, z = max displacement (sign = drag
direction; flip it for the tidal reversal), u ∈ (0,1) = sharpness/viscosity
(u^|d| decays away from the line). Lower u = tighter, crisper comb lines;
u→1 = broad smear.
// t = unit tangent {x,y}; displacement is symmetric in |d| but always along +t.functiontineShear(P, L, t, u, z) {
const nx = -t.y, ny = t.x; // unit normalconst d = (P.x - L.x) * nx + (P.y - L.y) * ny; // signed perp distanceconst amt = z * Math.pow(u, Math.abs(d));
return { x: P.x + t.x * amt, y: P.y + t.y * amt };
}
// A "rake" = N parallel tines: call tineShear once per L = base + i*spacing*n̂.
3. Serpentine wave — horizontal shift set by y (basis for Şal S-curves)
Wh(x, y) = ( x − A·sin(k·y + φ), y )
A = amplitude, k = spatial frequency in radians per pixel (wavelength
λ = 2π/k), φ = phase (offset successive passes to braid). CSAIL's reference
values are A≈20, λ≈140px. Chain Wh then Wv for a woven "peacock" texture;
run the wave opposite the last tidal direction for an authentic Şal.
4. Swirl / vortex — decaying rotation about a center (Bülbül Yuvası nests)
α(d) = θ₀ · e^(−d/ρ), then rotate (P − C) by α(d) about C.
Same decay idea as the tine, but the displacement is angular instead of linear.
θ₀ = peak swirl at center, ρ = falloff radius (equivalently ρ = −1/ln u).
Strong near the center, fading out → a tight spiral = a nightingale's nest.
functionswirl(P, C, theta0, rho) {
const dx = P.x - C.x, dy = P.y - C.y;
const a = theta0 * Math.exp(-Math.hypot(dx, dy) / rho);
const ca = Math.cos(a), sa = Math.sin(a);
return { x: C.x + dx * ca - dy * sa, y: C.y + dx * sa + dy * ca };
}
5. Adaptive resampling — the crispness keeper
Comb/wave/swirl maps stretch boundaries; without resampling, long segments go
faceted (polygonal "lightning bolt" edges). After every warp pass, subdivide
any segment longer than maxSeg. Cap total points with optional decimation so the
frame budget stays bounded.
functionresample(pts, maxSeg) { // closed polylineconst out = [], n = pts.length;
for (let i = 0; i < n; i++) {
const a = pts[i], b = pts[(i + 1) % n];
out.push(a);
const len = Math.hypot(b.x - a.x, b.y - a.y);
if (len > maxSeg) {
const k = Math.ceil(len / maxSeg);
for (let j = 1; j < k; j++) {
const t = j / k;
out.push({ x: a.x + (b.x - a.x) * t, y: a.y + (b.y - a.y) * t });
}
}
}
return out;
}
6. Ox-gall shouldering — radius schedule
Ox-gall (öd) is the surfactant that makes a drop spread into a disk and push
earlier colors aside — and each successive drop spreads slightly less (earlier
films resist it). You do not model this with extra blending; the §1 push is the
shouldering. Just decay the radius of later drops and expose gall as a slider
that scales the base radius.
functiongallRadius(baseR, gall, priorDrops) { // gall slider ∈ [0.4, 1.6]return baseR * gall * Math.pow(0.985, priorDrops);
}
Composition: the op list is the marble
State is { regions: [], ops: [] }. Mutate regions live for 60fps; use full
replay() only for undo (pop op, replay) and export (replay at print resolution).
Serializing ops (+ the PRNG seed) makes any marble reproducible and shareable.
functionwarpFor(op) { // op → vertex mapif (op.t === 'tine') return(P) =>tineShear(P, op.L, op.dir, op.u, op.z);
if (op.t === 'wave') return(P) =>waveWarp(P, op.A, op.k, op.phase);
if (op.t === 'swirl') return(P) =>swirl(P, op.C, op.theta0, op.rho);
}
functionreplay(ops, { segs = 128, maxSeg = 3 } = {}) {
const regions = [];
for (const op of ops) {
if (op.t === 'drop') {
applyDrop(regions, op.C, op.r, op.color, segs);
} else {
const f = warpFor(op);
for (const reg of regions) reg.pts = resample(reg.pts.map(f), maxSeg);
}
}
return regions;
}
// Deterministic positions → reproducible marble from a serialized seed.functionmulberry32(seed) {
returnfunction () {
let t = (seed += 0x6d2b79f5);
t = Math.imul(t ^ (t >>> 15), t | 1);
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
Traditional Ebru pattern scripts
Each pattern is just an ordered push onto ops. They build on each other exactly
as a marbler builds on the tray: a Battal ground first, then comb work over it.
// 2.1 BATTAL ("spattered") — the ground. Many interleaved drops, NO comb.functionbattal(ops, rng, W, H, pigments, n, gall) {
const baseR = Math.min(W, H) * 0.06;
for (let i = 0; i < n; i++)
ops.push({ t: 'drop', C: { x: rng() * W, y: rng() * H },
r: gallRadius(baseR, gall, i), color: pigments[i % pigments.length] });
}
// 2.2 GEL-GIT ("tidal") — parallel tine passes marching across x, z flipping sign.functiongelgit(ops, W, H, { spacing = 24, z = 46, u = 0.92 } = {}) {
let sign = 1;
for (let xL = spacing; xL < W; xL += spacing) {
ops.push({ t: 'tine', L: { x: xL, y: 0 }, dir: { x: 0, y: 1 }, u, z: z * sign });
sign = -sign; // ebb and flow
}
}
// 2.3 ŞAL ("shawl") — Gel-Git, THEN S-curves opposite the last tidal direction.functionsal(ops, W, H, opts = {}) {
gelgit(ops, W, H, opts);
const { A = 18, k = (2 * Math.PI) / 70, passes = 6 } = opts; // λ ≈ 70pxfor (let p = 0; p < passes; p++)
ops.push({ t: 'wave', A, k, phase: p * (Math.PI / 3) });
}
// 2.4 BÜLBÜL YUVASI ("nightingale's nest") — small Battal, then a grid of swirls.functionbulbulYuvasi(ops, rng, W, H, pigments, gall,
{ grid = 120, rho = 42, theta0 = 4.2 } = {}) {
battal(ops, rng, W, H, pigments, Math.floor((W * H) / (grid * grid)) * 3, gall);
for (let y = grid / 2; y < H; y += grid)
for (let x = grid / 2; x < W; x += grid) {
const jx = x + (rng() - 0.5) * grid * 0.4; // jitter so nests aren't gridlockedconst jy = y + (rng() - 0.5) * grid * 0.4;
ops.push({ t: 'swirl', C: { x: jx, y: jy }, theta0, rho });
}
}
Rendering & period pigments
Paint painter's-algorithm, oldest region first onto a cream "size-water"
ground; opaque fills + a thin warm-dark stroke read as the shouldered seam (the
vein) where the cream peeks through. Caller sets the device transform; geometry
stays in logical coords.
Period pigments (artist-pigment appearance — not the misleading electric CSS
"indigo / malachite" names). Pull each slightly toward gray; let cream show in veins.
Pigment
Hex
Note
Indigo (Indigofera)
#1B3B6F
muted desaturated navy, never electric blue
Madder red (Rubia)
#B23B3B
earthy brick-rose, slightly orange
Gamboge yellow
#E8A317
warm saffron-amber, not lemon
Malachite green
#4A8B6F
cool-mid mineral green, slightly grayed
Size-water (cream)
#F1E9D2
warm parchment — "the paper"
Vein / outline
#2A2418
warm near-black for shouldered seams
High-DPI export — "lift the paper"
Replay the same op list onto an offscreen canvas at scale×, with a tighter
maxSeg (in device px) and denser circles so edges are crisp at print resolution.
Then toBlob('image/png') and download. This is the whole reason to keep state as
a script: resolution independence is free.
Which deformation? drop → bloom/ground & bullseyes · tine → straight combed
columns (Gel-Git) · wave → S-curve weave (Şal) · swirl → spiral nests (Bülbül
Yuvası). Compose freely; order matters (ground before comb).
Knob
Lower value
Higher value
Sane default
u (viscosity)
tight, crisp comb lines
broad smear
0.90–0.94
z (tine drag)
gentle nudge
violent drag, may self-intersect
30–60 px
maxSeg (resample)
crisp but slower
fast but faceted
3 px live / 2/scale print
drop r base
tiny speckle
few fat blobs
~6% of min(W,H)
gall
drops barely spread
drops dominate, thin veins
1.0
swirl ρ
tiny tight nests
wide lazy swirls
~35–45 px
Live vs replay: mutate regions in place per op for interactivity; only
replay() for undo and export. Point budget: if a live frame drops below
60fps, raise maxSeg or decimate; do not lower drop segs (that breaks bullseyes).
Anti-patterns (Novice → Expert → Timeline)
1. Metaball / fluid-sim "smoke goo."
Novice: reaches for Navier-Stokes, curl-noise, reaction-diffusion, or metaballs
to get "swirly" looks. Result: blurry, blendy, non-deterministic, can't export at
print res, and tanks the frame rate.
Timeline: pre-2012 marbling demos used fluid solvers; Lu & Jaffer (IEEE CG&A
2012) proved drop+tine maps reproduce real marbling exactly and in real time.
2. Alpha-blended translucent disks instead of the radial push.
Novice: draws overlapping globalAlpha circles and hopes colors "mix." Gets
muddy browns and zero ring-in-ring.
Expert: applies the §1 push so earlier ink is physically displaced; fills stay
opaque; the vein is a seam, not a blend. That's the bullseye.
3. Combing coarse polylines (no resampling).
Novice: shears a 64-vertex circle and ships faceted, polygonal "lightning"
edges.
Expert: subdivides after every warp so no segment exceeds maxSeg; smooth dye.
Timeline: the perennial real-time-graphics gotcha — deformation needs vertex
density proportional to curvature it induces.
4. Electric CSS pigments + degrees-vs-radians sin.
Novice: uses #4B0082 "indigo," #0bda51 "malachite," and sin(k*y) with a
k meant for degrees. Result: neon toy with the wrong wavelength.
Expert: muted ground-mineral hexes (table above) and k in radians/px
(λ = 2π/k); cream shows through thin veins → reads handmade, not "smoke goo."
5. Unguarded singularity at a drop/swirl center.
Novice: divides by ‖P−C‖ with no floor → a NaN the instant a vertex lands
on a center, and the whole tray explodes.
Expert:Math.max(d2, 1e-6) epsilon guard on drops; hypot is safe for swirl.
Quality checklist
Three nested drops at the same center render a clean concentric bullseye
(drop map correct + area-preserving).
Combing a Battal yields smooth, non-faceted bands at any zoom (resampling
live).
Flipping tine z sign produces visible tidal back-and-forth (Gel-Git).
Şal wave runs opposite the final tidal direction; nests in Bülbül Yuvası
are jittered, not gridlocked.
Sustains 60fps at display resolution for a typical op count (live
mutation, bounded point budget).
liftPaper at scale ≥ 4 exports a crisp, valid PNG (tighter maxSeg).
No NaNs under stress (drop directly on an existing vertex; overlapping
swirl centers).
Pigments are muted minerals, cream shows in veins — never electric.
Same ops + seed replays identically (serializable, undoable marble).
Worked example: a faithful Şal, end to end
const W = 900, H = 1200, gall = 1.0;
constPIG = ['#1B3B6F', '#B23B3B', '#E8A317', '#4A8B6F']; // indigo, madder, gamboge, malachiteconstTHEME = { cream: '#F1E9D2', vein: '#2A2418' };
const rng = mulberry32(0xEB12); // seed → reproducible marbleconst ops = [];
battal(ops, rng, W, H, PIG, 70, gall); // 1. stone-like ground; later drops shouldered smallergelgit(ops, W, H, { spacing: 26, z: 50, u: 0.92 }); // 2. tidal combed columns (alternating z)// sal() would add the waves; inline here to show the opposite-direction weave:const k = (2 * Math.PI) / 70;
for (let p = 0; p < 6; p++) ops.push({ t: 'wave', A: 18, k, phase: p * Math.PI / 3 }); // 3. S-curvesconst ctx = canvas.getContext('2d'); // canvas sized W×H (× dpr in real use)render(ctx, replay(ops, { segs: 160, maxSeg: 3 }), { ...THEME, W, H }); // live preview// Lift the paper:liftPaper(ops, W, H, THEME, 4); // 3600×4800 PNG download
What a novice would ship: translucent blobs over a metaball field, electric
blue, faceted comb lines, no export. What this produces: opaque shouldered
cells with thin cream veins, smooth interlocked shawl S-curves over tidal columns,
deterministic and exportable at print resolution — historically faithful Ebru.
NOT-FOR boundaries
Real fluid simulation (Navier-Stokes, SPH, lattice-Boltzmann) → this skill is
explicitly the closed-form alternative; don't import a solver.
Raster image filters (blur, displacement-map a photo) → this deforms vector
boundaries, not pixels.
Figurative single-stroke marbling (flowers, the "Oseen flow" velocity field,
Jaffer's stroke.pdf) → out of scope; this covers the four geometric patterns.
Non-marbling generative art → use a general creative-coding skill.