Skip to content

HairKit

HairKit gives boneless catalog hair a real skeleton at runtime — EditableMesh in, hardware-skinned mesh out. The defining decision is a write-once, read-many bake: the mesh is edited exactly once (bones injected, vertices weighted), baked to a static hardware-skinned part, and never mutated again. After the bake there is no per-frame EditableMesh work, no CPU skinning — the GPU deforms the mesh natively and BonePhysics swings the bones.

-- CLIENT: rig every character's hair once, sway rides BonePhysics
local ReplicatedStorage = game:GetService("ReplicatedStorage")
local Kernel = require(ReplicatedStorage.ChloeKernel).boot()
local BonePhysics = require(ReplicatedStorage.ChloeKernel.BonePhysics)
local HairKit = require(ReplicatedStorage.ChloeKernel.HairKit)
local Bones = BonePhysics.attach()
HairKit.attach(Kernel, { BonePhysics = Bones })

The pipeline is three stages, each with one job:

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

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

The output skeleton is bone 1 (HairRoot, at Root, parentless) plus Sectors x Segments chain bones named Hair_S{sector}_B{band}, each positioned at the centroid of its sector band so bones sit on the actual strands. Chains hang off the root: Hair_S1_B1 parents HairRoot, Hair_S1_B2 parents Hair_S1_B1, and so on. Empty bands still emit a bone (interpolated along the sector axis) so every chain has uniform length.

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

The defaults produce a 10-bone skeleton (root + 3x3) — small enough to sway under the BonePhysics chain budget, dense enough that hair bends instead of hinging.

rig(meshPart) applies a plan to the part’s mesh, end to end, every engine call guarded:

  1. LoadAssetService:CreateEditableMeshAsync(meshPart.MeshContent) opens the mesh for editing. Vertex positions are read; the default Root is the top-center of the vertex bounds (Center.X, Top, Center.Z — where a scalp is).
  2. Skeleton — one AddBone per plan bone. The AddBone dictionary takes Name, CFrame, Virtual (required — HairKit passes false for real deforming bones), and ParentId linking chain bones to their parent’s bone id.
  3. SkinningSetVertexBones(id, bones) and SetVertexBoneWeights(id, weights) per vertex, straight from the plan.
  4. BakeContent.fromObject(editable) wraps the live editable as object-backed content; AssetService:CreateDataModelContentAsync(bakeContent) registers it into the DataModel (the call must return Enum.CreateContentResult.Success or the rig aborts); AssetService:CreateMeshPartAsync(bakeContent) then produces a static, hardware-skinned MeshPart from it.
  5. Swap in placemeshPart:ApplyMesh(baked) replaces the mesh content of the existing part, then the original Size is restored. The part keeps its welds, its position, and its appearance — nothing reparents, nothing replicates as a new instance.
  6. Root the editable — object-backed content reads the live editable, and destroying the editable would empty the part. HairKit holds it in a module-level [meshPart] = editable table until the part’s Destroying fires. The editable is never mutated again.
  7. Bone instances — the bake emits mesh bones only; engine skinning links them to Bone instances by name. HairKit builds the matching instance skeleton under the MeshPart: the root bone’s rest CFrame is its bind position offset from the mesh-bounds center, chain bones are offset from their parent’s bind position, all scaled by Size / MeshSize so the skeleton matches the worn scale.

rig() returns (true, boneNames) on success or (false, reason) on any failure — unsupported runtime, denied asset, failed registration — without touching the part.

The bones are ordinary Bone instances in a parent chain under the MeshPart, which is exactly what BonePhysics bind/bindCharacter looks for. One fixed-timestep stepper for every bound character, camera-distance culling, and the MaxChains budget from DeviceBench all apply unchanged. No per-accessory scripts.

Note the fallback’s mechanism: HairKit.attach calls sim:bindCharacter after a successful rig, and that scan is what picks up remaining boneless accessories as pendulums. If every accessory on a character fails to rig, HairKit binds nothing — bind the character into your BonePhysics sim yourself (or via your own character pipeline) if pendulum sway must be guaranteed regardless of rig outcomes.

-- CLIENT
local ReplicatedStorage = game:GetService("ReplicatedStorage")
local Kernel = require(ReplicatedStorage.ChloeKernel).boot()
local BonePhysics = require(ReplicatedStorage.ChloeKernel.BonePhysics)
local HairKit = require(ReplicatedStorage.ChloeKernel.HairKit)
local Bones = BonePhysics.attach({ AmbientWind = Vector3.new(0, 0, 5) })
local Manager = HairKit.attach(Kernel, {
BonePhysics = Bones,
Rig = { Sectors = 3, Segments = 4 }, -- one extra band: smoother bends
Settings = { Damping = 0.92, Stiffness = 0.1 },
OnRigged = function(character: Model, meshPart: MeshPart, boneNames: { string })
print(`rigged {meshPart.Name} on {character.Name}: {#boneNames} bones`)
end,
})

attach watches every player’s CharacterAppearanceLoaded (plus already-loaded characters), rigs each matching boneless hair accessory once, and binds the character’s chains into the shared sim. Accessories match when AccessoryType == Enum.AccessoryType.Hair or the name contains “hair” — override with Match. Handles that are not MeshParts, or that already carry bones, are skipped: already-boned hair sways through BonePhysics directly, no rigging needed.

-- SERVER
local ServerKernel = require(game:GetService("ServerScriptService").ChloeKernelServer)
local HairKit = require(game:GetService("ReplicatedStorage").ChloeKernel.HairKit)
HairKit.server(ServerKernel)

server() rigs on the server per session as characters load, so the boned mesh replicates natively to every client once. Sway stays client-local everywhere — clients still bind rigged characters into their own BonePhysics sim:

