blob: 8f319b90c32d16d71bb1fa1387e361c7d95f5d98 [file]
/*
* Copyright 2026 Rive
*/
#include "rive/renderer/triangulation_controller.hpp"
#include <algorithm>
#include <cassert>
#include <cmath>
namespace rive::gpu
{
bool TriangulationController::admits(float area, size_t verbCount)
{
assert(isEligible(area, verbCount));
if (std::isinf(m_thresholds.frameBudgetMs))
{
// The guards decide alone. There's no budget to spend down and
// endFrame() won't tune, so there's no score or bracket worth tracking
// either.
return true;
}
if (budgetExhausted())
{
// Out of budget. That's no verdict on the path's score, so don't record
// it as a rejection: it would tell endFrame() to loosen when the real
// problem is that we already admitted too much.
return false;
}
// Triangulate based on value density: the biggest wins are large-area /
// low-verb fills, so score = area / cost, where cost models triangulation's
// ~N-log-N verb complexity.
assert(verbCount > 1);
const float n = static_cast<float>(verbCount);
const float score = area / (n * std::log2(n));
if (score > m_scoreThreshold)
{
m_frameMinAdmittedScore = std::min(m_frameMinAdmittedScore, score);
return true;
}
else
{
m_frameMaxRejectedScore = std::max(m_frameMaxRejectedScore, score);
return false;
}
}
void TriangulationController::endFrame()
{
const double frameBudgetMs = m_thresholds.frameBudgetMs;
if (frameBudgetMs <= 0 || std::isinf(frameBudgetMs))
{
// Nothing to tune: at 0 we never triangulate, and at infinity the
// guards decide alone. Leave the threshold where it is so the decision
// stays independent of measured time.
return;
}
const double frameMs = m_frameSeconds * 1e3;
// Low-pass (EWMA) the noisy per-frame signal so the controller tracks the
// trend -- including slow thermal/DVFS drift -- rather than frame-to-frame
// jitter in which paths happen to be onscreen.
constexpr double Alpha = 0.1;
m_timeEwmaMs += Alpha * (frameMs - m_timeEwmaMs);
// Nudge the value-density threshold toward the budget, with a hysteresis
// dead-band. Over budget -> raise it (admit fewer, shedding the
// lowest-value paths first); under -> lower it.
const double ratio = m_timeEwmaMs / frameBudgetMs;
if (ratio > 1.05 || ratio < 0.95)
{
// Both sides aim at a score measured on this frame's bracket, betting
// it recurs in the next one. Overshoot a little so the decision still
// flips if it drifts.
constexpr float BracketBackoff = .99f;
// The sqrt makes the step large when far from budget and gentle near
// it, so it converges in a handful of frames without oscillating.
const double factor = std::clamp(std::sqrt(ratio), 0.5, 2.0);
if (factor > 1)
{
// EWMA called for us to tighten (reject more triangulations).
if (m_frameMinAdmittedScore !=
std::numeric_limits<float>::infinity()) // If we admitted
// nothing, we already
// rejected everything,
// so tightening further
// does nothing.
{
// The multiplicative step is blind to the actual triangulations
// being scored, operating solely on the time ratio to tighten
// the heuristic. However, we do track the frame's min admitted
// triangulation score, which lets us tighten enough to reject
// at least one more path when the step alone wouldn't make a
// difference.
// NOTE: The EWMA would converge either way, but the bracket
// gets us there in one frame instead of a geometric climb, and
// doesn't overrun the budget along the way.
m_scoreThreshold =
std::max(m_scoreThreshold * static_cast<float>(factor),
m_frameMinAdmittedScore / BracketBackoff);
}
}
else
{
// EWMA called for us to loosen (admit more triangulations).
if (m_frameMaxRejectedScore != 0) // If we rejected nothing, we
// already admitted everything, so
// loosening further does nothing.
{
// The multiplicative step is blind to the actual triangulations
// being scored, operating solely on the time ratio to loosen
// the heuristic. However, we do track the frame's max rejected
// triangulation score, which lets us loosen enough to admit at
// least one more path when the step alone wouldn't make a
// difference.
// NOTE: The EWMA would converge either way, but the bracket
// gets us there in one frame instead of a geometric descent,
// and doesn't leave triangulations on the table along the way.
m_scoreThreshold =
std::min(m_scoreThreshold * static_cast<float>(factor),
m_frameMaxRejectedScore * BracketBackoff);
}
}
}
m_scoreThreshold = std::clamp(m_scoreThreshold, 1.f, 1e9f);
}
} // namespace rive::gpu