Make Sprite With Claude

Efek (VFX)

Sistem fluida piksel — permukaan berpegas, gelembung, air sel, benda apung

Tiga model fluida murah untuk piksel — permukaan 1D berpegas (kolam, percikan), gelembung dari ventilasi (naik, goyang, menyatu, pecah), dan air sel 2D (automata) — plus benda apung/tenggelam dan cara menggabungkannya dengan texturing air.

Versi mentah untuk AI: md/fluid-system.md

Bagian gelembung dianalisis dari bubbles.ts buatan Sprite Fusion (MIT); bagian pegas, benda apung, dan air sel adalah teknik standar yang ditulis ulang di sini. Semuanya JavaScript murni, satu unit = satu piksel logis.

Pilih model sesuai kebutuhan

Kebutuhan Model Biaya Kelemahan
Permukaan kolam, ombak, riak, tempat benda jatuh Pegas 1D (PX.Surface) O(lebar) tidak mengalir, tidak ada air di luar bak
Gelembung, uap, partikel naik Partikel + volume (PX.Bubbles) O(n²) kecil hanya partikel
Air yang dituang, mengalir, mengisi cekungan Sel / automata (PX.CAWater) O(lebar·tinggi) permukaan sedikit berkedip, volume bisa bocor di sudut

Jangan memakai SPH atau solver Navier-Stokes untuk piksel: biayanya besar dan hasilnya halus (merusak gaya). Ketiga model di atas lebih cocok dengan estetika grid. Tampilan air (warna, permukaan, caustic) dikerjakan terpisah di texturing air.

Demo interaktif butuh JavaScript. Kode lengkap ada di bawah.

Klik untuk menjatuhkan batu. Geser massa jenis: di bawah 1 benda mengapung, di atas 1 tenggelam.

1. Permukaan berpegas (heightfield)

Tiap kolom i punya tinggi h[i] dan kecepatan v[i]. Dua langkah per tick:

// pegas ke tinggi 0 + redaman
v[i] += −k·h[i] − damp·v[i];   h[i] += v[i]
// sebar ke tetangga (ulang 4 kali, hitung dulu semua delta baru terapkan)
dl[i] = spread·(h[i] − h[i−1]);  v[i−1] += dl[i]
dr[i] = spread·(h[i] − h[i+1]);  v[i+1] += dr[i]
h[i−1] += dl[i];  h[i+1] += dr[i]

Nilai awal yang stabil: k = 0.03, damp = 0.03, spread = 0.22 (harus < 0.5). Jalankan dengan langkah tetap 1/60 dtk (akumulator), bukan dt variabel — kalau tidak, gelombang meledak saat frame lambat.

  • Percikan masuk: splash(x, −gaya) menambah kecepatan negatif di kolom itu (±½ di tetangga). Permukaan turun lalu memantul menjadi gelombang menjalar.
  • Tinggi di x pecahan: interpolasi linear dua kolom, lalu dibulatkan hanya saat menggambar.
  • Tambahkan ombak ambien (dua sinus kecil) setelah simulasi: y = sim.y(x) + ambien(x,t); simulasi tetap bersih.

2. Benda apung (Archimedes sederhana)

sub = clamp((y + r − permukaan) / (2r), 0, 1)       // fraksi tercelup
ay  = g·(1 − sub/rho)                               // rho = massa jenis relatif air
v  −= v·drag·sub·dt                                  // hambatan hanya saat tercelup

Pada rho < 1 benda berhenti saat sub = rho (mengapung dengan sebagian di atas air); pada rho > 1 tenggelam sampai dasar. Satu persamaan, tanpa cabang "terapung/tenggelam".

Saat masuk air (sub berubah dari 0 ke > 0, sekali): surface.splash(x, −min(3, vy·r·0.004)), semburkan min(14, vy·r·0.05) tetes, dan mulai jejak gelembung. Saat bergerak cepat di bawah air, munculkan gelembung kecil dengan probabilitas dt·12.

3. Percikan (tetes)

Partikel dengan gravitasi (vy += 220·dt), digambar 1 piksel terang + 1 piksel ekor LIT di belakang arah gerak. Saat vy > 0 dan y ≥ permukaan(x): buang tetes dan splash(x, −0.25) — riak kecil. Rantai sebab-akibat ini (benda → percikan → tetes → riak) membuat air terasa hidup tanpa simulasi mahal.

4. Gelembung

Setiap ventilasi punya laju spawn berfluktuasi (dt·5·density·(0.7 + 0.3·noise)) dan semburan tiap 1.4–3.2 dtk (5–10 gelembung rapat). Per gelembung:

vy  += (−(20 + 13r) − vy)·min(1, 3dt)       // naik lebih cepat bila besar, mendekati bertahap
ph  += dt·(4 + 2/r)                         // gelembung kecil bergetar lebih cepat
x    = x0 + sin(ph)·(0.5 + 0.8r)            // goyang kiri-kanan
x0  += noise(0.4t + i·0.13)·3·dt            // hanyut
r    = min(5, r + 0.12·dt)                  // tumbuh (tekanan turun)
  • Menyatu: dua gelembung (r ≥ 1) yang jaraknya < 0.75·(ra + rb) menjadi satu dengan r = ∛(ra³ + rb³) (volume kekal), posisi dirata-rata, dan wob = 1 (getaran gepeng sesaat).
  • Pecah: saat y < permukaan(x) + 1 → efek pecah 0.2 dtk: setengah elips percikan yang melebar (rx = r + 1 + 3k, tinggi ½) + dua tetes naik, memudar lewat Bayer.
  • Layer: sekitar 35% gelembung near digambar setelah benda (layer depan), sisanya di belakang — kedalaman murah.
  • Isi awal: sebar gelembung di sepanjang kolom ventilasi saat dibuat agar adegan tidak kosong pada detik pertama.

Gambar menurut ukuran (detail berbanding ukuran, jangan menggambar cincin untuk 1 piksel):

r Gambar
< 0.9 1 piksel (RIM atau LIT)
< 1.5 blok 2×2: HI LIT / RIM SHADE + glow kecil
≥ 1.5 cincin 1 piksel (sisi terang kiri-atas LIT, sisi gelap SHADE, lainnya RIM), isi 12% dither FILL, highlight HI di (−0.45, −0.45)·r, titik LIT di sisi berlawanan bila r ≥ 2, bentuk gepeng bergantian ±10%

