The editor had to be shut down before the optimisation would run at all
The open-world town had grown from a few hundred actors to a few thousand, and it had started to feel it. Before spending a day guessing, I audited it.
The cheap way to audit a whole project is not to load the meshes. Every static mesh carries asset-registry tags — triangle count, LOD count, whether Nanite is on, source dimensions — and those can be read as metadata without opening 1,768 assets. Cross-referenced against the map's external actor packages on disk, that gives a full picture in a couple of minutes.
The picture was worse than I expected.
What the audit found
The level had gone from 344 actors to 4,367: 2,922 static mesh actors, 549 door blueprints, 256 landscape streaming proxies, 176 streaming (HLOD) proxies, 89 point lights, 53 road splines.
- There were essentially no LODs anywhere. 4,759,993 triangles placed in the map, of
which 4,759,359 sat on single-LOD, non-Nanite meshes. Project-wide, 1,644 of 1,768 static meshes were single-LOD and non-Nanite; only 114 were Nanite. That is not "we should tune the LOD thresholds" — that is no level-of-detail system at all.
- 546 terraced houses were two mesh assets. Two variants, 6,036 triangles each,
placed 280 and 266 times. 3.3 million triangles, roughly 70% of the map's geometry, in two files. Which is very good news: fix two assets and most of the problem is gone.
- 201 textures at 4096 or above, and the 8192 set had grown from a handful to dozens
as the character roster went from four MetaHumans to about twenty-five, each arriving with a fresh uncapped 8192-square body bake. On a card with roughly 7 GB usable that is a direct video-memory cost.
- The streaming proxies were ten days stale. They pre-dated the bridges, the whole
river and its terrain carve. That was the explanation for a symptom I had been staring at for days: distant terrain rendering as interpenetrating spiky pyramids while line traces reported perfectly correct ground heights. The traces were hitting the real landscape; the camera was seeing a proxy built before the landscape was carved.
So: rebuild the proxies, and give the geometry some LODs.
The trap: "GPU Crash dump Triggered" during an HLOD build
Rebuilding the streaming proxies from inside the running editor took the graphics card down. Twice.
CrashType: GPUCrash ErrorMessage: GPU Crash dump Triggered
GPUBreadcrumbs: AsyncCompute Queue 0 -> SceneRender - ViewFamilies
-> GPUResourceUpdate -> RenderGraphExecute
The obvious reading is "too much work at once", so I did the obvious thing: batch it. Eight source actors per batch, save and collect garbage after each one, write progress to a resume file so a crash costs one batch rather than the run. Three passes of six batches went through fine with system memory flat at 25 GB. I raised it to ten per pass and it died on the fourth, at 28 batches of 52.
The clue was in where it died, not in how much it had done. The breadcrumb had moved:
| | first crash | second crash | |---|---|---| | queue | AsyncCompute Queue 0 | Graphics Queue 0 | | breadcrumb | SceneRender - ViewFamilies | EndDrawingViewport, TexturePoolCopyOps, CanvasClear |
Both crashes were in viewport rendering, not in the mesh merge. Proxy generation fills video memory, and then the editor's own frame — the thing drawing the viewport I was watching the progress bar in — cannot get its allocations, and the driver resets.
That reframes the whole problem. The build was not too big. The build was competing with a renderer that had no business running. Smaller batches only postpone the moment the two totals cross; they cannot stop it, because the editor keeps drawing between batches. Actor count was never the issue either — 542 source actors is nothing.
The right fix is to remove the renderer, not to shrink the work.
The fix: three separate headless passes
Unreal ships an out-of-process route for exactly this — a commandlet, run with no editor UI. So the whole optimisation became one batch file driving three commandlet invocations with the editor closed, which refuses to start at all if it can see the editor process running.
That refusal is not politeness. A commandlet cannot save a package the open editor is holding: Unreal saves by writing the new file and then moving the original aside, and the move fails on a locked file. The symptom is a wall of "error moving file" lines against always the same packages, and re-running only multiplies them.
Three passes, each its own process so a crash in one cannot touch the others, each resuming from a progress file:
- Assets. Cap every uncapped 8192-and-above texture to 4096, then build a three-step
LOD chain (100/50/20% triangles at screen sizes 1.0/0.35/0.12) on every single-LOD non-Nanite mesh over 1,500 triangles, skipping grooms and the MetaHuman build folders. Default is a dry run; applying requires an explicit environment flag.
- Audit. Read-only in both modes. Opening a World Partition map headlessly was the
least-proven thing in the run, so I made the pass that does it incapable of damaging anything.
- Streaming proxies. Last, deliberately, so the proxies build from the new LODs
rather than needing a second rebuild afterwards.
The second trap: a pass that reported 127 and did nothing
The first apply run capped 46 textures, correctly and permanently. Then it announced it had built LOD chains on 127 meshes.
It had built none. Zero LOD-generation log lines in a 395 KB log. No errors, no warnings, no exception.
The mechanism: LOD generation lives in the static mesh editor subsystem, and that module is not loaded in a script commandlet, so the subsystem is never created. Asking for it returns nothing, and the deprecated fallback path ends in a line that reads, in effect, "return the subsystem's answer if there is a subsystem, otherwise return -1". A bare -1. No log, no throw. The only place the contract is stated at all is a comment in the header: a negative value means the reduction could not be performed.
The count of 127 came from the script's own loop counter, not from the assets.
The repair is two lines: explicitly load the static mesh editor module, plus the two mesh reduction modules, at the top of the pass. Probed before and after — the subsystem goes from nothing to a live object, and the LOD call returns 3 instead of -1. The pass stayed in the commandlet, which is the right place for it anyway.
The guard that stops it recurring is the more useful half: compare the return value against the number of LODs you asked for, not against zero, and raise if it does not match. And, because an in-memory read is not proof either, check whether the package is still dirty after saving. An earlier pass had set a flag, read it back as set, and reported success while every single package had failed to write.
Nanite, and the four materials holding the whole town hostage
Of those 127 heavy single-LOD meshes, 92 were fully opaque or masked and could simply become Nanite; 35 were blocked and needed manual LOD chains instead. Nanite and hand-built LOD chains are alternatives, never both — building a chain for a mesh you are about to convert is wasted work.
What blocked the 35 was almost entirely glass. Four translucent glass materials gated the entire building stock, including the two terrace meshes that are 70% of the map. On the terrace the glass sits in its own material slot, so nothing about the geometry had to change: swapping that one slot for an opaque variant made both meshes Nanite-eligible. That was a deliberate call by the project owner and it costs something real — those 546 houses have modelled interiors and now have opaque windows. The house the player actually goes inside keeps its real glass. It is one slot, and it is reversible.
Two smaller traps came out of that work, and both are the same species as the -1:
- The blend mode enum stringifies as
<BlendMode.BLEND_OPAQUE: 0>. A tidy-looking
check for a name ending in "Opaque" matches nothing, so the first eligibility scan declared 0 of 127 meshes eligible and looked entirely plausible doing it. Test for the blockers as a substring instead.
- Writing into a material slot array silently does nothing. Reading the array back
gives you a copy; mutating an entry and setting the array again writes your edit into the copy. What works is constructing a brand-new array of new entries for every slot and setting that. Then reload the asset and read the slot back.
Where it ended up
All of this ran headless with the editor closed, and every result was re-read from the assets rather than trusted from the script's own bookkeeping: 46 textures capped, 35 glass-blocked meshes given three-LOD chains, 91 meshes converted to Nanite, the terrace glass swapped so the two 546-instance house meshes became Nanite, seven road and kerb meshes converted, and 303 streaming proxies rebuilt.
The owner played it afterwards and said it ran much smoother. That is a subjective report from one session on one machine, not a frame-time measurement, and I am recording it as exactly that — but it is a real signal, because the same person had been playing the unoptimised version daily.
One expected side effect worth knowing: capping a texture invalidates its derived data, so the next map load rebuilds several hundred of them. It looks alarming in the log and it is one-time.
Still not done: Nanite on the landscape. The crash logs put a number on why it keeps failing — roughly 140 MB of generated mesh per landscape proxy, times 256 proxies, is about 37 GB. One attempt got 35 exports in, about 5 GB, and died out of memory with 8.6 GB of 68.6 GB still free, which is a commit-space failure rather than physical exhaustion. It also cannot be finished headlessly at all: the build entry point is not exposed to scripting and needs either the editor's own Build menu or a subsystem tick, and a commandlet has no tick. It sits in a safe half-state and is genuinely unfinished.
One last thing that cost me a wrong conclusion mid-session and is worth a line: engine log timestamps are UTC while process and file timestamps are local. During British Summer Time that is a one-hour offset, and it made a batch of exports look as though it had been triggered by a pre-existing flag rather than by the save I had just done. Reconcile the two clocks before attributing a crash to anything.
What to take from it
- **A build that competes with the editor's own renderer for video memory cannot be
rescued by smaller batches.** Read the crash breadcrumb: if it died presenting a frame rather than in the work you asked for, the fix is to remove the renderer, not to shrink the work.
- Batching still earns its place even when it does not save you. Per-batch saving and
a resume file meant a crash at 28 of 52 cost one batch, and the way the failure moved across batches is what identified the real cause.
- A count of things processed is not evidence that anything was processed. That number
came from the loop, not from the assets. Verify by reading the changed state back out of the thing you changed.
- Check a return value against what you asked for, not against zero. An API that
signals failure with -1 and no log will pass any "did it not throw?" test you write.
- **A headless subsystem that comes back empty usually means an unloaded module, not an
unsupported operation.** Load the module explicitly; it is a general escape hatch.
- Assume struct arrays are copies and enums are strings until proved otherwise. The
material slot write and the blend mode check both failed silently and convincingly, in the same run, for unrelated reasons.
The through-line is the one this project keeps paying for: the failures that cost days are not crashes. Crashes are honest. The expensive ones return a plausible number and change nothing.