Initial commit: WebGPU portfolio — generative-art site with DOM overlay

This commit is contained in:
2026-07-26 19:06:17 +02:00
commit 7172894ebb
30 changed files with 3916 additions and 0 deletions

9
.dockerignore Normal file
View File

@@ -0,0 +1,9 @@
node_modules
dist
.git
.gitignore
*.md
!README.md
.DS_Store
.vscode
*.log

19
.gitignore vendored Normal file
View File

@@ -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

328
AGENTS.md Normal file
View File

@@ -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 `<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).

22
Dockerfile Normal file
View File

@@ -0,0 +1,22 @@
# ---- stage 1: build ----
FROM node:22-alpine AS build
WORKDIR /app
# Install deps (cache layer)
COPY package.json package-lock.json* ./
RUN npm ci
# Copy source and build
COPY tsconfig.json vite.config.ts index.html ./
COPY src/ src/
RUN npm run build
# ---- stage 2: serve ----
FROM nginx:alpine-slim AS serve
COPY --from=build /app/dist /usr/share/nginx/html
# Optional: custom nginx config for caching / security
COPY nginx.default.conf /etc/nginx/conf.d/default.conf
EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]

44
README.md Normal file
View File

@@ -0,0 +1,44 @@
# WebGPU Portfolio
Generative-art portfolio site. WebGPU canvas background, DOM overlay UI.
## Dev
```bash
npm install
npm run dev # vite dev server (opens browser)
npm run typecheck # tsc --noEmit
npm run build # typecheck + vite build → dist/
npm run preview # serve built dist/
```
## Docker
```bash
# Build
docker build -t webgpu-portfolio .
# Run (foreground)
docker run --rm -p 8080:80 webgpu-portfolio
# Run (detached)
docker run -d -p 8080:80 --name portfolio webgpu-portfolio
# Compose
docker compose up -d
```
Site at `http://localhost:8080`.
### Smoke test
```bash
docker run -d -p 8080:80 --name portfolio-test webgpu-portfolio
curl -sSf http://localhost:8080/ > /dev/null && echo "OK"
curl -sSf http://localhost:8080/assets/ | head -1 # should 200 (hashed JS)
docker stop portfolio-test
```
### Image size
~6 MB (nginx:alpine-slim + static dist). Multi-stage build — Node build layer is discarded.

6
docker-compose.yml Normal file
View File

@@ -0,0 +1,6 @@
services:
web:
build: .
ports:
- "8080:80"
restart: unless-stopped

493
index.html Normal file
View File

