hex-tower-defence tab crash: art_pipe export stage never decimates (137k tris / 109 MB assets) + the game loads every slot concurrently #855

Open
opened 2026-07-26 04:50:54 +00:00 by spikerj · 1 comment
Owner

Symptom

Opening https://learn.spikersoft.com/hex-tower-defence crashes the browser tab
once more than one Art Studio asset is assigned to a slot. With a single
assigned asset (the Command Center) it worked. The crash leaves no console
error
and no stack — the tab simply dies.

Root cause

Two independent defects that only became fatal together.

1. The export stage never decimates (spikersoft-backend)

SpikerSoft.EventHandlers.ArtPipeProcessor/appsettings.json declares the
export stage with Params: { include_animation: "true" } and no
target_faces
.

art_pipe reads it as int(params.get("target_faces") or 0)
(src/artpipe/models/blender_stage.py), and the game_export operator
decimates only if mesh_obj and self.target_faces > 0
(src/addons/artpipe/game_export.py). So the omission meant no decimation
ever ran
— every Art Studio game export ships at raw generative density.

Measured on a stored export-glb (blender_export_1de22c8e.glb, pulled from
GridFS and parsed):

triangles 137,799 (face budget is 5,000–16,000)
vertices 413,215 — exactly 3× triangles, i.e. fully unwelded, zero index reuse
binary buffer 17.77 MB of the 17.77 MB file
attributes POSITION, NORMAL, COLOR_0no TEXCOORD_0
texture 1 × 512×512 PNG, 0.43 MB (irrelevant to the size)

It is not a texture problem — it is raw vertex data.

The unwelded output is a second-order effect of the same bug: the exporter's
remove_doubles + normals_make_consistent cleanup sits inside the
if applied_decimate: block, so skipping decimation skips the weld too.

Production export-glb sizes today:

asset export-glb
Game: Main Building 109.51 MB
Test chair 73.17 MB
Hexatile: Zergling 53.85 MB
Chess: Pawn Piece 42.50 MB

Note the export stage makes the asset ~5× bigger than its own texturing-stage
input (21.60 MB → 109.51 MB for Main Building), which is backwards for a
game export.

GetArtAssetManifestQueryHandlerArtAssetArtifactSelection.PickExportMesh
prefers exactly this artifact, so the game downloads the biggest thing in the
asset.

2. The game loads every slot concurrently (spikersoft-angular)

HexTowerDefenceComponent.syncSlots pushed one applySlot per unapplied slot
into Promise.allSettled. All downloads and their GLTFLoader parses ran
at once, so peak memory was the sum of every model (blob + ArrayBuffer + typed
arrays, all live simultaneously). Four assigned slots ≈ 280 MB of GLB, ~3× that
transient. A renderer-process OOM kills the tab outright — which is exactly why
there was no catchable error.

One asset fit under the limit. Two did not.

Fix

  • spikersoft-backend: set "target_faces": "6000" on the export stage
    (matches the pipeline's own band — EXPORT_DEFAULT_TARGET_FACES = 5000,
    COVERAGE_PRESET_FASTEST = 6000). Config-only; no artpipe image rebuild.
  • spikersoft-angular: load slots sequentially; budget the loadout as a whole
    (192 MB — the board keeps every registered model resident, so its cost is the
    sum of its slots); give the shared game loader per-asset ceilings (128 MB /
    1.5M triangles) checked before parsing; surface a distinct "too detailed
    for the board" message instead of the generic load failure.

Follow-ups

  • Re-export the existing assets. The four above keep their oversized
    export-glb until the export stage is re-run; until then they will trip
    the new budget and fall back to procedural art.
  • or 0 is a latent trap. build_export_commands silently means "no
    decimation" when the caller forgets the param. Consider defaulting to
    EXPORT_DEFAULT_TARGET_FACES there. Deferred: deploying it rebuilds the
    36 GiB artpipe-model-prodstages monolith, and the backend config fix
    already covers the live path.
  • Missing TEXCOORD_0. The export references a baseColorTexture the
    mesh has no UVs to sample, so appearance rides entirely on COLOR_0.
    Worth a separate ticket — the texturing stage's output is being thrown
    away at export.
