ChatGPT
Download SKILL.md. Add it as a file in a ChatGPT Project or attach it to a chat. Ask ChatGPT to follow the marbling guide. Project files provide reference material; they do not install an automatic skill.
The reusable technique behind Su
Su, made by
Windags for $15, a Curiositech production.
Explore the original skill’s formulas, pattern recipes, diagrams, and code examples.
Use the guide
Download SKILL.md. Add it as a file in a ChatGPT Project or attach it to a chat. Ask ChatGPT to follow the marbling guide. Project files provide reference material; they do not install an automatic skill.
For Claude on the web, download the ZIP. In Customize → Skills, choose Create skill → Upload a skill, upload it, and enable it. For Claude Code, place SKILL.md at .claude/skills/closed-form-marbling-renderer/SKILL.md.
Download SKILL.md. In Gemini chat, attach it to your prompt. Where Gemini Spark Skills are available, choose Skills → Upload to make it reusable. For Gemini CLI, place it at .gemini/skills/closed-form-marbling-renderer/SKILL.md.
Try: “Use the marbling renderer skill to build a touch-friendly Ebru canvas with Battal drops, a Gel-Git comb, undo, and PNG export.”
Complete source
name: closed-form-marbling-renderer
description: >-
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.
allowed-tools: Read,Write,Edit,Bash
argument-hint: '[pattern: battal|gelgit|sal|bulbul] [target: canvas2d|export]'
metadata:
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
category: Creative & Visual
tags:
- marbling
- ebru
- closed-form
- canvas
- deformationRender 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.
Four invariants that make this faithful instead of generic:
All functions take/return {x, y} and are pure. regions = [{ pts:[{x,y}…], color }],
oldest first (paint order). Coordinates are in logical tray pixels.
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 C. The only singularity is a vertex
exactly at C — guard with epsilon.
// Push one point away from drop center C with disk radius r.
function dropPush(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 C
return { 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.
function applyDrop(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)
}general:
P' = P + t̂ · z · u^|d|, whered = (P − L) · n̂vertical tine atxL:(x, y + z·u^|x−xL|)horizontal tine atyL:(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.
function tineShear(P, L, t, u, z) {
const nx = -t.y, ny = t.x; // unit normal
const d = (P.x - L.x) * nx + (P.y - L.y) * ny; // signed perp distance
const 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̂.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.
function waveWarp(P, A, k, phase) {
return { x: P.x - A * Math.sin(k * P.y + phase), y: P.y };
}
α(d) = θ₀ · e^(−d/ρ), then rotate(P − C)byα(d)aboutC.
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.
function swirl(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 };
}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.
function resample(pts, maxSeg) { // closed polyline
const 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;
}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.
function gallRadius(baseR, gall, priorDrops) { // gall slider ∈ [0.4, 1.6]
return baseR * gall * Math.pow(0.985, priorDrops);
}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.
function warpFor(op) { // op → vertex map
if (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);
}
function replay(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.
function mulberry32(seed) {
return function () {
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;
};
}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.
function battal(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.
function gelgit(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.
function sal(ops, W, H, opts = {}) {
gelgit(ops, W, H, opts);
const { A = 18, k = (2 * Math.PI) / 70, passes = 6 } = opts; // λ ≈ 70px
for (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.
function bulbulYuvasi(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 gridlocked
const jy = y + (rng() - 0.5) * grid * 0.4;
ops.push({ t: 'swirl', C: { x: jx, y: jy }, theta0, rho });
}
}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.
function render(ctx, regions, { W, H, cream, vein, lw = 0.8 }) {
ctx.fillStyle = cream; ctx.fillRect(0, 0, W, H); // gum-tragacanth size
for (const reg of regions) {
const p = reg.pts;
ctx.beginPath(); ctx.moveTo(p[0].x, p[0].y);
for (let i = 1; i < p.length; i++) ctx.lineTo(p[i].x, p[i].y);
ctx.closePath();
ctx.fillStyle = reg.color; ctx.fill();
ctx.lineWidth = lw; ctx.strokeStyle = vein;
ctx.globalAlpha = 0.35; ctx.stroke(); ctx.globalAlpha = 1; // thin shouldered vein
}
}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 |
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.
function liftPaper(ops, W, H, theme, scale = 4) {
const off = document.createElement('canvas');
off.width = Math.round(W * scale); off.height = Math.round(H * scale);
const ctx = off.getContext('2d');
ctx.scale(scale, scale); // draw in logical coords
const regions = replay(ops, { segs: 256, maxSeg: 2 / scale }); // ~2 device px edges
render(ctx, regions, { ...theme, W, H, lw: 0.6 / scale });
off.toBlob((b) => {
const a = document.createElement('a');
a.href = URL.createObjectURL(b);
a.download = 'su-ebru.png'; a.click();
URL.revokeObjectURL(a.href);
}, 'image/png');
}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).
1. Metaball / fluid-sim "smoke goo."
2. Alpha-blended translucent disks instead of the radial push.
globalAlpha circles and hopes colors "mix." Gets
muddy browns and zero ring-in-ring.3. Combing coarse polylines (no resampling).
maxSeg; smooth dye.4. Electric CSS pigments + degrees-vs-radians sin.
#4B0082 "indigo," #0bda51 "malachite," and sin(k*y) with a
k meant for degrees. Result: neon toy with the wrong wavelength.k in radians/px
(λ = 2π/k); cream shows through thin veins → reads handmade, not "smoke goo."5. Unguarded singularity at a drop/swirl center.
‖P−C‖ with no floor → a NaN the instant a vertex lands
on a center, and the whole tray explodes.Math.max(d2, 1e-6) epsilon guard on drops; hypot is safe for swirl.z sign produces visible tidal back-and-forth (Gel-Git).liftPaper at scale ≥ 4 exports a crisp, valid PNG (tighter maxSeg).NaNs under stress (drop directly on an existing vertex; overlapping
swirl centers).ops + seed replays identically (serializable, undoable marble).const W = 900, H = 1200, gall = 1.0;
const PIG = ['#1B3B6F', '#B23B3B', '#E8A317', '#4A8B6F']; // indigo, madder, gamboge, malachite
const THEME = { cream: '#F1E9D2', vein: '#2A2418' };
const rng = mulberry32(0xEB12); // seed → reproducible marble
const ops = [];
battal(ops, rng, W, H, PIG, 70, gall); // 1. stone-like ground; later drops shouldered smaller
gelgit(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-curves
const 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 downloadWhat 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.
stroke.pdf) → out of scope; this covers the four geometric patterns.Fv/Fh, Wh, reference A/k); stroke.pdf (Oseen flow, out of scope).