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 +}