Table of Contents

Rendering

You need this when you are putting something on screen and it has to stay fast. For the minimum viable version, start with Draw in the world and come back here when you need batching or culling.

Nothing in shapez 2 is a GameObject per building — at this scale it could not be. The world is drawn every frame from the model by a pipeline of drawers, using instanced and combined meshes. To draw something of your own you join that pipeline.

The pipeline

DrawManager                     owns the frame
├── DrawOptions : FrameDrawOptionsNoLOD
├── Hooks       : DrawHooks     [Obsolete] but simple
└── MapDrawer
    └── Draw(FrameDrawOptionsNoLOD options)
        ├── Culler.Cull(...)          → MapCullResult
        ├── options.Hooks.OnDrawMap(options, map, cullResult)
        ├── foreach (IMapSubDrawer d in SubDrawers)   ← the intended extension point
        │       d.Draw(options, cullResult)
        └── options.Hooks.OnDrawFinal(options)

MapDrawer catches and logs exceptions from each sub-drawer, so a throwing drawer degrades rather than killing the frame. It also registers on the map's island/building add and remove events to invalidate its caches.

FrameDrawOptionsNoLOD

The per-frame context handed to every drawer. Most of what you need is on it:

public readonly RenderersCollection Renderers;   // where you submit draws
public readonly Player Player;                   // → Player.CurrentMap
public readonly Viewport Viewport;
public readonly IShapeColorScheme ColorScheme;
public readonly IColorVisualizationManager ColorVisualization;
public readonly AccentColorPalette AccentColorPalette;
public readonly VisualTheme Theme;               // [Obsolete] "should be injected"
public readonly DrawHooks Hooks;                 // [Obsolete]

public float3 CameraPosition_W { get; }
public Plane[] CameraPlanes { get; }
public double SimulationTime_G { get; }
public float DeltaTime { get; }
public int MaxBuildingIslandLayer { get; }
public FrameBudgetManager FrameBudget { get; }
public LODComputationParameters LODComputation { get; }

public const float OverviewModeZoom = 1500f;
public bool InOverviewMode => Viewport.Zoom > 1500f;

InOverviewMode is how you make an overlay behave differently zoomed out — the game uses the same flag to swap detailed island meshes for flat overview planes.

FrameDrawOptions is the same thing with LOD resolved; drawers that need level of detail take that instead.

Submitting a draw

RenderersCollection is a bundle of renderers, each batching a different category:

Field Use
RegularNonInstanced arbitrary one-off meshes — the general-purpose choice
UINonInstanced the same, on the UI layer
Misc, Buildings, Islands, Playingfield, Shapes, UI, … InstancedMeshManagers, for many copies of one mesh
BeltItems the item renderer, with shape drawing helpers
LazyMeshCombinationManager combined-mesh caching

The general-purpose call:

void DrawMesh(
    IMeshReference mesh,
    IMaterialReference material,
    Matrix4x4 matrix,
    RenderCategory category,
    MaterialPropertyBlock properties = null,
    ShadowToken castShadows = default,
    ShadowToken receiveShadows = default);
float3 position = tile_G.ToCenter_W(2.0f);

options.Renderers.RegularNonInstanced.DrawMesh(
    mesh,
    material,
    Matrix4x4.TRS(position, Quaternion.identity, new Vector3(20f, 20f, 20f)),
    RenderCategory.Misc,
    MaterialPropertyHelpers.CreateAlphaBlock(0.6f));

Property blocks — read this before using them

public static class MaterialPropertyHelpers
{
    public static int SHADER_ID_Alpha     = Shader.PropertyToID("_Alpha");
    public static int SHADER_ID_BaseColor = Shader.PropertyToID("_BaseColor");

    public static MaterialPropertyBlock CreateAlphaBlock(float alpha);
    public static MaterialPropertyBlock CreateBaseColorBlock(Color baseColor);
}

Both helpers mutate and return a shared static MaterialPropertyBlock. That is fine in the game's usage — set it, submit the draw immediately, never hold it — but it means you cannot build up several blocks and submit them later, and you cannot combine alpha and colour by calling both. If you need more than one property at once, keep your own MaterialPropertyBlock instance.

How an island gets drawn

There is no single mechanism, and picking the wrong one costs an evening. An island's visual can come from any of three places:

Source Where it lives Transform
IslandMeshDrawer.Data CustomData on the definition the island's own, one copy
ModularIslandMeshDrawer.Data CustomData on the definition a LocalChunkTransform per mesh, drawn at module.Transform * island.Transform
IIslandPlatformDrawer a session dictionary, not CustomData whatever the drawer does

The modular drawer is the one the game is migrating toward — IIslandPlatformDrawer is marked [Obsolete("Replace this drawer with the more generic ModularIslandMeshDrawer")].

