Particle engine

Hey guys, I have implemented a particle engine for my game, and I thought I’d provide a slightly better look at it’s technical detail for those who might be interested.

Here’s a link to live demo.

Demo controls:

  • Left Mouse button - drag to change angle
  • Right Mouse button - drag to pan
  • Mouse wheel - zoom

Since I wrote the engine myself for my own game, it’s completely bespoke. I did draw quite a bit of inspiration from squarefeet’s SPE. Let me know what you think. Some of the features:

  • depth sorting
  • texture atlas. All particles use just 1 texture.
  • “soft particle” shader, particles fade out when penetrating surfaces (you can see this on the “earth” emitter where dust and clumps of dirt fall through the ground)
  • curve-based color and scale mutation over time. Supports arbitrary number of colors and scales at arbitrary points in time. Curves are compiles to a single lookup texture for all particles.
  • dynamic particle count. No hard limit on how many particles an emitter can spawn, you can add 1000 particles and then remove 900 underlying vertex pool will be resized dynamically as needed in an efficient manner.
  • precise dynamic bounding box, at every tick of the simulation you have a tight bounding box on the particle set for each emitter, the box is updated incrementally each frame.
  • frustum culling and sleeping. Particle emitters that are off-screen get suspended, when they become visible again - they are simply fast-forwarded to current moment of the simulation, it’s completely transparent for the user.
  • each emitter uses a single shader, no matter how many “layers” it has. In my engine, the “Emitter” is actually a group, consisting of 1 or more "Layer"s, each layer defines it’s own transform, sprite, curves etc.

All in all, it does most of what I wanted from a particle engine. And its performance is good enough that most of the times I don’t even see particle engine when I do profiling.

for those who might be interested, here’s a JSON serialized emitter for healing effect:

