diff --git a/glsl/f_oit_particles.glsl b/glsl/f_oit_particles.glsl index e109a7501..bc4d54afd 100644 --- a/glsl/f_oit_particles.glsl +++ b/glsl/f_oit_particles.glsl @@ -7,35 +7,263 @@ uniform sampler2D sMainTex; +const int PARTICLE_RECONSTRUCTION_LEGACY = 0; +const int PARTICLE_RECONSTRUCTION_CUBIC = 1; +const int PARTICLE_ALPHA_LEGACY = 0; +const int PARTICLE_ALPHA_STORED = 1; +const int PARTICLE_ALPHA_LUMINANCE = 2; +const int PARTICLE_ALPHA_AND_LUMINANCE = 3; +const int PARTICLE_TRAIL_LEGACY = 0; +const int PARTICLE_TRAIL_ANALYTIC_CORE = 1; +const int PARTICLE_DIAGNOSTIC_COMPOSITE = 0; +const int PARTICLE_DIAGNOSTIC_TEXTURE = 1; +const int PARTICLE_DIAGNOSTIC_ALPHA = 2; +const int PARTICLE_DIAGNOSTIC_VERTEX = 3; + in vec2 fragUV1; flat in int fragInstanceID; layout(location = 0) out vec4 fragColor1; layout(location = 1) out vec4 fragColor2; +vec4 cubicWeights(float fraction) { + float t = clamp(fraction, 0.0, 1.0); + float t2 = t * t; + float t3 = t2 * t; + return vec4( + -0.5 * t + t2 - 0.5 * t3, + 1.0 - 2.5 * t2 + 1.5 * t3, + 0.5 * t + 2.0 * t2 - 1.5 * t3, + -0.5 * t2 + 0.5 * t3); +} + +void enhancedAtlasUV( + out vec2 uv, + out vec2 frameMinUV, + out vec2 frameMaxUV, + out ivec2 textureDimensions) { + + textureDimensions = max(textureSize(sMainTex, 0), ivec2(1)); + ivec2 gridSize = clamp(uGridSize, ivec2(1), textureDimensions); + int frameCount = gridSize.x * gridSize.y; + int frame = clamp(int(uParticles[fragInstanceID].positionFrame.w), 0, frameCount - 1); + ivec2 frameCoord = ivec2(frame % gridSize.x, frame / gridSize.x); + + vec2 cellMin = vec2(frameCoord) / vec2(gridSize); + vec2 cellMax = vec2(frameCoord + ivec2(1)) / vec2(gridSize); + vec2 cellCenter = 0.5 * (cellMin + cellMax); + vec2 halfTexel = 0.5 / vec2(textureDimensions); + frameMinUV = min(cellMin + halfTexel, cellCenter); + frameMaxUV = max(cellMax - halfTexel, cellCenter); + + vec2 localUV = clamp(fragUV1, vec2(0.0), vec2(1.0)); + uv = (vec2(frameCoord) + localUV) / vec2(gridSize); + uv = clamp(uv, frameMinUV, frameMaxUV); +} + +vec4 sampleCubicAtlas( + vec2 uv, + vec2 frameMinUV, + vec2 frameMaxUV, + ivec2 textureDimensions) { + + vec2 pixel = uv * vec2(textureDimensions) - 0.5; + vec2 basePixel = floor(pixel); + vec2 fraction = fract(pixel); + vec4 weightsX = cubicWeights(fraction.x); + vec4 weightsY = cubicWeights(fraction.y); + + vec4 cubicSample = vec4(0.0); + float weightSum = 0.0; + vec4 sample00 = vec4(0.0); + vec4 sample10 = vec4(0.0); + vec4 sample01 = vec4(0.0); + vec4 sample11 = vec4(0.0); + vec4 neighborhoodMin = vec4(1.0); + vec4 neighborhoodMax = vec4(0.0); + + for (int y = 0; y < 4; ++y) { + for (int x = 0; x < 4; ++x) { + vec2 samplePixel = basePixel + vec2(x - 1, y - 1); + vec2 sampleUV = (samplePixel + 0.5) / vec2(textureDimensions); + sampleUV = clamp(sampleUV, frameMinUV, frameMaxUV); + vec4 value = texture(sMainTex, sampleUV); + float weight = weightsX[x] * weightsY[y]; + cubicSample += value * weight; + weightSum += weight; + neighborhoodMin = min(neighborhoodMin, value); + neighborhoodMax = max(neighborhoodMax, value); + + if (x == 1 && y == 1) { + sample00 = value; + } else if (x == 2 && y == 1) { + sample10 = value; + } else if (x == 1 && y == 2) { + sample01 = value; + } else if (x == 2 && y == 2) { + sample11 = value; + } + } + } + + vec4 bilinearSample = mix( + mix(sample00, sample10, fraction.x), + mix(sample01, sample11, fraction.x), + fraction.y); + vec4 reconstructed = cubicSample / max(abs(weightSum), 0.0001); + reconstructed = clamp(reconstructed, neighborhoodMin, neighborhoodMax); + return mix( + bilinearSample, + reconstructed, + clamp(uParticleReconstructionStrength, 0.0, 1.0)); +} + +void decodeParticleSample( + vec4 sampleValue, + out vec3 sampleColor, + out float sampleAlpha) { + + sampleColor = sampleValue.rgb; + float storedAlpha = clamp(sampleValue.a, 0.0, 1.0); + float luminance = clamp(rgbToLuma(sampleColor), 0.0, 1.0); + + if (!isFeatureEnabled(FEATURE_PREMULALPHA)) { + sampleAlpha = storedAlpha; + return; + } else if (uParticleAlphaMode == PARTICLE_ALPHA_STORED) { + sampleAlpha = storedAlpha; + } else if (uParticleAlphaMode == PARTICLE_ALPHA_LUMINANCE) { + sampleAlpha = luminance; + sampleColor *= 1.0 / max(0.0001, luminance); + } else if (uParticleAlphaMode == PARTICLE_ALPHA_AND_LUMINANCE) { + sampleAlpha = min(storedAlpha, luminance); + if (luminance <= storedAlpha) { + sampleColor *= 1.0 / max(0.0001, luminance); + } + } else { + sampleAlpha = luminance; + sampleColor *= 1.0 / max(0.0001, luminance); + } + + sampleAlpha = pow( + clamp(sampleAlpha, 0.0, 1.0), + max(uParticleAlphaExponent, 0.0001)); +} + +float analyticParticleCoreEnvelope(vec2 localUV) { + if (uParticleTrailMode != PARTICLE_TRAIL_ANALYTIC_CORE || + uParticleTrailCoreIntensity <= 0.0) { + return 0.0; + } + + vec2 centered = 2.0 * localUV - 1.0; + if (uMotionBlur == 0) { + float radialCore = + 1.0 - smoothstep(0.0, 0.65, length(centered)); + return clamp(uParticleTrailCoreIntensity, 0.0, 1.0) * + radialCore * radialCore; + } + + vec2 inside = max(vec2(1.0) - abs(centered), vec2(0.0)); + float crossSection = inside.x * inside.x; + crossSection *= crossSection; + float endTaper = inside.y * inside.y; + return clamp(uParticleTrailCoreIntensity, 0.0, 1.0) * crossSection * endTaper; +} + void main() { - float oneOverGridX = 1.0 / uGridSize.x; - float oneOverGridY = 1.0 / uGridSize.y; + bool enhancedPolicy = + uParticleReconstructionMode != PARTICLE_RECONSTRUCTION_LEGACY || + uParticleAlphaMode != PARTICLE_ALPHA_LEGACY || + uParticleTrailMode != PARTICLE_TRAIL_LEGACY || + uParticleDiagnosticMode != PARTICLE_DIAGNOSTIC_COMPOSITE || + uParticleCoverageContrast > 0.0001 || + abs(uParticleAlphaExponent - 1.0) > 0.0001; + if (!enhancedPolicy) { + float oneOverGridX = 1.0 / uGridSize.x; + float oneOverGridY = 1.0 / uGridSize.y; + + vec2 uv = fragUV1; + uv.x *= oneOverGridX; + uv.y *= oneOverGridY; + + int frame = int(uParticles[fragInstanceID].positionFrame.w); + if (frame > 0) { + uv.y += oneOverGridY * (frame / uGridSize.x); + uv.x += oneOverGridX * (frame % uGridSize.x); + } - vec2 uv = fragUV1; - uv.x *= oneOverGridX; - uv.y *= oneOverGridY; + vec4 mainTexSample = texture(sMainTex, uv); + vec3 mainTexColor = mainTexSample.rgb; + float mainTexAlpha = mainTexSample.a; + if (isFeatureEnabled(FEATURE_PREMULALPHA)) { + mainTexAlpha = rgbToLuma(mainTexSample.rgb); + mainTexColor *= 1.0 / max(0.0001, mainTexAlpha); + } + vec3 objectColor = uParticles[fragInstanceID].color.rgb * mainTexColor; + float objectAlpha = uParticles[fragInstanceID].color.a * mainTexAlpha; + if (objectAlpha == 0.0) { + discard; + } + + float w = OIT_weight(gl_FragCoord.z, objectAlpha); + fragColor1 = vec4(objectColor * w, objectAlpha); + fragColor2 = vec4(w); + return; + } + + vec2 enhancedUV; + vec2 frameMinUV; + vec2 frameMaxUV; + ivec2 textureDimensions; + enhancedAtlasUV(enhancedUV, frameMinUV, frameMaxUV, textureDimensions); + + vec4 mainTexSample = uParticleReconstructionMode == PARTICLE_RECONSTRUCTION_CUBIC + ? sampleCubicAtlas( + enhancedUV, + frameMinUV, + frameMaxUV, + textureDimensions) + : texture(sMainTex, enhancedUV); + vec3 mainTexColor; + float mainTexAlpha; + decodeParticleSample(mainTexSample, mainTexColor, mainTexAlpha); + + if (uParticleCoverageContrast > 0.0) { + float contrast = clamp(uParticleCoverageContrast, 0.0, 1.0); + float detail = max( + mainTexAlpha, + smoothstep(0.02, 0.28, mainTexAlpha)); + mainTexAlpha = mix(mainTexAlpha, detail, contrast); + float luminance = max(rgbToLuma(mainTexColor), 0.0001); + vec3 normalizedColor = clamp(mainTexColor / luminance, vec3(0.0), vec3(4.0)); + mainTexColor = mix( + mainTexColor, + normalizedColor, + 0.4 * contrast * detail); + } - int frame = int(uParticles[fragInstanceID].positionFrame.w); - if (frame > 0) { - uv.y += oneOverGridY * (frame / uGridSize.x); - uv.x += oneOverGridX * (frame % uGridSize.x); + float trailCore = analyticParticleCoreEnvelope(fragUV1); + if (trailCore > 0.0) { + mainTexAlpha = max(mainTexAlpha, trailCore); + mainTexColor = mix(mainTexColor, vec3(1.0), trailCore); } - vec4 mainTexSample = texture(sMainTex, uv); - vec3 mainTexColor = mainTexSample.rgb; - float mainTexAlpha = mainTexSample.a; - if (isFeatureEnabled(FEATURE_PREMULALPHA)) { - mainTexAlpha = rgbToLuma(mainTexSample.rgb); - mainTexColor *= 1.0 / max(0.0001, mainTexAlpha); + vec3 objectColor; + float objectAlpha; + if (uParticleDiagnosticMode == PARTICLE_DIAGNOSTIC_TEXTURE) { + objectColor = mainTexColor; + objectAlpha = mainTexAlpha; + } else if (uParticleDiagnosticMode == PARTICLE_DIAGNOSTIC_ALPHA) { + objectColor = vec3(mainTexAlpha); + objectAlpha = 1.0; + } else if (uParticleDiagnosticMode == PARTICLE_DIAGNOSTIC_VERTEX) { + objectColor = uParticles[fragInstanceID].color.rgb; + objectAlpha = uParticles[fragInstanceID].color.a; + } else { + objectColor = uParticles[fragInstanceID].color.rgb * mainTexColor; + objectAlpha = uParticles[fragInstanceID].color.a * mainTexAlpha; } - vec3 objectColor = uParticles[fragInstanceID].color.rgb * mainTexColor; - float objectAlpha = uParticles[fragInstanceID].color.a * mainTexAlpha; if (objectAlpha == 0.0) { discard; } diff --git a/glsl/u_particles.glsl b/glsl/u_particles.glsl index 6d41accba..e88515b71 100644 --- a/glsl/u_particles.glsl +++ b/glsl/u_particles.glsl @@ -10,5 +10,14 @@ struct Particle { layout(std140) uniform Particles { ivec2 uGridSize; + int uParticleReconstructionMode; + int uParticleAlphaMode; + int uParticleTrailMode; + int uMotionBlur; + float uParticleReconstructionStrength; + float uParticleAlphaExponent; + float uParticleTrailCoreIntensity; + float uParticleCoverageContrast; + int uParticleDiagnosticMode; Particle uParticles[MAX_PARTICLES]; }; diff --git a/include/reone/game/effect/visual.h b/include/reone/game/effect/visual.h index b3a0591ce..bcdab0d11 100644 --- a/include/reone/game/effect/visual.h +++ b/include/reone/game/effect/visual.h @@ -17,12 +17,15 @@ #pragma once +#include "reone/resource/types.h" + #include "../effect.h" namespace reone { namespace scene { class ModelSceneNode; +struct ParticleRenderProfile; } namespace game { @@ -30,9 +33,19 @@ namespace game { class ServicesView; struct VisualEffectDesc; +bool particleRenderProfileForVisualEffect( + resource::GameID gameId, + uint32_t visualEffectId, + const VisualEffectDesc &desc, + scene::ParticleRenderProfile &profile); + class VisualEffect : public Effect { public: - VisualEffect(int visualEffectId, bool missEffect, ServicesView &services); + VisualEffect( + int visualEffectId, + bool missEffect, + resource::GameID gameId, + ServicesView &services); ~VisualEffect(); void applyTo(Object &object) override; @@ -42,6 +55,7 @@ class VisualEffect : public Effect { private: int _visualEffectId; bool _missEffect; + resource::GameID _gameId; const VisualEffectDesc *_desc {nullptr}; std::optional _location; ServicesView &_services; diff --git a/include/reone/game/visualeffects.h b/include/reone/game/visualeffects.h index 7214100c6..fb2fd3e10 100644 --- a/include/reone/game/visualeffects.h +++ b/include/reone/game/visualeffects.h @@ -44,6 +44,20 @@ struct VisualEffectDesc { std::shared_ptr soundImpact; }; +namespace VisualEffectIds { + +constexpr uint32_t grenadeFragmentation = 3003; +constexpr uint32_t grenadeStun = 3004; +constexpr uint32_t thermalDetonator = 3005; +constexpr uint32_t grenadePoison = 3006; +constexpr uint32_t grenadeSonic = 3007; +constexpr uint32_t grenadeAdhesive = 3008; +constexpr uint32_t grenadeCryoban = 3009; +constexpr uint32_t grenadePlasma = 3010; +constexpr uint32_t grenadeIon = 3011; + +} // namespace VisualEffectIds + class IVisualEffects { public: virtual std::optional get(uint32_t id) const = 0; diff --git a/include/reone/graphics/modelnode.h b/include/reone/graphics/modelnode.h index d987820ef..340bf3519 100644 --- a/include/reone/graphics/modelnode.h +++ b/include/reone/graphics/modelnode.h @@ -236,6 +236,7 @@ class ModelNode : boost::noncopyable { bool quaternionValueAt(ControllerType type, float time, glm::quat &value) const; KeyframeTrackMap &floatTracks() { return _floatTracks; } + const KeyframeTrackMap &floatTracks() const { return _floatTracks; } KeyframeTrackMap &vectorTracks() { return _vectorTracks; } KeyframeTrackMap &quaternionTracks() { return _quaternionTracks; } diff --git a/include/reone/graphics/uniforms.h b/include/reone/graphics/uniforms.h index dc2631136..0bff845e4 100644 --- a/include/reone/graphics/uniforms.h +++ b/include/reone/graphics/uniforms.h @@ -156,6 +156,15 @@ struct alignas(16) ParticleUniformsParticle { struct ParticleUniforms { glm::ivec2 gridSize {0}; + int reconstructionMode {0}; + int alphaMode {0}; + int trailMode {0}; + int motionBlur {0}; + float reconstructionStrength {0.0f}; + float alphaExponent {1.0f}; + float trailCoreIntensity {0.0f}; + float coverageContrast {0.0f}; + int diagnosticMode {0}; ParticleUniformsParticle particles[kMaxParticles]; }; diff --git a/include/reone/scene/node/emitter.h b/include/reone/scene/node/emitter.h index d90c19c8e..81173c750 100644 --- a/include/reone/scene/node/emitter.h +++ b/include/reone/scene/node/emitter.h @@ -19,6 +19,7 @@ #include "reone/system/timer.h" +#include "../render/pass.h" #include "modelnode.h" namespace reone { @@ -28,8 +29,73 @@ namespace scene { class ModelSceneNode; class ParticleSceneNode; +/** + * Optional presentation tuning applied to a complete particle model. + * + * The default values preserve authored Odyssey emitter behavior. Game-facing + * effects can opt into a profile without teaching the renderer asset names. + */ +struct ParticleRenderProfile { + float largeParticleScale {1.0f}; + float worldZScale {1.0f}; + float opacity {1.0f}; + float worldZOpacity {1.0f}; + float motionLengthScale {1.0f}; + float motionMaxWidth {std::numeric_limits::max()}; + float motionOpacity {1.0f}; + glm::vec3 colorTint {1.0f}; + float colorIntensity {1.0f}; + ParticleRenderPolicy policy; + float motionMaxLength {std::numeric_limits::max()}; +}; + class EmitterSceneNode : public ModelNodeSceneNode { public: + struct AnimationTimeSpan { + float startTime {0.0f}; + float endTime {0.0f}; + size_t repetitions {1}; + }; + + struct BirthrateStep { + float startRate {0.0f}; + float endRate {0.0f}; + float duration {0.0f}; + bool resetAccumulator {false}; + }; + + struct AnimationState { + std::optional birthrate; + std::optional> birthrateStepsForUpdate; + std::optional lifeExpectancy; + std::optional xSize; + std::optional ySize; + std::optional frameStart; + std::optional frameEnd; + std::optional fps; + std::optional spread; + std::optional velocity; + std::optional randomVelocity; + std::optional blurLength; + std::optional mass; + std::optional grav; + std::optional lightningDelay; + std::optional lightningRadius; + std::optional lightningScale; + std::optional lightningSubDiv; + std::optional particleSizeStart; + std::optional particleSizeMid; + std::optional particleSizeEnd; + std::optional colorStart; + std::optional colorMid; + std::optional colorEnd; + std::optional alphaStart; + std::optional alphaMid; + std::optional alphaEnd; + + bool empty() const; + }; + EmitterSceneNode( graphics::ModelNode &modelNode, ISceneGraph &sceneGraph, @@ -53,10 +119,21 @@ class EmitterSceneNode : public ModelNodeSceneNode { void detonate(); + static AnimationState animationStateAt(const graphics::ModelNode &animationNode, float time); + static std::optional> animationBirthrateStepsForUpdate( + const graphics::ModelNode &animationNode, + const std::vector &timeSpans, + float playbackSpeed, + float dt); + void applyAnimationState(const AnimationState &state); + void setRenderProfile(const ParticleRenderProfile &profile) { _renderProfile = profile; } + const ParticleRenderProfile &renderProfile() const { return _renderProfile; } + float getParticleSize(float time) const { return _particleSize.get(time); }; glm::vec3 getColor(float time) const { return _color.get(time); }; float getAlpha(float time) const { return _alpha.get(time); }; + float birthrate() const { return _birthrate; } float lifeExpectancy() const { return _lifeExpectancy; } int frameStart() const { return _frameStart; } int frameEnd() const { return _frameEnd; } @@ -91,23 +168,30 @@ class EmitterSceneNode : public ModelNodeSceneNode { float _spread {0.0f}; float _velocity {0.0f}; float _randomVelocity {0.0f}; + float _blurLength {0.0f}; float _mass {0.0f}; float _grav {0.0f}; float _lightningDelay {0.0f}; float _lightningRadius {0.0f}; float _lightningScale {0.0f}; int _lightningSubDiv {0}; + ParticleRenderProfile _renderProfile; - float _birthInterval {0.0f}; + float _birthAccumulator {0.0f}; + std::optional> _birthrateStepsForUpdate; Timer _birthTimer; bool _spawned {false}; + int _particleCount {0}; - std::deque _particlePool; /**< pre-allocated pool of particles */ + std::deque _particlePool; void spawnParticles(float dt); void removeExpiredParticles(float dt); - void doSpawnParticle(); + bool doSpawnParticle(float initialAge = 0.0f); void spawnLightningParticles(); + ParticleSceneNode *takeParticle(); + bool isSpawningSuppressed() const; + void discardSpawnTime(float dt); }; } // namespace scene diff --git a/include/reone/scene/node/model.h b/include/reone/scene/node/model.h index 38fa23ea3..a4bd97887 100644 --- a/include/reone/scene/node/model.h +++ b/include/reone/scene/node/model.h @@ -55,6 +55,7 @@ class ModelSceneNode : public SceneNode { static constexpr int alpha = 2; static constexpr int selfIllumColor = 4; static constexpr int color = 8; + static constexpr int emitter = 16; }; struct AnimationState { @@ -63,6 +64,7 @@ class ModelSceneNode : public SceneNode { float alpha {0.0f}; glm::vec3 selfIllumColor {0.0f}; glm::vec3 color {0.0f}; + EmitterSceneNode::AnimationState emitter; }; struct AnimationChannel { @@ -70,6 +72,9 @@ class ModelSceneNode : public SceneNode { std::shared_ptr lipAnim; AnimationProperties properties; float time {0.0f}; + float particleTime {0.0f}; + float updateDuration {0.0f}; + std::vector updateTimeSpans; std::unordered_map stateByNodeNumber; bool freeze {false}; /**< channel time is not to be updated */ bool transition {false}; /**< when computing states, use animation transition time as channel time */ @@ -123,6 +128,7 @@ class ModelSceneNode : public SceneNode { void setMainTexture(graphics::Texture *texture); void setEnvironmentMap(graphics::Texture *texture); void setPickable(bool pickable) { _pickable = pickable; } + void setParticleRenderProfile(const ParticleRenderProfile &profile); // Animation @@ -170,12 +176,14 @@ class ModelSceneNode : public SceneNode { std::deque _animChannels; AnimationBlendMode _animBlendMode {AnimationBlendMode::Single}; + std::vector _pendingAnimationEvents; // END Animation // Flags bool _pickable {false}; + ParticleRenderProfile _particleRenderProfile; // END Flags @@ -185,6 +193,7 @@ class ModelSceneNode : public SceneNode { void updateAnimations(float dt); void updateAnimationChannel(AnimationChannel &channel, float dt); + void dispatchAnimationEvents(); void computeAnimationStates(AnimationChannel &channel, float time, const graphics::ModelNode &modelNode); void applyAnimationStates(const graphics::ModelNode &modelNode); diff --git a/include/reone/scene/particleutil.h b/include/reone/scene/particleutil.h new file mode 100644 index 000000000..a9e66674d --- /dev/null +++ b/include/reone/scene/particleutil.h @@ -0,0 +1,104 @@ +/* + * Copyright (c) 2026 The reone project contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +#pragma once + +#include + +#include "glm/glm.hpp" + +#include "reone/scene/render/pass.h" + +namespace reone { + +namespace scene { + +namespace particleutil { + +constexpr float kMaxContinuousParticleDelta = 0.25f; +constexpr int kMaxSpawnParticlesPerUpdate = 256; + +struct ParticleSpawnSchedule { + std::array ages {0.0f}; + int count {0}; +}; + +struct MotionBlurBasis { + glm::vec3 right {0.0f}; + glm::vec3 up {0.0f}; + float lengthScale {1.0f}; +}; + +struct AtlasFrameBounds { + glm::vec2 minUV {0.5f}; + glm::vec2 maxUV {0.5f}; +}; + +struct DecodedParticleSample { + glm::vec3 color {0.0f}; + float alpha {0.0f}; +}; + +MotionBlurBasis buildMotionBlurBasis( + const glm::mat4 &emitterTransform, + const glm::vec3 &localVelocity, + const glm::vec3 &toCamera, + const glm::vec3 &cameraRight, + const glm::vec3 &cameraUp, + float particleLength, + float blurLength); + +int advanceSpawnAccumulator(float birthrate, float dt, float &accumulator); + +ParticleSpawnSchedule advanceSpawnSchedule( + float birthrate, + float dt, + float &accumulator); + +AtlasFrameBounds atlasFrameBounds( + const glm::ivec2 &textureSize, + const glm::ivec2 &gridSize, + int frame); + +glm::vec2 clampAtlasUV(const AtlasFrameBounds &bounds, const glm::vec2 &uv); + +std::array cubicReconstructionWeights(float fraction); + +DecodedParticleSample decodeParticleSample( + const glm::vec4 &sample, + ParticleAlphaMode alphaMode, + bool lightenBlend, + float alphaExponent); + +float enhanceParticleCoverage(float alpha, float contrast); + +float analyticParticleCoreEnvelope( + const glm::vec2 &localUV, + bool motionBlur, + float intensity); + +float clampMotionTrailLength( + float authoredLength, + float basisScale, + float profileScale, + float maximumLength); + +} // namespace particleutil + +} // namespace scene + +} // namespace reone diff --git a/include/reone/scene/render/pass.h b/include/reone/scene/render/pass.h index bbaeb3962..cba1f0e36 100644 --- a/include/reone/scene/render/pass.h +++ b/include/reone/scene/render/pass.h @@ -18,6 +18,7 @@ #pragma once #include "reone/graphics/types.h" +#include "reone/graphics/uniforms.h" namespace reone { @@ -51,6 +52,47 @@ enum class RenderPassName { Debug }; +enum class ParticleReconstruction { + Legacy = 0, + Cubic = 1 +}; + +enum class ParticleAlphaMode { + Legacy = 0, + Texture = 1, + Luminance = 2, + AlphaAndLuminance = 3 +}; + +enum class ParticleTrailMode { + Legacy = 0, + AnalyticCore = 1 +}; + +enum class ParticleDiagnosticMode { + Composite = 0, + TextureOnly = 1, + AlphaOnly = 2, + SolidColor = 3 +}; + +/** + * Shader-facing particle presentation options. + * + * All zero-valued modes select the original renderer path. This is deliberate: + * ordinary emitters remain one-sample and retain Odyssey Lighten decoding. + */ +struct ParticleRenderPolicy { + ParticleReconstruction reconstruction {ParticleReconstruction::Legacy}; + ParticleAlphaMode alpha {ParticleAlphaMode::Legacy}; + ParticleTrailMode trail {ParticleTrailMode::Legacy}; + ParticleDiagnosticMode diagnostic {ParticleDiagnosticMode::Composite}; + float reconstructionStrength {0.0f}; + float alphaExponent {1.0f}; + float trailCoreIntensity {0.0f}; + float coverageContrast {0.0f}; +}; + struct ParticleInstance { int frame {0}; glm::vec3 position {0.0f}; @@ -60,6 +102,33 @@ struct ParticleInstance { glm::vec3 up {0.0f}; }; +inline void populateParticleUniforms( + graphics::ParticleUniforms &uniforms, + const ParticleRenderPolicy &policy, + bool motionBlur, + const glm::ivec2 &gridSize, + const std::vector &particles) { + + uniforms.gridSize = gridSize; + uniforms.reconstructionMode = static_cast(policy.reconstruction); + uniforms.alphaMode = static_cast(policy.alpha); + uniforms.trailMode = static_cast(policy.trail); + uniforms.motionBlur = motionBlur ? 1 : 0; + uniforms.reconstructionStrength = policy.reconstructionStrength; + uniforms.alphaExponent = policy.alphaExponent; + uniforms.trailCoreIntensity = policy.trailCoreIntensity; + uniforms.coverageContrast = policy.coverageContrast; + uniforms.diagnosticMode = static_cast(policy.diagnostic); + for (size_t i = 0; i < particles.size(); ++i) { + const auto &particle = particles[i]; + uniforms.particles[i].positionFrame = glm::vec4(particle.position, static_cast(particle.frame)); + uniforms.particles[i].size = particle.size; + uniforms.particles[i].color = particle.color; + uniforms.particles[i].right = glm::vec4(particle.right, 0.0f); + uniforms.particles[i].up = glm::vec4(particle.up, 0.0f); + } +} + struct GrassInstance { int variant {0}; glm::vec3 position {0.0f}; @@ -102,6 +171,8 @@ class IRenderPass { virtual void drawParticles(graphics::Texture &texture, graphics::FaceCullMode faceCulling, bool premultipliedAlpha, + bool motionBlur, + const ParticleRenderPolicy &policy, const glm::ivec2 &gridSize, const std::vector &particles) = 0; diff --git a/include/reone/scene/render/pass/pbr.h b/include/reone/scene/render/pass/pbr.h index 63ec32104..23e971997 100644 --- a/include/reone/scene/render/pass/pbr.h +++ b/include/reone/scene/render/pass/pbr.h @@ -81,6 +81,8 @@ class PBRRenderPass : public IRenderPass, boost::noncopyable { void drawParticles(graphics::Texture &texture, graphics::FaceCullMode faceCulling, bool premultipliedAlpha, + bool motionBlur, + const ParticleRenderPolicy &policy, const glm::ivec2 &gridSize, const std::vector &particles) override; diff --git a/include/reone/scene/render/pass/retro.h b/include/reone/scene/render/pass/retro.h index 41d3bf1a7..97e9a637e 100644 --- a/include/reone/scene/render/pass/retro.h +++ b/include/reone/scene/render/pass/retro.h @@ -79,6 +79,8 @@ class RetroRenderPass : public IRenderPass, boost::noncopyable { void drawParticles(graphics::Texture &texture, graphics::FaceCullMode faceCulling, bool premultipliedAlpha, + bool motionBlur, + const ParticleRenderPolicy &policy, const glm::ivec2 &gridSize, const std::vector &particles) override; diff --git a/src/libs/game/CMakeLists.txt b/src/libs/game/CMakeLists.txt index 91fa0a4f7..e95a84e6b 100644 --- a/src/libs/game/CMakeLists.txt +++ b/src/libs/game/CMakeLists.txt @@ -457,6 +457,7 @@ set(GAME_SOURCES ${GAME_SOURCE_DIR}/effect/temporaryhitpoints.cpp ${GAME_SOURCE_DIR}/effect/trueseeing.cpp ${GAME_SOURCE_DIR}/effect/visual.cpp + ${GAME_SOURCE_DIR}/effect/visualprofile.cpp ${GAME_SOURCE_DIR}/effect/vpregenmodifier.cpp ${GAME_SOURCE_DIR}/effect/whirlwind.cpp ${GAME_SOURCE_DIR}/effect.cpp diff --git a/src/libs/game/effect/visual.cpp b/src/libs/game/effect/visual.cpp index b546cce13..6492b6ebf 100644 --- a/src/libs/game/effect/visual.cpp +++ b/src/libs/game/effect/visual.cpp @@ -29,10 +29,15 @@ namespace reone { namespace game { -VisualEffect::VisualEffect(int visualEffectId, bool missEffect, ServicesView &services) : +VisualEffect::VisualEffect( + int visualEffectId, + bool missEffect, + resource::GameID gameId, + ServicesView &services) : Effect(EffectType::Visual), _visualEffectId(visualEffectId), _missEffect(missEffect), + _gameId(gameId), _desc(services.game.visualEffects.get(visualEffectId).value_or(nullptr)), _services(services) { } @@ -84,6 +89,14 @@ void VisualEffect::applyTo(Object &object) { _node = graph->newModel(*_desc->impRootMNode, scene::ModelUsage::Projectile); graph->addRoot(_node); _node->setLocalTransform(glm::translate(_location.value())); + scene::ParticleRenderProfile profile; + if (particleRenderProfileForVisualEffect( + _gameId, + _visualEffectId, + *_desc, + profile)) { + _node->setParticleRenderProfile(profile); + } _node->playAnimation("impact"); } diff --git a/src/libs/game/effect/visualprofile.cpp b/src/libs/game/effect/visualprofile.cpp new file mode 100644 index 000000000..7b2f1ef55 --- /dev/null +++ b/src/libs/game/effect/visualprofile.cpp @@ -0,0 +1,281 @@ +/* + * Copyright (c) 2026 The reone project contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +#include "reone/game/effect/visual.h" + +#include "reone/game/visualeffects.h" +#include "reone/graphics/model.h" +#include "reone/scene/node/emitter.h" + +namespace reone { + +namespace game { + +static scene::ParticleRenderProfile baseGrenadeProfile() { + scene::ParticleRenderProfile profile; + profile.policy.reconstruction = scene::ParticleReconstruction::Cubic; + profile.policy.alpha = scene::ParticleAlphaMode::AlphaAndLuminance; + profile.policy.trail = scene::ParticleTrailMode::AnalyticCore; + profile.policy.coverageContrast = 0.55f; + return profile; +} + +static scene::ParticleRenderProfile fragmentationGrenadeProfile() { + auto profile = baseGrenadeProfile(); + profile.largeParticleScale = 0.50f; + profile.worldZScale = 0.48f; + profile.opacity = 0.38f; + profile.worldZOpacity = 0.42f; + profile.motionOpacity = 0.40f; + profile.motionLengthScale = 0.55f; + profile.motionMaxWidth = 0.12f; + profile.motionMaxLength = 0.80f; + profile.colorTint = glm::vec3(1.0f, 0.78f, 0.50f); + profile.policy.reconstructionStrength = 0.80f; + profile.policy.alphaExponent = 1.85f; + profile.policy.trailCoreIntensity = 0.04f; + profile.policy.coverageContrast = 0.32f; + return profile; +} + +static scene::ParticleRenderProfile stunGrenadeProfile() { + auto profile = baseGrenadeProfile(); + profile.largeParticleScale = 0.78f; + profile.worldZScale = 0.76f; + profile.opacity = 0.72f; + profile.worldZOpacity = 0.75f; + profile.motionOpacity = 0.48f; + profile.motionLengthScale = 0.60f; + profile.motionMaxWidth = 0.14f; + profile.motionMaxLength = 0.95f; + profile.policy.reconstructionStrength = 0.74f; + profile.policy.alphaExponent = 1.30f; + profile.policy.trailCoreIntensity = 0.05f; + return profile; +} + +static scene::ParticleRenderProfile thermalDetonatorProfile() { + auto profile = baseGrenadeProfile(); + profile.largeParticleScale = 0.70f; + profile.worldZScale = 0.67f; + profile.opacity = 0.72f; + profile.worldZOpacity = 0.75f; + profile.motionOpacity = 0.45f; + profile.motionLengthScale = 0.34f; + profile.motionMaxWidth = 0.075f; + profile.motionMaxLength = 0.52f; + profile.colorTint = glm::vec3(1.0f, 0.68f, 0.28f); + profile.colorIntensity = 1.05f; + profile.policy.reconstructionStrength = 0.84f; + profile.policy.alphaExponent = 1.30f; + profile.policy.trailCoreIntensity = 0.28f; + profile.policy.coverageContrast = 0.78f; + return profile; +} + +static scene::ParticleRenderProfile poisonGrenadeProfile() { + auto profile = baseGrenadeProfile(); + profile.largeParticleScale = 0.86f; + profile.worldZScale = 0.82f; + profile.opacity = 0.78f; + profile.worldZOpacity = 0.82f; + profile.motionOpacity = 0.55f; + profile.motionLengthScale = 0.72f; + profile.motionMaxWidth = 0.18f; + profile.motionMaxLength = 1.20f; + profile.policy.reconstructionStrength = 0.68f; + profile.policy.alphaExponent = 1.20f; + profile.policy.trailCoreIntensity = 0.03f; + return profile; +} + +static scene::ParticleRenderProfile sonicGrenadeProfile() { + auto profile = baseGrenadeProfile(); + profile.largeParticleScale = 0.42f; + profile.worldZScale = 0.40f; + profile.opacity = 0.32f; + profile.worldZOpacity = 0.35f; + profile.motionOpacity = 0.35f; + profile.motionLengthScale = 0.45f; + profile.motionMaxWidth = 0.10f; + profile.motionMaxLength = 0.70f; + profile.colorTint = glm::vec3(0.55f, 0.75f, 1.0f); + profile.policy.reconstructionStrength = 0.82f; + profile.policy.alphaExponent = 1.90f; + profile.policy.trailCoreIntensity = 0.04f; + profile.policy.coverageContrast = 0.28f; + return profile; +} + +static scene::ParticleRenderProfile adhesiveGrenadeProfile() { + auto profile = baseGrenadeProfile(); + profile.largeParticleScale = 0.58f; + profile.worldZScale = 0.54f; + profile.opacity = 0.45f; + profile.worldZOpacity = 0.48f; + profile.motionOpacity = 0.40f; + profile.motionLengthScale = 0.50f; + profile.motionMaxWidth = 0.12f; + profile.motionMaxLength = 0.80f; + profile.colorTint = glm::vec3(0.55f, 0.82f, 1.0f); + profile.policy.reconstructionStrength = 0.80f; + profile.policy.alphaExponent = 1.60f; + profile.policy.trailCoreIntensity = 0.03f; + profile.policy.coverageContrast = 0.35f; + return profile; +} + +static scene::ParticleRenderProfile cryobanGrenadeProfile() { + auto profile = baseGrenadeProfile(); + profile.largeParticleScale = 0.38f; + profile.worldZScale = 0.42f; + profile.opacity = 0.50f; + profile.worldZOpacity = 0.55f; + profile.motionOpacity = 0.35f; + profile.motionLengthScale = 0.40f; + profile.motionMaxWidth = 0.10f; + profile.motionMaxLength = 0.65f; + profile.colorTint = glm::vec3(0.35f, 0.75f, 1.0f); + profile.policy.reconstructionStrength = 0.82f; + profile.policy.alphaExponent = 1.70f; + profile.policy.trailCoreIntensity = 0.04f; + profile.policy.coverageContrast = 0.40f; + return profile; +} + +static scene::ParticleRenderProfile plasmaGrenadeProfile() { + auto profile = baseGrenadeProfile(); + profile.largeParticleScale = 0.70f; + profile.worldZScale = 0.68f; + profile.opacity = 0.70f; + profile.worldZOpacity = 0.73f; + profile.motionOpacity = 0.42f; + profile.motionLengthScale = 0.32f; + profile.motionMaxWidth = 0.07f; + profile.motionMaxLength = 0.48f; + profile.colorTint = glm::vec3(1.0f, 0.46f, 0.22f); + profile.colorIntensity = 1.05f; + profile.policy.reconstructionStrength = 0.86f; + profile.policy.alphaExponent = 1.35f; + profile.policy.trailCoreIntensity = 0.32f; + profile.policy.coverageContrast = 0.80f; + return profile; +} + +static scene::ParticleRenderProfile ionGrenadeProfile() { + auto profile = baseGrenadeProfile(); + profile.largeParticleScale = 0.56f; + profile.worldZScale = 0.65f; + profile.opacity = 0.50f; + profile.worldZOpacity = 0.80f; + profile.motionOpacity = 0.50f; + profile.motionLengthScale = 0.30f; + profile.motionMaxWidth = 0.06f; + profile.motionMaxLength = 0.42f; + profile.colorTint = glm::vec3(0.58f, 0.76f, 1.0f); + profile.colorIntensity = 1.05f; + profile.policy.reconstructionStrength = 0.88f; + profile.policy.alphaExponent = 1.70f; + profile.policy.trailCoreIntensity = 0.34f; + profile.policy.coverageContrast = 0.82f; + return profile; +} + +static bool matchesEffect( + const VisualEffectDesc &desc, + const char *label, + const char *impactModel) { + + return desc.label == label && + desc.impRootMNode && + desc.impRootMNode->name() == impactModel; +} + +bool particleRenderProfileForVisualEffect( + resource::GameID gameId, + uint32_t visualEffectId, + const VisualEffectDesc &desc, + scene::ParticleRenderProfile &profile) { + + if (gameId != resource::GameID::KotOR) { + return false; + } + + switch (visualEffectId) { + case VisualEffectIds::grenadeFragmentation: + if (matchesEffect(desc, "VFX_FNF_GRENADE_FRAGMENTATION", "v_grnfrag_fnf")) { + profile = fragmentationGrenadeProfile(); + return true; + } + break; + case VisualEffectIds::grenadeStun: + if (matchesEffect(desc, "VFX_FNF_GRENADE_STUN", "v_grnstun_fnf")) { + profile = stunGrenadeProfile(); + return true; + } + break; + case VisualEffectIds::thermalDetonator: + if (matchesEffect(desc, "VFX_FNF_GRENADE_THERMAL_DETONATOR", "v_grndeto_fnf")) { + profile = thermalDetonatorProfile(); + return true; + } + break; + case VisualEffectIds::grenadePoison: + if (matchesEffect(desc, "VFX_FNF_GRENADE_POISON", "v_grnpois_fnf")) { + profile = poisonGrenadeProfile(); + return true; + } + break; + case VisualEffectIds::grenadeSonic: + if (matchesEffect(desc, "VFX_FNF_GRENADE_SONIC", "v_grnsonc_fnf")) { + profile = sonicGrenadeProfile(); + return true; + } + break; + case VisualEffectIds::grenadeAdhesive: + if (matchesEffect(desc, "VFX_FNF_GRENADE_ADHESIVE", "v_grnadhs_fnf")) { + profile = adhesiveGrenadeProfile(); + return true; + } + break; + case VisualEffectIds::grenadeCryoban: + if (matchesEffect(desc, "VFX_FNF_GRENADE_CRYOBAN", "v_grncryo_fnf")) { + profile = cryobanGrenadeProfile(); + return true; + } + break; + case VisualEffectIds::grenadePlasma: + if (matchesEffect(desc, "VFX_FNF_GRENADE_PLASMA", "v_grnplas_fnf")) { + profile = plasmaGrenadeProfile(); + return true; + } + break; + case VisualEffectIds::grenadeIon: + if (matchesEffect(desc, "VFX_FNF_GRENADE_ION", "v_grnion_fnf")) { + profile = ionGrenadeProfile(); + return true; + } + break; + default: + break; + } + return false; +} + +} // namespace game + +} // namespace reone diff --git a/src/libs/game/script/routine/impl/effect.cpp b/src/libs/game/script/routine/impl/effect.cpp index c4e4c1d4a..c54794fa3 100644 --- a/src/libs/game/script/routine/impl/effect.cpp +++ b/src/libs/game/script/routine/impl/effect.cpp @@ -412,7 +412,8 @@ static Variable EffectVisualEffect(const std::vector &args, const Rout bool missEffect = static_cast(nMissEffect); // Execute - auto effect = ctx.game.newEffect(nVisualEffectId, missEffect, ctx.services); + auto gameId = ctx.game.isTSL() ? resource::GameID::TSL : resource::GameID::KotOR; + auto effect = ctx.game.newEffect(nVisualEffectId, missEffect, gameId, ctx.services); return Variable::ofEffect(std::move(effect)); } diff --git a/src/libs/game/visualeffects.cpp b/src/libs/game/visualeffects.cpp index 399a54462..5b9f1a81b 100644 --- a/src/libs/game/visualeffects.cpp +++ b/src/libs/game/visualeffects.cpp @@ -34,15 +34,15 @@ namespace game { static std::unordered_map workaroundLabels() { std::unordered_map labels; - labels["VFX_FNF_GRENADE_FRAGMENTATION"] = 3003; - labels["VFX_FNF_GRENADE_STUN"] = 3004; - labels["VFX_FNF_GRENADE_THERMAL_DETONATOR"] = 3005; - labels["VFX_FNF_GRENADE_POISON"] = 3006; - labels["VFX_FNF_GRENADE_SONIC"] = 3007; - labels["VFX_FNF_GRENADE_ADHESIVE"] = 3008; - labels["VFX_FNF_GRENADE_CRYOBAN"] = 3009; - labels["VFX_FNF_GRENADE_PLASMA"] = 3010; - labels["VFX_FNF_GRENADE_ION"] = 3011; + labels["VFX_FNF_GRENADE_FRAGMENTATION"] = VisualEffectIds::grenadeFragmentation; + labels["VFX_FNF_GRENADE_STUN"] = VisualEffectIds::grenadeStun; + labels["VFX_FNF_GRENADE_THERMAL_DETONATOR"] = VisualEffectIds::thermalDetonator; + labels["VFX_FNF_GRENADE_POISON"] = VisualEffectIds::grenadePoison; + labels["VFX_FNF_GRENADE_SONIC"] = VisualEffectIds::grenadeSonic; + labels["VFX_FNF_GRENADE_ADHESIVE"] = VisualEffectIds::grenadeAdhesive; + labels["VFX_FNF_GRENADE_CRYOBAN"] = VisualEffectIds::grenadeCryoban; + labels["VFX_FNF_GRENADE_PLASMA"] = VisualEffectIds::grenadePlasma; + labels["VFX_FNF_GRENADE_ION"] = VisualEffectIds::grenadeIon; return labels; } diff --git a/src/libs/scene/CMakeLists.txt b/src/libs/scene/CMakeLists.txt index 4be89d1b4..2a8a9be25 100644 --- a/src/libs/scene/CMakeLists.txt +++ b/src/libs/scene/CMakeLists.txt @@ -41,6 +41,7 @@ set(SCENE_HEADERS ${SCENE_INCLUDE_DIR}/node/sound.h ${SCENE_INCLUDE_DIR}/node/trigger.h ${SCENE_INCLUDE_DIR}/node/walkmesh.h + ${SCENE_INCLUDE_DIR}/particleutil.h ${SCENE_INCLUDE_DIR}/render/pass.h ${SCENE_INCLUDE_DIR}/render/pass/retro.h ${SCENE_INCLUDE_DIR}/render/pass/pbr.h @@ -67,6 +68,7 @@ set(SCENE_SOURCES ${SCENE_SOURCE_DIR}/node/sound.cpp ${SCENE_SOURCE_DIR}/node/trigger.cpp ${SCENE_SOURCE_DIR}/node/walkmesh.cpp + ${SCENE_SOURCE_DIR}/particleutil.cpp ${SCENE_SOURCE_DIR}/render/pass/retro.cpp ${SCENE_SOURCE_DIR}/render/pass/pbr.cpp ${SCENE_SOURCE_DIR}/render/pipeline.cpp diff --git a/src/libs/scene/graph.cpp b/src/libs/scene/graph.cpp index 370dcb1be..cc6988225 100644 --- a/src/libs/scene/graph.cpp +++ b/src/libs/scene/graph.cpp @@ -148,6 +148,11 @@ void SceneGraph::removeRoot(SoundSceneNode &node) { } void SceneGraph::update(float dt) { + if (_activeCamera) { + // Keep spawn suppression current before emitters advance. Culling is + // repeated after animation so rendering uses the final transforms. + cullRoots(); + } if (_updateRoots) { for (auto &root : _modelRoots) { root->update(dt); diff --git a/src/libs/scene/node/emitter.cpp b/src/libs/scene/node/emitter.cpp index 0b3eedb40..ce896e155 100644 --- a/src/libs/scene/node/emitter.cpp +++ b/src/libs/scene/node/emitter.cpp @@ -29,17 +29,427 @@ #include "reone/scene/graph.h" #include "reone/scene/node/camera.h" #include "reone/scene/node/particle.h" +#include "reone/scene/particleutil.h" #include "reone/scene/render/pass.h" #include "reone/system/randomutil.h" using namespace reone::graphics; +namespace { + +void appendBirthrateReset( + std::vector &steps, + float duration = 0.0f) { + + if (steps.empty() || !steps.back().resetAccumulator) { + steps.push_back({0.0f, 0.0f, duration, true}); + } else { + steps.back().duration += duration; + } +} + +void appendPositiveBirthrateSegment( + std::vector &steps, + float startRate, + float endRate, + float duration) { + + if (!std::isfinite(startRate) || + !std::isfinite(endRate) || + !std::isfinite(duration) || + duration <= 0.0f) { + return; + } + steps.push_back({ + glm::max(startRate, 0.0f), + glm::max(endRate, 0.0f), + duration, + false}); +} + +void appendBirthrateTrackSpan( + const reone::graphics::KeyframeTrack &track, + float startTime, + float endTime, + float playbackSpeed, + std::vector &steps) { + + if (endTime <= startTime || playbackSpeed <= 0.0f) { + appendBirthrateReset(steps); + return; + } + + float leftTime = startTime; + float leftValue = 0.0f; + if (!track.valueAtTime(leftTime, leftValue)) { + appendBirthrateReset(steps); + return; + } + + auto appendSegment = [&](float rightTime, float rightValue) { + float duration = (rightTime - leftTime) / playbackSpeed; + if (leftValue <= 0.0f && rightValue <= 0.0f) { + appendBirthrateReset(steps, duration); + } else if (leftValue >= 0.0f && rightValue >= 0.0f) { + if (leftValue <= 0.0f) { + appendBirthrateReset(steps); + } + appendPositiveBirthrateSegment( + steps, + leftValue, + rightValue, + duration); + if (rightValue <= 0.0f) { + appendBirthrateReset(steps); + } + } else { + float zeroFactor = -leftValue / (rightValue - leftValue); + float firstDuration = duration * zeroFactor; + float secondDuration = duration - firstDuration; + if (leftValue > 0.0f) { + appendPositiveBirthrateSegment( + steps, + leftValue, + 0.0f, + firstDuration); + appendBirthrateReset(steps, secondDuration); + } else { + appendBirthrateReset(steps, firstDuration); + appendPositiveBirthrateSegment( + steps, + 0.0f, + rightValue, + secondDuration); + } + } + leftTime = rightTime; + leftValue = rightValue; + }; + + for (const auto &keyframe : track.keyframes()) { + if (keyframe.time <= startTime || keyframe.time >= endTime) { + continue; + } + + float rightValue = 0.0f; + track.valueAtTime(keyframe.time, rightValue); + appendSegment(keyframe.time, rightValue); + } + + float rightValue = 0.0f; + track.valueAtTime(endTime, rightValue); + appendSegment(endTime, rightValue); +} + +float timeAtIntegratedBirthCount( + float startRate, + float endRate, + float duration, + double birthCount) { + + if (duration <= 0.0f || birthCount <= 0.0) { + return 0.0f; + } + + double slope = + (static_cast(endRate) - static_cast(startRate)) / + static_cast(duration); + double discriminant = + static_cast(startRate) * static_cast(startRate) + + 2.0 * slope * birthCount; + double root = std::sqrt(glm::max(discriminant, 0.0)); + double denominator = static_cast(startRate) + root; + if (denominator <= 0.0) { + return duration; + } + return glm::clamp( + static_cast(2.0 * birthCount / denominator), + 0.0f, + duration); +} + +reone::scene::particleutil::ParticleSpawnSchedule advanceAnimatedSpawnAccumulator( + const std::vector &steps, + float dt, + float &accumulator) { + + static constexpr float kWholeParticleEpsilon = 1e-5f; + + if (!std::isfinite(dt) || + !std::isfinite(accumulator) || + dt <= 0.0f || + dt > reone::scene::particleutil::kMaxContinuousParticleDelta) { + accumulator = 0.0f; + return {}; + } + + reone::scene::particleutil::ParticleSpawnSchedule schedule; + float elapsed = 0.0f; + for (const auto &step : steps) { + if (step.resetAccumulator) { + accumulator = 0.0f; + elapsed += glm::max(step.duration, 0.0f); + continue; + } + if (!std::isfinite(step.startRate) || + !std::isfinite(step.endRate) || + !std::isfinite(step.duration) || + step.duration <= 0.0f) { + continue; + } + + double segmentBirths = + 0.5 * + (static_cast(step.startRate) + + static_cast(step.endRate)) * + static_cast(step.duration); + double previousAccumulator = glm::clamp(accumulator, 0.0f, 1.0f); + double accumulatedBirths = previousAccumulator + segmentBirths; + if (!std::isfinite(accumulatedBirths)) { + accumulator = 0.0f; + elapsed += step.duration; + continue; + } + + double wholeParticles = + glm::floor(accumulatedBirths + kWholeParticleEpsilon); + accumulator = static_cast(accumulatedBirths - wholeParticles); + if (accumulator < 0.0f && + accumulator > -kWholeParticleEpsilon) { + accumulator = 0.0f; + } + int birthsInSegment = static_cast(glm::min( + wholeParticles, + static_cast( + reone::scene::particleutil::kMaxSpawnParticlesPerUpdate))); + for (int i = 0; + i < birthsInSegment && + schedule.count < reone::scene::particleutil::kMaxSpawnParticlesPerUpdate; + ++i) { + double targetBirthCount = + 1.0 - previousAccumulator + static_cast(i); + float birthTime = timeAtIntegratedBirthCount( + step.startRate, + step.endRate, + step.duration, + targetBirthCount); + schedule.ages[schedule.count++] = glm::clamp( + dt - (elapsed + birthTime), + 0.0f, + dt); + } + elapsed += step.duration; + } + return schedule; +} + +} // namespace + namespace reone { namespace scene { -static constexpr float kMotionBlurStrength = 0.25f; -static constexpr float kProjectileSpeed = 16.0f; +bool EmitterSceneNode::AnimationState::empty() const { + return !birthrate && + !birthrateStepsForUpdate && + !lifeExpectancy && + !xSize && + !ySize && + !frameStart && + !frameEnd && + !fps && + !spread && + !velocity && + !randomVelocity && + !blurLength && + !mass && + !grav && + !lightningDelay && + !lightningRadius && + !lightningScale && + !lightningSubDiv && + !particleSizeStart && + !particleSizeMid && + !particleSizeEnd && + !colorStart && + !colorMid && + !colorEnd && + !alphaStart && + !alphaMid && + !alphaEnd; +} + +EmitterSceneNode::AnimationState EmitterSceneNode::animationStateAt( + const ModelNode &animationNode, + float time) { + + AnimationState state; + auto readFloat = [&animationNode, time](ControllerType type) -> std::optional { + float value; + if (animationNode.floatValueAtTime(type, time, value)) { + return value; + } + return std::nullopt; + }; + auto readInt = [&readFloat](ControllerType type) -> std::optional { + auto value = readFloat(type); + return value ? std::make_optional(static_cast(*value)) : std::nullopt; + }; + auto readVector = [&animationNode, time](ControllerType type) -> std::optional { + glm::vec3 value; + if (animationNode.vectorValueAtTime(type, time, value)) { + return value; + } + return std::nullopt; + }; + + state.birthrate = readFloat(ControllerTypes::birthrate); + state.lifeExpectancy = readFloat(ControllerTypes::lifeExp); + state.xSize = readFloat(ControllerTypes::xSize); + state.ySize = readFloat(ControllerTypes::ySize); + state.frameStart = readInt(ControllerTypes::frameStart); + state.frameEnd = readInt(ControllerTypes::frameEnd); + state.fps = readFloat(ControllerTypes::fps); + state.spread = readFloat(ControllerTypes::spread); + state.velocity = readFloat(ControllerTypes::velocity); + state.randomVelocity = readFloat(ControllerTypes::randVel); + state.blurLength = readFloat(ControllerTypes::blurLength); + state.mass = readFloat(ControllerTypes::mass); + state.grav = readFloat(ControllerTypes::grav); + state.lightningDelay = readFloat(ControllerTypes::lightingDelay); + state.lightningRadius = readFloat(ControllerTypes::lightingRadius); + state.lightningScale = readFloat(ControllerTypes::lightingScale); + state.lightningSubDiv = readInt(ControllerTypes::lightingSubDiv); + state.particleSizeStart = readFloat(ControllerTypes::sizeStart); + state.particleSizeMid = readFloat(ControllerTypes::sizeMid); + state.particleSizeEnd = readFloat(ControllerTypes::sizeEnd); + state.colorStart = readVector(ControllerTypes::colorStart); + state.colorMid = readVector(ControllerTypes::colorMid); + state.colorEnd = readVector(ControllerTypes::colorEnd); + state.alphaStart = readFloat(ControllerTypes::alphaStart); + state.alphaMid = readFloat(ControllerTypes::alphaMid); + state.alphaEnd = readFloat(ControllerTypes::alphaEnd); + + return state; +} + +std::optional> +EmitterSceneNode::animationBirthrateStepsForUpdate( + const ModelNode &animationNode, + const std::vector &timeSpans, + float playbackSpeed, + float dt) { + + const auto &floatTracks = animationNode.floatTracks(); + auto track = floatTracks.find(ControllerTypes::birthrate); + if (track == floatTracks.end()) { + return std::nullopt; + } + + std::vector steps; + if (dt <= 0.0f || playbackSpeed <= 0.0f || timeSpans.empty()) { + appendBirthrateReset(steps); + return steps; + } + + for (const auto &timeSpan : timeSpans) { + for (size_t repetition = 0; + repetition < timeSpan.repetitions; + ++repetition) { + appendBirthrateTrackSpan( + track->second, + timeSpan.startTime, + timeSpan.endTime, + playbackSpeed, + steps); + } + } + return steps; +} + +void EmitterSceneNode::applyAnimationState(const AnimationState &state) { + if (state.birthrate) { + _birthrate = glm::max(*state.birthrate, 0.0f); + } + if (state.birthrateStepsForUpdate) { + _birthrateStepsForUpdate = *state.birthrateStepsForUpdate; + } + if (state.lifeExpectancy) { + _lifeExpectancy = *state.lifeExpectancy; + } + if (state.xSize) { + _size.x = *state.xSize; + } + if (state.ySize) { + _size.y = *state.ySize; + } + if (state.frameStart) { + _frameStart = *state.frameStart; + } + if (state.frameEnd) { + _frameEnd = *state.frameEnd; + } + if (state.fps) { + _fps = *state.fps; + } + if (state.spread) { + _spread = *state.spread; + } + if (state.velocity) { + _velocity = *state.velocity; + } + if (state.randomVelocity) { + _randomVelocity = *state.randomVelocity; + } + if (state.blurLength) { + _blurLength = *state.blurLength; + } + if (state.mass) { + _mass = *state.mass; + } + if (state.grav) { + _grav = *state.grav; + } + if (state.lightningDelay) { + _lightningDelay = *state.lightningDelay; + } + if (state.lightningRadius) { + _lightningRadius = *state.lightningRadius; + } + if (state.lightningScale) { + _lightningScale = *state.lightningScale; + } + if (state.lightningSubDiv) { + _lightningSubDiv = *state.lightningSubDiv; + } + if (state.particleSizeStart) { + _particleSize.start = *state.particleSizeStart; + } + if (state.particleSizeMid) { + _particleSize.mid = *state.particleSizeMid; + } + if (state.particleSizeEnd) { + _particleSize.end = *state.particleSizeEnd; + } + if (state.colorStart) { + _color.start = *state.colorStart; + } + if (state.colorMid) { + _color.mid = *state.colorMid; + } + if (state.colorEnd) { + _color.end = *state.colorEnd; + } + if (state.alphaStart) { + _alpha.start = *state.alphaStart; + } + if (state.alphaMid) { + _alpha.mid = *state.alphaMid; + } + if (state.alphaEnd) { + _alpha.end = *state.alphaEnd; + } +} void EmitterSceneNode::init() { _modelNode.floatValueAtTime(ControllerTypes::birthrate, 0.0f, _birthrate); @@ -59,6 +469,7 @@ void EmitterSceneNode::init() { _modelNode.floatValueAtTime(ControllerTypes::spread, 0.0f, _spread); _modelNode.floatValueAtTime(ControllerTypes::velocity, 0.0f, _velocity); _modelNode.floatValueAtTime(ControllerTypes::randVel, 0.0f, _randomVelocity); + _modelNode.floatValueAtTime(ControllerTypes::blurLength, 0.0f, _blurLength); _modelNode.floatValueAtTime(ControllerTypes::mass, 0.0f, _mass); _modelNode.floatValueAtTime(ControllerTypes::grav, 0.0f, _grav); _modelNode.floatValueAtTime(ControllerTypes::lightingDelay, 0.0f, _lightningDelay); @@ -80,30 +491,53 @@ void EmitterSceneNode::init() { _modelNode.floatValueAtTime(ControllerTypes::alphaMid, 0.0f, _alpha.mid); _modelNode.floatValueAtTime(ControllerTypes::alphaEnd, 0.0f, _alpha.end); - if (_birthrate != 0.0f) { - _birthInterval = 1.0f / _birthrate; - } - - // Pre-allocate particles - int numParticles; - if (_modelNode.emitter()->updateMode == ModelNode::Emitter::UpdateMode::Single) { - numParticles = 1; - } else { - numParticles = kMaxParticles; - } - for (int i = 0; i < numParticles; ++i) { - _particlePool.push_back(_sceneGraph.newParticle(*this).get()); - } } void EmitterSceneNode::update(float dt) { removeExpiredParticles(dt); - spawnParticles(dt); for (auto &child : _children) { + if (child->type() != SceneNodeType::Particle) { + continue; + } auto particle = static_cast(child); particle->update(dt); } + + if (isSpawningSuppressed()) { + discardSpawnTime(dt); + } else { + spawnParticles(dt); + } + _birthrateStepsForUpdate.reset(); +} + +bool EmitterSceneNode::isSpawningSuppressed() const { + for (auto ancestor = parent(); ancestor; ancestor = ancestor->parent()) { + if (ancestor->type() == SceneNodeType::Model && ancestor->isCulled()) { + return true; + } + } + return false; +} + +void EmitterSceneNode::discardSpawnTime(float dt) { + _birthAccumulator = 0.0f; + + auto emitter = _modelNode.emitter(); + if (emitter->updateMode == ModelNode::Emitter::UpdateMode::Single) { + if (!emitter->loop) { + _spawned = true; + } + return; + } + if (emitter->updateMode != ModelNode::Emitter::UpdateMode::Lightning) { + return; + } + _birthTimer.update(dt); + if (_birthTimer.elapsed()) { + _birthTimer.reset(_lightningDelay); + } } void EmitterSceneNode::removeExpiredParticles(float dt) { @@ -129,18 +563,26 @@ void EmitterSceneNode::removeExpiredParticles(float dt) { void EmitterSceneNode::spawnParticles(float dt) { std::shared_ptr emitter(_modelNode.emitter()); switch (emitter->updateMode) { - case ModelNode::Emitter::UpdateMode::Fountain: - if (_birthrate != 0.0f) { - _birthTimer.update(dt); - if (_birthTimer.elapsed()) { - doSpawnParticle(); - _birthTimer.reset(_birthInterval); + case ModelNode::Emitter::UpdateMode::Fountain: { + auto schedule = _birthrateStepsForUpdate + ? advanceAnimatedSpawnAccumulator( + *_birthrateStepsForUpdate, + dt, + _birthAccumulator) + : particleutil::advanceSpawnSchedule( + _birthrate, + dt, + _birthAccumulator); + for (int i = 0; i < schedule.count; ++i) { + if (!doSpawnParticle(schedule.ages[i])) { + break; } } break; + } case ModelNode::Emitter::UpdateMode::Single: if (!_spawned || (_children.empty() && emitter->loop)) { - doSpawnParticle(); + doSpawnParticle(dt); _spawned = true; } break; @@ -156,12 +598,30 @@ void EmitterSceneNode::spawnParticles(float dt) { } } -void EmitterSceneNode::doSpawnParticle() { - // Take particle from the pool, if available - if (_particlePool.empty()) { - return; +ParticleSceneNode *EmitterSceneNode::takeParticle() { + if (!_particlePool.empty()) { + auto particle = _particlePool.front(); + _particlePool.pop_front(); + return particle; + } + + int maxParticles = _modelNode.emitter()->updateMode == ModelNode::Emitter::UpdateMode::Single + ? 1 + : graphics::kMaxParticles; + if (_particleCount >= maxParticles) { + return nullptr; + } + + auto particle = _sceneGraph.newParticle(*this).get(); + ++_particleCount; + return particle; +} + +bool EmitterSceneNode::doSpawnParticle(float initialAge) { + auto particle = takeParticle(); + if (!particle) { + return false; } - auto particle = static_cast(_particlePool.front()); particle->setLifetime(0.0f); float halfW = 0.005f * _size.x; @@ -181,9 +641,11 @@ void EmitterSceneNode::doSpawnParticle() { particle->setAnimLength((_frameEnd - _frameStart + 1) / _fps); } - // Remove particle from pool and append it to emitter - _particlePool.pop_front(); addChild(*particle); + if (initialAge > 0.0f) { + particle->update(initialAge); + } + return true; } void EmitterSceneNode::spawnLightningParticles() { @@ -228,12 +690,10 @@ void EmitterSceneNode::spawnLightningParticles() { } for (auto &segment : segments) { - // Take particle from the pool, if available - if (_particlePool.empty()) { + auto particle = takeParticle(); + if (!particle) { return; } - auto particle = _particlePool.front(); - _particlePool.pop_front(); particle->setLifetime(0.0f); glm::vec3 endToStart(segment.second - segment.first); @@ -247,6 +707,9 @@ void EmitterSceneNode::spawnLightningParticles() { } void EmitterSceneNode::detonate() { + if (isSpawningSuppressed()) { + return; + } doSpawnParticle(); } @@ -263,7 +726,8 @@ void EmitterSceneNode::renderLeafs(IRenderPass &pass, const std::vectorget().camera()->view(); + auto &cameraNode = _sceneGraph.camera()->get(); + auto view = cameraNode.camera()->view(); auto cameraRight = glm::vec3(view[0][0], view[1][0], view[2][0]); auto cameraUp = glm::vec3(view[0][1], view[1][1], view[2][1]); auto cameraForward = glm::vec3(view[0][2], view[1][2], view[2][2]); @@ -274,17 +738,38 @@ void EmitterSceneNode::renderLeafs(IRenderPass &pass, const std::vectorframe(); particles[i].position = particle->origin(); particles[i].size = glm::vec2(particle->size()); - particles[i].color = glm::vec4(particle->color(), particle->alpha()); + particles[i].color = glm::vec4( + particle->color() * _renderProfile.colorTint * _renderProfile.colorIntensity, + particle->alpha()); + particles[i].color.a *= _renderProfile.opacity; switch (emitter->renderMode) { case ModelNode::Emitter::RenderMode::BillboardToLocalZ: - case ModelNode::Emitter::RenderMode::MotionBlur: - if (emitter->renderMode == ModelNode::Emitter::RenderMode::MotionBlur) { - particles[i].size = glm::vec2(particle->size().x, (1.0f + kMotionBlurStrength * kProjectileSpeed) * particle->size().y); - } particles[i].right = glm::vec4(emitterUp, 0.0f); particles[i].up = glm::vec4(emitterRight, 0.0f); break; + case ModelNode::Emitter::RenderMode::MotionBlur: { + auto basis = particleutil::buildMotionBlurBasis( + _absTransform, + particle->velocity(), + cameraNode.origin() - particle->origin(), + cameraRight, + cameraUp, + particles[i].size.y, + _blurLength); + particles[i].size.x = glm::min(particles[i].size.x, _renderProfile.motionMaxWidth); + particles[i].size.y = particleutil::clampMotionTrailLength( + particles[i].size.y, + basis.lengthScale, + _renderProfile.motionLengthScale, + _renderProfile.motionMaxLength); + particles[i].color.a *= _renderProfile.motionOpacity; + particles[i].right = glm::vec4(basis.right, 0.0f); + particles[i].up = glm::vec4(basis.up, 0.0f); + break; + } case ModelNode::Emitter::RenderMode::BillboardToWorldZ: + particles[i].size *= _renderProfile.worldZScale; + particles[i].color.a *= _renderProfile.worldZOpacity; particles[i].right = glm::vec4(0.0f, 1.0f, 0.0, 0.0f); particles[i].up = glm::vec4(1.0f, 0.0f, 0.0f, 0.0f); break; @@ -306,11 +791,25 @@ void EmitterSceneNode::renderLeafs(IRenderPass &pass, const std::vectorrenderMode != ModelNode::Emitter::RenderMode::MotionBlur && + emitter->renderMode != ModelNode::Emitter::RenderMode::BillboardToWorldZ) { + float largestDimension = glm::max(particles[i].size.x, particles[i].size.y); + float largeParticleFactor = glm::smoothstep(1.0f, 4.0f, largestDimension); + particles[i].size *= glm::mix(1.0f, _renderProfile.largeParticleScale, largeParticleFactor); + } } bool twosided = _modelNode.emitter()->twosided || _modelNode.emitter()->renderMode == ModelNode::Emitter::RenderMode::MotionBlur; auto faceCulling = twosided ? FaceCullMode::None : FaceCullMode::Back; bool premultipliedAlpha = emitter->blendMode == ModelNode::Emitter::BlendMode::Lighten; - pass.drawParticles(*texture, faceCulling, premultipliedAlpha, emitter->gridSize, particles); + bool motionBlur = emitter->renderMode == ModelNode::Emitter::RenderMode::MotionBlur; + pass.drawParticles( + *texture, + faceCulling, + premultipliedAlpha, + motionBlur, + _renderProfile.policy, + emitter->gridSize, + particles); } } // namespace scene diff --git a/src/libs/scene/node/model.cpp b/src/libs/scene/node/model.cpp index 01e48ac52..81c978dd4 100644 --- a/src/libs/scene/node/model.cpp +++ b/src/libs/scene/node/model.cpp @@ -65,6 +65,7 @@ void ModelSceneNode::buildNodeTree(ModelNode &node, SceneNode &parent) { sceneNode = _sceneGraph.newLight(*this, node); } else if (node.isEmitter()) { sceneNode = _sceneGraph.newEmitter(node); + static_cast(sceneNode.get())->setRenderProfile(_particleRenderProfile); } else { sceneNode = _sceneGraph.newDummy(node); } @@ -102,8 +103,9 @@ void ModelSceneNode::update(float dt) { if (!_enabled) { return; } - SceneNode::update(dt); updateAnimations(dt); + SceneNode::update(dt); + dispatchAnimationEvents(); } void ModelSceneNode::renderLeafs(IRenderPass &pass, const std::vector &leafs) { @@ -153,6 +155,9 @@ void ModelSceneNode::attach(const std::string &parentName, SceneNode &node) { parent->addChild(node); _attachments.insert(std::make_pair(parentName, &node)); + if (node.type() == SceneNodeType::Model) { + static_cast(&node)->setParticleRenderProfile(_particleRenderProfile); + } computeAABB(); } @@ -192,6 +197,20 @@ void ModelSceneNode::setEnvironmentMap(Texture *texture) { } } +void ModelSceneNode::setParticleRenderProfile(const ParticleRenderProfile &profile) { + _particleRenderProfile = profile; + for (auto &[_, node] : _nodeByNumber) { + if (node->type() == SceneNodeType::Emitter) { + static_cast(node)->setRenderProfile(profile); + } + } + for (auto &[_, attachment] : _attachments) { + if (attachment->type() == SceneNodeType::Model) { + static_cast(attachment)->setParticleRenderProfile(profile); + } + } +} + void ModelSceneNode::playAnimation(const std::string &name, std::shared_ptr lipAnim, AnimationProperties properties) { auto anim = _model->getAnimation(name); if (anim) { @@ -232,6 +251,7 @@ void ModelSceneNode::playAnimation(Animation &anim, std::shared_ptrtransitionTime() - kTransitionLength); + _animChannels[0].particleTime = _animChannels[0].time; } while (_animChannels.size() > 2ll) { _animChannels.pop_back(); @@ -269,6 +289,13 @@ ModelSceneNode::AnimationBlendMode ModelSceneNode::getAnimationBlendMode(int fla } void ModelSceneNode::updateAnimations(float dt) { + _pendingAnimationEvents.clear(); + for (auto &channel : _animChannels) { + for (auto &[_, state] : channel.stateByNodeNumber) { + state.emitter.birthrateStepsForUpdate.reset(); + } + } + // Erase finished channels switch (_animBlendMode) { case AnimationBlendMode::Single: @@ -300,6 +327,14 @@ void ModelSceneNode::updateAnimations(float dt) { } if (!channel.freeze) { updateAnimationChannel(channel, dt); + } else if (!_culled) { + channel.updateDuration = 0.0f; + channel.updateTimeSpans.clear(); + float time = channel.transition + ? channel.anim->transitionTime() + : channel.time; + channel.stateByNodeNumber.clear(); + computeAnimationStates(channel, time, *_model->rootNode()); } } @@ -313,9 +348,57 @@ void ModelSceneNode::updateAnimationChannel(AnimationChannel &channel, float dt) // Take length from the lip animation, if any float length = channel.lipAnim ? channel.lipAnim->length() : channel.anim->length(); - // Advance time + // Track exact wrapped intervals for animated particle birthrate integration. float oldTime = channel.time; - channel.time = glm::min(length, channel.time + channel.properties.speed * dt); + channel.updateDuration = dt; + channel.updateTimeSpans.clear(); + float advance = glm::max(channel.properties.speed * dt, 0.0f); + bool loop = channel.properties.flags & AnimationFlags::loop; + + if (length > 0.0f && advance > 0.0f) { + float particleTime = glm::clamp( + channel.particleTime, + 0.0f, + length); + if (loop && particleTime == length) { + particleTime = 0.0f; + } + + float particleEnd = glm::min(length, particleTime + advance); + if (!loop || particleTime + advance < length) { + channel.updateTimeSpans.push_back( + {particleTime, particleEnd, 1}); + channel.particleTime = particleEnd; + } else { + float remaining = advance; + float firstSpan = length - particleTime; + if (firstSpan > 0.0f) { + channel.updateTimeSpans.push_back( + {particleTime, length, 1}); + remaining -= firstSpan; + } + + size_t fullLoops = static_cast( + glm::floor(remaining / length)); + if (fullLoops > 0) { + channel.updateTimeSpans.push_back( + {0.0f, length, fullLoops}); + remaining -= static_cast(fullLoops) * length; + } + if (remaining > 0.0f) { + channel.updateTimeSpans.push_back( + {0.0f, remaining, 1}); + } + channel.particleTime = remaining; + } + } else if (length <= 0.0f) { + channel.particleTime = 0.0f; + } + + // Preserve the established animation-state clock and endpoint presentation. + channel.time = length > 0.0f + ? glm::min(length, oldTime + advance) + : 0.0f; // Clear transition flag if past transition time if (channel.transition && channel.time >= channel.anim->transitionTime()) { @@ -323,9 +406,9 @@ void ModelSceneNode::updateAnimationChannel(AnimationChannel &channel, float dt) } // Signal events between previous and current time - for (auto &event : channel.anim->events()) { + for (const auto &event : channel.anim->events()) { if (event.time > oldTime && event.time <= channel.time) { - signalEvent(event.name); + _pendingAnimationEvents.push_back(event.name); } } @@ -338,7 +421,6 @@ void ModelSceneNode::updateAnimationChannel(AnimationChannel &channel, float dt) bool lastFrame = channel.time == length; if (lastFrame) { - bool loop = channel.properties.flags & AnimationFlags::loop; if (loop) { channel.time = 0.0f; } else { @@ -347,6 +429,13 @@ void ModelSceneNode::updateAnimationChannel(AnimationChannel &channel, float dt) } } +void ModelSceneNode::dispatchAnimationEvents() { + for (const auto &event : _pendingAnimationEvents) { + signalEvent(event); + } + _pendingAnimationEvents.clear(); +} + static bool doesNodeHaveAncestor(const ModelNode &node, const std::string &name) { if (name.empty()) { return true; @@ -409,15 +498,29 @@ void ModelSceneNode::computeAnimationStates(AnimationChannel &channel, float tim state.transform *= glm::translate(position); state.transform *= glm::mat4_cast(orientation); } - if (animNode->floatValueAtTime(ControllerTypes::alpha, time, state.alpha)) { + if (modelNode.isMesh() && animNode->floatValueAtTime(ControllerTypes::alpha, time, state.alpha)) { state.flags |= AnimationStateFlags::alpha; } - if (animNode->vectorValueAtTime(ControllerTypes::selfIllumColor, time, state.selfIllumColor)) { + if (modelNode.isMesh() && animNode->vectorValueAtTime(ControllerTypes::selfIllumColor, time, state.selfIllumColor)) { state.flags |= AnimationStateFlags::selfIllumColor; } - if (animNode->vectorValueAtTime(ControllerTypes::color, time, state.color)) { + if (modelNode.isLight() && animNode->vectorValueAtTime(ControllerTypes::color, time, state.color)) { state.flags |= AnimationStateFlags::color; } + if (modelNode.isEmitter()) { + state.emitter = EmitterSceneNode::animationStateAt( + *animNode, + channel.particleTime); + state.emitter.birthrateStepsForUpdate = + EmitterSceneNode::animationBirthrateStepsForUpdate( + *animNode, + channel.updateTimeSpans, + channel.properties.speed, + channel.updateDuration); + if (!state.emitter.empty()) { + state.flags |= AnimationStateFlags::emitter; + } + } channel.stateByNodeNumber[modelNode.number()] = std::move(state); } @@ -481,6 +584,10 @@ void ModelSceneNode::applyAnimationStates(const ModelNode &modelNode) { combined.flags |= AnimationStateFlags::color; combined.color = state1.color; } + if (state1.flags & AnimationStateFlags::emitter) { + combined.flags |= AnimationStateFlags::emitter; + combined.emitter = state1.emitter; + } break; } case AnimationBlendMode::Overlay: @@ -506,6 +613,10 @@ void ModelSceneNode::applyAnimationStates(const ModelNode &modelNode) { combined.flags |= AnimationStateFlags::color; combined.color = state.color; } + if ((state.flags & AnimationStateFlags::emitter) && !(combined.flags & AnimationStateFlags::emitter)) { + combined.flags |= AnimationStateFlags::emitter; + combined.emitter = state.emitter; + } } break; default: @@ -524,6 +635,9 @@ void ModelSceneNode::applyAnimationStates(const ModelNode &modelNode) { if (combined.flags & AnimationStateFlags::color) { static_cast(sceneNode)->setColor(combined.color); } + if (combined.flags & AnimationStateFlags::emitter) { + static_cast(sceneNode)->applyAnimationState(combined.emitter); + } } for (auto &child : modelNode.children()) { @@ -551,6 +665,7 @@ void ModelSceneNode::setAnimationTime(float time) { } auto &channel = _animChannels.front(); channel.time = time; + channel.particleTime = time; bool looped = (channel.properties.flags & AnimationFlags::loop) != 0; bool frozen = channel.freeze; if (looped) { diff --git a/src/libs/scene/particleutil.cpp b/src/libs/scene/particleutil.cpp new file mode 100644 index 000000000..b9e896753 --- /dev/null +++ b/src/libs/scene/particleutil.cpp @@ -0,0 +1,256 @@ +/* + * Copyright (c) 2026 The reone project contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +#include "reone/scene/particleutil.h" + +#include +#include + +#include "reone/graphics/lumautil.h" + +namespace reone { + +namespace scene { + +namespace particleutil { + +static constexpr float kMinVectorLength = 1e-6f; +static constexpr float kMotionBlurTrailTime = 0.25f; + +MotionBlurBasis buildMotionBlurBasis( + const glm::mat4 &emitterTransform, + const glm::vec3 &localVelocity, + const glm::vec3 &toCamera, + const glm::vec3 &cameraRight, + const glm::vec3 &cameraUp, + float particleLength, + float blurLength) { + + glm::vec3 worldVelocity(emitterTransform * glm::vec4(localVelocity, 0.0f)); + float speed = glm::length(worldVelocity); + if (speed <= kMinVectorLength || particleLength <= kMinVectorLength) { + return {cameraRight, cameraUp, 1.0f}; + } + + glm::vec3 up(worldVelocity / speed); + glm::vec3 right(glm::cross(up, toCamera)); + float rightLength = glm::length(right); + if (rightLength <= kMinVectorLength) { + return {cameraRight, cameraUp, 1.0f}; + } + right /= rightLength; + + float trailLength = kMotionBlurTrailTime * speed * glm::max(blurLength, 0.0f); + return {right, up, 1.0f + trailLength / particleLength}; +} + +ParticleSpawnSchedule advanceSpawnSchedule( + float birthrate, + float dt, + float &accumulator) { + + ParticleSpawnSchedule schedule; + if (!std::isfinite(birthrate) || + !std::isfinite(dt) || + !std::isfinite(accumulator) || + birthrate <= 0.0f) { + accumulator = 0.0f; + return schedule; + } + if (dt <= 0.0f) { + return schedule; + } + if (dt > kMaxContinuousParticleDelta) { + accumulator = 0.0f; + return schedule; + } + + float previousAccumulator = glm::clamp(accumulator, 0.0f, 1.0f); + float accumulatedBirths = previousAccumulator + birthrate * dt; + if (!std::isfinite(accumulatedBirths)) { + accumulator = 0.0f; + return schedule; + } + + float wholeParticles = glm::floor(accumulatedBirths); + accumulator = accumulatedBirths - wholeParticles; + schedule.count = wholeParticles >= + static_cast(kMaxSpawnParticlesPerUpdate) + ? kMaxSpawnParticlesPerUpdate + : static_cast(wholeParticles); + for (int i = 0; i < schedule.count; ++i) { + float birthTime = + (1.0f - previousAccumulator + static_cast(i)) / + birthrate; + schedule.ages[i] = glm::clamp(dt - birthTime, 0.0f, dt); + } + return schedule; +} + +int advanceSpawnAccumulator(float birthrate, float dt, float &accumulator) { + return advanceSpawnSchedule(birthrate, dt, accumulator).count; +} + +AtlasFrameBounds atlasFrameBounds( + const glm::ivec2 &textureSize, + const glm::ivec2 &gridSize, + int frame) { + + glm::ivec2 safeTextureSize(glm::max(textureSize, glm::ivec2(1))); + glm::ivec2 safeGridSize(glm::clamp(gridSize, glm::ivec2(1), safeTextureSize)); + int frameCount = safeGridSize.x * safeGridSize.y; + int safeFrame = glm::clamp(frame, 0, frameCount - 1); + glm::ivec2 frameCoord( + safeFrame % safeGridSize.x, + safeFrame / safeGridSize.x); + + glm::vec2 cellMin = glm::vec2(frameCoord) / glm::vec2(safeGridSize); + glm::vec2 cellMax = glm::vec2(frameCoord + glm::ivec2(1)) / glm::vec2(safeGridSize); + glm::vec2 cellCenter = 0.5f * (cellMin + cellMax); + glm::vec2 halfTexel = 0.5f / glm::vec2(safeTextureSize); + + AtlasFrameBounds result; + result.minUV = glm::min(cellMin + halfTexel, cellCenter); + result.maxUV = glm::max(cellMax - halfTexel, cellCenter); + return result; +} + +glm::vec2 clampAtlasUV(const AtlasFrameBounds &bounds, const glm::vec2 &uv) { + return glm::clamp(uv, bounds.minUV, bounds.maxUV); +} + +std::array cubicReconstructionWeights(float fraction) { + float t = std::isfinite(fraction) ? glm::clamp(fraction, 0.0f, 1.0f) : 0.0f; + float t2 = t * t; + float t3 = t2 * t; + return { + -0.5f * t + t2 - 0.5f * t3, + 1.0f - 2.5f * t2 + 1.5f * t3, + 0.5f * t + 2.0f * t2 - 1.5f * t3, + -0.5f * t2 + 0.5f * t3}; +} + +DecodedParticleSample decodeParticleSample( + const glm::vec4 &sample, + ParticleAlphaMode alphaMode, + bool lightenBlend, + float alphaExponent) { + + DecodedParticleSample result; + result.color = glm::vec3(sample); + float storedAlpha = glm::clamp(sample.a, 0.0f, 1.0f); + float luminance = glm::clamp(graphics::rgbToLuma(result.color), 0.0f, 1.0f); + + if (!lightenBlend) { + result.alpha = storedAlpha; + return result; + } + + switch (alphaMode) { + case ParticleAlphaMode::Texture: + result.alpha = storedAlpha; + break; + case ParticleAlphaMode::Luminance: + result.alpha = luminance; + result.color *= 1.0f / glm::max(0.0001f, luminance); + break; + case ParticleAlphaMode::AlphaAndLuminance: + result.alpha = glm::min(storedAlpha, luminance); + if (luminance <= storedAlpha) { + result.color *= 1.0f / glm::max(0.0001f, luminance); + } + break; + case ParticleAlphaMode::Legacy: + default: + result.alpha = luminance; + result.color *= 1.0f / glm::max(0.0001f, luminance); + break; + } + + float exponent = std::isfinite(alphaExponent) && alphaExponent > 0.0f + ? alphaExponent + : 1.0f; + result.alpha = std::pow(glm::clamp(result.alpha, 0.0f, 1.0f), exponent); + return result; +} + +float enhanceParticleCoverage(float alpha, float contrast) { + float safeAlpha = std::isfinite(alpha) + ? glm::clamp(alpha, 0.0f, 1.0f) + : 0.0f; + float safeContrast = std::isfinite(contrast) + ? glm::clamp(contrast, 0.0f, 1.0f) + : 0.0f; + float detail = glm::max( + safeAlpha, + glm::smoothstep(0.02f, 0.28f, safeAlpha)); + return glm::mix(safeAlpha, detail, safeContrast); +} + +float analyticParticleCoreEnvelope( + const glm::vec2 &localUV, + bool motionBlur, + float intensity) { + + if (!std::isfinite(localUV.x) || + !std::isfinite(localUV.y) || + !std::isfinite(intensity) || + intensity <= 0.0f) { + return 0.0f; + } + + glm::vec2 centered = 2.0f * localUV - 1.0f; + float safeIntensity = glm::clamp(intensity, 0.0f, 1.0f); + if (!motionBlur) { + float radialCore = + 1.0f - glm::smoothstep(0.0f, 0.65f, glm::length(centered)); + return safeIntensity * radialCore * radialCore; + } + + glm::vec2 inside = glm::max(glm::vec2(1.0f) - glm::abs(centered), glm::vec2(0.0f)); + float crossSection = inside.x * inside.x; + crossSection *= crossSection; + float endTaper = inside.y * inside.y; + return safeIntensity * crossSection * endTaper; +} + +float clampMotionTrailLength( + float authoredLength, + float basisScale, + float profileScale, + float maximumLength) { + + if (!std::isfinite(authoredLength) || authoredLength <= 0.0f) { + return 0.0f; + } + + float safeBasisScale = std::isfinite(basisScale) ? glm::max(basisScale, 0.0f) : 1.0f; + float safeProfileScale = std::isfinite(profileScale) ? glm::max(profileScale, 0.0f) : 1.0f; + float safeMaximum = std::isfinite(maximumLength) + ? glm::max(maximumLength, 0.0f) + : std::numeric_limits::max(); + double scaled = static_cast(authoredLength) * + static_cast(safeBasisScale) * + static_cast(safeProfileScale); + return static_cast(glm::min(scaled, static_cast(safeMaximum))); +} + +} // namespace particleutil + +} // namespace scene + +} // namespace reone diff --git a/src/libs/scene/render/pass/pbr.cpp b/src/libs/scene/render/pass/pbr.cpp index 49c0f2fd7..ca6f4cd71 100644 --- a/src/libs/scene/render/pass/pbr.cpp +++ b/src/libs/scene/render/pass/pbr.cpp @@ -230,6 +230,8 @@ void PBRRenderPass::drawBillboard(Texture &texture, void PBRRenderPass::drawParticles(Texture &texture, FaceCullMode faceCulling, bool premultipliedAlpha, + bool motionBlur, + const ParticleRenderPolicy &policy, const glm::ivec2 &gridSize, const std::vector &particles) { _context.useProgram(_shaderRegistry.get(ShaderProgramId::oitParticles)); @@ -240,16 +242,8 @@ void PBRRenderPass::drawParticles(Texture &texture, locals.featureMask |= UniformsFeatureFlags::premulalpha; } }); - _uniforms.setParticles([&gridSize, &premultipliedAlpha, &particles](auto &p) { - p.gridSize = gridSize; - for (size_t i = 0; i < particles.size(); ++i) { - const auto &particle = particles[i]; - p.particles[i].positionFrame = glm::vec4(particle.position, static_cast(particle.frame)); - p.particles[i].size = particle.size; - p.particles[i].color = particle.color; - p.particles[i].right = glm::vec4(particle.right, 0.0f); - p.particles[i].up = glm::vec4(particle.up, 0.0f); - } + _uniforms.setParticles([&gridSize, &motionBlur, &policy, &particles](auto &p) { + populateParticleUniforms(p, policy, motionBlur, gridSize, particles); }); auto prevFaceCulling = _context.faceCullMode(); if (faceCulling != prevFaceCulling) { diff --git a/src/libs/scene/render/pass/retro.cpp b/src/libs/scene/render/pass/retro.cpp index 9ae872b01..f6693bfbc 100644 --- a/src/libs/scene/render/pass/retro.cpp +++ b/src/libs/scene/render/pass/retro.cpp @@ -220,6 +220,8 @@ void RetroRenderPass::drawBillboard(Texture &texture, void RetroRenderPass::drawParticles(Texture &texture, FaceCullMode faceCulling, bool premultipliedAlpha, + bool motionBlur, + const ParticleRenderPolicy &policy, const glm::ivec2 &gridSize, const std::vector &particles) { _context.useProgram(_shaderRegistry.get(ShaderProgramId::oitParticles)); @@ -230,16 +232,8 @@ void RetroRenderPass::drawParticles(Texture &texture, locals.featureMask |= UniformsFeatureFlags::premulalpha; } }); - _uniforms.setParticles([&gridSize, &premultipliedAlpha, &particles](auto &p) { - p.gridSize = gridSize; - for (size_t i = 0; i < particles.size(); ++i) { - const auto &particle = particles[i]; - p.particles[i].positionFrame = glm::vec4(particle.position, static_cast(particle.frame)); - p.particles[i].size = particle.size; - p.particles[i].color = particle.color; - p.particles[i].right = glm::vec4(particle.right, 0.0f); - p.particles[i].up = glm::vec4(particle.up, 0.0f); - } + _uniforms.setParticles([&gridSize, &motionBlur, &policy, &particles](auto &p) { + populateParticleUniforms(p, policy, motionBlur, gridSize, particles); }); auto prevFaceCulling = _context.faceCullMode(); if (faceCulling != prevFaceCulling) { diff --git a/test/CMakeLists.txt b/test/CMakeLists.txt index 7042a9518..ef4b87c70 100644 --- a/test/CMakeLists.txt +++ b/test/CMakeLists.txt @@ -48,6 +48,7 @@ set(TESTS_SOURCES ${TESTS_SOURCE_DIR}/game/d20/class.cpp ${TESTS_SOURCE_DIR}/game/d20/spells.cpp ${TESTS_SOURCE_DIR}/game/conversation.cpp + ${TESTS_SOURCE_DIR}/game/effect/visual.cpp ${TESTS_SOURCE_DIR}/game/journal.cpp ${TESTS_SOURCE_DIR}/game/messagebus.cpp ${TESTS_SOURCE_DIR}/game/object.cpp @@ -86,7 +87,9 @@ set(TESTS_SOURCES ${TESTS_SOURCE_DIR}/resource/resources.cpp ${TESTS_SOURCE_DIR}/resource/resref.cpp ${TESTS_SOURCE_DIR}/resource/strings.cpp - ${TESTS_SOURCE_DIR}/scene/model.cpp + ${TESTS_SOURCE_DIR}/scene/model.cpp + ${TESTS_SOURCE_DIR}/scene/particleprofile.cpp + ${TESTS_SOURCE_DIR}/scene/particleutil.cpp ${TESTS_SOURCE_DIR}/script/format/ncsreader.cpp ${TESTS_SOURCE_DIR}/script/format/ncswriter.cpp ${TESTS_SOURCE_DIR}/script/virtualmachine.cpp diff --git a/test/game/effect/visual.cpp b/test/game/effect/visual.cpp new file mode 100644 index 000000000..d630a72b8 --- /dev/null +++ b/test/game/effect/visual.cpp @@ -0,0 +1,208 @@ +/* + * Copyright (c) 2026 The reone project contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +#include + +#include "reone/game/effect/visual.h" +#include "reone/game/visualeffects.h" +#include "reone/graphics/model.h" +#include "reone/resource/types.h" +#include "reone/scene/node/emitter.h" + +using namespace reone; +using namespace reone::game; +using namespace reone::graphics; +using namespace reone::resource; +using namespace reone::scene; + +namespace { + +struct GrenadeVisual { + uint32_t id; + const char *label; + const char *impactModel; +}; + +constexpr GrenadeVisual kKotorGrenadeVisuals[] { + {VisualEffectIds::grenadeFragmentation, "VFX_FNF_GRENADE_FRAGMENTATION", "v_grnfrag_fnf"}, + {VisualEffectIds::grenadeStun, "VFX_FNF_GRENADE_STUN", "v_grnstun_fnf"}, + {VisualEffectIds::thermalDetonator, "VFX_FNF_GRENADE_THERMAL_DETONATOR", "v_grndeto_fnf"}, + {VisualEffectIds::grenadePoison, "VFX_FNF_GRENADE_POISON", "v_grnpois_fnf"}, + {VisualEffectIds::grenadeSonic, "VFX_FNF_GRENADE_SONIC", "v_grnsonc_fnf"}, + {VisualEffectIds::grenadeAdhesive, "VFX_FNF_GRENADE_ADHESIVE", "v_grnadhs_fnf"}, + {VisualEffectIds::grenadeCryoban, "VFX_FNF_GRENADE_CRYOBAN", "v_grncryo_fnf"}, + {VisualEffectIds::grenadePlasma, "VFX_FNF_GRENADE_PLASMA", "v_grnplas_fnf"}, + {VisualEffectIds::grenadeIon, "VFX_FNF_GRENADE_ION", "v_grnion_fnf"}, +}; + +VisualEffectDesc visualEffectDesc(const char *label, const char *impactModel) { + VisualEffectDesc desc; + desc.label = label; + desc.impRootMNode = std::make_shared( + impactModel, + 0, + nullptr, + std::vector>(), + "", + 1.0f); + return desc; +} + +ParticleRenderProfile selectedProfile( + GameID gameId, + const GrenadeVisual &visual) { + + ParticleRenderProfile profile; + EXPECT_TRUE(particleRenderProfileForVisualEffect( + gameId, + visual.id, + visualEffectDesc(visual.label, visual.impactModel), + profile)); + return profile; +} + +void expectUnselected( + GameID gameId, + uint32_t visualEffectId, + VisualEffectDesc desc) { + + ParticleRenderProfile profile; + profile.opacity = 0.123f; + EXPECT_FALSE(particleRenderProfileForVisualEffect( + gameId, + visualEffectId, + desc, + profile)); + EXPECT_FLOAT_EQ(0.123f, profile.opacity); +} + +} // namespace + +TEST(VisualEffectParticleProfile, should_select_all_nine_kotor_grenade_profiles) { + for (const auto &entry : kKotorGrenadeVisuals) { + SCOPED_TRACE(entry.label); + auto profile = selectedProfile(GameID::KotOR, entry); + + EXPECT_EQ(ParticleReconstruction::Cubic, profile.policy.reconstruction); + EXPECT_EQ(ParticleAlphaMode::AlphaAndLuminance, profile.policy.alpha); + EXPECT_EQ(ParticleTrailMode::AnalyticCore, profile.policy.trail); + EXPECT_EQ(ParticleDiagnosticMode::Composite, profile.policy.diagnostic); + EXPECT_GT(profile.policy.reconstructionStrength, 0.0f); + EXPECT_LE(profile.policy.reconstructionStrength, 1.0f); + EXPECT_GE(profile.policy.alphaExponent, 1.15f); + EXPECT_LE(profile.policy.alphaExponent, 2.0f); + EXPECT_GT(profile.policy.trailCoreIntensity, 0.0f); + EXPECT_LE(profile.policy.trailCoreIntensity, 0.4f); + EXPECT_GE(profile.policy.coverageContrast, 0.25f); + EXPECT_LE(profile.policy.coverageContrast, 0.9f); + EXPECT_GT(profile.motionMaxWidth, 0.0f); + EXPECT_LE(profile.motionMaxWidth, 0.22f); + EXPECT_GT(profile.motionMaxLength, 0.0f); + EXPECT_LE(profile.motionMaxLength, 1.5f); + + EXPECT_GE(profile.opacity, 0.3f); + EXPECT_LE(profile.opacity, 0.85f); + EXPECT_GE(profile.worldZOpacity, profile.opacity); + EXPECT_LE(profile.worldZOpacity, 0.9f); + EXPECT_GE(profile.motionOpacity, 0.2f); + EXPECT_LE(profile.motionOpacity, 0.6f); + EXPECT_GT(profile.colorTint.r, 0.0f); + EXPECT_GT(profile.colorTint.g, 0.0f); + EXPECT_GT(profile.colorTint.b, 0.0f); + EXPECT_LE(profile.colorTint.r, 1.0f); + EXPECT_LE(profile.colorTint.g, 1.0f); + EXPECT_LE(profile.colorTint.b, 1.0f); + EXPECT_GE(profile.colorIntensity, 1.0f); + EXPECT_LE(profile.colorIntensity, 1.05f); + } +} + +TEST(VisualEffectParticleProfile, should_keep_broad_area_grenades_localized) { + constexpr size_t broadAreaIndices[] = {0, 4, 5, 6}; + + for (size_t index : broadAreaIndices) { + const auto &entry = kKotorGrenadeVisuals[index]; + SCOPED_TRACE(entry.label); + auto profile = selectedProfile(GameID::KotOR, entry); + + EXPECT_LE(profile.largeParticleScale, 0.60f); + EXPECT_LE(profile.worldZScale, 0.60f); + EXPECT_LE(profile.opacity, 0.55f); + EXPECT_LE(profile.worldZOpacity, 0.60f); + EXPECT_GE(profile.policy.alphaExponent, 1.50f); + EXPECT_LE(profile.policy.coverageContrast, 0.50f); + } +} + +TEST(VisualEffectParticleProfile, should_keep_ion_plasma_and_thermal_trails_compact) { + constexpr GrenadeVisual criticalVisuals[] { + {VisualEffectIds::thermalDetonator, "VFX_FNF_GRENADE_THERMAL_DETONATOR", "v_grndeto_fnf"}, + {VisualEffectIds::grenadePlasma, "VFX_FNF_GRENADE_PLASMA", "v_grnplas_fnf"}, + {VisualEffectIds::grenadeIon, "VFX_FNF_GRENADE_ION", "v_grnion_fnf"}, + }; + + for (const auto &entry : criticalVisuals) { + SCOPED_TRACE(entry.label); + auto profile = selectedProfile(GameID::KotOR, entry); + + EXPECT_LE(profile.motionLengthScale, 0.5f); + EXPECT_LE(profile.motionMaxWidth, 0.12f); + EXPECT_LE(profile.motionMaxLength, 0.8f); + } +} + +TEST(VisualEffectParticleProfile, should_give_critical_grenades_distinct_energy_tints) { + auto thermal = selectedProfile(GameID::KotOR, kKotorGrenadeVisuals[2]); + auto plasma = selectedProfile(GameID::KotOR, kKotorGrenadeVisuals[7]); + auto ion = selectedProfile(GameID::KotOR, kKotorGrenadeVisuals[8]); + + EXPECT_GT(thermal.colorTint.r, thermal.colorTint.b); + EXPECT_GT(plasma.colorTint.r, plasma.colorTint.g); + EXPECT_GT(ion.colorTint.b, ion.colorTint.r); + EXPECT_GT(ion.policy.trailCoreIntensity, thermal.policy.trailCoreIntensity); +} + +TEST(VisualEffectParticleProfile, should_not_select_unknown_labels) { + expectUnselected( + GameID::KotOR, + VisualEffectIds::grenadeFragmentation, + visualEffectDesc("VFX_FNF_NOT_A_GRENADE", "v_grnfrag_fnf")); +} + +TEST(VisualEffectParticleProfile, should_not_select_unknown_ids) { + expectUnselected( + GameID::KotOR, + 42, + visualEffectDesc("VFX_FNF_GRENADE_FRAGMENTATION", "v_grnfrag_fnf")); +} + +TEST(VisualEffectParticleProfile, should_not_select_tsl_grenade_labels) { + for (const auto &entry : kKotorGrenadeVisuals) { + SCOPED_TRACE(entry.label); + expectUnselected( + GameID::TSL, + entry.id, + visualEffectDesc(entry.label, entry.impactModel)); + } +} + +TEST(VisualEffectParticleProfile, should_not_select_mismatched_kotor_models) { + expectUnselected( + GameID::KotOR, + VisualEffectIds::grenadeIon, + visualEffectDesc("VFX_FNF_GRENADE_ION", "v_grnplas_fnf")); +} diff --git a/test/scene/particleprofile.cpp b/test/scene/particleprofile.cpp new file mode 100644 index 000000000..09a215ed8 --- /dev/null +++ b/test/scene/particleprofile.cpp @@ -0,0 +1,248 @@ +/* + * Copyright (c) 2026 The reone project contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +#include + +#include + +#include "reone/graphics/model.h" +#include "reone/graphics/modelnode.h" +#include "reone/graphics/options.h" +#include "reone/graphics/uniforms.h" +#include "reone/scene/graph.h" +#include "reone/scene/node/emitter.h" +#include "reone/scene/node/model.h" +#include "reone/scene/render/pass.h" + +#include "../fixtures/audio.h" +#include "../fixtures/graphics.h" +#include "../fixtures/resource.h" +#include "../fixtures/scene.h" + +using namespace reone; +using namespace reone::audio; +using namespace reone::graphics; +using namespace reone::resource; +using namespace reone::scene; + +namespace { + +class ParticleProfileHarness { +public: + ParticleProfileHarness() { + _graphicsModule.init(); + _audioModule.init(); + _resourceModule.init(); + _scene = std::make_unique( + "particle-profile-test", + _pipelineFactory, + _graphicsOptions, + _graphicsModule.services(), + _audioModule.services(), + _resourceModule.services()); + } + + std::shared_ptr newModel(const std::string &name) { + auto rootNode = std::make_shared( + 0, + "root", + glm::vec3(0.0f), + glm::quat(1.0f, 0.0f, 0.0f, 0.0f), + true); + auto hookNode = std::make_shared( + 1, + "hook", + glm::vec3(0.0f), + glm::quat(1.0f, 0.0f, 0.0f, 0.0f), + true, + rootNode.get()); + auto emitterNode = std::make_shared( + 2, + "emitter", + glm::vec3(0.0f), + glm::quat(1.0f, 0.0f, 0.0f, 0.0f), + true, + rootNode.get()); + emitterNode->setEmitter(std::make_shared()); + rootNode->addChild(hookNode); + rootNode->addChild(emitterNode); + + auto model = std::make_unique( + name, + 0, + rootNode, + std::vector>(), + "", + 1.0f); + auto modelNode = _scene->newModel(*model, ModelUsage::Projectile); + _models.push_back(std::move(model)); + return modelNode; + } + + static const ParticleRenderProfile &profile(ModelSceneNode &model) { + return static_cast(model.getNodeByName("emitter"))->renderProfile(); + } + +private: + GraphicsOptions _graphicsOptions; + MockRenderPipelineFactory _pipelineFactory; + TestGraphicsModule _graphicsModule; + TestAudioModule _audioModule; + TestResourceModule _resourceModule; + std::unique_ptr _scene; + std::vector> _models; +}; + +void expectPolicyEqual(const ParticleRenderPolicy &expected, const ParticleUniforms &actual) { + EXPECT_EQ(static_cast(expected.reconstruction), actual.reconstructionMode); + EXPECT_EQ(static_cast(expected.alpha), actual.alphaMode); + EXPECT_EQ(static_cast(expected.trail), actual.trailMode); + EXPECT_EQ(static_cast(expected.diagnostic), actual.diagnosticMode); + EXPECT_FLOAT_EQ(expected.reconstructionStrength, actual.reconstructionStrength); + EXPECT_FLOAT_EQ(expected.alphaExponent, actual.alphaExponent); + EXPECT_FLOAT_EQ(expected.trailCoreIntensity, actual.trailCoreIntensity); + EXPECT_FLOAT_EQ(expected.coverageContrast, actual.coverageContrast); +} + +} // namespace + +TEST(ParticleProfile, should_default_to_legacy_single_sample_and_lighten_policy) { + ParticleRenderProfile profile; + ParticleUniforms uniforms; + uniforms.reconstructionMode = 42; + uniforms.alphaMode = 42; + uniforms.trailMode = 42; + uniforms.diagnosticMode = 42; + uniforms.reconstructionStrength = 42.0f; + uniforms.alphaExponent = 42.0f; + uniforms.trailCoreIntensity = 42.0f; + uniforms.coverageContrast = 42.0f; + + populateParticleUniforms( + uniforms, + profile.policy, + false, + glm::ivec2(4, 4), + {}); + + EXPECT_EQ(ParticleReconstruction::Legacy, profile.policy.reconstruction); + EXPECT_EQ(ParticleAlphaMode::Legacy, profile.policy.alpha); + EXPECT_EQ(ParticleTrailMode::Legacy, profile.policy.trail); + EXPECT_FLOAT_EQ(1.0f, profile.largeParticleScale); + EXPECT_FLOAT_EQ(1.0f, profile.opacity); + EXPECT_EQ(glm::vec3(1.0f), profile.colorTint); + expectPolicyEqual(profile.policy, uniforms); + EXPECT_EQ(0, uniforms.motionBlur); +} + +TEST(ParticleProfile, should_keep_the_particle_uniform_header_std140_compatible) { + EXPECT_EQ(32u, offsetof(ParticleUniforms, trailCoreIntensity)); + EXPECT_EQ(36u, offsetof(ParticleUniforms, coverageContrast)); + EXPECT_EQ(40u, offsetof(ParticleUniforms, diagnosticMode)); + EXPECT_EQ(0u, offsetof(ParticleUniforms, particles) % 16u); +} + +TEST(ParticleProfile, should_pack_the_same_non_default_policy_for_retro_and_pbr) { + ParticleRenderPolicy policy; + policy.reconstruction = ParticleReconstruction::Cubic; + policy.alpha = ParticleAlphaMode::AlphaAndLuminance; + policy.trail = ParticleTrailMode::AnalyticCore; + policy.diagnostic = ParticleDiagnosticMode::AlphaOnly; + policy.reconstructionStrength = 0.75f; + policy.alphaExponent = 1.25f; + policy.trailCoreIntensity = 0.08f; + policy.coverageContrast = 0.75f; + ParticleUniforms retroUniforms; + ParticleUniforms pbrUniforms; + + populateParticleUniforms( + retroUniforms, + policy, + true, + glm::ivec2(8, 2), + {}); + populateParticleUniforms( + pbrUniforms, + policy, + true, + glm::ivec2(8, 2), + {}); + + expectPolicyEqual(policy, retroUniforms); + expectPolicyEqual(policy, pbrUniforms); + EXPECT_EQ(retroUniforms.gridSize, pbrUniforms.gridSize); + EXPECT_EQ(retroUniforms.motionBlur, pbrUniforms.motionBlur); +} + +TEST(ParticleProfile, should_reset_policy_values_for_the_next_emitter_draw) { + ParticleRenderPolicy enhanced; + enhanced.reconstruction = ParticleReconstruction::Cubic; + enhanced.alpha = ParticleAlphaMode::AlphaAndLuminance; + enhanced.trail = ParticleTrailMode::AnalyticCore; + enhanced.diagnostic = ParticleDiagnosticMode::TextureOnly; + enhanced.reconstructionStrength = 0.75f; + enhanced.alphaExponent = 1.25f; + enhanced.trailCoreIntensity = 0.08f; + enhanced.coverageContrast = 0.75f; + ParticleUniforms uniforms; + populateParticleUniforms(uniforms, enhanced, true, glm::ivec2(8, 2), {}); + + ParticleRenderPolicy defaults; + populateParticleUniforms(uniforms, defaults, false, glm::ivec2(1, 1), {}); + + expectPolicyEqual(defaults, uniforms); + EXPECT_EQ(0, uniforms.motionBlur); + EXPECT_EQ(glm::ivec2(1, 1), uniforms.gridSize); +} + +TEST(ParticleProfile, should_propagate_to_existing_nested_model_attachments) { + ParticleProfileHarness harness; + auto parent = harness.newModel("parent"); + auto child = harness.newModel("child"); + auto grandchild = harness.newModel("grandchild"); + child->attach("hook", *grandchild); + parent->attach("hook", *child); + + ParticleRenderProfile profile; + profile.opacity = 0.4f; + profile.policy.reconstruction = ParticleReconstruction::Cubic; + parent->setParticleRenderProfile(profile); + + EXPECT_FLOAT_EQ(0.4f, ParticleProfileHarness::profile(*parent).opacity); + EXPECT_FLOAT_EQ(0.4f, ParticleProfileHarness::profile(*child).opacity); + EXPECT_FLOAT_EQ(0.4f, ParticleProfileHarness::profile(*grandchild).opacity); + EXPECT_EQ( + ParticleReconstruction::Cubic, + ParticleProfileHarness::profile(*grandchild).policy.reconstruction); +} + +TEST(ParticleProfile, should_propagate_to_model_attachments_added_later) { + ParticleProfileHarness harness; + auto parent = harness.newModel("parent"); + ParticleRenderProfile profile; + profile.motionOpacity = 0.6f; + profile.policy.trail = ParticleTrailMode::AnalyticCore; + parent->setParticleRenderProfile(profile); + + auto child = harness.newModel("child"); + parent->attach("hook", *child); + + EXPECT_FLOAT_EQ(0.6f, ParticleProfileHarness::profile(*child).motionOpacity); + EXPECT_EQ( + ParticleTrailMode::AnalyticCore, + ParticleProfileHarness::profile(*child).policy.trail); +} diff --git a/test/scene/particleutil.cpp b/test/scene/particleutil.cpp new file mode 100644 index 000000000..b4833ca92 --- /dev/null +++ b/test/scene/particleutil.cpp @@ -0,0 +1,752 @@ +/* + * Copyright (c) 2026 The reone project contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +#include + +#include "reone/graphics/animation.h" +#include "reone/graphics/model.h" +#include "reone/graphics/modelnode.h" +#include "reone/graphics/options.h" +#include "reone/scene/graph.h" +#include "reone/scene/node/emitter.h" +#include "reone/scene/node/model.h" +#include "reone/scene/node/particle.h" +#include "reone/scene/particleutil.h" + +#include "../fixtures/audio.h" +#include "../fixtures/graphics.h" +#include "../fixtures/resource.h" +#include "../fixtures/scene.h" + +using namespace reone; +using namespace reone::audio; +using namespace reone::graphics; +using namespace reone::resource; +using namespace reone::scene; + +namespace { + +class AnimatedEmitterHarness { +public: + AnimatedEmitterHarness( + float initialBirthrate, + float lifeExpectancy, + std::vector> animatedBirthrate = {}, + std::vector animationEvents = {}, + float animationSpeed = 1.0f, + ModelNode::Emitter::UpdateMode updateMode = + ModelNode::Emitter::UpdateMode::Fountain, + bool loop = false) { + + _graphicsModule.init(); + _audioModule.init(); + _resourceModule.init(); + _scene = std::make_unique( + "particle-test", + _pipelineFactory, + _graphicsOptions, + _graphicsModule.services(), + _audioModule.services(), + _resourceModule.services()); + + auto rootNode = std::make_shared( + 0, + "root", + glm::vec3(0.0f), + glm::quat(1.0f, 0.0f, 0.0f, 0.0f), + true); + auto emitterNode = std::make_shared( + 1, + "emitter", + glm::vec3(0.0f), + glm::quat(1.0f, 0.0f, 0.0f, 0.0f), + true, + rootNode.get()); + auto emitter = std::make_shared(); + emitter->updateMode = updateMode; + emitter->loop = loop; + emitterNode->setEmitter(emitter); + emitterNode->floatTracks()[ControllerTypes::birthrate].add(0.0f, initialBirthrate); + emitterNode->floatTracks()[ControllerTypes::lifeExp].add(0.0f, lifeExpectancy); + emitterNode->floatTracks()[ControllerTypes::sizeStart].add(0.0f, 1.0f); + emitterNode->floatTracks()[ControllerTypes::sizeMid].add(0.0f, 1.0f); + emitterNode->floatTracks()[ControllerTypes::sizeEnd].add(0.0f, 1.0f); + emitterNode->floatTracks()[ControllerTypes::alphaStart].add(0.0f, 1.0f); + emitterNode->floatTracks()[ControllerTypes::alphaMid].add(0.0f, 1.0f); + emitterNode->floatTracks()[ControllerTypes::alphaEnd].add(0.0f, 1.0f); + emitterNode->vectorTracks()[ControllerTypes::colorStart].add(0.0f, glm::vec3(1.0f)); + emitterNode->vectorTracks()[ControllerTypes::colorMid].add(0.0f, glm::vec3(1.0f)); + emitterNode->vectorTracks()[ControllerTypes::colorEnd].add(0.0f, glm::vec3(1.0f)); + rootNode->addChild(emitterNode); + + std::vector> animations; + if (!animatedBirthrate.empty() || !animationEvents.empty()) { + auto animationRoot = std::make_shared( + 0, + "root", + glm::vec3(0.0f), + glm::quat(1.0f, 0.0f, 0.0f, 0.0f), + false); + auto animationEmitter = std::make_shared( + 1, + "emitter", + glm::vec3(0.0f), + glm::quat(1.0f, 0.0f, 0.0f, 0.0f), + false, + animationRoot.get()); + for (const auto &[time, birthrate] : animatedBirthrate) { + animationEmitter->floatTracks()[ControllerTypes::birthrate].add(time, birthrate); + } + animationRoot->addChild(animationEmitter); + animations.push_back(std::make_shared( + "pulse", + 1.0f, + 0.0f, + "root", + animationRoot, + std::move(animationEvents))); + } + + _model = std::make_unique( + "particle-test", + 0, + rootNode, + animations, + "", + 1.0f); + _modelSceneNode = std::make_shared( + *_model, + ModelUsage::Projectile, + *_scene, + _graphicsModule.services(), + _audioModule.services(), + _resourceModule.services()); + _modelSceneNode->init(); + if (!animations.empty()) { + auto properties = + AnimationProperties::fromFlags(AnimationFlags::loop); + properties.speed = animationSpeed; + _modelSceneNode->playAnimation( + "pulse", + nullptr, + properties); + } + } + + ModelSceneNode &model() { + return *_modelSceneNode; + } + + EmitterSceneNode &emitter() { + return *static_cast(_modelSceneNode->getNodeByName("emitter")); + } + +private: + GraphicsOptions _graphicsOptions; + MockRenderPipelineFactory _pipelineFactory; + TestGraphicsModule _graphicsModule; + TestAudioModule _audioModule; + TestResourceModule _resourceModule; + std::unique_ptr _scene; + std::unique_ptr _model; + std::shared_ptr _modelSceneNode; +}; + +} // namespace + +TEST(ParticleUtil, should_transform_motion_blur_velocity_to_world_space) { + // The tar_m05aa waterfall emitters rotate local +Z to world -Z. + auto emitterTransform = glm::rotate(glm::pi(), glm::vec3(0.0f, 1.0f, 0.0f)); + + auto basis = particleutil::buildMotionBlurBasis( + emitterTransform, + glm::vec3(0.0f, 0.0f, 10.0f), + glm::vec3(0.0f, 1.0f, 0.0f), + glm::vec3(1.0f, 0.0f, 0.0f), + glm::vec3(0.0f, 0.0f, 1.0f), + 1.0f, + 1.0f); + + EXPECT_NEAR(1.0f, basis.right.x, 1e-5f); + EXPECT_NEAR(0.0f, basis.right.y, 1e-5f); + EXPECT_NEAR(0.0f, basis.right.z, 1e-5f); + EXPECT_NEAR(0.0f, basis.up.x, 1e-5f); + EXPECT_NEAR(0.0f, basis.up.y, 1e-5f); + EXPECT_NEAR(-1.0f, basis.up.z, 1e-5f); + EXPECT_NEAR(3.5f, basis.lengthScale, 1e-5f); +} + +TEST(ParticleUtil, should_add_motion_trail_length_in_world_units) { + auto ion = particleutil::buildMotionBlurBasis( + glm::mat4(1.0f), + glm::vec3(0.0f, 0.0f, 15.0f), + glm::vec3(0.0f, 1.0f, 0.0f), + glm::vec3(1.0f, 0.0f, 0.0f), + glm::vec3(0.0f, 1.0f, 0.0f), + 3.0f, + 1.0f); + auto thermal = particleutil::buildMotionBlurBasis( + glm::mat4(1.0f), + glm::vec3(0.0f, 0.0f, 20.0f), + glm::vec3(0.0f, 1.0f, 0.0f), + glm::vec3(1.0f, 0.0f, 0.0f), + glm::vec3(0.0f, 1.0f, 0.0f), + 4.0f, + 1.0f); + + EXPECT_NEAR(6.75f, 3.0f * ion.lengthScale, 1e-5f); + EXPECT_NEAR(9.0f, 4.0f * thermal.lengthScale, 1e-5f); +} + +TEST(ParticleUtil, should_fall_back_to_camera_axes_without_motion) { + auto basis = particleutil::buildMotionBlurBasis( + glm::mat4(1.0f), + glm::vec3(0.0f), + glm::vec3(0.0f, 0.0f, 1.0f), + glm::vec3(1.0f, 0.0f, 0.0f), + glm::vec3(0.0f, 1.0f, 0.0f), + 3.0f, + 1.0f); + + EXPECT_EQ(glm::vec3(1.0f, 0.0f, 0.0f), basis.right); + EXPECT_EQ(glm::vec3(0.0f, 1.0f, 0.0f), basis.up); + EXPECT_EQ(1.0f, basis.lengthScale); +} + +TEST(ParticleUtil, should_fall_back_to_camera_axes_for_camera_depth_motion) { + auto basis = particleutil::buildMotionBlurBasis( + glm::mat4(1.0f), + glm::vec3(0.0f, 0.0f, 10.0f), + glm::vec3(0.0f, 0.0f, 1.0f), + glm::vec3(1.0f, 0.0f, 0.0f), + glm::vec3(0.0f, 1.0f, 0.0f), + 1.0f, + 10.0f); + + EXPECT_EQ(glm::vec3(1.0f, 0.0f, 0.0f), basis.right); + EXPECT_EQ(glm::vec3(0.0f, 1.0f, 0.0f), basis.up); + EXPECT_EQ(1.0f, basis.lengthScale); +} + +TEST(ParticleUtil, should_fall_back_to_camera_axes_for_degenerate_particle_length) { + auto basis = particleutil::buildMotionBlurBasis( + glm::mat4(1.0f), + glm::vec3(0.0f, 0.0f, 10.0f), + glm::vec3(0.0f, 1.0f, 0.0f), + glm::vec3(1.0f, 0.0f, 0.0f), + glm::vec3(0.0f, 1.0f, 0.0f), + 0.0f, + 1.0f); + + EXPECT_EQ(glm::vec3(1.0f, 0.0f, 0.0f), basis.right); + EXPECT_EQ(glm::vec3(0.0f, 1.0f, 0.0f), basis.up); + EXPECT_EQ(1.0f, basis.lengthScale); +} + +TEST(ParticleUtil, should_spawn_at_the_same_rate_across_frame_rates) { + float accumulator60Hz = 0.0f; + int particles60Hz = 0; + for (int i = 0; i < 60; ++i) { + particles60Hz += particleutil::advanceSpawnAccumulator(400.0f, 1.0f / 60.0f, accumulator60Hz); + } + + float accumulator144Hz = 0.0f; + int particles144Hz = 0; + for (int i = 0; i < 144; ++i) { + particles144Hz += particleutil::advanceSpawnAccumulator(400.0f, 1.0f / 144.0f, accumulator144Hz); + } + + EXPECT_EQ(400, particles60Hz); + EXPECT_EQ(400, particles144Hz); + EXPECT_NEAR(accumulator60Hz, accumulator144Hz, 1e-4f); +} + +TEST(ParticleUtil, should_age_new_particles_from_their_birth_time) { + float accumulator = 0.0f; + + auto schedule = particleutil::advanceSpawnSchedule( + 20.0f, + 0.1f, + accumulator); + + ASSERT_EQ(2, schedule.count); + EXPECT_NEAR(0.05f, schedule.ages[0], 1e-6f); + EXPECT_NEAR(0.0f, schedule.ages[1], 1e-6f); +} + +TEST(ParticleUtil, should_clear_spawn_remainder_when_emitter_stops) { + float accumulator = 0.75f; + + EXPECT_EQ(0, particleutil::advanceSpawnAccumulator(0.0f, 1.0f / 60.0f, accumulator)); + EXPECT_EQ(0.0f, accumulator); +} + +TEST(ParticleUtil, should_drop_spawn_catch_up_after_a_discontinuous_frame) { + float accumulator = 0.75f; + + EXPECT_EQ(0, particleutil::advanceSpawnAccumulator(400.0f, 1.0f, accumulator)); + EXPECT_EQ(0.0f, accumulator); +} + +TEST(ParticleUtil, should_cap_spawn_work_without_carrying_integer_debt) { + float accumulator = 0.0f; + + EXPECT_EQ( + particleutil::kMaxSpawnParticlesPerUpdate, + particleutil::advanceSpawnAccumulator(1'000'000.0f, 1.0f / 60.0f, accumulator)); + EXPECT_GE(accumulator, 0.0f); + EXPECT_LT(accumulator, 1.0f); +} + +TEST(ParticleUtil, should_preserve_the_particle_uniform_batch_boundary) { + EXPECT_EQ(64, kMaxParticles); +} + +TEST(ParticleUtil, should_keep_reconstruction_samples_inside_the_selected_atlas_frame) { + auto bounds = particleutil::atlasFrameBounds( + glm::ivec2(256, 128), + glm::ivec2(4, 2), + 1); + + EXPECT_NEAR(0.25f + 0.5f / 256.0f, bounds.minUV.x, 1e-6f); + EXPECT_NEAR(0.50f - 0.5f / 256.0f, bounds.maxUV.x, 1e-6f); + EXPECT_NEAR(0.5f / 128.0f, bounds.minUV.y, 1e-6f); + EXPECT_NEAR(0.50f - 0.5f / 128.0f, bounds.maxUV.y, 1e-6f); + + auto below = particleutil::clampAtlasUV(bounds, glm::vec2(-10.0f)); + auto above = particleutil::clampAtlasUV(bounds, glm::vec2(10.0f)); + EXPECT_EQ(bounds.minUV, below); + EXPECT_EQ(bounds.maxUV, above); +} + +TEST(ParticleUtil, should_make_degenerate_atlas_grids_safe) { + auto onePixel = particleutil::atlasFrameBounds( + glm::ivec2(1, 1), + glm::ivec2(0, 0), + -10); + EXPECT_EQ(glm::vec2(0.5f), onePixel.minUV); + EXPECT_EQ(glm::vec2(0.5f), onePixel.maxUV); + + auto overDivided = particleutil::atlasFrameBounds( + glm::ivec2(2, 1), + glm::ivec2(100, 100), + 10'000); + EXPECT_LE(overDivided.minUV.x, overDivided.maxUV.x); + EXPECT_LE(overDivided.minUV.y, overDivided.maxUV.y); + EXPECT_GE(overDivided.minUV.x, 0.0f); + EXPECT_LE(overDivided.maxUV.x, 1.0f); +} + +TEST(ParticleUtil, should_normalize_the_cubic_reconstruction_kernel) { + for (float fraction : {0.0f, 0.1f, 0.5f, 0.9f, 1.0f}) { + auto weights = particleutil::cubicReconstructionWeights(fraction); + float sum = 0.0f; + for (float weight : weights) { + sum += weight; + } + EXPECT_NEAR(1.0f, sum, 1e-6f); + } +} + +TEST(ParticleUtil, should_use_stored_alpha_for_white_lighten_sprites) { + auto decoded = particleutil::decodeParticleSample( + glm::vec4(1.0f, 1.0f, 1.0f, 0.15f), + ParticleAlphaMode::AlphaAndLuminance, + true, + 1.0f); + + EXPECT_EQ(glm::vec3(1.0f), decoded.color); + EXPECT_NEAR(0.15f, decoded.alpha, 1e-6f); +} + +TEST(ParticleUtil, should_use_luminance_for_black_background_intensity_sprites) { + glm::vec3 authoredColor(0.20f, 0.05f, 0.0f); + auto decoded = particleutil::decodeParticleSample( + glm::vec4(authoredColor, 1.0f), + ParticleAlphaMode::AlphaAndLuminance, + true, + 1.0f); + + EXPECT_GT(decoded.alpha, 0.0f); + EXPECT_LT(decoded.alpha, 0.20f); + EXPECT_NEAR(authoredColor.r, decoded.color.r * decoded.alpha, 1e-6f); + EXPECT_NEAR(authoredColor.g, decoded.color.g * decoded.alpha, 1e-6f); + EXPECT_NEAR(authoredColor.b, decoded.color.b * decoded.alpha, 1e-6f); +} + +TEST(ParticleUtil, should_not_square_correlated_alpha_and_luminance) { + auto decoded = particleutil::decodeParticleSample( + glm::vec4(0.20f, 0.20f, 0.20f, 0.20f), + ParticleAlphaMode::AlphaAndLuminance, + true, + 1.0f); + + EXPECT_NEAR(0.20f, decoded.alpha, 1e-6f); + EXPECT_NEAR(1.0f, decoded.color.r, 1e-6f); + EXPECT_NEAR(1.0f, decoded.color.g, 1e-6f); + EXPECT_NEAR(1.0f, decoded.color.b, 1e-6f); +} + +TEST(ParticleUtil, should_preserve_authored_alpha_for_non_lighten_particles) { + glm::vec4 authored(0.8f, 0.1f, 0.05f, 0.35f); + auto decoded = particleutil::decodeParticleSample( + authored, + ParticleAlphaMode::Luminance, + false, + 2.0f); + + EXPECT_EQ(glm::vec3(authored), decoded.color); + EXPECT_NEAR(authored.a, decoded.alpha, 1e-6f); +} + +TEST(ParticleUtil, should_add_resolution_independent_contrast_without_changing_endpoints) { + EXPECT_FLOAT_EQ(0.0f, particleutil::enhanceParticleCoverage(0.0f, 1.0f)); + EXPECT_FLOAT_EQ(1.0f, particleutil::enhanceParticleCoverage(1.0f, 1.0f)); + EXPECT_FLOAT_EQ(0.1f, particleutil::enhanceParticleCoverage(0.1f, 0.0f)); + EXPECT_GT(particleutil::enhanceParticleCoverage(0.1f, 0.8f), 0.1f); + EXPECT_FLOAT_EQ(0.01f, particleutil::enhanceParticleCoverage(0.01f, 0.8f)); +} + +TEST(ParticleUtil, should_bound_the_analytic_particle_core) { + EXPECT_NEAR( + 0.8f, + particleutil::analyticParticleCoreEnvelope(glm::vec2(0.5f), false, 0.8f), + 1e-6f); + EXPECT_EQ( + 0.0f, + particleutil::analyticParticleCoreEnvelope(glm::vec2(0.0f, 0.5f), false, 1.0f)); + EXPECT_EQ( + 0.0f, + particleutil::analyticParticleCoreEnvelope(glm::vec2(0.0f, 0.5f), true, 1.0f)); + EXPECT_NEAR( + 0.8f, + particleutil::analyticParticleCoreEnvelope(glm::vec2(0.5f), true, 0.8f), + 1e-6f); + EXPECT_LE( + particleutil::analyticParticleCoreEnvelope(glm::vec2(0.5f), true, 10.0f), + 1.0f); + EXPECT_NEAR( + 1e-7f, + particleutil::analyticParticleCoreEnvelope(glm::vec2(0.5f), true, 1e-7f), + 1e-10f); +} + +TEST(ParticleUtil, should_cap_high_velocity_motion_trails_in_world_units) { + EXPECT_NEAR( + 8.0f, + particleutil::clampMotionTrailLength(4.0f, 100.0f, 1.0f, 8.0f), + 1e-6f); + EXPECT_NEAR( + 4.0f, + particleutil::clampMotionTrailLength(4.0f, 1.0f, 1.0f, 8.0f), + 1e-6f); + EXPECT_EQ( + 0.0f, + particleutil::clampMotionTrailLength(0.0f, 100.0f, 1.0f, 8.0f)); +} + +TEST(ParticleUtil, should_read_animated_emitter_controllers) { + ModelNode animationNode( + 1, + "spark", + glm::vec3(0.0f), + glm::quat(1.0f, 0.0f, 0.0f, 0.0f), + true); + animationNode.floatTracks()[ControllerTypes::birthrate].add(0.0f, 400.0f); + animationNode.floatTracks()[ControllerTypes::birthrate].add(0.3f, 0.0f); + animationNode.floatTracks()[ControllerTypes::xSize].add(0.0f, 0.0f); + animationNode.floatTracks()[ControllerTypes::xSize].add(0.3f, 600.0f); + animationNode.vectorTracks()[ControllerTypes::colorStart].add( + 0.0f, + glm::vec3(0.2f, 0.4f, 1.0f)); + + auto state = EmitterSceneNode::animationStateAt(animationNode, 0.15f); + + EXPECT_FALSE(state.empty()); + ASSERT_TRUE(state.birthrate); + EXPECT_NEAR(200.0f, *state.birthrate, 1e-5f); + ASSERT_TRUE(state.xSize); + EXPECT_NEAR(300.0f, *state.xSize, 1e-5f); + ASSERT_TRUE(state.colorStart); + EXPECT_EQ(glm::vec3(0.2f, 0.4f, 1.0f), *state.colorStart); + EXPECT_FALSE(state.lifeExpectancy); +} + +TEST(ParticleUtil, should_apply_emitter_controllers_before_spawning_in_the_same_frame) { + AnimatedEmitterHarness harness( + 0.0f, + 1.0f, + {{0.0f, 0.0f}, {0.1f, 20.0f}}); + + harness.model().update(0.1f); + + EXPECT_EQ(1u, harness.emitter().children().size()); +} + +TEST(ParticleUtil, should_integrate_animated_birthrate_across_frame_partitions) { + AnimatedEmitterHarness singleUpdate( + 0.0f, + 1.0f, + {{0.0f, 10.0f}, {0.1f, 30.0f}}); + AnimatedEmitterHarness partitionedUpdate( + 0.0f, + 1.0f, + {{0.0f, 10.0f}, {0.1f, 30.0f}}); + + singleUpdate.model().update(0.1f); + partitionedUpdate.model().update(0.05f); + partitionedUpdate.model().update(0.05f); + + ASSERT_EQ(2u, singleUpdate.emitter().children().size()); + ASSERT_EQ( + singleUpdate.emitter().children().size(), + partitionedUpdate.emitter().children().size()); + + std::vector singleLifetimes; + std::vector partitionedLifetimes; + for (auto *child : singleUpdate.emitter().children()) { + singleLifetimes.push_back( + static_cast(child)->lifetime()); + } + for (auto *child : partitionedUpdate.emitter().children()) { + partitionedLifetimes.push_back( + static_cast(child)->lifetime()); + } + std::sort(singleLifetimes.begin(), singleLifetimes.end()); + std::sort(partitionedLifetimes.begin(), partitionedLifetimes.end()); + for (size_t i = 0; i < singleLifetimes.size(); ++i) { + EXPECT_NEAR(singleLifetimes[i], partitionedLifetimes[i], 1e-5f); + } +} + +TEST(ParticleUtil, should_keep_fractional_births_until_an_animated_emitter_stops) { + AnimatedEmitterHarness harness( + 0.0f, + 1.0f, + {{0.0f, 10.0f}, {0.1f, 10.0f}, {0.2f, 0.0f}}); + + harness.model().update(0.075f); + harness.model().update(0.125f); + + EXPECT_EQ(1u, harness.emitter().children().size()); +} + +TEST(ParticleUtil, should_reset_fractional_births_across_zero_rate_gaps) { + AnimatedEmitterHarness coarseUpdates( + 0.0f, + 10.0f, + {{0.0f, 10.0f}, {0.1f, 0.0f}, {0.2f, 0.0f}, {0.3f, 10.0f}}); + AnimatedEmitterHarness fineUpdates( + 0.0f, + 10.0f, + {{0.0f, 10.0f}, {0.1f, 0.0f}, {0.2f, 0.0f}, {0.3f, 10.0f}}); + + coarseUpdates.model().update(0.2f); + coarseUpdates.model().update(0.1f); + fineUpdates.model().update(0.1f); + fineUpdates.model().update(0.1f); + fineUpdates.model().update(0.1f); + + EXPECT_TRUE(coarseUpdates.emitter().children().empty()); + EXPECT_EQ( + coarseUpdates.emitter().children().size(), + fineUpdates.emitter().children().size()); +} + +TEST(ParticleUtil, should_preserve_loop_overshoot_across_frame_partitions) { + AnimatedEmitterHarness coarseUpdates( + 0.0f, + 10.0f, + {{0.0f, 10.0f}, {1.0f, 10.0f}}); + AnimatedEmitterHarness fineUpdates( + 0.0f, + 10.0f, + {{0.0f, 10.0f}, {1.0f, 10.0f}}); + + for (int i = 0; i < 5; ++i) { + coarseUpdates.model().update(0.24f); + } + for (int i = 0; i < 6; ++i) { + fineUpdates.model().update(0.2f); + } + + EXPECT_EQ(12u, coarseUpdates.emitter().children().size()); + EXPECT_EQ( + coarseUpdates.emitter().children().size(), + fineUpdates.emitter().children().size()); +} + +TEST(ParticleUtil, should_keep_looped_emitter_controllers_on_the_particle_clock) { + AnimatedEmitterHarness coarseUpdates( + 0.0f, + 10.0f, + {{0.0f, 0.0f}, {1.0f, 20.0f}}); + AnimatedEmitterHarness fineUpdates( + 0.0f, + 10.0f, + {{0.0f, 0.0f}, {1.0f, 20.0f}}); + + for (int i = 0; i < 5; ++i) { + coarseUpdates.model().update(0.24f); + } + for (int i = 0; i < 6; ++i) { + fineUpdates.model().update(0.2f); + } + + EXPECT_NEAR(4.0f, coarseUpdates.emitter().birthrate(), 1e-4f); + EXPECT_NEAR( + coarseUpdates.emitter().birthrate(), + fineUpdates.emitter().birthrate(), + 1e-4f); + EXPECT_EQ( + coarseUpdates.emitter().children().size(), + fineUpdates.emitter().children().size()); +} + +TEST(ParticleUtil, should_integrate_animated_birthrate_at_non_unit_speed) { + AnimatedEmitterHarness singleUpdate( + 0.0f, + 10.0f, + {{0.0f, 10.0f}, {1.0f, 10.0f}}, + {}, + 2.0f); + AnimatedEmitterHarness partitionedUpdate( + 0.0f, + 10.0f, + {{0.0f, 10.0f}, {1.0f, 10.0f}}, + {}, + 2.0f); + + singleUpdate.model().update(0.2f); + for (int i = 0; i < 2; ++i) { + partitionedUpdate.model().update(0.1f); + } + + EXPECT_EQ(2u, singleUpdate.emitter().children().size()); + EXPECT_EQ( + singleUpdate.emitter().children().size(), + partitionedUpdate.emitter().children().size()); +} + +TEST(ParticleUtil, should_not_age_detonation_particles_before_their_first_render) { + AnimatedEmitterHarness harness( + 0.0f, + 1.0f, + {}, + {{0.05f, "detonate"}}); + + harness.model().update(0.1f); + + ASSERT_EQ(1u, harness.emitter().children().size()); + auto particle = static_cast( + *harness.emitter().children().begin()); + EXPECT_FLOAT_EQ(0.0f, particle->lifetime()); +} + +TEST(ParticleUtil, should_advance_culled_animation_without_hidden_particles_or_spawn_debt) { + AnimatedEmitterHarness harness( + 20.0f, + 1.0f, + {{0.0f, 20.0f}, {1.0f, 20.0f}}); + harness.model().setCulled(true); + + for (int i = 0; i < 5; ++i) { + harness.model().update(0.1f); + } + + ASSERT_EQ(1u, harness.model().animationChannels().size()); + EXPECT_NEAR(0.5f, harness.model().animationChannels().front().time, 1e-5f); + EXPECT_TRUE(harness.emitter().children().empty()); + + harness.model().setCulled(false); + harness.model().update(0.05f); + + EXPECT_EQ(1u, harness.emitter().children().size()); +} + +TEST(ParticleUtil, should_not_replay_a_culled_non_looping_single_emitter) { + AnimatedEmitterHarness harness( + 0.0f, + 1.0f, + {}, + {}, + 1.0f, + ModelNode::Emitter::UpdateMode::Single, + false); + harness.model().setCulled(true); + + harness.model().update(0.1f); + harness.model().setCulled(false); + harness.model().update(0.1f); + + EXPECT_TRUE(harness.emitter().children().empty()); +} + +TEST(ParticleUtil, should_keep_a_culled_looping_single_emitter_eligible) { + AnimatedEmitterHarness harness( + 0.0f, + 1.0f, + {}, + {}, + 1.0f, + ModelNode::Emitter::UpdateMode::Single, + true); + harness.model().setCulled(true); + + harness.model().update(0.1f); + harness.model().setCulled(false); + harness.model().update(0.1f); + + ASSERT_EQ(1u, harness.emitter().children().size()); + auto particle = static_cast( + *harness.emitter().children().begin()); + EXPECT_NEAR(0.1f, particle->lifetime(), 1e-6f); +} + +TEST(ParticleUtil, should_refresh_a_frozen_emitter_after_culled_animation_time) { + AnimatedEmitterHarness harness( + 0.0f, + 10.0f, + {{0.0f, 0.0f}, {1.0f, 20.0f}}); + + harness.model().update(0.1f); + harness.model().setCulled(true); + harness.model().update(0.4f); + harness.model().pauseAnimation(); + harness.model().setCulled(false); + harness.model().update(0.1f); + + EXPECT_NEAR(10.0f, harness.emitter().birthrate(), 1e-5f); + EXPECT_TRUE(harness.emitter().children().empty()); +} + +TEST(ParticleUtil, should_cap_particles_per_emitter_and_reuse_the_pool) { + AnimatedEmitterHarness harness(2560.0f, 0.1f); + + harness.model().update(0.1f); + ASSERT_EQ(static_cast(kMaxParticles), harness.emitter().children().size()); + std::unordered_set firstGeneration( + harness.emitter().children().begin(), + harness.emitter().children().end()); + + harness.model().update(0.1f); + + ASSERT_EQ(static_cast(kMaxParticles), harness.emitter().children().size()); + for (auto *particle : harness.emitter().children()) { + EXPECT_EQ(1u, firstGeneration.count(particle)); + } +}