feat: add text rendering with SDF's

This commit is contained in:
2026-10-17 16:00:51 +02:00
parent 7dd5ee7903
commit 67c685a158
12 changed files with 746 additions and 51 deletions
+131 -39
View File
@@ -11,60 +11,152 @@ layout (location = 2) flat in uint inShapeIndex;
layout (location = 0) out vec4 outFragColor;
float sdRoundedRect(vec2 p, vec2 halfSize, float radius)
// FreeType stores the edge at 128, inside is brighter
const float TEXT_SDF_EDGE = 128.0 / 255.0;
float roundedRectDistance(vec2 position, vec2 halfSize, float cornerRadius)
{
radius = min(radius, min(halfSize.x, halfSize.y));
vec2 q = abs(p) - halfSize + radius;
return length(max(q, 0.0)) + min(max(q.x, q.y), 0.0) - radius;
cornerRadius = min(cornerRadius, min(halfSize.x, halfSize.y));
vec2 cornerOffset = abs(position) - halfSize + cornerRadius;
float outsideDistance = length(max(cornerOffset, 0.0));
float insideDistance = min(max(cornerOffset.x, cornerOffset.y), 0.0);
return outsideDistance + insideDistance - cornerRadius;
}
// Approximate, but exact on the boundary which is what the edge and outline need
float sdEllipse(vec2 p, vec2 radii)
float ellipseDistance(vec2 position, vec2 radii)
{
radii = max(radii, vec2(1e-4));
float k0 = length(p / radii);
float k1 = length(p / (radii * radii));
return k0 < 1e-4 ? -min(radii.x, radii.y) : k0 * (k0 - 1.0) / k1;
float normalizedLength = length(position / radii);
if (normalizedLength < 1e-4) {
return -min(radii.x, radii.y);
}
float gradientLength = length(position / (radii * radii));
return normalizedLength * (normalizedLength - 1.0) / gradientLength;
}
float sdTriangle(vec2 p, vec2 p0, vec2 p1, vec2 p2)
vec2 closestPointOnSegmentOffset(vec2 position, vec2 segmentStart, vec2 segmentEnd)
{
vec2 e0 = p1 - p0, e1 = p2 - p1, e2 = p0 - p2;
vec2 v0 = p - p0, v1 = p - p1, v2 = p - p2;
vec2 pq0 = v0 - e0 * clamp(dot(v0, e0) / max(dot(e0, e0), 1e-8), 0.0, 1.0);
vec2 pq1 = v1 - e1 * clamp(dot(v1, e1) / max(dot(e1, e1), 1e-8), 0.0, 1.0);
vec2 pq2 = v2 - e2 * clamp(dot(v2, e2) / max(dot(e2, e2), 1e-8), 0.0, 1.0);
float winding = sign(e0.x * e2.y - e0.y * e2.x);
vec2 d = min(min(vec2(dot(pq0, pq0), winding * (v0.x * e0.y - v0.y * e0.x)),
vec2(dot(pq1, pq1), winding * (v1.x * e1.y - v1.y * e1.x))),
vec2(dot(pq2, pq2), winding * (v2.x * e2.y - v2.y * e2.x)));
return -sqrt(d.x) * sign(d.y);
vec2 segment = segmentEnd - segmentStart;
vec2 toPosition = position - segmentStart;
float along = clamp(
dot(toPosition, segment) / max(dot(segment, segment), 1e-8),
0.0, 1.0
);
return toPosition - segment * along;
}
float edgeSide(vec2 position, vec2 edgeStart, vec2 edgeEnd)
{
vec2 edge = edgeEnd - edgeStart;
vec2 toPosition = position - edgeStart;
return toPosition.x * edge.y - toPosition.y * edge.x;
}
float triangleDistance(vec2 position, vec2 a, vec2 b, vec2 c)
{
vec2 offsetAB = closestPointOnSegmentOffset(position, a, b);
vec2 offsetBC = closestPointOnSegmentOffset(position, b, c);
vec2 offsetCA = closestPointOnSegmentOffset(position, c, a);
float closestDistanceSquared = min(
dot(offsetAB, offsetAB),
min(dot(offsetBC, offsetBC), dot(offsetCA, offsetCA))
);
float winding = sign(edgeSide(c, a, b));
bool isInside =
winding * edgeSide(position, a, b) > 0.0 &&
winding * edgeSide(position, b, c) > 0.0 &&
winding * edgeSide(position, c, a) > 0.0;
float edgeDistance = sqrt(closestDistanceSquared);
return isInside ? -edgeDistance : edgeDistance;
}
float edgeCoverage(float signedDistance)
{
float pixelWidth = max(fwidth(signedDistance), 1e-4);
return clamp(0.5 - signedDistance / pixelWidth, 0.0, 1.0);
}
vec2 shapeUv(Shape2D shape)
{
return mix(
shape.uvRect.xy,
shape.uvRect.zw,
clamp(inQuadPos, 0.0, 1.0)
);
}
vec4 shadeText(Shape2D shape)
{
float sdf = sampleTexture2D(
shape.textureId, shape.samplerId, shapeUv(shape)
).r;
float signedDistance = (TEXT_SDF_EDGE - sdf) * shape.p0.x;
float fillCoverage = edgeCoverage(signedDistance);
if (shape.outline <= 0.0) {
return vec4(
shape.color.rgb,
shape.color.a * fillCoverage
);
}
vec4 outlineColor = vec4(shape.p1, shape.p2);
float outlineCoverage = edgeCoverage(signedDistance - shape.outline);
vec4 result = mix(outlineColor, shape.color, fillCoverage);
result.a *= outlineCoverage;
return result;
}
float shapeDistance(Shape2D shape)
{
if (shape.type == SHAPE_ELLIPSE) {
return ellipseDistance(inLocalPos, shape.halfSize);
}
if (shape.type == SHAPE_TRIANGLE) {
return triangleDistance(inLocalPos, shape.p0, shape.p1, shape.p2);
}
return roundedRectDistance(inLocalPos, shape.halfSize, shape.cornerRadius);
}
vec4 shadeShape(Shape2D shape)
{
float signedDistance = shapeDistance(shape);
if (shape.outline > 0.0) {
float halfOutline = shape.outline * 0.5;
signedDistance = abs(signedDistance + halfOutline) - halfOutline;
}
vec4 textureColor = sampleTexture2D(
shape.textureId, shape.samplerId, shapeUv(shape)
);
vec4 result = textureColor * shape.color;
result.a *= edgeCoverage(signedDistance);
return result;
}
void main()
{
Shape2D shape = pcs.shapeBuffer.shapes[inShapeIndex];
float dist;
if (shape.type == SHAPE_ELLIPSE) {
dist = sdEllipse(inLocalPos, shape.halfSize);
} else if (shape.type == SHAPE_TRIANGLE) {
dist = sdTriangle(inLocalPos, shape.p0, shape.p1, shape.p2);
if (shape.type == SHAPE_TEXT) {
outFragColor = shadeText(shape);
} else {
dist = sdRoundedRect(inLocalPos, shape.halfSize, shape.cornerRadius);
outFragColor = shadeShape(shape);
}
// Outlines grow inwards so the outer edge matches the filled shape
if (shape.outline > 0.0) {
dist = abs(dist + shape.outline * 0.5) - shape.outline * 0.5;
}
float edgeWidth = max(fwidth(dist), 1e-4);
float coverage = clamp(0.5 - dist / edgeWidth, 0.0, 1.0);
vec2 uv = mix(shape.uvRect.xy, shape.uvRect.zw, clamp(inQuadPos, 0.0, 1.0));
vec4 texColor = sampleTexture2D(shape.textureId, shape.samplerId, uv);
outFragColor = texColor * shape.color;
outFragColor.a *= coverage;
}
+1 -1
View File
@@ -4,8 +4,8 @@
#define SHAPE_RECT 0
#define SHAPE_ELLIPSE 1
#define SHAPE_TRIANGLE 2
#define SHAPE_TEXT 3
// Mirrors Shape2D in Renderer2D.h
struct Shape2D {
vec2 center;
vec2 halfSize;
+18 -10
View File
@@ -7,27 +7,35 @@ layout (location = 0) out vec2 outLocalPos;
layout (location = 1) out vec2 outQuadPos;
layout (location = 2) flat out uint outShapeIndex;
// Room for the antialiased edge outside the shape's bounds
const float EDGE_MARGIN = 1.0;
const vec2 CORNERS[6] = vec2[](
const vec2 QUAD_CORNERS[6] = vec2[](
vec2(-1.0, -1.0), vec2(1.0, -1.0), vec2(1.0, 1.0),
vec2(-1.0, -1.0), vec2(1.0, 1.0), vec2(-1.0, 1.0)
);
vec2 rotate(vec2 position, float angle)
{
float cosAngle = cos(angle);
float sinAngle = sin(angle);
return vec2(
position.x * cosAngle - position.y * sinAngle,
position.x * sinAngle + position.y * cosAngle
);
}
void main()
{
Shape2D shape = pcs.shapeBuffer.shapes[gl_InstanceIndex];
vec2 corner = CORNERS[gl_VertexIndex];
vec2 localPos = corner * (shape.halfSize + EDGE_MARGIN);
vec2 corner = QUAD_CORNERS[gl_VertexIndex];
vec2 localPosition = corner * (shape.halfSize + EDGE_MARGIN);
vec2 screenPosition = shape.center + rotate(localPosition, shape.rotation);
float c = cos(shape.rotation);
float s = sin(shape.rotation);
vec2 rotated = vec2(localPos.x * c - localPos.y * s, localPos.x * s + localPos.y * c);
gl_Position = pcs.projection * vec4(screenPosition, 0.0, 1.0);
gl_Position = pcs.projection * vec4(shape.center + rotated, 0.0, 1.0);
outLocalPos = localPos;
outQuadPos = localPos / max(shape.halfSize, vec2(1e-6)) * 0.5 + 0.5;
outLocalPos = localPosition;
outQuadPos = localPosition / max(shape.halfSize, vec2(1e-6)) * 0.5 + 0.5;
outShapeIndex = gl_InstanceIndex;
}