Bläddra i källkod

feat: add particle rendering support (#387)

* feat: add particle system data structures and parsing
* feat: implement particle system rendering with OpenGL
* feat: add AlphaChannelPriority texture flag for RG88/R8 formats
* fix: use per-emitter timers and respect emitter origin positions
* fix: restore GL blend and depth mask state after particle rendering
* chore: add logging for unknown particle initializer and operator types
* chore: pass particle count to operators to skip unused slots
* fix: clamp large delta times to prevent particle freeze
* chore: store transformed origin locally
* chore: remove unused m_emissionTimer field
* fix: add destructor to clean up OpenGL resources
* chore: save and restore blend function state
* chore: restore full GL blend/texture/buffer state
* fix: avoid normalizing zero vectors in sphere emitter
* fix: save GL state before modifying it
* chore: remove JSON dependency from particle renderer
* feat: particle spritesheet animation support
* fix: replace non-portable M_PI with GLM constants
* fix: guard random distribution against swapped bounds
* fix: guard fmod against zero duration
* fix: prevent instantaneous emitters from re-firing
* chore: correct namespace comment in TextureProvider.h
* perf: cache uniform locations in particle renderer
* chore: use correct literal type for double m_time
* fix: calculate spritesheet grid from texture and frame dimensions
* feat: add particle instance override property support with UserSettings
* fix: avoid normalizing property-driven particle colors
Aziz Hasanain 11 månader sedan
förälder
incheckning
fe6acd3152

+ 2 - 0
CMakeLists.txt

@@ -395,6 +395,8 @@ add_executable(
     src/WallpaperEngine/Render/Objects/CImage.cpp
     src/WallpaperEngine/Render/Objects/CSound.h
     src/WallpaperEngine/Render/Objects/CSound.cpp
+    src/WallpaperEngine/Render/Objects/CParticle.h
+    src/WallpaperEngine/Render/Objects/CParticle.cpp
 
     src/WallpaperEngine/Render/CFBO.h
     src/WallpaperEngine/Render/CFBO.cpp

+ 12 - 1
src/WallpaperEngine/Data/Assets/Texture.h

@@ -90,9 +90,10 @@ enum TextureFlags {
     TextureFlags_ClampUVs = 2,
     TextureFlags_IsGif = 4,
     TextureFlags_ClampUVsBorder = 8,
+    TextureFlags_AlphaChannelPriority = 524288, // Indicates RG88/R8 format where alpha is in G/R channel
     TextureFlags_All =
         TextureFlags_NoInterpolation | TextureFlags_ClampUVs |
-        TextureFlags_IsGif | TextureFlags_ClampUVsBorder,
+        TextureFlags_IsGif | TextureFlags_ClampUVsBorder | TextureFlags_AlphaChannelPriority,
 };
 
 struct Mipmap {
@@ -163,8 +164,18 @@ struct Texture {
     /** List of animation frames */
     std::vector<FrameSharedPtr> frames {};
 
+    /** Spritesheet grid data (from .tex-json metadata) */
+    uint32_t spritesheetCols = 0;
+    uint32_t spritesheetRows = 0;
+    uint32_t spritesheetFrames = 0;
+    float spritesheetDuration = 0.0f;
+
     [[nodiscard]] bool isAnimated () const {
         return (flags & TextureFlags_IsGif) == TextureFlags_IsGif;
     }
+
+    [[nodiscard]] bool hasSpritesheet () const {
+        return spritesheetFrames > 0;
+    }
 };
 }

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

@@ -12,6 +12,7 @@
 #include "Model.h"
 #include "Effect.h"
 #include "WallpaperEngine/Data/Utils/TypeCaster.h"
+#include <memory>
 
 namespace WallpaperEngine::Data::Model {
 using namespace WallpaperEngine::Data::Utils;
@@ -132,4 +133,233 @@ class Sound : public Object, public SoundData {
     explicit Sound (ObjectData data, SoundData soundData) noexcept : Object (std::move(data)), SoundData (std::move (soundData)) {};
     ~Sound () override = default;
 };
+
+/**
+ * Particle control points for forces and positions
+ */
+struct ParticleControlPoint {
+    int id;
+    uint32_t flags;
+    glm::vec3 offset;
+};
+
+/**
+ * Particle emitter configuration
+ */
+struct ParticleEmitter {
+    int id;
+    std::string name;
+    glm::vec3 directions;
+    glm::vec3 distanceMin;
+    glm::vec3 distanceMax;
+    glm::vec3 origin;
+    glm::ivec3 sign;
+    uint32_t instantaneous;
+    float speedMin;
+    float speedMax;
+    float rate;
+    int controlPoint;
+    uint32_t flags;
+};
+
+/**
+ * Particle initializer base and implementations
+ */
+class ParticleInitializerBase : public TypeCaster {
+  public:
+    virtual ~ParticleInitializerBase () = default;
+};
+
+class ColorRandomInitializer : public ParticleInitializerBase {
+  public:
+    ColorRandomInitializer (UserSettingUniquePtr min, UserSettingUniquePtr max) : min (std::move (min)), max (std::move (max)) {}
+    UserSettingUniquePtr min;
+    UserSettingUniquePtr max;
+};
+
+class SizeRandomInitializer : public ParticleInitializerBase {
+  public:
+    SizeRandomInitializer (UserSettingUniquePtr min, UserSettingUniquePtr max) : min (std::move (min)), max (std::move (max)) {}
+    UserSettingUniquePtr min;
+    UserSettingUniquePtr max;
+};
+
+class AlphaRandomInitializer : public ParticleInitializerBase {
+  public:
+    AlphaRandomInitializer (UserSettingUniquePtr min, UserSettingUniquePtr max) : min (std::move (min)), max (std::move (max)) {}
+    UserSettingUniquePtr min;
+    UserSettingUniquePtr max;
+};
+
+class LifetimeRandomInitializer : public ParticleInitializerBase {
+  public:
+    LifetimeRandomInitializer (UserSettingUniquePtr min, UserSettingUniquePtr max) : min (std::move (min)), max (std::move (max)) {}
+    UserSettingUniquePtr min;
+    UserSettingUniquePtr max;
+};
+
+class VelocityRandomInitializer : public ParticleInitializerBase {
+  public:
+    VelocityRandomInitializer (UserSettingUniquePtr min, UserSettingUniquePtr max) : min (std::move (min)), max (std::move (max)) {}
+    UserSettingUniquePtr min;
+    UserSettingUniquePtr max;
+};
+
+class RotationRandomInitializer : public ParticleInitializerBase {
+  public:
+    RotationRandomInitializer (UserSettingUniquePtr min, UserSettingUniquePtr max) : min (std::move (min)), max (std::move (max)) {}
+    UserSettingUniquePtr min;
+    UserSettingUniquePtr max;
+};
+
+class AngularVelocityRandomInitializer : public ParticleInitializerBase {
+  public:
+    AngularVelocityRandomInitializer (UserSettingUniquePtr min, UserSettingUniquePtr max) : min (std::move (min)), max (std::move (max)) {}
+    UserSettingUniquePtr min;
+    UserSettingUniquePtr max;
+};
+
+using ParticleInitializerUniquePtr = std::unique_ptr<ParticleInitializerBase>;
+
+/**
+ * Particle operator base and implementations
+ */
+class ParticleOperatorBase : public TypeCaster {
+  public:
+    virtual ~ParticleOperatorBase () = default;
+};
+
+class MovementOperator : public ParticleOperatorBase {
+  public:
+    MovementOperator (UserSettingUniquePtr drag, UserSettingUniquePtr gravity) : drag (std::move (drag)), gravity (std::move (gravity)) {}
+    UserSettingUniquePtr drag;
+    UserSettingUniquePtr gravity;
+};
+
+class AngularMovementOperator : public ParticleOperatorBase {
+  public:
+    AngularMovementOperator (UserSettingUniquePtr drag, UserSettingUniquePtr force) : drag (std::move (drag)), force (std::move (force)) {}
+    UserSettingUniquePtr drag;
+    UserSettingUniquePtr force;
+};
+
+class AlphaFadeOperator : public ParticleOperatorBase {
+  public:
+    AlphaFadeOperator (UserSettingUniquePtr fadeInTime, UserSettingUniquePtr fadeOutTime) : fadeInTime (std::move (fadeInTime)), fadeOutTime (std::move (fadeOutTime)) {}
+    UserSettingUniquePtr fadeInTime;
+    UserSettingUniquePtr fadeOutTime;
+};
+
+class SizeChangeOperator : public ParticleOperatorBase {
+  public:
+    SizeChangeOperator (UserSettingUniquePtr startTime, UserSettingUniquePtr endTime, UserSettingUniquePtr startValue, UserSettingUniquePtr endValue)
+        : startTime (std::move (startTime)), endTime (std::move (endTime)), startValue (std::move (startValue)), endValue (std::move (endValue)) {}
+    UserSettingUniquePtr startTime;
+    UserSettingUniquePtr endTime;
+    UserSettingUniquePtr startValue;
+    UserSettingUniquePtr endValue;
+};
+
+class AlphaChangeOperator : public ParticleOperatorBase {
+  public:
+    AlphaChangeOperator (UserSettingUniquePtr startTime, UserSettingUniquePtr endTime, UserSettingUniquePtr startValue, UserSettingUniquePtr endValue)
+        : startTime (std::move (startTime)), endTime (std::move (endTime)), startValue (std::move (startValue)), endValue (std::move (endValue)) {}
+    UserSettingUniquePtr startTime;
+    UserSettingUniquePtr endTime;
+    UserSettingUniquePtr startValue;
+    UserSettingUniquePtr endValue;
+};
+
+class ColorChangeOperator : public ParticleOperatorBase {
+  public:
+    ColorChangeOperator (UserSettingUniquePtr startTime, UserSettingUniquePtr endTime, UserSettingUniquePtr startValue, UserSettingUniquePtr endValue)
+        : startTime (std::move (startTime)), endTime (std::move (endTime)), startValue (std::move (startValue)), endValue (std::move (endValue)) {}
+    UserSettingUniquePtr startTime;
+    UserSettingUniquePtr endTime;
+    UserSettingUniquePtr startValue;
+    UserSettingUniquePtr endValue;
+};
+
+using ParticleOperatorUniquePtr = std::unique_ptr<ParticleOperatorBase>;
+
+/**
+ * Particle renderer configuration
+ */
+struct ParticleRenderer {
+    std::string name;
+    float length;
+    float maxLength;
+    float subdivision;
+};
+
+/**
+ * Child particle system
+ */
+struct ParticleChild {
+    std::string type;
+    std::string name;
+    int maxCount;
+    int controlPointStartIndex;
+    float probability;
+    glm::vec3 angles;
+    glm::vec3 origin;
+    glm::vec3 scale;
+    std::string particleFile;
+};
+
+/**
+ * Instance override values
+ */
+struct ParticleInstanceOverride {
+    UserSettingUniquePtr enabled;
+    UserSettingUniquePtr alpha;
+    UserSettingUniquePtr size;
+    UserSettingUniquePtr lifetime;
+    UserSettingUniquePtr rate;
+    UserSettingUniquePtr speed;
+    UserSettingUniquePtr count;
+    UserSettingUniquePtr color;   // Replaces particle color
+    UserSettingUniquePtr colorn;  // Multiplies particle color
+};
+
+struct ParticleData {
+    /** Position and transformation */
+    UserSettingUniquePtr origin;
+    UserSettingUniquePtr scale;
+    UserSettingUniquePtr angles;
+    UserSettingUniquePtr visible;
+
+    /** Parallax depth */
+    glm::vec2 parallaxDepth;
+
+    /** Reference to particle definition file */
+    std::string particleFile;
+
+    /** Particle system configuration */
+    std::string animationMode;
+    float sequenceMultiplier;
+    uint32_t maxCount;
+    uint32_t startTime;
+    uint32_t flags;
+
+    /** Material for rendering */
+    ModelUniquePtr material;
+
+    /** Emitters, initializers, operators, renderers */
+    std::vector<ParticleEmitter> emitters;
+    std::vector<ParticleInitializerUniquePtr> initializers;
+    std::vector<ParticleOperatorUniquePtr> operators;
+    std::vector<ParticleRenderer> renderers;
+    std::vector<ParticleControlPoint> controlPoints;
+    std::vector<ParticleChild> children;
+
+    /** Instance override */
+    ParticleInstanceOverride instanceOverride;
+};
+
+class Particle : public Object, public ParticleData {
+  public:
+    explicit Particle (ObjectData data, ParticleData particleData) noexcept : Object (std::move(data)), ParticleData (std::move (particleData)) {};
+    ~Particle () override = default;
+};
 } // namespace WallpaperEngine::Data::Model

+ 577 - 6
src/WallpaperEngine/Data/Parsers/ObjectParser.cpp

@@ -1,5 +1,6 @@
 #include "ObjectParser.h"
 #include "ModelParser.h"
+#include "MaterialParser.h"
 #include "EffectParser.h"
 
 #include "ShaderConstantParser.h"
@@ -7,6 +8,9 @@
 #include "WallpaperEngine/Data/Model/Project.h"
 #include "WallpaperEngine/Logging/Log.h"
 
+#include <glm/gtc/constants.hpp>
+#include <sstream>
+
 using namespace WallpaperEngine::Data::Parsers;
 using namespace WallpaperEngine::Data::Model;
 
@@ -16,18 +20,38 @@ ObjectUniquePtr ObjectParser::parse (const JSON& it, const Project& project) {
     const auto particleIt = it.find ("particle");
     const auto textIt = it.find ("text");
     const auto lightIt = it.find ("light");
-    const auto basedata = ObjectData {
-        .id = it.require <int> ("id", "Object must have an id"),
-        .name = it.require <std::string> ("name", "Object must have a name"),
-        .dependencies = parseDependencies (it),
-    };
+
+    // Parse base object data
+    // Some particle objects have numeric 'name' fields, so handle type mismatches gracefully
+    ObjectData basedata;
+    try {
+        basedata = ObjectData {
+            .id = it.require <int> ("id", "Object must have an id"),
+            .name = it.require <std::string> ("name", "Object must have a name"),
+            .dependencies = parseDependencies (it),
+        };
+    } catch (const std::exception& e) {
+        sLog.error ("Error parsing object base data: ", e.what ());
+        const auto idIt = it.find ("id");
+        const auto nameIt = it.find ("name");
+        int id = (idIt != it.end () && idIt->is_number ()) ? idIt->get<int> () : -1;
+        std::string name = "unknown";
+        if (nameIt != it.end ()) {
+            if (nameIt->is_string ()) {
+                name = nameIt->get<std::string> ();
+            } else if (nameIt->is_number ()) {
+                name = std::to_string (nameIt->get<int> ());
+            }
+        }
+        basedata = ObjectData { .id = id, .name = name, .dependencies = {} };
+    }
 
     if (imageIt != it.end () && imageIt->is_string ()) {
         return parseImage (it, project, basedata, *imageIt);
     } else if (soundIt != it.end () && soundIt->is_array ()) {
         return parseSound (it, basedata);
     } else if (particleIt != it.end ()) {
-        sLog.error ("Particle objects are not supported yet");
+        return parseParticle (it, project, basedata);
     } else if (textIt != it.end ()) {
         sLog.error ("Text objects are not supported yet");
     } else if (lightIt != it.end ()) {
@@ -218,4 +242,551 @@ ImageAnimationLayerUniquePtr ObjectParser::parseAnimationLayer (const JSON& it)
         .blend = it.require ("blend", "Animation layer must include blend"),
         .animation = it.require ("animation", "Animation layer must include an animation"),
     });
+}
+
+ParticleUniquePtr ObjectParser::parseParticle (const JSON& it, const Project& project, ObjectData base) {
+    try {
+    const auto& properties = project.properties;
+    const auto particleIt = it.find ("particle");
+
+    if (particleIt == it.end ()) {
+        sLog.error ("Particle object must have a particle definition");
+        return std::make_unique <Particle> (
+            std::move (base),
+            ParticleData {
+                .origin = it.user ("origin", properties, glm::vec3 (0.0f)),
+                .scale = it.user ("scale", properties, glm::vec3 (1.0f)),
+                .angles = it.user ("angles", properties, glm::vec3 (0.0f)),
+                .visible = it.user ("visible", properties, true),
+                .parallaxDepth = it.optional ("parallaxDepth", glm::vec2 (0.0f)),
+                .particleFile = "",
+                .animationMode = "sequence",
+                .sequenceMultiplier = 1.0f,
+                .maxCount = 100,
+                .startTime = 0,
+                .flags = 0,
+                .material = nullptr,
+                .emitters = {},
+                .initializers = {},
+                .operators = {},
+                .renderers = {},
+                .controlPoints = {},
+                .children = {},
+                .instanceOverride = {
+                    .enabled = std::make_unique<UserSetting> (UserSetting {.value = std::make_unique<DynamicValue> (false), .property = nullptr, .condition = std::nullopt}),
+                    .alpha = std::make_unique<UserSetting> (UserSetting {.value = std::make_unique<DynamicValue> (1.0f), .property = nullptr, .condition = std::nullopt}),
+                    .size = std::make_unique<UserSetting> (UserSetting {.value = std::make_unique<DynamicValue> (1.0f), .property = nullptr, .condition = std::nullopt}),
+                    .lifetime = std::make_unique<UserSetting> (UserSetting {.value = std::make_unique<DynamicValue> (1.0f), .property = nullptr, .condition = std::nullopt}),
+                    .rate = std::make_unique<UserSetting> (UserSetting {.value = std::make_unique<DynamicValue> (1.0f), .property = nullptr, .condition = std::nullopt}),
+                    .speed = std::make_unique<UserSetting> (UserSetting {.value = std::make_unique<DynamicValue> (1.0f), .property = nullptr, .condition = std::nullopt}),
+                    .count = std::make_unique<UserSetting> (UserSetting {.value = std::make_unique<DynamicValue> (1.0f), .property = nullptr, .condition = std::nullopt}),
+                    .color = std::make_unique<UserSetting> (UserSetting {.value = std::make_unique<DynamicValue> (glm::vec3 (1.0f)), .property = nullptr, .condition = std::nullopt}),
+                    .colorn = std::make_unique<UserSetting> (UserSetting {.value = std::make_unique<DynamicValue> (glm::vec3 (1.0f)), .property = nullptr, .condition = std::nullopt})
+                },
+            }
+        );
+    }
+
+    std::string particleFile;
+    if (particleIt->is_string ()) {
+        particleFile = particleIt->get<std::string> ();
+    }
+
+    // Load particle definition from file if it's a string reference
+    JSON particleJson = JSON::object ();
+    if (!particleFile.empty ()) {
+        try {
+            particleJson = WallpaperEngine::Data::JSON::JSON::parse (project.assetLocator->readString (particleFile));
+        } catch (std::runtime_error& e) {
+            sLog.error ("Cannot load particle file: ", particleFile, " - ", e.what ());
+        }
+    } else if (particleIt->is_object ()) {
+        particleJson = *particleIt;
+    }
+
+    // Parse emitters (note: field is named "emitter" not "emitters")
+    std::vector<ParticleEmitter> emitters;
+    const auto emittersIt = particleJson.find ("emitter");
+    if (emittersIt != particleJson.end () && emittersIt->is_array ()) {
+        for (const auto& emitter : *emittersIt) {
+            emitters.push_back (parseParticleEmitter (emitter));
+        }
+    }
+
+    // Parse initializers (note: field is named "initializer" not "initializers")
+    std::vector<ParticleInitializerUniquePtr> initializers;
+    const auto initializersIt = particleJson.find ("initializer");
+    if (initializersIt != particleJson.end () && initializersIt->is_array ()) {
+        for (const auto& initializer : *initializersIt) {
+            auto init = parseParticleInitializer (initializer, project.properties);
+            if (init) {
+                initializers.push_back (std::move (init));
+            }
+        }
+    }
+
+    // Parse operators (note: field is named "operator" not "operators")
+    std::vector<ParticleOperatorUniquePtr> operators;
+    const auto operatorsIt = particleJson.find ("operator");
+    if (operatorsIt != particleJson.end () && operatorsIt->is_array ()) {
+        for (const auto& op : *operatorsIt) {
+            auto oper = parseParticleOperator (op, project.properties);
+            if (oper) {
+                operators.push_back (std::move (oper));
+            }
+        }
+    }
+
+    // Parse renderers (note: field is named "renderer" not "renderers")
+    std::vector<ParticleRenderer> renderers;
+    const auto renderersIt = particleJson.find ("renderer");
+    if (renderersIt != particleJson.end () && renderersIt->is_array ()) {
+        for (const auto& renderer : *renderersIt) {
+            renderers.push_back (parseParticleRenderer (renderer));
+        }
+    }
+
+    // Add default sprite renderer if none specified
+    if (renderers.empty ()) {
+        renderers.push_back (ParticleRenderer {
+            .name = "sprite",
+            .length = 0.05f,
+            .maxLength = 10.0f,
+            .subdivision = 3.0f,
+        });
+    }
+
+    // Parse control points (note: field is named "controlpoint" not "controlpoints")
+    std::vector<ParticleControlPoint> controlPoints;
+    const auto controlPointsIt = particleJson.find ("controlpoint");
+    if (controlPointsIt != particleJson.end () && controlPointsIt->is_array ()) {
+        for (const auto& cp : *controlPointsIt) {
+            controlPoints.push_back (parseParticleControlPoint (cp));
+        }
+    }
+
+    // Parse children
+    std::vector<ParticleChild> children;
+    const auto childrenIt = particleJson.optional ("children");
+    if (childrenIt.has_value () && childrenIt->is_array ()) {
+        for (const auto& child : *childrenIt) {
+            children.push_back (parseParticleChild (child, project));
+        }
+    }
+
+    // Parse instance override
+    ParticleInstanceOverride instanceOverride = {
+        .enabled = std::make_unique<UserSetting> (UserSetting {
+            .value = std::make_unique<DynamicValue> (false),
+            .property = nullptr,
+            .condition = std::nullopt
+        }),
+        .alpha = std::make_unique<UserSetting> (UserSetting {
+            .value = std::make_unique<DynamicValue> (1.0f),
+            .property = nullptr,
+            .condition = std::nullopt
+        }),
+        .size = std::make_unique<UserSetting> (UserSetting {
+            .value = std::make_unique<DynamicValue> (1.0f),
+            .property = nullptr,
+            .condition = std::nullopt
+        }),
+        .lifetime = std::make_unique<UserSetting> (UserSetting {
+            .value = std::make_unique<DynamicValue> (1.0f),
+            .property = nullptr,
+            .condition = std::nullopt
+        }),
+        .rate = std::make_unique<UserSetting> (UserSetting {
+            .value = std::make_unique<DynamicValue> (1.0f),
+            .property = nullptr,
+            .condition = std::nullopt
+        }),
+        .speed = std::make_unique<UserSetting> (UserSetting {
+            .value = std::make_unique<DynamicValue> (1.0f),
+            .property = nullptr,
+            .condition = std::nullopt
+        }),
+        .count = std::make_unique<UserSetting> (UserSetting {
+            .value = std::make_unique<DynamicValue> (1.0f),
+            .property = nullptr,
+            .condition = std::nullopt
+        }),
+        .color = std::make_unique<UserSetting> (UserSetting {
+            .value = std::make_unique<DynamicValue> (glm::vec3 (1.0f)),
+            .property = nullptr,
+            .condition = std::nullopt
+        }),
+        .colorn = std::make_unique<UserSetting> (UserSetting {
+            .value = std::make_unique<DynamicValue> (glm::vec3 (1.0f)),
+            .property = nullptr,
+            .condition = std::nullopt
+        })
+    };
+    const auto instanceOverrideIt = it.optional ("instanceoverride");
+    if (instanceOverrideIt.has_value ()) {
+        instanceOverride = parseParticleInstanceOverride (*instanceOverrideIt, project.properties);
+    }
+
+    // Parse material - particles reference materials directly, not models
+    ModelUniquePtr material = nullptr;
+    const auto materialIt = particleJson.find ("material");
+    if (materialIt != particleJson.end () && materialIt->is_string ()) {
+        try {
+            std::string materialPath = materialIt->get<std::string> ();
+
+            // Particle materials are stored as just material definitions, not model files
+            // So we need to wrap them in a model structure
+            auto mat = MaterialParser::load (project, materialPath);
+
+            material = std::make_unique <ModelStruct> (ModelStruct {
+                .filename = materialPath,
+                .material = std::move (mat),
+                .solidlayer = false,
+                .fullscreen = false,
+                .passthrough = false,
+                .autosize = false,
+                .nopadding = false,
+                .width = std::nullopt,
+                .height = std::nullopt,
+                .puppet = std::nullopt,
+            });
+        } catch (std::runtime_error& e) {
+            sLog.error ("Cannot load particle material: ", materialIt->get<std::string> (), " - ", e.what ());
+        }
+    }
+
+    // Parse string fields safely
+    std::string animationMode = "sequence";
+    const auto animModeIt = particleJson.find ("animationmode");
+    if (animModeIt != particleJson.end () && animModeIt->is_string ()) {
+        animationMode = animModeIt->get<std::string> ();
+    }
+
+    // Parse numeric fields safely
+    float sequenceMultiplier = 1.0f;
+    uint32_t maxCount = 100;
+    uint32_t startTime = 0;
+    uint32_t flags = 0;
+
+    const auto seqMultIt = particleJson.find ("sequencemultiplier");
+    if (seqMultIt != particleJson.end () && seqMultIt->is_number ()) {
+        sequenceMultiplier = seqMultIt->get<float> ();
+    }
+
+    const auto maxCountIt = particleJson.find ("maxcount");
+    if (maxCountIt != particleJson.end () && maxCountIt->is_number ()) {
+        maxCount = maxCountIt->get<uint32_t> ();
+    }
+
+    const auto startTimeIt = particleJson.find ("starttime");
+    if (startTimeIt != particleJson.end () && startTimeIt->is_number ()) {
+        startTime = startTimeIt->get<uint32_t> ();
+    }
+
+    const auto flagsIt = particleJson.find ("flags");
+    if (flagsIt != particleJson.end () && flagsIt->is_number ()) {
+        flags = flagsIt->get<uint32_t> ();
+    }
+
+    return std::make_unique <Particle> (
+        std::move (base),
+        ParticleData {
+            .origin = it.user ("origin", properties, glm::vec3 (0.0f)),
+            .scale = it.user ("scale", properties, glm::vec3 (1.0f)),
+            .angles = it.user ("angles", properties, glm::vec3 (0.0f)),
+            .visible = it.user ("visible", properties, true),
+            .parallaxDepth = it.optional ("parallaxDepth", glm::vec2 (0.0f)),
+            .particleFile = particleFile,
+            .animationMode = animationMode,
+            .sequenceMultiplier = sequenceMultiplier,
+            .maxCount = maxCount,
+            .startTime = startTime,
+            .flags = flags,
+            .material = std::move (material),
+            .emitters = std::move (emitters),
+            .initializers = std::move (initializers),
+            .operators = std::move (operators),
+            .renderers = std::move (renderers),
+            .controlPoints = std::move (controlPoints),
+            .children = std::move (children),
+            .instanceOverride = std::move (instanceOverride),
+        }
+    );
+    } catch (nlohmann::json::exception& e) {
+        sLog.error ("Error parsing particle '", base.name, "': ", e.what ());
+        sLog.error ("Particle JSON: ", it.dump ());
+        throw;
+    }
+}
+
+ParticleEmitter ObjectParser::parseParticleEmitter (const JSON& it) {
+    // Parse string name safely
+    std::string name = "";
+    const auto nameIt = it.find ("name");
+    if (nameIt != it.end () && nameIt->is_string ()) {
+        name = nameIt->get<std::string> ();
+    }
+
+    // Helper lambda to parse vec3 fields that might be strings, arrays, or missing
+    auto parseVec3 = [&](const char* fieldName, const glm::vec3& defaultValue) -> glm::vec3 {
+        const auto fieldIt = it.find (fieldName);
+        if (fieldIt == it.end ()) {
+            return defaultValue;
+        }
+        if (fieldIt->is_string ()) {
+            return it.optional (fieldName, defaultValue);
+        }
+        if (fieldIt->is_array () && fieldIt->size () >= 3) {
+            return glm::vec3 (
+                (*fieldIt)[0].get<float> (),
+                (*fieldIt)[1].get<float> (),
+                (*fieldIt)[2].get<float> ()
+            );
+        }
+        return defaultValue;
+    };
+
+    auto parseIVec3 = [&](const char* fieldName, const glm::ivec3& defaultValue) -> glm::ivec3 {
+        const auto fieldIt = it.find (fieldName);
+        if (fieldIt == it.end ()) {
+            return defaultValue;
+        }
+        if (fieldIt->is_array () && fieldIt->size () >= 3) {
+            return glm::ivec3 (
+                (*fieldIt)[0].get<int> (),
+                (*fieldIt)[1].get<int> (),
+                (*fieldIt)[2].get<int> ()
+            );
+        }
+        return defaultValue;
+    };
+
+    try {
+        return ParticleEmitter {
+            .id = it.optional ("id", -1),
+            .name = name,
+            .directions = parseVec3 ("directions", glm::vec3 (1.0f, 1.0f, 0.0f)),
+            .distanceMin = parseVec3 ("distancemin", glm::vec3 (0.0f)),
+            .distanceMax = parseVec3 ("distancemax", glm::vec3 (256.0f)),
+            .origin = parseVec3 ("origin", glm::vec3 (0.0f)),
+            .sign = parseIVec3 ("sign", glm::ivec3 (0)),
+            .instantaneous = it.optional ("instantaneous", 0u),
+            .speedMin = it.optional ("speedmin", 0.0f),
+            .speedMax = it.optional ("speedmax", 0.0f),
+            .rate = it.optional ("rate", 5.0f),
+            .controlPoint = it.optional ("controlpoint", 0),
+            .flags = it.optional ("flags", 0u),
+        };
+    } catch (nlohmann::json::exception& e) {
+        sLog.error ("Error parsing emitter: ", e.what ());
+        sLog.error ("Emitter JSON: ", it.dump ());
+        throw;
+    }
+}
+
+ParticleInitializerUniquePtr ObjectParser::parseParticleInitializer (const JSON& it, const Properties& properties) {
+    std::string name = it.optional<std::string> ("name", "");
+
+    if (name == "colorrandom") {
+        // Only normalize if there's no property connection or values are > 1.0
+        auto minSetting = it.user ("min", properties, glm::vec3 (0.0f));
+        auto maxSetting = it.user ("max", properties, glm::vec3 (255.0f));
+
+        auto minVec = minSetting->value->getVec3 ();
+        if (minSetting->property == nullptr &&
+            (minVec.x > 1.0f || minVec.y > 1.0f || minVec.z > 1.0f)) {
+            minSetting->value->update (minVec / 255.0f);
+        }
+
+        auto maxVec = maxSetting->value->getVec3 ();
+        if (maxSetting->property == nullptr &&
+            (maxVec.x > 1.0f || maxVec.y > 1.0f || maxVec.z > 1.0f)) {
+            maxSetting->value->update (maxVec / 255.0f);
+        }
+
+        return std::make_unique<ColorRandomInitializer> (std::move (minSetting), std::move (maxSetting));
+    } else if (name == "sizerandom") {
+        return std::make_unique<SizeRandomInitializer> (
+            it.user ("min", properties, 0.0f),
+            it.user ("max", properties, 20.0f)
+        );
+    } else if (name == "alpharandom") {
+        return std::make_unique<AlphaRandomInitializer> (
+            it.user ("min", properties, 0.05f),
+            it.user ("max", properties, 1.0f)
+        );
+    } else if (name == "lifetimerandom") {
+        return std::make_unique<LifetimeRandomInitializer> (
+            it.user ("min", properties, 0.0f),
+            it.user ("max", properties, 1.0f)
+        );
+    } else if (name == "velocityrandom") {
+        return std::make_unique<VelocityRandomInitializer> (
+            it.user ("min", properties, glm::vec3 (-32.0f)),
+            it.user ("max", properties, glm::vec3 (32.0f))
+        );
+    } else if (name == "rotationrandom") {
+        return std::make_unique<RotationRandomInitializer> (
+            it.user ("min", properties, glm::vec3 (0.0f)),
+            it.user ("max", properties, glm::vec3 (0.0f, 0.0f, glm::two_pi<float>()))
+        );
+    } else if (name == "angularvelocityrandom") {
+        return std::make_unique<AngularVelocityRandomInitializer> (
+            it.user ("min", properties, glm::vec3 (0.0f, 0.0f, -5.0f)),
+            it.user ("max", properties, glm::vec3 (0.0f, 0.0f, 5.0f))
+        );
+    }
+
+    return nullptr;
+}
+
+ParticleOperatorUniquePtr ObjectParser::parseParticleOperator (const JSON& it, const Properties& properties) {
+    std::string name = it.optional<std::string> ("name", "");
+
+    if (name == "movement") {
+        return std::make_unique<MovementOperator> (
+            it.user ("drag", properties, 0.0f),
+            it.user ("gravity", properties, glm::vec3 (0.0f))
+        );
+    } else if (name == "angularmovement") {
+        return std::make_unique<AngularMovementOperator> (
+            it.user ("drag", properties, 0.0f),
+            it.user ("force", properties, glm::vec3 (0.0f))
+        );
+    } else if (name == "alphafade") {
+        return std::make_unique<AlphaFadeOperator> (
+            it.user ("fadeintime", properties, 0.5f),
+            it.user ("fadeouttime", properties, 0.5f)
+        );
+    } else if (name == "sizechange") {
+        return std::make_unique<SizeChangeOperator> (
+            it.user ("starttime", properties, 0.0f),
+            it.user ("endtime", properties, 1.0f),
+            it.user ("startvalue", properties, 1.0f),
+            it.user ("endvalue", properties, 0.0f)
+        );
+    } else if (name == "alphachange") {
+        return std::make_unique<AlphaChangeOperator> (
+            it.user ("starttime", properties, 0.0f),
+            it.user ("endtime", properties, 1.0f),
+            it.user ("startvalue", properties, 1.0f),
+            it.user ("endvalue", properties, 0.0f)
+        );
+    } else if (name == "colorchange") {
+        return std::make_unique<ColorChangeOperator> (
+            it.user ("starttime", properties, 0.0f),
+            it.user ("endtime", properties, 1.0f),
+            it.user ("startvalue", properties, glm::vec3 (1.0f)),
+            it.user ("endvalue", properties, glm::vec3 (1.0f))
+        );
+    }
+
+    return nullptr;
+}
+
+ParticleRenderer ObjectParser::parseParticleRenderer (const JSON& it) {
+    std::string name = "sprite";
+    const auto nameIt = it.find ("name");
+    if (nameIt != it.end () && nameIt->is_string ()) {
+        name = nameIt->get<std::string> ();
+    }
+
+    return ParticleRenderer {
+        .name = name,
+        .length = it.optional ("length", 0.05f),
+        .maxLength = it.optional ("maxlength", 10.0f),
+        .subdivision = it.optional ("subdivision", 3.0f),
+    };
+}
+
+ParticleControlPoint ObjectParser::parseParticleControlPoint (const JSON& it) {
+    // Parse offset - can be string "x y z" or array [x,y,z]
+    glm::vec3 offset (0.0f);
+    const auto offsetIt = it.find ("offset");
+    if (offsetIt != it.end ()) {
+        if (offsetIt->is_string ()) {
+            // Parse string format "x y z"
+            std::string offsetStr = offsetIt->get<std::string> ();
+            std::istringstream iss (offsetStr);
+            iss >> offset.x >> offset.y >> offset.z;
+        } else {
+            // Try parsing as vec3 directly
+            try {
+                offset = it.optional ("offset", glm::vec3 (0.0f));
+            } catch (...) {
+                offset = glm::vec3 (0.0f);
+            }
+        }
+    }
+
+    return ParticleControlPoint {
+        .id = it.optional ("id", -1),
+        .flags = it.optional ("flags", 0u),
+        .offset = offset,
+    };
+}
+
+ParticleChild ObjectParser::parseParticleChild (const JSON& it, const Project& project) {
+    std::string particleFile = "";
+    const auto particleIt = it.find ("particle");
+    if (particleIt != it.end () && particleIt->is_string ()) {
+        particleFile = particleIt->get<std::string> ();
+    }
+
+    std::string type = "static";
+    const auto typeIt = it.find ("type");
+    if (typeIt != it.end () && typeIt->is_string ()) {
+        type = typeIt->get<std::string> ();
+    }
+
+    std::string name = "";
+    const auto nameIt = it.find ("name");
+    if (nameIt != it.end () && nameIt->is_string ()) {
+        name = nameIt->get<std::string> ();
+    }
+
+    // Helper lambda to parse vec3 fields
+    auto parseVec3 = [&](const char* fieldName, const glm::vec3& defaultValue) -> glm::vec3 {
+        const auto fieldIt = it.find (fieldName);
+        if (fieldIt == it.end ()) {
+            return defaultValue;
+        }
+        if (fieldIt->is_string ()) {
+            return it.optional (fieldName, defaultValue);
+        }
+        if (fieldIt->is_array () && fieldIt->size () >= 3) {
+            return glm::vec3 (
+                (*fieldIt)[0].get<float> (),
+                (*fieldIt)[1].get<float> (),
+                (*fieldIt)[2].get<float> ()
+            );
+        }
+        return defaultValue;
+    };
+
+    return ParticleChild {
+        .type = type,
+        .name = name,
+        .maxCount = it.optional ("maxcount", 20),
+        .controlPointStartIndex = it.optional ("controlpointstartindex", 0),
+        .probability = it.optional ("probability", 1.0f),
+        .angles = parseVec3 ("angles", glm::vec3 (0.0f)),
+        .origin = parseVec3 ("origin", glm::vec3 (0.0f)),
+        .scale = parseVec3 ("scale", glm::vec3 (1.0f)),
+        .particleFile = particleFile,
+    };
+}
+
+ParticleInstanceOverride ObjectParser::parseParticleInstanceOverride (const JSON& it, const Properties& properties) {
+    return ParticleInstanceOverride {
+        .enabled = it.user ("enabled", properties, true),
+        .alpha = it.user ("alpha", properties, 1.0f),
+        .size = it.user ("size", properties, 1.0f),
+        .lifetime = it.user ("lifetime", properties, 1.0f),
+        .rate = it.user ("rate", properties, 1.0f),
+        .speed = it.user ("speed", properties, 1.0f),
+        .count = it.user ("count", properties, 1.0f),
+        .color = it.user ("color", properties, glm::vec3 (1.0f)),
+        .colorn = it.user ("colorn", properties, glm::vec3 (1.0f)),
+    };
 }

+ 10 - 0
src/WallpaperEngine/Data/Parsers/ObjectParser.h

@@ -21,6 +21,7 @@ class ObjectParser {
     static std::vector<int> parseDependencies (const JSON& it);
     static SoundUniquePtr parseSound (const JSON& it, ObjectData base);
     static ImageUniquePtr parseImage (const JSON& it, const Project& project, ObjectData base, const std::string& image);
+    static ParticleUniquePtr parseParticle (const JSON& it, const Project& project, ObjectData base);
     static std::vector <ImageEffectUniquePtr> parseEffects (const JSON& it, const Project& project);
     static ImageEffectUniquePtr parseEffect (const JSON& it, const Project& project);
     static std::vector <ImageEffectPassOverrideUniquePtr> parseEffectPassOverrides (const JSON& it, const Project& project);
@@ -29,5 +30,14 @@ class ObjectParser {
     static ComboMap parseComboMap (const JSON& it);
     static std::vector <ImageAnimationLayerUniquePtr> parseAnimationLayers (const JSON& it);
     static ImageAnimationLayerUniquePtr parseAnimationLayer (const JSON& it);
+
+    // Particle parsing helpers
+    static ParticleEmitter parseParticleEmitter (const JSON& it);
+    static ParticleInitializerUniquePtr parseParticleInitializer (const JSON& it, const Properties& properties);
+    static ParticleOperatorUniquePtr parseParticleOperator (const JSON& it, const Properties& properties);
+    static ParticleRenderer parseParticleRenderer (const JSON& it);
+    static ParticleControlPoint parseParticleControlPoint (const JSON& it);
+    static ParticleChild parseParticleChild (const JSON& it, const Project& project);
+    static ParticleInstanceOverride parseParticleInstanceOverride (const JSON& it, const Properties& properties);
 };
 } // namespace WallpaperEngine::Data::Parsers

+ 45 - 0
src/WallpaperEngine/Data/Parsers/TextureParser.cpp

@@ -1,6 +1,8 @@
 #include <cstring>
+#include <cmath>
 
 #include <lz4.h>
+#include <nlohmann/json.hpp>
 
 #include "TextureParser.h"
 #include "WallpaperEngine/Data/Assets/Texture.h"
@@ -271,4 +273,47 @@ FIF TextureParser::parseFIF (uint32_t value) {
         default:
             sLog.exception ("unknown free image format: ", value);
     }
+}
+
+TextureUniquePtr TextureParser::parse (const BinaryReader& file, const std::string& filename,
+                                       std::function<std::string(const std::string&)> metadataLoader) {
+    // Parse the binary .tex file first
+    auto result = parse (file);
+
+    // Try to load optional .tex-json metadata for spritesheet data
+    if (metadataLoader) {
+        parseSpritesheetMetadata (*result, filename, metadataLoader);
+    }
+
+    return result;
+}
+
+void TextureParser::parseSpritesheetMetadata (Texture& header, const std::string& filename,
+                                              std::function<std::string(const std::string&)> metadataLoader) {
+    try {
+        std::string texJsonContent = metadataLoader (filename + ".tex-json");
+        nlohmann::json texJson = nlohmann::json::parse (texJsonContent);
+
+        // Check for spritesheet sequences
+        if (texJson.contains ("spritesheetsequences") && texJson["spritesheetsequences"].is_array ()) {
+            auto& sequences = texJson["spritesheetsequences"];
+            if (!sequences.empty ()) {
+                auto& firstSeq = sequences[0];
+                int frames = firstSeq.value ("frames", 0);
+                float frameWidth = firstSeq.value ("width", 0.0f);
+                float frameHeight = firstSeq.value ("height", 0.0f);
+                float duration = firstSeq.value ("duration", 1.0f);
+
+                if (frames > 0 && frameWidth > 0.0f && frameHeight > 0.0f && header.width > 0 && header.height > 0) {
+                    // Calculate grid dimensions from texture size and frame size
+                    header.spritesheetCols = static_cast<uint32_t> (std::round (header.width / frameWidth));
+                    header.spritesheetRows = static_cast<uint32_t> (std::round (header.height / frameHeight));
+                    header.spritesheetFrames = static_cast<uint32_t> (frames);
+                    header.spritesheetDuration = duration;
+                }
+            }
+        }
+    } catch (const std::exception&) {
+        // .tex-json file is optional, only used for spritesheet data
+    }
 }

