Skip to content

feat(cuda): continuous multi-turn angle tracking and external torque for revolute joint - #466

Merged
MuGdxy merged 2 commits into
spiriMirror:mainfrom
Ligo04:feat/revolute-joint-continuous-angle
Jul 8, 2026
Merged

feat(cuda): continuous multi-turn angle tracking and external torque for revolute joint#466
MuGdxy merged 2 commits into
spiriMirror:mainfrom
Ligo04:feat/revolute-joint-continuous-angle

Conversation

@Ligo04

@Ligo04 Ligo04 commented Jul 7, 2026

Copy link
Copy Markdown
Contributor

Summary

Track the revolute joint angle as a continuously-unwrapped, multi-turn absolute value instead of a wrapped (-π, π] measurement, and add external-torque support for the joint.

Angle unwrap

  • The backend advances a persistent reference angle each step via ::remainder against the previously committed value, so angle, driving aim_angle, and limit/lower|upper now share the same unbounded frame and may span beyond ±π and across multiple turns.
  • The unwrap is exact while per-step rotation stays below π — an endpoint-sampling aliasing limit, not an implementation choice.
  • Reset/teleport jumps (which cannot be unwrapped from two wrapped samples) are handled by adopting user-authored angle entries during DOF re-sync, then re-syncing current_angles to the new pose.

External torque

  • Adds torque_to_F converting a scalar joint torque about a world-space axis into an ABD generalized force (virtual-work form Fᴬ = (τ/2)·[e]ₓ·A⁻ᵀ).
  • Includes a scale-invariant near-singular guard (|det|/(‖a₀‖‖a₁‖‖a₂‖)) that drops the torque and warns when the affine matrix collapses, avoiding a poisoned solve.

Docs

  • Specification docs for the base joint, limit, and driving joint updated to describe the unbounded-frame semantics (including the aliasing limit and the sub-stepping escape hatch for fast continuous rotation).

Breaking changes

The angle edge attribute is now a continuous multi-turn value rather than wrapped to (-π, π]. Consumers that previously assumed a bounded range should read it as an absolute unbounded angle. aim_angle and joint limits are interpreted in this same frame.

Notes

Energy, gradient, and Hessian kernels call the shared unwrap helper with identical arguments to stay a self-consistent triple.

…for revolute joint

Track the revolute joint angle as a continuously-unwrapped, multi-turn
absolute value instead of a wrapped (-pi, pi] measurement. The backend
advances a persistent reference angle each step via ::remainder against
the previously committed value, so `angle`, driving `aim_angle`, and
`limit/lower|upper` are all expressed in the same unbounded frame and may
now span beyond +/-pi and across multiple turns. The unwrap is exact
while per-step rotation stays below pi (an endpoint-sampling aliasing
limit); reset/teleport jumps are handled by adopting user-authored
`angle` entries during DOF re-sync.

Also adds `torque_to_F` to convert a scalar joint torque about a world
axis into an ABD generalized force, with a scale-invariant near-singular
guard that drops the torque when the affine matrix collapses.

Specification docs for the base joint, limit, and driving joint updated
to describe the unbounded-frame semantics.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Code Review

This pull request introduces continuously-unwrapped, multi-turn absolute angle tracking for revolute joints, replacing the previous wrapping behavior to $(-\pi, \pi]$. It updates the documentation, simplifies the joint limit implementation by removing the need for reference configurations, and adds support for adopting user-authored angles (resets/teleports). Additionally, it extracts the torque-to-force conversion into a utility function with a safety guard against near-singular affine matrices. The reviewer feedback highlights two important improvement opportunities: first, optimizing adopt_scene_angles() to avoid synchronous GPU-to-CPU copies when no user-authored angles are modified; second, preventing implicit double-precision promotion on the GPU in unwrap_angle by using typed pi constants and including <cmath>.

Important

The consumer version of Gemini Code Assist on GitHub is being sunset. Starting June 18, 2026, new organization installations will be blocked, and all code review activity will officially cease on July 17, 2026.
For more details on the timeline and next steps, please review the Help Documentation.

Comment on lines +468 to +507
current_angles.copy_to(h_adopted_angles);

IndexT geo_joint_index = 0;
bool adopted = false;

