From ef3aab7284401dea45875870dcf5557e04d1b804 Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Tue, 24 Mar 2026 16:31:09 +0000
Subject: [PATCH 1/3] Initial plan
From c3fa5e9c79285053f34e1f20af124535df00ba31 Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Tue, 24 Mar 2026 16:53:05 +0000
Subject: [PATCH 2/3] feat: Complete 2D Boids simulation with WebGPU + Svelte 5
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- Svelte 5 + Vite 6 project scaffold
- WebGPU compute pipeline (binning → boid-logic → render)
- Ping-pong boid storage buffers with toroidal topology
- Spatial grid binning shader (atomicAdd, 64 boids/cell max)
- Boid logic shader: separation, cohesion, alignment, predator
avoidance, goal seeking, SDF obstacle avoidance, MIC toroidal
distances
- Render shader: velocity-oriented triangles, blue boids / red predators
- WebGL2 + CPU fallback (spatial hash, instanced rendering)
- App.svelte: full-screen canvas, 2D overlay for grid/goals/obstacles,
pointer events for goal/obstacle placement and drag
- ControlPanel.svelte: all sliders, toolbox, FPS display, GPU status
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
---
.gitignore | 2 +
index.html | 16 +
package-lock.json | 1422 +++++++++++++++++++++++++
package.json | 16 +
src/App.svelte | 325 ++++++
src/components/ControlPanel.svelte | 260 +++++
src/main.js | 8 +
src/simulation/BoidSimulation.js | 410 +++++++
src/simulation/WebGLFallback.js | 409 +++++++
src/simulation/shaders/binning.wgsl | 58 +
src/simulation/shaders/boidLogic.wgsl | 246 +++++
src/simulation/shaders/render.wgsl | 76 ++
vite.config.js | 6 +
13 files changed, 3254 insertions(+)
create mode 100644 .gitignore
create mode 100644 index.html
create mode 100644 package-lock.json
create mode 100644 package.json
create mode 100644 src/App.svelte
create mode 100644 src/components/ControlPanel.svelte
create mode 100644 src/main.js
create mode 100644 src/simulation/BoidSimulation.js
create mode 100644 src/simulation/WebGLFallback.js
create mode 100644 src/simulation/shaders/binning.wgsl
create mode 100644 src/simulation/shaders/boidLogic.wgsl
create mode 100644 src/simulation/shaders/render.wgsl
create mode 100644 vite.config.js
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..1eae0cf
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,2 @@
+dist/
+node_modules/
diff --git a/index.html b/index.html
new file mode 100644
index 0000000..1a3b65e
--- /dev/null
+++ b/index.html
@@ -0,0 +1,16 @@
+
+
+
+
+
+ Boids Simulation
+
+
+
+
+
+
+
diff --git a/package-lock.json b/package-lock.json
new file mode 100644
index 0000000..0412af0
--- /dev/null
+++ b/package-lock.json
@@ -0,0 +1,1422 @@
+{
+ "name": "boids",
+ "version": "0.0.0",
+ "lockfileVersion": 3,
+ "requires": true,
+ "packages": {
+ "": {
+ "name": "boids",
+ "version": "0.0.0",
+ "devDependencies": {
+ "@sveltejs/vite-plugin-svelte": "^5.0.0",
+ "svelte": "^5.0.0",
+ "vite": "^6.0.0"
+ }
+ },
+ "node_modules/@esbuild/aix-ppc64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz",
+ "integrity": "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==",
+ "cpu": [
+ "ppc64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "aix"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/android-arm": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.12.tgz",
+ "integrity": "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/android-arm64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.12.tgz",
+ "integrity": "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/android-x64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.12.tgz",
+ "integrity": "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/darwin-arm64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.12.tgz",
+ "integrity": "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/darwin-x64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.12.tgz",
+ "integrity": "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/freebsd-arm64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.12.tgz",
+ "integrity": "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/freebsd-x64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.12.tgz",
+ "integrity": "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-arm": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.12.tgz",
+ "integrity": "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-arm64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.12.tgz",
+ "integrity": "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-ia32": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.12.tgz",
+ "integrity": "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==",
+ "cpu": [
+ "ia32"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-loong64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.12.tgz",
+ "integrity": "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==",
+ "cpu": [
+ "loong64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-mips64el": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.12.tgz",
+ "integrity": "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==",
+ "cpu": [
+ "mips64el"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-ppc64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.12.tgz",
+ "integrity": "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==",
+ "cpu": [
+ "ppc64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-riscv64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.12.tgz",
+ "integrity": "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==",
+ "cpu": [
+ "riscv64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-s390x": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.12.tgz",
+ "integrity": "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==",
+ "cpu": [
+ "s390x"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-x64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.12.tgz",
+ "integrity": "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/netbsd-arm64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.12.tgz",
+ "integrity": "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "netbsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/netbsd-x64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.12.tgz",
+ "integrity": "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "netbsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/openbsd-arm64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.12.tgz",
+ "integrity": "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openbsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/openbsd-x64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.12.tgz",
+ "integrity": "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openbsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/openharmony-arm64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.12.tgz",
+ "integrity": "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openharmony"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/sunos-x64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.12.tgz",
+ "integrity": "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "sunos"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/win32-arm64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.12.tgz",
+ "integrity": "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/win32-ia32": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.12.tgz",
+ "integrity": "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==",
+ "cpu": [
+ "ia32"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/win32-x64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.12.tgz",
+ "integrity": "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@jridgewell/gen-mapping": {
+ "version": "0.3.13",
+ "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz",
+ "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@jridgewell/sourcemap-codec": "^1.5.0",
+ "@jridgewell/trace-mapping": "^0.3.24"
+ }
+ },
+ "node_modules/@jridgewell/remapping": {
+ "version": "2.3.5",
+ "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz",
+ "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@jridgewell/gen-mapping": "^0.3.5",
+ "@jridgewell/trace-mapping": "^0.3.24"
+ }
+ },
+ "node_modules/@jridgewell/resolve-uri": {
+ "version": "3.1.2",
+ "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz",
+ "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.0.0"
+ }
+ },
+ "node_modules/@jridgewell/sourcemap-codec": {
+ "version": "1.5.5",
+ "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz",
+ "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@jridgewell/trace-mapping": {
+ "version": "0.3.31",
+ "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz",
+ "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@jridgewell/resolve-uri": "^3.1.0",
+ "@jridgewell/sourcemap-codec": "^1.4.14"
+ }
+ },
+ "node_modules/@rollup/rollup-android-arm-eabi": {
+ "version": "4.60.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.60.0.tgz",
+ "integrity": "sha512-WOhNW9K8bR3kf4zLxbfg6Pxu2ybOUbB2AjMDHSQx86LIF4rH4Ft7vmMwNt0loO0eonglSNy4cpD3MKXXKQu0/A==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ]
+ },
+ "node_modules/@rollup/rollup-android-arm64": {
+ "version": "4.60.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.60.0.tgz",
+ "integrity": "sha512-u6JHLll5QKRvjciE78bQXDmqRqNs5M/3GVqZeMwvmjaNODJih/WIrJlFVEihvV0MiYFmd+ZyPr9wxOVbPAG2Iw==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ]
+ },
+ "node_modules/@rollup/rollup-darwin-arm64": {
+ "version": "4.60.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.60.0.tgz",
+ "integrity": "sha512-qEF7CsKKzSRc20Ciu2Zw1wRrBz4g56F7r/vRwY430UPp/nt1x21Q/fpJ9N5l47WWvJlkNCPJz3QRVw008fi7yA==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ]
+ },
+ "node_modules/@rollup/rollup-darwin-x64": {
+ "version": "4.60.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.60.0.tgz",
+ "integrity": "sha512-WADYozJ4QCnXCH4wPB+3FuGmDPoFseVCUrANmA5LWwGmC6FL14BWC7pcq+FstOZv3baGX65tZ378uT6WG8ynTw==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ]
+ },
+ "node_modules/@rollup/rollup-freebsd-arm64": {
+ "version": "4.60.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.60.0.tgz",
+ "integrity": "sha512-6b8wGHJlDrGeSE3aH5mGNHBjA0TTkxdoNHik5EkvPHCt351XnigA4pS7Wsj/Eo9Y8RBU6f35cjN9SYmCFBtzxw==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ]
+ },
+ "node_modules/@rollup/rollup-freebsd-x64": {
+ "version": "4.60.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.60.0.tgz",
+ "integrity": "sha512-h25Ga0t4jaylMB8M/JKAyrvvfxGRjnPQIR8lnCayyzEjEOx2EJIlIiMbhpWxDRKGKF8jbNH01NnN663dH638mA==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-arm-gnueabihf": {
+ "version": "4.60.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.60.0.tgz",
+ "integrity": "sha512-RzeBwv0B3qtVBWtcuABtSuCzToo2IEAIQrcyB/b2zMvBWVbjo8bZDjACUpnaafaxhTw2W+imQbP2BD1usasK4g==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-arm-musleabihf": {
+ "version": "4.60.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.60.0.tgz",
+ "integrity": "sha512-Sf7zusNI2CIU1HLzuu9Tc5YGAHEZs5Lu7N1ssJG4Tkw6e0MEsN7NdjUDDfGNHy2IU+ENyWT+L2obgWiguWibWQ==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-arm64-gnu": {
+ "version": "4.60.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.60.0.tgz",
+ "integrity": "sha512-DX2x7CMcrJzsE91q7/O02IJQ5/aLkVtYFryqCjduJhUfGKG6yJV8hxaw8pZa93lLEpPTP/ohdN4wFz7yp/ry9A==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-arm64-musl": {
+ "version": "4.60.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.60.0.tgz",
+ "integrity": "sha512-09EL+yFVbJZlhcQfShpswwRZ0Rg+z/CsSELFCnPt3iK+iqwGsI4zht3secj5vLEs957QvFFXnzAT0FFPIxSrkQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-loong64-gnu": {
+ "version": "4.60.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.60.0.tgz",
+ "integrity": "sha512-i9IcCMPr3EXm8EQg5jnja0Zyc1iFxJjZWlb4wr7U2Wx/GrddOuEafxRdMPRYVaXjgbhvqalp6np07hN1w9kAKw==",
+ "cpu": [
+ "loong64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-loong64-musl": {
+ "version": "4.60.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.60.0.tgz",
+ "integrity": "sha512-DGzdJK9kyJ+B78MCkWeGnpXJ91tK/iKA6HwHxF4TAlPIY7GXEvMe8hBFRgdrR9Ly4qebR/7gfUs9y2IoaVEyog==",
+ "cpu": [
+ "loong64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-ppc64-gnu": {
+ "version": "4.60.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.60.0.tgz",
+ "integrity": "sha512-RwpnLsqC8qbS8z1H1AxBA1H6qknR4YpPR9w2XX0vo2Sz10miu57PkNcnHVaZkbqyw/kUWfKMI73jhmfi9BRMUQ==",
+ "cpu": [
+ "ppc64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-ppc64-musl": {
+ "version": "4.60.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.60.0.tgz",
+ "integrity": "sha512-Z8pPf54Ly3aqtdWC3G4rFigZgNvd+qJlOE52fmko3KST9SoGfAdSRCwyoyG05q1HrrAblLbk1/PSIV+80/pxLg==",
+ "cpu": [
+ "ppc64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-riscv64-gnu": {
+ "version": "4.60.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.60.0.tgz",
+ "integrity": "sha512-3a3qQustp3COCGvnP4SvrMHnPQ9d1vzCakQVRTliaz8cIp/wULGjiGpbcqrkv0WrHTEp8bQD/B3HBjzujVWLOA==",
+ "cpu": [
+ "riscv64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-riscv64-musl": {
+ "version": "4.60.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.60.0.tgz",
+ "integrity": "sha512-pjZDsVH/1VsghMJ2/kAaxt6dL0psT6ZexQVrijczOf+PeP2BUqTHYejk3l6TlPRydggINOeNRhvpLa0AYpCWSQ==",
+ "cpu": [
+ "riscv64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-s390x-gnu": {
+ "version": "4.60.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.60.0.tgz",
+ "integrity": "sha512-3ObQs0BhvPgiUVZrN7gqCSvmFuMWvWvsjG5ayJ3Lraqv+2KhOsp+pUbigqbeWqueGIsnn+09HBw27rJ+gYK4VQ==",
+ "cpu": [
+ "s390x"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-x64-gnu": {
+ "version": "4.60.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.60.0.tgz",
+ "integrity": "sha512-EtylprDtQPdS5rXvAayrNDYoJhIz1/vzN2fEubo3yLE7tfAw+948dO0g4M0vkTVFhKojnF+n6C8bDNe+gDRdTg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-x64-musl": {
+ "version": "4.60.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.60.0.tgz",
+ "integrity": "sha512-k09oiRCi/bHU9UVFqD17r3eJR9bn03TyKraCrlz5ULFJGdJGi7VOmm9jl44vOJvRJ6P7WuBi/s2A97LxxHGIdw==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-openbsd-x64": {
+ "version": "4.60.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.60.0.tgz",
+ "integrity": "sha512-1o/0/pIhozoSaDJoDcec+IVLbnRtQmHwPV730+AOD29lHEEo4F5BEUB24H0OBdhbBBDwIOSuf7vgg0Ywxdfiiw==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openbsd"
+ ]
+ },
+ "node_modules/@rollup/rollup-openharmony-arm64": {
+ "version": "4.60.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.60.0.tgz",
+ "integrity": "sha512-pESDkos/PDzYwtyzB5p/UoNU/8fJo68vcXM9ZW2V0kjYayj1KaaUfi1NmTUTUpMn4UhU4gTuK8gIaFO4UGuMbA==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openharmony"
+ ]
+ },
+ "node_modules/@rollup/rollup-win32-arm64-msvc": {
+ "version": "4.60.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.60.0.tgz",
+ "integrity": "sha512-hj1wFStD7B1YBeYmvY+lWXZ7ey73YGPcViMShYikqKT1GtstIKQAtfUI6yrzPjAy/O7pO0VLXGmUVWXQMaYgTQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ]
+ },
+ "node_modules/@rollup/rollup-win32-ia32-msvc": {
+ "version": "4.60.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.60.0.tgz",
+ "integrity": "sha512-SyaIPFoxmUPlNDq5EHkTbiKzmSEmq/gOYFI/3HHJ8iS/v1mbugVa7dXUzcJGQfoytp9DJFLhHH4U3/eTy2Bq4w==",
+ "cpu": [
+ "ia32"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ]
+ },
+ "node_modules/@rollup/rollup-win32-x64-gnu": {
+ "version": "4.60.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.60.0.tgz",
+ "integrity": "sha512-RdcryEfzZr+lAr5kRm2ucN9aVlCCa2QNq4hXelZxb8GG0NJSazq44Z3PCCc8wISRuCVnGs0lQJVX5Vp6fKA+IA==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ]
+ },
+ "node_modules/@rollup/rollup-win32-x64-msvc": {
+ "version": "4.60.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.60.0.tgz",
+ "integrity": "sha512-PrsWNQ8BuE00O3Xsx3ALh2Df8fAj9+cvvX9AIA6o4KpATR98c9mud4XtDWVvsEuyia5U4tVSTKygawyJkjm60w==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ]
+ },
+ "node_modules/@sveltejs/acorn-typescript": {
+ "version": "1.0.9",
+ "resolved": "https://registry.npmjs.org/@sveltejs/acorn-typescript/-/acorn-typescript-1.0.9.tgz",
+ "integrity": "sha512-lVJX6qEgs/4DOcRTpo56tmKzVPtoWAaVbL4hfO7t7NVwl9AAXzQR6cihesW1BmNMPl+bK6dreu2sOKBP2Q9CIA==",
+ "dev": true,
+ "license": "MIT",
+ "peerDependencies": {
+ "acorn": "^8.9.0"
+ }
+ },
+ "node_modules/@sveltejs/vite-plugin-svelte": {
+ "version": "5.1.1",
+ "resolved": "https://registry.npmjs.org/@sveltejs/vite-plugin-svelte/-/vite-plugin-svelte-5.1.1.tgz",
+ "integrity": "sha512-Y1Cs7hhTc+a5E9Va/xwKlAJoariQyHY+5zBgCZg4PFWNYQ1nMN9sjK1zhw1gK69DuqVP++sht/1GZg1aRwmAXQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@sveltejs/vite-plugin-svelte-inspector": "^4.0.1",
+ "debug": "^4.4.1",
+ "deepmerge": "^4.3.1",
+ "kleur": "^4.1.5",
+ "magic-string": "^0.30.17",
+ "vitefu": "^1.0.6"
+ },
+ "engines": {
+ "node": "^18.0.0 || ^20.0.0 || >=22"
+ },
+ "peerDependencies": {
+ "svelte": "^5.0.0",
+ "vite": "^6.0.0"
+ }
+ },
+ "node_modules/@sveltejs/vite-plugin-svelte-inspector": {
+ "version": "4.0.1",
+ "resolved": "https://registry.npmjs.org/@sveltejs/vite-plugin-svelte-inspector/-/vite-plugin-svelte-inspector-4.0.1.tgz",
+ "integrity": "sha512-J/Nmb2Q2y7mck2hyCX4ckVHcR5tu2J+MtBEQqpDrrgELZ2uvraQcK/ioCV61AqkdXFgriksOKIceDcQmqnGhVw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "debug": "^4.3.7"
+ },
+ "engines": {
+ "node": "^18.0.0 || ^20.0.0 || >=22"
+ },
+ "peerDependencies": {
+ "@sveltejs/vite-plugin-svelte": "^5.0.0",
+ "svelte": "^5.0.0",
+ "vite": "^6.0.0"
+ }
+ },
+ "node_modules/@types/estree": {
+ "version": "1.0.8",
+ "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz",
+ "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@types/trusted-types": {
+ "version": "2.0.7",
+ "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz",
+ "integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@typescript-eslint/types": {
+ "version": "8.57.2",
+ "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.57.2.tgz",
+ "integrity": "sha512-/iZM6FnM4tnx9csuTxspMW4BOSegshwX5oBDznJ7S4WggL7Vczz5d2W11ecc4vRrQMQHXRSxzrCsyG5EsPPTbA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/typescript-eslint"
+ }
+ },
+ "node_modules/acorn": {
+ "version": "8.16.0",
+ "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz",
+ "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==",
+ "dev": true,
+ "license": "MIT",
+ "bin": {
+ "acorn": "bin/acorn"
+ },
+ "engines": {
+ "node": ">=0.4.0"
+ }
+ },
+ "node_modules/aria-query": {
+ "version": "5.3.1",
+ "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.1.tgz",
+ "integrity": "sha512-Z/ZeOgVl7bcSYZ/u/rh0fOpvEpq//LZmdbkXyc7syVzjPAhfOa9ebsdTSjEBDU4vs5nC98Kfduj1uFo0qyET3g==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/axobject-query": {
+ "version": "4.1.0",
+ "resolved": "https://registry.npmjs.org/axobject-query/-/axobject-query-4.1.0.tgz",
+ "integrity": "sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/clsx": {
+ "version": "2.1.1",
+ "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz",
+ "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/debug": {
+ "version": "4.4.3",
+ "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
+ "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "ms": "^2.1.3"
+ },
+ "engines": {
+ "node": ">=6.0"
+ },
+ "peerDependenciesMeta": {
+ "supports-color": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/deepmerge": {
+ "version": "4.3.1",
+ "resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz",
+ "integrity": "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/devalue": {
+ "version": "5.6.4",
+ "resolved": "https://registry.npmjs.org/devalue/-/devalue-5.6.4.tgz",
+ "integrity": "sha512-Gp6rDldRsFh/7XuouDbxMH3Mx8GMCcgzIb1pDTvNyn8pZGQ22u+Wa+lGV9dQCltFQ7uVw0MhRyb8XDskNFOReA==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/esbuild": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz",
+ "integrity": "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==",
+ "dev": true,
+ "hasInstallScript": true,
+ "license": "MIT",
+ "bin": {
+ "esbuild": "bin/esbuild"
+ },
+ "engines": {
+ "node": ">=18"
+ },
+ "optionalDependencies": {
+ "@esbuild/aix-ppc64": "0.25.12",
+ "@esbuild/android-arm": "0.25.12",
+ "@esbuild/android-arm64": "0.25.12",
+ "@esbuild/android-x64": "0.25.12",
+ "@esbuild/darwin-arm64": "0.25.12",
+ "@esbuild/darwin-x64": "0.25.12",
+ "@esbuild/freebsd-arm64": "0.25.12",
+ "@esbuild/freebsd-x64": "0.25.12",
+ "@esbuild/linux-arm": "0.25.12",
+ "@esbuild/linux-arm64": "0.25.12",
+ "@esbuild/linux-ia32": "0.25.12",
+ "@esbuild/linux-loong64": "0.25.12",
+ "@esbuild/linux-mips64el": "0.25.12",
+ "@esbuild/linux-ppc64": "0.25.12",
+ "@esbuild/linux-riscv64": "0.25.12",
+ "@esbuild/linux-s390x": "0.25.12",
+ "@esbuild/linux-x64": "0.25.12",
+ "@esbuild/netbsd-arm64": "0.25.12",
+ "@esbuild/netbsd-x64": "0.25.12",
+ "@esbuild/openbsd-arm64": "0.25.12",
+ "@esbuild/openbsd-x64": "0.25.12",
+ "@esbuild/openharmony-arm64": "0.25.12",
+ "@esbuild/sunos-x64": "0.25.12",
+ "@esbuild/win32-arm64": "0.25.12",
+ "@esbuild/win32-ia32": "0.25.12",
+ "@esbuild/win32-x64": "0.25.12"
+ }
+ },
+ "node_modules/esm-env": {
+ "version": "1.2.2",
+ "resolved": "https://registry.npmjs.org/esm-env/-/esm-env-1.2.2.tgz",
+ "integrity": "sha512-Epxrv+Nr/CaL4ZcFGPJIYLWFom+YeV1DqMLHJoEd9SYRxNbaFruBwfEX/kkHUJf55j2+TUbmDcmuilbP1TmXHA==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/esrap": {
+ "version": "2.2.4",
+ "resolved": "https://registry.npmjs.org/esrap/-/esrap-2.2.4.tgz",
+ "integrity": "sha512-suICpxAmZ9A8bzJjEl/+rLJiDKC0X4gYWUxT6URAWBLvlXmtbZd5ySMu/N2ZGEtMCAmflUDPSehrP9BQcsGcSg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@jridgewell/sourcemap-codec": "^1.4.15",
+ "@typescript-eslint/types": "^8.2.0"
+ }
+ },
+ "node_modules/fdir": {
+ "version": "6.5.0",
+ "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
+ "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=12.0.0"
+ },
+ "peerDependencies": {
+ "picomatch": "^3 || ^4"
+ },
+ "peerDependenciesMeta": {
+ "picomatch": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/fsevents": {
+ "version": "2.3.3",
+ "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
+ "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
+ "dev": true,
+ "hasInstallScript": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": "^8.16.0 || ^10.6.0 || >=11.0.0"
+ }
+ },
+ "node_modules/is-reference": {
+ "version": "3.0.3",
+ "resolved": "https://registry.npmjs.org/is-reference/-/is-reference-3.0.3.tgz",
+ "integrity": "sha512-ixkJoqQvAP88E6wLydLGGqCJsrFUnqoH6HnaczB8XmDH1oaWU+xxdptvikTgaEhtZ53Ky6YXiBuUI2WXLMCwjw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@types/estree": "^1.0.6"
+ }
+ },
+ "node_modules/kleur": {
+ "version": "4.1.5",
+ "resolved": "https://registry.npmjs.org/kleur/-/kleur-4.1.5.tgz",
+ "integrity": "sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/locate-character": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/locate-character/-/locate-character-3.0.0.tgz",
+ "integrity": "sha512-SW13ws7BjaeJ6p7Q6CO2nchbYEc3X3J6WrmTTDto7yMPqVSZTUyY5Tjbid+Ab8gLnATtygYtiDIJGQRRn2ZOiA==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/magic-string": {
+ "version": "0.30.21",
+ "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz",
+ "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@jridgewell/sourcemap-codec": "^1.5.5"
+ }
+ },
+ "node_modules/ms": {
+ "version": "2.1.3",
+ "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
+ "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/nanoid": {
+ "version": "3.3.11",
+ "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz",
+ "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "license": "MIT",
+ "bin": {
+ "nanoid": "bin/nanoid.cjs"
+ },
+ "engines": {
+ "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
+ }
+ },
+ "node_modules/picocolors": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
+ "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==",
+ "dev": true,
+ "license": "ISC"
+ },
+ "node_modules/picomatch": {
+ "version": "4.0.4",
+ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz",
+ "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=12"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/jonschlinkert"
+ }
+ },
+ "node_modules/postcss": {
+ "version": "8.5.8",
+ "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.8.tgz",
+ "integrity": "sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/postcss/"
+ },
+ {
+ "type": "tidelift",
+ "url": "https://tidelift.com/funding/github/npm/postcss"
+ },
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "nanoid": "^3.3.11",
+ "picocolors": "^1.1.1",
+ "source-map-js": "^1.2.1"
+ },
+ "engines": {
+ "node": "^10 || ^12 || >=14"
+ }
+ },
+ "node_modules/rollup": {
+ "version": "4.60.0",
+ "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.60.0.tgz",
+ "integrity": "sha512-yqjxruMGBQJ2gG4HtjZtAfXArHomazDHoFwFFmZZl0r7Pdo7qCIXKqKHZc8yeoMgzJJ+pO6pEEHa+V7uzWlrAQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@types/estree": "1.0.8"
+ },
+ "bin": {
+ "rollup": "dist/bin/rollup"
+ },
+ "engines": {
+ "node": ">=18.0.0",
+ "npm": ">=8.0.0"
+ },
+ "optionalDependencies": {
+ "@rollup/rollup-android-arm-eabi": "4.60.0",
+ "@rollup/rollup-android-arm64": "4.60.0",
+ "@rollup/rollup-darwin-arm64": "4.60.0",
+ "@rollup/rollup-darwin-x64": "4.60.0",
+ "@rollup/rollup-freebsd-arm64": "4.60.0",
+ "@rollup/rollup-freebsd-x64": "4.60.0",
+ "@rollup/rollup-linux-arm-gnueabihf": "4.60.0",
+ "@rollup/rollup-linux-arm-musleabihf": "4.60.0",
+ "@rollup/rollup-linux-arm64-gnu": "4.60.0",
+ "@rollup/rollup-linux-arm64-musl": "4.60.0",
+ "@rollup/rollup-linux-loong64-gnu": "4.60.0",
+ "@rollup/rollup-linux-loong64-musl": "4.60.0",
+ "@rollup/rollup-linux-ppc64-gnu": "4.60.0",
+ "@rollup/rollup-linux-ppc64-musl": "4.60.0",
+ "@rollup/rollup-linux-riscv64-gnu": "4.60.0",
+ "@rollup/rollup-linux-riscv64-musl": "4.60.0",
+ "@rollup/rollup-linux-s390x-gnu": "4.60.0",
+ "@rollup/rollup-linux-x64-gnu": "4.60.0",
+ "@rollup/rollup-linux-x64-musl": "4.60.0",
+ "@rollup/rollup-openbsd-x64": "4.60.0",
+ "@rollup/rollup-openharmony-arm64": "4.60.0",
+ "@rollup/rollup-win32-arm64-msvc": "4.60.0",
+ "@rollup/rollup-win32-ia32-msvc": "4.60.0",
+ "@rollup/rollup-win32-x64-gnu": "4.60.0",
+ "@rollup/rollup-win32-x64-msvc": "4.60.0",
+ "fsevents": "~2.3.2"
+ }
+ },
+ "node_modules/source-map-js": {
+ "version": "1.2.1",
+ "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
+ "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==",
+ "dev": true,
+ "license": "BSD-3-Clause",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/svelte": {
+ "version": "5.55.0",
+ "resolved": "https://registry.npmjs.org/svelte/-/svelte-5.55.0.tgz",
+ "integrity": "sha512-SThllKq6TRMBwPtat7ASnm/9CDXnIhBR0NPGw0ujn2DVYx9rVwsPZxDaDQcYGdUz/3BYVsCzdq7pZarRQoGvtw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@jridgewell/remapping": "^2.3.4",
+ "@jridgewell/sourcemap-codec": "^1.5.0",
+ "@sveltejs/acorn-typescript": "^1.0.5",
+ "@types/estree": "^1.0.5",
+ "@types/trusted-types": "^2.0.7",
+ "acorn": "^8.12.1",
+ "aria-query": "5.3.1",
+ "axobject-query": "^4.1.0",
+ "clsx": "^2.1.1",
+ "devalue": "^5.6.4",
+ "esm-env": "^1.2.1",
+ "esrap": "^2.2.2",
+ "is-reference": "^3.0.3",
+ "locate-character": "^3.0.0",
+ "magic-string": "^0.30.11",
+ "zimmerframe": "^1.1.2"
+ },
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/tinyglobby": {
+ "version": "0.2.15",
+ "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz",
+ "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "fdir": "^6.5.0",
+ "picomatch": "^4.0.3"
+ },
+ "engines": {
+ "node": ">=12.0.0"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/SuperchupuDev"
+ }
+ },
+ "node_modules/vite": {
+ "version": "6.4.1",
+ "resolved": "https://registry.npmjs.org/vite/-/vite-6.4.1.tgz",
+ "integrity": "sha512-+Oxm7q9hDoLMyJOYfUYBuHQo+dkAloi33apOPP56pzj+vsdJDzr+j1NISE5pyaAuKL4A3UD34qd0lx5+kfKp2g==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "esbuild": "^0.25.0",
+ "fdir": "^6.4.4",
+ "picomatch": "^4.0.2",
+ "postcss": "^8.5.3",
+ "rollup": "^4.34.9",
+ "tinyglobby": "^0.2.13"
+ },
+ "bin": {
+ "vite": "bin/vite.js"
+ },
+ "engines": {
+ "node": "^18.0.0 || ^20.0.0 || >=22.0.0"
+ },
+ "funding": {
+ "url": "https://github.com/vitejs/vite?sponsor=1"
+ },
+ "optionalDependencies": {
+ "fsevents": "~2.3.3"
+ },
+ "peerDependencies": {
+ "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0",
+ "jiti": ">=1.21.0",
+ "less": "*",
+ "lightningcss": "^1.21.0",
+ "sass": "*",
+ "sass-embedded": "*",
+ "stylus": "*",
+ "sugarss": "*",
+ "terser": "^5.16.0",
+ "tsx": "^4.8.1",
+ "yaml": "^2.4.2"
+ },
+ "peerDependenciesMeta": {
+ "@types/node": {
+ "optional": true
+ },
+ "jiti": {
+ "optional": true
+ },
+ "less": {
+ "optional": true
+ },
+ "lightningcss": {
+ "optional": true
+ },
+ "sass": {
+ "optional": true
+ },
+ "sass-embedded": {
+ "optional": true
+ },
+ "stylus": {
+ "optional": true
+ },
+ "sugarss": {
+ "optional": true
+ },
+ "terser": {
+ "optional": true
+ },
+ "tsx": {
+ "optional": true
+ },
+ "yaml": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/vitefu": {
+ "version": "1.1.2",
+ "resolved": "https://registry.npmjs.org/vitefu/-/vitefu-1.1.2.tgz",
+ "integrity": "sha512-zpKATdUbzbsycPFBN71nS2uzBUQiVnFoOrr2rvqv34S1lcAgMKKkjWleLGeiJlZ8lwCXvtWaRn7R3ZC16SYRuw==",
+ "dev": true,
+ "license": "MIT",
+ "workspaces": [
+ "tests/deps/*",
+ "tests/projects/*",
+ "tests/projects/workspace/packages/*"
+ ],
+ "peerDependencies": {
+ "vite": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-beta.0"
+ },
+ "peerDependenciesMeta": {
+ "vite": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/zimmerframe": {
+ "version": "1.1.4",
+ "resolved": "https://registry.npmjs.org/zimmerframe/-/zimmerframe-1.1.4.tgz",
+ "integrity": "sha512-B58NGBEoc8Y9MWWCQGl/gq9xBCe4IiKM0a2x7GZdQKOW5Exr8S1W24J6OgM1njK8xCRGvAJIL/MxXHf6SkmQKQ==",
+ "dev": true,
+ "license": "MIT"
+ }
+ }
+}
diff --git a/package.json b/package.json
new file mode 100644
index 0000000..9cbdbc4
--- /dev/null
+++ b/package.json
@@ -0,0 +1,16 @@
+{
+ "name": "boids",
+ "private": true,
+ "version": "0.0.0",
+ "type": "module",
+ "scripts": {
+ "dev": "vite",
+ "build": "vite build",
+ "preview": "vite preview"
+ },
+ "devDependencies": {
+ "@sveltejs/vite-plugin-svelte": "^5.0.0",
+ "svelte": "^5.0.0",
+ "vite": "^6.0.0"
+ }
+}
diff --git a/src/App.svelte b/src/App.svelte
new file mode 100644
index 0000000..747f644
--- /dev/null
+++ b/src/App.svelte
@@ -0,0 +1,325 @@
+
+
+
+
+
+
+
+
+
+
+
+
{ goals = [] }}
+ onclearObstacles={() => { obstacles = [] }}
+ />
+
+
+
diff --git a/src/components/ControlPanel.svelte b/src/components/ControlPanel.svelte
new file mode 100644
index 0000000..c45a647
--- /dev/null
+++ b/src/components/ControlPanel.svelte
@@ -0,0 +1,260 @@
+
+
+
+
+
diff --git a/src/main.js b/src/main.js
new file mode 100644
index 0000000..37b4dbb
--- /dev/null
+++ b/src/main.js
@@ -0,0 +1,8 @@
+import { mount } from 'svelte'
+import App from './App.svelte'
+
+const app = mount(App, {
+ target: document.getElementById('app'),
+})
+
+export default app
diff --git a/src/simulation/BoidSimulation.js b/src/simulation/BoidSimulation.js
new file mode 100644
index 0000000..9359df3
--- /dev/null
+++ b/src/simulation/BoidSimulation.js
@@ -0,0 +1,410 @@
+import binningShader from './shaders/binning.wgsl?raw'
+import boidLogicShader from './shaders/boidLogic.wgsl?raw'
+import renderShader from './shaders/render.wgsl?raw'
+
+// ── Buffer-layout constants (must match WGSL structs) ──
+const BOID_FLOATS = 6 // pos(2) vel(2) type(1) pad(1)
+const OBSTACLE_FLOATS = 8 // type x y param1 param2 pad*3
+const GOAL_FLOATS = 4 // x y strength pad
+
+// ── Pre-allocated maximums ──
+const MAX_TOTAL_BOIDS = 20_100 // 20 000 prey + 100 predators
+const MAX_CELLS = 65_536 // 256 × 256 grid
+const MAX_PER_CELL = 64
+const MAX_OBSTACLES = 64
+const MAX_GOALS = 16
+const MAX_GRID_DIM = 256
+
+export class BoidSimulation {
+ constructor (canvas) {
+ this.canvas = canvas
+ this.device = null
+ this.context = null
+ this.format = null
+
+ // GPU buffers
+ this.boidBufA = null
+ this.boidBufB = null
+ this.paramsBuf = null
+ this.cellCountBuf = null
+ this.cellBoidsBuf = null
+ this.obstacleBuf = null
+ this.goalBuf = null
+ this.renderParamsBuf = null
+
+ // GPU pipelines
+ this.binningPipeline = null
+ this.logicPipeline = null
+ this.renderPipeline = null
+
+ // Simulation state
+ this.numBoids = 500
+ this.numPredators = 5
+ this.width = 800
+ this.height = 600
+ this.gridWidth = 16
+ this.gridHeight = 12
+ this.params = {
+ separationWeight : 1.5,
+ cohesionWeight : 1.0,
+ alignmentWeight : 1.0,
+ perceptionRadius : 50,
+ maxSpeed : 3.0,
+ }
+ this.obstacles = []
+ this.goals = []
+ this.initialized = false
+ }
+
+ // ── Public: initialise WebGPU ─────────────────────────────────────────────
+ async init () {
+ if (!navigator.gpu) throw new Error('WebGPU not supported')
+
+ const adapter = await navigator.gpu.requestAdapter({ powerPreference: 'high-performance' })
+ if (!adapter) throw new Error('No WebGPU adapter available')
+
+ this.device = await adapter.requestDevice()
+ this.device.addEventListener('uncapturederror', ev =>
+ console.error('[WebGPU device error]', ev.error))
+
+ this.context = this.canvas.getContext('webgpu')
+ if (!this.context) throw new Error('Cannot get WebGPU context from canvas')
+
+ this.format = navigator.gpu.getPreferredCanvasFormat()
+ this.width = Math.max(1, this.canvas.width)
+ this.height = Math.max(1, this.canvas.height)
+
+ this.context.configure({
+ device : this.device,
+ format : this.format,
+ alphaMode : 'premultiplied',
+ })
+
+ this._createBuffers()
+ await this._createPipelines()
+ this._initBoids()
+ this.initialized = true
+ }
+
+ // ── Private helpers ───────────────────────────────────────────────────────
+ _createBuffers () {
+ const d = this.device
+ const bSz = MAX_TOTAL_BOIDS * BOID_FLOATS * 4
+
+ this.boidBufA = d.createBuffer({
+ label : 'boids-A',
+ size : bSz,
+ usage : GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST,
+ })
+ this.boidBufB = d.createBuffer({
+ label : 'boids-B',
+ size : bSz,
+ usage : GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST,
+ })
+
+ // Params uniform (16 × f32/u32 = 64 bytes)
+ this.paramsBuf = d.createBuffer({
+ label : 'params',
+ size : 64,
+ usage : GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,
+ })
+
+ // Spatial grid
+ this.cellCountBuf = d.createBuffer({
+ label : 'cellCount',
+ size : MAX_CELLS * 4,
+ usage : GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST,
+ })
+ this.cellBoidsBuf = d.createBuffer({
+ label : 'cellBoids',
+ size : MAX_CELLS * MAX_PER_CELL * 4,
+ usage : GPUBufferUsage.STORAGE,
+ })
+
+ this.obstacleBuf = d.createBuffer({
+ label : 'obstacles',
+ size : Math.max(MAX_OBSTACLES * OBSTACLE_FLOATS * 4, 32),
+ usage : GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST,
+ })
+ this.goalBuf = d.createBuffer({
+ label : 'goals',
+ size : Math.max(MAX_GOALS * GOAL_FLOATS * 4, 32),
+ usage : GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST,
+ })
+
+ // Render params uniform (16 bytes)
+ this.renderParamsBuf = d.createBuffer({
+ label : 'renderParams',
+ size : 16,
+ usage : GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,
+ })
+ }
+
+ async _createPipelines () {
+ const d = this.device
+
+ // Binning
+ const binMod = d.createShaderModule({ label: 'binning', code: binningShader })
+ this.binningPipeline = d.createComputePipeline({
+ label : 'binning-pipeline',
+ layout : 'auto',
+ compute : { module: binMod, entryPoint: 'main' },
+ })
+
+ // Boid logic
+ const logicMod = d.createShaderModule({ label: 'boidLogic', code: boidLogicShader })
+ this.logicPipeline = d.createComputePipeline({
+ label : 'boidLogic-pipeline',
+ layout : 'auto',
+ compute : { module: logicMod, entryPoint: 'main' },
+ })
+
+ // Render
+ const renderMod = d.createShaderModule({ label: 'render', code: renderShader })
+ this.renderPipeline = d.createRenderPipeline({
+ label : 'render-pipeline',
+ layout : 'auto',
+ vertex : { module: renderMod, entryPoint: 'vs_main' },
+ fragment : {
+ module : renderMod,
+ entryPoint : 'fs_main',
+ targets : [{
+ format : this.format,
+ blend : {
+ color : { srcFactor: 'src-alpha', dstFactor: 'one-minus-src-alpha', operation: 'add' },
+ alpha : { srcFactor: 'one', dstFactor: 'one-minus-src-alpha', operation: 'add' },
+ },
+ }],
+ },
+ primitive : { topology: 'triangle-list' },
+ })
+
+ // Surface any shader compilation errors
+ for (const [name, mod] of [['binning', binMod], ['boidLogic', logicMod], ['render', renderMod]]) {
+ const info = await mod.getCompilationInfo()
+ for (const msg of info.messages) {
+ if (msg.type === 'error')
+ throw new Error(`[${name}] shader error at line ${msg.lineNum}: ${msg.message}`)
+ if (msg.type === 'warning')
+ console.warn(`[${name}] shader warning at line ${msg.lineNum}: ${msg.message}`)
+ }
+ }
+ }
+
+ _initBoids () {
+ const total = this.numBoids + this.numPredators
+ const data = new Float32Array(MAX_TOTAL_BOIDS * BOID_FLOATS)
+ for (let i = 0; i < total; i++) {
+ const base = i * BOID_FLOATS
+ const angle = Math.random() * Math.PI * 2
+ const speed = (0.4 + Math.random() * 0.6) * this.params.maxSpeed
+ data[base + 0] = Math.random() * this.width
+ data[base + 1] = Math.random() * this.height
+ data[base + 2] = Math.cos(angle) * speed
+ data[base + 3] = Math.sin(angle) * speed
+ data[base + 4] = i < this.numBoids ? 0 : 1
+ data[base + 5] = 0
+ }
+ this.device.queue.writeBuffer(this.boidBufA, 0, data)
+ }
+
+ _writeParamsBuffer (dt) {
+ const p = this.params
+ const pr = Math.max(1, p.perceptionRadius ?? 50)
+
+ // Compute grid dimensions (capped to MAX_GRID_DIM)
+ this.gridWidth = Math.max(1, Math.min(MAX_GRID_DIM, Math.ceil(this.width / pr)))
+ this.gridHeight = Math.max(1, Math.min(MAX_GRID_DIM, Math.ceil(this.height / pr)))
+
+ const buf = new ArrayBuffer(64)
+ const f = new Float32Array(buf)
+ const u = new Uint32Array(buf)
+ u[0] = this.numBoids + this.numPredators
+ u[1] = this.numPredators
+ f[2] = this.width
+ f[3] = this.height
+ f[4] = Math.min(dt, 0.05)
+ f[5] = p.separationWeight ?? 1.5
+ f[6] = p.cohesionWeight ?? 1.0
+ f[7] = p.alignmentWeight ?? 1.0
+ f[8] = pr
+ f[9] = p.maxSpeed ?? 3.0
+ u[10] = Math.min(this.obstacles.length, MAX_OBSTACLES)
+ u[11] = Math.min(this.goals.length, MAX_GOALS)
+ u[12] = this.gridWidth
+ u[13] = this.gridHeight
+ u[14] = 0
+ u[15] = 0
+ this.device.queue.writeBuffer(this.paramsBuf, 0, buf)
+ }
+
+ _writeRenderParamsBuffer () {
+ const buf = new ArrayBuffer(16)
+ const f = new Float32Array(buf)
+ const u = new Uint32Array(buf)
+ f[0] = this.width
+ f[1] = this.height
+ u[2] = this.numBoids + this.numPredators
+ u[3] = 0
+ this.device.queue.writeBuffer(this.renderParamsBuf, 0, buf)
+ }
+
+ // ── Public: per-frame update + render ────────────────────────────────────
+ update (dt, params) {
+ if (!this.initialized || !this.device) return
+ this.params = { ...this.params, ...params }
+
+ const total = this.numBoids + this.numPredators
+ if (total === 0) return
+
+ this._writeParamsBuffer(dt)
+ this._writeRenderParamsBuffer()
+
+ const encoder = this.device.createCommandEncoder({ label: 'frame' })
+
+ // ── Pass 1a: clear cell counts ──
+ const clearSize = this.gridWidth * this.gridHeight * 4
+ encoder.clearBuffer(this.cellCountBuf, 0, clearSize)
+
+ // ── Pass 1b: spatial binning ──
+ {
+ const bg = this.device.createBindGroup({
+ layout : this.binningPipeline.getBindGroupLayout(0),
+ entries : [
+ { binding: 0, resource: { buffer: this.boidBufA } },
+ { binding: 1, resource: { buffer: this.paramsBuf } },
+ { binding: 2, resource: { buffer: this.cellCountBuf } },
+ { binding: 3, resource: { buffer: this.cellBoidsBuf } },
+ ],
+ })
+ const pass = encoder.beginComputePass({ label: 'binning' })
+ pass.setPipeline(this.binningPipeline)
+ pass.setBindGroup(0, bg)
+ pass.dispatchWorkgroups(Math.ceil(total / 64))
+ pass.end()
+ }
+
+ // ── Pass 2: boid logic (A → B) ──
+ {
+ const bg = this.device.createBindGroup({
+ layout : this.logicPipeline.getBindGroupLayout(0),
+ entries : [
+ { binding: 0, resource: { buffer: this.boidBufA } },
+ { binding: 1, resource: { buffer: this.boidBufB } },
+ { binding: 2, resource: { buffer: this.paramsBuf } },
+ { binding: 3, resource: { buffer: this.cellCountBuf } },
+ { binding: 4, resource: { buffer: this.cellBoidsBuf } },
+ { binding: 5, resource: { buffer: this.obstacleBuf } },
+ { binding: 6, resource: { buffer: this.goalBuf } },
+ ],
+ })
+ const pass = encoder.beginComputePass({ label: 'boidLogic' })
+ pass.setPipeline(this.logicPipeline)
+ pass.setBindGroup(0, bg)
+ pass.dispatchWorkgroups(Math.ceil(total / 64))
+ pass.end()
+ }
+
+ // ── Pass 3: render boids from B (just written) ──
+ {
+ const bg = this.device.createBindGroup({
+ layout : this.renderPipeline.getBindGroupLayout(0),
+ entries : [
+ { binding: 0, resource: { buffer: this.boidBufB } },
+ { binding: 1, resource: { buffer: this.renderParamsBuf } },
+ ],
+ })
+ const pass = encoder.beginRenderPass({
+ label : 'render',
+ colorAttachments : [{
+ view : this.context.getCurrentTexture().createView(),
+ clearValue : { r: 0.04, g: 0.04, b: 0.08, a: 1.0 },
+ loadOp : 'clear',
+ storeOp : 'store',
+ }],
+ })
+ pass.setPipeline(this.renderPipeline)
+ pass.setBindGroup(0, bg)
+ pass.draw(total * 3)
+ pass.end()
+ }
+
+ this.device.queue.submit([encoder.finish()])
+
+ // Ping-pong: B is now current
+ ;[this.boidBufA, this.boidBufB] = [this.boidBufB, this.boidBufA]
+ }
+
+ // ── Public: resize canvas ─────────────────────────────────────────────────
+ resize (w, h) {
+ this.width = Math.max(1, w)
+ this.height = Math.max(1, h)
+ if (this.context && this.device) {
+ this.context.configure({
+ device : this.device,
+ format : this.format,
+ alphaMode : 'premultiplied',
+ })
+ }
+ }
+
+ // ── Public: update obstacle data ─────────────────────────────────────────
+ setObstacles (obstacles) {
+ this.obstacles = obstacles
+ if (!this.device) return
+ const data = new Float32Array(MAX_OBSTACLES * OBSTACLE_FLOATS)
+ const n = Math.min(obstacles.length, MAX_OBSTACLES)
+ for (let i = 0; i < n; i++) {
+ const o = obstacles[i]
+ const base = i * OBSTACLE_FLOATS
+ data[base + 0] = o.type === 0 ? 0 : 1
+ data[base + 1] = o.x
+ data[base + 2] = o.y
+ data[base + 3] = o.type === 0 ? o.radius : o.halfW
+ data[base + 4] = o.type === 0 ? 0 : o.halfH
+ }
+ this.device.queue.writeBuffer(this.obstacleBuf, 0, data)
+ }
+
+ // ── Public: update goal data ──────────────────────────────────────────────
+ setGoals (goals) {
+ this.goals = goals
+ if (!this.device) return
+ const data = new Float32Array(MAX_GOALS * GOAL_FLOATS)
+ const n = Math.min(goals.length, MAX_GOALS)
+ for (let i = 0; i < n; i++) {
+ const g = goals[i]
+ const base = i * GOAL_FLOATS
+ data[base + 0] = g.x
+ data[base + 1] = g.y
+ data[base + 2] = g.strength ?? 1.0
+ }
+ this.device.queue.writeBuffer(this.goalBuf, 0, data)
+ }
+
+ // ── Public: params (weights etc.) ─────────────────────────────────────────
+ updateParams (params) {
+ this.params = { ...this.params, ...params }
+ }
+
+ // ── Public: change boid / predator count ──────────────────────────────────
+ setBoidCount (numBoids, numPredators) {
+ this.numBoids = Math.max(0, Math.min(numBoids, 20_000))
+ this.numPredators = Math.max(0, Math.min(numPredators, 100))
+ if (!this.device) return
+ // Re-scatter all agents in the scene
+ this._initBoids()
+ }
+
+ // ── Public: cleanup ───────────────────────────────────────────────────────
+ destroy () {
+ this.initialized = false
+ ;[
+ this.boidBufA, this.boidBufB, this.paramsBuf,
+ this.cellCountBuf, this.cellBoidsBuf,
+ this.obstacleBuf, this.goalBuf, this.renderParamsBuf,
+ ].forEach(b => b?.destroy())
+ this.context?.unconfigure()
+ this.device?.destroy()
+ }
+}
diff --git a/src/simulation/WebGLFallback.js b/src/simulation/WebGLFallback.js
new file mode 100644
index 0000000..92bc349
--- /dev/null
+++ b/src/simulation/WebGLFallback.js
@@ -0,0 +1,409 @@
+// ─── WebGL2 + CPU Fallback ────────────────────────────────────────────────
+// Uses JavaScript for boid simulation and WebGL2 (or 2D Canvas) for rendering.
+
+const VERT_SRC = /* glsl */ `#version 300 es
+precision highp float;
+
+// Per-vertex (triangle template, divisor = 0)
+in vec2 aTriVert;
+
+// Per-instance boid data (divisor = 1)
+in vec2 aPos;
+in vec2 aVel;
+in float aType;
+
+uniform vec2 uSize;
+
+out vec4 vColor;
+
+void main() {
+ vec2 fwd = length(aVel) > 0.001 ? normalize(aVel) : vec2(0.0, -1.0);
+ vec2 rgt = vec2(-fwd.y, fwd.x);
+ float sz = aType > 0.5 ? 7.0 : 4.5;
+
+ vec2 world = aPos + fwd * (aTriVert.y * sz) + rgt * (aTriVert.x * sz);
+ gl_Position = vec4(
+ world.x / uSize.x * 2.0 - 1.0,
+ 1.0 - world.y / uSize.y * 2.0,
+ 0.0, 1.0
+ );
+ vColor = aType > 0.5
+ ? vec4(0.95, 0.22, 0.22, 1.0)
+ : vec4(0.25, 0.55, 0.95, 1.0);
+}
+`
+
+const FRAG_SRC = /* glsl */ `#version 300 es
+precision mediump float;
+in vec4 vColor;
+out vec4 fragColor;
+void main() { fragColor = vColor; }
+`
+
+// Triangle template in model space (tip forward, base behind)
+const TRI_VERTS = new Float32Array([
+ 0.0, 2.2, // tip
+ -1.0, -1.0, // rear-left
+ 1.0, -1.0, // rear-right
+])
+
+export class WebGLFallback {
+ constructor (canvas) {
+ this.canvas = canvas
+ this.gl = null
+ this.ctx2d = null // ultimate fallback
+ this.program = null
+ this.vao = null
+ this.triBuf = null
+ this.instanceBuf = null
+ this.uSizeLoc = null
+
+ this.boids = [] // { x, y, vx, vy, type }
+ this.numBoids = 500
+ this.numPredators = 5
+ this.width = 800
+ this.height = 600
+ this.params = {
+ separationWeight : 1.5,
+ cohesionWeight : 1.0,
+ alignmentWeight : 1.0,
+ perceptionRadius : 50,
+ maxSpeed : 3.0,
+ }
+ this.obstacles = []
+ this.goals = []
+ }
+
+ async init () {
+ const gl = this.canvas.getContext('webgl2')
+ if (gl) {
+ this.gl = gl
+ this._initGL()
+ } else {
+ // Ultimate fallback: plain 2D canvas
+ this.ctx2d = this.canvas.getContext('2d')
+ }
+ this.width = Math.max(1, this.canvas.width)
+ this.height = Math.max(1, this.canvas.height)
+ this._spawnBoids()
+ }
+
+ // ── WebGL2 setup ──────────────────────────────────────────────────────────
+ _initGL () {
+ const gl = this.gl
+ const vs = this._compileShader(gl.VERTEX_SHADER, VERT_SRC)
+ const fs = this._compileShader(gl.FRAGMENT_SHADER, FRAG_SRC)
+
+ this.program = gl.createProgram()
+ gl.attachShader(this.program, vs)
+ gl.attachShader(this.program, fs)
+ gl.linkProgram(this.program)
+ if (!gl.getProgramParameter(this.program, gl.LINK_STATUS))
+ throw new Error('GL link error: ' + gl.getProgramInfoLog(this.program))
+
+ this.uSizeLoc = gl.getUniformLocation(this.program, 'uSize')
+
+ this.vao = gl.createVertexArray()
+ gl.bindVertexArray(this.vao)
+
+ // Triangle template buffer (shared vertices, divisor 0)
+ this.triBuf = gl.createBuffer()
+ gl.bindBuffer(gl.ARRAY_BUFFER, this.triBuf)
+ gl.bufferData(gl.ARRAY_BUFFER, TRI_VERTS, gl.STATIC_DRAW)
+ const triLoc = gl.getAttribLocation(this.program, 'aTriVert')
+ gl.enableVertexAttribArray(triLoc)
+ gl.vertexAttribPointer(triLoc, 2, gl.FLOAT, false, 0, 0)
+ gl.vertexAttribDivisor(triLoc, 0) // same for every instance vertex
+
+ // Instance buffer (updated every frame, divisor 1)
+ this.instanceBuf = gl.createBuffer()
+ gl.bindBuffer(gl.ARRAY_BUFFER, this.instanceBuf)
+ const stride = 6 * 4 // x y vx vy type pad
+ const posLoc = gl.getAttribLocation(this.program, 'aPos')
+ const velLoc = gl.getAttribLocation(this.program, 'aVel')
+ const typeLoc = gl.getAttribLocation(this.program, 'aType')
+ gl.enableVertexAttribArray(posLoc); gl.vertexAttribPointer(posLoc, 2, gl.FLOAT, false, stride, 0); gl.vertexAttribDivisor(posLoc, 1)
+ gl.enableVertexAttribArray(velLoc); gl.vertexAttribPointer(velLoc, 2, gl.FLOAT, false, stride, 8); gl.vertexAttribDivisor(velLoc, 1)
+ gl.enableVertexAttribArray(typeLoc); gl.vertexAttribPointer(typeLoc, 1, gl.FLOAT, false, stride, 16); gl.vertexAttribDivisor(typeLoc, 1)
+
+ gl.bindVertexArray(null)
+
+ gl.enable(gl.BLEND)
+ gl.blendFunc(gl.SRC_ALPHA, gl.ONE_MINUS_SRC_ALPHA)
+ }
+
+ _compileShader (type, src) {
+ const gl = this.gl
+ const sh = gl.createShader(type)
+ gl.shaderSource(sh, src)
+ gl.compileShader(sh)
+ if (!gl.getShaderParameter(sh, gl.COMPILE_STATUS))
+ throw new Error('GL shader error: ' + gl.getShaderInfoLog(sh))
+ return sh
+ }
+
+ // ── Boid initialisation ───────────────────────────────────────────────────
+ _spawnBoids () {
+ const total = this.numBoids + this.numPredators
+ this.boids = []
+ for (let i = 0; i < total; i++) {
+ const angle = Math.random() * Math.PI * 2
+ const speed = (0.4 + Math.random() * 0.6) * this.params.maxSpeed
+ this.boids.push({
+ x : Math.random() * this.width,
+ y : Math.random() * this.height,
+ vx : Math.cos(angle) * speed,
+ vy : Math.sin(angle) * speed,
+ type : i < this.numBoids ? 0 : 1,
+ })
+ }
+ }
+
+ // ── Spatial grid (CPU) ────────────────────────────────────────────────────
+ _buildGrid () {
+ const pr = Math.max(1, this.params.perceptionRadius)
+ const gw = Math.max(1, Math.ceil(this.width / pr))
+ const gh = Math.max(1, Math.ceil(this.height / pr))
+ const grid = new Array(gw * gh).fill(null).map(() => [])
+ for (let i = 0; i < this.boids.length; i++) {
+ const b = this.boids[i]
+ const cx = Math.floor(b.x / pr) % gw
+ const cy = Math.floor(b.y / pr) % gh
+ grid[cy * gw + cx].push(i)
+ }
+ return { grid, gw, gh, pr }
+ }
+
+ // Minimum Image Convention
+ _minDiff (a, b, W) {
+ let d = b - a
+ if (d > W * 0.5) d -= W
+ if (d < -W * 0.5) d += W
+ return d
+ }
+
+ // ── CPU boid update ───────────────────────────────────────────────────────
+ _stepCPU (dt) {
+ const { separationWeight, cohesionWeight, alignmentWeight, perceptionRadius, maxSpeed } = this.params
+ const W = this.width
+ const H = this.height
+ const pr = Math.max(1, perceptionRadius)
+ const { grid, gw, gh } = this._buildGrid()
+
+ const next = this.boids.map((b, i) => {
+ const cx = Math.floor(b.x / pr) % gw
+ const cy = Math.floor(b.y / pr) % gh
+
+ let sepX = 0, sepY = 0
+ let cohX = 0, cohY = 0
+ let aliX = 0, aliY = 0
+ let nn = 0
+
+ let predAvX = 0, predAvY = 0
+
+ let preyN = 0, preyX = 0, preyY = 0
+
+ for (let dy = -1; dy <= 1; dy++) {
+ for (let dx = -1; dx <= 1; dx++) {
+ const nx = ((cx + dx) % gw + gw) % gw
+ const ny = ((cy + dy) % gh + gh) % gh
+ const cell = grid[ny * gw + nx]
+ for (const j of cell) {
+ if (j === i) continue
+ const o = this.boids[j]
+ const ddx = this._minDiff(b.x, o.x, W)
+ const ddy = this._minDiff(b.y, o.y, H)
+ const dist = Math.hypot(ddx, ddy)
+ if (dist >= pr || dist < 0.0001) continue
+
+ if (b.type === 0 && o.type === 0) {
+ const sepR = pr * 0.35
+ if (dist < sepR) {
+ sepX -= ddx / (dist * dist + 0.01)
+ sepY -= ddy / (dist * dist + 0.01)
+ }
+ cohX += ddx; cohY += ddy
+ aliX += o.vx; aliY += o.vy
+ nn++
+ } else if (b.type === 0 && o.type === 1 && dist < pr * 1.6) {
+ const w = pr * 1.6 - dist
+ predAvX -= (ddx / dist) * w
+ predAvY -= (ddy / dist) * w
+ } else if (b.type === 1 && o.type === 0) {
+ preyN++; preyX += b.x + ddx; preyY += b.y + ddy
+ }
+ }
+ }
+ }
+
+ let ax = 0, ay = 0
+
+ if (b.type === 0) {
+ const nc = Math.max(nn, 1)
+ ax += sepX * separationWeight
+ ay += sepY * separationWeight
+ ax += (cohX / nc) * cohesionWeight * 0.015
+ ay += (cohY / nc) * cohesionWeight * 0.015
+ ax += (aliX / nc - b.vx) * alignmentWeight * 0.08
+ ay += (aliY / nc - b.vy) * alignmentWeight * 0.08
+ ax += predAvX * 2.5
+ ay += predAvY * 2.5
+
+ // Goal seeking
+ for (const g of this.goals) {
+ const gdx = this._minDiff(b.x, g.x, W)
+ const gdy = this._minDiff(b.y, g.y, H)
+ const gd = Math.hypot(gdx, gdy)
+ if (gd > 1) {
+ ax += (gdx / gd) * (g.strength ?? 1) * 0.4
+ ay += (gdy / gd) * (g.strength ?? 1) * 0.4
+ }
+ }
+ } else {
+ // Predator pursuit
+ if (preyN > 0) {
+ const pcx = preyX / preyN
+ const pcy = preyY / preyN
+ const pdx = this._minDiff(b.x, pcx, W)
+ const pdy = this._minDiff(b.y, pcy, H)
+ const pd = Math.hypot(pdx, pdy)
+ if (pd > 1) {
+ const desired = { x: (pdx / pd) * maxSpeed * 1.25, y: (pdy / pd) * maxSpeed * 1.25 }
+ ax += (desired.x - b.vx) * 0.07
+ ay += (desired.y - b.vy) * 0.07
+ }
+ } else {
+ ax += b.vx * -0.005
+ ay += b.vy * -0.005
+ }
+ }
+
+ // Obstacle avoidance
+ for (const obs of this.obstacles) {
+ let dist, nx2, ny2
+ if (obs.type === 0) {
+ const dx2 = b.x - obs.x; const dy2 = b.y - obs.y
+ const d = Math.hypot(dx2, dy2)
+ dist = d - obs.radius
+ nx2 = d > 0.001 ? dx2 / d : 1
+ ny2 = d > 0.001 ? dy2 / d : 0
+ } else {
+ const dx2 = b.x - obs.x; const dy2 = b.y - obs.y
+ const qx = Math.abs(dx2) - obs.halfW; const qy = Math.abs(dy2) - obs.halfH
+ dist = Math.hypot(Math.max(qx, 0), Math.max(qy, 0)) + Math.min(Math.max(qx, qy), 0)
+ nx2 = Math.abs(dx2) / Math.max(obs.halfW, 0.001) > Math.abs(dy2) / Math.max(obs.halfH, 0.001) ? Math.sign(dx2) : 0
+ ny2 = nx2 === 0 ? Math.sign(dy2) : 0
+ }
+ const ar = pr * 0.7
+ if (dist < ar) {
+ const t = (ar - dist) / ar
+ ax += nx2 * t * t * 5
+ ay += ny2 * t * t * 5
+ }
+ }
+
+ let nvx = b.vx + ax
+ let nvy = b.vy + ay
+ const maxSpd = b.type === 1 ? maxSpeed * 1.3 : maxSpeed
+ const spd = Math.hypot(nvx, nvy)
+ if (spd > maxSpd && spd > 0.0001) { nvx = (nvx / spd) * maxSpd; nvy = (nvy / spd) * maxSpd }
+ const minSpd = maxSpd * 0.08
+ if (spd < minSpd && spd > 0.0001) { nvx = (nvx / spd) * minSpd; nvy = (nvy / spd) * minSpd }
+
+ let nx3 = b.x + nvx * dt
+ let ny3 = b.y + nvy * dt
+ nx3 = ((nx3 % W) + W) % W
+ ny3 = ((ny3 % H) + H) % H
+
+ return { x: nx3, y: ny3, vx: nvx, vy: nvy, type: b.type }
+ })
+
+ this.boids = next
+ }
+
+ // ── Rendering (WebGL2) ────────────────────────────────────────────────────
+ _renderGL () {
+ const gl = this.gl
+ const total = this.boids.length
+ gl.viewport(0, 0, this.width, this.height)
+ gl.clearColor(0.04, 0.04, 0.08, 1)
+ gl.clear(gl.COLOR_BUFFER_BIT)
+
+ if (total === 0) return
+
+ // Pack instance data
+ const data = new Float32Array(total * 6)
+ for (let i = 0; i < total; i++) {
+ const b = this.boids[i]
+ data[i * 6 + 0] = b.x; data[i * 6 + 1] = b.y
+ data[i * 6 + 2] = b.vx; data[i * 6 + 3] = b.vy
+ data[i * 6 + 4] = b.type
+ }
+
+ gl.bindBuffer(gl.ARRAY_BUFFER, this.instanceBuf)
+ gl.bufferData(gl.ARRAY_BUFFER, data, gl.DYNAMIC_DRAW)
+
+ gl.useProgram(this.program)
+ gl.uniform2f(this.uSizeLoc, this.width, this.height)
+ gl.bindVertexArray(this.vao)
+ gl.drawArraysInstanced(gl.TRIANGLES, 0, 3, total)
+ gl.bindVertexArray(null)
+ }
+
+ // ── Rendering (2D Canvas fallback) ────────────────────────────────────────
+ _render2D () {
+ const ctx = this.ctx2d
+ ctx.fillStyle = 'rgba(10, 10, 20, 1)'
+ ctx.fillRect(0, 0, this.width, this.height)
+
+ for (const b of this.boids) {
+ const spd = Math.hypot(b.vx, b.vy)
+ const fwdX = spd > 0.001 ? b.vx / spd : 0
+ const fwdY = spd > 0.001 ? b.vy / spd : -1
+ const sz = b.type === 1 ? 7 : 4.5
+
+ ctx.save()
+ ctx.translate(b.x, b.y)
+ ctx.beginPath()
+ ctx.moveTo( fwdX * sz * 2.2, fwdY * sz * 2.2)
+ ctx.lineTo(-fwdY * sz - fwdX * sz, fwdX * sz - fwdY * sz)
+ ctx.lineTo( fwdY * sz - fwdX * sz, -fwdX * sz - fwdY * sz)
+ ctx.closePath()
+ ctx.fillStyle = b.type === 1 ? '#f43' : '#4af'
+ ctx.fill()
+ ctx.restore()
+ }
+ }
+
+ // ── Public API (mirrors BoidSimulation) ───────────────────────────────────
+ update (dt, params) {
+ this.params = { ...this.params, ...params }
+ this._stepCPU(Math.min(dt, 0.05))
+ if (this.gl) this._renderGL()
+ else if (this.ctx2d) this._render2D()
+ }
+
+ resize (w, h) {
+ this.width = Math.max(1, w)
+ this.height = Math.max(1, h)
+ }
+
+ setObstacles (obstacles) { this.obstacles = obstacles }
+ setGoals (goals) { this.goals = goals }
+ updateParams (params) { this.params = { ...this.params, ...params } }
+
+ setBoidCount (numBoids, numPredators) {
+ this.numBoids = Math.max(0, Math.min(numBoids, 5_000))
+ this.numPredators = Math.max(0, Math.min(numPredators, 50))
+ this._spawnBoids()
+ }
+
+ destroy () {
+ const gl = this.gl
+ if (!gl) return
+ gl.deleteProgram(this.program)
+ gl.deleteBuffer(this.triBuf)
+ gl.deleteBuffer(this.instanceBuf)
+ gl.deleteVertexArray(this.vao)
+ }
+}
diff --git a/src/simulation/shaders/binning.wgsl b/src/simulation/shaders/binning.wgsl
new file mode 100644
index 0000000..58206ec
--- /dev/null
+++ b/src/simulation/shaders/binning.wgsl
@@ -0,0 +1,58 @@
+// ─── Spatial-Grid Binning Pass ──────────────────────────────────────────────
+// Clears are done via encoder.clearBuffer before dispatch.
+// Each boid atomically claims a slot in its grid cell.
+
+struct Boid {
+ pos : vec2f,
+ vel : vec2f,
+ boidType : f32,
+ _pad : f32,
+}
+
+struct Params {
+ numBoids : u32, // total = boids + predators
+ numPredators : u32,
+ width : f32,
+ height : f32,
+ dt : f32,
+ separationWeight: f32,
+ cohesionWeight : f32,
+ alignmentWeight : f32,
+ perceptionRadius: f32,
+ maxSpeed : f32,
+ numObstacles : u32,
+ numGoals : u32,
+ gridWidth : u32,
+ gridHeight : u32,
+ _pad1 : u32,
+ _pad2 : u32,
+}
+
+@group(0) @binding(0) var boids : array;
+@group(0) @binding(1) var params : Params;
+@group(0) @binding(2) var cellCount : array>;
+@group(0) @binding(3) var cellBoids : array;
+
+const MAX_PER_CELL : u32 = 64u;
+
+@compute @workgroup_size(64)
+fn main(@builtin(global_invocation_id) gid : vec3u) {
+ let i = gid.x;
+ if i >= params.numBoids { return; }
+
+ let pos = boids[i].pos;
+ let cs = params.perceptionRadius;
+
+ // Clamp to valid cell range
+ let rawX = floor(pos.x / cs);
+ let rawY = floor(pos.y / cs);
+ let cx = u32(clamp(rawX, 0.0, f32(params.gridWidth - 1u)));
+ let cy = u32(clamp(rawY, 0.0, f32(params.gridHeight - 1u)));
+
+ let cellIdx = cy * params.gridWidth + cx;
+
+ let slot = atomicAdd(&cellCount[cellIdx], 1u);
+ if slot < MAX_PER_CELL {
+ cellBoids[cellIdx * MAX_PER_CELL + slot] = i;
+ }
+}
diff --git a/src/simulation/shaders/boidLogic.wgsl b/src/simulation/shaders/boidLogic.wgsl
new file mode 100644
index 0000000..8f06b22
--- /dev/null
+++ b/src/simulation/shaders/boidLogic.wgsl
@@ -0,0 +1,246 @@
+// ─── Boid Logic Pass ────────────────────────────────────────────────────────
+// Reads from boidsIn + spatial grid, writes updated state to boidsOut.
+// Uses Minimum Image Convention for toroidal (wrap-around) topology.
+
+struct Boid {
+ pos : vec2f,
+ vel : vec2f,
+ boidType : f32,
+ _pad : f32,
+}
+
+struct Params {
+ numBoids : u32,
+ numPredators : u32,
+ width : f32,
+ height : f32,
+ dt : f32,
+ separationWeight: f32,
+ cohesionWeight : f32,
+ alignmentWeight : f32,
+ perceptionRadius: f32,
+ maxSpeed : f32,
+ numObstacles : u32,
+ numGoals : u32,
+ gridWidth : u32,
+ gridHeight : u32,
+ _pad1 : u32,
+ _pad2 : u32,
+}
+
+struct Obstacle {
+ obsType : f32, // 0 = circle, 1 = box
+ x : f32,
+ y : f32,
+ param1 : f32, // radius (circle) | halfW (box)
+ param2 : f32, // 0 (circle) | halfH (box)
+ _p1 : f32,
+ _p2 : f32,
+ _p3 : f32,
+}
+
+struct Goal {
+ x : f32,
+ y : f32,
+ strength : f32,
+ _pad : f32,
+}
+
+@group(0) @binding(0) var boidsIn : array;
+@group(0) @binding(1) var boidsOut : array;
+@group(0) @binding(2) var params : Params;
+@group(0) @binding(3) var cellCount : array;
+@group(0) @binding(4) var cellBoids : array;
+@group(0) @binding(5) var obstacles : array;
+@group(0) @binding(6) var goals : array;
+
+const MAX_PER_CELL : u32 = 64u;
+
+// Minimum Image Convention – shortest vector on torus
+fn toroidalDiff(from_p : vec2f, to_p : vec2f, sz : vec2f) -> vec2f {
+ var d = to_p - from_p;
+ if d.x > sz.x * 0.5 { d.x -= sz.x; }
+ else if d.x < -sz.x * 0.5 { d.x += sz.x; }
+ if d.y > sz.y * 0.5 { d.y -= sz.y; }
+ else if d.y < -sz.y * 0.5 { d.y += sz.y; }
+ return d;
+}
+
+// Returns a repulsion vector away from all obstacles
+fn obstacleForce(pos : vec2f) -> vec2f {
+ var force = vec2f(0.0);
+ let avoidR = params.perceptionRadius * 0.7;
+
+ for (var i = 0u; i < params.numObstacles; i = i + 1u) {
+ let obs = obstacles[i];
+ let center = vec2f(obs.x, obs.y);
+ var sdf : f32;
+ var normal : vec2f;
+
+ if obs.obsType < 0.5 {
+ // Circle SDF
+ let dp = pos - center;
+ let d = length(dp);
+ sdf = d - obs.param1;
+ normal = select(vec2f(1.0, 0.0), dp / d, d > 0.001);
+ } else {
+ // Box SDF
+ let dp = pos - center;
+ let q = abs(dp) - vec2f(obs.param1, obs.param2);
+ let ext = length(max(q, vec2f(0.0)));
+ sdf = ext + min(max(q.x, q.y), 0.0);
+ // Approximate outward normal
+ if abs(dp.x) / max(obs.param1, 0.001) > abs(dp.y) / max(obs.param2, 0.001) {
+ normal = vec2f(sign(dp.x), 0.0);
+ } else {
+ normal = vec2f(0.0, sign(dp.y));
+ }
+ }
+
+ if sdf < avoidR {
+ let t = (avoidR - sdf) / avoidR;
+ force += normal * t * t * 5.0;
+ }
+ }
+ return force;
+}
+
+@compute @workgroup_size(64)
+fn main(@builtin(global_invocation_id) gid : vec3u) {
+ let i = gid.x;
+ let total = params.numBoids;
+ if i >= total { return; }
+
+ let boid = boidsIn[i];
+ let pos = boid.pos;
+ let vel = boid.vel;
+ let isBoid = boid.boidType < 0.5;
+ let sz = vec2f(params.width, params.height);
+ let cs = params.perceptionRadius;
+ let gw = i32(params.gridWidth);
+ let gh = i32(params.gridHeight);
+ let cx = i32(floor(pos.x / cs));
+ let cy = i32(floor(pos.y / cs));
+
+ // Per-rule accumulators
+ var sepForce = vec2f(0.0);
+ var cohSum = vec2f(0.0);
+ var aliSum = vec2f(0.0);
+ var predAvoid= vec2f(0.0);
+ var neighborN= 0u;
+ var predN = 0u;
+
+ // Predator: track nearby prey centre
+ var preyN = 0u;
+ var preySum = vec2f(0.0);
+
+ // 3 × 3 grid neighbourhood with toroidal wrapping
+ for (var dy : i32 = -1; dy <= 1; dy = dy + 1) {
+ for (var dx : i32 = -1; dx <= 1; dx = dx + 1) {
+ let nx = ((cx + dx) % gw + gw) % gw;
+ let ny = ((cy + dy) % gh + gh) % gh;
+ let cellIdx = u32(ny) * params.gridWidth + u32(nx);
+ let cnt = min(cellCount[cellIdx], MAX_PER_CELL);
+
+ for (var k = 0u; k < cnt; k = k + 1u) {
+ let j = cellBoids[cellIdx * MAX_PER_CELL + k];
+ if j == i { continue; }
+
+ let other = boidsIn[j];
+ let diff = toroidalDiff(pos, other.pos, sz);
+ let dist = length(diff);
+
+ if dist >= cs || dist < 0.0001 { continue; }
+
+ let otherIsBoid = other.boidType < 0.5;
+
+ if isBoid && otherIsBoid {
+ // ── Flocking rules ──
+ let sepR = cs * 0.35;
+ if dist < sepR {
+ sepForce -= diff / (dist * dist + 0.01);
+ }
+ cohSum += diff;
+ aliSum += other.vel;
+ neighborN = neighborN + 1u;
+
+ } else if isBoid && !otherIsBoid {
+ // ── Boid evades predator (extended radius) ──
+ if dist < cs * 1.6 {
+ let w = cs * 1.6 - dist;
+ predAvoid -= normalize(diff) * w;
+ predN = predN + 1u;
+ }
+
+ } else if !isBoid && otherIsBoid {
+ // ── Predator tracks prey ──
+ preyN = preyN + 1u;
+ preySum = preySum + (pos + diff); // world-space prey pos
+ }
+ }
+ }
+ }
+
+ var newVel = vel;
+
+ if isBoid {
+ let nc = f32(max(neighborN, 1u));
+
+ // Separation
+ newVel += sepForce * params.separationWeight;
+ // Cohesion: steer toward local centre
+ newVel += (cohSum / nc) * params.cohesionWeight * 0.015;
+ // Alignment: match neighbours' velocity
+ newVel += (aliSum / nc - vel) * params.alignmentWeight * 0.08;
+
+ // Predator avoidance
+ if predN > 0u {
+ newVel += predAvoid * 2.5;
+ }
+
+ // Goal seeking
+ for (var g = 0u; g < params.numGoals; g = g + 1u) {
+ let gp = vec2f(goals[g].x, goals[g].y);
+ let gd = toroidalDiff(pos, gp, sz);
+ let gdst = length(gd);
+ if gdst > 1.0 {
+ newVel += normalize(gd) * goals[g].strength * 0.4;
+ }
+ }
+
+ } else {
+ // ── Predator: pursue prey flock centre ──
+ if preyN > 0u {
+ let preyCenter = preySum / f32(preyN);
+ let pd = toroidalDiff(pos, preyCenter, sz);
+ let pdst = length(pd);
+ if pdst > 1.0 {
+ let desired = normalize(pd) * params.maxSpeed * 1.25;
+ newVel += (desired - vel) * 0.07;
+ }
+ } else {
+ newVel *= 0.995; // slow wandering
+ }
+ }
+
+ // Obstacle avoidance for all agents
+ newVel += obstacleForce(pos);
+
+ // ── Speed clamping ──
+ let maxSpd = select(params.maxSpeed, params.maxSpeed * 1.3, !isBoid);
+ let spd = length(newVel);
+ if spd > maxSpd && spd > 0.0001 {
+ newVel = (newVel / spd) * maxSpd;
+ }
+ let minSpd = maxSpd * 0.08;
+ if spd < minSpd && spd > 0.0001 {
+ newVel = (newVel / spd) * minSpd;
+ }
+
+ // ── Toroidal position update ──
+ var newPos = pos + newVel * params.dt;
+ newPos.x = ((newPos.x % params.width) + params.width) % params.width;
+ newPos.y = ((newPos.y % params.height) + params.height) % params.height;
+
+ boidsOut[i] = Boid(newPos, newVel, boid.boidType, 0.0);
+}
diff --git a/src/simulation/shaders/render.wgsl b/src/simulation/shaders/render.wgsl
new file mode 100644
index 0000000..e5d9532
--- /dev/null
+++ b/src/simulation/shaders/render.wgsl
@@ -0,0 +1,76 @@
+// ─── Render Shader ───────────────────────────────────────────────────────────
+// Vertex-pulling from storage buffer; each boid = one triangle (3 verts).
+
+struct Boid {
+ pos : vec2f,
+ vel : vec2f,
+ boidType : f32,
+ _pad : f32,
+}
+
+struct RenderParams {
+ width : f32,
+ height : f32,
+ total : u32,
+ _pad : u32,
+}
+
+@group(0) @binding(0) var boids : array;
+@group(0) @binding(1) var rp : RenderParams;
+
+struct VOut {
+ @builtin(position) pos : vec4f,
+ @location(0) color : vec4f,
+}
+
+@vertex
+fn vs_main(@builtin(vertex_index) vi : u32) -> VOut {
+ let boidIdx = vi / 3u;
+ let triVtx = vi % 3u;
+
+ let b = boids[boidIdx];
+ let spd = length(b.vel);
+
+ // Forward direction (velocity normalised, default up)
+ var fwd = vec2f(0.0, -1.0);
+ if spd > 0.001 {
+ fwd = b.vel / spd;
+ }
+ let rgt = vec2f(-fwd.y, fwd.x);
+
+ // Predators are slightly larger
+ let sz = select(4.5, 7.0, b.boidType > 0.5);
+
+ // Triangle vertices in model space (tip forward, base behind)
+ var lp : vec2f;
+ if triVtx == 0u {
+ lp = fwd * sz * 2.2; // tip
+ } else if triVtx == 1u {
+ lp = fwd * (-sz) + rgt * sz; // rear-left
+ } else {
+ lp = fwd * (-sz) - rgt * sz; // rear-right
+ }
+
+ let wp = b.pos + lp;
+ let ndc = vec2f(
+ wp.x / rp.width * 2.0 - 1.0,
+ 1.0 - wp.y / rp.height * 2.0,
+ );
+
+ // Blue for boids, red for predators
+ let color = select(
+ vec4f(0.25, 0.55, 0.95, 1.0), // boid (false)
+ vec4f(0.95, 0.22, 0.22, 1.0), // predator (true)
+ b.boidType > 0.5,
+ );
+
+ var o : VOut;
+ o.pos = vec4f(ndc, 0.0, 1.0);
+ o.color = color;
+ return o;
+}
+
+@fragment
+fn fs_main(i : VOut) -> @location(0) vec4f {
+ return i.color;
+}
diff --git a/vite.config.js b/vite.config.js
new file mode 100644
index 0000000..951a9ba
--- /dev/null
+++ b/vite.config.js
@@ -0,0 +1,6 @@
+import { defineConfig } from 'vite'
+import { svelte } from '@sveltejs/vite-plugin-svelte'
+
+export default defineConfig({
+ plugins: [svelte()],
+})
From ee29821bde88cd8a22e9205da466703fc620f472 Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Tue, 24 Mar 2026 16:56:12 +0000
Subject: [PATCH 3/3] Add GitHub Actions workflow to deploy to GitHub Pages
Co-authored-by: LowWeiLin <3910365+LowWeiLin@users.noreply.github.com>
Agent-Logs-Url: https://github.com/LowWeiLin/Boids/sessions/89c0fa34-980e-4f1b-b44f-0a5ac0bf916a
---
.github/workflows/deploy.yml | 51 ++++++++++++++++++++++++++++++++++++
vite.config.js | 1 +
2 files changed, 52 insertions(+)
create mode 100644 .github/workflows/deploy.yml
diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml
new file mode 100644
index 0000000..db01d6c
--- /dev/null
+++ b/.github/workflows/deploy.yml
@@ -0,0 +1,51 @@
+name: Deploy to GitHub Pages
+
+on:
+ push:
+ branches:
+ - main
+ workflow_dispatch:
+
+permissions:
+ contents: read
+ pages: write
+ id-token: write
+
+concurrency:
+ group: pages
+ cancel-in-progress: false
+
+jobs:
+ build:
+ runs-on: ubuntu-latest
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v4
+
+ - name: Set up Node
+ uses: actions/setup-node@v4
+ with:
+ node-version: 22
+ cache: npm
+
+ - name: Install dependencies
+ run: npm ci
+
+ - name: Build
+ run: npm run build
+
+ - name: Upload artifact
+ uses: actions/upload-pages-artifact@v3
+ with:
+ path: dist
+
+ deploy:
+ needs: build
+ runs-on: ubuntu-latest
+ environment:
+ name: github-pages
+ url: ${{ steps.deployment.outputs.page_url }}
+ steps:
+ - name: Deploy to GitHub Pages
+ id: deployment
+ uses: actions/deploy-pages@v4
diff --git a/vite.config.js b/vite.config.js
index 951a9ba..f2c13fc 100644
--- a/vite.config.js
+++ b/vite.config.js
@@ -2,5 +2,6 @@ import { defineConfig } from 'vite'
import { svelte } from '@sveltejs/vite-plugin-svelte'
export default defineConfig({
+ base: '/Boids/',
plugins: [svelte()],
})