Model Viewer¶
Load glTF models with PBR textures and orbit camera.
📄 Docs onlyTags: 3d
Auto-downloads the Khronos DamagedHelmet sample on first run.
Asset references use the canonical Resource(package, name) form, which
resolves through :mod:importlib.resources. The DamagedHelmet directory
under examples/assets/ is a real Python package (it ships an empty
__init__.py); we add examples/assets to sys.path so the package
is importable even when the demo is launched via a bare file path
(uv run python <file>). See docs/package_resources.md.
Controls: Left-click drag: orbit camera Scroll wheel: zoom in/out Escape: quit
Usage: uv run python examples/features/3d/model_viewer.py
Source¶
1"""Model Viewer: Load glTF models with PBR textures and orbit camera.
2
3# /// simvx
4# web = { disabled = true, reason = "glTF model loading not yet supported on web." }
5# ///
6
7Auto-downloads the Khronos DamagedHelmet sample on first run.
8
9Asset references use the canonical ``Resource(package, name)`` form, which
10resolves through :mod:`importlib.resources`. The ``DamagedHelmet`` directory
11under ``examples/assets/`` is a real Python package (it ships an empty
12``__init__.py``); we add ``examples/assets`` to ``sys.path`` so the package
13is importable even when the demo is launched via a bare file path
14(``uv run python <file>``). See ``docs/package_resources.md``.
15
16Controls:
17 Left-click drag: orbit camera
18 Scroll wheel: zoom in/out
19 Escape: quit
20
21Usage:
22 uv run python examples/features/3d/model_viewer.py
23"""
24
25import math
26import sys
27import urllib.request
28from pathlib import Path
29
30# Make ``examples/assets`` importable so ``Resource("DamagedHelmet", ...)``
31# resolves to the package next door. Mirrors how a game project would ship
32# its own assets dir on ``sys.path``; production games typically rely on
33# their normal package install path instead.
34_ASSETS_PARENT = (Path(__file__).parent / "assets").resolve()
35if str(_ASSETS_PARENT) not in sys.path:
36 sys.path.insert(0, str(_ASSETS_PARENT))
37
38from simvx.core import ( # noqa: E402
39 Camera3D,
40 DirectionalLight3D,
41 Input,
42 InputMap,
43 Key,
44 MouseButton,
45 Node,
46 Resource,
47)
48from simvx.graphics import App # noqa: E402
49
50# One canonical handle per asset. Construction is lazy: the underlying file
51# is not touched until ``.path`` / ``.read_bytes()`` is accessed.
52HELMET_GLTF = Resource("DamagedHelmet", "DamagedHelmet.gltf")
53HELMET_BIN = Resource("DamagedHelmet", "DamagedHelmet.bin")
54HELMET_TEXTURES = [
55 Resource("DamagedHelmet", "Default_albedo.jpg"),
56 Resource("DamagedHelmet", "Default_normal.jpg"),
57 Resource("DamagedHelmet", "Default_metalRoughness.jpg"),
58 Resource("DamagedHelmet", "Default_emissive.jpg"),
59 Resource("DamagedHelmet", "Default_AO.jpg"),
60]
61
62# Khronos glTF-Sample-Assets raw URLs
63_BASE_URL = "https://raw.githubusercontent.com/KhronosGroup/glTF-Sample-Assets" "/main/Models/DamagedHelmet/glTF"
64_DOWNLOAD_TARGETS = [HELMET_GLTF, HELMET_BIN, *HELMET_TEXTURES]
65
66
67def _download_assets() -> None:
68 """Download the DamagedHelmet package contents if any file is missing."""
69 asset_dir = (Path(__file__).parent / "assets" / "DamagedHelmet").resolve()
70 asset_dir.mkdir(parents=True, exist_ok=True)
71 for resource in _DOWNLOAD_TARGETS:
72 dest = asset_dir / resource.name
73 if dest.exists():
74 continue
75 url = f"{_BASE_URL}/{resource.name}"
76 print(f"Downloading {resource.name}...")
77 urllib.request.urlretrieve(url, dest)
78
79
80class ModelViewer(Node):
81 def on_ready(self):
82 InputMap.add_action("quit", [Key.ESCAPE])
83
84 # Orbit state
85 self._yaw = 0.0
86 self._pitch = 20.0
87 self._distance = 3.5
88 self._auto_rotate = True
89 self._target = (0.0, 0.0, 0.0)
90
91 # Camera
92 self._cam = Camera3D(name="Camera", fov=45, near=0.1, far=100.0)
93 self.add_child(self._cam)
94
95 # Lighting: key + fill
96 key = DirectionalLight3D(name="KeyLight", intensity=1.5)
97 key.look_at((-1.0, -2.0, -1.0))
98 self.add_child(key)
99
100 fill = DirectionalLight3D(name="FillLight", intensity=0.4, colour=(0.6, 0.7, 1.0))
101 fill.look_at((1.0, -1.0, 2.0))
102 self.add_child(fill)
103
104 # Load model. ``import_gltf`` is backend-agnostic: the same call
105 # works on desktop (Vulkan texture uploads) and in the web runtime
106 # (pixels streamed via the resource drain channel). In browser builds
107 # the glTF assets must be bundled alongside the game; when they're
108 # missing ``import_gltf`` returns an empty Node3D and the viewer
109 # shows just the lit backdrop.
110 self._load_model()
111
112 self._update_camera()
113
114 def _load_model(self) -> None:
115 # Desktop-only: download the Khronos sample if it isn't cached.
116 # ``urllib`` isn't available under Pyodide by default, so skip the
117 # fetch in browser builds: the user must bundle assets themselves.
118 from simvx.graphics.assets.scene_import import import_gltf
119 if sys.platform != "emscripten":
120 _download_assets()
121
122 # ``Resource.path`` resolves the package handle through
123 # importlib.resources to a real filesystem path the glTF parser can
124 # open. Sibling .bin / .jpg files referenced from inside the .gltf
125 # are picked up relative to that path automatically.
126 model = import_gltf(str(HELMET_GLTF.path))
127 model.name = "Helmet"
128 self.add_child(model)
129
130 def on_process(self, dt):
131 # Auto-rotate
132 if self._auto_rotate:
133 self._yaw += 20.0 * dt
134
135 # Mouse drag orbit (left button = mouse_1)
136 if Input.is_mouse_button_pressed(MouseButton.LEFT):
137 delta = Input.mouse_delta
138 dx, dy = float(delta.x), float(delta.y)
139 if abs(dx) > 0.1 or abs(dy) > 0.1:
140 self._auto_rotate = False
141 self._yaw -= dx * 0.3
142 self._pitch += dy * 0.3
143 self._pitch = max(-89.0, min(89.0, self._pitch))
144
145 # Scroll zoom
146 scroll = Input.scroll_delta
147 if scroll[1] != 0.0:
148 self._distance -= scroll[1] * 0.3
149 self._distance = max(1.0, min(20.0, self._distance))
150
151 # Escape to quit
152 if Input.is_action_just_pressed("quit"):
153 self.app.quit()
154 return
155
156 self._update_camera()
157
158 def _update_camera(self):
159 yaw_rad = math.radians(self._yaw)
160 pitch_rad = math.radians(self._pitch)
161 cp = math.cos(pitch_rad)
162 x = self._target[0] + self._distance * cp * math.sin(yaw_rad)
163 y = self._target[1] + self._distance * math.sin(pitch_rad)
164 z = self._target[2] + self._distance * cp * math.cos(yaw_rad)
165 self._cam.position = (x, y, z)
166 self._cam.look_at(self._target)
167
168
169if __name__ == "__main__":
170 app = App(title="Model Viewer: DamagedHelmet", width=1280, height=720)
171 app.run(ModelViewer())