Back to Safe Sand · Material studies
SOURCE / PINNED RELEASE

Made of little things.

Safe Sand · Material studies

Release
9101a9cd9fb4…
Author-recorded commit
75d98e1b8edb…
License
LICENSE
Author’s source reference
nostr://npub182jczunncwe0jn6frpqwq3e0qjws7yqqnc3auccqv9nte2dnd63scjm4rf/wss%3A%2F%2Fgit.napplet.soy%2F/n-7682e55a791

Archive hash verified: 6c8ddabeb31100e1…. The source-to-build association is the author’s claim; it has not been independently rebuilt.

src/engine/sim.js
// Adapted from av/safe-sand, e27bafa. See THIRD_PARTY.md for provenance.
import { program, texture, framebuffer, setUniforms, bindTex } from './gl.js';
import * as S from './sim-shaders.js';

export const TW = 512;
const CELL = 0.5;

function mulberry(seed) {
  return () => {
    seed |= 0; 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;
  };
}

function makeNoise(rand) {
  const P = 256;
  const g = new Float32Array(P * P);
  for (let i = 0; i < g.length; i++) g[i] = rand();
  const at = (x, y) => g[((y & (P - 1)) * P) + (x & (P - 1))];
  const n = (x, y) => {
    const xi = Math.floor(x), yi = Math.floor(y);
    const fx = x - xi, fy = y - yi;
    const ux = fx * fx * (3 - 2 * fx), uy = fy * fy * (3 - 2 * fy);
    const a = at(xi, yi), b = at(xi + 1, yi), c = at(xi, yi + 1), d = at(xi + 1, yi + 1);
    return a + (b - a) * ux + (c - a) * uy + (a - b - c + d) * ux * uy;
  };
  return (x, y, oct = 5) => {
    let s = 0, amp = 0.5, f = 1, norm = 0;
    for (let o = 0; o < oct; o++) { s += amp * n(x * f + o * 17.3, y * f - o * 9.1); norm += amp; amp *= 0.5; f *= 2.03; }
    return s / norm;
  };
}

export class SandSim {
  constructor(gl, floatBlend) {
    this.gl = gl;
    this.floatBlend = floatBlend;
    this.frame = 0;
    this.vao = gl.createVertexArray();
    this.progs = {
      splat: program(gl, S.SPLAT_VS, S.SPLAT_FS, 'splat'),
      grid: program(gl, S.QUAD_VS, S.GRID_FS, 'grid'),
      hblur: program(gl, S.QUAD_VS, S.HBLUR_FS, 'hblur'),
      grad: program(gl, S.QUAD_VS, S.GRAD_FS, 'grad'),
      step: program(gl, S.QUAD_VS, S.STEP_FS, 'step'),
      stats: program(gl, S.QUAD_VS, S.STATS_FS, 'stats'),
    };
    this.statsTex = texture(gl, 16, 16, gl.RGBA32F, gl.RGBA, gl.FLOAT, gl.NEAREST);
    this.statsFb = framebuffer(gl, [this.statsTex]);
    this.pbo = gl.createBuffer();
    gl.bindBuffer(gl.PIXEL_PACK_BUFFER, this.pbo);
    gl.bufferData(gl.PIXEL_PACK_BUFFER, 16 * 16 * 16, gl.STREAM_READ);
    gl.bindBuffer(gl.PIXEL_PACK_BUFFER, null);
    this.statsBuf = new Float32Array(16 * 16 * 4);
    this.fence = null;
    this.stats = { volume: 0, flow: 0, coverage: 0 };
    this.spawn = [0, 0, 0, 0];
    this.spawnRadius = 4.5;
    this.spawnHeight = 12;
    this.tool = 0;
  }

