Skip to content

tweak(particlesys): Decouple Particles render update from logic step - #2709

Open
xezon wants to merge 6 commits into
TheSuperHackers:mainfrom
xezon:xezon/decouple-particle-update
Open

xezon wants to merge 6 commits into
TheSuperHackers:mainfrom
xezon:xezon/decouple-particle-update

Conversation

@xezon

@xezon xezon commented May 14, 2026

Copy link
Copy Markdown

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

  • Replicate in Generals
  • Add pull ids to commit titles
  • Investigate plane trail particles
  • Initialize particle templates with RETAIL_COMPATIBLE_CRC

@xezon xezon added Enhancement Is new feature or request Major Severity: Minor < Major < Critical < Blocker Gen Relates to Generals ZH Relates to Zero Hour Rendering Is Rendering related labels May 14, 2026
@greptile-apps

greptile-apps Bot commented May 14, 2026

Copy link
Copy Markdown
Greptile Summary

This PR decouples particle logic updates from render updates. The main changes are:

  • Frame pacing now tracks render progress through the current logic frame.
  • Particle movement, color, alpha, and size integration move into a render-time draw path.
  • Particle system logic updates now complete pending render integration before changing rates.
  • Generals and Zero Hour call particle logic and draw updates from their variant-specific loops.
  • RGBColor arithmetic now supports value-returning operators used by particle color integration.
Confidence Score: 5/5

This looks safe to merge.

  • No blocking issues found in the changed code.
Important Files Changed
Filename Overview
Core/GameEngine/Source/GameClient/System/ParticleSys.cpp Splits particle logic from render integration and updates particle lifetime, visibility, transform, and draw handling.
Core/GameEngine/Source/Common/FramePacer.cpp Adds logic-frame phase tracking for render-time interpolation.
Core/Libraries/Include/Lib/BaseType.h Adds RGBColor compound and binary arithmetic operators returning safe values for temporary results.
Generals/Code/GameEngineDevice/Source/W3DDevice/GameClient/W3DDisplay.cpp Adds particle draw updates to the Generals render loop.
GeneralsMD/Code/GameEngineDevice/Source/W3DDevice/GameClient/W3DDisplay.cpp Moves Zero Hour particle logic and draw updates into the display loop.

Reviews (14): Last reviewed commit: "tweak(particlesys): Decouple Particles r..." | Re-trigger Greptile

Comment thread Core/Libraries/Include/Lib/BaseType.h Outdated
@Caball009

Caball009 commented May 14, 2026

Copy link
Copy Markdown

I presume this PR fixes #2467.

psys->attachToObject(building);
Drawable *drawable = building->getDrawable();
psys->attachToObject(object);
Drawable *drawable = object->getDrawable();

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This rename was just a side quest from when looking around particle things.

Comment thread Core/GameEngine/Source/GameClient/System/ParticleSys.cpp Outdated
ParticleSystemInfo::WindMotion windMotion = m_system->getWindMotion();
// monitor lifetime
if (m_lifetimeLeft && --m_lifetimeLeft == 0)
return false;

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

@xezon
xezon force-pushed the xezon/decouple-particle-update branch from b94750d to b86a504 Compare May 17, 2026 11:40
@xezon

xezon commented May 17, 2026

Copy link
Copy Markdown
Author

The plane trails do not fade out gracefully. That needs looking into.

@Caball009

Caball009 commented May 17, 2026

Copy link
Copy Markdown

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.

Comment thread Core/Libraries/Include/Lib/BaseType.h
@xezon

xezon commented May 21, 2026

Copy link
Copy Markdown
Author

I will break the dummy particle manager changes off of this pull.

@xezon

xezon commented May 21, 2026

Copy link
Copy Markdown
Author

ParticleSystemManagerDummy changes split off to #2740

Skyaero42
Skyaero42 previously approved these changes Jun 16, 2026
Comment thread Core/Libraries/Include/Lib/BaseType.h
@xezon

xezon commented Jun 16, 2026

Copy link
Copy Markdown
Author

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)

@Skyaero42

Copy link
Copy Markdown

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.
I cannot withdraw my review until branch conflicts are resolved - long live Github

@xezon
xezon marked this pull request as draft June 17, 2026 07:53
@xezon

