Three.js Lessons
Three.js is the renderer for CONTRABAND's 3D space combat. It's powerful, mature, and has a learning curve. Here's what I learned during development — both the wins and the dead ends.
Why Three.js
The 3D combat experience in CONTRABAND needed real-time rendering of ships, weapons effects, and galaxy views. The options for browser 3D are essentially: Three.js, Babylon.js, or raw WebGL. I chose Three.js because its API is the most approachable, its documentation is extensive, and its community has answered most questions I ran into. Babylon.js is arguably more powerful; raw WebGL is arguably more minimal. Three.js is the productive middle.
GLB model loading
Every ship in CONTRABAND is a GLB file loaded at runtime. GLB is the binary variant of glTF — it's the standard 3D format for web. The GLTFLoader in Three.js handles parsing; after that you have a scene graph object you can manipulate.
The non-obvious thing about GLB files is that they embed their textures. This means a ship file is the only asset needed for that ship — no separate texture files to manage. It also means file sizes are larger than you'd expect. An average ship hull GLB in CONTRABAND is 400-800KB. For 16 ships, that's about 10MB of total 3D assets. This is the biggest contribution to the game's download size.
Lazy-loading helps. I load each ship's GLB only when the player actually flies it. The starter Scout MK-I loads immediately. Other ships load on-demand from the shipyard preview. This keeps initial download to about 3MB even with all ships available.
Performance on mobile
The biggest Three.js lesson was: mobile performance is nothing like desktop performance. My game ran at 60fps on my laptop and 15fps on a mid-range phone. Profiling revealed that the culprit was shader complexity — the Three.js default material does multiple lighting passes per pixel, which is expensive on mobile GPUs.
The fix was using simpler materials. I replaced MeshStandardMaterial with MeshBasicMaterial for most ships, accepting that ships would not respond to lighting dynamically. This is fine for a space game where lighting is already minimal. Mobile frame rates jumped to 45-60fps after the change. Players on older phones can now actually play.
The lesson: Three.js's "good" materials are designed for desktop and high-end mobile. Shipping to mobile means understanding which features you can afford.
Scene management
A Three.js scene is a tree of objects. Adding and removing objects has performance cost if you do it per-frame. I learned this the hard way — the original implementation removed and re-added laser bolts every frame, which caused noticeable stutters. The fix was to pool bolt objects: create 20 bolt meshes at startup and reuse them, setting visibility flags instead of adding/removing from scene.
This pattern — object pooling — is standard in game dev. In Three.js specifically, it's critical because scene graph operations are more expensive than in engines that handle pooling automatically.
Camera behavior
Getting the space combat camera to feel right took more iterations than any other system. Early versions had the camera rigidly following the player ship, which produced motion sickness when ships maneuvered. The fix was a soft-follow camera that lags behind the player's rotation and smooths acceleration curves. This is standard AAA game camera design; implementing it in Three.js took about a day.
The final camera uses a spring physics model: the camera wants to be at a specific offset from the ship, but moves toward that offset with spring dynamics (proportional + derivative force). Tuning the spring constants matters — too stiff feels rigid, too loose feels floaty.
Starfield and backgrounds
The galaxy view's starfield is 2000 Points objects rendered in a single draw call via a PointsMaterial. This is cheap enough that I didn't need to optimize. Where I did need to optimize was the parallax background during combat — originally implemented as three separate sprite layers, which caused z-sorting issues. Combined into a single cubemap texture, which also serves as the scene's environment map for reflective ship materials.
What I would do differently
- Start with mobile-first rendering. I retrofitted mobile support. It would have been faster to design the renderer for mobile from the start.
- Invest in a shader system earlier. I eventually wrote custom shaders for weapon effects. Doing this earlier would have improved visual quality.
- Use InstancedMesh for repeated objects. When rendering 30 identical ships in a fleet view, instancing is the right answer. Three.js supports it well.
- Learn the debugging tools. Three.js has a scene inspector (SceneUtils.detach, etc.). Use them early.
What surprised me
Three.js is more stable than I expected. Over 6 months of development, the library had zero bugs that affected me. Minor API deprecations, yes. But nothing that broke my game on a library update. For such an active project (weekly releases) this is impressive.
The community is also unusually helpful. Three.js-specific Stack Overflow questions almost always have answers. Complex GLTF animation problems — which I briefly had — were solved by reading existing Q&A. This is not true of every JavaScript library.
Would I use it again
For any browser 3D game: yes. Three.js is the default answer for the foreseeable future. The only alternatives I'd consider are Babylon.js (for games with heavier graphics demands) or raw WebGL (for games where Three.js's overhead becomes a problem — rare). For CONTRABAND specifically, Three.js was the right choice.