@@ -215,6 +215,13 @@ class OvPhysxManager(PhysicsManager):
215215 # physx.clone() in _warmup_and_load().
216216 # parent_positions is a list of (x, y, z) tuples — one per target.
217217 _pending_clones : ClassVar [list [tuple [str , list [str ], list [tuple [float , float , float ]]]]] = []
218+ # Pre-clone stage snapshot path. Populated by
219+ # :meth:`register_pre_clone_stage_snapshot` from inside
220+ # :func:`~isaaclab_ovphysx.cloner.ovphysx_replicate`, at the moment the
221+ # live USD stage carries only env_0 (USD-clone hasn't fired yet).
222+ # :meth:`_warmup_and_load` consumes the snapshot directly, avoiding the
223+ # post-clone full-stage flatten cost (~31s at 4096 envs on Anymal-D).
224+ _pre_clone_stage_path : ClassVar [str | None ] = None
218225 _atexit_registered : ClassVar [bool ] = False
219226 _scene_data_backend : ClassVar [OvPhysxSceneDataBackend | None ] = None
220227
@@ -245,6 +252,66 @@ def register_clone(
245252 """
246253 cls ._pending_clones .append ((source , targets , parent_positions or []))
247254
255+ @classmethod
256+ def register_pre_clone_stage_snapshot (cls , stage : Any ) -> None :
257+ """Snapshot the live USD stage to disk before USD cloning inflates it.
258+
259+ Called by :func:`~isaaclab_ovphysx.cloner.ovphysx_replicate`, which
260+ runs *before* :func:`cloner.usd_replicate` in
261+ :meth:`isaaclab.scene.InteractiveScene.clone_environments`. At that
262+ moment, the live stage carries only ``env_0``'s authored content (plus
263+ globals); ``stage.Export`` therefore flattens a small stage rather than
264+ the post-clone 4096-env version.
265+
266+ :meth:`_warmup_and_load` consumes the snapshot path directly, bypassing
267+ the slow ``_export_env0_only_stage`` fallback (which exports the full
268+ post-clone stage and then strips ``env_1..N`` from the resulting file —
269+ a ~31s flatten at 4096 envs on Anymal-D).
270+
271+ Idempotent: subsequent calls within the same setup are no-ops.
272+
273+ Args:
274+ stage: Live USD stage held by :class:`SimulationContext`.
275+ """
276+ if cls ._pre_clone_stage_path is not None :
277+ return
278+ if cls ._tmp_dir is None :
279+ cls ._tmp_dir = tempfile .TemporaryDirectory (prefix = "isaaclab_ovphysx_" )
280+ snapshot_path = os .path .join (cls ._tmp_dir .name , "scene_env0.usda" )
281+ stage .Export (snapshot_path )
282+ cls ._pre_clone_stage_path = snapshot_path
283+ logger .info ("OvPhysxManager: pre-clone stage snapshot exported to %s" , snapshot_path )
284+
285+ _physx_schemas_registered : ClassVar [bool ] = False
286+
287+ @classmethod
288+ def _ensure_physx_schemas_registered (cls ) -> None :
289+ """Register the ``PhysxSchema`` USD plugin shipped with the ovphysx wheel.
290+
291+ In Kit-based runs ``omni.physx`` registers the schema; in kitless
292+ runs it must be registered manually before the wheel can match
293+ ``PhysxContactReportAPI`` and friends on the stage. The wheel
294+ bundles the plugin under ``ovphysx/plugins/usd/PhysxSchema``. This
295+ method is idempotent — :meth:`pxr.Plug.Registry.RegisterPlugins`
296+ is a no-op once the plugin is registered.
297+ """
298+ if cls ._physx_schemas_registered :
299+ return
300+ try :
301+ import os # noqa: PLC0415
302+
303+ import ovphysx # noqa: PLC0415
304+
305+ from pxr import Plug # noqa: PLC0415
306+ except Exception :
307+ return
308+ plugin_root = os .path .join (os .path .dirname (ovphysx .__file__ ), "plugins" , "usd" )
309+ for sub in ("PhysxSchema/resources" , "PhysxSchemaAddition/resources" ):
310+ path = os .path .join (plugin_root , sub )
311+ if os .path .isdir (path ):
312+ Plug .Registry ().RegisterPlugins (path )
313+ cls ._physx_schemas_registered = True
314+
248315 @classmethod
249316 def initialize (cls , sim_context : SimulationContext ) -> None :
250317 """Initialize the physics manager with simulation context.
@@ -265,6 +332,7 @@ def initialize(cls, sim_context: SimulationContext) -> None:
265332 cls ._warmup_done = False
266333 cls ._usd_handle = None
267334 cls ._stage_path = None
335+ cls ._pre_clone_stage_path = None
268336 cls ._pending_clones = []
269337 # Construct the SceneDataBackend eagerly so :class:`SimulationContext`
270338 # captures a real instance (not ``None``) when it builds the central
@@ -494,7 +562,7 @@ def _warmup_and_load(cls) -> None:
494562 if scene_prim .IsValid ():
495563 cls ._configure_physx_scene_prim (scene_prim , PhysicsManager ._cfg , ovphysx_device )
496564
497- # Export the current USD stage to a temporary file so ovphysx can load it .
565+ # Resolve the USD stage path handed to ``physx.add_usd`` .
498566 #
499567 # When ``InteractiveScene`` runs with ``clone_usd=True``, the live USD
500568 # stage carries env_0..N's full asset subtrees as authored copies.
@@ -504,18 +572,26 @@ def _warmup_and_load(cls) -> None:
504572 # ``create_tensor_binding`` call into an O(N) USD enumeration -- the
505573 # hang you'd see at large env counts.
506574 #
507- # The workaround: strip ``/World/envs/env_<i>`` for i != 0 from the
508- # exported file before handing it to the wheel. Sensors that read
509- # USD directly (RayCaster, Camera, ContactSensor discovery) still see
510- # the full N-env stage; only the wheel-side physics ingestion is
511- # scoped to env_0, and ``physx.clone()`` re-populates env_1..N in
512- # the physics runtime with proper clone lineage (which is what the
513- # binding fast path expects).
514- cls ._tmp_dir = tempfile .TemporaryDirectory (prefix = "isaaclab_ovphysx_" )
515- stage_file = os .path .join (cls ._tmp_dir .name , "scene.usda" )
516- cls ._export_env0_only_stage (sim .stage , stage_file )
575+ # Fast path: ``ovphysx_replicate`` runs *before* ``cloner.usd_replicate``
576+ # in ``InteractiveScene.clone_environments``, so when it called
577+ # :meth:`register_pre_clone_stage_snapshot` the live stage carried only
578+ # env_0. That snapshot is the env_0-scoped USD the wheel needs; use it
579+ # directly when available.
580+ #
581+ # Fallback: if no pre-clone snapshot was registered (e.g. tests that
582+ # don't go through ``InteractiveScene.clone_environments``), export the
583+ # current (potentially post-clone) stage and strip ``env_1..N`` from
584+ # the resulting file -- correct, but with a flatten cost proportional
585+ # to the post-clone stage size.
586+ if cls ._pre_clone_stage_path is not None and os .path .exists (cls ._pre_clone_stage_path ):
587+ stage_file = cls ._pre_clone_stage_path
588+ logger .info ("OvPhysxManager: using pre-clone stage snapshot at %s" , stage_file )
589+ else :
590+ cls ._tmp_dir = tempfile .TemporaryDirectory (prefix = "isaaclab_ovphysx_" )
591+ stage_file = os .path .join (cls ._tmp_dir .name , "scene.usda" )
592+ cls ._export_env0_only_stage (sim .stage , stage_file )
593+ logger .info ("OvPhysxManager: exported env_0-scoped USD stage to %s" , stage_file )
517594 cls ._stage_path = stage_file
518- logger .info ("OvPhysxManager: exported env_0-scoped USD stage to %s" , stage_file )
519595
520596 if cls ._physx is None :
521597 cls ._construct_physx (ovphysx_device , gpu_index )
0 commit comments