From b994f05a9aa5f60cb9af51f6ef5a923a19b29d88 Mon Sep 17 00:00:00 2001 From: Mike Kocurek Date: Wed, 29 Apr 2026 12:00:58 -0400 Subject: [PATCH 1/3] Scale text resolution with zoom to keep glyphs sharp on zoom-in Pixi rasterizes Text once at the renderer resolution; zooming past 100% magnified the cached texture and produced heavy blur. Tie the Text's resolution to the current zoom so it re-rasterizes at the displayed size. Co-Authored-By: Claude Opus 4.7 --- src/editor/rendering/stage/PixiItemLayer.tsx | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/src/editor/rendering/stage/PixiItemLayer.tsx b/src/editor/rendering/stage/PixiItemLayer.tsx index eb91459..a44060a 100644 --- a/src/editor/rendering/stage/PixiItemLayer.tsx +++ b/src/editor/rendering/stage/PixiItemLayer.tsx @@ -649,8 +649,19 @@ const PixiItemView = memo(function PixiItemView({ let children: React.ReactNode; if (item.kind === 'text') { const { style: textStyle, textX, textY } = buildTextStyleProps(item); + // Pixi rasterizes Text to a texture at `resolution * devicePixelRatio`. + // When zoom > 1 the texture is magnified and turns blurry, so scale + // resolution with zoom to keep glyphs sharp on zoom-in. + const textResolution = Math.max(1, zoom) * (window.devicePixelRatio || 1); children = ( - + ); } else if (item.kind === 'image') { children = ; From 6e85b9f5a31b0b7e630d779f0c0c9c324e2d2923 Mon Sep 17 00:00:00 2001 From: Mike Kocurek Date: Wed, 29 Apr 2026 12:20:04 -0400 Subject: [PATCH 2/3] Render generators and item filters at zoom-aware resolution MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Generators rasterize to a Canvas2D bitmap at canvas dimensions; once the viewport zooms past 100% that bitmap is magnified and the pattern blurs. Render the buffer at a quantized pixel scale (1×/2×/4×) tied to zoom and ctx.scale into it so vector ops stay in logical units. Cap at 4× to bound VRAM. Bands' ctx.filter blur and noise's putImageData both bypass ctx transforms, so compensate explicitly: bands multiplies its blur radius by the current scale, noise samples at physical canvas size. Image adjustment filters get the same offscreen-FBO resolution treatment as the blur/drop-shadow path so color-corrected sprites also stay sharp. Co-Authored-By: Claude Opus 4.7 --- src/editor/generators/bandsGenerator.ts | 6 ++- src/editor/generators/noiseGenerator.ts | 14 +++++-- src/editor/generators/useGeneratorCanvas.ts | 16 ++++++-- src/editor/rendering/stage/PixiItemLayer.tsx | 40 ++++++++++++++++---- 4 files changed, 60 insertions(+), 16 deletions(-) diff --git a/src/editor/generators/bandsGenerator.ts b/src/editor/generators/bandsGenerator.ts index 8cfdc3d..93337fe 100644 --- a/src/editor/generators/bandsGenerator.ts +++ b/src/editor/generators/bandsGenerator.ts @@ -31,7 +31,11 @@ function drawBands( ctx.save(); ctx.fillStyle = color; ctx.globalAlpha = params.stripeGlow; - ctx.filter = `blur(${Math.max(1, thickness * 0.35)}px)`; + // ctx.filter blur is in physical pixels and ignores ctx transforms, + // so multiply by the current x-scale to keep the glow consistent in + // logical units when the surface is rendered at a higher pixel scale. + const physicalScale = ctx.getTransform().a; + ctx.filter = `blur(${Math.max(1, thickness * 0.35) * physicalScale}px)`; ctx.beginPath(); ctx.moveTo(cursor, -span / 2); ctx.lineTo(cursor + thickness, -span / 2); diff --git a/src/editor/generators/noiseGenerator.ts b/src/editor/generators/noiseGenerator.ts index f5fe92d..53252ec 100644 --- a/src/editor/generators/noiseGenerator.ts +++ b/src/editor/generators/noiseGenerator.ts @@ -28,11 +28,17 @@ export const noiseGeneratorSpec: GeneratorSpec = { noiseCanvas = document.createElement('canvas'); noiseCtx = noiseCanvas.getContext('2d'); } - if (noiseCanvas.width !== w) noiseCanvas.width = w; - if (noiseCanvas.height !== h) noiseCanvas.height = h; + // Generate noise at the destination's physical pixel size so each output + // pixel has its own random sample. Without this, a logical-size noise + // canvas drawn into a scaled context produces blocky upscaled noise. + const t = ctx.getTransform(); + const physW = Math.max(1, Math.round(w * t.a)); + const physH = Math.max(1, Math.round(h * t.d)); + if (noiseCanvas.width !== physW) noiseCanvas.width = physW; + if (noiseCanvas.height !== physH) noiseCanvas.height = physH; if (!noiseCtx) return; - const image = noiseCtx.createImageData(w, h); + const image = noiseCtx.createImageData(physW, physH); const data = image.data; const alpha = Math.round(255 * params.intensity); for (let i = 0; i < data.length; i += 4) { @@ -45,6 +51,8 @@ export const noiseGeneratorSpec: GeneratorSpec = { noiseCtx.putImageData(image, 0, 0); ctx.save(); ctx.globalCompositeOperation = 'overlay'; + // Bypass the caller's scale transform so noise lands 1:1 in physical pixels. + ctx.setTransform(1, 0, 0, 1, 0, 0); ctx.drawImage(noiseCanvas, 0, 0); ctx.restore(); }, diff --git a/src/editor/generators/useGeneratorCanvas.ts b/src/editor/generators/useGeneratorCanvas.ts index a9624f8..fdb6ee2 100644 --- a/src/editor/generators/useGeneratorCanvas.ts +++ b/src/editor/generators/useGeneratorCanvas.ts @@ -8,12 +8,14 @@ export function useGeneratorCanvas( item: GeneratorCanvasItem, canvasWidth: number, canvasHeight: number, + pixelScale: number = 1, ): HTMLCanvasElement | null { const canvasRef = useRef(null); const prevParamsRef = useRef(null); const prevWidthRef = useRef(0); const prevHeightRef = useRef(0); const prevSeedRef = useRef(0); + const prevPixelScaleRef = useRef(0); const [, forceRender] = useReducer((x: number) => x + 1, 0); useEffect(() => { @@ -21,7 +23,8 @@ export function useGeneratorCanvas( prevParamsRef.current === item.generatorParams && prevWidthRef.current === canvasWidth && prevHeightRef.current === canvasHeight && - prevSeedRef.current === item.seed + prevSeedRef.current === item.seed && + prevPixelScaleRef.current === pixelScale ) { return; } @@ -30,6 +33,7 @@ export function useGeneratorCanvas( prevWidthRef.current = canvasWidth; prevHeightRef.current = canvasHeight; prevSeedRef.current = item.seed; + prevPixelScaleRef.current = pixelScale; const spec = getGenerator(item.generatorParams.generatorType); if (!spec) return; @@ -39,17 +43,21 @@ export function useGeneratorCanvas( } const canvas = canvasRef.current; - canvas.width = canvasWidth; - canvas.height = canvasHeight; + // Render at higher physical resolution so the bitmap stays sharp when + // the editor zooms past 100%. Generators draw in logical units (canvasW + // × canvasH); ctx.scale lifts that into physical pixels. + canvas.width = Math.max(1, Math.round(canvasWidth * pixelScale)); + canvas.height = Math.max(1, Math.round(canvasHeight * pixelScale)); const ctx = canvas.getContext('2d'); if (!ctx) return; + ctx.setTransform(pixelScale, 0, 0, pixelScale, 0, 0); ctx.clearRect(0, 0, canvasWidth, canvasHeight); spec.draw(ctx, canvasWidth, canvasHeight, item.generatorParams, item.seed); forceRender(); - }, [item.generatorParams, item.seed, canvasWidth, canvasHeight]); + }, [item.generatorParams, item.seed, canvasWidth, canvasHeight, pixelScale]); return canvasRef.current; } diff --git a/src/editor/rendering/stage/PixiItemLayer.tsx b/src/editor/rendering/stage/PixiItemLayer.tsx index a44060a..6b7db5d 100644 --- a/src/editor/rendering/stage/PixiItemLayer.tsx +++ b/src/editor/rendering/stage/PixiItemLayer.tsx @@ -301,7 +301,7 @@ function buildImageAdjustmentFilters( // Image content component (uses hooks for async image loading + masking) // --------------------------------------------------------------------------- -export function PixiImageContent({ item }: { item: ImageCanvasItem }) { +export function PixiImageContent({ item, zoom = 1 }: { item: ImageCanvasItem; zoom?: number }) { const imageElement = useImageElement(item.src); const [maskNode, setMaskNode] = useState(null); @@ -321,10 +321,15 @@ export function PixiImageContent({ item }: { item: ImageCanvasItem }) { [imageElement], ); - const adjustmentFilters = useMemo( - () => buildImageAdjustmentFilters(item.adjustments), - [item.adjustments], - ); + const adjustmentFilters = useMemo(() => { + const filters = buildImageAdjustmentFilters(item.adjustments); + if (!filters) return undefined; + // Match filter resolution to displayed size so color-corrected pixels + // stay sharp on zoom-in (filters render through an offscreen FBO). + const filterResolution = Math.max(1, zoom) * (window.devicePixelRatio || 1); + for (const f of filters) f.resolution = filterResolution; + return filters; + }, [item.adjustments, zoom]); const drawClipMask = useCallback( (g: Graphics) => { @@ -368,16 +373,28 @@ export function PixiImageContent({ item }: { item: ImageCanvasItem }) { // Generator content component (renders full-canvas pattern via Canvas2D texture) // --------------------------------------------------------------------------- +// Quantize the zoom-driven pixel scale to powers of two so we don't +// re-rasterize the generator on every zoom tick. Caps at 4× to bound +// memory (a 2048² canvas at 4× is 8192² — ~256 MB at RGBA8). +function quantizeGeneratorPixelScale(zoom: number): number { + if (zoom <= 1) return 1; + if (zoom <= 2) return 2; + return 4; +} + function PixiGeneratorContent({ item, canvasWidth, canvasHeight, + zoom, }: { item: GeneratorCanvasItem; canvasWidth: number; canvasHeight: number; + zoom: number; }) { - const generatorCanvas = useGeneratorCanvas(item, canvasWidth, canvasHeight); + const pixelScale = quantizeGeneratorPixelScale(zoom); + const generatorCanvas = useGeneratorCanvas(item, canvasWidth, canvasHeight, pixelScale); const texture = useMemo( () => (generatorCanvas ? Texture.from(generatorCanvas) : Texture.EMPTY), @@ -389,7 +406,7 @@ function PixiGeneratorContent({ if (texture !== Texture.EMPTY) { texture.source.update(); } - }, [texture, item.generatorParams, item.seed, canvasWidth, canvasHeight]); + }, [texture, item.generatorParams, item.seed, canvasWidth, canvasHeight, pixelScale]); if (!generatorCanvas) return null; @@ -579,6 +596,12 @@ const PixiItemView = memo(function PixiItemView({ })); } } + // Filters render their input to an offscreen texture at filter.resolution + // (default = renderer resolution). When zoom > 1 that texture is magnified + // and the result blurs. Match the resolution to the displayed size so + // filtered content stays sharp on zoom-in. + const filterResolution = Math.max(1, zoom) * (window.devicePixelRatio || 1); + for (const f of filters) f.resolution = filterResolution; return filters.length > 0 ? filters : undefined; }, [item.blurRadius, item.shadow, item.kind, zoom]); @@ -640,6 +663,7 @@ const PixiItemView = memo(function PixiItemView({ item={item as GeneratorCanvasItem} canvasWidth={canvasWidth} canvasHeight={canvasHeight} + zoom={zoom} /> ); @@ -664,7 +688,7 @@ const PixiItemView = memo(function PixiItemView({ /> ); } else if (item.kind === 'image') { - children = ; + children = ; } else { children = ; } From 1d64167ed518945449834a4d58afc6cb508e9a77 Mon Sep 17 00:00:00 2001 From: Mike Kocurek Date: Wed, 29 Apr 2026 13:12:34 -0400 Subject: [PATCH 3/3] Fix edge artifacts on blurred and shadowed items MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three compounding bugs surface when blur radius or zoom is high: 1. Pixi's Gaussian BlurFilter samples raw UVs without clamping to the input frame. TexturePool reuses power-of-two textures across frames, so the area between the frame and po2 boundary keeps stale pixels from previous use. The blur kernel pulls those bright values in → zoom/blur-dependent edge artifacts that vary on text edits as the pool returns different textures (the hysteresis was the smoking gun). 2. FilterSystem clips the item's bounds to the viewport rect *before* adding padding. Panning a heavily-blurred item near a viewport edge crops the FBO on that side; the kernel hits clamp-to-edge there, producing a bright line that moves with screen position. 3. Auto-padding is `strength * 2`, but Pixi's optimized 4-pass kernel reaches ~5× strength. The Gaussian tail clips at the FBO boundary, leaving sharp cutoffs around the blur halo. Fixes: - New ClampingBlurFilter wraps Pixi's BlurFilter with shaders that clamp every sample to uInputClamp, so reads stay inside the active frame regardless of pool reuse. - Set clipToViewport=false on blur/shadow filters to keep the FBO at full (item + padding) regardless of pan position. - Bump padding to 6× strength (blur) and offset + blur*6 + 32 (shadow) so the falloff completes before the frame edge. Image color-adjustment filter resolution is also capped at 2× to keep the offscreen FBO within the GPU's max texture size at extreme zoom. Co-Authored-By: Claude Opus 4.7 --- src/editor/rendering/stage/PixiItemLayer.tsx | 54 +++-- .../rendering/stage/clampingBlurFilter.ts | 198 ++++++++++++++++++ 2 files changed, 238 insertions(+), 14 deletions(-) create mode 100644 src/editor/rendering/stage/clampingBlurFilter.ts diff --git a/src/editor/rendering/stage/PixiItemLayer.tsx b/src/editor/rendering/stage/PixiItemLayer.tsx index 6b7db5d..31acc59 100644 --- a/src/editor/rendering/stage/PixiItemLayer.tsx +++ b/src/editor/rendering/stage/PixiItemLayer.tsx @@ -1,8 +1,10 @@ import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react'; -import { BlurFilter, ColorMatrixFilter, FillGradient, Graphics, Polygon, Rectangle, Texture } from 'pixi.js'; +import { ColorMatrixFilter, FillGradient, Graphics, Polygon, Rectangle, Texture } from 'pixi.js'; import type { FederatedPointerEvent, Filter } from 'pixi.js'; import { DropShadowFilter } from 'pixi-filters'; +import { ClampingBlurFilter } from './clampingBlurFilter'; + import type { CanvasItem, CanvasTool, @@ -325,8 +327,10 @@ export function PixiImageContent({ item, zoom = 1 }: { item: ImageCanvasItem; zo const filters = buildImageAdjustmentFilters(item.adjustments); if (!filters) return undefined; // Match filter resolution to displayed size so color-corrected pixels - // stay sharp on zoom-in (filters render through an offscreen FBO). - const filterResolution = Math.max(1, zoom) * (window.devicePixelRatio || 1); + // stay sharp on zoom-in. Cap at 2× so the FBO doesn't exceed the GPU's + // max texture size on large items at extreme zoom (clipping shows up as + // rectangular edges on filtered output). + const filterResolution = Math.min(2, Math.max(1, zoom)) * (window.devicePixelRatio || 1); for (const f of filters) f.resolution = filterResolution; return filters; }, [item.adjustments, zoom]); @@ -581,27 +585,49 @@ const PixiItemView = memo(function PixiItemView({ const itemFilters = useMemo(() => { const filters: Filter[] = []; if (item.blurRadius > 0) { - filters.push(new BlurFilter({ strength: item.blurRadius * zoom })); + const strength = item.blurRadius * zoom; + // ClampingBlurFilter is a Gaussian BlurFilter with shaders that clamp + // sample UVs to the input texture's active frame (uInputClamp). + // Pixi's stock BlurFilter samples raw UVs; TexturePool reuses power- + // of-two textures whose pixels between the frame and po2 boundary + // keep stale data from previous use, and the blur kernel pulls those + // bright values into the result. Clamping eliminates that read. + const blurFilter = new ClampingBlurFilter({ + strength, + clipToViewport: false, + }); + // With clamping in place, padding determines where the visible blur + // halo ends. Pixi's auto-padding (strength*2) cuts the Gaussian tail + // off mid-falloff; the optimized 4-pass kernel reaches ~5× strength, + // so pad to 6× for a smooth fadeout. + blurFilter.padding = strength * 6; + filters.push(blurFilter); } if (item.kind !== 'text') { const s = item.shadow; const hasShadow = s && (s.blur > 0 || s.offsetX !== 0 || s.offsetY !== 0) && s.opacity > 0; if (hasShadow) { - filters.push(new DropShadowFilter({ + const shadowBlur = (s.blur / 2) * zoom; + const shadowOffsetX = s.offsetX * zoom; + const shadowOffsetY = s.offsetY * zoom; + const shadowFilter = new DropShadowFilter({ color: s.color, alpha: s.opacity, - blur: (s.blur / 2) * zoom, - offset: { x: s.offsetX * zoom, y: s.offsetY * zoom }, + blur: shadowBlur, + offset: { x: shadowOffsetX, y: shadowOffsetY }, quality: 8, - })); + }); + shadowFilter.clipToViewport = false; + // DropShadowFilter wraps a Kawase blur whose reach scales with + // blur × quality. Auto-padding (offset + blur*2 + quality*4) clips + // the tail; extend it the same way as BlurFilter. + shadowFilter.padding = + Math.max(Math.abs(shadowOffsetX), Math.abs(shadowOffsetY)) + + shadowBlur * 6 + + 32; + filters.push(shadowFilter); } } - // Filters render their input to an offscreen texture at filter.resolution - // (default = renderer resolution). When zoom > 1 that texture is magnified - // and the result blurs. Match the resolution to the displayed size so - // filtered content stays sharp on zoom-in. - const filterResolution = Math.max(1, zoom) * (window.devicePixelRatio || 1); - for (const f of filters) f.resolution = filterResolution; return filters.length > 0 ? filters : undefined; }, [item.blurRadius, item.shadow, item.kind, zoom]); diff --git a/src/editor/rendering/stage/clampingBlurFilter.ts b/src/editor/rendering/stage/clampingBlurFilter.ts new file mode 100644 index 0000000..1ba52ee --- /dev/null +++ b/src/editor/rendering/stage/clampingBlurFilter.ts @@ -0,0 +1,198 @@ +import { BlurFilter, type BlurFilterOptions, GlProgram, GpuProgram } from 'pixi.js'; + +// Gaussian kernel weights (matches Pixi's internal GAUSSIAN_VALUES so we can +// be a drop-in replacement for BlurFilter at the same kernelSize). +const GAUSSIAN_VALUES: Record = { + 5: [0.153388, 0.221461, 0.250301], + 7: [0.071303, 0.131514, 0.189879, 0.214607], + 9: [0.028532, 0.067234, 0.124009, 0.179044, 0.20236], + 11: [0.0093, 0.028002, 0.065984, 0.121703, 0.175713, 0.198596], + 13: [0.002406, 0.009255, 0.027867, 0.065666, 0.121117, 0.174868, 0.197641], + 15: [0.000489, 0.002403, 0.009246, 0.02784, 0.065602, 0.120999, 0.174697, 0.197448], +}; + +// Pixi's BlurFilter samples raw UVs without clamping. TexturePool returns +// power-of-two textures and reuses them across frames; the area between the +// active frame and the po2 boundary keeps stale pixels from previous use. +// At larger blur strengths the kernel reaches into that dirty padding and +// pulls in bright values, producing zoom/blur-dependent edge artifacts that +// vary on text edits as the pool hands back different textures. Clamping +// every sample to uInputClamp keeps reads inside the active frame. + +function generateClampingWgsl(horizontal: boolean, kernelSize: number): string { + const kernel = GAUSSIAN_VALUES[kernelSize]; + const halfLength = kernel.length; + const dimension = horizontal ? 'z' : 'w'; + + const blurStruct = Array.from({ length: kernelSize }, (_, i) => + ` @location(${i}) offset${i}: vec2,`, + ).join('\n'); + + const blurVertexOut = Array.from({ length: kernelSize }, (_, i) => { + const sampleIndex = i - halfLength + 1; + const offset = horizontal + ? `vec2(${sampleIndex.toFixed(1)} * pixelStrength, 0.0)` + : `vec2(0.0, ${sampleIndex.toFixed(1)} * pixelStrength)`; + return ` filteredCord + ${offset},`; + }).join('\n'); + + const blurSampling = Array.from({ length: kernelSize }, (_, i) => { + const kernelIndex = i < halfLength ? i : kernelSize - i - 1; + const weight = kernel[kernelIndex].toString(); + return ` finalColor += textureSample(uTexture, uSampler, clamp(offset${i}, gfu.uInputClamp.xy, gfu.uInputClamp.zw)) * ${weight};`; + }).join('\n'); + + return ` +struct GlobalFilterUniforms { + uInputSize: vec4, + uInputPixel: vec4, + uInputClamp: vec4, + uOutputFrame: vec4, + uGlobalFrame: vec4, + uOutputTexture: vec4, +}; + +struct BlurUniforms { + uStrength: f32, +}; + +@group(0) @binding(0) var gfu: GlobalFilterUniforms; +@group(0) @binding(1) var uTexture: texture_2d; +@group(0) @binding(2) var uSampler: sampler; +@group(1) @binding(0) var blurUniforms: BlurUniforms; + +struct VSOutput { + @builtin(position) position: vec4, +${blurStruct} +}; + +fn filterVertexPosition(aPosition: vec2) -> vec4 { + var position = aPosition * gfu.uOutputFrame.zw + gfu.uOutputFrame.xy; + position.x = position.x * (2.0 / gfu.uOutputTexture.x) - 1.0; + position.y = position.y * (2.0 * gfu.uOutputTexture.z / gfu.uOutputTexture.y) - gfu.uOutputTexture.z; + return vec4(position, 0.0, 1.0); +} + +fn filterTextureCoord(aPosition: vec2) -> vec2 { + return aPosition * (gfu.uOutputFrame.zw * gfu.uInputSize.zw); +} + +@vertex +fn mainVertex(@location(0) aPosition: vec2) -> VSOutput { + let filteredCord = filterTextureCoord(aPosition); + let pixelStrength = gfu.uInputSize.${dimension} * blurUniforms.uStrength; + return VSOutput( + filterVertexPosition(aPosition), +${blurVertexOut} + ); +} + +@fragment +fn mainFragment( + @builtin(position) position: vec4, +${blurStruct} +) -> @location(0) vec4 { + var finalColor = vec4(0.0); +${blurSampling} + return finalColor; +} +`; +} + +function generateClampingGlVertex(horizontal: boolean, kernelSize: number): string { + const halfLength = Math.ceil(kernelSize / 2); + const dimension = horizontal ? 'z' : 'w'; + const offsets = Array.from({ length: kernelSize }, (_, i) => { + const sampleIndex = (i - (halfLength - 1)).toFixed(1); + const offset = horizontal + ? `vec2(${sampleIndex} * pixelStrength, 0.0)` + : `vec2(0.0, ${sampleIndex} * pixelStrength)`; + return ` vBlurTexCoords[${i}] = textureCoord + ${offset};`; + }).join('\n'); + + return `in vec2 aPosition; +uniform float uStrength; +out vec2 vBlurTexCoords[${kernelSize}]; + +uniform vec4 uInputSize; +uniform vec4 uOutputFrame; +uniform vec4 uOutputTexture; + +vec4 filterVertexPosition(void) { + vec2 position = aPosition * uOutputFrame.zw + uOutputFrame.xy; + position.x = position.x * (2.0 / uOutputTexture.x) - 1.0; + position.y = position.y * (2.0 * uOutputTexture.z / uOutputTexture.y) - uOutputTexture.z; + return vec4(position, 0.0, 1.0); +} + +vec2 filterTextureCoord(void) { + return aPosition * (uOutputFrame.zw * uInputSize.zw); +} + +void main(void) { + gl_Position = filterVertexPosition(); + float pixelStrength = uInputSize.${dimension} * uStrength; + vec2 textureCoord = filterTextureCoord(); +${offsets} +}`; +} + +function generateClampingGlFragment(kernelSize: number): string { + const kernel = GAUSSIAN_VALUES[kernelSize]; + const halfLength = kernel.length; + const samples = Array.from({ length: kernelSize }, (_, i) => { + const prefix = i === 0 ? 'finalColor = ' : ' + '; + const kernelIndex = i < halfLength ? i : kernelSize - i - 1; + const weight = kernel[kernelIndex].toString(); + return `${prefix}texture(uTexture, clamp(vBlurTexCoords[${i}], uInputClamp.xy, uInputClamp.zw)) * ${weight}`; + }).join('\n'); + + return `in vec2 vBlurTexCoords[${kernelSize}]; +uniform sampler2D uTexture; +uniform vec4 uInputClamp; +out vec4 finalColor; + +void main(void) { +${samples}; +}`; +} + +function generateClampingPrograms( + horizontal: boolean, + kernelSize: number, +): { gpuProgram: GpuProgram; glProgram: GlProgram } { + const wgsl = generateClampingWgsl(horizontal, kernelSize); + const gpuProgram = GpuProgram.from({ + vertex: { source: wgsl, entryPoint: 'mainVertex' }, + fragment: { source: wgsl, entryPoint: 'mainFragment' }, + }); + const glProgram = GlProgram.from({ + vertex: generateClampingGlVertex(horizontal, kernelSize), + fragment: generateClampingGlFragment(kernelSize), + name: `clamping-blur-${horizontal ? 'horizontal' : 'vertical'}`, + }); + return { gpuProgram, glProgram }; +} + +interface BlurSubFilter { + gpuProgram: GpuProgram; + glProgram: GlProgram; +} + +export class ClampingBlurFilter extends BlurFilter { + constructor(options?: BlurFilterOptions) { + super(options); + const kernelSize = options?.kernelSize ?? 5; + if (!GAUSSIAN_VALUES[kernelSize]) { + throw new Error(`ClampingBlurFilter: unsupported kernelSize ${kernelSize}`); + } + const xPrograms = generateClampingPrograms(true, kernelSize); + const yPrograms = generateClampingPrograms(false, kernelSize); + const blurX = this.blurXFilter as unknown as BlurSubFilter; + const blurY = this.blurYFilter as unknown as BlurSubFilter; + blurX.gpuProgram = xPrograms.gpuProgram; + blurX.glProgram = xPrograms.glProgram; + blurY.gpuProgram = yPrograms.gpuProgram; + blurY.glProgram = yPrograms.glProgram; + } +}