SIMULATION / FREE / MIT EXAMPLE SOURCE

Chromaflow

Paint with motion

A colorful 2D fluid playground with HDR-range emissive dye, soft bloom, exposure controls, mouse momentum, and swirling fountains.

Chromaflow, running as a native Yeho application on Windows
Actual native application capture. The downloadable source produces this example.

Take it for a spin.

Drag on the canvas to inject dye and momentum. C cycles magenta, cyan, and gold; A toggles fountains; Space pauses; R clears. B toggles bloom. Up/Down adjust exposure. Floating-point radiance is tone-mapped for the SDR native window.

Extract the Windows download to a writable folder and open color-fluid.exe. Escape closes it. Database examples keep their local data in the working folder.

See how it works.

Yeho grid state, Dolphin math, bilinear sampling, semi-Lagrangian advection, a pressure solve, and incompressible-flow approximation. HDR radiance, highlight extraction, separable bloom, and filmic tone mapping.

A 96 × 64 CPU teaching solver with fixed walls, finite pressure iterations, and dye decay. Not a calibrated engineering simulator.

READ / CHANGE / UNDERSTAND

The whole example is yours.

These are the exact files in the source download. Start with the model, then follow the window’s event loop. Comments explain the decisions.

model.yh
using dolphin.math
// A small Eulerian fluid grid stores velocity and three dye concentrations.
// Units: cells, seconds, and HDR dye radiance 0..12. The method is stable-fluids:
// semi-Lagrangian advection + iterative pressure projection. It is a teaching
// approximation of incompressible flow, not a calibrated engineering solver.
class Fluid
{
    int width=96
    int height=64
    list of float vx=[]
    list of float vy=[]
    list of float red=[]
    list of float green=[]
    list of float blue=[]
    Fluid()
    {
        this.Reset()
    }
    Reset()
    {
        this.vx=[]
        this.vy=[]
        this.red=[]
        this.green=[]
        this.blue=[]
        for i from 0 < this.width*this.height
        {
            this.vx.Add(0.0)
            this.vy.Add(0.0)
            this.red.Add(0.0)
            this.green.Add(0.0)
            this.blue.Add(0.0)
        }
    }
    Sample(list of float grid,float x,float y) returns float
    {
        // Backtraced points are clamped inside the wall. Bilinear interpolation
        // samples four cells, avoiding the blocky movement of nearest neighbours.
        x=DolphinClampFloat(x,0.5,Math.IntToFloat(this.width)-1.5)
        y=DolphinClampFloat(y,0.5,Math.IntToFloat(this.height)-1.5)
        int ix=Math.FloorToInt(x)
        int iy=Math.FloorToInt(y)
        float fx=x-Math.IntToFloat(ix)
        float fy=y-Math.IntToFloat(iy)
        int at=iy*this.width+ix
        return (grid[at]*(1.0-fx)+grid[at+1]*fx)*(1.0-fy)+(grid[at+this.width]*(1.0-fx)+grid[at+this.width+1]*fx)*fy
    }
    Splat(float x,float y,float dx,float dy,float r,float g,float b)
    {
        for row from 1 < this.height-1
        {
            for col from 1 < this.width-1
            {
                float xx=Math.IntToFloat(col)-x
                float yy=Math.IntToFloat(row)-y
                float distance=xx*xx+yy*yy
                if distance<64.0
                {
                    int i=row*this.width+col
                    float falloff=1.0-distance/64.0
                    falloff=falloff*falloff
                    this.vx[i]=DolphinClampFloat(this.vx[i]+dx*falloff,-80.0,80.0)
                    this.vy[i]=DolphinClampFloat(this.vy[i]+dy*falloff,-80.0,80.0)
                    this.red[i]=DolphinClampFloat(this.red[i]+r*falloff,0.0,12.0)
                    this.green[i]=DolphinClampFloat(this.green[i]+g*falloff,0.0,12.0)
                    this.blue[i]=DolphinClampFloat(this.blue[i]+b*falloff,0.0,12.0)
                }
            }
        }
    }
    Walls()
    {
        for y from 0 < this.height
        {
            this.vx[y*this.width]=0
            this.vx[y*this.width+this.width-1]=0
            this.vy[y*this.width]=0
            this.vy[y*this.width+this.width-1]=0
        }
        for x from 0 < this.width
        {
            this.vx[x]=0
            this.vy[x]=0
            this.vx[(this.height-1)*this.width+x]=0
            this.vy[(this.height-1)*this.width+x]=0
        }
    }
    Divergence() returns float
    {
        float sum=0
        for y from 1 < this.height-1
        {
            for x from 1 < this.width-1
            {
                int i=y*this.width+x
                float d=(this.vx[i+1]-this.vx[i-1]+this.vy[i+this.width]-this.vy[i-this.width])*0.5
                sum=sum+d*d
            }
        }
        return sum/Math.IntToFloat(this.width*this.height)
    }
    Project()
    {
        this.Walls()
        list of float divergence=[]
        list of float pressure=[]
        for i from 0 < this.vx.count
        {
            divergence.Add(0.0)
            pressure.Add(0.0)
        }
        for y from 1 < this.height-1
        {
            for x from 1 < this.width-1
            {
                int i=y*this.width+x
                divergence[i]=(this.vx[i+1]-this.vx[i-1]+this.vy[i+this.width]-this.vy[i-this.width])*0.5
            }
        }
        // Jacobi iterations solve Laplacian(pressure)=divergence. Ping-pong value
        // lists keep every neighbour read on the previous iteration's snapshot.
        for iteration from 0 < 28
        {
            list of float next=pressure
            for y from 1 < this.height-1
            {
                for x from 1 < this.width-1
                {
                    int i=y*this.width+x
                    next[i]=(pressure[i-1]+pressure[i+1]+pressure[i-this.width]+pressure[i+this.width]-divergence[i])*0.25
                }
            }
            pressure=next
        }
        for y from 1 < this.height-1
        {
            for x from 1 < this.width-1
            {
                int i=y*this.width+x
                this.vx[i]=this.vx[i]-(pressure[i+1]-pressure[i-1])*0.5
                this.vy[i]=this.vy[i]-(pressure[i+this.width]-pressure[i-this.width])*0.5
            }
        }
        this.Walls()
    }
    Tick(float dt)
    {
        // Advect velocity with its previous value, then remove compressive motion.
        list of float oldX=this.vx
        list of float oldY=this.vy
        for y from 1 < this.height-1
        {
            for x from 1 < this.width-1
            {
                int i=y*this.width+x
                float bx=Math.IntToFloat(x)-oldX[i]*dt
                float by=Math.IntToFloat(y)-oldY[i]*dt
                this.vx[i]=this.Sample(oldX,bx,by)*0.998
                this.vy[i]=this.Sample(oldY,bx,by)*0.998
            }
        }
        this.Project()
        list of float oldR=this.red
        list of float oldG=this.green
        list of float oldB=this.blue
        float fade=1.0-dt*0.08
        for y from 1 < this.height-1
        {
            for x from 1 < this.width-1
            {
                int i=y*this.width+x
                float bx=Math.IntToFloat(x)-this.vx[i]*dt
                float by=Math.IntToFloat(y)-this.vy[i]*dt
                this.red[i]=this.Sample(oldR,bx,by)*fade
                this.green[i]=this.Sample(oldG,bx,by)*fade
                this.blue[i]=this.Sample(oldB,bx,by)*fade
            }
        }
    }
    Bloom(list of float radiance) returns list of float
    {
        // Extract only radiance above diffuse white. A separable five-tap Gaussian
        // spreads highlights without smearing the velocity or dye simulation itself.
        list of float horizontal=[]
        list of float glow=[]
        list of float weights=[1.0,4.0,6.0,4.0,1.0]
        for y from 0 < this.height
        {
            for x from 0 < this.width
            {
                float sum=0
                for tap from -2 <= 2
                {
                    int sx=DolphinClampInt(x+tap*2,0,this.width-1)
                    sum=sum+Math.MaxFloat(radiance[y*this.width+sx]-0.8,0.0)*weights[tap+2]
                }
                horizontal.Add(sum/16.0)
            }
        }
        for y from 0 < this.height
        {
            for x from 0 < this.width
            {
                float sum=0
                for tap from -2 <= 2
                {
                    int sy=DolphinClampInt(y+tap*2,0,this.height-1)
                    sum=sum+horizontal[sy*this.width+x]*weights[tap+2]
                }
                glow.Add(sum/16.0)
            }
        }
        return glow
    }
    Paint(buffer of int pixels,float exposure=0.8,bool bloom=true)
    {
        list of float glowR=this.Bloom(this.red)
        list of float glowG=this.Bloom(this.green)
        list of float glowB=this.Bloom(this.blue)
        float glow=0.0
        if bloom
        {
            glow=0.65
        }
        for i from 0 < pixels.count
        {
            int r=ToneMap(this.red[i]+glowR[i]*glow,exposure)
            int g=ToneMap(this.green[i]+glowG[i]*glow,exposure)
            int b=ToneMap(this.blue[i]+glowB[i]*glow+0.006,exposure)
            pixels[i]=b | (g<<8) | (r<<16) | (255<<24)
        }
    }
}
ToneMap(float radiance,float exposure) returns int
{
    // A filmic shoulder preserves bright detail above 1.0. Gamma approximation
    // encodes the result for this BGRA8 SDR window; it is not an HDR10 swapchain.
    float x=Math.MaxFloat(radiance*exposure,0.0)
    float mapped=(x*(2.51*x+0.03))/(x*(2.43*x+0.59)+0.14)
    return Math.RoundToInt(Math.SqrtFloat(Math.ClampFloat(mapped,0.0,1.0))*255.0)
}