Space paths are the trap. A space belt or pipe definition carries no IslandMeshDrawer.Data at all. Its meshes live on the visual theme — SpaceBeltResources / SpacePipeResources, reached through ISpacePathResources.GetMeshMaterials(PathNodeClassification) — and are drawn by a SpacePathPlatformDrawer. So borrowing a belt's look by reading its definition silently yields nothing.

Two details that bite if you try to re-stage those meshes through the modular drawer anyway: SpacePathPlatformDrawer drops its mesh 2.07314 world units and submits to Renderers.SpacePaths, while ModularIslandMeshDrawer does neither — and LocalChunkTransform.Position is an integer ChunkVector, so that sub-chunk drop cannot be expressed there at all. For a modded space-path-like island, write an IIslandPlatformDrawer instead.

Registering a platform drawer

The dictionary is built by GameSessionOrchestrator.CreateIslandPlatformDrawers, which walks GameIslands.SpaceBelts, SpacePipes and the rail lists. A modded island is in none of them, and ShapezShifter has no rewirer for drawers — it has them for islands, placers, toolbars, simulation and prediction, but not this. Postfix-hook that one method and add your entries to the dictionary it just returned:

DetourHelper.CreatePostfixHook(
    (GameSessionOrchestrator orchestrator, GameIslands islands) =>
        orchestrator.CreateIslandPlatformDrawers(islands),
    (orchestrator, islands, drawers) =>
    {
        drawers[myDefinitionId] = new MyPlatformDrawer(
            orchestrator.Theme.BaseResources.SpaceBelts);
        return drawers;
    });

Use the indexer, not Add — the vanilla loops use Add, and a duplicate key would take the whole session's drawer setup down. Implement DrawOverview too, or your island disappears on the zoomed-out map while everything around it stays.

Simulation renderers register themselves

Anything travelling through a machine — items on a belt, fluid in a pipe — is drawn by an ISimulationRenderer, and those need no registration at all:

Type[] second = (from type in GetAllLoadableTypes()
    where typeof(IIslandSimulationRenderer).IsAssignableFrom(type)
          && !type.IsAbstract && !type.IsInterface
    select type).ToArray();

GetAllLoadableTypes() is AppDomain.CurrentDomain.GetAssemblies(), so it reaches a mod assembly as readily as the game's own, and each type is built through a dependency container (IMapModel, GameMode, ISimulator and friends are bound). That is why every vanilla renderer carries [UsedImplicitly] — nothing references them by name.

Renderers are keyed by simulation type, not definition id, so one class covers every definition sharing that simulation. SimulationsDrawer logs an error and drops yours if another renderer already claims the same simulation type.

One caveat if you subclass SpacePathSimulationRenderer: it sorts connectors into per-item-type lists, then runs the whole item list once against the shape lists and once against the fluid lists, casting each item unconditionally. Fine where every connector on a node carries the same item type — an InvalidCastException the first time a FluidPackageItem meets the shape pass. An island mixing belts and pipes has to pair its own connectors instead.

Meshes

Type Lifetime
IMeshReference the interface everything takes (InstanceId, IsEmpty, GetMeshInternal())
TemporaryMeshReference a mesh you own and must dispose
LazyCombinedMesh a combined mesh built on demand
ExpiringDisposableObject<T> a cache slot that releases after disuse, driven by IResourceLifetime

Ready-made geometry lives on GeometryHelpers:

public static IMeshReference PlaneMesh;      // a unit plane
public static IMeshReference BillboardMesh;
public static TemporaryMeshReference GeneratePlaneMeshUVColoredUncached(Color color);
public static Mesh GenerateTransformedMeshUncached(Mesh baseMesh, Matrix4x4 trs);

Note how the game colours flat overlays: it does not set a colour per draw, it bakes the colour into a plane mesh and caches one mesh per colour (HUDOverviewModeMapResourcesRenderer keeps a DisposableDictionary<Color, TemporaryMeshReference>). For an overlay with a small palette — say eleven buckets from red to green — that is the pattern to copy.

Combining

Thousands of individual DrawMesh calls will cost you. MeshBuilder batches them into one mesh:

using MeshBuilder builder = new MeshBuilder("MyOverlay(chunk)", lod: 0);

foreach (GlobalChunkCoordinate chunk in island.Chunks)
{
    builder.AddTranslateScale(planeMesh, chunk.ToCenter_W(-20f), new float3(20f, 20f, 20f));
}

if (builder.Empty) return null;
TemporaryMeshReference combined = builder.GenerateSingleMeshMax65KVertices();

Other adds: AddTranslate, AddByTransform(mesh, in GlobalTileTransform), AddTranslateRotate(...), and LOD-mesh overloads. GenerateCombined() and GenerateLazy(bool) are the alternatives when you want a multi-mesh or deferred result.

Build these once and cache, keyed by whatever makes them invalid — the game caches per SuperChunkCoordinate and rebuilds when an island or building changes. Rebuilding a combined mesh every frame is worse than not combining at all.

Getting your code into the frame

Option A — postfix MapDrawer.Draw

The shortest route, and it hands you the full context:

Hook DrawHook = DetourHelper.CreatePostfixHook<MapDrawer, FrameDrawOptionsNoLOD>(
    (drawer, options) => drawer.Draw(options),
    (drawer, options) => Overlay.Draw(options));

Your code runs after all the game's drawers, which is what you want for an overlay that sits on top. options.Player.CurrentMap gives you the map without any global lookup.

Option B — DrawHooks

FrameDrawOptionsNoLOD.Hooks carries multicast delegates — OnDrawMap, OnDrawSuperChunk, OnDrawShapeResourceSource, OnDrawFluidResourceSource, OnDrawIslandNotch, OnDrawTrain, OnDrawFinal. HUDOverviewMode uses OnDrawSuperChunk this way:

DrawHooks hooks = DrawManager.Hooks;
hooks.OnDrawSuperChunk = (DrawHooks.DrawSuperChunkDelegate)Delegate.Combine(
    hooks.OnDrawSuperChunk, new DrawHooks.DrawSuperChunkDelegate(DrawSuperChunk));

Always Delegate.Combine / Delegate.Remove, never assign. The whole class is marked [Obsolete("Instead use/introduce concepts like the IMapSubDrawer.")] — it works, but it is on the way out.

Option C — IMapSubDrawer

The intended extension point. DrawManager takes IEnumerable<IMapSubDrawer> at construction and MapDrawer copies it into a list, so registering one after the fact means reaching that list — which the publicizer makes possible. More work than a hook, and more idiomatic; worth it for a drawer you intend to maintain.

Sub-drawers can also implement IMapSubDrawerIslandEventListener to be told when islands are registered or unregistered, which is how IslandOverviewDrawer keeps its per-super-chunk cache honest.

Depth, layers and overlays

Two facts that decide whether an overlay looks right, and neither is obvious until it looks wrong.

The instanced UI renderer ignores depth. options.Renderers.UI draws over everything, which is what the super chunk coordinate labels want — nothing occludes them from out in space. It is not what a label sitting on a machine wants: it shows through the platform above it. There is no easy fix from a mod, because the depth state belongs to the material and the materials come from the game's asset bundles. The practical answers are to scope what you draw to the layer being viewed, or to not draw world-space text at all and put the information in a side panel.

Layer visibility is a predicate the game already exposes:

if (!options.ShouldRenderPlatformContentsAtLayer(chunk.z))
{
    continue;   // the player is looking at a lower layer
}

That is chunkPlatformLayer <= MaxBuildingIslandLayer internally.

Warning

MaxBuildingIslandLayer defaults to 999, meaning unrestricted. Code shaped like if (chunk.z == options.MaxBuildingIslandLayer) — "only annotate the current layer" — therefore matches nothing during ordinary play and silently does nothing. Test against the predicate, and treat any value near 999 as "no layer restriction".

Translucent draws blend with each other. An overlay quad drawn on top of another overlay quad mixes colours: a green marker at the wash's 55% alpha over a red tile reads as yellow, not green. If a second layer of drawing is meant to have its own colour, give it its own property block at a high alpha rather than reusing the one underneath.

World-space text without a font

There is no world-space text API, but there is a character atlas — the one behind the super chunk labels. UXSuperChunkCoordinatesRendererMaterial maps a 7x7 grid:

Index Character
0–9 digits 0–9
10–35 A–Z
36 /
37 -

Build one quad per character with UVs into the cell, then draw them side by side through options.Renderers.UI. HUDSuperChunkCoordinatesVisualization is the working example, including the slightly surprising UV origin it uses.

Size them by the width the label is allowed to occupy rather than by a fixed world size. Viewport.Zoom is the camera's distance (4 near, 20000 far), so a label of world size s covers roughly s / zoom of the screen — multiply zoom by a constant for a stable on-screen size, and cap it so a four-digit number shrinks instead of sprawling across its neighbours.

Performance notes

  • Cull first. MapCullResult from MapDrawer tells you which super chunks are visible; drawing for the whole map when the camera sees a corner of it is the most common mistake.
  • Cache combined meshes; invalidate on the map's add/remove events.
  • Dispose every TemporaryMeshReference — TemporaryMeshReference.WarnAboutNonDisposedInstances() exists because leaking them is a known trap.
  • Respect options.FrameBudget if you generate meshes lazily.