xezon commented Jul 11, 2026

Copy link
Copy Markdown
Author

@greptileai
With this change, JetLenzflare does not render at render frame rate 25 or lower. Why?

Particle definition:

ParticleSystem JetLenzflare
  Priority = WEAPON_EXPLOSION
  IsOneShot = NO
  Shader = ADDITIVE
  Type = PARTICLE
  ParticleName = EXLnzFlar2.tga
  AngleX = 0.00 0.00
  AngleY = 0.00 0.00
  AngleZ = 0.00 0.00
  AngularRateX = 0.00 0.00
  AngularRateY = 0.00 0.00
  AngularRateZ = 0.00 0.00
  AngularDamping = 1.00 1.00
  VelocityDamping = 1.00 1.00
  Gravity = 0.00
  SlaveSystem = JetLenzflareExhaust
  SlavePosOffset = X:0.00 Y:0.00 Z:0.00
  Lifetime = 3.00 3.00
  SystemLifetime = 0
  Size = 20.00 20.00
  StartSizeRate = 0.00 0.00
  SizeRate = 0.20 0.20
  SizeRateDamping = 1.00 1.00
  Alpha1 = 1.00 1.00 0
  Alpha2 = 0.00 0.00 2
  Alpha3 = 0.00 0.00 0
  Alpha4 = 0.00 0.00 0
  Alpha5 = 0.00 0.00 0
  Alpha6 = 0.00 0.00 0
  Alpha7 = 0.00 0.00 0
  Alpha8 = 0.00 0.00 0
  Color1 = R:0 G:0 B:0 0
  Color2 = R:255 G:255 B:255 1
  Color3 = R:0 G:0 B:0 3
  Color4 = R:0 G:0 B:0 0
  Color5 = R:0 G:0 B:0 0
  Color6 = R:0 G:0 B:0 0
  Color7 = R:0 G:0 B:0 0
  Color8 = R:0 G:0 B:0 0
  ColorScale = 0.00 0.00
  BurstDelay = 0.00 0.00
  BurstCount = 1.00 1.00
  InitialDelay = 0.00 0.00
  DriftVelocity = X:0.00 Y:0.00 Z:0.25
  VelocityType = ORTHO
  VelOrthoX = -1.00 0.00
  VelOrthoY = 0.00 0.00
  VelOrthoZ = 0.00 0.00
  VolumeType = LINE
  VolLineStart = X:0.00 Y:0.00 Z:0.00
  VolLineEnd = X:0.00 Y:0.00 Z:0.00
  IsHollow = YES
  IsGroundAligned = NO
  IsEmitAboveGroundOnly = NO
  IsParticleUpTowardsEmitter = NO
  WindMotion = UNUSED
  WindAngleChangeMin = 0.149924
  WindAngleChangeMax = 0.449946
  WindPingPongStartAngleMin = 0.000000
  WindPingPongStartAngleMax = 0.785398
  WindPingPongEndAngleMin = 5.497787
  WindPingPongEndAngleMax = 6.283185
End

@greptile-apps

greptile-apps Bot commented Jul 11, 2026

Copy link
Copy Markdown

The root cause is the interaction between the decoupled logic/render updates, the initial black color of JetLenzflare particles, and the isInvisible() check in Particle::update().

Why it happens:

JetLenzflare uses Shader = ADDITIVE with:

Color1 = R:0 G:0 B:0 0   ← starts BLACK
Color2 = R:255 G:255 B:255 1

With the decoupled design, m_color is only advanced in Particle::draw() (the render step), not in Particle::update() (the logic step). The logic step only manages key frame index advancement and the isInvisible() death check.

At logic = 30 FPS, render = 25 FPS (or lower), the timing is:

Time Event
t=0ms Logic frame N: particle created, m_color = (0,0,0) (black)
t=33ms Logic frame N+1: update() runs with frameCount=1. draw() has NOT been called yet — render frame isn't due until t=40ms. isInvisible() sees color is still black → particle dies
t=40ms Render frame: draw() would have made the color white — but the particle is already gone

