Skip to content

MaterialKit

Erodes and restores the real rendered mesh of a part by removing and re-adding EditableMesh faces in noise order, with no re-bake between edits.

Realm
Client
Require
ReplicatedStorage.ChloeKernel.MaterialKit
Since
v0.7.0
Source
MaterialKit.luau

MaterialKit erodes the mesh a player actually sees. Dissolve flies pooled voxel dots that stand in for the target; MaterialKit removes and re-adds triangles on the live EditableMesh behind the part, so a wall eaten by acid loses its real surface. The part is baked once, at bind time, to render the live editable; after that every edit shows on the next frame with no re-bake. It is local-only VFX: each client plays its own copy.

local ReplicatedStorage = game:GetService("ReplicatedStorage")
local MaterialKit = require(ReplicatedStorage.ChloeKernel.MaterialKit)
local Kit = MaterialKit.new()
local Surface = Kit:bind(workspace.AcidDoor)
if Surface then
Surface:play({ Duration = 2 }) -- erodes face by face, in noise order
end

bind(meshPart): open the part’s own mesh, bake once, edit live

Section titled “bind(meshPart): open the part’s own mesh, bake once, edit live”
  1. The kit opens the part’s mesh with AssetService:CreateEditableMeshAsync on the content that ChloeKernel.MeshSource reads: MeshContent (an asset URI, or an EditableMesh from an earlier bake), falling back to Content.fromUri(meshPart.MeshId) where MeshContent cannot be read. A part with no mesh is rejected before any engine call with "the MeshPart has no mesh". Until the Unreleased fix after 0.9.1 the reason was "the MeshPart has no MeshId", and a part whose mesh came from an earlier bake (object content, no MeshId) was refused.
  2. AssetService:CreateMeshPartAsync(Content.fromObject(Mesh)) bakes a MeshPart whose content is object-backed: a reference to the live editable, not a copy of its vertices.
  3. The part’s original mesh is snapshotted first (a clone, taken through a temporary Archivable flip for parts that are not archivable), then meshPart:ApplyMesh(baked) swaps the object-backed content onto the part in place and Size is put back.
  4. The surface keeps the editable alive for the part’s lifetime.

From then on, setProgress calls Mesh:RemoveFace and Mesh:AddTriangle on that object and the render reflects it next frame. The bake happens once, only to link the part to the live object.

HairKit also bakes once, but registers its content through CreateDataModelContentAsync and never touches the mesh again. MaterialKit skips that registration because it keeps editing the same object.

bindBox(part, options?): the permission-free overlay

Section titled “bindBox(part, options?): the permission-free overlay”

Not every mesh is readable: third-party catalog meshes reject CreateEditableMeshAsync. bindBox opens no asset. It calls AssetService:CreateEditableMesh() and generates a subdivided box: six faces, each a grid of (Subdivisions + 1) x (Subdivisions + 1) vertices with two triangles per cell, wound outward. The default Subdivisions = 4 gives 4 x 4 x 2 x 6 = 192 faces. The mesh bakes into a new overlay MeshPart named <part>Surface, sized and posed like the part, with the part’s Color and Material, parented next to it, anchored, and with CanCollide, CanQuery and CanTouch off.

The original part goes to Transparency = 1 and keeps its collision. Erosion is render-only; the game decides when a wall stops blocking, the same stance as Dissolve. The price is a boxy silhouette in place of the real geometry.

Erosion order: noise rank plus a directional Bias

Section titled “Erosion order: noise rank plus a directional Bias”

Both bind modes catalog every face once: its id, its three vertex ids (the restore record), its centroid and normal, and an erosion rank from the pure MaterialKit.order:

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, and each face’s rank is its sorted midpoint, (position - 0.5) / count: face 1 of 4 ranks 0.125, never exactly 0 or 1. Nearby centroids sample nearby noise, so a sweep eats coherent patches instead of random single faces.

Bias adds a linear term: faces far along its direction score higher and erode last, faces the other way erode first. A wall eaten from the bottom up uses a Bias pointing up. The dot product runs against mesh-local positions in studs, so the same strength reads very differently on a small prop and on a wall tens of studs wide; size it to the mesh.

setProgress(alpha): erode and knit in one call

Section titled “setProgress(alpha): erode and knit in one call”
  • A face ranked below alpha that is present is removed with Mesh:RemoveFace and OnFaceGone fires.
  • A face ranked at or above alpha that was removed is re-added with Mesh:AddTriangle on its recorded vertex ids, and the new face id is stored.

RemoveFace removes the triangle but leaves its vertices, so the recorded vertex triple is what makes knitting possible. A driver whose alpha shrinks (a ward fading as the player backs off) knits the surface shut in reverse erosion order.

play asserts Duration > 0, records the start time and adds the surface to the kit’s playing set. Each kit has one Heartbeat connection, made when a surface starts playing and dropped when the last play finishes, so a kit with nothing playing has no per-frame work. Each frame the step sets progress to clamp((now - start) / Duration, 0, 1) (or 1 - that with Reverse), and at the end clears the play and runs OnDone on its own thread. A second play on the same surface replaces the first. restore() drops any play and calls setProgress(0).

OnFaceGone(centroid, normal) runs on its own thread for every face setProgress removes, including on later passes after a knit. centroid is converted to world space with Part.CFrame:PointToWorldSpace; normal stays in mesh-local space. It is the place to spawn debris or dust, for example a VfxSuite emit at the centroid.

  • Destroying a bound part, or a bindBox overlay, destroys the surface and frees its editable.
  • Destroying the original part behind an overlay destroys the overlay too, so it never renders on its own.
  • surface:destroy() on a bind surface applies the snapshotted original mesh back (keeping the part’s current Size) before freeing the editable, so the part shows its asset again. On a bindBox surface it restores the original’s Transparency and destroys the overlay.

EditableMeshes count against a per-device memory budget, which is why every path frees the editable. Before 0.9.0 a destroyed part kept its surface alive, a bindBox overlay could outlive its original, and destroy() on a bind surface left the part with no mesh.

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 dust at centroid; normal is mesh-local
end,
})
if not Surface then
return Reason -- denied, over budget or failed to bake
end
Surface:play({ Duration = 2.5 })
-- Later: knit it back shut
Surface:play({
Duration = 1.5,
Reverse = true,
OnDone = function()
Surface:destroy() -- puts the door's original mesh back
end,
})

Manual erosion: a ward that erodes as the player nears

Section titled “Manual erosion: a ward that erodes as the player nears”

Drive setProgress from a Scheduler job that exists only while the ward is bound:

local Surface = Kit:bind(WardWall)
if Surface then
local Job = self.Kernel.Scheduler:every(0.05, function()
local Character = Players.LocalPlayer.Character
if Character then
local Distance = (Character:GetPivot().Position - WardWall.Position).Magnitude
Surface:setProgress(1 - math.clamp((Distance - 5) / 15, 0, 1))
end
end)
WardWall.Destroying:Once(function()
Job:cancel() -- the surface dies with the part; stop driving it
end)
end

Falling back to bindBox without mesh permission

Section titled “Falling back to bindBox without mesh permission”
local Surface = Kit:bind(ThirdPartyProp)
if not Surface then
-- No read permission: erode a box overlay instead. The prop keeps its collision
Surface = Kit:bindBox(ThirdPartyProp, { Subdivisions = 6 })
end
if Surface then
Surface:play({ Duration = 2 })
end

MaterialKit.order is pure, so tooling and specs can preview or assert a pattern without an EditableMesh:

local Centroids = {
Vector3.new(0, 0, 0),
Vector3.new(0, 5, 0),
Vector3.new(0, 10, 0),
Vector3.new(0, 15, 0),
}
-- A strong upward Bias: low centroids rank low and erode first
local Ranks = MaterialKit.order(Centroids, 1, 0.35, Vector3.new(0, 5, 0))

The same Seed, NoiseScale and Bias always give the same ranks; this is the math both bind modes run at setup.

Option Type Default Description
Clock () -> number os.clock Time source for play sweeps.
SkipLoop boolean false Never connect Heartbeat. Specs drive kit:step(dt).
Option Type Default Description
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.
OnFaceGone (centroid: Vector3, normal: Vector3) -> () nil Runs per removed face. centroid in world space, normal mesh-local.

The same fields as bind, plus:

Option Type Default Description
Subdivisions number 4, clamped to 1-24 Cells per box face edge. Faces = 6 * Subdivisions^2 * 2.
Option Type Default Description
Duration number required, > 0 Seconds for the sweep.
Reverse boolean false Sweep from 1 to 0 (knit) instead of 0 to 1 (erode).
OnDone () -> () nil Runs on its own thread when the sweep completes. Not called if a later play replaces this one.
Member Description
MaterialKit.new(options?) -> kit A kit: a surface registry and a playing set. Connects nothing until a surface plays.
MaterialKit.supported() -> boolean Probes the “Allow Mesh & Image APIs” setting with a real scratch edit. Caches the verdict, but not a nil editable.
MaterialKit.order(centroids, seed?, noiseScale?, bias?) -> { number } Pure erosion ranks for a set of face centroids.
kit:bind(meshPart, options?) -> (Surface?, string?) Opens the part’s mesh, bakes once and renders the live editable in place. nil, reason on any failure.
kit:bindBox(part, options?) -> (Surface?, string?) Generates a subdivided box overlay and hides the original, whose collision is untouched.
surface:setProgress(alpha) Removes every face ranked below alpha and re-adds every face at or above it.
surface:play(options) Timed sweep of setProgress on the kit’s stepper.
surface:restore() Cancels any play and re-adds every face at once.
surface:destroy() Frees the surface: puts a bind part’s original mesh back, or restores a bindBox original and removes the overlay.
kit:step(dt) Advances every playing surface one frame. Call it yourself only with SkipLoop = true.
kit:destroy() Disconnects the stepper and destroys every surface the kit holds.

setProgress walks every cataloged face on each call; there is no early exit for faces far from the threshold. A playing surface calls it once per frame, so an active erosion costs one pass over that surface’s face list per frame. A bindBox overlay’s face count is 6 * Subdivisions^2 * 2 and under your control; a bound MeshPart has whatever the asset has, so a high-poly mesh mid-erosion walks its whole face list every frame it plays.

MaterialKit is local-only VFX, like Dissolve. RemoveFace and AddTriangle change a client’s own EditableMesh, and nothing replicates. Broadcast the trigger (a Bus event, a replicated state field, a CutsceneKit event or a VfxSuite handler) and have every client play the same effect. Ranks come from each face’s centroid and the Seed, so clients reading the same mesh with the same Seed erode in the same order with nothing extra on the wire.

ChloeKernel v0.9.1. Access to the framework is private and granted individually by Chloe. Games built with it credit CryptedChloes in their description.