import GL from './gl' interface RenderTarget { fbo: WebGLFramebuffer texture: WebGLTexture } // ─── Shared pass vertex shader ─────────────────────────────────────────────── const PASS_VERT = ` attribute vec2 aPosition; varying vec2 vUv; void main() { vUv = aPosition * 0.5 + 0.5; gl_Position = vec4(aPosition, 0.0, 1.0); }` // ─── Separable Gaussian blur ───────────────────────────────────────────────── // Call twice (horizontal then vertical) for a full 2D Gaussian blur. // uDirection = (1,0) for horizontal, (0,1) for vertical. const BLUR_FRAG = ` precision highp float; uniform sampler2D uTexture; uniform vec2 uResolution; uniform vec2 uDirection; uniform float uRadius; // Step multiplier: sample every N pixels instead of every 1. // Higher values cover a wider area with the same 17 taps — key for wide diffuse bloom. uniform float uStepMult; varying vec2 vUv; void main() { vec2 step = uDirection / uResolution * uStepMult; vec4 color = vec4(0.0); float total = 0.0; for (float i = -8.0; i <= 8.0; i++) { float w = exp(-i * i / (2.0 * uRadius * uRadius)); color += texture2D(uTexture, vUv + step * i) * w; total += w; } gl_FragColor = color / total; }` // ─── Brightness extraction ──────────────────────────────────────────────────── // Keeps only pixels whose luminance exceeds uThreshold; everything else goes black. // Used before the wide bloom pass so scattering only originates from bright light sources. const EXTRACT_FRAG = ` precision highp float; uniform sampler2D uTexture; uniform float uThreshold; varying vec2 vUv; void main() { vec4 c = texture2D(uTexture, vUv); float lum = dot(c.rgb, vec3(0.2126, 0.7152, 0.0722)); float bright = max(0.0, lum - uThreshold) / max(1.0 - uThreshold, 0.001); gl_FragColor = vec4(c.rgb * bright, 1.0); }` // ─── Dual-bloom composite ───────────────────────────────────────────────────── // Combines the original render with two independent bloom layers: // bloom1 — bright tight bloom (enhances perceived saturation / intensity) // bloom2 — wide dim bloom (simulates lens scattering / atmospheric glow) const COMPOSITE_FRAG = ` precision highp float; uniform sampler2D uBase; uniform sampler2D uBloom1; // bright tight bloom uniform sampler2D uBloom2; // wide dim bloom uniform float uIntensity1; uniform float uIntensity2; varying vec2 vUv; void main() { vec4 base = texture2D(uBase, vUv); vec4 bloom1 = texture2D(uBloom1, vUv); vec4 bloom2 = texture2D(uBloom2, vUv); gl_FragColor = clamp(base + bloom1 * uIntensity1 + bloom2 * uIntensity2, 0.0, 1.0); }` const QUAD = new Float32Array([-1, -1, 1, -1, -1, 1, -1, 1, 1, -1, 1, 1]) export interface PostProcessOptions { // Tight bloom — bright, few passes, small radius // Bloom layer A passesA?: number radiusA?: number intensityA?: number stepMultA?: number // sample stride (1 = every pixel, 3 = every 3rd pixel → 3× wider spread) // Bloom layer B — optionally extract bright pixels first for true scattering effect passesB?: number radiusB?: number intensityB?: number stepMultB?: number thresholdB?: number // luminance threshold before blur: 0 = blur everything, 0.5 = only bright pixels } export default class PostProcessGL extends GL { private _mainTarget!: RenderTarget private _pingTarget!: RenderTarget private _pongTarget!: RenderTarget private _extraTarget!: RenderTarget // holds one bloom result while the other is computed private _blurProgram!: WebGLProgram private _extractProgram!: WebGLProgram private _compositeProgram!: WebGLProgram private _quadBuffer!: WebGLBuffer private _extractTarget!: RenderTarget ppOptions: Required ppEnabled: boolean constructor( el: HTMLCanvasElement, vertexShader: string, fragmentShader: string, width: number, height: number, glOptions?: ConstructorParameters[5], ppOptions: PostProcessOptions = {} ) { super(el, vertexShader, fragmentShader, width, height, glOptions) this.ppEnabled = Object.keys(ppOptions).length > 0 this.ppOptions = { passesA: 1, radiusA: 5, intensityA: 1.2, stepMultA: 1, passesB: 1, radiusB: 0.5, intensityB: 1.2, stepMultB: 1, thresholdB: 0, ...ppOptions } this._initPostProcess() } // ─── Setup ────────────────────────────────────────────────────────────────── private _createTarget(w: number, h: number): RenderTarget { const gl = this.ctx const texture = gl.createTexture()! gl.bindTexture(gl.TEXTURE_2D, texture) gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, w, h, 0, gl.RGBA, gl.UNSIGNED_BYTE, null) gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR) gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR) gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE) gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE) const fbo = gl.createFramebuffer()! gl.bindFramebuffer(gl.FRAMEBUFFER, fbo) gl.framebufferTexture2D(gl.FRAMEBUFFER, gl.COLOR_ATTACHMENT0, gl.TEXTURE_2D, texture, 0) gl.bindFramebuffer(gl.FRAMEBUFFER, null) gl.bindTexture(gl.TEXTURE_2D, null) return { fbo, texture } } private _compilePassProgram(fragSrc: string): WebGLProgram { const gl = this.ctx const compile = (type: number, src: string) => { const s = gl.createShader(type)! gl.shaderSource(s, src) gl.compileShader(s) return s } const prog = gl.createProgram()! gl.attachShader(prog, compile(gl.VERTEX_SHADER, PASS_VERT)) gl.attachShader(prog, compile(gl.FRAGMENT_SHADER, fragSrc)) gl.linkProgram(prog) return prog } private _initPostProcess() { const w = this.el.width const h = this.el.height this._mainTarget = this._createTarget(w, h) this._pingTarget = this._createTarget(w, h) this._pongTarget = this._createTarget(w, h) this._extraTarget = this._createTarget(w, h) this._extractTarget = this._createTarget(w, h) this._blurProgram = this._compilePassProgram(BLUR_FRAG) this._extractProgram = this._compilePassProgram(EXTRACT_FRAG) this._compositeProgram = this._compilePassProgram(COMPOSITE_FRAG) this._quadBuffer = this.ctx.createBuffer()! this.ctx.bindBuffer(this.ctx.ARRAY_BUFFER, this._quadBuffer) this.ctx.bufferData(this.ctx.ARRAY_BUFFER, QUAD, this.ctx.STATIC_DRAW) } // ─── Blur helpers ──────────────────────────────────────────────────────────── private _bindQuad(program: WebGLProgram) { const gl = this.ctx gl.bindBuffer(gl.ARRAY_BUFFER, this._quadBuffer) const loc = gl.getAttribLocation(program, 'aPosition') gl.enableVertexAttribArray(loc) gl.vertexAttribPointer(loc, 2, gl.FLOAT, false, 0, 0) } private _blurPass(src: RenderTarget, dst: RenderTarget, dir: [number, number], radius: number, stepMult = 1) { const gl = this.ctx gl.bindFramebuffer(gl.FRAMEBUFFER, dst.fbo) gl.useProgram(this._blurProgram) this._bindQuad(this._blurProgram) gl.activeTexture(gl.TEXTURE0) gl.bindTexture(gl.TEXTURE_2D, src.texture) gl.uniform1i(gl.getUniformLocation(this._blurProgram, 'uTexture'), 0) gl.uniform2f(gl.getUniformLocation(this._blurProgram, 'uResolution'), this.el.width, this.el.height) gl.uniform2f(gl.getUniformLocation(this._blurProgram, 'uDirection'), dir[0], dir[1]) gl.uniform1f(gl.getUniformLocation(this._blurProgram, 'uRadius'), radius) gl.uniform1f(gl.getUniformLocation(this._blurProgram, 'uStepMult'), stepMult) gl.drawArrays(gl.TRIANGLES, 0, 6) } private _runBlur(src: RenderTarget, passes: number, radius: number, useExtra: boolean, stepMult = 1): RenderTarget { const a = this._pingTarget const b = useExtra ? this._extraTarget : this._pongTarget let cur: RenderTarget = src let scratch: [RenderTarget, RenderTarget] = [a, b] let idx = 0 for (let i = 0; i < passes; i++) { const dst0 = scratch[idx % 2]! const dst1 = scratch[(idx + 1) % 2]! this._blurPass(cur, dst0, [1, 0], radius, stepMult) this._blurPass(dst0, dst1, [0, 1], radius, stepMult) cur = dst1 idx += 2 } return cur } private _extractPass(src: RenderTarget, threshold: number) { const gl = this.ctx gl.bindFramebuffer(gl.FRAMEBUFFER, this._extractTarget.fbo) gl.useProgram(this._extractProgram) this._bindQuad(this._extractProgram) gl.activeTexture(gl.TEXTURE0) gl.bindTexture(gl.TEXTURE_2D, src.texture) gl.uniform1i(gl.getUniformLocation(this._extractProgram, 'uTexture'), 0) gl.uniform1f(gl.getUniformLocation(this._extractProgram, 'uThreshold'), threshold) gl.drawArrays(gl.TRIANGLES, 0, 6) } // ─── Resize ───────────────────────────────────────────────────────────────── override resize() { super.resize() if (!this._mainTarget) return const w = this.el.width const h = this.el.height for (const t of [this._mainTarget, this._pingTarget, this._pongTarget, this._extraTarget, this._extractTarget]) { this.ctx.bindTexture(this.ctx.TEXTURE_2D, t.texture) this.ctx.texImage2D(this.ctx.TEXTURE_2D, 0, this.ctx.RGBA, w, h, 0, this.ctx.RGBA, this.ctx.UNSIGNED_BYTE, null) } this.ctx.bindTexture(this.ctx.TEXTURE_2D, null) } // ─── Render ───────────────────────────────────────────────────────────────── override render() { if (!this.running) return requestAnimationFrame(this.render.bind(this)) const gl = this.ctx // Bypass: render directly to screen without any post-processing if (!this.ppEnabled) { gl.useProgram(this.program) this.initBuffers(this.positions) gl.bindFramebuffer(gl.FRAMEBUFFER, null) gl.viewport(0, 0, this.el.width, this.el.height) if (!this.renderOptions.externalTimeUse) { this.time = (Date.now() - this.startTime) / 1000 gl.uniform1f(this.programInfo.uniforms.time, this.time) } if (this.renderHook) this.renderHook() gl.drawArrays(gl.TRIANGLES, 0, 6) return } const { passesA, radiusA, intensityA, stepMultA, passesB, radiusB, intensityB, stepMultB, thresholdB } = this.ppOptions // Pass 1: render main shader to offscreen texture gl.useProgram(this.program) this.initBuffers(this.positions) gl.bindFramebuffer(gl.FRAMEBUFFER, this._mainTarget.fbo) gl.viewport(0, 0, this.el.width, this.el.height) if (!this.renderOptions.externalTimeUse) { this.time = (Date.now() - this.startTime) / 1000 gl.uniform1f(this.programInfo.uniforms.time, this.time) } if (this.renderHook) this.renderHook() gl.drawArrays(gl.TRIANGLES, 0, 6) const bloomA = this._runBlur(this._mainTarget, passesA, radiusA, false, stepMultA) // If thresholdB > 0: extract bright pixels first so bloom B scatters only from light sources const bloomBSrc = thresholdB > 0 ? (this._extractPass(this._mainTarget, thresholdB), this._extractTarget) : this._mainTarget const bloomB = this._runBlur(bloomBSrc, passesB, radiusB, true, stepMultB) gl.bindFramebuffer(gl.FRAMEBUFFER, null) gl.useProgram(this._compositeProgram) this._bindQuad(this._compositeProgram) gl.activeTexture(gl.TEXTURE0) gl.bindTexture(gl.TEXTURE_2D, this._mainTarget.texture) gl.uniform1i(gl.getUniformLocation(this._compositeProgram, 'uBase'), 0) gl.activeTexture(gl.TEXTURE1) gl.bindTexture(gl.TEXTURE_2D, bloomA.texture) gl.uniform1i(gl.getUniformLocation(this._compositeProgram, 'uBloom1'), 1) gl.activeTexture(gl.TEXTURE2) gl.bindTexture(gl.TEXTURE_2D, bloomB.texture) gl.uniform1i(gl.getUniformLocation(this._compositeProgram, 'uBloom2'), 2) gl.uniform1f(gl.getUniformLocation(this._compositeProgram, 'uIntensity1'), intensityA) gl.uniform1f(gl.getUniformLocation(this._compositeProgram, 'uIntensity2'), intensityB) gl.drawArrays(gl.TRIANGLES, 0, 6) } }