diff --git a/CMakeLists.txt b/CMakeLists.txt index 82f971a..f80e37e 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -110,6 +110,10 @@ set(APP_SOURCES "${APP_DIR}/runtime/events/RuntimeEventPayloads.h" "${APP_DIR}/runtime/events/RuntimeEventQueue.h" "${APP_DIR}/runtime/events/RuntimeEventType.h" + "${APP_DIR}/runtime/live/RenderStateComposer.cpp" + "${APP_DIR}/runtime/live/RenderStateComposer.h" + "${APP_DIR}/runtime/live/RuntimeLiveState.cpp" + "${APP_DIR}/runtime/live/RuntimeLiveState.h" "${APP_DIR}/runtime/presentation/RuntimeStateJson.cpp" "${APP_DIR}/runtime/presentation/RuntimeStateJson.h" "${APP_DIR}/runtime/presentation/RuntimeStatePresenter.cpp" @@ -165,6 +169,7 @@ target_include_directories(LoopThroughWithOpenGLCompositing PRIVATE "${APP_DIR}/runtime" "${APP_DIR}/runtime/coordination" "${APP_DIR}/runtime/events" + "${APP_DIR}/runtime/live" "${APP_DIR}/runtime/presentation" "${APP_DIR}/runtime/snapshot" "${APP_DIR}/runtime/store" @@ -282,6 +287,28 @@ endif() add_test(NAME RuntimeEventTypeTests COMMAND RuntimeEventTypeTests) +add_executable(RuntimeLiveStateTests + "${APP_DIR}/runtime/live/RenderStateComposer.cpp" + "${APP_DIR}/runtime/live/RuntimeLiveState.cpp" + "${APP_DIR}/runtime/support/RuntimeJson.cpp" + "${APP_DIR}/runtime/support/RuntimeParameterUtils.cpp" + "${CMAKE_CURRENT_SOURCE_DIR}/tests/RuntimeLiveStateTests.cpp" +) + +target_include_directories(RuntimeLiveStateTests PRIVATE + "${APP_DIR}" + "${APP_DIR}/runtime" + "${APP_DIR}/runtime/live" + "${APP_DIR}/runtime/support" + "${APP_DIR}/shader" +) + +if(MSVC) + target_compile_options(RuntimeLiveStateTests PRIVATE /W3) +endif() + +add_test(NAME RuntimeLiveStateTests COMMAND RuntimeLiveStateTests) + add_executable(RuntimeSubsystemTests "${APP_DIR}/runtime/store/LayerStackStore.cpp" "${APP_DIR}/runtime/store/ShaderPackageCatalog.cpp" diff --git a/apps/LoopThroughWithOpenGLCompositing/gl/RenderEngine.cpp b/apps/LoopThroughWithOpenGLCompositing/gl/RenderEngine.cpp index a2810c8..49a1039 100644 --- a/apps/LoopThroughWithOpenGLCompositing/gl/RenderEngine.cpp +++ b/apps/LoopThroughWithOpenGLCompositing/gl/RenderEngine.cpp @@ -1,83 +1,14 @@ #include "RenderEngine.h" -#include "RuntimeParameterUtils.h" #include "ShaderBuildQueue.h" #include #include -#include -#include -#include namespace { constexpr auto kOscOverlayCommitDelay = std::chrono::milliseconds(150); -constexpr double kOscSmoothingReferenceFps = 60.0; -constexpr double kOscSmoothingMaxStepSeconds = 0.25; - -std::string SimplifyOscControlKey(const std::string& text) -{ - std::string simplified; - for (unsigned char ch : text) - { - if (std::isalnum(ch)) - simplified.push_back(static_cast(std::tolower(ch))); - } - return simplified; -} - -bool MatchesOscControlKey(const std::string& candidate, const std::string& key) -{ - return candidate == key || SimplifyOscControlKey(candidate) == SimplifyOscControlKey(key); -} - -double ClampOscAlpha(double value) -{ - return (std::max)(0.0, (std::min)(1.0, value)); -} - -double ComputeTimeBasedOscAlpha(double smoothing, double deltaSeconds) -{ - const double clampedSmoothing = ClampOscAlpha(smoothing); - if (clampedSmoothing <= 0.0) - return 0.0; - if (clampedSmoothing >= 1.0) - return 1.0; - - const double clampedDeltaSeconds = (std::max)(0.0, (std::min)(kOscSmoothingMaxStepSeconds, deltaSeconds)); - if (clampedDeltaSeconds <= 0.0) - return 0.0; - - const double frameScale = clampedDeltaSeconds * kOscSmoothingReferenceFps; - return ClampOscAlpha(1.0 - std::pow(1.0 - clampedSmoothing, frameScale)); -} - -JsonValue BuildOscCommitValue(const ShaderParameterDefinition& definition, const ShaderParameterValue& value) -{ - switch (definition.type) - { - case ShaderParameterType::Boolean: - return JsonValue(value.booleanValue); - case ShaderParameterType::Enum: - return JsonValue(value.enumValue); - case ShaderParameterType::Text: - return JsonValue(value.textValue); - case ShaderParameterType::Trigger: - case ShaderParameterType::Float: - return JsonValue(value.numberValues.empty() ? 0.0 : value.numberValues.front()); - case ShaderParameterType::Vec2: - case ShaderParameterType::Color: - { - JsonValue array = JsonValue::MakeArray(); - for (double number : value.numberValues) - array.pushBack(JsonValue(number)); - return array; - } - } - - return JsonValue(); -} } RenderEngine::RenderEngine( @@ -229,51 +160,24 @@ void RenderEngine::ApplyRuntimeCoordinatorRenderReset(RuntimeCoordinatorRenderRe void RenderEngine::ClearOscOverlayState() { - mOscOverlayStates.clear(); + mRuntimeLiveState.Clear(); } void RenderEngine::UpdateOscOverlayState( const std::vector& updates, const std::vector& completedCommits) { + std::vector liveCompletions; + liveCompletions.reserve(completedCommits.size()); for (const OscOverlayCommitCompletion& completedCommit : completedCommits) - { - auto overlayIt = mOscOverlayStates.find(completedCommit.routeKey); - if (overlayIt == mOscOverlayStates.end()) - continue; + liveCompletions.push_back({ completedCommit.routeKey, completedCommit.generation }); + mRuntimeLiveState.ApplyOscCommitCompletions(liveCompletions); - OscOverlayState& overlay = overlayIt->second; - if (overlay.commitQueued && - overlay.pendingCommitGeneration == completedCommit.generation && - overlay.generation == completedCommit.generation) - { - mOscOverlayStates.erase(overlayIt); - } - } - - const auto now = std::chrono::steady_clock::now(); + std::vector liveUpdates; + liveUpdates.reserve(updates.size()); for (const OscOverlayUpdate& update : updates) - { - auto overlayIt = mOscOverlayStates.find(update.routeKey); - if (overlayIt == mOscOverlayStates.end()) - { - OscOverlayState overlay; - overlay.layerKey = update.layerKey; - overlay.parameterKey = update.parameterKey; - overlay.targetValue = update.targetValue; - overlay.lastUpdatedTime = now; - overlay.lastAppliedTime = now; - overlay.generation = 1; - mOscOverlayStates[update.routeKey] = std::move(overlay); - } - else - { - overlayIt->second.targetValue = update.targetValue; - overlayIt->second.lastUpdatedTime = now; - overlayIt->second.generation += 1; - overlayIt->second.commitQueued = false; - } - } + liveUpdates.push_back({ update.routeKey, update.layerKey, update.parameterKey, update.targetValue }); + mRuntimeLiveState.ApplyOscUpdates(liveUpdates); } void RenderEngine::ResizeView(int width, int height) @@ -416,129 +320,19 @@ void RenderEngine::ApplyOscOverlays( double smoothing, std::vector* commitRequests) { - if (states.empty() || mOscOverlayStates.empty()) + std::vector liveCommitRequests; + RuntimeLiveStateApplyOptions options; + options.allowCommit = allowCommit; + options.smoothing = smoothing; + options.commitDelay = kOscOverlayCommitDelay; + options.now = std::chrono::steady_clock::now(); + mRuntimeLiveState.ApplyToLayerStates(states, options, commitRequests ? &liveCommitRequests : nullptr); + + if (!commitRequests) return; - const auto now = std::chrono::steady_clock::now(); - const double clampedSmoothing = ClampOscAlpha(smoothing); - std::vector overlayKeysToRemove; - - for (auto& item : mOscOverlayStates) - { - const std::string& routeKey = item.first; - OscOverlayState& overlay = item.second; - auto stateIt = std::find_if(states.begin(), states.end(), - [&overlay](const RuntimeRenderState& state) - { - return MatchesOscControlKey(state.layerId, overlay.layerKey) || - MatchesOscControlKey(state.shaderId, overlay.layerKey) || - MatchesOscControlKey(state.shaderName, overlay.layerKey); - }); - if (stateIt == states.end()) - continue; - - auto definitionIt = std::find_if(stateIt->parameterDefinitions.begin(), stateIt->parameterDefinitions.end(), - [&overlay](const ShaderParameterDefinition& definition) - { - return MatchesOscControlKey(definition.id, overlay.parameterKey) || - MatchesOscControlKey(definition.label, overlay.parameterKey); - }); - if (definitionIt == stateIt->parameterDefinitions.end()) - continue; - - ShaderParameterValue targetValue; - std::string normalizeError; - if (!NormalizeAndValidateParameterValue(*definitionIt, overlay.targetValue, targetValue, normalizeError)) - continue; - - if (definitionIt->type == ShaderParameterType::Trigger) - { - ShaderParameterValue& value = stateIt->parameterValues[definitionIt->id]; - const double previousCount = value.numberValues.empty() ? 0.0 : value.numberValues[0]; - const double triggerTime = stateIt->timeSeconds; - value.numberValues = { previousCount + 1.0, triggerTime }; - overlayKeysToRemove.push_back(routeKey); - continue; - } - - const bool smoothable = - clampedSmoothing > 0.0 && - (definitionIt->type == ShaderParameterType::Float || - definitionIt->type == ShaderParameterType::Vec2 || - definitionIt->type == ShaderParameterType::Color); - if (!smoothable) - { - overlay.currentValue = targetValue; - overlay.hasCurrentValue = true; - stateIt->parameterValues[definitionIt->id] = overlay.currentValue; - if (allowCommit && - !overlay.commitQueued && - now - overlay.lastUpdatedTime >= kOscOverlayCommitDelay && - commitRequests) - { - commitRequests->push_back({ routeKey, overlay.layerKey, overlay.parameterKey, overlay.targetValue, overlay.generation }); - overlay.pendingCommitGeneration = overlay.generation; - overlay.commitQueued = true; - } - continue; - } - - if (!overlay.hasCurrentValue) - { - overlay.currentValue = DefaultValueForDefinition(*definitionIt); - auto currentIt = stateIt->parameterValues.find(definitionIt->id); - if (currentIt != stateIt->parameterValues.end()) - overlay.currentValue = currentIt->second; - overlay.hasCurrentValue = true; - } - - if (overlay.currentValue.numberValues.size() != targetValue.numberValues.size()) - overlay.currentValue.numberValues = targetValue.numberValues; - - double smoothingAlpha = clampedSmoothing; - if (overlay.lastAppliedTime != std::chrono::steady_clock::time_point()) - { - const double deltaSeconds = - std::chrono::duration_cast>(now - overlay.lastAppliedTime).count(); - smoothingAlpha = ComputeTimeBasedOscAlpha(clampedSmoothing, deltaSeconds); - } - overlay.lastAppliedTime = now; - - ShaderParameterValue nextValue = targetValue; - bool converged = true; - for (std::size_t index = 0; index < targetValue.numberValues.size(); ++index) - { - const double currentNumber = overlay.currentValue.numberValues[index]; - const double targetNumber = targetValue.numberValues[index]; - const double delta = targetNumber - currentNumber; - double nextNumber = currentNumber + delta * smoothingAlpha; - if (std::fabs(delta) <= 0.0005) - nextNumber = targetNumber; - else - converged = false; - nextValue.numberValues[index] = nextNumber; - } - - if (converged) - nextValue.numberValues = targetValue.numberValues; - - overlay.currentValue = nextValue; - overlay.hasCurrentValue = true; - stateIt->parameterValues[definitionIt->id] = overlay.currentValue; - if (allowCommit && - converged && - !overlay.commitQueued && - now - overlay.lastUpdatedTime >= kOscOverlayCommitDelay && - commitRequests) - { - commitRequests->push_back({ routeKey, overlay.layerKey, overlay.parameterKey, BuildOscCommitValue(*definitionIt, overlay.currentValue), overlay.generation }); - overlay.pendingCommitGeneration = overlay.generation; - overlay.commitQueued = true; - } - } - - for (const std::string& overlayKey : overlayKeysToRemove) - mOscOverlayStates.erase(overlayKey); + for (const RuntimeLiveOscCommitRequest& request : liveCommitRequests) + commitRequests->push_back({ request.routeKey, request.layerKey, request.parameterKey, request.value, request.generation }); } void RenderEngine::RenderLayerStack( diff --git a/apps/LoopThroughWithOpenGLCompositing/gl/RenderEngine.h b/apps/LoopThroughWithOpenGLCompositing/gl/RenderEngine.h index ac0865b..3c99ca5 100644 --- a/apps/LoopThroughWithOpenGLCompositing/gl/RenderEngine.h +++ b/apps/LoopThroughWithOpenGLCompositing/gl/RenderEngine.h @@ -6,6 +6,7 @@ #include "OpenGLShaderPrograms.h" #include "HealthTelemetry.h" #include "RuntimeCoordinator.h" +#include "RuntimeLiveState.h" #include "RuntimeSnapshotProvider.h" #include @@ -132,20 +133,6 @@ private: HDC mHdc; HGLRC mHglrc; - struct OscOverlayState - { - std::string layerKey; - std::string parameterKey; - JsonValue targetValue; - ShaderParameterValue currentValue; - bool hasCurrentValue = false; - std::chrono::steady_clock::time_point lastUpdatedTime; - std::chrono::steady_clock::time_point lastAppliedTime; - uint64_t generation = 0; - uint64_t pendingCommitGeneration = 0; - bool commitQueued = false; - }; - void ApplyOscOverlays( std::vector& states, bool allowCommit, @@ -158,5 +145,5 @@ private: unsigned mCachedRenderStateWidth = 0; unsigned mCachedRenderStateHeight = 0; std::chrono::steady_clock::time_point mLastPreviewPresentTime; - std::map mOscOverlayStates; + RuntimeLiveState mRuntimeLiveState; }; diff --git a/apps/LoopThroughWithOpenGLCompositing/runtime/live/RenderStateComposer.cpp b/apps/LoopThroughWithOpenGLCompositing/runtime/live/RenderStateComposer.cpp new file mode 100644 index 0000000..29b40f3 --- /dev/null +++ b/apps/LoopThroughWithOpenGLCompositing/runtime/live/RenderStateComposer.cpp @@ -0,0 +1,10 @@ +#include "RenderStateComposer.h" + +RenderStateCompositionResult RenderStateComposer::BuildFrameState(const RenderStateCompositionInput& input) const +{ + RenderStateCompositionResult result; + result.layerStates = input.baseLayerStates; + if (input.liveState) + input.liveState->ApplyToLayerStates(result.layerStates, input.liveStateOptions, &result.commitRequests); + return result; +} diff --git a/apps/LoopThroughWithOpenGLCompositing/runtime/live/RenderStateComposer.h b/apps/LoopThroughWithOpenGLCompositing/runtime/live/RenderStateComposer.h new file mode 100644 index 0000000..f0b506a --- /dev/null +++ b/apps/LoopThroughWithOpenGLCompositing/runtime/live/RenderStateComposer.h @@ -0,0 +1,24 @@ +#pragma once + +#include "RuntimeLiveState.h" + +#include + +struct RenderStateCompositionInput +{ + std::vector baseLayerStates; + RuntimeLiveState* liveState = nullptr; + RuntimeLiveStateApplyOptions liveStateOptions; +}; + +struct RenderStateCompositionResult +{ + std::vector layerStates; + std::vector commitRequests; +}; + +class RenderStateComposer +{ +public: + RenderStateCompositionResult BuildFrameState(const RenderStateCompositionInput& input) const; +}; diff --git a/apps/LoopThroughWithOpenGLCompositing/runtime/live/RuntimeLiveState.cpp b/apps/LoopThroughWithOpenGLCompositing/runtime/live/RuntimeLiveState.cpp new file mode 100644 index 0000000..f2124fb --- /dev/null +++ b/apps/LoopThroughWithOpenGLCompositing/runtime/live/RuntimeLiveState.cpp @@ -0,0 +1,262 @@ +#include "RuntimeLiveState.h" + +#include "RuntimeParameterUtils.h" + +#include +#include +#include +#include + +namespace +{ +constexpr double kOscSmoothingReferenceFps = 60.0; +constexpr double kOscSmoothingMaxStepSeconds = 0.25; + +std::string SimplifyOscControlKey(const std::string& text) +{ + std::string simplified; + for (unsigned char ch : text) + { + if (std::isalnum(ch)) + simplified.push_back(static_cast(std::tolower(ch))); + } + return simplified; +} + +bool MatchesOscControlKey(const std::string& candidate, const std::string& key) +{ + return candidate == key || SimplifyOscControlKey(candidate) == SimplifyOscControlKey(key); +} + +double ClampOscAlpha(double value) +{ + return (std::max)(0.0, (std::min)(1.0, value)); +} + +double ComputeTimeBasedOscAlpha(double smoothing, double deltaSeconds) +{ + const double clampedSmoothing = ClampOscAlpha(smoothing); + if (clampedSmoothing <= 0.0) + return 0.0; + if (clampedSmoothing >= 1.0) + return 1.0; + + const double clampedDeltaSeconds = (std::max)(0.0, (std::min)(kOscSmoothingMaxStepSeconds, deltaSeconds)); + if (clampedDeltaSeconds <= 0.0) + return 0.0; + + const double frameScale = clampedDeltaSeconds * kOscSmoothingReferenceFps; + return ClampOscAlpha(1.0 - std::pow(1.0 - clampedSmoothing, frameScale)); +} + +JsonValue BuildOscCommitValue(const ShaderParameterDefinition& definition, const ShaderParameterValue& value) +{ + switch (definition.type) + { + case ShaderParameterType::Boolean: + return JsonValue(value.booleanValue); + case ShaderParameterType::Enum: + return JsonValue(value.enumValue); + case ShaderParameterType::Text: + return JsonValue(value.textValue); + case ShaderParameterType::Trigger: + case ShaderParameterType::Float: + return JsonValue(value.numberValues.empty() ? 0.0 : value.numberValues.front()); + case ShaderParameterType::Vec2: + case ShaderParameterType::Color: + { + JsonValue array = JsonValue::MakeArray(); + for (double number : value.numberValues) + array.pushBack(JsonValue(number)); + return array; + } + } + + return JsonValue(); +} +} + +void RuntimeLiveState::Clear() +{ + mOscOverlayStates.clear(); +} + +std::size_t RuntimeLiveState::OverlayCount() const +{ + return mOscOverlayStates.size(); +} + +void RuntimeLiveState::ApplyOscUpdates(const std::vector& updates) +{ + const auto now = std::chrono::steady_clock::now(); + for (const RuntimeLiveOscUpdate& update : updates) + { + auto overlayIt = mOscOverlayStates.find(update.routeKey); + if (overlayIt == mOscOverlayStates.end()) + { + OscOverlayState overlay; + overlay.layerKey = update.layerKey; + overlay.parameterKey = update.parameterKey; + overlay.targetValue = update.targetValue; + overlay.lastUpdatedTime = now; + overlay.lastAppliedTime = now; + overlay.generation = 1; + mOscOverlayStates[update.routeKey] = std::move(overlay); + } + else + { + overlayIt->second.targetValue = update.targetValue; + overlayIt->second.lastUpdatedTime = now; + overlayIt->second.generation += 1; + overlayIt->second.commitQueued = false; + } + } +} + +void RuntimeLiveState::ApplyOscCommitCompletions(const std::vector& completedCommits) +{ + for (const RuntimeLiveOscCommitCompletion& completedCommit : completedCommits) + { + auto overlayIt = mOscOverlayStates.find(completedCommit.routeKey); + if (overlayIt == mOscOverlayStates.end()) + continue; + + OscOverlayState& overlay = overlayIt->second; + if (overlay.commitQueued && + overlay.pendingCommitGeneration == completedCommit.generation && + overlay.generation == completedCommit.generation) + { + mOscOverlayStates.erase(overlayIt); + } + } +} + +void RuntimeLiveState::ApplyToLayerStates( + std::vector& states, + const RuntimeLiveStateApplyOptions& options, + std::vector* commitRequests) +{ + if (states.empty() || mOscOverlayStates.empty()) + return; + + const auto now = options.now; + const double clampedSmoothing = ClampOscAlpha(options.smoothing); + std::vector overlayKeysToRemove; + + for (auto& item : mOscOverlayStates) + { + const std::string& routeKey = item.first; + OscOverlayState& overlay = item.second; + auto stateIt = std::find_if(states.begin(), states.end(), + [&overlay](const RuntimeRenderState& state) + { + return MatchesOscControlKey(state.layerId, overlay.layerKey) || + MatchesOscControlKey(state.shaderId, overlay.layerKey) || + MatchesOscControlKey(state.shaderName, overlay.layerKey); + }); + if (stateIt == states.end()) + continue; + + auto definitionIt = std::find_if(stateIt->parameterDefinitions.begin(), stateIt->parameterDefinitions.end(), + [&overlay](const ShaderParameterDefinition& definition) + { + return MatchesOscControlKey(definition.id, overlay.parameterKey) || + MatchesOscControlKey(definition.label, overlay.parameterKey); + }); + if (definitionIt == stateIt->parameterDefinitions.end()) + continue; + + ShaderParameterValue targetValue; + std::string normalizeError; + if (!NormalizeAndValidateParameterValue(*definitionIt, overlay.targetValue, targetValue, normalizeError)) + continue; + + if (definitionIt->type == ShaderParameterType::Trigger) + { + ShaderParameterValue& value = stateIt->parameterValues[definitionIt->id]; + const double previousCount = value.numberValues.empty() ? 0.0 : value.numberValues[0]; + const double triggerTime = stateIt->timeSeconds; + value.numberValues = { previousCount + 1.0, triggerTime }; + overlayKeysToRemove.push_back(routeKey); + continue; + } + + const bool smoothable = + clampedSmoothing > 0.0 && + (definitionIt->type == ShaderParameterType::Float || + definitionIt->type == ShaderParameterType::Vec2 || + definitionIt->type == ShaderParameterType::Color); + if (!smoothable) + { + overlay.currentValue = targetValue; + overlay.hasCurrentValue = true; + stateIt->parameterValues[definitionIt->id] = overlay.currentValue; + if (options.allowCommit && + !overlay.commitQueued && + now - overlay.lastUpdatedTime >= options.commitDelay && + commitRequests) + { + commitRequests->push_back({ routeKey, overlay.layerKey, overlay.parameterKey, overlay.targetValue, overlay.generation }); + overlay.pendingCommitGeneration = overlay.generation; + overlay.commitQueued = true; + } + continue; + } + + if (!overlay.hasCurrentValue) + { + overlay.currentValue = DefaultValueForDefinition(*definitionIt); + auto currentIt = stateIt->parameterValues.find(definitionIt->id); + if (currentIt != stateIt->parameterValues.end()) + overlay.currentValue = currentIt->second; + overlay.hasCurrentValue = true; + } + + if (overlay.currentValue.numberValues.size() != targetValue.numberValues.size()) + overlay.currentValue.numberValues = targetValue.numberValues; + + double smoothingAlpha = clampedSmoothing; + if (overlay.lastAppliedTime != std::chrono::steady_clock::time_point()) + { + const double deltaSeconds = + std::chrono::duration_cast>(now - overlay.lastAppliedTime).count(); + smoothingAlpha = ComputeTimeBasedOscAlpha(clampedSmoothing, deltaSeconds); + } + overlay.lastAppliedTime = now; + + ShaderParameterValue nextValue = targetValue; + bool converged = true; + for (std::size_t index = 0; index < targetValue.numberValues.size(); ++index) + { + const double currentNumber = overlay.currentValue.numberValues[index]; + const double targetNumber = targetValue.numberValues[index]; + const double delta = targetNumber - currentNumber; + double nextNumber = currentNumber + delta * smoothingAlpha; + if (std::fabs(delta) <= 0.0005) + nextNumber = targetNumber; + else + converged = false; + nextValue.numberValues[index] = nextNumber; + } + + if (converged) + nextValue.numberValues = targetValue.numberValues; + + overlay.currentValue = nextValue; + overlay.hasCurrentValue = true; + stateIt->parameterValues[definitionIt->id] = overlay.currentValue; + if (options.allowCommit && + converged && + !overlay.commitQueued && + now - overlay.lastUpdatedTime >= options.commitDelay && + commitRequests) + { + commitRequests->push_back({ routeKey, overlay.layerKey, overlay.parameterKey, BuildOscCommitValue(*definitionIt, overlay.currentValue), overlay.generation }); + overlay.pendingCommitGeneration = overlay.generation; + overlay.commitQueued = true; + } + } + + for (const std::string& overlayKey : overlayKeysToRemove) + mOscOverlayStates.erase(overlayKey); +} diff --git a/apps/LoopThroughWithOpenGLCompositing/runtime/live/RuntimeLiveState.h b/apps/LoopThroughWithOpenGLCompositing/runtime/live/RuntimeLiveState.h new file mode 100644 index 0000000..557019f --- /dev/null +++ b/apps/LoopThroughWithOpenGLCompositing/runtime/live/RuntimeLiveState.h @@ -0,0 +1,71 @@ +#pragma once + +#include "RuntimeJson.h" +#include "ShaderTypes.h" + +#include +#include +#include +#include +#include + +struct RuntimeLiveOscUpdate +{ + std::string routeKey; + std::string layerKey; + std::string parameterKey; + JsonValue targetValue; +}; + +struct RuntimeLiveOscCommitCompletion +{ + std::string routeKey; + uint64_t generation = 0; +}; + +struct RuntimeLiveOscCommitRequest +{ + std::string routeKey; + std::string layerKey; + std::string parameterKey; + JsonValue value; + uint64_t generation = 0; +}; + +struct RuntimeLiveStateApplyOptions +{ + bool allowCommit = false; + double smoothing = 0.0; + std::chrono::milliseconds commitDelay = std::chrono::milliseconds(150); + std::chrono::steady_clock::time_point now = std::chrono::steady_clock::now(); +}; + +class RuntimeLiveState +{ +public: + void Clear(); + std::size_t OverlayCount() const; + void ApplyOscUpdates(const std::vector& updates); + void ApplyOscCommitCompletions(const std::vector& completedCommits); + void ApplyToLayerStates( + std::vector& states, + const RuntimeLiveStateApplyOptions& options, + std::vector* commitRequests); + +private: + struct OscOverlayState + { + std::string layerKey; + std::string parameterKey; + JsonValue targetValue; + ShaderParameterValue currentValue; + bool hasCurrentValue = false; + std::chrono::steady_clock::time_point lastUpdatedTime; + std::chrono::steady_clock::time_point lastAppliedTime; + uint64_t generation = 0; + uint64_t pendingCommitGeneration = 0; + bool commitQueued = false; + }; + + std::map mOscOverlayStates; +}; diff --git a/docs/PHASE_3_LIVE_STATE_SERVICE_COORDINATION_DESIGN.md b/docs/PHASE_3_LIVE_STATE_SERVICE_COORDINATION_DESIGN.md index a371d7f..5e8a3b5 100644 --- a/docs/PHASE_3_LIVE_STATE_SERVICE_COORDINATION_DESIGN.md +++ b/docs/PHASE_3_LIVE_STATE_SERVICE_COORDINATION_DESIGN.md @@ -7,7 +7,7 @@ Phase 1 split runtime responsibilities into named subsystems. Phase 2 added the ## Status - Phase 3 design package: proposed. -- Phase 3 implementation: not started. +- Phase 3 implementation: groundwork started. - Current alignment: the repo has the right building blocks, but `OpenGLComposite::renderEffect()` still manually reconciles transient OSC overlays, completed OSC commits, committed/live snapshot selection, and render-state resolution on the render path. Current footholds: @@ -15,7 +15,9 @@ Current footholds: - `RuntimeStore` is split into durable state collaborators: `RuntimeConfigStore`, `LayerStackStore`, `ShaderPackageCatalog`, `RenderSnapshotBuilder`, presentation read models, and `HealthTelemetry`. - `RuntimeCoordinator` owns mutation validation/classification and publishes accepted/rejected/follow-up events. - `RuntimeSnapshotProvider` publishes render snapshots from `RenderSnapshotBuilder`. -- `RenderEngine` owns render-local OSC overlay state and final render-layer resolution. +- `RuntimeLiveState` owns transient OSC overlay bookkeeping and commit-settlement policy. +- `RenderStateComposer` exists as the first pure composition boundary for combining base layer state with live overlays. +- `RenderEngine` still owns final render-layer resolution, but its OSC overlay bookkeeping now delegates to `RuntimeLiveState`. - `ControlServices` owns OSC ingress, pending OSC updates, completed OSC commit notifications, and service start/stop. - `RuntimeEventDispatcher` now routes accepted mutations, reloads, snapshots, shader build events, backend observations, and health observations. @@ -250,6 +252,8 @@ Introduce `RuntimeLiveState`, `RenderStateComposer`, or an equivalent pair of cl Start by moving pure data operations out of `RenderEngine::ResolveRenderLayerStates(...)` without changing behavior. +Status: started. `runtime/live/RuntimeLiveState` and `runtime/live/RenderStateComposer` now exist, are included in the build, and have a focused `RuntimeLiveStateTests` target. + ### Step 2. Move OSC Overlay Bookkeeping Behind The Boundary Move these responsibilities out of the current frame orchestration: @@ -261,6 +265,8 @@ Move these responsibilities out of the current frame orchestration: The first implementation can still be called synchronously from the current render path. The important part is that the behavior has a named owner and tests. +Status: started. `RenderEngine` still exposes the compatibility methods used by `OpenGLComposite`, but it now delegates overlay updates, commit completions, smoothing, generation matching, and commit-request creation to `RuntimeLiveState`. + ### Step 3. Bridge Service Queues To Events Or Live-State Commands Replace `OpenGLComposite::renderEffect()` queue draining with a bridge that publishes or applies: @@ -324,14 +330,26 @@ Existing useful homes: - `RuntimeSubsystemTests` for pure state/composer behavior - `RuntimeEventTypeTests` for event bridge behavior -- a new `RuntimeLiveStateTests.cpp` target if the live-state code grows enough +- `RuntimeLiveStateTests` for the new live-state/composer boundary + +## Parallel Work Lanes + +The current groundwork is intended to let these lanes proceed in parallel with low overlap: + +| Lane | Primary files | Goal | +| --- | --- | --- | +| A. Live-state behavior | `runtime/live/RuntimeLiveState.*`, `tests/RuntimeLiveStateTests.cpp` | Finish stale completion tests, smoothing edge cases, trigger behavior, and overlay settle policy. | +| B. Render-state composition | `runtime/live/RenderStateComposer.*`, `gl/RenderEngine.*` | Move more of `RenderEngine::ResolveRenderLayerStates(...)` value composition behind the pure composer while keeping GL calls in `RenderEngine`. | +| C. Service bridge | `control/RuntimeServices.*`, `control/ControlServices.*`, possible new bridge class | Stop `OpenGLComposite::renderEffect()` from draining OSC update/completion queues directly. | +| D. App-frame orchestration | `gl/OpenGLComposite.*`, `gl/RuntimeUpdateController.*` | Replace render-effect glue with a narrow frame-state preparation call and commit-request handoff. | +| E. Persistence boundary | `runtime/coordination/RuntimeCoordinator.*`, `runtime/store/*`, event tests | Keep persistence request publication explicit and prepare for a later background writer without changing storage behavior yet. | ## Phase 3 Exit Criteria Phase 3 can be considered complete once the project can say: -- [ ] final render-state composition has a named, testable owner outside `OpenGLComposite` -- [ ] transient OSC overlay state has a named owner and tests +- [ ] final render-state composition has a named, testable owner outside `OpenGLComposite` (groundwork exists via `RenderStateComposer`; full snapshot/cache resolution still needs to move behind it) +- [x] transient OSC overlay state has a named owner and tests - [ ] overlay commit requests and completions no longer require `OpenGLComposite` to drain service queues directly - [ ] `RenderEngine` is closer to GL/render resource ownership and less responsible for value composition - [ ] `RuntimeStore` remains durable-state focused and does not gain live overlay responsibilities diff --git a/tests/RuntimeLiveStateTests.cpp b/tests/RuntimeLiveStateTests.cpp new file mode 100644 index 0000000..882d81c --- /dev/null +++ b/tests/RuntimeLiveStateTests.cpp @@ -0,0 +1,143 @@ +#include "RenderStateComposer.h" +#include "RuntimeLiveState.h" + +#include +#include +#include + +namespace +{ +int gFailures = 0; + +void Expect(bool condition, const char* message) +{ + if (condition) + return; + + std::cerr << "FAIL: " << message << "\n"; + ++gFailures; +} + +ShaderParameterDefinition FloatDefinition(const std::string& id, const std::string& label) +{ + ShaderParameterDefinition definition; + definition.id = id; + definition.label = label; + definition.type = ShaderParameterType::Float; + definition.defaultNumbers = { 0.0 }; + definition.minNumbers = { 0.0 }; + definition.maxNumbers = { 1.0 }; + return definition; +} + +RuntimeRenderState MakeLayerState() +{ + RuntimeRenderState state; + state.layerId = "layer-one"; + state.shaderId = "test-shader"; + state.shaderName = "Test Shader"; + state.parameterDefinitions.push_back(FloatDefinition("amount", "Amount")); + ShaderParameterValue amount; + amount.numberValues = { 0.25 }; + state.parameterValues["amount"] = amount; + return state; +} + +void TestRuntimeLiveStateAppliesLatestOscOverlay() +{ + RuntimeLiveState liveState; + + RuntimeLiveOscUpdate first; + first.routeKey = "layer-one\namount"; + first.layerKey = "layer-one"; + first.parameterKey = "amount"; + first.targetValue = JsonValue(0.5); + + RuntimeLiveOscUpdate second = first; + second.targetValue = JsonValue(0.75); + + liveState.ApplyOscUpdates({ first, second }); + Expect(liveState.OverlayCount() == 1, "live state keeps one overlay per route"); + + std::vector states = { MakeLayerState() }; + RuntimeLiveStateApplyOptions options; + options.allowCommit = false; + options.smoothing = 0.0; + liveState.ApplyToLayerStates(states, options, nullptr); + + const auto valueIt = states[0].parameterValues.find("amount"); + Expect(valueIt != states[0].parameterValues.end(), "overlay writes the target parameter"); + Expect(!valueIt->second.numberValues.empty() && std::fabs(valueIt->second.numberValues[0] - 0.75) < 0.0001, + "overlay applies the latest target value"); +} + +void TestRuntimeLiveStateQueuesAndCompletesCommit() +{ + RuntimeLiveState liveState; + + RuntimeLiveOscUpdate update; + update.routeKey = "layer-one\namount"; + update.layerKey = "layer-one"; + update.parameterKey = "amount"; + update.targetValue = JsonValue(0.9); + liveState.ApplyOscUpdates({ update }); + + std::vector states = { MakeLayerState() }; + std::vector commitRequests; + RuntimeLiveStateApplyOptions options; + options.allowCommit = true; + options.smoothing = 0.0; + options.commitDelay = std::chrono::milliseconds(0); + options.now = std::chrono::steady_clock::now() + std::chrono::milliseconds(1); + liveState.ApplyToLayerStates(states, options, &commitRequests); + + Expect(commitRequests.size() == 1, "live state queues a commit request once the overlay can settle"); + Expect(commitRequests[0].routeKey == "layer-one\namount", "commit request preserves route"); + Expect(commitRequests[0].generation == 1, "commit request carries overlay generation"); + + liveState.ApplyOscCommitCompletions({ { commitRequests[0].routeKey, commitRequests[0].generation } }); + Expect(liveState.OverlayCount() == 0, "matching commit completion removes settled overlay"); +} + +void TestRenderStateComposerBuildsFrameState() +{ + RuntimeLiveState liveState; + RuntimeLiveOscUpdate update; + update.routeKey = "layer-one\namount"; + update.layerKey = "Test Shader"; + update.parameterKey = "Amount"; + update.targetValue = JsonValue(0.6); + liveState.ApplyOscUpdates({ update }); + + RenderStateCompositionInput input; + input.baseLayerStates = { MakeLayerState() }; + input.liveState = &liveState; + input.liveStateOptions.allowCommit = false; + input.liveStateOptions.smoothing = 0.0; + + RenderStateComposer composer; + RenderStateCompositionResult result = composer.BuildFrameState(input); + + Expect(result.layerStates.size() == 1, "composer returns composed layer state"); + const auto valueIt = result.layerStates[0].parameterValues.find("amount"); + Expect(valueIt != result.layerStates[0].parameterValues.end(), "composer applies live overlay through live state"); + Expect(!valueIt->second.numberValues.empty() && std::fabs(valueIt->second.numberValues[0] - 0.6) < 0.0001, + "composer uses OSC key matching against shader names and labels"); +} +} + +int main() +{ + TestRuntimeLiveStateAppliesLatestOscOverlay(); + TestRuntimeLiveStateQueuesAndCompletesCommit(); + TestRenderStateComposerBuildsFrameState(); + + if (gFailures != 0) + { + std::cerr << gFailures << " RuntimeLiveState test failure(s).\n"; + return 1; + } + + std::cout << "RuntimeLiveState tests passed.\n"; + return 0; +}