Skip to content

HairKit

Gives boneless hair a generated skeleton over EditableMesh, bakes it once to a hardware-skinned mesh and hands the bones to BonePhysics, with pendulum sway as the fallback.

Realm
Shared
Require
ReplicatedStorage.ChloeKernel.HairKit
Since
v0.6.0
Source
HairKit.luau

HairKit gives boneless catalog hair a real skeleton at runtime: EditableMesh in, hardware-skinned mesh out. The design is write once, read many. The mesh is edited exactly once (bones added, vertices weighted), baked to a static skinned part and never touched again. After the bake there is no per-frame EditableMesh work and no CPU skinning: the GPU deforms the mesh and BonePhysics swings the bones. Hair that cannot be rigged still sways, through the BonePhysics bindAccessory pendulum. Dissolve and MaterialKit sit behind the same EditableMesh permission gates.

The pipeline has three stages, each with one job.

plan() is pure geometry. It takes a vertex cloud and a scalp anchor (Root) and splits the vertices two ways along the growth Axis (default straight down):

  • Sectors are radial groups around the axis, 3 by default. Each sector becomes one bone chain.
  • Segments are depth bands along the axis, 3 by default. Each band becomes one bone in its sector’s chain.

The skeleton is bone 1 (HairRoot, at Root, no parent) plus Sectors x Segments chain bones named Hair_S{sector}_B{band}, each at the centroid of its sector band so bones sit on the strands. Chains hang off the root: Hair_S1_B1 parents to HairRoot, Hair_S1_B2 to Hair_S1_B1, and so on. An empty band still gets a bone, interpolated along the sector axis, so every chain has the same length.

Vertex weights blend the two nearest chain bones by fractional band depth. The primary bone is the vertex’s own band bone and the secondary is the neighbouring band bone the vertex leans toward, with a blend of |fraction - 0.5| * 0.8 (0 at the band center, 0.4 at its edges), normalized to sum to 1. Vertices at or above the root plane weight fully to HairRoot and never pull band centroids. Each vertex carries at most MaxInfluences bones (default 2, clamped to 1-4). At MaxInfluences = 1 every bind is one bone at full weight.

The defaults give a 10-bone skeleton (root plus 3x3): small enough for the BonePhysics chain budget, dense enough that hair bends instead of hinging.

rig(meshPart) applies a plan to the part’s mesh. It first checks that the part has a mesh through ChloeKernel.MeshSource, the check MaterialKit and Dissolve share: a MeshPart whose MeshContent source type is None (or, where MeshContent cannot be read, whose MeshId is empty) returns (false, "the MeshPart has no mesh") before any EditableMesh call, so it never triggers the probe. It then returns (false, "Unsupported") when supported() is false. Every engine call after that is guarded:

  1. Load. AssetService:CreateEditableMeshAsync(MeshSource.content(meshPart)) opens the mesh. A nil result (the device’s editable memory budget is spent) fails this rig with a reason. The default Root is the top center of the vertex bounds, where a scalp is.
  2. Skeleton. One AddBone per plan bone, with Name, CFrame, Virtual = false (the field is required) and ParentId linking chain bones to their parent.
  3. Skinning. SetVertexBones and SetVertexBoneWeights per vertex, straight from the plan.
  4. Register. AssetService:CreateDataModelContentAsync(Content.fromObject(editable)) registers the editable into the DataModel and returns a result and a content. Anything but Enum.CreateContentResult.Success (for example StorageLimitExceeded) fails the rig and logs one warning per result value.
  5. Bake. AssetService:CreateMeshPartAsync bakes the returned content, which is the one that replicates, into a static hardware-skinned MeshPart. A bake whose HasSkinnedMesh is false is destroyed and fails the rig.
  6. Swap in place. meshPart:ApplyMesh(baked) replaces the existing part’s mesh and the original Size is restored. The part keeps its welds, position and appearance; nothing is reparented.
  7. Keep the editable alive. The baked content reads the live editable, and destroying the editable would empty the part. HairKit holds it in a module table until the part’s Destroying fires. It is never edited again.
  8. Bone instances. The bake emits mesh bones only, and engine skinning links them to Bone instances by name. HairKit builds the matching instance skeleton under the MeshPart: the root’s rest CFrame is its bind position offset from the mesh bounds center, chain bones are offset from their parent, and everything is scaled by Size / MeshSize so the skeleton matches the worn scale.

rig() returns (true, boneNames), or (false, reason) on any failure without changing the part. A failed rig destroys its editable so it does not hold the device’s editable memory budget until garbage collection.

The bones are ordinary Bone instances in a parent chain under the MeshPart, which is what BonePhysics bind and bindCharacter look for. The shared fixed-timestep stepper, camera culling and the DeviceBench MaxChains budget apply unchanged.

HairKit.attach(kernel, options) rigs every watched character’s hair and binds the result into a BonePhysics sim. Player.CharacterAppearanceLoaded fires only on the server, so the client watches CharacterAdded and every accessory that arrives in the character afterwards. Each new accessory re-arms one kernel Scheduler deadline per character, and the pass runs 0.25 s after the last one. Characters already present at attach, and players who join later, are watched the same way. PlayerRemoving drops a player’s connections and pending deadline.

The pass (on its own thread, since rig() yields on three Async calls) goes through the character’s accessories:

  • Accessories are filtered by Match. The default matches AccessoryType.Hair or a name containing “hair”.
  • A matched Handle that is a MeshPart with no bones is rigged. Handles that already carry bones, or are not MeshParts, are left for BonePhysics to handle directly.
  • A Handle another pass is already rigging is skipped, so two watchers never rig the same hair. The pass that finishes last does the bind.
  • If any accessory matched and a sim is attached, the character is bound once with sim:bindCharacter(character, Settings). That bind covers rigged hair (its new bones) and hair that failed to rig (the pendulum) alike.

Each character is bound once. Its entry clears when the character is destroyed or leaves the DataModel.

src/Client/Game/Controllers/HairController.luau
--!strict
-- HairController: rigs every character's hair once and sways it through BonePhysics.
local ReplicatedStorage = game:GetService("ReplicatedStorage")
local BonePhysics = require(ReplicatedStorage.ChloeKernel.BonePhysics)
local HairKit = require(ReplicatedStorage.ChloeKernel.HairKit)
local HairController = {
Name = "HairController",
}
function HairController.init(self: any, kernel: any)
self.Kernel = kernel
self.Bones = BonePhysics.attach({ AmbientWind = Vector3.new(0, 0, 5) })
end
function HairController.start(self: any)
self.Hair = HairKit.attach(self.Kernel, {
BonePhysics = self.Bones,
Rig = { Sectors = 3, Segments = 4 }, -- one extra band for smoother bends
Settings = { Damping = 0.92, Stiffness = 0.1 },
})
self.Rigged = self.Kernel.Bus:subscribe("Hair.Rigged", function(_topic, character, meshPart, boneNames)
print(`rigged {meshPart.Name} on {character.Name}: {#boneNames} bones`)
end)
end
function HairController.stop(self: any)
if self.Hair then
self.Hair:destroy()
end
if self.Rigged then
self.Rigged:disconnect()
end
self.Bones:destroy()
end
return HairController
local ReplicatedStorage = game:GetService("ReplicatedStorage")
local HairKit = require(ReplicatedStorage.ChloeKernel.HairKit)
-- In a service's start
HairKit.server(self.Kernel)

server() rigs on the server per session, on CharacterAppearanceLoaded (which fires there) plus any character already present, so the boned mesh replicates to every client once. Sway stays on the clients, which bind rigged characters into their own sim with bindCharacter (see BonePhysics).

HairKit.attach (client) HairKit.server
Who rigs Each client, for characters it sees The server, once per character
Permission needed Client editable-mesh access to the hair assets Server editable-mesh access to the hair assets
Replication None; the rig is local The baked mesh and bones replicate once
Per-frame network traffic None None
Failure (false, reason) per accessory; pendulum bind through the sim The accessory stays untouched; no bind on the server
Hair.Rigged Published Not published
local HairKit = require(game:GetService("ReplicatedStorage").ChloeKernel.HairKit)
if HairKit.supported() then
local Ok, Result = HairKit.rig(HairMeshPart, {
Sectors = 4,
Segments = 3,
Root = Vector3.new(0, 0.9, 0), -- object-space scalp anchor
})
if Ok then
print("bones:", table.concat(Result, ", "))
else
warn("rig failed:", Result)
end
end

