Skip to content

refactor(shaders): make all WebGL fragment shaders branchless for Mali 400#118

Merged
chiefcll merged 1 commit into
mainfrom
claude/nostalgic-jones-4d1edb
Jul 2, 2026
Merged

refactor(shaders): make all WebGL fragment shaders branchless for Mali 400#118
chiefcll merged 1 commit into
mainfrom
claude/nostalgic-jones-4d1edb

Conversation

@chiefcll

@chiefcll chiefcll commented Jul 2, 2026

Copy link
Copy Markdown
Contributor

What

Removes every if/else/ternary from the WebGL fragment shaders, per the Mali 400 / OpenGL ES 2.0 GPU floor documented in CLAUDE.md (in-order scalar fragment pipelines serialize both sides of any branch — including uniform-driven ones).

  • Ternary quadrant-radius selects (r.xy = (p.x > 0.0) ? r.yz : r.xw; ...) in RoundedWithShadow, RoundedWithBorder, RoundedWithBorderAndShadow, HolePunch → the branchless step()+mix() select already used in Rounded (shared quadRadius() helper).
  • Varying-driven branch in RoundedWithBorderAndShadow (worst offender — diverges per fragment) selecting between two shadowBox calls → step()/mix() parameter select into a single shadowBox call.
  • Uniform-driven early-return paths (borderZero, u_borderGap, u_color.a, u_shadow_color.a) in RoundedWithBorder, RoundedWithBorderAndShadow, Border, Shadow, RoundedWithShadow → compute all composites, mix()-select the result.
  • Gradient stop loops with data-driven if + return-inside-loop in LinearGradient, RadialGradient, RadialProgress → the previously-unused genGradientColors() mix-accumulation in ShaderUtils, rewritten to smoothstep-per-segment so it is pixel-identical to the old segment select, and unrolled per colors:N #define variant.
  • RadialProgress uniform ternaries (u_duration, u_countdown, progress normalize) → step()/mix(), with mix()-guarded denominators so the divisions stay finite at 0 without overflowing fp16 mediump to Inf/NaN (a max()-epsilon guard would).

Why step/mix instead of #define variants for the uniform branches

Shader props are runtime-mutable — animations write shaderProps directly and CoreShaderNode prop setters fire RecalcUniforms — while the program is fixed at createShader() time (getCacheMarkers is evaluated once). Keying a compile-time variant on border-w == 0 / border-gap == 0 would silently break a border animating from 0. The structural colors:N markers (array length can't animate) keep their #define unrolling. On Mali 400 the old serialized branch already paid for all paths per batch, so computing-everything+mix is equal or cheaper.

Reviewer notes

  • The border vertex shaders now initialize v_outerBorderUv/v_innerBorderUv/v_*BorderRadius in the zero-border case — the branchless fragment always evaluates the border SDFs, and an unwritten varying is undefined and can poison the final mix() with NaN. (The remaining vertex-shader if(borderZero == 0.0) is fine: the Mali 400 constraint is the fragment pipeline.)
  • The gap select uses step(0.0001, abs(u_borderGap))abs() matters, negative gaps are valid (see shader-border-gap) and the original condition was == 0.0.
  • No varyings added (RoundedWithBorder* stays at 7/8 rows), #ifdef GL_FRAGMENT_PRECISION_HIGH guards kept, every non-void GLSL function ends in an unconditional return, uniform values remain a pure function of resolvedProps + node w/h.
  • Canvas backend untouched (no branching constraint there).

Verification

  • Visual regression vs the certified chromium-ci snapshots (generated from the old branchy shaders), run in Docker: 180/180 pass, pixel-identical.
  • New shader-border-zero example test + certified snapshot covering the merged degenerate paths (zero-width border on both RoundedWithBorder* shaders, zero-alpha shadow on Shadow/RoundedWithShadow) — manually inspected.
  • New unit tests execute the generated genGradientColors GLSL (via mix/smoothstep shims) against the original segment-select reference across a 100-point sweep.
  • pnpm build, pnpm test (274 tests), pnpm lint clean.

🤖 Generated with Claude Code

…i 400

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 <noreply@anthropic.com>
@chiefcll chiefcll merged commit ef6ffe1 into main Jul 2, 2026
1 check passed
@chiefcll chiefcll deleted the claude/nostalgic-jones-4d1edb branch July 2, 2026 01:18
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant