Font builder
This commit is contained in:
@@ -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"
|
||||
|
||||
20
README.md
20
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
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
180
src/runtime/FontAtlasBuilder.cpp
Normal file
180
src/runtime/FontAtlasBuilder.cpp
Normal file
@@ -0,0 +1,180 @@
|
||||
#include "FontAtlasBuilder.h"
|
||||
|
||||
#include "NativeHandles.h"
|
||||
|
||||
#include <algorithm>
|
||||
#include <cctype>
|
||||
#include <fstream>
|
||||
#include <sstream>
|
||||
#include <system_error>
|
||||
#include <windows.h>
|
||||
|
||||
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<FontAtlasBuildOutput>& 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<char>(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<char> 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;
|
||||
}
|
||||
}
|
||||
52
src/runtime/FontAtlasBuilder.h
Normal file
52
src/runtime/FontAtlasBuilder.h
Normal file
@@ -0,0 +1,52 @@
|
||||
#pragma once
|
||||
|
||||
#include "ShaderTypes.h"
|
||||
|
||||
#include <filesystem>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
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<FontAtlasBuildOutput>& 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;
|
||||
};
|
||||
}
|
||||
@@ -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<FontAtlasBuildOutput> 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;
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
#pragma once
|
||||
|
||||
#include "FontAtlasBuilder.h"
|
||||
#include "ShaderTypes.h"
|
||||
|
||||
#include <filesystem>
|
||||
@@ -30,10 +31,12 @@ class SupportedShaderCatalog
|
||||
public:
|
||||
bool Load(const std::filesystem::path& shaderRoot, unsigned maxTemporalHistoryFrames, std::string& error);
|
||||
const std::vector<SupportedShaderSummary>& Shaders() const { return mShaders; }
|
||||
const std::map<std::string, std::vector<FontAtlasBuildOutput>>& FontAtlases() const { return mFontAtlasesByShaderId; }
|
||||
const ShaderPackage* FindPackage(const std::string& shaderId) const;
|
||||
|
||||
private:
|
||||
std::vector<SupportedShaderSummary> mShaders;
|
||||
std::map<std::string, ShaderPackage> mPackagesById;
|
||||
std::map<std::string, std::vector<FontAtlasBuildOutput>> mFontAtlasesByShaderId;
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
83
tests/FontAtlasBuilderTests.cpp
Normal file
83
tests/FontAtlasBuilderTests.cpp
Normal file
@@ -0,0 +1,83 @@
|
||||
#include "FontAtlasBuilder.h"
|
||||
#include "ShaderPackageRegistry.h"
|
||||
|
||||
#include <filesystem>
|
||||
#include <iostream>
|
||||
|
||||
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<RenderCadenceCompositor::FontAtlasBuildOutput> 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;
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
#include "SupportedShaderCatalog.h"
|
||||
|
||||
#include <filesystem>
|
||||
#include <iostream>
|
||||
#include <string>
|
||||
|
||||
@@ -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)
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user