@@ -0,0 +1,493 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Djosen</title>
<style>
html, body {
margin: 0;
height: 100%;
background: #000;
overflow: hidden;
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
}
canvas {
display: block;
position: fixed;
inset: 0;
width: 100vw;
height: 100vh;
touch-action: none;
z-index: 0;
}
#fallback {
position: fixed;
inset: 0;
display: none;
align-items: center;
justify-content: center;
color: #888;
text-align: center;
padding: 2rem;
z-index: 2;
}
/* ---------- DOM overlay ---------- */
#overlay {
position: fixed;
inset: 0;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 1.5rem;
color: #fff;
pointer-events: none;
z-index: 1;
}
#overlay > * {
pointer-events: auto;
}
/* nav bar */
nav {
position: absolute;
top: 6vh;
display: flex;
gap: clamp(1.5rem, 8vw, 5rem);
}
nav button {
font: inherit;
font-size: clamp(0.7rem, 1.6vw, 1rem);
letter-spacing: 0.15em;
background: transparent;
border: none;
color: rgba(255, 255, 255, 0.55);
cursor: pointer;
padding: 0.25rem 0.5rem;
transition: color 0.2s ease;
}
nav button:hover { color: rgba(255, 255, 255, 0.9); }
nav button.active {
color: #fff;
text-shadow: 0 0 12px rgba(120, 200, 255, 0.8);
}
/* scene content */
#scene-content {
text-align: center;
display: flex;
flex-direction: column;
align-items: center;
gap: 0.75rem;
max-width: min(90vw, 720px);
}
#scene-title {
font-size: clamp(1.6rem, 5vw, 3rem);
margin: 0;
letter-spacing: 0.05em;
}
#scene-subtitle {
font-size: clamp(0.9rem, 2vw, 1.25rem);
margin: 0;
opacity: 0.85;
}
#scene-body {
font-size: clamp(0.8rem, 1.6vw, 1rem);
margin: 0;
opacity: 0.7;
white-space: pre-line;
}
/* cycle button */
#cycle-button {
font: inherit;
font-size: clamp(0.8rem, 1.4vw, 1rem);
letter-spacing: 0.1em;
padding: 0.7rem 2.4rem;
background: rgba(22, 48, 78, 0.35);
border: 1.5px solid rgba(144, 184, 220, 0.6);
color: #fff;
border-radius: 999px;
cursor: pointer;
backdrop-filter: blur(2px);
transition: background 0.2s ease, border-color 0.2s ease, box-shadow 0.2s ease;
}
#cycle-button:hover {
background: rgba(40, 90, 140, 0.5);
border-color: rgba(180, 210, 240, 0.9);
box-shadow: 0 0 18px rgba(90, 170, 255, 0.4);
}
#cycle-button:active {
transform: scale(0.97);
}
/* ---------- resume section ---------- */
@keyframes resumeFadeIn {
from { opacity: 0; transform: translateX(-50%) translateY(18px); }
to { opacity: 1; transform: translateX(-50%) translateY(0); }
}
#resume-section {
position: absolute;
left: 50%;
top: 14vh;
transform: translateX(-50%);
width: min(90vw, 780px);
max-height: 76vh;
overflow-y: auto;
pointer-events: none;
opacity: 0;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif;
color: rgba(255, 255, 255, 0.9);
scrollbar-width: thin;
scrollbar-color: rgba(255,255,255,0.15) transparent;
transition: opacity 0.35s ease-out;
}
#resume-section.visible {
opacity: 1;
pointer-events: auto;
animation: resumeFadeIn 0.45s ease-out;
}
#resume-section::-webkit-scrollbar { width: 5px; }
#resume-section::-webkit-scrollbar-thumb { background: rgba(255,255,255,0.15); border-radius: 3px; }
.resume-container { padding-bottom: 2rem; }
/* --- header --- */
.resume-header {
text-align: center;
margin-bottom: 1.6rem;
}
.resume-header h2 {
font-size: clamp(1.4rem, 3.5vw, 2rem);
font-weight: 600;
margin: 0 0 0.3rem;
letter-spacing: 0.02em;
}
.resume-contact {
display: flex;
flex-wrap: wrap;
justify-content: center;
gap: 0.4rem 1rem;
font-size: clamp(0.72rem, 1.4vw, 0.85rem);
color: rgba(255,255,255,0.65);
margin-bottom: 0.2rem;
}
.resume-contact a {
color: #4a9eff;
text-decoration: none;
transition: color 0.2s;
}
.resume-contact a:hover { color: #7fbaff; }
.resume-contact .sep { color: rgba(255,255,255,0.25); }
.resume-location {
font-size: clamp(0.68rem, 1.2vw, 0.8rem);
color: rgba(255,255,255,0.45);
}
/* --- sections --- */
.resume-section { margin-bottom: 1.6rem; }
.section-title {
font-size: clamp(0.85rem, 1.6vw, 1rem);
font-weight: 600;
letter-spacing: 0.08em;
text-transform: uppercase;
color: rgba(255,255,255,0.5);
margin: 0 0 0.75rem;
padding-bottom: 0.4rem;
border-bottom: 1px solid rgba(255,255,255,0.08);
}
.summary-text {
font-size: clamp(0.78rem, 1.3vw, 0.9rem);
line-height: 1.55;
color: rgba(255,255,255,0.75);
margin: 0;
}
/* --- cards (work / education) --- */
.resume-card {
background: rgba(255,255,255,0.035);
border: 1px solid rgba(255,255,255,0.06);
border-radius: 10px;
padding: 1rem 1.2rem;
margin-bottom: 0.75rem;
transition: background 0.25s ease, border-color 0.25s ease;
}
.resume-card:hover {
background: rgba(255,255,255,0.06);
border-color: rgba(74,158,255,0.2);
}
.card-date {
font-size: clamp(0.7rem, 1.1vw, 0.78rem);
color: #4a9eff;
font-weight: 500;
margin-bottom: 0.2rem;
letter-spacing: 0.02em;
}
.card-role {
font-size: clamp(0.82rem, 1.4vw, 0.95rem);
font-weight: 600;
color: #fff;
margin-bottom: 0.1rem;
}
.card-company {
font-size: clamp(0.75rem, 1.2vw, 0.85rem);
color: rgba(255,255,255,0.55);
margin-bottom: 0.4rem;
}
.card-thesis {
font-size: clamp(0.72rem, 1.2vw, 0.82rem);
color: rgba(255,255,255,0.6);
font-style: italic;
}
.resume-card ul {
margin: 0.3rem 0 0;
padding-left: 1.2rem;
list-style: none;
}
.resume-card ul li {
font-size: clamp(0.72rem, 1.2vw, 0.82rem);
line-height: 1.5;
color: rgba(255,255,255,0.72);
margin-bottom: 0.2rem;
position: relative;
}
.resume-card ul li::before {
content: '—';
color: #4a9eff;
position: absolute;
left: -1.1rem;
}
/* --- skills --- */
.skills-group {
margin-bottom: 0.6rem;
}
.skills-group h4 {
font-size: clamp(0.75rem, 1.2vw, 0.85rem);
font-weight: 600;
color: rgba(255,255,255,0.6);
margin: 0 0 0.35rem;
letter-spacing: 0.03em;
}
.skill-pills {
display: flex;
flex-wrap: wrap;
gap: 0.4rem;
}
.pill {
display: inline-block;
font-size: clamp(0.68rem, 1.1vw, 0.78rem);
padding: 0.2rem 0.65rem;
border-radius: 999px;
background: rgba(74,158,255,0.1);
border: 1px solid rgba(74,158,255,0.18);
color: rgba(255,255,255,0.85);
transition: background 0.2s, border-color 0.2s;
}
.pill:hover {
background: rgba(74,158,255,0.18);
border-color: rgba(74,158,255,0.35);
}
/* --- hide/show --- */
#scene-content {
transition: opacity 0.3s ease-out;
}
#scene-content.hidden {
opacity: 0;
pointer-events: none;
}
/* ---------- projects section ---------- */
@keyframes projectsFadeIn {
from { opacity: 0; transform: translateX(-50%) translateY(18px); }
to { opacity: 1; transform: translateX(-50%) translateY(0); }
}
#projects-section {
position: absolute;
left: 50%;
top: 14vh;
transform: translateX(-50%);
width: min(90vw, 780px);
max-height: 76vh;
overflow-y: auto;
pointer-events: none;
opacity: 0;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif;
color: rgba(255, 255, 255, 0.9);
scrollbar-width: thin;
scrollbar-color: rgba(255,255,255,0.15) transparent;
transition: opacity 0.35s ease-out;
}
#projects-section.visible {
opacity: 1;
pointer-events: auto;
animation: projectsFadeIn 0.45s ease-out;
}
#projects-section::-webkit-scrollbar { width: 5px; }
#projects-section::-webkit-scrollbar-thumb { background: rgba(255,255,255,0.15); border-radius: 3px; }
.projects-container { padding-bottom: 2rem; }
.project-card {
background: rgba(255,255,255,0.035);
border: 1px solid rgba(255,255,255,0.06);
border-radius: 10px;
padding: 1.2rem;
margin-bottom: 0.75rem;
cursor: pointer;
transition: background 0.25s ease, border-color 0.25s ease;
}
.project-card:hover {
background: rgba(255,255,255,0.06);
border-color: rgba(74,158,255,0.2);
}
.project-date {
font-size: clamp(0.68rem, 1.1vw, 0.76rem);
color: #4a9eff;
font-weight: 500;
margin-bottom: 0.2rem;
letter-spacing: 0.02em;
}
.project-name {
font-size: clamp(0.9rem, 1.6vw, 1.1rem);
font-weight: 600;
color: #fff;
margin: 0 0 0.15rem;
}
.project-tagline {
font-size: clamp(0.75rem, 1.2vw, 0.85rem);
color: rgba(255,255,255,0.5);
margin-bottom: 0.5rem;
}
.project-desc {
font-size: clamp(0.72rem, 1.2vw, 0.82rem);
line-height: 1.5;
color: rgba(255,255,255,0.72);
margin: 0 0 0.6rem;
}
.project-tech {
display: flex;
flex-wrap: wrap;
gap: 0.35rem;
}
/* --- category grid --- */
.cat-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 0.75rem;
padding-bottom: 2rem;
}
@media (max-width: 500px) {
.cat-grid { grid-template-columns: 1fr; }
}
.cat-card {
background: rgba(255,255,255,0.035);
border: 1px solid rgba(255,255,255,0.06);
border-radius: 10px;
padding: 1.4rem;
cursor: pointer;
transition: background 0.25s ease, border-color 0.25s ease;
}
.cat-card:hover {
background: rgba(255,255,255,0.06);
border-color: rgba(74,158,255,0.2);
}
.cat-label {
font-size: clamp(0.95rem, 1.8vw, 1.15rem);
font-weight: 600;
color: #fff;
margin: 0 0 0.3rem;
}
.cat-desc {
font-size: clamp(0.72rem, 1.2vw, 0.82rem);
line-height: 1.45;
color: rgba(255,255,255,0.55);
margin: 0 0 0.5rem;
}
.cat-count {
font-size: clamp(0.65rem, 1vw, 0.72rem);
color: rgba(74,158,255,0.7);
font-weight: 500;
}
.cat-empty {
font-size: clamp(0.8rem, 1.3vw, 0.9rem);
color: rgba(255,255,255,0.35);
margin: 0;
}
.cat-list { padding-bottom: 2rem; }
.cat-list .project-back { margin-bottom: 1rem; }
/* --- project detail --- */
.project-detail { padding-bottom: 2rem; }
.project-back {
font: inherit;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif;
font-size: clamp(0.75rem, 1.2vw, 0.85rem);
background: transparent;
border: none;
color: #4a9eff;
cursor: pointer;
padding: 0;
margin-bottom: 1.2rem;
transition: color 0.2s;
}
.project-back:hover { color: #7fbaff; }
.project-detail .project-name {
font-size: clamp(1.1rem, 2.5vw, 1.5rem);
margin: 0.15rem 0;
}
.project-detail .project-date { margin-bottom: 0.1rem; }
.project-detail .project-tagline { margin-bottom: 0.75rem; }
.project-detail .project-tech { margin-bottom: 1rem; }
.project-body {
font-size: clamp(0.78rem, 1.3vw, 0.9rem);
line-height: 1.65;
color: rgba(255,255,255,0.78);
}
.project-body p {
margin: 0 0 0.75rem;
}
.project-body p:last-child { margin-bottom: 0; }
</style>
</head>
<body>
<canvas id="gpu"></canvas>
<div id="overlay">
<nav>
<button data-nav="0">Home</button>
<button data-nav="1">About</button>
<button data-nav="2">Projects</button>
<button data-nav="3">Contact</button>
<button data-nav="4">Resume</button>
</nav>
<div id="scene-content">
<h1 id="scene-title"></h1>
<h2 id="scene-subtitle"></h2>
<p id="scene-body"></p>
</div>
<button id="cycle-button">NEXT</button>
<div id="resume-section"></div>
<div id="projects-section"></div>
</div>
<div id="fallback">
<div>
<h2>WebGPU not available</h2>
<p>Open this page in a recent Chrome/Edge (or any WebGPU-enabled browser).</p>
</div>
</div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>

30
nginx.default.conf Normal file
View File

@@ -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";
}
}

