Files
webgpu-portfolio/AGENTS.md
djoser 9c99027ea6
Some checks failed
Build & Deploy / build (push) Failing after 35s
Build & Deploy / deploy (push) Has been skipped
feat: SPA routing, projects/resume sections, deployment infra
- Add client-side router with History API pushState (router.ts, pages.ts)
- Extend overlay with projects grid/category/detail views and resume section
- Add Gitea CI/CD workflow and webhook deployment server
- Improve Docker Compose with nginx reverse proxy config
- Add deployment script and .env.example
2026-07-26 23:48:15 +00:00

406 lines
19 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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 `<canvas>` 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<uniform> 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 `<button>` labels (HERO / ABOUT / PROJECTS /
CONTACT / RESUME). Clicking a label switches to that scene. The active scene is
highlighted via the `.active` class (set by `overlay.setScene`); hover handled by CSS.
- **Scene content (DOM):** per-scene title, subtitle, and body text from the `SCENES`
array in `app.ts`. Used for Hero (0), About (1), and Contact (3). Hidden via
`opacity: 0` transition for Projects (2) and Resume (4).
- **Projects section (DOM):** `#projects-section` — shown for scene 2. Renders
project cards from `src/data/projects/`. Clicking a card opens a detail view
with a back button (event delegation in `overlay.ts`). Returns to list on every
nav entry. Hides the cycle button.
- **Resume section (DOM):** `#resume-section` — shown for scene 4. Rendered from
`RESUME` data in `src/data/resume.ts` via `buildResumeHTML()`. Hides the cycle button.
- **Cycle button (DOM):** centered `<#cycle-button>` cycles `(scene + 1) % 5`.
Hidden on Projects (2) and Resume (4) — set via `style.display = 'none'` in
`setScene()`.
### Scene → section mapping
| Scene | Nav label | Content source | Cycle btn |
|-------|-----------|---------------------------|-----------|
| 0 | HERO | `SCENES[0]`#scene-content | visible |
| 1 | ABOUT | `SCENES[1]`#scene-content | visible |
| 2 | PROJECTS | `PROJECTS[]`#projects-section | hidden |
| 3 | CONTACT | `SCENES[3]`#scene-content | visible |
| 4 | RESUME | `RESUME`#resume-section | hidden |
### Section transitions
Special sections (`#projects-section`, `#resume-section`) use **opacity crossfades**
instead of `display: none/block`:
- Default state: `opacity: 0; pointer-events: none; transition: opacity 0.35s`
- Visible state (`.visible` class): `opacity: 1; pointer-events: auto; animation: fadeIn 0.45s`
- The fade-in animation lives on the `.visible` class so it **re-triggers every visit**,
not just the first render.
- `#scene-content` uses the same pattern with a `.hidden` class (`opacity: 0`).
## Roadmap (in suggested order)
1. **~~SDF text~~** ✅ — Slug algorithm ported; renders crisp in-canvas text labels
(HERO / ABOUT / PROJECTS / CONTACT) that change on button click. **Now dormant**
in favor of the DOM overlay; kept for reference.
2. **~~Nav + multiple buttons~~** — extend `layout.ts` with a list of element bounds;
wire hover/click per element; animate section transitions in the shader.
✅ Nav bar with 4 clickable labels, active/hover underlines, per-scene text content.
**Now DOM-owned** (no `layout.ts`); underlines are CSS.
3. **Scroll input** — wheel/touch drag → uniform `scroll`; drive the single-scrolling
scene in the shader.
4. **Interactive 3D space** — a second pipeline (raymarched or rasterized 3D)
composited behind the UI; WASD/click navigation; project nodes placed in space.
5. **Content authoring** — projects/about/contact data keyed to sections.
## Do / don't
- **Do** run `npm run typecheck` after every change.
- **Do** add UI to the DOM overlay in `index.html` and wire it in `overlay.ts`.
- **Do** keep `y up / bottom-left` in mind when wiring input.
- **Do** prefix intentionally-unused params with `_` (strict unused checks are on).
- **Do** add an `sd*` helper in the shader rather than inlining math.
- **Don't** add UI shapes inside the fragment shader. Prefer a DOM element + CSS.
- **Don't** inline shader strings in `.ts`; use `*.wgsl` + `?raw`.
- **Don't** commit changes unless explicitly asked. (opencode default.)
- **Don't** add code comments unless requested.
## WebGPU gotchas
- Always configure the canvas context after device acquisition and on resize; the
texture size comes from `canvas.width/height` (device pixels), not CSS pixels.
- Cap DPR at 2 for perf on high-density displays (see `DPR_CAP` in `app.ts`).
- Guard `device.lost` and set `configured = false` so a re-init path is possible.
- Bind group layouts come from `'auto'` pipeline layout; when adding a second pass,
create explicit bind group layouts to share resources (atlas texture, sampler).
## Text rendering (Slug algorithm) — DORMANT
Text is rendered entirely inside WebGPU using a port of the **Slug** algorithm
(Eric Lengyel's GPU text method, originally C++/commercial). We ported it from
[manthrax/JSlug](https://github.com/manthrax/JSlug) (a Three.js/WebGL JS port) to
plain TS + WGSL. The key difference from atlas-based SDF text: **no glyph atlas**
each glyph's TrueType quadratic Bézier curves are ray-traced directly in the
fragment shader for resolution-independent, perfectly sharp text.
> **Status:** Dormant. The files under `src/engine/text/` and the `opentype.js`
> deps are retained so they still typecheck, but they are **not wired into the
> render loop**. All visible text is now handled by the DOM overlay. The notes
> below are kept for reference in case the Slug engine is reactivated later.
### Pipeline: two passes, one command encoder
1. **Pass 1 — procedural** (`fullscreen.wgsl`): the fullscreen-triangle fragment
shader draws the background + SDF UI into the canvas texture (`storeOp: 'store'`).
2. **Pass 2 — text** (`text.wgsl`): loads the canvas texture (`loadOp: 'load'`),
draws instanced glyph quads with alpha-blended Slug coverage over the
procedural background. Skipped entirely if no text or glyph count is 0.
The text pass uses an explicit bind group layout (not `'auto'`) because it needs
two textures (curvesTexture, bandsTexture) in addition to its uniform buffer.
### CPU side: three files, three responsibilities
```
slug-generator.ts opentype.js Font → SlugData (curve/band arrays + metrics)
text-layout.ts text string + SlugData → TextLayoutResult (instance buffer data)
text-renderer.ts creates GPU textures + pipeline + buffers, renders the text pass
```
1. **`SlugGenerator.generate(font)`** — iterates every glyph, extracts quadratic
Bézier curves from the path (converting `L`/`Z` commands to degenerate
quadratics with midpoint control points), packs curves into a `Float32Array`
(RGBA32F texture data), spatially bins curves into horizontal and vertical
bands stored in a `Uint32Array` (RG32UI texture data), and collects per-codepoint
metadata (width, height, advanceWidth, bearings, band dimensions, texture coords).
Default: ASCII 32126 only; pass `fullRange: true` or `whitelist` for more.
2. **`layoutText(text, slugData, options)`** — iterates codepoints, measures line
width, applies justification, and emits per-glyph instance buffer data:
- `scaleBias` (vec4f): half-extent + center of the glyph quad in pixel space
- `glyphBandScale` (vec4f): glyph width/height in font units + band scale factors
- `bandMaxTexCoords` (vec4u): band count - 1 + band texture base coordinates
3. **`TextRenderer`** — on `init(device, format, slugData)`: creates the curves
texture (`rgba32float`), bands texture (`rg32uint`), the render pipeline (6-vert
quad, instanced, alpha-blended), and 4 vertex buffers (quad + 3 instance).
`updateText(layout)` uploads instance data; `renderPass(...)` adds the text
pass to an existing command encoder.
### WGSL shader notes (`text.wgsl`)
- Uses `textureLoad` (not `texelFetch`), `@interpolate(flat)` for per-instance data.
- WGSL has no fragment derivatives (`dFdx`/`dFdy`), so `pixelsPerEm` is passed as a
flat-interpolated `vec2f` computed in the vertex shader from the quad's pixel
half-extent (`2.0 * aScaleBias.xy`).
- Text coordinates are in **pixel space** (origin bottom-left, y up), same as the
procedural pass. The vertex shader maps pixel coords → clip coords via
`px / resolution * 2.0 - 1.0`.
- The `trace_ray_curve_h` function is the heart of Slug: solves the quadratic for
the ray-curve intersection, then computes coverage from the intersection's x
coordinate scaled by `pixelsPerEm` for anti-aliasing.
### Adding text labels (Slug — dormant)
The Slug engine is currently **not wired in**. To reactivate GPU text, you would
also need to restore the text pass in `renderer.ts` (the `initText` / `updateText`
methods and the second render pass were removed when text moved to the DOM overlay).
```ts
// 1. Generate slug data (once, on font load)
const gen = new SlugGenerator()
const slugData = await gen.generateFromUrl('/SomeFont.ttf')
// 2. Init the text renderer (once, after device init)
renderer.initText(slugData)
// 3. Layout text (re-call when text or position changes)
const layout = layoutText('Hello', slugData, {
fontScale: 0.3 * dpr, // ~0.3 of font units per pixel
startX: resX * 0.5, // pixel-space, origin bottom-left
startY: resY * 0.5,
justify: 'center'
})
renderer.updateText(layout)
// 4. The render loop picks it up automatically each frame.
```
Font files go in `public/` and are fetched at runtime. Uses `opentype.js` (runtime
dep) + `@types/opentype.js` (dev dep).
## Deployment (Docker + nginx + webhook CI/CD)
The stack runs in Docker Compose with two containers on a shared internal network:
| Container | Role | Port exposed |
|---------------------|-----------------------------------------|--------------|
| `webgpu-portfolio-web` | App: nginx serving the built dist/ | internal:80 |
| `webgpu-portfolio-nginx` | Reverse proxy in front of web | host:80 |
- `nginx/reverse.conf` — reverse proxy config, routes `djosen.one``web:80`
- `nginx.default.conf` — internal nginx config in the app container (SPA, gzip, caching)
- `docker-compose.yml` — full stack, health checks, auto-restart
- `.dockerignore` — excludes node_modules, dist, git, and CI files from the build context
### CI/CD webhook
A Python stdlib webhook server (`webhook/server.py`) listens on port 9000, verifies
Gitea's `X-Gitea-Signature` HMAC-SHA256, and on `push` events runs:
```
git pull origin master
docker compose up -d --build --remove-orphans
```
Minimal downtime (~12 s) because only the `web` container is rebuilt; nginx-proxy
stays running and buffers connections during the restart.
### Setup on the server (first time)
```bash
# 1. Clone the repo
git clone https://git.djosen.one/djoser/webgpu-portfolio /home/djoser/webgpu-portfolio
cd /home/djoser/webgpu-portfolio
# 2. Create .env with a webhook secret
openssl rand -hex 32 > /tmp/secret
echo "WEBHOOK_SECRET=$(cat /tmp/secret)" > .env
echo "REPO_DIR=/home/djoser/webgpu-portfolio" >> .env
# 3. Start the stack
docker compose up -d
# 4. Install & start the webhook service
sudo cp webhook/webhook.service /etc/systemd/system/
sudo systemctl daemon-reload
sudo systemctl enable --now webhook-webhook.service
# 5. Configure Gitea webhook
# - Go to repo Settings → Webhooks → Add Webhook (Gitea)
# - Target URL: http://<server-ip>:9000
# - Secret: <content of /tmp/secret>
# - Events: Push events
# - Active: ✓
```
### Manual deploy (no webhook)
```bash
bash scripts/deploy.sh
```
### Verify everything is working
```bash
# Stack health
docker compose ps
curl -s http://localhost:80/health
# Webhook health
curl -s http://localhost:9000/health
# Test webhook (simulate a push)
curl -X POST http://localhost:9000/ \
-H "X-Gitea-Event: push" \
-H "X-Gitea-Signature: ..." \
-d '{"ref":"refs/heads/master"}'
```