100K Procedural Rocks, Brutally Optimized

I searched online for procedural rocks and everything fell into two camps: low-poly stylized stuff, or gorgeous realistic shaders that would melt your GPU with a handful of instances. I needed something for a high-performance game: thousands of rocks that actually look like rocks, at max FPS.

So here’s my field of 100,000 realistic rocks generated from SDF marching cubes, rendered at max FPS with sub-millisecond frame times.

See it on CodePen →

Rocks

Each rock shape is a signed distance field (a sphere carved by 14-22 randomized smooth cuts) then polygonized through marching cubes at multiple resolutions. 8 unique base shapes, each with per-instance variation in scale, squash, rotation, color, wetness, and roughness. No textures, everything’s procedural.

Shader

Close-up rocks get FBM noise for color mixing, normal perturbation for surface bumps, and a wetness-driven specular/fresnel model. Distant rocks skip the noise entirely, flat normals, simple color blend, zero noise evaluations. The threshold is tuned to minimize popping, though you can spot the switch if you look for it.

Performance Optimization

This is where I spent most of my time:

  1. 4-level LOD: marching cubes → marching cubes → icosahedron impostor → box impostor
  2. Per-fragment LOD in the shader: normal perturbation drops off at half the LOD 2 distance, saving 12 noise evaluations per fragment across a huge screen area
  3. Pre-computed matrices: all 100K instance transforms calculated once at startup, zero per-frame matrix math
  4. Rolling hash dirty check with partial buffer upload: GPU buffers only upload when bucket contents actually change, and only the visible slice gets sent, not the full 100K-capacity array.
  5. Time-sliced frustum culling: 100K rocks processed over ~4 frames, not all at once
    Full 100K frustum cull every frame. Brute force beat time-slicing, which caused pop-in during fast camera rotation
  6. Static shadow map: renders once after first population, then only re-renders when the camera moves 4+ world units (quantized snapping to eliminate shadow shimmer)
  7. Heavily pooled objects: pre-allocated typed arrays, reused spheres/vectors for culling, no GC pressure in the render loop

32 draw calls. 8 shapes × 4 LODs. That’s it.

9 Likes

It’s locked at 30 for me