feat: Implement chunk-based Grid rendering for large grids (closes #123)

Adds a sub-grid system where grids larger than 64x64 cells are automatically
divided into 64x64 chunks, each with its own RenderTexture for incremental
rendering. This significantly improves performance for large grids by:

- Only re-rendering dirty chunks when cells are modified
- Caching rendered chunk textures between frames
- Viewport culling at the chunk level (skip invisible chunks entirely)

Implementation details:
- GridChunk class manages individual 64x64 cell regions with dirty tracking
- ChunkManager organizes chunks and routes cell access appropriately
- UIGrid::at() method transparently routes through chunks for large grids
- UIGrid::render() uses chunk-based blitting for large grids
- Compile-time CHUNK_SIZE (64) and CHUNK_THRESHOLD (64) constants
- Small grids (<= 64x64) continue to use flat storage (no regression)

Benchmark results show ~2x improvement in base layer render time for 100x100
grids (0.45ms -> 0.22ms) due to chunk caching.

Note: Dynamic layers (#147) still use full-grid textures; extending chunk
system to layers is tracked separately as #150.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
John McCardle 2025-11-28 22:33:16 -05:00
commit 9469c04b01
6 changed files with 1059 additions and 49 deletions

253
src/GridChunk.cpp Normal file
View file

@ -0,0 +1,253 @@
#include "GridChunk.h"
#include "UIGrid.h"
#include "PyTexture.h"
#include <algorithm>
#include <cmath>
// =============================================================================
// GridChunk implementation
// =============================================================================
GridChunk::GridChunk(int chunk_x, int chunk_y, int width, int height,
int world_x, int world_y, UIGrid* parent)
: chunk_x(chunk_x), chunk_y(chunk_y),
width(width), height(height),
world_x(world_x), world_y(world_y),
cells(width * height),
dirty(true), texture_initialized(false),
parent_grid(parent)
{}
UIGridPoint& GridChunk::at(int local_x, int local_y) {
return cells[local_y * width + local_x];
}
const UIGridPoint& GridChunk::at(int local_x, int local_y) const {
return cells[local_y * width + local_x];
}
void GridChunk::markDirty() {
dirty = true;
}
void GridChunk::ensureTexture(int cell_width, int cell_height) {
unsigned int required_width = width * cell_width;
unsigned int required_height = height * cell_height;
if (texture_initialized &&
cached_texture.getSize().x == required_width &&
cached_texture.getSize().y == required_height) {
return;
}
if (!cached_texture.create(required_width, required_height)) {
texture_initialized = false;
return;
}
texture_initialized = true;
dirty = true; // Force re-render after resize
cached_sprite.setTexture(cached_texture.getTexture());
}
void GridChunk::renderToTexture(int cell_width, int cell_height,
std::shared_ptr<PyTexture> texture) {
ensureTexture(cell_width, cell_height);
if (!texture_initialized) return;
cached_texture.clear(sf::Color::Transparent);
sf::RectangleShape rect;
rect.setSize(sf::Vector2f(cell_width, cell_height));
rect.setOutlineThickness(0);
// Render all cells in this chunk
for (int y = 0; y < height; ++y) {
for (int x = 0; x < width; ++x) {
const auto& cell = at(x, y);
sf::Vector2f pixel_pos(x * cell_width, y * cell_height);
// Draw background color
rect.setPosition(pixel_pos);
rect.setFillColor(cell.color);
cached_texture.draw(rect);
// Draw tile sprite if available
if (texture && cell.tilesprite != -1) {
sf::Sprite sprite = texture->sprite(cell.tilesprite, pixel_pos,
sf::Vector2f(1.0f, 1.0f));
cached_texture.draw(sprite);
}
}
}
cached_texture.display();
dirty = false;
}
sf::FloatRect GridChunk::getWorldBounds(int cell_width, int cell_height) const {
return sf::FloatRect(
sf::Vector2f(world_x * cell_width, world_y * cell_height),
sf::Vector2f(width * cell_width, height * cell_height)
);
}
bool GridChunk::isVisible(float left_edge, float top_edge,
float right_edge, float bottom_edge) const {
// Check if chunk's cell range overlaps with viewport's cell range
float chunk_right = world_x + width;
float chunk_bottom = world_y + height;
return !(world_x >= right_edge || chunk_right <= left_edge ||
world_y >= bottom_edge || chunk_bottom <= top_edge);
}
// =============================================================================
// ChunkManager implementation
// =============================================================================
ChunkManager::ChunkManager(int grid_x, int grid_y, UIGrid* parent)
: grid_x(grid_x), grid_y(grid_y), parent_grid(parent)
{
// Calculate number of chunks needed
chunks_x = (grid_x + GridChunk::CHUNK_SIZE - 1) / GridChunk::CHUNK_SIZE;
chunks_y = (grid_y + GridChunk::CHUNK_SIZE - 1) / GridChunk::CHUNK_SIZE;
chunks.reserve(chunks_x * chunks_y);
// Create chunks
for (int cy = 0; cy < chunks_y; ++cy) {
for (int cx = 0; cx < chunks_x; ++cx) {
// Calculate world position
int world_x = cx * GridChunk::CHUNK_SIZE;
int world_y = cy * GridChunk::CHUNK_SIZE;
// Calculate actual size (may be smaller at edges)
int chunk_width = std::min(GridChunk::CHUNK_SIZE, grid_x - world_x);
int chunk_height = std::min(GridChunk::CHUNK_SIZE, grid_y - world_y);
chunks.push_back(std::make_unique<GridChunk>(
cx, cy, chunk_width, chunk_height, world_x, world_y, parent
));
}
}
}
GridChunk* ChunkManager::getChunkForCell(int x, int y) {
if (x < 0 || x >= grid_x || y < 0 || y >= grid_y) {
return nullptr;
}
int chunk_x = x / GridChunk::CHUNK_SIZE;
int chunk_y = y / GridChunk::CHUNK_SIZE;
return getChunk(chunk_x, chunk_y);
}
const GridChunk* ChunkManager::getChunkForCell(int x, int y) const {
if (x < 0 || x >= grid_x || y < 0 || y >= grid_y) {
return nullptr;
}
int chunk_x = x / GridChunk::CHUNK_SIZE;
int chunk_y = y / GridChunk::CHUNK_SIZE;
return getChunk(chunk_x, chunk_y);
}
GridChunk* ChunkManager::getChunk(int chunk_x, int chunk_y) {
if (chunk_x < 0 || chunk_x >= chunks_x || chunk_y < 0 || chunk_y >= chunks_y) {
return nullptr;
}
return chunks[chunk_y * chunks_x + chunk_x].get();
}
const GridChunk* ChunkManager::getChunk(int chunk_x, int chunk_y) const {
if (chunk_x < 0 || chunk_x >= chunks_x || chunk_y < 0 || chunk_y >= chunks_y) {
return nullptr;
}
return chunks[chunk_y * chunks_x + chunk_x].get();
}
UIGridPoint& ChunkManager::at(int x, int y) {
GridChunk* chunk = getChunkForCell(x, y);
if (!chunk) {
// Return a static dummy point for out-of-bounds access
// This matches the original behavior of UIGrid::at()
static UIGridPoint dummy;
return dummy;
}
// Convert to local coordinates within chunk
int local_x = x % GridChunk::CHUNK_SIZE;
int local_y = y % GridChunk::CHUNK_SIZE;
// Mark chunk dirty when accessed for modification
chunk->markDirty();
return chunk->at(local_x, local_y);
}
const UIGridPoint& ChunkManager::at(int x, int y) const {
const GridChunk* chunk = getChunkForCell(x, y);
if (!chunk) {
static UIGridPoint dummy;
return dummy;
}
int local_x = x % GridChunk::CHUNK_SIZE;
int local_y = y % GridChunk::CHUNK_SIZE;
return chunk->at(local_x, local_y);
}
void ChunkManager::markAllDirty() {
for (auto& chunk : chunks) {
chunk->markDirty();
}
}
std::vector<GridChunk*> ChunkManager::getVisibleChunks(float left_edge, float top_edge,
float right_edge, float bottom_edge) {
std::vector<GridChunk*> visible;
visible.reserve(chunks.size()); // Pre-allocate for worst case
for (auto& chunk : chunks) {
if (chunk->isVisible(left_edge, top_edge, right_edge, bottom_edge)) {
visible.push_back(chunk.get());
}
}
return visible;
}
void ChunkManager::resize(int new_grid_x, int new_grid_y) {
// For now, simple rebuild - could be optimized to preserve data
grid_x = new_grid_x;
grid_y = new_grid_y;
chunks_x = (grid_x + GridChunk::CHUNK_SIZE - 1) / GridChunk::CHUNK_SIZE;
chunks_y = (grid_y + GridChunk::CHUNK_SIZE - 1) / GridChunk::CHUNK_SIZE;
chunks.clear();
chunks.reserve(chunks_x * chunks_y);
for (int cy = 0; cy < chunks_y; ++cy) {
for (int cx = 0; cx < chunks_x; ++cx) {
int world_x = cx * GridChunk::CHUNK_SIZE;
int world_y = cy * GridChunk::CHUNK_SIZE;
int chunk_width = std::min(GridChunk::CHUNK_SIZE, grid_x - world_x);
int chunk_height = std::min(GridChunk::CHUNK_SIZE, grid_y - world_y);
chunks.push_back(std::make_unique<GridChunk>(
cx, cy, chunk_width, chunk_height, world_x, world_y, parent_grid
));
}
}
}
int ChunkManager::dirtyChunks() const {
int count = 0;
for (const auto& chunk : chunks) {
if (chunk->dirty) ++count;
}
return count;
}

118
src/GridChunk.h Normal file
View file

@ -0,0 +1,118 @@
#pragma once
#include "Common.h"
#include <SFML/Graphics.hpp>
#include <vector>
#include <memory>
#include "UIGridPoint.h"
// Forward declarations
class UIGrid;
class PyTexture;
/**
* #123 - Grid chunk for sub-grid rendering system
*
* Each chunk represents a CHUNK_SIZE x CHUNK_SIZE portion of the grid.
* Chunks have their own RenderTexture and dirty flag for efficient
* incremental rendering - only dirty chunks are re-rendered.
*/
class GridChunk {
public:
// Compile-time configurable chunk size (power of 2 recommended)
static constexpr int CHUNK_SIZE = 64;
// Position of this chunk in chunk coordinates
int chunk_x, chunk_y;
// Actual dimensions (may be less than CHUNK_SIZE at grid edges)
int width, height;
// World position (in cell coordinates)
int world_x, world_y;
// Cell data for this chunk
std::vector<UIGridPoint> cells;
// Cached rendering
sf::RenderTexture cached_texture;
sf::Sprite cached_sprite;
bool dirty;
bool texture_initialized;
// Parent grid reference (for texture access)
UIGrid* parent_grid;
// Constructor
GridChunk(int chunk_x, int chunk_y, int width, int height,
int world_x, int world_y, UIGrid* parent);
// Access cell at local chunk coordinates
UIGridPoint& at(int local_x, int local_y);
const UIGridPoint& at(int local_x, int local_y) const;
// Mark chunk as needing re-render
void markDirty();
// Ensure texture is properly sized
void ensureTexture(int cell_width, int cell_height);
// Render chunk content to cached texture
void renderToTexture(int cell_width, int cell_height,
std::shared_ptr<PyTexture> texture);
// Get pixel bounds of this chunk in world coordinates
sf::FloatRect getWorldBounds(int cell_width, int cell_height) const;
// Check if chunk overlaps with viewport
bool isVisible(float left_edge, float top_edge,
float right_edge, float bottom_edge) const;
};
/**
* Manages a 2D array of chunks for a grid
*/
class ChunkManager {
public:
// Dimensions in chunks
int chunks_x, chunks_y;
// Grid dimensions in cells
int grid_x, grid_y;
// All chunks (row-major order)
std::vector<std::unique_ptr<GridChunk>> chunks;
// Parent grid
UIGrid* parent_grid;
// Constructor - creates chunks for given grid dimensions
ChunkManager(int grid_x, int grid_y, UIGrid* parent);
// Get chunk containing cell (x, y)
GridChunk* getChunkForCell(int x, int y);
const GridChunk* getChunkForCell(int x, int y) const;
// Get chunk at chunk coordinates
GridChunk* getChunk(int chunk_x, int chunk_y);
const GridChunk* getChunk(int chunk_x, int chunk_y) const;
// Access cell at grid coordinates (routes through chunk)
UIGridPoint& at(int x, int y);
const UIGridPoint& at(int x, int y) const;
// Mark all chunks dirty (for full rebuild)
void markAllDirty();
// Get chunks that overlap with viewport
std::vector<GridChunk*> getVisibleChunks(float left_edge, float top_edge,
float right_edge, float bottom_edge);
// Resize grid (rebuilds chunks)
void resize(int new_grid_x, int new_grid_y);
// Get total number of chunks
int totalChunks() const { return chunks_x * chunks_y; }
// Get number of dirty chunks
int dirtyChunks() const;
};

View file

@ -8,10 +8,10 @@
#include <cmath> // #142 - for std::floor
// UIDrawable methods now in UIBase.h
UIGrid::UIGrid()
UIGrid::UIGrid()
: grid_x(0), grid_y(0), zoom(1.0f), center_x(0.0f), center_y(0.0f), ptex(nullptr),
fill_color(8, 8, 8, 255), tcod_map(nullptr), tcod_dijkstra(nullptr), tcod_path(nullptr),
perspective_enabled(false) // Default to omniscient view
perspective_enabled(false), use_chunks(false) // Default to omniscient view
{
// Initialize entities list
entities = std::make_shared<std::list<std::shared_ptr<UIEntity>>>();
@ -24,30 +24,31 @@ UIGrid::UIGrid()
position = sf::Vector2f(0, 0); // Set base class position
box.setPosition(position); // Sync box position
box.setFillColor(sf::Color(0, 0, 0, 0));
// Initialize render texture (small default size)
renderTexture.create(1, 1);
// Initialize output sprite
output.setTextureRect(sf::IntRect(0, 0, 0, 0));
output.setPosition(0, 0);
output.setTexture(renderTexture.getTexture());
// Points vector starts empty (grid_x * grid_y = 0)
// TCOD map will be created when grid is resized
}
UIGrid::UIGrid(int gx, int gy, std::shared_ptr<PyTexture> _ptex, sf::Vector2f _xy, sf::Vector2f _wh)
: grid_x(gx), grid_y(gy),
zoom(1.0f),
ptex(_ptex), points(gx * gy),
zoom(1.0f),
ptex(_ptex),
fill_color(8, 8, 8, 255), tcod_map(nullptr), tcod_dijkstra(nullptr), tcod_path(nullptr),
perspective_enabled(false) // Default to omniscient view
perspective_enabled(false),
use_chunks(gx > CHUNK_THRESHOLD || gy > CHUNK_THRESHOLD) // #123 - Use chunks for large grids
{
// Use texture dimensions if available, otherwise use defaults
int cell_width = _ptex ? _ptex->sprite_width : DEFAULT_CELL_WIDTH;
int cell_height = _ptex ? _ptex->sprite_height : DEFAULT_CELL_HEIGHT;
center_x = (gx/2) * cell_width;
center_y = (gy/2) * cell_height;
entities = std::make_shared<std::list<std::shared_ptr<UIEntity>>>();
@ -57,12 +58,12 @@ UIGrid::UIGrid(int gx, int gy, std::shared_ptr<PyTexture> _ptex, sf::Vector2f _x
box.setSize(_wh);
position = _xy; // Set base class position
box.setPosition(position); // Sync box position
box.setPosition(position); // Sync box position
box.setFillColor(sf::Color(0,0,0,0));
// create renderTexture with maximum theoretical size; sprite can resize to show whatever amount needs to be rendered
renderTexture.create(1920, 1080); // TODO - renderTexture should be window size; above 1080p this will cause rendering errors
// Only initialize sprite if texture is available
if (ptex) {
sprite = ptex->sprite(0);
@ -77,23 +78,47 @@ UIGrid::UIGrid(int gx, int gy, std::shared_ptr<PyTexture> _ptex, sf::Vector2f _x
// Create TCOD map
tcod_map = new TCODMap(gx, gy);
// Create TCOD dijkstra pathfinder
tcod_dijkstra = new TCODDijkstra(tcod_map);
// Create TCOD A* pathfinder
tcod_path = new TCODPath(tcod_map);
// Initialize grid points with parent reference
for (int y = 0; y < gy; y++) {
for (int x = 0; x < gx; x++) {
int idx = y * gx + x;
points[idx].grid_x = x;
points[idx].grid_y = y;
points[idx].parent_grid = this;
// #123 - Initialize storage based on grid size
if (use_chunks) {
// Large grid: use chunk-based storage
chunk_manager = std::make_unique<ChunkManager>(gx, gy, this);
// Initialize all cells with parent reference
for (int cy = 0; cy < chunk_manager->chunks_y; ++cy) {
for (int cx = 0; cx < chunk_manager->chunks_x; ++cx) {
GridChunk* chunk = chunk_manager->getChunk(cx, cy);
if (!chunk) continue;
for (int ly = 0; ly < chunk->height; ++ly) {
for (int lx = 0; lx < chunk->width; ++lx) {
auto& cell = chunk->at(lx, ly);
cell.grid_x = chunk->world_x + lx;
cell.grid_y = chunk->world_y + ly;
cell.parent_grid = this;
}
}
}
}
} else {
// Small grid: use flat storage (original behavior)
points.resize(gx * gy);
for (int y = 0; y < gy; y++) {
for (int x = 0; x < gx; x++) {
int idx = y * gx + x;
points[idx].grid_x = x;
points[idx].grid_y = y;
points[idx].parent_grid = this;
}
}
}
// Initial sync of TCOD map
syncTCODMap();
}
@ -147,36 +172,64 @@ void UIGrid::render(sf::Vector2f offset, sf::RenderTarget& target)
// base layer - bottom color, tile sprite ("ground")
int cellsRendered = 0;
for (int x = (left_edge - 1 >= 0 ? left_edge - 1 : 0);
x < x_limit; //x < view_width;
x+=1)
{
//for (float y = (top_edge >= 0 ? top_edge : 0);
for (int y = (top_edge - 1 >= 0 ? top_edge - 1 : 0);
y < y_limit; //y < view_height;
y+=1)
{
auto pixel_pos = sf::Vector2f(
(x*cell_width - left_spritepixels) * zoom,
(y*cell_height - top_spritepixels) * zoom );
auto gridpoint = at(std::floor(x), std::floor(y));
// #123 - Use chunk-based rendering for large grids
if (use_chunks && chunk_manager) {
// Get visible chunks based on cell coordinate bounds
float right_edge = left_edge + width_sq + 2;
float bottom_edge = top_edge + height_sq + 2;
auto visible_chunks = chunk_manager->getVisibleChunks(left_edge, top_edge, right_edge, bottom_edge);
//sprite.setPosition(pixel_pos);
r.setPosition(pixel_pos);
r.setFillColor(gridpoint.color);
renderTexture.draw(r);
// tilesprite - only draw if texture is available
// if discovered but not visible, set opacity to 90%
// if not discovered... just don't draw it?
if (ptex && gridpoint.tilesprite != -1) {
sprite = ptex->sprite(gridpoint.tilesprite, pixel_pos, sf::Vector2f(zoom, zoom)); //setSprite(gridpoint.tilesprite);;
renderTexture.draw(sprite);
for (auto* chunk : visible_chunks) {
// Re-render dirty chunks to their cached textures
if (chunk->dirty) {
chunk->renderToTexture(cell_width, cell_height, ptex);
}
cellsRendered++;
// Calculate pixel position for this chunk's sprite
float chunk_pixel_x = (chunk->world_x * cell_width - left_spritepixels) * zoom;
float chunk_pixel_y = (chunk->world_y * cell_height - top_spritepixels) * zoom;
// Set up and draw the chunk sprite
chunk->cached_sprite.setPosition(chunk_pixel_x, chunk_pixel_y);
chunk->cached_sprite.setScale(zoom, zoom);
renderTexture.draw(chunk->cached_sprite);
cellsRendered += chunk->width * chunk->height;
}
} else {
// Original cell-by-cell rendering for small grids
for (int x = (left_edge - 1 >= 0 ? left_edge - 1 : 0);
x < x_limit; //x < view_width;
x+=1)
{
//for (float y = (top_edge >= 0 ? top_edge : 0);
for (int y = (top_edge - 1 >= 0 ? top_edge - 1 : 0);
y < y_limit; //y < view_height;
y+=1)
{
auto pixel_pos = sf::Vector2f(
(x*cell_width - left_spritepixels) * zoom,
(y*cell_height - top_spritepixels) * zoom );
auto gridpoint = at(std::floor(x), std::floor(y));
//sprite.setPosition(pixel_pos);
r.setPosition(pixel_pos);
r.setFillColor(gridpoint.color);
renderTexture.draw(r);
// tilesprite - only draw if texture is available
// if discovered but not visible, set opacity to 90%
// if not discovered... just don't draw it?
if (ptex && gridpoint.tilesprite != -1) {
sprite = ptex->sprite(gridpoint.tilesprite, pixel_pos, sf::Vector2f(zoom, zoom)); //setSprite(gridpoint.tilesprite);;
renderTexture.draw(sprite);
}
cellsRendered++;
}
}
}
@ -368,6 +421,10 @@ void UIGrid::render(sf::Vector2f offset, sf::RenderTarget& target)
UIGridPoint& UIGrid::at(int x, int y)
{
// #123 - Route through chunk manager for large grids
if (use_chunks && chunk_manager) {
return chunk_manager->at(x, y);
}
return points[y * grid_x + x];
}
@ -1109,7 +1166,8 @@ PyObject* UIGrid::py_at(PyUIGridObject* self, PyObject* args, PyObject* kwds)
auto type = (PyTypeObject*)PyObject_GetAttrString(McRFPy_API::mcrf_module, "GridPoint");
auto obj = (PyUIGridPointObject*)type->tp_alloc(type, 0);
//auto target = std::static_pointer_cast<UIEntity>(target);
obj->data = &(self->data->points[x + self->data->grid_x * y]);
// #123 - Use at() method to route through chunks for large grids
obj->data = &(self->data->at(x, y));
obj->grid = self->data;
return (PyObject*)obj;
}

View file

@ -21,6 +21,7 @@
#include "UIDrawable.h"
#include "UIBase.h"
#include "GridLayers.h"
#include "GridChunk.h"
class UIGrid: public UIDrawable
{
@ -75,7 +76,15 @@ public:
std::shared_ptr<PyTexture> getTexture();
sf::Sprite sprite, output;
sf::RenderTexture renderTexture;
// #123 - Chunk-based storage for large grid support
std::unique_ptr<ChunkManager> chunk_manager;
// Legacy flat storage (kept for small grids or compatibility)
std::vector<UIGridPoint> points;
// Use chunks for grids larger than this threshold
static constexpr int CHUNK_THRESHOLD = 64;
bool use_chunks;
std::shared_ptr<std::list<std::shared_ptr<UIEntity>>> entities;
// UIDrawable children collection (speech bubbles, effects, overlays, etc.)