From cd834a54d96e66927c42dc5493195d657af1d981 Mon Sep 17 00:00:00 2001 From: Erik Onarheim Date: Sun, 1 Dec 2024 21:55:56 -0600 Subject: [PATCH] feat: GPU particles implementation (#3212) 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 } }); --- CHANGELOG.md | 27 ++ karma.conf.js | 2 +- sandbox/src/game.ts | 4 +- sandbox/tests/emitter/index.ts | 4 +- sandbox/tests/gpu-particles/index.html | 12 + sandbox/tests/gpu-particles/index.ts | 66 ++++ .../Context/ExcaliburGraphicsContextWebGL.ts | 4 +- .../particle-renderer/particle-fragment.glsl | 41 +++ .../particle-renderer/particle-renderer.ts | 128 +++++++ .../particle-renderer/particle-vertex.glsl | 57 +++ src/engine/Graphics/Context/shader.ts | 42 ++- src/engine/Graphics/index.ts | 3 + src/engine/{ => Particles}/EmitterType.ts | 4 +- src/engine/Particles/GpuParticleEmitter.ts | 101 ++++++ src/engine/Particles/GpuParticleRenderer.ts | 312 +++++++++++++++++ src/engine/Particles/ParticleEmitter.ts | 6 +- src/engine/Particles/Particles.ts | 26 +- src/engine/Particles/index.ts | 5 + src/engine/Util/Assert.ts | 10 + src/engine/index.ts | 5 +- src/spec/AssertSpec.ts | 22 ++ src/spec/GpuParticleSpec.ts | 328 ++++++++++++++++++ src/spec/ParticleSpec.ts | 24 +- .../GpuParticlesSpec/particles-wrapped.png | Bin 0 -> 9570 bytes .../images/GpuParticlesSpec/particles.png | Bin 0 -> 6498 bytes src/stories/ParticleEmitter.stories.ts | 4 +- wallaby.js | 5 + 27 files changed, 1207 insertions(+), 35 deletions(-) create mode 100644 sandbox/tests/gpu-particles/index.html create mode 100644 sandbox/tests/gpu-particles/index.ts create mode 100644 src/engine/Graphics/Context/particle-renderer/particle-fragment.glsl create mode 100644 src/engine/Graphics/Context/particle-renderer/particle-renderer.ts create mode 100644 src/engine/Graphics/Context/particle-renderer/particle-vertex.glsl rename src/engine/{ => Particles}/EmitterType.ts (81%) create mode 100644 src/engine/Particles/GpuParticleEmitter.ts create mode 100644 src/engine/Particles/GpuParticleRenderer.ts create mode 100644 src/engine/Particles/index.ts create mode 100644 src/engine/Util/Assert.ts create mode 100644 src/spec/AssertSpec.ts create mode 100644 src/spec/GpuParticleSpec.ts create mode 100644 src/spec/images/GpuParticlesSpec/particles-wrapped.png create mode 100644 src/spec/images/GpuParticlesSpec/particles.png diff --git a/CHANGELOG.md b/CHANGELOG.md index 83f0c3229..bc032aee3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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({ @@ -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 diff --git a/karma.conf.js b/karma.conf.js index 0b53e2105..4c97d47e6 100644 --- a/karma.conf.js +++ b/karma.conf.js @@ -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: { diff --git a/sandbox/src/game.ts b/sandbox/src/game.ts index be34177ab..d46a5da8d 100644 --- a/sandbox/src/game.ts +++ b/sandbox/src/game.ts @@ -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, diff --git a/sandbox/tests/emitter/index.ts b/sandbox/tests/emitter/index.ts index c9832f74e..bb01c9fb1 100644 --- a/sandbox/tests/emitter/index.ts +++ b/sandbox/tests/emitter/index.ts @@ -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, diff --git a/sandbox/tests/gpu-particles/index.html b/sandbox/tests/gpu-particles/index.html new file mode 100644 index 000000000..47905cbb7 --- /dev/null +++ b/sandbox/tests/gpu-particles/index.html @@ -0,0 +1,12 @@ + + + + + + GPU Particles + + + + + + diff --git a/sandbox/tests/gpu-particles/index.ts b/sandbox/tests/gpu-particles/index.ts new file mode 100644 index 000000000..55d9054f7 --- /dev/null +++ b/sandbox/tests/gpu-particles/index.ts @@ -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])); diff --git a/src/engine/Graphics/Context/ExcaliburGraphicsContextWebGL.ts b/src/engine/Graphics/Context/ExcaliburGraphicsContextWebGL.ts index 8248a7284..bf656d8d7 100644 --- a/src/engine/Graphics/Context/ExcaliburGraphicsContextWebGL.ts +++ b/src/engine/Graphics/Context/ExcaliburGraphicsContextWebGL.ts @@ -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'; @@ -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, @@ -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); diff --git a/src/engine/Graphics/Context/particle-renderer/particle-fragment.glsl b/src/engine/Graphics/Context/particle-renderer/particle-fragment.glsl new file mode 100644 index 000000000..62bcca1fb --- /dev/null +++ b/src/engine/Graphics/Context/particle-renderer/particle-fragment.glsl @@ -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; + } +} \ No newline at end of file diff --git a/src/engine/Graphics/Context/particle-renderer/particle-renderer.ts b/src/engine/Graphics/Context/particle-renderer/particle-renderer.ts new file mode 100644 index 000000000..0ebda2bc9 --- /dev/null +++ b/src/engine/Graphics/Context/particle-renderer/particle-renderer.ts @@ -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 + } +} diff --git a/src/engine/Graphics/Context/particle-renderer/particle-vertex.glsl b/src/engine/Graphics/Context/particle-renderer/particle-vertex.glsl new file mode 100644 index 000000000..05946c6cd --- /dev/null +++ b/src/engine/Graphics/Context/particle-renderer/particle-vertex.glsl @@ -0,0 +1,57 @@ +#version 300 es +precision mediump float; + +uniform float deltaMs; +uniform float maxLifeMs; +uniform vec2 gravity; +uniform vec2 focus; +uniform float focusAccel; +uniform mat4 u_matrix; +uniform mat4 u_transform; +uniform float startSize; +uniform float endSize; +// uniform sampler2D obstacle; + +layout(location=0)in vec2 position; +layout(location=1)in vec2 velocity; +layout(location=2)in float rotation; +layout(location=3)in float angularVelocity; +layout(location=4)in float lifeMs; + +// TODO z index to handle buffer wrapping? + +// DO NOT RE-ORDER +out vec2 finalPosition; +out vec2 finalVelocity; +out float finalRotation; +out float finalAngularVelocity; +out float finalLifeMs; +void main(){ + // Evolve particle + float seconds = deltaMs / 1000.; + // euler integration + // Weird artifact of re-using the same buffer layout for update/draw + // we need differently named variables + vec2 finalGravity = gravity + normalize(focus - position) * focusAccel; + finalVelocity = velocity + finalGravity * seconds; + finalPosition = position + velocity * seconds + finalGravity * .5 * seconds * seconds; + finalRotation = rotation + angularVelocity * seconds; + finalAngularVelocity = angularVelocity; + finalLifeMs = clamp(lifeMs - deltaMs, 0., maxLifeMs); + + // Collision mask sampling + // vec2 samplePoint = finalPosition / vec2(width, height); + // vec4 collides = texture(obstacle, samplePoint); + // if (distance(collides,vec4(0.)) > .01) { + // // non opaque means we collide! recalc final pos/vel + // vec2 newVelocity = velocity * -.1;// lose energy + // finalVelocity = newVelocity + gravity * seconds; + // finalPosition = position + newVelocity * seconds + gravity * .5 * seconds * seconds; + // } + + float lifePercent = finalLifeMs / maxLifeMs; + vec2 transformedPos = (u_matrix * u_transform * vec4(finalPosition,0.,1.)).xy; + + gl_Position = vec4(transformedPos, 1.0 - lifePercent, 1.); // use life percent to sort z + gl_PointSize = mix(startSize, endSize, 1.0 - lifePercent); +} \ No newline at end of file diff --git a/src/engine/Graphics/Context/shader.ts b/src/engine/Graphics/Context/shader.ts index c63df66e5..cbc2713ca 100644 --- a/src/engine/Graphics/Context/shader.ts +++ b/src/engine/Graphics/Context/shader.ts @@ -1,4 +1,4 @@ -import { Color, Logger, Vector } from '../..'; +import { AffineMatrix, Color, Logger, Vector } from '../..'; import { Matrix } from '../../Math/matrix'; import { getAttributeComponentSize, getAttributePointerType } from './webgl-util'; @@ -85,6 +85,9 @@ export interface ShaderOptions { * Fragment shader source code in glsl #version 300 es */ fragmentSource: string; + + onPreLink?: (program: WebGLProgram) => void; + onPostCompile?: (shader: Shader) => void; } export class Shader { @@ -97,6 +100,8 @@ export class Shader { private _compiled = false; public readonly vertexSource: string; public readonly fragmentSource: string; + private _onPreLink?: (program: WebGLProgram) => void; + private _onPostCompile?: (shader: Shader) => void; public get compiled() { return this._compiled; @@ -107,10 +112,12 @@ export class Shader { * @param options specify shader vertex and fragment source */ constructor(options: ShaderOptions) { - const { gl, vertexSource, fragmentSource } = options; + const { gl, vertexSource, fragmentSource, onPreLink, onPostCompile } = options; this._gl = gl; this.vertexSource = vertexSource; this.fragmentSource = fragmentSource; + this._onPreLink = onPreLink; + this._onPostCompile = onPostCompile; } dispose() { @@ -151,6 +158,9 @@ export class Shader { } this._compiled = true; + if (this._onPostCompile) { + this._onPostCompile(this); + } return this.program; } @@ -363,6 +373,30 @@ export class Shader { this.setUniform('uniformMatrix4fv', name, false, value.data); } + setUniformAffineMatrix(name: string, value: AffineMatrix) { + this.setUniform('uniformMatrix4fv', name, false, [ + value.data[0], + value.data[1], + 0, + 0, + + value.data[2], + value.data[3], + 0, + 0, + + 0, + 0, + 1, + 0, + + value.data[4], + value.data[5], + 0, + 1 + ]); + } + /** * Set an {@apilink Matrix} uniform for the current shader, WILL NOT THROW on error. * @@ -448,6 +482,10 @@ export class Shader { gl.attachShader(program, vertexShader); gl.attachShader(program, fragmentShader); + if (this._onPreLink) { + this._onPreLink(program); + } + // link the program. gl.linkProgram(program); diff --git a/src/engine/Graphics/index.ts b/src/engine/Graphics/index.ts index 8c4055e54..8e44f558c 100644 --- a/src/engine/Graphics/index.ts +++ b/src/engine/Graphics/index.ts @@ -31,6 +31,9 @@ export * from './Context/ExcaliburGraphicsContext'; export * from './Context/ExcaliburGraphicsContext2DCanvas'; export * from './Context/ExcaliburGraphicsContextWebGL'; +// TODO DELETEME +export * from './Context/particle-renderer/particle-renderer'; + export * from './Context/debug-text'; // Post Processor diff --git a/src/engine/EmitterType.ts b/src/engine/Particles/EmitterType.ts similarity index 81% rename from src/engine/EmitterType.ts rename to src/engine/Particles/EmitterType.ts index 027614b61..d8966cfab 100644 --- a/src/engine/EmitterType.ts +++ b/src/engine/Particles/EmitterType.ts @@ -5,9 +5,9 @@ export enum EmitterType { /** * Constant for the circular emitter type */ - Circle, + Circle = 'circle', /** * Constant for the rectangular emitter type */ - Rectangle + Rectangle = 'rectangle' } diff --git a/src/engine/Particles/GpuParticleEmitter.ts b/src/engine/Particles/GpuParticleEmitter.ts new file mode 100644 index 000000000..a9a994a76 --- /dev/null +++ b/src/engine/Particles/GpuParticleEmitter.ts @@ -0,0 +1,101 @@ +import { Actor, clamp, Engine, ExcaliburGraphicsContextWebGL, GraphicsComponent, Random, vec, Vector } from '../'; +import { EmitterType } from './EmitterType'; +import { ParticleEmitterArgs, ParticleTransform } from './Particles'; +import { GpuParticleConfig, GpuParticleRenderer } from './GpuParticleRenderer'; + +export class GpuParticleEmitter extends Actor { + public particle: GpuParticleConfig = { + /** + * Gets or sets the life of each particle in milliseconds + */ + life: 2000, + transform: ParticleTransform.Global, + graphic: undefined, + opacity: 1, + angularVelocity: 0, + focus: undefined, + focusAccel: undefined, + randomRotation: false + }; + + public graphics = new GraphicsComponent(); + public renderer: GpuParticleRenderer; + public isEmitting: boolean = false; + public emitRate: number = 1; + public emitterType: EmitterType = EmitterType.Rectangle; + public radius: number = 0; + public readonly maxParticles: number = 2000; + random: Random; + + public get pos() { + return this.transform.pos; + } + + public set pos(pos: Vector) { + this.transform.pos = pos; + } + + public get z() { + return this.transform.z; + } + + public set z(z: number) { + this.transform.z = z; + } + + constructor(config: ParticleEmitterArgs & { maxParticles?: number; particle?: GpuParticleConfig }) { + super({ name: `GpuParticleEmitter`, width: config.width, height: config.height }); // somewhat goofy way of doing width/height + this.addComponent(this.graphics, true); + (this.graphics.onPostDraw as any) = this.draw.bind(this); + + const { particle, maxParticles, x, y, z, pos, isEmitting, emitRate, emitterType, radius, random } = { ...config }; + + this.maxParticles = clamp(maxParticles ?? this.maxParticles, 0, GpuParticleRenderer.GPU_MAX_PARTICLES); + + this.pos = pos ?? vec(x ?? 0, y ?? 0); + + this.z = z ?? 0; + + this.isEmitting = isEmitting ?? this.isEmitting; + + this.emitRate = emitRate ?? this.emitRate; + + this.emitterType = emitterType ?? this.emitterType; + + this.radius = radius ?? this.radius; + + this.particle = { ...this.particle, ...particle }; + + this.random = random ?? new Random(); + + this.renderer = new GpuParticleRenderer(this, this.random, this.particle); + } + + public _initialize(engine: Engine): void { + super._initialize(engine); + const context = engine.graphicsContext as ExcaliburGraphicsContextWebGL; + this.renderer.initialize(context.__gl, context); + } + + private _particlesToEmit = 0; + public update(engine: Engine, elapsedMs: number): void { + super.update(engine, elapsedMs); + + if (this.isEmitting) { + this._particlesToEmit += this.emitRate * (elapsedMs / 1000); + if (this._particlesToEmit > 1.0) { + this.emitParticles(Math.floor(this._particlesToEmit)); + this._particlesToEmit = this._particlesToEmit - Math.floor(this._particlesToEmit); + } + } + this.renderer.update(elapsedMs); + } + + public emitParticles(particleCount: number) { + this.renderer.emitParticles(particleCount); + } + + draw(ctx: ExcaliburGraphicsContextWebGL, elapsedMilliseconds: number) { + ctx.draw('ex.particle', this.renderer, elapsedMilliseconds); + } +} diff --git a/src/engine/Particles/GpuParticleRenderer.ts b/src/engine/Particles/GpuParticleRenderer.ts new file mode 100644 index 000000000..9236ddd99 --- /dev/null +++ b/src/engine/Particles/GpuParticleRenderer.ts @@ -0,0 +1,312 @@ +import { TwoPI } from '../Math/util'; +import { ExcaliburGraphicsContextWebGL } from '../Graphics/Context/ExcaliburGraphicsContextWebGL'; +import { GpuParticleEmitter } from './GpuParticleEmitter'; +import { ParticleConfig, ParticleTransform } from './Particles'; +import { Random } from '../Math/Random'; +import { Sprite } from '../Graphics/Sprite'; +import { EmitterType } from './EmitterType'; +import { assert } from '../Util/Assert'; + +export interface GpuParticleConfig extends ParticleConfig { + /** + * Only Sprite graphics are supported in GPU particles at the moment + */ + graphic?: Sprite; + /** + * Set the maximum particles to use for this emitter + */ + maxParticles?: number; +} + +/** + * Container for the GPU Particle State contains the internal state needed for the GPU + * to render particles and maintain state. + */ +export class GpuParticleRenderer { + static readonly GPU_MAX_PARTICLES: number = 100_000; + emitter: GpuParticleEmitter; + emitRate: number = 1; + particle: GpuParticleConfig; + + private _initialized: boolean = false; + private _vaos: WebGLVertexArrayObject[] = []; + private _buffers: WebGLBuffer[] = []; + private _random: Random; + + private _drawIndex = 0; + private _currentVao!: WebGLVertexArrayObject; + private _currentBuffer!: WebGLBuffer; + + private _numInputFloats = 2 + 2 + 1 + 1 + 1; + private _particleData: Float32Array; + private _particleIndex = 0; + private _uploadIndex: number = 0; + + private _wrappedLife = 0; + private _wrappedParticles = 0; + private _particleLife = 0; + + constructor(emitter: GpuParticleEmitter, random: Random, options: GpuParticleConfig) { + this.emitter = emitter; + this.particle = options; + this._particleData = new Float32Array(this.emitter.maxParticles * this._numInputFloats); + this._random = random; + this._particleLife = this.particle.life ?? 2000; + } + + public get isInitialized() { + return this._initialized; + } + + public get maxParticles(): number { + return this.emitter.maxParticles; + } + + initialize(gl: WebGL2RenderingContext, context: ExcaliburGraphicsContextWebGL) { + if (this._initialized) { + return; + } + + const numParticles = this.emitter.maxParticles; + const numInputFloats = this._numInputFloats; + const particleData = this._particleData; + const bytesPerFloat = 4; + + const particleDataBuffer1 = gl.createBuffer()!; + const vao1 = gl.createVertexArray()!; + gl.bindVertexArray(vao1); + gl.bindBuffer(gl.ARRAY_BUFFER, particleDataBuffer1); + gl.bufferData(gl.ARRAY_BUFFER, numParticles * numInputFloats * bytesPerFloat, gl.DYNAMIC_DRAW); + gl.bufferSubData(gl.ARRAY_BUFFER, 0, particleData); + let offset = 0; + // position + gl.vertexAttribPointer(0, 2, gl.FLOAT, false, numInputFloats * bytesPerFloat, 0); + offset += bytesPerFloat * 2; + // velocity + gl.vertexAttribPointer(1, 2, gl.FLOAT, false, numInputFloats * bytesPerFloat, offset); + offset += bytesPerFloat * 2; + // rotation + gl.vertexAttribPointer(2, 1, gl.FLOAT, false, numInputFloats * bytesPerFloat, offset); + offset += bytesPerFloat * 1; + // angularVelocity + gl.vertexAttribPointer(3, 1, gl.FLOAT, false, numInputFloats * bytesPerFloat, offset); + offset += bytesPerFloat * 1; + // life + gl.vertexAttribPointer(4, 1, gl.FLOAT, false, numInputFloats * bytesPerFloat, offset); + offset += bytesPerFloat * 1; + + // enable attributes + gl.enableVertexAttribArray(0); + gl.enableVertexAttribArray(1); + gl.enableVertexAttribArray(2); + gl.enableVertexAttribArray(3); + gl.enableVertexAttribArray(4); + + this._vaos.push(vao1); + this._buffers.push(particleDataBuffer1); + // Clean up + gl.bindVertexArray(null); + gl.bindBuffer(gl.ARRAY_BUFFER, null); + + const particleDataBuffer2 = gl.createBuffer()!; + const vao2 = gl.createVertexArray()!; + gl.bindVertexArray(vao2); + gl.bindBuffer(gl.ARRAY_BUFFER, particleDataBuffer2); + gl.bufferData(gl.ARRAY_BUFFER, numParticles * numInputFloats * bytesPerFloat, gl.DYNAMIC_DRAW); + offset = 0; + // position + gl.vertexAttribPointer(0, 2, gl.FLOAT, false, numInputFloats * bytesPerFloat, 0); + offset += bytesPerFloat * 2; + // velocity + gl.vertexAttribPointer(1, 2, gl.FLOAT, false, numInputFloats * bytesPerFloat, offset); + offset += bytesPerFloat * 2; + // rotation + gl.vertexAttribPointer(2, 1, gl.FLOAT, false, numInputFloats * bytesPerFloat, offset); + offset += bytesPerFloat * 1; + // angularVelocity + gl.vertexAttribPointer(3, 1, gl.FLOAT, false, numInputFloats * bytesPerFloat, offset); + offset += bytesPerFloat * 1; + // life + gl.vertexAttribPointer(4, 1, gl.FLOAT, false, numInputFloats * bytesPerFloat, offset); + offset += bytesPerFloat * 1; + + // enable attributes + gl.enableVertexAttribArray(0); + gl.enableVertexAttribArray(1); + gl.enableVertexAttribArray(2); + gl.enableVertexAttribArray(3); + gl.enableVertexAttribArray(4); + + this._vaos.push(vao2); + this._buffers.push(particleDataBuffer2); + + // Clean up + gl.bindVertexArray(null); + gl.bindBuffer(gl.ARRAY_BUFFER, null); + + this._currentVao = this._vaos[this._drawIndex % 2]; + this._currentBuffer = this._buffers[(this._drawIndex + 1) % 2]; + + this._initialized = true; + } + + private _emitted: [life: number, index: number][] = []; + emitParticles(particleCount: number) { + const startIndex = this._particleIndex; + const maxSize = this.maxParticles * this._numInputFloats; + const endIndex = particleCount * this._numInputFloats + startIndex; + let countParticle = 0; + for (let i = startIndex; i < endIndex; i += this._numInputFloats) { + const angle = this._random.floating(this.particle.minAngle || 0, this.particle.maxAngle || TwoPI); + let ranX: number = 0; + let ranY: number = 0; + if (this.emitter.emitterType === EmitterType.Rectangle) { + ranX = this._random.floating(-0.5, 0.5) * this.emitter.width; + ranY = this._random.floating(-0.5, 0.5) * this.emitter.height; + } else { + const radius = this._random.floating(0, this.emitter.radius); + ranX = radius * Math.cos(angle); + ranY = radius * Math.sin(angle); + } + + const data = [ + this.particle.transform === ParticleTransform.Local ? ranX : this.emitter.transform.pos.x + ranX, + this.particle.transform === ParticleTransform.Local ? ranY : this.emitter.transform.pos.y + ranY, // pos in world space + this._random.floating(this.particle.minSpeed || 0, this.particle.maxSpeed || 0), + this._random.floating(this.particle.minSpeed || 0, this.particle.maxSpeed || 0), // velocity + this.particle.randomRotation + ? this._random.floating(this.particle.minAngle || 0, this.particle.maxAngle || TwoPI) + : this.particle.rotation || 0, // rotation + this.particle.angularVelocity || 0, // angular velocity + this._particleLife // life + ]; + + countParticle++; + this._particleData.set(data, i % this._particleData.length); + } + + if (endIndex >= maxSize) { + this._wrappedParticles += (endIndex - maxSize) / this._numInputFloats; + this._wrappedLife = this._particleLife; + } else if (this._wrappedLife > 0) { + this._wrappedParticles += particleCount; + } + + this._particleIndex = endIndex % maxSize; + this._emitted.push([this._particleLife, startIndex]); + } + + private _uploadEmitted(gl: WebGL2RenderingContext) { + // upload index is the index of the previous upload + // particle index is the current index of modification + if (this._particleIndex !== this._uploadIndex) { + // Bind one buffer to ARRAY_BUFFER and the other to TFB + gl.bindBuffer(gl.ARRAY_BUFFER, this._buffers[(this._drawIndex + 1) % 2]); + if (this._particleIndex >= this._uploadIndex) { + gl.bufferSubData( + gl.ARRAY_BUFFER, + this._uploadIndex * 4, // dst byte offset 4 bytes per float + this._particleData, + this._uploadIndex, + this._particleIndex - this._uploadIndex + ); + } else { + // upload before the wrap + // prettier-ignore + gl.bufferSubData( + gl.ARRAY_BUFFER, + this._uploadIndex * 4, + this._particleData, + this._uploadIndex, + this._particleData.length - this._uploadIndex + ); + // upload after the wrap if there are any + if (this._wrappedParticles) { + // prettier-ignore + gl.bufferSubData( + gl.ARRAY_BUFFER, + 0, + this._particleData, + 0, + this._wrappedParticles * this._numInputFloats + ); + } + this._wrappedLife = this._particleLife; + } + gl.bindBuffer(gl.ARRAY_BUFFER, null); + } + this._uploadIndex = this._particleIndex % (this.maxParticles * this._numInputFloats); + } + + update(elapsedMs: number) { + this._particleLife = this.particle.life ?? this._particleLife; + if (this._wrappedLife > 0) { + this._wrappedLife -= elapsedMs; + } else { + this._wrappedLife = 0; + this._wrappedParticles = 0; + } + if (!this._emitted.length) { + return; + } + for (let i = this._emitted.length - 1; i >= 0; i--) { + const particle = this._emitted[i]; + particle[0] -= elapsedMs; + const life = particle[0]; + if (life <= 0) { + this._emitted.splice(i, 1); + } + } + + this._emitted.sort((a, b) => a[0] - b[0]); + } + + draw(gl: WebGL2RenderingContext) { + if (this._initialized) { + // Emit + this._uploadEmitted(gl); + + // Bind one buffer to ARRAY_BUFFER and the other to transform feedback buffer + gl.bindVertexArray(this._currentVao); + gl.bindBufferBase(gl.TRANSFORM_FEEDBACK_BUFFER, 0, this._currentBuffer); + + // Perform transform feedback (run the simulation) and the draw call all at once + if (this._wrappedLife && this._emitted[0] && this._emitted[0][1] > 0) { + const midpoint = this._emitted[0][1] / this._numInputFloats; + // draw oldest first (maybe make configurable) + assert(`midpoint greater than 0, actual: ${midpoint}`, () => midpoint > 0); + assert(`midpoint is less than max, actual: ${midpoint}`, () => midpoint < this.maxParticles); + gl.bindBufferRange( + gl.TRANSFORM_FEEDBACK_BUFFER, + 0, + this._currentBuffer, + this._emitted[0][1] * 4, + (this.maxParticles - midpoint) * this._numInputFloats * 4 + ); + gl.beginTransformFeedback(gl.POINTS); + gl.drawArrays(gl.POINTS, midpoint, this.maxParticles - midpoint); + gl.endTransformFeedback(); + + // then draw newer particles + gl.bindBufferRange(gl.TRANSFORM_FEEDBACK_BUFFER, 0, this._currentBuffer, 0, this._emitted[0][1] * 4); + gl.beginTransformFeedback(gl.POINTS); + gl.drawArrays(gl.POINTS, 0, midpoint); + gl.endTransformFeedback(); + } else { + gl.bindBufferRange(gl.TRANSFORM_FEEDBACK_BUFFER, 0, this._currentBuffer, 0, this._particleData.length * 4); + gl.beginTransformFeedback(gl.POINTS); + gl.drawArrays(gl.POINTS, 0, this.maxParticles); + gl.endTransformFeedback(); + } + + // Clean up after ourselves to avoid errors. + gl.bindVertexArray(null); + gl.bindBufferBase(gl.TRANSFORM_FEEDBACK_BUFFER, 0, null); + + // flip flop buffers, one will be draw the other simulation + this._currentVao = this._vaos[this._drawIndex % 2]; + this._currentBuffer = this._buffers[(this._drawIndex + 1) % 2]; + this._drawIndex = (this._drawIndex + 1) % 2; + } + } +} diff --git a/src/engine/Particles/ParticleEmitter.ts b/src/engine/Particles/ParticleEmitter.ts index 5a5e0e89b..3b2f35404 100644 --- a/src/engine/Particles/ParticleEmitter.ts +++ b/src/engine/Particles/ParticleEmitter.ts @@ -4,7 +4,7 @@ import { vec } from '../Math/vector'; import { Random } from '../Math/Random'; import { CollisionType } from '../Collision/CollisionType'; import { randomInRange } from '../Math/util'; -import { EmitterType } from '../EmitterType'; +import { EmitterType } from './EmitterType'; import { Particle, ParticleTransform, ParticleEmitterArgs, ParticleConfig } from './Particles'; import { RentalPool } from '../Util/RentalPool'; @@ -12,6 +12,8 @@ import { RentalPool } from '../Util/RentalPool'; * Using a particle emitter is a great way to create interesting effects * in your game, like smoke, fire, water, explosions, etc. `ParticleEmitter` * extend {@apilink Actor} allowing you to use all of the features that come with. + * + * These particles are simulated on the CPU in JavaScript */ export class ParticleEmitter extends Actor { private _particlesToEmit: number = 0; @@ -117,7 +119,7 @@ export class ParticleEmitter extends Actor { let ranY = 0; const angle = randomInRange(this.particle.minAngle || 0, this.particle.maxAngle || Math.PI * 2, this.random); - const vel = randomInRange(this.particle.minVel || 0, this.particle.maxVel || 0, this.random); + const vel = randomInRange(this.particle.minSpeed || 0, this.particle.maxSpeed || 0, this.random); const size = this.particle.startSize || randomInRange(this.particle.minSize || 5, this.particle.maxSize || 5, this.random); const dx = vel * Math.cos(angle); const dy = vel * Math.sin(angle); diff --git a/src/engine/Particles/Particles.ts b/src/engine/Particles/Particles.ts index 73ddcf743..528a323c2 100644 --- a/src/engine/Particles/Particles.ts +++ b/src/engine/Particles/Particles.ts @@ -8,14 +8,14 @@ import { Entity } from '../EntityComponentSystem/Entity'; import { BoundingBox } from '../Collision/BoundingBox'; import { clamp } from '../Math/util'; import { Graphic } from '../Graphics'; -import { EmitterType } from '../EmitterType'; +import { EmitterType } from './EmitterType'; import { MotionComponent } from '../EntityComponentSystem'; import { EulerIntegrator } from '../Collision/Integrator'; import type { ParticleEmitter } from './ParticleEmitter'; /** /** - * Particle is used in a {@apilink ParticleEmitter} + * CPU Particle is used in a {@apilink ParticleEmitter} */ export class Particle extends Entity { public static DefaultConfig: ParticleConfig = { @@ -246,13 +246,13 @@ export interface ParticleConfig { */ maxSize?: number; /** - * Minimum magnitude of the particle starting vel + * Minimum magnitude of the particle starting speed */ - minVel?: number; // TODO Change to speed! + minSpeed?: number; /** - * Maximum magnitude of the particle starting vel + * Maximum magnitude of the particle starting speed */ - maxVel?: number; + maxSpeed?: number; /** * Minimum angle to use for the particles starting rotation */ @@ -264,6 +264,8 @@ export interface ParticleConfig { /** * Gets or sets the optional focus where all particles should accelerate towards + * + * If the particle transform is global the focus is in world space, otherwise it is relative to the emitter */ focus?: Vector; /** @@ -298,11 +300,23 @@ export interface ParticleEmitterArgs { pos?: Vector; width?: number; height?: number; + /** + * Is emitting currently + */ isEmitting?: boolean; + /** + * Particles per second + */ emitRate?: number; focus?: Vector; focusAccel?: number; + /** + * Emitter shape + */ emitterType?: EmitterType; + /** + * Radius of the emitter if the emitter type is EmitterType.Circle + */ radius?: number; random?: Random; } diff --git a/src/engine/Particles/index.ts b/src/engine/Particles/index.ts new file mode 100644 index 000000000..4589cfee0 --- /dev/null +++ b/src/engine/Particles/index.ts @@ -0,0 +1,5 @@ +export * from './EmitterType'; +export * from './Particles'; +export * from './ParticleEmitter'; +export * from './GpuParticleEmitter'; +export * from './GpuParticleRenderer'; diff --git a/src/engine/Util/Assert.ts b/src/engine/Util/Assert.ts new file mode 100644 index 000000000..d65fcd33e --- /dev/null +++ b/src/engine/Util/Assert.ts @@ -0,0 +1,10 @@ +/** + * Asserts will throw in `process.env.NODE_ENV === 'development'` builds if the expression evaluates false + */ +export function assert(message: string, expression: () => boolean) { + if (process.env.NODE_ENV === 'development') { + if (!expression()) { + throw new Error(message); + } + } +} diff --git a/src/engine/index.ts b/src/engine/index.ts index 72c408a33..947817f65 100644 --- a/src/engine/index.ts +++ b/src/engine/index.ts @@ -23,9 +23,7 @@ export * from './Events/MediaEvents'; export * from './Events'; export * from './Label'; export { FontStyle, FontUnit, TextAlign, BaseAlign } from './Graphics/FontCommon'; -export * from './EmitterType'; -export { Particle, ParticleTransform, ParticleConfig as ParticleArgs, ParticleEmitterArgs } from './Particles/Particles'; -export * from './Particles/ParticleEmitter'; +export * from './Particles/index'; export * from './Collision/Physics'; export * from './Scene'; @@ -115,6 +113,7 @@ export * from './Util/StateMachine'; export * from './Util/Future'; export * from './Util/Semaphore'; export * from './Util/Coroutine'; +export * from './Util/Assert'; // ex.Deprecated // import * as deprecated from './Deprecated'; diff --git a/src/spec/AssertSpec.ts b/src/spec/AssertSpec.ts new file mode 100644 index 000000000..a026c4940 --- /dev/null +++ b/src/spec/AssertSpec.ts @@ -0,0 +1,22 @@ +import * as ex from '@excalibur'; +describe('An assert', () => { + it('exists', () => { + expect(ex.assert).toBeDefined(); + }); + + it('will throw in dev when expression is false', () => { + const action = () => { + ex.assert('throws', () => false); + }; + + expect(action).toThrowError('throws'); + }); + + it('will not throw in dev when expression is true', () => { + const action = () => { + ex.assert('throws', () => true); + }; + + expect(action).not.toThrowError('throws'); + }); +}); diff --git a/src/spec/GpuParticleSpec.ts b/src/spec/GpuParticleSpec.ts new file mode 100644 index 000000000..f9255dfad --- /dev/null +++ b/src/spec/GpuParticleSpec.ts @@ -0,0 +1,328 @@ +import { ExcaliburMatchers, ExcaliburAsyncMatchers } from 'excalibur-jasmine'; +import * as ex from '@excalibur'; +import { TestUtils } from './util/TestUtils'; + +describe('A GPU particle', () => { + let engine: ex.Engine; + let scene: ex.Scene; + beforeEach(async () => { + jasmine.addMatchers(ExcaliburMatchers); + jasmine.addAsyncMatchers(ExcaliburAsyncMatchers); + engine = TestUtils.engine( + { + width: 800, + height: 200, + backgroundColor: ex.Color.Black + }, + [] + ); + scene = new ex.Scene(); + engine.addScene('root', scene); + await TestUtils.runToReady(engine); + const clock = engine.clock as ex.TestClock; + clock.step(1); + }); + afterEach(() => { + engine.stop(); + engine.dispose(); + engine = null; + }); + + it('should have props set by the constructor', () => { + const emitter = new ex.GpuParticleEmitter({ + pos: new ex.Vector(400, 100), + width: 20, + height: 30, + isEmitting: true, + particle: { + minSpeed: 40, + maxSpeed: 50, + acc: ex.Vector.Zero.clone(), + minAngle: 0, + maxAngle: Math.PI / 2, + life: 4, + opacity: 0.5, + fade: false, + startSize: 1, + endSize: 10, + minSize: 1, + maxSize: 3, + beginColor: ex.Color.Red.clone(), + endColor: ex.Color.Blue.clone(), + graphic: null, + angularVelocity: 3, + randomRotation: false + }, + emitRate: 3, + emitterType: ex.EmitterType.Circle, + radius: 20, + random: new ex.Random(1337) + }); + + expect(emitter.pos.x).toBe(400); + expect(emitter.pos.y).toBe(100); + expect(emitter.width).toBe(20); + expect(emitter.height).toBe(30); + expect(emitter.isEmitting).toBe(true); + expect(emitter.particle.minSpeed).toBe(40); + expect(emitter.particle.maxSpeed).toBe(50); + expect(emitter.acc.toString()).toBe(ex.Vector.Zero.clone().toString()); + expect(emitter.particle.minAngle).toBe(0); + expect(emitter.particle.maxAngle).toBe(Math.PI / 2); + expect(emitter.emitRate).toBe(3); + expect(emitter.particle.life).toBe(4); + expect(emitter.particle.opacity).toBe(0.5); + expect(emitter.particle.fade).toBe(false); + expect(emitter.particle.focus).toBe(undefined); + expect(emitter.particle.focusAccel).toBe(undefined); + expect(emitter.particle.startSize).toBe(1); + expect(emitter.particle.endSize).toBe(10); + expect(emitter.particle.minSize).toBe(1); + expect(emitter.particle.maxSize).toBe(3); + expect(emitter.particle.beginColor.toString()).toBe(ex.Color.Red.clone().toString()); + expect(emitter.particle.endColor.toString()).toBe(ex.Color.Blue.clone().toString()); + expect(emitter.particle.graphic).toBe(null); + expect(emitter.emitterType).toBe(ex.EmitterType.Circle); + expect(emitter.radius).toBe(20); + expect(emitter.particle.angularVelocity).toBe(3); + expect(emitter.particle.randomRotation).toBe(false); + expect(emitter.random.seed).toBe(1337); + }); + + it('should emit particles', async () => { + const emitter = new ex.GpuParticleEmitter({ + pos: new ex.Vector(400, 100), + width: 20, + height: 30, + isEmitting: true, + emitRate: 5, + particle: { + minSpeed: 100, + maxSpeed: 200, + acc: ex.Vector.Zero.clone(), + minAngle: 0, + maxAngle: Math.PI / 2, + life: 4000, + opacity: 0.5, + fade: false, + startSize: 30, + endSize: 40, + beginColor: ex.Color.Red.clone(), + endColor: ex.Color.Blue.clone(), + graphic: null, + angularVelocity: 3, + randomRotation: false + }, + focus: null, + focusAccel: null, + emitterType: ex.EmitterType.Circle, + radius: 20, + random: new ex.Random(1337) + }); + engine.backgroundColor = ex.Color.Transparent; + engine.add(emitter); + emitter.emitParticles(10); + + engine.currentScene.update(engine, 100); + engine.currentScene.update(engine, 100); + engine.currentScene.update(engine, 100); + engine.currentScene.draw(engine.graphicsContext, 100); + engine.graphicsContext.flush(); + await expectAsync(engine.canvas).toEqualImage('src/spec/images/GpuParticlesSpec/particles.png'); + }); + + it("should emit particles and wrap it's ring buffer", async () => { + const emitter = new ex.GpuParticleEmitter({ + pos: new ex.Vector(400, 100), + width: 20, + height: 30, + isEmitting: true, + emitRate: 5, + maxParticles: 200, + particle: { + minSpeed: 100, + maxSpeed: 200, + acc: ex.Vector.Zero.clone(), + minAngle: 0, + maxAngle: Math.PI / 2, + life: 4000, + opacity: 0.5, + fade: false, + startSize: 30, + endSize: 40, + beginColor: ex.Color.Red.clone(), + endColor: ex.Color.Blue.clone(), + graphic: null, + angularVelocity: 3, + randomRotation: false + }, + focus: null, + focusAccel: null, + emitterType: ex.EmitterType.Circle, + radius: 20, + random: new ex.Random(1337) + }); + engine.backgroundColor = ex.Color.Transparent; + engine.add(emitter); + + engine.currentScene.update(engine, 100); + emitter.emitParticles(50); + engine.currentScene.draw(engine.graphicsContext, 100); + engine.graphicsContext.flush(); + + engine.currentScene.update(engine, 100); + emitter.emitParticles(50); + engine.currentScene.draw(engine.graphicsContext, 100); + engine.graphicsContext.flush(); + + engine.currentScene.update(engine, 100); + emitter.emitParticles(50); + engine.currentScene.draw(engine.graphicsContext, 100); + engine.graphicsContext.flush(); + + engine.currentScene.update(engine, 100); + emitter.emitParticles(50); + engine.currentScene.draw(engine.graphicsContext, 100); + engine.graphicsContext.flush(); + + await expectAsync(engine.canvas).toEqualImage('src/spec/images/GpuParticlesSpec/particles-wrapped.png'); + }); + + it('can be parented', async () => { + const emitter = new ex.ParticleEmitter({ + pos: new ex.Vector(0, 0), + width: 20, + height: 30, + isEmitting: true, + emitRate: 5, + particle: { + minSpeed: 100, + maxSpeed: 200, + acc: ex.Vector.Zero.clone(), + minAngle: 0, + maxAngle: Math.PI / 2, + life: 4000, + opacity: 0.5, + fade: false, + startSize: 30, + endSize: 40, + beginColor: ex.Color.Red.clone(), + endColor: ex.Color.Blue.clone(), + graphic: null, + angularVelocity: 3, + randomRotation: false + }, + focus: null, + focusAccel: null, + emitterType: ex.EmitterType.Circle, + radius: 20, + random: new ex.Random(1337) + }); + + const parent = new ex.Actor({ + pos: ex.vec(100, 50), + width: 10, + height: 10 + }); + parent.addChild(emitter); + + engine.backgroundColor = ex.Color.Transparent; + engine.add(emitter); + + emitter.emitParticles(20); + engine.currentScene.update(engine, 100); + engine.currentScene.update(engine, 100); + engine.currentScene.update(engine, 100); + engine.currentScene.draw(engine.graphicsContext, 100); + engine.graphicsContext.flush(); + await expectAsync(engine.canvas).toEqualImage('src/spec/images/ParticleSpec/parented.png'); + }); + + it('can set the particle transform to local making particles children of the emitter', () => { + const emitter = new ex.GpuParticleEmitter({ + particle: { + transform: ex.ParticleTransform.Local, + minSpeed: 100, + maxSpeed: 200, + acc: ex.Vector.Zero.clone(), + minAngle: 0, + maxAngle: Math.PI / 2, + life: 4000, + fade: false, + opacity: 0.5, + startSize: 30, + endSize: 40, + beginColor: ex.Color.Red.clone(), + endColor: ex.Color.Blue.clone(), + graphic: null, + angularVelocity: 3, + randomRotation: false + }, + pos: new ex.Vector(0, 0), + width: 20, + height: 30, + isEmitting: true, + emitRate: 5, + focus: null, + focusAccel: null, + emitterType: ex.EmitterType.Circle, + radius: 20, + random: new ex.Random(1337) + }); + engine.add(emitter); + emitter.emitParticles(20); + const particleData = (emitter.renderer as any)._particleData as Float32Array; + const stride = (emitter.renderer as any)._numInputFloats as number; + expect(particleData[0 * stride]).not.toBe(0); + expect(particleData[0 * stride + 1]).not.toBe(0); + expect(particleData[19 * stride]).not.toBe(0); + expect(particleData[19 * stride + 1]).not.toBe(0); + expect(particleData[20 * stride]).toBe(0); + expect(particleData[20 * stride + 1]).toBe(0); + expect(engine.currentScene.actors.length).toBe(1); + }); + + it('can set the particle transform to global adding particles directly to the scene', () => { + const emitter = new ex.GpuParticleEmitter({ + particle: { + transform: ex.ParticleTransform.Global, + minSpeed: 100, + maxSpeed: 200, + acc: ex.Vector.Zero.clone(), + minAngle: 0, + maxAngle: Math.PI / 2, + life: 4000, + opacity: 0.5, + fade: false, + startSize: 30, + endSize: 40, + beginColor: ex.Color.Red.clone(), + endColor: ex.Color.Blue.clone(), + graphic: null, + angularVelocity: 3, + randomRotation: false + }, + pos: new ex.Vector(100, 100), + width: 20, + height: 30, + isEmitting: true, + emitRate: 5, + focus: null, + focusAccel: null, + emitterType: ex.EmitterType.Circle, + radius: 20, + random: new ex.Random(1337) + }); + engine.add(emitter); + emitter.emitParticles(20); + const particleData = (emitter.renderer as any)._particleData as Float32Array; + const stride = (emitter.renderer as any)._numInputFloats as number; + expect(particleData[0 * stride]).toBeGreaterThan(80); + expect(particleData[0 * stride + 1]).toBeGreaterThan(80); + expect(particleData[19 * stride]).not.toBe(0); + expect(particleData[19 * stride + 1]).not.toBe(0); + expect(particleData[20 * stride]).toBe(0); + expect(particleData[20 * stride + 1]).toBe(0); + expect(engine.currentScene.actors.length).toBe(1); + }); +}); diff --git a/src/spec/ParticleSpec.ts b/src/spec/ParticleSpec.ts index bd5cc9727..327ca8814 100644 --- a/src/spec/ParticleSpec.ts +++ b/src/spec/ParticleSpec.ts @@ -35,8 +35,8 @@ describe('A particle', () => { height: 30, isEmitting: true, particle: { - minVel: 40, - maxVel: 50, + minSpeed: 40, + maxSpeed: 50, acc: ex.Vector.Zero.clone(), minAngle: 0, maxAngle: Math.PI / 2, @@ -64,8 +64,8 @@ describe('A particle', () => { expect(emitter.width).toBe(20); expect(emitter.height).toBe(30); expect(emitter.isEmitting).toBe(true); - expect(emitter.particle.minVel).toBe(40); - expect(emitter.particle.maxVel).toBe(50); + expect(emitter.particle.minSpeed).toBe(40); + expect(emitter.particle.maxSpeed).toBe(50); expect(emitter.acc.toString()).toBe(ex.Vector.Zero.clone().toString()); expect(emitter.particle.minAngle).toBe(0); expect(emitter.particle.maxAngle).toBe(Math.PI / 2); @@ -97,8 +97,8 @@ describe('A particle', () => { isEmitting: true, emitRate: 5, particle: { - minVel: 100, - maxVel: 200, + minSpeed: 100, + maxSpeed: 200, acc: ex.Vector.Zero.clone(), minAngle: 0, maxAngle: Math.PI / 2, @@ -139,8 +139,8 @@ describe('A particle', () => { isEmitting: true, emitRate: 5, particle: { - minVel: 100, - maxVel: 200, + minSpeed: 100, + maxSpeed: 200, acc: ex.Vector.Zero.clone(), minAngle: 0, maxAngle: Math.PI / 2, @@ -185,8 +185,8 @@ describe('A particle', () => { const emitter = new ex.ParticleEmitter({ particle: { transform: ex.ParticleTransform.Local, - minVel: 100, - maxVel: 200, + minSpeed: 100, + maxSpeed: 200, acc: ex.Vector.Zero.clone(), minAngle: 0, maxAngle: Math.PI / 2, @@ -222,8 +222,8 @@ describe('A particle', () => { const emitter = new ex.ParticleEmitter({ particle: { transform: ex.ParticleTransform.Global, - minVel: 100, - maxVel: 200, + minSpeed: 100, + maxSpeed: 200, acc: ex.Vector.Zero.clone(), minAngle: 0, maxAngle: Math.PI / 2, diff --git a/src/spec/images/GpuParticlesSpec/particles-wrapped.png b/src/spec/images/GpuParticlesSpec/particles-wrapped.png new file mode 100644 index 0000000000000000000000000000000000000000..05467eccf7b4a4ff745da93b8801112a44ac7bc3 GIT binary patch literal 9570 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

gFCSRUoU(6y@rFDc_l9Eul_Zm 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 literal 0 HcmV?d00001 diff --git a/src/stories/ParticleEmitter.stories.ts b/src/stories/ParticleEmitter.stories.ts index d977bcf30..0ff9a06db 100644 --- a/src/stories/ParticleEmitter.stories.ts +++ b/src/stories/ParticleEmitter.stories.ts @@ -49,8 +49,8 @@ export const Main: StoryObj = { emitRate, focusAccel: 800, particle: { - minVel, - maxVel, + minSpeed: minVel, + maxSpeed: maxVel, minAngle, maxAngle, opacity, diff --git a/wallaby.js b/wallaby.js index a41d5519d..0cd679b49 100644 --- a/wallaby.js +++ b/wallaby.js @@ -43,6 +43,11 @@ module.exports = function (wallaby) { plugins: [ new webpack.DefinePlugin({ 'process.env.__EX_VERSION': "'test-runner'" + }), + new webpack.DefinePlugin({ + 'process.env': { + NODE_ENV: JSON.stringify('development') + } }) ], module: {