this->for_each(geo_slots,
[&](geometry::Geometry& geo)
{
auto sc = geo.as<geometry::SimplicialComplex>();
UIPC_ASSERT(sc, "AffineBodyRevoluteJoint: Geometry must be a simplicial complex");

auto angle = sc->edges().find<Float>("angle");
if(angle)
{
auto angle_view = angle->view();
auto [offset, count] =
h_geo_joint_offsets_counts[geo_joint_index];
UIPC_ASSERT(angle_view.size() == count,
"AffineBodyRevoluteJoint: angle attribute size {} mismatch with joint count {}",
angle_view.size(),
count);

for(IndexT i = 0; i < count; ++i)
{
Float user_angle = angle_view[i];
if(user_angle != h_current_angles[offset + i])
{
h_adopted_angles[offset + i] = user_angle;
adopted = true;
}
}
}

++geo_joint_index;
});

if(adopted)
{
current_angles.copy_from(h_adopted_angles);
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

high

Every time adopt_scene_angles() is called, it performs a synchronous GPU-to-CPU copy of current_angles to h_adopted_angles via current_angles.copy_to(h_adopted_angles). Since this function is called during state updates, and in the vast majority of steps no user-authored angles are modified, this synchronous copy introduces unnecessary host-device synchronization overhead and stalls the CPU.

We can defer the copy and only initialize h_adopted_angles (using the already-available host-side h_current_angles) when a mismatch is actually detected. If no mismatch is found, we completely avoid any GPU-to-CPU or CPU-to-GPU copies.

        IndexT geo_joint_index = 0;
        bool   adopted         = false;

        this->for_each(geo_slots,
                       [&](geometry::Geometry& geo)
                       {
                           auto sc = geo.as<geometry::SimplicialComplex>();
                           UIPC_ASSERT(sc, "AffineBodyRevoluteJoint: Geometry must be a simplicial complex");

                           auto angle = sc->edges().find<Float>("angle");
                           if(angle)
                           {
                               auto angle_view = angle->view();
                               auto [offset, count] =
                                   h_geo_joint_offsets_counts[geo_joint_index];
                               UIPC_ASSERT(angle_view.size() == count,
                                           "AffineBodyRevoluteJoint: angle attribute size {} mismatch with joint count {}",
                                           angle_view.size(),
                                           count);

                               for(IndexT i = 0; i < count; ++i)
                               {
                                   Float user_angle = angle_view[i];
                                   if(user_angle != h_current_angles[offset + i])
                                   {
                                       if(!adopted)
                                       {
                                           h_adopted_angles = h_current_angles;
                                           adopted = true;
                                       }
                                       h_adopted_angles[offset + i] = user_angle;
                                   }
                               }
                           }

                           ++geo_joint_index;
                       });

        if(adopted)
        {
            current_angles.copy_from(h_adopted_angles);
        }

Comment on lines 1 to +11
#pragma once
#include <type_define.h>
#include <numbers>

namespace uipc::backend::cuda
{
// Unwrap `wrapped_angle` to the 2*pi representative nearest `ref` (energy and gradient must pass identical args).
UIPC_GENERIC inline Float unwrap_angle(Float wrapped_angle, Float ref)
{
return ref + ::remainder(wrapped_angle - ref, 2.0 * std::numbers::pi);
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

medium

Using 2.0 * std::numbers::pi (which is a double constant) causes implicit promotion to double precision when Float is float, leading to slow double-precision arithmetic on the GPU. Additionally, using ::remainder without including <cmath> or using the std:: namespace can cause portability issues across different compilers.

We should include <cmath>, use std::remainder, and use std::numbers::pi_v<Float> to ensure single-precision arithmetic when Float is float.

#pragma once
#include <type_define.h>
#include <numbers>
#include <cmath>

namespace uipc::backend::cuda
{
// Unwrap `wrapped_angle` to the 2*pi representative nearest `ref` (energy and gradient must pass identical args).
UIPC_GENERIC inline Float unwrap_angle(Float wrapped_angle, Float ref)
{
    return ref + std::remainder(wrapped_angle - ref, static_cast<Float>(2.0) * std::numbers::pi_v<Float>);
}

fmt 12.2.0's format_hexfloat uses operator~ on the fallback uint128
type, which is missing and breaks nvcc device compilation
(fmtlib/fmt#4821, fixed upstream in fmtlib/fmt#4813 but unreleased).
Pin spdlog's external fmt to 12.1.0 until a fixed release lands.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@MuGdxy
MuGdxy merged commit 619a541 into spiriMirror:main Jul 8, 2026
15 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants