Initial commit: WebGPU portfolio — generative-art site with DOM overlay
This commit is contained in:
9
.dockerignore
Normal file
9
.dockerignore
Normal file
@@ -0,0 +1,9 @@
|
||||
node_modules
|
||||
dist
|
||||
.git
|
||||
.gitignore
|
||||
*.md
|
||||
!README.md
|
||||
.DS_Store
|
||||
.vscode
|
||||
*.log
|
||||
19
.gitignore
vendored
Normal file
19
.gitignore
vendored
Normal 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
328
AGENTS.md
Normal 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 32–126 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
22
Dockerfile
Normal 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
44
README.md
Normal 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
6
docker-compose.yml
Normal file
@@ -0,0 +1,6 @@
|
||||
services:
|
||||
web:
|
||||
build: .
|
||||
ports:
|
||||
- "8080:80"
|
||||
restart: unless-stopped
|
||||
493
index.html
Normal file
493
index.html
Normal 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
30
nginx.default.conf
Normal 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
1090
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
22
package.json
Normal file
22
package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
BIN
public/SpaceMono-Regular.ttf
Normal file
BIN
public/SpaceMono-Regular.ttf
Normal file
Binary file not shown.
147
src/app.ts
Normal file
147
src/app.ts
Normal 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)
|
||||
}
|
||||
}
|
||||
18
src/data/projects/auv-simulation.ts
Normal file
18
src/data/projects/auv-simulation.ts
Normal 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
|
||||
20
src/data/projects/evol-engine.ts
Normal file
20
src/data/projects/evol-engine.ts
Normal 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
|
||||
17
src/data/projects/improv-gfx.ts
Normal file
17
src/data/projects/improv-gfx.ts
Normal 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
|
||||
66
src/data/projects/index.ts
Normal file
66
src/data/projects/index.ts
Normal 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: [],
|
||||
},
|
||||
]
|
||||
24
src/data/projects/point-cloud.ts
Normal file
24
src/data/projects/point-cloud.ts
Normal 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
|
||||
21
src/data/projects/radiance-caching.ts
Normal file
21
src/data/projects/radiance-caching.ts
Normal 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
131
src/data/resume.ts
Normal 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
104
src/engine/renderer.ts
Normal 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()])
|
||||
}
|
||||
}
|
||||
104
src/engine/shaders/fullscreen.wgsl
Normal file
104
src/engine/shaders/fullscreen.wgsl
Normal 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);
|
||||
}
|
||||
352
src/engine/text/slug-generator.ts
Normal file
352
src/engine/text/slug-generator.ts
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
177
src/engine/text/text-layout.ts
Normal file
177
src/engine/text/text-layout.ts
Normal 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++
|
||||
}
|
||||
205
src/engine/text/text-renderer.ts
Normal file
205
src/engine/text/text-renderer.ts
Normal 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
159
src/engine/text/text.wgsl
Normal 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
16
src/main.ts
Normal 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
14
src/ui/input.ts
Normal 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
254
src/ui/overlay.ts
Normal 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, '&').replace(/</g, '<').replace(/>/g, '>')
|
||||
}
|
||||
|
||||
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
18
tsconfig.json
Normal 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
6
vite.config.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
import { defineConfig } from 'vite'
|
||||
|
||||
export default defineConfig({
|
||||
server: { open: true },
|
||||
build: { target: 'esnext' }
|
||||
})
|
||||
Reference in New Issue
Block a user