Typography improvements
All checks were successful
CI / React UI Build (push) Successful in 11s
CI / Native Windows Build And Tests (push) Successful in 1m31s
CI / Windows Release Package (push) Successful in 2m20s

This commit is contained in:
2026-05-06 13:16:02 +10:00
parent 3dc7af6fc0
commit e59677c212
5 changed files with 126 additions and 129 deletions

View File

@@ -254,3 +254,4 @@ If neither variable is set, the workflow falls back to the repo-local defaults u
- Continue source cleanup/refactoring. Pass 1 done
- Support a separate sound shader `.slang` file in shader packages.
- Add WebView2
- move to MSDF, typography rasterisation

View File

@@ -13,9 +13,9 @@
namespace
{
constexpr int kTextSdfSpread = 20;
constexpr unsigned kTextSdfBlurPasses = 1;
constexpr float kTextFontPixelSize = 144.0f;
constexpr float kTextLayoutPadding = 48.0f;
constexpr float kSdfInfinity = 1.0e20f;
class GdiplusSession
{
@@ -54,128 +54,115 @@ std::wstring Utf8ToWide(const std::string& text)
return wide;
}
std::vector<unsigned char> BuildLocalSdf(const std::vector<unsigned char>& alpha, unsigned width, unsigned height)
void DistanceTransform1D(const std::vector<float>& input, std::vector<float>& output, unsigned count)
{
std::vector<unsigned char> sdf(static_cast<std::size_t>(width) * height * 4, 0);
std::vector<unsigned> locations(count, 0);
std::vector<float> boundaries(static_cast<std::size_t>(count) + 1, 0.0f);
unsigned segment = 0;
locations[0] = 0;
boundaries[0] = -kSdfInfinity;
boundaries[1] = kSdfInfinity;
for (unsigned q = 1; q < count; ++q)
{
float intersection = 0.0f;
for (;;)
{
const unsigned location = locations[segment];
intersection =
((input[q] + static_cast<float>(q * q)) - (input[location] + static_cast<float>(location * location))) /
(2.0f * static_cast<float>(q) - 2.0f * static_cast<float>(location));
if (intersection > boundaries[segment] || segment == 0)
break;
--segment;
}
++segment;
locations[segment] = q;
boundaries[segment] = intersection;
boundaries[segment + 1] = kSdfInfinity;
}
segment = 0;
for (unsigned q = 0; q < count; ++q)
{
while (boundaries[segment + 1] < static_cast<float>(q))
++segment;
const unsigned location = locations[segment];
const float delta = static_cast<float>(q) - static_cast<float>(location);
output[q] = delta * delta + input[location];
}
}
std::vector<float> DistanceTransform2D(const std::vector<unsigned char>& targetMask, unsigned width, unsigned height)
{
std::vector<float> rowInput(width, 0.0f);
std::vector<float> rowOutput(width, 0.0f);
std::vector<float> columnInput(height, 0.0f);
std::vector<float> columnOutput(height, 0.0f);
std::vector<float> rowDistance(static_cast<std::size_t>(width) * height, 0.0f);
std::vector<float> distance(static_cast<std::size_t>(width) * height, 0.0f);
for (unsigned y = 0; y < height; ++y)
{
for (unsigned x = 0; x < width; ++x)
{
const bool inside = alpha[static_cast<std::size_t>(y) * width + x] > 127;
int bestDistanceSq = kTextSdfSpread * kTextSdfSpread;
for (int oy = -kTextSdfSpread; oy <= kTextSdfSpread; ++oy)
{
const int sy = static_cast<int>(y) + oy;
if (sy < 0 || sy >= static_cast<int>(height))
continue;
for (int ox = -kTextSdfSpread; ox <= kTextSdfSpread; ++ox)
{
const int sx = static_cast<int>(x) + ox;
if (sx < 0 || sx >= static_cast<int>(width))
continue;
const bool sampleInside = alpha[static_cast<std::size_t>(sy) * width + sx] > 127;
if (sampleInside == inside)
continue;
const int distanceSq = ox * ox + oy * oy;
if (distanceSq < bestDistanceSq)
bestDistanceSq = distanceSq;
}
}
rowInput[x] = targetMask[static_cast<std::size_t>(y) * width + x] ? 0.0f : kSdfInfinity;
DistanceTransform1D(rowInput, rowOutput, width);
for (unsigned x = 0; x < width; ++x)
rowDistance[static_cast<std::size_t>(y) * width + x] = rowOutput[x];
}
const float distance = std::sqrt(static_cast<float>(bestDistanceSq));
const float signedDistance = (inside ? 1.0f : -1.0f) * distance;
float normalized = 0.5f + signedDistance / static_cast<float>(kTextSdfSpread * 2);
const unsigned char sourceAlpha = alpha[static_cast<std::size_t>(y) * width + x];
if (sourceAlpha > 0 && sourceAlpha < 255)
normalized = static_cast<float>(sourceAlpha) / 255.0f;
if (normalized < 0.0f)
normalized = 0.0f;
if (normalized > 1.0f)
normalized = 1.0f;
for (unsigned x = 0; x < width; ++x)
{
for (unsigned y = 0; y < height; ++y)
columnInput[y] = rowDistance[static_cast<std::size_t>(y) * width + x];
DistanceTransform1D(columnInput, columnOutput, height);
for (unsigned y = 0; y < height; ++y)
distance[static_cast<std::size_t>(y) * width + x] = columnOutput[y];
}
return distance;
}
std::vector<unsigned char> BuildTextSdfTexture(const std::vector<unsigned char>& alpha, unsigned width, unsigned height)
{
std::vector<unsigned char> insideMask(static_cast<std::size_t>(width) * height, 0);
std::vector<unsigned char> outsideMask(static_cast<std::size_t>(width) * height, 0);
for (std::size_t index = 0; index < alpha.size(); ++index)
{
const bool inside = alpha[index] > 127;
insideMask[index] = inside ? 1 : 0;
outsideMask[index] = inside ? 0 : 1;
}
const std::vector<float> distanceToInside = DistanceTransform2D(insideMask, width, height);
const std::vector<float> distanceToOutside = DistanceTransform2D(outsideMask, width, height);
std::vector<unsigned char> sdf(static_cast<std::size_t>(width) * height * 4, 0);
for (unsigned y = 0; y < height; ++y)
{
const unsigned flippedY = height - 1 - y;
for (unsigned x = 0; x < width; ++x)
{
const std::size_t source = static_cast<std::size_t>(y) * width + x;
const float signedDistance = std::sqrt(distanceToOutside[source]) - std::sqrt(distanceToInside[source]);
const float normalized = std::clamp(
0.5f + signedDistance / static_cast<float>(kTextSdfSpread * 2),
0.0f,
1.0f);
const unsigned char value = static_cast<unsigned char>(normalized * 255.0f + 0.5f);
const std::size_t out = (static_cast<std::size_t>(y) * width + x) * 4;
const std::size_t out = (static_cast<std::size_t>(flippedY) * width + x) * 4;
sdf[out + 0] = value;
sdf[out + 1] = value;
sdf[out + 2] = value;
sdf[out + 3] = value;
}
}
return sdf;
}
std::vector<unsigned char> BuildTextCoverageTexture(const std::vector<unsigned char>& alpha, unsigned width, unsigned height)
{
std::vector<unsigned char> coverage(static_cast<std::size_t>(width) * height * 4, 0);
for (unsigned y = 0; y < height; ++y)
{
for (unsigned x = 0; x < width; ++x)
{
const unsigned char value = alpha[static_cast<std::size_t>(y) * width + x];
const std::size_t out = (static_cast<std::size_t>(y) * width + x) * 4;
coverage[out + 0] = value;
coverage[out + 1] = value;
coverage[out + 2] = value;
coverage[out + 3] = value;
}
}
return coverage;
}
std::vector<unsigned char> FlipTextTextureForShaderUv(const std::vector<unsigned char>& pixels, unsigned width, unsigned height)
{
std::vector<unsigned char> flipped(pixels.size(), 0);
const std::size_t stride = static_cast<std::size_t>(width) * 4;
for (unsigned y = 0; y < height; ++y)
{
const std::size_t srcOffset = static_cast<std::size_t>(y) * stride;
const std::size_t dstOffset = static_cast<std::size_t>(height - 1 - y) * stride;
std::memcpy(flipped.data() + dstOffset, pixels.data() + srcOffset, stride);
}
return flipped;
}
std::vector<unsigned char> BlurTextSdf(const std::vector<unsigned char>& pixels, unsigned width, unsigned height, unsigned passes)
{
std::vector<unsigned char> current = pixels;
std::vector<unsigned char> next(pixels.size(), 0);
for (unsigned pass = 0; pass < passes; ++pass)
{
for (unsigned y = 0; y < height; ++y)
{
for (unsigned x = 0; x < width; ++x)
{
unsigned weightedTotal = 0;
unsigned weightSum = 0;
for (int oy = -1; oy <= 1; ++oy)
{
const int sy = static_cast<int>(y) + oy;
if (sy < 0 || sy >= static_cast<int>(height))
continue;
for (int ox = -1; ox <= 1; ++ox)
{
const int sx = static_cast<int>(x) + ox;
if (sx < 0 || sx >= static_cast<int>(width))
continue;
const unsigned weight = (ox == 0 && oy == 0) ? 4u : ((ox == 0 || oy == 0) ? 2u : 1u);
const std::size_t sample = (static_cast<std::size_t>(sy) * width + sx) * 4;
weightedTotal += static_cast<unsigned>(current[sample]) * weight;
weightSum += weight;
}
}
const unsigned char value = static_cast<unsigned char>((weightedTotal + weightSum / 2) / weightSum);
const std::size_t out = (static_cast<std::size_t>(y) * width + x) * 4;
next[out + 0] = value;
next[out + 1] = value;
next[out + 2] = value;
next[out + 3] = value;
}
}
current.swap(next);
}
return current;
}
void WriteTextMaskDebugDump(const std::string& text, const std::vector<unsigned char>& alpha, const std::vector<unsigned char>& sdf, unsigned width, unsigned height)
{
try
@@ -308,9 +295,7 @@ bool RasterizeTextSdf(const std::string& text, const std::filesystem::path& font
alpha[static_cast<std::size_t>(y) * kTextTextureWidth + x] = static_cast<unsigned char>(luminance);
}
}
sdf = BuildTextCoverageTexture(alpha, kTextTextureWidth, kTextTextureHeight);
sdf = BlurTextSdf(sdf, kTextTextureWidth, kTextTextureHeight, kTextSdfBlurPasses);
sdf = FlipTextTextureForShaderUv(sdf, kTextTextureWidth, kTextTextureHeight);
sdf = BuildTextSdfTexture(alpha, kTextTextureWidth, kTextTextureHeight);
WriteTextMaskDebugDump(text, alpha, sdf, kTextTextureWidth, kTextTextureHeight);
return true;
}

View File

@@ -4,7 +4,7 @@
#include <string>
#include <vector>
constexpr unsigned kTextTextureWidth = 2048;
constexpr unsigned kTextTextureHeight = 256;
constexpr unsigned kTextTextureWidth = 4096;
constexpr unsigned kTextTextureHeight = 512;
bool RasterizeTextSdf(const std::string& text, const std::filesystem::path& fontPath, std::vector<unsigned char>& sdf, std::string& error);

View File

@@ -34,7 +34,7 @@
"type": "float",
"default": 0.42,
"min": 0.1,
"max": 1.5,
"max": 3,
"step": 0.01
},
{

View File

@@ -10,6 +10,12 @@ float4 compositeOver(float4 baseColor, float4 overColor)
return float4(outRgb, outAlpha);
}
float sdfCoverage(float2 uv, float edge, float aa)
{
float distance = sampleTitleText(uv);
return smoothstep(edge - aa, edge + aa, distance);
}
float4 shadeVideo(ShaderContext context)
{
float2 resolution = max(context.outputResolution, float2(1.0, 1.0));
@@ -19,21 +25,26 @@ float4 shadeVideo(ShaderContext context)
float2 textUv = (context.uv - position) / safeTextSize;
bool insideTextRect = textUv.x >= 0.0 && textUv.x <= 1.0 && textUv.y >= 0.0 && textUv.y <= 1.0;
float mask = insideTextRect ? sampleTitleText(textUv) : 0.0;
float edge = 0.02;
float aa = max(fwidth(mask) * 1.5, 0.002);
float fill = smoothstep(edge - aa, edge + aa, mask);
float shadowRadius = min((outlineWidth + softness) * 0.025, 0.018);
float shadow = 0.0;
if (shadowRadius > 0.0001)
{
shadow = max(shadow, sampleTitleText(textUv + float2(shadowRadius, shadowRadius)));
shadow = max(shadow, sampleTitleText(textUv + float2(-shadowRadius, shadowRadius)));
shadow = max(shadow, sampleTitleText(textUv + float2(shadowRadius, -shadowRadius)));
shadow = max(shadow, sampleTitleText(textUv + float2(-shadowRadius, -shadowRadius)));
}
shadow = smoothstep(edge - aa, edge + aa, shadow) * (0.35 + softness);
float outlineAlpha = saturate(shadow * (1.0 - fill)) * outlineColor.a;
float distance = insideTextRect ? sampleTitleText(textUv) : 0.0;
float edge = 0.5;
float aa = max(fwidth(distance) * (1.75 + softness * 5.0), 0.0025);
float2 pixelTextUv = (1.0 / resolution) / safeTextSize;
float2 sampleOffset = pixelTextUv * 0.38;
float fill = (
sdfCoverage(textUv, edge, aa) * 2.0 +
sdfCoverage(textUv + float2(sampleOffset.x, sampleOffset.y), edge, aa) +
sdfCoverage(textUv + float2(-sampleOffset.x, sampleOffset.y), edge, aa) +
sdfCoverage(textUv + float2(sampleOffset.x, -sampleOffset.y), edge, aa) +
sdfCoverage(textUv + float2(-sampleOffset.x, -sampleOffset.y), edge, aa)) / 6.0;
float outlineDistance = outlineWidth * 0.16;
float outlineEdge = edge - outlineDistance;
float outline = (
sdfCoverage(textUv, outlineEdge, aa) * 2.0 +
sdfCoverage(textUv + float2(sampleOffset.x, sampleOffset.y), outlineEdge, aa) +
sdfCoverage(textUv + float2(-sampleOffset.x, sampleOffset.y), outlineEdge, aa) +
sdfCoverage(textUv + float2(sampleOffset.x, -sampleOffset.y), outlineEdge, aa) +
sdfCoverage(textUv + float2(-sampleOffset.x, -sampleOffset.y), outlineEdge, aa)) / 6.0;
float outlineAlpha = saturate(outline - fill) * outlineColor.a;
float fillAlpha = fill * fillColor.a;
float textAlpha = max(fillAlpha, outlineAlpha);
if (textAlpha <= 0.0001)