CGT Rocket X Racer is a fan-made 3D runner we build in Godot 4 and play in a desktop browser. You fly a hovering rocket car through a large factory made of many separate halls, then out over the rooftops and back in. That is a lot of geometry for a web build. This post collects the performance decisions that mattered most for us. I'm describing techniques, not quoting benchmark numbers, because results depend heavily on the hardware.
Fan-made, not affiliated with Tesla. Desktop-only.
1. Pick the renderer once, and use it everywhere
Godot's web export only supports the Compatibility renderer (OpenGL ES 3 / WebGL 2). We made the same choice for every platform. The game looks identical everywhere, it runs on older GPUs, and we only have one set of materials and lighting to tune.
The trade-off is real: the Mobile and Forward+ renderers give better lighting and post-processing. Switching native builds later is a one-line project setting, but every material and light would need checking again. For us, "looks the same in the browser as in testing" was worth more than the extra effects.
2. The no-threads web template
We export with the no-threads web template. It doesn't need SharedArrayBuffer, so it also doesn't need the cross-origin isolation headers (COOP/COEP) that threaded builds require. In practice that means the build runs on any static host and is easier to embed or deploy.
The cost is that you can't rely on threads in the browser the way you can on native. Anything that loads a lot of data has to be designed so it doesn't stall the main loop.
3. Render 3D below native resolution, keep the UI sharp
Fill rate is the first wall you hit in a browser on a high-DPI screen. Our approach on web and mobile is to render only the 3D viewport at a reduced scale, while the UI canvas stays at native resolution. The scale comes from the device pixel ratio and the window's pixel count, clamped to a sensible range, with bilinear filtering. There's also a project-level fallback scale in case the script never runs.
The result: the world is a little softer, but text and HUD stay crisp, and crisp text is what players notice. On a normal desktop window the scale stays at full resolution.
4. Near and far versions of every hall
The factory is made of many hall models. Each hall exists twice: a detailed "near" file and a cheap "far" shell that keeps the silhouette and landmarks. A site streamer swaps between them by distance.
Obstacles use the same idea with Godot visibility ranges. The detailed LOD0 mesh is only drawn close to the camera, and LOD1 takes over beyond that. Both levels share one animated hierarchy: the LOD1 meshes are grafted onto the LOD0 nodes. Animation runs once, and collision reads the same transforms.
This matters most when the route climbs out above the roofs, where you can suddenly see the whole site.
5. Fewer surfaces, fewer draw calls
Draw calls are expensive in WebGL. A few habits helped:
- Merge by material at import. A post-import script merges the car, coins and parts into one mesh with one surface per material and regenerates LODs. It keeps the named marker nodes we need, like nozzle positions, as Marker3D.
- MultiMesh for pickups. Coins and parts are drawn with MultiMesh instead of one node per pickup.
- Bake debuffs per pivot. The debuff obstacles are baked into a few merged meshes per moving pivot, to stay inside our draw budget.
- Bake instanced site geometry. The site was originally authored elsewhere. Our export script bakes each instanced mesh into one merged mesh per material with vertex colours before it becomes a GLB.
6. Keep textures in check
Obstacle textures come in as KTX2. Godot imports them and re-compresses to VRAM formats for the web build (S3TC and ETC2 in the pack). We also cap texture sizes per map type: base colour gets the most resolution, while ORM and emissive maps get far less. When two LOD levels had byte-identical textures, we imported the far one without images and let its meshes reuse the near materials. That avoids loading the same texture twice.
7. Cheap effects
Mist from the car's nozzles and pickup sparkles use CPUParticles3D, and the emitters are pooled rather than created per event. Pickup sparkles are parented to the car so they keep up at high speed without extra work. The crash effect is a short screen flash and a decaying camera shake. Both are cheap, and both do a lot for feel.
8. Audio that respects the browser
Browsers block audio until the user interacts with the page. Instead of fighting that, we don't create any audio players or play anything until the first real tap, click or key press. That avoids autoplay warnings and wasted work at startup. Our placeholder sound effects are also generated procedurally by a small script, so there are no downloaded audio files to ship.
9. Measure at fixed points, not just "it feels fine"
The project has a bench mode. It runs the game under a virtual framebuffer at a fixed resolution and frame rate, and records performance monitors at specific route points: a heavy press, the sign approaches, the garage and a worst-case combination of hazards. There's a separate mode for measuring the in-race HUD alone. Fixed checkpoints mean "this change made the press area slower" is something you can actually see, instead of a vibe.
What I'd tell someone starting a 3D Godot web game
- Decide on Compatibility and no-threads early, and design around them.
- Split 3D resolution from UI resolution.
- Build near/far versions of big environment chunks before you need them.
- Merge meshes by material and use MultiMesh for anything repeated.
- Make performance measurable at named points on your level.
If you want to see how it holds up in your own browser, the game is playable on desktop at https://cgt.monster/x/devto2. Feedback on frame pacing is very welcome.
Fan-made, not affiliated with Tesla. Desktop-only.
Top comments (0)