Halftone screen — newspaper-style dot pattern
Render the source through a rotated dot grid in the style of an old newspaper or comic-book print. Each cell samples the average luminance of its block from a downsampled copy of the source, then draws a dot whose size scales with `1 - luminance`: dark areas grow into big overlapping dots, highlights drop to nothing. `ANGLE` rotates the entire dot grid — the classic black-plate angle is 45°. `CELL_SIZE` sets the screen frequency: small cells feel like fine offset print, larger cells push into 60s pop-art / Lichtenstein territory. `DOT_SHAPE` swaps the circle for a square or diamond — square halftones read as a digital LED display, the diamond is the classic Roy Lichtenstein "Ben-Day dots" look. The `BOOST` knob is a pre-quantisation contrast adjustment so faint gradients still produce variation in dot size. Pure-JS implementation built on the engine's PathHandle + CanvasHandle. A native Rust `img.halftone(...)` op (parallel per-cell sampling + tighter packing) is queued as [[I-20260516-03]] for when this effect needs to run at 4K-plus.
// 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