@khudiiash/particles-playcanvas 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.mjs ADDED
@@ -0,0 +1,254 @@
1
+ // src/index.js
2
+ import * as pc from "playcanvas";
3
+ import {
4
+ ParticleSimulation,
5
+ PARTICLE_BYTES,
6
+ resolveEffect,
7
+ mergeRender
8
+ } from "@khudiiash/particles-core";
9
+
10
+ // src/render-wgsl.js
11
+ function particleVertexWgsl() {
12
+ return (
13
+ /* wgsl */
14
+ `
15
+ struct Particle {
16
+ position: vec3f,
17
+ life: f32,
18
+ velocity: vec3f,
19
+ maxLife: f32,
20
+ size: f32,
21
+ opacity: f32,
22
+ pathPhase: f32,
23
+ pathSpread: f32,
24
+ color: vec3f,
25
+ seed: f32,
26
+ };
27
+
28
+ var<storage, read> particles: array<Particle>;
29
+
30
+ uniform matrix_viewProjection: mat4x4f;
31
+ uniform matrix_view: mat4x4f;
32
+ uniform uSizeScale: f32;
33
+
34
+ varying vColor: vec4f;
35
+ varying vUv: vec2f;
36
+
37
+ const QUAD = array<vec2f, 6>(
38
+ vec2f(-0.5, -0.5), vec2f(0.5, -0.5), vec2f(0.5, 0.5),
39
+ vec2f(-0.5, -0.5), vec2f(0.5, 0.5), vec2f(-0.5, 0.5),
40
+ );
41
+
42
+ struct VertexInput {
43
+ @builtin(vertex_index) vertexIndex: u32,
44
+ };
45
+
46
+ struct VertexOutput {
47
+ @builtin(position) position: vec4f,
48
+ };
49
+
50
+ @vertex
51
+ fn vertexMain(input: VertexInput) -> VertexOutput {
52
+ let particleIndex = input.vertexIndex / 6u;
53
+ let corner = QUAD[input.vertexIndex % 6u];
54
+ let p = particles[particleIndex];
55
+
56
+ let alive = select(0.0, 1.0, p.life > 0.0);
57
+ let scale = p.size * uniform.uSizeScale * alive;
58
+
59
+ let right = vec3f(uniform.matrix_view[0][0], uniform.matrix_view[1][0], uniform.matrix_view[2][0]);
60
+ let up = vec3f(uniform.matrix_view[0][1], uniform.matrix_view[1][1], uniform.matrix_view[2][1]);
61
+ let world = p.position + (right * corner.x + up * corner.y) * scale;
62
+
63
+ var output: VertexOutput;
64
+ output.position = uniform.matrix_viewProjection * vec4f(world, 1.0);
65
+ vColor = vec4f(p.color, p.opacity * alive);
66
+ vUv = corner;
67
+ return output;
68
+ }
69
+ `
70
+ );
71
+ }
72
+ function particleFragmentWgsl() {
73
+ return (
74
+ /* wgsl */
75
+ `
76
+ uniform uAlphaCutoff: f32;
77
+
78
+ varying vColor: vec4f;
79
+ varying vUv: vec2f;
80
+
81
+ struct FragmentOutput {
82
+ @location(0) color: vec4f,
83
+ };
84
+
85
+ @fragment
86
+ fn fragmentMain() -> FragmentOutput {
87
+ let d = length(vUv) * 2.0;
88
+ let soft = clamp(1.0 - d, 0.0, 1.0);
89
+ let a = vColor.a * soft;
90
+ if (a < uniform.uAlphaCutoff) { discard; }
91
+ var output: FragmentOutput;
92
+ output.color = vec4f(vColor.rgb, a);
93
+ return output;
94
+ }
95
+ `
96
+ );
97
+ }
98
+
99
+ // src/index.js
100
+ var FRAME_DT_CLAMP = 1 / 60;
101
+ var ParticleSystem = class _ParticleSystem {
102
+ /**
103
+ * @param {pc.AppBase} app
104
+ * @param {object} config effect config (raw or resolved)
105
+ * @param {object} [opts]
106
+ * @param {pc.Entity} [opts.camera] camera entity (defaults to first scene camera)
107
+ * @param {pc.Entity} [opts.parent] entity to parent the particle render to
108
+ */
109
+ static async create(app, config, opts = {}) {
110
+ const system = new _ParticleSystem(app, config, opts);
111
+ await system.init();
112
+ return system;
113
+ }
114
+ constructor(app, config, opts = {}) {
115
+ this.app = app;
116
+ this.device = app.graphicsDevice;
117
+ this.gpu = this.device.wgpu;
118
+ if (!this.gpu) {
119
+ throw new Error(
120
+ "@khudiiash/particles-playcanvas requires a WebGPU graphics device (DEVICETYPE_WEBGPU)"
121
+ );
122
+ }
123
+ this._rawConfig = config;
124
+ this.camera = opts.camera || null;
125
+ this.parent = opts.parent || app.root;
126
+ this.config = null;
127
+ this.sim = null;
128
+ this.entity = null;
129
+ this.storageBuffer = null;
130
+ this._playing = true;
131
+ this._disposed = false;
132
+ }
133
+ async init() {
134
+ this.config = resolveEffect(this._rawConfig);
135
+ this.sim = new ParticleSimulation(this.gpu, this.config);
136
+ await this.sim.init();
137
+ this._buildRender();
138
+ return this;
139
+ }
140
+ _buildRender() {
141
+ const pcLib = pc;
142
+ const count = this.config.maxParticles;
143
+ const render = mergeRender(this.config.render);
144
+ this.storageBuffer = new pcLib.StorageBuffer(
145
+ this.device,
146
+ count * PARTICLE_BYTES,
147
+ pcLib.BUFFERUSAGE_COPY_DST | pcLib.BUFFERUSAGE_STORAGE
148
+ );
149
+ const shader = pcLib.ShaderMaterial ? new pcLib.ShaderMaterial({
150
+ uniqueName: "particles-core",
151
+ vertexGLSL: void 0,
152
+ fragmentGLSL: void 0,
153
+ vertexWGSL: particleVertexWgsl(),
154
+ fragmentWGSL: particleFragmentWgsl(),
155
+ attributes: {}
156
+ }) : new pcLib.Material();
157
+ shader.cull = pcLib.CULLFACE_NONE;
158
+ shader.blendType = blendType(pcLib, render.blendMode);
159
+ shader.depthWrite = !!render.depthWrite;
160
+ shader.depthTest = true;
161
+ shader.setParameter("particles", this.storageBuffer);
162
+ shader.setParameter("uSizeScale", 1);
163
+ shader.setParameter("uAlphaCutoff", render.alphaCutoff ?? 0.05);
164
+ shader.update();
165
+ this.material = shader;
166
+ const mesh = new pcLib.Mesh(this.device);
167
+ mesh.vertexBuffer = null;
168
+ mesh.primitive[0] = {
169
+ type: pcLib.PRIMITIVE_TRIANGLES,
170
+ base: 0,
171
+ count: count * 6,
172
+ indexed: false
173
+ };
174
+ mesh.aabb = new pcLib.BoundingBox(new pcLib.Vec3(), new pcLib.Vec3(1e4, 1e4, 1e4));
175
+ const node = new pcLib.GraphNode();
176
+ const instance = new pcLib.MeshInstance(mesh, shader, node);
177
+ instance.cull = false;
178
+ this.entity = new pcLib.Entity("particles-core");
179
+ this.entity.addComponent("render", { meshInstances: [instance] });
180
+ this.parent.addChild(this.entity);
181
+ this.meshInstance = instance;
182
+ }
183
+ _updateMatrices() {
184
+ const cam = this.camera || this.app.root.findComponent("camera")?.entity;
185
+ if (!cam) return;
186
+ const camComp = cam.camera;
187
+ if (!camComp) return;
188
+ const vp = camComp.viewProjectionMatrix || camComp._viewProjMat;
189
+ const view = camComp.viewMatrix || camComp._viewMat;
190
+ if (vp) this.material.setParameter("matrix_viewProjection", vp.data);
191
+ if (view) this.material.setParameter("matrix_view", view.data);
192
+ }
193
+ play() {
194
+ this._playing = true;
195
+ }
196
+ pause() {
197
+ this._playing = false;
198
+ }
199
+ reset() {
200
+ this.sim?.reset();
201
+ }
202
+ /** Advance the simulation and mirror the buffer. Call once per frame. */
203
+ update(dt) {
204
+ if (this._disposed || !this._playing || !this.sim) return;
205
+ const clamped = Math.min(dt > 0 ? dt : 1 / 60, FRAME_DT_CLAMP);
206
+ this.sim.step(clamped);
207
+ this._copyToStorage();
208
+ this._updateMatrices();
209
+ }
210
+ _copyToStorage() {
211
+ const dst = this.storageBuffer?.impl?.buffer;
212
+ if (!dst) return;
213
+ const enc = this.gpu.createCommandEncoder();
214
+ enc.copyBufferToBuffer(
215
+ this.sim.particleBuffer,
216
+ 0,
217
+ dst,
218
+ 0,
219
+ this.config.maxParticles * PARTICLE_BYTES
220
+ );
221
+ this.gpu.queue.submit([enc.finish()]);
222
+ }
223
+ async setConfig(config) {
224
+ this.config = resolveEffect(config);
225
+ await this.sim.setConfig(this.config);
226
+ }
227
+ dispose() {
228
+ this._disposed = true;
229
+ this.entity?.destroy();
230
+ this.entity = null;
231
+ this.storageBuffer?.destroy?.();
232
+ this.storageBuffer = null;
233
+ this.sim?.dispose(true);
234
+ this.sim = null;
235
+ }
236
+ };
237
+ function blendType(pcLib, mode) {
238
+ switch (mode) {
239
+ case "additive":
240
+ case "additiveAlpha":
241
+ case "screen":
242
+ return pcLib.BLEND_ADDITIVEALPHA ?? pcLib.BLEND_ADDITIVE;
243
+ case "multiply":
244
+ return pcLib.BLEND_MULTIPLICATIVE;
245
+ case "premultiplied":
246
+ return pcLib.BLEND_PREMULTIPLIED;
247
+ default:
248
+ return pcLib.BLEND_NORMAL;
249
+ }
250
+ }
251
+ export {
252
+ PARTICLE_BYTES,
253
+ ParticleSystem
254
+ };
package/package.json ADDED
@@ -0,0 +1,45 @@
1
+ {
2
+ "name": "@khudiiash/particles-playcanvas",
3
+ "version": "0.2.0",
4
+ "description": "PlayCanvas WebGPU runtime player for @khudiiash/particles-core effect configs.",
5
+ "license": "MIT",
6
+ "author": "khudiiash",
7
+ "keywords": [
8
+ "webgpu",
9
+ "particles",
10
+ "playcanvas",
11
+ "gpu",
12
+ "wgsl",
13
+ "vfx"
14
+ ],
15
+ "type": "module",
16
+ "main": "./src/index.js",
17
+ "module": "./src/index.js",
18
+ "unpkg": "./dist/index.mjs",
19
+ "jsdelivr": "./dist/index.mjs",
20
+ "exports": {
21
+ ".": {
22
+ "default": "./src/index.js"
23
+ }
24
+ },
25
+ "files": [
26
+ "src",
27
+ "dist"
28
+ ],
29
+ "scripts": {
30
+ "build": "esbuild src/index.js --bundle --format=esm --outfile=dist/index.mjs --external:playcanvas --external:@khudiiash/particles-core",
31
+ "prepublishOnly": "npm run build"
32
+ },
33
+ "peerDependencies": {
34
+ "playcanvas": ">=1.65.0"
35
+ },
36
+ "dependencies": {
37
+ "@khudiiash/particles-core": "^0.2.0"
38
+ },
39
+ "devDependencies": {
40
+ "esbuild": "^0.21.5"
41
+ },
42
+ "publishConfig": {
43
+ "access": "public"
44
+ }
45
+ }
package/src/index.js ADDED
@@ -0,0 +1,204 @@
1
+ /**
2
+ * @khudiiash/particles-playcanvas
3
+ *
4
+ * Plays a `@khudiiash/particles-core` effect config inside a PlayCanvas
5
+ * application using the WebGPU backend. The simulation runs as a WebGPU compute
6
+ * pass on PlayCanvas's own `GPUDevice` (`graphicsDevice.wgpu`); particles are
7
+ * drawn from the shared GPU storage buffer with a PlayCanvas WGSL material — no
8
+ * CPU readback.
9
+ *
10
+ * Requires PlayCanvas Engine v2 with a WebGPU device (DEVICETYPE_WEBGPU).
11
+ *
12
+ * Usage:
13
+ * import * as pc from 'playcanvas';
14
+ * import { ParticleSystem } from '@khudiiash/particles-playcanvas';
15
+ * const ps = await ParticleSystem.create(app, effectConfig, { camera });
16
+ * app.on('update', (dt) => ps.update(dt));
17
+ */
18
+ import * as pc from "playcanvas";
19
+ import {
20
+ ParticleSimulation,
21
+ PARTICLE_BYTES,
22
+ resolveEffect,
23
+ mergeRender,
24
+ } from "@khudiiash/particles-core";
25
+ import { particleVertexWgsl, particleFragmentWgsl } from "./render-wgsl.js";
26
+
27
+ const FRAME_DT_CLAMP = 1 / 60;
28
+
29
+ export class ParticleSystem {
30
+ /**
31
+ * @param {pc.AppBase} app
32
+ * @param {object} config effect config (raw or resolved)
33
+ * @param {object} [opts]
34
+ * @param {pc.Entity} [opts.camera] camera entity (defaults to first scene camera)
35
+ * @param {pc.Entity} [opts.parent] entity to parent the particle render to
36
+ */
37
+ static async create(app, config, opts = {}) {
38
+ const system = new ParticleSystem(app, config, opts);
39
+ await system.init();
40
+ return system;
41
+ }
42
+
43
+ constructor(app, config, opts = {}) {
44
+ this.app = app;
45
+ this.device = app.graphicsDevice;
46
+ this.gpu = this.device.wgpu;
47
+ if (!this.gpu) {
48
+ throw new Error(
49
+ "@khudiiash/particles-playcanvas requires a WebGPU graphics device (DEVICETYPE_WEBGPU)",
50
+ );
51
+ }
52
+ this._rawConfig = config;
53
+ this.camera = opts.camera || null;
54
+ this.parent = opts.parent || app.root;
55
+ this.config = null;
56
+ this.sim = null;
57
+ this.entity = null;
58
+ this.storageBuffer = null;
59
+ this._playing = true;
60
+ this._disposed = false;
61
+ }
62
+
63
+ async init() {
64
+ this.config = resolveEffect(this._rawConfig);
65
+ this.sim = new ParticleSimulation(this.gpu, this.config);
66
+ await this.sim.init();
67
+ this._buildRender();
68
+ return this;
69
+ }
70
+
71
+ _buildRender() {
72
+ const pcLib = pc;
73
+ const count = this.config.maxParticles;
74
+ const render = mergeRender(this.config.render);
75
+
76
+ // PlayCanvas-managed storage buffer that the material reads from; we copy
77
+ // the simulation output into it each frame (GPU -> GPU).
78
+ this.storageBuffer = new pcLib.StorageBuffer(
79
+ this.device,
80
+ count * PARTICLE_BYTES,
81
+ pcLib.BUFFERUSAGE_COPY_DST | pcLib.BUFFERUSAGE_STORAGE,
82
+ );
83
+
84
+ const shader = pcLib.ShaderMaterial
85
+ ? new pcLib.ShaderMaterial({
86
+ uniqueName: "particles-core",
87
+ vertexGLSL: undefined,
88
+ fragmentGLSL: undefined,
89
+ vertexWGSL: particleVertexWgsl(),
90
+ fragmentWGSL: particleFragmentWgsl(),
91
+ attributes: {},
92
+ })
93
+ : new pcLib.Material();
94
+
95
+ shader.cull = pcLib.CULLFACE_NONE;
96
+ shader.blendType = blendType(pcLib, render.blendMode);
97
+ shader.depthWrite = !!render.depthWrite;
98
+ shader.depthTest = true;
99
+ shader.setParameter("particles", this.storageBuffer);
100
+ shader.setParameter("uSizeScale", 1);
101
+ shader.setParameter("uAlphaCutoff", render.alphaCutoff ?? 0.05);
102
+ shader.update();
103
+ this.material = shader;
104
+
105
+ // A vertex-only mesh: 6 verts per particle, no vertex buffers (the shader
106
+ // reads everything from the storage buffer via vertex_index).
107
+ const mesh = new pcLib.Mesh(this.device);
108
+ mesh.vertexBuffer = null;
109
+ mesh.primitive[0] = {
110
+ type: pcLib.PRIMITIVE_TRIANGLES,
111
+ base: 0,
112
+ count: count * 6,
113
+ indexed: false,
114
+ };
115
+ mesh.aabb = new pcLib.BoundingBox(new pcLib.Vec3(), new pcLib.Vec3(1e4, 1e4, 1e4));
116
+
117
+ const node = new pcLib.GraphNode();
118
+ const instance = new pcLib.MeshInstance(mesh, shader, node);
119
+ instance.cull = false;
120
+
121
+ this.entity = new pcLib.Entity("particles-core");
122
+ this.entity.addComponent("render", { meshInstances: [instance] });
123
+ this.parent.addChild(this.entity);
124
+ this.meshInstance = instance;
125
+ }
126
+
127
+ _updateMatrices() {
128
+ const cam = this.camera || this.app.root.findComponent("camera")?.entity;
129
+ if (!cam) return;
130
+ const camComp = cam.camera;
131
+ if (!camComp) return;
132
+ const vp = camComp.viewProjectionMatrix || camComp._viewProjMat;
133
+ const view = camComp.viewMatrix || camComp._viewMat;
134
+ if (vp) this.material.setParameter("matrix_viewProjection", vp.data);
135
+ if (view) this.material.setParameter("matrix_view", view.data);
136
+ }
137
+
138
+ play() {
139
+ this._playing = true;
140
+ }
141
+
142
+ pause() {
143
+ this._playing = false;
144
+ }
145
+
146
+ reset() {
147
+ this.sim?.reset();
148
+ }
149
+
150
+ /** Advance the simulation and mirror the buffer. Call once per frame. */
151
+ update(dt) {
152
+ if (this._disposed || !this._playing || !this.sim) return;
153
+ const clamped = Math.min(dt > 0 ? dt : 1 / 60, FRAME_DT_CLAMP);
154
+ this.sim.step(clamped);
155
+ this._copyToStorage();
156
+ this._updateMatrices();
157
+ }
158
+
159
+ _copyToStorage() {
160
+ const dst = this.storageBuffer?.impl?.buffer;
161
+ if (!dst) return;
162
+ const enc = this.gpu.createCommandEncoder();
163
+ enc.copyBufferToBuffer(
164
+ this.sim.particleBuffer,
165
+ 0,
166
+ dst,
167
+ 0,
168
+ this.config.maxParticles * PARTICLE_BYTES,
169
+ );
170
+ this.gpu.queue.submit([enc.finish()]);
171
+ }
172
+
173
+ async setConfig(config) {
174
+ this.config = resolveEffect(config);
175
+ await this.sim.setConfig(this.config);
176
+ }
177
+
178
+ dispose() {
179
+ this._disposed = true;
180
+ this.entity?.destroy();
181
+ this.entity = null;
182
+ this.storageBuffer?.destroy?.();
183
+ this.storageBuffer = null;
184
+ this.sim?.dispose(true);
185
+ this.sim = null;
186
+ }
187
+ }
188
+
189
+ function blendType(pcLib, mode) {
190
+ switch (mode) {
191
+ case "additive":
192
+ case "additiveAlpha":
193
+ case "screen":
194
+ return pcLib.BLEND_ADDITIVEALPHA ?? pcLib.BLEND_ADDITIVE;
195
+ case "multiply":
196
+ return pcLib.BLEND_MULTIPLICATIVE;
197
+ case "premultiplied":
198
+ return pcLib.BLEND_PREMULTIPLIED;
199
+ default:
200
+ return pcLib.BLEND_NORMAL;
201
+ }
202
+ }
203
+
204
+ export { PARTICLE_BYTES };
@@ -0,0 +1,90 @@
1
+ /**
2
+ * Minimal PlayCanvas-dialect WGSL render shader for the PlayCanvas runtime.
3
+ *
4
+ * Reads the particle storage buffer (written by the core compute simulation) and
5
+ * draws each live particle as a camera-facing billboard. Written in PlayCanvas's
6
+ * WGSL flavour (`uniform`/`varying`/`var<storage>` declarations) so it can be
7
+ * compiled by `pc.ShaderMaterial` with `SHADERLANGUAGE_WGSL`.
8
+ */
9
+ export function particleVertexWgsl() {
10
+ return /* wgsl */ `
11
+ struct Particle {
12
+ position: vec3f,
13
+ life: f32,
14
+ velocity: vec3f,
15
+ maxLife: f32,
16
+ size: f32,
17
+ opacity: f32,
18
+ pathPhase: f32,
19
+ pathSpread: f32,
20
+ color: vec3f,
21
+ seed: f32,
22
+ };
23
+
24
+ var<storage, read> particles: array<Particle>;
25
+
26
+ uniform matrix_viewProjection: mat4x4f;
27
+ uniform matrix_view: mat4x4f;
28
+ uniform uSizeScale: f32;
29
+
30
+ varying vColor: vec4f;
31
+ varying vUv: vec2f;
32
+
33
+ const QUAD = array<vec2f, 6>(
34
+ vec2f(-0.5, -0.5), vec2f(0.5, -0.5), vec2f(0.5, 0.5),
35
+ vec2f(-0.5, -0.5), vec2f(0.5, 0.5), vec2f(-0.5, 0.5),
36
+ );
37
+
38
+ struct VertexInput {
39
+ @builtin(vertex_index) vertexIndex: u32,
40
+ };
41
+
42
+ struct VertexOutput {
43
+ @builtin(position) position: vec4f,
44
+ };
45
+
46
+ @vertex
47
+ fn vertexMain(input: VertexInput) -> VertexOutput {
48
+ let particleIndex = input.vertexIndex / 6u;
49
+ let corner = QUAD[input.vertexIndex % 6u];
50
+ let p = particles[particleIndex];
51
+
52
+ let alive = select(0.0, 1.0, p.life > 0.0);
53
+ let scale = p.size * uniform.uSizeScale * alive;
54
+
55
+ let right = vec3f(uniform.matrix_view[0][0], uniform.matrix_view[1][0], uniform.matrix_view[2][0]);
56
+ let up = vec3f(uniform.matrix_view[0][1], uniform.matrix_view[1][1], uniform.matrix_view[2][1]);
57
+ let world = p.position + (right * corner.x + up * corner.y) * scale;
58
+
59
+ var output: VertexOutput;
60
+ output.position = uniform.matrix_viewProjection * vec4f(world, 1.0);
61
+ vColor = vec4f(p.color, p.opacity * alive);
62
+ vUv = corner;
63
+ return output;
64
+ }
65
+ `;
66
+ }
67
+
68
+ export function particleFragmentWgsl() {
69
+ return /* wgsl */ `
70
+ uniform uAlphaCutoff: f32;
71
+
72
+ varying vColor: vec4f;
73
+ varying vUv: vec2f;
74
+
75
+ struct FragmentOutput {
76
+ @location(0) color: vec4f,
77
+ };
78
+
79
+ @fragment
80
+ fn fragmentMain() -> FragmentOutput {
81
+ let d = length(vUv) * 2.0;
82
+ let soft = clamp(1.0 - d, 0.0, 1.0);
83
+ let a = vColor.a * soft;
84
+ if (a < uniform.uAlphaCutoff) { discard; }
85
+ var output: FragmentOutput;
86
+ output.color = vec4f(vColor.rgb, a);
87
+ return output;
88
+ }
89
+ `;
90
+ }