commit 7172894ebb474b8aeee71adb427492c9d42223bc Author: djoser Date: Sun Jul 26 19:06:17 2026 +0200 Initial commit: WebGPU portfolio — generative-art site with DOM overlay diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..4c17717 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,9 @@ +node_modules +dist +.git +.gitignore +*.md +!README.md +.DS_Store +.vscode +*.log diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..8146ac0 --- /dev/null +++ b/.gitignore @@ -0,0 +1,19 @@ +node_modules +dist +.vite +*.log + +# IDE / editor +.vscode +.idea + +# OpenCode agent scratch +.opencode/ + +# Throwaway test scripts +test-*.cjs +test-*.mjs +test-*.js + +# LaTeX resume (unrelated to the site) +resume.tex diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..5f1b6ca --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,328 @@ +# AGENTS.md + +Conventions and operational notes for AI agents (and humans) working in this repo. +Read this before editing. Keep it updated when conventions change. + +## Project + +A portfolio website using a **hybrid architecture**: a WebGPU `` renders +the generative-art background, and a DOM overlay on top of it owns every UI +element (nav, titles, body text, button). The canvas is the only GPU surface; +all interactive UI is standard HTML/CSS. Pointer position is still captured at +the window level and fed to the shader as a uniform for background reactivity. + +### North-star goals (from the kickoff decisions) +- Tech: **Vite + TypeScript**, `@webgpu/types` for ambient types. +- Rendering: a **single fullscreen-triangle fragment shader** draws the procedural + generative-art background only. All UI (text, nav, button) is **DOM overlay**. +- Aesthetic: **abstract generative art** (domain-warped fbm flow fields, noise). +- Sections: **Hero / About / Projects / Contact**, driven by `scene` state in the + app loop. Scene changes update the DOM overlay content and shift the shader's + `palette`. +- Deliver style: **solid foundation first, extend later.** + +## Commands + +```bash +npm install +npm run dev # vite dev server, opens browser +npm run build # tsc --noEmit (typecheck) + vite build +npm run typecheck # tsc --noEmit — run before considering work done +npm run preview # serve the built dist/ +``` + +**Every task that changes code MUST end with `npm run typecheck` (and `npm run build` +if you want a full check).** Do not commit code that fails typecheck. + +## Stack / tooling notes + +- TypeScript `strict`, plus `noUnusedLocals` / `noUnusedParameters`. Remove unused + params/vars or prefix with `_` rather than leaving them. +- WGSL shaders live in `src/engine/shaders/*.wgsl` and are imported with Vite's + `?raw` suffix (`import src from './x.wgsl?raw'`). Keep all GPU code in `.wgsl` + files — do not inline large shader strings in `.ts`. +- `@webgpu/types` provides global `GPUDevice`, etc. Do not import them; they are + ambient via `types` in `tsconfig.json`. +- Target a modern WebGPU browser (recent Chrome/Edge/ChromeOS/Safari preview). + `index.html` ships a `#fallback` notice shown when `navigator.gpu` is missing. +- `opentype.js` is a runtime dep (font parsing for the Slug text generator); + `@types/opentype.js` is a dev dep. **The Slug text engine is dormant** — see + the "Text rendering" section. The deps are kept so the dormant files still + typecheck; they are not wired into the render loop. + +## Architecture + +``` +src/ +├── main.ts # boot: create App, fall back on no WebGPU +├── app.ts # rAF loop, input→uniforms, scene state, DOM overlay wiring +├── engine/ +│ ├── renderer.ts # device/context/pipeline, single procedural pass +│ ├── shaders/ +│ │ └── fullscreen.wgsl # procedural background only, one fragment shader +│ └── text/ # DORMANT Slug text engine (kept, not wired in) +│ ├── slug-generator.ts # CPU: opentype.js → curve/band texture data (Slug port) +│ ├── text-layout.ts # CPU: text string → instance buffer data per glyph +│ ├── text-renderer.ts # GPU: instanced quad pipeline + textures + bind groups +│ └── text.wgsl # WGSL Slug shader (ray-traces quadratic Béziers per frag) +├── ui/ +│ ├── input.ts # window-level pointer position capture (mouse uniform) +│ └── overlay.ts # DOM overlay: scene toggling, resume/project HTML builders +└── data/ + ├── resume.ts # ResumeData type + RESUME object (name, contact, work, edu, skills) + └── projects/ + ├── index.ts # ProjectPage type + PROJECTS registry (add new projects here) + ├── radiance-caching.ts + ├── point-cloud.ts + ├── evol-engine.ts + ├── auv-simulation.ts + └── improv-gfx.ts +``` + +### Layout: DOM-owned + +UI layout is owned by **CSS** in `index.html` (flexbox + viewport units). There is +no shared pixel-space layout contract between the DOM and the shader anymore: the +shader draws only the fullscreen background, so it needs only `resolution`, `time`, +`scene`, and `mouse` — no per-element bounds. + +When you add a new UI element: add it to the DOM overlay in `index.html`, style it +with CSS, and wire any click/hover in `overlay.ts`. You do not need to touch +`renderer.ts`, `fullscreen.wgsl`, or uniforms unless the background itself must +react to the new element. + +### Hybrid DOM-overlay principle + +The **DOM overlay** (`#overlay` in `index.html`, `z-index: 1` over the canvas) +owns all visible UI: the nav bar, scene title/subtitle/body, and the cycle button. +The canvas underneath draws the generative background only. The overlay is +`pointer-events: none` with interactive children set to `pointer-events: auto`, so +pointermove still reaches the window for the shader's mouse uniform while clicks +land on the right element. Do not add UI shapes inside the fragment shader — if +you need a new visual element, prefer a DOM element with CSS. + +### Data layer + +Content lives in `src/data/` as typed TypeScript objects. No runtime parsing, no +fetches — data is imported directly and rendered into HTML at first visit. + +**Resume** (`src/data/resume.ts`): +- Exports `ResumeData` interface and `RESUME` object. +- Fields: `name`, `contact[]`, `location`, `summary`, `work[]`, `education[]`, `skills[]`. +- `overlay.ts` has `buildResumeHTML(data)` that generates the full resume DOM from + the typed object. To update resume content, edit `resume.ts`. + +**Projects** (`src/data/projects/`): +- Each project is its own `.ts` file exporting a `ProjectPage`: + `{ id, name, tagline, date, tech[], content }` where `content` is free-form HTML. +- `index.ts` exports the `ProjectPage` type and the `PROJECTS` registry array. +- **To add a project:** create `src/data/projects/my-project.ts`, import it in + `index.ts`, add to the `PROJECTS` array. It auto-appears on the Projects page. +- **To remove a project:** remove its import and array entry from `index.ts`. +- Clicking a project card opens a detail view; a back button returns to the list. + Navigation is handled by event delegation on `#projects-section` in `overlay.ts`. +- The list resets on every nav entry (if you're viewing a detail and switch tabs, + coming back shows the list again). + +**To add a new data-driven section**, follow the Projects/Resume pattern: +1. Create `src/data/whatever.ts` with a typed interface + data export. +2. Add a `#whatever-section` div to `index.html` with the same CSS pattern + (opacity transition, `.visible` class with fade-in animation). +3. In `overlay.ts`: query the div, add a `whateverBuilt` flag, build the HTML + from data on first visit, toggle `.visible`/`.hidden` classes in `setScene()`. +4. Extend `colorBase`/`speedBase` arrays in `app.ts` and update cycle modulo. + +### Coordinate conventions + +- Layout space = **drawing-buffer pixels** (CSS pixels × `dpr`, capped at 2). +- Shader convention: origin **bottom-left**, **y up**. `app.ts` flips input y: + `mouse.y = res.y - clientY * dpr`. +- `Renderer` keeps `resolution` in actual device pixels. + +## WGSL / shader conventions + +- One oversized triangle, 3 hardcoded vertices in the vertex shader; no vertex buffers. +- Uniforms are a single `var u: Uniforms` at group 0, binding 0. +- Update `UNIFORM_FLOATS` in `renderer.ts` whenever you add a uniform field, and keep + the field order in TS `render(state)` identical to the WGSL struct declaration. +- Keep the shader readable: section it with `// ---------- name ----------` comments. + +## SDF reference helpers already in the shader + +- `sdRoundBox(p, halfExtent, radius)` — rounded rect (kept for future background motifs) +- `hash`, `noise`, `fbm` — value-noise fbm for the generative background +- `palette(s)` — hue shift keyed off `scene` + +To add more shapes, add `sd*` helpers next to the existing ones. + +## Currently rendered + +- **Background (shader):** domain-warped fbm flow field, palette indexed by `scene`, + vignette + grain. `scene` cycles 0→1→2→3→4 on each button click or nav click; each + scene shifts `palette`. `colorBase` and `speedBase` arrays in `app.ts` are indexed + by scene and must be extended when adding a new scene. +- **Nav bar (DOM):** top-center row of 5 ` + + + + + +
+

+

+

+
+ +
+
+ +
+
+

WebGPU not available

+

Open this page in a recent Chrome/Edge (or any WebGPU-enabled browser).

