Text rendering
Some checks failed
CI / React UI Build (push) Successful in 40s
CI / Native Windows Build And Tests (push) Failing after 2m28s
CI / Windows Release Package (push) Has been skipped

This commit is contained in:
2026-05-21 15:39:37 +10:00
parent 09efe2d6a0
commit df0a77ef01
8 changed files with 107 additions and 52 deletions

View File

@@ -46,7 +46,7 @@
"id": "scale", "id": "scale",
"label": "Scale", "label": "Scale",
"type": "float", "type": "float",
"default": 0.42, "default": 1,
"min": 0.1, "min": 0.1,
"max": 3, "max": 3,
"step": 0.01, "step": 0.01,
@@ -72,7 +72,7 @@
0, 0,
0, 0,
0, 0,
0.8 1
], ],
"description": "Text outline color and alpha." "description": "Text outline color and alpha."
}, },
@@ -80,10 +80,10 @@
"id": "outlineWidth", "id": "outlineWidth",
"label": "Outline Width", "label": "Outline Width",
"type": "float", "type": "float",
"default": 0.12, "default": 0.22,
"min": 0, "min": 0,
"max": 0.5, "max": 1,
"step": 0.01, "step": 0.02,
"description": "Width of the SDF outline around the text." "description": "Width of the SDF outline around the text."
}, },
{ {

View File

@@ -16,11 +16,18 @@ float sdfCoverage(float2 uv, float edge, float aa)
return smoothstep(edge - aa, edge + aa, distance); return smoothstep(edge - aa, edge + aa, distance);
} }
float coverage(float distance, float edge, float aa)
{
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));
float aspect = resolution.x / resolution.y; float aspect = resolution.x / resolution.y;
float2 textSize = float2(0.72 * scale, 0.09 * scale * aspect); float textHeight = 0.09 * scale * aspect;
float textWidth = textHeight * max(titleTextTextureAspect, 0.01) / aspect;
float2 textSize = float2(textWidth, textHeight);
float2 safeTextSize = max(textSize, float2(0.0001, 0.0001)); float2 safeTextSize = max(textSize, float2(0.0001, 0.0001));
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;
@@ -30,20 +37,15 @@ float4 shadeVideo(ShaderContext context)
float aa = max(fwidth(distance) * (1.75 + softness * 5.0), 0.0025); float aa = max(fwidth(distance) * (1.75 + softness * 5.0), 0.0025);
float2 pixelTextUv = (1.0 / resolution) / safeTextSize; float2 pixelTextUv = (1.0 / resolution) / safeTextSize;
float2 sampleOffset = pixelTextUv * 0.38; float2 sampleOffset = pixelTextUv * 0.38;
float msdfDistance = sampleTitleTextMsdf(textUv);
float fill = ( float fill = (
sdfCoverage(textUv, edge, aa) * 2.0 + coverage(msdfDistance, edge, aa) * 2.0 +
sdfCoverage(textUv + float2(sampleOffset.x, sampleOffset.y), edge, aa) + coverage(sampleTitleTextMsdf(textUv + float2(sampleOffset.x, sampleOffset.y)), edge, aa) +
sdfCoverage(textUv + float2(-sampleOffset.x, sampleOffset.y), edge, aa) + coverage(sampleTitleTextMsdf(textUv + float2(-sampleOffset.x, sampleOffset.y)), edge, aa) +
sdfCoverage(textUv + float2(sampleOffset.x, -sampleOffset.y), edge, aa) + coverage(sampleTitleTextMsdf(textUv + float2(sampleOffset.x, -sampleOffset.y)), edge, aa) +
sdfCoverage(textUv + float2(-sampleOffset.x, -sampleOffset.y), edge, aa)) / 6.0; coverage(sampleTitleTextMsdf(textUv + float2(-sampleOffset.x, -sampleOffset.y)), edge, aa)) / 6.0;
float outlineDistance = outlineWidth * 0.16; float outlineEdge = edge - min(outlineWidth * 0.7, 0.48);
float outlineEdge = edge - outlineDistance; float outline = coverage(distance, outlineEdge, aa);
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 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);

View File

