A huge browser scene needs fewer draw calls
I built a browser scene with 52,303 building footprints and 40,520 trees. In one recorded overview, Chrome drew 4,780,955 triangles in 41 draw calls and ran at 60 to 67 frames per second on an Apple M4. The reading came
I built a browser scene with 52,303 building footprints and 40,520 trees. In one recorded overview, Chrome drew 4,780,955 triangles in 41 draw calls and ran at 60 to 67 frames per second on an Apple M4.
The reading came from one scene on one machine, with views at 1600 by 900 and 1920 by 1080. That leaves cross-device performance unmeasured, but it changed how I think about a busy Three.js scene, because the number of visible things tells you very little about how many separate submissions the renderer has to make.
The buildings came from OpenStreetMap footprints, the roads from mapped centerlines, and the trees were generated from mapped green areas and residential lots. I wanted the scene to feel dense enough to explore from above and from the road, so deleting most of the objects wasn't an appealing way to get there.
Combine the things that stay put
Each building has its own footprint, so I couldn't use one identical box for every building and pretend the map was accurate. The builder projects coordinates into local meters and checks each footprint, then the browser generates walls and roof faces from its shape. Where the map provides height or level tags, it uses them; otherwise it falls back to a height based on building type.
The source data has limits. A mapped footprint can put a building in the right place while the missing height still has to be estimated, so the result is an approximation and shouldn't be mistaken for a survey.
At runtime, the wall and roof vertices for the buildings go into shared geometry buffers. Roof details use another combined buffer. Roads take a similar path: the scene generates ribbons for road surfaces, sidewalks and markings, then merges each category into its own mesh. One road still has its own shape and width, but it doesn't need its own scene object and material submission.
The generated data has 20,456 road features. A feature count includes mapped segments and doesn't tell you how many separate physical streets or intersections are present, so I keep those categories separate when describing the scene.
Reuse geometry when shapes repeat
Trees use instancing. The scene groups broadleaf trees and palms, then shares geometry for each group's trunks and crowns, with instance transforms to place and scale them and colors varied across the instances. Rooftop units, traffic and pedestrians use instancing too.
Of the 40,520 trees in the recorded build, 18,519 were sampled inside mapped green polygons and 22,001 were added near residential lots. The placements are generated. That count describes the vegetation the scene renders; OpenStreetMap didn't supply those individual tree positions.
Instancing and merging solve different problems in this build. A generated building needs its own wall outline, so I combine its vertices with the other buildings. A tree can reuse a trunk or canopy shape, so I keep that geometry and change the instance transform. Both choices reduce the number of separate mesh submissions without requiring every object to look identical.
Would the same choices fit a scene where every building needs independent animation or frequent edits? I'd expect to revisit the grouping. Here, the buildings stay put and the moving traffic already has its own instanced path.
Put small details in the material
A dense block of flat walls still looks unfinished when you fly close. I used a custom building shader to draw window grids, floor bands, storefront glass and color variation across the combined building mesh. A per-vertex height attribute gives the shader the building height it needs for the trim near the top of a wall.
That detail came from a bug. The first version didn't carry the height through the merged geometry, which made the parapet trim difficult to place correctly. Adding the height attribute gave the shader a value for each building even though the buildings shared one mesh.
The fix is useful beyond windows. Once geometry is combined, any information a material needs per original object has to travel with it somehow. In this case, height became a vertex attribute. The scene also keeps roof geometry separate for details that need actual shape.
Validate the geometry before chasing speed
One bug collapsed closed building polygons during simplification. A ring starts and ends at the same point, and feeding that repeated endpoint directly into the simplifier caused footprints to collapse. The builder now removes the duplicate closing point before simplifying and keeps the original points if the result has fewer than three.
Another bug gave roads a non-finite height. Road geometry ended up with invalid coordinates, and the fix assigns an explicit height by road class, while the smoke test includes a NaN scan alongside render statistics and checks entering drive mode, exiting it, and accelerating the car.
Those checks don't prove the map is perfect, or that every device will run it at the recorded frame rate. They catch failures that a frame counter alone can't: an empty class of geometry can make a scene fast while leaving the thing you wanted to see missing.
The generated scene data file is 12,971,472 bytes before any transfer compression. That is another cost to weigh alongside render calls, especially when the first visit has to load the data. I haven't measured a cross-device load-time comparison, so I can't say how this build would behave on your phone.
When you build a large browser scene, which limit shows up first for you: draw calls, geometry, load time, or something else?
Originally published by Dev.to WebDev. Aggregated on AIWithGhost for educational purposes — full credit and traffic to the original publisher.