100 / 1000 boids · 0 FPS · 0.0ms tick
defmodule PhxDemo.Examples.Boids do
@width 800
@height 600
@max_speed 4.0
@max_force 0.1
@perception 50.0
@separation_dist 25.0
@cell_size trunc(@perception)
def width, do: @width
def height, do: @height
def init(count \\ 100) do
for _ <- 1..count do
angle = :rand.uniform() * 2 * :math.pi()
speed = 2.0 + :rand.uniform() * 2.0
%{
x: :rand.uniform(@width) * 1.0,
y: :rand.uniform(@height) * 1.0,
vx: :math.cos(angle) * speed,
vy: :math.sin(angle) * speed
}
end
end
def tick(boids) do
grid = build_grid(boids)
Enum.map(boids, fn boid ->
boid
|> apply_rules(grid)
|> limit_speed()
|> move()
|> wrap()
end)
end
defp build_grid(boids) do
Enum.reduce(boids, %{}, fn boid, grid ->
key = {trunc(boid.x / @cell_size), trunc(boid.y / @cell_size)}
Map.update(grid, key, [boid], &[boid | &1])
end)
end
defp neighbors(boid, grid) do
cx = trunc(boid.x / @cell_size)
cy = trunc(boid.y / @cell_size)
for dx <- -1..1, dy <- -1..1, reduce: [] do
acc -> Map.get(grid, {cx + dx, cy + dy}, []) ++ acc
end
end
defp apply_rules(boid, grid) do
neighbors = neighbors(boid, grid)
{sep_x, sep_y, ali_x, ali_y, coh_x, coh_y, count} =
Enum.reduce(neighbors, {0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0}, fn other,
{sx, sy, ax, ay, cx, cy, n} ->
dx = other.x - boid.x
dy = other.y - boid.y
dist_sq = dx * dx + dy * dy
if dist_sq > 0 and dist_sq < @perception * @perception do
dist = :math.sqrt(dist_sq)
{sx2, sy2} =
if dist < @separation_dist do
{sx - dx / dist, sy - dy / dist}
else
{sx, sy}
end
{sx2, sy2, ax + other.vx, ay + other.vy, cx + other.x, cy + other.y, n + 1}
else
{sx, sy, ax, ay, cx, cy, n}
end
end)
if count > 0 do
{avx, avy} = steer(boid, ali_x / count, ali_y / count)
{cvx, cvy} = steer(boid, coh_x / count - boid.x, coh_y / count - boid.y)
{svx, svy} = {sep_x * @max_force * 1.5, sep_y * @max_force * 1.5}
%{boid | vx: boid.vx + svx + avx + cvx, vy: boid.vy + svy + avy + cvy}
else
boid
end
end
defp steer(boid, target_vx, target_vy) do
mag = :math.sqrt(target_vx * target_vx + target_vy * target_vy)
if mag > 0 do
dvx = target_vx / mag * @max_speed - boid.vx
dvy = target_vy / mag * @max_speed - boid.vy
limit_vec(dvx, dvy, @max_force)
else
{0.0, 0.0}
end
end
defp limit_vec(x, y, max) do
mag = :math.sqrt(x * x + y * y)
if mag > max, do: {x / mag * max, y / mag * max}, else: {x, y}
end
defp limit_speed(boid) do
speed = :math.sqrt(boid.vx * boid.vx + boid.vy * boid.vy)
if speed > @max_speed do
%{boid | vx: boid.vx / speed * @max_speed, vy: boid.vy / speed * @max_speed}
else
boid
end
end
defp move(boid), do: %{boid | x: boid.x + boid.vx, y: boid.y + boid.vy}
defp wrap(boid) do
%{boid | x: wrap_val(boid.x, @width), y: wrap_val(boid.y, @height)}
end
defp wrap_val(v, max) do
cond do
v < 0 -> v + max
v > max -> v - max
true -> v
end
end
def render(boids) do
canvas =
Easel.new(@width, @height)
|> Easel.set_fill_style("#0a0a2e")
|> Easel.fill_rect(0, 0, @width, @height)
buckets =
Enum.group_by(boids, fn boid ->
angle = :math.atan2(boid.vy, boid.vx)
div(round(angle / :math.pi() * 180 + 180), 10) * 10
end)
Enum.reduce(buckets, canvas, fn {hue, group}, acc ->
acc = Easel.set_fill_style(acc, "hsl(#{hue}, 70%, 60%)")
acc = Easel.begin_path(acc)
acc =
Enum.reduce(group, acc, fn boid, acc2 ->
angle = :math.atan2(boid.vy, boid.vx)
size = 6
x1 = boid.x + :math.cos(angle) * size * 2
y1 = boid.y + :math.sin(angle) * size * 2
x2 = boid.x + :math.cos(angle + 2.5) * size
y2 = boid.y + :math.sin(angle + 2.5) * size
x3 = boid.x + :math.cos(angle - 2.5) * size
y3 = boid.y + :math.sin(angle - 2.5) * size
acc2
|> Easel.move_to(x1, y1)
|> Easel.line_to(x2, y2)
|> Easel.line_to(x3, y3)
|> Easel.close_path()
end)
Easel.fill(acc)
end)
|> Easel.render()
end
end
defmodule PhxDemoWeb.BoidsLive do
use PhxDemoWeb, :live_view
@width PhxDemo.Examples.boids_width()
@height PhxDemo.Examples.boids_height()
@max_boids 1000
@frame_ms 16
@bucket_colors 0..35 |> Enum.map(&"hsl(#{&1 * 10}, 70%, 60%)") |> List.to_tuple()
def mount(_params, _session, socket) do
boids = PhxDemo.Examples.boids_init()
template_canvas =
Easel.new(@width, @height)
|> Easel.template(
:boid,
fn c ->
c
|> Easel.begin_path()
|> Easel.move_to(12, 0)
|> Easel.line_to(-4, -5)
|> Easel.line_to(-4, 5)
|> Easel.close_path()
|> Easel.fill()
end,
x: 1,
y: 1,
rotate: 3
)
canvas = render_boids(boids, template_canvas.template_opts)
background =
Easel.new(@width, @height)
|> Easel.set_fill_style("#0a0a2e")
|> Easel.fill_rect(0, 0, @width, @height)
|> Easel.render()
templates = template_canvas.templates
now = System.monotonic_time(:millisecond)
socket =
socket
|> assign(:boids, boids)
|> assign(:canvas, canvas)
|> assign(:templates, templates)
|> assign(:template_opts, template_canvas.template_opts)
|> assign(:background, background)
|> assign(:width, @width)
|> assign(:height, @height)
|> assign(:max_boids, @max_boids)
|> assign(:boid_count, length(boids))
|> assign(:fps, 0)
|> assign(:avg_tick_ms, 0.0)
|> assign(:fps_frames, 0)
|> assign(:fps_tick_acc_ms, 0.0)
|> assign(:fps_window_start, now)
|> Easel.LiveView.animate(
"fg",
:boids,
fn boids ->
{Easel.new(), PhxDemo.Examples.boids_tick(boids)}
end,
interval: @frame_ms
)
{:ok, socket}
end
def handle_info({:easel_tick, id}, socket) do
t0 = System.monotonic_time(:microsecond)
socket = Easel.LiveView.tick(socket, id)
boids = socket.assigns.boids
canvas = render_boids(boids, socket.assigns.template_opts)
t1 = System.monotonic_time(:microsecond)
tick_ms = (t1 - t0) / 1000.0
socket =
socket
|> Easel.LiveView.draw("fg", canvas, clear: true)
|> update_fps_stats(tick_ms, length(boids))
{:noreply, socket}
end
def handle_event("fg:click", %{"x" => x, "y" => y}, socket) do
current = socket.assigns.boids
if length(current) >= @max_boids do
{:noreply, socket}
else
new_boids =
for _ <- 1..10 do
angle = :rand.uniform() * 2 * :math.pi()
speed = 2.0 + :rand.uniform() * 2.0
%{
x: x * 1.0,
y: y * 1.0,
vx: :math.cos(angle) * speed,
vy: :math.sin(angle) * speed
}
end
boids = Enum.take(new_boids ++ current, @max_boids)
{:noreply,
socket
|> assign(:boids, boids)
|> assign(:boid_count, length(boids))}
end
end
defp render_boids(boids, template_opts) do
instances =
Enum.map(boids, fn boid ->
angle = :math.atan2(boid.vy, boid.vx)
bucket = rem(div(round(angle / :math.pi() * 180 + 180), 10), 36)
%{x: boid.x, y: boid.y, rotate: angle, fill: elem(@bucket_colors, bucket)}
end)
Easel.new(@width, @height)
|> Easel.with_template_opts(template_opts)
|> Easel.instances(:boid, instances)
|> Easel.render()
end
defp update_fps_stats(socket, tick_ms, boid_count) do
now = System.monotonic_time(:millisecond)
frames = socket.assigns.fps_frames + 1
tick_acc_ms = socket.assigns.fps_tick_acc_ms + tick_ms
window_start = socket.assigns.fps_window_start
elapsed = now - window_start
if elapsed >= 1000 do
fps = round(frames * 1000 / elapsed)
avg_tick_ms = tick_acc_ms / frames
socket
|> assign(:fps, fps)
|> assign(:avg_tick_ms, avg_tick_ms)
|> assign(:boid_count, boid_count)
|> assign(:fps_frames, 0)
|> assign(:fps_tick_acc_ms, 0.0)
|> assign(:fps_window_start, now)
else
socket
|> assign(:fps_frames, frames)
|> assign(:fps_tick_acc_ms, tick_acc_ms)
end
end
def render(assigns) do
~H"""
<.demo title="Boids — click to add" code_id="boids">
<Easel.LiveView.canvas_stack id="boids" width={@width} height={@height}>
<:layer id="bg" ops={@background.ops} />
<:layer id="fg" ops={@canvas.ops} templates={@templates} on_click />
</Easel.LiveView.canvas_stack>
<p class="text-sm text-gray-500 mt-2">
{@boid_count} / {@max_boids} boids · {@fps} FPS · {Float.round(@avg_tick_ms, 2)}ms tick
</p>
</.demo>
"""
end
end