@@ -111,8 +111,12 @@ std::vector<unsigned char> BuildRuntimeShaderGlobalParamsStd140(
break; break;
case ShaderParameterType::Text: case ShaderParameterType::Text:
{ {
const auto scaleIt = artifact.textTextureWidthScales.find(definition.id); const auto metricsIt = artifact.textTextureMetrics.find(definition.id);
AppendStd140Float(buffer, scaleIt == artifact.textTextureWidthScales.end() ? 1.0f : scaleIt->second); const RuntimeTextTextureMetrics metrics = metricsIt == artifact.textTextureMetrics.end()
? RuntimeTextTextureMetrics()
: metricsIt->second;
AppendStd140Float(buffer, metrics.activeWidthScale);
AppendStd140Float(buffer, metrics.aspect);
break; break;
} }
case ShaderParameterType::Trigger: case ShaderParameterType::Trigger:

View File

@@ -12,9 +12,9 @@
namespace namespace
{ {
constexpr GLuint kFirstTextTextureUnit = 8; constexpr GLuint kFirstTextTextureUnit = 8;
constexpr unsigned kTextTextureHeight = 128; constexpr unsigned kTextTextureHeight = 256;
constexpr unsigned kTextTexturePadding = 8; constexpr unsigned kTextTexturePadding = 16;
constexpr double kFontPixelsPerEm = 96.0; constexpr double kFontPixelsPerEm = 192.0;
std::string ReadTextFile(const std::filesystem::path& path) std::string ReadTextFile(const std::filesystem::path& path)
{ {
@@ -105,16 +105,20 @@ void RuntimeTextTextureCache::UpdateArtifactState(const RuntimeShaderArtifact& a
void RuntimeTextTextureCache::RefreshTextTextures(RuntimeShaderArtifact* artifactState) void RuntimeTextTextureCache::RefreshTextTextures(RuntimeShaderArtifact* artifactState)
{ {
if (artifactState) if (artifactState)
artifactState->textTextureWidthScales.clear(); artifactState->textTextureMetrics.clear();
for (TextTexture& textTexture : mTextTextures) for (TextTexture& textTexture : mTextTextures)
{ {
EnsureTextTexture(textTexture); EnsureTextTexture(textTexture);
if (artifactState) if (artifactState)
{ {
const float scale = textTexture.width == 0 RuntimeTextTextureMetrics metrics;
metrics.activeWidthScale = textTexture.width == 0
? 1.0f ? 1.0f
: static_cast<float>(textTexture.liveWidth) / static_cast<float>(textTexture.width); : static_cast<float>(textTexture.liveWidth) / static_cast<float>(textTexture.width);
artifactState->textTextureWidthScales[textTexture.parameterId] = scale; metrics.aspect = textTexture.height == 0
? 1.0f
: static_cast<float>(textTexture.liveWidth) / static_cast<float>(textTexture.height);
artifactState->textTextureMetrics[textTexture.parameterId] = metrics;
} }
} }
} }
@@ -336,7 +340,7 @@ bool RuntimeTextTextureCache::EnsureTextTexture(TextTexture& texture)
unsigned width = 0; unsigned width = 0;
unsigned height = 0; unsigned height = 0;
unsigned liveWidth = 1; unsigned liveWidth = 1;
std::vector<unsigned char> pixels = ComposeTextMask(*atlas, texture, text, width, height, liveWidth); std::vector<unsigned char> pixels = ComposeTextTexture(*atlas, texture, text, width, height, liveWidth);
if (pixels.empty() || width == 0 || height == 0) if (pixels.empty() || width == 0 || height == 0)
return false; return false;
@@ -353,11 +357,11 @@ bool RuntimeTextTextureCache::EnsureTextTexture(TextTexture& texture)
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR); glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, GL_CLAMP_TO_EDGE); glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, GL_CLAMP_TO_EDGE);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, GL_CLAMP_TO_EDGE); glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, GL_CLAMP_TO_EDGE);
glTexImage2D(GL_TEXTURE_2D, 0, GL_R8, static_cast<GLsizei>(width), static_cast<GLsizei>(height), 0, GL_RED, GL_UNSIGNED_BYTE, pixels.data()); glTexImage2D(GL_TEXTURE_2D, 0, GL_RGBA8, static_cast<GLsizei>(width), static_cast<GLsizei>(height), 0, GL_RGBA, GL_UNSIGNED_BYTE, pixels.data());
} }
else else
{ {
glTexSubImage2D(GL_TEXTURE_2D, 0, 0, 0, static_cast<GLsizei>(width), static_cast<GLsizei>(height), GL_RED, GL_UNSIGNED_BYTE, pixels.data()); glTexSubImage2D(GL_TEXTURE_2D, 0, 0, 0, static_cast<GLsizei>(width), static_cast<GLsizei>(height), GL_RGBA, GL_UNSIGNED_BYTE, pixels.data());
} }
glBindTexture(GL_TEXTURE_2D, 0); glBindTexture(GL_TEXTURE_2D, 0);
glPixelStorei(GL_UNPACK_ALIGNMENT, previousUnpackAlignment); glPixelStorei(GL_UNPACK_ALIGNMENT, previousUnpackAlignment);
@@ -370,7 +374,7 @@ bool RuntimeTextTextureCache::EnsureTextTexture(TextTexture& texture)
return texture.texture != 0; return texture.texture != 0;
} }
std::vector<unsigned char> RuntimeTextTextureCache::ComposeTextMask(const Atlas& atlas, const TextTexture& texture, const std::string& text, unsigned& width, unsigned& height, unsigned& liveWidth) const std::vector<unsigned char> RuntimeTextTextureCache::ComposeTextTexture(const Atlas& atlas, const TextTexture& texture, const std::string& text, unsigned& width, unsigned& height, unsigned& liveWidth) const
{ {
double advance = 0.0; double advance = 0.0;
for (unsigned char character : text) for (unsigned char character : text)
@@ -384,7 +388,7 @@ std::vector<unsigned char> RuntimeTextTextureCache::ComposeTextMask(const Atlas&
liveWidth = (std::max)(1u, static_cast<unsigned>(std::ceil(advance * kFontPixelsPerEm)) + kTextTexturePadding * 2u); liveWidth = (std::max)(1u, static_cast<unsigned>(std::ceil(advance * kFontPixelsPerEm)) + kTextTexturePadding * 2u);
width = (std::max)(fixedWidth, liveWidth); width = (std::max)(fixedWidth, liveWidth);
height = kTextTextureHeight; height = kTextTextureHeight;
std::vector<unsigned char> mask(static_cast<std::size_t>(width) * height, 0); std::vector<unsigned char> texturePixels(static_cast<std::size_t>(width) * height * 4u, 0);
const double baseline = kTextTexturePadding + (-atlas.ascender * kFontPixelsPerEm); const double baseline = kTextTexturePadding + (-atlas.ascender * kFontPixelsPerEm);
double penX = static_cast<double>(kTextTexturePadding); double penX = static_cast<double>(kTextTexturePadding);
@@ -417,8 +421,11 @@ std::vector<unsigned char> RuntimeTextTextureCache::ComposeTextMask(const Atlas&
const double u = (static_cast<double>(x) + 0.5 - destLeft) / destWidth; const double u = (static_cast<double>(x) + 0.5 - destLeft) / destWidth;
const double atlasX = glyph.atlasLeft + u * (glyph.atlasRight - glyph.atlasLeft); const double atlasX = glyph.atlasLeft + u * (glyph.atlasRight - glyph.atlasLeft);
const double atlasY = glyph.atlasTop + v * (glyph.atlasBottom - glyph.atlasTop); const double atlasY = glyph.atlasTop + v * (glyph.atlasBottom - glyph.atlasTop);
unsigned char& destination = mask[static_cast<std::size_t>(y) * width + static_cast<std::size_t>(x)]; unsigned char sample[4] = {};
destination = (std::max)(destination, SampleAtlasAlpha(atlas, atlasX, atlasY)); SampleAtlasPixel(atlas, atlasX, atlasY, sample);
unsigned char* destination = texturePixels.data() + (static_cast<std::size_t>(y) * width + static_cast<std::size_t>(x)) * 4u;
for (unsigned channel = 0; channel < 4u; ++channel)
destination[channel] = (std::max)(destination[channel], sample[channel]);
} }
} }
} }
@@ -428,13 +435,13 @@ std::vector<unsigned char> RuntimeTextTextureCache::ComposeTextMask(const Atlas&
for (unsigned y = 0; y < height / 2u; ++y) for (unsigned y = 0; y < height / 2u; ++y)
{ {
unsigned char* topRow = mask.data() + static_cast<std::size_t>(y) * width; unsigned char* topRow = texturePixels.data() + static_cast<std::size_t>(y) * width * 4u;
unsigned char* bottomRow = mask.data() + static_cast<std::size_t>(height - 1u - y) * width; unsigned char* bottomRow = texturePixels.data() + static_cast<std::size_t>(height - 1u - y) * width * 4u;
for (unsigned x = 0; x < width; ++x) for (unsigned x = 0; x < width * 4u; ++x)
std::swap(topRow[x], bottomRow[x]); std::swap(topRow[x], bottomRow[x]);
} }
return mask; return texturePixels;
} }
const RuntimeTextTextureCache::Atlas* RuntimeTextTextureCache::FindAtlas(const std::string& fontId) const const RuntimeTextTextureCache::Atlas* RuntimeTextTextureCache::FindAtlas(const std::string& fontId) const
@@ -463,12 +470,28 @@ std::string RuntimeTextTextureCache::DefaultTextValue(const RuntimeShaderArtifac
return std::string(); return std::string();
} }
unsigned char RuntimeTextTextureCache::SampleAtlasAlpha(const Atlas& atlas, double x, double y) void RuntimeTextTextureCache::SampleAtlasPixel(const Atlas& atlas, double x, double y, unsigned char* rgba)
{ {
const int ix = (std::max)(0, (std::min)(static_cast<int>(atlas.width) - 1, static_cast<int>(std::floor(x)))); const double clampedX = (std::max)(0.0, (std::min)(static_cast<double>(atlas.width) - 1.0, x));
const int iy = (std::max)(0, (std::min)(static_cast<int>(atlas.height) - 1, static_cast<int>(std::floor(y)))); const double clampedY = (std::max)(0.0, (std::min)(static_cast<double>(atlas.height) - 1.0, y));
const std::size_t pixelOffset = (static_cast<std::size_t>(iy) * atlas.width + static_cast<std::size_t>(ix)) * 4u; const int x0 = static_cast<int>(std::floor(clampedX));
return atlas.rgbaPixels[pixelOffset + 3u]; const int y0 = static_cast<int>(std::floor(clampedY));
const int x1 = (std::min)(static_cast<int>(atlas.width) - 1, x0 + 1);
const int y1 = (std::min)(static_cast<int>(atlas.height) - 1, y0 + 1);
const double tx = clampedX - static_cast<double>(x0);
const double ty = clampedY - static_cast<double>(y0);
const auto channelAt = [&atlas](int sx, int sy, unsigned channel) {
const std::size_t pixelOffset = (static_cast<std::size_t>(sy) * atlas.width + static_cast<std::size_t>(sx)) * 4u;
return static_cast<double>(atlas.rgbaPixels[pixelOffset + channel]);
};
for (unsigned channel = 0; channel < 4u; ++channel)
{
const double top = channelAt(x0, y0, channel) * (1.0 - tx) + channelAt(x1, y0, channel) * tx;
const double bottom = channelAt(x0, y1, channel) * (1.0 - tx) + channelAt(x1, y1, channel) * tx;
const double value = top * (1.0 - ty) + bottom * ty;
rgba[channel] = static_cast<unsigned char>((std::max)(0.0, (std::min)(255.0, std::round(value))));
}
} }
void RuntimeTextTextureCache::DestroyTexture(TextTexture& texture) void RuntimeTextTextureCache::DestroyTexture(TextTexture& texture)

