Rendering¶
This guide explains how the texture-based renderer turns a State
into an image, how textures are selected and layered, how grouping and recoloring work, and how to customize assets and performance. It also includes examples and troubleshooting tips.
Contents
- Rendering model and layering
- Texture maps and asset resolution
- Grouping and deterministic recoloring
- Moving overlays (direction triangles)
- Performance and caching
- Customization patterns
- End-to-end examples
- Troubleshooting
Rendering model and layering¶
The TextureRenderer
draws a snapshot of the grid into a single RGBA image. Each cell is composed from objects in that cell, using Appearance
flags and priority
to decide draw order.
-
Per cell, objects are split into functional roles by their
Appearance
:-
Background: objects with
appearance.background = True
. Exactly one is chosen (the background “tile”). -
Main: the primary non-background object.
-
Corner icons: objects with
appearance.icon = True
(up to four), drawn as small overlays in the corners. -
Others: any remaining non-background, non-icon objects (drawn between background and main).
-
-
Selection rules:
-
Background: from all background items, pick the one with the lowest priority after sorting descending (i.e., visually most “behind”; defaults often are floor
priority=10
and wallpriority=9
). -
Main: among non-backgrounds, pick the highest priority (lowest numeric value).
-
Corner icons: up to 4 highest-priority
icon=True
objects. -
Others: all remaining non-background, non-icon, non-main items.
-
-
Composition order in a cell:
-
Draw background, then others, then main.
-
Draw up to 4 corner icons scaled by
subicon_percent
and positioned to corners.
-
-
Object properties influence asset lookup:
- Every entity’s
AppearanceName
and the set of component “properties” (e.g.,"pushable"
,"pathfinding"
,"dead"
,"locked"
,"required"
, etc.) are used to find the best-matching texture from a texture map (see next section).
- Every entity’s
Texture maps and asset resolution¶
The renderer maps (AppearanceName, properties)
→ a file path or a directory under asset_root
. If the path is a directory, one file is chosen deterministically (per State.seed
) from that directory.
-
Built-ins:
-
DEFAULT_TEXTURE_MAP
: a Kenney-based mapping. -
FUTURAMA_TEXTURE_MAP
: an example alt set. -
TEXTURE_MAP_REGISTRY
:{ "kenney": KENNEY_TEXTURE_MAP, "futurama": FUTURAMA_TEXTURE_MAP }
.
-
-
The
texture_map
has entries like:-
(AppearanceName.BOX, ())
:"kenney/tiles/boxCrate.png"
-
(AppearanceName.BOX, ("pushable",))
:"kenney/tiles/boxCrate_double.png"
-
(AppearanceName.HUMAN, ("dead",))
:"kenney/animated_characters/zombie/zombie_fall.png"
-
-
Best-match selection:
-
Given a requested asset key
(AppearanceName, properties_set)
, the renderer selects the path whose declared properties tuple maximizes overlap and minimizes extra unmatched properties. -
This allows falling back to a generic “BOX” when the
("pushable",)
texture is not provided, and specializing when available.
-
-
Asset root:
-
All texture paths are resolved relative to
asset_root
(default"assets"
). -
If the resolved path is a directory, the renderer lists image files (
.png
,.jpg
,.jpeg
,.gif
), sorts them, and then chooses one via a deterministic RNG seeded fromstate.seed
(one selection per object as rendered).
-
-
Loading:
-
load_texture
opens the image and resizes to the required square size (cell size or subicon size), returning RGBA. -
If loading fails, the object is skipped (cell still renders other layers).
-
Grouping and deterministic recoloring¶
Some objects are grouped to share a distinctive color (hue) while preserving texture tone. This provides instant visual grouping without hand-authoring tinted variants of assets.
-
Group rules (
derive_groups
):-
Keys/doors by
key_id
:key_door_group_rule
maps both to"key:<key_id>"
. -
Paired portals:
portal_pair_group_rule
maps the pair to"portal:<A>-<B>"
whereA < B
are the portal EIDs.
-
-
Coloring:
-
group_to_color(group_id)
deterministically maps a group string to an RGB color viarandom.Random(group_id) → HSV → RGB
. -
apply_recolor_if_group(image, group)
recolors the texture to the group’s hue while preserving the per-pixel value (brightness/tone) and (by default) preserving saturation. -
The recoloring operates on non-transparent pixels only (
alpha > 0
).
-
-
Result:
-
All doors/keys of the same
key_id
share a hue. -
The two portals in a pair share a hue.
-
-
Custom groups:
- You can extend the
derive_groups
rules to recolor other categories consistently across the map (e.g., all boxes of a certain puzzle, enemies in squads, etc.).
- You can extend the
Moving overlays (direction triangles)¶
Entities with a Moving
component are annotated with a small set of white triangles pointing in the movement direction. The number of triangles equals the entity’s Moving.speed
, centered and spaced along the pointing axis.
-
Behavior:
-
If an object has
move_dir = (dx, dy)
andmove_speed > 0
,draw_direction_triangles_on_image
overlays triangles on a copy of the texture before compositing it into the grid. -
Triangles are centered; the centroid arrangement is symmetric around the image center. The triangles are isosceles and sized relative to the cell size.
-
-
Use:
- Makes it visually obvious when/where movers are headed, helpful for debugging and gameplay feedback, especially with subtle textures.
Performance and caching¶
Rendering an entire grid each step can be expensive if textures are reloaded and recolored repeatedly. The renderer uses a small, in-memory cache keyed by:
-
(asset_path, size, group, move_dir, move_speed)
-
asset_path
: full path to the image file used (after directory selection). -
size
: cell size or subicon size. -
group
: group identifier string orNone
(affects recoloring). -
move_dir
: movement direction vector (affects overlay). -
move_speed
: movement speed (affects overlay).
-
-
Tips:
-
Reuse a single
TextureRenderer
instance across frames so it reuses the cache. -
Keep
asset_root
consistent. -
Prefer using the same resolution across frames for maximum cache hits.
-
If you introduce dynamic recoloring rules or moving overlays that change every frame, expect more cache misses; consider coarser rendering or fewer variants for performance-critical loops.
-
Customization patterns¶
You have several knobs to adapt the renderer to your project:
-
Use a different texture map:
-
Start from
DEFAULT_TEXTURE_MAP
and add or override entries. -
Add directories for appearance variants; the renderer will pick one deterministically.
-
-
Add new appearances or property-matched variants:
-
Map
(AppearanceName.SOMETHING, ("propA","propB"))
to a path. -
Provide a fallback
(AppearanceName.SOMETHING, ())
so the mixer always finds something.
-
-
Override the texture lookup function (advanced):
-
The
render()
function accepts atex_lookup_fn
that can replace the default path resolution/loading/recoloring/overlay logic. -
Provide a function with signature
(ObjectRendering, size) -> PIL.Image | None
and pass it toTextureRenderer(tex_lookup_fn=...)
.
-
-
Control corner icon scaling:
subicon_percent
controls the relative size of corner icons (default0.4
).
-
Derive different groupings or colors:
-
Extend
DEFAULT_GROUP_RULES
with moreGroupRule
functions. -
Implement a different mapping in
group_to_color
if you want non-HSV-based palettes (e.g., fixed palette, colorblind-safe selections).
-
-
Switch to a different registry preset:
-
TEXTURE_MAP_REGISTRY["futurama"]
is included as an example of a radically different style. -
You can register your own under a custom key for quick swapping.
-
End-to-end examples¶
Basic rendering of a generated maze
from grid_universe.examples.maze import generate
from grid_universe.renderer.texture import TextureRenderer
state = generate(width=9, height=9, seed=42)
renderer = TextureRenderer(resolution=640, asset_root="assets")
img = renderer.render(state)
img.save("maze_snapshot.png")
Use a custom texture map and smaller subicons
from grid_universe.renderer.texture import TextureRenderer, DEFAULT_TEXTURE_MAP
from grid_universe.components.properties import AppearanceName
from copy import deepcopy
custom_map = deepcopy(DEFAULT_TEXTURE_MAP)
# Override human dead pose and box texture
custom_map[(AppearanceName.HUMAN, tuple(["dead"]))] = "my_pack/hero_dead.png"
custom_map[(AppearanceName.BOX, tuple([]))] = "my_pack/box.png"
renderer = TextureRenderer(resolution=480, subicon_percent=0.3, texture_map=custom_map, asset_root="assets")
img = renderer.render(state)
img.save("custom_snapshot.png")
Pick a random variant from a directory deterministically per run
from grid_universe.renderer.texture import TextureRenderer, DEFAULT_TEXTURE_MAP
from grid_universe.components.properties import AppearanceName
from copy import deepcopy
variants = deepcopy(DEFAULT_TEXTURE_MAP)
# Suppose you have assets/skins/walls/ with multiple .png files;
# all will be candidates, chosen deterministically per state.seed.
variants[(AppearanceName.WALL, tuple([]))] = "skins/walls"
renderer = TextureRenderer(texture_map=variants, asset_root="assets")
img = renderer.render(state)
img.save("variant_walls.png")
Extend grouping to colorize boxes by pushability
from grid_universe.renderer.texture import TextureRenderer, DEFAULT_TEXTURE_MAP, DEFAULT_GROUP_RULES
from grid_universe.renderer.texture import derive_groups # if you make a local wrapper
from grid_universe.state import State
from grid_universe.types import EntityID
def box_group_rule(state: State, eid: EntityID) -> str | None:
if eid in state.appearance and state.appearance[eid].name.name == "BOX":
return "box:push" if eid in state.pushable else "box:static"
return None
class BoxGroupingRenderer(TextureRenderer):
def render(self, state: State):
# Derive with custom ordering: keep default rules, then our box rule
groups = derive_groups(state, rules=DEFAULT_GROUP_RULES + [box_group_rule])
# Continue with normal rendering (groups are used internally by the default pipeline)
return super().render(state)
renderer.texture.render()
and replace the call to derive_groups(...)
with your own rules list.
Troubleshooting¶
-
“Icons overlap or are too big.”
- Adjust
subicon_percent
(e.g.,0.3
) to make corner icons smaller.
- Adjust
-
“My object didn’t render.”
-
Ensure its
AppearanceName
exists in the texture map. -
If your mapping points to a directory, confirm there are image files (
.png
/.jpg
/.jpeg
/.gif
) and that filesystem permissions are OK. -
Check that the path is relative to
asset_root
and thatasset_root
is correct.
-
-
“Group coloring looks odd or too saturated.”
apply_recolor_if_group
preserves tone (value), but you can adjust saturation behavior by modifyingrecolor_image_keep_tone
parameters (keep_saturation
,saturation_mix
,min_saturation
) if you expose or wrap them.
-
“Performance is slow when rendering many frames.”
-
Reuse the same
TextureRenderer
instance to exploit the cache. -
Avoid changing resolution between frames; that changes cache keys (
size
), causing misses. -
Reduce the number of moving overlays (move-speed triangles) if they change frequently; overlays are part of the cache key.
-
Consider pre-baking recolored versions for large batches of frames, or simplifying group rules.
-
-
“Textures look blurry.”
- Images are resized to the cell size. If your base assets are much smaller than the cell, upscaling will blur. Use higher-resolution assets or increase grid resolution.
-
“Random variant selection changes every run.”
- Set
state.seed
in yourState
(viaLevel
seed or generator seed). The directory selection usesRandom(state.seed)
for determinism.
- Set