Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions source/isaaclab/isaaclab/physics/physics_manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -265,6 +265,17 @@ def step(cls) -> None:
"""Step physics simulation by one timestep (physics only, no rendering)."""
pass

@classmethod
def pre_render(cls) -> None:
"""Sync deferred physics state to the rendering backend.

Called by :meth:`~isaaclab.sim.SimulationContext.render` before cameras
and visualizers read scene data. The default implementation is a no-op.
Backends that defer transform writes (e.g. Newton's dirty-flag pattern)
should override this to flush pending updates.
"""
pass

@classmethod
def close(cls) -> None:
"""Clean up physics resources.
Expand Down
1 change: 1 addition & 0 deletions source/isaaclab/isaaclab/sim/simulation_context.py
Original file line number Diff line number Diff line change
Expand Up @@ -620,6 +620,7 @@ def render(self, mode: int | None = None) -> None:
every physics step). Camera sensors drive their configured renderer when
fetching data, so this method remains backend-agnostic.
"""
self.physics_manager.pre_render()
self.update_visualizers(self.get_rendering_dt())

# Call render callbacks
Expand Down
268 changes: 268 additions & 0 deletions source/isaaclab_newton/isaaclab_newton/physics/_cubric.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,268 @@
# Copyright (c) 2022-2026, The Isaac Lab Project Developers (https://github.com/isaac-sim/IsaacLab/blob/main/CONTRIBUTORS.md).
# All rights reserved.
#
# SPDX-License-Identifier: BSD-3-Clause

"""Pure-Python ctypes bindings for the cubric GPU transform-hierarchy API.

Acquires the ``omni::cubric::IAdapter`` carb interface directly from the
Carbonite framework and wraps its function-pointer methods so that Newton
can call cubric's GPU transform propagation without C++ pybind11 changes.

The flow mirrors PhysX's ``DirectGpuHelper::updateXForms_GPU()``:

1. ``IAdapter::create`` → allocate a cubric adapter ID
2. ``IAdapter::bindToStage`` → bind to the current Fabric stage
3. ``IAdapter::compute`` → GPU kernel: propagate world transforms
4. ``IAdapter::release`` → free the adapter

When cubric is unavailable (e.g. CPU-only machine, plugin not loaded), the
caller falls back to the CPU ``update_world_xforms()`` path.
"""

from __future__ import annotations

import ctypes
import logging

logger = logging.getLogger(__name__)

# ---------------------------------------------------------------------------
# Carb Framework struct layout (CARB_ABI function-pointer offsets, x86_64)
# ---------------------------------------------------------------------------
# Counting only CARB_ABI fields from the top of ``struct Framework``:
# 0: loadPluginsEx
# 8: unloadAllPlugins
# 16: acquireInterfaceWithClient
# 24: tryAcquireInterfaceWithClient ← we use this one
_FW_OFF_TRY_ACQUIRE = 24

# ---------------------------------------------------------------------------
# IAdapter struct layout (from omni/cubric/IAdapter.h)
# ---------------------------------------------------------------------------
# 0: getAttribute
# 8: create(AdapterId*)
# 16: refcount
# 24: retain
# 32: release(AdapterId)
# 40: bindToStage(AdapterId, const FabricId&)
# 48: unbind
# 56: compute(AdapterId, options, dirtyMode, outFlags*)
_IA_OFF_CREATE = 8
_IA_OFF_RELEASE = 32
_IA_OFF_BIND = 40
_IA_OFF_COMPUTE = 56

# AdapterId sentinel
_INVALID_ADAPTER_ID = ctypes.c_uint64(~0).value

# AdapterComputeOptions flags (from IAdapter.h)
_OPT_FORCE_UPDATE = 1 << 0 # Force update, ignoring invalidation status
_OPT_FORCE_STATE_RECONSTRUCTION = 1 << 1 # Force full rebuild of internal accel structures
_OPT_SKIP_ISOLATED = 1 << 2 # Skip prims with connectivity degree 0
_OPT_RIGID_BODY = 1 << 3 # Use PhysicsRigidBodyAPI tag for inverse propagation

# Newton prims get tagged with PhysicsRigidBodyAPI at init time so
# cubric's eRigidBody mode can distinguish rigid-body buckets
# (Inverse: preserve world matrix written by Newton, derive local)
# from non-rigid-body buckets (Forward: propagate to children).
# eForceUpdate is ORed in to bypass the change-listener check.
_OPT_DEFAULT = _OPT_RIGID_BODY | _OPT_FORCE_UPDATE