View File

@@ -66,11 +66,11 @@ private:
bool LoadAtlasJson(const RenderCadenceCompositor::FontAtlasBuildOutput& output, Atlas& atlas, std::string& error) const; bool LoadAtlasJson(const RenderCadenceCompositor::FontAtlasBuildOutput& output, Atlas& atlas, std::string& error) const;
bool LoadAtlasImage(const RenderCadenceCompositor::FontAtlasBuildOutput& output, Atlas& atlas, std::string& error) const; bool LoadAtlasImage(const RenderCadenceCompositor::FontAtlasBuildOutput& output, Atlas& atlas, std::string& error) const;
bool EnsureTextTexture(TextTexture& texture); bool EnsureTextTexture(TextTexture& texture);
std::vector<unsigned char> ComposeTextMask(const Atlas& atlas, const TextTexture& texture, const std::string& text, unsigned& width, unsigned& height, unsigned& liveWidth) const; std::vector<unsigned char> ComposeTextTexture(const Atlas& atlas, const TextTexture& texture, const std::string& text, unsigned& width, unsigned& height, unsigned& liveWidth) const;
const Atlas* FindAtlas(const std::string& fontId) const; const Atlas* FindAtlas(const std::string& fontId) const;
static const ShaderParameterValue* FindParameterValue(const RuntimeShaderArtifact& artifact, const std::string& parameterId); static const ShaderParameterValue* FindParameterValue(const RuntimeShaderArtifact& artifact, const std::string& parameterId);
static std::string DefaultTextValue(const RuntimeShaderArtifact& artifact, const std::string& parameterId); static std::string DefaultTextValue(const RuntimeShaderArtifact& artifact, const std::string& parameterId);
static unsigned char SampleAtlasAlpha(const Atlas& atlas, double x, double y); static void SampleAtlasPixel(const Atlas& atlas, double x, double y, unsigned char* rgba);
static void DestroyTexture(TextTexture& texture); static void DestroyTexture(TextTexture& texture);
RuntimeShaderArtifact mArtifact; RuntimeShaderArtifact mArtifact;

