Being this far into the tutorials, you are well aware of the existence of particle groups. To recap everything we have learned about them, we know they serve to classify particles into “types”, which have similar behavior and characteristics. Each time we create a particle group, we may (optionally) give an init and update function to this group. The init function is responsible for determining how (and under which conditions) particles of the given type spawn into the game world and giving the particles their initial properties (i.e. initializing the particles). The update function is responsible for controlling the particles after being spawned, giving you the ability to program additional behavior for them beyond what the particle system does with particles on its own (updating the position, blending size and color, decrementing the life counter).
However, we have not really explored particle groups beyond that, since the particle effects themselves were the main focus. As it turns out though, particle groups themselves have several fields and properties which further control the characteristics of particles beloging to the group.
In this chapter, we will learn of most of these remaining features, such as the blending modes and draw modes (applicable only to sprite particles), the tethering system, which explains the whole picture of how emitterIndex and emitterNode properties really work.
This far into the tutorials, the drill of creating groups via the createGroup(init, update) function should be clear to you. By calling this function, you are creating a new particle group, i.e. a grouping of particles that share similar characteristics with each other. Among these characteristics is having the same init function and update function.
The two arguments: (init, update) are either the function name variables (of the init as the first, then the update as the second argument), or the special value nil. This mechanism allows to opt out of specifying functions for a given group.
The use-case for omitting (passing nil for) an update function we’ve already seen all the way back in Chapter 1, it’s simply when we don’t need our particles being updated or controlled in any special way and the generic update done by the particle system plugin (decreasing life counter, blending colors and sizes) is enough for us. On the other hand, if we want the particles to do more than that, we must pass an update function which manipulates the particles in our desired way.
Omitting an init function is a bit more nuanced. We use nil if we don’t want the particles to be spawned into the world by ordinary means (that is, automatically during the game loop). One such example is if the particles are spawned by other particles. We will then pass nil for the init argument, then call what would be the init function inside the update function of the parent particles. This can be used to make the parent particle leave behind “trails” of child particles (smoke, flames, etc), amongst other things.
As the return result of the createGroup() function, we get a particle group with the assigned init and update functions (or lack thereof).
local group
-- init, update function definitions
group = createGroup(init, update) -- group creation
Our deliberations on particle groups thus far seemed to stop there, with the assumption that there is not much else to say on the topic of particle groups. As it turns out, though, there is a whole bunch of features associated with particle groups. I have chosen not to get into that topic until now, to avoid muddying the waters of essentials for basic particle effects in the first chapters. However, as you are now more experienced, I will finally reveal what kinds of features were lurking behind particle groups this whole time.
The default behavior of each particle group is to have its init function executed once on each game tick in the main game loop. That’s fine and dandy if this is indeed the behavior we want - to have particles spawning by themselves, or optionally under some specific condition, like once every n-th frame, as we have done before.
We can opt out of having an init function specified for the particle group when we create it with createGroup(). To be clear, in this situation, the particle system still attempts to call the group’s init function on each single frame. However, because we have set it to nil, it’s treated as if there was an empty init function (no instructions).
We can also do the opposite, which is specifying an init function to the group, but instruct the particle system to not call it automatically. Why would such a feature even be needed? Well, sometimes, you may prefer to have particles triggered manually, not automatically per each game cycle. An example could be particles triggered on demand, via flipeffects. Yes, that too is possible in the plugin!
Particle groups have a field called autoTrigger, which determines whether or not the group init function gets automatically triggered by the plugin. It’s a boolean field, with the default value set to true, indicating the automatic triggering is enabled. If set to false, it will set the group to manual triggering mode, meaning the particle system no longer will call this group’s init function on its own, per each frame. You must use the aforementioned flipeffect to trigger the particle group.
group.autoTrigger = false -- this particle group does not get auto-triggered by the particle system
After you set the autoTrigger field to false, the particle system no longer spawns the particles of a given group automatically. Instead, spawning these particles is left at your discretion. One option is to use bounded functions, a mechanism provided by the plugin, for the purpose of triggering particle effects with TRNG.
Flipeffect 1 of Plugin_ParticleSystem and function binding
Flipeffect 1 in Plugin_ParticleSystem allows the plugin to call a registered Lua function. This is a regular Lua function, defined in the level script, that neither takes arguments or returns values. However, what makes it special is that it gets registered to the plugin with a special built-in function, bindFunction().
The bindFunction() function takes two arguments. The first is a numeric index, starting from 1 and counting upwards. The second is the Lua function that you would like to bind to the aforementioned index:
local function myFunction() -- we define what the function does...
end
bindFunction(1, myFunction) -- we bind the function to index 1
Okay, we registered the function to index 1. What now? Well, let’s read the description of the Plugin_ParticleSystem Flipeffect 1:
Particles. Call registered Lua function with <&> index
Do you see the point of it now? After we register our custom function with bindFunction(), we are able to trigger calling the Lua function from the level script with Flipeffect 1!
Fine, but how does this allow us to spawn particles? Well, for instance, we can call the desired init function of a group in the registered function:
local function myFunction()
initFunction() -- call the init function
end
Notice that the init function itself fits the criteria of what is allowed to be a registered Lua function: it has no arguments and returns no values. We might as well register the init function directly, then:
bindFunction(1, initFunction) -- we bind the function to index 1
If you place the trigger for Flipeffect 1 with index 1 selected in the <&> timer field and step on it in the level, you will see that the particle gets triggered once every time you step on the tile! You can use Flipeffect 1 as you would any other plugin trigger, to have particle effects activated from the TRNG side with the registered functions.
If for some reason you do not have access to the init function used for a particle group (for example, if it’s defined in a module script, something we will talk about in the next chapter), there is another way to make the function trigger an init function. It is the built-in invokeInit() function, but I will post-pone talking about it until we get to modules.
You might have noticed, especially with long lived particles, that when you save the game and reload, existing particles in the game world are lost (though as long as the init function is active, new ones get spawned in their place). Particles, by default, do not get stored in the savegame. This is actually the intended behavior of the plugin, with a simple reason – thousands of particles included in the save will bloat save files quite significantly. If said particles are short-lived and mainly decorative (smoke, flames), there isn’t a good reason to store them in the save file. If the effect is still active, the particles should quickly respawn anyway.
However, not all particles are decorative, but may serve a significant purpose in the game, such as traps, projectiles, elements of puzzles for game progression, etc. Here, a particle suddenly disappearing due to a save being reloaded would be a disaster, causing serious, game breaking bugs or softlocks that prevent further progress. Fortunately, particles CAN be included in save files.
If you desire a certain particle type to get saved and reloaded, there is the particle group setting, saved. It is a boolean set to false by default. Setting it to true will cause all particles of the given group to be saved in save files and restored from the saves after loading, in the exact same state as during the save.
group.saved = true -- enables particles from the group to be saved and reloaded
A recommendation is to only do this for particle effects that genuinely need save persistence, such as the aformentioned traps or projectiles or particles with really long lifespans, which take a while to respawn.
Warning on changing scripts and savegame stability
The saving feature for particle groups should ideally be enabled as last, after you are finished working on all of the particle effects used for a level. The integrity of the save files can be guaranteed only if the effect scripts used for a level did not change between when the save was made and when was reloaded. In case any changes were made to the level script between saving and loading, the safest option is to delete all the save files and start fresh. I know, this is probably not what you want to hear. Unfortunately, the effects of loading a save file if the script has been altered in between cannot be predicted. Reload such save files at your own risk, you have been warned.
If you were spawning multiple particles with long lifespans, you may have realized that you can have thousands of particles of the same particle group active at once. Sometimes, for various reasons, it may however be useful to limit the number of particles able to spawn for a specific particle group. Perhaps the particles have a heavy update function which causes lags, if the particles are spawned in excess. Or perhaps you simply want to have a hard limit to the pool of particles of a given type, that is lower than the maximum cap of particles.
The good news is, you can! The particle group field partLimit allows you to set a positive integer number, which will cap the amount of particles able to spawn for a given group, to that number.
group.partLimit = 150 -- set maximum of particles for "group" to 150
The above will cause the group group to have no more than 150 particles active at the same time. If any more particles will attempt to spawn (auto-triggered or manually triggered), the logger will receive the following warning message:
warning message placeholder
Of course, this prevents the particles from spawning, but the warning message will be spammed in the logger. To supress the warning, you need to check if the group limit was reached, before attempting to spawn another particle. You can do this in two equivalent ways.
The first way is by checking the read-only group field, partCount. As you might figure, it holds the number of currently active particles for the given group. You just need to compare the group’s partCount against the partLimit, before creating the particle in the init function:
local group
local function initFunction() -- check if partCount < partLimit
if group.partCount < group.partLimit then
local part = createSpritePart(group) -- this will make partCount increase by 1
-- rest of init
end -- end of if condition
end -- end of initFunction
The alternative to the above is to use the checkLimit() built-in function. It takes the group as an argument and returns the boolean true if the group partLimit has not been reached yet. Otherwise, the function returns false. A reminder that Lua allows to omit the explicit comparison of a boolean to the true or false value. In fact, this omission is the recommended coding style:
local group
local function initFunction()
if checkLimit(group) then -- implictly check for true
local part = createSpritePart(group)
-- rest of init
end -- end of if condition
end -- end of initFunction
Either of the two examples achieve the same thing, which is supressing the logger warnings when a partLimit of a given group is reached. An improvement would be perhaps to isolate the particle initialization itself to a separate, dedicated function, leaving the decision logic to the “main” init function (recall that we have done something similar in context of the spawnrate modification).
The part.emitterIndex and part.emitterNode fields, if you remember, allowed to spawn particles around moveables or near specific meshes of said moveables. This changed the particle spawn positions to be relative to the moveable / mesh, so a part.pos like (x: 256, y: -512, z: 1024) would make the particle position an offset from the object / mesh, instead of it appearing at the world coordinates (X: 256, Y: -512, Z: 1024). However, I alluded to this being only part of the full story behind this feature. Well, the time has come to reveal this full story, so strap yourself in!
How it really works
The emitterIndex and emitterNode properties are an integral element of the plugin’s particle moveable tethering system. The tethering system allows particles to be constrained, temporarily or indefinitely, to a specific moveable index via emitterIndex or optionally to a mesh of said moveable via emitterNode. This constraint makes the particle’s coordinate system relative to the chosen moveable / mesh. The duration of the constraint is determined by something known as a cutoff point. If a particle has been alive for longer than the number of game frames specified by the cutoff point, the particle gets released from the moveable (emitterIndex and emitterNode both get set to -1) and the coordinate system of the particle becomes global (which is the default state of particles).
The default duration of the cutoff point is 0 game frames, meaning that the particle gets released immediately after being spawned at the given coordinates. This effectively makes for a handy way to make the particle spawn near the moveable, but get instantly freed from the moveable after the init function exits, thus “spawning at the moveable”.
What if the cutoff point is longer, meaning the particle is not released immediately? The particle maintains relative position to the object, even if the object is moving around in the game world. It makes the particles move within the object’s frame of reference, until the time indicated by the cutoff point elapses. If the cutoff point is greater or equal to the particle lifeSpan, the particle remains tethered to the moveable.
To give an illustrative analogy for the tethering system and moving with the moveable’s frame of reference, imagine passengers flying in an airplane. The passengers are the particles, while the airplane is the moveable given by emitterIndex. The cutoff point is the duration of the flight in this analogy. As a passenger (particle), you can move freely around the aircraft. In theory, you can move, run, jump around just like back down on Earth (though the flight attendants may not appreciate this kind of behavior, but that’s besides the point). Simultaneously, by proxy of being in the aircraft, you are also soaring across the sky at several hundreds of kilometers/miles per hour! So, you have the freedom to move around the plane deck, all while zipping through the air at high speeds, by proxy of the aircraft. Therefore, your movement, when viewed from outside the aircraft, is a net sum of the motion instilled on you by the aircraft, plus any motion you do yourself within the aircraft.
In a similar way, the particles constrained to moveables move together with the moveable, as one unit. The pos vector is therefore relative to the position of the moveable. At the same time, the particles also have additional motion independent of the moveable through their velocity vector vel. Only once the cutoff point is reached, they lose this constraint to the moveable’s frame of reference. Of course, the airplane analogy is not perfect, as the particle can travel even far outside the vicinity of the tethering moveable, wheareas as a passenger of the airplane, you are confined to the plane deck and cannot travel outside of it, at least not without losing the constraint of the aircraft (and, likely, falling down to your demise). Nevertheless, the analogy still conveys the essence of the tethering system in some capacity. With that out of the way, let’s get into the specifics of the tethering system in the plugin.
All things related to the tethering system are controlled by the attach field of particle groups, meaning that the tethering settings are controlled for a particle group as a whole. The attach field is comprised of a few sub-fields itself. Among them are the the cutoff field, the random field and the tetherType field. Let’s describe the first two and then the latter one.
The cutoff and random fields
The cutoff sub-field of attach is the cutoff point mentioned earlier. It decides the point in the particle’s lifespan at which the “airplane flight trip” is over, i.e. when the particles ceases to be attached to the moveable and begins moving independently of it. The range of accepted values is identical to part.lifeSpan / part.lifeCounter, i.e. 0 to 32767. Its default value is 0, anyway.
If a cutoff point is set to a value of 100, it means that once a particle reaches a life duration of 100 game frames (notice that this is when part.lifeSpan - part.lifeCounter is equal to 100, due to the lifeCounter counting down to 0), it will get detached from the moveable.
What if the particle has a lifeSpan shorter than 100? Simple, in this case it will never get detached from the moveable for as long as it’s alive. This means that it is possible to set a cutoff value which makes particles attach for their entire lifespan. Setting cutoff with special constant NO_CUTOFF ensures this will always be the case, regardless of the individual lifeSpan value is for each particle of the group.
group.attach.cutoff = NO_CUTOFF -- ensures that the particles are always attached to the moveable
Notice that if you give the group a cutoff value like 100 (and the particle lifeSpan is longer), all of the particles will detach at the same point of their life. For some cases, this is desired, but perhaps not always. In some circumstances, it might be preferable if each particle detaches at a slightly different (randomized) time.
This is what the random field is for. By default, its value is also set to 0. What random does is it generates a random integer between 0 and a maximum equal to the value of random. This value is unique and constant to each particle, meaning it doesn’t change throughout the specific particle’s life. Then, this random integer gets added to the cutoff point for that particle. As a result, this will make different particles detach at different times, the effective cutoff point being somewhere in the range (cutoff, cutoff + random).
Here’s a specific example:
group.attach.cutoff = 20
group.attach.random = 10
We have a cutoff set to 20 and a random set to 10. This will mean that the particles of the particle group group will get detached at a randomized cutoff point between 20 (cutoff) and 30 (cutoff + random).
The tether type
Now we’ll talk about the tetherType component field of attach. The tetherType controls the way in which the tether of a particle works. As was explained in the airplane analogy, tethered particles inherit their motion from the moveable to which they are tethered to. However, the intricate details of how the motion is inherited depends on this tetherType setting. There are three TETHER_ constants, corresponding to the 3 possible options:
Tether type constants
Numeric Index
TETHER_ Constant
Description
0
TETHER_ROTATING
The coordinate system of particles factors in the rotational motion of a given moveable / mesh, along with the position. This is the default tethering mode.
1
TETHER_STATIC
The coordinate system of particles ignores the rotations of a moveable / mesh, only the changes to the moveable / mesh position are respected.
2
TETHER_NONE
This mode effectively makes the particles behave as if they were detached, following the global coordinate system. In spite of this, it keeps track of the moveable (emitterIndex) / mesh (emitterNode) until reaching the cutoff point.
The TETHER_ROTATING and the TETHER_STATIC tether types differ in how the moveable to which the particles are tethered affects the coordinate system of the particle. A rotating tether respects the rotation of the moveable, meaning if the moveable, or any of its meshes, rotate during animations or otherwise, the particle’s frame of reference rotates along with the moveable or mesh, on top of being displaced by the changes in the moveable’s position. The static tether disregards the rotation effects and only respects the changes in position. These tether effects are quite difficult to explain and even demonstrate outside of the game itself, so my only suggestion is to try both of them out and observe the difference yourself.
The TETHER_NONE is unique in that there is no effect on the frame of reference, the particle will still follow the global coordinate system (and the position is no longer relative to the moveable in any way). Regardless, the particle does not lose reference to the emitterIndex moveable, at least not until the cutoff point is reached. This results in the “tether” between the particle and the emitterIndex to become a more abstract concept. This even allows to invert the tethering relationship between the particle and the moveable, where the particle itself, through an update function, moves the moveable item around. Admittedly, though, this is a very niche scenario with few applications.
Manually tethering and untethering particles with particle function
I want to conclude this section by signifying that there are special particle functions which allow you to manually detach (untether) and (re)attach particles to moveables. These are the particleDetach() function, which untethers the given particle, and particleAttach(), which allows to tether (attach) a particle onto a moveable. Remember, even if the particle group has a rotating or static tether type and a cutoff set to NO_CUTOFF, the particles belonging to the group will not be attached to any moveable if no part.emitterIndex is given (because it is set to -1 by default). This allows particles, initially untethered, to attach themselves to a moveable at a later stage in life, or start attached to one moveable and attach to another moveable later.
An example usage for this could be making a napalm flamethrower weapon. The burning “napalm” particles, upon colliding with an enemy or Lara, can be manually attached to the moveable in the update function, sticking to and following the moveable. However, this is a very advanced feature of the tethering system for highly specific cases. You can read up more on both particleAttach() and particleDetach() functions in the scripting reference on built-in functions.
All the fields and settings of groups described up to this point were applicable to both types of particles (sprite and mesh). The fields that will be described below, however, are unique to sprite particles. You could set them on a group that deals with mesh particles, but it will simply have no effect.
Remember when I quickly alluded to different blending modes when we talked about sprite particle colors back in Chapter 1? Time to finally talk about it!
The blending modes are a particle group setting, accessed by the blendMode field. The values you can use here are numeric indices of the blending modes used internally by the Tomb4 engine. These are also the blending mode numbers used in the FLEP Smoke emitter white OCB setting patches and correspond to the New blending modes patch, adding extra blending modes. However, since it can be difficult to memorize which numeric index corresponds to which blending mode, the plugin has implemented BLEND_ constants to be used in the scripts, which give the same value as the blending mode numeric index. Below is a table listing the indices of the available blending modes, the BLEND_ constants used in scripts and the description of the blending mode. Note that to be able to use all of these modes, starting from 5 onwards, you must have the New blending modes FLEP patch enabled.
Blending modes list
Numeric Index
BLEND_ Constant
Description
0
BLEND_TEXTURE
Opaque. No transparency allowed, magenta (255, 0, 255) turns to black(0,0,0).
1
BLEND_DECAL
Opaque. Magenta(255, 0, 255) is transparent, other colors rendered as-is.
2
BLEND_COLORADD
Transparent additive blending. Pure black(0,0,0) is invisible. This is the default blending mode.
3
BLEND_SEMITRANS
Opaque. Pure black(0,0,0) and magenta(255, 255, 255) are invisible. Anything 3D geometry “behind” the sprite gets “covered” by the texture, leaving just the silhouettes visible.
4
BLEND_NOZBUFFER
Like BLEND_DECAL but ignores the Z-depth buffer, meaning particles get rendered even if they are hidden “behind” geometry.
5
BLEND_COLORSUB
Transparent subtractive blending. Source inversion, meaning that sprite colors are inverted (bright becomes dark). Pure black(0,0,0) is transparent.
6
none
Reserved for drawing line particles, do not use.
7
BLEND_SEMITRANS_ZBUFFER
Opaque. More research needed.
8
BLEND_DESTINATION_INV
Transparent. Destination inversion, meaning that colors “behind” the sprite are inverted (bright becomes dark). Pure black (0,0,0) is transparent.
9
BLEND_SCREEN_DARKEN
Transparent. Screen darken.
10
BLEND_SCREEN_CLEAR
Transparent. Screen clear.
11
BLEND_CUSTOM_11
Custom mode 11 (see New blending modes FLEP patch).
12
BLEND_CUSTOM_12
Custom mode 12 (see New blending modes FLEP patch).
13
BLEND_CUSTOM_13
Custom mode 13 (see New blending modes FLEP patch).
Each blending mode has unique interactions with the source (the sprite texture) and the destination (the background behind the sprite). Some of these blending modes are more useful than others. If you have the knowledge to do so, can also set custom blending modes: 11, 12, and 13, via the New blending modes FLEP patch.
Finally, blending modes are only applicable to sprite particles. On mesh particles, they have no effect.
Aside from blending modes for sprites, we also have drawing modes. Drawing modes determine what the renderer should draw, given the input. You might say – “Well, these are sprite particles, so it should draw sprites!” – and I agree. However, you may remember, in the Tomb4 there is a specific kind of particle – a thin line streak, oriented in the direction the particle is travelling in, optionally with a color. You see this subtype of particle with water drips when Lara gets out of water, rain drops, ricochets on walls when firing guns, and perhaps other examples I am forgetting about. These are line particles and apart from the slightly different rendering approach, the engine treats them identically to ordinary sprite particles.
We could not skip out on making these line particles available for the particle plugin as well. How do you access them? By setting the appropriate draw mode for the particle group!
There are 5 draw modes currently available in the plugin. Similarly to blending modes, they are numeric indices but come with handy DRAW_ constants for convenience. They are listed in the table below:
Drawing modes list
Numeric Index
DRAW_ Constant
Description
0
DRAW_SPRITE
Draws the sprite with the texture given by part.spriteIndex. This is the default drawing mode.
1
DRAW_SQUARE
Ignores the part.spriteIndex texture, drawing just a blank square / rectangle.
2
DRAW_LINE
Draws a thin line streak in place of a square. The length of the streak depends on the velocity vector and size of the particle.
3
DRAW_ARROW
Similar to DRAW_LINE but adds an arrowhead in the direction the particle is moving in.
4
DRAW_NONE
Does not render the particle (but it still remains active e.g. for the update function).
We see that we have some interesting entries in the table. They can be grouped into three general categories based on similarities.
DRAW_SPRITE and DRAW_SQUARE
DRAW_SPRITE and DRAW_SQUARE both draw the particle square/rectangle, respecting the blending mode set for the particle. DRAW_SPRITE also uses the texture indicated by the part’s spriteIndex, whereas DRAW_SQUARE ignores it, leaving the square blank.
DRAW_LINE and DRAW_ARROW
DRAW_LINE and DRAW_ARROW work differently, in that a line segment is drawn between the current pos vector of the particle and a projection away from the pos vector by a fraction of a the vel vector. Perhaps this does not immediately make sense, but in layman’s terms, this means that the position of the line segment depends on the part.pos vector, while the length of this segment depends on the particle’s current velocity and current size (either the blended size between sizeStart and sizeEnd or the overridden sizeCust). I’ve made a little interactive demo of this idea, which will perhaps get the meaning across better than any explanations I could come up with.
Anyway, the implication of this is that line particles will not get drawn if the velocity is 0 (that is, each x y z component is 0), even if the size is huge. There is an exception to this, however. There is a boolean field of groups, called lineIgnoreVel. If this field is set to true, the lines are instructed to ignore the velocity contributing to the length of the line segment.
group.lineIgnoreVel = true
In this case, the length of the line segment will be determined exclusively from the current size of the particle.
Line particles ignore the spriteIndex, sizeRatio, rot and rotVel sprite particle fields. Additionally, they ignore the blending mode setting, as there is a single, reserved blending mode for line particles, which is always additive color blending (i.e. pure black(0,0,0) is transparent).
DRAW_NONE
Perhaps you have read the description of this draw mode and consider it pointless. What is the purpose of going through the trouble of defining a particle group, then giving it the DRAW_NONE drawing mode, if such particles are not rendered at all? It seems redundant, either you have the particles drawn or don’t bother, right? However, I want to convince you now that these “invisi-particles”, as I like to call them, can be useful.
If we strip away all graphical qualities of a particle, what are we left with? We have the base properties of the particle: the pos, vel, accel vectors, the lifeSpan and lifeCounter. On top of that, we also have an init function and update function tied to the particle group of said particles.
We thus have an entity with a duration (lifetime), position, potentially a velocity and acceleration too. We can determine how this entity spawns by the init function, and control what it does with the update function. These “invisi-particles” are not rendered themselves, but thanks to having a position and velocity, can become a moving “canvas” for other, subsidiary effects taking place via the update function. This canvas can also be attached to specific entities, via the tethering system. This invisi-particle has a limited duration, dying after a certain amount of time. Because you can still do anything non-graphical with the particle in the update function, it can work as a “pilot” that carries other effects with it. Or to put things in TRNG terms, it can work as a localized, travelling Organizer, which you control via the update function.
A practical example of this, which will be shown later, is having an invisi-particle attached to a baddy via the part.emitterIndex, with its update function triggering child particles with the flame sprite to appear on the baddy’s meshes at random, all while decrementing the hitpoints of the enemy. This will cause the enemy to burn and evetually, die, just like Lara does! Hopefully, this shows you some of the potential that “invisi-particles” can have, if you view them as localized entities under scripting control.
Sprite particles, by default, take their sprites from... well... the DEFAULT_SPRITES slot. It’s possible to add custom sprite textures beyond the default 33 textures present in the unmodified DEFAULT_SPRITES object, but perhaps you would prefer to store your custom sprite textures in a separate sprite slot, while keeping the original DEFAULT_SPRITES intact (or to separate these textures in a meaningful way). You can use two other sprite slots for sprite particles, the MISC_SPRITES slot and also the CUSTOM_SPRITES slot added by TRNG (NG custom sprites, as they are labeled in the Tomb Editor toolset). You can even use all three slots at once in a single WAD, but only one of them can be assigned per each particle group.
To change which of these slots the sprite particles will take textures from, you must change the slot ID on the particle group via the spriteSlot field. The only acceptable values for this field are 463 for DEFAULT_SPRITES, 464 for MISC_SPRITES and 494 for CUSTOM_SPRITES. However, you do not have to memorize these IDs, you may also use the corresponding SLOT_ constants instead of the numerical indices:
group.spriteSlot = SLOT_MISC_SPRITES -- sets 464 for the MISC_SPRITES slot
Using, non-sprite slot indices (e.g. 455 or SLOT_ANIMATING15) will set spriteSlot back to the SLOT_DEFAULT_SPRITES value, with a logger warning.
The protective measures for the part.spriteIndex field exist for all 3 sprite slots, meaning that it is impossible for the spriteIndex go above the maximum index of sprite texture in a given sprite slot. Attempting to go past the maximum index will clamp the value and give the appropriate warning in the logger. The chosen sprite slot must possess at least 1 sprite texture (seems self-evident, but noting it just in case).