Table of Contents

Map System Architecture

Status: Production Standard


Core Principle

Provinces are pixels, not GameObjects. Single draw call. GPU handles everything visual.


The Problem

Traditional approach: Individual GameObjects/meshes per province with physics colliders.

Result: Unacceptable performance at scale. Each province adds:

  • Transform updates
  • Draw calls
  • Physics overhead
  • Memory fragmentation

The Solution: Texture-Based Rendering

Core concept: Province data lives in GPU textures.

  • Province IDs encoded in texture pixels
  • Ownership/controller in separate texture
  • Colors from palette lookup
  • Borders generated by compute shader
  • Selection via texture read (no physics)

Result: Single draw call for entire map. Scales linearly.


Dual-Layer Architecture

CPU Layer (Simulation)

  • Fixed-size structs in contiguous arrays
  • Deterministic operations
  • Generic ENGINE primitives + GAME-specific data

GPU Layer (Presentation)

  • Province ID texture
  • Ownership texture
  • Color palette
  • Border cache
  • All visual processing

Data flows one direction: Simulation → GPU Textures → Screen


Three Coordinate Spaces

Province Space (Topology)

  • Gameplay logic
  • Pathfinding
  • Adjacency relationships

Texture Space (GPU)

  • Rendering
  • Selection
  • Border detection
  • UV coordinates

World Space (3D)

  • Unit positions
  • Camera
  • Visual effects

Transformations between spaces are well-defined and cached.


Position as Cold Data

Province positions are NOT stored in hot simulation state.

Why:

  • Position is presentation, not simulation
  • Rarely changes (static geography)
  • Doesn't affect gameplay calculations
  • Saves cache space for hot data

Where stored: Separate cold data structures, loaded on-demand.


Map Mode System

Challenge: Different visualizations without performance loss.

Solution: Data textures + shader switching.

Each map mode:

  • Uses same province ID texture
  • Binds different data texture (owner, terrain, development, etc.)
  • Uses appropriate color palette
  • Same shader, different inputs

Mode switching: Change material property + bind texture. Instant.


Border Generation

Compute shader detects province boundaries:

  • Sample province ID at each pixel
  • Compare with neighbors
  • Output border intensity to border texture
  • Fragment shader applies border color

Why GPU: Millions of pixels, embarrassingly parallel.


Province Selection

Not physics: No raycasts, no colliders.

Texture-based:

  1. Convert screen position to UV
  2. Read province ID texture
  3. Decode ID from pixel color

Performance: Near-instant, scales perfectly.


GPU-Only Pipeline (Critical)

Never bring GPU data back to CPU unless absolutely necessary.

The Problem

GPU→CPU transfers (e.g., ReadPixels, GetPixels32) force synchronization:

  • CPU stalls waiting for GPU to complete all work
  • Typical stall: 100-200ms for full map texture
  • Destroys frame rate, causes visible hitches

The Rule

CPU → GPU: Allowed (upload data)
GPU → GPU: Preferred (compute shader → texture → shader)
GPU → CPU: Avoid (forces sync stall)

Correct Patterns

Map Mode Updates:

  1. CPU calculates per-province values (fast, ~3000 items)
  2. GPU compute shader colorizes pixels (parallel, ~1ms)
  3. Graphics.CopyTexture copies to texture array (GPU-to-GPU, <1ms)
  4. Fragment shader samples from array (instant)

Never do this:

// ❌ WRONG - GPU→CPU sync stall (100-200ms)
RenderTexture.active = renderTexture;
texture.ReadPixels(rect, 0, 0);
texture.Apply();
var pixels = texture.GetPixels32();
targetTexture.SetPixels32(pixels);
targetTexture.Apply();

Do this instead:

// ✅ CORRECT - GPU-to-GPU copy (<1ms)
Graphics.CopyTexture(sourceRenderTexture, 0, 0, targetTextureArray, sliceIndex, 0);

When GPU→CPU is Acceptable

  • Initialization (runs once at startup)
  • Debug/diagnostic readback (not in production)
  • Save system (can afford the stall)

Key Constraints

Province ID Texture

  • Point filtering required (no interpolation)
  • High precision format for large ID range
  • No mipmaps

Data Textures

  • Updated when simulation state changes
  • Delta updates preferred (only changed provinces)
  • Dirty flag system prevents redundant uploads

Compute Shaders

  • Explicit synchronization between passes
  • Avoid UAV/SRV binding conflicts
  • Y-flip only in fragment shader, not compute
  • Use RWTexture2D for all RenderTexture access (even read-only)

Anti-Patterns

Don't Do Instead
GameObject per province Texture pixels
Physics raycasts for selection Texture lookup
CPU pixel processing GPU compute shaders
Multiple draw calls Single draw call
Texture filtering on IDs Point filtering
Update everything every frame Dirty flag system
Store positions in hot data Cold data separation

Key Trade-offs

Aspect Traditional Texture-Based
Draw calls O(provinces) O(1)
Selection Physics overhead Texture read
Memory Scattered Contiguous
Flexibility Easy per-province effects Shader-based effects
Complexity Simple concept GPU expertise needed

URP Integration

Shader Approach

  • Start with Shader Graph for prototyping
  • Migrate to HLSL for production (full control)
  • Custom render features for precise ordering

Modern Features

  • Render Graph (auto-optimization)
  • GPU Resident Drawer (reduced CPU overhead)
  • Forward+ Rendering (multiple lights)

  • Pattern 4 (Hot/Cold Separation): Position as cold data
  • Pattern 9 (Double-Buffer): GPU texture updates
  • Pattern 11 (Dirty Flags): Delta texture updates

Provinces are pixels. GPU does visuals. One draw call.