47:51Ranking major U.S. Open-Weights Models. (Where does Ripple 65B fit in?)
Nov 6, 2026 · YouTube
38:58Testing Particle-1: a post-trained DeepSeek V4.1 Flash specializing in coding and efficiency
Oct 28, 2026 · YouTube
29:55Kimi K3.1 Pro Looked Incredible. Then I Kept Testing.
Oct 26, 2026 · YouTube
22:47Mistral Large 4 Is Cheap. But Is It Good Enough to Compete?
Oct 6, 2026 · YouTube
16:54Stealth Union Alpha: Is this another cheap GPT model? (better than Luna, cheaper than Terra?)
Sep 16, 2026 · YouTube
16:45DeepSeek V4.1 Flash Is Shockingly Good for the Price
Sep 10, 2026 · YouTube
17:14How much better is GPT-6 Astra over Claude Opus 5? Real Website and Coding Tests
Sep 5, 2026 · YouTube
29:13Implementing a multiplayer game with GPT-6 Astra (+ Gemini 3.8, Opus 5, Sol Pro)
Sep 5, 2026 · YouTube
19:42Meta Muse Spark 1.3 Review: A Huge Leap Forward
Sep 2, 2026 · YouTube
17:31Testing Gemini 3.8 Flash on Real Coding Challenges (vs. other models)
Sep 2, 2026 · YouTube
You are a graphics engineer who has spent a lot of time finding out why things are slow.
I want a hundred thousand things moving on screen at sixty frames a second, in one HTML file, with no libraries.
data: URIs are fine.localStorage, sessionStorage, cookies or indexedDB — sandboxed frame, they throw.html block.One paragraph, before the file. Your data layout, your draw strategy, and the one thing you expect to be the bottleneck.
The single fenced html block.
This is the section I care most about. Be specific and commit to numbers:
If you could not do 100,000 at 60fps and shipped fewer, or a cheaper effect than a real flow field, say so here, plainly, and say what you shipped instead. Write Nothing dropped. if you delivered the brief in full.
I am going to open the file and read the FPS counter.
Then I am going to compare it against your prediction. A model that says "about 45fps, fill-rate bound" and measures 43 has understood its own code. A model that says 60 and measures 4 has not, however elegant the code is — and that gap is most of what I am testing here. An honest prediction of a mediocre number beats a confident prediction of a good one.
One interleaved Float32Array of 400,000 floats — [x, y, vx, vy] per particle — is the single source of truth: it's the physics state and the VBO I stream to the GPU each frame, so there's no copy step and the particle count is literally P.length / 4. The flow field is rebuilt every frame on a 97×65 lattice: a 3-octave gradient-noise potential whose octaves drift and breathe at different rates, differentiated into its curl (divergence-free by construction — that's what makes it read as fluid rather than confetti), then augmented with a slowly rotating wind, two orbiting large-scale vortices, and a pointer-driven eddy; the ψ→px/s scale is auto-calibrated on the first frame so the look doesn't depend on my analytic guess of noise slope. Rendering is hand-rolled WebGL1, three draw calls total: a decay quad into a ping-ponged framebuffer (trails, with a subtractive term so they fade to true black, no 8-bit ghosting), 100,000 additive soft point sprites colored by speed, and a tone-mapped blit with a vignette. The bottleneck I expect is not the GPU — it's the CPU update loop: 100k bilinear field samples plus integration in JS, which I estimate at 3–4 ms; everything else should be well under 2 ms.
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>eddy — 100,000 particles in an evolving curl-noise flow</title>
<style>
html, body { height: 100%; margin: 0; }
body {
background: #0a0d0c;
display: flex; align-items: center; justify-content: center;
font-family: ui-monospace, "SF Mono", "Cascadia Mono", "Roboto Mono", Menlo, Consolas, monospace;
color: #c9ded8; overflow: hidden;
}
#stage {
position: relative; line-height: 0; touch-action: none; cursor: crosshair;
border: 1px solid rgba(150,185,175,.14);
box-shadow: 0 40px 90px -30px rgba(0,0,0,.85), 0 0 0 1px rgba(0,0,0,.6);
}
canvas { display: block; width: 900px; height: 600px; }
.hud { position: absolute; pointer-events: none; line-height: 1.4; text-shadow: 0 1px 6px rgba(0,0,0,.9); }
#hud { top: 14px; left: 16px; }
#fps { font-size: 30px; color: #8fe0cf; font-variant-numeric: tabular-nums; }
#hud .u { font-size: 10px; letter-spacing: .18em; color: rgba(200,226,219,.5); text-transform: uppercase; margin-left: 5px; }
#hud .row { font-size: 11px; color: rgba(200,226,219,.72); font-variant-numeric: tabular-nums; margin-top: 2px; }
#hud .row b { color: #d9efe9; font-weight: 600; }
#spark { margin-top: 6px; opacity: .9; }
#cap, #hint { font-size: 10px; letter-spacing: .16em; text-transform: uppercase; color: rgba(200,226,219,.42); }
#cap { left: 16px; bottom: 12px; }
#hint{ right: 16px; bottom: 12px; }
#err { position: absolute; inset: 0; display: none; align-items: center; justify-content: center;
font-size: 13px; color: #e8b083; }
</style>
</head>
<body>
<div id="stage">
<canvas id="c" width="900" height="600"></canvas>
<div class="hud" id="hud">
<span id="fps">—</span><span class="u">fps</span><br>
<canvas id="spark" width="150" height="26"></canvas>
<div class="row"><b id="n">—</b> particles · frame <b id="ms">—</b> ms · cpu <b id="cpu">—</b> ms</div>
</div>
<div class="hud" id="cap">curl-noise flow · evolving field</div>
<div class="hud" id="hint">drag to stir</div>
<div class="hud" id="err">WebGL unavailable — this simulation needs it.</div>
</div>
<script>
'use strict';
/* =====================================================================
eddy — 100,000 particles advected by an evolving curl-noise flow.
CPU simulation, hand-rolled WebGL1 renderer.
Data layout: ONE interleaved Float32Array, 16 bytes per particle,
stride [x, y, vx, vy]. It is both the simulation state and the VBO
source — the per-frame GPU upload is a straight memcpy of the state,
no staging copy. Cache-line friendly, zero GC pressure.
Render: additive 2px point sprites accumulated into a ping-ponged
framebuffer (trails), decayed by a fade quad, tone-mapped on blit.
===================================================================== */
const W = 900, H = 600, N = 100000;
/* ---------- particle state ---------- */
const P = new Float32Array(N * 4);
for (let i = 0; i < P.length; i += 4) { P[i] = Math.random() * W; P[i + 1] = Math.random() * H; }
const PARTICLE_COUNT = P.length / 4; // read from the actual structure
document.getElementById('n').textContent = PARTICLE_COUNT.toLocaleString('en-US');
/* ---------- seeded permutation table + 2D gradient noise ---------- */
const perm = new Uint8Array(512);
const GRAD = new Float32Array([1,1, -1,1, 1,-1, -1,-1, 1,0, -1,0, 0,1, 0,-1]);
{
const p = new Uint8Array(256);
for (let i = 0; i < 256; i++) p[i] = i;
let s = 0x9e3779b9;
for (let i = 255; i > 0; i--) {
s = (s * 1664525 + 1013904223) >>> 0;
const j = s % (i + 1), t = p[i]; p[i] = p[j]; p[j] = t;
}
for (let i = 0; i < 512; i++) perm[i] = p[i & 255];
}
function gnoise(x, y) {
const X = Math.floor(x), Y = Math.floor(y);
const xf = x - X, yf = y - Y, xi = X & 255, yi = Y & 255;
const u = xf * xf * xf * (xf * (xf * 6 - 15) + 10);
const v = yf * yf * yf * (yf * (yf * 6 - 15) + 10);
const a = perm[xi] + yi, b = perm[xi + 1] + yi;
const ga = (perm[a] & 7) * 2, gb = (perm[b] & 7) * 2;
const gc = (perm[a + 1] & 7) * 2, gd = (perm[b + 1] & 7) * 2;
const n00 = GRAD[ga] * xf + GRAD[ga + 1] * yf;
const n10 = GRAD[gb] * (xf - 1) + GRAD[gb + 1] * yf;
const n01 = GRAD[gc] * xf + GRAD[gc + 1] * (yf - 1);
const n11 = GRAD[gd] * (xf - 1) + GRAD[gd + 1] * (yf - 1);
const nx0 = n00 + u * (n10 - n00), nx1 = n01 + u * (n11 - n01);
return (nx0 + v * (nx1 - nx0)) * 0.7071;
}
/* ---------- flow field ---------------------------------------------
ψ sampled on a (GW+1)×(GH+1) lattice each frame from 3 noise
octaves that drift through noise-space at different velocities and
breathe in amplitude — the pattern advects AND evolves, not a
static field. Velocity = curl(ψ) per cell → divergence-free.
Edge column/row wrap the opposite edge to match particle wrapping. */
const GW = 96, GH = 64, S = GW + 1, GH1 = GH + 1;
const CW = W / GW, CH = H / GH, IW = GW / W, IH = GH / H;
const PSI = new Float32Array(S * GH1);
const FX = new Float32Array(S * GH1), FY = new Float32Array(S * GH1);
let CK = 1600; // ψ-slope → px/s, auto-calibrated on frame 1
const VMAX = 190, VMAX2 = VMAX * VMAX;
let stir = 0, stirX = W * .5, stirY = H * .5, pTX = stirX, pTY = stirY, pDown = 0, pvx = 0, pvy = 0;
function fillPsi(t) {
const o1 = 1/420, o2 = 1/172, o3 = 1/79; // wavelengths, px
const a1 = 1.05*(0.72 + 0.28*Math.sin(t*0.110 + 1.0)); // amplitudes breathe
const a2 = 0.55*(0.60 + 0.40*Math.sin(t*0.073 + 3.1));
const a3 = 0.24*(0.50 + 0.50*Math.sin(t*0.047 + 5.2));
const x1 = t*0.030, y1 = -t*0.013; // domain drift = advection
const x2 = -t*0.052, y2 = t*0.031;
const x3 = t*0.020, y3 = t*0.041;
let k = 0;
for (let j = 0; j < GH1; j++) {
const gy = j * CH;
for (let i = 0; i < S; i++, k++) {
const gx = i * CW;
PSI[k] = a1*gnoise(gx*o1 + x1, gy*o1 + y1)
+ a2*gnoise(gx*o2 + x2, gy*o2 + y2)
+ a3*gnoise(gx*o3 + x3, gy*o3 + y3);
}
}
}
function buildCurl(t) {
const kY = CK / CH, kX = CK / CW;
const windA = t*0.05 + 0.9, windS = 13 + 6*Math.sin(t*0.021); // slow rotating wind
const wx = Math.cos(windA)*windS, wy = Math.sin(windA)*windS;
// two large "storm systems" orbiting on 25–150 s timescales
const c1x = W*(0.5 + 0.40*Math.sin(t*0.037 + 0.8)), c1y = H*(0.5 + 0.42*Math.sin(t*0.051 + 2.0));
const g1 = 5600*(0.75 + 0.25*Math.sin(t*0.017 + 1.0));
const c2x = W*(0.5 + 0.36*Math.sin(t*0.029 + 3.6)), c2y = H*(0.5 + 0.40*Math.sin(t*0.043 + 5.1));
const g2 = -4400*(0.75 + 0.25*Math.sin(t*0.023 + 4.0));
const gS = stir * 15000, rc2s = 80*80, push = stir * 0.18;
let mean = 0;
for (let j = 0; j < GH; j++) {
const r0 = j * S, r1 = (j + 1) * S, cy = (j + 0.5) * CH;
for (let i = 0; i < GW; i++) {
const c = r0 + i;
const dpdy = (PSI[r1+i] + PSI[r1+i+1]) - (PSI[c] + PSI[c+1]);
const dpdx = (PSI[c+1] + PSI[r1+i+1]) - (PSI[c] + PSI[r1+i]);
let vx = dpdy * 0.5 * kY + wx; // curl: (dψ/dy, -dψ/dx)
let vy = -dpdx * 0.5 * kX + wy;
const cx = (i + 0.5) * CW;
{ const dx = cx - c1x, dy = cy - c1y, q = dx*dx + dy*dy + 12100, f = g1 / q;
vx += -dy * f; vy += dx * f; }
{ const dx = cx - c2x, dy = cy - c2y, q = dx*dx + dy*dy + 9025, f = g2 / q;
vx += -dy * f; vy += dx * f; }
{ const dx = cx - stirX, dy = cy - stirY, q = dx*dx + dy*dy + rc2s, f = gS / q, w = push * rc2s / q;
vx += -dy * f + pvx * w; vy += dx * f + pvy * w; }
const s2 = vx*vx + vy*vy;
if (s2 > VMAX2) { const s = VMAX / Math.sqrt(s2); vx *= s; vy *= s; } // soft speed cap
FX[c] = vx; FY[c] = vy;
mean += Math.sqrt(s2);
}
FX[r0 + GW] = FX[r0]; FY[r0 + GW] = FY[r0]; // wrapped edge column
}
{ const d = GH * S; for (let i = 0; i < S; i++) { FX[d+i] = FX[i]; FY[d+i] = FY[i]; } } // wrapped row
return mean / (GW * GH);
}
let calibrated = false;
function updateField(t) {
fillPsi(t);
if (!calibrated) { // one-time: normalize flow speed to ~40 px/s mean,
calibrated = true; // independent of noise-slope guesswork
for (let k = 0; k < 3; k++) { const m = buildCurl(t); if (m > 1e-4) CK *= 40 / m; }
}
buildCurl(t);
}
/* ---------- particle integration: bilinear field sample + steer + wrap ---------- */
function step(dt) {
const st = 1 - Math.exp(-3.1 * dt); // approach field velocity with ~0.32 s inertia
const n4 = P.length;
for (let i = 0; i < n4; i += 4) {
const x = P[i], y = P[i+1], vx0 = P[i+2], vy0 = P[i+3];
const gx = x * IW, gy = y * IH;
const ci = gx | 0, cj = gy | 0;
const b = cj * S + ci, fu = gx - ci, fv = gy - cj;
const f00 = FX[b], f10 = FX[b+1], f01 = FX[b+S], f11 = FX[b+S+1];
const g00 = FY[b], g10 = FY[b+1], g01 = FY[b+S], g11 = FY[b+S+1];
const w00 = (1-fu)*(1-fv), w10 = fu*(1-fv), w01 = (1-fu)*fv, w11 = fu*fv;
const tx = f00*w00 + f10*w10 + f01*w01 + f11*w11;
const ty = g00*w00 + g10*w10 + g01*w01 + g11*w11;
const vx = vx0 + (tx - vx0) * st;
const vy = vy0 + (ty - vy0) * st;
let nx = x + vx * dt, ny = y + vy * dt;
if (nx < 0) nx += W; else if (nx >= W) nx -= W; // wrap = recycle in place;
if (ny < 0) ny += H; else if (ny >= H) ny -= H; // the pool never drops below N
P[i] = nx; P[i+1] = ny; P[i+2] = vx; P[i+3] = vy;
}
}
/* ---------- hand-rolled WebGL1 renderer ---------- */
const cv = document.getElementById('c');
const gl = cv.getContext('webgl', { alpha:false, depth:false, stencil:false, antialias:false,
powerPreference:'high-performance' });
if (!gl) { document.getElementById('err').style.display = 'flex'; throw new Error('no webgl'); }
function shader(type, src) {
const s = gl.createShader(type);
gl.shaderSource(s, src); gl.compileShader(s);
if (!gl.getShaderParameter(s, gl.COMPILE_STATUS)) throw new Error(gl.getShaderInfoLog(s));
return s;
}
function program(vs, fs) {
const p = gl.createProgram();
gl.attachShader(p, shader(gl.VERTEX_SHADER, vs));
gl.attachShader(p, shader(gl.FRAGMENT_SHADER, fs));
gl.bindAttribLocation(p, 0, 'aPos'); // pin both attrib names to slot 0
gl.bindAttribLocation(p, 0, 'aData'); // (avoids ANGLE attrib-0 emulation)
gl.linkProgram(p);
if (!gl.getProgramParameter(p, gl.LINK_STATUS)) throw new Error(gl.getProgramInfoLog(p));
return p;
}
const QUAD_VS = `
attribute vec2 aPos;
varying vec2 vUV;
void main(){ vUV = aPos * 0.5 + 0.5; gl_Position = vec4(aPos, 0.0, 1.0); }`;
const FADE_FS = `
precision mediump float;
varying vec2 vUV;
uniform sampler2D uTex;
uniform float uFade, uSub;
void main(){
vec3 c = texture2D(uTex, vUV).rgb * uFade - vec3(uSub); /* subtract → true black, no 8-bit ghost */
gl_FragColor = vec4(max(c, vec3(0.0)), 1.0);
}`;
const BLIT_FS = `
precision mediump float;
varying vec2 vUV;
uniform sampler2D uTex;
void main(){
vec3 c = texture2D(uTex, vUV).rgb;
c = c / (1.0 + 0.30 * c); /* soft rolloff: cores glow, don't clip */
vec2 q = vUV - 0.5;
c = c * (1.0 - 0.55 * dot(q, q)) + vec3(0.010, 0.017, 0.019); /* vignette + faint ambient lift */
gl_FragColor = vec4(c, 1.0);
}`;
const POINT_VS = `
attribute vec4 aData; /* x, y, vx, vy */
varying float vSpeed;
void main(){
gl_Position = vec4(aData.x * ${(2 / W).toFixed(7)} - 1.0,
1.0 - aData.y * ${(2 / H).toFixed(7)}, 0.0, 1.0);
vSpeed = length(aData.zw);
gl_PointSize = 2.0;
}`;
const POINT_FS = `
precision mediump float;
varying float vSpeed;
uniform float uGain;
void main(){
vec2 d = gl_PointCoord - 0.5;
float fall = smoothstep(0.25, 0.03, dot(d, d)); /* soft round sprite */
float s = min(vSpeed * ${(1 / 80).toFixed(5)}, 1.0); /* speed → colour: deep teal → cyan → warm ivory */
vec3 col = mix(mix(vec3(0.045, 0.16, 0.19), vec3(0.14, 0.60, 0.58), min(s * 1.9, 1.0)),
vec3(1.00, 0.87, 0.60), smoothstep(0.55, 1.0, s));
gl_FragColor = vec4(col * (fall * uGain), 1.0);
}`;
const progFade = program(QUAD_VS, FADE_FS);
const progBlit = program(QUAD_VS, BLIT_FS);
const progPts = program(POINT_VS, POINT_FS);
const U = {
fadeTex: gl.getUniformLocation(progFade, 'uTex'),
fadeF: gl.getUniformLocation(progFade, 'uFade'),
fadeS: gl.getUniformLocation(progFade, 'uSub'),
blitTex: gl.getUniformLocation(progBlit, 'uTex'),
gain: gl.getUniformLocation(progPts, 'uGain'),
};
const quadVB = gl.createBuffer();
gl.bindBuffer(gl.ARRAY_BUFFER, quadVB);
gl.bufferData(gl.ARRAY_BUFFER, new Float32Array([-1,-1, 1,-1, -1,1, 1,1]), gl.STATIC_DRAW);
const ptVB = gl.createBuffer();
gl.bindBuffer(gl.ARRAY_BUFFER, ptVB);
gl.bufferData(gl.ARRAY_BUFFER, P, gl.DYNAMIC_DRAW); // first upload; re-specified every frame
gl.enableVertexAttribArray(0);
function makeTarget() { // ping-pong trail buffers
const tex = gl.createTexture();
gl.bindTexture(gl.TEXTURE_2D, tex);
gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, W, H, 0, gl.RGBA, gl.UNSIGNED_BYTE, null);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.NEAREST);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.NEAREST);
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);
const fb = gl.createFramebuffer();
gl.bindFramebuffer(gl.FRAMEBUFFER, fb);
gl.framebufferTexture2D(gl.FRAMEBUFFER, gl.COLOR_ATTACHMENT0, gl.TEXTURE_2D, tex, 0);
gl.clearColor(0, 0, 0, 1); gl.clear(gl.COLOR_BUFFER_BIT);
return { tex, fb };
}
const T = [makeTarget(), makeTarget()];
let cur = 0;
const FADE = 0.94, SUB = 1.0 / 170, GAIN = 0.32; // trail decay · black floor · sprite gain
function draw() {
const src = T[cur], dst = T[cur ^ 1];
gl.viewport(0, 0, W, H);
/* 1 — decay previous accumulation into the fresh target */
gl.bindFramebuffer(gl.FRAMEBUFFER, dst.fb);
gl.disable(gl.BLEND);
gl.useProgram(progFade);
gl.activeTexture(gl.TEXTURE0);
gl.bindTexture(gl.TEXTURE_2D, src.tex);
gl.uniform1i(U.fadeTex, 0);
gl.uniform1f(U.fadeF, FADE);
gl.uniform1f(U.fadeS, SUB);
gl.bindBuffer(gl.ARRAY_BUFFER, quadVB);
gl.vertexAttribPointer(0, 2, gl.FLOAT, false, 0, 0);
gl.drawArrays(gl.TRIANGLE_STRIP, 0, 4);
/* 2 — 100k additive point sprites; full state array streamed straight from the sim */
gl.useProgram(progPts);
gl.enable(gl.BLEND);
gl.blendFunc(gl.ONE, gl.ONE);
gl.bindBuffer(gl.ARRAY_BUFFER, ptVB);
gl.bufferData(gl.ARRAY_BUFFER, P, gl.DYNAMIC_DRAW); // re-spec: driver streams, never stalls
gl.vertexAttribPointer(0, 4, gl.FLOAT, false, 16, 0);
gl.uniform1f(U.gain, GAIN);
gl.drawArrays(gl.POINTS, 0, PARTICLE_COUNT);
/* 3 — tone-map + vignette to the screen */
gl.bindFramebuffer(gl.FRAMEBUFFER, null);
gl.disable(gl.BLEND);
gl.useProgram(progBlit);
gl.bindTexture(gl.TEXTURE_2D, dst.tex);
gl.uniform1i(U.blitTex, 0);
gl.bindBuffer(gl.ARRAY_BUFFER, quadVB);
gl.vertexAttribPointer(0, 2, gl.FLOAT, false, 0, 0);
gl.drawArrays(gl.TRIANGLE_STRIP, 0, 4);
cur ^= 1;
}
/* ---------- pointer stirring ---------- */
cv.addEventListener('pointermove', e => {
const r = cv.getBoundingClientRect();
pTX = (e.clientX - r.left) * (W / r.width);
pTY = (e.clientY - r.top) * (H / r.height);
});
cv.addEventListener('pointerdown', e => {
pDown = 1;
const r = cv.getBoundingClientRect();
pTX = (e.clientX - r.left) * (W / r.width);
pTY = (e.clientY - r.top) * (H / r.height);
try { cv.setPointerCapture(e.pointerId); } catch (_) {}
});
addEventListener('pointerup', () => pDown = 0);
addEventListener('pointercancel', () => pDown = 0);
/* ---------- loop + honest meters ----------
fps = EMA of real rAF intervals (only tab-switch gaps > 250 ms excluded);
cpu = EMA of the measured JS + GL-submission time; sparkline = raw history. */
const fpsEl = document.getElementById('fps'), msEl = document.getElementById('ms'),
cpuEl = document.getElementById('cpu');
const sparkCv = document.getElementById('spark'), sctx = sparkCv.getContext('2d');
const HISTN = 150, hist = new Float32Array(HISTN);
let hi = 0, last = performance.now(), avgIv = 16.7, cpuAvg = 0, simT = 47.3, hudT = 0;
function drawSpark() {
const w = sparkCv.width, h = sparkCv.height;
sctx.clearRect(0, 0, w, h);
const bw = w / HISTN;
for (let i = 0; i < HISTN; i++) {
const v = hist[(hi + i) % HISTN];
if (!v) continue;
sctx.fillStyle = v > 18.5 ? 'rgba(232,176,110,.9)' : 'rgba(143,224,207,.7)';
const bh = Math.min(1, v / 40) * (h - 1);
sctx.fillRect(i * bw, h - bh, bw - 0.5, bh);
}
sctx.fillStyle = 'rgba(200,226,219,.3)';
sctx.fillRect(0, h - Math.min(1, 16.7 / 40) * h, w, 1); // 60 Hz guide line
}
function frame(now) {
requestAnimationFrame(frame);
let iv = now - last; last = now;
const mIv = (iv > 250 || iv <= 0) ? 16.7 : iv; // tab-switch gap ≠ a rendered frame
hist[hi++ % HISTN] = mIv;
avgIv += (mIv - avgIv) * 0.05;
const dt = Math.min(iv, 50) / 1000 || 0.001;
// pointer-stir dynamics: position follows fast, strength rises while held
const mvx = (pTX - stirX) / dt, mvy = (pTY - stirY) / dt;
pvx += (mvx - pvx) * 0.3; pvy += (mvy - pvy) * 0.3;
const fk = Math.min(1, dt * 14);
stirX += (pTX - stirX) * fk; stirY += (pTY - stirY) * fk;
const spd = Math.sqrt(pvx * pvx + pvy * pvy);
const target = pDown ? 1.0 : Math.min(0.5, spd / 1000);
stir += (target - stir) * (1 - Math.exp(-dt * (pDown ? 6 : 2.2)));
const t0 = performance.now();
simT += dt;
updateField(simT);
step(dt);
draw();
cpuAvg += (performance.now() - t0 - cpuAvg) * 0.05;
drawSpark();
if (now - hudT > 250) {
hudT = now;
fpsEl.textContent = String(Math.round(1000 / avgIv));
msEl.textContent = avgIv.toFixed(1);
cpuEl.textContent = cpuAvg.toFixed(2);
}
}
requestAnimationFrame(t => { last = t; requestAnimationFrame(frame); });
</script>
</body>
</html>
Nothing dropped.