Hey all -
I’ve been a long time complainer about the build speed of my semi-outdated potato computers. And I’ve always dreamed of having some sort of ultra-fast compilation of my 36 JUCE projects.
Well, that day is today, so I figured I should drop a quick report here.
There are some posts already about the benefits of using Ninja + ccache to optimize build time, so this is nothing particularly new. But… the caveat is usually that it’s quite hard to compile JUCE to use ccache across more than one project.
The core issue is “Macro Poisoning”: JUCE embeds project-specific identity—like JucePlugin_Name, JucePlugin_Version, and unique Product IDs—directly into the compilation units of even the most generic modules (like juce_core or juce_graphics). From ccache’s perspective, the juce_core.cpp compiled for each plugin is fundamentally different because the command-line definitions differ, resulting in a 0% cross-project “hit rate.”
To solve this, I implemented a sort of macro sanitization at the CMake level. Instead of trying to hide these macros from ccache using its internal ignore_options (which can be brittle and trigger -Werror warnings for unused arguments), I use a CMake script to intercept the JUCE targets. For generic framework modules, I explicitly override the identity macros to neutral values—neutralizing strings to "" and integers to 0. This forces the compiler command lines to be identical across the entire fleet, allowing ccache to serve a single, global cached version of the JUCE modules to every plugin in the repository.
I am able to break the JUCE files up into two groups: those that only care about functional macros (like JUCE_PLUGINHOST_VST3) and those that also need the Identity macros (like JucePlugin_Name).
As long as the functional macros don’t change, I get 100% hits for the cache files and building is blazingly fast (since I only need to rebuild the identity-aware parts of JUCE). Since I have several different groups of plugins with several different functional macros, I end up building JUCE a few times (once for the hosts, for example), but still save a ton of time not rebuilding for every plugin.
One major “Gotcha” : since we break the cmake into many target groups (which have to be objects for this to work) that need to know about juce, it is tempting to link them directly to the JUCE targets. However, this leads to “duplicate symbol” errors. You have to be sure to link intermediate libraries using generator expressions to pull the INTERFACE_INCLUDE_DIRECTORIES and INTERFACE_COMPILE_DEFINITIONS from JUCE modules without actually linking the target. Only the final plugin executable “truly” links the JUCE source, ensuring it is compiled exactly once.
Finally, a tip for those setting up ccache on macOS: I found that stability improved dramatically when I stopped using shell-safe wildcards in ccache environment variables and instead relied on explicit CMake-level neutralization. By combining this with OBJECT library isolation, I’ve reached a state where 90% of a “clean” build is served from the cache, even when switching between entirely different plugin projects.
This has turned my deploy cycles from hours into minutes. The entire build of 36 plugins is under 20 minutes now, and with all the signing and notarizing and uploading to the server, the total deploy time is under an hour!!
Hallelujah, praise the great ccache ninja!!
Below are the first few compilations. As you can see, after the first couple, the ccaching has ramped up to 98% overlap, meaning it only needs to compile 2% of the code.
And a very simplified version of cmake:
cmake_minimum_required(VERSION 3.22)
project(MultiPluginFleet)
# 1. THE SURGICAL SANITIZER
# This macro strips identity from JUCE targets so ccache sees identical commands across projects.
macro(sanitize_juce_target target_name)
target_compile_definitions(${target_name} INTERFACE
"JucePlugin_Name=\"\""
"JucePlugin_VersionString=\"0.0.0\""
"JucePlugin_Manufacturer=\"GlobalCache\""
)
endmacro()
# 2. GLOBAL FRAMEWORK SETUP
# We sanitize the generic modules once. Every plugin will now hit the SAME cache for these.
find_package(JUCE CONFIG REQUIRED)
set(GENERIC_MODULES juce_core juce_graphics juce_events juce_dsp)
foreach(mod ${GENERIC_MODULES})
sanitize_juce_target(juce::${mod})
endforeach()
# 3. THE SHARED TIER (Naturally Sanitized)
# We use an OBJECT library so it can be compiled once and shared across binary targets.
add_library(SharedCommon OBJECT shared_source.cpp)
# --- THE SHIELDED INCLUDE PATTERN ---
# We pull headers/definitions WITHOUT linking the target.
# This allows SharedCommon to "know" about JUCE without triggering
# a second compilation of the JUCE .cpp files (which causes duplicate symbols).
foreach(mod ${GENERIC_MODULES})
target_include_directories(SharedCommon SYSTEM PUBLIC
$<TARGET_PROPERTY:juce::${mod},INTERFACE_INCLUDE_DIRECTORIES>)
target_compile_definitions(SharedCommon PRIVATE
$<TARGET_PROPERTY:juce::${mod},INTERFACE_COMPILE_DEFINITIONS>)
endforeach()
# 4. THE IDENTITY TIER (Plugin_A and Plugin_B)
# These targets receive the REAL identity macros.
# Plugin A
juce_add_plugin(Plugin_A
PRODUCT_NAME "Plugin A"
VERSION "1.2.3"
BUNDLE_ID "com.mycompany.plugina"
# ... other JUCE properties
)
# Final Assembly: Links the shared objects + the "real" JUCE modules
target_link_libraries(Plugin_A PRIVATE SharedCommon juce::juce_core juce::juce_graphics)
# Plugin B
juce_add_plugin(Plugin_B
PRODUCT_NAME "Plugin B"
VERSION "2.0.0"
BUNDLE_ID "com.mycompany.pluginb"
)
target_link_libraries(Plugin_B PRIVATE SharedCommon juce::juce_core juce::juce_graphics)
# VERIFICATION:
# ccache will now see the exact same command line for juce_core.cpp
# in both Plugin_A and Plugin_B, resulting in a 100% cross-project hit!

