Переглянути джерело

in we_manager with several screens engines now hotswap independently, turbulence operator uses real wallpaper engine formula now, simplexNoise3D implemented, each particle now gets a random seed at spawn that sets its phase offset and speed

UwU 4 днів тому
батько
коміт
ebb0feb2f6

+ 1 - 0
CMakeLists.txt

@@ -608,6 +608,7 @@ if(BUILD_TESTING)
         src/WallpaperEngine/Testing/Cases/SpectrumListeners.cpp
         src/WallpaperEngine/Testing/Cases/SpectrumNormalizer.cpp
         src/WallpaperEngine/Testing/Cases/SpectrumPipeline.cpp
+        src/WallpaperEngine/Testing/Cases/ParticleNoise.cpp
         src/WallpaperEngine/Testing/Cases/Localization.cpp
         src/WallpaperEngine/Testing/Cases/ScalingModes.cpp
         src/WallpaperEngine/Testing/Cases/CornerColor.cpp

+ 11 - 0
src/WallpaperEngine/Application/ApplicationContext.cpp

@@ -833,6 +833,17 @@ void ApplicationContext::loadSettingsFromArgv () {
 
     auto& configurationGroup = program.add_group ("Wallpaper configuration options");
 
+    configurationGroup.add_argument ("--control-file")
+	.help (
+	    "File the SIGUSR1 hotswap request is read from. Give every engine its own when several run at once, "
+	    "otherwise they all read the same one. Default: $XDG_RUNTIME_DIR/lwe-control"
+	)
+	.action ([this] (const std::string& value) -> void {
+	    if (!value.empty ()) {
+		this->settings.general.controlFile = value;
+	    }
+	});
+
     configurationGroup.add_argument ("--disable-particles")
 	.help ("Disables particles for the backgrounds")
 	.flag ()

+ 3 - 0
src/WallpaperEngine/Application/ApplicationContext.h

@@ -151,6 +151,8 @@ public:
 	    std::string webHostShm;
 	    uint32_t webHostWidth;
 	    uint32_t webHostHeight;
+	    /** Where SIGUSR1 hotswap requests are read from, see --control-file */
+	    std::optional<std::filesystem::path> controlFile;
 	} general;
 
 	struct {
@@ -255,6 +257,7 @@ public:
             .webHostShm = "",
             .webHostWidth = 0,
             .webHostHeight = 0,
+            .controlFile = std::nullopt,
         },
         .render = {
             .mode = NORMAL_WINDOW,

+ 3 - 1
src/WallpaperEngine/Application/WallpaperApplication.cpp

@@ -819,7 +819,9 @@ void WallpaperApplication::checkHotswapRequest () {
     }
 
     const char* runtimeDir = getenv ("XDG_RUNTIME_DIR");
-    const auto controlFile = std::filesystem::path (runtimeDir != nullptr ? runtimeDir : "/tmp") / "lwe-control";
+    const auto controlFile = this->m_context.settings.general.controlFile.value_or (
+	std::filesystem::path (runtimeDir != nullptr ? runtimeDir : "/tmp") / "lwe-control"
+    );
 
     std::ifstream file (controlFile);
 

+ 1 - 0
src/WallpaperEngine/Data/Model/Object.h

@@ -35,6 +35,7 @@ struct ObjectData {
     UserSettingUniquePtr groupScale;
     UserSettingUniquePtr groupAngles;
     UserSettingUniquePtr groupVisible;
+    UserSettingUniquePtr groupParallaxDepth;
 };
 
 class Object : public TypeCaster, public ObjectData {

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

@@ -40,6 +40,7 @@ ObjectUniquePtr ObjectParser::parse (const JSON& it, const Project& project) {
 	    .groupScale = it.user ("scale", project.properties, glm::vec3 (1.0f)),
 	    .groupAngles = it.user ("angles", project.properties, glm::vec3 (0.0f)),
 	    .groupVisible = it.user ("visible", project.properties, true),
+	    .groupParallaxDepth = it.user ("parallaxDepth", project.properties, glm::vec2 (1.0f)),
 	};
     } catch (const std::exception& e) {
 	sLog.error ("Error parsing object base data: ", e.what ());
@@ -65,6 +66,7 @@ ObjectUniquePtr ObjectParser::parse (const JSON& it, const Project& project) {
 	    .groupScale = it.user ("scale", project.properties, glm::vec3 (1.0f)),
 	    .groupAngles = it.user ("angles", project.properties, glm::vec3 (0.0f)),
 	    .groupVisible = it.user ("visible", project.properties, true),
+	    .groupParallaxDepth = it.user ("parallaxDepth", project.properties, glm::vec2 (1.0f)),
 	};
     }
 
@@ -169,7 +171,7 @@ TextUniquePtr ObjectParser::parseText (const JSON& it, const Project& project, O
 	    .color = it.color ("color", project.properties, Builders::ColorBuilder::White),
 	    .alpha = it.user ("alpha", project.properties, 1.0f),
 	    .visible = it.user ("visible", project.properties, true),
-	    .parallaxDepth = it.user ("parallaxDepth", project.properties, glm::vec2 (0.0f)),
+	    .parallaxDepth = it.user ("parallaxDepth", project.properties, glm::vec2 (1.0f)),
 	    .alignment = it.optional ("horizontalalign", it.optional ("alignment", std::string ("center"))),
 	    .verticalalign = it.optional ("verticalalign", std::string ("center")),
 	    .padding = it.optional ("padding", glm::vec2 (0.0f)),
@@ -201,7 +203,7 @@ ObjectParser::parseImage (const JSON& it, const Project& project, ObjectData bas
 		it.optional ("horizontalalign", it.optional ("alignment", std::string ("center")))
 	    ),
 	    .size = it.user ("size", properties, glm::vec2 (0.0f))->value->getVec2 (),
-	    .parallaxDepth = it.user ("parallaxDepth", properties, glm::vec2 (0.0f)),
+	    .parallaxDepth = it.user ("parallaxDepth", properties, glm::vec2 (1.0f)),
 	    .colorBlendMode = it.user ("colorBlendMode", properties, 0),
 	    .brightness = it.user ("brightness", properties, 1.0f),
 	    .clampUVs = it.optional ("clampuvs", false),
@@ -219,15 +221,18 @@ ObjectParser::parseImage (const JSON& it, const Project& project, ObjectData bas
 	const auto instanceTextures = instance->optional ("textures");
 
 	if (instanceTextures.has_value ()) {
-	    const auto parsed = TextureParser::parseTextureMap (*instanceTextures);
-	    firstPass.textures.insert (parsed.begin (), parsed.end ());
+	    // the instance's textures replace the material's, a solid layer's util/white included
+	    for (const auto& [index, texture] : TextureParser::parseTextureMap (*instanceTextures)) {
+		firstPass.textures.insert_or_assign (index, texture);
+	    }
 	}
 
 	const auto instanceUserTextures = instance->optional ("usertextures");
 
 	if (instanceUserTextures.has_value ()) {
-	    const auto parsed = TextureParser::parseTextureMap (*instanceUserTextures);
-	    firstPass.usertextures.insert (parsed.begin (), parsed.end ());
+	    for (const auto& [index, texture] : TextureParser::parseTextureMap (*instanceUserTextures)) {
+		firstPass.usertextures.insert_or_assign (index, texture);
+	    }
 	}
     }
 
@@ -345,7 +350,7 @@ ParticleUniquePtr ObjectParser::parseParticle (const JSON& it, const Project& pr
 		    .scale = it.user ("scale", properties, glm::vec3 (1.0f)),
 		    .angles = it.user ("angles", properties, glm::vec3 (0.0f)),
 		    .visible = it.user ("visible", properties, true),
-		    .parallaxDepth = it.user ("parallaxDepth", properties, glm::vec2 (0.0f)),
+		    .parallaxDepth = it.user ("parallaxDepth", properties, glm::vec2 (1.0f)),
 		    .particleFile = "",
 		    .animationMode = "sequence",
 		    .sequenceMultiplier = 1.0f,
@@ -548,7 +553,7 @@ ParticleUniquePtr ObjectParser::parseParticle (const JSON& it, const Project& pr
 		.scale = it.user ("scale", properties, glm::vec3 (1.0f)),
 		.angles = it.user ("angles", properties, glm::vec3 (0.0f)),
 		.visible = it.user ("visible", properties, true),
-		.parallaxDepth = it.user ("parallaxDepth", properties, glm::vec2 (0.0f)),
+		.parallaxDepth = it.user ("parallaxDepth", properties, glm::vec2 (1.0f)),
 		.particleFile = particleFile,
 		.animationMode = animationMode,
 		.sequenceMultiplier = sequenceMultiplier,

+ 29 - 25
src/WallpaperEngine/Render/Objects/CParticle.cpp

@@ -567,6 +567,7 @@ EmitterFunc CParticle::createBoxEmitter (const ParticleEmitter& emitter) {
 		p.age = 0.0f;
 		p.alive = true;
 		p.frame = -1.0f;
+		p.seed = m_usesParticleSeed ? WallpaperEngine::Maths::randomFloat (m_rng, 0.0f, 1.0f) : 0.0f;
 
 		p.initial.color = p.color;
 		p.initial.alpha = p.alpha;
@@ -711,6 +712,7 @@ EmitterFunc CParticle::createSphereEmitter (const ParticleEmitter& emitter) {
 	    p.age = 0.0f;
 	    p.alive = true;
 	    p.frame = -1.0f;
+	    p.seed = m_usesParticleSeed ? WallpaperEngine::Maths::randomFloat (m_rng, 0.0f, 1.0f) : 0.0f;
 
 	    p.initial.color = p.color;
 	    p.initial.alpha = p.alpha;
@@ -1246,30 +1248,24 @@ OperatorFunc CParticle::createTurbulenceOperator (const TurbulenceOperator& op)
     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
-	= WallpaperEngine::Maths::randomFloat (m_rng, phaseMinValue->getFloat (), phaseMaxValue->getFloat ());
-    const float turbSpeed
-	= WallpaperEngine::Maths::randomFloat (m_rng, speedMinValue->getFloat (), speedMaxValue->getFloat ());
+    this->m_usesParticleSeed = true;
 
-    return [this, scaleValue, timeScaleValue, maskValue, speedOverride, audioModeValue, audioBoundsValue,
-	    audioExponentValue, audioStartValue, audioEndValue, phase, baseTurbSpeed = turbSpeed] (
+    // same formula as wallpaper64.exe (sub_1401C9F60), phasemin is parsed there but never used
+    return [this, scaleValue, speedMinValue, speedMaxValue, timeScaleValue, maskValue, phaseMinValue, phaseMaxValue,
+	    speedOverride, audioModeValue, audioBoundsValue, audioExponentValue, audioStartValue, audioEndValue] (
 	       std::vector<ParticleInstance>& particles, uint32_t count, const std::vector<ControlPointData>&,
 	       float currentTime, float dt
 	   ) {
-	const float noiseScale = scaleValue->getFloat () * 2.0f;
-	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;
-	}
+	const float speedMin = speedMinValue->getFloat () * speedOverride->getFloat () * audio;
+	const float speedRange = speedMaxValue->getFloat () * speedOverride->getFloat () * audio - speedMin;
+	const float phaseRange = phaseMaxValue->getFloat () - phaseMinValue->getFloat ();
+	const float time = currentTime * timeScaleValue->getFloat ();
+	const float scale = scaleValue->getFloat ();
+	const glm::vec3 mask = maskValue->getVec3 ();
 
 	for (size_t i = 0; i < count; ++i) {
 	    ParticleInstance& p = particles[i];
@@ -1277,18 +1273,26 @@ OperatorFunc CParticle::createTurbulenceOperator (const TurbulenceOperator& op)
 		continue;
 	    }
 
-	    glm::vec3 noisePos = p.position;
-	    noisePos.x += phase + timeScale * currentTime;
-	    noisePos *= noiseScale;
+	    // WE's particle space is y-up
+	    const float phase = p.seed * phaseRange + time;
+	    const float x = scale * (p.position.x + phase);
+	    const float y = scale * (-p.position.y + phase);
+	    const float z = scale * (p.position.z + phase);
+	    const float step = (p.seed * speedRange + speedMin) * dt;
 
-	    glm::vec3 curlDir = curlNoise (noisePos);
-	    const float len = glm::length (curlDir);
-	    if (len > 0.0001f) {
-		curlDir = (curlDir / len) * turbSpeed;
+	    glm::vec3 delta (0.0f);
+
+	    if (mask.x != 0.0f) {
+		delta.x = simplexNoise3D (x, y, z) * mask.x;
+	    }
+	    if (mask.y != 0.0f) {
+		delta.y = simplexNoise3D (z, x, y) * mask.y;
+	    }
+	    if (mask.z != 0.0f) {
+		delta.z = simplexNoise3D (y, z, x) * mask.z;
 	    }
 
-	    curlDir *= mask;
-	    p.velocity += curlDir * dt * speed;
+	    p.velocity += glm::vec3 (delta.x, -delta.y, delta.z) * step;
 	}
     };
 }

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

@@ -64,6 +64,9 @@ struct ParticleInstance {
 	float lifetime { 1.0f };
     } initial;
 
+    /** Random 0..1 picked at spawn, WE's turbulence operator uses it for the particle's phase and speed */
+    float seed { 0.0f };
+
     bool alive { false };
 
     float getLifetimePos () const { return lifetime > 0.0f ? (age / lifetime) : 1.0f; }
@@ -243,6 +246,8 @@ private:
     float m_lastScreenHeight { 0.0f };
 
     std::mt19937 m_rng;
+    /** Only drawn when an operator needs it, so systems without one keep the same random sequence */
+    bool m_usesParticleSeed = false;
 
     bool m_initialized { false };
     Playback m_lastPlayback { Playback::Playing };

+ 30 - 6
src/WallpaperEngine/Render/Objects/CText.cpp

@@ -261,7 +261,8 @@ float clampParallaxAxis (float offset, float edgeA, float edgeB, float sceneExte
 } // namespace
 
 CText::CText (Wallpapers::CScene& scene, const Text& text) :
-    CObject (scene, text), CRenderable (scene, text, fontMaterial ()), ScriptableObject (scene, text), m_text (text) {
+    CObject (scene, text), CRenderable (scene, text, fontMaterial ()), ScriptableObject (scene, text), m_text (text),
+    m_font (text.font), m_loadedFont (text.font) {
     this->registerProperty ("color", *text.color->value);
     this->registerProperty ("alpha", *text.alpha->value);
     this->registerProperty ("origin", *text.origin->value);
@@ -269,6 +270,7 @@ CText::CText (Wallpapers::CScene& scene, const Text& text) :
     this->registerProperty ("visible", *text.visible->value);
     this->registerProperty ("pointSize", *text.pointSize->value);
     this->registerProperty ("text", *text.text->value);
+    this->registerProperty ("font", m_font);
     this->registerProperty ("parallaxDepth", *text.parallaxDepth->value);
     this->registerEffectConstants (text.effects);
 }
@@ -367,12 +369,12 @@ bool CText::loadEmbeddedFont () {
     // Wallpapers packed in .pkg don't expose physical paths, so we read the font
     // into memory and use FT_New_Memory_Face. m_fontData must outlive the face.
     // `systemfont_*` references signal "use a system font"; let the fallback handle them.
-    if (m_text.font.empty () || m_text.font.rfind ("systemfont_", 0) == 0) {
+    if (m_loadedFont.empty () || m_loadedFont.rfind ("systemfont_", 0) == 0) {
 	return false;
     }
 
     try {
-	auto stream = getAssetLocator ().read (m_text.font);
+	auto stream = getAssetLocator ().read (m_loadedFont);
 	stream->seekg (0, std::ios::end);
 	const auto size = stream->tellg ();
 	stream->seekg (0, std::ios::beg);
@@ -386,17 +388,28 @@ bool CText::loadEmbeddedFont () {
 	    return true;
 	}
 
-	sLog.error ("CText: FT_New_Memory_Face failed for '", m_text.font, "', falling back to system font");
+	sLog.error ("CText: FT_New_Memory_Face failed for '", m_loadedFont, "', falling back to system font");
     } catch (const std::exception& e) {
-	sLog.error ("CText: cannot read font '", m_text.font, "': ", e.what (), ", falling back to system font");
+	sLog.error ("CText: cannot read font '", m_loadedFont, "': ", e.what (), ", falling back to system font");
     }
 
     m_fontData.clear ();
     return false;
 }
 
+void CText::reloadFont () {
+    if (m_ftFace != nullptr) {
+	FT_Done_Face (m_ftFace);
+	m_ftFace = nullptr;
+    }
+
+    if (!loadEmbeddedFont ()) {
+	loadSystemFont ();
+    }
+}
+
 bool CText::loadSystemFont () {
-    std::string fontPath = fontconfigMatch (m_text.font);
+    std::string fontPath = fontconfigMatch (m_loadedFont);
 
     if (fontPath.empty ()) {
 	for (const auto& candidate : kFontCandidates) {
@@ -913,6 +926,17 @@ void CText::render () {
 	renderedText = current.empty () ? std::string (" ") : current;
     }
 
+    const std::string& requestedFont = m_font.getString ().empty () ? m_text.font : m_font.getString ();
+    if (requestedFont != m_loadedFont) {
+	m_loadedFont = requestedFont;
+	reloadFont ();
+	m_lastPixelSize = 0;
+    }
+
+    if (m_ftFace == nullptr) {
+	return;
+    }
+
     const unsigned int pixelSize = computeEffectivePixelSize ();
     if (pixelSize != m_lastPixelSize) {
 	m_lastPixelSize = pixelSize;

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

@@ -66,6 +66,7 @@ private:
     bool initFreeType ();
     bool loadEmbeddedFont ();
     bool loadSystemFont ();
+    void reloadFont ();
     unsigned int computeEffectivePixelSize () const;
     void initScriptLayer ();
 
@@ -77,6 +78,9 @@ private:
     FT_Library m_ftLibrary = nullptr;
     FT_Face m_ftFace = nullptr;
     std::vector<uint8_t> m_fontData;
+    /** Backs the scripts' layer.font */
+    DynamicValue m_font;
+    std::string m_loadedFont;
 
     std::shared_ptr<TextureProvider> m_glyphTexture;
 

+ 19 - 0
src/WallpaperEngine/Render/Objects/Effects/CPass.cpp

@@ -3,6 +3,7 @@
 #include <cctype>
 #include <cmath>
 #include <sstream>
+#include <type_traits>
 #include <utility>
 
 #include "WallpaperEngine/Render/Helpers/ContextAware.h"
@@ -588,8 +589,26 @@ void CPass::cleanupRenderSetup () {
     }
 }
 
+void CPass::refreshRenderableUniforms () {
+    const auto update = [this] (const char* name, const auto& value) {
+	const auto it = this->m_uniforms.find (name);
+
+	if (it != this->m_uniforms.end () && it->second->owned) {
+	    using Value = std::remove_cvref_t<decltype (value)>;
+	    *static_cast<Value*> (const_cast<void*> (it->second->value)) = value;
+	}
+    };
+
+    update ("g_UserAlpha", this->m_renderable.getUserAlpha ());
+    update ("g_Alpha", this->m_renderable.getAlpha ());
+    update ("g_Color", this->m_renderable.getColor ());
+    update ("g_Color4", this->m_renderable.getColor4 ());
+}
+
 void CPass::render () {
     glBindVertexArray (this->m_vao);
+    // copied when the pass was built, color and alpha scripts or animations change them every frame
+    this->refreshRenderableUniforms ();
 
     if (this->m_pass.shader == XRAY_EFFECT_SHADER) {
 	const bool fullReveal = this->getContext ().getApp ().getContext ().state.xray.fullReveal;

+ 1 - 0
src/WallpaperEngine/Render/Objects/Effects/CPass.h

@@ -135,6 +135,7 @@ private:
     void setupShaders ();
     /** Sets TEX<slot>FORMAT for the shader's formatcombo samplers, true if a combo changed */
     bool applyFormatCombos (const TextureMap& passTextures, const TextureMap& overrideTextures);
+    void refreshRenderableUniforms ();
     void setupShaderVariables ();
     /** GL type the linked program declares for a uniform (GL_NONE if it has no such active uniform) */
     [[nodiscard]] GLenum getDeclaredUniformType (const std::string& name) const;

+ 68 - 0
src/WallpaperEngine/Render/Utils/NoiseUtils.h

@@ -166,4 +166,72 @@ inline float simplexNoise1D (float x) {
     return 0.395f * (n0 + n1);
 }
 
+// Stefan Gustavson's 3D simplex noise, used by wallpaper64.exe's turbulence operator (sub_1401EB070, roughly -1..1)
+inline float simplexNoise3D (float x, float y, float z) {
+    static constexpr float gradients[12][3] = {
+	{ 1, 1, 0 }, { -1, 1, 0 }, { 1, -1, 0 }, { -1, -1, 0 }, { 1, 0, 1 }, { -1, 0, 1 },
+	{ 1, 0, -1 }, { -1, 0, -1 }, { 0, 1, 1 }, { 0, -1, 1 }, { 0, 1, -1 }, { 0, -1, -1 },
+    };
+    constexpr float F3 = 1.0f / 3.0f;
+    constexpr float G3 = 1.0f / 6.0f;
+
+    const float s = (x + y + z) * F3;
+    const int i = static_cast<int> (std::floor (x + s));
+    const int j = static_cast<int> (std::floor (y + s));
+    const int k = static_cast<int> (std::floor (z + s));
+    const float t = static_cast<float> (i + j + k) * G3;
+    const float x0 = x - (static_cast<float> (i) - t);
+    const float y0 = y - (static_cast<float> (j) - t);
+    const float z0 = z - (static_cast<float> (k) - t);
+
+    int i1, j1, k1, i2, j2, k2;
+
+    if (x0 >= y0) {
+	if (y0 >= z0) {
+	    i1 = 1, j1 = 0, k1 = 0, i2 = 1, j2 = 1, k2 = 0;
+	} else if (x0 >= z0) {
+	    i1 = 1, j1 = 0, k1 = 0, i2 = 1, j2 = 0, k2 = 1;
+	} else {
+	    i1 = 0, j1 = 0, k1 = 1, i2 = 1, j2 = 0, k2 = 1;
+	}
+    } else {
+	if (y0 < z0) {
+	    i1 = 0, j1 = 0, k1 = 1, i2 = 0, j2 = 1, k2 = 1;
+	} else if (x0 < z0) {
+	    i1 = 0, j1 = 1, k1 = 0, i2 = 0, j2 = 1, k2 = 1;
+	} else {
+	    i1 = 0, j1 = 1, k1 = 0, i2 = 1, j2 = 1, k2 = 0;
+	}
+    }
+
+    const float offsets[4][3] = {
+	{ x0, y0, z0 },
+	{ x0 - i1 + G3, y0 - j1 + G3, z0 - k1 + G3 },
+	{ x0 - i2 + 2.0f * G3, y0 - j2 + 2.0f * G3, z0 - k2 + 2.0f * G3 },
+	{ x0 - 1.0f + 3.0f * G3, y0 - 1.0f + 3.0f * G3, z0 - 1.0f + 3.0f * G3 },
+    };
+    const int corners[4][3] = { { 0, 0, 0 }, { i1, j1, k1 }, { i2, j2, k2 }, { 1, 1, 1 } };
+    const auto perm = [] (int index) { return static_cast<int> (PERLIN_PERM[index & 0xff]); };
+
+    float total = 0.0f;
+
+    for (int c = 0; c < 4; c++) {
+	const float* o = offsets[c];
+	float falloff = 0.6f - o[0] * o[0] - o[1] * o[1] - o[2] * o[2];
+
+	if (falloff < 0.0f) {
+	    continue;
+	}
+
+	const int gradient
+	    = perm (i + corners[c][0] + perm (j + corners[c][1] + perm (k + corners[c][2]))) % 12;
+
+	falloff *= falloff;
+	total += falloff * falloff
+	    * (gradients[gradient][0] * o[0] + gradients[gradient][1] * o[1] + gradients[gradient][2] * o[2]);
+    }
+
+    return 32.0f * total;
+}
+
 } // namespace WallpaperEngine::Render::Utils

+ 8 - 15
src/WallpaperEngine/Render/Wallpapers/CScene.cpp

@@ -615,7 +615,8 @@ glm::vec2 CScene::getParallaxOffset (const Object& object) const {
 	return { 0.0f, 0.0f };
     }
 
-    const auto depthOf = [] (const Object& candidate) -> std::optional<glm::vec2> {
+    // every object has a parallaxDepth, (1, 1) unless set (base object constructor sub_14016BE90)
+    const auto depthOf = [] (const Object& candidate) -> glm::vec2 {
 	if (candidate.is<Image> ()) {
 	    return candidate.as<Image> ()->parallaxDepth->value->getVec2 ();
 	}
@@ -625,29 +626,21 @@ glm::vec2 CScene::getParallaxOffset (const Object& object) const {
 	if (candidate.is<Particle> ()) {
 	    return candidate.as<Particle> ()->parallaxDepth->value->getVec2 ();
 	}
-	return std::nullopt;
+	return candidate.groupParallaxDepth->value->getVec2 ();
     };
 
-    // topmost ancestor that carries a parallaxDepth, guarded against a malformed parent loop
+    // the topmost ancestor moves its whole subtree, guarded against a malformed parent loop
     constexpr int maxParentDepth = 32;
     const Object* anchor = &object;
-    std::optional<glm::vec2> depth = depthOf (object);
-    const Object* current = &object;
-    for (int i = 0; i < maxParentDepth && current->parent.has_value (); ++i) {
-	const CObject* parent = this->getObject (current->parent.value ());
+    for (int i = 0; i < maxParentDepth && anchor->parent.has_value (); ++i) {
+	const CObject* parent = this->getObject (anchor->parent.value ());
 	if (parent == nullptr) {
 	    break;
 	}
-	current = &parent->getObject ();
-	if (const auto parentDepth = depthOf (*current); parentDepth.has_value ()) {
-	    anchor = current;
-	    depth = parentDepth;
-	}
+	anchor = &parent->getObject ();
     }
 
-    if (!depth.has_value ()) {
-	return { 0.0f, 0.0f };
-    }
+    const std::optional<glm::vec2> depth = depthOf (*anchor);
 
     const float amount = this->getScene ().camera.parallax.amount->value->getFloat ();
     const float width = static_cast<float> (this->getWidth ());

+ 40 - 0
src/WallpaperEngine/Testing/Cases/ParticleNoise.cpp

@@ -0,0 +1,40 @@
+#include <catch2/catch_test_macros.hpp>
+
+#include <cmath>
+
+#include "WallpaperEngine/Render/Utils/NoiseUtils.h"
+
+using WallpaperEngine::Render::Utils::simplexNoise1D;
+using WallpaperEngine::Render::Utils::simplexNoise3D;
+
+TEST_CASE ("Simplex noise is zero on lattice points") {
+    CHECK (simplexNoise1D (0.0f) == 0.0f);
+    CHECK (simplexNoise1D (7.0f) == 0.0f);
+    CHECK (std::abs (simplexNoise3D (0.0f, 0.0f, 0.0f)) < 1e-6f);
+}
+
+TEST_CASE ("Simplex noise stays in range and is continuous") {
+    float min1 = 0.0f, max1 = 0.0f, min3 = 0.0f, max3 = 0.0f;
+    float biggestStep = 0.0f;
+    float previous = simplexNoise3D (-50.0f, 3.1f - 50.0f * 0.37f, 7.7f + 50.0f * 0.21f);
+
+    for (int i = 0; i < 20000; i++) {
+	const float t = -50.0f + static_cast<float> (i) * 0.005f;
+	const float one = simplexNoise1D (t);
+	const float three = simplexNoise3D (t, 3.1f + t * 0.37f, 7.7f - t * 0.21f);
+
+	min1 = std::min (min1, one);
+	max1 = std::max (max1, one);
+	min3 = std::min (min3, three);
+	max3 = std::max (max3, three);
+	biggestStep = std::max (biggestStep, std::abs (three - previous));
+	previous = three;
+    }
+
+    CHECK (min1 >= -1.1f);
+    CHECK (max1 <= 1.1f);
+    CHECK (min3 >= -1.1f);
+    CHECK (max3 <= 1.1f);
+    CHECK (max3 - min3 > 1.0f);
+    CHECK (biggestStep < 0.1f);
+}