|
1 |
| -# python-3d-from-zero |
2 |
| -Demonstration of building 3d world, 2d projection from scratch |
| 1 | +3D Playground - on Python from scratch. |
| 2 | +===================================== |
| 3 | + |
| 4 | + |
| 5 | + |
| 6 | + |
| 7 | +#### TL;DR: Some basic 3D world playground with animations and [camera](#camera-keys-example) completely from scratch(only 2D pixels). |
| 8 | +This implementation / API only for demonstration and *playground* purposes based on [Perspective projection](https://en.wikipedia.org/wiki/3D_projection#Perspective_projection). |
| 9 | +Can be used on top of **any** 2d graphics engine/lib(frame buffers, sdl and etc.) |
| 10 | + |
| 11 | +Not implemented features due to low performance: |
| 12 | +* Face clipping not implemented, vertices clipping ignored too |
| 13 | +* Flat shading and Gouraud shading not implemented. |
| 14 | +* Z-buffering |
| 15 | + |
| 16 | +`models.Model` API is open demonstration of [MVP](https://stackoverflow.com/questions/5550620/the-purpose-of-model-view-projection-matrix) model and is definitely a good starting point/topic for 3D graphics. |
| 17 | + |
| 18 | +Also you can plot any function on 3D scene. |
| 19 | + |
| 20 | +* [How to use](#how-to-use) |
| 21 | +* [Model View Projection](#model-view-projection) |
| 22 | + * [Projection](#projection) |
| 23 | + * [Camera](#world-camera) |
| 24 | + * [Camera scene example](#camera-keys-example) |
| 25 | +* [Mesh and Wireframe](#mesh-and-wireframe) |
| 26 | +* [Rasterization](#rasterization) |
| 27 | +* [3D Plotting](#3d-plotting) |
| 28 | +* [Basic Wavefront .obj format support](#obj-format) |
| 29 | +* [Model API](#models-api) |
| 30 | +* [Trajectory API](#trajectory-api) |
| 31 | +* [Pygame Example](#pygame-example) |
| 32 | + |
| 33 | +## How to use |
| 34 | + |
| 35 | +There is only one requirement - to provide 2D pixel and line renderer(drawer) |
| 36 | + |
| 37 | +As current example uses `pygame`: |
| 38 | +```python |
| 39 | +from play3d.three_d import Device |
| 40 | +import pygame |
| 41 | + |
| 42 | +# our adapter will rely on pygame renderer |
| 43 | +put_pixel = lambda x, y, color: pygame.draw.circle(screen, color, (x, y), 1) |
| 44 | +# we certainly can draw lines ourselves using put_pixel three_d.drawline |
| 45 | +# but implementation below - much faster |
| 46 | +line_adapter = lambda p1, p2, color: pygame.draw.line(screen, color, (p1[x], p1[y]), (p2[x], p2[y]), 1) |
| 47 | + |
| 48 | +width, height = 1024, 768 # should be same as 2D provider |
| 49 | +Device.viewport(width, height) |
| 50 | +Device.set_renderer(put_pixel, line_adapter) |
| 51 | +screen = pygame.display.set_mode(Device.get_resolution()) |
| 52 | + |
| 53 | +``` |
| 54 | + |
| 55 | +That's all we need for setting up environment. |
| 56 | +Now we can create and render model objects by calling `Model.draw()` at each frame update (See example)\ |
| 57 | +To create model you can simply pass 3D world vertices as 2-d list `Model(data=data)` |
| 58 | + |
| 59 | +It is possible to provide faces as 2d array `Model(data=data, faces=faces)`. Face index starts from 1. Only triangles supported. For more information see below. |
| 60 | + |
| 61 | +Simply by providing 3D (or 4D homogeneous where w=1) `data` vertices list - Model transforms this coordinates from 3D world space to projected screen space |
| 62 | +```python |
| 63 | +from play3d.models import Model |
| 64 | + |
| 65 | +# our 2D library renderer setup.. See above. |
| 66 | + |
| 67 | +# Cube model. Already built-in `models.Cube` |
| 68 | +cube = Model(position=(0, 0, 0), |
| 69 | + data=[ |
| 70 | + [-1, 1, 1, 1], |
| 71 | + [1, 1, 1, 1], |
| 72 | + [-1, -1, 1, 1], |
| 73 | + [1, -1, 1, 1], |
| 74 | + [-1, 1, -1, 1], |
| 75 | + [1, 1, -1, 1], |
| 76 | + [1, -1, -1, 1], |
| 77 | + [-1, -1, -1, 1] |
| 78 | + ]) |
| 79 | +while True: # your render lib/method |
| 80 | + cube.draw() |
| 81 | +``` |
| 82 | +## Model View Projection |
| 83 | + |
| 84 | +`models.Model` and `three_d.Camera` implements all MVP(See `Model.draw`). |
| 85 | + |
| 86 | +### Projection |
| 87 | + |
| 88 | +Here we use perspective projection matrix\ |
| 89 | +Z axis of clipped cube(from frustum) mapped to [-1, 1] and our camera directed to -z axis (OpenGL convention)\ |
| 90 | +Projection Matrix can be tuned there (aspect ratio, FOV and etc.) \ |
| 91 | +`Camera.near = 1`\ |
| 92 | +`Camera.far = 10`\ |
| 93 | +`Camera.fov = 60`\ |
| 94 | +`Camera.aspect_ratio = 3/4` |
| 95 | + |
| 96 | +### World camera |
| 97 | + |
| 98 | +By OpenGL standard we basically move our scene. |
| 99 | +Facing direction considered when we move our camera in case of rotations(direction vector will be transformed too)\ |
| 100 | +Camera can be moved through `three_d.Camera` API: |
| 101 | +```python |
| 102 | +from play3d.three_d import Camera |
| 103 | +camera = Camera.get_instance() |
| 104 | + |
| 105 | +# move camera to x, y, z with 0.5 step considering facing direction |
| 106 | +camera['x'] += 0.5 |
| 107 | +camera['y'] += 0.5 |
| 108 | +camera['z'] += 0.5 |
| 109 | + |
| 110 | +camera.move(0.5, 0.5, 0.5) # identical above |
| 111 | + |
| 112 | +# rotate camera to our left on XZ plane |
| 113 | +camera.rotate('y', 2) # |
| 114 | +``` |
| 115 | + |
| 116 | +#### Camera keys example |
| 117 | + |
| 118 | + |
| 119 | +## Mesh and Wireframe |
| 120 | + |
| 121 | +To exploit mesh one should provide both `data` and `faces`. Face represents triple group of vertices index referenced from `data`. Face index starts from 1.\ |
| 122 | +By default object rendered as wireframe |
| 123 | +```python |
| 124 | + |
| 125 | +from play3d.models import Model |
| 126 | +triangle = Model(position=(-5, 3, -4), |
| 127 | + data=[ |
| 128 | + [-3, 1, -7, 1], |
| 129 | + [-2, 2, -7, 1], |
| 130 | + [-1, 0, -7, 1], |
| 131 | + ], faces=[[1, 2, 3]]) |
| 132 | +``` |
| 133 | + |
| 134 | + |
| 135 | + |
| 136 | + |
| 137 | +## Rasterization |
| 138 | + |
| 139 | +By default if data and faces provided, rasterization will be enabled.\ |
| 140 | +For rasterization we use - standard slope algorithm with horizontal filling lines. |
| 141 | +```python |
| 142 | +from play3d.models import Model |
| 143 | + |
| 144 | +white = (230, 230, 230) |
| 145 | +suzanne = Model.load_OBJ('suzanne.obj.txt', position=(-4, 2, -6), color=white, rasterize=True) |
| 146 | +suzanne_wireframe = Model.load_OBJ('suzanne.obj.txt', position=(-4, 2, -6), color=white) |
| 147 | +suzanne.rotate(0, -14) |
| 148 | +suzanne_wireframe.rotate(0, 14) |
| 149 | +``` |
| 150 | + |
| 151 | + |
| 152 | + |
| 153 | +## 3D plotting |
| 154 | + |
| 155 | +You can plot any function you want by providing parametric equation as `func(*parameters) -> [x, y, z]`. |
| 156 | +For example, sphere and some awesome wave both polar and parametric equations(Sphere built-in as `Models.Sphere`): |
| 157 | +```python |
| 158 | +import math |
| 159 | +from play3d.models import Plot |
| 160 | + |
| 161 | +def fn(phi, theta): |
| 162 | + |
| 163 | + return [ |
| 164 | + math.sin(phi * math.pi / 180) * math.cos(theta * math.pi / 180), |
| 165 | + math.sin(theta * math.pi / 180) * math.sin(phi * math.pi / 180), |
| 166 | + math.cos(phi * math.pi / 180) |
| 167 | + ] |
| 168 | + |
| 169 | +sphere_model = Plot(func=fn, allrange=[0, 360], position=(-4, 2, 1), color=(0, 64, 255)) |
| 170 | + |
| 171 | +blow_your_head = Plot( |
| 172 | + position=(-4, 2, 1), color=(0, 64, 255), |
| 173 | + func=lambda x, t: [x, math.cos(x) * math.cos(t), math.cos(t)], allrange=[0, 2*math.pi], interpolate=75 |
| 174 | +) |
| 175 | + |
| 176 | +``` |
| 177 | + |
| 178 | + |
| 179 | + |
| 180 | + |
| 181 | +## OBJ format |
| 182 | + |
| 183 | +Wawefront format is widely used as a standard in 3D graphics |
| 184 | + |
| 185 | +You can import your model here. Only vertices and faces supported.\ |
| 186 | +`Model.load_OBJ(cls, path, wireframe=False, **all_model_kwargs)` |
| 187 | + |
| 188 | +You can find examples here [github.com/alecjacobson/common-3d-test-models](https://github.com/alecjacobson/common-3d-test-models) |
| 189 | + |
| 190 | +```python |
| 191 | +Model.load_OBJ('beetle.obj.txt', wireframe=True, color=white, position=(-2, 2, -4), scale=3) |
| 192 | +``` |
| 193 | + |
| 194 | + |
| 195 | + |
| 196 | + |
| 197 | +## Models API |
| 198 | + |
| 199 | +`Models.Model` |
| 200 | + |
| 201 | +| Fields | Description | |
| 202 | +| ------------- | ------------- | |
| 203 | +| `position` | `tuple=(0, 0, 0)` with x, y, z world coordinates | |
| 204 | +| `scale` | `integer(=1)` | |
| 205 | +| `color` | `tuple` `(255, 255, 255)` | |
| 206 | +| `data` | `list[[x, y, z, [w=1]]]` - Model vertices(points) | |
| 207 | +| `faces` | `list[[A, B, C]]` - Defines triangles See: [Mesh and Wireframe](#mesh-and-wireframe) | |
| 208 | +| `rasterize` | `bool(=True)` - Rasterize - "fill" an object | |
| 209 | +| `shimmering` | `bool(=False)` - color flickering/dancing | |
| 210 | + |
| 211 | + |
| 212 | + |
| 213 | +```python |
| 214 | +# Initial Model Matrix |
| 215 | +model.matrix = Matrix([ |
| 216 | + [1 * scale, 0, 0, 0], |
| 217 | + [0, 1 * scale, 0, 0], |
| 218 | + [0, 0, 1 * scale, 0], |
| 219 | + [*position, 1] |
| 220 | + ]) |
| 221 | + |
| 222 | +``` |
| 223 | + |
| 224 | +## Trajectory API |
| 225 | + |
| 226 | +`Models.Trajectory` |
| 227 | + |
| 228 | +| Fields | Description | |
| 229 | +| ------------- | ------------- | |
| 230 | +| `func` | `func` Parametrized math function which takes `*args` and returns world respective coordinates `tuple=(x, y, z)` | |
| 231 | + |
| 232 | +To move our object through defined path we can build Trajectory for our object. |
| 233 | +You can provide any parametric equation with args.\ |
| 234 | +World coordinates defined by `func(*args)` tuple output. |
| 235 | + |
| 236 | +#### `model_obj @ translate(x, y, z)` |
| 237 | +translates object's model matrix (in world space) |
| 238 | + |
| 239 | +#### `rotate(self, angle_x, angle_y=0, angle_z=0)` |
| 240 | +Rotates object relative to particular axis plane. First object translated from the world space back to local origin, then we rotate the object |
| 241 | + |
| 242 | +#### `route(self, trajectory: 'Trajectory', enable_trace=False)` |
| 243 | +Set the function-based trajectory routing for the object. |
| 244 | + |
| 245 | + - trajectory `Trajectory` - trajectory state |
| 246 | + - enable_trace `bool` - Keep track of i.e. draw trajectory path (breadcrumbs) |
| 247 | + |
| 248 | +#### Example |
| 249 | +```python |
| 250 | +import math |
| 251 | + |
| 252 | +from play3d.models import Sphere, Trajectory |
| 253 | +white = (230, 230, 230) |
| 254 | +moving_sphere = Sphere(position=(1, 3, -5), color=white, interpolate=50) |
| 255 | +moving_sphere.route(Trajectory.ToAxis.Z(speed=0.02).backwards()) |
| 256 | + |
| 257 | +whirling_sphere = Sphere(position=(1, 3, -5), color=white, interpolate=50) |
| 258 | +# Already built-in as Trajectory.SineXY(speed=0.1) |
| 259 | +whirling_sphere.route(Trajectory(lambda x: [x, math.sin(x)], speed=0.1)) |
| 260 | + |
| 261 | + |
| 262 | +while True: # inside your "render()" |
| 263 | + moving_sphere.draw() |
| 264 | + whirling_sphere.draw() |
| 265 | +``` |
| 266 | +## Pygame example |
| 267 | + |
| 268 | +```python |
| 269 | +import logging |
| 270 | +import os |
| 271 | +import sys |
| 272 | + |
| 273 | +import pygame |
| 274 | + |
| 275 | +from play3d.models import Model, Grid |
| 276 | +from pygame_utils import handle_camera_with_keys # your keyboard control management |
| 277 | +from play3d.three_d import Device, Camera |
| 278 | +from play3d.utils import capture_fps |
| 279 | + |
| 280 | +logging.basicConfig(stream=sys.stdout, level=logging.INFO) |
| 281 | + |
| 282 | +os.environ["SDL_VIDEO_CENTERED"] = '1' |
| 283 | +black, white = (20, 20, 20), (230, 230, 230) |
| 284 | + |
| 285 | + |
| 286 | +Device.viewport(1024, 768) |
| 287 | +pygame.init() |
| 288 | +screen = pygame.display.set_mode(Device.get_resolution()) |
| 289 | + |
| 290 | +# just for simplicity - array access, we should avoid that |
| 291 | +x, y, z = 0, 1, 2 |
| 292 | + |
| 293 | +# pygame sdl line is faster than default one |
| 294 | +line_adapter = lambda p1, p2, color: pygame.draw.line(screen, color, (p1[x], p1[y]), (p2[x], p2[y]), 1) |
| 295 | +put_pixel = lambda x, y, color: pygame.draw.circle(screen, color, (x, y), 1) |
| 296 | + |
| 297 | +Device.set_renderer(put_pixel, line_renderer=line_adapter) |
| 298 | + |
| 299 | +grid = Grid(color=(30, 140, 200), dimensions=(30, 30)) |
| 300 | +suzanne = Model.load_OBJ('suzanne.obj.txt', position=(3, 2, -7), color=white, rasterize=True) |
| 301 | +beetle = Model.load_OBJ('beetle.obj.txt', wireframe=False, color=white, position=(0, 2, -11), scale=3) |
| 302 | +beetle.rotate(0, 45, 50) |
| 303 | + |
| 304 | +camera = Camera.get_instance() |
| 305 | +# move our camera up and back a bit, from origin |
| 306 | +camera.move(y=1, z=2) |
| 307 | + |
| 308 | + |
| 309 | +@capture_fps |
| 310 | +def frame(): |
| 311 | + if pygame.event.get(pygame.QUIT): |
| 312 | + sys.exit(0) |
| 313 | + |
| 314 | + screen.fill(black) |
| 315 | + handle_camera_with_keys() # we can move our camera |
| 316 | + grid.draw() |
| 317 | + beetle.draw() |
| 318 | + suzanne.rotate(0, 1, 0).draw() |
| 319 | + pygame.display.flip() |
| 320 | + |
| 321 | + |
| 322 | +while True: |
| 323 | + |
| 324 | + frame() |
| 325 | +``` |
0 commit comments