View File

@@ -12,8 +12,8 @@ struct FontAtlasBuildConfig
{ {
std::filesystem::path repoRoot; std::filesystem::path repoRoot;
std::filesystem::path cacheRoot; std::filesystem::path cacheRoot;
double sizePixelsPerEm = 64.0; double sizePixelsPerEm = 128.0;
double pixelRange = 4.0; double pixelRange = 8.0;
std::string atlasType = "mtsdf"; std::string atlasType = "mtsdf";
}; };

View File

@@ -15,6 +15,12 @@ struct RuntimeShaderPassArtifact
std::string outputName; std::string outputName;
}; };
struct RuntimeTextTextureMetrics
{
float activeWidthScale = 1.0f;
float aspect = 1.0f;
};
struct RuntimeShaderArtifact struct RuntimeShaderArtifact
{ {
std::string layerId; std::string layerId;
@@ -25,6 +31,6 @@ struct RuntimeShaderArtifact
std::string message; std::string message;
std::vector<ShaderParameterDefinition> parameterDefinitions; std::vector<ShaderParameterDefinition> parameterDefinitions;
std::map<std::string, ShaderParameterValue> parameterValues; std::map<std::string, ShaderParameterValue> parameterValues;
std::map<std::string, float> textTextureWidthScales; std::map<std::string, RuntimeTextTextureMetrics> textTextureMetrics;
std::vector<RenderCadenceCompositor::FontAtlasBuildOutput> fontAtlases; std::vector<RenderCadenceCompositor::FontAtlasBuildOutput> fontAtlases;
}; };

View File

@@ -53,7 +53,8 @@ std::string BuildParameterUniforms(const std::vector<ShaderParameterDefinition>&
{ {
if (definition.type == ShaderParameterType::Text) if (definition.type == ShaderParameterType::Text)
{ {
source << "\tfloat " << definition.id << "TextureWidthScale;\n"; source << "\tfloat " << definition.id << "TextureActiveWidthScale;\n";
source << "\tfloat " << definition.id << "TextureAspect;\n";
continue; continue;
} }
if (definition.type == ShaderParameterType::Trigger) if (definition.type == ShaderParameterType::Trigger)
@@ -102,17 +103,36 @@ std::string BuildTextSamplerDeclarations(const std::vector<ShaderParameterDefini
std::string BuildTextHelpers(const std::vector<ShaderParameterDefinition>& parameters) std::string BuildTextHelpers(const std::vector<ShaderParameterDefinition>& parameters)
{ {
std::ostringstream source; std::ostringstream source;
bool emittedMedian = false;
for (const ShaderParameterDefinition& definition : parameters) for (const ShaderParameterDefinition& definition : parameters)
{ {
if (definition.type != ShaderParameterType::Text) if (definition.type != ShaderParameterType::Text)
continue; continue;
if (!emittedMedian)
{
source
<< "float median(float r, float g, float b)\n"
<< "{\n"
<< "\treturn max(min(r, g), min(max(r, g), b));\n"
<< "}\n\n";
emittedMedian = true;
}
const std::string suffix = CapitalizeIdentifier(definition.id); const std::string suffix = CapitalizeIdentifier(definition.id);
source source
<< "float sample" << suffix << "(float2 uv)\n" << "float4 sample" << suffix << "Mtsdf(float2 uv)\n"
<< "{\n" << "{\n"
<< "\tif (uv.x < 0.0 || uv.x > 1.0 || uv.y < 0.0 || uv.y > 1.0)\n" << "\tif (uv.x < 0.0 || uv.x > 1.0 || uv.y < 0.0 || uv.y > 1.0)\n"
<< "\t\treturn 0.0;\n" << "\t\treturn float4(0.0, 0.0, 0.0, 0.0);\n"
<< "\treturn " << definition.id << "Texture.Sample(float2(uv.x * " << definition.id << "TextureWidthScale, uv.y)).r;\n" << "\treturn " << definition.id << "Texture.Sample(float2(uv.x * " << definition.id << "TextureActiveWidthScale, uv.y));\n"
<< "}\n\n"
<< "float sample" << suffix << "Msdf(float2 uv)\n"
<< "{\n"
<< "\tfloat4 mtsdf = sample" << suffix << "Mtsdf(uv);\n"
<< "\treturn median(mtsdf.r, mtsdf.g, mtsdf.b);\n"
<< "}\n\n"
<< "float sample" << suffix << "(float2 uv)\n"
<< "{\n"
<< "\treturn sample" << suffix << "Mtsdf(uv).a;\n"
<< "}\n\n" << "}\n\n"
<< "float4 draw" << suffix << "(float2 uv, float4 fillColor)\n" << "float4 draw" << suffix << "(float2 uv, float4 fillColor)\n"
<< "{\n" << "{\n"