forge3d.Viewer & Recorder¶
Viewer — realtime rendering¶
Viewer ¶
Realtime viewer for a forge3d World.
Parameters¶
world : World to observe.
mode : "realtime" is the only supported mode.
width, height : Frame dimensions in pixels.
title : If given, open a real OS window (windowed mode).
If None (default), render offscreen (headless mode).
fps : Target frames per second in windowed mode (default 60).
max_frames : Stop after this many frames in both windowed and headless
modes. None (default) = run until window close (windowed)
or 300 frames (headless).
is_open
property
¶
True until the viewer is closed.
Returns False when:
- the user closes the window or presses ESC (windowed), or
max_framesframes have been rendered (both modes), or- the headless frame limit (300) is reached without an explicit
max_framesvalue.
input
property
¶
Per-frame keyboard/mouse state snapshot.
Windowed mode: updated at the start of each :meth:draw call from
real OS events. Headless mode: always returns an empty snapshot.
dt
property
¶
Frame time in seconds.
Windowed mode: actual wall-clock time of the previous frame.
Headless mode: fixed 1/60 s.
__init__ ¶
__init__(world: World, mode: str = 'realtime', *, width: int = 800, height: int = 600, title: str | None = None, fps: int = 60, max_frames: int | None = None, controls: Any = None, shadow_resolution: int = 0, sky_color: tuple | None = None, show_grid: bool = True) -> None
draw ¶
Render one frame.
Windowed mode
Processes OS events, flips the previous frame, renders the new
frame. Returns None. Call :meth:draw_text after this to
overlay HUD elements — they appear on the same visual frame.
Headless mode
Returns an (H, W, 3) uint8 ndarray. Updates
:attr:input from the internal event builder.
draw_text ¶
draw_text(text: str, x: int | None = None, y: int | None = None, size: int = 20, color: tuple = (1.0, 1.0, 1.0), bg_alpha: float = 0.6, anchor: str = 'topleft', margin: int = 10) -> None
Render a HUD text overlay on top of the current frame.
Must be called after :meth:draw.
Windowed mode: GPU resources are cached — identical calls in successive frames reuse the same texture (zero re-upload).
Parameters¶
text : Text to display.
x, y : Pixel position of the anchor point. When omitted,
auto-placed from anchor + margin.
size : Font size in pixels.
color : RGB in [0, 1].
bg_alpha : Dark background rectangle opacity [0, 1].
anchor : "topleft" / "topright" / "topcenter" /
"center" / "bottomleft" / "bottomcenter" /
"bottomright". Hyphens and underscores are accepted
(e.g. "top-left").
margin : Edge margin in pixels when x or y is auto-placed.
Examples¶
viewer.draw_text("SCORE 42", anchor="topleft") # top-left corner viewer.draw_text("PRESS ENTER", anchor="center") # screen centre viewer.draw_text("3 lives", anchor="topright") # top-right corner
set_camera ¶
Set the camera for subsequent :meth:draw calls.
Parameters¶
cam : :class:~forge3d.render.snapshot.CameraSnapshot or a camera
controller (:class:~forge3d.camera.OrbitCamera etc.).
run ¶
run(dt: float | None = None, max_frames: int | None = None, collect_frames: bool = False) -> list[np.ndarray] | None
Step + draw in a loop until the viewer closes.
Parameters¶
dt : Physics step size (default: 1/60 s). max_frames : Override the frame limit for this run. collect_frames: If True, return all rendered frames (headless only).
step_once ¶
Advance one physics step and draw one frame (useful for debugging).
Windowed mode (v2.0)¶
Pass title to open a real OS window instead of rendering offscreen.
Everything else — is_open, input, draw(), draw_text() — works
identically between windowed and headless modes.
Shadow resolution¶
The windowed renderer uses shadow_resolution=1024 by default (raised from 512 in v2.1).
Pass a higher value for sharper close-up shadows:
viewer = f3d.Viewer(
world,
width=1920, height=1080,
title="High Quality",
shadow_resolution=2048, # 2K shadow map
)
shadow_resolution |
Quality | Cost |
|---|---|---|
| 512 | Low (old default) | Fast |
| 1024 | Medium (default) | Moderate |
| 2048 | High | Noticeable on old GPUs |
Without title |
With title |
|---|---|
| Offscreen FBO (headless, CI-safe) | Real OS window via glfw |
draw() returns (H,W,3) frame |
draw() returns None, flips to screen |
dt = fixed 1/60 s |
dt = real measured wall time |
is_open = False after max_frames (default 300) |
is_open = False when window closed or max_frames reached |
input = always empty |
input = live keyboard + mouse |
Game loop with windowed Viewer¶
import forge3d as f3d
world = f3d.World(gravity=(0, 0, -9.81))
world.add_ground()
car = world.add_box(size=(4, 2, 0.5), position=(0, 0, 0.3), mass=1200)
cam = f3d.FollowCamera(car, offset=(-10, 0, 3.5), frame="local")
viewer = f3d.Viewer(world, width=1280, height=720, title="Drive")
while viewer.is_open:
inp = viewer.input # real keyboard / mouse
if inp.key_held(f3d.Key.W):
car.apply_force((8000, 0, 0)) # engine force
world.step(dt=viewer.dt) # real wall-clock dt
viewer.set_camera(cam.to_snapshot(dt=viewer.dt))
viewer.draw() # render 3D + flip
viewer.draw_text(f"Speed: {speed:.0f} km/h",
x=640, y=660, size=32, anchor="center")
ESC or the window close button automatically sets viewer.is_open = False.
draw_text caching¶
draw_text() caches GPU resources keyed by (text, x, y, size, color, anchor).
Unchanged elements cost virtually nothing on repeated frames. Dynamic text
(counters, timers) creates a new texture each frame — keep the number of
changing lines small for maximum performance.
Usage examples¶
Simple render loop (headless)¶
world = f3d.World()
world.add_ground()
world.add_box(size=(1,1,1), position=(0,0,3), mass=1,
material=f3d.Material(color="red"))
viewer = f3d.Viewer(world, width=1280, height=720)
while viewer.is_open:
world.step(dt=1/60)
viewer.draw()
HUD text overlay¶
while viewer.is_open:
world.step()
viewer.draw() # 3D scene first
viewer.draw_text(f"Score: {score}", x=10, y=10, size=24)
viewer.draw_text("PAUSED", x=640, y=360, size=48,
color=(1.0, 0.8, 0.2), anchor="center")
viewer.draw_text(f"Speed: {speed:.0f} km/h",
x=1270, y=10, size=20, anchor="topright")
Terrain rendering¶
import numpy as np
heights = np.sin(np.linspace(0, 4*np.pi, 32)).reshape(32, 1) \
* np.ones((1, 32)) * 2
heights = heights.astype(np.float32)
world.add_terrain(heights=heights, cell_size=2.0, origin=(-32,-32,0),
material=f3d.Material(color=(0.28, 0.45, 0.18)))
viewer = f3d.Viewer(world)
while viewer.is_open:
world.step()
viewer.draw()
Camera control¶
cam = f3d.OrbitCamera(target=(0,0,1), distance=10, elevation=30)
while viewer.is_open:
inp = viewer.input
if inp.mouse_button(1):
dx, dy = inp.mouse_delta()
cam.rotate(d_azimuth=dx*0.5, d_elevation=-dy*0.5)
cam.zoom(inp.scroll_delta() * 0.5)
viewer.set_camera(cam.to_snapshot())
world.step()
viewer.draw()
HQ video recording¶
world.set_camera(position=(4, -7, 3), target=(0, 0, 1))
rec = f3d.Recorder(
world,
mode="hq",
resolution=(1920, 1080),
samples=64,
output="scene.mp4",
)
rec.run(duration=5.0, dt=1/240, fps=60)
Recorder — offline video capture¶
Recorder ¶
Capture a World's simulation as video frames.
Parameters¶
world : World to record. May be None when only run_policy
will be used (no free-simulation recording).
mode : 'realtime' or 'hq'.
resolution : (width, height) in pixels.
output : Output file path (mp4 via imageio, or image sequence dir).
samples : Ray samples per pixel for HQ mode (ignored in realtime).
run ¶
Simulate for duration seconds and save video.
Physics runs at dt timestep; frames are saved at fps rate.
So phys_steps_per_frame = round(1/(fps*dt)).
Parameters¶
duration : Simulation duration in seconds. dt : Physics integration step. fps : Output video frame rate.
run_policy ¶
run_policy(policy: Any, env: Any, duration: float = 5.0, fps: float = 24.0, deterministic: bool = True, seed: int = 0) -> None
Record a trained policy rollout from a Gymnasium-compatible env.
Parameters¶
policy : SB3 model or any object with predict(obs) method.
env : Gymnasium env (must support render_mode="rgb_array").
duration : Recording duration in seconds.
fps : Output video frame rate.
deterministic : Use deterministic policy actions.
seed : Env reset seed.