| /* |
| * 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 |