Skip to content

Fix OOM crash exporting large WMOs - #590

Open
Wobblucy wants to merge 4 commits into
Kruithne:mainfrom
Wobblucy:fix/wmo-export-oom
Open

Fix OOM crash exporting large WMOs#590
Wobblucy wants to merge 4 commits into
Kruithne:mainfrom
Wobblucy:fix/wmo-export-oom

Conversation

@Wobblucy

@Wobblucy Wobblucy commented Jul 22, 2026

Copy link
Copy Markdown

Exporting a very large WMO (e.g. a raid at several million triangles) exhausts the V8 renderer heap and crashes the app. There are three separate causes; this PR addresses all of them, applied and re-tested in order.

1. Geometry buffers were plain JS Arrays

The OBJ/GLTF/STL paths pre-allocated new Array(nInd * 3) for vertices, normals and each UV/colour layer. On V8 an index-assigned float Array holds boxed doubles at ~8 bytes/element plus overhead; a raid's tens of millions of elements across those buffers is multiple GB. Switched to Float32Array (4 bytes/element, no boxing, zero-initialised).

2. OBJWriter.appendGeometry concatenated via spread

Float32Array.from([...this.verts, ...verts]) builds a temporary JS array of every element before rebuilding a typed array — a triple copy per append, quadratic over a map's worth of WMOs. Replaced with a concatFloats helper that allocates once and copies with .set().

3. The model was still fully resident during export

Even with typed arrays, exportAsOBJ merged every group into one buffer, and separately WMOLoader.getGroup caches every group it loads and never evicts. Walking all groups to export therefore left the entire model resident twice over — and because a map export re-runs the WMO exporter once per referencing ADT tile, the loader cache compounded across passes, still OOMing on a raid.

Two changes here:

  • Added OBJWriter.writeStreamingGroups(). It writes the model one group at a time — each group's used verts/normals/UVs/colours emitted with a compact local numbering, then its faces, with a running global offset carried across groups. exportAsOBJ feeds it groups via an async generator, so peak memory is one group rather than the whole model.
  • The generator drops each group's loader-cache slot (wmo.groups[i] = null) once the writer has consumed it; getGroup transparently reloads from CASC if a later pass or the metadata writer needs the group again. The metadata-JSON pass, which runs after the geometry write, re-fetches per group and releases again.

Scoped to the merged exportAsOBJ path (which constructs its own OBJWriter per WMO). The glTF/STL paths, exportGroupsAsSeparateOBJ, and the shared write() used by the M2 and ADT exporters are untouched.

Output

Equivalent to the previous merged-buffer write. Line ordering differs — the streaming path interleaves each group's vertex block with its faces rather than all vertices before all faces — which is valid OBJ, since indices reference declaration order. Vertex culling (dropping vertices no face references) is preserved. Verified by parsing both outputs and confirming every face resolves to identical vertices, UVs, normals and material.

Testing

Exported the full Ulatek raid (12.1) as part of a map export on Windows. Before these changes the export reproducibly died with V8 javascript OOM (CALL_AND_RETRY_LAST) in the renderer process. After them the export completed: 4/4 ADT tiles, 23 WMO OBJ passes over the raid and its pieces, with the renderer holding a flat ~1.5 GB throughout the geometry work and no OOM.

Exporting a very large WMO (e.g. a raid at several million triangles) could
exhaust the heap and crash the app. Two causes:

1. WMOExporter's OBJ/GLTF/STL paths pre-allocated geometry buffers as plain JS
   Arrays (new Array(nInd * 3) for vertices, normals, and each UV/colour layer).
   On V8 an index-assigned float Array holds boxed doubles at ~8 bytes/element
   plus overhead; a raid's ~45M elements across the buffers is multiple GB.
   Switched these to Float32Array (4 bytes/element, no boxing, zero-initialised).

2. OBJWriter.appendGeometry (used when a map appends many WMOs) concatenated with
   Float32Array.from([...this.verts, ...verts]) - the spread builds a temporary
   JS array of every element before rebuilding a typed array, a triple copy per
   append that is quadratic in memory over a map's worth of WMOs. Replaced with a
   concatFloats helper that allocates the result once and copies via .set().

Measured on a 20-WMO append (30M floats): peak heap 1159 MB -> 629 MB. Output is
byte-identical; write() indexes the buffers the same way for typed arrays.
@Wobblucy

Copy link
Copy Markdown
Author

Closing to re-verify against a fuller test before review — will reopen once confirmed. Apologies for the noise.

@Wobblucy

Copy link
Copy Markdown
Author

Reopening — verified end-to-end on a large raid export (12.1 Venomous Abyss). Without this the export crashes with a JS heap OOM; with it the export completes cleanly and memory stays flat.

The Float32Array change reduced buffer size but a raid-sized WMO (several
million vertices) still exhausted the V8 renderer heap: exportAsOBJ merged
every group into one vertex/normal/uv buffer AND retained every group's
source arrays plus every batch's index array on the OBJWriter until the
final write.