1090
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

22
package.json Normal file
View File

@@ -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"
}
}

Binary file not shown.

147
src/app.ts Normal file
View File

@@ -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<boolean> {
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)
}
}

View File

@@ -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: `
<p>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.</p>
<p>Designed the simulator, developed the Gazebo plugins and the C++ ROS nodes.</p>
`,
}
export default page

View File

@@ -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: `
<p>Developed a high-performance, Vulkan-powered game engine with real-time physics
simulation, applicable to vehicle simulation and digital twin technology.</p>
<p>Built a modular scene editor for real-time rendering and physics-based simulation.</p>
<p>Implemented the Vulkan renderer for the engine core and an OpenGL PBR renderer
for the editor.</p>
`,
}
export default page

View File

@@ -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: `
<p>A renderer/rasterizer built from scratch to render .obj models and .tga textures,
with OpenCL providing GPU acceleration.</p>
<p>Built a clean API for constructing scenes and shaders.</p>
`,
}
export default page

View File

@@ -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: [],
},
]

View File

@@ -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: `
<p>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).</p>
<p>Created a software rasterization pipeline using atomic uint64 depth-color packing
and compute shaders, enabling fine-grained depth testing without a hardware depth
buffer.</p>
<p>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.</p>
`,
}
export default page

View File

