A lightweight but capable 3D renderer for Python + Pygame with custom projection math, first-person camera controls, procedural geometry, OBJ support, basic physics, and an optional GPU raster path.
Every item below links to its explanation section.
- Custom 3D Projection - Projection pipeline implemented manually (no external 3D engine).
- First-Person Camera (6-DOF) - Mouse look + keyboard movement with speed control.
- 15+ Procedural Shape Generators - Mountains, cities, fractals, knots, and more.
- Real-Time Rendering - Designed for responsive live rendering workflows.
- Animated Terrains - Time-based procedural surfaces.
- Extensible Shape API - Register your own shapes with
@register_shape. - Multiple Object Support - Render multiple meshes/shapes in one scene.
- Per-Shape Colors - Custom colors for polygon/raster workflows.
- Simple Physics Engine - Basic forces and collisions for spheres/planes/camera.
- OBJ Loading - Load and render
.objfiles with triangulation and UV parsing. - Rasterization Paths - CPU fill path + GPU compute-shader raster path.
- Three Render Modes -
MESH,POLYGON_FILL, andRASTERIZE. - Raster Debug Views - Depth and heat-map diagnostics.
- Texture Mapping - UV texture sampling in raster mode.
- Multi-Texture Pipeline - Multiple texture layers selectable per OBJ.
- Entities - Lightweight in-scene entities with scripted behaviour, bounding boxes, and basic collision support.
- Custom Shaders - Helper wrapper for OpenGL compute shaders with SSBO/uniform helpers.
- Runtime Shape Management - Toggle built-in defaults while running.
- Skybox Rendering - Generate cubemap skyboxes from UVs or cross atlas layouts.
- Pause + Settings UI - Tune mode, FOV, debug views, and lighting live.
- Video Renderer (Experimental) - Render OBJ animation clips to video output.
- macOS GPU Note - Compute-shader limitation and VM workaround.
pip install aiden3drendererRequirements:
- Python 3.11+
- Pygame 2.6.0+ (installed automatically)
from aiden3drenderer import Renderer3D, renderer_type
renderer = Renderer3D()
renderer.camera.position = [0, 0, 0]
renderer.render_type = renderer_type.POLYGON_FILL
renderer.run()Use loopable_run() when you want to integrate custom game logic each frame.
from aiden3drenderer import Renderer3D, renderer_type
renderer = Renderer3D()
renderer.render_type = renderer_type.POLYGON_FILL
while True:
# Custom logic here
renderer.loopable_run()Press Esc during run() to open the runtime menu.
You can:
- Switch render mode (
MESH/POLYGON_FILL/RASTERIZE) - Toggle raster debug views (depth/heat)
- Adjust FOV
- Adjust lighting strictness
- Toggle OBJ render mode handling
The renderer uses a full world-to-screen pipeline implemented in Python:
- Translate vertices into camera space.
- Apply yaw/pitch/roll rotations.
- Perspective divide by depth with FOV scaling.
- Map to screen coordinates.
No external 3D engine is required for the core projection math.
Camera movement is designed for interactive exploration:
- Movement:
W/A/S/D,Space,Left Shift - Speed boost:
Left Ctrl - Rotation: right mouse drag + arrow key nudging
- FOV tweak: mouse wheel
Built-in generators include mathematical and stylized surfaces such as:
- Mountain, canyon, pyramid, torus, sphere
- Mobius strip, megacity, mandelbulb slice
- Klein bottle, trefoil knot
- Animated waves/ripples/spirals/alien terrain/double helix
The engine is designed for live rendering loops and interactive scenes. Typical scenes can run smoothly at real-time frame rates depending on selected mode and geometry complexity.
Animated shapes use the frame parameter inside shape generators. This enables time-driven deformation without changing the API shape contract.
Create custom shapes using @register_shape.
from aiden3drenderer import Renderer3D, register_shape
import pygame
@register_shape("My Plane", key=pygame.K_p, is_animated=False, color=(200, 255, 150))
def generate_plane(grid_size=40, frame=0):
return [
[(1, 1, 1), (2, 1, 1), (3, 1, 1)],
[(1, 1, 2), (2, 1, 2), (3, 1, 2)],
[(1, 1, 3), (2, 1, 3), (3, 1, 3)],
]
renderer = Renderer3D()
renderer.run()Important shape rule:
- Return a rectangular matrix (
list[list[tuple | None]]) where all rows have the same length.
You can render multiple objects at once by appending several shape/OBJ entries to the active scene list.
Shapes can define explicit base colors in registration, which are respected in filled/raster workflows.
Physics module supports basic rigid-body style interactions:
- Sphere objects
- Plane colliders
- Gravity/forces
- Sphere-sphere and sphere-plane collision handling
- Camera physics wrapper support
For a full example, see the Physics section below.
Lightweight in-scene Entity objects are provided to attach models to simple runtime behaviour. Key points:
- Entities wrap a model (vertices/faces) and expose a small API:
add_script(script_str),toggle_gravity(),update(), and helpers to add variables/functions accessible to scripts. - Scripts are plain Python strings executed with
exec()in a sandboxed-ishvariablesnamespace; the default namespace containsentityandrenderer. - Built-in gravity script and collision helpers allow snapping, terminal velocity, and simple positional resolution using the renderer's
bounding_boxes. - Entities maintain
position,rotation,velocity, abounding_box, anddelta_timeand are updated each frame viaEntity.update().
Example:
from aiden3drenderer import Renderer3D, Entity, obj_loader
renderer = Renderer3D()
obj = obj_loader.get_obj("./assets/alloy_forge_block.obj")
entity = Entity(obj, renderer)
entity.toggle_gravity()
renderer.entities.append(entity)
while True:
for e in renderer.entities:
e.update()
renderer.loopable_run()OBJ workflow supports standard model loading with extra quality-of-life features:
- Load from file path
- Parse UV (
vt) data for raster texturing - Triangulate faces with more than 3 vertices
- Per-object offset and
texture_indexsupport
For full usage, see OBJ Loading.
Two fill/raster workflows are available:
- CPU software triangle filling (
POLYGON_FILL) - GPU compute-shader rasterization (
RASTERIZE)
CustomShader is a small helper around a ModernGL compute shader that parses buffer/uniform declarations and exposes simple helpers:
- Create with shader source and an optional ModernGL context:
CustomShader(shader_code, context=ctx). - Allocate SSBO-style buffers via
set_buffer(name, element_count, element_size=None)which binds storage buffers by layout binding. - Write to buffers/uniforms with
write_to_buffer(name, bytes)andwrite_to_uniform(name, value_or_bytes). - Read results back from buffers with
read_from_buffer(name, num_elements, element_type='vec3')which returns a NumPy array.
Example usage:
from aiden3drenderer.custom_shader import CustomShader
shader_src = """#version 430
layout(std430, binding=0) buffer mybuf { vec4 data[]; };
void main(){ /* ... */ }
"""
cs = CustomShader(shader_src, context=renderer.ctx)
cs.set_buffer('mybuf', element_count=1024, element_size=16)
# write raw bytes (numpy.tobytes()) and dispatch via cs.compute_shaderSwitch with renderer.render_type:
renderer_type.MESHrenderer_type.POLYGON_FILLrenderer_type.RASTERIZE
When using RASTERIZE mode:
toggle_depth_view(True)for depth visualizationtoggle_heat_map(True)for heat-map diagnostics
Apply texture sampling in raster mode using image files and OBJ UV coordinates.
Add multiple texture layers and assign each OBJ to one via texture_index.
from aiden3drenderer import Renderer3D, obj_loader, renderer_type
renderer = Renderer3D(width=1000, height=800)
renderer.render_type = renderer_type.RASTERIZE
renderer.using_obj_filetype_format = True
renderer.add_texture_for_raster("./assets/model1.png") # index 0
renderer.add_texture_for_raster("./assets/model2.png") # index 1
obj1 = obj_loader.get_obj("./assets/model1.obj", texture_index=0)
obj2 = obj_loader.get_obj("./assets/model2.obj", texture_index=1, offset=(6, 0, 0))
renderer.vertices_faces_list.append(obj1)
renderer.vertices_faces_list.append(obj2)
renderer.run()Enable or disable built-in shape set at runtime:
renderer = Renderer3D(load_default_shapes=False)
renderer.set_use_default_shapes(True)Skyboxes can be generated from:
- Explicit cubemap UV mappings
- Cross-layout atlas images via
generate_cross_type_cubemap_skybox(...)
Esc opens a pause/settings UI while running. Use it for fast iteration without restarting your app.
The package includes an experimental OBJ-to-video renderer:
- Uses the same projection concepts as live renderer
- Supports per-object transforms and rotation rates
- Suitable for simple pre-rendered clips
More details in Video Renderer.
RASTERIZE mode requires OpenGL 4.3 compute shaders, which are not available natively on macOS.
Use MESH/POLYGON_FILL on macOS, or see VM Workaround for macOS.
from aiden3drenderer import Renderer3D, renderer_type
renderer = Renderer3D()
renderer.render_type = renderer_type.RASTERIZE
renderer.set_texture_for_raster("./assets/alloy_forge_block.png")
renderer.toggle_depth_view(True)
renderer.run()Shape functions must return a rectangular vertex matrix. Jagged rows can cause IndexError during mesh traversal.
from aiden3drenderer import Renderer3D, register_shape
import pygame
@register_shape("My Pyramid", key=pygame.K_p, is_animated=False)
def generate_pyramid(grid_size=40, frame=0):
matrix = []
center = grid_size / 2
for x in range(grid_size):
row = []
for y in range(grid_size):
dx = abs(x - center)
dy = abs(y - center)
max_dist = max(dx, dy)
height = max(0, 10 - max_dist)
row.append((x, height, y))
matrix.append(row)
return matrix
renderer = Renderer3D()
renderer.run()Physics in Aiden3DRenderer is intentionally lightweight and extensible.
You can:
- Create sphere and plane physics objects
- Apply forces and impulses
- Simulate collisions
- Attach a physics camera
- Manage everything through
PhysicsObjectHandler
from aiden3drenderer import Renderer3D, physics, renderer_type
def main():
renderer = Renderer3D(width=1000, height=1000, title="My 3D Renderer")
shape = physics.ShapePhysicsObject(renderer, "sphere", (0, 0, 0), (100, 0, 0), 5, 20, 20)
shape.add_forces((-0.7, 0, 0))
shape.anchor_position = [20, 0, 0]
shape1 = physics.ShapePhysicsObject(renderer, "sphere", (0, 0, 0), (50, 0, 0), 5, 10, 20)
shape1.add_forces((0.7, 0, 0))
shape1.anchor_position = [0, 0, 0]
obj_handler = physics.PhysicsObjectHandler()
obj_handler.add_shape(shape)
obj_handler.add_shape(shape1)
renderer.set_starting_shape(None)
renderer.camera.position = [0, 0, 0]
renderer.render_type = renderer_type.POLYGON_FILL
while True:
obj_handler.handle_shapes()
renderer.loopable_run()
if __name__ == "__main__":
main()from aiden3drenderer import Renderer3D, physics, renderer_type
def main():
renderer = Renderer3D(width=1000, height=1000, title="My 3D Renderer")
obj_handler = physics.PhysicsObjectHandler()
plane_color = (200, 200, 200)
plane_size = 28
grid_size = 8
obj_handler.add_plane(renderer, [0, -14, 0], (0, 0, 0), plane_color, plane_size, grid_size)
obj_handler.add_plane(renderer, [-14, 0, 0], (0, 0, 90), plane_color, plane_size, grid_size)
obj_handler.add_plane(renderer, [14, 0, 0], (0, 0, 90), plane_color, plane_size, grid_size)
obj_handler.add_plane(renderer, [0, 0, -14], (90, 0, 0), plane_color, plane_size, grid_size)
obj_handler.add_plane(renderer, [0, 0, 14], (90, 0, 0), plane_color, plane_size, grid_size)
ball_color = (100, 100, 255)
ball_radius = 4
ball_mass = 2.5
ball_grid = 8
ball1 = physics.ShapePhysicsObject(renderer, "sphere", (0, 0, 0), ball_color, ball_radius, ball_mass, ball_grid)
ball1.anchor_position = [0, 0, 0]
ball2 = physics.ShapePhysicsObject(renderer, "sphere", (0, 0, 0), ball_color, ball_radius, ball_mass, ball_grid)
ball2.anchor_position = [9, 0, 0]
gravity = (0, -0.18, 0)
ball1.add_forces((1, 0, 1))
camera = physics.CameraPhysicsObject(renderer, renderer.camera, 1, 10)
obj_handler.add_camera(camera)
obj_handler.add_shape(ball1)
obj_handler.add_shape(ball2)
renderer.set_starting_shape(None)
renderer.render_type = renderer_type.POLYGON_FILL
renderer.camera.base_speed = 1.2
while True:
ball1.add_forces(gravity)
ball2.add_forces(gravity)
camera.add_forces(tuple(v * 100 for v in gravity))
obj_handler.handle_shapes()
renderer.loopable_run()
if __name__ == "__main__":
main()from aiden3drenderer import Renderer3D, obj_loader, renderer_type
def main():
renderer = Renderer3D(width=1000, height=1000, title="My 3D Renderer")
renderer.current_shape = None
renderer.camera.position = [0, 0, 0]
renderer.render_type = renderer_type.POLYGON_FILL
renderer.using_obj_filetype_format = True
obj = obj_loader.get_obj("./assets/alloy_forge_block.obj", texture_index=0)
renderer.vertices_faces_list.append(obj)
renderer.run()
if __name__ == "__main__":
main()obj_loader.get_obj(path, texture_index, offset=(x, y, z))supports per-object texture selection and world-space offset.- N-gon faces are triangulated automatically.
- UV coordinates (
vt) are parsed for texture mapping in raster mode. - Cross-layout skybox helper:
generate_cross_type_cubemap_skybox(radius, img_path).
A lightweight experimental renderer for creating video clips from OBJ scenes.
from aiden3drenderer.video_renderer import VideoRenderer3D, VideoRendererObject
obj = VideoRendererObject("assets/alloy_forge_block.obj")
obj.rotations_per_seccond = [10, 25, 0]
obj.rotation = [0, 0, 0]
vr = VideoRenderer3D(width=800, height=600, fps=30, shapes=[obj])
vr.render("out.avi", duration_s=5, verbose=True)from aiden3drenderer.video_renderer import VideoRenderer3D, VideoRendererObject
o1 = VideoRendererObject("assets/model1.obj")
o1.rotations_per_seccond = [0, 40, 0]
o2 = VideoRendererObject("assets/model2.obj")
o2.rotations_per_seccond = [10, 0, 5]
o2.anchor_pos = [4, 0, 8]
vr = VideoRenderer3D(width=1200, height=800, fps=24, shapes=[o2, o1])
vr.render("multiples.avi", duration_s=10, verbose=True)Tips:
- Keep resolution/FPS moderate while this module is still being optimized.
- Minor seam/overdraw artifacts are known limitations at the moment.
GPU RASTERIZE mode needs GL 4.3 compute shaders, which are unavailable on native macOS drivers.
- Install UTM
- Download Ubuntu ISO
- Create a Linux VM in UTM
- Install Python:
sudo apt update
sudo apt install python3.11
python3 --version
sudo apt install python3-pipThen install the package inside the VM:
pip install aiden3drendererW/A/S/D- Move forward/left/backward/rightSpace- Move upLeft Shift- Move downLeft Ctrl- Speed boost (2x)- Mouse wheel - Adjust camera FOV
- Arrow keys - Fine pitch/yaw adjustment
- Right mouse + drag - Look around
1- Mountain terrain2- Animated sine waves3- Ripple effect4- Canyon valley5- Stepped pyramid6- Spiral surface7- Torus8- Sphere9- Mobius strip0- MegacityQ- Alien landscapeE- Double helixR- Mandelbulb sliceT- Klein bottleY- Trefoil knot
Escape- Open/close pause menu inrun()mode
Mountain (1) - Smooth parabolic mountain with radial falloff.
Canyon (4) - U-shaped valley with sinusoidal variation.
Pyramid (5) - Stepped pyramid using Chebyshev distance.
Torus (7) - Classic donut shape from parametric equations.
Sphere (8) - UV sphere generated from spherical coordinates.
Mobius Strip (9) - Non-orientable surface with a single continuous side.
Megacity (0) - 80x80 procedural city (6400 vertices) with roads and building variation.
Mandelbulb (R) - 2D slice through a Mandelbulb-style fractal field.
Klein Bottle (T) - Non-orientable 4D-inspired surface projected into 3D.
Trefoil Knot (Y) - Tube mesh along a classic trefoil knot path.
Waves (2) - Multi-frequency flowing sine surface.
Ripple (3) - Expanding circular wave with amplitude decay.
Spiral (6) - Rotating polar-coordinate surface animation.
Alien Landscape (Q) - Mixed procedural terrain with craters, spikes, and pulsation.
Double Helix (E) - Twin strand structure with phase offset and animation.
- World coordinates
- Camera translation
- Camera rotation (yaw/pitch/roll)
- Perspective projection with FOV
- Screen-space mapping
Yaw (Y-axis):
x' = x*cos(theta) + z*sin(theta)
z' = -x*sin(theta) + z*cos(theta)
Pitch (X-axis):
y' = y*cos(phi) - z*sin(phi)
z' = y*sin(phi) + z*cos(phi)
Roll (Z-axis):
x' = x*cos(psi) - y*sin(psi)
y' = x*sin(psi) + y*cos(psi)
Vertices behind the camera (z <= 0.1) are culled (None) to avoid invalid perspective division and visual artifacts.
- Most built-in terrains are intended to be playable in real-time.
- Large scenes (especially filled modes) are heavier;
MESHmode is best for maximum speed. Megacityis one of the largest defaults and a good stress test.
from aiden3drenderer import Renderer3D
renderer = Renderer3D(width=1200, height=800)
renderer.run()Useful methods and attributes:
set_starting_shape(shape_name_or_none)set_use_default_shapes(bool)set_render_type(renderer_type.*)toggle_depth_view(bool)toggle_heat_map(bool)set_texture_for_raster(path)add_texture_for_raster(path)generate_cross_type_cubemap_skybox(radius, img_path)generate_cubemap_skybox(...)using_obj_filetype_formatvertices_faces_listlighting_strictnessentities: list ofEntityobjects attached to the scene (update them each frame or let your loop call them).CustomShader: helper class (seeaiden3drenderer.custom_shader.CustomShader) to run compute shaders and manage SSBO/uniform access.
from aiden3drenderer import Renderer3D
renderer = Renderer3D()
camera = renderer.camera
print(camera.position) # [x, y, z]
print(camera.rotation) # [pitch, yaw, roll]
print(camera.speed)
print(camera.base_speed)@register_shape(name, key=None, is_animated=False, color=None)
def generate_function(grid_size=40, frame=0):
return matrixExpected return type:
list[list[tuple[float, float, float] | None]](rectangular matrix)
aiden3drenderer/
|-- __init__.py
|-- renderer.py
|-- camera.py
|-- obj_loader.py
|-- physics.py
|-- shapes.py
`-- video_renderer.py
examples/
|-- basic_usage.py
|-- custom_shape_example.py
|-- obj_example.py
`-- physics_test.py
git clone https://github.com/AidenKielby/3D-mesh-Renderer
cd 3D-mesh-Renderer
pip install -e .
python examples/basic_usage.pypip install build twine
python -m build
python -m twine upload dist/*Created by Aiden. Procedural terrain ideas were AI-assisted in places; core renderer/projection/camera and package engineering are authored manually.
MIT









