Melt demo¶
Lava pours from a spout onto a half-metre block standing on dark stone. The block's material decides what happens: ice melts into water that runs off and chills the lava into black crust; wax and chocolate soften, slump and harden again as they cool; aluminium soaks up heat, glows, and only then melts. The liquid is drawn as one smooth, glowing surface, and one button switches back to the raw particles underneath.
The demo teaches two engine pieces that work together: a particle liquid
with temperature (kke::ParticleFluid, Position Based Fluids) and a voxel
solid that heats up and melts into more liquid (kke::MeltVolume), plus
screen-space fluid rendering (kke::FluidSurfaceRenderer) to make
particles read as a liquid. Start here for lava, water, slime, mud or
honey in a game, for things that melt, burn away or erode, or for any
effect where a solid turns into a liquid.

Run it¶
The executable is melt_demo (kke_add_game(melt_demo ...) in
CMakeLists.txt: add_executable on desktop, a shared
library on Android). The root CMakeLists.txt always adds it: it needs no
optional library (not FEMFX, not Jolt) and no asset pack.
cmake --build build --target melt_demo
cd build/bin
./melt_demo
KKE_SKIP_INTRO=1 ./melt_demo # skip the logo intro
KKE_MELT_PRESET=3 ./melt_demo # start with the aluminium block
| Variable | Effect |
|---|---|
KKE_MELT_PRESET=0..3 |
the starting block: 0 ice, 1 wax, 2 chocolate, 3 aluminium (clamped to that range) |
KKE_MELT_FILL=N |
start with N particles of lava already poured, beside the block (the budget grows to fit), and log the liquid's timings every 5 s: for measuring big liquids |
KKE_FLUID_GPU=0 / shared |
the liquid's neighbour search on the CPU only / on the graphics queue (a GPU with one queue); see kke/FluidGpu.h |
KKE_FLUID_GPU_CHECK=1 |
search on GPU and CPU both and log how often they differed (slow) |
KKE_SKIP_INTRO=1 |
skip the logo intro (engine-wide) |
Rebindings are saved to melt_demo_input.json (the name passed to
InputModule in main.cpp).
Controls¶
The actions are made in MeltDemoModule::defineInput
(MeltDemoModule.cpp), in the "Melt" group and the
game input context.
| Action | Keyboard / mouse | Controller |
|---|---|---|
Pour lava on / off (melt.pour) |
Space | A (south) |
Next block, and start over (melt.block) |
B | RB (right shoulder) |
Smooth liquid surface on / off (melt.smooth) |
L | Y (north) |
Reset (melt.reset) |
R | X (west) |
Turn the camera (camera.orbit) |
left-drag | right stick |
| Pan the camera | right-drag | no controller binding yet |
Zoom (camera.zoom) |
mouse wheel | d-pad up (closer) / down (further) |
Settings panel (panel.toggle) |
F3 or Esc, or click it | View (Back) |
| Developer panels (ImGui) | F1, developer builds only | no controller binding yet |
The camera uses the orbit camera's default Viewer controls (left-drag
orbits, right-drag pans, scroll zooms) and setPadControls(true) in
main.cpp for the right stick and d-pad. Button names are
positions, so on a PlayStation pad A is Cross, Y is Triangle and X is
Square.
The settings panel¶
The settings are a kke::DemoPanelModule titled "Melt", drawn with RmlUi
(docs/DEMO_PANEL.md). It starts open at the
left edge with the game keeping the controls; the mouse can click and drag
any row. panel.toggle (View on a pad, F3 on the keyboard) makes it
Active: up and down pick a row, left and right change it (hold to sweep),
A presses, B or Esc hands control back. On the keyboard it reads the
arrows, Enter and Esc directly. While it is Active, player 1's game
context is off, so A does not also toggle the pour and the d-pad does not
zoom. The last row, "Hide panel", collapses it.
Esc works like a pause menu: it opens the panel with the keyboard on
it, and Esc again goes back to the game. So Esc does not close the
window; the panel's "Quit" row, just above "Hide panel", does.
setEscapeMenu(false) gives Esc back to a game that needs it.
| Row | Kind | Range |
|---|---|---|
| Controls line | text | the four actions as button prompts for the device in hand |
| Block | choice | the four presets; changing it resets |
| Pour lava | toggle | |
| Smooth liquid surface | toggle | |
| Smoothing | slider, shown only while the surface is smooth | 0.01 to 0.2 m, step 0.01 |
| Pour rate | slider | 30 to 400 drops/s, step 10 |
| Lava temperature | slider | 800 to 1400 C, step 20 |
| Reset | button | |
| Status | live text | block left (%), liquid particles / 3,000 |
| Budget warning | text, shown only when full | "Liquid budget full: reset to pour again" |
| Timings | live text | fluid and melt ms per step, block surface triangles |
| Note | note | what each material does |
With a DemoPanelModule in the app, the engine's ImGui windows start
hidden; F1 shows them in developer builds.
How it plays¶
It is a sandbox with no goal. Lava pours at 120 drops a second from 1.5 m above the block. The block is 0.5 m on each side. What you should see:
| Block | Melts at | Starts at | What happens |
|---|---|---|---|
| Ice (melts to water) | 0 C | -15 C | eats inward from where the lava lands; the water runs off and cools the lava around it into crust |
| Wax (softens, re-hardens) | 60 C | 20 C | slumps; the molten wax stiffens as it cools and sets again below 45 C |
| Chocolate | 35 C | 18 C | melts fastest (highest melt rate); sets again below 25 C |
| Aluminium (glows, then melts) | 660 C | 20 C | conducts heat quickly, so the whole block warms and glows before any of it runs; the melt glows too |
Lava itself cools as it gives its heat away and crusts over below 550 C: it turns dark and rough and stops flowing.
Nothing floats: a piece of the block that's no longer joined to the
ground (its middle melted out, an undercut corner) falls and lands on
whatever is below it, and hot wax, chocolate and aluminium sag into any
hole under them before they melt (MeltMaterial::softening: 15, 8 and
40 degrees under the melting point). The panel says "collapsing" while
something falls.
The liquid has a budget of 3,000 particles (kMaxParticles), shared by
the lava and the melt. When it is full, no more lava comes out of the
spout and melted voxels stop turning into particles; the panel says so.
Reset (or picking another block) starts over.
How it works¶
Startup and the frame¶
main.cpp creates the Application (1280 x 720), sets the mood
dusk (the comment: "the hot, glowing melt reads best against a darker
sky") and adds the modules in this order:
InputModule(melt_demo_input.json)UiModule(RmlUi, for the panel)AudioModule, its ImGui window hidden: it plays the mood's ambience loop (the melt itself makes no sound)OrbitCameraModule(distance 2.8 m, pitch -0.35, yaw 0.6, target (0, 0.45, 0)),setPadControls(true)kke_melt::MeltDemoModule, the demoDemoPanelModule("Melt")DebugControlModule, its ImGui window hiddenStatsModule
MeltDemoModule::init defines the input, creates the sphere and fluid
surface renderers (the surface's blur radius set to 2.5 particle radii),
builds a 6 x 6 m ground quad at y = 0, reads KKE_MELT_PRESET, calls
reset() and builds the panel.
Each frame:
| Hook | What it does |
|---|---|
fixedUpdate (fixed step) |
emit lava from the spout, ParticleFluid::step, MeltVolume::step, time both |
update |
read the actions; if the block melted, rebuild its surface mesh and upload it |
prepass |
colour every particle from its material and temperature; render the particles into the fluid surface's offscreen targets |
renderShadow |
the block only |
render |
the ground, the block, then the smooth surface or the raw spheres |
Setting up a block: reset()¶
reset() throws away the old simulation and builds a new one from the
current preset:
- A
ParticleFluidwith 3 cm particles, container walls from (-2.8, -1, -2.8) to (2.8, 10, 2.8), the ground plane at y = 0, 2 substeps, low contact friction (0.02) and slow cooling to 20 C air. - Two liquid materials:
kLava(0) andkMelt(1). Lava is runny at 1200 C, thicker as it cools, and solid below 550 C. The melt gets the preset's ownFluidMaterial. - A
MeltVolumeof 24 x 24 x 24 voxels of 2.5 cm, filled with a 20 x 20 x 20 block (fillBox) at the preset's start temperature, with the preset's melting point, heat capacity, melt rate and conduction. Melted matter comes out askMeltparticles at 2 C above the melting point. - The block becomes a collider of the fluid: a lambda that returns the block's signed distance.
kke::MeltVolume* block = m_block.get();
m_fluid->addCollider([block](const glm::vec3& pos, glm::vec3& n) { return block->signedDistance(pos, n); });
That one line is the whole coupling for collisions: the fluid does not know what a voxel is, only a distance and a normal.
The liquid: kke::ParticleFluid¶
kke/ParticleFluid.h, ParticleFluid.cpp. Position Based Fluids (Macklin and Muller, SIGGRAPH 2013), the method behind NVIDIA Flex. Each substep:
- Apply gravity and predict every particle's new position.
- Sort particles by grid cell (a counting sort), so neighbours sit next to each other in memory, and build a neighbour list once. The kernel radius is 4 particle radii (12 cm here).
- Run a few Jacobi iterations that move each particle to bring its density back to the rest density (1000 kg/m3). This is what keeps the liquid from compressing.
- Collide with the ground, the container walls and every collider (the block's distance field).
- Set velocity from the change in position, then apply XSPH viscosity:
each particle shares some of its neighbours' velocity. The share goes
from
viscosityHottoviscosityColdas the particle cools.
Each particle also carries a temperature. It diffuses between neighbours
(heatDiffusion = 3) and cools toward the air (coolingRate = 0.05).
This is how water from the ice chills the lava: they are in the same
simulation, so their temperatures mix. Below its material's
solidifyTemperature a particle is "frozen": its velocity is damped
every substep and it moves with its neighbours, but it still falls if
nothing holds it up. That is the crust.
Lava is emitted in fixedUpdate as a stream about 8 cm wide leaving the
spout at 1.5 m/s. An accumulator (m_emitAccum) turns the pour rate into
whole particles per step. ParticleFluid::add returns false at the
budget, and the loop stops.
The solid: kke::MeltVolume¶
kke/MeltVolume.h,
MeltVolume.cpp. A voxel grid holding
density (1 solid, 0 empty) and temperature. step(dt, fluid) does three
things:
- Heat exchange with the liquid. Every particle within reach of a
solid voxel (its radius plus 1.5 voxels) trades heat with it, both
ways. The voxel's change is divided by
heatCapacity, the particle's byliquidHeatCapacity. So the lava cools as it melts the block, which is why it crusts on top of it. - Conduction. Each solid voxel moves toward the average of its solid
neighbours at a rate set by
conduction. Aluminium's high conduction (4) spreads heat through the whole block before any of it melts. - Melting with latent heat. A voxel above its melting point loses
density in proportion to the excess, and its temperature is set back
to the melting point. The comment in the header: "a voxel at its
melting point can't get hotter; extra heat goes into losing density
instead. That one rule is why melting looks gradual and eats inward
from the contact." Lost density accumulates per voxel; each time it
adds up to one particle's worth, a new
kMeltparticle is added at that voxel.
For collisions the volume keeps a signed distance field, built with a two-pass chamfer transform over the voxels. It is rebuilt only when a voxel crosses from solid to empty (density 0.5), not on every step that melts a little (docs/OPTIMIZATION.md #32). Outside the grid the distance keeps growing, so the grid's own edge is not an invisible wall (BUG-049 in BUGS.md).
Collapsing: MeltVolume::collapse()¶
step() calls it after melting. A flood fill over the voxels you see
(density above 0.5, 6-connected) labels each solid piece; any piece not
touching the bottom row (the ground) falls under gravity a cell at a
time, its soft fringe with it, until the cell below holds something that
isn't itself, then it merges with what it landed on. Soft voxels (within
softening degrees of melting) creep down into an empty cell below at
sagSpeed m/s. It runs only after something melted or while something
is falling (about 0.1 ms for 24x24x24), and tests in
tests/test_melt_volume.cpp (LoosePiecesFallAndLand,
SoftMaterialSagsIntoAHole) hold it to "nothing left in the air,
nothing lost on the way down".
The block's surface¶
update() calls MeltVolume::rebuildMesh(), which only does work when
something melted. It extracts the surface at density 0.5 by marching
tetrahedra: each cell is split into six tetrahedra, which needs 16 cases
instead of marching cubes' 256-entry tables and never leaves cracks.
Normals come from the density gradient, so the surface is smooth. Each
vertex also gets a glow value (0 cold to 1 at the melting point).
The demo turns that into vertex colours: aluminium keeps its colour and
puts the glow in uv.x (the dynamic mesh shader reads uv.x as
incandescent glow); the other materials are tinted toward their liquid
colour, so they look wet as they near melting. The mesh is drawn with
kke::DynamicMeshRenderer (metallic 0.8 for aluminium).
Drawing the liquid¶
The colours are worked out once per frame in prepass:
- Lava above 550 C mixes from dark to deep orange and glows in
proportion to
(T - 550) / 650. Below 550 C it is dark rock with roughness 0.95 and no glow. - Melt particles take the preset's liquid colour; molten aluminium also glows above 550 C.
Then one of two paths draws them:
- Smooth surface (default).
kke::FluidSurfaceRenderer(kke/FluidSurface.h), screen-space fluid rendering (van der Laan, Green and Sainz, 2009). Inprepass, the particles are drawn as spheres of 1.6 radii into an offscreen linear depth and colour target; a compute pass blurs the depth with a bilateral filter of a fixed size in world units that stops at depth jumps, so touching particles fuse and separate blobs stay apart. Inrender, a full-screen pass rebuilds positions and normals from the blurred depth, lights them with Fresnel and glow, and writes real depth so the block and ground still hide it. - Raw particles.
kke::SphereImpostorRendererdraws each particle as a lit sphere impostor of 1.25 radii ("a little overlap reads as one liquid, not marbles").
prepass returns early when ctx.sceneCovered is set (a full-screen
opaque menu is up), because its work only feeds render()
(docs/RENDERING_PRINCIPLES.md).
Design decisions¶
- Position Based Fluids for the liquid. The header says PBF is "stable at large time steps, no pressure explosions, and it 'feels' right long before it is physically exact, which is the goal".
- A separate liquid solver, not FEMFX. docs/HISTORY.md says "FEMFX
has no liquids, so this is separate from it". Both engine pieces are
pure CPU and know nothing about drawing, so they are unit-tested
(
tests/test_particle_fluid.cpp,tests/test_melt_volume.cpp) and can be reused with any renderer. - Latent heat as one rule. See "The solid" above: a voxel at its melting point spends extra heat on losing density. It gives gradual, inward melting without a thermodynamics solver; the header says "it isn't a thermodynamics solver".
- Marching tetrahedra, not marching cubes. 16 cases, no lookup tables, no ambiguous cases, watertight (docs/OPTIMIZATION.md #19).
- A real signed distance field for collisions. The header: "a particle that lands deep inside is pushed straight back out in one step instead of shooting up".
- The liquid surface in screen space instead of a mesh. OPTIMIZATION
#20: meshing particles costs CPU every frame and grows with volume; the
screen-space method costs per pixel. The trade-off, from
docs/HISTORY.md: the surface is opaque (no thickness-based transparency
or refraction yet) and shows some vertical streaking from the separable
blur.
Lswitches to raw particles to compare. - Small XSPH factors. The comment in
reset(): large values "average every particle's velocity with its neighbours so hard that a whole blob moves like one rigid body: a column of lava could never slump". - Lava holds more heat than it passes on.
liquidHeatCapacity = 3; the comment says "at 1.0 every drop crusted on touch and sealed the block in a black shell". It was tuned headless in simulated time: ice about 60% left at 5 s, about 8% at 25 s. - Two substeps. The comment: "a 1.5 m drop reaches ~6 m/s: 120 Hz keeps it from tunnelling".
- Low contact friction. The comment: "high contact friction made piles stand with vertical walls".
- A fixed particle budget. 3,000 particles, "the liquid budget (docs/OPTIMIZATION.md rule 5)". When full, pouring and melting stop adding particles instead of the frame rate falling.
- Tuned by eye. The comment above
kPresets: "values tuned by eye to 'feel' right in a ~1 minute pour, not measured from real materials (relative melting points and behaviour are real)". - The settings panel is RmlUi. Commit 944d596 moved the ImGui window
to
kke::DemoPanelModuleso a controller can reach every setting (docs/DEMO_PANEL.md).
Tuning¶
| What | Where | Effect |
|---|---|---|
kPresets: melting point, start temperature, heat capacity, melt rate, conduction |
MeltDemoModule.cpp | per material: higher heat capacity or lower melt rate = slower melting; higher conduction = heat spreads before it melts |
each preset's FluidMaterial (viscosity hot / cold, hot and solidify temperatures) |
kPresets |
how runny the melt is and whether it sets again |
lava solidifyTemperature = 550, viscosity 0.03 / 0.15 |
reset() |
higher solid point = crusts sooner |
liquidHeatCapacity = 3 |
reset() |
lower = lava crusts on contact; higher = lava melts more before cooling |
coolingRate = 0.05, heatDiffusion = 3 |
reset() |
how fast particles lose heat to the air and to each other |
friction = 0.02, substeps = 2 |
reset() |
higher friction = sand-like piles; fewer substeps = fast streams tunnel |
kParticleRadius = 0.03 |
MeltDemoModule.cpp | smaller = finer liquid, many more particles for the same volume |
kMaxParticles = 3000 |
MeltDemoModule.cpp | the budget; cost grows about linearly |
| block grid 24^3, cell 2.5 cm | reset() |
finer = smoother melting, cost grows with the cube |
m_pourRate = 120, m_lavaTemperature = 1200 |
MeltDemoModule.h | the panel's starting values |
blurWorldRadius = 2.5 r, depthFalloff = 2 r |
init() |
how much particles merge; the "Smoothing" slider sets the first |
kSpout = (0, 1.5, 0), stream 8 cm wide at 1.5 m/s |
fixedUpdate |
where and how the lava arrives |
Engine features it uses¶
| Feature | Header / module | Doc |
|---|---|---|
| Particle liquid with temperature | kke::ParticleFluid (kke/ParticleFluid.h) |
OPTIMIZATION.md #18 |
| Meltable voxel solid | kke::MeltVolume (kke/MeltVolume.h) |
OPTIMIZATION.md #19, #32 |
| Screen-space liquid surface | kke::FluidSurfaceRenderer (kke/FluidSurface.h) |
OPTIMIZATION.md #20 |
| Offscreen work before the main pass | Module::prepass, PrepassContext::sceneCovered (kke/Module.h) |
RENDERING_PRINCIPLES.md |
| Sphere impostors, meshes updated from the CPU | kke::SphereImpostorRenderer, kke::DynamicMeshRenderer (kke/SphereImpostors.h) |
|
| Actions, bindings, prompts | kke::InputModule |
INPUT.md |
| Settings panel | kke::DemoPanelModule |
DEMO_PANEL.md |
| Orbit camera with pad controls | kke::OrbitCameraModule |
DEMO_PANEL.md |
| Sky, sun, colour look | Application::setMood("dusk") |
MOODS.md |
| Performance and pause panels (F1) | kke::StatsModule, kke::DebugControlModule |
None of the liquid or melting pieces are exposed to Lua (docs/SCRIPTING.md has no fluid functions).
Assets¶
None. The ground, the block and the liquid are all built in code. The
only files it loads are the mood's: the sky picture (Poly Haven "Qwantani
Dusk 2 (Pure Sky)", CC0), fetched by the build for every demo, and the
night_crickets ambience loop (assets/ambience/night_crickets.flac,
CC0, in the repository), which the build copies next to every game
(docs/SCENES.md "Moods"). No Synty pack is used.
Only AudioModule plays mood ambience, which is why main.cpp
adds one (BUG-079 in BUGS.md, fixed). Without the file it logs a warning
and the demo is silent.
Make a game like this¶
- Copy the folder.
cp -r games/melt_demo games/my_lava, rename the target in CMakeLists.txt (including thegame.jsoncopy), the namespacekke_meltandname(), and addadd_subdirectory(games/my_lava)to the rootCMakeLists.txt.tools/new_gamemakes Lua-only games fromgames/template; liquids have no Lua bindings, so this starts from C++. - Decide on your liquids. Up to eight
FluidMaterials share oneParticleFluid. Water: low viscosity, no solidify temperature. Honey or mud: high cold viscosity. Lava: a solidify temperature. - Give the liquid a world to hit. Anything that can return a signed
distance and a normal can be a collider (
addCollider): a box, a ramp, a heightfield. SetboundsMin/boundsMaxto your level's size. - Add meltable things. One
MeltVolumeper object, positioned by itsorigin. Each needs its ownstepcall and its own collider line. Keep the grid small: its cost grows with the cube of its size. - Choose a look. Keep
FluidSurfaceRendererfor a liquid, or draw the spheres for a granular look (sand, pellets). - Add rules.
solidFraction()tells you how much of a block is left, so "melt the ice door to get through" is a check against it. - Read next: OPTIMIZATION.md (rule 5 on budgets), DEMO_PANEL.md, RENDERING_PRINCIPLES.md.
Pitfalls the code shows:
- The fluid's budget is shared: melt particles count against it too. A
game that pours forever must remove old particles (
removeIf) or it stops pouring. - Keep XSPH viscosity at or below about 0.15, or blobs move as rigid lumps.
- Fast streams need substeps (or smaller time steps), or they pass through thin colliders.
- Timings here come from
std::chronoaround each step and are smoothed; the demo was tuned on a software GPU at about 8 FPS (docs/HARDWARE_TESTS.md HW-012), so check speed on real hardware. - Prepass work must check
sceneCoveredif it only feeds the 3D scene.
Files¶
| File | What is in it |
|---|---|
| main.cpp | The application, the mood, the module list and the camera |
| MeltDemoModule.h | The module, the BlockPreset struct, all state |
| MeltDemoModule.cpp | The four presets, reset, pouring, stepping, the block mesh, particle colours, drawing, input actions, the settings panel |
| game.json | Marketplace manifest (id, title, tags, modules) |
| CMakeLists.txt | The executable, its shaders (including fluid_depth, fluid_blur, fluid_composite), the manifest, and kke_use_ui (the RmlUi shaders and fonts) |