{
  "position": {
    "x": -1.247165750193119,
    "y": 0,
    "z": -2.1666973927028286
  },
  "scale": {
    "x": 1,
    "y": 1,
    "z": 1
  },
  "rotation": {
    "x": 0,
    "y": 0,
    "z": 0,
    "w": 1
  },
  "parameters": [
    {
      "name": "scale",
      "itemSize": 1,
      "defaultTrackValue": {
        "itemSize": 1,
        "data": [
          1
        ],
        "positions": [
          0
        ]
      }
    },
    {
      "name": "color",
      "itemSize": 4,
      "defaultTrackValue": {
        "itemSize": 4,
        "data": [
          1,
          1,
          1,
          1
        ],
        "positions": [
          0
        ]
      }
    }
  ],
  "blendingMode": 0,
  "layers": [
    {
      "imageURL": "data/textures/particle/UETools/Light_Beam_04.png",
      "particleLife": {
        "min": 0.2,
        "max": 0.2
      },
      "particleSize": {
        "min": 0.35,
        "max": 0.35
      },
      "particleRotation": {
        "min": -3,
        "max": 3
      },
      "particleRotationSpeed": {
        "min": 0,
        "max": 0
      },
      "emissionShape": 3,
      "emissionFrom": 1,
      "emissionRate": 180,
      "emissionImmediate": 0,
      "parameterTracks": [
        {
          "name": "color",
          "track": {
            "itemSize": 4,
            "data": [
              0.9882352941176471,
              0.9882352941176471,
              0.9882352941176471,
              0.875,
              0.9490196078431372,
              0.9882352941176471,
              0.807843137254902,
              0.8035714285714286,
              0.9529411764705882,
              0.9882352941176471,
              0.011764705882352941,
              0.8928571428571429,
              0.9490196078431372,
              0.9882352941176471,
              0.5568627450980392,
              0.831874810506877,
              0.9254901960784314,
              0.9882352941176471,
              0.07058823529411765,
              0.8630952380952381,
              0.8549019607843137,
              0.9882352941176471,
              0.01568627450980392,
              0
            ],
            "positions": [
              0,
              0.043478260869565216,
              0.42934782608695654,
              0.16579809410363308,
              0.6191185229303157,
              1
            ]
          }
        },
        {
          "name": "scale",
          "track": {
            "itemSize": 1,
            "data": [
              1.0000284682291665,
              1.0000373125771604,
              0.9483778345360352,
              0.8304531540639897,
              0.7711138941647975,
              0.6556243239902346
            ],
            "positions": [
              0,
              0.105,
              0.265,
              0.41999999999999993,
              0.6449999999999999,
              0.955
            ]
          }
        }
      ],
      "position": {
        "x": 0,
        "y": 0,
        "z": 0
      },
      "scale": {
        "x": 0.1,
        "y": 0.1,
        "z": 0.1
      },
      "particleVelocityDirection": {
        "direction": {
          "x": 0,
          "y": 1,
          "z": 0
        },
        "angle": 6.283185307179586
      },
      "particleSpeed": {
        "min": 0.15,
        "max": 0.25
      }
    },
    {
      "imageURL": "data/textures/particle/travnik/plus_1.png",
      "particleLife": {
        "min": 0.55,
        "max": 0.7
      },
      "particleSize": {
        "min": 0.05,
        "max": 0.1
      },
      "particleRotation": {
        "min": 0,
        "max": 0
      },
      "particleRotationSpeed": {
        "min": 0,
        "max": 0
      },
      "emissionShape": 0,
      "emissionFrom": 1,
      "emissionRate": 43,
      "emissionImmediate": 0,
      "parameterTracks": [
        {
          "name": "color",
          "track": {
            "itemSize": 4,
            "data": [
              0.45098039215686275,
              0.9882352941176471,
              0.15294117647058825,
              0.9821428571428571,
              0.45098039215686275,
              0.9882352941176471,
              0.15294117647058825,
              0
            ],
            "positions": [
              0.5706521739130435,
              0.9836956521739131
            ]
          }
        },
        {
          "name": "scale",
          "track": {
            "itemSize": 1,
            "data": [
              0.8566666666666667,
              1.1319203456790123
            ],
            "positions": [
              0.13,
              1
            ]
          }
        }
      ],
      "position": {
        "x": 0,
        "y": 0,
        "z": 0
      },
      "scale": {
        "x": 0.15,
        "y": 0.15,
        "z": 0.15
      },
      "particleVelocityDirection": {
        "direction": {
          "x": 0,
          "y": 1,
          "z": 0
        },
        "angle": 3
      },
      "particleSpeed": {
        "min": 0.03,
        "max": 0.1
      }
    },
    {
      "imageURL": "data/textures/particle/UETools/Star_24.png",
      "particleLife": {
        "min": 0.17,
        "max": 0.2
      },
      "particleSize": {
        "min": 0.26,
        "max": 0.35
      },
      "particleRotation": {
        "min": 0,
        "max": 0
      },
      "particleRotationSpeed": {
        "min": 0,
        "max": 0
      },
      "emissionShape": 0,
      "emissionFrom": 1,
      "emissionRate": 23,
      "emissionImmediate": 0,
      "parameterTracks": [
        {
          "name": "color",
          "track": {
            "itemSize": 4,
            "data": [
              0.9882352941176471,
              0.9882352941176471,
              0.9882352941176471,
              0.6726190476190477,
              0.9882352941176471,
              0.9882352941176471,
              0.9882352941176471,
              0.9880952380952381,
              0.9882352941176471,
              0.9882352941176471,
              0.9882352941176471,
              0.9620584572947379,
              0.9882352941176471,
              0.9882352941176471,
              0.9882352941176471,
              0.5535714285714286,
              0.9882352941176471,
              0.9882352941176471,
              0.9882352941176471,
              0
            ],
            "positions": [
              0,
              0.10326086956521738,
              0.6304347826086957,
              0.7381625967837998,
              1
            ]
          }
        },
        {
          "name": "scale",
          "track": {
            "itemSize": 1,
            "data": [
              0.9699871782407408,
              1.859251561547733,
              1.9704398895068476,
              1.9227992842084616,
              1.673623753579152,
              1
            ],
            "positions": [
              0.015434782608695685,
              0.07782608695652167,
              0.19,
              0.625,
              0.7849999999999999,
              1
            ]
          }
        }
      ],
      "position": {
        "x": 0,
        "y": 0,
        "z": 0
      },
      "scale": {
        "x": 0.3,
        "y": 0.3,
        "z": 0.3
      },
      "particleVelocityDirection": {
        "direction": {
          "x": 0,
          "y": 1,
          "z": 0
        },
        "angle": 0
      },
      "particleSpeed": {
        "min": 0,
        "max": 0
      }
    }
  ]
}

