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

17 KiB
Raw Blame History

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

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

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:

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):

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

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

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

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:
import { Renderer, UiState } from './engine/renderer'
import { GlRenderer } from './engine/gl-renderer'
import type { RendererLike } from './engine/types'
  1. Change renderer field type:
private renderer: RendererLike = new Renderer()
  1. In start(), change the init block:
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

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):

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:

<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

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

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:

export default defineConfig({
  server: { open: true },
  build: { target: 'esnext' },
  assetsInclude: ['**/*.glsl'],
})

Step 2: Check dist size

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

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.