The Real Question
When you start a new simulation, the choice between Canvas 2D and Three.js is rarely about "which is better." It's about what the simulation actually needs. Canvas 2D's 2D drawing API is surprisingly capable, and the rendering thread stays on the CPU — which is sometimes exactly what you want when your physics also runs on the CPU and you need tight synchrony.
Three.js hands rendering to the GPU via WebGL. That's the right call when you have tens of thousands of particles, volumetric effects, or real-time 3D geometry. But it costs: more code, a larger dependency, and a higher debugging overhead.
Head-to-Head Comparison
| Criterion | Canvas 2D | Three.js (WebGL) |
|---|---|---|
| Setup complexity | 3 lines of code | ~20 lines minimum |
| Particle count (60 FPS) | ~2 000–5 000 | 50 000–1 000 000+ |
| Dependency size | 0 KB (browser built-in) | ~150 KB (Three.js core) |
| Custom shaders (GLSL) | Not available | Full GLSL support |
| 3D rendering | Manual projection math | Native 3D scene graph |
| ImageData / pixel ops | Fast, direct access | Requires render target readback |
| Text rendering | fillText() — trivial | TextGeometry / Sprites — complex |
| Interactivity (click/drag) | Simple coordinate math | Raycasting required |
| Debugging | DevTools, breakpoints work everywhere | GLSL errors are cryptic |
| Mobile performance | Acceptable for small N | GPU handles big N well |
| Postprocessing (bloom, blur) | Manual, slow | EffectComposer + built-in passes |
When Canvas 2D Wins
Choose Canvas 2D when:
- The simulation is inherently 2D. Cellular automata, agent-based models, 2D fluid dynamics, graphs, charts, decision trees — none of these benefit from a 3D scene graph. Adding Three.js would be pure overhead.
-
You need per-pixel control. Reaction-diffusion
patterns, heat maps, and any simulation that writes to a pixel grid
are easiest with
ImageData. Updating aUint8ClampedArrayon the CPU and callingputImageData()is simpler than managing WebGL textures. - Particle count is under ~5 000. Canvas 2D can comfortably render 2 000–5 000 circles or lines at 60 FPS on mid-range hardware. If your simulation never exceeds that, don't add WebGL complexity.
- The page needs to load fast. No CDN script tag, no WebGL context initialisation. A Canvas 2D simulation can be fully interactive within 50 ms of page load.
- You're writing a quick prototype. Canvas 2D lets you iterate in minutes. Validate the algorithm first, upgrade the renderer later.
When Three.js Wins
Switch to Three.js when:
-
Particle count exceeds ~5 000. Use
Pointswith aBufferGeometryand a customShaderMaterial. Moving a million particles per frame on the GPU is trivial; Canvas 2D would drop to single-digit FPS. - The scene is genuinely 3D. Galaxy simulations, orbital mechanics, molecular visualisations, and structural engineering need a proper 3D camera, perspective projection, and depth buffer.
- You need custom per-fragment effects. Gerstner ocean waves, Fresnel reflections, SDF-based ray marching, volumetric clouds — all of these require GLSL fragment shaders that simply cannot run in Canvas 2D.
-
The visual demands real-time lighting or shadows.
Three.js's
MeshStandardMaterialand shadow maps work out of the box. Replicating even basic Phong lighting in Canvas 2D is a research project.
Real Examples from This Site
Of the 225 simulations here, roughly half use Canvas 2D and half use Three.js. Here's the split:
- Canvas 2D: All cellular automata (Game of Life, Forest Fire, Reaction-Diffusion), all 2D agent models (Boids 2D, Ants, Epidemic SIR), sorting algorithms, decision trees, digital filters, financial charts, traffic models, and most educational physics demos.
- Three.js: Galaxy (80 000 star particles), ocean shader (Gerstner + Fresnel), tectonic plates (displacement map), SPH fluid (100 000 particles), path tracing, ray marching, and all true 3D mechanical simulations.
A good heuristic: start every simulation in Canvas 2D. If, after a working prototype, you're hitting a framerate wall or need a 3D view, migrate to Three.js. You'll only add the extra complexity when it's genuinely needed — and you'll understand the algorithm before fighting the renderer.
Starting with Canvas 2D, Migrating Later
The migration path from Canvas 2D to Three.js is usually straightforward if you separate your simulation logic from rendering from the start. Keep the physics update in a pure function that takes state and returns new state. The renderer is just a consumer of that state.
// Good structure — renderer is swappable
function updatePhysics(state, dt) { /* pure logic */ return newState; }
function renderCanvas2D(ctx, state) { /* draws to canvas */ }
function renderThreeJS(scene, state) { /* updates Three.js objects */ }
// In rAF loop:
state = updatePhysics(state, dt);
renderCanvas2D(ctx, state); // swap to renderThreeJS when ready
The trap to avoid: mixing physics calculations into draw calls. Once
you do ctx.moveTo(particle.x += vx * dt, ...) inside a
draw loop, migration becomes a full rewrite instead of a render swap.
Decision Checklist
- ⬜ Is the simulation 2D in nature? → Canvas 2D
- ⬜ Particle count < 5 000? → Canvas 2D
-
⬜ Need per-pixel
ImageDataaccess? → Canvas 2D - ⬜ Building a prototype first? → Canvas 2D
- ⬜ Scene is genuinely 3D? → Three.js
- ⬜ Particle count > 10 000? → Three.js
- ⬜ Need custom GLSL shaders? → Three.js
- ⬜ Need real-time lighting / shadows? → Three.js
- ⬜ Need post-processing (bloom, SSAO)? → Three.js