Numeric enums:


/**
 * @readonly
 * @enum {number}
 */
const EmissionShapeType = {
    Sphere: 0,
    Box: 1,
    Point: 3
};

/**
 * @readonly
 * @enum {number}
 */
const EmissionFromType = {
    Shell: 0,
    Volume: 1
};

Bit flags

/**
 *
 * @enum {number}
 */
export const ParticleEmitterFlag = {
    /**
     * Emitters that are asleep are not being simulated
     */
    Sleeping: 1,
    /**
     * Emitting emitters are allowed to produce new particles
     */
    Emitting: 2,
    /**
     * Internal flag, triggers sprite update in the emitter shader
     */
    SpritesNeedUpdate: 4,
    /**
     * When enabled emitter will start with pre-emitted particles
     */
    PreWarm: 8,

    /**
     * Emitter needs to be initialized before simulation can start proper
     */
    Initialized: 16,

    /**
     * Before emitter can be used it must be built first
     */
    Built: 32,

    /**
     * Position has changed since last update
     */
    PositionChanged: 64,

    /**
     * Whether particles should be sorted by depth or not
     */
    DepthSorting: 128,
};
13 Likes

That’s very very cool ! :neutral_face:
I’d really like to use your engine in my own games…

Same here, can you share it?

I’m very flattered by your wish to use it guys. I will be releasing the whole engine at some point, but I don’t know when I will have time to do that.

3 Likes

Well if you want to throw it to some early adopters, please count me in.

I was doing a bit of debugging and thought I’d show dynamic bounding box calculation that’s going on inside the engine:


the emission model is fairly simple, there’s a direction cone that you can control the direction and angle of, and AABB is computed using cone properties as well as particle speed.

4 Likes

Created a hyper-realistic weather simulation demo :slight_smile:

http://server1.lazy-kitty.com/tests/particles_2019_07_11/

it does show off soft particles rather well, as well as particle sorting

I know your point here is more about the cloud, but have you considered using lines instead of points for the rain? I would think it may help get a fake motion blur.

1 Like

@gui thanks, that’s a good point. The engine doesn’t handle anything other than sprites, so lines would be impossible in the current state. I do have a trail renderer, but there’s not easy way to connect the two.

A lot of particle engines on the marker separate simulation and rendering parts, where rendering is a plugin that goes on top and feeds from particle data. This way you can choose how to render your particles, as sprites, trails, meshes, lines etc.

I think that’s a great approach, but I do not have this implemented in my engine, sprites are all you get :smiley:

1 Like

Sweet soft particles cloud! :star_struck:

1 Like

I spent yesterday re-writing my texture atlas code to work incrementally both in terms of paint and layout. The work is complete and I’m happy to say that it takes a fraction of a millisecond to paint a new sprite onto the atlas now, including the finding the place for it. The Atlas also has a small adjustable cache which allows sprites that are not currently in use to remain painted. This way thrashing of the atlas is reduced significantly. I found that in my game all the sprites basically sit in the cache or are in use, so after a certain point the canvas is not being updated anymore as everything is on it. Here’s a small video with the atlas debug visualization overlayed on top of the game screen.

you might notice that as I click on the ground and a particle effect is spawned - it requests a new sprite, which is immediately painted onto the canvas, same when the chest opens.

@Usnul that is absolutely AMAZING! If you’d like a beta tester for the weather simulation, count me in! :slight_smile:

1 Like