Compare commits

...

7 commits

Author SHA1 Message Date
00371703ad Use docker
Some checks failed
Deploy to Pages / build (push) Failing after 33s
2026-05-08 22:02:29 +01:00
c2a9d0a826 Forgejo deploy 2026-05-08 08:05:49 +01:00
7e0f3d0303 Add pwa images 2026-05-04 10:55:49 +01:00
d73c3a8695 Remove legacy CSS 2026-05-04 10:55:43 +01:00
be0a49a11f Add simple tests & remove ! 2026-05-04 10:31:46 +01:00
0735dd764f Clean up code and enable strict TS 2026-05-04 10:16:28 +01:00
f350b1ff37 Modernise website by migrating to vite 2026-05-03 17:09:30 +01:00
56 changed files with 4897 additions and 21344 deletions

5
.claude/settings.json Normal file
View file

@ -0,0 +1,5 @@
{
"enabledPlugins": {
"frontend-design@claude-plugins-official": true
}
}

View file

@ -1 +0,0 @@
webpack.config.js

View file

@ -1,29 +0,0 @@
{
"root": true,
"env": {
"browser": true,
"es2020": true
},
"extends": [
"eslint:recommended",
"plugin:@typescript-eslint/eslint-recommended",
"plugin:@typescript-eslint/recommended",
"prettier"
],
"parser": "@typescript-eslint/parser",
"parserOptions": {
"ecmaVersion": 11,
"sourceType": "module"
},
"plugins": ["unused-imports", "@typescript-eslint", "prettier"],
"rules": {
"prettier/prettier": "error",
"unused-imports/no-unused-imports-ts": "error",
"@typescript-eslint/no-unused-vars": ["warn", { "argsIgnorePattern": "^_" }],
"@typescript-eslint/no-explicit-any": "off",
"@typescript-eslint/explicit-module-boundary-types": "off",
"@typescript-eslint/no-non-null-assertion": "off",
"@typescript-eslint/ban-ts-comment": "off",
"@typescript-eslint/no-empty-function": "off"
}
}

View file

@ -0,0 +1,43 @@
name: Deploy to Pages
on:
push:
branches: ['main']
pull_request:
branches: ['main']
workflow_dispatch:
concurrency:
group: 'pages'
cancel-in-progress: false
jobs:
build:
runs-on: docker
steps:
- uses: actions/checkout@v4
- name: Setup Node
uses: actions/setup-node@v4
with:
node-version-file: '.nvmrc'
cache: 'npm'
- name: Install dependencies
run: npm ci
- name: Lint
run: npm run lint -- --check || true
- name: Typecheck
run: npm run typecheck
- name: Build
run: npm run build
- name: Copy build to host pages mount
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
run: |
mkdir -p /pages
rsync -a --delete dist/ /pages/

View file

@ -1,51 +0,0 @@
name: Deploy to GitHub Pages
on:
push:
branches: ['main']
pull_request:
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:
- uses: actions/checkout@v4
- name: Setup Node
uses: actions/setup-node@v4
with:
node-version: 20.x
- name: Build
run: |
npm ci && npm run build
- name: Upload artifact
uses: actions/upload-pages-artifact@v3
with:
path: 'dist'
deploy:
environment:
name: github-pages
url: ${{ steps.deployment.outputs.page_url }}
runs-on: ubuntu-latest
needs: build
steps:
- name: Deploy to GitHub Pages
id: deployment
uses: actions/deploy-pages@v4

1
.nvmrc Normal file
View file

@ -0,0 +1 @@
22

View file

@ -4,7 +4,7 @@
"tabWidth": 2,
"singleQuote": true,
"endOfLine": "lf",
"importOrder": ["^[./]", ".*", ".scss$"],
"importOrderSeparation": true,
"importOrderSortSpecifiers": true
"plugins": ["@ianvs/prettier-plugin-sort-imports"],
"importOrder": ["<BUILTIN_MODULES>", "<THIRD_PARTY_MODULES>", "", "^[./]"],
"importOrderTypeScriptVersion": "5.0.0"
}

12
definitions.d.ts vendored
View file

@ -1,14 +1,4 @@
declare module '*.wgsl' {
const content: string;
export default content;
}
declare module '*.svg' {
const content: string;
export default content;
}
declare module '*.html' {
declare module '*.wgsl?raw' {
const content: string;
export default content;
}

82
index.html Normal file
View file

@ -0,0 +1,82 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta
name="viewport"
content="width=device-width,initial-scale=1,viewport-fit=cover"
/>
<meta name="theme-color" content="#b7455e" />
<meta
name="description"
content="A WebGPU agent simulation: a million blobs leave trails, infect each other across generations, and react to your brush."
/>
<meta property="og:title" content="Just a bunch of blobs" />
<meta
property="og:description"
content="A WebGPU agent simulation: a million blobs leave trails, infect each other across generations, and react to your brush."
/>
<meta property="og:url" content="https://schmelczer.dev" />
<meta property="og:image" content="https://schmelczer.dev/og-image.jpg" />
<meta property="og:image:width" content="1920" />
<meta property="og:image:height" content="1920" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<link rel="icon" type="image/x-icon" href="/favicon.ico" />
<link rel="apple-touch-icon" href="/apple-touch-icon-180x180.png" />
<link rel="manifest" href="/manifest.webmanifest" />
<title>Just a bunch of blobs</title>
</head>
<body>
<main class="canvas-container">
<canvas></canvas>
<section class="errors-container">
<noscript>JavaScript is required for this website.</noscript>
</section>
</main>
<aside>
<nav class="buttons">
<button class="info" aria-label="About"></button>
<button class="maximize-full-screen" aria-label="Enter fullscreen"></button>
<button class="minimize-full-screen" aria-label="Exit fullscreen"></button>
<button class="settings" aria-label="Settings"></button>
<button class="restart" aria-label="Restart simulation"></button>
</nav>
<main class="pages hidden info-page">
<section>
<h1>Just a bunch of blobs</h1>
<p>
A million autonomous agents wander a 2D field. Each one lays down a faint
trail and follows trails it senses ahead. Two generations are competing for
territory: the older one fades, the newer one spreads.
</p>
<p>
Drag your finger or mouse anywhere on the canvas to paint a wall. Walls slow
the new generation down and let the old one breathe a little longer. Open
<em>Settings</em> to retune sensors, decay rates and aggression.
</p>
<p>
Runs entirely on your GPU via WebGPU compute shaders &mdash; no servers, no
tracking, no analytics. Source on
<a href="https://github.com/schmelczer/webgpu" target="_blank" rel="noopener"
>GitHub</a
>.
</p>
</section>
</main>
<main class="pages hidden settings-page">
<section>
<div class="settings-content"></div>
<button id="apply-defaults" class="large-button">Apply defaults</button>
</section>
</main>
</aside>
<script type="module" src="/src/index.ts"></script>
</body>
</html>

24867
package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -1,61 +1,61 @@
{
"name": "webgpu-seed",
"version": "0.1.0",
"description": "🔺 A simple hello triangle example introducing WebGPU.",
"main": "dist/main.js",
"scripts": {
"start": "webpack serve --open --mode development",
"lint": "eslint --fix \"src/**/*.ts\" && prettier --write \"src/**/*.(ts|scss|json|html)\"",
"build": "webpack --mode production",
"update": "ncu"
},
"repository": {
"type": "git",
"url": "git+https://github.com/alaingalvan/webgpu-seed.git"
},
"keywords": [
"webgpu",
"webgl",
"example",
"seed",
"types",
"typescript"
],
"author": "Alain Galvan",
"license": "Unlicense",
"bugs": {
"url": "https://github.com/alaingalvan/webgpu-seed/issues"
},
"homepage": "https://github.com/alaingalvan/webgpu-seed#readme",
"devDependencies": {
"@webgpu/types": "^0.1.30",
"@trivago/prettier-plugin-sort-imports": "^3.3.0",
"@typescript-eslint/eslint-plugin": "^5.38.0",
"css-loader": "^6.7.1",
"eslint": "^8.23.1",
"eslint-config-prettier": "^8.5.0",
"eslint-plugin-prettier": "^4.2.1",
"eslint-plugin-unused-imports": "^2.0.0",
"html-webpack-plugin": "^5.5.0",
"inline-source-webpack-plugin": "^2.0.1",
"mini-css-extract-plugin": "^2.6.1",
"npm-check-updates": "^16.3.2",
"prettier": "^2.7.1",
"resolve-url-loader": "^5.0.0",
"responsive-loader": "^3.1.1",
"sass": "^1.55.0",
"sass-loader": "^13.0.2",
"sharp": "^0.31.0",
"string-replace-loader": "^3.1.0",
"svg-inline-loader": "^0.8.2",
"terser-webpack-plugin": "^5.3.6",
"ts-loader": "^9.4.1",
"typescript": "^4.8.3",
"webpack": "^5.74.0",
"webpack-cli": "^4.10.0",
"webpack-dev-server": "^4.11.1"
},
"dependencies": {
"gl-matrix": "^3.4.3"
}
"name": "webgpu-seed",
"version": "0.2.0",
"private": true,
"type": "module",
"description": "A WebGPU-powered slime-mold-meets-territory-control simulation.",
"scripts": {
"dev": "vite --host 0.0.0.0",
"build": "vite build",
"preview": "vite preview",
"lint": "eslint --fix \"src/**/*.ts\" && prettier --write \"src/**/*.{ts,scss,json,html}\"",
"typecheck": "tsc --noEmit",
"test": "vitest run",
"test:watch": "vitest",
"generate-icons": "pwa-assets-generator",
"update": "ncu"
},
"engines": {
"node": ">=20"
},
"repository": {
"type": "git",
"url": "git+https://github.com/schmelczer/webgpu.git"
},
"keywords": [
"webgpu",
"simulation",
"physarum",
"generative-art"
],
"author": "Andras Schmelczer",
"license": "Unlicense",
"browserslist": [
"supports webgpu and last 2 years"
],
"dependencies": {
"gl-matrix": "^3.4.4"
},
"devDependencies": {
"@eslint/js": "^10.0.1",
"@ianvs/prettier-plugin-sort-imports": "^4.7.1",
"@types/node": "^25.6.0",
"@vite-pwa/assets-generator": "^1.0.2",
"@webgpu/types": "^0.1.69",
"browserslist": "^4.28.2",
"eslint": "^10.3.0",
"eslint-config-prettier": "^10.1.8",
"eslint-plugin-prettier": "^5.5.5",
"eslint-plugin-unused-imports": "^4.4.1",
"globals": "^17.6.0",
"lightningcss": "^1.32.0",
"npm-check-updates": "^22.1.0",
"prettier": "^3.8.3",
"sass": "^1.99.0",
"typescript": "^6.0.3",
"typescript-eslint": "^8.59.1",
"vite": "^8.0.10",
"vite-plugin-singlefile": "^2.3.3",
"vitest": "^4.1.5"
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 908 B

BIN
public/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 587 B

6
public/favicon.svg Normal file
View file

@ -0,0 +1,6 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
<rect width="64" height="64" rx="14" fill="#b7455e" />
<circle cx="22" cy="26" r="9" fill="#fff" opacity="0.95" />
<circle cx="42" cy="32" r="11" fill="#fff" opacity="0.85" />
<circle cx="28" cy="44" r="7" fill="#fff" opacity="0.75" />
</svg>

After

Width:  |  Height:  |  Size: 312 B

View file

@ -0,0 +1,43 @@
{
"name": "Just a bunch of blobs",
"short_name": "Blobs",
"description": "A WebGPU agent simulation: a million blobs leave trails, infect each other across generations, and react to your brush.",
"start_url": "/",
"scope": "/",
"display": "fullscreen",
"display_override": ["fullscreen", "standalone", "minimal-ui"],
"orientation": "any",
"background_color": "#b7455e",
"theme_color": "#b7455e",
"icons": [
{
"src": "/favicon.svg",
"sizes": "any",
"type": "image/svg+xml",
"purpose": "any"
},
{
"src": "/pwa-64x64.png",
"sizes": "64x64",
"type": "image/png"
},
{
"src": "/pwa-192x192.png",
"sizes": "192x192",
"type": "image/png"
},
{
"src": "/pwa-512x512.png",
"sizes": "512x512",
"type": "image/png"
},
{
"src": "/maskable-icon-512x512.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "maskable"
}
],
"categories": ["entertainment", "graphics"],
"lang": "en"
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.9 KiB

BIN
public/pwa-192x192.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

BIN
public/pwa-512x512.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 3 KiB

BIN
public/pwa-64x64.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 488 B

9
pwa-assets.config.ts Normal file
View file

@ -0,0 +1,9 @@
import {
defineConfig,
minimal2023Preset as preset,
} from '@vite-pwa/assets-generator/config';
export default defineConfig({
preset,
images: ['public/favicon.svg'],
});

View file

@ -1,4 +1 @@
// @ts-ignore: injected by webpack
export const isProduction: boolean = __IS_PRODUCTION__;
// @ts-ignore: injected by webpack
export const lastEdit = new Date(__CURRENT_DATE__);
export const isProduction: boolean = import.meta.env.PROD;

View file

@ -1,3 +1,5 @@
import { vec2 } from 'gl-matrix';
import { AgentGenerationPipeline } from '../pipelines/agents/agent-generation/agent-generation-pipeline';
import { AgentPipeline } from '../pipelines/agents/agent-pipeline';
import { BrushPipeline } from '../pipelines/brush/brush-pipeline';
@ -13,8 +15,6 @@ import { sleep } from '../utils/sleep';
import { GamePresentation } from './game-presentation';
import { GameRules } from './game-rules';
import { vec2 } from 'gl-matrix';
export default class GameLoop {
private readonly trailMapA: ResizableTexture;
private readonly trailMapB: ResizableTexture;
@ -28,12 +28,9 @@ export default class GameLoop {
private readonly diffusionPipeline: DiffusionPipeline;
private hasFinished = false;
private readonly hasFinishedPromise: Promise<void> = new Promise(
(resolve) => (this.resolveHasFinished = resolve)
);
private resolveHasFinished: () => void;
private readonly finished = Promise.withResolvers<void>();
private isSwipeActive = false;
private activePointerId: number | null = null;
public constructor(
private readonly canvas: HTMLCanvasElement,
@ -73,44 +70,54 @@ export default class GameLoop {
this.renderPipeline = new RenderPipeline(context, this.device, this.commonState);
window.addEventListener('resize', this.resize.bind(this));
canvas.addEventListener('mousemove', this.onSwipe.bind(this));
canvas.addEventListener('touchmove', this.onSwipe.bind(this));
canvas.addEventListener('mousedown', (e) => {
if (!this.isSwipeActive) {
this.brushPipeline.clearSwipes();
this.isSwipeActive = true;
}
this.onSwipe(e);
});
canvas.addEventListener('pointerdown', this.onPointerDown.bind(this));
canvas.addEventListener('pointermove', this.onPointerMove.bind(this));
canvas.addEventListener('pointerup', this.onPointerUp.bind(this));
canvas.addEventListener('pointercancel', this.onPointerUp.bind(this));
}
canvas.addEventListener('touchstart', (e) => {
if (!this.isSwipeActive) {
this.brushPipeline.clearSwipes();
this.isSwipeActive = true;
}
this.onSwipe(e);
});
private onPointerDown(event: PointerEvent) {
if (this.activePointerId !== null) {
return;
}
this.activePointerId = event.pointerId;
this.canvas.setPointerCapture(event.pointerId);
this.brushPipeline.clearSwipes();
this.addSwipeAt(event);
}
window.addEventListener('mouseup', (e) => {
this.onSwipe(e);
this.isSwipeActive = false;
});
private onPointerMove(event: PointerEvent) {
if (event.pointerId !== this.activePointerId) {
return;
}
this.addSwipeAt(event);
}
window.addEventListener('touchend', (e) => {
this.onSwipe(e);
this.isSwipeActive = false;
});
private onPointerUp(event: PointerEvent) {
if (event.pointerId !== this.activePointerId) {
return;
}
this.addSwipeAt(event);
this.canvas.releasePointerCapture(event.pointerId);
this.activePointerId = null;
}
window.addEventListener('touchcancel', (e) => {
this.onSwipe(e);
this.isSwipeActive = false;
});
private addSwipeAt(event: PointerEvent) {
const position = vec2.fromValues(
event.clientX * this.devicePixelRatio,
this.canvas.height - event.clientY * this.devicePixelRatio
);
this.brushPipeline.addSwipe(position);
}
private get isSwipeActive(): boolean {
return this.activePointerId !== null;
}
public async start(): Promise<void> {
requestAnimationFrame(this.render.bind(this));
requestAnimationFrame(this.updateCounts.bind(this));
return this.hasFinishedPromise;
return this.finished.promise;
}
private async updateCounts(): Promise<void> {
@ -135,25 +142,6 @@ export default class GameLoop {
return this.agentGenerationPipeline.maxAgentCount;
}
private onSwipe(event: MouseEvent | TouchEvent) {
if (!this.isSwipeActive || !event) {
return;
}
if (event instanceof TouchEvent && event.touches.length === 0) {
return;
}
const x = event instanceof MouseEvent ? event.clientX : event.touches[0].clientX;
const y = event instanceof MouseEvent ? event.clientY : event.touches[0].clientY;
const position = vec2.fromValues(
x * this.devicePixelRatio,
this.canvas.height - y * this.devicePixelRatio
);
this.brushPipeline.addSwipe(position);
}
private resize() {
this.canvas.width = this.canvas.clientWidth * this.devicePixelRatio;
this.canvas.height = this.canvas.clientHeight * this.devicePixelRatio;
@ -161,7 +149,7 @@ export default class GameLoop {
private async render(time: DOMHighResTimeStamp) {
if (this.hasFinished) {
this.resolveHasFinished();
this.finished.resolve();
return;
}
@ -170,7 +158,7 @@ export default class GameLoop {
);
document.documentElement.style.setProperty(
'--accent-color',
`rgb(${accentColor.map((v: number) => v * 255).join(',')})`
`rgb(${accentColor[0] * 255},${accentColor[1] * 255},${accentColor[2] * 255})`
);
const deltaTime = this.deltaTimeCalculator.calculateDeltaTimeInSeconds(time);
@ -254,7 +242,7 @@ export default class GameLoop {
public async destroy() {
this.hasFinished = true;
await this.hasFinishedPromise;
await this.finished.promise;
this.copyPipeline?.destroy();
this.agentGenerationPipeline?.destroy();

View file

@ -1,14 +1,13 @@
import { vec3 } from 'gl-matrix';
import { settings } from '../settings';
import { hsl } from '../utils/hsl';
import { last } from '../utils/last';
import { Random } from '../utils/random';
import { vec3 } from 'gl-matrix';
const hues = [settings.startColorHue];
for (let i = 0; i < 100; i++) {
hues.push((last(hues) + Random.randomBetween(90, 240)) % 360);
hues.push((hues[hues.length - 1] + Random.randomBetween(90, 240)) % 360);
}
const colors = hues.map((hue) =>

View file

@ -1,11 +1,11 @@
import { vec2 } from 'gl-matrix';
import { GenerationCounts } from '../pipelines/agents/agent-generation/generation-counts';
import { settings } from '../settings';
import { clamp, clamp01 } from '../utils/clamp';
import { mix } from '../utils/mix';
import { Random } from '../utils/random';
import { vec2 } from 'gl-matrix';
export interface SpawnAction {
generation: number;
position: vec2;
@ -13,8 +13,8 @@ export interface SpawnAction {
}
export class GameRules {
private static readonly DEAFULT_SPAWN_INTERVAL = 8;
private static readonly DEAFULT_SPAWN_TIME_LENGTH = 2;
private static readonly DEFAULT_SPAWN_INTERVAL = 8;
private static readonly DEFAULT_SPAWN_TIME_LENGTH = 2;
private static readonly DEFAULT_SPAWN_RADIUS = 20;
private lastSpawnTimeInSeconds = 0;
@ -41,14 +41,14 @@ export class GameRules {
public getSpawnAction(timeInSeconds: number, canvasSize: vec2): SpawnAction {
if (
this.lastSpawnAction &&
timeInSeconds - this.lastSpawnTimeInSeconds < GameRules.DEAFULT_SPAWN_TIME_LENGTH
timeInSeconds - this.lastSpawnTimeInSeconds < GameRules.DEFAULT_SPAWN_TIME_LENGTH
) {
return this.lastSpawnAction;
}
this.currentSpawnInterval = mix(
GameRules.DEAFULT_SPAWN_INTERVAL,
GameRules.DEAFULT_SPAWN_INTERVAL / 5,
GameRules.DEFAULT_SPAWN_INTERVAL,
GameRules.DEFAULT_SPAWN_INTERVAL / 5,
clamp01((timeInSeconds - this.lastGenerationChangeTimeInSeconds) / 120)
);
@ -76,8 +76,8 @@ export class GameRules {
this.lastSpawnAction = {
generation: this.nextGenerationId,
position: vec2.fromValues(
Random.randomBetween(0, canvasSize.x),
Random.randomBetween(0, canvasSize.y)
Random.randomBetween(0, canvasSize[0]),
Random.randomBetween(0, canvasSize[1])
),
radius: this.currentSpawnRadius,
};

View file

@ -1,72 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta property="og:title" content="Just a bunch of blobs" />
<meta property="og:description" content="Discover my projects." />
<meta property="og:url" content="https://schmelczer.dev" />
<meta property="og:image:width" content="1920" />
<meta property="og:image:height" content="1920" />
<meta property="og:image" content="https://schmelczer.dev/og-image.jpg" />
<link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png" />
<link rel="icon" href="favicon.ico" type="image/x-icon" />
<link rel="icon" type="image/png" sizes="32x32" href="/favicon-32x32.png" />
<link rel="icon" type="image/png" sizes="16x16" href="/favicon-16x16.png" />
<meta
name="description"
content="I'm Andras Schmelczer, and this is my portfolio. Discover some of my projects. I'm passionate about solving challenging problems and designing large-scale systems, especially in the context of machine learning."
/>
<meta
name="viewport"
content="width=device-width,initial-scale=1,viewport-fit=cover"
/>
<meta name="theme-color" content="#b7455e" />
<title>Just a bunch of blobs</title>
<link inline inline-asset="index.css" inline-asset-delete />
</head>
<body>
<main class="canvas-container">
<canvas></canvas>
<section class="errors-container">
<noscript>JavaScript is required for this website.</noscript>
</section>
<!-- <div class="counters"><pre></pre></div> -->
</main>
<aside>
<nav class="buttons">
<button class="info"></button>
<button class="maximize-full-screen"></button>
<button class="minimize-full-screen"></button>
<button class="settings"></button>
<button class="restart"></button>
</nav>
<main class="pages hidden info-page">
<section>
<h1>Just a bunch of blobs</h1>
<p>
Lorem ipsum dolor sit amet consectetur adipisicing elit. Eaque itaque
perspiciatis nesciunt, molestiae officiis dignissimos porro! Provident totam
sit enim, dolores dicta possimus ex assumenda earum, ea tempore, aut quidem?
</p>
</section>
</main>
<main class="pages hidden settings-page">
<section>
<div class="settings-content"></div>
<button id="apply-defaults" class="large-button">Apply defaults</button>
</section>
</main>
</aside>
<script inline inline-asset="index.js" inline-asset-delete></script>
</body>
</html>

View file

@ -15,8 +15,10 @@ html > body {
> canvas {
height: 100%;
width: 100%;
cursor: url('../assets/icons/brush.svg') 0 24, auto;
touch-action: none;
cursor:
url('../assets/icons/brush.svg') 0 24,
auto;
}
> .errors-container {
@ -107,7 +109,8 @@ html > body {
@include on-large-screen {
width: 0;
border-radius: 0 var(--border-radius) var(--border-radius) 0;
transition: background-color var(--transition-time),
transition:
background-color var(--transition-time),
width var(--transition-time);
left: calc(-1 * var(--small-margin));
height: 140%;
@ -118,7 +121,8 @@ html > body {
@include on-small-screen {
height: 0;
border-radius: 0 0 var(--border-radius) var(--border-radius);
transition: background-color var(--transition-time),
transition:
background-color var(--transition-time),
height var(--transition-time);
top: calc(-1 * var(--small-margin));
width: 140%;
@ -130,10 +134,10 @@ html > body {
&::after {
background-color: var(--accent-color);
transition: transform var(--transition-time),
transition:
transform var(--transition-time),
background-color var(--transition-time);
-webkit-mask-repeat: no-repeat;
mask-repeat: no-repeat;
@include square(var(--icon-size));
@ -160,27 +164,18 @@ html > body {
}
&.info::after {
-webkit-mask-image: url('../assets/icons/info.svg');
mask-image: url('../assets/icons/info.svg');
}
&.maximize-full-screen::after {
-webkit-mask-image: url('../assets/icons/maximize.svg');
mask-image: url('../assets/icons/maximize.svg');
}
&.minimize-full-screen::after {
-webkit-mask-image: url('../assets/icons/minimize.svg');
mask-image: url('../assets/icons/minimize.svg');
}
&.settings::after {
-webkit-mask-image: url('../assets/icons/settings.svg');
mask-image: url('../assets/icons/settings.svg');
}
&.restart::after {
-webkit-mask-image: url('../assets/icons/restart.svg');
mask-image: url('../assets/icons/restart.svg');
}
}
@ -189,6 +184,8 @@ html > body {
> main.pages {
overflow-x: hidden;
overflow-y: auto;
scrollbar-width: thin;
scrollbar-color: var(--main-color) transparent;
&::-webkit-scrollbar-track,
&::-webkit-scrollbar {
background-color: transparent;
@ -201,7 +198,9 @@ html > body {
&,
> * {
transition: width var(--transition-time-long), height var(--transition-time-long);
transition:
width var(--transition-time-long),
height var(--transition-time-long);
@include on-large-screen {
width: max(500px, 10vw);

View file

@ -1,36 +1,19 @@
import '../assets/icons/info.svg';
import { isProduction, lastEdit } from './constants';
import { isProduction } from './constants';
import GameLoop from './game-loop/game-loop';
import { GameRules } from './game-loop/game-rules';
import './index.scss';
import { CollapsiblePanelAnimator } from './page/collapsible-panel-animator';
import { FullScreenHandler } from './page/full-screen-handler';
import { MenuHider } from './page/menu-hider';
import { setUpSettingsPage } from './page/set-up-settings-page';
import { SettingsSlider } from './page/settings-slider';
import { resetSettings } from './settings';
import { applyArrayPlugins } from './utils/array';
import { DeltaTimeCalculator } from './utils/delta-time-calculator';
import { ErrorHandler, Severity } from './utils/error-handler';
import { initializeGpu } from './utils/graphics/initialize-gpu';
declare global {
interface Array<T> {
x: T;
y: T;
}
interface ReadonlyArray<T> {
x: T;
y: T;
}
interface Float32Array {
x: number;
y: number;
}
}
const elements = {
aside: document.querySelector('aside') as HTMLDivElement,
infoButton: document.querySelector('button.info') as HTMLButtonElement,
@ -47,9 +30,7 @@ const elements = {
settingsButton: document.querySelector('button.settings') as HTMLButtonElement,
restartButton: document.querySelector('button.restart') as HTMLButtonElement,
canvas: document.querySelector('canvas') as HTMLCanvasElement,
canvasContainer: document.querySelector('main.canvas-container') as HTMLCanvasElement,
errorContainer: document.querySelector('.errors-container') as HTMLDivElement,
// counters: document.querySelector('.counters > pre') as HTMLPreElement,
};
const main = async () => {
@ -57,8 +38,6 @@ const main = async () => {
let shouldStop = false;
let game: GameLoop | null = null;
applyArrayPlugins();
ErrorHandler.addOnErrorListener((error, _metadata) => {
elements.errorContainer.innerHTML += `
<pre class="${error.severity}">${error.message}</div>
@ -109,16 +88,6 @@ const main = async () => {
sliders.forEach((slider) => slider.updateSliderValueBasedOnSource());
});
console.log({ lastEdit });
// const updateCounters = () => {
// elements.counters.innerHTML = `FPS: ${deltaTimeCalculator.fps.toFixed(2)}
// current gen: ${formatNumber(game?.aliveAgentCounts.currentGenerationCount ?? 0)}
// next gen: ${formatNumber(game?.aliveAgentCounts.nextGenerationCount ?? 0)}`;
// window.requestAnimationFrame(updateCounters);
// };
// updateCounters();
while (!shouldStop) {
const gameRules = new GameRules(performance.now() / 1000);
game = new GameLoop(elements.canvas, gpu, deltaTimeCalculator, gameRules);
@ -130,7 +99,8 @@ const main = async () => {
await game.start();
}
} catch (e) {
ErrorHandler.addError(Severity.ERROR, e.stack);
const message = e instanceof Error ? (e.stack ?? e.message) : String(e);
ErrorHandler.addError(Severity.ERROR, message);
console.error(e);
}
};

View file

@ -16,14 +16,16 @@ export class FullScreenHandler {
// on full screen request, only apply it to the target
if (e.key === 'F11') {
e.preventDefault();
FullScreenHandler.isInFullScreenMode()
? document.exitFullscreen()
: target.requestFullscreen();
if (FullScreenHandler.isInFullScreenMode()) {
document.exitFullscreen();
} else {
target.requestFullscreen();
}
}
});
addEventListener('fullscreenchange', this.updateButtons.bind(this));
maximizeButton.addEventListener('click', target.requestFullscreen.bind(target));
minimizeButton.addEventListener('click', document.exitFullscreen.bind(document));
maximizeButton.addEventListener('click', () => target.requestFullscreen());
minimizeButton.addEventListener('click', () => document.exitFullscreen());
}
public static isInFullScreenMode(): boolean {

View file

@ -6,13 +6,16 @@ export const setUpSettingsPage = (
settingsPage: HTMLDivElement,
maxAgentCount: number
): Array<SettingsSlider<any>> => {
const sliders = [
!isProduction &&
new SettingsSlider(settings, 'renderSpeed', {
min: 1,
max: 10,
rounding: Math.round,
}),
const sliders: Array<SettingsSlider<any>> = [
...(isProduction
? []
: [
new SettingsSlider(settings, 'renderSpeed', {
min: 1,
max: 10,
rounding: Math.round,
}),
]),
new SettingsSlider(settings, 'agentCount', {
min: 1,
@ -102,11 +105,9 @@ export const setUpSettingsPage = (
const sliderContainerElement = document.createElement('div');
sliders
.filter((v) => v)
.forEach((slider) => {
sliderContainerElement.appendChild(slider.element);
});
sliders.forEach((slider) => {
sliderContainerElement.appendChild(slider.element);
});
settingsPage.appendChild(sliderContainerElement);

View file

@ -2,9 +2,9 @@ import { getWorkgroupCounts } from '../../../utils/graphics/get-workgroup-counts
import { smartCompile } from '../../../utils/graphics/smart-compile';
import { CommonState } from '../../common-state/common-state';
import { AGENT_SIZE_IN_BYTES } from './agent';
import countingShader from './agent-counting.wgsl';
import firstGenerationShader from './agent-first-generation.wgsl';
import agentSchema from './agent-schema.wgsl';
import countingShader from './agent-counting.wgsl?raw';
import firstGenerationShader from './agent-first-generation.wgsl?raw';
import agentSchema from './agent-schema.wgsl?raw';
import { GenerationCounts } from './generation-counts';
export class AgentGenerationPipeline {

View file

@ -1,11 +1,11 @@
import { vec2 } from 'gl-matrix';
import { getWorkgroupCounts } from '../../utils/graphics/get-workgroup-counts';
import { smartCompile } from '../../utils/graphics/smart-compile';
import { CommonState } from '../common-state/common-state';
import agentSchme from './agent-generation/agent-schema.wgsl';
import agentSchema from './agent-generation/agent-schema.wgsl?raw';
import { AgentSettings } from './agent-settings';
import shader from './agent.wgsl';
import { vec2 } from 'gl-matrix';
import shader from './agent.wgsl?raw';
export class AgentPipeline {
private static readonly WORKGROUP_SIZE = 64;
@ -32,7 +32,7 @@ export class AgentPipeline {
bindGroupLayouts: [commonState.bindGroupLayout, this.bindGroupLayout],
}),
compute: {
module: smartCompile(device, CommonState.shaderCode, agentSchme, shader),
module: smartCompile(device, CommonState.shaderCode, agentSchema, shader),
entryPoint: 'main',
},
});

View file

@ -1,11 +1,10 @@
import { vec2 } from 'gl-matrix';
import { clamp } from '../../utils/clamp';
import { smartCompile } from '../../utils/graphics/smart-compile';
import { last } from '../../utils/last';
import { CommonState } from '../common-state/common-state';
import { BrushSettings } from './brush-settings';
import shader from './brush.wgsl';
import { vec2 } from 'gl-matrix';
import shader from './brush.wgsl?raw';
export class BrushPipeline {
private static readonly UNIFORM_COUNT = 2;
@ -188,7 +187,7 @@ export class BrushPipeline {
result.push(position);
}
result.push(last(points));
result.push(points[points.length - 1]);
return result;
}

View file

@ -1,7 +1,7 @@
import { generateNoise } from '../../utils/graphics/noise';
import { vec2 } from 'gl-matrix';
import { generateNoise } from '../../utils/graphics/noise';
export class CommonState {
private static readonly UNIFORM_COUNT = 4;

View file

@ -1,8 +1,8 @@
import { smartCompile } from '../../utils/graphics/smart-compile';
import shader from './copy.wgsl';
import { vec2 } from 'gl-matrix';
import { smartCompile } from '../../utils/graphics/smart-compile';
import shader from './copy.wgsl?raw';
export class CopyPipeline {
private static readonly UNIFORM_COUNT = 2;

View file

@ -1,7 +1,7 @@
import { setUpFullScreenQuad } from '../../utils/graphics/full-screen-quad';
import { smartCompile } from '../../utils/graphics/smart-compile';
import { CommonState } from '../common-state/common-state';
import shader from './diffuse.wgsl';
import shader from './diffuse.wgsl?raw';
import { DiffusionSettings } from './diffusion-settings';
export class DiffusionPipeline {
@ -11,7 +11,6 @@ export class DiffusionPipeline {
private readonly pipeline: GPURenderPipeline;
private readonly uniforms: GPUBuffer;
private readonly vertexBuffer: GPUBuffer;
private readonly noise: GPUTextureView;
private bindGroup?: GPUBindGroup;
private previousTrailMapIn?: GPUTextureView;

View file

@ -1,10 +1,10 @@
import { vec3 } from 'gl-matrix';
import { setUpFullScreenQuad } from '../../utils/graphics/full-screen-quad';
import { smartCompile } from '../../utils/graphics/smart-compile';
import { CommonState } from '../common-state/common-state';
import { RenderSettings } from './render-settings';
import shader from './render.wgsl';
import { vec3 } from 'gl-matrix';
import shader from './render.wgsl?raw';
export class RenderPipeline {
private static readonly UNIFORM_COUNT = 13;

View file

@ -31,7 +31,9 @@ p {
html {
height: 100%;
-webkit-font-smooth: antialiased;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
text-rendering: optimizeLegibility;
}
.large-button {

View file

@ -4,7 +4,8 @@
font-style: normal;
font-weight: 400;
font-display: swap;
src: local(''),
src:
local(''),
url('../../assets/fonts/comfortaa-v40-latin-regular.woff2') format('woff2'),
/* Chrome 26+, Opera 23+, Firefox 39+ */
url('../../assets/fonts/comfortaa-v40-latin-regular.woff') format('woff'); /* Chrome 6+, Firefox 3.6+, IE 9+, Safari 5.1+ */
@ -16,7 +17,8 @@
font-style: normal;
font-weight: 400;
font-display: swap;
src: local(''),
src:
local(''),
url('../../assets/fonts/open-sans-v34-latin-regular.woff2') format('woff2'),
/* Chrome 26+, Opera 23+, Firefox 39+ */
url('../../assets/fonts/open-sans-v34-latin-regular.woff') format('woff'); /* Chrome 6+, Firefox 3.6+, IE 9+, Safari 5.1+ */

View file

@ -49,14 +49,6 @@ $breakpoint-width: 600px !default;
@mixin blurred-background($color: transparent) {
background-color: color.adjust($color, $alpha: -0.66);
backdrop-filter: blur(var(--blur-radius));
-webkit-backdrop-filter: blur(var(--blur-radius));
@supports not (
(backdrop-filter: blur(var(--blur-radius))) or
(-webkit-backdrop-filter: blur(var(--blur-radius)))
) {
background-color: $color;
}
}
@mixin square($size) {
@ -65,7 +57,9 @@ $breakpoint-width: 600px !default;
}
@mixin title-font() {
font: 400 3rem 'Comfortaa', sans-serif;
font:
400 3rem 'Comfortaa',
sans-serif;
color: var(--normal-text-color);
line-height: 1;
@ -76,20 +70,26 @@ $breakpoint-width: 600px !default;
}
@mixin sub-title-font() {
font: 400 1.75rem 'Comfortaa', sans-serif;
font:
400 1.75rem 'Comfortaa',
sans-serif;
color: var(--normal-text-color);
hyphens: auto;
}
@mixin main-font() {
font: 400 1.1rem 'Open Sans', sans-serif;
font:
400 1.1rem 'Open Sans',
sans-serif;
color: var(--normal-text-color);
line-height: 1.8;
hyphens: auto;
}
@mixin special-text-font() {
font: 400 1rem 'Open Sans', sans-serif;
font:
400 1rem 'Open Sans',
sans-serif;
color: var(--special-text-color);
hyphens: auto;
font-style: italic;

View file

@ -1,19 +0,0 @@
const setIndexAlias = (name: string, index: number, type: any) => {
if (!Object.prototype.hasOwnProperty.call(type.prototype, name)) {
Object.defineProperty(type.prototype, name, {
get() {
return this[index];
},
set(value) {
this[index] = value;
},
});
}
};
export const applyArrayPlugins = () => {
setIndexAlias('x', 0, Array);
setIndexAlias('y', 1, Array);
setIndexAlias('x', 0, Float32Array);
setIndexAlias('y', 1, Float32Array);
};

View file

@ -41,6 +41,6 @@ export class DeltaTimeCalculator {
}
public get fps() {
return 1 / this.deltaTimeAccumulator;
return this.deltaTimeAccumulator ? 1 / this.deltaTimeAccumulator : 0;
}
}

View file

@ -30,7 +30,7 @@ export class ErrorHandler {
}
public static addMetadata(key: string, value: any) {
const serialized = {};
const serialized: Record<string, any> = {};
for (const k in value) {
serialized[k] = value[k];
}

View file

@ -0,0 +1,22 @@
import { describe, expect, it } from 'vitest';
import { formatNumber } from './format-number';
describe('formatNumber', () => {
it('renders integers without decimals', () => {
expect(formatNumber(42)).toBe('42 ');
});
it('renders fractional values with two decimals', () => {
expect(formatNumber(3.14159)).toBe('3.14 ');
});
it('renders thousands compactly', () => {
expect(formatNumber(2500)).toBe('2.5 thousand ');
});
it('renders millions compactly', () => {
expect(formatNumber(1_500_000)).toBe('1.5 million ');
});
it('appends the unit when provided', () => {
expect(formatNumber(5, 'agents')).toBe('5 agents');
expect(formatNumber(2_000_000, 'agents')).toBe('2.0 million agents');
});
});

View file

@ -13,75 +13,77 @@ export const generateNoise = ({
height: number;
}): GPUTextureView => {
const cacheKey = `${width}x${height}`;
if (!textureCache.has(cacheKey)) {
const { buffer, vertex } = setUpFullScreenQuad(device);
const vertexBuffer = buffer;
const pipeline = device.createRenderPipeline({
layout: 'auto',
vertex,
fragment: {
module: smartCompile(
device,
/* wgsl */ `
fn random_with_seed(uv: vec2<f32>, seed: f32) -> f32 {
return fract(sin(dot(uv, vec2(12.9898 + seed, 78.233 + seed)))* 43758.5453123 + seed);
}
@fragment
fn fragment(@location(0) uv: vec2<f32>) -> @location(0) vec4<f32> {
return vec4(
random_with_seed(uv, 0),
random_with_seed(uv, 1),
random_with_seed(uv, 2),
random_with_seed(uv, 3),
);
}`
),
entryPoint: 'fragment',
targets: [
{
format: 'rgba16float',
},
],
},
primitive: {
topology: 'triangle-strip',
},
});
const colorTexture = device.createTexture({
size: {
width,
height,
depthOrArrayLayers: 1,
},
format: 'rgba16float',
usage: GPUTextureUsage.TEXTURE_BINDING | GPUTextureUsage.RENDER_ATTACHMENT,
});
const renderPassDescriptor: GPURenderPassDescriptor = {
colorAttachments: [
{
view: colorTexture.createView(),
clearValue: { r: 1.0, g: 1.0, b: 1.0, a: 1.0 },
loadOp: 'clear',
storeOp: 'store',
},
],
};
const commandEncoder = device.createCommandEncoder();
const passEncoder = commandEncoder.beginRenderPass(renderPassDescriptor);
passEncoder.setPipeline(pipeline);
passEncoder.setVertexBuffer(0, vertexBuffer);
passEncoder.draw(4, 1);
passEncoder.end();
device.queue.submit([commandEncoder.finish()]);
textureCache.set(cacheKey, colorTexture);
const cached = textureCache.get(cacheKey);
if (cached) {
return cached.createView();
}
return textureCache.get(cacheKey).createView();
const { buffer, vertex } = setUpFullScreenQuad(device);
const vertexBuffer = buffer;
const pipeline = device.createRenderPipeline({
layout: 'auto',
vertex,
fragment: {
module: smartCompile(
device,
/* wgsl */ `
fn random_with_seed(uv: vec2<f32>, seed: f32) -> f32 {
return fract(sin(dot(uv, vec2(12.9898 + seed, 78.233 + seed)))* 43758.5453123 + seed);
}
@fragment
fn fragment(@location(0) uv: vec2<f32>) -> @location(0) vec4<f32> {
return vec4(
random_with_seed(uv, 0),
random_with_seed(uv, 1),
random_with_seed(uv, 2),
random_with_seed(uv, 3),
);
}`
),
entryPoint: 'fragment',
targets: [
{
format: 'rgba16float',
},
],
},
primitive: {
topology: 'triangle-strip',
},
});
const colorTexture = device.createTexture({
size: {
width,
height,
depthOrArrayLayers: 1,
},
format: 'rgba16float',
usage: GPUTextureUsage.TEXTURE_BINDING | GPUTextureUsage.RENDER_ATTACHMENT,
});
const renderPassDescriptor: GPURenderPassDescriptor = {
colorAttachments: [
{
view: colorTexture.createView(),
clearValue: { r: 1.0, g: 1.0, b: 1.0, a: 1.0 },
loadOp: 'clear',
storeOp: 'store',
},
],
};
const commandEncoder = device.createCommandEncoder();
const passEncoder = commandEncoder.beginRenderPass(renderPassDescriptor);
passEncoder.setPipeline(pipeline);
passEncoder.setVertexBuffer(0, vertexBuffer);
passEncoder.draw(4, 1);
passEncoder.end();
device.queue.submit([commandEncoder.finish()]);
textureCache.set(cacheKey, colorTexture);
return colorTexture.createView();
};

View file

@ -1,48 +1,40 @@
import { CopyPipeline } from '../../pipelines/copy/copy-pipeline';
import { vec2 } from 'gl-matrix';
import { CopyPipeline } from '../../pipelines/copy/copy-pipeline';
export class ResizableTexture {
private texture: GPUTexture;
private textureView: GPUTextureView;
private size: vec2;
private readonly copyPipeline: CopyPipeline;
private size: vec2 | null = null;
public constructor(private readonly device: GPUDevice, size: vec2) {
public constructor(
private readonly device: GPUDevice,
size: vec2
) {
this.copyPipeline = new CopyPipeline(this.device);
this.resize(size);
this.size = size;
this.texture = this.createTexture(size);
this.textureView = this.texture.createView();
}
public resize(size: vec2): void {
if (this.size !== null && vec2.equals(this.size, size)) {
if (vec2.equals(this.size, size)) {
return;
}
const newTexture = this.device.createTexture({
format: 'rgba16float',
size: {
width: size.x,
height: size.y,
},
usage:
GPUTextureUsage.STORAGE_BINDING |
GPUTextureUsage.TEXTURE_BINDING |
GPUTextureUsage.RENDER_ATTACHMENT,
});
const newTexture = this.createTexture(size);
const newTextureView = newTexture.createView();
if (this.textureView) {
const commandEncoder = this.device.createCommandEncoder();
this.copyPipeline.execute(
commandEncoder,
this.textureView,
newTextureView,
vec2.div(vec2.create(), this.size, size)
);
this.device.queue.submit([commandEncoder.finish()]);
this.texture.destroy();
}
const commandEncoder = this.device.createCommandEncoder();
this.copyPipeline.execute(
commandEncoder,
this.textureView,
newTextureView,
vec2.div(vec2.create(), this.size, size)
);
this.device.queue.submit([commandEncoder.finish()]);
this.texture.destroy();
this.size = size;
this.texture = newTexture;
@ -57,4 +49,15 @@ export class ResizableTexture {
this.texture.destroy();
this.copyPipeline.destroy();
}
private createTexture(size: vec2): GPUTexture {
return this.device.createTexture({
format: 'rgba16float',
size: { width: size[0], height: size[1] },
usage:
GPUTextureUsage.STORAGE_BINDING |
GPUTextureUsage.TEXTURE_BINDING |
GPUTextureUsage.RENDER_ATTACHMENT,
});
}
}

42
src/utils/hsl.test.ts Normal file
View file

@ -0,0 +1,42 @@
import { describe, expect, it } from 'vitest';
import { hsl } from './hsl';
describe('hsl', () => {
it('produces pure red at hue 0', () => {
const [r, g, b] = hsl(0, 100, 50);
expect(r).toBeCloseTo(1);
expect(g).toBeCloseTo(0);
expect(b).toBeCloseTo(0);
});
it('produces pure green at hue 120', () => {
const [r, g, b] = hsl(120, 100, 50);
expect(r).toBeCloseTo(0);
expect(g).toBeCloseTo(1);
expect(b).toBeCloseTo(0);
});
it('produces pure blue at hue 240', () => {
const [r, g, b] = hsl(240, 100, 50);
expect(r).toBeCloseTo(0);
expect(g).toBeCloseTo(0);
expect(b).toBeCloseTo(1);
});
it('produces gray at saturation 0', () => {
const [r, g, b] = hsl(180, 0, 50);
expect(r).toBeCloseTo(0.5);
expect(g).toBeCloseTo(0.5);
expect(b).toBeCloseTo(0.5);
});
it('produces black at lightness 0', () => {
const [r, g, b] = hsl(0, 100, 0);
expect(r).toBe(0);
expect(g).toBe(0);
expect(b).toBe(0);
});
it('produces white at lightness 100', () => {
const [r, g, b] = hsl(0, 100, 100);
expect(r).toBeCloseTo(1);
expect(g).toBeCloseTo(1);
expect(b).toBeCloseTo(1);
});
});

View file

@ -1,7 +1,7 @@
import { rgb } from './rgb';
import { vec3 } from 'gl-matrix';
import { rgb } from './rgb';
export const hsl = (hue: number, saturation: number, lightness: number): vec3 => {
hue /= 360;
saturation /= 100;

View file

@ -1,3 +0,0 @@
export function last<T>(a: Array<T>): T | null {
return a.length > 0 ? a[a.length - 1] : null;
}

63
src/utils/math.test.ts Normal file
View file

@ -0,0 +1,63 @@
import { describe, expect, it } from 'vitest';
import { clamp, clamp01 } from './clamp';
import { exponentialDecay } from './exponential-decay';
import { mix } from './mix';
describe('clamp', () => {
it('returns value when within bounds', () => {
expect(clamp(5, 0, 10)).toBe(5);
});
it('clamps below to lower bound', () => {
expect(clamp(-3, 0, 10)).toBe(0);
});
it('clamps above to upper bound', () => {
expect(clamp(42, 0, 10)).toBe(10);
});
});
describe('clamp01', () => {
it('passes through values in [0, 1]', () => {
expect(clamp01(0.25)).toBe(0.25);
});
it('clamps negatives to 0', () => {
expect(clamp01(-1)).toBe(0);
});
it('clamps above 1 to 1', () => {
expect(clamp01(2)).toBe(1);
});
});
describe('mix', () => {
it('returns from at q=0', () => {
expect(mix(10, 20, 0)).toBe(10);
});
it('returns to at q=1', () => {
expect(mix(10, 20, 1)).toBe(20);
});
it('interpolates at q=0.5', () => {
expect(mix(10, 20, 0.5)).toBe(15);
});
it('extrapolates outside [0, 1]', () => {
expect(mix(0, 10, 2)).toBe(20);
expect(mix(0, 10, -1)).toBe(-10);
});
});
describe('exponentialDecay', () => {
it('returns nextValue when bias is 1', () => {
expect(exponentialDecay({ accumulator: 0, nextValue: 10, biasOfNextValue: 1 })).toBe(
10
);
});
it('returns accumulator when bias is 0', () => {
expect(exponentialDecay({ accumulator: 5, nextValue: 10, biasOfNextValue: 0 })).toBe(
5
);
});
it('blends with given bias', () => {
expect(
exponentialDecay({ accumulator: 0, nextValue: 10, biasOfNextValue: 0.25 })
).toBe(2.5);
});
});

View file

@ -2,7 +2,7 @@ export const persist = <T extends Record<string, number>>(wrapee: T): T => {
const keys = Object.keys(wrapee);
keys.sort();
const keysToShortKeys = Object.fromEntries(keys.map((key, i) => [key, key]));
const keysToShortKeys = Object.fromEntries(keys.map((key) => [key, key]));
const params = new URLSearchParams(window.location.search);
const newParams = new URLSearchParams();

41
src/utils/random.test.ts Normal file
View file

@ -0,0 +1,41 @@
import { beforeEach, describe, expect, it } from 'vitest';
import { Random } from './random';
describe('Random', () => {
beforeEach(() => {
Random.seed = 42;
});
it('produces values in [0, 1)', () => {
for (let i = 0; i < 1000; i++) {
const v = Random.getRandom();
expect(v).toBeGreaterThanOrEqual(0);
expect(v).toBeLessThan(1);
}
});
it('is deterministic for the same seed', () => {
Random.seed = 42;
const a = Array.from({ length: 8 }, () => Random.getRandom());
Random.seed = 42;
const b = Array.from({ length: 8 }, () => Random.getRandom());
expect(a).toEqual(b);
});
it('produces different sequences for different seeds', () => {
Random.seed = 1;
const a = Array.from({ length: 4 }, () => Random.getRandom());
Random.seed = 2;
const b = Array.from({ length: 4 }, () => Random.getRandom());
expect(a).not.toEqual(b);
});
it('randomBetween stays within [from, to)', () => {
for (let i = 0; i < 1000; i++) {
const v = Random.randomBetween(-10, 10);
expect(v).toBeGreaterThanOrEqual(-10);
expect(v).toBeLessThan(10);
}
});
});

View file

@ -1,37 +1,22 @@
{
"compilerOptions": {
"target": "ES6",
"lib": [
"es2017",
"es2017.object",
"es2017.sharedmemory",
"es2016",
"es2016.array.include",
"es2015",
"es2015.core",
"es2015.promise",
"es2015.collection",
"es5",
"dom"
],
"module": "commonjs",
"declaration": false,
"noImplicitAny": false,
"removeComments": false,
"emitDecoratorMetadata": true,
"experimentalDecorators": true,
"noEmitHelpers": false,
"sourceMap": true,
"strictNullChecks": false,
"jsx": "react",
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "bundler",
"lib": ["ES2024", "DOM", "DOM.Iterable"],
"types": ["@webgpu/types", "vite/client"],
"isolatedModules": true,
"noEmit": true,
"skipLibCheck": true,
"resolveJsonModule": true,
"esModuleInterop": true,
"allowSyntheticDefaultImports": true
"allowSyntheticDefaultImports": true,
"forceConsistentCasingInFileNames": true,
"strict": true,
"noUnusedLocals": false,
"noUnusedParameters": false
},
"include": [
"definitions.d.ts",
"src/**/*",
"node_modules/@webgpu/types/**/*"
],
"compileOnSave": false,
"buildOnSave": false
}
"include": ["src/**/*", "definitions.d.ts", "vite.config.ts"]
}

29
vite.config.ts Normal file
View file

@ -0,0 +1,29 @@
import browserslist from 'browserslist';
import { browserslistToTargets } from 'lightningcss';
import { defineConfig } from 'vitest/config';
import { viteSingleFile } from 'vite-plugin-singlefile';
const cssTargets = browserslistToTargets(browserslist());
export default defineConfig({
plugins: [viteSingleFile()],
css: {
transformer: 'lightningcss',
lightningcss: {
targets: cssTargets,
},
},
build: {
target: 'es2022',
cssCodeSplit: false,
cssMinify: 'lightningcss',
assetsInlineLimit: Number.MAX_SAFE_INTEGER,
},
server: {
open: true,
},
test: {
environment: 'node',
include: ['src/**/*.test.ts'],
},
});

View file

@ -1,95 +0,0 @@
const path = require('path');
const HtmlWebpackPlugin = require('html-webpack-plugin');
const TerserPlugin = require('terser-webpack-plugin');
const InlineSourceWebpackPlugin = require('inline-source-webpack-plugin');
const MiniCssExtractPlugin = require('mini-css-extract-plugin');
const DefinePlugin = require('webpack').DefinePlugin;
module.exports = (env, argv) => ({
devtool: argv.mode === 'development' ? 'inline-source-map' : false,
entry: {
index: './src/index.ts',
},
watchOptions: {
ignored: '**/node_modules',
},
optimization: {
minimizer: [
new TerserPlugin({
terserOptions: {
module: true,
},
}),
],
},
plugins: [
new HtmlWebpackPlugin({
template: './src/index.html',
}),
new MiniCssExtractPlugin(),
argv.mode === 'production' &&
new InlineSourceWebpackPlugin({
compress: true,
}),
new DefinePlugin({
__CURRENT_DATE__: Date.now(),
__IS_PRODUCTION__: argv.mode === 'production',
}),
].filter((v) => v),
module: {
rules: [
{
test: /\.svg$/i,
type: 'asset/inline',
},
{
test: /\.woff2?$/i,
type: 'asset/resource',
generator: {
filename: '[hash:8][ext]',
},
},
{
test: /\/no-change\//i,
type: 'asset/resource',
generator: {
filename: '[name][ext]',
},
},
{
test: /\.wgsl$/i,
type: 'asset/source',
generator: {
filename: '[name][ext]',
},
},
{
test: /\.scss$/i,
use: [
MiniCssExtractPlugin.loader,
'css-loader',
'resolve-url-loader',
{
loader: 'sass-loader',
options: {
sourceMap: true, // required by resolve-url-loader
},
},
],
},
{
test: /\.ts$/i,
use: 'ts-loader',
},
],
},
resolve: {
extensions: ['.ts', '.js'],
},
output: {
clean: true,
filename: '[name].js',
path: path.resolve(__dirname, 'dist'),
publicPath: '',
},
});