From 784f3dae27e807a95f534b206d1993515a102341 Mon Sep 17 00:00:00 2001 From: CrispyW0nton Date: Thu, 23 Jul 2026 08:10:54 -0700 Subject: [PATCH 1/5] Fix motion-blur particle orientation --- include/reone/scene/node/emitter.h | 1 + include/reone/scene/particleutil.h | 47 +++++++++++++++ src/libs/scene/CMakeLists.txt | 2 + src/libs/scene/node/emitter.cpp | 26 +++++--- src/libs/scene/particleutil.cpp | 60 +++++++++++++++++++ test/CMakeLists.txt | 1 + test/scene/particleutil.cpp | 96 ++++++++++++++++++++++++++++++ 7 files changed, 225 insertions(+), 8 deletions(-) create mode 100644 include/reone/scene/particleutil.h create mode 100644 src/libs/scene/particleutil.cpp create mode 100644 test/scene/particleutil.cpp diff --git a/include/reone/scene/node/emitter.h b/include/reone/scene/node/emitter.h index d90c19c8e..c5dbb1cc6 100644 --- a/include/reone/scene/node/emitter.h +++ b/include/reone/scene/node/emitter.h @@ -91,6 +91,7 @@ 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}; diff --git a/include/reone/scene/particleutil.h b/include/reone/scene/particleutil.h new file mode 100644 index 000000000..d636f95d1 --- /dev/null +++ b/include/reone/scene/particleutil.h @@ -0,0 +1,47 @@ +/* + * 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 "glm/glm.hpp" + +namespace reone { + +namespace scene { + +namespace particleutil { + +struct MotionBlurBasis { + glm::vec3 right {0.0f}; + glm::vec3 up {0.0f}; + float lengthScale {1.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); + +} // namespace particleutil + +} // namespace scene + +} // namespace reone 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/node/emitter.cpp b/src/libs/scene/node/emitter.cpp index 0b3eedb40..337ebea3b 100644 --- a/src/libs/scene/node/emitter.cpp +++ b/src/libs/scene/node/emitter.cpp @@ -29,6 +29,7 @@ #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" @@ -38,9 +39,6 @@ namespace reone { namespace scene { -static constexpr float kMotionBlurStrength = 0.25f; -static constexpr float kProjectileSpeed = 16.0f; - void EmitterSceneNode::init() { _modelNode.floatValueAtTime(ControllerTypes::birthrate, 0.0f, _birthrate); _modelNode.floatValueAtTime(ControllerTypes::lifeExp, 0.0f, _lifeExpectancy); @@ -59,6 +57,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); @@ -263,7 +262,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]); @@ -277,13 +277,23 @@ void EmitterSceneNode::renderLeafs(IRenderPass &pass, const std::vectorcolor(), particle->alpha()); 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.y *= basis.lengthScale; + 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].right = glm::vec4(0.0f, 1.0f, 0.0, 0.0f); particles[i].up = glm::vec4(1.0f, 0.0f, 0.0f, 0.0f); diff --git a/src/libs/scene/particleutil.cpp b/src/libs/scene/particleutil.cpp new file mode 100644 index 000000000..cec3c40d1 --- /dev/null +++ b/src/libs/scene/particleutil.cpp @@ -0,0 +1,60 @@ +/* + * 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" + +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}; +} + +} // namespace particleutil + +} // namespace scene + +} // namespace reone diff --git a/test/CMakeLists.txt b/test/CMakeLists.txt index 7042a9518..413bb3a97 100644 --- a/test/CMakeLists.txt +++ b/test/CMakeLists.txt @@ -87,6 +87,7 @@ set(TESTS_SOURCES ${TESTS_SOURCE_DIR}/resource/resref.cpp ${TESTS_SOURCE_DIR}/resource/strings.cpp ${TESTS_SOURCE_DIR}/scene/model.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/scene/particleutil.cpp b/test/scene/particleutil.cpp new file mode 100644 index 000000000..85a6c5b3f --- /dev/null +++ b/test/scene/particleutil.cpp @@ -0,0 +1,96 @@ +/* + * 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/scene/particleutil.h" + +using namespace reone::scene; + +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); +} From 35aea2ae2c021f9eeb86bea946ad1686881721c1 Mon Sep 17 00:00:00 2001 From: CrispyW0nton Date: Sat, 25 Jul 2026 19:05:27 -0700 Subject: [PATCH 2/5] [scene] Stabilize and sharpen K1 grenade particles Apply emitter controllers before simulation, bound spawn and trail work, and keep enhanced reconstruction behind KOTOR 1 label profiles. The default renderer path and TSL effects remain unchanged. --- glsl/f_oit_particles.glsl | 225 ++++++++++- glsl/u_particles.glsl | 8 + include/reone/game/effect/visual.h | 14 +- include/reone/graphics/uniforms.h | 8 + include/reone/scene/node/emitter.h | 66 ++- include/reone/scene/node/model.h | 4 + include/reone/scene/particleutil.h | 45 +++ include/reone/scene/render/pass.h | 69 ++++ include/reone/scene/render/pass/pbr.h | 2 + include/reone/scene/render/pass/retro.h | 2 + src/libs/game/CMakeLists.txt | 1 + src/libs/game/effect/visual.cpp | 8 +- src/libs/game/effect/visualprofile.cpp | 129 ++++++ src/libs/game/script/routine/impl/effect.cpp | 3 +- src/libs/scene/node/emitter.cpp | 287 +++++++++++-- src/libs/scene/node/model.cpp | 43 +- src/libs/scene/particleutil.cpp | 160 ++++++++ src/libs/scene/render/pass/pbr.cpp | 14 +- src/libs/scene/render/pass/retro.cpp | 14 +- test/CMakeLists.txt | 4 +- test/game/effect/visual.cpp | 152 +++++++ test/scene/particleprofile.cpp | 235 +++++++++++ test/scene/particleutil.cpp | 403 +++++++++++++++++++ 23 files changed, 1816 insertions(+), 80 deletions(-) create mode 100644 src/libs/game/effect/visualprofile.cpp create mode 100644 test/game/effect/visual.cpp create mode 100644 test/scene/particleprofile.cpp diff --git a/glsl/f_oit_particles.glsl b/glsl/f_oit_particles.glsl index e109a7501..310019c37 100644 --- a/glsl/f_oit_particles.glsl +++ b/glsl/f_oit_particles.glsl @@ -13,29 +13,220 @@ 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 == 1) { + sampleAlpha = storedAlpha; + } else if (uParticleAlphaMode == 2) { + sampleAlpha = luminance; + sampleColor *= 1.0 / max(0.0001, luminance); + } else if (uParticleAlphaMode == 3) { + 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 analyticTrailEnvelope(vec2 localUV) { + if (uParticleTrailMode != 1 || + uMotionBlur == 0 || + uParticleTrailCoreIntensity <= 0.0) { + return 0.0; + } + + vec2 centered = 2.0 * localUV - 1.0; + 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 != 0 || + uParticleAlphaMode != 0 || + (uParticleTrailMode != 0 && uMotionBlur != 0) || + uParticleDiagnosticMode != 0 || + 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; + } - int frame = int(uParticles[fragInstanceID].positionFrame.w); - if (frame > 0) { - uv.y += oneOverGridY * (frame / uGridSize.x); - uv.x += oneOverGridX * (frame % uGridSize.x); + float w = OIT_weight(gl_FragCoord.z, objectAlpha); + fragColor1 = vec4(objectColor * w, objectAlpha); + fragColor2 = vec4(w); + return; } - 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); + vec2 enhancedUV; + vec2 frameMinUV; + vec2 frameMaxUV; + ivec2 textureDimensions; + enhancedAtlasUV(enhancedUV, frameMinUV, frameMaxUV, textureDimensions); + + vec4 mainTexSample = uParticleReconstructionMode == 1 + ? sampleCubicAtlas( + enhancedUV, + frameMinUV, + frameMaxUV, + textureDimensions) + : texture(sMainTex, enhancedUV); + vec3 mainTexColor; + float mainTexAlpha; + decodeParticleSample(mainTexSample, mainTexColor, mainTexAlpha); + + float trailCore = analyticTrailEnvelope(fragUV1); + mainTexAlpha += trailCore * mainTexAlpha * (1.0 - mainTexAlpha); + + vec3 objectColor; + float objectAlpha; + if (uParticleDiagnosticMode == 1) { + objectColor = mainTexColor; + objectAlpha = mainTexAlpha; + } else if (uParticleDiagnosticMode == 2) { + objectColor = vec3(mainTexAlpha); + objectAlpha = 1.0; + } else if (uParticleDiagnosticMode == 3) { + 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..12c3d9dca 100644 --- a/glsl/u_particles.glsl +++ b/glsl/u_particles.glsl @@ -10,5 +10,13 @@ struct Particle { layout(std140) uniform Particles { ivec2 uGridSize; + int uParticleReconstructionMode; + int uParticleAlphaMode; + int uParticleTrailMode; + int uMotionBlur; + float uParticleReconstructionStrength; + float uParticleAlphaExponent; + float uParticleTrailCoreIntensity; + int uParticleDiagnosticMode; Particle uParticles[MAX_PARTICLES]; }; diff --git a/include/reone/game/effect/visual.h b/include/reone/game/effect/visual.h index b3a0591ce..ca081e10e 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,17 @@ namespace game { class ServicesView; struct VisualEffectDesc; +scene::ParticleRenderProfile particleRenderProfileForVisualEffect( + resource::GameID gameId, + const VisualEffectDesc &desc); + 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 +53,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/graphics/uniforms.h b/include/reone/graphics/uniforms.h index dc2631136..3c3c40287 100644 --- a/include/reone/graphics/uniforms.h +++ b/include/reone/graphics/uniforms.h @@ -156,6 +156,14 @@ 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}; + int diagnosticMode {0}; ParticleUniformsParticle particles[kMaxParticles]; }; diff --git a/include/reone/scene/node/emitter.h b/include/reone/scene/node/emitter.h index c5dbb1cc6..a9ff01ac0 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,59 @@ 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 AnimationState { + std::optional birthrate; + 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,6 +105,11 @@ class EmitterSceneNode : public ModelNodeSceneNode { void detonate(); + static AnimationState animationStateAt(const graphics::ModelNode &animationNode, float time); + 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); }; @@ -98,17 +155,22 @@ class EmitterSceneNode : public ModelNodeSceneNode { float _lightningRadius {0.0f}; float _lightningScale {0.0f}; int _lightningSubDiv {0}; + ParticleRenderProfile _renderProfile; - float _birthInterval {0.0f}; + float _birthAccumulator {0.0f}; 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(); 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..bc40291cc 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 { @@ -123,6 +125,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 @@ -176,6 +179,7 @@ class ModelSceneNode : public SceneNode { // Flags bool _pickable {false}; + ParticleRenderProfile _particleRenderProfile; // END Flags diff --git a/include/reone/scene/particleutil.h b/include/reone/scene/particleutil.h index d636f95d1..05c151acb 100644 --- a/include/reone/scene/particleutil.h +++ b/include/reone/scene/particleutil.h @@ -17,20 +17,37 @@ #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 kMaxEmitterParticles = 256; + 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, @@ -40,6 +57,34 @@ MotionBlurBasis buildMotionBlurBasis( float particleLength, float blurLength); +int advanceSpawnAccumulator(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 analyticTrailEnvelope( + const glm::vec2 &localUV, + bool motionBlur, + float intensity); + +float clampMotionTrailLength( + float authoredLength, + float basisScale, + float profileScale, + float maximumLength); + } // namespace particleutil } // namespace scene diff --git a/include/reone/scene/render/pass.h b/include/reone/scene/render/pass.h index bbaeb3962..f9cfe8583 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,46 @@ 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}; +}; + struct ParticleInstance { int frame {0}; glm::vec3 position {0.0f}; @@ -60,6 +101,32 @@ 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.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 +169,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..2f6c82044 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,7 @@ void VisualEffect::applyTo(Object &object) { _node = graph->newModel(*_desc->impRootMNode, scene::ModelUsage::Projectile); graph->addRoot(_node); _node->setLocalTransform(glm::translate(_location.value())); + _node->setParticleRenderProfile(particleRenderProfileForVisualEffect(_gameId, *_desc)); _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..9ab6d2842 --- /dev/null +++ b/src/libs/game/effect/visualprofile.cpp @@ -0,0 +1,129 @@ +/* + * 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 grenadeProfile( + float largeParticleScale, + float worldZScale, + float motionLengthScale, + float motionMaxWidth, + float motionMaxLength, + float reconstructionStrength, + float alphaExponent, + float trailCoreIntensity) { + + scene::ParticleRenderProfile profile; + profile.largeParticleScale = largeParticleScale; + profile.worldZScale = worldZScale; + profile.motionLengthScale = motionLengthScale; + profile.motionMaxWidth = motionMaxWidth; + profile.motionMaxLength = motionMaxLength; + profile.policy.reconstruction = scene::ParticleReconstruction::Cubic; + profile.policy.alpha = scene::ParticleAlphaMode::AlphaAndLuminance; + profile.policy.trail = scene::ParticleTrailMode::AnalyticCore; + profile.policy.reconstructionStrength = reconstructionStrength; + profile.policy.alphaExponent = alphaExponent; + profile.policy.trailCoreIntensity = trailCoreIntensity; + return profile; +} + +static scene::ParticleRenderProfile fragmentationGrenadeProfile() { + return grenadeProfile(0.85f, 0.80f, 0.75f, 0.18f, 1.20f, 0.68f, 1.08f, 0.04f); +} + +static scene::ParticleRenderProfile stunGrenadeProfile() { + return grenadeProfile(0.85f, 0.82f, 0.70f, 0.16f, 1.10f, 0.72f, 1.08f, 0.05f); +} + +static scene::ParticleRenderProfile thermalDetonatorProfile() { + return grenadeProfile(0.72f, 0.68f, 0.45f, 0.10f, 0.75f, 0.80f, 1.15f, 0.07f); +} + +static scene::ParticleRenderProfile poisonGrenadeProfile() { + return grenadeProfile(0.90f, 0.85f, 0.80f, 0.20f, 1.35f, 0.65f, 1.05f, 0.03f); +} + +static scene::ParticleRenderProfile sonicGrenadeProfile() { + return grenadeProfile(0.88f, 0.82f, 0.70f, 0.16f, 1.10f, 0.68f, 1.08f, 0.04f); +} + +static scene::ParticleRenderProfile adhesiveGrenadeProfile() { + return grenadeProfile(0.90f, 0.85f, 0.75f, 0.18f, 1.20f, 0.65f, 1.05f, 0.03f); +} + +static scene::ParticleRenderProfile cryobanGrenadeProfile() { + return grenadeProfile(0.85f, 0.80f, 0.65f, 0.15f, 1.00f, 0.70f, 1.08f, 0.04f); +} + +static scene::ParticleRenderProfile plasmaGrenadeProfile() { + return grenadeProfile(0.75f, 0.70f, 0.45f, 0.10f, 0.70f, 0.82f, 1.15f, 0.07f); +} + +static scene::ParticleRenderProfile ionGrenadeProfile() { + return grenadeProfile(0.74f, 0.72f, 0.42f, 0.09f, 0.65f, 0.85f, 1.12f, 0.08f); +} + +scene::ParticleRenderProfile particleRenderProfileForVisualEffect( + resource::GameID gameId, + const VisualEffectDesc &desc) { + + if (gameId != resource::GameID::KotOR || !desc.impRootMNode) { + return {}; + } + const auto &impactModel = desc.impRootMNode->name(); + if (desc.label == "VFX_FNF_GRENADE_FRAGMENTATION" && impactModel == "v_grnfrag_fnf") { + return fragmentationGrenadeProfile(); + } + if (desc.label == "VFX_FNF_GRENADE_STUN" && impactModel == "v_grnstun_fnf") { + return stunGrenadeProfile(); + } + if (desc.label == "VFX_FNF_GRENADE_THERMAL_DETONATOR" && impactModel == "v_grndeto_fnf") { + return thermalDetonatorProfile(); + } + if (desc.label == "VFX_FNF_GRENADE_POISON" && impactModel == "v_grnpois_fnf") { + return poisonGrenadeProfile(); + } + if (desc.label == "VFX_FNF_GRENADE_SONIC" && impactModel == "v_grnsonc_fnf") { + return sonicGrenadeProfile(); + } + if (desc.label == "VFX_FNF_GRENADE_ADHESIVE" && impactModel == "v_grnadhs_fnf") { + return adhesiveGrenadeProfile(); + } + if (desc.label == "VFX_FNF_GRENADE_CRYOBAN" && impactModel == "v_grncryo_fnf") { + return cryobanGrenadeProfile(); + } + if (desc.label == "VFX_FNF_GRENADE_PLASMA" && impactModel == "v_grnplas_fnf") { + return plasmaGrenadeProfile(); + } + if (desc.label == "VFX_FNF_GRENADE_ION" && impactModel == "v_grnion_fnf") { + return ionGrenadeProfile(); + } + return {}; +} + +} // 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/scene/node/emitter.cpp b/src/libs/scene/node/emitter.cpp index 337ebea3b..9b11d2a92 100644 --- a/src/libs/scene/node/emitter.cpp +++ b/src/libs/scene/node/emitter.cpp @@ -39,6 +39,173 @@ namespace reone { namespace scene { +bool EmitterSceneNode::AnimationState::empty() const { + return !birthrate && + !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; +} + +void EmitterSceneNode::applyAnimationState(const AnimationState &state) { + if (state.birthrate) { + _birthrate = glm::max(*state.birthrate, 0.0f); + if (_birthrate == 0.0f) { + _birthAccumulator = 0.0f; + } + } + 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); _modelNode.floatValueAtTime(ControllerTypes::lifeExp, 0.0f, _lifeExpectancy); @@ -79,32 +246,46 @@ 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); + if (isSpawningSuppressed()) { + discardSpawnTime(dt); + } else { + spawnParticles(dt); + } for (auto &child : _children) { + if (child->type() != SceneNodeType::Particle) { + continue; + } auto particle = static_cast(child); particle->update(dt); } } +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; + + if (_modelNode.emitter()->updateMode != ModelNode::Emitter::UpdateMode::Lightning) { + return; + } + _birthTimer.update(dt); + if (_birthTimer.elapsed()) { + _birthTimer.reset(_lightningDelay); + } +} + void EmitterSceneNode::removeExpiredParticles(float dt) { if (_lifeExpectancy == -1.0f) { return; @@ -129,12 +310,10 @@ 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); - } + for (int spawnCount = particleutil::advanceSpawnAccumulator(_birthrate, dt, _birthAccumulator); + spawnCount > 0; + --spawnCount) { + doSpawnParticle(); } break; case ModelNode::Emitter::UpdateMode::Single: @@ -155,12 +334,30 @@ void EmitterSceneNode::spawnParticles(float dt) { } } +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 + : particleutil::kMaxEmitterParticles; + if (_particleCount >= maxParticles) { + return nullptr; + } + + auto particle = _sceneGraph.newParticle(*this).get(); + ++_particleCount; + return particle; +} + void EmitterSceneNode::doSpawnParticle() { - // Take particle from the pool, if available - if (_particlePool.empty()) { + auto particle = takeParticle(); + if (!particle) { return; } - auto particle = static_cast(_particlePool.front()); particle->setLifetime(0.0f); float halfW = 0.005f * _size.x; @@ -180,8 +377,6 @@ void EmitterSceneNode::doSpawnParticle() { particle->setAnimLength((_frameEnd - _frameStart + 1) / _fps); } - // Remove particle from pool and append it to emitter - _particlePool.pop_front(); addChild(*particle); } @@ -227,12 +422,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); @@ -246,6 +439,9 @@ void EmitterSceneNode::spawnLightningParticles() { } void EmitterSceneNode::detonate() { + if (isSpawningSuppressed()) { + return; + } doSpawnParticle(); } @@ -274,7 +470,10 @@ 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: particles[i].right = glm::vec4(emitterUp, 0.0f); @@ -289,12 +488,20 @@ 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..42c9ac146 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,8 @@ void ModelSceneNode::update(float dt) { if (!_enabled) { return; } - SceneNode::update(dt); updateAnimations(dt); + SceneNode::update(dt); } void ModelSceneNode::renderLeafs(IRenderPass &pass, const std::vector &leafs) { @@ -153,6 +154,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 +196,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) { @@ -409,15 +427,21 @@ 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, time); + if (!state.emitter.empty()) { + state.flags |= AnimationStateFlags::emitter; + } + } channel.stateByNodeNumber[modelNode.number()] = std::move(state); } @@ -481,6 +505,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 +534,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 +556,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()) { diff --git a/src/libs/scene/particleutil.cpp b/src/libs/scene/particleutil.cpp index cec3c40d1..b6dec07c8 100644 --- a/src/libs/scene/particleutil.cpp +++ b/src/libs/scene/particleutil.cpp @@ -17,6 +17,11 @@ #include "reone/scene/particleutil.h" +#include +#include + +#include "reone/graphics/lumautil.h" + namespace reone { namespace scene { @@ -53,6 +58,161 @@ MotionBlurBasis buildMotionBlurBasis( return {right, up, 1.0f + trailLength / particleLength}; } +int advanceSpawnAccumulator(float birthrate, float dt, float &accumulator) { + if (!std::isfinite(birthrate) || + !std::isfinite(dt) || + !std::isfinite(accumulator) || + birthrate <= 0.0f) { + accumulator = 0.0f; + return 0; + } + if (dt <= 0.0f) { + return 0; + } + if (dt > kMaxContinuousParticleDelta) { + accumulator = 0.0f; + return 0; + } + + accumulator += birthrate * dt; + if (!std::isfinite(accumulator)) { + accumulator = 0.0f; + return 0; + } + + float wholeParticles = glm::floor(accumulator); + accumulator -= wholeParticles; + if (wholeParticles >= static_cast(kMaxEmitterParticles)) { + return kMaxEmitterParticles; + } + return static_cast(wholeParticles); +} + +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 analyticTrailEnvelope( + const glm::vec2 &localUV, + bool motionBlur, + float intensity) { + + if (!motionBlur || + !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; + 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 glm::clamp(intensity, 0.0f, 1.0f) * 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 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 413bb3a97..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,8 @@ 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 diff --git a/test/game/effect/visual.cpp b/test/game/effect/visual.cpp new file mode 100644 index 000000000..5561efb9a --- /dev/null +++ b/test/game/effect/visual.cpp @@ -0,0 +1,152 @@ +/* + * 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 { + const char *label; + const char *impactModel; +}; + +constexpr GrenadeVisual kKotorGrenadeVisuals[] { + {"VFX_FNF_GRENADE_FRAGMENTATION", "v_grnfrag_fnf"}, + {"VFX_FNF_GRENADE_STUN", "v_grnstun_fnf"}, + {"VFX_FNF_GRENADE_THERMAL_DETONATOR", "v_grndeto_fnf"}, + {"VFX_FNF_GRENADE_POISON", "v_grnpois_fnf"}, + {"VFX_FNF_GRENADE_SONIC", "v_grnsonc_fnf"}, + {"VFX_FNF_GRENADE_ADHESIVE", "v_grnadhs_fnf"}, + {"VFX_FNF_GRENADE_CRYOBAN", "v_grncryo_fnf"}, + {"VFX_FNF_GRENADE_PLASMA", "v_grnplas_fnf"}, + {"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; +} + +void expectNeutral(const ParticleRenderProfile &profile) { + EXPECT_FLOAT_EQ(1.0f, profile.largeParticleScale); + EXPECT_FLOAT_EQ(1.0f, profile.worldZScale); + EXPECT_FLOAT_EQ(1.0f, profile.opacity); + EXPECT_FLOAT_EQ(1.0f, profile.worldZOpacity); + EXPECT_FLOAT_EQ(1.0f, profile.motionLengthScale); + EXPECT_EQ(std::numeric_limits::max(), profile.motionMaxWidth); + EXPECT_FLOAT_EQ(1.0f, profile.motionOpacity); + EXPECT_EQ(glm::vec3(1.0f), profile.colorTint); + EXPECT_FLOAT_EQ(1.0f, profile.colorIntensity); + EXPECT_EQ(ParticleReconstruction::Legacy, profile.policy.reconstruction); + EXPECT_EQ(ParticleAlphaMode::Legacy, profile.policy.alpha); + EXPECT_EQ(ParticleTrailMode::Legacy, profile.policy.trail); + EXPECT_EQ(ParticleDiagnosticMode::Composite, profile.policy.diagnostic); + EXPECT_FLOAT_EQ(0.0f, profile.policy.reconstructionStrength); + EXPECT_FLOAT_EQ(1.0f, profile.policy.alphaExponent); + EXPECT_FLOAT_EQ(0.0f, profile.policy.trailCoreIntensity); + EXPECT_EQ(std::numeric_limits::max(), profile.motionMaxLength); +} + +} // namespace + +TEST(VisualEffectParticleProfile, should_select_all_nine_kotor_grenade_profiles) { + for (const auto &entry : kKotorGrenadeVisuals) { + SCOPED_TRACE(entry.label); + auto profile = particleRenderProfileForVisualEffect( + GameID::KotOR, + visualEffectDesc(entry.label, entry.impactModel)); + + 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.0f); + EXPECT_LE(profile.policy.alphaExponent, 1.25f); + EXPECT_GT(profile.policy.trailCoreIntensity, 0.0f); + EXPECT_LE(profile.policy.trailCoreIntensity, 0.1f); + 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_FLOAT_EQ(1.0f, profile.opacity); + EXPECT_EQ(glm::vec3(1.0f), profile.colorTint); + EXPECT_FLOAT_EQ(1.0f, profile.colorIntensity); + } +} + +TEST(VisualEffectParticleProfile, should_keep_ion_plasma_and_thermal_trails_compact) { + constexpr GrenadeVisual criticalVisuals[] { + {"VFX_FNF_GRENADE_THERMAL_DETONATOR", "v_grndeto_fnf"}, + {"VFX_FNF_GRENADE_PLASMA", "v_grnplas_fnf"}, + {"VFX_FNF_GRENADE_ION", "v_grnion_fnf"}, + }; + + for (const auto &entry : criticalVisuals) { + SCOPED_TRACE(entry.label); + auto profile = particleRenderProfileForVisualEffect( + GameID::KotOR, + visualEffectDesc(entry.label, entry.impactModel)); + + EXPECT_LE(profile.motionLengthScale, 0.5f); + EXPECT_LE(profile.motionMaxWidth, 0.12f); + EXPECT_LE(profile.motionMaxLength, 0.8f); + } +} + +TEST(VisualEffectParticleProfile, should_return_neutral_for_unknown_labels) { + expectNeutral(particleRenderProfileForVisualEffect( + GameID::KotOR, + visualEffectDesc("VFX_FNF_NOT_A_GRENADE", "v_grnfrag_fnf"))); +} + +TEST(VisualEffectParticleProfile, should_return_neutral_for_tsl_grenade_labels) { + for (const auto &entry : kKotorGrenadeVisuals) { + SCOPED_TRACE(entry.label); + expectNeutral(particleRenderProfileForVisualEffect( + GameID::TSL, + visualEffectDesc(entry.label, entry.impactModel))); + } +} + +TEST(VisualEffectParticleProfile, should_return_neutral_for_mismatched_kotor_models) { + expectNeutral(particleRenderProfileForVisualEffect( + GameID::KotOR, + 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..a201421ab --- /dev/null +++ b/test/scene/particleprofile.cpp @@ -0,0 +1,235 @@ +/* + * 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/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); +} + +} // 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; + + 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_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; + 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; + 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 index 85a6c5b3f..d39027f74 100644 --- a/test/scene/particleutil.cpp +++ b/test/scene/particleutil.cpp @@ -17,10 +17,147 @@ #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/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 = {}) { + + _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 = ModelNode::Emitter::UpdateMode::Fountain; + 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()) { + 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::vector())); + } + + _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 (!animatedBirthrate.empty()) { + _modelSceneNode->playAnimation( + "pulse", + nullptr, + AnimationProperties::fromFlags(AnimationFlags::loop)); + } + } + + 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)); @@ -94,3 +231,269 @@ TEST(ParticleUtil, should_fall_back_to_camera_axes_for_camera_depth_motion) { 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_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(256, 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_bound_the_analytic_motion_trail_core) { + EXPECT_EQ( + 0.0f, + particleutil::analyticTrailEnvelope(glm::vec2(0.5f), false, 1.0f)); + EXPECT_EQ( + 0.0f, + particleutil::analyticTrailEnvelope(glm::vec2(0.0f, 0.5f), true, 1.0f)); + EXPECT_NEAR( + 0.8f, + particleutil::analyticTrailEnvelope(glm::vec2(0.5f), true, 0.8f), + 1e-6f); + EXPECT_LE( + particleutil::analyticTrailEnvelope(glm::vec2(0.5f), true, 10.0f), + 1.0f); + EXPECT_NEAR( + 1e-7f, + particleutil::analyticTrailEnvelope(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, 10.0f}}); + + harness.model().update(0.1f); + + EXPECT_EQ(1u, harness.emitter().children().size()); +} + +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_cap_particles_per_emitter_and_reuse_the_pool) { + AnimatedEmitterHarness harness(2560.0f, 0.1f); + + harness.model().update(0.1f); + ASSERT_EQ(256u, harness.emitter().children().size()); + std::unordered_set firstGeneration( + harness.emitter().children().begin(), + harness.emitter().children().end()); + + harness.model().update(0.1f); + + ASSERT_EQ(256u, harness.emitter().children().size()); + for (auto *particle : harness.emitter().children()) { + EXPECT_EQ(1u, firstGeneration.count(particle)); + } +} From 8ad8d52b65a5694c40963a64c93d9d99683d4081 Mon Sep 17 00:00:00 2001 From: CrispyW0nton Date: Sat, 25 Jul 2026 22:25:54 -0700 Subject: [PATCH 3/5] [scene] Finalize frame-stable K1 particle effects Integrate animated birth rates across keyframes, zero-rate gaps, and loop wraps without changing the established model animation clock. Refresh culled emitter state, keep the 64-particle render boundary, and scope the grenade presentation tuning to exact KOTOR 1 effects. --- glsl/f_oit_particles.glsl | 69 +++++-- glsl/u_particles.glsl | 1 + include/reone/game/effect/visual.h | 6 +- include/reone/game/visualeffects.h | 14 ++ include/reone/graphics/modelnode.h | 1 + include/reone/graphics/uniforms.h | 1 + include/reone/scene/node/emitter.h | 21 +- include/reone/scene/node/model.h | 5 + include/reone/scene/particleutil.h | 6 +- include/reone/scene/render/pass.h | 2 + src/libs/game/effect/visual.cpp | 9 +- src/libs/game/effect/visualprofile.cpp | 262 +++++++++++++++++++------ src/libs/game/visualeffects.cpp | 18 +- src/libs/scene/graph.cpp | 5 + src/libs/scene/node/emitter.cpp | 213 +++++++++++++++++++- src/libs/scene/node/model.cpp | 92 ++++++++- src/libs/scene/particleutil.cpp | 31 ++- test/game/effect/visual.cpp | 141 ++++++++----- test/scene/particleprofile.cpp | 13 ++ test/scene/particleutil.cpp | 211 ++++++++++++++++++-- 20 files changed, 944 insertions(+), 177 deletions(-) diff --git a/glsl/f_oit_particles.glsl b/glsl/f_oit_particles.glsl index 310019c37..bc4d54afd 100644 --- a/glsl/f_oit_particles.glsl +++ b/glsl/f_oit_particles.glsl @@ -7,6 +7,19 @@ 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; @@ -117,12 +130,12 @@ void decodeParticleSample( if (!isFeatureEnabled(FEATURE_PREMULALPHA)) { sampleAlpha = storedAlpha; return; - } else if (uParticleAlphaMode == 1) { + } else if (uParticleAlphaMode == PARTICLE_ALPHA_STORED) { sampleAlpha = storedAlpha; - } else if (uParticleAlphaMode == 2) { + } else if (uParticleAlphaMode == PARTICLE_ALPHA_LUMINANCE) { sampleAlpha = luminance; sampleColor *= 1.0 / max(0.0001, luminance); - } else if (uParticleAlphaMode == 3) { + } else if (uParticleAlphaMode == PARTICLE_ALPHA_AND_LUMINANCE) { sampleAlpha = min(storedAlpha, luminance); if (luminance <= storedAlpha) { sampleColor *= 1.0 / max(0.0001, luminance); @@ -137,14 +150,20 @@ void decodeParticleSample( max(uParticleAlphaExponent, 0.0001)); } -float analyticTrailEnvelope(vec2 localUV) { - if (uParticleTrailMode != 1 || - uMotionBlur == 0 || +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; @@ -154,10 +173,11 @@ float analyticTrailEnvelope(vec2 localUV) { void main() { bool enhancedPolicy = - uParticleReconstructionMode != 0 || - uParticleAlphaMode != 0 || - (uParticleTrailMode != 0 && uMotionBlur != 0) || - uParticleDiagnosticMode != 0 || + 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; @@ -198,7 +218,7 @@ void main() { ivec2 textureDimensions; enhancedAtlasUV(enhancedUV, frameMinUV, frameMaxUV, textureDimensions); - vec4 mainTexSample = uParticleReconstructionMode == 1 + vec4 mainTexSample = uParticleReconstructionMode == PARTICLE_RECONSTRUCTION_CUBIC ? sampleCubicAtlas( enhancedUV, frameMinUV, @@ -209,18 +229,35 @@ void main() { float mainTexAlpha; decodeParticleSample(mainTexSample, mainTexColor, mainTexAlpha); - float trailCore = analyticTrailEnvelope(fragUV1); - mainTexAlpha += trailCore * mainTexAlpha * (1.0 - 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); + } + + float trailCore = analyticParticleCoreEnvelope(fragUV1); + if (trailCore > 0.0) { + mainTexAlpha = max(mainTexAlpha, trailCore); + mainTexColor = mix(mainTexColor, vec3(1.0), trailCore); + } vec3 objectColor; float objectAlpha; - if (uParticleDiagnosticMode == 1) { + if (uParticleDiagnosticMode == PARTICLE_DIAGNOSTIC_TEXTURE) { objectColor = mainTexColor; objectAlpha = mainTexAlpha; - } else if (uParticleDiagnosticMode == 2) { + } else if (uParticleDiagnosticMode == PARTICLE_DIAGNOSTIC_ALPHA) { objectColor = vec3(mainTexAlpha); objectAlpha = 1.0; - } else if (uParticleDiagnosticMode == 3) { + } else if (uParticleDiagnosticMode == PARTICLE_DIAGNOSTIC_VERTEX) { objectColor = uParticles[fragInstanceID].color.rgb; objectAlpha = uParticles[fragInstanceID].color.a; } else { diff --git a/glsl/u_particles.glsl b/glsl/u_particles.glsl index 12c3d9dca..e88515b71 100644 --- a/glsl/u_particles.glsl +++ b/glsl/u_particles.glsl @@ -17,6 +17,7 @@ layout(std140) uniform Particles { 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 ca081e10e..bcdab0d11 100644 --- a/include/reone/game/effect/visual.h +++ b/include/reone/game/effect/visual.h @@ -33,9 +33,11 @@ namespace game { class ServicesView; struct VisualEffectDesc; -scene::ParticleRenderProfile particleRenderProfileForVisualEffect( +bool particleRenderProfileForVisualEffect( resource::GameID gameId, - const VisualEffectDesc &desc); + uint32_t visualEffectId, + const VisualEffectDesc &desc, + scene::ParticleRenderProfile &profile); class VisualEffect : public Effect { public: 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 3c3c40287..0bff845e4 100644 --- a/include/reone/graphics/uniforms.h +++ b/include/reone/graphics/uniforms.h @@ -163,6 +163,7 @@ struct ParticleUniforms { 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 a9ff01ac0..04df04863 100644 --- a/include/reone/scene/node/emitter.h +++ b/include/reone/scene/node/emitter.h @@ -51,8 +51,20 @@ struct ParticleRenderProfile { class EmitterSceneNode : public ModelNodeSceneNode { public: + struct AnimationTimeSpan { + float startTime {0.0f}; + float endTime {0.0f}; + size_t repetitions {1}; + }; + + struct BirthrateStep { + float birthCount {0.0f}; + bool resetAccumulator {false}; + }; + struct AnimationState { std::optional birthrate; + std::optional> birthrateStepsForUpdate; std::optional lifeExpectancy; std::optional xSize; std::optional ySize; @@ -106,6 +118,11 @@ 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; } @@ -114,6 +131,7 @@ class EmitterSceneNode : public ModelNodeSceneNode { 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; } @@ -158,6 +176,7 @@ class EmitterSceneNode : public ModelNodeSceneNode { ParticleRenderProfile _renderProfile; float _birthAccumulator {0.0f}; + std::optional> _birthrateStepsForUpdate; Timer _birthTimer; bool _spawned {false}; int _particleCount {0}; @@ -166,7 +185,7 @@ class EmitterSceneNode : public ModelNodeSceneNode { void spawnParticles(float dt); void removeExpiredParticles(float dt); - void doSpawnParticle(); + bool doSpawnParticle(); void spawnLightningParticles(); ParticleSceneNode *takeParticle(); bool isSpawningSuppressed() const; diff --git a/include/reone/scene/node/model.h b/include/reone/scene/node/model.h index bc40291cc..a4bd97887 100644 --- a/include/reone/scene/node/model.h +++ b/include/reone/scene/node/model.h @@ -72,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 */ @@ -173,6 +176,7 @@ class ModelSceneNode : public SceneNode { std::deque _animChannels; AnimationBlendMode _animBlendMode {AnimationBlendMode::Single}; + std::vector _pendingAnimationEvents; // END Animation @@ -189,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 index 05c151acb..4cfe08143 100644 --- a/include/reone/scene/particleutil.h +++ b/include/reone/scene/particleutil.h @@ -30,7 +30,7 @@ namespace scene { namespace particleutil { constexpr float kMaxContinuousParticleDelta = 0.25f; -constexpr int kMaxEmitterParticles = 256; +constexpr int kMaxSpawnParticlesPerUpdate = 256; struct MotionBlurBasis { glm::vec3 right {0.0f}; @@ -74,7 +74,9 @@ DecodedParticleSample decodeParticleSample( bool lightenBlend, float alphaExponent); -float analyticTrailEnvelope( +float enhanceParticleCoverage(float alpha, float contrast); + +float analyticParticleCoreEnvelope( const glm::vec2 &localUV, bool motionBlur, float intensity); diff --git a/include/reone/scene/render/pass.h b/include/reone/scene/render/pass.h index f9cfe8583..cba1f0e36 100644 --- a/include/reone/scene/render/pass.h +++ b/include/reone/scene/render/pass.h @@ -90,6 +90,7 @@ struct ParticleRenderPolicy { float reconstructionStrength {0.0f}; float alphaExponent {1.0f}; float trailCoreIntensity {0.0f}; + float coverageContrast {0.0f}; }; struct ParticleInstance { @@ -116,6 +117,7 @@ inline void populateParticleUniforms( 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]; diff --git a/src/libs/game/effect/visual.cpp b/src/libs/game/effect/visual.cpp index 2f6c82044..6492b6ebf 100644 --- a/src/libs/game/effect/visual.cpp +++ b/src/libs/game/effect/visual.cpp @@ -89,7 +89,14 @@ void VisualEffect::applyTo(Object &object) { _node = graph->newModel(*_desc->impRootMNode, scene::ModelUsage::Projectile); graph->addRoot(_node); _node->setLocalTransform(glm::translate(_location.value())); - _node->setParticleRenderProfile(particleRenderProfileForVisualEffect(_gameId, *_desc)); + 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 index 9ab6d2842..fe3ba9363 100644 --- a/src/libs/game/effect/visualprofile.cpp +++ b/src/libs/game/effect/visualprofile.cpp @@ -25,103 +25,247 @@ namespace reone { namespace game { -static scene::ParticleRenderProfile grenadeProfile( - float largeParticleScale, - float worldZScale, - float motionLengthScale, - float motionMaxWidth, - float motionMaxLength, - float reconstructionStrength, - float alphaExponent, - float trailCoreIntensity) { - +static scene::ParticleRenderProfile baseGrenadeProfile() { scene::ParticleRenderProfile profile; - profile.largeParticleScale = largeParticleScale; - profile.worldZScale = worldZScale; - profile.motionLengthScale = motionLengthScale; - profile.motionMaxWidth = motionMaxWidth; - profile.motionMaxLength = motionMaxLength; profile.policy.reconstruction = scene::ParticleReconstruction::Cubic; profile.policy.alpha = scene::ParticleAlphaMode::AlphaAndLuminance; profile.policy.trail = scene::ParticleTrailMode::AnalyticCore; - profile.policy.reconstructionStrength = reconstructionStrength; - profile.policy.alphaExponent = alphaExponent; - profile.policy.trailCoreIntensity = trailCoreIntensity; + profile.policy.coverageContrast = 0.55f; return profile; } static scene::ParticleRenderProfile fragmentationGrenadeProfile() { - return grenadeProfile(0.85f, 0.80f, 0.75f, 0.18f, 1.20f, 0.68f, 1.08f, 0.04f); + auto profile = baseGrenadeProfile(); + profile.largeParticleScale = 0.82f; + profile.worldZScale = 0.78f; + profile.opacity = 0.80f; + profile.worldZOpacity = 0.85f; + profile.motionOpacity = 0.55f; + profile.motionLengthScale = 0.70f; + profile.motionMaxWidth = 0.16f; + profile.motionMaxLength = 1.10f; + profile.policy.reconstructionStrength = 0.70f; + profile.policy.alphaExponent = 1.20f; + profile.policy.trailCoreIntensity = 0.04f; + return profile; } static scene::ParticleRenderProfile stunGrenadeProfile() { - return grenadeProfile(0.85f, 0.82f, 0.70f, 0.16f, 1.10f, 0.72f, 1.08f, 0.05f); + 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() { - return grenadeProfile(0.72f, 0.68f, 0.45f, 0.10f, 0.75f, 0.80f, 1.15f, 0.07f); + 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() { - return grenadeProfile(0.90f, 0.85f, 0.80f, 0.20f, 1.35f, 0.65f, 1.05f, 0.03f); + 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() { - return grenadeProfile(0.88f, 0.82f, 0.70f, 0.16f, 1.10f, 0.68f, 1.08f, 0.04f); + auto profile = baseGrenadeProfile(); + profile.largeParticleScale = 0.80f; + profile.worldZScale = 0.76f; + profile.opacity = 0.70f; + profile.worldZOpacity = 0.74f; + profile.motionOpacity = 0.45f; + profile.motionLengthScale = 0.58f; + profile.motionMaxWidth = 0.14f; + profile.motionMaxLength = 0.92f; + profile.policy.reconstructionStrength = 0.72f; + profile.policy.alphaExponent = 1.35f; + profile.policy.trailCoreIntensity = 0.04f; + return profile; } static scene::ParticleRenderProfile adhesiveGrenadeProfile() { - return grenadeProfile(0.90f, 0.85f, 0.75f, 0.18f, 1.20f, 0.65f, 1.05f, 0.03f); + auto profile = baseGrenadeProfile(); + profile.largeParticleScale = 0.86f; + profile.worldZScale = 0.82f; + profile.opacity = 0.78f; + profile.worldZOpacity = 0.82f; + profile.motionOpacity = 0.52f; + profile.motionLengthScale = 0.68f; + profile.motionMaxWidth = 0.16f; + profile.motionMaxLength = 1.08f; + profile.policy.reconstructionStrength = 0.68f; + profile.policy.alphaExponent = 1.18f; + profile.policy.trailCoreIntensity = 0.03f; + return profile; } static scene::ParticleRenderProfile cryobanGrenadeProfile() { - return grenadeProfile(0.85f, 0.80f, 0.65f, 0.15f, 1.00f, 0.70f, 1.08f, 0.04f); + auto profile = baseGrenadeProfile(); + profile.largeParticleScale = 0.76f; + profile.worldZScale = 0.72f; + profile.opacity = 0.68f; + profile.worldZOpacity = 0.72f; + profile.motionOpacity = 0.42f; + profile.motionLengthScale = 0.55f; + profile.motionMaxWidth = 0.13f; + profile.motionMaxLength = 0.85f; + profile.policy.reconstructionStrength = 0.74f; + profile.policy.alphaExponent = 1.40f; + profile.policy.trailCoreIntensity = 0.04f; + return profile; } static scene::ParticleRenderProfile plasmaGrenadeProfile() { - return grenadeProfile(0.75f, 0.70f, 0.45f, 0.10f, 0.70f, 0.82f, 1.15f, 0.07f); + 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() { - return grenadeProfile(0.74f, 0.72f, 0.42f, 0.09f, 0.65f, 0.85f, 1.12f, 0.08f); + 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; } -scene::ParticleRenderProfile particleRenderProfileForVisualEffect( +bool particleRenderProfileForVisualEffect( resource::GameID gameId, - const VisualEffectDesc &desc) { + uint32_t visualEffectId, + const VisualEffectDesc &desc, + scene::ParticleRenderProfile &profile) { - if (gameId != resource::GameID::KotOR || !desc.impRootMNode) { - return {}; - } - const auto &impactModel = desc.impRootMNode->name(); - if (desc.label == "VFX_FNF_GRENADE_FRAGMENTATION" && impactModel == "v_grnfrag_fnf") { - return fragmentationGrenadeProfile(); - } - if (desc.label == "VFX_FNF_GRENADE_STUN" && impactModel == "v_grnstun_fnf") { - return stunGrenadeProfile(); - } - if (desc.label == "VFX_FNF_GRENADE_THERMAL_DETONATOR" && impactModel == "v_grndeto_fnf") { - return thermalDetonatorProfile(); - } - if (desc.label == "VFX_FNF_GRENADE_POISON" && impactModel == "v_grnpois_fnf") { - return poisonGrenadeProfile(); + if (gameId != resource::GameID::KotOR) { + return false; } - if (desc.label == "VFX_FNF_GRENADE_SONIC" && impactModel == "v_grnsonc_fnf") { - return sonicGrenadeProfile(); - } - if (desc.label == "VFX_FNF_GRENADE_ADHESIVE" && impactModel == "v_grnadhs_fnf") { - return adhesiveGrenadeProfile(); - } - if (desc.label == "VFX_FNF_GRENADE_CRYOBAN" && impactModel == "v_grncryo_fnf") { - return cryobanGrenadeProfile(); - } - if (desc.label == "VFX_FNF_GRENADE_PLASMA" && impactModel == "v_grnplas_fnf") { - return plasmaGrenadeProfile(); - } - if (desc.label == "VFX_FNF_GRENADE_ION" && impactModel == "v_grnion_fnf") { - return ionGrenadeProfile(); + + 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 {}; + return false; } } // namespace game 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/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 9b11d2a92..99116bc7d 100644 --- a/src/libs/scene/node/emitter.cpp +++ b/src/libs/scene/node/emitter.cpp @@ -35,12 +35,159 @@ using namespace reone::graphics; +namespace { + +float integratePositiveLinearSegment(float leftValue, float rightValue, float duration) { + if (duration <= 0.0f || (leftValue <= 0.0f && rightValue <= 0.0f)) { + return 0.0f; + } + if (leftValue >= 0.0f && rightValue >= 0.0f) { + return 0.5f * (leftValue + rightValue) * duration; + } + + float zeroFactor = -leftValue / (rightValue - leftValue); + if (leftValue > 0.0f) { + return 0.5f * leftValue * duration * zeroFactor; + } + return 0.5f * rightValue * duration * (1.0f - zeroFactor); +} + +void appendBirthrateReset( + std::vector &steps) { + + if (steps.empty() || !steps.back().resetAccumulator) { + steps.push_back({0.0f, true}); + } +} + +void appendBirthCount( + std::vector &steps, + float birthCount) { + + if (!std::isfinite(birthCount) || birthCount <= 0.0f) { + return; + } + if (!steps.empty() && !steps.back().resetAccumulator) { + steps.back().birthCount += birthCount; + } else { + steps.push_back({birthCount, 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) { + if (leftValue <= 0.0f) { + appendBirthrateReset(steps); + } + appendBirthCount( + steps, + integratePositiveLinearSegment( + leftValue, + rightValue, + rightTime - leftTime) / + playbackSpeed); + if (rightValue <= 0.0f) { + appendBirthrateReset(steps); + } + 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); +} + +int 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 0; + } + + int spawnCount = 0; + for (const auto &step : steps) { + if (step.resetAccumulator) { + accumulator = 0.0f; + continue; + } + if (!std::isfinite(step.birthCount) || step.birthCount <= 0.0f) { + continue; + } + + accumulator += step.birthCount; + if (!std::isfinite(accumulator)) { + accumulator = 0.0f; + continue; + } + + float wholeParticles = + glm::floor(accumulator + kWholeParticleEpsilon); + accumulator -= wholeParticles; + if (accumulator < 0.0f && + accumulator > -kWholeParticleEpsilon) { + accumulator = 0.0f; + } + int remainingCapacity = + reone::scene::particleutil::kMaxSpawnParticlesPerUpdate - + spawnCount; + if (remainingCapacity <= 0) { + continue; + } + if (wholeParticles >= static_cast(remainingCapacity)) { + spawnCount += remainingCapacity; + } else { + spawnCount += static_cast(wholeParticles); + } + } + return spawnCount; +} + +} // namespace + namespace reone { namespace scene { bool EmitterSceneNode::AnimationState::empty() const { return !birthrate && + !birthrateStepsForUpdate && !lifeExpectancy && !xSize && !ySize && @@ -122,12 +269,46 @@ EmitterSceneNode::AnimationState EmitterSceneNode::animationStateAt( 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 (_birthrate == 0.0f) { - _birthAccumulator = 0.0f; - } + } + if (state.birthrateStepsForUpdate) { + _birthrateStepsForUpdate = *state.birthrateStepsForUpdate; } if (state.lifeExpectancy) { _lifeExpectancy = *state.lifeExpectancy; @@ -255,6 +436,7 @@ void EmitterSceneNode::update(float dt) { } else { spawnParticles(dt); } + _birthrateStepsForUpdate.reset(); for (auto &child : _children) { if (child->type() != SceneNodeType::Particle) { @@ -309,13 +491,25 @@ void EmitterSceneNode::removeExpiredParticles(float dt) { void EmitterSceneNode::spawnParticles(float dt) { std::shared_ptr emitter(_modelNode.emitter()); switch (emitter->updateMode) { - case ModelNode::Emitter::UpdateMode::Fountain: - for (int spawnCount = particleutil::advanceSpawnAccumulator(_birthrate, dt, _birthAccumulator); + case ModelNode::Emitter::UpdateMode::Fountain: { + int spawnCount = _birthrateStepsForUpdate + ? advanceAnimatedSpawnAccumulator( + *_birthrateStepsForUpdate, + dt, + _birthAccumulator) + : particleutil::advanceSpawnAccumulator( + _birthrate, + dt, + _birthAccumulator); + for (; spawnCount > 0; --spawnCount) { - doSpawnParticle(); + if (!doSpawnParticle()) { + break; + } } break; + } case ModelNode::Emitter::UpdateMode::Single: if (!_spawned || (_children.empty() && emitter->loop)) { doSpawnParticle(); @@ -343,7 +537,7 @@ ParticleSceneNode *EmitterSceneNode::takeParticle() { int maxParticles = _modelNode.emitter()->updateMode == ModelNode::Emitter::UpdateMode::Single ? 1 - : particleutil::kMaxEmitterParticles; + : graphics::kMaxParticles; if (_particleCount >= maxParticles) { return nullptr; } @@ -353,10 +547,10 @@ ParticleSceneNode *EmitterSceneNode::takeParticle() { return particle; } -void EmitterSceneNode::doSpawnParticle() { +bool EmitterSceneNode::doSpawnParticle() { auto particle = takeParticle(); if (!particle) { - return; + return false; } particle->setLifetime(0.0f); @@ -378,6 +572,7 @@ void EmitterSceneNode::doSpawnParticle() { } addChild(*particle); + return true; } void EmitterSceneNode::spawnLightningParticles() { diff --git a/src/libs/scene/node/model.cpp b/src/libs/scene/node/model.cpp index 42c9ac146..81c978dd4 100644 --- a/src/libs/scene/node/model.cpp +++ b/src/libs/scene/node/model.cpp @@ -105,6 +105,7 @@ void ModelSceneNode::update(float dt) { } updateAnimations(dt); SceneNode::update(dt); + dispatchAnimationEvents(); } void ModelSceneNode::renderLeafs(IRenderPass &pass, const std::vector &leafs) { @@ -250,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(); @@ -287,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: @@ -318,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()); } } @@ -331,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()) { @@ -341,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); } } @@ -356,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 { @@ -365,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; @@ -437,7 +508,15 @@ void ModelSceneNode::computeAnimationStates(AnimationChannel &channel, float tim state.flags |= AnimationStateFlags::color; } if (modelNode.isEmitter()) { - state.emitter = EmitterSceneNode::animationStateAt(*animNode, time); + 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; } @@ -586,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 index b6dec07c8..ebd59305a 100644 --- a/src/libs/scene/particleutil.cpp +++ b/src/libs/scene/particleutil.cpp @@ -82,8 +82,8 @@ int advanceSpawnAccumulator(float birthrate, float dt, float &accumulator) { float wholeParticles = glm::floor(accumulator); accumulator -= wholeParticles; - if (wholeParticles >= static_cast(kMaxEmitterParticles)) { - return kMaxEmitterParticles; + if (wholeParticles >= static_cast(kMaxSpawnParticlesPerUpdate)) { + return kMaxSpawnParticlesPerUpdate; } return static_cast(wholeParticles); } @@ -171,13 +171,25 @@ DecodedParticleSample decodeParticleSample( return result; } -float analyticTrailEnvelope( +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 (!motionBlur || - !std::isfinite(localUV.x) || + if (!std::isfinite(localUV.x) || !std::isfinite(localUV.y) || !std::isfinite(intensity) || intensity <= 0.0f) { @@ -185,11 +197,18 @@ float analyticTrailEnvelope( } 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 glm::clamp(intensity, 0.0f, 1.0f) * crossSection * endTaper; + return safeIntensity * crossSection * endTaper; } float clampMotionTrailLength( diff --git a/test/game/effect/visual.cpp b/test/game/effect/visual.cpp index 5561efb9a..ba6c004d5 100644 --- a/test/game/effect/visual.cpp +++ b/test/game/effect/visual.cpp @@ -32,20 +32,21 @@ using namespace reone::scene; namespace { struct GrenadeVisual { + uint32_t id; const char *label; const char *impactModel; }; constexpr GrenadeVisual kKotorGrenadeVisuals[] { - {"VFX_FNF_GRENADE_FRAGMENTATION", "v_grnfrag_fnf"}, - {"VFX_FNF_GRENADE_STUN", "v_grnstun_fnf"}, - {"VFX_FNF_GRENADE_THERMAL_DETONATOR", "v_grndeto_fnf"}, - {"VFX_FNF_GRENADE_POISON", "v_grnpois_fnf"}, - {"VFX_FNF_GRENADE_SONIC", "v_grnsonc_fnf"}, - {"VFX_FNF_GRENADE_ADHESIVE", "v_grnadhs_fnf"}, - {"VFX_FNF_GRENADE_CRYOBAN", "v_grncryo_fnf"}, - {"VFX_FNF_GRENADE_PLASMA", "v_grnplas_fnf"}, - {"VFX_FNF_GRENADE_ION", "v_grnion_fnf"}, + {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) { @@ -61,24 +62,32 @@ VisualEffectDesc visualEffectDesc(const char *label, const char *impactModel) { return desc; } -void expectNeutral(const ParticleRenderProfile &profile) { - EXPECT_FLOAT_EQ(1.0f, profile.largeParticleScale); - EXPECT_FLOAT_EQ(1.0f, profile.worldZScale); - EXPECT_FLOAT_EQ(1.0f, profile.opacity); - EXPECT_FLOAT_EQ(1.0f, profile.worldZOpacity); - EXPECT_FLOAT_EQ(1.0f, profile.motionLengthScale); - EXPECT_EQ(std::numeric_limits::max(), profile.motionMaxWidth); - EXPECT_FLOAT_EQ(1.0f, profile.motionOpacity); - EXPECT_EQ(glm::vec3(1.0f), profile.colorTint); - EXPECT_FLOAT_EQ(1.0f, profile.colorIntensity); - EXPECT_EQ(ParticleReconstruction::Legacy, profile.policy.reconstruction); - EXPECT_EQ(ParticleAlphaMode::Legacy, profile.policy.alpha); - EXPECT_EQ(ParticleTrailMode::Legacy, profile.policy.trail); - EXPECT_EQ(ParticleDiagnosticMode::Composite, profile.policy.diagnostic); - EXPECT_FLOAT_EQ(0.0f, profile.policy.reconstructionStrength); - EXPECT_FLOAT_EQ(1.0f, profile.policy.alphaExponent); - EXPECT_FLOAT_EQ(0.0f, profile.policy.trailCoreIntensity); - EXPECT_EQ(std::numeric_limits::max(), profile.motionMaxLength); +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 @@ -86,9 +95,7 @@ void expectNeutral(const ParticleRenderProfile &profile) { TEST(VisualEffectParticleProfile, should_select_all_nine_kotor_grenade_profiles) { for (const auto &entry : kKotorGrenadeVisuals) { SCOPED_TRACE(entry.label); - auto profile = particleRenderProfileForVisualEffect( - GameID::KotOR, - visualEffectDesc(entry.label, entry.impactModel)); + auto profile = selectedProfile(GameID::KotOR, entry); EXPECT_EQ(ParticleReconstruction::Cubic, profile.policy.reconstruction); EXPECT_EQ(ParticleAlphaMode::AlphaAndLuminance, profile.policy.alpha); @@ -96,33 +103,44 @@ TEST(VisualEffectParticleProfile, should_select_all_nine_kotor_grenade_profiles) 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.0f); - EXPECT_LE(profile.policy.alphaExponent, 1.25f); + EXPECT_GE(profile.policy.alphaExponent, 1.15f); + EXPECT_LE(profile.policy.alphaExponent, 1.9f); EXPECT_GT(profile.policy.trailCoreIntensity, 0.0f); - EXPECT_LE(profile.policy.trailCoreIntensity, 0.1f); + EXPECT_LE(profile.policy.trailCoreIntensity, 0.4f); + EXPECT_GE(profile.policy.coverageContrast, 0.5f); + 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_FLOAT_EQ(1.0f, profile.opacity); - EXPECT_EQ(glm::vec3(1.0f), profile.colorTint); - EXPECT_FLOAT_EQ(1.0f, profile.colorIntensity); + EXPECT_GE(profile.opacity, 0.4f); + 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_ion_plasma_and_thermal_trails_compact) { constexpr GrenadeVisual criticalVisuals[] { - {"VFX_FNF_GRENADE_THERMAL_DETONATOR", "v_grndeto_fnf"}, - {"VFX_FNF_GRENADE_PLASMA", "v_grnplas_fnf"}, - {"VFX_FNF_GRENADE_ION", "v_grnion_fnf"}, + {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 = particleRenderProfileForVisualEffect( - GameID::KotOR, - visualEffectDesc(entry.label, entry.impactModel)); + auto profile = selectedProfile(GameID::KotOR, entry); EXPECT_LE(profile.motionLengthScale, 0.5f); EXPECT_LE(profile.motionMaxWidth, 0.12f); @@ -130,23 +148,44 @@ TEST(VisualEffectParticleProfile, should_keep_ion_plasma_and_thermal_trails_comp } } -TEST(VisualEffectParticleProfile, should_return_neutral_for_unknown_labels) { - expectNeutral(particleRenderProfileForVisualEffect( +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, - visualEffectDesc("VFX_FNF_NOT_A_GRENADE", "v_grnfrag_fnf"))); + VisualEffectIds::grenadeFragmentation, + visualEffectDesc("VFX_FNF_NOT_A_GRENADE", "v_grnfrag_fnf")); } -TEST(VisualEffectParticleProfile, should_return_neutral_for_tsl_grenade_labels) { +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); - expectNeutral(particleRenderProfileForVisualEffect( + expectUnselected( GameID::TSL, - visualEffectDesc(entry.label, entry.impactModel))); + entry.id, + visualEffectDesc(entry.label, entry.impactModel)); } } -TEST(VisualEffectParticleProfile, should_return_neutral_for_mismatched_kotor_models) { - expectNeutral(particleRenderProfileForVisualEffect( +TEST(VisualEffectParticleProfile, should_not_select_mismatched_kotor_models) { + expectUnselected( GameID::KotOR, - visualEffectDesc("VFX_FNF_GRENADE_ION", "v_grnplas_fnf"))); + VisualEffectIds::grenadeIon, + visualEffectDesc("VFX_FNF_GRENADE_ION", "v_grnplas_fnf")); } diff --git a/test/scene/particleprofile.cpp b/test/scene/particleprofile.cpp index a201421ab..09a215ed8 100644 --- a/test/scene/particleprofile.cpp +++ b/test/scene/particleprofile.cpp @@ -15,6 +15,8 @@ * along with this program. If not, see . */ +#include + #include #include "reone/graphics/model.h" @@ -113,6 +115,7 @@ void expectPolicyEqual(const ParticleRenderPolicy &expected, const ParticleUnifo 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 @@ -127,6 +130,7 @@ TEST(ParticleProfile, should_default_to_legacy_single_sample_and_lighten_policy) uniforms.reconstructionStrength = 42.0f; uniforms.alphaExponent = 42.0f; uniforms.trailCoreIntensity = 42.0f; + uniforms.coverageContrast = 42.0f; populateParticleUniforms( uniforms, @@ -145,6 +149,13 @@ TEST(ParticleProfile, should_default_to_legacy_single_sample_and_lighten_policy) 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; @@ -154,6 +165,7 @@ TEST(ParticleProfile, should_pack_the_same_non_default_policy_for_retro_and_pbr) policy.reconstructionStrength = 0.75f; policy.alphaExponent = 1.25f; policy.trailCoreIntensity = 0.08f; + policy.coverageContrast = 0.75f; ParticleUniforms retroUniforms; ParticleUniforms pbrUniforms; @@ -185,6 +197,7 @@ TEST(ParticleProfile, should_reset_policy_values_for_the_next_emitter_draw) { 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), {}); diff --git a/test/scene/particleutil.cpp b/test/scene/particleutil.cpp index d39027f74..46406dc51 100644 --- a/test/scene/particleutil.cpp +++ b/test/scene/particleutil.cpp @@ -24,6 +24,7 @@ #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" @@ -44,7 +45,9 @@ class AnimatedEmitterHarness { AnimatedEmitterHarness( float initialBirthrate, float lifeExpectancy, - std::vector> animatedBirthrate = {}) { + std::vector> animatedBirthrate = {}, + std::vector animationEvents = {}, + float animationSpeed = 1.0f) { _graphicsModule.init(); _audioModule.init(); @@ -87,7 +90,7 @@ class AnimatedEmitterHarness { rootNode->addChild(emitterNode); std::vector> animations; - if (!animatedBirthrate.empty()) { + if (!animatedBirthrate.empty() || !animationEvents.empty()) { auto animationRoot = std::make_shared( 0, "root", @@ -111,7 +114,7 @@ class AnimatedEmitterHarness { 0.0f, "root", animationRoot, - std::vector())); + std::move(animationEvents))); } _model = std::make_unique( @@ -129,11 +132,14 @@ class AnimatedEmitterHarness { _audioModule.services(), _resourceModule.services()); _modelSceneNode->init(); - if (!animatedBirthrate.empty()) { + if (!animations.empty()) { + auto properties = + AnimationProperties::fromFlags(AnimationFlags::loop); + properties.speed = animationSpeed; _modelSceneNode->playAnimation( "pulse", nullptr, - AnimationProperties::fromFlags(AnimationFlags::loop)); + properties); } } @@ -282,7 +288,9 @@ TEST(ParticleUtil, should_drop_spawn_catch_up_after_a_discontinuous_frame) { TEST(ParticleUtil, should_cap_spawn_work_without_carrying_integer_debt) { float accumulator = 0.0f; - EXPECT_EQ(256, particleutil::advanceSpawnAccumulator(1'000'000.0f, 1.0f / 60.0f, accumulator)); + 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); } @@ -388,23 +396,35 @@ TEST(ParticleUtil, should_preserve_authored_alpha_for_non_lighten_particles) { EXPECT_NEAR(authored.a, decoded.alpha, 1e-6f); } -TEST(ParticleUtil, should_bound_the_analytic_motion_trail_core) { +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::analyticTrailEnvelope(glm::vec2(0.5f), false, 1.0f)); + particleutil::analyticParticleCoreEnvelope(glm::vec2(0.0f, 0.5f), false, 1.0f)); EXPECT_EQ( 0.0f, - particleutil::analyticTrailEnvelope(glm::vec2(0.0f, 0.5f), true, 1.0f)); + particleutil::analyticParticleCoreEnvelope(glm::vec2(0.0f, 0.5f), true, 1.0f)); EXPECT_NEAR( 0.8f, - particleutil::analyticTrailEnvelope(glm::vec2(0.5f), true, 0.8f), + particleutil::analyticParticleCoreEnvelope(glm::vec2(0.5f), true, 0.8f), 1e-6f); EXPECT_LE( - particleutil::analyticTrailEnvelope(glm::vec2(0.5f), true, 10.0f), + particleutil::analyticParticleCoreEnvelope(glm::vec2(0.5f), true, 10.0f), 1.0f); EXPECT_NEAR( 1e-7f, - particleutil::analyticTrailEnvelope(glm::vec2(0.5f), true, 1e-7f), + particleutil::analyticParticleCoreEnvelope(glm::vec2(0.5f), true, 1e-7f), 1e-10f); } @@ -453,13 +473,157 @@ TEST(ParticleUtil, should_apply_emitter_controllers_before_spawning_in_the_same_ AnimatedEmitterHarness harness( 0.0f, 1.0f, - {{0.0f, 0.0f}, {0.1f, 10.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, 0.0f}, {0.1f, 20.0f}}); + AnimatedEmitterHarness partitionedUpdate( + 0.0f, + 1.0f, + {{0.0f, 0.0f}, {0.1f, 20.0f}}); + + singleUpdate.model().update(0.1f); + partitionedUpdate.model().update(0.05f); + partitionedUpdate.model().update(0.05f); + + EXPECT_EQ(1u, singleUpdate.emitter().children().size()); + EXPECT_EQ( + singleUpdate.emitter().children().size(), + partitionedUpdate.emitter().children().size()); +} + +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, @@ -481,18 +645,35 @@ TEST(ParticleUtil, should_advance_culled_animation_without_hidden_particles_or_s EXPECT_EQ(1u, harness.emitter().children().size()); } +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(256u, harness.emitter().children().size()); + 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(256u, harness.emitter().children().size()); + ASSERT_EQ(static_cast(kMaxParticles), harness.emitter().children().size()); for (auto *particle : harness.emitter().children()) { EXPECT_EQ(1u, firstGeneration.count(particle)); } From f718d98c649dad97a968d713d6dc760dcdbceed3 Mon Sep 17 00:00:00 2001 From: CrispyW0nton Date: Fri, 31 Jul 2026 23:17:15 -0700 Subject: [PATCH 4/5] [scene] Keep particle births frame-stable Age fountain particles from their actual birth time so long and partitioned frames produce the same visual state. Consume culled non-looping one-shot emitters without preventing looping emitters from resuming. --- include/reone/scene/node/emitter.h | 6 +- include/reone/scene/particleutil.h | 10 ++ src/libs/scene/node/emitter.cpp | 229 +++++++++++++++++++---------- src/libs/scene/particleutil.cpp | 41 ++++-- test/scene/particleutil.cpp | 84 ++++++++++- 5 files changed, 272 insertions(+), 98 deletions(-) diff --git a/include/reone/scene/node/emitter.h b/include/reone/scene/node/emitter.h index 04df04863..81173c750 100644 --- a/include/reone/scene/node/emitter.h +++ b/include/reone/scene/node/emitter.h @@ -58,7 +58,9 @@ class EmitterSceneNode : public ModelNodeSceneNode { }; struct BirthrateStep { - float birthCount {0.0f}; + float startRate {0.0f}; + float endRate {0.0f}; + float duration {0.0f}; bool resetAccumulator {false}; }; @@ -185,7 +187,7 @@ class EmitterSceneNode : public ModelNodeSceneNode { void spawnParticles(float dt); void removeExpiredParticles(float dt); - bool doSpawnParticle(); + bool doSpawnParticle(float initialAge = 0.0f); void spawnLightningParticles(); ParticleSceneNode *takeParticle(); bool isSpawningSuppressed() const; diff --git a/include/reone/scene/particleutil.h b/include/reone/scene/particleutil.h index 4cfe08143..a9e66674d 100644 --- a/include/reone/scene/particleutil.h +++ b/include/reone/scene/particleutil.h @@ -32,6 +32,11 @@ 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}; @@ -59,6 +64,11 @@ MotionBlurBasis buildMotionBlurBasis( 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, diff --git a/src/libs/scene/node/emitter.cpp b/src/libs/scene/node/emitter.cpp index 99116bc7d..ce896e155 100644 --- a/src/libs/scene/node/emitter.cpp +++ b/src/libs/scene/node/emitter.cpp @@ -37,41 +37,34 @@ using namespace reone::graphics; namespace { -float integratePositiveLinearSegment(float leftValue, float rightValue, float duration) { - if (duration <= 0.0f || (leftValue <= 0.0f && rightValue <= 0.0f)) { - return 0.0f; - } - if (leftValue >= 0.0f && rightValue >= 0.0f) { - return 0.5f * (leftValue + rightValue) * duration; - } - - float zeroFactor = -leftValue / (rightValue - leftValue); - if (leftValue > 0.0f) { - return 0.5f * leftValue * duration * zeroFactor; - } - return 0.5f * rightValue * duration * (1.0f - zeroFactor); -} - void appendBirthrateReset( - std::vector &steps) { + std::vector &steps, + float duration = 0.0f) { if (steps.empty() || !steps.back().resetAccumulator) { - steps.push_back({0.0f, true}); + steps.push_back({0.0f, 0.0f, duration, true}); + } else { + steps.back().duration += duration; } } -void appendBirthCount( +void appendPositiveBirthrateSegment( std::vector &steps, - float birthCount) { - - if (!std::isfinite(birthCount) || birthCount <= 0.0f) { + float startRate, + float endRate, + float duration) { + + if (!std::isfinite(startRate) || + !std::isfinite(endRate) || + !std::isfinite(duration) || + duration <= 0.0f) { return; } - if (!steps.empty() && !steps.back().resetAccumulator) { - steps.back().birthCount += birthCount; - } else { - steps.push_back({birthCount, false}); - } + steps.push_back({ + glm::max(startRate, 0.0f), + glm::max(endRate, 0.0f), + duration, + false}); } void appendBirthrateTrackSpan( @@ -94,18 +87,40 @@ void appendBirthrateTrackSpan( } auto appendSegment = [&](float rightTime, float rightValue) { - if (leftValue <= 0.0f) { - appendBirthrateReset(steps); - } - appendBirthCount( - steps, - integratePositiveLinearSegment( + 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, - rightTime - leftTime) / - playbackSpeed); - if (rightValue <= 0.0f) { - appendBirthrateReset(steps); + 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; @@ -126,7 +141,34 @@ void appendBirthrateTrackSpan( appendSegment(endTime, rightValue); } -int advanceAnimatedSpawnAccumulator( +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) { @@ -138,45 +180,67 @@ int advanceAnimatedSpawnAccumulator( dt <= 0.0f || dt > reone::scene::particleutil::kMaxContinuousParticleDelta) { accumulator = 0.0f; - return 0; + return {}; } - int spawnCount = 0; + 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.birthCount) || step.birthCount <= 0.0f) { + if (!std::isfinite(step.startRate) || + !std::isfinite(step.endRate) || + !std::isfinite(step.duration) || + step.duration <= 0.0f) { continue; } - accumulator += step.birthCount; - if (!std::isfinite(accumulator)) { + 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; } - float wholeParticles = - glm::floor(accumulator + kWholeParticleEpsilon); - accumulator -= wholeParticles; + double wholeParticles = + glm::floor(accumulatedBirths + kWholeParticleEpsilon); + accumulator = static_cast(accumulatedBirths - wholeParticles); if (accumulator < 0.0f && accumulator > -kWholeParticleEpsilon) { accumulator = 0.0f; } - int remainingCapacity = - reone::scene::particleutil::kMaxSpawnParticlesPerUpdate - - spawnCount; - if (remainingCapacity <= 0) { - continue; - } - if (wholeParticles >= static_cast(remainingCapacity)) { - spawnCount += remainingCapacity; - } else { - spawnCount += static_cast(wholeParticles); + 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 spawnCount; + return schedule; } } // namespace @@ -431,12 +495,6 @@ void EmitterSceneNode::init() { void EmitterSceneNode::update(float dt) { removeExpiredParticles(dt); - if (isSpawningSuppressed()) { - discardSpawnTime(dt); - } else { - spawnParticles(dt); - } - _birthrateStepsForUpdate.reset(); for (auto &child : _children) { if (child->type() != SceneNodeType::Particle) { @@ -445,6 +503,13 @@ void EmitterSceneNode::update(float dt) { auto particle = static_cast(child); particle->update(dt); } + + if (isSpawningSuppressed()) { + discardSpawnTime(dt); + } else { + spawnParticles(dt); + } + _birthrateStepsForUpdate.reset(); } bool EmitterSceneNode::isSpawningSuppressed() const { @@ -459,7 +524,14 @@ bool EmitterSceneNode::isSpawningSuppressed() const { void EmitterSceneNode::discardSpawnTime(float dt) { _birthAccumulator = 0.0f; - if (_modelNode.emitter()->updateMode != ModelNode::Emitter::UpdateMode::Lightning) { + 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); @@ -492,19 +564,17 @@ void EmitterSceneNode::spawnParticles(float dt) { std::shared_ptr emitter(_modelNode.emitter()); switch (emitter->updateMode) { case ModelNode::Emitter::UpdateMode::Fountain: { - int spawnCount = _birthrateStepsForUpdate - ? advanceAnimatedSpawnAccumulator( - *_birthrateStepsForUpdate, - dt, - _birthAccumulator) - : particleutil::advanceSpawnAccumulator( - _birthrate, - dt, - _birthAccumulator); - for (; - spawnCount > 0; - --spawnCount) { - if (!doSpawnParticle()) { + 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; } } @@ -512,7 +582,7 @@ void EmitterSceneNode::spawnParticles(float dt) { } case ModelNode::Emitter::UpdateMode::Single: if (!_spawned || (_children.empty() && emitter->loop)) { - doSpawnParticle(); + doSpawnParticle(dt); _spawned = true; } break; @@ -547,7 +617,7 @@ ParticleSceneNode *EmitterSceneNode::takeParticle() { return particle; } -bool EmitterSceneNode::doSpawnParticle() { +bool EmitterSceneNode::doSpawnParticle(float initialAge) { auto particle = takeParticle(); if (!particle) { return false; @@ -572,6 +642,9 @@ bool EmitterSceneNode::doSpawnParticle() { } addChild(*particle); + if (initialAge > 0.0f) { + particle->update(initialAge); + } return true; } diff --git a/src/libs/scene/particleutil.cpp b/src/libs/scene/particleutil.cpp index ebd59305a..b9e896753 100644 --- a/src/libs/scene/particleutil.cpp +++ b/src/libs/scene/particleutil.cpp @@ -58,34 +58,51 @@ MotionBlurBasis buildMotionBlurBasis( return {right, up, 1.0f + trailLength / particleLength}; } -int advanceSpawnAccumulator(float birthrate, float dt, float &accumulator) { +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 0; + return schedule; } if (dt <= 0.0f) { - return 0; + return schedule; } if (dt > kMaxContinuousParticleDelta) { accumulator = 0.0f; - return 0; + return schedule; } - accumulator += birthrate * dt; - if (!std::isfinite(accumulator)) { + float previousAccumulator = glm::clamp(accumulator, 0.0f, 1.0f); + float accumulatedBirths = previousAccumulator + birthrate * dt; + if (!std::isfinite(accumulatedBirths)) { accumulator = 0.0f; - return 0; + return schedule; } - float wholeParticles = glm::floor(accumulator); - accumulator -= wholeParticles; - if (wholeParticles >= static_cast(kMaxSpawnParticlesPerUpdate)) { - return kMaxSpawnParticlesPerUpdate; + 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 static_cast(wholeParticles); + return schedule; +} + +int advanceSpawnAccumulator(float birthrate, float dt, float &accumulator) { + return advanceSpawnSchedule(birthrate, dt, accumulator).count; } AtlasFrameBounds atlasFrameBounds( diff --git a/test/scene/particleutil.cpp b/test/scene/particleutil.cpp index 46406dc51..b4833ca92 100644 --- a/test/scene/particleutil.cpp +++ b/test/scene/particleutil.cpp @@ -47,7 +47,10 @@ class AnimatedEmitterHarness { float lifeExpectancy, std::vector> animatedBirthrate = {}, std::vector animationEvents = {}, - float animationSpeed = 1.0f) { + float animationSpeed = 1.0f, + ModelNode::Emitter::UpdateMode updateMode = + ModelNode::Emitter::UpdateMode::Fountain, + bool loop = false) { _graphicsModule.init(); _audioModule.init(); @@ -74,7 +77,8 @@ class AnimatedEmitterHarness { true, rootNode.get()); auto emitter = std::make_shared(); - emitter->updateMode = ModelNode::Emitter::UpdateMode::Fountain; + 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); @@ -271,6 +275,19 @@ TEST(ParticleUtil, should_spawn_at_the_same_rate_across_frame_rates) { 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; @@ -484,20 +501,36 @@ TEST(ParticleUtil, should_integrate_animated_birthrate_across_frame_partitions) AnimatedEmitterHarness singleUpdate( 0.0f, 1.0f, - {{0.0f, 0.0f}, {0.1f, 20.0f}}); + {{0.0f, 10.0f}, {0.1f, 30.0f}}); AnimatedEmitterHarness partitionedUpdate( 0.0f, 1.0f, - {{0.0f, 0.0f}, {0.1f, 20.0f}}); + {{0.0f, 10.0f}, {0.1f, 30.0f}}); singleUpdate.model().update(0.1f); partitionedUpdate.model().update(0.05f); partitionedUpdate.model().update(0.05f); - EXPECT_EQ(1u, singleUpdate.emitter().children().size()); - EXPECT_EQ( + 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) { @@ -645,6 +678,45 @@ TEST(ParticleUtil, should_advance_culled_animation_without_hidden_particles_or_s 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, From a0032de42e119c868ce4f25529827a572c23019c Mon Sep 17 00:00:00 2001 From: CrispyW0nton Date: Fri, 31 Jul 2026 23:40:52 -0700 Subject: [PATCH 5/5] [game] Keep broad K1 grenade effects localized Reduce stacked haze and oversized sprites in the fragmentation, sonic, adhesive, and CryoBan profiles. Preserve their authored character with distinct tints while preventing peak frames from washing out the scene. --- src/libs/game/effect/visualprofile.cpp | 88 ++++++++++++++------------ test/game/effect/visual.cpp | 23 ++++++- 2 files changed, 68 insertions(+), 43 deletions(-) diff --git a/src/libs/game/effect/visualprofile.cpp b/src/libs/game/effect/visualprofile.cpp index fe3ba9363..7b2f1ef55 100644 --- a/src/libs/game/effect/visualprofile.cpp +++ b/src/libs/game/effect/visualprofile.cpp @@ -36,17 +36,19 @@ static scene::ParticleRenderProfile baseGrenadeProfile() { static scene::ParticleRenderProfile fragmentationGrenadeProfile() { auto profile = baseGrenadeProfile(); - profile.largeParticleScale = 0.82f; - profile.worldZScale = 0.78f; - profile.opacity = 0.80f; - profile.worldZOpacity = 0.85f; - profile.motionOpacity = 0.55f; - profile.motionLengthScale = 0.70f; - profile.motionMaxWidth = 0.16f; - profile.motionMaxLength = 1.10f; - profile.policy.reconstructionStrength = 0.70f; - profile.policy.alphaExponent = 1.20f; + 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; } @@ -103,49 +105,55 @@ static scene::ParticleRenderProfile poisonGrenadeProfile() { static scene::ParticleRenderProfile sonicGrenadeProfile() { auto profile = baseGrenadeProfile(); - profile.largeParticleScale = 0.80f; - profile.worldZScale = 0.76f; - profile.opacity = 0.70f; - profile.worldZOpacity = 0.74f; - profile.motionOpacity = 0.45f; - profile.motionLengthScale = 0.58f; - profile.motionMaxWidth = 0.14f; - profile.motionMaxLength = 0.92f; - profile.policy.reconstructionStrength = 0.72f; - profile.policy.alphaExponent = 1.35f; + 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.86f; - profile.worldZScale = 0.82f; - profile.opacity = 0.78f; - profile.worldZOpacity = 0.82f; - profile.motionOpacity = 0.52f; - profile.motionLengthScale = 0.68f; - profile.motionMaxWidth = 0.16f; - profile.motionMaxLength = 1.08f; - profile.policy.reconstructionStrength = 0.68f; - profile.policy.alphaExponent = 1.18f; + 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.76f; - profile.worldZScale = 0.72f; - profile.opacity = 0.68f; - profile.worldZOpacity = 0.72f; - profile.motionOpacity = 0.42f; - profile.motionLengthScale = 0.55f; - profile.motionMaxWidth = 0.13f; - profile.motionMaxLength = 0.85f; - profile.policy.reconstructionStrength = 0.74f; - profile.policy.alphaExponent = 1.40f; + 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; } diff --git a/test/game/effect/visual.cpp b/test/game/effect/visual.cpp index ba6c004d5..d630a72b8 100644 --- a/test/game/effect/visual.cpp +++ b/test/game/effect/visual.cpp @@ -104,17 +104,17 @@ TEST(VisualEffectParticleProfile, should_select_all_nine_kotor_grenade_profiles) 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, 1.9f); + 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.5f); + 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.4f); + EXPECT_GE(profile.opacity, 0.3f); EXPECT_LE(profile.opacity, 0.85f); EXPECT_GE(profile.worldZOpacity, profile.opacity); EXPECT_LE(profile.worldZOpacity, 0.9f); @@ -131,6 +131,23 @@ TEST(VisualEffectParticleProfile, should_select_all_nine_kotor_grenade_profiles) } } +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"},