Duskline is a racing game I built that runs in the browser: 12 worlds, 14 cars, nothing to install. Almost nothing in it is a downloaded asset. The skies are shaders, the cars are built from cross-sections, the tracks are splines, and every engine note is synthesised live.
It looked finished, but it didn't feel smooth. The racing frame ran at about 33 fps, and the obvious suspects were the GPU, because the scenes are dense, and the physics.
Both were wrong.
Measure the frame before you fix it
Instead of guessing, I timed the running game from the inside, through a debug handle the engine exposes in the browser. The physics took about 0.05 ms a frame, effectively free. Rendering took about 27 ms.
And it wasn't the kind of rendering cost I expected. The scene was about 12,500 separate meshes but only 800,000 triangles, which a GPU handles easily. Lowering the resolution changed nothing, which ruled out the GPU's pixel work. The cost was per-object overhead on the CPU: every mesh is a draw call, and each draw call is work the CPU does every frame, whether the mesh is big or tiny.
That changes what "optimise" means. Simplifying shaders or cutting polygons would have made the game look worse and run about the same.
Two fixes, and the one I didn't make
The shadows went first. The sun's shadow pass was about a fifth of the frame, and it was drawing thousands of distant trees and rocks into the shadow map for shadows you'd never see. Shrinking the area it covers (from 95 m around the car to 58 m, biased toward the direction you're driving) culled them, and the same shadow map spread over a smaller area made the nearby shadows sharper. That, and turning off a buffer setting nothing used, took the frame from about 27 ms to 24.5 ms.
The bigger cost was the scenery itself. Every tree, rock and building was a little group of five to eight separate meshes, one draw call each. The fix for too many draw calls is to merge meshes, and the tempting version is to merge everything static in the world into a few big ones.
I didn't, because of the trees. The wind shader decides how much a leaf sways from how high it sits within its own tree. Merge the whole forest in world coordinates and that information is gone, and every tree would flail. So each prop's own pieces were merged into one mesh per material, in the prop's own space, and anything that couldn't be merged safely was left alone, so the merge could never change how a prop looks.
Measured on a forest race, in the same browser before and after:
- meshes went from 12,474 to 5,627
- render time fell from about 24 ms to 11.75 ms
- the racing frame went from about 33 to about 85 fps
The forest looks the same, pixel for pixel.
The cars were the surprise
The cars were 65% of the frame's draw calls but only 0.5% of its triangles. Each wheel was several separate meshes. Merging each wheel internally halved the meshes per car, and because triangles were never the problem, I spent the savings on rounder tyres and bodies. The game got faster and better-looking from the same change.
The neon city had the same problem at a bigger scale: 21,151 meshes, mostly plain boxes on its towers. Merging the static parts took it to 4,971 meshes and from 1,838 draw calls to 843, with the triangle count unchanged.
A slow thing that wasn't rendering at all
One more "it feels slow" turned out to be the network. Static art was being revalidated on every visit, 30 round trips at about 112 ms each, until it got proper cache headers.
Keeping it fast
Performance that isn't checked goes back up. Duskline now has render and bundle-size budgets in CI, so a change that blows the budget fails the build instead of quietly shipping.
What I took from it: "not smooth" is a symptom, not a diagnosis. The part of the system you worry about most is not necessarily the part that's slow, and the cheapest fix is the one you make after you know which it is.
You can play Duskline at https://playduskline.vercel.app, and the code is at https://github.com/Srikarmk/duskline.