Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions examples/common/installShaders.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
99 changes: 99 additions & 0 deletions examples/tests/shader-edge-fade.ts
Original file line number Diff line number Diff line change
@@ -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,
});
}
1 change: 1 addition & 0 deletions exports/canvas-shaders.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
1 change: 1 addition & 0 deletions exports/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions exports/webgl-shaders.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down
20 changes: 20 additions & 0 deletions src/core/renderers/canvas/CanvasRenderer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<CanvasShaderType>,
Expand Down
105 changes: 105 additions & 0 deletions src/core/shaders/canvas/EdgeFade.ts
Original file line number Diff line number Diff line change
@@ -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<EdgeFadeProps> = {
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);
},
};
35 changes: 35 additions & 0 deletions src/core/shaders/templates/EdgeFadeTemplate.test.ts
Original file line number Diff line number Diff line change
@@ -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>): EdgeFadeProps {
const props = { ...input } as Record<string, unknown>;
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);
});
});
41 changes: 41 additions & 0 deletions src/core/shaders/templates/EdgeFadeTemplate.ts
Original file line number Diff line number Diff line change
@@ -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<EdgeFadeProps> = {
props: {
left: 0,
top: 0,
right: 0,
bottom: 0,
},
};
Loading
Loading