Resolved by PR #14
Bug Description
Nyx's geom-level export path appears to mishandle non-GLTF meshes that contain many sub-objects.
The attached hermetic repro generates two OBJ assets with the same geometry:
high_fanout.obj: 192 separate sub-objects
low_fanout.obj: the same geometry merged into one mesh
The minimal Genesis + Nyx scene is identical for both cases: one static mesh entity and one Nyx camera.
In the high_fanout case, the exported Nyx scene contains:
instance_count = 192
unique_uuid_count = 1
unique_uri_count = 1
top_uri_counts = [["../high_fanout.obj", 192]]
and Nyx emits 191 copies of:
[WARNING][Transform Component] Multiple references at pointing to the same instance.
In the low_fanout control case, the exported Nyx scene contains:
instance_count = 1
unique_uuid_count = 1
unique_uri_count = 1
This looks like the geom-level OBJ export path is not assigning distinct instance identity or distinct mesh references per exported sub-object.
Steps to Reproduce
Run this script in an environment with genesis-world, gs-nyx, gs-nyx-plugin, trimesh, and psutil installed:
#!/usr/bin/env python3
from __future__ import annotations
import argparse
import importlib.metadata
import json
import os
import resource
import shutil
import subprocess
import sys
import tempfile
import time
from collections import Counter
from pathlib import Path
import numpy as np
import trimesh
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser()
parser.add_argument("--count", type=int, default=192)
parser.add_argument("--subdivisions", type=int, default=2)
parser.add_argument("--worker", action="store_true")
parser.add_argument("--mesh-path", type=Path)
parser.add_argument("--workdir", type=Path)
parser.add_argument("--result-json", type=Path)
return parser.parse_args()
def peak_rss_gib() -> float:
return resource.getrusage(resource.RUSAGE_SELF).ru_maxrss / (1024.0 * 1024.0)
def make_component_mesh(subdivisions: int) -> trimesh.Trimesh:
mesh = trimesh.creation.icosphere(subdivisions=subdivisions, radius=0.35)
mesh.visual = trimesh.visual.ColorVisuals(
mesh=mesh,
vertex_colors=np.tile(np.array([[160, 190, 120, 255]], dtype=np.uint8), (len(mesh.vertices), 1)),
)
return mesh
def make_transform(idx: int, side: int) -> np.ndarray:
transform = np.eye(4)
transform[:3, 3] = [
float(idx % side) * 1.2,
float(idx // side) * 1.2,
0.2 * np.sin(idx * 0.17),
]
return transform
def export_high_fanout_obj(path: Path, *, count: int, subdivisions: int) -> None:
mesh = make_component_mesh(subdivisions)
scene = trimesh.Scene()
side = int(np.ceil(np.sqrt(count)))
for idx in range(count):
scene.add_geometry(
mesh.copy(),
node_name=f"node_{idx:04d}",
geom_name=f"geom_{idx:04d}",
transform=make_transform(idx, side),
)
scene.export(path)
def export_low_fanout_obj(path: Path, *, count: int, subdivisions: int) -> None:
base = make_component_mesh(subdivisions)
side = int(np.ceil(np.sqrt(count)))
meshes = []
for idx in range(count):
mesh = base.copy()
mesh.apply_transform(make_transform(idx, side))
meshes.append(mesh)
trimesh.util.concatenate(meshes).export(path)
def newest_nyx_scene_json(cache_root: Path) -> Path:
candidates = sorted(cache_root.glob("*/nyx_scene.json"), key=lambda p: p.stat().st_mtime)
assert candidates, f"no nyx_scene.json found under {cache_root}"
return candidates[-1]
def summarize_nyx_scene(scene_json_path: Path) -> dict[str, object]:
obj = json.loads(scene_json_path.read_text())
instance_array = obj["instance_array"]
uris = [instance["uri"] for instance in instance_array]
uri_counts = Counter(uris)
return {
"scene_json_path": str(scene_json_path),
"instance_count": len(instance_array),
"unique_uuid_count": len({instance["uuid"] for instance in instance_array}),
"unique_uri_count": len(uri_counts),
"top_uri_counts": uri_counts.most_common(3),
}
def worker(mesh_path: Path, workdir: Path, result_json: Path) -> None:
import genesis as gs
if workdir.exists():
shutil.rmtree(workdir)
workdir.mkdir(parents=True)
os.chdir(workdir)
t0 = time.perf_counter()
gs.init(backend=gs.gpu, logging_level="warning")
from gs_nyx_plugin.nyx_camera_options import NyxCameraOptions
scene = gs.Scene(show_viewer=False)
scene.add_entity(
gs.morphs.Mesh(
file=str(mesh_path),
fixed=True,
collision=False,
file_meshes_are_zup=False,
),
surface=gs.surfaces.Default(),
)
scene.add_sensor(
NyxCameraOptions(
res=(128, 128),
pos=(8.0, -18.0, 10.0),
lookat=(8.0, 8.0, 0.0),
up=(0.0, 0.0, 1.0),
fov=55.0,
spp=1,
denoise=False,
open_window=False,
)
)
scene.build(n_envs=1)
build_s = time.perf_counter() - t0
scene_json_path = newest_nyx_scene_json(workdir / "__nyx_cache__")
result = {
"mesh_path": str(mesh_path),
"build_s": round(build_s, 3),
"peak_rss_gib": round(peak_rss_gib(), 3),
**summarize_nyx_scene(scene_json_path),
}
result_json.write_text(json.dumps(result, indent=2))
def run_case(tmpdir: Path, label: str, mesh_path: Path) -> dict[str, object]:
workdir = tmpdir / f"run_{label}"
result_json = tmpdir / f"{label}_result.json"
cmd = [
sys.executable,
str(Path(__file__).resolve()),
"--worker",
"--mesh-path",
str(mesh_path),
"--workdir",
str(workdir),
"--result-json",
str(result_json),
]
proc = subprocess.run(cmd, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True, check=False)
result = json.loads(result_json.read_text()) if result_json.exists() else {}
warning_lines = [
line
for line in proc.stdout.splitlines()
if "Multiple references" in line or "Could not find instance" in line or "[ERROR][NYX]" in line
]
unique_warning_lines = list(dict.fromkeys(warning_lines))
result.update(
{
"returncode": proc.returncode,
"warning_count": len(warning_lines),
"warning_samples": unique_warning_lines[:5],
"log_tail": proc.stdout.splitlines()[-20:],
}
)
return result
def main() -> None:
args = parse_args()
if args.worker:
assert args.mesh_path is not None
assert args.workdir is not None
assert args.result_json is not None
worker(args.mesh_path, args.workdir, args.result_json)
return
with tempfile.TemporaryDirectory(prefix="nyx-geom-level-obj-memory-") as tmpdir_str:
tmpdir = Path(tmpdir_str)
high_obj = tmpdir / "high_fanout.obj"
low_obj = tmpdir / "low_fanout.obj"
export_high_fanout_obj(high_obj, count=args.count, subdivisions=args.subdivisions)
export_low_fanout_obj(low_obj, count=args.count, subdivisions=args.subdivisions)
summary = {
"versions": {
"python": sys.version.split()[0],
"genesis_world": importlib.metadata.version("genesis-world"),
"gs_nyx": importlib.metadata.version("gs-nyx"),
"gs_nyx_plugin": importlib.metadata.version("gs-nyx-plugin"),
},
"generation": {
"count": args.count,
"subdivisions": args.subdivisions,
"high_obj_size_mb": round(high_obj.stat().st_size / (1024.0 * 1024.0), 3),
"low_obj_size_mb": round(low_obj.stat().st_size / (1024.0 * 1024.0), 3),
},
"high_fanout": run_case(tmpdir, "high", high_obj),
"low_fanout": run_case(tmpdir, "low", low_obj),
}
print(json.dumps(summary, indent=2))
if __name__ == "__main__":
main()
Save it as repro.py and run:
On my machine it prints JSON showing:
high_fanout.instance_count = 192
high_fanout.unique_uuid_count = 1
high_fanout.unique_uri_count = 1
high_fanout.warning_count = 191
low_fanout.instance_count = 1
low_fanout.warning_count = 1
Expected Behavior
For a high-fanout OBJ, Nyx should export one distinct scene instance per geom with distinct instance identity and correct per-geom mesh/submesh reference handling.
The high-fanout case should not emit a warning for nearly every sub-object, and it should not export hundreds of Nyx instances that all share the same UUID and the same container OBJ URI.
Screenshots/Videos
Not applicable for this repro. The script prints JSON summary data and the relevant Nyx warnings directly.
Relevant log output
[WARNING][Transform Component] Multiple references at pointing to the same instance.
[WARNING][Transform Component] Multiple references at pointing to the same instance.
[WARNING][Transform Component] Multiple references at pointing to the same instance.
[ERROR][NYX][ASSERT_FAILURE] Failed to export shader library.
The repeated Multiple references at pointing to the same instance. warning only appears in the high_fanout case.
Environment
- OS: Ubuntu 22.04
- GPU/CPU: NVIDIA RTX 5000 Ada / AMD Threadripper 7970X
- GPU-driver version: 570.211.01
- CUDA / CUDA-toolkit version: host CUDA runtime 12.8
Release version or Commit ID
genesis-world==1.0.0
gs-nyx==0.1.1
gs-nyx-plugin==0.1.2
Additional Context
The important part is that the repro is fully hermetic:
- it generates both OBJ assets procedurally
- it uses the same minimal Genesis + Nyx scene for both cases
- the only difference is whether the OBJ is emitted as many separate sub-objects or one merged mesh
That makes the difference between the failing and control cases easy to verify locally.
Resolved by PR #14
Bug Description
Nyx's geom-level export path appears to mishandle non-GLTF meshes that contain many sub-objects.
The attached hermetic repro generates two OBJ assets with the same geometry:
high_fanout.obj: 192 separate sub-objectslow_fanout.obj: the same geometry merged into one meshThe minimal Genesis + Nyx scene is identical for both cases: one static mesh entity and one Nyx camera.
In the
high_fanoutcase, the exported Nyx scene contains:instance_count = 192unique_uuid_count = 1unique_uri_count = 1top_uri_counts = [["../high_fanout.obj", 192]]and Nyx emits
191copies of:[WARNING][Transform Component] Multiple references at pointing to the same instance.In the
low_fanoutcontrol case, the exported Nyx scene contains:instance_count = 1unique_uuid_count = 1unique_uri_count = 1This looks like the geom-level OBJ export path is not assigning distinct instance identity or distinct mesh references per exported sub-object.
Steps to Reproduce
Run this script in an environment with
genesis-world,gs-nyx,gs-nyx-plugin,trimesh, andpsutilinstalled:Save it as
repro.pyand run:On my machine it prints JSON showing:
high_fanout.instance_count = 192high_fanout.unique_uuid_count = 1high_fanout.unique_uri_count = 1high_fanout.warning_count = 191low_fanout.instance_count = 1low_fanout.warning_count = 1Expected Behavior
For a high-fanout OBJ, Nyx should export one distinct scene instance per geom with distinct instance identity and correct per-geom mesh/submesh reference handling.
The high-fanout case should not emit a warning for nearly every sub-object, and it should not export hundreds of Nyx instances that all share the same UUID and the same container OBJ URI.
Screenshots/Videos
Not applicable for this repro. The script prints JSON summary data and the relevant Nyx warnings directly.
Relevant log output
The repeated
Multiple references at pointing to the same instance.warning only appears in thehigh_fanoutcase.Environment
Release version or Commit ID
genesis-world==1.0.0gs-nyx==0.1.1gs-nyx-plugin==0.1.2Additional Context
The important part is that the repro is fully hermetic:
That makes the difference between the failing and control cases easy to verify locally.