Skip to content

[Task Clean-up][Assets] Serve the Shadow Hand from one asset with native PhysX tendons - #7161

Draft
hujc7 wants to merge 16 commits into
isaac-sim:developfrom
hujc7:jichuanh/shadow-tendon-codex-base
Draft

[Task Clean-up][Assets] Serve the Shadow Hand from one asset with native PhysX tendons#7161
hujc7 wants to merge 16 commits into
isaac-sim:developfrom
hujc7:jichuanh/shadow-tendon-codex-base

Conversation

@hujc7

@hujc7 hujc7 commented Aug 19, 2026

Copy link
Copy Markdown
Collaborator

Summary

  • One Shadow Hand asset replaces the per-engine pair; the Physics USD variant selects the engine, so the two configurations can no longer drift apart.
  • The asset carries its four PhysX fixed tendons natively, so Isaac Lab no longer synthesises them at spawn — removing a per-environment patch that never completed scene construction at 4096 envs.
  • set_fixed_tendon_position_target_index is declared once on BaseArticulation and implemented by PhysX, Newton and OVPhysX, so a task commands a tendon target without naming a backend.
  • Both hand tasks train to 4000 iterations on both backends with no NaN.

The asset

physx.usda gains four fixed tendons: PhysxTendonAxisRootAPI on each finger's J3 carrying the gains Menagerie gives the equivalent MuJoCo actuator, and PhysxTendonAxisAPI on J2/J1 with gearing = 0.017453292. That constant is pi/180, which cancels PhysX's radians-to-degrees conversion so tendon length is q_J1 + q_J2 — the quantity MjcTendon computes with coef [1, 1]. That equality is what lets one command mean the same thing on both engines.

physics.usda moves PhysicsArticulationRootAPI onto the enclosing prim and replaces the in-link weld with a rootJoint whose body0 is empty, which is how UsdPhysics spells "attach to world". The previous joint named an Xform rather than a rigid body, so PhysX reported fixed_base=False.

The asset is vendored in-repo and is load-bearing. With the spawn-time patch removed, nothing fixes up the published tendon-less asset at runtime, so usd_path defaults to the copy in isaaclab_assets/data/. This reverts to ISAAC_NUCLEUS_DIR once the fixed asset is published.

Framework

  • Nested articulation roots resolve to the outermost prim. Six call sites each demanded exactly one prim with ArticulationRootAPI; a backend root such as NewtonArticulationRootAPI includes it as a built-in, so an asset carrying both could not spawn without naming its root. Sibling roots remain an error, now naming the paths it found.
  • Applying a USD schema no longer rebuilds the applied-schema list, which had silently dropped tendon fragments composed from other layers.
  • MuJoCo custom attributes are registered before the stage is traversed; without it every mjc:* attribute was dropped on import and armature/friction fell back to schema defaults.

Validation

task backend success (mean last 20)
reorient Newton 0.9524
reorient PhysX 0.9507
handover Newton 0.9733
handover PhysX 0.8607

24 handover runs x 4000 iterations on PhysX (4096 envs, 12 seeds x 2 asset variants) completed with 0 NaN. Goals are sampled uniformly over SO(3), a harder distribution than the two-axis subset these numbers were previously measured on.

Suites: isaaclab_physx articulation 241, isaaclab_newton articulation 319, isaaclab_ov articulation 119, joint-wrench sensors 16 + 11, tendon fragments 22. All 32 registered entry points resolve.

Known-open

  • The intermittent PhysX handover NaN reported earlier did not reproduce in this round — 12 control runs on the unmodified asset finished clean where ~4 failures were expected. This PR does not claim to fix it.
  • mjc:damping on the tendon-coupled joints is not what reaches the solver; both backends agree on the value they do use, so this is a fidelity question rather than a cross-backend divergence.

hujc7 added 11 commits August 18, 2026 15:45
The published asset's physx variant carries neither the four fixed tendons the
mujoco variant expresses as MjcTendon prims nor a world attachment PhysX honours,
so the hand spawned floating with no tendons and Isaac Lab had to synthesise both
at spawn time.

physx.usda gains the four tendons: PhysxTendonAxisRootAPI on each finger's J3 with
the gains Menagerie gives the equivalent MuJoCo actuator, and PhysxTendonAxisAPI on
J2 and J1 with gearing pi/180, which cancels PhysX's radians-to-degrees conversion
so the tendon length is q_J1 + q_J2 -- the quantity MjcTendon computes with coef
[1, 1].

physics.usda moves PhysicsArticulationRootAPI from the forearm link to the enclosing
prim and replaces the in-link weld with a rootJoint whose body0 is empty, which is how
UsdPhysics spells "attach to world"; the previous joint named an Xform rather than a
rigid body, so PhysX reported fixed_base False.

A .gitattributes opts the text layers out of LFS, which otherwise reduces them to
pointer files.
A fixed tendon's length is a weighted sum of the joint angles it spans, and each
engine actuates it through a different native mechanism: PhysX shifts the tendon's
length offset, MuJoCo drives the actuator whose transmission is the tendon. Without
a shared entry point every caller has to branch on the backend.

set_fixed_tendon_position_target_index is declared once on BaseArticulation and
implemented by both engines, so a task commands a target length and never names a
mechanism. PhysX buffers the negated offset and flushes it from write_data_to_sim,
the same shared step that flushes joint targets, rather than writing per-call.

Newton has no tendon accessor on its ArticulationView -- MuJoCo's tendons and their
actuators reach the model only as flat mujoco:* arrays spanning every world -- so
MjcTendonActuatorView resolves the per-articulation indexing that implementation
needs. It indexes eligible actuators once by (world, target label); scanning them
per tendon instead cost 174 s for one hand and 344 s per articulation view for two.

The MuJoCo custom attributes carrying that data are registered on the model builder
before the stage is traversed, without which every mjc:* attribute is dropped on
import and joint armature, joint friction and actuator definitions silently fall
back to schema defaults.
Tendons have their own index space, so a joint action term cannot reach them: a hand
whose fingers are tendon-coupled had no way to command those joints from a manager-based
task at all.

The term maps a normalised action onto the tendon's commandable span and clips to
[-1, 1] first, as the joint terms do -- a policy samples from an unbounded Gaussian,
so early actions otherwise leave the tendon's reachable range and yank the fingers.
It buffers through the asset and reaches the simulation from write_data_to_sim.
Applying a schema rebuilt the prim's applied-schema list, which dropped the fragments
composed from other layers. A hand's tendon APIs live in the asset's physx layer, so
spawning it through the schema path silently removed them, and the articulation came
up reporting no tendons for the action term to bind to.
The hand had a configuration per engine, so the two could and did drift: different
spawn rotations, different gains, and a Newton-only variant that tasks had to select
by name. ShadowHand.cfg(physics) returns one asset whose Physics USD variant selects
the engine, and everything a task needs to spawn or address the hand -- the asset
path, the sixteen joint motors, the four tendon motors, the fingertip bodies -- is
stated once on the class.

joint_ordering and body_ordering are declared so both engines expose the hand's
joints in one index space. Left unset each backend uses its own enumeration order,
and MEASURED they differ: Newton walks depth-first per finger, PhysX breadth-first
by joint level, so index 3 is rh_FFJ3 on one and rh_MFJ4 on the other. Every
observation past index 2 then names a different joint, and a policy trained on one
engine scored 0.0005 on the other against a 0.6165 native baseline.

articulation_root_prim_path is stated rather than searched for: the search demands
exactly one prim carrying an articulation-root API, and the asset's root link also
carries NewtonArticulationRootAPI, which satisfies the same check.

usd_path defaults to the copy vendored in this package until the fixed asset is
published; SHADOW_HAND_USD overrides it.
The task took its hand from the per-engine configurations and re-stated the hand's
own joint, tendon and fingertip names, so a change to the hand had to be mirrored in
the task. It now takes them from ShadowHand.

The tendon command is a Shadow-specific concern, so the tendon write and the index
resolution it needs live in a Shadow subclass of the reorientation environment rather
than in the shared base, which the Allegro hand also uses and which has no tendons.
The manager configuration declares its tendon action term in an actions class of its
own for the same reason.
Both hands took their configuration from the per-engine Shadow Hand configs and
raised every actuator's gains, including the tendon-coupled joints. Those joints take
no position command, and MEASURED, driving them costs the tendon most of its travel:
11.1 rad falls to 1.0 rad. The hands now take the gains the asset supplies.

