← All posts

Editor scripting: the size-cull that made my buildings dissolve

Post #5 was about runtime scripts - gameplay that runs when you press Play. This is a different beast: editor scripts, which run in the editor, on demand, to reshape the project itself. Same idea (write TypeScript, it compiles in the tab), completely different job.

Runtime script (post #5) Editor script (this post)
Runs during Play on a button click, in the editor
Acts on its own entity, each frame the whole scene/assets, once
API onUpdate, input, physics find, connected, combine, create
Undo n/a (it's the game) the entire run = one Ctrl-Z
Ships? yes, into the game no, editor-only

The problem: buildings that dissolve

Here's what actually sent me down this road, and it wasn't the object count - it was watching the world fall apart as I flew through it. Turn on distance culling in the Synty POLYGON Fantasy Kingdom demo (gorgeous pack, it's what I test everything on), back the camera away from a house, and it doesn't fade cleanly into the distance - it dissolves. The roof vanishes while the walls stay. A railing pops out from under a floor that's still there. The building comes apart and reassembles, one piece at a time, as you move. It looks broken, because it is.

The cause is two clever things colliding. First, a modular kit is built for variety: a house isn't a house, it's a wall, plus a corner, plus a floor tile, plus a railing, plus a half roof tile - each a separate object you snap together however you like. Open the demo scene and the overlay reads:

Objects   15,241

Click one roof and the inspector says SM_Bld_House_Roof_Tile_Half - a single half roof tile, its own GameObject, its own transform, its own line in a 15,241-object hierarchy. Great for building.

A single half roof-tile selected in the Awaken editor - its own GameObject, its own MeshRenderer - inside the 15,241-object Synty demo scene.

Second, Awaken's distance culling is size-aware - the honest substitute for the occlusion system I threw away. It culls small things up close and keeps big things visible far away, so a keep stays on the skyline while a pebble vanishes at your feet. For independent props that's exactly right. But it rests on one assumption: an object's size tells you how far away it still matters. A modular building snaps that assumption in half. The wall is big, so it survives to the far distance; the half roof-tile is tiny, so it's culled up close - same building, its parts winking out at different ranges, coming apart in size order as you retreat. The engine never stood a chance: to it there is no building, only a bag of assorted-size parts, each judged alone. (Same lesson as post #8: before optimising a technique, check its assumptions against your actual data.)

And to be clear, this was never about raw draw calls. Awaken already instances and static-batches hard: in that same scene, 15,241 objects render in 759 draw calls, not 15,241 (186 static chunks plus one 7,321-instance batch). The engine works. What it can't do on its own is know that these forty parts are one thing that should live or die together.

The knowledge of what counts as one building is the user's, not mine. So instead of hard-coding "merge buildings" into the engine, Awaken exposes an editor-scripting API and lets the merge be a script you write and ship as content.

The actual tool

Here's the real thing - Combine Buildings.awakenscript, shipped in Awaken's starter content. Two parts: a run() that walks the scene, and a bakeMerge() that does the geometry. Start with run():

export const params = [
  { name: "seed",    type: "string", default: "SM_Bld_House_" },
  { name: "family",  type: "string", default: "SM_Bld_House_" },
  { name: "gap",     type: "number", default: 0.3 },
  { name: "maxSpan", type: "number", default: 15 },
];

export default async function run(api, p) {
  const familyRe = new RegExp("^" + p.family + "(?!.*_combined)"); // never re-absorb our own output
  const done = new Set();
  let built = 0;

  for (const seed of api.find(p.seed)) {
    if (done.has(seed.id) || /_combined$/i.test(seed.name)) continue;

    // Flood-fill the connected parts by world-space bounding-box overlap (grid-broadphased, not N²).
    const parts = api.connected(seed, { match: familyRe, gap: p.gap });
    for (const part of parts) done.add(part.id);

    // Guard: if the cluster is implausibly large, connectivity over-reached (a long part bridged a gap
    // into a neighbour). Skip rather than silently fuse two buildings into one.
    let mnx = 1e9, mnz = 1e9, mxx = -1e9, mxz = -1e9;
    for (const part of parts) {
      const w = part.worldPos;
      mnx = Math.min(mnx, w.x); mxx = Math.max(mxx, w.x);
      mnz = Math.min(mnz, w.z); mxz = Math.max(mxz, w.z);
    }
    if (mxx - mnx > p.maxSpan || mxz - mnz > p.maxSpan) {
      api.log(`Skipped "${seed.name}": cluster span ${(mxx-mnx).toFixed(1)}x${(mxz-mnz).toFixed(1)}m > maxSpan ${p.maxSpan}m`);
      continue;
    }

    // Merge the structural parts; leave anything named like a door (or a prior merged result) OUT.
    const statics = parts.filter((x) => !/door/i.test(x.name) && !/_combined$/i.test(x.name) && x.meshData);
    if (statics.length < 2) continue;

    const groups = bakeMerge(statics);               // ← the merge happens here, in the script
    api.combine(statics, groups, { name: seed.name + "_combined" }); // ← the atomic swap is the engine's
    built++;
    await api.yield();                               // hand a frame back so the editor paints + Cancel works
  }
  api.log(`Done - ${built} building(s) combined.`);
}

Three things in there earn their keep:

  • api.connected is a flood-fill over world-space AABB overlap, grid-broadphased so a scene of thousands of objects isn't an N² scan. gap is the slack (0.3 m) that lets snapped-but-not-touching parts count as connected.
  • The maxSpan guard. Flood-fill is greedy; one long fence or a shared wall can bridge two houses and swallow the whole street. Rather than trust it, the script sanity-checks the cluster's footprint and skips anything too big. On the Synty demo it caught four of them, and said so.
  • await api.yield() between buildings. An editor script runs to completion with nothing preempting it - without yielding, merging 34 buildings would freeze the UI solid. yield hands a frame back to the browser so the progress log paints and the Cancel button works; it also throws if you cancelled, so the run stops cleanly and the partial work collapses into one undo.

And bakeMerge() - the part the engine deliberately does not own:

// Bake each part's world transform into its vertices and concatenate, grouped by texture → one merged
// mesh per group. Normals use the world matrix's 3×3 + renormalise: exact for rotation + UNIFORM scale
// (the modular case). Non-uniform scale would need a proper inverse-transpose.
function bakeMerge(parts) {
  const byTex = new Map();
  for (const p of parts) {
    const key = p.getField("MeshRenderer", "texture") || p.meshId || "";
    if (!byTex.has(key)) byTex.set(key, { parts: [], appearance: appearanceOf(p) });
    byTex.get(key).parts.push(p);
  }
  const groups = [];
  for (const [, g] of byTex) {
    // …allocate positions/normals/uvs/indices for the whole group…
    for (const part of g.parts) {
      const d = part.meshData, m = part.worldMatrix; // column-major 16
      for (let v = 0; v < d.positions.length / 3; v++) {
        // position = M · vertex
        positions[o]   = m[0]*px + m[4]*py + m[8]*pz  + m[12];
        positions[o+1] = m[1]*px + m[5]*py + m[9]*pz  + m[13];
        positions[o+2] = m[2]*px + m[6]*py + m[10]*pz + m[14];
        // normal = normalize(M3x3 · n)
        // …
      }
      // append indices offset by the running vertex base
    }
    groups.push({ data: { positions, normals, uvs, indices }, appearance: g.appearance });
  }
  return groups;
}

This is the deliberate line in the sand. The policy - group by texture, bake world matrices, decide that doors stay dynamic and _combined outputs are off-limits - lives in the script, because it's exactly the pack-specific knowledge the engine shouldn't hard-code. The engine's only job is the atomic swap: api.combine(parts, bakedGroups, opts) deletes the parts, adds the merged meshes, and preserves any dynamic child (a door under a wall) under the result - as one undoable operation.

flowchart TD
  F["api.find(seed)"] --> C["api.connected
flood-fill cluster"] C --> G{"span > maxSpan?"} G -->|yes| SK["skip + log"] G -->|no| B["bakeMerge
(in the script)"] B --> M["api.combine
(atomic, engine)"] M --> Y["await api.yield()"] Y --> F

Why the whole run is one Ctrl-Z

A tool that mass-edits your scene is terrifying unless undo is bulletproof, and getting there taught me something about entity handles. My first version composed combine from primitives: make the merged mesh, re-parent the doors, delete the walls. Forward: fine. Undo: corrupted the scene.

The culprit was the generational handles from post #2. Undo restores deleted objects by re-instantiating them from a snapshot - minting brand-new handles. So a "re-parent the door" command that captured the wall's old handle points at a tombstone once undo restores the wall under a new one. Every command was individually correct; the composition was wrong.

The fix: make combine a single atomic snapshot-swap - capture the whole affected forest up front, and on undo restore all of it in one shot instead of replaying fragile sub-steps. One command owns one snapshot, so nothing goes stale. (I adversarially reviewed it and it still coughed up two edge cases - a static nested under a preserved door getting duplicated, and a re-run re-absorbing its own _combined output - which says a lot about how much sharper review is than "it worked when I tried it.") The one-undo wrapper itself is nearly free: every edit is already a Command, so a small runGroup splices the whole run into one composite. 3,905 edits, one Ctrl-Z.

What it actually did

I ran it on the Synty demo, same camera before and after. The console tells the story first:

Combined 291 parts -> SM_Bld_House_Roof_Tile_Edge_End_02 (10)_combined
Combined 312 parts -> SM_Bld_House_Railing_Half_01 (4)_combined
Combined 173 parts -> SM_Bld_House_Base_Wall_02 (122)_combined
…
Skipped "SM_Bld_House_Base_Wall_01 (67)": cluster span 17.3x13.8m > maxSpan 15m
Skipped "SM_Bld_House_StoneArch_Beam_01": cluster span 15.0x22.8m > maxSpan 15m
…
Done - 34 building(s) combined.

34 buildings, 3,905 parts, collapsed into ~99 merged meshes (one per texture per building), with the maxSpan guard correctly refusing four over-reaching clusters. And the stats:

Metric Before After Δ
Draw calls 759 540 −29%
Objects 15,241 11,435 −25%
Main-batch instances 7,321 4,510 −2,811
Triangles 2,596,399 2,741,736 +5.6% ⚠️
Mesh RAM 183.4 MB 227.2 MB +44 MB ⚠️
FPS 120 (vsync) 120 (vsync) -

Read that honestly. This is a draw-call and object-count win, not an FPS win - on an M-series GPU at 2198×1138 the frame is vsync-capped at 120 either way; the machine isn't sweating. What genuinely dropped is draw calls (−29%) and object count (−25%) - 3,806 fewer entities to cull, transform and bookkeep every frame, which is exactly the per-object CPU cost you'd feel on a weaker device or a bigger scene.

But none of that is why I built it, and - this matters - none of it is even the point of the tool. I measured with distance culling off, so the table can't show the thing that actually sent me here: a merged building is now one object, so it culls as a unit instead of dissolving part-by-part. That win doesn't live in this table at all - it's the difference between a house that stays whole when you walk away and one that comes apart in your peripheral vision. The numbers are a bonus; coherent culling is the reason.

And it isn't free: 5.6% more triangles and 44 MB more mesh RAM. Baking un-instances the geometry - a private copy of every part instead of one shared mesh drawn thousands of times - so the GPU has more to push, not less. No free lunch, and that mesh-RAM number is where I embarrassed myself.

A side-quest: the memory bug I was wrong about

This is a detour - the tool was already doing its job - but it caught me out, and the honest half of any engineering story is the part where you're wrong. Look back at the table: mesh RAM jumped 44 MB. My immediate assumption was we're not freeing the original meshes. Combine bakes the parts into new merged meshes and deletes the part entities - but I bet it never removes the part meshes from the asset store. A classic leak.

I went to prove it, and I was half right. The AssetStore had an addMesh and - I checked - no remove path at all. Nothing ever GC'd a mesh. So I built one properly: removeMesh, and a refcounted, undo-safe prune inside combine - after baking, free any source mesh that no surviving object still references (via a MeshRenderer or a mesh-collider), stash the data so undo can put it back, and only free a shared modular mesh when the last building using it combines. Tests for the lot: prunes an orphan, keeps a still-referenced mesh, never touches builtins, survives undo/redo.

Then I re-measured. Mesh RAM went from 228.4 MB (un-pruned) to 227.2 MB (pruned).

The fix recovered 1.2 MB of 44.

I'd misdiagnosed it. The prune works - it freed the meshes that genuinely orphaned - but in a modular pack almost nothing ever does. The same roof-tile mesh lives in the four skipped clusters, in standalone placements, in buildings I didn't combine; merging 34 houses rarely removes a mesh's last reference, and the few that do orphan are tiny (a railing segment ≈ 14 KB).

The 44 MB isn't a leak. It's un-instancing. Before combine, 7,321 instances shared a few hundred unique meshes - each stored once, drawn thousands of times. bakeMerge writes a private copy of every part's geometry into the merged buffer. That's the entire trade laid bare: instancing is memory-cheap and draw-call-expensive; baking is the opposite. You don't optimise your way out of it - you choose, per scene, which resource you have to spare.

I kept the prune. It's correct hygiene, it's undo-safe, and it genuinely helps when you combine a self-contained structure whose meshes are used nowhere else. But I never marched off for a memory win - I marched off because the world was dissolving. The RAM was a rabbit hole I fell down while measuring, and I climbed out almost empty-handed. That's exactly why it's in here.

After running Combine Buildings: a whole house is now a single merged mesh (highlighted), and the stats fall with it - objects 15,241 → 11,435, draw calls 759 → 540.

The point

The geometry-baking lives in the script, not the engine - Awaken provides sharp primitives (find, connected, one atomic undoable combine, yield) and gets out of the way. An engine for one person is a pile of hard-coded actions; an engine for everyone hands people the tools and lets them build the action I never thought of.

And a smaller lesson, from the detour: measure before you believe your own diagnosis. I was certain the RAM was a leak; one number proved me wrong. But the tool was never about that number - it was about a house staying a house when you walk away from it. That's the win, and it doesn't show up in a memory readout at all.

← All posts