diff --git a/examples/tests/shader-radial-progress.ts b/examples/tests/shader-radial-progress.ts index 568e28a..8bb1df5 100644 --- a/examples/tests/shader-radial-progress.ts +++ b/examples/tests/shader-radial-progress.ts @@ -6,7 +6,11 @@ export async function automation(settings: ExampleSettings) { } export default async function test({ renderer, testRoot }: ExampleSettings) { - // 1. Fill-up animation: progress 0 → 1, looping. Cyan ring on a dim track. + const ANIM_DURATION = 2000; + + // 1. Self-animating fill: shader drives progress 0 → 1 from u_time, looping. + // No JS-side animation needed — pure GLSL/Canvas math each frame. + // Starts static (duration: 0); press SPACE to enable. const fillRing = renderer.createNode({ x: 40, y: 40, @@ -15,22 +19,18 @@ export default async function test({ renderer, testRoot }: ExampleSettings) { color: 0x00000000, shader: renderer.createShader('RadialProgress', { width: 16, + duration: 0, progress: 0, + countdown: 0, // fill 0 -> 1 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). + // 2. Self-animating countdown: shader drives progress 1 → 0, looping. + // Matches the reference screenshot recipe (blue arc, dim blue track). + // Starts static (duration: 0); press SPACE to enable. const countdownRing = renderer.createNode({ x: 380, y: 40, @@ -39,20 +39,15 @@ export default async function test({ renderer, testRoot }: ExampleSettings) { color: 0x00000000, shader: renderer.createShader('RadialProgress', { width: 14, + duration: 0, progress: 1, + countdown: 1, // drain 1 -> 0 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, @@ -118,4 +113,38 @@ export default async function test({ renderer, testRoot }: ExampleSettings) { }), parent: testRoot, }); + + // Instructions + const instructions = renderer.createTextNode({ + x: 40, + y: 720, + fontSize: 28, + fontFamily: 'Ubuntu', + color: 0xffffffff, + text: 'Press SPACE to toggle the fill + countdown animations (top-left, top-middle).', + parent: testRoot, + }); + + const statusLabel = renderer.createTextNode({ + x: 40, + y: 760, + fontSize: 24, + fontFamily: 'Ubuntu', + color: 0xaaaaaaff, + text: 'animation: off', + parent: testRoot, + }); + // statusLabel and instructions kept as locals so they aren't GC-tracked away + void instructions; + + let animationOn = false; + window.addEventListener('keydown', (e) => { + if (e.key !== ' ' && e.code !== 'Space') return; + e.preventDefault(); + animationOn = !animationOn; + const d = animationOn ? ANIM_DURATION : 0; + fillRing.shader.props!.duration = d; + countdownRing.shader.props!.duration = d; + statusLabel.text = 'animation: ' + (animationOn ? 'on' : 'off'); + }); } diff --git a/src/core/shaders/canvas/RadialProgress.ts b/src/core/shaders/canvas/RadialProgress.ts index 9cffdcf..aa0c6a4 100644 --- a/src/core/shaders/canvas/RadialProgress.ts +++ b/src/core/shaders/canvas/RadialProgress.ts @@ -51,6 +51,7 @@ export const RadialProgress: CanvasShaderType< ComputedRadialProgressValues > = { props: RadialProgressTemplate.props, + time: true, update(node) { const props = this.props!; const autoRadius = Math.min(node.w, node.h) * 0.5 - props.width * 0.5; @@ -76,9 +77,17 @@ export const RadialProgress: CanvasShaderType< .computed as ComputedRadialProgressValues; const { tx, ty } = node.globalTransform!; const props = this.props!; - const { width, progress, startAngle, direction, cap } = props; + const { width, startAngle, direction, cap, duration, countdown } = props; const stops = props.stops; + // Effective progress: when duration > 0 the shader self-animates from + // node.time (millis since stage start). Otherwise use the static prop. + let progress = props.progress; + if (duration > 0) { + const cyclePos = (node.time % duration) / duration; + progress = countdown === 1 ? 1 - cyclePos : cyclePos; + } + const ax = tx + cx; const ay = ty + cy; diff --git a/src/core/shaders/templates/RadialProgressTemplate.test.ts b/src/core/shaders/templates/RadialProgressTemplate.test.ts index 732bbd6..f7e525e 100644 --- a/src/core/shaders/templates/RadialProgressTemplate.test.ts +++ b/src/core/shaders/templates/RadialProgressTemplate.test.ts @@ -98,6 +98,24 @@ describe('RadialProgressTemplate', () => { }); }); + describe('duration', () => { + const cfg = RadialProgressTemplate.props!.duration; + if (!isAdvancedShaderProp(cfg)) + throw new Error('duration should be advanced'); + + it('returns default (0) when undefined', () => { + expect(cfg.resolve!(undefined as never, {} as never)).toBe(0); + }); + + it('clamps negative values to 0', () => { + expect(cfg.resolve!(-100 as never, {} as never)).toBe(0); + }); + + it('passes through positive values', () => { + expect(cfg.resolve!(5000 as never, {} as never)).toBe(5000); + }); + }); + describe('defaults via resolveShaderProps', () => { it('applies all defaults when no props given', () => { const r = resolve({}); @@ -110,6 +128,8 @@ describe('RadialProgressTemplate', () => { expect(r.stops).toEqual([0]); expect(r.trackColor).toBe(0x00000000); expect(r.cap).toBe(1); + expect(r.duration).toBe(0); + expect(r.countdown).toBe(1); }); it('clamps progress through full resolution path', () => { @@ -121,5 +141,11 @@ describe('RadialProgressTemplate', () => { const r = resolve({ colors: [0xff0000ff, 0x00ff00ff, 0x0000ffff] }); expect(r.stops).toEqual([0, 0.5, 1]); }); + + it('threads duration and countdown through full resolution path', () => { + const r = resolve({ duration: 3000, countdown: 0 }); + expect(r.duration).toBe(3000); + expect(r.countdown).toBe(0); + }); }); }); diff --git a/src/core/shaders/templates/RadialProgressTemplate.ts b/src/core/shaders/templates/RadialProgressTemplate.ts index f6cced5..90b892b 100644 --- a/src/core/shaders/templates/RadialProgressTemplate.ts +++ b/src/core/shaders/templates/RadialProgressTemplate.ts @@ -58,6 +58,22 @@ export interface RadialProgressProps { * @default 1 */ cap: 0 | 1; + /** + * When > 0, the shader self-animates one full cycle every `duration` ms, + * looping. Overrides the static `progress` prop. `0` disables (use `progress`). + * + * Pair with `countdown` to choose fill vs. drain. + * + * @default 0 + */ + duration: number; + /** + * Animation direction when `duration > 0`. `0` fills (0→1 over a cycle), + * `1` drains (1→0 over a cycle). Ignored when `duration === 0`. + * + * @default 1 + */ + countdown: 0 | 1; } export const RadialProgressTemplate: CoreShaderType = { @@ -108,5 +124,14 @@ export const RadialProgressTemplate: CoreShaderType = { }, trackColor: 0x00000000, cap: 1, + duration: { + default: 0, + resolve(value) { + if (value === undefined) return this.default; + if (value < 0) return 0; + return value; + }, + }, + countdown: 1, }, }; diff --git a/src/core/shaders/webgl/RadialProgress.ts b/src/core/shaders/webgl/RadialProgress.ts index 36772a3..311f847 100644 --- a/src/core/shaders/webgl/RadialProgress.ts +++ b/src/core/shaders/webgl/RadialProgress.ts @@ -9,6 +9,7 @@ import type { WebGlRenderer } from '../../renderers/webgl/WebGlRenderer.js'; export const RadialProgress: WebGlShaderType = { props: RadialProgressTemplate.props, + time: true, update(node: CoreNode) { const props = this.props!; @@ -21,6 +22,8 @@ export const RadialProgress: WebGlShaderType = { this.uniform1f('u_progress', props.progress); this.uniform1f('u_startAngle', props.startAngle); this.uniform1f('u_direction', props.direction); + this.uniform1f('u_duration', props.duration); + this.uniform1f('u_countdown', props.countdown); this.uniform1fv('u_stops', new Float32Array(props.stops)); const colors: number[] = []; @@ -61,6 +64,7 @@ export const RadialProgress: WebGlShaderType = { #define TWO_PI 6.28318530717958647692 uniform float u_alpha; + uniform float u_time; uniform vec2 u_dimensions; uniform sampler2D u_texture; @@ -70,6 +74,8 @@ export const RadialProgress: WebGlShaderType = { uniform float u_progress; uniform float u_startAngle; uniform float u_direction; + uniform float u_duration; + uniform float u_countdown; uniform float u_stops[MAX_STOPS]; uniform vec4 u_colors[MAX_STOPS]; @@ -107,6 +113,13 @@ export const RadialProgress: WebGlShaderType = { void main() { vec4 base = texture2D(u_texture, v_textureCoords) * v_color; + // Effective progress: when u_duration > 0 the shader self-animates from + // u_time, otherwise we use the static u_progress prop. countdown == 1 + // drains (1 -> 0), countdown == 0 fills (0 -> 1). + float cyclePos = u_duration > 0.0 ? fract(u_time / u_duration) : 0.0; + float animProgress = u_countdown > 0.5 ? 1.0 - cyclePos : cyclePos; + float progress = u_duration > 0.0 ? animProgress : u_progress; + vec2 p = v_nodeCoords.xy * u_dimensions - u_center; float dist = length(p); float halfW = u_width * 0.5; @@ -122,24 +135,24 @@ export const RadialProgress: WebGlShaderType = { // 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 arcCoverage = progress >= 1.0 ? 1.0 : step(t, 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; + float a1 = u_startAngle + u_direction * 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); + float capGate = step(0.0001, progress) * step(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; + float gradT = progress > 0.0 ? clamp(t / progress, 0.0, 1.0) : 0.0; vec4 fillCol = getGradientColor(gradT); // Composite: track under fill (if track enabled), both gated by ringCoverage