Load Assets

Asset loaders parse files into data. Scene helpers or visual classes decide how that data is displayed.

OBJ with materials

result: ke.ObjImportView = self.scene.add_obj(
    path="/environment",
    obj_path=obj_file,
    double_sided=True,
)
result.root.set_local_scale(scale=ke.Vec3(scale, scale, scale))

Run:

python ./python/examples/view_obj_scene.py --obj-file /path/to/model.obj
Complete source: view_obj_scene.py
  1"""Generic OBJ/MTL scene viewer through the component/material path.
  2
  3This example is the reusable version of the Crytek Sponza viewer.  It uses
  4``SceneContext.add_obj()`` so imported meshes flow through:
  5
  6OBJ/MTL -> MeshComponent + MaterialBindingComponent + RenderComponent
  7        -> SceneResourceManager metadata mirrors.
  8
  9Typical usage:
 10
 11    python ./python/examples/view_obj_scene.py --obj-file=./assets/external/Scenes/foo/foo.obj
 12    python ./python/examples/view_obj_scene.py --obj-file=./assets/external/Scenes/CornellBox/CornellBox-Original.obj --root-path /cornell_box --scale 1.0
 13    python ./python/examples/view_obj_scene.py --preset=CRYTEK_SPONZA
 14"""
 15
 16from __future__ import annotations
 17
 18import argparse
 19import math
 20from pathlib import Path
 21
 22import kangengine as ke
 23from kangengine import imgui
 24
 25CRYTEK_SPONZA_OBJ = (
 26    Path(__file__).resolve().parents[2]
 27    / "assets"
 28    / "external"
 29    / "Scenes"
 30    / "crytek_sponza"
 31    / "sponza.obj"
 32)
 33
 34
 35def scene_root_from_path(path: Path) -> str:
 36    """Return a stable, scene-safe root path derived from an OBJ filename."""
 37
 38    safe = "".join(ch if ch.isalnum() or ch == "_" else "_" for ch in path.stem)
 39    safe = safe.strip("_") or "obj_scene"
 40    if safe[0].isdigit():
 41        safe = f"obj_{safe}"
 42    return f"/{safe}"
 43
 44
 45def normalize_root_path(path: str | None, obj_file: Path) -> str:
 46    if not path:
 47        return scene_root_from_path(obj_file)
 48    path = path.strip()
 49    if not path:
 50        return scene_root_from_path(obj_file)
 51    return path if path.startswith("/") else f"/{path}"
 52
 53
 54def parse_up_axis(value: str):
 55    value = value.upper()
 56    if value == "Y":
 57        return ke.UpAxis.Y
 58    if value == "Z":
 59        return ke.UpAxis.Z
 60    raise ValueError(f"unsupported up axis: {value}")
 61
 62
 63class ObjSceneViewer(ke.App):
 64    def __init__(
 65        self,
 66        obj_file: Path,
 67        *,
 68        root_path: str | None = None,
 69        title: str | None = None,
 70        scale: float = 1.0,
 71        double_sided: bool = True,
 72        show_ground: bool = False,
 73        ground_size: float | None = None,
 74        ground_y: float | None = None,
 75        light_direction=None,
 76        light_color=None,
 77        light_intensity: float = 1.15,
 78        light_ambient=None,
 79        camera_pos=None,
 80        camera_fov: float = 58.0,
 81    ):
 82        super().__init__()
 83        obj_file = Path(obj_file)
 84        self.obj_file = str(obj_file)
 85        self.root_path = normalize_root_path(root_path, obj_file)
 86        self.title = title or f"OBJ Scene: {obj_file.name}"
 87        self.scale = float(scale)
 88        self.double_sided = bool(double_sided)
 89        self.show_ground = bool(show_ground)
 90        self.ground_size = None if ground_size is None else float(ground_size)
 91        self.ground_y = None if ground_y is None else float(ground_y)
 92        self.light_direction = light_direction or ke.Vec3(-0.35, 0.82, -0.45)
 93        self.light_color = light_color or ke.Vec3(1.0, 0.96, 0.9)
 94        self.light_intensity = float(light_intensity)
 95        self.light_ambient = light_ambient or ke.Vec3(0.32, 0.32, 0.32)
 96        self.camera_pos = camera_pos if camera_pos else None
 97        self.camera_fov = float(camera_fov)
 98
 99    def setup(self):