  resize(worldW, worldH, count) {
    const gl = this.gl;
    if (this.state) {
      for (const s of this.state) { gl.deleteTexture(s.A); gl.deleteTexture(s.B); gl.deleteFramebuffer(s.fb); }
      for (const k of ['splatTex', 'G0', 'G1', 'T0', 'T1']) gl.deleteTexture(this[k]);
      for (const k of ['splatFb', 'gridFb', 'T0fb', 'T1fb', 'G0fb']) gl.deleteFramebuffer(this[k]);
      if (this.fence) { gl.deleteSync(this.fence); this.fence = null; }
    }
    this.world = [worldW, worldH];
    this.TH = Math.max(1, Math.ceil(count / TW));
    this.count = TW * this.TH;
    this.active = this.count;
    this.GW = Math.ceil(worldW / CELL);
    this.GH = Math.ceil(worldH / CELL);
    this.cell = [worldW / this.GW, worldH / this.GH];
    const mk = () => texture(gl, TW, this.TH, gl.RGBA32F, gl.RGBA, gl.FLOAT, gl.NEAREST);
    this.state = [0, 1].map(() => {
      const A = mk(), B = mk();
      return { A, B, fb: framebuffer(gl, [A, B]) };
    });
    this.cur = 0;
    const splatFmt = this.floatBlend ? gl.RGBA32F : gl.RGBA16F;
    const splatType = this.floatBlend ? gl.FLOAT : gl.HALF_FLOAT;
    this.splatTex = texture(gl, this.GW, this.GH, splatFmt, gl.RGBA, splatType, gl.NEAREST);
    this.splatFb = framebuffer(gl, [this.splatTex]);
    this.G0 = texture(gl, this.GW, this.GH, gl.RGBA16F, gl.RGBA, gl.HALF_FLOAT, gl.LINEAR);
    this.G1 = texture(gl, this.GW, this.GH, gl.RGBA16F, gl.RGBA, gl.HALF_FLOAT, gl.LINEAR);
    this.gridFb = framebuffer(gl, [this.G0, this.G1]);
    this.T0 = texture(gl, this.GW, this.GH, gl.RGBA16F, gl.RGBA, gl.HALF_FLOAT, gl.NEAREST);
    this.T1 = texture(gl, this.GW, this.GH, gl.RGBA16F, gl.RGBA, gl.HALF_FLOAT, gl.NEAREST);
    this.T0fb = framebuffer(gl, [this.T0]);
    this.T1fb = framebuffer(gl, [this.T1]);
    this.G0fb = framebuffer(gl, [this.G0]);
  }

  setPreset(p) {
    this.preset = p;
  }

  presetUniforms() {
    const p = this.preset;
    return { uPal: { type: 'v4a', v: p.palette }, uSize: p.size, uSize2: p.size2, uFric: p.fric, uMaterial: p.material || [0, 0, 0.08, 0], uMagnet: this.magnet ? 1 : 0 };
  }

