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.
Mental model
Section titled “Mental model”The pipeline has three stages, each with one job.
1. plan(): the sector and band skeleton
Section titled “1. plan(): the sector and band skeleton”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.
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. 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:
- 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 defaultRootis the top center of the vertex bounds, where a scalp is. - Skeleton. One
AddBoneper plan bone, withName,CFrame,Virtual = false(the field is required) andParentIdlinking chain bones to their parent. - Skinning.
SetVertexBonesandSetVertexBoneWeightsper vertex, straight from the plan. - Register.
AssetService:CreateDataModelContentAsync(Content.fromObject(editable))registers the editable into the DataModel and returns a result and a content. Anything butEnum.CreateContentResult.Success(for exampleStorageLimitExceeded) fails the rig and logs one warning per result value. - Bake.
AssetService:CreateMeshPartAsyncbakes the returned content, which is the one that replicates, into a static hardware-skinned MeshPart. A bake whoseHasSkinnedMeshis false is destroyed and fails the rig. - Swap in place.
meshPart:ApplyMesh(baked)replaces the existing part’s mesh and the originalSizeis restored. The part keeps its welds, position and appearance; nothing is reparented. - 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
Destroyingfires. It is never edited again. - Bone instances. The bake emits mesh bones only, and engine skinning links them to
Boneinstances 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 bySize / MeshSizeso 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.
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 what BonePhysics bind and
bindCharacter look for. The shared fixed-timestep stepper, camera culling and the DeviceBench
MaxChains budget apply unchanged.
The client manager
Section titled “The client manager”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 matchesAccessoryType.Hairor 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.
Permission gates
Section titled “Permission gates”Client bulk manager
Section titled “Client bulk manager”--!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 HairControllerServer-side rigging
Section titled “Server-side rigging”local ReplicatedStorage = game:GetService("ReplicatedStorage")
local HairKit = require(ReplicatedStorage.ChloeKernel.HairKit)
-- In a service's startHairKit.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 |
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 }) 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 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 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 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 1The 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.
Configuration
Section titled “Configuration”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. |
API reference
Section titled “API reference”| 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. |
Bus topics
Section titled “Bus topics”| Topic | Payload | Fired |
|---|---|---|
Hair.Rigged |
character: Model, meshPart: MeshPart, boneNames: { string } |
The client manager rigged an accessory. HairKit.server does not publish it. |