HumankindAssetFramework

Dedicated District Visual — custom model with a strategic footprint

Which district page do I want?

Give a single district its own building model and the persistent top-down footprint that vanilla districts show at strategic zoom — scoped to that one district, with the shared visual affinity (and the non-plugin fallback) left intact. Verified in-game 2026-08-12 on ENC’s Breeder Reactor (a Extension_Base_BreederReactor that otherwise borrows DistrictVisualAffinity_Base_Industry).

This is the successor to the old isolate mesh-swap (District-Visuals.md), which could render a custom building but deleted the footprint (it replaced the whole selector with a single leaf). The full investigation that led here is in District-Footprint-Investigation.md and District-Dedicated-Visual-Feasibility.md.

The idea

A district’s on-tile visual is a FxEvolverMaterialLevelBuildSelector/…Emitter whose levelBuildItems hold the building Elements and the Decal items — and the decals are the footprint. So instead of throwing that tree away, we author a reduced-to-one copy of a vanilla template: keep every decal, keep the native LOD structure, but repoint the one main building slot at our own mesh. Then at runtime we drop that selector onto our district’s tile only.

One asset can’t carry everything, though: the building Element’s outputLayer is an un-authorable game bundle asset (an FxOutputLayer), so we bake it null and bind a borrowed one at runtime. That single runtime step is the whole reason this is a hybrid and not pure data.

Bake (ENCReload editor — Tools/HAF/District/…)

  1. 1b. Bake Reactor District Element — clones the template’s largest building Element and swaps its fxMesh to our baked <name>_FxMesh. Saves Assets/Resources/<name>_Element.asset.
  2. 1c. Bake Reactor District Selector — clones the template emitter, repoints the largest building slot at our Element, nulls the other building (prop) slots, and keeps every Decal/Emitter item (the footprint + smoke). Saves Assets/Resources/<name>_Selector.asset → its GUID is what the config points at.

Both use ScriptableObject.CreateInstance(type) + field-copy (a valid m_Script, unlike Instantiate), with outputLayer and companion nulled so the mod bundle build accepts them.

The template is any single-building district family with an inline footprint — ENC uses NuclearTest (LvlBuild_Brick_Main). The footprint you get is that template’s decals (e.g. brick national-project plant outlines), not something derived from your mesh.

Config (community.humankind.haf.cfg, [District])

DistrictRepoint = false          # the old forced mesh-swap path stays OFF
DistrictMainRows =               # the shared-cell path stays OFF (see "Two paths" below)
DistrictSelectorTile = Extension_Base_BreederReactor=1734045847,1174137851,-1040006991,1418115374
DistrictFootprint =              # optional: graft a donor's decals (blank = keep the baked footprint)
DistrictFootprintDrop = Gravel,CityBricks,Battlement,Destroyed,Dammaged,Damaged   # surface-texture filter; blank = keep the rock layers

DistrictSelectorTile = ConstructibleDefinitionName=a,b,c,d (semicolon-separated for several districts). The building’s texture comes from the registry entry’s atlasGuid (haf_districts.json), so the district must have a normal District Factory entry too (that’s where the mesh + atlas are baked).

What the plugin does (PollDistrictSelectorTile, every frame)

For each live district whose ConstructibleDefinitionName matches:

  1. Load our selector by GUID and fully Load it (cached per district).
  2. Prepare once (CenterScopedBuilding):
    • Center — our reactor inherited the template’s off-center main-slot LevelBuildItem.Position; zero it so it sits at tile origin like the preview.
    • Instant appearSetInstantAppear walks the whole tree and sets every node’s fadeInOutMode = Instant, so the footprint shows the instant the tile draws instead of ramping in over ~1 s. One re-emit applies both.
  3. Place our selector on channels[mainLayer].evolverMaterial, RefreshChannel, and re-assert each frame (a cheap ReferenceEquals guard — the game resets the channel on its own UpdateLevelBuild).
  4. Bind the output layer (BindReactorBuilding) — our Element is the only leaf with a null outputLayer (every vanilla leaf has one, so no GUID needed); borrow a real building layer from a vanilla leaf, clone it private, bind it on, and re-Load so meshIndex/outputLayerIndex resolve → it renders.
  5. Texture (ApplyScopedAlbedo) — bind the district’s own baked albedo atlas onto that layer clone via the full-texture path (textureIndex = 1 + AddNullAtlasInfo), re-asserted every 15 ticks (resolution switches rebuild the layer’s materials). Keeps the donor’s normal/rough maps when the district ships none.

All of it re-arms on save/reload via ResetDistrictSessionState, which drops every session-scoped binding and frees the session’s runtime clones — the private leaves, cloned selectors/output-layers, deep-clone material nodes and the B&W gray albedo are all Object.Instantiate/new Texture2D copies that Unity’s unused-asset sweep never collects, so they’re tracked as they’re created (never a LoadAsset‘d bundle atlas) and Destroyed on the main-thread Update after a reset (the reset itself can run off-thread via Sandbox.Load). Without this, each in-session reload leaked one FxOutputLayer + N cloned materials + a gray texture per scoped district.

Preview fidelity (District Factory)

The model orientation is already correct; only the preview’s reference frame needed aligning to the game: the hexgrid is drawn +30° CW and the compass / North indicator +150° CW (a hexagon is 60°-symmetric, so +30 ≡ +150 — the frame stays consistent). Preview-only; it doesn’t touch the bake or runtime.

Two paths (why scoped)

Known limitation — one-time footprint reveal (~1s on first zoom-out)

The strategic footprint builds ~1s the first time you zoom out to it in a session, then shows instantly for the rest of the session. This is an engine limitation, not a fixable bug — verified by exhausting every lever:

Forcing it would require running the strategic decal evolve out of its render context (or poking the per-hex GPU revision buffer directly) — high risk, low odds, not proportionate to a one-time cosmetic reveal. Banked.

Footprint graft + the “rocks layer” trap (GraftFootprint) ← read before touching donor footprints

The footprint is configurable at runtime: GraftFootprint swaps our selector’s decal items for a chosen donor district’s decals (registry footprintDonor, or the DistrictFootprint config), keeping our building Element item(s) and re-emitting. CollectDecalItems walks the full donor tree (levelBuildItems + fxMaterialCacheEntries + pairs + default/invalid), deduped by name+position — a CityMapSelector repeats the same decals per culture (MissileSilo collected 207 → ~81 distinct).

THE TRAP (cost us a full day, 2026-08-15): the reactor’s long-blamed “rocky texture in the centre” and a ground twitch were neither terrain, model base, nor ground material — they were decals the graft brought in. The MissileSilo donor carries Decal_CityBricks_Industry_Gravel_* (gravel) and Decal_Destroyed_Battlement_* / Decal_Dammaged_Battlement_* (rubble, on PointOfInterest_Curiosities_OutputLayer). The rubble/POI layer renders at close 3D zoom (unlike the SchematicView/CityMap decals) and toggles on/off at the strategic↔3D zoom boundary = the flicker. Fix: GraftFootprint drops donor decals by name via the DistrictFootprintDrop config (comma-separated, case-insensitive substrings) — default Gravel,CityBricks,Battlement,Destroyed,Dammaged,Damaged, so only Decal_SchematicView_* survives (clean strategic footprint, zero close-zoom rock, zero twitch). Set it blank to keep the full rock texture, or list your own substrings to tune which layers show. This is the “surface texture” knob.

Rule: when a district shows unexpected close-zoom ground artifacts or flicker, suspect the grafted decals first. Dump them with the [DecalBind] probe (name · OutputLayerIndex · layer name · maskedByTerrain) before theorising about terrain.

Unique footprint (DistrictFootprintMask) — a private SOLID BLOCK, sized + rotated

Give the district its own strategic footprint instead of a generic donor outline. Shipping form: a solid tinted block (the decal’s full quad), sized and rotated to sit over the model’s square base — verified in-game on the Breeder Reactor.

Bonus: it appears INSTANTLY. Because the block is the decal’s own quad (no per-hex strategic decal render-data to evolve), it sidesteps the ~1s first-zoom-out reveal that the graft footprint has (the engine limit banked under “Known limitation” above). The block draws the moment the tile does.

Why a block and not a silhouette: we built the full silhouette pipeline (below) and it works mechanically, but the SchematicView shader renders any injected mask as faint, sketchy hand-drawn strokes — a crisp reactor outline (or even two bold domes) dissolves into an unreadable smudge at strategic zoom. That’s a property of the game’s schematic-map rendering, not a bug we can tune out. The solid block reads cleanly, so that’s what ships.

Config ([District]):

Runtime (InjectReactorFootprint, once): clone the SchematicView decal into a private copy; repoint one footprint item at it, LocalScale = size, AxeZ rotated by the config angle, centre it, null loadedEvolverMaterialGuid, drop the other footprint decals. The decal’s maskTexture GUID is left invalid, so FillLayerData sees maskTexture.IsNull, skips the mask, and draws the full solid quad = the block.

