diff --git a/examples/common/installShaders.ts b/examples/common/installShaders.ts index fc3970b..3c02335 100644 --- a/examples/common/installShaders.ts +++ b/examples/common/installShaders.ts @@ -25,6 +25,7 @@ export async function installShaders(stage: Stage, renderMode: string) { stage.shManager.registerShaderType('HolePunch', shaders.HolePunch); stage.shManager.registerShaderType('RadialGradient', shaders.RadialGradient); stage.shManager.registerShaderType('LinearGradient', shaders.LinearGradient); + stage.shManager.registerShaderType('EdgeFade', shaders.EdgeFade); stage.shManager.registerShaderType('RadialProgress', shaders.RadialProgress); stage.shManager.registerShaderType('Blur', shaders.Blur); } diff --git a/examples/tests/shader-edge-fade.ts b/examples/tests/shader-edge-fade.ts new file mode 100644 index 0000000..dfc09e0 --- /dev/null +++ b/examples/tests/shader-edge-fade.ts @@ -0,0 +1,99 @@ +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) { + // Checkerboard background so the fade provably reveals what is behind the + // faded nodes instead of just darkening them. + const tile = 100; + for (let row = 0; row < 8; row++) { + for (let col = 0; col < 13; col++) { + renderer.createNode({ + x: col * tile, + y: row * tile, + w: tile, + h: tile, + color: (row + col) % 2 === 0 ? 0x113355ff : 0xddaa33ff, + parent: testRoot, + }); + } + } + + // 1. The hero use case: image fading out to the right + renderer.createNode({ + x: 20, + y: 20, + w: 540, + h: 300, + src: rockoImg, + shader: renderer.createShader('EdgeFade', { right: 220 }), + parent: testRoot, + }); + + // 2. Image fading on all four edges (vignette) + renderer.createNode({ + x: 620, + y: 20, + w: 400, + h: 300, + src: rockoImg, + shader: renderer.createShader('EdgeFade', { + left: 80, + top: 80, + right: 80, + bottom: 80, + }), + parent: testRoot, + }); + + // 3. Color rect (no texture) fading left and right + renderer.createNode({ + x: 20, + y: 380, + w: 540, + h: 160, + color: 0xff0000ff, + shader: renderer.createShader('EdgeFade', { left: 150, right: 150 }), + parent: testRoot, + }); + + // 4. All-zero fades must render identically to the default shader + renderer.createNode({ + x: 620, + y: 380, + w: 400, + h: 160, + color: 0x00ff00ff, + shader: renderer.createShader('EdgeFade'), + parent: testRoot, + }); + + // 5. Composes with corner colors and node alpha + renderer.createNode({ + x: 20, + y: 600, + w: 540, + h: 160, + src: rockoImg, + alpha: 0.6, + colorTop: 0xffffffff, + colorBottom: 0xff66ffff, + shader: renderer.createShader('EdgeFade', { right: 270 }), + parent: testRoot, + }); + + // 6. Fade distance larger than the node: never reaches full opacity + renderer.createNode({ + x: 620, + y: 600, + w: 400, + h: 160, + color: 0x0000ffff, + shader: renderer.createShader('EdgeFade', { right: 800 }), + parent: testRoot, + }); +} diff --git a/exports/canvas-shaders.ts b/exports/canvas-shaders.ts index 3c22fdf..d28895a 100644 --- a/exports/canvas-shaders.ts +++ b/exports/canvas-shaders.ts @@ -8,6 +8,7 @@ export { Border } from '../src/core/shaders/canvas/Border.js'; export { Shadow } from '../src/core/shaders/canvas/Shadow.js'; export { HolePunch } from '../src/core/shaders/canvas/HolePunch.js'; export { LinearGradient } from '../src/core/shaders/canvas/LinearGradient.js'; +export { EdgeFade } from '../src/core/shaders/canvas/EdgeFade.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/index.ts b/exports/index.ts index 745fb12..9886c7b 100644 --- a/exports/index.ts +++ b/exports/index.ts @@ -46,6 +46,7 @@ export * from '../src/core/shaders/templates/HolePunchTemplate.js'; export * from '../src/core/shaders/templates/RoundedTemplate.js'; export * from '../src/core/shaders/templates/ShadowTemplate.js'; export * from '../src/core/shaders/templates/LinearGradientTemplate.js'; +export * from '../src/core/shaders/templates/EdgeFadeTemplate.js'; export * from '../src/core/shaders/templates/RadialGradientTemplate.js'; // Shaders diff --git a/exports/webgl-shaders.ts b/exports/webgl-shaders.ts index f75cf44..502a8e8 100644 --- a/exports/webgl-shaders.ts +++ b/exports/webgl-shaders.ts @@ -8,6 +8,7 @@ export { Border } from '../src/core/shaders/webgl/Border.js'; export { Shadow } from '../src/core/shaders/webgl/Shadow.js'; export { HolePunch } from '../src/core/shaders/webgl/HolePunch.js'; export { LinearGradient } from '../src/core/shaders/webgl/LinearGradient.js'; +export { EdgeFade } from '../src/core/shaders/webgl/EdgeFade.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'; diff --git a/src/core/renderers/canvas/CanvasRenderer.ts b/src/core/renderers/canvas/CanvasRenderer.ts index c995611..4905e4f 100644 --- a/src/core/renderers/canvas/CanvasRenderer.ts +++ b/src/core/renderers/canvas/CanvasRenderer.ts @@ -260,6 +260,26 @@ export class CanvasRenderer extends CoreRenderer { } } + /** + * Renders a node's content (texture or color rect) into an arbitrary 2D + * context instead of the main canvas. Used by shaders that need to + * composite the node's content offscreen (e.g. EdgeFade alpha masks). + * + * The content is drawn at the node's global position — the caller is + * responsible for setting a transform on `target` that maps it to the + * desired location. + */ + renderNodeContent(node: CoreNode, target: CanvasRenderingContext2D): void { + let texture = node.props.texture; + if (node.placeholderActive === true || texture === null) { + texture = this.stage.defaultTexture as Texture; + } + const prev = this.context; + this.context = target; + this.renderContext(node, texture); + this.context = prev; + } + createShaderNode( shaderKey: string, shaderType: Readonly, diff --git a/src/core/shaders/canvas/EdgeFade.ts b/src/core/shaders/canvas/EdgeFade.ts new file mode 100644 index 0000000..9a21812 --- /dev/null +++ b/src/core/shaders/canvas/EdgeFade.ts @@ -0,0 +1,105 @@ +import type { CanvasShaderType } from '../../renderers/canvas/CanvasShaderNode.js'; +import type { CanvasRenderer } from '../../renderers/canvas/CanvasRenderer.js'; +import { + EdgeFadeTemplate, + type EdgeFadeProps, +} from '../templates/EdgeFadeTemplate.js'; + +// Shared scratch canvas, grown on demand and reused across all EdgeFade nodes. +// Rendering is single-threaded and non-reentrant so one scratch is enough. +let scratchCanvas: HTMLCanvasElement | null = null; +let scratchCtx: CanvasRenderingContext2D | null = null; + +/** + * Canvas2D implementation of {@link EdgeFade}: the node's content is drawn + * into an offscreen canvas, the edge ramps are erased with destination-out + * gradients, and the result is composited onto the main canvas. This keeps + * the background behind the node intact, which a destination-out pass on the + * main canvas could not. + */ +export const EdgeFade: CanvasShaderType = { + props: EdgeFadeTemplate.props, + render(ctx, node, renderContext) { + const props = this.props!; + const left = props.left; + const top = props.top; + const right = props.right; + const bottom = props.bottom; + + if (left <= 0 && top <= 0 && right <= 0 && bottom <= 0) { + renderContext(); + return; + } + + const w = node.props.w; + const h = node.props.h; + if (w <= 0 || h <= 0) { + return; + } + + const pr = this.stage.pixelRatio; + const sw = w * pr; + const sh = h * pr; + + if (scratchCanvas === null) { + scratchCanvas = document.createElement('canvas'); + scratchCtx = scratchCanvas.getContext('2d') as CanvasRenderingContext2D; + } + const sctx = scratchCtx!; + + if (scratchCanvas.width < sw || scratchCanvas.height < sh) { + // Growing the canvas implicitly clears it + scratchCanvas.width = Math.ceil(sw); + scratchCanvas.height = Math.ceil(sh); + } else { + sctx.setTransform(1, 0, 0, 1, 0, 0); + sctx.clearRect(0, 0, sw, sh); + } + + // Draw the node's content at the scratch origin + const tx = node.globalTransform!.tx; + const ty = node.globalTransform!.ty; + sctx.setTransform(pr, 0, 0, pr, -tx * pr, -ty * pr); + (this.stage.renderer as CanvasRenderer).renderNodeContent(node, sctx); + sctx.setTransform(1, 0, 0, 1, 0, 0); + + // Erase each edge with a linear ramp. Sequential destination-out passes + // multiply: dst *= (1 - g), matching the WebGL ramp product. + sctx.globalCompositeOperation = 'destination-out'; + if (left > 0) { + const d = left * pr; + const g = sctx.createLinearGradient(0, 0, d, 0); + g.addColorStop(0, 'rgba(0,0,0,1)'); + g.addColorStop(1, 'rgba(0,0,0,0)'); + sctx.fillStyle = g; + sctx.fillRect(0, 0, d, sh); + } + if (top > 0) { + const d = top * pr; + const g = sctx.createLinearGradient(0, 0, 0, d); + g.addColorStop(0, 'rgba(0,0,0,1)'); + g.addColorStop(1, 'rgba(0,0,0,0)'); + sctx.fillStyle = g; + sctx.fillRect(0, 0, sw, d); + } + if (right > 0) { + const d = right * pr; + const g = sctx.createLinearGradient(sw - d, 0, sw, 0); + g.addColorStop(0, 'rgba(0,0,0,0)'); + g.addColorStop(1, 'rgba(0,0,0,1)'); + sctx.fillStyle = g; + sctx.fillRect(sw - d, 0, d, sh); + } + if (bottom > 0) { + const d = bottom * pr; + const g = sctx.createLinearGradient(0, sh - d, 0, sh); + g.addColorStop(0, 'rgba(0,0,0,0)'); + g.addColorStop(1, 'rgba(0,0,0,1)'); + sctx.fillStyle = g; + sctx.fillRect(0, sh - d, sw, d); + } + sctx.globalCompositeOperation = 'source-over'; + + ctx.drawImage(scratchCanvas, 0, 0, sw, sh, tx, ty, w, h); + }, +}; diff --git a/src/core/shaders/templates/EdgeFadeTemplate.test.ts b/src/core/shaders/templates/EdgeFadeTemplate.test.ts new file mode 100644 index 0000000..1468f9c --- /dev/null +++ b/src/core/shaders/templates/EdgeFadeTemplate.test.ts @@ -0,0 +1,35 @@ +import { describe, it, expect } from 'vitest'; +import { EdgeFadeTemplate, type EdgeFadeProps } from './EdgeFadeTemplate.js'; +import { resolveShaderProps } from '../../renderers/CoreShaderNode.js'; + +function resolve(input: Partial): EdgeFadeProps { + const props = { ...input } as Record; + resolveShaderProps(props, EdgeFadeTemplate.props as never); + return props as unknown as EdgeFadeProps; +} + +describe('EdgeFadeTemplate', () => { + it('defaults all edges to 0 (no fade)', () => { + const props = resolve({}); + expect(props.left).toBe(0); + expect(props.top).toBe(0); + expect(props.right).toBe(0); + expect(props.bottom).toBe(0); + }); + + it('passes through a single edge, leaving others at 0', () => { + const props = resolve({ right: 420 }); + expect(props.right).toBe(420); + expect(props.left).toBe(0); + expect(props.top).toBe(0); + expect(props.bottom).toBe(0); + }); + + it('passes through all edges', () => { + const props = resolve({ left: 10, top: 20, right: 30, bottom: 40 }); + expect(props.left).toBe(10); + expect(props.top).toBe(20); + expect(props.right).toBe(30); + expect(props.bottom).toBe(40); + }); +}); diff --git a/src/core/shaders/templates/EdgeFadeTemplate.ts b/src/core/shaders/templates/EdgeFadeTemplate.ts new file mode 100644 index 0000000..c23c5e1 --- /dev/null +++ b/src/core/shaders/templates/EdgeFadeTemplate.ts @@ -0,0 +1,41 @@ +import type { CoreShaderType } from '../../renderers/CoreShaderNode.js'; + +/** + * Properties of the {@link EdgeFade} shader + */ +export interface EdgeFadeProps { + /** + * Fade distance in pixels from the left edge. Alpha ramps 0 → 1 over this + * distance. 0 disables the fade for this edge. + * + * @default 0 + */ + left: number; + /** + * Fade distance in pixels from the top edge. + * + * @default 0 + */ + top: number; + /** + * Fade distance in pixels from the right edge. + * + * @default 0 + */ + right: number; + /** + * Fade distance in pixels from the bottom edge. + * + * @default 0 + */ + bottom: number; +} + +export const EdgeFadeTemplate: CoreShaderType = { + props: { + left: 0, + top: 0, + right: 0, + bottom: 0, + }, +}; diff --git a/src/core/shaders/webgl/EdgeFade.ts b/src/core/shaders/webgl/EdgeFade.ts new file mode 100644 index 0000000..48d6829 --- /dev/null +++ b/src/core/shaders/webgl/EdgeFade.ts @@ -0,0 +1,84 @@ +import type { WebGlShaderType } from '../../renderers/webgl/WebGlShaderNode.js'; +import { + EdgeFadeTemplate, + type EdgeFadeProps, +} from '../templates/EdgeFadeTemplate.js'; + +/** + * Multiplies the node's alpha by a linear ramp inward from each edge with a + * non-zero fade distance, revealing whatever is rendered behind the node. + * Unlike {@link LinearGradient} this masks the texture's own alpha instead of + * blending a gradient color over its RGB. + */ +export const EdgeFade: WebGlShaderType = { + props: EdgeFadeTemplate.props, + update() { + const props = this.props!; + // Reciprocals are uploaded so the fragment shader needs no division and + // no zero-guard branch: 1e6 saturates clamp(px * recip) to 1.0 within a + // fraction of a pixel, which is exactly "no fade on this edge". + this.uniform4f( + 'u_fadeRecip', + props.left > 0 ? 1 / props.left : 1e6, + props.top > 0 ? 1 / props.top : 1e6, + props.right > 0 ? 1 / props.right : 1e6, + props.bottom > 0 ? 1 / props.bottom : 1e6, + ); + }, + vertex: ` + # ifdef GL_FRAGMENT_PRECISION_HIGH + precision highp float; + # else + precision mediump float; + # endif + + attribute vec2 a_position; + attribute vec2 a_textureCoords; + attribute vec4 a_color; + attribute vec2 a_nodeCoords; + + uniform vec2 u_resolution; + uniform float u_pixelRatio; + + varying vec4 v_color; + varying vec2 v_textureCoords; + varying vec2 v_nodeCoords; + + void main() { + vec2 normalized = a_position * u_pixelRatio / u_resolution; + vec2 zero_two = normalized * 2.0; + vec2 clip_space = zero_two - 1.0; + + v_color = a_color; + v_textureCoords = a_textureCoords; + v_nodeCoords = a_nodeCoords; + + gl_Position = vec4(clip_space * vec2(1.0, -1.0), 0, 1); + } + `, + fragment: ` + # ifdef GL_FRAGMENT_PRECISION_HIGH + precision highp float; + # else + precision mediump float; + # endif + + uniform vec2 u_dimensions; + uniform sampler2D u_texture; + uniform vec4 u_fadeRecip; + + varying vec4 v_color; + varying vec2 v_textureCoords; + varying vec2 v_nodeCoords; + + void main() { + vec4 color = v_color * texture2D(u_texture, v_textureCoords); + vec2 px = v_nodeCoords * u_dimensions; + float fade = clamp(px.x * u_fadeRecip.x, 0.0, 1.0) + * clamp(px.y * u_fadeRecip.y, 0.0, 1.0) + * clamp((u_dimensions.x - px.x) * u_fadeRecip.z, 0.0, 1.0) + * clamp((u_dimensions.y - px.y) * u_fadeRecip.w, 0.0, 1.0); + gl_FragColor = color * fade; + } + `, +}; diff --git a/visual-regression/certified-snapshots/chromium-ci/shader-edge-fade-1.png b/visual-regression/certified-snapshots/chromium-ci/shader-edge-fade-1.png new file mode 100644 index 0000000..a1e2342 Binary files /dev/null and b/visual-regression/certified-snapshots/chromium-ci/shader-edge-fade-1.png differ