Introduction
Seven years ago I wrote a blog post on the previous incarnation of this advent calendar that demonstrated a new library I had written, called Terminal::Print
, by making a (very primitive) snowfall simulator.
However, I was never entirely pleased with the outcome, especially after I saw this video about an implementation in APL that incorporated variable flake sizes, speeds, and turbulence.
Let’s address this by creating a brand-new snowfall simulator using a new Raku library, Dan Vu’s excellent Raylib::Bindings
. These are generated bindings for the superb single-header game development library raylib
.
Let’s snow!
Every raylib
program works by first initializing it’s window:init-window(screen-width, screen-height, "Snowfall Simulator")
.
The init-window
function has the side effect of setting up the OpenGL context. This is important, because without this context we aren’t able to load textures into GPU’s VRAM. Unlike many frameworks, init-window
does not return a window object.
Font time
We want to draw the *
character on tthe screen to represent our snowflake. There should be some way to use the UTF-8 characters from .TTF files, but I could not get it working when trying to use ❄
.
raylib
does not draw fonts (or images) directly but rather supports loading fonts into memory and then creating images from them and then loading those images into textures.
my $image = image-text('*', 32, $white);
my $texture = load-texture-from-image($image);
unload-image($image);
Note the call to unload-image
. We are going to need to be very careful about how most of our memory is managed when using raylib
. We call unload-image($image)
immediately after generating the texture because we have no further use for the image.
while
forever (until not)
After init-window
, the most important piece of a raylib
program is its while loop. This is the loop that does all updating and rendering of the program’s state.
We also can’t forget to call set-target-fps
, otherwise our FPS is entirely determined (rather than simply constrained) by our processing speed.
Let’s look at a fully working script that draws a single *
in the center of the screen:
use v6.*;
use Raylib::Bindings;
constant screen-height = 800;
constant screen-width = 800;
constant black = init-black;
constant white = init-raywhite;
init-window(screen-width,screen-height,"Snowfall Simulator");
my $image = image-text('*', 32, white);
my $texture = load-texture-from-image($image);
unload-image($image);
set-target-fps(60);
while !window-should-close {
begin-drawing;
clear-background(black);
draw-texture-ex(
$texture,
Vector2.init(screen-width/2e0, screen-height/2e0),
0e0,
1e0,
white
);
end-drawing;
}
unload-texture($texture);
close-window;
Try adjusting the fourth parameter of draw-texture-ex
to higher values: this controls the scale of the texture. We will be using this to have different sized snowflakes.
Design Strategy
Before we dive into coding the actual snowfall, let’s first consider what we actually want from the program. Then we can design around those features.
- Snowflakes of various sizes falling down the screen.
- These snowflakes should appear natural as they enter the top of the screen — no easily discernable regularity or synchronization should be seen.
- A wind to blow the flakes.
- An accumulation of snowflakes on the bottom of the screen.
Looking at requirement one, snowflakes of various sizes, we could individualize the size of each snowflake. However, considering the sub-requirement of appearing naturally without apparent synchronization, it probably makes sense to do our work in layers. If we are going to be using layers anyway, we might as well attach the scale to the layer and save space on our eventual Snowflake
object.
What should we base our layers on, in the end? In Raku it’s a natural fit to use interval supplies at various frequencies to trigger the creation of new snow as well as to trigger updates to the layer’s snowflakes. So, each layer will have both an interval supply and an update supply.
We want don’t want the snowflakes to fall uniformly, so one thing we can do is assign a random weight to each snowflake. We can then use this when we calculate position to make a heavier snowflake fall faster.
Wind is extremely simple to integrate, if a bit tricky to tune. We’ll have this as an attribute of the layer as well.
For accummulation we will create an array that is the length of the total number of pixels. The array will be one-dimensional for performance. We will encapsulate this behavior in a class. But! There is an important caveat: We want to update this accumulator array from multiple threads, so we will make it a monitor class using OO::Monitors
.
Now our proposed classes look something like the following:
The final code
Rather than break this down step by step, for the sake of time I will instead present you with the full code of a snowfall simulator. Then I will explain my process for how I arrived there
use Raylib::Bindings;
use NativeCall;
use OO::Monitors;
## Initialization
constant screen-width = 1024;
constant screen-height = 768;
init-window(screen-width, screen-height, "Snowfall Simulator");
my int32 $zero = 0;
my int32 $size = 48;
my $white = init-white;
my $black = init-black;
## Implementation Classes
class Snowflake {
my $TEXTURE; # Only one needed
has Bool $.falling is rw = True;
has Vector2 $.pos is required is rw;
has Num() $.weight is required;
method initialize-texture {
my $image = image-text('*', 32, $white);
$TEXTURE = load-texture-from-image($image);
unload-image($image);
}
method unload-texture { unload-texture($TEXTURE) }
method texture { $TEXTURE }
}
constant BOUNDS-SCALE = 4e0;
monitor SnowfallAccumulator {
has @.pixels = 0 xx (screen-width * screen-height);
method fill-pixel($x, $y, $scale) {
my $range = (BOUNDS-SCALE * $scale).Int;
for (-$range)..$range -> $x-offset {
for (-$range)..$range -> $y-offset {
next unless ($y + $y-offset) > 0 && ($x + $x-offset) > 0;
@!pixels[(($y + $y-offset) * (screen-width)) + ($x + $x-offset)] ||= 1;
}
}
}
method check-pixel($x, $y) {
@!pixels[($y * screen-width) + $x]
}
}
my SnowfallAccumulator $accumulator .= new;
class SnowfallLayer {
has Snowflake @.snowflakes;
has Num $.scale is required;
has Int $.layer-number is required;
has Vector2 $.wind is rw = Vector2.init(1.001e0 + (2.5e0 * $!scale) * ((1..3).pick %% 2 ?? -1 !! 1), 1.02e0);
has Bool $.started = False;
has Supply $!update-supply = Supply.interval((($!scale + 0.5) / 30 + ($!layer-number / 1000)));
has Supply $!more-supply = Supply.interval($!scale, $!scale + ($!layer-number / 50));
submethod TWEAK() {
self!fill-snowflakes;
$!update-supply.act: {
$!started && @!snowflakes.grep(*.falling).map: {
$_.pos.y = $_.pos.y + (($_.weight + $!scale) * 10) + $!wind.y;
$_.pos.x = $_.pos.x + (1 + ($!wind.x + (0.05 * (1..2).roll)));
if $_.pos.x > screen-width {
$_.pos.x = $_.pos.x - screen-width;
}
if $_.pos.x < 0 {
$_.pos.x = $_.pos.x + screen-width;
}
if $_.pos.y >= screen-height || $accumulator.check-pixel($_.pos.x, $_.pos.y) {
my $size = BOUNDS-SCALE * $!scale;
$_.pos.y = screen-height - $size;
until not $accumulator.check-pixel($_.pos.x, $_.pos.y) {
$_.pos.y = $_.pos.y - $size;
}
$_.pos.x = $_.pos.x + (Bool.pick ?? -$size !! Bool.pick ?? 0 !! $size);
$accumulator.fill-pixel($_.pos.x, $_.pos.y, $!scale);
$_.falling = False;
}
};
}
$!more-supply.act: { $!started && self!fill-snowflakes };
}
method !fill-snowflakes() {
for (^screen-width).pick(((1 .. 8).roll).abs) -> $x {
@!snowflakes.push: Snowflake.new: :pos(Vector2.init($x.Num, (^16).roll.Num)), :weight((1..5).roll * $!scale);
}
}
method render {
for @!snowflakes.grep(Snowflake:D) -> $flake {
draw-texture-ex($flake.texture, $flake.pos, 0e0, $!scale, $white);
}
}
method start { $!started = True }
}
class SnowfallRenderer {
has @.layers;
submethod TWEAK {
constant scales = (0.1e0, 0.2e0, 0.25e0, 0.3e0, 0.33e0, 0.36e0, 0.4e0, 0.5e0);
@!layers = scales.kv.map: -> $layer-number, $scale { SnowfallLayer.new: :$scale, :$layer-number }
}
method start { @!layers.map: *.start }
method render { @!layers.map: *.render }
}
## Rendering
set-target-fps(60);
Snowflake.initialize-texture;
my $renderer = SnowfallRenderer.new;
$renderer.start;
while !window-should-close {
begin-drawing;
clear-background($black);
$renderer.render;
end-drawing;
}
Snowflake.unload-texture;
close-window;
Process notes
I implemented the Snowflake
class and then immediately began working on SnowfallLayer
. Once I had the $layer.render
producing falling snow, I created the SnowfallRenderer
class to own and render multiple layers.
Interval supplies are created to update the positions of the snowflakes. Note that this is done outside of the context of the main loop — while we can afford to visit every flake and draw it, we don’t want to pay the math and assignment costs while rendering our frames.
We create snowflake layers of different scales to fake a sense of depth in the field of view. Each layer has it’s own unique scale as well as its own wind. We make this wind dependent on scale so that the far away snow seems to be more affected by the wind than the closer up snow.
Once the snow hits an area that the accumulator has marked occupied, we rewind it by a fraction of the total size of the character and then mark it as no longer falling. Since we only do our movement processing on falling snowflakes, these fallen flakes will stay in their final positions.
Issues
There is occasional slow-down after enough snow has been accumulated. This is because iterating over the list of extant snowflakes for each layer is taking an increasing amount of time. I’ve tried several strategies to mitigate this but I have a feeling that I will have to revisit my entire design.
De-structuring the Snowflake
objects into individual arrays of weights, positions, and falling states would be one possible avenue to explore. I might also try creating the objects in an entirely different language. (Sound strange? More on that to come in a future post…)
The snow
In the end we have this result:
I’m quite pleased with the way it looks. It’s leaps and bounds ahead of what we did with Terminal::Print
. We are missing Unicode, which would be nicer, but we need to leave a few items to address for the eventual Make It Snow 3.0.
Until then, stay cozy!