@@ -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: `
<p>Researched and implemented a real-time radiance cache for volumetric path tracing
using 3D Gaussian Splatting and a fused MLP as the cache representation.</p>
<p>Developed an online differentiable optimization pipeline in CUDA that adapts the
Gaussian cache to dynamic lighting and transfer function changes during rendering.</p>
<p>Built a spatial-hash-based fused MLP for radiance caching on volumetric
heterogeneous media. Supervised by Prof. Westermann.</p>
`,
}
export default page

131
src/data/resume.ts Normal file
View File

@@ -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 <em>Forza Motorsport 8</em> 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'],
},
],
}

104
src/engine/renderer.ts Normal file
View File

@@ -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<boolean> {
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()])
}
}

View File

@@ -0,0 +1,104 @@
struct Uniforms {
resolution: vec2<f32>,
time: f32,
scene: f32,
mouse: vec2<f32>,
date: vec4<f32>,
sampleRate: vec4<f32>,
stemMultipliers: array<vec4<f32>, 8>,
};
@group(0) @binding(0) var<uniform> u: Uniforms;
struct VsOut {
@builtin(position) pos: vec4<f32>,
@location(0) uv: vec2<f32>,
};
@vertex
fn vs(@builtin(vertex_index) i: u32) -> VsOut {
let p = array<vec2<f32>, 3>(
vec2<f32>(-1.0, -3.0),
vec2<f32>(-1.0, 1.0),
vec2<f32>(3.0, 1.0),
);
let clip = p[i];
var out: VsOut;
out.pos = vec4<f32>(clip, 0.0, 1.0);
out.uv = clip;
return out;
}
@fragment
fn fs(in: VsOut) -> @location(0) vec4<f32> {
let res = u.resolution;
let fragCoord = (in.uv * vec2<f32>(0.5) + vec2<f32>(0.5)) * res;
var uv = (fragCoord - 0.5 * res) / res.y;
let slowTime = u.time * 0.2;
let bgGrey = vec3<f32>(0.11, 0.11, 0.12);
let cardGrey = vec3<f32>(0.30, 0.32, 0.36);
let coreWhite = vec3<f32>(0.90, 0.95, 0.90);
// ---------- palette keyed by scene ----------
let scene0 = vec3<f32>(0.20, 0.75, 0.20); // HERO emerald
let scene1 = vec3<f32>(0.85, 0.55, 0.20); // ABOUT amber
let scene2 = vec3<f32>(0.25, 0.60, 0.95); // PROJECTS cobalt
let scene3 = vec3<f32>(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<f32>(rChannel, gChannel, bChannel);
let coreNeon = vec3<f32>(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<f32>(finalColor * vignette, 1.0);
}

View File

@@ -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<number, CodePointData>
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<SlugData> {
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<SlugData> {
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<number, CodePointData>()
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
}
}
}

View File

@@ -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++
}

