[skottie] Effects cleanup pass

Split-off effects into separate CUs.

TBR=
Change-Id: Ic214027d27e1c341085d9a8c74f605caba260630
Reviewed-on: https://skia-review.googlesource.com/c/skia/+/221118
Reviewed-by: Florin Malita <fmalita@chromium.org>
Commit-Queue: Florin Malita <fmalita@chromium.org>
diff --git a/modules/skottie/skottie.gni b/modules/skottie/skottie.gni
index 342860b..7d67294 100644
--- a/modules/skottie/skottie.gni
+++ b/modules/skottie/skottie.gni
@@ -20,7 +20,6 @@
   "$_src/SkottieJson.cpp",
   "$_src/SkottieJson.h",
   "$_src/SkottieLayer.cpp",
-  "$_src/SkottieLayerEffect.cpp",
   "$_src/SkottiePriv.h",
   "$_src/SkottiePrecompLayer.cpp",
   "$_src/SkottieProperty.cpp",
@@ -28,9 +27,16 @@
   "$_src/SkottieValue.cpp",
   "$_src/SkottieValue.h",
 
+  "$_src/effects/DropShadowEffect.cpp",
   "$_src/effects/Effects.cpp",
   "$_src/effects/Effects.h",
+  "$_src/effects/FillEffect.cpp",
+  "$_src/effects/GaussianBlurEffect.cpp",
+  "$_src/effects/GradientEffect.cpp",
+  "$_src/effects/LevelsEffect.cpp",
+  "$_src/effects/TintEffect.cpp",
   "$_src/effects/TransformEffect.cpp",
+  "$_src/effects/TritoneEffect.cpp",
 
   "$_src/text/RangeSelector.cpp",
   "$_src/text/RangeSelector.h",
diff --git a/modules/skottie/src/SkottieAdapter.cpp b/modules/skottie/src/SkottieAdapter.cpp
index b2b799d..3c5f2ac 100644
--- a/modules/skottie/src/SkottieAdapter.cpp
+++ b/modules/skottie/src/SkottieAdapter.cpp
@@ -12,18 +12,15 @@
 #include "include/core/SkMatrix44.h"
 #include "include/core/SkPath.h"
 #include "include/core/SkRRect.h"
-#include "include/effects/SkTableColorFilter.h"
 #include "include/private/SkTo.h"
 #include "include/utils/Sk3D.h"
 #include "modules/skottie/src/SkottieValue.h"
-#include "modules/sksg/include/SkSGColorFilter.h"
 #include "modules/sksg/include/SkSGDraw.h"
 #include "modules/sksg/include/SkSGGradient.h"
 #include "modules/sksg/include/SkSGGroup.h"
 #include "modules/sksg/include/SkSGPaint.h"
 #include "modules/sksg/include/SkSGPath.h"
 #include "modules/sksg/include/SkSGRect.h"
-#include "modules/sksg/include/SkSGRenderEffect.h"
 #include "modules/sksg/include/SkSGTransform.h"
 #include "modules/sksg/include/SkSGTrimEffect.h"
 
@@ -288,58 +285,6 @@
     grad->setEndRadius(SkPoint::Distance(this->startPoint(), this->endPoint()));
 }
 
-GradientRampEffectAdapter::GradientRampEffectAdapter(sk_sp<sksg::RenderNode> child)
-    : fRoot(sksg::ShaderEffect::Make(std::move(child))) {}
-
-GradientRampEffectAdapter::~GradientRampEffectAdapter() = default;
-
-void GradientRampEffectAdapter::apply() {
-    // This adapter manages a SG fragment with the following structure:
-    //
-    // - ShaderEffect [fRoot]
-    //     \  GradientShader [fGradient]
-    //     \  child/wrapped fragment
-    //
-    // The gradient shader is updated based on the (animatable) intance type (linear/radial).
-
-    auto update_gradient = [this] (InstanceType new_type) {
-        if (new_type != fInstanceType) {
-            fGradient = new_type == InstanceType::kLinear
-                    ? sk_sp<sksg::Gradient>(sksg::LinearGradient::Make())
-                    : sk_sp<sksg::Gradient>(sksg::RadialGradient::Make());
-
-            fRoot->setShader(fGradient);
-            fInstanceType = new_type;
-        }
-
-        fGradient->setColorStops({ {0, fStartColor}, {1, fEndColor} });
-    };
-
-    static constexpr int kLinearShapeValue = 1;
-    const auto instance_type = (SkScalarRoundToInt(fShape) == kLinearShapeValue)
-            ? InstanceType::kLinear
-            : InstanceType::kRadial;
-
-    // Sync the gradient shader instance if needed.
-    update_gradient(instance_type);
-
-    // Sync instance-dependent gradient params.
-    if (instance_type == InstanceType::kLinear) {
-        auto* lg = static_cast<sksg::LinearGradient*>(fGradient.get());
-        lg->setStartPoint(fStartPoint);
-        lg->setEndPoint(fEndPoint);
-    } else {
-        SkASSERT(instance_type == InstanceType::kRadial);
-
-        auto* rg = static_cast<sksg::RadialGradient*>(fGradient.get());
-        rg->setStartCenter(fStartPoint);
-        rg->setEndCenter(fStartPoint);
-        rg->setEndRadius(SkPoint::Distance(fStartPoint, fEndPoint));
-    }
-
-    // TODO: blend, scatter
-}
-
 TrimEffectAdapter::TrimEffectAdapter(sk_sp<sksg::TrimEffect> trimEffect)
     : fTrimEffect(std::move(trimEffect)) {
     SkASSERT(fTrimEffect);
@@ -376,158 +321,4 @@
     fTrimEffect->setMode(mode);
 }
 
-DropShadowEffectAdapter::DropShadowEffectAdapter(sk_sp<sksg::DropShadowImageFilter> dropShadow)
-    : fDropShadow(std::move(dropShadow)) {
-    SkASSERT(fDropShadow);
-}
-
-DropShadowEffectAdapter::~DropShadowEffectAdapter() = default;
-
-void DropShadowEffectAdapter::apply() {
-    // fColor -> RGB, fOpacity -> A
-    fDropShadow->setColor(SkColorSetA(fColor, SkTPin(SkScalarRoundToInt(fOpacity), 0, 255)));
-
-    // The offset is specified in terms of a bearing angle + distance.
-    SkScalar rad = SkDegreesToRadians(90 - fDirection);
-    fDropShadow->setOffset(SkVector::Make( fDistance * SkScalarCos(rad),
-                                          -fDistance * SkScalarSin(rad)));
-
-    // Close enough to AE.
-    static constexpr SkScalar kSoftnessToSigmaFactor = 0.3f;
-    const auto sigma = fSoftness * kSoftnessToSigmaFactor;
-    fDropShadow->setSigma(SkVector::Make(sigma, sigma));
-
-    fDropShadow->setMode(fShadowOnly ? sksg::DropShadowImageFilter::Mode::kShadowOnly
-                                     : sksg::DropShadowImageFilter::Mode::kShadowAndForeground);
-}
-
-GaussianBlurEffectAdapter::GaussianBlurEffectAdapter(sk_sp<sksg::BlurImageFilter> blur)
-    : fBlur(std::move(blur)) {
-    SkASSERT(fBlur);
-}
-
-GaussianBlurEffectAdapter::~GaussianBlurEffectAdapter() = default;
-
-void GaussianBlurEffectAdapter::apply() {
-    static constexpr SkVector kDimensionsMap[] = {
-        { 1, 1 }, // 1 -> horizontal and vertical
-        { 1, 0 }, // 2 -> horizontal
-        { 0, 1 }, // 3 -> vertical
-    };
-
-    const auto dim_index = SkTPin<size_t>(static_cast<size_t>(fDimensions),
-                                          1, SK_ARRAY_COUNT(kDimensionsMap)) - 1;
-
-    // Close enough to AE.
-    static constexpr SkScalar kBlurrinessToSigmaFactor = 0.3f;
-    const auto sigma = fBlurriness * kBlurrinessToSigmaFactor;
-
-    fBlur->setSigma({ sigma * kDimensionsMap[dim_index].x(),
-                      sigma * kDimensionsMap[dim_index].y() });
-
-    static constexpr SkBlurImageFilter::TileMode kRepeatEdgeMap[] = {
-        SkBlurImageFilter::kClampToBlack_TileMode, // 0 -> repeat edge pixels: off
-        SkBlurImageFilter::       kClamp_TileMode, // 1 -> repeat edge pixels: on
-    };
-
-    const auto repeat_index = SkTPin<size_t>(static_cast<size_t>(fRepeatEdge),
-                                             0, SK_ARRAY_COUNT(kRepeatEdgeMap) - 1);
-    fBlur->setTileMode(kRepeatEdgeMap[repeat_index]);
-}
-
-
-// Levels color correction effect.
-//
-// Maps the selected channels from [inBlack...inWhite] to [outBlack, outWhite],
-// based on a gamma exponent.
-//
-// For [i0..i1] -> [o0..o1]:
-//
-//   c' = o0 + (o1 - o0) * ((c - i0) / (i1 - i0)) ^ G
-//
-// The output is optionally clipped to the output range.
-//
-// In/out intervals are clampped to [0..1].  Inversion is allowed.
-LevelsEffectAdapter::LevelsEffectAdapter(sk_sp<sksg::RenderNode> child)
-    : fEffect(sksg::ExternalColorFilter::Make(std::move(child))) {
-    SkASSERT(fEffect);
-}
-
-LevelsEffectAdapter::~LevelsEffectAdapter() = default;
-
-void LevelsEffectAdapter::apply() {
-    enum LottieChannel {
-        kRGB_Channel = 1,
-          kR_Channel = 2,
-          kG_Channel = 3,
-          kB_Channel = 4,
-          kA_Channel = 5,
-    };
-
-    const auto channel = SkScalarTruncToInt(fChannel);
-    if (channel < kRGB_Channel || channel > kA_Channel) {
-        fEffect->setColorFilter(nullptr);
-        return;
-    }
-
-    auto in_0 = SkTPin(fInBlack,  0.0f, 1.0f),
-         in_1 = SkTPin(fInWhite,  0.0f, 1.0f),
-        out_0 = SkTPin(fOutBlack, 0.0f, 1.0f),
-        out_1 = SkTPin(fOutWhite, 0.0f, 1.0f),
-            g = 1 / SkTMax(fGamma, 0.0f);
-
-    float clip[] = {0, 1};
-    const auto kLottieDoClip = 1;
-    if (SkScalarTruncToInt(fClipBlack) == kLottieDoClip) {
-        const auto idx = fOutBlack <= fOutWhite ? 0 : 1;
-        clip[idx] = out_0;
-    }
-    if (SkScalarTruncToInt(fClipWhite) == kLottieDoClip) {
-        const auto idx = fOutBlack <= fOutWhite ? 1 : 0;
-        clip[idx] = out_1;
-    }
-    SkASSERT(clip[0] <= clip[1]);
-
-    auto dIn  =  in_1 -  in_0,
-         dOut = out_1 - out_0;
-
-    if (SkScalarNearlyZero(dIn)) {
-        // Degenerate dIn == 0 makes the arithmetic below explode.
-        //
-        // We could specialize the builder to deal with that case, or we could just
-        // nudge by epsilon to make it all work.  The latter approach is simpler
-        // and doesn't have any noticeable downsides.
-        //
-        // Also nudge in_0 towards 0.5, in case it was sqashed against an extremity.
-        // This allows for some abrupt transition when the output interval is not
-        // collapsed, and produces results closer to AE.
-        static constexpr auto kEpsilon = 2 * SK_ScalarNearlyZero;
-        dIn  += std::copysign(kEpsilon, dIn);
-        in_0 += std::copysign(kEpsilon, .5f - in_0);
-        SkASSERT(!SkScalarNearlyZero(dIn));
-    }
-
-    uint8_t lut[256];
-
-    auto t =      -in_0 / dIn,
-        dT = 1 / 255.0f / dIn;
-
-    // TODO: is linear gamma common-enough to warrant a fast path?
-    for (size_t i = 0; i < 256; ++i) {
-        const auto out = out_0 + dOut * std::pow(std::max(t, 0.0f), g);
-        SkASSERT(!SkScalarIsNaN(out));
-
-        lut[i] = static_cast<uint8_t>(std::round(SkTPin(out, clip[0], clip[1]) * 255));
-
-        t += dT;
-    }
-
-    fEffect->setColorFilter(SkTableColorFilter::MakeARGB(
-        channel == kA_Channel                            ? lut : nullptr,
-        channel == kR_Channel || channel == kRGB_Channel ? lut : nullptr,
-        channel == kG_Channel || channel == kRGB_Channel ? lut : nullptr,
-        channel == kB_Channel || channel == kRGB_Channel ? lut : nullptr
-    ));
-}
-
 } // namespace skottie
