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/sandbox.js
import { createContext } from './gl.js';
import { SandSim, TW } from './sim.js';
import { Renderer } from './render.js';
import { PRESETS } from './presets.js';
import { TrayCamera } from './camera.js';
import { CoordinatedAudio } from '../coordinated-audio.js';

// Local GPU orchestration with optional host-coordinated synthesized audio.
export class Sandbox {
  constructor(canvas, media, onAudioChange) {
    this.media = media; this.onAudioChange = onAudioChange;
    this.canvas = canvas;
    const { gl, floatBlend } = createContext(canvas);
    this.gl = gl;
    this.sim = new SandSim(gl, floatBlend);
    this.renderer = new Renderer(gl);
    this.camera = new TrayCamera();
    this.material = 'beach';
    this.surface = 'light';
    this.amount = 0.8;
    this.radius = 4.75;
    this.tool = 'push';
    this.speed = 1;
    this.flowRate = 12000;
    this.pourHeight = 12;
    this.spawnCursor = 0;
    this.spawnRemainder = 0;
    this.undoState = null;
    this.paused = matchMedia('(prefers-reduced-motion: reduce)').matches;
    this.keys = new Set();
    this.pointers = new Map();
    this.tilt = [0, 0];
    this.pad = [0, 0];
    this.shakeUntil = 0;
    this.last = 0;
    this.raf = 0;
    this.seed = 24;
    this.steps = 0;
    this.quality = 0;
    this.slowFrames = 0;
    this.live = true;
    this.visible = !document.hidden;
    this.skipped = false;
    this.sound = null;
    this.resize();
    this.pour(false, false);
    this.draw();
    this.schedule();
  }

  resize() {
    const w = Math.max(1, this.canvas.clientWidth), h = Math.max(1, this.canvas.clientHeight);
    if (w === this.width && h === this.height && this.dpr === this.targetDpr()) return;
    this.clearInput(); this.hoverPoint = null;
    const old = this.sim.world ? this.readParticles() : null;
    const world = this.sim.world?.slice();
    this.width = w; this.height = h; this.dpr = this.targetDpr();
    this.canvas.width = Math.round(w * this.dpr);
    this.canvas.height = Math.round(h * this.dpr);
    this.renderer.resize(this.canvas.width, this.canvas.height, Math.min(1, 1 / this.dpr));
    const scale = matchMedia('(pointer: coarse)').matches ? 0.16 : 0.23;
    this.mmPerPx = scale;
    const W = Math.max(10, w * scale), H = Math.max(10, h * scale);
    // Hosts can begin with a tiny iframe and expand it after boot. A fixed
    // initial budget avoids permanently sparse trays after that first resize.
    const count = this.sim.count || (matchMedia('(pointer: coarse)').matches ? 90000 : 180000);
    this.sim.resize(W, H, count);
    this.sim.setPreset(PRESETS[this.material]);
    if (old) {
      for (let i = 0; i < this.sim.count; i++) {
        old.A[i * 4] *= W / world[0];
        old.A[i * 4 + 1] *= H / world[1];
        old.A[i * 4 + 2] = old.A[i * 4 + 3] = 0;
      }
      this.upload(old);
    }
    this.setSurface(this.surface);
    this.draw();
  }

  targetDpr() { return Math.min(devicePixelRatio || 1, this.quality ? 1 : 1.75); }

  setSurface(key) {
    this.surface = key;
    const c = document.createElement('canvas');
    c.width = 256; c.height = 256;
    const ctx = c.getContext('2d');
    const colors = { light: ['#eeece5', '#dcdad2'], dark: ['#1b1b20', '#0d0e13'], slate: ['#526371', '#35434f'] };
    const pair = colors[key] || colors.light;
    const g = ctx.createLinearGradient(0, 0, 256, 256);
    g.addColorStop(0, pair[0]); g.addColorStop(1, pair[1]);
    ctx.fillStyle = g; ctx.fillRect(0, 0, 256, 256);
    this.renderer.setScreen(c);
    this.renderer.look.screenGain = key === 'dark' ? 0.7 : 1;
    this.renderer.look.shadowK = key === 'light' ? 0.44 : 0.3;
    this.draw();
  }

  setMaterial(key) {
    if (!PRESETS[key]) return;
    this.checkpoint();
    this.material = key;
    this.sim.setPreset(PRESETS[key]);
    this.sim.magnet = false;
    if (this.tool === 'magnet' || this.tool === 'repel') this.setTool('push');
    this.pour(!this.paused, false);
  }