# AdapterDirtyMode
_DIRTY_ALL = 0 # eAll — dirty all prims in the stage
_DIRTY_COARSE = 1 # eCoarse — dirty all prims in visited buckets


# ---------------------------------------------------------------------------
# ctypes struct mirrors
# ---------------------------------------------------------------------------
class _Version(ctypes.Structure):
_fields_ = [("major", ctypes.c_uint32), ("minor", ctypes.c_uint32)]


class _InterfaceDesc(ctypes.Structure):
"""``carb::InterfaceDesc`` — {const char* name, Version version}."""
_fields_ = [
("name", ctypes.c_char_p),
("version", _Version),
]


def _read_u64(addr: int) -> int:
return ctypes.c_uint64.from_address(addr).value

Comment on lines +70 to +94
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hardcoded byte offsets into native C++ struct layouts

The constants _FW_OFF_TRY_ACQUIRE = 24, _IA_OFF_CREATE = 8, _IA_OFF_RELEASE = 32, _IA_OFF_BIND = 40, and _IA_OFF_COMPUTE = 56 are hardcoded byte offsets into Carbonite's Framework vtable and IAdapter's function-pointer table, derived from a specific build of the Carbonite SDK.

If NVIDIA inserts or reorders fields in either struct in a future Kit/Isaac Sim release, every call through these pointers will silently dispatch to the wrong function. Because the pointer reads via _read_u64 bypass all type safety, the failure mode is either silent mis-computation or an immediate segfault — both hard to diagnose.

A few mitigations worth considering:

  • Add a version assertion on the carb framework (acquireFramework returns a version) to bail out early when the framework version changes.
  • Add a smoke-test after acquiring the pointers: e.g., call IAdapter::getAttribute (offset 0) and verify the returned version matches the expected IAdapter 0.1 version before using the other slots.
  • Document the exact Kit/Isaac Sim version these offsets were verified against in a comment next to each constant.


# ---------------------------------------------------------------------------
# Public API
# ---------------------------------------------------------------------------
class CubricBindings:
"""Typed wrappers around the cubric ``IAdapter`` API.

Call :meth:`initialize` once; if it returns ``True``, the four adapter
methods are available.
"""

def __init__(self) -> None:
self._ia_ptr: int = 0
self._create_fn = None
self._release_fn = None
self._bind_fn = None
self._compute_fn = None

# -- lifecycle -----------------------------------------------------------

def initialize(self) -> bool:
"""Acquire the cubric ``IAdapter`` from the carb framework."""
# Ensure the omni.cubric extension (native carb plugin) is loaded.
try:
import omni.kit.app

ext_mgr = omni.kit.app.get_app().get_extension_manager()
if not ext_mgr.is_extension_enabled("omni.cubric"):
ext_mgr.set_extension_enabled_immediate("omni.cubric", True)
if not ext_mgr.is_extension_enabled("omni.cubric"):
logger.warning("Failed to enable omni.cubric extension")
return False
except Exception as exc:
logger.warning("Cannot enable omni.cubric: %s", exc)
return False

# Get Framework* via libcarb.so acquireFramework (singleton).
try:
libcarb = ctypes.CDLL("libcarb.so")
except OSError:
logger.warning("Could not load libcarb.so")
return False

libcarb.acquireFramework.restype = ctypes.c_void_p
libcarb.acquireFramework.argtypes = [ctypes.c_char_p, _Version]
fw_ptr = libcarb.acquireFramework(b"isaaclab.cubric", _Version(0, 0))
if not fw_ptr:
logger.warning("acquireFramework returned null")
return False

# Read tryAcquireInterfaceWithClient fn-ptr from Framework vtable.
try_acquire_addr = _read_u64(fw_ptr + _FW_OFF_TRY_ACQUIRE)
if try_acquire_addr == 0:
logger.warning("tryAcquireInterfaceWithClient is null in Framework")
return False

try_acquire_fn = ctypes.CFUNCTYPE(
ctypes.c_void_p, # return: void* (IAdapter*)
ctypes.c_char_p, # clientName
_InterfaceDesc, # desc (by value)
ctypes.c_char_p, # pluginName
)(try_acquire_addr)

desc = _InterfaceDesc(
name=b"omni::cubric::IAdapter",
version=_Version(0, 1),
)

