Skip to content

Dissolve

Voxelizes parts, models and avatars into pooled dots that drift through a noise field, reassemble, erode by hand or fly into another shape, as client-local VFX.

Realm
Shared
Require
ReplicatedStorage.ChloeKernel.Dissolve
Since
v0.6.0
Source
Dissolve.luau

Dissolve turns things into drifting voxels and back. It is a vertex-sampled voxelizer: a target is sampled into a point cloud (mesh vertices when the runtime allows it, a surface shell otherwise), each point becomes a pooled dot, and every dot advects through a 3D Perlin field while it fades. The same machinery runs in reverse (a target assembles out of dust), as manual erosion driven by setProgress, and as a morph that flies one shape’s dots into another’s. VfxSuite exposes it as a timeline action, and MaterialKit erodes real geometry instead of dots.

Everything is client-local. play() and morph() hide the target through LocalTransparencyModifier, which never touches replicated state, so other clients see nothing. For a shared effect, replicate the trigger and play on every client (see the VFX cookbook).

Sampling: vertices first, a surface shell as the floor

Section titled “Sampling: vertices first, a surface shell as the floor”

Dissolve.sample(target, dotSize, maxPoints) walks every BasePart of the target and returns world-space points with colors:

  • A MeshPart with a mesh prefers vertex sampling. CreateEditableMeshAsync(content, { FixedSize = true }) opens the mesh that ChloeKernel.MeshSource reads (the same check HairKit and MaterialKit use), vertex positions are read with a stride that fits the per-part budget and scaled by Size / MeshSize, and the editable is destroyed straight away, including when reading it fails. The silhouette is the real mesh.
  • Everything else, including a MeshPart with no mesh (never opened, so no engine error while the Mesh APIs are off) and any mesh that cannot be opened, samples a surface shell: grid cells inside the part shape within one cell of its boundary. Ball and Cylinder filter to their true silhouette (an ellipsoid, and an elliptical cylinder with end caps); Block keeps its outer layer. Interior cells drop, so nothing dissolves as a filled box. A 4x4x4 block at a 1-stud step gives exactly 56 cells, the 4^3 grid minus the 2^3 interior.

Points then snap to a DotSize voxel grid and dedupe per cell: one dot per cell at the cell center, colored by the first point that landed there (point colors come from Part.Color). The per-part budget is maxPoints / partCount, at least 8. Shell samplers fit their budget by coarsening their step 1.5x per attempt.

Vertex sampling needs the “Allow Mesh & Image APIs” experience setting and read permission on the asset. Avatars wear catalog meshes most experiences cannot open, so the same denial would repeat on every effect. A module-level cache keyed by mesh content records permanent denials, and every later sample of that mesh goes straight to the shell. With the setting off, Studio logs the engine’s “EditableMesh is not accessible” error for each attempt, even inside pcall; that failure is permanent, so the cache also holds it to one error per mesh.

Only permanent failures are cached. An error that mentions a budget, memory, a timeout, throttling, HTTP, the network, “too many” or “try again” retries on the next play. A nil editable (the device’s editable memory budget is spent) falls back to the shell for that sample and is never cached either.

Loose dots move by Dissolve.noiseVelocity(position, age, NoiseScale, ScatterSpeed, Drift): three offset Perlin samples form a direction, scaled by ScatterSpeed, plus the constant Drift. The field is deterministic per input and varies over position and time, so dust wanders coherently instead of jittering. Each dot fades over its first 1.2 s of looseness.

Every dot carries a deterministic noise key from 0 to 1, derived from its home position. Each step compares the key against the controller’s Progress:

  • Key < Progress: the dot is loose. It ages, advects and fades.
  • Key >= Progress: the dot is home-bound. It eases back toward its surface cell at ReturnSpeed and its age unwinds at triple speed, so it becomes solid again.

Because the keys are spatial noise, the surface erodes in patches instead of fading evenly, and when Progress recedes the same patches close up. Drive setProgress from a distance check and a ward wall erodes as the player approaches and reseals as they walk away.

  • Timed mode (Duration = n): Progress ramps 0 to 1 over Duration (1 to 0 with Reverse = true). The effect finishes 1.2 s after the ramp ends, so the last loose dots finish fading.
  • Manual mode (Duration = nil): the controller never finishes on its own. You drive setProgress(0-1) and call Stop() eventually.

Resting costs nothing, and manual controllers hibernate

Section titled “Resting costs nothing, and manual controllers hibernate”

Dots at rest skip their frame: a settled dot (home, solid, anchor unmoved), a parked dot (fully faded) and a landed morph dot on a still destination are not written. workspace:BulkMoveTo receives only the frame’s movers.

A manual controller that sits fully at rest (progress 0, every dot settled) for HibernateSeconds (default 2) hibernates. Its dots go back to the pool, the pool trims to PoolKeep idle dots (default 64), the controller leaves the stepper and the real target shows again. The next setProgress above 0 rebuilds the dots from the cached sample. With every controller at rest the Heartbeat connection disconnects, so a ward wall nobody is near costs nothing. HibernateSeconds = math.huge keeps the voxel render forever. A manual controller that got no points (the budget was spent) has nothing standing in for its target, so it shows the target at once instead of waiting.

Home positions ride the target’s anchor part: its PrimaryPart, the part itself, or the first BasePart found. Each frame the anchor’s CFrame change since spawn maps every home into the anchor’s current frame, so reassembly, restructure easing and morph endpoints land on the target wherever it moved. A player who walks off mid-effect reforms where they are. A target whose anchor has moved never counts as at rest, so it does not hibernate.

morph(source, destination) samples both shapes once, then:

  1. Pairing. pairPoints sorts both clouds bottom-up (Y, then X, then Z) and assigns by rank. With more destinations than sources, ranks spread across the range; with fewer, they wrap. Every destination cell is used either way, and flights read bottom-to-bottom instead of crossing.
  2. Stagger. Each dot’s launch is offset by its key through morphAlpha(progress, key, stagger): low keys launch first and every dot lands by progress 1. Stagger = 0 launches everything together.
  3. Flight. Position lerps from home to destination with smoothstep easing, plus a turbulence wobble from the same Perlin field under a sin(alpha * pi) envelope, zero at both ends, so launches and landings are exact.
  4. Color. Each dot lerps from its source cell’s color to its paired destination cell’s color.
  5. Reveal. When the flight lands the destination shows (Reveal, default true) and the source stays hidden. Chain morph(destination, source) to go back, or a Reverse play on the source to reform it.

Both endpoints ride their own anchors. A destination that samples to nothing becomes one point at its pivot.

Shell sampling boxes anything the vertex sampler cannot read, and avatars are the usual case. Chunks = true on play() and morph() clones every BasePart of the target as a full-detail flying chunk. Meshes, decals, textures, SurfaceAppearance and wrap layers survive the clone; joints, welds, sounds, emitters and scripts are stripped. Chunks use the same threshold keys and flights as the dots while shrinking (to 5% of rest size) and fading into the dust. Unarchivable parts are cloned through a temporary Archivable flip.

play and morph set LocalTransparencyModifier = 1 on the target’s parts and on what renders with them: Decals (which covers Textures), ParticleEmitters, Beams, Trails and Fire, including those under Attachments. A part target hides what is under it but never a child part the sample did not cover. The sim records each object’s value before its first hide, so chained effects (dissolve then reassemble, morph there and back) restore the pre-effect value, never a hidden one from mid-chain. A hidden object that is destroyed is dropped from that record, so a dissolved NPC that is destroyed afterwards leaves nothing behind.

  • One Heartbeat connection serves every active controller. It connects with the first effect and disconnects when no controller is active.
  • Dots are pooled per template (Pool, initial size 16). The default dot is an anchored SmoothPlastic cube with no collision, query, touch or shadow; CustomDot clones your template instead.
  • All dot and chunk CFrames go through one workspace:BulkMoveTo per controller per frame.
local ReplicatedStorage = game:GetService("ReplicatedStorage")
local Dissolve = require(ReplicatedStorage.ChloeKernel.Dissolve)
local Vfx = Dissolve.new() -- one sim for the whole client: pools and the denial cache persist
-- Forward: scatter into rising dust over 1.5 s. The target stays hidden afterwards.
Vfx:play(BanishedNpc, { Duration = 1.5, Drift = Vector3.new(0, 8, 0), Spin = true })
-- Reverse: assemble the target out of dust. It shows again at the end.
Vfx:play(RestoredStatue, { Duration = 2, Reverse = true })
src/Client/Game/Controllers/WardController.luau
--!strict
-- WardController: the ward wall erodes as the local player approaches.
local Players = game:GetService("Players")
local ReplicatedStorage = game:GetService("ReplicatedStorage")
local Dissolve = require(ReplicatedStorage.ChloeKernel.Dissolve)
local WardController = {
Name = "WardController",
}
function WardController.init(self: any, kernel: any)
self.Kernel = kernel
self.Vfx = Dissolve.new()
end
function WardController.start(self: any)
local Wall = workspace:WaitForChild("WardWall") :: BasePart
self.Ward = self.Vfx:play(Wall, { HibernateSeconds = 3 }) -- Duration = nil: manual mode
self.Job = self.Kernel.Scheduler:every(1 / 30, function()
local Character = Players.LocalPlayer.Character
if not Character then
return
end
local Distance = (Character:GetPivot().Position - Wall.Position).Magnitude
-- Intact at 20 studs, fully eroded at 5
self.Ward:setProgress(1 - math.clamp((Distance - 5) / 15, 0, 1))
end)
end
function WardController.stop(self: any)
if self.Job then
self.Job:cancel()
end
self.Vfx:destroy() -- stops the ward and restores the wall
end
return WardController

Dots past the threshold scatter; the rest ease home and become solid. Walk away and the wall closes up, and 3 s later the controller hibernates and the real wall shows. Ward:Stop() ends the effect and restores the wall whatever the progress.

Vfx:play(Character, {
Duration = 1.6,
DotSize = 0.3,
Spin = true,
Chunks = true, -- body parts fly as full-detail clones and shrink into the dust
OnDone = function()
Vfx:play(Character, { Duration = 1.6, Reverse = true, Chunks = true })
end,
})
Vfx:morph(Character, GolemStatue, {
Duration = 1.6,
Chunks = true,
OnDone = function()
Vfx:morph(GolemStatue, Character, {
OnDone = function()
-- The statue was the return flight's source and stayed hidden: reform it
Vfx:play(GolemStatue, { Reverse = true })
end,
})
end,
})
local Ember = Instance.new("Part")
Ember.Material = Enum.Material.Neon
Ember.Color = Color3.fromRGB(255, 120, 40)
Ember.Shape = Enum.PartType.Ball
Vfx:play(BurningDoor, { Duration = 2, CustomDot = Ember, Drift = Vector3.new(0, 12, 0) })

Each template gets its own pool. Dots still take the sampled cell’s color and the play’s DotSize.

Option Type Default Meaning
MaxPoints number Client: 500-5000 by device quality. Server: 2000. Live dots across every play and morph.
Parent Instance workspace Container for dots and chunks.
PoolKeep number 64 Idle dots each pool keeps when a hibernating controller trims it.
Option Type Default Meaning
DotSize number 0.25 Voxel edge in studs: the sampling grid and the dot size.
MaxPoints number sim MaxPoints Point cap for this play, also capped by the sim’s remaining budget.
Duration number nil Seconds to a full dissolve. nil is manual setProgress mode.
ScatterSpeed number 14 Noise velocity magnitude, studs per second.
NoiseScale number 0.08 Noise field frequency.
Drift Vector3 (0, 6, 0) Constant added velocity; the default rises like ash.
Reverse boolean false Assemble instead of dissolve; shows the target at the end.
Spin boolean false Random per-dot rotation.
ReturnSpeed number 8 Ease-home rate (1/s) for dots becoming solid again.
HibernateSeconds number 2 Manual mode only: seconds fully at rest before hibernating. math.huge never hibernates.
Chunks boolean false Clone every target part as a full-detail flying chunk.
CustomDot BasePart nil Template cloned into the pool instead of the default cube.
OnDone () -> () nil Called on its own thread on a natural finish or Stop().

MorphConfig for sim:morph(source, destination, config)

Section titled “MorphConfig for sim:morph(source, destination, config)”
Option Type Default Meaning
DotSize number 0.25 Voxel edge in studs.
MaxPoints number sim MaxPoints Point cap for this morph.
Duration number 1.5 Flight seconds.
Turbulence number 4 Mid-flight noise offset in studs, zero at both ends.
NoiseScale number 0.08 Turbulence field frequency.
Stagger number 0.35 0-1 launch spread by dot key. 0 launches together.
Spin boolean false Random per-dot rotation.
Chunks boolean false Source parts fly as full-detail chunks into the landing shape.
CustomDot BasePart nil Template cloned into the pool instead of the default cube.
Reveal boolean true Show the destination when the flight lands.
OnDone () -> () nil Called on its own thread on a natural finish or Stop().
  • MaxPoints is a live-dot budget shared by every effect. Each play or morph gets min(config.MaxPoints or sim.MaxPoints, sim.MaxPoints - liveCount) points, so overlapping effects sample coarser (or get no dots at zero budget) instead of blowing the frame.
  • Unset, the budget scales from 500 to 5000 by DeviceBench quality on a client, on a log curve (a mid-range device lands near 2750), and is 2000 elsewhere.
  • Samplers cap themselves: vertex sampling strides across the vertex list, and shell and grid sampling coarsen their step 1.5x per attempt.
  • Per frame the cost is one loop over live dots and one BulkMoveTo per controller with only the movers. No physics, no network, and no work at all for hibernated controllers.
Member Description
Dissolve.new(options?) -> Sim Creates a sim: pools, the hidden-state record and a Heartbeat connection made on demand.
sim:play(target, config?) -> Controller Hides the target and plays a dissolve, or an assembly with Reverse.
sim:morph(source, destination, config?) -> Controller Voxelizes the source and flies its dots into the destination’s sampled shape.
sim:destroy() Finishes every active and hibernated controller with a full restore, and destroys the pools and the default template.
controller:Stop() Ends the effect now: releases every dot, destroys chunks and restores every object it hid.
controller:setProgress(alpha) Manual mode: 0 intact, 1 fully dissolved, thresholded per dot. Wakes a hibernated controller when above 0.

Pure helpers, used by the sim and open for custom effects:

Member Description
Dissolve.noiseVelocity(position, seconds, noiseScale, scatterSpeed, drift) -> Vector3 Advection velocity at a position and time. Deterministic per input.
Dissolve.snap(points, dotSize) Snaps { Position, Color } points to a voxel grid: one cell center per cell, first color wins.
Dissolve.pairPoints(source, destination) -> { number } Bottom-up rank pairing; every destination index is used.
Dissolve.morphAlpha(progress, key, stagger) -> number Staggered per-dot flight alpha; every dot lands at progress 1.
Dissolve.surfacePoints(cframe, size, shape, dotSize, maxPoints) -> { Vector3 } Shell cells for Block, Ball and Cylinder; the interior drops.
Dissolve.gridPoints(cframe, size, dotSize, maxPoints) -> { Vector3 } Filled bounding-box grid, for custom volume effects.
Dissolve.sample(target, dotSize, maxPoints) The full sampling pipeline: vertices per MeshPart, shell fallback, snap and dedupe.
Path Progress On natural finish On Stop() or sim:destroy()
play (forward) 0 to 1 over Duration Target stays hidden Everything restored
play (Reverse) 1 to 0 over Duration Target restored Everything restored
play (manual) You drive it Never finishes; hibernates at rest and shows the target Everything restored
morph 0 to 1 over Duration Destination shown (if Reveal), source stays hidden Both sides restored

A forward dissolve leaves the target hidden on purpose: it is gone. Destroy it, respawn it or reverse-play it. OnDone runs on natural finishes and on Stop().

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