neolateral

programming, drawing, photograpy etc.

Snowflakes Simulation

A simple snowflakes simulation to show snowfall in LOVE2D. Snowflakes are generated using LOVE2D's particle system.

This simulation is heavily inspired by Dan Shiffman's video about snowflakes "Coding Challenge #88: Snowfall".

This youtube video shows a demo of the simulation

The code for the project is at https://github.com/abhishekmishra/explorations/snowflakes

To run this yourself clone the repo and run it using love2d.

# clone the repo
git clone https://github.com/abhishekmishra/explorations.git

# goto the snowflakes folder
cd explorations/snowflakes

# run love2d in the current folder
love .

Credits

Artwork used in the simulation

Pixel Art for the Snowflakes

Artist: alxl

Artwork Page: pixel-art-snowflakes

License: CC-0

Background Music

Artist: caliderium

Artwork Page: a-lucid-dream

License: CC-BY 3.0

Code

snowflake.lua

--- snowflake.lua -- Snowflake class
--
-- date: 09/04/2024
-- author: Abhishek Mishra

local Class = require 'middleclass'

-- Load the sprite sheet
-- The sprite sheet contains 6 tiles in a row and 3 tiles in a column
-- Each tile is 9x9 pixels
-- The sprite sheet is available at
-- https://opengameart.org/content/pixel-art-snowflakes
-- made by https://opengameart.org/users/alxl
local spriteSheet = love.graphics.newImage("pixel_snowflakes.png")

-- Define the size of each tile
local tileWidth, tileHeight = 9, 9

-- Define the number of tiles per row and column
local tilesPerRow, tilesPerColumn = 6, 3

-- Create a table to hold the quads
local quads = {}

-- Create quads for each tile in the sprite sheet
for y = 0, tilesPerColumn - 1 do
    for x = 0, tilesPerRow - 1 do
        -- Calculate the position of the tile in the sprite sheet
        local quad = love.graphics.newQuad(x * tileWidth, y * tileHeight,
            tileWidth, tileHeight, spriteSheet:getDimensions())
        table.insert(quads, quad)
    end
end

-- Define the SnowFlake class
local SnowFlake = Class('SnowFlake')

function SnowFlake:initialize(x, y, size)
    self.origx = x
    self.origy = y
    self.x = self.origx
    self.y = self.origy
    self.size = size
    self.speed = math.random(50, 100)
    self.direction = math.random() * 2 * math.pi

    self:setParticleSystem()
end

