Model Viewer¶
load a glTF model with PBR textures and frame it with an orbit camera.
▶ Run in browserTags: 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())