+
+
+ + + \ No newline at end of file diff --git a/nginx.default.conf b/nginx.default.conf new file mode 100644 index 0000000..2d33b2f --- /dev/null +++ b/nginx.default.conf @@ -0,0 +1,30 @@ +server { + listen 80; + server_name _; + + root /usr/share/nginx/html; + index index.html; + + # Security headers + add_header X-Content-Type-Options "nosniff" always; + add_header X-Frame-Options "DENY" always; + add_header Referrer-Policy "strict-origin-when-cross-origin" always; + + # Enable compression + gzip on; + gzip_types text/css application/javascript text/javascript application/wasm image/svg+xml; + gzip_min_length 512; + + # Immutable cache for hashed assets + location /assets/ { + expires 1y; + add_header Cache-Control "public, immutable"; + } + + # Everything else → index.html (SPA fallback) + location / { + try_files $uri $uri/ /index.html; + expires -1; + add_header Cache-Control "no-cache"; + } +} diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..59006fd --- /dev/null +++ b/package-lock.json @@ -0,0 +1,1090 @@ +{ + "name": "webgpu-portfolio", + "version": "0.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "webgpu-portfolio", + "version": "0.0.0", + "dependencies": { + "@playwright/test": "^1.61.1", + "opentype.js": "^2.0.0" + }, + "devDependencies": { + "@types/opentype.js": "^1.3.10", + "@webgpu/types": "^0.1.40", + "typescript": "^5.4.5", + "vite": "^5.2.11" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz", + "integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.21.5.tgz", + "integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz", + "integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.21.5.tgz", + "integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz", + "integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz", + "integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz", + "integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz", + "integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz", + "integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz", + "integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz", + "integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz", + "integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz", + "integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz", + "integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz", + "integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz", + "integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz", + "integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz", + "integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz", + "integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz", + "integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz", + "integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz", + "integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz", + "integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@playwright/test": { + "version": "1.61.1", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.61.1.tgz", + "integrity": "sha512-8nKv6+0RJSL9FE4jYOEGXnPeM/Hg12qZpmqzZjRh3qM0Y7c3z1mrOTfFLids72RDQYVh9WpLEfR5WdpNX4fkig==", + "license": "Apache-2.0", + "dependencies": { + "playwright": "1.61.1" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.62.2.tgz", + "integrity": "sha512-6o7ZLZK+BeenkZCFNDXqpbjw9bD6nuWonvS/lwQJp7NoVVxm6p3qE7qQ5jGuBjiFsgvqjD8mZAU5oWxTmbOeOg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.62.2.tgz", + "integrity": "sha512-BaH7BllCACHoH1LguOU56UItGfUWjujlO65kS9LAodViaN4bwIKd7oeW/ZHJ/4ljr/7MIiENnNy3HJ0zXv8Zkw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.62.2.tgz", + "integrity": "sha512-v39RCCvj4He82I9sFmk+M1VZ0PLM9sfsLVikjfx2hYBNALhrrOR2D3JjQA6AhlaSOgcR+RzrKY7e1+bT6SUO/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.62.2.tgz", + "integrity": "sha512-yl0y2vq3S3lHeuXhEdss6TWfKW8vkujImO12tn4ZkG/4oghr09LvdYm2RElVjokTQiUvDUGXLGsYeLqUMCKpGA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.62.2.tgz", + "integrity": "sha512-tT4pvt4qXD+vEoezupCWi+a1F0vvDiksiHc+PxRlYTOH1I6/X4id9jPxTP+Fg+545euaFT1jJVs4CEdHZAU1vw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.62.2.tgz", + "integrity": "sha512-6nU5F2wCW+qvCBhTn1pdIU3bzsIoF7EUwsCDRxilWGprQR6yd508YnH9+OKFCwpfS8pjZqDUmnCAr7exax0XCg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.62.2.tgz", + "integrity": "sha512-n1GJHPOvpIfhi3TmrCeh6S6URt9BFCt0KQE3qvexyGCTAKpR4Lg+eWvNZEqu7epxwus/8ElT3hacYEucm49SZg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.62.2.tgz", + "integrity": "sha512-JqgflS8wEB+UXV/vS1RpRbifGBeN4D5lz8D8oOFbFZw4vedvdOgCFAjfBmIMdW3yL10XpQQ0Ambepw6MXrhOnA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.62.2.tgz", + "integrity": "sha512-wnFJkogWvN4jm/hQRF2UBaeUmk20j5+DmHvoyWii2b8HJDyvz1MF2OU/6ynXt2KR63rbZLWkFpoytpdc/yBuSA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.62.2.tgz", + "integrity": "sha512-HVu2bp0zhvJ8xHEV9+UUs7S90VadmBSY3LcIMvozbPo4AuMGDWlz3ymHLHZPX4hR67TKTt8Qp5PJ5RBg/i+RMQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.62.2.tgz", + "integrity": "sha512-mQqqAV8QaoSgr9I2fKDLY2BAVvmKjWoGiu/cSYQonsLvtqwEn1E4QYfnCOcp5zoEqNhsDYin1s6jx/VJmrxlZg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.62.2.tgz", + "integrity": "sha512-IxKLoxCQ2IWi6bT2akyDUBGsOImDKB+sPp4EsTmwFQ/fMwpCKm8uLSSgP/Kx/QYUgKis6SEZ5/Nlhup0DIA0PQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.62.2.tgz", + "integrity": "sha512-Mk5ha2RQSgyFfmYYLkBpPnUk8D8FriBxesO1u9O75X0mHgXL1UQcH5Itl2lurWL2tj0RxV9b9tJgipac0hRY9A==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.62.2.tgz", + "integrity": "sha512-CjvEnqJL/0/TQ3TXX3OPIJ/kmBellrWd4heXUmHeJlTnmwjKpSJzoehLaL6Xk0ZnMHBu9dZuFADNOrtjF4v+2w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.62.2.tgz", + "integrity": "sha512-1SiZbzwdkaDURsew/tSOrooKiYy7EQGT6m8ufavAi9NEyQb/6VuIxFXAL1fqa4iZe3g4NbNk4P7J32z2tw5Mgg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.62.2.tgz", + "integrity": "sha512-nQts12zJ3NQRoE6uYljOH89v7szzLDvG2JD/vsX+vGXU8w/At1GowTZ5/7qeFQ8m7L55rpR8Okugnuo5bgjy2Q==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.62.2.tgz", + "integrity": "sha512-E9/ll019jhPIJgpzfZoIkBGhcz+kKNgVWYRY0zr9srBdPPFVpvOKW8VaJKUbeK+eZXyQF9ltME+Kk6affeaPgg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.62.2.tgz", + "integrity": "sha512-5BqxR/pshjey51iliyzTD5Xi3EN0aLmQ2lZ3lvefVV9c82BvrLo2/6OT55iifpWBufs6kdwWbuOKS841DrmK9A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.62.2.tgz", + "integrity": "sha512-uNN83XxQrRAh/w0/pmAfibcwyb6YWt4gP+dpnQKPVJshAloQ785ii8CT8ZCIxkGg9opVsvAlGhFitSm6D1Jjpg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.62.2.tgz", + "integrity": "sha512-srjEIxSH3LRnJN6THczDHWQplqEMFiAJrTab0msUryh9kwNpkICf3Ea6q6MN/2cZwRFUNx5w+h6Hpi4QuHS6Zg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.62.2.tgz", + "integrity": "sha512-8hOJnxgbyObnCm5AlRA3A931xX19xq80RjVTKgJOvEKWqJruP/Uf12IbAOaDjjEXYRewwHLfmF0YRIdK3OwKWA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.62.2.tgz", + "integrity": "sha512-mmF4AY1i0hG/bLWUctUq59gtmgaSIRa3cu/A3JFRp/sCNEme2bgDEiDS22P9FbnJB8NJNF4jPJiSP5RHQpUTDg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.62.2.tgz", + "integrity": "sha512-DZgkknc6jhHrk46V25vbAM0zZkyP0nSDkJB8/dRkLTxv470dOmWDqGoEJl/9A0dFfS7yE3REOwNDxpHwSLSt0Q==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.62.2.tgz", + "integrity": "sha512-T6xr6ucWSFto+VGajA8YH26LdpHRuP4YLHEKAtCWvJDOlnmWcDZVCI2Jmjr+IFHDlt2zRaTAKE4tfjTaWLgJBg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.62.2.tgz", + "integrity": "sha512-BfzEnDJOt9T8M989/lA37EcJgat01wLRnoi5dQf3QzOH7jzpqTAzdDbVfRljVr5r+jzKqpbHeyOfAaXxAd0PAA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/opentype.js": { + "version": "1.3.10", + "resolved": "https://registry.npmjs.org/@types/opentype.js/-/opentype.js-1.3.10.tgz", + "integrity": "sha512-F67EFyk6j02okHz5JCgata3ZRAcZi9GLnzmkHw/rzJq3OCc8/ZVdoKrxMTYjcQP6IYHGBz2cav1cpzkOkPiPCQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@webgpu/types": { + "version": "0.1.71", + "resolved": "https://registry.npmjs.org/@webgpu/types/-/types-0.1.71.tgz", + "integrity": "sha512-mMy8/ODcKhab808co15eW+yN+HgXoQxRQHTiBV9Mrvl1r0ufnid7YOcI+gi4eUWSWl9ezD6TW2KXccrL8HCh2A==", + "dev": true, + "license": "BSD-3-Clause" + }, + "node_modules/esbuild": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz", + "integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=12" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.21.5", + "@esbuild/android-arm": "0.21.5", + "@esbuild/android-arm64": "0.21.5", + "@esbuild/android-x64": "0.21.5", + "@esbuild/darwin-arm64": "0.21.5", + "@esbuild/darwin-x64": "0.21.5", + "@esbuild/freebsd-arm64": "0.21.5", + "@esbuild/freebsd-x64": "0.21.5", + "@esbuild/linux-arm": "0.21.5", + "@esbuild/linux-arm64": "0.21.5", + "@esbuild/linux-ia32": "0.21.5", + "@esbuild/linux-loong64": "0.21.5", + "@esbuild/linux-mips64el": "0.21.5", + "@esbuild/linux-ppc64": "0.21.5", + "@esbuild/linux-riscv64": "0.21.5", + "@esbuild/linux-s390x": "0.21.5", + "@esbuild/linux-x64": "0.21.5", + "@esbuild/netbsd-x64": "0.21.5", + "@esbuild/openbsd-x64": "0.21.5", + "@esbuild/sunos-x64": "0.21.5", + "@esbuild/win32-arm64": "0.21.5", + "@esbuild/win32-ia32": "0.21.5", + "@esbuild/win32-x64": "0.21.5" + } + }, + "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/nanoid": { + "version": "3.3.16", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.16.tgz", + "integrity": "sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q==", + "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/opentype.js": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/opentype.js/-/opentype.js-2.0.0.tgz", + "integrity": "sha512-kCyjv6xdDY1W/jLWZ/L3QhhTlKUqDZMQ5+Jdlw12b3dXkKNpYBqqlMMj0YDQPShWFTMwgZI1hG14kN3XUDSg/A==", + "license": "MIT", + "bin": { + "ot": "bin/ot" + } + }, + "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/playwright": { + "version": "1.61.1", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.61.1.tgz", + "integrity": "sha512-DWnY5o3YbLWK4GovuAVwpqL+1VwGNdUGrRr++8j8PtQQzvAVZUIMjKQ90fY689sEJZJBbZVw1rXaOKSTitkzPQ==", + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.61.1" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "fsevents": "2.3.2" + } + }, + "node_modules/playwright-core": { + "version": "1.61.1", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.61.1.tgz", + "integrity": "sha512-h7Qlt6m4REp25qvIdvbDtVmD4LqVXfpRxhORv9L0jzETM05p4fuPJ3dKyuSXQxDSbXnmS79HAgi9589lGSpLkg==", + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/playwright/node_modules/fsevents": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/postcss": { + "version": "8.5.17", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.17.tgz", + "integrity": "sha512-J7EF+8X+CzRPaJPOv9Ck2wNWJvGnnl3PcNPAdGg6GTLjyVpyQ0yATMSXRFRV01BviT/9Gwuc3rjEyJbDJG9a4w==", + "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.12", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/rollup": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.62.2.tgz", + "integrity": "sha512-RFnrW4lhXA3s3eqHDZvN654g8OTjzRfqpIRJYczCGB6HzphckVAi/Qh4tbPUbRuDi7s1Llv8g/NspLkttY3gTA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.9" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.62.2", + "@rollup/rollup-android-arm64": "4.62.2", + "@rollup/rollup-darwin-arm64": "4.62.2", + "@rollup/rollup-darwin-x64": "4.62.2", + "@rollup/rollup-freebsd-arm64": "4.62.2", + "@rollup/rollup-freebsd-x64": "4.62.2", + "@rollup/rollup-linux-arm-gnueabihf": "4.62.2", + "@rollup/rollup-linux-arm-musleabihf": "4.62.2", + "@rollup/rollup-linux-arm64-gnu": "4.62.2", + "@rollup/rollup-linux-arm64-musl": "4.62.2", + "@rollup/rollup-linux-loong64-gnu": "4.62.2", + "@rollup/rollup-linux-loong64-musl": "4.62.2", + "@rollup/rollup-linux-ppc64-gnu": "4.62.2", + "@rollup/rollup-linux-ppc64-musl": "4.62.2", + "@rollup/rollup-linux-riscv64-gnu": "4.62.2", + "@rollup/rollup-linux-riscv64-musl": "4.62.2", + "@rollup/rollup-linux-s390x-gnu": "4.62.2", + "@rollup/rollup-linux-x64-gnu": "4.62.2", + "@rollup/rollup-linux-x64-musl": "4.62.2", + "@rollup/rollup-openbsd-x64": "4.62.2", + "@rollup/rollup-openharmony-arm64": "4.62.2", + "@rollup/rollup-win32-arm64-msvc": "4.62.2", + "@rollup/rollup-win32-ia32-msvc": "4.62.2", + "@rollup/rollup-win32-x64-gnu": "4.62.2", + "@rollup/rollup-win32-x64-msvc": "4.62.2", + "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/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/vite": { + "version": "5.4.21", + "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz", + "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.21.3", + "postcss": "^8.4.43", + "rollup": "^4.20.0" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || >=20.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", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.4.0" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + } + } + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..01fb0a7 --- /dev/null +++ b/package.json @@ -0,0 +1,22 @@ +{ + "name": "webgpu-portfolio", + "private": true, + "version": "0.0.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "tsc --noEmit && vite build", + "preview": "vite preview", + "typecheck": "tsc --noEmit" + }, + "devDependencies": { + "@types/opentype.js": "^1.3.10", + "@webgpu/types": "^0.1.40", + "typescript": "^5.4.5", + "vite": "^5.2.11" + }, + "dependencies": { + "@playwright/test": "^1.61.1", + "opentype.js": "^2.0.0" + } +} diff --git a/public/SpaceMono-Regular.ttf b/public/SpaceMono-Regular.ttf new file mode 100644 index 0000000..8bf82ce Binary files /dev/null and b/public/SpaceMono-Regular.ttf differ diff --git a/src/app.ts b/src/app.ts new file mode 100644 index 0000000..e0c9b21 --- /dev/null +++ b/src/app.ts @@ -0,0 +1,147 @@ +import { Renderer, UiState } from './engine/renderer' +import { Input } from './ui/input' +import { DomOverlay } from './ui/overlay' + +const DPR_CAP = 2 + +interface SceneContent { + title: string + subtitle: string + body: string +} + +const SCENES: SceneContent[] = [ + { + title: 'Ahmed A. Mohamed', + subtitle: 'Creative Developer & Graphics Engineer', + body: 'Welcome to my personal website, there\'s a lot going on in my life that I will be sharing here randomly over time so take a look.' + }, + { + title: 'ABOUT', + subtitle: 'Who I am', + body: 'I build creative web experiences\nusing cutting-edge graphics technology.' + }, + { + title: 'PROJECTS', + subtitle: 'Things I have built', + body: 'A collection of work exploring\nWebGPU, shaders, and generative art.' + }, + { + title: 'CONTACT', + subtitle: 'Get in touch', + body: 'Reach me at hello@example.com\nor find me on GitHub.' + }, + { + title: 'RESUME', + subtitle: 'Ahmed Mohamed', + body: 'Graphics & Systems Engineer' + } +] + +export class App { + private canvas: HTMLCanvasElement + private renderer = new Renderer() + private input!: Input + private overlay!: DomOverlay + + private dpr = 1 + private res = new Float32Array(2) + private mouse = new Float32Array(2) + + private scene = 0 + private prevScene = -1 + private startTime = 0 + private rafId = 0 + private running = false + private stemMultipliers = new Float32Array(32) + + constructor(canvas: HTMLCanvasElement) { + this.canvas = canvas + } + + async start(): Promise { + this.input = new Input() + this.overlay = new DomOverlay(SCENES, { + onNavClick: (s) => { this.scene = s }, + onButtonClick: () => { this.scene = (this.scene + 1) % 5 } + }) + this.overlay.setScene(this.scene) + this.resize() + const ok = await this.renderer.init(this.canvas) + if (!ok) return false + this.startTime = performance.now() + this.running = true + this.loop() + return true + } + + stop(): void { + this.running = false + cancelAnimationFrame(this.rafId) + } + + private resize(): void { + this.dpr = Math.min(window.devicePixelRatio || 1, DPR_CAP) + const w = Math.floor(window.innerWidth * this.dpr) + const h = Math.floor(window.innerHeight * this.dpr) + if (this.canvas.width !== w || this.canvas.height !== h) { + this.canvas.width = w + this.canvas.height = h + this.res[0] = w + this.res[1] = h + if (this.renderer.ready) this.renderer.resize() + } + } + + private computeStemMultipliers(time: number): void { + const sm = this.stemMultipliers + // scene-based color identity and speed + const colorBase = [0.0, 0.6, 1.2, 1.8, 2.4] + const speedBase = [0.0, 0.2, 0.1, 0.0, 0.15] + const scene = this.scene + + // [0] = (camera speed, 0, color offset, twist/flash) + sm[0] = speedBase[scene] + sm[2] = colorBase[scene] * 0.2 + sm[3] = 0.3 + 0.3 * Math.sin(time * 0.5) + + // [1] = (color offset, 0, 0, 0) + sm[4] = colorBase[scene] * 0.4 + + // [2] = (size scale, 0, 0, 0) + sm[8] = 0.1// + 0.4 * Math.sin(time * 0.3 + scene * 3.0) + + // rest of the array stays zero + } + + private loop = (): void => { + if (!this.running) return + this.rafId = requestAnimationFrame(this.loop) + + const now = performance.now() + const time = (now - this.startTime) / 1000 + + this.resize() + + const mx = this.input.x * this.dpr + const my = this.res[1] - this.input.y * this.dpr + this.mouse[0] = mx + this.mouse[1] = my + + if (this.scene !== this.prevScene) { + this.prevScene = this.scene + this.overlay.setScene(this.scene) + } + + this.computeStemMultipliers(time) + + const state: UiState = { + resolution: this.res, + time, + scene: this.scene, + mouse: this.mouse, + stemMultipliers: this.stemMultipliers + } + this.renderer.render(state) + } +} \ No newline at end of file diff --git a/src/data/projects/auv-simulation.ts b/src/data/projects/auv-simulation.ts new file mode 100644 index 0000000..a0a913a --- /dev/null +++ b/src/data/projects/auv-simulation.ts @@ -0,0 +1,18 @@ +import { ProjectPage } from './index' + +const page: ProjectPage = { + id: 'auv-simulation', + name: 'AUV Simulation & Interfacing', + tagline: 'Full physics simulation + ROS + Qt ground control', + date: '2021', + tech: ['C++', 'ROS', 'Gazebo', 'Qt5'], + content: ` +

Full AUV physics simulation in Gazebo with ROS nodes using GStreamer to pipeline +media from AUV/simulation to an external monitoring interface via a Qt application +for the ground control team.

+ +

Designed the simulator, developed the Gazebo plugins and the C++ ROS nodes.

+`, +} + +export default page diff --git a/src/data/projects/evol-engine.ts b/src/data/projects/evol-engine.ts new file mode 100644 index 0000000..3e38208 --- /dev/null +++ b/src/data/projects/evol-engine.ts @@ -0,0 +1,20 @@ +import { ProjectPage } from './index' + +const page: ProjectPage = { + id: 'evol-engine', + name: 'Evol Game Engine', + tagline: 'Graduation Project — Vulkan-powered engine', + date: '07/2021', + tech: ['C', 'C++', 'Vulkan', 'OpenGL', 'ImGUI'], + content: ` +

Developed a high-performance, Vulkan-powered game engine with real-time physics +simulation, applicable to vehicle simulation and digital twin technology.

+ +

Built a modular scene editor for real-time rendering and physics-based simulation.

+ +

Implemented the Vulkan renderer for the engine core and an OpenGL PBR renderer +for the editor.

+`, +} + +export default page diff --git a/src/data/projects/improv-gfx.ts b/src/data/projects/improv-gfx.ts new file mode 100644 index 0000000..a566790 --- /dev/null +++ b/src/data/projects/improv-gfx.ts @@ -0,0 +1,17 @@ +import { ProjectPage } from './index' + +const page: ProjectPage = { + id: 'improv-gfx', + name: 'ImprovGFX', + tagline: 'From-scratch rasterizer with OpenCL GPU acceleration', + date: '2021', + tech: ['C++', 'OpenCL'], + content: ` +

A renderer/rasterizer built from scratch to render .obj models and .tga textures, +with OpenCL providing GPU acceleration.

+ +

Built a clean API for constructing scenes and shaders.

+`, +} + +export default page diff --git a/src/data/projects/index.ts b/src/data/projects/index.ts new file mode 100644 index 0000000..98c6ab8 --- /dev/null +++ b/src/data/projects/index.ts @@ -0,0 +1,66 @@ +// ---------- types ---------- + +export interface ProjectPage { + id: string + name: string + tagline: string + date: string + tech: string[] + content: string // free-form HTML +} + +export interface ProjectCategory { + id: string + label: string + description: string + projects: ProjectPage[] +} + +// ---------- project files ---------- + +import radianceCaching from './radiance-caching' +import pointCloud from './point-cloud' +import evolEngine from './evol-engine' +import auvSimulation from './auv-simulation' +import improvGfx from './improv-gfx' + +// ---------- flat list (for lookups) ---------- + +export const PROJECTS: ProjectPage[] = [ + radianceCaching, + pointCloud, + evolEngine, + auvSimulation, + improvGfx, +] + +// ---------- categories ---------- +// Add new categories here. Projects go in the `projects` array. +// Empty categories get a placeholder card on the grid. + +export const CATEGORIES: ProjectCategory[] = [ + { + id: 'programming', + label: 'Programming', + description: 'Graphics, engines, tools, and low-level systems.', + projects: [radianceCaching, pointCloud, evolEngine, improvGfx, auvSimulation], + }, + { + id: 'hardware', + label: 'Hardware', + description: 'Embedded, FPGA, PCB, and physical builds.', + projects: [], + }, + { + id: 'visuals', + label: 'Visuals', + description: 'Shaders, demoscene, generative art, and rendering experiments.', + projects: [], + }, + { + id: 'music', + label: 'Music', + description: 'Production, sound design, tools, and performances.', + projects: [], + }, +] diff --git a/src/data/projects/point-cloud.ts b/src/data/projects/point-cloud.ts new file mode 100644 index 0000000..cf47985 --- /dev/null +++ b/src/data/projects/point-cloud.ts @@ -0,0 +1,24 @@ +import { ProjectPage } from './index' + +const page: ProjectPage = { + id: 'point-cloud', + name: 'GPU-Driven Point Cloud Renderer', + tagline: 'TUM — 140M+ points at ~100 FPS', + date: '2024', + tech: ['C++', 'Vulkan', 'GLSL'], + content: ` +

Implemented a real-time GPU-driven renderer for large point clouds (140M+ points +at ~100 FPS) using a custom 5-stage compute shader pipeline in Vulkan, based on +Schütz et al. (2021, 2022).

+ +

Created a software rasterization pipeline using atomic uint64 depth-color packing +and compute shaders, enabling fine-grained depth testing without a hardware depth +buffer.

+ +

Implemented batch-based frustum culling, Morton-code spatial sorting, and +screen-coverage LOD selection via 10-bit quantized position precision. Used Vulkan +subgroup extensions for warp-level optimizations.

+`, +} + +export default page diff --git a/src/data/projects/radiance-caching.ts b/src/data/projects/radiance-caching.ts new file mode 100644 index 0000000..4e48c02 --- /dev/null +++ b/src/data/projects/radiance-caching.ts @@ -0,0 +1,21 @@ +import { ProjectPage } from './index' + +const page: ProjectPage = { + id: 'radiance-caching', + name: 'Radiance Caching for Real-Time Volumetric Rendering', + tagline: "Master's Thesis — TUM", + date: '12/2025 — 06/2026', + tech: ['C++', 'CUDA', 'Vulkan', 'OptiX'], + content: ` +

Researched and implemented a real-time radiance cache for volumetric path tracing +using 3D Gaussian Splatting and a fused MLP as the cache representation.

+ +

Developed an online differentiable optimization pipeline in CUDA that adapts the +Gaussian cache to dynamic lighting and transfer function changes during rendering.

+ +

Built a spatial-hash-based fused MLP for radiance caching on volumetric +heterogeneous media. Supervised by Prof. Westermann.

+`, +} + +export default page diff --git a/src/data/resume.ts b/src/data/resume.ts new file mode 100644 index 0000000..2129453 --- /dev/null +++ b/src/data/resume.ts @@ -0,0 +1,131 @@ +// ---------- resume data types ---------- + +export interface ResumeEntry { + role: string + company: string + location: string + date: string + bullets: string[] + thesis?: string // education only +} + +export interface SkillGroup { + label: string + skills: string[] +} + +export interface ResumeData { + name: string + contact: { label: string; href?: string }[] + location: string + summary: string + education: ResumeEntry[] + work: ResumeEntry[] + skills: SkillGroup[] +} + +// ---------- resume content ---------- + +export const RESUME: ResumeData = { + name: 'Ahmed Mohamed', + contact: [ + { label: '(+49) 15202103583' }, + { label: 'ed.mohamed@tum.de', href: 'mailto:ed.mohamed@tum.de' }, + { label: 'linkedin.com/in/inedited/', href: 'https://linkedin.com/in/inedited/' }, + { label: 'github.com/inedited', href: 'https://github.com/inedited' }, + ], + location: 'Gustav-Schwab-Str., 4, 81673 München, Germany', + summary: + 'Experienced software developer with a strong background in graphics programming, real-time rendering, XR development and AI engineering. Experienced in developing high-performance applications and specializing in low-level optimizations.', + education: [ + { + role: 'Master of Science in Informatics: Games Engineering', + company: 'Technical University of Munich (TUM)', + location: 'Munich, Germany', + date: '04/2024 \u2014 06/2026', + thesis: + 'Thesis: Real-time volume path tracing using neural radiance caching and 3DGS Radiance cache.', + bullets: [], + }, + { + role: 'Bachelor of Science in Software Systems and Computer Engineering', + company: 'Ain Shams University', + location: 'Cairo, Egypt', + date: '07/2016 \u2014 07/2021', + thesis: 'Thesis: 3D game engine in C.', + bullets: [], + }, + { + role: 'Dual Bachelor of Science in Software Systems and Computer Engineering', + company: 'University of East London', + location: 'London, United Kingdom', + date: '07/2018 \u2014 07/2021', + bullets: [], + }, + ], + work: [ + { + role: 'Software Developer (Working Student)', + company: 'Siemens AG', + location: 'Munich, Germany', + date: '04/2025 \u2014 Present', + bullets: [ + 'Led full cycle development across Physical AI, simulation, XR, and robotics integration projects within the Industrial Metaverse Lab.', + 'Worked on technical collaboration projects with NVIDIA, Meta, and Amazon AWS for Industrial AI applications.', + 'Built XR applications for Meta Quest, Meta RayBan, and Apple Vision Pro, implementing real time XR AI assisted solutions for industrial environments.', + 'Worked on robotics simulation and training using NVIDIA IsaacSim, developed ROS integrations, Teleoperation protocols, and deployed trained models to physical robots.', + 'Deployed and fine-tuned LLM models for industrial applications and internal tooling.', + ], + }, + { + role: 'Software Developer', + company: 'The Forge ConfettiFX', + location: 'California (Remote), USA', + date: '03/2022 \u2014 08/2023', + bullets: [ + 'Contributed to real-time rendering engine for Forza Motorsport 8 with Turn10 studio, optimizing rendering performance and rewriting shadow rendering system and asset pipelines.', + 'Improved The Forge rendering framework visibility buffer implementation, optimizing the graphics pipeline for real time rendering and testing across different consoles.', + 'Developed and optimized Linux ports for The Forge rendering framework and optimized the framework for Steam Deck.', + 'Redesigned the CI/CD testing pipeline for a Linux-based rendering framework, reducing testing time.', + ], + }, + { + role: 'Software Developer', + company: 'SciChart', + location: 'London (Remote), United Kingdom', + date: '01/2022 \u2014 01/2025', + bullets: [ + 'Developed and optimized the SciChart 3D graphics engine for high performance data visualization, simulation, telemetry, and real time analytics.', + 'Ported the 3D graphics engine and WPF Desktop application to Linux.', + 'Worked on the development of JavaScript/WebGL version of the 3D graphics engine on top of the C++ core engine.', + 'Implemented a Vulkan rendering backend, increasing performance on large scale datasets by about 20% across multiple platforms.', + ], + }, + { + role: 'Software Developer', + company: '412 Labs', + location: 'Cairo, Egypt', + date: '03/2021 \u2014 12/2021', + bullets: [ + 'Developed VR applications using Unity for different client projects for architectural visualization and internal designs.', + 'Taught a class of 20 students on VR development fundamentals using Unity.', + 'Built and deployed multiple full stack solutions for 412 Labs projects and clients.', + ], + }, + ], + + skills: [ + { + label: 'Languages', + skills: ['C', 'C++', 'C#', 'Python','Swift','TypeScript','Kotlin', 'GLSL', 'HLSL'], + }, + { + label: 'Graphics & HPC', + skills: ['Vulkan', 'OpenGL', 'DirectX', 'CUDA', 'WebGpu', 'Volume Rendering', 'Path Tracing'], + }, + { + label: 'Other', + skills: ['CI/CD', 'AI Agents', 'LLM', 'ROS', 'Unity', 'Unreal Engine', 'Git', 'Docker'], + }, + ], +} diff --git a/src/engine/renderer.ts b/src/engine/renderer.ts new file mode 100644 index 0000000..9bb451d --- /dev/null +++ b/src/engine/renderer.ts @@ -0,0 +1,104 @@ +import shaderSource from './shaders/fullscreen.wgsl?raw' + +const UNIFORM_FLOATS = 48 // res(2), time, scene, mouse(2), pad(2), date(4), sampleRate(4), stemMultipliers(32) +const UNIFORM_BYTES = UNIFORM_FLOATS * 4 + +export interface UiState { + resolution: Float32Array + time: number + scene: number + mouse: Float32Array + stemMultipliers: Float32Array +} + +export class Renderer { + private device!: GPUDevice + private context!: GPUCanvasContext + private format!: GPUTextureFormat + private pipeline!: GPURenderPipeline + private uniformBuffer!: GPUBuffer + private bindGroup!: GPUBindGroup + private uniformData = new Float32Array(UNIFORM_FLOATS) + private configured = false + + get ready(): boolean { + return this.configured + } + + async init(canvas: HTMLCanvasElement): Promise { + if (!navigator.gpu) return false + const adapter = await navigator.gpu.requestAdapter() + if (!adapter) return false + this.device = await adapter.requestDevice() + this.context = canvas.getContext('webgpu') as GPUCanvasContext + this.format = navigator.gpu.getPreferredCanvasFormat() + this.configure() + + const module = this.device.createShaderModule({ code: shaderSource }) + this.pipeline = this.device.createRenderPipeline({ + layout: 'auto', + vertex: { module, entryPoint: 'vs' }, + fragment: { module, entryPoint: 'fs', targets: [{ format: this.format }] }, + primitive: { topology: 'triangle-list' } + }) + + this.uniformBuffer = this.device.createBuffer({ + size: UNIFORM_BYTES, + usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST + }) + this.bindGroup = this.device.createBindGroup({ + layout: this.pipeline.getBindGroupLayout(0), + entries: [{ binding: 0, resource: { buffer: this.uniformBuffer } }] + }) + + this.device.lost.then((info) => { console.error('WebGPU device lost:', info); this.configured = false }) + this.device.onuncapturederror = (ev: GPUUncapturedErrorEvent) => { + console.error('WebGPU uncaptured error:', ev.error) + } + this.configured = true + return true + } + + configure(): void { + this.context.configure({ + device: this.device, + format: this.format, + alphaMode: 'opaque' + }) + } + + resize(): void { + this.configure() + } + + render(state: UiState): void { + if (!this.configured) return + const d = this.uniformData + d[0] = state.resolution[0] + d[1] = state.resolution[1] + d[2] = state.time + d[3] = state.scene + d[4] = state.mouse[0] + d[5] = state.mouse[1] + // d[6], d[7]: padding — left as zero + // d[8]-d[11]: date — left as zero + // d[12]-d[15]: sampleRate — left as zero + d.set(state.stemMultipliers, 16) + this.device.queue.writeBuffer(this.uniformBuffer, 0, this.uniformData) + + const encoder = this.device.createCommandEncoder() + const view = this.context.getCurrentTexture().createView() + + const bgPass = encoder.beginRenderPass({ + colorAttachments: [ + { view, clearValue: { r: 0, g: 0, b: 0, a: 1 }, loadOp: 'clear', storeOp: 'store' } + ] + }) + bgPass.setPipeline(this.pipeline) + bgPass.setBindGroup(0, this.bindGroup) + bgPass.draw(3) + bgPass.end() + + this.device.queue.submit([encoder.finish()]) + } +} \ No newline at end of file diff --git a/src/engine/shaders/fullscreen.wgsl b/src/engine/shaders/fullscreen.wgsl new file mode 100644 index 0000000..30d894b --- /dev/null +++ b/src/engine/shaders/fullscreen.wgsl @@ -0,0 +1,104 @@ +struct Uniforms { + resolution: vec2, + time: f32, + scene: f32, + mouse: vec2, + date: vec4, + sampleRate: vec4, + stemMultipliers: array, 8>, +}; + +@group(0) @binding(0) var u: Uniforms; + +struct VsOut { + @builtin(position) pos: vec4, + @location(0) uv: vec2, +}; + +@vertex +fn vs(@builtin(vertex_index) i: u32) -> VsOut { + let p = array, 3>( + vec2(-1.0, -3.0), + vec2(-1.0, 1.0), + vec2(3.0, 1.0), + ); + let clip = p[i]; + var out: VsOut; + out.pos = vec4(clip, 0.0, 1.0); + out.uv = clip; + return out; +} + +@fragment +fn fs(in: VsOut) -> @location(0) vec4 { + let res = u.resolution; + let fragCoord = (in.uv * vec2(0.5) + vec2(0.5)) * res; + + var uv = (fragCoord - 0.5 * res) / res.y; + + let slowTime = u.time * 0.2; + + let bgGrey = vec3(0.11, 0.11, 0.12); + let cardGrey = vec3(0.30, 0.32, 0.36); + let coreWhite = vec3(0.90, 0.95, 0.90); + + // ---------- palette keyed by scene ---------- + let scene0 = vec3(0.20, 0.75, 0.20); // HERO – emerald + let scene1 = vec3(0.85, 0.55, 0.20); // ABOUT – amber + let scene2 = vec3(0.25, 0.60, 0.95); // PROJECTS – cobalt + let scene3 = vec3(0.90, 0.35, 0.55); // CONTACT – rose + + let sFloor = i32(clamp(floor(u.scene), 0.0, 3.0)); + let sCeil = i32(clamp(ceil(u.scene), 0.0, 3.0)); + var c0 = scene0; + c0 = select(c0, scene1, sFloor == 1); + c0 = select(c0, scene2, sFloor == 2); + c0 = select(c0, scene3, sFloor == 3); + var c1 = scene0; + c1 = select(c1, scene1, sCeil == 1); + c1 = select(c1, scene2, sCeil == 2); + c1 = select(c1, scene3, sCeil == 3); + let tBlend = u.scene - floor(u.scene); + let sceneAccent = mix(c0, c1, tBlend * tBlend * (3.0 - 2.0 * tBlend)); + + // ---------- mouse-reactive distortion ---------- + let mousePos = (u.mouse - 0.5 * res) / res.y; + let distMouse = length(uv - mousePos); + let influence = exp(-distMouse * 7.0); + uv += (mousePos - uv) * influence * 0.12; + + let angle = atan2(uv.y, uv.x); + let radius = length(uv); + + let baseSpiral = angle + (radius * 8.0) - slowTime; + let ripple = sin(radius * 15.0 - (u.time * 0.5)) * 0.08; + let distortedSpiral = baseSpiral + ripple; + + let armCount = 2.0; + let rChannel = smoothstep(0.05, 0.55, abs(sin((distortedSpiral + 0.04) * armCount))); + let gChannel = smoothstep(0.05, 0.55, abs(sin(distortedSpiral * armCount))); + let bChannel = smoothstep(0.05, 0.55, abs(sin((distortedSpiral - 0.04) * armCount))); + let patternChannels = vec3(rChannel, gChannel, bChannel); + + let coreNeon = vec3(0.02 / (patternChannels + 0.025)); + let spiralColor = mix(cardGrey, sceneAccent, patternChannels.g); + let primarySpiral = coreNeon * spiralColor; + + let ringWave = sin(radius * 12.0 - (u.time * 1.5)); + let sharpRing = smoothstep(0.85, 0.95, ringWave); + let ringMask = sharpRing * smoothstep(0.7, 0.1, radius); + let secondaryRings = cardGrey * ringMask * 0.4; + + let vignette = smoothstep(0.95, 0.4, radius); + + var finalColor = bgGrey + secondaryRings; + finalColor = mix(finalColor, primarySpiral, 0.85); + + let centerLight = 0.015 / (radius + 0.02); + finalColor += centerLight * coreWhite; + + // soft cursor glow + finalColor += exp(-distMouse * 4.0) * 0.03 * sceneAccent; + + return vec4(finalColor * vignette, 1.0); +} diff --git a/src/engine/text/slug-generator.ts b/src/engine/text/slug-generator.ts new file mode 100644 index 0000000..397a8b6 --- /dev/null +++ b/src/engine/text/slug-generator.ts @@ -0,0 +1,352 @@ +import type { Font, Glyph, PathCommand } from 'opentype.js' + +const TEXTURE_WIDTH = 4096 + +export interface CodePointData { + codePoint: number + width: number + height: number + advanceWidth: number + bearingX: number + bearingY: number + bandCount: number + bandDimX: number + bandDimY: number + bandsTexCoordX: number + bandsTexCoordY: number +} + +export interface SlugData { + codePoints: Map + curvesData: Float32Array + bandsData: Uint32Array + curvesWidth: number + curvesHeight: number + bandsWidth: number + bandsHeight: number + ascender: number + descender: number + lineGap: number + unitsPerEm: number +} + +interface Curve { + first: boolean + x1: number + y1: number + x2: number + y2: number + x3: number + y3: number + texelIndex: number +} + +export interface GeneratorOptions { + bandCount?: number + fullRange?: boolean + whitelist?: number[] | null +} + +export class SlugGenerator { + private bandCount: number + private fullRange: boolean + private whitelist: number[] | null + + constructor(options: GeneratorOptions = {}) { + this.bandCount = options.bandCount ?? 16 + this.fullRange = options.fullRange ?? false + this.whitelist = options.whitelist ?? null + } + + async generateFromUrl(url: string): Promise { + const opentype = await import('opentype.js') + const response = await fetch(url) + if (!response.ok) throw new Error(`Failed to fetch font: ${response.status}`) + const buffer = await response.arrayBuffer() + const font = opentype.parse(buffer) + return this.generate(font) + } + + async generateFromBuffer(buffer: ArrayBuffer): Promise { + const opentype = await import('opentype.js') + const font = opentype.parse(buffer) + return this.generate(font) + } + + generate(font: Font): SlugData { + const curvesTexData: number[] = [] + const bandsTexBandOffsets: number[] = [] + const bandsTexCurveOffsets: number[] = [] + const codePointsData: CodePointData[] = [] + + for (let i = 0; i < font.glyphs.length; i++) { + const glyph: Glyph = font.glyphs.get(i) + let cp = glyph.unicode + if (cp === undefined) { + if (i === 0) cp = -1 + else continue + } + + if (!this.fullRange && i !== 0) { + if (this.whitelist) { + if (!this.whitelist.includes(cp)) continue + } else if (cp < 32 || cp > 126) { + continue + } + } + + const path = glyph.path + const bbox = glyph.getBoundingBox() + if (bbox.x1 === bbox.x2 || bbox.y1 === bbox.y2) { + codePointsData.push({ + codePoint: cp, + width: 0, + height: 0, + advanceWidth: Math.floor(glyph.advanceWidth || 0), + bearingX: 0, + bearingY: 0, + bandCount: 0, + bandDimX: 0, + bandDimY: 0, + bandsTexCoordX: 0, + bandsTexCoordY: 0 + }) + continue + } + + const gx1 = bbox.x1 + const gy1 = bbox.y1 + const gx2 = bbox.x2 + const gy2 = bbox.y2 + + const curves = this.buildCurves(path.commands, gx1, gy1) + if (!curves || curves.length === 0) continue + + this.fixDegenerateCurves(curves) + + const bandsTexelIndex = Math.floor(bandsTexBandOffsets.length / 2) + + this.packCurves(curves, curvesTexData) + + const sizeX = 1 + (gx2 - gx1) + const sizeY = 1 + (gy2 - gy1) + let bCount = this.bandCount + if (sizeX < bCount || sizeY < bCount) { + bCount = Math.floor(Math.min(sizeX, sizeY) / 2) + if (bCount < 1) bCount = 1 + } + + const bandDimY = Math.ceil(sizeY / bCount) + const bandDimX = Math.ceil(sizeX / bCount) + + // Horizontal bands: sort by max X descending + const hSorted = [...curves].sort((a, b) => Math.max(b.x1, b.x2, b.x3) - Math.max(a.x1, a.x2, a.x3)) + let bandMinY = 0 + let bandMaxY = bandDimY + for (let b = 0; b < bCount; b++) { + const bandTexelOffset = Math.floor(bandsTexCurveOffsets.length / 2) + let curveCount = 0 + for (const c of hSorted) { + if (c.y1 === c.y2 && c.y2 === c.y3) continue + const curveMinY = Math.min(c.y1, c.y2, c.y3) + const curveMaxY = Math.max(c.y1, c.y2, c.y3) + if (curveMinY > bandMaxY || curveMaxY < bandMinY) continue + const curveOffsetX = c.texelIndex % TEXTURE_WIDTH + const curveOffsetY = Math.floor(c.texelIndex / TEXTURE_WIDTH) + bandsTexCurveOffsets.push(curveOffsetX, curveOffsetY) + curveCount++ + } + bandsTexBandOffsets.push(curveCount, bandTexelOffset) + bandMinY += bandDimY + bandMaxY += bandDimY + } + + // Vertical bands: sort by max Y descending + const vSorted = [...curves].sort((a, b) => Math.max(b.y1, b.y2, b.y3) - Math.max(a.y1, a.y2, a.y3)) + let bandMinX = 0 + let bandMaxX = bandDimX + for (let b = 0; b < bCount; b++) { + const bandTexelOffset = Math.floor(bandsTexCurveOffsets.length / 2) + let curveCount = 0 + for (const c of vSorted) { + if (c.x1 === c.x2 && c.x2 === c.x3) continue + const curveMinX = Math.min(c.x1, c.x2, c.x3) + const curveMaxX = Math.max(c.x1, c.x2, c.x3) + if (curveMinX > bandMaxX || curveMaxX < bandMinX) continue + const curveOffsetX = c.texelIndex % TEXTURE_WIDTH + const curveOffsetY = Math.floor(c.texelIndex / TEXTURE_WIDTH) + bandsTexCurveOffsets.push(curveOffsetX, curveOffsetY) + curveCount++ + } + bandsTexBandOffsets.push(curveCount, bandTexelOffset) + bandMinX += bandDimX + bandMaxX += bandDimX + } + + codePointsData.push({ + codePoint: cp, + width: Math.floor(gx2 - gx1), + height: Math.floor(gy2 - gy1), + advanceWidth: Math.floor(glyph.advanceWidth || 0), + bearingX: Math.floor(gx1), + bearingY: Math.floor(gy1), + bandCount: bCount, + bandDimX, + bandDimY, + bandsTexCoordX: bandsTexelIndex % TEXTURE_WIDTH, + bandsTexCoordY: Math.floor(bandsTexelIndex / TEXTURE_WIDTH) + }) + } + + // Post-processing: offset curve offsets by band header texel count + const bandHeaderTexels = Math.floor(bandsTexBandOffsets.length / 2) + for (let i = 1; i < bandsTexBandOffsets.length; i += 2) { + bandsTexBandOffsets[i] += bandHeaderTexels + } + + return this.buildOutput(codePointsData, curvesTexData, bandsTexBandOffsets, bandsTexCurveOffsets, font) + } + + private buildCurves( + commands: PathCommand[], + gx1: number, + gy1: number + ): Curve[] | null { + const curves: Curve[] = [] + let currentX = 0 + let currentY = 0 + let firstCurve = false + let startOfShapeX = 0 + let startOfShapeY = 0 + + for (const cmd of commands) { + if (cmd.type === 'M') { + firstCurve = true + currentX = cmd.x - gx1 + currentY = cmd.y - gy1 + startOfShapeX = currentX + startOfShapeY = currentY + } else if (cmd.type === 'L') { + const nextX = cmd.x - gx1 + const nextY = cmd.y - gy1 + curves.push({ + first: firstCurve, + x1: currentX, y1: currentY, + x2: (currentX + nextX) / 2.0, + y2: (currentY + nextY) / 2.0, + x3: nextX, y3: nextY, + texelIndex: 0 + }) + firstCurve = false + currentX = nextX + currentY = nextY + } else if (cmd.type === 'Q') { + const nextX = cmd.x - gx1 + const nextY = cmd.y - gy1 + curves.push({ + first: firstCurve, + x1: currentX, y1: currentY, + x2: cmd.x1 - gx1, y2: cmd.y1 - gy1, + x3: nextX, y3: nextY, + texelIndex: 0 + }) + firstCurve = false + currentX = nextX + currentY = nextY + } else if (cmd.type === 'C') { + return null + } else if (cmd.type === 'Z') { + if (currentX !== startOfShapeX || currentY !== startOfShapeY) { + curves.push({ + first: firstCurve, + x1: currentX, y1: currentY, + x2: (currentX + startOfShapeX) / 2.0, + y2: (currentY + startOfShapeY) / 2.0, + x3: startOfShapeX, y3: startOfShapeY, + texelIndex: 0 + }) + firstCurve = false + } + currentX = startOfShapeX + currentY = startOfShapeY + } + } + + return curves + } + + private fixDegenerateCurves(curves: Curve[]): void { + for (const c of curves) { + if ((c.x2 === c.x1 && c.y2 === c.y1) || (c.x2 === c.x3 && c.y2 === c.y3)) { + c.x2 = (c.x1 + c.x3) / 2.0 + c.y2 = (c.y1 + c.y3) / 2.0 + } + } + } + + private packCurves(curves: Curve[], curvesTexData: number[]): void { + for (const c of curves) { + if (c.first && curvesTexData.length % 4 !== 0) { + const toAdd = 4 - (curvesTexData.length % 4) + for (let i = 0; i < toAdd; i++) curvesTexData.push(-1.0) + } + + const texelCount = Math.floor(curvesTexData.length / 4) + const col = texelCount % TEXTURE_WIDTH + const newRow = col === TEXTURE_WIDTH - 1 + if (newRow) { + const toAdd = 8 - (curvesTexData.length % 4) + for (let i = 0; i < toAdd; i++) curvesTexData.push(-1.0) + } + + if (c.first || newRow) { + c.texelIndex = Math.floor(curvesTexData.length / 4) + curvesTexData.push(c.x1, c.y1) + } else { + c.texelIndex = Math.floor((Math.floor(curvesTexData.length / 2) - 1) / 2) + } + + curvesTexData.push(c.x2, c.y2) + curvesTexData.push(c.x3, c.y3) + } + } + + private buildOutput( + codePoints: CodePointData[], + curvesList: number[], + bandOffsets: number[], + curveOffsets: number[], + font: Font + ): SlugData { + const map = new Map() + codePoints.forEach(cp => map.set(cp.codePoint, cp)) + + const curvesTexels = Math.ceil(curvesList.length / 4) + const curvesTexHeight = Math.max(1, Math.ceil(curvesTexels / TEXTURE_WIDTH)) + + const curvesFloatArray = new Float32Array(TEXTURE_WIDTH * curvesTexHeight * 4) + curvesFloatArray.fill(-1.0) + curvesFloatArray.set(curvesList) + + const bandsTexels = Math.floor(bandOffsets.length / 2) + Math.floor(curveOffsets.length / 2) + const bandsTexHeight = Math.max(1, Math.ceil(bandsTexels / TEXTURE_WIDTH)) + + const bandsUintArray = new Uint32Array(TEXTURE_WIDTH * bandsTexHeight * 2) + bandsUintArray.set(bandOffsets, 0) + bandsUintArray.set(curveOffsets, bandOffsets.length) + + return { + codePoints: map, + curvesData: curvesFloatArray, + bandsData: bandsUintArray, + curvesWidth: TEXTURE_WIDTH, + curvesHeight: curvesTexHeight, + bandsWidth: TEXTURE_WIDTH, + bandsHeight: bandsTexHeight, + ascender: font.ascender || 0, + descender: font.descender || 0, + lineGap: (font as unknown as { lineGap?: number }).lineGap || 0, + unitsPerEm: font.unitsPerEm || 0 + } + } +} \ No newline at end of file diff --git a/src/engine/text/text-layout.ts b/src/engine/text/text-layout.ts new file mode 100644 index 0000000..b694543 --- /dev/null +++ b/src/engine/text/text-layout.ts @@ -0,0 +1,177 @@ +import type { SlugData, CodePointData } from './slug-generator' + +export interface TextOptions { + fontScale?: number + startX?: number + startY?: number + lineHeight?: number + justify?: 'left' | 'center' | 'right' +} + +export interface TextLayoutResult { + /** per-instance: (sx, sy, cx, cy) float */ + scaleBias: Float32Array + /** per-instance: (glyphWidth, glyphHeight, bandScaleX, bandScaleY) float */ + glyphBandScale: Float32Array + /** per-instance: (bandMax, bandMax, bandsTexCoordX, bandsTexCoordY) uint32 */ + bandMaxTexCoords: Uint32Array + glyphCount: number +} + +export function layoutText( + text: string, + slugData: SlugData, + options: TextOptions = {} +): TextLayoutResult { + const ascender = slugData.ascender + const descender = slugData.descender + const lineGap = slugData.lineGap + + const defaultLineHeight = slugData.unitsPerEm > 0 + ? (ascender - descender + lineGap) + : 2000 + + const { + fontScale = 0.5, + lineHeight = defaultLineHeight * fontScale, + startX = 0, + startY = 0, + justify = 'left' + } = options + + const maxGlyphs = countGlyphs(text) + const result: TextLayoutResult = { + scaleBias: new Float32Array(maxGlyphs * 4), + glyphBandScale: new Float32Array(maxGlyphs * 4), + bandMaxTexCoords: new Uint32Array(maxGlyphs * 4), + glyphCount: 0 + } + + const lines = text.split('\n') + let currentY = startY + + for (const line of lines) { + let lineWidth = 0 + let j = 0 + while (j < line.length) { + const charCode = line.codePointAt(j)! + j += charCode > 0xffff ? 2 : 1 + const data = slugData.codePoints.get(charCode) ?? slugData.codePoints.get(-1) + if (data) { + lineWidth += data.advanceWidth * fontScale + } else if (line[j - 1] === ' ') { + lineWidth += 600 * fontScale + } + } + + let currentX = startX + if (justify === 'center') currentX -= lineWidth / 2.0 + else if (justify === 'right') currentX -= lineWidth + + let k = 0 + while (k < line.length) { + const charCode = line.codePointAt(k)! + k += charCode > 0xffff ? 2 : 1 + const data = slugData.codePoints.get(charCode) ?? slugData.codePoints.get(-1) + + if (data) { + if (data.width > 0 && data.height > 0) { + addGlyph(result, data, currentX, currentY, fontScale) + } + currentX += data.advanceWidth * fontScale + } else if (line[k - 1] === ' ') { + currentX += 600 * fontScale + } + } + currentY -= lineHeight + } + + return result +} + +export function measureText( + text: string, + slugData: SlugData, + fontScale: number +): number { + let width = 0 + let j = 0 + while (j < text.length) { + const charCode = text.codePointAt(j)! + j += charCode > 0xffff ? 2 : 1 + const data = slugData.codePoints.get(charCode) ?? slugData.codePoints.get(-1) + if (data) { + width += data.advanceWidth * fontScale + } else if (text[j - 1] === ' ') { + width += 600 * fontScale + } + } + return width +} + +export function mergeLayouts(layouts: TextLayoutResult[]): TextLayoutResult { + const total = layouts.reduce((s, l) => s + l.glyphCount, 0) + const merged: TextLayoutResult = { + scaleBias: new Float32Array(total * 4), + glyphBandScale: new Float32Array(total * 4), + bandMaxTexCoords: new Uint32Array(total * 4), + glyphCount: 0 + } + for (const layout of layouts) { + const n = layout.glyphCount + if (n === 0) continue + const off = merged.glyphCount + merged.scaleBias.set(layout.scaleBias.subarray(0, n * 4), off * 4) + merged.glyphBandScale.set(layout.glyphBandScale.subarray(0, n * 4), off * 4) + merged.bandMaxTexCoords.set(layout.bandMaxTexCoords.subarray(0, n * 4), off * 4) + merged.glyphCount += n + } + return merged +} + +function countGlyphs(text: string): number { + let count = 0 + let j = 0 + while (j < text.length) { + const charCode = text.codePointAt(j)! + j += charCode > 0xffff ? 2 : 1 + count++ + } + return count +} + +function addGlyph( + result: TextLayoutResult, + cpData: CodePointData, + x: number, + y: number, + fontScale: number +): void { + const i = result.glyphCount + const quadW = cpData.width * fontScale + const quadH = cpData.height * fontScale + const px = x + cpData.bearingX * fontScale + const py = y + cpData.bearingY * fontScale + + const sx = quadW / 2.0 + const sy = quadH / 2.0 + const cx = px + sx + const cy = py + sy + + result.scaleBias[i * 4 + 0] = sx + result.scaleBias[i * 4 + 1] = sy + result.scaleBias[i * 4 + 2] = cx + result.scaleBias[i * 4 + 3] = cy + + result.glyphBandScale[i * 4 + 0] = cpData.width + result.glyphBandScale[i * 4 + 1] = cpData.height + result.glyphBandScale[i * 4 + 2] = cpData.width / cpData.bandDimX + result.glyphBandScale[i * 4 + 3] = cpData.height / cpData.bandDimY + + result.bandMaxTexCoords[i * 4 + 0] = cpData.bandCount - 1 + result.bandMaxTexCoords[i * 4 + 1] = cpData.bandCount - 1 + result.bandMaxTexCoords[i * 4 + 2] = cpData.bandsTexCoordX + result.bandMaxTexCoords[i * 4 + 3] = cpData.bandsTexCoordY + + result.glyphCount++ +} \ No newline at end of file diff --git a/src/engine/text/text-renderer.ts b/src/engine/text/text-renderer.ts new file mode 100644 index 0000000..f41b50c --- /dev/null +++ b/src/engine/text/text-renderer.ts @@ -0,0 +1,205 @@ +import shaderSource from './text.wgsl?raw' +import type { SlugData } from './slug-generator' +import type { TextLayoutResult } from './text-layout' + +const TEXT_UNIFORM_FLOATS = 8 // resolution(2) + pad(2) + color(4) +const TEXT_UNIFORM_BYTES = TEXT_UNIFORM_FLOATS * 4 + +const QUAD_VERTICES = new Float32Array([ + -1.0, -1.0, + -1.0, 1.0, + 1.0, 1.0, + -1.0, -1.0, + 1.0, 1.0, + 1.0, -1.0 +]) + +const MAX_GLYPHS = 4096 +const INSTANCE_STRIDE = 4 * 4 + +export class TextRenderer { + private device!: GPUDevice + private pipeline!: GPURenderPipeline + private bindGroup!: GPUBindGroup + private uniformBuffer!: GPUBuffer + private uniformData = new Float32Array(TEXT_UNIFORM_FLOATS) + private quadBuffer!: GPUBuffer + + private scaleBiasBuffer!: GPUBuffer + private glyphBandScaleBuffer!: GPUBuffer + private bandMaxTexCoordsBuffer!: GPUBuffer + + private curvesTex!: GPUTexture + private bandsTex!: GPUTexture + private glyphCount = 0 + private initialized = false + + get ready(): boolean { + return this.initialized + } + + init(device: GPUDevice, format: GPUTextureFormat, slugData: SlugData): void { + this.device = device + + this.createTextures(slugData) + + const module = device.createShaderModule({ code: shaderSource }) + + const bindGroupLayout = device.createBindGroupLayout({ + entries: [ + { binding: 0, visibility: GPUShaderStage.VERTEX | GPUShaderStage.FRAGMENT, buffer: { type: 'uniform' } }, + { binding: 1, visibility: GPUShaderStage.FRAGMENT, texture: { sampleType: 'unfilterable-float' } }, + { binding: 2, visibility: GPUShaderStage.FRAGMENT, texture: { sampleType: 'uint' } } + ] + }) + + const pipelineLayout = device.createPipelineLayout({ bindGroupLayouts: [bindGroupLayout] }) + + this.pipeline = device.createRenderPipeline({ + layout: pipelineLayout, + vertex: { + module, + entryPoint: 'vs_main', + buffers: [ + { + arrayStride: 2 * 4, + attributes: [{ shaderLocation: 0, offset: 0, format: 'float32x2' }] + }, + { + arrayStride: INSTANCE_STRIDE, + stepMode: 'instance', + attributes: [{ shaderLocation: 1, offset: 0, format: 'float32x4' }] + }, + { + arrayStride: INSTANCE_STRIDE, + stepMode: 'instance', + attributes: [{ shaderLocation: 2, offset: 0, format: 'float32x4' }] + }, + { + arrayStride: INSTANCE_STRIDE, + stepMode: 'instance', + attributes: [{ shaderLocation: 3, offset: 0, format: 'uint32x4' }] + } + ] + }, + fragment: { + module, + entryPoint: 'fs_main', + targets: [{ + 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' } + }) + + this.uniformBuffer = device.createBuffer({ + size: TEXT_UNIFORM_BYTES, + usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST + }) + + this.quadBuffer = device.createBuffer({ + size: QUAD_VERTICES.byteLength, + usage: GPUBufferUsage.VERTEX | GPUBufferUsage.COPY_DST + }) + device.queue.writeBuffer(this.quadBuffer, 0, QUAD_VERTICES) + + this.scaleBiasBuffer = device.createBuffer({ + size: MAX_GLYPHS * INSTANCE_STRIDE, + usage: GPUBufferUsage.VERTEX | GPUBufferUsage.COPY_DST + }) + this.glyphBandScaleBuffer = device.createBuffer({ + size: MAX_GLYPHS * INSTANCE_STRIDE, + usage: GPUBufferUsage.VERTEX | GPUBufferUsage.COPY_DST + }) + this.bandMaxTexCoordsBuffer = device.createBuffer({ + size: MAX_GLYPHS * INSTANCE_STRIDE, + usage: GPUBufferUsage.VERTEX | GPUBufferUsage.COPY_DST + }) + + this.bindGroup = device.createBindGroup({ + layout: bindGroupLayout, + entries: [ + { binding: 0, resource: { buffer: this.uniformBuffer } }, + { binding: 1, resource: this.curvesTex.createView() }, + { binding: 2, resource: this.bandsTex.createView() } + ] + }) + + this.initialized = true + } + + private createTextures(slugData: SlugData): void { + this.curvesTex = this.device.createTexture({ + size: { width: slugData.curvesWidth, height: slugData.curvesHeight }, + format: 'rgba32float', + usage: GPUTextureUsage.TEXTURE_BINDING | GPUTextureUsage.COPY_DST + }) + this.device.queue.writeTexture( + { texture: this.curvesTex }, + slugData.curvesData.buffer as BufferSource, + { bytesPerRow: slugData.curvesWidth * 4 * 4 }, + { width: slugData.curvesWidth, height: slugData.curvesHeight } + ) + + this.bandsTex = this.device.createTexture({ + size: { width: slugData.bandsWidth, height: slugData.bandsHeight }, + format: 'rg32uint', + usage: GPUTextureUsage.TEXTURE_BINDING | GPUTextureUsage.COPY_DST + }) + this.device.queue.writeTexture( + { texture: this.bandsTex }, + slugData.bandsData.buffer as BufferSource, + { bytesPerRow: slugData.bandsWidth * 2 * 4 }, + { width: slugData.bandsWidth, height: slugData.bandsHeight } + ) + } + + updateText(layout: TextLayoutResult): void { + if (!this.initialized) return + const count = Math.min(layout.glyphCount, MAX_GLYPHS) + this.glyphCount = count + if (count === 0) return + + const sb = layout.scaleBias.subarray(0, count * 4) + this.device.queue.writeBuffer(this.scaleBiasBuffer, 0, sb.buffer as BufferSource, sb.byteOffset, sb.byteLength) + const gb = layout.glyphBandScale.subarray(0, count * 4) + this.device.queue.writeBuffer(this.glyphBandScaleBuffer, 0, gb.buffer as BufferSource, gb.byteOffset, gb.byteLength) + const bm = layout.bandMaxTexCoords.subarray(0, count * 4) + this.device.queue.writeBuffer(this.bandMaxTexCoordsBuffer, 0, bm.buffer as BufferSource, bm.byteOffset, bm.byteLength) + } + + renderPass( + encoder: GPUCommandEncoder, + canvasView: GPUTextureView, + resolution: Float32Array, + textColor: Float32Array + ): void { + if (!this.initialized || this.glyphCount === 0) return + + this.uniformData[0] = resolution[0] + this.uniformData[1] = resolution[1] + this.uniformData[4] = textColor[0] + this.uniformData[5] = textColor[1] + this.uniformData[6] = textColor[2] + this.uniformData[7] = textColor[3] + this.device.queue.writeBuffer(this.uniformBuffer, 0, this.uniformData) + + const pass = encoder.beginRenderPass({ + colorAttachments: [ + { view: canvasView, loadOp: 'load', storeOp: 'store' } + ] + }) + pass.setPipeline(this.pipeline) + pass.setBindGroup(0, this.bindGroup) + pass.setVertexBuffer(0, this.quadBuffer) + pass.setVertexBuffer(1, this.scaleBiasBuffer) + pass.setVertexBuffer(2, this.glyphBandScaleBuffer) + pass.setVertexBuffer(3, this.bandMaxTexCoordsBuffer) + pass.draw(6, this.glyphCount) + pass.end() + } +} \ No newline at end of file diff --git a/src/engine/text/text.wgsl b/src/engine/text/text.wgsl new file mode 100644 index 0000000..bd33229 --- /dev/null +++ b/src/engine/text/text.wgsl @@ -0,0 +1,159 @@ +// Text pass: Slug algorithm ported from JSlug (GLSL3 → WGSL). +// Evaluates TrueType quadratic Bézier curves directly in the fragment +// shader for resolution-independent, anti-aliased text rendering. +// No glyph atlas — curves are packed into a float texture, spatially +// banded into a uint texture, and ray-traced per fragment. + +const TEXTURE_WIDTH = 4096u; +const EPSILON = 0.0001; + +struct TextUniforms { + resolution: vec2f, + textColor: vec4f, +} + +@group(0) @binding(0) var u: TextUniforms; +@group(0) @binding(1) var curvesTex: texture_2d; +@group(0) @binding(2) var bandsTex: texture_2d; + +struct VsIn { + @location(0) position: vec2f, + @location(1) aScaleBias: vec4f, + @location(2) aGlyphBandScale: vec4f, + @location(3) aBandMaxTexCoords: vec4u, +} + +struct VsOut { + @builtin(position) pos: vec4f, + @location(0) vTexCoords: vec2f, + @interpolate(flat) @location(1) vGlyphBandScale: vec4f, + @interpolate(flat) @location(2) vBandMaxTexCoords: vec4u, + @interpolate(flat) @location(3) vPixelsPerEm: vec2f, +} + +@vertex +fn vs_main(in: VsIn) -> VsOut { + let px = in.position.x * in.aScaleBias.x + in.aScaleBias.z; + let py = in.position.y * in.aScaleBias.y + in.aScaleBias.w; + var out: VsOut; + out.pos = vec4f( + px / u.resolution.x * 2.0 - 1.0, + py / u.resolution.y * 2.0 - 1.0, + 0.0, 1.0 + ); + out.vTexCoords = in.position * 0.5 + vec2f(0.5); + out.vGlyphBandScale = in.aGlyphBandScale; + out.vBandMaxTexCoords = in.aBandMaxTexCoords; + let ppe = vec2f(2.0 * in.aScaleBias.x, 2.0 * in.aScaleBias.y); + out.vPixelsPerEm = clamp(ppe, vec2f(1.0), vec2f(200.0)); + return out; +} + +// ---------- Slug ray tracer ---------- + +fn trace_ray_curve_h(p1: vec2f, p2: vec2f, p3: vec2f, pixels_per_em: f32) -> f32 { + if (max(max(p1.x, p2.x), p3.x) * pixels_per_em < -0.5) { + return 0.0; + } + + let y_flags = select(0u, 2u, p1.y > 0.0) + + select(0u, 4u, p2.y > 0.0) + + select(0u, 8u, p3.y > 0.0); + let code = (0x2E74u >> y_flags) & 3u; + if (code == 0u) { + return 0.0; + } + + let a = p1 - p2 * 2.0 + p3; + let b = p1 - p2; + let c = p1.y; + let ayr = 1.0 / a.y; + let disc = max(b.y * b.y - a.y * c, 0.0); + let d = sqrt(disc); + var t1 = (b.y - d) * ayr; + var t2 = (b.y + d) * ayr; + + if (abs(a.y) < EPSILON) { + t1 = c / (2.0 * b.y); + t2 = t1; + } + + var coverage = 0.0; + + if ((code & 1u) != 0u) { + let x1 = (a.x * t1 - b.x * 2.0) * t1 + p1.x; + coverage += clamp(x1 * pixels_per_em + 0.5, 0.0, 1.0); + } + + if (code > 1u) { + let x2 = (a.x * t2 - b.x * 2.0) * t2 + p1.x; + coverage -= clamp(x2 * pixels_per_em + 0.5, 0.0, 1.0); + } + + return coverage; +} + +fn trace_ray_band_h( + band_data: vec2u, + pixels_per_em: f32, + v_tex: vec2f, + glyph_scale: vec2f +) -> f32 { + var coverage = 0.0; + for (var curve = 0u; curve < band_data.x; curve++) { + let curve_offset = band_data.y + curve; + let curve_loc = textureLoad(bandsTex, vec2u(curve_offset & 0xFFFu, curve_offset >> 12u), 0).xy; + let p12 = textureLoad(curvesTex, curve_loc, 0) / vec4f(glyph_scale, glyph_scale) - vec4f(v_tex, v_tex); + let p3 = textureLoad(curvesTex, vec2u(curve_loc.x + 1u, curve_loc.y), 0).xy / glyph_scale - v_tex; + coverage += trace_ray_curve_h(p12.xy, p12.zw, p3.xy, pixels_per_em); + } + return coverage; +} + +fn trace_ray_band_v( + band_data: vec2u, + pixels_per_em: f32, + v_tex: vec2f, + glyph_scale: vec2f +) -> f32 { + var coverage = 0.0; + for (var curve = 0u; curve < band_data.x; curve++) { + let curve_offset = band_data.y + curve; + let curve_loc = textureLoad(bandsTex, vec2u(curve_offset & 0xFFFu, curve_offset >> 12u), 0).xy; + let p12 = textureLoad(curvesTex, curve_loc, 0) / vec4f(glyph_scale, glyph_scale) - vec4f(v_tex, v_tex); + let p3 = textureLoad(curvesTex, vec2u(curve_loc.x + 1u, curve_loc.y), 0).xy / glyph_scale - v_tex; + coverage += trace_ray_curve_h(p12.yx, p12.wz, p3.yx, pixels_per_em); + } + return coverage; +} + +@fragment +fn fs_main(in: VsOut) -> @location(0) vec4f { + let glyph_scale = in.vGlyphBandScale.xy; + let band_scale = in.vGlyphBandScale.zw; + let band_max = in.vBandMaxTexCoords.xy; + let bands_tex_coords = in.vBandMaxTexCoords.zw; + let v_tex = in.vTexCoords; + let pixels_per_em = in.vPixelsPerEm; + + let band_index = clamp( + vec2u(v_tex * band_scale), + vec2u(0u, 0u), + band_max + ); + + let h_band_offset = bands_tex_coords.y * TEXTURE_WIDTH + bands_tex_coords.x + band_index.y; + let h_band_data = textureLoad(bandsTex, vec2u(h_band_offset & 0xFFFu, h_band_offset >> 12u), 0).xy; + + let v_band_offset = bands_tex_coords.y * TEXTURE_WIDTH + bands_tex_coords.x + band_max.y + 1u + band_index.x; + let v_band_data = textureLoad(bandsTex, vec2u(v_band_offset & 0xFFFu, v_band_offset >> 12u), 0).xy; + + var coverage_x = trace_ray_band_h(h_band_data, pixels_per_em.x, v_tex, glyph_scale); + var coverage_y = trace_ray_band_v(v_band_data, pixels_per_em.y, v_tex, glyph_scale); + + coverage_x = min(abs(coverage_x), 1.0); + coverage_y = min(abs(coverage_y), 1.0); + let slug_alpha = (coverage_x + coverage_y) * 0.5; + + return vec4f(u.textColor.rgb, u.textColor.a * slug_alpha); +} \ No newline at end of file diff --git a/src/main.ts b/src/main.ts new file mode 100644 index 0000000..79a31e3 --- /dev/null +++ b/src/main.ts @@ -0,0 +1,16 @@ +import { App } from './app' + +const canvas = document.getElementById('gpu') as HTMLCanvasElement +const fallback = document.getElementById('fallback') as HTMLElement + +async function boot(): Promise { + const app = new App(canvas) + const ok = await app.start() + if (!ok) { + fallback.style.display = 'flex' + canvas.style.display = 'none' + return + } +} + +void boot() \ No newline at end of file diff --git a/src/ui/input.ts b/src/ui/input.ts new file mode 100644 index 0000000..92179b3 --- /dev/null +++ b/src/ui/input.ts @@ -0,0 +1,14 @@ +// Window-level pointer position capture. Discrete clicks and hover are +// handled by the DOM overlay elements directly; this class only feeds the +// continuous pointer position that the background shader consumes. +export class Input { + x = 0 + y = 0 + + constructor() { + window.addEventListener('pointermove', (e) => { + this.x = e.clientX + this.y = e.clientY + }) + } +} \ No newline at end of file diff --git a/src/ui/overlay.ts b/src/ui/overlay.ts new file mode 100644 index 0000000..7a8caf2 --- /dev/null +++ b/src/ui/overlay.ts @@ -0,0 +1,254 @@ +import { RESUME, ResumeData, ResumeEntry, SkillGroup } from '../data/resume' +import { PROJECTS, CATEGORIES, ProjectPage, ProjectCategory } from '../data/projects/index' + +interface SceneContent { + title: string + subtitle: string + body: string +} + +export interface OverlayCallbacks { + onNavClick: (scene: number) => void + onButtonClick: () => void +} + +// ---------- HTML builders ---------- + +function esc(s: string): string { + return s.replace(/&/g, '&').replace(//g, '>') +} + +function buildContact(data: ResumeData): string { + return data.contact + .map((c, i) => { + const sep = i > 0 ? '·' : '' + if (c.href) return `${sep}${esc(c.label)}` + return `${sep}${esc(c.label)}` + }) + .join('\n') +} + +function buildEntryCard(e: ResumeEntry): string { + const bullets = e.bullets.length + ? `
    ${e.bullets.map((b) => `
  • ${b}
  • `).join('')}
` + : '' + const thesis = e.thesis ? `
${esc(e.thesis)}
` : '' + return `
+
${esc(e.date)}
+
${esc(e.role)}
+
${esc(e.company)} — ${esc(e.location)}
+ ${thesis}${bullets} +
` +} + +function buildSkillGroup(g: SkillGroup): string { + const pills = g.skills.map((s) => `${esc(s)}`).join('') + return `
+

${esc(g.label)}

+
${pills}
+
` +} + +function buildResumeHTML(data: ResumeData): string { + const workCards = data.work.map(buildEntryCard).join('') + const eduCards = data.education.map(buildEntryCard).join('') + const skillGroups = data.skills.map(buildSkillGroup).join('') + return `
+
+

${esc(data.name)}

+
${buildContact(data)}
+
${esc(data.location)}
+
+
+

Summary

+

${esc(data.summary)}

+
+
+

Work Experience

+ ${workCards} +
+
+

Education

+ ${eduCards} +
+
+

Skills

+ ${skillGroups} +
+
` +} + +// ---------- project HTML builders ---------- + +function buildCategoryGridHTML(categories: ProjectCategory[]): string { + const cards = categories + .map( + (c) => `
+

${esc(c.label)}

+

${esc(c.description)}

+ ${c.projects.length} project${c.projects.length === 1 ? '' : 's'} +
` + ) + .join('') + return `
${cards}
` +} + +function buildCategoryListHTML(cat: ProjectCategory): string { + if (cat.projects.length === 0) { + return `
+ +

Nothing here yet.

+
` + } + const cards = cat.projects + .map( + (p) => `
+
${esc(p.date)}
+

${esc(p.name)}

+
${esc(p.tagline)}
+
${p.tech.map((t) => `${esc(t)}`).join('')}
+
` + ) + .join('') + return `
+ + ${cards} +
` +} + +function buildProjectDetailHTML(p: ProjectPage, catId: string): string { + return `
+ +
${esc(p.date)}
+

${esc(p.name)}

+
${esc(p.tagline)}
+
${p.tech.map((t) => `${esc(t)}`).join('')}
+
${p.content}
+
` +} + +// ---------- overlay ---------- + +export class DomOverlay { + private readonly navItems: HTMLElement[] + private readonly titleEl: HTMLElement + private readonly subtitleEl: HTMLElement + private readonly bodyEl: HTMLElement + private readonly sceneContentEl: HTMLElement + private readonly projectsSectionEl: HTMLElement + private readonly resumeSectionEl: HTMLElement + private readonly cycleBtnEl: HTMLElement + private readonly scenes: SceneContent[] + private projectsBuilt = false + private resumeBuilt = false + + constructor(scenes: SceneContent[], callbacks: OverlayCallbacks) { + this.scenes = scenes + this.navItems = Array.from(document.querySelectorAll('[data-nav]')) + this.titleEl = document.getElementById('scene-title') as HTMLElement + this.subtitleEl = document.getElementById('scene-subtitle') as HTMLElement + this.bodyEl = document.getElementById('scene-body') as HTMLElement + this.sceneContentEl = document.getElementById('scene-content') as HTMLElement + this.projectsSectionEl = document.getElementById('projects-section') as HTMLElement + this.resumeSectionEl = document.getElementById('resume-section') as HTMLElement + this.cycleBtnEl = document.getElementById('cycle-button') as HTMLElement + + this.navItems.forEach((item, i) => { + item.addEventListener('click', () => callbacks.onNavClick(i)) + }) + + this.cycleBtnEl.addEventListener('click', () => callbacks.onButtonClick()) + + // delegate clicks inside projects section + this.projectsSectionEl.addEventListener('click', (e) => { + const target = e.target as HTMLElement + + // category card click → open category + const catCard = target.closest('.cat-card') + if (catCard && catCard.dataset.catId) { + this.showCategory(catCard.dataset.catId) + return + } + + // project card click → open detail + const projCard = target.closest('.project-card') + if (projCard && projCard.dataset.projectId) { + // find which category this project belongs to + const cat = CATEGORIES.find((c) => + c.projects.some((p) => p.id === projCard.dataset.projectId) + ) + if (cat) { + this.showProjectDetail(projCard.dataset.projectId, cat.id) + } + return + } + + // back button + const back = target.closest('.project-back') + if (back && back.dataset.back === 'grid') { + this.showCategoryGrid() + return + } + if (back && back.dataset.back === 'cat' && back.dataset.catId) { + this.showCategory(back.dataset.catId) + return + } + }) + } + + private showCategoryGrid(): void { + this.projectsSectionEl.innerHTML = buildCategoryGridHTML(CATEGORIES) + } + + private showCategory(id: string): void { + const cat = CATEGORIES.find((c) => c.id === id) + if (!cat) return + this.projectsSectionEl.innerHTML = buildCategoryListHTML(cat) + } + + private showProjectDetail(projectId: string, catId: string): void { + const p = PROJECTS.find((p) => p.id === projectId) + if (!p) return + this.projectsSectionEl.innerHTML = buildProjectDetailHTML(p, catId) + } + + setScene(scene: number): void { + const content = this.scenes[scene] + if (content) { + this.titleEl.textContent = content.title + this.subtitleEl.textContent = content.subtitle + this.bodyEl.textContent = content.body + } + + // default: show scene-content, hide special sections + this.sceneContentEl.classList.remove('hidden') + this.projectsSectionEl.classList.remove('visible') + this.resumeSectionEl.classList.remove('visible') + this.cycleBtnEl.style.display = '' + + if (scene === 2) { + this.sceneContentEl.classList.add('hidden') + this.projectsSectionEl.classList.add('visible') + this.cycleBtnEl.style.display = 'none' + if (!this.projectsBuilt) { + this.showCategoryGrid() + this.projectsBuilt = true + } else { + // always reset to category grid when landing on projects + this.showCategoryGrid() + } + } else if (scene === 4) { + this.sceneContentEl.classList.add('hidden') + this.resumeSectionEl.classList.add('visible') + this.cycleBtnEl.style.display = 'none' + if (!this.resumeBuilt) { + this.resumeSectionEl.innerHTML = buildResumeHTML(RESUME) + this.resumeBuilt = true + } + } + + this.navItems.forEach((item, i) => { + item.classList.toggle('active', i === scene) + }) + } +} diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..f077db6 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,18 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "bundler", + "strict": true, + "lib": ["ES2022", "DOM", "DOM.Iterable"], + "types": ["@webgpu/types", "vite/client"], + "skipLibCheck": true, + "noEmit": true, + "isolatedModules": true, + "resolveJsonModule": true, + "forceConsistentCasingInFileNames": true, + "noUnusedLocals": true, + "noUnusedParameters": true + }, + "include": ["src"] +} \ No newline at end of file diff --git a/vite.config.ts b/vite.config.ts new file mode 100644 index 0000000..6a72aaf --- /dev/null +++ b/vite.config.ts @@ -0,0 +1,6 @@ +import { defineConfig } from 'vite' + +export default defineConfig({ + server: { open: true }, + build: { target: 'esnext' } +}) \ No newline at end of file