Dissolve
Dissolve turns things into drifting voxels — and back. It is a vertex-sampled voxelizer: targets are sampled into a point cloud (mesh vertices when the runtime allows, a surface shell otherwise), each point becomes a pooled dot, and every dot advects through a 3D Perlin field while fading out. The same machinery runs in reverse (assemble a target out of drifting dust), as a manually-driven erosion (setProgress), and as a morph that flies one shape’s dots into another’s.
Everything is client-local VFX: play() and morph() hide the target through LocalTransparencyModifier, which never touches replicated state. Other clients see nothing — broadcast the trigger through a state channel and play on every client for shared effects (see the VFX cookbook).
-- CLIENTlocal ReplicatedStorage = game:GetService("ReplicatedStorage")local Dissolve = require(ReplicatedStorage.ChloeKernel.Dissolve)
local Vfx = Dissolve.new()Vfx:play(BanishedNpc, { Duration = 1.5, Drift = Vector3.new(0, 8, 0), Spin = true })Mental model
Section titled “Mental model”Sampling: vertices first, surface shell as the floor
Section titled “Sampling: vertices first, surface shell as the floor”sample(target, dotSize, maxPoints) walks every BasePart of the target and produces world-space points with colors:
- MeshParts prefer vertex sampling:
CreateEditableMeshAsync(meshPart.MeshContent, { FixedSize = true })opens the mesh read-only, vertex positions are read with a stride that respects the per-part budget, scaled bySize / MeshSize, and the editable is destroyed immediately. The silhouette is the real mesh. - Everything else — plain parts, and meshes the runtime refuses to load — samples a surface shell: grid cells inside the part shape within one cell of the boundary.
BallandCylinderfilter to their true silhouette (ellipsoid; elliptical cylinder with end caps),Blockkeeps its outer layer. Interior cells drop, so nothing dissolves as a filled box — the spec pins a 4x4x4 block at exactly 56 shell cells (the 4³ grid minus the 2³ interior).
Points then snap to a DotSize voxel grid and dedupe per cell — one dot per cell, positioned 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, floored at 8 points per part; shell samplers cap themselves by coarsening their step 1.5x per attempt until they fit.
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 vector, 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 seconds of looseness and the whole effect releases its dots to the pool when done.
Erosion, not fading: the per-dot threshold
Section titled “Erosion, not fading: the per-dot threshold”Every dot carries a deterministic noise key in 0..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 through the field, and fades.Key >= Progress— the dot is home-bound: it eases back toward its surface cell atReturnSpeedand its age (so its fade) unwinds at triple speed — it resolidifies.
Because the keys are spatial noise, the surface erodes in patches instead of fading uniformly — and when Progress recedes, the same patches knit themselves shut. That is the restructure: drive setProgress from a proximity 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(or 1 to 0 withReverse = true). The effect finishes 1.2 seconds after the ramp ends, giving the last loose dots their full fade. - Manual mode (
Duration = nil): the controller never finishes on its own; you ownsetProgress(0..1)and eventually callStop().
Homes ride the anchor
Section titled “Homes ride the anchor”Home positions are not fixed world points — they ride the target’s anchor part (PrimaryPart, the part itself, or the first BasePart found). Each frame the anchor’s delta CFrame 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 to. A player who walks off mid-effect reforms where they are, not where they stood.
Morph: flying one shape into another
Section titled “Morph: flying one shape into another”morph(source, destination) samples both shapes once at call time, 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 modulo — every destination cell is used either way. Flights read bottom-to-bottom instead of crossing chaotically. - Stagger — each dot’s launch is offset by its noise key through
morphAlpha(progress, key, stagger): low keys launch first, and every dot lands by progress 1.Stagger = 0launches everything in lockstep. - Flight — position lerps home-to-destination with smoothstep easing, plus a turbulence wobble sampled from the same Perlin field, enveloped by
sin(alpha * pi)— zero at both endpoints, so launches and landings are exact. - Color — each dot lerps from its source cell’s color to its paired destination cell’s color along the eased flight.
- Reveal — when the flight lands, the destination un-hides (
Reveal, default true) and the source stays hidden. Chainmorph(destination, source)to morph back, or aReverseplay on the source to reform it out of dust.
Both endpoints ride their own anchors: launches lift off a moving source, landings meet the destination where it is. An empty destination sample (no parts) degrades to a single point at the destination’s pivot.
Chunks: full detail for avatars
Section titled “Chunks: full detail for avatars”Grid sampling boxes anything the vertex sampler cannot read — and avatars are the common case (catalog meshes the experience cannot editable-load). Chunks = true on play() and morph() clones every BasePart of the target as a full-detail flying chunk: meshes, textures, faces, SurfaceAppearance, and wrap layers survive the clone; joints, welds, sounds, emitters, and scripts strip. Chunks ride the same noise field or morph flight as the dots — same threshold keys, so partial dissolves and restructures work on them — while shrinking (down to 5% of rest size) and fading into the dust. Detail stays perfect at launch and melts into particulate instead of starting squared. Unarchivable parts are force-cloned through a temporary Archivable flip.
On meshes the experience owns, vertex sampling already yields detailed voxel shells — chunks are for what you cannot read.
Rendering: one stepper, pooled dots, bulk moves
Section titled “Rendering: one stepper, pooled dots, bulk moves”- One
Heartbeatstepper serves every active controller, connects on the first effect, and disconnects itself when the last finishes. - Dots are pooled per template (Pool, initial size 16) — the default dot is an anchored, collision-less, shadow-less SmoothPlastic cube;
CustomDotclones your template instead. - All dot and chunk CFrames write through a single
workspace:BulkMoveToper controller per frame.
Permissions
Section titled “Permissions”Dissolve and reassemble
Section titled “Dissolve and reassemble”-- CLIENTlocal ReplicatedStorage = game:GetService("ReplicatedStorage")local Dissolve = require(ReplicatedStorage.ChloeKernel.Dissolve)
local Vfx = Dissolve.new()
-- Forward: scatter into drifting dust over 1.5s, then release the dotsVfx:play(BanishedNpc, { Duration = 1.5, Spin = true })
-- Reverse: assemble the target out of dust. The target un-hides at the endVfx:play(RestoredStatue, { Duration = 2, Reverse = true })Manual erosion (proximity dissolves, shields)
Section titled “Manual erosion (proximity dissolves, shields)”local Players = game:GetService("Players")local RunService = game:GetService("RunService")
local Controller = Vfx:play(WardWall, {}) -- Duration = nil: manual mode
RunService.Heartbeat:Connect(function() local Character = Players.LocalPlayer.Character if not Character then return end local Distance = (Character:GetPivot().Position - WardWall.Position).Magnitude -- 0 intact at 20 studs, fully eroded at 5 Controller:setProgress(1 - math.clamp((Distance - 5) / 15, 0, 1))end)Dots past the threshold scatter; dots behind it ease home at ReturnSpeed and resolidify. Walk away and the wall knits itself shut. Call Controller:Stop() to end the effect and restore the wall regardless of 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, shrinking 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 })Chained effects restore cleanly: the sim captures each object’s LocalTransparencyModifier before the first hide, so a dissolve-then-reassemble or a morph-there-and-back restores the pre-effect value, never a mid-chain hidden one. Decals and Textures on hidden parts hide and restore with them — faces and clothing vanish cleanly.
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 distinct template gets its own pool. Dots still take the sampled cell’s Color and the play’s DotSize.
Configuration
Section titled “Configuration”Options — Dissolve.new(options)
Section titled “Options — Dissolve.new(options)”| Option | Type | Default | Meaning |
|---|---|---|---|
MaxPoints |
number |
device-scaled 500..5000 (client), 2000 (server) |
Total live dots across all plays and morphs |
Parent |
Instance |
workspace |
Container for dot and chunk parts |
PlayConfig — sim:play(target, config)
Section titled “PlayConfig — sim:play(target, config)”| Option | Type | Default | Meaning |
|---|---|---|---|
DotSize |
number |
0.25 |
Voxel edge in studs — sampling grid and dot size |
MaxPoints |
number |
sim MaxPoints |
Point cap for this play (further capped by the sim’s remaining budget) |
Duration |
number |
nil |
Seconds to full dissolve. nil = manual setProgress mode |
ScatterSpeed |
number |
14 |
Noise velocity magnitude, studs/s |
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; restores the target at the end |
Spin |
boolean |
false |
Random per-dot angular velocity |
ReturnSpeed |
number |
8 |
Ease-home rate (1/s) for resolidifying dots during restructure |
Chunks |
boolean |
false |
Clone every target part as a full-detail flying chunk |
CustomDot |
BasePart |
nil |
Template cloned into the pool instead of a cube |
OnDone |
() -> () |
nil |
Fired (task-spawned) on natural finish or Stop() |
MorphConfig — sim:morph(source, destination, config)
Section titled “MorphConfig — 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 endpoints |
NoiseScale |
number |
0.08 |
Turbulence field frequency |
Stagger |
number |
0.35 |
0..1 launch spread by dot key. 0 = uniform launch |
Spin |
boolean |
false |
Random per-dot angular velocity |
Chunks |
boolean |
false |
Source parts fly as full-detail chunks into the landing shape |
CustomDot |
BasePart |
nil |
Template cloned into the pool instead of a cube |
Reveal |
boolean |
true |
Show the destination when the flight lands |
OnDone |
() -> () |
nil |
Fired (task-spawned) on natural finish or Stop() |
Performance budgets
Section titled “Performance budgets”MaxPointsis a live-dot budget, not a per-effect one. Eachplay/morphgetsmin(config.MaxPoints or sim.MaxPoints, sim.MaxPoints - liveCount)points — overlapping effects share the pool and later effects sample coarser (or not at all at zero budget) rather than blowing the frame.- Unconfigured, the budget scales 500..5000 by DeviceBench quality on clients (log-mapped: a mid-range device lands near 2750) and 2000 elsewhere.
- Samplers self-cap: vertex sampling strides across the vertex list, shell and grid sampling coarsen their step 1.5x per attempt until they fit the budget.
- Per frame, the cost is one loop over live dots plus one
BulkMoveToper controller — no per-dot Instances beyond the pooled parts, no physics, no network.
API reference
Section titled “API reference”| Member | Description |
|---|---|
Dissolve.new(options: Options?) |
Creates a sim: pools, hidden-state ledger, lazy Heartbeat stepper |
sim:play(target: Instance, config: PlayConfig?) |
Hides the target and plays a dissolve (or assembly with Reverse). Returns a controller |
sim:morph(source: Instance, destination: Instance, config: MorphConfig?) |
Voxelizes the source and flies its dots into the destination’s sampled shape. Returns a controller |
sim:destroy() |
Stops every active controller (with full restore), destroys pools and the default template |
controller:Stop() |
Ends the effect now: releases every dot, destroys chunks, restores every touched object |
controller:setProgress(alpha: number) |
Manual mode only (Duration = nil): 0 intact, 1 fully dissolved, noise-thresholded per dot |
Pure helpers — engine-free, used by the sim and open for custom effects:
| Member | Description |
|---|---|
Dissolve.noiseVelocity(position, seconds, noiseScale, scatterSpeed, drift): Vector3 |
Advection velocity at a world 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: { Vector3 }, destination: { Vector3 }): { number } |
Bottom-up rank pairing; every destination index used |
Dissolve.morphAlpha(progress, key, stagger): number |
Per-dot staggered flight alpha; everyone lands at progress 1 |
Dissolve.surfacePoints(cframe, size, shape, dotSize, maxPoints): { Vector3 } |
Surface-shell grid cells for Block/Ball/Cylinder; interior drops |
Dissolve.gridPoints(cframe, size, dotSize, maxPoints): { Vector3 } |
Filled bounding-box grid, kept for custom volume effects |
Dissolve.sample(target, dotSize, maxPoints) |
Full sampling pipeline: vertex-first per MeshPart, shell fallback, snap + dedupe |
Forward, reverse, and restore semantics
Section titled “Forward, reverse, and restore semantics”| Path | Progress | On natural finish | On Stop() / sim:destroy() |
|---|---|---|---|
play (forward) |
0 → 1 over Duration |
Target stays hidden (it dissolved) | Everything restored |
play (Reverse) |
1 → 0 over Duration |
Target restored (it reassembled) | Everything restored |
play (manual) |
You drive it | Never finishes naturally | Everything restored |
morph |
0 → 1 over Duration |
Destination revealed (if Reveal), source stays hidden |
Both sides restored |
A forward dissolve deliberately leaves the target hidden — it is gone; destroy it, respawn it, or reverse-play it back. OnDone fires on both natural finishes and stops.
Gotchas
Section titled “Gotchas”- Local only, by design.
LocalTransparencyModifierand client-side dots never replicate. For a shared effect, replicate the trigger (a state channel or bus event) and let every client run its ownplay()— identical noise keys derive from positions, so the erosion pattern matches everywhere. - Positions and colors sample once at call time. A target that changes appearance mid-effect keeps its sampled colors; homes follow the anchor’s movement but not re-posing of individual limbs.
- Anchor choice matters for models. Models without a
PrimaryPartanchor to their first BasePart — setPrimaryParton rigs you dissolve so homes ride the root, not a random limb. - The sim outlives effects — reuse it. Pools and the denied-mesh cache amortize across plays. Creating a
Dissolve.new()per effect throws away warm pools and re-triggers permission warnings. - Chunks multiply parts. Every BasePart of the target becomes a live clone for the effect’s duration. For a 15-part avatar that is 15 chunks plus the dot cloud; keep
Chunksfor hero moments, not crowds.