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
88 changes: 88 additions & 0 deletions examples/tests/shader-linear-gradient-alpha.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
import type { ExampleSettings } from '../common/ExampleSettings.js';

/**
* Transparency-focused regression for the LinearGradient shader.
*
* Each gradient sits on top of an opaque white background so that any
* unexpected transparency (or unexpected opacity) is visible against it:
* - solid opaque stops must fully hide the background (the WebGL bug where
* solid colors rendered semi-transparent)
* - partial-alpha stops must let the background show through proportionally
* - a stop fading to alpha 0 must reveal the background at that end
* - node opacity (alpha) must scale the whole gradient (the `u_alpha` path)
*/
export async function automation(settings: ExampleSettings) {
await test(settings);
await settings.snapshot();
}

export default async function test({ renderer, testRoot }: ExampleSettings) {
const degToRad = (deg: number) => (Math.PI / 180) * deg;

// Opaque white backdrop — transparency reads as white.
renderer.createNode({
x: 0,
y: 0,
w: 1920,
h: 1080,
color: 0xffffffff,
parent: testRoot,
});

const w = 880;
const h = 440;

// Solid opaque stops: must be fully opaque (no white bleed-through).
renderer.createNode({
x: 40,
y: 40,
w,
h,
shader: renderer.createShader('LinearGradient', {
colors: [0xff0000ff, 0x0000ffff],
angle: degToRad(0),
}),
parent: testRoot,
});

// Partial-alpha middle stop: white shows through the centre band.
renderer.createNode({
x: 1000,
y: 40,
w,
h,
shader: renderer.createShader('LinearGradient', {
colors: [0xff0000ff, 0x00ff0080, 0x0000ffff],
angle: degToRad(90),
}),
parent: testRoot,
});

// Fade to fully transparent: bottom edge reveals the white backdrop.
renderer.createNode({
x: 40,
y: 600,
w,
h,
shader: renderer.createShader('LinearGradient', {
colors: [0x000000ff, 0x00000000],
stops: [0, 1],
angle: degToRad(0),
}),
parent: testRoot,
});

// Node opacity: solid gradient at 50% alpha blends with the white backdrop.
renderer.createNode({
x: 1000,
y: 600,
w,
h,
alpha: 0.5,
shader: renderer.createShader('LinearGradient', {
colors: [0xff00ffff, 0xffff00ff],
angle: degToRad(45),
}),
parent: testRoot,
});
}
45 changes: 45 additions & 0 deletions src/core/shaders/canvas/LinearGradient.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
import { describe, expect, it } from 'vitest';
import { LinearGradient } from './LinearGradient.js';
import { normalizeCanvasColor } from '../../lib/colorCache.js';

/**
* Invoke the canvas LinearGradient `update()` with a minimal fake shader node
* and return the computed CSS color strings.
*/
function computeColors(colors: number[]): string[] {
const ctx = {
props: { colors, stops: [0, 1], angle: 0 },
computed: undefined as unknown,
toColorString: (value: number) => normalizeCanvasColor(value, true),
};
// Colors are RGBA encoded (alpha in the low byte) — see parseToRgbaString.
LinearGradient.update!.call(ctx as never, { w: 100, h: 100 } as never);
return (ctx.computed as { colors: string[] }).colors;
}

describe('canvas LinearGradient color mapping', () => {
it('preserves per-stop alpha (no opaque fallback)', () => {
// 0x00ff0080 is a half-transparent green stop. The previous
// "nearest opaque RGB" workaround mis-read the high byte as alpha
// (it is actually red in RGBA) and overwrote the low byte, turning
// transparent stops solid. The faithful mapping must keep alpha ~0.5.
const [opaqueRed, halfGreen] = computeColors([0xff0000ff, 0x00ff0080]);

expect(opaqueRed).toBe('rgba(255,0,0,1)');
expect(halfGreen).toBe(`rgba(0,255,0,${0x80 / 255})`);
});

it('keeps fully transparent stops transparent', () => {
const [, transparent] = computeColors([0x000000ff, 0x00000000]);
expect(transparent).toBe('rgba(0,0,0,0)');
});

it('maps every stop straight through toColorString', () => {
const colors = [0xff0000ff, 0x00ff00ff, 0x0000ffff];
expect(computeColors(colors)).toEqual([
'rgba(255,0,0,1)',
'rgba(0,255,0,1)',
'rgba(0,0,255,1)',
]);
});
});
19 changes: 1 addition & 18 deletions src/core/shaders/canvas/LinearGradient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,24 +31,7 @@ export const LinearGradient: CanvasShaderType<
y0: line * Math.sin(angle) + nHeight * 0.5,
x1: line * Math.cos(angle + Math.PI) + nWidth * 0.5,
y1: line * Math.sin(angle + Math.PI) + nHeight * 0.5,
colors: this.props!.colors.map((value, i, arr) => {
const alpha = (value >>> 24) & 0xff;
if (alpha === 0) {
let nearestRGB = value & 0x00ffffff;
for (let step = 1; step < arr.length; step++) {
if (i - step >= 0 && ((arr[i - step]! >>> 24) & 0xff) > 0) {
nearestRGB = arr[i - step]! & 0x00ffffff;
break;
}
if (i + step < arr.length && ((arr[i + step]! >>> 24) & 0xff) > 0) {
nearestRGB = arr[i + step]! & 0x00ffffff;
break;
}
}
value = (value & 0xff000000) | nearestRGB;
}
return this.toColorString(value);
}),
colors: this.props!.colors.map((value) => this.toColorString(value)),
};
},
render(ctx, node, renderContext) {
Expand Down
70 changes: 70 additions & 0 deletions src/core/shaders/webgl/LinearGradient.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
import { describe, expect, it } from 'vitest';
import { LinearGradient } from './LinearGradient.js';

interface GradUniforms {
a: [number, number];
b: number;
}

/**
* Invoke the WebGL LinearGradient `update()` with a fake shader node that
* captures the gradient uniforms (`u_grad_a` / `u_grad_b`). The fragment
* shader computes `dist = dot(v_textureCoords, u_grad_a) + u_grad_b`, so these
* uniforms fully define the gradient ramp.
*/
function computeGrad(angle: number, w: number, h: number): GradUniforms {
const out: GradUniforms = { a: [0, 0], b: 0 };
const ctx = {
props: { colors: [0x000000ff, 0xffffffff], stops: [0, 1], angle },
uniform2f: (name: string, v0: number, v1: number) => {
if (name === 'u_grad_a') out.a = [v0, v1];
},
uniform1f: (name: string, v: number) => {
if (name === 'u_grad_b') out.b = v;
},
uniform1fv: () => undefined,
uniform4fv: () => undefined,
};
LinearGradient.update!.call(ctx as never, { w, h } as never);
return out;
}

const distAt = (g: GradUniforms, tx: number, ty: number) =>
tx * g.a[0] + ty * g.a[1] + g.b;

describe('webgl LinearGradient gradient uniforms', () => {
it('angle 0 ramps top -> bottom across the node-local box', () => {
const g = computeGrad(0, 200, 100);
expect(g.a[0]).toBeCloseTo(0, 5);
expect(g.a[1]).toBeCloseTo(1, 5);
expect(g.b).toBeCloseTo(0, 5);
// dist runs 0 (top) -> 1 (bottom)
expect(distAt(g, 0.5, 0)).toBeCloseTo(0, 5);
expect(distAt(g, 0.5, 1)).toBeCloseTo(1, 5);
});

it('angle 90deg ramps horizontally', () => {
const g = computeGrad(Math.PI / 2, 200, 100);
expect(g.a[0]).toBeCloseTo(-1, 5);
expect(g.a[1]).toBeCloseTo(0, 5);
expect(distAt(g, 0, 0.5)).toBeCloseTo(1, 5);
expect(distAt(g, 1, 0.5)).toBeCloseTo(0, 5);
});

it('endpoints stay within [0,1] for an arbitrary angle', () => {
const g = computeGrad(Math.PI / 4, 640, 360);
// Box corners must map into the clampable [0,1] ramp range.
const corners = [
distAt(g, 0, 0),
distAt(g, 1, 0),
distAt(g, 0, 1),
distAt(g, 1, 1),
];
for (let i = 0; i < corners.length; i++) {
expect(corners[i]).toBeGreaterThanOrEqual(-1e-6);
expect(corners[i]).toBeLessThanOrEqual(1 + 1e-6);
}
// The gradient axis is symmetric about the box center -> dist = 0.5 there.
expect(distAt(g, 0.5, 0.5)).toBeCloseTo(0.5, 5);
});
});
87 changes: 36 additions & 51 deletions src/core/shaders/webgl/LinearGradient.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import type { CoreNode } from '../../CoreNode.js';
import { getNormalizedRgbaComponents } from '../../lib/utils.js';
import {
LinearGradientTemplate,
Expand All @@ -8,9 +9,33 @@ import type { WebGlShaderType } from '../../renderers/webgl/WebGlShaderNode.js';

export const LinearGradient: WebGlShaderType<LinearGradientProps> = {
props: LinearGradientTemplate.props,
update() {
update(node: CoreNode) {
const props = this.props!;
this.uniform1f('u_angle', props.angle - (Math.PI / 180) * 90);

// The gradient distance is an affine function of the node-local texture
// coordinates, so it reduces to `dist = dot(v_textureCoords, a) + b`.
// `a`/`b` depend only on the angle and node dimensions (both are part of
// the value-key), so we compute them once on the CPU here instead of
// recomputing the trig per fragment on the GPU.
const angle = props.angle - (Math.PI / 180) * 90;
const c = Math.cos(angle);
const s = Math.sin(angle);
const w = node.w;
const h = node.h;

const lineDist = Math.abs(w * c) + Math.abs(h * s);
// Gradient axis (from -> to), gradVec = -lineDist * (c, s)
const gx = -lineDist * c;
const gy = -lineDist * s;
const gg = gx * gx + gy * gy;
const invGG = gg > 0 ? 1 / gg : 0;
// Gradient origin: f = lineDist * 0.5 * (c, s) + dimensions * 0.5
const fx = lineDist * 0.5 * c + w * 0.5;
const fy = lineDist * 0.5 * s + h * 0.5;

this.uniform2f('u_grad_a', w * gx * invGG, h * gy * invGG);
this.uniform1f('u_grad_b', -(fx * gx + fy * gy) * invGG);

this.uniform1fv('u_stops', new Float32Array(props.stops));
const colors: number[] = [];
for (let i = 0; i < props.colors.length; i++) {
Expand All @@ -22,51 +47,6 @@ export const LinearGradient: WebGlShaderType<LinearGradientProps> = {
getCacheMarkers(props: LinearGradientProps) {
return `colors:${props.colors.length}`;
},
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;

uniform vec2 u_resolution;
uniform float u_pixelRatio;
uniform vec2 u_dimensions;
uniform float u_angle;

varying vec4 v_color;
varying vec2 v_textureCoords;
varying float v_dist;

const float PI = 3.14159265359;

vec2 calcPoint(float d, float angle) {
return d * vec2(cos(angle), sin(angle)) + (u_dimensions * 0.5);
}

void main() {
vec2 normalized = a_position * u_pixelRatio / u_resolution;
vec2 zero_two = normalized * 2.0;
vec2 clip_space = zero_two - 1.0;

gl_Position = vec4(clip_space * vec2(1.0, -1.0), 0, 1);

v_color = a_color;
v_textureCoords = a_textureCoords;

float a = u_angle;
float lineDist = abs(u_dimensions.x * cos(a)) + abs(u_dimensions.y * sin(a));
vec2 f = calcPoint(lineDist * 0.5, a);
vec2 t = calcPoint(lineDist * 0.5, a + PI);
vec2 gradVec = t - f;
float dist = dot(a_textureCoords * u_dimensions - f, gradVec) / dot(gradVec, gradVec);
v_dist = dist;
}
`,
fragment(renderer: WebGlRenderer, props: LinearGradientProps) {
return `
# ifdef GL_FRAGMENT_PRECISION_HIGH
Expand All @@ -78,13 +58,17 @@ export const LinearGradient: WebGlShaderType<LinearGradientProps> = {
#define MAX_STOPS ${props.colors.length}
#define LAST_STOP ${props.colors.length - 1}

uniform float u_alpha;

uniform sampler2D u_texture;

uniform vec2 u_grad_a;
uniform float u_grad_b;
uniform float u_stops[MAX_STOPS];
uniform vec4 u_colors[MAX_STOPS];

varying vec4 v_color;
varying vec2 v_textureCoords;
varying float v_dist;

vec4 getGradientColor(float dist) {
dist = clamp(dist, 0.0, 1.0);
Expand All @@ -110,9 +94,10 @@ export const LinearGradient: WebGlShaderType<LinearGradientProps> = {

void main() {
vec4 color = texture2D(u_texture, v_textureCoords) * v_color;
vec4 colorOut = getGradientColor(v_dist);
vec3 blendedRGB = mix(color.rgb, colorOut.rgb, clamp(colorOut.a, 0.0, 1.0));
gl_FragColor = vec4(blendedRGB, color.a);
float dist = dot(v_textureCoords, u_grad_a) + u_grad_b;
vec4 colorOut = getGradientColor(dist);
color = mix(color, colorOut, clamp(colorOut.a, 0.0, 1.0));
gl_FragColor = color * u_alpha;
}
`;
},
Expand Down
4 changes: 2 additions & 2 deletions src/core/shaders/webgl/RadialGradient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -83,8 +83,8 @@ export const RadialGradient: WebGlShaderType<RadialGradientProps> = {
float dist = length((point - u_projection) / u_size);

vec4 colorOut = getGradientColor(dist);
vec3 blendedRGB = mix(color.rgb, colorOut.rgb, clamp(colorOut.a, 0.0, 1.0));
gl_FragColor = vec4(blendedRGB, color.a);
color = mix(color, colorOut, clamp(colorOut.a, 0.0, 1.0));
gl_FragColor = color * u_alpha;
}
`;
},
Expand Down
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading