#include "CParticle.h" #include "WallpaperEngine/Data/Model/Property.h" #include "WallpaperEngine/Logging/Log.h" #include "WallpaperEngine/Maths.h" #include "WallpaperEngine/Render/Utils/NoiseUtils.h" #include #include #include #include #include extern float g_Time; extern float g_RealTime; using namespace WallpaperEngine::Render::Objects; using namespace WallpaperEngine::Render::Utils; using namespace WallpaperEngine::Data::Model; CParticle::CParticle (Wallpapers::CScene& scene, const Particle& particle) : CObject (scene, particle), CRenderable (scene, particle, *particle.material->material), ScriptableObject (scene, particle), m_particle (particle) { this->registerProperty ("scale", *particle.scale->value); this->registerProperty ("angles", *particle.angles->value); this->registerProperty ("visible", *particle.visible->value); this->registerProperty ("parallaxDepth", *particle.parallaxDepth->value); this->detectTexture (); std::random_device rd; m_rng.seed (rd ()); // Read renderer config early - buffer sizing below depends on it if (!m_particle.renderers.empty ()) { const auto& renderer = m_particle.renderers[0]; if (renderer.name == "rope" || renderer.name == "ropetrail") { // Both rope and ropetrail use genericropeparticle shader m_useRopeRenderer = true; m_ropeSubdivision = std::max (0, static_cast (renderer.subdivision)); m_ropeUVScale = renderer.uvScale; m_ropeUVScrolling = renderer.uvScrolling; m_ropeUVSmoothing = renderer.uvSmoothing; if (renderer.name == "ropetrail") { m_useTrailRenderer = true; m_trailLength = renderer.length; m_ropeSegments = std::max (2, static_cast (renderer.segments)); } } else if (renderer.name == "spritetrail") { // spritetrail uses genericparticle with TRAILRENDERER combo m_useTrailRenderer = true; m_trailLength = renderer.length; m_trailMaxLength = renderer.maxLength; m_trailMinLength = renderer.minLength; } } float countMultiplier = particle.instanceOverride.count->value->getFloat (); uint32_t adjustedMaxCount = static_cast (particle.maxCount * countMultiplier); // Use wallpaper's specified count, or default if maxCount is 0 m_maxParticles = (adjustedMaxCount > 0) ? adjustedMaxCount : DEFAULT_MAX_PARTICLES; m_particles.resize (m_maxParticles); if (m_useRopeRenderer) { // Rope: N particles connect via (N-1) segments, each subdivided into sub-segments const int subdivision = std::max (1, m_ropeSubdivision); const int maxSubSegments = std::max (1, static_cast (m_maxParticles - 1)) * subdivision; m_vertices.resize (maxSubSegments * 4 * ROPE_FLOATS_PER_VERTEX); m_indices.resize (maxSubSegments * 6); } else { // 4 vertices, 6 indices per particle const int verticesPerParticle = 4; const int indicesPerParticle = 6; m_vertices.resize (m_maxParticles * verticesPerParticle * SPRITE_FLOATS_PER_VERTEX); m_indices.resize (m_maxParticles * indicesPerParticle); } } CParticle::~CParticle () { delete m_pass; if (m_vao != 0) { glDeleteVertexArrays (1, &m_vao); } if (m_vbo != 0) { glDeleteBuffers (1, &m_vbo); } if (m_ebo != 0) { glDeleteBuffers (1, &m_ebo); } m_vertices.clear (); m_indices.clear (); } void CParticle::setup () { if (m_initialized) { return; } // Convert origin from screen space (0,0 top-left) to centered space, matching the // ortho(-width/2, width/2, -height/2, height/2) projection m_lastScreenWidth = getScene ().getCamera ().getWidth (); m_lastScreenHeight = getScene ().getCamera ().getHeight (); glm::vec3 origin = m_particle.origin->value->getVec3 (); origin.x -= m_lastScreenWidth / 2.0f; origin.y = m_lastScreenHeight / 2.0f - origin.y; m_transformedOrigin = origin; if (m_particle.material && m_particle.material->material && !m_particle.material->material->passes.empty ()) { auto& firstPass = *m_particle.material->material->passes.begin (); // Overbright: brightness multiplier for additive particles auto overbrightIt = firstPass->constants.find ("ui_editor_properties_overbright"); if (overbrightIt != firstPass->constants.end ()) { m_overbright = overbrightIt->second->value->getFloat (); } } // TextureParser computes the spritesheet grid from TEXS frame data (animated textures) or // .tex-json metadata (static textures). GIF-style animated textures (separate GL texture per // frame) get 0 cols/rows since a 1x1 grid can't hold all frames - no SPRITESHEET mode needed, // frame switching happens via texture ID instead. if (const auto texture = getTexture ()) { m_spritesheetCols = static_cast (texture->getSpritesheetCols ()); m_spritesheetRows = static_cast (texture->getSpritesheetRows ()); m_spritesheetFrames = static_cast (texture->getSpritesheetFrames ()); m_spritesheetDuration = texture->getSpritesheetDuration (); } setupEmitters (); setupInitializers (); setupOperators (); setupPass (); 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; // flags bit 0 = linkMouse m_controlPoints[cp.id].linkMouse = (cp.flags & 1) != 0; m_controlPoints[cp.id].worldSpace = (cp.flags & 2) != 0; if (m_controlPoints[cp.id].linkMouse) { m_hasMouseControlPoint = true; } // Mouse-linked CPs get their position from update() instead if (!m_controlPoints[cp.id].linkMouse) { if (m_controlPoints[cp.id].worldSpace) { // World space: offset is in screen-centered coords, convert to particle local space m_controlPoints[cp.id].position = cp.offset - m_transformedOrigin; } else { // Local space: offset is already relative to particle system center m_controlPoints[cp.id].position = cp.offset; } } } } m_initialized = true; } void CParticle::render () { if (!m_initialized) { return; } const auto& appContext = this->getScene ().getContext ().getApp ().getContext (); const auto visibility = appContext.resolveObjectVisibility (this->getId (), this->getObject ().name); if (!visibility.value_or (m_particle.visible->value->getBool ())) { return; } syncTransformedOrigin (); // stop() drops every particle, and a later play() starts emitting from scratch const auto playback = this->getPlayback (); if (playback == Playback::Stopped) { m_particleCount = 0; } else if (m_lastPlayback == Playback::Stopped) { m_emitters.clear (); setupEmitters (); } m_lastPlayback = playback; const float currentTime = m_hasMouseControlPoint ? g_RealTime : g_Time; // Initialize time on first render to avoid a huge dt spike, and skip the update // that frame to avoid an initial burst if (m_time == 0.0) { m_time = currentTime; // "starttime" prewarms the system so it starts already populated instead of every // particle visibly leaving the emitter at once if (!m_prewarmed && m_particle.startTime > 0.0f && playback == Playback::Playing) { m_prewarmed = true; constexpr float step = 1.0f / 30.0f; m_time = currentTime - m_particle.startTime; for (float left = m_particle.startTime; left > 0.0f; left -= step) { const float dt = std::min (step, left); m_time += dt; update (dt); } m_time = currentTime; } if (m_useRopeRenderer) { renderRope (); } else { renderSprites (); } return; } float dt = currentTime - static_cast (m_time); m_time = currentTime; if (dt > 0.0f && playback != Playback::Stopped) { // Cap dt to prevent simulation instability across different FPS dt = std::min (dt, 0.1f); update (dt); } if (m_particleCount > 0 && m_particle.material) { if (m_useRopeRenderer) { renderRope (); } else { renderSprites (); } } } bool CParticle::isPlaying () const { const auto playback = this->getPlayback (); return playback == Playback::Playing || (playback == Playback::Paused && m_particleCount > 0); } // scripts can move the system every frame (e.g. an origin that follows the cursor) void CParticle::syncTransformedOrigin () { const float screenWidth = static_cast (getScene ().getWidth ()); const float screenHeight = static_cast (getScene ().getHeight ()); glm::vec3 origin = m_particle.origin->value->getVec3 (); origin.x -= screenWidth / 2.0f; origin.y = screenHeight / 2.0f - origin.y; if (origin == m_transformedOrigin && screenWidth == m_lastScreenWidth && screenHeight == m_lastScreenHeight) { return; } m_transformedOrigin = origin; m_lastScreenWidth = screenWidth; m_lastScreenHeight = screenHeight; for (auto& cp : m_controlPoints) { if (!cp.linkMouse && cp.worldSpace) { cp.position = cp.offset - m_transformedOrigin; } } } void CParticle::update (float dt) { float screenWidth = static_cast (getScene ().getWidth ()); float screenHeight = static_cast (getScene ().getHeight ()); const glm::vec2* mousePos = getScene ().getMousePositionNormalized (); if (mousePos) { for (auto& cp : m_controlPoints) { if (cp.linkMouse) { // Convert mouse position from normalized [0,1] to centered screen space glm::vec3 position; position.x = (mousePos->x * screenWidth) - (screenWidth / 2.0f); position.y = (screenHeight / 2.0f) - (mousePos->y * screenHeight); position.z = 0.0f; position += cp.offset; // Subtract transformed origin to keep in particle local space (avoids // double transformation by the model matrix) cp.position = position - m_transformedOrigin; } } } // pause() stops emission but keeps simulating what is already alive if (this->getPlayback () == Playback::Playing) { for (auto& emitter : m_emitters) { emitter (m_particles, m_particleCount, dt); } } for (uint32_t i = 0; i < m_particleCount; i++) { m_particles[i].age += dt; } for (auto& op : m_operators) { op (m_particles, m_particleCount, m_controlPoints, static_cast (m_time), dt); } for (uint32_t i = 0; i < m_particleCount; i++) { auto& p = m_particles[i]; if (m_spritesheetFrames > 0) { float lifetimePos = p.getLifetimePos (); float animSpeed = m_particle.sequenceMultiplier > 0.0f ? m_particle.sequenceMultiplier : 1.0f; if (m_particle.animationMode == "randomframe") { if (p.frame < 0.0f) { std::mt19937 particleRng ( static_cast (reinterpret_cast (&p)) ); std::uniform_int_distribution dist (0, m_spritesheetFrames - 1); p.frame = static_cast (dist (particleRng)); } } else if (m_particle.animationMode == "once") { p.frame = std::min ( lifetimePos * m_spritesheetFrames * animSpeed, static_cast (m_spritesheetFrames - 1) ); } else { 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 (m_spritesheetFrames)); } else { p.frame = std::fmod ( lifetimePos * m_spritesheetFrames * animSpeed, static_cast (m_spritesheetFrames) ); } } } } // Order-preserving compaction: particles only die from lifetime expiry (never from // size, since size can oscillate), and index 0 must stay the oldest particle uint32_t writeIdx = 0; for (uint32_t readIdx = 0; readIdx < m_particleCount; readIdx++) { if (m_particles[readIdx].isAlive ()) { if (writeIdx != readIdx) { m_particles[writeIdx] = m_particles[readIdx]; } writeIdx++; } } m_particleCount = writeIdx; } const Particle& CParticle::getParticle () const { return m_particle; } const float& CParticle::getBrightness () const { return m_overbright; } const float& CParticle::getUserAlpha () const { return m_particle.instanceOverride.alpha->value->getFloat (); } const float& CParticle::getAlpha () const { return m_particle.instanceOverride.alpha->value->getFloat (); } const glm::vec3& CParticle::getColor () const { static const glm::vec3 defaultColor (1.0f); if (m_particle.instanceOverride.color && m_particle.instanceOverride.color->value) { return m_particle.instanceOverride.color->value->getVec3 (); } return defaultColor; } const glm::vec4& CParticle::getColor4 () const { static const glm::vec4 defaultColor (1.0f); if (m_particle.instanceOverride.color && m_particle.instanceOverride.color->value) { return m_particle.instanceOverride.color->value->getVec4 (); } return defaultColor; } const glm::vec3& CParticle::getCompositeColor () const { return getColor (); } // ========== 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 (); glm::vec3 transformedEmitterOrigin = emitter.origin; transformedEmitterOrigin.y = -transformedEmitterOrigin.y; int controlPointIndex = emitter.controlPoint; if (controlPointIndex == -1 && !m_particle.controlPoints.empty ()) { const auto& cp0 = m_particle.controlPoints[0]; if ((cp0.flags & 1) != 0) { controlPointIndex = 0; } } glm::vec3 flippedDirections = emitter.directions; flippedDirections.y = -flippedDirections.y; bool limitOnePerFrame = (emitter.flags & 2) != 0; bool randomPeriodicEmission = (emitter.flags & 4) != 0; return [this, emitter, transformedEmitterOrigin, controlPointIndex, rate, flippedDirections, limitOnePerFrame, randomPeriodicEmission, emissionTimer = 0.0f, delayTimer = emitter.delay, durationTimer = 0.0f, periodicTimer = 0.0f, periodicDuration = 0.0f, periodicDelay = 0.0f, emitting = false, instantaneousEmitted = false] (std::vector& particles, uint32_t& count, float dt) mutable { if (count >= particles.size ()) { return; } if (delayTimer > 0.0f) { delayTimer -= dt; return; } if (emitter.duration > 0.0f) { durationTimer += dt; if (durationTimer >= emitter.duration) { return; } } if (randomPeriodicEmission) { periodicTimer += dt; if (!emitting) { if (periodicTimer >= periodicDelay) { emitting = true; periodicTimer = 0.0f; periodicDuration = WallpaperEngine::Maths::randomFloat ( m_rng, emitter.minPeriodicDuration, emitter.maxPeriodicDuration ); } else { return; } } else { if (periodicTimer >= periodicDuration) { emitting = false; periodicTimer = 0.0f; periodicDelay = WallpaperEngine::Maths::randomFloat ( m_rng, emitter.minPeriodicDelay, emitter.maxPeriodicDelay ); return; } } } // TODO: audio processing (audioProcessingMode, audioProcessingBounds, etc.) uint32_t toEmit = 0; if (emitter.instantaneous > 0 && !instantaneousEmitted) { toEmit = emitter.instantaneous; instantaneousEmitted = true; } if (emitter.rate > 0.0f) { emissionTimer += dt * rate; uint32_t rateEmit = static_cast (emissionTimer); emissionTimer -= static_cast (rateEmit); // limitOnePerFrame (flags bit 1): cap at 1 to prevent rope artifacts if (limitOnePerFrame && rateEmit > 1) { rateEmit = 1; } toEmit += rateEmit; } for (uint32_t i = 0; i < toEmit && count < particles.size (); i++) { auto& p = particles[count]; glm::vec3 spawnOrigin = transformedEmitterOrigin; if (controlPointIndex >= 0 && controlPointIndex < static_cast (m_controlPoints.size ())) { spawnOrigin += m_controlPoints[controlPointIndex].position; } // Random position within the box volume (hollow box if distanceMin > 0) glm::vec3 randomPos; for (int axis = 0; axis < 3; axis++) { float minDist = emitter.distanceMin[axis]; float maxDist = emitter.distanceMax[axis]; float dist = WallpaperEngine::Maths::randomFloat (m_rng, minDist, maxDist); // Randomly flip sign to center the distribution if (WallpaperEngine::Maths::randomFloat (m_rng, 0.0f, 1.0f) < 0.5f) { dist = -dist; } randomPos[axis] = dist; } randomPos *= flippedDirections; p.position = spawnOrigin + randomPos; // Emitter does not set velocity - initializers handle that p.velocity = glm::vec3 (0.0f); 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 = 1.0f * m_particle.instanceOverride.lifetime->value->getFloat (); p.age = 0.0f; p.alive = true; p.frame = -1.0f; p.initial.color = p.color; p.initial.alpha = p.alpha; p.initial.size = p.size; p.initial.lifetime = p.lifetime; // Reset oscillator state for reused particles p.oscillateAlpha = {}; p.oscillateSize = {}; p.oscillatePosition = {}; 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 (); // Convert emitter origin from screen space (Y down) to centered space (Y up) glm::vec3 transformedEmitterOrigin = emitter.origin; transformedEmitterOrigin.y = -transformedEmitterOrigin.y; int controlPointIndex = emitter.controlPoint; // Auto-detect control point 0 if not specified and CP0 has linkMouse if (controlPointIndex == -1 && !m_particle.controlPoints.empty ()) { const auto& cp0 = m_particle.controlPoints[0]; if ((cp0.flags & 1) != 0) { // bit 0 = linkMouse controlPointIndex = 0; } } bool limitOnePerFrame = (emitter.flags & 2) != 0; return [this, emitter, transformedEmitterOrigin, controlPointIndex, rate, lifetime, limitOnePerFrame, emissionTimer = 0.0f, remaining = emitter.instantaneous] (std::vector& particles, uint32_t& count, float dt) mutable { if (count >= particles.size ()) { return; } emissionTimer += dt * rate; uint32_t toEmit = static_cast (emissionTimer); emissionTimer -= static_cast (toEmit); // limitOnePerFrame (flags bit 1): cap at 1 to prevent rope artifacts if (limitOnePerFrame && toEmit > 1) { toEmit = 1; } if (remaining > 0) { toEmit = remaining; remaining = 0; } for (uint32_t i = 0; i < toEmit && count < particles.size (); i++) { auto& p = particles[count]; glm::vec3 spawnOrigin = transformedEmitterOrigin; if (controlPointIndex >= 0 && controlPointIndex < static_cast (m_controlPoints.size ())) { spawnOrigin += m_controlPoints[controlPointIndex].position; } glm::vec3 randomPos; // flags & 4 == 0: orthographic particles use a 2D disk distribution in X/Y // flags & 4 != 0: perspective particles use a 3D spherical shell distribution if ((m_particle.flags & 4) == 0) { float angle = WallpaperEngine::Maths::randomFloat (m_rng, 0.0f, glm::two_pi ()); float minRadius = emitter.distanceMin.x; float maxRadius = emitter.distanceMax.x; // Use sqrt for uniform area distribution in annulus float minRadiusSq = minRadius * minRadius; float maxRadiusSq = maxRadius * maxRadius; float radiusXY = std::sqrt (WallpaperEngine::Maths::randomFloat (m_rng, minRadiusSq, maxRadiusSq)); randomPos = glm::vec3 ( radiusXY * std::cos (angle), radiusXY * std::sin (angle), WallpaperEngine::Maths::randomFloat (m_rng, -maxRadius, maxRadius) ); randomPos *= emitter.directions; } else { float theta = WallpaperEngine::Maths::randomFloat (m_rng, 0.0f, glm::two_pi ()); float cosTheta = WallpaperEngine::Maths::randomFloat (m_rng, -1.0f, 1.0f); float sinTheta = std::sqrt (1.0f - cosTheta * cosTheta); randomPos = glm::vec3 (sinTheta * std::cos (theta), sinTheta * std::sin (theta), cosTheta); // Use cubic root for uniform volume distribution float minRadius = emitter.distanceMin.x; float maxRadius = emitter.distanceMax.x; float minRadiusCubed = minRadius * minRadius * minRadius; float maxRadiusCubed = maxRadius * maxRadius * maxRadius; float radius = std::cbrt (WallpaperEngine::Maths::randomFloat (m_rng, minRadiusCubed, maxRadiusCubed)); randomPos *= radius; randomPos *= emitter.directions; } // sign property forces per-axis polarity: 0 = both, 1 = positive only, -1 = negative only for (int i = 0; i < 3; i++) { if (emitter.sign[i] == 1) { randomPos[i] = std::abs (randomPos[i]); } else if (emitter.sign[i] == -1) { randomPos[i] = -std::abs (randomPos[i]); } } p.position = spawnOrigin + randomPos; // Set velocity only if emitter specifies speed (otherwise use initializers) if (emitter.speedMax > 0.0f || emitter.speedMin != 0.0f) { // Velocity pointing outward from ellipsoid (randomPos already includes directions scaling) glm::vec3 direction = glm::length (randomPos) > 0.0f ? glm::normalize (randomPos) : glm::vec3 (0.0f, 1.0f, 0.0f); float speed = WallpaperEngine::Maths::randomFloat (m_rng, emitter.speedMin, emitter.speedMax); p.velocity = direction * speed; } else { p.velocity = glm::vec3 (0.0f); } 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.frame = -1.0f; p.initial.color = p.color; p.initial.alpha = p.alpha; p.initial.size = p.size; p.initial.lifetime = p.lifetime; // Reset oscillator state for reused particles p.oscillateAlpha = {}; p.oscillateSize = {}; p.oscillatePosition = {}; 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 ()) { func = createColorRandomInitializer (*initializer->as ()); } else if (initializer->is ()) { func = createSizeRandomInitializer (*initializer->as ()); } else if (initializer->is ()) { func = createAlphaRandomInitializer (*initializer->as ()); } else if (initializer->is ()) { const auto& lifeInit = *initializer->as (); m_uniformLifetimes = (lifeInit.min->value->getFloat () == lifeInit.max->value->getFloat ()); func = createLifetimeRandomInitializer (lifeInit); } else if (initializer->is ()) { func = createVelocityRandomInitializer (*initializer->as ()); } else if (initializer->is ()) { func = createRotationRandomInitializer (*initializer->as ()); } else if (initializer->is ()) { func = createAngularVelocityRandomInitializer (*initializer->as ()); } else if (initializer->is ()) { func = createTurbulentVelocityRandomInitializer (*initializer->as ()); } else if (initializer->is ()) { func = createMapSequenceAroundControlPointInitializer ( *initializer->as () ); } 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 (); DynamicValue* colorOverride = m_particle.instanceOverride.colorn->value.get (); return [this, minValue, maxValue, colorOverride] (ParticleInstance& p) { p.color = WallpaperEngine::Maths::randomVec3 (m_rng, minValue->getVec3 (), maxValue->getVec3 ()) * colorOverride->getVec3 (); p.initial.color = p.color; }; } InitializerFunc CParticle::createSizeRandomInitializer (const SizeRandomInitializer& init) { DynamicValue* minValue = init.min->value.get (); DynamicValue* maxValue = init.max->value.get (); DynamicValue* exponentValue = init.exponent->value.get (); DynamicValue* sizeOverride = m_particle.instanceOverride.size->value.get (); return [this, minValue, maxValue, exponentValue, sizeOverride] (ParticleInstance& p) { float t = WallpaperEngine::Maths::randomFloat (m_rng, 0.0f, 1.0f); float exponent = exponentValue->getFloat (); float min = minValue->getFloat (); float max = maxValue->getFloat (); // Apply exponent for non-linear distribution float adjustedT = std::pow (t, exponent); p.size = (min + adjustedT * (max - min)) * sizeOverride->getFloat () / 2.0f; p.initial.size = p.size; }; } InitializerFunc CParticle::createAlphaRandomInitializer (const AlphaRandomInitializer& init) { DynamicValue* minValue = init.min->value.get (); DynamicValue* maxValue = init.max->value.get (); DynamicValue* alphaOverride = m_particle.instanceOverride.alpha->value.get (); return [this, minValue, maxValue, alphaOverride] (ParticleInstance& p) { p.alpha = WallpaperEngine::Maths::randomFloat (m_rng, minValue->getFloat (), maxValue->getFloat ()) * alphaOverride->getFloat (); p.initial.alpha = p.alpha; }; } InitializerFunc CParticle::createLifetimeRandomInitializer (const LifetimeRandomInitializer& init) { DynamicValue* minValue = init.min->value.get (); DynamicValue* maxValue = init.max->value.get (); DynamicValue* lifetimeOverride = m_particle.instanceOverride.lifetime->value.get (); return [this, minValue, maxValue, lifetimeOverride] (ParticleInstance& p) { p.lifetime = WallpaperEngine::Maths::randomFloat (m_rng, minValue->getFloat (), maxValue->getFloat ()) * lifetimeOverride->getFloat (); p.initial.lifetime = p.lifetime; }; } InitializerFunc CParticle::createVelocityRandomInitializer (const VelocityRandomInitializer& init) { DynamicValue* minValue = init.min->value.get (); DynamicValue* maxValue = init.max->value.get (); DynamicValue* speedOverride = m_particle.instanceOverride.speed->value.get (); return [this, minValue, maxValue, speedOverride] (ParticleInstance& p) { glm::vec3 vel = WallpaperEngine::Maths::randomVec3 (m_rng, minValue->getVec3 (), maxValue->getVec3 ()) * speedOverride->getFloat (); vel.y = -vel.y; p.velocity += vel; }; } InitializerFunc CParticle::createRotationRandomInitializer (const RotationRandomInitializer& init) { DynamicValue* minValue = init.min->value.get (); DynamicValue* maxValue = init.max->value.get (); DynamicValue* speedOverride = m_particle.instanceOverride.speed->value.get (); return [this, minValue, maxValue, speedOverride] (ParticleInstance& p) { p.rotation = WallpaperEngine::Maths::randomVec3 (m_rng, minValue->getVec3 (), maxValue->getVec3 ()) * speedOverride->getFloat (); }; } InitializerFunc CParticle::createAngularVelocityRandomInitializer (const AngularVelocityRandomInitializer& init) { DynamicValue* minValue = init.min->value.get (); DynamicValue* maxValue = init.max->value.get (); DynamicValue* exponentValue = init.exponent->value.get (); DynamicValue* speedOverride = m_particle.instanceOverride.speed->value.get (); return [this, minValue, maxValue, exponentValue, speedOverride] (ParticleInstance& p) { glm::vec3 minVec = minValue->getVec3 (); glm::vec3 maxVec = maxValue->getVec3 (); float exponent = exponentValue->getFloat (); // exponent = 1: uniform; exponent -> 0: bias towards max; exponent >= 2: bias towards min glm::vec3 result; for (int i = 0; i < 3; i++) { float t = WallpaperEngine::Maths::randomFloat (m_rng, 0.0f, 1.0f); t = std::pow (t, exponent); result[i] = minVec[i] + t * (maxVec[i] - minVec[i]); } p.angularVelocity = result * speedOverride->getFloat (); }; } InitializerFunc CParticle::createTurbulentVelocityRandomInitializer (const TurbulentVelocityRandomInitializer& init) { DynamicValue* speedMin = init.speedMin->value.get (); DynamicValue* speedMax = init.speedMax->value.get (); DynamicValue* offsetVal = init.offset->value.get (); DynamicValue* scaleVal = init.scale->value.get (); DynamicValue* forwardVal = init.forward->value.get (); DynamicValue* timeScaleVal = init.timeScale->value.get (); DynamicValue* phaseMinVal = init.phaseMin->value.get (); DynamicValue* phaseMaxVal = init.phaseMax->value.get (); DynamicValue* rightVal = init.right->value.get (); DynamicValue* speedOverride = m_particle.instanceOverride.speed->value.get (); return [this, speedMin, speedMax, offsetVal, scaleVal, forwardVal, timeScaleVal, phaseMinVal, phaseMaxVal, rightVal, speedOverride] (ParticleInstance& p) { glm::vec3 forward = forwardVal->getVec3 (); glm::vec3 right = rightVal->getVec3 (); // Y-flip for coordinate system conversion forward.y = -forward.y; right.y = -right.y; if (glm::length (forward) > 0.0001f) { forward = glm::normalize (forward); } else { // Default forward direction when not specified (up in centered space) forward = glm::vec3 (0.0f, 1.0f, 0.0f); } if (glm::length (right) > 0.0001f) { right = glm::normalize (right); } else { right = glm::vec3 (1.0f, 0.0f, 0.0f); } float speed = WallpaperEngine::Maths::randomFloat (m_rng, speedMin->getFloat (), speedMax->getFloat ()); float scale = scaleVal->getFloat (); float offset = offsetVal->getFloat (); float timeScale = timeScaleVal->getFloat (); float phaseMin = phaseMinVal->getFloat (); float phaseMax = phaseMaxVal->getFloat (); // Sample noise at position + time offset: timescale shifts the field over time so // particles spawned at different times drift differently (evolving vapor stream); // the position term gives spatial coherence between nearby particles. glm::vec3 noisePos = p.position * 0.1f; noisePos += glm::vec3 (static_cast (m_time) * timeScale); // Phase adds per-particle randomization to noise position float phase = WallpaperEngine::Maths::randomFloat (m_rng, phaseMin, phaseMax); glm::vec3 samplePos = noisePos + glm::vec3 (phase, phase * 0.7f, phase * 1.3f); glm::vec3 result = curlNoise (samplePos); float len = glm::length (result); if (len < 0.0001f) { result = forward; } else { result = result / len; } // Scale limits how far direction can deviate from forward if (scale < 2.0f) { float cosAngle = glm::dot (result, forward); float angle = std::acos (glm::clamp (cosAngle, -1.0f, 1.0f)) / glm::pi (); float maxAngle = scale / 2.0f; if (angle > maxAngle && maxAngle > 0.0001f) { glm::vec3 axis = glm::cross (result, forward); float axisLen = glm::length (axis); if (axisLen > 0.0001f) { axis = axis / axisLen; float rotAngle = (angle - maxAngle) * glm::pi (); glm::mat3 rot = glm::mat3 (glm::rotate (glm::mat4 (1.0f), rotAngle, axis)); result = rot * result; } } } // Offset rotates result around right axis (tilts up/down) if (std::abs (offset) > 0.0001f) { glm::mat3 rot = glm::mat3 (glm::rotate (glm::mat4 (1.0f), -offset, right)); result = rot * result; } // 2D/orthographic particles (flags & 4 == 0): project onto XY. curlNoise is 3D but // z-drift is meaningless here and makes rope segments diverge in depth. if ((m_particle.flags & 4) == 0) { result.z = 0.0f; float len2d = glm::length (result); if (len2d > 0.0001f) { result /= len2d; } } glm::vec3 finalVel = result * speed * speedOverride->getFloat (); p.velocity += finalVel; }; } InitializerFunc CParticle::createMapSequenceAroundControlPointInitializer (const MapSequenceAroundControlPointInitializer& init) { DynamicValue* controlPointValue = init.controlPoint->value.get (); DynamicValue* countValue = init.count->value.get (); DynamicValue* speedMinValue = init.speedMin->value.get (); DynamicValue* speedMaxValue = init.speedMax->value.get (); DynamicValue* speedOverride = m_particle.instanceOverride.speed->value.get (); // Sequence counter is shared (closure state) across all particles spawned by this // initializer, giving each one a distinct angle around the circle int sequenceIndex = 0; return [this, controlPointValue, countValue, speedMinValue, speedMaxValue, sequenceIndex, speedOverride] (ParticleInstance& p) mutable { int controlPoint = static_cast (controlPointValue->getFloat ()); int count = static_cast (countValue->getFloat ()); if (count < 1) { count = 1; } float angle = (static_cast (sequenceIndex) / static_cast (count)) * glm::two_pi (); sequenceIndex = (sequenceIndex + 1) % count; glm::vec3 centerPos = glm::vec3 (0.0f); if (controlPoint >= 0 && controlPoint < static_cast (m_controlPoints.size ())) { centerPos = m_controlPoints[controlPoint].position; } p.position = centerPos; glm::vec3 speedMin = speedMinValue->getVec3 (); glm::vec3 speedMax = speedMaxValue->getVec3 (); glm::vec3 speed = WallpaperEngine::Maths::randomVec3 (m_rng, speedMin, speedMax); // Flip Y before rotation to convert to centered space speed.y = -speed.y; // Rotating by the sequence angle gives the outward radial/circular pattern glm::mat3 rotationMatrix = glm::mat3 ( std::cos (angle), -std::sin (angle), 0.0f, std::sin (angle), std::cos (angle), 0.0f, 0.0f, 0.0f, 1.0f ); glm::vec3 rotatedSpeed = rotationMatrix * speed * speedOverride->getFloat (); p.velocity = rotatedSpeed; }; } // ========== OPERATORS ========== void CParticle::setupOperators () { for (const auto& op : m_particle.operators) { if (!op) { continue; } OperatorFunc func; if (op->is ()) { func = createMovementOperator (*op->as ()); } else if (op->is ()) { func = createAngularMovementOperator (*op->as ()); } else if (op->is ()) { func = createAlphaFadeOperator (*op->as ()); } else if (op->is ()) { func = createSizeChangeOperator (*op->as ()); } else if (op->is ()) { func = createAlphaChangeOperator (*op->as ()); } else if (op->is ()) { func = createColorChangeOperator (*op->as ()); } else if (op->is ()) { func = createTurbulenceOperator (*op->as ()); } else if (op->is ()) { func = createVortexOperator (*op->as ()); } else if (op->is ()) { func = createControlPointAttractOperator (*op->as ()); } else if (op->is ()) { func = createOscillateAlphaOperator (*op->as ()); } else if (op->is ()) { func = createOscillateSizeOperator (*op->as ()); } else if (op->is ()) { func = createOscillatePositionOperator (*op->as ()); } else { sLog.out ("Unknown operator type"); } if (func) { m_operators.push_back (std::move (func)); } } } OperatorFunc CParticle::createMovementOperator (const MovementOperator& op) { DynamicValue* dragValue = op.drag->value.get (); DynamicValue* gravityValue = op.gravity->value.get (); DynamicValue* speedOverride = m_particle.instanceOverride.speed->value.get (); return [dragValue, gravityValue, speedOverride] ( std::vector& particles, uint32_t count, const std::vector&, float, float dt ) { float speed = speedOverride->getFloat (); float drag = dragValue->getFloat (); glm::vec3 gravity = gravityValue->getVec3 (); // Flip gravity Y for centered space gravity.y = -gravity.y; for (uint32_t i = 0; i < count; i++) { auto& p = particles[i]; if (!p.alive) { continue; } // Integrate position from current velocity (already speed-scaled) before // updating velocity for next frame p.position += p.velocity * dt; p.velocity += gravity * dt * speed; // Drag decay, clamped so drag*dt > 1.0 can't reverse velocity float dragFactor = 1.0f - (drag * dt); if (dragFactor < 0.0f) { dragFactor = 0.0f; } p.velocity *= dragFactor; } }; } OperatorFunc CParticle::createAngularMovementOperator (const AngularMovementOperator& op) { DynamicValue* dragValue = op.drag->value.get (); DynamicValue* forceValue = op.force->value.get (); DynamicValue* speedOverride = m_particle.instanceOverride.speed->value.get (); return [dragValue, forceValue, speedOverride] ( std::vector& particles, uint32_t count, const std::vector&, float, float dt ) { float drag = dragValue->getFloat (); float speed = speedOverride->getFloat (); glm::vec3 force = forceValue->getVec3 (); for (uint32_t i = 0; i < count; i++) { auto& p = particles[i]; if (!p.alive) { continue; } p.rotation += p.angularVelocity * dt * speed; p.angularVelocity += force * dt * speed; // Positive drag slows down, negative speeds up; clamped so drag*dt > 1.0 can't reverse it float dragFactor = 1.0f - (drag * dt); if (dragFactor < 0.0f) { dragFactor = 0.0f; } p.angularVelocity *= dragFactor; // Wrap rotation to prevent floating-point precision issues const float pi = glm::pi (); const float two_pi = glm::two_pi (); for (int j = 0; j < 3; j++) { while (p.rotation[j] > pi) { p.rotation[j] -= two_pi; } while (p.rotation[j] < -pi) { p.rotation[j] += two_pi; } } } }; } OperatorFunc CParticle::createAlphaFadeOperator (const AlphaFadeOperator& op) { DynamicValue* fadeInTimeValue = op.fadeInTime->value.get (); DynamicValue* fadeOutTimeValue = op.fadeOutTime->value.get (); return [fadeInTimeValue, fadeOutTimeValue] ( std::vector& particles, uint32_t count, const std::vector&, float, float ) { float fadeInTime = fadeInTimeValue->getFloat (); float fadeOutTime = fadeOutTimeValue->getFloat (); for (uint32_t i = 0; i < count; i++) { auto& p = particles[i]; if (!p.alive) { continue; } float life = p.getLifetimePos (); if (life <= fadeInTime) { float fade = WallpaperEngine::Maths::fadeValue (life, 0.0f, fadeInTime, 0.0f, 1.0f); p.alpha = p.initial.alpha * fade; } else if (life > fadeOutTime) { float fade = 1.0f - WallpaperEngine::Maths::fadeValue (life, fadeOutTime, 1.0f, 0.0f, 1.0f); p.alpha = p.initial.alpha * fade; } else { p.alpha = p.initial.alpha; } // Update oscillator base so oscillateAlpha combines properly p.oscillateAlpha.base = p.alpha; } }; } OperatorFunc CParticle::createSizeChangeOperator (const SizeChangeOperator& op) { DynamicValue* startTimeValue = op.startTime->value.get (); DynamicValue* endTimeValue = op.endTime->value.get (); DynamicValue* startValueValue = op.startValue->value.get (); DynamicValue* endValueValue = op.endValue->value.get (); return [startTimeValue, endTimeValue, startValueValue, endValueValue] ( std::vector& particles, uint32_t count, const std::vector&, float, float ) { float startTime = startTimeValue->getFloat (); float endTime = endTimeValue->getFloat (); float startValue = startValueValue->getFloat (); float endValue = endValueValue->getFloat (); for (uint32_t i = 0; i < count; i++) { auto& p = particles[i]; if (!p.alive) { continue; } float life = p.getLifetimePos (); float multiplier = WallpaperEngine::Maths::fadeValue (life, startTime, endTime, startValue, endValue); p.size = p.initial.size * multiplier; // Update oscillator base so oscillateSize combines properly p.oscillateSize.base = p.size; } }; } OperatorFunc CParticle::createAlphaChangeOperator (const AlphaChangeOperator& op) { DynamicValue* startTimeValue = op.startTime->value.get (); DynamicValue* endTimeValue = op.endTime->value.get (); DynamicValue* startValueValue = op.startValue->value.get (); DynamicValue* endValueValue = op.endValue->value.get (); return [startTimeValue, endTimeValue, startValueValue, endValueValue] ( std::vector& particles, uint32_t count, const std::vector&, float, float ) { float startTime = startTimeValue->getFloat (); float endTime = endTimeValue->getFloat (); float startValue = startValueValue->getFloat (); float endValue = endValueValue->getFloat (); for (uint32_t i = 0; i < count; i++) { auto& p = particles[i]; if (!p.alive) { continue; } float life = p.getLifetimePos (); float multiplier = WallpaperEngine::Maths::fadeValue (life, startTime, endTime, startValue, endValue); p.alpha = p.initial.alpha * multiplier; // Update oscillator base so oscillateAlpha combines properly p.oscillateAlpha.base = p.alpha; } }; } OperatorFunc CParticle::createColorChangeOperator (const ColorChangeOperator& op) { DynamicValue* startTimeValue = op.startTime->value.get (); DynamicValue* endTimeValue = op.endTime->value.get (); DynamicValue* startValueValue = op.startValue->value.get (); DynamicValue* endValueValue = op.endValue->value.get (); return [startTimeValue, endTimeValue, startValueValue, endValueValue] ( std::vector& particles, uint32_t count, const std::vector&, float, float ) { float startTime = startTimeValue->getFloat (); float endTime = endTimeValue->getFloat (); glm::vec3 startValue = startValueValue->getVec3 (); glm::vec3 endValue = endValueValue->getVec3 (); for (uint32_t i = 0; i < count; i++) { auto& p = particles[i]; if (!p.alive) { continue; } float life = p.getLifetimePos (); glm::vec3 color; color.r = WallpaperEngine::Maths::fadeValue (life, startTime, endTime, startValue.r, endValue.r); color.g = WallpaperEngine::Maths::fadeValue (life, startTime, endTime, startValue.g, endValue.g); color.b = WallpaperEngine::Maths::fadeValue (life, startTime, endTime, startValue.b, endValue.b); p.color = p.initial.color * color; } }; } OperatorFunc CParticle::createTurbulenceOperator (const TurbulenceOperator& op) { DynamicValue* scaleValue = op.scale->value.get (); DynamicValue* speedMinValue = op.speedMin->value.get (); DynamicValue* speedMaxValue = op.speedMax->value.get (); DynamicValue* timeScaleValue = op.timeScale->value.get (); DynamicValue* maskValue = op.mask->value.get (); DynamicValue* phaseMinValue = op.phaseMin->value.get (); DynamicValue* phaseMaxValue = op.phaseMax->value.get (); DynamicValue* speedOverride = m_particle.instanceOverride.speed->value.get (); // TODO: audio processing support (audioProcessingMode/Bounds/Exponent/FrequencyStart/FrequencyEnd) // Phase and speed are randomized once per operator instance, not per particle const float phase = WallpaperEngine::Maths::randomFloat (m_rng, phaseMinValue->getFloat (), phaseMaxValue->getFloat ()); const float turbSpeed = WallpaperEngine::Maths::randomFloat (m_rng, speedMinValue->getFloat (), speedMaxValue->getFloat ()); return [scaleValue, timeScaleValue, maskValue, speedOverride, phase, turbSpeed] ( std::vector& particles, uint32_t count, const std::vector&, float currentTime, float dt ) { const float noiseScale = scaleValue->getFloat () * 2.0f; const float timeScale = timeScaleValue->getFloat (); const glm::vec3 mask = maskValue->getVec3 (); const float speed = speedOverride->getFloat (); if (turbSpeed <= 0.0001f) { return; } for (size_t i = 0; i < count; ++i) { ParticleInstance& p = particles[i]; if (!p.alive) { continue; } glm::vec3 noisePos = p.position; noisePos.x += phase + timeScale * currentTime; noisePos *= noiseScale; glm::vec3 curlDir = curlNoise (noisePos); const float len = glm::length (curlDir); if (len > 0.0001f) { curlDir = (curlDir / len) * turbSpeed; } curlDir *= mask; p.velocity += curlDir * dt * speed; } }; } OperatorFunc CParticle::createVortexOperator (const VortexOperator& op) { int controlPoint = op.controlPoint; int flags = op.flags; DynamicValue* axisValue = op.axis->value.get (); DynamicValue* offsetValue = op.offset->value.get (); DynamicValue* distanceInnerValue = op.distanceInner->value.get (); DynamicValue* distanceOuterValue = op.distanceOuter->value.get (); DynamicValue* speedInnerValue = op.speedInner->value.get (); DynamicValue* speedOuterValue = op.speedOuter->value.get (); DynamicValue* centerForceValue = op.centerForce->value.get (); DynamicValue* ringRadiusValue = op.ringRadius->value.get (); DynamicValue* ringWidthValue = op.ringWidth->value.get (); DynamicValue* ringPullDistanceValue = op.ringPullDistance->value.get (); DynamicValue* ringPullForceValue = op.ringPullForce->value.get (); DynamicValue* audioModeValue = op.audioProcessingMode->value.get (); DynamicValue* speedOverride = m_particle.instanceOverride.speed->value.get (); int audioMode = static_cast (audioModeValue->getFloat ()); bool infiniteAxis = (flags & 1) != 0; bool maintainDistance = (flags & 2) != 0; bool ringShape = (flags & 4) != 0; return [controlPoint, axisValue, offsetValue, distanceInnerValue, distanceOuterValue, speedInnerValue, speedOuterValue, centerForceValue, ringRadiusValue, ringWidthValue, ringPullDistanceValue, ringPullForceValue, audioMode, infiniteAxis, maintainDistance, ringShape, speedOverride] ( std::vector& particles, uint32_t count, const std::vector& controlPoints, float, float dt ) { float audioAmplitude = 0.0f; // TODO: sample from AudioContext once audio processing is implemented // Audio mode enabled but no audio available yet - skip vortex entirely if (audioMode > 0 && audioAmplitude == 0.0f) { return; } glm::vec3 axis = axisValue->getVec3 (); glm::vec3 offset = offsetValue->getVec3 (); float distanceInner = distanceInnerValue->getFloat (); float distanceOuter = distanceOuterValue->getFloat (); float speedInner = speedInnerValue->getFloat (); float speedOuter = speedOuterValue->getFloat (); float centerForce = centerForceValue->getFloat (); float ringRadius = ringRadiusValue->getFloat (); float ringWidth = ringWidthValue->getFloat (); float ringPullDistance = ringPullDistanceValue->getFloat (); float ringPullForce = ringPullForceValue->getFloat (); if (audioMode > 0) { speedInner *= (1.0f + audioAmplitude); speedOuter *= (1.0f + audioAmplitude); } glm::vec3 center = glm::vec3 (0.0f); if (controlPoint >= 0 && controlPoint < static_cast (controlPoints.size ())) { center = controlPoints[controlPoint].position + offset; } else { center = offset; } if (glm::length (axis) > 0.0f) { axis = glm::normalize (axis); } else { axis = glm::vec3 (0.0f, 0.0f, 1.0f); } for (uint32_t i = 0; i < count; i++) { auto& p = particles[i]; if (!p.alive) { continue; } glm::vec3 toParticle = p.position - center; // infiniteAxis: project onto the plane perpendicular to axis (cylinder shape); // otherwise use full 3D distance (sphere shape) float axialDistance = 0.0f; glm::vec3 radialVector = toParticle; if (infiniteAxis) { axialDistance = glm::dot (toParticle, axis); radialVector = toParticle - axis * axialDistance; } float distance = glm::length (radialVector); glm::vec3 tangent = glm::cross (axis, radialVector); if (glm::length (tangent) > 0.001f) { tangent = glm::normalize (tangent); } else { continue; // particle is on the axis } float speed = 0.0f; glm::vec3 radialForce = glm::vec3 (0.0f); if (ringShape) { // Ring mode: hollow center with ring-shaped influence zone float ringInner = ringRadius - ringWidth * 0.5f; float ringOuter = ringRadius + ringWidth * 0.5f; if (distance < ringInner) { // Inside the ring's hollow center - no spin, but may be pulled outward speed = 0.0f; } else if (distance <= ringOuter) { // Inside the ring - full effect float t = (distance - ringInner) / ringWidth; speed = glm::mix (speedInner, speedOuter, t); } else if (distance <= ringOuter + ringPullDistance) { // Outside ring but within pull distance - attract toward ring float pullT = (distance - ringOuter) / ringPullDistance; speed = speedOuter * (1.0f - pullT); if (distance > 0.001f) { glm::vec3 towardRing = -glm::normalize (radialVector); radialForce = towardRing * ringPullForce * pullT; } } else { // Too far from ring - no effect speed = 0.0f; } } else { // Standard vortex mode float disMid = distanceOuter - distanceInner + 0.1f; if (disMid < 0 || distance < distanceInner) { speed = speedInner; } else if (distance > distanceOuter) { speed = speedOuter; } else { float t = (distance - distanceInner) / disMid; speed = glm::mix (speedInner, speedOuter, t); } } p.velocity += tangent * speed * dt * speedOverride->getFloat (); p.velocity += radialForce * dt * speedOverride->getFloat (); if (maintainDistance && distance > 0.001f) { glm::vec3 towardCenter = -glm::normalize (radialVector); p.velocity += towardCenter * centerForce * dt * speedOverride->getFloat (); } } }; } OperatorFunc CParticle::createControlPointAttractOperator (const ControlPointAttractOperator& op) { int controlPoint = op.controlPoint; DynamicValue* originValue = op.origin->value.get (); DynamicValue* scaleValue = op.scale->value.get (); DynamicValue* thresholdValue = op.threshold->value.get (); DynamicValue* speedOverride = m_particle.instanceOverride.speed->value.get (); return [controlPoint, originValue, scaleValue, thresholdValue, speedOverride] ( std::vector& particles, uint32_t count, const std::vector& controlPoints, float currentTime, float dt ) { glm::vec3 origin = originValue->getVec3 (); float scale = scaleValue->getFloat (); float threshold = thresholdValue->getFloat () / 2.0f; if (controlPoint < 0 || controlPoint >= static_cast (controlPoints.size ())) { return; } glm::vec3 center = controlPoints[controlPoint].position + origin; for (uint32_t i = 0; i < count; i++) { auto& p = particles[i]; if (!p.alive) { continue; } glm::vec3 toCenter = center - p.position; float distance = glm::length (toCenter); if (distance > 0.001f && distance < threshold) { glm::vec3 direction = toCenter / distance; glm::vec3 forceVec = direction * scale * dt; p.velocity += forceVec * speedOverride->getFloat (); } } }; } OperatorFunc CParticle::createOscillateAlphaOperator (const OscillateAlphaOperator& op) { DynamicValue* freqMinValue = op.frequencyMin->value.get (); DynamicValue* freqMaxValue = op.frequencyMax->value.get (); DynamicValue* scaleMinValue = op.scaleMin->value.get (); DynamicValue* scaleMaxValue = op.scaleMax->value.get (); DynamicValue* phaseMinValue = op.phaseMin->value.get (); DynamicValue* phaseMaxValue = op.phaseMax->value.get (); return [this, freqMinValue, freqMaxValue, scaleMinValue, scaleMaxValue, phaseMinValue, phaseMaxValue] ( std::vector& particles, uint32_t count, const std::vector&, float, float ) { float freqMin = freqMinValue->getFloat (); float freqMax = freqMaxValue->getFloat (); float scaleMin = scaleMinValue->getFloat (); float scaleMax = scaleMaxValue->getFloat (); float phaseMin = phaseMinValue->getFloat (); float phaseMax = phaseMaxValue->getFloat (); for (uint32_t i = 0; i < count; i++) { auto& p = particles[i]; // Initialize per-particle oscillator values on first use if (!p.oscillateAlpha.initialized) { p.oscillateAlpha.frequency = WallpaperEngine::Maths::randomFloat (m_rng, freqMin, freqMax); p.oscillateAlpha.scale = WallpaperEngine::Maths::randomFloat (m_rng, scaleMin, scaleMax); p.oscillateAlpha.phase = WallpaperEngine::Maths::randomFloat (m_rng, phaseMin, phaseMax + 2.0f * glm::pi ()); p.oscillateAlpha.base = p.alpha; p.oscillateAlpha.initialized = true; } // Cosine wave interpolating between scaleMin and scaleMax float w = p.oscillateAlpha.frequency; float t = p.age; float cosVal = (std::cos (w * t + p.oscillateAlpha.phase) + 1.0f) * 0.5f; float multiplier = glm::mix (scaleMin, scaleMax, cosVal); // Apply to base value (alphafade updates base each frame if present) p.alpha = p.oscillateAlpha.base * multiplier; } }; } OperatorFunc CParticle::createOscillateSizeOperator (const OscillateSizeOperator& op) { DynamicValue* freqMinValue = op.frequencyMin->value.get (); DynamicValue* freqMaxValue = op.frequencyMax->value.get (); DynamicValue* scaleMinValue = op.scaleMin->value.get (); DynamicValue* scaleMaxValue = op.scaleMax->value.get (); DynamicValue* phaseMinValue = op.phaseMin->value.get (); DynamicValue* phaseMaxValue = op.phaseMax->value.get (); return [this, freqMinValue, freqMaxValue, scaleMinValue, scaleMaxValue, phaseMinValue, phaseMaxValue] ( std::vector& particles, uint32_t count, const std::vector&, float, float ) { float freqMin = freqMinValue->getFloat (); float freqMax = freqMaxValue->getFloat (); float scaleMin = scaleMinValue->getFloat (); float scaleMax = scaleMaxValue->getFloat (); float phaseMin = phaseMinValue->getFloat (); float phaseMax = phaseMaxValue->getFloat (); for (uint32_t i = 0; i < count; i++) { auto& p = particles[i]; // Initialize per-particle oscillator values on first use if (!p.oscillateSize.initialized) { p.oscillateSize.frequency = WallpaperEngine::Maths::randomFloat (m_rng, freqMin, freqMax); p.oscillateSize.scale = WallpaperEngine::Maths::randomFloat (m_rng, scaleMin, scaleMax); p.oscillateSize.phase = WallpaperEngine::Maths::randomFloat (m_rng, phaseMin, phaseMax + 2.0f * glm::pi ()); p.oscillateSize.base = p.size; p.oscillateSize.initialized = true; } // Cosine wave interpolating between scaleMin and scaleMax float w = p.oscillateSize.frequency; float t = p.age; float cosVal = (std::cos (w * t + p.oscillateSize.phase) + 1.0f) * 0.5f; float multiplier = glm::mix (scaleMin, scaleMax, cosVal); // Apply to base value (sizeChange updates base each frame if present) p.size = p.oscillateSize.base * multiplier; } }; } OperatorFunc CParticle::createOscillatePositionOperator (const OscillatePositionOperator& op) { DynamicValue* freqMinValue = op.frequencyMin->value.get (); DynamicValue* freqMaxValue = op.frequencyMax->value.get (); DynamicValue* scaleMinValue = op.scaleMin->value.get (); DynamicValue* scaleMaxValue = op.scaleMax->value.get (); DynamicValue* phaseMinValue = op.phaseMin->value.get (); DynamicValue* phaseMaxValue = op.phaseMax->value.get (); DynamicValue* maskValue = op.mask->value.get (); DynamicValue* speedOverride = m_particle.instanceOverride.speed->value.get (); return [this, freqMinValue, freqMaxValue, scaleMinValue, scaleMaxValue, phaseMinValue, phaseMaxValue, maskValue, speedOverride] ( std::vector& particles, uint32_t count, const std::vector&, float, float dt ) { float freqMin = freqMinValue->getFloat (); float freqMax = freqMaxValue->getFloat (); float scaleMin = scaleMinValue->getFloat (); float scaleMax = scaleMaxValue->getFloat (); float phaseMin = phaseMinValue->getFloat (); float phaseMax = phaseMaxValue->getFloat (); glm::vec3 mask = maskValue->getVec3 (); for (uint32_t i = 0; i < count; i++) { auto& p = particles[i]; // Initialize per-particle oscillator values on first use (per axis) if (!p.oscillatePosition.initialized) { for (int axis = 0; axis < 3; axis++) { p.oscillatePosition.frequency[axis] = WallpaperEngine::Maths::randomFloat (m_rng, freqMin, freqMax); p.oscillatePosition.scale[axis] = WallpaperEngine::Maths::randomFloat (m_rng, scaleMin, scaleMax); p.oscillatePosition.phase[axis] = WallpaperEngine::Maths::randomFloat (m_rng, phaseMin, phaseMax + 2.0f * glm::pi ()); } p.oscillatePosition.initialized = true; } float t = p.age; glm::vec3 delta (0.0f); for (int axis = 0; axis < 3; axis++) { float w = 2.0f * glm::pi () * p.oscillatePosition.frequency[axis] / (2.0f * glm::pi ()); // Derivative of cos is -sin; multiplied by dt for position change float move = -p.oscillatePosition.scale[axis] * w * std::sin (w * t + p.oscillatePosition.phase[axis]) * dt; // Apply mask as bias multiplier for this axis delta[axis] = move * mask[axis] * speedOverride->getFloat (); } p.position += delta; } }; } // ========== RENDERING ========== void CParticle::setupPass () { if (!m_particle.material || !m_particle.material->material || m_particle.material->material->passes.empty ()) { sLog.error ("No valid material for particle ", m_particle.name); return; } const auto& firstPass = **m_particle.material->material->passes.begin (); m_passOverride = std::make_unique (); m_passOverride->combos["THICKFORMAT"] = 1; if (m_useRopeRenderer) { m_passOverride->shaderOverride = "genericropeparticle"; } if (m_spritesheetFrames > 0) { m_passOverride->combos["SPRITESHEET"] = 1; } if (m_useTrailRenderer) { m_passOverride->combos["TRAILRENDERER"] = 1; } // Force texture 0 to use the input (particle texture) rather than the shader's // default "util/white" annotation, which would override it in setupRenderTexture() m_passBinds = { { 0, "previous" } }; auto refractIt = firstPass.combos.find ("REFRACT"); m_hasRefract = refractIt != firstPass.combos.end () && refractIt->second != 0; m_passFBOProvider = std::make_shared (this); // REFRACT: create a copy FBO shadowing _rt_FullFrameBuffer. The shader reads g_Texture3 // (= _rt_FullFrameBuffer) while we render TO the scene FBO; reading and writing the same FBO // is undefined behavior in OpenGL and causes black reads on NVIDIA. Placing a copy FBO under // the same name in our FBOProvider makes CPass resolve g_Texture3 to the copy instead - we // blit the scene content into it before each render. if (m_hasRefract) { auto sceneFBO = getScene ().getFBO (); float w = static_cast (sceneFBO->getRealWidth ()); float h = static_cast (sceneFBO->getRealHeight ()); m_refractFBO = m_passFBOProvider->create ( "_rt_FullFrameBuffer", TextureFormat_ARGB8888, TextureFlags_ClampUVs, 1.0f, { w, h }, { w, h } ); } m_pass = new Effects::CPass (*this, m_passFBOProvider, firstPass, *m_passOverride, m_passBinds, std::nullopt); m_pass->setDestination (getScene ().getFBO ()); m_pass->setInput (getTexture ()); // Set matrix pointers - CPass will dereference these each frame m_pass->setModelViewProjectionMatrix (&m_mvpMatrix); m_pass->setModelViewProjectionMatrixInverse (&m_mvpMatrixInverse); m_pass->setModelMatrix (&m_modelMatrix); m_pass->setViewProjectionMatrix (&m_viewProjectionMatrix); GLint prevVAO = 0; glGetIntegerv (GL_VERTEX_ARRAY_BINDING, &prevVAO); glGenVertexArrays (1, &m_vao); glGenBuffers (1, &m_vbo); glGenBuffers (1, &m_ebo); glBindVertexArray (m_vao); glBindBuffer (GL_ARRAY_BUFFER, m_vbo); glBindBuffer (GL_ELEMENT_ARRAY_BUFFER, m_ebo); const GLuint program = m_pass->getProgramID (); if (m_useRopeRenderer) { // Rope vertex layout: 7 attributes, 26 floats/vertex, stride=104 bytes // a_PositionVec4(4) + a_TexCoordVec4(4) + a_TexCoordVec4C1(4) + a_TexCoordVec4C2(4) // + a_TexCoordVec4C3(4) + a_TexCoordC4(2) + a_Color(4) = 26 const GLsizei stride = sizeof (float) * ROPE_FLOATS_PER_VERTEX; const GLint loc0 = glGetAttribLocation (program, "a_PositionVec4"); const GLint loc1 = glGetAttribLocation (program, "a_TexCoordVec4"); const GLint loc2 = glGetAttribLocation (program, "a_TexCoordVec4C1"); const GLint loc3 = glGetAttribLocation (program, "a_TexCoordVec4C2"); const GLint loc4 = glGetAttribLocation (program, "a_TexCoordVec4C3"); const GLint loc5 = glGetAttribLocation (program, "a_TexCoordC4"); const GLint loc6 = glGetAttribLocation (program, "a_Color"); if (loc0 >= 0) { glEnableVertexAttribArray (loc0); glVertexAttribPointer (loc0, 4, GL_FLOAT, GL_FALSE, stride, (void*)(sizeof (float) * 0)); } if (loc1 >= 0) { glEnableVertexAttribArray (loc1); glVertexAttribPointer (loc1, 4, GL_FLOAT, GL_FALSE, stride, (void*)(sizeof (float) * 4)); } if (loc2 >= 0) { glEnableVertexAttribArray (loc2); glVertexAttribPointer (loc2, 4, GL_FLOAT, GL_FALSE, stride, (void*)(sizeof (float) * 8)); } if (loc3 >= 0) { glEnableVertexAttribArray (loc3); glVertexAttribPointer (loc3, 4, GL_FLOAT, GL_FALSE, stride, (void*)(sizeof (float) * 12)); } if (loc4 >= 0) { glEnableVertexAttribArray (loc4); glVertexAttribPointer (loc4, 4, GL_FLOAT, GL_FALSE, stride, (void*)(sizeof (float) * 16)); } if (loc5 >= 0) { glEnableVertexAttribArray (loc5); glVertexAttribPointer (loc5, 2, GL_FLOAT, GL_FALSE, stride, (void*)(sizeof (float) * 20)); } if (loc6 >= 0) { glEnableVertexAttribArray (loc6); glVertexAttribPointer (loc6, 4, GL_FLOAT, GL_FALSE, stride, (void*)(sizeof (float) * 22)); } } else { // Sprite vertex layout: 5 attributes, 17 floats/vertex, stride=68 bytes // a_Position(3) + a_TexCoordVec4(4) + a_Color(4) + a_TexCoordVec4C1(4) + a_TexCoordC2(2) = 17 const GLsizei stride = sizeof (float) * SPRITE_FLOATS_PER_VERTEX; const GLint loc0 = glGetAttribLocation (program, "a_Position"); const GLint loc1 = glGetAttribLocation (program, "a_TexCoordVec4"); const GLint loc2 = glGetAttribLocation (program, "a_Color"); const GLint loc3 = glGetAttribLocation (program, "a_TexCoordVec4C1"); const GLint loc4 = glGetAttribLocation (program, "a_TexCoordC2"); if (loc0 >= 0) { glEnableVertexAttribArray (loc0); glVertexAttribPointer (loc0, 3, GL_FLOAT, GL_FALSE, stride, (void*)(sizeof (float) * 0)); } if (loc1 >= 0) { glEnableVertexAttribArray (loc1); glVertexAttribPointer (loc1, 4, GL_FLOAT, GL_FALSE, stride, (void*)(sizeof (float) * 3)); } if (loc2 >= 0) { glEnableVertexAttribArray (loc2); glVertexAttribPointer (loc2, 4, GL_FLOAT, GL_FALSE, stride, (void*)(sizeof (float) * 7)); } if (loc3 >= 0) { glEnableVertexAttribArray (loc3); glVertexAttribPointer (loc3, 4, GL_FLOAT, GL_FALSE, stride, (void*)(sizeof (float) * 11)); } if (loc4 >= 0) { glEnableVertexAttribArray (loc4); glVertexAttribPointer (loc4, 2, GL_FLOAT, GL_FALSE, stride, (void*)(sizeof (float) * 15)); } } glBindVertexArray (prevVAO); setupGeometryCallbacks (); setupParticleUniforms (); } void CParticle::setupGeometryCallbacks () { m_pass->setGeometryCallback ( // Setup attribs: save current VAO, bind particle VAO [this] () { glGetIntegerv (GL_VERTEX_ARRAY_BINDING, &m_prevVAO); glBindVertexArray (m_vao); }, // Draw geometry: indexed rendering [this] () { glDrawElements (GL_TRIANGLES, m_activeIndexCount, GL_UNSIGNED_INT, nullptr); }, // Cleanup: restore previous VAO [this] () { glBindVertexArray (m_prevVAO); } ); } void CParticle::setupParticleUniforms () { // Add particle-specific uniforms from common_particles.h that CPass doesn't provide // These are pointer-based: CPass reads the current value each frame m_pass->addUniform ("g_ModelMatrixInverse", &m_modelMatrixInverse); m_pass->addUniform ("g_OrientationUp", &m_orientationUp); m_pass->addUniform ("g_OrientationRight", &m_orientationRight); m_pass->addUniform ("g_OrientationForward", &m_orientationForward); m_pass->addUniform ("g_ViewUp", &m_viewUp); m_pass->addUniform ("g_ViewRight", &m_viewRight); m_pass->addUniform ("g_EyePosition", &m_eyePosition); m_pass->addUniform ("g_RenderVar0", &m_renderVar0); m_pass->addUniform ("g_RenderVar1", &m_renderVar1); // REFRACT: set g_RefractAmount (shader default 0.05, may not be applied by CPass's parameter system) if (m_hasRefract) { m_pass->addUniform ("g_RefractAmount", &m_refractAmount); } } void CParticle::updateMatrices () { glm::vec3 scale = m_particle.scale->value->getVec3 (); glm::vec3 angles = m_particle.angles->value->getVec3 (); m_modelMatrix = glm::mat4 (1.0f); m_modelMatrix = glm::translate (m_modelMatrix, m_transformedOrigin); this->applyParallaxToModelMatrix (); // Negate X and Z rotations to account for Y-flipped coordinate system m_modelMatrix = glm::rotate (m_modelMatrix, -angles.z, glm::vec3 (0, 0, 1)); m_modelMatrix = glm::rotate (m_modelMatrix, angles.y, glm::vec3 (0, 1, 0)); m_modelMatrix = glm::rotate (m_modelMatrix, -angles.x, glm::vec3 (1, 0, 0)); m_modelMatrix = glm::scale (m_modelMatrix, scale); m_modelMatrixInverse = glm::inverse (m_modelMatrix); this->updateParticleViewProjection (); m_mvpMatrix = m_viewProjectionMatrix * m_modelMatrix; m_mvpMatrixInverse = glm::inverse (m_mvpMatrix); m_orientationUp = glm::vec3 (0.0f, 1.0f, 0.0f); m_orientationRight = glm::vec3 (1.0f, 0.0f, 0.0f); m_orientationForward = glm::vec3 (0.0f, 0.0f, 1.0f); m_viewUp = glm::vec3 (0.0f, 1.0f, 0.0f); m_viewRight = glm::vec3 (1.0f, 0.0f, 0.0f); this->updateParticleRenderVars (); } void CParticle::applyParallaxToModelMatrix () { // CScene::renderFrame() already folds disableparallax into getParallaxDisplacement() if (!getScene ().getScene ().camera.parallax.enabled->value->getBool ()) { return; } const glm::vec2 offset = getScene ().getParallaxOffset (m_particle); const glm::vec3 parallaxOffset { offset.x, offset.y, 0.0f }; m_modelMatrix = glm::translate (m_modelMatrix, parallaxOffset); } void CParticle::updateParticleViewProjection () { if ((m_particle.flags & 4) != 0) { // Perspective particles use a dedicated perspective projection float width = getScene ().getCamera ().getWidth (); float height = getScene ().getCamera ().getHeight (); float aspect = width / height; float fov = glm::radians (getScene ().getCamera ().getFov ()); float nearz = getScene ().getCamera ().getNearZ (); float farz = getScene ().getCamera ().getFarZ (); glm::mat4 perspectiveProj = glm::perspective (fov, aspect, nearz, farz); glm::mat4 perspectiveView = glm::lookAt (glm::vec3 (0.0f, 0.0f, 1000.0f), glm::vec3 (0.0f, 0.0f, 0.0f), glm::vec3 (0.0f, 1.0f, 0.0f)); m_viewProjectionMatrix = perspectiveProj * perspectiveView; m_eyePosition = glm::vec3 (0.0f, 0.0f, 1000.0f); } else { // Orthographic projection from scene camera m_viewProjectionMatrix = getScene ().getCamera ().getProjection () * getScene ().getCamera ().getLookAt (); // The shader's ComputeParticleTrailTangents computes trail ribbon width via // cross(eyeDirection, velocity). With the ortho eye at (0,0,0) and particles at z=0, // eyeDirection is purely XY, so the cross product is Z-only and invisible under // orthographic projection. Placing the eye at z=1000 gives it a visible XY component. m_eyePosition = glm::vec3 (0.0f, 0.0f, 1000.0f); } } void CParticle::updateParticleRenderVars () { m_renderVar0 = glm::vec4 (m_trailLength, m_trailMaxLength, m_trailMinLength, 0.0f); if (m_spritesheetFrames > 0 && m_spritesheetCols > 0 && m_spritesheetRows > 0) { float frameWidth = 1.0f / static_cast (m_spritesheetCols); float frameHeight = 1.0f / static_cast (m_spritesheetRows); float textureRatio = 1.0f; if (const auto texture = getTexture ()) { // Use atlas dimensions (resolution vec4) rather than getRealWidth/Height, which // returns per-frame dimensions for animated textures - the shader needs the // per-frame pixel aspect ratio: (atlasH * frameHeight) / (atlasW * frameWidth). const glm::vec4* res = texture->getResolution (); float w = res->x; float h = res->y; if (w > 0.0f) { textureRatio = (h * frameHeight) / (w * frameWidth); } } m_renderVar1 = glm::vec4 (frameWidth, frameHeight, static_cast (m_spritesheetFrames), textureRatio); } else { float textureRatio = 1.0f; if (const auto texture = getTexture ()) { float w = static_cast (texture->getRealWidth ()); float h = static_cast (texture->getRealHeight ()); if (w > 0.0f) { textureRatio = h / w; } } m_renderVar1 = glm::vec4 (0.0f, 0.0f, 0.0f, textureRatio); } } void CParticle::renderSprites () { if (m_particleCount == 0 || m_pass == nullptr) { return; } uint32_t aliveCount = 0; for (uint32_t i = 0; i < m_particleCount; i++) { if (m_particles[i].alive) { aliveCount++; } } if (aliveCount == 0) { return; } // Build vertex data in WP shader layout: // a_Position(3) + a_TexCoordVec4(uv.x, uv.y, rotZ, size)(4) + a_Color(4) // + a_TexCoordVec4C1(vel.x, vel.y, vel.z, lifetime)(4) + a_TexCoordC2(rotX, rotY)(2) = 17 floats uint32_t vertexIndex = 0; uint32_t indexOffset = 0; for (uint32_t i = 0; i < m_particleCount; i++) { const auto& p = m_particles[i]; if (!p.alive) { continue; } // Skip particles with invalid values if (!std::isfinite (p.position.x) || !std::isfinite (p.position.y) || !std::isfinite (p.position.z) || !std::isfinite (p.size) || p.size <= 0.0f || p.size > 10000.0f) { continue; } // Encode the CPU-computed frame (accounts for sequenceMultiplier and animation mode) // into the lifetime value the WP shader's ComputeSpriteFrame expects: it derives the // current frame via floor(frac(lifetime) * numFrames) and the inter-frame blend via // frac(lifetime * numFrames). float lifetime = p.getLifetimePos (); if (m_spritesheetFrames > 0 && p.frame >= 0.0f) { if (m_particle.animationMode == "randomframe") { // Center within the frame to avoid floating-point edge cases lifetime = (p.frame + 0.5f) / static_cast (m_spritesheetFrames); } else { lifetime = p.frame / static_cast (m_spritesheetFrames); } } auto addVertex = [&] (float u, float v) { const uint32_t base = vertexIndex * SPRITE_FLOATS_PER_VERTEX; // a_Position (vec3) m_vertices[base + 0] = p.position.x; m_vertices[base + 1] = p.position.y; m_vertices[base + 2] = p.position.z; // a_TexCoordVec4 (vec4: uv.x, uv.y, rotZ, size) m_vertices[base + 3] = u; m_vertices[base + 4] = v; m_vertices[base + 5] = p.rotation.z; m_vertices[base + 6] = p.size; // a_Color (vec4: r, g, b, a) m_vertices[base + 7] = p.color.r; m_vertices[base + 8] = p.color.g; m_vertices[base + 9] = p.color.b; m_vertices[base + 10] = p.alpha; // a_TexCoordVec4C1 (vec4: vel.x, vel.y, vel.z, lifetime) m_vertices[base + 11] = p.velocity.x; m_vertices[base + 12] = p.velocity.y; m_vertices[base + 13] = p.velocity.z; m_vertices[base + 14] = lifetime; // a_TexCoordC2 (vec2: rotX, rotY) m_vertices[base + 15] = p.rotation.x; m_vertices[base + 16] = p.rotation.y; vertexIndex++; }; uint32_t baseVertex = vertexIndex; addVertex (0.0f, 1.0f); // 0: Bottom-left addVertex (1.0f, 1.0f); // 1: Bottom-right addVertex (1.0f, 0.0f); // 2: Top-right addVertex (0.0f, 0.0f); // 3: Top-left m_indices[indexOffset++] = baseVertex + 0; m_indices[indexOffset++] = baseVertex + 1; m_indices[indexOffset++] = baseVertex + 2; m_indices[indexOffset++] = baseVertex + 2; m_indices[indexOffset++] = baseVertex + 3; m_indices[indexOffset++] = baseVertex + 0; } m_activeIndexCount = static_cast (indexOffset); if (m_activeIndexCount == 0) { return; } #if !NDEBUG std::string str = "Particles "; str += this->getParticle ().name + " (" + std::to_string (this->getId ()) + ", " + this->getParticle ().particleFile + ")"; glPushDebugGroup (GL_DEBUG_SOURCE_APPLICATION, 0, -1, str.c_str ()); #endif glBindBuffer (GL_ARRAY_BUFFER, m_vbo); glBufferData ( GL_ARRAY_BUFFER, static_cast (vertexIndex * SPRITE_FLOATS_PER_VERTEX * sizeof (float)), m_vertices.data (), GL_DYNAMIC_DRAW ); glBindBuffer (GL_ELEMENT_ARRAY_BUFFER, m_ebo); glBufferData ( GL_ELEMENT_ARRAY_BUFFER, static_cast (indexOffset * sizeof (uint32_t)), m_indices.data (), GL_DYNAMIC_DRAW ); updateMatrices (); // REFRACT: blit current scene content into the copy FBO first, giving the shader a // snapshot of what's behind the particles without a read/write feedback loop if (m_hasRefract && m_refractFBO) { auto sceneFBO = getScene ().getFBO (); GLint w = static_cast (sceneFBO->getRealWidth ()); GLint h = static_cast (sceneFBO->getRealHeight ()); glBindFramebuffer (GL_READ_FRAMEBUFFER, sceneFBO->getFramebuffer ()); glBindFramebuffer (GL_DRAW_FRAMEBUFFER, m_refractFBO->getFramebuffer ()); glBlitFramebuffer (0, 0, w, h, 0, 0, w, h, GL_COLOR_BUFFER_BIT, GL_NEAREST); } // ComputeParticleTrailTangents produces a right vector with a Z component (from // cross(eyeDirection, velocity), where eyeDirection has an XY offset from the model // transform). For 2D/ortho particles at z=0, the ortho near plane sits at ndc.z=-1, so any // Z offset pushes vertices past it and clips half the quad. GL_DEPTH_CLAMP avoids that by // clamping depth instead of clipping. glEnable (GL_DEPTH_CLAMP); // CPass::render() handles: FBO binding, texture setup, uniforms, blending, draw call, cleanup m_pass->render (); glDisable (GL_DEPTH_CLAMP); #if !NDEBUG glPopDebugGroup (); #endif } void CParticle::renderRope () { if (m_particleCount < 2 || m_pass == nullptr) { return; } // Already in spawn order (oldest at index 0) thanks to compaction in update(); // all particles in [0, m_particleCount) are alive. const uint32_t aliveCount = m_particleCount; // Each segment between consecutive particles is subdivided into m_ropeSubdivision // sub-segments via Catmull-Rom spline, for smooth curves instead of harsh corners. // // Rope vertex layout (26 floats per vertex, THICKFORMAT): // [0-3] a_PositionVec4: startPos.xyz, sizeStart // [4-7] a_TexCoordVec4: endPos.xyz, trailLength // [8-11] a_TexCoordVec4C1: CP0.xyz, trailPosition // [12-15] a_TexCoordVec4C2: CP1.xyz, sizeEnd // [16-19] a_TexCoordVec4C3: colorEnd.rgba // [20-21] a_TexCoordC4: uvs.xy // [22-25] a_Color: colorStart.rgba const uint32_t numSegments = aliveCount - 1; const int subdivision = std::max (1, m_ropeSubdivision); auto catmullRom = [] (const glm::vec3& p0, const glm::vec3& p1, const glm::vec3& p2, const glm::vec3& p3, float t) -> glm::vec3 { float t2 = t * t, t3 = t2 * t; return 0.5f * ((2.0f * p1) + (-p0 + p2) * t + (2.0f * p0 - 5.0f * p1 + 4.0f * p2 - p3) * t2 + (-p0 + 3.0f * p1 - 3.0f * p2 + p3) * t3); }; // First pass: evaluate the spline to get all interpolated points (position, size, color) const uint32_t totalPoints = numSegments * subdivision + 1; this->m_splinePositions.resize (totalPoints); this->m_splineSizes.resize (totalPoints); this->m_splineColors.resize (totalPoints); auto& splinePositions = this->m_splinePositions; auto& splineSizes = this->m_splineSizes; auto& splineColors = this->m_splineColors; for (uint32_t i = 0; i < numSegments; i++) { const auto& p1 = m_particles[i]; const auto& p2 = m_particles[i + 1]; const auto& p0 = (i > 0) ? m_particles[i - 1] : p1; const auto& p3 = (i + 2 < aliveCount) ? m_particles[i + 2] : p2; for (int k = 0; k < subdivision; k++) { float t = static_cast (k) / static_cast (subdivision); uint32_t idx = i * subdivision + k; splinePositions[idx] = catmullRom (p0.position, p1.position, p2.position, p3.position, t); splineSizes[idx] = glm::mix (p1.size, p2.size, t); splineColors[idx] = glm::mix (glm::vec4 (p1.color, p1.alpha), glm::vec4 (p2.color, p2.alpha), t); } } // Last point is the final particle { const auto& pLast = m_particles[aliveCount - 1]; splinePositions[totalPoints - 1] = pLast.position; splineSizes[totalPoints - 1] = pLast.size; splineColors[totalPoints - 1] = glm::vec4 (pLast.color, pLast.alpha); } // Second pass: build quads from consecutive spline points. The shader computes UV.v as // trailPosition / (trailLength - 1), so trailLength/trailPosition are expressed in // sub-segment units for the correct UV slice per quad. UV scale divides the effective // length, pushing UVs past [0,1] so the texture repeats. uint32_t vertexIndex = 0; uint32_t indexOffset = 0; const uint32_t totalSubSegments = totalPoints - 1; const float uvScale = (m_ropeUVScale > 0.0f) ? m_ropeUVScale : 1.0f; const float trailLength = static_cast (totalSubSegments) / uvScale + 1.0f; const float usableLength = trailLength - 1.0f; // UV smoothing: distribute UV proportional to arc length instead of uniform index. // Per wiki: only when all particle lifetimes match and scrolling is disabled. const bool useSmoothing = m_ropeUVSmoothing && m_uniformLifetimes && !m_ropeUVScrolling; auto& cumulativeArcLength = this->m_cumulativeArcLength; float totalArcLength = 0.0f; if (useSmoothing) { cumulativeArcLength.resize (totalPoints, 0.0f); for (uint32_t i = 1; i < totalPoints; i++) { totalArcLength += glm::distance (splinePositions[i], splinePositions[i - 1]); cumulativeArcLength[i] = totalArcLength; } } // UV scrolling: shift UV along the rope over time (1 UV cycle per second) float scrollOffset = 0.0f; if (m_ropeUVScrolling && usableLength > 0.0f) { scrollOffset = std::fmod (static_cast (g_Time), 10000.0f) * usableLength; } for (uint32_t s = 0; s < totalSubSegments; s++) { const glm::vec3& posStart = splinePositions[s]; const glm::vec3& posEnd = splinePositions[s + 1]; float sizeStart = splineSizes[s]; float sizeEnd = splineSizes[s + 1]; const glm::vec4& colorStart = splineColors[s]; const glm::vec4& colorEnd = splineColors[s + 1]; // Neighboring points for shader tangent computation (CP0/CP1) const glm::vec3& posPrev = (s > 0) ? splinePositions[s - 1] : posStart; const glm::vec3& posAfter = (s + 2 < totalPoints) ? splinePositions[s + 2] : posEnd; // Compute trailPosition for UV mapping float trailPosition; if (useSmoothing && totalArcLength > 0.0f) { // Arc-length parameterization: map cumulative distance to sub-segment space trailPosition = cumulativeArcLength[s] / totalArcLength * static_cast (totalSubSegments); } else { trailPosition = static_cast (s); } trailPosition += scrollOffset; auto addRopeVertex = [&] (float uvX, float uvY) { const uint32_t base = vertexIndex * ROPE_FLOATS_PER_VERTEX; // a_PositionVec4: startPos.xyz, sizeStart m_vertices[base + 0] = posStart.x; m_vertices[base + 1] = posStart.y; m_vertices[base + 2] = posStart.z; m_vertices[base + 3] = sizeStart; // a_TexCoordVec4: endPos.xyz, trailLength m_vertices[base + 4] = posEnd.x; m_vertices[base + 5] = posEnd.y; m_vertices[base + 6] = posEnd.z; m_vertices[base + 7] = trailLength; // a_TexCoordVec4C1: CP0.xyz (neighbor before start), trailPosition m_vertices[base + 8] = posPrev.x; m_vertices[base + 9] = posPrev.y; m_vertices[base + 10] = posPrev.z; m_vertices[base + 11] = trailPosition; // a_TexCoordVec4C2: CP1.xyz (neighbor after end), sizeEnd m_vertices[base + 12] = posAfter.x; m_vertices[base + 13] = posAfter.y; m_vertices[base + 14] = posAfter.z; m_vertices[base + 15] = sizeEnd; // a_TexCoordVec4C3: colorEnd.rgba m_vertices[base + 16] = colorEnd.r; m_vertices[base + 17] = colorEnd.g; m_vertices[base + 18] = colorEnd.b; m_vertices[base + 19] = colorEnd.a; // a_TexCoordC4: uvs.xy m_vertices[base + 20] = uvX; m_vertices[base + 21] = uvY; // a_Color: colorStart.rgba m_vertices[base + 22] = colorStart.r; m_vertices[base + 23] = colorStart.g; m_vertices[base + 24] = colorStart.b; m_vertices[base + 25] = colorStart.a; vertexIndex++; }; // Quad: 4 vertices (left/right at start/end of segment) uint32_t baseVertex = vertexIndex; addRopeVertex (0.0f, 0.0f); // left at start addRopeVertex (1.0f, 0.0f); // right at start addRopeVertex (1.0f, 1.0f); // right at end addRopeVertex (0.0f, 1.0f); // left at end m_indices[indexOffset++] = baseVertex + 0; m_indices[indexOffset++] = baseVertex + 1; m_indices[indexOffset++] = baseVertex + 2; m_indices[indexOffset++] = baseVertex + 2; m_indices[indexOffset++] = baseVertex + 3; m_indices[indexOffset++] = baseVertex + 0; } m_activeIndexCount = static_cast (indexOffset); if (m_activeIndexCount == 0) { return; } #if !NDEBUG std::string str = "Rope particles "; str += this->getParticle ().name + " (" + std::to_string (this->getId ()) + ", " + this->getParticle ().particleFile + ")"; glPushDebugGroup (GL_DEBUG_SOURCE_APPLICATION, 0, -1, str.c_str ()); #endif glBindBuffer (GL_ARRAY_BUFFER, m_vbo); glBufferData ( GL_ARRAY_BUFFER, static_cast (vertexIndex * ROPE_FLOATS_PER_VERTEX * sizeof (float)), m_vertices.data (), GL_DYNAMIC_DRAW ); glBindBuffer (GL_ELEMENT_ARRAY_BUFFER, m_ebo); glBufferData ( GL_ELEMENT_ARRAY_BUFFER, static_cast (indexOffset * sizeof (uint32_t)), m_indices.data (), GL_DYNAMIC_DRAW ); updateMatrices (); // REFRACT: blit current scene content into the copy FBO before rendering if (m_hasRefract && m_refractFBO) { auto sceneFBO = getScene ().getFBO (); GLint w = static_cast (sceneFBO->getRealWidth ()); GLint h = static_cast (sceneFBO->getRealHeight ()); glBindFramebuffer (GL_READ_FRAMEBUFFER, sceneFBO->getFramebuffer ()); glBindFramebuffer (GL_DRAW_FRAMEBUFFER, m_refractFBO->getFramebuffer ()); glBlitFramebuffer (0, 0, w, h, 0, 0, w, h, GL_COLOR_BUFFER_BIT, GL_NEAREST); } glEnable (GL_DEPTH_CLAMP); m_pass->render (); glDisable (GL_DEPTH_CLAMP); #if !NDEBUG glPopDebugGroup (); #endif }