Halbton-Raster — Zeitungs-Punkt-Muster
Rendert die Quelle durch ein rotiertes Punktraster im Stil alter Zeitungsdrucke oder Comic-Hefte. Jede Zelle sampelt die mittlere Helligkeit ihres Blocks aus einer skalierten Kopie der Quelle und zeichnet einen Punkt, dessen Größe mit `1 - luminance` skaliert: dunkle Bereiche werden zu großen, sich überlappenden Punkten, Lichter verschwinden komplett. `ANGLE` dreht das gesamte Punktraster — der klassische Schwarzdruckwinkel ist 45°. `CELL_SIZE` bestimmt die Rasterweite: kleine Zellen wirken wie feiner Offsetdruck, große Zellen kippen in 60er-Pop-Art / Lichtenstein-Optik. `DOT_SHAPE` ersetzt den Kreis durch Quadrat oder Raute — Quadrat liest sich wie ein digitaler LED-Display, die Raute ist der klassische Roy-Lichtenstein-„Ben-Day-Dots"-Look. `BOOST` ist eine Kontrast-Vor-Anpassung damit feine Gradienten noch sichtbar bleiben. Pure-JS-Implementierung auf Basis von PathHandle + CanvasHandle. Eine native Rust-Op `img.halftone(...)` (paralleles per-Zell- Sampling + dichtere Packung) liegt als [[I-20260516-03]] im Backlog, wenn dieser Effekt mal bei 4K+ laufen muss.
// Halftone screen — render the source through a rotated dot grid like
// an old newspaper print. Each cell samples a per-block luminance from
// a downsampled copy of the source, then draws a dot whose size scales
// with `1 - luminance` (dark areas → big dots, highlights → no dot).
// `ANGLE` rotates the entire dot grid for the classic 45° black-plate
// look. Pure JS — no native helper.
//!INPUT: SRC
//!OUTPUT: OUT
//!PARAM: CELL_SIZE:integer=10,min=2,max=64
//!PARAM: ANGLE:number=45,min=0,max=90,step=1
//!PARAM: DOT_SHAPE:enum(circle|square|diamond)=circle
//!PARAM: BOOST:number=1.0,min=0.3,max=3.0,step=0.1
//!PARAM: BG_COLOR:string=#ffffff
//!PARAM: INK_COLOR:string=#1a1a1a
const img = Engine.loadImage(SRC);
const W = img.width, H = img.height;
// Per-cell luminance via downsample: each pixel in `grid` averages a
// CELL_SIZE × CELL_SIZE block of the source. ~250× fewer FFI hops
// than calling getPixel on every grid cell of the original.
const gridW = Math.max(1, Math.ceil(W / CELL_SIZE));
const gridH = Math.max(1, Math.ceil(H / CELL_SIZE));
const grid = img.clone().resize(gridW, gridH);
// Build the output via a CanvasHandle: a single background rect-path
// for the paper, then one big path with every dot. tiny-skia
// rasterises the whole path in one pass, parallel-friendly.
const cv = Engine.createCanvas(W, H);
const bgPath = Engine.createPath().rect(0, 0, W, H);
cv.fill(BG_COLOR).drawPath(bgPath);
bgPath.free();
const dots = Engine.createPath();
const maxR = CELL_SIZE * 0.55;
const angle = ANGLE * Math.PI / 180;
const cosA = Math.cos(angle), sinA = Math.sin(angle);
// Iterate over grid cells in grid-local coords (origin = image centre,
// axes rotated by ANGLE). Range covers the worst-case diagonal so the
// rotated grid still tiles the whole image rect at any angle.
const halfDiag = Math.sqrt(W * W + H * H) / 2;
const range = Math.ceil(halfDiag / CELL_SIZE) + 1;
for (let j = -range; j < range; j++) {
for (let i = -range; i < range; i++) {
// Cell centre in grid-local coords
const lx = (i + 0.5) * CELL_SIZE;
const ly = (j + 0.5) * CELL_SIZE;
// Forward-rotate into image coords (rotated grid → axis-aligned img)
const cx = lx * cosA - ly * sinA + W / 2;
const cy = lx * sinA + ly * cosA + H / 2;
if (cx < -maxR || cx >= W + maxR || cy < -maxR || cy >= H + maxR) continue;
// Sample the downsampled image at the dot's image-space position.
// Cheap and visually correct enough for newspaper-style output.
const gx = Math.min(gridW - 1, Math.max(0, Math.floor(cx / CELL_SIZE)));
const gy = Math.min(gridH - 1, Math.max(0, Math.floor(cy / CELL_SIZE)));
const p = grid.getPixel(px(gx, gy));
// Skip cells that are mostly-transparent in the source — there's
// no content to halftone here, and the final applyMask(img) would
// zero them out anyway. Saves path size + render time.
if (p.a < 0.1) continue;
const lum = 0.299 * p.r + 0.587 * p.g + 0.114 * p.b;
// Optional contrast boost: makes shadows/highlights snap harder
// before they get quantised by the dot-size mapping.
const boosted = Math.max(0, Math.min(1, 0.5 + (lum - 0.5) * BOOST));
const r = maxR * (1 - boosted);
if (r < 0.3) continue;
if (DOT_SHAPE === 'circle') {
dots.circle(cx, cy, r);
} else if (DOT_SHAPE === 'square') {
dots.rect(cx - r, cy - r, 2 * r, 2 * r);
} else if (DOT_SHAPE === 'diamond') {
dots.moveTo(cx, cy - r);
dots.lineTo(cx + r, cy);
dots.lineTo(cx, cy + r);
dots.lineTo(cx - r, cy);
dots.close();
}
}
}
cv.fill(INK_COLOR).drawPath(dots);
dots.free();
const out = cv.toImage();
// Inherit the source's alpha so transparent regions in the input stay
// transparent in the output — otherwise the background-rect fill would
// turn even fully-transparent areas into solid BG_COLOR.
out.applyMask(img);
out.save(OUT);
out.free();
cv.free();
grid.free();
img.free();
// © 2026 Michael Lechner · mlc OpticScript · https://mlcgo.eu · Elastic License 2.0