blob: 3f49a94ddafe34a988769c8f6e9511cee9b8f13a [file]
/*
* Copyright 2026 Rive
*/
// Inkbleed hairlines reuse the tessellation of their fill's first pass. If the
// draw sort ever orders a hairline's subpass differently from its fill's
// passes, the hairlines' patches stop being contiguous and they stop batching,
// which still renders correctly. These tests count batches and their
// ElementRanges instead of pixels.
#include "common/render_context_null.hpp"
#include "rive/renderer/rive_renderer.hpp"
#include <catch.hpp>
#include <array>
#include <map>
#include <vector>
using namespace rive;
using namespace rive::gpu;
namespace
{
class CapturingRenderContext : public RenderContextNULL
{
public:
explicit CapturingRenderContext(bool supportsPipelineDynamicState)
{
m_platformFeatures
.supportsPipelineDynamicState = supportsPipelineDynamicState;
}
void flush(const FlushDescriptor& desc) override
{
if (desc.drawList == nullptr)
{
return;
}
for (const DrawBatch& batch : *desc.drawList)
{
++m_batchCounts[batch.drawType];
for ([[maybe_unused]] const auto& range : batch.elementRanges())
{
++m_rangeCounts[batch.drawType];
}
}
}
size_t count(DrawType drawType) const
{
return find(m_batchCounts, drawType);
}
size_t rangeCount(DrawType drawType) const
{
return find(m_rangeCounts, drawType);
}
private:
static size_t find(
const std::map<DrawType, size_t>& counts,
DrawType drawType)
{
auto it = counts.find(drawType);
return it != counts.end() ? it->second : 0;
}
std::map<DrawType, size_t> m_batchCounts;
std::map<DrawType, size_t> m_rangeCounts;
};
constexpr uint32_t TargetSize = 256;
// Opaque squares: a 6x6 grid that doesn't overlap (so it can share batches),
// then a 5x5 grid offset to overlap it (so it lands in later draw groups). With
// this many draws the sort is large enough that std::sort no longer falls back
// to insertion sort, which would hide an unstable ordering of equal keys.
constexpr int GridSize = 6;
constexpr float Cell = 40;
constexpr float Square = 30;
constexpr size_t PathCount = GridSize * GridSize +
(GridSize - 1) * (GridSize - 1);
std::vector<std::array<float, 4>> makeRects()
{
std::vector<std::array<float, 4>> rects;
for (int layer = 0; layer < 2; ++layer)
{
const int n = GridSize - layer;
const float offset = 4 + layer * Cell / 2;
for (int y = 0; y < n; ++y)
{
for (int x = 0; x < n; ++x)
{
const float l = offset + x * Cell, t = offset + y * Cell;
rects.push_back({l, t, l + Square, t + Square});
}
}
}
assert(rects.size() == PathCount);
return rects;
}
std::unique_ptr<RenderContext> drawRects(
bool supportsPipelineDynamicState,
CapturingRenderContext** capture)
{
auto impl = std::make_unique<CapturingRenderContext>(
supportsPipelineDynamicState);
*capture = impl.get();
auto ctx = std::make_unique<RenderContext>(std::move(impl));
ctx->beginFrame({
.renderTargetWidth = TargetSize,
.renderTargetHeight = TargetSize,
.msaaSampleCount = 1,
.clockwiseFillOverride = true,
.inkbleedOverride = true,
});
REQUIRE(ctx->frameInterlockMode() == InterlockMode::depthStencil);
auto paint = ctx->makeRenderPaint();
paint->color(0xff336699);
RiveRenderer renderer(ctx.get());
for (const auto& r : makeRects())
{
auto path = ctx->makeEmptyRenderPath();
path->moveTo(r[0], r[1]);
path->lineTo(r[2], r[1]);
path->lineTo(r[2], r[3]);
path->lineTo(r[0], r[3]);
path->close();
renderer.drawPath(path.get(), paint.get());
}
auto target = (*capture)->makeRenderTarget(TargetSize, TargetSize);
ctx->flush({.renderTarget = target.get()});
return ctx;
}
} // namespace
TEST_CASE(
"opaque hairlines batch like their fills (dynamic state)",
"[RiveRenderer][hairline]")
{
CapturingRenderContext* capture;
auto ctx = drawRects(/*supportsPipelineDynamicState=*/true, &capture);
// Dynamic fills and hairlines each merge into one batch with a list of
// ranges, and the hairlines' ranges line up with the fills'.
CHECK(capture->count(DrawType::stencilDynamicMidpointFans) == 1);
CHECK(capture->count(DrawType::depthAAOuterHairline) == 1);
const size_t fills = capture->rangeCount(
DrawType::stencilDynamicMidpointFans);
const size_t hairlines = capture->rangeCount(DrawType::depthAAOuterHairline);
CHECK(fills > 1);
CHECK(fills < PathCount);
CHECK(hairlines == fills);
}
TEST_CASE(
"opaque hairlines batch like their fills (no dynamic state)",
"[RiveRenderer][hairline]")
{
CapturingRenderContext* capture;
auto ctx = drawRects(/*supportsPipelineDynamicState=*/false, &capture);
const size_t borrowedCoverage = capture->count(
DrawType::stencilMidpointFanBorrowedCoverage);
const size_t fans = capture->count(DrawType::stencilMidpointFans);
const size_t resets = capture->count(DrawType::stencilMidpointFanReset);
CHECK(fans > 1);
CHECK(fans < PathCount);
CHECK(borrowedCoverage == fans);
CHECK(resets == fans);
// Static fills can't hold a list of ranges, but hairlines can: they merge
// into one batch with a range per fan batch.
CHECK(capture->count(DrawType::depthAAOuterHairline) == 1);
CHECK(capture->rangeCount(DrawType::depthAAOuterHairline) == fans);
}