100        self.standard_materials = self.create_standard_materials()
101        self.normal_maps_enabled = True
102        self.normal_texture_bindings = []
103        self.specular_texture_count = 0
104        self.alpha_texture_count = 0
105
106        self._configure_lighting()
107
108        self.import_view = self.scene.add_obj(
109            self.root_path,
110            self.obj_file,
111            double_sided=self.double_sided,
112        )
113        self.import_view.root.set_local_scale(
114            ke.Vec3(self.scale, self.scale, self.scale)
115        )
116
117        for view in self.import_view:
118            material = view.prim.get_material()
119            normal_map = getattr(material, "normal_map", None)
120            if normal_map is not None:
121                self.normal_texture_bindings.append((material, normal_map))
122            if getattr(material, "specular_map", None) is not None:
123                self.specular_texture_count += 1
124            if getattr(material, "alpha_map", None) is not None:
125                self.alpha_texture_count += 1
126
127        self.bounds_min, self.bounds_max = compute_obj_bounds(self.import_view.info)
128        if self.bounds_min is not None and self.bounds_max is not None:
129            self.bounds_min *= self.scale
130            self.bounds_max *= self.scale
131        self._setup_camera()
132
133        if self.show_ground:
134            self._add_ground_from_bounds()
135
136        print(
137            "OBJ scene loaded: "
138            f"{self.obj_file} root={self.root_path} "
139            f"subsets={len(self.import_view)} "
140            f"materials={self.import_view.info.material_count} "
141            f"textures={len(self.textures)} "
142            f"alpha_mapped={self.alpha_texture_count} "
143            f"normal_mapped={len(self.normal_texture_bindings)} "
144            f"specular_mapped={self.specular_texture_count}"
145        )
146
147    def render(self):
148        imgui.begin(self.title)
149        imgui.text(Path(self.obj_file).name)
150        imgui.text(f"root: {self.root_path}")
151        imgui.text(f"subsets: {len(self.import_view)}")
152        imgui.text(f"materials: {self.import_view.info.material_count}")
153        imgui.text(f"textures: {len(self.textures)}")
154        imgui.text(f"alpha maps: {self.alpha_texture_count}")
155        imgui.text(f"normal maps: {len(self.normal_texture_bindings)}")
156        imgui.text(f"specular maps: {self.specular_texture_count}")
157        changed, self.normal_maps_enabled = imgui.checkbox(
158            "normal maps",
159            self.normal_maps_enabled,
160        )
161        if changed:
162            self._apply_normal_map_toggle()
163        if self.bounds_min is not None and self.bounds_max is not None:
164            size = self.bounds_max - self.bounds_min
165            imgui.text(f"bounds: {size[0]:.2f}, {size[1]:.2f}, {size[2]:.2f}")
166        imgui.end()
167
168    def _configure_lighting(self):
169        self.set_light_direction(self.light_direction)
170        self.set_light_color(self.light_color)
171        self.set_light_intensity(self.light_intensity)
172        self.set_light_ambient(self.light_ambient)
173
174    def _setup_camera(self):
175        camera = self.get_camera()
176        camera.set_near_plane(0.05)
177        camera.set_far_plane(5000.0)
178        camera.set_fov(self.camera_fov)
179        if self.camera_pos:
180            camera.set_camera_pos(self.camera_pos)
181            camera.set_target_pos(self.camera_pos + ke.Vec3(-3.0, 0.0, 0.0))
182            self.set_camera_move_speed(max(5.0, 20.0 * self.scale))
183        else:
184            if self.bounds_min is None or self.bounds_max is None:
185                camera.set_camera_pos(ke.Vec3(0.0, 4.0, 14.0))
186                camera.set_target_pos(ke.Vec3(0.0, 2.0, 0.0))
187                self.set_camera_move_speed(max(1.0, 20.0 * self.scale))
188                return
189
190            center = (self.bounds_min + self.bounds_max) * 0.5
191            size = self.bounds_max - self.bounds_min
192            radius = max(float(math.sqrt(float((size * size).sum()))) * 0.5, 1.0)
193            camera.set_target_pos(
194                ke.Vec3(float(center[0]), float(center[1]), float(center[2]))
195            )
196            camera.set_camera_pos(
197                ke.Vec3(
198                    float(center[0] + radius * 0.15),
199                    float(center[1] + radius * 0.10),
200                    float(center[2] + radius * 0.85),
201                )
202            )
203            self.set_camera_move_speed(max(1.0, radius * 0.75))
204
205    def _add_ground_from_bounds(self):
206        size = None
207        ground_y = self.ground_y
208        if self.bounds_min is not None and self.bounds_max is not None:
209            bounds_size = self.bounds_max - self.bounds_min
210            size = max(float(bounds_size[0]), float(bounds_size[2]), 1.0) * 1.2
211            if ground_y is None:
212                ground_y = float(self.bounds_min[1])
213
214        ground_size = self.ground_size if self.ground_size is not None else size
215        if ground_size is None:
216            ground_size = max(20.0, 20.0 * self.scale)
217        if ground_y is None:
218            ground_y = 0.0
219
220        ground = self.add_ground(
221            "/ground",
222            scale=ground_size,
223            material=self.standard_materials.ground,
224        )
225        ground.prim.set_local_translation(ke.Vec3(0.0, ground_y, 0.0))
226
227    def _apply_normal_map_toggle(self):
228        for material, texture in self.normal_texture_bindings:
229            material.normal_map = texture if self.normal_maps_enabled else None
230        self.resources.invalidate_usage_cache()
231
232
233def compute_obj_bounds(info):
234    import numpy as np
235
236    mins = []
237    maxs = []
238    subsets = list(info.subsets)
239    meshes = [subset.mesh_data for subset in subsets] if subsets else [info.mesh_data]
240    for mesh in meshes:
241        vertices = mesh.vertices
242        if not vertices:
243            continue
244        arr = np.asarray([[v.x, v.y, v.z] for v in vertices], dtype=np.float32)
245        mins.append(arr.min(axis=0))
246        maxs.append(arr.max(axis=0))
247    if not mins:
248        return None, None
249    return np.stack(mins).min(axis=0), np.stack(maxs).max(axis=0)
250
251
252def build_parser() -> argparse.ArgumentParser:
253    parser = argparse.ArgumentParser(description="View an OBJ/MTL scene.")
254    parser.add_argument("--obj-file", type=Path, default=None)
255    parser.add_argument("--preset", type=str, default=None)
256    parser.add_argument("--root-path", default=None)
257    parser.add_argument("--scale", type=float, default=None)
258    parser.add_argument("--width", type=int, default=1920)
259    parser.add_argument("--height", type=int, default=1080)
260    parser.add_argument("--single-sided", action="store_true")
261    parser.add_argument("--ground", action="store_true")
262    parser.add_argument("--ground-size", type=float, default=None)
263    parser.add_argument("--ground-y", type=float, default=None)
264    parser.add_argument("--up-axis", choices=("Y", "Z"), default="Y")
265    parser.add_argument("--title", default=None)
266    return parser
267
268
269def main():
270    parser = build_parser()
271    args = parser.parse_args()
272    if not args.preset and not args.obj_file:
273        raise RuntimeError("Need to specify --obj-file or --preset")
274
275    preset = args.preset.upper() if args.preset else None
276    if preset == "CRYTEK_SPONZA":
277        args.obj_file = CRYTEK_SPONZA_OBJ
278    elif preset is not None:
279        raise ValueError(f"unknown OBJ scene preset: {args.preset}")
280
281    obj_file = args.obj_file.expanduser().resolve()
282    if not obj_file.exists():
283        raise FileNotFoundError(obj_file)
284
285    app = None
286    if preset == "CRYTEK_SPONZA":
287        scale = 0.01 if args.scale is None else args.scale
288        app = ObjSceneViewer(
289            obj_file,
290            root_path=args.root_path or "/crytek_sponza",
291            title=args.title or "Crytek Sponza",
292            scale=scale,
293            double_sided=not args.single_sided,
294            show_ground=args.ground,
295            ground_size=(
296                1200.0 * scale if args.ground_size is None else args.ground_size
297            ),
298            ground_y=(-4.0 * scale if args.ground_y is None else args.ground_y),
299            light_direction=ke.Vec3(-0.32, 0.93, -0.17),
300            light_color=ke.Vec3(1.0, 0.94, 0.86),
301            light_intensity=1.25,
302            light_ambient=ke.Vec3(0.38, 0.36, 0.32),
303            camera_pos=ke.Vec3(5.92, 4.87, -0.96),
304            camera_fov=58.0,
305        )
306    else:
307        scale = 1.0 if args.scale is None else args.scale
308        app = ObjSceneViewer(
309            obj_file,
310            root_path=args.root_path,
311            title=args.title,
312            scale=scale,
313            double_sided=not args.single_sided,
314            show_ground=args.ground,
315            ground_size=args.ground_size,
316            ground_y=args.ground_y,
317        )
318    app.initialize(args.width, args.height, False, parse_up_axis(args.up_axis))
319    app.start()
320
321
322if __name__ == "__main__":
323    main()

FBX mesh

meshes: list[ke.asset.FBXStaticMeshInfo] = ke.asset.FBXLoader.load_meshes(
    fbx_path=fbx_file,
    scale=scale,
)
for i, mesh in enumerate(meshes):
    self.scene.add_mesh(
        path=f"/fbx/mesh_{i}",
        mesh_data=mesh.mesh_data,
        material=material,
    )

USD

USD is available only in an explicitly USD-enabled development build. The distributed wheel configuration intentionally disables it.

Examples:

  • python/examples/view_fbx_mesh.py

  • python/examples/view_obj_scene.py

  • python/examples/view_usd_scene.py

  • python/examples/usd_file_bridge.py

Imported asset with materials