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.
Mental model
Section titled “Mental model”One solver, three writers
Section titled “One solver, three writers”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.
Flat arrays, one pass
Section titled “Flat arrays, one pass”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:
- Integrate:
Velocity = (Current - Previous) * Damping, thenCurrent += Velocity + (Gravity * GravityScale + Wind * WindScale) * dt^2. - Stiffness: lerp toward the animated pose by
Stiffness. This is pose-hold strength, not springiness. - Constrain: an inextensible link to the parent restores
RestLengthexactly. 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.
Fixed timestep with a hitch clamp
Section titled “Fixed timestep with a hitch clamp”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.
Writing through Transform
Section titled “Writing through Transform”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.
Culling, budget and sleep
Section titled “Culling, budget and sleep”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
MaxDistanceof the camera. AShouldSimulatefunction replaces the distance check entirely. - Past the
MaxChainsbudget, 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.
Teleport guard
Section titled “Teleport guard”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.
Lifecycle
Section titled “Lifecycle”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.
Bind the local character on every spawn
Section titled “Bind the local character on every spawn”--!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) endend
function SwayController.stop(self: any) if self.Scope then self.Scope:destroy() end self.Bones:destroy()end
return SwayControllerFor 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.
Choose which accessories swing
Section titled “Choose which accessories swing”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 rigidBones:bindCharacter(character, nil, { MaxSwingDegrees = 20 }, function(accessory: Accessory) return accessory.AccessoryType == Enum.AccessoryType.Hairend)Layered clothing (a Handle with a WrapLayer) never swings, whatever the predicate returns.
Single meshes and part chains
Section titled “Single meshes and part chains”-- A skinned cape: heavier than hairBones: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.
Boneless accessories
Section titled “Boneless accessories”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.
The pendulum model
Section titled “The pendulum model”- The worn pose comes from the joint’s current world frame and the original handle-side offset, so the pendulum follows animation.
- 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. - 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.
- The swing angle from rest is clamped to
MaxSwingDegrees. - 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.
Manual stepping
Section titled “Manual stepping”local Sim = BonePhysics.attach({ SkipLoop = true })Sim:bind(HairPart)
-- In a job that already runs every frame, for example a PreRender phase scheduler jobSim:step(dt)SkipLoop = true never connects Heartbeat. The specs drive the solver this way.
Configuration
Section titled “Configuration”Options for BonePhysics.attach(options)
Section titled “Options for BonePhysics.attach(options)”| 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. |
AccessorySettings for bindAccessory
Section titled “AccessorySettings for bindAccessory”| 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. |
DeviceBench budgets
Section titled “DeviceBench budgets”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.
API reference
Section titled “API reference”| 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. |
Reading Stats
Section titled “Reading Stats”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.