Painted Waterfall

Paint on the waterfall to change the colors

Source Code for this Sketch

let colorField

let particles = []
let particleCount = 1000
let particleSize = 35
let colorTool = true
let toolRadius = 50
let lastX = 0
let lastY = 0

let dragStarted = false
let dragFrames = 0

class ToolVector {
    static zero() {
        return new ToolVector(0, 0, 0, 0)
    }

    constructor(x, y, dx, dy) {
        this.x = x
        this.y = y
        this.dx = dx
        this.dy = dy
    }

    get position() {
        return createVector(this.x, this.y)
    }

    get delta() {
        return createVector(this.dx, this.dy)
    }

    isZero() {
        return this.dx === 0 && this.dy === 0
    }
}

class Particle {
    constructor() {
        // initialize in a random position on screen
        this.v = createVector(0, 0);
        this.random()
        this.color = color(90)
    }

    get x() {
        return this.v.x
    }

    get y() {
        return this.v.y
    }

    random() {
        this.v.x = random(0, width)
        this.v.y = random(0, height)
    }

    flow(vec) {
        this.v.x += vec.x
        this.v.y += vec.y

        // wrap around, but random
        if(this.x > width) {
            this.v.x = 0
            this.v.y = random(0, height)
        }
        if(this.y > height) {
            this.v.y = 0
            this.v.x = random(0, width)
        }
        if(this.x < 0) {
            this.v.x = width
            this.v.y = random(0, height)
        }
        if(this.y < 0) {
            this.v.y = height
            this.v.x = random(0, width)
        }
    }

    draw() {
        fill(this.color)
        circle(this.v.x, this.v.y, particleSize)
    }
}

function initializeParticles() {
    // Particle initialization
    for (let i = 0; i < particleCount; i++) {
        if (particles[i]) {
            continue
        }
        particles[i] = new Particle()
    }
    particles = particles.slice(0, particleCount)
}

function initializeFlowField(x, y) {
    return createVector(0, .4)
}

function initializeColorField(x, y) {
    return createVector(0, 0)
}

function setup() {
    createCanvas(800, 600, P2D, canvas)
    chrisDefaults()

    colorField = new FlowField(10, 10, 20, initializeColorField, createVector(0, 0))
    colorField.resizeToCanvas()

    initializeParticles()
}

function getMouseFlow() {
    // this is jumpy.
    // TODO We should probably do some kind of smoothing by sampling
    //  lastX and lastY as an array or rolling window and averaging them to
    //  reduce jumpiness. For example, continuously collect the last 5
    //  measurements and then average when returning
    const xyTreshold = 3
    const frameTreshold = 3

    if(!mouseIsPressed) {
        dragStarted = false
        dragFrames = 0
    }

    // At beginning of drag track the origin position of the mouse
    if(mouseIsPressed && !dragStarted) {
        dragStarted = true
        lastX = mouseX
        lastY = mouseY
    }

    // If the mouse button is held, increment drag frames
    if(mouseIsPressed) {
        dragFrames++
    }

    let vector = ToolVector.zero()

    if (dragFrames > 0 && (mouseX-lastX > xyTreshold || mouseY-lastY > xyTreshold || dragFrames > frameTreshold)) {
        vector = new ToolVector(lastX, lastY, mouseX-lastX, mouseY-lastY)
        lastX = mouseX
        lastY = mouseY
    }

    if(!colorTool) {
        vector.dx = 0
        vector.dy = 0
    }

    return vector
}

function visitGrid(x, y, radius, func) {
    radius /= colorField.scale
    x/=colorField.scale
    y/=colorField.scale

    let distance = Number.MAX_SAFE_INTEGER
    let xTest = 0
    let yTest = 0

    // Looking at the whole grid is expensive, so only look at the part
    // near where we clicked.
    let startI = max(floor(x-radius-1), 0)
    let startJ = max(floor(y-radius-1), 0)
    let endI = min(floor(x+radius+1), colorField.width)
    let endJ = min(floor(y+radius+1), colorField.height)

    for (let i = startI; i < endI; i++) {
        for (let j = startJ; j < endJ; j++) {
            // We still need to check whether we're in the radius of
            // the tool because the corners won't be, but whether we
            // include a block on the edge or not depends on the tool
            // radius.
            xTest = x/colorField.scale
            yTest = y/colorField.scale

            if (x < i) {
                xTest = i
            } else if (x > i) {
                xTest = i+1
            }

            if (y < j) {
                yTest = j
            } else if (y > j) {
                yTest = j+1
            }

            distance = sqrt(pow(x - xTest, 2) + pow(y - yTest, 2))
            if(distance <= radius) {
                // debug view to see brush influence
                // rect(i*colorField.scale, j*colorField.scale, colorField.scale, colorField.scale)
                colorField.field[i][j] = func(colorField.field[i][j], distance)
            }
        }
    }
}

function weightedVector(x, y, dx, dy, distance) {
    // Tune the exponent to increase the "strength" of the brush
    let factor = 1 - pow(distance/(toolRadius/colorField.scale), 4)
    let dfactor = 1-factor
    return createVector(x*factor+dx*dfactor, y*factor+dy*dfactor)
}

