diff --git a/README.md b/README.md index dc4deb4..e51b022 100644 --- a/README.md +++ b/README.md @@ -2,38 +2,41 @@ ![](https://github.com/sudara/melatonin_blur/actions/workflows/tests.yml/badge.svg) -Melatonin Blur is a batteries-included, cross-platform CPU blur and shadow compositing library for the [JUCE C++ framework](https://juce.com/). +Melatonin Blur is a batteries-included, cross-platform CPU blur and shadow compositing library for +the [JUCE C++ framework](https://juce.com/). *Batteries-included* means it aims to give you everything out of the box: -* ๐Ÿ‘ฉโ€๐ŸŽจ Figma/CSS-accurate drop and inner shadows! -* ๐Ÿ’…๐Ÿผ Filled and stroked paths! +* ๐Ÿ‘ฉโ€๐ŸŽจ Figma/CSS-accurate drop and inner shadows on paths! +* ๐Ÿ”  Drop and Inner Text shadows! +* ๐Ÿ’…๐Ÿผ Supports both filled and stroked paths! * ๐ŸŒ‡ ARGB image blurs! * ๐Ÿš€ Fast! (see [benchmarks](#more-benchmarks)). * ๐Ÿ”Ž Retina-friendly (context scale-aware)! * ๐Ÿฐ Trivial to layer multiple shadows! -* โš™๏ธ Behind-the-scenes multi-layer caching! +* โš™๏ธ Behind-the-scenes multi-layer caching! * ๐Ÿ˜Ž Debug optimized for high quality of life! -* ๐Ÿค– Over 1000 correctness tests running on mac/windows! +* ๐Ÿค– Over 1000 correctness tests passing on mac/windows! * ๐Ÿš‚ Compatible down to macOS 10.13 (progressive speedups on recent versions) -The goal: modern vector interfaces in JUCE (100s of shadows) without resorting to deprecated solutions with lower quality of life (looking at you, OpenGL on macOS!). +The goal: modern vector interfaces in JUCE (100s of shadows) without resorting to deprecated solutions with lower quality of life (looking at you, OpenGL on macOS!). https://github.com/sudara/melatonin_blur/assets/472/3e1d6c9a-aab9-422f-a262-6b63cbca5b71 -Melatonin Blur provides a 10-30x speedup over using Stack Blur alone. +Melatonin Blur provides a 10-30x speedup over using Stack Blur alone. On macOS, it depends on the built-in Accelerate framework. -On Windows, it optionally depends on the Intel IPP library. If IPP is not present, it will fall back to a JUCE FloatVectorOperations implementation for single channel (shadows, etc) and Gin's Stack Blur for ARGB. +On Windows, it *optionally* depends on the Intel IPP library. If IPP is not present, it will fall back to a JUCE FloatVectorOperations implementation for single channel (shadows, etc) and Gin's Stack Blur for ARGB. -Interested in how the blurring works? [I wrote an in-depth article about Stack Blur](https://melatonin.dev/blog/implementing-marios-stack-blur-15-times-in-cpp/). +Interested in how the blurring +works? [I wrote an in-depth article about re-implementing Stack Blur 15+ times](https://melatonin.dev/blog/implementing-marios-stack-blur-15-times-in-cpp/). -## Installation +## Installation -Melatonin Blur is a JUCE Module. +Melatonin Blur is a JUCE Module. If you are new to JUCE modules, don't be scared! They are easy to set up. @@ -49,12 +52,12 @@ git submodule add -b main https://github.com/sudara/melatonin_blur.git modules/m ### CMake option 2: FetchContent ```cmake -Include (FetchContent) -FetchContent_Declare (melatonin_blur - GIT_REPOSITORY https://github.com/sudara/melatonin_blur.git - GIT_TAG origin/main - SOURCE_DIR ${CMAKE_CURRENT_BINARY_DIR}/melatonin_blur) -FetchContent_MakeAvailable (melatonin_blur) +Include(FetchContent) +FetchContent_Declare(melatonin_blur + GIT_REPOSITORY https://github.com/sudara/melatonin_blur.git + GIT_TAG origin/main + SOURCE_DIR ${CMAKE_CURRENT_BINARY_DIR}/melatonin_blur) +FetchContent_MakeAvailable(melatonin_blur) ``` ### Tell CMake about the module @@ -91,7 +94,7 @@ Download (via git like above, or via the UI here) and "Add a module from a speci ### IPP on Windows -If you aren't already using it, Intel IPP might feel like an annoying dependency. Understandable! [I wrote a blog post describing how to set it up locally and in CI](https://melatonin.dev/blog/using-intel-performance-primitives-ipp-with-juce-and-cmake/). +If you aren't already using it, Intel IPP might feel like an annoying dependency. Understandable! [I wrote a blog post describing how to set it up locally and in CI](https://melatonin.dev/blog/using-intel-performance-primitives-ipp-with-juce-and-cmake/). It's not too bad! It's fantastic tool to have for dsp as well (albeit with an annoying API!) and it'll speed up the single channel (shadows, etc) on Windows. And don't worry, without IPP you'll still get Stack Blur performance + shadow caching. @@ -99,7 +102,7 @@ It's not too bad! It's fantastic tool to have for dsp as well (albeit with an an ### Drop Shadows -Drop shadows work on a `juce::Path`. +Drop shadows work on a `juce::Path`. Add a `melatonin::DropShadow` as a member of your `juce::Component`, specifying the blur radius like so: @@ -107,7 +110,9 @@ Add a `melatonin::DropShadow` as a member of your `juce::Component`, specifying melatonin::DropShadow shadow = { juce::Colours::black, 8 }; ``` -In `paint` of your component, call `shadow.render(g, path)`, passing in the graphics context and the path to render. **Remember to render drop shadows *before* rendering the path!** +In `paint` of your component, call `shadow.render(g, path)`, passing in the graphics context and the path to render. + +**Remember to render drop shadows *before* rendering the path!** ### Color, Radius, Offset, Spread @@ -166,7 +171,6 @@ private: The `juce::Path` itself doesn't *have* to be a member variable to still take advantage of the caching. The path is passed in on `render` (instead of on construction). This frees you up to recalculate the path in `paint` (i.e. there are times when `resized` won't be called, such as when animating), while still retaining the cached shadow when the data is identical: - ```cpp class MySlider : public juce::Component { @@ -193,7 +197,7 @@ private: Inner shadows function identically and have the same API as drop shadows. Just call `melatonin::InnerShadow`. -**Remember, inner shadows are rendered *after* the path is rendered**. +**Remember, inner shadows are rendered *after* the path is rendered**. ```cpp class MySlider : public juce::Component @@ -229,11 +233,11 @@ melatonin::DropShadow thumbShadow { { juce::Colours::blue, 3, { 0, 0 } }}; ``` -Shadows are rendered in the order they are specified. +Shadows are rendered in the order they are specified. ### Updating and Animating Shadows -Starting with version 1.2, caching of the underlying single channel blurs is position, color, offset and opacity agnostic. +Starting with version 1.2, caching of the underlying single channel blurs is position, color, offset and opacity agnostic. Translation: you can animate these things at 60fps and it's very cheap. Only some image compositing, no blur recalculation. @@ -253,15 +257,45 @@ shadow.setSpread(10); ### Default Constructors for Inner/DropShadow -Don't know your colors at compile time? +Don't know your colors at compile time? That's fine, there's a default constructor, you can update the color in the paint method. ### Stroked Paths -Stroked paths are supported for both inner and drop shadows. +Stroked paths are supported for both inner and drop shadows. + +Instead of calling `shadow.render(g, path)`, you'll need to call `shadow.render(g, path, pathStrokeType)`, passing in the same stroke type as you used to stroke the path. -Instead of calling `shadow.render(g, path)`, you'll need to call `shadow.renderStroked (g, path, pathStrokeType)`. +### Text shadows + +![AudioPluginHost - 2023-12-23 45@2x](https://github.com/sudara/melatonin_blur/assets/472/858e305f-f223-4df7-ad72-ace7e69865aa) + +Text shadows use the same `melatonin::DropShadow` and `melatonin::InnerShadow` classes as paths. You'll just need to call `renderText` instead of `render`. + +```cpp +class MySlider : public juce::Component +{ +public: + + void paint (juce::Graphics& g) override + { + g.setColour (juce::Colours::black); + g.setFont (juce::Font (20)); + shadow.render (g, "Hello World", getLocalBounds(), juce::Justification::centred); + g.drawText ("Hello World", getLocalBounds(), juce::Justification::centred); + } + +private: + melatonin::DropShadow shadow = {{ juce::Colours::red, 8, { -2, 0 } }}; +} +``` + +Right now, the API just mirrors `g.drawText`, so it's not particularly DRY. I'm open to suggestions on how to improve this. Ellipses aren't supported, but you can open a PR. I will never support `drawFittedText` โ€” it's downright evil and should be deprecated and removed from JUCE :) + +Just like `g.drawText`, you can pass in a `juce::Rectangle`, `juce::Rectangle`, or the `x, y, w, h` as bounds arguments. + +Text shadows are cached. As with path shadows, repainting is cheap and changing their color, offset, opacity is free. However, changing the font, text, justification or bounds will require re-rendering (both the glyphs and underlying blur). ### Full Color Blurs @@ -304,7 +338,7 @@ I've got plans to add some more background blur helpers for these use cases! ## Motivation -Blurs are essential to modern design. Layered drop shadows, frosted glass effects, blurred backgrounds โ€” you won't see a nice looking app in the 2020s without them. +Blurs are essential to modern design. Layered drop shadows, frosted glass effects, blurred backgrounds โ€” you won't see a nice looking app in the 2020s without them. Designers tend to work in vector based tools such as Figma. Shadows are a big part of their workflow. It's how they bring depth and life to 2D interfaces. Melatonin Blur lets you take a designer's work in CSS/Figma and quickly translate it. No need to export image strips and so on like it's the 1990s! @@ -314,20 +348,23 @@ For example, I have a slider that look like this: Every single part of this slider is a vector path with shadows. The background track (2 inner), to the level indicator (3 inner, 2 drop) to the knob (3 drop shadow). They all need to be rendered fast enough that many of these can be happily animated at 60fps without freezing up the UI on older machines. -Stack Blur [via the Gin implementation](https://github.com/FigBug/Gin) first made this feel technically possible for me. Thanks Roland! +Stack Blur [via the Gin implementation](https://github.com/FigBug/Gin) first made this feel technically possible for me. Thanks Roland! However, performance is death by 1000 cuts. I was still finding myself building little caching helpers. Sometimes these shadows add up, and I didn't feel "safe", in particular on Windows. On larger images (above 500px in a dimension) Stack Blur can takes milliseconds of CPU time (which is unacceptable for responsive UI targeting 60fps). I was also seeing some sluggishness in Debug mode, which drives me crazy! So I started to get curious about the Stack Blur algorithm. I kept thinking I could make it: -* **Faster**. The original stack blur algorithm was made for an environment without access to SIMD or intel/apple vendor libs, in 2004. For larger images, I was seeing images take `ms`. I wanted to see them in the `ยตs`. In Debug, Stack Blur is sluggish and can't always provide 60fps, even on a fast machine. -* **Cleaner**. The Gin Stack Blur implementation comes from a long line of ports originating with [Mario's js implementation](https://web.archive.org/web/20110707030516/http://www.quasimondo.com/StackBlurForCanvas/StackBlur.js). That means there's no templates, no code reuse, there's multiplication and left shift tables to avoid division, and all kinds of trickery that I felt was needed with modern C++ and modern compilers. -* **Understandable**. The concept of the "stack" in Stack Blur โ€” what is it? I had a hard time finding resources that made it easy to understand. The original notes on the algorithm don't align with how implementations worked in practice. So I was interested in understanding how the algorithm works. +* **Faster**. The original stack blur algorithm was made for an environment without access to SIMD or intel/apple vendor libs, in 2004. For larger images, I was seeing images take `ms`. I wanted to see them in the `ยตs`. In Debug, Stack Blur is sluggish and can't always provide 60fps, even on a fast machine. +* **Cleaner**. The Gin Stack Blur implementation comes from a long line of ports originating + with [Mario's js implementation](https://web.archive.org/web/20110707030516/http://www.quasimondo.com/StackBlurForCanvas/StackBlur.js). + That means there's no templates, no code reuse, there's multiplication and left shift tables to avoid division, and all kinds of trickery that I felt was needed with modern C++ and modern compilers. +* **Understandable**. The concept of the "stack" in Stack Blur โ€” what is it? I had a hard time finding resources that made it easy to understand. The original notes on the algorithm don't align with how implementations worked in + practice. So I was interested in understanding how the algorithm works. * **Fully Tested**. This is critical when iteratively re-implementing algorithms, and I felt like it was a must-have. * **Benchmarked**. The only way to effectively compare implementations was to test on Windows and MacOS. -* **Batteries included**. +* **Batteries included**. -Thanks to being arrogant and setting a somewhat ridiculously high bar, I implemented Stack Blur probably 25+ times over the course of a few weeks. Enough where I can do it in my sleep. There are 15 reference implementations in this repo that pass the tests, but most didn't bring the performance improvements I was looking for. I still have a few more implementations that I'd like to try, but I've already invested ridiculous amounts of time into what I've been calling my *C++ performance final exam* โ€” in short, I would like to move on with my life! +Thanks to being arrogant and setting a somewhat ridiculously high bar, I implemented Stack Blur probably 25+ times over the course of a few weeks. Enough where I can do it in my sleep. There are 15 reference implementations in this repo that pass the tests, but most didn't bring the performance improvements I was looking for. I still have a few more implementations that I'd like to try, but I've already invested ridiculous amounts of time into what I've been calling my *C++ performance final exam* โ€” in short, I would like to move on with my life! ## More Benchmarks @@ -340,11 +377,11 @@ Benchmarks are REALLY messy things. * Using benchmark averages obscure outliers (and outliers matter, especially in dsp, but even in UI). * Results differ on different machines. (I swear a fresh restart of my Apple M1 made vImage run faster!) -So, here are some cherry-picked benchmarks. The Windows machine is an AMD Ryzen 9 and the mac is a M1 MacBook Pro. In all cases, the image dimensions are square (e.g. 50x50px) and the times are `ยตs` (microseconds, or a millionth of a second) averaged over 100 runs. That means that when you see a number like 1000, it means 1ms. Please open issues if you are seeing discrepancies or want to contribute to the benchmarks. +So, here are some cherry-picked benchmarks. The Windows machine is an AMD Ryzen 9 and the mac is a M1 MacBook Pro. In all cases, the image dimensions are square (e.g. 50x50px) and the times are `ยตs` (microseconds, or a millionth of a second) averaged over 100 runs. That means that when you see a number like 1000, it means 1ms. Please open issues if you are seeing discrepancies or want to contribute to the benchmarks. ### Cached Drop Shadows -My #1 performance goal with this library was for drop-shadows to be screaming fast. +My #1 performance goal with this library was for drop-shadows to be screaming fast. 99% of the time I'm rendering single channel shadows for vector UI. @@ -356,18 +393,18 @@ On Windows, with IPP as a dependency: -Note: I haven't been including JUCE's DropShadow class. That's in part because it's not compatible with design programs like Figma or standards like CSS. But it also performs 20-30x worse than Stack Blur and up to 500x worse than Melatonin Blur. +Note: I haven't been including JUCE's DropShadow class. That's in part because it's not compatible with design programs like Figma or standards like CSS. But it also performs 20-30x worse than Stack Blur and up to 500x worse than Melatonin Blur. -To show this clearly, the time axis (in `ยตs`) has to be logarithmic: +To show this clearly, the time axis (in `ยตs`) has to be logarithmic: ### Single Channel Blurs (Uncached Shadows) -My #2 performance goal was for single channel blurs underlying shadows themselves to take `ยตs`, not `ms`. You can also think of these as the timings for the *first* time a shadow is built with a blur. Optimizing these larger image sizes ensures that drop shadows won't be a cause for dropped frames on their first render (and can even be animated). +My #2 performance goal was for single channel blurs underlying shadows themselves to take `ยตs`, not `ms`. You can also think of these as the timings for the *first* time a shadow is built with a blur. Optimizing these larger image sizes ensures that drop shadows won't be a cause for dropped frames on their first render (and can even be animated). -Stack Blur (and in particular the Gin implementation) is already *very* optimized, especially for smaller dimensions. It's hard to beat the raw blur performance on smaller images like a 32x32px (although caching the blur is still very much worth it). However, as image dimensions scale, Stack Blur gets into the `ms`, even on single channels. +Stack Blur (and in particular the Gin implementation) is already *very* optimized, especially for smaller dimensions. It's hard to beat the raw blur performance on smaller images like a 32x32px (although caching the blur is still very much worth it). However, as image dimensions scale, Stack Blur gets into the `ms`, even on single channels. -Melatonin Blur stays under `1ms` for the initial render of most realistic image sizes and radii. +Melatonin Blur stays under `1ms` for the initial render of most realistic image sizes and radii. @@ -377,7 +414,7 @@ On Windows (with IPP): ### Optimized for Debug Too -Debug is where we spend 95% of our day! Nothing worse than clicking around a janky low FPS UI, uncertain of how it will perform in Release. +Debug is where we spend 95% of our day! Nothing worse than clicking around a janky low FPS UI, uncertain of how it will perform in Release. Because it directly talks to vendor vector libraries and the caching is still in play, Melatonin Blur is *almost* as fast in Debug as it is in Release. Individual drop shadows are up to 30x-50x faster than a Debug Stack Blur, and timings will stay in ยตs, not ms. The following chart is again on a logarithmic scale: @@ -385,7 +422,7 @@ Because it directly talks to vendor vector libraries and the caching is still in ### Full Color Blurs (ARGB) -ARGB blurs are often used to blur a whole window or a big part of the screen. +ARGB blurs are often used to blur a whole window or a big part of the screen. The blur itself usually has to be a good 32px or 48px something to look nice. The image dimensions are large. The radii are large. And there are 4 channels. This is rough on performance. @@ -393,12 +430,15 @@ Initial Melatonin ARGB blurs usually break into the `ms` once image dimensions g -ARGB on Windows just about killed me. I tried many implementations, and [still have a few left to try](https://github.com/sudara/melatonin_blur/issues/2). Nothing consistenly outperforms Gin (vImage can be 2x on larger images but suffers on larger radii), so Gin is being used as the blur implementation backing Windows ARGB. +ARGB on Windows just about killed me. I tried many implementations, +and [still have a few left to try](https://github.com/sudara/melatonin_blur/issues/2). Nothing consistenly outperforms +Gin (vImage can be 2x on larger images but suffers on larger radii), so Gin is being used as the blur implementation backing Windows ARGB. + +What you need to know: -What you need to know: * Debug will also be fast thanks to caching. * You won't be able to animate RGBA blurs. -* Very large blurs (over 1000x1000px with large radii) may cause UI sluggishness. Please measure! +* Very large blurs (over 1000x1000px with large radii) may cause UI sluggishness. Please measure! ## FAQ @@ -412,19 +452,20 @@ Don't ask. ### Seriously, why? -In JUCE, graphics options are limited, as it's a cross-platform C++ framework. Yes, you could spin up an OpenGL context (deprecated and crusty on MacOS), but you lose a lot of conveniences working with the normal JUCE rendering. +In JUCE, graphics options are limited, as it's a cross-platform C++ framework. Yes, you could spin up an OpenGL context (deprecated and crusty on MacOS), but you lose a lot of conveniences working with the normal JUCE rendering. JUCE + Stack Blur got me very close to having what I needed to happily make modern plugin UIs. This library was the last piece of the puzzle. ### What's tested? -Tests were necessary to verify implementations were correct. Horizontal and vertical blur passes of Stack Blur are tested in isolation. Most of the implementations in this repository pass the tests. +Tests were necessary to verify implementations were correct. Horizontal and vertical blur passes of Stack Blur are +tested in isolation. Most of the implementations in this repository pass the tests. ### How can I run the benchmarks on my machine -It could be fun to codesign and release the benchmark binaries... In general I sort of daydream about JUCE hiring me to release a benchmark plugin utility for JUCE that compares dsp and UI performance across different machines (and reports to the cloud so we can all benefit from results). +It could be fun to codesign and release the benchmark binaries... In general I sort of daydream about JUCE hiring me to release a benchmark plugin utility for JUCE that compares dsp and UI performance across different machines (and reports to the cloud so we can all benefit from results). ## Interesting facts learned along the way @@ -446,23 +487,24 @@ It turns out that JUCE, like most frameworks handling image compositing, pre-mul In other words, if the 8-bit red channel is at max, at 255, but the alpha is at a third, at 85, the red component of the pixel is actually stored pre-multiplied by the alpha value. As 85, not 255. -The alpha information is kept in the pixel, and when you ask JUCE for the color, it will unpremultiplied it so you see "red" as 255 and alpha at 85. +The alpha information is kept in the pixel, and when you ask JUCE for the color, it will unpremultiplied it so you see "red" as 255 and alpha at 85. -This is done both for functional and performance reasons, you can [read more here](https://learn.microsoft.com/en-us/windows/apps/develop/win2d/premultiplied-alpha) and find some [interesting stuff on the JUCE forum](https://forum.juce.com/t/softwareimagerenderer-produces-premultiplied-argb/8991) about it. +This is done both for functional and performance reasons, you can [read more here](https://learn.microsoft.com/en-us/windows/apps/develop/win2d/premultiplied-alpha) and find some [interesting stuff on the JUCE forum](https://forum.juce.com/t/softwareimagerenderer-produces-premultiplied-argb/8991) about it. ### Actual pixels are stored as `BGRA` in memory -All modern consumer MacOS and Windows machines are little endian. After banging my head for a couple hours on strangely failing tests, I realized JUCE (and most platforms it abstracts by) stores pixels in [BGRA order in memory for these machines](https://github.com/juce-framework/JUCE/blob/a7d1e61a5511875dc8f345816fca94042ce074fb/modules/juce_graphics/native/juce_CoreGraphicsContext_mac.mm#L165). +All modern consumer MacOS and Windows machines are little endian. After banging my head for a couple hours on strangely failing tests, I realized JUCE (and most platforms it abstracts by) stores pixels +in [BGRA order in memory for these machines](https://github.com/juce-framework/JUCE/blob/a7d1e61a5511875dc8f345816fca94042ce074fb/modules/juce_graphics/native/juce_CoreGraphicsContext_mac.mm#L165). -Funny, because on the web, we think in terms of `RGBA`. -On desktop, we think in terms of `ARGB`. +Funny, because on the web, we think in terms of `RGBA`. +On desktop, we think in terms of `ARGB`. But the computers think in `BGRA`. ### Vendor and Vector implementations Originally I split work between "naive" (pixel by pixel) and vector implementations of Mario's Stack Blur algorithm. I learned some interesting things via the vector implementation attempts. -Image vector algos seem to need to be converted to floating point to efficiently work with vector operations on Intel and Mac. There are sometimes 8-bit functions available, but kernels have to be at least 16-bit (to calculate sums or to perform convolution). So in bulk, there's always some need for conversion. +Image vector algos seem to need to be converted to floating point to efficiently work with vector operations on Intel and Mac. There are sometimes 8-bit functions available, but kernels have to be at least 16-bit (to calculate sums or to perform convolution). So in bulk, there's always some need for conversion. I tried batching the vector operations (aka, entire rows or cols of pixels) to be 32/64/100, thinking this would be a more efficient for larger images. It's not. It's slower. My best guess is vdsp/ipp under the hood juggle data and memory better than I can manually. However, the fallback implementation could still benefit from this method. @@ -478,14 +520,14 @@ I started out with everything in one big ole free function. 0 code reuse, as a t This might seem like a strange thing to say, or even a no-brainer to a seasoned C++ developer, but I was surprised by how much of a negative impact things like templates, classes, and function calls regularly had on performance in these implementations. -In reality, I think the truth is more subtle: compilers have a lot of history with C, and are very good at optimizing a linear mess of instructions. There are more considerations when moving to templates and classes (such as memory layout, the resulting impact on cache locality, "seeing through" the indirection created by classes and function calls). The compiler can't always make the same straightforward assumptions, if only because there is more complexity for the compiler itself. My conclusion is that the human has to work *harder* at optimizing modern C++ code than with naive "risky" pointer juggling C-ish implementations. +In reality, I think the truth is more subtle: compilers have a lot of history with C, and are very good at optimizing a linear mess of instructions. There are more considerations when moving to templates and classes (such as memory layout, the resulting impact on cache locality, "seeing through" the indirection created by classes and function calls). The compiler can't always make the same straightforward assumptions, if only because there is more complexity for the compiler itself. My conclusion is that the human has to work *harder* at optimizing modern C++ code than with naive "risky" pointer juggling C-ish implementations. In other words, this project humbled me. I love to advocate for clarity, readability and maintainability. I tend to despise spaghetti code and esoteric C++ tricks. In the rare times when the absolute priority is raw speed, I still 100% believe readability and maintainability are possible, but there's definitely a price to be paid for composability and reusability. I paid that price, both in terms of raw hours, as well as an end result I wish I could be prouder of. - ## Acknowledgements * Mars, for being my reliable rubber duck! Inner Shadow caching and compositing geometry broke my brain. * Roland Rabian for JUCE Stack Blur workhorse via [Gin](https://github.com/figbug/gin). -* LukeM1 on the forums for [figuring out the `drawImageAt` optimization](https://forum.juce.com/t/faster-blur-glassmorphism-ui/43086/76). +* LukeM1 on the forums + for [figuring out the `drawImageAt` optimization](https://forum.juce.com/t/faster-blur-glassmorphism-ui/43086/76). * Ecstasy on the Discord for the motivation and feedback around stroked paths and default constructors. diff --git a/melatonin/blur_demo_component.h b/melatonin/blur_demo_component.h index 0a03039..8599fdc 100644 --- a/melatonin/blur_demo_component.h +++ b/melatonin/blur_demo_component.h @@ -3,6 +3,12 @@ #include "juce_gui_basics/juce_gui_basics.h" #include "juce_gui_extra/juce_gui_extra.h" +#if (JUCE_MAJOR_VERSION >= 7) && (JUCE_MINOR_VERSION >= 1 || JUCE_BUILDNUMBER >= 3) + #define MELATONIN_VBLANK 1 +#else + #define MELATONIN_VBLANK 0 +#endif + namespace melatonin { // TODO: Maybe someone else can make this nicer? @@ -13,6 +19,7 @@ namespace melatonin public: BlurDemoComponent() { + setOpaque (true); colorSelector.addChangeListener (this); addAndMakeVisible (radiusSlider); @@ -21,6 +28,7 @@ namespace melatonin addAndMakeVisible (spreadSlider); addAndMakeVisible (opacitySlider); addAndMakeVisible (colorSelector); + addAndMakeVisible (animateButton); radiusSlider.setColour (juce::Slider::ColourIds::trackColourId, juce::Colours::grey); spreadSlider.setColour (juce::Slider::ColourIds::trackColourId, juce::Colours::grey); @@ -48,6 +56,8 @@ namespace melatonin innerShadow.setRadius ((size_t) radiusSlider.getValue()); strokedDropShadow.setRadius ((size_t) radiusSlider.getValue()); strokedInnerShadow.setRadius ((size_t) radiusSlider.getValue()); + textDropShadow.setRadius ((size_t) radiusSlider.getValue()); + textInnerShadow.setRadius ((size_t) radiusSlider.getValue()); repaint(); }; @@ -56,6 +66,8 @@ namespace melatonin innerShadow.setSpread ((size_t) spreadSlider.getValue()); strokedDropShadow.setSpread ((size_t) spreadSlider.getValue()); strokedInnerShadow.setSpread ((size_t) spreadSlider.getValue()); + textDropShadow.setSpread ((size_t) spreadSlider.getValue()); + textInnerShadow.setSpread ((size_t) spreadSlider.getValue()); repaint(); }; @@ -64,6 +76,8 @@ namespace melatonin innerShadow.setOffset ({ (int) offsetXSlider.getValue(), (int) offsetYSlider.getValue() }); strokedDropShadow.setOffset ({ (int) offsetXSlider.getValue(), (int) offsetYSlider.getValue() }); strokedInnerShadow.setOffset ({ (int) offsetXSlider.getValue(), (int) offsetYSlider.getValue() }); + textDropShadow.setOffset ({ (int) offsetXSlider.getValue(), (int) offsetYSlider.getValue() }); + textInnerShadow.setOffset ({ (int) offsetXSlider.getValue(), (int) offsetYSlider.getValue() }); repaint(); }; @@ -72,6 +86,8 @@ namespace melatonin innerShadow.setOffset ({ (int) offsetXSlider.getValue(), (int) offsetYSlider.getValue() }); strokedDropShadow.setOffset ({ (int) offsetXSlider.getValue(), (int) offsetYSlider.getValue() }); strokedInnerShadow.setOffset ({ (int) offsetXSlider.getValue(), (int) offsetYSlider.getValue() }); + textDropShadow.setOffset ({ (int) offsetXSlider.getValue(), (int) offsetYSlider.getValue() }); + textInnerShadow.setOffset ({ (int) offsetXSlider.getValue(), (int) offsetYSlider.getValue() }); repaint(); }; @@ -80,6 +96,8 @@ namespace melatonin innerShadow.setOpacity ((float) opacitySlider.getValue()); strokedDropShadow.setOpacity ((float) opacitySlider.getValue()); strokedInnerShadow.setOpacity ((float) opacitySlider.getValue()); + textDropShadow.setOpacity ((float) opacitySlider.getValue()); + textInnerShadow.setOpacity ((float) opacitySlider.getValue()); repaint(); }; #if MELATONIN_VBLANK @@ -88,6 +106,10 @@ namespace melatonin this->repaint(); } }; #endif + animateButton.onClick = [this] { + animating = !animating; + animateButton.setButtonText (animating ? "Stop!" : "Animate!"); + }; } void modulate() @@ -106,7 +128,8 @@ namespace melatonin void paint (juce::Graphics& g) override { auto start = juce::Time::getMillisecondCounterHiRes(); - // modulate(); + if (animating) + modulate(); g.fillAll (juce::Colours::black); g.setColour (contentColor); @@ -118,16 +141,19 @@ namespace melatonin g.fillPath (innerShadowedPath); innerShadow.render (g, innerShadowedPath); - strokedDropShadow.renderStroked (g, strokedDropPath, juce::PathStrokeType (6)); + strokedDropShadow.render (g, strokedDropPath, juce::PathStrokeType (6)); g.strokePath (strokedDropPath, juce::PathStrokeType (6)); g.strokePath (strokedInnerPath, juce::PathStrokeType (6)); - strokedInnerShadow.renderStroked (g, strokedInnerPath, juce::PathStrokeType (6)); + strokedInnerShadow.render (g, strokedInnerPath, juce::PathStrokeType (6)); g.setColour (juce::Colours::white); g.setFont (juce::Font (50).boldened()); - g.drawText ("wow", textBounds, juce::Justification::centred); - // shadowedText.render(g, "wow", textBounds, juce::Justification::centred); + textDropShadow.render (g, "drop", textBounds, juce::Justification::left); + g.drawText ("drop", textBounds, juce::Justification::left); + + g.drawText ("inner", textBounds, juce::Justification::centredRight); + textInnerShadow.render (g, "inner", textBounds.toFloat(), juce::Justification::centredRight); g.setFont (juce::Font (16)); auto labels = juce::StringArray ("radius", "spread", "offsetX", "offsetY", "opacity"); @@ -163,7 +189,7 @@ namespace melatonin strokedInnerPath.clear(); strokedInnerPath.addArc ((float) strokedPathInnerBounds.getX(), (float) strokedPathInnerBounds.getY(), (float) strokedPathInnerBounds.getWidth(), (float) strokedPathInnerBounds.getHeight(), 4.4f, 7.1f, true); - // textBounds = area.removeFromTop (100).withSizeKeepingCentre (300, 100); + textBounds = area.removeFromTop (100).withSizeKeepingCentre (300, 100); auto sliderGroup = area.removeFromTop (60).withSizeKeepingCentre (300, 50); sliderLabelsBounds = area.removeFromTop (20).withSizeKeepingCentre (300, 20); @@ -179,6 +205,7 @@ namespace melatonin area.removeFromTop (10); colorSelector.setBounds (area.removeFromTop (200).withSizeKeepingCentre (200, 200)); + animateButton.setBounds (area.removeFromTop (50).withSizeKeepingCentre (100, 30)); } void changeListenerCallback (juce::ChangeBroadcaster* source) override @@ -190,11 +217,14 @@ namespace melatonin innerShadow.setColor (newColor); strokedDropShadow.setColor (newColor); strokedInnerShadow.setColor (newColor); + textDropShadow.setColor (newColor); + textInnerShadow.setColor (newColor); repaint(); } } private: + bool animating = false; juce::Rectangle contentBounds { 0, 0, 100, 100 }; juce::Path dropShadowedPath; juce::Path innerShadowedPath; @@ -206,7 +236,8 @@ namespace melatonin melatonin::InnerShadow innerShadow { { juce::Colours::black, 10 } }; melatonin::DropShadow strokedDropShadow { { juce::Colours::white, 10 } }; melatonin::InnerShadow strokedInnerShadow { { juce::Colours::white, 10 } }; - // melatonin::ShadowedText { { juce::Colours::black, 10 } }; + melatonin::DropShadow textDropShadow { { juce::Colours::black, 10 } }; + melatonin::InnerShadow textInnerShadow { { juce::Colours::white, 2 } }; melatonin::CachedBlur blur { 5 }; juce::Rectangle textBounds; juce::Rectangle sliderLabelsBounds; @@ -221,9 +252,34 @@ namespace melatonin | juce::ColourSelector::showColourspace, 0, 0 }; + juce::TextButton animateButton { "Animate!" }; #if MELATONIN_VBLANK juce::VBlankAttachment vBlankCallback; #endif size_t modulator = 0; }; + + class TextShadowDemo : public juce::Component + { + public: + void paint (juce::Graphics& g) override + { + // https://codepen.io/namho/pen/jEaXra + juce::String text = "TEXTSHADOW"; + g.fillAll (juce::Colour::fromRGB (236, 229, 218)); + g.setFont (font.withExtraKerningFactor (-0.1f)); + + // drop shadow renders under the text! + dropShadow.render (g, text, getLocalBounds(), juce::Justification::centred); + g.setColour (juce::Colour::fromRGB (241, 235, 229)); + g.drawText (text, getLocalBounds(), juce::Justification::centred); + } + + private: + juce::Font font { "Arial Black", 110.f, 0 }; + melatonin::DropShadow dropShadow { + { juce::Colour::fromRGB (196, 181, 157), 12, { 0, 13 } }, + { juce::Colours::white, 1, { 0, -2 } } + }; + }; } diff --git a/melatonin/internal/cached_shadows.h b/melatonin/internal/cached_shadows.h index f3b6f1e..7d5f65c 100644 --- a/melatonin/internal/cached_shadows.h +++ b/melatonin/internal/cached_shadows.h @@ -31,79 +31,68 @@ namespace melatonin::internal void render (juce::Graphics& g, const juce::Path& newPath, bool lowQuality = false) { - // Before Melatonin Blur, it was all low quality! - float scale = 1.0; - if (!lowQuality) - scale = g.getInternalContext().getPhysicalPixelScaleFactor(); + setScale (g, lowQuality); - // break cache if we're painting on a different monitor, etc - if (!juce::approximatelyEqual (lastScale, scale)) + // Store a copy of the path. + juce::Path pathCopy (newPath); + + // If it's new to us, strip its location and store its float x/y offset to 0,0 + updatePathIfNeeded (pathCopy); + + renderInternal (g); + } + + void render (juce::Graphics& g, const juce::Path& newPath, const juce::PathStrokeType& newType, bool lowQuality = false) + { + stroked = true; + setScale (g, lowQuality); + if (newType != strokeType) { + strokeType = newType; needsRecalculate = true; - lastScale = scale; } - // Store a copy of the path. - // We'll strip and store its float x/y offset to 0,0 - juce::Path incomingOriginAgnosticPath; - // Stroking the path changes its bounds. // Do this before we strip the origin and compare with cache. - if (stroked) - { - strokeType.createStrokedPath(incomingOriginAgnosticPath, newPath, {}, scale); - } - else - { - incomingOriginAgnosticPath = newPath; - } + juce::Path strokedPath; + strokeType.createStrokedPath (strokedPath, newPath, {}, scale); - // stripping the origin lets us animate/translate paths in our UI without breaking blur cache - auto incomingOrigin = incomingOriginAgnosticPath.getBounds().getPosition(); - incomingOriginAgnosticPath.applyTransform (juce::AffineTransform::translation (-incomingOrigin)); + updatePathIfNeeded (strokedPath); - // has the path actually changed? - if (needsRecalculate || (incomingOriginAgnosticPath != lastOriginAgnosticPath)) - { - // we already created a copy above, this is faster than creating another - lastOriginAgnosticPath.swapWithPath (incomingOriginAgnosticPath); - - // we'll need this later for compositing - lastOriginAgnosticPathScaled = lastOriginAgnosticPath; - lastOriginAgnosticPathScaled.applyTransform(juce::AffineTransform::scale (scale)); - - // remember the new placement in the context - pathPositionInContext = incomingOrigin; + renderInternal (g); + } - // create the single channel shadows - recalculateBlurs (scale); - } + void render (juce::Graphics& g, const juce::String& text, const juce::Rectangle& area, juce::Justification justification) + { + setScale (g, false); - // the path is the same, but it's been moved to new coordinates - else if (incomingOrigin != pathPositionInContext) + // TODO: right now if text is repositioned it *will* break blur cache + // This seems favorable than calling arrangement.createPath on each call? + // But if you are animating text shadows and grumpy at performance, please open an issue :) + TextArrangement newTextArrangement { text, g.getCurrentFont(), area, justification }; + if (newTextArrangement != lastTextArrangement) { - // reposition the cached single channel shadows - pathPositionInContext = incomingOrigin; + lastTextArrangement = newTextArrangement; + juce::GlyphArrangement arr; + arr.addLineOfText (g.getCurrentFont(), text, area.getX(), area.getY()); + arr.justifyGlyphs (0, arr.getNumGlyphs(), area.getX(), area.getY(), area.getWidth(), area.getHeight(), justification); + juce::Path path; + arr.createPath (path); + updatePathIfNeeded (path); } - // have any of the shadows changed color/opacity or been recalculated? - // if so, recreate the ARGB composite of all the shadows together - if (needsRecomposite) - compositeShadowsToARGB (); + renderInternal (g); + // need to still render a path here, which path? + } - // finally, draw the cached composite into the main graphics context - drawARGBComposite (g, scale); + void render (juce::Graphics& g, const juce::String& text, const juce::Rectangle& area, juce::Justification justification) + { + render (g, text, area.toFloat(), justification); } - void renderStroked (juce::Graphics& g, const juce::Path& newPath, const juce::PathStrokeType& newType, bool lowQuality = false) + void render (juce::Graphics& g, const juce::String& text, int x, int y, int width, int height, juce::Justification justification) { - stroked = true; - if (newType != strokeType) - { - strokeType = newType; - needsRecalculate = true; - } - render (g, newPath, lowQuality); + render (g, text, juce::Rectangle (x, y, width, height).toFloat(), justification); } void setRadius (size_t radius, size_t index = 0) @@ -121,7 +110,7 @@ namespace melatonin::internal void setOffset (juce::Point offset, size_t index = 0) { if (index < renderedSingleChannelShadows.size()) - needsRecomposite = renderedSingleChannelShadows[index].updateOffset (offset, lastScale); + needsRecomposite = renderedSingleChannelShadows[index].updateOffset (offset, scale); } void setColor (juce::Colour color, size_t index = 0) @@ -151,12 +140,76 @@ namespace melatonin::internal bool needsRecomposite = true; bool needsRecalculate = true; - float lastScale = 1.0; + float scale = 1.0; bool stroked = false; juce::PathStrokeType strokeType { -1.0f }; - void recalculateBlurs (float scale) + struct TextArrangement + { + juce::String text = {}; + juce::Font font = {}; + juce::Rectangle area = {}; + juce::Justification justification = juce::Justification::left; + + bool operator== (const TextArrangement& other) const + { + return text == other.text && font == other.font && area == other.area && justification == other.justification; + } + + bool operator!= (const TextArrangement& other) const + { + return !(*this == other); + } + }; + + TextArrangement lastTextArrangement = {}; + + void setScale (juce::Graphics& g, bool lowQuality) + { + // Before Melatonin Blur, it was all low quality! + float newScale = 1.0; + if (!lowQuality) + newScale = g.getInternalContext().getPhysicalPixelScaleFactor(); + + // break cache if we're painting on a different monitor, etc + if (!juce::approximatelyEqual (scale, newScale)) + { + needsRecalculate = true; + scale = newScale; + } + } + + void updatePathIfNeeded (juce::Path& pathToBlur) + { + // stripping the origin lets us animate/translate paths in our UI without breaking blur cache + auto incomingOrigin = pathToBlur.getBounds().getPosition(); + pathToBlur.applyTransform (juce::AffineTransform::translation (-incomingOrigin)); + + // has the path actually changed? + if (needsRecalculate || (pathToBlur != lastOriginAgnosticPath)) + { + // we already created a copy (that is passed in here), this is faster than creating another + lastOriginAgnosticPath.swapWithPath (pathToBlur); + + // we'll need this later for compositing + // TODO: Do we really need to store two copies? + lastOriginAgnosticPathScaled = lastOriginAgnosticPath; + lastOriginAgnosticPathScaled.applyTransform (juce::AffineTransform::scale (scale)); + + // remember the new placement in the context + pathPositionInContext = incomingOrigin; + + needsRecalculate = true; + } + else if (incomingOrigin != pathPositionInContext) + { + // reposition the cached single channel shadows + pathPositionInContext = incomingOrigin; + } + } + + void recalculateBlurs() { for (auto& shadow : renderedSingleChannelShadows) { @@ -166,7 +219,22 @@ namespace melatonin::internal needsRecomposite = true; } - void drawARGBComposite (juce::Graphics& g, float scale, bool optimizeClipBounds = false) + void renderInternal (juce::Graphics& g) + { + // if it's a new path or the path actually changed, redo the single channel blurs + if (needsRecalculate) + recalculateBlurs(); + + // have any of the shadows changed position/color/opacity OR been recalculated? + // if so, recreate the ARGB composite of all the shadows together + if (needsRecomposite) + compositeShadowsToARGB(); + + // draw the cached composite into the main graphics context + drawARGBComposite (g); + } + + void drawARGBComposite (juce::Graphics& g, bool optimizeClipBounds = false) { // support default constructors, 0 radius blurs, etc if (compositedARGB.isNull()) @@ -196,7 +264,7 @@ namespace melatonin::internal // This is done at the main graphics context scale // The path is at 0,0 and the shadows are placed at their correct relative *integer* positions - void compositeShadowsToARGB () + void compositeShadowsToARGB() { // figure out the largest bounds we need to composite // this is the union of all the shadow bounds diff --git a/melatonin/internal/rendered_single_channel_shadow.h b/melatonin/internal/rendered_single_channel_shadow.h index cc8e8a2..62ea713 100644 --- a/melatonin/internal/rendered_single_channel_shadow.h +++ b/melatonin/internal/rendered_single_channel_shadow.h @@ -37,6 +37,7 @@ namespace melatonin juce::Image& render (juce::Path& originAgnosticPath, float scale, bool stroked = false) { + jassert(scale > 0); scaledPathBounds = (originAgnosticPath.getBounds() * scale).getSmallestIntegerContainer(); updateScaledShadowBounds (scale); diff --git a/melatonin_blur.h b/melatonin_blur.h index d616b9c..7b5056b 100644 --- a/melatonin_blur.h +++ b/melatonin_blur.h @@ -5,7 +5,7 @@ BEGIN_JUCE_MODULE_DECLARATION ID: melatonin_blur vendor: Sudara -version: 1.2.0 +version: 1.3.0 name: Optimized CPU vector blurring and JUCE drop shadowing with tests and benchmarks description: Blurry Life license: MIT diff --git a/tests/fixtures/figma/5x5black2px-inner-offset2-0@2x.png b/tests/fixtures/figma/5x5black2px-inner-offset2-0@2x.png new file mode 100644 index 0000000..12bafa7 Binary files /dev/null and b/tests/fixtures/figma/5x5black2px-inner-offset2-0@2x.png differ diff --git a/tests/stroked_path.cpp b/tests/stroked_path.cpp index f0ee16e..92d029e 100644 --- a/tests/stroked_path.cpp +++ b/tests/stroked_path.cpp @@ -8,6 +8,98 @@ // All of these tests operate @1x TEST_CASE ("Melatonin Blur Stroked Path") { + // Test Image is a 5 pixel diagonal stroke + // 0 is white, 1 is black + // 0 0 0 0 0 0 0 0 0 + // 0 0 0 0 0 0 0 0 0 + // 0 0 0 0 0 0 1 0 0 + // 0 0 0 0 0 1 0 0 0 + // 0 0 0 0 1 0 0 0 0 + // 0 0 0 1 0 0 0 0 0 + // 0 0 1 0 0 0 0 0 0 + // 0 0 0 0 0 0 0 0 0 + // 0 0 0 0 0 0 0 0 0 + juce::Path p; + + // stick the 5x5 line centered inside a 9x9 + p.addLineSegment (juce::Line (2, 6, 6, 2), 1.0f); + + // needed for JUCE not to pee its pants (aka leak) when working with graphics + juce::ScopedJuceInitialiser_GUI juce; + + juce::Image result (juce::Image::ARGB, 9, 9, true); + juce::Graphics g (result); + + // TODO: sorta surprised this doesn't stroke the center by default, JUCE? + SECTION ("no shadow") + { + g.fillAll (juce::Colours::white); + auto strokeType = juce::PathStrokeType (2.0f); + g.strokePath (p, strokeType); + + // the top left corner is all white + CHECK (getPixels (result, { 0, 1 }, { 0, 1 }) == "FFFFFFFF, FFFFFFFF, FFFFFFFF, FFFFFFFF"); + + // the bottom right corner is all white + CHECK (getPixels (result, { 7, 8 }, { 7, 8 }) == "FFFFFFFF, FFFFFFFF, FFFFFFFF, FFFFFFFF"); + } + + // this doesn't test shadow details, just that the API works :) + SECTION ("drop shadow with stroke of 2") + { + g.fillAll (juce::Colours::white); + melatonin::DropShadow shadow (juce::Colours::black, 2); + auto strokeType = juce::PathStrokeType (3.0f); + shadow.render (g, p, strokeType); + g.strokePath (p, strokeType); + + save_test_image (result, "stroked_path.png"); + + // the top left corner is no longer white + CHECK (getPixels (result, { 0, 1 }, { 0, 1 }) != "FFFFFFFF, FFFFFFFF, FFFFFFFF, FFFFFFFF"); + + // the bottom right corner is no longer white + CHECK (getPixels (result, { 7, 8 }, { 7, 8 }) != "FFFFFFFF, FFFFFFFF, FFFFFFFF, FFFFFFFF"); + } + + SECTION ("changing stroke type breaks cache") + { + g.fillAll (juce::Colours::white); + melatonin::DropShadow shadow (juce::Colours::black, 2); + auto strokeType = juce::PathStrokeType (3.0f); + shadow.render (g, p, strokeType); + + // erase the image, render the shadow again + g.fillAll (juce::Colours::white); + strokeType = juce::PathStrokeType (0.0f); + shadow.render (g, p, strokeType); + + // there should be no more shadow (and we didn't render the path, so pure white) + CHECK (filledBounds (result).isEmpty()); + + // erase the image, render the shadow again + g.fillAll (juce::Colours::white); + strokeType = juce::PathStrokeType (1.0f); + shadow.render (g, p, strokeType); + + CHECK (!filledBounds (result).isEmpty()); + } + + SECTION ("inner shadow") + { + g.fillAll (juce::Colours::white); + melatonin::InnerShadow shadow (juce::Colours::white, 1); + auto strokeType = juce::PathStrokeType (3.0f); + g.strokePath (p, strokeType); + CHECK (getPixel (result, 2, 6) == "FF000000"); + CHECK (getPixel (result, 3, 5) == "FF000000"); + CHECK (getPixel (result, 4, 4) == "FF000000"); + shadow.render (g, p, strokeType); + CHECK (getPixel (result, 2, 6) != "FF000000"); + CHECK (getPixel (result, 3, 5) != "FF000000"); + CHECK (getPixel (result, 4, 4) != "FF000000"); + save_test_image (result, "stroked_path_inner.png"); + } } diff --git a/tests/text_shadow.cpp b/tests/text_shadow.cpp new file mode 100644 index 0000000..7a643fe --- /dev/null +++ b/tests/text_shadow.cpp @@ -0,0 +1,84 @@ +#include "../melatonin/implementations/gin.h" +#include "../melatonin/shadows.h" +#include "helpers/pixel_helpers.h" +#include +#include +#include + +// All of these tests operate @1x +TEST_CASE ("Melatonin Blur Text Shadow") +{ + // Test Image is a 9x9 with 0 in the center + // 0 is white, 1 is black + + // Grid is 9x9, and we're drawing a big centered O + + // 0 0 0 0 0 0 0 0 0 + // 0 0 0 0 0 0 0 0 0 + // 0 0 0 0 0 0 0 0 0 + // 0 0 0 0 0 0 0 0 0 + // 0 0 0 0 0 0 0 0 0 + // 0 0 0 0 0 0 0 0 0 + // 0 0 0 0 0 0 0 0 0 + // 0 0 0 0 0 0 0 0 0 + // 0 0 0 0 0 0 0 0 0 + + // needed for JUCE not to pee its pants (aka leak) when working with graphics + juce::ScopedJuceInitialiser_GUI juce; + + juce::Image result (juce::Image::ARGB, 9, 9, true); + juce::Graphics g (result); + + SECTION ("no shadow") + { + g.fillAll (juce::Colours::white); + g.setColour (juce::Colours::black); + g.drawText ("O", 0, 0, 9, 9, juce::Justification::centred, false); + + // middle of image is white + CHECK (result.getPixelAt (4, 4).toDisplayString (true) == "FFFFFFFF"); + + // top middle is close to black + auto topMiddle = result.getPixelAt (4, 4); + CHECK (getPixel (result, 4, 0) != "FFFFFFFF"); + CHECK (topMiddle.getRed() == topMiddle.getGreen()); + } + + // not testing mechanics/details of drop shadow here, just that API worked + SECTION ("drop shadow") + { + g.fillAll (juce::Colours::white); + melatonin::DropShadow shadow (juce::Colours::black, 3); + g.setColour (juce::Colours::black); + shadow.render (g, "O", 0, 0, 9, 9, juce::Justification::centred); + g.drawText ("O", 0, 0, 9, 9, juce::Justification::centred, false); + + // middle of image is no longer white + CHECK (result.getPixelAt (4, 4).toDisplayString (true) != "FFFFFFFF"); + save_test_image (result, "text_shadow.png"); + } + + SECTION ("inner shadow") + { + g.fillAll (juce::Colours::white); + melatonin::InnerShadow shadow (juce::Colours::red, 1); + g.setColour (juce::Colours::black); + g.drawText ("O", 0, 0, 9, 9, juce::Justification::centred, false); + shadow.render (g, "O", 0, 0, 9, 9, juce::Justification::centred); + + // top middle has more red than green + auto topMiddle = result.getPixelAt (4, 0); + CHECK (topMiddle.getRed() > topMiddle.getGreen()); + save_test_image (result, "text_inner_shadow.png"); + } + + SECTION ("accepts permutations of rectangle") + { + melatonin::InnerShadow shadow (juce::Colours::red, 1); + shadow.render (g, "O", 0, 0, 9, 9, juce::Justification::centred); + juce::Rectangle intRect (0, 0, 9, 9); + shadow.render (g, "O", intRect, juce::Justification::centred); + juce::Rectangle floatRect (0, 0, 9, 9); + shadow.render (g, "O", floatRect, juce::Justification::centred); + } +}