diff --git a/examples/tests/shader-linear-gradient-alpha.ts b/examples/tests/shader-linear-gradient-alpha.ts new file mode 100644 index 0000000..45f5846 --- /dev/null +++ b/examples/tests/shader-linear-gradient-alpha.ts @@ -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, + }); +} diff --git a/src/core/shaders/canvas/LinearGradient.test.ts b/src/core/shaders/canvas/LinearGradient.test.ts new file mode 100644 index 0000000..0d28276 --- /dev/null +++ b/src/core/shaders/canvas/LinearGradient.test.ts @@ -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)', + ]); + }); +}); diff --git a/src/core/shaders/canvas/LinearGradient.ts b/src/core/shaders/canvas/LinearGradient.ts index 2cfdd36..8036d6e 100644 --- a/src/core/shaders/canvas/LinearGradient.ts +++ b/src/core/shaders/canvas/LinearGradient.ts @@ -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) { diff --git a/src/core/shaders/webgl/LinearGradient.test.ts b/src/core/shaders/webgl/LinearGradient.test.ts new file mode 100644 index 0000000..ce40d7e --- /dev/null +++ b/src/core/shaders/webgl/LinearGradient.test.ts @@ -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); + }); +}); diff --git a/src/core/shaders/webgl/LinearGradient.ts b/src/core/shaders/webgl/LinearGradient.ts index c27fd15..e321875 100644 --- a/src/core/shaders/webgl/LinearGradient.ts +++ b/src/core/shaders/webgl/LinearGradient.ts @@ -1,3 +1,4 @@ +import type { CoreNode } from '../../CoreNode.js'; import { getNormalizedRgbaComponents } from '../../lib/utils.js'; import { LinearGradientTemplate, @@ -8,9 +9,33 @@ import type { WebGlShaderType } from '../../renderers/webgl/WebGlShaderNode.js'; export const LinearGradient: WebGlShaderType = { 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++) { @@ -22,51 +47,6 @@ export const LinearGradient: WebGlShaderType = { 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 @@ -78,13 +58,17 @@ export const LinearGradient: WebGlShaderType = { #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); @@ -110,9 +94,10 @@ export const LinearGradient: WebGlShaderType = { 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; } `; }, diff --git a/src/core/shaders/webgl/RadialGradient.ts b/src/core/shaders/webgl/RadialGradient.ts index 13e2182..f92b080 100644 --- a/src/core/shaders/webgl/RadialGradient.ts +++ b/src/core/shaders/webgl/RadialGradient.ts @@ -83,8 +83,8 @@ export const RadialGradient: WebGlShaderType = { 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; } `; }, diff --git a/visual-regression/certified-snapshots/chromium-ci/shader-linear-gradient-1.png b/visual-regression/certified-snapshots/chromium-ci/shader-linear-gradient-1.png index 3079a90..0012891 100644 Binary files a/visual-regression/certified-snapshots/chromium-ci/shader-linear-gradient-1.png and b/visual-regression/certified-snapshots/chromium-ci/shader-linear-gradient-1.png differ diff --git a/visual-regression/certified-snapshots/chromium-ci/shader-linear-gradient-alpha-1.png b/visual-regression/certified-snapshots/chromium-ci/shader-linear-gradient-alpha-1.png new file mode 100644 index 0000000..62f26b8 Binary files /dev/null and b/visual-regression/certified-snapshots/chromium-ci/shader-linear-gradient-alpha-1.png differ diff --git a/visual-regression/certified-snapshots/chromium-ci/shader-radial-gradient-1.png b/visual-regression/certified-snapshots/chromium-ci/shader-radial-gradient-1.png index b210e4a..adb5519 100644 Binary files a/visual-regression/certified-snapshots/chromium-ci/shader-radial-gradient-1.png and b/visual-regression/certified-snapshots/chromium-ci/shader-radial-gradient-1.png differ