+ 5 - 0
src/WallpaperEngine/Data/Parsers/TextureParser.h

@@ -3,6 +3,7 @@
 #include "WallpaperEngine/Data/Assets/Texture.h"
 
 #include <iostream>
+#include <functional>
 
 #include "WallpaperEngine/Data/Model/Types.h"
 #include "WallpaperEngine/Data/Utils/BinaryReader.h"
@@ -14,6 +15,8 @@ using namespace WallpaperEngine::Data::Assets;
 class TextureParser {
   public:
     static TextureUniquePtr parse (const BinaryReader& file);
+    static TextureUniquePtr parse (const BinaryReader& file, const std::string& filename,
+                                    std::function<std::string(const std::string&)> metadataLoader);
     static MipmapSharedPtr parseMipmap (const BinaryReader& file, const Texture& header);
     static FrameSharedPtr parseFrame (const BinaryReader& file);
 
@@ -21,6 +24,8 @@ class TextureParser {
     static void parseTextureHeader (Texture& header, const BinaryReader& file);
     static void parseContainer (Texture& header, const BinaryReader& file);
     static void parseAnimations (Texture& header, const BinaryReader& file);
+    static void parseSpritesheetMetadata (Texture& header, const std::string& filename,
+                                          std::function<std::string(const std::string&)> metadataLoader);
     static TextureFormat parseTextureFormat (uint32_t value);
     static uint32_t parseTextureFlags (uint32_t value);
     static FIF parseFIF (uint32_t value);

+ 7 - 4
src/WallpaperEngine/Data/Parsers/UserSettingParser.cpp

@@ -61,14 +61,17 @@ UserSettingUniquePtr UserSettingParser::parse (const json& data, const Propertie
         value->update (valueIt.get <float> ());
     } else if (valueIt.is_boolean ()) {
         value->update (valueIt.get <bool> ());
-    } else if (valueIt.is_null () && property != nullptr) {
-        // null values are directly connected to the property
-        value->connect (property.get());
-    } else {
+    } else if (valueIt.is_null ()) {
         // null value with no connection to property
         value->update ();
     }
 
+    // TODO: This might need to be removed if it causes issues with default values
+    // Connect to property if one is specified (this allows property overrides to propagate)
+    if (property != nullptr) {
+        value->connect (property.get());
+    }
+
     return std::make_unique <UserSetting> (UserSetting {
         .value = std::move (value),
         .property = property,

+ 16 - 0
src/WallpaperEngine/Render/CFBO.cpp

@@ -135,4 +135,20 @@ const glm::vec4* CFBO::getResolution () const {
 
 bool CFBO::isAnimated () const {
     return false;
+}
+
+uint32_t CFBO::getSpritesheetCols () const {
+    return 0;  // FBOs don't have spritesheets
+}
+
+uint32_t CFBO::getSpritesheetRows () const {
+    return 0;  // FBOs don't have spritesheets
+}
+
+uint32_t CFBO::getSpritesheetFrames () const {
+    return 0;  // FBOs don't have spritesheets
+}
+
+float CFBO::getSpritesheetDuration () const {
+    return 0.0f;  // FBOs don't have spritesheets
 }

+ 4 - 0
src/WallpaperEngine/Render/CFBO.h

@@ -27,6 +27,10 @@ class CFBO final : public TextureProvider {
     [[nodiscard]] const std::vector<FrameSharedPtr>& getFrames () const override;
     [[nodiscard]] const glm::vec4* getResolution () const override;
     [[nodiscard]] bool isAnimated () const override;
+    [[nodiscard]] uint32_t getSpritesheetCols () const override;
+    [[nodiscard]] uint32_t getSpritesheetRows () const override;
+    [[nodiscard]] uint32_t getSpritesheetFrames () const override;
+    [[nodiscard]] float getSpritesheetDuration () const override;
 
   private:
     GLuint m_framebuffer = GL_NONE;

+ 16 - 0
src/WallpaperEngine/Render/CTexture.cpp

@@ -213,3 +213,19 @@ const std::vector<FrameSharedPtr>& CTexture::getFrames () const {
 bool CTexture::isAnimated () const {
     return this->getHeader ().isAnimated ();
 }
+
+uint32_t CTexture::getSpritesheetCols () const {
+    return this->getHeader ().spritesheetCols;
+}
+
+uint32_t CTexture::getSpritesheetRows () const {
+    return this->getHeader ().spritesheetRows;
+}
+
+uint32_t CTexture::getSpritesheetFrames () const {
+    return this->getHeader ().spritesheetFrames;
+}
+
+float CTexture::getSpritesheetDuration () const {
+    return this->getHeader ().spritesheetDuration;
+}

+ 4 - 0
src/WallpaperEngine/Render/CTexture.h

@@ -28,6 +28,10 @@ class CTexture final : public TextureProvider {
     [[nodiscard]] const glm::vec4* getResolution () const override;
     [[nodiscard]] const std::vector<FrameSharedPtr>& getFrames () const override;
     [[nodiscard]] bool isAnimated () const override;
+    [[nodiscard]] uint32_t getSpritesheetCols () const override;
+    [[nodiscard]] uint32_t getSpritesheetRows () const override;
+    [[nodiscard]] uint32_t getSpritesheetFrames () const override;
+    [[nodiscard]] float getSpritesheetDuration () const override;
 
   private:
     /**

+ 1020 - 0
src/WallpaperEngine/Render/Objects/CParticle.cpp

@@ -0,0 +1,1020 @@
+#include "CParticle.h"
+#include "WallpaperEngine/Logging/Log.h"
+#include "WallpaperEngine/Data/Model/Property.h"
+
+#include <GL/glew.h>
+#include <glm/gtc/matrix_transform.hpp>
+#include <glm/gtc/constants.hpp>
+#include <algorithm>
+#include <cmath>
+#include <unordered_set>
+
+extern float g_Time;
+
+using namespace WallpaperEngine::Render::Objects;
+using namespace WallpaperEngine::Data::Model;
+
+namespace {
+    // Helper: Random float in range
+    inline float randomFloat (std::mt19937& rng, float min, float max) {
+        if (max < min) std::swap (min, max);
+        std::uniform_real_distribution<float> dist (min, max);
+        return dist (rng);
+    }
+
+    // Helper: Random vec3 in range
+    inline glm::vec3 randomVec3 (std::mt19937& rng, const glm::vec3& min, const glm::vec3& max) {
+        return glm::vec3 (
+            randomFloat (rng, min.x, max.x),
+            randomFloat (rng, min.y, max.y),
+            randomFloat (rng, min.z, max.z)
+        );
+    }
+
+    // Helper: Linear interpolation
+    inline float lerp (float t, float a, float b) {
+        return a + t * (b - a);
+    }
+
+    // Helper: Fade value change over lifetime
+    inline float fadeValue (float life, float startTime, float endTime, float startValue, float endValue) {
+        if (life <= startTime)
+            return startValue;
+        else if (life >= endTime)
+            return endValue;
+        else {
+            float t = (life - startTime) / (endTime - startTime);
+            return lerp (t, startValue, endValue);
+        }
+    }
+}
+
+CParticle::CParticle (Wallpapers::CScene& scene, const Particle& particle) :
+    CObject (scene, particle),
+    m_particle (particle) {
+    // Initialize random number generator with time-based seed
+    std::random_device rd;
+    m_rng.seed (rd ());
+
+    // Reserve particle pool
+    m_particles.resize (std::min (particle.maxCount, MAX_PARTICLES));
+}
+
+CParticle::~CParticle () {
+    if (m_vao != 0) {
+        glDeleteVertexArrays (1, &m_vao);
+    }
+    if (m_vbo != 0) {
+        glDeleteBuffers (1, &m_vbo);
+    }
+    if (m_shaderProgram != 0) {
+        glDeleteProgram (m_shaderProgram);
+    }
+}
+
+void CParticle::setup () {
+    if (m_initialized)
+        return;
+
+    // Convert origin from screen space to centered space
+    // Projection uses ortho(-width/2, width/2, -height/2, height/2)
+    // but particle origins are in screen space where (0,0) is top-left
+    float screenWidth = getScene ().getCamera ().getWidth ();
+    float screenHeight = getScene ().getCamera ().getHeight ();
+
+    glm::vec3 origin = m_particle.origin->value->getVec3 ();
+    origin.x -= screenWidth / 2.0f;
+    origin.y = screenHeight / 2.0f - origin.y;
+    m_transformedOrigin = origin;
+
+    // Load particle material texture and blending mode
+    if (m_particle.material && m_particle.material->material && !m_particle.material->material->passes.empty ()) {
+        auto& firstPass = *m_particle.material->material->passes.begin ();
+
+        m_blendingMode = firstPass->blending;
+
+        auto& textures = firstPass->textures;
+        if (!textures.empty ()) {
+            std::string textureName = textures.begin ()->second;
+            if (textureName.find ("_rt_") == 0 || textureName.find ("_alias_") == 0) {
+                m_texture = getScene ().findFBO (textureName);
+            } else {
+                m_texture = getContext ().resolveTexture (textureName);
+            }
+            if (m_texture) {
+                m_textureFormat = m_texture->getFormat ();
+
+                // Get spritesheet data from texture (parsed in TextureParser)
+                m_spritesheetCols = static_cast<int> (m_texture->getSpritesheetCols ());
+                m_spritesheetRows = static_cast<int> (m_texture->getSpritesheetRows ());
+                m_spritesheetFrames = static_cast<int> (m_texture->getSpritesheetFrames ());
+                m_spritesheetDuration = m_texture->getSpritesheetDuration ();
+
+                sLog.out ("Particle '", m_particle.name, "' texture: ", textureName,
+                          " | cols=", m_spritesheetCols, " rows=", m_spritesheetRows,
+                          " frames=", m_spritesheetFrames, " duration=", m_spritesheetDuration);
+            }
+        }
+    }
+
+    setupEmitters ();
+    setupInitializers ();
+    setupOperators ();
+    setupBuffers ();
+
+    // Setup control points (max 8)
+    m_controlPoints.resize (8);
+    for (const auto& cp : m_particle.controlPoints) {
+        if (cp.id >= 0 && cp.id < 8) {
+            m_controlPoints [cp.id].offset = cp.offset;
+            m_controlPoints [cp.id].linkMouse = (cp.flags & 1) != 0;
+            m_controlPoints [cp.id].worldSpace = (cp.flags & 2) != 0;
+        }
+    }
+
+    m_initialized = true;
+}
+
+void CParticle::render () {
+    if (!m_initialized || !m_particle.visible->value->getBool ())
+        return;
+
+    // Update particles
+    float dt = g_Time - static_cast<float> (m_time);
+    m_time = g_Time;
+
+    if (dt > 0.0f) {
+        dt = std::min (dt, 0.1f);
+        update (dt);
+    }
+
+    // Render particles
+    if (m_particleCount > 0 && m_particle.material) {
+        renderSprites ();
+    }
+}
+
+void CParticle::update (float dt) {
+    // Emit particles
+    for (auto& emitter : m_emitters) {
+        emitter (m_particles, m_particleCount, dt);
+    }
+
+    // Update particle lifetime and remove dead particles
+    for (uint32_t i = 0; i < m_particleCount; ) {
+        auto& p = m_particles [i];
+        p.age += dt;
+
+        // Update animation frame if we have a spritesheet
+        if (m_spritesheetFrames > 0) {
+            // Calculate frame based on particle lifetime
+            float lifetimePos = p.getLifetimePos();
+
+            // Apply sequence multiplier if present
+            float animSpeed = m_particle.sequenceMultiplier > 0.0f ? m_particle.sequenceMultiplier : 1.0f;
+
+            // Calculate frame based on animation mode
+            if (m_particle.animationMode == "once") {
+                // Play animation once over particle lifetime
+                p.frame = std::min(lifetimePos * m_spritesheetFrames * animSpeed, static_cast<float>(m_spritesheetFrames - 1));
+            } else {
+                // Default to "loop" mode - loop animation based on duration
+                if (m_spritesheetDuration > 0.0f) {
+                    float timeInCycle = std::fmod(p.age * animSpeed, m_spritesheetDuration);
+                    float cyclePos = timeInCycle / m_spritesheetDuration;
+                    p.frame = std::fmod(cyclePos * m_spritesheetFrames, static_cast<float>(m_spritesheetFrames));
+                } else {
+                    p.frame = 0.0f;
+                }
+            }
+        }
+
+        if (!p.isAlive ()) {
+            // Swap with last particle and reduce count
+            if (i < m_particleCount - 1) {
+                p = m_particles [m_particleCount - 1];
+            }
+            m_particleCount--;
+        } else {
+            i++;
+        }
+    }
+
+    // Apply operators to living particles
+    for (auto& op : m_operators) {
+        op (m_particles, m_particleCount, m_controlPoints, static_cast<float> (m_time), dt);
+    }
+}
+
+const Particle& CParticle::getParticle () const {
+    return m_particle;
+}
+
+// ========== EMITTERS ==========
+
+void CParticle::setupEmitters () {
+    for (const auto& emitter : m_particle.emitters) {
+        EmitterFunc func;
+
+        if (emitter.name == "boxrandom") {
+            func = createBoxEmitter (emitter);
+        } else if (emitter.name == "sphererandom") {
+            func = createSphereEmitter (emitter);
+        } else {
+            sLog.out ("Unknown emitter type: ", emitter.name);
+            continue;
+        }
+
+        if (func) {
+            m_emitters.push_back (std::move (func));
+        }
+    }
+}
+
+EmitterFunc CParticle::createBoxEmitter (const ParticleEmitter& emitter) {
+    float rate = emitter.rate * m_particle.instanceOverride.rate->value->getFloat ();
+    float lifetime = 1.0f * m_particle.instanceOverride.lifetime->value->getFloat ();
+
+    return [this, emitter, rate, lifetime, emissionTimer = 0.0f, remaining = emitter.instantaneous](std::vector<ParticleInstance>& particles, uint32_t& count, float dt) mutable {
+        if (count >= particles.size ())
+            return;
+
+        emissionTimer += dt * rate;
+
+        uint32_t toEmit = static_cast<uint32_t> (emissionTimer);
+        emissionTimer -= static_cast<float> (toEmit);
+
+        if (remaining > 0) {
+            toEmit = remaining;
+            remaining = 0;
+        }
+
+        for (uint32_t i = 0; i < toEmit && count < particles.size (); i++) {
+            auto& p = particles [count];
+
+            // Spawn at random position within box volume
+            glm::vec3 randomPos = randomVec3 (m_rng, emitter.distanceMin, emitter.distanceMax);
+            p.position = emitter.origin + randomPos;
+
+            // Velocity based on position direction and emitter settings
+            glm::vec3 direction = glm::length (randomPos) > 0.0f ? glm::normalize (randomPos) : glm::vec3 (0, 1, 0);
+            direction = direction * emitter.directions;
+
+            float speed = randomFloat (m_rng, emitter.speedMin, emitter.speedMax);
+            p.velocity = direction * speed;
+
+            p.acceleration = glm::vec3 (0.0f);
+            p.rotation = glm::vec3 (0.0f);
+            p.angularVelocity = glm::vec3 (0.0f);
+            p.angularAcceleration = glm::vec3 (0.0f);
+
+            // Default properties (will be overridden by initializers)
+            p.color = glm::vec3 (1.0f) * m_particle.instanceOverride.colorn->value->getVec3 ();
+            p.alpha = 1.0f * m_particle.instanceOverride.alpha->value->getFloat ();
+            p.size = 20.0f * m_particle.instanceOverride.size->value->getFloat ();
+            p.lifetime = lifetime;
+            p.age = 0.0f;
+            p.alive = true;
+
+            // Store initial values
+            p.initial.color = p.color;
+            p.initial.alpha = p.alpha;
+            p.initial.size = p.size;
+            p.initial.lifetime = p.lifetime;
+
+            // Apply initializers
+            for (auto& init : m_initializers) {
+                init (p);
+            }
+
+            count++;
+        }
+    };
+}
+
+EmitterFunc CParticle::createSphereEmitter (const ParticleEmitter& emitter) {
+    float rate = emitter.rate * m_particle.instanceOverride.rate->value->getFloat ();
+    float lifetime = 1.0f * m_particle.instanceOverride.lifetime->value->getFloat ();
+
+    return [this, emitter, rate, lifetime, emissionTimer = 0.0f, remaining = emitter.instantaneous](std::vector<ParticleInstance>& particles, uint32_t& count, float dt) mutable {
+        if (count >= particles.size ())
+            return;
+
+        emissionTimer += dt * rate;
+
+        uint32_t toEmit = static_cast<uint32_t> (emissionTimer);
+        emissionTimer -= static_cast<float> (toEmit);
+
+        if (remaining > 0) {
+            toEmit = remaining;
+            remaining = 0;
+        }
+
+        for (uint32_t i = 0; i < toEmit && count < particles.size (); i++) {
+            auto& p = particles [count];
+
+            // Spawn at random position within sphere volume
+            float theta = randomFloat (m_rng, 0.0f, glm::two_pi<float>());
+            float phi = randomFloat (m_rng, 0.0f, glm::pi<float>());
+            float radius = randomFloat (m_rng, emitter.distanceMin.x, emitter.distanceMax.x);
+
+            glm::vec3 randomPos (
+                radius * std::sin (phi) * std::cos (theta),
+                radius * std::sin (phi) * std::sin (theta),
+                radius * std::cos (phi)
+            );
+            p.position = emitter.origin + randomPos;
+
+            // Velocity pointing outward from sphere center
+            glm::vec3 direction = glm::length (randomPos) > 0.0f ? glm::normalize (randomPos) : glm::vec3 (0.0f, 1.0f, 0.0f);
+            float speed = randomFloat (m_rng, emitter.speedMin, emitter.speedMax);
+            p.velocity = direction * speed * emitter.directions;
+
+            p.acceleration = glm::vec3 (0.0f);
+            p.rotation = glm::vec3 (0.0f);
+            p.angularVelocity = glm::vec3 (0.0f);
+            p.angularAcceleration = glm::vec3 (0.0f);
+
+            p.color = glm::vec3 (1.0f) * m_particle.instanceOverride.colorn->value->getVec3 ();
+            p.alpha = 1.0f * m_particle.instanceOverride.alpha->value->getFloat ();
+            p.size = 20.0f * m_particle.instanceOverride.size->value->getFloat ();
+            p.lifetime = lifetime;
+            p.age = 0.0f;
+            p.alive = true;
+
+            p.initial.color = p.color;
+            p.initial.alpha = p.alpha;
+            p.initial.size = p.size;
+            p.initial.lifetime = p.lifetime;
+
+            for (auto& init : m_initializers) {
+                init (p);
+            }
+
+            count++;
+        }
+    };
+}
+
+// ========== INITIALIZERS ==========
+
+void CParticle::setupInitializers () {
+    for (const auto& initializer : m_particle.initializers) {
+        if (!initializer) {
+            continue;
+        }
+
+        InitializerFunc func;
+
+        if (initializer->is<ColorRandomInitializer> ()) {
+            func = createColorRandomInitializer (*initializer->as<ColorRandomInitializer> ());
+        } else if (initializer->is<SizeRandomInitializer> ()) {
+            func = createSizeRandomInitializer (*initializer->as<SizeRandomInitializer> ());
+        } else if (initializer->is<AlphaRandomInitializer> ()) {
+            func = createAlphaRandomInitializer (*initializer->as<AlphaRandomInitializer> ());
+        } else if (initializer->is<LifetimeRandomInitializer> ()) {
+            func = createLifetimeRandomInitializer (*initializer->as<LifetimeRandomInitializer> ());
+        } else if (initializer->is<VelocityRandomInitializer> ()) {
+            func = createVelocityRandomInitializer (*initializer->as<VelocityRandomInitializer> ());
+        } else if (initializer->is<RotationRandomInitializer> ()) {
+            func = createRotationRandomInitializer (*initializer->as<RotationRandomInitializer> ());
+        } else if (initializer->is<AngularVelocityRandomInitializer> ()) {
+            func = createAngularVelocityRandomInitializer (*initializer->as<AngularVelocityRandomInitializer> ());
+        } else {
+            sLog.out ("Unknown initializer type");
+        }
+
+        if (func) {
+            m_initializers.push_back (std::move (func));
+        }
+    }
+}
+
+InitializerFunc CParticle::createColorRandomInitializer (const ColorRandomInitializer& init) {
+    DynamicValue* minValue = init.min->value.get ();
+    DynamicValue* maxValue = init.max->value.get ();
+
+    return [this, minValue, maxValue](ParticleInstance& p) {
+        p.color = randomVec3 (m_rng, minValue->getVec3 (), maxValue->getVec3 ()) * m_particle.instanceOverride.colorn->value->getVec3 ();
+        p.initial.color = p.color;
+    };
+}
+
+InitializerFunc CParticle::createSizeRandomInitializer (const SizeRandomInitializer& init) {
+    DynamicValue* minValue = init.min->value.get ();
+    DynamicValue* maxValue = init.max->value.get ();
+
+    return [this, minValue, maxValue](ParticleInstance& p) {
+        p.size = randomFloat (m_rng, minValue->getFloat (), maxValue->getFloat ()) * m_particle.instanceOverride.size->value->getFloat ();
+        p.initial.size = p.size;
+    };
+}
+
+InitializerFunc CParticle::createAlphaRandomInitializer (const AlphaRandomInitializer& init) {
+    DynamicValue* minValue = init.min->value.get ();
+    DynamicValue* maxValue = init.max->value.get ();
+
+    return [this, minValue, maxValue](ParticleInstance& p) {
+        p.alpha = randomFloat (m_rng, minValue->getFloat (), maxValue->getFloat ()) * m_particle.instanceOverride.alpha->value->getFloat ();
+        p.initial.alpha = p.alpha;
+    };
+}
+
+InitializerFunc CParticle::createLifetimeRandomInitializer (const LifetimeRandomInitializer& init) {
+    DynamicValue* minValue = init.min->value.get ();
+    DynamicValue* maxValue = init.max->value.get ();
+
+    return [this, minValue, maxValue](ParticleInstance& p) {
+        p.lifetime = randomFloat (m_rng, minValue->getFloat (), maxValue->getFloat ()) * m_particle.instanceOverride.lifetime->value->getFloat ();
+        p.initial.lifetime = p.lifetime;
+    };
+}
+
+InitializerFunc CParticle::createVelocityRandomInitializer (const VelocityRandomInitializer& init) {
+    DynamicValue* minValue = init.min->value.get ();
+    DynamicValue* maxValue = init.max->value.get ();
+
+    return [this, minValue, maxValue](ParticleInstance& p) {
+        glm::vec3 vel = randomVec3 (m_rng, minValue->getVec3 (), maxValue->getVec3 ());
+        p.velocity += vel * m_particle.instanceOverride.speed->value->getFloat ();
+    };
+}
+
+InitializerFunc CParticle::createRotationRandomInitializer (const RotationRandomInitializer& init) {
+    DynamicValue* minValue = init.min->value.get ();
+    DynamicValue* maxValue = init.max->value.get ();
+
+    return [this, minValue, maxValue](ParticleInstance& p) {
+        p.rotation = randomVec3 (m_rng, minValue->getVec3 (), maxValue->getVec3 ());
+    };
+}
+
+InitializerFunc CParticle::createAngularVelocityRandomInitializer (const AngularVelocityRandomInitializer& init) {
+    DynamicValue* minValue = init.min->value.get ();
+    DynamicValue* maxValue = init.max->value.get ();
+
+    return [this, minValue, maxValue](ParticleInstance& p) {
+        p.angularVelocity = randomVec3 (m_rng, minValue->getVec3 (), maxValue->getVec3 ());
+    };
+}
+
+// ========== OPERATORS ==========
+
+void CParticle::setupOperators () {
+    for (const auto& op : m_particle.operators) {
+        if (!op) {
+            continue;
+        }
+
+        OperatorFunc func;
+
+        if (op->is<MovementOperator> ()) {
+            func = createMovementOperator (*op->as<MovementOperator> ());
+        } else if (op->is<AngularMovementOperator> ()) {
+            func = createAngularMovementOperator (*op->as<AngularMovementOperator> ());
+        } else if (op->is<AlphaFadeOperator> ()) {
+            func = createAlphaFadeOperator (*op->as<AlphaFadeOperator> ());
+        } else if (op->is<SizeChangeOperator> ()) {
+            func = createSizeChangeOperator (*op->as<SizeChangeOperator> ());
+        } else if (op->is<AlphaChangeOperator> ()) {
+            func = createAlphaChangeOperator (*op->as<AlphaChangeOperator> ());
+        } else if (op->is<ColorChangeOperator> ()) {
+            func = createColorChangeOperator (*op->as<ColorChangeOperator> ());
+        } else {
+            sLog.out ("Unknown operator type");
+        }
+
+        if (func) {
+            m_operators.push_back (std::move (func));
+        }
+    }
+}
+
+OperatorFunc CParticle::createMovementOperator (const MovementOperator& op) {
+    float speed = m_particle.instanceOverride.speed->value->getFloat ();
+    float drag = op.drag->value->getFloat ();
+    glm::vec3 gravity = op.gravity->value->getVec3 ();
+
+    return [drag, gravity, speed](
+        std::vector<ParticleInstance>& particles,
+        uint32_t count,
+        const std::vector<ControlPointData>&,
+        float,
+        float dt
+    ) {
+        for (uint32_t i = 0; i < count; i++) {
+            auto& p = particles [i];
+
+            // Apply drag force
+            glm::vec3 dragForce = -drag * p.velocity;
+
+            // Total acceleration
+            glm::vec3 totalAccel = (dragForce + gravity) * speed;
+
+            // Update velocity and position
+            p.velocity += totalAccel * dt;
+            p.position += p.velocity * dt;
+        }
+    };
+}
+
+OperatorFunc CParticle::createAngularMovementOperator (const AngularMovementOperator& op) {
+    float drag = op.drag->value->getFloat ();
+    glm::vec3 force = op.force->value->getVec3 ();
+
+    return [drag, force](
+        std::vector<ParticleInstance>& particles,
+        uint32_t count,
+        const std::vector<ControlPointData>&,
+        float,
+        float dt
+    ) {
+        for (uint32_t i = 0; i < count; i++) {
+            auto& p = particles [i];
+
+            glm::vec3 dragForce = -drag * p.angularVelocity;
+            glm::vec3 totalAccel = dragForce + force;
+
+            p.angularVelocity += totalAccel * dt;
+            p.rotation += p.angularVelocity * dt;
+        }
+    };
+}
+
+OperatorFunc CParticle::createAlphaFadeOperator (const AlphaFadeOperator& op) {
+    float fadeInTime = op.fadeInTime->value->getFloat ();
+    float fadeOutTime = op.fadeOutTime->value->getFloat ();
+
+    return [fadeInTime, fadeOutTime](
+        std::vector<ParticleInstance>& particles,
+        uint32_t count,
+        const std::vector<ControlPointData>&,
+        float,
+        float
+    ) {
+        for (uint32_t i = 0; i < count; i++) {
+            auto& p = particles [i];
+
+            float life = p.getLifetimePos ();
+
+            if (life <= fadeInTime) {
+                float fade = fadeValue (life, 0.0f, fadeInTime, 0.0f, 1.0f);
+                p.alpha = p.initial.alpha * fade;
+            } else if (life > fadeOutTime) {
+                float fade = 1.0f - fadeValue (life, fadeOutTime, 1.0f, 0.0f, 1.0f);
+                p.alpha = p.initial.alpha * fade;
+            } else {
+                p.alpha = p.initial.alpha;
+            }
+        }
+    };
+}
+
+OperatorFunc CParticle::createSizeChangeOperator (const SizeChangeOperator& op) {
+    float startTime = op.startTime->value->getFloat ();
+    float endTime = op.endTime->value->getFloat ();
+    float startValue = op.startValue->value->getFloat ();
+    float endValue = op.endValue->value->getFloat ();
+
+    return [startTime, endTime, startValue, endValue](
+        std::vector<ParticleInstance>& particles,
+        uint32_t count,
+        const std::vector<ControlPointData>&,
+        float,
+        float
+    ) {
+        for (uint32_t i = 0; i < count; i++) {
+            auto& p = particles [i];
+
+            float life = p.getLifetimePos ();
+            float multiplier = fadeValue (life, startTime, endTime, startValue, endValue);
+            p.size = p.initial.size * multiplier;
+        }
+    };
+}
+
+OperatorFunc CParticle::createAlphaChangeOperator (const AlphaChangeOperator& op) {
+    float startTime = op.startTime->value->getFloat ();
+    float endTime = op.endTime->value->getFloat ();
+    float startValue = op.startValue->value->getFloat ();
+    float endValue = op.endValue->value->getFloat ();
+
+    return [startTime, endTime, startValue, endValue](
+        std::vector<ParticleInstance>& particles,
+        uint32_t count,
+        const std::vector<ControlPointData>&,
+        float,
+        float
+    ) {
+        for (uint32_t i = 0; i < count; i++) {
+            auto& p = particles [i];
+
+            float life = p.getLifetimePos ();
+            float multiplier = fadeValue (life, startTime, endTime, startValue, endValue);
+            p.alpha = p.initial.alpha * multiplier;
+        }
+    };
+}
+
+OperatorFunc CParticle::createColorChangeOperator (const ColorChangeOperator& op) {
+    float startTime = op.startTime->value->getFloat ();
+    float endTime = op.endTime->value->getFloat ();
+    glm::vec3 startValue = op.startValue->value->getVec3 ();
+    glm::vec3 endValue = op.endValue->value->getVec3 ();
+
+    return [startTime, endTime, startValue, endValue](
+        std::vector<ParticleInstance>& particles,
+        uint32_t count,
+        const std::vector<ControlPointData>&,
+        float,
+        float
+    ) {
+        for (uint32_t i = 0; i < count; i++) {
+            auto& p = particles [i];
+
+            float life = p.getLifetimePos ();
+
+            glm::vec3 color;
+            color.r = fadeValue (life, startTime, endTime, startValue.r, endValue.r);
+            color.g = fadeValue (life, startTime, endTime, startValue.g, endValue.g);
+            color.b = fadeValue (life, startTime, endTime, startValue.b, endValue.b);
+
+            p.color = p.initial.color * color;
+        }
+    };
+}
+
+// ========== RENDERING ==========
+
+GLuint CParticle::compileShader (GLenum type, const char* source) {
+    GLuint shader = glCreateShader (type);
+    glShaderSource (shader, 1, &source, nullptr);
+    glCompileShader (shader);
+
+    GLint success;
+    glGetShaderiv (shader, GL_COMPILE_STATUS, &success);
+    if (!success) {
+        char infoLog[512];
+        glGetShaderInfoLog (shader, 512, nullptr, infoLog);
+        sLog.error ("Particle shader compilation failed: ", infoLog);
+        return 0;
+    }
+    return shader;
+}
+
+GLuint CParticle::createShaderProgram () {
+    const char* vertexShaderSource = R"(
+        #version 330 core
+        layout (location = 0) in vec3 aPos;
+        layout (location = 1) in vec2 aTexCoord;
+        layout (location = 2) in float aRotation;
+        layout (location = 3) in float aSize;
+        layout (location = 4) in vec4 aColor;
+        layout (location = 5) in float aFrame;
+
+        out vec2 vTexCoord;
+        out vec4 vColor;
+        out float vFrame;
+
+        uniform mat4 g_ModelViewProjectionMatrix;
+
+        void main() {
+            // Calculate billboard offset based on texture coordinates
+            // Offset from center: (0,0)-(1,1) becomes (-0.5,-0.5)-(0.5,0.5)
+            vec2 offset = aTexCoord - 0.5;
+
+            // Apply rotation around Z axis
+            float c = cos(aRotation);
+            float s = sin(aRotation);
+            vec2 rotated = vec2(
+                offset.x * c - offset.y * s,
+                offset.x * s + offset.y * c
+            );
+
+            // Scale by particle size
+            vec3 billboardPos = aPos + vec3(rotated * aSize, 0.0);
+
+            gl_Position = g_ModelViewProjectionMatrix * vec4(billboardPos, 1.0);
+            vTexCoord = aTexCoord;
+            vColor = aColor;
+            vFrame = aFrame;
+        }
+    )";
+
+    const char* fragmentShaderSource = R"(
+        #version 330 core
+        in vec2 vTexCoord;
+        in vec4 vColor;
+        in float vFrame;
+
+        out vec4 FragColor;
+
+        uniform sampler2D g_Texture0;
+        uniform int u_HasTexture;
+        uniform int u_TextureFormat; // 8 = RG88, 9 = R8
+        uniform vec2 u_SpritesheetSize; // x=cols, y=rows
+
+        void main() {
+            vec4 texColor;
+            if (u_HasTexture == 1) {
+                // Calculate UV coordinates for spritesheet frame
+                vec2 uv = vTexCoord;
+                if (u_SpritesheetSize.x > 0.0) {
+                    // Spritesheet: adjust UVs to sample only the current frame
+                    float cols = u_SpritesheetSize.x;
+                    float rows = u_SpritesheetSize.y;
+                    float frameIndex = floor(vFrame);
+
+                    float frameX = mod(frameIndex, cols);
+                    float frameY = floor(frameIndex / cols);
+
+                    float frameWidth = 1.0 / cols;
+                    float frameHeight = 1.0 / rows;
+
+                    // Calculate UV coordinates for the current frame
+                    uv = vec2(
+                        frameX * frameWidth + vTexCoord.x * frameWidth,
+                        frameY * frameHeight + vTexCoord.y * frameHeight
+                    );
+                }
+
+                // Sample texture
+                vec4 sample = texture(g_Texture0, uv);
+
+                // Convert texture format like common_fragment.h does
+                if (u_TextureFormat == 8) {
+                    // RG88: R channel is color (grayscale), G channel is alpha
+                    texColor = vec4(sample.rrr, sample.g);
+                } else if (u_TextureFormat == 9) {
+                    // R8: R channel is alpha, white color
+                    texColor = vec4(1.0, 1.0, 1.0, sample.r);
+                } else {
+                    // Normal RGBA
+                    texColor = sample;
+                }
+            } else {
+                // No texture - create circular particle fallback
+                vec2 coord = vTexCoord - vec2(0.5);
+                float dist = length(coord) * 2.0;
+                if (dist > 1.0) discard;
+                float alpha = 1.0 - dist;
+                texColor = vec4(1.0, 1.0, 1.0, alpha);
+            }
+            FragColor = vColor * texColor;
+        }
+    )";
+
+    GLuint vertexShader = compileShader (GL_VERTEX_SHADER, vertexShaderSource);
+    GLuint fragmentShader = compileShader (GL_FRAGMENT_SHADER, fragmentShaderSource);
+
+    if (vertexShader == 0 || fragmentShader == 0) {
+        return 0;
+    }
+
+    GLuint program = glCreateProgram ();
+    glAttachShader (program, vertexShader);
+    glAttachShader (program, fragmentShader);
+    glLinkProgram (program);
+
+    GLint success;
+    glGetProgramiv (program, GL_LINK_STATUS, &success);
+    if (!success) {
+        char infoLog[512];
+        glGetProgramInfoLog (program, 512, nullptr, infoLog);
+        sLog.error ("Particle shader program linking failed: ", infoLog);
+        glDeleteShader (vertexShader);
+        glDeleteShader (fragmentShader);
+        return 0;
+    }
+
+    glDeleteShader (vertexShader);
+    glDeleteShader (fragmentShader);
+
+    return program;
+}
+
+void CParticle::setupBuffers () {
+    // Create shader program
+    m_shaderProgram = createShaderProgram ();
+    if (m_shaderProgram == 0) {
+        sLog.error ("Failed to create particle shader program for ", m_particle.name);
+        return;
+    }
+
+    // Cache uniform locations
+    m_uniformTexture = glGetUniformLocation (m_shaderProgram, "g_Texture0");
+    m_uniformHasTexture = glGetUniformLocation (m_shaderProgram, "u_HasTexture");
+    m_uniformTextureFormat = glGetUniformLocation (m_shaderProgram, "u_TextureFormat");
+    m_uniformSpritesheetSize = glGetUniformLocation (m_shaderProgram, "u_SpritesheetSize");
+
+    glGenVertexArrays (1, &m_vao);
+    glGenBuffers (1, &m_vbo);
+
+    glBindVertexArray (m_vao);
+    glBindBuffer (GL_ARRAY_BUFFER, m_vbo);
+
+    // Vertex format: pos(3) + texcoord(2) + rotation(1) + size(1) + color(4) + frame(1) = 12 floats
+    const int stride = sizeof (float) * 12;
+
+    // Position (location 0)
+    glEnableVertexAttribArray (0);
+    glVertexAttribPointer (0, 3, GL_FLOAT, GL_FALSE, stride, (void*)0);
+
+    // Texture coordinates (location 1)
+    glEnableVertexAttribArray (1);
+    glVertexAttribPointer (1, 2, GL_FLOAT, GL_FALSE, stride, (void*)(sizeof (float) * 3));
+
+    // Rotation (location 2)
+    glEnableVertexAttribArray (2);
+    glVertexAttribPointer (2, 1, GL_FLOAT, GL_FALSE, stride, (void*)(sizeof (float) * 5));
+
+    // Size (location 3)
+    glEnableVertexAttribArray (3);
+    glVertexAttribPointer (3, 1, GL_FLOAT, GL_FALSE, stride, (void*)(sizeof (float) * 6));
+
+    // Color (location 4) - includes alpha as 4th component
+    glEnableVertexAttribArray (4);
+    glVertexAttribPointer (4, 4, GL_FLOAT, GL_FALSE, stride, (void*)(sizeof (float) * 7));
+
+    // Frame (location 5)
+    glEnableVertexAttribArray (5);
+    glVertexAttribPointer (5, 1, GL_FLOAT, GL_FALSE, stride, (void*)(sizeof (float) * 11));
+
+    glBindVertexArray (0);
+}
+
+void CParticle::renderSprites () {
+    if (m_particleCount == 0)
+        return;
+
+    // Count alive particles
+    uint32_t aliveCount = 0;
+    for (uint32_t i = 0; i < m_particleCount; i++) {
+        if (m_particles[i].alive) aliveCount++;
+    }
+
+    if (aliveCount == 0)
+        return;
+
+    // Prepare vertex data - 6 vertices per particle (2 triangles forming a quad)
+    // Vertex format: pos(3) + texcoord(2) + rotation(1) + size(1) + color(4) + frame(1) = 12 floats per vertex
+    std::vector<float> vertices;
+    vertices.reserve (aliveCount * 6 * 12);
+
+    for (uint32_t i = 0; i < m_particleCount; i++) {
+        const auto& p = m_particles [i];
+        if (!p.alive)
+            continue;
+
+        float size = p.size / 2.0f;
+        float rz = p.rotation.z;
+
+        // Create 6 vertices forming 2 triangles (GL_QUADS not available in core profile)
+
+        auto addVertex = [&](float u, float v) {
+            vertices.push_back (p.position.x);
+            vertices.push_back (p.position.y);
+            vertices.push_back (p.position.z);
+            vertices.push_back (u);
+            vertices.push_back (v);
+            vertices.push_back (rz);
+            vertices.push_back (size);
+            vertices.push_back (p.color.r);
+            vertices.push_back (p.color.g);
+            vertices.push_back (p.color.b);
+            vertices.push_back (p.alpha);
+            vertices.push_back (p.frame);
+        };
+
+        // Triangle 1
+        addVertex (0.0f, 1.0f);  // Bottom-left
+        addVertex (1.0f, 1.0f);  // Bottom-right
+        addVertex (1.0f, 0.0f);  // Top-right
+
+        // Triangle 2
+        addVertex (1.0f, 0.0f);  // Top-right
+        addVertex (0.0f, 0.0f);  // Top-left
+        addVertex (0.0f, 1.0f);  // Bottom-left
+    }
+
+    if (m_shaderProgram == 0) {
+        return;
+    }
+
+    // Clear any existing GL errors before we start
+    while (glGetError () != GL_NO_ERROR);
+
+    // Save current GL state before any modifications
+    GLint prevProgram = 0;
+    GLint prevVAO = 0;
+    GLint prevTexture = 0;
+    GLboolean prevBlendEnabled = glIsEnabled (GL_BLEND);
+    GLboolean prevDepthMask = GL_TRUE;
+    GLint prevBlendSrcRGB = GL_ONE;
+    GLint prevBlendDstRGB = GL_ZERO;
+    GLint prevBlendSrcAlpha = GL_ONE;
+    GLint prevBlendDstAlpha = GL_ZERO;
+    GLint prevActiveTexture = GL_TEXTURE0;
+    GLint prevArrayBuffer = 0;
+    glGetIntegerv (GL_CURRENT_PROGRAM, &prevProgram);
+    glGetIntegerv (GL_VERTEX_ARRAY_BINDING, &prevVAO);
+    glGetIntegerv (GL_TEXTURE_BINDING_2D, &prevTexture);
+    glGetBooleanv (GL_DEPTH_WRITEMASK, &prevDepthMask);
+    glGetIntegerv (GL_BLEND_SRC_RGB, &prevBlendSrcRGB);
+    glGetIntegerv (GL_BLEND_DST_RGB, &prevBlendDstRGB);
+    glGetIntegerv (GL_BLEND_SRC_ALPHA, &prevBlendSrcAlpha);
+    glGetIntegerv (GL_BLEND_DST_ALPHA, &prevBlendDstAlpha);
+    glGetIntegerv (GL_ACTIVE_TEXTURE, &prevActiveTexture);
+    glGetIntegerv (GL_ARRAY_BUFFER_BINDING, &prevArrayBuffer);
+
+    glBindBuffer (GL_ARRAY_BUFFER, m_vbo);
+    glBufferData (GL_ARRAY_BUFFER, vertices.size () * sizeof (float), vertices.data (), GL_DYNAMIC_DRAW);
+
+    // Use particle shader
+    glUseProgram (m_shaderProgram);
+
+    // Bind particle texture
+    if (m_texture) {
+        glActiveTexture (GL_TEXTURE0);
+        glBindTexture (GL_TEXTURE_2D, m_texture->getTextureID (0));
+
+        // Set texture wrapping mode
+        glTexParameteri (GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, GL_CLAMP_TO_EDGE);
+        glTexParameteri (GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, GL_CLAMP_TO_EDGE);
+
+        if (m_uniformTexture != -1) {
+            glUniform1i (m_uniformTexture, 0);
+        }
+        if (m_uniformHasTexture != -1) {
+            glUniform1i (m_uniformHasTexture, 1);
+        }
+        if (m_uniformTextureFormat != -1) {
+            glUniform1i (m_uniformTextureFormat, static_cast<int> (m_textureFormat));
+        }
+        // Set spritesheet size (cols, rows)
+        if (m_uniformSpritesheetSize != -1) {
+            glUniform2f (m_uniformSpritesheetSize, static_cast<float>(m_spritesheetCols), static_cast<float>(m_spritesheetRows));
+        }
+    } else {
+        if (m_uniformHasTexture != -1) {
+            glUniform1i (m_uniformHasTexture, 0);
+        }
+        if (m_uniformSpritesheetSize != -1) {
+            glUniform2f (m_uniformSpritesheetSize, 0.0f, 0.0f);
+        }
+    }
+
+    // Build model matrix from particle object transform
+    glm::vec3 scale = m_particle.scale->value->getVec3 ();
+    glm::vec3 angles = m_particle.angles->value->getVec3 ();
+
+    glm::mat4 model = glm::mat4 (1.0f);
+    model = glm::translate (model, m_transformedOrigin);
+    model = glm::rotate (model, glm::radians (angles.z), glm::vec3 (0, 0, 1));
+    model = glm::rotate (model, glm::radians (angles.y), glm::vec3 (0, 1, 0));
+    model = glm::rotate (model, glm::radians (angles.x), glm::vec3 (1, 0, 0));
+    model = glm::scale (model, scale);
+
+    // Apply camera transform
+    glm::mat4 mvp = getScene ().getCamera ().getProjection () * getScene ().getCamera ().getLookAt () * model;
+    GLint mvpLoc = glGetUniformLocation (m_shaderProgram, "g_ModelViewProjectionMatrix");
+    if (mvpLoc != -1) {
+        glUniformMatrix4fv (mvpLoc, 1, GL_FALSE, &mvp[0][0]);
+    }
+
+    // Enable blending for particles
+    glEnable (GL_BLEND);
+    // Apply blending mode from material
+    switch (m_blendingMode) {
+        case Data::Model::BlendingMode_Additive:
+            glBlendFunc (GL_SRC_ALPHA, GL_ONE);
+            break;
+        case Data::Model::BlendingMode_Translucent:
+            glBlendFunc (GL_SRC_ALPHA, GL_ONE_MINUS_SRC_ALPHA);
+            break;
+        case Data::Model::BlendingMode_Normal:
+        default:
+            glBlendFunc (GL_SRC_ALPHA, GL_ONE_MINUS_SRC_ALPHA);
+            break;
+    }
+    glDepthMask (GL_FALSE); // Don't write to depth buffer for transparent particles
+
+    // Render triangles (6 vertices per particle, 2 triangles forming a quad)
+    glBindVertexArray (m_vao);
+    glDrawArrays (GL_TRIANGLES, 0, aliveCount * 6);
+    glBindVertexArray (0);
+
+    // Restore state
+    glDepthMask (prevDepthMask);
+    glBlendFuncSeparate (prevBlendSrcRGB, prevBlendDstRGB, prevBlendSrcAlpha, prevBlendDstAlpha);
+    if (prevBlendEnabled) {
+        glEnable (GL_BLEND);
+    } else {
+        glDisable (GL_BLEND);
+    }
+    glUseProgram (prevProgram);
+    glActiveTexture (prevActiveTexture);
+    glBindTexture (GL_TEXTURE_2D, prevTexture);
+    glBindBuffer (GL_ARRAY_BUFFER, prevArrayBuffer);
+    glBindVertexArray (prevVAO);
+}

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

@@ -0,0 +1,181 @@
+#pragma once
+
+#include "WallpaperEngine/Render/CObject.h"
+#include "WallpaperEngine/Render/Wallpapers/CScene.h"
+#include "WallpaperEngine/Data/Model/Object.h"
+
+#include <glm/vec3.hpp>
+#include <glm/vec4.hpp>
+#include <vector>
+#include <random>
+#include <functional>
+
+using namespace WallpaperEngine;
+using namespace WallpaperEngine::Render;
+using namespace WallpaperEngine::Data::Model;
+
+namespace WallpaperEngine::Render::Objects {
+
+constexpr uint32_t MAX_PARTICLES = 10000;
+
+/**
+ * Runtime particle instance state
+ */
+struct ParticleInstance {
+    // Position and movement
+    glm::vec3 position {0.0f};
+    glm::vec3 velocity {0.0f};
+    glm::vec3 acceleration {0.0f};
+
+    // Rotation
+    glm::vec3 rotation {0.0f};
+    glm::vec3 angularVelocity {0.0f};
+    glm::vec3 angularAcceleration {0.0f};
+
+    // Visual properties
+    glm::vec3 color {1.0f};
+    float alpha {1.0f};
+    float size {20.0f};
+    float frame {0.0f};         // Current animation frame
+
+    // Lifetime
+    float lifetime {1.0f};      // Total lifetime in seconds
+    float age {0.0f};           // Current age in seconds
+
+    // Initial values for resets/multipliers
+    struct {
+        glm::vec3 color {1.0f};
+        float alpha {1.0f};
+        float size {20.0f};
+        float lifetime {1.0f};
+    } initial;
+
+    bool alive {false};
+
+    // Get normalized lifetime position (0.0 to 1.0)
+    float getLifetimePos () const {
+        return lifetime > 0.0f ? (age / lifetime) : 1.0f;
+    }
+
+    bool isAlive () const {
+        return alive && age < lifetime;
+    }
+};
+
+/**
+ * Control point runtime data
+ */
+struct ControlPointData {
+    glm::vec3 position {0.0f};
+    glm::vec3 offset {0.0f};
+    bool linkMouse {false};
+    bool worldSpace {false};
+};
+
+/**
+ * Particle emitter function
+ */
+using EmitterFunc = std::function<void(std::vector<ParticleInstance>&, uint32_t&, float)>;
+
+/**
+ * Particle initializer function
+ */
+using InitializerFunc = std::function<void(ParticleInstance&)>;
+
+/**
+ * Particle operator function
+ */
+using OperatorFunc = std::function<void(std::vector<ParticleInstance>&, uint32_t, const std::vector<ControlPointData>&, float, float)>;
+
+class CParticle final : public CObject {
+    friend CObject;
+
+  public:
+    CParticle (Wallpapers::CScene& scene, const Particle& particle);
+    ~CParticle ();
+
+    void setup ();
+    void render () override;
+    void update (float dt);
+
+    [[nodiscard]] const Particle& getParticle () const;
+
+  protected:
+    void setupEmitters ();
+    void setupInitializers ();
+    void setupOperators ();
+
+    // Emitter creators
+    EmitterFunc createBoxEmitter (const ParticleEmitter& emitter);
+    EmitterFunc createSphereEmitter (const ParticleEmitter& emitter);
+
+    // Initializer creators
+    InitializerFunc createColorRandomInitializer (const ColorRandomInitializer& init);
+    InitializerFunc createSizeRandomInitializer (const SizeRandomInitializer& init);
+    InitializerFunc createAlphaRandomInitializer (const AlphaRandomInitializer& init);
+    InitializerFunc createLifetimeRandomInitializer (const LifetimeRandomInitializer& init);
+    InitializerFunc createVelocityRandomInitializer (const VelocityRandomInitializer& init);
+    InitializerFunc createRotationRandomInitializer (const RotationRandomInitializer& init);
+    InitializerFunc createAngularVelocityRandomInitializer (const AngularVelocityRandomInitializer& init);
+
+    // Operator creators
+    OperatorFunc createMovementOperator (const MovementOperator& op);
+    OperatorFunc createAngularMovementOperator (const AngularMovementOperator& op);
+    OperatorFunc createAlphaFadeOperator (const AlphaFadeOperator& op);
+    OperatorFunc createSizeChangeOperator (const SizeChangeOperator& op);
+    OperatorFunc createAlphaChangeOperator (const AlphaChangeOperator& op);
+    OperatorFunc createColorChangeOperator (const ColorChangeOperator& op);
+
+    // Rendering
+    void renderSprites ();
+    void setupBuffers ();
+
+  private:
+    const Particle& m_particle;
+
+    std::vector<ParticleInstance> m_particles;
+    uint32_t m_particleCount {0};
+
+    std::vector<EmitterFunc> m_emitters;
+    std::vector<InitializerFunc> m_initializers;
+    std::vector<OperatorFunc> m_operators;
+
+    std::vector<ControlPointData> m_controlPoints;
+
+    double m_time {0.0};
+
+    // OpenGL buffers
+    GLuint m_vao {0};
+    GLuint m_vbo {0};
+    GLuint m_shaderProgram {0};
+
+    // Cached uniform locations
+    GLint m_uniformTexture {-1};
+    GLint m_uniformHasTexture {-1};
+    GLint m_uniformTextureFormat {-1};
+    GLint m_uniformSpritesheetSize {-1};
+
+    // Particle material texture
+    std::shared_ptr<const TextureProvider> m_texture {nullptr};
+    Data::Model::BlendingMode m_blendingMode {Data::Model::BlendingMode_Translucent};
+    Data::Assets::TextureFormat m_textureFormat {Data::Assets::TextureFormat_ARGB8888};
+
+    // Spritesheet animation data
+    int m_spritesheetCols {0};
+    int m_spritesheetRows {0};
+    int m_spritesheetFrames {0};
+    float m_spritesheetDuration {1.0f};
+
+    // Transformed origin (screen space to centered space conversion)
+    glm::vec3 m_transformedOrigin {0.0f};
+
+    // Random number generator
+    std::mt19937 m_rng;
+
+    bool m_initialized {false};
+
+    // Helper methods
+    GLuint compileShader (GLenum type, const char* source);
+    GLuint createShaderProgram ();
+};
+} // namespace WallpaperEngine::Render::Objects

+ 9 - 1
src/WallpaperEngine/Render/TextureCache.cpp

@@ -25,7 +25,15 @@ std::shared_ptr<const TextureProvider> TextureCache::resolve (const std::string&
             const auto contents = project->assetLocator->texture (filename);
             auto stream = BinaryReader (contents);
 
-            auto parsedTexture = TextureParser::parse (stream);
+            // Create metadata loader lambda that captures the assetLocator
+            // Note: Unlike texture() which prepends "materials/", readString() doesn't,
+            // so we need to construct the full path here
+            auto metadataLoader = [&project](const std::string& metaFilename) -> std::string {
+                std::filesystem::path fullPath = std::filesystem::path("materials") / metaFilename;
+                return project->assetLocator->readString (fullPath);
+            };
+
+            auto parsedTexture = TextureParser::parse (stream, filename, metadataLoader);
             auto texture = std::make_shared <CTexture> (std::move (parsedTexture));
 
             this->store (filename, texture);

+ 17 - 1
src/WallpaperEngine/Render/TextureProvider.h

@@ -62,5 +62,21 @@ class TextureProvider {
      * @return If the texture is animated or not
      */
     [[nodiscard]] virtual bool isAnimated () const = 0;
+    /**
+     * @return Number of columns in spritesheet grid (0 if not a spritesheet)
+     */
+    [[nodiscard]] virtual uint32_t getSpritesheetCols () const = 0;
+    /**
+     * @return Number of rows in spritesheet grid (0 if not a spritesheet)
+     */
+    [[nodiscard]] virtual uint32_t getSpritesheetRows () const = 0;
+    /**
+     * @return Total number of frames in spritesheet (0 if not a spritesheet)
+     */
+    [[nodiscard]] virtual uint32_t getSpritesheetFrames () const = 0;
+    /**
+     * @return Duration of spritesheet animation in seconds
+     */
+    [[nodiscard]] virtual float getSpritesheetDuration () const = 0;
 };
-} // namespace WallpaperEngine::Assets
+} // namespace WallpaperEngine::Render

+ 11 - 0
src/WallpaperEngine/Render/Wallpapers/CScene.cpp

@@ -1,5 +1,6 @@
 #include "WallpaperEngine/Render/Objects/CImage.h"
 #include "WallpaperEngine/Render/Objects/CSound.h"
+#include "WallpaperEngine/Render/Objects/CParticle.h"
 
 #include "WallpaperEngine/Render/WallpaperState.h"
 
@@ -186,6 +187,16 @@ Render::CObject* CScene::createObject (const Object& object) {
         renderObject = image;
     } else if (object.is<Sound> ()) {
         renderObject = new Objects::CSound (*this, *object.as<Sound> ());
+    } else if (object.is<Particle> ()) {
+        auto* particle = new Objects::CParticle (*this, *object.as<Particle> ());
+
+        try {
+            particle->setup ();
+        } catch (std::runtime_error&) {
+            sLog.error ("Cannot setup particle ", particle->getParticle ().name);
+        }
+
+        renderObject = particle;
     }
 
     if (renderObject != nullptr)