From 61bea1b7746b7e66c110170e003194a47a7714ac Mon Sep 17 00:00:00 2001 From: Warinyourself Date: Tue, 23 Jun 2026 17:22:03 +0300 Subject: [PATCH] Hide pixelation post-process --- .../themes/destruction/constants.ts | 3 +- src/components/themes/flow/constants.ts | 3 +- src/components/themes/planet/constants.ts | 3 +- src/components/themes/plasma/constants.ts | 3 +- src/components/themes/random/constants.ts | 3 +- src/components/themes/rings/constants.ts | 3 +- src/components/themes/rings3d/constants.ts | 3 +- src/components/themes/sphere/constants.ts | 3 +- src/components/themes/tenderness/constants.ts | 3 +- src/components/themes/tunnel/constants.ts | 3 +- src/components/themes/zappy/constants.ts | 3 +- src/utils/gl.ts | 89 +++++++++++++++++-- src/utils/postprocess.ts | 48 +++++++++- src/utils/theme-inputs.ts | 7 ++ 14 files changed, 157 insertions(+), 20 deletions(-) diff --git a/src/components/themes/destruction/constants.ts b/src/components/themes/destruction/constants.ts index 50e68f4..66c6074 100644 --- a/src/components/themes/destruction/constants.ts +++ b/src/components/themes/destruction/constants.ts @@ -1,5 +1,6 @@ import type { AppTheme } from '@/models/app' -import { pxratio, randomButton, resetButton, buildInputSlider } from '@/utils/theme-inputs' +import { pxratio, randomButton, resetButton, buildInputSlider, +} from '@/utils/theme-inputs' export const DESTRUCTION_SETTINGS: AppTheme = { name: 'Destruction', diff --git a/src/components/themes/flow/constants.ts b/src/components/themes/flow/constants.ts index 2df5db8..001ae3d 100644 --- a/src/components/themes/flow/constants.ts +++ b/src/components/themes/flow/constants.ts @@ -1,5 +1,6 @@ import type { AppTheme } from '@/models/app' -import { pxratio, hueSlider, brightnessSlider, randomButton, resetButton, buildInputSlider, buildInvertCheckbox } from '@/utils/theme-inputs' +import { pxratio, hueSlider, brightnessSlider, randomButton, resetButton, buildInputSlider, buildInvertCheckbox, +} from '@/utils/theme-inputs' export const FLOW_SETTINGS: AppTheme = { name: 'Flow', diff --git a/src/components/themes/planet/constants.ts b/src/components/themes/planet/constants.ts index ba1fb8c..a067546 100644 --- a/src/components/themes/planet/constants.ts +++ b/src/components/themes/planet/constants.ts @@ -1,5 +1,6 @@ import type { AppTheme } from '@/models/app' -import { pxratio, randomButton, resetButton, buildInputSlider } from '@/utils/theme-inputs' +import { pxratio, randomButton, resetButton, buildInputSlider, +} from '@/utils/theme-inputs' export const PLANET_SETTINGS: AppTheme = { name: 'Planet', diff --git a/src/components/themes/plasma/constants.ts b/src/components/themes/plasma/constants.ts index b8f4c69..eaa866d 100644 --- a/src/components/themes/plasma/constants.ts +++ b/src/components/themes/plasma/constants.ts @@ -1,5 +1,6 @@ import type { AppTheme } from '@/models/app' -import { pxratio, hueSlider, randomButton, resetButton, buildInputSlider } from '@/utils/theme-inputs' +import { pxratio, hueSlider, randomButton, resetButton, buildInputSlider, +} from '@/utils/theme-inputs' export const PLASMA_SETTINGS: AppTheme = { name: 'Plasma', diff --git a/src/components/themes/random/constants.ts b/src/components/themes/random/constants.ts index 40ac0ed..fc87464 100644 --- a/src/components/themes/random/constants.ts +++ b/src/components/themes/random/constants.ts @@ -1,5 +1,6 @@ import type { AppTheme } from '@/models/app' -import { pxratio, hueSlider, brightnessSlider, randomButton, resetButton, buildInputSlider, buildInvertCheckbox } from '@/utils/theme-inputs' +import { pxratio, hueSlider, brightnessSlider, randomButton, resetButton, buildInputSlider, buildInvertCheckbox, +} from '@/utils/theme-inputs' export const RANDOM_SETTINGS: AppTheme = { name: 'Random', diff --git a/src/components/themes/rings/constants.ts b/src/components/themes/rings/constants.ts index 32572eb..ab0fc65 100644 --- a/src/components/themes/rings/constants.ts +++ b/src/components/themes/rings/constants.ts @@ -1,5 +1,6 @@ import type { AppTheme } from '@/models/app' -import { pxratio, randomButton, resetButton, buildInputSlider } from '@/utils/theme-inputs' +import { pxratio, randomButton, resetButton, buildInputSlider, +} from '@/utils/theme-inputs' export const RINGS_SETTINGS: AppTheme = { name: 'Rings', diff --git a/src/components/themes/rings3d/constants.ts b/src/components/themes/rings3d/constants.ts index 6a5c0f8..9ef1178 100644 --- a/src/components/themes/rings3d/constants.ts +++ b/src/components/themes/rings3d/constants.ts @@ -1,5 +1,6 @@ import type { AppTheme } from '@/models/app' -import { pxratio, randomButton, resetButton, buildInputSlider, buildInputColor } from '@/utils/theme-inputs' +import { pxratio, randomButton, resetButton, buildInputSlider, buildInputColor, +} from '@/utils/theme-inputs' export const RINGS3D_SETTINGS: AppTheme = { name: 'Rings3D', diff --git a/src/components/themes/sphere/constants.ts b/src/components/themes/sphere/constants.ts index c2749a7..bff7dbc 100644 --- a/src/components/themes/sphere/constants.ts +++ b/src/components/themes/sphere/constants.ts @@ -1,5 +1,6 @@ import type { AppTheme } from '@/models/app' -import { pxratio, hueSlider, brightnessSlider, randomButton, resetButton, buildInputSlider, buildInvertCheckbox } from '@/utils/theme-inputs' +import { pxratio, hueSlider, brightnessSlider, randomButton, resetButton, buildInputSlider, buildInvertCheckbox, +} from '@/utils/theme-inputs' export const SPHERE_SETTINGS: AppTheme = { name: 'Sphere', diff --git a/src/components/themes/tenderness/constants.ts b/src/components/themes/tenderness/constants.ts index 29b366f..4534aed 100644 --- a/src/components/themes/tenderness/constants.ts +++ b/src/components/themes/tenderness/constants.ts @@ -1,5 +1,6 @@ import type { AppTheme } from '@/models/app' -import { pxratio, randomButton, resetButton, buildInputSlider } from '@/utils/theme-inputs' +import { pxratio, randomButton, resetButton, buildInputSlider, +} from '@/utils/theme-inputs' export const TENDERNESS_SETTINGS: AppTheme = { name: 'Tenderness', diff --git a/src/components/themes/tunnel/constants.ts b/src/components/themes/tunnel/constants.ts index ddc1fb6..97780ce 100644 --- a/src/components/themes/tunnel/constants.ts +++ b/src/components/themes/tunnel/constants.ts @@ -1,5 +1,6 @@ import type { AppTheme } from '@/models/app' -import { pxratio, randomButton, resetButton, buildInputSlider } from '@/utils/theme-inputs' +import { pxratio, randomButton, resetButton, buildInputSlider, +} from '@/utils/theme-inputs' export const TUNNEL_SETTINGS: AppTheme = { name: 'Tunnel', diff --git a/src/components/themes/zappy/constants.ts b/src/components/themes/zappy/constants.ts index e732026..836466c 100644 --- a/src/components/themes/zappy/constants.ts +++ b/src/components/themes/zappy/constants.ts @@ -1,5 +1,6 @@ import type { AppTheme } from '@/models/app' -import { pxratio, randomButton, resetButton, buildInputSlider } from '@/utils/theme-inputs' +import { pxratio, randomButton, resetButton, buildInputSlider, +} from '@/utils/theme-inputs' export const ZAPPY_SETTINGS: AppTheme = { name: 'Zappy', diff --git a/src/utils/gl.ts b/src/utils/gl.ts index 5fdcf30..3a8a2c6 100644 --- a/src/utils/gl.ts +++ b/src/utils/gl.ts @@ -30,8 +30,15 @@ export default class GL { height!: number innerRunning = false + pixelSize = 1 // >1 enables pixelation post-pass (e.g. 6 = 6×6 pixel blocks) positions!: number[] + + // ─── Pixelation resources (lazy-initialised on first use) ─────────────────── + private _pxFbo: WebGLFramebuffer | null = null + private _pxTex: WebGLTexture | null = null + private _pxProg: WebGLProgram | null = null + private _pxQuad: WebGLBuffer | null = null positionBuffer!: WebGLBuffer | null listeners: WindowListenerCallback[] = [] @@ -172,16 +179,38 @@ export default class GL { render() { this.running && requestAnimationFrame(this.render) + const gl = this.ctx + const usePixelate = this.pixelSize > 1 + + if (usePixelate) { + if (!this._pxFbo) this._initPixelate() + gl.bindFramebuffer(gl.FRAMEBUFFER, this._pxFbo) + } + if (!this.renderOptions.externalTimeUse) { this.time = (Date.now() - this.startTime) / 1000 - this.ctx.uniform1f(this.programInfo.uniforms.time, this.time) + gl.uniform1f(this.programInfo.uniforms.time, this.time) } - if (this.renderHook) { - this.renderHook() + if (this.renderHook) this.renderHook() + gl.drawArrays(gl.TRIANGLES, 0, 6) + + if (usePixelate) { + gl.bindFramebuffer(gl.FRAMEBUFFER, null) + gl.useProgram(this._pxProg) + gl.bindBuffer(gl.ARRAY_BUFFER, this._pxQuad) + const loc = gl.getAttribLocation(this._pxProg!, 'aPos') + gl.enableVertexAttribArray(loc) + gl.vertexAttribPointer(loc, 2, gl.FLOAT, false, 0, 0) + gl.activeTexture(gl.TEXTURE0) + gl.bindTexture(gl.TEXTURE_2D, this._pxTex) + gl.uniform1i(gl.getUniformLocation(this._pxProg!, 'uTex'), 0) + gl.uniform2f(gl.getUniformLocation(this._pxProg!, 'uRes'), this.el.width, this.el.height) + gl.uniform1f(gl.getUniformLocation(this._pxProg!, 'uSize'), this.pixelSize) + gl.drawArrays(gl.TRIANGLES, 0, 6) + gl.useProgram(this.program) + this.initBuffers(this.positions) } - - this.ctx.drawArrays(this.ctx.TRIANGLES, 0, 6) } initBuffers(positions: number[]) { @@ -235,6 +264,56 @@ export default class GL { this.ctx.uniform2fv(this.programInfo.uniforms.resolution, [width * this.pxratio, height * this.pxratio]) this.initBuffers(this.positions) + + if (this._pxTex) { + this.ctx.bindTexture(this.ctx.TEXTURE_2D, this._pxTex) + this.ctx.texImage2D(this.ctx.TEXTURE_2D, 0, this.ctx.RGBA, this.el.width, this.el.height, 0, this.ctx.RGBA, this.ctx.UNSIGNED_BYTE, null) + this.ctx.bindTexture(this.ctx.TEXTURE_2D, null) + } + } + + private _initPixelate() { + const gl = this.ctx + const w = this.el.width + const h = this.el.height + + this._pxTex = gl.createTexture() + gl.bindTexture(gl.TEXTURE_2D, this._pxTex) + 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.NEAREST) + gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.NEAREST) + 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) + + this._pxFbo = gl.createFramebuffer() + gl.bindFramebuffer(gl.FRAMEBUFFER, this._pxFbo) + gl.framebufferTexture2D(gl.FRAMEBUFFER, gl.COLOR_ATTACHMENT0, gl.TEXTURE_2D, this._pxTex, 0) + gl.bindFramebuffer(gl.FRAMEBUFFER, null) + gl.bindTexture(gl.TEXTURE_2D, null) + + const compile = (type: number, src: string) => { + const s = gl.createShader(type)! + gl.shaderSource(s, src) + gl.compileShader(s) + return s + } + this._pxProg = gl.createProgram()! + gl.attachShader(this._pxProg, compile(gl.VERTEX_SHADER, `#version 300 es +in vec2 aPos; out vec2 vUv; +void main() { vUv = aPos * 0.5 + 0.5; gl_Position = vec4(aPos, 0.0, 1.0); }`)) + gl.attachShader(this._pxProg, compile(gl.FRAGMENT_SHADER, `#version 300 es +precision highp float; +uniform sampler2D uTex; uniform vec2 uRes; uniform float uSize; +in vec2 vUv; out vec4 fragColor; +void main() { + vec2 b = floor(gl_FragCoord.xy / uSize) * uSize + uSize * 0.5; + fragColor = texture(uTex, b / uRes); +}`)) + gl.linkProgram(this._pxProg) + + this._pxQuad = gl.createBuffer()! + gl.bindBuffer(gl.ARRAY_BUFFER, this._pxQuad) + gl.bufferData(gl.ARRAY_BUFFER, new Float32Array([-1,-1, 1,-1, -1,1, -1,1, 1,-1, 1,1]), gl.STATIC_DRAW) } createShaderOfType(ctx: ctxType, type: GLenum, source: string) { diff --git a/src/utils/postprocess.ts b/src/utils/postprocess.ts index e93b56e..128af20 100644 --- a/src/utils/postprocess.ts +++ b/src/utils/postprocess.ts @@ -56,6 +56,21 @@ void main() { fragColor = vec4(c.rgb * bright, 1.0); }` +// ─── Pixelation ────────────────────────────────────────────────────────────── +// Quantises screen coordinates to uPixelSize×uPixelSize blocks, sampling from +// the block centre — gives a retro pixel-art look applied before bloom. +const PIXELATE_FRAG = `#version 300 es +precision highp float; +uniform sampler2D uTexture; +uniform vec2 uResolution; +uniform float uPixelSize; // block size in screen pixels (1 = no effect) +in vec2 vUv; +out vec4 fragColor; +void main() { + vec2 block = floor(gl_FragCoord.xy / uPixelSize) * uPixelSize + uPixelSize * 0.5; + fragColor = texture(uTexture, block / uResolution); +}` + // ─── Dual-bloom composite ───────────────────────────────────────────────────── // Combines the original render with two independent bloom layers: // bloom1 — bright tight bloom (enhances perceived saturation / intensity) @@ -91,6 +106,8 @@ export interface PostProcessOptions { intensityB?: number stepMultB?: number thresholdB?: number // luminance threshold before blur: 0 = blur everything, 0.5 = only bright pixels + // Pixelation applied before bloom — 1 = off, higher = larger pixel blocks + pixelSize?: number } export default class PostProcessGL extends GL { @@ -101,9 +118,11 @@ export default class PostProcessGL extends GL { private _blurProgram!: WebGLProgram private _extractProgram!: WebGLProgram + private _pixelateProgram!: WebGLProgram private _compositeProgram!: WebGLProgram private _quadBuffer!: WebGLBuffer private _extractTarget!: RenderTarget + private _pixelateTarget!: RenderTarget ppOptions: Required ppEnabled: boolean @@ -122,6 +141,7 @@ export default class PostProcessGL extends GL { this.ppOptions = { passesA: 1, radiusA: 5, intensityA: 1.2, stepMultA: 1, passesB: 1, radiusB: 0.5, intensityB: 1.2, stepMultB: 1, thresholdB: 0, + pixelSize: 1, ...ppOptions } this._initPostProcess() @@ -169,8 +189,10 @@ export default class PostProcessGL extends GL { this._pongTarget = this._createTarget(w, h) this._extraTarget = this._createTarget(w, h) this._extractTarget = this._createTarget(w, h) + this._pixelateTarget = this._createTarget(w, h) this._blurProgram = this._compilePassProgram(BLUR_FRAG) this._extractProgram = this._compilePassProgram(EXTRACT_FRAG) + this._pixelateProgram = this._compilePassProgram(PIXELATE_FRAG) this._compositeProgram = this._compilePassProgram(COMPOSITE_FRAG) this._quadBuffer = this.ctx.createBuffer()! this.ctx.bindBuffer(this.ctx.ARRAY_BUFFER, this._quadBuffer) @@ -231,6 +253,19 @@ export default class PostProcessGL extends GL { gl.drawArrays(gl.TRIANGLES, 0, 6) } + private _pixelatePass(src: RenderTarget) { + const gl = this.ctx + gl.bindFramebuffer(gl.FRAMEBUFFER, this._pixelateTarget.fbo) + gl.useProgram(this._pixelateProgram) + this._bindQuad(this._pixelateProgram) + gl.activeTexture(gl.TEXTURE0) + gl.bindTexture(gl.TEXTURE_2D, src.texture) + gl.uniform1i(gl.getUniformLocation(this._pixelateProgram, 'uTexture'), 0) + gl.uniform2f(gl.getUniformLocation(this._pixelateProgram, 'uResolution'), this.el.width, this.el.height) + gl.uniform1f(gl.getUniformLocation(this._pixelateProgram, 'uPixelSize'), this.ppOptions.pixelSize) + gl.drawArrays(gl.TRIANGLES, 0, 6) + } + // ─── Resize ───────────────────────────────────────────────────────────────── override resize() { @@ -238,7 +273,7 @@ export default class PostProcessGL extends GL { 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]) { + for (const t of [this._mainTarget, this._pingTarget, this._pongTarget, this._extraTarget, this._extractTarget, this._pixelateTarget]) { 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) } @@ -268,7 +303,7 @@ export default class PostProcessGL extends GL { return } - const { passesA, radiusA, intensityA, stepMultA, passesB, radiusB, intensityB, stepMultB, thresholdB } = this.ppOptions + const { passesA, radiusA, intensityA, stepMultA, passesB, radiusB, intensityB, stepMultB, thresholdB, pixelSize } = this.ppOptions // Pass 1: render main shader to offscreen texture gl.useProgram(this.program) @@ -282,7 +317,12 @@ export default class PostProcessGL extends GL { if (this.renderHook) this.renderHook() gl.drawArrays(gl.TRIANGLES, 0, 6) - const bloomA = this._runBlur(this._mainTarget, passesA, radiusA, false, stepMultA) + // Optional pixelation applied before bloom so the effect covers both base and glow + const renderBase = pixelSize > 1 + ? (this._pixelatePass(this._mainTarget), this._pixelateTarget) + : this._mainTarget + + const bloomA = this._runBlur(renderBase, passesA, radiusA, false, stepMultA) // If thresholdB > 0: extract bright pixels first so bloom B scatters only from light sources const bloomBSrc = thresholdB > 0 @@ -295,7 +335,7 @@ export default class PostProcessGL extends GL { this._bindQuad(this._compositeProgram) gl.activeTexture(gl.TEXTURE0) - gl.bindTexture(gl.TEXTURE_2D, this._mainTarget.texture) + gl.bindTexture(gl.TEXTURE_2D, renderBase.texture) gl.uniform1i(gl.getUniformLocation(this._compositeProgram, 'uBase'), 0) gl.activeTexture(gl.TEXTURE1) diff --git a/src/utils/theme-inputs.ts b/src/utils/theme-inputs.ts index 9388b5e..1dae963 100644 --- a/src/utils/theme-inputs.ts +++ b/src/utils/theme-inputs.ts @@ -83,3 +83,10 @@ export const buildInvertCheckbox = (): AppInputThemeGeneral => ({ type: 'checkbox', value: false }) + +export const buildCheckbox = (name: string, value = false): AppInputThemeGeneral => ({ + name, + label: `input.${name}`, + type: 'checkbox' as const, + value +})