From 330f9a085056768de22b9be8d7a51e6a6887ac76 Mon Sep 17 00:00:00 2001 From: Chris Lorenzo Date: Wed, 1 Jul 2026 20:45:19 -0400 Subject: [PATCH] refactor(shaders): make all WebGL fragment shaders branchless for Mali 400 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Mali 400-class fragment pipelines have no dynamic branching: every if/ else/ternary serializes both paths for the batch, uniform-driven branches included. Replace all fragment-shader branches with step()/mix() arithmetic: - Ternary quadrant-radius selects in roundedBox/shadowBox (RoundedWith Shadow/Border/BorderAndShadow, HolePunch) now use the step+mix pattern from Rounded. - The varying-driven shadow-geometry branch in RoundedWithBorderAndShadow (worst offender: diverges per fragment) is now a step/mix parameter select into a single shadowBox call. - borderZero / u_borderGap early-return paths in RoundedWithBorder, RoundedWithBorderAndShadow and Border compute all composites and mix()-select. Runtime step/mix was chosen over compile-time #define variants because shader props are runtime-mutable (animations write shaderProps directly) while the program is fixed at createShader time — keying a variant on border-w/border-gap would break prop animation. The border vertex shaders now initialize all border varyings in the zero-border case so the always-evaluated fragment SDFs never read undefined values. - u_color.a / u_shadow_color.a gates in Shadow and RoundedWithShadow are step() multiplies. - Gradient stop loops (LinearGradient, RadialGradient, RadialProgress) use the branchless mix-accumulation from genGradientColors, rewritten to smoothstep-per-segment (pixel-identical to the old segment select for ascending stops) and unrolled per colors:N program variant. - RadialProgress duration/countdown/progress ternaries are step/mix with mix()-guarded denominators (a max()-epsilon would overflow fp16 mediump to Inf/NaN). Verified against the certified chromium-ci snapshots in Docker: 180/180 pass, pixel-identical to the branchy shaders. Adds shader-border-zero covering the merged degenerate paths (zero-width border, zero-alpha shadow) plus unit tests proving genGradientColors matches the reference segment select. Co-Authored-By: Claude Fable 5 --- examples/tests/shader-border-zero.ts | 94 +++++++++++++ .../webgl/internal/ShaderUtils.test.ts | 124 ++++++++++++++++++ .../renderers/webgl/internal/ShaderUtils.ts | 27 ++-- src/core/shaders/webgl/Border.ts | 23 ++-- src/core/shaders/webgl/HolePunch.ts | 17 ++- src/core/shaders/webgl/LinearGradient.ts | 22 +--- src/core/shaders/webgl/RadialGradient.ts | 23 +--- src/core/shaders/webgl/RadialProgress.ts | 38 +++--- src/core/shaders/webgl/RoundedWithBorder.ts | 67 +++++----- .../webgl/RoundedWithBorderAndShadow.ts | 103 ++++++++------- src/core/shaders/webgl/RoundedWithShadow.ts | 28 ++-- src/core/shaders/webgl/Shadow.ts | 9 +- .../chromium-ci/shader-border-zero-1.png | Bin 0 -> 29309 bytes 13 files changed, 389 insertions(+), 186 deletions(-) create mode 100644 examples/tests/shader-border-zero.ts create mode 100644 src/core/renderers/webgl/internal/ShaderUtils.test.ts create mode 100644 visual-regression/certified-snapshots/chromium-ci/shader-border-zero-1.png diff --git a/examples/tests/shader-border-zero.ts b/examples/tests/shader-border-zero.ts new file mode 100644 index 0000000..8ed4429 --- /dev/null +++ b/examples/tests/shader-border-zero.ts @@ -0,0 +1,94 @@ +import type { ExampleSettings } from '../common/ExampleSettings.js'; + +export async function automation(settings: ExampleSettings) { + // Snapshot single page + await test(settings); + await settings.snapshot(); +} + +/** + * Degenerate-prop paths of the border/shadow shaders: zero-width borders and + * fully transparent shadow colors. These paths are selected branchlessly in + * the fragment shaders (Mali 400 target), so this locks in that a zero border + * renders exactly like plain Rounded and a zero-alpha shadow renders nothing. + */ +export default async function test({ renderer, testRoot }: ExampleSettings) { + const node = renderer.createNode({ + x: 0, + y: 0, + w: 1920, + h: 1080, + color: 0xffffffff, + parent: testRoot, + }); + + // Zero-width border: must render identical to plain Rounded + renderer.createNode({ + x: 300, + y: 300, + mount: 0.5, + w: 250, + h: 250, + color: 0xff00ffff, + shader: renderer.createShader('RoundedWithBorder', { + radius: 30, + 'border-w': 0, + 'border-color': 0xff0000ff, + }), + parent: node, + }); + + // Zero-width border with a shadow: shadow hugs the node box + renderer.createNode({ + x: 700, + y: 300, + mount: 0.5, + w: 250, + h: 250, + color: 0xff00ffff, + shader: renderer.createShader('RoundedWithBorderAndShadow', { + radius: 30, + 'border-w': 0, + 'border-color': 0xff0000ff, + 'shadow-x': 50, + 'shadow-spread': 50, + 'shadow-blur': 100, + }), + parent: node, + }); + + // Zero-alpha shadow color: no shadow may appear + renderer.createNode({ + x: 1100, + y: 300, + mount: 0.5, + w: 250, + h: 250, + color: 0xff00ffff, + shader: renderer.createShader('Shadow', { + x: 50, + spread: 50, + blur: 100, + color: 0x00000000, + }), + parent: node, + }); + + // Zero-alpha rounded shadow color: plain rounded corners, no shadow + renderer.createNode({ + x: 1500, + y: 300, + mount: 0.5, + w: 250, + h: 250, + color: 0xff00ffff, + shader: renderer.createShader('RoundedWithShadow', { + radius: 30, + 'shadow-x': 50, + 'shadow-spread': 50, + 'shadow-blur': 100, + 'shadow-color': 0x00000000, + }), + parent: node, + }); +} diff --git a/src/core/renderers/webgl/internal/ShaderUtils.test.ts b/src/core/renderers/webgl/internal/ShaderUtils.test.ts new file mode 100644 index 0000000..c08890f --- /dev/null +++ b/src/core/renderers/webgl/internal/ShaderUtils.test.ts @@ -0,0 +1,124 @@ +import { describe, expect, it } from 'vitest'; +import { genGradientColors } from './ShaderUtils.js'; + +type Vec4 = [number, number, number, number]; + +const smoothstep = (e0: number, e1: number, x: number): number => { + const t = Math.min(Math.max((x - e0) / (e1 - e0), 0), 1); + return t * t * (3 - 2 * t); +}; + +const mix = (a: Vec4, b: Vec4, t: number): Vec4 => [ + a[0] + (b[0] - a[0]) * t, + a[1] + (b[1] - a[1]) * t, + a[2] + (b[2] - a[2]) * t, + a[3] + (b[3] - a[3]) * t, +]; + +/** + * Evaluate the generated GLSL statements as JS. The generated code only uses + * `mix`, `smoothstep`, `u_colors[i]`, `u_stops[i]` and `dist`, so shimming + * those makes the string directly executable. + */ +function evalGradient(stops: number[], colors: Vec4[], dist: number): Vec4 { + const src = genGradientColors(stops.length).replace( + 'vec4 colorOut =', + 'let colorOut =', + ); + const fn = new Function( + 'mix', + 'smoothstep', + 'u_stops', + 'u_colors', + 'dist', + `${src}; return colorOut;`, + ); + return fn(mix, smoothstep, stops, colors, dist) as Vec4; +} + +/** + * Reference implementation: the original branchy segment select + * (below first stop / above last stop / smoothstep within the segment). + */ +function referenceGradient( + stops: number[], + colors: Vec4[], + dist: number, +): Vec4 { + dist = Math.min(Math.max(dist, 0), 1); + if (dist <= stops[0]!) return colors[0]!; + const last = stops.length - 1; + if (dist >= stops[last]!) return colors[last]!; + for (let i = 0; i < last; i++) { + if (dist >= stops[i]! && dist <= stops[i + 1]!) { + return mix( + colors[i]!, + colors[i + 1]!, + smoothstep(stops[i]!, stops[i + 1]!, dist), + ); + } + } + return colors[last]!; +} + +const RED: Vec4 = [1, 0, 0, 1]; +const GREEN: Vec4 = [0, 1, 0, 1]; +const BLUE: Vec4 = [0, 0, 1, 0.5]; +const WHITE: Vec4 = [1, 1, 1, 1]; + +describe('genGradientColors', () => { + it('emits no branches (no if / ternary / return)', () => { + const src = genGradientColors(4); + expect(src.includes('if')).toBe(false); + expect(src.includes('?')).toBe(false); + expect(src.includes('return')).toBe(false); + }); + + it('with a single stop resolves to the first color', () => { + const out = evalGradient([0], [RED], 0.7); + expect(out).toEqual(RED); + }); + + it('clamps to the first color below the first stop', () => { + const out = evalGradient([0.25, 0.75], [RED, GREEN], 0.1); + expect(out).toEqual(RED); + }); + + it('clamps to the last color above the last stop', () => { + const out = evalGradient([0.25, 0.75], [RED, GREEN], 0.9); + expect(out).toEqual(GREEN); + }); + + it('interpolates with smoothstep inside a segment', () => { + const dist = 0.5; + const out = evalGradient([0.25, 0.75], [RED, GREEN], dist); + const expected = mix(RED, GREEN, smoothstep(0.25, 0.75, dist)); + for (let i = 0; i < 4; i++) { + expect(out[i]).toBeCloseTo(expected[i]!, 6); + } + }); + + it('matches the reference segment select across a multi-stop ramp', () => { + const stops = [0, 0.3, 0.6, 1]; + const colors = [RED, GREEN, BLUE, WHITE]; + for (let d = 0; d <= 100; d++) { + const dist = d / 100; + const out = evalGradient(stops, colors, dist); + const ref = referenceGradient(stops, colors, dist); + for (let i = 0; i < 4; i++) { + expect(out[i]).toBeCloseTo(ref[i]!, 6); + } + } + }); + + it('is exact at stop boundaries', () => { + const stops = [0, 0.5, 1]; + const colors = [RED, GREEN, BLUE]; + expect(evalGradient(stops, colors, 0)).toEqual(RED); + const mid = evalGradient(stops, colors, 0.5); + for (let i = 0; i < 4; i++) { + expect(mid[i]).toBeCloseTo(GREEN[i]!, 6); + } + expect(evalGradient(stops, colors, 1)).toEqual(BLUE); + }); +}); diff --git a/src/core/renderers/webgl/internal/ShaderUtils.ts b/src/core/renderers/webgl/internal/ShaderUtils.ts index 93a1fae..2c474d1 100644 --- a/src/core/renderers/webgl/internal/ShaderUtils.ts +++ b/src/core/renderers/webgl/internal/ShaderUtils.ts @@ -243,21 +243,22 @@ export const DefaultVertexSource = ` `; /** - * generate fragment source for - * @param stops - * @returns + * Generates branchless gradient-stop evaluation statements for a fragment + * shader. Expects a `float dist` in scope and `u_stops`/`u_colors` uniform + * arrays of length `stops`; leaves the result in `vec4 colorOut`. + * + * The accumulated `mix()` chain is exactly equivalent to selecting the + * segment containing `dist` (for ascending stops): below a segment the + * smoothstep is 0 (no-op), above it is 1 (fully replaced by the next color). + * No `if`/ternary — Mali 400-class fragment pipelines serialize any branch. */ export function genGradientColors(stops: number): string { - let result = ` - float stopCalc = (dist - u_stops[0]) / (u_stops[1] - u_stops[0]); - vec4 colorOut = mix(u_colors[0], u_colors[1], stopCalc); - `; - if (stops > 2) { - for (let i = 2; i < stops; i++) { - result += `colorOut = mix(colorOut, u_colors[${i}], clamp((dist - u_stops[${ - i - 1 - }]) / (u_stops[${i}] - u_stops[${i - 1}]), 0.0, 1.0));`; - } + let result = `vec4 colorOut = u_colors[0];`; + for (let i = 1; i < stops; i++) { + result += ` + colorOut = mix(colorOut, u_colors[${i}], smoothstep(u_stops[${ + i - 1 + }], u_stops[${i}], dist));`; } return result; } diff --git a/src/core/shaders/webgl/Border.ts b/src/core/shaders/webgl/Border.ts index 1618870..06b580d 100644 --- a/src/core/shaders/webgl/Border.ts +++ b/src/core/shaders/webgl/Border.ts @@ -135,24 +135,25 @@ export const Border: WebGlShaderType = { float outerDist = box(boxUv + v_outerBorderUv, v_outerSize - edgeWidth); float innerDist = box(boxUv + v_innerBorderUv, v_innerSize - edgeWidth); - if(u_borderGap == 0.0) { - float outerAlpha = 1.0 - smoothstep(-0.5 * edgeWidth, 0.5 * edgeWidth, outerDist); - float innerAlpha = 1.0 - smoothstep(-0.5 * edgeWidth, 0.5 * edgeWidth, innerDist); - resultColor = mix(resultColor, u_borderColor, outerAlpha * u_borderColor.a); - resultColor = mix(resultColor, color, innerAlpha); - gl_FragColor = resultColor * u_alpha; - return; - } + float outerAlpha = 1.0 - smoothstep(-0.5 * edgeWidth, 0.5 * edgeWidth, outerDist); + float innerAlpha = 1.0 - smoothstep(-0.5 * edgeWidth, 0.5 * edgeWidth, innerDist); float nodeDist = box(boxUv, v_halfDimensions - edgeWidth); float nodeAlpha = 1.0 - smoothstep(-0.5 * edgeWidth, 0.5 * edgeWidth, nodeDist); float borderDist = max(-innerDist, outerDist); float borderAlpha = 1.0 - smoothstep(-0.5 * edgeWidth, 0.5 * edgeWidth, borderDist); - resultColor = mix(resultColor, color, nodeAlpha); - resultColor = mix(resultColor, u_borderColor, borderAlpha * u_borderColor.a); - gl_FragColor = resultColor * u_alpha; + // Branchless gap select -- Mali 400 serializes uniform branches, so both + // composites are computed and mix()-selected on hasGap instead. + vec4 resNoGap = mix(resultColor, u_borderColor, outerAlpha * u_borderColor.a); + resNoGap = mix(resNoGap, color, innerAlpha); + + vec4 resGap = mix(resultColor, color, nodeAlpha); + resGap = mix(resGap, u_borderColor, borderAlpha * u_borderColor.a); + + float hasGap = step(0.0001, abs(u_borderGap)); + gl_FragColor = mix(resNoGap, resGap, hasGap) * u_alpha; } `, }; diff --git a/src/core/shaders/webgl/HolePunch.ts b/src/core/shaders/webgl/HolePunch.ts index 778b672..36f0201 100644 --- a/src/core/shaders/webgl/HolePunch.ts +++ b/src/core/shaders/webgl/HolePunch.ts @@ -46,11 +46,18 @@ export const HolePunch: WebGlShaderType = { void main() { vec4 color = texture2D(u_texture, v_textureCoords) * v_color; vec2 p = (v_textureCoords.xy * u_dimensions.xy - u_pos) - u_size; - vec4 r = u_radius; - r.xy = (p.x > 0.0) ? r.yz : r.xw; - r.x = (p.y > 0.0) ? r.y : r.x; - p = abs(p) - u_size + r.x; - float dist = min(max(p.x, p.y), 0.0) + length(max(p, 0.0)) - r.x + 2.0; + + // Branchless radius selection based on quadrant + // x: TL, y: TR, z: BR, w: BL + vec2 stepVal = step(vec2(0.0), p); + float r = mix( + mix(u_radius.x, u_radius.y, stepVal.x), + mix(u_radius.w, u_radius.z, stepVal.x), + stepVal.y + ); + + p = abs(p) - u_size + r; + float dist = min(max(p.x, p.y), 0.0) + length(max(p, 0.0)) - r + 2.0; float roundedAlpha = 1.0 - smoothstep(0.0, u_pixelRatio, dist); gl_FragColor = mix(color, vec4(0.0), min(color.a, roundedAlpha)); } diff --git a/src/core/shaders/webgl/LinearGradient.ts b/src/core/shaders/webgl/LinearGradient.ts index e321875..864524c 100644 --- a/src/core/shaders/webgl/LinearGradient.ts +++ b/src/core/shaders/webgl/LinearGradient.ts @@ -6,6 +6,7 @@ import { } from '../templates/LinearGradientTemplate.js'; import type { WebGlRenderer } from '../../renderers/webgl/WebGlRenderer.js'; import type { WebGlShaderType } from '../../renderers/webgl/WebGlShaderNode.js'; +import { genGradientColors } from '../../renderers/webgl/internal/ShaderUtils.js'; export const LinearGradient: WebGlShaderType = { props: LinearGradientTemplate.props, @@ -56,7 +57,6 @@ export const LinearGradient: WebGlShaderType = { # endif #define MAX_STOPS ${props.colors.length} - #define LAST_STOP ${props.colors.length - 1} uniform float u_alpha; @@ -72,24 +72,8 @@ export const LinearGradient: WebGlShaderType = { 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]; + ${genGradientColors(props.colors.length)} + return colorOut; } void main() { diff --git a/src/core/shaders/webgl/RadialGradient.ts b/src/core/shaders/webgl/RadialGradient.ts index f92b080..3f6b08a 100644 --- a/src/core/shaders/webgl/RadialGradient.ts +++ b/src/core/shaders/webgl/RadialGradient.ts @@ -6,6 +6,7 @@ import { } from '../templates/RadialGradientTemplate.js'; import type { WebGlShaderType } from '../../renderers/webgl/WebGlShaderNode.js'; import type { WebGlRenderer } from '../../renderers/webgl/WebGlRenderer.js'; +import { genGradientColors } from '../../renderers/webgl/internal/ShaderUtils.js'; export const RadialGradient: WebGlShaderType = { props: RadialGradientTemplate.props, @@ -37,7 +38,6 @@ export const RadialGradient: WebGlShaderType = { # endif #define MAX_STOPS ${props.colors.length} - #define LAST_STOP ${props.colors.length - 1} uniform float u_alpha; uniform vec2 u_dimensions; @@ -56,25 +56,8 @@ export const RadialGradient: WebGlShaderType = { 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]; + ${genGradientColors(props.colors.length)} + return colorOut; } void main() { diff --git a/src/core/shaders/webgl/RadialProgress.ts b/src/core/shaders/webgl/RadialProgress.ts index 29151f2..e2eb928 100644 --- a/src/core/shaders/webgl/RadialProgress.ts +++ b/src/core/shaders/webgl/RadialProgress.ts @@ -6,6 +6,7 @@ import { } from '../templates/RadialProgressTemplate.js'; import type { WebGlShaderType } from '../../renderers/webgl/WebGlShaderNode.js'; import type { WebGlRenderer } from '../../renderers/webgl/WebGlRenderer.js'; +import { genGradientColors } from '../../renderers/webgl/internal/ShaderUtils.js'; export const RadialProgress: WebGlShaderType = { props: RadialProgressTemplate.props, @@ -57,7 +58,6 @@ export const RadialProgress: WebGlShaderType = { # endif #define MAX_STOPS ${maxStops} - #define LAST_STOP ${maxStops - 1} #define CAP_ROUND ${props.cap} #define HAS_TRACK ${props.trackColor !== 0 ? 1 : 0} @@ -87,22 +87,8 @@ export const RadialProgress: WebGlShaderType = { 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]; + ${genGradientColors(maxStops)} + return colorOut; } // Coverage of a disc centered at \`c\` with radius \`r\` at pixel \`p\` (with 1px AA) @@ -116,9 +102,13 @@ export const RadialProgress: WebGlShaderType = { // 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; + // Branchless (Mali 400 serializes uniform branches): the mix()-guarded + // denominator keeps the division finite when u_duration == 0 -- a + // max()-clamped epsilon would overflow fp16 mediump to Inf/NaN. + float hasDuration = step(0.0001, u_duration); + float cyclePos = fract(u_time / mix(1.0, u_duration, hasDuration)) * hasDuration; + float animProgress = mix(cyclePos, 1.0 - cyclePos, step(0.5, u_countdown)); + float progress = mix(u_progress, animProgress, hasDuration); vec2 p = v_nodeCoords.xy * u_dimensions - u_center; float dist = length(p); @@ -135,7 +125,7 @@ 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 = progress >= 1.0 ? 1.0 : step(t, progress); + float arcCoverage = max(step(1.0, progress), step(t, progress)); float fillCoverage = ringCoverage * arcCoverage; #if CAP_ROUND @@ -151,8 +141,10 @@ export const RadialProgress: WebGlShaderType = { #endif // Sample gradient. Normalize \`t\` to the *filled* portion so the gradient - // spans the visible arc end-to-end regardless of progress. - float gradT = progress > 0.0 ? clamp(t / progress, 0.0, 1.0) : 0.0; + // spans the visible arc end-to-end regardless of progress. mix()-guarded + // denominator: safe at progress == 0 without fp16 overflow. + float hasProgress = step(0.000001, progress); + float gradT = clamp(t / mix(1.0, progress, hasProgress), 0.0, 1.0) * hasProgress; vec4 fillCol = getGradientColor(gradT); // Composite: track under fill (if track enabled), both gated by ringCoverage. diff --git a/src/core/shaders/webgl/RoundedWithBorder.ts b/src/core/shaders/webgl/RoundedWithBorder.ts index ac66fc9..07000f3 100644 --- a/src/core/shaders/webgl/RoundedWithBorder.ts +++ b/src/core/shaders/webgl/RoundedWithBorder.ts @@ -64,6 +64,15 @@ export const RoundedWithBorder: WebGlShaderType = { v_innerSize = vec2(0.0); v_outerSize = vec2(0.0); + // Defaults for the zero-border case: the fragment shader is branchless + // and always evaluates the border SDFs, so every varying must carry a + // finite value (an unwritten varying is undefined and can poison the + // final mix() with NaN). + v_outerBorderUv = vec2(0.0); + v_innerBorderUv = vec2(0.0); + v_outerBorderRadius = u_radius; + v_innerBorderRadius = u_radius; + if(borderZero == 0.0) { vec4 adjustedBorderWidth = u_borderWidth - 1.0 + clamp(u_borderWidth, -1.0, 1.0); @@ -72,9 +81,6 @@ export const RoundedWithBorder: WebGlShaderType = { float borderBottom = adjustedBorderWidth.z; float borderLeft = adjustedBorderWidth.w; - v_outerBorderUv = vec2(0.0); - v_innerBorderUv = vec2(0.0); - vec2 borderSize = vec2(borderRight + borderLeft, borderTop + borderBottom); vec2 extraSize = borderSize * u_borderAlign; float gapLeft = step(0.001, borderLeft) * u_borderGap; @@ -159,52 +165,49 @@ export const RoundedWithBorder: WebGlShaderType = { varying vec4 v_outerBorderRadius; varying vec2 v_halfDimensions; + // Branchless quadrant radius select (r: x TL, y TR, z BR, w BL) -- + // Mali 400-class fragment pipelines serialize any branch, ternaries included. + float quadRadius(vec2 p, vec4 r) { + vec2 stepVal = step(vec2(0.0), p); + return mix(mix(r.x, r.y, stepVal.x), mix(r.w, r.z, stepVal.x), stepVal.y); + } + float roundedBox(vec2 p, vec2 s, vec4 r) { - r.xy = (p.x > 0.0) ? r.yz : r.xw; - r.x = (p.y > 0.0) ? r.y : r.x; - vec2 q = abs(p) - s + r.x; - return (min(max(q.x, q.y), 0.0) + length(max(q, 0.0))) - r.x; + float rad = quadRadius(p, r); + vec2 q = abs(p) - s + rad; + return (min(max(q.x, q.y), 0.0) + length(max(q, 0.0))) - rad; } void main() { vec4 color = texture2D(u_texture, v_textureCoords) * v_color; - vec4 resultColor = vec4(0.0); vec2 boxUv = v_nodeCoords.xy * u_dimensions - v_halfDimensions; float borderZero = 1.0 - step(0.001, dot(abs(u_borderWidth), vec4(1.0))); float edgeWidth = 1.0 / u_pixelRatio; - float nodeDist; - float nodeAlpha; - - if(borderZero == 1.0) { - nodeDist = roundedBox(boxUv, v_halfDimensions - edgeWidth, u_radius); - nodeAlpha = 1.0 - smoothstep(-0.5 * edgeWidth, 0.5 * edgeWidth, nodeDist); - gl_FragColor = (color * nodeAlpha) * u_alpha; - return; - } + float nodeDist = roundedBox(boxUv, v_halfDimensions - edgeWidth, u_radius); + float nodeAlpha = 1.0 - smoothstep(-0.5 * edgeWidth, 0.5 * edgeWidth, nodeDist); float outerDist = roundedBox(boxUv + v_outerBorderUv, v_outerSize - edgeWidth, v_outerBorderRadius); float innerDist = roundedBox(boxUv + v_innerBorderUv, v_innerSize - edgeWidth, v_innerBorderRadius); - - if(u_borderGap == 0.0) { - float outerAlpha = 1.0 - smoothstep(-0.5 * edgeWidth, 0.5 * edgeWidth, outerDist); - float innerAlpha = 1.0 - smoothstep(-0.5 * edgeWidth, 0.5 * edgeWidth, innerDist); - resultColor = mix(resultColor, u_borderColor, outerAlpha * u_borderColor.a); - resultColor = mix(resultColor, color, innerAlpha); - gl_FragColor = resultColor * u_alpha; - return; - } - - nodeDist = roundedBox(boxUv, v_halfDimensions - edgeWidth, u_radius); - nodeAlpha = 1.0 - smoothstep(-0.5 * edgeWidth, 0.5 * edgeWidth, nodeDist); + float outerAlpha = 1.0 - smoothstep(-0.5 * edgeWidth, 0.5 * edgeWidth, outerDist); float innerAlpha = 1.0 - smoothstep(-0.5 * edgeWidth, 0.5 * edgeWidth, innerDist); - float gapAlpha = max(0.0, innerAlpha - nodeAlpha); + float gapAlpha = max(0.0, innerAlpha - nodeAlpha); float borderDist = max(-innerDist, outerDist); float borderAlpha = 1.0 - smoothstep(-0.5 * edgeWidth, 0.5 * edgeWidth, borderDist); - resultColor = (color * nodeAlpha) + (u_fillColor * gapAlpha); - resultColor = mix(resultColor, u_borderColor, borderAlpha * u_borderColor.a); + // Branchless path select -- Mali 400 serializes uniform branches, so all + // three composites (no gap / gap / zero border) are computed and + // mix()-selected instead of if/else'd. + vec4 resNoGap = mix(vec4(0.0), u_borderColor, outerAlpha * u_borderColor.a); + resNoGap = mix(resNoGap, color, innerAlpha); + + vec4 resGap = (color * nodeAlpha) + (u_fillColor * gapAlpha); + resGap = mix(resGap, u_borderColor, borderAlpha * u_borderColor.a); + + float hasGap = step(0.0001, abs(u_borderGap)); + vec4 resultColor = mix(resNoGap, resGap, hasGap); + resultColor = mix(resultColor, color * nodeAlpha, borderZero); gl_FragColor = resultColor * u_alpha; } `, diff --git a/src/core/shaders/webgl/RoundedWithBorderAndShadow.ts b/src/core/shaders/webgl/RoundedWithBorderAndShadow.ts index a2d17e0..75daebd 100644 --- a/src/core/shaders/webgl/RoundedWithBorderAndShadow.ts +++ b/src/core/shaders/webgl/RoundedWithBorderAndShadow.ts @@ -71,6 +71,15 @@ export const RoundedWithBorderAndShadow: WebGlShaderType 0.0) ? r.yz : r.xw; - r.x = (p.y > 0.0) ? r.y : r.x; - vec2 q = abs(p) - s + r.x; - return (min(max(q.x, q.y), 0.0) + length(max(q, 0.0))) - r.x; + float rad = quadRadius(p, r); + vec2 q = abs(p) - s + rad; + return (min(max(q.x, q.y), 0.0) + length(max(q, 0.0))) - rad; } float shadowBox(vec2 p, vec2 s, vec4 r) { - r.xy = (p.x > 0.0) ? r.yz : r.xw; - r.x = (p.y > 0.0) ? r.y : r.x; - vec2 q = abs(p) - s + r.x; - float dist = min(max(q.x, q.y), 0.0) + length(max(q, 0.0)) - r.x; + float rad = quadRadius(p, r); + vec2 q = abs(p) - s + rad; + float dist = min(max(q.x, q.y), 0.0) + length(max(q, 0.0)) - rad; return 1.0 - smoothstep(-u_shadow.w, u_shadow.w + u_shadow.z, dist); } void main() { vec4 color = texture2D(u_texture, v_textureCoords) * v_color; - vec4 resultColor = vec4(0.0); vec2 boxUv = v_nodeCoords.xy * u_dimensions - v_halfDimensions; float borderZero = 1.0 - step(0.001, dot(abs(u_borderWidth), vec4(1.0))); float edgeWidth = 1.0 / u_pixelRatio; - float nodeDist; - float nodeAlpha; - float shadowAlpha; - - if(borderZero == 1.0) { - nodeDist = roundedBox(boxUv, v_halfDimensions - edgeWidth, u_radius); - nodeAlpha = 1.0 - smoothstep(-0.5 * edgeWidth, 0.5 * edgeWidth, nodeDist); - shadowAlpha = shadowBox(boxUv - u_shadow.xy, v_halfDimensions + u_shadow.w - edgeWidth, u_radius + u_shadow.z); - resultColor = mix(resultColor, u_shadowColor, shadowAlpha); - gl_FragColor = mix(resultColor, color, nodeAlpha) * u_alpha; - return; - } - if(v_outerSize.x > v_halfDimensions.x || v_outerSize.y > v_halfDimensions.y) { - shadowAlpha = shadowBox(boxUv + v_outerBorderUv - u_shadow.xy, v_outerSize + u_shadow.w - edgeWidth, v_outerBorderRadius + u_shadow.z); - } - else { - shadowAlpha = shadowBox(boxUv - u_shadow.xy, v_halfDimensions + u_shadow.w - edgeWidth, u_radius + u_shadow.z); - } + float nodeDist = roundedBox(boxUv, v_halfDimensions - edgeWidth, u_radius); + float nodeAlpha = 1.0 - smoothstep(-0.5 * edgeWidth, 0.5 * edgeWidth, nodeDist); + + // Shadow geometry select, branchless: when the border grows the quad + // beyond the node (outer size exceeds half dimensions on either axis) + // the shadow hugs the outer border box, otherwise the node box. This + // replaces a varying-driven if/else (worst offender: diverges per + // fragment) with a step()/mix() parameter select into ONE shadowBox call. + // When borderZero, v_outerSize is vec2(0.0) so outerSel is 0 and the + // node-box parameters win, matching the old zero-border path. + float outerSel = max( + step(v_halfDimensions.x, v_outerSize.x), + step(v_halfDimensions.y, v_outerSize.y) + ); + float shadowAlpha = shadowBox( + boxUv + v_outerBorderUv * outerSel - u_shadow.xy, + mix(v_halfDimensions, v_outerSize, outerSel) + u_shadow.w - edgeWidth, + mix(u_radius, v_outerBorderRadius, outerSel) + u_shadow.z + ); float outerDist = roundedBox(boxUv + v_outerBorderUv, v_outerSize - edgeWidth, v_outerBorderRadius); float innerDist = roundedBox(boxUv + v_innerBorderUv, v_innerSize - edgeWidth, v_innerBorderRadius); + float outerAlpha = 1.0 - smoothstep(-0.5 * edgeWidth, 0.5 * edgeWidth, outerDist); + float innerAlpha = 1.0 - smoothstep(-0.5 * edgeWidth, 0.5 * edgeWidth, innerDist); - if(u_borderGap == 0.0) { - float outerAlpha = 1.0 - smoothstep(-0.5 * edgeWidth, 0.5 * edgeWidth, outerDist); - float innerAlpha = 1.0 - smoothstep(-0.5 * edgeWidth, 0.5 * edgeWidth, innerDist); - resultColor = mix(resultColor, u_shadowColor, shadowAlpha); - resultColor = mix(resultColor, u_borderColor, outerAlpha * u_borderColor.a); - resultColor = mix(resultColor, color, innerAlpha); - gl_FragColor = resultColor * u_alpha; - return; - } - - nodeDist = roundedBox(boxUv, v_halfDimensions - edgeWidth, u_radius); - nodeAlpha = 1.0 - smoothstep(-0.5 * edgeWidth, 0.5 * edgeWidth, nodeDist); float borderDist = max(-innerDist, outerDist); float borderAlpha = 1.0 - smoothstep(-0.5 * edgeWidth, 0.5 * edgeWidth, borderDist); - resultColor = mix(resultColor, u_shadowColor, shadowAlpha); - resultColor = mix(resultColor, color, nodeAlpha); - resultColor = mix(resultColor, u_borderColor, borderAlpha * u_borderColor.a); + + // Branchless path select -- Mali 400 serializes uniform branches, so all + // three composites (no gap / gap / zero border) are computed and + // mix()-selected instead of if/else'd. + vec4 shadowBase = u_shadowColor * shadowAlpha; + + vec4 resNoGap = mix(shadowBase, u_borderColor, outerAlpha * u_borderColor.a); + resNoGap = mix(resNoGap, color, innerAlpha); + + vec4 resFill = mix(shadowBase, color, nodeAlpha); + vec4 resGap = mix(resFill, u_borderColor, borderAlpha * u_borderColor.a); + + float hasGap = step(0.0001, abs(u_borderGap)); + vec4 resultColor = mix(resNoGap, resGap, hasGap); + resultColor = mix(resultColor, resFill, borderZero); gl_FragColor = resultColor * u_alpha; } `, diff --git a/src/core/shaders/webgl/RoundedWithShadow.ts b/src/core/shaders/webgl/RoundedWithShadow.ts index 9dd1f89..5ec8c0d 100644 --- a/src/core/shaders/webgl/RoundedWithShadow.ts +++ b/src/core/shaders/webgl/RoundedWithShadow.ts @@ -98,18 +98,23 @@ export const RoundedWithShadow: WebGlShaderType = { varying vec4 v_shadowRadius; varying vec2 v_shadowSmooth; + // Branchless quadrant radius select (r: x TL, y TR, z BR, w BL) -- + // Mali 400-class fragment pipelines serialize any branch, ternaries included. + float quadRadius(vec2 p, vec4 r) { + vec2 stepVal = step(vec2(0.0), p); + return mix(mix(r.x, r.y, stepVal.x), mix(r.w, r.z, stepVal.x), stepVal.y); + } + float roundedBox(vec2 p, vec2 s, vec4 r) { - r.xy = (p.x > 0.0) ? r.yz : r.xw; - r.x = (p.y > 0.0) ? r.y : r.x; - vec2 q = abs(p) - s + r.x; - return (min(max(q.x, q.y), 0.0) + length(max(q, 0.0))) - r.x; + float rad = quadRadius(p, r); + vec2 q = abs(p) - s + rad; + return (min(max(q.x, q.y), 0.0) + length(max(q, 0.0))) - rad; } float shadowBox(vec2 p, vec2 s, vec4 r) { - r.xy = (p.x > 0.0) ? r.yz : r.xw; - r.x = (p.y > 0.0) ? r.y : r.x; - vec2 q = abs(p) - s + r.x; - float dist = min(max(q.x, q.y), 0.0) + length(max(q, 0.0)) - r.x; + float rad = quadRadius(p, r); + vec2 q = abs(p) - s + rad; + float dist = min(max(q.x, q.y), 0.0) + length(max(q, 0.0)) - rad; return 1.0 - smoothstep(v_shadowSmooth.x, v_shadowSmooth.y, dist); } @@ -119,11 +124,8 @@ export const RoundedWithShadow: WebGlShaderType = { float boxDist = roundedBox(v_boxUv, v_boxSize, u_radius); float roundedAlpha = 1.0 - smoothstep(v_boxSmooth.x, v_boxSmooth.y, boxDist); - vec4 resColor = vec4(0.0); - if (u_shadow_color.a > 0.0) { - float shadowAlpha = shadowBox(v_shadowBox, v_shadowSize, v_shadowRadius); - resColor = u_shadow_color * shadowAlpha; - } + float shadowAlpha = shadowBox(v_shadowBox, v_shadowSize, v_shadowRadius); + vec4 resColor = u_shadow_color * (shadowAlpha * step(0.0001, u_shadow_color.a)); resColor = mix(resColor, color, min(color.a, roundedAlpha)); gl_FragColor = resColor * u_alpha; diff --git a/src/core/shaders/webgl/Shadow.ts b/src/core/shaders/webgl/Shadow.ts index 3163fda..6e9bae9 100644 --- a/src/core/shaders/webgl/Shadow.ts +++ b/src/core/shaders/webgl/Shadow.ts @@ -106,11 +106,10 @@ export const Shadow: WebGlShaderType = { float boxDist = box(v_boxUv, v_boxSize); float boxAlpha = 1.0 - smoothstep(v_boxSmooth.x, v_boxSmooth.y, boxDist); - vec4 resColor = vec4(0.0); - if (u_color.a > 0.0) { - float shadowDist = shadowBox(v_shadowBox, v_shadowSize, v_shadowRadius); - resColor = u_color * shadowDist; - } + // Branchless alpha gate -- Mali 400 serializes uniform branches; the + // step() zeroes the shadow term exactly when u_color.a == 0. + float shadowDist = shadowBox(v_shadowBox, v_shadowSize, v_shadowRadius); + vec4 resColor = u_color * (shadowDist * step(0.0001, u_color.a)); resColor = mix(resColor, color, min(color.a, boxAlpha)); gl_FragColor = resColor * u_alpha; diff --git a/visual-regression/certified-snapshots/chromium-ci/shader-border-zero-1.png b/visual-regression/certified-snapshots/chromium-ci/shader-border-zero-1.png new file mode 100644 index 0000000000000000000000000000000000000000..c0f7347c6e94830098228395963564bf537615aa GIT binary patch literal 29309 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

tl- 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 literal 0 HcmV?d00001