Two models get the same prompt. Each writes a shader. Your GPU is the judge — and the error log is the commentator.
The idea
Benchmarks tell you a model "scores 82 on HumanEval." That's abstract. Refract makes model quality visible: one canvas alive with an animated neon tunnel, the other black with ERROR: 0:12: 'outColor' : undeclared identifier staring at you. Then the failing model gets exactly one chance to fix itself. That before/after frame is something a leaderboard can never give you — and it's pure screen-record bait.
The non-negotiable rule: never fake it
Everything renders live. The browser compiles the model's actual text output with a real WebGL2 context and shows the driver's real info log. No canned shaders, no "simulated error." Three verification experiments prove the rig measures reality: a tunnel prompt (something must animate), a deliberately broken shader (the log must appear verbatim and repair must change the code), and a double-run nonce check (byte-identical outputs twice = you're reading a cache).
Architecture
Browser (Vite :5173) Node backend (:3001, auto-bump) Providers
┌────────────────────┐ /api/forge ┌──────────────────────┐ fetch ┌──────────────┐
│ Slot A cfg │ Slot B│ ──────────▶ │ Promise.all(A, B) │ ──────▶ │ Particle.ai │
│ canvas A │ cv B │ ◀────────── │ {raw, glsl, sha, tok}│ ◀────── │ Ollama / LMS │
│ single rAF ─────────│ /api/repair│ strip reasoning_ │ │ OpenRouter │
│ u_time, u_resolution│ {glsl, log} │ content · retry 2× │ └──────────────┘
└────────────────────┘ /api/models│ nonce per run │
▲ (proxy) └──────────────────────┘
│ Vite dev middleware re-reads .backend-port per request
│ so backend port bumps never break the proxy
All model traffic goes through the backend. That single decision buys three things: localhost providers work with zero CORS setup, API keys never touch the client bundle, and reasoning_content can be stripped in one place before it ever reaches the browser.
The rendering core: ~40 lines of raw WebGL2
No three.js. The vertex shader is a fullscreen triangle with zero buffers — gl_VertexID does all the work:
#version 300 es
void main(){
vec2 p = vec2(float((gl_VertexID << 1) & 2), float(gl_VertexID & 2));
gl_Position = vec4(p * 2.0 - 1.0, 0.0, 1.0);
}
// frontend/src/webgl.ts
export function compileProgram(gl: WebGL2RenderingContext, fragSrc: string) {
const vs = gl.createShader(gl.VERTEX_SHADER)!;
gl.shaderSource(vs, VERT); gl.compileShader(vs);
const fs = gl.createShader(gl.FRAGMENT_SHADER)!;
gl.shaderSource(fs, fragSrc); gl.compileShader(fs);
if (!gl.getShaderParameter(fs, gl.COMPILE_STATUS))
return { ok: false, log: gl.getShaderInfoLog(fs) }; // verbatim, never paraphrased
const prog = gl.createProgram()!;
gl.attachShader(prog, vs); gl.attachShader(prog, fs); gl.linkProgram(prog);
return { ok: true, program: prog };
}
One requestAnimationFrame loop drives both canvases, setting u_time and u_resolution per frame with DPR-aware viewports. Each canvas owns its own context, so a failed compile on one side can't touch the other.
The model layer: capability detection, not assumptions
The interesting bugs live here. Local providers (Ollama, LM Studio) don't report reasoning tokens — so the UI shows n/a and hides the thinking toggle instead of printing a fake 0. The chat_template_kwargs: {enable_thinking: false} field is sent only for Particle deepseek-* models; anything else would reject it. Reasoning budgets floor at 900 tokens, and an HTTP 200 with empty content triggers one retry at double budget — because a silent empty reply usually means the hidden chain-of-thought ate the whole window.
// Strip the CoT text; only its token COUNT may leave the server
const { reasoning_content: _strip, reasoning: _strip2, ...safeMsg } = msg;
const reasoningTokens = json?.usage?.completion_tokens_details?.reasoning_tokens ?? null;
And the model picker degrades gracefully: GET {base}/models populates suggestions, but a hand-typed name always runs — because real models like deepseek-v4-flash-0731 don't even appear in the provider's own list.
The repair pass: the actual story
const repairPrompt = `The following GLSL ES 3.00 fragment shader failed to compile. ...`
+ failingShader + verbatimCompilerLog + freshNonce;
The error log goes back verbatim to the same model. What comes back gets recompiled immediately. Over a session, the history strip accumulates compiled/failed per model — a visible scoreboard of who writes valid GLSL and who can debug it.
The unglamorous win: ports
The least exciting code had the most user-visible impact. Both servers auto-bump when their port is taken (Vite did already; the Express backend learned to scan 3001–3020 and write .backend-port), and the Vite dev proxy re-reads that file per request instead of caching :3001 at startup. npm run dev now survives a crowded machine.
What I'd add next
Tournament ELO across N models, a diff view for original-vs-repaired shaders, perceptual-hash scoring of canvas output as an automatic "most interesting" vote, and a WebGPU/WGSL variant of the same rig to compare failure modes across shading languages.
Code & more: https://www.dailybuild.xyz/project/261-refract



Top comments (0)