5. Air sel (automata)

Grid g: 0 kosong, 1 air, 2 tembok. Pindai dari bawah ke atas; untuk tiap sel air, coba berurutan:

  1. Jatuh lurus jika sel di bawah kosong.
  2. Diagonal bawah (arah acak) jika sel samping dan diagonal kosong.
  3. Mengalir ke samping sejauh flow sel (1–8) ke arah acak.

Aturan yang membuatnya benar: (a) arah pindai kiri/kanan bergantian tiap langkah supaya air tidak condong ke satu sisi; (b) tandai sel yang baru dipindah (stamp) agar tidak diproses dua kali dalam satu langkah; (c) jalankan 2 langkah per frame agar alirannya cepat; (d) sumber (keran) menyuntik sel tiap langkah, bukan tiap frame, supaya aliran utuh.

Demo interaktif butuh JavaScript. Kode lengkap ada di bawah.

Pewarnaan: pakai tampilan yang sama dengan demo kolam supaya ketiganya terlihat satu keluarga. Hitung kedalaman dari permukaan per kolom (pindai dari atas: sel air tanpa air di atas → d = 0, selain itu d = d_atas + 1). Isi sel air = piksel dinding bawah air dari poolScene, ditimpa TINT dengan alpha 4 tingkat menurut tone(1 − min(1, d/14), x, y, 4, 0.25). Sel d = 0 = LIT (kilau berjalan → HI), d = 1 = RIM, d = 2…6 = pita SHADE yang menipis lewat Bayer, sel kosong = langit. Caustic memakai Water.caustics(lit, t, mask) dengan mask = hanya sel air. Kedalaman dari permukaan membuat kolam dangkal tetap terang walaupun ada di dasar peta.

Hibrida dengan permukaan pegas (demo di atas): sel air hanya model badan air; tumbukan dan gelombangnya dibuat oleh komponen lain supaya tidak terasa dua jenis air yang berbeda:

  1. Deteksi mendarat: tiap sel menyimpan fall (berapa langkah ia jatuh lurus). Saat ia berhenti, catat {x, y, v, water} ke ev. Itu satu-satunya "gaya" yang dibutuhkan.
  2. Muka air per kolom = sel teratas yang ditopang (bawahnya tidak kosong), dihaluskan (l + 2a + r)/4 dan dibatasi ±1 dari sel mentah — ini juga menghilangkan kedipan permukaan. Lalu digeser oleh PX.Surface (h[x]): lembah hanya memotong sel air, puncak hanya mengisi sel kosong.
  3. Saat ev datang: surface.splash(x, −sp·0.06) (gelombang menjalar), foam.add (busa putih di muka air), spray (tetes bergravitasi; mendarat di air = riak kecil), dan fizz.burst (gelembung mikro terseret turun lalu naik). Beri jeda ~0,12 dtk per 4 kolom, kalau tidak aliran keran memicu semburan tiap sel.
  4. Gelembung mikro (PX.Fizz): titik 1 piksel (r < 0,9, berkedip) atau blok 2×2; naik dengan goyang kecil, hilang bila keluar dari sel air, pecah (riak + tetes kecil) bila mencapai muka air. inWater(x, y) dari sel membuat gelembung hanya hidup di dalam air berbentuk bebas.
  5. Bejana berhubungan (penyamaan tinggi): gravitasi + aliran samping saja tidak membuat tinggi air sama di dua bejana yang tersambung, dan lambat meratakan genangan. Tambahkan level() setelah tiap langkah: beri label badan air (flood-fill 4 arah), cari muka tertinggi dan terendah per badan (sel air yang atasnya kosong, bawahnya terisi, fall = 0 — sel aliran keran tidak ikut), lalu bila selisih ≥ 2 baris pindahkan sel dari muka tertinggi ke sel kosong tepat di atas muka terendah, maksimal lvRate sel per badan per langkah (debit penyamaan). Badan air yang terhubung lewat saluran bawah otomatis ikut turun/naik bersama: tambah air di satu bejana, semua naik. Hasil pindah dicatat (lv) dan dipakai memukul surface.splash (asal turun, tujuan naik) sehingga penyesuaian terlihat sebagai ombak, bukan air yang tiba-tiba hilang. Volume kekal (sel dipindah, tidak dibuat).
  6. Aduk: geser acak sel air searah seretan, lalu fizz.burst + splash + foam sebanding kecepatan seretan.

Demo kolam di atas memakai hal yang sama: benda masuk air melepas busa dan gelembung mikro, tetes yang jatuh kembali kadang menyeret satu gelembung.

Kelemahan yang perlu diketahui: genangan tipis 1 sel tidak merata sempurna dan volume bisa bocor di sudut. Itu bisa diterima sebagai "kilau" di gaya piksel.

Menggabungkan semuanya

  1. surface.update(dt), benda, tetes, bubbles.update(dt).
  2. compose latar menurut tinggi permukaan, benda bawah air, tint, snow, gelembung belakang, surface(), gelembung depan, tetes, caustics().
  3. Satu fungsi sy(x) dibagikan ke gelembung (titik pecah), benda (fraksi tercelup), tint, dan garis permukaan — satu sumber kebenaran untuk tinggi air.

Performa

  • Gelembung: tabrakan O(n²); batasi populasi (≤ ~150) atau pakai grid sel jika lebih.
  • Pegas 1D: 4 putaran penyebaran × lebar; murah.
  • Air sel: ~9.000 sel × 2 langkah masih lancar; gunakan Uint8Array, hindari alokasi array di dalam loop sel.
  • Semua posisi digambar setelah pembulatan; simulasi tetap float.

Checklist untuk AI

  1. Pilih model dari tabel; jangan menggunakan solver fluida kontinu.
  2. Langkah tetap 1/60 untuk pegas; spread < 0.5.
  3. Benda: satu rumus ay = g(1 − sub/rho); masuk air memicu percikan sekali.
  4. Gelembung: kecepatan, goyang, tumbuh, menyatu dengan ∛, pecah dengan efek setengah elips; detail menurut ukuran.
  5. Air sel (+ level() agar bejana berhubungan sama tinggi; sel jatuh dikecualikan): pindai bawah→atas, arah bergantian, stamp, keran per langkah; catat pendaratan (fall) untuk gelombang, busa, percikan, dan gelembung mikro.
  6. Satu fungsi tinggi permukaan dibagikan ke semua komponen.
  7. Lihat hasil render diperbesar sebelum menyetel angka.

Kode

Kode: src/code/17-fluid.js
// 17-fluid.js — tiga model fluida murah untuk piksel: (1) PX.Surface = permukaan 1D berpegas, (2) PX.Bubbles = gelembung dari ventilasi, (3) PX.CAWater = air 2D berbasis sel (automata) + pelengkap: PX.Foam (busa), PX.Fizz (gelembung mikro).

// (1) Permukaan: tiap kolom = pegas (Hooke) + penyebaran ke tetangga. h>0 = naik. Langkah tetap 1/60 dtk agar stabil.
PX.Surface = class {
  constructor(n, base, o = {}) {
    this.n = n; this.base = base; this.h = new Float32Array(n); this.v = new Float32Array(n); this.dl = new Float32Array(n); this.dr = new Float32Array(n);
    this.k = o.k ?? 0.03; this.damp = o.damp ?? 0.03; this.spread = o.spread ?? 0.22; this.acc = 0;
  }
  splash(x, f) { const i = PX.clamp(Math.round(x), 0, this.n - 1); this.v[i] += f; if (i > 0) this.v[i - 1] += f * 0.5; if (i < this.n - 1) this.v[i + 1] += f * 0.5; }   // f<0 = ditekan ke bawah
  y(x) { x = PX.clamp(x, 0, this.n - 1); const i = Math.floor(x), j = Math.min(this.n - 1, i + 1), f = x - i; return this.base - (this.h[i] * (1 - f) + this.h[j] * f); }
  update(dt) { this.acc += dt; while (this.acc >= 1 / 60) { this.acc -= 1 / 60; this.step(); } }
  step() {
    const { n, h, v, dl, dr, k, damp, spread } = this;
    for (let i = 0; i < n; i++) { v[i] += -k * h[i] - damp * v[i]; h[i] += v[i]; }                    // pegas ke tinggi 0 + redaman
    for (let pass = 0; pass < 4; pass++) {                                                            // sebar: tetangga ditarik ke selisih tinggi
      for (let i = 0; i < n; i++) { if (i > 0) { dl[i] = spread * (h[i] - h[i - 1]); v[i - 1] += dl[i]; } if (i < n - 1) { dr[i] = spread * (h[i] - h[i + 1]); v[i + 1] += dr[i]; } }
      for (let i = 0; i < n; i++) { if (i > 0) h[i - 1] += dl[i]; if (i < n - 1) h[i + 1] += dr[i]; }
    }
  }
};

