3 Grid Rendering Pipeline
John McCardle edited this page 2026-02-07 23:48:35 +00:00

Grid Rendering Pipeline

Overview

The Grid rendering pipeline handles multi-layer tilemap rendering with chunk-based caching for optimal performance. It supports arbitrary numbers of rendering layers, viewport culling, zoom, and entity rendering.

Parent Page: Grid-System

Related Pages:

Key Files:

  • src/UIGrid.cpp::render() - Main rendering orchestration
  • src/GridLayers.cpp - ColorLayer and TileLayer rendering
  • src/UIGrid.cpp::renderChunk() - Per-chunk RenderTexture management

Architecture Overview

Layer-Based Rendering

Grids render content through layers rather than per-cell properties. Each layer is either a ColorLayer (solid colors) or TileLayer (texture sprites).

Render order is determined by z_index:

  • Layers with z_index < 0 render below entities
  • Entities render at the z=0 boundary
  • Layers with z_index >= 0 render above entities (overlays)

Within each group, lower z_index values render first (behind higher values).

z_index: -3    -2    -1    0    +1    +2
         |     |     |     |     |     |
       [background] [tiles] [ENTITIES] [fog] [UI overlay]

Chunk-Based Caching

Large grids are divided into chunks. Each chunk maintains its own sf::RenderTexture that caches the rendered result.

Key concepts:

  • Only chunks intersecting the viewport are considered for rendering
  • Each chunk tracks whether its content is "dirty" (needs redraw)
  • Static content renders once, then the cached texture is reused

Dirty Flag Propagation

When layer content changes, only affected chunks are marked dirty:

  1. layer.set((x, y), value) marks the containing chunk as dirty
  2. On next render, dirty chunks redraw to their RenderTexture
  3. Clean chunks simply blit their cached texture

This means a 1000x1000 grid with one changing cell redraws only one chunk, not 1,000,000 cells.


Render Pipeline Stages

Stage 1: Viewport Calculation

Calculate which chunks and cells are visible based on camera position, zoom, and grid dimensions.

Key properties:

  • center - Camera position in pixel coordinates (Vector)
  • zoom - Scale factor (1.0 = normal, 2.0 = 2x magnification)
  • size - Viewport dimensions in screen pixels

Stage 2: Below-Entity Layers

For each layer with z_index < 0, sorted by z_index:

  1. Determine which chunks intersect viewport
  2. For each visible chunk:
    • If dirty: redraw layer content to chunk's RenderTexture
    • Draw chunk's cached texture to output

Stage 3: Entity Rendering

Entities render at the z=0 boundary:

  1. Iterate entity collection
  2. Cull entities outside viewport bounds
  3. Draw visible entity sprites at interpolated positions

Stage 4: Above-Entity Layers

For each layer with z_index >= 0, sorted by z_index:

  • Same chunk-based rendering as Stage 2
  • These layers appear as overlays (fog, highlights, UI elements)

Stage 5: Final Compositing

All rendered content is drawn to the window.


Creating and Managing Layers

Standalone Layer Objects

Layers are created as standalone objects, then attached to grids:

grid = mcrfpy.Grid(grid_size=(50, 50), pos=(0, 0), size=(800, 600), layers=[])

# Create layers as standalone objects
background = mcrfpy.ColorLayer(name="background", z_index=-2)
grid.add_layer(background)

terrain = mcrfpy.TileLayer(name="terrain", z_index=-1, texture=tileset)
grid.add_layer(terrain)

# Overlay above entities (z_index >= 0)
fog = mcrfpy.ColorLayer(name="fog", z_index=1)
grid.add_layer(fog)

Passing Layers at Construction

You can also pass layers during Grid construction:

bg = mcrfpy.ColorLayer(name="background", z_index=-2)
tiles = mcrfpy.TileLayer(name="terrain", z_index=-1, texture=tileset)
fog = mcrfpy.ColorLayer(name="fog", z_index=1)

grid = mcrfpy.Grid(
    grid_size=(50, 50),
    pos=(0, 0),
    size=(800, 600),
    layers=[bg, tiles, fog]
)

Accessing Layers

# By name
fog_layer = grid.layer("fog")

# All layers (returns tuple)
all_layers = grid.layers
print(f"Grid has {len(all_layers)} layers")

Removing Layers

grid.remove_layer(fog_layer)

Layer Operations

ColorLayer

cl = mcrfpy.ColorLayer(name="highlights", z_index=1)
grid.add_layer(cl)

# Fill all cells with one color
cl.fill(mcrfpy.Color(0, 0, 0, 255))

# Set individual cell
cl.set((5, 5), mcrfpy.Color(255, 0, 0, 128))

# Read cell value
color = cl.at((5, 5))
print(f"Color: ({color.r}, {color.g}, {color.b}, {color.a})")

TileLayer

tl = mcrfpy.TileLayer(name="terrain", z_index=-1, texture=tileset)
grid.add_layer(tl)

# Set tile sprite index
tl.set((5, 5), 42)

# Read tile index
idx = tl.at((5, 5))

# Fill all cells
tl.fill(0)  # All floor tiles

Layer Properties

layer = grid.layer("fog")

# Visibility toggle
layer.visible = False  # Hide layer
layer.visible = True   # Show layer