The reward summed one squared-action penalty over both agents' actions, so each hand
paid for the other's. Once a hand releases the ball the shared distance term stops
responding to its pose, and nothing else discourages it from saturating its motors --
which is what the released hand's over-curling was. Each agent now pays for its own
actions.
Both command modules defined a term and its configuration in one file, where the rest
of the repository keeps configurations in a module of their own. Each configuration
moves to commands_cfg.py beside its implementation, which imports it only for typing.

The goal orientation is now drawn from the shared uniform SO(3) sampler. Composing two
axis-angle rotations, as both terms did, reaches only a two-axis subset of orientations
and needs a unit-axis buffer per axis to do it -- so success rates measured after this
change are measured on a harder goal distribution than those before it.
set_fixed_tendon_position_target_index is abstract on BaseArticulation, and OVPhysX was
the one backend without an implementation -- so every OVPhysX articulation raised
TypeError on construction, not only those with tendons.

OVPhysX carries the same tendon model as PhysX, so it commands the target the same way:
the schema documents offset as the value added to the accumulated length, so a target
length is applied as its negation and delegated to the offset setter this backend
already implements natively.
Six call sites each searched for a prim carrying ArticulationRootAPI and demanded exactly
one match. More than one prim can answer that query without the asset being ambiguous: a
backend-specific root such as NewtonArticulationRootAPI includes ArticulationRootAPI among
its built-in schemas, so a link carrying the former also answers for the latter, while the
enclosing prim carries the root PhysX needs to treat the articulation as fixed-base. Both
are required, and an asset carrying both could not be spawned without naming its root in
the configuration.

The search moves to one helper beside the generic resolver it wraps, and drops any root
nested inside another: the outer prim describes the articulation the inner one belongs to.
Sibling roots remain an error, since they are separate articulations and nothing says which
was meant, and the message now names the paths it found and the field that disambiguates.

The Shadow Hand no longer has to state articulation_root_prim_path, which existed only to
step around this.
…tendon-codex-base

# Conflicts:
#	source/isaaclab_tasks/test/core/test_handover_physics_presets.py
@github-actions github-actions Bot added the asset New asset feature or request label Aug 19, 2026
hujc7 added 5 commits August 18, 2026 22:16
The tendon target is commanded every step, and OVPhysX applied it through
set_fixed_tendon_offset_index, which pushes the buffer to the simulation as it fills it.
That is right for a property edited once at setup and wrong for a per-step command: it
made the action term the only sim write outside write_data_to_sim, the shared step that
flushes joint targets.

Both setters now fill the buffer through one helper. The property setter still pushes
immediately; the target command leaves the write to write_data_to_sim, matching PhysX and
Newton, so a step has exactly one place where data reaches the simulation.
The handover environment expanded {ENV_REGEX_NS} itself before building its assets,
against a failure whose cause was never identified. AssetBase does that expansion for
assets a direct environment builds itself, and MEASURED, the training entry point the
workaround was written for now reaches three learning iterations with zero "is not
global" errors without it.

Both hand environments resolved their tendon indices and built the same two limit tensors
from the same source. That moves to one helper beside the other shared task utilities.

Also dropped: a goal-command unit vector left behind when the sampler moved to uniform
SO(3), a factory wrapping one action-term constructor used twice, three module constants
computing two adjacent values, and a five-line layout helper called once.
Pairing a tendon with the actuator that drives it read as one 98-line function. It is four
steps: read the mujoco:* arrays, index the eligible actuators by (world, target label), walk
the instances pairing them up, then require every instance to agree. Each is now named and
carries the reasoning that belongs to it -- in particular why the indexing happens once, which
is the difference between 0.1 s and minutes of start-up per articulation view.

No behaviour changes: the Newton articulation suite returns the same 319 passed / 4 skipped.
The repository sends *.usd and *.usda to LFS. This asset opted its text layers out so the
tendon and root-weld edits would diff as text during review, which made it the only *.usda
in the tree stored as a plain blob -- and `git lfs fsck --pointers`, which the pre-commit
LFS hook falls back to, rejects exactly that.

The layers now follow the same rule as every other .usda here. The asset is vendored only
until the fixed one is published, at which point these files leave the repository.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

asset New asset feature or request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant