Adding Visual Effects
Brief Overview of Particle Systems#
Visual effects play an important role in enhancing the realism of the images generated by 3D applications, be it a simulator or a game. To simulate various phenomena such as fire, smoke, explosions, electric sparks, fountains, jet engine plumes, wake waves, magic and many others, particle systems are extensively used in 3D graphics. These effects are crucial in creating believable images that captivate the audience.
Particles can be material points, elementary sprites, triangles, small polygons, or even geometric shapes. Typically, the system releases particles at random points within a predetermined volume, such as a sphere, cylinder, or cone, or from a single point in various directions. The system determines the lifetime of a particle, and destroys it when that time runs out.
Each particle in the system dynamically changes its coordinates based on a specific algorithm, such as the law of universal gravitation. Additionally, particles are not static and may alter both their position and shape over time.
A particle system in UNIGINE consists of three main entities:
- Emitter – the source that emits particles according to the values set in the Emitter parameters.
- Particles themselves, which are emitted according to the predefined behavior after emission.
- Additional physical effects applied to the particles that affect their behavior.
To create the desired effects in our game, we will need a flash and smoke effect for when a shot is fired, as well as a bullet impact effect that leaves a trace on the hit surface. Both effects will use the particle system, and the latter effect will also include a decal projection to simulate the impact. We'll start with creating a simplified version of the flash and smoke effect manually before using the ready-made assets.
Creating a Simple Effect Template#
The shot effect will have two components — flash and smoke, and we'll use a separate particle system for each. Let's start with the flash:
- Let's add a new particle system to the scene (Create -> Particle System -> Particles) and call it flash.
- The created system emits particles continuously, but for the flash we need to do it once, so set the Spawn Rate to 0. Now we need to turn off the node in the World Hierarchy and turn it on to see what the flash will look like.
- We don't want our single particle imitating the flash to move anywhere after it appears, we only need it increase in size. The particle behavior after emission is controlled by the Behavior After Emission group parameters. There we will set Velocity to 0 so that the particle does not move anywhere, and the Increase In Radius value — to 1.5.
- Let's check what the effect looks like — turn off and on the flash node. It's already better, but it's too slow for a flash, so we need to speed up the effect. To do this, reduce Life Time to 0.1.
- Now you only need to replace the material — in the Surface Material group assign the data/fps/particles/materials/gun_flash_0_mat.mat material.
The flash is ready, now let's add some smoke. It's done as follows:
- Clone the first particle system and name this copy smoke.
- Assign the data/fps/particles/materials/gun_smoke_gray_mat.mat material to it.
-
To synchronize several particle systems, we need to combine them into a hierarchy (the Duration value of the parent particle system must be big enough to enclose Duration and Delay intervals of all child particle systems). So, to make smoke automatically appear when flash is activated, add smoke as a child node for flash.
- In our case everything is simple, each of the systems generates one particle, but we need to make the smoke appear with a slight delay and last a little longer. For this, we'll set the Delay parameter in the Emission group equal to 0.1, and Life Time — to 0.2. For Increase In Radius, set the value to 0.3.
- The last step is to drag the flash node from World Hierarchy to Asset Browser and rename the resulting asset to gun_fire.node. Now it is ready to be used for the fire effect (the node itself can be removed from the scene).
Implementing the Muzzle Flash and Hit Effect#
Visual effects for shooting can be implemented in a separate component. You can get information about the hit point and spawn a NodeReference representing the hit effect at this point oriented along the hit normal. For the muzzle flash, you can attach a NodeDummy to the muzzle of the pistol, and spawn a muzzle flash NodeReference at this position.
In the component code below, the OnHit() and OnShoot() methods implement this logic.
-
Create the VFXController component and copy the code below.
VFXController.h
#pragma once #include <UnigineComponentSystem.h> class VFXController : public Unigine::ComponentBase { public: COMPONENT_DEFINE(VFXController, Unigine::ComponentBase); // NodeReference for flash and hit effects of a shot PROP_PARAM(File, hitPrefab, NULL); PROP_PARAM(File, muzzleFlashPrefab, NULL); // shoot event handler void onShoot(Unigine::Math::Mat4 transform); // hit event handler void onHit(Unigine::Math::Vec3 hitPoint, Unigine::Math::vec3 hitNormal, Unigine::ObjectPtr hitObject); };
VFXController.cpp
#include "VFXController.h" REGISTER_COMPONENT(VFXController); using namespace Unigine; using namespace Math; void VFXController::onShoot(Unigine::Math::Mat4 transform) { // if no flash effect NodeReference is specified, do nothing if (!muzzleFlashPrefab) return; // load NodeReference shot effect NodePtr muzzleFlashVFX = World::loadNode(FileSystem::guidToPath(FileSystem::getGUID(muzzleFlashPrefab.getRaw()))); // set the flash position to the specified coordinates of the gun's muzzle muzzleFlashVFX->setWorldTransform(transform); } void VFXController::onHit(Unigine::Math::Vec3 hitPoint, Unigine::Math::vec3 hitNormal, Unigine::ObjectPtr hitObject) { // if the node of the hit effect isn't specified, do nothing if (!hitPrefab) return; // load the hit effect node from the file NodePtr hitVFX = World::loadNode(FileSystem::guidToPath(FileSystem::getGUID(hitPrefab.getRaw()))); // set the uploaded node to the specified hit point and deploy it in the direction of normal vector hitVFX->setParent(hitObject); hitVFX->setWorldPosition(hitPoint); hitVFX->setWorldDirection(hitNormal, vec3_up, Math::AXIS_Y); }
-
Modify the WeaponController component in order to use logic of VFXController.
WeaponController.h
#pragma once #include <UnigineComponentSystem.h> #include <UnigineVisualizer.h> #include "ShootInput.h" ${#HL}$ #include "VFXController.h" ${HL#}$ class WeaponController : public Unigine::ComponentBase { public: COMPONENT_DEFINE(WeaponController, Unigine::ComponentBase); PROP_PARAM(Node, shooting_camera, nullptr); PROP_PARAM(Node, shoot_input_node, nullptr); ${#HL}$ PROP_PARAM(Node, weapon_muzzle, nullptr); PROP_PARAM(Node, vfx_node, nullptr); Unigine::NodeDummyPtr weaponMuzzle; VFXController* vfx; ${HL#}$ Unigine::PlayerDummyPtr shootingCamera = nullptr; ShootInput *shootInput = nullptr; int damage = 1; // Intersection mask to define objects to be affected by bullets int mask = ~0; // declare methods to be called at the corresponding stages of the execution sequence COMPONENT_INIT(init); COMPONENT_UPDATE(update); void shoot(); protected: // world main loop overrides void init(); void update(); };
WeaponController.cpp
#include "WeaponController.h" REGISTER_COMPONENT(WeaponController); using namespace Unigine; using namespace Math; void WeaponController::shoot() { ${#HL}$ if (weaponMuzzle) vfx->onShoot(weaponMuzzle->getWorldTransform()); ${HL#}$ // set the line starting point (p0) in the camera position and end point (p1) in the point 100 units away in the camera view direction Vec3 p0 = shootingCamera->getWorldPosition(); Vec3 p1 = shootingCamera->getWorldPosition() + shootingCamera->getWorldDirection() * 100; // create an intersection-normal storage object WorldIntersectionNormalPtr hitInfo = WorldIntersectionNormal::create(); get the first object intersected by the (p0,p1) line Unigine::ObjectPtr hitObject = World::getIntersection(p0, p1, mask, hitInfo); // if the intersection is found if (hitObject) { // render the intersection normal to the surface in the hit point using Visualizer Visualizer::renderVector(hitInfo->getPoint(), hitInfo->getPoint() + hitInfo->getNormal(), vec4_red, 0.25f, false, 2.0f); ${#HL}$ // render the hit effect in the intersection point vfx->onHit(hitInfo->getPoint(), hitInfo->getNormal(), hitObject); ${HL#}$ } } void WeaponController::init() { // getting the camera to which the ShootInput component is assigned shootingCamera = checked_ptr_cast<Unigine::PlayerDummy>(shooting_camera.get()); } void WeaponController::update() { // handle user input: check if the 'fire' button is pressed if (shootInput->isShooting()) shoot(); }
Save all the files that we modified and then build and run the application by hitting Ctrl + F5 to make the Component System update properties used to assign the components to nodes. Close the application after running it and switch to UnigineEditor.
- Add the VFXController property to the player Dummy Object.
-
Now add a point where the shoot effect will be visualized. Create a NodeDummy, call it muzzle, make it a child of the pistol Skinned Mesh, and place it near the end of the weapon muzzle.
- Select the player Dummy Object, assign the muzzle node to the Weapon Muzzle field in the WeaponController section.
-
Assign the player Dummy Object to the Vfx Node field in the WeaponController section.
- Add the data/fps/bullet/bullet_hit.node asset to the Hit Prefab field of the VFXController section.
-
Add the gun_fire.node asset that we prepared earlier to the Muzzle Flash Prefab field.
- Now you can press Play and test the shooting visual effects.
VFX Lifetime#
With each shot, a new node is generated to indicate where the bullet hit. You can fire quite a lot of shots during the game. And it could be times more if we had a machine gun! We need to be mindful of performance and avoid unnecessary resource usage. Therefore, we should regularly delete nodes that are no longer critical for gameplay, such as the trace effect on walls after some time.
To control the duration of visual effects, you can add the Lifetime component that will allow you to define a time interval for the node during which it will live and after which it will be deleted. Here's the code for this component:
LifeTime.h
#pragma once
#include <UnigineComponentSystem.h>
class LifeTime :
public Unigine::ComponentBase
{
public:
COMPONENT_DEFINE(LifeTime, Unigine::ComponentBase);
// life time duration
PROP_PARAM(Float, lifeTime, 1.0f);
// declare methods to be called at the corresponding stages of the execution sequence
COMPONENT_INIT(init);
COMPONENT_UPDATE(update);
protected:
float startTime = 0.0f;
// world main loop overrides
void init();
void update();
};
LifeTime.cpp
#include "LifeTime.h"
#include <UnigineGame.h>
REGISTER_COMPONENT(LifeTime);
using namespace Unigine;
void LifeTime::init()
{
// remember initialization time of an object
startTime = Game::getTime();
}
void LifeTime::update()
{
// wait until the lifetime ends and delete the object
if (Game::getTime() - startTime > lifeTime)
node.deleteLater();
}
Save all the files that we modified and then build and run the application by hitting Ctrl + F5 to make the Component System update properties used to assign the components to nodes. Close the application after running it and switch to UnigineEditor.
Add the Lifetime component to the bullet_hit.node and gun_fire.node NodeReferences.
-
For the bullet_hit.node, the Life Time parameter is set to 1 second.
-
For the gun_fire.node, the Life Time parameter is set to 5 seconds.