VPYrender 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
display_driver.py ADDED
@@ -0,0 +1,305 @@
1
+ """Software triangle renderer and built-in scene meshes."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from array import array
6
+ from dataclasses import dataclass
7
+ from math import floor, inf, sqrt
8
+ from typing import TypeAlias
9
+
10
+ from mathengine import Mat4, identity, multiply, scale, translation
11
+
12
+ RGB: TypeAlias = tuple[float, float, float]
13
+ ProjectedVertex: TypeAlias = tuple[float, float, float, float, float, float]
14
+ ProjectedTriangle: TypeAlias = tuple[ProjectedVertex, ProjectedVertex, ProjectedVertex, float]
15
+ SHADERS = ("Vertex", "Flat", "Toon", "Wireframe", "Depth")
16
+ BACKGROUND = (12, 17, 24)
17
+
18
+
19
+ @dataclass(frozen=True, slots=True)
20
+ class Mesh:
21
+ """Indexed mesh with interleaved XYZ position and RGB color attributes."""
22
+
23
+ vertices: tuple[float, ...]
24
+ indices: tuple[int, ...]
25
+
26
+ def __post_init__(self) -> None:
27
+ if not self.vertices or len(self.vertices) % 6:
28
+ raise ValueError("vertices must contain interleaved XYZ/RGB values")
29
+ if not self.indices or len(self.indices) % 3:
30
+ raise ValueError("indices must contain one or more triangles")
31
+ vertex_count = len(self.vertices) // 6
32
+ if min(self.indices) < 0 or max(self.indices) >= vertex_count:
33
+ raise ValueError("mesh index is outside the vertex array")
34
+
35
+
36
+ def cube_mesh() -> Mesh:
37
+ vertices = (
38
+ -1, -1, -1, 1.00, 0.26, 0.19,
39
+ 1, -1, -1, 1.00, 0.68, 0.20,
40
+ 1, 1, -1, 0.38, 0.82, 0.34,
41
+ -1, 1, -1, 0.15, 0.72, 0.67,
42
+ -1, -1, 1, 0.20, 0.52, 0.92,
43
+ 1, -1, 1, 0.46, 0.34, 0.88,
44
+ 1, 1, 1, 0.92, 0.34, 0.67,
45
+ -1, 1, 1, 0.95, 0.72, 0.32,
46
+ )
47
+ indices = (
48
+ 0, 2, 1, 0, 3, 2,
49
+ 4, 5, 6, 4, 6, 7,
50
+ 0, 1, 5, 0, 5, 4,
51
+ 3, 7, 6, 3, 6, 2,
52
+ 0, 4, 7, 0, 7, 3,
53
+ 1, 2, 6, 1, 6, 5,
54
+ )
55
+ return Mesh(vertices, indices)
56
+
57
+
58
+ @dataclass(frozen=True, slots=True)
59
+ class SceneObject:
60
+ mesh: Mesh
61
+ model: Mat4
62
+
63
+
64
+ @dataclass(frozen=True, slots=True)
65
+ class Scene:
66
+ objects: tuple[SceneObject, ...]
67
+
68
+ @property
69
+ def cube_count(self) -> int:
70
+ return len(self.objects) - 1
71
+
72
+
73
+ def make_scene(extra_cubes: int = 0) -> Scene:
74
+ if extra_cubes < 0:
75
+ raise ValueError("extra_cubes cannot be negative")
76
+ ground = Mesh((
77
+ -9, -1.2, -9, 0.16, 0.21, 0.24,
78
+ 9, -1.2, -9, 0.16, 0.21, 0.24,
79
+ 9, -1.2, 9, 0.22, 0.28, 0.29,
80
+ -9, -1.2, 9, 0.22, 0.28, 0.29,
81
+ ), (0, 2, 1, 0, 3, 2))
82
+ objects = [SceneObject(ground, identity())]
83
+ cube = cube_mesh()
84
+ for row in range(3):
85
+ for column in range(3):
86
+ x = (column - 1) * 2.2
87
+ z = (row - 1) * 2.2
88
+ model = multiply(translation(x, -0.68, z), scale(0.48, 0.48, 0.48))
89
+ objects.append(SceneObject(cube, model))
90
+ for index in range(extra_cubes):
91
+ column = index % 5
92
+ row = index // 5
93
+ x = (column - 2) * 1.15
94
+ z = 3.5 + row * 1.2
95
+ model = multiply(translation(x, -0.68, z), scale(0.35, 0.35, 0.35))
96
+ objects.append(SceneObject(cube, model))
97
+ return Scene(tuple(objects))
98
+
99
+
100
+ class Renderer:
101
+ """CPU depth-buffer renderer; completed scan bands stay in the RGB buffer."""
102
+
103
+ def __init__(self, size: tuple[int, int]) -> None:
104
+ self.width, self.height = size
105
+ if self.width < 1 or self.height < 1:
106
+ raise ValueError("render target dimensions must be positive")
107
+ self.pixels = bytearray(self.width * self.height * 3)
108
+ self.depth = array("f", [inf]) * (self.width * self.height)
109
+ self._cached_scene: Scene | None = None
110
+ self._cached_mvp: Mat4 | None = None
111
+ self._triangles: tuple[ProjectedTriangle, ...] = ()
112
+ self._smoke_points: tuple[tuple[float, float, float, float], ...] = ()
113
+
114
+ def _project_point(self, mvp: Mat4, point: tuple[float, float, float], radius: float) -> tuple[float, float, float, float] | None:
115
+ x, y, z = point
116
+ clip_x = mvp[0] * x + mvp[4] * y + mvp[8] * z + mvp[12]
117
+ clip_y = mvp[1] * x + mvp[5] * y + mvp[9] * z + mvp[13]
118
+ clip_z = mvp[2] * x + mvp[6] * y + mvp[10] * z + mvp[14]
119
+ clip_w = mvp[3] * x + mvp[7] * y + mvp[11] * z + mvp[15]
120
+ if clip_w <= 0.05:
121
+ return None
122
+ return (
123
+ (clip_x / clip_w * 0.5 + 0.5) * self.width,
124
+ (0.5 - clip_y / clip_w * 0.5) * self.height,
125
+ clip_z / clip_w,
126
+ radius * self.height / (2.0 * clip_w),
127
+ )
128
+
129
+ def _project_scene(self, scene: Scene, mvp: Mat4) -> None:
130
+ if scene is self._cached_scene and mvp == self._cached_mvp:
131
+ return
132
+ triangles: list[ProjectedTriangle] = []
133
+ light = (-0.35, 0.82, 0.45)
134
+ light_length = sqrt(sum(component * component for component in light))
135
+ light = tuple(component / light_length for component in light)
136
+ for scene_object in scene.objects:
137
+ transform = multiply(mvp, scene_object.model)
138
+ mesh = scene_object.mesh
139
+ projected: list[ProjectedVertex | None] = []
140
+ for offset in range(0, len(mesh.vertices), 6):
141
+ x, y, z, red, green, blue = mesh.vertices[offset:offset + 6]
142
+ clip_x = transform[0] * x + transform[4] * y + transform[8] * z + transform[12]
143
+ clip_y = transform[1] * x + transform[5] * y + transform[9] * z + transform[13]
144
+ clip_z = transform[2] * x + transform[6] * y + transform[10] * z + transform[14]
145
+ clip_w = transform[3] * x + transform[7] * y + transform[11] * z + transform[15]
146
+ if clip_w <= 0.05:
147
+ projected.append(None)
148
+ continue
149
+ reciprocal_w = 1.0 / clip_w
150
+ screen_x = (clip_x * reciprocal_w * 0.5 + 0.5) * self.width
151
+ screen_y = (0.5 - clip_y * reciprocal_w * 0.5) * self.height
152
+ projected.append((screen_x, screen_y, clip_z * reciprocal_w, red, green, blue))
153
+ for offset in range(0, len(mesh.indices), 3):
154
+ indices = mesh.indices[offset:offset + 3]
155
+ first, second, third = (projected[index] for index in indices)
156
+ if first is None or second is None or third is None:
157
+ continue
158
+ points = [mesh.vertices[index * 6:index * 6 + 3] for index in indices]
159
+ edge_a = tuple(points[1][axis] - points[0][axis] for axis in range(3))
160
+ edge_b = tuple(points[2][axis] - points[0][axis] for axis in range(3))
161
+ normal = (
162
+ edge_a[1] * edge_b[2] - edge_a[2] * edge_b[1],
163
+ edge_a[2] * edge_b[0] - edge_a[0] * edge_b[2],
164
+ edge_a[0] * edge_b[1] - edge_a[1] * edge_b[0],
165
+ )
166
+ normal_length = sqrt(sum(component * component for component in normal)) or 1.0
167
+ brightness = 0.22 + 0.78 * abs(sum(normal[axis] * light[axis] for axis in range(3)) / normal_length)
168
+ triangles.append((first, second, third, brightness))
169
+ self._triangles = tuple(triangles)
170
+ self._cached_scene = scene
171
+ self._cached_mvp = mvp
172
+ self._smoke_points = tuple(
173
+ point for point in (
174
+ self._project_point(mvp, (-2.8, 0.15, -1.6), 0.55),
175
+ self._project_point(mvp, (-2.35, 0.48, -1.65), 0.72),
176
+ self._project_point(mvp, (-1.85, 0.25, -1.7), 0.62),
177
+ self._project_point(mvp, (-2.3, 0.78, -1.65), 0.46),
178
+ ) if point is not None
179
+ )
180
+
181
+ def _clear_band(self, top: int, bottom: int) -> None:
182
+ background = bytes(BACKGROUND) * self.width
183
+ depth_row = array("f", [inf]) * self.width
184
+ for y in range(top, bottom):
185
+ pixel_start = y * self.width * 3
186
+ self.pixels[pixel_start:pixel_start + self.width * 3] = background
187
+ depth_start = y * self.width
188
+ self.depth[depth_start:depth_start + self.width] = depth_row
189
+
190
+ def _draw_triangle(self, triangle: ProjectedTriangle, top: int, bottom: int, shader: str) -> None:
191
+ first, second, third, brightness = triangle
192
+ ax, ay, az, ar, ag, ab = first
193
+ bx, by, bz, br, bg, bb = second
194
+ cx, cy, cz, cr, cg, cb = third
195
+ area = (by - cy) * (ax - cx) + (cx - bx) * (ay - cy)
196
+ if abs(area) < 0.0001:
197
+ return
198
+ left = max(0, int(min(ax, bx, cx)))
199
+ right = min(self.width - 1, int(max(ax, bx, cx)))
200
+ upper = max(top, 0, int(min(ay, by, cy)))
201
+ lower = min(bottom - 1, self.height - 1, int(max(ay, by, cy)))
202
+ if left > right or upper > lower:
203
+ return
204
+ for y in range(upper, lower + 1):
205
+ py = y + 0.5
206
+ for x in range(left, right + 1):
207
+ px = x + 0.5
208
+ weight_a = ((by - cy) * (px - cx) + (cx - bx) * (py - cy)) / area
209
+ weight_b = ((cy - ay) * (px - cx) + (ax - cx) * (py - cy)) / area
210
+ weight_c = 1.0 - weight_a - weight_b
211
+ if min(weight_a, weight_b, weight_c) < 0.0:
212
+ continue
213
+ if shader == "Wireframe" and min(weight_a, weight_b, weight_c) > 0.035:
214
+ continue
215
+ depth = (weight_a * az + weight_b * bz + weight_c * cz + 1.0) * 0.5
216
+ pixel_index = y * self.width + x
217
+ if depth >= self.depth[pixel_index]:
218
+ continue
219
+ self.depth[pixel_index] = depth
220
+ if shader == "Depth":
221
+ red = green = blue = int(max(0.0, min(1.0, 1.0 - depth)) * 255)
222
+ else:
223
+ if shader == "Flat":
224
+ red, green, blue = (ar + br + cr) / 3, (ag + bg + cg) / 3, (ab + bb + cb) / 3
225
+ else:
226
+ red = weight_a * ar + weight_b * br + weight_c * cr
227
+ green = weight_a * ag + weight_b * bg + weight_c * cg
228
+ blue = weight_a * ab + weight_b * bb + weight_c * cb
229
+ if shader == "Toon":
230
+ brightness = max(0.28, floor(brightness * 4) / 4)
231
+ red *= brightness
232
+ green *= brightness
233
+ blue *= brightness
234
+ red, green, blue = (int(max(0.0, min(1.0, value)) * 255) for value in (red, green, blue))
235
+ pixel_offset = pixel_index * 3
236
+ self.pixels[pixel_offset:pixel_offset + 3] = bytes((red, green, blue))
237
+
238
+ def _draw_smoke(self, top: int, bottom: int) -> None:
239
+ for center_x, center_y, ndc_depth, radius in self._smoke_points:
240
+ left = max(0, int(center_x - radius))
241
+ right = min(self.width - 1, int(center_x + radius))
242
+ upper = max(top, 0, int(center_y - radius))
243
+ lower = min(bottom - 1, self.height - 1, int(center_y + radius))
244
+ if radius < 1 or left > right or upper > lower:
245
+ continue
246
+ depth = (ndc_depth + 1.0) * 0.5
247
+ for y in range(upper, lower + 1):
248
+ for x in range(left, right + 1):
249
+ dx = (x + 0.5 - center_x) / radius
250
+ dy = (y + 0.5 - center_y) / radius
251
+ distance = dx * dx + dy * dy
252
+ if distance >= 1.0:
253
+ continue
254
+ pixel_index = y * self.width + x
255
+ if depth > self.depth[pixel_index] + 0.015:
256
+ continue
257
+ pixel_offset = pixel_index * 3
258
+ alpha = (1.0 - distance) * 0.28
259
+ for channel in range(3):
260
+ old = self.pixels[pixel_offset + channel]
261
+ self.pixels[pixel_offset + channel] = int(old * (1.0 - alpha) + 185 * alpha)
262
+
263
+ def render_band(
264
+ self,
265
+ scene: Scene,
266
+ mvp: Mat4,
267
+ top: int,
268
+ band_height: int,
269
+ shader: str = "Vertex",
270
+ smoke: bool = False,
271
+ ) -> tuple[int, int]:
272
+ if shader not in SHADERS:
273
+ raise ValueError(f"Unknown software shader: {shader}")
274
+ if band_height < 1:
275
+ raise ValueError("band_height must be positive")
276
+ start = max(0, top)
277
+ end = min(self.height, top + band_height)
278
+ if start >= end:
279
+ return start, start
280
+ self._project_scene(scene, mvp)
281
+ self._clear_band(start, end)
282
+ for triangle in self._triangles:
283
+ self._draw_triangle(triangle, start, end, shader)
284
+ if smoke:
285
+ self._draw_smoke(start, end)
286
+ return start, end
287
+
288
+ def render_all(self, scene: Scene, mvp: Mat4, shader: str = "Vertex", smoke: bool = False) -> bytearray:
289
+ self.render_band(scene, mvp, 0, self.height, shader, smoke)
290
+ return self.pixels
291
+
292
+
293
+ def write_ppm(path: str, pixels: bytes, width: int, height: int) -> None:
294
+ """Write tightly packed RGB pixels as a portable PPM image."""
295
+ expected_size = width * height * 3
296
+ if len(pixels) != expected_size:
297
+ raise ValueError(f"expected {expected_size} pixel bytes, got {len(pixels)}")
298
+ row_size = width * 3
299
+ flipped = b"".join(
300
+ pixels[row * row_size:(row + 1) * row_size]
301
+ for row in range(height - 1, -1, -1)
302
+ )
303
+ with open(path, "wb") as image:
304
+ image.write(f"P6\n{width} {height}\n255\n".encode("ascii"))
305
+ image.write(flipped)
gui.py ADDED
@@ -0,0 +1,131 @@
1
+ """Optional pygame window and sidebar controls for the software renderer."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any
6
+
7
+ from display_driver import Renderer, SHADERS, make_scene
8
+ from main import view_projection
9
+
10
+
11
+ def run(config: dict[str, Any]) -> None:
12
+ try:
13
+ import pygame
14
+ except ImportError as error:
15
+ raise RuntimeError("GUI support requires pygame-ce; install with: pip install '.[gui]'") from error
16
+
17
+ pygame.init()
18
+ width, height = config["width"], config["height"]
19
+ sidebar_width = 292
20
+ screen = pygame.display.set_mode((width + sidebar_width, height))
21
+ pygame.display.set_caption(config["title"])
22
+ renderer = Renderer((width, height))
23
+ frame = pygame.image.frombuffer(renderer.pixels, (width, height), "RGB")
24
+ scene = make_scene()
25
+ mvp = view_projection(width, height)
26
+ clock = pygame.time.Clock()
27
+ font = pygame.font.SysFont("consolas", 16)
28
+ small_font = pygame.font.SysFont("consolas", 13)
29
+ show_scan_bar = True
30
+ scan_speed = 300.0
31
+ scan_top = 0
32
+ scene_dirty = True
33
+ smoke = False
34
+ extra_cubes = 0
35
+ shader_index = 0
36
+ shader_menu_open = False
37
+ dragging_speed = False
38
+ running = True
39
+
40
+ def reset_scan() -> None:
41
+ nonlocal scan_top, scene_dirty
42
+ scan_top = 0
43
+ scene_dirty = True
44
+
45
+ def text(label: str, position: tuple[int, int], color: tuple[int, int, int] = (215, 224, 231), small: bool = False) -> None:
46
+ image = (small_font if small else font).render(label, True, color)
47
+ screen.blit(image, position)
48
+
49
+ def button(label: str, rectangle: Any, active: bool = False) -> None:
50
+ background = (51, 91, 88) if active else (39, 48, 58)
51
+ pygame.draw.rect(screen, background, rectangle, border_radius=4)
52
+ pygame.draw.rect(screen, (70, 87, 97), rectangle, 1, border_radius=4)
53
+ text(label, (rectangle.x + 10, rectangle.y + 8), (235, 241, 242))
54
+
55
+ try:
56
+ while running:
57
+ delta = min(clock.tick(60) / 1000.0, 0.05)
58
+ for event in pygame.event.get():
59
+ if event.type == pygame.QUIT:
60
+ running = False
61
+ elif event.type == pygame.KEYDOWN and event.key == pygame.K_ESCAPE:
62
+ running = False
63
+ elif event.type == pygame.MOUSEBUTTONUP and event.button == 1:
64
+ dragging_speed = False
65
+ elif event.type == pygame.MOUSEBUTTONDOWN and event.button == 1:
66
+ mouse_x, mouse_y = event.pos
67
+ if dragging_speed:
68
+ dragging_speed = False
69
+ if 18 <= mouse_x <= 274 and 78 <= mouse_y <= 114:
70
+ show_scan_bar = not show_scan_bar
71
+ elif 18 <= mouse_x <= 274 and 184 <= mouse_y <= 220:
72
+ smoke = not smoke
73
+ reset_scan()
74
+ elif 24 <= mouse_x <= 268 and 148 <= mouse_y <= 174:
75
+ dragging_speed = True
76
+ scan_speed = 30 + (mouse_x - 24) / 244 * 1170
77
+ elif 18 <= mouse_x <= 274 and 252 <= mouse_y <= 288:
78
+ shader_menu_open = not shader_menu_open
79
+ elif shader_menu_open and 18 <= mouse_x <= 274 and 292 <= mouse_y < 292 + 30 * len(SHADERS):
80
+ shader_index = (mouse_y - 292) // 30
81
+ shader_menu_open = False
82
+ reset_scan()
83
+ elif 18 <= mouse_x <= 274 and 448 <= mouse_y <= 484:
84
+ extra_cubes += 1
85
+ scene = make_scene(extra_cubes)
86
+ reset_scan()
87
+ elif 18 <= mouse_x <= 274 and 494 <= mouse_y <= 530:
88
+ extra_cubes = 0
89
+ scene = make_scene()
90
+ reset_scan()
91
+ elif event.type == pygame.MOUSEMOTION and dragging_speed:
92
+ mouse_x = max(24, min(268, event.pos[0]))
93
+ scan_speed = 30 + (mouse_x - 24) / 244 * 1170
94
+
95
+ if scene_dirty:
96
+ band_height = max(1, int(scan_speed * max(delta, 1 / 60)))
97
+ renderer.render_band(scene, mvp, scan_top, band_height, SHADERS[shader_index], smoke)
98
+ scan_top += band_height
99
+ if scan_top >= height:
100
+ scan_top = height
101
+ scene_dirty = False
102
+
103
+ screen.blit(frame, (sidebar_width, 0))
104
+ pygame.draw.rect(screen, (24, 30, 38), (0, 0, sidebar_width, height))
105
+ pygame.draw.line(screen, (59, 72, 80), (sidebar_width - 1, 0), (sidebar_width - 1, height), 1)
106
+ text("SOFTWARE RENDERER", (20, 20), (225, 160, 104))
107
+ text(f"SCENE {scene.cube_count} CUBES", (20, 49), small=True)
108
+ button(f"Show scan bar {'ON' if show_scan_bar else 'OFF'}", pygame.Rect(18, 78, 256, 36), show_scan_bar)
109
+ text(f"SCAN SPEED {int(scan_speed)} px/s", (20, 125), small=True)
110
+ pygame.draw.line(screen, (65, 78, 87), (24, 161), (268, 161), 4)
111
+ slider_x = int(24 + (scan_speed - 30) / 1170 * 244)
112
+ pygame.draw.line(screen, (222, 137, 81), (24, 161), (slider_x, 161), 4)
113
+ pygame.draw.circle(screen, (240, 182, 128), (slider_x, 161), 7)
114
+ button(f"Smoke {'ON' if smoke else 'OFF'}", pygame.Rect(18, 184, 256, 36), smoke)
115
+ text("SOFTWARE SHADER", (20, 230), small=True)
116
+ button(f"{SHADERS[shader_index]} v", pygame.Rect(18, 252, 256, 36))
117
+ if shader_menu_open:
118
+ for index, name in enumerate(SHADERS):
119
+ button(name, pygame.Rect(18, 292 + index * 30, 256, 28), index == shader_index)
120
+ button("+ Add cube", pygame.Rect(18, 448, 256, 36))
121
+ button("Reset scene", pygame.Rect(18, 494, 256, 36))
122
+ status = f"SCAN {min(100, int(scan_top / height * 100)):3d}%" if scene_dirty else "SCENE UP TO DATE"
123
+ text(status, (sidebar_width + 16, 14), (228, 184, 142), small=True)
124
+ text(f"{clock.get_fps():.0f} FPS", (sidebar_width + 16, 34), (213, 221, 228), small=True)
125
+ if show_scan_bar and scene_dirty:
126
+ scan_y = min(height - 1, scan_top)
127
+ pygame.draw.line(screen, (235, 145, 88), (sidebar_width, scan_y), (sidebar_width + width, scan_y), 2)
128
+ pygame.display.flip()
129
+ finally:
130
+ del frame
131
+ pygame.quit()
main.py ADDED
@@ -0,0 +1,105 @@
1
+ """Command-line entry point for the lightweight software renderer."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import json
7
+ import sys
8
+ from pathlib import Path
9
+ from time import perf_counter
10
+ from typing import Any
11
+
12
+ from display_driver import Renderer, make_scene, write_ppm
13
+ from mathengine import look_at, multiply, perspective
14
+
15
+ DEFAULT_CONFIG: dict[str, Any] = {
16
+ "gui": False,
17
+ "width": 960,
18
+ "height": 540,
19
+ "title": "PYrendering",
20
+ "frame_output": "frame.ppm",
21
+ }
22
+
23
+
24
+ def default_config_path() -> Path:
25
+ if getattr(sys, "frozen", False):
26
+ return Path(sys.executable).resolve().parent / "config.json"
27
+ return Path.cwd() / "config.json"
28
+
29
+
30
+ def load_config(path: Path) -> dict[str, Any]:
31
+ if not path.exists():
32
+ return DEFAULT_CONFIG.copy()
33
+ try:
34
+ config = json.loads(path.read_text(encoding="utf-8"))
35
+ except json.JSONDecodeError as error:
36
+ raise ValueError(f"Invalid JSON in {path}: {error}") from error
37
+ if not isinstance(config, dict):
38
+ raise ValueError("config.json must contain a JSON object")
39
+ merged = DEFAULT_CONFIG | config
40
+ if type(merged["gui"]) is not bool:
41
+ raise ValueError("config.gui must be true or false")
42
+ for key in ("width", "height"):
43
+ if type(merged[key]) is not int or merged[key] < 1:
44
+ raise ValueError(f"config.{key} must be a positive integer")
45
+ if not isinstance(merged["title"], str) or not isinstance(merged["frame_output"], str):
46
+ raise ValueError("config.title and config.frame_output must be strings")
47
+ return merged
48
+
49
+
50
+ def view_projection(width: int, height: int) -> tuple[float, ...]:
51
+ projection = perspective(1.0, width / height, 0.1, 100.0)
52
+ view = look_at((6.5, 5.2, 8.5), (0.0, -0.2, 0.0))
53
+ return multiply(projection, view)
54
+
55
+
56
+ def render_frame(config: dict[str, Any], output: Path) -> None:
57
+ renderer = Renderer((config["width"], config["height"]))
58
+ pixels = renderer.render_all(make_scene(), view_projection(config["width"], config["height"]))
59
+ write_ppm(str(output), pixels, config["width"], config["height"])
60
+ print(f"Rendered {config['width']}x{config['height']} frame to {output}")
61
+
62
+
63
+ def run_benchmark(config: dict[str, Any], frames: int = 10000) -> float:
64
+ if frames < 1:
65
+ raise ValueError("frames must be positive")
66
+ width = min(config["width"], 320)
67
+ height = min(config["height"], 180)
68
+ renderer = Renderer((width, height))
69
+ scene = make_scene()
70
+ mvp = view_projection(width, height)
71
+ band_height = max(1, height // 60)
72
+ started = perf_counter()
73
+ for index in range(frames):
74
+ top = (index * band_height) % height
75
+ renderer.render_band(scene, mvp, top, min(band_height, height - top))
76
+ elapsed = perf_counter() - started
77
+ bands_per_second = frames / elapsed
78
+ print(f"CPU software scan benchmark: {bands_per_second:,.0f} bands/s ({width}x{height}, {band_height} rows/band)")
79
+ return bands_per_second
80
+
81
+
82
+ def main() -> None:
83
+ parser = argparse.ArgumentParser(description="Minimal software 3D renderer")
84
+ parser.add_argument("--config", type=Path, default=default_config_path())
85
+ parser.add_argument("--benchmark", action="store_true", help="benchmark software scan throughput")
86
+ parser.add_argument("--frames", type=int, default=10000, help="benchmark frame count")
87
+ parser.add_argument("--output", type=Path, help="headless output image path")
88
+ arguments = parser.parse_args()
89
+ try:
90
+ config = load_config(arguments.config)
91
+ if arguments.benchmark:
92
+ run_benchmark(config, arguments.frames)
93
+ elif config["gui"]:
94
+ from gui import run
95
+
96
+ run(config)
97
+ else:
98
+ output = arguments.output or Path(config["frame_output"])
99
+ render_frame(config, output)
100
+ except (ImportError, RuntimeError) as error:
101
+ raise SystemExit(f"Renderer startup failed: {error}") from error
102
+
103
+
104
+ if __name__ == "__main__":
105
+ main()
mathengine.py ADDED
@@ -0,0 +1,116 @@
1
+ """Small column-major 3D math helpers for the renderer."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from math import cos, sin, tan
6
+ from typing import TypeAlias
7
+
8
+ Vec3: TypeAlias = tuple[float, float, float]
9
+ Mat4: TypeAlias = tuple[float, ...]
10
+
11
+
12
+ def _normalize(vector: Vec3) -> Vec3:
13
+ length = sum(component * component for component in vector) ** 0.5
14
+ if length == 0.0:
15
+ raise ValueError("Cannot normalize a zero-length vector")
16
+ return tuple(component / length for component in vector) # type: ignore[return-value]
17
+
18
+
19
+ def _subtract(left: Vec3, right: Vec3) -> Vec3:
20
+ return (left[0] - right[0], left[1] - right[1], left[2] - right[2])
21
+
22
+
23
+ def _cross(left: Vec3, right: Vec3) -> Vec3:
24
+ return (
25
+ left[1] * right[2] - left[2] * right[1],
26
+ left[2] * right[0] - left[0] * right[2],
27
+ left[0] * right[1] - left[1] * right[0],
28
+ )
29
+
30
+
31
+ def _dot(left: Vec3, right: Vec3) -> float:
32
+ return sum(a * b for a, b in zip(left, right))
33
+
34
+
35
+ def identity() -> Mat4:
36
+ return (
37
+ 1.0, 0.0, 0.0, 0.0,
38
+ 0.0, 1.0, 0.0, 0.0,
39
+ 0.0, 0.0, 1.0, 0.0,
40
+ 0.0, 0.0, 0.0, 1.0,
41
+ )
42
+
43
+
44
+ def multiply(left: Mat4, right: Mat4) -> Mat4:
45
+ if len(left) != 16 or len(right) != 16:
46
+ raise ValueError("Matrix multiplication requires two 4x4 matrices")
47
+ return tuple(
48
+ sum(left[index * 4 + row] * right[column * 4 + index] for index in range(4))
49
+ for column in range(4)
50
+ for row in range(4)
51
+ )
52
+
53
+
54
+ def perspective(field_of_view: float, aspect: float, near: float, far: float) -> Mat4:
55
+ if not 0.0 < field_of_view < 3.141592653589793:
56
+ raise ValueError("field_of_view must be between 0 and pi radians")
57
+ if aspect <= 0.0 or near <= 0.0 or far <= near:
58
+ raise ValueError("Expected aspect > 0 and 0 < near < far")
59
+ scale = 1.0 / tan(field_of_view / 2.0)
60
+ depth = near - far
61
+ return (
62
+ scale / aspect, 0.0, 0.0, 0.0,
63
+ 0.0, scale, 0.0, 0.0,
64
+ 0.0, 0.0, (far + near) / depth, -1.0,
65
+ 0.0, 0.0, (2.0 * far * near) / depth, 0.0,
66
+ )
67
+
68
+
69
+ def look_at(eye: Vec3, target: Vec3, up: Vec3 = (0.0, 1.0, 0.0)) -> Mat4:
70
+ forward = _normalize(_subtract(target, eye))
71
+ side = _normalize(_cross(forward, up))
72
+ camera_up = _cross(side, forward)
73
+ return (
74
+ side[0], camera_up[0], -forward[0], 0.0,
75
+ side[1], camera_up[1], -forward[1], 0.0,
76
+ side[2], camera_up[2], -forward[2], 0.0,
77
+ -_dot(side, eye), -_dot(camera_up, eye), _dot(forward, eye), 1.0,
78
+ )
79
+
80
+
81
+ def rotation_x(angle: float) -> Mat4:
82
+ cosine, sine = cos(angle), sin(angle)
83
+ return (
84
+ 1.0, 0.0, 0.0, 0.0,
85
+ 0.0, cosine, sine, 0.0,
86
+ 0.0, -sine, cosine, 0.0,
87
+ 0.0, 0.0, 0.0, 1.0,
88
+ )
89
+
90
+
91
+ def rotation_y(angle: float) -> Mat4:
92
+ cosine, sine = cos(angle), sin(angle)
93
+ return (
94
+ cosine, 0.0, -sine, 0.0,
95
+ 0.0, 1.0, 0.0, 0.0,
96
+ sine, 0.0, cosine, 0.0,
97
+ 0.0, 0.0, 0.0, 1.0,
98
+ )
99
+
100
+
101
+ def translation(x: float, y: float, z: float) -> Mat4:
102
+ return (
103
+ 1.0, 0.0, 0.0, 0.0,
104
+ 0.0, 1.0, 0.0, 0.0,
105
+ 0.0, 0.0, 1.0, 0.0,
106
+ x, y, z, 1.0,
107
+ )
108
+
109
+
110
+ def scale(x: float, y: float, z: float) -> Mat4:
111
+ return (
112
+ x, 0.0, 0.0, 0.0,
113
+ 0.0, y, 0.0, 0.0,
114
+ 0.0, 0.0, z, 0.0,
115
+ 0.0, 0.0, 0.0, 1.0,
116
+ )
@@ -0,0 +1,72 @@
1
+ Metadata-Version: 2.4
2
+ Name: VPYrender
3
+ Version: 0.1.0
4
+ Summary: A lightweight from-scratch software 3D renderer with optional desktop GUI
5
+ Requires-Python: >=3.10
6
+ Description-Content-Type: text/markdown
7
+ Provides-Extra: gui
8
+ Requires-Dist: pygame-ce>=2.5; extra == "gui"
9
+ Provides-Extra: build
10
+ Requires-Dist: build>=1.2; extra == "build"
11
+ Requires-Dist: pyinstaller>=6.0; extra == "build"
12
+
13
+ # PYrendering
14
+
15
+ A from-scratch, pure-Python software 3D renderer. It transforms and rasterizes triangles into a retained CPU framebuffer with a depth buffer; pygame-ce is used only for the optional desktop window and controls. No OpenGL, ModernGL, or GPU is required.
16
+
17
+ ## Run
18
+
19
+ Install the optional GUI and run `python main.py`:
20
+
21
+ ```powershell
22
+ python -m pip install -e ".[gui]"
23
+ vpyrender
24
+ ```
25
+
26
+ Set `"gui": true` in `config.json` to load the window and sidebar. Set it to `false` for a headless render to `frame.ppm`, or override the output with `--output`. The GUI includes scan visibility and speed, smoke, software-shader selection, add-cube, and reset-scene controls. Scene changes are rasterized a horizontal band at a time; already-rendered bands remain untouched until the next scan reaches them. Press Escape to close the window.
27
+
28
+ The command-line entry point also supports a CPU scan-band throughput check:
29
+
30
+ ```powershell
31
+ python main.py --benchmark --frames 10000
32
+ ```
33
+
34
+ The benchmark reports CPU scan-band throughput, not displayed FPS. This is a Python software renderer, so performance depends on CPU, resolution, and scene complexity; 4,000 FPS is not a realistic general-purpose guarantee.
35
+
36
+ ## Debug scripts
37
+
38
+ Run the scripts from the project root:
39
+
40
+ ```powershell
41
+ python test_scripts/mathtest.py
42
+ python test_scripts/meshtest.py
43
+ python test_scripts/configtest.py
44
+ python test_scripts/renderer_smoke_test.py
45
+ python test_scripts/benchmark.py --frames 10000
46
+ ```
47
+
48
+ The math, mesh, and renderer tests need only Python. Install the `gui` extra to run the interactive window. The CPU renderer runs on Raspberry Pi without an OpenGL driver; pygame-ce may require the platform's SDL development/runtime packages when installed from source.
49
+
50
+ ## Install on Linux / Raspberry Pi
51
+
52
+ After publishing to PyPI, install the dependency-free renderer with `python -m pip install VPYrender`; use `python -m pip install "VPYrender[gui]"` to include the optional window. From a local checkout, use `python -m pip install .` or `python -m pip install ".[gui]"`. The installed command is `vpyrender`. To build a wheel, install the build extra and run:
53
+
54
+ ```sh
55
+ python -m pip install '.[build]'
56
+ python -m build --wheel
57
+ python -m pip install dist/vpyrender-0.1.0-py3-none-any.whl
58
+ ```
59
+
60
+ The wheel is platform-independent and the headless renderer uses only the Python standard library.
61
+
62
+ ## Build a Windows executable
63
+
64
+ From PowerShell:
65
+
66
+ ```powershell
67
+ python -m pip install ".[gui,build]"
68
+ python -m PyInstaller --noconfirm --onefile --name pyrendering --exclude-module OpenGL --exclude-module cv2 --exclude-module numpy main.py
69
+ Copy-Item config.json dist\config.json
70
+ ```
71
+
72
+ The executable reads `config.json` beside itself. GUI builds bundle pygame-ce; headless-only builds can omit `--collect-all pygame` and install without the `gui` extra.
@@ -0,0 +1,9 @@
1
+ display_driver.py,sha256=54D1LbPfCoKCeZxv-spM9kUrQfajdycnHZttzlHibLg,11558
2
+ gui.py,sha256=l6dAgYp2n2Gg4bLPJjzNZ22l1QGWQz56duMqH7h2jRo,5506
3
+ main.py,sha256=F_W7nlvWU4c-i6yJ5ssng2Gqgym8Uytp6RoUHUKRHJk,3775
4
+ mathengine.py,sha256=TqMPjjAoeyj0C5MS0Z1qwvDaA-Ph2QLmbgvfK-Y3atg,3077
5
+ vpyrender-0.1.0.dist-info/METADATA,sha256=tYknNTbhhkBxhmDxTqUah5T10Y4ou9Tn30YPX90MQCI,3267
6
+ vpyrender-0.1.0.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
7
+ vpyrender-0.1.0.dist-info/entry_points.txt,sha256=digr_-cZjVCh564fhfiQqWwTseIfZwnrr440iC4Q1rE,40
8
+ vpyrender-0.1.0.dist-info/top_level.txt,sha256=g28heu3d3RP10Pf0Qx6eFvpTO6Amxdsf_pVFWv-H2Rw,35
9
+ vpyrender-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (84.0.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ vpyrender = main:main
@@ -0,0 +1,4 @@
1
+ display_driver
2
+ gui
3
+ main
4
+ mathengine