Skip to content

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.

-- CLIENT
local 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 order

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:

  1. AssetService:CreateEditableMeshAsync(Content.fromUri(meshPart.MeshId)) opens the mesh for editing. This is the same gated call HairKit’s rig() uses, against a URI built from the part’s MeshId string (an empty MeshId is rejected before any engine call: "the MeshPart has no MeshId").
  2. AssetService:CreateMeshPartAsync(Content.fromObject(Mesh)) bakes a MeshPart — but the content it bakes from is Content.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 to Mesh, the same instance bind() is holding.
  3. meshPart:ApplyMesh(bakedPart) copies that object-backed mesh content onto the original part in place, then restores Size (ApplyMesh can change it via MeshSize, same as HairKit’s bake path).
  4. The Surface roots Mesh for the part’s lifetime — nothing ever calls Mesh: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 originalbindBox() 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)
end

Scores 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 < alpha and the face is presentMesh:RemoveFace(Face.Id), mark it gone, fire OnFaceGone if set.
  • Face.Rank >= alpha and the face was removedMesh: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 id AddTriangle returns (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 MaterialKit kit’s stepper connects at construction (unless SkipLoop) 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 replaces self.Play outright — the interrupted play’s OnDone never 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(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.

-- CLIENT
local 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)
return
end
Surface:play({ Duration = 2.5 })
-- Later: a receding driver knits it back shut
Surface: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 Bias
end

Same 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.

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

BindOptionskit: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

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
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
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

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.

  • destroy() is not symmetric between the two bind modes. For a bindBox() surface, destroy() restores the original part’s Transparency and destroys only the overlay — the source part is untouched and reappears exactly as it was. For a bind() surface, there is no original to restore: destroy() simply calls Mesh:Destroy(), and because the part’s rendered content is object-backed content pointing at that same Mesh, destroying it empties the MeshPart’s geometry — the part is left with no visible mesh, permanently, until you ApplyMesh something else onto it yourself. restore() before destroy() does not help; the final Mesh:Destroy() runs regardless of face state.
  • OnFaceGone’s normal is mesh-local, not world space. Only centroid is transformed through Part.CFrame:PointToWorldSpace(...); normal is passed through raw. If the bound part is rotated, orienting spawned debris off normal directly will point the wrong way — rotate it by the part’s CFrame rotation yourself before using it to aim anything.
  • bindBox()’s overlay does not follow the source part. The overlay is Anchored = true and its CFrame is 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’s OnDone silently. A second play() while one is already running replaces self.Play outright; the first call’s completion callback simply never fires, with no warning.
  • Re-binding an already-bound MeshPart is not guarded against. bind() already replaced the part’s mesh content via ApplyMesh on first use; nothing stops a second bind() 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, or restore on a surface after destroy() operates on a Mesh that no longer exists and will error. Drop your reference to a Surface once you destroy it.