function applyFlow(vector) {
    // Don't let the color tool apply grey. This is a janky way to do it
    // but we're not going to reuse this elsewhere (probably)
    if (vector.isZero() && colorTool) {
        return
    }

    visitGrid(vector.x, vector.y, toolRadius, (gridVector, distance) => {
        // factor determines the amount of speed we add to the vector based
        // on the delta (in pixels). Speed should always be 0-1, and we'll take
        // a delta of 10 pixels to be the highest allowed speed, but we'll use
        // 10 as a baseline to allow smaller increments.
        if(vector.isZero()) {
            return createVector(0, 0)
        }

        let factor = 10

        let m = max(abs(vector.delta.x), abs(vector.delta.y))
        if(m > factor) {
            factor = m
        }

        return weightedVector(gridVector.x, gridVector.y, vector.delta.x/factor, vector.delta.y/factor, distance)
    })
}

function showParticleTool(x, y, radius) {
    const offset = radius*.7+20

    noStroke()
    fill(0, 100, 50)
    circle(x+offset-5, y+offset-15, 10)
    fill(100, 100, 50)
    circle(x+offset+1, y+offset-5, 10)
    fill(200, 100, 50)
    circle(x+offset-11, y+offset-5, 10)
}

function showEraseTool(x, y, radius) {
    const offset = radius*.7+10
    strokeWeight(1)
    stroke(0)
    fill(90)
    circle(x+offset, y+offset, 20)
}

function showToolRadius(x, y, radius, hue, func) {
    strokeWeight(1)
    stroke(hue, 100, 50, .5)
    fill(hue, 100, 50, .2)
    circle(x, y, radius*2)
    if (func) {
        func()
    }
}

function inRect(x, y, w, h) {
    return (mouseX >= x && mouseX < x+w && mouseY >= y && mouseY < y+h)
}

function draw() {
    background(100)

    noStroke()

    // Draw flow colorField
    let c
    for (let i = 0; i < particles.length; i++) {
        particles[i].flow(createVector(0, .4))

        c = colorField.at(particles[i].x, particles[i].y)
        particles[i].color = color(c.heading()+180, c.mag() < .00001 ? 0 : 100, 90-(c.mag() * 40))
        particles[i].draw()
    }


    showToolRadius(mouseX, mouseY, toolRadius, 220)
    if(colorTool) {
        showParticleTool(mouseX, mouseY, toolRadius)
    } else {
        showEraseTool(mouseX, mouseY, toolRadius)
    }

    if (inRect(0, 0, width, height)) {
        applyFlow(getMouseFlow())
    }
}

canvas.addEventListener("wheel", (e) => {
    if(e.wheelDelta < 0) {
        toolRadius -= 10
    } else {
        toolRadius += 10
    }
    if(toolRadius < 10) {
        toolRadius = 10
    }
    if (toolRadius > 100) {
        toolRadius = 100
    }
})

document.addEventListener("DOMContentLoaded", () => {
    let p5ui = document.getElementById("p5ui")
    p5ui.innerHTML = `
<table>
    <tr><td colspan="2">Use the mouse wheel to increase or decrease the size of the brush.<br>
    Click and drag to paint. Painting direction determines the color.</td></tr>
    <tr>
        <td>
            <label for="particlesInput">Particles</label>
            <input id="particlesInput" type="range"
                   min="1000" max="3000" step="10" value="1000">
            <span id="particlesValue" class="mono">1000</span>
        </td>
        <td>Change the number of particles</td>
    </tr>
    <tr>
        <td>
            <button id="resetColors">Reset Colors</button>
            <span style="width: 5em; display: inline-block;"></span>
            <button id="colorTool">
            <span style="color:red;">C</span><span style="color:purple;">o</span><span style="color:blue;">l</span><span style="color:green;">o</span><span style="color:orange;">r</span> tool
            </button>
            <button id="eraserTool">Eraser</button>
        </td>
    </tr>
</table>
	`
    let particlesInput = document.getElementById("particlesInput")
    let particlesValue = document.getElementById("particlesValue")

    let resetColorsButton = document.getElementById("resetColors")
    let colorToolButton = document.getElementById("colorTool")
    let eraserToolButton = document.getElementById("eraserTool")

    particlesInput.addEventListener("input", (e) => {
        particleCount = Math.round(Number(e.target.value))
        particleSize = 5 + 10*3000/particleCount

        particlesValue.innerText = String(particleCount)
        initializeParticles()
    })

    resetColorsButton.addEventListener("click", (e) => {
        colorField.visit(initializeColorField)
    })

    colorToolButton.addEventListener("click", (e) => {
        colorTool = true
    })

    eraserToolButton.addEventListener("click", (e) => {
        colorTool = false
    })
})

You may use all or part of the p5 code seen on this page for your own creations, without restriction. Attribution is appreciated but not required.

This sketch also includes the following sources: p5common.js . Sources are available under their respective licenses.

See all p5.js sketches