diff --git a/modules/skottie/src/SkottieAdapter.h b/modules/skottie/src/SkottieAdapter.h
index 2619399..8cf11a8 100644
--- a/modules/skottie/src/SkottieAdapter.h
+++ b/modules/skottie/src/SkottieAdapter.h
@@ -234,37 +234,6 @@
     using INHERITED = GradientAdapter;
 };
 
-class GradientRampEffectAdapter final : public SkNVRefCnt<GradientRampEffectAdapter> {
-public:
-    explicit GradientRampEffectAdapter(sk_sp<sksg::RenderNode> child);
-    ~GradientRampEffectAdapter();
-
-    ADAPTER_PROPERTY(StartPoint, SkPoint , SkPoint::Make(0, 0))
-    ADAPTER_PROPERTY(EndPoint  , SkPoint , SkPoint::Make(0, 0))
-    ADAPTER_PROPERTY(StartColor, SkColor ,       SK_ColorBLACK)
-    ADAPTER_PROPERTY(EndColor  , SkColor ,       SK_ColorBLACK)
-    ADAPTER_PROPERTY(Blend     , SkScalar,                   0)
-    ADAPTER_PROPERTY(Scatter   , SkScalar,                   0)
-
-    // Really an enum: 1 -> linear, 7 -> radial (?!)
-    ADAPTER_PROPERTY(Shape     , SkScalar,                   0)
-
-    const sk_sp<sksg::ShaderEffect>& root() const { return fRoot; }
-
-private:
-    enum class InstanceType {
-        kNone,
-        kLinear,
-        kRadial,
-    };
-
-    void apply();
-
-    sk_sp<sksg::ShaderEffect> fRoot;
-    sk_sp<sksg::Gradient>     fGradient;
-    InstanceType              fInstanceType = InstanceType::kNone;
-};
-
 class TrimEffectAdapter final : public SkNVRefCnt<TrimEffectAdapter> {
 public:
     explicit TrimEffectAdapter(sk_sp<sksg::TrimEffect>);
@@ -280,79 +249,6 @@
     sk_sp<sksg::TrimEffect> fTrimEffect;
 };
 
-class DropShadowEffectAdapter final : public SkNVRefCnt<DropShadowEffectAdapter> {
-public:
-    explicit DropShadowEffectAdapter(sk_sp<sksg::DropShadowImageFilter>);
-    ~DropShadowEffectAdapter();
-
-    ADAPTER_PROPERTY(Color     , SkColor , SK_ColorBLACK)
-    ADAPTER_PROPERTY(Opacity   , SkScalar,           255)
-    ADAPTER_PROPERTY(Direction , SkScalar,             0)
-    ADAPTER_PROPERTY(Distance  , SkScalar,             0)
-    ADAPTER_PROPERTY(Softness  , SkScalar,             0)
-    ADAPTER_PROPERTY(ShadowOnly, bool    ,         false)
-
-private:
-    void apply();
-
-    const sk_sp<sksg::DropShadowImageFilter> fDropShadow;
-};
-
-class GaussianBlurEffectAdapter final : public SkNVRefCnt<GaussianBlurEffectAdapter> {
-public:
-    explicit GaussianBlurEffectAdapter(sk_sp<sksg::BlurImageFilter>);
-    ~GaussianBlurEffectAdapter();
-
-    // AE/BM model properties.  These are all animatable/interpolatable.
-
-    // Controls the blur sigma.
-    ADAPTER_PROPERTY(Blurriness, SkScalar, 0)
-
-    // Enum selecting the blur dimensionality:
-    //
-    //   1 -> horizontal & vertical
-    //   2 -> horizontal
-    //   3 -> vertical
-    //
-    ADAPTER_PROPERTY(Dimensions, SkScalar, 1)
-
-    // Enum selecting edge behavior:
-    //
-    //   0 -> clamp
-    //   1 -> repeat
-    //
-    ADAPTER_PROPERTY(RepeatEdge, SkScalar, 0)
-
-private:
-    void apply();
-
-    const sk_sp<sksg::BlurImageFilter> fBlur;
-};
-
-class LevelsEffectAdapter final : public SkNVRefCnt<LevelsEffectAdapter> {
-public:
-    explicit LevelsEffectAdapter(sk_sp<sksg::RenderNode> child);
-    ~LevelsEffectAdapter();
-
-    // 1: RGB, 2: R, 3: G, 4: B, 5: A
-    ADAPTER_PROPERTY(  Channel, SkScalar, 1)
-    ADAPTER_PROPERTY(  InBlack, SkScalar, 0)
-    ADAPTER_PROPERTY(  InWhite, SkScalar, 1)
-    ADAPTER_PROPERTY( OutBlack, SkScalar, 0)
-    ADAPTER_PROPERTY( OutWhite, SkScalar, 1)
-    ADAPTER_PROPERTY(    Gamma, SkScalar, 1)
-    // 1: clip, 2,3: don't clip
-    ADAPTER_PROPERTY(ClipBlack, SkScalar, 1)
-    ADAPTER_PROPERTY(ClipWhite, SkScalar, 1)
-
-    const sk_sp<sksg::ExternalColorFilter>& root() const { return fEffect; }
-
-private:
-    void apply();
-
-    sk_sp<sksg::ExternalColorFilter> fEffect;
-};
-
 } // namespace skottie
 
 #endif // SkottieAdapter_DEFINED
diff --git a/modules/skottie/src/SkottieLayer.cpp b/modules/skottie/src/SkottieLayer.cpp
index dde866c..22b02f2 100644
--- a/modules/skottie/src/SkottieLayer.cpp
+++ b/modules/skottie/src/SkottieLayer.cpp
@@ -15,6 +15,7 @@
 #include "modules/skottie/src/SkottieAdapter.h"
 #include "modules/skottie/src/SkottieJson.h"
 #include "modules/skottie/src/SkottieValue.h"
+#include "modules/skottie/src/effects/Effects.h"
 #include "modules/sksg/include/SkSGClipEffect.h"
 #include "modules/sksg/include/SkSGDraw.h"
 #include "modules/sksg/include/SkSGGroup.h"
@@ -625,7 +626,7 @@
 
     // Optional layer effects.
     if (const skjson::ArrayValue* jeffects = (*jlayer)["ef"]) {
-        layer = this->attachLayerEffects(*jeffects, &layer_animators, std::move(layer));
+        layer = EffectBuilder(this, &layer_animators).attachEffects(*jeffects, std::move(layer));
     }
 
     // Attach the transform after effects, when needed.