## Symptom Opening https://learn.spikersoft.com/hex-tower-defence crashes the browser tab once more than one Art Studio asset is assigned to a slot. With a single assigned asset (the Command Center) it worked. The crash leaves **no console error** and no stack — the tab simply dies. ## Root cause Two independent defects that only became fatal together. ### 1. The export stage never decimates (`spikersoft-backend`) `SpikerSoft.EventHandlers.ArtPipeProcessor/appsettings.json` declares the export stage with `Params: { include_animation: "true" }` and **no `target_faces`**. art_pipe reads it as `int(params.get("target_faces") or 0)` (`src/artpipe/models/blender_stage.py`), and the `game_export` operator decimates only `if mesh_obj and self.target_faces > 0` (`src/addons/artpipe/game_export.py`). So the omission meant **no decimation ever ran** — every Art Studio game export ships at raw generative density. Measured on a stored `export-glb` (`blender_export_1de22c8e.glb`, pulled from GridFS and parsed): | | | |---|---| | triangles | **137,799** (face budget is 5,000–16,000) | | vertices | **413,215** — exactly 3× triangles, i.e. fully unwelded, zero index reuse | | binary buffer | 17.77 MB of the 17.77 MB file | | attributes | `POSITION`, `NORMAL`, `COLOR_0` — **no `TEXCOORD_0`** | | texture | 1 × 512×512 PNG, 0.43 MB (irrelevant to the size) | It is **not** a texture problem — it is raw vertex data. The unwelded output is a second-order effect of the same bug: the exporter's `remove_doubles` + `normals_make_consistent` cleanup sits **inside** the `if applied_decimate:` block, so skipping decimation skips the weld too. Production `export-glb` sizes today: | asset | export-glb | |---|---| | Game: Main Building | **109.51 MB** | | Test chair | 73.17 MB | | Hexatile: Zergling | 53.85 MB | | Chess: Pawn Piece | 42.50 MB | Note the export stage makes the asset ~5× **bigger** than its own texturing-stage input (21.60 MB → 109.51 MB for Main Building), which is backwards for a game export. `GetArtAssetManifestQueryHandler` → `ArtAssetArtifactSelection.PickExportMesh` prefers exactly this artifact, so the game downloads the biggest thing in the asset. ### 2. The game loads every slot concurrently (`spikersoft-angular`) `HexTowerDefenceComponent.syncSlots` pushed one `applySlot` per unapplied slot into `Promise.allSettled`. All downloads **and** their `GLTFLoader` parses ran at once, so peak memory was the sum of every model (blob + ArrayBuffer + typed arrays, all live simultaneously). Four assigned slots ≈ 280 MB of GLB, ~3× that transient. A renderer-process OOM kills the tab outright — which is exactly why there was no catchable error. One asset fit under the limit. Two did not. ## Fix - `spikersoft-backend`: set `"target_faces": "6000"` on the export stage (matches the pipeline's own band — `EXPORT_DEFAULT_TARGET_FACES` = 5000, `COVERAGE_PRESET_FASTEST` = 6000). Config-only; no artpipe image rebuild. - `spikersoft-angular`: load slots sequentially; budget the loadout as a whole (192 MB — the board keeps every registered model resident, so its cost is the sum of its slots); give the shared game loader per-asset ceilings (128 MB / 1.5M triangles) checked **before** parsing; surface a distinct "too detailed for the board" message instead of the generic load failure. ## Follow-ups - [ ] **Re-export the existing assets.** The four above keep their oversized `export-glb` until the export stage is re-run; until then they will trip the new budget and fall back to procedural art. - [ ] **`or 0` is a latent trap.** `build_export_commands` silently means "no decimation" when the caller forgets the param. Consider defaulting to `EXPORT_DEFAULT_TARGET_FACES` there. Deferred: deploying it rebuilds the 36 GiB `artpipe-model-prodstages` monolith, and the backend config fix already covers the live path. - [ ] **Missing `TEXCOORD_0`.** The export references a `baseColorTexture` the mesh has no UVs to sample, so appearance rides entirely on `COLOR_0`. Worth a separate ticket — the texturing stage's output is being thrown away at export.
Author
Owner