// (2) Gelembung (dari sistem Sprite Fusion, ditulis ulang): ventilasi -> naik, goyang, tumbuh, menyatu (volume kekal), pecah di permukaan.
PX.Bubbles = class {
  constructor({ x0, x1, top, ground, surfY, vents, density = 1, seed = 1 }) {
    Object.assign(this, { x0, x1, top, ground, D: density, surfY: surfY ?? (() => top), t: 0, list: [], pops: [] });
    const R = (this.R = PX.rng(seed)); this.rg = (a, b) => a + (b - a) * R();
    this.vents = (vents ?? [0.17, 0.5, 0.83].map((f) => x0 + f * (x1 - x0))).map((x) => ({ x, acc: R(), burst: this.rg(0.5, 2.5), seed: this.rg(0, 100) }));
    for (const v of this.vents) for (let y = ground(v.x) - 4; y > top; y -= this.rg(9, 23) / density) {      // isi awal sepanjang kolom supaya adegan tidak kosong
      this.spawn(v, y, 0); const b = this.list[this.list.length - 1]; b.r *= 1 + (1 - (y - top) / (ground(v.x) - top)) * 0.4; b.vy = -(20 + 13 * b.r);
    }
  }
  spawn(v, y, big) {
    const R = this.R, rg = this.rg, r = big ? rg(1.5, 3.2) * big : R() < 0.7 ? rg(0.5, 1.4) : rg(1.4, 2.6);
    this.list.push({ x0: v.x + rg(-2, 2), x: v.x, y, r, vy: 0, ph: rg(0, 6.283), wob: 0, near: R() < 0.35 });
  }
  add(x, y, r) { this.list.push({ x0: x, x, y, r, vy: -(20 + 13 * r), ph: this.rg(0, 6.283), wob: 0, near: false }); }   // gelembung dari sumber lain (benda jatuh)
  update(dt) {
    const L = this.list, rg = this.rg, n1 = (x, s) => PX.vnoise(x, s * 3.7) * 2 - 1; this.t += dt; const t = this.t;
    for (const v of this.vents) {
      v.acc += dt * 5 * this.D * (0.7 + 0.3 * n1(t * 0.8 + v.seed, 3));                                           // laju spawn berfluktuasi pelan
      for (; v.acc > 1; v.acc--) this.spawn(v, this.ground(v.x) - 2, 0);
      if ((v.burst -= dt) < 0) { v.burst = rg(1.4, 3.2); const n = 5 + Math.floor(this.R() * 6); for (let i = 0; i < n; i++) this.spawn(v, this.ground(v.x) - 2 - i * 2.5, rg(0.6, 1)); }   // semburan
    }
    for (let i = L.length - 1; i >= 0; i--) {
      const b = L[i];
      b.vy += (-(20 + 13 * b.r) - b.vy) * Math.min(1, dt * 3);                                                     // kecepatan naik ∝ ukuran, mendekati bertahap
      b.y += b.vy * dt; b.ph += dt * (4 + 2 / b.r);
      b.x = b.x0 + Math.sin(b.ph) * (0.5 + 0.8 * b.r); b.x0 += n1(t * 0.4 + i * 0.13, 5) * 3 * dt;                 // goyang kiri-kanan + hanyut
      b.r = Math.min(5, b.r + dt * 0.12); b.wob *= 1 - Math.min(1, dt * 4);                                         // tumbuh (tekanan turun)
      if (b.y < this.surfY(b.x) + 1) { this.pops.push({ x: b.x, y: this.surfY(b.x), r: b.r, t: 0 }); L.splice(i, 1); }
    }
    for (let i = 0; i < L.length; i++) {                                                                           // menyatu: volume kekal r = ∛(ra³+rb³)
      const a = L[i]; if (a.r < 1) continue;
      for (let j = i + 1; j < L.length; j++) { const b = L[j]; if (b.r < 1 || Math.hypot(a.x - b.x, a.y - b.y) > (a.r + b.r) * 0.75) continue; a.r = Math.cbrt(a.r ** 3 + b.r ** 3); a.x0 = (a.x0 + b.x0) / 2; a.wob = 1; L.splice(j--, 1); }
    }
    for (let i = this.pops.length - 1; i >= 0; i--) if ((this.pops[i].t += dt) > 0.2) this.pops.splice(i, 1);
  }
  draw(buf, lit, layer) {
    if (layer !== 'front') { for (const b of this.list) if (!b.near) this.bubble(buf, lit, b); for (const p of this.pops) this.pop(buf, p); }
    if (layer !== 'back') for (const b of this.list) if (b.near) this.bubble(buf, lit, b);
  }
  bubble(buf, lit, b) {                                              // ukuran menentukan detail: <0.9 = 1px, <1.5 = 2×2 empat warna, lainnya = cincin + highlight
    const { HI, LIT, RIM, SHADE } = PX.WATER, FILL = PX.mix(RIM, SHADE, 0.5), cx = b.x, cy = b.y, r = b.r;
    if (r < 0.9) { buf.px(Math.floor(cx), Math.floor(cy), r < 0.7 ? RIM : LIT); return; }
    if (r < 1.5) { const px = Math.floor(cx), py = Math.floor(cy); buf.px(px, py, HI); buf.px(px + 1, py, LIT); buf.px(px, py + 1, RIM); buf.px(px + 1, py + 1, SHADE); lit.glow(cx + 0.5, cy + 0.5, 3, LIT, 0.12); return; }
    const sq = 0.1 * Math.sin(b.ph * 2) * Math.min(1, (r - 1.5) / 2) + b.wob * 0.25, rx = r * (1 + sq), ry = r * (1 - sq);   // bentuk bergetar (gepeng bergantian) + kejut saat menyatu
    for (let y = Math.floor(cy - ry - 1); y <= Math.ceil(cy + ry + 1); y++) for (let x = Math.floor(cx - rx - 1); x <= Math.ceil(cx + rx + 1); x++) {
      const dx = (x + 0.5 - cx) / rx, dy = (y + 0.5 - cy) / ry, d = Math.sqrt(dx * dx + dy * dy) * r; if (d > r + 0.5) continue;
      const l = (-dx - dy) / Math.SQRT2 / Math.max(0.01, d / r);                                                    // + = menghadap cahaya kiri-atas
      if (d > r - 0.6) buf.px(x, y, l > 0.45 ? LIT : l < -0.55 ? SHADE : RIM); else if (PX.bayer(x, y) < 0.12) buf.px(x, y, FILL);   // tepi 1px; isi hanya 12% dither
    }
    const hx = Math.round(cx - rx * 0.45), hy = Math.round(cy - ry * 0.45);
    buf.px(hx, hy, HI); if (r >= 2.5) { buf.px(hx + 1, hy, LIT); buf.px(hx, hy + 1, LIT); } if (r >= 2) buf.px(Math.round(cx + rx * 0.5), Math.round(cy + ry * 0.5), LIT);
    lit.glow(cx, cy, r * 2.4 + 2, RIM, 0.14);
  }
  pop(buf, p) {                                                      // pecah: setengah elips percikan melebar + 2 tetes naik, memudar via Bayer
    const k = p.t / 0.2, r = p.r + 1 + k * 3, dot = (x, y, c) => { const px = Math.floor(x), py = Math.floor(y); if (1 - k > PX.bayer(px, py)) buf.px(px, py, c); };
    for (let a = 0; a < 6.283; a += 0.5 / r) if (Math.sin(a) < 0.2) dot(p.x + Math.cos(a) * r, p.y + Math.sin(a) * r * 0.5, k < 0.5 ? PX.WATER.HI : PX.WATER.LIT);
    if (p.r > 1.5) { dot(p.x - 2, p.y - 2 - k * 4, PX.WATER.LIT); dot(p.x + 2, p.y - 3 - k * 5, PX.WATER.LIT); }
  }
};

