Browse Source

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 ngày trước cách đây
mục cha
commit
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 ();
 }