SwingTwistConstraintPart.h 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465
  1. // SPDX-FileCopyrightText: 2021 Jorrit Rouwe
  2. // SPDX-License-Identifier: MIT
  3. #pragma once
  4. #include <Jolt/Geometry/Ellipse.h>
  5. #include <Jolt/Physics/Constraints/ConstraintPart/RotationEulerConstraintPart.h>
  6. #include <Jolt/Physics/Constraints/ConstraintPart/AngleConstraintPart.h>
  7. JPH_NAMESPACE_BEGIN
  8. /// Quaternion based constraint that decomposes the rotation in constraint space in swing and twist: q = q_swing * q_twist
  9. /// where q_swing.x = 0 and where q_twist.y = q_twist.z = 0
  10. ///
  11. /// - Rotation around the twist (x-axis) is within [inTwistMinAngle, inTwistMaxAngle].
  12. /// - Rotation around the swing axis (y and z axis) are limited to an ellipsoid in quaternion space formed by the equation:
  13. ///
  14. /// (q_swing.y / sin(inSwingYHalfAngle / 2))^2 + (q_swing.z / sin(inSwingZHalfAngle / 2))^2 <= 1
  15. ///
  16. /// Which roughly corresponds to an elliptic cone shape with major axis (inSwingYHalfAngle, inSwingZHalfAngle).
  17. ///
  18. /// In case inSwingYHalfAngle = 0, the rotation around Y will be constrained to 0 and the rotation around Z
  19. /// will be constrained between [-inSwingZHalfAngle, inSwingZHalfAngle]. Vice versa if inSwingZHalfAngle = 0.
  20. class SwingTwistConstraintPart
  21. {
  22. public:
  23. /// Set limits for this constraint (see description above for parameters)
  24. void SetLimits(float inTwistMinAngle, float inTwistMaxAngle, float inSwingYHalfAngle, float inSwingZHalfAngle)
  25. {
  26. constexpr float cLockedAngle = DegreesToRadians(0.5f);
  27. constexpr float cFreeAngle = DegreesToRadians(179.5f);
  28. // Assume sane input
  29. JPH_ASSERT(inTwistMinAngle <= 0.0f && inTwistMinAngle >= -JPH_PI);
  30. JPH_ASSERT(inTwistMaxAngle >= 0.0f && inTwistMaxAngle <= JPH_PI);
  31. JPH_ASSERT(inSwingYHalfAngle >= 0.0f && inSwingYHalfAngle <= JPH_PI);
  32. JPH_ASSERT(inSwingZHalfAngle >= 0.0f && inSwingZHalfAngle <= JPH_PI);
  33. // Calculate the sine and cosine of the half angles
  34. Vec4 s, c;
  35. (0.5f * Vec4(inTwistMinAngle, inTwistMaxAngle, inSwingYHalfAngle, inSwingZHalfAngle)).SinCos(s, c);
  36. // Store axis flags which are used at runtime to quickly decided which contraints to apply
  37. mRotationFlags = 0;
  38. if (inTwistMinAngle > -cLockedAngle && inTwistMaxAngle < cLockedAngle)
  39. {
  40. mRotationFlags |= TwistXLocked;
  41. mSinTwistHalfMinAngle = 0.0f;
  42. mSinTwistHalfMaxAngle = 0.0f;
  43. mCosTwistHalfMinAngle = 1.0f;
  44. mCosTwistHalfMaxAngle = 1.0f;
  45. }
  46. else if (inTwistMinAngle < -cFreeAngle && inTwistMaxAngle > cFreeAngle)
  47. {
  48. mRotationFlags |= TwistXFree;
  49. mSinTwistHalfMinAngle = -1.0f;
  50. mSinTwistHalfMaxAngle = 1.0f;
  51. mCosTwistHalfMinAngle = 0.0f;
  52. mCosTwistHalfMaxAngle = 0.0f;
  53. }
  54. else
  55. {
  56. mSinTwistHalfMinAngle = s.GetX();
  57. mSinTwistHalfMaxAngle = s.GetY();
  58. mCosTwistHalfMinAngle = c.GetX();
  59. mCosTwistHalfMaxAngle = c.GetY();
  60. }
  61. if (inSwingYHalfAngle < cLockedAngle)
  62. {
  63. mRotationFlags |= SwingYLocked;
  64. mSinSwingYQuarterAngle = 0.0f;
  65. }
  66. else if (inSwingYHalfAngle > cFreeAngle)
  67. {
  68. mRotationFlags |= SwingYFree;
  69. mSinSwingYQuarterAngle = 1.0f;
  70. }
  71. else
  72. {
  73. mSinSwingYQuarterAngle = s.GetZ();
  74. }
  75. if (inSwingZHalfAngle < cLockedAngle)
  76. {
  77. mRotationFlags |= SwingZLocked;
  78. mSinSwingZQuarterAngle = 0.0f;
  79. }
  80. else if (inSwingZHalfAngle > cFreeAngle)
  81. {
  82. mRotationFlags |= SwingZFree;
  83. mSinSwingZQuarterAngle = 1.0f;
  84. }
  85. else
  86. {
  87. mSinSwingZQuarterAngle = s.GetW();
  88. }
  89. }
  90. /// Clamp twist and swing against the constraint limits, returns which parts were clamped (everything assumed in constraint space)
  91. inline void ClampSwingTwist(Quat &ioSwing, bool &outSwingYClamped, bool &outSwingZClamped, Quat &ioTwist, bool &outTwistClamped) const
  92. {
  93. // Start with not clamped
  94. outTwistClamped = false;
  95. outSwingYClamped = false;
  96. outSwingZClamped = false;
  97. // Check that swing and twist quaternions don't contain rotations around the wrong axis
  98. JPH_ASSERT(ioSwing.GetX() == 0.0f);
  99. JPH_ASSERT(ioTwist.GetY() == 0.0f);
  100. JPH_ASSERT(ioTwist.GetZ() == 0.0f);
  101. // Ensure quaternions have w > 0
  102. bool negate_swing = ioSwing.GetW() < 0.0f;
  103. if (negate_swing)
  104. ioSwing = -ioSwing;
  105. bool negate_twist = ioTwist.GetW() < 0.0f;
  106. if (negate_twist)
  107. ioTwist = -ioTwist;
  108. if (mRotationFlags & TwistXLocked)
  109. {
  110. // Twist axis is locked, clamp whenever twist is not identity
  111. if (ioTwist.GetX() != 0.0f)
  112. {
  113. ioTwist = Quat::sIdentity();
  114. outTwistClamped = true;
  115. }
  116. }
  117. else if ((mRotationFlags & TwistXFree) == 0)
  118. {
  119. // Twist axis has limit, clamp whenever out of range
  120. float delta_min = mSinTwistHalfMinAngle - ioTwist.GetX();
  121. float delta_max = ioTwist.GetX() - mSinTwistHalfMaxAngle;
  122. if (delta_min > 0.0f || delta_max > 0.0f)
  123. {
  124. // We're outside of the limits, get actual delta to min/max range
  125. // Note that a twist of -1 and 1 represent the same angle, so if the difference is bigger than 1, the shortest angle is the other way around (2 - difference)
  126. // We should actually be working with angles rather than sin(angle / 2). When the difference is small the approximation is accurate, but
  127. // when working with extreme values the calculation is off and e.g. when the limit is between 0 and 180 a value of approx -60 will clamp
  128. // to 180 rather than 0 (you'd expect anything > -90 to go to 0).
  129. delta_min = abs(delta_min);
  130. if (delta_min > 1.0f) delta_min = 2.0f - delta_min;
  131. delta_max = abs(delta_max);
  132. if (delta_max > 1.0f) delta_max = 2.0f - delta_max;
  133. // Pick the twist that corresponds to the smallest delta
  134. if (delta_min < delta_max)
  135. ioTwist = Quat(mSinTwistHalfMinAngle, 0, 0, mCosTwistHalfMinAngle);
  136. else
  137. ioTwist = Quat(mSinTwistHalfMaxAngle, 0, 0, mCosTwistHalfMaxAngle);
  138. outTwistClamped = true;
  139. }
  140. }
  141. // Clamp swing
  142. if (mRotationFlags & SwingYLocked)
  143. {
  144. if (mRotationFlags & SwingZLocked)
  145. {
  146. // Both swing Y and Z are disabled, no degrees of freedom in swing
  147. outSwingYClamped = ioSwing.GetY() != 0.0f;
  148. outSwingZClamped = ioSwing.GetZ() != 0.0f;
  149. if (outSwingYClamped || outSwingZClamped)
  150. ioSwing = Quat::sIdentity();
  151. }
  152. else
  153. {
  154. // Swing Y angle disabled, only 1 degree of freedom in swing
  155. float z = Clamp(ioSwing.GetZ(), -mSinSwingZQuarterAngle, mSinSwingZQuarterAngle);
  156. outSwingYClamped = ioSwing.GetY() != 0.0f;
  157. outSwingZClamped = z != ioSwing.GetZ();
  158. if (outSwingYClamped || outSwingZClamped)
  159. ioSwing = Quat(0, 0, z, sqrt(1.0f - Square(z)));
  160. }
  161. }
  162. else if (mRotationFlags & SwingZLocked)
  163. {
  164. // Swing Z angle disabled, only 1 degree of freedom in swing
  165. float y = Clamp(ioSwing.GetY(), -mSinSwingYQuarterAngle, mSinSwingYQuarterAngle);
  166. outSwingYClamped = y != ioSwing.GetY();
  167. outSwingZClamped = ioSwing.GetZ() != 0.0f;
  168. if (outSwingYClamped || outSwingZClamped)
  169. ioSwing = Quat(0, y, 0, sqrt(1.0f - Square(y)));
  170. }
  171. else
  172. {
  173. // Two degrees of freedom, use ellipse to solve limits
  174. Ellipse ellipse(mSinSwingYQuarterAngle, mSinSwingZQuarterAngle);
  175. Float2 point(ioSwing.GetY(), ioSwing.GetZ());
  176. if (!ellipse.IsInside(point))
  177. {
  178. Float2 closest = ellipse.GetClosestPoint(point);
  179. ioSwing = Quat(0, closest.x, closest.y, sqrt(max(0.0f, 1.0f - Square(closest.x) - Square(closest.y))));
  180. outSwingYClamped = true;
  181. outSwingZClamped = true;
  182. }
  183. }
  184. // Flip sign back
  185. if (negate_swing)
  186. ioSwing = -ioSwing;
  187. if (negate_twist)
  188. ioTwist = -ioTwist;
  189. JPH_ASSERT(ioSwing.IsNormalized());
  190. JPH_ASSERT(ioTwist.IsNormalized());
  191. }
  192. /// Calculate properties used during the functions below
  193. /// @param inDeltaTime Time step
  194. /// @param inBody1 The first body that this constraint is attached to
  195. /// @param inBody2 The second body that this constraint is attached to
  196. /// @param inConstraintRotation The current rotation of the constraint in constraint space
  197. /// @param inConstraintToWorld Rotates from constraint space into world space
  198. inline void CalculateConstraintProperties(float inDeltaTime, const Body &inBody1, const Body &inBody2, QuatArg inConstraintRotation, QuatArg inConstraintToWorld)
  199. {
  200. // Decompose into swing and twist
  201. Quat q_swing, q_twist;
  202. inConstraintRotation.GetSwingTwist(q_swing, q_twist);
  203. // Clamp against joint limits
  204. Quat q_clamped_swing = q_swing, q_clamped_twist = q_twist;
  205. bool swing_y_clamped, swing_z_clamped, twist_clamped;
  206. ClampSwingTwist(q_clamped_swing, swing_y_clamped, swing_z_clamped, q_clamped_twist, twist_clamped);
  207. if (mRotationFlags & SwingYLocked)
  208. {
  209. Quat twist_to_world = inConstraintToWorld * q_swing;
  210. mWorldSpaceSwingLimitYRotationAxis = twist_to_world.RotateAxisY();
  211. mWorldSpaceSwingLimitZRotationAxis = twist_to_world.RotateAxisZ();
  212. if (mRotationFlags & SwingZLocked)
  213. {
  214. // Swing fully locked
  215. mSwingLimitYConstraintPart.CalculateConstraintProperties(inDeltaTime, inBody1, inBody2, mWorldSpaceSwingLimitYRotationAxis);
  216. mSwingLimitZConstraintPart.CalculateConstraintProperties(inDeltaTime, inBody1, inBody2, mWorldSpaceSwingLimitZRotationAxis);
  217. }
  218. else
  219. {
  220. // Swing only locked around Y
  221. mSwingLimitYConstraintPart.CalculateConstraintProperties(inDeltaTime, inBody1, inBody2, mWorldSpaceSwingLimitYRotationAxis);
  222. if (swing_z_clamped)
  223. {
  224. if (Sign(q_swing.GetW()) * q_swing.GetZ() < 0.0f)
  225. mWorldSpaceSwingLimitZRotationAxis = -mWorldSpaceSwingLimitZRotationAxis; // Flip axis if angle is negative because the impulse limit is going to be between [-FLT_MAX, 0]
  226. mSwingLimitZConstraintPart.CalculateConstraintProperties(inDeltaTime, inBody1, inBody2, mWorldSpaceSwingLimitZRotationAxis);
  227. }
  228. else
  229. mSwingLimitZConstraintPart.Deactivate();
  230. }
  231. }
  232. else if (mRotationFlags & SwingZLocked)
  233. {
  234. // Swing only locked around Z
  235. Quat twist_to_world = inConstraintToWorld * q_swing;
  236. mWorldSpaceSwingLimitYRotationAxis = twist_to_world.RotateAxisY();
  237. mWorldSpaceSwingLimitZRotationAxis = twist_to_world.RotateAxisZ();
  238. if (swing_y_clamped)
  239. {
  240. if (Sign(q_swing.GetW()) * q_swing.GetY() < 0.0f)
  241. mWorldSpaceSwingLimitYRotationAxis = -mWorldSpaceSwingLimitYRotationAxis; // Flip axis if angle is negative because the impulse limit is going to be between [-FLT_MAX, 0]
  242. mSwingLimitYConstraintPart.CalculateConstraintProperties(inDeltaTime, inBody1, inBody2, mWorldSpaceSwingLimitYRotationAxis);
  243. }
  244. else
  245. mSwingLimitYConstraintPart.Deactivate();
  246. mSwingLimitZConstraintPart.CalculateConstraintProperties(inDeltaTime, inBody1, inBody2, mWorldSpaceSwingLimitZRotationAxis);
  247. }
  248. else if ((mRotationFlags & SwingYZFree) != SwingYZFree)
  249. {
  250. // Swing has limits around Y and Z
  251. if (swing_y_clamped || swing_z_clamped)
  252. {
  253. // Calculate axis of rotation from clamped swing to swing
  254. Vec3 current = (inConstraintToWorld * q_swing).RotateAxisX();
  255. Vec3 desired = (inConstraintToWorld * q_clamped_swing).RotateAxisX();
  256. mWorldSpaceSwingLimitYRotationAxis = desired.Cross(current);
  257. float len = mWorldSpaceSwingLimitYRotationAxis.Length();
  258. if (len != 0.0f)
  259. {
  260. mWorldSpaceSwingLimitYRotationAxis /= len;
  261. mSwingLimitYConstraintPart.CalculateConstraintProperties(inDeltaTime, inBody1, inBody2, mWorldSpaceSwingLimitYRotationAxis);
  262. }
  263. else
  264. mSwingLimitYConstraintPart.Deactivate();
  265. }
  266. else
  267. mSwingLimitYConstraintPart.Deactivate();
  268. mSwingLimitZConstraintPart.Deactivate();
  269. }
  270. else
  271. {
  272. // No swing limits
  273. mSwingLimitYConstraintPart.Deactivate();
  274. mSwingLimitZConstraintPart.Deactivate();
  275. }
  276. if (mRotationFlags & TwistXLocked)
  277. {
  278. // Twist locked, always activate constraint
  279. mWorldSpaceTwistLimitRotationAxis = (inConstraintToWorld * q_swing).RotateAxisX();
  280. mTwistLimitConstraintPart.CalculateConstraintProperties(inDeltaTime, inBody1, inBody2, mWorldSpaceTwistLimitRotationAxis);
  281. }
  282. else if ((mRotationFlags & TwistXFree) == 0)
  283. {
  284. // Twist has limits
  285. if (twist_clamped)
  286. {
  287. mWorldSpaceTwistLimitRotationAxis = (inConstraintToWorld * q_swing).RotateAxisX();
  288. if (Sign(q_twist.GetW()) * q_twist.GetX() < 0.0f)
  289. mWorldSpaceTwistLimitRotationAxis = -mWorldSpaceTwistLimitRotationAxis; // Flip axis if angle is negative because the impulse limit is going to be between [-FLT_MAX, 0]
  290. mTwistLimitConstraintPart.CalculateConstraintProperties(inDeltaTime, inBody1, inBody2, mWorldSpaceTwistLimitRotationAxis);
  291. }
  292. else
  293. mTwistLimitConstraintPart.Deactivate();
  294. }
  295. else
  296. {
  297. // No twist limits
  298. mTwistLimitConstraintPart.Deactivate();
  299. }
  300. }
  301. /// Deactivate this constraint
  302. void Deactivate()
  303. {
  304. mSwingLimitYConstraintPart.Deactivate();
  305. mSwingLimitZConstraintPart.Deactivate();
  306. mTwistLimitConstraintPart.Deactivate();
  307. }
  308. /// Check if constraint is active
  309. inline bool IsActive() const
  310. {
  311. return mSwingLimitYConstraintPart.IsActive() || mSwingLimitZConstraintPart.IsActive() || mTwistLimitConstraintPart.IsActive();
  312. }
  313. /// Must be called from the WarmStartVelocityConstraint call to apply the previous frame's impulses
  314. inline void WarmStart(Body &ioBody1, Body &ioBody2, float inWarmStartImpulseRatio)
  315. {
  316. mSwingLimitYConstraintPart.WarmStart(ioBody1, ioBody2, inWarmStartImpulseRatio);
  317. mSwingLimitZConstraintPart.WarmStart(ioBody1, ioBody2, inWarmStartImpulseRatio);
  318. mTwistLimitConstraintPart.WarmStart(ioBody1, ioBody2, inWarmStartImpulseRatio);
  319. }
  320. /// Iteratively update the velocity constraint. Makes sure d/dt C(...) = 0, where C is the constraint equation.
  321. inline bool SolveVelocityConstraint(Body &ioBody1, Body &ioBody2)
  322. {
  323. bool impulse = false;
  324. // Solve swing constraint
  325. if (mSwingLimitYConstraintPart.IsActive())
  326. impulse |= mSwingLimitYConstraintPart.SolveVelocityConstraint(ioBody1, ioBody2, mWorldSpaceSwingLimitYRotationAxis, -FLT_MAX, (mRotationFlags & SwingYLocked)? FLT_MAX : 0.0f);
  327. if (mSwingLimitZConstraintPart.IsActive())
  328. impulse |= mSwingLimitZConstraintPart.SolveVelocityConstraint(ioBody1, ioBody2, mWorldSpaceSwingLimitZRotationAxis, -FLT_MAX, (mRotationFlags & SwingZLocked)? FLT_MAX : 0.0f);
  329. // Solve twist constraint
  330. if (mTwistLimitConstraintPart.IsActive())
  331. impulse |= mTwistLimitConstraintPart.SolveVelocityConstraint(ioBody1, ioBody2, mWorldSpaceTwistLimitRotationAxis, -FLT_MAX, (mRotationFlags & TwistXLocked)? FLT_MAX : 0.0f);
  332. return impulse;
  333. }
  334. /// Iteratively update the position constraint. Makes sure C(...) = 0.
  335. /// @param ioBody1 The first body that this constraint is attached to
  336. /// @param ioBody2 The second body that this constraint is attached to
  337. /// @param inConstraintRotation The current rotation of the constraint in constraint space
  338. /// @param inConstraintToBody1 , inConstraintToBody2 Rotates from constraint space to body 1/2 space
  339. /// @param inBaumgarte Baumgarte constant (fraction of the error to correct)
  340. inline bool SolvePositionConstraint(Body &ioBody1, Body &ioBody2, QuatArg inConstraintRotation, QuatArg inConstraintToBody1, QuatArg inConstraintToBody2, float inBaumgarte) const
  341. {
  342. Quat q_swing, q_twist;
  343. inConstraintRotation.GetSwingTwist(q_swing, q_twist);
  344. bool swing_y_clamped, swing_z_clamped, twist_clamped;
  345. ClampSwingTwist(q_swing, swing_y_clamped, swing_z_clamped, q_twist, twist_clamped);
  346. // Solve rotation violations
  347. if (swing_y_clamped || swing_z_clamped || twist_clamped)
  348. {
  349. RotationEulerConstraintPart part;
  350. Quat inv_initial_orientation = inConstraintToBody2 * (inConstraintToBody1 * q_swing * q_twist).Conjugated();
  351. part.CalculateConstraintProperties(ioBody1, Mat44::sRotation(ioBody1.GetRotation()), ioBody2, Mat44::sRotation(ioBody2.GetRotation()));
  352. return part.SolvePositionConstraint(ioBody1, ioBody2, inv_initial_orientation, inBaumgarte);
  353. }
  354. return false;
  355. }
  356. /// Return lagrange multiplier for swing
  357. inline float GetTotalSwingYLambda() const
  358. {
  359. return mSwingLimitYConstraintPart.GetTotalLambda();
  360. }
  361. inline float GetTotalSwingZLambda() const
  362. {
  363. return mSwingLimitZConstraintPart.GetTotalLambda();
  364. }
  365. /// Return lagrange multiplier for twist
  366. inline float GetTotalTwistLambda() const
  367. {
  368. return mTwistLimitConstraintPart.GetTotalLambda();
  369. }
  370. /// Save state of this constraint part
  371. void SaveState(StateRecorder &inStream) const
  372. {
  373. mSwingLimitYConstraintPart.SaveState(inStream);
  374. mSwingLimitZConstraintPart.SaveState(inStream);
  375. mTwistLimitConstraintPart.SaveState(inStream);
  376. }
  377. /// Restore state of this constraint part
  378. void RestoreState(StateRecorder &inStream)
  379. {
  380. mSwingLimitYConstraintPart.RestoreState(inStream);
  381. mSwingLimitZConstraintPart.RestoreState(inStream);
  382. mTwistLimitConstraintPart.RestoreState(inStream);
  383. }
  384. private:
  385. // CONFIGURATION PROPERTIES FOLLOW
  386. enum ERotationFlags
  387. {
  388. /// Indicates that axis is completely locked (cannot rotate around this axis)
  389. TwistXLocked = 1 << 0,
  390. SwingYLocked = 1 << 1,
  391. SwingZLocked = 1 << 2,
  392. /// Indicates that axis is completely free (can rotate around without limits)
  393. TwistXFree = 1 << 3,
  394. SwingYFree = 1 << 4,
  395. SwingZFree = 1 << 5,
  396. SwingYZFree = SwingYFree | SwingZFree
  397. };
  398. uint8 mRotationFlags;
  399. // Constants
  400. float mSinTwistHalfMinAngle;
  401. float mSinTwistHalfMaxAngle;
  402. float mCosTwistHalfMinAngle;
  403. float mCosTwistHalfMaxAngle;
  404. float mSinSwingYQuarterAngle;
  405. float mSinSwingZQuarterAngle;
  406. // RUN TIME PROPERTIES FOLLOW
  407. /// Rotation axis for the angle constraint parts
  408. Vec3 mWorldSpaceSwingLimitYRotationAxis;
  409. Vec3 mWorldSpaceSwingLimitZRotationAxis;
  410. Vec3 mWorldSpaceTwistLimitRotationAxis;
  411. /// The constraint parts
  412. AngleConstraintPart mSwingLimitYConstraintPart;
  413. AngleConstraintPart mSwingLimitZConstraintPart;
  414. AngleConstraintPart mTwistLimitConstraintPart;
  415. };
  416. JPH_NAMESPACE_END