Skip to main content

Pure-Python 3D physics game engine — own dynamics, own rules, no compromises.

Project description

forge3d

Pure-Python 3D physics game engine — own dynamics, own rules, no compromises.

PyPI version Python 3.12+ License: MIT CI Tests

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, axis(), mouse_pos, scroll_delta
Camera OrbitCamera (with handle_input()), FollowCamera — orbit, zoom, pan, smooth follow
Character CharacterControllermove(), jump(), glide(), move_camera_relative()
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; BVH (Rust)
Contact Impulse-based PGS solver, Coulomb friction, Baumgarte stabilization
Joints Hinge, Ball, Prismatic, Fixed, Distance, Spring; weld / release
Terrain Heightfield collision + rendering (32×32 to 512×512)
Robots UR5 6-DOF FK/IK, DH parameters, Jacobian
Rendering RealtimeRenderer (OpenGL 3.3 PBR), DeferredRenderer (OpenGL 4.3 + SSAO + CSM + Bloom), HQRenderer (software ray-tracer)
ECS EntityWorld, Transform, Rigidbody, MeshRenderer, Script, PhysicsSystem
Animation Skeleton, AnimationClip, AnimationPlayer, BlendTree, FABRIKSolver
Audio AudioSystem, AudioSource, AudioClip — OpenAL, headless null fallback
Particles ParticleEmitter, ParticleSystemsparks, smoke, debris, rain presets
Scene SceneManager, SceneNode, Prefab — additive scene transitions
UI Canvas, DebugPanel, InspectorPanel, HierarchyPanel
Editor EditorApp — Play/Pause/Step, transform gizmos
RL Gymnasium-compatible environments; JAX JIT+vmap batch rollouts
Performance JAX JIT+vmap: 2 000× throughput; Rust PyO3 core (PGS, GJK/EPA, BVH)
Testing 459+ automated tests; PyBullet baseline comparison
Typed Fully annotated with py.typed marker

Installation

# Full install — physics, rendering, and RL in one command
pip install pyforge3d

# Development install
git clone https://github.com/iruki-dev/forge3d
cd forge3d
pip install -e ".[dev]"

# WebGPU renderer (experimental, P34 — falls back to OpenGL if omitted)
pip install "pyforge3d[experimental]"

Install name vs import name: pip install pyforge3d — then import forge3d in your code.

Requirements: Python 3.12+, NumPy, SciPy, JAX (CPU wheel), moderngl, glfw, gymnasium


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

forge3d ships a Gymnasium-compatible physics world. Use it directly with any RL library:

import gymnasium as gym
import forge3d as f3d
from stable_baselines3 import PPO

# Build a custom Gymnasium env backed by forge3d physics
class ReachEnv(gym.Env):
    def __init__(self):
        super().__init__()
        self.world = f3d.World(gravity=(0, 0, -9.81))
        # ... define observation_space / action_space

env   = ReachEnv()
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
│   │   ├── character.py         # CharacterController
│   │   ├── 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/           # SAT, GJK/EPA, heightfield, layers, raycast
│   │   ├── contact/             # Impulse solver (PGS + Coulomb friction)
│   │   ├── constraints/         # Joints: Hinge, Ball, Prismatic, Fixed, Distance, Spring
│   │   ├── 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
│   │   ├── ecs/                 # EntityWorld, Transform, Rigidbody, Systems…
│   │   ├── animation/           # Skeleton, AnimationClip, FABRIKSolver…
│   │   ├── audio/               # AudioSystem, AudioSource, AudioClip (OpenAL)
│   │   ├── particle/            # ParticleEmitter, ParticleSystem, presets
│   │   ├── scene/               # SceneManager, SceneNode, Prefab
│   │   ├── ui/                  # Canvas, DebugPanel, InspectorPanel…
│   │   ├── editor/              # EditorApp, gizmos, layout
│   │   ├── render/
│   │   │   ├── snapshot.py      # SceneSnapshot — pure-data physics↔render contract
│   │   │   ├── base.py          # Renderer ABC
│   │   │   ├── realtime/        # OpenGL 3.3 PBR rasteriser
│   │   │   ├── deferred/        # OpenGL 4.3 deferred (SSAO, CSM, HDR, Bloom)
│   │   │   ├── hq/              # Software ray-tracer (Blinn-Phong, AO, AA)
│   │   │   ├── wgpu_backend/    # WebGPU renderer (experimental, P34)
│   │   │   └── shaders/         # GLSL + WGSL shader sources
│   │   ├── io/                  # OBJ loader, MeshData, world snapshot
│   │   └── py.typed             # PEP 561 type marker
│   └── forge3d_core/            # Rust crate (PyO3): PGS, GJK/EPA, BVH, SIMD math
├── apps/
│   ├── game/                    # FORGE RUNNER — 3D physics platformer
│   ├── fps_battleroyal/         # FPS battle-royale demo
│   └── showcase/                # Engine feature showcase
├── examples/                    # Self-contained examples (≤15 lines each)
├── tests/                       # 459+ automated tests
├── validation/                  # PyBullet / MuJoCo baseline comparison
├── assets/                      # 3D models, textures
├── docs_src/                    # MkDocs documentation source
├── 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:

  1. Fork → feature branch → PR
  2. ruff check . && ruff format . must pass
  3. mypy src/ must pass
  4. New physics code needs a conservation-law or analytical-solution test
  5. 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

pyforge3d-2.2.1.tar.gz (209.6 kB view details)

Uploaded Source

Built Distribution

If you're not sure about the file name format, learn more about wheel file names.

pyforge3d-2.2.1-cp312-abi3-manylinux_2_34_x86_64.whl (573.5 kB view details)

Uploaded CPython 3.12+manylinux: glibc 2.34+ x86-64

File details

Details for the file pyforge3d-2.2.1.tar.gz.

File metadata

  • Download URL: pyforge3d-2.2.1.tar.gz
  • Upload date:
  • Size: 209.6 kB
  • Tags: Source
  • Uploaded using Trusted Publishing? Yes
  • Uploaded via: twine/6.1.0 CPython/3.13.12

File hashes

Hashes for pyforge3d-2.2.1.tar.gz
Algorithm Hash digest
SHA256 5c6495c3e5d9925fac051ef5effc0b6b545888f5c4dd3b2e50367aac4c45692a
MD5 8bfc4ecd2ea6392a76f83fefc6a8ab95
BLAKE2b-256 7e913aad5cecda10fe739ec112d9c0ea0c19a89cadf6e9734c16cd4143ca8d91

See more details on using hashes here.

File details

Details for the file pyforge3d-2.2.1-cp312-abi3-manylinux_2_34_x86_64.whl.

File metadata

File hashes

Hashes for pyforge3d-2.2.1-cp312-abi3-manylinux_2_34_x86_64.whl
Algorithm Hash digest
SHA256 5671114359241ae5f51265445ef1e520e9168febadc7c0d8f3ccbd89c4fd96b2
MD5 6dc71aacf3cddead02e58183baed5c94
BLAKE2b-256 d1afa8a271ecaf747b6ab6a4d4c6ade51fe7153457f20375cb251a36f9e92c5d

See more details on using hashes here.

Supported by

AWS Cloud computing and Security Sponsor Datadog Monitoring Depot Continuous Integration Fastly CDN Google Download Analytics Pingdom Monitoring Sentry Error logging StatusPage Status page