Make Sprite With Claude

Teknik Rendering

Palet terbatas dan ordered dithering

Shading bola/benda dengan 5–6 warna dan matriks Bayer 4×4 sebagai ganti gradient, supaya tetap terlihat pixel art.

Versi mentah untuk AI: md/dither-palette.md

Prinsip

Gradient halus langsung merusak gaya piksel. Gunakan ramp 5–6 warna (gelap → terang) dan pilih indeks per piksel dari nilai cahaya k (0..1):

v   = k * (n - 1)
idx = floor(v + bayer(x, y))     // bayer(x,y) di (0,1), tak bias

bayer(x,y) memakai matriks 4×4 yang bergantung pada posisi piksel, jadi pola dither stabil (tidak berkedip saat objek diam). Tanpa dither (round(v)) kita dapat pita warna keras; dengan dither, transisi terlihat bertekstur.

Demo interaktif butuh JavaScript. Kode lengkap ada di bawah.

Resep ramp

Peran Aturan
Warna 0 sangat gelap, hue digeser ke ungu/biru (bukan hitam murni)
Warna 1–3 tubuh utama; kecerahan naik, hue bergeser ke kuning
Warna 4–5 highlight; terakhir hampir putih

Tekstur permukaan: tambahkan noise blok (hash integer, bukan noise halus) ke k sebelum dither. Lihat PX.hash.

Aturan

  • Maksimal 6 warna per material; seluruh adegan idealnya ≤ 32 warna.
  • Pola Bayer ditentukan koordinat layar/atlas, bukan koordinat lokal objek, bila ingin pola tetap saat objek bergerak; pakai koordinat lokal bila ingin pola "menempel" pada objek.
  • Dither hanya di area luas (bola, tanah). Pada garis 1–2 piksel, pakai warna solid.

Kode

Kode: src/code/32-demo-dither.js
// Shading bola dengan ramp palet terbatas + ordered dithering Bayer 4×4. Gerakkan mouse = arah cahaya.
PX.demos.dither = (host) => {
  const N = 48, cv = PX.canvas(host, N, N, 288, false, 'Bola 6 warna'), c = cv.getContext('2d'), img = c.createImageData(N, N), p = PX.pointer(cv);
  const RAMPS = {
    oranye: ['#2a1208', '#7a2e0e', '#c4561a', '#ee7a1c', '#ffb25a', '#fff1c9'],
    biru: ['#241a66', '#2f3cb4', '#4f7bff', '#8fcbff', '#dff2ff', '#ffffff'],
    hijau: ['#0d2a1c', '#1c5a38', '#2f9355', '#5fd17f', '#b6f2c4', '#f0fff3'],
  };
  let ramp = RAMPS.oranye.map(PX.hex), dither = true;
  const ui = PX.ui(host);
  ui.select('Ramp', Object.keys(RAMPS), 'oranye', (v) => (ramp = RAMPS[v].map(PX.hex)));
  ui.check('Dither Bayer', true, (v) => (dither = v));
  PX.loop(host, (dt, t) => {
    let lx = Math.cos(t * 0.8) * 0.7, ly = -0.55;
    if (p.in) { lx = (p.x / N - 0.5) * 2; ly = (p.y / N - 0.5) * 2; }
    const lz = 0.75, ln = Math.hypot(lx, ly, lz); lx /= ln; ly /= ln; const Lz = lz / ln;
    const R = N / 2 - 1, d = img.data, n = ramp.length;
    for (let y = 0; y < N; y++) for (let x = 0; x < N; x++) {
      const i = (y * N + x) * 4, nx = (x + 0.5 - N / 2) / R, ny = (y + 0.5 - N / 2) / R, r2 = nx * nx + ny * ny;
      if (r2 > 1) { d[i + 3] = 0; continue; }
      const nz = Math.sqrt(1 - r2), l = Math.max(0, nx * lx + ny * ly + nz * Lz);
      let k = 0.1 + 0.9 * l + (PX.hash(Math.floor(x + t * 4) >> 1, y >> 1, 3) - 0.5) * 0.14;   // tekstur blok kasar
      k = PX.clamp(k, 0, 0.999) * (n - 1);
      const idx = dither ? Math.floor(k + PX.bayer(x, y)) : Math.round(k);                       // inti ordered dithering
      const col = ramp[Math.min(n - 1, idx)];
      d[i] = col[0]; d[i + 1] = col[1]; d[i + 2] = col[2]; d[i + 3] = 255;
    }
    c.putImageData(img, 0, 0);
  });
};

Helper (Bayer & hash) ada di inti:

Kode: src/code/00-core.js
// 00-core.js — helper dasar: RNG, warna, Bayer 4x4, canvas pixelated, loop, kontrol UI
const PX = (window.PX = { demos: {} });

PX.rng = (seed) => () => {            // mulberry32: deterministik per seed
  seed = (seed + 0x6d2b79f5) | 0;
  let t = Math.imul(seed ^ (seed >>> 15), 1 | seed);
  t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
  return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
PX.hex = (h) => [1, 3, 5].map((i) => parseInt(h.slice(i, i + 2), 16));
PX.rgb = (c) => `rgb(${c[0]},${c[1]},${c[2]})`;
PX.clamp = (v, a, b) => Math.max(a, Math.min(b, v));
PX.BAYER = [0, 8, 2, 10, 12, 4, 14, 6, 3, 11, 1, 9, 15, 7, 13, 5];
PX.bayer = (x, y) => (PX.BAYER[((y & 3) << 2) | (x & 3)] + 0.5) / 16; // ambang 0..1 per piksel
PX.hash = (x, y, s = 0) => {          // hash integer -> 0..1 (noise blok, tanpa interpolasi)
  let h = Math.imul(x, 374761393) ^ Math.imul(y, 668265263) ^ Math.imul(s, 1274126177);
  h = Math.imul(h ^ (h >>> 13), 1103515245);
  return ((h ^ (h >>> 16)) >>> 0) / 4294967296;
};

// Canvas resolusi rendah, ditampilkan membesar tanpa blur (image-rendering: pixelated)
PX.canvas = (host, w, h, cssW, smooth = false, caption = '') => {
  const fig = document.createElement('figure');
  const cv = document.createElement('canvas');
  cv.width = w; cv.height = h;
  cv.style.width = cssW + 'px'; cv.style.maxWidth = '100%'; cv.style.height = 'auto';
  cv.style.imageRendering = smooth ? 'auto' : 'pixelated';
  fig.appendChild(cv);
  if (caption) { const f = document.createElement('figcaption'); f.textContent = caption; fig.appendChild(f); }
  host.appendChild(fig);
  return cv;
};

// Loop animasi: berhenti otomatis saat demo di luar layar
PX.loop = (host, fn) => {
  let on = true, last = performance.now(), t = 0;
  new IntersectionObserver((e) => (on = e[0].isIntersecting)).observe(host);
  const f = (now) => {
    const dt = PX.clamp((now - last) / 1000, 0, 0.05); last = now;
    if (on) { t += dt; fn(dt, t); }
    requestAnimationFrame(f);
  };
  requestAnimationFrame(f);
};

// Pointer dalam koordinat piksel logis canvas
PX.pointer = (cv) => {
  const p = { x: 0, y: 0, in: false, down: false, clicks: [] };
  const set = (e) => {
    const r = cv.getBoundingClientRect();
    p.x = Math.floor(((e.clientX - r.left) / r.width) * cv.width);
    p.y = Math.floor(((e.clientY - r.top) / r.height) * cv.height);
  };
  cv.addEventListener('pointermove', (e) => { set(e); p.in = true; });
  cv.addEventListener('pointerleave', () => { p.in = false; p.down = false; });
  cv.addEventListener('pointerdown', (e) => { set(e); p.in = p.down = true; p.clicks.push({ x: p.x, y: p.y }); });
  cv.addEventListener('pointerup', () => (p.down = false));
  cv.style.touchAction = 'none'; cv.style.cursor = 'crosshair';
  return p;
};

// Kontrol minimal
PX.ui = (host) => {
  const bar = document.createElement('div'); bar.className = 'ctl'; host.prepend(bar);
  const wrap = (label, el) => { const l = document.createElement('label'); l.append(label + ' ', el); bar.appendChild(l); return el; };
  return {
    select(label, opts, val, fn) {
      const s = document.createElement('select');
      opts.forEach((o) => s.add(new Option(o.label ?? o, o.value ?? o)));
      s.value = val; s.onchange = () => fn(s.value); wrap(label, s); return s;
    },
    range(label, min, max, step, val, fn) {
      const r = document.createElement('input'); r.type = 'range'; r.min = min; r.max = max; r.step = step; r.value = val;
      const fill = () => r.style.setProperty('--p', ((r.value - min) / (max - min)) * 100 + '%');
      r.oninput = () => { fill(); fn(+r.value); }; fill(); wrap(label, r); return r;
    },
    button(label, fn) { const b = document.createElement('button'); b.type = 'button'; b.textContent = label; b.onclick = fn; bar.appendChild(b); return b; },
    check(label, val, fn) {
      const c = document.createElement('input'); c.type = 'checkbox'; c.checked = val;
      c.onchange = () => fn(c.checked); wrap(label, c); return c;
    },
  };
};

// Garis piksel integer (DDA) — tanpa anti-alias
PX.line = (c, x0, y0, x1, y1, col) => {
  const n = Math.max(1, Math.max(Math.abs(x1 - x0), Math.abs(y1 - y0)));
  c.fillStyle = col;
  for (let i = 0; i <= n; i++) c.fillRect(Math.round(x0 + ((x1 - x0) * i) / n), Math.round(y0 + ((y1 - y0) * i) / n), 1, 1);
};