THE TWO TRAPS (cost hours — still apply to the private clone):

  1. Mutating the SHARED SchematicView decal leaks to EVERY district’s footprint. Must clone it (private copy).
  2. Instantiate does not copy the base [NonSerialized] evolverDescriptorInstanceFxEvolverMaterial.ResolveDependencies NREs and the clone writes no render data (renders as a single pixel). Fix: copy evolverDescriptorInstance from the original (shared descriptor singleton) before ResolveDependencies + Load. And size is the item’s LocalScale (the host “Tiny” brick’s 0.04 was the shrink), not the decal’s defaultSize/bboxOverride.

The silhouette pipeline (parked, reusable). baker/reactor_silhouette.py (headless Blender) renders the GLB top-down, strips the base plane (mn.z + frac·height), fills enclosed holes (numpy flood) into solid shapes, and writes the mask. A valid hex maskGuidStr + binding our texture as the decal’s mask atlas (atlases[0], elementData[0].Uvs=(0,0,1,1)) makes the decal actually cut to that shape — proven — but the SchematicView shader renders the result faint (above), so it’s not shipped. The experiments are stashed on spike/district-unique-footprint; the crisp version would need a different, solid-rendering output layer (the CityMap/gravel one — which reintroduces the close-zoom twitch we removed, see District-Footprint-Investigation.md).

MESH footprint (DistrictFootprintMesh) — the district’s REAL geometry at strategic zoom ← the winner

The decal routes above are flat textures, and shapes rendered through SchematicView come out sketchy. The clean answer is to skip the decal entirely and keep the district’s own 3D building mesh visible at strategic zoom, so the footprint literally is the model. Verified in-game on the Breeder Reactor (2026-08-15).

The fade is a per-element GPU gate, not a camera switch. There is no separate “strategic camera” — building meshes and SchematicView decals both render in the Default render context. Each FxEvolverMaterialLevelBuildElement carries a RenderFeatureSelector whose SelectionFlags0 bitmask says which camera zoom-bands (“render features”) draw it. PresentationCameraController.OnCameraLayerChanged enables a band’s TerrainRenderFeatureFlags via RenderFeatureProvider.SetRenderFeatureState, which animates a shader buffer 0↔1 (the smooth fade). The reactor’s element was gated to SelectionFlags0 = 1 (RealisticTerrain = close band only) → it vanished at strategic zoom. SelectionFlags0 == 0 = AlwaysEnabled → the same geometry renders in EVERY band, strategic included. No re-bake, no LOD hack. (RenderFeatures bits: RealisticTerrain=1, TopographicTerrain=2, DiplomaticTerrain=4, …)

KeepDistrictMeshAtStrategicZoom (once per district): walk the selector for every mesh-bearing element (CollectMeshElements), read-modify-write its boxed renderFeatureSelector.SelectionFlags0 = 0, OnEditionChange(), then LoadFxMaterial(sel). Scoped + safe — the reactor’s element is its own asset, and AlwaysEnabled only adds the strategic band (close zoom unchanged). Bonus: like the block, it appears instantly (no ~1s reveal — no per-hex decal render-data). Also drops the template’s baked footprint decal items (DistrictFootprintMeshHideDecal, default on) so the inherited donor outline (e.g. the MissileSilo silhouette) doesn’t show beneath the mesh.

B&W when zoomed out (DistrictFootprintMeshBW). The reactor’s skin is a runtime-bound Texture2D (scopedAlbedo on scopedDonorClone, re-asserted every ~15 ticks by BindScopedSheet), so DesiredScopedAlbedo() just picks colour-vs-grey each re-assert — no second element. Grey copy = MakeGrayCopy (Blit→RenderTexture→ReadPixels handles the non-readable atlas, then AdjustSkin(t,1,1,0,0,0) = luminance desaturate). Zoom signal (the crux): ask RenderFeatureProvider.ComputeRenderState(RenderFeatureSelector) for the current 0..1 visibility of the Topographic band (SelectionFlags0 = 2, the schematic look) — grey when ≥ 0.5. Do NOT key the Realistic/close band (=1): it stays on well past the schematic crossover, so the reactor kept its colour zoomed out. Get the single provider via Resources.FindObjectsOfTypeAll.

Flat when zoomed out (DistrictFootprintMeshFlat). A 3D model on the flat map still reads as a model; squash it. UpdateMeshFlatness() (same Topographic signal) scales each element’s size.y by a tunable flatten height on the schematic map, restores full height up close, re-emitting via materialDataHasChanged + RefreshChannel on the crossover only. (size is the element scale the scoped setup already uses size = 0 to hide props with.) The vertical is terrain-owned — the item’s Position.y does NOT lift a level-build mesh (the selector GPU write never reads it; height comes from the terrain adaptation), so a lift lever is a dead end. Instead, size.y ≈ 0.02 is paper-flat but coplanar with the ground and its edges drown where the tile’s terrain rises over them; DistrictFootprintMeshFlatHeight (default 0.17) is the sweet spot that reads flat yet pokes clear of the terrain — live-tunable in the F8 window (slider + ±0.01/±0.05 buttons; FlatHeightValue/SetFlatHeight). Result: full 3D colour building up close, flat grayscale footprint zoomed out.

