Make Sprite With Claude

Teknik Rendering

Texturing air piksel — tint kedalaman, permukaan, caustic, dan tile top-down

Air terdiri dari beberapa lapis sederhana yang ditumpuk — tint 4 tingkat, garis permukaan, anyaman caustic dari sinus, serpihan mengambang, dan tile top-down 2 lapis yang di-scroll — semuanya di grid integer dengan ramp biru dan dither Bayer.

Versi mentah untuk AI: md/water-texturing.md

Teknik dianalisis dari sistem gelembung Sprite Fusion (bubbles.ts, MIT): bagian tint, permukaan, caustic, dan serpihan. Kode di sini ditulis ulang agar bisa dipakai tanpa bergantung pada game tertentu.

Demo interaktif butuh JavaScript. Kode lengkap ada di bawah.

Ganti tampilan, lalu matikan tiap lapisan (tint, caustic, serpihan) untuk melihat kontribusinya.

Prinsip

Air tidak bisa dibuat dengan satu tekstur. Air terbaca dari lapisan yang saling menimpa, masing-masing murah dan hanya memakai ramp kecil:

  1. Tint kedalaman — warna biru gelap di atas apa pun yang ada di belakang, makin pekat ke bawah.
  2. Garis permukaan — 1 piksel terang, 1 piksel rim, lalu pita bayangan yang menipis.
  3. Caustic — jaring cahaya di dasar dan benda di bawah air.
  4. Serpihan mengambang — titik kecil yang hanyut dan berkedip.
  5. Gelembung / benda / percikan — lihat sistem fluida.

Urutan gambar: latar → benda bawah air → tint → serpihan → gelembung belakang → permukaan → gelembung depan → percikan → caustic (buffer cahaya, paling akhir).

Ramp air

Peran Warna Dipakai untuk
HI #ffffff kilau puncak, highlight gelembung
LIT #c6f3ff garis permukaan, sisi terang
RIM #68bfe6 tepi, rim permukaan
SHADE #2f7fb6 sisi gelap, pita di bawah permukaan
TINT #0b3550 warna overlay kedalaman

Semakin dalam, air makin gelap, makin jenuh, dan bergeser ke biru-ungu; jangan hanya menurunkan kecerahan. Untuk jenis air lain ganti ramp (rawa: hijau zaitun, lava: lihat texturing, racun: hijau neon), logikanya tetap.

1. Tint kedalaman 4 tingkat

Alpha kontinu menghasilkan ratusan warna di atas latar bertekstur. Kuantisasi kedalaman ke 4 tingkat (PX.tone(k, x, y, 4, 0.25)) dengan dither hanya di batas, lalu pakai tiap tingkat sebagai alpha overlay: [0.10, 0.18, 0.26, 0.34]. k = (y − atas)/(dasar − atas). Hasilnya pita kedalaman yang terlihat disengaja, bukan gradient.

2. Garis permukaan

sy    = round(atas + sin(0.11x + 1.6t)·A1 + sin(0.043x − 0.9t)·A2)     // 2 sinus beda frekuensi -> tidak terasa berulang
glint = sin(0.37x − 2.3t) · sin(0.13x + 1.1t) > 0.55                    // perkalian 2 sinus = kilau yang berjalan acak
piksel(x, sy)     = glint ? HI : LIT
piksel(x, sy + 1) = RIM
untuk k = 2..6: jika bayer(x, sy+k) < 0.5 − 0.07k → SHADE              // pita yang menipis ke bawah

sy selalu integer. Bila permukaan berasal dari simulasi (pegas), ganti rumus sinus dengan tinggi kolom; sisanya sama. Setiap 4 kolom tambahkan glow kecil di bawah garis (0.09, naik ke 0.2 saat glint) agar permukaan terasa bercahaya.

3. Caustic: jaring dari dua medan sinus

Caustic adalah garis tempat nilai medan ≈ 0:

