- Hugo static site at root: config, content (home/about/resume/blog + 5 projects), data/resume.yaml, custom layouts (gpu background, resume renderer, projects list), dark theme with canvas behind content - gpu-bg/: extracted WebGPU/WebGL2 spiral engine as a Vite sub-package; slim bootstrap drops router/overlay/text renderer; fixed uniforms (scene0/dim0); self-contained engine.js - archive/: retired Vite SPA preserved (router, overlay, data, engine/text orphaned) - 3-stage Dockerfile (node -> hugo extended/dart-sass -> nginx), static nginx config - DEPLOYMENT.md handover + README rewrite; pre-hugo rollback tag
552 lines
17 KiB
Markdown
552 lines
17 KiB
Markdown
# WebGL Fallback Renderer Implementation Plan
|
||
|
||
> **For Hermes:** Use `opencode` CLI to implement this plan task-by-task. Each task is self-contained and should be verified before moving to the next.
|
||
|
||
**Goal:** Add a WebGL2 fallback renderer so devices without WebGPU still get an animated procedural background behind the DOM overlay, instead of a static error message.
|
||
|
||
**Architecture:** A new `GlRenderer` class mirrors the existing `Renderer` interface (init, render, resize, ready). `app.ts` tries WebGPU first; if that fails, it falls back to WebGL2. Only if both fail does the `#fallback` static message appear. The DOM overlay (nav, content, projects, resume) runs identically regardless of which GPU backend is active — no overlay code changes.
|
||
|
||
**Tech Stack:** TypeScript, WebGL2 (GLSL 300 ES), UBO for uniform passing, Vite `?raw` imports for shader source.
|
||
|
||
---
|
||
|
||
## Current state recap
|
||
|
||
| File | Role | Change needed? |
|
||
|------|------|:---:|
|
||
| `src/engine/renderer.ts` | WebGPU-only renderer; checks `navigator.gpu`, returns false if unavailable | Extract interface |
|
||
| `src/engine/shaders/fullscreen.wgsl` | WGSL fullscreen-triangle fragment shader (spiral + dim) | None (reference for GLSL port) |
|
||
| `src/app.ts` | `App.start()` calls `renderer.init()`, returns false on failure → site dead | Try GL on GPU fail |
|
||
| `src/main.ts` | Shows `#fallback` div + hides canvas when `app.start()` returns false | Adjust: keep canvas visible for GL path; only show fallback when both fail |
|
||
| `index.html` | `#fallback` div (`display:none`), `#gpu` canvas, `#overlay` div | Remove or update fallback message text |
|
||
|
||
---
|
||
|
||
## Task 1: Define shared renderer interface
|
||
|
||
**Objective:** Extract a structural interface so both `Renderer` and `GlRenderer` are interchangeable in `app.ts`.
|
||
|
||
**Files:**
|
||
- Create: `src/engine/types.ts`
|
||
|
||
**Step 1: Create the interface file**
|
||
|
||
```ts
|
||
// src/engine/types.ts
|
||
import type { UiState } from './renderer'
|
||
|
||
export interface RendererLike {
|
||
readonly ready: boolean
|
||
init(canvas: HTMLCanvasElement): Promise<boolean>
|
||
render(state: UiState): void
|
||
resize(): void
|
||
}
|
||
```
|
||
|
||
Note: `UiState` is already exported from `renderer.ts`. Keep the import there; this avoids circular deps because `types.ts` only imports a type.
|
||
|
||
**Step 2: Verify**
|
||
|
||
```bash
|
||
npm run typecheck
|
||
```
|
||
Expected: PASS (no usages yet, just the file existing).
|
||
|
||
---
|
||
|
||
## Task 2: Make `Renderer` conform to the interface
|
||
|
||
**Objective:** Ensure `Renderer` already satisfies `RendererLike` (it does — just verify typecheck passes when assigned).
|
||
|
||
**Files:**
|
||
- Modify: `src/engine/renderer.ts`
|
||
|
||
**Step 1: Import the interface and add a type assertion test**
|
||
|
||
No code changes needed to `Renderer` itself — it already has `ready`, `init`, `render`, `resize`. Just verify:
|
||
|
||
```bash
|
||
npm run typecheck
|
||
```
|
||
Expected: PASS.
|
||
|
||
---
|
||
|
||
## Task 3: Create GLSL fullscreen shader
|
||
|
||
**Objective:** Port the WGSL spiral shader to GLSL 300 ES (WebGL2). Same visual output: spiral arms, mouse distortion, scene-based palette, dim factor.
|
||
|
||
**Files:**
|
||
- Create: `src/engine/shaders/fullscreen.glsl`
|
||
|
||
**Shader content (vertex + fragment in one file, split on marker comments):**
|
||
|
||
```glsl
|
||
//---VERTEX---
|
||
#version 300 es
|
||
|
||
const vec2 POSITIONS[3] = vec2[](
|
||
vec2(-1.0, -1.0),
|
||
vec2(-1.0, 3.0),
|
||
vec2( 3.0, -1.0)
|
||
);
|
||
|
||
out vec2 vUv;
|
||
|
||
void main() {
|
||
vec2 pos = POSITIONS[gl_VertexID];
|
||
gl_Position = vec4(pos, 0.0, 1.0);
|
||
vUv = pos;
|
||
}
|
||
|
||
//---FRAGMENT---
|
||
#version 300 es
|
||
precision highp float;
|
||
|
||
layout(std140) uniform Uniforms {
|
||
vec2 resolution;
|
||
float time;
|
||
float scene;
|
||
vec2 mouse;
|
||
// 8 bytes padding (vec2 → vec4 alignment in std140)
|
||
vec4 date;
|
||
vec4 sampleRate;
|
||
vec4 stemMultipliers[8];
|
||
};
|
||
|
||
in vec2 vUv;
|
||
out vec4 outColor;
|
||
|
||
void main() {
|
||
vec2 fragCoord = (vUv * 0.5 + 0.5) * resolution;
|
||
vec2 uv = (fragCoord - 0.5 * resolution) / resolution.y;
|
||
|
||
float slowTime = time * 0.2;
|
||
|
||
vec3 bgGrey = vec3(0.11, 0.11, 0.12);
|
||
vec3 cardGrey = vec3(0.30, 0.32, 0.36);
|
||
vec3 coreWhite = vec3(0.90, 0.95, 0.90);
|
||
|
||
// palette keyed by scene
|
||
vec3 scene0 = vec3(0.20, 0.75, 0.20); // HERO – emerald
|
||
vec3 scene1 = vec3(0.85, 0.55, 0.20); // ABOUT – amber
|
||
vec3 scene2 = vec3(0.25, 0.60, 0.95); // PROJECTS – cobalt
|
||
vec3 scene3 = vec3(0.90, 0.35, 0.55); // CONTACT – rose
|
||
|
||
int sFloor = int(clamp(floor(scene), 0.0, 3.0));
|
||
int sCeil = int(clamp(ceil(scene), 0.0, 3.0));
|
||
|
||
vec3 c0;
|
||
if (sFloor == 0) c0 = scene0;
|
||
else if (sFloor == 1) c0 = scene1;
|
||
else if (sFloor == 2) c0 = scene2;
|
||
else c0 = scene3;
|
||
|
||
vec3 c1;
|
||
if (sCeil == 0) c1 = scene0;
|
||
else if (sCeil == 1) c1 = scene1;
|
||
else if (sCeil == 2) c1 = scene2;
|
||
else c1 = scene3;
|
||
|
||
float tBlend = scene - floor(scene);
|
||
float smoothT = tBlend * tBlend * (3.0 - 2.0 * tBlend);
|
||
vec3 sceneAccent = mix(c0, c1, smoothT);
|
||
|
||
// mouse-reactive distortion
|
||
vec2 mousePos = (mouse - 0.5 * resolution) / resolution.y;
|
||
float distMouse = length(uv - mousePos);
|
||
float influence = exp(-distMouse * 7.0);
|
||
uv += (mousePos - uv) * influence * 0.12;
|
||
|
||
float angle = atan(uv.y, uv.x);
|
||
float radius = length(uv);
|
||
|
||
float baseSpiral = angle + (radius * 8.0) - slowTime;
|
||
float ripple = sin(radius * 15.0 - (time * 0.5)) * 0.08;
|
||
float distortedSpiral = baseSpiral + ripple;
|
||
|
||
float armCount = 2.0;
|
||
float rChannel = smoothstep(0.05, 0.55, abs(sin((distortedSpiral + 0.04) * armCount)));
|
||
float gChannel = smoothstep(0.05, 0.55, abs(sin(distortedSpiral * armCount)));
|
||
float bChannel = smoothstep(0.05, 0.55, abs(sin((distortedSpiral - 0.04) * armCount)));
|
||
vec3 patternChannels = vec3(rChannel, gChannel, bChannel);
|
||
|
||
vec3 coreNeon = vec3(0.02 / (patternChannels + 0.025));
|
||
vec3 spiralColor = mix(cardGrey, sceneAccent, patternChannels.g);
|
||
vec3 primarySpiral = coreNeon * spiralColor;
|
||
|
||
float ringWave = sin(radius * 12.0 - (time * 1.5));
|
||
float sharpRing = smoothstep(0.85, 0.95, ringWave);
|
||
float ringMask = sharpRing * smoothstep(0.7, 0.1, radius);
|
||
vec3 secondaryRings = cardGrey * ringMask * 0.4;
|
||
|
||
float vignette = smoothstep(0.95, 0.4, radius);
|
||
|
||
vec3 finalColor = bgGrey + secondaryRings;
|
||
finalColor = mix(finalColor, primarySpiral, 0.85);
|
||
|
||
float centerLight = 0.015 / (radius + 0.02);
|
||
finalColor += centerLight * coreWhite;
|
||
|
||
// soft cursor glow
|
||
finalColor += exp(-distMouse * 4.0) * 0.03 * sceneAccent;
|
||
|
||
// dim when not on the home page (date.x controls it)
|
||
float dim = date.x;
|
||
finalColor = mix(finalColor, vec3(0.0), dim);
|
||
|
||
outColor = vec4(finalColor * vignette, 1.0);
|
||
}
|
||
```
|
||
|
||
**Key porting notes:**
|
||
- `select(a, b, cond)` → `if/else` chains (clearer than mix-with-bool-cast in GLSL)
|
||
- `array<vec4<f32>, 8>` → `vec4 stemMultipliers[8]`
|
||
- `@builtin(position)` → `gl_Position`
|
||
- `@location(0)` → `out vec2 vUv` / `in vec2 vUv`
|
||
- Uniform buffer layout with `std140` matches the Float32Array layout exactly (vec2 + float + float = 16 bytes, then 8 bytes padding, then vec4 — same as the TS array d[6],d[7] being zero)
|
||
- `atan2(y, x)` → GLSL `atan(y, x)` (same signature)
|
||
|
||
**Step 2: Verify the file exists**
|
||
|
||
```bash
|
||
wc -l src/engine/shaders/fullscreen.glsl
|
||
```
|
||
Expected: ~108 lines.
|
||
|
||
---
|
||
|
||
## Task 4: Create `GlRenderer` class
|
||
|
||
**Objective:** Implement the WebGL2 renderer that mirrors `Renderer`'s lifecycle.
|
||
|
||
**Files:**
|
||
- Create: `src/engine/gl-renderer.ts`
|
||
|
||
**Step 1: Write the GlRenderer class**
|
||
|
||
```ts
|
||
import glslSource from './shaders/fullscreen.glsl?raw'
|
||
import type { UiState } from './renderer'
|
||
import type { RendererLike } from './types'
|
||
|
||
const UNIFORM_FLOATS = 48
|
||
const UNIFORM_BYTES = UNIFORM_FLOATS * 4
|
||
|
||
function compileShader(gl: WebGL2RenderingContext, type: number, source: string): WebGLShader | null {
|
||
const shader = gl.createShader(type)
|
||
if (!shader) return null
|
||
gl.shaderSource(shader, source)
|
||
gl.compileShader(shader)
|
||
if (!gl.getShaderParameter(shader, gl.COMPILE_STATUS)) {
|
||
console.error('GL shader compile error:', gl.getShaderInfoLog(shader))
|
||
gl.deleteShader(shader)
|
||
return null
|
||
}
|
||
return shader
|
||
}
|
||
|
||
function linkProgram(gl: WebGL2RenderingContext, vs: WebGLShader, fs: WebGLShader): WebGLProgram | null {
|
||
const program = gl.createProgram()
|
||
if (!program) return null
|
||
gl.attachShader(program, vs)
|
||
gl.attachShader(program, fs)
|
||
gl.linkProgram(program)
|
||
if (!gl.getProgramParameter(program, gl.LINK_STATUS)) {
|
||
console.error('GL program link error:', gl.getProgramInfoLog(program))
|
||
gl.deleteProgram(program)
|
||
return null
|
||
}
|
||
return program
|
||
}
|
||
|
||
function splitShaderSource(source: string): { vs: string; fs: string } {
|
||
const parts = source.split('//---FRAGMENT---')
|
||
if (parts.length !== 2) throw new Error('GLSL source must contain //---VERTEX--- and //---FRAGMENT--- markers')
|
||
const vs = parts[0].replace('//---VERTEX---', '').trim()
|
||
const fs = parts[1].trim()
|
||
return { vs, fs }
|
||
}
|
||
|
||
export class GlRenderer implements RendererLike {
|
||
private gl: WebGL2RenderingContext | null = null
|
||
private program: WebGLProgram | null = null
|
||
private ubo: WebGLBuffer | null = null
|
||
private uniformData = new Float32Array(UNIFORM_FLOATS)
|
||
private _ready = false
|
||
|
||
get ready(): boolean {
|
||
return this._ready
|
||
}
|
||
|
||
async init(canvas: HTMLCanvasElement): Promise<boolean> {
|
||
const gl = canvas.getContext('webgl2', {
|
||
alpha: false,
|
||
antialias: false,
|
||
depth: false,
|
||
stencil: false,
|
||
preserveDrawingBuffer: false,
|
||
})
|
||
if (!gl) return false
|
||
|
||
this.gl = gl
|
||
|
||
const { vs: vsSrc, fs: fsSrc } = splitShaderSource(glslSource)
|
||
|
||
const vs = compileShader(gl, gl.VERTEX_SHADER, vsSrc)
|
||
if (!vs) return false
|
||
|
||
const fs = compileShader(gl, gl.FRAGMENT_SHADER, fsSrc)
|
||
if (!fs) {
|
||
gl.deleteShader(vs)
|
||
return false
|
||
}
|
||
|
||
const program = linkProgram(gl, vs, fs)
|
||
gl.deleteShader(vs)
|
||
gl.deleteShader(fs)
|
||
if (!program) return false
|
||
|
||
this.program = program
|
||
|
||
// Create UBO
|
||
const ubo = gl.createBuffer()
|
||
if (!ubo) return false
|
||
gl.bindBuffer(gl.UNIFORM_BUFFER, ubo)
|
||
gl.bufferData(gl.UNIFORM_BUFFER, UNIFORM_BYTES, gl.DYNAMIC_DRAW)
|
||
|
||
const blockIndex = gl.getUniformBlockIndex(program, 'Uniforms')
|
||
if (blockIndex === gl.INVALID_INDEX) {
|
||
console.error('Uniform block "Uniforms" not found in GLSL program')
|
||
return false
|
||
}
|
||
gl.uniformBlockBinding(program, blockIndex, 0)
|
||
gl.bindBufferBase(gl.UNIFORM_BUFFER, 0, ubo)
|
||
|
||
this.ubo = ubo
|
||
this._ready = true
|
||
return true
|
||
}
|
||
|
||
resize(): void {
|
||
if (!this.gl) return
|
||
this.gl.viewport(0, 0, this.gl.canvas.width, this.gl.canvas.height)
|
||
}
|
||
|
||
render(state: UiState): void {
|
||
const gl = this.gl
|
||
if (!gl || !this.ubo || !this._ready) 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] = state.dim
|
||
// d[12]-d[15]: sampleRate — left as zero
|
||
d.set(state.stemMultipliers, 16)
|
||
|
||
gl.bindBuffer(gl.UNIFORM_BUFFER, this.ubo)
|
||
gl.bufferSubData(gl.UNIFORM_BUFFER, 0, this.uniformData)
|
||
|
||
gl.useProgram(this.program)
|
||
gl.drawArrays(gl.TRIANGLES, 0, 3)
|
||
}
|
||
}
|
||
```
|
||
|
||
**Step 2: Verify**
|
||
|
||
```bash
|
||
npm run typecheck
|
||
```
|
||
Expected: PASS. The GLSL import via `?raw` is a string; the `RendererLike` import is a type; all WebGL2 types are in the standard DOM lib.
|
||
|
||
---
|
||
|
||
## Task 5: Wire fallback logic in `app.ts`
|
||
|
||
**Objective:** `App.start()` tries WebGPU → falls back to WebGL → only returns false if both fail.
|
||
|
||
**Files:**
|
||
- Modify: `src/app.ts`
|
||
|
||
**Changes:**
|
||
|
||
1. Add imports at top:
|
||
```ts
|
||
import { Renderer, UiState } from './engine/renderer'
|
||
import { GlRenderer } from './engine/gl-renderer'
|
||
import type { RendererLike } from './engine/types'
|
||
```
|
||
|
||
2. Change renderer field type:
|
||
```ts
|
||
private renderer: RendererLike = new Renderer()
|
||
```
|
||
|
||
3. In `start()`, change the init block:
|
||
```ts
|
||
let ok = await this.renderer.init(this.canvas)
|
||
if (!ok) {
|
||
this.renderer = new GlRenderer()
|
||
ok = await this.renderer.init(this.canvas)
|
||
}
|
||
if (!ok) return false
|
||
```
|
||
|
||
That's it. The rest of `app.ts` (resize, loop, render calls) works unchanged because both renderers satisfy `RendererLike`.
|
||
|
||
**Step 2: Verify**
|
||
|
||
```bash
|
||
npm run typecheck
|
||
```
|
||
Expected: PASS.
|
||
|
||
---
|
||
|
||
## Task 6: Adjust `main.ts` fallback behavior
|
||
|
||
**Objective:** Only show the static `#fallback` div when BOTH WebGPU and WebGL are unavailable. When WebGL succeeds, the canvas is already visible and rendering — no action needed.
|
||
|
||
**Files:**
|
||
- Modify: `src/main.ts`
|
||
|
||
**Current code (lines 1-16):**
|
||
```ts
|
||
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()
|
||
```
|
||
|
||
**New code:** No structural change needed — the logic is already correct. When `app.start()` returns false (both GPU and GL failed), the fallback div shows. When it returns true (either backend works), the canvas is visible and rendering. No changes required.
|
||
|
||
**Step 2: Update the fallback message in `index.html`**
|
||
|
||
Change the `#fallback` content in `index.html` to mention WebGL too:
|
||
|
||
```html
|
||
<div id="fallback">
|
||
<div>
|
||
<h2>Graphics not available</h2>
|
||
<p>Your browser doesn't support WebGPU or WebGL. Open this page in a recent Chrome, Edge, or Firefox.</p>
|
||
</div>
|
||
</div>
|
||
```
|
||
|
||
**Step 3: Verify**
|
||
|
||
```bash
|
||
npm run typecheck
|
||
```
|
||
Expected: PASS (no TS changes, just HTML).
|
||
|
||
---
|
||
|
||
## Task 7: Build and final check
|
||
|
||
**Objective:** Full build to verify everything compiles and bundles correctly.
|
||
|
||
**Files:** None (verification only)
|
||
|
||
**Step 1: Build**
|
||
|
||
```bash
|
||
npm run build
|
||
```
|
||
Expected: PASS. The `?raw` import for `.glsl` should be handled by Vite just like `.wgsl`.
|
||
|
||
If Vite complains about `.glsl` extension, add to `vite.config.ts`:
|
||
```ts
|
||
export default defineConfig({
|
||
server: { open: true },
|
||
build: { target: 'esnext' },
|
||
assetsInclude: ['**/*.glsl'],
|
||
})
|
||
```
|
||
|
||
**Step 2: Check dist size**
|
||
|
||
```bash
|
||
du -sh dist/
|
||
```
|
||
The GLSL source (~3KB raw) will be inlined as a string in the JS bundle. Acceptable overhead.
|
||
|
||
---
|
||
|
||
## Task 8: Manual smoke test (dev server)
|
||
|
||
**Objective:** Verify the site works in both paths.
|
||
|
||
**Files:** None (verification only)
|
||
|
||
**Step 1: Start dev server**
|
||
|
||
```bash
|
||
npm run dev
|
||
```
|
||
|
||
**Step 2: Test WebGPU path**
|
||
|
||
Open in Chrome/Edge with WebGPU enabled → should see the normal WebGPU spiral + DOM overlay.
|
||
|
||
**Step 3: Test WebGL fallback path**
|
||
|
||
Open in Firefox (no WebGPU) → should see the WebGL spiral + fully functional DOM overlay.
|
||
|
||
Alternatively, temporarily disable WebGPU in Chrome (`chrome://flags/#enable-unsafe-webgpu` → Disabled) and reload.
|
||
|
||
**Step 4: Verify DOM overlay on both paths**
|
||
|
||
- Click through nav tabs (Home, About, Projects, Resume)
|
||
- Open a project detail, click back
|
||
- Scroll resume
|
||
- Confirm mouse movement affects the background (cursor glow)
|
||
|
||
---
|
||
|
||
## Summary of changes
|
||
|
||
| File | Action | Lines |
|
||
|------|--------|-------|
|
||
| `src/engine/types.ts` | Create | ~6 |
|
||
| `src/engine/shaders/fullscreen.glsl` | Create | ~108 |
|
||
| `src/engine/gl-renderer.ts` | Create | ~110 |
|
||
| `src/app.ts` | Modify: add GL fallback init | +8, ~4 changed |
|
||
| `src/main.ts` | No changes needed | 0 |
|
||
| `index.html` | Update fallback message text | ~2 lines changed |
|
||
| `vite.config.ts` | Possibly add `assetsInclude` for `.glsl` | +1 line |
|
||
|
||
Total: ~230 new lines, ~6 lines modified. No DOM overlay code touched. No existing shader touched.
|
||
|
||
---
|
||
|
||
## Risks and mitigations
|
||
|
||
1. **Vite doesn't handle `.glsl` with `?raw`** — Mitigation: add `assetsInclude: ['**/*.glsl']` to vite config. Vite treats included assets as raw strings by default when imported with `?raw`.
|
||
|
||
2. **GLSL precision differences** — The WGSL shader uses `f32` (IEEE 754 binary32). WebGL fragment shaders default to `mediump` which may not have enough precision for the spiral math. Mitigation: use `precision highp float;` at the top of the fragment shader. All modern mobile and desktop GPUs support `highp` in fragment shaders.
|
||
|
||
3. **UBO layout mismatch** — If the GLSL compiler pads the struct differently than expected, the uniform data will be misaligned. Mitigation: explicitly use `layout(std140)` which guarantees the exact layout we've matched. The TS array layout was designed to match std140 already (d[6],d[7] = 8 bytes of padding before `date`).
|
||
|
||
4. **`noUnusedLocals` / `noUnusedParameters`** — If any GL renderer methods or params trigger TS unused warnings. Mitigation: all method implementations use their params; the `_ready` naming convention is already established in the project.
|
||
|
||
5. **Context loss handling** — The WebGL2 context can be lost. Mitigation: WebGL2 has `webglcontextlost` / `webglcontextrestored` events. For now, the current codebase doesn't handle WebGPU device loss recovery (beyond logging), so feature parity is sufficient. Can add recovery in a follow-up.
|