meshshader-engine 0.2.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.
- meshshader_engine/__init__.py +12 -0
- meshshader_engine/__main__.py +6 -0
- meshshader_engine/application.py +278 -0
- meshshader_engine/assets/__init__.py +107 -0
- meshshader_engine/assets/bunny.py +46 -0
- meshshader_engine/assets/data/bunny.npz +0 -0
- meshshader_engine/assets/data/teapot.npz +0 -0
- meshshader_engine/assets/mesh_data.py +188 -0
- meshshader_engine/assets/meshlets.py +344 -0
- meshshader_engine/assets/primitives.py +189 -0
- meshshader_engine/assets/serialization.py +171 -0
- meshshader_engine/assets/teapot.py +38 -0
- meshshader_engine/assets/texture_image.py +221 -0
- meshshader_engine/camera.py +218 -0
- meshshader_engine/cli.py +261 -0
- meshshader_engine/config.py +1058 -0
- meshshader_engine/errors.py +97 -0
- meshshader_engine/gl/__init__.py +10 -0
- meshshader_engine/gl/backends/__init__.py +1 -0
- meshshader_engine/gl/backends/base.py +39 -0
- meshshader_engine/gl/backends/classic.py +121 -0
- meshshader_engine/gl/backends/mesh_nv.py +141 -0
- meshshader_engine/gl/buffers.py +276 -0
- meshshader_engine/gl/capabilities.py +253 -0
- meshshader_engine/gl/context.py +178 -0
- meshshader_engine/gl/debug.py +145 -0
- meshshader_engine/gl/hot_reload.py +91 -0
- meshshader_engine/gl/program.py +264 -0
- meshshader_engine/gl/reflection.py +181 -0
- meshshader_engine/gl/shader_abi.py +143 -0
- meshshader_engine/gl/shader_compiler.py +356 -0
- meshshader_engine/gl/textures.py +456 -0
- meshshader_engine/math3d.py +310 -0
- meshshader_engine/scene_state.py +128 -0
- meshshader_engine-0.2.0.dist-info/METADATA +1723 -0
- meshshader_engine-0.2.0.dist-info/RECORD +40 -0
- meshshader_engine-0.2.0.dist-info/WHEEL +5 -0
- meshshader_engine-0.2.0.dist-info/entry_points.txt +2 -0
- meshshader_engine-0.2.0.dist-info/licenses/LICENSE +674 -0
- meshshader_engine-0.2.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
"""MeshShaderEngine: a didactic engine for classical and
|
|
2
|
+
NVIDIA mesh-shader render paths.
|
|
3
|
+
|
|
4
|
+
The package wraps PyOpenGL boilerplate and gives students a small,
|
|
5
|
+
explicit interface made of TOML configuration, shader files, and
|
|
6
|
+
optional model animation. Importing this package must not trigger any
|
|
7
|
+
OpenGL or display side effects.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
__version__ = "0.2.0"
|
|
11
|
+
|
|
12
|
+
__all__ = ["__version__"]
|
|
@@ -0,0 +1,278 @@
|
|
|
1
|
+
"""Application loop: connects window, mouse input, scene state, and
|
|
2
|
+
render path.
|
|
3
|
+
|
|
4
|
+
This module is the only place that registers ``glfw`` input callbacks
|
|
5
|
+
and thereby binds the pure, window-system-independent
|
|
6
|
+
:class:`~meshshader_engine.camera.OrbitCamera` to actual mouse events.
|
|
7
|
+
The interaction sensitivities
|
|
8
|
+
(``_ORBIT_SENSITIVITY_RAD_PER_PIXEL``, ``_SCROLL_ZOOM_SENSITIVITY``,
|
|
9
|
+
``_DRAG_ZOOM_SENSITIVITY_PER_PIXEL``) are engine constants, not
|
|
10
|
+
configuration values -- only *whether* orbit/zoom are active is
|
|
11
|
+
configurable via ``interaction.orbit_enabled``/``zoom_enabled`` (see
|
|
12
|
+
README, section "TOML reference").
|
|
13
|
+
|
|
14
|
+
``run_experiment`` blocks until the window is closed and is therefore
|
|
15
|
+
deliberately not covered by automated tests (a test would either have
|
|
16
|
+
to run forever or artificially interrupt the event loop, without
|
|
17
|
+
still being meaningful). The individual building blocks (camera,
|
|
18
|
+
SceneState, ``ClassicBackend``, ``MeshNVBackend``, ``gl.context.window``)
|
|
19
|
+
are already tested independently; the backend choice itself is a
|
|
20
|
+
simple dictionary lookup via ``_RENDER_BACKENDS``, whose keys exactly
|
|
21
|
+
match the ``pipeline.backend`` values already validated in
|
|
22
|
+
``config.py``. The effect-applying part of the mouse callbacks
|
|
23
|
+
(``_apply_orbit_delta``, ``_apply_zoom_delta``) is factored out as a
|
|
24
|
+
pure function so that the acceptance criterion "disabled interaction
|
|
25
|
+
produces no unexpected input effects" (PLAN_SHADER_ENGINE.md, Phase 13)
|
|
26
|
+
is testable without a real context -- only the glfw callback
|
|
27
|
+
registration itself remains untested and is kept correspondingly
|
|
28
|
+
small.
|
|
29
|
+
"""
|
|
30
|
+
|
|
31
|
+
from __future__ import annotations
|
|
32
|
+
|
|
33
|
+
import time
|
|
34
|
+
from typing import Any
|
|
35
|
+
|
|
36
|
+
from meshshader_engine.camera import OrbitCamera
|
|
37
|
+
from meshshader_engine.config import ExperimentConfig
|
|
38
|
+
from meshshader_engine.gl.backends.classic import ClassicBackend
|
|
39
|
+
from meshshader_engine.gl.backends.mesh_nv import MeshNVBackend
|
|
40
|
+
from meshshader_engine.gl.context import window
|
|
41
|
+
from meshshader_engine.scene_state import SceneState
|
|
42
|
+
|
|
43
|
+
__all__ = ["run_experiment"]
|
|
44
|
+
|
|
45
|
+
# Backend selection via pipeline.backend (see config.py,
|
|
46
|
+
# _ALLOWED_BACKENDS -- already validated there against exactly these
|
|
47
|
+
# two values, so a KeyError here would be an internal consistency bug,
|
|
48
|
+
# not a user error).
|
|
49
|
+
_RENDER_BACKENDS = {"classic": ClassicBackend, "mesh_nv": MeshNVBackend}
|
|
50
|
+
|
|
51
|
+
_ORBIT_SENSITIVITY_RAD_PER_PIXEL = 0.005
|
|
52
|
+
_SCROLL_ZOOM_SENSITIVITY = 0.5
|
|
53
|
+
_DRAG_ZOOM_SENSITIVITY_PER_PIXEL = 0.02
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
class _MouseState:
|
|
57
|
+
"""Pure state of the last known mouse position and button status."""
|
|
58
|
+
|
|
59
|
+
def __init__(self) -> None:
|
|
60
|
+
self.orbit_dragging = False
|
|
61
|
+
self.zoom_dragging = False
|
|
62
|
+
self.last_x = 0.0
|
|
63
|
+
self.last_y = 0.0
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _apply_orbit_delta(
|
|
67
|
+
camera: OrbitCamera, dx: float, dy: float, *, enabled: bool
|
|
68
|
+
) -> None:
|
|
69
|
+
"""Apply a mouse-movement delta as an orbit, if ``enabled``.
|
|
70
|
+
|
|
71
|
+
Pure function with no glfw/GL dependency: with ``enabled=False``,
|
|
72
|
+
``camera`` is not touched at all, instead of merely being called
|
|
73
|
+
with a delta of 0 -- this makes "no unexpected input effects" when
|
|
74
|
+
interaction is disabled directly testable.
|
|
75
|
+
|
|
76
|
+
``dy`` is a GLFW screen-coordinate delta (Y grows downward), while
|
|
77
|
+
``OrbitCamera.orbit`` expects a world-space elevation delta (Y
|
|
78
|
+
grows upward, see ``math3d.py``). The sign of ``dy`` is therefore
|
|
79
|
+
NOT flipped: dragging upward (``dy`` negative in screen
|
|
80
|
+
coordinates) should lower the camera (negative elevation change)
|
|
81
|
+
-- the familiar "trackball" feel, where the front edge of the
|
|
82
|
+
object follows the mouse movement instead of running opposite to
|
|
83
|
+
it.
|
|
84
|
+
"""
|
|
85
|
+
if enabled:
|
|
86
|
+
camera.orbit(
|
|
87
|
+
-dx * _ORBIT_SENSITIVITY_RAD_PER_PIXEL,
|
|
88
|
+
dy * _ORBIT_SENSITIVITY_RAD_PER_PIXEL,
|
|
89
|
+
)
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def _apply_zoom_delta(
|
|
93
|
+
camera: OrbitCamera, delta: float, *, enabled: bool, sensitivity: float
|
|
94
|
+
) -> None:
|
|
95
|
+
"""Apply a zoom delta, if ``enabled``.
|
|
96
|
+
|
|
97
|
+
Pure function, shared by scroll-wheel and right-click-drag zoom
|
|
98
|
+
(see :func:`run_experiment`); the two input sources have very
|
|
99
|
+
different magnitudes (scroll "notches" vs. screen pixels), hence
|
|
100
|
+
the explicit ``sensitivity`` parameter instead of a shared
|
|
101
|
+
constant.
|
|
102
|
+
"""
|
|
103
|
+
if enabled:
|
|
104
|
+
camera.zoom(-delta * sensitivity)
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def _apply_resize(
|
|
108
|
+
camera: OrbitCamera, viewport_size: list[float], width: int, height: int
|
|
109
|
+
) -> None:
|
|
110
|
+
"""Update the camera aspect ratio and viewport size on a resize
|
|
111
|
+
event.
|
|
112
|
+
|
|
113
|
+
Pure function with no GL call (the actual ``glViewport()`` call
|
|
114
|
+
stays in the callback in :func:`run_experiment`). Non-positive
|
|
115
|
+
sizes (e.g. when minimizing the window under some window managers)
|
|
116
|
+
are silently ignored instead of raising an error --
|
|
117
|
+
``OrbitCamera.set_viewport`` itself would raise a ``ValueError``
|
|
118
|
+
for that, which would crash a window handler.
|
|
119
|
+
|
|
120
|
+
Parameters
|
|
121
|
+
----------
|
|
122
|
+
camera : meshshader_engine.camera.OrbitCamera
|
|
123
|
+
viewport_size : list[float]
|
|
124
|
+
Two-element list ``[width, height]``, updated in place (not a
|
|
125
|
+
tuple, since the caller must keep the current value across
|
|
126
|
+
frames).
|
|
127
|
+
width, height : int
|
|
128
|
+
New framebuffer size in pixels.
|
|
129
|
+
"""
|
|
130
|
+
if width <= 0 or height <= 0:
|
|
131
|
+
return
|
|
132
|
+
camera.set_viewport(width, height)
|
|
133
|
+
viewport_size[0] = float(width)
|
|
134
|
+
viewport_size[1] = float(height)
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def run_experiment(config: ExperimentConfig, *, visible: bool = True) -> None:
|
|
138
|
+
"""Run an experiment interactively in a window (blocking).
|
|
139
|
+
|
|
140
|
+
Left-click-drag orbits the camera (if ``interaction.orbit_enabled``).
|
|
141
|
+
Zoom (if ``interaction.zoom_enabled``) is possible in two ways, so
|
|
142
|
+
it also works on notebooks without a scroll wheel: scrolling, or
|
|
143
|
+
right-click-drag up/down (up = move closer, down = move farther --
|
|
144
|
+
symmetric to scrolling up/forward). Disabled interaction is already
|
|
145
|
+
checked in the respective callbacks before ``OrbitCamera.orbit``/
|
|
146
|
+
``zoom`` is called -- no mouse events are then forwarded to the
|
|
147
|
+
camera at all.
|
|
148
|
+
|
|
149
|
+
Parameters
|
|
150
|
+
----------
|
|
151
|
+
config : meshshader_engine.config.ExperimentConfig
|
|
152
|
+
visible : bool
|
|
153
|
+
Whether the window is created visibly. Default ``True`` for
|
|
154
|
+
real use via ``mse run``; Level B tests (see
|
|
155
|
+
``tests/integration``) set ``False`` to check the success path
|
|
156
|
+
without a flashing window.
|
|
157
|
+
|
|
158
|
+
Raises
|
|
159
|
+
------
|
|
160
|
+
GraphicsContextError
|
|
161
|
+
If no OpenGL context can be created, or (only for
|
|
162
|
+
``pipeline.backend = "mesh_nv"``) if ``GL_NV_mesh_shader`` is
|
|
163
|
+
not available.
|
|
164
|
+
ShaderCompilationError
|
|
165
|
+
If the configured shaders fail to compile/link, or violate the
|
|
166
|
+
shader ABI.
|
|
167
|
+
"""
|
|
168
|
+
import glfw
|
|
169
|
+
|
|
170
|
+
camera = OrbitCamera.from_config(config.camera, config.interaction)
|
|
171
|
+
scene = SceneState.from_config(config.light, config.animation, camera)
|
|
172
|
+
mouse = _MouseState()
|
|
173
|
+
|
|
174
|
+
with window(
|
|
175
|
+
width=config.window.width,
|
|
176
|
+
height=config.window.height,
|
|
177
|
+
title=config.window.title,
|
|
178
|
+
debug=True,
|
|
179
|
+
visible=visible,
|
|
180
|
+
) as handle:
|
|
181
|
+
camera.set_viewport(handle.width, handle.height)
|
|
182
|
+
viewport_size = [float(handle.width), float(handle.height)]
|
|
183
|
+
|
|
184
|
+
backend_factory = _RENDER_BACKENDS[config.pipeline.backend]
|
|
185
|
+
backend = backend_factory(config)
|
|
186
|
+
messenger = None
|
|
187
|
+
if handle.debug_context:
|
|
188
|
+
from meshshader_engine.gl.debug import DebugMessenger
|
|
189
|
+
|
|
190
|
+
messenger = DebugMessenger()
|
|
191
|
+
|
|
192
|
+
def _on_cursor_pos(_glfw_window: Any, x: float, y: float) -> None:
|
|
193
|
+
dx = x - mouse.last_x
|
|
194
|
+
dy = y - mouse.last_y
|
|
195
|
+
if mouse.orbit_dragging:
|
|
196
|
+
_apply_orbit_delta(
|
|
197
|
+
camera, dx, dy, enabled=config.interaction.orbit_enabled
|
|
198
|
+
)
|
|
199
|
+
if mouse.zoom_dragging:
|
|
200
|
+
# Dragging upward (dy < 0 in screen coordinates) should
|
|
201
|
+
# zoom in, see the run_experiment docstring; hence -dy.
|
|
202
|
+
_apply_zoom_delta(
|
|
203
|
+
camera,
|
|
204
|
+
-dy,
|
|
205
|
+
enabled=config.interaction.zoom_enabled,
|
|
206
|
+
sensitivity=_DRAG_ZOOM_SENSITIVITY_PER_PIXEL,
|
|
207
|
+
)
|
|
208
|
+
mouse.last_x = x
|
|
209
|
+
mouse.last_y = y
|
|
210
|
+
|
|
211
|
+
def _on_mouse_button(
|
|
212
|
+
_glfw_window: Any, button: int, action: int, _mods: int
|
|
213
|
+
) -> None:
|
|
214
|
+
if button == glfw.MOUSE_BUTTON_LEFT:
|
|
215
|
+
mouse.orbit_dragging = action == glfw.PRESS
|
|
216
|
+
elif button == glfw.MOUSE_BUTTON_RIGHT:
|
|
217
|
+
mouse.zoom_dragging = action == glfw.PRESS
|
|
218
|
+
|
|
219
|
+
def _on_scroll(
|
|
220
|
+
_glfw_window: Any, _x_offset: float, y_offset: float
|
|
221
|
+
) -> None:
|
|
222
|
+
_apply_zoom_delta(
|
|
223
|
+
camera,
|
|
224
|
+
y_offset,
|
|
225
|
+
enabled=config.interaction.zoom_enabled,
|
|
226
|
+
sensitivity=_SCROLL_ZOOM_SENSITIVITY,
|
|
227
|
+
)
|
|
228
|
+
|
|
229
|
+
def _on_framebuffer_size(
|
|
230
|
+
_glfw_window: Any, fb_width: int, fb_height: int
|
|
231
|
+
) -> None:
|
|
232
|
+
_apply_resize(camera, viewport_size, fb_width, fb_height)
|
|
233
|
+
if fb_width > 0 and fb_height > 0:
|
|
234
|
+
from OpenGL.GL import glViewport
|
|
235
|
+
|
|
236
|
+
glViewport(0, 0, fb_width, fb_height)
|
|
237
|
+
|
|
238
|
+
glfw.set_cursor_pos_callback(handle.glfw_window, _on_cursor_pos)
|
|
239
|
+
glfw.set_mouse_button_callback(handle.glfw_window, _on_mouse_button)
|
|
240
|
+
glfw.set_scroll_callback(handle.glfw_window, _on_scroll)
|
|
241
|
+
glfw.set_framebuffer_size_callback(
|
|
242
|
+
handle.glfw_window, _on_framebuffer_size
|
|
243
|
+
)
|
|
244
|
+
|
|
245
|
+
from OpenGL.GL import (
|
|
246
|
+
GL_COLOR_BUFFER_BIT,
|
|
247
|
+
GL_DEPTH_BUFFER_BIT,
|
|
248
|
+
GL_DEPTH_TEST,
|
|
249
|
+
glClear,
|
|
250
|
+
glClearColor,
|
|
251
|
+
glEnable,
|
|
252
|
+
)
|
|
253
|
+
|
|
254
|
+
# Depth testing (z-buffering): each fragment's depth is compared
|
|
255
|
+
# against the per-pixel depth buffer so that nearer geometry
|
|
256
|
+
# correctly occludes farther geometry, regardless of draw order.
|
|
257
|
+
glEnable(GL_DEPTH_TEST)
|
|
258
|
+
glClearColor(0.1, 0.1, 0.12, 1.0)
|
|
259
|
+
|
|
260
|
+
start_time = time.perf_counter()
|
|
261
|
+
try:
|
|
262
|
+
while not glfw.window_should_close(handle.glfw_window):
|
|
263
|
+
scene.time_seconds = time.perf_counter() - start_time
|
|
264
|
+
# Polling instead of an additional file watcher (see
|
|
265
|
+
# PLAN_SHADER_ENGINE.md, Phase 21, task 1); a failed
|
|
266
|
+
# reload logs itself and leaves the current program
|
|
267
|
+
# untouched (task 3).
|
|
268
|
+
backend.reload_shaders_if_changed()
|
|
269
|
+
glClear(GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT)
|
|
270
|
+
backend.draw(
|
|
271
|
+
scene, viewport_size=(viewport_size[0], viewport_size[1])
|
|
272
|
+
)
|
|
273
|
+
glfw.swap_buffers(handle.glfw_window)
|
|
274
|
+
glfw.poll_events()
|
|
275
|
+
finally:
|
|
276
|
+
if messenger is not None:
|
|
277
|
+
messenger.close()
|
|
278
|
+
backend.close()
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
"""Geometry generation and vertex data model.
|
|
2
|
+
|
|
3
|
+
Covers the shared ``MeshData`` contract, procedural primitives
|
|
4
|
+
(plane/cube), pre-built assets loaded from disk (teapot/bunny/a
|
|
5
|
+
student's custom mesh), and meshlet partitioning for the mesh-shader
|
|
6
|
+
render path.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from collections.abc import Callable
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
from typing import Final
|
|
14
|
+
|
|
15
|
+
from meshshader_engine.assets.bunny import load_bunny
|
|
16
|
+
from meshshader_engine.assets.mesh_data import MeshData
|
|
17
|
+
from meshshader_engine.assets.primitives import build_cube, build_plane
|
|
18
|
+
from meshshader_engine.assets.serialization import load_mesh_data
|
|
19
|
+
from meshshader_engine.assets.teapot import load_teapot
|
|
20
|
+
from meshshader_engine.errors import MeshDataError
|
|
21
|
+
|
|
22
|
+
__all__ = ["OBJECT_BUILDERS", "build_object", "load_custom_mesh"]
|
|
23
|
+
|
|
24
|
+
#: ``object.type`` (see :mod:`meshshader_engine.config`) -> a
|
|
25
|
+
#: zero-argument builder producing its ``MeshData``. Covers every
|
|
26
|
+
#: built-in object type; ``"custom"`` is handled separately by
|
|
27
|
+
#: :func:`build_object`, since it additionally needs a path. Single
|
|
28
|
+
#: source of truth shared by both render-path backends
|
|
29
|
+
#: (:mod:`meshshader_engine.gl.backends.classic`,
|
|
30
|
+
#: :mod:`meshshader_engine.gl.backends.mesh_nv`), which both need to
|
|
31
|
+
#: build the same object regardless of which backend renders it --
|
|
32
|
+
#: keeping one copy here instead of one per backend avoids the two
|
|
33
|
+
#: falling out of sync if a new object type is ever added.
|
|
34
|
+
OBJECT_BUILDERS: Final[dict[str, Callable[[], MeshData]]] = {
|
|
35
|
+
"plane": build_plane,
|
|
36
|
+
"cube": build_cube,
|
|
37
|
+
"teapot": load_teapot,
|
|
38
|
+
"bunny": load_bunny,
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def load_custom_mesh(path: Path) -> MeshData:
|
|
43
|
+
"""Load a student-provided custom mesh.
|
|
44
|
+
|
|
45
|
+
The file must already be in the engine's versioned ``MeshData``
|
|
46
|
+
``.npz`` format (see :mod:`meshshader_engine.assets.serialization`)
|
|
47
|
+
-- produced ahead of time from a student's OBJ file by
|
|
48
|
+
``tools/import_mesh.py`` (see README, section "Custom Student
|
|
49
|
+
Meshes"). This function does no format conversion itself, exactly
|
|
50
|
+
like :func:`meshshader_engine.assets.teapot.load_teapot`/
|
|
51
|
+
:func:`meshshader_engine.assets.bunny.load_bunny`.
|
|
52
|
+
|
|
53
|
+
Parameters
|
|
54
|
+
----------
|
|
55
|
+
path : pathlib.Path
|
|
56
|
+
Already-resolved path to the ``.npz`` file (see
|
|
57
|
+
:mod:`meshshader_engine.config`, "Path safety").
|
|
58
|
+
|
|
59
|
+
Returns
|
|
60
|
+
-------
|
|
61
|
+
MeshData
|
|
62
|
+
|
|
63
|
+
Raises
|
|
64
|
+
------
|
|
65
|
+
MeshDataError
|
|
66
|
+
If the file is missing (names the exact ``tools/import_mesh.py``
|
|
67
|
+
invocation to fix it) or invalid (see
|
|
68
|
+
:func:`meshshader_engine.assets.serialization.load_mesh_data`).
|
|
69
|
+
"""
|
|
70
|
+
if not path.exists():
|
|
71
|
+
raise MeshDataError(
|
|
72
|
+
f"Custom mesh not found: {path}. Convert your OBJ file first "
|
|
73
|
+
f"with 'python tools/import_mesh.py <model.obj> {path}' (see "
|
|
74
|
+
"README, section 'Custom Student Meshes')."
|
|
75
|
+
)
|
|
76
|
+
return load_mesh_data(path)
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def build_object(object_type: str, mesh_path: Path | None = None) -> MeshData:
|
|
80
|
+
"""Build or load the ``MeshData`` for one ``[object]`` entry,
|
|
81
|
+
regardless of whether it is procedural, a pre-built engine asset,
|
|
82
|
+
or a student's custom mesh.
|
|
83
|
+
|
|
84
|
+
Deliberately takes plain parameters rather than a
|
|
85
|
+
:class:`meshshader_engine.config.ObjectConfig`, so this module
|
|
86
|
+
stays importable and testable without depending on
|
|
87
|
+
:mod:`meshshader_engine.config` (same reasoning as
|
|
88
|
+
:func:`meshshader_engine.assets.texture_image.decode_texture_image`).
|
|
89
|
+
|
|
90
|
+
Parameters
|
|
91
|
+
----------
|
|
92
|
+
object_type : str
|
|
93
|
+
One of the keys in :data:`OBJECT_BUILDERS`, or ``"custom"``.
|
|
94
|
+
mesh_path : pathlib.Path or None
|
|
95
|
+
Required if and only if ``object_type == "custom"`` (already
|
|
96
|
+
enforced by :mod:`meshshader_engine.config`, so a violation
|
|
97
|
+
here would be an internal consistency bug, not a user error --
|
|
98
|
+
see the ``assert`` below).
|
|
99
|
+
|
|
100
|
+
Returns
|
|
101
|
+
-------
|
|
102
|
+
MeshData
|
|
103
|
+
"""
|
|
104
|
+
if object_type == "custom":
|
|
105
|
+
assert mesh_path is not None # already guaranteed by config.py
|
|
106
|
+
return load_custom_mesh(mesh_path)
|
|
107
|
+
return OBJECT_BUILDERS[object_type]()
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
"""Loading the Stanford Bunny from the pre-built asset.
|
|
2
|
+
|
|
3
|
+
Like the teapot (see :mod:`meshshader_engine.assets.teapot`), the
|
|
4
|
+
bunny is not generated procedurally but loaded from a file that
|
|
5
|
+
``tools/build_assets.py`` generates once from the official Stanford
|
|
6
|
+
source -- see README, section "Assets", for provenance and license
|
|
7
|
+
terms (notably: research/educational use and free redistribution are
|
|
8
|
+
permitted, commercial use is not, without Stanford's permission --
|
|
9
|
+
unlike this engine's own GPLv3 license).
|
|
10
|
+
|
|
11
|
+
At 34,834 vertices / 69,451 triangles (after dropping the source's
|
|
12
|
+
1,113 unreferenced vertices, see ``tools/build_assets.py``), this is
|
|
13
|
+
by far the highest-resolution object the engine ships, meant to make
|
|
14
|
+
meshlet partitioning (:mod:`meshshader_engine.assets.meshlets`)
|
|
15
|
+
visibly more interesting than the teapot's ~40 meshlets.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
from pathlib import Path
|
|
21
|
+
from typing import Final
|
|
22
|
+
|
|
23
|
+
from meshshader_engine.assets.mesh_data import MeshData
|
|
24
|
+
from meshshader_engine.assets.serialization import _load_prebuilt_asset
|
|
25
|
+
|
|
26
|
+
__all__ = ["BUNNY_ASSET_PATH", "load_bunny"]
|
|
27
|
+
|
|
28
|
+
#: Location of the pre-built bunny asset (see ``tools/build_assets.py``).
|
|
29
|
+
BUNNY_ASSET_PATH: Final[Path] = Path(__file__).parent / "data" / "bunny.npz"
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def load_bunny() -> MeshData:
|
|
33
|
+
"""Load the pre-built Stanford Bunny geometry.
|
|
34
|
+
|
|
35
|
+
Returns
|
|
36
|
+
-------
|
|
37
|
+
MeshData
|
|
38
|
+
|
|
39
|
+
Raises
|
|
40
|
+
------
|
|
41
|
+
MeshDataError
|
|
42
|
+
If the asset is missing (generate it with
|
|
43
|
+
``python tools/build_assets.py``, see README, section
|
|
44
|
+
"Assets") or invalid.
|
|
45
|
+
"""
|
|
46
|
+
return _load_prebuilt_asset(BUNNY_ASSET_PATH, "Bunny")
|
|
Binary file
|
|
Binary file
|
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
"""Immutable, validated vertex data model for triangulated meshes.
|
|
2
|
+
|
|
3
|
+
Data conventions (see ``PLAN_SHADER_ENGINE.md``, section 4.4, and
|
|
4
|
+
README, section "Vertex layout"):
|
|
5
|
+
|
|
6
|
+
- ``positions``: ``float32[N, 3]``, object-space (local/model-space)
|
|
7
|
+
position -- i.e. before the ``mse_Model`` transform; only becomes a
|
|
8
|
+
world-space position once multiplied by it in the shader (see
|
|
9
|
+
README, section "Coordinate Spaces for Lighting").
|
|
10
|
+
- ``normals``: ``float32[N, 3]``, row-wise unit vectors.
|
|
11
|
+
- ``uvs``: ``float32[N, 2]``, texture coordinates (no value range is
|
|
12
|
+
enforced, since repeating/tiled UVs outside [0, 1] are allowed).
|
|
13
|
+
- ``tangents``: ``float32[N, 4]``; ``tangents[:, :3]`` is row-wise
|
|
14
|
+
normalized, ``tangents[:, 3]`` holds the handedness (exactly +1 or
|
|
15
|
+
-1). The bitangent is NOT stored, but reconstructed in the shader:
|
|
16
|
+
``bitangent = cross(normal, tangent.xyz) * tangent.w``.
|
|
17
|
+
- ``indices``: ``uint32[M]`` with ``M % 3 == 0`` (a plain triangle
|
|
18
|
+
list, no fans/strips); every value lies in ``[0, N)``.
|
|
19
|
+
- Right-handed coordinate system, consistent with
|
|
20
|
+
:mod:`meshshader_engine.math3d`; triangles are wound
|
|
21
|
+
counter-clockwise (CCW) when viewed from the front (i.e. from the
|
|
22
|
+
direction the normal points).
|
|
23
|
+
|
|
24
|
+
``MeshData`` fully validates this contract in ``__post_init__`` and
|
|
25
|
+
then locks all referenced NumPy arrays against writes
|
|
26
|
+
(``flags.writeable = False``), so that a once-constructed instance
|
|
27
|
+
can no longer be accidentally mutated even via a shared array
|
|
28
|
+
reference.
|
|
29
|
+
"""
|
|
30
|
+
|
|
31
|
+
from __future__ import annotations
|
|
32
|
+
|
|
33
|
+
from dataclasses import dataclass
|
|
34
|
+
|
|
35
|
+
import numpy as np
|
|
36
|
+
|
|
37
|
+
from meshshader_engine.errors import MeshDataError
|
|
38
|
+
|
|
39
|
+
__all__ = ["MeshData"]
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
@dataclass(frozen=True, eq=False, slots=True)
|
|
43
|
+
class MeshData:
|
|
44
|
+
"""Triangulated, fully validated vertex data model.
|
|
45
|
+
|
|
46
|
+
``eq=False`` because the default comparison generated by
|
|
47
|
+
``@dataclass`` would compare NumPy arrays element-wise, raising a
|
|
48
|
+
``ValueError`` ("truth value of an array is ambiguous"); instance
|
|
49
|
+
identity is sufficient for this model.
|
|
50
|
+
|
|
51
|
+
Attributes
|
|
52
|
+
----------
|
|
53
|
+
positions : numpy.ndarray
|
|
54
|
+
Vertex positions, shape (N, 3), dtype float32.
|
|
55
|
+
normals : numpy.ndarray
|
|
56
|
+
Unit normals, shape (N, 3), dtype float32.
|
|
57
|
+
uvs : numpy.ndarray
|
|
58
|
+
Texture coordinates, shape (N, 2), dtype float32.
|
|
59
|
+
tangents : numpy.ndarray
|
|
60
|
+
Tangents, shape (N, 4), dtype float32; see the module
|
|
61
|
+
docstring for the convention used for ``tangents[:, 3]``
|
|
62
|
+
(handedness).
|
|
63
|
+
indices : numpy.ndarray
|
|
64
|
+
Triangle index list, shape (M,) with M % 3 == 0, dtype
|
|
65
|
+
uint32.
|
|
66
|
+
|
|
67
|
+
Raises
|
|
68
|
+
------
|
|
69
|
+
MeshDataError
|
|
70
|
+
If any array violates shape, dtype, value range (finite
|
|
71
|
+
values, normalized normals/tangents, valid handedness), or
|
|
72
|
+
index bounds.
|
|
73
|
+
"""
|
|
74
|
+
|
|
75
|
+
positions: np.ndarray
|
|
76
|
+
normals: np.ndarray
|
|
77
|
+
uvs: np.ndarray
|
|
78
|
+
tangents: np.ndarray
|
|
79
|
+
indices: np.ndarray
|
|
80
|
+
|
|
81
|
+
def __post_init__(self) -> None:
|
|
82
|
+
_validate(self)
|
|
83
|
+
for array in (
|
|
84
|
+
self.positions,
|
|
85
|
+
self.normals,
|
|
86
|
+
self.uvs,
|
|
87
|
+
self.tangents,
|
|
88
|
+
self.indices,
|
|
89
|
+
):
|
|
90
|
+
array.flags.writeable = False
|
|
91
|
+
|
|
92
|
+
@property
|
|
93
|
+
def vertex_count(self) -> int:
|
|
94
|
+
"""Number of vertices (rows in ``positions``)."""
|
|
95
|
+
return self.positions.shape[0]
|
|
96
|
+
|
|
97
|
+
@property
|
|
98
|
+
def triangle_count(self) -> int:
|
|
99
|
+
"""Number of triangles (``len(indices) // 3``)."""
|
|
100
|
+
return self.indices.shape[0] // 3
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
# --- Internal validation -----------------------------------------------------
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def _validate(mesh: MeshData) -> None:
|
|
107
|
+
_require_float_array(mesh.positions, "positions", ncols=3)
|
|
108
|
+
_require_float_array(mesh.normals, "normals", ncols=3)
|
|
109
|
+
_require_float_array(mesh.uvs, "uvs", ncols=2)
|
|
110
|
+
_require_float_array(mesh.tangents, "tangents", ncols=4)
|
|
111
|
+
|
|
112
|
+
n = mesh.positions.shape[0]
|
|
113
|
+
if n == 0:
|
|
114
|
+
raise MeshDataError("positions: must not be empty")
|
|
115
|
+
for name, array in (
|
|
116
|
+
("normals", mesh.normals),
|
|
117
|
+
("uvs", mesh.uvs),
|
|
118
|
+
("tangents", mesh.tangents),
|
|
119
|
+
):
|
|
120
|
+
if array.shape[0] != n:
|
|
121
|
+
raise MeshDataError(
|
|
122
|
+
f"{name}: expected {n} rows (like positions), got: "
|
|
123
|
+
f"{array.shape[0]}"
|
|
124
|
+
)
|
|
125
|
+
|
|
126
|
+
_require_unit_rows(mesh.normals, "normals")
|
|
127
|
+
_require_unit_rows(mesh.tangents[:, :3], "tangents[:, :3]")
|
|
128
|
+
|
|
129
|
+
handedness = mesh.tangents[:, 3]
|
|
130
|
+
if not np.all(np.isclose(np.abs(handedness), 1.0, atol=1e-6)):
|
|
131
|
+
raise MeshDataError("tangents[:, 3] (handedness) must be +1 or -1")
|
|
132
|
+
|
|
133
|
+
_validate_indices(mesh.indices, vertex_count=n)
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def _require_float_array(array: np.ndarray, name: str, *, ncols: int) -> None:
|
|
137
|
+
if not isinstance(array, np.ndarray):
|
|
138
|
+
raise MeshDataError(
|
|
139
|
+
f"{name}: must be a numpy.ndarray, got: {type(array).__name__}"
|
|
140
|
+
)
|
|
141
|
+
if array.dtype != np.float32:
|
|
142
|
+
raise MeshDataError(
|
|
143
|
+
f"{name}: expected dtype float32, got: {array.dtype}"
|
|
144
|
+
)
|
|
145
|
+
if array.ndim != 2 or array.shape[1] != ncols:
|
|
146
|
+
raise MeshDataError(
|
|
147
|
+
f"{name}: expected shape (N, {ncols}), got: shape={array.shape}"
|
|
148
|
+
)
|
|
149
|
+
if not np.all(np.isfinite(array)):
|
|
150
|
+
raise MeshDataError(f"{name}: contains non-finite values (NaN/Inf)")
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def _require_unit_rows(
|
|
154
|
+
array: np.ndarray, name: str, *, atol: float = 1e-4
|
|
155
|
+
) -> None:
|
|
156
|
+
norms = np.linalg.norm(array, axis=1)
|
|
157
|
+
if not np.all(np.isclose(norms, 1.0, atol=atol)):
|
|
158
|
+
bad_row = int(np.argmax(np.abs(norms - 1.0)))
|
|
159
|
+
raise MeshDataError(
|
|
160
|
+
f"{name}: must contain row-wise unit vectors; "
|
|
161
|
+
f"row {bad_row} has length {norms[bad_row]:.6f}"
|
|
162
|
+
)
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def _validate_indices(indices: np.ndarray, *, vertex_count: int) -> None:
|
|
166
|
+
if not isinstance(indices, np.ndarray):
|
|
167
|
+
raise MeshDataError(
|
|
168
|
+
f"indices: must be a numpy.ndarray, got: {type(indices).__name__}"
|
|
169
|
+
)
|
|
170
|
+
if indices.dtype != np.uint32:
|
|
171
|
+
raise MeshDataError(
|
|
172
|
+
f"indices: expected dtype uint32, got: {indices.dtype}"
|
|
173
|
+
)
|
|
174
|
+
if indices.ndim != 1:
|
|
175
|
+
raise MeshDataError(
|
|
176
|
+
f"indices: expected a 1D array, got: shape={indices.shape}"
|
|
177
|
+
)
|
|
178
|
+
if indices.shape[0] == 0 or indices.shape[0] % 3 != 0:
|
|
179
|
+
raise MeshDataError(
|
|
180
|
+
f"indices: length must be a positive multiple of 3, "
|
|
181
|
+
f"got: {indices.shape[0]}"
|
|
182
|
+
)
|
|
183
|
+
max_index = int(indices.max())
|
|
184
|
+
if max_index >= vertex_count:
|
|
185
|
+
raise MeshDataError(
|
|
186
|
+
f"indices: value {max_index} is outside the valid range "
|
|
187
|
+
f"[0, {vertex_count - 1}]"
|
|
188
|
+
)
|