Compare commits

..

10 commits

Author SHA1 Message Date
Matthew Deville
a9bd994a39 wip 2026-09-29 00:08:19 +02:00
Matthew Deville
44c40dcf50 sky 2026-09-28 23:50:36 +02:00
Matthew Deville
f9cb9f8235 trunk port 2026-09-28 23:40:19 +02:00
Matthew Deville
3bd353ebda reduce boid number 2026-09-28 23:25:42 +02:00
Matthew Deville
50815f2904 make attraction force stronger 2026-09-28 23:25:04 +02:00
Matthew Deville
96c27d4b0d track cursor 2026-09-28 23:17:46 +02:00
Matthew Deville
af4065e149 wip 2026-09-28 22:58:54 +02:00
Matthew Deville
2ce0307b3c default major version docker 2026-09-28 22:18:31 +02:00
Matthew Deville
716389f1ab update port 2026-09-28 22:09:59 +02:00
Matthew Deville
b7361bd083 Squashed commit of the following:
commit 9cba02c71e
Author: Matthew Deville <matthew@stockly.ai>
Date:   Mon Sep 28 21:49:33 2026 +0200

    boids

commit df85367414
Author: Matthew Deville <matthew@stockly.ai>
Date:   Mon Sep 28 12:18:10 2026 +0200

    trunk

commit bcf542f963
Author: Matthew Deville <matthew@stockly.ai>
Date:   Mon Sep 28 10:07:38 2026 +0200

    init biods
2026-09-28 21:52:00 +02:00
23 changed files with 887 additions and 1354 deletions

3
.gitignore vendored
View file

@ -1,4 +1,3 @@
dist
target
static
result
.direnv

971
Cargo.lock generated

File diff suppressed because it is too large Load diff

View file

@ -1,6 +1,10 @@
[workspace]
members = ["app", "server"]
resolver = "3"
[package]
edition = "2024"
name = "website"
version = "0.1.0"
[dependencies]
bevy = { version = "0.19", default-features = false, features = ["2d", "ui"] }
[profile.dev.package."*"]
opt-level = 3

View file

@ -1,26 +1,70 @@
FROM rust:1 AS builder
WORKDIR /usr/src/website
COPY . .
# syntax=docker/dockerfile:1
ARG RUST_VERSION=1
# ---- Base: Rust toolchain + build tools ----
FROM rust:${RUST_VERSION}-slim-bookworm AS chef
WORKDIR /app
RUN apt-get update \
&& apt-get install -y --no-install-recommends binaryen brotli yq \
&& apt-get install -y --no-install-recommends curl ca-certificates \
&& rm -rf /var/lib/apt/lists/*
RUN rustup target add wasm32-unknown-unknown
RUN VERSION="$(tomlq -r '.package[] | select(.name == "wasm-bindgen") | .version' Cargo.lock)" \
&& cargo install wasm-bindgen-cli --version "$VERSION"
# Install the toolchain declared in rust-toolchain.toml (incl. wasm32 target) in its own layer
COPY rust-toolchain.toml .
RUN rustup show active-toolchain || rustup toolchain install
RUN ./build-wasm.sh release
RUN cargo build --release -p server
# Prebuilt binaries via cargo-binstall; wasm-bindgen-cli must match the version in Cargo.lock
RUN curl -L --proto '=https' --tlsv1.2 -sSf \
https://raw.githubusercontent.com/cargo-bins/cargo-binstall/main/install-from-binstall-release.sh | bash \
&& cargo binstall -y --locked cargo-chef trunk
FROM debian:trixie-slim
RUN apt-get update && apt-get install -y --no-install-recommends ca-certificates curl && rm -rf /var/lib/apt/lists/*
COPY --from=builder /usr/src/website/target/release/server /usr/local/bin/server
COPY --from=builder /usr/src/website/static /app/static
# ---- Planner: compute the dependency recipe ----
FROM chef AS planner
COPY . .
RUN cargo chef prepare --recipe-path recipe.json
# ---- Builder: cook deps (cached), then build the site with trunk ----
FROM chef AS builder
COPY --from=planner /app/recipe.json recipe.json
# Must match the flags trunk passes to cargo (see web/index.html) to reuse the cache
RUN cargo chef cook --release --target wasm32-unknown-unknown --no-default-features \
--recipe-path recipe.json
COPY . .
RUN mkdir -p assets
RUN --mount=type=cache,target=/root/.cache/trunk \
trunk build --release --locked
# ---- Runtime: static files served by nginx ----
FROM nginx:1-alpine AS runtime
COPY <<'EOF' /etc/nginx/conf.d/default.conf
server {
listen 3000;
server_name _;
root /usr/share/nginx/html;
index index.html;
gzip on;
gzip_types application/wasm application/javascript text/css;
# Bevy expects real 404s for missing assets (Trunk.toml: no_spa = true)
location / {
try_files $uri $uri/ =404;
}
location ~* \.(wasm|js|css)$ {
add_header Cache-Control "public, max-age=31536000, immutable";
try_files $uri =404;
}
location = /index.html {
add_header Cache-Control "no-cache";
}
}
EOF
COPY --from=builder /app/dist /usr/share/nginx/html
WORKDIR /app
EXPOSE 3000
HEALTHCHECK --interval=30s --timeout=5s --start-period=5s --retries=3 \
CMD curl -f http://localhost:3000/health || exit 1
CMD ["server"]

11
Trunk.toml Normal file
View file

@ -0,0 +1,11 @@
[build]
dist = "dist"
target = "web/index.html"
[serve]
# Required in order to receive 404s for missing assets, which is what Bevy expects.
no_spa = true
# Open a browser tab once the initial build is complete.
open = true
# Server port
port = 3000

View file

@ -1,2 +0,0 @@
[build]
target = "wasm32-unknown-unknown"

View file

@ -1,8 +0,0 @@
[package]
edition = "2024"
name = "app"
version = "0.1.0"
[dependencies]
avian2d = "0.7"
bevy = { version = "0.19", default-features = false, features = ["2d"] }

View file

@ -1,220 +0,0 @@
use avian2d::prelude::*;
use bevy::{
input::common_conditions::input_just_pressed,
prelude::*,
window::{PrimaryWindow, WindowResized},
};
fn main() {
App::new()
.insert_resource(ClearColor(Color::srgb_u8(26, 26, 46)))
.add_plugins((
DefaultPlugins.set(WindowPlugin {
primary_window: Some(Window {
fit_canvas_to_parent: true,
prevent_default_event_handling: false,
canvas: Some("#bevy-canvas".into()),
..default()
}),
..default()
}),
PhysicsPlugins::default(),
))
.insert_resource(Time::<Physics>::default().with_relative_speed(5.0))
.insert_resource(DefaultRestitution(
Restitution {
coefficient: 0.5,
combine_rule: CoefficientCombine::Average,
}
))
.add_systems(Startup, setup)
.add_systems(
Update,
(
update_screen_edges,
spawn_ball.run_if(input_just_pressed(MouseButton::Left)),
),
)
.run();
}
#[derive(Component, Clone, Copy)]
enum ScreenEdge {
Top,
Bottom,
Left,
Right,
}
fn setup(
mut commands: Commands,
mut meshes: ResMut<Assets<Mesh>>,
mut materials: ResMut<Assets<ColorMaterial>>,
window: Query<&Window, With<PrimaryWindow>>,
) {
commands.spawn(Camera2d);
setup_pachinko_rail(&mut commands, &mut meshes, &mut materials);
if let Ok(window) = window.single() {
spawn_screen_edges(&mut commands, &mut meshes, &mut materials, window.size());
}
}
#[derive(Component)]
pub struct PachinkoRail {
pub points: Vec<Vec2>,
}
fn setup_pachinko_rail(commands: &mut Commands, meshes: &mut Assets<Mesh>, materials: &mut Assets<ColorMaterial>) {
// Control points in world space (y up). The spline passes through each one.
// Bottom-left entry -> straight up the left wall -> arc over the top -> into the pin zone.
let control_points = [
Vec2::new(-320.0, -260.0), // rail entry (launch point)
Vec2::new(-330.0, -120.0),
Vec2::new(-330.0, 60.0),
Vec2::new(-320.0, 200.0),
Vec2::new(-260.0, 280.0), // start of the top arc
Vec2::new(-160.0, 310.0),
Vec2::new(-60.0, 290.0), // crest, curving down
Vec2::new(0.0, 240.0), // exit: ball drops into the pins here
];
// Catmull-Rom is a cardinal spline with tension 0.5.
// Lower tension = rounder, higher = tighter corners.
let curve = CubicCardinalSpline::new(0.5, control_points)
.to_curve()
.expect("need at least 2 control points");
let points: Vec<Vec2> = curve.iter_positions(128).collect();
commands.spawn((
RigidBody::Static,
Collider::polyline(points.clone(), None),
Mesh2d(meshes.add(Polyline2d::new(points.iter().copied()))),
MeshMaterial2d(materials.add(Color::srgb_u8(255, 255, 255))),
Transform::from_xyz(0.0, 0.0, 0.0),
PachinkoRail { points },
));
}
#[derive(Component, Clone, Copy)]
struct Ball;
fn spawn_ball(
mouse_button: Res<ButtonInput<MouseButton>>,
window: Single<&Window, With<PrimaryWindow>>,
camera_query: Single<(&Camera, &GlobalTransform)>,
time: Res<Time>,
mut commands: Commands,
mut meshes: ResMut<Assets<Mesh>>,
mut materials: ResMut<Assets<ColorMaterial>>,
) {
const CIRCLE_RADIUS: f32 = 5.0;
let (camera, camera_transform) = *camera_query;
if mouse_button.just_pressed(MouseButton::Left)
&& let Some(cursor_position) = window.cursor_position()
&& let Ok(world_position) = camera.viewport_to_world_2d(camera_transform, cursor_position)
{
println!("world_position: {:?}", world_position);
commands.spawn((
RigidBody::Dynamic,
Collider::circle(CIRCLE_RADIUS),
LinearVelocity(Vec2::new(-100.0, 200.0)),
Mesh2d(meshes.add(Circle::new(CIRCLE_RADIUS))),
MeshMaterial2d(materials.add(Color::hsl(time.elapsed_secs().sin() * 360.0, 1.0, 0.5))),
Transform::from_xyz(world_position.x, world_position.y, 0.0),
Ball,
));
}
}
fn spawn_screen_edges(
commands: &mut Commands,
meshes: &mut Assets<Mesh>,
materials: &mut Assets<ColorMaterial>,
size: Vec2,
) {
const EDGE_COLOR: Color = Color::srgb_u8(255, 255, 255);
let material = materials.add(EDGE_COLOR);
for edge in [
ScreenEdge::Top,
ScreenEdge::Bottom,
ScreenEdge::Left,
ScreenEdge::Right,
] {
let (collider, transform, rect_size) = edge_shape(edge, size);
commands.spawn((
RigidBody::Static,
collider,
transform,
edge,
Mesh2d(meshes.add(Rectangle::from_size(rect_size))),
MeshMaterial2d(material.clone()),
));
}
}
fn edge_shape(edge: ScreenEdge, size: Vec2) -> (Collider, Transform, Vec2) {
const EDGE_THICKNESS: f32 = 5.0;
let half = size / 2.0;
let thickness = EDGE_THICKNESS;
match edge {
ScreenEdge::Top => {
let rect = Vec2::new(size.x, thickness);
(
Collider::rectangle(rect.x, rect.y),
Transform::from_xyz(0.0, half.y - thickness / 2.0, 0.0),
rect,
)
}
ScreenEdge::Bottom => {
let rect = Vec2::new(size.x, thickness);
(
Collider::rectangle(rect.x, rect.y),
Transform::from_xyz(0.0, -half.y + thickness / 2.0, 0.0),
rect,
)
}
ScreenEdge::Left => {
let rect = Vec2::new(thickness, size.y);
(
Collider::rectangle(rect.x, rect.y),
Transform::from_xyz(-half.x + thickness / 2.0, 0.0, 0.0),
rect,
)
}
ScreenEdge::Right => {
let rect = Vec2::new(thickness, size.y);
(
Collider::rectangle(rect.x, rect.y),
Transform::from_xyz(half.x - thickness / 2.0, 0.0, 0.0),
rect,
)
}
}
}
fn update_screen_edges(
mut resized: PopulatedMessageReader<WindowResized>,
mut edges: Query<(&ScreenEdge, &mut Collider, &mut Transform, &Mesh2d)>,
mut meshes: ResMut<Assets<Mesh>>,
) {
for event in resized.read() {
let size = Vec2::new(event.width, event.height);
for (edge, mut collider, mut transform, mesh2d) in &mut edges {
let (new_collider, new_transform, rect_size) = edge_shape(*edge, size);
*collider = new_collider;
*transform = new_transform;
if let Some(mut mesh) = meshes.get_mut(&mesh2d.0) {
*mesh = Rectangle::from_size(rect_size).into();
}
}
}
}

181
assets/shaders/sky.wgsl Normal file
View file

@ -0,0 +1,181 @@
#import bevy_sprite::mesh2d_vertex_output::VertexOutput
#import bevy_sprite::mesh2d_view_bindings::{globals, view}
// Single-scattering atmosphere (Rayleigh + Mie + ozone) with the sun fixed
// just above the horizon, plus a thin layer of drifting clouds.
const PI: f32 = 3.14159265;
const PLANET_RADIUS: f32 = 6371000.0;
const ATMOSPHERE_RADIUS: f32 = 6471000.0;
const RAYLEIGH_HEIGHT: f32 = 8000.0;
const MIE_HEIGHT: f32 = 1200.0;
const CAMERA_ALTITUDE: f32 = 200.0;
const C_RAYLEIGH: vec3<f32> = vec3<f32>(5.802, 13.558, 33.100) * 1e-6;
const C_MIE: vec3<f32> = vec3<f32>(3.996, 3.996, 3.996) * 1e-6;
const C_OZONE: vec3<f32> = vec3<f32>(0.650, 1.881, 0.085) * 1e-6;
const MIE_G: f32 = 0.76;
const VIEW_SAMPLES: i32 = 16;
const LIGHT_SAMPLES: i32 = 4;
const SUN_INTENSITY: f32 = 20.0;
const SUN_ANGULAR_RADIUS: f32 = 0.012;
// Vertical field of view is 2 * atan(FOV_SCALE)
const FOV_SCALE: f32 = 0.6;
// Screen height (in NDC) of the horizon line
const HORIZON_NDC: f32 = -0.55;
fn sun_direction() -> vec3<f32> {
// Slightly right of center, ~2 degrees above the horizon
return normalize(vec3<f32>(0.25, 0.035, -1.0));
}
// Near and far distances to a sphere centered at the planet center, or -1
// when the ray misses.
fn sphere_hit(origin: vec3<f32>, dir: vec3<f32>, radius: f32) -> vec2<f32> {
let b = dot(origin, dir);
let c = dot(origin, origin) - radius * radius;
let d = b * b - c;
if d < 0.0 {
return vec2<f32>(-1.0);
}
let s = sqrt(d);
return vec2<f32>(-b - s, -b + s);
}
fn density(p: vec3<f32>) -> vec3<f32> {
let h = max(length(p) - PLANET_RADIUS, 0.0);
let ozone = max(0.0, 1.0 - abs(h - 25000.0) / 15000.0);
return vec3<f32>(exp(-h / RAYLEIGH_HEIGHT), exp(-h / MIE_HEIGHT), ozone);
}
fn absorb(optical_depth: vec3<f32>) -> vec3<f32> {
return exp(-(optical_depth.x * C_RAYLEIGH
+ optical_depth.y * C_MIE * 1.1
+ optical_depth.z * C_OZONE));
}
fn light_optical_depth(p: vec3<f32>, dir: vec3<f32>) -> vec3<f32> {
let len = sphere_hit(p, dir, ATMOSPHERE_RADIUS).y;
let step = len / f32(LIGHT_SAMPLES);
var depth = vec3<f32>(0.0);
for (var i = 0; i < LIGHT_SAMPLES; i++) {
depth += density(p + dir * (f32(i) + 0.5) * step) * step;
}
return depth;
}
fn phase_rayleigh(c: f32) -> f32 {
return 3.0 * (1.0 + c * c) / (16.0 * PI);
}
fn phase_hg(c: f32, g: f32) -> f32 {
let g2 = g * g;
return (1.0 - g2) / (4.0 * PI * pow(1.0 + g2 - 2.0 * g * c, 1.5));
}
// In-scattered light along the view ray; `transmittance` receives the
// attenuation of whatever lies behind it.
fn scatter(origin: vec3<f32>, dir: vec3<f32>, sun: vec3<f32>, transmittance: ptr<function, vec3<f32>>) -> vec3<f32> {
var len = sphere_hit(origin, dir, ATMOSPHERE_RADIUS).y;
let ground = sphere_hit(origin, dir, PLANET_RADIUS);
if ground.x > 0.0 {
len = ground.x;
}
let c = dot(dir, sun);
let phase_r = phase_rayleigh(c);
let phase_m = phase_hg(c, MIE_G);
var depth = vec3<f32>(0.0);
var rayleigh = vec3<f32>(0.0);
var mie = vec3<f32>(0.0);
var prev_t = 0.0;
for (var i = 1; i <= VIEW_SAMPLES; i++) {
// Quadratic distribution: dense samples near the camera, where the air is thick
let f = f32(i) / f32(VIEW_SAMPLES);
let t = f * f * len;
let step = t - prev_t;
let p = origin + dir * (t - 0.5 * step);
let local = density(p);
depth += local * step;
let light = absorb(depth + light_optical_depth(p, sun));
rayleigh += light * local.x * step;
mie += light * local.y * step;
prev_t = t;
}
*transmittance = absorb(depth);
return (rayleigh * C_RAYLEIGH * phase_r + mie * C_MIE * phase_m) * SUN_INTENSITY;
}
fn hash(p: vec2<f32>) -> f32 {
return fract(sin(dot(p, vec2<f32>(127.1, 311.7))) * 43758.5453);
}
fn noise(p: vec2<f32>) -> f32 {
let i = floor(p);
let f = fract(p);
let u = f * f * (3.0 - 2.0 * f);
return mix(
mix(hash(i), hash(i + vec2<f32>(1.0, 0.0)), u.x),
mix(hash(i + vec2<f32>(0.0, 1.0)), hash(i + vec2<f32>(1.0, 1.0)), u.x),
u.y,
);
}
fn fbm(p: vec2<f32>) -> f32 {
var value = 0.0;
var amplitude = 0.5;
var q = p;
for (var i = 0; i < 5; i++) {
value += amplitude * noise(q);
q *= 2.0;
amplitude *= 0.5;
}
return value;
}
@fragment
fn fragment(mesh: VertexOutput) -> @location(0) vec4<f32> {
let aspect = view.viewport.z / view.viewport.w;
// uv.y is 0 at the top of the quad
let ndc = vec2<f32>(mesh.uv.x * 2.0 - 1.0, 1.0 - mesh.uv.y * 2.0);
let dir = normalize(vec3<f32>(
ndc.x * aspect * FOV_SCALE,
(ndc.y - HORIZON_NDC) * FOV_SCALE,
-1.0,
));
let sun = sun_direction();
let origin = vec3<f32>(0.0, PLANET_RADIUS + CAMERA_ALTITUDE, 0.0);
var transmittance: vec3<f32>;
var color = scatter(origin, dir, sun, &transmittance);
let cos_sun = dot(dir, sun);
if dir.y > 0.0 {
// Sun disc, reddened by the air in front of it
let disc = smoothstep(cos(SUN_ANGULAR_RADIUS * 1.2), cos(SUN_ANGULAR_RADIUS * 0.8), cos_sun);
color += disc * transmittance * SUN_INTENSITY * 20.0;
// Clouds on a flat plane, lit by the sunlight that reaches them
let p = dir.xz / dir.y * 2.0;
let t = globals.time;
let clouds = fbm(p + vec2<f32>(t * 0.02, 0.0)) * 0.6
+ fbm(p * 2.0 + vec2<f32>(t * 0.035, t * 0.008)) * 0.4;
let cover = smoothstep(0.5, 0.8, clouds) * smoothstep(0.0, 0.15, dir.y);
let sunlight = absorb(light_optical_depth(origin, sun)) * SUN_INTENSITY;
let lit = sunlight * (0.04 + phase_hg(cos_sun, 0.6) * 0.3) + color * 0.5;
color = mix(color, lit, cover * 0.85);
}
// Exposure tonemap, then dither to hide banding in the gradients
color = 1.0 - exp(-color * 2.0);
color += (hash(mesh.position.xy) - 0.5) / 255.0;
return vec4<f32>(color, 1.0);
}

View file

@ -1,35 +0,0 @@
#!/usr/bin/env bash
set -euo pipefail
PROFILE="${1:-release}"
if [ "$PROFILE" = "dev" ]; then
PROFILE_DIR="debug"
CARGO_FLAGS="--features=bevy/dynamic_linking"
else
PROFILE_DIR="release"
CARGO_FLAGS="--release"
fi
cargo build $CARGO_FLAGS --target wasm32-unknown-unknown -p app
mkdir -p static
# Drop stale bindgen / compression outputs from prior builds.
rm -f static/2dscene*
wasm-bindgen --out-name 2dscene \
--out-dir static \
--target web \
"target/wasm32-unknown-unknown/$PROFILE_DIR/app.wasm"
if [ "$PROFILE" != "dev" ]; then
wasm-opt -Oz --all-features -o static/2dscene_bg.wasm static/2dscene_bg.wasm
# Precompress assets Axum will negotiate via Accept-Encoding.
for f in static/2dscene_bg.wasm static/2dscene.js; do
gzip -9 -k -f "$f"
brotli -q 11 -f -k "$f"
done
fi
cp web/index.html static/

View file

@ -10,7 +10,8 @@
};
};
outputs = inputs@{
outputs =
inputs@{
flake-parts,
nixpkgs,
rust-overlay,
@ -24,43 +25,35 @@
"aarch64-darwin"
];
perSystem = { pkgs, system, ... }:
perSystem =
{ pkgs, system, ... }:
let
overlays = [ (import rust-overlay) ];
rustPkgs = import nixpkgs {
inherit system overlays;
};
rustToolchain = rustPkgs.rust-bin.fromRustupToolchainFile ./rust-toolchain.toml;
cargoLock = fromTOML (builtins.readFile ./Cargo.lock);
wasmBindgen = pkgs.lib.findFirst
(pkg: pkg.name == "wasm-bindgen")
(throw "Could not find wasm-bindgen package")
cargoLock.package;
wasm-bindgen-cli = pkgs.buildWasmBindgenCli rec {
src = pkgs.fetchCrate {
pname = "wasm-bindgen-cli";
version = wasmBindgen.version;
hash = "sha256-a7lcXJnnZkYReja+iUO7NqqrWyv3toxnUgQb8s4IS5s=";
};
cargoDeps = pkgs.rustPlatform.fetchCargoVendor {
inherit src;
inherit (src) pname version;
hash = "sha256-R1Tas33Ursy8kqsxguAkG0ZhNed2n5uFTAhw1l2qlLY=";
};
};
runtimeDeps = with pkgs; [
alsa-lib
udev
vulkan-loader
wayland
libxkbcommon
];
in
{
devShells.default = pkgs.mkShell {
buildInputs = [
packages = with pkgs; [
pkg-config
rustToolchain
wasm-bindgen-cli
pkgs.binaryen
pkgs.brotli
pkgs.just
trunk
];
buildInputs = runtimeDeps;
shellHook = ''
export LD_LIBRARY_PATH="$LD_LIBRARY_PATH:${pkgs.lib.makeLibraryPath runtimeDeps}"
'';
};
};
};

View file

@ -1,35 +0,0 @@
# List available recipes
default:
@just --list
# Build the Bevy app to WASM (release by default; use `just build dev` for debug)
build profile="release":
./build-wasm.sh {{profile}}
# Build WASM in debug mode
build-dev:
./build-wasm.sh dev
# Build the HTTP server (release)
build-server:
cargo build --release -p server
# Build WASM + server for production
build-all: build build-server
# Run the server (serves ./static on :3000)
serve:
cargo run -p server
# Build WASM then run the server
run: build-dev serve
# Type-check the workspace
check:
cargo clippy --target wasm32-unknown-unknown -p app
cargo clippy --target x86_64-unknown-linux-gnu -p server
# Remove build artifacts
clean:
rm -rf static
cargo clean

View file

@ -1,9 +0,0 @@
[package]
edition = "2024"
name = "server"
version = "0.1.0"
[dependencies]
axum = "0.8"
tokio = { version = "1", features = ["full"] }
tower-http = { version = "0.7", features = ["fs"] }

View file

@ -1,17 +0,0 @@
use axum::{Router, routing::get};
use tower_http::services::ServeDir;
#[tokio::main]
async fn main() {
let static_files = ServeDir::new("static")
.precompressed_gzip()
.precompressed_br();
let app = Router::new()
.route("/health", get(|| async { "OK" }))
.fallback_service(static_files);
println!("Listening on http://0.0.0.0:3000");
let listener = tokio::net::TcpListener::bind("0.0.0.0:3000").await.unwrap();
axum::serve(listener, app).await.unwrap();
}

218
src/boids.rs Normal file
View file

@ -0,0 +1,218 @@
use bevy::{platform::collections::HashMap, prelude::*};
use std::f32::consts::PI;
use crate::{
config::ModelConfig,
pointer::PointerTarget,
spatial_grid::{SpatialGrid, update_spatial_grid},
};
pub struct BoidsPlugin;
impl Plugin for BoidsPlugin {
fn build(&self, app: &mut App) {
app.init_resource::<SpatialGrid>()
.add_systems(Startup, setup)
.add_systems(
Update,
sync_boid_count.run_if(resource_changed::<ModelConfig>),
)
.add_systems(FixedUpdate, (update_spatial_grid, update_boids).chain());
}
}
#[derive(Component)]
pub struct Boid {
velocity: Vec2,
}
#[derive(Resource)]
struct BoidAssets {
mesh: Mesh2d,
material: MeshMaterial2d<ColorMaterial>,
}
fn setup(
model_config: Res<ModelConfig>,
mut commands: Commands,
mut meshes: ResMut<Assets<Mesh>>,
mut materials: ResMut<Assets<ColorMaterial>>,
) {
let ModelConfig {
num_boids,
max_velocity,
..
} = *model_config;
// Triangle pointing right
let assets = BoidAssets {
mesh: Mesh2d(meshes.add(Triangle2d::new(
Vec2::new(10.0, 0.0),
Vec2::new(-3.0, 3.0),
Vec2::new(-3.0, -3.0),
))),
material: MeshMaterial2d(materials.add(Color::BLACK)),
};
spawn_boids(
&mut commands,
&assets,
0..num_boids,
num_boids,
max_velocity,
);
commands.insert_resource(assets);
}
fn spawn_boids(
commands: &mut Commands,
assets: &BoidAssets,
range: std::ops::Range<usize>,
num_boids: usize,
max_velocity: f32,
) {
let mesh = assets.mesh.clone();
let material = assets.material.clone();
commands.spawn_batch(range.map(move |n| {
let angle = 2.0 * PI / (num_boids as f32) * n as f32;
(
Boid {
velocity: Vec2::from_angle(angle) * max_velocity,
},
mesh.clone(),
material.clone(),
Transform::default().rotate(Quat::from_rotation_z(angle)),
)
}));
}
/// Spawns or despawns boids so that their count matches `ModelConfig::num_boids`.
fn sync_boid_count(
model_config: Res<ModelConfig>,
assets: Option<Res<BoidAssets>>,
mut commands: Commands,
boids: Query<Entity, With<Boid>>,
) {
let Some(assets) = assets else {
return;
};
let current = boids.iter().len();
let target = model_config.num_boids;
if target > current {
spawn_boids(
&mut commands,
&assets,
current..target,
target,
model_config.max_velocity,
);
} else {
for entity in boids.iter().take(current - target) {
commands.entity(entity).despawn();
}
}
}
struct Model {
separation: Vec2,
alignment: Vec2,
cohesion: Vec2,
}
fn update_boids(
model_config: Res<ModelConfig>,
window: Single<&Window, With<bevy::window::PrimaryWindow>>,
time: Res<Time<Fixed>>,
grid: Res<SpatialGrid>,
pointer: Res<PointerTarget>,
mut query: Query<(Entity, &mut Boid, &mut Transform)>,
) {
let dt = time.delta_secs();
let x_bound = window.width() * model_config.x_bound;
let y_bound = window.height() * model_config.y_bound;
let mut model_updates = HashMap::new();
for (entity, boid, transform) in query.iter() {
let mut nb_neighbors: usize = 0;
let mut separation = Vec2::new(0.0, 0.0);
let mut avg_speed = Vec2::new(0.0, 0.0);
let mut avg_pos = Vec2::new(0.0, 0.0);
for &(other_entity, other_position) in grid.neighbors(transform.translation.xy()) {
if entity == other_entity {
continue;
}
let delta = transform.translation.xy() - other_position;
let distance = delta.length();
if distance < model_config.protected_range {
if distance > f32::EPSILON {
let direction = delta / distance;
let strength = 1.0 - distance / model_config.protected_range;
separation += direction * strength;
}
} else if distance < model_config.visible_range {
let Ok((_, other_boid, _)) = query.get(other_entity) else {
continue;
};
avg_speed += other_boid.velocity;
avg_pos += other_position;
nb_neighbors += 1;
}
}
if nb_neighbors > 0 {
avg_speed /= nb_neighbors as f32;
avg_pos /= nb_neighbors as f32;
}
model_updates.insert(
entity,
Model {
separation,
alignment: avg_speed - boid.velocity,
cohesion: avg_pos - transform.translation.xy(),
},
);
}
for (entity, mut boid, mut transform) in query.iter_mut() {
let mut out_of_bounds_acceleration = Vec2::new(0.0, 0.0);
if transform.translation.x < -x_bound / 2.0 {
out_of_bounds_acceleration.x = 1.0;
} else if transform.translation.x > x_bound / 2.0 {
out_of_bounds_acceleration.x = -1.0;
}
if transform.translation.y < -y_bound / 2.0 {
out_of_bounds_acceleration.y = 1.0;
} else if transform.translation.y > y_bound / 2.0 {
out_of_bounds_acceleration.y = -1.0;
}
let out_of_bounds_acceleration = out_of_bounds_acceleration.normalize_or_zero();
let model_update = model_updates.get(&entity).unwrap();
let boundary_force = out_of_bounds_acceleration * model_config.max_velocity;
let separation_force = model_update.separation * model_config.separation_factor;
let alignment_force = model_update.alignment * model_config.alignment_factor;
let cohesion_force = model_update.cohesion * model_config.cohesion_factor;
// Push each boid forward along its current heading
let self_force = boid.velocity.normalize_or_zero() * model_config.self_acceleration;
// Pull towards the mouse cursor or touch point
let attraction_force = pointer.0.map_or(Vec2::ZERO, |target| {
(target - transform.translation.xy()).normalize_or_zero()
* model_config.attraction_force
});
let acceleration = boundary_force
+ separation_force
+ alignment_force
+ cohesion_force
+ self_force
+ attraction_force;
boid.velocity += acceleration * dt;
boid.velocity = boid.velocity.clamp_length_max(model_config.max_velocity);
transform.translation.x += boid.velocity.x * dt;
transform.translation.y += boid.velocity.y * dt;
transform.rotation = Quat::from_rotation_z(boid.velocity.y.atan2(boid.velocity.x));
}
}

44
src/config.rs Normal file
View file

@ -0,0 +1,44 @@
use bevy::prelude::*;
pub struct ConfigPlugin;
impl Plugin for ConfigPlugin {
fn build(&self, app: &mut App) {
app.init_resource::<ModelConfig>()
.register_type::<ModelConfig>();
}
}
#[derive(Reflect, Resource)]
#[reflect(Resource)]
pub struct ModelConfig {
pub num_boids: usize,
pub x_bound: f32,
pub y_bound: f32,
pub max_velocity: f32,
pub protected_range: f32,
pub visible_range: f32,
pub alignment_factor: f32,
pub cohesion_factor: f32,
pub separation_factor: f32,
pub self_acceleration: f32,
pub attraction_force: f32,
}
impl Default for ModelConfig {
fn default() -> Self {
Self {
num_boids: 250,
x_bound: 0.75,
y_bound: 0.75,
max_velocity: 250.0,
protected_range: 20.0,
visible_range: 230.0,
alignment_factor: 0.3,
cohesion_factor: 0.2,
separation_factor: 500.0,
self_acceleration: 100.0,
attraction_force: 250.0,
}
}
}

34
src/main.rs Normal file
View file

@ -0,0 +1,34 @@
mod boids;
mod config;
mod pointer;
mod sky;
mod spatial_grid;
mod ui;
use bevy::prelude::*;
fn main() {
App::new()
.insert_resource(ClearColor(Color::srgb_u8(26, 26, 46)))
.insert_resource(Time::<Fixed>::from_hz(60.0))
.add_plugins(DefaultPlugins.set(WindowPlugin {
primary_window: Some(Window {
fit_canvas_to_parent: true,
prevent_default_event_handling: false,
canvas: Some("#bevy".into()),
..default()
}),
..default()
}))
.add_plugins((
config::ConfigPlugin,
pointer::PointerPlugin,
sky::SkyPlugin,
boids::BoidsPlugin,
ui::ConfigUiPlugin,
))
.add_systems(Startup, |mut commands: Commands| {
commands.spawn(Camera2d);
})
.run();
}

28
src/pointer.rs Normal file
View file

@ -0,0 +1,28 @@
use bevy::{prelude::*, window::PrimaryWindow};
pub struct PointerPlugin;
impl Plugin for PointerPlugin {
fn build(&self, app: &mut App) {
app.init_resource::<PointerTarget>()
.add_systems(Update, update_pointer_target);
}
}
/// World position of the active touch or mouse cursor, if any.
#[derive(Resource, Default)]
pub struct PointerTarget(pub Option<Vec2>);
fn update_pointer_target(
window: Single<&Window, With<PrimaryWindow>>,
camera: Single<(&Camera, &GlobalTransform)>,
touches: Res<Touches>,
mut target: ResMut<PointerTarget>,
) {
let (camera, camera_transform) = *camera;
// Touch and cursor positions are both in logical window pixels
target.0 = touches
.first_pressed_position()
.or_else(|| window.cursor_position())
.and_then(|position| camera.viewport_to_world_2d(camera_transform, position).ok());
}

51
src/sky.rs Normal file
View file

@ -0,0 +1,51 @@
use bevy::{
prelude::*,
render::render_resource::AsBindGroup,
shader::ShaderRef,
sprite_render::{Material2d, Material2dPlugin},
window::PrimaryWindow,
};
pub struct SkyPlugin;
impl Plugin for SkyPlugin {
fn build(&self, app: &mut App) {
app.add_plugins(Material2dPlugin::<SkyMaterial>::default())
.add_systems(Startup, setup_sky)
.add_systems(Update, fit_sky_to_window);
}
}
/// Animated sky, driven by the global shader time.
#[derive(Asset, TypePath, AsBindGroup, Clone)]
struct SkyMaterial {}
impl Material2d for SkyMaterial {
fn fragment_shader() -> ShaderRef {
"shaders/sky.wgsl".into()
}
}
#[derive(Component)]
struct Sky;
fn setup_sky(
mut commands: Commands,
mut meshes: ResMut<Assets<Mesh>>,
mut materials: ResMut<Assets<SkyMaterial>>,
) {
commands.spawn((
Sky,
Mesh2d(meshes.add(Rectangle::new(1.0, 1.0))),
MeshMaterial2d(materials.add(SkyMaterial {})),
// Behind the boids
Transform::from_xyz(0.0, 0.0, -10.0),
));
}
fn fit_sky_to_window(
window: Single<&Window, (With<PrimaryWindow>, Changed<Window>)>,
mut sky: Single<&mut Transform, With<Sky>>,
) {
sky.scale = window.size().extend(1.0);
}

44
src/spatial_grid.rs Normal file
View file

@ -0,0 +1,44 @@
use bevy::{platform::collections::HashMap, prelude::*};
use crate::{boids::Boid, config::ModelConfig};
#[derive(Resource, Default)]
pub struct SpatialGrid {
pub cell_size: f32,
// Maps a grid coordinate (x, y) to the boids (and their positions) in that cell
pub cells: HashMap<(i32, i32), Vec<(Entity, Vec2)>>,
}
impl SpatialGrid {
fn cell(&self, position: Vec2) -> (i32, i32) {
let cell = (position / self.cell_size).floor();
(cell.x as i32, cell.y as i32)
}
/// Entries in the 3x3 block of cells around `position`. With `cell_size` at
/// least the search radius, this covers every boid within that radius.
pub fn neighbors(&self, position: Vec2) -> impl Iterator<Item = &(Entity, Vec2)> {
let (cx, cy) = self.cell(position);
(cx - 1..=cx + 1)
.flat_map(move |x| (cy - 1..=cy + 1).map(move |y| (x, y)))
.filter_map(|key| self.cells.get(&key))
.flatten()
}
}
pub fn update_spatial_grid(
model_config: Res<ModelConfig>,
mut grid: ResMut<SpatialGrid>,
boids: Query<(Entity, &Transform), With<Boid>>,
) {
grid.cell_size = model_config
.visible_range
.max(model_config.protected_range)
.max(1.0);
grid.cells.clear();
for (entity, transform) in &boids {
let position = transform.translation.xy();
let cell = grid.cell(position);
grid.cells.entry(cell).or_default().push((entity, position));
}
}

146
src/ui.rs Normal file
View file

@ -0,0 +1,146 @@
use bevy::{prelude::*, reflect::structs::Struct};
use crate::config::ModelConfig;
pub struct ConfigUiPlugin;
impl Plugin for ConfigUiPlugin {
fn build(&self, app: &mut App) {
app.add_systems(Startup, setup_ui).add_systems(
Update,
(
toggle_panel,
update_config_labels.run_if(resource_changed::<ModelConfig>),
),
);
}
}
/// Editable `ModelConfig` fields and the amount each -/+ click changes them by.
const CONFIG_FIELDS: &[(&str, f32)] = &[
("num_boids", 5.0),
("x_bound", 0.05),
("y_bound", 0.05),
("max_velocity", 10.0),
("protected_range", 5.0),
("visible_range", 10.0),
("alignment_factor", 0.05),
("cohesion_factor", 0.05),
("separation_factor", 10.0),
("self_acceleration", 5.0),
("attraction_force", 10.0),
];
#[derive(Component)]
struct ConfigPanel;
#[derive(Component)]
struct ConfigLabel(&'static str);
fn read_field(config: &ModelConfig, name: &str) -> String {
let field = config.field(name).unwrap();
if let Some(v) = field.try_downcast_ref::<f32>() {
format!("{v:.2}")
} else if let Some(v) = field.try_downcast_ref::<usize>() {
v.to_string()
} else {
unreachable!("unsupported ModelConfig field type for {name}")
}
}
fn step_field(config: &mut ModelConfig, name: &str, delta: f32) {
let field = config.field_mut(name).unwrap();
if let Some(v) = field.try_downcast_mut::<f32>() {
*v = (*v + delta).max(0.0);
} else if let Some(v) = field.try_downcast_mut::<usize>() {
*v = v.saturating_add_signed(delta as isize);
}
}
fn setup_ui(mut commands: Commands, model_config: Res<ModelConfig>) {
let font = TextFont::from_font_size(14.0);
commands
.spawn((
Node {
position_type: PositionType::Absolute,
top: px(10),
left: px(10),
padding: UiRect::all(px(8)),
display: Display::Grid,
// name, -, value, +
grid_template_columns: vec![RepeatedGridTrack::auto(4)],
row_gap: px(4),
column_gap: px(6),
align_items: AlignItems::Center,
border_radius: BorderRadius::all(px(6)),
..default()
},
BackgroundColor(Color::srgba(0.0, 0.0, 0.0, 0.6)),
Visibility::Hidden,
ConfigPanel,
))
.with_children(|panel| {
for &(name, step) in CONFIG_FIELDS {
panel.spawn((Text::new(name), font.clone()));
spawn_step_button(panel, "-", name, -step, &font);
panel.spawn((
Text::new(read_field(&model_config, name)),
font.clone(),
TextLayout::justify(Justify::Center),
Node {
min_width: px(60),
..default()
},
ConfigLabel(name),
));
spawn_step_button(panel, "+", name, step, &font);
}
});
}
fn spawn_step_button(
parent: &mut ChildSpawnerCommands,
label: &str,
field: &'static str,
delta: f32,
font: &TextFont,
) {
parent
.spawn((
Button,
Node {
width: px(22),
height: px(22),
justify_content: JustifyContent::Center,
align_items: AlignItems::Center,
border_radius: BorderRadius::all(px(4)),
..default()
},
BackgroundColor(Color::srgb(0.25, 0.25, 0.4)),
children![(Text::new(label), font.clone())],
))
.observe(
move |_: On<Pointer<Click>>, mut config: ResMut<ModelConfig>| {
step_field(&mut config, field, delta);
},
);
}
/// Shows or hides the config panel when M is pressed.
fn toggle_panel(
keys: Res<ButtonInput<KeyCode>>,
mut panel: Single<&mut Visibility, With<ConfigPanel>>,
) {
if keys.just_pressed(KeyCode::KeyM) {
panel.toggle_visible_hidden();
}
}
fn update_config_labels(
model_config: Res<ModelConfig>,
mut labels: Query<(&ConfigLabel, &mut Text)>,
) {
for (label, mut text) in &mut labels {
text.0 = read_field(&model_config, label.0);
}
}

View file

@ -3,29 +3,15 @@
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Personal Website</title>
<style>
html,
body {
margin: 0;
width: 100%;
height: 100%;
overflow: hidden;
}
canvas {
display: block;
}
</style>
<title>Personal website</title>
<link data-trunk rel="copy-dir" href="../assets" />
<link data-trunk rel="inline" href="style.css" />
<link data-trunk rel="rust" data-cargo-no-default-features data-wasm-opt="z" data-wasm-opt-params="--all-features"
href="../" />
</head>
<body>
<canvas id="bevy-canvas"></canvas>
<script type="module">
import init from './2dscene.js';
init();
</script>
<canvas id="bevy"></canvas>
</body>
</html>

11
web/style.css Normal file
View file

@ -0,0 +1,11 @@
* {
margin: 0;
padding: 0;
border: 0;
}
html,
body {
width: 100%;
height: 100%;
}