  pour(intro = true, remember = true) {
    if (remember && this.sim.world) this.checkpoint();
    this.clearInput();
    const amount = this.amount * (this.material === 'glass' ? 0.5 : this.material === 'salt' ? 0.65 : 1);
    this.sim.pour({ seed: this.seed++, intro: intro && !this.paused, amount });
    this.spawnCursor = Math.floor(this.sim.count * amount) % this.sim.count;
    this.draw();
  }

  clear() { this.checkpoint(); this.clearInput(); this.sim.pour({ intro: false, amount: 0 }); this.spawnCursor = 0; this.draw(); }
  shake() { this.shakeUntil = performance.now() + 650; }
  clearInput() { this.keys?.clear(); this.pointers?.clear(); this.pad = [0, 0]; this.tilt = [0, 0]; }

  setTool(tool) {
    if (!['push', 'pour', 'rake', 'erase', 'magnet', 'repel', 'camera'].includes(tool)) return;
    if ((tool === 'magnet' || tool === 'repel') && this.material !== 'iron') return;
    this.clearInput(); this.tool = tool;
    this.sim.tool = { push: 0, pour: 1, rake: 2, erase: 3, magnet: 4, repel: 5, camera: 6 }[tool];
    this.sim.magnet = tool === 'magnet' || tool === 'repel';
  }

  setCamera(pitch, yaw) { this.camera.set(pitch, yaw); this.draw(); }

  checkpoint() {
    if (!this.sim.world) return;
    this.undoState = { ...this.readParticles(), world: [...this.sim.world], material: this.material, surface: this.surface, cursor: this.spawnCursor };
  }

  undo() {
    const s = this.undoState;
    if (!s) return false;
    this.clearInput();
    for (let i = 0; i < this.sim.count; i++) {
      s.A[i * 4] *= this.sim.world[0] / s.world[0];
      s.A[i * 4 + 1] *= this.sim.world[1] / s.world[1];
    }
    this.material = s.material; this.sim.setPreset(PRESETS[s.material]);
    this.setTool('push'); this.spawnCursor = s.cursor;
    this.upload(s); this.setSurface(s.surface); this.undoState = null; this.draw();
    return true;
  }

  singleStep() { if (this.paused) { this.frame(1 / 60); this.draw(); } }

  pointer(id, x, y, start = false) {
    if (this.tool === 'camera') {
      const previous = this.pointers.get(id);
      if (start) this.pointers.set(id, { screen: [x, y], pitch: this.camera.pitch, yaw: this.camera.yaw });
      else if (previous) this.setCamera(previous.pitch + (y - previous.screen[1]) * 0.18, previous.yaw + (x - previous.screen[0]) * 0.18);
      return;
    }
    const p = this.camera.unproject(x / this.width, y / this.height, this.sim.world);
    if (p[0] < 0 || p[1] < 0 || p[0] > this.sim.world[0] || p[1] > this.sim.world[1]) {
      this.pointers.delete(id); return;
    }
    this.hoverPoint = p;
    const old = this.pointers.get(id);
    if (start) {
      if (!this.pointers.size) this.checkpoint();
      this.pointers.set(id, { last: p, current: p });
    }
    else if (old) old.current = p;
  }

  endPointer(id, cancelled = false) {
    const p = this.pointers.get(id);
    if (!p) return;
    if (this.tool === 'camera' || cancelled) this.pointers.delete(id);
    else p.ended = true; // Preserve a quick tap until one simulation frame sees it.
  }