# Z-index (render order)
layer.z_index = 2  # Move to front

# Grid dimensions (read-only)
w, h = layer.grid_size

Performance Characteristics

Static Grids:

  • Near-zero CPU cost after initial render
  • Cached chunk textures reused frame-to-frame
  • Only viewport calculation and texture blitting

Dynamic Grids:

  • Cost proportional to number of dirty chunks
  • Single-cell changes affect only one chunk
  • Bulk operations should batch changes before render

Large Grids:

  • 1000x1000+ grids render efficiently
  • Only visible chunks processed
  • Memory scales with grid size (chunk textures)

FOV Overlay Pattern

The most common overlay pattern is fog of war using a ColorLayer:

grid = mcrfpy.Grid(grid_size=(50, 50), pos=(0, 0), size=(800, 600), layers=[])

# Background and terrain layers (below entities)
bg = mcrfpy.ColorLayer(name="bg", z_index=-2)
grid.add_layer(bg)
bg.fill(mcrfpy.Color(20, 20, 30, 255))

# Fog overlay (above entities, z_index >= 0)
fog = mcrfpy.ColorLayer(name="fog", z_index=1)
grid.add_layer(fog)
fog.fill(mcrfpy.Color(0, 0, 0, 255))

# Make cells traversable
for x in range(50):
    for y in range(50):
        grid.at(x, y).transparent = True
        grid.at(x, y).walkable = True

# After computing FOV, update fog
grid.compute_fov((25, 25), radius=10)
w, h = grid.grid_size
for x in range(w):
    for y in range(h):
        if grid.is_in_fov((x, y)):
            fog.set((x, y), mcrfpy.Color(0, 0, 0, 0))    # Transparent
        else:
            fog.set((x, y), mcrfpy.Color(0, 0, 0, 192))  # Dimmed

Multiple Overlay Layers

Pre-create multiple overlay layers and toggle between them:

grid = mcrfpy.Grid(grid_size=(50, 50), pos=(0, 0), size=(800, 600), layers=[])

highlight = mcrfpy.ColorLayer(name="highlight", z_index=1)
danger = mcrfpy.ColorLayer(name="danger", z_index=2)
fog = mcrfpy.ColorLayer(name="fog", z_index=3)
grid.add_layer(highlight)
grid.add_layer(danger)
grid.add_layer(fog)

def show_danger_zones():
    highlight.visible = False
    danger.visible = True
    fog.visible = False

def show_fog_of_war():
    highlight.visible = False
    danger.visible = False
    fog.visible = True

Recreating Legacy Three-Layer Rendering

The old system rendered: background color, tile sprite, FOV overlay. Here's the modern equivalent:

grid = mcrfpy.Grid(
    grid_size=(50, 50),
    pos=(100, 100),
    size=(400, 400),
    layers=[]
)

# Layer 1: Background colors (z=-2, behind everything)
background = mcrfpy.ColorLayer(name="background", z_index=-2)
grid.add_layer(background)
background.fill(mcrfpy.Color(20, 20, 30, 255))

# Layer 2: Tile sprites (z=-1, above background, below entities)
tiles = mcrfpy.TileLayer(name="tiles", z_index=-1, texture=tileset)
grid.add_layer(tiles)

# Layer 3: FOV overlay (z=+1, above entities)
fog = mcrfpy.ColorLayer(name="fog", z_index=1)
grid.add_layer(fog)

# Populate layers
for x in range(50):
    for y in range(50):
        tiles.set((x, y), calculate_tile_index(x, y))

Common Issues

Issue: Layer Changes Don't Appear

Cause: Use layer methods (set(), fill()) which automatically mark chunks dirty.

Issue: Overlay Appears Behind Entities

Cause: Layer z_index is negative.

Fix: Use z_index >= 0 for overlays:

overlay = mcrfpy.ColorLayer(name="overlay", z_index=1)  # Not z_index=-1

Issue: Performance Degrades with Many Changes

Cause: Each set() call marks a chunk dirty; scattered changes mean many chunk redraws.

Fix: For bulk updates, use fill() for uniform values:

# Single operation - efficient
layer.fill(mcrfpy.Color(0, 0, 0, 0))

# Many individual calls - slower but necessary for non-uniform data
for x in range(100):
    for y in range(100):
        layer.set((x, y), compute_value(x, y))

API Quick Reference

Grid Properties:

  • center - Camera position (Vector, pixels in grid space)
  • zoom - Scale factor (float)
  • layers - All layers (tuple, sorted by z_index)
  • fill_color - Grid background color (behind all layers)
  • grid_size - Grid dimensions (tuple)

Grid Methods:

  • add_layer(layer) - Attach a layer object
  • remove_layer(layer) - Detach a layer
  • layer("name") - Get layer by name

Layer Construction:

  • mcrfpy.ColorLayer(name="...", z_index=N) - Color overlay
  • mcrfpy.TileLayer(name="...", z_index=N, texture=tex) - Tile sprites

Layer Properties:

  • z_index - Render order (int)
  • visible - Show/hide (bool)
  • grid_size - Dimensions (tuple, read-only)

Layer Methods:

  • set((x, y), value) - Set cell (Color or int)
  • at((x, y)) - Get cell value
  • fill(value) - Fill all cells

Navigation: