# 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 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, 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 { 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 { 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

Graphics not available

Your browser doesn't support WebGPU or WebGL. Open this page in a recent Chrome, Edge, or Firefox.

``` **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.