  frame(dt) {
    dt *= this.speed;
    const k = this.keys;
    const tx = Number(k.has('ArrowRight') || k.has('d')) - Number(k.has('ArrowLeft') || k.has('a'));
    const ty = Number(k.has('ArrowDown') || k.has('s')) - Number(k.has('ArrowUp') || k.has('w'));
    for (let i = 0; i < 2; i++) {
      const target = (i ? ty : tx) || this.pad[i];
      this.tilt[i] += (target - this.tilt[i]) * Math.min(1, dt * 7);
    }
    const shaking = performance.now() < this.shakeUntil || k.has(' ');
    const grav = [this.tilt[0] * 8800, this.tilt[1] * 8800, 9810, shaking ? 5 : 0];
    if (shaking) { grav[0] += (Math.random() - 0.5) * 22000; grav[1] += (Math.random() - 0.5) * 22000; }
    let fingers = [...this.pointers.values()].filter(p => p.current).slice(0, 5).map(p => ({
      x0: p.last[0], y0: p.last[1], x1: p.current[0], y1: p.current[1],
      vx: (p.current[0] - p.last[0]) / Math.max(dt, 0.001),
      vy: (p.current[1] - p.last[1]) / Math.max(dt, 0.001), r: this.radius,
    }));
    if (this.tool === 'pour' && !fingers.length && k.has('f')) {
      const p = this.hoverPoint || this.sim.world.map(v => v / 2);
      fingers = [{ x0: p[0], y0: p[1], x1: p[0], y1: p[1], vx: 0, vy: 0, r: this.radius }];
    }
    if (this.tool === 'pour' && fingers.length) {
      const f = fingers[0];
      this.spawnRemainder += dt * this.flowRate;
      const count = Math.floor(this.spawnRemainder); this.spawnRemainder -= count;
      this.sim.spawn = [this.spawnCursor, count, f.x1, f.y1];
      this.sim.spawnRadius = this.radius * 0.7; this.sim.spawnHeight = this.pourHeight;
      this.spawnCursor = (this.spawnCursor + count) % this.sim.count;
      fingers = [];
    } else if (this.tool === 'rake' && fingers.length) {
      const f = fingers[0], length = Math.hypot(f.vx, f.vy);
      const nx = length > 4 ? -f.vy / length : 1, ny = length > 4 ? f.vx / length : 0;
      fingers = [-2, -1, 0, 1, 2].map(i => ({ ...f,
        x0: f.x0 + nx * i * this.radius * 0.65, x1: f.x1 + nx * i * this.radius * 0.65,
        y0: f.y0 + ny * i * this.radius * 0.65, y1: f.y1 + ny * i * this.radius * 0.65,
        r: Math.max(0.4, this.radius * 0.12),
      }));
    }
    this.sim.splat();
    for (let s = 0; s < 3; s++) {
      if (s === 2) this.sim.splat();
      this.sim.step(dt / 3, grav, fingers.map(f => ({ ...f,
        x0: f.x0 + (f.x1 - f.x0) * s / 3, y0: f.y0 + (f.y1 - f.y0) * s / 3,
        x1: f.x0 + (f.x1 - f.x0) * (s + 1) / 3, y1: f.y0 + (f.y1 - f.y0) * (s + 1) / 3,
      })));
    }
    for (const [id, p] of this.pointers) {
      if (p.ended) this.pointers.delete(id);
      else if (p.current) p.last = [...p.current];
    }
    this.steps++;
    if (this.steps % 8 === 0) {
      const stats = this.sim.readStats();
      this.flow = stats.flow / Math.max(1, this.sim.world[0] * this.sim.world[1]);
      this.updateSound();
    }
    this.renderer.draw(this.sim, this.light(), this.camera);
  }

  light() {
    const v = [-0.32 - this.tilt[0] * 0.3, -0.45 - this.tilt[1] * 0.3, 1.2];
    const length = Math.hypot(...v);
    return v.map(x => x / length);
  }

  draw() {
    if (!this.sim?.world || !this.sim.preset || !this.live) return;
    this.sim.splat();
    this.renderer.draw(this.sim, this.light(), this.camera);
  }

  schedule() {
    if (!this.raf && this.live && this.visible && !this.skipped && !this.paused) {
      this.raf = requestAnimationFrame(t => {
        this.raf = 0;
        const elapsed = this.last ? t - this.last : 16.7;
        this.last = t;
        // Lower render resolution, never discard grains or reset the artwork.
        if (elapsed > 45) this.slowFrames++; else this.slowFrames = Math.max(0, this.slowFrames - 1);
        if (this.slowFrames > 160 && !this.quality && this.dpr > 1) { this.quality = 1; this.resize(); }
        this.frame(Math.min(elapsed / 1000, 1 / 30));
        this.schedule();
      });
    }
  }

  suspend() {
    cancelAnimationFrame(this.raf); this.raf = 0; this.last = 0;
    this.clearInput();
    if (this.sound) this.sound.gain.gain.setTargetAtTime(0, this.sound.ctx.currentTime, 0.02);
  }

  setPaused(paused) {
    this.paused = paused;
    if (paused) this.suspend(); else this.schedule();
    this.draw();
  }

  get soundOn() { return Boolean(this.soundRequested && this.audio?.audible); }

