1 Grid-Rendering-Pipeline
John McCardle edited this page 2025-10-25 22:48:05 +00:00

Grid Rendering Pipeline

Overview

The Grid rendering pipeline is McRogueFace's most complex rendering subsystem. It handles viewport culling, zoom, entity rendering, and FOV overlays - all within a single sf::RenderTexture for optimal performance.

Parent Page: Grid-System

Related Pages:

Key Files:

  • src/UIGrid.cpp::render() - Main rendering entry point (lines 97-310)
  • src/GameEngine.cpp - ProfilingMetrics struct for tracking render times
  • src/Profiler.h - ScopedTimer RAII helper

Related Issues:

  • #116 - Dirty flag system (avoid re-rendering static content)
  • #115 - SpatialHash for entity culling optimization
  • #113 - Batch operations API

Render Pipeline Stages

The pipeline executes in 4 stages during each frame:

1. Viewport Calculation (culling bounds)
   ↓
2. Base Layer Rendering (tiles + background colors)
   ↓
3. Entity Layer Rendering (visible entities)
   ↓
4. FOV Overlay Rendering (discovered/visible states)

All rendering happens into an sf::RenderTexture, then the final texture is drawn to the window in a single operation.


Stage 1: Viewport Calculation

Purpose

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

Implementation

// Get cell dimensions from texture
int cell_width = ptex ? ptex->sprite_width : DEFAULT_CELL_WIDTH;
int cell_height = ptex ? ptex->sprite_height : DEFAULT_CELL_HEIGHT;

// Camera center in grid coordinates
float center_x_sq = center_x / cell_width;
float center_y_sq = center_y / cell_height;

// Viewport size in cells
float width_sq = box.getSize().x / (cell_width * zoom);
float height_sq = box.getSize().y / (cell_height * zoom);

// Visible cell range
float left_edge = center_x_sq - (width_sq / 2.0);
float top_edge = center_y_sq - (height_sq / 2.0);
int x_limit = left_edge + width_sq + 2;  // +2 for margin
int y_limit = top_edge + height_sq + 2;

Key Insight: The +2 margin ensures partially-visible cells at viewport edges are rendered completely.

Camera Properties

  • center_x, center_y - Camera position in pixel coordinates
  • zoom - Scale factor (1.0 = normal, 2.0 = 2x zoom)
  • box.getSize() - Viewport dimensions in pixels

Python API

grid.center_x = player.x * cell_width  # Follow player
grid.center_y = player.y * cell_height
grid.zoom = 2.0  # Zoom in

Stage 2: Base Layer Rendering

Purpose

Render the "ground" layer: background colors and tile sprites.

Loop Structure

// Profile this stage
ScopedTimer gridTimer(Resources::game->metrics.gridRenderTime);

int cellsRendered = 0;
for (int x = left_edge - 1; x < x_limit; x++) {
    for (int y = top_edge - 1; y < y_limit; y++) {
        // Calculate pixel position within renderTexture
        auto pixel_pos = sf::Vector2f(
            (x * cell_width - left_spritepixels) * zoom,
            (y * cell_height - top_spritepixels) * zoom
        );
        
        auto gridpoint = at(x, y);
        
        // 1. Draw background color
        r.setPosition(pixel_pos);
        r.setFillColor(gridpoint.color);
        renderTexture.draw(r);
        
        // 2. Draw tile sprite (if texture exists and tilesprite set)
        if (ptex && gridpoint.tilesprite != -1) {
            sprite = ptex->sprite(gridpoint.tilesprite, pixel_pos, 
                                 sf::Vector2f(zoom, zoom));
            renderTexture.draw(sprite);
        }
        
        cellsRendered++;
    }
}

// Record metrics for profiling overlay
Resources::game->metrics.gridCellsRendered += cellsRendered;

Per-Cell Data

Each UIGridPoint has:

  • color - RGBA background color (drawn first)
  • tilesprite - Sprite index from texture sheet (-1 = no sprite)
  • walkable - Affects pathfinding (not used in rendering)

Performance Characteristics

