| #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 |