-
-
Notifications
You must be signed in to change notification settings - Fork 5.4k
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Drop shadow overhaul: improve correctness and performance #2548
Conversation
paint.setAlpha(clamp(alpha, 0, 255)); | ||
float strokeAlpha = opacityAnimation.getValue() / 100f; | ||
int alpha = (int) (parentAlpha * strokeAlpha); | ||
alpha = clamp(alpha, 0, 255); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Applying this clamp right away, here and elsewhere, fixes issues with >255 or <0 alpha values, which caused shadows to not appear.
Such alpha values are possible when interpolating using an easing that evaluates to <0 or >1 at some point along its curve.
getBounds(offScreenRectF, matrix, true); | ||
offScreenPaint.setAlpha(layerAlpha); | ||
Utils.saveLayerCompat(canvas, offScreenRectF, offScreenPaint); | ||
getBounds(offScreenRectF, parentMatrix, true); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This one got lost in the diff, but is very important - matrix
was being passed here, causing incorrect transformations being applied when applyOpacitiesToLayers
is true; see the precomp_opacity_killer.json
testcase.
@@ -17,6 +17,8 @@ | |||
<attr name="lottie_imageAssetsFolder" format="string" /> | |||
<attr name="lottie_progress" format="float" /> | |||
<attr name="lottie_enableMergePathsForKitKatAndAbove" format="boolean" /> | |||
<attr name="lottie_applyOpacityToLayers" format="boolean" /> |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
this was missing for applyOpacityToLayers
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Wow, this is really amazing work. I really appreciate it and also how this can directly lead to further improvements for mattes and masks.
I left a few comments but a couple of broad themes:
- Take a look at the warnings in Android Studio and try to make them all go away. I try to keep the gutter "green" so that it's easy to spot new issues
- There are a bunch of new allocations in the hot path. Calls to getMatrix() are one example but there are also RectFs, float arrays, etc. It would be great to keep the hot path clean of allocations
- Run the Android Studio formatter on the new files to fix a few consistency issues
Looking forward to working with you to get this in!
lottie/src/main/java/com/airbnb/lottie/utils/OffscreenLayer.java
Outdated
Show resolved
Hide resolved
lottie/src/main/java/com/airbnb/lottie/utils/OffscreenLayer.java
Outdated
Show resolved
Hide resolved
lottie/src/main/java/com/airbnb/lottie/utils/OffscreenLayer.java
Outdated
Show resolved
Hide resolved
lottie/src/main/java/com/airbnb/lottie/utils/OffscreenLayer.java
Outdated
Show resolved
Hide resolved
lottie/src/main/java/com/airbnb/lottie/utils/OffscreenLayer.java
Outdated
Show resolved
Hide resolved
lottie/src/main/java/com/airbnb/lottie/utils/OffscreenLayer.java
Outdated
Show resolved
Hide resolved
lottie/src/main/java/com/airbnb/lottie/utils/OffscreenLayer.java
Outdated
Show resolved
Hide resolved
lottie/src/main/java/com/airbnb/lottie/animation/content/ContentGroup.java
Show resolved
Hide resolved
Also, I'm not sure why the snapshot diff against master is so big but if you compare it to 453b43c which is at You can see a much smaller snapshot diff. I just scanned it and the diffs look good to me! Feel free to scan them yourself as well. |
@gpeal Thanks for the review, Gabriel! Addressed all of the comments. The snapshot diffs look fine to me as well, but I'd also like to see another run with the latest round of changes, to ensure nothing was regressed accidentally. |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This is really great work! One small comment to clean up some code and then rebase and I'm excited to see the snapshot diffs.
Run ./gradlew apiDump
and it'll update the .api
file and CI will pass. Again, I need to figure out a sustainable way to maintain abi backwards compatibility but I don't want to block this PR on that.
if (needNewBitmap(bitmap, scaledBounds)) { | ||
if (bitmap != null) { | ||
deallocateBitmap(bitmap); | ||
} | ||
bitmap = allocateBitmap(scaledBounds, Bitmap.Config.ARGB_8888); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
if (needNewBitmap(bitmap, scaledBounds)) { | |
if (bitmap != null) { | |
deallocateBitmap(bitmap); | |
} | |
bitmap = allocateBitmap(scaledBounds, Bitmap.Config.ARGB_8888); | |
if (needNewBitmap(bitmap, scaledBounds)) { | |
if (bitmap != null) { | |
deallocateBitmap(bitmap); | |
} | |
bitmap = allocateBitmap(scaledBounds, Bitmap.Config.ARGB_8888); |
I think it would be great if you could encapsulate the needNew
+ deallocate
+ allocate
into a single helper that returns the bitmap.
Then, in each of the functions that calls the new function (something like getBitmap(Rect)
, it would just use the return value as a local variable rather than using fields on the object
This commit introduces a large change to how drop shadows are rendered, introducing an `applyShadowsToLayers` flag which, by analogy to `applyOpacitiesToLayers`, allows layers to be treated as a whole for the purposes of drop shadows, improving the accuracy and bringing lottie-android in line with other renderers (lottie-web and lottie-ios). Several different codepaths for different hardware/software combinations are introduced to ensure the fastest rendering available, even on legacy devices. The calculation of shadow direction with respect to transforms is improved so that the output matches lottie-web and lottie-ios. Image layers now cast shadows correctly thanks to a workaround to device-specific issues when combining `Paint.setShadowLayer()` and bitmap rendering. Even in non-`applyShadowsToLayers` mode, correctness is improved by allowing the shadow-to-be-applied to propagate in a similar way as alpha. This allows some amount of visual fidelity to be recovered for animations or environments where enabling `applyShadowsToLayers` is not possible. A number of issues that caused incorrect rendering in some other cases have been fixed.
When rendering a view, the Android graphics stack, by convention, gives it a canvas where the transformation to put the view in the right position is applied. It also allows the view to draw with its intrinsic width and height in pixels, and the canvas has an inverse scaling transform to draw to the final surface with the right size. Thus, the transform matrices we work with during hardware rendering exclude this inverse scaling. In software rendering mode, we bake this scaling into the root matrix, which causes different values to appear in software vs hardware rendering, which: * Has an impact on the final image due to e.g. integer rounding * Makes debugging HW/SW differences trickier, as values are different. To make the modes closer, "unbake" the pre-existing scale of the canvas from the initial root matrix. To mitigate risk, this is only done for scale, and not for the full matrix itself, as there is code which might interact with other types of transforms in unexpected ways. (Whereas scaling is a "safer" one.) This can be done in the future to achieve 100% equal values between the two modes.
`ImageLayer.getBounds()` works with a parent-to-world matrix called `parentMatrix`, but `ImageLayer.drawLayer()` gets called with layer-to-world as `parentMatrix`, despite the name. (See call to `drawLayer() `in `BaseLayer.draw()`). This caused `getBounds()` to apply the layer's own transform effectively two times and the returned bounds to be incorrect. With non-shadowed layers, this was masked away by the fact that `Canvas.saveLayer()` appears to ignore the provided bounds Rect, and allows anything drawn anywhere on the canvas to appear after a corresponding `canvas.restore()`. On top of this, we erroneously multiplied the returned `bounds` by the density again, which meant that the final bitmap was "overprovisioned" by a factor of 2-3 (depending on the device's pixel density). In most cases, and combined with an erroneous order of operations when applying matrices to the original and off-screen canvases, this was enough to hide the problem even on shadowed layers. Fix by: * Not relying on `getBounds()`, as we don't have a correct `parentMatrix` to feed it. * Correcting the order of operations, so that we respect the convention of not applying any matrix to the canvas before calling `OffscreenLayer.start()`, which simplifies the code and allows us to use the same pattern as other invocations of `OffscreenLayer`. * Getting rid of the unnecessary scale by `density` and then normalization back, borne out of a misunderstanding of why `density` is a factor in this calculation. (It effectively gets applied to *all* values somewhere in the pipeline, and has nothing to do with images per se.) Drive-by fix `getBounds()` itself to respect `maintainOriginalImageBounds`.
In this case, we need to calculate the actual bounds of the children layers, and pass that as the clip rectangle to both `OffscreenLayer` and `Canvas.clipRect()`.
Commit 9527586 introduced an issue with transformation order, causing View-wide translations to be misapplied in software rendering.
Second strike... third strike I just remove it
Mostly seen on small renders.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This is truly fantastic work. I genuinely thank you for the hours you put into this. This is, without any doubt, the largest external contribution to this repo ever and it looks great. I hope we can build on this to get render node wins for mattes and masks.
High-level summary
This PR introduces a large change to how drop shadows are rendered, introducing an
applyShadowsToLayers
flag which, by analogy toapplyOpacitiesToLayers
, allows layers to be treated as a whole for the purposes of drop shadows, improving the accuracy and bringing lottie-android in line with other renderers (lottie-web and lottie-ios).Several different codepaths for different hardware/software combinations are introduced to ensure the fastest rendering available, even on legacy devices.
The calculation of shadow direction with respect to transforms is improved so that the output matches lottie-web and lottie-ios.
Image layers now cast shadows correctly thanks to a workaround to device-specific issues when combining
Paint.setShadowLayer()
and bitmap rendering.Even in non-
applyShadowsToLayers
mode, correctness is improved by allowing the shadow-to-be-applied to propagate in a similar way as alpha. This allows some amount of visual fidelity to be recovered for animations or environments where enablingapplyShadowsToLayers
is not possible.A number of issues that caused incorrect rendering in some other cases have been fixed.
Background
Drop shadows in Lottie
Lottie specifies drop shadows as a tuple of (angle, distance, radius, color, alpha), with each element being animatable.
The consensus behavior for the rendering of a layer with a drop shadow, which seems to be mostly respected in lottie-web and lottie-ios, seems to be:
theta
), distance (d
), radius (r
), color with alpha (C
).So
(original layer).So
to new surfaceSs
(shadow).r' = c * r
toSs
, wherec
is some platform-specific constant intended to normalize blur implementations between platforms. (Ours is 0.33, lottie-web's is 0.25; see Apply scaling factor to drop shadow softness #2541).Ss
with the color and combine the alpha by applying the following for each pixelP
:P.rgb = C.rgb * P.a; P.a = C.a * P.a
.Ss
, and needs to be drawn into its final position.theta
andd
intodx
anddy
, with the 0 position at 12 o'clock:dx = d * cos(theta - pi/2); dy = d*sin(theta - pi/2)
.Ss
ontoSi
(intermediate surface) with a translation of(dx, dy)
.So
(original layer) ontoSi
with identity transform.Si
into the framebuffer using the layer's alpha and blend mode.Some non-obvious consequences of the definition above:
Drop shadows in lottie-android currently
lottie-android's current implementation of drop shadows differs in important ways:
setShadowLayer()
simply doesn't work for images consistently. (See the last image in Improve drop shadow effect accuracy #2523 (comment).)Contributions of this PR
This PR introduces the following improvements and additions.
OffscreenLayer
implementation, which serves as an abstraction that can replacecanvas.saveLayer()
for off-screen rendering and composition onto the final bitmap, but with the important distinction that it can also handle drop shadows, and possibly use hardware-acceleratedRenderNode
s andRenderEffects
where available.OffscreenLayer
, call its.start()
method with a parent canvas and aComposeOp
, and draw on the returned canvas. Once finished, callOffscreenLayer.finish()
to compose everything from the returned canvas to the parent canvas, applying alpha, blend mode, drop shadows, and color filters.OffscreenLayer
makes a dynamic decision on what to use for rendering - a no-op, forward to.saveLayer()
, a HW-acceleratedRenderNode
, or a software bitmap, depending on the requestedComposeOp
and hardware/SDK support.OffscreenLayer
becomes a useful abstraction that can be extended to e.g. support hardware blurs, multiple drop shadows, or to support mattes in a hardware-accelerated fashion where possible.applyShadowsToLayers
flag which, by analogy toapplyOpacityToLayers
, turns on a more accurate mode that implements the drop shadow algorithm described above.OffscreenLayer
is used to apply alpha ifapplyOpacityToLayers
is enabled, and to apply shadows ifapplyShadowsToLayers
is enabled. The cost is paid only once if both alpha and drop shadows are present on a layer.saveLayer()
calls in the code have been rewritten to useOffscreenLayer
- the blast radius is minimized.OffscreenLayer
is presently used only to apply alpha and drop shadows, and blend mode and color filters are still applied inBaseLayer
usingsaveLayer()
directly.applyShadowsToLayers
isfalse
: we plumb the shadow through.draw()
anddrawLayer()
calls similarly to alpha, and this allows us to render per-shape shadows on children of composition layers too.OffscreenLayer
as well, and image layers now render shadows properly in all cases.Open questions
applyShadowsToLayers
betrue
by default? Some codepaths, such as when rendering purely via software, can be slow if shadow-casting layers are exceedingly large. But, the performance is still acceptable, and in the vast majority of cases everything is quite snappy.applyShadowsToLayers
plus an old device should trigger the purely-software shadow rendering mode. Simulating this in condition manually yields accurate results, and the performance seems surprisingly good, but it's unclear what will happen on a lower-end phone. There's also always the possibility of some device subtlety being missed. I don't have access to an older Android device.Testcases
These files now match between lottie-web and lottie-android:
drop_shadow_comparator.json
simple_shadow_casters_ll2.json
The files from this earlier PR still all render the same: #2523, with the exception of the fix for image layer bug, which fixes the rendering of the Map icon as mentioned in the comment of that PR.
This file has been used as a perf stress test with many <255 opacity precomps, some stacked inside each other, that must all be blended separately: precomp_opacity_killer.json