Skip to content

BonePhysics

Verlet-chain secondary motion for hair, capes, tails and boneless accessories, on a fixed timestep with camera culling and a DeviceBench chain budget.

Realm
Client
Require
ReplicatedStorage.ChloeKernel.BonePhysics
Since
v0.3.0
Source
BonePhysics.luau

BonePhysics adds secondary motion to hair, capes and tails with verlet chains. It runs a fixed-timestep solver and writes bone poses through Bone.Transform, the slot the animation system uses, never WorldCFrame. The fixed step means a 30 fps phone and a 240 fps desktop settle into the same sag. Writing Transform means the skeleton goes back exactly as it was found when a handle is destroyed. HairKit gives boneless hair a skeleton so it can sway per vertex here, and DeviceBench sets the chain budget on each device.

Bones are visual only, so a server-side simulation burns CPU for nothing anyone sees. The module lives in the shared folder, but attach it on the client.

One simulation (the object attach returns) steps every bound handle from one RunService.Heartbeat connection. The connection exists only while at least one handle is bound: the first bind connects it and the last handle’s destroy disconnects it and clears the accumulator. Three binding paths share the solver:

Path Target Writes through
bind(root) Every Bone hierarchy under a BasePart Bone.Transform
bindParts(parts) A chain of plain parts (stud-style tails) BasePart.CFrame
bindAccessory(accessory) A boneless accessory, as a swing-clamped pendulum The joint that holds the accessory on

bindCharacter is the bulk scan. It binds every descendant part that carries bones, then binds each remaining accessory that should swing as a pendulum.

Binding a root or accessory that is already bound returns its existing handle. Before 0.9.0 a second bindCharacter built a duplicate solver on the same bones, and a second accessory bind captured the already-swung offset as its rest pose, so the accessory drifted further with each call. bindParts chains are not deduplicated.

Each bound root gets one handle with flat parallel arrays (Position, PrevPosition, RestCFrame, RestLength, AnimatedWorld, EffectiveWorld) and no per-bone objects. Nodes are ordered parents before children, so one in-order pass per substep does all the work:

  1. Integrate: Velocity = (Current - Previous) * Damping, then Current += Velocity + (Gravity * GravityScale + Wind * WindScale) * dt^2.
  2. Stiffness: lerp toward the animated pose by Stiffness. This is pose-hold strength, not springiness.
  3. Constrain: an inextensible link to the parent restores RestLength exactly. Parents solve first, so the correction propagates down the chain in the same pass.

Chain roots are pinned: they copy the animated pose every step. The rig owns chain roots and physics swings only the children. A root bone pins to TransformedWorldCFrame, the pose the engine draws including the track’s Transform, since WorldCFrame is the rest pose and children would otherwise swing against a pose the player never sees. A new bone chain starts where it is drawn, not at its rest pose.

step(dt) feeds an accumulator that drains in 1 / FixedHz slices (default 60 Hz). The accumulator is capped at MaxSubsteps slices (default 3), so a long hitch simulates at most 3 substeps instead of spiraling. Poses are written once per frame after the substeps.

A WorldCFrame write would permanently change a bone’s rest CFrame. The solver computes each node’s world pose (the animated rotation, swung by the shortest arc from the animated segment direction to the simulated one) and writes Transform = RestWorld:Inverse() * WorldCFrame, composed against the parent’s already-written world pose. Parents-first order keeps every child exact. destroy() sets every bone’s Transform back to CFrame.identity.

Eligibility is computed once per frame, into one reused table, and reused across that frame’s substeps:

  • A chain simulates when its root is within MaxDistance of the camera. A ShouldSimulate function replaces the distance check entirely.
  • Past the MaxChains budget, eligible chains are sorted by camera distance (the key is computed once per chain per frame) and the nearest win. The rest sleep.
  • A sleeping chain freezes at its last pose with no integration and no writes. When it wakes it snaps to the animated pose instead of integrating from stale positions, so a chain that slept while its root crossed the map does not whip.

Accessory pendulums sit outside the MaxChains budget. They are one bob point each and only distance-cull.

If a root moves more than 50 studs in one step, the chain snaps to the animated pose instead of integrating the jump. The distance is fixed. Respawns and teleports do not fling chains.