Current Performance:

  • 100x100 grid, ~1000 visible cells
  • Grid render time: 8-12ms (without dirty flags)
  • Target: <2ms after dirty flag optimization (#116)

Bottlenecks:

  • Individual renderTexture.draw() calls per cell
  • No caching of static content
  • Python/C++ boundary crossings for at(x, y) calls

Stage 3: Entity Layer Rendering

Purpose

Render entities that exist on the grid at their interpolated positions.

Loop Structure

{
    ScopedTimer entityTimer(Resources::game->metrics.entityRenderTime);
    int entitiesRendered = 0;
    int totalEntities = entities->size();
    
    for (auto e : *entities) {
        // Viewport culling: skip out-of-bounds entities
        if (e->position.x < left_edge - 1 || 
            e->position.x >= left_edge + width_sq + 1 ||
            e->position.y < top_edge - 1 || 
            e->position.y >= top_edge + height_sq + 1) {
            continue;
        }
        
        // Get entity's sprite
        auto& drawent = e->sprite;
        drawent.setScale(sf::Vector2f(zoom, zoom));
        
        // Calculate pixel position (supports fractional positions)
        auto pixel_pos = sf::Vector2f(
            (e->position.x * cell_width - left_spritepixels) * zoom,
            (e->position.y * cell_height - top_spritepixels) * zoom
        );
        
        // Render entity sprite
        drawent.render(pixel_pos, renderTexture);
        
        entitiesRendered++;
    }
    
    // Record metrics
    Resources::game->metrics.entitiesRendered += entitiesRendered;
    Resources::game->metrics.totalEntities += totalEntities;
}

Entity Positions

Entities have floating-point positions (entity.position.x, entity.position.y), allowing smooth sub-cell movement:

# Entity at fractional position
entity.x = 10.5  # Halfway between cells 10 and 11
entity.y = 20.75

Performance Considerations

Current Performance:

  • 50 entities: ~2-3ms render time
  • 500 entities: ~20-30ms (all visible)
  • With culling: only visible entities rendered

Optimization Opportunities:

  • #115: SpatialHash for O(1) visibility queries
  • #117: Memory pool to reduce allocation overhead
  • Sort by Z-order before rendering (not yet implemented)

Stage 4: FOV Overlay Rendering

Purpose

If perspective_enabled is true, overlay cells based on entity's discovered/visible state.

Three Visibility States

  1. Visible - Entity can currently see this cell → No overlay
  2. Discovered but not visible - Entity has seen this cell before → Dark gray overlay (RGBA: 32, 32, 40, 192)
  3. Undiscovered - Entity has never seen this cell → Black overlay (RGBA: 0, 0, 0, 255)

Loop Structure

if (perspective_enabled) {
    ScopedTimer fovTimer(Resources::game->metrics.fovOverlayTime);
    auto entity = perspective_entity.lock();  // weak_ptr to entity
    
    sf::RectangleShape overlay;
    overlay.setSize(sf::Vector2f(cell_width * zoom, cell_height * zoom));
    
    if (entity) {
        // Valid entity - use its gridstate
        for (int x = left_edge - 1; x < x_limit; x++) {
            for (int y = top_edge - 1; y < y_limit; y++) {
                int idx = y * grid_x + x;
                const auto& state = entity->gridstate[idx];
                
                overlay.setPosition(pixel_pos);
                
                if (!state.discovered) {
                    // Black - never seen
                    overlay.setFillColor(sf::Color(0, 0, 0, 255));
                    renderTexture.draw(overlay);
                } else if (!state.visible) {
                    // Dark gray - seen before, not currently visible
                    overlay.setFillColor(sf::Color(32, 32, 40, 192));
                    renderTexture.draw(overlay);
                }
                // else: visible and discovered - no overlay
            }
        }
    } else {
        // Entity destroyed/invalid - all cells black
        // ... render full black overlays ...
    }
}
// else: omniscient view (perspective_enabled = false) - no overlays

Perspective System

See Grid-TCOD-Integration for details on how compute_fov() updates entity gridstate.

Python API:

# Enable perspective view from entity's POV
grid.set_perspective(player_entity)

# Disable perspective (omniscient view)
grid.clear_perspective()

# Check if perspective enabled
if grid.perspective_enabled:
    print("Using entity perspective")

Final Output

RenderTexture to Window

After all 4 stages complete:

renderTexture.display();  // Finalize texture

// output sprite uses the renderTexture
output.setTextureRect(sf::IntRect(0, 0, box.getSize().x, box.getSize().y));
output.setPosition(box.getPosition() + offset);

// Single draw call to window
target.draw(output);

Performance Benefit: Entire grid (1000s of cells + entities + overlays) rendered to window in one draw call.


Profiling Integration

The rendering pipeline is instrumented with ScopedTimer for F3 overlay:

void UIGrid::render(sf::Vector2f offset, sf::RenderTarget& target) {
    ScopedTimer gridTimer(Resources::game->metrics.gridRenderTime);
    
    // Stage 2: Base layer
    // ... rendering ...
    
    // Stage 3: Entities
    {
        ScopedTimer entityTimer(Resources::game->metrics.entityRenderTime);
        // ... entity rendering ...
    }
    
    // Stage 4: FOV overlay
    if (perspective_enabled) {
        ScopedTimer fovTimer(Resources::game->metrics.fovOverlayTime);
        // ... overlay rendering ...
    }
}

Press F3 in-game to see:

  • Grid render time
  • Entity render time
  • FOV overlay time
  • Cells/entities rendered

See Performance-and-Profiling for details on using the profiler.


Performance Optimization Workflow

Current Bottlenecks (October 2025)

  1. Grid re-renders every frame even if static

    • Solution: #116 (Dirty flag system)
    • Expected improvement: 8-12ms → <2ms for static content
  2. Entity culling is O(n)

    • Solution: #115 (SpatialHash)
    • Expected improvement: 10,000 entities with 50 visible → 1-2ms
  3. Individual draw calls per cell

    • Solution: Vertex arrays or instanced rendering
    • Expected improvement: 50% reduction in draw call overhead

Profiling a Grid Performance Issue

Example workflow using Performance-Optimization-Workflow:

# 1. Create benchmark scene
import mcrfpy

mcrfpy.createScene("test")
grid = mcrfpy.Grid(grid_size=(100, 100), pos=(0, 0), size=(1024, 768))

# Fill grid with varied content
for x in range(100):
    for y in range(100):
        cell = grid.at((x, y))
        cell.tilesprite = (x + y) % 10
        cell.color = (50 + x, 50 + y, 100, 255)

ui = mcrfpy.sceneUI("test")
ui.append(grid)
mcrfpy.setScene("test")

# 2. Press F3 to see profiler overlay
# 3. Check metrics:
#    - Grid render time: X ms
#    - Cells rendered: Y
#    - FPS: Z

Interpreting Results:

  • Grid render time > 10ms → Consider dirty flags
  • Entities rendered << total entities → Culling is working
  • FOV overlay time > 5ms → Large visible area or complex state

Common Issues

Issue: Grid Renders Off-Center

Cause: center_x/center_y values incorrect relative to camera follow logic.

Fix:

# Ensure center is in pixel coordinates, not cell coordinates
grid.center_x = player.x * cell_width
grid.center_y = player.y * cell_height

Issue: Entities Flicker at Viewport Edge

Cause: Culling margin too small (entities disappear before fully off-screen).

Fix: The -1/+1 margins in culling logic should handle this, but if needed:

// Increase margin from 1 to 2
if (e->position.x < left_edge - 2 || e->position.x >= left_edge + width_sq + 2)

Issue: Zoom Breaks Entity Positions

Cause: zoom not applied consistently to entity pixel positions.

Fix: Ensure both entity position calculation AND sprite scale use zoom:

auto pixel_pos = sf::Vector2f(
    (e->position.x * cell_width - left_spritepixels) * zoom,
    (e->position.y * cell_height - top_spritepixels) * zoom
);
drawent.setScale(sf::Vector2f(zoom, zoom));

Issue: FOV Overlay Has Gaps

Cause: gridstate not updated after compute_fov().

Fix: See Grid-TCOD-Integration for proper FOV computation sequence.


Future Improvements

Dirty Flag System (#116)

Only re-render cells that changed:

// Proposed implementation
bool grid_dirty = true;  // Set when cells change

void UIGrid::render() {
    if (!grid_dirty && cachedTexture.valid()) {
        // Use cached texture
        target.draw(cachedOutput);
        return;
    }
    
    // Full re-render
    // ... existing pipeline ...
    
    grid_dirty = false;
}

Benefits:

  • Static grids: 8-12ms → <1ms (just copy cached texture)
  • Animated grids: Only re-render changed cells

Batch Cell Updates (#113)

Expose NumPy-style batch operations:

# Current: O(n) Python/C++ crossings
for x in range(100):
    for y in range(100):
        grid.at((x, y)).color = (255, 0, 0, 255)

# Proposed: O(1) crossings
grid.set_colors(colors_array)  # NumPy array or Python list

SpatialHash Entity Culling (#115)

Replace linear entity scan with spatial queries:

// Current: O(n)
for (auto e : *entities) {
    if (in_viewport(e)) render(e);
}

// Proposed: O(visible)
auto visible_entities = spatial_hash.query(viewport_bounds);
for (auto e : visible_entities) {
    render(e);
}

API Reference

See docs/api_reference_dynamic.html for complete Grid API.

Key Properties:

  • grid.center_x, grid.center_y - Camera position (pixels)
  • grid.zoom - Zoom level (float, default 1.0)
  • grid.perspective_enabled - Read-only, check if FOV overlay active

Key Methods:

  • grid.set_perspective(entity) - Enable FOV from entity's POV
  • grid.clear_perspective() - Disable FOV overlay
  • grid.at((x, y)) - Get cell for modification

Navigation: