The deep woods at dusk, spruces and gold larches under a gondola line
POWDERLINE
BLOG · ENGINEERING

Drawing a forest for less

Most of what a frame cost went on trees nobody could see. How we measured it honestly, what we cut, and the two engine bugs that turned up along the way.

· 6 min read

Powderline never felt slow on a fast laptop, which is exactly why it was worth measuring: a game that is comfortable on good hardware can still spend its budget badly, and the people who pay for that are on older Chromebooks and mid-range phones. This is what the profiling found, what changed, and what we learned about measuring a web game honestly. The short version is in the changelog; the general architecture is on Under the hood.

Measuring without fooling yourself

The game's screenshot tests run on a software renderer, which is fine for checking what a frame looks like and useless for timing it. So the first job was a profiler that runs the real game on the real GPU: headless Chromium drawing through Metal on an M1 Pro, with a small autopilot riding the mountain the way a player would.

The first numbers were wrong, and it is worth saying how. The obvious way to time the GPU is to read a pixel back after each frame, which makes the CPU wait for the GPU to finish. But on a multisampled canvas, reading even one pixel makes the browser resolve the whole antialiased frame first, so the measurement timed its own side effect. Timer queries also counted the GPU's idle gaps. What survived is simpler: run the game uncapped, vsync off, and time the gap from the start of one frame to the start of the next. That is what the machine can sustain, and the JavaScript inside each frame can be split by subsystem on top.

The second lesson was noise. On a laptop doing other work, a single run could swing 20% either way, more than most of the improvements being measured. The only comparison worth trusting was an interleaved A/B: the previous build served beside the new one, the same ride on each, alternating for three rounds. If the gap holds in every round, it is real.

What a frame was spending

A typical frame drew about 1.5 million triangles in around 260 draw calls. The biggest single cost was the woods. Each spruce is built in code with about 900 triangles: seven tiers of drooping branches, a snow layer with real thickness, a trunk and a leader. Every tree in the streamed area was drawn every frame at full detail, including the ones behind the camera and the ones 600 metres away covering a few pixels. Shrubs were nearly as heavy.

Two more findings shaped the work. On a fast GPU at Retina resolution, the cost per pixel dominated; quartering the pixels saved only part of it, so geometry mattered too. And about 120 of the 260 draw calls came from the snow park, where every kicker and rail is three to five separate meshes. On a slow CPU, draw calls limit the frame.

A rider on open snow with woods along the slope
Most of the trees in a view stand well over a hundred metres away, where they are only a few dozen pixels tall.

Only what the camera can see

Trees, rocks and shrubs are drawn with instancing: one draw call per tree shape, holding every copy of it. The catch is that an instanced mesh is culled as a whole, so if any tree of that shape is in view, all of them are drawn. Now the game keeps every placed tree on the CPU and, right before each shape is drawn, tests each copy against the camera that is actually rendering and packs the visible ones to the front of the buffer. The test runs once per render, for every camera, so the screenshot tools and any future camera get it for free.

Repacking had one side effect. Each tree took its shade of green from its slot in the buffer, so once slots are reshuffled every frame, a tree would change colour whenever a neighbour left the view. Each tree now carries a stable seed of its own.

A lighter tree in the distance

Past 150 metres, a tree is drawn from a far version of itself. The trees are generated from a seeded random sequence, so the far version rolls exactly the same rings, the same drooping tips and the same snow line as the near one, and simply emits fewer faces: one green face and one snow face per branch instead of three and five, and no 10 cm rim on the snow. The bounding boxes of the two versions match to the centimetre. A far tree has about a third of the faces, and at that range the difference is a pixel or two. Because only around 4% of a typical view's woods stand closer than 150 metres, nearly every tree on screen takes the cheaper version. Shrubs switch at 60 metres and are not drawn at all past 400, where they are a few fogged pixels.

Pixels nobody looked at

Three smaller changes went after the per-pixel cost. The canvas was multisampled, but the engine renders every frame into its own antialiased target and only copies the result to the canvas, so the canvas's four-sample buffer, about 160 MB at Retina size, smoothed nothing. On WebGL it is gone, and the picture is as smooth as before. The sky, with its cloud noise per pixel, is now drawn after the scenery, so pixels the mountain already covers are skipped. And a ring of distant peaks behind the sky, drawn every frame only to be painted over, is no longer drawn.

On the CPU side, the groove your board leaves in the snow used to rebuild all 12,000 of its vertices every time it grew by one slice. It now shifts its buffers along and writes only the new slice and the fading tail.

Two bugs in the engine

Comparing old and new builds frame by frame turned up something nobody had reported: on WebGL on a Mac, the boulder field had no boulders. The game is built on three.js, and its WebGPU-era renderer puts the positions of up to 1,000 instanced copies into a uniform block on the reasoning that both GPU backends allow 64 KB. WebGPU does. WebGL2 only guarantees 16 KB, enough for 256 copies, and Apple's Metal-backed WebGL gives exactly that. Any instanced mesh holding between 257 and 1,000 copies failed to compile and drew nothing: the rocks, the shrubs and the gondola cabins. The rocks were still there to crash into. Above the device's limit, those meshes now take the path the engine already used for larger counts.

The second came out of the hockey stop's snow spray, which on WebGL drew almost nothing. Counting drawn pixels rather than eyeballing screenshots pinned it down: the live spray drew about 1,400 pixels where a fresh copy of the same mesh drew about 15,000. On that instancing path the engine uploaded a mesh's geometry before running the update that marks its instance buffer as changed, so every change reached the GPU a frame late, and a repacked tree could briefly wear its neighbour's colour. Both fixes are small patches, to be removed once the engine fixes them upstream.

The mountain range and the slope below it
Rocks and shrubs, now drawn on every device that runs the game through WebGL.

What it bought

Measured on the M1 Pro at 2880 by 1800 pixels, three alternating rounds against the previous build, on the same ride:

MEASUREBEFOREAFTERCHANGE
Average frame8.94 ms7.65 ms−14%
Slowest 5% of frames18.5 ms14.8 ms−20%
Slowest 1% of frames25 ms19.4 ms−22%
JavaScript per frame8.0 ms6.7 ms−16%
Triangles per frame1.5 millionabout 0.9 millionabout −40%

These come from one fast machine. The triangle cut should matter more on phones and integrated graphics, but we have not measured those yet, so we claim no numbers for them.

What's next

With the woods cheaper, the snow park is now the largest cost: its features account for nearly half the draw calls, and batching them into a handful of calls is the biggest CPU saving left. On slow machines, building a fresh stretch of mountain as it streams in can still take one long frame, which shows as a hitch; that work can be spread over several frames. And the game's adaptive resolution only steps down below 42 frames per second, so a machine stuck at 50 never reaches 60. Each will get the same treatment: measure, A/B against the last build, check the picture by eye. For the rest of this week's changes, see A week on the mountain.

More from the blog

Your first run starts in seconds

Free in your browser on a laptop, a phone or a gamepad. No download, no sign-up.

PLAY FREE