MaterialKit
MaterialKit erodes the actual rendered mesh. Where Dissolve flies pooled voxel dots — a particle simulation standing in for the target — MaterialKit removes and re-adds triangles on the live EditableMesh object backing the part you see, so the baked MeshPart on screen is the thing being edited. There is no simulated stand-in and no re-bake step between edits: a wall eaten by acid loses its true surface, not a cloud of particles hiding it.
-- CLIENTlocal ReplicatedStorage = game:GetService("ReplicatedStorage")local MaterialKit = require(ReplicatedStorage.ChloeKernel.MaterialKit)
local Kit = MaterialKit.new()local Surface = Kit:bind(AcidDoor)Surface:play({ Duration = 2 }) -- erodes face by face, in noise orderMental model
Section titled “Mental model”bind(meshPart) — open the part’s own mesh, bake once, edit forever
Section titled “bind(meshPart) — open the part’s own mesh, bake once, edit forever”bind(meshPart) reads the part’s existing mesh asset and rewires the part to render a live object instead of a static asset:
AssetService:CreateEditableMeshAsync(Content.fromUri(meshPart.MeshId))opens the mesh for editing. This is the same gated call HairKit’srig()uses, against a URI built from the part’sMeshIdstring (an emptyMeshIdis rejected before any engine call:"the MeshPart has no MeshId").AssetService:CreateMeshPartAsync(Content.fromObject(Mesh))bakes aMeshPart— but the content it bakes from isContent.fromObject(Mesh), object-backed content wrapping the live editable, not a snapshot of its current vertices. The baked part’s mesh content is a reference toMesh, the same instancebind()is holding.meshPart:ApplyMesh(bakedPart)copies that object-backed mesh content onto the original part in place, then restoresSize(ApplyMeshcan change it viaMeshSize, same as HairKit’s bake path).- The
SurfacerootsMeshfor the part’s lifetime — nothing ever callsMesh:Destroy()until the surface itself is torn down.
This is the mechanism behind the “renders immediately, no re-bake” claim, and it is worth being precise about: the bake happens exactly once, at bind() time, purely to attach the live Mesh object as the part’s render source. After that, meshPart’s displayed geometry and Mesh’s live vertex/face state are the same data — Surface:setProgress() calls Mesh:RemoveFace(id) or Mesh:AddTriangle(...) directly on that object, and the next frame’s render simply reflects the object’s current faces, because it was never reading anything else. There is no second CreateMeshPartAsync/ApplyMesh round trip per edit — that round trip only ever happens once, to establish the live link.
This is the opposite call discipline from HairKit, which also bakes once but then treats the result as frozen — write-once, read-many, never mutated again. MaterialKit bakes once to make the link live, then mutates that same object indefinitely — write-many, read-continuously, current by construction rather than by re-baking.
One further contrast worth flagging precisely: HairKit’s bake path registers its object-backed content into the DataModel first (CreateDataModelContentAsync) before CreateMeshPartAsync. MaterialKit’s bind() and bindBox() skip that step entirely — they go straight from Content.fromObject(Mesh) into CreateMeshPartAsync. HairKit needs a durable, DataModel-registered bake because it never touches the mesh again after handing off to BonePhysics; MaterialKit needs the opposite — the live object reference kept alive, not a registered snapshot — which is consistent with rooting Mesh for the part’s lifetime rather than registering and discarding it.
bindBox(part, {Subdivisions}) — the permission-free overlay
Section titled “bindBox(part, {Subdivisions}) — the permission-free overlay”Not every part’s mesh is readable — third-party catalog meshes reject CreateEditableMeshAsync outright. bindBox() sidesteps the gate completely: it calls AssetService:CreateEditableMesh() with no asset URI at all, generating a fresh subdivided box mesh from scratch (six faces, each an NxN vertex grid at Subdivisions cells per edge, two triangles per cell — the default Subdivisions = 4 yields 4×4×2×6 = 192 faces per box). That generated mesh bakes and applies to a brand-new overlay MeshPart, sized and posed to match the target part, parented alongside it.
Each of the six faces is built from a { Normal, U, V } frame (the three axis pairs and their negatives), so every face’s grid is authored directly in the plane its normal points out of, walked by two spanning vectors scaled to the part’s half-size along each axis. Vertices fill a (Subdivisions + 1) x (Subdivisions + 1) grid per face; each cell becomes two triangles (A,B,C and A,C,D), wound consistently by the frame so all six faces end up outward-facing without any post-hoc normal fix-up.
The original part goes fully transparent (Transparency = 1, restored on restore()/destroy()) and hides behind the overlay, but collision stays live on the original — bindBox() never touches CanCollide, CanQuery, or CanTouch on the source part, only on the overlay (which sets all three off, plus Anchored = true, so the overlay itself never interacts with physics or queries). This is why bindBox() exists: it lets MaterialKit erode any part — no read permission needed, no mesh asset needed — at the cost of a boxy silhouette standing in for the real geometry, exactly the same silhouette-for-detail trade Dissolve makes when its vertex sampler falls back to a surface shell.
Erosion order — Perlin rank plus a directional Bias
Section titled “Erosion order — Perlin rank plus a directional Bias”Both bind() and bindBox() catalog every face once at setup time: for each face id, its three vertex ids (the restore record), its centroid and normal (from the vertex positions), and an erosion rank computed by the pure MaterialKit.order function:
Score = math.noise(Centroid.X * NoiseScale, Centroid.Y * NoiseScale + Seed * 17.23, Centroid.Z * NoiseScale)if Bias then Score += Centroid:Dot(Bias)endScores sort ascending (ties broken by face index, so no two faces ever tie), and each face’s rank becomes its sorted midpoint: (position - 0.5) / count — face 1 of 4 ranks at 0.125, never at exactly 0 or 1. Because nearby centroids sample nearby noise, adjacent faces get similar ranks, so an alpha sweep eats coherent patches of surface rather than popping random faces one at a time — the same “erodes in patches, not uniformly” effect Dissolve gets from spatial noise keys on its dots.
Bias adds a plain dot product against each centroid’s raw (mesh-local) position, so it is a linear term riding on top of the noise — a face far along Bias’s direction scores higher and erodes last; a face far in the opposite direction scores lower and erodes first. A wall eaten from the bottom up uses a Bias pointing up. Because the dot product runs against unscaled local coordinates, the same Bias magnitude reads very differently on a small prop than on a wall spanning tens of studs — size Bias’s strength to the mesh, not to a fixed constant reused everywhere.
MaterialKit.order is exposed standalone — pure, engine-free, usable for tests or tooling that want erosion ranks without ever touching a MeshPart.
setProgress(alpha) — erode and knit, one call
Section titled “setProgress(alpha) — erode and knit, one call”setProgress(alpha) is the single primitive both directions run through:
Face.Rank < alphaand the face is present →Mesh:RemoveFace(Face.Id), mark it gone, fireOnFaceGoneif set.Face.Rank >= alphaand the face was removed →Mesh:AddTriangle(Face.Vertices[1], Face.Vertices[2], Face.Vertices[3])against the face’s originally recorded vertex ids, mark it present, and store the new face idAddTrianglereturns (removed-and-readded faces get a fresh id from the engine; the surface always tracks the current one).
Recording each face’s original vertex ids at bind time is what makes knitting possible at all: RemoveFace deletes the triangle, not the vertices — the three vertices stay in the mesh — but nothing about a bare vertex id tells you which triangle it used to belong to. Without the vertex triple captured once in catalogFaces before any removal, there would be no way to know which three vertices to reconnect, or in what order, to reproduce the exact original face. A receding driver — proximity fading, a manual ward controller — calls setProgress with a shrinking alpha and the surface knits itself shut face by face, in the reverse of its erosion order.
play({Duration, Reverse?}) — the shared stepper sweep
Section titled “play({Duration, Reverse?}) — the shared stepper sweep”play(options) is a timed ramp over the same setProgress primitive: it asserts Duration > 0, records StartedAt = Kit.Clock(), and registers itself in the kit’s Playing set. Every MaterialKit.new() kit runs one Heartbeat stepper for every surface bound to it — the same shared-stepper shape as Dissolve, though scoped per kit instance rather than module-wide. Each tick, Kit:step(dt) computes alpha = clamp((now - StartedAt) / Duration, 0, 1), calls setProgress(Reverse and 1 - alpha or alpha), and on alpha >= 1 clears the play and task-spawns OnDone.
Two things worth being precise about, since they are easy to assume rather than verify:
- Unlike Dissolve’s stepper (lazily connected on the first effect, disconnected when the last one finishes), a
MaterialKitkit’s stepper connects at construction (unlessSkipLoop) and stays connected for the kit’s whole life, regardless of whether anything is currently playing. - Calling
play()again on a surface that is already playing replacesself.Playoutright — the interrupted play’sOnDonenever fires; it is simply discarded, not queued or cancelled explicitly.
restore() is the instant form: it drops any running Play, clears the kit’s Playing entry, and calls setProgress(0) — every face returns immediately, whatever the previous alpha was.
OnFaceGone — a hook per removal
Section titled “OnFaceGone — a hook per removal”OnFaceGone(centroid, normal) fires (task-spawned) once per face that setProgress actually removes — including every knit-then-re-erode pass, since ranks and the removed/present bookkeeping are per current state, not per lifetime. centroid is transformed into world space through Part.CFrame:PointToWorldSpace(...); normal, notably, is not — it is passed through in the mesh’s local space, unrotated (see Gotchas below). This is the natural place to spawn per-face debris: a puff of dust, a spark, a chip of the surface flying off — and it composes directly with VfxSuite’s Spawn/Emit actions or a one-off Dissolve dust burst at the reported centroid.
Permissions
Section titled “Permissions”Erode and knit a MeshPart you own
Section titled “Erode and knit a MeshPart you own”-- CLIENTlocal ReplicatedStorage = game:GetService("ReplicatedStorage")local MaterialKit = require(ReplicatedStorage.ChloeKernel.MaterialKit)
local Kit = MaterialKit.new()
local Surface, Reason = Kit:bind(AcidDoor, { NoiseScale = 0.4, Bias = Vector3.new(0, 1, 0) * 0.6, -- erodes bottom-up OnFaceGone = function(centroid: Vector3, normal: Vector3) -- spawn debris/dust at centroid; normal is mesh-local, not world end,})if not Surface then warn("could not bind AcidDoor:", Reason) returnend
Surface:play({ Duration = 2.5 })
-- Later: a receding driver knits it back shutSurface:play({ Duration = 1.5, Reverse = true, OnDone = function() print("AcidDoor fully restored")end })Manual erosion (a ward that erodes as the player nears)
Section titled “Manual erosion (a ward that erodes as the player nears)”local Players = game:GetService("Players")local RunService = game:GetService("RunService")
local Surface = Kit:bind(WardWall) -- assume success for brevity
RunService.Heartbeat:Connect(function() local Character = Players.LocalPlayer.Character if not Character then return end local Distance = (Character:GetPivot().Position - WardWall.Position).Magnitude Surface:setProgress(1 - math.clamp((Distance - 5) / 15, 0, 1))end)Falling back to bindBox for a part with no mesh permission
Section titled “Falling back to bindBox for a part with no mesh permission”local Surface, Reason = Kit:bind(ThirdPartyProp)if not Surface then -- No read permission on this asset — fall back to a box overlay. -- Collision on ThirdPartyProp stays live; only its look changes. Surface = Kit:bindBox(ThirdPartyProp, { Subdivisions = 6 })end
Surface:play({ Duration = 2 })Erosion ranks without a mesh, for tooling and tests
Section titled “Erosion ranks without a mesh, for tooling and tests”MaterialKit.order is pure and engine-free — useful for previewing an erosion pattern, or asserting on it in a spec, without ever creating an EditableMesh:
local Centroids = { Vector3.new(0, 0, 0), Vector3.new(0, 5, 0), Vector3.new(0, 10, 0), Vector3.new(0, 15, 0),}
-- Bias pushes erosion bottom-up: low centroids rank low (erode first)local Ranks = MaterialKit.order(Centroids, 1, 0.35, Vector3.new(0, 5, 0))for Index, Rank in Ranks do print(Index, Rank) -- monotonically increasing with height under this BiasendSame Seed, NoiseScale, and Bias inputs always produce the same ranks — this is exactly the math bind/bindBox run once at setup to build each face’s Rank.
Configuration
Section titled “Configuration”KitOptions — MaterialKit.new(options)
Section titled “KitOptions — MaterialKit.new(options)”| Option | Type | Default | Meaning |
|---|---|---|---|
Clock |
() -> number |
os.clock |
Time source for play() sweeps |
SkipLoop |
boolean |
false |
Skip the automatic Heartbeat connection — specs and custom loops drive kit:step(dt) manually |
BindOptions — kit:bind(meshPart, options)
Section titled “BindOptions — kit:bind(meshPart, options)”| Option | Type | Default | Meaning |
|---|---|---|---|
Seed |
number |
0 |
Noise layout seed |
NoiseScale |
number |
0.35 |
Noise frequency — studs per noise feature |
Bias |
Vector3 |
nil |
Directional erosion weight: a unit direction scaled by strength, dot-producted against each centroid |
OnFaceGone |
(centroid: Vector3, normal: Vector3) -> () |
nil |
Fired per removed face. centroid is world space; normal is mesh-local |
BoxOptions — kit:bindBox(part, options)
Section titled “BoxOptions — kit:bindBox(part, options)”Same fields as BindOptions, plus:
| Option | Type | Default | Meaning |
|---|---|---|---|
Subdivisions |
number |
4 (clamped 1..24) |
Cells per box face edge — total faces = 6 * Subdivisions^2 * 2 |
PlayOptions — surface:play(options)
Section titled “PlayOptions — surface:play(options)”| Option | Type | Default | Meaning |
|---|---|---|---|
Duration |
number |
required, must be > 0 |
Seconds for alpha to sweep 0 → 1 |
Reverse |
boolean |
false |
Sweep 1 → 0 (knit) instead of 0 → 1 (erode) |
OnDone |
() -> () |
nil |
Fired (task-spawned) when the sweep completes. Not called if a later play() interrupts this one |
API reference
Section titled “API reference”| Member | Description |
|---|---|
MaterialKit.new(options: KitOptions?) |
Creates a kit: a surface registry, a Playing set, and a Heartbeat stepper (unless SkipLoop) |
MaterialKit.supported(): boolean |
Probes the “Allow Mesh & Image APIs” gate with a real scratch-mesh edit |
kit:bind(meshPart: MeshPart, options: BindOptions?): (Surface?, string?) |
Opens the part’s own mesh, bakes once, and rewires the part to render that live object. (nil, reason) on any failure |
kit:bindBox(part: BasePart, options: BoxOptions?): (Surface?, string?) |
Generates and bakes a subdivided box overlay; hides the original part but leaves its collision untouched |
surface:setProgress(alpha: number) |
Removes every face ranked below alpha, re-adds every face ranked at or above it |
surface:play(options: PlayOptions) |
Timed sweep of setProgress on the kit’s stepper |
surface:restore() |
Cancels any running play and instantly re-adds every face (setProgress(0)) |
surface:destroy() |
Tears the surface down — see Gotchas for how this differs between bind() and bindBox() surfaces |
kit:step(dt: number) |
Advances every playing surface one tick. Call manually only with SkipLoop = true |
kit:destroy() |
Disconnects the stepper and destroys every surface the kit holds |
Pure helper:
| Member | Description |
|---|---|
MaterialKit.order(centroids: { Vector3 }, seed: number?, noiseScale: number?, bias: Vector3?): { number } |
Erosion ranks for a set of face centroids — the same math bind/bindBox catalog internally, engine-free |
Performance
Section titled “Performance”setProgress(alpha) walks every cataloged face on the surface each time it is called — there is no early-out for faces far from the current threshold, and no incremental “only touch what crossed” bookkeeping beyond the per-face Gone flag. A play() sweep calls setProgress once per Heartbeat tick for every currently-playing surface, so the per-frame cost of an active erosion is one full pass over that surface’s face list, not just the handful of faces actually flipping state that frame. Budget face counts accordingly: a bindBox() overlay’s face count is 6 * Subdivisions^2 * 2, directly in your control, while a bind()’d MeshPart’s face count is whatever the source asset has — a high-poly mesh mid-erosion costs a full-mesh face-list walk every tick it plays, the same shape of cost as Dissolve’s “one loop over live dots” per controller per frame, just over faces instead of dots.
MaterialKit is local-only VFX, the same stance as Dissolve: RemoveFace/AddTriangle mutate a client’s own EditableMesh object, and none of it replicates. This is not server-authoritative geometry — a wall that “erodes” on one client is untouched on every other machine until that machine runs its own bind()/play(). Broadcast the trigger (a bus event, a replicated state field) and have every client play the identical effect — since erosion rank is derived from each face’s centroid position plus Seed (default 0 everywhere unless you override it), clients reading the same mesh asset with the same Seed compute identical ranks and erode in the same visual order without any extra data on the wire. Compose the trigger through your own network call, or through VfxSuite’s Custom/Handlers action or a CutsceneKit scene event, exactly as you would wire a Dissolve play.
Gotchas
Section titled “Gotchas”destroy()is not symmetric between the two bind modes. For abindBox()surface,destroy()restores the original part’sTransparencyand destroys only the overlay — the source part is untouched and reappears exactly as it was. For abind()surface, there is no original to restore:destroy()simply callsMesh:Destroy(), and because the part’s rendered content is object-backed content pointing at that sameMesh, destroying it empties the MeshPart’s geometry — the part is left with no visible mesh, permanently, until youApplyMeshsomething else onto it yourself.restore()beforedestroy()does not help; the finalMesh:Destroy()runs regardless of face state.OnFaceGone’snormalis mesh-local, not world space. Onlycentroidis transformed throughPart.CFrame:PointToWorldSpace(...);normalis passed through raw. If the bound part is rotated, orienting spawned debris offnormaldirectly will point the wrong way — rotate it by the part’sCFramerotation yourself before using it to aim anything.bindBox()’s overlay does not follow the source part. The overlay isAnchored = trueand itsCFrameis set once, at bind time, to match the part’s pose then. If the original part moves or rotates afterward, the overlay stays where it was created —bindBox()is for parts that hold still for the surface’s lifetime, not moving props.- Interrupting
play()drops the interrupted call’sOnDonesilently. A secondplay()while one is already running replacesself.Playoutright; the first call’s completion callback simply never fires, with no warning. - Re-binding an already-bound
MeshPartis not guarded against.bind()already replaced the part’s mesh content viaApplyMeshon first use; nothing stops a secondbind()call on the same part, but it would read back the already-baked object-backed content rather than the original asset. This path is untested — avoid binding the same part twice. - No guard on a destroyed surface. Calling
setProgress,play, orrestoreon a surface afterdestroy()operates on aMeshthat no longer exists and will error. Drop your reference to aSurfaceonce you destroy it.