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).
Mental model
Section titled “Mental model”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 thatChloeKernel.MeshSourcereads (the same check HairKit and MaterialKit use), vertex positions are read with a stride that fits the per-part budget and scaled bySize / 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.
BallandCylinderfilter to their true silhouette (an ellipsoid, and an elliptical cylinder with end caps);Blockkeeps 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.
Which mesh failures are cached
Section titled “Which mesh failures are cached”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.
Advection: the noise field
Section titled “Advection: the noise field”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.
Erosion: the per-dot threshold
Section titled “Erosion: the per-dot threshold”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 atReturnSpeedand 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):Progressramps 0 to 1 overDuration(1 to 0 withReverse = 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 drivesetProgress(0-1)and callStop()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.
Homes ride the anchor
Section titled “Homes ride the anchor”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: flying one shape into another
Section titled “Morph: flying one shape into another”morph(source, destination) samples both shapes once, then:
- Pairing.
pairPointssorts 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. - 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 = 0launches everything together. - 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. - Color. Each dot lerps from its source cell’s color to its paired destination cell’s color.
- Reveal. When the flight lands the destination shows (
Reveal, default true) and the source stays hidden. Chainmorph(destination, source)to go back, or aReverseplay on the source to reform it.
Both endpoints ride their own anchors. A destination that samples to nothing becomes one point at its pivot.
Chunks: full detail for avatars
Section titled “Chunks: full detail for avatars”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.
Hiding and restoring
Section titled “Hiding and restoring”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.
Rendering
Section titled “Rendering”- 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;
CustomDotclones your template instead. - All dot and chunk CFrames go through one
workspace:BulkMoveToper controller per frame.
Dissolve and reassemble
Section titled “Dissolve and reassemble”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 })Proximity erosion
Section titled “Proximity erosion”--!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 wallend
return WardControllerDots 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.
Avatar disintegration with full detail
Section titled “Avatar disintegration with full detail”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,})Morph an avatar into a statue and back
Section titled “Morph an avatar into a statue and back”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,})Custom dots
Section titled “Custom dots”local Ember = Instance.new("Part")Ember.Material = Enum.Material.NeonEmber.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.
Configuration
Section titled “Configuration”Options for Dissolve.new(options)
Section titled “Options for Dissolve.new(options)”| 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. |
PlayConfig for sim:play(target, config)
Section titled “PlayConfig for sim:play(target, config)”| 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(). |
Performance budgets
Section titled “Performance budgets”MaxPointsis a live-dot budget shared by every effect. Eachplayormorphgetsmin(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
BulkMoveToper controller with only the movers. No physics, no network, and no work at all for hibernated controllers.
API reference
Section titled “API reference”| 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. |
Forward, reverse, and restore semantics
Section titled “Forward, reverse, and restore semantics”| 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().