plan() is pure and touches no engine API, so it works in tooling, tests and custom skinning:

-- Two strands hanging from y = 10: one at x = 2, one at x = -2, six vertices each
-- down to y = 4, plus one scalp vertex at the root
local Vertices = { Vector3.new(0, 10, 0) }
for Step = 1, 6 do
table.insert(Vertices, Vector3.new(2, 10 - Step, 0))
table.insert(Vertices, Vector3.new(-2, 10 - Step, 0))
end
local Plan = HairKit.plan(Vertices, {
Root = Vector3.new(0, 10, 0),
Sectors = 2,
Segments = 3,
})
-- Plan.Bones: 7 bones, HairRoot plus 2 sectors x 3 segments:
-- [1] HairRoot Parent 0
-- [2] Hair_S1_B1 Parent 1 [5] Hair_S2_B1 Parent 1
-- [3] Hair_S1_B2 Parent 2 [6] Hair_S2_B2 Parent 5
-- [4] Hair_S1_B3 Parent 3 [7] Hair_S2_B3 Parent 6
-- Plan.Weights[vertexIndex]: { { Bone = index, Weight = 0-1 } }, summing to 1

The spec pins this cloud: each chain bone lands within 0.5 studs of its strand, the scalp vertex binds { Bone = 1, Weight = 1 }, and the deepest vertices weight mostly to a _B3 bone.

PlanOptions for HairKit.plan(vertices, options)

Section titled “PlanOptions for HairKit.plan(vertices, options)”
Option Type Default Meaning
Root Vector3 required Scalp anchor, in the same space as the vertices.
Axis Vector3 (0, -1, 0) Growth direction.
Sectors number 3 Radial chains.
Segments number 3 Bones per chain.
MaxInfluences number 2, clamped to 1-4 Bones per vertex.

RigOptions for HairKit.rig(meshPart, options)

Section titled “RigOptions for HairKit.rig(meshPart, options)”
Option Type Default Meaning
Sectors, Segments, MaxInfluences, Axis as plan() Passed to plan().
Root Vector3 Top center of the mesh bounds Object-space scalp anchor.

AttachOptions for HairKit.attach(kernel, options)

Section titled “AttachOptions for HairKit.attach(kernel, options)”
Option Type Default Meaning
BonePhysics BonePhysics sim nil Characters with matched hair bind into this sim once. Without it nothing is bound.
Characters "All" | "Local" "All" Which players’ characters to watch.
Rig RigOptions nil Passed to every rig() call.
Match (accessory: Accessory) -> boolean Hair type or “hair” in the name Accessory filter.
Settings ChainSettings nil BonePhysics settings for the character bind.
OnRigged (character, meshPart, boneNames) -> () nil Called on its own thread per successful rig.

ServerOptions for HairKit.server(kernel, options)

Section titled “ServerOptions for HairKit.server(kernel, options)”
Option Type Default Meaning
Rig RigOptions nil Passed to every rig() call.
Match (accessory: Accessory) -> boolean Hair type or “hair” in the name Accessory filter.
Member Description
HairKit.plan(vertices, options) -> Plan Pure skeleton plan. Plan.Bones is a list of { Name, Position, Parent } (parent 0 is the root); Plan.Weights[vertexIndex] is a list sorted by weight, summing to 1.
HairKit.supported() -> boolean Probes the EditableMesh bone API with a real AddBone call. Caches true or false; a spent memory budget returns false uncached.
HairKit.rig(meshPart, options?) -> (boolean, any) Rigs in place: plan, skin, register, bake, ApplyMesh, Bone instances. Returns (true, boneNames) or (false, reason). A MeshPart with no mesh returns (false, "the MeshPart has no mesh") before probing.
HairKit.attach(kernel, options?) -> Manager Client bulk manager. manager:destroy() disconnects every watcher and cancels pending passes.
HairKit.server(kernel, options?) Rigs on the server per session. Returns the kernel:onSession connection.
Topic Payload Fired
Hair.Rigged character: Model, meshPart: MeshPart, boneNames: { string } The client manager rigged an accessory. HairKit.server does not publish it.

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