Ein Bild entlang einer Kurve biegen — Bezier oder Acht
Das Porträt wird in einer langen Reihe wiederholt und entlang einer Kurve gebogen — wie eine Bilderkette, die einem Band folgt. Die Kurve ist entweder ein sanfter Bezier-Schwung oder eine Acht — mit CURVE_TYPE umschalten. Einstellbar: NUM_IMAGES (wie viele Kopien auf der Kurve), TILE_H (Bandhöhe), GRID_ROWS / GRID_COLS (Mesh-Auflösung — höher heißt glattere Ränder, aber langsamer), SHOW_CURVE (Hilfslinie ein-/ausblenden). Hintergrund: Hier läuft eine einzige `warpGrid`-Operation ohne Bildzerschneiden. Frühere Varianten haben das Bild in 450 kleine Vierecke geschnitten und einzeln perspektivisch verzerrt — sichtbar an den Säumen zwischen den Streifen. Die Gitter-Variante erzeugt ein durchgehend glattes Band in einem Durchgang.
INPUT
OUTPUT
JavaScript
// Place repeated images along an arbitrary curve using ONE grid warp
// demo_bezier_placer_3.js
//!INPUT: INPUT
//!OUTPUT: OUTPUT
//!PARAM: CURVE_TYPE:enum(bezier|figure8)=figure8
//!PARAM: NUM_IMAGES:integer=30,min=2,max=200
//!PARAM: TILE_H:number=53,min=10,max=400
//!PARAM: GRID_ROWS:integer=80,min=10,max=400
//!PARAM: GRID_COLS:integer=200,min=10,max=800
//!PARAM: SHOW_CURVE:boolean=true
// Earlier versions of this demo cut every source image into 15 vertical
// strips and ran ONE perspective warp per strip × 30 placements = 450
// warps per render — ~edge-aligned but with visible seams between
// strips and per-image hops where the homography hand-off doesn't quite
// match. This version replaces that machinery with a SINGLE
// `warpGrid` call:
//
// 1. Build a "film strip" source: NUM_IMAGES copies of the portrait
// laid horizontally, then scaled to fit a CANVAS_W × TILE_H band
// at the top of a CANVAS_W × CANVAS_H source canvas.
// 2. Pre-compute a fine LUT mapping curve points to (x,y,arcDist) so
// we can do constant-time closest-curve-point lookups per grid
// cell.
// 3. For every grid cell on the output canvas, project the cell's
// position onto the curve. Inside the strip half-width →
// sample-UV maps to the right film-strip pixel. Outside →
// out-of-[0,1] UV → border policy paints transparent.
// 4. One `warpGrid` call produces the bent-along-curve composition.
//
// The result has no strip seams (the mesh is C1-smooth between cells)
// and is cheaper than the per-strip perspective hop. The only
// behaviour difference vs the old script: at a self-intersection of
// the curve (the X in figure-8), the grid samples whichever branch's
// projection wins — there's no Z-sort. For a Bézier with no
// self-crossing this is moot; for figure-8 the result is acceptable.
const CANVAS_W = 1200;
const CANVAS_H = 600;
const BLEND_OVER = BlendMode.Over;
// ── Curves ────────────────────────────────────────────────────────────────
const figure8Curve = {
scale: 300,
centerX: CANVAS_W / 2,
centerY: CANVAS_H / 2,
point: function (t) {
const angle = t * Math.PI * 2;
return {
x: this.centerX + Math.sin(angle) * this.scale,
y: this.centerY + Math.sin(angle * 2) * (this.scale / 2.5),
};
},
tangent: function (t) {
const dt = 0.001;
const p1 = this.point(t);
const p2 = this.point(Math.min(0.999, t + dt));
const dx = p2.x - p1.x, dy = p2.y - p1.y;
const len = Math.sqrt(dx * dx + dy * dy);
return len < 1e-6 ? { x: 1, y: 0 } : { x: dx / len, y: dy / len };
},
normal: function (t) {
const tg = this.tangent(t);
return { x: -tg.y, y: tg.x };
},
};
const bezierCurve = {
p0: { x: 80, y: 300 },
p1: { x: 380, y: 30 },
p2: { x: 820, y: 570 },
p3: { x: 1120, y: 300 },
point: function (t) {
const mt = 1 - t, mt2 = mt * mt, mt3 = mt2 * mt;
const t2 = t * t, t3 = t2 * t;
return {
x: mt3 * this.p0.x + 3 * mt2 * t * this.p1.x + 3 * mt * t2 * this.p2.x + t3 * this.p3.x,
y: mt3 * this.p0.y + 3 * mt2 * t * this.p1.y + 3 * mt * t2 * this.p2.y + t3 * this.p3.y,
};
},
tangent: function (t) {
const mt = 1 - t;
const dx = 3 * mt * mt * (this.p1.x - this.p0.x) + 6 * mt * t * (this.p2.x - this.p1.x) + 3 * t * t * (this.p3.x - this.p2.x);
const dy = 3 * mt * mt * (this.p1.y - this.p0.y) + 6 * mt * t * (this.p2.y - this.p1.y) + 3 * t * t * (this.p3.y - this.p2.y);
const len = Math.sqrt(dx * dx + dy * dy);
return len < 1e-6 ? { x: 1, y: 0 } : { x: dx / len, y: dy / len };
},
normal: function (t) {
const tg = this.tangent(t);
return { x: -tg.y, y: tg.x };
},
};
const curve = CURVE_TYPE === "bezier" ? bezierCurve : figure8Curve;
// ── Curve LUT ──────────────────────────────────────────────────────────────
//
// `curveLUT[i]` carries (x, y, arcDist, t) at uniform t-step. Used to
// answer "what's the arc parameter of the curve point closest to
// (x,y)?" with one O(N) scan per grid cell.
const LUT_STEPS = 1500;
const curveLUT = [];
{
let totalLen = 0;
let prev = curve.point(0);
curveLUT.push({ t: 0, x: prev.x, y: prev.y, dist: 0 });
for (let i = 1; i <= LUT_STEPS; i++) {
const t = i / LUT_STEPS;
const pt = curve.point(t);
const dx = pt.x - prev.x, dy = pt.y - prev.y;
totalLen += Math.sqrt(dx * dx + dy * dy);
curveLUT.push({ t, x: pt.x, y: pt.y, dist: totalLen });
prev = pt;
}
}
const totalLen = curveLUT[curveLUT.length - 1].dist;
// Trim 5% off each end so images don't run off the curve's tips.
const startDist = 0.05 * totalLen;
const endDist = 0.95 * totalLen;
const usedLen = endDist - startDist;
// ── Film-strip source ──────────────────────────────────────────────────────
//
// One canvas-sized image with a horizontal band of NUM_IMAGES copies
// of the portrait at the top. The band's full width matches CANVAS_W
// so its u ∈ [0,1] maps linearly to "fraction along the used curve
// segment".
const portrait = Engine.loadImage(INPUT);
const portraitW = portrait.width;
const portraitH = portrait.height;
// Fit one tile so its aspect matches the portrait at TILE_H pixels tall.
const tileW = Math.max(1, Math.round(TILE_H * (portraitW / portraitH)));
const stripW = NUM_IMAGES * tileW;
// Build the raw film strip first (stripW × TILE_H), then blit it into
// a canvas-sized source canvas after scaling to CANVAS_W wide.
const filmStrip = Engine.createImage(stripW, TILE_H);
for (let i = 0; i < NUM_IMAGES; i++) {
const tile = portrait.clone().resize(tileW, TILE_H);
filmStrip.blendAt(tile, px(i * tileW, 0), 1.0, BLEND_OVER);
tile.free();
}
portrait.free();
// Squash the film strip horizontally so it occupies the full canvas
// width — this makes u_film == arc_progress trivially.
filmStrip.resize(CANVAS_W, TILE_H);
// Vertically-centred film placement — leaves transparent margins
// above and below so warpGrid's bilinear mesh interpolation can
// transition smoothly into "nothing" instead of hitting a hard
// in-strip / off-strip cutoff. Without this, cells whose corners
// straddle the strip boundary produce a scalloped silhouette
// because the binary border policy can't blend within a cell.
const filmTopY = Math.round((CANVAS_H - TILE_H) / 2);
const srcCanvas = Engine.createImage(CANVAS_W, CANVAS_H);
srcCanvas.blendAt(filmStrip, px(0, filmTopY), 1.0, BLEND_OVER);
filmStrip.free();
// Film occupies y ∈ [filmTopY, filmTopY + TILE_H] of a CANVAS_H-tall
// source. perp = 0 → v = 0.5; perp = ±TILE_H/2 → strip edges; beyond
// that we let v drift into the transparent margin and only fall
// back on border policy at v < 0 or v > 1.
const filmHalfH = TILE_H / 2;
// ── Grid construction ─────────────────────────────────────────────────────
//
// Output grid is GRID_ROWS × GRID_COLS uniform cells on the destination
// canvas. For each cell at output position (xOut, yOut) we run a
// closest-point search over `curveLUT` to find which curve sample is
// nearest. That sample's arc distance + signed perpendicular offset
// give the source UV; outside the strip half-width we push (-1, -1)
// to trip `border: WarpBorder.Transparent`.
//
// Cost: GRID_ROWS × GRID_COLS × LUT_STEPS = 80 × 200 × 1500 = 24M
// distance evaluations. ~2-3s in QuickJS — acceptable for a demo, and
// runs ONCE per render regardless of NUM_IMAGES.
const nodes = {};
for (let r = 0; r < GRID_ROWS; r++) {
const row = [];
const yOut = (r / (GRID_ROWS - 1)) * CANVAS_H;
for (let c = 0; c < GRID_COLS; c++) {
const xOut = (c / (GRID_COLS - 1)) * CANVAS_W;
// 1. Closest point on curve.
let bestIdx = 0;
let bestDist2 = Infinity;
for (let i = 0; i < curveLUT.length; i++) {
const dx = curveLUT[i].x - xOut;
const dy = curveLUT[i].y - yOut;
const d2 = dx * dx + dy * dy;
if (d2 < bestDist2) { bestDist2 = d2; bestIdx = i; }
}
const cp = curveLUT[bestIdx];
// 2. Signed perpendicular distance from cell to curve.
const norm = curve.normal(cp.t);
const perp = (xOut - cp.x) * norm.x + (yOut - cp.y) * norm.y;
// 3. Map to source-canvas UV linearly — no hard "in-strip /
// off-strip" branch. The film strip is centred in the source
// canvas; perp = 0 lands at v = 0.5, perp = ±TILE_H/2 at the
// strip edges, perp beyond that drifts into the transparent
// margins. Cells whose closest curve point sits outside
// [startDist, endDist] map to srcU outside [0,1] → border
// policy paints transparent.
const arcU = (cp.dist - startDist) / usedLen; // outside [0,1] for trim region
const srcU = arcU;
const srcV = 0.5 + perp / CANVAS_H;
row.push(srcU, srcV);
}
nodes[String(r)] = row;
}
// ── Single warpGrid call ───────────────────────────────────────────────────
const warped = srcCanvas.warpGrid(
{ rows: GRID_ROWS, cols: GRID_COLS, nodes, border: WarpBorder.Transparent },
Interp.Bilinear,
);
// ── Output composite ──────────────────────────────────────────────────────
const outImg = Engine.createImage(1, 1);
outImg.setPixel(px(0, 0), new Pixel(1, 1, 1, 0));
outImg.resize(CANVAS_W, CANVAS_H);
outImg.blendAt(warped, px(0, 0), 1.0, BLEND_OVER);
warped.free();
if (SHOW_CURVE) {
const ov = Engine.createCanvas(CANVAS_W, CANVAS_H);
let path = `M ${curve.point(0).x},${curve.point(0).y}`;
const steps = 200;
for (let i = 1; i <= steps; i++) {
const t = i / steps;
const pt = curve.point(t);
path += ` L ${pt.x},${pt.y}`;
}
ov.pen(new Pixel(0.10, 0.10, 0.10, 0.85), 3.0);
ov.drawPathStr(path, true);
const ovImg = ov.toImage();
outImg.blendAt(ovImg, px(0, 0), 1.0, BLEND_OVER);
ovImg.free();
ov.free();
}
outImg.save(OUTPUT);
outImg.free();
// © 2026 Michael Lechner · mlc OpticScript · https://mlcgo.eu · Elastic License 2.0