瀏覽代碼

revorked a lot of effects and scene

UwU 2 天之前
父節點
當前提交
9197814375
共有 41 個文件被更改,包括 1137 次插入 和 1974 次删除
  1. 0 7
      CMakeLists.txt
  2. 37 24
      src/WallpaperEngine/Data/Model/Object.h
  3. 26 14
      src/WallpaperEngine/Data/Parsers/ObjectParser.cpp
  4. 37 7
      src/WallpaperEngine/Render/CFBO.cpp
  5. 6 1
      src/WallpaperEngine/Render/CFBO.h
  6. 0 2
      src/WallpaperEngine/Render/CWallpaper.cpp
  7. 4 0
      src/WallpaperEngine/Render/Camera.cpp
  8. 2 0
      src/WallpaperEngine/Render/Camera.h
  9. 2 2
      src/WallpaperEngine/Render/FBOProvider.cpp
  10. 1 1
      src/WallpaperEngine/Render/FBOProvider.h
  11. 19 10
      src/WallpaperEngine/Render/Objects/CImage.cpp
  12. 1 0
      src/WallpaperEngine/Render/Objects/CImage.h
  13. 12 28
      src/WallpaperEngine/Render/Objects/CMesh.cpp
  14. 2 3
      src/WallpaperEngine/Render/Objects/CMesh.h
  15. 343 284
      src/WallpaperEngine/Render/Objects/CParticle.cpp
  16. 6 10
      src/WallpaperEngine/Render/Objects/CParticle.h
  17. 2 2
      src/WallpaperEngine/Render/Objects/CText.cpp
  18. 0 1
      src/WallpaperEngine/Render/Objects/CText.h
  19. 31 10
      src/WallpaperEngine/Render/Objects/Effects/CPass.cpp
  20. 6 0
      src/WallpaperEngine/Render/Objects/Effects/CPass.h
  21. 74 8
      src/WallpaperEngine/Render/Shaders/GLSLContext.cpp
  22. 5 1
      src/WallpaperEngine/Render/Shaders/GLSLContext.h
  23. 123 4
      src/WallpaperEngine/Render/Shaders/ShaderUnit.cpp
  24. 7 0
      src/WallpaperEngine/Render/Shaders/ShaderUnit.h
  25. 24 25
      src/WallpaperEngine/Render/Utils/NoiseUtils.h
  26. 132 47
      src/WallpaperEngine/Render/Wallpapers/CScene.cpp
  27. 20 2
      src/WallpaperEngine/Render/Wallpapers/CScene.h
  28. 19 1021
      src/WallpaperEngine/Scripting/Adapters/VectorAdapter.cpp
  29. 13 12
      src/WallpaperEngine/Scripting/Adapters/VectorAdapter.h
  30. 2 2
      src/WallpaperEngine/Scripting/ConsoleObject.cpp
  31. 0 253
      src/WallpaperEngine/Scripting/Modules/ColorModule.cpp
  32. 0 16
      src/WallpaperEngine/Scripting/Modules/ColorModule.h
  33. 0 80
      src/WallpaperEngine/Scripting/Modules/MathModule.cpp
  34. 0 16
      src/WallpaperEngine/Scripting/Modules/MathModule.h
  35. 0 12
      src/WallpaperEngine/Scripting/Modules/ScriptModule.cpp
  36. 0 26
      src/WallpaperEngine/Scripting/Modules/ScriptModule.h
  37. 62 1
      src/WallpaperEngine/Scripting/SceneObject.cpp
  38. 95 31
      src/WallpaperEngine/Scripting/ScriptEngine.cpp
  39. 2 3
      src/WallpaperEngine/Scripting/ScriptEngine.h
  40. 0 5
      src/WallpaperEngine/Scripting/resources/builtins.js
  41. 22 3
      src/WallpaperEngine/Testing/Cases/ParticleNoise.cpp

+ 0 - 7
CMakeLists.txt

@@ -595,13 +595,6 @@ set(COMMON_SOURCES
     src/WallpaperEngine/Scripting/Adapters/ScriptableObjectAdapter.h
     src/WallpaperEngine/Scripting/Adapters/ScriptableObjectAdapter.cpp
 
-    src/WallpaperEngine/Scripting/Modules/ColorModule.h
-    src/WallpaperEngine/Scripting/Modules/ColorModule.cpp
-    src/WallpaperEngine/Scripting/Modules/MathModule.h
-    src/WallpaperEngine/Scripting/Modules/MathModule.cpp
-    src/WallpaperEngine/Scripting/Modules/ScriptModule.h
-    src/WallpaperEngine/Scripting/Modules/ScriptModule.cpp
-
     src/WallpaperEngine/Scripting/ConsoleObject.h
     src/WallpaperEngine/Scripting/ConsoleObject.cpp
     src/WallpaperEngine/Scripting/EngineObject.h

+ 37 - 24
src/WallpaperEngine/Data/Model/Object.h

@@ -540,6 +540,13 @@ public:
     UserSettingUniquePtr endValue;
 };
 
+struct ParticleBlendWindow {
+    float inStart;
+    float inEnd;
+    float outStart;
+    float outEnd;
+};
+
 class TurbulenceOperator : public ParticleOperatorBase {
 public:
     TurbulenceOperator (
@@ -573,15 +580,15 @@ public:
 class VortexOperator : public ParticleOperatorBase {
 public:
     VortexOperator (
-	int controlPoint, int flags, UserSettingUniquePtr axis, UserSettingUniquePtr offset,
+	bool v2, int controlPoint, int flags, UserSettingUniquePtr axis, UserSettingUniquePtr offset,
 	UserSettingUniquePtr distanceInner, UserSettingUniquePtr distanceOuter, UserSettingUniquePtr speedInner,
 	UserSettingUniquePtr speedOuter, UserSettingUniquePtr centerForce, UserSettingUniquePtr ringRadius,
 	UserSettingUniquePtr ringWidth, UserSettingUniquePtr ringPullDistance, UserSettingUniquePtr ringPullForce,
 	UserSettingUniquePtr audioProcessingMode, UserSettingUniquePtr audioProcessingBounds,
 	UserSettingUniquePtr audioProcessingExponent, UserSettingUniquePtr audioProcessingFrequencyStart,
-	UserSettingUniquePtr audioProcessingFrequencyEnd
+	UserSettingUniquePtr audioProcessingFrequencyEnd, ParticleBlendWindow blend
     ) :
-	controlPoint (controlPoint), flags (flags), axis (std::move (axis)), offset (std::move (offset)),
+	v2 (v2), controlPoint (controlPoint), flags (flags), axis (std::move (axis)), offset (std::move (offset)),
 	distanceInner (std::move (distanceInner)), distanceOuter (std::move (distanceOuter)),
 	speedInner (std::move (speedInner)), speedOuter (std::move (speedOuter)), centerForce (std::move (centerForce)),
 	ringRadius (std::move (ringRadius)), ringWidth (std::move (ringWidth)),
@@ -590,25 +597,29 @@ public:
 	audioProcessingBounds (std::move (audioProcessingBounds)),
 	audioProcessingExponent (std::move (audioProcessingExponent)),
 	audioProcessingFrequencyStart (std::move (audioProcessingFrequencyStart)),
-	audioProcessingFrequencyEnd (std::move (audioProcessingFrequencyEnd)) { }
+	audioProcessingFrequencyEnd (std::move (audioProcessingFrequencyEnd)), blend (blend) { }
+    /** vortex_v2: axis turned by the control point, centerforce and the ring shape, blend window */
+    bool v2;
     int controlPoint;
-    int flags; // 1 = infinite axis, 2 = maintain distance to center, 4 = ring shape
+    int flags; // 1 = infinite axis, 2 = centerforce (v2), 4 = ring shape (v2)
+    /** Settings with 2D/3D defaults are null when the file leaves them out */
     UserSettingUniquePtr axis;
-    UserSettingUniquePtr offset;
-    UserSettingUniquePtr distanceInner; // Standard vortex inner radius
-    UserSettingUniquePtr distanceOuter; // Standard vortex outer radius
+    UserSettingUniquePtr offset; // vortex only
+    UserSettingUniquePtr distanceInner;
+    UserSettingUniquePtr distanceOuter;
     UserSettingUniquePtr speedInner;
     UserSettingUniquePtr speedOuter;
-    UserSettingUniquePtr centerForce; // Strength to pull particles toward center
-    UserSettingUniquePtr ringRadius; // Ring mode: radius of the ring
-    UserSettingUniquePtr ringWidth; // Ring mode: width of the ring
-    UserSettingUniquePtr ringPullDistance; // Ring mode: distance at which ring attracts particles
-    UserSettingUniquePtr ringPullForce; // Ring mode: strength of ring attraction
+    UserSettingUniquePtr centerForce;
+    UserSettingUniquePtr ringRadius;
+    UserSettingUniquePtr ringWidth;
+    UserSettingUniquePtr ringPullDistance;
+    UserSettingUniquePtr ringPullForce;
     UserSettingUniquePtr audioProcessingMode;
     UserSettingUniquePtr audioProcessingBounds;
     UserSettingUniquePtr audioProcessingExponent;
     UserSettingUniquePtr audioProcessingFrequencyStart;
     UserSettingUniquePtr audioProcessingFrequencyEnd;
+    ParticleBlendWindow blend;
 };
 
 class InheritValueFromEventOperator : public ParticleOperatorBase {
@@ -622,14 +633,18 @@ public:
 class ControlPointAttractOperator : public ParticleOperatorBase {
 public:
     ControlPointAttractOperator (
-	int controlPoint, UserSettingUniquePtr origin, UserSettingUniquePtr scale, UserSettingUniquePtr threshold
+	int controlPoint, int flags, UserSettingUniquePtr scale, UserSettingUniquePtr threshold,
+	UserSettingUniquePtr deleteThreshold
     ) :
-	controlPoint (controlPoint), origin (std::move (origin)), scale (std::move (scale)),
-	threshold (std::move (threshold)) { }
+	controlPoint (controlPoint), flags (flags), scale (std::move (scale)), threshold (std::move (threshold)),
+	deleteThreshold (std::move (deleteThreshold)) { }
     int controlPoint;
-    UserSettingUniquePtr origin;
+    /** 1 = particles passing within deletethreshold of the point die, 2 = never pull further than the point */
+    int flags;
+    /** null when the file leaves them out, the defaults depend on the scene being 2D or 3D */
     UserSettingUniquePtr scale;
     UserSettingUniquePtr threshold;
+    UserSettingUniquePtr deleteThreshold;
 };
 
 class OscillateAlphaOperator : public ParticleOperatorBase {
@@ -686,13 +701,6 @@ public:
 };
 
 /** blendinstart, blendinend, blendoutstart, blendoutend: how much of an operator applies over the particle's life */
-struct ParticleBlendWindow {
-    float inStart;
-    float inEnd;
-    float outStart;
-    float outEnd;
-};
-
 class CapVelocityOperator : public ParticleOperatorBase {
 public:
     CapVelocityOperator (UserSettingUniquePtr maxSpeed, ParticleBlendWindow blend) :
@@ -861,10 +869,15 @@ struct ParticleInstanceOverride {
     UserSettingUniquePtr rate;
     UserSettingUniquePtr speed;
     UserSettingUniquePtr count;
+    /** Scales the spawn color, only read in HDR scene rendering */
+    UserSettingUniquePtr brightness;
     UserSettingUniquePtr color; // Replaces particle color
     UserSettingUniquePtr colorn; // Multiplies particle color
     /** colorn (or the legacy color) was given, WE keeps -1 in it otherwise */
     bool hasColor = false;
+    /** controlpoint0..7 replace a control point's translation, controlpointangle0..7 turn it, null when not set */
+    std::array<UserSettingUniquePtr, 8> controlPoints;
+    std::array<UserSettingUniquePtr, 8> controlPointAngles;
 };
 
 struct ParticleData {

+ 26 - 14
src/WallpaperEngine/Data/Parsers/ObjectParser.cpp

@@ -789,6 +789,7 @@ ParticleUniquePtr ObjectParser::parseParticle (const JSON& it, const Project& pr
 			.rate = Builders::UserSettingBuilder::fromValue(1.0f),
 			.speed = Builders::UserSettingBuilder::fromValue(1.0f),
 			.count = Builders::UserSettingBuilder::fromValue(1.0f),
+			.brightness = Builders::UserSettingBuilder::fromValue(1.0f),
 			.color = Builders::UserSettingBuilder::fromValue(1.0f),
 			.colorn = Builders::UserSettingBuilder::fromValue(1.0f),
 		    },
@@ -924,6 +925,7 @@ ParticleUniquePtr ObjectParser::parseParticle (const JSON& it, const Project& pr
 	    .rate = Builders::UserSettingBuilder::fromValue (1.0f),
 	    .speed = Builders::UserSettingBuilder::fromValue (1.0f),
 	    .count = Builders::UserSettingBuilder::fromValue (1.0f),
+	    .brightness = Builders::UserSettingBuilder::fromValue (1.0f),
 	    .color = Builders::UserSettingBuilder::fromValue (1.0f),
 	    .colorn = Builders::UserSettingBuilder::fromValue (1.0f),
 	};
@@ -1106,9 +1108,10 @@ ParticleInitializerUniquePtr ObjectParser::parseParticleInitializer (const JSON&
     std::string name = it.optional<std::string> ("name", "");
 
     if (name == "colorrandom") {
+	// read as plain numbers, CParticle divides them by 255 like sub_1401C5490 does ("1 1 1" is nearly black)
 	return std::make_unique<ColorRandomInitializer> (
-	    it.color ("min", properties, Builders::ColorBuilder::Black),
-	    it.color ("max", properties, Builders::ColorBuilder::White), it.user ("exponent", properties, 1.0f)
+	    it.user ("min", properties, glm::vec3 (0.0f)), it.user ("max", properties, glm::vec3 (255.0f)),
+	    it.user ("exponent", properties, 1.0f)
 	);
     } else if (name == "sizerandom") {
 	return std::make_unique<SizeRandomInitializer> (
@@ -1236,10 +1239,11 @@ ParticleOperatorUniquePtr ObjectParser::parseParticleOperator (const JSON& it, c
 	    it.user ("startvalue", properties, glm::vec3 (1.0f)), it.user ("endvalue", properties, glm::vec3 (1.0f))
 	);
     } else if (name == "turbulence") {
+	// defaults from sub_1401BEB80, the 2D/3D ones are picked by CParticle
 	return std::make_unique<TurbulenceOperator> (
-	    it.user ("scale", properties, 0.005f), it.user ("speedmin", properties, 500.0f),
-	    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),
+	    userIfSet (it, "scale", properties), userIfSet (it, "speedmin", properties),
+	    userIfSet (it, "speedmax", properties), userIfSet (it, "timescale", properties),
+	    userIfSet (it, "mask", properties), it.user ("phasemin", properties, 0.0f),
 	    it.user ("phasemax", properties, 0.0f), it.user ("audioprocessingmode", properties, 0),
 	    it.user ("audioprocessingbounds", properties, glm::vec2 (0.8f, 1.0f)),
 	    it.user ("audioprocessingexponent", properties, 2.0f),
@@ -1247,20 +1251,20 @@ ParticleOperatorUniquePtr ObjectParser::parseParticleOperator (const JSON& it, c
 	    it.user ("audioprocessingfrequencyend", properties, 1)
 	);
     } else if (name == "vortex" || name == "vortex_v2") {
+	// defaults from sub_1401BEF00 (vortex) and sub_1401BF2D0 (vortex_v2)
 	return std::make_unique<VortexOperator> (
-	    it.optional ("controlpoint", 0),
-	    it.optional ("flags", 0), // 1 = infinite axis, 2 = maintain distance, 4 = ring shape
+	    name == "vortex_v2", controlPointIndex (it, "controlpoint", 0), it.optional ("flags", 0),
 	    it.user ("axis", properties, glm::vec3 (0.0f, 0.0f, 1.0f)),
-	    it.user ("offset", properties, glm::vec3 (0.0f)), it.user ("distanceinner", properties, 500.0f),
-	    it.user ("distanceouter", properties, 650.0f), it.user ("speedinner", properties, 2500.0f),
+	    it.user ("offset", properties, glm::vec3 (0.0f)), userIfSet (it, "distanceinner", properties),
+	    userIfSet (it, "distanceouter", properties), userIfSet (it, "speedinner", properties),
 	    it.user ("speedouter", properties, 0.0f), it.user ("centerforce", properties, 1.0f),
-	    it.user ("ringradius", properties, 300.0f), it.user ("ringwidth", properties, 50.0f),
-	    it.user ("ringpulldistance", properties, 50.0f), it.user ("ringpullforce", properties, 10.0f),
+	    userIfSet (it, "ringradius", properties), userIfSet (it, "ringwidth", properties),
+	    userIfSet (it, "ringpulldistance", properties), userIfSet (it, "ringpullforce", properties),
 	    it.user ("audioprocessingmode", properties, 0),
 	    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)
+	    it.user ("audioprocessingfrequencyend", properties, 1), parseBlendWindow (it)
 	);
     } else if (name == "inheritvaluefromevent") {
 	return std::make_unique<InheritValueFromEventOperator> (
@@ -1271,9 +1275,10 @@ ParticleOperatorUniquePtr ObjectParser::parseParticleOperator (const JSON& it, c
 	    )
 	);
     } else if (name == "controlpointattract") {
+	// defaults from sub_1401BDEE0. Its "offset" is loaded but never read by the operator
 	return std::make_unique<ControlPointAttractOperator> (
-	    it.optional ("controlpoint", 0), it.user ("origin", properties, glm::vec3 (0.0f)),
-	    it.user ("scale", properties, 100.0f), it.user ("threshold", properties, 1000.0f)
+	    controlPointIndex (it, "controlpoint", 0), it.optional ("flags", 2), userIfSet (it, "scale", properties),
+	    userIfSet (it, "threshold", properties), userIfSet (it, "deletethreshold", properties)
 	);
     } else if (name == "oscillatealpha") {
 	return std::make_unique<OscillateAlphaOperator> (
@@ -1565,11 +1570,18 @@ ParticleInstanceOverride ObjectParser::parseParticleInstanceOverride (const JSON
 	.rate = it.user ("rate", properties, 1.0f),
 	.speed = it.user ("speed", properties, 1.0f),
 	.count = it.user ("count", properties, 1.0f),
+	.brightness = it.user ("brightness", properties, 1.0f),
 	.color = it.user ("color", properties, glm::vec3 (1.0f)),
 	.colorn = it.user ("colorn", properties, glm::vec3 (1.0f)),
 	.hasColor = it.optional ("colorn").has_value () || it.optional ("color").has_value (),
     };
 
+    // property table sub_14024D940, applied by sub_14022BD40
+    for (size_t i = 0; i < result.controlPoints.size (); i++) {
+	result.controlPoints[i] = userIfSet (it, "controlpoint" + std::to_string (i), properties);
+	result.controlPointAngles[i] = userIfSet (it, "controlpointangle" + std::to_string (i), properties);
+    }
+
     // WE converts the legacy 0-255 "color" into colorn on load (replacing any colorn) and only reads colorn after
     if (it.optional ("color").has_value ()) {
 	result.colorn = Builders::UserSettingBuilder::fromValue (result.color->value->getVec3 () / 255.0f);

+ 37 - 7
src/WallpaperEngine/Render/CFBO.cpp

@@ -1,4 +1,7 @@
 #include "CFBO.h"
+
+#include <algorithm>
+
 #include "WallpaperEngine/Logging/Log.h"
 
 using namespace WallpaperEngine::Render;
@@ -21,16 +24,21 @@ GLint internalFormat (const TextureFormat format) {
 
 CFBO::CFBO (
     std::string name, const TextureFormat format, const uint32_t flags, const float scale, uint32_t realWidth,
-    uint32_t realHeight, uint32_t textureWidth, uint32_t textureHeight, const glm::vec4& borderColor
-) : m_scale (scale), m_name (std::move (name)), m_format (format), m_flags (flags) {
+    uint32_t realHeight, uint32_t textureWidth, uint32_t textureHeight, const glm::vec4& borderColor,
+    const uint32_t mipLevels
+) : m_scale (scale), m_name (std::move (name)), m_format (format), m_flags (flags), m_mipLevels (std::max (mipLevels, 1u)) {
     constexpr GLenum drawBuffers[1] = { GL_COLOR_ATTACHMENT0 };
     glGenFramebuffers (1, &this->m_framebuffer);
     glBindFramebuffer (GL_FRAMEBUFFER, this->m_framebuffer);
     glGenTextures (1, &this->m_texture);
     glBindTexture (GL_TEXTURE_2D, this->m_texture);
-    glTexImage2D (
-	GL_TEXTURE_2D, 0, internalFormat (format), textureWidth, textureHeight, 0, GL_RGBA, GL_UNSIGNED_BYTE, nullptr
-    );
+    for (uint32_t level = 0; level < this->m_mipLevels; level++) {
+	glTexImage2D (
+	    GL_TEXTURE_2D, level, internalFormat (format), std::max (textureWidth >> level, 1u),
+	    std::max (textureHeight >> level, 1u), 0, GL_RGBA, GL_UNSIGNED_BYTE, nullptr
+	);
+    }
+    glTexParameteri (GL_TEXTURE_2D, GL_TEXTURE_MAX_LEVEL, this->m_mipLevels - 1);
 #if !NDEBUG
     glObjectLabel (GL_TEXTURE, this->m_texture, -1, this->m_name.c_str ());
 #endif /* DEBUG */
@@ -51,19 +59,30 @@ CFBO::CFBO (
     if (flags & TextureFlags_NoInterpolation) {
 	glTexParameteri (GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_NEAREST);
 	glTexParameteri (GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_NEAREST);
+    } else if (this->m_mipLevels > 1) {
+	// WE samples its mipmapped frame buffer trilinear without anisotropy
+	glTexParameteri (GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR);
+	glTexParameteri (GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR_MIPMAP_LINEAR);
     } else {
 	glTexParameteri (GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR);
 	glTexParameteri (GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR);
     }
 
-    glTexParameterf (GL_TEXTURE_2D, GL_TEXTURE_MAX_ANISOTROPY, 8.0f);
+    if (this->m_mipLevels == 1) {
+	glTexParameterf (GL_TEXTURE_2D, GL_TEXTURE_MAX_ANISOTROPY, 8.0f);
+    }
 
     glFramebufferTexture2D (GL_FRAMEBUFFER, GL_COLOR_ATTACHMENT0, GL_TEXTURE_2D, this->m_texture, 0);
     glDrawBuffers (1, drawBuffers);
 
     if (glCheckFramebufferStatus (GL_FRAMEBUFFER) != GL_FRAMEBUFFER_COMPLETE && internalFormat (format) != GL_RGBA8) {
 	sLog.error ("FBO ", this->m_name, " can't render to format ", format, ", falling back to RGBA8");
-	glTexImage2D (GL_TEXTURE_2D, 0, GL_RGBA8, textureWidth, textureHeight, 0, GL_RGBA, GL_UNSIGNED_BYTE, nullptr);
+	for (uint32_t level = 0; level < this->m_mipLevels; level++) {
+	    glTexImage2D (
+		GL_TEXTURE_2D, level, GL_RGBA8, std::max (textureWidth >> level, 1u), std::max (textureHeight >> level, 1u),
+		0, GL_RGBA, GL_UNSIGNED_BYTE, nullptr
+	    );
+	}
 	this->m_format = TextureFormat_ARGB8888;
     }
 
@@ -133,6 +152,17 @@ void CFBO::attachDepthBuffer () {
     glBindFramebuffer (GL_FRAMEBUFFER, previous);
 }
 
+void CFBO::generateMipmaps () const {
+    if (this->m_mipLevels < 2) {
+	return;
+    }
+
+    glBindTexture (GL_TEXTURE_2D, this->m_texture);
+    glGenerateMipmap (GL_TEXTURE_2D);
+}
+
+uint32_t CFBO::getMipLevels () const { return this->m_mipLevels; }
+
 const std::string& CFBO::getName () const { return this->m_name; }
 
 const float& CFBO::getScale () const { return this->m_scale; }

+ 6 - 1
src/WallpaperEngine/Render/CFBO.h

@@ -13,7 +13,7 @@ public:
     CFBO (
 	std::string name, const TextureFormat format, const uint32_t flags, const float scale, uint32_t realWidth,
 	uint32_t realHeight, uint32_t textureWidth, uint32_t textureHeight,
-	const glm::vec4& borderColor = { 0.0f, 0.0f, 0.0f, 1.0f }
+	const glm::vec4& borderColor = { 0.0f, 0.0f, 0.0f, 1.0f }, uint32_t mipLevels = 1
     );
     ~CFBO () override;
 
@@ -32,6 +32,10 @@ public:
     /** Adds a depth buffer, only 3D scenes draw into the scene buffer with depth testing */
     void attachDepthBuffer ();
 
+    /** Rebuilds mip levels 1 and up from level 0, only for buffers created with more than one level */
+    void generateMipmaps () const;
+    [[nodiscard]] uint32_t getMipLevels () const;
+
     [[nodiscard]] const std::string& getName () const;
     [[nodiscard]] const float& getScale () const;
     [[nodiscard]] TextureFormat getFormat () const override;
@@ -65,6 +69,7 @@ private:
     std::string m_name = "";
     TextureFormat m_format = TextureFormat_UNKNOWN;
     uint32_t m_flags = TextureFlags_NoFlags;
+    uint32_t m_mipLevels = 1;
     /** Placeholder for frames, FBOs only have ONE */
     std::vector<FrameSharedPtr> m_frames = {};
 };

+ 0 - 2
src/WallpaperEngine/Render/CWallpaper.cpp

@@ -437,8 +437,6 @@ void CWallpaper::setupFramebuffers (const bool depth, const TextureFormat format
     }
 
     this->m_sceneFBO = sceneFBO;
-
-    this->alias ("_rt_MipMappedFrameBuffer", "_rt_FullFrameBuffer");
 }
 
 AudioContext& CWallpaper::getAudioContext () const { return this->m_audioContext; }

+ 4 - 0
src/WallpaperEngine/Render/Camera.cpp

@@ -29,6 +29,10 @@ const glm::mat4& Camera::getProjection () const { return this->m_projection; }
 
 const glm::mat4& Camera::getLookAt () const { return this->m_lookat; }
 
+const glm::mat4& Camera::getFullscreenProjection () const {
+    return this->m_isOrthogonal ? this->m_orthogonal : this->m_projection;
+}
+
 const glm::mat4& Camera::getWorldView () const { return this->m_worldView; }
 
 const glm::mat4& Camera::getPerspectiveLayerViewProjection () const { return this->m_perspectiveLayer; }

+ 2 - 0
src/WallpaperEngine/Render/Camera.h

@@ -39,6 +39,8 @@ public:
     [[nodiscard]] const glm::vec3& getUp () const;
     [[nodiscard]] const glm::mat4& getProjection () const;
     [[nodiscard]] const glm::mat4& getLookAt () const;
+    /** The whole scene buffer with no camera view, zoom or parallax, what fullscreen layers draw with */
+    [[nodiscard]] const glm::mat4& getFullscreenProjection () const;
     [[nodiscard]] Wallpapers::CScene& getScene () const;
     [[nodiscard]] bool isOrthogonal () const;
     [[nodiscard]] bool isPerspective () const;

+ 2 - 2
src/WallpaperEngine/Render/FBOProvider.cpp

@@ -43,11 +43,11 @@ std::shared_ptr<CFBO> FBOProvider::create (const FBO& base, uint32_t flags, cons
 
 std::shared_ptr<CFBO> FBOProvider::create (
     const std::string& name, TextureFormat format, uint32_t flags, float scale, glm::vec2 realSize,
-    glm::vec2 textureSize, const glm::vec4& borderColor
+    glm::vec2 textureSize, const glm::vec4& borderColor, const uint32_t mipLevels
 ) {
     return this->m_fbos[name] = std::make_shared<CFBO> (
 	       name, format, flags, scale, realSize.x, realSize.y, textureSize.x, textureSize.y,
-	       borderColor
+	       borderColor, mipLevels
 	   );
 }
 

+ 1 - 1
src/WallpaperEngine/Render/FBOProvider.h

@@ -16,7 +16,7 @@ public:
     std::shared_ptr<CFBO> create (const FBO& base, uint32_t flags, glm::vec2 size);
     std::shared_ptr<CFBO> create (
 	const std::string& name, TextureFormat format, uint32_t flags, float scale, glm::vec2 realSize,
-	glm::vec2 textureSize, const glm::vec4& borderColor = { 0.0f, 0.0f, 0.0f, 1.0f }
+	glm::vec2 textureSize, const glm::vec4& borderColor = { 0.0f, 0.0f, 0.0f, 1.0f }, uint32_t mipLevels = 1
     );
     std::shared_ptr<CFBO> alias (const std::string& newName, const std::string& original);
     [[nodiscard]] std::shared_ptr<CFBO> find (const std::string& name) const;

+ 19 - 10
src/WallpaperEngine/Render/Objects/CImage.cpp

@@ -1737,9 +1737,13 @@ void CImage::setup () {
 	return;
     }
 
-    // passthrough without effects has nothing to draw, WE doesn't composite these either
-    // (sub_140175830 only takes the offscreen path with effects or a non-normal blend mode)
-    if (this->m_image.model->passthrough && this->m_image.effects.empty ()) {
+    this->m_readByOtherLayer = std::ranges::any_of (this->getScene ().getScene ().objects, [this] (const auto& object) {
+	return object->id != this->getImage ().id
+	    && std::ranges::find (object->dependencies, this->getImage ().id) != object->dependencies.end ();
+    });
+
+    // passthrough without effects has nothing to draw unless another layer reads its _rt_imageLayerComposite
+    if (this->m_image.model->passthrough && this->m_image.effects.empty () && !this->m_readByOtherLayer) {
 	return;
     }
 
@@ -1835,13 +1839,9 @@ void CImage::setup () {
     }
 
     const int colorBlendMode = this->m_image.colorBlendMode->value->getInt ();
-    const bool readByOtherLayer = std::ranges::any_of (this->getScene ().getScene ().objects, [this] (const auto& object) {
-	return object->id != this->getImage ().id
-	    && std::ranges::find (object->dependencies, this->getImage ().id) != object->dependencies.end ();
-    });
     // WE keeps the result of a layer another one reads in _a and only copies it to the screen from there,
     // drawing the last effect pass straight to the screen would leave _a one pass behind (or empty)
-    const bool copyForReaders = readByOtherLayer && this->getImage ().visible->value->getBool ();
+    const bool copyForReaders = this->m_readByOtherLayer && this->getImage ().visible->value->getBool ();
 
     // fog sends every layer through its buffer and a FOG_COMPUTED composite (sub_1401E6F50, sub_1401EBBC0)
     const bool fog = this->getScene ().hasDistanceFog () || this->getScene ().hasHeightFog ();
@@ -1874,6 +1874,9 @@ void CImage::setup () {
 	    *this, std::make_shared<FBOProvider> (this), **this->m_materials.colorBlending.material->passes.begin (),
 	    *this->m_materials.colorBlending.override, std::nullopt, std::nullopt
 	));
+	// the layer buffer already holds color and alpha, WE draws this composite with a white renderer color
+	// (sub_1401E8AA0 sets renderer +288..+300 to 1 before it)
+	this->m_passes.back ()->setNeutralColor (true);
 
 	if (fog) {
 	    this->m_fogPass = this->m_passes.back ();
@@ -2154,7 +2157,7 @@ void CImage::render () {
 	this->rebuildActivePasses ();
     }
 
-    if (this->m_image.model->passthrough && !this->m_hasActiveEffectPass) {
+    if (this->m_image.model->passthrough && !this->m_hasActiveEffectPass && !this->m_readByOtherLayer) {
 	return;
     }
 
@@ -2442,9 +2445,15 @@ void CImage::updateScreenSpacePosition () {
     }
 
     glm::mat4 mvp = this->getViewProjection () * rotModel;
+    const bool fullscreen = this->getImage ().model->fullscreen;
+
+    // WE draws fullscreen layers with an identity transform, camera movement and parallax don't reach them
+    if (fullscreen) {
+	mvp = this->getScene ().getCamera ().getFullscreenProjection ();
+    }
 
     // CScene::renderFrame() already folds disableparallax into getParallaxDisplacement()
-    if (this->getScene ().getScene ().camera.parallax.enabled->value->getBool ()) {
+    if (this->getScene ().getScene ().camera.parallax.enabled->value->getBool () && !fullscreen) {
 	const glm::vec2 offset = this->getScene ().getParallaxOffset (this->getImage ());
 	float x = offset.x;
 	float y = offset.y;

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

@@ -269,6 +269,7 @@ private:
 
     bool m_initialized = false;
     bool m_isDependency = false;
+    bool m_readByOtherLayer = false;
 
     struct {
 	struct {

+ 12 - 28
src/WallpaperEngine/Render/Objects/CMesh.cpp

@@ -431,40 +431,24 @@ void CMesh::render () {
     }
 }
 
-glm::mat4 CMesh::localTransform (const Object& object) const {
-    const glm::vec3 angles = object.groupAngles->value->getVec3 ();
-
-    // T * Rz * Ry * Rx * S, WE's row vector version of it is sub_140148A20 + sub_14016B7C0
-    glm::mat4 transform = glm::translate (glm::mat4 (1.0f), object.origin->value->getVec3 ());
-    transform = glm::rotate (transform, angles.z, glm::vec3 (0.0f, 0.0f, 1.0f));
-    transform = glm::rotate (transform, angles.y, glm::vec3 (0.0f, 1.0f, 0.0f));
-    transform = glm::rotate (transform, angles.x, glm::vec3 (1.0f, 0.0f, 0.0f));
-    return glm::scale (transform, object.groupScale->value->getVec3 ());
-}
-
 void CMesh::updateMatrices () {
-    glm::mat4 model = this->localTransform (this->m_mesh);
-    const Object* current = &this->m_mesh;
-
-    for (int depth = 0; current->parent.has_value () && depth < 64; depth++) {
-	const auto* parent = this->getScene ().getObject (current->parent.value ());
-
-	if (parent == nullptr) {
-	    break;
-	}
-
-	current = &parent->getObject ();
-	model = this->localTransform (*current) * model;
-    }
-
-    const auto& camera = this->getScene ().getCamera ();
+    const auto& scene = this->getScene ();
+    const auto& camera = scene.getCamera ();
+    const glm::mat4 model = scene.objectWorldMatrix (this->m_mesh);
 
     this->m_modelMatrix = model;
     this->m_normalMatrix = glm::mat3 (model);
-    this->m_viewProjection = camera.getPerspective () * camera.getView ();
+    this->m_viewProjection = scene.getWorldViewProjection ();
     this->m_modelViewProjection = this->m_viewProjection * model;
     this->m_modelViewProjectionInverse = glm::inverse (this->m_modelViewProjection);
-    this->m_eyePosition = camera.getEye ();
+
+    if (camera.isPerspective ()) {
+	this->m_eyePosition = camera.getEye ();
+    } else {
+	// 2D scenes keep their camera 2000 units out (end of sub_1401891A0), same as particles see it
+	const glm::vec2 eye = scene.getCameraEye ();
+	this->m_eyePosition = glm::vec3 (eye.x, -eye.y, 2000.0f);
+    }
 }
 
 const Mesh& CMesh::getMesh () const { return this->m_mesh; }

+ 2 - 3
src/WallpaperEngine/Render/Objects/CMesh.h

@@ -9,8 +9,8 @@ namespace WallpaperEngine::Render::Objects {
 using namespace WallpaperEngine::Data::Model;
 
 /**
- * A static 3D model ("model" objects) in a perspective scene. Every mesh in the .mdl has its own material and is
- * drawn straight into the scene buffer with depth testing, the bind pose only (no skinning or animation yet)
+ * A static 3D model ("model" objects), in 3D scenes or placed in a 2D one. Every mesh in the .mdl has its own material
+ * and is drawn straight into the scene buffer with depth testing, the bind pose only (no skinning or animation yet)
  */
 class CMesh final : public Scripting::ScriptableObject {
 public:
@@ -32,7 +32,6 @@ public:
 
 private:
     void updateMatrices ();
-    [[nodiscard]] glm::mat4 localTransform (const Object& object) const;
 
     const Mesh& m_mesh;
     std::vector<MaterialUniquePtr> m_materials;

+ 343 - 284
src/WallpaperEngine/Render/Objects/CParticle.cpp

@@ -24,6 +24,39 @@ using namespace WallpaperEngine::Render::Utils;
 using namespace WallpaperEngine::Data::Model;
 
 namespace {
+/** wallpaper64.exe works in a y-up particle space, the particles here live in the same space mirrored on y */
+glm::vec3 flipY (glm::vec3 value) {
+    value.y = -value.y;
+    return value;
+}
+
+/** A blend window as sub_1401C2A40 stores it, active only when it changes anything (the loader's blended tags) */
+struct BlendWindow {
+    float inStart;
+    float inScale;
+    float outEnd;
+    float outScale;
+    bool active;
+};
+
+BlendWindow makeBlendWindow (const ParticleBlendWindow& window) {
+    const float inStart = std::min (window.inStart, window.inEnd - 0.0001f);
+    const float outEnd = window.outEnd <= window.outStart + 0.0001f ? window.outStart + 0.0001f : window.outEnd;
+    const float inLength = window.inEnd - inStart;
+    const float outLength = outEnd - window.outStart;
+    const bool active = (window.inEnd > 0.01f || window.outStart < 0.99f)
+	&& (window.outStart - window.inEnd > 0.01f || inLength > 0.01f || outLength > 0.01f);
+
+    return { inStart, 1.0f / inLength, outEnd, 1.0f / outLength, active };
+}
+
+/** sub_14022A530 */
+float blendWeight (const BlendWindow& window, const ParticleInstance& p) {
+    const float life = p.age / p.lifetime;
+    return std::clamp ((window.outEnd - life) * window.outScale, 0.0f, 1.0f)
+	* std::clamp ((life - window.inStart) * window.inScale, 0.0f, 1.0f);
+}
+
 /** sub_1401D15A0 cases 0xB/0xC: a color moved in HSV by the override color's distance to the reference */
 glm::vec3 shiftColor (const glm::vec3& rgb, const glm::vec3& shift) {
     const glm::vec3 hsv = WallpaperEngine::Maths::rgbToHsv (rgb);
@@ -143,12 +176,6 @@ void CParticle::setup () {
 	return;
     }
 
-    m_lastScreenWidth = getScene ().getCamera ().getWidth ();
-    m_lastScreenHeight = getScene ().getCamera ().getHeight ();
-    if (m_parent == nullptr) {
-	m_transformedOrigin = this->sceneOrigin ();
-    }
-
     if (m_particle.material && m_particle.material->material && !m_particle.material->material->passes.empty ()) {
 	auto& firstPass = *m_particle.material->material->passes.begin ();
 
@@ -233,8 +260,6 @@ void CParticle::render () {
 	return;
     }
 
-    syncTransformedOrigin ();
-
     // stop() drops every particle, and a later play() starts emitting from scratch
     const auto playback = this->getPlayback ();
     if (playback == Playback::Stopped) {
@@ -288,6 +313,12 @@ void CParticle::draw (const glm::mat4& base) {
     const glm::mat4 top = m_worldSpace ? base : base * placement;
     m_modelMatrix = m_worldSpace ? glm::mat4 (1.0f) : top;
 
+    m_drawFlipY = !getScene ().getCamera ().isPerspective ();
+
+    if (m_drawFlipY) {
+	m_modelMatrix = m_modelMatrix * glm::scale (glm::mat4 (1.0f), glm::vec3 (1.0f, -1.0f, 1.0f));
+    }
+
     if (m_particleCount > 0 && m_particle.material) {
 	if (m_useRopeRenderer) {
 	    renderRope ();
@@ -338,38 +369,6 @@ bool CParticle::isPlaying () const {
     return playback == Playback::Playing || (playback == Playback::Paused && m_particleCount > 0);
 }
 
-glm::vec3 CParticle::sceneOrigin () const {
-    glm::vec3 origin = m_particle.origin->value->getVec3 ();
-
-    // 3D scenes place everything in world units as they are
-    if (getScene ().getCamera ().isPerspective ()) {
-	return origin;
-    }
-
-    // screen space (0,0 top-left) to the centered space of the ortho(-width/2, width/2, -height/2, height/2) projection
-    const float screenWidth = static_cast<float> (getScene ().getWidth ());
-    const float screenHeight = static_cast<float> (getScene ().getHeight ());
-
-    origin.x -= screenWidth / 2.0f;
-    origin.y = screenHeight / 2.0f - origin.y;
-    return origin;
-}
-
-// scripts can move the system every frame (e.g. an origin that follows the cursor)
-void CParticle::syncTransformedOrigin () {
-    const float screenWidth = static_cast<float> (getScene ().getWidth ());
-    const float screenHeight = static_cast<float> (getScene ().getHeight ());
-    const glm::vec3 origin = this->sceneOrigin ();
-
-    if (origin == m_transformedOrigin && screenWidth == m_lastScreenWidth && screenHeight == m_lastScreenHeight) {
-	return;
-    }
-
-    m_transformedOrigin = origin;
-    m_lastScreenWidth = screenWidth;
-    m_lastScreenHeight = screenHeight;
-}
-
 void CParticle::update (float dt) {
     // children share the instance override and scale their own time
     const float childDt = dt;
@@ -570,8 +569,13 @@ void CParticle::refreshColorOverride () {
 	&& (distance.x >= 0.0035294117f || distance.y >= 0.0035294117f || distance.z >= 0.0035294117f)
 	&& (m_parent == nullptr || m_parent->m_colorOverride.active);
 
-    // a file without a color initializer, or any scene before version 5, multiplies by the color instead
-    m_colorOverride.tint = m_colorOverride.active && m_particle.overrideColorTints ? color : glm::vec3 (1.0f);
+    // sub_1401D15A0: the spawn color starts at the brightness override (HDR scene rendering only, not with particle
+    // flag 8). A file without a color initializer, or any scene before version 5, also multiplies by the color
+    const float brightness = getScene ().isHDR () && (m_particle.flags & 8) == 0
+	? instanceOverride.brightness->value->getFloat ()
+	: 1.0f;
+    m_colorOverride.tint
+	= (m_colorOverride.active && m_particle.overrideColorTints ? color : glm::vec3 (1.0f)) * brightness;
 
     if (m_colorOverride.active) {
 	m_colorOverride.hsv = WallpaperEngine::Maths::rgbToHsv (color);
@@ -627,23 +631,23 @@ glm::mat4 CParticle::eventPlacement (const ParticleChild& child, const glm::vec3
 }
 
 glm::mat4 CParticle::objectMatrix () const {
-    glm::vec3 scale = m_particle.scale->value->getVec3 ();
-    glm::vec3 angles = m_particle.angles->value->getVec3 ();
-
-    glm::mat4 matrix = glm::translate (glm::mat4 (1.0f), m_transformedOrigin);
+    // WE's world matrix, parent chain included: particles parented to a layer or group move and scale with it
+    glm::mat4 matrix = getScene ().objectWorldMatrix (m_particle);
+
+    // 2D scenes: WE's space (y up from the bottom left) to the centered y down space of the ortho projection here
+    if (!getScene ().getCamera ().isPerspective ()) {
+	const glm::mat4 flipY = glm::scale (glm::mat4 (1.0f), glm::vec3 (1.0f, -1.0f, 1.0f));
+	const glm::vec3 center (getScene ().getWidth () / 2.0f, getScene ().getHeight () / 2.0f, 0.0f);
+	matrix = flipY * glm::translate (glm::mat4 (1.0f), -center) * matrix * flipY;
+    }
 
     // CScene::renderFrame() already folds disableparallax into getParallaxDisplacement()
     if (getScene ().getScene ().camera.parallax.enabled->value->getBool ()) {
 	const glm::vec2 offset = getScene ().getParallaxOffset (m_particle);
-	matrix = glm::translate (matrix, glm::vec3 (offset.x, offset.y, 0.0f));
+	matrix = glm::translate (glm::mat4 (1.0f), glm::vec3 (offset.x, offset.y, 0.0f)) * matrix;
     }
 
-    // Negate X and Z rotations to account for Y-flipped coordinate system, 3D scenes are not flipped
-    const float flip = getScene ().getCamera ().isPerspective () ? 1.0f : -1.0f;
-    matrix = glm::rotate (matrix, flip * angles.z, glm::vec3 (0, 0, 1));
-    matrix = glm::rotate (matrix, angles.y, glm::vec3 (0, 1, 0));
-    matrix = glm::rotate (matrix, flip * angles.x, glm::vec3 (1, 0, 0));
-    return glm::scale (matrix, scale);
+    return matrix;
 }
 
 void CParticle::updateFrame () {
@@ -723,19 +727,15 @@ void CParticle::updateControlPoints () {
 	    continue;
 	}
 
-	// sub_14022A070: offsets are local unless flag 2 says world, and the point lives in the system's space.
-	// Control point 0 of a world space system always counts as local
+	// sub_14022A070: the point's local matrix (its offset, or what the instance override made of it) is local
+	// unless flag 2 says world, and the point lives in the system's space. Control point 0 of a world space system
+	// always counts as local
+	const glm::mat4 local = localControlPointMatrix (i);
 	glm::mat4 matrix;
 	if (m_worldSpace) {
-	    if (cp.worldSpace && i != 0) {
-		continue;
-	    }
-	    matrix = m_frame * glm::translate (glm::mat4 (1.0f), cp.offset);
+	    matrix = cp.worldSpace && i != 0 ? local : m_frame * local;
 	} else {
-	    if (!cp.worldSpace) {
-		continue;
-	    }
-	    matrix = toLocal * glm::translate (glm::mat4 (1.0f), cp.offset);
+	    matrix = cp.worldSpace ? toLocal * local : local;
 	}
 
 	cp.orientation = glm::mat3 (matrix);
@@ -1418,8 +1418,8 @@ InitializerFunc CParticle::createColorRandomInitializer (const ColorRandomInitia
     // wallpaper64.exe sub_14023B340 case 3, one random for all three channels. An active override color moves
     // both ends in HSV first (sub_1401D15A0 case 0xB)
     return [this, minValue, maxValue, exponentValue] (ParticleInstance& p) {
-	glm::vec3 min = minValue->getVec3 ();
-	glm::vec3 max = maxValue->getVec3 ();
+	glm::vec3 min = minValue->getVec3 () / 255.0f;
+	glm::vec3 max = maxValue->getVec3 () / 255.0f;
 	if (m_colorOverride.active) {
 	    min = shiftColor (min, m_colorOverride.shift);
 	    max = shiftColor (max, m_colorOverride.shift);
@@ -1551,8 +1551,9 @@ InitializerFunc CParticle::createTurbulentVelocityRandomInitializer (const Turbu
 	);
 	const float phaseMin = phaseMinVal->getFloat ();
 	const float phaseRange = (phaseMaxVal->getFloat () - phaseMin) * audio;
-	// WE adds the scene's camera path fade here, 0 outside of fades, so there is no time term
-	const float phase = phaseMin + WallpaperEngine::Maths::randomFloat (m_rng, 0.0f, 1.0f) * phaseRange;
+	// sub_14023B340 case 9: the renderer's scene clock is added to the phase
+	const float phase = WallpaperEngine::Maths::randomFloat (m_rng, 0.0f, 1.0f) * phaseRange + phaseMin
+	    + getScene ().getSceneClock ();
 	const float timeScale = timeScaleVal->getFloat () * m_particle.instanceOverride.rate->value->getFloat ();
 
 	const float angle = simplexNoise1D (phase * timeScale) * glm::pi<float> () * scaleVal->getFloat ()
@@ -2030,13 +2031,17 @@ OperatorFunc CParticle::createColorChangeOperator (const ColorChangeOperator& op
 }
 
 OperatorFunc CParticle::createTurbulenceOperator (const TurbulenceOperator& op) {
-    DynamicValue* scaleValue = op.scale->value.get ();
-    DynamicValue* speedMinValue = op.speedMin->value.get ();
-    DynamicValue* speedMaxValue = op.speedMax->value.get ();
-    DynamicValue* maskValue = op.mask->value.get ();
+    DynamicValue* scaleValue = op.scale ? op.scale->value.get () : nullptr;
+    DynamicValue* speedMinValue = op.speedMin ? op.speedMin->value.get () : nullptr;
+    DynamicValue* speedMaxValue = op.speedMax ? op.speedMax->value.get () : nullptr;
+    DynamicValue* timeScaleValue = op.timeScale ? op.timeScale->value.get () : nullptr;
+    DynamicValue* maskValue = op.mask ? op.mask->value.get () : nullptr;
     DynamicValue* phaseMinValue = op.phaseMin->value.get ();
     DynamicValue* phaseMaxValue = op.phaseMax->value.get ();
-    DynamicValue* speedOverride = m_particle.instanceOverride.speed->value.get ();
+    // the loader binds speedmin/speedmax to the speed override unless particle flag 0x10, timescale to the rate
+    DynamicValue* speedOverride
+	= (m_particle.flags & 0x10) == 0 ? m_particle.instanceOverride.speed->value.get () : nullptr;
+    DynamicValue* rateOverride = m_particle.instanceOverride.rate->value.get ();
     DynamicValue* audioModeValue = op.audioProcessingMode->value.get ();
     DynamicValue* audioBoundsValue = op.audioProcessingBounds->value.get ();
     DynamicValue* audioExponentValue = op.audioProcessingExponent->value.get ();
@@ -2045,235 +2050,282 @@ OperatorFunc CParticle::createTurbulenceOperator (const TurbulenceOperator& op)
 
     this->m_usesParticleSeed = true;
 
-    // same formula as wallpaper64.exe (sub_1401C9F60), phasemin is parsed there but never used
-    return [this, scaleValue, speedMinValue, speedMaxValue, maskValue, phaseMinValue, phaseMaxValue,
-	    speedOverride, audioModeValue, audioBoundsValue, audioExponentValue, audioStartValue, audioEndValue] (
-	       std::vector<ParticleInstance>& particles, uint32_t count, const std::vector<ControlPointData>&,
-	       float, float dt
+    // sub_14023FBC0 case 14, defaults from sub_1401BEB80. phasemin is loaded but never read
+    return [this, scaleValue, speedMinValue, speedMaxValue, timeScaleValue, maskValue, phaseMinValue, phaseMaxValue,
+	    speedOverride, rateOverride, audioModeValue, audioBoundsValue, audioExponentValue, audioStartValue,
+	    audioEndValue] (
+	       std::vector<ParticleInstance>& particles, uint32_t count, const std::vector<ControlPointData>&, float,
+	       float dt
 	   ) {
-	const float audio = sampleAudio (
-	    audioModeValue->getInt (), audioBoundsValue->getVec2 (), audioExponentValue->getFloat (),
-	    audioStartValue->getInt (), audioEndValue->getInt ()
-	);
-	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 scale = scaleValue->getFloat ();
-	const glm::vec3 mask = maskValue->getVec3 ();
-
-	for (size_t i = 0; i < count; ++i) {
-	    ParticleInstance& p = particles[i];
-	    if (!p.alive) {
-		continue;
-	    }
+	const bool flat = !getScene ().getCamera ().isPerspective ();
+	const glm::vec3 mask
+	    = maskValue != nullptr ? maskValue->getVec3 () : (flat ? glm::vec3 (1.0f, 1.0f, 0.0f) : glm::vec3 (1.0f));
+	if (mask.x == 0.0f && mask.y == 0.0f && mask.z == 0.0f) {
+	    return;
+	}
 
-	    // WE adds the scene's camera path fade times timescale here, 0 outside of fades, so timescale does nothing
-	    const float phase = p.seed * phaseRange;
+	const float audio = audioModeValue->getInt () != 0
+	    ? sampleAudio (
+		  audioModeValue->getInt (), audioBoundsValue->getVec2 (), audioExponentValue->getFloat (),
+		  audioStartValue->getInt (), audioEndValue->getInt ()
+	      )
+	    : 1.0f;
+	const float speed = speedOverride != nullptr ? speedOverride->getFloat () : 1.0f;
+	const float speedMin
+	    = (speedMinValue != nullptr ? speedMinValue->getFloat () : (flat ? 500.0f : 1.0f)) * speed;
+	const float speedMax
+	    = (speedMaxValue != nullptr ? speedMaxValue->getFloat () : (flat ? 1000.0f : 5.0f)) * speed;
+	const float speedRange = (speedMax - speedMin) * audio;
+	const float scaledMin = speedMin * audio;
+	const float phaseRange = phaseMaxValue->getFloat () - phaseMinValue->getFloat ();
+	const float scale = scaleValue != nullptr ? scaleValue->getFloat () : (flat ? 0.01f : 0.5f);
+	const float timeScale
+	    = (timeScaleValue != nullptr ? timeScaleValue->getFloat () : (flat ? 20.0f : 1.0f)) * rateOverride->getFloat ();
+	// the renderer's scene clock moves the noise field
+	const float time = getScene ().getSceneClock () * timeScale;
+	const glm::vec3 step = mask * frameScaledDelta (dt);
+
+	const auto push = [&] (ParticleInstance& p) {
+	    const float phase = p.seed * phaseRange + time;
 	    // WE's particle space is y-up
-	    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;
+	    const float x = scale * (phase + p.position.x);
+	    const float y = scale * (phase - p.position.y);
+	    const float z = scale * (phase + p.position.z);
+	    const float strength = p.seed * speedRange + scaledMin;
 
 	    glm::vec3 delta (0.0f);
 
 	    if (mask.x != 0.0f) {
-		delta.x = simplexNoise3D (x, y, z) * mask.x;
+		delta.x = simplexNoise3D (x, y, z);
 	    }
 	    if (mask.y != 0.0f) {
-		delta.y = simplexNoise3D (z, x, y) * mask.y;
+		delta.y = simplexNoise3D (z, x, y);
 	    }
 	    if (mask.z != 0.0f) {
-		delta.z = simplexNoise3D (y, z, x) * mask.z;
+		delta.z = simplexNoise3D (y, z, x);
 	    }
 
-	    p.velocity += glm::vec3 (delta.x, -delta.y, delta.z) * step;
+	    delta *= step * strength;
+	    p.velocity += glm::vec3 (delta.x, -delta.y, delta.z);
+	};
+
+	// every pool slot, dead ones included
+	for (uint32_t i = 0; i < count; i++) {
+	    if (particles[i].alive) {
+		push (particles[i]);
+	    }
+	}
+	for (uint32_t slot = 0; slot < m_ghostUsed.size (); slot++) {
+	    if (m_ghostUsed[slot]) {
+		push (m_ghosts[slot]);
+	    }
 	}
     };
 }
 
 OperatorFunc CParticle::createVortexOperator (const VortexOperator& op) {
-    int controlPoint = op.controlPoint;
-    int flags = op.flags;
+    const int controlPoint = op.controlPoint;
+    const bool v2 = op.v2;
+    const bool infiniteAxis = (op.flags & 1) != 0;
+    // vortex (v1) reads neither centerforce nor the ring
+    const bool useCenterForce = v2 && (op.flags & 2) != 0;
+    const bool ringShape = v2 && (op.flags & 4) != 0;
+    const BlendWindow blend = makeBlendWindow (op.blend);
     DynamicValue* axisValue = op.axis->value.get ();
     DynamicValue* offsetValue = op.offset->value.get ();
-    DynamicValue* distanceInnerValue = op.distanceInner->value.get ();
-    DynamicValue* distanceOuterValue = op.distanceOuter->value.get ();
-    DynamicValue* speedInnerValue = op.speedInner->value.get ();
+    DynamicValue* distanceInnerValue = op.distanceInner ? op.distanceInner->value.get () : nullptr;
+    DynamicValue* distanceOuterValue = op.distanceOuter ? op.distanceOuter->value.get () : nullptr;
+    DynamicValue* speedInnerValue = op.speedInner ? op.speedInner->value.get () : nullptr;
     DynamicValue* speedOuterValue = op.speedOuter->value.get ();
     DynamicValue* centerForceValue = op.centerForce->value.get ();
-    DynamicValue* ringRadiusValue = op.ringRadius->value.get ();
-    DynamicValue* ringWidthValue = op.ringWidth->value.get ();
-    DynamicValue* ringPullDistanceValue = op.ringPullDistance->value.get ();
-    DynamicValue* ringPullForceValue = op.ringPullForce->value.get ();
+    DynamicValue* ringRadiusValue = op.ringRadius ? op.ringRadius->value.get () : nullptr;
+    DynamicValue* ringWidthValue = op.ringWidth ? op.ringWidth->value.get () : nullptr;
+    DynamicValue* ringPullDistanceValue = op.ringPullDistance ? op.ringPullDistance->value.get () : nullptr;
+    DynamicValue* ringPullForceValue = op.ringPullForce ? op.ringPullForce->value.get () : nullptr;
     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 ();
-
-    bool infiniteAxis = (flags & 1) != 0;
-    bool maintainDistance = (flags & 2) != 0;
-    bool ringShape = (flags & 4) != 0;
+    DynamicValue* speedOverride
+	= (m_particle.flags & 0x10) == 0 ? m_particle.instanceOverride.speed->value.get () : nullptr;
 
-    return [controlPoint, axisValue, offsetValue, distanceInnerValue, distanceOuterValue, speedInnerValue,
-	    speedOuterValue, centerForceValue, ringRadiusValue, ringWidthValue, ringPullDistanceValue,
-	    ringPullForceValue, audioModeValue, audioBoundsValue, audioExponentValue, audioStartValue, audioEndValue,
-	    infiniteAxis, maintainDistance, ringShape, speedOverride, this] (
+    // sub_14023FBC0 case 15 (vortex), 16 and its blended variant 37 (vortex_v2). Loaders in sub_1401C5490, defaults
+    // from sub_1401BEF00 / sub_1401BF2D0. Worked out in WE's y-up space
+    return [this, controlPoint, v2, infiniteAxis, useCenterForce, ringShape, blend, axisValue, offsetValue,
+	    distanceInnerValue, distanceOuterValue, speedInnerValue, speedOuterValue, centerForceValue, ringRadiusValue,
+	    ringWidthValue, ringPullDistanceValue, ringPullForceValue, audioModeValue, audioBoundsValue,
+	    audioExponentValue, audioStartValue, audioEndValue, speedOverride] (
 	       std::vector<ParticleInstance>& particles, uint32_t count,
 	       const std::vector<ControlPointData>& controlPoints, float, float dt
 	   ) {
-	const float audioResponse = sampleAudio (
-	    audioModeValue->getInt (), audioBoundsValue->getVec2 (), audioExponentValue->getFloat (),
-	    audioStartValue->getInt (), audioEndValue->getInt ()
-	);
-
-	if (audioResponse <= 0.0f) {
-	    return;
-	}
+	const bool flat = !getScene ().getCamera ().isPerspective ();
+	const float audio = audioModeValue->getInt () != 0
+	    ? sampleAudio (
+		  audioModeValue->getInt (), audioBoundsValue->getVec2 (), audioExponentValue->getFloat (),
+		  audioStartValue->getInt (), audioEndValue->getInt ()
+	      )
+	    : 1.0f;
+	const float speedScale = speedOverride != nullptr ? speedOverride->getFloat () : 1.0f;
+	const float speedInner
+	    = (speedInnerValue != nullptr ? speedInnerValue->getFloat () : (flat ? 2500.0f : 1.0f)) * speedScale;
+	const float speedOuter = speedOuterValue->getFloat () * speedScale;
+	const float scaledDt = frameScaledDelta (dt);
+	const float innerSpeed = speedInner * audio * scaledDt;
+	const float speedRange = (speedOuter - speedInner) * audio * scaledDt;
 
+	// the loader normalizes the axis, (0, 0, 1) when it is too short
 	glm::vec3 axis = axisValue->getVec3 ();
-	glm::vec3 offset = offsetValue->getVec3 ();
-	float distanceInner = distanceInnerValue->getFloat ();
-	float distanceOuter = distanceOuterValue->getFloat ();
-	float speedInner = speedInnerValue->getFloat ();
-	float speedOuter = speedOuterValue->getFloat ();
-	float centerForce = centerForceValue->getFloat ();
-	float ringRadius = ringRadiusValue->getFloat ();
-	float ringWidth = ringWidthValue->getFloat ();
-	float ringPullDistance = ringPullDistanceValue->getFloat ();
-	float ringPullForce = ringPullForceValue->getFloat ();
-
-	speedInner *= audioResponse;
-	speedOuter *= audioResponse;
-
-	glm::vec3 center = glm::vec3 (0.0f);
-	if (controlPoint >= 0 && controlPoint < static_cast<int> (controlPoints.size ())) {
-	    center = controlPoints[controlPoint].position + offset;
-	} else {
-	    center = offset;
-	}
+	axis = glm::length (axis) >= 0.001f ? glm::normalize (axis) : glm::vec3 (0.0f, 0.0f, 1.0f);
 
-	if (glm::length (axis) > 0.0f) {
-	    axis = glm::normalize (axis);
+	const ControlPointData& point = controlPoints[controlPoint];
+	glm::vec3 center = controlPointWE (controlPoint);
+	if (v2) {
+	    // turned (and scaled) by the control point's matrix, not normalized again
+	    axis = flipY (point.orientation * flipY (axis));
 	} else {
-	    axis = glm::vec3 (0.0f, 0.0f, 1.0f);
+	    center += offsetValue->getVec3 ();
 	}
 
-	for (uint32_t i = 0; i < count; i++) {
-	    auto& p = particles[i];
-	    if (!p.alive) {
-		continue;
-	    }
-
-	    glm::vec3 toParticle = p.position - center;
-
-	    // infiniteAxis: project onto the plane perpendicular to axis (cylinder shape);
-	    // otherwise use full 3D distance (sphere shape)
-	    float axialDistance = 0.0f;
-	    glm::vec3 radialVector = toParticle;
-	    if (infiniteAxis) {
-		axialDistance = glm::dot (toParticle, axis);
-		radialVector = toParticle - axis * axialDistance;
+	float start;
+	float rangeScale;
+	if (ringShape) {
+	    const float pullDistance
+		= ringPullDistanceValue != nullptr ? ringPullDistanceValue->getFloat () : (flat ? 50.0f : 0.25f);
+	    start = ringWidthValue != nullptr ? ringWidthValue->getFloat () : (flat ? 50.0f : 0.2f);
+	    rangeScale = pullDistance != 0.0f ? 1.0f / pullDistance : 1.0f;
+	} else {
+	    const float distanceOuter
+		= distanceOuterValue != nullptr ? distanceOuterValue->getFloat () : (flat ? 650.0f : 2.0f);
+	    start = distanceInnerValue != nullptr ? distanceInnerValue->getFloat () : (flat ? 500.0f : 1.0f);
+	    rangeScale = start != distanceOuter ? 1.0f / (distanceOuter - start) : 1.0f;
+	}
+	const float ringRadius = ringRadiusValue != nullptr ? ringRadiusValue->getFloat () : (flat ? 300.0f : 1.0f);
+	const float ringPull
+	    = (ringPullForceValue != nullptr ? ringPullForceValue->getFloat () : (flat ? 10.0f : 0.05f)) * dt;
+	const float centerPull = useCenterForce ? centerForceValue->getFloat () / dt : 0.0f;
+
+	const auto spin = [&] (ParticleInstance& p) {
+	    const glm::vec3 position = flipY (p.position);
+	    const glm::vec3 velocity = flipY (p.velocity);
+	    const glm::vec3 toParticle = position - center;
+	    const glm::vec3 along = infiniteAxis ? axis * glm::dot (toParticle, axis) : glm::vec3 (0.0f);
+	    const glm::vec3 radial = toParticle - along;
+	    const float distance = glm::length (radial);
+	    // WE's rsqrt turns a particle on the axis into NaN, leave it alone instead
+	    if (distance == 0.0f) {
+		return;
 	    }
+	    const glm::vec3 direction = radial / distance;
+	    const float weight = v2 && blend.active ? blendWeight (blend, p) : 1.0f;
 
-	    float distance = glm::length (radialVector);
-
-	    glm::vec3 tangent = glm::cross (axis, radialVector);
-	    if (glm::length (tangent) > 0.001f) {
-		tangent = glm::normalize (tangent);
+	    float t;
+	    if (ringShape) {
+		t = std::clamp ((std::abs (ringRadius - distance) - start) * rangeScale, 0.0f, 1.0f);
 	    } else {
-		continue; // particle is on the axis
+		t = std::clamp ((distance - start) * rangeScale, 0.0f, 1.0f);
 	    }
 
-	    float speed = 0.0f;
-	    glm::vec3 radialForce = glm::vec3 (0.0f);
+	    glm::vec3 change = glm::cross (direction, axis) * ((t * speedRange + innerSpeed) * weight);
 
-	    if (ringShape) {
-		// Ring mode: hollow center with ring-shaped influence zone
-		float ringInner = ringRadius - ringWidth * 0.5f;
-		float ringOuter = ringRadius + ringWidth * 0.5f;
-
-		if (distance < ringInner) {
-		    // Inside the ring's hollow center - no spin, but may be pulled outward
-		    speed = 0.0f;
-		} else if (distance <= ringOuter) {
-		    // Inside the ring - full effect
-		    float t = (distance - ringInner) / ringWidth;
-		    speed = glm::mix (speedInner, speedOuter, t);
-		} else if (distance <= ringOuter + ringPullDistance) {
-		    // Outside ring but within pull distance - attract toward ring
-		    float pullT = (distance - ringOuter) / ringPullDistance;
-		    speed = speedOuter * (1.0f - pullT);
-		    if (distance > 0.001f) {
-			glm::vec3 towardRing = -glm::normalize (radialVector);
-			radialForce = towardRing * ringPullForce * pullT;
-		    }
-		} else {
-		    // Too far from ring - no effect
-		    speed = 0.0f;
-		}
-	    } else {
-		// Standard vortex mode
-		float disMid = distanceOuter - distanceInner + 0.1f;
-
-		if (disMid < 0 || distance < distanceInner) {
-		    speed = speedInner;
-		} else if (distance > distanceOuter) {
-		    speed = speedOuter;
-		} else {
-		    float t = (distance - distanceInner) / disMid;
-		    speed = glm::mix (speedInner, speedOuter, t);
+	    if (v2) {
+		// keeps the particle at its distance from the axis over the coming move, and pulls it onto the ring
+		const glm::vec3 next = velocity * dt + position - center - along;
+		const float nextDistance = glm::length (next);
+		float pull = nextDistance > 0.0f ? (distance / nextDistance - 1.0f) * centerPull : 0.0f;
+		if (ringShape) {
+		    const float falloff = 1.0f - t;
+		    pull += (falloff != 1.0f ? std::copysign (falloff, ringRadius - distance) : 0.0f) * ringPull;
 		}
+		change += next * (pull * weight);
 	    }
 
-	    p.velocity += tangent * speed * dt * speedOverride->getFloat ();
-
-	    p.velocity += radialForce * dt * speedOverride->getFloat ();
+	    p.velocity += flipY (change);
+	};
 
-	    if (maintainDistance && distance > 0.001f) {
-		glm::vec3 towardCenter = -glm::normalize (radialVector);
-		p.velocity += towardCenter * centerForce * dt * speedOverride->getFloat ();
+	for (uint32_t i = 0; i < count; i++) {
+	    if (particles[i].alive) {
+		spin (particles[i]);
+	    }
+	}
+	for (uint32_t slot = 0; slot < m_ghostUsed.size (); slot++) {
+	    if (m_ghostUsed[slot]) {
+		spin (m_ghosts[slot]);
 	    }
 	}
     };
 }
 
 OperatorFunc CParticle::createControlPointAttractOperator (const ControlPointAttractOperator& op) {
-    int controlPoint = op.controlPoint;
-    DynamicValue* originValue = op.origin->value.get ();
-    DynamicValue* scaleValue = op.scale->value.get ();
-    DynamicValue* thresholdValue = op.threshold->value.get ();
-    DynamicValue* speedOverride = m_particle.instanceOverride.speed->value.get ();
+    const int controlPoint = op.controlPoint;
+    const bool deleteNear = (op.flags & 1) != 0;
+    const bool clampToPoint = (op.flags & 2) != 0;
+    DynamicValue* scaleValue = op.scale ? op.scale->value.get () : nullptr;
+    DynamicValue* thresholdValue = op.threshold ? op.threshold->value.get () : nullptr;
+    DynamicValue* deleteThresholdValue = op.deleteThreshold ? op.deleteThreshold->value.get () : nullptr;
+    DynamicValue* speedOverride
+	= (m_particle.flags & 0x10) == 0 ? m_particle.instanceOverride.speed->value.get () : nullptr;
 
-    return [controlPoint, originValue, scaleValue, thresholdValue, speedOverride] (
+    if (deleteNear) {
+	m_tracksPreviousPosition = true;
+    }
+
+    // sub_14023FBC0 case 10, defaults from sub_1401BDEE0
+    return [this, controlPoint, deleteNear, clampToPoint, scaleValue, thresholdValue, deleteThresholdValue,
+	    speedOverride] (
 	       std::vector<ParticleInstance>& particles, uint32_t count,
-	       const std::vector<ControlPointData>& controlPoints, float currentTime, float dt
+	       const std::vector<ControlPointData>& controlPoints, float, float dt
 	   ) {
-	glm::vec3 origin = originValue->getVec3 ();
-	float scale = scaleValue->getFloat ();
-	float threshold = thresholdValue->getFloat () / 2.0f;
+	const bool flat = !getScene ().getCamera ().isPerspective ();
+	const float scale = (scaleValue != nullptr ? scaleValue->getFloat () : (flat ? 512.0f : 20.0f))
+	    * (speedOverride != nullptr ? speedOverride->getFloat () : 1.0f);
+	const float threshold = thresholdValue != nullptr ? thresholdValue->getFloat () : (flat ? 512.0f : 5.0f);
+	const float force = scale * frameScaledDelta (dt);
+	const glm::vec3 center = controlPoints[controlPoint].position;
+
+	const auto attract = [&] (ParticleInstance& p) {
+	    const glm::vec3 toCenter = center - p.position;
+	    const float distance = glm::length (toCenter);
+	    if (!(distance < threshold) || !(distance > std::numeric_limits<float>::min ())) {
+		return;
+	    }
+	    float pull = (1.0f - distance / threshold) * force;
+	    if (clampToPoint && distance < pull) {
+		pull = distance;
+	    }
+	    p.velocity += toCenter * (pull / distance);
+	};
 
-	if (controlPoint < 0 || controlPoint >= static_cast<int> (controlPoints.size ())) {
-	    return;
+	for (uint32_t i = 0; i < count; i++) {
+	    if (particles[i].alive) {
+		attract (particles[i]);
+	    }
+	}
+	for (uint32_t slot = 0; slot < m_ghostUsed.size (); slot++) {
+	    if (m_ghostUsed[slot]) {
+		attract (m_ghosts[slot]);
+	    }
 	}
 
-	glm::vec3 center = controlPoints[controlPoint].position + origin;
+	if (!deleteNear) {
+	    return;
+	}
 
+	// sub_14022A150: a particle whose step since the operators started came within deletethreshold dies
+	const float deleteThreshold
+	    = deleteThresholdValue != nullptr ? deleteThresholdValue->getFloat () : (flat ? 15.0f : 0.5f);
 	for (uint32_t i = 0; i < count; i++) {
 	    auto& p = particles[i];
 	    if (!p.alive) {
 		continue;
 	    }
-
-	    glm::vec3 toCenter = center - p.position;
-	    float distance = glm::length (toCenter);
-
-	    if (distance > 0.001f && distance < threshold) {
-		glm::vec3 direction = toCenter / distance;
-		glm::vec3 forceVec = direction * scale * dt;
-		p.velocity += forceVec * speedOverride->getFloat ();
+	    const glm::vec3 step = p.position - p.previousPosition;
+	    const float length = glm::length (step);
+	    const glm::vec3 direction = length > 0.0f ? step / length : step;
+	    const float along = std::clamp (glm::dot (center - p.previousPosition, direction), 0.0f, length);
+	    const glm::vec3 closest = center - (p.previousPosition + direction * along);
+	    if (!(deleteThreshold * deleteThreshold < glm::dot (closest, closest))) {
+		p.age = p.lifetime;
 	    }
 	}
     };
@@ -2424,39 +2476,6 @@ OperatorFunc CParticle::createOscillatePositionOperator (const OscillatePosition
 // ========== 2.7 COMPONENTS ==========
 
 namespace {
-/** wallpaper64.exe works in a y-up particle space, the particles here live in the same space mirrored on y */
-glm::vec3 flipY (glm::vec3 value) {
-    value.y = -value.y;
-    return value;
-}
-
-/** A blend window as sub_1401C2A40 stores it, active only when it changes anything (the loader's blended tags) */
-struct BlendWindow {
-    float inStart;
-    float inScale;
-    float outEnd;
-    float outScale;
-    bool active;
-};
-
-BlendWindow makeBlendWindow (const ParticleBlendWindow& window) {
-    const float inStart = std::min (window.inStart, window.inEnd - 0.0001f);
-    const float outEnd = window.outEnd <= window.outStart + 0.0001f ? window.outStart + 0.0001f : window.outEnd;
-    const float inLength = window.inEnd - inStart;
-    const float outLength = outEnd - window.outStart;
-    const bool active = (window.inEnd > 0.01f || window.outStart < 0.99f)
-	&& (window.outStart - window.inEnd > 0.01f || inLength > 0.01f || outLength > 0.01f);
-
-    return { inStart, 1.0f / inLength, outEnd, 1.0f / outLength, active };
-}
-
-/** sub_14022A530 */
-float blendWeight (const BlendWindow& window, const ParticleInstance& p) {
-    const float life = p.age / p.lifetime;
-    return std::clamp ((window.outEnd - life) * window.outScale, 0.0f, 1.0f)
-	* std::clamp ((life - window.inStart) * window.inScale, 0.0f, 1.0f);
-}
-
 /** 0.0 up to the zero range wallpaper64.exe replaces with FLT_EPSILON */
 glm::vec3 remapRange (const glm::vec3& min, const glm::vec3& max) {
     glm::vec3 range = max - min;
@@ -2565,6 +2584,43 @@ float dayFraction () {
 
 glm::vec3 CParticle::controlPointWE (int index) const { return flipY (m_controlPoints[index].position); }
 
+glm::vec3 CParticle::drawVector (const glm::vec3& value) const { return m_drawFlipY ? flipY (value) : value; }
+
+glm::mat4 CParticle::localControlPointMatrix (size_t index) const {
+    const auto& cp = m_controlPoints[index];
+    glm::mat4 local = glm::translate (glm::mat4 (1.0f), cp.offset);
+
+    // sub_14022BD40, skipped for points with flags 0x10005 like WE
+    if (cp.followParent || index >= m_particle.instanceOverride.controlPoints.size ()) {
+	return local;
+    }
+
+    if (const auto& angles = m_particle.instanceOverride.controlPointAngles[index]; angles) {
+	// Rz * Ry * Rx in WE's y-up space, radians
+	const glm::vec3 angle = angles->value->getVec3 ();
+	const float cx = std::cos (angle.x), sx = std::sin (angle.x);
+	const float cy = std::cos (angle.y), sy = std::sin (angle.y);
+	const float cz = std::cos (angle.z), sz = std::sin (angle.z);
+	const glm::mat3 rotation (
+	    cy * cz, cy * sz, -sy,
+	    sy * cz * sx - cx * sz, sy * sz * sx + cx * cz, sx * cy,
+	    cx * cz * sy + sx * sz, cx * sz * sy - sx * cz, cx * cy
+	);
+	// the same rotation in the y mirrored space used here
+	const glm::mat3 mirror (1.0f, 0.0f, 0.0f, 0.0f, -1.0f, 0.0f, 0.0f, 0.0f, 1.0f);
+	const glm::mat3 mirrored = mirror * rotation * mirror;
+	local[0] = glm::vec4 (mirrored[0], 0.0f);
+	local[1] = glm::vec4 (mirrored[1], 0.0f);
+	local[2] = glm::vec4 (mirrored[2], 0.0f);
+    }
+
+    if (const auto& position = m_particle.instanceOverride.controlPoints[index]; position) {
+	local[3] = glm::vec4 (flipY (position->value->getVec3 ()), 1.0f);
+    }
+
+    return local;
+}
+
 float CParticle::frameScaledDelta (float dt) const {
     const float ratio = m_frameDelta > 0.0f ? std::min (1.0f, 0.025f / m_frameDelta) : 1.0f;
     return std::pow (ratio, 0.7f) * dt;
@@ -3849,7 +3905,7 @@ void CParticle::updateParticleViewProjection () {
 	// g_EyePosition in 2D scenes is the camera position 2000 units out (end of sub_1401891A0), the trail
 	// shader's ComputeParticleTrailTangents crosses the eye direction with the velocity
 	const glm::vec2 eye = getScene ().getCameraEye ();
-	m_eyePosition = glm::vec3 (eye.x, -eye.y, 2000.0f);
+	m_eyePosition = glm::vec3 (eye.x, eye.y, 2000.0f);
     }
 }
 
@@ -3943,12 +3999,15 @@ void CParticle::renderSprites () {
 	    }
 	}
 
+	const glm::vec3 position = this->drawVector (p.position);
+	const glm::vec3 velocity = this->drawVector (p.velocity);
+
 	auto addVertex = [&] (float u, float v) {
 	    const uint32_t base = vertexIndex * SPRITE_FLOATS_PER_VERTEX;
 	    // a_Position (vec3)
-	    m_vertices[base + 0] = p.position.x;
-	    m_vertices[base + 1] = p.position.y;
-	    m_vertices[base + 2] = p.position.z;
+	    m_vertices[base + 0] = position.x;
+	    m_vertices[base + 1] = position.y;
+	    m_vertices[base + 2] = position.z;
 	    // a_TexCoordVec4 (vec4: uv.x, uv.y, rotZ, size)
 	    m_vertices[base + 3] = u;
 	    m_vertices[base + 4] = v;
@@ -3960,9 +4019,9 @@ void CParticle::renderSprites () {
 	    m_vertices[base + 9] = p.color.b;
 	    m_vertices[base + 10] = p.alpha;
 	    // a_TexCoordVec4C1 (vec4: vel.x, vel.y, vel.z, lifetime)
-	    m_vertices[base + 11] = p.velocity.x;
-	    m_vertices[base + 12] = p.velocity.y;
-	    m_vertices[base + 13] = p.velocity.z;
+	    m_vertices[base + 11] = velocity.x;
+	    m_vertices[base + 12] = velocity.y;
+	    m_vertices[base + 13] = velocity.z;
 	    m_vertices[base + 14] = lifetime;
 	    // a_TexCoordC2 (vec2: rotX, rotY)
 	    m_vertices[base + 15] = p.rotation.x;
@@ -4057,10 +4116,10 @@ void CParticle::buildRopeTrail (uint32_t& vertexIndex, uint32_t& indexOffset) {
 	const float scroll = static_cast<float> (m_trailScroll[i]);
 
 	for (int k = 0; k < segments; k++) {
-	    const glm::vec3& start = point (k);
-	    const glm::vec3& end = point (k + 1);
-	    const glm::vec3& before = point (std::max (k - 1, 0));
-	    const glm::vec3& after = point (std::min (k + 2, segments));
+	    const glm::vec3 start = this->drawVector (point (k));
+	    const glm::vec3 end = this->drawVector (point (k + 1));
+	    const glm::vec3 before = this->drawVector (point (std::max (k - 1, 0)));
+	    const glm::vec3 after = this->drawVector (point (std::min (k + 2, segments)));
 	    // with uvscrolling the length slot carries the segment index and positions move back with every push
 	    const float lengthSlot = m_ropeUVScrolling ? static_cast<float> (k) : trailLength;
 	    const float position = m_ropeUVScrolling ? static_cast<float> (k) - scroll : static_cast<float> (k);
@@ -4184,16 +4243,16 @@ void CParticle::renderRope () {
 	}
 
 	for (uint32_t s = 0; s < totalSubSegments; s++) {
-	    const glm::vec3& posStart = splinePositions[s];
-	    const glm::vec3& posEnd = splinePositions[s + 1];
+	    const glm::vec3 posStart = this->drawVector (splinePositions[s]);
+	    const glm::vec3 posEnd = this->drawVector (splinePositions[s + 1]);
 	    float sizeStart = splineSizes[s];
 	    float sizeEnd = splineSizes[s + 1];
 	    const glm::vec4& colorStart = splineColors[s];
 	    const glm::vec4& colorEnd = splineColors[s + 1];
 
 	    // Neighboring points for shader tangent computation (CP0/CP1)
-	    const glm::vec3& posPrev = (s > 0) ? splinePositions[s - 1] : posStart;
-	    const glm::vec3& posAfter = (s + 2 < totalPoints) ? splinePositions[s + 2] : posEnd;
+	    const glm::vec3 posPrev = (s > 0) ? this->drawVector (splinePositions[s - 1]) : posStart;
+	    const glm::vec3 posAfter = (s + 2 < totalPoints) ? this->drawVector (splinePositions[s + 2]) : posEnd;
 
 	    // Compute trailPosition for UV mapping
 	    float trailPosition;

+ 6 - 10
src/WallpaperEngine/Render/Objects/CParticle.h

@@ -199,6 +199,10 @@ protected:
     [[nodiscard]] glm::vec3 remapLayerOrigin () const;
     /** Control point position in wallpaper64.exe's y-up particle space */
     [[nodiscard]] glm::vec3 controlPointWE (int index) const;
+    /** A position or direction as the vertex buffer takes it, see m_drawFlipY */
+    [[nodiscard]] glm::vec3 drawVector (const glm::vec3& value) const;
+    /** A control point's matrix before the system's transform: its offset, or the instance override's point/angles */
+    [[nodiscard]] glm::mat4 localControlPointMatrix (size_t index) const;
     /** Operators scale some forces by how long frames take (sub_140236CD0): dt * min(1, 0.025 / frame time)^0.7 */
     [[nodiscard]] float frameScaledDelta (float dt) const;
     /** wallpaper64.exe's time since the layer became visible (+1904), the remap input layertime */
@@ -212,9 +216,6 @@ protected:
     void setupGeometryCallbacks ();
     void setupParticleUniforms ();
     void updateMatrices ();
-    /** The system's origin in the space it's drawn in (centered and y-down for 2D scenes, world for 3D ones) */
-    [[nodiscard]] glm::vec3 sceneOrigin () const;
-    void syncTransformedOrigin ();
     void updateParticleViewProjection ();
     void updateParticleRenderVars ();
 
@@ -293,6 +294,8 @@ private:
 
     // Particle-specific uniform data (stored here, pointed to by CPass)
     glm::mat4 m_modelMatrix { 1.0f };
+    /** 2D scenes: vertices are uploaded in WE's y-up particle frame, m_modelMatrix carries the flip */
+    bool m_drawFlipY = false;
     glm::mat4 m_modelMatrixInverse { 1.0f };
     glm::mat4 m_mvpMatrix { 1.0f };
     glm::mat4 m_mvpMatrixInverse { 1.0f };
@@ -340,13 +343,6 @@ private:
     static constexpr int SPRITE_FLOATS_PER_VERTEX = 17;
     static constexpr int ROPE_FLOATS_PER_VERTEX = 26;
 
-    // Screen space to centered space conversion
-    glm::vec3 m_transformedOrigin { 0.0f };
-
-    // Last known resolution for detecting changes
-    float m_lastScreenWidth { 0.0f };
-    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;

+ 2 - 2
src/WallpaperEngine/Render/Objects/CText.cpp

@@ -356,7 +356,6 @@ void CText::initScriptLayer () {
 
     // a script already running as a regular property module drives the value itself
     if (this->getScene ().getScriptEngine ().hasScript (*m_text.text->value)) {
-	m_textFromProperty = true;
 	return;
     }
 
@@ -826,7 +825,8 @@ void CText::render () {
 	);
 	const std::string current = se.layerText (m_layerHandle);
 	renderedText = current.empty () ? std::string (" ") : current;
-    } else if (m_textFromProperty) {
+    } else {
+	// other scripts can write layer.text too
 	const std::string current = m_text.text->value->getString ();
 	renderedText = current.empty () ? std::string (" ") : current;
     }

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

@@ -131,7 +131,6 @@ private:
     glm::vec4 m_backgroundColor4 = { 0.0f, 0.0f, 0.0f, 1.0f };
     const glm::vec4 m_white = { 1.0f, 1.0f, 1.0f, 1.0f };
 
-    bool m_textFromProperty = false;
     mutable glm::vec3 m_colorCache {};
     mutable glm::vec4 m_color4Cache = { 1.0f, 1.0f, 1.0f, 1.0f };
 

+ 31 - 10
src/WallpaperEngine/Render/Objects/Effects/CPass.cpp

@@ -255,7 +255,11 @@ std::optional<std::string> CPass::resolveUserTextureName (const std::string& pro
 }
 
 std::shared_ptr<const CFBO> CPass::resolveFBO (const std::string& name) const {
-    auto fbo = this->m_fboProvider->find (name);
+    std::shared_ptr<const CFBO> fbo = this->m_fboProvider->find (name);
+
+    if (fbo == nullptr && name == "_rt_MipMappedFrameBuffer") {
+	fbo = this->m_renderable.getScene ().requireMipMappedFrameBuffer ();
+    }
 
     if (fbo == nullptr) {
 	sLog.exception ("Tried to resolve and FBO without any luck: ", name);
@@ -614,7 +618,7 @@ 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) {
+	if (it != this->m_uniforms.end () && it->second->owned && !this->m_constantUniforms.contains (name)) {
 	    using Value = std::remove_cvref_t<decltype (value)>;
 	    *static_cast<Value*> (const_cast<void*> (it->second->value)) = value;
 	}
@@ -623,7 +627,7 @@ void CPass::refreshRenderableUniforms () {
     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 ());
+    update ("g_Color4", this->m_neutralColor ? glm::vec4 (1.0f) : this->m_renderable.getColor4 ());
 }
 
 void CPass::render () {
@@ -758,6 +762,8 @@ void CPass::setClearColor (const glm::vec4* color) { this->m_clearColor = color;
 
 void CPass::setKeepDestination (bool keep) { this->m_keepDestination = keep; }
 
+void CPass::setNeutralColor (bool neutral) { this->m_neutralColor = neutral; }
+
 void CPass::setPosition (GLuint position) { this->a_Position = position; }
 
 const MaterialPass& CPass::getPass () const { return this->m_pass; }
@@ -1041,7 +1047,7 @@ std::shared_ptr<CPass::CompiledShader> CPass::compileShaderSources (
     );
 
     auto [vertex, fragment]
-	= Shaders::GLSLContext::get ().toGlsl (compiled->shader->vertex (), compiled->shader->fragment ());
+	= Shaders::GLSLContext::get ().toGlsl (compiled->shader->vertex (), compiled->shader->fragment (), shaderName);
     compiled->vertex = std::move (vertex);
     compiled->fragment = std::move (fragment);
 
@@ -1371,6 +1377,13 @@ void CPass::setupTextureUniforms () {
 	const glm::vec4* res = texture->getResolution ();
 
 	this->addUniform (namestream.str (), res);
+
+	// the mip count of a mipmapped frame buffer, REFLECTION scales its roughness LOD by it
+	if (const auto fbo = std::dynamic_pointer_cast<const CFBO> (texture); fbo != nullptr && fbo->getMipLevels () > 1) {
+	    this->addUniform (
+		"g_Texture" + std::to_string (textureIndex) + "MipMapInfo", static_cast<float> (fbo->getMipLevels ())
+	    );
+	}
     }
 
     this->addUniform ("g_Texture0Resolution", &this->m_texture0Resolution);
@@ -1404,12 +1417,18 @@ void CPass::setupUniforms () {
 	    static_cast<float> (scene.getWidth ()) / static_cast<float> (std::max (scene.getHeight (), 1))
 	)
     );
-    // register variables like brightness and alpha with some default value
-    this->addUniform ("g_Brightness", renderable.getBrightness ());
-    this->addUniform ("g_UserAlpha", renderable.getUserAlpha ());
-    this->addUniform ("g_Alpha", renderable.getAlpha ());
-    this->addUniform ("g_Color", renderable.getColor ());
-    this->addUniform ("g_Color4", renderable.getColor4 ());
+    // register variables like brightness and alpha with the layer's values, unless the pass sets them itself
+    const auto addRenderableUniform = [this] (const char* name, const auto& value) {
+	if (!this->m_constantUniforms.contains (name)) {
+	    this->addUniform (name, value);
+	}
+    };
+
+    addRenderableUniform ("g_Brightness", renderable.getBrightness ());
+    addRenderableUniform ("g_UserAlpha", renderable.getUserAlpha ());
+    addRenderableUniform ("g_Alpha", renderable.getAlpha ());
+    addRenderableUniform ("g_Color", renderable.getColor ());
+    addRenderableUniform ("g_Color4", renderable.getColor4 ());
     if (!this->m_uniforms.contains ("g_CompositeColor")) {
 	this->addUniform ("g_CompositeColor", renderable.getCompositeColor ());
     }
@@ -1526,6 +1545,7 @@ void CPass::setupShaderVariables () {
 
 	ShaderVariable* var = vertex == nullptr ? fragment : vertex;
 	this->addUniform (var, value->value.get ());
+	this->m_constantUniforms.insert (var->getName ());
     }
 
     // apply override constants (highest priority, overrides both defaults and pass constants)
@@ -1538,6 +1558,7 @@ void CPass::setupShaderVariables () {
 
 	ShaderVariable* var = vertex == nullptr ? fragment : vertex;
 	this->addUniform (var, value->value.get ());
+	this->m_constantUniforms.insert (var->getName ());
     }
 
     // bind the full-reveal bypass uniform injected by patchXrayFullRevealBypass() (see setupShaders());

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

@@ -1,5 +1,6 @@
 #pragma once
 
+#include <set>
 #include <unordered_map>
 
 #include <functional>
@@ -60,6 +61,8 @@ public:
     void setClearColor (const glm::vec4* color);
     /** Draw over what a private destination already holds instead of clearing it first */
     void setKeepDestination (bool keep);
+    /** g_Color4 stays opaque white, for composites of a buffer that already carries the object's color and alpha */
+    void setNeutralColor (bool neutral);
     void setBlendingMode (BlendingMode blendingmode);
     [[nodiscard]] BlendingMode getBlendingMode () const;
     [[nodiscard]] std::shared_ptr<const CFBO> resolveFBO (const std::string& name) const;
@@ -251,6 +254,9 @@ private:
     std::map<std::string, UniformEntry*> m_uniforms = {};
     const glm::vec4* m_clearColor = nullptr;
     bool m_keepDestination = false;
+    bool m_neutralColor = false;
+    // uniforms the pass sets as material or override constants, the renderable values leave these alone
+    std::set<std::string> m_constantUniforms;
     std::map<std::string, ReferenceUniformEntry*> m_referenceUniforms = {};
     BlendingMode m_blendingmode = BlendingMode_Normal;
     const glm::mat4* m_modelViewProjectionMatrix;

+ 74 - 8
src/WallpaperEngine/Render/Shaders/GLSLContext.cpp

@@ -1,9 +1,17 @@
 #include "GLSLContext.h"
-#include "WallpaperEngine/Logging/Log.h"
 
+#include <algorithm>
 #include <cassert>
+#include <cstdlib>
+#include <filesystem>
+#include <fstream>
+#include <iomanip>
 #include <memory>
 #include <mutex>
+#include <regex>
+#include <set>
+#include <sstream>
+#include <stdexcept>
 #include <unordered_map>
 
 #include "SPIRV/GlslangToSpv.h"
@@ -133,7 +141,65 @@ GLSLContext& GLSLContext::get () {
     return *sInstance;
 }
 
-std::pair<std::string, std::string> GLSLContext::toGlsl (const std::string& vertex, const std::string& fragment) {
+namespace {
+// glslang's log says "ERROR: 0:367: ..." against the exact string it was given, show those lines with some context
+std::string describeFailure (
+    const std::string& name, const std::string& stage, const std::string& source, const std::string& log
+) {
+    static const std::regex errorLine (R"((?:ERROR|WARNING): \d+:(\d+):)");
+    constexpr int context = 4;
+
+    std::vector<std::string> lines;
+    std::istringstream input (source);
+    for (std::string line; std::getline (input, line);) {
+	lines.push_back (line);
+    }
+
+    std::set<int> errors;
+    for (auto it = std::sregex_iterator (log.cbegin (), log.cend (), errorLine); it != std::sregex_iterator (); ++it) {
+	errors.insert (std::stoi ((*it)[1].str ()));
+    }
+
+    std::ostringstream out;
+    out << "GLSL " << stage << " unit of " << (name.empty () ? "<unnamed shader>" : name) << " failed to parse:\n"
+	<< log;
+
+    int shownUpTo = 0;
+    for (const int error : errors) {
+	const int from = std::max ({ 1, error - context, shownUpTo + 1 });
+	const int to = std::min (static_cast<int> (lines.size ()), error + context);
+
+	if (from > to) {
+	    continue;
+	}
+	if (from > shownUpTo + 1) {
+	    out << "  ...\n";
+	}
+	for (int number = from; number <= to; number++) {
+	    out << (number == error ? ">" : " ") << std::setw (5) << number << ": " << lines[number - 1] << '\n';
+	}
+	shownUpTo = to;
+    }
+
+    // the whole thing is ~500 lines with includes, too much for the log every time
+    if (const char* dir = std::getenv ("LWE_SHADER_DUMP_DIR"); dir != nullptr && *dir != '\0') {
+	std::string file = name.empty () ? "shader" : name;
+	std::replace (file.begin (), file.end (), '/', '_');
+	const auto path = std::filesystem::path (dir) / (file + "." + stage + ".glsl");
+
+	std::error_code ec;
+	std::filesystem::create_directories (dir, ec);
+	std::ofstream (path) << source;
+	out << "full source written to " << path.string () << '\n';
+    }
+
+    return out.str ();
+}
+} // namespace
+
+std::pair<std::string, std::string> GLSLContext::toGlsl (
+    const std::string& vertex, const std::string& fragment, const std::string& name
+) {
     // pure function of the two sources, and passes get rebuilt often
     static std::mutex cacheMutex;
     static std::unordered_map<std::string, std::pair<std::string, std::string>> cache;
@@ -162,8 +228,7 @@ std::pair<std::string, std::string> GLSLContext::toGlsl (const std::string& vert
     vertexShader.setAutoMapBindings (true);
 
     if (!vertexShader.parse (&BuiltInResource, 100, false, EShMsgDefault)) {
-	sLog.error ("GLSL vertex unit parsing Failed: ", vertexShader.getInfoLog ());
-	return { "", "" };
+	throw std::runtime_error (describeFailure (name, "vertex", vertex, vertexShader.getInfoLog ()));
     }
     glslang::TShader fragmentShader (EShLangFragment);
 
@@ -177,16 +242,17 @@ std::pair<std::string, std::string> GLSLContext::toGlsl (const std::string& vert
     fragmentShader.setAutoMapBindings (true);
 
     if (!fragmentShader.parse (&BuiltInResource, 100, false, EShMsgDefault)) {
-	sLog.error ("GLSL fragment unit parsing Failed: ", fragmentShader.getInfoLog ());
-	return { "", "" };
+	throw std::runtime_error (describeFailure (name, "fragment", fragment, fragmentShader.getInfoLog ()));
     }
     glslang::TProgram program;
     program.addShader (&vertexShader);
     program.addShader (&fragmentShader);
 
     if (!program.link (EShMsgDefault)) {
-	sLog.error ("Program Linking Failed: ", program.getInfoLog ());
-	return { "", "" };
+	throw std::runtime_error (
+	    "GLSL program " + (name.empty () ? std::string ("<unnamed shader>") : name) + " failed to link: "
+	    + program.getInfoLog ()
+	);
     }
 
     std::vector<uint32_t> spirv;

+ 5 - 1
src/WallpaperEngine/Render/Shaders/GLSLContext.h

@@ -14,7 +14,11 @@ public:
     GLSLContext ();
     ~GLSLContext ();
 
-    [[nodiscard]] std::pair<std::string, std::string> toGlsl (const std::string& vertex, const std::string& fragment);
+    /** Throws with the error and the offending source lines when glslang rejects either unit, `name` is only
+     *  used for that report. */
+    [[nodiscard]] std::pair<std::string, std::string> toGlsl (
+	const std::string& vertex, const std::string& fragment, const std::string& name = ""
+    );
 
     [[nodiscard]] static GLSLContext& get ();
 

+ 123 - 4
src/WallpaperEngine/Render/Shaders/ShaderUnit.cpp

@@ -68,6 +68,19 @@ using namespace WallpaperEngine::Data::Builders;
 using namespace WallpaperEngine::Render::Shaders;
 
 namespace {
+// "name|name|..." of every uniform, varying and attribute the source declares, for regex alternations
+std::string declaredInputNames (const std::string& source) {
+    static const std::regex inputDecl (R"(\b(?:uniform|varying|attribute)\s+\w+\s+([A-Za-z_]\w*))");
+
+    std::string names;
+    for (auto it = std::sregex_iterator (source.cbegin (), source.cend (), inputDecl); it != std::sregex_iterator ();
+	 ++it) {
+	names += (names.empty () ? "" : "|") + (*it)[1].str ();
+    }
+
+    return names;
+}
+
 // the quoted filename of the #include at start, or nothing if the quotes aren't on the same line
 std::optional<std::string> includeFilename (const std::string& source, const size_t start) {
     const size_t lineEnd = std::min (source.find ('\n', start), source.size ());
@@ -851,10 +864,25 @@ std::string ShaderUnit::applyFragmentVaryingShadowCompatibility (std::string sou
     return source;
 }
 
+std::string ShaderUnit::applyDirectiveSemicolonCompatibility (std::string source) const {
+    static const std::regex directive (R"((^|\n)([ \t]*#[ \t]*(?:if|elif)\b[^\n;]*?)[ \t]*;[ \t]*(?=\r?\n|$))");
+
+    std::string result = std::regex_replace (source, directive, "$1$2");
+    if (result != source) {
+	sLog.out ("Dropped trailing semicolon from #if/#elif directive(s) in ", this->m_file);
+    }
+
+    return result;
+}
+
 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+|wpeVar_\w+)\b)");
+    std::string names = R"(texSample2D\w*|texture\w*|g_\w+|v_\w+|wpeVar_\w+)";
+    if (const std::string inputs = declaredInputNames (source); !inputs.empty ()) {
+	names += "|" + inputs;
+    }
+    const std::regex nonConstant (R"(\b(?:)" + names + R"()\b)");
 
     std::string result;
     size_t count = 0;
@@ -884,6 +912,95 @@ std::string ShaderUnit::applyNonConstantConstCompatibility (std::string source)
     return result;
 }
 
+std::string ShaderUnit::applyNonConstantGlobalConstCompatibility (std::string source) const {
+    static const std::regex constGlobal (
+	R"((^|\n)[ \t]*const\s+((?:float|int|uint|bool|[biu]?vec[234]|mat[234](?:x[234])?)\s+([A-Za-z_]\w*))\s*=([^;]*);)"
+    );
+    static const std::regex mainOpen (R"(\bvoid\s+main\s*\([^)]*\)\s*\{)");
+
+    // only statements outside any function body count as globals
+    std::vector<bool> topLevel (source.size () + 1, false);
+    int depth = 0;
+    bool comment = false;
+    for (size_t i = 0; i < source.size (); i++) {
+	topLevel[i] = depth == 0;
+
+	if (source[i] == '\n') {
+	    comment = false;
+	} else if (!comment && source.compare (i, 2, "//") == 0) {
+	    comment = true;
+	} else if (!comment && source[i] == '{') {
+	    depth++;
+	} else if (!comment && source[i] == '}' && depth > 0) {
+	    depth--;
+	}
+    }
+
+    std::string nonConstant = R"(texSample2D\w*|texture\w*|wpeVar_\w+)";
+    if (const std::string inputs = declaredInputNames (source); !inputs.empty ()) {
+	nonConstant += "|" + inputs;
+    }
+
+    std::unordered_map<std::string, int> declCount;
+    for (auto it = std::sregex_iterator (source.cbegin (), source.cend (), constGlobal); it != std::sregex_iterator ();
+	 ++it) {
+	declCount[(*it)[3].str ()]++;
+    }
+
+    std::string result;
+    std::string assignments;
+    std::vector<std::string> moved;
+    auto last = source.cbegin ();
+
+    for (auto it = std::sregex_iterator (source.cbegin (), source.cend (), constGlobal); it != std::sregex_iterator ();
+	 ++it) {
+	const auto& match = *it;
+	const std::string name = match[3].str ();
+	const std::string init = match[4].str ();
+
+	if (!topLevel[match.position (0) + match[1].length ()] || declCount[name] != 1) {
+	    continue;
+	}
+
+	// a const that reads an earlier moved one isn't constant anymore either
+	const std::regex uses (R"(\b(?:)" + nonConstant + R"()\b)");
+	if (!std::regex_search (init, uses)) {
+	    continue;
+	}
+
+	result.append (last, match[0].first);
+	result += match[1].str () + match[2].str () + ";";
+	last = match[0].second;
+
+	assignments += " " + name + " =" + init + ";";
+	nonConstant += "|" + name;
+	moved.push_back (name);
+    }
+
+    if (moved.empty ()) {
+	return source;
+    }
+
+    result.append (last, source.cend ());
+
+    const auto mains = std::distance (std::sregex_iterator (result.cbegin (), result.cend (), mainOpen), std::sregex_iterator ());
+    if (mains != 1) {
+	return source;
+    }
+
+    std::smatch mainMatch;
+    std::regex_search (result, mainMatch, mainOpen);
+    result.insert (mainMatch.position (0) + mainMatch.length (0), assignments);
+
+    std::string names;
+    for (const auto& name : moved) {
+	names += (names.empty () ? "" : ", ") + name;
+    }
+    sLog.out ("Moved non-constant global const initializer(s) into main() in ", this->m_file, ": ", names);
+
+    return result;
+}
+
 void ShaderUnit::parseComboConfiguration (const std::string& content, const int defaultValue) {
     // "require"/"requireany" on a combo are editor-only, they decide whether the editor shows the
     // option. wallpaper64.exe (sub_140133B60) never reads them, it just takes the default
@@ -1209,9 +1326,11 @@ const std::string& ShaderUnit::compile () {
 
     const std::string compat = this->applyNonConstantConstCompatibility (this->applyFloatConditionCompatibility (
 	this->applyVectorTruncationCompatibility (this->applyFragmentVaryingShadowCompatibility (
-	    this->applyFragmentTexCoordCompatibility (
-		this->applyNarrowFragmentVaryingCompatibility (this->applyLinkedVaryingCompatibility (this->m_preprocessed))
-	    )
+	    this->applyFragmentTexCoordCompatibility (this->applyNarrowFragmentVaryingCompatibility (
+		this->applyLinkedVaryingCompatibility (this->applyNonConstantGlobalConstCompatibility (
+		    this->applyDirectiveSemicolonCompatibility (this->m_preprocessed)
+		))
+	    ))
 	))
     ));
 

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

@@ -81,6 +81,13 @@ private:
     /** 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;
+    /** Same thing for globals (`const vec2 ratio = g_Texture0Resolution.xy * g_Scale;`). GLSL needs a constant
+     *  expression for any global initializer, so the global loses its initializer and gets assigned at the
+     *  top of main() instead. */
+    [[nodiscard]] std::string applyNonConstantGlobalConstCompatibility (std::string source) const;
+    /** Some workshop shaders end an #if/#elif with a `;` (`#elif AUDIOSAMPLES == 32;`) and work in WE,
+     *  glslang rejects the directive. Strips the trailing `;`. */
+    [[nodiscard]] std::string applyDirectiveSemicolonCompatibility (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);

+ 24 - 25
src/WallpaperEngine/Render/Utils/NoiseUtils.h

@@ -168,7 +168,11 @@ 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)
+/**
+ * The 3D simplex noise of wallpaper64.exe's noise object (vtable off_140488440 slot 2, sub_1400FD010), used by the
+ * turbulence operator, roughly -1..1. Gustavson's simplex with one difference: the far corner hashes
+ * perm[j + 1 + perm[k]] where Gustavson has perm[j + 1 + perm[k + 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 },
@@ -177,7 +181,7 @@ inline float simplexNoise3D (float x, float y, float z) {
     constexpr float F3 = 1.0f / 3.0f;
     constexpr float G3 = 1.0f / 6.0f;
 
-    const float s = (x + y + z) * F3;
+    const float s = (y + z + x) * 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));
@@ -186,25 +190,13 @@ inline float simplexNoise3D (float x, float y, float z) {
     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;
-	}
-    }
+    // the binary's comparisons, ties included
+    const int i1 = y0 <= x0 && z0 <= x0;
+    const int j1 = x0 < y0 && z0 < y0;
+    const int k1 = x0 < z0 && y0 < z0;
+    const int i2 = i1 || (x0 < y0 && z0 <= x0) || (x0 < z0 && y0 <= x0);
+    const int j2 = j1 || (y0 < z0 && x0 < y0) || (z0 <= y0 && y0 <= x0);
+    const int k2 = k1 || (z0 <= y0 && x0 < z0) || (y0 < z0 && z0 <= x0);
 
     const float offsets[4][3] = {
 	{ x0, y0, z0 },
@@ -212,8 +204,16 @@ inline float simplexNoise3D (float x, float y, float z) {
 	{ 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 int ii = i & 0xff;
+    const int jj = j & 0xff;
+    const int kk = k & 0xff;
     const auto perm = [] (int index) { return static_cast<int> (PERLIN_PERM[index & 0xff]); };
+    const int hashes[4] = {
+	perm (ii + perm (jj + perm (kk))),
+	perm (ii + i1 + perm (jj + j1 + perm (kk + k1))),
+	perm (ii + i2 + perm (jj + j2 + perm (kk + k2))),
+	perm (ii + 1 + perm (jj + 1 + perm (kk))),
+    };
 
     float total = 0.0f;
 
@@ -225,12 +225,11 @@ inline float simplexNoise3D (float x, float y, float z) {
 	    continue;
 	}
 
-	const int gradient
-	    = perm (i + corners[c][0] + perm (j + corners[c][1] + perm (k + corners[c][2]))) % 12;
+	const int gradient = hashes[c] % 12;
 
 	falloff *= falloff;
 	total += falloff * falloff
-	    * (gradients[gradient][0] * o[0] + gradients[gradient][1] * o[1] + gradients[gradient][2] * o[2]);
+	    * (gradients[gradient][2] * o[2] + gradients[gradient][1] * o[1] + gradients[gradient][0] * o[0]);
     }
 
     return 32.0f * total;

+ 132 - 47
src/WallpaperEngine/Render/Wallpapers/CScene.cpp

@@ -17,6 +17,7 @@
 #include "WallpaperEngine/Data/Parsers/ObjectParser.h"
 
 #include <algorithm>
+#include <bit>
 #include <chrono>
 #include <cmath>
 #include <cstdlib>
@@ -83,8 +84,13 @@ CScene::CScene (
 	&& scene->camera.bloom.enabled->value->getBool () && scene->camera.bloom.hdr->value->getBool ();
     this->m_volumetrics = std::make_unique<Volumetrics> (*this);
 
+    // models depth test against each other, 2D scenes can hold some too
+    const bool hasModels = std::ranges::any_of (scene->objects, [] (const auto& object) { return object->template is<Mesh> (); });
+
     // needed before scene setup below, which creates FBOs
-    this->setupFramebuffers (perspective, this->m_hdr ? TextureFormat_RGBA16161616f : TextureFormat_ARGB8888);
+    this->setupFramebuffers (
+	perspective || hasModels, this->m_hdr ? TextureFormat_RGBA16161616f : TextureFormat_ARGB8888
+    );
 
     const uint32_t sceneWidth = this->m_camera->getCanvasWidth ();
     const uint32_t sceneHeight = this->m_camera->getCanvasHeight ();
@@ -288,7 +294,7 @@ Render::CObject* CScene::dispatchObjectType (const Object& object) {
 	auto* camera = new Objects::CCamera (*this, *object.as<SceneCamera> ());
 	this->m_sceneCameras.push_back (camera);
 	renderObject = camera;
-    } else if (object.is<Mesh> () && this->m_camera->isPerspective ()) {
+    } else if (object.is<Mesh> ()) {
 	renderObject = new Objects::CMesh (*this, *object.as<Mesh> ());
     } else if (object.is<Particle> ()) {
 	const auto& particleData = *object.as<Particle> ();
@@ -470,11 +476,20 @@ void CScene::renderFrameSteps (const glm::ivec4& viewport) {
 	}
 
 	this->m_volumetrics->composite ();
+
+	if (cur == this->m_bloomObject) {
+	    this->updateMipMappedFrameBuffer ();
+	}
+
 	timeStep ("render " + cur->getObject ().name, [&] { cur->render (); });
     }
 
     this->m_volumetrics->composite ();
 
+    if (this->m_bloomObject == nullptr) {
+	this->updateMipMappedFrameBuffer ();
+    }
+
     if (this->m_hdr) {
 	this->renderHDRBloom ();
     }
@@ -629,6 +644,43 @@ void releaseLevel (auto& level) {
 }
 } // namespace
 
+std::shared_ptr<const CFBO> CScene::requireMipMappedFrameBuffer () {
+    if (this->_rt_MipMappedFrameBuffer != nullptr) {
+	return this->_rt_MipMappedFrameBuffer;
+    }
+
+    // sub_1400D2C60 with flag 0x10: max (1, min (log2 (np2 (w) / 2), log2 (np2 (h) / 2)) - 2) levels,
+    // np2 = the next power of two, 8 for a 1920x1058 buffer
+    const auto halfPowerOfTwo = [] (const uint32_t value) {
+	return std::bit_width (std::bit_ceil (std::max (value, 2u)) >> 1) - 1;
+    };
+    const uint32_t width = this->m_sceneFBO->getRealWidth ();
+    const uint32_t height = this->m_sceneFBO->getRealHeight ();
+    const int levels = std::max (1, static_cast<int> (std::min (halfPowerOfTwo (width), halfPowerOfTwo (height))) - 2);
+
+    this->_rt_MipMappedFrameBuffer = this->create (
+	"_rt_MipMappedFrameBuffer", this->m_sceneFBO->getFormat (), TextureFlags_ClampUVs, 1.0, { width, height },
+	{ width, height }, { 0.0f, 0.0f, 0.0f, 1.0f }, static_cast<uint32_t> (levels)
+    );
+
+    return this->_rt_MipMappedFrameBuffer;
+}
+
+void CScene::updateMipMappedFrameBuffer () const {
+    if (this->_rt_MipMappedFrameBuffer == nullptr) {
+	return;
+    }
+
+    const auto width = static_cast<GLint> (this->m_sceneFBO->getRealWidth ());
+    const auto height = static_cast<GLint> (this->m_sceneFBO->getRealHeight ());
+
+    glBindFramebuffer (GL_READ_FRAMEBUFFER, this->m_sceneFBO->getFramebuffer ());
+    glBindFramebuffer (GL_DRAW_FRAMEBUFFER, this->_rt_MipMappedFrameBuffer->getFramebuffer ());
+    glBlitFramebuffer (0, 0, width, height, 0, 0, width, height, GL_COLOR_BUFFER_BIT, GL_NEAREST);
+    this->_rt_MipMappedFrameBuffer->generateMipmaps ();
+    glBindFramebuffer (GL_FRAMEBUFFER, this->m_sceneFBO->getFramebuffer ());
+}
+
 void CScene::releaseHDRBloom () {
     for (auto& level : this->m_bloomLevels) {
 	releaseLevel (level);
@@ -862,17 +914,30 @@ void CScene::updateParallax () {
     }
 }
 
-glm::mat4 CScene::objectWorldMatrix (const Object& object) const {
-    const auto local = [] (const Object& current) {
-	const glm::vec3 angles = current.groupAngles->value->getVec3 ();
-	glm::mat4 transform = glm::translate (glm::mat4 (1.0f), current.origin->value->getVec3 ());
-	transform = glm::rotate (transform, angles.z, glm::vec3 (0.0f, 0.0f, 1.0f));
-	transform = glm::rotate (transform, angles.y, glm::vec3 (0.0f, 1.0f, 0.0f));
-	transform = glm::rotate (transform, angles.x, glm::vec3 (1.0f, 0.0f, 0.0f));
-	return glm::scale (transform, current.groupScale->value->getVec3 ());
-    };
+glm::mat4 CScene::objectLocalMatrix (const Object& object) {
+    // images and particles keep their own (scriptable) scale and angles next to the base object's copy
+    glm::vec3 scale = object.groupScale->value->getVec3 ();
+    glm::vec3 angles = object.groupAngles->value->getVec3 ();
+
+    if (object.is<Image> ()) {
+	scale = object.as<Image> ()->scale->value->getVec3 ();
+	angles = object.as<Image> ()->angles->value->getVec3 ();
+    } else if (object.is<Particle> ()) {
+	scale = object.as<Particle> ()->scale->value->getVec3 ();
+	angles = object.as<Particle> ()->angles->value->getVec3 ();
+    } else if (object.is<Text> ()) {
+	scale = object.as<Text> ()->scale->value->getVec3 ();
+    }
+
+    glm::mat4 transform = glm::translate (glm::mat4 (1.0f), object.origin->value->getVec3 ());
+    transform = glm::rotate (transform, angles.z, glm::vec3 (0.0f, 0.0f, 1.0f));
+    transform = glm::rotate (transform, angles.y, glm::vec3 (0.0f, 1.0f, 0.0f));
+    transform = glm::rotate (transform, angles.x, glm::vec3 (1.0f, 0.0f, 0.0f));
+    return glm::scale (transform, scale);
+}
 
-    glm::mat4 world = local (object);
+glm::mat4 CScene::objectWorldMatrix (const Object& object) const {
+    glm::mat4 world = objectLocalMatrix (object);
     const Object* current = &object;
 
     for (int depth = 0; current->parent.has_value () && depth < 64; depth++) {
@@ -883,7 +948,7 @@ glm::mat4 CScene::objectWorldMatrix (const Object& object) const {
 	}
 
 	current = &parent->getObject ();
-	world = local (*current) * world;
+	world = objectLocalMatrix (*current) * world;
     }
 
     return world;
@@ -1514,40 +1579,57 @@ void CScene::appendLayer (CObject* object) {
     this->m_objectsByRenderOrder.insert (std::ranges::find (this->m_objectsByRenderOrder, this->m_bloomObject), object);
 }
 
-Render::CObject* CScene::createLayer (const std::string& imagePath) {
+Render::CObject* CScene::buildLayer (JSON layerJson) {
     const int id = this->m_nextDynamicLayerId++;
-    const std::string name = "scriptlayer_" + std::to_string (id);
-
-    // same minimal-object-JSON approach the constructor uses for the bloom layer, so this gets the
-    // same defaults a real scene.json image object would
-    const auto tryBuild = [&] (const std::string& path) -> Render::CObject* {
-	const JSON layerJson = {
-	    { "id", id },
-	    { "name", name },
-	    { "image", path },
-	    { "visible", true },
-	};
 
-	auto objectData = ObjectParser::parse (layerJson, this->getScene ().project);
-	Render::CObject* renderObject = this->createObject (*objectData);
+    layerJson["id"] = id;
+    if (!layerJson.contains ("name") || !layerJson["name"].is_string ()) {
+	layerJson["name"] = "scriptlayer_" + std::to_string (id);
+    }
+    if (!layerJson.contains ("visible")) {
+	layerJson["visible"] = true;
+    }
 
-	if (renderObject == nullptr) {
-	    return nullptr;
-	}
+    auto objectData = ObjectParser::parse (layerJson, this->getScene ().project);
+    Render::CObject* renderObject = this->createObject (*objectData);
 
+    if (renderObject == nullptr) {
+	return nullptr;
+    }
+
+    this->m_dynamicObjectData.emplace_back (std::move (objectData));
+    this->appendLayer (renderObject);
+
+    return renderObject;
+}
+
+// scripts write into createLayer()'s result without a null check, so failures get a scriptable, non-rendering placeholder
+Render::CObject* CScene::createPlaceholderLayer () {
+    try {
+	const int id = this->m_nextDynamicLayerId++;
+	const JSON placeholderJson = { { "id", id }, { "name", "scriptlayer_" + std::to_string (id) }, { "visible", true } };
+	auto objectData = ObjectParser::parse (placeholderJson, this->getScene ().project);
+	auto* renderObject = new Scripting::ScriptableObject (*this, *objectData);
+
+	this->m_objects.emplace (renderObject->getId (), renderObject);
 	this->m_dynamicObjectData.emplace_back (std::move (objectData));
 	this->appendLayer (renderObject);
 
 	return renderObject;
-    };
+    } catch (const std::exception& placeholderError) {
+	sLog.error ("createLayer placeholder fallback also failed: ", placeholderError.what ());
+	return nullptr;
+    }
+}
 
+Render::CObject* CScene::createLayer (const std::string& imagePath) {
     // createObject() throws on a missing asset, and unwinding a C++ exception through the QuickJS callback is undefined behavior
     try {
 	if (const auto cached = this->m_createLayerAliases.find (imagePath); cached != this->m_createLayerAliases.end ()) {
-	    return tryBuild (cached->second);
+	    return this->buildLayer ({ { "image", cached->second } });
 	}
 
-	return tryBuild (imagePath);
+	return this->buildLayer ({ { "image", imagePath } });
     } catch (const std::exception& e) {
 	// scripts written against a workshop dependency's original layout use the un-prefixed name, try the prefixed copy first
 	if (const auto alias = this->getScene ().project.assetLocator->resolveWorkshopDependencyAlias (imagePath);
@@ -1557,7 +1639,7 @@ Render::CObject* CScene::createLayer (const std::string& imagePath) {
 		    "createLayer: '", imagePath, "' not found, found and using workshop-dependency copy '",
 		    alias->string (), "' instead"
 		);
-		auto* layer = tryBuild (alias->string ());
+		auto* layer = this->buildLayer ({ { "image", alias->string () } });
 		this->m_createLayerAliases.emplace (imagePath, alias->string ());
 		return layer;
 	    } catch (const std::exception& aliasError) {
@@ -1567,21 +1649,24 @@ Render::CObject* CScene::createLayer (const std::string& imagePath) {
 	    sLog.error ("createLayer failed for '", imagePath, "', falling back to an invisible placeholder: ", e.what ());
 	}
 
-	// scripts write into createLayer()'s result without a null check, so fall back to a scriptable, non-rendering placeholder
-	try {
-	    const JSON placeholderJson = { { "id", id }, { "name", name }, { "visible", true } };
-	    auto objectData = ObjectParser::parse (placeholderJson, this->getScene ().project);
-	    auto* renderObject = new Scripting::ScriptableObject (*this, *objectData);
+	return this->createPlaceholderLayer ();
+    }
+}
 
-	    this->m_objects.emplace (renderObject->getId (), renderObject);
-	    this->m_dynamicObjectData.emplace_back (std::move (objectData));
-	    this->appendLayer (renderObject);
+Render::CObject* CScene::createLayerFromConfig (JSON config) {
+    // WE (scenescript64 sub_180011340): a config with "color" and no layer type key becomes a solid layer
+    static constexpr std::array layerTypes = { "model", "particle", "sprite", "image", "text", "light", "sound", "camera" };
 
-	    return renderObject;
-	} catch (const std::exception& placeholderError) {
-	    sLog.error ("createLayer placeholder fallback also failed: ", placeholderError.what ());
-	    return nullptr;
-	}
+    if (config.contains ("color")
+	&& std::ranges::none_of (layerTypes, [&config] (const char* key) { return config.contains (key); })) {
+	config["image"] = "models/util/solidlayer.json";
+    }
+
+    try {
+	return this->buildLayer (config);
+    } catch (const std::exception& e) {
+	sLog.error ("createLayer failed for ", config.dump (), ", falling back to an invisible placeholder: ", e.what ());
+	return this->createPlaceholderLayer ();
     }
 }
 

+ 20 - 2
src/WallpaperEngine/Render/Wallpapers/CScene.h

@@ -1,5 +1,6 @@
 #pragma once
 
+#include "WallpaperEngine/Data/JSON.h"
 #include "WallpaperEngine/Render/Camera.h"
 
 #include "WallpaperEngine/Render/CWallpaper.h"
@@ -33,6 +34,12 @@ public:
 
     [[nodiscard]] const Scene& getScene () const;
 
+    /**
+     * _rt_MipMappedFrameBuffer, created on the first material that samples it (sub_140181AF0, scene flag 0x800).
+     * Holds the previous frame's scene with mips, what REFLECTION reads with a roughness based LOD
+     */
+    std::shared_ptr<const CFBO> requireMipMappedFrameBuffer ();
+
     [[nodiscard]] int getWidth () const override;
     [[nodiscard]] int getHeight () const override;
     [[nodiscard]] int getCanvasWidth () const override;
@@ -57,6 +64,10 @@ public:
      * ancestor's origin and parallaxDepth, a child's own depth is ignored.
      */
     [[nodiscard]] glm::vec2 getParallaxOffset (const Data::Model::Object& object) const;
+    /** T * Rz * Ry * Rx * S of the object alone, in WE's scene space */
+    [[nodiscard]] static glm::mat4 objectLocalMatrix (const Data::Model::Object& object);
+    /** T * Rz * Ry * Rx * S down the parent chain, WE's object world matrix (sub_1401850A0) */
+    [[nodiscard]] glm::mat4 objectWorldMatrix (const Data::Model::Object& object) const;
 
     [[nodiscard]] const std::vector<CObject*>& getObjectsByRenderOrder () const;
     /** g_Fog* uniforms (sub_140186440). Height params come in two versions: WE's world (y up from the bottom) and
@@ -106,6 +117,9 @@ public:
     /** Creates a new image layer from a model json at runtime, appended to the render order. Backs
      *  the scripting API's thisScene.createLayer(). Returns nullptr if the model couldn't be set up. */
     Render::CObject* createLayer (const std::string& imagePath);
+    /** thisScene.createLayer() with a configuration object, already stringified by WE's _Internal.stringifyConfig
+     *  into scene.json object form. */
+    Render::CObject* createLayerFromConfig (Data::JSON::JSON config);
 
     /** Moves an existing layer to the given render-order slot. Backs thisScene.sortLayer(). */
     void sortLayer (CObject* object, int index);
@@ -121,6 +135,9 @@ protected:
     friend class CWallpaper;
 
 private:
+    Render::CObject* buildLayer (Data::JSON::JSON layerJson);
+    Render::CObject* createPlaceholderLayer ();
+
     /**
      * Grows the render canvas (symmetrically around the layout center, so object positions stay valid) until
      * every top-level image layer with a declared size fits, see --expand-canvas
@@ -140,11 +157,11 @@ private:
     /** WE's HDR bloom (sub_140183610) and combine_hdr_upsample into the scene buffer */
     void renderHDRBloom ();
     void releaseHDRBloom ();
+    /** Copies the finished scene into _rt_MipMappedFrameBuffer and rebuilds its mips, WE does it before bloom */
+    void updateMipMappedFrameBuffer () const;
     void updateFog (const glm::vec3& eye);
     /** Mouse parallax smoothing, after updateCamera () since a camera object moves the parallax camera too */
     void updateParallax ();
-    /** T * Rz * Ry * Rx * S down the parent chain, WE's object world matrix (sub_1401850A0) */
-    [[nodiscard]] glm::mat4 objectWorldMatrix (const Object& object) const;
     [[nodiscard]] int nextFreeLightSlot () const;
 
     Render::CObject* createObject (const Object& object);
@@ -216,5 +233,6 @@ private:
     std::shared_ptr<const CFBO> _rt_8FrameBuffer = nullptr;
     std::shared_ptr<const CFBO> _rt_Bloom = nullptr;
     std::shared_ptr<const CFBO> _rt_shadowAtlas = nullptr;
+    std::shared_ptr<const CFBO> _rt_MipMappedFrameBuffer = nullptr;
 };
 } // namespace WallpaperEngine::Render::Wallpaper

+ 19 - 1021
src/WallpaperEngine/Scripting/Adapters/VectorAdapter.cpp

@@ -1,848 +1,22 @@
 #include "VectorAdapter.h"
 
 #include "../ScriptEngine.h"
-#include "WallpaperEngine/Data/Utils/SFINAE.h"
-#include "WallpaperEngine/Data/Utils/ScopeGuard.h"
 
-#include <variant>
-
-using namespace WallpaperEngine::Data::Utils;
 using namespace WallpaperEngine::Data::Model;
 using namespace WallpaperEngine::Scripting::Adapters;
 
-static uint32_t VectorInstanceId = 0;
-static uint32_t VectorAdapterInstanceId = 0;
-static constexpr int InvalidVectorInstanceId = 0;
-
-// magic value used to ensure the assigned opaque value we got back is valid
-#define VEC_OPAQUE_MAGIC 0xdeadbee0
-#define VEC_MAGIC_CHECK_EXCEPTION(container, components)                                                               \
-    do {                                                                                                               \
-	if (!container || container->magic != (int)(VEC_OPAQUE_MAGIC + components)) {                                  \
-	    return JS_ThrowTypeError (ctx, "invalid or mismatched vector%d instance", components);                     \
-	}                                                                                                              \
-    } while (0)
-#define VEC_MAGIC_CHECK_ERROR(container, components)                                                                   \
-    do {                                                                                                               \
-	if (!container || container->magic != (int)(VEC_OPAQUE_MAGIC + components)) {                                  \
-	    return -1;                                                                                                 \
-	}                                                                                                              \
-    } while (0)
-
-template <int components> std::map<uint32_t, VectorAdapter<components>&> vectorAdapterInstances;
-
-template <int components> struct VectorOpaqueContainer {
-    int magic;
-    VectorAdapter<components>& adapter;
-    DynamicValue& value;
-    uint32_t id;
-};
-
-template <int components> auto vector_new () -> decltype (auto) {
-    static_assert (components >= 2 && components <= 4, "Unsupported vector type");
-
-    if constexpr (components == 2) {
-	return glm::vec2 {};
-    } else if constexpr (components == 3) {
-	return glm::vec3 {};
-    } else if constexpr (components == 4) {
-	return glm::vec4 {};
-    }
-}
-
-template auto vector_new<2> () -> decltype (auto);
-template auto vector_new<3> () -> decltype (auto);
-template auto vector_new<4> () -> decltype (auto);
-
-template <int components> auto vector_new (float value) -> decltype (auto) {
-    static_assert (components >= 2 && components <= 4, "Unsupported vector type");
-
-    if constexpr (components == 2) {
-	return glm::vec2 (value);
-    } else if constexpr (components == 3) {
-	return glm::vec3 (value);
-    } else if constexpr (components == 4) {
-	return glm::vec4 (value);
-    }
-}
-
-template auto vector_new<2> (float value) -> decltype (auto);
-template auto vector_new<3> (float value) -> decltype (auto);
-template auto vector_new<4> (float value) -> decltype (auto);
-
-template <int components> auto vector_get (DynamicValue& value) -> decltype (auto) {
-    static_assert (components >= 2 && components <= 4, "Unsupported vector type");
-
-    if constexpr (components == 2) {
-	return value.getVec2 ();
-    } else if constexpr (components == 3) {
-	return value.getVec3 ();
-    } else if constexpr (components == 4) {
-	return value.getVec4 ();
-    }
-}
-
-template <int components> auto vector_get (JSContext* ctx, JSValue source) -> decltype (auto) {
-    static_assert (components >= 2 && components <= 4, "Unsupported vector type");
-
-    int tag = JS_VALUE_GET_TAG (source);
-
-    if (tag == JS_TAG_INT) {
-	int32_t value = 0;
-
-	JS_ToInt32 (ctx, &value, source);
-
-	return vector_new<components> (value);
-    }
-
-    if (JS_TAG_IS_FLOAT64 (tag)) {
-	double value = 0.0f;
-
-	JS_ToFloat64 (ctx, &value, source);
-
-	return vector_new<components> (static_cast<float> (value));
-    }
-
-    if (tag == JS_TAG_OBJECT) {
-	JSValue x = JS_GetPropertyStr (ctx, source, "x");
-	JSValue y = JS_GetPropertyStr (ctx, source, "y");
-	JSValue z = JS_GetPropertyStr (ctx, source, "z");
-	JSValue w = JS_GetPropertyStr (ctx, source, "w");
-
-	if (!JS_IsNumber (x) || !JS_IsNumber (y)) {
-	    throw std::runtime_error ("Unsupported type conversion for VectorAdapter");
-	}
-
-	// a wider vector handed to a narrower target just loses its extra components
-	double xVal = 0.0f, yVal = 0.0f, zVal = 0.0f, wVal = 0.0f;
-
-	JS_ToFloat64 (ctx, &xVal, x);
-	JS_ToFloat64 (ctx, &yVal, y);
-
-	if (JS_IsNumber (z)) {
-	    JS_ToFloat64 (ctx, &zVal, z);
-	}
-
-	if (JS_IsNumber (w)) {
-	    JS_ToFloat64 (ctx, &wVal, w);
-	}
-
-	if constexpr (components == 2) {
-	    return glm::vec2 (xVal, yVal);
-	} else if constexpr (components == 3) {
-	    return glm::vec3 (xVal, yVal, zVal);
-	} else if constexpr (components == 4) {
-	    return glm::vec4 (xVal, yVal, zVal, wVal);
-	}
-    }
-
-    throw std::runtime_error ("Unsupported type conversion for VectorAdapter");
-}
-
-template auto vector_get<2> (JSContext* ctx, JSValue source) -> decltype (auto);
-template auto vector_get<3> (JSContext* ctx, JSValue source) -> decltype (auto);
-template auto vector_get<4> (JSContext* ctx, JSValue source) -> decltype (auto);
-template auto vector_get<2> (DynamicValue& value) -> decltype (auto);
-template auto vector_get<3> (DynamicValue& value) -> decltype (auto);
-template auto vector_get<4> (DynamicValue& value) -> decltype (auto);
-
-template <int components>
-JSValue vector_property_get (JSContext* ctx, JSValueConst obj_val, JSAtom atom, JSValueConst receiver) {
-    JSClassID classId = 0;
-
-    auto* container = static_cast<VectorOpaqueContainer<components>*> (JS_GetAnyOpaque (obj_val, &classId));
-
-    VEC_MAGIC_CHECK_EXCEPTION (container, components);
-
-    // Exotic get_property intercepts *every* lookup on this class, bypassing the prototype
-    // chain - so methods (multiply, add, dot, ...), which live only on the prototype, must be
-    // looked up manually below when the name isn't x/y/z/w.
-    const char* name = JS_AtomToCString (ctx, atom);
-
-    if (name != nullptr) {
-	ScopeGuard guard ([=] { JS_FreeCString (ctx, name); });
-	const auto value = vector_get<components> (container->value);
-
-	if (strcmp (name, "x") == 0) {
-	    return JS_NewFloat64 (ctx, value.x);
-	}
-	if (strcmp (name, "y") == 0) {
-	    return JS_NewFloat64 (ctx, value.y);
-	}
-	if constexpr (components >= 3) {
-	    if (strcmp (name, "z") == 0) {
-		return JS_NewFloat64 (ctx, value.z);
-	    }
-
-	    if constexpr (components >= 4) {
-		if (strcmp (name, "w") == 0) {
-		    return JS_NewFloat64 (ctx, value.w);
-		}
-	    }
-	}
-    }
-
-    JSValue proto = JS_GetClassProto (ctx, classId);
-    JSValue result = JS_GetProperty (ctx, proto, atom);
-    JS_FreeValue (ctx, proto);
-    return result;
-}
-
-template JSValue vector_property_get<2> (JSContext* ctx, JSValueConst obj_val, JSAtom atom, JSValueConst receiver);
-template JSValue vector_property_get<3> (JSContext* ctx, JSValueConst obj_val, JSAtom atom, JSValueConst receiver);
-template JSValue vector_property_get<4> (JSContext* ctx, JSValueConst obj_val, JSAtom atom, JSValueConst receiver);
-
-template <int components>
-int vector_property_set (
-    JSContext* ctx, JSValueConst obj_val, JSAtom atom, JSValueConst val, JSValueConst receiver, int flags
-) {
-    JSClassID classId = 0;
-    auto* container = static_cast<VectorOpaqueContainer<components>*> (JS_GetAnyOpaque (obj_val, &classId));
-
-    VEC_MAGIC_CHECK_ERROR (container, components);
-
-    int tag = JS_VALUE_GET_TAG (val);
-
-    // reading an undeclared property assigns undefined, keep the component as it was instead of throwing
-    if (tag == JS_TAG_UNDEFINED || tag == JS_TAG_NULL) {
-	return 0;
-    }
-
-    if (tag != JS_TAG_INT && !JS_TAG_IS_FLOAT64 (tag)) {
-	return -1;
-    }
-
-    const char* name = JS_AtomToCString (ctx, atom);
-
-    if (name == nullptr) {
-	return -1;
-    }
-
-    ScopeGuard guard ([=] { JS_FreeCString (ctx, name); });
-    auto vec = vector_get<components> (container->value);
-    void* into = nullptr;
-
-    if (strcmp (name, "x") == 0) {
-	if constexpr (
-	    std::is_same_v<decltype (vec), glm::vec2> || std::is_same_v<decltype (vec), glm::vec3>
-	    || std::is_same_v<decltype (vec), glm::vec4>
-	) {
-	    into = &vec.x;
-	} else if constexpr (std::is_same_v<decltype (vec), Color>) {
-	    into = &vec.r;
-	}
-    } else if (strcmp (name, "y") == 0) {
-	if constexpr (
-	    std::is_same_v<decltype (vec), glm::vec2> || std::is_same_v<decltype (vec), glm::vec3>
-	    || std::is_same_v<decltype (vec), glm::vec4>
-	) {
-	    into = &vec.y;
-	} else if constexpr (std::is_same_v<decltype (vec), Color>) {
-	    into = &vec.g;
-	}
-    } else if constexpr (components >= 3) {
-	if (strcmp (name, "z") == 0) {
-	    if constexpr (std::is_same_v<decltype (vec), glm::vec3> || std::is_same_v<decltype (vec), glm::vec4>) {
-		into = &vec.z;
-	    } else if constexpr (std::is_same_v<decltype (vec), Color>) {
-		into = &vec.b;
-	    }
-	} else if constexpr (components >= 4) {
-	    if (strcmp (name, "w") == 0) {
-		if constexpr (std::is_same_v<decltype (vec), glm::vec4>) {
-		    into = &vec.w;
-		} else if constexpr (std::is_same_v<decltype (vec), Color>) {
-		    into = &vec.a;
-		}
-	    }
-	}
-    }
-
-    if (into == nullptr) {
-	return -1;
-    }
-
-    double value = 0;
-
-    JS_ToFloat64 (ctx, &value, val);
-
-    *static_cast<float*> (into) = static_cast<float> (value);
-
-    container->value.update (vec, DynamicValue::UpdateSource::Script);
-
-    return 0;
-}
-
-template int vector_property_set<2> (
-    JSContext* ctx, JSValueConst obj_val, JSAtom atom, JSValueConst val, JSValueConst receiver, int flags
-);
-template int vector_property_set<3> (
-    JSContext* ctx, JSValueConst obj_val, JSAtom atom, JSValueConst val, JSValueConst receiver, int flags
-);
-template int vector_property_set<4> (
-    JSContext* ctx, JSValueConst obj_val, JSAtom atom, JSValueConst val, JSValueConst receiver, int flags
-);
-
-template <int components> JSValue vector_copy (JSContext* ctx, JSValueConst this_val, int argc, JSValueConst* argv) {
-    JSClassID classId = 0;
-
-    auto* container = static_cast<VectorOpaqueContainer<components>*> (JS_GetAnyOpaque (this_val, &classId));
-
-    VEC_MAGIC_CHECK_EXCEPTION (container, components);
-
-    return container->adapter.instantiate (container->value, true);
-}
-
-template JSValue vector_copy<2> (JSContext* ctx, JSValueConst this_val, int argc, JSValueConst* argv);
-template JSValue vector_copy<3> (JSContext* ctx, JSValueConst this_val, int argc, JSValueConst* argv);
-template JSValue vector_copy<4> (JSContext* ctx, JSValueConst this_val, int argc, JSValueConst* argv);
-
-bool vector_value_equals (JSContext* ctx, JSValue left, JSValue right) {
-    if (JS_TAG_IS_FLOAT64 (JS_VALUE_GET_TAG (left)) || JS_TAG_IS_FLOAT64 (JS_VALUE_GET_TAG (right))) {
-	double x1Val = 0.0f, x2Val = 0.0f;
-
-	JS_ToFloat64 (ctx, &x1Val, left);
-	JS_ToFloat64 (ctx, &x2Val, right);
-
-	if (std::abs (x1Val - x2Val) > 0.00001) {
-	    return false;
-	}
-    } else {
-	if (!JS_IsEqual (ctx, left, right)) {
-	    return false;
-	}
-    }
-
-    return true;
-}
-
-template <int components> JSValue vector_equals (JSContext* ctx, JSValueConst this_val, int argc, JSValueConst* argv) {
-    if (argc == 0) {
-	return JS_FALSE;
-    }
-
-    JSClassID classId = 0;
-
-    auto* container = static_cast<VectorOpaqueContainer<components>*> (JS_GetAnyOpaque (this_val, &classId));
-
-    VEC_MAGIC_CHECK_EXCEPTION (container, components);
-
-    JSValue other = argv[0];
-    auto* otherContainer = static_cast<VectorOpaqueContainer<components>*> (JS_GetAnyOpaque (other, &classId));
-
-    VEC_MAGIC_CHECK_EXCEPTION (otherContainer, components);
-
-    const auto vector = vector_get<components> (container->value);
-    const auto otherVector = vector_get<components> (otherContainer->value);
-
-    if constexpr (components == 2) {
-	return vector.x == otherVector.x && vector.y == otherVector.y ? JS_TRUE : JS_FALSE;
-    } else if constexpr (components == 3) {
-	return vector.x == otherVector.x && vector.y == otherVector.y && vector.z == otherVector.z ? JS_TRUE : JS_FALSE;
-    } else if constexpr (components == 4) {
-	return vector.x == otherVector.x && vector.y == otherVector.y && vector.z == otherVector.z
-		&& vector.w == otherVector.w
-	    ? JS_TRUE
-	    : JS_FALSE;
-    }
-
-    return JS_FALSE;
-}
-
-template JSValue vector_equals<2> (JSContext* ctx, JSValueConst this_val, int argc, JSValueConst* argv);
-template JSValue vector_equals<3> (JSContext* ctx, JSValueConst this_val, int argc, JSValueConst* argv);
-template JSValue vector_equals<4> (JSContext* ctx, JSValueConst this_val, int argc, JSValueConst* argv);
-
-template <int components> JSValue vector_length (JSContext* ctx, JSValueConst this_val, int argc, JSValueConst* argv) {
-    JSClassID classId = 0;
-
-    auto* container = static_cast<VectorOpaqueContainer<components>*> (JS_GetAnyOpaque (this_val, &classId));
-
-    VEC_MAGIC_CHECK_EXCEPTION (container, components);
-
-    return JS_NewFloat64 (ctx, glm::length (vector_get<components> (container->value)));
-}
-
-template JSValue vector_length<2> (JSContext* ctx, JSValueConst this_val, int argc, JSValueConst* argv);
-template JSValue vector_length<3> (JSContext* ctx, JSValueConst this_val, int argc, JSValueConst* argv);
-template JSValue vector_length<4> (JSContext* ctx, JSValueConst this_val, int argc, JSValueConst* argv);
-
 template <int components>
-JSValue vector_constructor (JSContext* ctx, JSValueConst new_target, int argc, JSValueConst* argv, int magic) {
-    auto it = vectorAdapterInstances<components>.find (magic);
-
-    if (it == vectorAdapterInstances<components>.end ()) {
-	return JS_ThrowTypeError (ctx, "invalid vector%d constructor instance", components);
-    }
-
-    JSValue result = it->second.instantiate ();
-    JSClassID classId = 0;
-    auto* container = static_cast<VectorOpaqueContainer<components>*> (JS_GetAnyOpaque (result, &classId));
-
-    VEC_MAGIC_CHECK_EXCEPTION (container, components);
-
-    // `new Vec3()` with no args is valid and expected to default to a zero vector - already
-    // zero-initialized above, so nothing further to do here.
-    if (argc > 1) {
-	// new Vec3(x, y, z): one number per component, anything not given stays 0
-	double parts[4] = { 0.0, 0.0, 0.0, 0.0 };
-
-	for (int i = 0; i < argc && i < components; i++) {
-	    JS_ToFloat64 (ctx, &parts[i], argv[i]);
-	}
-
-	if constexpr (components == 2) {
-	    container->value.update (glm::vec2 (parts[0], parts[1]), DynamicValue::UpdateSource::Initialization);
-	} else if constexpr (components == 3) {
-	    container->value.update (glm::vec3 (parts[0], parts[1], parts[2]), DynamicValue::UpdateSource::Initialization);
-	} else {
-	    container->value.update (
-		glm::vec4 (parts[0], parts[1], parts[2], parts[3]), DynamicValue::UpdateSource::Initialization
-	    );
-	}
-    } else if (argc > 0) {
-	container->value.update (vector_get<components> (ctx, argv[0]), DynamicValue::UpdateSource::Initialization);
-    }
-
-    return result;
-}
-
-template JSValue vector_constructor<2> (JSContext* ctx, JSValueConst this_val, int argc, JSValueConst* argv, int magic);
-template JSValue vector_constructor<3> (JSContext* ctx, JSValueConst this_val, int argc, JSValueConst* argv, int magic);
-template JSValue vector_constructor<4> (JSContext* ctx, JSValueConst this_val, int argc, JSValueConst* argv, int magic);
-
-template <int components> void vector_finalizer (JSRuntime* rt, JSValueConst val) {
-    JSClassID classId = 0;
-    const auto* container = static_cast<VectorOpaqueContainer<components>*> (JS_GetAnyOpaque (val, &classId));
-
-    if (!container || container->magic != (int)(VEC_OPAQUE_MAGIC + components)) {
-	return;
-    }
-
-    // free container and the associated DynamicValue if temporal
-    if (container->id != InvalidVectorInstanceId) {
-	container->adapter.free (container->id);
-    }
-
-    delete container;
-}
-
-template void vector_finalizer<2> (JSRuntime* rt, JSValueConst val);
-template void vector_finalizer<3> (JSRuntime* rt, JSValueConst val);
-template void vector_finalizer<4> (JSRuntime* rt, JSValueConst val);
-
-template <int components>
-JSValue vector_normalize (JSContext* ctx, JSValueConst this_val, int argc, JSValueConst* argv) {
-    JSClassID classId = 0;
-    const auto* container = static_cast<VectorOpaqueContainer<components>*> (JS_GetAnyOpaque (this_val, &classId));
-
-    VEC_MAGIC_CHECK_EXCEPTION (container, components);
-
-    JSValue newVector = container->adapter.instantiate (container->value, true);
-
-    const auto* newContainer = static_cast<VectorOpaqueContainer<components>*> (JS_GetAnyOpaque (newVector, &classId));
-
-    // glm::normalize of a zero vector is NaN, which poisons whatever property the script returns
-    const auto source = vector_get<components> (container->value);
-    const auto length = glm::length (source);
-
-    newContainer->value.update (
-	length > 0.0f ? source / length : glm::vec<components, float> (0.0f), DynamicValue::UpdateSource::Script
-    );
-
-    return newVector;
-}
-
-template JSValue vector_normalize<2> (JSContext* ctx, JSValueConst this_val, int argc, JSValueConst* argv);
-template JSValue vector_normalize<3> (JSContext* ctx, JSValueConst this_val, int argc, JSValueConst* argv);
-template JSValue vector_normalize<4> (JSContext* ctx, JSValueConst this_val, int argc, JSValueConst* argv);
-
-template <int components> JSValue vector_add (JSContext* ctx, JSValueConst this_val, int argc, JSValueConst* argv) {
-    if (argc != 1) {
-	return JS_UNDEFINED;
-    }
-
-    JSClassID classId = 0;
-    const auto* container = static_cast<VectorOpaqueContainer<components>*> (JS_GetAnyOpaque (this_val, &classId));
-
-    VEC_MAGIC_CHECK_EXCEPTION (container, components);
-
-    JSValue newVector = container->adapter.instantiate ();
-    const auto* newContainer = static_cast<VectorOpaqueContainer<components>*> (JS_GetAnyOpaque (newVector, &classId));
-
-    VEC_MAGIC_CHECK_EXCEPTION (newContainer, components);
-
-    newContainer->value.update (
-	vector_get<components> (ctx, argv[0]) + vector_get<components> (container->value),
-	DynamicValue::UpdateSource::Initialization
-    );
-
-    return newVector;
-}
-
-template JSValue vector_add<2> (JSContext* ctx, JSValueConst this_val, int argc, JSValueConst* argv);
-template JSValue vector_add<3> (JSContext* ctx, JSValueConst this_val, int argc, JSValueConst* argv);
-template JSValue vector_add<4> (JSContext* ctx, JSValueConst this_val, int argc, JSValueConst* argv);
-
-template <int components>
-JSValue vector_subtract (JSContext* ctx, JSValueConst this_val, int argc, JSValueConst* argv) {
-    if (argc != 1) {
-	return JS_UNDEFINED;
-    }
-
-    JSClassID classId = 0;
-    const auto* container = static_cast<VectorOpaqueContainer<components>*> (JS_GetAnyOpaque (this_val, &classId));
-
-    VEC_MAGIC_CHECK_EXCEPTION (container, components);
-
-    JSValue newVector = container->adapter.instantiate ();
-    const auto* newContainer = static_cast<VectorOpaqueContainer<components>*> (JS_GetAnyOpaque (newVector, &classId));
-
-    VEC_MAGIC_CHECK_EXCEPTION (newContainer, components);
-
-    newContainer->value.update (
-	vector_get<components> (ctx, argv[0]) - vector_get<components> (container->value),
-	DynamicValue::UpdateSource::Initialization
-    );
-
-    return newVector;
-}
-
-template JSValue vector_subtract<2> (JSContext* ctx, JSValueConst this_val, int argc, JSValueConst* argv);
-template JSValue vector_subtract<3> (JSContext* ctx, JSValueConst this_val, int argc, JSValueConst* argv);
-template JSValue vector_subtract<4> (JSContext* ctx, JSValueConst this_val, int argc, JSValueConst* argv);
-
-template <int components>
-JSValue vector_multiply (JSContext* ctx, JSValueConst this_val, int argc, JSValueConst* argv) {
-    if (argc != 1) {
-	return JS_UNDEFINED;
-    }
-
-    JSClassID classId = 0;
-    const auto* container = static_cast<VectorOpaqueContainer<components>*> (JS_GetAnyOpaque (this_val, &classId));
-
-    VEC_MAGIC_CHECK_EXCEPTION (container, components);
-
-    JSValue newVector = container->adapter.instantiate ();
-    const auto* newContainer = static_cast<VectorOpaqueContainer<components>*> (JS_GetAnyOpaque (newVector, &classId));
-
-    VEC_MAGIC_CHECK_EXCEPTION (newContainer, components);
-
-    newContainer->value.update (
-	vector_get<components> (ctx, argv[0]) * vector_get<components> (container->value),
-	DynamicValue::UpdateSource::Initialization
-    );
-
-    return newVector;
-}
-
-template JSValue vector_multiply<2> (JSContext* ctx, JSValueConst this_val, int argc, JSValueConst* argv);
-template JSValue vector_multiply<3> (JSContext* ctx, JSValueConst this_val, int argc, JSValueConst* argv);
-template JSValue vector_multiply<4> (JSContext* ctx, JSValueConst this_val, int argc, JSValueConst* argv);
-
-template <int components> JSValue vector_divide (JSContext* ctx, JSValueConst this_val, int argc, JSValueConst* argv) {
-    if (argc != 1) {
-	return JS_UNDEFINED;
-    }
-
-    JSClassID classId = 0;
-    const auto* container = static_cast<VectorOpaqueContainer<components>*> (JS_GetAnyOpaque (this_val, &classId));
-
-    VEC_MAGIC_CHECK_EXCEPTION (container, components);
-
-    JSValue newVector = container->adapter.instantiate ();
-    const auto* newContainer = static_cast<VectorOpaqueContainer<components>*> (JS_GetAnyOpaque (newVector, &classId));
-
-    VEC_MAGIC_CHECK_EXCEPTION (newContainer, components);
-
-    newContainer->value.update (
-	vector_get<components> (ctx, argv[0]) / vector_get<components> (container->value),
-	DynamicValue::UpdateSource::Initialization
-    );
-
-    return newVector;
-}
-
-template JSValue vector_divide<2> (JSContext* ctx, JSValueConst this_val, int argc, JSValueConst* argv);
-template JSValue vector_divide<3> (JSContext* ctx, JSValueConst this_val, int argc, JSValueConst* argv);
-template JSValue vector_divide<4> (JSContext* ctx, JSValueConst this_val, int argc, JSValueConst* argv);
-
-template <int components> JSValue vector_dot (JSContext* ctx, JSValueConst this_val, int argc, JSValueConst* argv) {
-    if (argc != 1) {
-	return JS_UNDEFINED;
-    }
-
-    JSClassID classId = 0;
-    const auto* container = static_cast<VectorOpaqueContainer<components>*> (JS_GetAnyOpaque (this_val, &classId));
-
-    VEC_MAGIC_CHECK_EXCEPTION (container, components);
-
-    JSValue newVector = container->adapter.instantiate ();
-    const auto* newContainer = static_cast<VectorOpaqueContainer<components>*> (JS_GetAnyOpaque (newVector, &classId));
-
-    VEC_MAGIC_CHECK_EXCEPTION (newContainer, components);
-
-    newContainer->value.update (
-	glm::dot (vector_get<components> (ctx, argv[0]), vector_get<components> (container->value)),
-	DynamicValue::UpdateSource::Initialization
-    );
-
-    return newVector;
-}
-
-template JSValue vector_dot<2> (JSContext* ctx, JSValueConst this_val, int argc, JSValueConst* argv);
-template JSValue vector_dot<3> (JSContext* ctx, JSValueConst this_val, int argc, JSValueConst* argv);
-template JSValue vector_dot<4> (JSContext* ctx, JSValueConst this_val, int argc, JSValueConst* argv);
-
-template <int components> JSValue vector_cross (JSContext* ctx, JSValueConst this_val, int argc, JSValueConst* argv) {
-    if (argc != 1) {
-	return JS_UNDEFINED;
-    }
-
-    JSClassID classId = 0;
-    const auto* container = static_cast<VectorOpaqueContainer<components>*> (JS_GetAnyOpaque (this_val, &classId));
-
-    VEC_MAGIC_CHECK_EXCEPTION (container, components);
-
-    JSValue newVector = container->adapter.instantiate ();
-    const auto* newContainer = static_cast<VectorOpaqueContainer<components>*> (JS_GetAnyOpaque (newVector, &classId));
-
-    VEC_MAGIC_CHECK_EXCEPTION (newContainer, components);
-
-    newContainer->value.update (
-	glm::cross (vector_get<components> (ctx, argv[0]), vector_get<components> (container->value)),
-	DynamicValue::UpdateSource::Initialization
-    );
-
-    return newVector;
-}
-
-template JSValue vector_cross<3> (JSContext* ctx, JSValueConst this_val, int argc, JSValueConst* argv);
-
-template <int components> JSValue vector_mix (JSContext* ctx, JSValueConst this_val, int argc, JSValueConst* argv) {
-    if (argc != 2) {
-	return JS_ThrowTypeError (ctx, "vector_mix: wrong number of arguments");
-    }
-
-    if (!JS_IsNumber (argv[1])) {
-	return JS_ThrowTypeError (ctx, "vector_mix: invalid argument");
-    }
-
-    double amount = 0.0f;
-
-    JS_ToFloat64 (ctx, &amount, argv[1]);
-
-    JSClassID classId = 0;
-    const auto* container = static_cast<VectorOpaqueContainer<components>*> (JS_GetAnyOpaque (this_val, &classId));
-
-    VEC_MAGIC_CHECK_EXCEPTION (container, components);
-
-    JSValue newVector = container->adapter.instantiate ();
-    const auto* newContainer = static_cast<VectorOpaqueContainer<components>*> (JS_GetAnyOpaque (newVector, &classId));
-
-    VEC_MAGIC_CHECK_EXCEPTION (newContainer, components);
-
-    newContainer->value.update (
-	glm::mix (vector_get<components> (ctx, argv[0]), vector_get<components> (container->value), amount),
-	DynamicValue::UpdateSource::Initialization
-    );
-
-    return newVector;
-}
-
-template JSValue vector_mix<2> (JSContext* ctx, JSValueConst this_val, int argc, JSValueConst* argv);
-template JSValue vector_mix<3> (JSContext* ctx, JSValueConst this_val, int argc, JSValueConst* argv);
-template JSValue vector_mix<4> (JSContext* ctx, JSValueConst this_val, int argc, JSValueConst* argv);
-
-template <int components> JSValue vector_min (JSContext* ctx, JSValueConst this_val, int argc, JSValueConst* argv) {
-    if (argc != 1) {
-	return JS_UNDEFINED;
-    }
-
-    JSClassID classId = 0;
-    const auto* container = static_cast<VectorOpaqueContainer<components>*> (JS_GetAnyOpaque (this_val, &classId));
-
-    VEC_MAGIC_CHECK_EXCEPTION (container, components);
-
-    JSValue newVector = container->adapter.instantiate ();
-    const auto* newContainer = static_cast<VectorOpaqueContainer<components>*> (JS_GetAnyOpaque (newVector, &classId));
-
-    VEC_MAGIC_CHECK_EXCEPTION (newContainer, components);
-
-    newContainer->value.update (
-	glm::min (vector_get<components> (ctx, argv[0]), vector_get<components> (container->value)),
-	DynamicValue::UpdateSource::Initialization
-    );
-
-    return newVector;
-}
-
-template JSValue vector_min<2> (JSContext* ctx, JSValueConst this_val, int argc, JSValueConst* argv);
-template JSValue vector_min<3> (JSContext* ctx, JSValueConst this_val, int argc, JSValueConst* argv);
-template JSValue vector_min<4> (JSContext* ctx, JSValueConst this_val, int argc, JSValueConst* argv);
-
-template <int components> JSValue vector_max (JSContext* ctx, JSValueConst this_val, int argc, JSValueConst* argv) {
-    if (argc != 1) {
-	return JS_UNDEFINED;
-    }
-
-    JSClassID classId = 0;
-    const auto* container = static_cast<VectorOpaqueContainer<components>*> (JS_GetAnyOpaque (this_val, &classId));
-
-    VEC_MAGIC_CHECK_EXCEPTION (container, components);
-
-    JSValue newVector = container->adapter.instantiate ();
-    const auto* newContainer = static_cast<VectorOpaqueContainer<components>*> (JS_GetAnyOpaque (newVector, &classId));
-
-    VEC_MAGIC_CHECK_EXCEPTION (newContainer, components);
-
-    newContainer->value.update (
-	glm::max (vector_get<components> (ctx, argv[0]), vector_get<components> (container->value)),
-	DynamicValue::UpdateSource::Initialization
-    );
-
-    return newVector;
-}
-
-template JSValue vector_max<2> (JSContext* ctx, JSValueConst this_val, int argc, JSValueConst* argv);
-template JSValue vector_max<3> (JSContext* ctx, JSValueConst this_val, int argc, JSValueConst* argv);
-template JSValue vector_max<4> (JSContext* ctx, JSValueConst this_val, int argc, JSValueConst* argv);
-
-template <int components> JSValue vector_abs (JSContext* ctx, JSValueConst this_val, int argc, JSValueConst* argv) {
-    JSClassID classId = 0;
-    const auto* container = static_cast<VectorOpaqueContainer<components>*> (JS_GetAnyOpaque (this_val, &classId));
-
-    VEC_MAGIC_CHECK_EXCEPTION (container, components);
-
-    JSValue newVector = container->adapter.instantiate ();
-    const auto* newContainer = static_cast<VectorOpaqueContainer<components>*> (JS_GetAnyOpaque (newVector, &classId));
-
-    VEC_MAGIC_CHECK_EXCEPTION (newContainer, components);
-
-    newContainer->value.update (
-	glm::abs (vector_get<components> (container->value)), DynamicValue::UpdateSource::Initialization
-    );
-
-    return newVector;
-}
-
-template JSValue vector_abs<2> (JSContext* ctx, JSValueConst this_val, int argc, JSValueConst* argv);
-template JSValue vector_abs<3> (JSContext* ctx, JSValueConst this_val, int argc, JSValueConst* argv);
-template JSValue vector_abs<4> (JSContext* ctx, JSValueConst this_val, int argc, JSValueConst* argv);
-
-template <int components> JSValue vector_sign (JSContext* ctx, JSValueConst this_val, int argc, JSValueConst* argv) {
-    JSClassID classId = 0;
-    const auto* container = static_cast<VectorOpaqueContainer<components>*> (JS_GetAnyOpaque (this_val, &classId));
-
-    VEC_MAGIC_CHECK_EXCEPTION (container, components);
-
-    JSValue newVector = container->adapter.instantiate ();
-    const auto* newContainer = static_cast<VectorOpaqueContainer<components>*> (JS_GetAnyOpaque (newVector, &classId));
-
-    VEC_MAGIC_CHECK_EXCEPTION (newContainer, components);
-
-    newContainer->value.update (
-	glm::sign (vector_get<components> (container->value)), DynamicValue::UpdateSource::Initialization
-    );
-
-    return newVector;
-}
-
-template JSValue vector_sign<2> (JSContext* ctx, JSValueConst this_val, int argc, JSValueConst* argv);
-template JSValue vector_sign<3> (JSContext* ctx, JSValueConst this_val, int argc, JSValueConst* argv);
-template JSValue vector_sign<4> (JSContext* ctx, JSValueConst this_val, int argc, JSValueConst* argv);
-
-template <int components> JSValue vector_round (JSContext* ctx, JSValueConst this_val, int argc, JSValueConst* argv) {
-    JSClassID classId = 0;
-    const auto* container = static_cast<VectorOpaqueContainer<components>*> (JS_GetAnyOpaque (this_val, &classId));
-
-    VEC_MAGIC_CHECK_EXCEPTION (container, components);
-
-    JSValue newVector = container->adapter.instantiate ();
-    const auto* newContainer = static_cast<VectorOpaqueContainer<components>*> (JS_GetAnyOpaque (newVector, &classId));
-
-    VEC_MAGIC_CHECK_EXCEPTION (newContainer, components);
-
-    newContainer->value.update (
-	glm::round (vector_get<components> (container->value)), DynamicValue::UpdateSource::Initialization
-    );
-
-    return newVector;
-}
-
-template JSValue vector_round<2> (JSContext* ctx, JSValueConst this_val, int argc, JSValueConst* argv);
-template JSValue vector_round<3> (JSContext* ctx, JSValueConst this_val, int argc, JSValueConst* argv);
-template JSValue vector_round<4> (JSContext* ctx, JSValueConst this_val, int argc, JSValueConst* argv);
-
-template <int components> JSValue vector_floor (JSContext* ctx, JSValueConst this_val, int argc, JSValueConst* argv) {
-    JSClassID classId = 0;
-    const auto* container = static_cast<VectorOpaqueContainer<components>*> (JS_GetAnyOpaque (this_val, &classId));
-
-    VEC_MAGIC_CHECK_EXCEPTION (container, components);
-
-    JSValue newVector = container->adapter.instantiate ();
-    const auto* newContainer = static_cast<VectorOpaqueContainer<components>*> (JS_GetAnyOpaque (newVector, &classId));
-
-    VEC_MAGIC_CHECK_EXCEPTION (newContainer, components);
-
-    newContainer->value.update (
-	glm::floor (vector_get<components> (container->value)), DynamicValue::UpdateSource::Initialization
-    );
-
-    return newVector;
-}
-
-template JSValue vector_floor<2> (JSContext* ctx, JSValueConst this_val, int argc, JSValueConst* argv);
-template JSValue vector_floor<3> (JSContext* ctx, JSValueConst this_val, int argc, JSValueConst* argv);
-template JSValue vector_floor<4> (JSContext* ctx, JSValueConst this_val, int argc, JSValueConst* argv);
-
-template <int components> JSValue vector_ceil (JSContext* ctx, JSValueConst this_val, int argc, JSValueConst* argv) {
-    JSClassID classId = 0;
-    const auto* container = static_cast<VectorOpaqueContainer<components>*> (JS_GetAnyOpaque (this_val, &classId));
-
-    VEC_MAGIC_CHECK_EXCEPTION (container, components);
-
-    JSValue newVector = container->adapter.instantiate ();
-    const auto* newContainer = static_cast<VectorOpaqueContainer<components>*> (JS_GetAnyOpaque (newVector, &classId));
-    const auto vector = vector_get<components> (container->value);
-
-    VEC_MAGIC_CHECK_EXCEPTION (newContainer, components);
-
-    newContainer->value.update (glm::ceil (vector), DynamicValue::UpdateSource::Initialization);
-
-    return newVector;
-}
-
-template JSValue vector_ceil<2> (JSContext* ctx, JSValueConst this_val, int argc, JSValueConst* argv);
-template JSValue vector_ceil<3> (JSContext* ctx, JSValueConst this_val, int argc, JSValueConst* argv);
-template JSValue vector_ceil<4> (JSContext* ctx, JSValueConst this_val, int argc, JSValueConst* argv);
-
-template <int components>
-JSValue vector_toString (JSContext* ctx, JSValueConst this_val, int argc, JSValueConst* argv) {
-    JSClassID classId = 0;
-    const auto* container = static_cast<VectorOpaqueContainer<components>*> (JS_GetAnyOpaque (this_val, &classId));
-
-    VEC_MAGIC_CHECK_EXCEPTION (container, components);
-
-    return JS_NewString (ctx, container->value.toString ().c_str ());
-}
-
-template JSValue vector_toString<2> (JSContext* ctx, JSValueConst this_val, int argc, JSValueConst* argv);
-template JSValue vector_toString<3> (JSContext* ctx, JSValueConst this_val, int argc, JSValueConst* argv);
-template JSValue vector_toString<4> (JSContext* ctx, JSValueConst this_val, int argc, JSValueConst* argv);
+VectorAdapter<components>::VectorAdapter (ScriptEngine& engine) :
+    ObjectAdapter (engine), m_prototypeName ("_Vec" + std::to_string (components)) { }
 
-// the class is exotic with no own properties, JSON.stringify would otherwise produce "{}".
-// WE's vectors are plain JS objects, so they serialize as their components
-template <int components>
-JSValue vector_toJSON (JSContext* ctx, JSValueConst this_val, int argc, JSValueConst* argv) {
-    JSClassID classId = 0;
-    auto* container = static_cast<VectorOpaqueContainer<components>*> (JS_GetAnyOpaque (this_val, &classId));
+template <int components> JSValue VectorAdapter<components>::create (const glm::vec<components, float>& value) const {
+    static constexpr const char* names[] = { "x", "y", "z", "w" };
 
-    VEC_MAGIC_CHECK_EXCEPTION (container, components);
+    JSContext* ctx = this->m_engine.getContext ();
+    JSValue prototype = JS_GetPropertyStr (ctx, this->m_engine.getGlobalThis (), this->m_prototypeName.c_str ());
+    JSValue result = JS_IsObject (prototype) ? JS_NewObjectProto (ctx, prototype) : JS_NewObject (ctx);
 
-    static constexpr const char* names[] = { "x", "y", "z", "w" };
-    const auto value = vector_get<components> (container->value);
-    JSValue result = JS_NewObject (ctx);
+    JS_FreeValue (ctx, prototype);
 
     for (int i = 0; i < components; i++) {
 	JS_SetPropertyStr (ctx, result, names[i], JS_NewFloat64 (ctx, value[i]));
@@ -851,206 +25,30 @@ JSValue vector_toJSON (JSContext* ctx, JSValueConst this_val, int argc, JSValueC
     return result;
 }
 
-template <int components>
-VectorAdapter<components>::VectorAdapter (ScriptEngine& engine) :
-    ObjectAdapter (engine), m_instanceId (++VectorAdapterInstanceId), m_name ("Vec" + std::to_string (components)),
-    m_exoticMethods (
-	{
-	    .get_property = vector_property_get<components>,
-	    .set_property = vector_property_set<components>,
-	}
-    ) {
-    vectorAdapterInstances<components>.emplace (this->m_instanceId, *this);
-    this->registerType (
-	{
-	    .class_name = this->m_name.c_str (),
-	    .finalizer = vector_finalizer<components>,
-	    .exotic = &this->m_exoticMethods,
-	}
-    );
-
-    m_prototype = JS_NewObject (this->m_engine.getContext ());
-
-    JS_DupValue (this->m_engine.getContext (), m_prototype);
-
-    JSValue ctor = JS_NewCFunctionMagic (
-	this->m_engine.getContext (), vector_constructor<components>, this->m_name.c_str (), 1,
-	JS_CFUNC_constructor_magic, this->m_instanceId
-    );
-
-    JS_SetConstructor (this->m_engine.getContext (), ctor, m_prototype);
-    JS_DefinePropertyValueStr (
-	this->m_engine.getContext (), m_prototype, "copy",
-	JS_NewCFunction (this->m_engine.getContext (), vector_copy<components>, "copy", 0), JS_PROP_ENUMERABLE
-    );
-    JS_DefinePropertyValueStr (
-	this->m_engine.getContext (), m_prototype, "equals",
-	JS_NewCFunction (this->m_engine.getContext (), vector_equals<components>, "equals", 1), JS_PROP_ENUMERABLE
-    );
-    JS_DefinePropertyValueStr (
-	this->m_engine.getContext (), m_prototype, "length",
-	JS_NewCFunction (this->m_engine.getContext (), vector_length<components>, "length", 0), JS_PROP_ENUMERABLE
-    );
-    JS_DefinePropertyValueStr (
-	this->m_engine.getContext (), m_prototype, "lengthSqr",
-	JS_NewCFunction (this->m_engine.getContext (), vector_length<components>, "lengthSqr", 0), JS_PROP_ENUMERABLE
-    );
-    JS_DefinePropertyValueStr (
-	this->m_engine.getContext (), m_prototype, "normalize",
-	JS_NewCFunction (this->m_engine.getContext (), vector_normalize<components>, "normalize", 0), JS_PROP_ENUMERABLE
-    );
-    JS_DefinePropertyValueStr (
-	this->m_engine.getContext (), m_prototype, "add",
-	JS_NewCFunction (this->m_engine.getContext (), vector_add<components>, "add", 1), JS_PROP_ENUMERABLE
-    );
-    JS_DefinePropertyValueStr (
-	this->m_engine.getContext (), m_prototype, "subtract",
-	JS_NewCFunction (this->m_engine.getContext (), vector_subtract<components>, "subtract", 1), JS_PROP_ENUMERABLE
-    );
-    JS_DefinePropertyValueStr (
-	this->m_engine.getContext (), m_prototype, "multiply",
-	JS_NewCFunction (this->m_engine.getContext (), vector_multiply<components>, "multiply", 1), JS_PROP_ENUMERABLE
-    );
-    JS_DefinePropertyValueStr (
-	this->m_engine.getContext (), m_prototype, "divide",
-	JS_NewCFunction (this->m_engine.getContext (), vector_divide<components>, "divide", 1), JS_PROP_ENUMERABLE
-    );
-    JS_DefinePropertyValueStr (
-	this->m_engine.getContext (), m_prototype, "dot",
-	JS_NewCFunction (this->m_engine.getContext (), vector_dot<components>, "dot", 1), JS_PROP_ENUMERABLE
-    );
-    if constexpr (components == 3) {
-	JS_DefinePropertyValueStr (
-	    this->m_engine.getContext (), m_prototype, "cross",
-	    JS_NewCFunction (this->m_engine.getContext (), vector_cross<components>, "cross", 1), JS_PROP_ENUMERABLE
-	);
-    }
-    JS_DefinePropertyValueStr (
-	this->m_engine.getContext (), m_prototype, "mix",
-	JS_NewCFunction (this->m_engine.getContext (), vector_mix<components>, "mix", 2), JS_PROP_ENUMERABLE
-    );
-    JS_DefinePropertyValueStr (
-	this->m_engine.getContext (), m_prototype, "min",
-	JS_NewCFunction (this->m_engine.getContext (), vector_min<components>, "min", 1), JS_PROP_ENUMERABLE
-    );
-    JS_DefinePropertyValueStr (
-	this->m_engine.getContext (), m_prototype, "max",
-	JS_NewCFunction (this->m_engine.getContext (), vector_max<components>, "max", 1), JS_PROP_ENUMERABLE
-    );
-    JS_DefinePropertyValueStr (
-	this->m_engine.getContext (), m_prototype, "abs",
-	JS_NewCFunction (this->m_engine.getContext (), vector_abs<components>, "abs", 0), JS_PROP_ENUMERABLE
-    );
-    JS_DefinePropertyValueStr (
-	this->m_engine.getContext (), m_prototype, "sign",
-	JS_NewCFunction (this->m_engine.getContext (), vector_sign<components>, "sign", 0), JS_PROP_ENUMERABLE
-    );
-    JS_DefinePropertyValueStr (
-	this->m_engine.getContext (), m_prototype, "round",
-	JS_NewCFunction (this->m_engine.getContext (), vector_round<components>, "round", 0), JS_PROP_ENUMERABLE
-    );
-    JS_DefinePropertyValueStr (
-	this->m_engine.getContext (), m_prototype, "floor",
-	JS_NewCFunction (this->m_engine.getContext (), vector_floor<components>, "floor", 0), JS_PROP_ENUMERABLE
-    );
-    JS_DefinePropertyValueStr (
-	this->m_engine.getContext (), m_prototype, "ceil",
-	JS_NewCFunction (this->m_engine.getContext (), vector_ceil<components>, "ceil", 0), JS_PROP_ENUMERABLE
-    );
-    JS_DefinePropertyValueStr (
-	this->m_engine.getContext (), m_prototype, "toString",
-	JS_NewCFunction (this->m_engine.getContext (), vector_toString<components>, "toString", 0), JS_PROP_ENUMERABLE
-    );
-    JS_DefinePropertyValueStr (
-	this->m_engine.getContext (), m_prototype, "toJSON",
-	JS_NewCFunction (this->m_engine.getContext (), vector_toJSON<components>, "toJSON", 0), JS_PROP_ENUMERABLE
-    );
-
-    JS_SetClassProto (this->m_engine.getContext (), this->m_classId, m_prototype);
-
-    // JS_SetConstructor only wires up prototype<->constructor for instanceof; without exposing
-    // ctor as a global here, `new Vec3(...)` didn't exist, and scripts using it at module top
-    // level (not inside a function) would throw ReferenceError during module evaluation, killing
-    // that script before init()/update() ever ran.
-    JS_DefinePropertyValueStr (
-	this->m_engine.getContext (), this->m_engine.getGlobalThis (), this->m_name.c_str (), ctor, JS_PROP_ENUMERABLE
-    );
-}
-
-template <int components> VectorAdapter<components>::~VectorAdapter () {
-    vectorAdapterInstances<components>.erase (this->m_instanceId);
-
-    // Runs after ScriptEngine has freed the JS runtime/context, so m_prototype and everything
-    // else tied to it is already gone - nothing left to release here.
-}
-
 template <int components> JSValue VectorAdapter<components>::instantiate (ScriptableObject& object) {
-    throw std::runtime_error ("Cannot create a Vector4 instance from a ScriptableObject");
+    throw std::runtime_error ("Cannot create a vector instance from a ScriptableObject");
 }
 
 template <int components> JSValue VectorAdapter<components>::instantiate (DynamicValue& value) {
-    JSValue result = this->ObjectAdapter::instantiate (value);
-    JS_SetOpaque (
-	result,
-	new VectorOpaqueContainer<components> {
-	    .magic = VEC_OPAQUE_MAGIC + components,
-	    .adapter = *this,
-	    .value = value,
-	    .id = InvalidVectorInstanceId,
-	}
-    );
-
-    return result;
+    if constexpr (components == 2) {
+	return this->create (value.getVec2 ());
+    } else if constexpr (components == 3) {
+	return this->create (value.getVec3 ());
+    } else {
+	return this->create (value.getVec4 ());
+    }
 }
 
 template <int components> JSValue VectorAdapter<components>::instantiate (DynamicValue& source, bool temporal) {
-    auto value = std::make_unique<DynamicValue> (source);
-    uint32_t id = ++VectorInstanceId;
-    JSValue result = this->ObjectAdapter::instantiate (*value);
-    JS_SetOpaque (
-	result,
-	new VectorOpaqueContainer<components> {
-	    .magic = VEC_OPAQUE_MAGIC + components,
-	    .adapter = *this,
-	    .value = *value,
-	    .id = id,
-	}
-    );
-
-    this->m_values.emplace (id, std::move (value));
-
-    return result;
+    return this->instantiate (source);
 }
 
 template <int components> JSValue VectorAdapter<components>::instantiate () {
-    auto value = std::make_unique<DynamicValue> (vector_new<components> ());
-    uint32_t id = ++VectorInstanceId;
-    JSValue result = this->ObjectAdapter::instantiate (*value);
-    JS_SetOpaque (
-	result,
-	new VectorOpaqueContainer<components> {
-	    .magic = VEC_OPAQUE_MAGIC + components,
-	    .adapter = *this,
-	    .value = *value,
-	    .id = id,
-	}
-    );
-
-    this->m_values.emplace (id, std::move (value));
-
-    return result;
-}
-
-template <int components> void VectorAdapter<components>::free (uint32_t vectorId) {
-    auto it = this->m_values.find (vectorId);
-
-    if (it != this->m_values.end ()) {
-	this->m_values.erase (it);
-    }
+    return this->create (glm::vec<components, float> (0.0f));
 }
 
 namespace WallpaperEngine::Scripting::Adapters {
 template class VectorAdapter<2>;
 template class VectorAdapter<3>;
 template class VectorAdapter<4>;
-} // namespace WallpaperEngine::Scripting::Adapters
+}

+ 13 - 12
src/WallpaperEngine/Scripting/Adapters/VectorAdapter.h

@@ -3,32 +3,33 @@
 #include "ObjectAdapter.h"
 #include "WallpaperEngine/Data/Model/Types.h"
 
+#include <glm/glm.hpp>
+#include <string>
+
 namespace WallpaperEngine::Scripting::Adapters {
+/**
+ * Hands native vectors to scripts as instances of WE's own Vec2/Vec3/Vec4 classes
+ * (assets scripts/jsclasses/baseclasses.js), the same way scenescript64 does it: a plain
+ * object on the _VecN prototype with x/y/z/w copied in. They're always copies.
+ */
 template <int components> class VectorAdapter : public ObjectAdapter {
 public:
     explicit VectorAdapter (ScriptEngine& engine);
-    ~VectorAdapter () override;
+    ~VectorAdapter () override = default;
 
     int length () { return components; }
     JSValue instantiate (Data::Model::DynamicValue& value) override;
     JSValue instantiate (ScriptableObject& object) override;
-    /**
-     * @return A new, anonymous JSValue representing the vector
-     */
     JSValue instantiate (Data::Model::DynamicValue& source, bool temporal);
     JSValue instantiate ();
 
-    void free (uint32_t vectorId);
-
 private:
-    JSValue m_prototype;
-    uint32_t m_instanceId;
-    std::string m_name;
-    JSClassExoticMethods m_exoticMethods;
-    std::map<uint32_t, Data::Model::DynamicValueUniquePtr> m_values;
+    JSValue create (const glm::vec<components, float>& value) const;
+
+    std::string m_prototypeName;
 };
 
 extern template class VectorAdapter<2>;
 extern template class VectorAdapter<3>;
 extern template class VectorAdapter<4>;
-}
+}

+ 2 - 2
src/WallpaperEngine/Scripting/ConsoleObject.cpp

@@ -62,11 +62,11 @@ ConsoleObject::ConsoleObject (ScriptEngine& engine, Render::Wallpapers::CScene&
     JS_SetOpaque (this->m_instance, this);
     JS_DefinePropertyValueStr (
 	this->m_engine.getContext (), this->m_instance, "log",
-	JS_NewCFunction (this->m_engine.getContext (), console_log, "log", 1), JS_PROP_ENUMERABLE
+	JS_NewCFunction (this->m_engine.getContext (), console_log, "log", 1), JS_PROP_C_W_E
     );
     JS_DefinePropertyValueStr (
 	this->m_engine.getContext (), this->m_instance, "error",
-	JS_NewCFunction (this->m_engine.getContext (), console_error, "error", 1), JS_PROP_ENUMERABLE
+	JS_NewCFunction (this->m_engine.getContext (), console_error, "error", 1), JS_PROP_C_W_E
     );
 }
 

+ 0 - 253
src/WallpaperEngine/Scripting/Modules/ColorModule.cpp

@@ -1,253 +0,0 @@
-#include "ColorModule.h"
-
-#include "WallpaperEngine/Scripting/ScriptEngine.h"
-
-using namespace WallpaperEngine::Scripting::Modules;
-
-#define min_f(a, b, c) (fminf (a, fminf (b, c)))
-#define max_f(a, b, c) (fmaxf (a, fmaxf (b, c)))
-
-static uint32_t ColorModuleInstanceId = 0;
-std::map<uint32_t, ColorModule&> colorModules;
-
-JSValue wecolor_rgb2hsv (JSContext* ctx, JSValueConst this_val, int argc, JSValueConst* argv, int magic);
-JSValue wecolor_hsv2rgb (JSContext* ctx, JSValueConst this_val, int argc, JSValueConst* argv, int magic);
-JSValue wecolor_normalizecolor (JSContext* ctx, JSValueConst this_val, int argc, JSValueConst* argv, int magic);
-JSValue wecolor_expandcolor (JSContext* ctx, JSValueConst this_val, int argc, JSValueConst* argv, int magic);
-
-int wecolor_init (JSContext* ctx, JSModuleDef* m) {
-    JS_SetModuleExport (
-	ctx, m, "rgb2hsv", JS_NewCFunctionMagic (ctx, wecolor_rgb2hsv, "rgb2hsv", 1, JS_CFUNC_generic_magic, 0)
-    );
-    JS_SetModuleExport (
-	ctx, m, "hsv2rgb", JS_NewCFunctionMagic (ctx, wecolor_hsv2rgb, "hsv2rgb", 1, JS_CFUNC_generic_magic, 0)
-    );
-    JS_SetModuleExport (
-	ctx, m, "normalizeColor", JS_NewCFunctionMagic (ctx, wecolor_normalizecolor, "normalizeColor", 1, JS_CFUNC_generic_magic, 0)
-    );
-    JS_SetModuleExport (
-	ctx, m, "expandColor", JS_NewCFunctionMagic (ctx, wecolor_expandcolor, "expandColor", 1, JS_CFUNC_generic_magic, 0)
-    );
-
-    return 0;
-}
-
-JSValue wecolor_rgb2hsv (JSContext* ctx, JSValueConst this_val, int argc, JSValueConst* argv, int magic) {
-    if (argc != 1) {
-	return JS_ThrowTypeError (ctx, "wecolor_rgb2hsv: wrong number of arguments");
-    }
-
-    if (JS_VALUE_GET_TAG (argv[0]) != JS_TAG_OBJECT) {
-	return JS_ThrowTypeError (ctx, "wecolor_rgb2hsv: invalid argument");
-    }
-
-    JSValue x = JS_GetPropertyStr (ctx, argv[0], "x");
-    JSValue y = JS_GetPropertyStr (ctx, argv[0], "y");
-    JSValue z = JS_GetPropertyStr (ctx, argv[0], "z");
-
-    double xVal = 0.0f, yVal = 0.0f, zVal = 0.0f;
-
-    JS_ToFloat64 (ctx, &xVal, x);
-    JS_ToFloat64 (ctx, &yVal, y);
-    JS_ToFloat64 (ctx, &zVal, z);
-
-    // conversion code from https://gist.github.com/yoggy/8999625
-    float h, s, v; // h:0-360.0, s:0.0-1.0, v:0.0-1.0
-
-    float max = max_f (xVal, yVal, zVal);
-    float min = min_f (xVal, yVal, zVal);
-
-    v = max;
-
-    if (max == 0.0f) {
-	s = 0;
-	h = 0;
-    } else if (max - min == 0.0f) {
-	s = 0;
-	h = 0;
-    } else {
-	s = (max - min) / max;
-
-	if (max == xVal) {
-	    h = 60 * ((yVal - zVal) / (max - min)) + 0;
-	} else if (max == yVal) {
-	    h = 60 * ((zVal - xVal) / (max - min)) + 120;
-	} else {
-	    h = 60 * ((xVal - yVal) / (max - min)) + 240;
-	}
-    }
-
-    if (h < 0) {
-	h += 360.0f;
-    }
-
-    const auto it = colorModules.find (magic);
-
-    if (it == colorModules.end ()) {
-	return JS_UNDEFINED;
-    }
-
-    JSValue value = it->second.getEngine ().getAdapters ().vec3->instantiate ();
-
-    JS_SetPropertyStr (ctx, value, "x", JS_NewFloat64 (ctx, h));
-    JS_SetPropertyStr (ctx, value, "y", JS_NewFloat64 (ctx, s));
-    JS_SetPropertyStr (ctx, value, "z", JS_NewFloat64 (ctx, v));
-
-    return value;
-}
-
-JSValue wecolor_hsv2rgb (JSContext* ctx, JSValueConst this_val, int argc, JSValueConst* argv, int magic) {
-    if (argc != 1) {
-	return JS_ThrowTypeError (ctx, "wecolor_hsv2rgb: wrong number of arguments");
-    }
-
-    if (JS_VALUE_GET_TAG (argv[0]) != JS_TAG_OBJECT) {
-	return JS_ThrowTypeError (ctx, "wecolor_hsv2rgb: invalid argument");
-    }
-
-    JSValue x = JS_GetPropertyStr (ctx, argv[0], "x");
-    JSValue y = JS_GetPropertyStr (ctx, argv[0], "y");
-    JSValue z = JS_GetPropertyStr (ctx, argv[0], "z");
-
-    double xVal = 0.0f, yVal = 0.0f, zVal = 0.0f;
-
-    JS_ToFloat64 (ctx, &xVal, x);
-    JS_ToFloat64 (ctx, &yVal, y);
-    JS_ToFloat64 (ctx, &zVal, z);
-
-    // conversion code from https://gist.github.com/yoggy/8999625
-    float r, g, b; // 0.0-1.0
-
-    int hi = (int)(xVal / 60.0f) % 6;
-    float f = (xVal / 60.0f) - hi;
-    float p = zVal * (1.0f - yVal);
-    float q = zVal * (1.0f - yVal * f);
-    float t = zVal * (1.0f - yVal * (1.0f - f));
-
-    switch (hi) {
-	case 0:
-	    r = zVal, g = t, b = p;
-	    break;
-	case 1:
-	    r = q, g = zVal, b = p;
-	    break;
-	case 2:
-	    r = p, g = zVal, b = t;
-	    break;
-	case 3:
-	    r = p, g = q, b = zVal;
-	    break;
-	case 4:
-	    r = t, g = p, b = zVal;
-	    break;
-	case 5:
-	    r = zVal, g = p, b = q;
-	    break;
-	default:
-	    r = 0, g = 0, b = 0;
-	    break;
-    }
-
-    const auto it = colorModules.find (magic);
-
-    if (it == colorModules.end ()) {
-	return JS_UNDEFINED;
-    }
-
-    JSValue value = it->second.getEngine ().getAdapters ().vec3->instantiate ();
-
-    JS_SetPropertyStr (ctx, value, "x", JS_NewFloat64 (ctx, r));
-    JS_SetPropertyStr (ctx, value, "y", JS_NewFloat64 (ctx, g));
-    JS_SetPropertyStr (ctx, value, "z", JS_NewFloat64 (ctx, b));
-
-    return value;
-}
-
-JSValue wecolor_normalizecolor (JSContext* ctx, JSValueConst this_val, int argc, JSValueConst* argv, int magic) {
-    if (argc != 1) {
-	return JS_ThrowTypeError (ctx, "wecolor_normalizecolor: wrong number of arguments");
-    }
-
-    if (JS_VALUE_GET_TAG (argv[0]) != JS_TAG_OBJECT) {
-	return JS_ThrowTypeError (ctx, "wecolor_normalizecolor: invalid argument");
-    }
-
-    const auto it = colorModules.find (magic);
-
-    if (it == colorModules.end ()) {
-	return JS_UNDEFINED;
-    }
-
-    JSValue x = JS_GetPropertyStr (ctx, argv[0], "x");
-    JSValue y = JS_GetPropertyStr (ctx, argv[0], "y");
-    JSValue z = JS_GetPropertyStr (ctx, argv[0], "z");
-
-    if (!JS_IsNumber (x) || !JS_IsNumber (y) || !JS_IsNumber (z)) {
-	return JS_ThrowTypeError (ctx, "wecolor_normalizecolor: invalid argument");
-    }
-
-    double xVal = 0.0f, yVal = 0.0f, zVal = 0.0f;
-
-    JS_ToFloat64 (ctx, &xVal, x);
-    JS_ToFloat64 (ctx, &yVal, y);
-    JS_ToFloat64 (ctx, &zVal, z);
-
-    JSValue value = it->second.getEngine ().getAdapters ().vec3->instantiate ();
-
-    JS_SetPropertyStr (ctx, value, "x", JS_NewFloat64 (ctx, xVal / 255.0f));
-    JS_SetPropertyStr (ctx, value, "y", JS_NewFloat64 (ctx, yVal / 255.0f));
-    JS_SetPropertyStr (ctx, value, "z", JS_NewFloat64 (ctx, zVal / 255.0f));
-
-    return value;
-}
-
-JSValue wecolor_expandcolor (JSContext* ctx, JSValueConst this_val, int argc, JSValueConst* argv, int magic) {
-    if (argc != 1) {
-	return JS_ThrowTypeError (ctx, "wecolor_expandcolor: wrong number of arguments");
-    }
-
-    if (JS_VALUE_GET_TAG (argv[0]) != JS_TAG_OBJECT) {
-	return JS_ThrowTypeError (ctx, "wecolor_expandcolor: invalid argument");
-    }
-
-    const auto it = colorModules.find (magic);
-
-    if (it == colorModules.end ()) {
-	return JS_UNDEFINED;
-    }
-
-    JSValue x = JS_GetPropertyStr (ctx, argv[0], "x");
-    JSValue y = JS_GetPropertyStr (ctx, argv[0], "y");
-    JSValue z = JS_GetPropertyStr (ctx, argv[0], "z");
-
-    if (!JS_IsNumber (x) || !JS_IsNumber (y) || !JS_IsNumber (z)) {
-	return JS_ThrowTypeError (ctx, "wecolor_expandcolor: invalid argument");
-    }
-
-    double xVal = 0.0f, yVal = 0.0f, zVal = 0.0f;
-
-    JS_ToFloat64 (ctx, &xVal, x);
-    JS_ToFloat64 (ctx, &yVal, y);
-    JS_ToFloat64 (ctx, &zVal, z);
-
-    JSValue value = it->second.getEngine ().getAdapters ().vec3->instantiate ();
-
-    JS_SetPropertyStr (ctx, value, "x", JS_NewFloat64 (ctx, xVal * 255.0f));
-    JS_SetPropertyStr (ctx, value, "y", JS_NewFloat64 (ctx, yVal * 255.0f));
-    JS_SetPropertyStr (ctx, value, "z", JS_NewFloat64 (ctx, zVal * 255.0f));
-
-    return value;
-}
-
-ColorModule::ColorModule (ScriptEngine& engine) : ScriptModule (engine, "WEColor", wecolor_init) {
-    this->m_instanceId = ++ColorModuleInstanceId;
-
-    JS_AddModuleExport (this->getEngine ().getContext (), this->getDefinition (), "rgb2hsv");
-    JS_AddModuleExport (this->getEngine ().getContext (), this->getDefinition (), "hsv2rgb");
-    JS_AddModuleExport (this->getEngine ().getContext (), this->getDefinition (), "normalizeColor");
-    JS_AddModuleExport (this->getEngine ().getContext (), this->getDefinition (), "expandColor");
-
-    colorModules.emplace (this->m_instanceId, *this);
-}
-
-ColorModule::~ColorModule () { colorModules.erase (this->m_instanceId); }

+ 0 - 16
src/WallpaperEngine/Scripting/Modules/ColorModule.h

@@ -1,16 +0,0 @@
-#pragma once
-#include "ScriptModule.h"
-
-namespace WallpaperEngine::Scripting {
-class ScriptEngine;
-}
-namespace WallpaperEngine::Scripting::Modules {
-class ColorModule : public ScriptModule {
-public:
-    ColorModule (ScriptEngine& engine);
-    ~ColorModule () override;
-
-protected:
-    uint32_t m_instanceId;
-};
-}

+ 0 - 80
src/WallpaperEngine/Scripting/Modules/MathModule.cpp

@@ -1,80 +0,0 @@
-#include "MathModule.h"
-
-#include "WallpaperEngine/Scripting/ScriptEngine.h"
-
-using namespace WallpaperEngine::Scripting::Modules;
-
-#define min_f(a, b, c) (fminf (a, fminf (b, c)))
-#define max_f(a, b, c) (fmaxf (a, fmaxf (b, c)))
-
-static uint32_t MathModuleInstanceId = 0;
-std::map<uint32_t, MathModule&> mathModules;
-
-JSValue wemath_smoothstep (JSContext* ctx, JSValueConst this_val, int argc, JSValueConst* argv, int magic);
-JSValue wemath_mix (JSContext* ctx, JSValueConst this_val, int argc, JSValueConst* argv, int magic);
-
-int wemath_init (JSContext* ctx, JSModuleDef* m) {
-    JS_SetModuleExport (
-	ctx, m, "smoothStep", JS_NewCFunctionMagic (ctx, wemath_smoothstep, "smoothStep", 3, JS_CFUNC_generic_magic, 0)
-    );
-    JS_SetModuleExport (
-	ctx, m, "mix", JS_NewCFunctionMagic (ctx, wemath_mix, "mix", 3, JS_CFUNC_generic_magic, 0)
-    );
-    JS_SetModuleExport (ctx, m, "deg2rad", JS_NewFloat64 (ctx, 0.01745329251994329576923690768489));
-    JS_SetModuleExport (ctx, m, "rad2deg", JS_NewFloat64 (ctx, 57.295779513082320876798154814105));
-
-    return 0;
-}
-
-JSValue wemath_smoothstep (JSContext* ctx, JSValueConst this_val, int argc, JSValueConst* argv, int magic) {
-    if (argc != 3) {
-	return JS_ThrowTypeError (ctx, "wemath_smoothstep: wrong number of arguments");
-    }
-
-    if (!JS_IsNumber (argv[0]) || !JS_IsNumber (argv[1]) || !JS_IsNumber (argv[2])) {
-	return JS_ThrowTypeError (ctx, "wemath_smoothstep: invalid argument");
-    }
-
-    double edge0 = 0.0f;
-    double edge1 = 1.0f;
-    double x = 0.0f;
-
-    JS_ToFloat64 (ctx, &edge0, argv[0]);
-    JS_ToFloat64 (ctx, &edge1, argv[1]);
-    JS_ToFloat64 (ctx, &x, argv[2]);
-
-    return JS_NewFloat64 (ctx, glm::smoothstep (edge0, edge1, x));
-}
-
-JSValue wemath_mix (JSContext* ctx, JSValueConst this_val, int argc, JSValueConst* argv, int magic) {
-    if (argc != 3) {
-	return JS_ThrowTypeError (ctx, "wemath_mix: wrong number of arguments");
-    }
-
-    if (!JS_IsNumber (argv[0]) || !JS_IsNumber (argv[1]) || !JS_IsNumber (argv[2])) {
-	return JS_ThrowTypeError (ctx, "wemath_mix: invalid argument");
-    }
-
-    double a = 0.0f;
-    double b = 1.0f;
-    double value = 0.0f;
-
-    JS_ToFloat64 (ctx, &a, argv[0]);
-    JS_ToFloat64 (ctx, &b, argv[1]);
-    JS_ToFloat64 (ctx, &value, argv[2]);
-
-    return JS_NewFloat64 (ctx, glm::mix (a, b, value));
-}
-
-MathModule::MathModule (ScriptEngine& engine) : ScriptModule (engine, "WEMath", wemath_init) {
-    this->m_instanceId = ++MathModuleInstanceId;
-
-    JS_AddModuleExport (this->getEngine ().getContext (), this->getDefinition (), "smoothStep");
-    JS_AddModuleExport (this->getEngine ().getContext (), this->getDefinition (), "mix");
-    JS_AddModuleExport (this->getEngine ().getContext (), this->getDefinition (), "deg2rad");
-    JS_AddModuleExport (this->getEngine ().getContext (), this->getDefinition (), "rad2deg");
-
-    mathModules.emplace (this->m_instanceId, *this);
-}
-
-MathModule::~MathModule () { mathModules.erase (this->m_instanceId); }

+ 0 - 16
src/WallpaperEngine/Scripting/Modules/MathModule.h

@@ -1,16 +0,0 @@
-#pragma once
-#include "ScriptModule.h"
-
-namespace WallpaperEngine::Scripting {
-class ScriptEngine;
-}
-namespace WallpaperEngine::Scripting::Modules {
-class MathModule : public ScriptModule {
-public:
-    MathModule (ScriptEngine& engine);
-    ~MathModule () override;
-
-protected:
-    uint32_t m_instanceId;
-};
-}

+ 0 - 12
src/WallpaperEngine/Scripting/Modules/ScriptModule.cpp

@@ -1,12 +0,0 @@
-#include "ScriptModule.h"
-
-#include "WallpaperEngine/Scripting/ScriptEngine.h"
-
-using namespace WallpaperEngine::Scripting::Modules;
-
-ScriptModule::ScriptModule (ScriptEngine& engine, const std::string& name, JSModuleInitFunc* init) :
-    m_name (name), m_engine (engine) {
-    this->m_definition = JS_NewCModule (this->m_engine.getContext (), this->m_name.c_str (), init);
-}
-
-ScriptModule::~ScriptModule () { }

+ 0 - 26
src/WallpaperEngine/Scripting/Modules/ScriptModule.h

@@ -1,26 +0,0 @@
-#pragma once
-
-#include "quickjs.h"
-
-#include <string>
-
-namespace WallpaperEngine::Scripting {
-class ScriptEngine;
-}
-namespace WallpaperEngine::Scripting::Modules {
-class ScriptModule {
-public:
-    virtual ~ScriptModule ();
-
-    ScriptEngine& getEngine () const { return m_engine; }
-    const std::string& getName () const { return m_name; }
-    JSModuleDef* getDefinition () const { return m_definition; }
-
-protected:
-    ScriptModule (ScriptEngine& engine, const std::string& name, JSModuleInitFunc* init);
-
-    const std::string m_name;
-    ScriptEngine& m_engine;
-    JSModuleDef* m_definition;
-};
-}

+ 62 - 1
src/WallpaperEngine/Scripting/SceneObject.cpp

@@ -6,7 +6,10 @@
 #include "WallpaperEngine/Data/Utils/ScopeGuard.h"
 #include "WallpaperEngine/Render/Wallpapers/CScene.h"
 
+#include <optional>
+
 using namespace WallpaperEngine::Scripting;
+using JSON = WallpaperEngine::Data::JSON::JSON;
 
 SceneObject* get_opaque (JSValueConst this_val) {
     JSClassID classId;
@@ -294,12 +297,70 @@ JSValue get_layer_index (JSContext* ctx, JSValueConst this_val, int argc, JSValu
     return JS_NewInt32 (ctx, container->getScene ().getObjectIndex (layer));
 }
 
+// WE turns a configuration object into scene.json object form with _Internal.stringifyConfig from baseclasses.js
+std::optional<JSON> stringify_layer_config (JSContext* ctx, JSValueConst config) {
+    const JSValue global = JS_GetGlobalObject (ctx);
+    const JSValue internal = JS_GetPropertyStr (ctx, global, "_Internal");
+    const JSValue stringify = JS_GetPropertyStr (ctx, internal, "stringifyConfig");
+    JSValue result = JS_UNDEFINED;
+
+    if (JS_IsFunction (ctx, stringify)) {
+	result = JS_Call (ctx, stringify, internal, 1, &config);
+    }
+
+    JS_FreeValue (ctx, stringify);
+    JS_FreeValue (ctx, internal);
+    JS_FreeValue (ctx, global);
+
+    if (JS_IsException (result)) {
+	JS_FreeValue (ctx, JS_GetException (ctx));
+	return std::nullopt;
+    }
+
+    const char* text = JS_IsString (result) ? JS_ToCString (ctx, result) : nullptr;
+    JS_FreeValue (ctx, result);
+
+    if (text == nullptr) {
+	return std::nullopt;
+    }
+
+    ScopeGuard guard ([=] { JS_FreeCString (ctx, text); });
+    JSON parsed = JSON::parse (text, nullptr, false);
+
+    if (!parsed.is_object ()) {
+	return std::nullopt;
+    }
+
+    return parsed;
+}
+
 JSValue create_layer (JSContext* ctx, JSValueConst this_val, int argc, JSValueConst* argv) {
-    if (argc != 1 || !JS_IsString (argv[0])) {
+    if (argc != 1) {
 	return JS_UNDEFINED;
     }
 
     auto* container = get_opaque (this_val);
+
+    if (JS_IsObject (argv[0])) {
+	const auto config = stringify_layer_config (ctx, argv[0]);
+
+	if (!config.has_value ()) {
+	    return JS_UNDEFINED;
+	}
+
+	auto* object = container->getScene ().createLayerFromConfig (*config);
+
+	if (object == nullptr || !object->is<ScriptableObject> ()) {
+	    return JS_UNDEFINED;
+	}
+
+	return container->getEngine ().getAdapters ().object->instantiate (*object->as<ScriptableObject> ());
+    }
+
+    if (!JS_IsString (argv[0])) {
+	return JS_UNDEFINED;
+    }
+
     const char* path = JS_ToCString (ctx, argv[0]);
 
     if (path == nullptr) {

+ 95 - 31
src/WallpaperEngine/Scripting/ScriptEngine.cpp

@@ -4,9 +4,6 @@
 #include "WallpaperEngine/Media/ThumbnailPalette.h"
 
 #include "Adapters/ScriptableObjectAdapter.h"
-#include "Modules/ColorModule.h"
-#include "Modules/MathModule.h"
-#include "Modules/ScriptModule.h"
 #include "ScriptPropertiesObject.h"
 #include "ScriptableObject.h"
 #include "WallpaperEngine/Audio/AudioContext.h"
@@ -23,12 +20,14 @@
 
 #include <algorithm>
 #include <array>
+#include <cctype>
 #include <cerrno>
 #include <chrono>
 #include <cmath>
 #include <cstdio>
 #include <cstring>
 #include <ctime>
+#include <filesystem>
 #include <future>
 #include <optional>
 #include <poll.h>
@@ -76,17 +75,48 @@ void scriptengine_dump (JSContext* ctx, JSValueConst obj) {
     js_free (ctx, props);
 }
 
+std::optional<std::string> ScriptEngine::readScriptAsset (const std::string& path) const {
+    try {
+	return this->m_scene.getAssetLocator ().readString (path);
+    } catch (std::filesystem::filesystem_error& e) {
+	sLog.error ("ScriptEngine: cannot read ", path, ": ", e.what ());
+	return std::nullopt;
+    }
+}
+
+// same lookup as scenescript64: the import name lowercased under scripts/jsmodules/, ".js" appended
+// unless the name already has it ('WEMath' -> scripts/jsmodules/wemath.js)
 JSModuleDef* scriptengine_module_loader (JSContext* ctx, const char* module, void* opaque) {
     const auto* scriptEngine = static_cast<ScriptEngine*> (opaque);
 
-    const auto& modules = scriptEngine->getModules ();
-    const auto it = modules.find (module);
+    std::string name (module);
+    std::ranges::transform (name, name.begin (), [] (unsigned char c) { return std::tolower (c); });
+
+    std::string path = "scripts/jsmodules/" + name;
+
+    if (name.find (".js") == std::string::npos) {
+	path += ".js";
+    }
+
+    const auto source = scriptEngine->readScriptAsset (path);
+
+    if (!source.has_value ()) {
+	JS_ThrowReferenceError (ctx, "could not load module '%s'", module);
+	return nullptr;
+    }
+
+    JSValue compiled
+	= JS_Eval (ctx, source->c_str (), source->size (), module, JS_EVAL_TYPE_MODULE | JS_EVAL_FLAG_COMPILE_ONLY);
 
-    if (it == modules.end ()) {
+    if (JS_IsException (compiled)) {
 	return nullptr;
     }
 
-    return it->second->getDefinition ();
+    auto* definition = static_cast<JSModuleDef*> (JS_VALUE_GET_PTR (compiled));
+
+    JS_FreeValue (ctx, compiled);
+
+    return definition;
 }
 
 JSValue ScriptEngine::dynamicToJs (DynamicValue& value) const {
@@ -138,6 +168,24 @@ JSValue ScriptEngine::userPropertyToJs (Property& property) const {
     return this->dynamicToJs (property);
 }
 
+// real WE spreads a number returned for a vector property over every component and the property stays a
+// vector, so the next update() still gets a Vec
+static bool updateVectorFromNumber (DynamicValue& source, float number) {
+    switch (source.getType ()) {
+	case DynamicValue::Vec4:
+	    source.update (glm::vec4 (number), DynamicValue::UpdateSource::Script);
+	    return true;
+	case DynamicValue::Vec3:
+	    source.update (glm::vec3 (number), DynamicValue::UpdateSource::Script);
+	    return true;
+	case DynamicValue::Vec2:
+	    source.update (glm::vec2 (number), DynamicValue::UpdateSource::Script);
+	    return true;
+	default:
+	    return false;
+    }
+}
+
 static void jsToDynamicValue (JSContext* ctx, JSValue val, DynamicValue& source) {
     if (JS_IsException (val)) {
 	return;
@@ -156,7 +204,9 @@ static void jsToDynamicValue (JSContext* ctx, JSValue val, DynamicValue& source)
     }
 
     if (tag == JS_TAG_INT) {
-	source.update (JS_VALUE_GET_INT (val), DynamicValue::UpdateSource::Script);
+	if (!updateVectorFromNumber (source, static_cast<float> (JS_VALUE_GET_INT (val)))) {
+	    source.update (JS_VALUE_GET_INT (val), DynamicValue::UpdateSource::Script);
+	}
 	return;
     }
 
@@ -166,7 +216,11 @@ static void jsToDynamicValue (JSContext* ctx, JSValue val, DynamicValue& source)
     }
 
     if (JS_TAG_IS_FLOAT64 (tag)) {
-	source.update (static_cast<float> (JS_VALUE_GET_FLOAT64 (val)), DynamicValue::UpdateSource::Script);
+	const auto number = static_cast<float> (JS_VALUE_GET_FLOAT64 (val));
+
+	if (!updateVectorFromNumber (source, number)) {
+	    source.update (number, DynamicValue::UpdateSource::Script);
+	}
 	return;
     }
 
@@ -189,8 +243,16 @@ static void jsToDynamicValue (JSContext* ctx, JSValue val, DynamicValue& source)
 	    JS_FreeValue (ctx, w);
 	});
 
+	// runs every frame, a bad value from one script must not take the whole wallpaper down
 	if (!JS_IsNumber (x) || !JS_IsNumber (y)) {
-	    sLog.exception ("Vector's x and y components must be numbers");
+	    static bool reported = false;
+
+	    if (!reported) {
+		reported = true;
+		sLog.error ("Script returned a vector without numeric x and y components, keeping the previous value");
+	    }
+
+	    return;
 	}
 
 	double xVal = 0.0f, yVal = 0.0f, zVal = 0.0f, wVal = 0.0f;
@@ -250,6 +312,19 @@ ScriptEngine::ScriptEngine (Wallpapers::CScene& scene, Media::MediaSource& media
 
     this->m_globalThis = JS_GetGlobalObject (this->m_context);
 
+    // WE's own Vec2/Vec3/Vec4/Mat3/Mat4 classes, the natives below only create instances of them
+    if (const auto baseClasses = this->readScriptAsset ("scripts/jsclasses/baseclasses.js"); baseClasses.has_value ()) {
+	JSValue result = JS_Eval (
+	    this->m_context, baseClasses->c_str (), baseClasses->size (), "baseclasses.js", JS_EVAL_TYPE_GLOBAL
+	);
+
+	if (JS_IsException (result)) {
+	    logJSException (this->m_context, "baseclasses.js", baseClasses);
+	}
+
+	JS_FreeValue (this->m_context, result);
+    }
+
     this->m_adapters = {
 	.vec4 = std::unique_ptr<Adapters::VectorAdapter<4>> (new Adapters::VectorAdapter<4> (*this)),
 	.vec3 = std::unique_ptr<Adapters::VectorAdapter<3>> (new Adapters::VectorAdapter<3> (*this)),
@@ -264,28 +339,22 @@ ScriptEngine::ScriptEngine (Wallpapers::CScene& scene, Media::MediaSource& media
     this->m_consoleObject = std::make_unique<ConsoleObject> (*this, scene);
     this->m_scriptPropertiesObject = std::make_unique<ScriptPropertiesObject> (*this, scene);
 
-    auto wemath = std::make_unique<Modules::MathModule> (*this);
-    auto wecolor = std::make_unique<Modules::ColorModule> (*this);
-
-    this->m_modules.emplace (wemath->getName (), std::move (wemath));
-    this->m_modules.emplace (wecolor->getName (), std::move (wecolor));
-
     JS_SetModuleLoaderFunc (this->m_runtime, nullptr, scriptengine_module_loader, this);
     this->installBuiltins ();
     JS_DefinePropertyValueStr (
-	this->m_context, this->m_globalThis, "engine", this->m_engineObject->getInstance (), JS_PROP_ENUMERABLE
+	this->m_context, this->m_globalThis, "engine", this->m_engineObject->getInstance (), JS_PROP_C_W_E
     );
     JS_DefinePropertyValueStr (
-	this->m_context, this->m_globalThis, "input", this->m_inputObject->getInstance (), JS_PROP_ENUMERABLE
+	this->m_context, this->m_globalThis, "input", this->m_inputObject->getInstance (), JS_PROP_C_W_E
     );
     JS_DefinePropertyValueStr (
-	this->m_context, this->m_globalThis, "thisScene", this->m_sceneObject->getInstance (), JS_PROP_ENUMERABLE
+	this->m_context, this->m_globalThis, "thisScene", this->m_sceneObject->getInstance (), JS_PROP_C_W_E
     );
     JS_DefinePropertyValueStr (
-	this->m_context, this->m_globalThis, "console", this->m_consoleObject->getInstance (), JS_PROP_ENUMERABLE
+	this->m_context, this->m_globalThis, "console", this->m_consoleObject->getInstance (), JS_PROP_C_W_E
     );
     JS_DefinePropertyValueStr (
-	this->m_context, this->m_globalThis, "shared", JS_NewObject (this->m_context), JS_PROP_ENUMERABLE
+	this->m_context, this->m_globalThis, "shared", JS_NewObject (this->m_context), JS_PROP_C_W_E
     );
 }
 
@@ -309,7 +378,6 @@ ScriptEngine::~ScriptEngine () {
     this->m_inputObject.reset ();
     this->m_sceneObject.reset ();
     this->m_scriptPropertiesObject.reset ();
-    this->m_modules.clear ();
     this->m_scriptModules.clear ();
 
     if (this->m_context) {
@@ -319,9 +387,8 @@ ScriptEngine::~ScriptEngine () {
 	JS_FreeRuntime (this->m_runtime);
     }
 
-    // Freeing the runtime above runs a GC pass that finalizes every still-live vector object,
-    // each calling back into its owning VectorAdapter::free() to release the DynamicValue behind
-    // it - so the adapters must outlive JS_FreeRuntime(), or those finalizers hit freed memory.
+    // Freeing the runtime above runs a GC pass that finalizes every still-live layer object, whose
+    // class belongs to the object adapter - so the adapters must outlive JS_FreeRuntime().
     this->m_adapters.vec4.reset ();
     this->m_adapters.vec3.reset ();
     this->m_adapters.vec2.reset ();
@@ -1129,14 +1196,11 @@ bool ScriptEngine::hasCursorHandlers (const ScriptableObject& object) {
 void ScriptEngine::dispatchCursorEvent (
     const char* handler, ScriptableObject& object, const glm::vec2& worldPosition, const glm::vec2& localPosition
 ) {
+    // Vec3 per WE's lib.sceneScript.d.ts, scripts call add()/subtract() on them
     const auto makePosition = [this] (const glm::vec2& value) {
-	JSValue result = JS_NewObject (this->m_context);
+	DynamicValue position (glm::vec3 (value, 0.0f));
 
-	JS_SetPropertyStr (this->m_context, result, "x", JS_NewFloat64 (this->m_context, value.x));
-	JS_SetPropertyStr (this->m_context, result, "y", JS_NewFloat64 (this->m_context, value.y));
-	JS_SetPropertyStr (this->m_context, result, "z", JS_NewFloat64 (this->m_context, 0.0));
-
-	return result;
+	return this->m_adapters.vec3->instantiate (position);
     };
 
     for (auto& [key, module] : this->m_scriptModules) {

+ 2 - 3
src/WallpaperEngine/Scripting/ScriptEngine.h

@@ -5,7 +5,6 @@
 #include "ConsoleObject.h"
 #include "EngineObject.h"
 #include "InputObject.h"
-#include "Modules/ScriptModule.h"
 #include "SceneObject.h"
 
 #include <chrono>
@@ -180,7 +179,8 @@ public:
     [[nodiscard]] bool hasScript (const DynamicValue& value) const;
     const JSObjectAdapters& getAdapters () const { return m_adapters; }
     const Render::Wallpapers::CScene& getScene () const { return m_scene; }
-    const std::map<std::string, std::unique_ptr<Modules::ScriptModule>>& getModules () const { return m_modules; }
+    /** Reads a file through the wallpaper's asset locator (project first, then the assets dir), nullopt if it is missing */
+    std::optional<std::string> readScriptAsset (const std::string& path) const;
 
 private:
     JSValue call (JSValue module, int argc, JSValueConst argv[], const char* name);
@@ -207,7 +207,6 @@ private:
     std::unique_ptr<ConsoleObject> m_consoleObject;
     std::unique_ptr<ScriptPropertiesObject> m_scriptPropertiesObject;
 
-    std::map<std::string, std::unique_ptr<Modules::ScriptModule>> m_modules = {};
     std::map<std::string, LoadedModule> m_scriptModules = {};
     std::vector<std::string> m_retiredScriptKeys = {};
 

+ 0 - 5
src/WallpaperEngine/Scripting/resources/builtins.js

@@ -30,8 +30,3 @@ globalThis.localStorage = globalThis.localStorage || {
     }
   }
 };
-globalThis.MediaPlaybackEvent = globalThis.MediaPlaybackEvent || {
-  PLAYBACK_STOPPED: 0,
-  PLAYBACK_PLAYING: 1,
-  PLAYBACK_PAUSED: 2
-};

+ 22 - 3
src/WallpaperEngine/Testing/Cases/ParticleNoise.cpp

@@ -15,8 +15,9 @@ TEST_CASE ("Simplex noise is zero on lattice points") {
 
 TEST_CASE ("Simplex noise stays in range and is continuous") {
     float min1 = 0.0f, max1 = 0.0f, min3 = 0.0f, max3 = 0.0f;
+    // only the 1D noise is continuous, WE's 3D hash (see simplexNoise3D) jumps at some cell borders
     float biggestStep = 0.0f;
-    float previous = simplexNoise3D (-50.0f, 3.1f - 50.0f * 0.37f, 7.7f + 50.0f * 0.21f);
+    float previous = simplexNoise1D (-50.0f);
 
     for (int i = 0; i < 20000; i++) {
 	const float t = -50.0f + static_cast<float> (i) * 0.005f;
@@ -27,8 +28,8 @@ TEST_CASE ("Simplex noise stays in range and is continuous") {
 	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;
+	biggestStep = std::max (biggestStep, std::abs (one - previous));
+	previous = one;
     }
 
     CHECK (min1 >= -1.1f);
@@ -38,3 +39,21 @@ TEST_CASE ("Simplex noise stays in range and is continuous") {
     CHECK (max3 - min3 > 1.0f);
     CHECK (biggestStep < 0.1f);
 }
+
+TEST_CASE ("3D simplex noise matches wallpaper64.exe") {
+    // sub_1400FD010 run under emulation with these inputs
+    struct Sample {
+	float x, y, z, expected;
+    };
+    const Sample samples[] = {
+	{ -36.5635757f, 34.7433739f, 26.3774624f, 0.26951617f },
+	{ -24.4930973f, -0.456491292f, -5.05089331f, 0.000486342702f },
+	{ 26.2280083f, -49.7893944f, -5.46128082f, -0.404468179f },
+	{ -28.3400612f, -7.78834248f, -47.0959206f, -0.373473674f },
+	{ 33.7577972f, 5.64543247f, 14.2294359f, -0.48249346f },
+    };
+
+    for (const auto& sample : samples) {
+	CHECK (std::abs (simplexNoise3D (sample.x, sample.y, sample.z) - sample.expected) < 1e-5f);
+    }
+}