# Try several acquisition strategies — the required client name
# varies across Kit configurations.
ia_ptr = try_acquire_fn(b"carb.scripting-python.plugin", desc, None)
if not ia_ptr:
ia_ptr = try_acquire_fn(None, desc, None)
if not ia_ptr:
acquire_addr = _read_u64(fw_ptr + 16) # acquireInterfaceWithClient
if acquire_addr:
acquire_fn = ctypes.CFUNCTYPE(
ctypes.c_void_p,
ctypes.c_char_p,
_InterfaceDesc,
ctypes.c_char_p,
)(acquire_addr)
ia_ptr = acquire_fn(b"isaaclab.cubric", desc, None)
if not ia_ptr:
logger.warning(
"Could not acquire omni::cubric::IAdapter — "
"cubric plugin may not be registered or interface version mismatch"
)
return False
self._ia_ptr = ia_ptr

# Wrap the four IAdapter function pointers we need.
create_addr = _read_u64(ia_ptr + _IA_OFF_CREATE)
release_addr = _read_u64(ia_ptr + _IA_OFF_RELEASE)
bind_addr = _read_u64(ia_ptr + _IA_OFF_BIND)
compute_addr = _read_u64(ia_ptr + _IA_OFF_COMPUTE)

if not all([create_addr, release_addr, bind_addr, compute_addr]):
logger.warning("One or more IAdapter function pointers are null")
return False

self._create_fn = ctypes.CFUNCTYPE(
ctypes.c_bool, ctypes.POINTER(ctypes.c_uint64),
)(create_addr)

self._release_fn = ctypes.CFUNCTYPE(
ctypes.c_bool, ctypes.c_uint64,
)(release_addr)

# FabricId is uint64, passed by const-ref -> pointer on x86_64
self._bind_fn = ctypes.CFUNCTYPE(
ctypes.c_bool, ctypes.c_uint64, ctypes.POINTER(ctypes.c_uint64),
)(bind_addr)

self._compute_fn = ctypes.CFUNCTYPE(
ctypes.c_bool,
ctypes.c_uint64, # adapterId
ctypes.c_uint32, # options (AdapterComputeOptions)
ctypes.c_int32, # dirtyMode (AdapterDirtyMode)
ctypes.c_void_p, # outAccountFlags* (nullable)
)(compute_addr)

logger.info("cubric IAdapter bindings ready")
return True

@property
def available(self) -> bool:
return self._ia_ptr != 0

# -- cubric adapter methods ----------------------------------------------

def create_adapter(self) -> int | None:
"""Create a cubric adapter. Returns an adapter ID or ``None``."""
if not self._create_fn:
return None
adapter_id = ctypes.c_uint64(_INVALID_ADAPTER_ID)
ok = self._create_fn(ctypes.byref(adapter_id))
if not ok or adapter_id.value == _INVALID_ADAPTER_ID:
logger.warning("IAdapter::create failed")
return None
return adapter_id.value

def bind_to_stage(self, adapter_id: int, fabric_id: int) -> bool:
"""Bind the adapter to a Fabric stage."""
if not self._bind_fn:
return False
fid = ctypes.c_uint64(fabric_id)
ok = self._bind_fn(adapter_id, ctypes.byref(fid))
if not ok:
logger.warning("IAdapter::bindToStage failed (adapter=%d, fabricId=%d)", adapter_id, fabric_id)
return ok

def compute(self, adapter_id: int) -> bool:
"""Run the GPU transform-hierarchy compute pass.

Uses ``eRigidBody | eForceUpdate`` with ``eAll`` dirty mode.
``eRigidBody`` makes cubric apply Inverse propagation on buckets
tagged with ``PhysicsRigidBodyAPI`` (keeps Newton's world transforms,
derives local) and Forward on everything else (propagates to children).
``eForceUpdate`` bypasses the change-listener dirty check.
"""
if not self._compute_fn:
return False
flags = ctypes.c_uint32(0)
ok = self._compute_fn(adapter_id, _OPT_DEFAULT, _DIRTY_ALL, ctypes.byref(flags))
if not ok:
logger.warning("IAdapter::compute returned false (flags=0x%x)", flags.value)
return ok

def release_adapter(self, adapter_id: int) -> None:
"""Release an adapter."""
if not adapter_id or not self._release_fn:
return
self._release_fn(adapter_id)
Loading
Loading