The frameCount == 0 guard in update() was intended to protect against this:

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 draw() call has yet occurred by frameCount=1. At 25 FPS render, draw() simply hasn't been called between frameCount=0 and frameCount=1, so the particle is still black when isInvisible() fires.

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 draw() call has occurred before enabling the isInvisible() early-exit in update(). For example, a m_hasBeenDrawn flag on the particle: skip the isInvisible() check until the flag is set by the first draw() call.

@xezon

xezon commented Jul 11, 2026

Copy link
Copy Markdown
Author

@greptileai
This is not the cause of the issue. The Logic step cannot run faster than the Render step. And I already tested without the isInvisible() test and it the issue still happens. Please check for other reasons.

@greptile-apps

greptile-apps Bot commented Jul 11, 2026

Copy link
Copy Markdown

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 isInvisible() check being removed didn't reproduce the fix. I apologize for the incorrect diagnosis.

Looking more carefully at the decoupling for other potential causes: one thing that stands out is that m_alpha and m_color are now only accumulated in draw(), but the keyframe rate recomputation (computeAlphaRate() / computeColorRate()) happens in update(). So there's an asymmetry — the rate changes on logic ticks, but the accumulated value only advances on render ticks. If a keyframe boundary is crossed in update() and then draw() overshoots because of a large timeScale, the alpha/color could drift past the intended keyframe target value with no correction, since the direct-snap m_alpha = m_alphaKey[...].value was removed.

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.

@xezon

xezon commented Jul 11, 2026

Copy link
Copy Markdown
Author

I found the hidden particles at sub 30 fps are an explicit feature in ParticleSystem::createParticle

		//
		// 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;

@xezon

xezon commented Jul 11, 2026

Copy link
Copy Markdown
Author

I cannot figure out why the USA02 Cine JetLenzflare is lagging behind.

Jet Lenzflare in normal gameplay looks normal.

jetlenzflare

@xezon

xezon commented Jul 16, 2026

Copy link
Copy Markdown
Author

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):

ParticleSystem JetLenzflare
  Priority = WEAPON_EXPLOSION
  IsOneShot = NO
  Shader = ADDITIVE
  Type = PARTICLE
  ParticleName = EXLnzFlar2.tga
  AngleZ = 0.00 0.00
  AngularRateZ = 0.00 0.00
  AngularDamping = 1.00 1.00
  VelocityDamping = 1.00 1.00
  Gravity = 0.00
  SlaveSystem = JetLenzflareExhaust
  SlavePosOffset = X:0.00 Y:0.00 Z:0.00
  Lifetime = 3.00 3.00
  SystemLifetime = 0
  Size = 20.00 20.00
  StartSizeRate = 0.00 0.00
  SizeRate = 0.20 0.20
  SizeRateDamping = 1.00 1.00
  Alpha1 = 1.00 1.00 0
  Alpha2 = 0.00 0.00 2
  Alpha3 = 0.00 0.00 0
  Alpha4 = 0.00 0.00 0
  Alpha5 = 0.00 0.00 0
  Alpha6 = 0.00 0.00 0
  Alpha7 = 0.00 0.00 0
  Alpha8 = 0.00 0.00 0
  Color1 = R:0 G:0 B:0 0
  Color2 = R:255 G:255 B:255 1
  Color3 = R:0 G:0 B:0 3
  Color4 = R:0 G:0 B:0 0
  Color5 = R:0 G:0 B:0 0
  Color6 = R:0 G:0 B:0 0
  Color7 = R:0 G:0 B:0 0
  Color8 = R:0 G:0 B:0 0
  ColorScale = 0.00 0.00
  BurstDelay = 0.00 0.00
  BurstCount = 1.00 1.00
  InitialDelay = 0.00 0.00
  DriftVelocity = X:0.00 Y:0.00 Z:0.25
  VelocityType = ORTHO
  VelOrthoX = -1.00 0.00
  VelOrthoY = 0.00 0.00
  VelOrthoZ = 0.00 0.00
  VolumeType = LINE
  VolLineStart = X:0.00 Y:0.00 Z:0.00
  VolLineEnd = X:0.00 Y:0.00 Z:0.00
  IsHollow = YES
  IsGroundAligned = NO
  IsEmitAboveGroundOnly = NO
  IsParticleUpTowardsEmitter = NO
