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.pypython/examples/view_obj_scene.pypython/examples/view_usd_scene.pypython/examples/usd_file_bridge.py
