4 Commits

Author SHA1 Message Date
Aiden
d411453f80 timing refactor
All checks were successful
CI / React UI Build (push) Successful in 10s
CI / Native Windows Build And Tests (push) Successful in 3m1s
CI / Windows Release Package (push) Successful in 3m20s
2026-05-12 23:39:57 +10:00
Aiden
4a049a557a Render timing 2026-05-12 22:18:27 +10:00
Aiden
13586c611a Start up settle 2026-05-12 22:04:46 +10:00
Aiden
3a83d9617f Clock updates 2026-05-12 21:44:26 +10:00
35 changed files with 542 additions and 114 deletions

View File

@@ -32,6 +32,17 @@ VideoIOScheduleTime VideoPlayoutScheduler::NextScheduleTime()
return time; return time;
} }
void VideoPlayoutScheduler::AlignNextScheduleTimeToPlayback(int64_t streamTime, uint64_t leadFrames)
{
if (mFrameDuration <= 0 || streamTime < 0)
return;
const uint64_t playbackFrameIndex = static_cast<uint64_t>(streamTime / mFrameDuration);
const uint64_t minimumScheduleIndex = playbackFrameIndex + leadFrames;
if (minimumScheduleIndex > mScheduledFrameIndex)
mScheduledFrameIndex = minimumScheduleIndex;
}
VideoPlayoutRecoveryDecision VideoPlayoutScheduler::AccountForCompletionResult(VideoIOCompletionResult result, uint64_t readyQueueDepth) VideoPlayoutRecoveryDecision VideoPlayoutScheduler::AccountForCompletionResult(VideoIOCompletionResult result, uint64_t readyQueueDepth)
{ {
++mCompletedFrameIndex; ++mCompletedFrameIndex;

View File

@@ -12,6 +12,7 @@ public:
void Configure(int64_t frameDuration, int64_t timeScale, const VideoPlayoutPolicy& policy); void Configure(int64_t frameDuration, int64_t timeScale, const VideoPlayoutPolicy& policy);
void Reset(); void Reset();
VideoIOScheduleTime NextScheduleTime(); VideoIOScheduleTime NextScheduleTime();
void AlignNextScheduleTimeToPlayback(int64_t streamTime, uint64_t leadFrames);
VideoPlayoutRecoveryDecision AccountForCompletionResult(VideoIOCompletionResult result, uint64_t readyQueueDepth = 0); VideoPlayoutRecoveryDecision AccountForCompletionResult(VideoIOCompletionResult result, uint64_t readyQueueDepth = 0);
double FrameBudgetMilliseconds() const; double FrameBudgetMilliseconds() const;
uint64_t ScheduledFrameIndex() const { return mScheduledFrameIndex; } uint64_t ScheduledFrameIndex() const { return mScheduledFrameIndex; }

View File

@@ -526,13 +526,24 @@ bool DeckLinkSession::PopulateOutputFrame(IDeckLinkMutableVideoFrame* outputVide
bool DeckLinkSession::ScheduleFrame(IDeckLinkMutableVideoFrame* outputVideoFrame) bool DeckLinkSession::ScheduleFrame(IDeckLinkMutableVideoFrame* outputVideoFrame)
{ {
const VideoIOScheduleTime scheduleTime = mScheduler.NextScheduleTime();
if (outputVideoFrame == nullptr || output == nullptr) if (outputVideoFrame == nullptr || output == nullptr)
{ {
++mState.deckLinkScheduleFailureCount; ++mState.deckLinkScheduleFailureCount;
return false; return false;
} }
BMDTimeValue streamTime = 0;
double playbackSpeed = 0.0;
if (output->GetScheduledStreamTime(mScheduler.TimeScale(), &streamTime, &playbackSpeed) == S_OK && playbackSpeed > 0.0)
{
RefreshBufferedVideoFrameCount();
const uint64_t leadFrames = mState.actualDeckLinkBufferedFramesAvailable
? static_cast<uint64_t>(mState.actualDeckLinkBufferedFrames) + 1
: static_cast<uint64_t>(mPlayoutPolicy.targetPrerollFrames);
mScheduler.AlignNextScheduleTimeToPlayback(streamTime, leadFrames);
}
const VideoIOScheduleTime scheduleTime = mScheduler.NextScheduleTime();
const auto scheduleStart = std::chrono::steady_clock::now(); const auto scheduleStart = std::chrono::steady_clock::now();
const HRESULT result = output->ScheduleVideoFrame(outputVideoFrame, scheduleTime.streamTime, scheduleTime.duration, scheduleTime.timeScale); const HRESULT result = output->ScheduleVideoFrame(outputVideoFrame, scheduleTime.streamTime, scheduleTime.duration, scheduleTime.timeScale);
const auto scheduleEnd = std::chrono::steady_clock::now(); const auto scheduleEnd = std::chrono::steady_clock::now();

View File

@@ -11,21 +11,22 @@ Before adding features here, read the guardrails in [Render Cadence Golden Rules
```text ```text
RenderThread RenderThread
owns a hidden OpenGL context owns a hidden OpenGL context
polls latest input frames without waiting polls the oldest ready input frame without waiting
uploads input frames into a render-owned GL texture uploads input frames into a render-owned GL texture
renders simple BGRA8 motion at selected cadence renders simple BGRA8 motion at selected cadence
queues async PBO readback queues async PBO readback
publishes completed frames into SystemFrameExchange publishes completed frames into SystemFrameExchange
InputFrameMailbox InputFrameMailbox
owns latest disposable CPU input slots owns bounded FIFO CPU input slots
drops older unsampled input frames when newer frames arrive keeps a bounded three-ready-frame input buffer for render
trims frames beyond that bound to avoid runaway input latency
protects the one frame currently being uploaded by render protects the one frame currently being uploaded by render
uses a single contiguous copy when capture row stride matches mailbox row stride uses a single contiguous copy when capture row stride matches mailbox row stride
SystemFrameExchange SystemFrameExchange
owns Free / Rendering / Completed / Scheduled slots owns Free / Rendering / Completed / Scheduled slots
drops old completed unscheduled frames when render needs space preserves completed output frames once they are waiting for playout
protects scheduled frames until DeckLink completion protects scheduled frames until DeckLink completion
DeckLinkOutputThread DeckLinkOutputThread
@@ -34,7 +35,7 @@ DeckLinkOutputThread
never renders never renders
``` ```
Startup warms up real rendered frames before DeckLink scheduled playback starts. When DeckLink input is available, startup also waits briefly for two ready input frames before the render thread starts so the first render ticks are deliberate rather than lucky. Startup builds one settled output reserve before DeckLink scheduled playback starts: the completed-frame reserve must reach the configured depth and remain ready for the configured settle window. When DeckLink input is available, startup also waits briefly for three ready input frames before the render thread starts so the first render ticks are deliberate rather than lucky.
## Current Scope ## Current Scope
@@ -46,13 +47,13 @@ Included now:
- hidden render-thread-owned OpenGL context - hidden render-thread-owned OpenGL context
- simple smooth-motion renderer - simple smooth-motion renderer
- BGRA8-only output - BGRA8-only output
- non-blocking latest-frame input mailbox - non-blocking three-frame FIFO input mailbox for render
- fast contiguous mailbox copy path for matching input row strides - fast contiguous mailbox copy path for matching input row strides
- bounded two-frame input warmup before render cadence starts - bounded three-frame input warmup before render cadence starts
- render-thread-owned input texture upload - render-thread-owned input texture upload
- async PBO readback - async PBO readback
- latest-N system-memory frame exchange - latest-N system-memory frame exchange
- rendered-frame warmup - settled completed-frame output reserve before DeckLink preroll, with DeckLink scheduled depth still targeted at four
- background Slang compile of `shaders/happy-accident` - background Slang compile of `shaders/happy-accident`
- app-owned display/render layer model for shader build readiness - app-owned display/render layer model for shader build readiness
- app-owned submission of a completed shader artifact - app-owned submission of a completed shader artifact
@@ -122,9 +123,9 @@ This tracks parity with `apps/LoopThroughWithOpenGLCompositing`.
- [x] Trigger parameter pulse count/time for latest trigger events - [x] Trigger parameter pulse count/time for latest trigger events
- [x] Optional DeckLink input capture - [x] Optional DeckLink input capture
- [x] UYVY8 input capture with render-thread GPU decode to shader input texture - [x] UYVY8 input capture with render-thread GPU decode to shader input texture
- [x] Latest-frame CPU input mailbox - [x] Three-frame FIFO CPU input mailbox for render
- [x] Fast contiguous input mailbox copy when source/destination stride matches - [x] Fast contiguous input mailbox copy when source/destination stride matches
- [x] Bounded two-frame input warmup before render cadence starts - [x] Bounded three-frame input warmup before render cadence starts
- [x] Render-owned input texture upload - [x] Render-owned input texture upload
- [x] Runtime shaders receive input through `gVideoInput` - [x] Runtime shaders receive input through `gVideoInput`
- [x] Live DeckLink input bound to `gVideoInput` - [x] Live DeckLink input bound to `gVideoInput`
@@ -197,6 +198,7 @@ Currently consumed fields:
- `autoReload` - `autoReload`
- `maxTemporalHistoryFrames` - `maxTemporalHistoryFrames`
- `previewFps` - `previewFps`
- `startupSettleMs`
- `enableExternalKeying` - `enableExternalKeying`
The loaded config is treated as a read-only startup snapshot. Subsystems that need config should receive this snapshot or a narrowed config struct from app orchestration; they should not reload files independently. The loaded config is treated as a read-only startup snapshot. Subsystems that need config should receive this snapshot or a narrowed config struct from app orchestration; they should not reload files independently.
@@ -236,7 +238,7 @@ DeckLink output is an optional edge service in this app.
Startup order is: Startup order is:
1. start render thread 1. start render thread
2. warm up rendered system-memory frames 2. build a settled completed-frame output reserve at normal render cadence
3. try to attach DeckLink output 3. try to attach DeckLink output
4. start telemetry and HTTP either way 4. start telemetry and HTTP either way
@@ -254,10 +256,10 @@ Startup order is:
2. try to attach DeckLink input for the configured input mode 2. try to attach DeckLink input for the configured input mode
3. prefer BGRA8 capture, otherwise accept raw UYVY8 capture and configure the mailbox for UYVY8 bytes 3. prefer BGRA8 capture, otherwise accept raw UYVY8 capture and configure the mailbox for UYVY8 bytes
4. start `DeckLinkInputThread` 4. start `DeckLinkInputThread`
5. wait briefly for two ready input warmup frames before starting render cadence 5. wait briefly for three ready input warmup frames before starting render cadence
6. leave input absent if discovery, setup, format support, or stream startup fails 6. leave input absent if discovery, setup, format support, or stream startup fails
`DeckLinkInput` and `DeckLinkInputThread` are deliberately narrow. They capture BGRA8 frames directly or raw UYVY8 frames into `InputFrameMailbox`; they do not call GL, render, preview, screenshot, shader, or output scheduling code. UYVY8-to-RGBA decode happens later inside the render-thread-owned input texture upload path, so the DeckLink callback stays a capture/copy edge only. The mailbox uses one contiguous copy when the capture row stride matches the configured mailbox row stride, and falls back to row-by-row copy only for padded or mismatched frames. Unsupported input modes or formats outside BGRA8/UYVY8 are reported explicitly and treated as an unavailable edge rather than silently converted. `DeckLinkInput` and `DeckLinkInputThread` are deliberately narrow. They capture BGRA8 frames directly or raw UYVY8 frames into `InputFrameMailbox`; they do not call GL, render, preview, screenshot, shader, or output scheduling code. UYVY8-to-RGBA decode happens later inside the render-thread-owned input texture upload path, so the DeckLink callback stays a capture/copy edge only. The render upload path consumes the oldest ready input frame from a bounded three-ready-frame queue, so the input behaves like a small jitter buffer instead of a latest-frame preview mailbox. The mailbox trims older frames beyond that bound to avoid runaway latency, uses one contiguous copy when the capture row stride matches the configured mailbox row stride, and falls back to row-by-row copy only for padded or mismatched frames. Unsupported input modes or formats outside BGRA8/UYVY8 are reported explicitly and treated as an unavailable edge rather than silently converted.
Input warmup is startup-only and bounded. It may delay render-thread startup for a short window, but it does not add waits to the steady-state render cadence loop. Input warmup is startup-only and bounded. It may delay render-thread startup for a short window, but it does not add waits to the steady-state render cadence loop.
@@ -269,10 +271,23 @@ Normal cadence samples are available through `GET /api/state` and are not printe
- warning when schedule failures increase - warning when schedule failures increase
- error when the app/DeckLink output buffer is starved - error when the app/DeckLink output buffer is starved
Render cadence telemetry:
- `clockOverruns`: render cadence overruns where missed time was detected
- `clockSkippedFrames`: selected-cadence frame intervals skipped instead of catch-up rendering
- `clockOveruns` / `clockSkipped`: compatibility aliases for quick polling scripts
Input telemetry: Input telemetry:
- `inputFramesReceived`: frames accepted into `InputFrameMailbox` - `inputFramesReceived`: frames accepted into `InputFrameMailbox`
- `inputFramesDropped`: ready input frames dropped or missed because the mailbox was full - `inputFramesDropped`: ready input frames dropped or missed because the mailbox was full
- `renderFrameMs`: most recent render-thread draw duration, excluding completed-readback copy and readback queue work
- `renderFrameBudgetUsedPercent`: most recent render draw time as a percentage of the selected frame budget
- `renderFrameMaxMs`: maximum observed render-thread draw duration for this process
- `readbackQueueMs`: time spent queueing the most recent async BGRA8 PBO readback
- `completedReadbackCopyMs`: time spent mapping/copying the most recent completed readback into system-memory frame storage
- `completedDrops`: completed unscheduled system-memory frames dropped by latest-N acquire paths; expected to stay flat in the cadence compositor output path
- `acquireMisses`: times render/readback could not acquire a writable system-memory frame slot; completed frames waiting for playout are preserved instead of being displaced
- `inputConsumeMisses`: render ticks where no ready input frame was available to upload - `inputConsumeMisses`: render ticks where no ready input frame was available to upload
- `inputUploadMisses`: input texture upload attempts that reused the previous GL input texture - `inputUploadMisses`: input texture upload attempts that reused the previous GL input texture
- `inputReadyFrames`: ready input frames currently queued in `InputFrameMailbox` - `inputReadyFrames`: ready input frames currently queued in `InputFrameMailbox`
@@ -391,7 +406,7 @@ This app keeps the same core behavior but splits it into modules that can grow:
- `frames/`: system-memory handoff - `frames/`: system-memory handoff
- `platform/`: COM/Win32/hidden GL context support - `platform/`: COM/Win32/hidden GL context support
- `render/`: cadence thread, clock, and simple renderer - `render/`: cadence thread, clock, and simple renderer
- `frames/InputFrameMailbox`: non-blocking latest-frame CPU input handoff with contiguous-copy fast path for matching row strides - `frames/InputFrameMailbox`: non-blocking bounded FIFO CPU input handoff with contiguous-copy fast path for matching row strides
- `render/InputFrameTexture`: render-thread-owned upload of the latest CPU input frame into GL, including raw UYVY8 decode into the shader-visible input texture - `render/InputFrameTexture`: render-thread-owned upload of the latest CPU input frame into GL, including raw UYVY8 decode into the shader-visible input texture
- `render/readback/`: PBO-backed BGRA8 readback and completed-frame publication - `render/readback/`: PBO-backed BGRA8 readback and completed-frame publication
- `render/runtime/RuntimeRenderScene`: render-thread-owned GL scene for ready runtime shader layers - `render/runtime/RuntimeRenderScene`: render-thread-owned GL scene for ready runtime shader layers

View File

@@ -107,17 +107,27 @@ int main(int argc, char** argv)
inputMailboxConfig.pixelFormat = VideoIOPixelFormat::Bgra8; inputMailboxConfig.pixelFormat = VideoIOPixelFormat::Bgra8;
inputMailboxConfig.rowBytes = VideoIORowBytes(inputMailboxConfig.pixelFormat, inputMailboxConfig.width); inputMailboxConfig.rowBytes = VideoIORowBytes(inputMailboxConfig.pixelFormat, inputMailboxConfig.width);
inputMailboxConfig.capacity = 4; inputMailboxConfig.capacity = 4;
inputMailboxConfig.maxReadyFrames = 3;
InputFrameMailbox inputMailbox(inputMailboxConfig); InputFrameMailbox inputMailbox(inputMailboxConfig);
VideoFormat inputVideoMode; VideoFormat inputVideoMode;
VideoFormat outputVideoMode;
std::string inputVideoModeError; std::string inputVideoModeError;
const bool inputVideoModeResolved = ResolveConfiguredVideoFormat(appConfig.inputVideoFormat, appConfig.inputFrameRate, inputVideoMode); const bool inputVideoModeResolved = ResolveConfiguredVideoFormat(appConfig.inputVideoFormat, appConfig.inputFrameRate, inputVideoMode);
const bool outputVideoModeResolved = ResolveConfiguredVideoFormat(appConfig.outputVideoFormat, appConfig.outputFrameRate, outputVideoMode);
if (!inputVideoModeResolved) if (!inputVideoModeResolved)
{ {
inputVideoModeError = "Unsupported DeckLink inputVideoFormat/inputFrameRate in config/runtime-host.json: " + inputVideoModeError = "Unsupported DeckLink inputVideoFormat/inputFrameRate in config/runtime-host.json: " +
appConfig.inputVideoFormat + " / " + appConfig.inputFrameRate; appConfig.inputVideoFormat + " / " + appConfig.inputFrameRate;
RenderCadenceCompositor::LogWarning("app", inputVideoModeError); RenderCadenceCompositor::LogWarning("app", inputVideoModeError);
} }
if (!outputVideoModeResolved)
{
RenderCadenceCompositor::LogWarning(
"app",
"Unsupported DeckLink outputVideoFormat/outputFrameRate in config/runtime-host.json; render cadence will use parsed frame-rate fallback: " +
appConfig.outputVideoFormat + " / " + appConfig.outputFrameRate);
}
RenderCadenceCompositor::DeckLinkInput deckLinkInput(inputMailbox); RenderCadenceCompositor::DeckLinkInput deckLinkInput(inputMailbox);
RenderCadenceCompositor::DeckLinkInputThread deckLinkInputThread(deckLinkInput); RenderCadenceCompositor::DeckLinkInputThread deckLinkInputThread(deckLinkInput);
@@ -139,7 +149,7 @@ int main(int argc, char** argv)
deckLinkInputStarted = true; deckLinkInputStarted = true;
RenderCadenceCompositor::Log("app", "DeckLink input edge started for " + inputVideoMode.displayName + "."); RenderCadenceCompositor::Log("app", "DeckLink input edge started for " + inputVideoMode.displayName + ".");
RenderCadenceCompositor::Log("app", "Waiting for DeckLink input warmup frames."); RenderCadenceCompositor::Log("app", "Waiting for DeckLink input warmup frames.");
constexpr std::size_t kInputStartupBufferedFrames = 2; constexpr std::size_t kInputStartupBufferedFrames = 3;
constexpr std::chrono::milliseconds kInputWarmupTimeout(1000); constexpr std::chrono::milliseconds kInputWarmupTimeout(1000);
if (WaitForInputWarmup(inputMailbox, kInputStartupBufferedFrames, kInputWarmupTimeout)) if (WaitForInputWarmup(inputMailbox, kInputStartupBufferedFrames, kInputWarmupTimeout))
{ {
@@ -172,7 +182,10 @@ int main(int argc, char** argv)
RenderThread::Config renderConfig; RenderThread::Config renderConfig;
renderConfig.width = frameExchangeConfig.width; renderConfig.width = frameExchangeConfig.width;
renderConfig.height = frameExchangeConfig.height; renderConfig.height = frameExchangeConfig.height;
renderConfig.frameDurationMilliseconds = RenderCadenceCompositor::FrameDurationMillisecondsFromRateString(appConfig.outputFrameRate); const double fallbackFrameDurationMilliseconds = RenderCadenceCompositor::FrameDurationMillisecondsFromRateString(appConfig.outputFrameRate);
renderConfig.frameDurationMilliseconds = outputVideoModeResolved
? RenderCadenceCompositor::FrameDurationMillisecondsFromDisplayMode(outputVideoMode.displayMode, fallbackFrameDurationMilliseconds)
: fallbackFrameDurationMilliseconds;
renderConfig.pboDepth = 6; renderConfig.pboDepth = 6;
RenderThread renderThread(frameExchange, &inputMailbox, renderConfig); RenderThread renderThread(frameExchange, &inputMailbox, renderConfig);

View File

@@ -29,8 +29,9 @@ AppConfig DefaultAppConfig()
config.autoReload = true; config.autoReload = true;
config.maxTemporalHistoryFrames = 12; config.maxTemporalHistoryFrames = 12;
config.previewFps = 30.0; config.previewFps = 30.0;
config.warmupCompletedFrames = 4; config.warmupCompletedFrames = 8;
config.warmupTimeout = std::chrono::seconds(3); config.warmupTimeout = std::chrono::seconds(3);
config.startupSettle = std::chrono::seconds(5);
config.prerollTimeout = std::chrono::seconds(3); config.prerollTimeout = std::chrono::seconds(3);
config.prerollPoll = std::chrono::milliseconds(2); config.prerollPoll = std::chrono::milliseconds(2);
config.runtimeShaderId = "happy-accident"; config.runtimeShaderId = "happy-accident";

View File

@@ -30,8 +30,9 @@ struct AppConfig
bool autoReload = true; bool autoReload = true;
std::size_t maxTemporalHistoryFrames = 12; std::size_t maxTemporalHistoryFrames = 12;
double previewFps = 30.0; double previewFps = 30.0;
std::size_t warmupCompletedFrames = 4; std::size_t warmupCompletedFrames = 8;
std::chrono::milliseconds warmupTimeout = std::chrono::seconds(3); std::chrono::milliseconds warmupTimeout = std::chrono::seconds(3);
std::chrono::milliseconds startupSettle = std::chrono::seconds(5);
std::chrono::milliseconds prerollTimeout = std::chrono::seconds(3); std::chrono::milliseconds prerollTimeout = std::chrono::seconds(3);
std::chrono::milliseconds prerollPoll = std::chrono::milliseconds(2); std::chrono::milliseconds prerollPoll = std::chrono::milliseconds(2);
std::string runtimeShaderId = "happy-accident"; std::string runtimeShaderId = "happy-accident";

View File

@@ -4,6 +4,7 @@
#include <algorithm> #include <algorithm>
#include <cctype> #include <cctype>
#include <cstdint>
#include <cstdlib> #include <cstdlib>
#include <fstream> #include <fstream>
#include <sstream> #include <sstream>
@@ -133,6 +134,9 @@ bool AppConfigProvider::Load(const std::filesystem::path& path, std::string& err
ApplySize(root, "maxTemporalHistoryFrames", mConfig.maxTemporalHistoryFrames); ApplySize(root, "maxTemporalHistoryFrames", mConfig.maxTemporalHistoryFrames);
ApplyDouble(root, "previewFps", mConfig.previewFps); ApplyDouble(root, "previewFps", mConfig.previewFps);
ApplyBool(root, "enableExternalKeying", mConfig.deckLink.externalKeyingEnabled); ApplyBool(root, "enableExternalKeying", mConfig.deckLink.externalKeyingEnabled);
std::size_t startupSettleMilliseconds = static_cast<std::size_t>(mConfig.startupSettle.count());
ApplySize(root, "startupSettleMs", startupSettleMilliseconds);
mConfig.startupSettle = std::chrono::milliseconds(startupSettleMilliseconds);
mLoadedFromFile = true; mLoadedFromFile = true;
error.clear(); error.clear();
@@ -181,6 +185,50 @@ double FrameDurationMillisecondsFromRateString(const std::string& rateText, doub
return 1000.0 / rate; return 1000.0 / rate;
} }
double FrameDurationMillisecondsFromDisplayMode(BMDDisplayMode displayMode, double fallbackMilliseconds)
{
struct ModeRate
{
BMDDisplayMode mode;
int64_t frameDuration;
int64_t timeScale;
};
static const ModeRate rates[] =
{
{ bmdModeHD720p50, 1, 50 },
{ bmdModeHD720p5994, 1001, 60000 },
{ bmdModeHD720p60, 1, 60 },
{ bmdModeHD1080i50, 1, 25 },
{ bmdModeHD1080i5994, 1001, 30000 },
{ bmdModeHD1080i6000, 1, 30 },
{ bmdModeHD1080p2398, 1001, 24000 },
{ bmdModeHD1080p24, 1, 24 },
{ bmdModeHD1080p25, 1, 25 },
{ bmdModeHD1080p2997, 1001, 30000 },
{ bmdModeHD1080p30, 1, 30 },
{ bmdModeHD1080p50, 1, 50 },
{ bmdModeHD1080p5994, 1001, 60000 },
{ bmdModeHD1080p6000, 1, 60 },
{ bmdMode4K2160p2398, 1001, 24000 },
{ bmdMode4K2160p24, 1, 24 },
{ bmdMode4K2160p25, 1, 25 },
{ bmdMode4K2160p2997, 1001, 30000 },
{ bmdMode4K2160p30, 1, 30 },
{ bmdMode4K2160p50, 1, 50 },
{ bmdMode4K2160p5994, 1001, 60000 },
{ bmdMode4K2160p60, 1, 60 }
};
for (const ModeRate& rate : rates)
{
if (rate.mode == displayMode && rate.timeScale > 0)
return (static_cast<double>(rate.frameDuration) * 1000.0) / static_cast<double>(rate.timeScale);
}
return fallbackMilliseconds;
}
void VideoFormatDimensions(const std::string& formatName, unsigned& width, unsigned& height) void VideoFormatDimensions(const std::string& formatName, unsigned& width, unsigned& height)
{ {
std::string normalized = formatName; std::string normalized = formatName;

View File

@@ -1,6 +1,7 @@
#pragma once #pragma once
#include "AppConfig.h" #include "AppConfig.h"
#include "DeckLinkDisplayMode.h"
#include <filesystem> #include <filesystem>
#include <string> #include <string>
@@ -27,6 +28,7 @@ private:
}; };
double FrameDurationMillisecondsFromRateString(const std::string& rateText, double fallbackRate = 59.94); double FrameDurationMillisecondsFromRateString(const std::string& rateText, double fallbackRate = 59.94);
double FrameDurationMillisecondsFromDisplayMode(BMDDisplayMode displayMode, double fallbackMilliseconds);
void VideoFormatDimensions(const std::string& formatName, unsigned& width, unsigned& height); void VideoFormatDimensions(const std::string& formatName, unsigned& width, unsigned& height);
std::filesystem::path FindConfigFile(const std::filesystem::path& relativePath = "config/runtime-host.json"); std::filesystem::path FindConfigFile(const std::filesystem::path& relativePath = "config/runtime-host.json");
std::filesystem::path FindRepoPath(const std::filesystem::path& relativePath); std::filesystem::path FindRepoPath(const std::filesystem::path& relativePath);

View File

@@ -87,10 +87,8 @@ public:
} }
mRuntimeLayers.StartStartupBuild(mConfig.runtimeShaderId); mRuntimeLayers.StartStartupBuild(mConfig.runtimeShaderId);
Log("app", "Waiting for rendered warmup frames."); if (!BuildSettledOutputReserve(error))
if (!mFrameExchange.WaitForCompletedDepth(mConfig.warmupCompletedFrames, mConfig.warmupTimeout))
{ {
error = "Timed out waiting for rendered warmup frames.";
LogError("app", error); LogError("app", error);
Stop(); Stop();
return false; return false;
@@ -167,6 +165,24 @@ private:
Log("app", mVideoOutputStatus); Log("app", mVideoOutputStatus);
} }
bool BuildSettledOutputReserve(std::string& error)
{
const auto reserveTimeout = mConfig.warmupTimeout + mConfig.startupSettle + mConfig.warmupTimeout;
Log("app",
"Building settled output reserve: waiting for " + std::to_string(mConfig.warmupCompletedFrames) +
" completed frame(s) to remain ready for " + std::to_string(mConfig.startupSettle.count()) + " ms.");
if (mFrameExchange.WaitForStableCompletedDepth(
mConfig.warmupCompletedFrames,
mConfig.startupSettle,
reserveTimeout))
{
return true;
}
error = "Timed out waiting for settled output reserve.";
return false;
}
void DisableVideoOutput(const std::string& reason) void DisableVideoOutput(const std::string& reason)
{ {
mOutputThread.Stop(); mOutputThread.Stop();

View File

@@ -251,6 +251,7 @@ inline std::string RuntimeStateToJson(const RuntimeStateJsonInput& input)
writer.KeyUInt("maxTemporalHistoryFrames", static_cast<uint64_t>(input.config.maxTemporalHistoryFrames)); writer.KeyUInt("maxTemporalHistoryFrames", static_cast<uint64_t>(input.config.maxTemporalHistoryFrames));
writer.KeyDouble("previewFps", input.config.previewFps); writer.KeyDouble("previewFps", input.config.previewFps);
writer.KeyBool("enableExternalKeying", input.config.deckLink.externalKeyingEnabled); writer.KeyBool("enableExternalKeying", input.config.deckLink.externalKeyingEnabled);
writer.KeyUInt("startupSettleMs", static_cast<uint64_t>(input.config.startupSettle.count()));
writer.KeyString("inputVideoFormat", input.config.inputVideoFormat); writer.KeyString("inputVideoFormat", input.config.inputVideoFormat);
writer.KeyString("inputFrameRate", input.config.inputFrameRate); writer.KeyString("inputFrameRate", input.config.inputFrameRate);
writer.KeyString("outputVideoFormat", input.config.outputVideoFormat); writer.KeyString("outputVideoFormat", input.config.outputVideoFormat);
@@ -283,9 +284,9 @@ inline std::string RuntimeStateToJson(const RuntimeStateJsonInput& input)
writer.Key("performance"); writer.Key("performance");
writer.BeginObject(); writer.BeginObject();
writer.KeyDouble("frameBudgetMs", FrameDurationMillisecondsFromRateString(input.config.outputFrameRate)); writer.KeyDouble("frameBudgetMs", FrameDurationMillisecondsFromRateString(input.config.outputFrameRate));
writer.KeyNull("renderMs"); writer.KeyDouble("renderMs", input.telemetry.renderFrameMilliseconds);
writer.KeyNull("smoothedRenderMs"); writer.KeyNull("smoothedRenderMs");
writer.KeyNull("budgetUsedPercent"); writer.KeyDouble("budgetUsedPercent", input.telemetry.renderFrameBudgetUsedPercent);
writer.KeyNull("completionIntervalMs"); writer.KeyNull("completionIntervalMs");
writer.KeyNull("smoothedCompletionIntervalMs"); writer.KeyNull("smoothedCompletionIntervalMs");
writer.KeyNull("maxCompletionIntervalMs"); writer.KeyNull("maxCompletionIntervalMs");

View File

@@ -112,6 +112,7 @@ bool InputFrameMailbox::SubmitFrame(const void* bytes, unsigned rowBytes, uint64
slot.frameIndex = frameIndex; slot.frameIndex = frameIndex;
++slot.generation; ++slot.generation;
mReadyIndices.push_back(slotIndex); mReadyIndices.push_back(slotIndex);
TrimReadyFramesLocked();
++mCounters.submittedFrames; ++mCounters.submittedFrames;
mCounters.latestFrameIndex = frameIndex; mCounters.latestFrameIndex = frameIndex;
mCounters.hasSubmittedFrame = true; mCounters.hasSubmittedFrame = true;
@@ -119,27 +120,16 @@ bool InputFrameMailbox::SubmitFrame(const void* bytes, unsigned rowBytes, uint64
return true; return true;
} }
bool InputFrameMailbox::TryAcquireLatest(InputFrame& frame) bool InputFrameMailbox::TryAcquireOldest(InputFrame& frame)
{ {
std::lock_guard<std::mutex> lock(mMutex); std::lock_guard<std::mutex> lock(mMutex);
while (!mReadyIndices.empty()) while (!mReadyIndices.empty())
{ {
const std::size_t index = mReadyIndices.back(); const std::size_t index = mReadyIndices.front();
mReadyIndices.pop_back(); mReadyIndices.pop_front();
if (index >= mSlots.size() || mSlots[index].state != InputFrameSlotState::Ready) if (index >= mSlots.size() || mSlots[index].state != InputFrameSlotState::Ready)
continue; continue;
while (!mReadyIndices.empty())
{
const std::size_t olderIndex = mReadyIndices.front();
mReadyIndices.pop_front();
if (olderIndex >= mSlots.size() || mSlots[olderIndex].state != InputFrameSlotState::Ready)
continue;
mSlots[olderIndex].state = InputFrameSlotState::Free;
++mSlots[olderIndex].generation;
++mCounters.droppedReadyFrames;
}
mSlots[index].state = InputFrameSlotState::Reading; mSlots[index].state = InputFrameSlotState::Reading;
FillFrameLocked(index, frame); FillFrameLocked(index, frame);
++mCounters.consumedFrames; ++mCounters.consumedFrames;
@@ -246,6 +236,14 @@ bool InputFrameMailbox::DropOldestReadyLocked()
return false; return false;
} }
void InputFrameMailbox::TrimReadyFramesLocked()
{
if (mConfig.maxReadyFrames == 0)
return;
while (mReadyIndices.size() > mConfig.maxReadyFrames)
DropOldestReadyLocked();
}
std::size_t InputFrameMailbox::FrameByteCount() const std::size_t InputFrameMailbox::FrameByteCount() const
{ {
return static_cast<std::size_t>(mConfig.rowBytes) * static_cast<std::size_t>(mConfig.height); return static_cast<std::size_t>(mConfig.rowBytes) * static_cast<std::size_t>(mConfig.height);

View File

@@ -23,6 +23,7 @@ struct InputFrameMailboxConfig
VideoIOPixelFormat pixelFormat = VideoIOPixelFormat::Bgra8; VideoIOPixelFormat pixelFormat = VideoIOPixelFormat::Bgra8;
unsigned rowBytes = 0; unsigned rowBytes = 0;
std::size_t capacity = 0; std::size_t capacity = 0;
std::size_t maxReadyFrames = 0;
}; };
struct InputFrame struct InputFrame
@@ -63,7 +64,7 @@ public:
InputFrameMailboxConfig Config() const; InputFrameMailboxConfig Config() const;
bool SubmitFrame(const void* bytes, unsigned rowBytes, uint64_t frameIndex); bool SubmitFrame(const void* bytes, unsigned rowBytes, uint64_t frameIndex);
bool TryAcquireLatest(InputFrame& frame); bool TryAcquireOldest(InputFrame& frame);
bool Release(const InputFrame& frame); bool Release(const InputFrame& frame);
void Clear(); void Clear();
InputFrameMailboxMetrics Metrics() const; InputFrameMailboxMetrics Metrics() const;
@@ -80,6 +81,7 @@ private:
bool IsValidLocked(const InputFrame& frame) const; bool IsValidLocked(const InputFrame& frame) const;
void FillFrameLocked(std::size_t index, InputFrame& frame) const; void FillFrameLocked(std::size_t index, InputFrame& frame) const;
bool DropOldestReadyLocked(); bool DropOldestReadyLocked();
void TrimReadyFramesLocked();
std::size_t FrameByteCount() const; std::size_t FrameByteCount() const;
mutable std::mutex mMutex; mutable std::mutex mMutex;

View File

@@ -46,14 +46,11 @@ bool SystemFrameExchange::AcquireForRender(SystemFrame& frame)
{ {
std::lock_guard<std::mutex> lock(mMutex); std::lock_guard<std::mutex> lock(mMutex);
if (!AcquireFreeLocked(frame)) if (!AcquireFreeLocked(frame))
{
if (!DropOldestCompletedLocked() || !AcquireFreeLocked(frame))
{ {
frame = SystemFrame(); frame = SystemFrame();
++mCounters.acquireMisses; ++mCounters.acquireMisses;
return false; return false;
} }
}
++mCounters.acquiredFrames; ++mCounters.acquiredFrames;
return true; return true;
@@ -130,6 +127,51 @@ bool SystemFrameExchange::WaitForCompletedDepth(std::size_t targetDepth, std::ch
}); });
} }
bool SystemFrameExchange::WaitForStableCompletedDepth(
std::size_t targetDepth,
std::chrono::milliseconds stableDuration,
std::chrono::milliseconds timeout)
{
if (targetDepth == 0)
return true;
const auto deadline = std::chrono::steady_clock::now() + timeout;
std::unique_lock<std::mutex> lock(mMutex);
bool stableWindowStarted = false;
std::chrono::steady_clock::time_point stableSince;
while (true)
{
const auto now = std::chrono::steady_clock::now();
if (now >= deadline)
return false;
if (CompletedCountLocked() >= targetDepth)
{
if (stableDuration <= std::chrono::milliseconds::zero())
return true;
if (!stableWindowStarted)
{
stableSince = now;
stableWindowStarted = true;
}
const auto stableDeadline = stableSince + stableDuration;
if (now >= stableDeadline)
return true;
mCondition.wait_until(lock, stableDeadline < deadline ? stableDeadline : deadline);
continue;
}
stableWindowStarted = false;
mCondition.wait_until(lock, deadline, [&]() {
return CompletedCountLocked() >= targetDepth;
});
}
}
void SystemFrameExchange::Clear() void SystemFrameExchange::Clear()
{ {
std::lock_guard<std::mutex> lock(mMutex); std::lock_guard<std::mutex> lock(mMutex);
@@ -189,27 +231,6 @@ bool SystemFrameExchange::AcquireFreeLocked(SystemFrame& frame)
return false; return false;
} }
bool SystemFrameExchange::DropOldestCompletedLocked()
{
while (!mCompletedIndices.empty())
{
const std::size_t index = mCompletedIndices.front();
mCompletedIndices.pop_front();
if (index >= mSlots.size() || mSlots[index].state != SystemFrameSlotState::Completed)
continue;
Slot& slot = mSlots[index];
slot.state = SystemFrameSlotState::Free;
slot.frameIndex = 0;
++slot.generation;
++mCounters.completedDrops;
mCondition.notify_all();
return true;
}
return false;
}
bool SystemFrameExchange::IsValidLocked(const SystemFrame& frame) const bool SystemFrameExchange::IsValidLocked(const SystemFrame& frame) const
{ {
return frame.index < mSlots.size() && mSlots[frame.index].generation == frame.generation; return frame.index < mSlots.size() && mSlots[frame.index].generation == frame.generation;

View File

@@ -22,6 +22,10 @@ public:
bool ConsumeCompletedForSchedule(SystemFrame& frame); bool ConsumeCompletedForSchedule(SystemFrame& frame);
bool ReleaseScheduledByBytes(void* bytes); bool ReleaseScheduledByBytes(void* bytes);
bool WaitForCompletedDepth(std::size_t targetDepth, std::chrono::milliseconds timeout); bool WaitForCompletedDepth(std::size_t targetDepth, std::chrono::milliseconds timeout);
bool WaitForStableCompletedDepth(
std::size_t targetDepth,
std::chrono::milliseconds stableDuration,
std::chrono::milliseconds timeout);
void Clear(); void Clear();
SystemFrameExchangeMetrics Metrics() const; SystemFrameExchangeMetrics Metrics() const;
@@ -36,7 +40,6 @@ private:
}; };
bool AcquireFreeLocked(SystemFrame& frame); bool AcquireFreeLocked(SystemFrame& frame);
bool DropOldestCompletedLocked();
bool IsValidLocked(const SystemFrame& frame) const; bool IsValidLocked(const SystemFrame& frame) const;
void FillFrameLocked(std::size_t index, SystemFrame& frame); void FillFrameLocked(std::size_t index, SystemFrame& frame);
std::size_t CompletedCountLocked() const; std::size_t CompletedCountLocked() const;

View File

@@ -71,7 +71,7 @@ GLuint InputFrameTexture::PollAndUpload(InputFrameMailbox* mailbox)
return mTexture; return mTexture;
InputFrame frame; InputFrame frame;
if (!mailbox->TryAcquireLatest(frame)) if (!mailbox->TryAcquireOldest(frame))
{ {
++mUploadMisses; ++mUploadMisses;
mLastUploadMilliseconds = 0.0; mLastUploadMilliseconds = 0.0;

View File

@@ -13,6 +13,7 @@ RenderCadenceClock::RenderCadenceClock(double frameDurationMilliseconds)
void RenderCadenceClock::Reset(TimePoint now) void RenderCadenceClock::Reset(TimePoint now)
{ {
mNextRenderTime = now; mNextRenderTime = now;
mPendingFrameAdvance = 1;
mOverrunCount = 0; mOverrunCount = 0;
mSkippedFrameCount = 0; mSkippedFrameCount = 0;
} }
@@ -27,10 +28,12 @@ RenderCadenceClock::Tick RenderCadenceClock::Poll(TimePoint now)
} }
tick.due = true; tick.due = true;
mPendingFrameAdvance = 1;
const Duration lateBy = now - mNextRenderTime; const Duration lateBy = now - mNextRenderTime;
if (lateBy > mFrameDuration) if (lateBy > mFrameDuration)
{ {
tick.skippedFrames = static_cast<uint64_t>(lateBy / mFrameDuration); tick.skippedFrames = static_cast<uint64_t>(lateBy / mFrameDuration);
mPendingFrameAdvance += tick.skippedFrames;
++mOverrunCount; ++mOverrunCount;
mSkippedFrameCount += tick.skippedFrames; mSkippedFrameCount += tick.skippedFrames;
} }
@@ -39,7 +42,8 @@ RenderCadenceClock::Tick RenderCadenceClock::Poll(TimePoint now)
void RenderCadenceClock::MarkRendered(TimePoint now) void RenderCadenceClock::MarkRendered(TimePoint now)
{ {
mNextRenderTime += mFrameDuration; mNextRenderTime += mFrameDuration * mPendingFrameAdvance;
mPendingFrameAdvance = 1;
if (now - mNextRenderTime > mFrameDuration * 4) if (now - mNextRenderTime > mFrameDuration * 4)
mNextRenderTime = now + mFrameDuration; mNextRenderTime = now + mFrameDuration;
} }

View File

@@ -31,6 +31,7 @@ public:
private: private:
Duration mFrameDuration; Duration mFrameDuration;
TimePoint mNextRenderTime = Clock::now(); TimePoint mNextRenderTime = Clock::now();
uint64_t mPendingFrameAdvance = 1;
uint64_t mOverrunCount = 0; uint64_t mOverrunCount = 0;
uint64_t mSkippedFrameCount = 0; uint64_t mSkippedFrameCount = 0;
}; };

View File

@@ -85,6 +85,11 @@ RenderThread::Metrics RenderThread::GetMetrics() const
metrics.skippedFrames = mSkippedFrames.load(std::memory_order_relaxed); metrics.skippedFrames = mSkippedFrames.load(std::memory_order_relaxed);
metrics.shaderBuildsCommitted = mShaderBuildsCommitted.load(std::memory_order_relaxed); metrics.shaderBuildsCommitted = mShaderBuildsCommitted.load(std::memory_order_relaxed);
metrics.shaderBuildFailures = mShaderBuildFailures.load(std::memory_order_relaxed); metrics.shaderBuildFailures = mShaderBuildFailures.load(std::memory_order_relaxed);
metrics.renderFrameMilliseconds = mRenderFrameMilliseconds.load(std::memory_order_relaxed);
metrics.renderFrameBudgetUsedPercent = mRenderFrameBudgetUsedPercent.load(std::memory_order_relaxed);
metrics.renderFrameMaxMilliseconds = mRenderFrameMaxMilliseconds.load(std::memory_order_relaxed);
metrics.readbackQueueMilliseconds = mReadbackQueueMilliseconds.load(std::memory_order_relaxed);
metrics.completedReadbackCopyMilliseconds = mCompletedReadbackCopyMilliseconds.load(std::memory_order_relaxed);
metrics.inputFramesReceived = mInputFramesReceived.load(std::memory_order_relaxed); metrics.inputFramesReceived = mInputFramesReceived.load(std::memory_order_relaxed);
metrics.inputFramesDropped = mInputFramesDropped.load(std::memory_order_relaxed); metrics.inputFramesDropped = mInputFramesDropped.load(std::memory_order_relaxed);
metrics.inputConsumeMisses = mInputConsumeMisses.load(std::memory_order_relaxed); metrics.inputConsumeMisses = mInputConsumeMisses.load(std::memory_order_relaxed);
@@ -154,6 +159,7 @@ void RenderThread::ThreadMain()
CountAcquireMiss(); CountAcquireMiss();
}, },
[this]() { CountCompleted(); }); [this]() { CountCompleted(); });
PublishReadbackMetrics(readback);
const auto now = RenderCadenceClock::Clock::now(); const auto now = RenderCadenceClock::Clock::now();
const RenderCadenceClock::Tick tick = clock.Poll(now); const RenderCadenceClock::Tick tick = clock.Poll(now);
@@ -178,6 +184,7 @@ void RenderThread::ThreadMain()
{ {
mPboQueueMisses.fetch_add(1, std::memory_order_relaxed); mPboQueueMisses.fetch_add(1, std::memory_order_relaxed);
} }
PublishReadbackMetrics(readback);
CountRendered(); CountRendered();
++frameIndex; ++frameIndex;
@@ -196,6 +203,7 @@ void RenderThread::ThreadMain()
CountAcquireMiss(); CountAcquireMiss();
}, },
[this]() { CountCompleted(); }); [this]() { CountCompleted(); });
PublishReadbackMetrics(readback);
} }
readback.Shutdown(); readback.Shutdown();
@@ -237,6 +245,29 @@ void RenderThread::CountAcquireMiss()
mAcquireMisses.fetch_add(1, std::memory_order_relaxed); mAcquireMisses.fetch_add(1, std::memory_order_relaxed);
} }
void RenderThread::PublishReadbackMetrics(const Bgra8ReadbackPipeline& readback)
{
const double renderMilliseconds = readback.LastRenderFrameMilliseconds();
mRenderFrameMilliseconds.store(renderMilliseconds, std::memory_order_relaxed);
if (mConfig.frameDurationMilliseconds > 0.0)
{
mRenderFrameBudgetUsedPercent.store(
(renderMilliseconds / mConfig.frameDurationMilliseconds) * 100.0,
std::memory_order_relaxed);
}
else
{
mRenderFrameBudgetUsedPercent.store(0.0, std::memory_order_relaxed);
}
const double previousMax = mRenderFrameMaxMilliseconds.load(std::memory_order_relaxed);
if (renderMilliseconds > previousMax)
mRenderFrameMaxMilliseconds.store(renderMilliseconds, std::memory_order_relaxed);
mReadbackQueueMilliseconds.store(readback.LastReadbackQueueMilliseconds(), std::memory_order_relaxed);
mCompletedReadbackCopyMilliseconds.store(readback.LastCompletedReadbackCopyMilliseconds(), std::memory_order_relaxed);
}
void RenderThread::PublishInputMetrics(const InputFrameTexture& inputTexture) void RenderThread::PublishInputMetrics(const InputFrameTexture& inputTexture)
{ {
if (mInputMailbox != nullptr) if (mInputMailbox != nullptr)

View File

@@ -16,6 +16,7 @@
class SystemFrameExchange; class SystemFrameExchange;
class InputFrameMailbox; class InputFrameMailbox;
class InputFrameTexture; class InputFrameTexture;
class Bgra8ReadbackPipeline;
class RenderThread class RenderThread
{ {
@@ -38,6 +39,11 @@ public:
uint64_t skippedFrames = 0; uint64_t skippedFrames = 0;
uint64_t shaderBuildsCommitted = 0; uint64_t shaderBuildsCommitted = 0;
uint64_t shaderBuildFailures = 0; uint64_t shaderBuildFailures = 0;
double renderFrameMilliseconds = 0.0;
double renderFrameBudgetUsedPercent = 0.0;
double renderFrameMaxMilliseconds = 0.0;
double readbackQueueMilliseconds = 0.0;
double completedReadbackCopyMilliseconds = 0.0;
uint64_t inputFramesReceived = 0; uint64_t inputFramesReceived = 0;
uint64_t inputFramesDropped = 0; uint64_t inputFramesDropped = 0;
uint64_t inputConsumeMisses = 0; uint64_t inputConsumeMisses = 0;
@@ -71,6 +77,7 @@ private:
void CountRendered(); void CountRendered();
void CountCompleted(); void CountCompleted();
void CountAcquireMiss(); void CountAcquireMiss();
void PublishReadbackMetrics(const Bgra8ReadbackPipeline& readback);
void PublishInputMetrics(const InputFrameTexture& inputTexture); void PublishInputMetrics(const InputFrameTexture& inputTexture);
void TryCommitReadyRuntimeShader(RuntimeRenderScene& runtimeRenderScene); void TryCommitReadyRuntimeShader(RuntimeRenderScene& runtimeRenderScene);
bool TryTakePendingRuntimeShaderArtifact(RuntimeShaderArtifact& artifact); bool TryTakePendingRuntimeShaderArtifact(RuntimeShaderArtifact& artifact);
@@ -96,6 +103,11 @@ private:
std::atomic<uint64_t> mSkippedFrames{ 0 }; std::atomic<uint64_t> mSkippedFrames{ 0 };
std::atomic<uint64_t> mShaderBuildsCommitted{ 0 }; std::atomic<uint64_t> mShaderBuildsCommitted{ 0 };
std::atomic<uint64_t> mShaderBuildFailures{ 0 }; std::atomic<uint64_t> mShaderBuildFailures{ 0 };
std::atomic<double> mRenderFrameMilliseconds{ 0.0 };
std::atomic<double> mRenderFrameBudgetUsedPercent{ 0.0 };
std::atomic<double> mRenderFrameMaxMilliseconds{ 0.0 };
std::atomic<double> mReadbackQueueMilliseconds{ 0.0 };
std::atomic<double> mCompletedReadbackCopyMilliseconds{ 0.0 };
std::atomic<uint64_t> mInputFramesReceived{ 0 }; std::atomic<uint64_t> mInputFramesReceived{ 0 };
std::atomic<uint64_t> mInputFramesDropped{ 0 }; std::atomic<uint64_t> mInputFramesDropped{ 0 };
std::atomic<uint64_t> mInputConsumeMisses{ 0 }; std::atomic<uint64_t> mInputConsumeMisses{ 0 };

View File

@@ -2,8 +2,18 @@
#include "../frames/SystemFrameTypes.h" #include "../frames/SystemFrameTypes.h"
#include <chrono>
#include <cstring> #include <cstring>
namespace
{
double MillisecondsSince(std::chrono::steady_clock::time_point start)
{
return std::chrono::duration_cast<std::chrono::duration<double, std::milli>>(
std::chrono::steady_clock::now() - start).count();
}
}
Bgra8ReadbackPipeline::~Bgra8ReadbackPipeline() Bgra8ReadbackPipeline::~Bgra8ReadbackPipeline()
{ {
Shutdown(); Shutdown();
@@ -50,10 +60,15 @@ bool Bgra8ReadbackPipeline::RenderAndQueue(uint64_t frameIndex, const RenderCall
return false; return false;
glBindFramebuffer(GL_FRAMEBUFFER, mFramebuffer); glBindFramebuffer(GL_FRAMEBUFFER, mFramebuffer);
const auto renderStart = std::chrono::steady_clock::now();
renderFrame(frameIndex); renderFrame(frameIndex);
mLastRenderFrameMilliseconds = MillisecondsSince(renderStart);
glBindFramebuffer(GL_FRAMEBUFFER, 0); glBindFramebuffer(GL_FRAMEBUFFER, 0);
return mPboRing.QueueReadback(mFramebuffer, mWidth, mHeight, frameIndex); const auto queueStart = std::chrono::steady_clock::now();
const bool queued = mPboRing.QueueReadback(mFramebuffer, mWidth, mHeight, frameIndex);
mLastReadbackQueueMilliseconds = MillisecondsSince(queueStart);
return queued;
} }
void Bgra8ReadbackPipeline::ConsumeCompleted( void Bgra8ReadbackPipeline::ConsumeCompleted(
@@ -68,12 +83,14 @@ void Bgra8ReadbackPipeline::ConsumeCompleted(
PboReadbackRing::CompletedReadback readback; PboReadbackRing::CompletedReadback readback;
while (mPboRing.TryAcquireCompleted(readback)) while (mPboRing.TryAcquireCompleted(readback))
{ {
const auto copyStart = std::chrono::steady_clock::now();
glBindBuffer(GL_PIXEL_PACK_BUFFER, readback.pbo); glBindBuffer(GL_PIXEL_PACK_BUFFER, readback.pbo);
void* mapped = glMapBuffer(GL_PIXEL_PACK_BUFFER, GL_READ_ONLY); void* mapped = glMapBuffer(GL_PIXEL_PACK_BUFFER, GL_READ_ONLY);
if (!mapped) if (!mapped)
{ {
glBindBuffer(GL_PIXEL_PACK_BUFFER, 0); glBindBuffer(GL_PIXEL_PACK_BUFFER, 0);
mPboRing.ReleaseCompleted(readback); mPboRing.ReleaseCompleted(readback);
mLastCompletedReadbackCopyMilliseconds = MillisecondsSince(copyStart);
continue; continue;
} }
@@ -99,6 +116,7 @@ void Bgra8ReadbackPipeline::ConsumeCompleted(
glUnmapBuffer(GL_PIXEL_PACK_BUFFER); glUnmapBuffer(GL_PIXEL_PACK_BUFFER);
glBindBuffer(GL_PIXEL_PACK_BUFFER, 0); glBindBuffer(GL_PIXEL_PACK_BUFFER, 0);
mPboRing.ReleaseCompleted(readback); mPboRing.ReleaseCompleted(readback);
mLastCompletedReadbackCopyMilliseconds = MillisecondsSince(copyStart);
} }
} }

View File

@@ -38,6 +38,9 @@ public:
unsigned RowBytes() const { return mRowBytes; } unsigned RowBytes() const { return mRowBytes; }
VideoIOPixelFormat PixelFormat() const { return VideoIOPixelFormat::Bgra8; } VideoIOPixelFormat PixelFormat() const { return VideoIOPixelFormat::Bgra8; }
uint64_t PboQueueMisses() const { return mPboRing.QueueMisses(); } uint64_t PboQueueMisses() const { return mPboRing.QueueMisses(); }
double LastRenderFrameMilliseconds() const { return mLastRenderFrameMilliseconds; }
double LastReadbackQueueMilliseconds() const { return mLastReadbackQueueMilliseconds; }
double LastCompletedReadbackCopyMilliseconds() const { return mLastCompletedReadbackCopyMilliseconds; }
private: private:
bool CreateRenderTarget(); bool CreateRenderTarget();
@@ -48,5 +51,8 @@ private:
unsigned mRowBytes = 0; unsigned mRowBytes = 0;
GLuint mFramebuffer = 0; GLuint mFramebuffer = 0;
GLuint mTexture = 0; GLuint mTexture = 0;
double mLastRenderFrameMilliseconds = 0.0;
double mLastReadbackQueueMilliseconds = 0.0;
double mLastCompletedReadbackCopyMilliseconds = 0.0;
PboReadbackRing mPboRing; PboReadbackRing mPboRing;
}; };

View File

@@ -65,7 +65,7 @@ void RuntimeRenderScene::RenderFrame(uint64_t frameIndex, unsigned width, unsign
} }
// Shader source contract: // Shader source contract:
// - gVideoInput is the decoded latest input texture for every layer in the stack. // - gVideoInput is the decoded current input texture for every layer in the stack.
// - gLayerInput starts as gVideoInput for the first layer, then becomes the previous layer output. // - gLayerInput starts as gVideoInput for the first layer, then becomes the previous layer output.
GLuint layerInputTexture = videoInputTexture; GLuint layerInputTexture = videoInputTexture;
std::size_t nextTargetIndex = 0; std::size_t nextTargetIndex = 0;

View File

@@ -19,11 +19,20 @@ struct CadenceTelemetrySnapshot
uint64_t scheduledTotal = 0; uint64_t scheduledTotal = 0;
uint64_t completedPollMisses = 0; uint64_t completedPollMisses = 0;
uint64_t scheduleFailures = 0; uint64_t scheduleFailures = 0;
uint64_t completedDrops = 0;
uint64_t acquireMisses = 0;
uint64_t completions = 0; uint64_t completions = 0;
uint64_t displayedLate = 0; uint64_t displayedLate = 0;
uint64_t dropped = 0; uint64_t dropped = 0;
uint64_t clockOverruns = 0;
uint64_t clockSkippedFrames = 0;
uint64_t shaderBuildsCommitted = 0; uint64_t shaderBuildsCommitted = 0;
uint64_t shaderBuildFailures = 0; uint64_t shaderBuildFailures = 0;
double renderFrameMilliseconds = 0.0;
double renderFrameBudgetUsedPercent = 0.0;
double renderFrameMaxMilliseconds = 0.0;
double readbackQueueMilliseconds = 0.0;
double completedReadbackCopyMilliseconds = 0.0;
uint64_t inputFramesReceived = 0; uint64_t inputFramesReceived = 0;
uint64_t inputFramesDropped = 0; uint64_t inputFramesDropped = 0;
uint64_t inputConsumeMisses = 0; uint64_t inputConsumeMisses = 0;
@@ -75,6 +84,8 @@ public:
snapshot.scheduleFailures = outputMetrics.scheduleFailures > threadMetrics.scheduleFailures snapshot.scheduleFailures = outputMetrics.scheduleFailures > threadMetrics.scheduleFailures
? outputMetrics.scheduleFailures ? outputMetrics.scheduleFailures
: threadMetrics.scheduleFailures; : threadMetrics.scheduleFailures;
snapshot.completedDrops = exchangeMetrics.completedDrops;
snapshot.acquireMisses = exchangeMetrics.acquireMisses;
snapshot.completions = outputMetrics.completions; snapshot.completions = outputMetrics.completions;
snapshot.displayedLate = outputMetrics.displayedLate; snapshot.displayedLate = outputMetrics.displayedLate;
snapshot.dropped = outputMetrics.dropped; snapshot.dropped = outputMetrics.dropped;
@@ -104,8 +115,15 @@ public:
{ {
CadenceTelemetrySnapshot snapshot = Sample(exchange, output, outputThread); CadenceTelemetrySnapshot snapshot = Sample(exchange, output, outputThread);
const auto renderMetrics = renderThread.GetMetrics(); const auto renderMetrics = renderThread.GetMetrics();
snapshot.clockOverruns = renderMetrics.clockOverruns;
snapshot.clockSkippedFrames = renderMetrics.skippedFrames;
snapshot.shaderBuildsCommitted = renderMetrics.shaderBuildsCommitted; snapshot.shaderBuildsCommitted = renderMetrics.shaderBuildsCommitted;
snapshot.shaderBuildFailures = renderMetrics.shaderBuildFailures; snapshot.shaderBuildFailures = renderMetrics.shaderBuildFailures;
snapshot.renderFrameMilliseconds = renderMetrics.renderFrameMilliseconds;
snapshot.renderFrameBudgetUsedPercent = renderMetrics.renderFrameBudgetUsedPercent;
snapshot.renderFrameMaxMilliseconds = renderMetrics.renderFrameMaxMilliseconds;
snapshot.readbackQueueMilliseconds = renderMetrics.readbackQueueMilliseconds;
snapshot.completedReadbackCopyMilliseconds = renderMetrics.completedReadbackCopyMilliseconds;
snapshot.inputFramesReceived = renderMetrics.inputFramesReceived; snapshot.inputFramesReceived = renderMetrics.inputFramesReceived;
snapshot.inputFramesDropped = renderMetrics.inputFramesDropped; snapshot.inputFramesDropped = renderMetrics.inputFramesDropped;
snapshot.inputConsumeMisses = renderMetrics.inputConsumeMisses; snapshot.inputConsumeMisses = renderMetrics.inputConsumeMisses;

View File

@@ -21,11 +21,22 @@ inline void WriteCadenceTelemetryJson(JsonWriter& writer, const CadenceTelemetry
writer.KeyUInt("scheduledTotal", snapshot.scheduledTotal); writer.KeyUInt("scheduledTotal", snapshot.scheduledTotal);
writer.KeyUInt("completedPollMisses", snapshot.completedPollMisses); writer.KeyUInt("completedPollMisses", snapshot.completedPollMisses);
writer.KeyUInt("scheduleFailures", snapshot.scheduleFailures); writer.KeyUInt("scheduleFailures", snapshot.scheduleFailures);
writer.KeyUInt("completedDrops", snapshot.completedDrops);
writer.KeyUInt("acquireMisses", snapshot.acquireMisses);
writer.KeyUInt("completions", snapshot.completions); writer.KeyUInt("completions", snapshot.completions);
writer.KeyUInt("late", snapshot.displayedLate); writer.KeyUInt("late", snapshot.displayedLate);
writer.KeyUInt("dropped", snapshot.dropped); writer.KeyUInt("dropped", snapshot.dropped);
writer.KeyUInt("clockOverruns", snapshot.clockOverruns);
writer.KeyUInt("clockSkippedFrames", snapshot.clockSkippedFrames);
writer.KeyUInt("clockOveruns", snapshot.clockOverruns);
writer.KeyUInt("clockSkipped", snapshot.clockSkippedFrames);
writer.KeyUInt("shaderCommitted", snapshot.shaderBuildsCommitted); writer.KeyUInt("shaderCommitted", snapshot.shaderBuildsCommitted);
writer.KeyUInt("shaderFailures", snapshot.shaderBuildFailures); writer.KeyUInt("shaderFailures", snapshot.shaderBuildFailures);
writer.KeyDouble("renderFrameMs", snapshot.renderFrameMilliseconds);
writer.KeyDouble("renderFrameBudgetUsedPercent", snapshot.renderFrameBudgetUsedPercent);
writer.KeyDouble("renderFrameMaxMs", snapshot.renderFrameMaxMilliseconds);
writer.KeyDouble("readbackQueueMs", snapshot.readbackQueueMilliseconds);
writer.KeyDouble("completedReadbackCopyMs", snapshot.completedReadbackCopyMilliseconds);
writer.KeyUInt("inputFramesReceived", snapshot.inputFramesReceived); writer.KeyUInt("inputFramesReceived", snapshot.inputFramesReceived);
writer.KeyUInt("inputFramesDropped", snapshot.inputFramesDropped); writer.KeyUInt("inputFramesDropped", snapshot.inputFramesDropped);
writer.KeyUInt("inputConsumeMisses", snapshot.inputConsumeMisses); writer.KeyUInt("inputConsumeMisses", snapshot.inputConsumeMisses);

View File

@@ -82,7 +82,6 @@ private:
std::this_thread::sleep_for(mConfig.idleSleep); std::this_thread::sleep_for(mConfig.idleSleep);
continue; continue;
} }
SystemFrame frame; SystemFrame frame;
if (!mExchange.ConsumeCompletedForSchedule(frame)) if (!mExchange.ConsumeCompletedForSchedule(frame))
{ {

View File

@@ -11,5 +11,6 @@
"autoReload": true, "autoReload": true,
"maxTemporalHistoryFrames": 12, "maxTemporalHistoryFrames": 12,
"previewFps": 30, "previewFps": 30,
"startupSettleMs": 5000,
"enableExternalKeying": true "enableExternalKeying": true
} }

View File

@@ -557,6 +557,8 @@ components:
type: number type: number
previewFps: previewFps:
type: number type: number
startupSettleMs:
type: number
enableExternalKeying: enableExternalKeying:
type: boolean type: boolean
inputVideoFormat: inputVideoFormat:
@@ -633,10 +635,12 @@ components:
type: number type: number
renderMs: renderMs:
type: number type: number
description: Alias of cadence.renderFrameMs for clients that read the top-level performance object.
smoothedRenderMs: smoothedRenderMs:
type: number type: number
budgetUsedPercent: budgetUsedPercent:
type: number type: number
description: Alias of cadence.renderFrameBudgetUsedPercent for clients that read the top-level performance object.
completionIntervalMs: completionIntervalMs:
type: number type: number
smoothedCompletionIntervalMs: smoothedCompletionIntervalMs:
@@ -654,6 +658,41 @@ components:
CadenceTelemetry: CadenceTelemetry:
type: object type: object
properties: properties:
clockOverruns:
type: number
description: Render cadence overruns where the render thread was late enough to skip one or more frame intervals.
clockSkippedFrames:
type: number
description: Total render cadence frame intervals skipped instead of catch-up rendering.
clockOveruns:
type: number
deprecated: true
description: Deprecated misspelled alias for clockOverruns.
clockSkipped:
type: number
deprecated: true
description: Deprecated alias for clockSkippedFrames.
renderFrameMs:
type: number
description: Most recent render-thread frame draw duration in milliseconds, excluding completed-readback copy and readback queue work.
renderFrameBudgetUsedPercent:
type: number
description: Most recent render-thread frame draw duration as a percentage of the selected frame budget.
renderFrameMaxMs:
type: number
description: Maximum observed render-thread frame draw duration in milliseconds for this process.
readbackQueueMs:
type: number
description: Most recent duration spent queueing BGRA8 async PBO readback after rendering.
completedReadbackCopyMs:
type: number
description: Most recent duration spent mapping and copying a completed BGRA8 readback into system-memory frame storage.
completedDrops:
type: number
description: Number of completed unscheduled system-memory frames dropped so render could reuse the slot.
acquireMisses:
type: number
description: Number of times render/readback could not acquire a writable system-memory frame slot.
inputFramesReceived: inputFramesReceived:
type: number type: number
inputFramesDropped: inputFramesDropped:

View File

@@ -1,5 +1,6 @@
#include "AppConfigProvider.h" #include "AppConfigProvider.h"
#include <chrono>
#include <filesystem> #include <filesystem>
#include <fstream> #include <fstream>
#include <iostream> #include <iostream>
@@ -36,6 +37,7 @@ std::filesystem::path WriteConfigFixture()
<< " \"autoReload\": false,\n" << " \"autoReload\": false,\n"
<< " \"maxTemporalHistoryFrames\": 8,\n" << " \"maxTemporalHistoryFrames\": 8,\n"
<< " \"previewFps\": 24,\n" << " \"previewFps\": 24,\n"
<< " \"startupSettleMs\": 2500,\n"
<< " \"enableExternalKeying\": true\n" << " \"enableExternalKeying\": true\n"
<< "}\n"; << "}\n";
return path; return path;
@@ -66,6 +68,7 @@ void TestLoadsRuntimeHostConfig()
Expect(!config.autoReload, "auto reload loads"); Expect(!config.autoReload, "auto reload loads");
Expect(config.maxTemporalHistoryFrames == 8, "history length loads"); Expect(config.maxTemporalHistoryFrames == 8, "history length loads");
Expect(config.previewFps == 24.0, "preview fps loads"); Expect(config.previewFps == 24.0, "preview fps loads");
Expect(config.startupSettle == std::chrono::milliseconds(2500), "startup settle loads");
Expect(config.deckLink.externalKeyingEnabled, "external keying loads"); Expect(config.deckLink.externalKeyingEnabled, "external keying loads");
std::filesystem::remove(path); std::filesystem::remove(path);
@@ -104,6 +107,8 @@ void TestHelpers()
const double duration = FrameDurationMillisecondsFromRateString("50"); const double duration = FrameDurationMillisecondsFromRateString("50");
Expect(duration > 19.9 && duration < 20.1, "frame duration parses numeric rate"); Expect(duration > 19.9 && duration < 20.1, "frame duration parses numeric rate");
const double deckLinkDuration = FrameDurationMillisecondsFromDisplayMode(bmdModeHD1080p5994, 0.0);
Expect(deckLinkDuration > 16.6833 && deckLinkDuration < 16.6834, "DeckLink 59.94 display mode duration is exact");
const std::filesystem::path configPath = FindConfigFile(); const std::filesystem::path configPath = FindConfigFile();
Expect(!configPath.empty(), "default config is discoverable from test working directory"); Expect(!configPath.empty(), "default config is discoverable from test working directory");

View File

@@ -60,6 +60,27 @@ void TestLatePollRecordsSkippedFrames()
Expect(cadence.SkippedFrameCount() == 3, "late poll accumulates skipped frames"); Expect(cadence.SkippedFrameCount() == 3, "late poll accumulates skipped frames");
} }
void TestLatePollSkipsMissedIntervalsInsteadOfCatchingUp()
{
using Clock = RenderCadenceClock::Clock;
RenderCadenceClock cadence(10.0);
const auto start = Clock::now();
cadence.Reset(start);
const auto late = start + std::chrono::milliseconds(35);
const auto tick = cadence.Poll(late);
Expect(tick.due, "late skipped-interval poll is due");
Expect(tick.skippedFrames == 3, "late skipped-interval poll counts missed frames");
cadence.MarkRendered(late);
Expect(cadence.NextRenderTime() > late, "late render schedules the next tick in the future");
Expect(cadence.NextRenderTime() - late <= std::chrono::milliseconds(6), "late render does not leave catch-up frames due immediately");
const auto immediateFollowup = cadence.Poll(late);
Expect(!immediateFollowup.due, "cadence does not allow an immediate catch-up render after a late frame");
Expect(immediateFollowup.sleepFor > RenderCadenceClock::Duration::zero(), "cadence reports wait time after skipping missed intervals");
}
void TestMarkRenderedRebasesAfterLargeStall() void TestMarkRenderedRebasesAfterLargeStall()
{ {
using Clock = RenderCadenceClock::Clock; using Clock = RenderCadenceClock::Clock;
@@ -81,6 +102,7 @@ int main()
TestEarlyPollWaitsWithoutAdvancing(); TestEarlyPollWaitsWithoutAdvancing();
TestDuePollRendersWithoutSkipping(); TestDuePollRendersWithoutSkipping();
TestLatePollRecordsSkippedFrames(); TestLatePollRecordsSkippedFrames();
TestLatePollSkipsMissedIntervalsInsteadOfCatchingUp();
TestMarkRenderedRebasesAfterLargeStall(); TestMarkRenderedRebasesAfterLargeStall();
if (gFailures != 0) if (gFailures != 0)

View File

@@ -57,32 +57,29 @@ void TestAcquirePublishesAndSchedules()
Expect(metrics.scheduledFrames == 1, "scheduled metric is counted"); Expect(metrics.scheduledFrames == 1, "scheduled metric is counted");
} }
void TestAcquireDropsOldestCompletedUnscheduled() void TestAcquirePreservesCompletedFrames()
{ {
SystemFrameExchange exchange(MakeConfig(2)); SystemFrameExchange exchange(MakeConfig(2));
SystemFrame first; SystemFrame first;
SystemFrame second; SystemFrame second;
SystemFrame third; SystemFrame third;
Expect(exchange.AcquireForRender(first), "first frame can be acquired"); Expect(exchange.AcquireForRender(first), "first preserving frame can be acquired");
first.frameIndex = 1; first.frameIndex = 1;
Expect(exchange.PublishCompleted(first), "first frame can be completed"); Expect(exchange.PublishCompleted(first), "first preserving frame can be completed");
Expect(exchange.AcquireForRender(second), "second frame can be acquired"); Expect(exchange.AcquireForRender(second), "second preserving frame can be acquired");
second.frameIndex = 2; second.frameIndex = 2;
Expect(exchange.PublishCompleted(second), "second frame can be completed"); Expect(exchange.PublishCompleted(second), "second preserving frame can be completed");
Expect(exchange.AcquireForRender(third), "third acquire drops the oldest completed frame"); Expect(!exchange.AcquireForRender(third), "render acquire refuses to drop completed frames");
Expect(third.index == first.index, "oldest completed slot is reused");
SystemFrame scheduled; SystemFrame scheduled;
Expect(exchange.ConsumeCompletedForSchedule(scheduled), "remaining completed frame can be scheduled"); Expect(exchange.ConsumeCompletedForSchedule(scheduled), "oldest completed frame survives acquire miss");
Expect(scheduled.index == second.index, "newer completed frame survives drop"); Expect(scheduled.frameIndex == 1, "preserving acquire keeps FIFO output continuity");
Expect(scheduled.frameIndex == 2, "newer frame index survives drop");
SystemFrameExchangeMetrics metrics = exchange.Metrics(); SystemFrameExchangeMetrics metrics = exchange.Metrics();
Expect(metrics.completedDrops == 1, "drop metric is counted"); Expect(metrics.completedDrops == 0, "preserving acquire does not count completed drops");
Expect(metrics.renderingCount == 1, "reused slot is rendering"); Expect(metrics.acquireMisses == 1, "preserving acquire miss is counted");
Expect(metrics.scheduledCount == 1, "consumed slot is scheduled");
} }
void TestScheduledFramesAreNotDropped() void TestScheduledFramesAreNotDropped()
@@ -154,16 +151,38 @@ void TestCompletedPollMissIsCounted()
SystemFrameExchangeMetrics metrics = exchange.Metrics(); SystemFrameExchangeMetrics metrics = exchange.Metrics();
Expect(metrics.completedPollMisses == 1, "completed poll miss is counted"); Expect(metrics.completedPollMisses == 1, "completed poll miss is counted");
} }
void TestStableCompletedDepthCanBeObserved()
{
SystemFrameExchange exchange(MakeConfig(1));
SystemFrame frame;
Expect(exchange.AcquireForRender(frame), "stable-depth frame can be acquired");
Expect(exchange.PublishCompleted(frame), "stable-depth frame can be completed");
Expect(
exchange.WaitForStableCompletedDepth(1, std::chrono::milliseconds(1), std::chrono::milliseconds(50)),
"stable completed depth can be observed");
}
void TestStableCompletedDepthTimesOut()
{
SystemFrameExchange exchange(MakeConfig(1));
Expect(
!exchange.WaitForStableCompletedDepth(1, std::chrono::milliseconds(1), std::chrono::milliseconds(1)),
"missing stable completed depth times out");
}
} }
int main() int main()
{ {
TestAcquirePublishesAndSchedules(); TestAcquirePublishesAndSchedules();
TestAcquireDropsOldestCompletedUnscheduled(); TestAcquirePreservesCompletedFrames();
TestScheduledFramesAreNotDropped(); TestScheduledFramesAreNotDropped();
TestGenerationValidationRejectsStaleFrames(); TestGenerationValidationRejectsStaleFrames();
TestPixelFormatAwareSizing(); TestPixelFormatAwareSizing();
TestCompletedPollMissIsCounted(); TestCompletedPollMissIsCounted();
TestStableCompletedDepthCanBeObserved();
TestStableCompletedDepthTimesOut();
if (gFailures != 0) if (gFailures != 0)
{ {

View File

@@ -29,33 +29,18 @@ InputFrameMailboxConfig MakeConfig(std::size_t capacity = 2)
return config; return config;
} }
InputFrameMailboxConfig MakeBufferedConfig(std::size_t capacity = 4, std::size_t maxReadyFrames = 2)
{
InputFrameMailboxConfig config = MakeConfig(capacity);
config.maxReadyFrames = maxReadyFrames;
return config;
}
std::vector<unsigned char> MakeFrame(unsigned char value) std::vector<unsigned char> MakeFrame(unsigned char value)
{ {
return std::vector<unsigned char>(16, value); return std::vector<unsigned char>(16, value);
} }
void TestAcquireLatestDropsOlderReadyFrames()
{
InputFrameMailbox mailbox(MakeConfig(3));
const std::vector<unsigned char> frame1 = MakeFrame(1);
const std::vector<unsigned char> frame2 = MakeFrame(2);
const std::vector<unsigned char> frame3 = MakeFrame(3);
Expect(mailbox.SubmitFrame(frame1.data(), 8, 1), "first input frame submits");
Expect(mailbox.SubmitFrame(frame2.data(), 8, 2), "second input frame submits");
Expect(mailbox.SubmitFrame(frame3.data(), 8, 3), "third input frame submits");
InputFrame latest;
Expect(mailbox.TryAcquireLatest(latest), "latest input frame can be acquired");
Expect(latest.frameIndex == 3, "mailbox returns newest frame");
Expect(latest.bytes != nullptr && static_cast<const unsigned char*>(latest.bytes)[0] == 3, "latest frame bytes match newest frame");
Expect(mailbox.Release(latest), "latest input frame can be released");
const InputFrameMailboxMetrics metrics = mailbox.Metrics();
Expect(metrics.droppedReadyFrames == 2, "older ready input frames are dropped after latest acquire");
Expect(metrics.freeCount == 3, "all slots are free after release");
}
void TestSubmitDropsOldestWhenFull() void TestSubmitDropsOldestWhenFull()
{ {
InputFrameMailbox mailbox(MakeConfig(2)); InputFrameMailbox mailbox(MakeConfig(2));
@@ -67,10 +52,10 @@ void TestSubmitDropsOldestWhenFull()
Expect(mailbox.SubmitFrame(frame2.data(), 8, 2), "second frame submits into full test"); Expect(mailbox.SubmitFrame(frame2.data(), 8, 2), "second frame submits into full test");
Expect(mailbox.SubmitFrame(frame3.data(), 8, 3), "third frame submits by dropping oldest ready frame"); Expect(mailbox.SubmitFrame(frame3.data(), 8, 3), "third frame submits by dropping oldest ready frame");
InputFrame latest; InputFrame oldest;
Expect(mailbox.TryAcquireLatest(latest), "latest frame available after drop"); Expect(mailbox.TryAcquireOldest(oldest), "oldest frame available after drop");
Expect(latest.frameIndex == 3, "newest frame survived full mailbox"); Expect(oldest.frameIndex == 2, "bounded mailbox keeps FIFO order after trimming oldest overflow");
Expect(mailbox.Release(latest), "newest frame releases"); Expect(mailbox.Release(oldest), "oldest frame releases");
const InputFrameMailboxMetrics metrics = mailbox.Metrics(); const InputFrameMailboxMetrics metrics = mailbox.Metrics();
Expect(metrics.droppedReadyFrames >= 1, "full mailbox records ready drop"); Expect(metrics.droppedReadyFrames >= 1, "full mailbox records ready drop");
@@ -85,18 +70,61 @@ void TestReadingFrameIsProtected()
Expect(mailbox.SubmitFrame(frame1.data(), 8, 1), "protected frame submits"); Expect(mailbox.SubmitFrame(frame1.data(), 8, 1), "protected frame submits");
InputFrame acquired; InputFrame acquired;
Expect(mailbox.TryAcquireLatest(acquired), "protected frame acquired"); Expect(mailbox.TryAcquireOldest(acquired), "protected frame acquired");
Expect(!mailbox.SubmitFrame(frame2.data(), 8, 2), "producer cannot overwrite frame currently being read"); Expect(!mailbox.SubmitFrame(frame2.data(), 8, 2), "producer cannot overwrite frame currently being read");
Expect(mailbox.Release(acquired), "protected frame releases"); Expect(mailbox.Release(acquired), "protected frame releases");
Expect(mailbox.SubmitFrame(frame2.data(), 8, 2), "producer can submit after release"); Expect(mailbox.SubmitFrame(frame2.data(), 8, 2), "producer can submit after release");
} }
void TestAcquireOldestConsumesFifoWithoutDroppingReadyFrames()
{
InputFrameMailbox mailbox(MakeConfig(3));
const std::vector<unsigned char> frame1 = MakeFrame(1);
const std::vector<unsigned char> frame2 = MakeFrame(2);
Expect(mailbox.SubmitFrame(frame1.data(), 8, 1), "fifo first frame submits");
Expect(mailbox.SubmitFrame(frame2.data(), 8, 2), "fifo second frame submits");
InputFrame acquired;
Expect(mailbox.TryAcquireOldest(acquired), "fifo oldest frame acquired");
Expect(acquired.frameIndex == 1, "fifo acquire returns oldest frame");
Expect(mailbox.Release(acquired), "fifo acquired frame releases");
const InputFrameMailboxMetrics metrics = mailbox.Metrics();
Expect(metrics.readyCount == 1, "fifo acquire leaves newer frame ready");
Expect(metrics.droppedReadyFrames == 0, "fifo acquire does not drop newer ready frame");
}
void TestMaxReadyFramesKeepsConfiguredInputBuffer()
{
InputFrameMailbox mailbox(MakeBufferedConfig(4, 3));
const std::vector<unsigned char> frame1 = MakeFrame(1);
const std::vector<unsigned char> frame2 = MakeFrame(2);
const std::vector<unsigned char> frame3 = MakeFrame(3);
const std::vector<unsigned char> frame4 = MakeFrame(4);
Expect(mailbox.SubmitFrame(frame1.data(), 8, 1), "bounded first frame submits");
Expect(mailbox.SubmitFrame(frame2.data(), 8, 2), "bounded second frame submits");
Expect(mailbox.SubmitFrame(frame3.data(), 8, 3), "bounded third frame submits");
Expect(mailbox.SubmitFrame(frame4.data(), 8, 4), "bounded fourth frame submits");
InputFrame acquired;
Expect(mailbox.TryAcquireOldest(acquired), "bounded oldest available frame acquired");
Expect(acquired.frameIndex == 2, "bounded buffer trims oldest beyond configured ready frame limit");
Expect(mailbox.Release(acquired), "bounded acquired frame releases");
const InputFrameMailboxMetrics metrics = mailbox.Metrics();
Expect(metrics.readyCount == 2, "bounded acquire leaves remaining configured ready frames");
Expect(metrics.droppedReadyFrames == 1, "bounded buffer records trimmed frame");
}
} }
int main() int main()
{ {
TestAcquireLatestDropsOlderReadyFrames();
TestSubmitDropsOldestWhenFull(); TestSubmitDropsOldestWhenFull();
TestReadingFrameIsProtected(); TestReadingFrameIsProtected();
TestAcquireOldestConsumesFifoWithoutDroppingReadyFrames();
TestMaxReadyFramesKeepsConfiguredInputBuffer();
if (gFailures != 0) if (gFailures != 0)
{ {

View File

@@ -43,6 +43,13 @@ int main()
RenderCadenceCompositor::CadenceTelemetrySnapshot telemetry; RenderCadenceCompositor::CadenceTelemetrySnapshot telemetry;
telemetry.renderFps = 59.94; telemetry.renderFps = 59.94;
telemetry.renderFrameMilliseconds = 2.5;
telemetry.renderFrameBudgetUsedPercent = 15.0;
telemetry.renderFrameMaxMilliseconds = 4.0;
telemetry.readbackQueueMilliseconds = 0.6;
telemetry.completedReadbackCopyMilliseconds = 1.2;
telemetry.completedDrops = 3;
telemetry.acquireMisses = 4;
telemetry.shaderBuildsCommitted = 1; telemetry.shaderBuildsCommitted = 1;
const std::filesystem::path root = MakeTestRoot(); const std::filesystem::path root = MakeTestRoot();
@@ -98,6 +105,13 @@ int main()
ExpectContains(json, "\"type\":\"color\"", "state JSON should serialize parameter types for the UI"); ExpectContains(json, "\"type\":\"color\"", "state JSON should serialize parameter types for the UI");
ExpectContains(json, "\"width\":1920", "state JSON should expose output width"); ExpectContains(json, "\"width\":1920", "state JSON should expose output width");
ExpectContains(json, "\"height\":1080", "state JSON should expose output height"); ExpectContains(json, "\"height\":1080", "state JSON should expose output height");
ExpectContains(json, "\"renderMs\":2.5", "state JSON should expose top-level render timing");
ExpectContains(json, "\"budgetUsedPercent\":15", "state JSON should expose top-level render budget percentage");
ExpectContains(json, "\"renderFrameMs\":2.5", "state JSON should expose cadence render timing");
ExpectContains(json, "\"readbackQueueMs\":0.6", "state JSON should expose readback queue timing");
ExpectContains(json, "\"completedReadbackCopyMs\":1.2", "state JSON should expose completed readback copy timing");
ExpectContains(json, "\"completedDrops\":3", "state JSON should expose completed drop count");
ExpectContains(json, "\"acquireMisses\":4", "state JSON should expose acquire miss count");
std::filesystem::remove_all(root); std::filesystem::remove_all(root);

View File

@@ -26,6 +26,8 @@ struct FakeExchangeMetrics
std::size_t scheduledCount = 0; std::size_t scheduledCount = 0;
uint64_t completedFrames = 0; uint64_t completedFrames = 0;
uint64_t scheduledFrames = 0; uint64_t scheduledFrames = 0;
uint64_t completedDrops = 0;
uint64_t acquireMisses = 0;
}; };
struct FakeExchange struct FakeExchange
@@ -65,8 +67,15 @@ struct FakeOutput
struct FakeRenderThreadMetrics struct FakeRenderThreadMetrics
{ {
uint64_t clockOverruns = 0;
uint64_t skippedFrames = 0;
uint64_t shaderBuildsCommitted = 0; uint64_t shaderBuildsCommitted = 0;
uint64_t shaderBuildFailures = 0; uint64_t shaderBuildFailures = 0;
double renderFrameMilliseconds = 0.0;
double renderFrameBudgetUsedPercent = 0.0;
double renderFrameMaxMilliseconds = 0.0;
double readbackQueueMilliseconds = 0.0;
double completedReadbackCopyMilliseconds = 0.0;
uint64_t inputFramesReceived = 0; uint64_t inputFramesReceived = 0;
uint64_t inputFramesDropped = 0; uint64_t inputFramesDropped = 0;
uint64_t inputConsumeMisses = 0; uint64_t inputConsumeMisses = 0;
@@ -94,6 +103,8 @@ void TestTelemetrySamplesCompletedPollMissesAndShaderCounts()
exchange.metrics.scheduledCount = 4; exchange.metrics.scheduledCount = 4;
exchange.metrics.completedFrames = 100; exchange.metrics.completedFrames = 100;
exchange.metrics.scheduledFrames = 96; exchange.metrics.scheduledFrames = 96;
exchange.metrics.completedDrops = 2;
exchange.metrics.acquireMisses = 3;
FakeOutput output; FakeOutput output;
output.metrics.actualBufferedFramesAvailable = true; output.metrics.actualBufferedFramesAvailable = true;
@@ -104,8 +115,15 @@ void TestTelemetrySamplesCompletedPollMissesAndShaderCounts()
outputThread.metrics.scheduleFailures = 0; outputThread.metrics.scheduleFailures = 0;
FakeRenderThread renderThread; FakeRenderThread renderThread;
renderThread.metrics.clockOverruns = 5;
renderThread.metrics.skippedFrames = 8;
renderThread.metrics.shaderBuildsCommitted = 1; renderThread.metrics.shaderBuildsCommitted = 1;
renderThread.metrics.shaderBuildFailures = 0; renderThread.metrics.shaderBuildFailures = 0;
renderThread.metrics.renderFrameMilliseconds = 2.5;
renderThread.metrics.renderFrameBudgetUsedPercent = 15.0;
renderThread.metrics.renderFrameMaxMilliseconds = 4.0;
renderThread.metrics.readbackQueueMilliseconds = 0.6;
renderThread.metrics.completedReadbackCopyMilliseconds = 1.2;
renderThread.metrics.inputFramesReceived = 9; renderThread.metrics.inputFramesReceived = 9;
renderThread.metrics.inputFramesDropped = 2; renderThread.metrics.inputFramesDropped = 2;
renderThread.metrics.inputConsumeMisses = 3; renderThread.metrics.inputConsumeMisses = 3;
@@ -122,8 +140,17 @@ void TestTelemetrySamplesCompletedPollMissesAndShaderCounts()
Expect(snapshot.completedFrames == 1, "completed frame count is sampled"); Expect(snapshot.completedFrames == 1, "completed frame count is sampled");
Expect(snapshot.scheduledFrames == 4, "scheduled frame count is sampled"); Expect(snapshot.scheduledFrames == 4, "scheduled frame count is sampled");
Expect(snapshot.completedPollMisses == 12, "completed poll misses are sampled"); Expect(snapshot.completedPollMisses == 12, "completed poll misses are sampled");
Expect(snapshot.completedDrops == 2, "completed drops are sampled");
Expect(snapshot.acquireMisses == 3, "acquire misses are sampled");
Expect(snapshot.clockOverruns == 5, "clock overrun count is sampled");
Expect(snapshot.clockSkippedFrames == 8, "clock skipped frame count is sampled");
Expect(snapshot.shaderBuildsCommitted == 1, "shader committed count is sampled"); Expect(snapshot.shaderBuildsCommitted == 1, "shader committed count is sampled");
Expect(snapshot.shaderBuildFailures == 0, "shader failure count is sampled"); Expect(snapshot.shaderBuildFailures == 0, "shader failure count is sampled");
Expect(snapshot.renderFrameMilliseconds == 2.5, "render frame timing is sampled");
Expect(snapshot.renderFrameBudgetUsedPercent == 15.0, "render budget percentage is sampled");
Expect(snapshot.renderFrameMaxMilliseconds == 4.0, "render frame max timing is sampled");
Expect(snapshot.readbackQueueMilliseconds == 0.6, "readback queue timing is sampled");
Expect(snapshot.completedReadbackCopyMilliseconds == 1.2, "completed readback copy timing is sampled");
Expect(snapshot.inputFramesReceived == 9, "input received count is sampled"); Expect(snapshot.inputFramesReceived == 9, "input received count is sampled");
Expect(snapshot.inputFramesDropped == 2, "input dropped count is sampled"); Expect(snapshot.inputFramesDropped == 2, "input dropped count is sampled");
Expect(snapshot.inputConsumeMisses == 3, "input consume miss count is sampled"); Expect(snapshot.inputConsumeMisses == 3, "input consume miss count is sampled");
@@ -173,11 +200,20 @@ void TestTelemetrySerializesToJson()
snapshot.scheduledTotal = 118; snapshot.scheduledTotal = 118;
snapshot.completedPollMisses = 3; snapshot.completedPollMisses = 3;
snapshot.scheduleFailures = 0; snapshot.scheduleFailures = 0;
snapshot.completedDrops = 4;
snapshot.acquireMisses = 5;
snapshot.completions = 117; snapshot.completions = 117;
snapshot.displayedLate = 1; snapshot.displayedLate = 1;
snapshot.dropped = 2; snapshot.dropped = 2;
snapshot.clockOverruns = 3;
snapshot.clockSkippedFrames = 5;
snapshot.shaderBuildsCommitted = 1; snapshot.shaderBuildsCommitted = 1;
snapshot.shaderBuildFailures = 0; snapshot.shaderBuildFailures = 0;
snapshot.renderFrameMilliseconds = 2.5;
snapshot.renderFrameBudgetUsedPercent = 15.0;
snapshot.renderFrameMaxMilliseconds = 4.0;
snapshot.readbackQueueMilliseconds = 0.6;
snapshot.completedReadbackCopyMilliseconds = 1.2;
snapshot.inputFramesReceived = 10; snapshot.inputFramesReceived = 10;
snapshot.inputFramesDropped = 1; snapshot.inputFramesDropped = 1;
snapshot.inputConsumeMisses = 2; snapshot.inputConsumeMisses = 2;
@@ -205,8 +241,14 @@ void TestTelemetrySerializesToJson()
"\"free\":7,\"completed\":1,\"scheduled\":4," "\"free\":7,\"completed\":1,\"scheduled\":4,"
"\"renderedTotal\":120,\"scheduledTotal\":118," "\"renderedTotal\":120,\"scheduledTotal\":118,"
"\"completedPollMisses\":3,\"scheduleFailures\":0," "\"completedPollMisses\":3,\"scheduleFailures\":0,"
"\"completedDrops\":4,\"acquireMisses\":5,"
"\"completions\":117,\"late\":1,\"dropped\":2," "\"completions\":117,\"late\":1,\"dropped\":2,"
"\"clockOverruns\":3,\"clockSkippedFrames\":5,"
"\"clockOveruns\":3,\"clockSkipped\":5,"
"\"shaderCommitted\":1,\"shaderFailures\":0," "\"shaderCommitted\":1,\"shaderFailures\":0,"
"\"renderFrameMs\":2.5,\"renderFrameBudgetUsedPercent\":15,"
"\"renderFrameMaxMs\":4,\"readbackQueueMs\":0.6,"
"\"completedReadbackCopyMs\":1.2,"
"\"inputFramesReceived\":10,\"inputFramesDropped\":1," "\"inputFramesReceived\":10,\"inputFramesDropped\":1,"
"\"inputConsumeMisses\":2,\"inputUploadMisses\":3," "\"inputConsumeMisses\":2,\"inputUploadMisses\":3,"
"\"inputReadyFrames\":1,\"inputReadingFrames\":0," "\"inputReadyFrames\":1,\"inputReadingFrames\":0,"

View File

@@ -70,6 +70,19 @@ void TestDefaultPolicyReportsLagWithoutSkippingScheduleTime()
Expect(scheduler.NextScheduleTime().streamTime == 1000, "default recovery keeps stream time continuous"); Expect(scheduler.NextScheduleTime().streamTime == 1000, "default recovery keeps stream time continuous");
} }
void TestScheduleCursorCanAlignToPlaybackClock()
{
VideoPlayoutScheduler scheduler;
scheduler.Configure(1000, 50000);
(void)scheduler.NextScheduleTime();
scheduler.AlignNextScheduleTimeToPlayback(10000, 4);
Expect(scheduler.NextScheduleTime().streamTime == 14000, "schedule cursor skips stale stream time after underfeed");
scheduler.AlignNextScheduleTimeToPlayback(11000, 1);
Expect(scheduler.NextScheduleTime().streamTime == 15000, "schedule cursor does not move backward");
}
void TestMeasuredRecoveryIsCappedByPolicy() void TestMeasuredRecoveryIsCappedByPolicy()
{ {
VideoPlayoutPolicy policy; VideoPlayoutPolicy policy;
@@ -133,6 +146,7 @@ int main()
TestScheduleAdvancesFromZero(); TestScheduleAdvancesFromZero();
TestLateAndDroppedRecoveryUsesMeasuredPressure(); TestLateAndDroppedRecoveryUsesMeasuredPressure();
TestDefaultPolicyReportsLagWithoutSkippingScheduleTime(); TestDefaultPolicyReportsLagWithoutSkippingScheduleTime();
TestScheduleCursorCanAlignToPlaybackClock();
TestMeasuredRecoveryIsCappedByPolicy(); TestMeasuredRecoveryIsCappedByPolicy();
TestCleanCompletionTracksCompletedIndexAndClearsStreaks(); TestCleanCompletionTracksCompletedIndexAndClearsStreaks();
TestPolicyNormalization(); TestPolicyNormalization();