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.
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);
}
}
