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:
parent
a4be6f1a9c
commit
781d95ae75
11 changed files with 767 additions and 63 deletions
486
tests/TextAntialiasing/main.cpp
Normal file
486
tests/TextAntialiasing/main.cpp
Normal file
|
|
@ -0,0 +1,486 @@
|
|||
//SPDX-License-Identifier: LGPL-3.0-only
|
||||
//SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts®
|
||||
|
||||
// Text drawn by the UI kernels has to be antialiased: a glyph's edge ramps
|
||||
// from transparent to opaque over one screen pixel, at any size. FontAtlas
|
||||
// keeps each glyph as an SDF in which stb_truetype writes kOnEdgeValue +
|
||||
// kPixelDistScale * distance (in atlas pixels) per byte; the kernels decode a
|
||||
// sample back into a distance and divide it by the atlas pixels one screen
|
||||
// pixel spans along the glyph. ui-text and ui-fused used to multiply by
|
||||
// kPixelDistScale instead of 255 / kPixelDistScale, which made the distance
|
||||
// 1024/255 (about 4) times too large and the ramp a quarter of a pixel wide:
|
||||
// nearly every edge pixel came out fully on or fully off, and text looked
|
||||
// pixelated at every size.
|
||||
//
|
||||
// The test draws one scene through DispatchText and again through
|
||||
// DispatchFused, captures the frame and checks:
|
||||
//
|
||||
// - one string at 14 and 64 px, below and above the atlas's 32 px base
|
||||
// size, and at 14 px stretched to three times its width, each against
|
||||
// stb_truetype's own antialiased rasterizer at the same size, aspect and
|
||||
// sub-pixel position: about as many pixels are partly covered, and where
|
||||
// the reference is partly covered the coverage is close to it. The
|
||||
// quarter-pixel ramp gives a quarter as many; a ramp one atlas pixel
|
||||
// wide, which ignores the glyph's size, half as many at 14 px and twice
|
||||
// as many at 64 px; one ramp width for both axes of the stretched row,
|
||||
// the width across its horizontal edges, makes it three pixels wide
|
||||
// across the stems;
|
||||
// - a glyph turned by swapping its UVs, the one way a GlyphItem flips, has
|
||||
// to draw its mirror image. A signed UV span gave it hard edges;
|
||||
// - atlas texels no glyph reaches hold 0, like every glyph's padding, and
|
||||
// drawn shrunk far below 4 px they have to draw nothing. A ramp wider
|
||||
// than the distances a byte can hold left the whole quad translucent;
|
||||
// - the fused text phase draws what DispatchText draws.
|
||||
//
|
||||
// Needs a live Wayland compositor and a Vulkan device, like UIFallbackParity.
|
||||
|
||||
#include "vulkan/vulkan.h"
|
||||
#include "vulkan/vk_enum_string_helper.h"
|
||||
#include <cstdlib>
|
||||
|
||||
// PNG decode for the captured frame. The library only compiles the writer
|
||||
// (Window::CaptureNextFrame); STB_IMAGE_STATIC keeps the reader private to this file.
|
||||
#define STB_IMAGE_IMPLEMENTATION
|
||||
#define STB_IMAGE_STATIC
|
||||
#define STBI_ONLY_PNG
|
||||
#include "../../lib/stb_image.h"
|
||||
// The reference rasterizer. Declarations only: the definitions are the
|
||||
// library's own (Font.cpp), and Font::font is the stbtt_fontinfo they take.
|
||||
#include "../../lib/stb_truetype.h"
|
||||
|
||||
import Crafter.Graphics;
|
||||
import Crafter.Event;
|
||||
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 test binary's own directory: the build places the compiled shaders
|
||||
// and font.ttf next to it, and the test runner's working directory is not
|
||||
// that directory.
|
||||
fs::path ExeDir() {
|
||||
return fs::read_symlink("/proc/self/exe").parent_path();
|
||||
}
|
||||
|
||||
constexpr std::uint32_t kWidth = 640;
|
||||
constexpr std::uint32_t kHeight = 560;
|
||||
|
||||
// Straight stems, curves and diagonals.
|
||||
constexpr std::string_view kText = "Hamburgefonstiv";
|
||||
constexpr float kTextX = 20.0f;
|
||||
constexpr std::uint32_t kGlyphCapacity = 64;
|
||||
|
||||
struct Row {
|
||||
float size;
|
||||
float baseline;
|
||||
float stretch; // horizontal scale applied to ShapeText's glyphs
|
||||
};
|
||||
constexpr std::array<Row, 3> kRows{{ {14.0f, 40.0f, 1.0f}, {64.0f, 120.0f, 1.0f}, {14.0f, 180.0f, 3.0f} }};
|
||||
// The glyph mirrored by its UVs, taken from the 64 px row, and where the
|
||||
// original and the flipped copy go: whole pixels, so the check maps pixel to
|
||||
// pixel. 'g' is neither left-right nor top-bottom symmetric.
|
||||
constexpr char kMirrorGlyph = 'g';
|
||||
constexpr float kMirrorX = 340.0f;
|
||||
constexpr float kMirrorY = 150.0f;
|
||||
constexpr float kMirrorGap = 16.0f;
|
||||
// The empty-texel item: 0.4 of the atlas drawn over 32 px is 12.8 atlas
|
||||
// pixels per screen pixel, a glyph under 3 px. FontAtlas packs shelves from
|
||||
// the top left, and this string fills one, so the bottom right is untouched.
|
||||
constexpr float kEmptyX = 500.0f;
|
||||
constexpr float kEmptyY = 150.0f;
|
||||
constexpr float kEmptySize = 32.0f;
|
||||
constexpr float kEmptyUv0 = 0.55f;
|
||||
constexpr float kEmptyUv1 = 0.95f;
|
||||
// The fused copy of the scene sits this much lower: a whole number of
|
||||
// pixels, so it samples the atlas exactly where the standalone copy does.
|
||||
constexpr int kFusedOffset = 270;
|
||||
|
||||
// A pixel is an edge pixel when it is partly covered.
|
||||
constexpr float kPartialLow = 0.02f;
|
||||
constexpr float kPartialHigh = 0.98f;
|
||||
// The SDF ramp is not the reference's box filter, so a correct kernel lands
|
||||
// near the reference rather than on it: 1.02, 1.07 and 1.09 times its edge
|
||||
// pixels (14 px, 64 px, 14 px stretched) on an RTX 4090, 1.02, 1.11 and 1.09
|
||||
// on llvmpipe, with a mean difference of 0.065 to 0.083 on them. The
|
||||
// quarter-pixel ramp gives 0.31 and 0.21 times and 0.20; a ramp one atlas
|
||||
// pixel wide 0.55 and 2.19 times and 0.14; one width for both axes 1.62 times
|
||||
// on the stretched row.
|
||||
constexpr float kMinEdgeRatio = 0.7f;
|
||||
constexpr float kMaxEdgeRatio = 1.4f;
|
||||
constexpr float kMaxEdgeError = 0.12f;
|
||||
|
||||
template<typename T>
|
||||
void CreateItemBuffer(GraphicsBuffer<T, true>& buffer, std::uint32_t count) {
|
||||
buffer.Create(
|
||||
VK_BUFFER_USAGE_STORAGE_BUFFER_BIT | VK_BUFFER_USAGE_SHADER_DEVICE_ADDRESS_BIT,
|
||||
VK_MEMORY_PROPERTY_HOST_VISIBLE_BIT | VK_MEMORY_PROPERTY_DEVICE_LOCAL_BIT, count);
|
||||
}
|
||||
|
||||
// Per-pixel coverage, 0 for the background and 1 for fully covered.
|
||||
struct Coverage {
|
||||
int width = 0;
|
||||
int height = 0;
|
||||
std::vector<float> value;
|
||||
|
||||
float At(int x, int y) const { return value[static_cast<std::size_t>(y) * width + x]; }
|
||||
};
|
||||
|
||||
int Code(float coverage) {
|
||||
return static_cast<int>(std::lround(coverage * 255.0f));
|
||||
}
|
||||
|
||||
// White text over an opaque black background on an 8-bit UNORM swapchain:
|
||||
// the red channel is the coverage the kernels composited.
|
||||
Coverage LoadFrameCoverage(const fs::path& path) {
|
||||
Coverage frame;
|
||||
int channels = 0;
|
||||
stbi_uc* pixels = stbi_load(path.string().c_str(), &frame.width, &frame.height, &channels, STBI_rgb_alpha);
|
||||
if (pixels == nullptr) return frame;
|
||||
frame.value.resize(static_cast<std::size_t>(frame.width) * frame.height);
|
||||
for (std::size_t i = 0; i < frame.value.size(); ++i) frame.value[i] = pixels[i * 4] / 255.0f;
|
||||
stbi_image_free(pixels);
|
||||
return frame;
|
||||
}
|
||||
|
||||
// stb_truetype's area coverage of kText laid out the way UIRenderer::ShapeText
|
||||
// lays it out, stretched as the test stretches the row: the same pen positions
|
||||
// (the atlas's advances, scaled and summed in the same order), each glyph
|
||||
// rasterised at its own sub-pixel offset and composited "over" as the kernels
|
||||
// do.
|
||||
Coverage ReferenceCoverage(Font& font, const FontAtlas& atlas, const Row& row) {
|
||||
Coverage ref{static_cast<int>(kWidth), static_cast<int>(kHeight),
|
||||
std::vector<float>(static_cast<std::size_t>(kWidth) * kHeight, 0.0f)};
|
||||
const float scale = stbtt_ScaleForPixelHeight(&font.font, row.size);
|
||||
const float atlasScale = row.size / FontAtlas::kBaseSize;
|
||||
float cursor = 0.0f;
|
||||
for (const char c : kText) {
|
||||
const int codepoint = static_cast<unsigned char>(c);
|
||||
const Glyph* glyph = atlas.Lookup(font, static_cast<std::uint32_t>(codepoint));
|
||||
if (glyph == nullptr) return ref;
|
||||
const float pen = kTextX + cursor * row.stretch;
|
||||
const float shift = pen - std::floor(pen);
|
||||
const float scaleX = scale * row.stretch;
|
||||
int x0 = 0, y0 = 0, x1 = 0, y1 = 0;
|
||||
stbtt_GetCodepointBitmapBoxSubpixel(&font.font, codepoint, scaleX, scale, shift, 0.0f, &x0, &y0, &x1, &y1);
|
||||
const int w = x1 - x0;
|
||||
const int h = y1 - y0;
|
||||
if (w > 0 && h > 0) {
|
||||
std::vector<unsigned char> bitmap(static_cast<std::size_t>(w) * h);
|
||||
stbtt_MakeCodepointBitmapSubpixel(&font.font, bitmap.data(), w, h, w, scaleX, scale, shift, 0.0f, codepoint);
|
||||
const int left = static_cast<int>(std::floor(pen)) + x0;
|
||||
const int top = static_cast<int>(row.baseline) + y0;
|
||||
for (int y = 0; y < h; ++y) {
|
||||
for (int x = 0; x < w; ++x) {
|
||||
const int px = left + x;
|
||||
const int py = top + y;
|
||||
if (px < 0 || py < 0 || px >= ref.width || py >= ref.height) continue;
|
||||
const float a = bitmap[static_cast<std::size_t>(y) * w + x] / 255.0f;
|
||||
float& dst = ref.value[static_cast<std::size_t>(py) * ref.width + px];
|
||||
dst = a + dst * (1.0f - a);
|
||||
}
|
||||
}
|
||||
}
|
||||
cursor += glyph->advance * atlasScale;
|
||||
}
|
||||
return ref;
|
||||
}
|
||||
|
||||
// Half-open pixel box.
|
||||
struct Box {
|
||||
int x0 = 0, y0 = 0, x1 = 0, y1 = 0;
|
||||
};
|
||||
|
||||
// The reference's covered pixels, plus a margin for the kernels' ramp, which
|
||||
// reaches half a pixel past the outline.
|
||||
Box CoveredBox(const Coverage& ref) {
|
||||
Box box{ref.width, ref.height, 0, 0};
|
||||
for (int y = 0; y < ref.height; ++y) {
|
||||
for (int x = 0; x < ref.width; ++x) {
|
||||
if (ref.At(x, y) <= 0.0f) continue;
|
||||
box.x0 = std::min(box.x0, x);
|
||||
box.y0 = std::min(box.y0, y);
|
||||
box.x1 = std::max(box.x1, x + 1);
|
||||
box.y1 = std::max(box.y1, y + 1);
|
||||
}
|
||||
}
|
||||
constexpr int kMargin = 2;
|
||||
box.x0 = std::max(box.x0 - kMargin, 0);
|
||||
box.y0 = std::max(box.y0 - kMargin, 0);
|
||||
box.x1 = std::min(box.x1 + kMargin, ref.width);
|
||||
box.y1 = std::min(box.y1 + kMargin, ref.height);
|
||||
return box;
|
||||
}
|
||||
|
||||
bool Partial(float coverage) {
|
||||
return coverage > kPartialLow && coverage < kPartialHigh;
|
||||
}
|
||||
|
||||
struct EdgeStats {
|
||||
int frameEdges = 0; // partly covered pixels in the frame
|
||||
int referenceEdges = 0; // partly covered pixels in the reference
|
||||
float meanError = 0.0f; // mean |frame - reference| over the reference's edge pixels
|
||||
};
|
||||
|
||||
// Compares the frame, `dy` pixels lower, with the reference inside `box`.
|
||||
EdgeStats CompareEdges(const Coverage& frame, int dy, const Coverage& ref, const Box& box) {
|
||||
EdgeStats stats;
|
||||
double error = 0.0;
|
||||
for (int y = box.y0; y < box.y1; ++y) {
|
||||
for (int x = box.x0; x < box.x1; ++x) {
|
||||
const float f = frame.At(x, y + dy);
|
||||
const float r = ref.At(x, y);
|
||||
if (Partial(f)) ++stats.frameEdges;
|
||||
if (Partial(r)) {
|
||||
++stats.referenceEdges;
|
||||
error += std::abs(f - r);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (stats.referenceEdges > 0) stats.meanError = static_cast<float>(error / stats.referenceEdges);
|
||||
return stats;
|
||||
}
|
||||
|
||||
void CheckEdges(std::string_view what, const EdgeStats& stats) {
|
||||
const float ratio = stats.referenceEdges > 0
|
||||
? static_cast<float>(stats.frameEdges) / static_cast<float>(stats.referenceEdges)
|
||||
: 0.0f;
|
||||
Check(ratio >= kMinEdgeRatio && ratio <= kMaxEdgeRatio,
|
||||
std::format("{}: {} antialiased edge pixels, stb_truetype's rasterizer {} ({:.2f} times, allowed {:.1f} to {:.1f})",
|
||||
what, stats.frameEdges, stats.referenceEdges, ratio, kMinEdgeRatio, kMaxEdgeRatio));
|
||||
Check(stats.referenceEdges > 0 && stats.meanError <= kMaxEdgeError,
|
||||
std::format("{}: coverage on the reference's edge pixels is {:.3f} from it on average (allowed {:.2f})",
|
||||
what, stats.meanError, kMaxEdgeError));
|
||||
}
|
||||
|
||||
std::string RowName(const Row& row) {
|
||||
return row.stretch == 1.0f ? std::format("{} px", row.size)
|
||||
: std::format("{} px stretched {}x", row.size, row.stretch);
|
||||
}
|
||||
|
||||
int Run() {
|
||||
const fs::path dir = ExeDir();
|
||||
const fs::path outDir = fs::temp_directory_path() / "crafter-text-antialiasing";
|
||||
std::error_code ec;
|
||||
fs::remove_all(outDir, ec);
|
||||
fs::create_directories(outDir);
|
||||
const fs::path png = outDir / "frame.png";
|
||||
|
||||
Device::Initialize();
|
||||
Window window(kWidth, kHeight, "TextAntialiasing test");
|
||||
// CaptureNextFrame writes 8-bit RGBA, swapping BGRA, and the coverage is
|
||||
// read from those bytes. A surface whose 8-bit formats can't be storage
|
||||
// images gets another format, which neither understands.
|
||||
if (window.colorFormat != VK_FORMAT_B8G8R8A8_UNORM && window.colorFormat != VK_FORMAT_R8G8B8A8_UNORM) {
|
||||
std::println("SKIP the swapchain format {} isn't 8-bit RGBA or BGRA, which the captured frame needs",
|
||||
string_VkFormat(window.colorFormat));
|
||||
return 77;
|
||||
}
|
||||
VkCommandBuffer init = window.StartInit();
|
||||
|
||||
GraphicsDescriptorHeap heap;
|
||||
heap.Initialize(/*images*/ 8, /*buffers*/ 8, /*samplers*/ 4);
|
||||
window.descriptorHeap = &heap;
|
||||
|
||||
Font font(dir / "font.ttf");
|
||||
FontAtlas atlas;
|
||||
atlas.Initialize(init);
|
||||
|
||||
UIRenderer ui;
|
||||
ui.fontAtlas = &atlas;
|
||||
ui.Initialize(window, heap, init,
|
||||
dir / "ui-quads.comp.spv", dir / "ui-circles.comp.spv",
|
||||
dir / "ui-images.comp.spv", dir / "ui-text.comp.spv",
|
||||
dir / "ui-fused.comp.spv");
|
||||
window.passes.push_back(&ui);
|
||||
|
||||
GraphicsBuffer<QuadItem, true> background;
|
||||
GraphicsBuffer<GlyphItem, true> standalone;
|
||||
GraphicsBuffer<GlyphItem, true> fused;
|
||||
CreateItemBuffer(background, 1);
|
||||
CreateItemBuffer(standalone, kGlyphCapacity);
|
||||
CreateItemBuffer(fused, kGlyphCapacity);
|
||||
BufferSlot backgroundSlot = ui.RegisterBuffer(background);
|
||||
BufferSlot standaloneSlot = ui.RegisterBuffer(standalone);
|
||||
BufferSlot fusedSlot = ui.RegisterBuffer(fused);
|
||||
|
||||
// Filled on the frame: the rows, then the mirrored pair and the
|
||||
// empty-texel item. The fused buffer holds the same items kFusedOffset
|
||||
// lower.
|
||||
std::uint32_t glyphCount = 0;
|
||||
std::array<std::uint32_t, kRows.size()> rowCounts{};
|
||||
GlyphItem original{};
|
||||
GlyphItem flipped{};
|
||||
EventListener<UIBuildArgs> buildSub(&ui.onBuild, [&](UIBuildArgs a) {
|
||||
VkCommandBuffer cmd = a.cmd;
|
||||
// The window never clears its image. Opaque black under white text
|
||||
// makes the red channel the coverage.
|
||||
background.value[0] = QuadItem{ 0, 0, 4096, 4096, 0.0f, 0.0f, 0.0f, 1.0f, 0, 0, 0, 0, 0, 0, 0, 0 };
|
||||
|
||||
GlyphItem* items = standalone.value;
|
||||
glyphCount = 0;
|
||||
std::uint32_t mirrorIndex = kGlyphCapacity;
|
||||
for (std::size_t r = 0; r < kRows.size(); ++r) {
|
||||
const Row& row = kRows[r];
|
||||
const std::uint32_t first = glyphCount;
|
||||
rowCounts[r] = ui.ShapeText(font, row.size, kTextX, row.baseline, kText, {1.0f, 1.0f, 1.0f, 1.0f},
|
||||
items + first, kGlyphCapacity - first);
|
||||
glyphCount += rowCounts[r];
|
||||
if (row.stretch != 1.0f) {
|
||||
for (std::uint32_t i = first; i < glyphCount; ++i) {
|
||||
items[i].x = kTextX + (items[i].x - kTextX) * row.stretch;
|
||||
items[i].w *= row.stretch;
|
||||
}
|
||||
}
|
||||
// Every character of kText has ink, so item i is character i.
|
||||
if (row.size == 64.0f && row.stretch == 1.0f && rowCounts[r] == kText.size()) {
|
||||
mirrorIndex = first + static_cast<std::uint32_t>(kText.find(kMirrorGlyph));
|
||||
}
|
||||
}
|
||||
if (mirrorIndex < kGlyphCapacity && glyphCount + 3 <= kGlyphCapacity) {
|
||||
original = items[mirrorIndex];
|
||||
original.x = kMirrorX;
|
||||
original.y = kMirrorY;
|
||||
flipped = original;
|
||||
flipped.x = kMirrorX + original.w + kMirrorGap;
|
||||
std::swap(flipped.u0, flipped.u1);
|
||||
std::swap(flipped.v0, flipped.v1);
|
||||
items[glyphCount++] = original;
|
||||
items[glyphCount++] = flipped;
|
||||
|
||||
GlyphItem empty{};
|
||||
empty.x = kEmptyX;
|
||||
empty.y = kEmptyY;
|
||||
empty.w = kEmptySize;
|
||||
empty.h = kEmptySize;
|
||||
empty.u0 = kEmptyUv0;
|
||||
empty.v0 = kEmptyUv0;
|
||||
empty.u1 = kEmptyUv1;
|
||||
empty.v1 = kEmptyUv1;
|
||||
empty.r = empty.g = empty.b = empty.a = 1.0f;
|
||||
items[glyphCount++] = empty;
|
||||
}
|
||||
for (std::uint32_t i = 0; i < glyphCount; ++i) {
|
||||
fused.value[i] = items[i];
|
||||
fused.value[i].y += kFusedOffset;
|
||||
}
|
||||
|
||||
constexpr VkAccessFlags read = VK_ACCESS_SHADER_READ_BIT;
|
||||
constexpr VkPipelineStageFlags stage = VK_PIPELINE_STAGE_COMPUTE_SHADER_BIT;
|
||||
background.FlushDevice(cmd, read, stage);
|
||||
standalone.FlushDevice(cmd, read, stage);
|
||||
fused.FlushDevice(cmd, read, stage);
|
||||
|
||||
ui.DispatchQuads(cmd, backgroundSlot, 1);
|
||||
ui.DispatchText(cmd, standaloneSlot, glyphCount);
|
||||
ui.DispatchFused(cmd, {}, {}, {}, {fusedSlot, glyphCount});
|
||||
});
|
||||
|
||||
// The first frame is enough: DispatchText and DispatchFused upload the
|
||||
// glyphs ShapeText rasterised into the atlas before they draw, in the
|
||||
// same command buffer, and the capture waits for the frame.
|
||||
window.FinishInit();
|
||||
window.CaptureNextFrame(png);
|
||||
window.Render();
|
||||
// The buffers, atlas and heap go out of scope while the frame may still
|
||||
// be presenting; settle the queue first.
|
||||
Device::CheckVkResult(vkQueueWaitIdle(Device::queue));
|
||||
|
||||
if (std::getenv("CRAFTER_GRAPHICS_VALIDATION") != nullptr) {
|
||||
Check(Device::validationErrorCount == 0,
|
||||
std::format("no Vulkan validation errors while rendering and capturing ({} seen)", Device::validationErrorCount));
|
||||
}
|
||||
const bool shaped = std::ranges::all_of(rowCounts, [](std::uint32_t n) { return n == kText.size(); });
|
||||
Check(shaped && glyphCount == kText.size() * kRows.size() + 3,
|
||||
std::format("every glyph was shaped ({} items for {} rows of {} glyphs and 3 more)",
|
||||
glyphCount, kRows.size(), kText.size()));
|
||||
|
||||
const Coverage frame = LoadFrameCoverage(png);
|
||||
Check(!frame.value.empty(), "the frame was captured");
|
||||
if (frame.value.empty() || !shaped) return EXIT_FAILURE;
|
||||
|
||||
// Both copies of the scene, one above the other, inside the frame.
|
||||
const float sceneRight = std::max(kEmptyX + kEmptySize, flipped.x + flipped.w);
|
||||
const float sceneBottom = std::max(kEmptyY + kEmptySize, flipped.y + flipped.h);
|
||||
const bool fits = sceneRight <= static_cast<float>(kWidth) && sceneBottom <= static_cast<float>(kFusedOffset)
|
||||
&& frame.width >= static_cast<int>(kWidth) && frame.height >= 2 * kFusedOffset;
|
||||
Check(fits, std::format("the {}x{} frame holds both copies of the scene, {} by {} px each",
|
||||
frame.width, frame.height, sceneRight, sceneBottom));
|
||||
if (!fits) return EXIT_FAILURE;
|
||||
|
||||
for (const Row& row : kRows) {
|
||||
const Coverage ref = ReferenceCoverage(font, atlas, row);
|
||||
const Box box = CoveredBox(ref);
|
||||
CheckEdges(std::format("DispatchText, {}", RowName(row)), CompareEdges(frame, 0, ref, box));
|
||||
CheckEdges(std::format("DispatchFused, {}", RowName(row)), CompareEdges(frame, kFusedOffset, ref, box));
|
||||
}
|
||||
|
||||
// The flipped copy against the original read backwards on both axes.
|
||||
// One code value of slack: the two sample the same atlas points through
|
||||
// different float arithmetic.
|
||||
{
|
||||
const int w = static_cast<int>(original.w);
|
||||
const int h = static_cast<int>(original.h);
|
||||
const int ox = static_cast<int>(original.x);
|
||||
const int fx = static_cast<int>(flipped.x);
|
||||
const int y0 = static_cast<int>(original.y);
|
||||
int maxDiff = 0;
|
||||
float peak = 0.0f;
|
||||
for (int y = 0; y < h; ++y) {
|
||||
for (int x = 0; x < w; ++x) {
|
||||
const float a = frame.At(ox + x, y0 + y);
|
||||
const float b = frame.At(fx + (w - 1 - x), y0 + (h - 1 - y));
|
||||
maxDiff = std::max(maxDiff, std::abs(Code(a) - Code(b)));
|
||||
peak = std::max(peak, a);
|
||||
}
|
||||
}
|
||||
Check(peak >= 0.99f && maxDiff <= 1,
|
||||
std::format("a '{}' turned by swapping its UVs draws its mirror image (max difference {}, peak coverage {:.2f})",
|
||||
kMirrorGlyph, maxDiff, peak));
|
||||
}
|
||||
|
||||
// Texels holding 0 draw nothing, however far they are shrunk.
|
||||
{
|
||||
int maxCode = 0;
|
||||
for (int y = static_cast<int>(kEmptyY); y < static_cast<int>(kEmptyY + kEmptySize); ++y) {
|
||||
for (int x = static_cast<int>(kEmptyX); x < static_cast<int>(kEmptyX + kEmptySize); ++x) {
|
||||
maxCode = std::max(maxCode, Code(frame.At(x, y)));
|
||||
}
|
||||
}
|
||||
Check(maxCode == 0, std::format("empty atlas texels shrunk to {:.1f} atlas pixels per screen pixel draw nothing (max {})",
|
||||
(kEmptyUv1 - kEmptyUv0) * FontAtlas::kAtlasSize / kEmptySize, maxCode));
|
||||
}
|
||||
|
||||
// The whole fused copy against the whole standalone one. One code value
|
||||
// of slack: the two kernels share the per-pixel math, but the driver
|
||||
// compiles them separately and may contract or reorder it differently on
|
||||
// an antialiased edge.
|
||||
{
|
||||
int maxDiff = 0;
|
||||
for (int y = 0; y < kFusedOffset; ++y) {
|
||||
for (int x = 0; x < static_cast<int>(kWidth); ++x) {
|
||||
maxDiff = std::max(maxDiff, std::abs(Code(frame.At(x, y)) - Code(frame.At(x, y + kFusedOffset))));
|
||||
}
|
||||
}
|
||||
Check(maxDiff <= 1, std::format("DispatchFused draws what DispatchText draws (max difference {})", maxDiff));
|
||||
}
|
||||
|
||||
if (failures != 0) std::println(" frame kept for inspection: {}", png.string());
|
||||
return failures == 0 ? EXIT_SUCCESS : EXIT_FAILURE;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
int main() {
|
||||
const int result = Run();
|
||||
if (result != 77) std::println("{}", result == EXIT_SUCCESS ? "all checks passed" : "check(s) failed");
|
||||
return result;
|
||||
}
|
||||
Loading…
Reference in a new issue