// (3) Air sel (cellular automata). g: 0 kosong, 1 air, 2 tembok. Pindai bawah->atas, arah kiri/kanan bergantian tiap langkah agar tidak miring sebelah.
// fall[i] = berapa langkah sel itu jatuh berturut-turut. Saat berhenti (mendarat) dicatat ke ev = {x, y, v, water} -> sumber percikan, gelombang, gelembung.
PX.CAWater = class {
  constructor(w, h, seed = 3) { this.w = w; this.h = h; this.g = new Uint8Array(w * h); this.m = new Uint8Array(w * h); this.fall = new Uint8Array(w * h); this.ev = []; this.lv = []; this.lvRate = 6; this.stamp = 0; this.dir = 0; this.flow = 4; this.R = PX.rng(seed); }
  step() {
    const { w, h, g, m, fall: fl, ev } = this; if (++this.stamp > 250) { this.stamp = 1; m.fill(0); } const st = this.stamp; this.dir ^= 1;
    for (let y = h - 2; y >= 0; y--) for (let k = 0; k < w; k++) {
      const x = this.dir ? k : w - 1 - k, i = y * w + x; if (g[i] !== 1 || m[i] === st) continue;       // m: sel yang baru dipindah langkah ini jangan diproses lagi
      const b = i + w, d = this.R() < 0.5 ? -1 : 1; let to = -1;
      if (g[b] === 0) to = b;                                                                          // 1) jatuh lurus
      else {
        for (let q = 0; q < 2 && to < 0; q++) { const s = q ? -d : d; if (x + s >= 0 && x + s < w && g[b + s] === 0 && g[i + s] === 0) to = b + s; }   // 2) diagonal bawah
        for (let q = 0; q < 2 && to < 0; q++) { const s = q ? -d : d; let j = 0; while (j < this.flow && x + s * (j + 1) >= 0 && x + s * (j + 1) < w && g[i + s * (j + 1)] === 0) j++; if (j > 0) to = i + s * j; }   // 3) mengalir ke samping
      }
      if (to !== b) { if (fl[i] >= 3 && ev.length < 48) ev.push({ x, y, v: fl[i], water: g[b] === 1 }); fl[i] = 0; }   // tidak lagi jatuh lurus = mendarat
      if (to >= 0) { g[to] = 1; g[i] = 0; m[to] = st; fl[to] = to === b ? Math.min(255, fl[i] + 1) : 0; fl[i] = 0; }
    }
    if (this.lvRate > 0) this.level();
  }
  // Bejana berhubungan: tiap badan air (sel air terhubung 4 arah) disamakan tingginya. Muka tertinggi di badan itu menyumbang sel,
  // muka terendah menerimanya (sel diletakkan tepat di atasnya), selama selisih >= 2 baris. Maks lvRate sel per badan per langkah = debit penyamaan.
  // Sel yang masih jatuh (fall > 0) dan sel tanpa topangan bukan muka air, jadi aliran keran tidak ikut "disedot". Posisi pindah dicatat di lv (x asal, x tujuan) untuk gelombang.
  level() {
    const { w, h, g, fall: fl, R } = this, n = w * h, lab = this.lab || (this.lab = new Int32Array(n)), stk = this.stk || (this.stk = new Int32Array(n)); lab.fill(0); let c = 0;
    for (let s0 = 0; s0 < n; s0++) {
      if (g[s0] !== 1 || lab[s0]) continue;
      let sp = 0, hy = 1e9, ly = -1, S = [], K = []; stk[sp++] = s0; lab[s0] = ++c;
      while (sp) {
        const i = stk[--sp], x = i % w, y = (i / w) | 0;
        if (y > 0 && y < h - 1 && g[i - w] === 0 && g[i + w] !== 0 && fl[i] === 0) {
          if (y < hy) { hy = y; S = []; } if (y === hy) S.push(i);
          if (y > ly) { ly = y; K = []; } if (y === ly) K.push(i);
        }
        if (x > 0 && g[i - 1] === 1 && !lab[i - 1]) { lab[i - 1] = c; stk[sp++] = i - 1; }
        if (x < w - 1 && g[i + 1] === 1 && !lab[i + 1]) { lab[i + 1] = c; stk[sp++] = i + 1; }
        if (y > 0 && g[i - w] === 1 && !lab[i - w]) { lab[i - w] = c; stk[sp++] = i - w; }
        if (y < h - 1 && g[i + w] === 1 && !lab[i + w]) { lab[i + w] = c; stk[sp++] = i + w; }
      }
      if (ly - hy < 2) continue;
      for (let m = Math.min(this.lvRate, (ly - hy) >> 1); m > 0 && S.length && K.length; m--) {
        const a = S.splice((R() * S.length) | 0, 1)[0], b = K.splice((R() * K.length) | 0, 1)[0] - w;
        g[a] = 0; g[b] = 1; fl[a] = fl[b] = 0; if (this.lv.length < 256) this.lv.push(a % w, b % w);
      }
    }
  }
};

// (4) Busa permukaan: intensitas per kolom, meluruh pelan; digambar sebagai piksel putih berambang Bayer tepat di muka air.
PX.Foam = class {
  constructor(n) { this.f = new Float32Array(n); }
  add(x, a, s = 3) { for (let dx = -s; dx <= s; dx++) { const i = Math.round(x) + dx; if (i >= 0 && i < this.f.length) this.f[i] = Math.min(1, this.f[i] + a * (1 - Math.abs(dx) / (s + 1))); } }
  update(dt) { for (let i = 0; i < this.f.length; i++) this.f[i] = Math.max(0, this.f[i] - dt * 0.35); }
  draw(buf, surfY) {                                                   // surfY(x) < 0 = kolom tanpa muka air
    for (let x = 0; x < this.f.length; x++) { const a = this.f[x]; if (a < 0.04) continue; const y = Math.round(surfY(x)); if (y < 0) continue;
      if (a > PX.bayer(x, y)) buf.px(x, y, PX.WATER.HI); if (a * 0.8 > PX.bayer(x + 1, y - 1)) buf.px(x, y - 1, PX.WATER.LIT); }
  }
};

