Static pads, triangle mesh
The cube never loses contact; it slides steadily down through the pads.
What stops a robot hand from holding an object in FeatherPGS, what to set to make it work, and what needs fixing in the solver. Every number comes from two scenes: a Franka arm that grasps a 40 mm cube and shakes it, and a two-pad coupon that strips the same problem down to one contact. Compared throughout against SolverMuJoCo on the same models.
fpgs-main @ d95efe1d
Newton 1.5.0.dev0 · Warp 1.16.0.dev20260716
RTX PRO 6000 Blackwell (sm_120)
Given enough constraint rows and enough iterations, FeatherPGS holds a shaken object as tightly as SolverMuJoCo and tolerates a far larger time step before the grasp itself fails — with no contact tuning at all. Getting there means changing defaults that silently discard most of the scene's contacts, and avoiding two options that make things worse.
The contact-row budget defaults to 32 rows — ten contacts. The arm scene needs 201. Overflow is silent, and below 96 rows the cube is never picked up at all. The default solve mode also cannot hold that many rows cheaply, because its shared-memory footprint grows as the square of the budget.
One row family has no compliance term. Contacts between a free object and a fixed surface carry only the regularisation constant, so a squeeze between rigid surfaces has no defined force. That scene slides, ejects if squeezed harder, changes answer with the iteration count, and does not repeat run to run.
Two options make grasps worse. Extra velocity iterations turn every not-yet-touching contact into a wall, and the friction anchors trade a downward slide for an upward push.
With the rows in place the cube stays within 0.82 mm through an eleven-second shake at twelve iterations, and 0.31 mm at twenty — matching SolverMuJoCo, which needs tuned contact settings to get there. Nothing was tuned: friction, compliance, position correction and regularisation were left at their defaults and the result is insensitive to all of them across wide sweeps.
Strip the arm away and the grasp alone survives a 16.7 ms time step, where SolverMuJoCo loses the object past 2 ms. The gap in the full scene is the arm and controller, not the contact model.
This configuration produced every measurement on this page. Two of the three settings are structural — they decide whether the scene's contacts can be represented at all — and the third is required for CUDA graph capture.
solver setupsolver = newton.solvers.SolverFeatherPGS(
model,
pgs_mode="matrix_free", # "split" cannot hold this scene's rows cheaply
dense_max_constraints=256, # scene peak is 201; the default 32 drops most of it
mf_max_constraints=4096,
pgs_iterations=12, # 20 halves the creep at proportional cost
double_buffer=False, # required for CUDA graph capture
# friction_mode="bisection_desaxce", # 0.72 mm -> 0.40 mm, matrix-free only
# pgs_beta, pgs_cfm, compliance and friction settings left at their defaults
)
Then supply the arm's gravity torque through control.joint_f, because the solver will not. And check what your scene actually needs:
find out whether contacts are being droppedsolver = newton.solvers.SolverFeatherPGS(model, row_watermark=True, ...)
# ... run the scene ...
print(solver.constraint_row_watermarks())
# {'dense_high_water': 201, 'mf_high_water': 12, 'contact_high_water': 71, ...}
A position-controlled arm needs a gravity term, and no Newton solver computes one yet — it is a platform gap being closed across all of them rather than a FeatherPGS defect. It matters here only because without it the test arm never reaches its target pose, so nothing downstream can be measured at all. Every arm-scene number on this page supplies it as a feed-forward torque through control.joint_f, leaving every gain and solver setting untouched.
With the arm in position and the rows in place, the solver options can be judged on their own. Only two move the number much.
The two parameters people usually reach for do nothing useful. The grasp survives every value of the position-correction factor from zero to 0.9 and every value of the regularisation term across four orders of magnitude — but the residual creep under the position-correction factor bounces between 0.33 mm and 4.50 mm with no pattern, so there is nothing to tune toward.
pgs_velocity_iterations makes the grasp worse, not better. pgs_cfm does nothing to contact softness. friction_smoothing does nothing at all. A grasp built from immovable pads and a fixed overlap has no defined grip force and will give you a different answer on every machine. Each is explained below.Every contact between the arm and anything else becomes one normal row plus two friction rows in a fixed-size per-world buffer. Drive rows and joint-limit rows are allocated from the same buffer first, so contacts are the first thing sacrificed when it fills.
The solver's own high-water telemetry gives the real requirement over a full grasp and shake: 201 dense rows at peak, from up to 71 simultaneous contacts. The constructor default is dense_max_constraints=32, which is ten contacts. The gap is not a matter of degree — below 96 rows the gripper cannot pick the cube up at all.
Slots are claimed with an atomic counter. A contact that does not fit rolls the counter back and marks itself dead:
newton/_src/solvers/feather_pgs/kernels.py:2863 else:
# Dense path
slot = wp.atomic_add(world_slot_counter, world, slots_needed)
if slot + slots_needed > max_constraints:
# Roll back the counter so finalize sees only filled slots
wp.atomic_add(world_slot_counter, world, -slots_needed)
contact_slot[c] = -1
contact_path[c] = -1
return
warnings.warn and no logging. The only way to find out is to opt into row_watermark=True.The default pgs_kernel="tiled_row" stages the whole constraint matrix in static shared memory:
newton/_src/solvers/feather_pgs/solver_feather_pgs.py:8650// Packed LOWER triangle of C in row-major (i*(i+1)/2 + j), j<=i
__shared__ float s_Ctri[TILE_TRI]; // TILE_TRI = M*(M+1)/2
__shared__ float s_lam[TILE_M];
__shared__ float s_rhs[TILE_M];
__shared__ float s_diag[TILE_M];
__shared__ int s_rtype[TILE_M];
__shared__ int s_parent[TILE_M];
__shared__ float s_mu[TILE_M];
That is 2M² + 26M bytes — quadratic in the row budget. CUDA's default per-block limit for a static shared-memory allocation is 48 KiB, and the opt-in above that applies only to dynamic shared memory, so on a device holding to the default the budget caps at 150 rows. Some devices allow more: on the RTX PRO 6000 Blackwell used for these measurements, 201 rows loads and 256 does not, putting its limit between 84 and 134 KiB.
| dense_max_constraints | Shared memory needed | Fits CUDA's default 48 KiB? | Measured on the test GPU |
|---|---|---|---|
| 32 (default) | 2.8 KiB | yes — but only 10 contacts | loads |
| 128 | 35.2 KiB | yes | loads |
| 150 | 47.8 KiB | yes — the largest that does | loads |
| 201 (what the scene needs) | 84.0 KiB | no | loads, at 0.11× realtime |
| 256 | 134.5 KiB | no | will not load |
Nothing checks any of this at construction. The solver already has a shared-memory estimator with a helpful error message, but it is wired into a different kernel family; the PGS kernel is built straight from dense_max_constraints with no device query, and the failure arrives as a CUDA module-load error inside the first step.
pgs_mode="matrix_free" uses a generated kernel with an O(M) footprint and does build at 256 rows — which is why every measurement here uses it. That makes matrix-free mode effectively mandatory for any articulated scene with a real contact set, even though the constructor default is "split". Either the default should change, or the tiled path should move its staging to dynamic shared memory.| Configuration | Mode | Rows | Result | Speed |
|---|
On its own defaults the solver never picks the cube up, entirely because of the 32-row budget, and it does so quietly — the arm runs the full scripted sequence over an object that never moves. Raising the split path's budget until it fits costs about three times the wall-clock time, because a 201-row tile is 84 KiB of shared memory driven by a single warp. Matrix-free mode is not an optimisation here; it is the only configuration that is both correct and fast.
Strip the scene down to two pads and one object and this becomes visible in isolation. It matters because it is the shape most people reach for when writing a minimal grasp test, and it has no answer.
Pads welded to the world, the object placed between them with a fixed geometric overlap, then shaken by a body force. Nothing to set but the overlap. Contacts take the free-body row path.
The pads are the fingers of a four-joint carrier that closes on the object, lifts it off a support and shakes it, all position driven. Grip force comes from the finger drive. Contacts take the articulated path, which is what a real gripper uses.
A 40 mm cube, 0.5 mm of overlap per side, gravity down, an oscillating force shaking it sideways for ten seconds. It does not fall — it never reaches a frame with zero contacts — but it does not stay put either:
| Object collider | Contacts | Slid down | Pushed up | SolverMuJoCo, slid down |
|---|
Push the object equally hard from both sides and it does not move, whether you push with one newton or a thousand. Nothing in its motion distinguishes them, so nothing in the solve pins the pair of forces down: both contacts only say "do not penetrate", and neither has a compliance that would let a penetration depth stand in for a force. A compliant contact model does not have this problem, which is why SolverMuJoCo is untroubled by the same scene.
What FeatherPGS produces instead comes from the position-correction term, which asks for the penetration to be removed within one step — from both sides at once, in a sweep that visits one row and then the other. Three measurements pin that down.
First, the answer moves with the iteration count, on fixed geometry:
This is also why the collision representation matters so much here and so little once the pads are driven. The box presents eight contact points and the mesh eleven, so the two land on different values of an undetermined force, and the friction capacity that follows differs with them. Nothing about triangles is at fault; each representation simply converges to a different accident.
Second, squeezing harder makes it worse, and past a threshold the object is ejected:
| Overlap per side | FeatherPGS, box | FeatherPGS, mesh | SolverMuJoCo, box | SolverMuJoCo, mesh |
|---|
Third, take the shake away, take gravity away, and simply place the object between the pads. Nothing in the scene is trying to move it. It moves anyway:
| Overlap per side | Peak speed, FeatherPGS | Drift, FeatherPGS | Drift, SolverMuJoCo |
|---|
Turning the position-correction factor down settles it completely:
| pgs_beta | Peak speed | Drift over one second |
|---|
At pgs_beta = 0 the object does not move at all — the drift is exactly zero, not small. Every millimetre it travels comes from the position-correction term, and it grows far faster than linearly in that term because the push feeds back into the next step's penetration. The two opposed rows would cancel if solved together; they are not, because the sweep visits one and then the other.
The usual remedy is a compliance term, which turns the penetration back into a force. FeatherPGS has one — dense_contact_compliance — but it does not reach here. Sweeping it from zero to 1e-4 over the ejecting coupon produces bit-identical results, because contacts between a free object and world-fixed pads land on the free-body row family, whose diagonal is seeded with pgs_cfm and nothing else. A gripper's contacts land on a different family that does have the term.
| Comparison | Trajectory difference |
|---|
The box is bit-identical under every combination of double buffering and parallel streams, and bit-identical when simply run again. The mesh is not: two runs with nothing changed at all agree until frame 573 and then part company, ending 1.9 mm apart. Contact rows are claimed with an atomic counter, so the sweep order is whatever the GPU happened to schedule. On a well-posed problem that is a rounding-level detail. Here it decides the answer.
Replace the welded pads with a real finger drive and grip force becomes a defined quantity: stiffness times how far past the surface the finger is commanded. Nothing about the contact model changes.
| Commanded squeeze | FeatherPGS, box | FeatherPGS, mesh | SolverMuJoCo, box | SolverMuJoCo, mesh |
|---|
Each cell is downward slip / sideways lag in millimetres. Downward slip is the object sliding through the grasp; sideways lag is it trailing the shake, which is inertia rather than slipping.
From one millimetre of squeeze upward, FeatherPGS holds the object with essentially zero downward slip, and it does not care whether the object is a box or a triangle mesh. SolverMuJoCo on the same coupon needs four millimetres of squeeze to get close, and at one millimetre it lets the object slide 21 mm. That ordering is the right way round: holding an object firmly with a modest squeeze is what a hard contact model is for.
| Physics time step | FeatherPGS | SolverMuJoCo |
|---|
On this grasp FeatherPGS still holds the object at a 16.7 ms step — one physics step per control frame — with 1.6 mm of slip, while SolverMuJoCo loses it beyond about 2 ms.
The cube never loses contact; it slides steadily down through the pads.
The same scene with only the collider swapped. It settles within a fraction of a millimetre and stays.
Nothing is moving the pads. The object is thrown out of the gap by the position-correction term alone.
Close, lift off the support, shake. The same contact model, with a grip force that exists.
The friction anchor options stop the static coupon's mesh from sliding, and replace the slide with travel in the other direction:
"It no longer falls" is not the same as "it is held". Both options convert a downward slide into an upward push, which is what you would expect from a friction row carrying a positional bias rather than only a velocity target. A grasp that slowly extrudes the object upward out of the fingers will pass a drop test and fail everything else.
FeatherPGS forms the pair coefficient as the arithmetic mean of the two shape values. Newton's own MuJoCo path takes the element-wise maximum. Same model, same materials, different friction cone.
feather_pgs/kernels.py:3096 vs mujoco/kernels.py:165# FeatherPGS
mu = 0.0
mat_count = 0
if shape_a >= 0:
mu += shape_material_mu[shape_a]
mat_count += 1
if shape_b >= 0:
mu += shape_material_mu[shape_b]
mat_count += 1
if mat_count > 0:
mu /= float(mat_count)
# SolverMuJoCo
resolved_friction = wp.max(geom_friction[worldid, g1], geom_friction[worldid, g2])Three alternatives to the default lagged cone exist. Two are clearly better for a grasp: bisection cuts peak creep from 0.72 mm to 0.44 mm, and bisection with the de Saxée correction to 0.40 mm — a 44% improvement for about 1.4× the solve time, with no other change. The bracketed Newton variant is worse than the default at 0.96 mm. None can be selected unless pgs_mode="matrix_free", and all are excluded from the propagation contact path entirely.
contact_friction_shared_anchor=True puts both at the midpoint and removes it. Its measured effect in the arm scene is small (0.82 mm to 0.80 mm) only because that grasp barely penetrates.pgs_velocity_iterations solves positions with a position-correction bias, then cleans up velocities without one. It should improve a grasp. It does the opposite, and the reason is one hard-coded argument.
A contact whose surfaces are still apart has a positive gap. The correct velocity-level constraint for it is "you may close at up to gap over dt", which is what the position pass builds. The velocity pass rebuilds every right-hand side with the speculative scale forced to zero:
newton/_src/solvers/feather_pgs/solver_feather_pgs.py:4779self._stage4_compute_rhs_world(
dt,
bias_scale=0.0,
contact_speculative_scale=0.0, # <-- not a parameter
joint_limit_speculative_scale=1.0,
output=self.rhs_unbiased,
)
if self._has_free_rigid_bodies:
self._compute_mf_rhs_bias(dt, bias_scale=0.0, speculative_scale=0.0,
output=self.mf_rhs_unbiased)
Dropping the position bias is correct and is the point of the pass. Dropping the speculative term is not: the constraint becomes "you may not approach at all", applied to pairs that are millimetres apart. Every one turns into an invisible wall for the duration of the velocity iterations.
The arm scene's collision gap is 5 mm per shape, so during the shake there are typically 14 touching contacts and 32 speculative ones. Shrinking the gap by 50× so almost no speculative contacts exist removes the regression and lets the pass do what it is supposed to:
| Configuration | Speculative contacts live | Peak creep |
|---|
contact_speculative_scale=1.0, matching what the position pass already does and what the constraint means. As written, the option cannot be used on any scene with a non-trivial collision gap — which is the default configuration of every Newton scene.The measurement throughout is the object's position in the gripper's own frame, referenced to the start of the shake. That removes the commanded motion and leaves only relative movement — slip, squeeze and rattle.
The reference, with the gravity term supplied by the scene. Peak creep 0.31 mm.
The arm sags below the IK target and the phase gate never opens, so the grasp is never attempted.
Stiff enough to reach the pose, but the cube works its way out of the over-stiff pads and falls 3.65 s into the shake.
Original gains, contact settings at defaults. Peak creep 0.82 mm across the full shake, 0.31 mm at twenty iterations.
The control loop stays at 60 Hz; only the number of physics substeps changes. Speed is measured with the physics loop inside a captured CUDA graph so it reflects the solver rather than Python.
| Time step | FeatherPGS creep | SolverMuJoCo creep | FeatherPGS per frame | SolverMuJoCo per frame |
|---|
Two things fall out. FeatherPGS starts 2.6× behind on creep at the smallest step and the two converge as the step grows — at 8.3 ms they are within 5% of each other. And the cost ratio barely moves: SolverMuJoCo is 2.7–2.8× cheaper per frame at every step, because a flat per-substep cost across a 16× change in substep count means both are latency-bound rather than compute-bound at one environment. A single 9-DOF arm produces tens of kernel launches per substep, almost all with grids of one to thirty-two threads. The Cholesky factorisation of the 9×9 mass matrix runs on one thread — small_dof_threshold=12 selects the serial kernel below thirteen degrees of freedom — and the Gauss-Seidel solve is launched as one tile of thirty-two threads per world, in both solve modes. There is nothing wrong with that at scale; it is the wrong shape for one arm.
Three costs are avoidable at any scale:
newton/_src/solvers/feather_pgs/solver_feather_pgs.py:5046 — ungated, every stepself._stage3_compute_v_hat(state_in, state_aug, dt)
self._clamp_rigid_velocity_limits(self.v_hat)
wp.copy(self._debug_stage3_qd_work, self.qd_work)
wp.copy(self._debug_stage3_joint_qdd, state_aug.joint_qdd)
wp.copy(self._debug_stage3_v_hat, self.v_hat)
There are three ways to spend more time on the same second of simulation: smaller substeps, more solver iterations per substep, or — on an unmerged branch — frozen inner substeps that reuse the mass-matrix factorisation and the contact Jacobians.
| Solver iterations | Grasp | Per frame | Speed |
|---|
Iterations are the cleanest lever, and they are what closes the accuracy gap: twenty iterations reaches 0.31 mm, exactly SolverMuJoCo's number on this scene, and thirty-two beats it at 0.22 mm. The price is 20.9 ms per frame against SolverMuJoCo's 5.2 ms — four times the compute to match. Past thirty-two there is nothing left to buy. The wrinkle is that convergence is not monotone — eight iterations is worse than four and much worse than twelve. Under-converged Gauss-Seidel does not give a slightly softer grasp; it gives a different one. That is an argument for running enough iterations to be past the noisy region rather than for tuning the number.
| Solver × inner substeps | Effective time step | Grasp | Per frame | Speed |
|---|
Two solver substeps with no inner loop gives 3.43 mm at 1.95 ms. Adding four frozen inner substeps — a four-times-smaller integration step — gives 3.51 mm at 5.82 ms. Refining the integration changed nothing, because the error is not in the integration; it is in the contact basis, which the frozen loop deliberately does not refresh. At the same effective step size, plain substepping gives 1.30 mm.
It does earn its keep rescuing a step too large to work at all: at one solver substep the plain solve drops the cube, and four frozen inner substeps turn that into a 7.47 mm hold at 5.9× realtime. But the behaviour is not monotone — eight loses the grasp and sixteen drops the cube again — which is what you would expect when positions are advanced far from where the Jacobians were built. As a speed lever for grasping it needs the contact basis refreshed on some cadence, not frozen for the whole step.
Every speed number here depends on capturing the 16-substep physics loop once and replaying it. With the constructor defaults, that capture fails.
| use_parallel_streams | double_buffer | Capture of a 16-substep frame |
|---|
Only double_buffer is responsible, and the reason is structural. The double-buffer memset forks onto a private stream and never joins back within the same step:
newton/_src/solvers/feather_pgs/solver_feather_pgs.py:5530if self._memset_stream is not None:
with wp.ScopedTimer("DB_Memset", ...):
with wp.ScopedStream(self._memset_stream): # forks the capture
for size in self.size_groups:
if self._mass_update_global_flag:
self._H_bufs[self._buf_idx][size].zero_()
self._J_bufs[self._buf_idx][size].zero_()
self._memset_done_event[self._buf_idx] = self._memset_stream.record_event()
self._buf_idx = 1 - self._buf_idx
wp.ScopedStream synchronises on entry but not on exit. The only join is the wait_event at the top of the next step, so the final step of any captured region leaves an unjoined fork and capture fails. On the first captured step the event being waited on was also recorded outside the capture, which is separately illegal.
A method exists that would fix the second half of this, seed_double_buffer_events(). It has zero callers anywhere in the repository, and its docstring says it must be called inside graph capture, which is the opposite of what would work. No test in the repository captures a FeatherPGS step(), which is why this survived.
double_buffer=False. On a 9-DOF arm the buffers involved are about 1.5 KB, so the memset it hides costs nothing; the extra stream, the per-step event allocation and the extra dependency edges cost strictly more. A capture guard using the existing device.is_capturing would let the default stay on for the uncaptured case.friction_smoothing is accepted by the constructor, documented as the Huber-norm delta for friction velocity normalisation, and stored on the instance. No kernel reads it, and norm_huber appears nowhere in the package. It is live in the sibling Featherstone and semi-implicit solvers, which is where it was copied from. Setting it is a silent no-op.
pgs_cfm is added to the Gauss-Seidel denominator but not to the residual the sweep drives to zero, so it changes how fast the sweep converges rather than what it converges to. Sweeping it across four orders of magnitude moves peak creep from 0.720 mm to 0.788 mm. If you were expecting it to soften the contact, it will not.
The docstring states that enable_joint_limits is incompatible with pgs_kernel="tiled_contact" and "streaming". Nothing enforces it. Those kernels never receive the row-type or row-parent arrays, so limit rows are projected as if they were contacts. This is wrong physics, not a performance downgrade, and it happens without any message.
None of joint_damping, joint_friction, or joint_target_mode appears anywhere in the package — zero matches across all three files. SolverMuJoCo reads all three. Passive joint damping vanishes, joint dry friction vanishes, and every DOF with a non-zero gain gets both a position and a velocity term regardless of the drive mode the model declares.
The matrix-free and propagation paths clamp the position-correction push with rigid_body_max_depenetration_velocity. The dense path — where articulated contacts land under the default articulated_contact_response="immediate" — does not take that array as an argument at all. A bad initial grasp pose gets an unbounded correction.
FeatherPGS reads the deprecated DOF-layout aliases control.joint_target_pos and joint_target_vel. Under Newton's coordinate-layout targets those properties raise, so step() fails outright; under the older layout they emit a deprecation warning on every step. Any scene using the newer layout has to turn it off.
Gravity compensation is deliberately not on this list. A position-controlled arm needs one, and FeatherPGS has none — but neither does any other Newton solver, so it belongs to the platform rather than to this solver. Supply it however you like; everything below is what remains once you have.
Thirty-two rows is ten contacts; a two-finger grasp over a table already needs seventy. Size the default from the model's contact capacity, and warn once when a contact is dropped. Silent contact deletion is the failure mode most likely to be mistaken for a friction problem.
Two opposed contacts on a rigid object have no force balance to converge to, so the position-correction term becomes the only thing setting the answer — and it throws the object out in proportion to the overlap. dense_contact_compliance already provides a compliance term for the row family a gripper's contacts land on; the free-body family has none, and its diagonal carries pgs_cfm and nothing else. Until that is filled in, a hard-contact grasp has to be driven, never wedged.
The de Saxée bisection cone halves the creep of the default for about 1.4× the solve time, but only exists on one solve mode and not on the propagation path. Separately, FeatherPGS averages the two friction coefficients while Newton's own MuJoCo path takes their maximum — two solvers in one library disagreeing about what a material means. Default the shared friction anchor on while you are there; the torque couple it removes is specific to pinch grasps.
One hard-coded contact_speculative_scale=0.0 turns every not-yet-touching contact into a wall and makes pgs_velocity_iterations a net negative on any scene with a normal collision gap.
A quadratic static allocation puts the row budget at the mercy of the device: 150 rows under CUDA's default 48 KiB static limit, roughly 220 on a device that allows 99 KiB. Until it moves, validate dense_max_constraints against the device's actual limit at construction and say so, rather than failing as a module-load error inside the first step. The solver already has an estimator that does exactly this for a different kernel.
Use the existing device.is_capturing to fall back to an inline memset, or default double_buffer to off. Add a test that captures step() — there is currently none, which is why this has gone unnoticed.
At one environment a 9-DOF arm costs 0.91 ms per substep against SolverMuJoCo's 0.32 ms, and the ratio holds across a 16× change in substep count — it is launch latency, not arithmetic. A 9×9 Cholesky on one thread and the whole solve on one warp are the right shapes at scale and the wrong ones here. Fusing the small-model path would make FeatherPGS competitive on single-scene work as well as on batched work.
Three wp.copy calls run on every step for buffers nothing reads. friction_smoothing is documented and inert. Both cost trust more than they cost time.
Reading the deprecated DOF-layout aliases makes FeatherPGS unusable with Newton's current default and noisy with the old one.
dylanturpin/newton-collab branch fpgs-main at d95efe1d — 111 commits ahead of and 119 behind newton-physics/newton@main. PR #2608 has not moved since 8 July 2026 and is blocked, so there is no upstream FeatherPGS that runs these scenes.StoneT2000/newton-tests at 32c1544b, franka_cube_shake.py, unmodified. A fixed FR3, analytic IK, a scripted approach→descend→grasp→lift→shake sequence at 60 Hz with 16 substeps. Its SolverMuJoCo settings come from the grasp-shake-drift investigation and are left as they are; the only thing changed here is the solver.setupgit clone --branch fpgs-main https://github.com/dylanturpin/newton-collab.git
cd newton-collab && git checkout d95efe1d1bb07497a4e51bf9563f36bc8821ab73
uv venv .venv --python 3.12 && uv sync --extra examples
git clone https://github.com/StoneT2000/newton-tests.git
cd newton-tests && git checkout 32c1544bac713bb722e9f805c26dc99413f0e359
the one change that mattersfrom harness import make_env
from gravcomp import GravityCompensator
env = make_env("fpgs") # original gains, nothing else changed
comp = GravityCompensator(env)
for frame in range(900):
comp.apply() # writes tau_g into control.joint_f
env.step()