Making plasma effects


In the olden days of computer programming, having many frames of a giant image was an unacceptable usage of the memory. To avoid this but still have graphical effects, there are many algorithmic methods to substitute for it.

One of these methods is the plasma effect. While there are many ways to achieve the plasma effect, I will use the one using a combination of sine waves.

1. Making sine tables

Since sine waves are involved, the program will feature heavy use of the sine function (in fact at least once per pixel and per frame). As even the CPU's own sine function is typically 10 to 20 times slower than the usual arithmetical operations, it may be a good idea to preprocess the values that we will use, depending on whether or not accessing the RAM will be faster than computing the sine of a value and rounding it down.

For simplicity, the sine function that we want will map the range $[0..255]$ to $[0..255]$, corresponding to the values, for our sine table, of

$$\operatorname{sine}[n] = \lfloor((\sin(\frac{2\pi n}{255}) * 255) + 255) / 2\rceil$$

This will achieve the correct range, with $\operatorname{sine}[0] = 128$ (the "zero" of our function), $\operatorname{sine}[64] = 255$ (corresponding to the maximum at $\pi / 2$ and $\operatorname{sine}[192] = 0$ (the minimum at $3\pi/2$).

In terms of code, this will correspond to something of the form

To generate a single particle, we will need to generate its speed, its lifespan (before deletion), and an angle for its direction. For now we will assume that all particles are generated at the center of the frame.
stuff sineTable = []; for(var i = 0; i < 256; i++) { sineTable[i] = Math.round(((Math.sin(i * 2 * Math.PI / 255) * 255) + 255) / 2); }

Then we can simply make a sine function by

stuff function sine(x) { x = x % 256; return (x < 0) ? sineTable[-x] : sineTable[x]; }

You can then either use this look-up table for finding sine values or calculating it on the go, depending on what is easier or faster.

2. Basic sine waves

There are many types of sine waves that can be done for plasma effects, but the two basic types are the plane wave and the radial wave.

The plane wave simply has a direction. It's defined by a vector $\vec{v}$ such that all points on a line orthogonal to this direction will have the same value.

$$\sin(\lambda \vec{v} \cdot \vec{x} + \phi)$$
stuff function plane(x, y, vx, vy, phase, length) { return sine(Math.floor((vx * x + vy * y) * length + phase) ); }

By simply adding a multiple of the current frame as the phase of the function, we can also make it move forward. Here's a general example of a plane sine wave.

The radial wave on the other hand is defined by a center point from which the wave originates, the sine depending on the distance from that center point.

$$\sin(\lambda \|\vec x - \vec c\|+ \phi)$$
stuff function radial(x, y, phase, centerX, centerY, diameter) { var dx = x - centerX; var dy = y - centerY; return sine(Math.floor(Math.sqrt(dx * dx + dy * dy) * diameter + phase)); }

Just as with the plane wave, we can make the phase depend on time, as well as shift the center of the wave to the center of the canvas.

3. Color palettes

To add some colors to the plasma effects, we'll need to define a color palette in which the result of either the function planeWave or radialWave will pick the current color at a given pixel, in our case a set of 256 colors.

To define this palette, we'll need a set of colors, the first one being the color of the boundary of the palette, which is the color defined at palette[0] and palette[255] (to have a mostly smooth transition, it has to be roughly the same color, but for simplicity, we'll just take the same color for both values).

We then need to define a few more colors that will be roughly equidistant in the palette. Since we only work with 256 values, we can't really use too many different of those colors if we want things to remain smooth.

For $n$ colors, we will have those colors defined at $\operatorname{palette}[k * 256/(n + 1)]$. Hence for one color, we get it defined at $128$, and for two colors, at $85$ and $170$. Then every values undefined will be an weighted average of the two closest defined colors.

stuff function makePalette(colors) { var palette = []; var red = []; var green = []; var blue = []; var max = Math.floor(255 / colors.length); for(var i = 0; i < colors.length; i++) { var j = (i+1 == colors.length)?0:i+1; red[i] = (colors[i]['r'] - colors[j]['r']) / max; green[i] = (colors[i]['g'] - colors[j]['g']) / max; blue[i] = (colors[i]['b'] - colors[j]['b']) / max; } for(var i = 0; i <= max; i++) { for(var j = 0; j < colors.length; j++) { palette[j * (max + 1) + i] = { r : Math.floor(colors[j]['r'] - red[j] * i), g : Math.floor(colors[j]['g'] - green[j] * i), b : Math.floor(colors[j]['b'] - blue[j] * i) }; } } return palette; }

Here's an example of a palette with one boundary color and one other color :

And here is one with three other colors :
Now instead of the sine wave giving us values of shades of gray, we can make it give values in the array of colors of the palette.

4. Mixing waves

The effect becomes most useful when we start mixing waves together. In this case, to not exceed the value of $255$, each wave will only contribute a fraction of its value (for $n$ sine waves, they each contribute a fraction of $1/n$ their usual value).

stuff function fillCanvasColorMultiple(f, palette, param) { var pixel; var max = f.length; for(var x = 0; x < width; x++) { for(var y = 0; y < height; y++) { pixel = (x + y * width) * 4; var s = 0; for(var i = 0; i < max; i++) { s += f[i](x, y, param[i][0], param[i][1], param[i][2], param[i][3]) / max; } var color = palette[Math.floor(s)]; imgData.data[pixel] = color.r; imgData.data[pixel+1] = color.g; imgData.data[pixel+2] = color.b; } } }

For instance, here's the superposition of two radial sine waves on each side of the canvas.

The superposition of two orthogonal plane waves

And the superposition of a radial wave and a plane wave.

5. Further types of waves

Two simple generalizations of radial waves are to simply take the other two most common distance measurements, the Manhattan distance and the Chebyshev distance, the two boundaries of the $L_p$ distance $$d_p(a,b) = (|a_x - b_x|^p + |a_y - b_y|^p)^{1/p}$$ The Manhattan distance being simply the $L_1$ distance defined by $d_1(a,b) = |a_x - b_x| + |a_y - b_y|$, while the Chebyshev is the limit
stuff function Manhattan(x, y, phase, centerX, centerY, diameter) { var dx = Math.abs(x - centerX); var dy = Math.abs(y - centerY); return sine(Math.floor((dx + dy) * diameter + phase) ); }
stuff function Chebyshev (x, y, phase, centerX, centerY, diameter) { var dx = Math.abs(x - centerX); var dy = Math.abs(y - centerY); return sine(Math.floor(Math.max(dx, dy) * diameter + phase) ); }

More generally, we can use the superellipse formula for a varierty of shapes

$$((\frac{x}{a})^n + (\frac{y}{b})^n)^{-\frac 1n} = r$$
It may also be interesting to simply input random formulas in it, which may give interesting shapes. For instance, here is what happen when replacing the radius $r$ by its sine $\sin(r)$ for a radial wave

or for a plane wave

To avoid having to do computationally expensive square roots, it can be interesting to use the radius squared instead of the radius

$$\sin(x^2 + y^2)$$ Here's the result for a distance computed as $x \times y$.

It may also be interesting to switch the sine with another periodic function (which may be less computationally expensive, for instance), such as the sawtooth function

or the triangle function

6. Putting it all together

We can now combine this variety of effects, perhaps with a randomizer, to obtain different plasma effects.

7. Optimization

As every pixel on screen is completely independant of neighbouring pixels, and all use the same formula with different parameters, it is an algorithm very easily optimizable for use with GPUs. Rather than compute the value of every pixel sequentially, we can do a block computation of groups of pixels with the GPU.

If we have a total of $N$ pixels on screen to compute, and $M$ available blocks available in the GPU, we can run computations on groups of $N$%$M$ pixels. To avoid doing the same computations over and over, we'll also save screen coordinates $(x,y)$ into the DRAM. Every pixel will be represented in the DRAM by two integers, the screen position $x$ and $y$, while the color of each pixel will be saved in a pixel buffer object (or PBO) for later display.

Unrolling the sum over waves


Last updated : 2017-08-29 14:53:45
Tags : graphics , graphical-effects , javascript , tutorial