  async setSound(on) {
    if (on && !this.media) throw new Error('Sound needs host media support.');
    this.soundRequested = on;
    if (on && !this.sound) {
      this.audio = new CoordinatedAudio(this.media, 'Safe Sand', { onChange: () => { this.updateSound(); this.onAudioChange?.(); } });
      this.audio.activate();
      const ctx = this.audio.context;
      if (!ctx || !this.audio.output) throw new Error('Audio could not start.');
      const buffer = ctx.createBuffer(1, ctx.sampleRate * 2, ctx.sampleRate);
      const data = buffer.getChannelData(0);
      for (let i = 0; i < data.length; i++) data[i] = (Math.random() * 2 - 1) * (Math.random() < 0.12 ? 1 : 0.08);
      const source = ctx.createBufferSource(); source.buffer = buffer; source.loop = true;
      const filter = ctx.createBiquadFilter(); filter.type = 'bandpass'; filter.frequency.value = 3000; filter.Q.value = 0.6;
      const gain = ctx.createGain(); gain.gain.value = 0;
      source.connect(filter).connect(gain).connect(this.audio.output); source.start();
      this.sound = { ctx, gain, filter, source };
    }
    if (this.sound) {
      this.audio.setMuted(!on);
      if (on) this.audio.activate();
      this.updateSound();
    }
  }

  resetSound() {
    this.soundRequested = false;
    if (this.sound) { this.sound.source.stop(); this.sound = null; }
    this.audio?.dispose(); this.audio = null;
  }

  updateSound() {
    if (!this.sound) return;
    const moving = this.soundOn && !this.paused && this.visible && !this.skipped;
    const volume = moving ? Math.min(0.23, Math.max(0, (this.flow || 0) - 0.1) / 180) : 0;
    this.sound.gain.gain.setTargetAtTime(volume, this.sound.ctx.currentTime, 0.07);
    this.sound.filter.frequency.setTargetAtTime(this.material === 'glass' || this.material === 'salt' ? 4300 : this.material === 'snow' ? 1600 : 3000, this.sound.ctx.currentTime, 0.1);
  }

  readParticles() {
    const gl = this.gl, sim = this.sim;
    gl.bindFramebuffer(gl.FRAMEBUFFER, sim.state[sim.cur].fb);
    const A = new Float32Array(sim.count * 4), B = new Float32Array(sim.count * 4);
    gl.readBuffer(gl.COLOR_ATTACHMENT0); gl.readPixels(0, 0, TW, sim.TH, gl.RGBA, gl.FLOAT, A);
    gl.readBuffer(gl.COLOR_ATTACHMENT1); gl.readPixels(0, 0, TW, sim.TH, gl.RGBA, gl.FLOAT, B);
    gl.readBuffer(gl.COLOR_ATTACHMENT0); gl.bindFramebuffer(gl.FRAMEBUFFER, null);
    return { A, B };
  }

  upload({ A, B }) {
    const gl = this.gl;
    for (const state of this.sim.state) {
      for (const [key, data] of [['A', A], ['B', B]]) {
        gl.bindTexture(gl.TEXTURE_2D, state[key]);
        gl.texSubImage2D(gl.TEXTURE_2D, 0, 0, 0, TW, this.sim.TH, gl.RGBA, gl.FLOAT, data);
      }
    }
  }

  info() {
    return { material: this.material, paused: this.paused, count: this.sim.count, steps: this.steps, world: [...this.sim.world], surface: this.surface, quality: this.quality, sound: Boolean(this.soundOn), magnet: Boolean(this.sim.magnet), tool: this.tool, speed: this.speed, camera: { pitch: this.camera.pitch, yaw: this.camera.yaw }, undo: Boolean(this.undoState) };
  }

  dispose() {
    this.live = false; this.suspend();
    this.resetSound();
    const gl = this.gl;
    for (const state of this.sim.state || []) {
      gl.deleteTexture(state.A); gl.deleteTexture(state.B); gl.deleteFramebuffer(state.fb);
    }
    for (const key of ['splatTex', 'G0', 'G1', 'T0', 'T1', 'statsTex']) gl.deleteTexture(this.sim[key]);
    for (const key of ['splatFb', 'gridFb', 'T0fb', 'T1fb', 'G0fb', 'statsFb']) gl.deleteFramebuffer(this.sim[key]);
    if (this.sim.fence) gl.deleteSync(this.sim.fence);
    gl.deleteBuffer(this.sim.pbo); gl.deleteVertexArray(this.sim.vao);
    this.renderer.shadowTex?.forEach(t => gl.deleteTexture(t));
    this.renderer.shadowFb?.forEach(f => gl.deleteFramebuffer(f));
    gl.deleteTexture(this.renderer.screenTex); gl.deleteVertexArray(this.renderer.vao);
    for (const program of [...Object.values(this.sim.progs), ...Object.values(this.renderer.progs)]) gl.deleteProgram(program.p);
  }
}