diff --git a/src/core/shaders/webgl/RoundedWithBorder.test.ts b/src/core/shaders/webgl/RoundedWithBorder.test.ts new file mode 100644 index 0000000..5dfd53a --- /dev/null +++ b/src/core/shaders/webgl/RoundedWithBorder.test.ts @@ -0,0 +1,131 @@ +import { describe, expect, it } from 'vitest'; +import { + BORDER_VALUES_LENGTH, + calcBorderShaderValues, +} from './RoundedWithBorder.js'; +import type { Vec4 } from '../../renderers/webgl/internal/ShaderUtils.js'; + +const calc = ( + borderWidth: Vec4, + align: number, + gap: number, + radius: Vec4, + w: number, + h: number, +): Float32Array => { + const out = new Float32Array(BORDER_VALUES_LENGTH); + calcBorderShaderValues(borderWidth, align, gap, radius, w, h, out); + return out; +}; + +describe('calcBorderShaderValues', () => { + it('should compute uniform border, align outside, no gap', () => { + // border 4 on all sides, align=1 (outside), gap=0, radius 16, 200x100 node + // adjustedBorderWidth = 4 - 1 + clamp(4, -1, 1) = 4 + const v = calc([4, 4, 4, 4], 1, 0, [16, 16, 16, 16], 200, 100); + + // outerSize = (dimensions + gapSize + borderSize * align) * 0.5 + expect(v[0]).toBe(104); + expect(v[1]).toBe(54); + // innerSize = outerSize - borderSize * 0.5 + expect(v[2]).toBe(100); + expect(v[3]).toBe(50); + // symmetric border -> zero UV offsets (-0 from sign() * 0 is fine) + expect(v[4]).toBeCloseTo(0); + expect(v[5]).toBeCloseTo(0); + expect(v[6]).toBeCloseTo(0); + expect(v[7]).toBeCloseTo(0); + // outerBorderRadius = radius + max(top*align+gap, side*align+gap) = 16 + 4 + expect(v[8]).toBe(20); + expect(v[9]).toBe(20); + expect(v[10]).toBe(20); + expect(v[11]).toBe(20); + // innerBorderRadius = outer - max(top, side) = 20 - 4 + expect(v[12]).toBe(16); + expect(v[13]).toBe(16); + expect(v[14]).toBe(16); + expect(v[15]).toBe(16); + // outerSize exceeds half dimensions -> edgeOffset = extraSize + gap + expect(v[16]).toBe(8); + expect(v[17]).toBe(8); + }); + + it('should compute asymmetric border with gap and center align', () => { + // borderWidth [top, right, bottom, left] = [2, 4, 6, 8], align=0.5, gap=2 + // adjusted widths equal inputs since all > 1 + const v = calc([2, 4, 6, 8], 0.5, 2, [10, 10, 10, 10], 100, 80); + + // borderSize = (right+left, top+bottom) = (12, 8); extra = (6, 4) + // all sides have width -> gapSize = (4, 4) + // outerSize = ((100+4+6)/2, (80+4+4)/2) + expect(v[0]).toBe(55); + expect(v[1]).toBe(44); + // innerSize = outer - (6, 4) + expect(v[2]).toBe(49); + expect(v[3]).toBe(40); + // borderDiff = (right-left, bottom-top) = (-4, 4); gapDiff = (0, 0) + // outerUv = (-sign*abs*align*0.5, ...) = (1, -1) + expect(v[4]).toBe(1); + expect(v[5]).toBe(-1); + // innerUv = outerUv + sign*abs*0.5 = (-1, 1) + expect(v[6]).toBe(-1); + expect(v[7]).toBe(1); + // outer radius per corner: 10 + max(adjacent side*0.5 + 2) + expect(v[8]).toBe(16); // TL: 10 + max(2*0.5+2, 8*0.5+2) = 10 + 6 + expect(v[9]).toBe(14); // TR: 10 + max(3, 4) + expect(v[10]).toBe(15); // BR: 10 + max(5, 4) + expect(v[11]).toBe(16); // BL: 10 + max(5, 6) + // inner radius: outer - max(adjacent widths) + expect(v[12]).toBe(8); // 16 - max(2, 8) + expect(v[13]).toBe(10); // 14 - max(2, 4) + expect(v[14]).toBe(9); // 15 - max(6, 4) + expect(v[15]).toBe(8); // 16 - max(6, 8) + // edgeOffset = extraSize + gap + expect(v[16]).toBe(8); + expect(v[17]).toBe(6); + }); + + it('should collapse sub-pixel widths like the GLSL adjustment', () => { + // adjusted = 0.5 - 1 + clamp(0.5) = 0 -> behaves as zero-width sides + const v = calc([0.5, 0.5, 0.5, 0.5], 1, 2, [10, 10, 10, 10], 100, 100); + + // borderSize = (0, 0); gap only applies to sides >= 0.001 -> none + expect(v[0]).toBe(50); + expect(v[1]).toBe(50); + expect(v[2]).toBe(50); + expect(v[3]).toBe(50); + // edgeOffset: outerSize >= halfDimensions -> extra(0) + gap(2) + expect(v[16]).toBe(2); + expect(v[17]).toBe(2); + }); + + it('should align inside without growing beyond node bounds', () => { + // align=0 (inside), border 4, no gap: outerSize = halfDimensions + const v = calc([4, 4, 4, 4], 0, 0, [10, 10, 10, 10], 100, 100); + + expect(v[0]).toBe(50); + expect(v[1]).toBe(50); + expect(v[2]).toBe(46); + expect(v[3]).toBe(46); + // outer radius unchanged at align=0, gap=0 + expect(v[8]).toBe(10); + // inner radius = 10 - 4 + expect(v[12]).toBe(6); + // outerSize == halfDimensions -> step passes, but extra+gap is 0 + expect(v[16]).toBe(0); + expect(v[17]).toBe(0); + }); + + it('should clamp negative inner radius to zero', () => { + // border wider than radius: inner radius would go negative + const v = calc([12, 12, 12, 12], 1, 0, [4, 4, 4, 4], 200, 200); + + // outer = 4 + 12, inner = 16 - 12 = 4; with radius 0 corners: + expect(v[8]).toBe(16); + expect(v[12]).toBe(4); + + const sharp = calc([12, 12, 12, 12], 1, 0, [0, 0, 0, 0], 200, 200); + expect(sharp[8]).toBe(12); + expect(sharp[12]).toBe(0); + }); +}); diff --git a/src/core/shaders/webgl/RoundedWithBorder.ts b/src/core/shaders/webgl/RoundedWithBorder.ts index ac66fc9..db51cd0 100644 --- a/src/core/shaders/webgl/RoundedWithBorder.ts +++ b/src/core/shaders/webgl/RoundedWithBorder.ts @@ -7,19 +7,187 @@ import { type RoundedWithBorderProps, } from '../templates/RoundedWithBorderTemplate.js'; +/** + * Layout of the values written by {@link calcBorderShaderValues}: + * + * ``` + * [0] outerSize.x [1] outerSize.y + * [2] innerSize.x [3] innerSize.y + * [4] outerBorderUv.x [5] outerBorderUv.y + * [6] innerBorderUv.x [7] innerBorderUv.y + * [8..11] outerBorderRadius (TL, TR, BR, BL) + * [12..15] innerBorderRadius (TL, TR, BR, BL) + * [16] edgeOffset.x [17] edgeOffset.y + * ``` + */ +export const BORDER_VALUES_LENGTH = 18; + +/** + * CPU mirror of the border math that previously ran in the vertex shader. + * + * Every value here is constant across the quad (derived only from border + * props, factored radius and node dimensions), so computing it once per + * prop/dimension change and uploading uniforms is strictly cheaper than + * interpolating 18 floats of varyings per fragment. + * + * `borderWidth` is [top, right, bottom, left]; `radius` is the already + * factored corner radius array (TL, TR, BR, BL). + */ +export const calcBorderShaderValues = ( + borderWidth: Vec4, + align: number, + gap: number, + radius: Vec4, + w: number, + h: number, + out: Float32Array, +): void => { + // adjustedBorderWidth = u_borderWidth - 1.0 + clamp(u_borderWidth, -1.0, 1.0) + const bwT = borderWidth[0]; + const bwR = borderWidth[1]; + const bwB = borderWidth[2]; + const bwL = borderWidth[3]; + const top = bwT - 1 + (bwT < -1 ? -1 : bwT > 1 ? 1 : bwT); + const right = bwR - 1 + (bwR < -1 ? -1 : bwR > 1 ? 1 : bwR); + const bottom = bwB - 1 + (bwB < -1 ? -1 : bwB > 1 ? 1 : bwB); + const left = bwL - 1 + (bwL < -1 ? -1 : bwL > 1 ? 1 : bwL); + + const borderSizeX = right + left; + const borderSizeY = top + bottom; + const extraX = borderSizeX * align; + const extraY = borderSizeY * align; + + // gap* = step(0.001, border*) * u_borderGap + const gapTop = top >= 0.001 ? gap : 0; + const gapRight = right >= 0.001 ? gap : 0; + const gapBottom = bottom >= 0.001 ? gap : 0; + const gapLeft = left >= 0.001 ? gap : 0; + const gapSizeX = gapLeft + gapRight; + const gapSizeY = gapTop + gapBottom; + + const outerX = (w + gapSizeX + extraX) * 0.5; + const outerY = (h + gapSizeY + extraY) * 0.5; + out[0] = outerX; + out[1] = outerY; + out[2] = outerX - borderSizeX * 0.5; + out[3] = outerY - borderSizeY * 0.5; + + let borderDiffX = right - left; + let borderDiffY = bottom - top; + const signDiffX = borderDiffX > 0 ? 1 : borderDiffX < 0 ? -1 : 0; + const signDiffY = borderDiffY > 0 ? 1 : borderDiffY < 0 ? -1 : 0; + borderDiffX = borderDiffX < 0 ? -borderDiffX : borderDiffX; + borderDiffY = borderDiffY < 0 ? -borderDiffY : borderDiffY; + + let gapDiffX = gapRight - gapLeft; + let gapDiffY = gapBottom - gapTop; + const signGapDiffX = gapDiffX > 0 ? 1 : gapDiffX < 0 ? -1 : 0; + const signGapDiffY = gapDiffY > 0 ? 1 : gapDiffY < 0 ? -1 : 0; + gapDiffX = gapDiffX < 0 ? -gapDiffX : gapDiffX; + gapDiffY = gapDiffY < 0 ? -gapDiffY : gapDiffY; + + const outerUvX = + -signDiffX * borderDiffX * align * 0.5 - signGapDiffX * gapDiffX * 0.5; + const outerUvY = + -signDiffY * borderDiffY * align * 0.5 - signGapDiffY * gapDiffY * 0.5; + out[4] = outerUvX; + out[5] = outerUvY; + out[6] = outerUvX + signDiffX * borderDiffX * 0.5; + out[7] = outerUvY + signDiffY * borderDiffY * 0.5; + + const alignTop = top * align + gap; + const alignRight = right * align + gap; + const alignBottom = bottom * align + gap; + const alignLeft = left * align + gap; + + const oTl = radius[0] + (alignTop > alignLeft ? alignTop : alignLeft); + const oTr = radius[1] + (alignTop > alignRight ? alignTop : alignRight); + const oBr = radius[2] + (alignBottom > alignRight ? alignBottom : alignRight); + const oBl = radius[3] + (alignBottom > alignLeft ? alignBottom : alignLeft); + out[8] = oTl < 0 ? 0 : oTl; + out[9] = oTr < 0 ? 0 : oTr; + out[10] = oBr < 0 ? 0 : oBr; + out[11] = oBl < 0 ? 0 : oBl; + + const iTl = out[8]! - (top > left ? top : left); + const iTr = out[9]! - (top > right ? top : right); + const iBr = out[10]! - (bottom > right ? bottom : right); + const iBl = out[11]! - (bottom > left ? bottom : left); + out[12] = iTl < 0 ? 0 : iTl; + out[13] = iTr < 0 ? 0 : iTr; + out[14] = iBr < 0 ? 0 : iBr; + out[15] = iBl < 0 ? 0 : iBl; + + // edgeOffset = step(u_dimensions * 0.5, outerSize) * (extraSize + u_borderGap) + out[16] = outerX >= w * 0.5 ? extraX + gap : 0; + out[17] = outerY >= h * 0.5 ? extraY + gap : 0; +}; + +// Scratch buffer for calcBorderShaderValues. Safe to share: the values are +// copied into the uniform collection by the uniform setters before the next +// update() call can run. +const borderValues = new Float32Array(BORDER_VALUES_LENGTH); + +/** + * Similar to the {@link DefaultShader} but cuts out 4 rounded rectangle corners + * as defined by the specified corner {@link RoundedProps.radius} and renders a + * border as defined by {@link RoundedWithBorderProps}. + * + * All border geometry is precomputed on the CPU (cached per prop/dimension + * change via the shader value-key cache) and uploaded as uniforms, so the + * fragment shader carries the same 3 varyings as the plain Rounded shader. + * With a zero-width border the per-pixel cost is identical to Rounded. + */ export const RoundedWithBorder: WebGlShaderType = { props: RoundedWithBorderTemplate.props, update(node: CoreNode) { this.uniformRGBA('u_borderColor', this.props!['border-color']); this.uniformRGBA('u_fillColor', this.props!['border-fill']); - this.uniform4fa('u_borderWidth', this.props!['border-w'] as Vec4); - this.uniform1f('u_borderGap', this.props!['border-gap'] as number); - this.uniform1f('u_borderAlign', this.props!['border-align'] as number); + const gap = this.props!['border-gap'] as number; + this.uniform1f('u_borderGap', gap); + + const radius = calcFactoredRadiusArray( + this.props!.radius as Vec4, + node.w, + node.h, + ); + this.uniform4fa('u_radius', radius); + + const borderWidth = this.props!['border-w'] as Vec4; + + // borderZero = 1.0 - step(0.001, dot(abs(u_borderWidth), vec4(1.0))) + const sumAbs = + (borderWidth[0] < 0 ? -borderWidth[0] : borderWidth[0]) + + (borderWidth[1] < 0 ? -borderWidth[1] : borderWidth[1]) + + (borderWidth[2] < 0 ? -borderWidth[2] : borderWidth[2]) + + (borderWidth[3] < 0 ? -borderWidth[3] : borderWidth[3]); + const borderZero = sumAbs >= 0.001 ? 0 : 1; + this.uniform1f('u_borderZero', borderZero); + + // With no border, both shader stages early-out before reading any of the + // border uniforms, so skip computing and uploading them entirely. + if (borderZero === 1) { + return; + } - this.uniform4fa( - 'u_radius', - calcFactoredRadiusArray(this.props!.radius as Vec4, node.w, node.h), + const v = borderValues; + calcBorderShaderValues( + borderWidth, + this.props!['border-align'] as number, + gap, + radius, + node.w, + node.h, + v, ); + + this.uniform2f('u_outerSize', v[0]!, v[1]!); + this.uniform2f('u_innerSize', v[2]!, v[3]!); + this.uniform2f('u_outerBorderUv', v[4]!, v[5]!); + this.uniform2f('u_innerBorderUv', v[6]!, v[7]!); + this.uniform4f('u_outerBorderRadius', v[8]!, v[9]!, v[10]!, v[11]!); + this.uniform4f('u_innerBorderRadius', v[12]!, v[13]!, v[14]!, v[15]!); + this.uniform2f('u_edgeOffset', v[16]!, v[17]!); }, vertex: ` # ifdef GL_FRAGMENT_PRECISION_HIGH @@ -37,94 +205,28 @@ export const RoundedWithBorder: WebGlShaderType = { uniform float u_pixelRatio; uniform vec2 u_dimensions; - uniform vec4 u_radius; - uniform vec4 u_borderWidth; - uniform float u_borderGap; - uniform float u_borderAlign; + uniform float u_borderZero; + uniform vec2 u_edgeOffset; varying vec4 v_color; varying vec2 v_textureCoords; varying vec2 v_nodeCoords; - varying vec2 v_innerSize; - varying vec2 v_outerSize; - varying vec2 v_outerBorderUv; - varying vec2 v_innerBorderUv; - varying vec4 v_innerBorderRadius; - varying vec4 v_outerBorderRadius; - varying vec2 v_halfDimensions; - void main() { - vec2 vertexPos = a_position * u_pixelRatio; vec2 screenSpace = vec2(2.0 / u_resolution.x, -2.0 / u_resolution.y); vec2 edge = clamp(a_nodeCoords * 2.0 - vec2(1.0), -1.0, 1.0); - vec2 edgeOffset = vec2(0.0); - float borderZero = 1.0 - step(0.001, dot(abs(u_borderWidth), vec4(1.0))); - - v_innerSize = vec2(0.0); - v_outerSize = vec2(0.0); - - if(borderZero == 0.0) { - vec4 adjustedBorderWidth = u_borderWidth - 1.0 + clamp(u_borderWidth, -1.0, 1.0); - - float borderTop = adjustedBorderWidth.x; - float borderRight = adjustedBorderWidth.y; - 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; - float gapRight = step(0.001, borderRight) * u_borderGap; - float gapTop = step(0.001, borderTop) * u_borderGap; - float gapBottom = step(0.001, borderBottom) * u_borderGap; - vec2 gapSize = vec2(gapLeft + gapRight, gapTop + gapBottom); - - v_outerSize = (u_dimensions + gapSize + extraSize) * 0.5; - v_innerSize = v_outerSize - borderSize * 0.5; - - // Use sign() to avoid branching - vec2 borderDiff = vec2(borderRight - borderLeft, borderBottom - borderTop); - vec2 signDiff = sign(borderDiff); - borderDiff = abs(borderDiff); - - vec2 gapDiff = vec2(gapRight - gapLeft, gapBottom - gapTop); - vec2 signGapDiff = sign(gapDiff); - gapDiff = abs(gapDiff); - - v_outerBorderUv = -signDiff * borderDiff * u_borderAlign * 0.5 - signGapDiff * gapDiff * 0.5; - v_innerBorderUv = v_outerBorderUv + signDiff * borderDiff * 0.5; - - v_outerBorderRadius = vec4( - max(0.0, u_radius.x + max(borderTop * u_borderAlign + u_borderGap, borderLeft * u_borderAlign + u_borderGap)), - max(0.0, u_radius.y + max(borderTop * u_borderAlign + u_borderGap, borderRight * u_borderAlign + u_borderGap)), - max(0.0, u_radius.z + max(borderBottom * u_borderAlign + u_borderGap, borderRight * u_borderAlign + u_borderGap)), - max(0.0, u_radius.w + max(borderBottom * u_borderAlign + u_borderGap, borderLeft * u_borderAlign + u_borderGap)) - ); - - v_innerBorderRadius = vec4( - max(0.0, v_outerBorderRadius.x - max(borderTop, borderLeft)), - max(0.0, v_outerBorderRadius.y - max(borderTop, borderRight)), - max(0.0, v_outerBorderRadius.z - max(borderBottom, borderRight)), - max(0.0, v_outerBorderRadius.w - max(borderBottom, borderLeft)) - ); - - vec2 edgeOffsetExtra = step(u_dimensions * 0.5, v_outerSize) * edge * (extraSize + u_borderGap); - edgeOffset = edgeOffsetExtra; - - vertexPos = (a_position + edge + edgeOffset) * u_pixelRatio; - } + + // With a border the quad is expanded by 1px plus the precomputed + // outside-growth; u_borderZero zeroes both terms when borderless. + float hasBorder = 1.0 - u_borderZero; + vec2 edgeOffset = edge * u_edgeOffset * hasBorder; + vec2 vertexPos = (a_position + edge * hasBorder + edgeOffset) * u_pixelRatio; gl_Position = vec4(vertexPos.x * screenSpace.x - 1.0, -sign(screenSpace.y) * (vertexPos.y * -abs(screenSpace.y)) + 1.0, 0.0, 1.0); v_color = a_color; v_nodeCoords = a_nodeCoords + (screenSpace + edgeOffset) / (u_dimensions); v_textureCoords = a_textureCoords + (screenSpace + edgeOffset) / (u_dimensions); - - v_halfDimensions = u_dimensions * 0.5; } `, fragment: ` @@ -134,31 +236,28 @@ export const RoundedWithBorder: WebGlShaderType = { precision mediump float; # endif - uniform vec2 u_resolution; uniform float u_pixelRatio; uniform float u_alpha; uniform vec2 u_dimensions; uniform sampler2D u_texture; uniform vec4 u_radius; - uniform vec4 u_borderWidth; uniform vec4 u_borderColor; uniform vec4 u_fillColor; uniform float u_borderGap; - uniform float u_borderAlign; + uniform float u_borderZero; + + uniform vec2 u_innerSize; + uniform vec2 u_outerSize; + uniform vec2 u_outerBorderUv; + uniform vec2 u_innerBorderUv; + uniform vec4 u_innerBorderRadius; + uniform vec4 u_outerBorderRadius; varying vec4 v_color; varying vec2 v_textureCoords; varying vec2 v_nodeCoords; - varying vec2 v_innerSize; - varying vec2 v_outerSize; - varying vec2 v_outerBorderUv; - varying vec2 v_innerBorderUv; - varying vec4 v_innerBorderRadius; - varying vec4 v_outerBorderRadius; - varying vec2 v_halfDimensions; - 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; @@ -169,22 +268,22 @@ export const RoundedWithBorder: WebGlShaderType = { 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))); + vec2 halfDimensions = u_dimensions * 0.5; + vec2 boxUv = v_nodeCoords.xy * u_dimensions - halfDimensions; float edgeWidth = 1.0 / u_pixelRatio; float nodeDist; float nodeAlpha; - if(borderZero == 1.0) { - nodeDist = roundedBox(boxUv, v_halfDimensions - edgeWidth, u_radius); + if(u_borderZero == 1.0) { + nodeDist = roundedBox(boxUv, halfDimensions - edgeWidth, u_radius); nodeAlpha = 1.0 - smoothstep(-0.5 * edgeWidth, 0.5 * edgeWidth, nodeDist); gl_FragColor = (color * nodeAlpha) * u_alpha; return; } - float outerDist = roundedBox(boxUv + v_outerBorderUv, v_outerSize - edgeWidth, v_outerBorderRadius); - float innerDist = roundedBox(boxUv + v_innerBorderUv, v_innerSize - edgeWidth, v_innerBorderRadius); + float outerDist = roundedBox(boxUv + u_outerBorderUv, u_outerSize - edgeWidth, u_outerBorderRadius); + float innerDist = roundedBox(boxUv + u_innerBorderUv, u_innerSize - edgeWidth, u_innerBorderRadius); if(u_borderGap == 0.0) { float outerAlpha = 1.0 - smoothstep(-0.5 * edgeWidth, 0.5 * edgeWidth, outerDist); @@ -195,7 +294,7 @@ export const RoundedWithBorder: WebGlShaderType = { return; } - nodeDist = roundedBox(boxUv, v_halfDimensions - edgeWidth, u_radius); + nodeDist = roundedBox(boxUv, halfDimensions - edgeWidth, u_radius); nodeAlpha = 1.0 - smoothstep(-0.5 * edgeWidth, 0.5 * edgeWidth, nodeDist); float innerAlpha = 1.0 - smoothstep(-0.5 * edgeWidth, 0.5 * edgeWidth, innerDist); float gapAlpha = max(0.0, innerAlpha - nodeAlpha);