fix(ui): decode the font atlas's SDF in atlas pixels, so text edges ramp over one screen pixel

Shaders
- ui-text and ui-fused turned a sample of the R8_UNORM atlas into a
  distance with (128/255 - sdf) * 32. stb_truetype writes kOnEdgeValue +
  kPixelDistScale * distance into each byte, so an atlas pixel is
  kPixelDistScale / 255 of a sample; 32 made every distance 1024/255
  (about 4) times too large, and the coverage ramp, meant to span one
  screen pixel, spanned a quarter of one: nearly every edge pixel came out
  fully on or fully off, and text looked pixelated at every size. On an
  RTX 4090, UIFallbackParity's 22 px label had 98 partly covered pixels
  where stb_truetype's own rasterizer has 415. Wrong since the v3 UI
  (1f56973), and copied into the fused kernel (574242d).
- The decode now lives once, in ui-shared.glsl: UI_SDF_ON_EDGE and
  UI_SDF_PIXEL_DIST_SCALE are FontAtlas's two constants in the encoder's
  own units, uiSdfDistance decodes a sample, uiGlyphRamp works out a
  glyph's ramp when it is loaded and uiGlyphCoverage the coverage per
  pixel. Both kernels call them, and a copy of either should; the UI_
  prefix keeps them clear of a copied kernel's own names.
- stb_truetype truncates, so a byte b stands for [b, b + 1) of the encoded
  value and the decode takes its middle: decoding b pulled every outline
  1/64 atlas pixel inwards.
- The ramp is capped at what a byte holds, about 4 atlas pixels each way
  of the outline. With the corrected decode a wider one left every glyph
  quad's padding translucent below 4 px.
- The UV span is taken with abs(): a glyph turned by swapping its UVs got
  a negative scale, a ramp of 0.0001 and hard edges.
- A glyph scaled differently along x and y ramps along the distance's
  gradient, from two one-texel differences. With the corrected decode, one
  width for both axes, the more minified one's, would leave a row stretched
  three times with stems three pixels soft. Glyphs scaled alike, as
  ShapeText's are, keep one multiply-add per pixel, the ramp's reciprocal
  worked out with the glyph.
- The atlas size is queried rather than FontAtlas::kAtlasSize hard-coded
  as 1024.

WebGPU
- The WGSL text kernel decoded the distance right but ramped over one
  atlas pixel whatever the size: half a screen pixel at 16 px, two at
  64 px. It now repeats ui-shared.glsl's decode, cap, abs and gradient
  path, and renders TextAntialiasing's scene as Vulkan does (run through
  Deno's WebGPU: 1.02, 1.07 and 1.09 times the reference's edge pixels,
  where the old kernel gave 0.55, 2.19 and 0.89).

FontAtlas
- kOnEdgeValue and kPixelDistScale say the kernels carry copies of them.

Tests
- TextAntialiasing draws one string at 14 px, 64 px and 14 px stretched
  three times, a 'g' turned by its UVs and empty atlas texels shrunk to
  12.8 atlas pixels per screen pixel, through DispatchText and again
  through DispatchFused, and holds them to stb_truetype's antialiased
  rasterizer, to the mirror image and to nothing. On master the two plain
  rows have 0.31 and 0.21 times the reference's edge pixels; with the
  decode fixed alone the stretched row had 1.62 times, the turned 'g' was
  127 codes off its mirror and the empty texels drew 48. Now: 1.02, 1.07
  and 1.09 times, 0 and 0, on the 4090, on llvmpipe and on descriptor
  sets. It skips a swapchain that isn't 8-bit, which the capture can't
  read, and captures the first frame.
- FontAtlasDecode reads ui-shared.glsl and dom-webgpu.js and holds their
  copies of the two constants to FontAtlas's. No GPU.

Docs
- AGENTS.consumer.md has a custom text shader decode through the helpers
  and gives the encoding; README says so in a line; AGENTS.md lists the
  tests and where the decode lives.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Jorijn van der Graaf 2026-10-31 20:22:12 +01:00
commit 781d95ae75
Signed by: jorijnvdgraaf
GPG key ID: 2937E59CDCC1BCFB
11 changed files with 767 additions and 63 deletions

View file

@ -0,0 +1,101 @@
//SPDX-License-Identifier: LGPL-3.0-only
//SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts®
// The text kernels decode FontAtlas's SDF with FontAtlas's own encoding.
// stb_truetype writes kOnEdgeValue + kPixelDistScale * distance into each
// byte, and the shaders carry copies of those two numbers: UI_SDF_ON_EDGE and
// UI_SDF_PIXEL_DIST_SCALE in shaders/ui-shared.glsl, which ui-text and the
// fused kernel decode through, and again in the WGSL text kernel of
// additional/dom-webgpu.js. The decode once multiplied by kPixelDistScale
// where 255 / kPixelDistScale belongs, which left every edge pixelated, and a
// copy that drifts from FontAtlas would go unseen on WebGPU, where no test
// renders (wasm runs none) and a small drift passes TextAntialiasing's
// tolerances anyway. So this reads the sources and compares the numbers, and
// checks that both GLSL kernels decode through the shared helper rather than
// arithmetic of their own.
//
// Pure CPU: reads files, no device.
#include <cstdlib>
import Crafter.Graphics;
import std;
using namespace Crafter;
namespace fs = std::filesystem;
namespace {
int failures = 0;
void Check(bool ok, std::string_view what) {
std::println("{} {}", ok ? "PASS" : "FAIL", what);
if (!ok) ++failures;
}
// The directory holding shaders/ and additional/, whether the test runs from
// its build output directory or the project root, as ExampleShaders finds it.
fs::path LocateRoot() {
for (const fs::path candidate : {".", "..", "../..", "../../.."}) {
std::error_code ec;
if (fs::is_regular_file(candidate / "shaders" / "ui-shared.glsl", ec)
&& fs::is_regular_file(candidate / "additional" / "dom-webgpu.js", ec)) {
return candidate;
}
}
return {};
}
std::string ReadFile(const fs::path& path) {
std::ifstream in(path, std::ios::binary);
return std::string(std::istreambuf_iterator<char>(in), std::istreambuf_iterator<char>());
}
// The numbers `pattern`'s one capture group matches in `source`.
std::vector<float> Matches(const std::string& source, const std::string& pattern) {
std::vector<float> values;
const std::regex re(pattern);
for (auto it = std::sregex_iterator(source.begin(), source.end(), re); it != std::sregex_iterator(); ++it) {
values.push_back(std::stof((*it)[1].str()));
}
return values;
}
// `declaration` is the source up to the name, `name` the constant, followed
// by "= <number>;" in either language.
void CheckConstant(std::string_view file, const std::string& source, const std::string& declaration,
std::string_view name, float expected) {
const std::vector<float> values = Matches(source, declaration + R"(\s*=\s*([0-9]+(?:\.[0-9]+)?)\s*;)");
std::string found = values.empty() ? "none" : "";
for (std::size_t i = 0; i < values.size(); ++i) found += std::format("{}{}", i == 0 ? "" : ", ", values[i]);
Check(values.size() == 1 && values[0] == expected,
std::format("{} defines {} once, as {} (found {})", file, name, expected, found));
}
} // namespace
int main() {
const fs::path root = LocateRoot();
if (root.empty()) {
std::println(std::cerr, "could not find shaders/ and additional/ from {}", fs::current_path().string());
return EXIT_FAILURE;
}
const std::string glsl = ReadFile(root / "shaders" / "ui-shared.glsl");
const std::string wgsl = ReadFile(root / "additional" / "dom-webgpu.js");
const float onEdge = static_cast<float>(FontAtlas::kOnEdgeValue);
const float pixelDistScale = FontAtlas::kPixelDistScale;
CheckConstant("ui-shared.glsl", glsl, R"(const\s+float\s+UI_SDF_ON_EDGE)", "UI_SDF_ON_EDGE", onEdge);
CheckConstant("ui-shared.glsl", glsl, R"(const\s+float\s+UI_SDF_PIXEL_DIST_SCALE)", "UI_SDF_PIXEL_DIST_SCALE", pixelDistScale);
CheckConstant("dom-webgpu.js", wgsl, R"(const\s+UI_SDF_ON_EDGE\s*:\s*f32)", "UI_SDF_ON_EDGE", onEdge);
CheckConstant("dom-webgpu.js", wgsl, R"(const\s+UI_SDF_PIXEL_DIST_SCALE\s*:\s*f32)", "UI_SDF_PIXEL_DIST_SCALE", pixelDistScale);
for (const std::string_view kernel : {"ui-text.comp.glsl", "ui-fused.comp.glsl"}) {
const std::string source = ReadFile(root / "shaders" / kernel);
Check(source.find("uiGlyphCoverage(") != std::string::npos,
std::format("{} decodes the atlas through ui-shared.glsl's uiGlyphCoverage", kernel));
}
std::println("{}", failures == 0 ? "all checks passed" : "check(s) failed");
return failures == 0 ? EXIT_SUCCESS : EXIT_FAILURE;
}