Model Viewer

Load glTF models with PBR textures and orbit camera.

📄 Docs only

Tags: 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())