| /* | |
| * Copyright 2026 Rive | |
| */ | |
| // Tests RiveRenderPath's cached inner-fan triangulation: geometry earns a | |
| // persistent triangulation on its second request (the first is a throwaway in | |
| // the caller's allocator), after which identical requests reuse the same | |
| // instance, and every kind of raw-path mutation invalidates it so the next | |
| // request rebuilds with the new geometry. | |
| // | |
| // NOTE: rebuild is detected by the triangulation reflecting the new geometry, | |
| // NOT by pointer identity -- the allocator's reset() reuses the same memory, so | |
| // a rebuilt triangulator often lands at the same address. | |
| #include "rive_render_path.hpp" | |
| #include "gr_inner_fan_triangulator.hpp" | |
| #include "rive/math/simd.hpp" | |
| #include <catch.hpp> | |
| DISABLE_CLANG_SIMD_ABI_WARNING() | |
| namespace rive | |
| { | |
| namespace | |
| { | |
| // Mirrors what PathDraw::Make does: reuse the path's cached triangulation when | |
| // it has one, and only build otherwise. createTriangulator() asserts it isn't | |
| // called on a path that already holds a current triangulation. | |
| GrInnerFanTriangulator* obtainTriangulator(const RiveRenderPath& path, | |
| TrivialBlockAllocator& alloc) | |
| { | |
| if (GrInnerFanTriangulator* cached = path.cachedTriangulator()) | |
| { | |
| return cached; | |
| } | |
| return path.createTriangulator(alloc); | |
| } | |
| TEST_CASE("triangulation cache reuses an unmutated path", "[TriangulatorCache]") | |
| { | |
| TrivialBlockAllocator perFrame(GrTriangulator::kArenaDefaultChunkSize); | |
| RiveRenderPath path; | |
| path.moveTo(0, 0); | |
| path.lineTo(100, 0); | |
| path.lineTo(0, 100); | |
| path.close(); | |
| // First sighting: a correct triangulation, but built in the caller's | |
| // allocator and not retained by the path. | |
| GrInnerFanTriangulator* first = obtainTriangulator(path, perFrame); | |
| REQUIRE(first != nullptr); | |
| const size_t vertexCount = first->maxVertexCount(FillRule::nonZero); | |
| CHECK(vertexCount > 0); | |
| // Second sighting promotes it to storage of the path's own, so it is a | |
| // distinct instance from the throwaway (which is still alive here). | |
| GrInnerFanTriangulator* second = obtainTriangulator(path, perFrame); | |
| REQUIRE(second != nullptr); | |
| CHECK(second != first); | |
| CHECK(second->maxVertexCount(FillRule::nonZero) == vertexCount); | |
| // From here on it's cached, and comes back for free without rebuilding. | |
| CHECK(path.cachedTriangulator() == second); | |
| CHECK(path.cachedTriangulator() == second); | |
| } | |
| TEST_CASE("triangulation cache doesn't retain single-use geometry", | |
| "[TriangulatorCache]") | |
| { | |
| TrivialBlockAllocator perFrame(GrTriangulator::kArenaDefaultChunkSize); | |
| RiveRenderPath path; | |
| // A path that mutates between every request never reaches a second | |
| // sighting, so it is served entirely from the caller's allocator. | |
| for (int i = 0; i < 4; ++i) | |
| { | |
| path.moveTo(0, static_cast<float>(i)); | |
| path.lineTo(100, static_cast<float>(i)); | |
| path.lineTo(0, 100 + static_cast<float>(i)); | |
| path.close(); | |
| GrInnerFanTriangulator* triangulator = | |
| obtainTriangulator(path, perFrame); | |
| REQUIRE(triangulator != nullptr); | |
| CHECK(triangulator->maxVertexCount(FillRule::nonZero) > 0); | |
| // Never reaches a second sighting, so nothing is ever retained. | |
| CHECK(path.cachedTriangulator() == nullptr); | |
| } | |
| } | |
| TEST_CASE("triangulation cache re-promotes after a mutation", | |
| "[TriangulatorCache]") | |
| { | |
| TrivialBlockAllocator perFrame(GrTriangulator::kArenaDefaultChunkSize); | |
| RiveRenderPath path; | |
| // Two requests promote the geometry to a cached triangulation. | |
| auto promote = [&]() { | |
| obtainTriangulator(path, perFrame); | |
| return obtainTriangulator(path, perFrame); | |
| }; | |
| auto freshCount = [&]() { | |
| TrivialBlockAllocator alloc(GrTriangulator::kArenaDefaultChunkSize); | |
| GrInnerFanTriangulator fresh(path.getRawPath(), | |
| path.getBounds(), | |
| &alloc); | |
| return fresh.maxVertexCount(FillRule::nonZero); | |
| }; | |
| path.moveTo(0, 0); | |
| path.lineTo(100, 0); | |
| path.lineTo(0, 100); | |
| path.close(); | |
| GrInnerFanTriangulator* cached = promote(); | |
| REQUIRE(cached != nullptr); | |
| const size_t firstCount = cached->maxVertexCount(FillRule::nonZero); | |
| CHECK(firstCount == freshCount()); | |
| CHECK(path.cachedTriangulator() == cached); | |
| // Mutating drops the cache, and the geometry has to earn a new one from | |
| // scratch. This is the cycle a path goes through when it settles, changes, | |
| // and settles again -- the one place a stale cache could survive being | |
| // invalidated once. | |
| path.moveTo(200, 0); | |
| path.cubicTo(250, 50, 350, 150, 400, 300); | |
| path.lineTo(200, 300); | |
| path.close(); | |
| GrInnerFanTriangulator* rePromoted = promote(); | |
| REQUIRE(rePromoted != nullptr); | |
| const size_t secondCount = rePromoted->maxVertexCount(FillRule::nonZero); | |
| CHECK(secondCount == freshCount()); | |
| CHECK(secondCount != firstCount); | |
| // ...and the re-promoted instance is the one that sticks. | |
| CHECK(path.cachedTriangulator() == rePromoted); | |
| } | |
| TEST_CASE("triangulation cache invalidates on every mutation kind", | |
| "[TriangulatorCache]") | |
| { | |
| TrivialBlockAllocator perFrame(GrTriangulator::kArenaDefaultChunkSize); | |
| RiveRenderPath path; | |
| path.moveTo(0, 0); | |
| path.lineTo(100, 0); | |
| path.lineTo(0, 100); | |
| path.close(); | |
| // The cached count, and a fresh count built independently from the current | |
| // raw path. Both derive the sweep axis from the path's bounds, so their | |
| // monotone decompositions match. | |
| auto cachedCount = [&]() { | |
| return obtainTriangulator(path, perFrame) | |
| ->maxVertexCount(FillRule::nonZero); | |
| }; | |
| auto freshCount = [&]() { | |
| TrivialBlockAllocator alloc(GrTriangulator::kArenaDefaultChunkSize); | |
| GrInnerFanTriangulator fresh(path.getRawPath(), | |
| path.getBounds(), | |
| &alloc); | |
| return fresh.maxVertexCount(FillRule::nonZero); | |
| }; | |
| uint64_t lastMutationID = path.getRawPathMutationID(); | |
| REQUIRE(cachedCount() > 0); | |
| CHECK(cachedCount() == freshCount()); | |
| // A mutation must advance the cache key (invalidating it) and the rebuilt | |
| // triangulation must match a fresh one -- not stale, not garbage. We assert | |
| // key advance rather than a vertex-count change because some mutations (a | |
| // congruent extra contour) don't alter the count yet still must invalidate. | |
| auto expectInvalidatedAndCorrect = [&]() { | |
| uint64_t id = path.getRawPathMutationID(); | |
| CHECK(id != lastMutationID); | |
| lastMutationID = id; | |
| CHECK(cachedCount() == freshCount()); | |
| }; | |
| // moveTo / lineTo / close. | |
| path.moveTo(200, 0); | |
| path.lineTo(300, 0); | |
| path.lineTo(200, 100); | |
| path.close(); | |
| expectInvalidatedAndCorrect(); | |
| // cubicTo. | |
| path.moveTo(0, 200); | |
| path.cubicTo(50, 150, 150, 250, 300, 200); | |
| path.lineTo(0, 300); | |
| path.close(); | |
| expectInvalidatedAndCorrect(); | |
| // addRawPath. | |
| { | |
| RiveRenderPath extra; | |
| extra.moveTo(400, 0); | |
| extra.lineTo(500, 0); | |
| extra.lineTo(400, 100); | |
| extra.close(); | |
| path.addRawPath(extra.getRawPath()); | |
| } | |
| expectInvalidatedAndCorrect(); | |
| // addRenderPath. | |
| { | |
| RiveRenderPath other; | |
| other.moveTo(600, 0); | |
| other.lineTo(700, 0); | |
| other.lineTo(600, 100); | |
| other.close(); | |
| path.addRenderPath(&other, Mat2D()); | |
| } | |
| expectInvalidatedAndCorrect(); | |
| // rewind clears the path, so there's nothing left to triangulate. | |
| path.rewind(); | |
| CHECK(path.getRawPathMutationID() != lastMutationID); | |
| CHECK(cachedCount() == 0); | |
| CHECK(freshCount() == 0); | |
| } | |
| } // namespace | |
| } // namespace rive |