Skip to content
Merged
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
63 changes: 46 additions & 17 deletions examples/tests/shader-radial-progress.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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,
Expand All @@ -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,
Expand Down Expand Up @@ -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');
});
}
11 changes: 10 additions & 1 deletion src/core/shaders/canvas/RadialProgress.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;

Expand Down
26 changes: 26 additions & 0 deletions src/core/shaders/templates/RadialProgressTemplate.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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({});
Expand All @@ -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', () => {
Expand All @@ -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);
});
});
});
25 changes: 25 additions & 0 deletions src/core/shaders/templates/RadialProgressTemplate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<RadialProgressProps> = {
Expand Down Expand Up @@ -108,5 +124,14 @@ export const RadialProgressTemplate: CoreShaderType<RadialProgressProps> = {
},
trackColor: 0x00000000,
cap: 1,
duration: {
default: 0,
resolve(value) {
if (value === undefined) return this.default;
if (value < 0) return 0;
return value;
},
},
countdown: 1,
},
};
21 changes: 17 additions & 4 deletions src/core/shaders/webgl/RadialProgress.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import type { WebGlRenderer } from '../../renderers/webgl/WebGlRenderer.js';

export const RadialProgress: WebGlShaderType<RadialProgressProps> = {
props: RadialProgressTemplate.props,
time: true,
update(node: CoreNode) {
const props = this.props!;

Expand All @@ -21,6 +22,8 @@ export const RadialProgress: WebGlShaderType<RadialProgressProps> = {
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[] = [];
Expand Down Expand Up @@ -61,6 +64,7 @@ export const RadialProgress: WebGlShaderType<RadialProgressProps> = {
#define TWO_PI 6.28318530717958647692

uniform float u_alpha;
uniform float u_time;
uniform vec2 u_dimensions;
uniform sampler2D u_texture;

Expand All @@ -70,6 +74,8 @@ export const RadialProgress: WebGlShaderType<RadialProgressProps> = {
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];
Expand Down Expand Up @@ -107,6 +113,13 @@ export const RadialProgress: WebGlShaderType<RadialProgressProps> = {
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;
Expand All @@ -122,24 +135,24 @@ export const RadialProgress: WebGlShaderType<RadialProgressProps> = {

// 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
Expand Down
Loading