  pour({ seed = 1, intro = true, amount = 1 } = {}) {
    const gl = this.gl;
    const [W, H] = this.world;
    const rand = mulberry(seed * 7919 + 13);
    const noise = makeNoise(rand);
    const DW = 96, DH = Math.round(96 * H / W);
    const dens = new Float32Array(DW * DH);
    const cx = 0.5 + (rand() - 0.5) * 0.1, cy = 0.47 + (rand() - 0.5) * 0.1;
    const aspect = H / W;
    let total = 0;
    for (let y = 0; y < DH; y++) {
      for (let x = 0; x < DW; x++) {
        const u = (x + 0.5) / DW, v = (y + 0.5) / DH;
        const dx = (u - cx), dy = (v - cy) * aspect;
        const r = Math.sqrt(dx * dx + dy * dy * 0.42) / 0.56;
        const n = noise(u * 3.1 * W / 60, v * 3.1 * H / 60, 5);
        const fine = noise(u * 14, v * 14 * aspect, 3);
        const mid = noise(u * W / 9 + 40, v * H / 9 - 20, 3);
        let d = (n - 0.47) * 3.2 + (1 - r) * 1.9 - 0.25;
        d = Math.min(1, Math.max(0, d * 1.05));
        d = d * d * (3 - 2 * d) * (0.45 + 0.7 * fine) * (0.35 + 1.3 * mid);
        d = d + 0.004 + 0.03 * Math.max(0, 1 - r * 0.8);
        dens[y * DW + x] = d;
        total += d;
      }
    }
    const cdf = new Float32Array(DW * DH);
    let acc = 0;
    for (let i = 0; i < cdf.length; i++) { acc += dens[i] / total; cdf[i] = acc; }
    const n = this.count;
    const A = new Float32Array(n * 4);
    const B = new Float32Array(n * 4);
    const live = Math.floor(n * amount);
    const PS = 240, path = new Float32Array(PS * 2);
    const turns = 2.2 + rand() * 0.8, ph = rand() * 6.283;
    for (let k = 0; k < PS; k++) {
      const t = k / (PS - 1);
      const rad = 0.03 + 0.36 * Math.sqrt(t);
      const th = ph + 6.283 * turns * t;
      const wob = 1 + 0.25 * (noise(t * 6, 3.7, 2) - 0.5);
      path[k * 2] = (cx + Math.cos(th) * rad * wob) * W;
      path[k * 2 + 1] = (cy + Math.sin(th) * rad * wob * 0.62 / aspect * 1.6) * H;
    }
    const POUR = 1.35;
    for (let i = 0; i < n; i++) {
      if (i >= live) { B[i * 4 + 3] = 0; A[i * 4] = -10; continue; }
      const t = rand();
      let lo = 0, hi = cdf.length - 1;
      while (lo < hi) { const m = (lo + hi) >> 1; if (cdf[m] < t) lo = m + 1; else hi = m; }
      const cxi = lo % DW, cyi = (lo / DW) | 0;
      const x = (cxi + rand()) / DW * W;
      const y = (cyi + rand()) / DH * H;
      A[i * 4] = Math.min(W - 0.3, Math.max(0.3, x));
      A[i * 4 + 1] = Math.min(H - 0.3, Math.max(0.3, y));
      A[i * 4 + 2] = 0;
      A[i * 4 + 3] = 0;
      if (intro) {
        let best = 0, bd = 1e9;
        for (let k = 0; k < PS; k++) {
          const ex = path[k * 2] - x, ey = path[k * 2 + 1] - y;
          const dd = ex * ex + ey * ey;
          if (dd < bd) { bd = dd; best = k; }
        }
        const z = 25 + rand() * 65;
        const vz0 = -200 - rand() * 250;
        const tf = (vz0 + Math.sqrt(vz0 * vz0 + 2 * 9810 * z)) / 9810;
        const a = rand() * 6.283, sp = 4 + 18 * rand();
        A[i * 4 + 2] = Math.cos(a) * sp;
        A[i * 4 + 3] = Math.sin(a) * sp;
        A[i * 4] = x - A[i * 4 + 2] * tf;
        A[i * 4 + 1] = y - A[i * 4 + 3] * tf;
        const delay = 0.05 + POUR * best / (PS - 1) + 0.18 * rand() + Math.sqrt(bd) * 0.012;
        B.set([z, vz0, rand(), 1 + delay], i * 4);
      } else {
        B.set([0, 0, rand(), 1], i * 4);
      }
    }
    for (const s of this.state) {
      gl.bindTexture(gl.TEXTURE_2D, s.A);
      gl.texSubImage2D(gl.TEXTURE_2D, 0, 0, 0, TW, this.TH, gl.RGBA, gl.FLOAT, A);
      gl.bindTexture(gl.TEXTURE_2D, s.B);
      gl.texSubImage2D(gl.TEXTURE_2D, 0, 0, 0, TW, this.TH, gl.RGBA, gl.FLOAT, B);
    }
  }

  get A() { return this.state[this.cur].A; }
  get B() { return this.state[this.cur].B; }

  splat() {
    const gl = this.gl;
    const pr = this.progs.splat;
    gl.bindFramebuffer(gl.FRAMEBUFFER, this.splatFb);
    gl.viewport(0, 0, this.GW, this.GH);
    gl.clearColor(0, 0, 0, 0);
    gl.clear(gl.COLOR_BUFFER_BIT);
    gl.enable(gl.BLEND);
    gl.blendFunc(gl.ONE, gl.ONE);
    gl.useProgram(pr.p);
    bindTex(gl, pr, 'uA', this.A, 0);
    bindTex(gl, pr, 'uB', this.B, 1);
    setUniforms(gl, pr, { ...this.presetUniforms(), uTW: { type: 'i', v: TW }, uWorld: this.world, uGrid: [this.GW, this.GH] });
    gl.bindVertexArray(this.vao);
    gl.drawArrays(gl.POINTS, 0, this.active);
    gl.disable(gl.BLEND);

    const gp = this.progs.grid;
    gl.bindFramebuffer(gl.FRAMEBUFFER, this.gridFb);
    gl.useProgram(gp.p);
    bindTex(gl, gp, 'uS', this.splatTex, 0);
    gl.drawArrays(gl.TRIANGLES, 0, 3);

    const hb = this.progs.hblur;
    gl.useProgram(hb.p);
    gl.bindFramebuffer(gl.FRAMEBUFFER, this.T0fb);
    bindTex(gl, hb, 'uT', this.G0, 0);
    gl.uniform2i(hb.u.uDir, 1, 0);
    gl.drawArrays(gl.TRIANGLES, 0, 3);
    gl.bindFramebuffer(gl.FRAMEBUFFER, this.T1fb);
    bindTex(gl, hb, 'uT', this.T0, 0);
    gl.uniform2i(hb.u.uDir, 0, 1);
    gl.drawArrays(gl.TRIANGLES, 0, 3);

    const gr = this.progs.grad;
    gl.useProgram(gr.p);
    gl.bindFramebuffer(gl.FRAMEBUFFER, this.G0fb);
    bindTex(gl, gr, 'uT', this.T1, 0);
    setUniforms(gl, gr, { uCell: this.cell });
    gl.drawArrays(gl.TRIANGLES, 0, 3);
  }