// (5) Gelembung mikro di dalam air (titik-titik): tiap gelembung naik dengan goyang kecil, hilang bila keluar dari air, pecah (onPop) bila mencapai muka air.
// inWater(x, y) -> bool menentukan di mana gelembung boleh ada, jadi cocok untuk air sel berbentuk bebas.
PX.Fizz = class {
  constructor(inWater, onPop, seed = 7) { Object.assign(this, { inW: inWater, onPop, list: [], max: 320, R: PX.rng(seed) }); }
  burst(x, y, n, spread = 3, down = 0) {                              // down > 0: awalnya terseret turun oleh air yang jatuh, lalu naik
    const R = this.R, rg = (a, b) => a + (b - a) * R();
    for (let i = 0; i < n && this.list.length < this.max; i++) this.list.push({ x: x + rg(-spread, spread), y: y + rg(0, spread), r: rg(0.4, 1.3), vy: rg(-4, down), ph: rg(0, 6.283) });
  }
  update(dt) {
    const L = this.list;
    for (let i = L.length - 1; i >= 0; i--) {
      const b = L[i]; b.ph += dt * (6 + 2 / b.r); b.vy += (-(10 + 14 * b.r) - b.vy) * Math.min(1, 2.5 * dt); b.y += b.vy * dt; b.x += Math.sin(b.ph) * 6 * dt;
      const ix = Math.round(b.x), iy = Math.round(b.y);
      if (!this.inW(ix, iy)) { if (b.vy < 0 && this.inW(ix, iy + 1) && this.onPop) this.onPop(ix, iy); L.splice(i, 1); }
    }
  }
  draw(buf, lit) {
    const P = PX.WATER;
    for (const b of this.list) {
      const x = Math.round(b.x), y = Math.round(b.y);
      if (b.r < 0.9) buf.px(x, y, Math.sin(b.ph * 0.7) > 0.6 ? P.HI : P.RIM);                    // kecil = 1 piksel, berkedip
      else { buf.px(x, y, P.HI); buf.px(x + 1, y, P.LIT); buf.px(x, y + 1, P.RIM); buf.px(x + 1, y + 1, P.SHADE); lit.glow(x + 0.5, y + 0.5, 3, P.LIT, 0.12); }
    }
  }
};
Kode: src/code/52-demo-fluid.js
// Demo fluida: permukaan berpegas + benda berapung/tenggelam (Archimedes sederhana) + percikan + gelembung. Klik = jatuhkan batu.
PX.demos.fluid = (host) => {
  const W = 160, H = 90, TOP = 30, FLOOR = 82, cv = PX.canvas(host, W, H, 800), c = cv.getContext('2d'), buf = new PX.Buf(W, H), lit = new PX.Light(W, H), p = PX.pointer(cv);
  const wat = new PX.Water(0, W, TOP, FLOOR), sc = PX.poolScene(W, H, TOP, () => FLOOR), surf = new PX.Surface(W, TOP), R = PX.rng(5), rg = (a, b) => a + (b - a) * R();
  const objs = [], drops = []; let t = 0, rho = 1.6, caus = true;
  const sy = (x) => surf.y(x) + Math.sin(x * 0.11 + t * 1.6) * 0.4 + Math.sin(x * 0.043 - t * 0.9) * 0.5;      // permukaan = simulasi + ombak halus
  const bub = new PX.Bubbles({ x0: 0, x1: W, top: TOP, ground: () => FLOOR, surfY: sy, density: 0.7, seed: 2 });
  const foam = new PX.Foam(W), ui = PX.ui(host);
  ui.range('Massa jenis benda', 0.4, 2.5, 0.1, rho, (v) => (rho = v)); ui.check('Caustic', true, (v) => (caus = v));
  ui.button('Hujan batu', () => { for (let i = 0; i < 6; i++) drop(rg(10, W - 10), -i * 12); });
  const drop = (x, y) => { objs.push({ x, y, vx: rg(-8, 8), vy: 0, r: rg(2, 4), rho, wet: false }); if (objs.length > 14) objs.shift(); };
  const spray = (x, y, speed, r) => { const n = Math.min(14, Math.floor(speed * r * 0.05)); for (let i = 0; i < n; i++) drops.push({ x: x + rg(-r, r), y, vx: rg(-30, 30), vy: -(40 + rg(0.2, 0.7) * speed) }); };
  PX.loop(host, (dt) => {
    t += dt; for (const k of p.clicks.splice(0)) drop(k.x, 2);
    for (const o of objs) {
      const s = sy(o.x), sub = PX.clamp((o.y + o.r - s) / (2 * o.r), 0, 1);                                  // fraksi tercelup 0..1
      o.vy += 150 * (1 - sub / o.rho) * dt;                                                                    // gravitasi - gaya apung (∝ tercelup / massa jenis)
      o.vy -= o.vy * 2.2 * sub * dt; o.vx -= o.vx * 1.5 * sub * dt; o.x += o.vx * dt; o.y += o.vy * dt;        // hambatan hanya saat tercelup
      if (o.y + o.r > FLOOR) { o.y = FLOOR - o.r; o.vy *= -0.2; o.vx *= 0.5; }
      if (!o.wet && sub > 0) { o.wet = true; surf.splash(o.x, -Math.min(3, o.vy * o.r * 0.004)); spray(o.x, s, Math.abs(o.vy), o.r); foam.add(o.x, Math.min(1, Math.abs(o.vy) * o.r * 0.004), 3); for (let n = Math.min(16, Math.floor(Math.abs(o.vy) * o.r * 0.06)); n > 0; n--) bub.add(o.x + rg(-o.r, o.r), s + rg(3, 3 + 2 * o.r), rg(0.4, 0.8)); }   // masuk air: tekan permukaan + percikan
      if (sub === 0) o.wet = false;
      if (sub > 0.5 && Math.hypot(o.vx, o.vy) > 30 && R() < dt * 12) bub.add(o.x + rg(-1, 1), o.y - o.r * 0.5, rg(0.6, 1.4));
      if (sub > 0.3 && Math.hypot(o.vx, o.vy) > 15 && R() < dt * 30) bub.add(o.x + rg(-o.r, o.r), o.y + rg(-o.r, o.r), rg(0.4, 0.8));   // titik-titik gelembung mikro   // jejak gelembung
    }
    for (let i = drops.length - 1; i >= 0; i--) { const d = drops[i]; d.vy += 220 * dt; d.x += d.vx * dt; d.y += d.vy * dt; if (d.vy > 0 && d.y >= sy(d.x)) { surf.splash(d.x, -0.25); foam.add(d.x, 0.15, 1); if (R() < 0.35) bub.add(d.x, sy(d.x) + rg(2, 5), rg(0.4, 0.7)); drops.splice(i, 1); } }   // tetes jatuh -> riak kecil
    surf.update(dt); bub.update(dt); foam.update(dt);
    lit.clear(); wat.compose(buf, sc.sky, sc.under, sy);
    for (const o of objs) { buf.disc(o.x, o.y, o.r, [58, 52, 78]); buf.disc(o.x - 0.5, o.y - 0.5, o.r - 1, [110, 106, 128]); buf.px(o.x - o.r * 0.4, o.y - o.r * 0.4, [207, 205, 210]); }
    wat.tint(buf, sy); wat.snow(buf, t); bub.draw(buf, lit, 'back'); wat.surface(buf, lit, t, sy); foam.draw(buf, sy); bub.draw(buf, lit, 'front');
    for (const d of drops) { buf.px(d.x, d.y, PX.WATER.HI); buf.px(d.x - d.vx * 0.02, d.y - d.vy * 0.02, PX.WATER.LIT); }
    if (caus) wat.caustics(lit, t);
    buf.flush(c); lit.flush(c);
  });
};
Kode: src/code/53-demo-waterca.js
// Demo air sel hibrida: badan air = sel (mengalir + penyamaan tinggi / bejana berhubungan), muka air = pegas 1D (gelombang), tumbukan -> percikan + busa + gelembung mikro. Alat 'aduk' = mengobok air.
PX.demos.waterca = (host) => {
  const W = 128, H = 72, FL = H - 2, cv = PX.canvas(host, W, H, 768, false, 'Seret di atas kanvas untuk memakai alat'), c = cv.getContext('2d'), buf = new PX.Buf(W, H), p = PX.pointer(cv);
  const sim = new PX.CAWater(W, H), g = sim.g, lit = new PX.Light(W, H), wat = new PX.Water(0, W, 0, FL), sc = PX.poolScene(W, H, H * 0.45, () => FL), P = PX.WATER;
  const surf = new PX.Surface(W, 0), foam = new PX.Foam(W), R = PX.rng(11), rg = (a, b) => a + (b - a) * R();
  const STONE = ['#2b2a3a', '#4a4a5e', '#6e6f80'].map(PX.hex), TA = [0.1, 0.18, 0.26, 0.34];
  const top = new Int16Array(W).fill(-1), y0 = new Int16Array(W).fill(-1), dp = new Uint16Array(W), drops = [], cool = new Float32Array((W >> 2) + 1);     // top = muka air efektif (-1 = kolom tanpa muka air), y0 = muka sel mentah
  const inW = (x, y) => x >= 0 && y >= 0 && x < W && y < H && g[y * W + x] === 1 && !(top[x] >= 0 && y < top[x]);
  const spray = (x, y, v, n) => { for (let i = 0; i < n && drops.length < 400; i++) drops.push({ x: x + rg(-1.5, 1.5), y: y - 1, vx: rg(-30, 30), vy: -(30 + rg(0.3, 1) * Math.min(70, v * 3)) }); };
  const fizz = new PX.Fizz(inW, (x) => { surf.splash(x, -0.05); foam.add(x, 0.1, 1); if (R() < 0.4) spray(x, top[x] >= 0 ? top[x] : 0, 4, 1); });
  let tool = 'water', faucet = true, caus = true, t = 0, px0 = 0, py0 = 0, fx = 40, fq = 4;
  const box = (x0, x1, y0, y1) => { for (let y = y0; y <= y1; y++) for (let x = x0; x <= x1; x++) g[y * W + x] = 2; };
  const build = (layout) => { g.fill(0); sim.fall.fill(0); drops.length = 0; fizz.list.length = 0; surf.h.fill(0); surf.v.fill(0); foam.f.fill(0); fx = 40;
    box(0, W - 1, H - 2, H - 1); box(0, 0, 0, H - 1); box(W - 1, W - 1, 0, H - 1);
    if (layout === 'vessels') {                                                                                // 5 bejana beda lebar, tersambung saluran di bawah (sekat berhenti di y=66); A penuh, sisanya kosong
      box(97, 97, 0, H - 1); box(W - 1, W - 1, 0, H - 1); box(7, 8, 4, 66); box(29, 30, 26, 66); box(43, 44, 18, 66); box(75, 76, 34, 66); fx = 14;
      for (let y = 6; y < FL; y++) for (let x = 1; x <= 6; x++) g[y * W + x] = 1;
    } else { box(24, 71, 44, 45); box(90, 91, 30, 45); } };
  const ui = PX.ui(host);
  ui.select('Alat', [{ label: 'tuang air', value: 'water' }, { label: 'aduk', value: 'stir' }, { label: 'tembok', value: 'wall' }, { label: 'hapus', value: 'erase' }], tool, (v) => (tool = v));
  ui.range('Aliran samping', 1, 8, 1, sim.flow, (v) => (sim.flow = v)); ui.range('Penyamaan tinggi', 0, 12, 1, sim.lvRate, (v) => (sim.lvRate = v)); ui.range('Debit keran', 1, 4, 1, fq, (v) => (fq = v));
  const fc = ui.check('Keran', true, (v) => (faucet = v)); ui.check('Caustic', true, (v) => (caus = v));
  ui.button('Reset', () => build()); ui.button('Bejana berhubungan', () => { build('vessels'); faucet = fc.checked = false; });
  build();
  const impacts = () => {                                                                                       // sel jatuh mendarat -> gelombang, busa, gelembung terseret, percikan
    for (const e of sim.ev) {
      const sp = Math.min(40, e.v), k = e.x >> 2; if (t - cool[k] < 0.12) continue; cool[k] = t;                    // jeda per ~4 kolom: aliran utuh tidak memicu semburan tiap sel
      if (e.water) { surf.splash(e.x, -Math.min(2.2, sp * 0.06)); foam.add(e.x, Math.min(1, sp * 0.04), 3); fizz.burst(e.x, e.y + 2, Math.min(6, 1 + ((sp * 0.15) | 0)), 3, 24); }
      spray(e.x, e.y, sp, Math.min(e.water ? 5 : 3, 1 + ((sp * 0.12) | 0)));
    }
    sim.ev.length = 0;
  };
  const surface = () => {                                                                                       // muka air per kolom: sel teratas yang ditopang, dihaluskan, lalu digeser gelombang pegas
    for (let x = 0; x < W; x++) { y0[x] = top[x] = -1; let y = 0; while (y < H && g[y * W + x] === 0) y++; if (y < H - 1 && g[y * W + x] === 1 && g[(y + 1) * W + x] !== 0) y0[x] = y; }
    for (let x = 0; x < W; x++) {
      const a = y0[x]; if (a < 0) continue;
      const l = x > 0 && y0[x - 1] >= 0 ? y0[x - 1] : a, r = x < W - 1 && y0[x + 1] >= 0 ? y0[x + 1] : a;
      let ty = Math.round(PX.clamp(PX.clamp((l + 2 * a + r) / 4, a - 1, a + 1) - PX.clamp(surf.h[x] * 1.3, -3, 3), a - 3, a + 3));
      while (ty > a && g[ty * W + x] !== 1) ty--; while (ty < a && g[ty * W + x] !== 0) ty++;                      // lembah hanya memotong sel air, puncak hanya mengisi sel kosong
      top[x] = ty;
    }
  };
  PX.loop(host, (dt) => {
    t += dt; const mx = p.x | 0, my = p.y | 0, sp = Math.hypot(mx - px0, my - py0);
    if (p.down && p.in) for (let dy = -2; dy <= 2; dy++) for (let dx = -2; dx <= 2; dx++) {
      const x = mx + dx, y = my + dy, i = y * W + x; if (x < 1 || y < 0 || x >= W - 1 || y >= H - 2 || dx * dx + dy * dy > 5) continue;
      if (tool === 'water' && g[i] === 0) g[i] = 1; else if (tool === 'wall' && g[i] !== 2) g[i] = 2; else if (tool === 'erase') g[i] = 0;
    }
    if (p.down && p.in && tool === 'stir' && sp > 0.3) {                                                           // kobok: geser sel air acak searah seretan + gelembung + riak + percikan
      const sx = Math.sign(mx - px0), sy = Math.sign(my - py0);
      for (let k = 0; k < 14; k++) {
        const x = mx + ((R() * 7 - 3.5) | 0), y = my + ((R() * 7 - 3.5) | 0); if (x < 1 || x >= W - 1 || y < 0 || y >= H - 2 || g[y * W + x] !== 1) continue;
        const nx = x + (R() < 0.6 ? sx : ((R() * 3 - 1) | 0)), ny = y + (R() < 0.6 ? sy : ((R() * 3 - 1) | 0));
        if (nx > 0 && nx < W - 1 && ny >= 0 && ny < H - 2 && g[ny * W + nx] === 0) { g[ny * W + nx] = 1; g[y * W + x] = 0; sim.fall[ny * W + nx] = 0; }
      }
      fizz.burst(mx, my, Math.min(3, 1 + ((sp * 0.6) | 0)), 3, 6);
      const ts = top[mx]; if (ts >= 0 && Math.abs(my - ts) < 7) { surf.splash(mx, -Math.min(1.2, sp * 0.1)); foam.add(mx, Math.min(0.6, sp * 0.05), 3); if (R() < Math.min(1, sp * 0.1)) spray(mx, ts, sp * 2, 1); }
    }
    px0 = mx; py0 = my;
    for (let k = 0; k < 2; k++) { if (faucet) for (let x = fx; x < fx + fq; x++) if (g[2 * W + x] === 0) { g[2 * W + x] = 1; sim.fall[2 * W + x] = 0; } sim.step(); }   // keran menyuntik tiap langkah -> aliran utuh
    for (let k = 0; k < sim.lv.length; k += 2) { surf.splash(sim.lv[k], -0.04); surf.splash(sim.lv[k + 1], 0.04); }      // penyamaan tinggi: muka asal turun, muka tujuan naik -> ombak
    sim.lv.length = 0;
    for (let x = 0; x < W; x++) surf.v[x] = PX.clamp(surf.v[x], -1.5, 1.5);
    impacts(); surf.update(dt); surface(); fizz.update(dt); foam.update(dt);
    for (let i = drops.length - 1; i >= 0; i--) {                                                                 // tetes: gravitasi; jatuh ke air = riak + busa + kadang gelembung
      const d = drops[i]; d.vy += 220 * dt; d.x += d.vx * dt; d.y += d.vy * dt; const ix = Math.round(d.x), iy = Math.round(d.y);
      if (ix < 0 || ix >= W || iy >= H) { drops.splice(i, 1); continue; }
      if (iy >= 0) { const v = g[iy * W + ix]; if (v === 2) drops.splice(i, 1); else if (v === 1 && d.vy > 0 && inW(ix, iy)) { surf.splash(ix, -0.12); foam.add(ix, 0.12, 1); if (R() < 0.3) fizz.burst(ix, iy + 1, 1 + ((R() * 2) | 0), 1.5, 14); drops.splice(i, 1); } }
    }
    dp.fill(0); lit.clear();
    for (let y = 0; y < H; y++) for (let x = 0; x < W; x++) {
      const o = y * W + x, v = g[o], tx = top[x], open = tx >= 0;
      if (v === 2) { buf.px(x, y, STONE[PX.tone(0.25 + 0.6 * PX.hash(x >> 1, y >> 1, 3), x, y, 3, 0.25)]); dp[x] = 0; continue; }
      const w = open ? (v === 1 && y >= tx) || (v === 0 && y >= tx && y < y0[x]) : v === 1;
      if (!w) { buf.d[o] = sc.sky.d[o]; dp[x] = 0; continue; }
      const d = y > 0 && g[o - W] === 2 ? 4 : dp[x]; dp[x] = d + 1;                                              // d = kedalaman dari muka; di bawah tembok dianggap dalam (tanpa kilau muka)
      buf.d[o] = sc.under.d[o];
      if (!open && y < H - 1 && g[o + W] === 0) { buf.px(x, y, (x + y) & 1 ? P.LIT : P.RIM); continue; }       // sel jatuh bebas (aliran/tetes) = terang
      buf.px(x, y, P.TINT, TA[PX.tone(1 - Math.min(1, d / 14), x, y, 4, 0.25)]);
      if (d === 0) { const glint = Math.sin(x * 0.37 - t * 2.3) * Math.sin(x * 0.13 + t * 1.1) > 0.55; buf.px(x, y, glint ? P.HI : P.LIT); if ((x & 3) === 0) lit.glow(x, y + 2, 7, P.LIT, glint ? 0.2 : 0.09); }
      else if (d === 1) buf.px(x, y, P.RIM);
      else if (d < 7 && PX.bayer(x, y) < 0.5 - d * 0.07) buf.px(x, y, P.SHADE);
    }
    foam.draw(buf, (x) => top[x]); fizz.draw(buf, lit);
    for (const d of drops) { buf.px(d.x, d.y, P.HI); buf.px(d.x - d.vx * 0.02, d.y - d.vy * 0.02, P.LIT); }
    if (caus) wat.caustics(lit, t, (x, y) => y < H && inW(x, y));
    buf.flush(c); lit.flush(c);
  });
};