"""Nyx Scene Exporter for Genesis."""
# External dependencies
import os
import torch
import math
import numpy as np
from trimesh.visual.color import ColorVisuals
# Internal dependencies
import genesis as gs
from genesis.utils.misc import qd_to_torch
from gs_nyx import nyx_py_sdk as nps
# Relative imports
from .nyx_camera_shared_metadata import _nyx_random_string
from .nyx_scene_utils import (
get_rel_asset_path,
instance_type,
primitive_type,
primitive_scale,
file_morph_scale,
surface_for_material,
camera_get_quaternion,
particle_recon_budget,
is_exportable_entity,
is_rigid_entity,
is_pbd_entity,
is_fem_entity,
is_dynamic_mesh_entity,
is_mpm_visual_entity,
is_mpm_recon_entity,
is_sph_recon_entity,
should_export_at_geom_level,
)
# Only the main exporter class is part of the public API
__all__ = ["NyxSceneExporter"]
# Look up table for metal colors (We'll handle complex IORs later)
METAL_PROPERTIES = {
"aluminium": {"metallic_color": (0.916, 0.923, 0.924)},
"gold": {"metallic_color": (1.0, 0.773, 0.307)},
"copper": {"metallic_color": (0.932, 0.623, 0.522)},
"brass": {"metallic_color": (0.910, 0.778, 0.423)},
"iron": {"metallic_color": (0.530, 0.513, 0.494)},
"titanium": {"metallic_color": (0.441, 0.400, 0.361)},
"vanadium": {"metallic_color": (0.534, 0.526, 0.546)},
"lithium": {"metallic_color": (0.916, 0.890, 0.807)},
}
# ============================================================================
# SceneAsset Exporter
# ============================================================================
[docs]
class NyxSceneExporter:
"""Translate a Genesis scene into a Nyx ``SceneAsset`` JSON description.
Walks the built Genesis scene once to populate a
:class:`gs_nyx.nyx_py_sdk.SceneAsset` with one instance per renderable
sub-geometry, the cameras, lights, environment maps and light fields
that the renderer should use, then serializes the result with
:meth:`export_to_file`. The Nyx renderer reads the written JSON during
its scene load step.
Instances are emitted in solver order (rigid → FEM → PBD → MPM-visual →
MPM-recon) so the deformable index space matches the layout
:class:`NyxPyRenderer` uses when streaming per-frame vertex data and
calling ``set_deform_entity_active_triangles``.
Conversion handles
------------------
- Z-up (Genesis) → Y-up (Nyx) for static transforms and scales.
- WXYZ → XYZW quaternion handedness.
- Rigid entities are split into one Nyx instance per vgeom; dynamic-mesh
entities (FEM / PBD / MPM) emit a single ``DynamicMesh`` instance with
worst-case vertex / triangle budgets pre-allocated.
The :attr:`_entity_uuid_pairs` list it builds is the canonical mapping
used elsewhere in the plugin to translate Nyx UUIDs back to Genesis
entities (see :meth:`NyxPyRenderer.pick_pixel`).
"""
[docs]
def __init__(
self,
scene,
export_folder,
cameras=None,
lights=None,
asset_root_path=None,
env_maps=None,
light_fields=None,
):
"""Build the Nyx ``SceneAsset`` from a Genesis scene.
Runs the full export pipeline (instance / camera / light / env-map /
light-field build) immediately; call :meth:`export_to_file` after
construction to serialize the result. Asset side effects (e.g.
MJCF / procedural-mesh ``.obj`` exports) are written under
``export_folder`` during construction.
Parameters
----------
scene : genesis.Scene
Built Genesis scene to export. All entities with
``morph.visualization=True`` and a renderable type are included.
export_folder : str
Directory under ``__nyx_cache__/`` where the scene JSON and any
per-vgeom ``.obj`` files are written. Must already exist.
cameras : list of dict or list of genesis.Camera, optional
Cameras to write into the scene description. Pass camera-definition
dicts (the format produced by :meth:`NyxCameraSensor.build`) when
driving the renderer through Nyx sensors. When ``None``, the
scene's non-debug Genesis ``Camera`` objects are exported instead.
lights : list of gs_nyx.nyx_py_sdk.LightAsset, optional
Lights to include. ``None`` (default) means no lights.
asset_root_path : str, optional
Root prefix recorded in the scene description for resolving
relative asset paths. Defaults to empty string (paths are
relative to ``export_folder``).
env_maps : list of gs_nyx.nyx_py_sdk.EnvironmentMapAsset, optional
Environment maps to include. ``None`` (default) means none.
light_fields : list of gs_nyx.nyx_py_sdk.LightFieldAsset, optional
Pre-baked light fields to include. ``None`` (default) means none.
"""
# Keep track of the scene and additional properties
self._scene = scene
self._export_folder = export_folder
self._asset_root_path = asset_root_path if asset_root_path is not None else ""
self._genesis_assets_dir = gs.utils.get_assets_dir()
# Map that allows us to retrived uuids based on entities.
self._entity_uuid_pairs = []
# Create SceneAsset
self._scene_asset = nps.SceneAsset()
self._scene_asset.version = 1
self._scene_asset.rootFolder = "../../"
# Process cameras (only Genesis Camera objects)
if cameras is None:
self._cameras = [cam for cam in scene.visualizer.cameras if not cam.debug]
else:
self._cameras = cameras
# Process lights - if None, use empty list (caller should provide lights)
self._lights = lights if lights is not None else []
# Process env_maps - if None, use empty list (caller should provide env_maps)
self._env_maps = env_maps if env_maps is not None else []
# Process light_fields - if None, use empty list (caller should provide light_fields)
self._light_fields = light_fields if light_fields is not None else []
# Animation tracking
self._frame_counter = 0
# Build scene
self._build_scene()
def _build_scene(self):
# First we simply count how many instances will endup in our scene (scene level instances)
num_instances = 0
for entity in self._scene.entities:
# Some entities are simply not supported
if not is_exportable_entity(entity):
continue
# If we support the sub-scene export we simply export one instance per scene for this entity.
if should_export_at_geom_level(entity):
num_instances += entity.n_vgeoms
else:
num_instances += 1
# Enumerate the other components.
num_cameras = len(self._cameras)
num_lights = len(self._lights)
num_env_maps = len(self._env_maps)
num_light_fields = len(self._light_fields)
# Resize the buffers
self._scene_asset.instance_resize(num_instances)
self._scene_asset.camera_resize(num_cameras)
self._scene_asset.light_resize(num_lights)
self._scene_asset.env_map_resize(num_env_maps)
self._scene_asset.light_field_resize(num_light_fields)
# Build all the scene components.
self._build_instances()
self._build_cameras()
self._build_lights()
self._build_env_maps()
self._build_light_fields()
def _build_instances(self):
"""Build all mesh/primitive instances, sorted by solver type (rigid first, then FEM, PBD, MPM)."""
instance_idx = 0
# Collect exportable entities preserving original order
exportable_entities = [
e for e in self._scene.entities if is_exportable_entity(e)
]
# Order: rigid -> FEM -> PBD -> MPM (visual then recon) -> SPH (recon). This matches the
# order the renderer walks deformables when building the reference / indirection table, so
# the deform index used by set_deform_entity_active_triangles() lines up.
rigid_entities = [e for e in exportable_entities if is_rigid_entity(e)]
fem_entities = [e for e in exportable_entities if is_fem_entity(e)]
pbd_entities = [e for e in exportable_entities if is_pbd_entity(e)]
mpm_visual_entities = [
e for e in exportable_entities if is_mpm_visual_entity(e)
]
mpm_recon_entities = [e for e in exportable_entities if is_mpm_recon_entity(e)]
sph_recon_entities = [e for e in exportable_entities if is_sph_recon_entity(e)]
sorted_entities = (
rigid_entities
+ fem_entities
+ pbd_entities
+ mpm_visual_entities
+ mpm_recon_entities
+ sph_recon_entities
)
# Loop through the entities and process them.
for entity in sorted_entities:
if should_export_at_geom_level(entity):
instance_idx = self._build_vgeom_instances(entity, instance_idx)
else:
instance_idx = self._build_entity_instance(entity, instance_idx)
# Function that is able to apply textures to a material override.
def _apply_texture(
self,
surface,
mat,
vmesh,
surface_attr,
mat_property,
mat_attr,
metadata_key=None,
):
texture = getattr(surface, surface_attr, None)
if texture is None:
return False
# Raise the property, it will be overriden
mat.set_properties(mat_property)
# Is it a full image or just a value?
if isinstance(texture, gs.textures.ImageTexture):
# Non genesis resource
if texture.image_path:
# Use the path as is.
setattr(mat, mat_attr, texture.image_path)
# Genesis resource, use relative paths
elif (
vmesh is not None
and hasattr(vmesh, "metadata")
and metadata_key
and metadata_key in vmesh.metadata
):
# Need to apply the genesis path
setattr(
mat,
mat_attr,
get_rel_asset_path(
vmesh.metadata[metadata_key], self._genesis_assets_dir, "."
),
)
elif isinstance(texture, gs.textures.ColorTexture):
setattr(mat, mat_attr, "")
# Succesfully overriden
return True
def _build_material_override(
self, surface, entity=None, vmesh=None, overriddenSurface=False
):
"""Build material override from Genesis surface.
Args:
surface: Genesis surface object
entity: Optional entity object (needed for plane UV scale handling)
vmesh: Optional vmesh object (for accessing metadata like texture_path)
"""
mat = nps.MaterialAsset()
# If the surface was overriden, unset all the textures.
if overriddenSurface:
mat.set_properties(nps.EMaterialProperty.AlbedoTexture)
mat.albedoTexture = ""
mat.set_properties(nps.EMaterialProperty.NormalTexture)
mat.normalTexture = ""
mat.set_properties(nps.EMaterialProperty.ArmTexture)
mat.armTexture = ""
mat.set_properties(nps.EMaterialProperty.EmissionTexture)
mat.emissionTexture = ""
# Handle metals
if isinstance(surface, gs.surfaces.Metal) and surface.metal_type is not None:
metal_props = METAL_PROPERTIES.get(surface.metal_type)
if metal_props is not None:
mat.set_properties(nps.EMaterialProperty.AlbedoColor)
mat.set_properties(nps.EMaterialProperty.Metalness)
color = metal_props["metallic_color"]
mat.albedoColor = nps.float4(color[0], color[1], color[2], 1.0)
mat.metalness = 1.0
# Colors and alpha
if hasattr(surface, "color") and surface.color is not None:
mat.set_properties(nps.EMaterialProperty.AlbedoColor)
color = surface.color
alpha = getattr(surface, "opacity", None)
if alpha is None:
alpha = 1.0
mat.albedoColor = nps.float4(color[0], color[1], color[2], alpha)
elif hasattr(surface, "opacity") and surface.opacity is not None:
mat.set_properties(nps.EMaterialProperty.AlbedoColor)
mat.albedoColor = nps.float4(1.0, 1.0, 1.0, surface.opacity)
# Emission
hasEmission = False
if hasattr(surface, "emissive") and surface.emissive is not None:
mat.set_properties(nps.EMaterialProperty.Emission)
mat.emission = nps.float3(*surface.emissive)
hasEmission = True
else:
emissive_tex = getattr(surface, "emissive_texture", None)
if isinstance(emissive_tex, gs.textures.ColorTexture):
# Genesis routes Emission(color=...)
# This converts back the genesis texture to an RGB value.
c = np.atleast_1d(np.asarray(emissive_tex.color)).ravel()
r = float(c[0])
g = float(c[1]) if c.size > 1 else r
b = float(c[2]) if c.size > 2 else r
mat.set_properties(nps.EMaterialProperty.Emission)
mat.emission = nps.float3(r, g, b)
hasEmission = True
# Roughness
if hasattr(surface, "roughness") and surface.roughness is not None:
mat.set_properties(nps.EMaterialProperty.PerceptualRoughness)
mat.perceptualRoughness = surface.roughness
# Metallic
if hasattr(surface, "metallic") and surface.metallic is not None:
mat.set_properties(nps.EMaterialProperty.Metalness)
mat.metalness = surface.metallic
# Albedo texture
hasDiffuse = self._apply_texture(
surface=surface,
mat=mat,
vmesh=vmesh,
surface_attr="diffuse_texture",
mat_property=nps.EMaterialProperty.AlbedoTexture,
mat_attr="albedoTexture",
metadata_key="texture_path",
)
# Normal texture
self._apply_texture(
surface=surface,
mat=mat,
vmesh=vmesh,
surface_attr="normal_texture",
mat_property=nps.EMaterialProperty.NormalTexture,
mat_attr="normalTexture",
metadata_key="normal_texture_path",
)
# Emission texture
hasEmission = hasEmission or self._apply_texture(
surface=surface,
mat=mat,
vmesh=vmesh,
surface_attr="emissive_texture",
mat_property=nps.EMaterialProperty.EmissionTexture,
mat_attr="emissionTexture",
metadata_key="emissive_texture_path",
)
# Double sided support
if hasattr(surface, "double_sided"):
mat.set_properties(nps.EMaterialProperty.Sidedness)
mat.sidedness = nps.EMaterialSidedness.Double
# In the specific case of the diffuse texture and a plane, we handle UVs
if (
hasDiffuse
and entity is not None
and isinstance(entity.morph, gs.morphs.Plane)
):
plane_size = entity.morph.plane_size
mat.set_properties(nps.EMaterialProperty.UVScale)
mat.uvScale = nps.float2(float(plane_size[0]), float(plane_size[1]))
# For now, if it emissive, we kill the other lobes.
if hasEmission:
mat.set_properties(nps.EMaterialProperty.AlbedoColor)
mat.albedoColor = nps.float4(0.0, 0.0, 0.0, 1.0)
mat.set_properties(nps.EMaterialProperty.Metalness)
mat.metalness = 1.0
return mat
def _build_full_material(self, surface, entity=None):
"""Build a complete material description for dynamic meshes.
Unlike _build_material_override which only exports properties that differ from defaults,
this method exports all material properties needed for rendering since dynamic meshes
don't have a mesh file with embedded materials to fall back on.
"""
mat = nps.MaterialAsset()
# Always set albedo color (default to white if not specified)
mat.set_properties(nps.EMaterialProperty.AlbedoColor)
if hasattr(surface, "color") and surface.color is not None:
color = surface.color
alpha = (
getattr(surface, "opacity", 1.0) if surface.opacity is not None else 1.0
)
mat.albedoColor = nps.float4(color[0], color[1], color[2], alpha)
else:
alpha = (
getattr(surface, "opacity", 1.0)
if hasattr(surface, "opacity") and surface.opacity is not None
else 1.0
)
mat.albedoColor = nps.float4(1.0, 1.0, 1.0, alpha)
# Only set roughness and metallic if they are specified, otherwise leave them at Nyx default
if hasattr(surface, "roughness") and surface.roughness is not None:
mat.set_properties(nps.EMaterialProperty.PerceptualRoughness)
mat.perceptualRoughness = surface.roughness
# Only set metallic if it's specified, otherwise leave it at Nyx default
if hasattr(surface, "metallic") and surface.metallic is not None:
mat.set_properties(nps.EMaterialProperty.Metalness)
mat.metalness = surface.metallic
# Textures (if available)
if hasattr(surface, "diffuse_texture") and surface.diffuse_texture is not None:
texture = surface.diffuse_texture
if isinstance(texture, gs.textures.ImageTexture) and texture.image_path:
mat.set_properties(nps.EMaterialProperty.AlbedoTexture)
mat.albedoTexture = get_rel_asset_path(
texture.image_path, self._genesis_assets_dir
)
if hasattr(surface, "normal_texture") and surface.normal_texture is not None:
texture = surface.normal_texture
if isinstance(texture, gs.textures.ImageTexture) and texture.image_path:
mat.set_properties(nps.EMaterialProperty.NormalTexture)
mat.normalTexture = get_rel_asset_path(
texture.image_path, self._genesis_assets_dir
)
# Double sided support (default to single sided)
if hasattr(surface, "double_sided") and surface.double_sided:
mat.set_properties(nps.EMaterialProperty.Sidedness)
mat.sidedness = nps.EMaterialSidedness.Double
return mat
def _build_entity_instance(self, entity, instance_idx):
"""Build a single entity instance (not expanded to vgeoms)."""
instance = self._scene_asset.get_instance(instance_idx)
# Type and scale - FEM, PBD, and MPM entities use DynamicMesh type
if is_dynamic_mesh_entity(entity):
instance.type = nps.EInstanceType.DynamicMesh
# Set vertex and triangle counts from entity
if is_fem_entity(entity):
instance.dynamicMesh_numVertices = entity.n_vertices
instance.dynamicMesh_numTriangles = entity.n_surfaces
elif is_pbd_entity(entity):
instance.dynamicMesh_numVertices = entity.n_vverts
instance.dynamicMesh_numTriangles = entity.n_vfaces
elif is_mpm_visual_entity(entity):
# Skinning mesh: fixed topology, animated verts (via mpm_solver.vverts_render)
instance.dynamicMesh_numVertices = entity.n_vverts
instance.dynamicMesh_numTriangles = entity.n_vfaces
instance.dynamicMesh_dynamicConnectivity = True
elif is_mpm_recon_entity(entity):
# Variable topology: pre-allocate worst-case from solver domain + recon voxel scale
max_v, max_t = particle_recon_budget(self._scene.mpm_solver, entity)
instance.dynamicMesh_numVertices = max_v
instance.dynamicMesh_numTriangles = max_t
instance.dynamicMesh_dynamicConnectivity = True
elif is_sph_recon_entity(entity):
# SPH liquids share the MPM-recon variable-topology path: budget the same way
# using the SPH domain + particle radius.
max_v, max_t = particle_recon_budget(self._scene.sph_solver, entity)
instance.dynamicMesh_numVertices = max_v
instance.dynamicMesh_numTriangles = max_t
instance.dynamicMesh_dynamicConnectivity = True
# DynamicMesh vertices are in absolute world position, so use identity transform
instance.position = nps.float3(0.0, 0.0, 0.0)
instance.rotation = nps.quaternion(0.0, 0.0, 0.0, 1.0)
scale = nps.float3(
1.0, 1.0, 1.0
) # Identity scale - vertices are already world-space
else:
# Transform for non-dynamic meshes
pos = nps.float3_z_up_to_y_up_a(
nps.float3(
entity.morph.pos[0], entity.morph.pos[1], entity.morph.pos[2]
)
)
instance.position = pos
# Rotation
instance.rotation = nps.quaternion(
entity.morph.quat[0],
entity.morph.quat[1],
entity.morph.quat[2],
entity.morph.quat[3],
)
instance.rotation = nps.quaternion_z_up_to_y_up_a(
nps.quaternion_wxyz_to_xyzw(instance.rotation)
)
# Type and scale
instance.type = instance_type(entity.morph)
if isinstance(entity.morph, gs.morphs.Primitive):
instance.primitive_type = primitive_type(entity.morph)
scale = primitive_scale(entity.morph)
elif isinstance(entity.morph, gs.morphs.URDF):
subscene_path = os.path.abspath(entity.morph.file).replace("\\", "/")
instance.subscene_uri = subscene_path
instance.convertAxis = entity.morph.file_meshes_are_zup
scale = file_morph_scale(entity.morph)
# Sanity check - we don't support URDFs with merged fixed links since we can't export the sub-scene with the correct structure to handle them.
if entity.morph.merge_fixed_links:
raise RuntimeError(
f"The Nyx Renderer does not support exporting an URDF entity with merged fixed links: {entity.morph.file}"
)
elif isinstance(entity.morph, gs.morphs.FileMorph):
mesh_path = os.path.abspath(entity.morph.file).replace("\\", "/")
instance.mesh_uri = mesh_path
instance.convertAxis = entity.vgeoms[0].metadata["imported_as_zup"]
scale = file_morph_scale(entity.morph)
else:
scale = nps.float3(1.0, 1.0, 1.0)
# Convert the scale to Yup before leaving.
instance.scale = nps.float3_z_up_to_y_up_a(scale)
# Material
surface, overriddenSurface = surface_for_material(entity)
if is_dynamic_mesh_entity(entity):
# Dynamic meshes need complete material description (no mesh file with embedded materials)
instance.matOverride = self._build_full_material(surface, entity)
else:
# Get vmesh for primitives (to access metadata like texture_path)
vmesh = None
if (
isinstance(entity.morph, gs.morphs.Primitive)
and hasattr(entity, "vgeoms")
and entity.vgeoms
):
vmesh = entity.vgeoms[0].vmesh
instance.matOverride = self._build_material_override(
surface, entity, vmesh, overriddenSurface
)
# Flags
instance.smooth = is_mpm_recon_entity(entity) or is_sph_recon_entity(entity)
instance.enabled = True
instance.uuid = nps.generate_uuid()
# Keep track of the mapping
self._entity_uuid_pairs.append((entity, instance.uuid))
self._scene_asset.set_instance(instance_idx, instance)
return instance_idx + 1
def _export_vgeom_to_obj(self, vgeom):
"""Serialize a vgeom's trimesh to an .obj inside the export folder and return its path.
Used for vgeoms that have no source mesh file on disk, either because the source
is a container Genesis re-meshes (MJCF) or because the mesh is procedurally
generated (e.g. gs.morphs.Terrain).
"""
filename = _nyx_random_string() + ".obj"
full_path = os.path.join(self._export_folder, filename)
vgeom.vmesh.trimesh.export(full_path)
return full_path
def _build_vgeom_instances(self, entity, instance_idx):
"""Build instances from entity vgeoms."""
vgeoms_pos = qd_to_torch(self._scene.rigid_solver.vgeoms_state.pos).cpu()
vgeoms_quat = qd_to_torch(self._scene.rigid_solver.vgeoms_state.quat).cpu()
# Generate a unique UUID for the whole entity
entity_uuid = nps.generate_uuid()
# Keep track of the mapping (only done once)
self._entity_uuid_pairs.append((entity, entity_uuid))
# Loop through the geometries
for vgeom in entity.vgeoms:
# Grab the instance to fill
instance = self._scene_asset.get_instance(instance_idx)
# Handle the position
pos = vgeoms_pos[vgeom.idx, 0]
instance.position = nps.float3(pos[0], pos[1], pos[2])
instance.position = nps.float3_z_up_to_y_up_a(instance.position)
# Handle the rotation
vgeom_quat = vgeoms_quat[vgeom.idx, 0]
quat = vgeom_quat
if torch.all(vgeom_quat == 0):
quat[0] = 1.0
instance.rotation = nps.quaternion(
quat[0].item(), quat[1].item(), quat[2].item(), quat[3].item()
)
instance.rotation = nps.quaternion_z_up_to_y_up_a(
nps.quaternion_wxyz_to_xyzw(instance.rotation)
)
# Type
if hasattr(vgeom, "type"):
if vgeom.type == gs.GEOM_TYPE.BOX:
instance.type = nps.EInstanceType.Primitive
instance.primitive_type = nps.EPrimitiveType.Box
extents = vgeom.data
instance.scale = nps.float3(extents[0], extents[2], extents[1])
elif vgeom.type == gs.GEOM_TYPE.SPHERE:
instance.type = nps.EInstanceType.Primitive
instance.primitive_type = nps.EPrimitiveType.Sphere
r = vgeom.data[0]
instance.scale = nps.float3(r, r, r)
elif vgeom.type == gs.GEOM_TYPE.CYLINDER:
instance.type = nps.EInstanceType.Primitive
instance.primitive_type = nps.EPrimitiveType.Cylinder
r, h = vgeom.data[0], vgeom.data[1]
instance.scale = nps.float3(r, h, r)
else:
instance.type = nps.EInstanceType.Mesh
# Get scale from entity morph if available
if isinstance(vgeom.entity.morph, gs.morphs.FileMorph):
instance.scale = file_morph_scale(vgeom.entity.morph)
else:
instance.scale = nps.float3(1.0, 1.0, 1.0)
else:
instance.type = nps.EInstanceType.Mesh
instance.convertAxis = vgeom.metadata["imported_as_zup"]
# Get scale from entity morph if available
if isinstance(vgeom.entity.morph, gs.morphs.FileMorph):
instance.scale = file_morph_scale(vgeom.entity.morph)
else:
instance.scale = nps.float3(1.0, 1.0, 1.0)
# Convert the scale
instance.scale = nps.float3_z_up_to_y_up_a(instance.scale)
# URI for mesh types
if instance.type == nps.EInstanceType.Mesh:
if isinstance(vgeom.entity.morph, gs.morphs.MJCF):
uri = self._export_vgeom_to_obj(vgeom)
elif "mesh_path" in vgeom.metadata:
uri = os.path.relpath(vgeom.metadata["mesh_path"]).replace(
"\\", "/"
)
else:
# Procedural meshes (e.g. gs.morphs.Terrain) have no source file
uri = self._export_vgeom_to_obj(vgeom)
if uri:
instance.mesh_uri = uri
# Material (try to get from vgeom visual)
visual = vgeom.get_trimesh().visual
if isinstance(visual, ColorVisuals):
geom_color = visual.main_color / 255.0
mat = nps.MaterialAsset()
mat.set_properties(nps.EMaterialProperty.AlbedoColor)
mat.albedoColor = nps.float4(
geom_color[0], geom_color[1], geom_color[2], geom_color[3]
)
instance.matOverride = mat
else:
# Pass vgeom.vmesh to access metadata (e.g., texture_path for primitives)
vmesh = vgeom.vmesh if hasattr(vgeom, "vmesh") else None
instance.matOverride = self._build_material_override(
entity.surface, entity, vmesh
)
# Flags
instance.smooth = False
instance.enabled = True
instance.uuid = entity_uuid
# For MJCFs we want to keep track of the link and subgeometry name.
if isinstance(vgeom.entity.morph, gs.morphs.MJCF):
instance.name = f"{vgeom.link.name}_{vgeom.link.vgeoms.index(vgeom)}"
self._scene_asset.set_instance(instance_idx, instance)
instance_idx += 1
return instance_idx
def _build_cameras(self):
"""Build all cameras from Genesis Camera objects or dict definitions."""
for camera_idx, camera in enumerate(self._cameras):
cam_asset = self._scene_asset.get_camera(camera_idx)
if isinstance(camera, dict):
self._build_camera_from_dict(cam_asset, camera)
else:
self._build_camera_from_object(cam_asset, camera)
self._scene_asset.set_camera(camera_idx, cam_asset)
def _build_camera_from_dict(self, cam_asset, camera_def):
"""Build camera from dictionary definition."""
# Evaluate the position and convert to y-up
pos = camera_def["pos"]
cam_asset.position = nps.float3(pos[0], pos[1], pos[2])
cam_asset.position = nps.float3_z_up_to_y_up_a(cam_asset.position)
# Rotation
quat_z_up_wxyz = camera_get_quaternion(camera_def)
quat_wxyz = nps.quaternion(
quat_z_up_wxyz[0], quat_z_up_wxyz[1], quat_z_up_wxyz[2], quat_z_up_wxyz[3]
)
quat_xyzw = nps.quaternion_wxyz_to_xyzw(quat_wxyz)
quat_xyzw = nps.quaternion_z_up_to_y_up_a(quat_xyzw)
quat_xyzw = nps.quaternion_conjugate(quat_xyzw)
cam_asset.rotation = nps.quaternion_mul(
nps.quaternion(0.5, 0.5, 0.5, 0.5), quat_xyzw
)
# Properties
cam_asset.fovY = math.radians(camera_def["fov"])
cam_asset.resolution = nps.uint2(camera_def["res"][0], camera_def["res"][1])
cam_asset.aperture = camera_def.get("aperture", 2.8)
cam_asset.focalLength = camera_def.get("focal_len", 10.0)
cam_asset.nearPlane = camera_def.get("near", 0.1)
cam_asset.farPlane = camera_def.get("far", 1000.0)
cam_asset.spp = camera_def.get("spp", 16)
cam_asset.denoise = camera_def.get("denoise", False)
cam_asset.toneMapper = camera_def.get("tone_mapper", nps.EToneMapper.Reinhard)
cam_asset.antiAliasing = camera_def.get("anti_aliasing", nps.EAntiAliasing.SMAA)
def _build_camera_from_object(self, cam_asset, camera):
"""Build camera from Genesis Camera object."""
# Position
pos = camera._initial_pos
cam_asset.position = nps.float3(pos[0], pos[1], pos[2])
cam_asset.position = nps.float3_z_up_to_y_up_a(cam_asset.position)
# Rotation
# TODO: FIX ME LATER
cam_asset.rotation = nps.quaternion(0.0, 0.0, 0.0, 1.0)
assert False
# Properties
cam_asset.fovY = math.radians(camera.fov)
cam_asset.resolution = nps.uint2(camera.res[0], camera.res[1])
cam_asset.aperture = camera.aperture
cam_asset.focalLength = camera.focal_len
cam_asset.nearPlane = camera.near
cam_asset.farPlane = camera.far
cam_asset.spp = camera.spp
cam_asset.denoise = camera.denoise
cam_asset.toneMapper = getattr(nps.EToneMapper, "None")
cam_asset.antiAliasing = getattr(nps.EAntiAliasing, "None")
def _build_lights(self):
"""Build all lights from LightAsset definitions."""
for light_idx, light in enumerate(self._lights):
assert hasattr(light, "type") and hasattr(light, "intensity"), (
f"Light at index {light_idx} must be a LightAsset, got {type(light)}"
)
self._scene_asset.set_light(light_idx, light)
def _build_env_maps(self):
for env_map_idx, env_map in enumerate(self._env_maps):
assert hasattr(env_map, "texture"), (
f"EnvMap at index {env_map_idx} must be a EnvironmentMapAsset, got {type(env_map)}"
)
self._scene_asset.set_env_map(env_map_idx, env_map)
def _build_light_fields(self):
for light_field_idx, light_field in enumerate(self._light_fields):
self._scene_asset.set_light_field(light_field_idx, light_field)
[docs]
def export_to_file(self, export_path):
"""Serialize the built ``SceneAsset`` to JSON on disk.
Parameters
----------
export_path : str
Output path for the scene-description JSON. The Nyx renderer
reads this file in :meth:`NyxRenderer.load_scene_from_file`,
so the path must be readable when the renderer builds.
Conventionally written as ``<export_folder>/nyx_scene.json``.
"""
nps.export_scene_file(export_path, self._scene_asset)
[docs]
def destroy(self):
del self._scene_asset
self._scene_asset = None