blob: 5ef9319bd0eb7a80c1eb46bb7bb9913e8a34cea2 [file] [edit]
#include "rive/offscreen_raster.hpp"
#ifdef RIVE_CANVAS
#include "rive/renderer.hpp"
#include "rive/renderer/cmd/deferred_canvas_host.hpp"
#include "rive/renderer/render_canvas.hpp"
#include <algorithm>
#include <atomic>
#include <cassert>
#include <cmath>
namespace rive
{
namespace offscreen
{
namespace
{
// NaN passes through (std::max/min return their first argument when the
// comparison fails), which the callers' finiteness checks then reject.
float clampResolution(float resolution)
{
constexpr float kMinRes = 0.01f;
constexpr float kMaxRes = 8.0f;
return std::min<float>(std::max<float>(resolution, kMinRes), kMaxRes);
}
} // namespace
bool planRasterScale(Renderer* renderer, float resolution, RasterPlan* out)
{
// How many device pixels one local unit covers right now: viewport zoom
// times window density times whatever scale the mount transform adds. This
// is the number naive sizing ignores -- rasterizing in local units means
// that on a 2x display at 100% zoom a resolution-1 raster is already a
// half-resolution image being magnified, which is what softens text.
// Reading it here (rather than at replay) is why DeferredRenderer shadows
// its CTM: the raster size has to be chosen while recording.
out->haveCtm = renderer->currentTransform(&out->ctm);
// Read before rasterizing, for the same reason as the CTM: if the host
// hands back a fresh renderer to composite through, that renderer starts
// at opacity 1 and the enclosing modulateOpacity() scope has to be carried
// over by hand.
out->haveOpacity = renderer->currentModulatedOpacity(&out->modulatedOpacity);
float deviceScale = 1.0f;
if (out->haveCtm)
{
const float s = out->ctm.findMaxScale();
if (std::isfinite(s) && s > 0.0f)
{
// Quantize to sixteenths so scrubbing zoom does not re-raster and
// re-allocate on every frame. Rounding up keeps the raster at least
// as fine as the screen, and the scales a user actually rests at
// (1, 1.5, 2, 3, 4) are already multiples of 1/16, so the cases
// that matter stay pixel exact rather than merely close.
// The slack absorbs float noise at a boundary (a rotated 1.5 reads
// as 1.50000012), which would otherwise flip buckets per frame.
constexpr float kScaleQuantum = 16.0f;
constexpr float kSlack = 1.0f / 1024.0f;
deviceScale = std::ceil(s * kScaleQuantum - kSlack) / kScaleQuantum;
}
}
const float res = clampResolution(resolution);
// Texels per local unit. resolution 1 means one texel per screen pixel, so
// the composite is a 1:1 blit; 2 is a genuine 2x supersample of what the
// screen shows. A renderer that cannot report a transform leaves
// deviceScale at 1, which is the old local-unit meaning.
const float rasterScale = res * deviceScale;
if (!(rasterScale > 0.0f) || !std::isfinite(rasterScale))
{
return false;
}
out->rasterScale = rasterScale;
return true;
}
uint32_t bucketedRasterDim(uint32_t n)
{
// A fixed 64 rather than a ladder that widens with n. The obvious
// refinement -- a quantum of n/16, so the slack stays a fixed *fraction* --
// degenerates at kMaxDim = 2048: the next step up, 128, would only be
// reached at n >= 2048, which is the cap itself. So 64 is the whole ladder
// until kMaxDim grows.
constexpr uint32_t kQuantum = 64;
if (n >= kMaxDim)
{
return kMaxDim;
}
const uint32_t bucketed = ((n + kQuantum - 1) / kQuantum) * kQuantum;
return std::min(bucketed, kMaxDim);
}
RasterResize decideRasterResize(
uint32_t needW,
uint32_t needH,
uint32_t haveW,
uint32_t haveH,
uint8_t* streak)
{
const uint32_t wantW = bucketedRasterDim(needW);
const uint32_t wantH = bucketedRasterDim(needH);
RasterResize out;
if (haveW == 0 || haveH == 0)
{
*streak = 0;
return {true, wantW, wantH};
}
if (needW > haveW || needH > haveH)
{
// Grow immediately: a raster smaller than its content crops, and a crop
// is visible where an oversized texture is only wasteful. Only the axis
// that is actually short grows, so growing one axis never sneaks an
// un-hysteresised shrink onto the other.
*streak = 0;
out.reallocate = true;
out.widthPx = needW > haveW ? wantW : haveW;
out.heightPx = needH > haveH ? wantH : haveH;
return out;
}
if (wantW < haveW || wantH < haveH)
{
// Wants to shrink. Wait: content animating through a size would
// otherwise re-allocate every frame, and re-allocating is far more
// expensive than carrying a texture that is one bucket too big.
// Increment before comparing, so the kShrinkDraws'th consecutive
// shrink request is the one that acts rather than the one after it.
++(*streak);
if (*streak < kShrinkDraws)
{
out.widthPx = haveW;
out.heightPx = haveH;
return out;
}
*streak = 0;
return {true, wantW, wantH};
}
*streak = 0;
out.widthPx = haveW;
out.heightPx = haveH;
return out;
}
bool fitRasterToBox(
const AABB& box,
uint32_t guardTexels,
RasterFit fit,
RasterPlan* io)
{
assert(fit == RasterFit::stableGrid || guardTexels == 0);
const float w = box.width();
const float h = box.height();
if (!(w > 0.0f) || !(h > 0.0f))
{
return false;
}
// Cap by shrinking the scale, not by clamping one axis: clamping a single
// dimension would make the two axes disagree about the mapping, and the
// composite inverts one scale for both. Uniform coarsening at least keeps
// the image undistorted.
//
// stableGrid holds back room for the guard band and for the texel either
// end of the origin snap can add, so the cap still holds after they are
// applied rather than being clamped away afterwards.
const uint32_t reserve = fit == RasterFit::stableGrid ? 2u * guardTexels + 2u
: 0u;
const float usableDim = static_cast<float>(
kMaxDim > reserve ? kMaxDim - reserve : 1u);
const float maxScale = std::min<float>(usableDim / w, usableDim / h);
const float rasterScale = std::min<float>(io->rasterScale, maxScale);
if (!(rasterScale > 0.0f) || !std::isfinite(rasterScale))
{
return false;
}
io->rasterScale = rasterScale;
if (fit == RasterFit::exact)
{
io->box = box;
io->widthPx = static_cast<uint32_t>(std::min<float>(
std::max<float>(std::ceil(w * rasterScale), 1.0f),
static_cast<float>(kMaxDim)));
io->heightPx = static_cast<uint32_t>(std::min<float>(
std::max<float>(std::ceil(h * rasterScale), 1.0f),
static_cast<float>(kMaxDim)));
return true;
}
// Snap the origin down to the raster's own texel grid, anchored at local 0
// -- NOT to the tight box's own edge. If the origin tracked the content,
// the content would land at the same sub-texel phase in every raster, and
// beginComposite's pixel snap would then quantize the composite to whole
// device pixels: sub-pixel motion would judder. A grid fixed in local space
// lets the phase vary continuously, exactly as a fixed box does.
const float guard = static_cast<float>(guardTexels);
const float leftTx = std::floor(box.left() * rasterScale) - guard;
const float topTx = std::floor(box.top() * rasterScale) - guard;
const float rightTx = std::ceil(box.right() * rasterScale) + guard;
const float bottomTx = std::ceil(box.bottom() * rasterScale) + guard;
if (!std::isfinite(leftTx) || !std::isfinite(topTx) ||
!std::isfinite(rightTx) || !std::isfinite(bottomTx))
{
return false;
}
const uint32_t widthPx = bucketedRasterDim(
static_cast<uint32_t>(std::min<float>(
std::max<float>(rightTx - leftTx, 1.0f),
static_cast<float>(kMaxDim))));
const uint32_t heightPx = bucketedRasterDim(
static_cast<uint32_t>(std::min<float>(
std::max<float>(bottomTx - topTx, 1.0f),
static_cast<float>(kMaxDim))));
io->widthPx = widthPx;
io->heightPx = heightPx;
// The box spans exactly the chosen pixel size, so the composite's
// 1 / rasterScale inverse maps the raster onto it with nothing left over.
io->box = AABB(
leftTx / rasterScale,
topTx / rasterScale,
(leftTx + static_cast<float>(widthPx)) / rasterScale,
(topTx + static_cast<float>(heightPx)) / rasterScale);
return true;
}
void holdRasterAllocation(uint32_t widthPx, uint32_t heightPx, RasterPlan* io)
{
if (widthPx <= io->widthPx && heightPx <= io->heightPx)
{
return;
}
const uint32_t w = std::max(widthPx, io->widthPx);
const uint32_t h = std::max(heightPx, io->heightPx);
// Right and bottom only. io->box.left/top is what beginComposite's pixel
// snap reads; moving it would shift the composite on screen.
io->box = AABB(
io->box.left(),
io->box.top(),
io->box.left() + static_cast<float>(w) / io->rasterScale,
io->box.top() + static_cast<float>(h) / io->rasterScale);
io->widthPx = w;
io->heightPx = h;
}
bool planRaster(
Renderer* renderer,
const AABB& box,
float resolution,
RasterPlan* out)
{
RasterPlan plan;
if (!planRasterScale(renderer, resolution, &plan))
{
return false;
}
if (!fitRasterToBox(box, 0, RasterFit::exact, &plan))
{
return false;
}
*out = plan;
return true;
}
namespace
{
std::atomic<float> s_displayScale{1.0f};
} // namespace
void setDisplayScale(float scale)
{
if (!std::isfinite(scale) || scale <= 0.0f)
{
return;
}
s_displayScale.store(
std::min(std::max(scale, 1.0f), 4.0f),
std::memory_order_relaxed);
}
float displayScale() { return s_displayScale.load(std::memory_order_relaxed); }
bool planFixedRasterSize(const AABB& box, float resolution, RasterPlan* out)
{
RasterPlan plan;
const float res = clampResolution(resolution) * displayScale();
if (!(res > 0.0f) || !std::isfinite(res))
{
return false;
}
plan.rasterScale = res;
if (!fitRasterToBox(box, 0, RasterFit::exact, &plan))
{
return false;
}
*out = plan;
return true;
}
bool planFixedRaster(
Renderer* renderer,
const AABB& box,
float resolution,
RasterPlan* out)
{
RasterPlan plan;
if (!planFixedRasterSize(box, resolution, &plan))
{
return false;
}
plan.haveCtm = renderer->currentTransform(&plan.ctm);
plan.haveOpacity = renderer->currentModulatedOpacity(&plan.modulatedOpacity);
*out = plan;
return true;
}
CanvasContentScope::CanvasContentScope(
cmd::DeferredCanvasHost* host,
gpu::RenderCanvas* canvas,
const RasterPlan& plan,
uint32_t clearColor)
: m_host(host)
, m_canvas(canvas)
, m_renderer(nullptr)
{
if (host == nullptr || canvas == nullptr)
{
return;
}
// beginCanvasContent hands back a recording renderer whose frame targets
// the canvas texture. Draw content into it at exactly rasterScale texels
// per local unit -- not widthPx/w, which would bake ceil()'s rounding into
// the mapping. Curve flattening and feathering read this transform, so this
// is also what decides how finely the content is tessellated.
m_renderer = host->beginCanvasContent(canvas, clearColor);
if (m_renderer == nullptr)
{
return;
}
m_renderer->save();
m_renderer->transform(Mat2D::fromScale(plan.rasterScale, plan.rasterScale));
m_renderer->translate(-plan.box.left(), -plan.box.top());
}
CanvasContentScope::~CanvasContentScope()
{
if (m_renderer == nullptr)
{
// Nothing was drawn, so never end a bracket that never began.
return;
}
m_renderer->restore();
m_host->endCanvasContent(m_canvas);
}
CompositePlacement beginComposite(
Renderer* renderer,
cmd::DeferredCanvasHost* host,
const RasterPlan& plan,
float compositedRasterScale,
const AABB& compositedBox)
{
// Some hosts cannot composite through the renderer that was drawing when
// the offscreen frame interrupted it, and hand back a clean one instead.
// It shares no state, so the current transform and the modulated opacity
// both have to be re-applied by hand -- and it inherits no clip. Known
// limitation: on that path (WebGL today) an ancestor's clip does not
// constrain the composite, so cached content under a clipping shape can
// paint outside it where the vector draw would have been cropped.
Renderer* composite = renderer;
float compositeOpacity = 1.0f;
if (host != nullptr)
{
if (Renderer* fresh = host->compositeRenderer())
{
if (plan.haveCtm && plan.haveOpacity)
{
composite = fresh;
compositeOpacity = plan.modulatedOpacity;
}
}
}
composite->save();
if (composite != renderer)
{
composite->transform(plan.ctm);
}
// Pixel snap. Even at a perfectly matched scale, an origin that lands on a
// half pixel makes every bilinear tap a blend of two texels -- a box blur
// over the whole image, and the reason cache-as-bitmap has historically
// needed a pixelSnapping switch. Only meaningful when the transform is axis
// aligned; under rotation or skew there is no pixel grid to snap to.
if (plan.haveCtm && plan.ctm.xy() == 0.0f && plan.ctm.yx() == 0.0f &&
plan.ctm.xx() != 0.0f && plan.ctm.yy() != 0.0f)
{
const Vec2D deviceOrigin = plan.ctm *
Vec2D(compositedBox.left(), compositedBox.top());
const Vec2D delta = {
std::round(deviceOrigin.x) - deviceOrigin.x,
std::round(deviceOrigin.y) - deviceOrigin.y};
// The nudge is in device space but transform() concatenates in local
// space, so push it back through the (diagonal) linear part.
composite->translate(delta.x / plan.ctm.xx(), delta.y / plan.ctm.yy());
}
composite->translate(compositedBox.left(), compositedBox.top());
// The exact inverse of the scale the content was rasterized at. Deriving it
// from w / widthPx instead would fold ceil()'s rounding into the mapping,
// leaving a fractional-width box permanently resampled at ~0.995x -- a blur
// on every pixel for nothing. The cost is that the quad reaches up to one
// texel past the box on the right and bottom, which is the sliver that
// rounding allocated anyway.
const float invScale = 1.0f / compositedRasterScale;
composite->transform(Mat2D::fromScale(invScale, invScale));
return {composite, compositeOpacity};
}
CompositePlacement beginComposite(
Renderer* renderer,
cmd::DeferredCanvasHost* host,
const RasterPlan& plan,
float compositedRasterScale)
{
return beginComposite(renderer, host, plan, compositedRasterScale, plan.box);
}
void endComposite(const CompositePlacement& placement)
{
placement.renderer->restore();
}
CompositePlacement beginPlacement(
Renderer* renderer,
cmd::DeferredCanvasHost* host,
const RasterPlan& plan)
{
// Same renderer choice as beginComposite.
Renderer* target = renderer;
float opacity = 1.0f;
if (host != nullptr)
{
if (Renderer* fresh = host->compositeRenderer())
{
if (plan.haveCtm && plan.haveOpacity)
{
target = fresh;
opacity = plan.modulatedOpacity;
}
}
}
target->save();
if (target != renderer)
{
target->transform(plan.ctm);
}
return {target, opacity};
}
CachedRaster::CachedRaster() = default;
CachedRaster::~CachedRaster() = default;
void CachedRaster::release()
{
canvas.reset();
widthPx = 0;
heightPx = 0;
contentRevision = 0;
dirty = true;
}
} // namespace offscreen
} // namespace rive
#endif // RIVE_CANVAS