u  = 0.16x + 1.9·sin(0.12y + 0.9t)        v  = 0.19y + 1.9·sin(0.1x − 0.7t)
n1 = sin(u) + sin(v)                       // 0 di sepanjang jalur yang berkelok -> jaring
n2 = sin(0.09x + 0.07y + 1.4·sin(0.15y − 0.6t)) + sin(0.11y − 0.05x + 1.4·sin(0.13x + 0.8t))
f  = (max(0, 1 − |n1|·3) + 0.7·max(0, 1 − |n2|·3)) · k(y)
  • 1 − |n|·3 mengubah "dekat nol" menjadi garis setebal sekitar 1/3 satuan.
  • 1.9·sin(...) adalah warp: tanpanya garisnya kisi lurus; dengan warp menjadi jaring organik yang bergerak.
  • Dua medan berbeda arah dijumlahkan, medan kedua 0.7× agar menjadi anyaman sekunder yang lebih redup.
  • k(y): 1 di dasar, 0.18 + 0.32·(1 − kedalaman) di atasnya — cahaya lebih kuat di dasar dan memudar ke permukaan.
  • Tiga tingkat via Bayer: f > 1.05 atau (f > 0.8 dan bayer < (f−0.8)/0.25) → LIT ×0.3; f > 0.45 atau (f > 0.3 dan bayer < (f−0.3)/0.15) → RIM ×0.15; selain itu tidak ada cahaya.
  • Ditambahkan additive ke buffer cahaya (PX.Light.add), bukan menimpa piksel — sehingga warna benda di bawahnya tetap terbaca (glow).

4. Serpihan mengambang

Tanpa state: posisi dihitung dari hash(i) dan waktu. y = atas + (y0 + (2.5 + 1.5·sin(s))·t) mod tinggi, x = x0 + 6·sin(0.3t + s). Gambar bila bayer(x,y) ≤ 0.55 + 0.3·sin(1.3t + s) (berkedip tanpa alpha). Satu dari tiga warna redup #5f8a9a #7fa3a8 #4c6f86; puncak kedipan memakai LIT. Sekitar 90 titik untuk layar 160×90.

5. Air top-down (tile)

Untuk lantai air dilihat dari atas, pakai tile hasil bake bermaterial water: jaring terang = batas sel Worley (1 − edge/0.3, dikuadratkan), plus fbm halus. Animasi tanpa menghitung ulang noise:

  • Gambar dua salinan tile (seed berbeda) yang digeser berlawanan arah; offset integer per frame (floor(t·4)).
  • Gabung dalam domain indeks: jika lapis B terang dan lapis A terang → indeks tertinggi (kilau putih); jika hanya B terang → naikkan ke tingkat terang menengah; selain itu pakai A.
  • Kecepatan rendah (2–4 piksel/dtk). Gerak cepat dan non-integer menghilangkan kesan piksel.
// satu piksel hasil gabungan (n = jumlah warna ramp)
let i = a;
if (b >= n - 2) i = a >= n - 2 ? n - 1 : Math.max(a, n - 3);

Performa

  • Caustic adalah bagian termahal (≈ 6 sin per piksel). Batasi ke kotak air, atau hitung tiap 2 frame, atau di setengah resolusi lalu drawImage 2×.
  • Tint dan permukaan hanya melintasi kolom area air; tile top-down hanya indeks dan tidak memanggil fungsi matematika.
  • Latar (langit dan dinding/dasar) di-bake sekali ke dua buffer; tiap frame hanya memilih salah satu per kolom menurut tinggi permukaan (compose).

Checklist untuk AI

  1. Ramp air 4–5 warna dari putih ke biru gelap, hue bergeser; tidak ada rgba() kontinu.
  2. Tint kedalaman dikuantisasi 4 tingkat dengan dither di batas.
  3. Permukaan: 1px terang, 1px rim, pita bayangan Bayer; posisi sy integer.
  4. Caustic = nol-persilangan dua medan sinus ber-warp, additive di buffer cahaya, kuat di dasar.
  5. Animasi top-down = dua tile digeser berlawanan, offset integer.
  6. Semua pola Bayer memakai koordinat layar agar stabil saat kamera diam.
  7. Periksa visual diperbesar; matikan lapisan satu per satu untuk memastikan tiap lapis berguna.

Kode

Kode: src/code/16-water.js
// 16-water.js — texturing air: tint kedalaman 4 tingkat, garis permukaan, caustic (anyaman sinus), serpihan mengambang, dan tile top-down yang di-scroll 2 lapis.
PX.WATER = { HI: PX.hex('#ffffff'), LIT: PX.hex('#c6f3ff'), RIM: PX.hex('#68bfe6'), SHADE: PX.hex('#2f7fb6'), TINT: PX.hex('#0b3550'), SPECK: ['#5f8a9a', '#7fa3a8', '#4c6f86'].map(PX.hex) };
PX.Light.prototype.add = function (x, y, c, k) {            // tambah 1 piksel cahaya (additive) tanpa profil glow
  if (x < 0 || y < 0 || x >= this.w || y >= this.h) return;
  const o = (y * this.w + x) * 3; this.d[o] += c[0] * k; this.d[o + 1] += c[1] * k; this.d[o + 2] += c[2] * k;
};
PX.MAT.water = { ramp: ['#0e3a5c', '#1a6a9a', '#3aa0cc', '#8fe0f0', '#ffffff'], f: (x, y, S, s) => {   // tile top-down: jaring terang = batas sel Worley
  const w = PX.worley(x / 8, y / 8, s, S / 8), line = Math.max(0, 1 - w[1] / 0.3);
  return 0.18 + 0.22 * PX.fbm(x / 4, y / 4, s + 4, S / 4) + 0.62 * line * line;
} };