End

ParticleSystem JetLenzflareExhaust
  Priority = WEAPON_EXPLOSION
  IsOneShot = NO
  Shader = ALPHA
  Type = PARTICLE
  ParticleName = EXCloud01.tga
  AngleZ = 0.00 1.00
  AngularRateZ = 0.00 0.00
  AngularDamping = 1.00 1.00
  VelocityDamping = 0.00 0.00
  Gravity = 0.00
  Lifetime = 35.00 35.00
  SystemLifetime = 0
  Size = 0.50 0.50
  StartSizeRate = 0.00 0.00
  SizeRate = 0.60 2.00
  SizeRateDamping = 0.93 0.94
  Alpha1 = 0.50 0.50 0
  Alpha2 = 0.00 0.50 25
  Alpha3 = 0.00 0.00 35
  Alpha4 = 0.00 0.00 0
  Alpha5 = 0.00 0.00 0
  Alpha6 = 0.00 0.00 0
  Alpha7 = 0.00 0.00 0
  Alpha8 = 0.00 0.00 0
  Color1 = R:245 G:204 B:141 0
  Color2 = R:128 G:128 B:128 10
  Color3 = R:0 G:0 B:0 0
  Color4 = R:0 G:0 B:0 0
  Color5 = R:0 G:0 B:0 0
  Color6 = R:0 G:0 B:0 0
  Color7 = R:0 G:0 B:0 0
  Color8 = R:0 G:0 B:0 0
  ColorScale = 0.00 0.00
  BurstDelay = 2.00 2.00
  BurstCount = 1.00 1.00
  InitialDelay = 0.00 0.00
  DriftVelocity = X:0.00 Y:0.00 Z:0.00
  VelocityType = ORTHO
  VelOrthoX = 0.00 0.00
  VelOrthoY = 0.00 0.00
  VelOrthoZ = 0.00 0.00
  VolumeType = POINT
  IsHollow = NO
  IsGroundAligned = NO
  IsEmitAboveGroundOnly = NO
  IsParticleUpTowardsEmitter = NO
  WindMotion = UNUSED
  WindAngleChangeMin = 0.149924
  WindAngleChangeMax = 0.449946
  WindPingPongStartAngleMin = 0.000000
  WindPingPongStartAngleMax = 0.785398
  WindPingPongEndAngleMin = 5.497787
  WindPingPongEndAngleMax = 6.283185
End

@greptile-apps

greptile-apps Bot commented Jul 16, 2026

Copy link
Copy Markdown

Here are the potential root causes, ranked by likelihood:


1. Spawn position lag due to update-order change (most likely)

In the old code, ParticleSystemManager::update() (which includes spawning) ran from W3DDisplay::draw()after GameClient::update() had already refreshed all drawable bone/animation transforms. New particles therefore spawned at the current render-frame transform.

Now update() runs from GameLogic::update()before GameClient::update(). When updateTransform() calls attachedTo->getTransformMatrix(), it reads the transform from the previous client frame. Particles spawn one render-frame behind the drawable's true visual position. For a fast jet this is a visible displacement, especially because JetLenzflare particles have near-zero own velocity (VelOrthoX = -1.00 0.00, DriftVelocity = 0.25 Z), so they essentially mark where the jet was, not where it is.

This was literally the reason Lorenzen & Wilczynski originally moved particle update into W3DDisplay::draw() — to capture the render-object's transform "while it is still valid."


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. m_logicalPos captures the object's raw world-space origin, but the lensflare should track the exhaust bone. If the drawable's getTransformMatrix() returns the bone-resolved transform only after client update, then logic-time updateTransform() will get an outdated bone position regardless of object speed.


3. timeScale doesn't represent actual elapsed wall-clock time per render frame

getActualLogicTimeScaleOverFpsRatio() appears to be a fixed-ratio value (logic step duration / target FPS frame duration), not a measurement of actual elapsed time since the last draw() call. At genuinely variable fps (like 22fps in a heavy cutscene), the actual render-frame wall-clock time fluctuates. If timeScale is a fixed ratio (e.g., logicHz / targetRenderHz), particle velocity integration will over- or under-shoot the correct world displacement each frame, and the error accumulates as visible lag against the emitter.


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 draw() call. If draw() is called once per render frame with a fixed timeScale that assumes 1 logic step, and 2 logic steps occurred, particles integrate only half the needed physics displacement for that frame, lagging behind the emitter.


