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:
- Convert screen position to UV
- Read province ID texture
- 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:
- CPU calculates per-province values (fast, ~3000 items)
- GPU compute shader colorizes pixels (parallel, ~1ms)
Graphics.CopyTexturecopies to texture array (GPU-to-GPU, <1ms)- 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
RWTexture2Dfor 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)
Related Patterns
- 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.