Skip to content

Comments

Add ParticleGroup system with mesh rendering and Lua scripting API#8

Draft
Copilot wants to merge 8 commits intoDevelopXfrom
copilot/add-particle-group-system
Draft

Add ParticleGroup system with mesh rendering and Lua scripting API#8
Copilot wants to merge 8 commits intoDevelopXfrom
copilot/add-particle-group-system

Conversation

Copy link

Copilot AI commented Feb 18, 2026

Checklist

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)

  • GroupParticle struct with physics, visuals, lifetime, and mesh transform/interpolation data
  • ParticleGroup class: emission control, rate accumulator, per-particle physics update
  • IsMeshGroup() auto-detects sprite vs mesh via nmeshes > 0 and SpriteSequencesIds
  • Mesh particles build world transform from Orientation + MeshScale + Position each frame

Renderer

  • PrepareParticleGroups() handles sprite groups via AddSpriteBillboard
  • DrawParticleGroupMeshes() handles mesh groups following the DrawFishSwarm pattern — transparent face collection + instanced static mesh rendering with per-particle color, room lighting, and interpolated transforms
  • Mesh index clamped to Objects[id].nmeshes range; LightMode sourced from actual mesh

Lua API (EffectsFunctions.cpp)

  • CreateParticleGroup(objectID, maxParticles) — accepts sprite sequences or mesh objects
  • Emission: Start/Stop/Pause/Resume/EmitBurst/SetEmissionRate
  • Templates: velocity, acceleration, lifetime, size, color, rotation ranges
  • Mesh-specific: SetInitialOrientation(Vec3), SetInitialMeshScale(float), IsMeshGroup()
  • Per-particle: GetParticle/SetParticle/ForEachParticle with orientation/meshScale fields
  • SetSpriteIndex selects sprite frame or mesh variant depending on object type

Example Lua scripts (Scripts/Examples/ParticleGroups/)

  • SinusoidalWave.lua — sine-driven position offset
  • SpiralMotion.lua — trigonometric helix via particle ID angle offset
  • FlockingBoids.lua — separation/alignment/cohesion with boundary containment
  • FallingLeaves.lua — per-particle sway phase, wind gusts, air resistance
-- Mesh swarm (e.g. fish-like particles)
local swarm = TEN.Effects.CreateParticleGroup(TEN.Objects.ObjID.FISH_EMITTER, 30)
swarm:SetSpriteIndex(0)                          -- mesh variant
swarm:SetInitialMeshScale(1.0)
swarm:SetInitialOrientation(Vec3(0, 0, 0))
swarm:EmitBurst(20)
swarm:Start()

-- Per-particle orientation in update
swarm:ForEachParticle(function(i, p)
    return { orientation = Vec3(0, p.age * 2.0, 0) }
end)

💬 We'd love your input! Share your thoughts on Copilot coding agent in our 2 minute survey.

Copilot AI and others added 3 commits February 18, 2026 00:47
… 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>
Copilot AI changed the title [WIP] Add particle group system implementation in TEN Add ParticleGroup system with C++ core and Lua scripting API Feb 18, 2026
Copilot AI requested a review from TrainWrack February 18, 2026 00:53
Copilot AI and others added 2 commits February 18, 2026 01:00
…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>
Copilot AI changed the title Add ParticleGroup system with C++ core and Lua scripting API Add ParticleGroup system with Lua-driven behavior and example scripts Feb 18, 2026
Copilot AI and others added 2 commits February 19, 2026 01:17
- 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>
Copilot AI changed the title Add ParticleGroup system with Lua-driven behavior and example scripts Add ParticleGroup system with mesh rendering and Lua scripting API Feb 19, 2026
@TrainWrack TrainWrack requested a review from Copilot February 20, 2026 16:58
Copy link

Copilot AI left a comment

Choose a reason for hiding this comment

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

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 ParticleGroup C++ 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.

Comment on lines +176 to +209
// 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);
}
}
Copy link

Copilot AI Feb 20, 2026

Choose a reason for hiding this comment

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

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.

Copilot uses AI. Check for mistakes.
object.LightMode = mesh.LightMode;
object.Polygon = &poly;
object.World = worldMatrix;
object.Room = &_rooms[p.RoomNumber];
Copy link

Copilot AI Feb 20, 2026

Choose a reason for hiding this comment

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

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.

Copilot uses AI. Check for mistakes.
Comment on lines +855 to +864
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);
Copy link

Copilot AI Feb 20, 2026

Choose a reason for hiding this comment

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

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.

Suggested change
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);

Copilot uses AI. Check for mistakes.
Comment on lines +1832 to +1833
AddSpriteBillboard(
&_sprites[Objects[group.ObjectID].meshIndex + p.SpriteIndex],
Copy link

Copilot AI Feb 20, 2026

Choose a reason for hiding this comment

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

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.

Suggested change
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],

Copilot uses AI. Check for mistakes.
Comment on lines +235 to +247
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;
}
Copy link

Copilot AI Feb 20, 2026

Choose a reason for hiding this comment

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

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

Copilot uses AI. Check for mistakes.
_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;
Copy link

Copilot AI Feb 20, 2026

Choose a reason for hiding this comment

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

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.

Copilot uses AI. Check for mistakes.
Comment on lines +176 to +209
// 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);
}
}
Copy link

Copilot AI Feb 20, 2026

Choose a reason for hiding this comment

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

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.

Copilot uses AI. Check for mistakes.
Comment on lines +753 to +763
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];
}
Copy link

Copilot AI Feb 20, 2026

Choose a reason for hiding this comment

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

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.

Copilot uses AI. Check for mistakes.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants