diff --git a/CMakeLists.txt b/CMakeLists.txt index da5a406..8fb5422 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -122,6 +122,7 @@ else() target_link_libraries(RenderCadenceCompositor PRIVATE opengl32 Ole32 + Windowscodecs Ws2_32 ) source_group(TREE "${SRC_DIR}" FILES ${RENDER_CADENCE_APP_SOURCES}) diff --git a/src/README.md b/src/README.md index 61a3aa4..eb9d1c2 100644 --- a/src/README.md +++ b/src/README.md @@ -89,7 +89,6 @@ Intentionally not included yet: - additional input format conversion/scaling - temporal/history/feedback shader storage - texture/LUT asset upload -- text-parameter rasterization and font atlas GL binding - runtime state - OSC control - persistent control/state writes @@ -142,7 +141,7 @@ This tracks parity with `apps/LoopThroughWithOpenGLCompositing`. - [ ] Texture asset loading and upload - [ ] LUT asset loading and upload - [x] CPU-side MSDF/MTSDF font atlas generation cache -- [ ] Text parameter rasterization +- [x] Single-line text parameter rasterization and GL binding - [ ] Trigger history/event buffers for overlapping repeated trigger effects - [ ] Full runtime state store/read model - [ ] Persistent layer stack/config writes @@ -353,8 +352,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 +- text parameters are single-line ASCII masks backed by prepared MTSDF font atlases - manifest defaults initialize parameters - HTTP controls can update runtime parameter values without rebuilding GL programs when the shader program is unchanged - trigger parameters are treated as latest-pulse controls: each press increments the trigger count and records the current runtime time @@ -371,7 +369,7 @@ Shader source semantics: - later layers receive `gVideoInput = original input` and `gLayerInput = previous layer`. - named intermediate pass inputs inside a multipass layer are still routed through the selected pass-source slot; layer stacking should use `gLayerInput`. -The `/api/state` shader list uses the same support rules as runtime shader compilation and reports only packages this app can run today. Unsupported manifest feature sets such as temporal, feedback, texture-backed, font-backed, or text-parameter shaders are hidden from the control UI for now. +The `/api/state` shader list uses the same support rules as runtime shader compilation and reports only packages this app can run today. Unsupported manifest feature sets such as temporal, feedback, and texture-backed shaders are hidden from the control UI for now. Runtime shaders are exposed through `RuntimeLayerModel` as display layers with manifest parameter definitions, current parameter values, build status, and render-ready artifacts. POST controls mutate this app-owned model and may start background shader builds when the selected shader changes. diff --git a/src/render/GLExtensions.h b/src/render/GLExtensions.h index 0115f7d..f207e83 100644 --- a/src/render/GLExtensions.h +++ b/src/render/GLExtensions.h @@ -61,6 +61,8 @@ #define GL_UNIFORM_BUFFER 0x8A11 #define GL_RGBA8 0x8058 #define GL_RGBA16F 0x881A +#define GL_RED 0x1903 +#define GL_R8 0x8229 #define GL_TEXTURE0 0x84C0 #define GL_ACTIVE_TEXTURE 0x84E0 #define GL_ARRAY_BUFFER 0x8892 diff --git a/src/render/runtime/RuntimeRenderSceneRender.cpp b/src/render/runtime/RuntimeRenderSceneRender.cpp index 38fa55e..2060f53 100644 --- a/src/render/runtime/RuntimeRenderSceneRender.cpp +++ b/src/render/runtime/RuntimeRenderSceneRender.cpp @@ -105,7 +105,7 @@ GLuint RuntimeRenderScene::RenderLayer( if (!pass.renderer || !pass.renderer->HasProgram()) continue; - GLuint sourceTexture = videoInputTexture; + GLuint sourceTexture = layerInputTexture; if (!pass.inputNames.empty()) { const std::string& inputName = pass.inputNames.front(); diff --git a/src/render/runtime/RuntimeShaderRenderer.cpp b/src/render/runtime/RuntimeShaderRenderer.cpp index 3011165..1b4ae4f 100644 --- a/src/render/runtime/RuntimeShaderRenderer.cpp +++ b/src/render/runtime/RuntimeShaderRenderer.cpp @@ -56,6 +56,11 @@ bool RuntimeShaderRenderer::CommitPreparedProgram(RuntimePreparedShaderProgram& preparedProgram.program = 0; preparedProgram.vertexShader = 0; preparedProgram.fragmentShader = 0; + if (!mTextTextures.Configure(mArtifact, error)) + { + DestroyProgram(); + return false; + } return true; } @@ -64,6 +69,7 @@ void RuntimeShaderRenderer::UpdateArtifactState(const RuntimeShaderArtifact& art mArtifact.parameterDefinitions = artifact.parameterDefinitions; mArtifact.parameterValues = artifact.parameterValues; mArtifact.message = artifact.message; + mTextTextures.UpdateArtifactState(artifact); } bool RuntimeShaderRenderer::BuildPreparedProgram( @@ -117,6 +123,7 @@ bool RuntimeShaderRenderer::BuildPreparedPassProgram( preparedProgram.succeeded = true; AssignSamplerUniforms(preparedProgram.program); + RuntimeTextTextureCache::AssignSamplerUniforms(preparedProgram.program, artifact); return true; } @@ -131,6 +138,7 @@ void RuntimeShaderRenderer::RenderFrame(uint64_t frameIndex, unsigned width, uns glDisable(GL_BLEND); UpdateGlobalParams(frameIndex, width, height); BindRuntimeTextures(sourceTexture, layerInputTexture); + mTextTextures.BindTextTextures(mProgram); glBindVertexArray(mVertexArray); glUseProgram(mProgram); glDrawArrays(GL_TRIANGLES, 0, 3); @@ -300,6 +308,7 @@ void RuntimeShaderRenderer::DestroyProgram() glDeleteShader(mVertexShader); if (mFragmentShader != 0) glDeleteShader(mFragmentShader); + mTextTextures.ShutdownGl(); mProgram = 0; mVertexShader = 0; mFragmentShader = 0; diff --git a/src/render/runtime/RuntimeShaderRenderer.h b/src/render/runtime/RuntimeShaderRenderer.h index fea29de..402b00a 100644 --- a/src/render/runtime/RuntimeShaderRenderer.h +++ b/src/render/runtime/RuntimeShaderRenderer.h @@ -2,6 +2,7 @@ #include "GLExtensions.h" #include "RuntimeShaderProgram.h" +#include "RuntimeTextTextureCache.h" #include "../../runtime/RuntimeShaderArtifact.h" #include @@ -45,6 +46,7 @@ private: void DestroyStaticGlResources(); RuntimeShaderArtifact mArtifact; + RuntimeTextTextureCache mTextTextures; GLuint mProgram = 0; GLuint mVertexShader = 0; GLuint mFragmentShader = 0; diff --git a/src/render/runtime/RuntimeTextTextureCache.cpp b/src/render/runtime/RuntimeTextTextureCache.cpp new file mode 100644 index 0000000..19a70f2 --- /dev/null +++ b/src/render/runtime/RuntimeTextTextureCache.cpp @@ -0,0 +1,450 @@ +#include "RuntimeTextTextureCache.h" + +#include "../../runtime/RuntimeJson.h" + +#include +#include +#include +#include +#include +#include + +namespace +{ +constexpr GLuint kFirstTextTextureUnit = 8; +constexpr unsigned kTextTextureHeight = 128; +constexpr unsigned kTextTexturePadding = 8; +constexpr double kFontPixelsPerEm = 96.0; + +std::string ReadTextFile(const std::filesystem::path& path) +{ + std::ifstream input(path, std::ios::binary); + if (!input) + return std::string(); + std::ostringstream buffer; + buffer << input.rdbuf(); + return buffer.str(); +} + +const JsonValue* FindObjectValue(const JsonValue& object, const std::string& key) +{ + return object.isObject() ? object.find(key) : nullptr; +} + +double NumberMember(const JsonValue& object, const std::string& key, double fallback = 0.0) +{ + const JsonValue* value = FindObjectValue(object, key); + return value && value->isNumber() ? value->asNumber(fallback) : fallback; +} + +struct ComThreadGuard +{ + ~ComThreadGuard() + { + if (initialized) + CoUninitialize(); + } + + bool Initialize() + { + const HRESULT result = CoInitializeEx(nullptr, COINIT_MULTITHREADED); + initialized = SUCCEEDED(result); + return SUCCEEDED(result) || result == RPC_E_CHANGED_MODE; + } + + bool initialized = false; +}; +} + +RuntimeTextTextureCache::~RuntimeTextTextureCache() +{ + ShutdownGl(); +} + +bool RuntimeTextTextureCache::Configure(const RuntimeShaderArtifact& artifact, std::string& error) +{ + ShutdownGl(); + mArtifact = artifact; + + for (const RenderCadenceCompositor::FontAtlasBuildOutput& output : artifact.fontAtlases) + { + Atlas atlas; + if (!LoadAtlas(output, atlas, error)) + return false; + mAtlases.push_back(std::move(atlas)); + } + + for (const ShaderParameterDefinition& definition : artifact.parameterDefinitions) + { + if (definition.type != ShaderParameterType::Text) + continue; + + if (FindAtlas(definition.fontId) == nullptr) + { + error = "No prepared font atlas is available for text parameter '" + definition.id + "'."; + return false; + } + + TextTexture texture; + texture.parameterId = definition.id; + texture.fontId = definition.fontId; + mTextTextures.push_back(std::move(texture)); + } + + error.clear(); + return true; +} + +void RuntimeTextTextureCache::UpdateArtifactState(const RuntimeShaderArtifact& artifact) +{ + mArtifact.parameterDefinitions = artifact.parameterDefinitions; + mArtifact.parameterValues = artifact.parameterValues; +} + +void RuntimeTextTextureCache::BindTextTextures(GLuint program) +{ + for (std::size_t index = 0; index < mTextTextures.size(); ++index) + { + TextTexture& textTexture = mTextTextures[index]; + if (!EnsureTextTexture(textTexture)) + continue; + + glActiveTexture(GL_TEXTURE0 + kFirstTextTextureUnit + static_cast(index)); + glBindTexture(GL_TEXTURE_2D, textTexture.texture); + } + + glUseProgram(program); + AssignSamplerUniforms(program, mArtifact); + glUseProgram(0); + glActiveTexture(GL_TEXTURE0); +} + +void RuntimeTextTextureCache::ShutdownGl() +{ + for (TextTexture& texture : mTextTextures) + DestroyTexture(texture); + mTextTextures.clear(); + mAtlases.clear(); +} + +void RuntimeTextTextureCache::AssignSamplerUniforms(GLuint program, const RuntimeShaderArtifact& artifact) +{ + glUseProgram(program); + GLuint nextUnit = kFirstTextTextureUnit; + for (const ShaderParameterDefinition& definition : artifact.parameterDefinitions) + { + if (definition.type != ShaderParameterType::Text) + continue; + + const std::string samplerName = definition.id + "Texture"; + const GLint location = glGetUniformLocation(program, samplerName.c_str()); + if (location >= 0) + glUniform1i(location, static_cast(nextUnit)); + const std::string samplerArrayName = samplerName + "_0"; + const GLint arrayLocation = glGetUniformLocation(program, samplerArrayName.c_str()); + if (arrayLocation >= 0) + glUniform1i(arrayLocation, static_cast(nextUnit)); + ++nextUnit; + } + glUseProgram(0); +} + +bool RuntimeTextTextureCache::LoadAtlas(const RenderCadenceCompositor::FontAtlasBuildOutput& output, Atlas& atlas, std::string& error) const +{ + atlas.fontId = output.fontId; + if (!LoadAtlasJson(output, atlas, error)) + return false; + if (!LoadAtlasImage(output, atlas, error)) + return false; + if (atlas.width == 0 || atlas.height == 0 || atlas.rgbaPixels.empty()) + { + error = "Font atlas image is empty for font '" + output.fontId + "'."; + return false; + } + return true; +} + +bool RuntimeTextTextureCache::LoadAtlasJson(const RenderCadenceCompositor::FontAtlasBuildOutput& output, Atlas& atlas, std::string& error) const +{ + const std::string jsonText = ReadTextFile(output.jsonPath); + if (jsonText.empty()) + { + error = "Could not read font atlas json: " + output.jsonPath.string(); + return false; + } + + JsonValue root; + if (!ParseJson(jsonText, root, error)) + return false; + + const JsonValue* metrics = FindObjectValue(root, "metrics"); + if (metrics) + { + atlas.ascender = NumberMember(*metrics, "ascender", atlas.ascender); + atlas.descender = NumberMember(*metrics, "descender", atlas.descender); + atlas.lineHeight = NumberMember(*metrics, "lineHeight", atlas.lineHeight); + } + + const JsonValue* glyphs = FindObjectValue(root, "glyphs"); + if (!glyphs || !glyphs->isArray()) + { + error = "Font atlas json has no glyph array: " + output.jsonPath.string(); + return false; + } + + for (const JsonValue& glyphJson : glyphs->asArray()) + { + if (!glyphJson.isObject()) + continue; + + const unsigned codepoint = static_cast(NumberMember(glyphJson, "unicode", 0.0)); + Glyph glyph; + glyph.advance = NumberMember(glyphJson, "advance", 0.0); + + const JsonValue* planeBounds = FindObjectValue(glyphJson, "planeBounds"); + const JsonValue* atlasBounds = FindObjectValue(glyphJson, "atlasBounds"); + if (planeBounds && atlasBounds) + { + glyph.planeLeft = NumberMember(*planeBounds, "left", 0.0); + glyph.planeTop = NumberMember(*planeBounds, "top", 0.0); + glyph.planeRight = NumberMember(*planeBounds, "right", 0.0); + glyph.planeBottom = NumberMember(*planeBounds, "bottom", 0.0); + glyph.atlasLeft = NumberMember(*atlasBounds, "left", 0.0); + glyph.atlasTop = NumberMember(*atlasBounds, "top", 0.0); + glyph.atlasRight = NumberMember(*atlasBounds, "right", 0.0); + glyph.atlasBottom = NumberMember(*atlasBounds, "bottom", 0.0); + glyph.hasBounds = true; + } + + atlas.glyphsByCodepoint[codepoint] = glyph; + } + + error.clear(); + return true; +} + +bool RuntimeTextTextureCache::LoadAtlasImage(const RenderCadenceCompositor::FontAtlasBuildOutput& output, Atlas& atlas, std::string& error) const +{ + ComThreadGuard comGuard; + if (!comGuard.Initialize()) + { + error = "Could not initialize COM for font atlas PNG loading."; + return false; + } + + Microsoft::WRL::ComPtr factory; + HRESULT result = CoCreateInstance( + CLSID_WICImagingFactory, + nullptr, + CLSCTX_INPROC_SERVER, + IID_PPV_ARGS(factory.GetAddressOf())); + if (FAILED(result)) + { + error = "Could not create WIC imaging factory for font atlas PNG loading."; + return false; + } + + Microsoft::WRL::ComPtr decoder; + result = factory->CreateDecoderFromFilename( + output.imagePath.wstring().c_str(), + nullptr, + GENERIC_READ, + WICDecodeMetadataCacheOnLoad, + decoder.GetAddressOf()); + if (FAILED(result)) + { + error = "Could not decode font atlas PNG: " + output.imagePath.string(); + return false; + } + + Microsoft::WRL::ComPtr frame; + result = decoder->GetFrame(0, frame.GetAddressOf()); + if (FAILED(result)) + { + error = "Could not read font atlas PNG frame: " + output.imagePath.string(); + return false; + } + + Microsoft::WRL::ComPtr converter; + result = factory->CreateFormatConverter(converter.GetAddressOf()); + if (FAILED(result)) + { + error = "Could not create WIC format converter for font atlas PNG."; + return false; + } + + result = converter->Initialize( + frame.Get(), + GUID_WICPixelFormat32bppRGBA, + WICBitmapDitherTypeNone, + nullptr, + 0.0, + WICBitmapPaletteTypeCustom); + if (FAILED(result)) + { + error = "Could not convert font atlas PNG to RGBA."; + return false; + } + + UINT width = 0; + UINT height = 0; + converter->GetSize(&width, &height); + atlas.width = static_cast(width); + atlas.height = static_cast(height); + atlas.rgbaPixels.assign(static_cast(atlas.width) * atlas.height * 4u, 0); + + const UINT stride = width * 4u; + result = converter->CopyPixels(nullptr, stride, static_cast(atlas.rgbaPixels.size()), atlas.rgbaPixels.data()); + if (FAILED(result)) + { + error = "Could not copy font atlas PNG pixels."; + return false; + } + + error.clear(); + return true; +} + +bool RuntimeTextTextureCache::EnsureTextTexture(TextTexture& texture) +{ + const ShaderParameterValue* value = FindParameterValue(mArtifact, texture.parameterId); + const std::string text = value ? value->textValue : DefaultTextValue(mArtifact, texture.parameterId); + if (texture.texture != 0 && texture.cachedText == text) + return true; + + const Atlas* atlas = FindAtlas(texture.fontId); + if (!atlas) + return false; + + unsigned width = 0; + unsigned height = 0; + std::vector pixels = ComposeTextMask(*atlas, text, width, height); + if (pixels.empty() || width == 0 || height == 0) + return false; + + if (texture.texture == 0) + glGenTextures(1, &texture.texture); + + glBindTexture(GL_TEXTURE_2D, texture.texture); + glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR); + glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR); + glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, GL_CLAMP_TO_EDGE); + glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, GL_CLAMP_TO_EDGE); + glPixelStorei(GL_UNPACK_ALIGNMENT, 1); + glTexImage2D(GL_TEXTURE_2D, 0, GL_R8, static_cast(width), static_cast(height), 0, GL_RED, GL_UNSIGNED_BYTE, pixels.data()); + glBindTexture(GL_TEXTURE_2D, 0); + + texture.cachedText = text; + texture.width = width; + texture.height = height; + return texture.texture != 0; +} + +std::vector RuntimeTextTextureCache::ComposeTextMask(const Atlas& atlas, const std::string& text, unsigned& width, unsigned& height) const +{ + double advance = 0.0; + for (unsigned char character : text) + { + const auto glyphIt = atlas.glyphsByCodepoint.find(character); + if (glyphIt != atlas.glyphsByCodepoint.end()) + advance += glyphIt->second.advance; + } + + width = (std::max)(1u, static_cast(std::ceil(advance * kFontPixelsPerEm)) + kTextTexturePadding * 2u); + height = kTextTextureHeight; + std::vector mask(static_cast(width) * height, 0); + + const double baseline = kTextTexturePadding + (-atlas.ascender * kFontPixelsPerEm); + double penX = static_cast(kTextTexturePadding); + + for (unsigned char character : text) + { + const auto glyphIt = atlas.glyphsByCodepoint.find(character); + if (glyphIt == atlas.glyphsByCodepoint.end()) + continue; + + const Glyph& glyph = glyphIt->second; + if (glyph.hasBounds) + { + const int destLeft = static_cast(std::floor(penX + glyph.planeLeft * kFontPixelsPerEm)); + const int destTop = static_cast(std::floor(baseline + glyph.planeTop * kFontPixelsPerEm)); + const int destRight = static_cast(std::ceil(penX + glyph.planeRight * kFontPixelsPerEm)); + const int destBottom = static_cast(std::ceil(baseline + glyph.planeBottom * kFontPixelsPerEm)); + const double destWidth = (std::max)(1.0, static_cast(destRight - destLeft)); + const double destHeight = (std::max)(1.0, static_cast(destBottom - destTop)); + + for (int y = destTop; y < destBottom; ++y) + { + if (y < 0 || y >= static_cast(height)) + continue; + const double v = (static_cast(y) + 0.5 - destTop) / destHeight; + for (int x = destLeft; x < destRight; ++x) + { + if (x < 0 || x >= static_cast(width)) + continue; + const double u = (static_cast(x) + 0.5 - destLeft) / destWidth; + const double atlasX = glyph.atlasLeft + u * (glyph.atlasRight - glyph.atlasLeft); + const double atlasY = glyph.atlasTop + v * (glyph.atlasBottom - glyph.atlasTop); + unsigned char& destination = mask[static_cast(y) * width + static_cast(x)]; + destination = (std::max)(destination, SampleAtlasAlpha(atlas, atlasX, atlasY)); + } + } + } + + penX += glyph.advance * kFontPixelsPerEm; + } + + for (unsigned y = 0; y < height / 2u; ++y) + { + unsigned char* topRow = mask.data() + static_cast(y) * width; + unsigned char* bottomRow = mask.data() + static_cast(height - 1u - y) * width; + for (unsigned x = 0; x < width; ++x) + std::swap(topRow[x], bottomRow[x]); + } + + return mask; +} + +const RuntimeTextTextureCache::Atlas* RuntimeTextTextureCache::FindAtlas(const std::string& fontId) const +{ + for (const Atlas& atlas : mAtlases) + { + if (atlas.fontId == fontId) + return &atlas; + } + return nullptr; +} + +const ShaderParameterValue* RuntimeTextTextureCache::FindParameterValue(const RuntimeShaderArtifact& artifact, const std::string& parameterId) +{ + const auto valueIt = artifact.parameterValues.find(parameterId); + return valueIt == artifact.parameterValues.end() ? nullptr : &valueIt->second; +} + +std::string RuntimeTextTextureCache::DefaultTextValue(const RuntimeShaderArtifact& artifact, const std::string& parameterId) +{ + for (const ShaderParameterDefinition& definition : artifact.parameterDefinitions) + { + if (definition.id == parameterId) + return definition.defaultTextValue; + } + return std::string(); +} + +unsigned char RuntimeTextTextureCache::SampleAtlasAlpha(const Atlas& atlas, double x, double y) +{ + const int ix = (std::max)(0, (std::min)(static_cast(atlas.width) - 1, static_cast(std::floor(x)))); + const int iy = (std::max)(0, (std::min)(static_cast(atlas.height) - 1, static_cast(std::floor(y)))); + const std::size_t pixelOffset = (static_cast(iy) * atlas.width + static_cast(ix)) * 4u; + return atlas.rgbaPixels[pixelOffset + 3u]; +} + +void RuntimeTextTextureCache::DestroyTexture(TextTexture& texture) +{ + if (texture.texture != 0) + glDeleteTextures(1, &texture.texture); + texture.texture = 0; + texture.width = 0; + texture.height = 0; + texture.cachedText.clear(); +} diff --git a/src/render/runtime/RuntimeTextTextureCache.h b/src/render/runtime/RuntimeTextTextureCache.h new file mode 100644 index 0000000..4f047e8 --- /dev/null +++ b/src/render/runtime/RuntimeTextTextureCache.h @@ -0,0 +1,76 @@ +#pragma once + +#include "GLExtensions.h" +#include "../../runtime/RuntimeShaderArtifact.h" + +#include +#include +#include + +class RuntimeTextTextureCache +{ +public: + RuntimeTextTextureCache() = default; + RuntimeTextTextureCache(const RuntimeTextTextureCache&) = delete; + RuntimeTextTextureCache& operator=(const RuntimeTextTextureCache&) = delete; + ~RuntimeTextTextureCache(); + + bool Configure(const RuntimeShaderArtifact& artifact, std::string& error); + void UpdateArtifactState(const RuntimeShaderArtifact& artifact); + void BindTextTextures(GLuint program); + void ShutdownGl(); + + static void AssignSamplerUniforms(GLuint program, const RuntimeShaderArtifact& artifact); + +private: + struct Glyph + { + double advance = 0.0; + double planeLeft = 0.0; + double planeTop = 0.0; + double planeRight = 0.0; + double planeBottom = 0.0; + double atlasLeft = 0.0; + double atlasTop = 0.0; + double atlasRight = 0.0; + double atlasBottom = 0.0; + bool hasBounds = false; + }; + + struct Atlas + { + std::string fontId; + unsigned width = 0; + unsigned height = 0; + double ascender = -0.9; + double descender = 0.25; + double lineHeight = 1.2; + std::vector rgbaPixels; + std::map glyphsByCodepoint; + }; + + struct TextTexture + { + std::string parameterId; + std::string fontId; + std::string cachedText; + GLuint texture = 0; + unsigned width = 0; + unsigned height = 0; + }; + + bool LoadAtlas(const RenderCadenceCompositor::FontAtlasBuildOutput& output, Atlas& atlas, std::string& error) const; + bool LoadAtlasJson(const RenderCadenceCompositor::FontAtlasBuildOutput& output, Atlas& atlas, std::string& error) const; + bool LoadAtlasImage(const RenderCadenceCompositor::FontAtlasBuildOutput& output, Atlas& atlas, std::string& error) const; + bool EnsureTextTexture(TextTexture& texture); + std::vector ComposeTextMask(const Atlas& atlas, const std::string& text, unsigned& width, unsigned& height) const; + const Atlas* FindAtlas(const std::string& fontId) const; + static const ShaderParameterValue* FindParameterValue(const RuntimeShaderArtifact& artifact, const std::string& parameterId); + static std::string DefaultTextValue(const RuntimeShaderArtifact& artifact, const std::string& parameterId); + static unsigned char SampleAtlasAlpha(const Atlas& atlas, double x, double y); + static void DestroyTexture(TextTexture& texture); + + RuntimeShaderArtifact mArtifact; + std::vector mAtlases; + std::vector mTextTextures; +}; diff --git a/src/runtime/RuntimeShaderArtifact.h b/src/runtime/RuntimeShaderArtifact.h index 3c02a9d..81a8d10 100644 --- a/src/runtime/RuntimeShaderArtifact.h +++ b/src/runtime/RuntimeShaderArtifact.h @@ -1,6 +1,7 @@ #pragma once #include "ShaderTypes.h" +#include "FontAtlasBuilder.h" #include #include @@ -24,4 +25,5 @@ struct RuntimeShaderArtifact std::string message; std::vector parameterDefinitions; std::map parameterValues; + std::vector fontAtlases; }; diff --git a/src/runtime/RuntimeSlangShaderCompiler.cpp b/src/runtime/RuntimeSlangShaderCompiler.cpp index 9e61fc4..bfe932b 100644 --- a/src/runtime/RuntimeSlangShaderCompiler.cpp +++ b/src/runtime/RuntimeSlangShaderCompiler.cpp @@ -112,6 +112,17 @@ RuntimeSlangShaderBuild RuntimeSlangShaderCompiler::BuildShader(const std::strin return build; } + RenderCadenceCompositor::FontAtlasBuildConfig fontConfig; + fontConfig.repoRoot = repoRoot; + RenderCadenceCompositor::FontAtlasBuilder fontAtlasBuilder(fontConfig); + std::vector fontAtlasOutputs; + if (!fontAtlasBuilder.BuildPackageFontAtlases(shaderPackage, fontAtlasOutputs, error)) + { + build.succeeded = false; + build.message = error.empty() ? "Font atlas build failed." : error; + return build; + } + ShaderCompiler compiler( repoRoot, runtimeBuildDir / (shaderId + ".wrapper.slang"), @@ -144,6 +155,7 @@ RuntimeSlangShaderBuild RuntimeSlangShaderCompiler::BuildShader(const std::strin build.artifact.shaderId = shaderPackage.id; build.artifact.displayName = shaderPackage.displayName; build.artifact.parameterDefinitions = shaderPackage.parameters; + build.artifact.fontAtlases = std::move(fontAtlasOutputs); if (!build.artifact.passes.empty()) build.artifact.fragmentShaderSource = build.artifact.passes.front().fragmentShaderSource; build.artifact.message = shaderPackage.displayName + " Slang compile completed in " + std::to_string(milliseconds) + " ms."; diff --git a/src/runtime/SupportedShaderCatalog.cpp b/src/runtime/SupportedShaderCatalog.cpp index d295b6e..1bb974f 100644 --- a/src/runtime/SupportedShaderCatalog.cpp +++ b/src/runtime/SupportedShaderCatalog.cpp @@ -21,13 +21,25 @@ ShaderSupportResult CheckStatelessSinglePassShaderSupport(const ShaderPackage& s if (!shaderPackage.textureAssets.empty()) return { false, "RenderCadenceCompositor does not load shader texture assets yet; texture-backed shaders need a CPU-prepared asset handoff first." }; - if (!shaderPackage.fontAssets.empty()) - return { false, "RenderCadenceCompositor does not load shader font assets yet; text shaders need a CPU-prepared asset handoff first." }; - for (const ShaderParameterDefinition& parameter : shaderPackage.parameters) { - if (parameter.type == ShaderParameterType::Text) - return { false, "RenderCadenceCompositor currently skips text parameters because they require per-shader text texture storage." }; + if (parameter.type != ShaderParameterType::Text) + continue; + + if (parameter.fontId.empty()) + return { false, "Text parameter '" + parameter.id + "' must reference a declared font asset." }; + + bool hasFontAsset = false; + for (const ShaderFontAsset& fontAsset : shaderPackage.fontAssets) + { + if (fontAsset.id == parameter.fontId) + { + hasFontAsset = true; + break; + } + } + if (!hasFontAsset) + return { false, "Text parameter '" + parameter.id + "' references unknown font asset '" + parameter.fontId + "'." }; } bool writesLayerOutput = false; diff --git a/tests/RenderCadenceCompositorSupportedShaderCatalogTests.cpp b/tests/RenderCadenceCompositorSupportedShaderCatalogTests.cpp index 25735dc..c263902 100644 --- a/tests/RenderCadenceCompositorSupportedShaderCatalogTests.cpp +++ b/tests/RenderCadenceCompositorSupportedShaderCatalogTests.cpp @@ -112,19 +112,39 @@ void RejectsTextureAssets() Expect(result.reason.find("texture") != std::string::npos, "texture rejection should mention texture assets"); } -void RejectsTextParameters() +void RejectsTextParametersWithoutDeclaredFont() { ShaderPackage shaderPackage = MakeSinglePassPackage(); ShaderParameterDefinition parameter; parameter.id = "caption"; parameter.type = ShaderParameterType::Text; + parameter.fontId = "missing"; shaderPackage.parameters.push_back(parameter); const RenderCadenceCompositor::ShaderSupportResult result = RenderCadenceCompositor::CheckStatelessSinglePassShaderSupport(shaderPackage); - 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"); + Expect(!result.supported, "text parameters without declared fonts should be rejected"); + Expect(result.reason.find("unknown font") != std::string::npos, "text rejection should mention the missing font"); +} + +void SupportsTextParametersWithDeclaredFont() +{ + ShaderPackage shaderPackage = MakeSinglePassPackage(); + ShaderFontAsset fontAsset; + fontAsset.id = "roboto"; + shaderPackage.fontAssets.push_back(fontAsset); + ShaderParameterDefinition parameter; + parameter.id = "caption"; + parameter.type = ShaderParameterType::Text; + parameter.fontId = "roboto"; + shaderPackage.parameters.push_back(parameter); + + const RenderCadenceCompositor::ShaderSupportResult result = + RenderCadenceCompositor::CheckStatelessSinglePassShaderSupport(shaderPackage); + + Expect(result.supported, "text parameters with declared fonts should be supported"); + Expect(result.reason.empty(), "supported text parameters should not report a rejection reason"); } void BuildsDeclaredFontAtlasesDuringCatalogLoad() @@ -133,6 +153,14 @@ void BuildsDeclaredFontAtlasesDuringCatalogLoad() std::string error; Expect(catalog.Load(RepoRoot() / "shaders", 12, error), "shader catalog loads"); + bool textOverlaySupported = false; + for (const RenderCadenceCompositor::SupportedShaderSummary& shader : catalog.Shaders()) + { + if (shader.id == "text-overlay") + textOverlaySupported = true; + } + Expect(textOverlaySupported, "text overlay is listed as a supported shader after font atlas preparation"); + 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"); @@ -153,7 +181,8 @@ int main() RejectsUnknownPassInput(); RejectsTemporalPackage(); RejectsTextureAssets(); - RejectsTextParameters(); + RejectsTextParametersWithoutDeclaredFont(); + SupportsTextParametersWithDeclaredFont(); BuildsDeclaredFontAtlasesDuringCatalogLoad(); if (gFailures != 0)