Confirmed by production bisect — and my first fix (#580) was wrong

Bisected against the live page with @spikerj, one variable at a time:

loadout result
empty page loads
test5 — 9.18 MB export page loads, skin renders
Game: Main Building — 109.51 MB export crashes Chrome and Safari

So the cause is the oversized export, confirmed empirically rather than
inferred.

Two corrections to the original analysis

Concurrency was never the live trigger. Only ONE slot was ever assigned
(building:command-center6a64c5c9a616921dff155f2a). The sequential-loading
change in #580 fixes a real latent bug, but it is not what was crashing this page.

#580's guards never fired. I sized them at 128 MB / 1.5M triangles so the
un-decimated assets already in production would still load — which meant the one
asset killing the page passed every check. A budget tuned to accommodate broken
data protects nothing. The byte check also read blob.size, i.e. after the
109 MB was already resident, which on Safari is enough to lose the tab on its own.

Why it appeared to work at first

It is a marginal failure, not a deterministic one:

  • The loadout's UpdatedAt is byte-identical to its CreatedAt (20:37:51), so
    no second assignment was ever persisted. onArtAssetPicked loads the model
    before saving, so the tab died mid-load and the PATCH never ran — which is why
    the DB shows one slot even though several were assigned.
  • Hexatile: Zergling was created at 20:40:42, three minutes after the loadout
    was saved, matching "I generated a few more and went to assign them".
  • The asset was never re-generated (retry=0), so the 109.51 MB file is the same
    one that worked once. One copy sat at the edge of survivable; a second on top
    was reliably fatal.

Fixes

  • backend #486 — export-stage target_faces (the cure) + meshSizeBytes in
    the games manifest, so a client can refuse before requesting the body.
  • angular #581 — budgets sized against a healthy export (24 MB / 60k tris per
    asset, 96 MB per loadout), refusal before download, regression test pinned to
    the real 109.51 MB asset.
  • angular #580 — merged and deployed; keeps sequential loading.
## Confirmed by production bisect — and my first fix (#580) was wrong Bisected against the live page with @spikerj, one variable at a time: | loadout | result | |---|---| | empty | page loads | | `test5` — 9.18 MB export | page loads, skin renders | | `Game: Main Building` — 109.51 MB export | crashes Chrome **and** Safari | So the cause is the **oversized export**, confirmed empirically rather than inferred. ### Two corrections to the original analysis **Concurrency was never the live trigger.** Only ONE slot was ever assigned (`building:command-center` → `6a64c5c9a616921dff155f2a`). The sequential-loading change in #580 fixes a real latent bug, but it is not what was crashing this page. **#580's guards never fired.** I sized them at 128 MB / 1.5M triangles so the un-decimated assets already in production would still load — which meant the one asset killing the page passed every check. A budget tuned to accommodate broken data protects nothing. The byte check also read `blob.size`, i.e. after the 109 MB was already resident, which on Safari is enough to lose the tab on its own. ### Why it appeared to work at first It is a *marginal* failure, not a deterministic one: - The loadout's `UpdatedAt` is byte-identical to its `CreatedAt` (20:37:51), so **no second assignment was ever persisted**. `onArtAssetPicked` loads the model before saving, so the tab died mid-load and the PATCH never ran — which is why the DB shows one slot even though several were assigned. - `Hexatile: Zergling` was created at 20:40:42, three minutes after the loadout was saved, matching "I generated a few more and went to assign them". - The asset was never re-generated (`retry=0`), so the 109.51 MB file is the same one that worked once. One copy sat at the edge of survivable; a second on top was reliably fatal. ### Fixes - **backend #486** — export-stage `target_faces` (the cure) + `meshSizeBytes` in the games manifest, so a client can refuse before requesting the body. - **angular #581** — budgets sized against a healthy export (24 MB / 60k tris per asset, 96 MB per loadout), refusal before download, regression test pinned to the real 109.51 MB asset. - **angular #580** — merged and deployed; keeps sequential loading.
Sign in to join this conversation.