Model Viewer

load a glTF model with PBR textures and frame it with an orbit camera.

▶ Run in browser

Tags: 3d gltf pbr camera

import_gltf(path) turns a glTF file into a node tree: a MeshInstance3D for each mesh primitive, with a PBR Material built from the file’s base colour, normal, occlusion-roughness-metallic and emissive textures. Sibling .bin and .png files are found relative to the .gltf path, so the call takes only that one path.

glTF models are authored at real-world scale, and samples differ by orders of magnitude: this radio is a few centimetres across, and an orbit distance that suits it shows nothing at all of a building. So the viewer measures the model once it is loaded. bounding_radius walks every MeshInstance3D under it with find_all, and the camera’s distance, zoom limits and near and far planes are all read off that radius, so pointing the viewer at another file needs no tuning.

An OrbitCamera3D takes its own input: drag with the mouse or one finger to orbit, and scroll or pinch to zoom. It circles the model on its own until you first drag it. A key light and a cool fill light, both DirectionalLight3D, light the scene.

The model is the Khronos BoomBox sample. It is third-party, so the installed example library does not ship it: if it is not beside this file it is downloaded once into $XDG_CACHE_HOME/simvx/example-assets/BoomBox (~/.cache by default). It is CC0-1.0; see assets/ATTRIBUTION.md.

Controls: Drag: orbit the camera Scroll / pinch: zoom

Source

  1# /// simvx
  2# tags = ["3d", "gltf", "pbr", "camera"]
  3# ///
  4"""Model Viewer: load a glTF model with PBR textures and frame it with an orbit camera.
  5
  6``import_gltf(path)`` turns a glTF file into a node tree: a ``MeshInstance3D``
  7for each mesh primitive, with a PBR ``Material`` built from the file's base colour,
  8normal, occlusion-roughness-metallic and emissive textures. Sibling ``.bin``
  9and ``.png`` files are found relative to the ``.gltf`` path, so the call takes
 10only that one path.
 11
 12glTF models are authored at real-world scale, and samples differ by orders of
 13magnitude: this radio is a few centimetres across, and an orbit distance that suits
 14it shows nothing at all of a building. So the viewer measures the model once
 15it is loaded. ``bounding_radius`` walks every ``MeshInstance3D`` under it with
 16``find_all``, and the camera's distance, zoom limits and near and far planes
 17are all read off that radius, so pointing the viewer at another file needs no
 18tuning.
 19
 20An ``OrbitCamera3D`` takes its own input: drag with the mouse or one finger to
 21orbit, and scroll or pinch to zoom. It circles the model on its own until you
 22first drag it. A key light and a cool fill light, both ``DirectionalLight3D``,
 23light the scene.
 24
 25The model is the Khronos BoomBox sample. It is third-party, so the installed
 26example library does not ship it: if it is not beside this file it is
 27downloaded once into ``$XDG_CACHE_HOME/simvx/example-assets/BoomBox``
 28(``~/.cache`` by default). It is CC0-1.0; see ``assets/ATTRIBUTION.md``.
 29
 30Controls:
 31    Drag: orbit the camera
 32    Scroll / pinch: zoom
 33"""
 34
 35import math
 36import os
 37import urllib.request
 38from pathlib import Path
 39
 40from simvx.core import Anchor, CanvasLayer, DirectionalLight3D, Label, MeshInstance3D, Node, OrbitCamera3D, on_input
 41from simvx.graphics import App, import_gltf
 42
 43BOOMBOX_URL = "https://raw.githubusercontent.com/KhronosGroup/glTF-Sample-Assets/main/Models/BoomBox/glTF"
 44BOOMBOX_FILES = (
 45    "BoomBox.gltf",
 46    "BoomBox.bin",
 47    "BoomBox_baseColor.png",
 48    "BoomBox_normal.png",
 49    "BoomBox_occlusionRoughnessMetallic.png",
 50    "BoomBox_emissive.png",
 51)
 52SPIN_SPEED = math.radians(20.0)  # auto-orbit, radians per second
 53FRAME_MARGIN = 1.15  # space around the framed model, so its corners stay in view as it turns
 54
 55
 56def boombox_dir() -> Path:
 57    """The BoomBox beside this file if it is there, else a per-user cached download."""
 58    here = Path(__file__).parent / "assets" / "BoomBox"
 59    if all((here / name).exists() for name in BOOMBOX_FILES):
 60        return here
 61    cache = Path(os.environ.get("XDG_CACHE_HOME", Path.home() / ".cache")) / "simvx" / "example-assets" / "BoomBox"
 62    cache.mkdir(parents=True, exist_ok=True)
 63    for name in BOOMBOX_FILES:
 64        if not (cache / name).exists():
 65            urllib.request.urlretrieve(f"{BOOMBOX_URL}/{name}", cache / name)
 66    return cache
 67
 68
 69def bounding_radius(model) -> float:
 70    """Distance from the model's origin to the furthest point of any mesh under it."""
 71    radius = 0.0
 72    # A single-mesh file imports as one MeshInstance3D, and find_all skips the node it is called on.
 73    for node in (model, *model.find_all(MeshInstance3D)):
 74        if isinstance(node, MeshInstance3D) and node.mesh is not None:
 75            offset = math.dist(node.world_position, model.world_position)
 76            radius = max(radius, offset + max(abs(node.world_scale)) * node.mesh.bounding_radius())
 77    return radius
 78
 79
 80class ModelViewer(Node):
 81    def on_ready(self):
 82        self.add_child(DirectionalLight3D(direction=(-1, -2, -1), intensity=1.5))
 83        self.add_child(DirectionalLight3D(direction=(1, -1, 2), intensity=0.4, colour=(0.6, 0.7, 1.0)))
 84
 85        model = self.add_child(import_gltf(str(boombox_dir() / "BoomBox.gltf")))
 86
 87        # Pull back until the model's bounding sphere fills the vertical field of view.
 88        radius = bounding_radius(model)
 89        fov = 45.0
 90        fit = radius / math.sin(math.radians(fov) / 2)
 91        self.camera = self.add_child(
 92            OrbitCamera3D(
 93                fov=fov,
 94                pivot=model.world_position,
 95                distance=fit * FRAME_MARGIN,
 96                min_distance=radius * 1.05,
 97                max_distance=fit * 8,
 98                pitch=math.radians(-20),
 99                yaw=0.0,
100                near=radius * 0.01,
101                far=fit * 8 + radius * 4,
102            )
103        )
104        self.spinning = True
105
106        # Example help overlay: not part of the technique shown.
107        hud = self.add_child(CanvasLayer())
108        hud.add_child(Label("Drag: orbit    Scroll / pinch: zoom", anchor=Anchor.BOTTOM_LEFT, inset=12))
109
110    @on_input("orbit_camera.drag")
111    def stop_spinning(self, _event):
112        self.spinning = False
113
114    def on_update(self, dt):
115        if self.spinning:
116            self.camera.orbit(SPIN_SPEED * dt, 0.0)
117
118
119if __name__ == "__main__":
120    App(title="Model Viewer", width=1280, height=720).run(ModelViewer())