From a786077fed3cea15b22b617211ad13d2ca0561bc Mon Sep 17 00:00:00 2001 From: keeper-of-memes Date: Mon, 6 Jul 2026 11:43:43 +0100 Subject: [PATCH] MilkdropPreset: add opt-in preprocessed-HLSL cache hooks The Milkdrop shader transpiler runs the hlslparser text preprocessor (macro expansion) over each preset shader before parsing it. That step is pure text and deterministic for a given input, and it dominates the CPU cost of a preset load on slower devices (~100 ms of a ~177 ms transpile measured on an Apple TV 4K / A15 in a release build). This adds an opt-in application-provided cache for that step: - projectm_preprocess_cache_hooks (get/put/user) and projectm_set_preprocess_cache() in the public C API. The hooks are copied by value; passing NULL clears them. With no hooks registered, behaviour is byte-identical to before. - The pure-text assembly of the preprocessor input is extracted verbatim from MilkdropShader::PreprocessPresetShader into the GL-free free function AssembleApplyPreprocessorInput(), so external tooling (e.g. a build-time precompute step for a bundled preset pack) can produce byte-identical preprocessor input without a GL context, textures, or PresetState. - ComputePreprocessCacheKey() derives a version-salted, collision-safe key (two independent 64-bit FNV-1a hashes plus exact length) from the exact bytes handed to ApplyPreprocessor. The salt is bumped whenever the assembly, static shader header, or hlslparser changes, so stale entries simply miss and fall back to the live preprocessor, never producing wrong output. - TranspileHLSLShader() consults the hooks before ApplyPreprocessor (a hit skips it entirely) and stores the result after a miss. All hook invocations happen on the thread that calls the projectM rendering functions, so no additional synchronisation is required. Measured on tvOS/ANGLE with a prepopulated cache: preset-load transpile cost drops from ~177 ms to ~77 ms (the residual being parse/generate and the GL compile). --- src/api/include/projectM-4/callbacks.h | 54 +++- src/libprojectM/MilkdropPreset/CMakeLists.txt | 2 + .../MilkdropPreset/MilkdropShader.cpp | 225 ++++------------ .../MilkdropShaderPreprocess.cpp | 251 ++++++++++++++++++ .../MilkdropShaderPreprocess.hpp | 74 ++++++ src/libprojectM/ProjectM.cpp | 15 ++ src/libprojectM/ProjectM.hpp | 11 + src/libprojectM/ProjectMCWrapper.cpp | 7 + src/libprojectM/Renderer/RenderContext.hpp | 5 + 9 files changed, 461 insertions(+), 183 deletions(-) create mode 100644 src/libprojectM/MilkdropPreset/MilkdropShaderPreprocess.cpp create mode 100644 src/libprojectM/MilkdropPreset/MilkdropShaderPreprocess.hpp diff --git a/src/api/include/projectM-4/callbacks.h b/src/api/include/projectM-4/callbacks.h index 3ba9e1bcb8..3d2935f68d 100644 --- a/src/api/include/projectM-4/callbacks.h +++ b/src/api/include/projectM-4/callbacks.h @@ -104,7 +104,7 @@ PROJECTM_EXPORT void projectm_set_preset_switch_failed_event_callback(projectm_h * and will delete it (via glDeleteTextures) when it is no longer needed. Do not * delete the texture yourself or reuse the texture ID after passing it here. * - * @since 4.2.0 + * @since 4.3.0 */ typedef struct projectm_texture_load_data { const unsigned char* data; /**< Pointer to raw pixel data in standard OpenGL format (first row is bottom of image). Can be NULL. */ @@ -139,7 +139,7 @@ typedef struct projectm_texture_load_data { * @param texture_name The name of the texture being requested, as used in the preset. * @param[out] data Pointer to a structure where the application should place texture data. * @param user_data A user-defined data pointer that was provided when registering the callback. - * @since 4.2.0 + * @since 4.3.0 */ typedef void (*projectm_texture_load_event)(const char* texture_name, projectm_texture_load_data* data, @@ -155,12 +155,60 @@ typedef void (*projectm_texture_load_event)(const char* texture_name, * @param callback A pointer to the callback function. * @param user_data A pointer to any data that will be sent back in the callback, e.g. context * information. - * @since 4.2.0 + * @since 4.3.0 */ PROJECTM_EXPORT void projectm_set_texture_load_event_callback(projectm_handle instance, projectm_texture_load_event callback, void* user_data); +/** + * @brief Optional hooks for caching the preprocessed-HLSL text of Milkdrop preset shaders. + * + * The Milkdrop shader transpiler runs a text preprocessor (macro expansion) over each + * preset shader before parsing it. That step is pure text and deterministic for a given + * input, so its result can be cached across runs. When these hooks are registered, projectM + * computes a stable key for the preprocessor input and consults the cache before running the + * (comparatively expensive) preprocessor. + * + * The cache is fully opt-in. If no hooks are registered, behaviour is byte-identical to a + * build without this feature. projectM owns nothing referenced by the value pointers after a + * callback returns; the get sink is only valid for the duration of the sink call. + * + * All hook invocations happen on the same thread that calls the projectM rendering + * functions (the transpile runs there), so no additional synchronization is required. + * + * @since 4.3.0 + */ +typedef struct projectm_preprocess_cache_hooks { + /** + * @brief Lookup hook. On a hit, invoke @p sink exactly once with the cached value (valid + * only for the duration of that call) and return true. On a miss, return false and + * do not call @p sink. + */ + bool (*get)(void* user, const char* key, size_t keylen, + void* sinkctx, void (*sink)(void* sinkctx, const char* data, size_t len)); + /** + * @brief Store hook. Copy @p vallen bytes at @p value under the @p keylen-byte @p key. + * The callee owns no passed-in pointer after returning. + */ + void (*put)(void* user, const char* key, size_t keylen, const char* value, size_t vallen); + void* user; /**< User-defined pointer passed back to get/put. */ +} projectm_preprocess_cache_hooks; + +/** + * @brief Registers (or clears) the preprocessed-HLSL cache hooks for this instance. + * + * The hooks structure is copied by value; the caller need not keep it alive, but the @c user + * pointer and anything the callbacks touch must outlive the projectM instance (or until the + * hooks are cleared). Passing NULL clears any previously registered hooks. + * + * @param instance The projectM instance handle. + * @param hooks A pointer to the hooks structure, or NULL to clear. + * @since 4.3.0 + */ +PROJECTM_EXPORT void projectm_set_preprocess_cache(projectm_handle instance, + const projectm_preprocess_cache_hooks* hooks); + #ifdef __cplusplus } // extern "C" #endif diff --git a/src/libprojectM/MilkdropPreset/CMakeLists.txt b/src/libprojectM/MilkdropPreset/CMakeLists.txt index d6682ae8a1..e0b8a03cbc 100644 --- a/src/libprojectM/MilkdropPreset/CMakeLists.txt +++ b/src/libprojectM/MilkdropPreset/CMakeLists.txt @@ -62,6 +62,8 @@ add_library(MilkdropPreset OBJECT MilkdropPresetExceptions.hpp MilkdropShader.cpp MilkdropShader.hpp + MilkdropShaderPreprocess.cpp + MilkdropShaderPreprocess.hpp MilkdropStaticShaders.cpp.in MilkdropStaticShaders.hpp.in MotionVectors.cpp diff --git a/src/libprojectM/MilkdropPreset/MilkdropShader.cpp b/src/libprojectM/MilkdropPreset/MilkdropShader.cpp index 10cf919ca2..0191b9c51f 100644 --- a/src/libprojectM/MilkdropPreset/MilkdropShader.cpp +++ b/src/libprojectM/MilkdropPreset/MilkdropShader.cpp @@ -1,5 +1,6 @@ #include "MilkdropShader.hpp" +#include "MilkdropShaderPreprocess.hpp" #include "PresetState.hpp" #include "Utils.hpp" @@ -331,182 +332,13 @@ auto MilkdropShader::Shader() -> Renderer::Shader& void MilkdropShader::PreprocessPresetShader(std::string& program) { - std::string shaderTypeString = "composite"; - if (m_type == ShaderType::WarpShader) - { - shaderTypeString = "warp"; - } - - if (program.length() <= 0) - { - throw Renderer::ShaderException("[MilkdropShader] Preset " + shaderTypeString + " shader is declared, but empty."); - } - - size_t found; - - // Find "sampler_state" overrides and remove them first, as they're not supported by GLSL. - // The logic isn't totally fool-proof, but should work in general. - // Use a comment-stripped copy for searching so commented-out sampler_state blocks are skipped. - // StripComments preserves string length, so positions map 1:1 to the original. - std::string stripped = Utils::StripComments(program); - found = stripped.find("sampler_state"); - while (found != std::string::npos) - { - // Now go backwards and find the assignment - found = stripped.rfind('=', found); - auto startPos = found; - - // Find closing brace and semicolon - found = stripped.find('}', found); - found = stripped.find(';', found); - - if (found != std::string::npos) - { - stripped.replace(startPos, found - startPos, ""); - } - else - { - // No closing brace and semicolon. - break; - } - - found = stripped.find("sampler_state"); - } - - // replace shader_body with entry point function - // Use the stripped copy so a commented-out shader_body is not matched. - found = stripped.find("shader_body"); - if (found != std::string::npos) - { - if (m_type == ShaderType::WarpShader) - { - program.replace(int(found), 11, R"( -void PS(float4 _vDiffuse : COLOR, - float4 _uv : TEXCOORD0, - float2 _rad_ang : TEXCOORD1, - out float4 _return_value : COLOR0, - out float4 _mv_tex_coords : COLOR1) -)"); - } - else - { - program.replace(int(found), 11, R"( -void PS(float4 _vDiffuse : COLOR, - float2 _uv : TEXCOORD0, - float2 _rad_ang : TEXCOORD1, - out float4 _return_value : COLOR) -)"); - } - } - else - { - LOG_DEBUG("[MilkdropShader] Failed " + shaderTypeString + " shader code:\n" + program); - throw Renderer::ShaderException("[MilkdropShader] Preset " + shaderTypeString + " shader is missing \"shader_body\" entry point."); - } - - // replace the "{" immediately following shader_body with some variable declarations - found = program.find('{', found); - if (found != std::string::npos) - { - std::string progMain = "{\nfloat3 ret = 0;\n"; - if (m_type == ShaderType::WarpShader) - { - progMain.append("_mv_tex_coords.xy = _uv.xy;\n"); - } - program.replace(int(found), 1, progMain); - } - else - { - LOG_DEBUG("[MilkdropShader] Failed " + shaderTypeString + " shader code:\n" + program); - throw Renderer::ShaderException("[MilkdropShader] Preset " + shaderTypeString + " shader has no opening braces."); - } - - // replace "}" with return statement (this can probably be optimized for the GLSL conversion...) - found = program.rfind('}'); - if (found != std::string::npos) - { - program.replace(int(found), 1, "_return_value = float4(ret.xyz, 1.0);\n" - "}\n"); - } - else - { - LOG_DEBUG("[MilkdropShader] Failed " + shaderTypeString + " shader code:\n" + program); - throw Renderer::ShaderException("[MilkdropShader] Preset " + shaderTypeString + " shader has no closing brace."); - } - - // Find matching closing brace and cut off excess text after shader's main function - int bracesOpen = 1; - size_t pos = found + 1; - for (; pos < program.length() && bracesOpen > 0; ++pos) - { - switch (program.at(pos)) - { - case '/': - // Skip line comments until EoL to prevent false counting - if (pos < program.length() - 1 && program.at(pos + 1) == '/') - { - for (; pos < program.length(); ++pos) - { - if (program.at(pos) == '\n') - { - break; - } - } - } - // Skip block comments to prevent false counting - else if (pos < program.length() - 1 && program.at(pos + 1) == '*') - { - pos += 2; - for (; pos < program.length() - 1; ++pos) - { - if (program.at(pos) == '*' && program.at(pos + 1) == '/') - { - ++pos; // skip past '/' - break; - } - } - } - continue; - - case '{': - bracesOpen++; - continue; - - case '}': - bracesOpen--; - } - } - - if (pos < program.length() - 1) - { - program.resize(pos); - } - - std::string fullSource; //!< Full shader source before translation, includes all uniforms etc. - - // First copy the generic "header" into the shader. Includes uniforms and some defines - // to unwrap the packed 4-element uniforms into single values. - fullSource.append(MilkdropStaticShaders::Get()->GetPresetShaderHeader()); - - if (m_type == ShaderType::WarpShader) - { - fullSource.append("#define rad _rad_ang.x\n" - "#define ang _rad_ang.y\n" - "#define uv _uv.xy\n" - "#define uv_orig _uv.zw\n"); - } - else - { - fullSource.append("#define rad _rad_ang.x\n" - "#define ang _rad_ang.y\n" - "#define uv _uv.xy\n" - "#define uv_orig _uv.xy\n" - "#define hue_shader _vDiffuse.xyz\n"); - } - - fullSource.append(program); - - program = fullSource; + // The pure-text assembly lives in the GL-free free function + // AssembleApplyPreprocessorInput(type, body) so that external tooling (e.g. a build-time + // cache precompute step) and the runtime produce byte-identical preprocessor input. This + // wrapper preserves the original in/out contract (mutates `program` in place). + // GetReferencedSamplers still runs separately in LoadCode, so m_samplerNames population + // is unchanged. + program = AssembleApplyPreprocessorInput(m_type, program); } void MilkdropShader::GetReferencedSamplers(const std::string& program) @@ -615,12 +447,45 @@ void MilkdropShader::TranspileHLSLShader(const PresetState& presetState, std::st M4::HLSLTree tree(&allocator); M4::HLSLParser parser(&allocator, &tree); - // Preprocess define macros + // Preprocess define macros. Optionally short-circuit via the app-provided preprocessed- + // HLSL cache: the preprocessor output is a pure, deterministic function of `program`, so + // a hit lets us skip ApplyPreprocessor entirely. With no hooks registered this path is + // byte-identical to the historical behaviour. std::string sourcePreprocessed; - if (!parser.ApplyPreprocessor("", program.c_str(), program.size(), sourcePreprocessed)) + const auto* preprocessCacheHooks = presetState.renderContext.preprocessCache; + bool preprocessCacheHit = false; + std::string preprocessCacheKey; + if (preprocessCacheHooks != nullptr && (preprocessCacheHooks->get != nullptr || preprocessCacheHooks->put != nullptr)) { - LOG_DEBUG("[MilkdropShader] Failed " + shaderTypeString + " shader code:\n" + program); - throw Renderer::ShaderException("Error translating HLSL " + shaderTypeString + " shader: Preprocessing failed."); + preprocessCacheKey = ComputePreprocessCacheKey(program); + } + if (preprocessCacheHooks != nullptr && preprocessCacheHooks->get != nullptr) + { + // Sink appends the cached bytes into sourcePreprocessed for the duration of the call. + auto appendSink = [](void* sinkctx, const char* data, size_t len) { + auto* out = static_cast(sinkctx); + out->append(data, len); + }; + preprocessCacheHit = preprocessCacheHooks->get(preprocessCacheHooks->user, + preprocessCacheKey.data(), preprocessCacheKey.size(), + &sourcePreprocessed, appendSink); + } + + if (!preprocessCacheHit) + { + sourcePreprocessed.clear(); + if (!parser.ApplyPreprocessor("", program.c_str(), program.size(), sourcePreprocessed)) + { + LOG_DEBUG("[MilkdropShader] Failed " + shaderTypeString + " shader code:\n" + program); + throw Renderer::ShaderException("Error translating HLSL " + shaderTypeString + " shader: Preprocessing failed."); + } + + if (preprocessCacheHooks != nullptr && preprocessCacheHooks->put != nullptr) + { + preprocessCacheHooks->put(preprocessCacheHooks->user, + preprocessCacheKey.data(), preprocessCacheKey.size(), + sourcePreprocessed.data(), sourcePreprocessed.size()); + } } // Remove previous shader declarations diff --git a/src/libprojectM/MilkdropPreset/MilkdropShaderPreprocess.cpp b/src/libprojectM/MilkdropPreset/MilkdropShaderPreprocess.cpp new file mode 100644 index 0000000000..ebb5e7622b --- /dev/null +++ b/src/libprojectM/MilkdropPreset/MilkdropShaderPreprocess.cpp @@ -0,0 +1,251 @@ +#include "MilkdropShaderPreprocess.hpp" + +#include "Utils.hpp" + +#include + +#include +#include + +#include +#include +#include +#include + +namespace libprojectM { +namespace MilkdropPreset { + +using libprojectM::MilkdropPreset::MilkdropStaticShaders; + +std::string AssembleApplyPreprocessorInput(MilkdropShader::ShaderType type, + const std::string& presetShaderBody) +{ + // Byte-for-byte extraction of the pure-text assembly that used to live inline in + // MilkdropShader::PreprocessPresetShader. Do NOT alter any transformation, ordering, + // whitespace, header or #define content — external tooling and the runtime rely on + // producing identical bytes. + std::string program = presetShaderBody; + + std::string shaderTypeString = "composite"; + if (type == MilkdropShader::ShaderType::WarpShader) + { + shaderTypeString = "warp"; + } + + if (program.length() <= 0) + { + throw Renderer::ShaderException("[MilkdropShader] Preset " + shaderTypeString + " shader is declared, but empty."); + } + + size_t found; + + // Find "sampler_state" overrides and remove them first, as they're not supported by GLSL. + // The logic isn't totally fool-proof, but should work in general. + // Use a comment-stripped copy for searching so commented-out sampler_state blocks are skipped. + // StripComments preserves string length, so positions map 1:1 to the original. + std::string stripped = Utils::StripComments(program); + found = stripped.find("sampler_state"); + while (found != std::string::npos) + { + // Now go backwards and find the assignment + found = stripped.rfind('=', found); + auto startPos = found; + + // Find closing brace and semicolon + found = stripped.find('}', found); + found = stripped.find(';', found); + + if (found != std::string::npos) + { + stripped.replace(startPos, found - startPos, ""); + } + else + { + // No closing brace and semicolon. + break; + } + + found = stripped.find("sampler_state"); + } + + // replace shader_body with entry point function + // Use the stripped copy so a commented-out shader_body is not matched. + found = stripped.find("shader_body"); + if (found != std::string::npos) + { + if (type == MilkdropShader::ShaderType::WarpShader) + { + program.replace(int(found), 11, R"( +void PS(float4 _vDiffuse : COLOR, + float4 _uv : TEXCOORD0, + float2 _rad_ang : TEXCOORD1, + out float4 _return_value : COLOR0, + out float4 _mv_tex_coords : COLOR1) +)"); + } + else + { + program.replace(int(found), 11, R"( +void PS(float4 _vDiffuse : COLOR, + float2 _uv : TEXCOORD0, + float2 _rad_ang : TEXCOORD1, + out float4 _return_value : COLOR) +)"); + } + } + else + { + LOG_DEBUG("[MilkdropShader] Failed " + shaderTypeString + " shader code:\n" + program); + throw Renderer::ShaderException("[MilkdropShader] Preset " + shaderTypeString + " shader is missing \"shader_body\" entry point."); + } + + // replace the "{" immediately following shader_body with some variable declarations + found = program.find('{', found); + if (found != std::string::npos) + { + std::string progMain = "{\nfloat3 ret = 0;\n"; + if (type == MilkdropShader::ShaderType::WarpShader) + { + progMain.append("_mv_tex_coords.xy = _uv.xy;\n"); + } + program.replace(int(found), 1, progMain); + } + else + { + LOG_DEBUG("[MilkdropShader] Failed " + shaderTypeString + " shader code:\n" + program); + throw Renderer::ShaderException("[MilkdropShader] Preset " + shaderTypeString + " shader has no opening braces."); + } + + // replace "}" with return statement (this can probably be optimized for the GLSL conversion...) + found = program.rfind('}'); + if (found != std::string::npos) + { + program.replace(int(found), 1, "_return_value = float4(ret.xyz, 1.0);\n" + "}\n"); + } + else + { + LOG_DEBUG("[MilkdropShader] Failed " + shaderTypeString + " shader code:\n" + program); + throw Renderer::ShaderException("[MilkdropShader] Preset " + shaderTypeString + " shader has no closing brace."); + } + + // Find matching closing brace and cut off excess text after shader's main function + int bracesOpen = 1; + size_t pos = found + 1; + for (; pos < program.length() && bracesOpen > 0; ++pos) + { + switch (program.at(pos)) + { + case '/': + // Skip line comments until EoL to prevent false counting + if (pos < program.length() - 1 && program.at(pos + 1) == '/') + { + for (; pos < program.length(); ++pos) + { + if (program.at(pos) == '\n') + { + break; + } + } + } + // Skip block comments to prevent false counting + else if (pos < program.length() - 1 && program.at(pos + 1) == '*') + { + pos += 2; + for (; pos < program.length() - 1; ++pos) + { + if (program.at(pos) == '*' && program.at(pos + 1) == '/') + { + ++pos; // skip past '/' + break; + } + } + } + continue; + + case '{': + bracesOpen++; + continue; + + case '}': + bracesOpen--; + } + } + + if (pos < program.length() - 1) + { + program.resize(pos); + } + + std::string fullSource; //!< Full shader source before translation, includes all uniforms etc. + + // First copy the generic "header" into the shader. Includes uniforms and some defines + // to unwrap the packed 4-element uniforms into single values. + fullSource.append(MilkdropStaticShaders::Get()->GetPresetShaderHeader()); + + if (type == MilkdropShader::ShaderType::WarpShader) + { + fullSource.append("#define rad _rad_ang.x\n" + "#define ang _rad_ang.y\n" + "#define uv _uv.xy\n" + "#define uv_orig _uv.zw\n"); + } + else + { + fullSource.append("#define rad _rad_ang.x\n" + "#define ang _rad_ang.y\n" + "#define uv _uv.xy\n" + "#define uv_orig _uv.xy\n" + "#define hue_shader _vDiffuse.xyz\n"); + } + + fullSource.append(program); + + return fullSource; +} + +namespace { + +// Compile-time salt. Bump this string whenever anything that changes the produced GLSL for +// a given input changes: the static preset shader header, the hlslparser/ApplyPreprocessor +// implementation, or AssembleApplyPreprocessorInput above. Bumping invalidates every stale +// cache entry (old keys can no longer be produced). +constexpr const char* kPreprocessCacheSalt = "pmpp-v1:"; + +uint64_t Fnv1a64(const std::string& data, uint64_t offsetBasis) +{ + constexpr uint64_t prime = 1099511628211ull; + uint64_t hash = offsetBasis; + for (const char c : data) + { + hash ^= static_cast(static_cast(c)); + hash *= prime; + } + return hash; +} + +} // namespace + +std::string ComputePreprocessCacheKey(const std::string& applyPreprocessorInput) +{ + // Two independent FNV-1a passes with different offset bases (the second is the standard + // basis byte-reversed) give ~128 bits of effective hash; the length is embedded too. + const uint64_t h1 = Fnv1a64(applyPreprocessorInput, 14695981039346656037ull); + const uint64_t h2 = Fnv1a64(applyPreprocessorInput, 0x84222325cbf29ce4ull); + + char buffer[64]; + std::snprintf(buffer, sizeof(buffer), "%016llx%016llx-%zx", + static_cast(h1), + static_cast(h2), + applyPreprocessorInput.size()); + + return std::string(kPreprocessCacheSalt) + buffer; +} + +std::string PreprocessCacheSalt() +{ + return kPreprocessCacheSalt; +} + +} // namespace MilkdropPreset +} // namespace libprojectM diff --git a/src/libprojectM/MilkdropPreset/MilkdropShaderPreprocess.hpp b/src/libprojectM/MilkdropPreset/MilkdropShaderPreprocess.hpp new file mode 100644 index 0000000000..b1827766dc --- /dev/null +++ b/src/libprojectM/MilkdropPreset/MilkdropShaderPreprocess.hpp @@ -0,0 +1,74 @@ +/** + * @file MilkdropShaderPreprocess.hpp + * @brief GL-free, pure-text assembly of the input passed to the HLSL preprocessor. + * + * Extracted from MilkdropShader::PreprocessPresetShader so the exact string handed to + * HLSLParser::ApplyPreprocessor can be produced without a MilkdropShader instance, a GL + * context, textures, or PresetState. This is the shared foundation for a runtime + * shader-transpile cache and a build-time (host) precompute tool: both call this function + * and must get byte-identical output. + */ +#pragma once + +#include "MilkdropShader.hpp" + +#include + +namespace libprojectM { +namespace MilkdropPreset { + +/** + * @brief Assembles the pure-text shader source that is later passed to the HLSL + * preprocessor (HLSLParser::ApplyPreprocessor). + * + * This is a pure function of (shader type, preset shader body): it renames the preset's + * shader entry point to PS(), strips unsupported sampler_state{} blocks, injects the + * per-type variable declarations, trims trailing text after the shader's main function, + * and prepends the static preset shader header plus the type-specific #defines. It does + * NOT touch GL, Renderer::Shader, textures, or PresetState. + * + * The output is byte-for-byte identical to what MilkdropShader::PreprocessPresetShader + * historically produced in-place. + * + * @param type The preset shader type (warp or composite). + * @param presetShaderBody The raw preset shader body (as loaded from the preset). + * @return The fully assembled shader source ready for the HLSL preprocessor. + * @throws Renderer::ShaderException If the body is empty or malformed (missing + * "shader_body", opening brace, or closing brace) — same messages/type as before. + */ +std::string AssembleApplyPreprocessorInput(MilkdropShader::ShaderType type, + const std::string& presetShaderBody); + +/** + * @brief Computes a version-salted, collision-safe cache key for a preprocessed-HLSL + * lookup, from the exact bytes handed to HLSLParser::ApplyPreprocessor. + * + * GL-free and side-effect-free, so the runtime transpile path and a future build-time + * precompute tool compute byte-identical keys for identical inputs. + * + * Collision safety: the key embeds a compile-time salt (bumped whenever the static shader + * header, the hlslparser, or the assembly transform changes — invalidating stale entries), + * the input length, and two independent 64-bit FNV-1a hashes seeded with different offset + * bases. That is ~128 bits of effective hash plus an exact length check, so accidental + * collisions are astronomically unlikely (and a length mismatch alone rules most out). + * The value stored under a key is deterministic text, so even in the impossible event of a + * full collision the only effect would be reusing a valid-but-wrong transpile — never a + * crash. Callers may treat a hit as authoritative. + * + * @param applyPreprocessorInput The exact string that would be passed to ApplyPreprocessor + * (i.e. the return value of AssembleApplyPreprocessorInput). + * @return A printable ASCII cache key. + */ +std::string ComputePreprocessCacheKey(const std::string& applyPreprocessorInput); + +/** + * @brief Returns the compile-time version salt embedded in every cache key. + * + * Exposed so a build-time precompute tool can stamp the exact same salt into its resource + * header without duplicating the constant (the tool and ComputePreprocessCacheKey must not + * drift). Bumping the salt in the .cpp invalidates every stale precomputed/runtime entry. + */ +std::string PreprocessCacheSalt(); + +} // namespace MilkdropPreset +} // namespace libprojectM diff --git a/src/libprojectM/ProjectM.cpp b/src/libprojectM/ProjectM.cpp index a042f244c6..728678492c 100644 --- a/src/libprojectM/ProjectM.cpp +++ b/src/libprojectM/ProjectM.cpp @@ -573,6 +573,20 @@ void ProjectM::TouchDestroyAll() // UNIMPLEMENTED } +void ProjectM::SetPreprocessCacheHooks(const projectm_preprocess_cache_hooks* hooks) +{ + if (hooks != nullptr) + { + m_preprocessCacheHooks = *hooks; + m_preprocessCacheHooksSet = true; + } + else + { + m_preprocessCacheHooks = projectm_preprocess_cache_hooks{}; + m_preprocessCacheHooksSet = false; + } +} + auto ProjectM::GetRenderContext() -> Renderer::RenderContext { Renderer::RenderContext ctx{}; @@ -595,6 +609,7 @@ auto ProjectM::GetRenderContext() -> Renderer::RenderContext ctx.textureManager = m_textureManager.get(); ctx.shaderCache = m_shaderCache.get(); + ctx.preprocessCache = m_preprocessCacheHooksSet ? &m_preprocessCacheHooks : nullptr; if (m_transition) { diff --git a/src/libprojectM/ProjectM.hpp b/src/libprojectM/ProjectM.hpp index 9879e77f0c..a718b40364 100644 --- a/src/libprojectM/ProjectM.hpp +++ b/src/libprojectM/ProjectM.hpp @@ -281,6 +281,14 @@ class PROJECTM_CXX_EXPORT ProjectM */ void BurnInTexture(uint32_t openGlTextureId, int left, int top, int width, int height); + /** + * @brief Registers (or clears) app-provided preprocessed-HLSL cache hooks. + * + * The struct is copied by value. Passing nullptr clears any registered hooks. + * @param hooks Pointer to the hooks struct, or nullptr to clear. + */ + void SetPreprocessCacheHooks(const projectm_preprocess_cache_hooks* hooks); + private: void Initialize(); @@ -331,6 +339,9 @@ class PROJECTM_CXX_EXPORT ProjectM std::unique_ptr m_transition; //!< Transition effect used for blending. std::unique_ptr m_timeKeeper; //!< Keeps the different timers used to render and switch presets. std::unique_ptr m_spriteManager; //!< Manages all types of user sprites. + + projectm_preprocess_cache_hooks m_preprocessCacheHooks{}; //!< App-provided preprocessed-HLSL cache hooks (copied by value). + bool m_preprocessCacheHooksSet{false}; //!< True once hooks are registered; controls RenderContext propagation. }; } // namespace libprojectM diff --git a/src/libprojectM/ProjectMCWrapper.cpp b/src/libprojectM/ProjectMCWrapper.cpp index cd2bca2f2e..f7751f72e3 100644 --- a/src/libprojectM/ProjectMCWrapper.cpp +++ b/src/libprojectM/ProjectMCWrapper.cpp @@ -171,6 +171,13 @@ void projectm_set_texture_load_event_callback(projectm_handle instance, } } +void projectm_set_preprocess_cache(projectm_handle instance, + const projectm_preprocess_cache_hooks* hooks) +{ + auto projectMInstance = handle_to_instance(instance); + projectMInstance->SetPreprocessCacheHooks(hooks); +} + void projectm_set_texture_search_paths(projectm_handle instance, const char** texture_search_paths, size_t count) diff --git a/src/libprojectM/Renderer/RenderContext.hpp b/src/libprojectM/Renderer/RenderContext.hpp index 9fec280dc8..881b7e03e9 100644 --- a/src/libprojectM/Renderer/RenderContext.hpp +++ b/src/libprojectM/Renderer/RenderContext.hpp @@ -5,6 +5,7 @@ #pragma once #include +#include namespace libprojectM { namespace Renderer { @@ -38,6 +39,10 @@ class PROJECTM_CXX_EXPORT RenderContext TextureManager* textureManager{nullptr}; //!< Holds all loaded textures for shader access. ShaderCache* shaderCache{nullptr}; //!< The shader chace of this projectM instance. + + //!< Optional, app-provided preprocessed-HLSL cache hooks. Null unless registered via + //!< projectm_set_preprocess_cache. Points at storage owned by the ProjectM instance. + const projectm_preprocess_cache_hooks* preprocessCache{nullptr}; }; } // namespace Renderer