diff --git a/modules/skottie/src/SkottieLayerEffect.cpp b/modules/skottie/src/SkottieLayerEffect.cpp
deleted file mode 100644
index 995c4de..0000000
--- a/modules/skottie/src/SkottieLayerEffect.cpp
+++ /dev/null
@@ -1,485 +0,0 @@
-/*
- * Copyright 2018 Google Inc.
- *
- * Use of this source code is governed by a BSD-style license that can be
- * found in the LICENSE file.
- */
-
-#include "modules/skottie/src/effects/Effects.h"
-
-#include "modules/skottie/src/SkottieAdapter.h"
-#include "modules/skottie/src/SkottieJson.h"
-#include "modules/skottie/src/SkottieValue.h"
-#include "modules/sksg/include/SkSGColorFilter.h"
-#include "modules/sksg/include/SkSGPaint.h"
-#include "modules/sksg/include/SkSGRenderEffect.h"
-#include "src/utils/SkJSON.h"
-
-namespace skottie {
-namespace internal {
-
-namespace {
-
-sk_sp<sksg::RenderNode> AttachGradientLayerEffect(const skjson::ArrayValue& jprops,
-                                                  const AnimationBuilder* abuilder,
-                                                  AnimatorScope* ascope,
-                                                  sk_sp<sksg::RenderNode> layer) {
-    enum : size_t {
-        kStartPoint_Index  = 0,
-        kStartColor_Index  = 1,
-        kEndPoint_Index    = 2,
-        kEndColor_Index    = 3,
-        kRampShape_Index   = 4,
-        kRampScatter_Index = 5,
-        kBlendRatio_Index  = 6,
-
-        kMax_Index        = kBlendRatio_Index,
-    };
-
-    if (jprops.size() <= kMax_Index) {
-        return nullptr;
-    }
-
-    const skjson::ObjectValue* p0 = jprops[ kStartPoint_Index];
-    const skjson::ObjectValue* p1 = jprops[   kEndPoint_Index];
-    const skjson::ObjectValue* c0 = jprops[ kStartColor_Index];
-    const skjson::ObjectValue* c1 = jprops[   kEndColor_Index];
-    const skjson::ObjectValue* sh = jprops[  kRampShape_Index];
-    const skjson::ObjectValue* bl = jprops[ kBlendRatio_Index];
-    const skjson::ObjectValue* sc = jprops[kRampScatter_Index];
-
-    if (!p0 || !p1 || !c0 || !c1 || !sh || !bl || !sc) {
-        return nullptr;
-    }
-
-    auto adapter = sk_make_sp<GradientRampEffectAdapter>(std::move(layer));
-
-    abuilder->bindProperty<VectorValue>((*p0)["v"], ascope,
-        [adapter](const VectorValue& p0) {
-            adapter->setStartPoint(ValueTraits<VectorValue>::As<SkPoint>(p0));
-        });
-    abuilder->bindProperty<VectorValue>((*p1)["v"], ascope,
-        [adapter](const VectorValue& p1) {
-            adapter->setEndPoint(ValueTraits<VectorValue>::As<SkPoint>(p1));
-        });
-    abuilder->bindProperty<VectorValue>((*c0)["v"], ascope,
-        [adapter](const VectorValue& c0) {
-            adapter->setStartColor(ValueTraits<VectorValue>::As<SkColor>(c0));
-        });
-    abuilder->bindProperty<VectorValue>((*c1)["v"], ascope,
-        [adapter](const VectorValue& c1) {
-            adapter->setEndColor(ValueTraits<VectorValue>::As<SkColor>(c1));
-        });
-    abuilder->bindProperty<ScalarValue>((*sh)["v"], ascope,
-        [adapter](const ScalarValue& shape) {
-            adapter->setShape(shape);
-        });
-    abuilder->bindProperty<ScalarValue>((*sh)["v"], ascope,
-        [adapter](const ScalarValue& blend) {
-            adapter->setBlend(blend);
-        });
-    abuilder->bindProperty<ScalarValue>((*sc)["v"], ascope,
-        [adapter](const ScalarValue& scatter) {
-            adapter->setScatter(scatter);
-        });
-
-    return adapter->root();
-}
-
-sk_sp<sksg::RenderNode> AttachTintLayerEffect(const skjson::ArrayValue& jprops,
-                                              const AnimationBuilder* abuilder,
-                                              AnimatorScope* ascope,
-                                              sk_sp<sksg::RenderNode> layer) {
-    enum : size_t {
-        kMapBlackTo_Index = 0,
-        kMapWhiteTo_Index = 1,
-        kAmount_Index     = 2,
-        // kOpacity_Index    = 3, // currently unused (not exported)
-
-        kMax_Index        = kAmount_Index,
-    };
-
-    if (jprops.size() <= kMax_Index) {
-        return nullptr;
-    }
-
-    const skjson::ObjectValue* color0_prop = jprops[kMapBlackTo_Index];
-    const skjson::ObjectValue* color1_prop = jprops[kMapWhiteTo_Index];
-    const skjson::ObjectValue* amount_prop = jprops[    kAmount_Index];
-
-    if (!color0_prop || !color1_prop || !amount_prop) {
-        return nullptr;
-    }
-
-    auto tint_node =
-            sksg::GradientColorFilter::Make(std::move(layer),
-                                            abuilder->attachColor(*color0_prop, ascope, "v"),
-                                            abuilder->attachColor(*color1_prop, ascope, "v"));
-    if (!tint_node) {
-        return nullptr;
-    }
-
-    abuilder->bindProperty<ScalarValue>((*amount_prop)["v"], ascope,
-        [tint_node](const ScalarValue& w) {
-            tint_node->setWeight(w / 100); // 100-based
-        });
-
-    return std::move(tint_node);
-}
-
-sk_sp<sksg::RenderNode> AttachTritoneLayerEffect(const skjson::ArrayValue& jprops,
-                                                 const AnimationBuilder* abuilder,
-                                                 AnimatorScope* ascope,
-                                                 sk_sp<sksg::RenderNode> layer) {
-    enum : size_t {
-        kHiColor_Index     = 0,
-        kMiColor_Index     = 1,
-        kLoColor_Index     = 2,
-        kBlendAmount_Index = 3,
-
-        kMax_Index         = kBlendAmount_Index,
-    };
-
-    if (jprops.size() <= kMax_Index) {
-        return nullptr;
-    }
-
-    const skjson::ObjectValue* hicolor_prop = jprops[    kHiColor_Index];
-    const skjson::ObjectValue* micolor_prop = jprops[    kMiColor_Index];
-    const skjson::ObjectValue* locolor_prop = jprops[    kLoColor_Index];
-    const skjson::ObjectValue*   blend_prop = jprops[kBlendAmount_Index];
-
-    if (!hicolor_prop || !micolor_prop || !locolor_prop || !blend_prop) {
-        return nullptr;
-    }
-
-    auto tritone_node =
-            sksg::GradientColorFilter::Make(std::move(layer), {
-                                            abuilder->attachColor(*locolor_prop, ascope, "v"),
-                                            abuilder->attachColor(*micolor_prop, ascope, "v"),
-                                            abuilder->attachColor(*hicolor_prop, ascope, "v") });
-    if (!tritone_node) {
-        return nullptr;
-    }
-
-    abuilder->bindProperty<ScalarValue>((*blend_prop)["v"], ascope,
-        [tritone_node](const ScalarValue& w) {
-            tritone_node->setWeight((100 - w) / 100); // 100-based, inverted (!?).
-        });
-
-    return std::move(tritone_node);
-}
-
-sk_sp<sksg::RenderNode> AttachFillLayerEffect(const skjson::ArrayValue& jprops,
-                                              const AnimationBuilder* abuilder,
-                                              AnimatorScope* ascope,
-                                              sk_sp<sksg::RenderNode> layer) {
-    enum : size_t {
-        kFillMask_Index = 0,
-        kAllMasks_Index = 1,
-        kColor_Index    = 2,
-        kInvert_Index   = 3,
-        kHFeather_Index = 4,
-        kVFeather_Index = 5,
-        kOpacity_Index  = 6,
-
-        kMax_Index      = kOpacity_Index,
-    };
-
-    if (jprops.size() <= kMax_Index) {
-        return nullptr;
-    }
-
-    const skjson::ObjectValue*   color_prop = jprops[  kColor_Index];
-    const skjson::ObjectValue* opacity_prop = jprops[kOpacity_Index];
-    if (!color_prop || !opacity_prop) {
-        return nullptr;
-    }
-
-    sk_sp<sksg::Color> color_node = abuilder->attachColor(*color_prop, ascope, "v");
-    if (!color_node) {
-        return nullptr;
-    }
-
-    abuilder->bindProperty<ScalarValue>((*opacity_prop)["v"], ascope,
-        [color_node](const ScalarValue& o) {
-            const auto c = color_node->getColor();
-            const auto a = sk_float_round2int_no_saturate(SkTPin(o, 0.0f, 1.0f) * 255);
-            color_node->setColor(SkColorSetA(c, a));
-        });
-
-    return sksg::ModeColorFilter::Make(std::move(layer),
-                                       std::move(color_node),
-                                       SkBlendMode::kSrcIn);
-}
-
-sk_sp<sksg::RenderNode> AttachDropShadowLayerEffect(const skjson::ArrayValue& jprops,
-                                                    const AnimationBuilder* abuilder,
-                                                    AnimatorScope* ascope,
-                                                    sk_sp<sksg::RenderNode> layer) {
-    enum : size_t {
-        kShadowColor_Index = 0,
-        kOpacity_Index     = 1,
-        kDirection_Index   = 2,
-        kDistance_Index    = 3,
-        kSoftness_Index    = 4,
-        kShadowOnly_Index  = 5,
-
-        kMax_Index         = kShadowOnly_Index,
-    };
-
-    if (jprops.size() <= kMax_Index) {
-        return nullptr;
-    }
-
-    const skjson::ObjectValue*       color_prop = jprops[kShadowColor_Index];
-    const skjson::ObjectValue*     opacity_prop = jprops[    kOpacity_Index];
-    const skjson::ObjectValue*   direction_prop = jprops[  kDirection_Index];
-    const skjson::ObjectValue*    distance_prop = jprops[   kDistance_Index];
-    const skjson::ObjectValue*    softness_prop = jprops[   kSoftness_Index];
-    const skjson::ObjectValue* shadow_only_prop = jprops[ kShadowOnly_Index];
-
-    if (!color_prop ||
-        !opacity_prop ||
-        !direction_prop ||
-        !distance_prop ||
-        !softness_prop ||
-        !shadow_only_prop) {
-        return nullptr;
-    }
-
-    auto shadow_effect  = sksg::DropShadowImageFilter::Make();
-    auto shadow_adapter = sk_make_sp<DropShadowEffectAdapter>(shadow_effect);
-
-    abuilder->bindProperty<VectorValue>((*color_prop)["v"], ascope,
-        [shadow_adapter](const VectorValue& c) {
-            shadow_adapter->setColor(ValueTraits<VectorValue>::As<SkColor>(c));
-        });
-    abuilder->bindProperty<ScalarValue>((*opacity_prop)["v"], ascope,
-        [shadow_adapter](const ScalarValue& o) {
-            shadow_adapter->setOpacity(o);
-        });
-    abuilder->bindProperty<ScalarValue>((*direction_prop)["v"], ascope,
-        [shadow_adapter](const ScalarValue& d) {
-            shadow_adapter->setDirection(d);
-        });
-    abuilder->bindProperty<ScalarValue>((*distance_prop)["v"], ascope,
-        [shadow_adapter](const ScalarValue& d) {
-            shadow_adapter->setDistance(d);
-        });
-    abuilder->bindProperty<ScalarValue>((*softness_prop)["v"], ascope,
-        [shadow_adapter](const ScalarValue& s) {
-            shadow_adapter->setSoftness(s);
-        });
-    abuilder->bindProperty<ScalarValue>((*shadow_only_prop)["v"], ascope,
-        [shadow_adapter](const ScalarValue& s) {
-            shadow_adapter->setShadowOnly(SkToBool(s));
-        });
-
-    return sksg::ImageFilterEffect::Make(std::move(layer), std::move(shadow_effect));
-}
-
-sk_sp<sksg::RenderNode> AttachGaussianBlurLayerEffect(const skjson::ArrayValue& jprops,
-                                                      const AnimationBuilder* abuilder,
-                                                      AnimatorScope* ascope,
-                                                      sk_sp<sksg::RenderNode> layer) {
-    enum : size_t {
-        kBlurriness_Index = 0,
-        kDimensions_Index = 1,
-        kRepeatEdge_Index = 2,
-
-        kMax_Index        = kRepeatEdge_Index,
-    };
-
-    if (jprops.size() <= kMax_Index) {
-        return nullptr;
-    }
-
-    const skjson::ObjectValue* blurriness_prop = jprops[kBlurriness_Index];
-    const skjson::ObjectValue* dimensions_prop = jprops[kDimensions_Index];
-    const skjson::ObjectValue* repeatedge_prop = jprops[kRepeatEdge_Index];
-
-    if (!blurriness_prop || !dimensions_prop || !repeatedge_prop) {
-        return nullptr;
-    }
-
-    auto blur_effect   = sksg::BlurImageFilter::Make();
-    auto blur_addapter = sk_make_sp<GaussianBlurEffectAdapter>(blur_effect);
-
-    abuilder->bindProperty<ScalarValue>((*blurriness_prop)["v"], ascope,
-        [blur_addapter](const ScalarValue& b) {
-            blur_addapter->setBlurriness(b);
-        });
-    abuilder->bindProperty<ScalarValue>((*dimensions_prop)["v"], ascope,
-        [blur_addapter](const ScalarValue& d) {
-            blur_addapter->setDimensions(d);
-        });
-    abuilder->bindProperty<ScalarValue>((*repeatedge_prop)["v"], ascope,
-        [blur_addapter](const ScalarValue& r) {
-            blur_addapter->setRepeatEdge(r);
-        });
-
-    return sksg::ImageFilterEffect::Make(std::move(layer), std::move(blur_effect));
-}
-
-sk_sp<sksg::RenderNode> AttachLevelsLayerEffect(const skjson::ArrayValue& jprops,
-                                                const AnimationBuilder* abuilder,
-                                                AnimatorScope* ascope,
-                                                sk_sp<sksg::RenderNode> layer) {
-    enum : size_t {
-        kChannel_Index        = 0,
-        // ???                = 1,
-        kInputBlack_Index     = 2,
-        kInputWhite_Index     = 3,
-        kGamma_Index          = 4,
-        kOutputBlack_Index    = 5,
-        kOutputWhite_Index    = 6,
-        kClipToOutBlack_Index = 7,
-        kClipToOutWhite_Index = 8,
-
-        kMax_Index        = kClipToOutWhite_Index,
-    };
-
-    if (jprops.size() <= kMax_Index) {
-        return nullptr;
-    }
-
-    const skjson::ObjectValue*    channel_prop = jprops[       kChannel_Index];
-    const skjson::ObjectValue*     iblack_prop = jprops[    kInputBlack_Index];
-    const skjson::ObjectValue*     iwhite_prop = jprops[    kInputWhite_Index];
-    const skjson::ObjectValue*      gamma_prop = jprops[         kGamma_Index];
-    const skjson::ObjectValue*     oblack_prop = jprops[   kOutputBlack_Index];
-    const skjson::ObjectValue*     owhite_prop = jprops[   kOutputWhite_Index];
-    const skjson::ObjectValue* clip_black_prop = jprops[kClipToOutBlack_Index];
-    const skjson::ObjectValue* clip_white_prop = jprops[kClipToOutWhite_Index];
-
-    if (!channel_prop || !iblack_prop || !iwhite_prop || !gamma_prop ||
-        !oblack_prop || !owhite_prop || !clip_black_prop || !clip_white_prop) {
-        return nullptr;
-    }
-
-    auto adapter = sk_make_sp<LevelsEffectAdapter>(std::move(layer));
-
-    abuilder->bindProperty<ScalarValue>((*channel_prop)["v"], ascope,
-        [adapter](const ScalarValue& channel) {
-            adapter->setChannel(channel);
-        });
-    abuilder->bindProperty<ScalarValue>((*iblack_prop)["v"], ascope,
-        [adapter](const ScalarValue& ib) {
-            adapter->setInBlack(ib);
-        });
-    abuilder->bindProperty<ScalarValue>((*iwhite_prop)["v"], ascope,
-        [adapter](const ScalarValue& iw) {
-            adapter->setInWhite(iw);
-        });
-    abuilder->bindProperty<ScalarValue>((*oblack_prop)["v"], ascope,
-        [adapter](const ScalarValue& ob) {
-            adapter->setOutBlack(ob);
-        });
-    abuilder->bindProperty<ScalarValue>((*owhite_prop)["v"], ascope,
-        [adapter](const ScalarValue& ow) {
-            adapter->setOutWhite(ow);
-        });
-    abuilder->bindProperty<ScalarValue>((*gamma_prop)["v"], ascope,
-        [adapter](const ScalarValue& g) {
-            adapter->setGamma(g);
-        });
-
-    abuilder->bindProperty<ScalarValue>((*clip_black_prop)["v"], ascope,
-        [adapter](const ScalarValue& cb) {
-            adapter->setClipBlack(cb);
-        });
-    abuilder->bindProperty<ScalarValue>((*clip_white_prop)["v"], ascope,
-        [adapter](const ScalarValue& cw) {
-            adapter->setClipWhite(cw);
-        });
-
-    return adapter->root();
-}
-
-using EffectBuilderT = sk_sp<sksg::RenderNode> (*)(const skjson::ArrayValue&,
-                                                   const AnimationBuilder*,
-                                                   AnimatorScope*,
-                                                   sk_sp<sksg::RenderNode>);
-
-EffectBuilderT FindEffectBuilder(const AnimationBuilder* abuilder,
-                                 const skjson::ObjectValue& jeffect) {
-    // First, try assigned types.
-    enum : int32_t {
-        kTint_Effect         = 20,
-        kFill_Effect         = 21,
-        kTritone_Effect      = 23,
-        kDropShadow_Effect   = 25,
-        kGaussianBlur_Effect = 29,
-    };
-
-    const auto ty = ParseDefault<int>(jeffect["ty"], -1);
-
-    switch (ty) {
-    case kTint_Effect:
-        return AttachTintLayerEffect;
-    case kFill_Effect:
-        return AttachFillLayerEffect;
-    case kTritone_Effect:
-        return AttachTritoneLayerEffect;
-    case kDropShadow_Effect:
-        return AttachDropShadowLayerEffect;
-    case kGaussianBlur_Effect:
-        return AttachGaussianBlurLayerEffect;
-    default:
-        break;
-    }
-
-    // Some effects don't have an assigned type, but the data is still present.
-    // Try a name-based lookup.
-
-    if (const skjson::StringValue* mn = jeffect["mn"]) {
-        if (!strcmp(mn->begin(), "ADBE Ramp")) {
-            return AttachGradientLayerEffect;
-        }
-        if (!strcmp(mn->begin(), "ADBE Easy Levels2")) {
-            return AttachLevelsLayerEffect;
-        }
-        if (!strcmp(mn->begin(), "ADBE Geometry2")) {
-            return AttachTransformEffect;
-        }
-    }
-
-    abuilder->log(Logger::Level::kWarning, nullptr, "Unsupported layer effect type: %d.", ty);
-
-    return nullptr;
-}
-
-} // namespace
-
-sk_sp<sksg::RenderNode> AnimationBuilder::attachLayerEffects(const skjson::ArrayValue& jeffects,
-                                                             AnimatorScope* ascope,
-                                                             sk_sp<sksg::RenderNode> layer) const {
-    if (!layer) {
-        return nullptr;
-    }
-
-    for (const skjson::ObjectValue* jeffect : jeffects) {
-        if (!jeffect) {
-            continue;
-        }
-
-        const auto builder = FindEffectBuilder(this, *jeffect);
-        const skjson::ArrayValue* jprops = (*jeffect)["ef"];
-        if (!builder || !jprops) {
-            continue;
-        }
-
-        layer = builder(*jprops, this, ascope, std::move(layer));
-
-        if (!layer) {
-            this->log(Logger::Level::kError, jeffect, "Invalid layer effect.");
-            return nullptr;
-        }
-    }
-
-    return layer;
-}
-
-} // namespace internal
-} // namespace skottie
diff --git a/modules/skottie/src/SkottiePriv.h b/modules/skottie/src/SkottiePriv.h
index 44218f7..1bdd3a6 100644
--- a/modules/skottie/src/SkottiePriv.h
+++ b/modules/skottie/src/SkottiePriv.h
@@ -101,8 +101,6 @@
 
     sk_sp<sksg::RenderNode> attachComposition(const skjson::ObjectValue&, AnimatorScope*) const;
     sk_sp<sksg::RenderNode> attachLayer(const skjson::ObjectValue*, AttachLayerContext*) const;
