fix: blob corner fill snap + visual artifacts

This commit is contained in:
2 * r + 2 * t 2026-06-04 19:31:34 +10:00
parent a0acd1a22c
commit 665594023e
2 changed files with 36 additions and 16 deletions

View file

@ -32,6 +32,18 @@ static float cpuSmoothstep(float edge0, float edge1, float x) {
return t * t * (3.0f - 2.0f * t); return t * t * (3.0f - 2.0f * t);
} }
static float cornerFillFactor(float sd, float smoothFactor) {
// Continuous two-sided window. The corner is squared (factor -> 0) only within
// ±smoothFactor of the neighbour's edge (the visible junction); it keeps its full
// radius both far outside the neighbour and deep inside it (where it is buried and
// squaring would only crease the interior). C0-continuous across sd = 0 — unlike the
// old `if (sd >= 0)` branch, which snapped the radius full<->square (factor 1<->0) on
// sub-pixel motion as a corner crossed the edge, flickering the fill bridge in/out.
const float outside = cpuSmoothstep(0.0f, smoothFactor, sd); // 0 at edge, ->1 far outside
const float inside = cpuSmoothstep(0.0f, -smoothFactor, sd); // 0 at edge, ->1 deep inside
return std::max(outside, inside);
}
BlobShape::BlobShape(QQuickItem* parent) BlobShape::BlobShape(QQuickItem* parent)
: QQuickItem(parent) { : QQuickItem(parent) {
setFlag(ItemHasContents); setFlag(ItemHasContents);
@ -310,20 +322,16 @@ void BlobShape::updatePolish() {
if (si->isCornerExcluded(sj) || sj->isCornerExcluded(si)) if (si->isCornerExcluded(sj) || sj->isCornerExcluded(si))
continue; continue;
const auto& rj = m_cachedRects[j]; const auto& rj = m_cachedRects[j];
// Skip when the corner is inside rj: it's buried, not on a visible junction, // Square each corner only near rj's edge; keep full radius far outside AND
// so squaring it would only perturb interior SDF gradients. // deep inside rj (buried, so it can't crease the visible junction).
const float sdTr = cpuSdBox(cTrX, cTrY, rj.cx, rj.cy, rj.hw, rj.hh); const float sdTr = cpuSdBox(cTrX, cTrY, rj.cx, rj.cy, rj.hw, rj.hh);
const float sdBr = cpuSdBox(cBrX, cBrY, rj.cx, rj.cy, rj.hw, rj.hh); const float sdBr = cpuSdBox(cBrX, cBrY, rj.cx, rj.cy, rj.hw, rj.hh);
const float sdBl = cpuSdBox(cBlX, cBlY, rj.cx, rj.cy, rj.hw, rj.hh); const float sdBl = cpuSdBox(cBlX, cBlY, rj.cx, rj.cy, rj.hw, rj.hh);
const float sdTl = cpuSdBox(cTlX, cTlY, rj.cx, rj.cy, rj.hw, rj.hh); const float sdTl = cpuSdBox(cTlX, cTlY, rj.cx, rj.cy, rj.hw, rj.hh);
if (sdTr >= 0.0f) fTr = std::min(fTr, cornerFillFactor(sdTr, smoothFactor));
fTr = std::min(fTr, cpuSmoothstep(0.0f, smoothFactor, sdTr)); fBr = std::min(fBr, cornerFillFactor(sdBr, smoothFactor));
if (sdBr >= 0.0f) fBl = std::min(fBl, cornerFillFactor(sdBl, smoothFactor));
fBr = std::min(fBr, cpuSmoothstep(0.0f, smoothFactor, sdBr)); fTl = std::min(fTl, cornerFillFactor(sdTl, smoothFactor));
if (sdBl >= 0.0f)
fBl = std::min(fBl, cpuSmoothstep(0.0f, smoothFactor, sdBl));
if (sdTl >= 0.0f)
fTl = std::min(fTl, cpuSmoothstep(0.0f, smoothFactor, sdTl));
} }
if (cornerFill && m_cachedHasInverted) { if (cornerFill && m_cachedHasInverted) {

View file

@ -42,9 +42,12 @@ float sdBox(vec2 p, vec2 center, vec2 halfSize) {
float smin(float a, float b, float k) { float smin(float a, float b, float k) {
// Circular smooth min — the blend fillet is a true circular arc of radius k, // Circular smooth min — the blend fillet is a true circular arc of radius k,
// tangent to both surfaces (not a polynomial/squircle curve). Deviation region // tangent to both surfaces (not a polynomial/squircle curve). Deviates from
// width = k (deviates only where both a < k and b < k), matching the cubic it // min(a, b) only in the corner region where BOTH a < k and b < k (unlike the
// replaces. Always <= min(a, b); max blend depth at a == b is (sqrt(2) - 1) * k. // cubic it replaced, which deviated over the whole band |a - b| < k). Always
// <= min(a, b); max blend depth at a == b is (sqrt(2) - 1) * k. It is C1 but
// not C2 at the support boundary, so a circular-arc fillet shows the usual
// line-meets-arc curvature step — by design, that is the "circular" look.
return max(k, min(a, b)) - length(max(vec2(k) - vec2(a, b), vec2(0.0))); return max(k, min(a, b)) - length(max(vec2(k) - vec2(a, b), vec2(0.0)));
} }
@ -164,8 +167,8 @@ void main() {
continue; continue;
if ((excludeMask & (1 << j)) != 0) if ((excludeMask & (1 << j)) != 0)
continue; continue;
// smin only deviates from min within smoothFactor // Circular smin deviates from min only where BOTH dArr are < smoothFactor.
if (abs(dArr[i] - dArr[j]) >= smoothFactor) if (max(dArr[i], dArr[j]) >= smoothFactor)
continue; continue;
mergedSdf = min(mergedSdf, smin(dArr[i], dArr[j], smoothFactor)); mergedSdf = min(mergedSdf, smin(dArr[i], dArr[j], smoothFactor));
} }
@ -231,7 +234,16 @@ void main() {
dInner -= sinkValue; dInner -= sinkValue;
float dFrame = smaxSharpA(dOuter, -dInner, smoothFactor); // The circular smax fillet has radius kFrame; when it exceeds the border thickness
// it can't complete inside the border, so the sharp outer-box term bleeds onto the
// inner edge and bulges the inner corners (worst when thickness < smoothFactor — the
// default border is thinner than the blend radius). Clamp kFrame to the thinnest side
// so the inner edge stays a clean constant-radius arc. Each inner corner is bounded by
// its thinner adjacent side, so the global min is correct for every corner.
float minThick = min(min(innerTop - outerTop, outerBot - innerBot),
min(innerLeft - outerLeft, outerRight - innerRight));
float kFrame = clamp(min(smoothFactor, minThick - 1.0), 1.0, smoothFactor);
float dFrame = smaxSharpA(dOuter, -dInner, kFrame);
mergedSdf = smin(mergedSdf, dFrame, smoothFactor); mergedSdf = smin(mergedSdf, dFrame, smoothFactor);
if (dFrame < minDist) { if (dFrame < minDist) {