// Latar kolam: langit senja + dinding bawah air + dasar pasir. Dua buffer; compose() memilih salah satunya per kolom menurut tinggi permukaan.
PX.poolScene = (W, H, top, floorY) => {
  const sky = new PX.Buf(W, H), under = new PX.Buf(W, H);
  const SKY = ['#1b1030', '#3a2250', '#74386a', '#c0566a', '#f08a5a'].map(PX.hex), WALL = ['#0a2236', '#123650', '#1b4a6a'].map(PX.hex), SAND = ['#3a2f3a', '#6a5648', '#a08462', '#d2b383'].map(PX.hex);
  for (let y = 0; y < H; y++) for (let x = 0; x < W; x++) {
    sky.px(x, y, SKY[PX.tone(y / (top + 4) + (PX.fbm(x / 30, y / 8, 3, 0, 0, 2) - 0.5) * 0.25, x, y, 5, 0.25)]);
    const fl = floorY(x);
    if (y >= fl) under.px(x, y, SAND[PX.tone(0.25 + 0.45 * PX.fbm(x / 6, y / 3, 7, 0, 0, 3) + (y === Math.round(fl) ? 0.25 : 0), x, y, 4, 0.25)]);
    else under.px(x, y, WALL[PX.tone(0.2 + 0.5 * PX.fbm(x / 18, y / 10, 4, 0, 0, 3), x, y, 3, 0.25)]);
  }
  return { sky, under };
};

PX.Water = class {
  constructor(x0, x1, top, bottom, seed = 1) {
    Object.assign(this, { x0, x1, top, bottom, pal: PX.WATER });
    this.specks = Array.from({ length: 90 }, (_, i) => ({ x: x0 + PX.hash(i, 1, seed) * (x1 - x0), y: PX.hash(i, 2, seed) * (bottom - top), s: PX.hash(i, 3, seed) * 100, c: i % 3 }));
  }
  surf(x, t) { return this.top + Math.sin(x * 0.11 + t * 1.6) * 0.6 + Math.sin(x * 0.043 - t * 0.9) * 0.8; }   // permukaan default: 2 sinus beda frekuensi
  compose(buf, sky, under, surfY) {                                                                        // atas permukaan = langit, bawah = isi kolam
    for (let x = 0; x < buf.w; x++) { const s = Math.round(surfY(x)); for (let y = 0; y < buf.h; y++) buf.d[y * buf.w + x] = y < s ? sky.d[y * buf.w + x] : under.d[y * buf.w + x]; }
  }
  tint(buf, surfY) {                                                    // lapisan warna air: 4 tingkat kedalaman, batasnya didither (bukan alpha kontinu)
    const A = [0.1, 0.18, 0.26, 0.34], span = Math.max(1, this.bottom - this.top);
    for (let x = this.x0; x < this.x1; x++) for (let y = Math.max(0, Math.ceil(surfY(x))); y < this.bottom + 4; y++) buf.px(x, y, this.pal.TINT, A[PX.tone((y - this.top) / span, x, y, 4, 0.25)]);
  }
  surface(buf, lit, t, surfY) {                                         // garis permukaan: terang 1px, rim 1px, lalu pita bayangan yang menipis (Bayer)
    const P = this.pal;
    for (let x = this.x0; x < this.x1; x++) {
      const sy = Math.round(surfY(x)), glint = Math.sin(x * 0.37 - t * 2.3) * Math.sin(x * 0.13 + t * 1.1) > 0.55;
      buf.px(x, sy, glint ? P.HI : P.LIT); buf.px(x, sy + 1, P.RIM);
      for (let k = 2; k < 7; k++) if (PX.bayer(x, sy + k) < 0.5 - k * 0.07) buf.px(x, sy + k, P.SHADE);
      if ((x & 3) === 0) lit.glow(x, sy + 2, 7, P.LIT, glint ? 0.2 : 0.09);
    }
  }
  caustics(lit, t, mask) {                                                    // anyaman cahaya: garis tempat sin(u)+sin(v) ≈ 0, dua medan ber-warp, 3 tingkat via Bayer, memudar ke atas kecuali di dasar
    const P = this.pal, gy = this.bottom, top = this.top;
    for (let y = top; y <= gy + 6; y++) {
      const k = y >= gy - 1 ? 1 : 0.18 + 0.32 * (1 - (y - top) / Math.max(1, gy - top));
      for (let x = this.x0; x < this.x1; x++) {
        if (mask && !mask(x, y)) continue;                               // mask opsional: caustic hanya di piksel air (air sel)
        const u = x * 0.16 + 1.9 * Math.sin(y * 0.12 + t * 0.9), v = y * 0.19 + 1.9 * Math.sin(x * 0.1 - t * 0.7);
        const n1 = Math.sin(u) + Math.sin(v), n2 = Math.sin(x * 0.09 + y * 0.07 + 1.4 * Math.sin(y * 0.15 - t * 0.6)) + Math.sin(y * 0.11 - x * 0.05 + 1.4 * Math.sin(x * 0.13 + t * 0.8));
        const f = (Math.max(0, 1 - Math.abs(n1) * 3) + 0.7 * Math.max(0, 1 - Math.abs(n2) * 3)) * k, d = PX.bayer(x, y);
        if (f > 1.05 || (f > 0.8 && d < (f - 0.8) / 0.25)) lit.add(x, y, P.LIT, 0.3);
        else if (f > 0.45 || (f > 0.3 && d < (f - 0.3) / 0.15)) lit.add(x, y, P.RIM, 0.15);
      }
    }
  }
  snow(buf, t) {                                                        // serpihan mengambang (marine snow): hanyut pelan, berkedip lewat ambang Bayer
    const h = this.bottom - this.top;
    for (const s of this.specks) {
      const x = Math.floor(s.x + 6 * Math.sin(t * 0.3 + s.s)), y = Math.floor(this.top + ((s.y + (2.5 + 1.5 * Math.sin(s.s)) * t) % h)), tw = Math.sin(t * 1.3 + s.s);
      if (PX.bayer(x, y) > 0.55 + 0.3 * tw) continue;
      buf.px(x, y, tw > 0.85 ? this.pal.LIT : this.pal.SPECK[s.c]);
    }
  }
};

// Air top-down: 2 tile yang sama (seed beda) digeser berlawanan, offset INTEGER per frame; piksel terang yang tumpang tindih jadi kilau putih.
PX.waterTile = (buf, A, B, t) => {
  const S = A.S, n = A.pal.length, ax = Math.floor(t * 4), ay = Math.floor(t * 2), bx = Math.floor(t * 3), by = Math.floor(t * 3);
  for (let y = 0; y < buf.h; y++) for (let x = 0; x < buf.w; x++) {
    const a = A.idx[((y + ay) % S) * S + ((x + ax) % S)], b = B.idx[(((y - by) % S + S) % S) * S + (((x - bx) % S + S) % S)];
    let i = a; if (b >= n - 2) i = a >= n - 2 ? n - 1 : Math.max(a, n - 3);
    buf.px(x, y, A.pal[i]);
  }
};
Kode: src/code/51-demo-water.js
// Demo texturing air: penampang samping (tint + permukaan + caustic + serpihan) atau tile top-down dua lapis. Matikan tiap lapisan untuk melihat kontribusinya.
PX.demos.water = (host) => {
  const W = 160, H = 90, TOP = 24, FLOOR = 78, cv = PX.canvas(host, W, H, 800), c = cv.getContext('2d'), buf = new PX.Buf(W, H), lit = new PX.Light(W, H);
  const wat = new PX.Water(0, W, TOP, FLOOR), sc = PX.poolScene(W, H, TOP, () => FLOOR), A = PX.bakeTex('water', 32, { emb: 0, seed: 2 }), B = PX.bakeTex('water', 32, { emb: 0, seed: 9 });
  let mode = 'side', tint = true, caus = true, snow = true, t = 0;
  const ui = PX.ui(host);
  ui.select('Tampilan', [{ label: 'penampang samping', value: 'side' }, { label: 'tile top-down (2 lapis scroll)', value: 'top' }], mode, (v) => (mode = v));
  ui.check('Tint kedalaman', true, (v) => (tint = v)); ui.check('Caustic', true, (v) => (caus = v)); ui.check('Serpihan', true, (v) => (snow = v));
  PX.loop(host, (dt) => {
    t += dt;
    if (mode === 'top') { PX.waterTile(buf, A, B, t); buf.flush(c); return; }
    const sy = (x) => wat.surf(x, t);
    lit.clear(); wat.compose(buf, sc.sky, sc.under, sy);
    if (tint) wat.tint(buf, sy);
    if (snow) wat.snow(buf, t);
    wat.surface(buf, lit, t, sy);
    if (caus) wat.caustics(lit, t);
    buf.flush(c); lit.flush(c);
  });
};