  step(dt, grav, fingers) {
    const gl = this.gl;
    const pr = this.progs.step;
    const next = 1 - this.cur;
    gl.bindFramebuffer(gl.FRAMEBUFFER, this.state[next].fb);
    gl.viewport(0, 0, TW, this.TH);
    gl.useProgram(pr.p);
    bindTex(gl, pr, 'uA', this.A, 0);
    bindTex(gl, pr, 'uB', this.B, 1);
    bindTex(gl, pr, 'uG0', this.G0, 2);
    bindTex(gl, pr, 'uG1', this.G1, 3);
    const FA = new Float32Array(20), FB = new Float32Array(20);
    fingers.slice(0, 5).forEach((f, i) => {
      FA.set([f.x0, f.y0, f.x1, f.y1], i * 4);
      FB.set([f.vx, f.vy, f.r, 1], i * 4);
    });
    setUniforms(gl, pr, {
      ...this.presetUniforms(),
      uTW: { type: 'i', v: TW }, uWorld: this.world, uDt: dt, uGrav: grav,
      uFA: { type: 'v4a', v: FA }, uFB: { type: 'v4a', v: FB },
      uFrame: { type: 'u', v: (this.frame++) >>> 0 },
      uTool: this.tool, uSpawn: this.spawn,
      uSpawnConfig: [this.spawnRadius, this.spawnHeight, 0, this.count],
    });
    gl.bindVertexArray(this.vao);
    gl.drawArrays(gl.TRIANGLES, 0, 3);
    this.cur = next;
    this.spawn = [0, 0, 0, 0];
  }

  readStats() {
    const gl = this.gl;
    if (this.fence) {
      const st = gl.clientWaitSync(this.fence, 0, 0);
      if (st === gl.TIMEOUT_EXPIRED) return this.stats;
      gl.deleteSync(this.fence);
      this.fence = null;
      gl.bindBuffer(gl.PIXEL_PACK_BUFFER, this.pbo);
      gl.getBufferSubData(gl.PIXEL_PACK_BUFFER, 0, this.statsBuf);
      gl.bindBuffer(gl.PIXEL_PACK_BUFFER, null);
      let vol = 0, flow = 0, cov = 0;
      for (let i = 0; i < 256; i++) { vol += this.statsBuf[i * 4]; flow += this.statsBuf[i * 4 + 1]; cov += this.statsBuf[i * 4 + 2]; }
      const ca = this.cell[0] * this.cell[1];
      this.stats = { volume: vol * ca, flow: flow * ca, coverage: cov / (this.GW * this.GH) };
    }
    const pr = this.progs.stats;
    gl.bindFramebuffer(gl.FRAMEBUFFER, this.statsFb);
    gl.viewport(0, 0, 16, 16);
    gl.useProgram(pr.p);
    bindTex(gl, pr, 'uS', this.splatTex, 0);
    gl.bindVertexArray(this.vao);
    gl.drawArrays(gl.TRIANGLES, 0, 3);
    gl.bindBuffer(gl.PIXEL_PACK_BUFFER, this.pbo);
    // Orphan the previous readback allocation before the next GPU write.
    gl.bufferData(gl.PIXEL_PACK_BUFFER, 16 * 16 * 16, gl.STREAM_READ);
    gl.readPixels(0, 0, 16, 16, gl.RGBA, gl.FLOAT, 0);
    gl.bindBuffer(gl.PIXEL_PACK_BUFFER, null);
    this.fence = gl.fenceSync(gl.SYNC_GPU_COMMANDS_COMPLETE, 0);
    gl.flush();
    return this.stats;
  }
}