Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: GPU particles implementation #3212

Open
wants to merge 15 commits into
base: main
Choose a base branch
from
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>
38 changes: 38 additions & 0 deletions sandbox/tests/gpu-particles/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
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: 100_000,
emitRate: 1000,
particle: {
beginColor: ex.Color.Orange,
endColor: ex.Color.White,
fade: true,
startSize: 100,
endSize: 0,
life: 5500,
graphic: swordImg.toSprite()
}
});
particles.isEmitting = true;
game.add(particles);

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';

export const pixelSnapEpsilon = 0.0001;

Expand Down Expand Up @@ -324,10 +325,11 @@ export class ExcaliburGraphicsContextWebGL implements ExcaliburGraphicsContext {
this.register(new CircleRenderer());
this.register(new PointRenderer());
this.register(new LineRenderer());
this.register(new ParticleRenderer());

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,37 @@
#version 300 es
precision mediump float;

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

uniform vec4 beginColor;
uniform vec4 endColor;
uniform bool fade;

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

void main(){

float alpha = finalLifeMs / maxLifeMs;

/** Draw texture */
if (useTexture) {
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 * alpha;
} else {
/** Draw circle */
float distanceFromPointCenter = distance(gl_PointCoord.xy, vec2(.5));
// TODO smooth edge instead of hard discard
if (distanceFromPointCenter > .5) discard;
vec4 color = mix(beginColor, endColor, 1.0 - alpha);
fragColor = color * (fade ? alpha : 1.0);
}
}
111 changes: 111 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,111 @@
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 { GpuParticleState } from '../../../Particles/GpuParticleState';
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';

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) {
// if (this._texture) {
// return this._texture; // TODO invalidate if image changes
// }
// TODO DOM apis really sucks perf cache it?
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(particleState: GpuParticleState, elapsedMs: number): void {
const gl = this._gl;

this._shader.use();
this._shader.setUniformMatrix('u_matrix', this._context.ortho);
const transform = this._context.getTransform();
this._shader.setUniformAffineMatrix('u_transform', transform);
this._shader.setUniformBoolean('useTexture', particleState.particle.graphic ? true : false); // TODO configurable in particle state
this._shader.setUniformFloat('maxLifeMs', particleState.particle.life ?? 2000); // TODO configurable in particle state
// this._shader.setUniformFloat('uRandom', Math.random()); // TODO ex Random
this._shader.setUniformFloat('deltaMs', elapsedMs);
this._shader.setUniformFloatVector('gravity', particleState.particle.acc ?? vec(0, 0));
this._shader.setUniformFloatColor('beginColor', particleState.particle.beginColor ?? Color.Transparent);
this._shader.setUniformFloatColor('endColor', particleState.particle.endColor ?? Color.Transparent);
this._shader.setUniformBoolean('fade', particleState.particle.fade ?? true);
this._shader.setUniformFloat('startSize', particleState.particle.startSize ?? 10);
this._shader.setUniformFloat('endSize', particleState.particle.endSize ?? 10);

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

const texture = this._getTexture(graphic.image.image);
// TODO need to hint the GC

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);

particleState.draw(gl);
}
hasPendingDraws(): boolean {
return false;
}
flush(): void {
// pass
}
dispose(): void {
// pass
}
}
50 changes: 50 additions & 0 deletions src/engine/Graphics/Context/particle-renderer/particle-vertex.glsl
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
#version 300 es
precision mediump float;

uniform float deltaMs;
uniform float maxLifeMs;
uniform vec2 gravity;
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;

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
// todo weird artifact of re-using the same buffer layout for update/draw
finalVelocity = velocity + gravity * seconds;
finalPosition = position + velocity * seconds + gravity * .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 perc = finalLifeMs / maxLifeMs;
vec2 transformedPos = (u_matrix * u_transform * vec4(finalPosition,0.,1.)).xy;

gl_Position = vec4(transformedPos, 0., 1.);
gl_PointSize = mix(startSize, endSize, 1.0 - perc);
}
42 changes: 40 additions & 2 deletions src/engine/Graphics/Context/shader.ts
Original file line number Diff line number Diff line change
@@ -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';

Expand Down Expand Up @@ -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 {
Expand All @@ -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;
Expand All @@ -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() {
Expand Down Expand Up @@ -151,6 +158,9 @@ export class Shader {
}

this._compiled = true;
if (this._onPostCompile) {
this._onPostCompile(this);
}
return this.program;
}

Expand Down Expand Up @@ -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.
*
Expand Down Expand Up @@ -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);

Expand Down
20 changes: 19 additions & 1 deletion src/engine/Graphics/Graphic.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,28 @@
import { Vector, vec } from '../Math/vector';
import { ExcaliburGraphicsContext } from './Context/ExcaliburGraphicsContext';
import { ExcaliburGraphicsContext, HTMLImageSource } from './Context/ExcaliburGraphicsContext';
import { BoundingBox } from '../Collision/BoundingBox';
import { Color } from '../Color';
import { watch } from '../Util/Watch';
import { AffineMatrix } from '../Math/affine-matrix';

export interface GraphicImageWithCoordinatesOptions {
x?: number;
y?: number;
data?: Record<string, any>;
}

export interface GraphicImageWithCoordinates {
image: HTMLImageSource;
sx: number;
sy: number;
sw?: number;
sh?: number;
dx?: number;
dy?: number;
dw?: number;
dh?: number;
}

export interface GraphicOptions {
/**
* The width of the graphic
Expand Down
Loading
Loading