-    sk_sp<sksg::RenderNode> attachLayerEffects(const skjson::ArrayValue& jeffects, AnimatorScope*,
-                                               sk_sp<sksg::RenderNode>) const;
 
     sk_sp<sksg::RenderNode> attachBlendMode(const skjson::ObjectValue&,
                                             sk_sp<sksg::RenderNode>) const;
diff --git a/modules/skottie/src/effects/DropShadowEffect.cpp b/modules/skottie/src/effects/DropShadowEffect.cpp
new file mode 100644
index 0000000..de5d3e8
--- /dev/null
+++ b/modules/skottie/src/effects/DropShadowEffect.cpp
@@ -0,0 +1,101 @@
+/*
+ * Copyright 2019 Google Inc.
+ *
+ * Use of this source code is governed by a BSD-style license that can be
+ * found in the LICENSE file.
+ */
+
+#include "modules/skottie/src/effects/Effects.h"
+
+#include "modules/skottie/src/SkottieAdapter.h"
+#include "modules/skottie/src/SkottieValue.h"
+#include "modules/sksg/include/SkSGRenderEffect.h"
+#include "src/utils/SkJSON.h"
+
+namespace skottie {
+namespace internal {
+
+namespace  {
+
+class DropShadowAdapter final : public SkNVRefCnt<DropShadowAdapter> {
+public:
+    explicit DropShadowAdapter(sk_sp<sksg::DropShadowImageFilter> dropShadow)
+        : fDropShadow(std::move(dropShadow)) {
+        SkASSERT(fDropShadow);
+    }
+
+    ADAPTER_PROPERTY(Color     , SkColor , SK_ColorBLACK)
+    ADAPTER_PROPERTY(Opacity   , SkScalar,           255)
+    ADAPTER_PROPERTY(Direction , SkScalar,             0)
+    ADAPTER_PROPERTY(Distance  , SkScalar,             0)
+    ADAPTER_PROPERTY(Softness  , SkScalar,             0)
+    ADAPTER_PROPERTY(ShadowOnly, bool    ,         false)
+
+private:
+    void apply() {
+        // fColor -> RGB, fOpacity -> A
+        fDropShadow->setColor(SkColorSetA(fColor, SkTPin(SkScalarRoundToInt(fOpacity), 0, 255)));
+
+        // The offset is specified in terms of a bearing angle + distance.
+        SkScalar rad = SkDegreesToRadians(90 - fDirection);
+        fDropShadow->setOffset(SkVector::Make( fDistance * SkScalarCos(rad),
+                                              -fDistance * SkScalarSin(rad)));
+
+        // Close enough to AE.
+        static constexpr SkScalar kSoftnessToSigmaFactor = 0.3f;
+        const auto sigma = fSoftness * kSoftnessToSigmaFactor;
+        fDropShadow->setSigma(SkVector::Make(sigma, sigma));
+
+        fDropShadow->setMode(fShadowOnly ? sksg::DropShadowImageFilter::Mode::kShadowOnly
+                                         : sksg::DropShadowImageFilter::Mode::kShadowAndForeground);
+    }
+
+    const sk_sp<sksg::DropShadowImageFilter> fDropShadow;
+};
+
+} // anonymous ns
+
+sk_sp<sksg::RenderNode> EffectBuilder::attachDropShadowEffect(const skjson::ArrayValue& jprops,
+                                                              sk_sp<sksg::RenderNode> layer) const {
+    enum : size_t {
+        kShadowColor_Index = 0,
+        kOpacity_Index     = 1,
+        kDirection_Index   = 2,
+        kDistance_Index    = 3,
+        kSoftness_Index    = 4,
+        kShadowOnly_Index  = 5,
+    };
+
+    auto shadow_effect  = sksg::DropShadowImageFilter::Make();
+    auto shadow_adapter = sk_make_sp<DropShadowAdapter>(shadow_effect);
+
+    fBuilder->bindProperty<VectorValue>(GetPropValue(jprops, kShadowColor_Index), fScope,
+        [shadow_adapter](const VectorValue& c) {
+            shadow_adapter->setColor(ValueTraits<VectorValue>::As<SkColor>(c));
+        });
+    fBuilder->bindProperty<ScalarValue>(GetPropValue(jprops, kOpacity_Index), fScope,
+        [shadow_adapter](const ScalarValue& o) {
+            shadow_adapter->setOpacity(o);
+        });
+    fBuilder->bindProperty<ScalarValue>(GetPropValue(jprops, kDirection_Index), fScope,
+        [shadow_adapter](const ScalarValue& d) {
+            shadow_adapter->setDirection(d);
+        });
+    fBuilder->bindProperty<ScalarValue>(GetPropValue(jprops, kDistance_Index), fScope,
+        [shadow_adapter](const ScalarValue& d) {
+            shadow_adapter->setDistance(d);
+        });
+    fBuilder->bindProperty<ScalarValue>(GetPropValue(jprops, kSoftness_Index), fScope,
+        [shadow_adapter](const ScalarValue& s) {
+            shadow_adapter->setSoftness(s);
+        });
+    fBuilder->bindProperty<ScalarValue>(GetPropValue(jprops, kShadowOnly_Index), fScope,
+        [shadow_adapter](const ScalarValue& s) {
+            shadow_adapter->setShadowOnly(SkToBool(s));
+        });
+
+    return sksg::ImageFilterEffect::Make(std::move(layer), std::move(shadow_effect));
+}
+
+} // namespace internal
+} // namespace skottie
diff --git a/modules/skottie/src/effects/Effects.cpp b/modules/skottie/src/effects/Effects.cpp
index f6c62c8..ea6fcdb 100644
--- a/modules/skottie/src/effects/Effects.cpp
+++ b/modules/skottie/src/effects/Effects.cpp
@@ -7,11 +7,96 @@
 
 #include "modules/skottie/src/effects/Effects.h"
 
