diff --git a/index.html b/index.html
index c8a9a31..f765c11 100644
--- a/index.html
+++ b/index.html
@@ -83,7 +83,14 @@
align-items: center;
gap: 0.75rem;
max-width: min(90vw, 720px);
+ max-height: 76vh;
+ overflow-y: auto;
+ padding-bottom: 2rem;
+ scrollbar-width: thin;
+ scrollbar-color: rgba(255,255,255,0.15) transparent;
}
+ #scene-content::-webkit-scrollbar { width: 5px; }
+ #scene-content::-webkit-scrollbar-thumb { background: rgba(255,255,255,0.15); border-radius: 3px; }
#scene-title {
font-size: clamp(1.6rem, 5vw, 3rem);
margin: 0;
@@ -478,8 +485,8 @@
-
WebGPU not available
-
Open this page in a recent Chrome/Edge (or any WebGPU-enabled browser).
+
Graphics not available
+
Your browser doesn't support WebGPU or WebGL. Open this page in a recent Chrome, Edge, or Firefox.
diff --git a/src/app.ts b/src/app.ts
index 5db9f87..ff330ee 100644
--- a/src/app.ts
+++ b/src/app.ts
@@ -1,4 +1,6 @@
import { Renderer, UiState } from './engine/renderer'
+import { GlRenderer } from './engine/gl-renderer'
+import type { RendererLike } from './engine/types'
import { Input } from './ui/input'
import { DomOverlay } from './ui/overlay'
import { Router, Route } from './router'
@@ -9,7 +11,7 @@ const DPR_CAP = 2
export class App {
private canvas: HTMLCanvasElement
- private renderer = new Renderer()
+ private renderer: RendererLike = new Renderer()
private input!: Input
private overlay!: DomOverlay
private router = new Router()
@@ -47,7 +49,11 @@ export class App {
this.router.replace(Router.sceneToBasePath(this.scene))
this.resize()
- const ok = await this.renderer.init(this.canvas)
+ 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
this.startTime = performance.now()
this.running = true
diff --git a/src/engine/gl-renderer.ts b/src/engine/gl-renderer.ts
new file mode 100644
index 0000000..795cea7
--- /dev/null
+++ b/src/engine/gl-renderer.ts
@@ -0,0 +1,127 @@
+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
+
+ 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[8] = state.dim
+ 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)
+ }
+}
diff --git a/src/engine/shaders/fullscreen.glsl b/src/engine/shaders/fullscreen.glsl
new file mode 100644
index 0000000..983bf86
--- /dev/null
+++ b/src/engine/shaders/fullscreen.glsl
@@ -0,0 +1,110 @@
+//---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;
+ 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);
+
+ vec3 scene0 = vec3(0.20, 0.75, 0.20);
+ vec3 scene1 = vec3(0.85, 0.55, 0.20);
+ vec3 scene2 = vec3(0.25, 0.60, 0.95);
+ vec3 scene3 = vec3(0.90, 0.35, 0.55);
+
+ 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);
+
+ 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;
+
+ finalColor += exp(-distMouse * 4.0) * 0.03 * sceneAccent;
+
+ float dim = date.x;
+ finalColor = mix(finalColor, vec3(0.0), dim);
+
+ outColor = vec4(finalColor * vignette, 1.0);
+}
diff --git a/src/engine/types.ts b/src/engine/types.ts
new file mode 100644
index 0000000..6bf16da
--- /dev/null
+++ b/src/engine/types.ts
@@ -0,0 +1,8 @@
+import type { UiState } from './renderer'
+
+export interface RendererLike {
+ readonly ready: boolean
+ init(canvas: HTMLCanvasElement): Promise
+ render(state: UiState): void
+ resize(): void
+}