View File

@@ -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()
}
}

159
src/engine/text/text.wgsl Normal file
View File

@@ -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<uniform> u: TextUniforms;
@group(0) @binding(1) var curvesTex: texture_2d<f32>;
@group(0) @binding(2) var bandsTex: texture_2d<u32>;
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);
}

16
src/main.ts Normal file
View File

@@ -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<void> {
const app = new App(canvas)
const ok = await app.start()
if (!ok) {
fallback.style.display = 'flex'
canvas.style.display = 'none'
return
}
}
void boot()

14
src/ui/input.ts Normal file
View File

@@ -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
})
}
}

254
src/ui/overlay.ts Normal file
View File

@@ -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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
}
function buildContact(data: ResumeData): string {
return data.contact
.map((c, i) => {
const sep = i > 0 ? '<span class="sep">·</span>' : ''
if (c.href) return `${sep}<a href="${esc(c.href)}">${esc(c.label)}</a>`
return `${sep}<span>${esc(c.label)}</span>`
})
.join('\n')
}
function buildEntryCard(e: ResumeEntry): string {
const bullets = e.bullets.length
? `<ul>${e.bullets.map((b) => `<li>${b}</li>`).join('')}</ul>`
: ''
const thesis = e.thesis ? `<div class="card-thesis">${esc(e.thesis)}</div>` : ''
return `<div class="resume-card">
<div class="card-date">${esc(e.date)}</div>
<div class="card-role">${esc(e.role)}</div>
<div class="card-company">${esc(e.company)}${esc(e.location)}</div>
${thesis}${bullets}
</div>`
}
function buildSkillGroup(g: SkillGroup): string {
const pills = g.skills.map((s) => `<span class="pill">${esc(s)}</span>`).join('')
return `<div class="skills-group">
<h4>${esc(g.label)}</h4>
<div class="skill-pills">${pills}</div>
</div>`
}
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 `<div class="resume-container">
<header class="resume-header">
<h2>${esc(data.name)}</h2>
<div class="resume-contact">${buildContact(data)}</div>
<div class="resume-location">${esc(data.location)}</div>
</header>
<section class="resume-section">
<h3 class="section-title">Summary</h3>
<p class="summary-text">${esc(data.summary)}</p>
</section>
<section class="resume-section">
<h3 class="section-title">Work Experience</h3>
${workCards}
</section>
<section class="resume-section">
<h3 class="section-title">Education</h3>
${eduCards}
</section>
<section class="resume-section">
<h3 class="section-title">Skills</h3>
${skillGroups}
</section>
</div>`
}
// ---------- project HTML builders ----------
function buildCategoryGridHTML(categories: ProjectCategory[]): string {
const cards = categories
.map(
(c) => `<div class="cat-card" data-cat-id="${esc(c.id)}">
<h3 class="cat-label">${esc(c.label)}</h3>
<p class="cat-desc">${esc(c.description)}</p>
<span class="cat-count">${c.projects.length} project${c.projects.length === 1 ? '' : 's'}</span>
</div>`
)
.join('')
return `<div class="cat-grid">${cards}</div>`
}
function buildCategoryListHTML(cat: ProjectCategory): string {
if (cat.projects.length === 0) {
return `<div class="cat-list">
<button class="project-back" data-back="grid">← Categories</button>
<p class="cat-empty">Nothing here yet.</p>
</div>`
}
const cards = cat.projects
.map(
(p) => `<div class="project-card" data-project-id="${esc(p.id)}">
<div class="project-date">${esc(p.date)}</div>
<h3 class="project-name">${esc(p.name)}</h3>
<div class="project-tagline">${esc(p.tagline)}</div>
<div class="project-tech">${p.tech.map((t) => `<span class="pill">${esc(t)}</span>`).join('')}</div>
</div>`
)
.join('')
return `<div class="cat-list">
<button class="project-back" data-back="grid">← Categories</button>
${cards}
</div>`
}
function buildProjectDetailHTML(p: ProjectPage, catId: string): string {
return `<div class="project-detail">
<button class="project-back" data-back="cat" data-cat-id="${esc(catId)}">← Back</button>
<div class="project-date">${esc(p.date)}</div>
<h2 class="project-name">${esc(p.name)}</h2>
<div class="project-tagline">${esc(p.tagline)}</div>
<div class="project-tech">${p.tech.map((t) => `<span class="pill">${esc(t)}</span>`).join('')}</div>
<div class="project-body">${p.content}</div>
</div>`
}
// ---------- 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<HTMLElement>('[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<HTMLElement>('.cat-card')
if (catCard && catCard.dataset.catId) {
this.showCategory(catCard.dataset.catId)
return
}
// project card click → open detail
const projCard = target.closest<HTMLElement>('.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<HTMLElement>('.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)
})
}
}

18
tsconfig.json Normal file
View File

@@ -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"]
}

6
vite.config.ts Normal file
View File

@@ -0,0 +1,6 @@
import { defineConfig } from 'vite'
export default defineConfig({
server: { open: true },
build: { target: 'esnext' }
})