Frame timing
All checks were successful
CI / React UI Build (push) Successful in 10s
CI / Native Windows Build And Tests (push) Successful in 2m53s
CI / Windows Release Package (push) Successful in 3m6s

This commit is contained in:
Aiden
2026-05-12 01:08:32 +10:00
parent ac729dc2b9
commit f1f4e3421b
6 changed files with 141 additions and 39 deletions

View File

@@ -47,6 +47,18 @@ bool RenderOutputQueue::TryPop(RenderOutputFrame& frame)
return true; return true;
} }
bool RenderOutputQueue::DropOldestFrame()
{
std::lock_guard<std::mutex> lock(mMutex);
if (mReadyFrames.empty())
return false;
ReleaseFrame(mReadyFrames.front());
mReadyFrames.pop_front();
++mDroppedCount;
return true;
}
void RenderOutputQueue::Clear() void RenderOutputQueue::Clear()
{ {
std::lock_guard<std::mutex> lock(mMutex); std::lock_guard<std::mutex> lock(mMutex);

View File

@@ -34,6 +34,7 @@ public:
void Configure(const VideoPlayoutPolicy& policy); void Configure(const VideoPlayoutPolicy& policy);
bool Push(RenderOutputFrame frame); bool Push(RenderOutputFrame frame);
bool TryPop(RenderOutputFrame& frame); bool TryPop(RenderOutputFrame& frame);
bool DropOldestFrame();
void Clear(); void Clear();
RenderOutputQueueMetrics GetMetrics() const; RenderOutputQueueMetrics GetMetrics() const;

View File

@@ -359,6 +359,12 @@ void VideoBackend::StartOutputProducerWorker()
if (mOutputProducerWorkerRunning) if (mOutputProducerWorkerRunning)
return; return;
const double frameBudgetMilliseconds = State().frameBudgetMilliseconds;
const auto frameDuration = frameBudgetMilliseconds > 0.0
? std::chrono::duration_cast<RenderCadenceController::Duration>(
std::chrono::duration<double, std::milli>(frameBudgetMilliseconds))
: std::chrono::milliseconds(16);
mRenderCadenceController.Configure(frameDuration, std::chrono::steady_clock::now());
mLastOutputProductionCompletion = VideoIOCompletion(); mLastOutputProductionCompletion = VideoIOCompletion();
mLastOutputProductionTime = std::chrono::steady_clock::time_point(); mLastOutputProductionTime = std::chrono::steady_clock::time_point();
mOutputProducerWorkerStopping = false; mOutputProducerWorkerStopping = false;
@@ -433,11 +439,16 @@ void VideoBackend::OutputProducerWorkerMain()
const RenderOutputQueueMetrics metrics = mReadyOutputQueue.GetMetrics(); const RenderOutputQueueMetrics metrics = mReadyOutputQueue.GetMetrics();
RecordReadyQueueDepthSample(metrics); RecordReadyQueueDepthSample(metrics);
const OutputProductionDecision decision = mOutputProductionController.Decide(BuildOutputProductionPressure(metrics));
if (decision.action != OutputProductionAction::Produce || decision.requestedFrames == 0) const auto now = std::chrono::steady_clock::now();
RenderCadenceDecision cadenceDecision = mRenderCadenceController.Tick(now);
if (cadenceDecision.action == RenderCadenceAction::Wait)
{ {
const auto waitDuration = (std::min)(
std::chrono::duration_cast<std::chrono::milliseconds>(cadenceDecision.waitDuration),
OutputProducerWakeInterval());
std::unique_lock<std::mutex> lock(mOutputProducerMutex); std::unique_lock<std::mutex> lock(mOutputProducerMutex);
mOutputProducerCondition.wait_for(lock, OutputProducerWakeInterval()); mOutputProducerCondition.wait_for(lock, waitDuration);
if (mOutputProducerWorkerStopping) if (mOutputProducerWorkerStopping)
{ {
mOutputProducerWorkerRunning = false; mOutputProducerWorkerRunning = false;
@@ -454,16 +465,7 @@ void VideoBackend::OutputProducerWorkerMain()
completion = mLastOutputProductionCompletion; completion = mLastOutputProductionCompletion;
} }
const bool belowTargetDepth = metrics.depth < decision.targetReadyFrames; const std::size_t producedFrames = ProduceReadyOutputFrames(completion, 1);
const auto now = std::chrono::steady_clock::now();
if (!belowTargetDepth &&
mLastOutputProductionTime != std::chrono::steady_clock::time_point() &&
now - mLastOutputProductionTime < OutputProducerWakeInterval())
{
continue;
}
const std::size_t producedFrames = ProduceReadyOutputFrames(completion, decision.requestedFrames);
if (producedFrames > 0) if (producedFrames > 0)
{ {
mLastOutputProductionTime = std::chrono::steady_clock::now(); mLastOutputProductionTime = std::chrono::steady_clock::now();
@@ -600,10 +602,6 @@ std::size_t VideoBackend::ProduceReadyOutputFrames(const VideoIOCompletion& comp
std::size_t producedFrames = 0; std::size_t producedFrames = 0;
while (producedFrames < maxFrames) while (producedFrames < maxFrames)
{ {
const OutputProductionDecision decision = mOutputProductionController.Decide(BuildOutputProductionPressure(metrics));
if (decision.action != OutputProductionAction::Produce)
break;
if (!RenderReadyOutputFrame(mVideoIODevice->State(), completion)) if (!RenderReadyOutputFrame(mVideoIODevice->State(), completion))
break; break;
++producedFrames; ++producedFrames;
@@ -634,7 +632,10 @@ bool VideoBackend::RenderReadyOutputFrame(const VideoIOState& state, const Video
VideoIOOutputFrame outputFrame; VideoIOOutputFrame outputFrame;
const auto acquireStart = std::chrono::steady_clock::now(); const auto acquireStart = std::chrono::steady_clock::now();
if (!mSystemOutputFramePool.AcquireFreeSlot(outputSlot)) if (!mSystemOutputFramePool.AcquireFreeSlot(outputSlot))
return false; {
if (!mReadyOutputQueue.DropOldestFrame() || !mSystemOutputFramePool.AcquireFreeSlot(outputSlot))
return false;
}
outputFrame = outputSlot.frame; outputFrame = outputSlot.frame;
const auto acquireEnd = std::chrono::steady_clock::now(); const auto acquireEnd = std::chrono::steady_clock::now();

View File

@@ -1,6 +1,7 @@
#pragma once #pragma once
#include "OutputProductionController.h" #include "OutputProductionController.h"
#include "RenderCadenceController.h"
#include "RenderOutputQueue.h" #include "RenderOutputQueue.h"
#include "SystemOutputFramePool.h" #include "SystemOutputFramePool.h"
#include "VideoBackendLifecycle.h" #include "VideoBackendLifecycle.h"
@@ -105,6 +106,7 @@ private:
VideoBackendLifecycle mLifecycle; VideoBackendLifecycle mLifecycle;
VideoPlayoutPolicy mPlayoutPolicy; VideoPlayoutPolicy mPlayoutPolicy;
OutputProductionController mOutputProductionController; OutputProductionController mOutputProductionController;
RenderCadenceController mRenderCadenceController;
RenderOutputQueue mReadyOutputQueue; RenderOutputQueue mReadyOutputQueue;
SystemOutputFramePool mSystemOutputFramePool; SystemOutputFramePool mSystemOutputFramePool;
std::unique_ptr<VideoIODevice> mVideoIODevice; std::unique_ptr<VideoIODevice> mVideoIODevice;

View File

@@ -2,7 +2,15 @@
## Status ## Status
Proposed. In progress.
Implemented so far:
- real DeckLink buffered-frame telemetry is exposed separately from synthetic scheduler lead
- pure `RenderCadenceController` exists with non-GL tests
- `SystemOutputFramePool` now exposes the Phase 7.7 state vocabulary: `Free`, `Rendering`, `Completed`, `Scheduled`
- the output producer now uses `RenderCadenceController` to render one output frame per cadence tick
- DeckLink scheduling remains a separate top-up pass capped by the configured preroll target
Phase 7.5 and 7.6 proved useful pieces individually: Phase 7.5 and 7.6 proved useful pieces individually:
@@ -38,6 +46,16 @@ DeckLink playout scheduler
The system-memory frame buffer becomes the contract between render timing and device timing. The system-memory frame buffer becomes the contract between render timing and device timing.
Core principle:
- The render cadence should be stable and boring.
- If the selected output mode is 59.94 fps, the render producer should attempt to render at 59.94 fps.
- It should not speed up just because the DeckLink buffer is empty.
- It should not slow down because DeckLink is full or because completed frames have not drained.
- Completed-but-unscheduled frames are a latest-N cache. Old completed frames may be dropped/recycled to keep rendering at cadence.
- Scheduled frames are protected until DeckLink completes them.
- The only normal reason for the render cadence to deviate is that rendering/GPU work itself overruns the frame budget.
## Non-Goals ## Non-Goals
- Do not hide failure by repeating frames as the primary strategy. - Do not hide failure by repeating frames as the primary strategy.
@@ -64,6 +82,14 @@ That means the system can be full and still look wrong, because "full" is not ti
### Target Shape ### Target Shape
```text ```text
Startup / warmup
render cadence starts first
render thread produces warmup frames at the selected cadence
completed system-memory queue reaches warmup target
DeckLink preroll is scheduled from completed frames
DeckLink playback starts with a filled buffer
Steady state
RenderCadenceController RenderCadenceController
owns output frame tick: frame 0, 1, 2... owns output frame tick: frame 0, 1, 2...
owns render target time owns render target time
@@ -73,7 +99,8 @@ RenderCadenceController
PlayoutFrameStore PlayoutFrameStore
owns free / rendering / completed / scheduled slots owns free / rendering / completed / scheduled slots
tracks frame number, render time, completion time, and schedule state tracks frame number, render time, completion time, and schedule state
exposes completed frames to DeckLink scheduler exposes latest completed frames to DeckLink scheduler
may drop/recycle oldest unscheduled completed frames when render cadence needs space
DeckLinkPlayoutScheduler DeckLinkPlayoutScheduler
owns DeckLink schedule time owns DeckLink schedule time
@@ -111,14 +138,28 @@ Rules:
- If the render thread is early, it waits/yields. - If the render thread is early, it waits/yields.
- If it is slightly late, it renders the next frame immediately and records lateness. - If it is slightly late, it renders the next frame immediately and records lateness.
- If it is badly late, policy may skip render ticks before rendering the newest frame. - If it is badly late because render/GPU work overran the frame budget, policy may skip render ticks before rendering the newest frame.
- Skipping render ticks is a render-cadence decision, not a DeckLink stream-time jump. - Skipping render ticks is an overrun policy, not a buffer-fill strategy.
- DeckLink schedule time should remain continuous unless a deliberate device recovery policy says otherwise. - DeckLink schedule time should remain continuous unless a deliberate device recovery policy says otherwise.
Non-rule:
- The render producer must not render faster than the selected cadence to refill DeckLink.
- DeckLink should start only after warmup/preroll has filled enough completed frames.
- If the DeckLink buffer drains in steady state, that is a real timing failure to measure, not a signal for the render thread to sprint.
## Buffer Model ## Buffer Model
Use a fixed system-memory slot pool. Use a fixed system-memory slot pool.
The completed portion of the pool is not a strict consume-before-render queue. It is a latest-N rendered-frame cache:
- render cadence writes one frame per selected output tick
- if completed-but-unscheduled frames are full, the oldest completed frame is disposable
- DeckLink scheduling consumes from the completed cache when it needs frames
- frames already scheduled to DeckLink are never recycled until completion
- if all slots are scheduled/in flight, cadence may miss because there is genuinely no safe system-memory target
Suggested starting values: Suggested starting values:
- completed-frame target: 2-4 frames - completed-frame target: 2-4 frames
@@ -266,14 +307,14 @@ Before more scheduling changes, measure the real device buffer.
Deliverables: Deliverables:
- call DeckLink `GetBufferedVideoFrameCount()` after schedule/completion where available - [x] call DeckLink `GetBufferedVideoFrameCount()` after schedule/completion where available
- expose `actualDeckLinkBufferedFrames` - [x] expose `actualDeckLinkBufferedFrames`
- keep `scheduledLeadFrames` but label it synthetic/internal - [x] keep `scheduledLeadFrames` but label it synthetic/internal
- record schedule-call duration and failures - [x] record schedule-call duration and failures
Exit criteria: Exit criteria:
- runtime telemetry distinguishes app completed queue, system scheduled slots, synthetic lead, and actual DeckLink buffer depth - [x] runtime telemetry distinguishes app completed queue, system scheduled slots, synthetic lead, and actual DeckLink buffer depth
### Step 2: Rename Existing Queues To Match Their Roles ### Step 2: Rename Existing Queues To Match Their Roles
@@ -295,17 +336,17 @@ Add a pure timing helper first.
Responsibilities: Responsibilities:
- compute next render tick - [x] compute next render tick
- track frame duration - [x] track frame duration
- report early/late/drift - [x] report early/late/drift
- decide whether to render, wait, or skip render ticks - [x] decide whether to render, wait, or skip render ticks
Tests: Tests:
- exact cadence advances - [x] exact cadence advances
- late ticks are measured - [x] late ticks are measured
- large lateness can skip according to policy - [x] large lateness can skip according to policy
- no dependency on GL or DeckLink - [x] no dependency on GL or DeckLink
### Step 4: Move Output Production To Cadence Ticks ### Step 4: Move Output Production To Cadence Ticks
@@ -313,15 +354,36 @@ Replace queue-pressure-only production with cadence-driven production.
Initial behavior: Initial behavior:
- render at selected output cadence - [x] render at selected output cadence
- produce into system-memory slots - [x] produce into system-memory slots
- publish completed frames - [x] publish completed frames
- pause when completed queue is at max depth - [x] recycle/drop oldest unscheduled completed frames when cadence needs a slot
- [ ] only wait when every safe slot is scheduled/in flight
Exit criteria: Exit criteria:
- output rendering continues without DeckLink completions - output rendering continues without DeckLink completions
- output rendering does not schedule DeckLink directly - output rendering does not schedule DeckLink directly
- completed-frame buffering behaves as latest-N, not consume-before-render
### Step 4a: Add Warmup Before DeckLink Playback
DeckLink output should not start consuming before the render cadence has prepared an initial cushion.
Initial behavior:
- configure DeckLink output without starting scheduled playback
- start the render cadence producer
- render warmup frames at the selected cadence, not faster
- wait until completed-frame depth reaches `targetWarmupFrames`
- schedule those completed frames as DeckLink preroll
- call `StartScheduledPlayback()`
Exit criteria:
- startup does not require the render producer to catch up by rendering faster than cadence
- DeckLink begins playback with a real completed-frame buffer
- if warmup cannot fill within a bounded timeout, startup enters degraded state with telemetry
### Step 5: Make DeckLink Scheduler A Separate Top-Up Loop ### Step 5: Make DeckLink Scheduler A Separate Top-Up Loop

View File

@@ -94,6 +94,29 @@ void TestOverflowReleasesDroppedFrame()
Expect(gReleasedFrames == 1, "pop transfers ownership without releasing"); Expect(gReleasedFrames == 1, "pop transfers ownership without releasing");
} }
void TestDropOldestFrameReleasesFrame()
{
gReleasedFrames = 0;
VideoPlayoutPolicy policy;
policy.maxReadyFrames = 2;
RenderOutputQueue queue(policy);
queue.Push(MakeOwnedFrame(1));
queue.Push(MakeOwnedFrame(2));
Expect(queue.DropOldestFrame(), "oldest ready frame can be explicitly dropped");
Expect(gReleasedFrames == 1, "explicit drop releases oldest frame");
RenderOutputQueueMetrics metrics = queue.GetMetrics();
Expect(metrics.depth == 1, "explicit drop reduces queue depth");
Expect(metrics.droppedCount == 1, "explicit drop increments dropped count");
RenderOutputFrame frame;
Expect(queue.TryPop(frame), "newest frame remains after explicit drop");
Expect(frame.frameIndex == 2, "explicit drop keeps newest frame");
Expect(!queue.DropOldestFrame(), "empty queue cannot drop a frame");
}
void TestUnderrunIsCounted() void TestUnderrunIsCounted()
{ {
RenderOutputQueue queue; RenderOutputQueue queue;
@@ -169,6 +192,7 @@ int main()
TestQueuePreservesOrdering(); TestQueuePreservesOrdering();
TestBoundedQueueDropsOldestFrame(); TestBoundedQueueDropsOldestFrame();
TestOverflowReleasesDroppedFrame(); TestOverflowReleasesDroppedFrame();
TestDropOldestFrameReleasesFrame();
TestUnderrunIsCounted(); TestUnderrunIsCounted();
TestConfigureShrinksDepthToNewCapacity(); TestConfigureShrinksDepthToNewCapacity();
TestConfigureReleasesTrimmedFrames(); TestConfigureReleasesTrimmedFrames();