--- SnowFlake:setParticleSystem: Set the particle system for the snowflake
function SnowFlake:setParticleSystem()
    -- use a random quad from the sprite sheet
    self.quad = quads[math.random(1, #quads)]
    self.psystem = love.graphics.newParticleSystem(spriteSheet, 32)
    self.psystem:setQuads(self.quad)

    -- particles live at least 1s and at most 5-20s
    self.psystem:setParticleLifetime(1, math.random(5, 20))

    -- set emission rate to 1 particle per second
    self.psystem:setEmissionRate(1)

    -- only emit for 1.5s which means only 1 particle will be emitted
    self.psystem:setEmitterLifetime(1.5)

    -- full size variation (1 is max)
    self.psystem:setSizeVariation(1)

    -- set linear acceleration to -20, 20 on x-axis
    -- and -10, 50 on y-axis
    -- Random movement in all directions.
    self.psystem:setLinearAcceleration(-20, -10, 20, 50)

    -- set radial acceleration to -10, 10
    -- Random acceleration towards/away the center.
    self.psystem:setRadialAcceleration(-10, 10)

    -- set spin to -2, 2
    -- Random spin.
    self.psystem:setSpin(-2, 2)

    -- set colors to white
    -- Fade to transparency.
    self.psystem:setColors(1, 1, 1, 1, 1, 1, 1, 0)

    -- set sizes to 1, 1.5, 1.1, 0.75, 0.25
    -- Random sizes for the snowflake.
    self.psystem:setSizes(1, 1.5, 1.1, 0.75, 0.25, 2, 3.3, 4)
end

function SnowFlake:update(dt)
    self.y = self.y + self.speed * dt
    self.x = self.x + math.sin(self.direction) * self.size * dt

    --if the x position crosses the screen boundaries, wrap it around
    if self.x < 0 then
        self.x = love.graphics.getWidth()
    elseif self.x > love.graphics.getWidth() then
        self.x = 0
    end

    -- if the snowflake is beyond the bottom of the screen, reset it to
    -- origx, origy
    if self:beyondScreen(love.graphics.getHeight()) then
        self.x = self.origx
        self.y = self.origy
        self:setParticleSystem()
    end

    self.psystem:update(dt)
end

function SnowFlake:draw()
    love.graphics.draw(self.psystem, self.x, self.y)
end

--- SnowFlake:beyondScreen: Check if the snowflake is beyond the bottom of the 
-- screen
-- @param height The height of the screen
function SnowFlake:beyondScreen(height)
    return self.y > height
end

--- SnowFlake:stop: Stop the particle system
function SnowFlake:stop()
    self.psystem:stop()
end

return SnowFlake

main.lua

--- main.lua: Snowflakes Simulation in LÖVE
-- date: 09/04/2024
-- author: Abhishek Mishra

local SnowFlake = require 'snowflake'

-- table to hold all the snowflakes
local snowflakes

-- font for credits
local creditsFont = love.graphics.newFont(12)

-- background music
local music = love.audio.newSource("A Lucid Dream.ogg", "stream")

-- number of snowflakes to create
local NUM_SNOWFLAKES = 5000

-- release rate per second
local RELEASE_RATE = 500

-- total released
local totalReleased = 0

--- createSnowflake: Create a new snowflake
-- @return SnowFlake: A new snowflake object
local function createSnowflake()
    return SnowFlake:new(
        math.random(0, love.graphics.getWidth()),
        math.random(-love.graphics.getHeight(),
            love.graphics.getHeight() * 0.05),
        math.random(1, 3))
end

--- restart: Restart the simulation
local function restart()
    -- stop all existing snowflakes
    if snowflakes then
        for _, snowflake in ipairs(snowflakes) do
            snowflake:stop()
        end
    end

    -- create a new table to hold snowflakes
    snowflakes = {}
    totalReleased = 0

    -- if there is already music playing, stop it
    love.audio.stop()

    -- load music and play
    music:setLooping(true)
    love.audio.play(music)
end

--- love.load: Called once at the start of the simulation
function love.load()
    restart()
end

--- love.update: Called every frame, updates the simulation
function love.update(dt)
    if totalReleased < NUM_SNOWFLAKES then
        local toRelease = math.floor(RELEASE_RATE * dt)

        -- create new snowflakes
        for i = 1, toRelease do
            table.insert(snowflakes, createSnowflake())
            totalReleased = totalReleased + 1
        end
    end

    for _, snowflake in ipairs(snowflakes) do
        snowflake:update(dt)
    end
end

--- love.draw: Called every frame, draws the simulation
function love.draw()
    love.graphics.setColor(0.8, 0.9, 1)
    for _, snowflake in ipairs(snowflakes) do
        snowflake:draw()
    end

    -- draw text at the bottom of the screen to show credits
    love.graphics.setColor(0.7, 0.5, 0.5, 0.8)
    -- set credits font
    love.graphics.setFont(creditsFont)
    love.graphics.print("Snowflakes Simulation in LÖVE by ne0l4t3r4l (https://neolateral.in)", 10,
        love.graphics.getHeight() - 50)
    love.graphics.print("Pixel-art snowflakes by https://opengameart.org/users/alxl", 10, love.graphics.getHeight() - 35)
    love.graphics.print("Background Score - \"The Lucid Dream\" by https://opengameart.org/users/caliderium", 10,
        love.graphics.getHeight() - 20)

    -- draw text at the bottom-right of the screen to show FPS
    -- love.graphics.setColor(0.5, 0.4, 0.4)
    -- love.graphics.print("FPS: " .. love.timer.getFPS(), love.graphics.getWidth() - 50, love.graphics.getHeight() - 20)
end

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

conf.lua

function love.conf(t)
    -- set the window title
    t.window.title = "Snowflakes Simulation"

    -- run fullscreen
    t.window.fullscreen = true

    -- disable unused modules for performance
    t.modules.joystick = false
    t.modules.physics = false
    t.modules.touch = false
end