Skip to content

[Bug]: Nyx geom-level OBJ export reuses the same instance UUID and mesh URI for every sub-object #13

Description

@NoahLinckeScout

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:

python repro.py

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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions