neolateral

programming, drawing, photograpy etc.

This is another simulation based on one of the "Coding Train" channel's videos Coding Challenge #124: Flocking Simulation. It is based on the original work - Boids by Craig Reynolds. The simulation is built using love2d.

Boids are "flocking creatures" which simulate the behaviour of birds and fishes when flying or swimming together respectively. It is based on the autonomous behaviour of each organism in the group which tries to move based on changes around it.

Each boid tries to stay together with the boids around it, but not too close. It also tries to move in the general direction of the boids around it. These behaviours can be programmed into the autonomous boid based on three simple rules:

  1. Separation: Move in such a way that one avoids a collision with, or coming too close to a nearby organism/boid.
  2. Alignment: Move in the same average direction as the nearby boids.
  3. Cohesion: Move towards the average location of the nearby boids.

The above rules have some parameters which can be tweaked, especially the definition of nearby.

The movement of each boid is a combination of the acceleration computed by evaluating each of the above three rules separately. The values of separation, alignment, and cohesion can be combined in various proportions to make the final acceleration of the individual boid. This leads remarkably different flocking behaviours from the boids.

The following video shows some of the possibilities of the simulation.

Code

The following program implements the flocking simulation in Love2D. The latest version of the program can be found on github at flocking-sim-boids exploration

boid.lua

--- boid.lua - Boid class for the flocking simulation
--
-- date: 23/3/2024
-- author: Abhishek Mishra

local class = require('middleclass')
local nl = require('ne0luv')
local Vector = nl.Vector

--- Boid class
local Boid = class('Boid')

--- constructor of the Boid class with a given position
-- if no position is provided, it is random
--@param x the x position of the boid
--@param y the y position of the boid
function Boid:initialize(panel, x, y)
    self.panel = panel
    -- position is random if not provided
    x = x or math.random(self.panel:getX(), self.panel:getWidth())
    y = y or math.random(self.panel:getY(), self.panel:getHeight())
    self.position = Vector(x, y)
    self.velocity = Vector.random2D()
    -- get random value in range 0.5 to 1.5
    local m = math.random(20, 40)/10
    self.velocity:setMag(m)
    self.acceleration = Vector(0, 0)
    self.maxForce = 0.2
    self.maxSpeed = 5
    self.boidWidth = 5
end

--- check if the boid is within the screen
-- and wrap around if it goes out of bounds
function Boid:edges()
    if self.position.x + self.boidWidth > self.panel:getWidth() then
        self.position.x = self.panel:getX() + self.boidWidth
    elseif self.position.x - self.boidWidth < self.panel:getX() then
        self.position.x = self.panel:getWidth() - self.boidWidth
    end
    if self.position.y + self.boidWidth > self.panel:getHeight() then
        self.position.y = self.panel:getY() + self.boidWidth
    elseif self.position.y - self.boidWidth < self.panel:getY() then
        self.position.y = self.panel:getHeight() - self.boidWidth
    end
end

--- flock the boid with the other boids
-- @param boids the list of boids
function Boid:flock(boids, alignmentSlider, cohesionSlider, separationSlider)
    local alignment = self:align(boids)
    local cohesion = self:cohesion(boids)
    local separation = self:separation(boids)

    alignment = alignment * alignmentSlider.currentValue
    cohesion = cohesion * cohesionSlider.currentValue
    separation = separation * separationSlider.currentValue

    self.acceleration = self.acceleration + separation
    self.acceleration = self.acceleration + alignment
    self.acceleration = self.acceleration + cohesion
end

--- align the boid with the other boids
-- @param boids the list of boids
-- @return the alignment force
function Boid:align(boids)
    local perceptionRadius = 25
    local steering = Vector(0, 0)
    local total = 0
    for _, boid in ipairs(boids) do
        local distance = self.position:dist(boid.position)
        if boid ~= self and distance > 0 and distance < perceptionRadius then
            steering = steering + boid.velocity
            total = total + 1
        end
    end
    if total > 0 then
        steering = steering / total
        steering:setMag(self.maxSpeed)
        steering = steering - self.velocity
        steering:limit(self.maxForce)
    end
    return steering
end

--- separation of the boid with the other boids
-- @param boids the list of boids
-- @return the separation force
function Boid:separation(boids)
    local perceptionRadius = 24
    local steering = Vector(0, 0)
    local total = 0
    for _, boid in ipairs(boids) do
        local distance = self.position:dist(boid.position)
        if boid ~= self and distance > 0 and distance < perceptionRadius then
            local diff = self.position - boid.position
            diff = diff / (distance * distance)
            steering = steering + diff
            total = total + 1
        end
    end
    if total > 0 then
        steering = steering / total
        steering:setMag(self.maxSpeed)
        steering = steering - self.velocity
        steering:limit(self.maxForce)
    end
    return steering
