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 orderendMental model
Section titled “Mental model”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”- The kit opens the part’s mesh with
AssetService:CreateEditableMeshAsyncon the content thatChloeKernel.MeshSourcereads:MeshContent(an asset URI, or an EditableMesh from an earlier bake), falling back toContent.fromUri(meshPart.MeshId)whereMeshContentcannot 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, noMeshId) was refused. AssetService:CreateMeshPartAsync(Content.fromObject(Mesh))bakes aMeshPartwhose content is object-backed: a reference to the live editable, not a copy of its vertices.- The part’s original mesh is snapshotted first (a clone, taken through a temporary
Archivableflip for parts that are not archivable), thenmeshPart:ApplyMesh(baked)swaps the object-backed content onto the part in place andSizeis put back. - 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)endScores 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
alphathat is present is removed withMesh:RemoveFaceandOnFaceGonefires. - A face ranked at or above
alphathat was removed is re-added withMesh:AddTriangleon 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(options): a timed sweep
Section titled “play(options): a timed sweep”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
Section titled “OnFaceGone”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.
A surface lives as long as its part
Section titled “A surface lives as long as its part”- Destroying a bound part, or a
bindBoxoverlay, 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 abindsurface applies the snapshotted original mesh back (keeping the part’s currentSize) before freeing the editable, so the part shows its asset again. On abindBoxsurface it restores the original’sTransparencyand 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.
Permissions
Section titled “Permissions”Erode and knit a MeshPart you own
Section titled “Erode and knit a MeshPart you own”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 bakeend
Surface:play({ Duration = 2.5 })
-- Later: knit it back shutSurface: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)endFalling 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 })endif Surface then Surface:play({ Duration = 2 })endErosion ranks without a mesh
Section titled “Erosion ranks without a mesh”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 firstlocal 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.
Configuration
Section titled “Configuration”MaterialKit.new(options?)
Section titled “MaterialKit.new(options?)”| Option | Type | Default | Description |
|---|---|---|---|
Clock |
() -> number |
os.clock |
Time source for play sweeps. |
SkipLoop |
boolean |
false |
Never connect Heartbeat. Specs drive kit:step(dt). |
kit:bind(meshPart, options?)
Section titled “kit:bind(meshPart, options?)”| 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. |
kit:bindBox(part, options?)
Section titled “kit:bindBox(part, options?)”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. |
surface:play(options)
Section titled “surface:play(options)”| 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. |
API reference
Section titled “API reference”| 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. |
Performance
Section titled “Performance”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.