Преглед на файлове

fixed several todos, wayland driver no longer blocks while waiting for compositor events, added real engine audio response, effects that start hidden now get their passes built, TEMP-DIAG cleanup

UwU преди 5 дни
родител
ревизия
9c6a5fd978

+ 8 - 0
src/WallpaperEngine/Application/WallpaperApplication.cpp

@@ -2174,6 +2174,14 @@ void WallpaperApplication::render () {
 	m_videoDriver->getInputContext ().update ();
 	m_videoDriver->dispatchEventQueue ();
 
+	// read every frame, --fps can change with a hotswap
+	const float minimumTime = 1.0f / std::max (1, this->m_context.settings.render.maximumFPS);
+	const float frameTime = m_videoDriver->getRenderTime () - rawTimeNow;
+
+	if (frameTime < minimumTime) {
+	    std::this_thread::sleep_for (std::chrono::duration<float> (minimumTime - frameTime));
+	}
+
 	if (m_videoDriver->closeRequested ()) {
 	    sLog.out ("Stop requested by driver");
 	    this->m_context.state.general.keepRunning = false;

+ 73 - 11
src/WallpaperEngine/Audio/Drivers/Recorders/PulseAudioPlaybackRecorder.cpp

@@ -1,5 +1,6 @@
 #include "PulseAudioPlaybackRecorder.h"
 #include "WallpaperEngine/Logging/Log.h"
+#include <pulse/rtclock.h>
 #include <chrono>
 #include <cmath>
 #include <cstring>
@@ -24,12 +25,55 @@ std::string wallClockTimestamp () {
 }
 } // namespace
 
-void pa_stream_notify_cb (pa_stream* stream, void* /*userdata*/) {
+void pa_server_info_cb (pa_context* ctx, const pa_server_info* info, void* userdata);
+
+void pa_retry_capture_cb (pa_mainloop_api* api, pa_time_event* event, const struct timeval* /*tv*/, void* userdata) {
+    auto* recorder = static_cast<PulseAudioPlaybackRecorder::PulseAudioData*> (userdata);
+
+    api->time_free (event);
+
+    if (recorder->captureStream == nullptr) {
+	return;
+    }
+
+    pa_context* ctx = pa_stream_get_context (recorder->captureStream);
+
+    if (pa_context_get_state (ctx) != PA_CONTEXT_READY) {
+	return;
+    }
+
+    if (pa_operation* o = pa_context_get_server_info (ctx, &pa_server_info_cb, userdata)) {
+	pa_operation_unref (o);
+    }
+}
+
+void pa_stream_notify_cb (pa_stream* stream, void* userdata) {
+    auto* recorder = static_cast<PulseAudioPlaybackRecorder::PulseAudioData*> (userdata);
+
     switch (pa_stream_get_state (stream)) {
 	case PA_STREAM_FAILED:
-	    sLog.error ("Cannot open stream for capture. Audio processing is disabled");
+	case PA_STREAM_TERMINATED:
+	    if (stream != recorder->captureStream) {
+		break;
+	    }
+
+	    // the server can drop the monitor stream while sinks change state (a hotswap starting or stopping
+	    // sounds) and nothing else may follow to re-take it, so retry on our own
+	    if (!recorder->captureLost) {
+		recorder->captureLost = true;
+		sLog.error ("Audio capture stream lost, retrying every second");
+	    }
+
+	    pa_context_rttime_new (
+		pa_stream_get_context (stream), pa_rtclock_now () + PA_USEC_PER_SEC, &pa_retry_capture_cb, userdata
+	    );
 	    break;
 	case PA_STREAM_READY:
+	    if (recorder->captureLost) {
+		recorder->captureLost = false;
+		sLog.out ("Audio capture stream restored");
+	    }
+
 	    sLog.debug ("[", wallClockTimestamp (), "] Audio processing: capture stream ready");
 	    break;
 	default:
@@ -102,29 +146,42 @@ void pa_stream_read_cb (pa_stream* stream, const size_t /*nbytes*/, void* userda
 }
 
 void pa_server_info_cb (pa_context* ctx, const pa_server_info* info, void* userdata) {
-    if (info == nullptr) {
+    if (info == nullptr || info->default_sink_name == nullptr) {
 	return;
     }
 
     auto* recorder = static_cast<PulseAudioPlaybackRecorder::PulseAudioData*> (userdata);
+    const std::string monitor_name = std::string (info->default_sink_name) + ".monitor";
+
+    if (recorder->captureStream) {
+	// sink/source events also fire for volume and state changes (another wallpaper starting its sounds),
+	// only re-take the stream when the default sink moved or the old one died
+	const bool alive = PA_STREAM_IS_GOOD (pa_stream_get_state (recorder->captureStream));
+
+	if (alive && monitor_name == recorder->monitorName) {
+	    return;
+	}
+
+	// the context keeps a connected stream alive, unref alone left it feeding the same buffer
+	pa_stream_set_state_callback (recorder->captureStream, nullptr, nullptr);
+	pa_stream_set_read_callback (recorder->captureStream, nullptr, nullptr);
+	pa_stream_disconnect (recorder->captureStream);
+	pa_stream_unref (recorder->captureStream);
+	recorder->captureStream = nullptr;
+    }
+
+    recorder->monitorName = monitor_name;
 
     pa_sample_spec spec;
     spec.format = PA_SAMPLE_U8;
     spec.rate = 44100;
     spec.channels = 1;
 
-    if (recorder->captureStream) {
-	pa_stream_unref (recorder->captureStream);
-    }
-
     recorder->captureStream = pa_stream_new (ctx, "output monitor", &spec, nullptr);
 
     pa_stream_set_state_callback (recorder->captureStream, &pa_stream_notify_cb, userdata);
     pa_stream_set_read_callback (recorder->captureStream, &pa_stream_read_cb, userdata);
 
-    std::string monitor_name (info->default_sink_name);
-    monitor_name += ".monitor";
-
     pa_buffer_attr attr {};
 
     // 10 = latency msecs, 750 = max msecs to store
@@ -154,7 +211,10 @@ void pa_context_notify_cb (pa_context* ctx, void* userdata) {
 	    {
 		pa_context_set_subscribe_callback (ctx, pa_context_subscribe_cb, userdata);
 		pa_operation* o = pa_context_subscribe (
-		    ctx, static_cast<pa_subscription_mask_t> (PA_SUBSCRIPTION_MASK_SINK | PA_SUBSCRIPTION_MASK_SOURCE),
+		    ctx,
+		    static_cast<pa_subscription_mask_t> (
+			PA_SUBSCRIPTION_MASK_SINK | PA_SUBSCRIPTION_MASK_SOURCE | PA_SUBSCRIPTION_MASK_SERVER
+		    ),
 		    nullptr, nullptr
 		);
 
@@ -222,6 +282,8 @@ PulseAudioPlaybackRecorder::~PulseAudioPlaybackRecorder () {
     }
 
     if (m_captureData.captureStream) {
+	pa_stream_set_state_callback (m_captureData.captureStream, nullptr, nullptr);
+	pa_stream_disconnect (m_captureData.captureStream);
 	pa_stream_unref (m_captureData.captureStream);
     }
 

+ 3 - 0
src/WallpaperEngine/Audio/Drivers/Recorders/PulseAudioPlaybackRecorder.h

@@ -6,6 +6,7 @@
 #include <SDL.h>
 #include <atomic>
 #include <chrono>
+#include <string>
 #include <pulse/pulseaudio.h>
 
 #define WAVE_BUFFER_SIZE 1024
@@ -25,6 +26,8 @@ public:
 	size_t currentWritePointer;
 	bool fullFrameReady;
 	pa_stream* captureStream;
+	std::string monitorName;
+	bool captureLost;
     };
 
     PulseAudioPlaybackRecorder ();

+ 11 - 3
src/WallpaperEngine/Data/Model/Object.h

@@ -149,7 +149,7 @@ struct ParticleEmitter {
     float delay;
     float duration;
     glm::vec2 audioProcessingBounds;
-    int audioProcessingExponent;
+    float audioProcessingExponent;
     int audioProcessingFrequencyStart;
     int audioProcessingFrequencyEnd;
     int audioProcessingMode;
@@ -374,7 +374,9 @@ public:
 	UserSettingUniquePtr distanceInner, UserSettingUniquePtr distanceOuter, UserSettingUniquePtr speedInner,
 	UserSettingUniquePtr speedOuter, UserSettingUniquePtr centerForce, UserSettingUniquePtr ringRadius,
 	UserSettingUniquePtr ringWidth, UserSettingUniquePtr ringPullDistance, UserSettingUniquePtr ringPullForce,
-	UserSettingUniquePtr audioProcessingMode, UserSettingUniquePtr audioProcessingBounds
+	UserSettingUniquePtr audioProcessingMode, UserSettingUniquePtr audioProcessingBounds,
+	UserSettingUniquePtr audioProcessingExponent, UserSettingUniquePtr audioProcessingFrequencyStart,
+	UserSettingUniquePtr audioProcessingFrequencyEnd
     ) :
 	controlPoint (controlPoint), flags (flags), axis (std::move (axis)), offset (std::move (offset)),
 	distanceInner (std::move (distanceInner)), distanceOuter (std::move (distanceOuter)),
@@ -382,7 +384,10 @@ public:
 	ringRadius (std::move (ringRadius)), ringWidth (std::move (ringWidth)),
 	ringPullDistance (std::move (ringPullDistance)), ringPullForce (std::move (ringPullForce)),
 	audioProcessingMode (std::move (audioProcessingMode)),
-	audioProcessingBounds (std::move (audioProcessingBounds)) { }
+	audioProcessingBounds (std::move (audioProcessingBounds)),
+	audioProcessingExponent (std::move (audioProcessingExponent)),
+	audioProcessingFrequencyStart (std::move (audioProcessingFrequencyStart)),
+	audioProcessingFrequencyEnd (std::move (audioProcessingFrequencyEnd)) { }
     int controlPoint;
     int flags; // 1 = infinite axis, 2 = maintain distance to center, 4 = ring shape
     UserSettingUniquePtr axis;
@@ -398,6 +403,9 @@ public:
     UserSettingUniquePtr ringPullForce; // Ring mode: strength of ring attraction
     UserSettingUniquePtr audioProcessingMode;
     UserSettingUniquePtr audioProcessingBounds;
+    UserSettingUniquePtr audioProcessingExponent;
+    UserSettingUniquePtr audioProcessingFrequencyStart;
+    UserSettingUniquePtr audioProcessingFrequencyEnd;
 };
 
 class ControlPointAttractOperator : public ParticleOperatorBase {

+ 8 - 5
src/WallpaperEngine/Data/Parsers/ObjectParser.cpp

@@ -611,7 +611,7 @@ ParticleEmitter ObjectParser::parseParticleEmitter (const JSON& it) {
 	    .delay = it.optional ("delay", 0.0f),
 	    .duration = it.optional ("duration", 0.0f),
 	    .audioProcessingBounds = parseVec2 ("audioprocessingbounds", glm::vec2 (0.8f, 1.0f)),
-	    .audioProcessingExponent = it.optional ("audioprocessingexponent", 2),
+	    .audioProcessingExponent = it.optional ("audioprocessingexponent", 2.0f),
 	    .audioProcessingFrequencyStart = it.optional ("audioprocessingfrequencystart", 0),
 	    .audioProcessingFrequencyEnd = it.optional ("audioprocessingfrequencyend", 1),
 	    .audioProcessingMode = it.optional ("audioprocessingmode", 0),
@@ -716,10 +716,10 @@ ParticleOperatorUniquePtr ObjectParser::parseParticleOperator (const JSON& it, c
 	    it.user ("speedmax", properties, 1000.0f), it.user ("timescale", properties, 0.01f),
 	    it.user ("mask", properties, glm::vec3 (1.0f, 1.0f, 0.0f)), it.user ("phasemin", properties, 0.0f),
 	    it.user ("phasemax", properties, 0.0f), it.user ("audioprocessingmode", properties, 0),
-	    it.user ("audioprocessingbounds", properties, glm::vec2 (0.0f, 1.0f)),
-	    it.user ("audioprocessingexponent", properties, 1.0f),
+	    it.user ("audioprocessingbounds", properties, glm::vec2 (0.8f, 1.0f)),
+	    it.user ("audioprocessingexponent", properties, 2.0f),
 	    it.user ("audioprocessingfrequencystart", properties, 0),
-	    it.user ("audioprocessingfrequencyend", properties, 15)
+	    it.user ("audioprocessingfrequencyend", properties, 1)
 	);
     } else if (name == "vortex" || name == "vortex_v2") {
 	return std::make_unique<VortexOperator> (
@@ -732,7 +732,10 @@ ParticleOperatorUniquePtr ObjectParser::parseParticleOperator (const JSON& it, c
 	    it.user ("ringradius", properties, 300.0f), it.user ("ringwidth", properties, 50.0f),
 	    it.user ("ringpulldistance", properties, 50.0f), it.user ("ringpullforce", properties, 10.0f),
 	    it.user ("audioprocessingmode", properties, 0),
-	    it.user ("audioprocessingbounds", properties, glm::vec2 (0.0f, 1.0f))
+	    it.user ("audioprocessingbounds", properties, glm::vec2 (0.8f, 1.0f)),
+	    it.user ("audioprocessingexponent", properties, 2.0f),
+	    it.user ("audioprocessingfrequencystart", properties, 0),
+	    it.user ("audioprocessingfrequencyend", properties, 1)
 	);
     } else if (name == "controlpointattract") {
 	return std::make_unique<ControlPointAttractOperator> (

+ 0 - 13
src/WallpaperEngine/Render/Drivers/GLFWOpenGLDriver.cpp

@@ -15,8 +15,6 @@
 #include <cstdlib>
 #include <unistd.h>
 
-#include <algorithm>
-
 using namespace WallpaperEngine::Render::Drivers;
 
 void CustomGLFWErrorHandler (int errorCode, const char* reason) { sLog.error ("GLFW error ", errorCode, ": ", reason); }
@@ -108,10 +106,6 @@ glm::ivec2 GLFWOpenGLDriver::getFramebufferSize () const {
 uint32_t GLFWOpenGLDriver::getFrameCounter () const { return this->m_frameCounter; }
 
 void GLFWOpenGLDriver::dispatchEventQueue () {
-    static float startTime, endTime;
-    // read every frame, --fps can change with a hotswap
-    const float minimumTime = 1.0f / std::max (1, this->m_context.settings.render.maximumFPS);
-    startTime = this->getRenderTime ();
     glClear (GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT);
 
     for (const auto& [screen, viewport] : this->m_output->getViewports ()) {
@@ -139,17 +133,10 @@ void GLFWOpenGLDriver::dispatchEventQueue () {
 	}
     }
 
-    // TODO: frametime control should go back to CWallpaperApplication once actual particles are
-    // implemented, as those will likely require a different processing rate
     this->m_output->updateRender ();
     glfwSwapBuffers (this->m_window);
     glfwPollEvents ();
     this->m_frameCounter++;
-    endTime = this->getRenderTime ();
-
-    if ((endTime - startTime) < minimumTime) {
-	usleep ((minimumTime - (endTime - startTime)) * CLOCKS_PER_SEC);
-    }
 }
 
 void* GLFWOpenGLDriver::getProcAddress (const char* name) const {

+ 32 - 14
src/WallpaperEngine/Render/Drivers/WaylandOpenGLDriver.cpp

@@ -20,11 +20,15 @@ extern "C" {
 #undef static
 
 #include <algorithm>
+#include <cerrno>
+#include <poll.h>
 #include <string.h>
 #include <unistd.h>
 
 using namespace WallpaperEngine::Render::Drivers;
 
+constexpr int EVENT_WAIT_TIMEOUT_MS = 100;
+
 static void handlePointerEnter (
     void* data, struct wl_pointer* wl_pointer, uint32_t serial, struct wl_surface* surface, wl_fixed_t surface_x,
     wl_fixed_t surface_y
@@ -440,26 +444,40 @@ void WaylandOpenGLDriver::dispatchEventQueue () {
 	}
     }
 
-    // TODO: frametime control should go back to CWallpaperApplication once actual particles are
-    // implemented, as those will likely require a different processing rate
-    // TODO: write a non-blocking version of this once particle simulation starts working, otherwise
-    // wl_display_dispatch will block if no surfaces are being drawn
-    static float startTime, endTime;
-    // read every frame, --fps can change with a hotswap
-    const float minimumTime = 1.0f / std::max (1, this->m_context.settings.render.maximumFPS);
-    startTime = this->getRenderTime ();
+    // the compositor stops sending frame callbacks to hidden surfaces, a blocking dispatch would stall the main loop
+    wl_display* display = m_waylandContext.display;
 
-    if (wl_display_dispatch (m_waylandContext.display) == -1) {
-	m_requestedExit = true;
+    while (wl_display_prepare_read (display) != 0) {
+	if (wl_display_dispatch_pending (display) == -1) {
+	    m_requestedExit = true;
+	    return;
+	}
     }
 
-    m_frameCounter++;
+    wl_display_flush (display);
+
+    pollfd fd = { .fd = wl_display_get_fd (display), .events = POLLIN, .revents = 0 };
+    const int ready = poll (&fd, 1, EVENT_WAIT_TIMEOUT_MS);
+
+    if (ready > 0) {
+	if (wl_display_read_events (display) == -1) {
+	    m_requestedExit = true;
+	    return;
+	}
+    } else {
+	wl_display_cancel_read (display);
 
-    endTime = this->getRenderTime ();
+	if (ready == -1 && errno != EINTR) {
+	    m_requestedExit = true;
+	    return;
+	}
+    }
 
-    if ((endTime - startTime) < minimumTime) {
-	usleep ((minimumTime - (endTime - startTime)) * CLOCKS_PER_SEC);
+    if (wl_display_dispatch_pending (display) == -1) {
+	m_requestedExit = true;
     }
+
+    m_frameCounter++;
 }
 
 Output::Output& WaylandOpenGLDriver::getOutput () { return this->m_output; }

+ 178 - 256
src/WallpaperEngine/Render/Objects/CImage.cpp

@@ -283,8 +283,7 @@ std::optional<double> scorePuppetMeshCoherence (const PuppetMeshData& data) {
 // plausible stride is tried against every candidate mesh header found in the file, and whichever
 // combination produces the most coherent triangulated mesh wins.
 std::optional<PuppetVertexLayout> resolvePuppetVertexLayout (
-    const BinaryReader& reader, size_t markerSize, size_t mdlsOffset, size_t meshHeaderSize,
-    const std::string& debugName
+    const BinaryReader& reader, size_t markerSize, size_t mdlsOffset, size_t meshHeaderSize
 ) {
     constexpr size_t minVertexStride = 20; // position (12 bytes) + uv (8 bytes), no bone data at all
     constexpr size_t maxVertexStride = 256; // generous upper bound, comfortably covers multi-bone rigs
@@ -295,9 +294,6 @@ std::optional<PuppetVertexLayout> resolvePuppetVertexLayout (
     std::optional<PuppetVertexLayout> best;
     double bestScore = std::numeric_limits<double>::max ();
 
-    // TEMP-DIAG: score of every (block, stride) attempt
-    std::vector<std::tuple<double, size_t, size_t, size_t>> allScored;
-
     for (const auto& block : candidates) {
 	for (size_t stride = minVertexStride; stride <= maxVertexStride; stride += strideStep) {
 	    const auto data = readPuppetMeshData (reader, block, meshHeaderSize, stride);
@@ -310,10 +306,6 @@ std::optional<PuppetVertexLayout> resolvePuppetVertexLayout (
 		continue;
 	    }
 
-	    if (!debugName.empty ()) {
-		allScored.emplace_back (*score, block.headerOffset, stride, data->positions.size () / 3);
-	    }
-
 	    if (*score >= bestScore) {
 		continue;
 	    }
@@ -323,18 +315,6 @@ std::optional<PuppetVertexLayout> resolvePuppetVertexLayout (
 	}
     }
 
-    if (!debugName.empty ()) {
-	std::sort (allScored.begin (), allScored.end ());
-	sLog.out ("TEMP-DIAG vertex layout candidates for ", debugName, ": total=", allScored.size ());
-	for (size_t i = 0; i < allScored.size () && i < 10; i++) {
-	    const auto& [score, headerOffset, stride, vertexCount] = allScored[i];
-	    sLog.out (
-		"TEMP-DIAG   #", i, " score=", score, " headerOffset=", headerOffset, " stride=", stride,
-		" vertexCount=", vertexCount
-	    );
-	}
-    }
-
     return best;
 }
 
@@ -577,7 +557,8 @@ std::optional<size_t> findNextPuppetClipHeader (
 
 // Parses every baked animation clip out of the MDLA section (see docs/rendering/MDL_FILES.md).
 std::vector<PuppetAnimationClip> parsePuppetAnimationClips (
-    const std::vector<char>& data, const BinaryReader& reader, size_t mdlaOffset, uint32_t expectedBoneCount
+    const std::vector<char>& data, const BinaryReader& reader, size_t mdlaOffset, uint32_t expectedBoneCount,
+    bool dumpBone29
 ) {
     reader.base ().seekg (static_cast<std::streamoff> (mdlaOffset), std::ios::beg);
 
@@ -652,7 +633,7 @@ std::vector<PuppetAnimationClip> parsePuppetAnimationClips (
 	    }
 
 	    // TEMP-DIAG: raw keyframe dump for bone 29
-	    if (boneIndex == 29 && expectedBoneCount > 29) {
+	    if (dumpBone29 && boneIndex == 29 && expectedBoneCount > 29) {
 		float minRotZ = std::numeric_limits<float>::max (), maxRotZ = std::numeric_limits<float>::lowest ();
 		float minPosX = std::numeric_limits<float>::max (), maxPosX = std::numeric_limits<float>::lowest ();
 		for (const auto& kf : track) {
@@ -837,7 +818,7 @@ CImage::ResolvedTransform CImage::resolveTransform (const Object& object) const
 	local.scale.y *= anchorScale.y;
 	resolved = { local.origin, local.scale * resolved.scale, local.angle + anchorAngle, meshPivotAngle };
 
-	if ((chain[i]->id == 422 || chain[i]->id == 134) && !this->m_finalOriginLogged.contains (chain[i]->id)) {
+	if (chain[i]->id == 134 && !this->m_finalOriginLogged.contains (chain[i]->id)) {
 	    this->m_finalOriginLogged.insert (chain[i]->id);
 	    sLog.out (
 		"TEMP-DIAG final resolved origin for ", chain[i]->name, " (", chain[i]->id, "): anchorOrigin=(",
@@ -1127,11 +1108,12 @@ CImage::~CImage () {
     this->m_texture->decrementUsageCount ();
 
     // delete passes first as they depend on the image's data
-    for (auto* pass : this->m_passes) {
+    for (auto* pass : this->m_allPasses.empty () ? this->m_passes : this->m_allPasses) {
 	delete pass;
     }
 
     this->m_passes.clear ();
+    this->m_allPasses.clear ();
 
     glDeleteBuffers (1, &this->m_sceneSpacePosition);
     glDeleteBuffers (1, &this->m_copySpacePosition);
@@ -1179,9 +1161,7 @@ bool CImage::loadPuppetMesh (const glm::vec2& size) {
 
 	const bool isDiagTarget
 	    = this->getImage ().name == "bodyhairkochuru" || this->getImage ().name == "spiritblossomahribase";
-	const auto layout = resolvePuppetVertexLayout (
-	    reader, markerSize, mdlsOffset, meshHeaderSize, isDiagTarget ? this->getImage ().name : ""
-	);
+	const auto layout = resolvePuppetVertexLayout (reader, markerSize, mdlsOffset, meshHeaderSize);
 	if (!layout.has_value ()) {
 	    sLog.error ("Could not find a usable MDLV mesh block in ", *this->getImage ().model->puppet);
 	    return false;
@@ -1318,8 +1298,9 @@ bool CImage::loadPuppetMesh (const glm::vec2& size) {
 
 		std::vector<PuppetAnimationClip> clips;
 		if (mdlaOffsetLooksValid) {
-		    clips
-			= parsePuppetAnimationClips (data, reader, mdlaOffset, static_cast<uint32_t> (this->m_puppetBones.size ()));
+		    clips = parsePuppetAnimationClips (
+			data, reader, mdlaOffset, static_cast<uint32_t> (this->m_puppetBones.size ()), isDiagTarget
+		    );
 		} else {
 		    sLog.error (
 			"Puppet MDLS data for ", *this->getImage ().model->puppet,
@@ -1570,7 +1551,7 @@ void CImage::updatePuppetSkinning () {
 	animatedLocals[i] = local;
 
 	// TEMP-DIAG: bones 29/30 logged every frame
-	if (!this->m_boneTrackDiagLogged || ((i == 29 || i == 30) && this->getImage ().name == "bodyhairkochuru")) {
+	if ((i == 29 || i == 30) && this->getImage ().name == "bodyhairkochuru") {
 	    sLog.out (
 		"TEMP-DIAG bone anim for ", this->getImage ().name, " (", this->getId (), ") i=", i, " parent=",
 		bone.parent, " bindLocalPos=(", bone.bindLocal[3].x, ",", bone.bindLocal[3].y, ") animatedPos=(",
@@ -1581,8 +1562,6 @@ void CImage::updatePuppetSkinning () {
 	}
     }
 
-    this->m_boneTrackDiagLogged = true;
-
     const std::vector<glm::mat4> worldAnimated = composeBoneWorldTransforms (animatedParents, animatedLocals);
 
     // attachment points (see getAttachmentPointMeshTransform) need the live bone transforms independently
@@ -1769,47 +1748,9 @@ void CImage::setupPuppetGeometryCallback (Effects::CPass* pass) const {
 		);
 	    }
 
-	    if (this->getId () == 418) {
-		static bool uniformChecked = false;
-		if (!uniformChecked) {
-		    uniformChecked = true;
-		    const GLint loc = glGetUniformLocation (pass->getProgramID (), "g_ModelViewProjectionMatrix");
-		    GLfloat uniformVals[16] = {};
-		    if (loc >= 0) {
-			glGetUniformfv (pass->getProgramID (), loc, uniformVals);
-		    }
-		    sLog.out (
-			"TEMP-DIAG uniform check for koshinibody: programID=", pass->getProgramID (),
-			" mvpLocation=", loc, " uniformCol0=(", uniformVals[0], ",", uniformVals[1], ",",
-			uniformVals[2], ",", uniformVals[3], ") uniformCol3=(", uniformVals[12], ",", uniformVals[13],
-			",", uniformVals[14], ",", uniformVals[15], ")"
-		    );
-		}
-	    }
-
 	    glBindBuffer (GL_ELEMENT_ARRAY_BUFFER, this->m_puppetIndices);
 	    glDrawElements (GL_TRIANGLES, this->m_puppetIndexCount, GL_UNSIGNED_SHORT, nullptr);
 
-	    {
-		static int liveDiagCounter = 0;
-		if (this->getId () == 418 && (liveDiagCounter++ % 25 == 0)) {
-		    GLfloat liveBuf[9] = {};
-		    glBindBuffer (GL_ARRAY_BUFFER, this->m_puppetSpacePosition);
-		    glGetBufferSubData (GL_ARRAY_BUFFER, 0, sizeof (liveBuf), liveBuf);
-		    const glm::mat4& mvp = this->m_modelViewProjectionScreen;
-		    const glm::vec4 c0 = mvp * glm::vec4 (liveBuf[0], liveBuf[1], liveBuf[2], 1.0f);
-		    const glm::vec4 c1 = mvp * glm::vec4 (liveBuf[3], liveBuf[4], liveBuf[5], 1.0f);
-		    const glm::vec4 c2 = mvp * glm::vec4 (liveBuf[6], liveBuf[7], liveBuf[8], 1.0f);
-		    sLog.out (
-			"TEMP-DIAG live-ndc frame=", liveDiagCounter, " for ", this->getImage ().name, " (",
-			this->getId (), "): buf=[", liveBuf[0], " ", liveBuf[1], " ", liveBuf[2], "|", liveBuf[3], " ",
-			liveBuf[4], " ", liveBuf[5], "|", liveBuf[6], " ", liveBuf[7], " ", liveBuf[8], "] ndc0=(",
-			c0.x / c0.w, ",", c0.y / c0.w, ") ndc1=(", c1.x / c1.w, ",", c1.y / c1.w, ") ndc2=(",
-			c2.x / c2.w, ",", c2.y / c2.w, ")"
-		    );
-		}
-	    }
-
 	    {
 		static int mikasaEyeDumpCounter = 0;
 		if (this->getId () == 603 && mikasaEyeDumpCounter++ == 5) {
@@ -1831,34 +1772,6 @@ void CImage::setupPuppetGeometryCallback (Effects::CPass* pass) const {
 		}
 	    }
 
-	    {
-		static int dumpCounter = 0;
-		if (this->getId () == 418 && dumpCounter++ == 100) {
-		    const glm::mat4& copyProj = this->m_modelViewProjectionCopy;
-		    sLog.out (
-			"TEMP-DIAG modelViewProjectionCopy for koshinibody: col0=(", copyProj[0][0], ",", copyProj[0][1],
-			",", copyProj[0][2], ",", copyProj[0][3], ") col1=(", copyProj[1][0], ",", copyProj[1][1], ",",
-			copyProj[1][2], ",", copyProj[1][3], ") col3=(", copyProj[3][0], ",", copyProj[3][1], ",",
-			copyProj[3][2], ",", copyProj[3][3], ") m_size=(", this->m_size.x, ",", this->m_size.y, ")"
-		    );
-		    GLint vp[4] = {};
-		    glGetIntegerv (GL_VIEWPORT, vp);
-		    const int w = vp[2], h = vp[3];
-		    if (w > 0 && h > 0 && w < 8192 && h < 8192) {
-			std::vector<unsigned char> pixels (static_cast<size_t> (w) * h * 4);
-			glReadPixels (0, 0, w, h, GL_RGBA, GL_UNSIGNED_BYTE, pixels.data ());
-			FILE* f = fopen ("/tmp/koshini_fbo_dump.raw", "wb");
-			if (f) {
-			    fwrite (&w, sizeof (int), 1, f);
-			    fwrite (&h, sizeof (int), 1, f);
-			    fwrite (pixels.data (), 1, pixels.size (), f);
-			    fclose (f);
-			    sLog.out ("TEMP-DIAG dumped FBO contents for koshinibody: ", w, "x", h, " to /tmp/koshini_fbo_dump.raw");
-			}
-		    }
-		}
-	    }
-
 	    if (!this->m_puppetDrawErrorChecked) {
 		this->m_puppetDrawErrorChecked = true;
 		GLint boundFBO = 0;
@@ -1891,42 +1804,6 @@ void CImage::setupPuppetGeometryCallback (Effects::CPass* pass) const {
 		    " colorMask=(", (int) colorMask[0], ",", (int) colorMask[1], ",", (int) colorMask[2], ",",
 		    (int) colorMask[3], ")"
 		);
-
-		const size_t vertexCount = this->m_puppetRawPositions.size () / 3;
-		std::vector<GLushort> idx (this->m_puppetIndexCount);
-		glBindBuffer (GL_ELEMENT_ARRAY_BUFFER, this->m_puppetIndices);
-		glGetBufferSubData (GL_ELEMENT_ARRAY_BUFFER, 0, idx.size () * sizeof (GLushort), idx.data ());
-
-		std::vector<GLfloat> pos (vertexCount * 3);
-		glBindBuffer (GL_ARRAY_BUFFER, this->m_puppetSpacePosition);
-		glGetBufferSubData (GL_ARRAY_BUFFER, 0, pos.size () * sizeof (GLfloat), pos.data ());
-
-		double totalArea = 0.0, minArea = 1e30, maxArea = 0.0;
-		size_t degenerate = 0, outOfRange = 0;
-		for (size_t t = 0; t + 2 < idx.size (); t += 3) {
-		    const GLushort ia = idx[t], ib = idx[t + 1], ic = idx[t + 2];
-		    if (ia >= vertexCount || ib >= vertexCount || ic >= vertexCount) {
-			outOfRange++;
-			continue;
-		    }
-		    const glm::vec3 a (pos[ia * 3], pos[ia * 3 + 1], pos[ia * 3 + 2]);
-		    const glm::vec3 b (pos[ib * 3], pos[ib * 3 + 1], pos[ib * 3 + 2]);
-		    const glm::vec3 c (pos[ic * 3], pos[ic * 3 + 1], pos[ic * 3 + 2]);
-		    const double area = 0.5 * glm::length (glm::cross (b - a, c - a));
-		    if (area < 1e-6) {
-			degenerate++;
-		    }
-		    totalArea += area;
-		    minArea = std::min (minArea, area);
-		    maxArea = std::max (maxArea, area);
-		}
-		const size_t triCount = idx.size () / 3;
-		sLog.out (
-		    "TEMP-DIAG mesh coherence for ", this->getImage ().name, " (", this->getId (), "): vertexCount=",
-		    vertexCount, " triCount=", triCount, " degenerate=", degenerate, " outOfRange=", outOfRange,
-		    " minArea=", minArea, " maxArea=", maxArea, " avgArea=", (triCount > 0 ? totalArea / triCount : 0.0),
-		    " totalArea=", totalArea
-		);
 	    }
 	},
 	[pass] () {
@@ -1944,6 +1821,83 @@ void CImage::setupPuppetGeometryCallback (Effects::CPass* pass) const {
     );
 }
 
+void CImage::addEffectPasses (const ImageEffect& effect) {
+    const auto fboProvider = std::make_shared<FBOProvider> (this);
+
+    for (const auto& fbo : effect.effect->fbos) {
+	fboProvider->create (
+	    *fbo,
+	    this->m_image.model->passthrough ? (this->m_texture->getFlags () | TextureFlags_ClampUVs)
+					      : this->m_texture->getFlags (),
+	    this->getSize ()
+	);
+    }
+
+    // TODO: MAKE USE OF ZIP OPERATOR IN BOOST? WAY OVERKILL JUST FOR THIS...
+
+    auto curEffect = effect.effect->passes.begin ();
+    auto endEffect = effect.effect->passes.end ();
+    auto curOverride = effect.passOverrides.begin ();
+    auto endOverride = effect.passOverrides.end ();
+
+    for (; curEffect != endEffect; ++curEffect) {
+	if (!(*curEffect)->material.has_value ()) {
+	    if (!(*curEffect)->command.has_value ()) {
+		sLog.error ("Pass without material and command not supported");
+		continue;
+	    }
+
+	    if (!(*curEffect)->source.has_value ()) {
+		sLog.error ("Pass without material and source not supported");
+		continue;
+	    }
+
+	    if (!(*curEffect)->target.has_value ()) {
+		sLog.error ("Pass without material and target not supported");
+		continue;
+	    }
+
+	    if ((*curEffect)->command != Command_Copy) {
+		sLog.error ("Only copy command is supported for pass without material");
+		continue;
+	    }
+
+	    auto virtualPass
+		= std::make_unique<MaterialPass> (MaterialPass { .blending = BlendingMode_Normal,
+								 .cullmode = CullingMode_Disable,
+								 .depthtest = DepthtestMode_Disabled,
+								 .depthwrite = DepthwriteMode_Disabled,
+								 .shader = "commands/copy",
+								 .textures = { { 0, *(*curEffect)->source } },
+								 .combos = {},
+								 .constants = {} });
+
+	    const auto& config = *this->m_virtualPassess.emplace_back (std::move (virtualPass));
+
+	    this->m_passes.push_back (new CPass (
+		*this, fboProvider, config, std::nullopt, std::nullopt, (*curEffect)->target.value ()
+	    ));
+	} else {
+	    for (auto& pass : (*curEffect)->material.value ()->passes) {
+		const auto override = curOverride != endOverride
+		    ? **curOverride
+		    : std::optional<std::reference_wrapper<const ImageEffectPassOverride>> (std::nullopt);
+		const auto target = (*curEffect)->target.has_value ()
+		    ? *(*curEffect)->target
+		    : std::optional<std::reference_wrapper<std::string>> (std::nullopt);
+
+		this->m_passes.push_back (
+		    new CPass (*this, fboProvider, *pass, override, (*curEffect)->binds, target)
+		);
+	    }
+
+	    if (curOverride != endOverride) {
+		++curOverride;
+	    }
+	}
+    }
+}
+
 void CImage::setup () {
     if (this->m_initialized) {
 	return;
@@ -1952,24 +1906,9 @@ void CImage::setup () {
     // TODO: CHECK ORDER OF THINGS, 2419444134'S ID 27 DEPENDS ON 104'S COMPOSITE_A WHEN OUR LAST RENDER IS ON
     // COMPOSITE_B
     // TODO: SUPPORT PASSTHROUGH (IT'S A SHADER)
-    if (this->m_image.model->passthrough) {
-	// passthrough without effects has nothing to draw
-	if (this->m_image.effects.empty ()) {
-	    return;
-	}
-
-	// some scenes declare effects with visible set to false
-	bool allEffectsInvisible = true;
-	for (const auto& cur : this->m_image.effects) {
-	    if (cur->visible->value->getBool ()) {
-		allEffectsInvisible = false;
-		break;
-	    }
-	}
-
-	if (allEffectsInvisible) {
-	    return;
-	}
+    // passthrough without effects has nothing to draw
+    if (this->m_image.model->passthrough && this->m_image.effects.empty ()) {
+	return;
     }
 
     const auto& debug = this->getScene ().getContext ().getApp ().getContext ().settings.render.debug;
@@ -1980,6 +1919,9 @@ void CImage::setup () {
 	);
     }
 
+    std::vector<const DynamicValue*> passVisibility (this->m_passes.size (), nullptr);
+    std::vector<bool> passFromEffect (this->m_passes.size (), false);
+
     if (!debug.baseOnly && !this->getImage ().effects.empty ()) {
 	for (const auto& cur : this->m_image.effects) {
 	    if (std::find (debug.skipEffects.begin (), debug.skipEffects.end (), static_cast<int> (cur->id))
@@ -1992,84 +1934,42 @@ void CImage::setup () {
 	    );
 
 	    // an explicit --disable-effect/--enable-effect override wins over the scene's own visibility
-	    if (effectVisibility.has_value () ? !*effectVisibility : !cur->visible->value->getBool ()) {
+	    if (effectVisibility.has_value () && !*effectVisibility) {
 		continue;
 	    }
 
-	    const auto fboProvider = std::make_shared<FBOProvider> (this);
+	    // scripts can toggle hidden effects at runtime; puppets can't, their mesh pass layout
+	    // depends on the pass count
+	    const bool followsVisibility = !effectVisibility.has_value () && !this->m_hasPuppetMesh;
 
-	    for (const auto& fbo : cur->effect->fbos) {
-		fboProvider->create (
-		    *fbo,
-		    this->m_image.model->passthrough ? (this->m_texture->getFlags () | TextureFlags_ClampUVs)
-						      : this->m_texture->getFlags (),
-		    this->getSize ()
-		);
+	    if (!followsVisibility && !effectVisibility.has_value () && !cur->visible->value->getBool ()) {
+		continue;
 	    }
 
-	    // TODO: MAKE USE OF ZIP OPERATOR IN BOOST? WAY OVERKILL JUST FOR THIS...
-
-	    auto curEffect = cur->effect->passes.begin ();
-	    auto endEffect = cur->effect->passes.end ();
-	    auto curOverride = cur->passOverrides.begin ();
-	    auto endOverride = cur->passOverrides.end ();
-
-	    for (; curEffect != endEffect; ++curEffect) {
-		if (!(*curEffect)->material.has_value ()) {
-		    if (!(*curEffect)->command.has_value ()) {
-			sLog.error ("Pass without material and command not supported");
-			continue;
-		    }
+	    const DynamicValue* visibleValue = followsVisibility ? cur->visible->value.get () : nullptr;
+	    const size_t firstEffectPass = this->m_passes.size ();
 
-		    if (!(*curEffect)->source.has_value ()) {
-			sLog.error ("Pass without material and source not supported");
-			continue;
-		    }
-
-		    if (!(*curEffect)->target.has_value ()) {
-			sLog.error ("Pass without material and target not supported");
-			continue;
-		    }
-
-		    if ((*curEffect)->command != Command_Copy) {
-			sLog.error ("Only copy command is supported for pass without material");
-			continue;
-		    }
-
-		    auto virtualPass
-			= std::make_unique<MaterialPass> (MaterialPass { .blending = BlendingMode_Normal,
-									 .cullmode = CullingMode_Disable,
-									 .depthtest = DepthtestMode_Disabled,
-									 .depthwrite = DepthwriteMode_Disabled,
-									 .shader = "commands/copy",
-									 .textures = { { 0, *(*curEffect)->source } },
-									 .combos = {},
-									 .constants = {} });
-
-		    const auto& config = *this->m_virtualPassess.emplace_back (std::move (virtualPass));
-
-		    this->m_passes.push_back (new CPass (
-			*this, fboProvider, config, std::nullopt, std::nullopt, (*curEffect)->target.value ()
-		    ));
-		} else {
-		    for (auto& pass : (*curEffect)->material.value ()->passes) {
-			const auto override = curOverride != endOverride
-			    ? **curOverride
-			    : std::optional<std::reference_wrapper<const ImageEffectPassOverride>> (std::nullopt);
-			const auto target = (*curEffect)->target.has_value ()
-			    ? *(*curEffect)->target
-			    : std::optional<std::reference_wrapper<std::string>> (std::nullopt);
-
-			this->m_passes.push_back (
-			    new CPass (*this, fboProvider, *pass, override, (*curEffect)->binds, target)
-			);
-		    }
+	    try {
+		this->addEffectPasses (*cur);
+	    } catch (const std::exception& e) {
+		if (visibleValue == nullptr || visibleValue->getBool ()) {
+		    throw;
+		}
 
-		    if (curOverride != endOverride) {
-			++curOverride;
-		    }
+		for (size_t i = firstEffectPass; i < this->m_passes.size (); i++) {
+		    delete this->m_passes[i];
 		}
+
+		this->m_passes.resize (firstEffectPass);
+		sLog.error (
+		    "Dropping hidden effect ", cur->id, " (", cur->name, ") on ", this->getImage ().name, ": ",
+		    e.what ()
+		);
+		continue;
 	    }
+
+	    passVisibility.resize (this->m_passes.size (), visibleValue);
+	    passFromEffect.resize (this->m_passes.size (), true);
 	}
     }
 
@@ -2151,6 +2051,52 @@ void CImage::setup () {
 	}
     }
 
+    passVisibility.resize (this->m_passes.size (), nullptr);
+    passFromEffect.resize (this->m_passes.size (), false);
+
+    for (size_t i = 0; i < this->m_passes.size (); i++) {
+	this->m_allPassStates.push_back (
+	    { passVisibility[i], this->m_passes[i]->getBlendingMode (), passFromEffect[i] }
+	);
+    }
+
+    this->m_allPasses = this->m_passes;
+
+    CRenderable::setup ();
+
+    this->rebuildActivePasses ();
+    this->m_initialized = true;
+}
+
+bool CImage::effectVisibilityChanged () const {
+    for (size_t i = 0; i < this->m_allPassStates.size (); i++) {
+	const auto* visible = this->m_allPassStates[i].visible;
+
+	if (visible != nullptr && visible->getBool () != this->m_activePassMask[i]) {
+	    return true;
+	}
+    }
+
+    return false;
+}
+
+void CImage::rebuildActivePasses () {
+    this->m_passes.clear ();
+    this->m_activePassMask.assign (this->m_allPasses.size (), false);
+    this->m_hasActiveEffectPass = false;
+
+    for (size_t i = 0; i < this->m_allPasses.size (); i++) {
+	const auto& state = this->m_allPassStates[i];
+
+	this->m_allPasses[i]->setBlendingMode (state.blending);
+
+	if (state.visible == nullptr || state.visible->getBool ()) {
+	    this->m_activePassMask[i] = true;
+	    this->m_hasActiveEffectPass |= state.fromEffect;
+	    this->m_passes.push_back (this->m_allPasses[i]);
+	}
+    }
+
     // if there's more than one pass the blendmode has to be moved from the beginning to the end
     if (this->m_passes.size () > 1) {
 	const auto first = this->m_passes.begin ();
@@ -2160,10 +2106,11 @@ void CImage::setup () {
 	(*first)->setBlendingMode (BlendingMode_Normal);
     }
 
-    CRenderable::setup ();
+    // setupPasses() ping-pongs these, every rebuild has to start from the same pair
+    this->m_currentMainFBO = this->m_mainFBO;
+    this->m_currentSubFBO = this->m_subFBO;
 
     this->setupPasses ();
-    this->m_initialized = true;
 }
 
 void CImage::setupPasses () {
@@ -2178,9 +2125,6 @@ void CImage::setupPasses () {
     std::shared_ptr<const TextureProvider> effectInput = nullptr;
 
     for (; cur != end; ++cur) {
-	// TODO: PROPERLY CHECK EFFECT'S VISIBILITY AND TAKE IT INTO ACCOUNT
-	// TODO: THIS REQUIRES ON-THE-FLY EVALUATION OF EFFECTS VISIBILITY TO FIGURE OUT
-	// TODO: WHICH ONE IS THE LAST + A FEW OTHER THINGS
 	Effects::CPass* pass = *cur;
 	std::shared_ptr<const CFBO> prevDrawTo = drawTo;
 	bool writesToTarget = false;
@@ -2207,7 +2151,6 @@ void CImage::setupPasses () {
 	writesToTarget = this->configurePassTarget (pass, drawTo, asInput, effectInput, inTargetEffectSequence);
 	// TODO: PROPERLY CHECK IF THIS IS ALL THAT'S NEEDED
 	if (!writesToTarget && this->shouldRenderFinalPass (std::next (cur) == end)) {
-	    // TODO: PROPERLY CHECK EFFECT'S VISIBILITY AND TAKE IT INTO ACCOUNT
 	    drawTo = this->getScene ().getFBO ();
 
 	    // A puppet with no effects has its geometry pass be both the first AND the last pass, drawn
@@ -2224,35 +2167,6 @@ void CImage::setupPasses () {
 	    // WE's final pass inverse lands in the layer's local space (origin at its center, unscaled
 	    // pixels); older shaders like the bundled xray.vert unproject the pointer through it
 	    inverseProjection = &this->m_objectSpaceProjectionInverse;
-
-	    if (this->m_hasPuppetMesh) {
-		GLfloat bufDump[18] = {};
-		glBindBuffer (GL_ARRAY_BUFFER, spacePosition);
-		glGetBufferSubData (GL_ARRAY_BUFFER, 0, sizeof (bufDump), bufDump);
-		sLog.out (
-		    "TEMP-DIAG final-pass-branch for ", this->getImage ().name, " (", this->getId (),
-		    "): isFirstPass=", isFirstPass, " m_pos=(", this->m_pos.x, ",", this->m_pos.y, ",", this->m_pos.z,
-		    ",", this->m_pos.w, ") spacePosition=", spacePosition, " sceneSpacePositionBuffer=",
-		    this->getSceneSpacePosition (), " passCount=", this->m_passes.size (), " bufVerts=[",
-		    bufDump[0], " ", bufDump[1], " ", bufDump[2], " | ", bufDump[3], " ", bufDump[4], " ", bufDump[5],
-		    " | ", bufDump[6], " ", bufDump[7], " ", bufDump[8], " | ", bufDump[9], " ", bufDump[10], " ",
-		    bufDump[11], " | ", bufDump[12], " ", bufDump[13], " ", bufDump[14], " | ", bufDump[15], " ",
-		    bufDump[16], " ", bufDump[17], "]"
-		);
-
-		const glm::mat4& mvp = *projection;
-		const glm::vec4 c0 = mvp * glm::vec4 (bufDump[0], bufDump[1], bufDump[2], 1.0f);
-		const glm::vec4 c1 = mvp * glm::vec4 (bufDump[3], bufDump[4], bufDump[5], 1.0f);
-		const glm::vec4 c2 = mvp * glm::vec4 (bufDump[6], bufDump[7], bufDump[8], 1.0f);
-		sLog.out (
-		    "TEMP-DIAG projection for ", this->getImage ().name, " (", this->getId (), "): mvpRow0=(", mvp[0][0],
-		    ",", mvp[1][0], ",", mvp[2][0], ",", mvp[3][0], ") mvpRow1=(", mvp[0][1], ",", mvp[1][1], ",",
-		    mvp[2][1], ",", mvp[3][1], ") clip0=(", c0.x, ",", c0.y, ",", c0.z, ",", c0.w, ") ndc0=(",
-		    c0.x / c0.w, ",", c0.y / c0.w, ") clip1=(", c1.x, ",", c1.y, ",", c1.z, ",", c1.w, ") ndc1=(",
-		    c1.x / c1.w, ",", c1.y / c1.w, ") clip2=(", c2.x, ",", c2.y, ",", c2.z, ",", c2.w, ") ndc2=(",
-		    c2.x / c2.w, ",", c2.y / c2.w, ")"
-		);
-	    }
 	}
 
 	pass->setDestination (drawTo);
@@ -2348,6 +2262,14 @@ void CImage::render () {
 	return;
     }
 
+    if (this->effectVisibilityChanged ()) {
+	this->rebuildActivePasses ();
+    }
+
+    if (this->m_image.model->passthrough && !this->m_hasActiveEffectPass) {
+	return;
+    }
+
     glColorMask (true, true, true, true);
 
     this->updateScreenSpacePosition ();

+ 13 - 1
src/WallpaperEngine/Render/Objects/CImage.h

@@ -124,6 +124,9 @@ public:
 
 protected:
     void setupPasses ();
+    void rebuildActivePasses ();
+    void addEffectPasses (const ImageEffect& effect);
+    [[nodiscard]] bool effectVisibilityChanged () const;
 
     void updateScreenSpacePosition ();
     void updateEffectTextureProjection ();
@@ -182,7 +185,6 @@ private:
     bool m_transformDiagnosticLogged = false;
     mutable std::set<int> m_attachmentDiagnosticLogged = {};
     mutable std::set<int> m_finalOriginLogged = {};
-    bool m_boneTrackDiagLogged = false;
     std::vector<GLfloat> m_puppetRawPositions = {};
     /** This object's current resolved scale, mirrored here so updatePuppetSkinning() (called after
      *  updateGeometryBuffers() each frame, see render()) can fold it into puppet vertex positions
@@ -236,6 +238,16 @@ private:
     mutable float m_alphaCache = 1.0f;
 
     std::vector<Effects::CPass*> m_passes = {};
+    std::vector<Effects::CPass*> m_allPasses = {};
+    struct PassState {
+	/** nullptr for passes that always render */
+	const DynamicValue* visible;
+	BlendingMode blending;
+	bool fromEffect;
+    };
+    std::vector<PassState> m_allPassStates = {};
+    std::vector<bool> m_activePassMask = {};
+    bool m_hasActiveEffectPass = false;
     std::vector<MaterialPassUniquePtr> m_virtualPassess = {};
 
     glm::vec4 m_pos = {};

+ 68 - 17
src/WallpaperEngine/Render/Objects/CParticle.cpp

@@ -396,6 +396,39 @@ void CParticle::setupEmitters () {
     }
 }
 
+float CParticle::sampleAudio (
+    int mode, const glm::vec2& bounds, float exponent, int frequencyStart, int frequencyEnd
+) const {
+    // same curve as wallpaper64.exe. Modes 1/2/3 pick left/right/averaged channels there, the recorder is mono
+    if (mode == 0) {
+	return 1.0f;
+    }
+
+    int first = std::clamp (frequencyStart, 0, 15);
+    int last = std::clamp (frequencyEnd, 0, 15);
+
+    if (last < first) {
+	std::swap (first, last);
+    }
+
+    const auto& recorder = this->getScene ().getAudioContext ().getRecorder ();
+    float peak = 0.0f;
+
+    recorder.lock ();
+    for (int i = first; i <= last; i++) {
+	peak = std::max (peak, recorder.audio16[i]);
+    }
+    recorder.unlock ();
+
+    float t = (peak - bounds.x) / (bounds.y - bounds.x);
+    // NaN from equal bounds ends up as 0 like the original
+    t = t >= 1.0f ? 1.0f : (t >= 0.0f ? t : 0.0f);
+
+    const float response = std::pow (t * t * (3.0f - 2.0f * t), exponent);
+
+    return response >= 1.0f ? 1.0f : (response >= 0.0f ? response : 0.0f);
+}
+
 EmitterFunc CParticle::createBoxEmitter (const ParticleEmitter& emitter) {
     float rate = emitter.rate * m_particle.instanceOverride.rate->value->getFloat ();
 
@@ -462,8 +495,6 @@ EmitterFunc CParticle::createBoxEmitter (const ParticleEmitter& emitter) {
 		}
 	    }
 
-	    // TODO: audio processing (audioProcessingMode, audioProcessingBounds, etc.)
-
 	    uint32_t toEmit = 0;
 	    if (emitter.instantaneous > 0 && !instantaneousEmitted) {
 		toEmit = emitter.instantaneous;
@@ -471,7 +502,11 @@ EmitterFunc CParticle::createBoxEmitter (const ParticleEmitter& emitter) {
 	    }
 
 	    if (emitter.rate > 0.0f) {
-		emissionTimer += dt * rate;
+		const float audio = sampleAudio (
+		    emitter.audioProcessingMode, emitter.audioProcessingBounds, emitter.audioProcessingExponent,
+		    emitter.audioProcessingFrequencyStart, emitter.audioProcessingFrequencyEnd
+		);
+		emissionTimer += dt * rate * audio;
 		uint32_t rateEmit = static_cast<uint32_t> (emissionTimer);
 		emissionTimer -= static_cast<float> (rateEmit);
 		// limitOnePerFrame (flags bit 1): cap at 1 to prevent rope artifacts
@@ -567,7 +602,11 @@ EmitterFunc CParticle::createSphereEmitter (const ParticleEmitter& emitter) {
 	    return;
 	}
 
-	emissionTimer += dt * rate;
+	const float audio = sampleAudio (
+	    emitter.audioProcessingMode, emitter.audioProcessingBounds, emitter.audioProcessingExponent,
+	    emitter.audioProcessingFrequencyStart, emitter.audioProcessingFrequencyEnd
+	);
+	emissionTimer += dt * rate * audio;
 	uint32_t toEmit = static_cast<uint32_t> (emissionTimer);
 	emissionTimer -= static_cast<float> (toEmit);
 	// limitOnePerFrame (flags bit 1): cap at 1 to prevent rope artifacts
@@ -1232,8 +1271,11 @@ OperatorFunc CParticle::createTurbulenceOperator (const TurbulenceOperator& op)
     DynamicValue* phaseMinValue = op.phaseMin->value.get ();
     DynamicValue* phaseMaxValue = op.phaseMax->value.get ();
     DynamicValue* speedOverride = m_particle.instanceOverride.speed->value.get ();
-
-    // TODO: audio processing support (audioProcessingMode/Bounds/Exponent/FrequencyStart/FrequencyEnd)
+    DynamicValue* audioModeValue = op.audioProcessingMode->value.get ();
+    DynamicValue* audioBoundsValue = op.audioProcessingBounds->value.get ();
+    DynamicValue* audioExponentValue = op.audioProcessingExponent->value.get ();
+    DynamicValue* audioStartValue = op.audioProcessingFrequencyStart->value.get ();
+    DynamicValue* audioEndValue = op.audioProcessingFrequencyEnd->value.get ();
 
     // Phase and speed are randomized once per operator instance, not per particle
     const float phase
@@ -1241,7 +1283,8 @@ OperatorFunc CParticle::createTurbulenceOperator (const TurbulenceOperator& op)
     const float turbSpeed
 	= WallpaperEngine::Maths::randomFloat (m_rng, speedMinValue->getFloat (), speedMaxValue->getFloat ());
 
-    return [scaleValue, timeScaleValue, maskValue, speedOverride, phase, turbSpeed] (
+    return [this, scaleValue, timeScaleValue, maskValue, speedOverride, audioModeValue, audioBoundsValue,
+	    audioExponentValue, audioStartValue, audioEndValue, phase, baseTurbSpeed = turbSpeed] (
 	       std::vector<ParticleInstance>& particles, uint32_t count, const std::vector<ControlPointData>&,
 	       float currentTime, float dt
 	   ) {
@@ -1249,6 +1292,11 @@ OperatorFunc CParticle::createTurbulenceOperator (const TurbulenceOperator& op)
 	const float timeScale = timeScaleValue->getFloat ();
 	const glm::vec3 mask = maskValue->getVec3 ();
 	const float speed = speedOverride->getFloat ();
+	const float audio = sampleAudio (
+	    audioModeValue->getInt (), audioBoundsValue->getVec2 (), audioExponentValue->getFloat (),
+	    audioStartValue->getInt (), audioEndValue->getInt ()
+	);
+	const float turbSpeed = baseTurbSpeed * audio;
 
 	if (turbSpeed <= 0.0001f) {
 	    return;
@@ -1291,24 +1339,29 @@ OperatorFunc CParticle::createVortexOperator (const VortexOperator& op) {
     DynamicValue* ringPullDistanceValue = op.ringPullDistance->value.get ();
     DynamicValue* ringPullForceValue = op.ringPullForce->value.get ();
     DynamicValue* audioModeValue = op.audioProcessingMode->value.get ();
+    DynamicValue* audioBoundsValue = op.audioProcessingBounds->value.get ();
+    DynamicValue* audioExponentValue = op.audioProcessingExponent->value.get ();
+    DynamicValue* audioStartValue = op.audioProcessingFrequencyStart->value.get ();
+    DynamicValue* audioEndValue = op.audioProcessingFrequencyEnd->value.get ();
     DynamicValue* speedOverride = m_particle.instanceOverride.speed->value.get ();
 
-    int audioMode = static_cast<int> (audioModeValue->getFloat ());
-
     bool infiniteAxis = (flags & 1) != 0;
     bool maintainDistance = (flags & 2) != 0;
     bool ringShape = (flags & 4) != 0;
 
     return [controlPoint, axisValue, offsetValue, distanceInnerValue, distanceOuterValue, speedInnerValue,
 	    speedOuterValue, centerForceValue, ringRadiusValue, ringWidthValue, ringPullDistanceValue,
-	    ringPullForceValue, audioMode, infiniteAxis, maintainDistance, ringShape, speedOverride] (
+	    ringPullForceValue, audioModeValue, audioBoundsValue, audioExponentValue, audioStartValue, audioEndValue,
+	    infiniteAxis, maintainDistance, ringShape, speedOverride, this] (
 	       std::vector<ParticleInstance>& particles, uint32_t count,
 	       const std::vector<ControlPointData>& controlPoints, float, float dt
 	   ) {
-	float audioAmplitude = 0.0f; // TODO: sample from AudioContext once audio processing is implemented
+	const float audioResponse = sampleAudio (
+	    audioModeValue->getInt (), audioBoundsValue->getVec2 (), audioExponentValue->getFloat (),
+	    audioStartValue->getInt (), audioEndValue->getInt ()
+	);
 
-	// Audio mode enabled but no audio available yet - skip vortex entirely
-	if (audioMode > 0 && audioAmplitude == 0.0f) {
+	if (audioResponse <= 0.0f) {
 	    return;
 	}
 
@@ -1324,10 +1377,8 @@ OperatorFunc CParticle::createVortexOperator (const VortexOperator& op) {
 	float ringPullDistance = ringPullDistanceValue->getFloat ();
 	float ringPullForce = ringPullForceValue->getFloat ();
 
-	if (audioMode > 0) {
-	    speedInner *= (1.0f + audioAmplitude);
-	    speedOuter *= (1.0f + audioAmplitude);
-	}
+	speedInner *= audioResponse;
+	speedOuter *= audioResponse;
 
 	glm::vec3 center = glm::vec3 (0.0f);
 	if (controlPoint >= 0 && controlPoint < static_cast<int> (controlPoints.size ())) {

+ 4 - 0
src/WallpaperEngine/Render/Objects/CParticle.h

@@ -114,6 +114,10 @@ protected:
     EmitterFunc createBoxEmitter (const ParticleEmitter& emitter);
     EmitterFunc createSphereEmitter (const ParticleEmitter& emitter);
 
+    [[nodiscard]] float sampleAudio (
+	int mode, const glm::vec2& bounds, float exponent, int frequencyStart, int frequencyEnd
+    ) const;
+
     InitializerFunc createColorRandomInitializer (const ColorRandomInitializer& init);
     InitializerFunc createSizeRandomInitializer (const SizeRandomInitializer& init);
     InitializerFunc createAlphaRandomInitializer (const AlphaRandomInitializer& init);

+ 35 - 2
src/WallpaperEngine/Render/Shaders/ShaderUnit.cpp

@@ -684,6 +684,39 @@ std::string ShaderUnit::applyFragmentVaryingShadowCompatibility (std::string sou
     return source;
 }
 
+std::string ShaderUnit::applyNonConstantConstCompatibility (std::string source) const {
+    // locals only, globals sit at column 0
+    static const std::regex constLocal (R"((^|\n)([ \t]+)const\s+([^;=]+=([^;]*);))");
+    static const std::regex nonConstant (R"(\b(?:texSample2D\w*|texture\w*|g_\w+|v_\w+)\b)");
+
+    std::string result;
+    size_t count = 0;
+    auto last = source.cbegin ();
+
+    for (auto it = std::sregex_iterator (source.cbegin (), source.cend (), constLocal); it != std::sregex_iterator ();
+	 ++it) {
+	const auto& match = *it;
+
+	if (!std::regex_search (match[4].first, match[4].second, nonConstant)) {
+	    continue;
+	}
+
+	result.append (last, match[0].first);
+	result += match[1].str () + match[2].str () + match[3].str ();
+	last = match[0].second;
+	count++;
+    }
+
+    if (count == 0) {
+	return source;
+    }
+
+    result.append (last, source.cend ());
+    sLog.out ("Dropped const from ", count, " non-constant local(s) in ", this->m_file);
+
+    return result;
+}
+
 void ShaderUnit::parseComboConfiguration (const std::string& content, const int defaultValue) {
     // TODO: SUPPORT REQUIRES SO WE PROPERLY FOLLOW THE REQUIRED CHAIN
     JSON data;
@@ -995,11 +1028,11 @@ const std::string& ShaderUnit::compile () {
 	}
     }
 
-    const std::string compat = this->applyFloatConditionCompatibility (
+    const std::string compat = this->applyNonConstantConstCompatibility (this->applyFloatConditionCompatibility (
 	this->applyVectorTruncationCompatibility (this->applyFragmentVaryingShadowCompatibility (
 	    this->applyFragmentTexCoordCompatibility (this->applyLinkedVaryingCompatibility (this->m_preprocessed))
 	))
-    );
+    ));
 
     {
 	std::lock_guard lock (cacheMutex);

+ 3 - 0
src/WallpaperEngine/Render/Shaders/ShaderUnit.h

@@ -71,6 +71,9 @@ private:
     /** HLSL converts a float to bool implicitly (`cond ? a : b`, `if (cond)`), GLSL needs a real bool.
      *  Rewrites a bare float variable used as such a condition to `(cond != 0.0)`. */
     [[nodiscard]] std::string applyFloatConditionCompatibility (std::string source) const;
+    /** HLSL accepts a `const` local initialized from a texture sample, uniform or varying, GLSL only allows
+     *  constant expressions there. Drops the `const` from such locals. */
+    [[nodiscard]] std::string applyNonConstantConstCompatibility (std::string source) const;
 
     void parseComboConfiguration (const std::string& content, int defaultValue = 0);
     void parseParameterConfiguration (const std::string& type, const std::string& name, const std::string& content);

+ 0 - 8
src/WallpaperEngine/Testing/Render/TestingOpenGLDriver.cpp

@@ -4,8 +4,6 @@
 
 #include "WallpaperEngine/Render/Drivers/Output/GLFWWindowOutput.h"
 
-#include <algorithm>
-
 using namespace WallpaperEngine::Testing::Render;
 
 void TestingCustomGLFWErrorHandler (int errorCode, const char* reason) {
@@ -92,10 +90,6 @@ glm::ivec2 TestingOpenGLDriver::getFramebufferSize () const {
 
 uint32_t TestingOpenGLDriver::getFrameCounter () const { return this->m_frameCounter; }
 void TestingOpenGLDriver::dispatchEventQueue () {
-    static float startTime, endTime;
-    // read every frame, --fps can change with a hotswap
-    const float minimumTime = 1.0f / std::max (1, this->m_context.settings.render.maximumFPS);
-    startTime = this->getRenderTime ();
     glClear (GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT);
 
     for (const auto& [screen, viewport] : this->m_output->getViewports ()) {
@@ -123,10 +117,8 @@ void TestingOpenGLDriver::dispatchEventQueue () {
 	}
     }
 
-    // TODO: frametime control should move back to CWallpaperApplication once real particles need their own processing rate
     this->m_output->updateRender ();
     glfwSwapBuffers (this->m_window);
     glfwPollEvents ();
     this->m_frameCounter++;
-    endTime = this->getRenderTime ();
 }