Pure-Python 3D physics game engine — easy like pygame, beautiful like simulation.
Project description
forge3d
Pure-Python 3D game engine — easy like pygame, beautiful like simulation.
forge3d is a batteries-included 3D physics and game engine written entirely in Python — no external physics engines (no MuJoCo, PyBullet, or Bullet). Dynamics, collision, and contact are all solved by forge3d's own code, backed by NumPy and optional JAX acceleration.
┌──────────────────────────────────────────────────────────────┐
│ Public API (Facade) │
│ App · World · Body · Shape · Material · Viewer · Recorder │
│ Input · Key · OrbitCamera · FollowCamera │
└──────────────┬──────────────────────────┬────────────────────┘
┌──────────▼──────────┐ ┌──────────▼───────────────────┐
│ Physics core │ │ Rendering layer │
│ RNEA · CRBA · ABA │ │ RealtimeRenderer (OpenGL) │
│ SAT · GJK/EPA │ │ HQRenderer (ray-tracer) │
│ Impulse contact │ │ SceneSnapshot contract │
└─────────────────────┘ └──────────────────────────────┘
Features
| Category | Details |
|---|---|
| Game loop | App class — @on_start / @on_update / @on_render decorators |
| Input | Input snapshot — key_held, key_pressed, mouse_pos, scroll_delta |
| Camera | OrbitCamera, FollowCamera — orbit, zoom, pan, smooth follow |
| Physics | Rigid-body dynamics (RNEA / CRBA / ABA), semi-implicit Euler integrator |
| Collision | SAT OBB-OBB (15-axis), sphere, capsule, GJK/EPA convex-mesh; AABB broad-phase |
| Contact | Impulse solver, Coulomb friction, Baumgarte correction, warm-starting |
| Grasping | weld / release kinematic constraints; friction-based pinch grasp |
| Robots | UR5 6-DOF FK/IK, DH parameters, Jacobian |
| Rendering | Real-time PBR (Cook-Torrance, PCF shadows, albedo/normal textures); HQ software ray-tracer |
| RL | Gymnasium-compatible ReachEnv / PickPlaceEnv |
| Performance | JAX JIT+vmap: 2 000× throughput vs. NumPy; AABB broad-phase O(n log n) |
| Testing | 215+ automated tests; PyBullet baseline comparison |
| Typed | Fully annotated with py.typed marker |
Installation
# Core library (physics + rendering)
pip install pyforge3d
# With rendering dependencies
pip install "pyforge3d[render]"
# With RL dependencies
pip install "pyforge3d[rl]"
# Everything
pip install "pyforge3d[all]"
# Development install
git clone https://github.com/iruki-dev/forge3d
cd forge3d
pip install -e ".[dev]"
Install name vs import name:
pip install pyforge3d— thenimport forge3din your code.
Requirements: Python 3.12+, NumPy, SciPy, JAX (CPU wheel)
Quick start (15 lines)
import forge3d as f3d
world = f3d.World(gravity=(0, 0, -9.81))
world.add_ground()
box = world.add_box(size=(1, 1, 1), position=(0, 0, 5), mass=1.0)
viewer = f3d.Viewer(world, max_frames=90)
while viewer.is_open:
world.step(dt=1 / 60)
viewer.draw()
print(f"Box landed at z = {box.position[2]:.3f} m")
App-style game loop
The App class wraps the game loop into a decorator-driven flow — just like popular game
frameworks, but with full 3D physics built in.
import forge3d as f3d
app = f3d.App("Physics Sandbox", width=1280, height=720, fps=60)
ball = None # filled in on_start
@app.on_start
def setup(world: f3d.World) -> None:
global ball
world.add_ground()
ball = world.add_sphere(radius=0.4, position=(0, 0, 6),
material=f3d.Material(color="orange"))
@app.on_update
def update(world: f3d.World, dt: float, inp: f3d.Input) -> None:
# Jump on space
if inp.key_pressed(f3d.Key.SPACE):
world.apply_impulse(ball, (0, 0, 8))
# Nudge with arrow keys
if inp.key_held(f3d.Key.RIGHT):
world.apply_impulse(ball, (3 * dt, 0, 0))
if inp.key_held(f3d.Key.LEFT):
world.apply_impulse(ball, (-3 * dt, 0, 0))
app.run()
Recording a high-quality video
import forge3d as f3d
world = f3d.World(gravity=(0, 0, -9.81))
world.add_ground(material=f3d.Material(color="ground", roughness=0.8))
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=16, output="bounce.mp4")
rec.run(duration=3.0, dt=1 / 240, fps=60)
The same World code — only the renderer changes. That is the SceneSnapshot contract working.
Camera controls
import forge3d as f3d
world = f3d.World()
world.add_ground()
ball = world.add_sphere(radius=0.5, position=(0, 0, 3))
# Orbit camera starts 10 m back, 30° elevation
cam = f3d.OrbitCamera(target=(0, 0, 1), distance=10, elevation=30)
viewer = f3d.Viewer(world, max_frames=300)
while viewer.is_open:
inp = viewer.input # per-frame Input snapshot
cam.rotate(d_azimuth=inp.scroll_delta() * 5)
if inp.mouse_button(1): # right-drag to orbit
dx, dy = inp.mouse_delta()
cam.rotate(d_azimuth=dx * 0.5, d_elevation=-dy * 0.5)
viewer.set_camera(cam.to_snapshot())
world.step()
viewer.draw()
Robot arm control
import numpy as np
import forge3d as f3d
import forge3d.robot as f3r
world = f3d.World()
world.add_ground()
arm = f3r.load("ur5", base_position=(0, 0, 0))
world.add(arm)
arm.set_joints([0.0, -np.pi/2, np.pi/2, -np.pi/2, -np.pi/2, 0.0])
world.step()
ee_pos, ee_rot = arm.ee_pose()
print(f"End-effector: {ee_pos.round(4)}")
Reinforcement learning
from apps.robot_rl.envs.reach_env import ReachEnv
from stable_baselines3 import PPO
env = ReachEnv(render_mode=None) # headless, fast
model = PPO("MlpPolicy", env, verbose=1)
model.learn(total_timesteps=200_000)
model.save("reach_policy")
JAX batch stepping — 2 000× throughput:
import jax, jax.numpy as jnp
from forge3d.sim.jax_batch import batch_reach_reset, batch_reach_step
key = jax.random.PRNGKey(42)
q, tgt, obs = batch_reach_reset(key, n_envs=256)
q, obs, rew, done = batch_reach_step(q, tgt, jnp.zeros((256, 6)))
API reference
forge3d.App
| Member | Description |
|---|---|
App(title, width, height, fps, gravity) |
Create application |
@app.on_start |
fn(world) — called once before the loop |
@app.on_update |
fn(world, dt, inp) — called every frame before step() |
@app.on_render |
fn(world, viewer) — called after draw() |
app.world |
The managed World |
app.run(max_frames) |
Start the game loop |
forge3d.World
| Member | Description |
|---|---|
World(gravity) |
Create world; default gravity (0, 0, -9.81) |
add_ground(material, size, height) |
Static ground plane |
add_box(size, position, mass, …) |
Dynamic box body |
add_sphere(radius, position, mass, …) |
Dynamic sphere body |
add_capsule(radius, half_length, …) |
Dynamic capsule body |
add_mesh(mesh_data, …) |
Dynamic convex-hull body |
remove(body) |
Remove a body from simulation |
clear(keep_statics) |
Remove all (or all dynamic) bodies |
get_body(name) |
Find body by name |
bodies |
list[Body] — all current bodies |
step(dt) |
Advance physics by dt s (default 1/60) |
snapshot() |
Build SceneSnapshot for rendering |
apply_impulse(body, impulse) |
Δv = impulse / mass |
teleport(body, position, quat) |
Instantly move a body |
weld(body, anchor, local_offset) |
Kinematic attachment |
release(body) |
Remove weld constraint |
set_camera(position, target, …) |
Default camera pose |
time |
Elapsed simulation time in seconds |
forge3d.Body
| Member | Description |
|---|---|
position |
(3,) world-frame position |
velocity |
(3,) linear velocity m/s |
orientation |
(4,) quaternion [w, x, y, z] |
angular_velocity |
(3,) angular velocity rad/s |
name |
String label |
is_static |
bool |
mass |
float, kg |
apply_force(force) |
Accumulate force for next step() |
apply_torque(torque) |
Accumulate torque for next step() |
set_position(pos) |
Teleport shortcut |
set_velocity(vel) |
Velocity override |
forge3d.Input
| Member | Description |
|---|---|
key_held(key) |
bool — key currently pressed |
key_pressed(key) |
bool — key just went down this frame |
key_released(key) |
bool — key just went up this frame |
mouse_pos() |
(x, y) pixels |
mouse_delta() |
(dx, dy) pixels since last frame |
mouse_button(n) |
bool — mouse button 0/1/2 |
scroll_delta() |
float — mouse wheel tick this frame |
forge3d.Key — key name constants
f3d.Key.SPACE, Key.ESCAPE, Key.ENTER, Key.BACKSPACE, Key.TAB
Key.UP, Key.DOWN, Key.LEFT, Key.RIGHT
Key.W, Key.A, Key.S, Key.D # also Key.Q … Key.Z
Key.N0 … Key.N9
Key.F1 … Key.F12
Key.SHIFT, Key.CTRL, Key.ALT
forge3d.OrbitCamera
| Member | Description |
|---|---|
OrbitCamera(target, distance, azimuth, elevation, fov_deg) |
Create camera |
rotate(d_azimuth, d_elevation) |
Orbit around target |
zoom(delta) |
Multiply distance; delta > 0 → zoom in |
pan(dx, dy) |
Translate target in screen space |
position |
Current world-space eye position |
to_snapshot() |
CameraSnapshot for viewer.set_camera() |
forge3d.Shape
Shape.box(size=(1, 1, 1))
Shape.sphere(radius=0.5)
Shape.capsule(radius=0.2, half_length=0.5)
Shape.convex_mesh(mesh_data) # from forge3d.io.load_obj()
forge3d.Material
Material(color="red") # built-in preset
Material(color=(0.9, 0.4, 0.1)) # RGB tuple in [0, 1]
Material(color="default", roughness=0.3, metallic=0.7)
Material(texture_path="wall.png")
Built-in colour presets: "default", "red", "blue", "green", "orange",
"ground", "gold", "white".
forge3d.Viewer
viewer = f3d.Viewer(world, width=1280, height=720, max_frames=None)
viewer.is_open # False when window closed or max_frames reached
viewer.draw() # render one frame → ndarray (H×W×3 uint8)
viewer.input # current-frame Input snapshot
viewer.set_camera(cam) # override camera (CameraSnapshot or OrbitCamera)
viewer.run(dt, max_frames, collect_frames)
viewer.close()
forge3d.Recorder
rec = f3d.Recorder(world, mode="realtime", # or "hq"
resolution=(1920, 1080),
samples=64, # HQ only
output="sim.mp4")
rec.run(duration=5.0, dt=1/240, fps=60)
rec.run_policy(policy, env, duration=5.0) # SB3-compatible
Architecture
forge3d is designed around one principle: physics and rendering never know about each other.
world.step() # pure physics, no renderer import
snap = world.snapshot() # SceneSnapshot — pure data
frame = renderer.render(snap) # renderer consumes data only
The SceneSnapshot is the sole bridge. Swap the renderer without touching the physics.
Physics core → SceneSnapshot → RealtimeRenderer (OpenGL)
(RNEA/CRBA/ABA) → HQRenderer (ray-tracer)
→ headless / training (no snapshot)
Project structure
forge3d/
├── src/forge3d/
│ ├── __init__.py # Public API: World, App, Body, Shape, Material…
│ ├── app.py # App — game-loop abstraction
│ ├── input.py # Input, Key, _InputBuilder
│ ├── camera.py # OrbitCamera, FollowCamera
│ ├── facade.py # World, Body, Shape, Material facades
│ ├── viewer.py # Viewer (realtime render loop)
│ ├── recorder.py # Recorder (video capture)
│ ├── backend.py # NumPy ↔ JAX backend switch
│ ├── math/ # SE3, quaternion, spatial algebra
│ ├── dynamics/ # RNEA, CRBA, ABA
│ ├── collision/
│ │ ├── detection.py # SAT + AABB broad-phase
│ │ ├── gjk.py # GJK distance / intersection
│ │ └── epa.py # EPA penetration depth
│ ├── contact/
│ │ └── solver.py # Impulse solver (PGS + Coulomb friction)
│ ├── model/ # RigidBodyModel, DH joints, kinematics
│ ├── sim/
│ │ ├── world.py # PhysicsWorld (step, snapshot, CRUD)
│ │ ├── jax_batch.py # JAX JIT+vmap batch physics
│ │ └── domain_rand.py # Domain randomisation
│ ├── robot/ # Robot, UR5 preset, FK/IK
│ ├── render/
│ │ ├── snapshot.py # SceneSnapshot, Transform, Material…
│ │ ├── base.py # Renderer ABC
│ │ ├── realtime/ # OpenGL rasteriser (PBR, PCF shadow)
│ │ └── hq/ # Software ray-tracer (Blinn-Phong, AO, AA)
│ ├── io/
│ │ ├── obj_loader.py # Pure-Python OBJ parser
│ │ └── mesh_data.py # MeshData, convex_hull_inertia
│ └── py.typed # PEP 561 type marker
├── apps/robot_rl/ # RL application (Gymnasium envs, training)
├── examples/ # Self-contained examples (≤15 lines each)
├── tests/ # 215+ automated tests
├── validation/ # PyBullet / MuJoCo baseline comparison
├── assets/ # 3D models, textures
├── CHANGELOG.md
├── CONTRIBUTING.md
├── LICENSE
└── pyproject.toml
Validation
| Check | Result |
|---|---|
| PyBullet acceleration comparison (50 pairs) | max_abs < 2e-11 ✅ |
| Energy conservation (torque-free, no damping) | deviation < 0.1 % ✅ |
| Pendulum period vs. closed-form | error < 0.01 % ✅ |
| JAX ↔ NumPy FK consistency | max_diff < 1e-16 ✅ |
| Restitution coefficient vs. theory | error < 1.5 % ✅ |
| Coulomb friction threshold | ✅ |
Backend switching
ENGINE_BACKEND=numpy python my_sim.py # NumPy (default, CPU)
ENGINE_BACKEND=jax python my_sim.py # JAX JIT+vmap
The physics core is identical under both backends. JAX enables JIT compilation and
vmap for batched multi-environment rollouts.
Testing
# Single file (recommended for iteration)
pytest tests/test_collision.py -q
# Full suite (skips slow training tests)
pytest tests/ --ignore=tests/test_p9_training.py -q
# With coverage
pytest --cov=forge3d tests/ -q
Contributing
See CONTRIBUTING.md. In short:
- Fork → feature branch → PR
ruff check . && ruff format .must passmypy src/must pass- New physics code needs a conservation-law or analytical-solution test
- New public API needs an example in
examples/
License
MIT — see LICENSE.
Project details
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file pyforge3d-1.0.0.tar.gz.
File metadata
- Download URL: pyforge3d-1.0.0.tar.gz
- Upload date:
- Size: 396.2 kB
- Tags: Source
- Uploaded using Trusted Publishing? Yes
- Uploaded via: twine/6.1.0 CPython/3.13.12
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
83b3e87f0f3012de28bf02c69ca7f5641353c6f17db1ee3d89cf068877fe69be
|
|
| MD5 |
c5e18c594e2bd779dfb999a38a5e0293
|
|
| BLAKE2b-256 |
b623961844981fb2626683943922567b434b7285e300edebf54ec475c461be0c
|
Provenance
The following attestation bundles were made for pyforge3d-1.0.0.tar.gz:
Publisher:
release.yml on iruki-dev/forge3d
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
pyforge3d-1.0.0.tar.gz -
Subject digest:
83b3e87f0f3012de28bf02c69ca7f5641353c6f17db1ee3d89cf068877fe69be - Sigstore transparency entry: 1708632829
- Sigstore integration time:
-
Permalink:
iruki-dev/forge3d@b79365999bee3f30b97cd5a50d49ae87c36fc426 -
Branch / Tag:
refs/tags/v1.0.3 - Owner: https://github.com/iruki-dev
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
release.yml@b79365999bee3f30b97cd5a50d49ae87c36fc426 -
Trigger Event:
push
-
Statement type:
File details
Details for the file pyforge3d-1.0.0-py3-none-any.whl.
File metadata
- Download URL: pyforge3d-1.0.0-py3-none-any.whl
- Upload date:
- Size: 128.5 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? Yes
- Uploaded via: twine/6.1.0 CPython/3.13.12
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
055a1747d5d9f4d28d3bf516ba1165489ca5348de8e7c1d1a7fbea9ce053ccd4
|
|
| MD5 |
7ce8dce60f63ef6ff6df795360fab996
|
|
| BLAKE2b-256 |
213a4d25c73da1a6c59be6e45a24cd14d09979354099cb60741e333aaf8b277e
|
Provenance
The following attestation bundles were made for pyforge3d-1.0.0-py3-none-any.whl:
Publisher:
release.yml on iruki-dev/forge3d
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
pyforge3d-1.0.0-py3-none-any.whl -
Subject digest:
055a1747d5d9f4d28d3bf516ba1165489ca5348de8e7c1d1a7fbea9ce053ccd4 - Sigstore transparency entry: 1708632906
- Sigstore integration time:
-
Permalink:
iruki-dev/forge3d@b79365999bee3f30b97cd5a50d49ae87c36fc426 -
Branch / Tag:
refs/tags/v1.0.3 - Owner: https://github.com/iruki-dev
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
release.yml@b79365999bee3f30b97cd5a50d49ae87c36fc426 -
Trigger Event:
push
-
Statement type: