Dekiro Dev Blog

Part 7 of 28

From 20 fps to 144, and a GPU Heap That Ran Out: Performance on Big Maps

Nine hundred animated monsters, tens of thousands of plants, two thousand crates, and the one rule that saved us twice: measure before you optimize.

The problem #

The new Sanctuary Vale (previous post) is 2.6 x 3 km. In its first version the generator placed 270 monster packs and 54 Shadows across three hunting grounds, plus a walled town, farmsteads and over 20,000 trees, plants and rocks. The commit that built the map already batched plants in chunks and split the tree colliders (more on both below). Even so, with everything in place the game dropped to about 20 fps.

The tempting move is to guess: "it's the grass", "it's the shadows", "it's the trees". Those guesses are expensive because they're often wrong, and a wrong optimization leaves complexity behind. So the first step was to measure where the frame time actually goes.

Measure first: the CPU, not the GPU #

The measurement pointed away from rendering. The frame was CPU-bound, and the biggest item was 941 animated monsters. Every one of them had a live AnimationPlayer updating a skeleton every frame, including monsters a kilometre away that nobody could see. Their AI was already asleep at that distance (see "dozing" below); their skeletons were not.

The comment that went into the code says it in one line:

## Distance level of detail (the big cost on large maps was hundreds of far-away models animating):
## animate within ANIM_RANGE of the camera, hold the pose up to HIDE_RANGE, beyond that not drawn.
const ANIM_RANGE := 70.0
const HIDE_RANGE := 170.0
const LOD_CHECK := 0.5

Step 1: animation LOD #

The first fix was cheap and local to ModelVisual, the node that shows a monster's model. Twice a second each monster checks its distance to the camera. Within 70 m it animates. Between 70 and 170 m it holds its pose: the AnimationPlayer is switched off, so no skeleton updates happen. Beyond 170 m it isn't drawn at all.

func _update_lod() -> void:
	var cam := get_viewport().get_camera_3d() if is_inside_tree() else null
	if cam == null or DisplayServer.get_name() == "headless":
		return
	var d := cam.global_position.distance_to(actor.global_position)
	var show := d <= HIDE_RANGE or actor.is_dead
	if visible != show:
		visible = show
	var animate := show and (d <= ANIM_RANGE or actor.is_dead)
	if animate != _animating:
		_animating = animate
		if _anim:
			_anim.active = animate  # a far model holds its pose: no skeleton updates

One detail matters more than it looks: the check timers are staggered (LOD_CHECK * randf_range(0.8, 1.2)). Otherwise 941 monsters would all check on the same frame and cause a hitch twice a second.

Step 2: don't have 941 monsters #

LOD made far monsters cheap. The better question was why they existed at all. A player can see perhaps 150 m. Monsters 2 km away are only a cost: memory, AI ticks, network state.

So we changed how a map is populated. Every Shadow and every pack now has a slot: a place in its zone and a list of who stands there (types, levels, which ones are Elites). The monsters only exist while a player is near:

const SPAWN_RANGE := 250.0
const DESPAWN_RANGE := 320.0
const DESPAWN_AFTER := 15.0
const SLOT_TICK := 1.0
Monster slots around one player, dumped from a running game: only the slots inside 250 m are spawned

Once a second the server checks each slot. An empty slot within 250 m of a player spawns. A filled slot whose monsters are all idle at home, with every player beyond 320 m for 15 seconds, goes away again, and the slot remembers exactly what stood there. The gap between 250 and 320 m, plus the 15 seconds, is hysteresis: walking back and forth along the edge doesn't spawn and despawn the same pack over and over. And since clients only see 160 m (the network interest range), all of this happens out of sight.

Two conditions keep it honest. A slot only despawns if its monsters are idle: dozing at home, unhurt, and for a Shadow, untouched with no waves out. Nobody can escape a fight by running away and coming back to a fresh, full-health monster. And a killed slot refills only after its normal respawn time.

A test walks a player around the Vale and checks both directions: everything spawned is within range, and the far fields go away when the player goes back to town.

Dozing monsters #

An older optimization from the open-world days still does a lot of work: monsters with no player within 70 m doze (Actor.dormant skips their per-frame work until a player comes near or something hits them), and maps with no players at all sleep completely.

Plants in chunks #

The rendering side needed the same treatment, and got it with the map itself. Today the Vale's scatter lists 15,000 pines and 50,000 ferns in the Shadow Woods alone. Plants were always drawn as MultiMesh batches, but one batch per kind per map would mean the GPU draws (and shadows) every tree on the map every frame. So they're batched per 96 m chunk with a draw distance:

## Plants are batched per CHUNK square and drawn up to DRAW_RANGE m away (small ones half that):
## a big map would otherwise draw (and shadow) every tree it has, every frame.
const CHUNK := 96.0

Trees are drawn to 200 m and small plants to half that. Grass works differently: it only exists within 150 m of the player, and its chunks are built a few per frame as you walk and freed behind you. In the code's words: "a huge map costs no more than a small one."

The Whispering Fields from 110 m up (fog off): trees everywhere, grass and small plants only near the camera

Load time: one collider body was too many #

Frame rate wasn't the only problem. Loading the big map was slow too, and the cause was surprising. Every tree trunk has a collider. All of them were shapes on one StaticBody3D, and each shape added to a body with thousands of shapes made the next one slower. The fix is again a grid:

## Trunk colliders go into one body per COLLIDER_CELL square: thousands of shapes on a single body
## made each new one slower (a big map took 25 s to plant).
const COLLIDER_CELL := 64.0

With one body per 64 m cell, no body grows large enough for that slowdown to matter.

The terrain cache that never hit #

The terrain is a height grid baked from noise and then flattened around towns, water, roads and portals. On a 2.6 x 3 km map that bake is expensive, and it gives the same result every time, so it should happen once. We added a disk cache in user://terrain_cache, keyed by a hash of the map's data.

The first version never hit the cache. The map's data dictionary is mutated at runtime: code adds parsed helper values to it, such as a polygon's _points. So by the time the hash was taken, the "same" map data hashed differently on every run. The fix hashes only the data from the file and skips the keys that code adds (which by convention start with _):

## Hash of map data, without the caches code adds to it ("_" keys, e.g. a polygon's parsed "_points").
static func data_hash(v: Variant) -> int:
	if v is Dictionary:
		var parts: Array = []
		for k: Variant in v:
			if not (k is String and (k as String).begins_with("_")):
				parts.append([k, data_hash(v[k])])
		return hash(parts)

The key also includes a BAKE_VERSION, so changing the bake code invalidates old files. A test checks that a second load comes from disk in under a second and gives the same heights.

The lesson is general: a cache keyed on mutable state is a cache that silently doesn't work. It didn't crash or give wrong results. It was just slow, which is the hardest kind of bug to notice.

The result, round one #

With animation LOD, proximity spawning, dozing, chunked plants and the cache, the Vale ran at 144 fps at 4K, the refresh-rate cap of the test machine. That left room to spend: shortly after, a commit made the woods denser, drew trees out to 200 m, and put three times as many packs and Shadows on the map.1 When the Vale was later shrunk to about 1.4 x 1.8 km, the spawn radius came down with it, to 200 m, with despawning at 260 m.

Then the art pass (next post) added pixel-texture shaders, leaf cards, denser grass, cloud shadows and a lens effect, all on top of this budget.

