Skip to content

Rendering and Camera

magmacrunchmedia edited this page Aug 24, 2026 · 3 revisions

Rendering and Camera

CanvasRenderer

from texastoast import CanvasRenderer

renderer = CanvasRenderer(game.canvas, 640, 480)

renderer.width, renderer.height    # the viewport, readable since 0.4.0

The width and height are the viewport, and should match the canvas. The renderer owns a Camera sized to match.

Pass the renderer to the UI widgets rather than repeating the size — they read it back from these properties. See UI Components.

Drawing

renderer.clear()                                  # call first, every frame

renderer.draw_tilemap(tilemap, tile_colors)
renderer.draw_rect(x, y, w, h, color, tag="")
renderer.draw_image(x, y, photo_image, anchor="nw", tag="")
renderer.draw_text(x, y, text, fill="#fff", font=("Courier", 10))
renderer.draw_hud_text(x, y, text, fill="#fff", font=("Courier", 10))

Everything is world space and camera-relative except draw_hud_text, which is screen space and ignores the camera. That is the whole difference between the two text calls.

draw_text and draw_hud_text pass extra keyword arguments straight to tkinter's create_text, so anchor, width, justify and friends all work.

draw_tilemap

renderer.draw_tilemap(tilemap, {0: "#7cb342", 1: "#5d4037"})
renderer.draw_tilemap(tilemap, colors, skip_tiles={0})

A tile is drawn when its id has an entry in tile_colors. Ids with no entry are left transparent — that is how you make a tile invisible, and it is why there is no special-cased "empty" id. skip_tiles skips ids that do have a color, for when you want to hide a layer temporarily.

Only tiles inside the camera's view are drawn, so map size costs you nothing at render time.

In 0.1.x, id 0 was skipped unconditionally regardless of the color map. See Migrating to 0.2.0.

Draw order

The canvas paints in call order, so draw back to front:

def render():
    renderer.clear()
    renderer.draw_tilemap(tilemap, TILE_COLORS)   # ground
    renderer.draw_rect(...)                       # entities
    hud.render()                                  # overlays, last
    dialogue.render()
    menu.render()
    renderer.present()                            # end every frame with this

clear() deletes everything, including dialogue and menu graphics — which is why the widgets are frame-driven and you call their render() after it. See UI Components.

The backend seam

Added in 0.4.0. CanvasRenderer satisfies two typing.Protocols that describe what the engine asks of a drawing backend:

from texastoast import Renderer, UISurface

isinstance(renderer, Renderer)    # True — world-space drawing
isinstance(renderer, UISurface)   # True — screen-space widget drawing

They are structural, so a backend implements them by having the methods; there is nothing to subclass. Renderer covers camera, width, height, clear(), present(), and the draw_* calls. UISurface covers begin_group(), clear_group(), ui_rect() and ui_text() — the screen-space half the UI widgets are written against.

tkinter is the only backend today. The protocols exist so that the eventual SDL/framebuffer backend — the one a console-class device would need — has a contract to implement rather than a rewrite to negotiate.

present()

renderer.present()    # no-op on tkinter

The tkinter Canvas is retained-mode, so there is nothing to flip and present() does nothing. Call it at the end of every render function anyway. A buffered backend swaps its off-screen buffer there, and that is the one call which cannot be retrofitted later without editing every game ever written against the engine.

Groups

renderer.begin_group("hud")            # discard what this group drew last frame
renderer.ui_rect(8, 8, 120, 10, fill="#333333", group="hud")
renderer.ui_text(8, 8, "HP", fill="#cccccc", group="hud")
renderer.clear_group("hud")            # remove it entirely

On tkinter a group is a canvas tag. On an immediate-mode backend begin_group would be a no-op, because clear() already wiped the frame. Widgets are written against the group model so the same widget code works either way.

Camera

camera = renderer.camera

camera.follow(target_x, target_y, map_width=800, map_height=600, dt=dt)
camera.set_position(x, y)
camera.world_to_screen(wx, wy)   # -> (sx, sy)
camera.screen_to_world(sx, sy)   # -> (wx, wy)
camera.is_visible(x, y, w, h)    # -> bool

follow centers on the target and eases toward it. Pass map_width and map_height to clamp the view inside the map so it never shows past the edge; omit them (or pass 0) for an unclamped camera.

camera.x/camera.y are the top-left of the view in world coordinates, and are plain attributes you can read or set.

dt is required

Pass the frame's dt to follow. As of 0.5.0 omitting it raises TypeError (it warned throughout 0.4.x).

camera.follow(player.center_x, player.center_y, dt=dt)   # correct
camera.follow(player.center_x, player.center_y)          # TypeError

dt stays last in the signature, so correct 0.4.x call sites — keyword dt=dt and full-positional five-argument calls — work unchanged.

The reason is the same one behind Entity.move() taking dt: without it the easing is applied once per frame, so the camera converges twice as fast at 60 fps as at 30, and the game feels different on different machines. With dt, smoothing is treated as a per-frame factor at 30 fps, converted to a time constant and integrated over the real elapsed time — so the camera lags by the same distance at any frame rate.

Smoothing

Camera.smoothing defaults to 0.1: ease 10% of the remaining distance per frame at 30 fps.

Camera(640, 480, smoothing=1.0)   # instant snap
Camera(640, 480, smoothing=0.05)  # slow, floaty

30 fps is the reference rate simply because that is where the old per-frame behaviour and the dt behaviour agree exactly, which made the 0.3.0 change a no-op for anyone already running at the default frame rate.

SpriteSheet

from texastoast import SpriteSheet

sheet = SpriteSheet("characters.png", frame_width=16, frame_height=16)
frame = sheet.get_frame(game.root, col=0, row=0)
renderer.draw_image(player.x, player.y, frame)

Frames are cropped on demand and cached, so calling get_frame every frame is fine. An out-of-range col/row raises ValueError rather than returning a blank image.

sheet.cols, sheet.rows, sheet.total_frames
sheet.frame_width, sheet.frame_height

With Pillow installed (pip install "texastoast[sprites]") cropping is pixel-accurate and handles transparency. Without it, tkinter's PhotoImage.copy() is used, which is more limited — notably it only reads GIF and PNG and has no alpha compositing.

Keeping images alive

tkinter drops a PhotoImage as soon as Python stops referencing it, and the image silently disappears from the canvas. SpriteSheet holds its own frames, but if you build images yourself, keep them:

frames = [make_frame(i) for i in range(4)]   # module-level, not a local

The same applies to load_image:

from texastoast.render import load_image

background = load_image(game.root, "bg.png")   # must stay referenced

Clone this wiki locally