Skip to content

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).

-- CLIENT
local 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 })

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 by Size / 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. Ball and Cylinder filter to their true silhouette (ellipsoid; elliptical cylinder with end caps), Block keeps 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.

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 at ReturnSpeed and 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): Progress ramps 0 to 1 over Duration (or 1 to 0 with Reverse = 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 own setProgress(0..1) and eventually call Stop().

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(source, destination) samples both shapes once at call time, then:

  1. PairingpairPoints 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 modulo — every destination cell is used either way. Flights read bottom-to-bottom instead of crossing chaotically.
  2. 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 = 0 launches everything in lockstep.
  3. 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.
  4. Color — each dot lerps from its source cell’s color to its paired destination cell’s color along the eased flight.
  5. Reveal — when the flight lands, the destination un-hides (Reveal, default true) and the source stays hidden. Chain morph(destination, source) to morph back, or a Reverse play 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.

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 Heartbeat stepper 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; CustomDot clones your template instead.
  • All dot and chunk CFrames write through a single workspace:BulkMoveTo per controller per frame.
-- CLIENT
local 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 dots
Vfx:play(BanishedNpc, { Duration = 1.5, Spin = true })
-- Reverse: assemble the target out of dust. The target un-hides at the end
Vfx: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.

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,
})
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.

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 distinct template gets its own pool. Dots still take the sampled cell’s Color and the play’s DotSize.

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
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()

MorphConfigsim: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()
  • MaxPoints is a live-dot budget, not a per-effect one. Each play/morph gets min(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 BulkMoveTo per controller — no per-dot Instances beyond the pooled parts, no physics, no network.
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
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.

  • Local only, by design. LocalTransparencyModifier and client-side dots never replicate. For a shared effect, replicate the trigger (a state channel or bus event) and let every client run its own play() — 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 PrimaryPart anchor to their first BasePart — set PrimaryPart on 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 Chunks for hero moments, not crowds.