tweak(particlesys): Decouple Particles render update from logic step - #2709
tweak(particlesys): Decouple Particles render update from logic step#2709xezon wants to merge 6 commits into
Conversation
|
| Filename | Overview |
|---|---|
| Core/GameEngine/Source/GameClient/System/ParticleSys.cpp | Splits particle logic and render updates, adds frame-rate-aware integration and keyframe validation, and preserves legacy save fields where required. |
| Core/GameEngine/Source/Common/FramePacer.cpp | Tracks fractional progress through the current logic frame for render interpolation. |
| Core/GameEngine/Include/GameClient/ParticleSys.h | Extends particle and manager interfaces with render-update, validation, and timing support. |
| Generals/Code/GameEngineDevice/Source/W3DDevice/GameClient/W3DDisplay.cpp | Runs the particle render update from the Generals display path. |
| GeneralsMD/Code/GameEngineDevice/Source/W3DDevice/GameClient/W3DDisplay.cpp | Separates the logic-gated particle update from the per-render particle draw update in Zero Hour. |
| Core/Libraries/Include/Lib/BaseType.h | Adds value-safe arithmetic operators for RGBColor; the prior dangling-reference defect is fixed. |
| Generals/Code/GameEngine/Source/Common/GameEngine.cpp | Signals the frame pacer after each new Generals logic frame. |
| GeneralsMD/Code/GameEngine/Source/Common/GameEngine.cpp | Signals the frame pacer after each new Zero Hour logic frame. |
Flowchart
%%{init: {'theme': 'neutral'}}%%
flowchart LR
Logic[GameLogic frame] --> Phase[Reset logic-frame phase]
Logic --> ParticleUpdate[Particle logic update]
ParticleUpdate --> Lifetime[Lifetime and keyframe progression]
ParticleUpdate --> Emission[Particle emission and attachments]
Render[Display frame] --> ParticleDraw[Particle render update]
Phase --> ParticleDraw
ParticleDraw --> Motion[Motion, damping, gravity, and wind]
ParticleDraw --> Appearance[Alpha, color, and size]
Motion --> W3D[W3D particle rendering]
Appearance --> W3D
Reviews (11): Last reviewed commit: "tweak(particlesys): Decouple Particles r..." | Re-trigger Greptile
|
I presume this PR fixes #2467. |
| psys->attachToObject(building); | ||
| Drawable *drawable = building->getDrawable(); | ||
| psys->attachToObject(object); | ||
| Drawable *drawable = object->getDrawable(); |
There was a problem hiding this comment.
This rename was just a side quest from when looking around particle things.
| ParticleSystemInfo::WindMotion windMotion = m_system->getWindMotion(); | ||
| // monitor lifetime | ||
| if (m_lifetimeLeft && --m_lifetimeLeft == 0) | ||
| return false; |
There was a problem hiding this comment.
I moved the lifetime check from the bottom of the update to the very top, because there is no reason to go through all the trouble of updating the particle when the lifetime hits zero anyway. So this is a very minor performance improvement maybe.
b94750d to
b86a504
Compare
|
The plane trails do not fade out gracefully. That needs looking into. |
|
The changes in this PR don't seem remotely retail compatible. Like many other replays, this PR makes GR1 mismatch with headless mode for me. The mismatch happens at frame 14227, but only with headless mode. It looks like the CI replay checker is silently broken. |
b86a504 to
278f319
Compare
|
I will break the dummy particle manager changes off of this pull. |
|
ParticleSystemManagerDummy changes split off to #2740 |
|
This still needs work. Plane trail issue I have locally fixed but there was another issue that I already forgot. Maybe need to watch old videos. Edit: I remember it was plane exhaust and low frame rate (USA 02 mission intro) |
Would have liked this PR in draft then, so I didn't spend time reviewing it. |
|
@greptileai Particle definition: |
|
The root cause is the interaction between the decoupled logic/render updates, the initial black color of JetLenzflare particles, and the Why it happens: JetLenzflare uses With the decoupled design, At logic = 30 FPS, render = 25 FPS (or lower), the timing is:
The if (frameCount == 0)
{
// Pass one full logic frame before trying to update and delete this potentially now
// invisible particle, because the later render update may fade it in and make it visible.
return true;
}But it only protects the creation frame. It does not protect against the case where render FPS < logic FPS and no The existing todo in the code already captures this: // TheSuperHackers @todo This check is shady for particles that fade in first. A more robust logic would be good.
if (isInvisible())
return false;A robust fix would need to track whether at least one |
|
@greptileai |
|
You're right, I was wrong on both counts — the logic step can't run faster than render in this engine's design, and the Looking more carefully at the decoupling for other potential causes: one thing that stands out is that Could the issue be related to particle position or alpha/color values going out of expected range due to this rate/accumulation split, rather than the lifetime check? If you can describe what specific visual artifact you're seeing, I can narrow down the analysis. |
|
I found the hidden particles at sub 30 fps are an explicit feature in //
// Check if particle is below priorities we allow for this FPS or if it being skipped because
// all particles are being skipped (excluding special fps independent particles at
// getMinDynamicParticleSkipPriority())
//
if( priority < TheGameLODManager->getMinDynamicParticlePriority() ||
(priority < TheGameLODManager->getMinDynamicParticleSkipPriority() &&
TheGameLODManager->isParticleSkipped()) )
return nullptr; |
|
Brother @greptileai, please list potential reasons why this change could have introduced lagging-behind particles on fast moving objects or drawables. It is not observable with all particles, but is 100% with JetLenzflare in a cutscene, that runs at 22 fps. The effect(s): |
|
Here are the potential root causes, ranked by likelihood: 1. Spawn position lag due to update-order change (most likely) In the old code, Now This was literally the reason Lorenzen & Wilczynski originally moved particle update into 2. Bone/animated transform vs. logical object position The particle system is attached to a drawable whose bone positions are resolved during the W3D client pipeline, not during game logic. 3.
4. Multiple logic steps between render frames are not compensated At 22fps render / 30fps logic, there will be render frames where 1 logic step fires and others where 2 fire before the next 5.
In short: The most actionable cause is #1 — particles now spawn with one client-update-cycle of position lag that the original design deliberately avoided by running |
PR Summary by QodoDecouple particle rendering from fixed logic updates
AI Description
Diagram
High-Level Assessment
Files changed (10)
|
|
After several review rounds with Claude Opus I have now updated and polished this change further. It is ready for human re-review. |
Code Review by Qodo
1.
|
| { | ||
| m_alpha = m_alphaKey[ m_alphaTargetKey ].value; | ||
| m_alphaTargetKey++; | ||
| computeAlphaRate(); |
There was a problem hiding this comment.
Was removing the alpha key snap intentional? Retail sets m_alpha to the reached key value before computing the next rate. computeAlphaRate() is key-to-key, so any render-step drift now carries into later segments.
There was a problem hiding this comment.
I think it was intentional but I also cannot remember all the details anymore. I was unable to observe bugs from it.
There was a problem hiding this comment.
I dug into it more. This is a one-frame phase lead, not drift. We could restore m_alpha = m_alphaKey[m_alphaTargetKey].value before incrementing the target, then have draw() skip alpha integration when m_alphaTargetKey > 1 && frameCount == m_alphaKey[m_alphaTargetKey - 1].frame.
frameCount can be derived from TheGameClient->getFrame() - m_createTimestamp. This holds the snapped value for the boundary frame and starts the new rate on the next frame without adding state.
I don't think this is materially noticeable in normal play. The fix would be more retail-compatible, but I'm happy to approve either way.
There was a problem hiding this comment.
I am not convinced the above observations are correct. I remember I extensively tested this and the + KeyFrameDelay on one of these advances and not on the other already correctly migrated it.
If there are observable issues with the code as presented, please say with which particle to observe this with.
There was a problem hiding this comment.
I measured this with ToxinSpray in three builds: the base, this branch, and this branch with the snap restored:
age base head head+snap
4 0.312500 0.312500 0.312500
5 0.250000 0.305357 0.242857
20 0.142857 0.198214 0.135714
38 0.014286 0.069643 0.007143
The missing snap leaves alpha 0.055357 above the base for the remaining 34 frames. Restoring it removes that persistent offset; the remaining 0.007143 is the next rate being applied in draw() on the boundary frame, which the skip described above handles.
The same alpha keys are used by ToxinBuildingClearSpray, ToxinUpgradedBuildingClearSpray, AnthraxSpray, AnthraxGammaSpray and ScudStormBuildingGoo. The KeyFrameDelay comment exempts alpha because retail overwrote the accumulated value at the key—the line removed here.
There was a problem hiding this comment.
Ok. I did a long chat session with Claude Opus about this and we settled on a solution that moves the alpha frame computation entirely to the render update, which then solves this problem. The Particle class loses its m_alphaRate member, but is preserved for RETAIL_COMPATIBLE_XFER_SAVE. The implementations for alpha rate and color rate are now different, but it was not easily possible to do the same for the color rate and not introduce a visual divergence. Maybe it can be revisited in the future.
The logic for key frames not starting at 0 has changed for not PRESERVER_RETAIL_PARTICLES. They now legitimately hold the value before reaching the first frame instead of advancing to the first key frame and then holding its value towards the second frame. This is more true to what the INI setup says.
It's all part of the last fixup commit.
The code is only getting more complicated with every new comment added to this pull 😆
439c4c9 to
6143a7a
Compare
…rticle::update() into additional functions (#2709)
…icle::draw() (#2709)
4c0cbb0 to
07c95d1
Compare
|
Fixup commits squashed |
|
Any final words? |
|
To use Codex here, create an environment for this repo. |
|
@codex Review |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 07c95d1142
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| ParticleSystem* sys = *it++; | ||
| DEBUG_ASSERTCRASH(sys != nullptr, ("ParticleSystemManager::draw: ParticleSystem is null")); | ||
|
|
||
| sys->draw(timeScale); |
There was a problem hiding this comment.
Refresh smudge snapshots after advancing particles
When the render rate exceeds the logic rate, ParticleSystemManager::update() rebuilds each Smudge from the particle's position, size, and opacity only on logic ticks, but this new call subsequently changes those properties on every render tick. W3DParticleSystemManager::doParticles() merely marks the stored smudge for drawing, and W3DSmudge renders its stored values, so heat-distortion particles remain at the logic rate and visibly lag the corresponding particle motion. Synchronize the smudge properties after the render-step particle updates.
Useful? React with 👍 / 👎.
| // TheSuperHackers @info The acceleration pushes the position for only half of the step it is added | ||
| // in, on average, whereas the original single step integration pushes it for the whole step. This | ||
| // gain interpolates between both and remains the original value at time scale 1. | ||
| const Real accelGain = velGain * (1.0f + timeScale) * 0.5f; |
There was a problem hiding this comment.
Make the acceleration gain compose across render steps
When a logic frame is split across multiple render steps and gravity is nonzero, this heuristic does not reproduce the original full-step position update. For example, with VelocityDamping = 0.5, zero initial velocity, and two timeScale = 0.5 draws, the accumulated acceleration displacement is about 0.5251 * accel, whereas one timeScale = 1 draw produces 0.5 * accel; velocity composes correctly, but the trajectory still changes with render FPS. Derive the acceleration position gain from the same composable state transition instead of the half-step average.
Useful? React with 👍 / 👎.
| if (m_windMotion != ParticleSystemInfo::WIND_MOTION_NOT_USED ) | ||
| updateWindMotion(timeScale); |
There was a problem hiding this comment.
Keep wind RNG transitions on the logic update
For ping-pong wind systems, updateWindMotion() rerolls the angle change and endpoints through the shared GameClientRandomValueReal stream whenever an endpoint is crossed. Moving that call here means particle emission now consumes random values before the wind reroll instead of after it even at the original frame rate, and at higher render rates the reroll can also occur between logic emissions; subsequent particle positions, velocities, and other randomized attributes therefore change with render FPS and no longer preserve the retail sequence. Keep the RNG-consuming state transition logic-timed and interpolate only the resulting wind motion during draws.
Useful? React with 👍 / 👎.

Merge with Rebase
This change decouples the Particles render update from the logic step.
Split into 6 commits for ease of understanding and review.
TODO
Initialize particle templates with RETAIL_COMPATIBLE_CRC