Every connection lives on its handle. A handle destroys itself when its root (or accessory) fires Destroying. Handles from bindCharacter also destroy themselves when the character leaves the DataModel without Destroy(), which happens in places that keep old characters with PlayerCharacterDestroyBehavior. sim:destroy() destroys every handle and disconnects the loop.

src/Client/Game/Controllers/SwayController.luau
--!strict
-- SwayController: hair, cape and accessory sway on the local character.
local Players = game:GetService("Players")
local ReplicatedStorage = game:GetService("ReplicatedStorage")
local BonePhysics = require(ReplicatedStorage.ChloeKernel.BonePhysics)
local SwayController = {
Name = "SwayController",
}
function SwayController.init(self: any, kernel: any)
self.Kernel = kernel
self.Bones = BonePhysics.attach({
AmbientWind = Vector3.new(0, 0, 6), -- direction * strength, gusting between about 0.1x and 1x
})
end
function SwayController.start(self: any)
self.Scope = self.Kernel:createScope()
local function onCharacter(character: Model)
-- Idempotent: a second call returns the existing handles
self.Bones:bindCharacter(character, { Damping = 0.92, Stiffness = 0.1 })
end
local Player = Players.LocalPlayer
self.Scope:add(Player.CharacterAdded:Connect(onCharacter))
if Player.Character then
onCharacter(Player.Character)
end
end
function SwayController.stop(self: any)
if self.Scope then
self.Scope:destroy()
end
self.Bones:destroy()
end
return SwayController

For authored wind, pass Wind = function() return WindVector end instead of AmbientWind. It is sampled once per step() call and reused across that frame’s substeps.

By default bindCharacter swings Hair, Hat, Neck, Shoulder, Front, Back, Waist and Unknown accessories (Unknown covers accessories not equipped by spawning or ApplyDescriptionAsync). Face items and layered-only types (shirts, shoes, eyebrows) sit on the body. A fourth argument replaces that choice:

-- Only hair sways; hats stay rigid
Bones:bindCharacter(character, nil, { MaxSwingDegrees = 20 }, function(accessory: Accessory)
return accessory.AccessoryType == Enum.AccessoryType.Hair
end)

Layered clothing (a Handle with a WrapLayer) never swings, whatever the predicate returns.

-- A skinned cape: heavier than hair
Bones:bind(CapeMeshPart, { GravityScale = 1.4, Stiffness = 0.06 })
-- A stud-style tail from plain parts. Parts[1] is the pin: weld it to the character.
-- The rest should be anchored and non-colliding, because the solver writes their CFrames.
Bones:bindParts({ TailRoot, Segment1, Segment2, Segment3 }, {
Stiffness = 0.05,
Damping = 0.85,
})

For things that hang (tails, chains, lanterns), bind with Stiffness = 0 and let gravity own them. At Stiffness = 1 the chain pins to the animated pose and only follows root motion.

local Bound = Bones:bindAccessory(HairAccessory, {
Stiffness = 0.08,
MaxSwingDegrees = 25,
})

Most catalog hair has no bones. bindAccessory (which bindCharacter runs for accessories whose Handle has no bones) models the accessory as a pendulum bob hinged at its attachment joint, clamps the swing to MaxSwingDegrees and steers whichever joint holds it on:

Joint kind How it is steered On destroy
Weld / Motor6D The handle-side C0 or C1 offset rotates Both offsets restored exactly
RigidConstraint The handle-side Attachment.CFrame rotates; nothing is swapped or disabled Attachment CFrame restored exactly
WeldConstraint Swapped for an equivalent local Weld named CKSwayWeld, since a WeldConstraint has no offset to rotate The weld is destroyed and the constraint enabled again

An accessory with no Handle or no steerable joint returns nil. It is refused, never half-bound. If a joint write throws (the joint was destroyed elsewhere), the handle destroys itself.

  1. The worn pose comes from the joint’s current world frame and the original handle-side offset, so the pendulum follows animation.
  2. The rest lever runs from the hinge to the worn handle center. A handle centered on its joint (lever under 0.1 studs) gets a synthetic lever of 0.75 studs down its own UpVector, so there is always something to swing.
  3. The bob integrates with the same verlet step as chain nodes (damping, gravity, wind, stiffness toward the rest bob), then returns to the lever length.
  4. The swing angle from rest is clamped to MaxSwingDegrees.
  5. The whole worn pose rotates about the hinge by the clamped swing, and the handle-side offset is written so the joint itself holds the swung pose. Nothing fights the rig, and restoring the offset puts the accessory back exactly.

