Skip to main content

Shapes, generated bodies, and constraints

Use shape handles for geometry, a GeneratedBodyService for Bethesda/Havok body graphs, and a ConstraintService for live world constraints. The service objects are part of the ownership contract, not temporary factories.

Build primitive and convex shapes

#include <RPS/Runtime/Shape.h>

using namespace RPS::Runtime;
using namespace RPS::Runtime::Physics;

ShapeFactory shapes{module};

auto sphere = shapes.buildSphereGame(4.0f);
if (!sphere) {
return;
}

std::array<Vector4, 4> points{
Vector4{-0.1f, 0.0f, 0.0f, 0.0f},
Vector4{ 0.1f, 0.0f, 0.0f, 0.0f},
Vector4{ 0.0f, 0.1f, 0.0f, 0.0f},
Vector4{ 0.0f, 0.0f, 0.1f, 0.0f},
};

auto convex = shapes.buildConvex(points, 0.01f);

buildSphereGame() accepts game units. Convex points and radius are Havok space. Inputs must be finite and stay within the engine's supported vertex budget.

ShapeHandle owns one caller-side Havok reference and is move-only. Keep it alive through the native create call. A successfully created compound or generated body acquires its own native shape reference, so the caller handle may be reset afterward when the caller has no other use for it.

Static and dynamic compounds

Each child contains a borrowed shape and a complete transform/scale:

CompoundChild child{};
child.shape = sphere.get();
child.transform.transform.translation = {0.0f, 0.0f, 0.1f, 1.0f};

std::array<CompoundChild, 1> children{child};
auto compound = shapes.buildStaticCompound(children);

Use DynamicCompoundShape when child transforms must change after creation:

DynamicCompoundShape dynamicCompound{module};
if (!dynamicCompound.create(children)) {
return;
}

std::array<ChildTransform, 1> transforms{child.transform};

WorldWriteGuard write{module, hknpWorld};
const auto updated = dynamicCompound.updateTransforms(
write,
hknpWorld,
transforms);

Creation preallocates stable instance IDs and update scratch. updateTransforms uses the matching world write epoch and updates only changed children without hot-path allocation.

Create a generated collider

Keep one GeneratedBodyService in the subsystem that owns the scene/world generation:

#include <RPS/Runtime/GeneratedBody.h>

GeneratedBodyService bodies{module};
if (!bodies) {
return;
}

GeneratedBodyCreateInfo info{};
info.hknpWorld = hknpWorld;
info.bhkWorld = bhkWorld;
info.shape = sphere.get();
info.collisionFilterInfo = filterInfo;
info.materialId = materialId;
info.motionType = GeneratedMotionType::Keyframed;
info.name = "MyPlugin_Collider";

GeneratedBodyCreateError error{};
GeneratedBody body = bodies.create(info, error);
if (!body) {
logOnce(toString(error));
return;
}

const BodyId id = body.bodyId();
void* collisionObject = body.collisionObject();
void* ownerNode = body.ownerNode();

The factory builds the native physics-system data, Bethesda physics system, collision object, and owner NiNode, inserts the body, then verifies material, motion, filter, flags, world identity, body ID, and collision back-pointer.

The successful native body graph owns its own reference to the source shape; the caller may release its ShapeHandle afterward. Do not treat the returned collision object or node as a separately owned reference.

Retire generated bodies

Prefer explicit retirement on the service's constructing thread:

if (!bodies.retire(body)) {
// Keep the service alive and diagnose; do not manually free native pieces.
}

Destruction from another thread queues removal. Call serviceOwnerThreadRetirements() on the owner thread. After removal, call serviceCompletedPhysicsSteps() once per real completed post-solve physics step. References are released after eight completed steps.

Use shutdownAfterWorldLoss() only after the world is gone and no physics reader can run. Destroying the service early does not make outstanding body ownership safe.

Create an owned stock constraint

Construct one service for the exact hknp world:

#include <RPS/Runtime/Constraint.h>

ConstraintService constraints{module, hknpWorld};
if (!constraints) {
return;
}

BallAndSocketConstraintInfo info{};
info.bodyA = bodyA;
info.bodyB = bodyB;
info.localPivotA = pivotA;
info.localPivotB = pivotB;

WorldWriteGuard write{module, hknpWorld};
if (!write.active()) {
return;
}

auto created = constraints.createBallAndSocket(write, info);
if (!created) {
return;
}

OwnedConstraint joint = std::move(created.constraint);

Available stock constructors are:

  • ball and socket with local body pivots;
  • limited hinge with body-world transforms, world pivot/axis, and ordered relative-angle limits;
  • prismatic with body-world transforms, world pivot/axis, and ordered Havok-unit distance limits.

Axes must be finite and non-degenerate. Both bodies must be complete in the same guarded world.

Retire constraints

Retire explicitly under the matching write guard:

WorldWriteGuard write{module, hknpWorld};
if (write.active()) {
(void)constraints.retire(write, joint);
(void)constraints.servicePendingRetirements(write);
}

Cross-thread destruction enters a fixed 512-entry pending queue. Service it on the owner thread. On a confirmed world loss, shutdownAfterWorldLoss() clears the service's retained bookkeeping without calling a dead world.

Position motors

PositionMotor::create() owns one native Havok motor reference and validates force, tau, damping, and recovery-velocity tuning:

PositionMotorTuning tuning{};
tuning.minimumForce = -500.0f;
tuning.maximumForce = 500.0f;

auto motor = PositionMotor::create(module, tuning);
if (!motor) {
return;
}

The current stable constraint factories do not attach the motor for you. If a consumer-owned native constraint references it, detach/destroy that constraint before calling resetDetached() or allowing the motor owner to die.

:::danger Never manually dismantle an owned graph

Do not release a generated body's node, collision object, physics system, body, or shape piecemeal. Do not destroy a live constraint ID without its service and write epoch. The wrappers exist because the required ordering and deferred lifetimes cross several native ownership domains.

:::