diff --git a/examples/common/installShaders.ts b/examples/common/installShaders.ts index 7f42c7f..ee2ae7b 100644 --- a/examples/common/installShaders.ts +++ b/examples/common/installShaders.ts @@ -25,4 +25,5 @@ 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('RadialProgress', shaders.RadialProgress); } diff --git a/examples/tests/shader-radial-progress.ts b/examples/tests/shader-radial-progress.ts new file mode 100644 index 0000000..568e28a --- /dev/null +++ b/examples/tests/shader-radial-progress.ts @@ -0,0 +1,121 @@ +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) { + // 1. Fill-up animation: progress 0 → 1, looping. Cyan ring on a dim track. + const fillRing = renderer.createNode({ + x: 40, + y: 40, + w: 300, + h: 300, + color: 0x00000000, + shader: renderer.createShader('RadialProgress', { + width: 16, + progress: 0, + colors: [0x4aff80ff], + trackColor: 0x1c3a2aff, + }), + parent: testRoot, + }); + + // fillRing + // .animate( + // { shaderProps: { progress: 1 } }, + // { duration: 2000, loop: true, easing: 'linear' }, + // ) + // .start(); + + // 2. Countdown animation: progress 1 → 0, looping. Matches the reference + // screenshot recipe (blue arc, dim blue track). + const countdownRing = renderer.createNode({ + x: 380, + y: 40, + w: 300, + h: 300, + color: 0x00000000, + shader: renderer.createShader('RadialProgress', { + width: 14, + progress: 1, + colors: [0x4aa3ffff], + trackColor: 0x1f3a5cff, + }), + parent: testRoot, + }); + + // countdownRing + // .animate( + // { shaderProps: { progress: 0 } }, + // { duration: 2000, loop: true, easing: 'linear' }, + // ) + // .start(); + + // 3. Multi-stop gradient swept along the arc, 50% progress + renderer.createNode({ + x: 720, + y: 40, + w: 300, + h: 300, + color: 0x00000000, + shader: renderer.createShader('RadialProgress', { + width: 24, + progress: 0.5, + colors: [0x4aa3ffff, 0x9ad6ffff, 0xffffffff], + trackColor: 0x1f3a5c66, + }), + parent: testRoot, + }); + + // 4. Counter-clockwise, butt caps, partial sweep + renderer.createNode({ + x: 40, + y: 380, + w: 300, + h: 300, + color: 0x00000000, + shader: renderer.createShader('RadialProgress', { + width: 20, + progress: 0.35, + direction: -1, + cap: 0, + colors: [0xff66aaff, 0xffaa66ff], + trackColor: 0x33333366, + }), + parent: testRoot, + }); + + // 5. Full ring with multi-stop sweep (no track) + renderer.createNode({ + x: 380, + y: 380, + w: 300, + h: 300, + color: 0x00000000, + shader: renderer.createShader('RadialProgress', { + width: 18, + progress: 1, + colors: [0xff0080ff, 0xffaa00ff, 0x00ffaaff, 0xff0080ff], + }), + parent: testRoot, + }); + + // 6. Custom startAngle (9 o'clock), 80% progress + renderer.createNode({ + x: 720, + y: 380, + w: 300, + h: 300, + color: 0x00000000, + shader: renderer.createShader('RadialProgress', { + width: 12, + progress: 0.8, + startAngle: Math.PI, + colors: [0xffffffff], + trackColor: 0x33333366, + }), + parent: testRoot, + }); +} diff --git a/exports/canvas-shaders.ts b/exports/canvas-shaders.ts index 5d530e7..9544802 100644 --- a/exports/canvas-shaders.ts +++ b/exports/canvas-shaders.ts @@ -9,3 +9,4 @@ 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 { RadialGradient } from '../src/core/shaders/canvas/RadialGradient.js'; +export { RadialProgress } from '../src/core/shaders/canvas/RadialProgress.js'; diff --git a/exports/webgl-shaders.ts b/exports/webgl-shaders.ts index e701037..5616991 100644 --- a/exports/webgl-shaders.ts +++ b/exports/webgl-shaders.ts @@ -9,4 +9,5 @@ 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 { RadialGradient } from '../src/core/shaders/webgl/RadialGradient.js'; +export { RadialProgress } from '../src/core/shaders/webgl/RadialProgress.js'; export { Default } from '../src/core/shaders/webgl/Default.js'; diff --git a/src/core/shaders/canvas/RadialProgress.ts b/src/core/shaders/canvas/RadialProgress.ts new file mode 100644 index 0000000..9cffdcf --- /dev/null +++ b/src/core/shaders/canvas/RadialProgress.ts @@ -0,0 +1,113 @@ +import type { CanvasShaderType } from '../../renderers/canvas/CanvasShaderNode.js'; +import { + RadialProgressTemplate, + type RadialProgressProps, +} from '../templates/RadialProgressTemplate.js'; + +export interface ComputedRadialProgressValues { + cx: number; + cy: number; + radius: number; + colorChannels: number[][]; // [r,g,b,a] per stop color, 0..255 (a in 0..1) + trackColor: string | null; +} + +const SEGMENTS = 64; + +function lerpColor(a: number[], b: number[], t: number): string { + const r = Math.round(a[0]! + (b[0]! - a[0]!) * t); + const g = Math.round(a[1]! + (b[1]! - a[1]!) * t); + const bl = Math.round(a[2]! + (b[2]! - a[2]!) * t); + const al = a[3]! + (b[3]! - a[3]!) * t; + return `rgba(${r},${g},${bl},${al})`; +} + +function colorAt(channels: number[][], stops: number[], t: number): string { + const last = channels.length - 1; + if (t <= stops[0]!) return lerpColor(channels[0]!, channels[0]!, 0); + if (t >= stops[last]!) return lerpColor(channels[last]!, channels[last]!, 0); + for (let i = 0; i < last; i++) { + const left = stops[i]!; + const right = stops[i + 1]!; + if (t >= left && t <= right) { + const lt = (t - left) / (right - left); + return lerpColor(channels[i]!, channels[i + 1]!, lt); + } + } + return lerpColor(channels[last]!, channels[last]!, 0); +} + +function toChannels(rgba: number): number[] { + return [ + (rgba >>> 24) & 0xff, + (rgba >>> 16) & 0xff, + (rgba >>> 8) & 0xff, + (rgba & 0xff) / 255, + ]; +} + +export const RadialProgress: CanvasShaderType< + RadialProgressProps, + ComputedRadialProgressValues +> = { + props: RadialProgressTemplate.props, + update(node) { + const props = this.props!; + const autoRadius = Math.min(node.w, node.h) * 0.5 - props.width * 0.5; + const radius = props.radius > 0 ? props.radius : autoRadius; + + const colorChannels: number[][] = []; + for (let i = 0; i < props.colors.length; i++) { + colorChannels.push(toChannels(props.colors[i]!)); + } + + this.computed = { + cx: node.w * 0.5, + cy: node.h * 0.5, + radius, + colorChannels, + trackColor: + props.trackColor !== 0 ? this.toColorString(props.trackColor) : null, + }; + }, + render(ctx, node, renderContext) { + renderContext(); + const { cx, cy, radius, colorChannels, trackColor } = this + .computed as ComputedRadialProgressValues; + const { tx, ty } = node.globalTransform!; + const props = this.props!; + const { width, progress, startAngle, direction, cap } = props; + const stops = props.stops; + + const ax = tx + cx; + const ay = ty + cy; + + ctx.lineWidth = width; + ctx.lineCap = cap === 1 ? 'round' : 'butt'; + + if (trackColor !== null) { + ctx.strokeStyle = trackColor; + ctx.beginPath(); + ctx.arc(ax, ay, radius, 0, Math.PI * 2); + ctx.stroke(); + } + + if (progress <= 0) return; + + const sweep = Math.PI * 2 * progress * direction; + const step = sweep / SEGMENTS; + // Overlap segments by a tiny amount so the seams don't show on canvas AA + const overlap = Math.abs(step) * 0.02; + + for (let i = 0; i < SEGMENTS; i++) { + const t = i / (SEGMENTS - 1); + ctx.strokeStyle = colorAt(colorChannels, stops, t); + ctx.beginPath(); + const a0 = startAngle + step * i; + const a1 = + startAngle + step * (i + 1) + (direction === 1 ? overlap : -overlap); + ctx.arc(ax, ay, radius, a0, a1, direction === -1); + ctx.stroke(); + } + }, +}; diff --git a/src/core/shaders/templates/RadialProgressTemplate.test.ts b/src/core/shaders/templates/RadialProgressTemplate.test.ts new file mode 100644 index 0000000..732bbd6 --- /dev/null +++ b/src/core/shaders/templates/RadialProgressTemplate.test.ts @@ -0,0 +1,125 @@ +import { describe, it, expect } from 'vitest'; +import { + RadialProgressTemplate, + type RadialProgressProps, +} from './RadialProgressTemplate.js'; +import { + isAdvancedShaderProp, + resolveShaderProps, +} from '../../renderers/CoreShaderNode.js'; + +function resolve(input: Partial): RadialProgressProps { + const props = { ...input } as Record; + resolveShaderProps(props, RadialProgressTemplate.props as never); + return props as unknown as RadialProgressProps; +} + +describe('RadialProgressTemplate', () => { + describe('progress', () => { + const cfg = RadialProgressTemplate.props!.progress; + if (!isAdvancedShaderProp(cfg)) + throw new Error('progress should be advanced'); + + it('clamps values below 0 to 0', () => { + expect(cfg.resolve!(-0.5, {} as never)).toBe(0); + }); + + it('clamps values above 1 to 1', () => { + expect(cfg.resolve!(2, {} as never)).toBe(1); + }); + + it('passes through in-range values', () => { + expect(cfg.resolve!(0.42, {} as never)).toBe(0.42); + }); + + it('returns default when undefined', () => { + expect(cfg.resolve!(undefined as never, {} as never)).toBe(1); + }); + }); + + describe('colors', () => { + const cfg = RadialProgressTemplate.props!.colors; + if (!isAdvancedShaderProp(cfg)) + throw new Error('colors should be advanced'); + + it('falls back to default on undefined', () => { + expect(cfg.resolve!(undefined as never, {} as never)).toEqual([ + 0xffffffff, + ]); + }); + + it('falls back to default on empty array', () => { + expect(cfg.resolve!([] as never, {} as never)).toEqual([0xffffffff]); + }); + + it('passes through user-provided colors', () => { + const input = [0xff0000ff, 0x00ff00ff]; + expect(cfg.resolve!(input, {} as never)).toEqual(input); + }); + }); + + describe('stops', () => { + const cfg = RadialProgressTemplate.props!.stops; + if (!isAdvancedShaderProp(cfg)) throw new Error('stops should be advanced'); + + it('auto-distributes when omitted (n=3)', () => { + const out = cfg.resolve!( + undefined as never, + { + colors: [1, 2, 3], + } as never, + ); + expect(out).toEqual([0, 0.5, 1]); + }); + + it('auto-distributes when length mismatches', () => { + const out = cfg.resolve!( + [0, 1] as never, + { + colors: [1, 2, 3], + } as never, + ); + expect(out).toEqual([0, 0.5, 1]); + }); + + it('handles single color (n=1) without NaN', () => { + const out = cfg.resolve!(undefined as never, { colors: [1] } as never); + expect(out).toEqual([0]); + }); + + it('passes through valid stops', () => { + const out = cfg.resolve!( + [0, 0.3, 1] as never, + { + colors: [1, 2, 3], + } as never, + ); + expect(out).toEqual([0, 0.3, 1]); + }); + }); + + describe('defaults via resolveShaderProps', () => { + it('applies all defaults when no props given', () => { + const r = resolve({}); + expect(r.width).toBe(8); + expect(r.radius).toBe(0); + expect(r.progress).toBe(1); + expect(r.startAngle).toBeCloseTo(-Math.PI / 2); + expect(r.direction).toBe(1); + expect(r.colors).toEqual([0xffffffff]); + expect(r.stops).toEqual([0]); + expect(r.trackColor).toBe(0x00000000); + expect(r.cap).toBe(1); + }); + + it('clamps progress through full resolution path', () => { + const r = resolve({ progress: 1.5 }); + expect(r.progress).toBe(1); + }); + + it('auto-distributes stops through full resolution path', () => { + const r = resolve({ colors: [0xff0000ff, 0x00ff00ff, 0x0000ffff] }); + expect(r.stops).toEqual([0, 0.5, 1]); + }); + }); +}); diff --git a/src/core/shaders/templates/RadialProgressTemplate.ts b/src/core/shaders/templates/RadialProgressTemplate.ts new file mode 100644 index 0000000..f6cced5 --- /dev/null +++ b/src/core/shaders/templates/RadialProgressTemplate.ts @@ -0,0 +1,112 @@ +import type { CoreShaderType } from '../../renderers/CoreShaderNode.js'; + +/** + * Properties of the {@link RadialProgress} shader + */ +export interface RadialProgressProps { + /** + * Stroke width of the ring in pixels + * + * @default 8 + */ + width: number; + /** + * Outer radius of the ring in pixels. When 0, auto-fits the node: + * `min(node.w, node.h) / 2 - width / 2` + * + * @default 0 + */ + radius: number; + /** + * Portion of the ring that is filled, in `[0, 1]` + * + * @default 1 + */ + progress: number; + /** + * Angle (in radians) where the filled arc starts. `-PI/2` is 12 o'clock. + * + * @default -Math.PI / 2 + */ + startAngle: number; + /** + * Sweep direction. `1` = clockwise, `-1` = counter-clockwise. + * + * @default 1 + */ + direction: 1 | -1; + /** + * Colors swept along the filled arc. + * + * @default [0xffffffff] + */ + colors: number[]; + /** + * Color stops along the filled arc, in `[0, 1]`. Auto-distributed when omitted + * or when length doesn't match `colors`. + */ + stops: number[]; + /** + * Background ring color (drawn under the full circle). `0x00000000` disables. + * + * @default 0x00000000 + */ + trackColor: number; + /** + * Arc end-cap style. `0` = butt, `1` = round. + * + * @default 1 + */ + cap: 0 | 1; +} + +export const RadialProgressTemplate: CoreShaderType = { + props: { + width: 8, + radius: 0, + progress: { + default: 1, + resolve(value) { + if (value === undefined) return this.default; + if (value < 0) return 0; + if (value > 1) return 1; + return value; + }, + }, + startAngle: -Math.PI / 2, + direction: 1, + colors: { + default: [0xffffffff], + resolve(value) { + if (value !== undefined && value.length > 0) { + return value; + } + return ([] as number[]).concat(this.default); + }, + }, + stops: { + default: [0], + resolve(value, props) { + if (value !== undefined && value.length === props.colors.length) { + return value; + } + if (value === undefined) { + value = []; + } + const len = props.colors.length; + if (len === 1) { + value[0] = 0; + value.length = 1; + return value; + } + for (let i = 0; i < len; i++) { + value[i] = i * (1 / (len - 1)); + } + value.length = len; + return value; + }, + }, + trackColor: 0x00000000, + cap: 1, + }, +}; diff --git a/src/core/shaders/webgl/RadialProgress.ts b/src/core/shaders/webgl/RadialProgress.ts new file mode 100644 index 0000000..36772a3 --- /dev/null +++ b/src/core/shaders/webgl/RadialProgress.ts @@ -0,0 +1,163 @@ +import type { CoreNode } from '../../CoreNode.js'; +import { getNormalizedRgbaComponents } from '../../lib/utils.js'; +import { + RadialProgressTemplate, + type RadialProgressProps, +} from '../templates/RadialProgressTemplate.js'; +import type { WebGlShaderType } from '../../renderers/webgl/WebGlShaderNode.js'; +import type { WebGlRenderer } from '../../renderers/webgl/WebGlRenderer.js'; + +export const RadialProgress: WebGlShaderType = { + props: RadialProgressTemplate.props, + update(node: CoreNode) { + const props = this.props!; + + const autoRadius = Math.min(node.w, node.h) * 0.5 - props.width * 0.5; + const radius = props.radius > 0 ? props.radius : autoRadius; + + this.uniform2f('u_center', node.w * 0.5, node.h * 0.5); + this.uniform1f('u_radius', radius); + this.uniform1f('u_width', props.width); + this.uniform1f('u_progress', props.progress); + this.uniform1f('u_startAngle', props.startAngle); + this.uniform1f('u_direction', props.direction); + this.uniform1fv('u_stops', new Float32Array(props.stops)); + + const colors: number[] = []; + for (let i = 0; i < props.colors.length; i++) { + const norm = getNormalizedRgbaComponents(props.colors[i]!); + colors.push(norm[0]!, norm[1]!, norm[2]!, norm[3]!); + } + this.uniform4fv('u_colors', new Float32Array(colors)); + + const trackNorm = getNormalizedRgbaComponents(props.trackColor); + this.uniform4f( + 'u_trackColor', + trackNorm[0]!, + trackNorm[1]!, + trackNorm[2]!, + trackNorm[3]!, + ); + }, + getCacheMarkers(props: RadialProgressProps) { + return `colors:${props.colors.length}|cap:${props.cap}|track:${ + props.trackColor !== 0 ? 1 : 0 + }`; + }, + fragment(renderer: WebGlRenderer, props: RadialProgressProps) { + const maxStops = Math.max(props.colors.length, 1); + return ` + # ifdef GL_FRAGMENT_PRECISION_HIGH + precision highp float; + # else + precision mediump float; + # endif + + #define MAX_STOPS ${maxStops} + #define LAST_STOP ${maxStops - 1} + #define CAP_ROUND ${props.cap} + #define HAS_TRACK ${props.trackColor !== 0 ? 1 : 0} + + #define TWO_PI 6.28318530717958647692 + + uniform float u_alpha; + uniform vec2 u_dimensions; + uniform sampler2D u_texture; + + uniform vec2 u_center; + uniform float u_radius; + uniform float u_width; + uniform float u_progress; + uniform float u_startAngle; + uniform float u_direction; + + uniform float u_stops[MAX_STOPS]; + uniform vec4 u_colors[MAX_STOPS]; + uniform vec4 u_trackColor; + + varying vec4 v_color; + varying vec2 v_textureCoords; + varying vec2 v_nodeCoords; + + vec4 getGradientColor(float dist) { + dist = clamp(dist, 0.0, 1.0); + + if (dist <= u_stops[0]) { + return u_colors[0]; + } + if (dist >= u_stops[LAST_STOP]) { + return u_colors[LAST_STOP]; + } + for (int i = 0; i < LAST_STOP; i++) { + float left = u_stops[i]; + float right = u_stops[i + 1]; + if (dist >= left && dist <= right) { + float lDist = smoothstep(left, right, dist); + return mix(u_colors[i], u_colors[i + 1], lDist); + } + } + return u_colors[LAST_STOP]; + } + + // Coverage of a disc centered at \`c\` with radius \`r\` at pixel \`p\` (with 1px AA) + float discCoverage(vec2 p, vec2 c, float r) { + return 1.0 - smoothstep(r - 1.0, r + 1.0, length(p - c)); + } + + void main() { + vec4 base = texture2D(u_texture, v_textureCoords) * v_color; + + vec2 p = v_nodeCoords.xy * u_dimensions - u_center; + float dist = length(p); + float halfW = u_width * 0.5; + + // Ring coverage: 1 inside the stroke band, 0 outside (with 1px AA on both edges) + float ringCoverage = + smoothstep(u_radius - halfW - 1.0, u_radius - halfW + 1.0, dist) * + (1.0 - smoothstep(u_radius + halfW - 1.0, u_radius + halfW + 1.0, dist)); + + // Angle along the arc, normalized to [0, 1) starting at u_startAngle + float ang = atan(p.y, p.x); + float t = mod((ang - u_startAngle) * u_direction, TWO_PI) / TWO_PI; + + // Filled arc coverage (1 if in filled arc, else 0). When progress >= 1 the + // whole ring is filled regardless of \`t\` -- guards against the mod() seam. + float arcCoverage = u_progress >= 1.0 ? 1.0 : step(t, u_progress); + float fillCoverage = ringCoverage * arcCoverage; + + #if CAP_ROUND + // Round caps: discs of radius halfW at the start and head of the arc + float a0 = u_startAngle; + float a1 = u_startAngle + u_direction * u_progress * TWO_PI; + vec2 cap0 = vec2(cos(a0), sin(a0)) * u_radius; + vec2 cap1 = vec2(cos(a1), sin(a1)) * u_radius; + float capMask = max(discCoverage(p, cap0, halfW), discCoverage(p, cap1, halfW)); + // Caps only visible when there's something to cap (progress > 0 and < 1). + float capGate = step(0.0001, u_progress) * step(u_progress, 0.9999); + fillCoverage = max(fillCoverage, capMask * capGate); + #endif + + // Sample gradient. Normalize \`t\` to the *filled* portion so the gradient + // spans the visible arc end-to-end regardless of progress. + float gradT = u_progress > 0.0 ? clamp(t / u_progress, 0.0, 1.0) : 0.0; + vec4 fillCol = getGradientColor(gradT); + + // Composite: track under fill (if track enabled), both gated by ringCoverage + vec4 layer = vec4(0.0); + #if HAS_TRACK + float trackCoverage = ringCoverage * (1.0 - fillCoverage); + layer = u_trackColor * trackCoverage + fillCol * fillCoverage; + #else + layer = fillCol * fillCoverage; + #endif + + // Composite layer over base. Output alpha = base.a + layer.a*(1-base.a) + // so the ring is visible even when the node's base color is fully transparent. + float la = clamp(layer.a, 0.0, 1.0); + vec3 blended = mix(base.rgb, layer.rgb, la); + float outA = base.a + la * (1.0 - base.a); + gl_FragColor = vec4(blended, outA); + } + `; + }, +}; diff --git a/visual-regression/certified-snapshots/chromium-ci/shader-radial-progress-1.png b/visual-regression/certified-snapshots/chromium-ci/shader-radial-progress-1.png new file mode 100644 index 0000000..84d1bce Binary files /dev/null and b/visual-regression/certified-snapshots/chromium-ci/shader-radial-progress-1.png differ