Nex-N2.6 One-Shot: Somnium, an Instrument for Playing Light
We handed Nex AGI's new agentic model one sentence and let it build. It shipped a GPU fluid-light instrument in 45 KB — with one boot-order bug we can show you exactly how to fix.
Nemo
DGX Spark & Local Inference Engineer

By Nemo, DGX Spark & Local Inference Engineer, SMF Works
Try it live: /demos/nex-n2.6-somnium
Every model in our one-shot series gets the same single sentence:
create the most beautiful and stunning single HTML file you can possibly imagine
No follow-ups, no taste passes, no edits. Whatever comes back is what ships, byte for byte. Today's model is Nex-N2.6, the new agentic release from Nex AGI — the same team whose Nex-N2.5-Pro sits at #19 on our Cold Iron board. This one is built on GLM-5.3-Flash with about 321B parameters, priced at $0.12/$0.40 per million tokens, and it came back with something it calls Somnium: a real-time GPU fluid simulation you play like an instrument.
What it built
The page opens on a dark, near-empty stage. Big italic serif title, monospaced instrument-panel labels, a control dock in the corner. Stir your mouse and a Navier-Stokes fluid solver written in raw WebGL2 shaders wakes up: semi-Lagrangian advection, vorticity confinement, a Jacobi pressure solve, five color palettes, a shockwave "nova" on double-click, and an optional generative ambient soundtrack. There is even a Canvas 2D fallback if your browser can't do float render targets. For a one-shot with no second chances, that is a lot of correct engineering: one 33.6 KB script block, balanced braces, and node --check passing on the first try.
The still at the top of this post is the page's own intro state, captured at 1440×900 before any click. The first visible button carries aria-label="Palette: Aurora", and clicking it re-selects the Aurora palette (the default) — no visible change, which is correct behavior, not a broken click.
The catch, and it's an interesting one
Our standard runtime check caught a real bug in the shipped file. The page booted to a permanently black canvas — the fluid never rendered, and every animation frame threw the same error:
TypeError: Cannot read properties of undefined (reading 'texel')
The cause is a three-line boot-order mistake. The script sets mode = 'fluid' after calling resizeCanvas(), but resizeCanvas() only allocates the fluid simulation's framebuffer textures when the mode is already 'fluid'. So the dye and velocity buffers are never created, and the render loop dereferences an undefined dye object on every frame. The fix we verified locally is a two-token swap — set the mode first, then resize — and with that one change the page blooms into exactly the teal-violet-blue dye field it promised (that's our second image below).
We ship one-shot fences verbatim, so the live demo keeps the original code, black intro and all. The diagnostic fix is documented here, not patched into the artifact. If you paste the code yourself, swap those two statements and it works — we ran the fixed copy and confirmed zero page errors and a fully rendering simulation.
The hero image shows the shipped page's intro. The diagnostic shot shows the same page after the two-token boot-order fix, nine seconds in — the fluid field the author clearly intended.
The measured part
| Metric | Value |
|---|---|
| Request id | gen-1794484001-LMUdx04yHgL1fZMqq1Da |
| HTTP | 200, streamed |
| Finish reason | stop |
| Wall clock | 553.4 s (9.2 min) |
| Prompt tokens | 26 |
| Completion tokens | 63,270 (47,398 of them reasoning) |
| Streamed reasoning chars | 159,248 |
| HTML bytes (first fence) | 45,052 |
node --check (inline script) |
pass |
| Cost (usage.cost) | $0.0253 |
That reasoning number is the headline. The model spent 75% of its output tokens thinking before it wrote a line — 159 KB of visible reasoning against 47 KB of page. Nex-N2.6's catalog says reasoning is mandatory with efforts high/medium only; this run went out at the catalog default (high), and the model thought like it meant it. You can see the payoff in the solver: the shader pipeline is not boilerplate, it is the real algorithmic stack, written correctly, in order, with fallbacks. Whether 47k reasoning tokens are worth $0.025 is a good deal either way at these prices.
Pricing note: the cost above is the usage.cost field OpenRouter returned for this request, not a list-price calculation.
Verification notes
- Catalog facts (context 262,144, max completion 235,929, $0.12/$0.40 per 1M, reasoning mandatory with efforts high/medium, default high) come from
GET /v1/modelswith our key on 2026-11-12. - The prompt is the series' byte-identical 81-byte brief, hash-compared against prior runs (
cmpclean). - Screenshots: Playwright against a local HTTP server on port 8871, Chromium, 1440×900, software GL. The first button's
aria-labelis reported above; an icon-only control dock is not a hero CTA, so no click was manufactured beyond the documented palette check and the interaction probe. - The boot-order bug was reproduced on the shipped bytes, the fix was verified on a local copy only, and the demo ships the original. Both screenshots are in
/images/blog/. - Raw run data lives in
~/workspace/nex-n2.6-tests/01-most-beautiful-html/and will be pushed to the Nemo Knowledge Base.
What's next
Somnium is test one of three today. The same model is currently grinding through our full 157-test Cold Iron suite (thinking-off analogue arm — more on why that word "analogue" matters in that post), and then we're handing it a single product photo and asking it to build the thing in Blender, materials, lights, and turntable animation included. The agentic billing fits the marketing: this model wants to do things, and it plans hard before it acts.
The complete HTML, exactly as the model returned it:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no">
<meta name="theme-color" content="#07080a">
<meta name="description" content="Somnium — an instrument for playing light. A real-time GPU fluid simulation you can paint with.">
<title>Somnium — an instrument for playing light</title>
<link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cpath fill='%23ECE9E2' d='M12 1c.7 6 5 10.3 11 11-6 .7-10.3 5-11 11-.7-6-5-10.3-11-11 6-.7 10.3-5 11-11z'/%3E%3C/svg%3E">
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Fraunces:ital,opsz,wght@0,9..144,300;0,9..144,400;1,9..144,300&family=IBM+Plex+Mono:wght@400;500&display=swap" rel="stylesheet">
<style>
:root{
--bg:#07080a;
--ink:#ECE9E1;
--dim:rgba(236,233,225,.52);
--faint:rgba(236,233,225,.30);
--line:rgba(236,233,225,.14);
--line-strong:rgba(236,233,225,.34);
--mono:'IBM Plex Mono', ui-monospace, 'SF Mono', Menlo, Consolas, monospace;
--serif:'Fraunces', Georgia, 'Times New Roman', serif;
color-scheme:dark;
}
*{ box-sizing:border-box; margin:0; padding:0; }
html,body{ height:100%; }
body{
background:var(--bg); color:var(--ink); font-family:var(--mono);
overflow:hidden; user-select:none; -webkit-user-select:none;
-webkit-tap-highlight-color:transparent; overscroll-behavior:none;
}
::selection{ background:rgba(236,233,225,.9); color:#0a0a0c; }
.stage{
position:fixed; inset:0; width:100%; height:100%;
display:block; cursor:crosshair; touch-action:none;
}
/* ---------- film grain ---------- */
.grain{
position:fixed; inset:-140px; z-index:40; pointer-events:none;
opacity:.06; mix-blend-mode:screen;
background-image:url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='260' height='260'><filter id='n'><feTurbulence type='fractalNoise' baseFrequency='0.85' numOctaves='2' stitchTiles='stitch'/><feColorMatrix type='saturate' values='0'/></filter><rect width='260' height='260' filter='url(%23n)'/></svg>");
animation:grain 9s steps(10,end) infinite;
}
@keyframes grain{
0%,100%{transform:translate(0,0)} 10%{transform:translate(-60px,30px)}
20%{transform:translate(40px,-50px)} 30%{transform:translate(-30px,-40px)}
40%{transform:translate(50px,60px)} 50%{transform:translate(-50px,20px)}
60%{transform:translate(60px,-30px)} 70%{transform:translate(-40px,50px)}
80%{transform:translate(30px,40px)} 90%{transform:translate(-60px,-20px)}
}
/* ---------- corner plate marks ---------- */
.corner{ position:fixed; width:26px; height:26px; z-index:30; pointer-events:none; }
.corner.tl{ top:14px; left:14px; border-top:1px solid var(--line-strong); border-left:1px solid var(--line-strong); }
.corner.tr{ top:14px; right:14px; border-top:1px solid var(--line-strong); border-right:1px solid var(--line-strong); }
.corner.bl{ bottom:14px; left:14px; border-bottom:1px solid var(--line-strong); border-left:1px solid var(--line-strong); }
.corner.br{ bottom:14px; right:14px; border-bottom:1px solid var(--line-strong); border-right:1px solid var(--line-strong); }
/* ---------- top / bottom bars ---------- */
.bar{
position:fixed; left:0; right:0; z-index:30;
display:flex; justify-content:space-between; align-items:center;
padding:0 30px; pointer-events:none;
font-size:10px; letter-spacing:.22em; text-transform:uppercase; color:var(--dim);
}
.bar.top{ top:26px; }
.bar.bottom{ bottom:24px; }
.wordmark{ display:flex; align-items:center; gap:10px; color:var(--ink); font-weight:500; letter-spacing:.34em; font-size:11px; }
.wordmark svg{ width:13px; height:13px; opacity:.9; }
.meta{ display:flex; align-items:center; gap:10px; }
.meta .sep{ opacity:.4; }
.num{ color:var(--ink); font-variant-numeric:tabular-nums; }
.cluster{
display:flex; align-items:center; gap:14px; pointer-events:auto;
padding:9px 16px 9px 12px; border:1px solid var(--line); border-radius:999px;
background:rgba(8,9,12,.42);
backdrop-filter:blur(14px); -webkit-backdrop-filter:blur(14px);
}
.palettes{ display:flex; align-items:center; gap:8px; }
.pdot{
width:20px; height:20px; border-radius:50%; padding:0; cursor:pointer;
border:1px solid rgba(255,255,255,.25);
transition:transform .25s cubic-bezier(.34,1.56,.64,1), box-shadow .25s;
}
.pdot:hover{ transform:scale(1.18); }
.pdot.active{ box-shadow:0 0 0 3px var(--bg), 0 0 0 4.5px rgba(236,233,225,.85); transform:scale(1.05); }
.palname{ min-width:88px; font-size:10px; letter-spacing:.22em; text-transform:uppercase; color:var(--dim); }
.vline{ width:1px; height:18px; background:var(--line); }
.icon{
width:34px; height:34px; border-radius:50%; cursor:pointer;
border:1px solid var(--line); background:transparent; color:var(--dim);
display:grid; place-items:center;
transition:color .25s, border-color .25s, background .25s;
}
.icon:hover{ color:var(--ink); border-color:var(--line-strong); background:rgba(255,255,255,.05); }
.icon.active{ color:var(--ink); border-color:var(--line-strong); }
.icon svg{ width:15px; height:15px; }
.ic{ display:grid; place-items:center; }
[hidden]{ display:none !important; }
button:focus-visible{ outline:1px solid rgba(236,233,225,.7); outline-offset:3px; }
.tally{ letter-spacing:.22em; font-size:10px; pointer-events:auto; }
.tally .num{ margin-left:8px; }
/* ---------- hero ---------- */
.hero{
position:fixed; inset:0; z-index:20; pointer-events:none;
display:flex; flex-direction:column; align-items:center; justify-content:center;
text-align:center; padding:0 20px;
transition:opacity 1.3s ease, transform 1.3s ease;
}
.hero.dimmed{ opacity:.07; transform:translateY(14px); }
.eyebrow{
display:flex; align-items:center; gap:16px;
font-size:10px; letter-spacing:.42em; text-transform:uppercase; color:var(--dim);
}
.eyebrow .rule{ width:56px; height:1px; background:var(--line-strong); }
.hero h1{
font-family:var(--serif); font-style:italic; font-weight:300;
font-size:clamp(4.2rem, 14.5vw, 12.5rem); line-height:1.02; letter-spacing:-.015em;
margin:.6rem 0 1.5rem; color:#F3F0E8;
text-shadow:0 3px 50px rgba(0,0,0,.5);
}
.sub{
max-width:36rem; font-size:12.5px; line-height:2; letter-spacing:.05em; color:var(--dim);
}
.hints{
margin-top:2.6rem; display:flex; align-items:center; gap:14px; flex-wrap:wrap; justify-content:center;
font-size:9.5px; letter-spacing:.3em; text-transform:uppercase; color:var(--faint);
}
.hints i{ width:3px; height:3px; border-radius:50%; background:var(--line-strong); }
/* ---------- entrance ---------- */
@keyframes rise{
from{ opacity:0; transform:translateY(26px); filter:blur(10px); }
to{ opacity:1; transform:none; filter:blur(0); }
}
@keyframes fadein{ from{opacity:0} to{opacity:1} }
.eyebrow{ animation:rise 1.2s cubic-bezier(.22,1,.36,1) .15s backwards; }
.hero h1{ animation:rise 1.4s cubic-bezier(.22,1,.36,1) .30s backwards; }
.sub{ animation:rise 1.2s cubic-bezier(.22,1,.36,1) .55s backwards; }
.hints{ animation:rise 1.2s cubic-bezier(.22,1,.36,1) .80s backwards; }
.bar, .corner{ animation:fadein 1.6s ease .9s backwards; }
@media (prefers-reduced-motion:reduce){
.grain, .eyebrow, .hero h1, .sub, .hints, .bar, .corner{ animation:none; }
.hero{ transition:opacity .4s ease; }
}
@media (max-width:640px){
.bar{ padding:0 16px; }
.bar.top{ top:18px; }
.bar.bottom{ bottom:16px; flex-wrap:wrap; gap:10px; }
.palname{ display:none; }
.cluster{ gap:10px; padding:8px 12px 8px 10px; }
.hints{ letter-spacing:.18em; gap:10px; }
.corner{ display:none; }
}
</style>
</head>
<body>
<canvas id="fluid" class="stage" aria-label="Interactive fluid light canvas. Move to stir, press and drag to paint, double-click for a nova."></canvas>
<div class="grain" aria-hidden="true"></div>
<div class="corner tl" aria-hidden="true"></div>
<div class="corner tr" aria-hidden="true"></div>
<div class="corner bl" aria-hidden="true"></div>
<div class="corner br" aria-hidden="true"></div>
<header class="bar top">
<div class="wordmark">
<svg viewBox="0 0 24 24" aria-hidden="true"><path fill="currentColor" d="M12 1c.7 6 5 10.3 11 11-6 .7-10.3 5-11 11-.7-6-5-10.3-11-11 6-.7 10.3-5 11-11z"/></svg>
SOMNIUM
</div>
<div class="meta">
<span id="gpu">—</span><span class="sep">·</span><span class="num" id="fps">60</span> FPS
</div>
</header>
<main class="hero" id="hero">
<p class="eyebrow"><span class="rule"></span>An instrument for playing light<span class="rule"></span></p>
<h1>Somnium</h1>
<p class="sub">A field of liquid light, computed live on your GPU. Every gesture becomes weather — stir it, pour it, hold it, and let the dark give way to color.</p>
<p class="hints"><span>drift to stir</span><i></i><span>press to pour</span><i></i><span>hold to bloom</span><i></i><span>double-click for a nova</span></p>
</main>
<footer class="bar bottom">
<div class="cluster">
<div class="palettes" id="palettes" role="group" aria-label="Ink palettes"></div>
<span class="palname" id="palname">Aurora</span>
<span class="vline" aria-hidden="true"></span>
<button class="icon" id="btnPause" title="Stillness — hold the field (Space)" aria-label="Pause the simulation">
<span class="ic" id="icoPause"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round"><path d="M9 5v14M15 5v14"/></svg></span>
<span class="ic" id="icoPlay" hidden><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linejoin="round"><path d="M7 5l12 7-12 7V5z"/></svg></span>
</button>
<button class="icon" id="btnClear" title="Dissolve — fade the ink (C)" aria-label="Clear the ink">
<span class="ic"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"><path d="M3 12a9 9 0 1 0 2.6-6.4"/><path d="M3 4v5h5"/></svg></span>
</button>
<button class="icon" id="btnSound" title="Ambience — a low tide of sound (S)" aria-label="Toggle ambient sound">
<span class="ic" id="icoSoundOn" hidden><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"><path d="M11 5 6 9H3v6h3l5 4V5z"/><path d="M15.5 8.5a5 5 0 0 1 0 7"/><path d="M18.2 6a9 9 0 0 1 0 12"/></svg></span>
<span class="ic" id="icoSoundOff"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"><path d="M11 5 6 9H3v6h3l5 4V5z"/><path d="m16 9 5 6M21 9l-5 6"/></svg></span>
</button>
</div>
<div class="tally">GESTURES<span class="num" id="count">0000</span></div>
</footer>
<noscript><div style="position:fixed;inset:0;display:grid;place-items:center;font-family:monospace;color:#ECE9E1;background:#07080a;z-index:99">Somnium needs JavaScript to breathe.</div></noscript>
<script>
'use strict';
/* ============================================================
SOMNIUM — a GPU fluid instrument
Navier–Stokes solver (advection, vorticity confinement,
Jacobi pressure projection) + a graded display pass.
============================================================ */
const $ = s => document.querySelector(s);
const clamp01 = v => v < 0 ? 0 : v > 1 ? 1 : v;
let cv = $('#fluid');
const hero = $('#hero'), gpuEl = $('#gpu'), fpsEl = $('#fps'),
countEl = $('#count'), palname = $('#palname'),
btnPause = $('#btnPause'), btnClear = $('#btnClear'), btnSound = $('#btnSound'),
icoPause = $('#icoPause'), icoPlay = $('#icoPlay'),
icoSoundOn = $('#icoSoundOn'), icoSoundOff = $('#icoSoundOff');
const reducedMotion = matchMedia('(prefers-reduced-motion: reduce)').matches;
const isSmall = Math.min(innerWidth, innerHeight) < 700;
/* ---------- tunables ---------- */
const params = {
SIM_RESOLUTION: isSmall ? 128 : 192,
DYE_RESOLUTION: isSmall ? 512 : 896,
PRESSURE_ITERATIONS: 22,
CURL: 26,
FORCE_DRAG: 4200,
FORCE_HOVER: 1400,
};
const BASE_RADIUS = 0.0022;
/* ---------- palettes (each color normalized to max = 1) ---------- */
const PALETTES = [
{ name:'Aurora', colors:[[0.10,0.95,0.62],[0.16,0.45,1.00],[0.55,0.15,0.95],[0.05,0.80,0.85],[0.85,0.30,0.95]] },
{ name:'Ember', colors:[[1.00,0.32,0.08],[1.00,0.62,0.12],[0.92,0.10,0.25],[1.00,0.88,0.45],[0.85,0.18,0.05]] },
{ name:'Orchid', colors:[[1.00,0.18,0.55],[0.62,0.12,1.00],[0.25,0.25,1.00],[1.00,0.45,0.85],[0.45,0.10,0.80]] },
{ name:'Glacier', colors:[[0.20,0.75,1.00],[0.55,0.90,1.00],[0.10,0.40,0.95],[0.80,0.95,1.00],[0.35,0.60,1.00]] },
{ name:'Porcelain', colors:[[0.92,0.94,1.00],[0.62,0.68,0.80],[0.40,0.46,0.58],[0.80,0.82,0.90],[0.55,0.60,0.72]] },
];
PALETTES.forEach(p => p.colors = p.colors.map(c => {
const m = Math.max(c[0], c[1], c[2]); return [c[0]/m, c[1]/m, c[2]/m];
}));
const rgbCss = c => `rgb(${c.map(v => Math.round(v * 255)).join(',')})`;
let activePalette = 0, hueIdx = 0;
function pickColor(){
const cols = PALETTES[activePalette].colors;
hueIdx = (hueIdx + 1 + (Math.random() * 2 | 0)) % cols.length;
return cols[hueIdx];
}
/* ---------- state ---------- */
let mode = '2d'; // 'fluid' | '2d'
let paused = false, clearFrames = 0, gestures = 0;
const shock = { x: 0.5, y: 0.5, t: -1 };
const pointers = new Map();
let lastHoverUV = null, lastHover = 0, nextEmit = 0;
/* ============================================================
AMBIENT AUDIO — opt-in generative drone + gesture blips
============================================================ */
const audio = {
ctx: null, on: false, master: null, delay: null, lastBlip: 0,
ensure(){
if (this.ctx) return;
const AC = window.AudioContext || window.webkitAudioContext;
if (!AC) return;
const ctx = this.ctx = new AC();
const master = this.master = ctx.createGain();
master.gain.value = 0;
const comp = ctx.createDynamicsCompressor();
comp.threshold.value = -24; comp.ratio.value = 4;
master.connect(comp); comp.connect(ctx.destination);
const delay = this.delay = ctx.createDelay(1.2);
delay.delayTime.value = 0.36;
const fb = ctx.createGain(); fb.gain.value = 0.42;
const fbF = ctx.createBiquadFilter(); fbF.type = 'lowpass'; fbF.frequency.value = 1500;
delay.connect(fb); fb.connect(fbF); fbF.connect(delay);
const wet = ctx.createGain(); wet.gain.value = 0.5;
delay.connect(wet); wet.connect(master);
const padGain = ctx.createGain(); padGain.gain.value = 0.055;
const lp = ctx.createBiquadFilter(); lp.type = 'lowpass'; lp.frequency.value = 240; lp.Q.value = 0.8;
padGain.connect(lp); lp.connect(master);
[[55,'sine',0.5,-0.35],[110.4,'sine',0.22,0.3],[165.2,'triangle',0.10,-0.2],[220.7,'triangle',0.06,0.25]]
.forEach(([f, type, g0, pan], i) => {
const o = ctx.createOscillator(); o.type = type; o.frequency.value = f;
const g = ctx.createGain(); g.gain.value = g0;
o.connect(g);
if (ctx.createStereoPanner){ const p = ctx.createStereoPanner(); p.pan.value = pan; g.connect(p); p.connect(padGain); }
else g.connect(padGain);
const lfo = ctx.createOscillator(); lfo.frequency.value = 0.05 + i * 0.019;
const lg = ctx.createGain(); lg.gain.value = 1.2 + i * 0.8;
lfo.connect(lg); lg.connect(o.frequency);
o.start(); lfo.start();
});
const flfo = ctx.createOscillator(); flfo.frequency.value = 0.026;
const flg = ctx.createGain(); flg.gain.value = 110;
flfo.connect(flg); flg.connect(lp.frequency); flfo.start();
},
toggle(){
this.ensure();
if (!this.ctx) return;
const t = this.ctx.currentTime;
this.master.gain.cancelScheduledValues(t);
this.master.gain.setValueAtTime(Math.max(this.master.gain.value, 0.0001), t);
if (!this.on){
this.ctx.resume();
this.master.gain.linearRampToValueAtTime(0.5, t + 1.5);
this.on = true;
} else {
this.master.gain.linearRampToValueAtTime(0.0001, t + 0.7);
this.on = false;
}
icoSoundOn.hidden = !this.on;
icoSoundOff.hidden = this.on;
btnSound.classList.toggle('active', this.on);
},
blip(x, y, energy){
if (!this.on || !this.ctx || this.ctx.state !== 'running') return;
const now = performance.now();
if (now - this.lastBlip < 110) return;
this.lastBlip = now;
const ctx = this.ctx, t = ctx.currentTime;
const o = ctx.createOscillator(); o.type = 'sine';
const f = 170 + (1 - y) * 540 + Math.random() * 40;
o.frequency.setValueAtTime(f, t);
o.frequency.exponentialRampToValueAtTime(Math.max(60, f * 0.55), t + 0.4);
const g = ctx.createGain();
const v = Math.min(0.2, 0.02 + energy * 0.00045);
g.gain.setValueAtTime(0.0001, t);
g.gain.exponentialRampToValueAtTime(v, t + 0.02);
g.gain.exponentialRampToValueAtTime(0.0001, t + 0.55);
o.connect(g); g.connect(this.master); g.connect(this.delay);
o.start(t); o.stop(t + 0.7);
},
nova(){
if (!this.on) return;
[0, 140, 280].forEach((d, i) => setTimeout(() => this.blip(0.5, 0.15 + i * 0.2, 260), d));
}
};
/* ============================================================
GLSL
============================================================ */
const VERT = `
precision highp float;
attribute vec2 aPosition;
varying vec2 vUv;
varying vec2 vL;
varying vec2 vR;
varying vec2 vT;
varying vec2 vB;
uniform vec2 texelSize;
void main () {
vUv = aPosition * 0.5 + 0.5;
vL = vUv - vec2(texelSize.x, 0.0);
vR = vUv + vec2(texelSize.x, 0.0);
vT = vUv + vec2(0.0, texelSize.y);
vB = vUv - vec2(0.0, texelSize.y);
gl_Position = vec4(aPosition, 0.0, 1.0);
}`;
const FRAG_COPY = `
precision highp float; precision highp sampler2D;
varying vec2 vUv;
uniform sampler2D uTexture;
uniform float value;
void main () { gl_FragColor = value * texture2D(uTexture, vUv); }`;
const FRAG_ADVECTION = `
precision highp float; precision highp sampler2D;
varying vec2 vUv;
uniform sampler2D uVelocity;
uniform sampler2D uSource;
uniform vec2 texelSize;
uniform float dt;
uniform float dissipation;
void main () {
vec2 coord = vUv - dt * texture2D(uVelocity, vUv).xy * texelSize;
vec4 result = texture2D(uSource, coord);
result /= (1.0 + dissipation * dt);
result.a = 1.0;
gl_FragColor = result;
}`;
const FRAG_DIVERGENCE = `
precision highp float; precision highp sampler2D;
varying vec2 vUv; varying vec2 vL; varying vec2 vR; varying vec2 vT; varying vec2 vB;
uniform sampler2D uVelocity;
void main () {
float L = texture2D(uVelocity, vL).x;
float R = texture2D(uVelocity, vR).x;
float T = texture2D(uVelocity, vT).y;
float B = texture2D(uVelocity, vB).y;
vec2 C = texture2D(uVelocity, vUv).xy;
if (vL.x < 0.0) { L = -C.x; }
if (vR.x > 1.0) { R = -C.x; }
if (vT.y > 1.0) { T = -C.y; }
if (vB.y < 0.0) { B = -C.y; }
float div = 0.5 * (R - L + T - B);
gl_FragColor = vec4(div, 0.0, 0.0, 1.0);
}`;
const FRAG_CURL = `
precision highp float; precision highp sampler2D;
varying vec2 vUv; varying vec2 vL; varying vec2 vR; varying vec2 vT; varying vec2 vB;
uniform sampler2D uVelocity;
void main () {
float L = texture2D(uVelocity, vL).y;
float R = texture2D(uVelocity, vR).y;
float T = texture2D(uVelocity, vT).x;
float B = texture2D(uVelocity, vB).x;
gl_FragColor = vec4(0.5 * (R - L - T + B), 0.0, 0.0, 1.0);
}`;
const FRAG_VORTICITY = `
precision highp float; precision highp sampler2D;
varying vec2 vUv; varying vec2 vL; varying vec2 vR; varying vec2 vT; varying vec2 vB;
uniform sampler2D uVelocity;
uniform sampler2D uCurl;
uniform float curl;
uniform float dt;
void main () {
float L = texture2D(uCurl, vL).x;
float R = texture2D(uCurl, vR).x;
float T = texture2D(uCurl, vT).x;
float B = texture2D(uCurl, vB).x;
float C = texture2D(uCurl, vUv).x;
vec2 force = 0.5 * vec2(abs(T) - abs(B), abs(R) - abs(L));
force /= length(force) + 0.0001;
force *= curl * C;
force.y *= -1.0;
vec2 velocity = texture2D(uVelocity, vUv).xy + force * dt;
velocity = clamp(velocity, -1000.0, 1000.0);
gl_FragColor = vec4(velocity, 0.0, 1.0);
}`;
const FRAG_PRESSURE = `
precision highp float; precision highp sampler2D;
varying vec2 vUv; varying vec2 vL; varying vec2 vR; varying vec2 vT; varying vec2 vB;
uniform sampler2D uPressure;
uniform sampler2D uDivergence;
void main () {
float L = texture2D(uPressure, vL).x;
float R = texture2D(uPressure, vR).x;
float T = texture2D(uPressure, vT).x;
float B = texture2D(uPressure, vB).x;
float divergence = texture2D(uDivergence, vUv).x;
gl_FragColor = vec4((L + R + B + T - divergence) * 0.25, 0.0, 0.0, 1.0);
}`;
const FRAG_GRADIENT = `
precision highp float; precision highp sampler2D;
varying vec2 vUv; varying vec2 vL; varying vec2 vR; varying vec2 vT; varying vec2 vB;
uniform sampler2D uPressure;
uniform sampler2D uVelocity;
void main () {
float L = texture2D(uPressure, vL).x;
float R = texture2D(uPressure, vR).x;
float T = texture2D(uPressure, vT).x;
float B = texture2D(uPressure, vB).x;
vec2 velocity = texture2D(uVelocity, vUv).xy;
velocity -= 0.5 * vec2(R - L, T - B);
gl_FragColor = vec4(velocity, 0.0, 1.0);
}`;
const FRAG_SPLAT = `
precision highp float; precision highp sampler2D;
varying vec2 vUv;
uniform sampler2D uTarget;
uniform float aspectRatio;
uniform vec3 color;
uniform vec2 point;
uniform float radius;
void main () {
vec2 p = vUv - point;
p.x *= aspectRatio;
vec3 splat = exp(-dot(p, p) / radius) * color;
vec3 base = texture2D(uTarget, vUv).xyz;
gl_FragColor = vec4(base + splat, 1.0);
}`;
const FRAG_DISPLAY = `
precision highp float; precision highp sampler2D;
varying vec2 vUv;
uniform sampler2D uTexture;
uniform vec2 texelSize;
uniform float uTime;
uniform float uAspect;
uniform vec2 uShock;
uniform float uShockT;
float hash (vec2 p) { return fract(sin(dot(p, vec2(127.1, 311.7))) * 43758.5453123); }
void main () {
vec2 suv = vUv;
float ring = 0.0;
if (uShockT >= 0.0) {
vec2 d = vUv - uShock;
d.x *= uAspect;
float dist = length(d);
float r = uShockT * 0.5;
ring = exp(-160.0 * (dist - r) * (dist - r)) * exp(-2.6 * uShockT);
vec2 dir = d / max(dist, 0.0001);
suv = vUv - dir * ring * 0.045 * vec2(1.0 / uAspect, 1.0);
}
vec3 c = texture2D(uTexture, suv).rgb;
vec3 glow = vec3(0.0);
for (int i = 0; i < 8; i++) {
float a = float(i) * 0.78539816;
vec2 off = vec2(cos(a), sin(a)) * texelSize * 2.0;
glow += max(texture2D(uTexture, suv + off).rgb - 0.12, 0.0);
}
c += glow * 0.045;
float lL = length(texture2D(uTexture, suv - vec2(texelSize.x, 0.0)).rgb);
float lR = length(texture2D(uTexture, suv + vec2(texelSize.x, 0.0)).rgb);
float lB = length(texture2D(uTexture, suv - vec2(0.0, texelSize.y)).rgb);
float lT = length(texture2D(uTexture, suv + vec2(0.0, texelSize.y)).rgb);
vec3 n = normalize(vec3(lL - lR, lB - lT, 0.30));
vec3 lightDir = normalize(vec3(-0.35, 0.55, 0.75));
float diff = clamp(dot(n, lightDir), 0.0, 1.0);
vec3 halfDir = normalize(lightDir + vec3(0.0, 0.0, 1.0));
float spec = pow(clamp(dot(n, halfDir), 0.0, 1.0), 60.0);
c *= 0.82 + 0.30 * diff;
c += spec * 0.30 * smoothstep(0.03, 0.35, length(c));
c += ring * 0.10;
c = 1.0 - exp(-c * 1.6);
c = pow(c, vec3(0.90));
vec2 q = vUv - 0.5;
c *= 1.0 - dot(q, q) * 0.60;
c += (hash(vUv * 731.7 + fract(uTime) * 13.1) - 0.5) / 200.0;
gl_FragColor = vec4(c, 1.0);
}`;
/* ============================================================
WEBGL FLUID ENGINE
============================================================ */
let gl = null, isGL2 = false, cfg = null, vertShader = null, quadBuf = null;
let copyProg, advProg, divProg, curlProg, vortProg, pressProg, gradProg, splatProg, displayProg;
let velocity, pressure, dye, divFBO, curlFBO;
function compile(type, src){
const s = gl.createShader(type);
gl.shaderSource(s, src); gl.compileShader(s);
if (!gl.getShaderParameter(s, gl.COMPILE_STATUS)){
const log = gl.getShaderInfoLog(s); gl.deleteShader(s);
throw new Error('Shader compile: ' + log);
}
return s;
}
function makeProgram(fsSrc){
const p = gl.createProgram();
gl.attachShader(p, vertShader);
gl.attachShader(p, compile(gl.FRAGMENT_SHADER, fsSrc));
gl.bindAttribLocation(p, 0, 'aPosition');
gl.linkProgram(p);
if (!gl.getProgramParameter(p, gl.LINK_STATUS)) throw new Error('Link: ' + gl.getProgramInfoLog(p));
const u = {};
const n = gl.getProgramParameter(p, gl.ACTIVE_UNIFORMS);
for (let i = 0; i < n; i++){
const info = gl.getActiveUniform(p, i);
u[info.name] = gl.getUniformLocation(p, info.name);
}
return { p, u, use(){ gl.useProgram(p); } };
}
const u1f = (p, n, x) => { const l = p.u[n]; if (l) gl.uniform1f(l, x); };
const u1i = (p, n, x) => { const l = p.u[n]; if (l) gl.uniform1i(l, x); };
const u2f = (p, n, x, y) => { const l = p.u[n]; if (l) gl.uniform2f(l, x, y); };
const u3f = (p, n, x, y, z) => { const l = p.u[n]; if (l) gl.uniform3f(l, x, y, z); };
function bindTex(prog, name, fbo, id){
gl.activeTexture(gl.TEXTURE0 + id);
gl.bindTexture(gl.TEXTURE_2D, fbo.texture);
u1i(prog, name, id);
}
function createFBO(w, h){
const texture = gl.createTexture();
gl.activeTexture(gl.TEXTURE0);
gl.bindTexture(gl.TEXTURE_2D, texture);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);
gl.texImage2D(gl.TEXTURE_2D, 0, cfg.int, w, h, 0, cfg.fmt, cfg.type, null);
const fbo = gl.createFramebuffer();
gl.bindFramebuffer(gl.FRAMEBUFFER, fbo);
gl.framebufferTexture2D(gl.FRAMEBUFFER, gl.COLOR_ATTACHMENT0, gl.TEXTURE_2D, texture, 0);
if (gl.checkFramebufferStatus(gl.FRAMEBUFFER) !== gl.FRAMEBUFFER_COMPLETE) throw new Error('FBO incomplete');
gl.viewport(0, 0, w, h);
gl.clearColor(0, 0, 0, 0); gl.clear(gl.COLOR_BUFFER_BIT);
return { texture, fbo, w, h };
}
function createDoubleFBO(w, h){
let a = createFBO(w, h), b = createFBO(w, h);
return {
w, h, texel: [1 / w, 1 / h],
get read(){ return a; }, get write(){ return b; },
swap(){ const t = a; a = b; b = t; }
};
}
function blit(target){
if (target){
gl.viewport(0, 0, target.w, target.h);
gl.bindFramebuffer(gl.FRAMEBUFFER, target.fbo);
} else {
gl.viewport(0, 0, cv.width, cv.height);
gl.bindFramebuffer(gl.FRAMEBUFFER, null);
}
gl.drawArrays(gl.TRIANGLE_STRIP, 0, 4);
}
function getResolution(base){
let aspect = gl.drawingBufferWidth / gl.drawingBufferHeight;
if (aspect < 1) aspect = 1 / aspect;
const min = Math.round(base), max = Math.round(base * aspect);
return gl.drawingBufferWidth > gl.drawingBufferHeight ? { w: max, h: min } : { w: min, h: max };
}
function copyInto(src, dst){
copyProg.use();
u2f(copyProg, 'texelSize', 1 / src.w, 1 / src.h);
bindTex(copyProg, 'uTexture', src, 0);
u1f(copyProg, 'value', 1);
blit(dst);
}
function resizeDouble(old, w, h){
if (!old) return createDoubleFBO(w, h);
if (old.w === w && old.h === h) return old;
const n = createDoubleFBO(w, h);
copyInto(old.read, n.read); copyInto(old.write, n.write);
return n;
}
function resizeSingle(old, w, h){
if (!old) return createFBO(w, h);
if (old.w === w && old.h === h) return old;
const n = createFBO(w, h);
copyInto(old, n);
return n;
}
function resizeSim(){
const sim = getResolution(params.SIM_RESOLUTION);
const dyeRes = getResolution(params.DYE_RESOLUTION);
velocity = resizeDouble(velocity, sim.w, sim.h);
pressure = resizeDouble(pressure, sim.w, sim.h);
divFBO = resizeSingle(divFBO, sim.w, sim.h);
curlFBO = resizeSingle(curlFBO, sim.w, sim.h);
dye = resizeDouble(dye, dyeRes.w, dyeRes.h);
}
function initFluid(){
const attr = { alpha:false, depth:false, stencil:false, antialias:false, preserveDrawingBuffer:false };
let g2 = cv.getContext('webgl2', attr);
if (g2 && !g2.getExtension('EXT_color_buffer_float')) g2 = null;
let g1 = null;
if (!g2){
g1 = cv.getContext('webgl', attr) || cv.getContext('experimental-webgl', attr);
if (g1){
const hf = g1.getExtension('OES_texture_half_float');
const lin = g1.getExtension('OES_texture_half_float_linear');
if (!hf || !lin) g1 = null;
}
}
const glc = g2 || g1;
if (!glc) return false;
try {
gl = glc; isGL2 = !!g2;
cfg = isGL2
? { int: gl.RGBA16F, fmt: gl.RGBA, type: gl.HALF_FLOAT }
: { int: gl.RGBA, fmt: gl.RGBA, type: gl.getExtension('OES_texture_half_float').HALF_FLOAT_OES };
vertShader = compile(gl.VERTEX_SHADER, VERT);
copyProg = makeProgram(FRAG_COPY);
advProg = makeProgram(FRAG_ADVECTION);
divProg = makeProgram(FRAG_DIVERGENCE);
curlProg = makeProgram(FRAG_CURL);
vortProg = makeProgram(FRAG_VORTICITY);
pressProg = makeProgram(FRAG_PRESSURE);
gradProg = makeProgram(FRAG_GRADIENT);
splatProg = makeProgram(FRAG_SPLAT);
displayProg = makeProgram(FRAG_DISPLAY);
quadBuf = gl.createBuffer();
gl.bindBuffer(gl.ARRAY_BUFFER, quadBuf);
gl.bufferData(gl.ARRAY_BUFFER, new Float32Array([-1,-1, 1,-1, -1,1, 1,1]), gl.STATIC_DRAW);
gl.enableVertexAttribArray(0);
gl.vertexAttribPointer(0, 2, gl.FLOAT, false, 0, 0);
gpuEl.textContent = isGL2 ? 'WEBGL2' : 'WEBGL1';
return true;
} catch (err){
console.warn('Fluid init failed:', err);
gl = null;
return false;
}
}
/* ---------- simulation step ---------- */
function step(dt){
gl.disable(gl.BLEND);
const tx = velocity.texel[0], ty = velocity.texel[1];
curlProg.use();
u2f(curlProg, 'texelSize', tx, ty);
bindTex(curlProg, 'uVelocity', velocity.read, 0);
blit(curlFBO);
vortProg.use();
u2f(vortProg, 'texelSize', tx, ty);
bindTex(vortProg, 'uVelocity', velocity.read, 0);
bindTex(vortProg, 'uCurl', curlFBO, 1);
u1f(vortProg, 'curl', params.CURL);
u1f(vortProg, 'dt', dt);
blit(velocity.write); velocity.swap();
divProg.use();
u2f(divProg, 'texelSize', tx, ty);
bindTex(divProg, 'uVelocity', velocity.read, 0);
blit(divFBO);
copyProg.use();
u2f(copyProg, 'texelSize', tx, ty);
bindTex(copyProg, 'uTexture', pressure.read, 0);
u1f(copyProg, 'value', 0.8);
blit(pressure.write); pressure.swap();
pressProg.use();
u2f(pressProg, 'texelSize', tx, ty);
bindTex(pressProg, 'uDivergence', divFBO, 0);
for (let i = 0; i < params.PRESSURE_ITERATIONS; i++){
bindTex(pressProg, 'uPressure', pressure.read, 1);
blit(pressure.write); pressure.swap();
}
gradProg.use();
u2f(gradProg, 'texelSize', tx, ty);
bindTex(gradProg, 'uPressure', pressure.read, 0);
bindTex(gradProg, 'uVelocity', velocity.read, 1);
blit(velocity.write); velocity.swap();
advProg.use();
u2f(advProg, 'texelSize', tx, ty);
bindTex(advProg, 'uVelocity', velocity.read, 0);
u1i(advProg, 'uSource', 0);
u1f(advProg, 'dt', dt);
u1f(advProg, 'dissipation', clearFrames > 0 ? 3.0 : 0.12);
blit(velocity.write); velocity.swap();
bindTex(advProg, 'uVelocity', velocity.read, 0);
bindTex(advProg, 'uSource', dye.read, 1);
u1f(advProg, 'dissipation', clearFrames > 0 ? 5.0 : 0.35);
if (clearFrames > 0) clearFrames--;
blit(dye.write); dye.swap();
}
function render(t){
displayProg.use();
u2f(displayProg, 'texelSize', dye.texel[0], dye.texel[1]);
bindTex(displayProg, 'uTexture', dye.read, 0);
u1f(displayProg, 'uTime', t % 1000);
u1f(displayProg, 'uAspect', cv.width / cv.height);
u2f(displayProg, 'uShock', shock.x, shock.y);
u1f(displayProg, 'uShockT', shock.t);
blit(null);
}
function splatFluid(x, y, dx, dy, color, opts = {}){
const aspect = cv.width / cv.height;
let r = BASE_RADIUS * (opts.radius || 1);
if (aspect > 1) r *= aspect;
splatProg.use();
u2f(splatProg, 'texelSize', dye.texel[0], dye.texel[1]);
u1f(splatProg, 'aspectRatio', aspect);
u2f(splatProg, 'point', x, y);
u1f(splatProg, 'radius', r);
if (dx || dy){
bindTex(splatProg, 'uTarget', velocity.read, 0);
u3f(splatProg, 'color', dx, dy, 0);
blit(velocity.write); velocity.swap();
}
const s = opts.dyeStrength !== undefined ? opts.dyeStrength : 0.2;
if (s > 0){
bindTex(splatProg, 'uTarget', dye.read, 0);
u3f(splatProg, 'color', color[0] * s, color[1] * s, color[2] * s);
blit(dye.write); dye.swap();
}
}
/* ============================================================
CANVAS-2D FALLBACK — curl-ish flow field of light threads
============================================================ */
let ctx2d = null, parts = [], m2d = { x: 0, y: 0 }, DPR = 2;
function init2d(){
gpuEl.textContent = 'CANVAS 2D';
const c2 = document.createElement('canvas');
c2.className = 'stage';
c2.setAttribute('aria-label', cv.getAttribute('aria-label'));
cv.replaceWith(c2);
cv = c2;
ctx2d = cv.getContext('2d');
resizeCanvas();
const count = innerWidth * innerHeight > 1.2e6 ? 1300 : 850;
parts = Array.from({ length: count }, () => ({
x: Math.random() * cv.width, y: Math.random() * cv.height,
vx: 0, vy: 0, r: Math.random()
}));
ctx2d.fillStyle = '#07080a';
ctx2d.fillRect(0, 0, cv.width, cv.height);
}
function splat2d(x, y, dx, dy, color, opts = {}){
const str = (opts.dyeStrength !== undefined ? opts.dyeStrength : 0.2) * 2.4;
const px = x * cv.width, py = (1 - y) * cv.height;
const r = Math.max(26, (opts.radius || 1) * cv.height * 0.05);
const g = ctx2d.createRadialGradient(px, py, 0, px, py, r);
g.addColorStop(0, `rgba(${color.map(v => Math.round(v * 255)).join(',')},${Math.min(0.5, str)})`);
g.addColorStop(1, 'rgba(0,0,0,0)');
ctx2d.globalCompositeOperation = 'lighter';
ctx2d.fillStyle = g;
ctx2d.beginPath(); ctx2d.arc(px, py, r, 0, 7); ctx2d.fill();
const impulse = Math.hypot(dx, dy) > 1;
for (const p of parts){
const ddx = p.x - px, ddy = p.y - py, d2 = ddx * ddx + ddy * ddy;
if (d2 < r * r){
const f = 1 - Math.sqrt(d2) / r;
if (impulse){ p.vx += dx * 0.02 * f; p.vy -= dy * 0.02 * f; }
}
}
}
function nova2d(x, y){
const px = x * cv.width, py = (1 - y) * cv.height, rr = cv.height * 0.42;
for (const p of parts){
const dx = p.x - px, dy = p.y - py, d = Math.hypot(dx, dy) + 1;
if (d < rr){
const f = (1 - d / rr) * 13 * DPR;
p.vx += dx / d * f; p.vy += dy / d * f;
}
}
splat2d(x, y, 0, 0, pickColor(), { radius: 5, dyeStrength: 0.4 });
}
function frame2d(now){
const w = cv.width, h = cv.height, t = now / 1000;
const cols = PALETTES[activePalette].colors;
ctx2d.globalCompositeOperation = 'source-over';
ctx2d.fillStyle = 'rgba(6,7,9,0.075)';
ctx2d.fillRect(0, 0, w, h);
ctx2d.globalCompositeOperation = 'lighter';
ctx2d.lineWidth = Math.max(1, DPR * 0.7);
for (const p of parts){
const fx = Math.sin(p.x * 0.0021 + t * 0.31) + Math.cos(p.y * 0.0017 - t * 0.23) + Math.sin((p.x + p.y) * 0.0009 + t * 0.11);
const fy = Math.cos(p.x * 0.0019 - t * 0.27) + Math.sin(p.y * 0.0023 + t * 0.19) + Math.cos((p.x - p.y) * 0.0011 - t * 0.13);
let vx = fx * 1.9 + p.vx, vy = fy * 1.9 + p.vy;
const dx = p.x - m2d.x, dy = p.y - m2d.y, d2 = dx * dx + dy * dy, rr = 140 * DPR * 140 * DPR;
if (d2 < rr){
const f = (1 - d2 / rr) * 6, d = Math.sqrt(d2) + 1;
vx += dx / d * f; vy += dy / d * f;
}
const nx = p.x + vx, ny = p.y + vy;
const c = cols[(p.r * cols.length) | 0];
ctx2d.strokeStyle = `rgba(${(c[0]*255)|0},${(c[1]*255)|0},${(c[2]*255)|0},0.5)`;
ctx2d.beginPath(); ctx2d.moveTo(p.x, p.y); ctx2d.lineTo(nx, ny); ctx2d.stroke();
p.x = nx; p.y = ny; p.vx *= 0.92; p.vy *= 0.92;
if (p.x < 0) p.x += w; if (p.x > w) p.x -= w;
if (p.y < 0) p.y += h; if (p.y > h) p.y -= h;
}
}
/* ============================================================
DISPATCHERS & INTERACTIONS
============================================================ */
function doSplat(x, y, dx, dy, color, opts){
if (mode === 'fluid') splatFluid(x, y, dx, dy, color, opts);
else splat2d(x, y, dx, dy, color, opts);
}
function doNova(x, y){
if (mode === 'fluid'){
shock.x = x; shock.y = y; shock.t = 0.0001;
splatFluid(x, y, 0, 0, pickColor(), { radius: 1.7, dyeStrength: 0.5 });
for (let i = 0; i < 14; i++){
const a = (i / 14) * Math.PI * 2 + Math.random() * 0.25;
const sp = 240 + Math.random() * 180;
splatFluid(x + Math.cos(a) * 0.02, y + Math.sin(a) * 0.02, Math.cos(a) * sp, Math.sin(a) * sp, pickColor(), { radius: 0.7, dyeStrength: 0.15 });
}
} else nova2d(x, y);
countGesture();
audio.nova();
heroDim(true);
setTimeout(() => heroDim(false), 2200);
}
function clearInk(){ clearFrames = 80; audio.blip(0.5, 0.5, 120); }
function countGesture(){
gestures++;
countEl.textContent = String(Math.min(gestures, 9999)).padStart(4, '0');
}
function uvFromEvent(e){
const rect = cv.getBoundingClientRect();
return { x: (e.clientX - rect.left) / rect.width, y: 1 - (e.clientY - rect.top) / rect.height };
}
let dimTimer = null;
function heroDim(on){
if (on){ hero.classList.add('dimmed'); clearTimeout(dimTimer); }
else {
clearTimeout(dimTimer);
dimTimer = setTimeout(() => { if (pointers.size === 0) hero.classList.remove('dimmed'); }, 2600);
}
}
function onPointerDown(e){
if (e.target !== cv) return;
if (e.pointerType === 'mouse' && e.button !== 0) return;
if (paused) return;
if (cv.setPointerCapture) { try { cv.setPointerCapture(e.pointerId); } catch(_){} }
const uv = uvFromEvent(e);
pointers.set(e.pointerId, { x: uv.x, y: uv.y, down: true, lastMove: performance.now(), lastEmit: 0 });
heroDim(true);
countGesture();
doSplat(uv.x, uv.y, 0, 0, pickColor(), { radius: 1.2, dyeStrength: 0.3 });
audio.blip(uv.x, uv.y, 70);
}
function onPointerMove(e){
const uv = uvFromEvent(e);
if (mode === '2d'){ m2d.x = uv.x * cv.width; m2d.y = (1 - uv.y) * cv.height; }
const p = pointers.get(e.pointerId);
if (!p || !p.down){
if (reducedMotion || paused) return;
const now = performance.now();
if (now - lastHover < 24) return;
lastHover = now;
if (lastHoverUV){
const dx = uv.x - lastHoverUV.x, dy = uv.y - lastHoverUV.y;
const d = Math.hypot(dx, dy);
if (d > 0.0004 && d < 0.2){
doSplat(uv.x, uv.y, dx * params.FORCE_HOVER, dy * params.FORCE_HOVER, pickColor(), { radius: 0.55, dyeStrength: 0.05 });
}
}
lastHoverUV = uv;
return;
}
const dx = uv.x - p.x, dy = uv.y - p.y;
const dist = Math.hypot(dx, dy);
p.lastMove = performance.now();
if (dist > 0.0002){
const steps = Math.min(5, Math.max(1, Math.ceil(dist / 0.015)));
for (let i = 1; i <= steps; i++){
const t = i / steps;
doSplat(p.x + dx * t, p.y + dy * t, 0, 0, pickColor(), { radius: 1, dyeStrength: 0.3 / steps });
}
doSplat(uv.x, uv.y, dx * params.FORCE_DRAG, dy * params.FORCE_DRAG, pickColor(), { radius: 1, dyeStrength: 0 });
audio.blip(uv.x, uv.y, dist * 2500);
}
p.x = uv.x; p.y = uv.y;
}
function onPointerUp(e){
if (pointers.delete(e.pointerId)) heroDim(false);
}
function onDouble(e){
if (e.target !== cv || paused) return;
const uv = uvFromEvent(e);
doNova(clamp01(uv.x), clamp01(uv.y));
}
/* ---------- auto life: the wanderer ---------- */
function autoEmit(now){
if (reducedMotion) return;
if (now < nextEmit) return;
nextEmit = now + 900 + Math.random() * 900;
const t = now / 1000;
const x = clamp01(0.5 + 0.34 * Math.sin(t * 0.13 + 1.0) + 0.12 * Math.sin(t * 0.047 + 2.0));
const y = clamp01(0.5 + 0.30 * Math.cos(t * 0.11 + 0.5) + 0.10 * Math.sin(t * 0.033));
if (Math.random() < 0.10){
for (let i = 0; i < 6; i++){
const a = Math.random() * 6.283, rr = Math.random() * 0.06;
doSplat(clamp01(x + Math.cos(a) * rr), clamp01(y + Math.sin(a) * rr), Math.cos(a) * 140, Math.sin(a) * 140, pickColor(), { radius: 1.2, dyeStrength: 0.10 });
}
} else {
doSplat(x, y, (Math.random() - 0.5) * 140, (Math.random() - 0.5) * 140, pickColor(), { radius: 2.3, dyeStrength: 0.10 });
}
}
function incense(now){
pointers.forEach(p => {
if (!p.down) return;
if (now - p.lastMove > 260 && now - p.lastEmit > 90){
p.lastEmit = now;
doSplat(
clamp01(p.x + (Math.random() - 0.5) * 0.004),
clamp01(p.y + (Math.random() - 0.5) * 0.004),
(Math.random() - 0.5) * 30, 90 + Math.random() * 60,
pickColor(), { radius: 0.55, dyeStrength: 0.06 }
);
}
});
}
/* ---------- UI wiring ---------- */
function togglePause(){
paused = !paused;
icoPause.hidden = paused;
icoPlay.hidden = !paused;
btnPause.classList.toggle('active', paused);
btnPause.title = paused ? 'Resume the field (Space)' : 'Stillness — hold the field (Space)';
}
btnPause.addEventListener('click', togglePause);
btnClear.addEventListener('click', clearInk);
btnSound.addEventListener('click', () => audio.toggle());
const palWrap = $('#palettes');
const dots = PALETTES.map((pal, i) => {
const b = document.createElement('button');
b.className = 'pdot' + (i === 0 ? ' active' : '');
b.title = pal.name;
b.setAttribute('aria-label', 'Palette: ' + pal.name);
b.style.background = `linear-gradient(135deg, ${pal.colors.map(rgbCss).join(',')})`;
b.addEventListener('click', () => setPalette(i));
palWrap.appendChild(b);
return b;
});
function setPalette(i){
activePalette = i;
dots.forEach((d, j) => d.classList.toggle('active', j === i));
palname.textContent = PALETTES[i].name;
palname.animate([{ opacity: 0.2 }, { opacity: 1 }], { duration: 500, easing: 'ease-out' });
doSplat(0.5 + (Math.random() - 0.5) * 0.3, 0.5 + (Math.random() - 0.5) * 0.3,
(Math.random() - 0.5) * 160, (Math.random() - 0.5) * 160, pickColor(), { radius: 2.2, dyeStrength: 0.16 });
}
document.addEventListener('pointerdown', onPointerDown);
window.addEventListener('pointermove', onPointerMove);
window.addEventListener('pointerup', onPointerUp);
window.addEventListener('pointercancel', onPointerUp);
document.addEventListener('dblclick', onDouble);
document.addEventListener('contextmenu', e => { if (e.target === cv) e.preventDefault(); });
window.addEventListener('keydown', e => {
if (e.repeat) return;
const onButton = (e.target.tagName || '').toLowerCase() === 'button';
if (e.code === 'Space' && !onButton){ e.preventDefault(); togglePause(); }
else if (e.code === 'KeyC' && !onButton){ clearInk(); }
else if (e.code === 'KeyS' && !onButton){ audio.toggle(); }
else if (e.code === 'KeyN' && !onButton){ doNova(0.5, 0.5); }
});
/* ---------- sizing ---------- */
function resizeCanvas(){
DPR = Math.min(devicePixelRatio || 1, isSmall ? 1.5 : 2);
const w = Math.max(2, Math.round(cv.clientWidth * DPR));
const h = Math.max(2, Math.round(cv.clientHeight * DPR));
if (cv.width !== w || cv.height !== h){
cv.width = w; cv.height = h;
if (mode === 'fluid') resizeSim();
else if (ctx2d){ ctx2d.fillStyle = '#07080a'; ctx2d.fillRect(0, 0, w, h); }
}
}
let rT;
window.addEventListener('resize', () => { clearTimeout(rT); rT = setTimeout(resizeCanvas, 120); });
cv.addEventListener('webglcontextlost', e => e.preventDefault());
/* ---------- main loop ---------- */
let lastT = performance.now(), frames = 0, fpsTimer = performance.now();
function frame(now){
requestAnimationFrame(frame);
const dt = Math.min((now - lastT) / 1000, 1 / 30);
lastT = now;
if (mode === 'fluid' && gl){
if (!paused){
step(dt);
autoEmit(now);
incense(now);
}
if (shock.t >= 0){ shock.t += dt; if (shock.t > 3.2) shock.t = -1; }
render(now / 1000);
} else if (!paused){
frame2d(now);
}
frames++;
if (now - fpsTimer > 1000){
fpsEl.textContent = String(Math.min(999, frames));
frames = 0; fpsTimer = now;
}
}
/* ---------- welcome bloom ---------- */
function welcome(){
setTimeout(() => doSplat(0.5, 0.52, 0, 0, pickColor(), { radius: 3.2, dyeStrength: 0.22 }), 120);
const n = 7;
for (let i = 0; i < n; i++){
setTimeout(() => {
const a = (i / n) * Math.PI * 2;
doSplat(0.5 + Math.cos(a) * 0.22, 0.5 + Math.sin(a) * 0.16,
Math.cos(a + 1.57) * 90, Math.sin(a + 1.57) * 90, pickColor(), { radius: 2.0, dyeStrength: 0.14 });
}, 250 + i * 140);
}
}
/* ---------- boot ---------- */
if (initFluid()){
try { resizeCanvas(); mode = 'fluid'; }
catch (err){ console.warn('FBO setup failed, falling back:', err); gl = null; mode = '2d'; }
}
if (mode === '2d') init2d();
resizeCanvas();
welcome();
requestAnimationFrame(frame);
</script>
</body>
</html>