Tutorial 2 — Realtime & HQ Rendering¶
forge3d has two renderers sharing the same API: a realtime OpenGL rasteriser and a high-quality software ray-tracer. Both consume a SceneSnapshot — the physics code never changes.
Realtime viewer¶
import forge3d as f3d
world = f3d.World()
world.add_ground()
world.add_box(size=(1, 1, 1), position=(0, 0, 3), mass=1.0,
material=f3d.Material(color="red"))
viewer = f3d.Viewer(world, width=1280, height=720)
while viewer.is_open:
world.step(dt=1/60)
viewer.draw()
Viewer controls (default)¶
| Action | Control |
|---|---|
| Orbit | Left-drag |
| Pan | Middle-drag |
| Zoom | Scroll |
| Close | Esc or window X |
Camera¶
cam = f3d.OrbitCamera(
target=(0, 0, 1), # look-at point
distance=10, # metres from target
azimuth=45, # degrees
elevation=30, # degrees above horizontal
fov_deg=60,
)
viewer = f3d.Viewer(world)
while viewer.is_open:
inp = viewer.input
# Right-drag to orbit
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.1)
viewer.set_camera(cam.to_snapshot())
world.step()
viewer.draw()
Materials¶
# Built-in colour presets
f3d.Material(color="red")
f3d.Material(color="blue")
f3d.Material(color="gold")
f3d.Material(color="ground")
# RGB tuple [0, 1]
f3d.Material(color=(0.9, 0.4, 0.1))
# PBR parameters
f3d.Material(color="white", roughness=0.1, metallic=0.9) # mirror-like
# Emissive glow (lava, neon, sci-fi energy cores)
f3d.Material(color=(1.0, 0.3, 0.0), emissive=3.0) # glowing orange
# Texture
f3d.Material(texture_path="assets/textures/brick.png")
emissive is a scalar intensity multiplier on the material colour.
Set to 0.0 (default) for no emission; typical values are 1.0 – 5.0.
High-quality offline recording¶
import forge3d as f3d
world = f3d.World(gravity=(0, 0, -9.81))
world.add_ground(material=f3d.Material(color="ground", roughness=0.8))
ball = world.add_sphere(
radius=0.4, position=(0, 0, 4.4), mass=1.0, restitution=0.8,
material=f3d.Material(color="orange"))
world.set_camera(position=(4, -7, 3), target=(0, 0, 1))
rec = f3d.Recorder(
world,
mode="hq",
resolution=(1920, 1080),
samples=64, # rays per pixel — higher = better quality, slower
output="bounce.mp4",
)
rec.run(duration=3.0, dt=1/240, fps=60)
samples=64 gives cinema-quality anti-aliasing; samples=4 is fast preview.
Recording a policy rollout¶
from stable_baselines3 import PPO
from forge3d.sim.jax_batch import make_reach_env
env = make_reach_env()
model = PPO.load("reach_policy")
rec = f3d.Recorder(world, mode="hq", output="rollout.mp4")
rec.run_policy(model, env, duration=5.0)
Heightfield terrain rendering (v1.1.0)¶
Before v1.1.0, world.add_terrain() was collision-only — the terrain was invisible.
In v1.1.0, terrain automatically appears as a shaded triangle mesh with shadow casting:
import numpy as np
rng = np.random.default_rng(42)
heights = (
3.0 * np.sin(np.linspace(0, 2*np.pi, 48))[:, None] *
np.cos(np.linspace(0, 3*np.pi, 48))[None, :] +
rng.uniform(-0.3, 0.3, (48, 48))
).astype(np.float32)
heights = np.clip(heights - heights.min(), 0, 8)
world.add_terrain(
heights=heights,
cell_size=2.5,
origin=(-60, -60, 0),
material=f3d.Material(color=(0.28, 0.42, 0.16), roughness=0.95),
)
viewer = f3d.Viewer(world)
while viewer.is_open:
world.step()
viewer.draw() # terrain rendered as smooth mesh with normals and shadows
The terrain mesh is generated lazily on the first render call, cached, and reused every frame.
HUD text overlay (v1.1.0)¶
Viewer.draw_text() renders a text overlay after the 3D scene:
score = 0
while viewer.is_open:
world.step()
viewer.draw() # 3D scene
viewer.draw_text(f"Score: {score}", x=10, y=10, size=24)
viewer.draw_text("PAUSE", x=640, y=360,
size=48, color=(1, 0.8, 0), anchor="center")
viewer.draw_text("ESC to quit", x=1270, y=10,
size=18, anchor="topright")
| Parameter | Default | Description |
|---|---|---|
x, y |
10, 10 |
Pixel position |
size |
20 |
Font size (px) |
color |
(1,1,1) |
RGB in [0,1] |
bg_alpha |
0.6 |
Background box opacity |
anchor |
"topleft" |
"topleft" / "center" / "topright" |
FollowCamera — local frame (v1.1.0)¶
For vehicle games, use frame="local" so the camera always stays behind the body:
car = world.add_box(size=(2, 1, 0.5), position=(0, 0, 0.3), mass=20)
cam = f3d.FollowCamera(
car,
offset=(-8, 0, 3), # 8 m behind, 3 m up (car's local frame)
frame="local",
smoothing_hz=8, # snappy tracking
fov_deg=60,
)
while viewer.is_open:
world.step()
viewer.set_camera(cam.to_snapshot(dt=viewer.dt))
viewer.draw()
With frame="world" (default), the camera offset is always world-aligned regardless
of where the car points. With frame="local", the camera rotates with the car.
Windowed game viewer (v2.0)¶
Pass title to open a real OS window. The API is identical to the headless
viewer — only the rendering backend changes.
import forge3d as f3d
world = f3d.World(gravity=(0, 0, -9.81))
world.add_ground()
car = world.add_box(size=(4, 2, 0.55), position=(0, 0, 0.4), mass=1200,
friction=0.05,
material=f3d.Material(color=(0.1, 0.4, 0.9)))
car.angular_damping = 4.0
cam = f3d.FollowCamera(car, offset=(-10, 0, 3.5), frame="local",
smoothing_hz=9, fov_deg=65)
viewer = f3d.Viewer(world, width=1280, height=720, title="Drive")
while viewer.is_open:
inp = viewer.input
if inp.key_held(f3d.Key.W):
# rotate forward direction by quaternion, project to XY
import numpy as np
w, x, y, z = car.orientation
fwd = np.array([1-2*(y*y+z*z), 2*(x*y+w*z), 0.0])
fwd /= max(np.linalg.norm(fwd), 1e-6)
car.apply_force(fwd * 9000)
if inp.key_held(f3d.Key.A):
car.apply_torque((0, 0, 8000))
if inp.key_held(f3d.Key.D):
car.apply_torque((0, 0, -8000))
world.step(dt=viewer.dt)
viewer.set_camera(cam.to_snapshot(dt=viewer.dt))
viewer.draw()
speed = float(np.linalg.norm(car.velocity[:2])) * 3.6
viewer.draw_text(f"{speed:5.0f} km/h", x=640, y=680, size=32,
color=(1, 1, 1), anchor="center")
viewer.draw_text("W/A/D to drive ESC to quit",
x=10, y=10, size=16, color=(0.8, 0.8, 0.8))
What changes with title:
| Property | Headless (no title) | Windowed (with title) |
|---|---|---|
viewer.dt |
Fixed 1/60 s |
Real wall-clock frame time |
viewer.input |
Always empty | Live keyboard + mouse |
viewer.is_open |
Frame-count limit | Window open + ESC not pressed |
viewer.draw() |
Returns (H,W,3) ndarray |
Returns None, flips display |
draw_text() |
One-shot texture per call | Cached; unchanged text is free |
ESC and the window close button automatically end the loop.