5. emissionAdjustment interpolation uses only the logic-frame delta

frameDeltaPos = m_logicalPos - m_lastLogicalPos spreads burst particles along the path between two consecutive logic positions. At 22fps render / 30fps logic, this is fine for the spread itself, but the spread starts at m_lastLogicalPos (one full logic frame back) rather than at the emitter's current render-frame position. For fast-moving emitters the entire spawn cluster is displaced backward.


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 update() inside the render pipeline. The fix would be to either run updateTransform() / updateLogicalPos() a second time at the start of draw() before any new particles are spawned (but spawning already happened), or to record and use the render-time emitter position separately from m_logicalPos for the purpose of computing the visual emitter origin in draw().

@Skyaero42 Skyaero42 left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks good to me.

@xezon
xezon force-pushed the xezon/decouple-particle-update branch 2 times, most recently from 439c4c9 to 6143a7a Compare September 2, 2026 15:32
Comment thread Core/GameEngine/Source/GameClient/System/ParticleSys.cpp Outdated
Comment thread Core/GameEngine/Source/GameClient/System/ParticleSys.cpp
@xezon
xezon force-pushed the xezon/decouple-particle-update branch from 4c0cbb0 to 07c95d1 Compare September 5, 2026 08:24
@xezon

xezon commented Sep 5, 2026

Copy link
Copy Markdown
Author

Fixup commits squashed

@xezon

xezon commented Sep 5, 2026

Copy link
Copy Markdown
Author

Any final words?

@chatgpt-codex-connector

Copy link
Copy Markdown

To use Codex here, create an environment for this repo.

@OmarAglan

Copy link
Copy Markdown

@codex Review

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 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".

Comment thread Core/GameEngine/Source/GameClient/System/ParticleSys.cpp
Comment thread Core/GameEngine/Source/GameClient/System/ParticleSys.cpp Outdated
Comment thread Core/GameEngine/Source/GameClient/System/ParticleSys.cpp Outdated
@xezon
xezon force-pushed the xezon/decouple-particle-update branch from 07c95d1 to eb2c0db Compare September 15, 2026 15:15
@xezon

xezon commented Sep 15, 2026

Copy link
Copy Markdown
Author

I have reworked the decoupling implementation again. All the complicated math has been removed from the draw update and the particle rate updates are back in the logic update and are applied with time scale in the draw update. The advantage of this approach is that the rate integration is now exact to what it did originally and there is no frame rate dependent deviation. Is easier to understand and maintain.

Comment thread Core/GameEngine/Source/GameClient/System/ParticleSys.cpp Outdated
Comment thread Core/GameEngine/Source/GameClient/System/ParticleSys.cpp Outdated
Comment thread Core/GameEngine/Source/GameClient/System/ParticleSys.cpp Outdated
@xezon
xezon force-pushed the xezon/decouple-particle-update branch from eb2c0db to a5d8ea7 Compare September 16, 2026 10:04
@xezon

xezon commented Sep 16, 2026

Copy link
Copy Markdown
Author

Comments polished and fixup commits merged down.

@coderabbitai

coderabbitai Bot commented Sep 16, 2026

Copy link
Copy Markdown

Review Change StackReview Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Advanced

Run ID: 9b802496-33c2-4d2a-a517-ccffbb194c95

📥 Commits

Reviewing files that changed from the base of the PR and between a5d8ea7 and 1df79ff.

📒 Files selected for processing (1)
  • Core/GameEngine/Source/GameClient/System/ParticleSys.cpp

Included review availability: Your plan provides up to 10 included reviews per hour; 9 remain after this review.


Walkthrough

The change separates particle logic updates from render-time integration. FramePacer exposes logic-frame progress. Particle systems use this phase during drawing, with updated validation, serialization, transforms, visibility handling, and display integration.

Changes

Particle render-phase separation