Add OBJWriter.writeStreamingGroups(), which writes the model one group at a
time - each group's used verts/normals/uvs/colours are emitted with a
compact local numbering, then its faces with a running global offset. WMO
groups never share vertices, so no cross-group dedup is lost. exportAsOBJ
now feeds groups through an async generator that fetches, transforms and
yields one group at a time, so peak memory is a single group rather than the
whole model. Output is equivalent to the previous merged-buffer write.

Scoped to the WMO OBJ path (fresh OBJWriter per WMO); glTF/STL and the
shared write() used by M2/ADT exporters are untouched.
@Wobblucy

Copy link
Copy Markdown
Author

Pushed a follow-up commit that fixes a remaining OOM the typed-array change alone did not cover.

The typed arrays reduced the buffer footprint, but exportAsOBJ still merged every group into one set of buffers while holding each group's source arrays and every batch's index array on the writer until the final write. On the Ulatek raid that still exhausted the renderer heap — the crash dump showed V8 javascript OOM (CALL_AND_RETRY_LAST), and it happened a few seconds after the .obj and .mtl had been flushed, so the files on disk looked complete while the process had actually died.

OBJWriter.writeStreamingGroups() writes the model one group at a time, and exportAsOBJ now feeds it groups from an async generator, so peak memory is a single group instead of the whole model.

Re-ran the same raid export afterwards: 1.14 GB .obj written, .mtl written after it, the app stayed up and continued into later phases, and the renderer sat around 1.5–2.5 GB with no OOM. The same WMO happened to be exported three times in that run and all three passes completed.

WMOLoader.getGroup caches every group it loads and never evicts, so walking
all groups to export leaves the entire model (boxed vertex/normal/index/uv
arrays) resident in the loader - on top of the per-group buffers the writer
builds. Because a map export re-runs the WMO exporter once per referencing
ADT tile, that cache compounds across passes and still exhausts the V8
renderer heap on a raid-sized WMO even with the streaming writer.

The streaming generator now drops each group's cache slot (wmo.groups[i] =
null) once the writer has consumed it; getGroup transparently reloads the
group from CASC if a later pass needs it. The meta-JSON pass, which runs after
the geometry write and reads only lightweight per-group metadata, re-fetches
via getGroup and releases again, so peak memory there is also a single group.

Scoped to the merged exportAsOBJ path; exportGroupsAsSeparateOBJ is untouched.
@Wobblucy

Wobblucy commented Aug 1, 2026

Copy link
Copy Markdown
Author

Follow-up: the typed-array change alone was not sufficient. On a full map export the raid still OOM'd, because WMOLoader.getGroup caches every group and never evicts, and the map export re-runs the WMO exporter once per referencing tile — so the loader cache compounded across passes until the heap was exhausted.

Added group-cache eviction: the streaming generator drops each group's cache slot once the writer has consumed it, and getGroup reloads from CASC on demand for any later pass or the metadata writer.

Re-tested end to end on the raid map export: 4/4 tiles, 23 WMO OBJ passes, renderer flat at ~1.5 GB throughout, no OOM. Before, it crashed partway through the passes.

The streaming writer emitted vc lines inline per group and skipped groups
without a second colour set. The Blender addon pairs vc lines with
vertices purely by order, so one colourless group in the middle of a
model silently shifted every later colour onto the wrong vertex. The
monolithic writer zero-filled those vertices, so its output never had
the gap.

Buffer the colours during the pass and write them after the last group,
zero-filling groups that had none whenever any group carries colours.
Costs 16 bytes per emitted vertex for coloured groups, which is bounded
and small next to the geometry this path exists to avoid holding.

Also close the file writer in a finally block; a failure mid-stream
previously leaked the handle and left a partial .obj that looked
complete.

Verified by streaming three one-triangle groups with the middle one
colourless: 9 vertices now produce 9 vc lines with the middle three
zero-filled, where before it produced 6 and misaligned the last three.
@Wobblucy

Wobblucy commented Aug 3, 2026

Copy link
Copy Markdown
Author

Pushed a correctness fix found while re-reviewing this branch.

The streaming writer emitted vc lines inline per group and skipped groups without a second colour set. The Blender addon pairs vc lines with vertices by order alone, so a colourless group in the middle of a model shifted every later colour onto the wrong vertex — the monolithic writer zero-filled those vertices, so its output never had the gap. Colours are now buffered during the pass and written after the last group, zero-filled where a group had none. Verified with a three-group stream (middle group colourless): 9 vertices now yield 9 aligned vc lines where before it yielded 6 misaligned ones.

Same commit also closes the file writer in a finally, so a failure mid-stream can no longer leak the handle and leave a partial .obj that looks complete.

One trade-off worth stating for review: with group eviction, the metadata path re-fetches each group from CASC when meta export is enabled, so those groups are loaded twice per pass. That is the price of keeping peak memory at a single group; the re-fetch is sequential and did not measurably change export time in the runs above.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants