Something went wrong. Try again.
Web frontend and supporting services for lance.blue
Something went wrong. Try again.
30 kB · 883 lines
TypeScript
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884// Procedural camo patterns.//// Every generator is a pure function of (palette, params). No animation, no// time input: a camo is one static image, and the same parameters always// produce the same pixels.//// The governing constraint is size. MegaMek samples the camo about the centre// of an 84x72 sprite at one texel per pixel, so a pattern gets roughly 6000// pixels to make its point, and most of those are covered by the unit's own// shading. Patterns here therefore favour few colours, large features and hard// edges. Anything relying on fine detail or subtle gradients turns to mud at// that size, which is why several generators quantise deliberately.
import type { RGB } from "./palette";import { rng, fbm, ridged, noise2, warp, worley, scatter, clamp01,} from "./rng";import type { Point } from "./rng";
export type PatternParams = { seed: number; /** Feature size. 0 is a few huge shapes, 1 is many small ones. */ scale: number; /** Edge hardness. 0 blends between colours, 1 snaps to flat bands. */ contrast: number; /** Roughness or complexity; what it means is per pattern. */ detail: number; /** Rotation of the whole pattern, 0..1 of a full turn. */ angle: number;};
export type Generator = { id: string; name: string; hint: string; /** Optional glyph shown beside the name in the picker. */ emoji?: string; render(img: ImageData, palette: RGB[], p: PatternParams): void;};
// --- helpers ---------------------------------------------------------------
const lerp = (a: number, b: number, t: number) => a + (b - a) * t;
function put(d: Uint8ClampedArray, i: number, c: RGB) { d[i] = c[0]; d[i + 1] = c[1]; d[i + 2] = c[2]; d[i + 3] = 255;}
/** Rotate a pixel coordinate about the image centre. */function rot( p: PatternParams, x: number, y: number, w: number, h: number,): [number, number] { const a = p.angle * Math.PI * 2; if (a === 0) return [x, y]; const cx = x - w / 2; const cy = y - h / 2; const s = Math.sin(a); const c = Math.cos(a); return [c * cx - s * cy + w / 2, s * cx + c * cy + h / 2];}
/** Snap a 0..1 value to one flat palette colour. */function band(v: number, palette: RGB[], contrast: number): RGB { const t = clamp01((v - 0.5) * (0.7 + contrast * 3.5) + 0.5); return palette[Math.min(palette.length - 1, Math.floor(t * palette.length))]!;}
/** Blend a 0..1 value along the palette. Contrast hardens the transitions. */function ramp(v: number, palette: RGB[], contrast: number): RGB { if (palette.length === 1) return palette[0]!; const t = clamp01((v - 0.5) * (0.7 + contrast * 3.5) + 0.5) * (palette.length - 1); const i = Math.min(palette.length - 2, Math.floor(t)); const f = t - i; const a = palette[i]!; const b = palette[i + 1]!; return [ Math.round(lerp(a[0]!, b[0]!, f)), Math.round(lerp(a[1]!, b[1]!, f)), Math.round(lerp(a[2]!, b[2]!, f)), ];}
/** Feature frequency in cycles across the image. */const freq = (p: PatternParams, lo: number, hi: number) => lerp(lo, hi, p.scale);
/** * The camo size the generators above are tuned against, per the note at the * top of this file. Anything counted per image rather than per pixel - a * simulation lattice, a number of seed points - has to be taken relative to * this, because the picker draws its thumbnails at half size and a count that * suits 84x72 is four times too dense at 42x36. */const REF_W = 84;const REF_H = 72;
// --- generators ------------------------------------------------------------
const dpm: Generator = { id: "dpm", name: "Disruptive", hint: "Soft-edged blobs. The default military pattern, and the safest at small sizes.", render(img, palette, p) { const { width: w, height: h, data } = img; const f = freq(p, 1.5, 7) / w; const oct = 2 + Math.round(p.detail * 3); for (let y = 0; y < h; y++) { for (let x = 0; x < w; x++) { const [rx, ry] = rot(p, x, y, w, h); const v = fbm(rx * f * w * 0.02, ry * f * w * 0.02, p.seed, oct); put(data, (y * w + x) * 4, band(v, palette, p.contrast)); } } },};
const digital: Generator = { id: "digital", name: "Digital", hint: "The same shapes snapped to a coarse grid. Built for few pixels.", render(img, palette, p) { const { width: w, height: h, data } = img; const cell = Math.max(2, Math.round(lerp(10, 3, p.scale))); const f = freq(p, 1.5, 6) / w; const oct = 2 + Math.round(p.detail * 2); for (let y = 0; y < h; y++) { for (let x = 0; x < w; x++) { const gx = Math.floor(x / cell) * cell + cell / 2; const gy = Math.floor(y / cell) * cell + cell / 2; const [rx, ry] = rot(p, gx, gy, w, h); const v = fbm(rx * f * w * 0.02, ry * f * w * 0.02, p.seed, oct); put(data, (y * w + x) * 4, band(v, palette, 1)); } } },};
const tiger: Generator = { id: "tiger", name: "Tiger stripe", hint: "Bands dragged sideways by noise.", render(img, palette, p) { const { width: w, height: h, data } = img; const bands = freq(p, 2, 7); const drag = 0.4 + p.detail * 2.2; for (let y = 0; y < h; y++) { for (let x = 0; x < w; x++) { const [rx, ry] = rot(p, x, y, w, h); const n = fbm(rx * 0.035, ry * 0.02, p.seed, 3); const v = (Math.sin((ry / h + n * drag * 0.5) * Math.PI * 2 * bands) + 1) / 2; put(data, (y * w + x) * 4, band(v, palette, 0.6 + p.contrast * 0.4)); } } },};
const splinter: Generator = { id: "splinter", name: "Splinter", hint: "Hard-edged angular facets. Very legible when small.", render(img, palette, p) { const { width: w, height: h, data } = img; const rand = rng(p.seed); const count = Math.round(lerp(5, 26, p.scale)); const pts: Point[] = scatter(count, w, h, 0, rand); // Anisotropic distance shears the cells into shards rather than pebbles. const shear = 0.4 + p.detail * 1.6; const a = p.angle * Math.PI * 2; const sa = Math.sin(a); const ca = Math.cos(a); const owner = pts.map((_, i) => i % palette.length); for (let y = 0; y < h; y++) { for (let x = 0; x < w; x++) { let best = 0; let bestD = Infinity; for (let i = 0; i < pts.length; i++) { let dx = pts[i]!.x - x; let dy = pts[i]!.y - y; const ux = ca * dx - sa * dy; const uy = sa * dx + ca * dy; const d = ux * ux + (uy * uy) / (shear * shear); if (d < bestD) { bestD = d; best = i; } } put(data, (y * w + x) * 4, palette[owner[best]!]!); } } },};
const turing: Generator = { id: "turing", name: "Reaction diffusion", hint: "Gray-Scott chemistry. Spots, worms or mazes depending on the seed.", render(img, palette, p) { const { width: w, height: h, data } = img; const n = w * h; const rand = rng(p.seed); let a = new Float32Array(n).fill(1); let b = new Float32Array(n); // Gray-Scott only patterns along a narrow diagonal band of the feed/kill // plane: the kill rate has to rise with the feed rate or B either floods // the grid or dies out, and either way the image comes out one flat // colour. Drawing the two independently mostly misses that band, so the // seed picks a position along it instead and jitters across its width. const mix = rand(); const feed = lerp(0.038, 0.058, mix); const kill = lerp(0.059, 0.0635, mix) + (rand() - 0.5) * 0.0016; const dA = 1.0; // The band also narrows as B's diffusion moves away from half A's, so // detail only leans on the ratio; wider and the ends of the band die. const dB = 0.46 + p.detail * 0.08;
// Nucleation sites, per unit area rather than per image: the picker // renders thumbnails at half size, and a count tuned for the full camo // saturates a quarter of the pixels with B and smothers the reaction. const density = lerp(3, 16, p.scale) / (REF_W * REF_H); const seeds = Math.max(2, Math.round(density * n)); for (let s = 0; s < seeds; s++) { const cx = Math.floor(rand() * w); const cy = Math.floor(rand() * h); const r = 2 + Math.floor(rand() * 4); for (let y = -r; y <= r; y++) { for (let x = -r; x <= r; x++) { const px = (cx + x + w) % w; const py = (cy + y + h) % h; b[py * w + px] = 1; } } }
const na = new Float32Array(n); const nb = new Float32Array(n); const steps = 900; for (let step = 0; step < steps; step++) { for (let y = 0; y < h; y++) { const yUp = ((y - 1 + h) % h) * w; const yDn = ((y + 1) % h) * w; const yC = y * w; for (let x = 0; x < w; x++) { const xL = (x - 1 + w) % w; const xR = (x + 1) % w; const i = yC + x; // Five-point Laplacian with the usual diagonal weights. const lapA = a[yC + xL]! * 0.2 + a[yC + xR]! * 0.2 + a[yUp + x]! * 0.2 + a[yDn + x]! * 0.2 + a[yUp + xL]! * 0.05 + a[yUp + xR]! * 0.05 + a[yDn + xL]! * 0.05 + a[yDn + xR]! * 0.05 - a[i]!; const lapB = b[yC + xL]! * 0.2 + b[yC + xR]! * 0.2 + b[yUp + x]! * 0.2 + b[yDn + x]! * 0.2 + b[yUp + xL]! * 0.05 + b[yUp + xR]! * 0.05 + b[yDn + xL]! * 0.05 + b[yDn + xR]! * 0.05 - b[i]!; const abb = a[i]! * b[i]! * b[i]!; na[i] = clamp01(a[i]! + dA * lapA - abb + feed * (1 - a[i]!)); nb[i] = clamp01(b[i]! + dB * lapB + abb - (kill + feed) * b[i]!); } } a.set(na); b.set(nb); }
for (let i = 0; i < n; i++) { put(data, i * 4, band(clamp01(b[i]! * 3.2), palette, p.contrast)); } },};
const life: Generator = { id: "life", name: "Cellular caves", hint: "Random noise smoothed by a majority rule until it clumps.", render(img, palette, p) { const { width: w, height: h, data } = img; const rand = rng(p.seed); const density = 0.42 + p.detail * 0.14; let cells = new Uint8Array(w * h); for (let i = 0; i < cells.length; i++) cells[i] = rand() < density ? 1 : 0;
const passes = 3 + Math.round((1 - p.scale) * 4); const next = new Uint8Array(w * h); for (let pass = 0; pass < passes; pass++) { for (let y = 0; y < h; y++) { for (let x = 0; x < w; x++) { let live = 0; for (let dy = -1; dy <= 1; dy++) { for (let dx = -1; dx <= 1; dx++) { if (dx === 0 && dy === 0) continue; live += cells[((y + dy + h) % h) * w + ((x + dx + w) % w)]!; } } const i = y * w + x; next[i] = live > 4 ? 1 : live < 4 ? 0 : cells[i]!; } } cells.set(next); }
// A second, coarser field decides which of the remaining colours fills the // open areas, so the result uses the whole palette rather than two tones. const f = 0.02 + p.scale * 0.04; for (let y = 0; y < h; y++) { for (let x = 0; x < w; x++) { const i = y * w + x; const tint = fbm(x * f, y * f, p.seed ^ 0x5bf03635, 2); const half = Math.max(1, Math.floor(palette.length / 2)); const idx = cells[i]! ? Math.min( palette.length - 1, half + Math.floor(tint * (palette.length - half)), ) : Math.floor(tint * half); put(data, i * 4, palette[Math.min(palette.length - 1, idx)]!); } } },};
const cyclic: Generator = { id: "cyclic", name: "Cyclic automaton", hint: "Cells eat their successor. Settles into spirals and shells.", render(img, palette, p) { const { width: w, height: h, data } = img; const rand = rng(p.seed);
// Simulate on a coarse lattice, then blow it up. At one cell per pixel the // spirals are a pixel wide and read as static; the whole point of this // pattern is the shape it settles into, so it needs room to settle. // // The cell scales with the image so the lattice keeps its size, not its // resolution. Held fixed, a half-size thumbnail simulates a quarter of the // cells, which is where the state cap below starts to bite. const cell = Math.max(2, Math.round(lerp(7, 3, p.scale) * (w / REF_W))); const cw = Math.ceil(w / cell); const ch = Math.ceil(h / cell);
// A cycle only sustains itself if the lattice is big enough to hold every // state at once, and the number it can hold grows with the log of its area: // a random start on a small lattice tends to miss a link in the chain, and // once the cycle is broken the automaton freezes and then homogenises to a // single flat colour. Measured against the lattice actually being drawn, // not the full-size one, so a thumbnail settles rather than collapsing. const states = Math.min( Math.max(3, Math.round(lerp(4, 10, p.detail))), Math.max(3, Math.floor(Math.log2(cw * ch)) + 1), );
let cells = new Uint8Array(cw * ch); for (let i = 0; i < cells.length; i++) cells[i] = Math.floor(rand() * states);
// Enough steps for a wave to cross the lattice more than once, or it never // organises into spirals at all. const steps = Math.max(cw, ch) * 2; const next = new Uint8Array(cw * ch); for (let s = 0; s < steps; s++) { for (let y = 0; y < ch; y++) { for (let x = 0; x < cw; x++) { const i = y * cw + x; const want = (cells[i]! + 1) % states; let eaten = false; for (let dy = -1; dy <= 1 && !eaten; dy++) { for (let dx = -1; dx <= 1; dx++) { if (dx === 0 && dy === 0) continue; if ( cells[((y + dy + ch) % ch) * cw + ((x + dx + cw) % cw)]! === want ) { eaten = true; break; } } } next[i] = eaten ? want : cells[i]!; } } cells.set(next); }
for (let y = 0; y < h; y++) { for (let x = 0; x < w; x++) { const v = cells[Math.floor(y / cell) * cw + Math.floor(x / cell)]!; put(data, (y * w + x) * 4, palette[v % palette.length]!); } } },};
const gradient: Generator = { id: "gradient", name: "Gradient", hint: "A clean ramp through the palette, linear or radial.", render(img, palette, p) { const { width: w, height: h, data } = img; const radial = p.detail > 0.5; const reps = Math.max(1, Math.round(freq(p, 1, 4))); for (let y = 0; y < h; y++) { for (let x = 0; x < w; x++) { const [rx, ry] = rot(p, x, y, w, h); let v: number; if (radial) { v = clamp01( Math.hypot(rx - w / 2, ry - h / 2) / (Math.min(w, h) / 2), ); } else { v = clamp01(ry / h); } v = (v * reps) % 1; put(data, (y * w + x) * 4, ramp(v, palette, p.contrast * 0.6)); } } },};
const noise: Generator = { id: "noise", name: "Static", hint: "Blocky random speckle. Chunky enough to survive shrinking.", render(img, palette, p) { const { width: w, height: h, data } = img; const cell = Math.max(1, Math.round(lerp(8, 1, p.scale))); const rand = rng(p.seed); const grid: number[] = []; const gw = Math.ceil(w / cell); const gh = Math.ceil(h / cell); for (let i = 0; i < gw * gh; i++) grid.push(rand()); for (let y = 0; y < h; y++) { for (let x = 0; x < w; x++) { const g = grid[Math.floor(y / cell) * gw + Math.floor(x / cell)]!; put(data, (y * w + x) * 4, band(g, palette, p.contrast)); } } },};
const splatter: Generator = { id: "splatter", name: "Splatter", hint: "Thrown paint: irregular blobs with satellite droplets.", render(img, palette, p) { const { width: w, height: h, data } = img; const rand = rng(p.seed); const base = palette[0]!; for (let i = 0; i < w * h; i++) put(data, i * 4, base);
const blobs = Math.round(lerp(4, 22, p.scale)); for (let s = 0; s < blobs; s++) { const color = palette[1 + Math.floor(rand() * Math.max(1, palette.length - 1))]!; const cx = rand() * w; const cy = rand() * h; const r = lerp(w * 0.22, w * 0.05, p.scale) * (0.5 + rand()); const wobble = 0.3 + p.detail * 0.9; const phase = rand() * Math.PI * 2; const lobes = 3 + Math.floor(rand() * 4); for (let y = 0; y < h; y++) { for (let x = 0; x < w; x++) { let dx = x - cx; let dy = y - cy; if (dx > w / 2) dx -= w; if (dx < -w / 2) dx += w; if (dy > h / 2) dy -= h; if (dy < -h / 2) dy += h; const d = Math.hypot(dx, dy); const ang = Math.atan2(dy, dx); const edge = r * (1 + Math.sin(ang * lobes + phase) * wobble * 0.35); if (d < edge) put(data, (y * w + x) * 4, color); } } // Satellites: what makes it read as thrown rather than drawn. const drops = Math.floor(rand() * 7); for (let d = 0; d < drops; d++) { const ang = rand() * Math.PI * 2; const dist = r * (1.1 + rand() * 1.4); const dr = Math.max(1, r * 0.1 * rand()); const dxc = (cx + Math.cos(ang) * dist + w) % w; const dyc = (cy + Math.sin(ang) * dist + h) % h; for (let y = -Math.ceil(dr); y <= dr; y++) { for (let x = -Math.ceil(dr); x <= dr; x++) { if (x * x + y * y > dr * dr) continue; const px = Math.floor((dxc + x + w) % w); const py = Math.floor((dyc + y + h) % h); put(data, (py * w + px) * 4, color); } } } } },};
const marble: Generator = { id: "marble", name: "Marble", hint: "Noise folded through itself. Veined and organic.", render(img, palette, p) { const { width: w, height: h, data } = img; const f = freq(p, 1.5, 6) * 0.02; const strength = 8 + p.detail * 44; for (let y = 0; y < h; y++) { for (let x = 0; x < w; x++) { const [rx, ry] = rot(p, x, y, w, h); const [wx, wy] = warp(rx * f, ry * f, p.seed, strength * f); const v = (Math.sin((wx + fbm(wx, wy, p.seed, 4) * 3) * 3) + 1) / 2; put(data, (y * w + x) * 4, ramp(v, palette, 0.35 + p.contrast * 0.65)); } } },};
const crackle: Generator = { id: "crackle", name: "Crackle", hint: "Dried mud. Flat plates with dark seams.", render(img, palette, p) { const { width: w, height: h, data } = img; const rand = rng(p.seed); const count = Math.round(lerp(8, 44, p.scale)); const pts = scatter(count, w, h, 0, rand); const seam = 0.5 + (1 - p.detail) * 2.5; const dark = palette[0]!; for (let y = 0; y < h; y++) { for (let x = 0; x < w; x++) { const { f1, f2, index } = worley(x, y, pts, w, h); const edge = f2 - f1; const c = edge < seam ? dark : palette[1 + (index % Math.max(1, palette.length - 1))]!; put(data, (y * w + x) * 4, c); } } },};
const dazzle: Generator = { id: "dazzle", name: "Dazzle", hint: "Hard geometric bands at clashing angles. Built to break up an outline.", render(img, palette, p) { const { width: w, height: h, data } = img; const rand = rng(p.seed); const zones = Math.round(lerp(3, 9, p.scale)); const pts = scatter(zones, w, h, 0, rand); const angles = pts.map(() => rand() * Math.PI); const widths = pts.map( () => lerp(w * 0.16, w * 0.05, p.detail) * (0.6 + rand()), ); const offsets = pts.map(() => rand() * 100); for (let y = 0; y < h; y++) { for (let x = 0; x < w; x++) { let best = 0; let bestD = Infinity; for (let i = 0; i < pts.length; i++) { const d = (pts[i]!.x - x) ** 2 + (pts[i]!.y - y) ** 2; if (d < bestD) { bestD = d; best = i; } } const a = angles[best]! + p.angle * Math.PI * 2; const proj = x * Math.cos(a) + y * Math.sin(a) + offsets[best]!; const stripe = Math.floor(proj / widths[best]!); put( data, (y * w + x) * 4, palette[ ((stripe % palette.length) + palette.length) % palette.length ]!, ); } } },};
const hexnet: Generator = { id: "hexnet", name: "Hex net", hint: "A honeycomb lattice. Reads as technical rather than natural.", render(img, palette, p) { const { width: w, height: h, data } = img; const size = lerp(22, 6, p.scale); const lineW = 0.06 + (1 - p.detail) * 0.22; for (let y = 0; y < h; y++) { for (let x = 0; x < w; x++) { const [rx, ry] = rot(p, x, y, w, h); // Axial hex coordinates, then distance to the nearest cell centre. const q = ((Math.sqrt(3) / 3) * rx - ry / 3) / size; const r = ((2 / 3) * ry) / size; const cx = Math.round(q); const cy = Math.round(r); const cz = Math.round(-q - r); const dq = Math.abs(cx - q); const dr = Math.abs(cy - r); const dz = Math.abs(cz + q + r); const edge = Math.max(dq, dr, dz); const cellIdx = Math.abs((cx * 7 + cy * 13) % palette.length); const fill = palette[cellIdx]!; put(data, (y * w + x) * 4, edge > 0.5 - lineW ? palette[0]! : fill); } } },};
const stripes: Generator = { id: "stripes", name: "Bands", hint: "Straight parallel bands of uneven width.", render(img, palette, p) { const { width: w, height: h, data } = img; const rand = rng(p.seed); const count = Math.round(lerp(3, 18, p.scale)); const widths = Array.from( { length: count }, () => 0.4 + rand() * (0.4 + p.detail * 1.6), ); const total = widths.reduce((a, b) => a + b, 0); const bounds: number[] = []; let acc = 0; for (const wd of widths) { acc += wd / total; bounds.push(acc); } for (let y = 0; y < h; y++) { for (let x = 0; x < w; x++) { const [, ry] = rot(p, x, y, w, h); const t = (((ry / h) % 1) + 1) % 1; let idx = bounds.findIndex((b) => t <= b); if (idx < 0) idx = bounds.length - 1; put(data, (y * w + x) * 4, palette[idx % palette.length]!); } } },};
const rust: Generator = { id: "rust", name: "Weathered", hint: "A base coat eaten away in patches. Looks like a machine that has worked.", render(img, palette, p) { const { width: w, height: h, data } = img; const f = freq(p, 2, 8) * 0.02; const bite = 0.35 + p.detail * 0.3;
// Wear first, because the cut is a quantile of it rather than a fixed // level. `ridged` does not promise to use its whole range over a given // patch of noise, and at a large feature size a whole 84x72 frame can sit // on one side of a constant threshold - the base coat is then either // untouched or eaten away entirely, and the swatch is one flat colour. // Against the quantile `bite` means what it reads as: the fraction of the // surface that is worn. Both coats are always present. const wear = new Float32Array(w * h); for (let y = 0; y < h; y++) { for (let x = 0; x < w; x++) { const [rx, ry] = rot(p, x, y, w, h); wear[y * w + x] = ridged( rx * f * 1.7, ry * f * 1.7, p.seed ^ 0x1b873593, 4, ); } } const sorted = Float32Array.from(wear).sort(); const cut = sorted[ Math.min(sorted.length - 1, Math.floor((1 - bite) * sorted.length)) ]!;
for (let y = 0; y < h; y++) { for (let x = 0; x < w; x++) { const i = y * w + x; const [rx, ry] = rot(p, x, y, w, h); const base = fbm(rx * f * 0.5, ry * f * 0.5, p.seed, 3); const grit = noise2(x * 0.5, y * 0.5, p.seed ^ 0xcc9e2d51) * 0.12; const v = wear[i]! > cut ? clamp01(base * 0.45 + grit) : clamp01(0.55 + base * 0.45 + grit); put(data, i * 4, band(v, palette, p.contrast)); } } },};
const circuit: Generator = { id: "circuit", name: "Circuit", hint: "Orthogonal traces and pads. Deliberately artificial.", render(img, palette, p) { const { width: w, height: h, data } = img; const rand = rng(p.seed); const bg = palette[0]!; for (let i = 0; i < w * h; i++) put(data, i * 4, bg);
const grid = Math.max(4, Math.round(lerp(16, 5, p.scale))); const traces = Math.round(lerp(8, 30, p.scale)); const trace = palette[palette.length - 1]!; const pad = palette[Math.max(0, palette.length - 2)]!;
const paint = (x: number, y: number, c: RGB) => { const px = ((Math.round(x) % w) + w) % w; const py = ((Math.round(y) % h) + h) % h; put(data, (py * w + px) * 4, c); };
for (let t = 0; t < traces; t++) { let x = Math.floor(rand() * (w / grid)) * grid; let y = Math.floor(rand() * (h / grid)) * grid; let steps = 2 + Math.floor(rand() * (3 + p.detail * 6)); let horiz = rand() < 0.5; for (let s = 0; s < steps; s++) { const len = grid * (1 + Math.floor(rand() * 3)); const dir = rand() < 0.5 ? 1 : -1; for (let i = 0; i < len; i++) { if (horiz) paint(x + i * dir, y, trace); else paint(x, y + i * dir, trace); // A second pixel of width, or it disappears when shrunk. if (horiz) paint(x + i * dir, y + 1, trace); else paint(x + 1, y + i * dir, trace); } if (horiz) x += len * dir; else y += len * dir; horiz = !horiz; } for (let dy = -1; dy <= 2; dy++) { for (let dx = -1; dx <= 2; dx++) paint(x + dx, y + dy, pad); } } },};
const flow: Generator = { id: "flow", name: "Flow", hint: "Streaks combed along a noise field.", render(img, palette, p) { const { width: w, height: h, data } = img; const f = freq(p, 1.2, 4) * 0.02; const streak = lerp(3, 16, p.detail); for (let y = 0; y < h; y++) { for (let x = 0; x < w; x++) { const [rx, ry] = rot(p, x, y, w, h); // Walk backwards along the field and average what we pass through. let sx = rx; let sy = ry; let sum = 0; const taps = 6; for (let i = 0; i < taps; i++) { const a = fbm(sx * f, sy * f, p.seed, 3) * Math.PI * 4; sx += Math.cos(a) * (streak / taps); sy += Math.sin(a) * (streak / taps); sum += fbm(sx * f * 2.3, sy * f * 2.3, p.seed ^ 0x27d4eb2f, 2); } put(data, (y * w + x) * 4, band(sum / taps, palette, p.contrast)); } } },};
const contour: Generator = { id: "contour", name: "Contour", hint: "Topographic lines through a noise height field.", render(img, palette, p) { const { width: w, height: h, data } = img; const f = freq(p, 1.2, 5) * 0.02; const lines = Math.round(lerp(3, 8, p.detail)); for (let y = 0; y < h; y++) { for (let x = 0; x < w; x++) { const [rx, ry] = rot(p, x, y, w, h); const v = fbm(rx * f, ry * f, p.seed, 4); const t = (v * lines) % 1; const onLine = t < 0.14 + (1 - p.contrast) * 0.12; const fill = palette[ Math.min(palette.length - 1, Math.floor(v * palette.length)) ]!; put(data, (y * w + x) * 4, onLine ? palette[0]! : fill); } } },};
const pride: Generator = { id: "pride", name: "Pride", emoji: "\u{1F3F3}\uFE0F\u200D\u26A7\uFE0F", hint: "Equal bands in palette order. Pair it with a flag palette.", render(img, palette, p) { const { width: w, height: h, data } = img; // Equal bands, in the palette's own order - not sorted, not blended. A // flag is an ordered list of stripes, so the palette IS the design and // anything clever here would only get in the way. The angle control turns // the whole thing, which is how you get a diagonal or vertical flag. const n = palette.length; const stripes = Math.max( 1, Math.round(n * Math.max(1, Math.round(1 + p.scale * 2))), ); for (let y = 0; y < h; y++) { for (let x = 0; x < w; x++) { const [, ry] = rot(p, x, y, w, h); const t = (((ry / h) % 1) + 1) % 1; const idx = Math.min(stripes - 1, Math.floor(t * stripes)) % n; put(data, (y * w + x) * 4, palette[idx]!); } } },};
const solid: Generator = { id: "solid", name: "Solid", hint: "One flat colour. Sometimes the right answer.", render(img, palette, p) { const { width: w, height: h, data } = img; const c = palette[ Math.min(palette.length - 1, Math.floor(p.detail * palette.length)) ]!; for (let i = 0; i < w * h; i++) put(data, i * 4, c); },};
export const GENERATORS: Generator[] = [ dpm, digital, splinter, tiger, turing, life, cyclic, crackle, marble, dazzle, stripes, hexnet, splatter, rust, circuit, flow, contour, gradient, noise, solid, // Last, which at three columns puts it in the bottom-right corner. It is the // one entry here that is not a camouflage technique, so it sits apart rather // than in the middle of the list. pride,];
export const byId = (id: string): Generator => GENERATORS.find((g) => g.id === id) ?? GENERATORS[0]!;