diff --git a/examples/common/installShaders.ts b/examples/common/installShaders.ts index ee2ae7b..fc3970b 100644 --- a/examples/common/installShaders.ts +++ b/examples/common/installShaders.ts @@ -26,4 +26,5 @@ export async function installShaders(stage: Stage, renderMode: string) { stage.shManager.registerShaderType('RadialGradient', shaders.RadialGradient); stage.shManager.registerShaderType('LinearGradient', shaders.LinearGradient); stage.shManager.registerShaderType('RadialProgress', shaders.RadialProgress); + stage.shManager.registerShaderType('Blur', shaders.Blur); } diff --git a/examples/tests/shader-blur.ts b/examples/tests/shader-blur.ts new file mode 100644 index 0000000..3f4e789 --- /dev/null +++ b/examples/tests/shader-blur.ts @@ -0,0 +1,29 @@ +import rockoImg from '../assets/rocko.png'; +import type { ExampleSettings } from '../common/ExampleSettings.js'; + +export async function automation(settings: ExampleSettings) { + await test(settings); + await settings.snapshot(); +} + +export default async function test({ renderer, testRoot }: ExampleSettings) { + const amounts = [0, 2, 6, 12]; + const size = 300; + const gap = 20; + + for (let i = 0; i < amounts.length; i++) { + renderer.createNode({ + x: 20 + i * (size + gap), + y: 20, + w: size, + h: size, + texture: renderer.createTexture('ImageTexture', { + src: rockoImg, + }), + shader: renderer.createShader('Blur', { + amount: amounts[i], + }), + parent: testRoot, + }); + } +} diff --git a/exports/canvas-shaders.ts b/exports/canvas-shaders.ts index 9544802..3c22fdf 100644 --- a/exports/canvas-shaders.ts +++ b/exports/canvas-shaders.ts @@ -10,3 +10,4 @@ export { HolePunch } from '../src/core/shaders/canvas/HolePunch.js'; export { LinearGradient } from '../src/core/shaders/canvas/LinearGradient.js'; export { RadialGradient } from '../src/core/shaders/canvas/RadialGradient.js'; export { RadialProgress } from '../src/core/shaders/canvas/RadialProgress.js'; +export { Blur } from '../src/core/shaders/canvas/Blur.js'; diff --git a/exports/webgl-shaders.ts b/exports/webgl-shaders.ts index 5616991..f75cf44 100644 --- a/exports/webgl-shaders.ts +++ b/exports/webgl-shaders.ts @@ -10,4 +10,5 @@ export { HolePunch } from '../src/core/shaders/webgl/HolePunch.js'; export { LinearGradient } from '../src/core/shaders/webgl/LinearGradient.js'; export { RadialGradient } from '../src/core/shaders/webgl/RadialGradient.js'; export { RadialProgress } from '../src/core/shaders/webgl/RadialProgress.js'; +export { Blur } from '../src/core/shaders/webgl/Blur.js'; export { Default } from '../src/core/shaders/webgl/Default.js'; diff --git a/src/core/shaders/canvas/Blur.ts b/src/core/shaders/canvas/Blur.ts new file mode 100644 index 0000000..da6819e --- /dev/null +++ b/src/core/shaders/canvas/Blur.ts @@ -0,0 +1,23 @@ +import type { CanvasShaderType } from '../../renderers/canvas/CanvasShaderNode.js'; +import { BlurTemplate, type BlurProps } from '../templates/BlurTemplate.js'; + +export interface ComputedBlurValues { + filter: string; +} + +/** + * Canvas2D Blur backed by the native `ctx.filter = 'blur(Npx)'`. Browsers that + * lack filter support (Chrome 38-52) will silently no-op — accept the + * degradation rather than emulating a multi-pass blur in JS. + */ +export const Blur: CanvasShaderType = { + props: BlurTemplate.props, + saveAndRestore: true, + update() { + this.computed.filter = `blur(${this.props!.amount}px)`; + }, + render(ctx, _node, renderContext) { + ctx.filter = this.computed.filter!; + renderContext(); + }, +}; diff --git a/src/core/shaders/templates/BlurTemplate.test.ts b/src/core/shaders/templates/BlurTemplate.test.ts new file mode 100644 index 0000000..03bda60 --- /dev/null +++ b/src/core/shaders/templates/BlurTemplate.test.ts @@ -0,0 +1,23 @@ +import { describe, it, expect } from 'vitest'; +import { BlurTemplate, type BlurProps } from './BlurTemplate.js'; +import { resolveShaderProps } from '../../renderers/CoreShaderNode.js'; + +function resolve(input: Partial): BlurProps { + const props = { ...input } as Record; + resolveShaderProps(props, BlurTemplate.props as never); + return props as unknown as BlurProps; +} + +describe('BlurTemplate', () => { + it('applies default amount when omitted', () => { + expect(resolve({}).amount).toBe(4); + }); + + it('passes through user-provided amount', () => { + expect(resolve({ amount: 10 }).amount).toBe(10); + }); + + it('passes through zero (no blur)', () => { + expect(resolve({ amount: 0 }).amount).toBe(0); + }); +}); diff --git a/src/core/shaders/templates/BlurTemplate.ts b/src/core/shaders/templates/BlurTemplate.ts new file mode 100644 index 0000000..b3b5f70 --- /dev/null +++ b/src/core/shaders/templates/BlurTemplate.ts @@ -0,0 +1,25 @@ +import type { CoreShaderType } from '../../renderers/CoreShaderNode.js'; + +/** + * Properties of the {@link Blur} shader. + * + * Intended for nodes with an Image Texture. Applying it to color-only or text + * nodes will just blur the solid fill. + */ +export interface BlurProps { + /** + * Blur amount in node-space pixels. + * + * Single-pass kernel — small values (1-8) look best. Larger values still + * work but lose smoothness because there are only 9 samples per pixel. + * + * @default 4 + */ + amount: number; +} + +export const BlurTemplate: CoreShaderType = { + props: { + amount: 4, + }, +}; diff --git a/src/core/shaders/webgl/Blur.ts b/src/core/shaders/webgl/Blur.ts new file mode 100644 index 0000000..9d035e9 --- /dev/null +++ b/src/core/shaders/webgl/Blur.ts @@ -0,0 +1,47 @@ +import type { WebGlShaderType } from '../../renderers/webgl/WebGlShaderNode.js'; +import { BlurTemplate, type BlurProps } from '../templates/BlurTemplate.js'; + +/** + * Single-pass 3x3 Gaussian-approximation blur (1,2,1 / 2,4,2 / 1,2,1) / 16. + * + * 9 texture fetches, no second pass, no render target — designed for Image + * Textures on constrained devices. Larger blurs trade smoothness for speed; + * if a higher-quality blur is needed, stack multiple nodes or do a separable + * pass via a RenderTexture. + */ +export const Blur: WebGlShaderType = { + props: BlurTemplate.props, + update() { + this.uniform1f('u_amount', this.props!.amount); + }, + fragment: ` + # ifdef GL_FRAGMENT_PRECISION_HIGH + precision highp float; + # else + precision mediump float; + # endif + + uniform vec2 u_dimensions; + uniform sampler2D u_texture; + uniform float u_amount; + + varying vec4 v_color; + varying vec2 v_textureCoords; + + void main() { + vec2 px = vec2(u_amount) / u_dimensions; + + vec4 c = texture2D(u_texture, v_textureCoords) * 0.25; + c += texture2D(u_texture, v_textureCoords + vec2( px.x, 0.0)) * 0.125; + c += texture2D(u_texture, v_textureCoords + vec2(-px.x, 0.0)) * 0.125; + c += texture2D(u_texture, v_textureCoords + vec2(0.0, px.y)) * 0.125; + c += texture2D(u_texture, v_textureCoords + vec2(0.0, -px.y)) * 0.125; + c += texture2D(u_texture, v_textureCoords + vec2( px.x, px.y)) * 0.0625; + c += texture2D(u_texture, v_textureCoords + vec2(-px.x, -px.y)) * 0.0625; + c += texture2D(u_texture, v_textureCoords + vec2( px.x, -px.y)) * 0.0625; + c += texture2D(u_texture, v_textureCoords + vec2(-px.x, px.y)) * 0.0625; + + gl_FragColor = c * v_color; + } + `, +}; diff --git a/visual-regression/certified-snapshots/chromium-ci/shader-blur-1.png b/visual-regression/certified-snapshots/chromium-ci/shader-blur-1.png new file mode 100644 index 0000000..5e3c774 Binary files /dev/null and b/visual-regression/certified-snapshots/chromium-ci/shader-blur-1.png differ