From 678ff81105753656aa4822f4f675ef96dc9d2b83 Mon Sep 17 00:00:00 2001 From: Chris Craik Date: Tue, 1 Mar 2016 13:27:54 -0800 Subject: [PATCH 1/2] Clip projected ripples to outlines bug:27343928 Also fixes positioning of ripples to a scrolled projection receiver. Change-Id: I74b7233c46d7c15839ca8bf50e188ba6646d7432 --- libs/hwui/BakedOpDispatcher.cpp | 7 ++ libs/hwui/BakedOpState.cpp | 21 +++- libs/hwui/BakedOpState.h | 5 +- libs/hwui/FrameBuilder.cpp | 33 +++---- libs/hwui/LayerBuilder.cpp | 5 +- libs/hwui/OpenGLRenderer.cpp | 6 ++ libs/hwui/Snapshot.cpp | 10 +- libs/hwui/Snapshot.h | 8 +- libs/hwui/tests/unit/FrameBuilderTests.cpp | 98 ++++++++++++++++++- .../res/layout/projection_clipping.xml | 36 ++++--- 10 files changed, 185 insertions(+), 44 deletions(-) diff --git a/libs/hwui/BakedOpDispatcher.cpp b/libs/hwui/BakedOpDispatcher.cpp index 78764b5f44486..bce583c895703 100644 --- a/libs/hwui/BakedOpDispatcher.cpp +++ b/libs/hwui/BakedOpDispatcher.cpp @@ -30,6 +30,7 @@ #include #include #include +#include namespace android { namespace uirenderer { @@ -527,6 +528,12 @@ void BakedOpDispatcher::onOvalOp(BakedOpRenderer& renderer, const OvalOp& op, co SkPath path; SkRect rect = getBoundsOfFill(op); path.addOval(rect); + + if (state.computedState.localProjectionPathMask != nullptr) { + // Mask the ripple path by the local space projection mask in local space. + // Note that this can create CCW paths. + Op(path, *state.computedState.localProjectionPathMask, kIntersect_SkPathOp, &path); + } renderConvexPath(renderer, state, path, *(op.paint)); } } diff --git a/libs/hwui/BakedOpState.cpp b/libs/hwui/BakedOpState.cpp index 682bd045098de..26653f77daeb5 100644 --- a/libs/hwui/BakedOpState.cpp +++ b/libs/hwui/BakedOpState.cpp @@ -63,9 +63,22 @@ ResolvedRenderState::ResolvedRenderState(LinearAllocator& allocator, Snapshot& s clipState = nullptr; clippedBounds.setEmpty(); } else { - // Not rejected! compute true clippedBounds and clipSideFlags + // Not rejected! compute true clippedBounds, clipSideFlags, and path mask clipSideFlags = computeClipSideFlags(clipRect, clippedBounds); clippedBounds.doIntersect(clipRect); + + if (CC_UNLIKELY(snapshot.projectionPathMask)) { + // map projection path mask from render target space into op space, + // so intersection with op geometry is possible + Matrix4 inverseTransform; + inverseTransform.loadInverse(transform); + SkMatrix skInverseTransform; + inverseTransform.copyTo(skInverseTransform); + + auto localMask = allocator.create(); + snapshot.projectionPathMask->transform(skInverseTransform, localMask); + localProjectionPathMask = localMask; + } } } @@ -73,13 +86,15 @@ ResolvedRenderState::ResolvedRenderState(LinearAllocator& allocator, Snapshot& s : transform(*snapshot.transform) , clipState(snapshot.mutateClipArea().serializeClip(allocator)) , clippedBounds(clipState->rect) - , clipSideFlags(OpClipSideFlags::Full) {} + , clipSideFlags(OpClipSideFlags::Full) + , localProjectionPathMask(nullptr) {} ResolvedRenderState::ResolvedRenderState(const ClipRect* clipRect, const Rect& dstRect) : transform(Matrix4::identity()) , clipState(clipRect) , clippedBounds(dstRect) - , clipSideFlags(computeClipSideFlags(clipRect->rect, dstRect)) { + , clipSideFlags(computeClipSideFlags(clipRect->rect, dstRect)) + , localProjectionPathMask(nullptr) { clippedBounds.doIntersect(clipRect->rect); } diff --git a/libs/hwui/BakedOpState.h b/libs/hwui/BakedOpState.h index 4365ef870ddaa..ffe2901782a9d 100644 --- a/libs/hwui/BakedOpState.h +++ b/libs/hwui/BakedOpState.h @@ -88,6 +88,7 @@ public: const ClipBase* clipState = nullptr; Rect clippedBounds; int clipSideFlags = 0; + const SkPath* localProjectionPathMask = nullptr; }; /** @@ -154,7 +155,6 @@ public: // simple state (straight pointer/value storage): const float alpha; const RoundRectClipState* roundRectClipState; - const ProjectionPathMask* projectionPathMask; const RecordedOp* op; private: @@ -165,21 +165,18 @@ private: : computedState(allocator, snapshot, recordedOp, expandForStroke) , alpha(snapshot.alpha) , roundRectClipState(snapshot.roundRectClipState) - , projectionPathMask(snapshot.projectionPathMask) , op(&recordedOp) {} BakedOpState(LinearAllocator& allocator, Snapshot& snapshot, const ShadowOp* shadowOpPtr) : computedState(allocator, snapshot) , alpha(snapshot.alpha) , roundRectClipState(snapshot.roundRectClipState) - , projectionPathMask(snapshot.projectionPathMask) , op(shadowOpPtr) {} BakedOpState(const ClipRect* clipRect, const Rect& dstRect, const RecordedOp& recordedOp) : computedState(clipRect, dstRect) , alpha(1.0f) , roundRectClipState(nullptr) - , projectionPathMask(nullptr) , op(&recordedOp) {} }; diff --git a/libs/hwui/FrameBuilder.cpp b/libs/hwui/FrameBuilder.cpp index 04de98afe85c7..b586a0109c276 100644 --- a/libs/hwui/FrameBuilder.cpp +++ b/libs/hwui/FrameBuilder.cpp @@ -389,34 +389,31 @@ void FrameBuilder::deferShadow(const RenderNodeOp& casterNodeOp) { } void FrameBuilder::deferProjectedChildren(const RenderNode& renderNode) { - const SkPath* projectionReceiverOutline = renderNode.properties().getOutline().getPath(); int count = mCanvasState.save(SaveFlags::MatrixClip); + const SkPath* projectionReceiverOutline = renderNode.properties().getOutline().getPath(); - // can't be null, since DL=null node rejection happens before deferNodePropsAndOps - const DisplayList& displayList = *(renderNode.getDisplayList()); + SkPath transformedMaskPath; // on stack, since BakedOpState makes a deep copy + if (projectionReceiverOutline) { + // transform the mask for this projector into render target space + // TODO: consider combining both transforms by stashing transform instead of applying + SkMatrix skCurrentTransform; + mCanvasState.currentTransform()->copyTo(skCurrentTransform); + projectionReceiverOutline->transform( + skCurrentTransform, + &transformedMaskPath); + mCanvasState.setProjectionPathMask(mAllocator, &transformedMaskPath); + } - const RecordedOp* op = (displayList.getOps()[displayList.projectionReceiveIndex]); - const RenderNodeOp* backgroundOp = static_cast(op); - const RenderProperties& backgroundProps = backgroundOp->renderNode->properties(); - - // Transform renderer to match background we're projecting onto - // (by offsetting canvas by translationX/Y of background rendernode, since only those are set) - mCanvasState.translate(backgroundProps.getTranslationX(), backgroundProps.getTranslationY()); - - // If the projection receiver has an outline, we mask projected content to it - // (which we know, apriori, are all tessellated paths) - mCanvasState.setProjectionPathMask(mAllocator, projectionReceiverOutline); - - // draw projected nodes for (size_t i = 0; i < renderNode.mProjectedNodes.size(); i++) { RenderNodeOp* childOp = renderNode.mProjectedNodes[i]; - int restoreTo = mCanvasState.save(SaveFlags::Matrix); + + // Apply transform between ancestor and projected descendant mCanvasState.concatMatrix(childOp->transformFromCompositingAncestor); + deferRenderNodeOpImpl(*childOp); mCanvasState.restoreToCount(restoreTo); } - mCanvasState.restoreToCount(count); } diff --git a/libs/hwui/LayerBuilder.cpp b/libs/hwui/LayerBuilder.cpp index bc39621f2cb21..c5af279653d9d 100644 --- a/libs/hwui/LayerBuilder.cpp +++ b/libs/hwui/LayerBuilder.cpp @@ -140,7 +140,10 @@ public: // Identical round rect clip state means both ops will clip in the same way, or not at all. // As the state objects are const, we can compare their pointers to determine mergeability if (lhs->roundRectClipState != rhs->roundRectClipState) return false; - if (lhs->projectionPathMask != rhs->projectionPathMask) return false; + + // Local masks prevent merge, since they're potentially in different coordinate spaces + if (lhs->computedState.localProjectionPathMask + || rhs->computedState.localProjectionPathMask) return false; /* Clipping compatibility check * diff --git a/libs/hwui/OpenGLRenderer.cpp b/libs/hwui/OpenGLRenderer.cpp index b7a5923cdd651..7693fdcbe817c 100644 --- a/libs/hwui/OpenGLRenderer.cpp +++ b/libs/hwui/OpenGLRenderer.cpp @@ -1148,7 +1148,9 @@ bool OpenGLRenderer::storeDisplayState(DeferredDisplayState& state, int stateDef // always store/restore, since these are just pointers state.mRoundRectClipState = currentSnapshot()->roundRectClipState; +#if !HWUI_NEW_OPS state.mProjectionPathMask = currentSnapshot()->projectionPathMask; +#endif return false; } @@ -1156,7 +1158,9 @@ void OpenGLRenderer::restoreDisplayState(const DeferredDisplayState& state, bool setGlobalMatrix(state.mMatrix); writableSnapshot()->alpha = state.mAlpha; writableSnapshot()->roundRectClipState = state.mRoundRectClipState; +#if !HWUI_NEW_OPS writableSnapshot()->projectionPathMask = state.mProjectionPathMask; +#endif if (state.mClipValid && !skipClipRestore) { writableSnapshot()->setClip(state.mClip.left, state.mClip.top, @@ -1833,6 +1837,7 @@ void OpenGLRenderer::drawCircle(float x, float y, float radius, const SkPaint* p path.addCircle(x, y, radius); } +#if !HWUI_NEW_OPS if (CC_UNLIKELY(currentSnapshot()->projectionPathMask != nullptr)) { // mask ripples with projection mask SkPath maskPath = *(currentSnapshot()->projectionPathMask->projectionMask); @@ -1852,6 +1857,7 @@ void OpenGLRenderer::drawCircle(float x, float y, float radius, const SkPaint* p // in local space. Note that this can create CCW paths. Op(path, maskPath, kIntersect_SkPathOp, &path); } +#endif drawConvexPath(path, p); } diff --git a/libs/hwui/Snapshot.cpp b/libs/hwui/Snapshot.cpp index 27fea1ff59f50..cf5e69a1e6ae2 100644 --- a/libs/hwui/Snapshot.cpp +++ b/libs/hwui/Snapshot.cpp @@ -146,6 +146,9 @@ void Snapshot::resetTransform(float x, float y, float z) { } void Snapshot::buildScreenSpaceTransform(Matrix4* outTransform) const { +#if HWUI_NEW_OPS + LOG_ALWAYS_FATAL("not supported - not needed by new ops"); +#else // build (reverse ordered) list of the stack of snapshots, terminated with a NULL Vector snapshotList; snapshotList.push(nullptr); @@ -171,6 +174,7 @@ void Snapshot::buildScreenSpaceTransform(Matrix4* outTransform) const { outTransform->multiply(*(current->transform)); } } +#endif } /////////////////////////////////////////////////////////////////////////////// @@ -223,15 +227,19 @@ void Snapshot::setClippingRoundRect(LinearAllocator& allocator, const Rect& boun } void Snapshot::setProjectionPathMask(LinearAllocator& allocator, const SkPath* path) { +#if HWUI_NEW_OPS + // TODO: remove allocator param for HWUI_NEW_OPS + projectionPathMask = path; +#else if (path) { ProjectionPathMask* mask = new (allocator) ProjectionPathMask; mask->projectionMask = path; buildScreenSpaceTransform(&(mask->projectionMaskTransform)); - projectionPathMask = mask; } else { projectionPathMask = nullptr; } +#endif } /////////////////////////////////////////////////////////////////////////////// diff --git a/libs/hwui/Snapshot.h b/libs/hwui/Snapshot.h index b03643f06f1c6..3a01d049109cb 100644 --- a/libs/hwui/Snapshot.h +++ b/libs/hwui/Snapshot.h @@ -63,6 +63,7 @@ public: float radius; }; +// TODO: remove for HWUI_NEW_OPS class ProjectionPathMask { public: static void* operator new(size_t size) = delete; @@ -219,6 +220,7 @@ public: * Fills outTransform with the current, total transform to screen space, * across layer boundaries. */ + // TODO: remove for HWUI_NEW_OPS void buildScreenSpaceTransform(Matrix4* outTransform) const; /** @@ -294,9 +296,13 @@ public: const RoundRectClipState* roundRectClipState; /** - * Current projection masking path - used exclusively to mask tessellated circles. + * Current projection masking path - used exclusively to mask projected, tessellated circles. */ +#if HWUI_NEW_OPS + const SkPath* projectionPathMask; +#else const ProjectionPathMask* projectionPathMask; +#endif void dump() const; diff --git a/libs/hwui/tests/unit/FrameBuilderTests.cpp b/libs/hwui/tests/unit/FrameBuilderTests.cpp index f86898fd669ad..aedef53c7cb38 100644 --- a/libs/hwui/tests/unit/FrameBuilderTests.cpp +++ b/libs/hwui/tests/unit/FrameBuilderTests.cpp @@ -990,21 +990,26 @@ TEST(FrameBuilder, projectionReorder) { EXPECT_EQ(Rect(100, 100), op.unmappedBounds); EXPECT_EQ(SK_ColorWHITE, op.paint->getColor()); expectedMatrix.loadIdentity(); + EXPECT_EQ(nullptr, state.computedState.localProjectionPathMask); break; case 1: EXPECT_EQ(Rect(-10, -10, 60, 60), op.unmappedBounds); EXPECT_EQ(SK_ColorDKGRAY, op.paint->getColor()); - expectedMatrix.loadTranslate(50, 50, 0); // TODO: should scroll be respected here? + expectedMatrix.loadTranslate(50 - scrollX, 50 - scrollY, 0); + ASSERT_NE(nullptr, state.computedState.localProjectionPathMask); + EXPECT_EQ(Rect(-35, -30, 45, 50), + Rect(state.computedState.localProjectionPathMask->getBounds())); break; case 2: EXPECT_EQ(Rect(100, 50), op.unmappedBounds); EXPECT_EQ(SK_ColorBLUE, op.paint->getColor()); expectedMatrix.loadTranslate(-scrollX, 50 - scrollY, 0); + EXPECT_EQ(nullptr, state.computedState.localProjectionPathMask); break; default: ADD_FAILURE(); } - EXPECT_MATRIX_APPROX_EQ(expectedMatrix, state.computedState.transform); + EXPECT_EQ(expectedMatrix, state.computedState.transform); } }; @@ -1045,6 +1050,9 @@ TEST(FrameBuilder, projectionReorder) { }); auto parent = TestUtils::createNode(0, 0, 100, 100, [&receiverBackground, &child](RenderProperties& properties, RecordingCanvas& canvas) { + // Set a rect outline for the projecting ripple to be masked against. + properties.mutableOutline().setRoundRect(10, 10, 90, 90, 5, 1.0f); + canvas.save(SaveFlags::MatrixClip); canvas.translate(-scrollX, -scrollY); // Apply scroll (note: bg undoes this internally) canvas.drawRenderNode(receiverBackground.get()); @@ -1059,6 +1067,92 @@ TEST(FrameBuilder, projectionReorder) { EXPECT_EQ(3, renderer.getIndex()); } +RENDERTHREAD_TEST(FrameBuilder, projectionHwLayer) { + static const int scrollX = 5; + static const int scrollY = 10; + class ProjectionHwLayerTestRenderer : public TestRendererBase { + public: + void startRepaintLayer(OffscreenBuffer* offscreenBuffer, const Rect& repaintRect) override { + EXPECT_EQ(0, mIndex++); + } + void onArcOp(const ArcOp& op, const BakedOpState& state) override { + EXPECT_EQ(1, mIndex++); + ASSERT_EQ(nullptr, state.computedState.localProjectionPathMask); + } + void endLayer() override { + EXPECT_EQ(2, mIndex++); + } + void onRectOp(const RectOp& op, const BakedOpState& state) override { + EXPECT_EQ(3, mIndex++); + ASSERT_EQ(nullptr, state.computedState.localProjectionPathMask); + } + void onOvalOp(const OvalOp& op, const BakedOpState& state) override { + EXPECT_EQ(4, mIndex++); + ASSERT_NE(nullptr, state.computedState.localProjectionPathMask); + Matrix4 expected; + expected.loadTranslate(100 - scrollX, 100 - scrollY, 0); + EXPECT_EQ(expected, state.computedState.transform); + EXPECT_EQ(Rect(-85, -80, 295, 300), + Rect(state.computedState.localProjectionPathMask->getBounds())); + } + void onLayerOp(const LayerOp& op, const BakedOpState& state) override { + EXPECT_EQ(5, mIndex++); + ASSERT_EQ(nullptr, state.computedState.localProjectionPathMask); + } + }; + auto receiverBackground = TestUtils::createNode(0, 0, 400, 400, + [](RenderProperties& properties, RecordingCanvas& canvas) { + properties.setProjectionReceiver(true); + // scroll doesn't apply to background, so undone via translationX/Y + // NOTE: translationX/Y only! no other transform properties may be set for a proj receiver! + properties.setTranslationX(scrollX); + properties.setTranslationY(scrollY); + + canvas.drawRect(0, 0, 400, 400, SkPaint()); + }); + auto projectingRipple = TestUtils::createNode(0, 0, 200, 200, + [](RenderProperties& properties, RecordingCanvas& canvas) { + properties.setProjectBackwards(true); + properties.setClipToBounds(false); + canvas.drawOval(100, 100, 300, 300, SkPaint()); // drawn mostly out of layer bounds + }); + auto child = TestUtils::createNode(100, 100, 300, 300, + [&projectingRipple](RenderProperties& properties, RecordingCanvas& canvas) { + properties.mutateLayerProperties().setType(LayerType::RenderLayer); + canvas.drawRenderNode(projectingRipple.get()); + canvas.drawArc(0, 0, 200, 200, 0.0f, 280.0f, true, SkPaint()); + }); + auto parent = TestUtils::createNode(0, 0, 400, 400, + [&receiverBackground, &child](RenderProperties& properties, RecordingCanvas& canvas) { + // Set a rect outline for the projecting ripple to be masked against. + properties.mutableOutline().setRoundRect(10, 10, 390, 390, 0, 1.0f); + canvas.translate(-scrollX, -scrollY); // Apply scroll (note: bg undoes this internally) + canvas.drawRenderNode(receiverBackground.get()); + canvas.drawRenderNode(child.get()); + }); + + OffscreenBuffer** layerHandle = child->getLayerHandle(); + + // create RenderNode's layer here in same way prepareTree would, setting windowTransform + OffscreenBuffer layer(renderThread.renderState(), Caches::getInstance(), 200, 200); + Matrix4 windowTransform; + windowTransform.loadTranslate(100, 100, 0); // total transform of layer's origin + layer.setWindowTransform(windowTransform); + *layerHandle = &layer; + + auto syncedList = TestUtils::createSyncedNodeList(parent); + LayerUpdateQueue layerUpdateQueue; // Note: enqueue damage post-sync, so bounds are valid + layerUpdateQueue.enqueueLayerWithDamage(child.get(), Rect(200, 200)); + FrameBuilder frameBuilder(layerUpdateQueue, SkRect::MakeWH(400, 400), 400, 400, + syncedList, sLightGeometry, nullptr); + ProjectionHwLayerTestRenderer renderer; + frameBuilder.replayBakedOps(renderer); + EXPECT_EQ(6, renderer.getIndex()); + + // clean up layer pointer, so we can safely destruct RenderNode + *layerHandle = nullptr; +} + // creates a 100x100 shadow casting node with provided translationZ static sp createWhiteRectShadowCaster(float translationZ) { return TestUtils::createNode(0, 0, 100, 100, diff --git a/tests/HwAccelerationTest/res/layout/projection_clipping.xml b/tests/HwAccelerationTest/res/layout/projection_clipping.xml index 1f2b93946f487..1ea9f9cd49f6b 100644 --- a/tests/HwAccelerationTest/res/layout/projection_clipping.xml +++ b/tests/HwAccelerationTest/res/layout/projection_clipping.xml @@ -3,24 +3,32 @@ xmlns:android="http://schemas.android.com/apk/res/android" android:layout_width="match_parent" android:layout_height="match_parent"> - - - - + + + + + + From a748c08241e43fc68c7c34767d819aef5183936e Mon Sep 17 00:00:00 2001 From: Chris Craik Date: Tue, 1 Mar 2016 18:48:37 -0800 Subject: [PATCH 2/2] Fix ripple positioning within scrolled node bug:27275799 Skip applying clip/matrix properties from projected node op, since we don't want to respect the clip, and matrix is already baked into transformFromCompositedAncestor. This skips op clips in the new pipeline, and fixed a double application of scroll on ripple backgrounds. Change-Id: I4f72448fe0463ab666564ca538d8b6bf525d98de --- libs/hwui/FrameBuilder.cpp | 17 +++++-- libs/hwui/tests/unit/FrameBuilderTests.cpp | 53 ++++++++++++++++++++++ 2 files changed, 65 insertions(+), 5 deletions(-) diff --git a/libs/hwui/FrameBuilder.cpp b/libs/hwui/FrameBuilder.cpp index b586a0109c276..1bc37e25f934b 100644 --- a/libs/hwui/FrameBuilder.cpp +++ b/libs/hwui/FrameBuilder.cpp @@ -406,13 +406,20 @@ void FrameBuilder::deferProjectedChildren(const RenderNode& renderNode) { for (size_t i = 0; i < renderNode.mProjectedNodes.size(); i++) { RenderNodeOp* childOp = renderNode.mProjectedNodes[i]; - int restoreTo = mCanvasState.save(SaveFlags::Matrix); + RenderNode& childNode = *childOp->renderNode; - // Apply transform between ancestor and projected descendant - mCanvasState.concatMatrix(childOp->transformFromCompositingAncestor); + // Draw child if it has content, but ignore state in childOp - matrix already applied to + // transformFromCompositingAncestor, and record-time clip is ignored when projecting + if (!childNode.nothingToDraw()) { + int restoreTo = mCanvasState.save(SaveFlags::MatrixClip); - deferRenderNodeOpImpl(*childOp); - mCanvasState.restoreToCount(restoreTo); + // Apply transform between ancestor and projected descendant + mCanvasState.concatMatrix(childOp->transformFromCompositingAncestor); + + deferNodePropsAndOps(childNode); + + mCanvasState.restoreToCount(restoreTo); + } } mCanvasState.restoreToCount(count); } diff --git a/libs/hwui/tests/unit/FrameBuilderTests.cpp b/libs/hwui/tests/unit/FrameBuilderTests.cpp index aedef53c7cb38..8802d07de7536 100644 --- a/libs/hwui/tests/unit/FrameBuilderTests.cpp +++ b/libs/hwui/tests/unit/FrameBuilderTests.cpp @@ -1153,6 +1153,59 @@ RENDERTHREAD_TEST(FrameBuilder, projectionHwLayer) { *layerHandle = nullptr; } +RENDERTHREAD_TEST(FrameBuilder, projectionChildScroll) { + static const int scrollX = 500000; + static const int scrollY = 0; + class ProjectionChildScrollTestRenderer : public TestRendererBase { + public: + void onRectOp(const RectOp& op, const BakedOpState& state) override { + EXPECT_EQ(0, mIndex++); + EXPECT_TRUE(state.computedState.transform.isIdentity()); + } + void onOvalOp(const OvalOp& op, const BakedOpState& state) override { + EXPECT_EQ(1, mIndex++); + ASSERT_NE(nullptr, state.computedState.clipState); + ASSERT_EQ(ClipMode::Rectangle, state.computedState.clipState->mode); + ASSERT_EQ(Rect(400, 400), state.computedState.clipState->rect); + EXPECT_TRUE(state.computedState.transform.isIdentity()); + } + }; + auto receiverBackground = TestUtils::createNode(0, 0, 400, 400, + [](RenderProperties& properties, RecordingCanvas& canvas) { + properties.setProjectionReceiver(true); + canvas.drawRect(0, 0, 400, 400, SkPaint()); + }); + auto projectingRipple = TestUtils::createNode(0, 0, 200, 200, + [](RenderProperties& properties, RecordingCanvas& canvas) { + // scroll doesn't apply to background, so undone via translationX/Y + // NOTE: translationX/Y only! no other transform properties may be set for a proj receiver! + properties.setTranslationX(scrollX); + properties.setTranslationY(scrollY); + properties.setProjectBackwards(true); + properties.setClipToBounds(false); + canvas.drawOval(0, 0, 200, 200, SkPaint()); + }); + auto child = TestUtils::createNode(0, 0, 400, 400, + [&projectingRipple](RenderProperties& properties, RecordingCanvas& canvas) { + // Record time clip will be ignored by projectee + canvas.clipRect(100, 100, 300, 300, SkRegion::kIntersect_Op); + + canvas.translate(-scrollX, -scrollY); // Apply scroll (note: bg undoes this internally) + canvas.drawRenderNode(projectingRipple.get()); + }); + auto parent = TestUtils::createNode(0, 0, 400, 400, + [&receiverBackground, &child](RenderProperties& properties, RecordingCanvas& canvas) { + canvas.drawRenderNode(receiverBackground.get()); + canvas.drawRenderNode(child.get()); + }); + + FrameBuilder frameBuilder(sEmptyLayerUpdateQueue, SkRect::MakeWH(400, 400), 400, 400, + TestUtils::createSyncedNodeList(parent), sLightGeometry, nullptr); + ProjectionChildScrollTestRenderer renderer; + frameBuilder.replayBakedOps(renderer); + EXPECT_EQ(2, renderer.getIndex()); +} + // creates a 100x100 shadow casting node with provided translationZ static sp createWhiteRectShadowCaster(float translationZ) { return TestUtils::createNode(0, 0, 100, 100,