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 - Continue source cleanup/refactoring. Pass 1 done
- Support a separate sound shader `.slang` file in shader packages. - Support a separate sound shader `.slang` file in shader packages.
- Add WebView2 - Add WebView2
- move to MSDF, typography rasterisation

View File

@@ -13,9 +13,9 @@
namespace namespace
{ {
constexpr int kTextSdfSpread = 20; constexpr int kTextSdfSpread = 20;
constexpr unsigned kTextSdfBlurPasses = 1;
constexpr float kTextFontPixelSize = 144.0f; constexpr float kTextFontPixelSize = 144.0f;
constexpr float kTextLayoutPadding = 48.0f; constexpr float kTextLayoutPadding = 48.0f;
constexpr float kSdfInfinity = 1.0e20f;
class GdiplusSession class GdiplusSession
{ {
@@ -54,128 +54,115 @@ std::wstring Utf8ToWide(const std::string& text)
return wide; 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 y = 0; y < height; ++y)
{ {
for (unsigned x = 0; x < width; ++x) for (unsigned x = 0; x < width; ++x)
{ rowInput[x] = targetMask[static_cast<std::size_t>(y) * width + x] ? 0.0f : kSdfInfinity;
const bool inside = alpha[static_cast<std::size_t>(y) * width + x] > 127; DistanceTransform1D(rowInput, rowOutput, width);
int bestDistanceSq = kTextSdfSpread * kTextSdfSpread; for (unsigned x = 0; x < width; ++x)
for (int oy = -kTextSdfSpread; oy <= kTextSdfSpread; ++oy) rowDistance[static_cast<std::size_t>(y) * width + x] = rowOutput[x];
{
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;
}
} }
const float distance = std::sqrt(static_cast<float>(bestDistanceSq)); for (unsigned x = 0; x < width; ++x)
const float signedDistance = (inside ? 1.0f : -1.0f) * distance; {
float normalized = 0.5f + signedDistance / static_cast<float>(kTextSdfSpread * 2); for (unsigned y = 0; y < height; ++y)
const unsigned char sourceAlpha = alpha[static_cast<std::size_t>(y) * width + x]; columnInput[y] = rowDistance[static_cast<std::size_t>(y) * width + x];
if (sourceAlpha > 0 && sourceAlpha < 255) DistanceTransform1D(columnInput, columnOutput, height);
normalized = static_cast<float>(sourceAlpha) / 255.0f; for (unsigned y = 0; y < height; ++y)
if (normalized < 0.0f) distance[static_cast<std::size_t>(y) * width + x] = columnOutput[y];
normalized = 0.0f; }
if (normalized > 1.0f)
normalized = 1.0f; 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 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 + 0] = value;
sdf[out + 1] = value; sdf[out + 1] = value;
sdf[out + 2] = value; sdf[out + 2] = value;
sdf[out + 3] = value; sdf[out + 3] = value;
} }
} }
return sdf; 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) void WriteTextMaskDebugDump(const std::string& text, const std::vector<unsigned char>& alpha, const std::vector<unsigned char>& sdf, unsigned width, unsigned height)
{ {
try 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); alpha[static_cast<std::size_t>(y) * kTextTextureWidth + x] = static_cast<unsigned char>(luminance);
} }
} }
sdf = BuildTextCoverageTexture(alpha, kTextTextureWidth, kTextTextureHeight); sdf = BuildTextSdfTexture(alpha, kTextTextureWidth, kTextTextureHeight);
sdf = BlurTextSdf(sdf, kTextTextureWidth, kTextTextureHeight, kTextSdfBlurPasses);
sdf = FlipTextTextureForShaderUv(sdf, kTextTextureWidth, kTextTextureHeight);
WriteTextMaskDebugDump(text, alpha, sdf, kTextTextureWidth, kTextTextureHeight); WriteTextMaskDebugDump(text, alpha, sdf, kTextTextureWidth, kTextTextureHeight);
return true; return true;
} }

View File

@@ -4,7 +4,7 @@
#include <string> #include <string>
#include <vector> #include <vector>
constexpr unsigned kTextTextureWidth = 2048; constexpr unsigned kTextTextureWidth = 4096;
constexpr unsigned kTextTextureHeight = 256; constexpr unsigned kTextTextureHeight = 512;
bool RasterizeTextSdf(const std::string& text, const std::filesystem::path& fontPath, std::vector<unsigned char>& sdf, std::string& error); 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", "type": "float",
"default": 0.42, "default": 0.42,
"min": 0.1, "min": 0.1,
"max": 1.5, "max": 3,
"step": 0.01 "step": 0.01
}, },
{ {

View File

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