+#include "modules/skottie/src/SkottieJson.h"
+#include "modules/sksg/include/SkSGRenderNode.h"
 #include "src/utils/SkJSON.h"
 
 namespace skottie {
 namespace internal {
 
+EffectBuilder::EffectBuilder(const AnimationBuilder* abuilder, AnimatorScope* ascope)
+    : fBuilder(abuilder)
+    , fScope(ascope) {}
+
+EffectBuilder::EffectBuilderT EffectBuilder::findBuilder(const skjson::ObjectValue& jeffect) const {
+    // First, try assigned types.
+    enum : int32_t {
+        kTint_Effect         = 20,
+        kFill_Effect         = 21,
+        kTritone_Effect      = 23,
+        kDropShadow_Effect   = 25,
+        kGaussianBlur_Effect = 29,
+    };
+
+    const auto ty = ParseDefault<int>(jeffect["ty"], -1);
+
+    switch (ty) {
+    case kTint_Effect:
+        return &EffectBuilder::attachTintEffect;
+    case kFill_Effect:
+        return &EffectBuilder::attachFillEffect;
+    case kTritone_Effect:
+        return &EffectBuilder::attachTritoneEffect;
+    case kDropShadow_Effect:
+        return &EffectBuilder::attachDropShadowEffect;
+    case kGaussianBlur_Effect:
+        return &EffectBuilder::attachGaussianBlurEffect;
+    default:
+        break;
+    }
+
+    // Some effects don't have an assigned type, but the data is still present.
+    // Try a name-based lookup.
+
+    static constexpr char kGradientEffectMN[] = "ADBE Ramp",
+                            kLevelsEffectMN[] = "ADBE Easy Levels2",
+                         kTransformEffectMN[] = "ADBE Geometry2";
+
+    if (const skjson::StringValue* mn = jeffect["mn"]) {
+        if (!strcmp(mn->begin(), kGradientEffectMN)) {
+            return &EffectBuilder::attachGradientEffect;
+        }
+        if (!strcmp(mn->begin(), kLevelsEffectMN)) {
+            return &EffectBuilder::attachLevelsEffect;
+        }
+        if (!strcmp(mn->begin(), kTransformEffectMN)) {
+            return &EffectBuilder::attachTransformEffect;
+        }
+    }
+
+    fBuilder->log(Logger::Level::kWarning, nullptr, "Unsupported layer effect type: %d.", ty);
+
+    return nullptr;
+}
+
+sk_sp<sksg::RenderNode> EffectBuilder::attachEffects(const skjson::ArrayValue& jeffects,
+                                                     sk_sp<sksg::RenderNode> layer) const {
+    if (!layer) {
+        return nullptr;
+    }
+
+    for (const skjson::ObjectValue* jeffect : jeffects) {
+        if (!jeffect) {
+            continue;
+        }
+
+        const auto builder = this->findBuilder(*jeffect);
+        const skjson::ArrayValue* jprops = (*jeffect)["ef"];
+        if (!builder || !jprops) {
+            continue;
+        }
+
+        layer = (this->*builder)(*jprops, std::move(layer));
+
+        if (!layer) {
+            fBuilder->log(Logger::Level::kError, jeffect, "Invalid layer effect.");
+            return nullptr;
+        }
+    }
+
+    return layer;
+}
+
 const skjson::Value& EffectBuilder::GetPropValue(const skjson::ArrayValue& jprops,
                                                  size_t prop_index) {
     static skjson::NullValue kNull;
diff --git a/modules/skottie/src/effects/Effects.h b/modules/skottie/src/effects/Effects.h
index f3d5883..a66aec6 100644
--- a/modules/skottie/src/effects/Effects.h
+++ b/modules/skottie/src/effects/Effects.h
@@ -13,20 +13,42 @@
 namespace skottie {
 namespace internal {
 
-class AnimationBuilder;
-
-// TODO: relocate SkottieLayerEffect builder logic here.
 class EffectBuilder final : public SkNoncopyable {
 public:
+    EffectBuilder(const AnimationBuilder*, AnimatorScope*);
+
+    sk_sp<sksg::RenderNode> attachEffects(const skjson::ArrayValue&,
+                                          sk_sp<sksg::RenderNode>) const;
+
+private:
+    using EffectBuilderT = sk_sp<sksg::RenderNode>(EffectBuilder::*)(const skjson::ArrayValue&,
+                                                                     sk_sp<sksg::RenderNode>) const;
+
+    sk_sp<sksg::RenderNode> attachTintEffect        (const skjson::ArrayValue&,
+                                                     sk_sp<sksg::RenderNode>) const;
+    sk_sp<sksg::RenderNode> attachFillEffect        (const skjson::ArrayValue&,
+                                                     sk_sp<sksg::RenderNode>) const;
+    sk_sp<sksg::RenderNode> attachTritoneEffect     (const skjson::ArrayValue&,
+                                                     sk_sp<sksg::RenderNode>) const;
+    sk_sp<sksg::RenderNode> attachDropShadowEffect  (const skjson::ArrayValue&,
+                                                     sk_sp<sksg::RenderNode>) const;
+    sk_sp<sksg::RenderNode> attachGaussianBlurEffect(const skjson::ArrayValue&,
+                                                     sk_sp<sksg::RenderNode>) const;
+    sk_sp<sksg::RenderNode> attachGradientEffect    (const skjson::ArrayValue&,
+                                                     sk_sp<sksg::RenderNode>) const;
+    sk_sp<sksg::RenderNode> attachLevelsEffect      (const skjson::ArrayValue&,
+                                                     sk_sp<sksg::RenderNode>) const;
+    sk_sp<sksg::RenderNode> attachTransformEffect   (const skjson::ArrayValue&,
+                                                     sk_sp<sksg::RenderNode>) const;
+
+    EffectBuilderT findBuilder(const skjson::ObjectValue&) const;
+
     static const skjson::Value& GetPropValue(const skjson::ArrayValue& jprops, size_t prop_index);
+
+    const AnimationBuilder*   fBuilder;
+    AnimatorScope*            fScope;
 };
 
-sk_sp<sksg::RenderNode> AttachTransformEffect(const skjson::ArrayValue&,
-                                              const AnimationBuilder*,
-                                              AnimatorScope*,
-                                              sk_sp<sksg::RenderNode>);
-
-
 } // namespace internal
 } // namespace skottie
 
diff --git a/modules/skottie/src/effects/FillEffect.cpp b/modules/skottie/src/effects/FillEffect.cpp
new file mode 100644
index 0000000..9a15618
--- /dev/null
+++ b/modules/skottie/src/effects/FillEffect.cpp
@@ -0,0 +1,59 @@
+/*
+ * Copyright 2019 Google Inc.
+ *
+ * Use of this source code is governed by a BSD-style license that can be
+ * found in the LICENSE file.
+ */
+
+#include "modules/skottie/src/effects/Effects.h"
+
+#include "modules/skottie/src/SkottieValue.h"
+#include "modules/sksg/include/SkSGColorFilter.h"
+#include "modules/sksg/include/SkSGPaint.h"
+#include "src/utils/SkJSON.h"
+
+namespace skottie {
+namespace internal {
+
+sk_sp<sksg::RenderNode> EffectBuilder::attachFillEffect(const skjson::ArrayValue& jprops,
+                                                        sk_sp<sksg::RenderNode> layer) const {
+    enum : size_t {
+        kFillMask_Index = 0,
+        kAllMasks_Index = 1,
+        kColor_Index    = 2,
+        kInvert_Index   = 3,
+        kHFeather_Index = 4,
+        kVFeather_Index = 5,
+        kOpacity_Index  = 6,
+
+        kMax_Index      = kOpacity_Index,
+    };
+
+    if (jprops.size() <= kMax_Index) {
+        return nullptr;
+    }
+
+    const skjson::ObjectValue*   color_prop = jprops[  kColor_Index];
+    const skjson::ObjectValue* opacity_prop = jprops[kOpacity_Index];
+    if (!color_prop || !opacity_prop) {
+        return nullptr;
+    }
+    sk_sp<sksg::Color> color_node = fBuilder->attachColor(*color_prop, fScope, "v");
+    if (!color_node) {
+        return nullptr;
+    }
+
+    fBuilder->bindProperty<ScalarValue>((*opacity_prop)["v"], fScope,
+        [color_node](const ScalarValue& o) {
+            const auto c = color_node->getColor();
+            const auto a = sk_float_round2int_no_saturate(SkTPin(o, 0.0f, 1.0f) * 255);
+            color_node->setColor(SkColorSetA(c, a));
+        });
+
+    return sksg::ModeColorFilter::Make(std::move(layer),
+                                       std::move(color_node),
+                                       SkBlendMode::kSrcIn);
+}
+
+} // namespace internal
+} // namespace skottie
diff --git a/modules/skottie/src/effects/GaussianBlurEffect.cpp b/modules/skottie/src/effects/GaussianBlurEffect.cpp
new file mode 100644
index 0000000..1a18754
--- /dev/null
+++ b/modules/skottie/src/effects/GaussianBlurEffect.cpp
@@ -0,0 +1,110 @@
+/*
+ * Copyright 2019 Google Inc.
+ *
+ * Use of this source code is governed by a BSD-style license that can be
+ * found in the LICENSE file.
+ */
+
+#include "modules/skottie/src/effects/Effects.h"
+
+#include "include/effects/SkBlurImageFilter.h"
+#include "modules/skottie/src/SkottieAdapter.h"
+#include "modules/skottie/src/SkottieValue.h"
+#include "modules/sksg/include/SkSGRenderEffect.h"
+#include "src/utils/SkJSON.h"
+
+namespace skottie {
+namespace internal {
+
+namespace  {
+
+class GaussianBlurEffectAdapter final : public SkNVRefCnt<GaussianBlurEffectAdapter> {
+public:
+    explicit GaussianBlurEffectAdapter(sk_sp<sksg::BlurImageFilter> blur)
+        : fBlur(std::move(blur)) {
+        SkASSERT(fBlur);
+    }
+
+    // AE/BM model properties.  These are all animatable/interpolatable.
+
+    // Controls the blur sigma.
+    ADAPTER_PROPERTY(Blurriness, SkScalar, 0)
+
+    // Enum selecting the blur dimensionality:
+    //
+    //   1 -> horizontal & vertical
+    //   2 -> horizontal
+    //   3 -> vertical
+    //
+    ADAPTER_PROPERTY(Dimensions, SkScalar, 1)
+
+    // Enum selecting edge behavior:
+    //
+    //   0 -> clamp
+    //   1 -> repeat
+    //
+    ADAPTER_PROPERTY(RepeatEdge, SkScalar, 0)
+
+private:
+    void apply() {
+        static constexpr SkVector kDimensionsMap[] = {
+            { 1, 1 }, // 1 -> horizontal and vertical
+            { 1, 0 }, // 2 -> horizontal
+            { 0, 1 }, // 3 -> vertical
+        };
+
+        const auto dim_index = SkTPin<size_t>(static_cast<size_t>(fDimensions),
+                                              1, SK_ARRAY_COUNT(kDimensionsMap)) - 1;
+
+        // Close enough to AE.
+        static constexpr SkScalar kBlurrinessToSigmaFactor = 0.3f;
+        const auto sigma = fBlurriness * kBlurrinessToSigmaFactor;
+
+        fBlur->setSigma({ sigma * kDimensionsMap[dim_index].x(),
+                          sigma * kDimensionsMap[dim_index].y() });
+
+        static constexpr SkBlurImageFilter::TileMode kRepeatEdgeMap[] = {
+            SkBlurImageFilter::kClampToBlack_TileMode, // 0 -> repeat edge pixels: off
+            SkBlurImageFilter::       kClamp_TileMode, // 1 -> repeat edge pixels: on
+        };
+
+        const auto repeat_index = SkTPin<size_t>(static_cast<size_t>(fRepeatEdge),
+                                                 0, SK_ARRAY_COUNT(kRepeatEdgeMap) - 1);
+        fBlur->setTileMode(kRepeatEdgeMap[repeat_index]);
+    }
+
+    const sk_sp<sksg::BlurImageFilter> fBlur;
+};
+
+} // anonymous ns
+
+sk_sp<sksg::RenderNode> EffectBuilder::attachGaussianBlurEffect(
+        const skjson::ArrayValue& jprops,
+        sk_sp<sksg::RenderNode> layer) const {
+    enum : size_t {
+        kBlurriness_Index = 0,
+        kDimensions_Index = 1,
+        kRepeatEdge_Index = 2,
+    };
+
+    auto blur_effect   = sksg::BlurImageFilter::Make();
+    auto blur_addapter = sk_make_sp<GaussianBlurEffectAdapter>(blur_effect);
+
+    fBuilder->bindProperty<ScalarValue>(GetPropValue(jprops, kBlurriness_Index), fScope,
+        [blur_addapter](const ScalarValue& b) {
+            blur_addapter->setBlurriness(b);
+        });
+    fBuilder->bindProperty<ScalarValue>(GetPropValue(jprops, kDimensions_Index), fScope,
+        [blur_addapter](const ScalarValue& d) {
+            blur_addapter->setDimensions(d);
+        });
+    fBuilder->bindProperty<ScalarValue>(GetPropValue(jprops, kRepeatEdge_Index), fScope,
+        [blur_addapter](const ScalarValue& r) {
+            blur_addapter->setRepeatEdge(r);
+        });
+
+    return sksg::ImageFilterEffect::Make(std::move(layer), std::move(blur_effect));
+}
+
+} // namespace internal
+} // namespace skottie
diff --git a/modules/skottie/src/effects/GradientEffect.cpp b/modules/skottie/src/effects/GradientEffect.cpp
new file mode 100644
index 0000000..a4e67b1
--- /dev/null
+++ b/modules/skottie/src/effects/GradientEffect.cpp
@@ -0,0 +1,146 @@
+/*
+ * Copyright 2019 Google Inc.
+ *
+ * Use of this source code is governed by a BSD-style license that can be
+ * found in the LICENSE file.
+ */
+
+#include "modules/skottie/src/effects/Effects.h"
+
+#include "modules/skottie/src/SkottieAdapter.h"
+#include "modules/skottie/src/SkottieValue.h"
+#include "modules/sksg/include/SkSGGradient.h"
+#include "modules/sksg/include/SkSGRenderEffect.h"
+#include "src/utils/SkJSON.h"
+
+namespace skottie {
+namespace internal {
+
+namespace  {
+
+class GradientRampEffectAdapter final : public SkNVRefCnt<GradientRampEffectAdapter> {
+public:
+    explicit GradientRampEffectAdapter(sk_sp<sksg::RenderNode> child)
+        : fRoot(sksg::ShaderEffect::Make(std::move(child))) {}
+
+    ADAPTER_PROPERTY(StartPoint, SkPoint , SkPoint::Make(0, 0))
+    ADAPTER_PROPERTY(EndPoint  , SkPoint , SkPoint::Make(0, 0))
+    ADAPTER_PROPERTY(StartColor, SkColor ,       SK_ColorBLACK)
+    ADAPTER_PROPERTY(EndColor  , SkColor ,       SK_ColorBLACK)
+    ADAPTER_PROPERTY(Blend     , SkScalar,                   0)
+    ADAPTER_PROPERTY(Scatter   , SkScalar,                   0)
+
+    // Really an enum: 1 -> linear, 7 -> radial (?!)
+    ADAPTER_PROPERTY(Shape     , SkScalar,                   0)
+
+    const sk_sp<sksg::ShaderEffect>& root() const { return fRoot; }
+
+private:
+    enum class InstanceType {
+        kNone,
+        kLinear,
+        kRadial,
+    };
+
+    void apply() {
+        // This adapter manages a SG fragment with the following structure:
+        //
+        // - ShaderEffect [fRoot]
+        //     \  GradientShader [fGradient]
+        //     \  child/wrapped fragment
+        //
+        // The gradient shader is updated based on the (animatable) instance type (linear/radial).
+
+        auto update_gradient = [this] (InstanceType new_type) {
+            if (new_type != fInstanceType) {
+                fGradient = new_type == InstanceType::kLinear
+                        ? sk_sp<sksg::Gradient>(sksg::LinearGradient::Make())
+                        : sk_sp<sksg::Gradient>(sksg::RadialGradient::Make());
+
+                fRoot->setShader(fGradient);
+                fInstanceType = new_type;
+            }
+
+            fGradient->setColorStops({ {0, fStartColor}, {1, fEndColor} });
+        };
+
+        static constexpr int kLinearShapeValue = 1;
+        const auto instance_type = (SkScalarRoundToInt(fShape) == kLinearShapeValue)
+                ? InstanceType::kLinear
+                : InstanceType::kRadial;
+
+        // Sync the gradient shader instance if needed.
+        update_gradient(instance_type);
+
+        // Sync instance-dependent gradient params.
+        if (instance_type == InstanceType::kLinear) {
+            auto* lg = static_cast<sksg::LinearGradient*>(fGradient.get());
+            lg->setStartPoint(fStartPoint);
+            lg->setEndPoint(fEndPoint);
+        } else {
+            SkASSERT(instance_type == InstanceType::kRadial);
+
+            auto* rg = static_cast<sksg::RadialGradient*>(fGradient.get());
+            rg->setStartCenter(fStartPoint);
+            rg->setEndCenter(fStartPoint);
+            rg->setEndRadius(SkPoint::Distance(fStartPoint, fEndPoint));
+        }
+
+        // TODO: blend, scatter
+    }
+
+    sk_sp<sksg::ShaderEffect> fRoot;
+    sk_sp<sksg::Gradient>     fGradient;
+    InstanceType              fInstanceType = InstanceType::kNone;
+};
+
+} // anonymous ns
+
+sk_sp<sksg::RenderNode> EffectBuilder::attachGradientEffect(const skjson::ArrayValue& jprops,
+                                                            sk_sp<sksg::RenderNode> layer) const {
+    enum : size_t {
+        kStartPoint_Index  = 0,
+        kStartColor_Index  = 1,
+        kEndPoint_Index    = 2,
+        kEndColor_Index    = 3,
+        kRampShape_Index   = 4,
+        kRampScatter_Index = 5,
+        kBlendRatio_Index  = 6,
+    };
+
+    auto adapter = sk_make_sp<GradientRampEffectAdapter>(std::move(layer));
+
+    fBuilder->bindProperty<VectorValue>(GetPropValue(jprops, kStartPoint_Index), fScope,
+        [adapter](const VectorValue& p0) {
+            adapter->setStartPoint(ValueTraits<VectorValue>::As<SkPoint>(p0));
+        });
+    fBuilder->bindProperty<VectorValue>(GetPropValue(jprops, kEndPoint_Index), fScope,
+        [adapter](const VectorValue& p1) {
+            adapter->setEndPoint(ValueTraits<VectorValue>::As<SkPoint>(p1));
+        });
+    fBuilder->bindProperty<VectorValue>(GetPropValue(jprops, kStartColor_Index), fScope,
+        [adapter](const VectorValue& c0) {
+            adapter->setStartColor(ValueTraits<VectorValue>::As<SkColor>(c0));
+        });
+    fBuilder->bindProperty<VectorValue>(GetPropValue(jprops, kEndColor_Index), fScope,
+        [adapter](const VectorValue& c1) {
+            adapter->setEndColor(ValueTraits<VectorValue>::As<SkColor>(c1));
+        });
+    fBuilder->bindProperty<ScalarValue>(GetPropValue(jprops, kRampShape_Index), fScope,
+        [adapter](const ScalarValue& shape) {
+            adapter->setShape(shape);
+        });
+    fBuilder->bindProperty<ScalarValue>(GetPropValue(jprops, kBlendRatio_Index), fScope,
+        [adapter](const ScalarValue& blend) {
+            adapter->setBlend(blend);
+        });
+    fBuilder->bindProperty<ScalarValue>(GetPropValue(jprops, kRampScatter_Index), fScope,
+        [adapter](const ScalarValue& scatter) {
+            adapter->setScatter(scatter);
+        });
+
+    return adapter->root();
+}
+
+} // namespace internal
+} // namespace skottie
diff --git a/modules/skottie/src/effects/LevelsEffect.cpp b/modules/skottie/src/effects/LevelsEffect.cpp
new file mode 100644
index 0000000..bc9ef35
--- /dev/null
+++ b/modules/skottie/src/effects/LevelsEffect.cpp
@@ -0,0 +1,191 @@
+/*
+ * Copyright 2019 Google Inc.
+ *
+ * Use of this source code is governed by a BSD-style license that can be
+ * found in the LICENSE file.
+ */
+
+#include "modules/skottie/src/effects/Effects.h"
+
+#include "include/effects/SkTableColorFilter.h"
+#include "modules/skottie/src/SkottieAdapter.h"
+#include "modules/skottie/src/SkottieValue.h"
+#include "modules/sksg/include/SkSGColorFilter.h"
+#include "src/utils/SkJSON.h"
+
+#include <cmath>
+
+namespace skottie {
+namespace internal {
+
+// Levels color correction effect.
+//
+// Maps the selected channels from [inBlack...inWhite] to [outBlack, outWhite],
+// based on a gamma exponent.
+//
+// For [i0..i1] -> [o0..o1]:
+//
+//   c' = o0 + (o1 - o0) * ((c - i0) / (i1 - i0)) ^ G
+//
+// The output is optionally clipped to the output range.
+//
+// In/out intervals are clampped to [0..1].  Inversion is allowed.
+
+namespace  {
+
+class LevelsEffectAdapter final : public SkNVRefCnt<LevelsEffectAdapter> {
+public:
+    explicit LevelsEffectAdapter(sk_sp<sksg::RenderNode> child)
+        : fEffect(sksg::ExternalColorFilter::Make(std::move(child))) {
+        SkASSERT(fEffect);
+    }
+
+    // 1: RGB, 2: R, 3: G, 4: B, 5: A
+    ADAPTER_PROPERTY(  Channel, SkScalar, 1)
+    ADAPTER_PROPERTY(  InBlack, SkScalar, 0)
+    ADAPTER_PROPERTY(  InWhite, SkScalar, 1)
+    ADAPTER_PROPERTY( OutBlack, SkScalar, 0)
+    ADAPTER_PROPERTY( OutWhite, SkScalar, 1)
+    ADAPTER_PROPERTY(    Gamma, SkScalar, 1)
+    // 1: clip, 2,3: don't clip
+    ADAPTER_PROPERTY(ClipBlack, SkScalar, 1)
+    ADAPTER_PROPERTY(ClipWhite, SkScalar, 1)
+
+    const sk_sp<sksg::ExternalColorFilter>& root() const { return fEffect; }
+
+private:
+    void apply() {
+        enum LottieChannel {
+            kRGB_Channel = 1,
+              kR_Channel = 2,
+              kG_Channel = 3,
+              kB_Channel = 4,
+              kA_Channel = 5,
+        };
+
+        const auto channel = SkScalarTruncToInt(fChannel);
+        if (channel < kRGB_Channel || channel > kA_Channel) {
+            fEffect->setColorFilter(nullptr);
+            return;
+        }
+
+        auto in_0 = SkTPin(fInBlack,  0.0f, 1.0f),
+             in_1 = SkTPin(fInWhite,  0.0f, 1.0f),
+            out_0 = SkTPin(fOutBlack, 0.0f, 1.0f),
+            out_1 = SkTPin(fOutWhite, 0.0f, 1.0f),
+                g = 1 / SkTMax(fGamma, 0.0f);
+
+        float clip[] = {0, 1};
+        const auto kLottieDoClip = 1;
+        if (SkScalarTruncToInt(fClipBlack) == kLottieDoClip) {
+            const auto idx = fOutBlack <= fOutWhite ? 0 : 1;
+            clip[idx] = out_0;
+        }
+        if (SkScalarTruncToInt(fClipWhite) == kLottieDoClip) {
+            const auto idx = fOutBlack <= fOutWhite ? 1 : 0;
+            clip[idx] = out_1;
+        }
+        SkASSERT(clip[0] <= clip[1]);
+
+        auto dIn  =  in_1 -  in_0,
+             dOut = out_1 - out_0;
+
+        if (SkScalarNearlyZero(dIn)) {
+            // Degenerate dIn == 0 makes the arithmetic below explode.
+            //
+            // We could specialize the builder to deal with that case, or we could just
+            // nudge by epsilon to make it all work.  The latter approach is simpler
+            // and doesn't have any noticeable downsides.
+            //
+            // Also nudge in_0 towards 0.5, in case it was sqashed against an extremity.
+            // This allows for some abrupt transition when the output interval is not
+            // collapsed, and produces results closer to AE.
+            static constexpr auto kEpsilon = 2 * SK_ScalarNearlyZero;
+            dIn  += std::copysign(kEpsilon, dIn);
+            in_0 += std::copysign(kEpsilon, .5f - in_0);
+            SkASSERT(!SkScalarNearlyZero(dIn));
+        }
+
+        uint8_t lut[256];
+
+        auto t =      -in_0 / dIn,
+            dT = 1 / 255.0f / dIn;
+
+        // TODO: is linear gamma common-enough to warrant a fast path?
+        for (size_t i = 0; i < 256; ++i) {
+            const auto out = out_0 + dOut * std::pow(std::max(t, 0.0f), g);
+            SkASSERT(!SkScalarIsNaN(out));
+
+            lut[i] = static_cast<uint8_t>(std::round(SkTPin(out, clip[0], clip[1]) * 255));
+
+            t += dT;
+        }
+
+        fEffect->setColorFilter(SkTableColorFilter::MakeARGB(
+            channel == kA_Channel                            ? lut : nullptr,
+            channel == kR_Channel || channel == kRGB_Channel ? lut : nullptr,
+            channel == kG_Channel || channel == kRGB_Channel ? lut : nullptr,
+            channel == kB_Channel || channel == kRGB_Channel ? lut : nullptr
+        ));
+    }
+
+    sk_sp<sksg::ExternalColorFilter> fEffect;
+};
+
+} // anonymous ns
+
+sk_sp<sksg::RenderNode> EffectBuilder::attachLevelsEffect(const skjson::ArrayValue& jprops,
+                                                          sk_sp<sksg::RenderNode> layer) const {
+    enum : size_t {
+        kChannel_Index        = 0,
+        // ???                = 1,
+        kInputBlack_Index     = 2,
+        kInputWhite_Index     = 3,
+        kGamma_Index          = 4,
+        kOutputBlack_Index    = 5,
+        kOutputWhite_Index    = 6,
+        kClipToOutBlack_Index = 7,
+        kClipToOutWhite_Index = 8,
+    };
+
+    auto adapter = sk_make_sp<LevelsEffectAdapter>(std::move(layer));
+
+    fBuilder->bindProperty<ScalarValue>(GetPropValue(jprops, kChannel_Index), fScope,
+        [adapter](const ScalarValue& channel) {
+            adapter->setChannel(channel);
+        });
+    fBuilder->bindProperty<ScalarValue>(GetPropValue(jprops, kInputBlack_Index), fScope,
+        [adapter](const ScalarValue& ib) {
+            adapter->setInBlack(ib);
+        });
+    fBuilder->bindProperty<ScalarValue>(GetPropValue(jprops, kInputWhite_Index), fScope,
+        [adapter](const ScalarValue& iw) {
+            adapter->setInWhite(iw);
+        });
+    fBuilder->bindProperty<ScalarValue>(GetPropValue(jprops, kOutputBlack_Index), fScope,
+        [adapter](const ScalarValue& ob) {
+            adapter->setOutBlack(ob);
+        });
+    fBuilder->bindProperty<ScalarValue>(GetPropValue(jprops, kOutputWhite_Index), fScope,
+        [adapter](const ScalarValue& ow) {
+            adapter->setOutWhite(ow);
+        });
+    fBuilder->bindProperty<ScalarValue>(GetPropValue(jprops, kGamma_Index), fScope,
+        [adapter](const ScalarValue& g) {
+            adapter->setGamma(g);
+        });
+
+    fBuilder->bindProperty<ScalarValue>(GetPropValue(jprops, kClipToOutBlack_Index), fScope,
+        [adapter](const ScalarValue& cb) {
+            adapter->setClipBlack(cb);
+        });
+    fBuilder->bindProperty<ScalarValue>(GetPropValue(jprops, kClipToOutWhite_Index), fScope,
+        [adapter](const ScalarValue& cw) {
+            adapter->setClipWhite(cw);
+        });
+
+    return adapter->root();
+}
+
+} // namespace internal
+} // namespace skottie
diff --git a/modules/skottie/src/effects/TintEffect.cpp b/modules/skottie/src/effects/TintEffect.cpp
new file mode 100644
index 0000000..f43fe58
--- /dev/null
+++ b/modules/skottie/src/effects/TintEffect.cpp
@@ -0,0 +1,58 @@
+/*
+ * Copyright 2019 Google Inc.
+ *
+ * Use of this source code is governed by a BSD-style license that can be
+ * found in the LICENSE file.
+ */
+
+#include "modules/skottie/src/effects/Effects.h"
+
+#include "modules/skottie/src/SkottieValue.h"
+#include "modules/sksg/include/SkSGPaint.h"
+#include "modules/sksg/include/SkSGColorFilter.h"
+#include "src/utils/SkJSON.h"
+
+namespace skottie {
+namespace internal {
+
+sk_sp<sksg::RenderNode> EffectBuilder::attachTintEffect(const skjson::ArrayValue& jprops,
+                                                        sk_sp<sksg::RenderNode> layer) const {
+    enum : size_t {
+        kMapBlackTo_Index = 0,
+        kMapWhiteTo_Index = 1,
+        kAmount_Index     = 2,
+        // kOpacity_Index    = 3, // currently unused (not exported)
+
+        kMax_Index        = kAmount_Index,
+    };
+
+    if (jprops.size() <= kMax_Index) {
+        return nullptr;
+    }
+
+    const skjson::ObjectValue* color0_prop = jprops[kMapBlackTo_Index];
+    const skjson::ObjectValue* color1_prop = jprops[kMapWhiteTo_Index];
+    const skjson::ObjectValue* amount_prop = jprops[    kAmount_Index];
+
+    if (!color0_prop || !color1_prop || !amount_prop) {
+        return nullptr;
+    }
+
+    auto tint_node =
+            sksg::GradientColorFilter::Make(std::move(layer),
+                                            fBuilder->attachColor(*color0_prop, fScope, "v"),
+                                            fBuilder->attachColor(*color1_prop, fScope, "v"));
+    if (!tint_node) {
+        return nullptr;
+    }
+
+    fBuilder->bindProperty<ScalarValue>((*amount_prop)["v"], fScope,
+        [tint_node](const ScalarValue& w) {
+            tint_node->setWeight(w / 100); // 100-based
+        });
+
+    return std::move(tint_node);
+}
+
+} // namespace internal
+} // namespace skottie
diff --git a/modules/skottie/src/effects/TransformEffect.cpp b/modules/skottie/src/effects/TransformEffect.cpp
index 7dfb134..5904b9d 100644
--- a/modules/skottie/src/effects/TransformEffect.cpp
+++ b/modules/skottie/src/effects/TransformEffect.cpp
@@ -41,10 +41,8 @@
 
 } // anonymous ns
 
-sk_sp<sksg::RenderNode> AttachTransformEffect(const skjson::ArrayValue& jprops,
-                                              const AnimationBuilder* abuilder,
-                                              AnimatorScope* ascope,
-                                              sk_sp<sksg::RenderNode> layer) {
+sk_sp<sksg::RenderNode> EffectBuilder::attachTransformEffect(const skjson::ArrayValue& jprops,
+                                                             sk_sp<sksg::RenderNode> layer) const {
     enum : size_t {
         kAnchorPoint_Index            =  0,
         kPosition_Index               =  1,
@@ -64,44 +62,36 @@
     auto t_adapter = sk_make_sp<TransformAdapter2D>(matrix);
     auto s_adapter = sk_make_sp<ScaleAdapter>(t_adapter);
 
-    abuilder->bindProperty<VectorValue>(EffectBuilder::GetPropValue(jprops, kAnchorPoint_Index),
-                                        ascope,
+    fBuilder->bindProperty<VectorValue>(GetPropValue(jprops, kAnchorPoint_Index), fScope,
         [t_adapter](const VectorValue& ap) {
             t_adapter->setAnchorPoint(ValueTraits<VectorValue>::As<SkPoint>(ap));
         });
-    abuilder->bindProperty<VectorValue>(EffectBuilder::GetPropValue(jprops, kPosition_Index),
-                                        ascope,
+    fBuilder->bindProperty<VectorValue>(GetPropValue(jprops, kPosition_Index), fScope,
         [t_adapter](const VectorValue& p) {
             t_adapter->setPosition(ValueTraits<VectorValue>::As<SkPoint>(p));
         });
-    abuilder->bindProperty<ScalarValue>(EffectBuilder::GetPropValue(jprops, kRotation_Index),
-                                        ascope,
+    fBuilder->bindProperty<ScalarValue>(GetPropValue(jprops, kRotation_Index), fScope,
         [t_adapter](const ScalarValue& r) {
             t_adapter->setRotation(r);
         });
-    abuilder->bindProperty<ScalarValue>(EffectBuilder::GetPropValue(jprops, kSkew_Index),
-                                        ascope,
+    fBuilder->bindProperty<ScalarValue>(GetPropValue(jprops, kSkew_Index), fScope,
         [t_adapter](const ScalarValue& s) {
             t_adapter->setSkew(s);
         });
-    abuilder->bindProperty<ScalarValue>(EffectBuilder::GetPropValue(jprops, kSkewAxis_Index),
-                                        ascope,
+    fBuilder->bindProperty<ScalarValue>(GetPropValue(jprops, kSkewAxis_Index), fScope,
         [t_adapter](const ScalarValue& sa) {
             t_adapter->setSkewAxis(sa);
         });
 
-    abuilder->bindProperty<ScalarValue>(EffectBuilder::GetPropValue(jprops, kUniformScale_Index),
-                                        ascope,
+    fBuilder->bindProperty<ScalarValue>(GetPropValue(jprops, kUniformScale_Index), fScope,
         [s_adapter](const ScalarValue& u) {
             s_adapter->setIsUniform(SkScalarRoundToInt(u));
         });
-    abuilder->bindProperty<ScalarValue>(EffectBuilder::GetPropValue(jprops, kScaleHeight_Index),
-                                        ascope,
+    fBuilder->bindProperty<ScalarValue>(GetPropValue(jprops, kScaleHeight_Index), fScope,
         [s_adapter](const ScalarValue& sh) {
             s_adapter->setScaleHeight(sh);
         });
-    abuilder->bindProperty<ScalarValue>(EffectBuilder::GetPropValue(jprops, kScaleWidth_Index),
-                                        ascope,
+    fBuilder->bindProperty<ScalarValue>(GetPropValue(jprops, kScaleWidth_Index), fScope,
         [s_adapter](const ScalarValue& sw) {
             s_adapter->setScaleWidth(sw);
         });
@@ -109,8 +99,7 @@
     auto opacity_node = sksg::OpacityEffect::Make(sksg::TransformEffect::Make(std::move(layer),
                                                                               std::move(matrix)));
 
-    abuilder->bindProperty<ScalarValue>(EffectBuilder::GetPropValue(jprops, kOpacity_Index),
-                                        ascope,
+    fBuilder->bindProperty<ScalarValue>(GetPropValue(jprops, kOpacity_Index), fScope,
         [opacity_node](const ScalarValue& o) {
             opacity_node->setOpacity(o * 0.01f);
         });
diff --git a/modules/skottie/src/effects/TritoneEffect.cpp b/modules/skottie/src/effects/TritoneEffect.cpp
new file mode 100644
index 0000000..4ef60d3
--- /dev/null
+++ b/modules/skottie/src/effects/TritoneEffect.cpp
@@ -0,0 +1,60 @@
+/*
+ * Copyright 2019 Google Inc.
+ *
+ * Use of this source code is governed by a BSD-style license that can be
+ * found in the LICENSE file.
+ */
+
+#include "modules/skottie/src/effects/Effects.h"
+
+#include "modules/skottie/src/SkottieValue.h"
+#include "modules/sksg/include/SkSGColorFilter.h"
+#include "modules/sksg/include/SkSGPaint.h"
+#include "src/utils/SkJSON.h"
+
+namespace skottie {
+namespace internal {
+
+sk_sp<sksg::RenderNode> EffectBuilder::attachTritoneEffect(const skjson::ArrayValue& jprops,
+                                                           sk_sp<sksg::RenderNode> layer) const {
+    enum : size_t {
+        kHiColor_Index     = 0,
+        kMiColor_Index     = 1,
+        kLoColor_Index     = 2,
+        kBlendAmount_Index = 3,
+
+        kMax_Index         = kBlendAmount_Index,
+    };
+
+    if (jprops.size() <= kMax_Index) {
+        return nullptr;
+    }
+
+    const skjson::ObjectValue* hicolor_prop = jprops[    kHiColor_Index];
+    const skjson::ObjectValue* micolor_prop = jprops[    kMiColor_Index];
+    const skjson::ObjectValue* locolor_prop = jprops[    kLoColor_Index];
+    const skjson::ObjectValue*   blend_prop = jprops[kBlendAmount_Index];
+
+    if (!hicolor_prop || !micolor_prop || !locolor_prop || !blend_prop) {
+        return nullptr;
+    }
+
+    auto tritone_node =
+            sksg::GradientColorFilter::Make(std::move(layer), {
+                                            fBuilder->attachColor(*locolor_prop, fScope, "v"),
+                                            fBuilder->attachColor(*micolor_prop, fScope, "v"),
+                                            fBuilder->attachColor(*hicolor_prop, fScope, "v") });
+    if (!tritone_node) {
+        return nullptr;
+    }
+
+    fBuilder->bindProperty<ScalarValue>((*blend_prop)["v"], fScope,
+        [tritone_node](const ScalarValue& w) {
+            tritone_node->setWeight((100 - w) / 100); // 100-based, inverted (!?).
+        });
+
+    return std::move(tritone_node);
+}
+
+} // namespace internal
+} // namespace skottie