Authoring — per district in the District Factory (preferred). These settings are registry fields on the district entry (footprintMesh / footprintMeshBW / footprintMeshFlat / footprintMeshFlatHeight / footprintMeshHideDecal), edited under Mesh footprint in the District Factory window and saved to haf_districts.json (Save settings, no re-bake). When an entry sets footprintMesh = true its values are authoritative; otherwise the global config below stays in charge (so a district keeps working before it’s authored per-entry). The plugin resolves this in ResolveScopedFootprint. Foot-gun guarded: because an authoritative entry replaces the global config, an entry with the master toggle on but the sub-options off would regress the district to 3D colour — so ticking Mesh footprint in the window pre-fills B&W + Flatten + Hide-decal (height 0.17). The decal-donor field is greyed out ((superseded by Mesh footprint)) while the mesh footprint is on, since the two are mutually exclusive.

Global config fallback ([District], all need DistrictFootprintMesh = true): DistrictFootprintMesh, DistrictFootprintMeshBW, DistrictFootprintMeshFlat, DistrictFootprintMeshFlatHeight (default 0.17), DistrictFootprintMeshHideDecal (default true). Leave DistrictFootprintMask blank (the decal route and the mesh route are mutually exclusive).

Ground under the district (DistrictApplyGroundMaterial + the ApplyGroundMaterialDefinition prefix)

The per-entry ground paint (groundMaterial in haf_districts.json, e.g. Constructible_Temperate_03) is the game’s own terrain material, resolved from the criteria-24 GroundMaterialDefinition vocabulary and forced onto the district’s tile. Two mechanisms, because a postfix alone doesn’t hold:

Caveats learned the hard way: the ground paint only covers the district perimeter, never the built centre (that’s the model/decals); GroundMaterialDefinition reps in haf_ground_colors.json are not the rendered look (Prairie_Mediterranean reads tan in data, renders green grass); the value is the user’s Factory choice — propose, don’t hand-edit their registry.

Migrating a district onto the scoped path (BakeScopedSelector) + multi-district coexistence

Every district can render via the scoped path (its own data-authored CityMapSelector on the tile — the route that carries the mesh footprint) instead of the legacy per-frame isolate/repoint swap. The District Factory’s Bake button bakes this automatically: BakeScopedSelector(resourceName) clones a single-building footprint template, swaps in the district’s baked FxMesh (<name>_Element), reduces the template’s building slots to that one while keeping its decals (CityMapSelector_<name>), and stores the selector GUID on the registry entry (selectorGuid). The plugin routes any entry with a selectorGuid through the scoped path (IsScopedDistrict + PollDistrictSelectorTile merge the registry GUIDs with the DistrictSelectorTile config), so the legacy isolate path skips it. Trap: the footprint template’s building elements load lazily — a cold bake makes FindLargestElement return “no building Element found”, so BakeScopedSelector does a warm pass first (equivalent to running Tools/HAF/District/Probe). Only single-building templates reduce cleanly (NuclearTest, MissileSilo).

Two scoped districts coexist independently — verified with the Breeder Reactor + a Greek-temple Oracle in one game, each wearing its own texture and footprint. This needed a real fix. The scoped path first held texture/B&W/flatten in global statics (built for one district); subtler still, its three driving calls (BindReactorBuilding / ApplyScopedAlbedo / UpdateMeshFlatness) ran after the per-district loop, so they executed with the state of the last district only, and BindReactorBuilding bound every district’s element to one shared layer clone. The fix has two halves: (1) the scoped state lives in a per-district ScopedState (a name-keyed dict; the scopedX/fpX/mesh* names are proxy properties onto the current S, so every function body stayed byte-identical); (2) the three calls moved inside the loop (per-district S set first), and BindReactorBuilding(onlyName) binds only that district’s element to its own layer clone, gated by S.donorClone. Lesson: a “per-district state” refactor is incomplete until the call sites are per-district too — check where the drivers are invoked, not just where the state lives.

Composed districts & alpha-cutout foliage

A district model can be a composed “pizza” — a base building plus extra parts (a grove of trees), each baked with its own knobs and merged into one mesh + one super-atlas (the runtime still ships a single FxMesh/atlas). Two traps on the scoped path:

Dead ends (do not re-walk — all falsified 2026-08-15)

Open / next