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.
State is a struct-of-arrays: six typed arrays (Float32Array x, y, vx, vy; Int32Array life; Uint8Array palette offset) so the update loop is a linear scan with no object headers and no GC. The flow field is not sampled per particle from a noise function — that's the classic way to die at 100k. Instead I evaluate a divergence-free field once per frame onto a coarse 77×52 grid (five sine modes of a stream function, differentiated analytically, plus two counter-rotating drifting vortices and a slow global wind), ~4000 nodes × 5 cos ≈ 0.4 ms, and each particle does a bilinear fetch from that grid — 8 float reads and some multiplies. Drawing bypasses the 2D path entirely: I own one ImageData, view it as a Uint32Array, decay every pixel by 7/8 with a per-byte mask trick (c - ((c>>>3)&0x1f1f1f1f)) to get trails, splat one 32-bit word per particle from a 256-entry speed×hue LUT, and putImageData once. No fillRect, no arc, no per-particle state changes on the context. The bottleneck I expect is the particle loop itself, and specifically the scattered write at the end of it: 100k effectively random writes into a 2.16 MB buffer means ~100k cache-line misses per frame that no amount of arithmetic tightening removes.
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>100,000 particles — evolving flow field</title>
<style>
html,body{margin:0;background:#06070b;color:#bfe6f5;
font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace}
#wrap{position:relative;width:900px;margin:14px auto 0}
canvas{display:block;width:900px;height:600px;background:#000}
#hud{position:absolute;left:10px;top:8px;font-size:12px;line-height:1.5;
white-space:pre;pointer-events:none;
text-shadow:0 0 7px #000,0 0 3px #000,0 1px 0 #000}
#hud .k{color:#7fe8ff}
#hud .d{color:#7f8f9c}
#foot{width:900px;margin:8px auto 20px;font-size:11px;color:#586a78;line-height:1.6}
</style>
</head>
<body>
<div id="wrap"><canvas id="c" width="900" height="600"></canvas><div id="hud"></div></div>
<div id="foot">
Struct-of-arrays particles · divergence-free stream-function field on a 77×52 grid, re-evaluated every frame ·
bilinear sample per particle · trails by per-byte 7/8 decay of a Uint32 view of one ImageData · single putImageData.
</div>
<script>
(function(){
"use strict";
/* ---------------- canvas / framebuffer ---------------- */
const W = 900, H = 600, PIX = W * H;
const cv = document.getElementById('c');
const ctx = cv.getContext('2d', { alpha:false, desynchronized:true });
const img = ctx.createImageData(W, H);
const buf = new Uint32Array(img.data.buffer); // 0xAABBGGRR (little-endian)
const BG = 0xff000000;
buf.fill(BG);
/* ---------------- particles: struct of arrays ---------------- */
const N = 100000;
const px = new Float32Array(N);
const py = new Float32Array(N);
const pvx = new Float32Array(N);
const pvy = new Float32Array(N);
const plf = new Int32Array(N); // frames of life left before recycling
const phu = new Uint8Array(N); // palette row offset (hue<<5)
let seed = 0x9e3779b9|0;
function rnd(){ seed^=seed<<13; seed^=seed>>>17; seed^=seed<<5; return (seed>>>0)/4294967296; }
/* colour LUT: 8 hues x 32 brightness levels, indexed by hue<<5 | speedLevel */
const HUES = [[ 70,130,255],[120,205,255],[175,150,255],[255,120,205],
[110,255,225],[255,210,140],[ 90,255,170],[230,244,255]];
const LUT = new Uint32Array(8*32);
for (let h=0; h<8; h++){
const r=HUES[h][0], g=HUES[h][1], b=HUES[h][2];
for (let l=0; l<32; l++){
const s = l/31;
LUT[(h<<5)|l] = (255<<24) | (((b*s)|0)<<16) | (((g*s)|0)<<8) | ((r*s)|0);
}
}
for (let i=0; i<N; i++){
px[i] = rnd()*W; py[i] = rnd()*H;
pvx[i]= 0; pvy[i]= 0;
plf[i]= 30 + (rnd()*480|0);
phu[i]= (rnd()*8|0) << 5;
}
/* ---------------- the flow field ---------------- */
/* Stream function psi = sum A_m * sin(kx*x + ky*y + w*t + ph).
u = d(psi)/dy, v = -d(psi)/dx -> divergence-free (no sources, only eddies).
Amplitudes normalised by |k| so each mode contributes ~A px/frame of speed.
Plus two counter-rotating vortices whose centres orbit, plus a slow global wind. */
const GS = 12, invGS = 1/GS;
const GW = (W/GS|0) + 2, GH = (H/GS|0) + 2; // 77 x 52 = 4004 nodes
const fu = new Float32Array(GW*GH);
const fv = new Float32Array(GW*GH);
const TAU = Math.PI*2;
const M = [];
function mode(nx, ny, w, ph, A){
const kx = TAU*nx/W, ky = TAU*ny/H, k = Math.sqrt(kx*kx+ky*ky);
M.push({ kx:kx, ky:ky, w:w, ph:ph, cu:A*ky/k, cv:-A*kx/k });
}
mode( 0.9, 0.6, 0.110, 0.0, 1.70);
mode(-1.6, 1.2, -0.170, 1.3, 1.15);
mode( 2.7, -2.1, 0.230, 2.6, 0.72);
mode(-4.3, 3.4, -0.290, 4.1, 0.44);
mode( 6.9, 5.7, 0.370, 5.5, 0.27);
const kx0=M[0].kx, ky0=M[0].ky, cu0=M[0].cu, cv0=M[0].cv, w0=M[0].w, ph0=M[0].ph;
const kx1=M[1].kx, ky1=M[1].ky, cu1=M[1].cu, cv1=M[1].cv, w1=M[1].w, ph1=M[1].ph;
const kx2=M[2].kx, ky2=M[2].ky, cu2=M[2].cu, cv2=M[2].cv, w2=M[2].w, ph2=M[2].ph;
const kx3=M[3].kx, ky3=M[3].ky, cu3=M[3].cu, cv3=M[3].cv, w3=M[3].w, ph3=M[3].ph;
const kx4=M[4].kx, ky4=M[4].ky, cu4=M[4].cu, cv4=M[4].cv, w4=M[4].w, ph4=M[4].ph;
const VS1 = 950, VS2 = -880, VSOFT = 25000;
function buildField(t){
const cos = Math.cos, sin = Math.sin;
const p0=w0*t+ph0, p1=w1*t+ph1, p2=w2*t+ph2, p3=w3*t+ph3, p4=w4*t+ph4;
const dux = 0.55*cos(t*0.077), duy = 0.40*sin(t*0.061);
const cx1 = 450 + 270*cos(t*0.19), cy1 = 300 + 140*sin(t*0.25);
const cx2 = 450 + 300*cos(t*0.13+2.2), cy2 = 300 + 165*sin(t*0.11+1.1);
const _fu = fu, _fv = fv;
let o = 0;
for (let j=0; j<GH; j++){
const y = j*GS;
const r0=ky0*y+p0, r1=ky1*y+p1, r2=ky2*y+p2, r3=ky3*y+p3, r4=ky4*y+p4;
const dy1 = y-cy1, dy2 = y-cy2;
const dy1s = dy1*dy1, dy2s = dy2*dy2;
for (let i=0; i<GW; i++, o++){
const x = i*GS;
const c0=cos(kx0*x+r0), c1=cos(kx1*x+r1), c2=cos(kx2*x+r2),
c3=cos(kx3*x+r3), c4=cos(kx4*x+r4);
let u = cu0*c0 + cu1*c1 + cu2*c2 + cu3*c3 + cu4*c4 + dux;
let v = cv0*c0 + cv1*c1 + cv2*c2 + cv3*c3 + cv4*c4 + duy;
let dx = x-cx1;
let f = VS1/(dx*dx + dy1s + VSOFT);
u -= dy1*f; v += dx*f;
dx = x-cx2;
f = VS2/(dx*dx + dy2s + VSOFT);
u -= dy2*f; v += dx*f;
_fu[o] = u; _fv[o] = v;
}
}
}
/* ---------------- trail decay: per-byte multiply by 7/8 ---------------- */
function decay(){
const b = buf;
for (let i=0; i<PIX; i++){
const c = b[i];
if (c !== BG){
const d = c - ((c>>>3) & 0x1f1f1f1f);
b[i] = (d & 0xf8f8f8) ? (d | 0xff000000) : BG;
}
}
}
/* ---------------- update + splat ---------------- */
function step(){
const _px=px, _py=py, _vx=pvx, _vy=pvy, _lf=plf, _hu=phu;
const _fu=fu, _fv=fv, _b=buf, _L=LUT;
const gw=GW, ig=invGS, w=W, h=H;
const n = _px.length; // count read from the data structure
let s = seed;
for (let i=0; i<n; i++){
let x = _px[i], y = _py[i];
const gx = x*ig, gy = y*ig;
const i0 = gx|0, j0 = gy|0;
const fx = gx-i0, fy = gy-j0;
const o = j0*gw + i0;
const ax = 1-fx, ay = 1-fy;
const w00 = ax*ay, w10 = fx*ay, w01 = ax*fy, w11 = fx*fy;
const u = _fu[o]*w00 + _fu[o+1]*w10 + _fu[o+gw]*w01 + _fu[o+gw+1]*w11;
const v = _fv[o]*w00 + _fv[o+1]*w10 + _fv[o+gw]*w01 + _fv[o+gw+1]*w11;
let vx = _vx[i], vy = _vy[i];
vx += (u - vx)*0.12; // inertia -> streaks curl instead of snapping
vy += (v - vy)*0.12;
x += vx; y += vy;
let lf = _lf[i] - 1;
if (lf <= 0 || !(x >= 0 && x < w && y >= 0 && y < h)){
s^=s<<13; s^=s>>>17; s^=s<<5; x = (s>>>0)/4294967296*w;
s^=s<<13; s^=s>>>17; s^=s<<5; y = (s>>>0)/4294967296*h;
s^=s<<13; s^=s>>>17; s^=s<<5; lf = 90 + ((s>>>0)%420);
vx = 0; vy = 0;
}
_px[i]=x; _py[i]=y; _vx[i]=vx; _vy[i]=vy; _lf[i]=lf;
const sp = vx*vx + vy*vy;
let l = 9 + (sp*2.4|0); if (l > 31) l = 31;
_b[(y|0)*w + (x|0)] = _L[_hu[i] + l];
}
seed = s;
}
/* ---------------- honest timing ---------------- */
const hud = document.getElementById('hud');
const HN = 90;
const dts = new Float64Array(HN);
let dtCount = 0, dtIdx = 0;
let accF=0, accS=0, accB=0, accSamples=0;
let last = performance.now(), hudAt = 0;
function frame(now){
const dt = now - last; last = now;
if (dt > 0 && dt < 500){ dts[dtIdx] = dt; dtIdx = (dtIdx+1)%HN; if (dtCount<HN) dtCount++; }
const t = now * 0.001;
const a = performance.now();
buildField(t);
const b = performance.now();
decay();
const c = performance.now();
step();
const d = performance.now();
ctx.putImageData(img, 0, 0);
const e = performance.now();
accF += (b-a); accB += (c-b) + (e-d); accS += (d-c); accSamples++;
if (now - hudAt > 200){
hudAt = now;
let sum = 0;
for (let i=0; i<dtCount; i++) sum += dts[i];
const mean = dtCount ? sum/dtCount : 0;
const fps = mean > 0 ? 1000/mean : 0;
const k = accSamples || 1;
hud.innerHTML =
'<span class="k">FPS</span> ' + fps.toFixed(1) +
' <span class="d">(' + mean.toFixed(2) + ' ms/frame, mean of ' + dtCount + ')</span>\n' +
'<span class="k">particles</span> ' + px.length.toLocaleString() +
' <span class="d">(px.length)</span>\n' +
'<span class="d">field ' + (accF/k).toFixed(2) + ' ms ' +
'update+splat ' + (accS/k).toFixed(2) + ' ms ' +
'decay+blit ' + (accB/k).toFixed(2) + ' ms</span>';
accF=accS=accB=0; accSamples=0;
}
requestAnimationFrame(frame);
}
requestAnimationFrame(frame);
})();
</script>
</body>
</html>
FPS I expect: 60 on a 2023 laptop with Iris Xe / Radeon 780M class integrated graphics in Chrome on a 60 Hz panel — i.e. vsync-capped, with the internal readout showing roughly 6–9 ms of CPU work per 16.7 ms frame. On a 120 Hz panel it should read somewhere near 100–120. If the machine is a low-power U-series part with DDR4 and a busy thermal budget, the honest downside case is ~50, and the thing that will have given way is the scattered write / blit, not the math.
Where the time goes (from the on-screen breakdown, which is measured, not asserted):
c !== BG early-out skips the black majority; putImageData is a 2.16 MB texture upload per frame, which on integrated graphics is shared-memory bandwidth and is usually 0.6–1.5 ms.Math.cos. This is why the field is on a grid and not per-particle: sampling five sines per particle would be 500k cos calls per frame, ~8–12 ms on its own, and would have cost me the frame.The first thing that breaks at 1M: the update loop, via memory, not arithmetic. The six state arrays go from ~2.2 MB to ~22 MB, so the streaming reads stop being cache-resident, and the scattered splat now issues 1M misses per frame into a framebuffer that only has 540k pixels — so you're paying full cache-miss cost for writes that mostly overwrite each other (roughly 2 particles per pixel; the image stops gaining information while the cost keeps climbing). Expect ~40–55 ms/frame, i.e. 18–25 fps, with update+splat swelling to ~85% of the frame. Nothing crashes; it just goes syrupy. The decay and blit are unchanged — they're per-pixel, not per-particle.
What you'd need to go ten times faster: get the particles off the CPU entirely. WebGL2, state in two RGBA32F textures ping-ponged through a fragment shader (or gl.TRANSFORM_FEEDBACK into a double-buffered VBO if you prefer vertex-stage integration), the flow field evaluated analytically in the shader from the same stream-function modes — no grid, no interpolation, no CPU→GPU upload of particle data at all. Draw as GL_POINTS with additive blending into a float or half-float FBO, and do the trail decay as a full-screen quad multiply instead of a JS loop over Uint32Array. That removes the two things that dominate here — the scattered scalar write and the 2.16 MB putImageData — and 1M–4M particles at 60 fps becomes a fill-rate question rather than a memory-latency question.
Nothing dropped.