Skip to content

Commit

Permalink
feat: GPU particles implementation (#3212)
Browse files Browse the repository at this point in the history
GPU Particle Implementation! Added GPU particle implementation for MANY MANY particles in the simulation, similar to the existing CPU particle implementation. Note `maxParticles` is new for GPU particles.

  var particles = new ex.GpuParticleEmitter({
    pos: ex.vec(300, 500),
    maxParticles: 10_000,
    emitRate: 1000,
    radius: 100,
    emitterType: ex.EmitterType.Circle,
    particle: {
      beginColor: ex.Color.Orange,
      endColor: ex.Color.Purple,
      focus: ex.vec(0, -400),
      focusAccel: 1000,
      startSize: 100,
      endSize: 0,
      life: 3000,
      minSpeed: -100,
      maxSpeed: 100,
      angularVelocity: 2,
      randomRotation: true,
      transform: ex.ParticleTransform.Local
    }
  });
  • Loading branch information
eonarheim authored Dec 2, 2024
1 parent 4ab4c0b commit cd834a5
Show file tree
Hide file tree
Showing 27 changed files with 1,207 additions and 35 deletions.
27 changes: 27 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,8 @@ This project adheres to [Semantic Versioning](http://semver.org/).
* `fadeFlag` is renamed to `fade`
* `acceleration` is renamed to `acc`
* `particleLife` is renamed to `life`
* `minVel` is renamed to `minSpeed`
* `maxVel` is renamed to `maxSpeed`
* `ParticleEmitter` now takes a separate `particle: ParticleConfig` parameter to disambiguate between particles parameters and emitter ones
```typescript
const emitter = new ex.ParticleEmitter({
Expand Down Expand Up @@ -76,6 +78,31 @@ This project adheres to [Semantic Versioning](http://semver.org/).

### Added

- Added GPU particle implementation for MANY MANY particles in the simulation, similar to the existing CPU particle implementation. Note `maxParticles` is new for GPU particles.
```typescript
var particles = new ex.GpuParticleEmitter({
pos: ex.vec(300, 500),
maxParticles: 10_000,
emitRate: 1000,
radius: 100,
emitterType: ex.EmitterType.Circle,
particle: {
beginColor: ex.Color.Orange,
endColor: ex.Color.Purple,
focus: ex.vec(0, -400),
focusAccel: 1000,
startSize: 100,
endSize: 0,
life: 3000,
minSpeed: -100,
maxSpeed: 100,
angularVelocity: 2,
randomRotation: true,
transform: ex.ParticleTransform.Local
}
});
```
- Added `ex.assert()` that can be used to throw in development builds
- Added `easing` option to `moveTo(...)`
- Added new option bag style input to actions with durations in milliseconds instead of speed
```typescript
Expand Down
2 changes: 1 addition & 1 deletion karma.conf.js
Original file line number Diff line number Diff line change
Expand Up @@ -134,7 +134,7 @@ module.exports = (config) => {
plugins: [
new webpack.DefinePlugin({
'process.env.__EX_VERSION': "'test-runner'",
'process.env.NODE_ENV': JSON.stringify('test')
'process.env.NODE_ENV': JSON.stringify('development')
})
],
module: {
Expand Down
4 changes: 2 additions & 2 deletions sandbox/src/game.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1046,8 +1046,8 @@ var emitter = new ex.ParticleEmitter({
width: 2,
height: 2,
particle: {
minVel: 417,
maxVel: 589,
minSpeed: 417,
maxSpeed: 589,
minAngle: Math.PI,
maxAngle: Math.PI * 2,
opacity: 0.84,
Expand Down
4 changes: 2 additions & 2 deletions sandbox/tests/emitter/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,8 +34,8 @@ var emitter = new ex.ParticleEmitter({
endColor: ex.Color.Magenta,
startSize: 5,
endSize: 100,
minVel: 100,
maxVel: 200,
minSpeed: 100,
maxSpeed: 200,
minAngle: 5.1,
maxAngle: 6.2,
fade: true,
Expand Down
12 changes: 12 additions & 0 deletions sandbox/tests/gpu-particles/index.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>GPU Particles</title>
</head>
<body>
<script src="../../lib/excalibur.js"></script>
<script src="./index.js"></script>
</body>
</html>
66 changes: 66 additions & 0 deletions sandbox/tests/gpu-particles/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
var game = new ex.Engine({
width: 1000,
height: 1000,
displayMode: ex.DisplayMode.FitScreen
});

var swordImg = new ex.ImageSource('https://cdn.rawgit.com/excaliburjs/Excalibur/7dd48128/assets/sword.png');

var particles = new ex.GpuParticleEmitter({
pos: ex.vec(300, 500),
maxParticles: 10_000,
emitRate: 1000,
radius: 100,
width: 200,
height: 100,
emitterType: ex.EmitterType.Rectangle,
particle: {
acc: ex.vec(0, -100),
// opacity: 0.1,
beginColor: ex.Color.Orange,
endColor: ex.Color.Purple,
// fade: true,
focus: ex.vec(0, -400),
focusAccel: 1000,
startSize: 100,
endSize: 0,
life: 3000,
minSpeed: -100,
maxSpeed: 100,
angularVelocity: 2,
randomRotation: true,
transform: ex.ParticleTransform.Local
// graphic: swordImg.toSprite()
}
});

game.input.pointers.primary.on('move', (evt) => {
particles.pos.x = evt.worldPos.x;
particles.pos.y = evt.worldPos.y;
});

particles.isEmitting = true;
game.add(particles);

game.add(
new ex.Actor({
width: 200,
height: 100,
color: ex.Color.Red,
pos: ex.vec(400, 400)
})
);

// var particles2 = new ex.GpuParticleEmitter({
// pos: ex.vec(700, 500),
// particle: {
// beginColor: ex.Color.Blue,
// endColor: ex.Color.Rose,
// fade: true,
// startSize: 50,
// endSize: 20
// }
// });
// game.add(particles2);

game.start(new ex.Loader([swordImg]));
4 changes: 3 additions & 1 deletion src/engine/Graphics/Context/ExcaliburGraphicsContextWebGL.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ import { Material, MaterialOptions } from './material';
import { MaterialRenderer } from './material-renderer/material-renderer';
import { Shader, ShaderOptions } from './shader';
import { GarbageCollector } from '../../GarbageCollector';
import { ParticleRenderer } from './particle-renderer/particle-renderer';
import { ImageRendererV2 } from './image-renderer-v2/image-renderer-v2';
import { Flags } from '../../Flags';

Expand Down Expand Up @@ -317,6 +318,7 @@ export class ExcaliburGraphicsContextWebGL implements ExcaliburGraphicsContext {
this.register(new CircleRenderer());
this.register(new PointRenderer());
this.register(new LineRenderer());
this.register(new ParticleRenderer());
this.register(
new ImageRendererV2({
uvPadding: this.uvPadding,
Expand All @@ -326,7 +328,7 @@ export class ExcaliburGraphicsContextWebGL implements ExcaliburGraphicsContext {

this.materialScreenTexture = gl.createTexture();
if (!this.materialScreenTexture) {
throw new Error('');
throw new Error('Could not create screen texture!');
}
gl.bindTexture(gl.TEXTURE_2D, this.materialScreenTexture);
gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, this.width, this.height, 0, gl.RGBA, gl.UNSIGNED_BYTE, null);
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
#version 300 es
precision mediump float;

uniform sampler2D graphic;
uniform bool useTexture;
uniform float maxLifeMs;

uniform vec4 beginColor;
uniform vec4 endColor;
uniform bool fade;
uniform float startOpacity;

in float finalRotation;
in float finalLifeMs;
out vec4 fragColor;

void main(){

float lifePct = finalLifeMs / maxLifeMs;

if (useTexture) {
/** Draw texture */
if (lifePct <= 0.) discard;
float mid = .5;
float cosine = cos(finalRotation);
float sine = sin(finalRotation);
vec2 rotated = vec2(cosine * (gl_PointCoord.x - mid) + sine * (gl_PointCoord.y - mid) + mid,
cosine * (gl_PointCoord.y - mid) - sine * (gl_PointCoord.x - mid) + mid);
vec4 color = texture(graphic, rotated);
fragColor = color * (fade ? lifePct : 1.0);
} else {
/** Draw circle */
if (lifePct <= 0.) discard;
vec2 uv = gl_PointCoord.xy * 2.0 - 1.0;
float dist = 1.0 - length(uv);
float edge = fwidth(dot(uv, uv));
float circle = smoothstep(-edge/2.0, edge/2.0, dist);
vec4 color = mix(beginColor, endColor, 1.0 - lifePct) * startOpacity;
fragColor = color * (fade ? lifePct : 1.0) * circle;
}
}
128 changes: 128 additions & 0 deletions src/engine/Graphics/Context/particle-renderer/particle-renderer.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
import { ExcaliburGraphicsContextWebGL } from '../ExcaliburGraphicsContextWebGL';
import { RendererPlugin } from '../renderer';
import { Shader } from '../shader';
import particleVertexSource from './particle-vertex.glsl';
import particleFragmentSource from './particle-fragment.glsl';
import { GpuParticleRenderer } from '../../../Particles/GpuParticleRenderer';
import { vec } from '../../../Math/vector';
import { Color } from '../../../Color';
import { HTMLImageSource } from '../ExcaliburGraphicsContext';
import { ImageSourceAttributeConstants } from '../../ImageSource';
import { parseImageWrapping } from '../../Wrapping';
import { parseImageFiltering } from '../../Filtering';
import { AffineMatrix } from '../../../Math/affine-matrix';
import { ParticleTransform } from '../../../Particles/Particles';

export class ParticleRenderer implements RendererPlugin {
public readonly type = 'ex.particle' as const;
public priority: number = 0;
private _gl!: WebGL2RenderingContext;
private _context!: ExcaliburGraphicsContextWebGL;
private _shader!: Shader;

initialize(gl: WebGL2RenderingContext, context: ExcaliburGraphicsContextWebGL): void {
this._gl = gl;
this._context = context;
this._shader = new Shader({
gl,
vertexSource: particleVertexSource,
fragmentSource: particleFragmentSource,
onPreLink: (program) => {
gl.transformFeedbackVaryings(
program,
['finalPosition', 'finalVelocity', 'finalRotation', 'finalAngularVelocity', 'finalLifeMs'],
gl.INTERLEAVED_ATTRIBS
);
}
});
this._shader.compile();
this._shader.use();
this._shader.setUniformMatrix('u_matrix', this._context.ortho);
}

private _getTexture(image: HTMLImageSource) {
const maybeFiltering = image.getAttribute(ImageSourceAttributeConstants.Filtering);
const filtering = maybeFiltering ? parseImageFiltering(maybeFiltering) : undefined;
const wrapX = parseImageWrapping(image.getAttribute(ImageSourceAttributeConstants.WrappingX) as any);
const wrapY = parseImageWrapping(image.getAttribute(ImageSourceAttributeConstants.WrappingY) as any);

const force = image.getAttribute('forceUpload') === 'true' ? true : false;
const texture = this._context.textureLoader.load(
image,
{
filtering,
wrapping: { x: wrapX, y: wrapY }
},
force
)!;
// remove force attribute after upload
image.removeAttribute('forceUpload');
return texture;
}

draw(renderer: GpuParticleRenderer, elapsedMs: number): void {
const gl = this._gl;

this._shader.use();
this._shader.setUniformMatrix('u_matrix', this._context.ortho);
const transform = renderer.particle.transform === ParticleTransform.Local ? this._context.getTransform() : AffineMatrix.identity();
this._shader.setUniformAffineMatrix('u_transform', transform);
this._shader.setUniformBoolean('fade', renderer.particle.fade ? true : false);
this._shader.setUniformBoolean('useTexture', renderer.particle.graphic ? true : false);
this._shader.setUniformFloat('maxLifeMs', renderer.particle.life ?? 2000);
this._shader.setUniformFloat('deltaMs', elapsedMs);
this._shader.setUniformFloatVector('gravity', renderer.particle.acc ?? vec(0, 0));
this._shader.setUniformFloatColor('beginColor', renderer.particle.beginColor ?? Color.Transparent);
this._shader.setUniformFloatColor('endColor', renderer.particle.endColor ?? Color.Transparent);

let startSize = renderer.particle.startSize ?? 0;
let endSize = renderer.particle.endSize ?? 0;
const size = renderer.particle.size ?? 0;
if (size > 0) {
startSize = size;
endSize = size;
}

this._shader.setUniformFloat('startSize', startSize ?? 10);
this._shader.setUniformFloat('endSize', endSize ?? 10);
this._shader.setUniformFloat('startOpacity', renderer.particle.opacity ?? 1);

if (renderer.particle.focus) {
this._shader.setUniformFloatVector('focus', renderer.particle.focus);
this._shader.setUniformFloat('focusAccel', renderer.particle.focusAccel ?? 0);
}

// Particle Graphic (only Sprites right now)
if (renderer.particle.graphic) {
const graphic = renderer.particle.graphic;

const texture = this._getTexture(graphic.image.image);

gl.activeTexture(gl.TEXTURE0);
gl.bindTexture(gl.TEXTURE_2D, texture);
this._shader.setUniformInt('graphic', 0);
}

// Collision Mask
// gl.activeTexture(gl.TEXTURE0 + 1);
// gl.bindTexture(gl.TEXTURE_2D, obstacleTex);
// gl.uniform1i(u_obstacle, 1);

// Blending wont work because ex doesn't have a depth attachment
// gl.enable(gl.DEPTH_TEST);
// gl.enable(gl.BLEND);
// gl.blendEquation(gl.FUNC_ADD);
// gl.blendFunc(gl.SRC_ALPHA, gl.ONE_MINUS_SRC_ALPHA);

renderer.draw(gl);
}
hasPendingDraws(): boolean {
return false;
}
flush(): void {
// pass
}
dispose(): void {
// pass
}
}
Loading

0 comments on commit cd834a5

Please sign in to comment.