feat(cuda): continuous multi-turn angle tracking and external torque for revolute joint - #466
Conversation
…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>
There was a problem hiding this comment.
Code Review
This pull request introduces continuously-unwrapped, multi-turn absolute angle tracking for revolute joints, replacing the previous wrapping behavior to 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.
| 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); | ||
| } |
There was a problem hiding this comment.
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);
}
| #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); | ||
| } |
There was a problem hiding this comment.
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>
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
::remainderagainst the previously committed value, soangle, drivingaim_angle, andlimit/lower|uppernow share the same unbounded frame and may span beyond±πand across multiple turns.π— an endpoint-sampling aliasing limit, not an implementation choice.angleentries during DOF re-sync, then re-syncingcurrent_anglesto the new pose.External torque
torque_to_Fconverting a scalar joint torque about a world-space axis into an ABD generalized force (virtual-work formFᴬ = (τ/2)·[e]ₓ·A⁻ᵀ).|det|/(‖a₀‖‖a₁‖‖a₂‖)) that drops the torque and warns when the affine matrix collapses, avoiding a poisoned solve.Docs
Breaking changes
The
angleedge 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_angleand 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.