From 081364e76427a983c87eac64192dea3a60a364d5 Mon Sep 17 00:00:00 2001 From: Aiden Date: Wed, 20 May 2026 15:49:29 +1000 Subject: [PATCH] Font builder --- CMakeLists.txt | 39 ++++ README.md | 20 +- shaders/vhs/shader.slang | 55 +++--- src/README.md | 4 +- src/app/RuntimeLayerControllerBuild.cpp | 9 +- .../runtime/RuntimeRenderSceneRender.cpp | 6 +- src/runtime/FontAtlasBuilder.cpp | 180 ++++++++++++++++++ src/runtime/FontAtlasBuilder.h | 52 +++++ src/runtime/SupportedShaderCatalog.cpp | 13 ++ src/runtime/SupportedShaderCatalog.h | 3 + tests/CMakeLists.txt | 10 + tests/FontAtlasBuilderTests.cpp | 83 ++++++++ ...eCompositorSupportedShaderCatalogTests.cpp | 35 ++++ 13 files changed, 476 insertions(+), 33 deletions(-) create mode 100644 src/runtime/FontAtlasBuilder.cpp create mode 100644 src/runtime/FontAtlasBuilder.h create mode 100644 tests/FontAtlasBuilderTests.cpp diff --git a/CMakeLists.txt b/CMakeLists.txt index a27fdab..da5a406 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -11,6 +11,7 @@ set(CMAKE_CXX_EXTENSIONS OFF) set(SRC_DIR "${CMAKE_CURRENT_SOURCE_DIR}/src") set(TEST_DIR "${CMAKE_CURRENT_SOURCE_DIR}/tests") set(SLANG_ROOT "${CMAKE_CURRENT_SOURCE_DIR}/3rdParty/slang-2026.8-windows-x86_64" CACHE PATH "Path to a Slang binary release containing bin/slangc.exe") +set(MSDF_ATLAS_GEN_ROOT "${CMAKE_CURRENT_SOURCE_DIR}/3rdParty/msdf-atlas-gen" CACHE PATH "Path to msdf-atlas-gen binary release") set(VIDEO_SHADER_INCLUDE_DIRS "${SRC_DIR}" @@ -61,6 +62,16 @@ set(SLANG_RUNTIME_FILES "${SLANG_ROOT}/bin/slang-glslang.dll" ) set(SLANG_LICENSE_FILE "${SLANG_ROOT}/LICENSE") +set(MSDF_ATLAS_GEN_EXE_FILE "${MSDF_ATLAS_GEN_ROOT}/msdf-atlas-gen.exe") +file(GLOB MSDF_ATLAS_GEN_DLL_FILES CONFIGURE_DEPENDS + "${MSDF_ATLAS_GEN_ROOT}/*.dll" +) +set(MSDF_ATLAS_GEN_RUNTIME_FILES + "${MSDF_ATLAS_GEN_EXE_FILE}" + ${MSDF_ATLAS_GEN_DLL_FILES} +) +set(MSDF_ATLAS_GEN_LICENSE_FILE "${MSDF_ATLAS_GEN_ROOT}/LICENSE.txt") +set(MSDF_ATLAS_GEN_README_FILE "${MSDF_ATLAS_GEN_ROOT}/README.md") set(RENDER_CADENCE_APP_REQUIRED_FILES "${SRC_DIR}/RenderCadenceCompositor.cpp" @@ -136,6 +147,34 @@ foreach(slang_runtime_file IN LISTS SLANG_RUNTIME_FILES) endif() endforeach() +foreach(msdf_runtime_file IN LISTS MSDF_ATLAS_GEN_RUNTIME_FILES) + if(EXISTS "${msdf_runtime_file}") + install(FILES "${msdf_runtime_file}" + DESTINATION "3rdParty/msdf-atlas-gen" + ) + else() + message(STATUS "msdf-atlas-gen runtime file not found and will not be installed: ${msdf_runtime_file}") + endif() +endforeach() + +if(EXISTS "${MSDF_ATLAS_GEN_LICENSE_FILE}") + install(FILES "${MSDF_ATLAS_GEN_LICENSE_FILE}" + DESTINATION "third_party_notices" + RENAME "MSDF_ATLAS_GEN_LICENSE.txt" + ) +else() + message(STATUS "msdf-atlas-gen license file not found: ${MSDF_ATLAS_GEN_LICENSE_FILE}") +endif() + +if(EXISTS "${MSDF_ATLAS_GEN_README_FILE}") + install(FILES "${MSDF_ATLAS_GEN_README_FILE}" + DESTINATION "third_party_notices" + RENAME "MSDF_ATLAS_GEN_README.md" + ) +else() + message(STATUS "msdf-atlas-gen readme file not found: ${MSDF_ATLAS_GEN_README_FILE}") +endif() + if(EXISTS "${SLANG_LICENSE_FILE}") install(FILES "${SLANG_LICENSE_FILE}" DESTINATION "third_party_notices" diff --git a/README.md b/README.md index 7566a3c..01cd7c2 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@ The app loads shader packages from `shaders/`, compiles Slang to GLSL at runtime ## Repository Layout -- `apps/LoopThroughWithOpenGLCompositing/`: native C++ host app. +- `src/`: native C++ host app. - `shaders/`: shader packages, each with `shader.json` and `shader.slang`. - `ui/`: Vite/React control UI. - `config/runtime-host.json`: runtime configuration. @@ -30,6 +30,7 @@ Native app internals are grouped by boundary: - Node.js and npm for the control UI. - Blackmagic Desktop Video drivers and a DeckLink device for the current production video I/O backend. - Slang binary release with `slangc.exe`, `slang-compiler.dll`, `slang-glslang.dll`, and `LICENSE`. +- `msdf-atlas-gen` Windows binary release with `msdf-atlas-gen.exe`, `LICENSE.txt`, and any adjacent runtime DLLs for font atlas generation. Default expected Slang path: @@ -37,6 +38,12 @@ Default expected Slang path: 3rdParty/slang-2026.8-windows-x86_64 ``` +Default expected `msdf-atlas-gen` path: + +```text +3rdParty/msdf-atlas-gen +``` + Override example: ```powershell @@ -88,10 +95,11 @@ The package folder will contain: ```text dist/VideoShader/ - LoopThroughWithOpenGLCompositing.exe + RenderCadenceCompositor.exe config/ shaders/ 3rdParty/slang/bin/ + 3rdParty/msdf-atlas-gen/ ui/dist/ docs/ SHADER_CONTRACT.md @@ -99,9 +107,9 @@ dist/VideoShader/ third_party_notices/ ``` -You can run `LoopThroughWithOpenGLCompositing.exe` directly from that folder. In packaged mode, the app resolves `config/`, `shaders/`, `3rdParty/slang/bin/slangc.exe`, `ui/dist/`, and `runtime/templates/` relative to the exe folder. In development mode, it still falls back to repo-root discovery. +You can run `RenderCadenceCompositor.exe` directly from that folder. In packaged mode, the app resolves `config/`, `shaders/`, `3rdParty/slang/bin/slangc.exe`, `3rdParty/msdf-atlas-gen/msdf-atlas-gen.exe`, `ui/dist/`, and `runtime/templates/` relative to the exe folder. In development mode, it still falls back to repo-root discovery. -The install step copies only the Slang runtime files required by the shader compiler (`slangc.exe`, `slang-compiler.dll`, and `slang-glslang.dll`) plus `third_party_notices/SLANG_LICENSE.txt`. It does not copy the full Slang release folder. +The install step copies only the Slang runtime files required by the shader compiler (`slangc.exe`, `slang-compiler.dll`, and `slang-glslang.dll`) plus `third_party_notices/SLANG_LICENSE.txt`. It also copies `msdf-atlas-gen.exe`, any adjacent `msdf-atlas-gen` DLLs, and the `third_party_notices/MSDF_ATLAS_GEN_LICENSE.txt` and `third_party_notices/MSDF_ATLAS_GEN_README.md` notice files. It does not copy full third-party release folders. Create a zip for distribution: @@ -250,6 +258,7 @@ The Gitea workflow expects two act runners: The Windows jobs validate native third-party dependencies before configuring CMake. Because `3rdParty/` is ignored, configure this path on the runner or in a Gitea repository variable: - `SLANG_ROOT`: path to the Slang binary release folder containing `bin/slangc.exe`. +- `MSDF_ATLAS_GEN_ROOT`: path to the `msdf-atlas-gen` binary release folder containing `msdf-atlas-gen.exe`. The Windows runner also needs the Visual Studio ATL component installed. In Visual Studio Build Tools 2022, add `C++ ATL for latest v143 build tools (x86 & x64)`, component ID `Microsoft.VisualStudio.Component.VC.ATL`. @@ -257,9 +266,10 @@ Example runner paths: ```text D:\SDKs\slang-2026.8-windows-x86_64 +D:\SDKs\msdf-atlas-gen ``` -If `SLANG_ROOT` is not set, the workflow falls back to the repo-local default under `3rdParty/`. +If `SLANG_ROOT` or `MSDF_ATLAS_GEN_ROOT` is not set, the workflow falls back to the repo-local defaults under `3rdParty/`. ## Still Todo diff --git a/shaders/vhs/shader.slang b/shaders/vhs/shader.slang index 70f108e..bd5c0ce 100644 --- a/shaders/vhs/shader.slang +++ b/shaders/vhs/shader.slang @@ -63,6 +63,11 @@ float grainScalar(float2 uv) return frac(sin(dot(uv, float2(12.9898, 78.233))) * 43758.5453); } +float4 sampleVhsFrame(float2 uv) +{ + return sampleVideo(saturate(uv)); +} + float3 animatedChromaGrain(float2 uv, float time, float2 outputResolution, float grainSize) { float safeGrainSize = max(grainSize, 0.001); @@ -142,15 +147,15 @@ float3 softBloom(float2 uv, float2 outputResolution, float radius) float2 dx = float2(pixel.x * radius, 0.0); float2 dy = float2(0.0, pixel.y * radius); - float3 sum = sampleVideo(frac(uv)).rgb * 0.28; - sum += sampleVideo(frac(uv + dx)).rgb * 0.14; - sum += sampleVideo(frac(uv - dx)).rgb * 0.14; - sum += sampleVideo(frac(uv + dy)).rgb * 0.14; - sum += sampleVideo(frac(uv - dy)).rgb * 0.14; - sum += sampleVideo(frac(uv + dx + dy)).rgb * 0.075; - sum += sampleVideo(frac(uv + dx - dy)).rgb * 0.075; - sum += sampleVideo(frac(uv - dx + dy)).rgb * 0.075; - sum += sampleVideo(frac(uv - dx - dy)).rgb * 0.075; + float3 sum = sampleVhsFrame(uv).rgb * 0.28; + sum += sampleVhsFrame(uv + dx).rgb * 0.14; + sum += sampleVhsFrame(uv - dx).rgb * 0.14; + sum += sampleVhsFrame(uv + dy).rgb * 0.14; + sum += sampleVhsFrame(uv - dy).rgb * 0.14; + sum += sampleVhsFrame(uv + dx + dy).rgb * 0.075; + sum += sampleVhsFrame(uv + dx - dy).rgb * 0.075; + sum += sampleVhsFrame(uv - dx + dy).rgb * 0.075; + sum += sampleVhsFrame(uv - dx - dy).rgb * 0.075; return sum; } @@ -158,11 +163,11 @@ float3 softCrossBlur(float2 uv, float2 outputResolution, float radius) { float2 pixel = 1.0 / max(outputResolution, float2(1.0, 1.0)); float2 offset = pixel * radius; - float3 sum = sampleVideo(frac(uv)).rgb * 0.40; - sum += sampleVideo(frac(uv + float2(offset.x, 0.0))).rgb * 0.15; - sum += sampleVideo(frac(uv - float2(offset.x, 0.0))).rgb * 0.15; - sum += sampleVideo(frac(uv + float2(0.0, offset.y))).rgb * 0.15; - sum += sampleVideo(frac(uv - float2(0.0, offset.y))).rgb * 0.15; + float3 sum = sampleVhsFrame(uv).rgb * 0.40; + sum += sampleVhsFrame(uv + float2(offset.x, 0.0)).rgb * 0.15; + sum += sampleVhsFrame(uv - float2(offset.x, 0.0)).rgb * 0.15; + sum += sampleVhsFrame(uv + float2(0.0, offset.y)).rgb * 0.15; + sum += sampleVhsFrame(uv - float2(0.0, offset.y)).rgb * 0.15; return sum; } @@ -174,14 +179,14 @@ float3 applyChromaCrawl(float3 color, float2 uv, float time, float2 outputResolu float2 pixel = 1.0 / max(outputResolution, float2(1.0, 1.0)); float lumaCenter = dot(color, float3(0.299, 0.587, 0.114)); - float lumaX = dot(sampleVideo(frac(uv + float2(pixel.x, 0.0))).rgb, float3(0.299, 0.587, 0.114)); - float lumaY = dot(sampleVideo(frac(uv + float2(0.0, pixel.y))).rgb, float3(0.299, 0.587, 0.114)); + float lumaX = dot(sampleVhsFrame(uv + float2(pixel.x, 0.0)).rgb, float3(0.299, 0.587, 0.114)); + float lumaY = dot(sampleVhsFrame(uv + float2(0.0, pixel.y)).rgb, float3(0.299, 0.587, 0.114)); float edge = saturate((abs(lumaX - lumaCenter) + abs(lumaY - lumaCenter)) * 6.0); float phase = sin(uv.y * outputResolution.y * 1.35 + time * 36.0) * cos(uv.x * outputResolution.x * 0.55 - time * 21.0); float2 crawlOffset = float2(phase, -phase * 0.35) * pixel * (1.0 + amount * 8.0); - float3 shiftedA = sampleVideo(frac(uv + crawlOffset)).rgb; - float3 shiftedB = sampleVideo(frac(uv - crawlOffset * 0.75)).rgb; + float3 shiftedA = sampleVhsFrame(uv + crawlOffset).rgb; + float3 shiftedB = sampleVhsFrame(uv - crawlOffset * 0.75).rgb; float3 crawled = color; crawled.r = lerp(color.r, shiftedA.r, edge * amount); crawled.b = lerp(color.b, shiftedB.b, edge * amount); @@ -249,7 +254,7 @@ float3 blurVhs(float2 uv, float d, int sampleCount) break; float2 offset = circle(start, float(sampleCount), float(i)) * scale; - sum += sampleVideo(frac(uv + offset)).rgb * weight; + sum += sampleVhsFrame(uv + offset).rgb * weight; } return sum; @@ -299,23 +304,23 @@ float4 buildTapeSmear(ShaderContext context) float4 finishVhs(ShaderContext context) { float time = distortedTapeTime(context); - float3 color = sampleVideo(context.uv).rgb; + float3 color = sampleVhsFrame(context.uv).rgb; // Radial red/blue offsets create lens and deck misregistration before the // wider tape effects are layered in. float2 centered = context.uv * 2.0 - 1.0; centered.x *= context.outputResolution.x / max(context.outputResolution.y, 1.0); float2 aberrationOffset = centered * (aberrationAmount * 0.0015); - float redAberration = sampleVideo(frac(context.uv + aberrationOffset)).r; - float blueAberration = sampleVideo(frac(context.uv - aberrationOffset)).b; + float redAberration = sampleVhsFrame(context.uv + aberrationOffset).r; + float blueAberration = sampleVhsFrame(context.uv - aberrationOffset).b; color.r = lerp(color.r, redAberration, 0.35); color.b = lerp(color.b, blueAberration, 0.35); float2 halationOffset = float2(0.0015, 0.0) * (1.0 + smear * 0.35); float3 halationSource = - sampleVideo(frac(context.uv + halationOffset)).rgb * 0.4 + - sampleVideo(frac(context.uv - halationOffset)).rgb * 0.4 + - sampleVideo(frac(context.uv + halationOffset * 2.0)).rgb * 0.2; + sampleVhsFrame(context.uv + halationOffset).rgb * 0.4 + + sampleVhsFrame(context.uv - halationOffset).rgb * 0.4 + + sampleVhsFrame(context.uv + halationOffset * 2.0).rgb * 0.2; float halationLuma = dot(halationSource, float3(0.299, 0.587, 0.114)); float halationMask = smoothstep(0.45, 1.0, halationLuma) * halationAmount; color += halationSource * float3(1.0, 0.38, 0.24) * halationMask * 0.35; diff --git a/src/README.md b/src/README.md index 8f7af6c..61a3aa4 100644 --- a/src/README.md +++ b/src/README.md @@ -89,7 +89,7 @@ Intentionally not included yet: - additional input format conversion/scaling - temporal/history/feedback shader storage - texture/LUT asset upload -- text-parameter rasterization +- text-parameter rasterization and font atlas GL binding - runtime state - OSC control - persistent control/state writes @@ -141,6 +141,7 @@ This tracks parity with `apps/LoopThroughWithOpenGLCompositing`. - [ ] Feedback buffers - [ ] Texture asset loading and upload - [ ] LUT asset loading and upload +- [x] CPU-side MSDF/MTSDF font atlas generation cache - [ ] Text parameter rasterization - [ ] Trigger history/event buffers for overlapping repeated trigger effects - [ ] Full runtime state store/read model @@ -352,6 +353,7 @@ Current runtime shader support is deliberately limited to stateless full-frame p - no temporal history - no feedback storage - no texture/LUT assets yet +- font atlas generation is CPU-side only; text parameter atlas upload/binding is not render-ready yet - no text parameters yet - manifest defaults initialize parameters - HTTP controls can update runtime parameter values without rebuilding GL programs when the shader program is unchanged diff --git a/src/app/RuntimeLayerControllerBuild.cpp b/src/app/RuntimeLayerControllerBuild.cpp index fd63321..65e860a 100644 --- a/src/app/RuntimeLayerControllerBuild.cpp +++ b/src/app/RuntimeLayerControllerBuild.cpp @@ -17,7 +17,14 @@ void RuntimeLayerController::LoadSupportedShaderCatalog(const std::string& shade return; } - Log("runtime-shader", "Supported shader catalog loaded with " + std::to_string(mShaderCatalog.Shaders().size()) + " shader(s)."); + std::size_t preparedFontAtlases = 0; + for (const auto& entry : mShaderCatalog.FontAtlases()) + preparedFontAtlases += entry.second.size(); + + Log( + "runtime-shader", + "Supported shader catalog loaded with " + std::to_string(mShaderCatalog.Shaders().size()) + + " shader(s), prepared " + std::to_string(preparedFontAtlases) + " font atlas asset(s)."); } void RuntimeLayerController::InitializeLayerModel(std::string& runtimeShaderId) diff --git a/src/render/runtime/RuntimeRenderSceneRender.cpp b/src/render/runtime/RuntimeRenderSceneRender.cpp index 65ff2e5..38fa55e 100644 --- a/src/render/runtime/RuntimeRenderSceneRender.cpp +++ b/src/render/runtime/RuntimeRenderSceneRender.cpp @@ -113,7 +113,11 @@ GLuint RuntimeRenderScene::RenderLayer( { sourceTexture = videoInputTexture; } - else if (inputName != "layerInput") + else if (inputName == "layerInput") + { + sourceTexture = layerInputTexture; + } + else { // Named intermediate pass inputs currently use the gVideoInput binding slot as the // selected pass source. Layer stack shaders should use gLayerInput for previous-layer diff --git a/src/runtime/FontAtlasBuilder.cpp b/src/runtime/FontAtlasBuilder.cpp new file mode 100644 index 0000000..aab69e6 --- /dev/null +++ b/src/runtime/FontAtlasBuilder.cpp @@ -0,0 +1,180 @@ +#include "FontAtlasBuilder.h" + +#include "NativeHandles.h" + +#include +#include +#include +#include +#include +#include + +namespace RenderCadenceCompositor +{ +namespace +{ +std::string QuotePath(const std::filesystem::path& path) +{ + return "\"" + path.string() + "\""; +} + +std::string NumberText(double value) +{ + std::ostringstream stream; + stream << value; + return stream.str(); +} +} + +FontAtlasBuilder::FontAtlasBuilder(FontAtlasBuildConfig config) : + mConfig(std::move(config)) +{ + if (mConfig.cacheRoot.empty()) + mConfig.cacheRoot = mConfig.repoRoot / "runtime" / "font_cache"; +} + +bool FontAtlasBuilder::BuildPackageFontAtlases( + const ShaderPackage& shaderPackage, + std::vector& outputs, + std::string& error) const +{ + outputs.clear(); + for (const ShaderFontAsset& fontAsset : shaderPackage.fontAssets) + { + FontAtlasBuildOutput output; + if (!BuildFontAtlas(shaderPackage, fontAsset, output, error)) + return false; + outputs.push_back(std::move(output)); + } + + error.clear(); + return true; +} + +bool FontAtlasBuilder::BuildFontAtlas( + const ShaderPackage& shaderPackage, + const ShaderFontAsset& fontAsset, + FontAtlasBuildOutput& output, + std::string& error) const +{ + output = FontAtlasBuildOutput(); + if (fontAsset.id.empty()) + { + error = "Font asset id is empty for shader package '" + shaderPackage.id + "'."; + return false; + } + if (fontAsset.path.empty() || !std::filesystem::exists(fontAsset.path)) + { + error = "Font asset '" + fontAsset.id + "' does not exist: " + fontAsset.path.string(); + return false; + } + + std::filesystem::path executablePath; + if (!FindMsdfAtlasGenExecutable(mConfig.repoRoot, executablePath)) + { + error = "Could not find msdf-atlas-gen.exe under 3rdParty/msdf-atlas-gen."; + return false; + } + + const std::filesystem::path packageCache = PackageCacheDirectory(shaderPackage); + std::error_code fsError; + std::filesystem::create_directories(packageCache, fsError); + if (fsError) + { + error = "Could not create font atlas cache directory: " + packageCache.string(); + return false; + } + + const std::string outputStem = SanitizePathToken(shaderPackage.id + "-" + fontAsset.id); + output.fontId = fontAsset.id; + output.imagePath = packageCache / (outputStem + ".png"); + output.jsonPath = packageCache / (outputStem + ".json"); + + const std::string commandLine = + QuotePath(executablePath) + + " -font " + QuotePath(fontAsset.path) + + " -fontname " + fontAsset.id + + " -type " + mConfig.atlasType + + " -format png" + + " -size " + NumberText(mConfig.sizePixelsPerEm) + + " -pxrange " + NumberText(mConfig.pixelRange) + + " -yorigin top" + + " -imageout " + QuotePath(output.imagePath) + + " -json " + QuotePath(output.jsonPath); + + if (!RunProcess(commandLine, mConfig.repoRoot, error)) + return false; + + if (!std::filesystem::exists(output.imagePath) || !std::filesystem::exists(output.jsonPath) || + std::filesystem::file_size(output.imagePath) == 0 || std::filesystem::file_size(output.jsonPath) == 0) + { + error = "msdf-atlas-gen did not produce expected atlas outputs for font '" + fontAsset.id + "'."; + return false; + } + + error.clear(); + return true; +} + +bool FontAtlasBuilder::FindMsdfAtlasGenExecutable(const std::filesystem::path& repoRoot, std::filesystem::path& executablePath) +{ + const std::filesystem::path expectedPath = repoRoot / "3rdParty" / "msdf-atlas-gen" / "msdf-atlas-gen.exe"; + if (std::filesystem::exists(expectedPath)) + { + executablePath = expectedPath; + return true; + } + + return false; +} + +std::filesystem::path FontAtlasBuilder::PackageCacheDirectory(const ShaderPackage& shaderPackage) const +{ + return mConfig.cacheRoot / SanitizePathToken(shaderPackage.id); +} + +std::string FontAtlasBuilder::SanitizePathToken(const std::string& value) +{ + std::string sanitized; + sanitized.reserve(value.size()); + for (unsigned char character : value) + { + if (std::isalnum(character) || character == '-' || character == '_') + sanitized.push_back(static_cast(character)); + else + sanitized.push_back('_'); + } + return sanitized.empty() ? "font" : sanitized; +} + +bool FontAtlasBuilder::RunProcess(const std::string& commandLine, const std::filesystem::path& workingDirectory, std::string& error) +{ + STARTUPINFOA startupInfo = {}; + PROCESS_INFORMATION processInfo = {}; + startupInfo.cb = sizeof(startupInfo); + + std::vector mutableCommandLine(commandLine.begin(), commandLine.end()); + mutableCommandLine.push_back('\0'); + + if (!CreateProcessA(nullptr, mutableCommandLine.data(), nullptr, nullptr, FALSE, CREATE_NO_WINDOW, nullptr, workingDirectory.string().c_str(), &startupInfo, &processInfo)) + { + error = "Failed to launch msdf-atlas-gen.exe."; + return false; + } + + UniqueHandle processHandle(processInfo.hProcess); + UniqueHandle threadHandle(processInfo.hThread); + WaitForSingleObject(processHandle.get(), INFINITE); + + DWORD exitCode = 0; + GetExitCodeProcess(processHandle.get(), &exitCode); + if (exitCode != 0) + { + error = "msdf-atlas-gen.exe returned a non-zero exit code."; + return false; + } + + error.clear(); + return true; +} +} diff --git a/src/runtime/FontAtlasBuilder.h b/src/runtime/FontAtlasBuilder.h new file mode 100644 index 0000000..eca5726 --- /dev/null +++ b/src/runtime/FontAtlasBuilder.h @@ -0,0 +1,52 @@ +#pragma once + +#include "ShaderTypes.h" + +#include +#include +#include + +namespace RenderCadenceCompositor +{ +struct FontAtlasBuildConfig +{ + std::filesystem::path repoRoot; + std::filesystem::path cacheRoot; + double sizePixelsPerEm = 64.0; + double pixelRange = 4.0; + std::string atlasType = "mtsdf"; +}; + +struct FontAtlasBuildOutput +{ + std::string fontId; + std::filesystem::path imagePath; + std::filesystem::path jsonPath; +}; + +class FontAtlasBuilder +{ +public: + explicit FontAtlasBuilder(FontAtlasBuildConfig config); + + bool BuildPackageFontAtlases( + const ShaderPackage& shaderPackage, + std::vector& outputs, + std::string& error) const; + + bool BuildFontAtlas( + const ShaderPackage& shaderPackage, + const ShaderFontAsset& fontAsset, + FontAtlasBuildOutput& output, + std::string& error) const; + + static bool FindMsdfAtlasGenExecutable(const std::filesystem::path& repoRoot, std::filesystem::path& executablePath); + +private: + std::filesystem::path PackageCacheDirectory(const ShaderPackage& shaderPackage) const; + static std::string SanitizePathToken(const std::string& value); + static bool RunProcess(const std::string& commandLine, const std::filesystem::path& workingDirectory, std::string& error); + + FontAtlasBuildConfig mConfig; +}; +} diff --git a/src/runtime/SupportedShaderCatalog.cpp b/src/runtime/SupportedShaderCatalog.cpp index 6d5e5ed..d295b6e 100644 --- a/src/runtime/SupportedShaderCatalog.cpp +++ b/src/runtime/SupportedShaderCatalog.cpp @@ -66,6 +66,7 @@ bool SupportedShaderCatalog::Load(const std::filesystem::path& shaderRoot, unsig { mShaders.clear(); mPackagesById.clear(); + mFontAtlasesByShaderId.clear(); if (shaderRoot.empty()) { @@ -80,6 +81,10 @@ bool SupportedShaderCatalog::Load(const std::filesystem::path& shaderRoot, unsig if (!registry.Scan(shaderRoot, packagesById, packageOrder, packageStatuses, error)) return false; + FontAtlasBuildConfig fontConfig; + fontConfig.repoRoot = shaderRoot.parent_path(); + FontAtlasBuilder fontAtlasBuilder(fontConfig); + for (const std::string& packageId : packageOrder) { const auto packageIt = packagesById.find(packageId); @@ -87,6 +92,14 @@ bool SupportedShaderCatalog::Load(const std::filesystem::path& shaderRoot, unsig continue; const ShaderPackage& shaderPackage = packageIt->second; + if (!shaderPackage.fontAssets.empty()) + { + std::vector fontAtlasOutputs; + std::string fontAtlasError; + if (fontAtlasBuilder.BuildPackageFontAtlases(shaderPackage, fontAtlasOutputs, fontAtlasError)) + mFontAtlasesByShaderId[shaderPackage.id] = std::move(fontAtlasOutputs); + } + const ShaderSupportResult support = CheckStatelessSinglePassShaderSupport(shaderPackage); if (!support.supported) continue; diff --git a/src/runtime/SupportedShaderCatalog.h b/src/runtime/SupportedShaderCatalog.h index 92eb94d..95df5bd 100644 --- a/src/runtime/SupportedShaderCatalog.h +++ b/src/runtime/SupportedShaderCatalog.h @@ -1,5 +1,6 @@ #pragma once +#include "FontAtlasBuilder.h" #include "ShaderTypes.h" #include @@ -30,10 +31,12 @@ class SupportedShaderCatalog public: bool Load(const std::filesystem::path& shaderRoot, unsigned maxTemporalHistoryFrames, std::string& error); const std::vector& Shaders() const { return mShaders; } + const std::map>& FontAtlases() const { return mFontAtlasesByShaderId; } const ShaderPackage* FindPackage(const std::string& shaderId) const; private: std::vector mShaders; std::map mPackagesById; + std::map> mFontAtlasesByShaderId; }; } diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index 84f61ab..4d637db 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -40,6 +40,7 @@ add_video_shader_test(RenderCadenceCompositorRuntimeShaderParamsTests ) add_video_shader_test(RenderCadenceCompositorRuntimeLayerModelTests + "${SRC_DIR}/runtime/FontAtlasBuilder.cpp" "${SRC_DIR}/runtime/RuntimeLayerModel.cpp" "${SRC_DIR}/runtime/RuntimeJson.cpp" "${SRC_DIR}/runtime/RuntimeParameterUtils.cpp" @@ -48,7 +49,15 @@ add_video_shader_test(RenderCadenceCompositorRuntimeLayerModelTests "${TEST_DIR}/RenderCadenceCompositorRuntimeLayerModelTests.cpp" ) +add_video_shader_test(FontAtlasBuilderTests + "${SRC_DIR}/runtime/FontAtlasBuilder.cpp" + "${SRC_DIR}/runtime/RuntimeJson.cpp" + "${SRC_DIR}/shader/ShaderPackageRegistry.cpp" + "${TEST_DIR}/FontAtlasBuilderTests.cpp" +) + add_video_shader_test(RenderCadenceCompositorSupportedShaderCatalogTests + "${SRC_DIR}/runtime/FontAtlasBuilder.cpp" "${SRC_DIR}/runtime/RuntimeJson.cpp" "${SRC_DIR}/runtime/SupportedShaderCatalog.cpp" "${SRC_DIR}/shader/ShaderPackageRegistry.cpp" @@ -59,6 +68,7 @@ add_video_shader_test(RenderCadenceCompositorRuntimeStateJsonTests "${SRC_DIR}/app/AppConfig.cpp" "${SRC_DIR}/app/AppConfigProvider.cpp" "${SRC_DIR}/json/JsonWriter.cpp" + "${SRC_DIR}/runtime/FontAtlasBuilder.cpp" "${SRC_DIR}/runtime/RuntimeJson.cpp" "${SRC_DIR}/runtime/RuntimeLayerModel.cpp" "${SRC_DIR}/runtime/RuntimeParameterUtils.cpp" diff --git a/tests/FontAtlasBuilderTests.cpp b/tests/FontAtlasBuilderTests.cpp new file mode 100644 index 0000000..4a6ed9a --- /dev/null +++ b/tests/FontAtlasBuilderTests.cpp @@ -0,0 +1,83 @@ +#include "FontAtlasBuilder.h" +#include "ShaderPackageRegistry.h" + +#include +#include + +namespace +{ +int gFailures = 0; + +void Expect(bool condition, const char* message) +{ + if (condition) + return; + + std::cerr << "FAIL: " << message << "\n"; + ++gFailures; +} + +std::filesystem::path RepoRoot() +{ + std::filesystem::path path = std::filesystem::current_path(); + while (!path.empty()) + { + if (std::filesystem::exists(path / "3rdParty" / "msdf-atlas-gen" / "msdf-atlas-gen.exe")) + return path; + const std::filesystem::path parent = path.parent_path(); + if (parent.empty() || parent == path) + break; + path = parent; + } + return std::filesystem::current_path(); +} + +void TestFindsBundledExecutable() +{ + std::filesystem::path executablePath; + Expect( + RenderCadenceCompositor::FontAtlasBuilder::FindMsdfAtlasGenExecutable(RepoRoot(), executablePath), + "bundled msdf-atlas-gen executable is found"); + Expect(std::filesystem::exists(executablePath), "bundled msdf-atlas-gen executable exists"); +} + +void TestBuildsTextOverlayFontAtlas() +{ + const std::filesystem::path repoRoot = RepoRoot(); + ShaderPackageRegistry registry(4); + ShaderPackage shaderPackage; + std::string error; + Expect(registry.ParseManifest(repoRoot / "shaders" / "text-overlay" / "shader.json", shaderPackage, error), "text overlay manifest parses"); + Expect(!shaderPackage.fontAssets.empty(), "text overlay declares a font asset"); + + RenderCadenceCompositor::FontAtlasBuildConfig config; + config.repoRoot = repoRoot; + config.cacheRoot = repoRoot / "runtime" / "test_font_cache"; + RenderCadenceCompositor::FontAtlasBuilder builder(config); + std::vector outputs; + Expect(builder.BuildPackageFontAtlases(shaderPackage, outputs, error), "text overlay font atlas builds"); + Expect(outputs.size() == 1, "one font atlas output is produced"); + if (!outputs.empty()) + { + Expect(std::filesystem::exists(outputs[0].imagePath), "font atlas image exists"); + Expect(std::filesystem::exists(outputs[0].jsonPath), "font atlas json exists"); + Expect(std::filesystem::file_size(outputs[0].imagePath) > 0, "font atlas image is not empty"); + Expect(std::filesystem::file_size(outputs[0].jsonPath) > 0, "font atlas json is not empty"); + } +} +} + +int main() +{ + TestFindsBundledExecutable(); + TestBuildsTextOverlayFontAtlas(); + + if (gFailures != 0) + { + std::cerr << gFailures << " font atlas builder test failure(s).\n"; + return 1; + } + + std::cout << "FontAtlasBuilder tests passed.\n"; + return 0; +} diff --git a/tests/RenderCadenceCompositorSupportedShaderCatalogTests.cpp b/tests/RenderCadenceCompositorSupportedShaderCatalogTests.cpp index 4c0f025..25735dc 100644 --- a/tests/RenderCadenceCompositorSupportedShaderCatalogTests.cpp +++ b/tests/RenderCadenceCompositorSupportedShaderCatalogTests.cpp @@ -1,5 +1,6 @@ #include "SupportedShaderCatalog.h" +#include #include #include @@ -29,6 +30,21 @@ ShaderPackage MakeSinglePassPackage() return shaderPackage; } +std::filesystem::path RepoRoot() +{ + std::filesystem::path path = std::filesystem::current_path(); + while (!path.empty()) + { + if (std::filesystem::exists(path / "shaders" / "text-overlay" / "shader.json")) + return path; + const std::filesystem::path parent = path.parent_path(); + if (parent.empty() || parent == path) + break; + path = parent; + } + return std::filesystem::current_path(); +} + void SupportsSinglePassStatelessPackage() { const ShaderPackage shaderPackage = MakeSinglePassPackage(); @@ -110,6 +126,24 @@ void RejectsTextParameters() Expect(!result.supported, "text-parameter packages should be rejected for now"); Expect(result.reason.find("text") != std::string::npos, "text rejection should mention text parameters"); } + +void BuildsDeclaredFontAtlasesDuringCatalogLoad() +{ + RenderCadenceCompositor::SupportedShaderCatalog catalog; + std::string error; + Expect(catalog.Load(RepoRoot() / "shaders", 12, error), "shader catalog loads"); + + const auto& fontAtlases = catalog.FontAtlases(); + const auto textOverlayIt = fontAtlases.find("text-overlay"); + Expect(textOverlayIt != fontAtlases.end(), "text overlay font atlas is prepared during catalog load"); + if (textOverlayIt != fontAtlases.end() && !textOverlayIt->second.empty()) + { + Expect(std::filesystem::exists(textOverlayIt->second.front().imagePath), "catalog font atlas image exists"); + Expect(std::filesystem::exists(textOverlayIt->second.front().jsonPath), "catalog font atlas json exists"); + Expect(std::filesystem::file_size(textOverlayIt->second.front().imagePath) > 0, "catalog font atlas image is not empty"); + Expect(std::filesystem::file_size(textOverlayIt->second.front().jsonPath) > 0, "catalog font atlas json is not empty"); + } +} } int main() @@ -120,6 +154,7 @@ int main() RejectsTemporalPackage(); RejectsTextureAssets(); RejectsTextParameters(); + BuildsDeclaredFontAtlasesDuringCatalogLoad(); if (gFailures != 0) {