Files
webgpu-portfolio/.hermes/plans/2025-07-27_120000-webgl-fallback.md
djoser 9ea85db5ce Migrate Vite SPA to Hugo (hugo-coder, dark) with WebGPU background
- 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
2026-08-21 03:39:42 +02:00

552 lines
17 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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.