Pseudo-3D (Mode-7 & raycasting)
Two ways to get a 3D-looking world out of a 2D engine, both rendered into a buffer-less StripDraw view so they cost zero retained RAM. picogame_mode7 gives you a receding ground plane (a racer track, a flying-carpet floor); picogame_ray gives you walls and corridors (a dungeon, a maze). See /reference/ for the signatures.
| You want | Reach for | How it draws |
|---|---|---|
| A ground / floor that recedes to the horizon | picogame_mode7.Camera (drives the C Canvas.mode7) | C primitive, per-scanline - fast |
| Walls / a first-person corridor | picogame_ray.Raycaster | pure Python DDA - slower, tune it |
Both draw into the below-horizon rows and leave the sky to you; you fill the rows above the horizon yourself (a flat colour, a gradient, or fx.Sky).
picogame_mode7
Section titled “picogame_mode7”
A Mode-7 perspective floor: each screen row below the horizon samples one distance into a texture, so a flat top-down image reads as a ground plane stretching to the horizon. All the maths lives in this Python helper; the per-scanline fill is the C engine primitive Canvas.mode7, so it is cheap.
import picogame_mode7 as m7
m7.Camera - perspective ground plane
Section titled “m7.Camera - perspective ground plane”Camera(fov=0.66)- holds the field of view (higher = wider, more fish-eye). Reuse one camera; pass the pose todraw()each frame..draw(canvas, texture, x, y, angle, horizon, height, y_off=0)- fillcanvas(aCanvasor aStripDrawview) belowhorizonwith a receding view oftexture.x/yare the camera position in world (tile) units,angleis the heading in radians,horizonis the screen row of the horizon line, andheightis how high the camera sits (bigger = the ground recedes slower, so you see further). In aStripDrawcallback passy_off=vyso the perspective divide uses the absolute screen row.
texture must have power-of-2 width and height, and one world unit = one full texture tile. A texture that tiles seamlessly (grass, a road with rumble stripes) can use a large height and wrap forever; a single non-tiling image (one closed circuit) needs a small height or the far distance repeats it.
import picogame as pgimport picogame_mode7 as m7
cam = m7.Camera(fov=0.9)
def ground(view, vx, vy, vw, vh): for r in range(vh): # sky: fill rows above the horizon yourself if vy + r < HORIZON: view.fill_rect(0, r, vw, 1, SKY) cam.draw(view, TRACK, car.x, car.y, car.heading, HORIZON, 5.0, y_off=vy)
scene.add(pg.StripDraw(ground, 0, 0, W, H)) # 0 retained bytes# ...each frame: move car.x/y/heading, then scene.refresh()picogame_ray
Section titled “picogame_ray”
A Wolfenstein-style raycaster: one DDA ray per screen column finds the nearest wall, and each column is drawn as a distance-shaded vertical bar. Pure Python, no firmware change - the walls are solid fill_rect columns into a StripDraw view.
import picogame_ray
picogame_ray.Raycaster - first-person wall caster
Section titled “picogame_ray.Raycaster - first-person wall caster”Raycaster(world, wall_colors, sky, floor, fov=0.66, stride=2)-worldis a list of equal-length strings ('0'= empty,'1'..'9'= wall types);wall_colorsmaps each wall type to a(face_colour, side_colour)pair (the side colour a touch darker = a free depth cue);sky/floorare wire-RGB565 backgrounds.stridecasts one ray per N columns - the perf/quality knob (see below)..cast(px, py, ang, sw, sh)- cast the rays for camera position(px, py)and headingang, caching each column’s wall span. Call once per frame beforescene.refresh()..draw(view, vx, vy, vw, vh)- theStripDrawcallback: paints the sky/floor bands and the wall columns. Adjacent identical columns are merged into onefill_rect..solid(x, y)- is the map cell at integer(x, y)a wall? Use it for movement collision (out of bounds counts as a wall)..zbuf- aftercast(), a list of each column’s wall distance (the depth bufferproject_spritetests against)..project_sprite(sx, sy, margin=0.2)- project a world-space point(sx, sy)onto the screen for the lastcast(). Returns(screen_x, size, depth), orNoneif the point is behind the camera or hidden by a nearer wall.sizeis the on-screen height in px at the wall scale - setsprite.scale = size / bitmap_height; sort your sprites bydepthfar-to-near so nearer ones draw on top.marginis z-test slack so a sprite flush against a wall is not culled.
import picogame as pgimport picogame_ray
MAP = ["1111111111", "1000000001", "1011100201", "1000000001", "1111111111"]WALLS = {1: (pg.rgb565(150, 150, 160), pg.rgb565(95, 95, 110)), 2: (pg.rgb565(170, 90, 60), pg.rgb565(110, 55, 35))}
rc = picogame_ray.Raycaster(MAP, WALLS, pg.rgb565(30, 30, 48), pg.rgb565(40, 34, 30), stride=2)scene.add(pg.StripDraw(rc.draw, 0, 0, W, H))# ...each frame:rc.cast(px, py, ang, W, H) # cast, then paintscene.refresh()# ...to move without walking through walls:if not rc.solid(int(nx), int(py)): px = nxEnemies & pickups - billboard sprites
Section titled “Enemies & pickups - billboard sprites”The raycaster draws only walls; enemies and pickups are ordinary Sprites scaled and placed each frame from project_sprite, depth-tested against the walls so they hide behind corners. Add the sprites to the Scene after the StripDraw so they layer on top, and reuse a picogame_pool.Pool rather than creating them per frame.
GUYS = [Enemy(3.5, 2.5), Enemy(6.5, 3.5)] # world (tile) positionsfor g in GUYS: g.spr = pg.Sprite(DEMON_BMP, -40, -40) # DEMON_BMP is 8 px tall g.spr.anchor = (0.5, 0.5) scene.add(g.spr) # AFTER the StripDraw -> on top of walls
def frame(): rc.cast(px, py, ang, W, H) # cast walls (fills rc.zbuf) GUYS.sort(key=lambda g: -((g.x - px) ** 2 + (g.y - py) ** 2)) # far-to-near for g in GUYS: p = rc.project_sprite(g.x, g.y) if p: sx, size, _ = p g.spr.move(sx, HORIZON) # centre on the horizon row g.spr.scale = size / 8.0 # bitmap is 8 px tall g.spr.visible = True else: g.spr.visible = False # off-screen or behind a wall scene.refresh()