Frame timing
This commit is contained in:
@@ -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);
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
Reference in New Issue
Block a user