Layer / File(s) Summary
Frame phase tracking
Core/GameEngine/Include/Common/FramePacer.h, Core/GameEngine/Source/Common/FramePacer.cpp
FramePacer stores, updates, resets, and exposes normalized progress through the current logic frame.
Particle logic and render contracts
Core/GameEngine/Include/GameClient/ParticleSys.h, Core/GameEngine/Source/GameClient/System/ParticleSys.cpp, Core/Libraries/Include/Lib/BaseType.h
Particle logic updates are separated from time-scaled drawing. Particle transforms, visibility, wind, keyframes, and RGBColor arithmetic use the updated interfaces.
Particle validation and persistence
Core/GameEngine/Source/GameClient/System/ParticleSys.cpp
Particle metadata and alpha keyframes are validated. Attachment state is checked. Versioned serialization reads legacy particle data without retaining the removed field.
Manager and display integration
Core/GameEngine/Source/GameClient/System/ParticleSys.cpp, Generals/Code/GameEngine/..., GeneralsMD/Code/GameEngine/...
The manager completes prior render integration before logic updates. Display code conditionally updates systems when game logic advances and draws them on each render path.

Priority: ➖ Normal

Estimated code review effort: 4 (Complex) | ~60 minutes

Change: Bug fix

Sequence Diagram(s)

sequenceDiagram
  participant FramePacer
  participant GameClient
  participant ParticleSystemManager
  participant W3DDisplay
  FramePacer->>GameClient: provide logic-frame phase
  GameClient->>ParticleSystemManager: update when game logic advances
  W3DDisplay->>ParticleSystemManager: draw with render phase
  ParticleSystemManager->>ParticleSystemManager: complete prior phase and draw systems
Loading

Merge Risk: ⚪ Minimal · up to 1df79

No concrete merge-blocking particle regression remains from the reviewed changes.

🚥 Pre-merge checks | ✅ 4
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly and concisely describes the main change: decoupling particle render updates from the logic step.
Description check ✅ Passed The description directly explains the particle render-update decoupling and provides relevant implementation and review context.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

Comment @coderabbitai help to get the list of available commands.

@coderabbitai
coderabbitai Bot requested review from Mauller and bobtista September 16, 2026 10:05

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2


ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Advanced

Run ID: 36569756-2404-4f78-ad04-47303a365511

📥 Commits

Reviewing files that changed from the base of the PR and between 039bd17 and a5d8ea7.

📒 Files selected for processing (11)
  • Core/GameEngine/Include/Common/FramePacer.h
  • Core/GameEngine/Include/GameClient/ParticleSys.h
  • Core/GameEngine/Source/Common/FramePacer.cpp
  • Core/GameEngine/Source/GameClient/System/ParticleSys.cpp
  • Core/Libraries/Include/Lib/BaseType.h
  • Generals/Code/GameEngine/Source/GameClient/GameClient.cpp
  • Generals/Code/GameEngine/Source/GameLogic/Object/Update/BoneFXUpdate.cpp
  • Generals/Code/GameEngineDevice/Source/W3DDevice/GameClient/W3DDisplay.cpp
  • GeneralsMD/Code/GameEngine/Source/GameClient/GameClient.cpp
  • GeneralsMD/Code/GameEngine/Source/GameLogic/Object/Update/BoneFXUpdate.cpp
  • GeneralsMD/Code/GameEngineDevice/Source/W3DDevice/GameClient/W3DDisplay.cpp

Included review availability: Your plan provides up to 10 included reviews per hour; 9 remain after this review.

Comment on lines +685 to +686
DEBUG_ASSERTCRASH(m_colorTargetKey > 0 && m_alphaTargetKey > 0,
("Particle::isInvisible - The color and alpha frame keys are expected to be larger than zero"));

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win

Relax the assert; target key zero is a valid state.

In the non-PRESERVE_RETAIL_PARTICLES build, the constructor sets m_alphaTargetKey = 0 and m_colorTargetKey = 0 (lines 349 and 368). update() advances a target key only when frameCount > m_alphaKey[m_alphaTargetKey].frame (lines 489 and 506). If the first alpha or color keyframe has a nonzero frame, the target key stays 0 until that frame passes, and update() still calls isInvisible() for every frameCount >= 1 (line 520). The assert then fires in debug builds for legitimate INI data, for example JetLenzflareExhaust-style systems whose first key starts later than frame 0. The non-assert branches already handle target key 0 correctly, because m_alphaKey[0].frame != 0 makes the particle count as still transitioning.

