From 328fc724a64ead26f13ccb97d1ce6075b6fee173 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20Davidovi=C4=87?= Date: Thu, 25 Jul 2024 00:26:41 +0200 Subject: [PATCH] Apply blend modes on layer level and add Multiply blend mode (#2519) This commit improves blend mode support in lottie-android in two ways: * Applying blend modes on layer-level, instead of fill level * Adding support for the Multiply blend mode ## Applying blend modes on layer level The Lottie format defines blend modes as attributes on a layer. However, lottie-android is presently applying the layer blend modes on a solid color fill only. Notably, this causes any stroked or gradient-filled shapes or image layers to blend incorrectly, such as in this file: [stroke-blending-test.json](https://github.com/user-attachments/files/16346206/stroke-blending-test.json) (The file contains a filled + stroked shape that renders as a pink square on other platforms, but renders with a visible stroke on lottie-android since its blend mode is applied only on the fill.) Instead, we move this decision to `BaseLayer` by analogy to transparent layer handling, which is closer to how the format specifies the property and fixes these cases. ## Multiply support `BlendModeCompat` is designed to resolve to either a `BlendMode` (added in Android Q, supporting most modern blend modes) or `PorterDuff.Mode` (always available, but a smaller choice of modes as it is mostly focused on alpha compositing). We use `BlendModeCompat` to support Lottie layer blend modes (`bm` key) to ensure compatibility on all platforms. For consistency, we don't support values which don't have a `PorterDuff.Mode` equivalent. Our support for Lottie blend modes did not include Multiply due to a slightly different behavior between the `PorterDuff.MULTIPLY` (exposed as `BlendModeCompat.MODULATE`) and `BlendModeCompat.MULTIPLY` variants. Namely, the formula used for `PorterDuff.MODULATE`, combined with alpha-premultiplication done by Skia, means that a layer with an alpha < 1.0 and multiply blend mode will also darken the destination: ![Incorrect-Blend](https://github.com/user-attachments/assets/6a2113ef-4bac-4bbc-830b-1353adf4ee2b) (Multiply-blended layers with < 1.0 alpha on the left, Screen-blended layers with < 1.0 alpha on the right) However, what we can do instead is clear the canvas with a solid white color instead of transparent before drawing the layer's contents as normal. When blending the resulting bitmap over an opaque background using `PorterDuff.MULTIPLY` (i.e. `BlendModeCompat.MODULATE`), the end result will be as if we had used `BlendModeCompat.MULTIPLY`, since all-1.0 (white) is a multiplication identity: ![Correct-Blend](https://github.com/user-attachments/assets/126022ef-6e47-48ee-b803-1d9800ca2c75) This PR implements the latter solution and adds a consistent support for the Multiply blend mode for all Android versions. *Test file used:* [blendmode-tests-multiply+screen+bg.zip](https://github.com/user-attachments/files/16365843/blendmode-tests-multiply%2Bscreen%2Bbg.zip) --- .../lottie/animation/content/FillContent.java | 3 --- .../lottie/model/content/LBlendMode.java | 15 ++++++++++- .../airbnb/lottie/model/layer/BaseLayer.java | 25 +++++++++++++++++-- 3 files changed, 37 insertions(+), 6 deletions(-) diff --git a/lottie/src/main/java/com/airbnb/lottie/animation/content/FillContent.java b/lottie/src/main/java/com/airbnb/lottie/animation/content/FillContent.java index 1690989c05..8dc05b877c 100644 --- a/lottie/src/main/java/com/airbnb/lottie/animation/content/FillContent.java +++ b/lottie/src/main/java/com/airbnb/lottie/animation/content/FillContent.java @@ -11,7 +11,6 @@ import android.graphics.RectF; import androidx.annotation.Nullable; -import androidx.core.graphics.PaintCompat; import com.airbnb.lottie.L; import com.airbnb.lottie.LottieDrawable; @@ -68,8 +67,6 @@ public FillContent(final LottieDrawable lottieDrawable, BaseLayer layer, ShapeFi return; } - PaintCompat.setBlendMode(paint, layer.getBlendMode().toNativeBlendMode()); - path.setFillType(fill.getFillType()); colorAnimation = fill.getColor().createAnimation(); diff --git a/lottie/src/main/java/com/airbnb/lottie/model/content/LBlendMode.java b/lottie/src/main/java/com/airbnb/lottie/model/content/LBlendMode.java index e1c91a0acd..4a4012b05d 100644 --- a/lottie/src/main/java/com/airbnb/lottie/model/content/LBlendMode.java +++ b/lottie/src/main/java/com/airbnb/lottie/model/content/LBlendMode.java @@ -33,6 +33,20 @@ public BlendModeCompat toNativeBlendMode() { switch (this) { case NORMAL: return null; + case MULTIPLY: + // BlendModeCompat.MULTIPLY does not exist on Android < Q. Instead, there's + // BlendModeCompat.MODULATE, which maps to PorterDuff.Mode.MODULATE and not + // PorterDuff.Mode.MULTIPLY. + // + // MODULATE differs from MULTIPLY in that it doesn't perform + // any alpha blending. It just does a component-wise multiplication + // of the colors. + // + // For proper results on all platforms, we will map the MULTIPLY + // blend mode to MODULATE, and then do a slight adjustment to + // how we render such layers to still achieve the correct result. + // See BaseLayer.draw(). + return BlendModeCompat.MODULATE; case SCREEN: return BlendModeCompat.SCREEN; case OVERLAY: @@ -48,7 +62,6 @@ public BlendModeCompat toNativeBlendMode() { // To prevent unexpected issues where animations look correct // during development but silently break for users with older devices // we won't support any of these until Q is widely used. - case MULTIPLY: case COLOR_DODGE: case COLOR_BURN: case HARD_LIGHT: diff --git a/lottie/src/main/java/com/airbnb/lottie/model/layer/BaseLayer.java b/lottie/src/main/java/com/airbnb/lottie/model/layer/BaseLayer.java index 5a4afc01b8..5e8c48953a 100644 --- a/lottie/src/main/java/com/airbnb/lottie/model/layer/BaseLayer.java +++ b/lottie/src/main/java/com/airbnb/lottie/model/layer/BaseLayer.java @@ -14,6 +14,7 @@ import androidx.annotation.CallSuper; import androidx.annotation.FloatRange; import androidx.annotation.Nullable; +import androidx.core.graphics.PaintCompat; import com.airbnb.lottie.L; import com.airbnb.lottie.LottieComposition; @@ -117,6 +118,8 @@ static BaseLayer forModel( float blurMaskFilterRadius = 0f; @Nullable BlurMaskFilter blurMaskFilter; + @Nullable LPaint solidWhitePaint; + BaseLayer(LottieDrawable lottieDrawable, Layer layerModel) { this.lottieDrawable = lottieDrawable; this.layerModel = layerModel; @@ -258,7 +261,7 @@ public void draw(Canvas canvas, Matrix parentMatrix, int parentAlpha) { } } int alpha = (int) ((parentAlpha / 255f * (float) opacity / 100f) * 255); - if (!hasMatteOnThisLayer() && !hasMasksOnThisLayer()) { + if (!hasMatteOnThisLayer() && !hasMasksOnThisLayer() && getBlendMode() == LBlendMode.NORMAL) { matrix.preConcat(transform.getMatrix()); if (L.isTraceEnabled()) { L.beginSection("Layer#drawLayer"); @@ -307,13 +310,31 @@ public void draw(Canvas canvas, Matrix parentMatrix, int parentAlpha) { L.beginSection("Layer#saveLayer"); } contentPaint.setAlpha(255); + PaintCompat.setBlendMode(contentPaint, getBlendMode().toNativeBlendMode()); Utils.saveLayerCompat(canvas, rect, contentPaint); if (L.isTraceEnabled()) { L.endSection("Layer#saveLayer"); } // Clear the off screen buffer. This is necessary for some phones. - clearCanvas(canvas); + if (getBlendMode() != LBlendMode.MULTIPLY) { + clearCanvas(canvas); + } else { + // Due to the difference between PorterDuffMode.MULTIPLY (which we use for compatibility + // with Android < Q) and BlendMode.MULTIPLY (which is the correct, alpha-blended mode), + // we will alpha-blend the contents of this layer on top of a white background before + // we multiply it with the opaque substrate below (with canvas.restore()). + // + // Since white is the identity color for multiplication, this will behave as if we + // had correctly performed an alpha-blended multiply (such as BlendMode.MULTIPLY), but + // will work pre-Q as well. + if (solidWhitePaint == null) { + solidWhitePaint = new LPaint(); + solidWhitePaint.setColor(0xffffffff); + } + canvas.drawRect(rect.left - 1, rect.top - 1, rect.right + 1, rect.bottom + 1, solidWhitePaint); + } + if (L.isTraceEnabled()) { L.beginSection("Layer#drawLayer"); }