The Shadow Woods at 3840x2054 with a measured fps overlay (capped by the monitor's 144 Hz)

Round two: the GPU heap that ran out #

The frame rate was fine. Then, on day five, an agent's screenshot run on the reworked Hellmouth Rift produced 7,518 error lines, almost all of them the same:

Cannot create uniform set ... not enough room in the RESOURCES descriptor heap
(please increase rendering/rendering_device/d3d12/max_resource_descriptors)

On Direct3D 12, every live material, skeleton and skinned mesh holds GPU descriptors, and the heap has a fixed size. The error message suggests the obvious fix: make the heap bigger. The brief for the agent that investigated said no: raising the limit was "acceptable only as an extra safety margin with a stated reason, not as the fix." Find out what is holding descriptors, and why the number keeps growing.

The diagnostic scenario it wrote (scenario_render_heap.gd) teleports the player across a map, hop after hop, and prints the live skeletons, the distinct materials drawn and the video memory at each stop. Two things were growing without limit:

  1. Monster models that were never freed. The big maps keep a few hundred monsters around a player, and every one of them built its skinned model when it spawned, even out of sight. Animation LOD stopped them animating, but each model still held its skeleton and buffers. After a few teleports or waystone hops, the Hellmouth Rift had 2,419 live skeletons and climbing.
  2. Materials made per object. Each monster had its own hit-flash material, each capsule its own material, and props asked a helper for a material by colour and then modified the one they got, which forced a new one each time. The Rift drew 3,308 distinct materials.

The fixes are small:

## ... beyond RELEASE_RANGE the model is freed (built again on the way back in). A model holds GPU
## descriptors for its skeleton and every skinned surface (D3D12: rendering_device/d3d12/max_resource_descriptors,
## ~30 per monster): the big maps keep ~400 monsters around a player [...], so after a few teleports or
## waystone hops the heap ran out. Now only the monsters near the camera hold a model.
const ANIM_RANGE := 70.0
const HIDE_RANGE := 170.0
const RELEASE_RANGE := 200.0

The hit-flash material became one static material shared by every monster. Capsule materials and the swing effect are only created when something actually needs them. The map's material helper caches by look (colour, glow, roughness), and the five places that used to modify the returned material now pass those values in instead. The heap limit stayed at its default.

Hellmouth Rift, same hops Before After
Live skeletons 269, rising to 2,419 105 to 402, bounded
Descriptor heap errors 3,826 0
Distinct materials drawn 1,100 at start, rising to 3,308 159 at start, about 300
Video memory up to 2.69 GB about 1.2 to 1.4 GB

The Vale went from up to 1,781 skeletons to 126-293. Prop materials alone dropped from 731 to 67 on the Rift. A regression test checks that twelve props share at most four materials, that the material helper returns the same material for the same look, and that a far monster gives its model back.

The lesson is the same one as in round one, one level lower: level of detail has to reach memory, not just work. A monster that doesn't animate still costs whatever it holds.

Clutter: two thousand objects, one material #

The living-world pass2 It has its own post. put crates, barrels, carts, market stalls and laundry lines into every town and camp: 2,129 placements across the maps, 918 of them in the Vale. Drawn naively, that's thousands of draw calls and, after round two, an obvious descriptor risk. Clutter reuses the plant approach and goes one step further:

Drawn like Flora: everything of one model inside one CHUNK square is one MultiMesh, and every model shares ONE material [...]: a model's material colours are baked into its vertices at load, its "Paint_<n>" parts take the instance's colour, each instance gets a brightness jitter. So a dressed town costs (models x chunks in view) draw calls and one material.

Chunks are 48 m, clutter is drawn to 130 m (small pieces to 70 m and without shadows), and only big pieces like carts and stalls collide, with their boxes merged into one physics body per 64 m cell. The buildings got the same treatment in their detail pass: weathering went into vertex colours so that every house shares one material.

Sprites: a video memory budget, enforced by tests #

The switch to pre-rendered pixel sprites for players and NPCs3 It has its own post. moved the cost from the GPU's vertex work to texture memory. A character's sheets cover 8 directions and dozens of animation clips, in several paper-doll layers. Two tests keep that in check. One dresses a character (body, a hairstyle, the heaviest armor, eyes) and loads everything one weapon type plays: it must stay under 19 MB, and it's 18.3 MB for the male body and 16.4 MB for the female. The other builds every NPC and townsperson of the Vale plus 30 passers-by (63 people) and checks their sheets against a 128 MB budget: they need 92.7 MB. The trick that made those numbers possible is packing the sheets per "bank" (the shared clips, then each weapon set's own), so a character only loads the pages of clips it can actually play.

Where it stands #

A fresh run of the scenic screenshot scenario on the current build, at 3840x2054 with everything on (sprites, sprite-style shading, clutter, grass, volumetric fog, depth of field), measured between 106 and 147 fps across twelve spots in the Vale, with 389 to 1,451 draw calls. The low end is in the densest woods and at the stone circle at night. That's on the development machine, a fast desktop; nobody has measured a modest PC yet, and that's the next honest number to get.

Takeaways #

  • Measure first. We expected rendering; it was animation and AI on the CPU. Later, it was GPU memory nobody was looking at.
  • The cheapest monster is the one that doesn't exist. Proximity spawning with hysteresis beat every per-monster optimization. The second cheapest is the one that doesn't hold a model.
  • Don't take the error message's advice blindly. "Increase the heap" would have hidden a leak that grew with every teleport.
  • Stagger periodic checks. A thousand timers that fire on the same frame make a hitch.
  • Grid your big things and share materials: plant batches, clutter, colliders, grass. Anything "one per object" or "one per map" breaks on a big map.
  • Key caches on immutable inputs. Check that your cache actually hits.
  • Put budgets in tests. A video memory number in a test fails the day someone adds one clip too many.

What's next #

With frame time to spare, we spent it on looks. Next: chasing the Valheim look with pixel noise, leaf cards, grass that parts around you and shadows of clouds that aren't there, and then leaving it for something else.