end

--- cohesion of the boid with the other boids
-- @param boids the list of boids
-- @return the cohesion force
function Boid:cohesion(boids)
    local perceptionRadius = 50
    local steering = Vector(0, 0)
    local total = 0
    for _, boid in ipairs(boids) do
        local distance = self.position:dist(boid.position)
        if boid ~= self and distance > 0 and distance < perceptionRadius then
            steering = steering + boid.position
            total = total + 1
        end
    end
    if total > 0 then
        steering = steering / total
        steering = steering - self.position
        steering:setMag(self.maxSpeed)
        steering = steering - self.velocity
        steering:limit(self.maxForce)
    end
    return steering
end

--- update the boid
function Boid:update()
    self.position = self.position + self.velocity
    self.velocity = self.velocity + self.acceleration
    self.velocity:limit(self.maxSpeed)
    self.acceleration = self.acceleration * 0
end

--- show the boid on the screen
function Boid:show()
    -- only draw the boid if it is within the panel
    local inside = self.panel.rect:contains(self.position.x, self.position.y)
    if not inside then
        return
    end

    -- set the color of the boid
    love.graphics.setColor(1, 1, 1)

    -- draw boid as a circle in the direction of the velocity
    love.graphics.push()
    love.graphics.translate(self.position.x, self.position.y)
    local theta = self.velocity:heading() - math.pi/2
    love.graphics.rotate(theta)
    --- three lines of the triangle, base width is half of the boid width
    local baseWidth = self.boidWidth/2
    -- base line
    love.graphics.line(-baseWidth, -baseWidth, baseWidth, -baseWidth)
    -- right line
    love.graphics.line(baseWidth, -baseWidth, 0, self.boidWidth)
    -- left line
    love.graphics.line(-baseWidth, -baseWidth, 0, self.boidWidth)
    love.graphics.pop()

    -- draw the boid as a circle
    -- love.graphics.circle('fill', self.position.x, self.position.y, self.boidWidth-2)
end

return Boid

main.lua

--- main.lua: Flocking Simulation in LÖVE using boids. Based on a video by
-- Daniel Shiffman on the Coding Train youtube channel.
--
-- date: 23/3/2024
-- author: Abhishek Mishra

local Class = require('middleclass')
local Boid = require('boid')
local nl = require('ne0luv')
local Layout = nl.Layout
local Rect = nl.Rect
local Slider = nl.Slider
local Panel = nl.Panel
local Text = nl.Text

local cw, ch
local top
local boidPanel
local controlPanel
local cpWidth = 150

----- ControlPanel ------

-- A Control Panel Class which extends Layout
local ControlPanel = Class('ControlPanel', Layout)