🛠️ Proposed fix
-	DEBUG_ASSERTCRASH(m_colorTargetKey > 0 && m_alphaTargetKey > 0,
-		("Particle::isInvisible - The color and alpha frame keys are expected to be larger than zero"));
+#if PRESERVE_RETAIL_PARTICLES
+	// The retail initialization targets the second key, so the target keys are never zero here.
+	DEBUG_ASSERTCRASH(m_colorTargetKey > 0 && m_alphaTargetKey > 0,
+		("Particle::isInvisible - The color and alpha frame keys are expected to be larger than zero"));
+#endif
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
DEBUG_ASSERTCRASH(m_colorTargetKey > 0 && m_alphaTargetKey > 0,
("Particle::isInvisible - The color and alpha frame keys are expected to be larger than zero"));
#if PRESERVE_RETAIL_PARTICLES
// The retail initialization targets the second key, so the target keys are never zero here.
DEBUG_ASSERTCRASH(m_colorTargetKey > 0 && m_alphaTargetKey > 0,
("Particle::isInvisible - The color and alpha frame keys are expected to be larger than zero"));
#endif

}
#endif

validateAlphaKeyframes(m_alphaKey, ARRAY_SIZE(m_alphaKey));

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

🔎 Supported by static analysis

🏁 Script executed:

#!/bin/bash
# Find every declaration/definition of validateAlphaKeyframes and its parameter types.
rg -n -C6 'validateAlphaKeyframes' --type=cpp --type=cc --type=h
# Confirm the RandomKeyframe and Keyframe definitions.
rg -n -C6 'struct RandomKeyframe|^struct Keyframe' --type=h

Repository: TheSuperHackers/GeneralsGameCode

Length of output: 3239


🏁 Script executed:

#!/bin/bash
sed -n '185,240p' Core/GameEngine/Source/GameClient/ParticleSys.cpp
sed -n '1178,1202p' Core/GameEngine/Source/GameClient/ParticleSys.cpp
sed -n '65,90p' Core/GameEngine/Include/GameClient/ParticleSys.h
sed -n '300,340p' Core/GameEngine/Include/GameClient/ParticleSys.h

Repository: TheSuperHackers/GeneralsGameCode

Length of output: 2927


🏁 Script executed:

#!/bin/bash
git ls-files | grep -iE '(^|/)ParticleSys\.cpp$|(^|/)ParticleSys\.h$'

Repository: TheSuperHackers/GeneralsGameCode

Length of output: 276


🏁 Script executed:

#!/bin/bash
rg -n -C8 'validateAlphaKeyframes' Core/GameEngine/Source/GameClient/System/ParticleSys.cpp Core/GameEngine/Include/GameClient/ParticleSys.h
sed -n '1184,1200p' Core/GameEngine/Source/GameClient/System/ParticleSys.cpp
sed -n '190,215p' Core/GameEngine/Source/GameClient/System/ParticleSys.cpp
sed -n '70,83p' Core/GameEngine/Include/GameClient/ParticleSys.h
sed -n '318,330p' Core/GameEngine/Include/GameClient/ParticleSys.h

Repository: TheSuperHackers/GeneralsGameCode

Length of output: 9308


Pass a compatible keyframe type to validateAlphaKeyframes. validateAlphaKeyframes accepts Keyframe*, but ParticleSystemInfo::m_alphaKey is RandomKeyframe[MAX_KEYFRAMES]. The call at ParticleSys.cpp:1193 therefore does not compile. Do not fix this with a cast, because the function accesses frame using the Keyframe layout. Add an overload or template that accepts ParticleSystemInfo::RandomKeyframe*.

@xezon
xezon force-pushed the xezon/decouple-particle-update branch from a5d8ea7 to 1df79ff Compare September 16, 2026 16:10
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Enhancement Is new feature or request Gen Relates to Generals Major Severity: Minor < Major < Critical < Blocker Rendering Is Rendering related ZH Relates to Zero Hour

Projects

None yet

Development

Successfully merging this pull request may close these issues.

8 participants