(Reasonably) seamless and scalable wall and ground rendering


The most interesting part of this project is probably the grass and hedge wall "decal" system I built to give the floor and walls some character. The core idea is to use really, really small models (a few points and faces each) and randomly rotate and place them on a surface to provide surface detail. I explicitly wanted to try out Godot's multimesh functionality with this project to figure out if it's viable for providing this type of visual flourish (yes, but it's easy to go overboard). There's something conceptually very satisfying to me about having "real" geometry actually polishing out a scene by throwing thousands of untextured tris at the GPU instead of just postering some textures (and maybe bumpmaps) on an otherwise flat surface.

Using procedurally placed low-res geometry to add detail to a scene has a few benefits:

  • Small asset size (each model file is just a few lines of points and faces, and the material files just define albedo color -- a few kilobytes at most; the vast, vast majority of the game size is Godot itself)
  • Scalability with GPU performance (slow GPU? tile a few large instances; fast GPU? tile several hundred small ones)
  • Ease of changing the look and feel of a surface without re-baking assets
  • It will always look good on newer displays (no low-res textures filling a whole viewport in 10 years -- same reasoning behind why early 3D games upscaled to modern resolutions can still look so good)

My initial approach took this form:

  • When building a new maze, the maze itself is divided into a series of blocks, each of which has 4 walls and 4 corners.
  • In the editor, I gave the maze block entity (which also holds obstacles, coins, etc.) 8 multimeshes, once for each "surface" (N/S/E/W wall; NE/SE/NW/SW corner), and one more for the grass.
    • Alternatively, I could've also just used one multimesh for all hedge detail
  • Each multimesh entity was given a buffer for mesh transforms, equal to the max size at highest detail level. Since the maze block entities all share underlying buffers when the game is running (the maze block scene is not unique), the memory cost is reasonable.
    •  Absent some random rotation applied to each instance, this means the hedge/grass pattern of each maze block is the same, but in practice it's basically impossible to notice.
  • Upon being created for the first time, the maze block checks if it's already decided what random layout it wants for this game round (i.e. has the buffer been set up), and bails out if that's already the case.
    • In theory, I could've statically generated the mesh locations once offline and baked them for each detail level, but it's very cheap to do this at runtime
  • For the grass, this is pretty easy:
    • The algorithm drops $GRASS_CLUMP_COUNT models randomly on the floor within the rounds at random rotations
  • For the hedge walls, it's a little trickier:
    • The algorithm then walks up and down every wall, assigning model transforms that are rotated and offset a random amount at each location
      • Just dropping them at totally random locations on the wall led to them feeling to "clumpy" and not actually random (even though it was actually moreso)
    • Special care was given to the exact offsets and max random offsets so the "seams" between each wall and corner are hard to notice, but we don't wind up with hedge detail floating in space

All said and done, and after many rounds of tweaking, this produces a nice result. The GIF above this post shows this process cycling through random placement+rotation on/off, as well as different detail levels.

That being said, even with me being draw-call efficient by using instanced rendering, I found this approach is pretty conclusively slower than just throwing a couple textures and a shader on the grass/hedge surface and calling it a day. I was pretty surprised by how quickly the rendering cost added up when I was prototyping, even on a reasonably fast modern desktop GPU. Optimizing for mobile and web was an interesting processes, though, and helped expose me to more Godot features (which was the main reason I built this project in the first place). The optimization steps (roughly) looked like this:

  • Make everything a bit bigger to reduce model instance counts (~5-20% speed up)
  • Use Godot's occlusion culling system to hint to the renderer what could be safely ignored (biggest speed up, >50%)
    • On web, this system isn't usable (web exports are usually done threadless to workaround Safari bugs, and the occlusion culler only operates on threaded exports), so I have to manually cull multimeshes. I can get a reasonable approximation of the real culling system's performance by manually culling (visible = false) the multimeshes that are more than $FOG_DISTANCE away from the player, as well as those that are entirely out of the camera frustum (~30-40% speed up)
      • On desktop/mobile platforms, I leave my more manual systems off since the native occlusion culling is always better
  • Switch the project to Forward+ renderer, and turn on fade out visibility for the decal, and pull in the decal visibility ~5 units closer than the fog (~15-20% speed up)

With all these optimizations in place, the game went from ~60-80% usage on an Nvidia 3080 Ti at 100 FPS/4K to a consistent ~30% figure at 120 FPS/4K on the game's "Ultra" preset, which throws ~5.5 million primitives on screen at once. Mobile device and performant web browser/hardware combos pretty comfortably handle the game on "High" (which lands at ~3.3 million).

TL;DR

  • Just because you're using low-poly models doesn't mean you don't have to worry about optimization
    • It probably would've been easier to learn how to use Photoshop or improve at Blender and make an asset that looks nicer and runs better
  • ...but if you plan properly, you can throw a lot of geometry at Godot (across the Forward+, Mobile, and Compatibility renderers)

My very questionable GDScript implementation of mesh placement lives here: https://github.com/maclyn/mmmmv5/blob/master/maze_block.gd

Get M.M.'s Maze Madness

Leave a comment

Log in with itch.io to leave a comment.