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 BonePhysicslocal 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 })Mental model
Section titled “Mental model”The pipeline is three stages, each with one job:
1. plan() — the sector/band skeleton
Section titled “1. plan() — the sector/band skeleton”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.
2. rig() — skin, bake, swap in place
Section titled “2. rig() — skin, bake, swap in place”rig(meshPart) applies a plan to the part’s mesh, end to end, every engine call guarded:
- Load —
AssetService:CreateEditableMeshAsync(meshPart.MeshContent)opens the mesh for editing. Vertex positions are read; the defaultRootis the top-center of the vertex bounds (Center.X, Top, Center.Z— where a scalp is). - Skeleton — one
AddBoneper plan bone. TheAddBonedictionary takesName,CFrame,Virtual(required — HairKit passesfalsefor real deforming bones), andParentIdlinking chain bones to their parent’s bone id. - Skinning —
SetVertexBones(id, bones)andSetVertexBoneWeights(id, weights)per vertex, straight from the plan. - Bake —
Content.fromObject(editable)wraps the live editable as object-backed content;AssetService:CreateDataModelContentAsync(bakeContent)registers it into the DataModel (the call must returnEnum.CreateContentResult.Successor the rig aborts);AssetService:CreateMeshPartAsync(bakeContent)then produces a static, hardware-skinned MeshPart from it. - Swap in place —
meshPart:ApplyMesh(baked)replaces the mesh content of the existing part, then the originalSizeis restored. The part keeps its welds, its position, and its appearance — nothing reparents, nothing replicates as a new instance. - 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] = editabletable until the part’sDestroyingfires. The editable is never mutated again. - Bone instances — the bake emits mesh bones only; engine skinning links them to
Boneinstances 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 bySize / MeshSizeso 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.
3. Sway — handoff to BonePhysics
Section titled “3. Sway — handoff to BonePhysics”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.
Permission gates
Section titled “Permission gates”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 bulk manager
Section titled “Client bulk manager”-- CLIENTlocal 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-side rigging
Section titled “Server-side rigging”-- SERVERlocal 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)endfor _, Player in Players:GetPlayers() do if Player.Character then bindCharacter(Player.Character) end Player.CharacterAdded:Connect(bindCharacter)endPlayers.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 |
Manual rigging
Section titled “Manual rigging”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) endendPlanning without rigging
Section titled “Planning without rigging”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 rootlocal 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 1The 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.
Configuration
Section titled “Configuration”PlanOptions — HairKit.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 |
RigOptions — HairKit.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 |
AttachOptions — HairKit.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 |
ServerOptions — HairKit.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 |
API reference
Section titled “API reference”| 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 |
Bus topics
Section titled “Bus topics”| 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.
Gotchas
Section titled “Gotchas”- 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. Virtualis required onAddBone. The dictionary form is{ Name, CFrame, Virtual, ParentId }; HairKit passesVirtual = falsefor deforming bones. Omitting the field is an engine error, not a default.- The bake emits mesh bones only. Without the name-linked
Boneinstance skeleton HairKit builds in step 7, the baked mesh renders but nothing can pose it. If you build your own pipeline overrig()’s internals, the instance names must match the plan’s bone names exactly. - Size math matters.
ApplyMeshcan changeMeshSize; HairKit restores the part’s originalSizeand scales the Bone instance rest CFrames bySize / MeshSizeso 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
MaxChainsbudget decide which characters’ hair simulates each frame. A crowd of rigged characters degrades to the nearest N swaying, the rest holding pose — by design.