Solver Observables#
Experimental
The solver observable API may change while additional solvers and observable categories are migrated to it.
Quantities produced by a solver but not required to advance simulation belong
in newton.solvers.SolverBase.Observables, separately from
State and Contacts. Request only the arrays
an application needs by composing entries from solver.ObservableKind in a set:
from newton.solvers import SolverMuJoCo
solver = SolverMuJoCo(model)
observables = solver.observables(
{
solver.ObservableKind.BODY_QDD,
solver.ObservableKind.BODY_PARENT_F,
}
)
solver.step(state_in, state_out, control, contacts, dt, observables=observables)
acceleration = observables.body_qdd
parent_wrench = observables.body_parent_f
The required kinds argument accepts a collection, passed positionally or by
keyword. Use solver.observables(set()) to allocate an empty container, or
solver.observables(solver.supported_observables) to explicitly request every
observable supported by the configured solver. contacts and requires_grad
are keyword-only. Prefer selecting only the outputs you need:
the supported set varies by backend and can grow as solvers gain capabilities.
Supply contacts=contacts whenever the requested set includes contact-indexed fields.
Allocate an observable container once and reuse it across steps. The container is
owned by the solver instance that allocated it. Contact-indexed observables are
sized from the rigid and soft capacities of the Contacts passed to
the factory, typically created by a CollisionPipeline:
pipeline = newton.CollisionPipeline(model)
solver = newton.solvers.SolverXPBD(model)
contacts = pipeline.contacts()
observables = solver.observables(kinds={solver.ObservableKind.CONTACT_F}, contacts=contacts)
pipeline.collide(state_in, contacts)
solver.step(state_in, state_out, control, contacts, dt, observables=observables)
contact_force = observables.contact_f
All requested arrays are allocated when observables() returns. An unrequested
field is None; a requested field with zero capacity is an empty array.
Contact-indexed requests require contacts even when their capacity is zero.
Body- and joint-only observables need neither a pipeline nor contacts; if contacts
are supplied for such requests or an empty request, they are ignored and
observables.contacts is None.
The live contact counts do not determine allocation sizes. contact_f has
contacts.rigid_contact_max + contacts.soft_contact_max entries, with rigid slots
followed by soft slots. Kernels use the contact counts to process valid entries.
If the solver owns a collision pipeline, contacts defaults to solver.contacts.
A step may use any buffer accepted by
validate_observables(), so allocate from the
largest buffer the container will be used with. Rows follow the step’s buffer:
its contact counts give the valid rows, and the soft segment begins at its
rigid_contact_max. observables.contacts_for_kind(kind) reports the buffer
each contact-indexed array was last produced with (observables.contacts for
contact_f).
SensorContact and the viewer require that buffer, and
custom consumers should check it together with observables.model is model.
Newly allocated forces are zero and can be read by a sensor or viewer before the
first step. Allocate observables and contacts before graph capture; neither
ordinary nor conditional graph execution needs deferred observable allocation.
Other solver scratch buffers may still need their usual warmup. Graph replays do
not update the buffer record; call validate_observables() with a replayed
graph’s buffer before consuming its outputs.
Native collision backends#
With MuJoCo’s internal collision detection, construct the solver first and size the pipeline for the backend’s export capacity:
solver = newton.solvers.SolverMuJoCo(model)
pipeline = newton.CollisionPipeline(
model, rigid_contact_max=solver.get_max_contact_count(), soft_contact_max=0
)
contacts = pipeline.contacts()
observables = solver.observables(kinds={solver.ObservableKind.CONTACT_F}, contacts=contacts)
solver.step(state_in, state_out, control, contacts, dt, observables=observables)
There is no pipeline.collide() call in this mode: the solver fills contact
geometry and forces. Native Kamino similarly seeds the model’s rigid capacity
when constructed before the pipeline. Both MuJoCo backends and Kamino reject
observable storage smaller than their configured export capacity.
With use_mujoco_cpu=True, nconmax bounds contact export, not simulation;
excess contacts are omitted with a warning.
A solver advertises available entries through
supported_observables and rejects an
unsupported request during allocation. Passing an observable container to a
different solver, or contacts that exceed its contact arrays, is also rejected.
Standard observables#
Kind and field |
Description |
Solvers |
|---|---|---|
|
Rigid-body center-of-mass spatial accelerations in the world frame |
|
|
Incoming parent-joint wrenches on rigid bodies |
|
|
Contact spatial forces in the row layout of the step’s contacts |
|
SolverKamino computes acceleration as the discrete
step-average (body_qd_out - body_qd_in) / dt. Across an impact, this includes
the velocity impulse divided by dt. MuJoCo computes BODY_QDD and
BODY_PARENT_F even with disable_sensors=True by enabling the
post-constraint RNE stage independently of sensors.
With use_mujoco_cpu=True, body and contact observables are exported
from the step’s input dynamics, including with RK4; use the input state for
contact positions. Body observables require Euler, RK4, implicit, or implicitfast
integration. CONTACT_F, like the SolverMuJoCo.ObservableKind.QFRC_* kinds,
supports any integrator; overflow observable requests are rejected.
SolverSteel accounts BODY_PARENT_F from the step’s solved
motion, including its contact, friction, spin and loop-closure impulses, so loads carried by a
subtree reach the joints that support it; see the solver’s documentation for the cases it leaves
out. Like SolverMuJoCo, it reconstructs the wrench from the bodies’ rigid
inertia, without joint armature, so a dynamic joint’s projected wrench is its generalized load
(joint_f, explicit passive spring and damping forces, and joint-space rows) less
armature * qdd. SolverFeatherstone reports the start-of-step inverse
dynamics at zero joint acceleration instead.
SolverSteel reports only the linear part of each rigid
contact force; the torque part is zero. Contacts without solved rows, such as rows
dropped for capacity or skipped for sleeping islands, report zero. A sleeping body’s
contacts therefore read as unloaded even though they still support it; use
sleep_skip_constraints=False or disable sleeping when consumers rely on these forces.
Kamino exports contact points in the step’s input body frames and world-frame wrenches about the input centers of mass. Use the input state when consuming those contacts, including for native contact detection. In-place callers must preserve the input poses separately if a consumer needs to transform the contact points back to world coordinates.
SolverCoupled exposes a body
observable when every entry that owns bodies supports that kind. It allocates an
entry-local container for each sub-solver and gathers owned rows into parent
model order. Contact observables are not exposed by the coupled wrapper because
filtered entry contacts require an explicit contact-index remapping contract.
Solver-specific observables#
A solver may expose additional kinds through solver.ObservableKind. For
example, MuJoCo adds QFRC_ACTUATOR and returns a
SolverMuJoCo.Observables container with a qfrc_actuator array:
solver = newton.solvers.SolverMuJoCo(model)
observables = solver.observables(
kinds={
solver.ObservableKind.BODY_QDD,
solver.ObservableKind.QFRC_ACTUATOR,
}
)
MuJoCo also exports QFRC_BIAS, QFRC_CONSTRAINT, and
QFRC_FRICTIONLOSS on both backends. qfrc_frictionloss is the part of
qfrc_constraint that comes from each DOF’s own friction loss, so
qfrc_constraint - qfrc_frictionloss is the constraint load on a DOF without
it. All three come from MuJoCo’s last force evaluation of the step: the start of
the step, or the last stage with RK4. The solver writes observables only during step()
and reset() leaves them unchanged, so zero
them for reset worlds if a consumer reads them before the next step.
The constants are strings, so literal names work too:
observables = solver.observables(kinds={"body_qdd", "qfrc_actuator"})
Pass a collection even for one kind, such as {"body_qdd"}. Bare strings and
unknown names are rejected. ObservableKind is not iterable; query
solver.supported_observables for the names accepted by the configured backend.
An inherited name does not imply support for that observable.
Steel sleep flags#
With enable_sleeping=True, SolverSteel exposes
BODY_AWAKE through
observables.body_awake, a bool array with shape (model.body_count,).
A step that receives the container overwrites it with True for awake bodies and
False for sleeping ones as of the end of that step, also during graph replay;
other steps leave it unchanged. A new container holds the solver’s current flags.
reset() and
notify_model_changed() wake islands according to
the sleeping rules but do not write existing containers; the next step that
receives one reports the result. Bodies of articulations with no responding
degrees of freedom, such as a body welded to the world, always read True.
Without sleeping the kind is unsupported and requesting it raises.
MuJoCo overflow diagnostics#
SolverMuJoCo exposes
OVERFLOW through
observables.overflow, an int32 array with shape (model.world_count,).
It reports capacity overflow after a step without synchronizing the device in
the solver. Requesting it requires use_mujoco_cpu=False.
Use OverflowBits to interpret Newton’s
flags, and mujoco_warp.OverflowType for MuJoCo Warp’s flags. For example,
this scene deliberately allocates fewer solver contacts than Newton generates:
import mujoco_warp
import newton
import warp as wp
from newton.solvers import SolverMuJoCo
builder = newton.ModelBuilder()
builder.add_ground_plane()
for i in range(4):
body = builder.add_body(xform=wp.transform((i * 0.2, 0.0, 0.04), wp.quat_identity()))
builder.add_shape_sphere(body, radius=0.05)
model = builder.finalize()
solver = SolverMuJoCo(model, use_mujoco_contacts=False, nconmax=1)
pipeline = newton.CollisionPipeline(model)
contacts = pipeline.contacts()
state_in, state_out = model.state(), model.state()
newton.eval_fk(model, model.joint_q, model.joint_qd, state_in)
observables = solver.observables(kinds={solver.ObservableKind.OVERFLOW})
pipeline.collide(state_in, contacts)
solver.step(state_in, state_out, model.control(), contacts, 1.0 / 60.0, observables=observables)
overflow = observables.overflow.numpy()
if (overflow & SolverMuJoCo.OverflowBits.CONTACT_PIPELINE).any():
print("Newton contact pipeline capacity exceeded")
if (overflow & SolverMuJoCo.OverflowBits.SOLVER_NCONMAX).any():
print("MuJoCo Warp solver contact capacity exceeded")
if (overflow & mujoco_warp.OverflowType.NEFC).any():
print("MuJoCo Warp constraint capacity exceeded")
The bit layout is:
Bits |
Public flags |
Meaning |
|---|---|---|
0-15 |
|
MuJoCo Warp diagnostics, including |
16 |
Newton generated more rigid contacts than |
|
17 |
Newton generated more rigid contacts than the solver’s shared
|
The Newton flags describe shared contact buffers and are repeated in every
world’s mask when the global count exceeds capacity. They do not identify which
world lost contacts. MuJoCo Warp’s flags retain their backend-provided world
assignment. When MuJoCo Warp performs its own collision detection
(use_mujoco_contacts=True), the Newton flags are suppressed and contacts
may be None.
Allocate the container before CUDA graph capture and pass it to each step that
should update the diagnostic. The mask reflects that step only, including after
graph replay; omitted or unselected observables remain unchanged. Reading
.numpy() synchronizes with the device, so inspect the result outside capture.
Newton checks every declared mujoco_warp.OverflowType member when importing
the backend and raises RuntimeError if any flag uses bits above 15. This
prevents future upstream flags from silently colliding with Newton’s flags,
even if the upstream ALL mask omits a member.
The observable reports runtime diagnostics; strict_capacity=True instead
compares the two buffer capacities before conversion. A capacity mismatch can occur
without actual overflow, and no Python warning or exception is automatically
raised for a nonzero runtime mask.
Selection#
ArticulationView reads frequencies from a
SolverBase.Observables source rather than requiring field registration on the
model. Standard and custom fields using supported static layouts, such as
BODY and JOINT_DOF, can therefore be selected directly:
view = newton.selection.ArticulationView(model, "robot_*")
accelerations = view.get_attribute("body_qdd", observables)
The container must belong to the same model and the field must be allocated.
Custom string frequencies still need the model’s articulation-ownership
metadata. Raw contact rows do not have stable, unique articulation ownership:
their order changes during collision detection and their endpoints can belong
to different articulations. The view rejects contact frequencies. Filter using
contact endpoints or reduce forces to a BODY-frequency field before using
an articulation view; automatic contact filtering and reduction are not provided.
Sensors#
Solver-dependent sensors expose a composable solver_observable_kinds set. A
caller can union the requirements of multiple consumers, allocate one
container, and pass it through the step:
imu = newton.sensors.SensorIMU(model, sites="imu_*", request_state_attributes=False)
contact_sensor = newton.sensors.SensorContact(
model, sensing_shapes="foot_*", request_contact_attributes=False
)
kinds = imu.solver_observable_kinds | contact_sensor.solver_observable_kinds
# Use contacts from a pipeline configured with a compatible capacity.
observables = solver.observables(kinds=kinds, contacts=contacts)
solver.step(state_in, state_out, control, contacts, dt, observables=observables)
imu.update(state_out, observables=observables)
contact_sensor.update(state_in, contacts, observables=observables)
The viewer follows the same pattern:
viewer.log_contacts(contacts, state_in, observables=observables)
Substep scheduling#
Allocate the union of needed observables once, then use
select() to create reusable subsets.
Each subset has the same concrete container type and shares the selected array
objects, including gradients, with the original container. Omitted fields are
None in the subset, while the original container and its arrays are unchanged.
The subset’s read-only kinds and is_requested(kind) describe only its
selected fields. No observable arrays are allocated or copied by select().
For example, an IMU may need acceleration every substep, while a contact sensor only needs the final substep’s forces. After configuring the solver, collision pipeline, and sensors as above:
kinds = imu.solver_observable_kinds | contact_sensor.solver_observable_kinds
observables = solver.observables(kinds=kinds, contacts=contacts)
every_substep = observables.select(imu.solver_observable_kinds)
substep_dt = frame_dt / num_substeps
for substep in range(num_substeps):
is_last = substep == num_substeps - 1
requested = observables if is_last else every_substep
# Run the usual collision update here if using external collision detection.
solver.step(state_in, state_out, control, contacts, substep_dt, observables=requested)
state_in, state_out = state_out, state_in
imu.update(state_in, observables=every_substep)
contact_sensor.update(state_in, contacts, observables=observables)
If all fields are needed only at the end, pass observables=None on earlier
substeps. observables.select(set()) also requests no diagnostics.
Selecting a field absent from the source raises ValueError; selecting
an existing subset can only narrow it. Select from the original container to
create a different combination. Do not temporarily replace fields with None.
Contact-indexed subsets share their contact-buffer record with their source
and siblings. A body-only or empty subset has contacts=None even when the
original container includes contact fields.
Create selections before graph capture and reuse them. A fixed Python substep schedule is captured with the graph; changing a Python selection afterward does not change an existing graph. Device-driven choices require graph control flow with the desired selections captured in its branches.
Skipped arrays retain their previous values. is_requested() does not mean
those values were updated, and mixed-rate containers do not represent a single
time snapshot. Consume sensors only after their required fields have been
produced, before reusing the corresponding state or contact geometry. Last-substep
forces are not frame-averaged forces; averaging or integration requires sampling
the relevant substeps. Contact rows may change between collision updates.
Selection controls observable writes and optional diagnostic work, not calculations needed by the dynamics or other requested fields. Legacy extended attributes can still request their own work. MuJoCo currently keeps its post-constraint RNE stage enabled after its first request, so omitting those observables does not necessarily avoid that internal computation on subsequent steps.
Contact forces from SolverVBD#
SolverVBD exports one wrench per contact record it solves – body-body
contacts, and rigid-soft particle, edge, and face records against rigid shapes – through
contact_f:
Create the
CollisionPipelineand its contacts, then requestSolverBase.ObservableKind.CONTACT_Fwithsolver.observables(contacts=contacts, kinds=...). This allocates the entire force array before stepping or graph capture; no contact attributes need to be requested.Each frame, run collision detection and pass the container to
solver.step(..., observables=observables). The step evaluates the wrenches directly into the array only whenCONTACT_Fis selected, without changing the simulation result. Omitobservablesor pass a selection withoutCONTACT_Fto skip reporting on earlier substeps.Rows
[0, rigid_contact_max)hold the body-body contacts (i < rigid_contact_count) when VBD integrates the rigid bodies: the force on body 0 by body 1 with its torque about body 0’s center of mass, the convention shared by every solver writingcontact_f. Withintegrate_with_external_rigid_solver=True, VBD’s rigid rows are zero: request and consume the external solver’s forces through its own observable container. Rowsrigid_contact_max + ifori < soft_contact_counthold the rigid-soft records. Rows past either active count are zero.
Each soft row is expressed in world frame. Its first three entries are the force [N] exerted on
the contacted shape’s body (model.shape_body[contacts.soft_contact_shape[i]], -1 for a
static shape) by the soft feature; its last three entries are the torque [N·m] of that force about
the body’s center of mass, or about the world origin for a static shape. The force acts at the
shape-side contact point (soft_contact_body_pos mapped to world space). Negate the force to get
the force on the soft contact point, and distribute it to the record’s particles with
soft_contact_barycentric. All values are the solver’s own contact law evaluated once at the
final configuration of the step – the forces the last iteration balanced – not time-step
averages; records without penetration are zero.
For soft contacts this is the penalty law at the final per-contact stiffness, damping while the contact point approaches the surface, and regularized Coulomb friction on the step’s slip, bounded by the elastic normal load. Body-body contacts use the compliant ALM or legacy AVBD contact law with its multipliers. Both evaluations retain the step-start pose history used by the iterations.
Soft self-contact forces are not exported. SensorContact reads the
rigid-contact rows, so it reports VBD’s body-body contacts, but it does not read the soft rows.
Construct it with request_contact_attributes=False and pass the container as
sensor.update(state_out, contacts, observables=observables).
import numpy as np
import warp as wp
import newton
from newton.solvers import SolverBase
builder = newton.ModelBuilder()
builder.add_ground_plane()
builder.add_particle(pos=wp.vec3(0.0, 0.0, 0.045), vel=wp.vec3(0.0), mass=1.0, radius=0.05)
ball = builder.add_body(xform=wp.transform(wp.vec3(1.0, 0.0, 0.099), wp.quat_identity()))
ball_shape = builder.add_shape_sphere(ball, radius=0.1)
builder.color()
model = builder.finalize()
pipeline = newton.CollisionPipeline(model)
contacts = pipeline.contacts()
solver = newton.solvers.SolverVBD(model)
observables = solver.observables(kinds={solver.ObservableKind.CONTACT_F}, contacts=contacts)
state_in, state_out = model.state(), model.state()
pipeline.collide(state_in, contacts)
solver.step(state_in, state_out, None, contacts, dt=1.0 / 60.0, observables=observables)
# Body-body rows: force on body 0 by body 1; flip the sign of rows where the ball is shape 1.
n_rigid = int(contacts.rigid_contact_count.numpy()[0])
rigid = observables.contact_f.numpy()[:n_rigid]
shape0 = contacts.rigid_contact_shape0.numpy()[:n_rigid]
sign = np.where(shape0 == ball_shape, 1.0, -1.0)
force_on_ball = (sign[:, None] * rigid[:, :3]).sum(axis=0) # [N], world frame
# Soft rows follow the rigid rows.
n_soft = int(contacts.soft_contact_count.numpy()[0])
start = contacts.rigid_contact_max
wrenches = observables.contact_f.numpy()[start : start + n_soft]
force_on_shape = wrenches[:, :3] # [N], on the ground (static shape), world frame
torque = wrenches[:, 3:] # [N·m], about the world origin for the static ground
# Reaction on the soft side: negate and distribute by barycentric weights.
corners = contacts.soft_contact_indices.numpy()[:n_soft]
weights = contacts.soft_contact_barycentric.numpy()[:n_soft]
particle_force = np.zeros((model.particle_count, 3))
for row in range(n_soft):
for corner, weight in zip(corners[row], weights[row]):
if corner >= 0:
particle_force[corner] -= weight * force_on_shape[row]
print(n_rigid > 0, force_on_ball[2] > 0.0, n_soft, force_on_shape[0, 2] < 0.0, particle_force[0, 2] > 0.0)
True True 1 True True
Deprecated extended attributes#
Deprecated since version 1.7: Request solver-produced arrays from the solver instead of extending
State or Contacts.
The following compatibility paths remain available for a deprecation period:
Deprecated destination |
Replacement |
|---|---|
|
|
|
|
|
|
|
|
|
|
|
|
|
Pass contact observables to |
The request methods and legacy force export via update_contacts() emit
DeprecationWarning. MuJoCo and Kamino keep geometry-only update_contacts()
supported without a warning when contacts.force is None.
SensorIMU and SensorContact retain their legacy
request_state_attributes=True and request_contact_attributes=True defaults
during the deprecation period; explicitly pass False when using solver observables.
Setting either option to True is deprecated in Newton 1.7 and emits one
sensor-specific warning at the caller’s location. Passing False does not
emit a warning or request legacy fields.
Contacts.EXTENDED_ATTRIBUTES and direct requested_attributes={"force"}
remain compatibility APIs. New integrations should not allocate
Contacts.force.
State.EXTENDED_ATTRIBUTES remains a compatibility registry for the three
deprecated state destinations listed above.
This deprecation does not affect custom attributes registered with
ModelBuilder.add_custom_attribute.
Existing custom model, state, and control data remain supported; the migration
only covers built-in solver-produced diagnostics. The new CONTACT* frequencies
are reserved for solver observables: ModelBuilder.add_custom_attribute() rejects
them because builder finalization does not allocate dynamic contact rows.
Adding solver observables#
Solver developers extend ObservableKind with
string constants and Observables with array
fields. Declare the kinds the solver computes in SUPPORTED_OBSERVABLES;
solver.supported_observables reports the effective instance capabilities.
from dataclasses import dataclass
import warp as wp
class CustomSolver(newton.solvers.SolverBase):
class ObservableKind(newton.solvers.SolverBase.ObservableKind):
CONTACT_PRESSURE = "contact_pressure"
@dataclass(eq=False)
class Observables(newton.solvers.SolverBase.Observables):
contact_pressure: "wp.array[float] | None" = newton.solvers.SolverBase.Observables.field(
dtype=float,
frequency=newton.Model.AttributeFrequency.CONTACT_RIGID,
)
"""Rigid contact pressure [Pa], shape (rigid_contact_max,)."""
SUPPORTED_OBSERVABLES = frozenset({ObservableKind.CONTACT_PRESSURE})
The field name supplies its kind, so the string is declared only once. Pass
kind="another_name" to
SolverBase.Observables.field()
when the public kind differs from the field name, for example a qualified name
such as "thermal:temperature". Each field also declares its Warp dtype and
row frequency: the indexing domain, not how often it is updated.
ObservableKind subclasses inherit standard and custom constants without
modifying their parents. Class definition rejects empty or non-string public
attributes, changes to inherited values, and duplicate strings under different
names, including conflicts across multiple parents. Redeclaring the same name
and value is allowed; private attributes starting with _ are ignored.
The factory instantiates self.Observables(). Solvers inherit the base
container unless they extend it; a solver adding fields must derive its nested
class from its parent’s Observables and use @dataclass(eq=False).
The dataclass defaults declared arrays to None and excludes them from
constructor arguments. eq=False preserves identity and hashing for
articulation-view caches.
solver.observables(kinds=...) allocates requested arrays in declaration order on the
model’s device, using the requested gradient setting. Derived containers inherit
fields and may redeclare a field’s dtype or frequency without changing their
parents or siblings. Missing declarations, duplicate kinds, and missing
dataclass decorators are rejected before allocation.
Contact dependencies follow the field’s frequency. Any requested contact frequency,
including custom fields requested without CONTACT_F, is sized from
contacts= or the solver-owned pipeline. Body- and joint-indexed fields do not
require a collision pipeline or contact storage.
In step(), call validate_observables() before
launching work or modifying outputs. It checks the container and the step’s
contacts and records those contacts for the requested fields. Compute diagnostics
only when requested:
self.validate_observables(observables, contacts)
# Advance the simulation, then compute the requested diagnostic.
if observables is not None and observables.is_requested(self.ObservableKind.CONTACT_PRESSURE):
wp.launch(
compute_contact_pressure,
dim=contacts.rigid_contact_max,
inputs=[contacts.rigid_contact_count],
outputs=[observables.contact_pressure],
device=self.model.device,
)
Here compute_contact_pressure is a solver-specific kernel.
is_requested() tests the request,
including for zero-length arrays; it does not indicate freshness.
Customizing the factory and selection#
Most custom fields need only their declarations. For additional initialization,
override observables(kinds, *, contacts=None, requires_grad=None) and delegate
to super().observables(kinds, contacts=contacts, requires_grad=requires_grad).
Preserve this signature. Perform backend preflight checks before delegating and finish auxiliary
allocation before returning the container. All factory allocations must finish
before graph capture; neither step() nor select() calls the factory.
Simple derived containers inherit select() unchanged. Containers with nested
observable containers must override it, call super(), and select their
children in the returned object. See
SolverCoupled for an example.
Other custom metadata is shallow-copied.
Field declarations describe runtime diagnostics and do not introduce USD-authorable model attributes.
Contact row domains#
Three experimental frequencies describe the existing contact storage layouts:
CONTACT_RIGID:rigid_contact_maxrigid-rigid slots.CONTACT_SOFT:soft_contact_maxsoft-rigid slots. This does not include the separate soft self-contact storage.CONTACT: the packed sum of both capacities, used bycontact_ffor compatibility. The soft segment begins atrigid_contact_max, not at the live rigid contact count.
These frequencies currently describe solver observables, not builder custom
attributes. Capacity determines allocation; live counts determine which slots
can be read. Packed storage does not guarantee that a solver produces forces for
both segments. SensorContact currently consumes only
rigid-rigid forces, using rigid_contact_count and rigid shape endpoints.
See Contact forces from SolverVBD for SolverVBD’s soft-contact output.