Journal

WebGL · three.js ·

QVERSE: twelve particle worlds on the GPU

How QVERSE runs a separate GPU particle simulation for each of its twelve worlds from a single self-contained HTML file — float render targets, per-world shaders, and a particle budget that fits the hardware.

One file, no network

QVERSE ships as a single HTML file. three.js r128 is inlined, every shader is a string in the page, and nothing is fetched after load — it runs offline and can be hosted anywhere as a static file.

Particles live in textures

Particle state is kept on the GPU, not in JavaScript arrays. Position and velocity each live in a square float render target — one texel per particle, RGBA — and two full-screen shader passes advance them every frame:

  • Velocity pass: reads the current position and velocity textures and writes new velocities, using the active world's velocity shader.
  • Position pass: integrates pos += V.xyz * uDt into the second position texture.

Each pass renders into the "other" texture of a pair, then the pair swaps — a ping-pong, so no pass ever reads and writes the same texture:

quadMesh.material = getVelMat(activeWorld);
renderer.setRenderTarget(velRT[1-cur]);
renderer.render(quadScene, quadCam);
/* position pass */
quadMesh.material = posMat;
renderer.setRenderTarget(posRT[1-cur]);
renderer.render(quadScene, quadCam);
cur = 1 - cur;

The passes draw one oversized triangle — vertices (−1,−1), (3,−1), (−1,3) — instead of a two-triangle quad, which covers the viewport with no diagonal seam. Simulation targets use nearest filtering and no depth or stencil buffer: they hold data, not pictures.

One shader per world

Each world is its own physics — standing waves, sinc interpolation, a phase-locked loop, four-dimensional rotation by quaternion pairs, and more. A world's velocity shader is assembled from a shared head and tail around that world's body, compiled the first time the world opens, and cached:

function getVelMat(W){
  if(!velMats[W]){
    velMats[W] = new THREE.ShaderMaterial({ uniforms: shared, vertexShader: QUAD_VERT,
      fragmentShader: VEL_HEAD + '\n' + VEL_BODY[W] + '\n' + VEL_TAIL });
  }
  return velMats[W];
}

Four of the worlds also keep a memory: a 128 × 128 field texture that the particles deposit into and that decays every step — by factors from 0.90 to 0.9995 depending on the world — so their paths leave fading traces.

A particle budget that fits the hardware

The particle grid is 192 × 192 (36,864 particles) on desktop and 128 × 128 (16,384) on phones. At startup QVERSE reads the unmasked renderer string through WEBGL_debug_renderer_info; if it matches a software rasterizer — SwiftShader, llvmpipe, Microsoft Basic Render — the grid drops to 96 × 96 and a notice suggests turning on hardware acceleration. iOS devices and GPUs without float textures fall back to half-float targets. A ?sim= query parameter overrides the grid between 64 and 512 for testing.

The twelve are coupled oscillators

The twelve world-orbs on the main field are Kuramoto systems. Each orb holds 120 phase oscillators whose natural frequencies are drawn from a truncated Cauchy (Lorentzian) distribution — the distribution Kuramoto used for his closed-form analysis. Every frame an orb measures its order parameter r (how aligned its phases are) and mean phase, then advances each oscillator:

this.ph[i]+=(this.om[i]+this.K*this.r*Math.sin(this.meanPhase-this.ph[i])+this.gK*this.gR*Math.sin(this.gPsi-this.ph[i]))*dt;

The last term is a second level of coupling: every orb also feels the global order parameter of all twelve (gK = 0.34), so synchrony in one world pulls on the others. The geometry follows the math — each particle blends from a point on a sphere toward a point on a ring with weight r², so an orb visibly flattens into a disc as its oscillators lock.

By the numbers

  • 12 worlds, each with its own velocity shader
  • 36,864 GPU particles on desktop — 16,384 on phones, 9,216 in software rendering
  • 12 × 120 = 1,440 coupled oscillators on the main field
  • 1 HTML file, 0 network requests