-- CLIENT (paired with HairKit.server)
local Players = game:GetService("Players")
local ReplicatedStorage = game:GetService("ReplicatedStorage")
local BonePhysics = require(ReplicatedStorage.ChloeKernel.BonePhysics)
local Bones = BonePhysics.attach()
local function bindCharacter(character: Model)
Bones:bindCharacter(character)
end
for _, Player in Players:GetPlayers() do
if Player.Character then
bindCharacter(Player.Character)
end
Player.CharacterAdded:Connect(bindCharacter)
end
Players.PlayerAdded:Connect(function(player: Player)
player.CharacterAdded:Connect(bindCharacter)
end)

Choosing a mode:

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 + bones replicate natively, once
Per-frame network traffic Zero Zero
Failure behavior (false, reason) per accessory; pendulum fallback via the sim Warns and leaves the accessory untouched
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 override
})
if Ok then
print("bones:", table.concat(Result, ", "))
else
warn("rig failed:", Result)
end
end

plan() is pure and engine-free — usable for tooling, tests, or custom skinning:

-- Two strands hanging from y = 10: one at x = +2, one at x = -2, six
-- vertices each descending 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 + 2 sectors x 3 segments, chained:
-- [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 } }, sums to 1

The spec pins this exact cloud: each chain bone lands within 0.5 studs of its strand’s x = ±2 line (the centroid math puts bones on the hair, not on the axis), the scalp vertex binds { Bone = 1, Weight = 1 }, and every y = 4 vertex weights primarily (≥ 0.5) to a _B3 bone.

PlanOptionsHairKit.plan(vertices, options)

Section titled “PlanOptions — HairKit.plan(vertices, options)”
Option Type Default Meaning
Root Vector3 required Scalp anchor, 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 1..4) Bones per vertex

RigOptionsHairKit.rig(meshPart, options)

Section titled “RigOptions — HairKit.rig(meshPart, options)”
Option Type Default Meaning
Sectors number 3 Passed to plan()
Segments number 3 Passed to plan()
MaxInfluences number 2 Passed to plan()
Axis Vector3 (0, -1, 0) Passed to plan()
Root Vector3 top-center of the mesh bounds Object-space scalp anchor

AttachOptionsHairKit.attach(kernel, options)

Section titled “AttachOptions — HairKit.attach(kernel, options)”
Option Type Default Meaning
BonePhysics BonePhysics sim nil Rigged characters bind into this sim after each successful rig
Characters "All" | "Local" "All" Which players’ characters to rig
Rig RigOptions nil Forwarded to every rig() call
Match (accessory: Accessory) -> boolean Hair heuristic Accessory filter. Default: AccessoryType.Hair or a name containing “hair”
Settings ChainSettings nil BonePhysics bind settings for rigged chains
OnRigged (character, meshPart, boneNames) -> () nil Fired (task-spawned) per successful rig

ServerOptionsHairKit.server(kernel, options)

Section titled “ServerOptions — HairKit.server(kernel, options)”
Option Type Default Meaning
Rig RigOptions nil Forwarded to every rig() call
Match (accessory: Accessory) -> boolean Hair heuristic Accessory filter
Member Description
HairKit.plan(vertices: { Vector3 }, options: PlanOptions): Plan Pure sector/band skeleton plan. Plan.Bones is { Name, Position, Parent } (parent 0 = root); Plan.Weights[vertexIndex] is a weight list sorted by weight, summing to 1
HairKit.supported(): boolean Probes the EditableMesh bone API with a real AddBone call. Cached after the first call
HairKit.rig(meshPart: MeshPart, options: RigOptions?): (boolean, any) Rigs in place: plan, skin, bake, ApplyMesh, Bone instances. Returns (true, boneNames) or (false, reason)
HairKit.attach(kernel, options: AttachOptions?) Client bulk manager. Returns a handle with destroy()
HairKit.server(kernel, options: ServerOptions?) Server-side rigging per session; the baked mesh replicates once
Topic Payload Fired when
Hair.Rigged (character: Model, meshPart: MeshPart, boneNames: { string }) The client attach manager successfully rigs an accessory

HairKit.server does not publish Hair.Rigged — the server path has no kernel bus reference. Use OnRigged on the client manager, or listen for the replicated bones, if you need a server-side rig signal.

  • The baked editable is memory you keep. Object-backed content reads the live editable, and Destroying it empties the part — so HairKit roots one editable per rigged part until that part is destroyed. Rigging many unique hair meshes holds that many editables. The same character re-rigging (respawn with the same accessory) creates a fresh part, so the old editable releases with the old part.
  • Virtual is required on AddBone. The dictionary form is { Name, CFrame, Virtual, ParentId }; HairKit passes Virtual = false for deforming bones. Omitting the field is an engine error, not a default.
  • The bake emits mesh bones only. Without the name-linked Bone instance skeleton HairKit builds in step 7, the baked mesh renders but nothing can pose it. If you build your own pipeline over rig()’s internals, the instance names must match the plan’s bone names exactly.
  • Size math matters. ApplyMesh can change MeshSize; HairKit restores the part’s original Size and scales the Bone instance rest CFrames by Size / MeshSize so the skeleton lands on the worn mesh, not the asset-space one.
  • Rigging is per-appearance, not per-spawn. The manager rigs on CharacterAppearanceLoaded — the point where accessories exist. Characters already present at attach time are rigged immediately.
  • Sway budgets still apply. Rigged chains are ordinary BonePhysics chains: camera-distance culling and the DeviceBench-funded MaxChains budget decide which characters’ hair simulates each frame. A crowd of rigged characters degrades to the nearest N swaying, the rest holding pose — by design.