Add ParticleGroup system with mesh rendering and Lua scripting API#8
Add ParticleGroup system with mesh rendering and Lua scripting API#8
Conversation
… integration Co-authored-by: TrainWrack <120750885+TrainWrack@users.noreply.github.com>
… and GetSpriteSequence Co-authored-by: TrainWrack <120750885+TrainWrack@users.noreply.github.com>
Co-authored-by: TrainWrack <120750885+TrainWrack@users.noreply.github.com>
…FlockingBoids, FallingLeaves Co-authored-by: TrainWrack <120750885+TrainWrack@users.noreply.github.com>
…id boundary check Co-authored-by: TrainWrack <120750885+TrainWrack@users.noreply.github.com>
- GroupParticle: add Orientation, MeshScale, Transform/PrevTransform fields - ParticleGroup: rename SpriteSeqID to ObjectID, add IsMeshGroup() detection - EmitParticle/Update: build world transform matrices for mesh particles - DrawParticleGroupMeshes: new renderer function following DrawFishSwarm pattern - PrepareParticleGroups: skip mesh groups (rendered via Draw path) - Lua API: add SetInitialOrientation, SetInitialMeshScale, IsMeshGroup - Lua API: orientation/meshScale exposed in GetParticle/SetParticle/ForEachParticle Co-authored-by: TrainWrack <120750885+TrainWrack@users.noreply.github.com>
… LightMode Co-authored-by: TrainWrack <120750885+TrainWrack@users.noreply.github.com>
There was a problem hiding this comment.
Pull request overview
This pull request adds a comprehensive ParticleGroup system that bridges C++ particle physics/rendering with Lua-driven behavior logic. The system supports both sprite billboards and instanced 3D mesh rendering, enabling effects like swarms, magic particles, and environmental effects.
Changes:
- Added
ParticleGroupC++ class with emission control, physics simulation, and lifecycle management - Integrated sprite and mesh rendering pipelines supporting interpolation, transparency sorting, and per-particle transforms
- Exposed full Lua API with per-particle callbacks, emission control, and template configuration
Reviewed changes
Copilot reviewed 15 out of 15 changed files in this pull request and generated 8 comments.
Show a summary per file
| File | Description |
|---|---|
| TombEngine/Game/effects/ParticleGroup.h | Header defining ParticleGroup class, GroupParticle struct, and API functions |
| TombEngine/Game/effects/ParticleGroup.cpp | Core implementation of particle emission, physics updates, and group management |
| TombEngine/Scripting/Internal/TEN/Effects/EffectsFunctions.cpp | Lua bindings for ParticleGroup creation, configuration, and per-particle manipulation |
| TombEngine/Scripting/Internal/ReservedScriptNames.h | Added script name constants for ParticleGroup API |
| TombEngine/Renderer/RendererDrawEffect.cpp | Sprite billboard rendering for particle groups |
| TombEngine/Renderer/RendererDraw.cpp | Mesh instanced rendering following FishSwarm pattern with transparency sorting |
| TombEngine/Renderer/Renderer.h | Added renderer method declarations for particle group rendering |
| TombEngine/Game/control/control.cpp | Integrated UpdateParticleGroups() into main game loop |
| TombEngine/Game/Setup.cpp | Added ClearParticleGroups() to level initialization |
| TombEngine/TombEngine.vcxproj | Added ParticleGroup source files to project |
| Scripts/Examples/ParticleGroups/*.lua | Four example scripts demonstrating wave, spiral, flocking, and leaf effects |
| Scripts/Examples/ParticleGroups/README.md | Comprehensive documentation for example scripts and API usage |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| // Update existing particles. | ||
| for (auto& p : Particles) | ||
| { | ||
| if (!p.Active) | ||
| continue; | ||
|
|
||
| p.StoreInterpolationData(); | ||
|
|
||
| // Update lifetime. | ||
| p.Age += dt; | ||
| p.AgeNormalized = p.Age / p.Lifetime; | ||
|
|
||
| if (p.Age >= p.Lifetime) | ||
| { | ||
| p.Active = false; | ||
| continue; | ||
| } | ||
|
|
||
| // Update physics. | ||
| p.Velocity += p.Acceleration * dt; | ||
| p.Position += p.Velocity * dt; | ||
|
|
||
| // Update rotation. | ||
| p.Rotation += InitRotationVel * dt; | ||
|
|
||
| // Rebuild transform for mesh particles. | ||
| if (isMesh) | ||
| { | ||
| auto rotMatrix = Matrix::CreateFromYawPitchRoll( | ||
| p.Orientation.y, p.Orientation.x, p.Orientation.z); | ||
| auto scaleMatrix = Matrix::CreateScale(p.MeshScale); | ||
| p.Transform = scaleMatrix * rotMatrix * Matrix::CreateTranslation(p.Position); | ||
| } | ||
| } |
There was a problem hiding this comment.
Particles do not update their room number as they move through space. The particle's RoomNumber is set once during emission (line 147 of ParticleGroup.cpp) and never updated as the particle moves. This will cause incorrect lighting and rendering if particles cross room boundaries. Other effect systems typically update room numbers during their update loops. The Update() function should call GetPointCollision() or FindRoomNumber() to update each particle's room number based on its current position.
| object.LightMode = mesh.LightMode; | ||
| object.Polygon = &poly; | ||
| object.World = worldMatrix; | ||
| object.Room = &_rooms[p.RoomNumber]; |
There was a problem hiding this comment.
The particle's RoomNumber is used to index into _rooms array without bounds checking. If a particle has an invalid RoomNumber (e.g., from initialization or corruption), this will cause an out-of-bounds access. The renderer should validate that p.RoomNumber is within valid bounds (0 <= p.RoomNumber < _rooms.size()) before accessing _rooms[p.RoomNumber]. This applies to both line 810 and line 860.
| int clampedMeshIndex = std::clamp(p.SpriteIndex, 0, Objects[group.ObjectID].nmeshes - 1); | ||
| const auto& mesh = *GetMesh(Objects[group.ObjectID].meshIndex + clampedMeshIndex); | ||
|
|
||
| _stInstancedStaticMeshBuffer.StaticMeshes[0].World = Matrix::Lerp(p.PrevTransform, p.Transform, GetInterpolationFactor()); | ||
| _stInstancedStaticMeshBuffer.StaticMeshes[0].Color = Vector4(p.ParticleColor.R(), p.ParticleColor.G(), p.ParticleColor.B(), p.ParticleColor.A()); | ||
| _stInstancedStaticMeshBuffer.StaticMeshes[0].Ambient = _rooms[p.RoomNumber].AmbientLight; | ||
| _stInstancedStaticMeshBuffer.StaticMeshes[0].LightMode = (int)mesh.LightMode; | ||
|
|
||
| if (rendererPass != RendererPass::GBuffer) | ||
| BindInstancedStaticLights(_rooms[p.RoomNumber].LightsToDraw, 0); |
There was a problem hiding this comment.
The particle's RoomNumber is used to index into _rooms array without bounds checking. If a particle has an invalid RoomNumber (e.g., from initialization or corruption), this will cause an out-of-bounds access. The renderer should validate that p.RoomNumber is within valid bounds (0 <= p.RoomNumber < _rooms.size()) before accessing _rooms[p.RoomNumber]. This is a duplicate issue with line 810.
| int clampedMeshIndex = std::clamp(p.SpriteIndex, 0, Objects[group.ObjectID].nmeshes - 1); | |
| const auto& mesh = *GetMesh(Objects[group.ObjectID].meshIndex + clampedMeshIndex); | |
| _stInstancedStaticMeshBuffer.StaticMeshes[0].World = Matrix::Lerp(p.PrevTransform, p.Transform, GetInterpolationFactor()); | |
| _stInstancedStaticMeshBuffer.StaticMeshes[0].Color = Vector4(p.ParticleColor.R(), p.ParticleColor.G(), p.ParticleColor.B(), p.ParticleColor.A()); | |
| _stInstancedStaticMeshBuffer.StaticMeshes[0].Ambient = _rooms[p.RoomNumber].AmbientLight; | |
| _stInstancedStaticMeshBuffer.StaticMeshes[0].LightMode = (int)mesh.LightMode; | |
| if (rendererPass != RendererPass::GBuffer) | |
| BindInstancedStaticLights(_rooms[p.RoomNumber].LightsToDraw, 0); | |
| if (p.RoomNumber < 0 || static_cast<size_t>(p.RoomNumber) >= _rooms.size()) | |
| continue; | |
| auto& room = _rooms[static_cast<size_t>(p.RoomNumber)]; | |
| int clampedMeshIndex = std::clamp(p.SpriteIndex, 0, Objects[group.ObjectID].nmeshes - 1); | |
| const auto& mesh = *GetMesh(Objects[group.ObjectID].meshIndex + clampedMeshIndex); | |
| _stInstancedStaticMeshBuffer.StaticMeshes[0].World = Matrix::Lerp(p.PrevTransform, p.Transform, GetInterpolationFactor()); | |
| _stInstancedStaticMeshBuffer.StaticMeshes[0].Color = Vector4(p.ParticleColor.R(), p.ParticleColor.G(), p.ParticleColor.B(), p.ParticleColor.A()); | |
| _stInstancedStaticMeshBuffer.StaticMeshes[0].Ambient = room.AmbientLight; | |
| _stInstancedStaticMeshBuffer.StaticMeshes[0].LightMode = (int)mesh.LightMode; | |
| if (rendererPass != RendererPass::GBuffer) | |
| BindInstancedStaticLights(room.LightsToDraw, 0); |
| AddSpriteBillboard( | ||
| &_sprites[Objects[group.ObjectID].meshIndex + p.SpriteIndex], |
There was a problem hiding this comment.
The sprite index is not clamped before accessing the _sprites array. If p.SpriteIndex is negative or exceeds the number of sprites in the sequence, this will cause an out-of-bounds access on line 1833. The code should clamp p.SpriteIndex similarly to how the mesh renderer clamps the mesh index (line 787 and 855 of RendererDraw.cpp). For sprite sequences, Objects[group.ObjectID] should have a sprite count that can be derived from the SpriteSequencesIds or sprite data structures.
| AddSpriteBillboard( | |
| &_sprites[Objects[group.ObjectID].meshIndex + p.SpriteIndex], | |
| const auto baseSpriteIndex = Objects[group.ObjectID].meshIndex; | |
| const int requestedSpriteIndex = baseSpriteIndex + p.SpriteIndex; | |
| // Validate sprite index before accessing _sprites to avoid out-of-bounds. | |
| if (requestedSpriteIndex < 0 || | |
| requestedSpriteIndex >= static_cast<int>(_sprites.size())) | |
| { | |
| continue; | |
| } | |
| AddSpriteBillboard( | |
| &_sprites[requestedSpriteIndex], |
| void UpdateParticleGroups() | ||
| { | ||
| for (auto& group : ParticleGroupList) | ||
| { | ||
| if (!group.Active) | ||
| continue; | ||
|
|
||
| group.Update(DELTA_TIME); | ||
|
|
||
| // Deactivate group if stopped and no active particles remain. | ||
| if (group.State == ParticleGroupState::Stopped && group.GetActiveCount() == 0) | ||
| group.Active = false; | ||
| } |
There was a problem hiding this comment.
The ParticleGroup::StoreInterpolationData() method is never called from the update loop. This method stores previous state for interpolation, but UpdateParticleGroups() only calls group.Update(). Other similar systems (e.g., UpdateFishSwarm() in FishSwarm.cpp:281) call StoreInterpolationData() within their Update functions. However, for ParticleGroup, the group-level interpolation data (specifically PrevEmitterPosition) is never updated because the standalone StoreInterpolationData() method is not invoked anywhere. This will cause the emitter position interpolation to always use stale data from initialization (Vector3::Zero).
| _stInstancedStaticMeshBuffer.StaticMeshes[0].World = Matrix::Lerp(p.PrevTransform, p.Transform, GetInterpolationFactor()); | ||
| _stInstancedStaticMeshBuffer.StaticMeshes[0].Color = Vector4(p.ParticleColor.R(), p.ParticleColor.G(), p.ParticleColor.B(), p.ParticleColor.A()); | ||
| _stInstancedStaticMeshBuffer.StaticMeshes[0].Ambient = _rooms[p.RoomNumber].AmbientLight; | ||
| _stInstancedStaticMeshBuffer.StaticMeshes[0].LightMode = (int)mesh.LightMode; |
There was a problem hiding this comment.
The fish swarm renderer on line 728 retrieves LightMode from moveableObj.ObjectMeshes[0]->LightMode, but the ParticleGroup renderer on line 861 retrieves it from mesh.LightMode. These should be consistent. Since each particle can select different mesh indices via p.SpriteIndex, using mesh.LightMode (which corresponds to the actual mesh being rendered for that particle) is more correct than always using ObjectMeshes[0], but this inconsistency with the FishSwarm pattern should be verified as intentional.
| // Update existing particles. | ||
| for (auto& p : Particles) | ||
| { | ||
| if (!p.Active) | ||
| continue; | ||
|
|
||
| p.StoreInterpolationData(); | ||
|
|
||
| // Update lifetime. | ||
| p.Age += dt; | ||
| p.AgeNormalized = p.Age / p.Lifetime; | ||
|
|
||
| if (p.Age >= p.Lifetime) | ||
| { | ||
| p.Active = false; | ||
| continue; | ||
| } | ||
|
|
||
| // Update physics. | ||
| p.Velocity += p.Acceleration * dt; | ||
| p.Position += p.Velocity * dt; | ||
|
|
||
| // Update rotation. | ||
| p.Rotation += InitRotationVel * dt; | ||
|
|
||
| // Rebuild transform for mesh particles. | ||
| if (isMesh) | ||
| { | ||
| auto rotMatrix = Matrix::CreateFromYawPitchRoll( | ||
| p.Orientation.y, p.Orientation.x, p.Orientation.z); | ||
| auto scaleMatrix = Matrix::CreateScale(p.MeshScale); | ||
| p.Transform = scaleMatrix * rotMatrix * Matrix::CreateTranslation(p.Position); | ||
| } | ||
| } |
There was a problem hiding this comment.
When the particle group is paused (State == ParticleGroupState::Paused), the Update() function still processes all particles normally, updating their lifetime, physics, and transforms. According to the Lua API documentation (line 783), Pause should "freeze" existing particles. The Update function should check if the group is paused and skip particle updates in that case, only processing them when State == ParticleGroupState::Running or State == ParticleGroupState::Stopped.
| static ParticleGroup* LuaCreateParticleGroup(GAME_OBJECT_ID objectID, int maxParticles) | ||
| { | ||
| if (!CheckIfSlotExists(objectID, "CreateParticleGroup")) | ||
| return nullptr; | ||
|
|
||
| int id = CreateParticleGroup(objectID, maxParticles); | ||
| if (id < 0) | ||
| return nullptr; | ||
|
|
||
| return &ParticleGroupList[id]; | ||
| } |
There was a problem hiding this comment.
Returning a raw pointer to an element in ParticleGroupList is unsafe if the group later gets deactivated and reused. If a Lua script holds a reference to this ParticleGroup pointer after the group has been deactivated (line 246 of ParticleGroup.cpp), subsequent access to that pointer could reference a completely different particle group that has been created in that slot, leading to unpredictable behavior. Consider either: 1) implementing a handle/ID-based system where Lua scripts reference groups by ID and the API validates the group is still active before each operation, or 2) adding a generation counter to each group slot to detect stale references.
Checklist
CHANGELOG.mdfile on the branch/fork (if it is an internal change, this is not needed).Links to issue(s) this pull request concerns (if applicable)
N/A
Pull request description
Adds a particle group system where C++ owns storage, physics, lifecycle, and rendering, while Lua drives all behavior logic via per-particle callbacks. Supports both sprite and mesh rendering — if the specified object ID is a moveable with meshes, particles render as instanced 3D meshes (like fish/bat/rat swarms); otherwise they render as billboarded sprites.
C++ Core (
Game/effects/ParticleGroup.h/.cpp)GroupParticlestruct with physics, visuals, lifetime, and mesh transform/interpolation dataParticleGroupclass: emission control, rate accumulator, per-particle physics updateIsMeshGroup()auto-detects sprite vs mesh vianmeshes > 0andSpriteSequencesIdsOrientation+MeshScale+Positioneach frameRenderer
PrepareParticleGroups()handles sprite groups viaAddSpriteBillboardDrawParticleGroupMeshes()handles mesh groups following theDrawFishSwarmpattern — transparent face collection + instanced static mesh rendering with per-particle color, room lighting, and interpolated transformsObjects[id].nmeshesrange; LightMode sourced from actual meshLua API (
EffectsFunctions.cpp)CreateParticleGroup(objectID, maxParticles)— accepts sprite sequences or mesh objectsStart/Stop/Pause/Resume/EmitBurst/SetEmissionRateSetInitialOrientation(Vec3),SetInitialMeshScale(float),IsMeshGroup()GetParticle/SetParticle/ForEachParticlewithorientation/meshScalefieldsSetSpriteIndexselects sprite frame or mesh variant depending on object typeExample Lua scripts (
Scripts/Examples/ParticleGroups/)SinusoidalWave.lua— sine-driven position offsetSpiralMotion.lua— trigonometric helix via particle ID angle offsetFlockingBoids.lua— separation/alignment/cohesion with boundary containmentFallingLeaves.lua— per-particle sway phase, wind gusts, air resistance💬 We'd love your input! Share your thoughts on Copilot coding agent in our 2 minute survey.