function ControlPanel:initialize(w, h)
    Layout.initialize(self, Rect(0, 0, w, h),
        {
            layout = 'column',
            bgColor = { 0.1, 0.1, 0.5 },
        }
    )

    local itemH = 20

    -- sliders
    self.alignmentSlider = nil
    self.cohesionSlider = nil
    self.separationSlider = nil

    -- slider labels
    local alignmentLabel
    local cohesionLabel
    local separationLabel

    -- fps text display
    self.fpsText = nil

    ----------- Alignment Slider ------------
    -- create and add alignment slider label
    alignmentLabel = Text(
        Rect(0, 0, cpWidth, 20),
        {
            text = 'Alignment:',
            bgColor = { 0.2, 0.2, 0, 1 },
            align = 'center'
        }
    )
    self:addChild(alignmentLabel)

    -- create and add alignment slider
    self.alignmentSlider = Slider(
        Rect(0, 0, cpWidth, 20),
        {
            minValue = 0,
            maxValue = 1.5,
            currentValue = 0.5,
            bgColor = { 0.2, 0.2, 0, 1 }
        }
    )

    self.alignmentSlider:addChangeHandler(function(value)
        -- trim value to 2 decimal places
        value = math.floor(value * 100) / 100
        alignmentLabel:setText('Alignment: ' .. value)
    end)

    self:addChild(self.alignmentSlider)

    -- set initial Alignment value
    alignmentLabel:setText('Alignment: ' .. self.alignmentSlider.currentValue)

    -- add empty panel
    self:separator(20)

    ----------- Cohesion Slider ------------

    -- create and add cohesion slider label
    cohesionLabel = Text(
        Rect(0, 0, cpWidth, 20),
        {
            text = 'Cohesion:',
            bgColor = { 0.2, 0.2, 0, 1 },
            align = 'center'
        }
    )

    self:addChild(cohesionLabel)

    self.cohesionSlider = Slider(
        Rect(0, 0, cpWidth, 20),
        {
            minValue = 0,
            maxValue = 1,
            currentValue = 0.1,
            bgColor = { 0.2, 0.2, 0, 1 }
        }
    )

    self.cohesionSlider:addChangeHandler(function(value)
        -- trim value to 2 decimal places
        value = math.floor(value * 100) / 100
        cohesionLabel:setText('Cohesion: ' .. value)
    end)

    self:addChild(self.cohesionSlider)
    -- set initial Cohesion
    cohesionLabel:setText('Cohesion: ' .. self.cohesionSlider.currentValue)

    -- separator
    self:separator(20)

    ----------- Separation Slider ------------

    separationLabel = Text(
        Rect(0, 0, cpWidth, 20),
        {
            text = 'Separation:',
            bgColor = { 0.2, 0.2, 0, 1 },
            align = 'center'
        }
    )

    self:addChild(separationLabel)

    self.separationSlider = Slider(
        Rect(0, 0, cpWidth, 20),
        {
            minValue = 0,
            maxValue = 1,
            currentValue = 0.4,
            bgColor = { 0.2, 0.2, 0, 1 }
        }
    )

    self.separationSlider:addChangeHandler(function(value)
        -- trim value to 2 decimal places
        value = math.floor(value * 100) / 100
        separationLabel:setText('Separation: ' .. value)
    end)

    self:addChild(self.separationSlider)
    -- set initial Separation
    separationLabel:setText('Separation: ' .. self.separationSlider.currentValue)

    -- separator
    -- total height for 3 sliders, 3 labels, and 2 separators, each itemH
    -- in height. Add one more itemH for the fps text display.
    local totalHeight = itemH * (3 + 3 + 2 + 1);
    self:separator(ch - totalHeight)

    self.fpsText = Text(
        Rect(0, 0, cpWidth, 20),
        {
            text = 'FPS: 0',
            bgColor = { 0.2, 0.2, 0, 1 },
            fgColor = { 1, 0, 0, 1},
            align = 'center'
        }
    )
    self:addChild(self.fpsText)
end

--- Create and add a separator to the control panel, with the 
-- specified height.
-- @param height The height of the separator
function ControlPanel:separator(height)
    local emptyPanel = Panel(
        Rect(0, 0, cpWidth, height),
        {
            bgColor = { 0.2, 0.2, 0, 1 }
        }
    )
    self:addChild(emptyPanel)
end

------- Main Program -------

-- random seed
math.randomseed(os.time())

local boids

local function initBoids()
    boids = {}
    for i = 1, 300 do
        local b = Boid(boidPanel)
        table.insert(boids, b)
    end
end

--- love.load: Called once at the start of the simulation
function love.load()
    cw, ch = love.graphics.getWidth(), love.graphics.getHeight()
    top = Layout(
        Rect(0, 0, cw, ch),
        {
            bgColor = { 0.1, 0.1, 0.1 },
            layout = 'row',
        }
    )

    boidPanel = Layout(
        Rect(0, 0, cw - cpWidth, ch),
        {
            bgColor = { 0.1, 0.5, 0.1 },
        }
    )
    top:addChild(boidPanel)

    controlPanel = ControlPanel(cpWidth, ch)

    top:addChild(controlPanel)

    top:show()

    initBoids()
end

--- love.update: Called every frame, updates the simulation
function love.update(dt)
    top:update(dt)

    for _, boid in ipairs(boids) do
        boid:edges()
        boid:flock(boids, controlPanel.alignmentSlider, controlPanel.cohesionSlider, controlPanel.separationSlider)
        boid:update()
    end
end

--- love.draw: Called every frame, draws the simulation
function love.draw()
    --update fps
    controlPanel.fpsText:setText('FPS: ' .. love.timer.getFPS())

    top:draw()

    for _, boid in ipairs(boids) do
        boid:show()
    end
end

-- escape to exit
function love.keypressed(key)
    if key == "escape" then
        love.event.quit()
    end
end

--- love.mousepressed: Called when a mouse button is pressed
function love.mousepressed(x, y, button, istouch, presses)
    top:mousepressed(x, y, button, istouch, presses)
end

--- love.mousereleased: Called when a mouse button is released
function love.mousereleased(x, y, button, istouch, presses)
    top:mousereleased(x, y, button, istouch, presses)
end

--- love.mousemoved: Called when the mouse is moved
function love.mousemoved(x, y, dx, dy, istouch)
    top:mousemoved(x, y, dx, dy, istouch)
end