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 +305 -0
- gui.py +131 -0
- main.py +105 -0
- mathengine.py +116 -0
- vpyrender-0.1.0.dist-info/METADATA +72 -0
- vpyrender-0.1.0.dist-info/RECORD +9 -0
- vpyrender-0.1.0.dist-info/WHEEL +5 -0
- vpyrender-0.1.0.dist-info/entry_points.txt +2 -0
- vpyrender-0.1.0.dist-info/top_level.txt +4 -0
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,,
|