GravityScale defaults to 0.4 here instead of 1: a welded accessory should sway near its worn pose, not hang off it.

local Sim = BonePhysics.attach({ SkipLoop = true })
Sim:bind(HairPart)
-- In a job that already runs every frame, for example a PreRender phase scheduler job
Sim:step(dt)

SkipLoop = true never connects Heartbeat. The specs drive the solver this way.

Option Type Default Meaning
Gravity Vector3 (0, -30, 0) World gravity applied to every chain.
Wind () -> Vector3 nil Wind sampler, called once per step().
AmbientWind Vector3 nil Gusting breeze used when Wind is nil. Direction times strength; gusts between about 0.1x and 1x.
FixedHz number 60 Solver rate.
MaxSubsteps number 3 Most substeps per frame after a hitch.
MaxDistance number Client: profile BoneDistance. Server: 100. Camera cull distance in studs.
MaxChains number Client: profile BoneChains. Server: 20. Simultaneous chain budget. Nearest chains win.
ShouldSimulate (root: BasePart) -> boolean nil Replaces the distance check.
SkipLoop boolean false Never connect Heartbeat; call step() yourself.

ChainSettings for bind, bindParts and bindCharacter

Section titled “ChainSettings for bind, bindParts and bindCharacter”
Setting Default Meaning
Damping 0.92 Velocity kept per step. Lower settles floppier.
Stiffness 0.1 0-1 pull toward the animated pose per step. 0 lets gravity own it; 1 pins it to the pose.
GravityScale 1 Multiplier on the simulation’s gravity.
WindScale 1 Multiplier on the simulation’s wind.
Setting Default Meaning
Damping 0.9 Velocity kept per step.
Stiffness 0.08 Pull back toward the worn pose.
GravityScale 0.4 Scaled down so welded accessories sway near the worn pose.
WindScale 1 Multiplier on the simulation’s wind.
MaxSwingDegrees 25 Rotation clamp that prevents full flips.

On a client, unset MaxDistance and MaxChains come from the DeviceBench profile. The bench runs once, and both values scale with its Compute axis (0.25 to 4):

Option Profile field Mid-range base Scaling
MaxChains BoneChains 10 max(round(10 * Compute), 1): 3 on the weakest devices to 40 on the strongest
MaxDistance BoneDistance 80 studs round(80 * Compute): 20 to 320 studs

Off the client the static defaults apply: MaxChains = 20, MaxDistance = 100.

Member Description
BonePhysics.attach(options?) -> BonePhysics Creates a simulation. The Heartbeat connection starts with the first bound handle unless SkipLoop.
sim:bind(root, settings?) -> Handle? Simulates every Bone hierarchy under root. nil when the part has no bones. Returns the existing handle when root is already bound.
sim:bindParts(parts, settings?) -> Handle A chain of parts. parts[1] is the pin; asserts at least a pin and one link.
sim:bindAccessory(accessory, settings?) -> Handle? Pendulum sway for a boneless accessory. nil without a Handle or a steerable joint. Returns the existing handle when already bound.
sim:bindCharacter(character, settings?, accessorySettings?, match?) -> { Handle } Binds every boned part plus every accessory match accepts (default: the swing types listed above). Skips layered clothing. Handles release when the character leaves the DataModel.
sim:step(dt) Advances the accumulator and runs due substeps. Called for you unless SkipLoop.
sim:destroy() Destroys every handle and disconnects the loop.
sim.Stats { Bound, Simulated }: live handles, and handle-steps simulated in the last frame.
handle:destroy() Unbinds. Resets bone Transforms to CFrame.identity or restores the accessory’s joint. Disconnects the loop when it was the last handle.

Stats.Bound counts live handles of every kind. Stats.Simulated counts handle-steps in the last frame that ran a substep: one chain stepping 3 substeps after a hitch counts 3, and accessory pendulums count too. A steady value near MaxChains * (FixedHz / frame rate) means the budget is full and distant chains are sleeping.

ChloeKernel v0.9.1. Access to the framework is private and granted individually by Chloe. Games built with it credit CryptedChloes in their description.