@khudiiash/particles-three 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,186 @@
1
+ // src/index.js
2
+ import * as THREE2 from "three";
3
+ import {
4
+ ParticleSimulation,
5
+ PARTICLE_BYTES as PARTICLE_BYTES2,
6
+ resolveEffect,
7
+ mergeRender,
8
+ PARTICLE_STRIDE_FLOATS as PARTICLE_STRIDE_FLOATS2
9
+ } from "@khudiiash/particles-core";
10
+
11
+ // src/mesh.js
12
+ import * as THREE from "three";
13
+ import { StorageInstancedBufferAttribute } from "three";
14
+ import {
15
+ Fn,
16
+ storage,
17
+ instanceIndex,
18
+ vec3,
19
+ vec4,
20
+ float,
21
+ uniform,
22
+ positionLocal
23
+ } from "three/tsl";
24
+ import { SpriteNodeMaterial } from "three/webgpu";
25
+ import { PARTICLE_STRIDE_FLOATS, PARTICLE_BYTES } from "@khudiiash/particles-core";
26
+ var VEC4_PER_PARTICLE = PARTICLE_STRIDE_FLOATS / 4;
27
+ var BLEND = {
28
+ additive: THREE.AdditiveBlending,
29
+ additiveAlpha: THREE.AdditiveBlending,
30
+ normal: THREE.NormalBlending,
31
+ premultiplied: THREE.CustomBlending,
32
+ multiply: THREE.MultiplyBlending,
33
+ screen: THREE.AdditiveBlending
34
+ };
35
+ function buildParticleMesh({ renderer, particleBuffer, maxParticles, render }) {
36
+ const count = maxParticles;
37
+ const storageArray = new Float32Array(count * PARTICLE_STRIDE_FLOATS);
38
+ const storageAttr = new StorageInstancedBufferAttribute(storageArray, 4);
39
+ storageAttr.setUsage?.(THREE.DynamicDrawUsage);
40
+ const particles = storage(storageAttr, "vec4", count * VEC4_PER_PARTICLE);
41
+ const base = instanceIndex.mul(VEC4_PER_PARTICLE);
42
+ const p0 = particles.element(base);
43
+ const p2 = particles.element(base.add(2));
44
+ const p3 = particles.element(base.add(3));
45
+ const lifeT = p0.w;
46
+ const sizeAttr = p2.x;
47
+ const opacityAttr = p2.y;
48
+ const sizeScale = uniform(1);
49
+ const material = new SpriteNodeMaterial();
50
+ material.transparent = true;
51
+ material.depthWrite = !!render.depthWrite;
52
+ material.depthTest = true;
53
+ material.blending = BLEND[render.blendMode] ?? THREE.NormalBlending;
54
+ material.positionNode = p0.xyz;
55
+ material.scaleNode = sizeAttr.mul(sizeScale);
56
+ const alphaCutoff = float(render.alphaCutoff ?? 0.05);
57
+ material.colorNode = Fn(() => {
58
+ const uv = positionLocal.xy;
59
+ const d = uv.length().mul(2);
60
+ const soft = float(1).sub(d).clamp(0, 1);
61
+ const a = soft.mul(opacityAttr);
62
+ return vec4(p3.xyz, a);
63
+ })();
64
+ material.scaleNode = sizeAttr.mul(sizeScale).mul(lifeT.greaterThan(0).select(float(1), float(0)));
65
+ const geometry = new THREE.PlaneGeometry(1, 1);
66
+ const instanced = new THREE.InstancedBufferGeometry().copy(geometry);
67
+ instanced.instanceCount = count;
68
+ const mesh = new THREE.Mesh(instanced, material);
69
+ mesh.frustumCulled = false;
70
+ const device = renderer?.backend?.device;
71
+ mesh.onSimStep = () => {
72
+ if (!device) return;
73
+ const dst = renderer.backend.get(storageAttr)?.buffer;
74
+ if (!dst) return;
75
+ const enc = device.createCommandEncoder();
76
+ enc.copyBufferToBuffer(particleBuffer, 0, dst, 0, count * PARTICLE_BYTES);
77
+ device.queue.submit([enc.finish()]);
78
+ };
79
+ mesh.userData.sizeScale = sizeScale;
80
+ return mesh;
81
+ }
82
+
83
+ // src/index.js
84
+ var FRAME_DT_CLAMP = 1 / 60;
85
+ var ParticleSystem = class _ParticleSystem {
86
+ /**
87
+ * Async factory — preferred entry point.
88
+ * @param {object} config effect config (raw or resolved)
89
+ * @param {object} opts
90
+ * @param {import('three').WebGPURenderer} opts.renderer initialized WebGPU renderer
91
+ * @param {import('three').Object3D} [opts.scene] object to add the particle mesh to
92
+ * @param {import('three').Camera} [opts.camera]
93
+ */
94
+ static async create(config, opts) {
95
+ const system = new _ParticleSystem(config, opts);
96
+ await system.init();
97
+ return system;
98
+ }
99
+ constructor(config, { renderer, scene, camera } = {}) {
100
+ if (!renderer) throw new Error("ParticleSystem requires a three.js WebGPURenderer");
101
+ this.renderer = renderer;
102
+ this.scene = scene || null;
103
+ this.camera = camera || null;
104
+ this._rawConfig = config;
105
+ this.config = null;
106
+ this.mesh = null;
107
+ this.sim = null;
108
+ this._lastTime = 0;
109
+ this._playing = true;
110
+ this._disposed = false;
111
+ }
112
+ async init() {
113
+ const device = getDevice(this.renderer);
114
+ if (!device) {
115
+ throw new Error(
116
+ "WebGPU device unavailable \u2014 call `await renderer.init()` before creating the ParticleSystem"
117
+ );
118
+ }
119
+ this.config = resolveEffect(this._rawConfig);
120
+ this.sim = new ParticleSimulation(device, this.config);
121
+ await this.sim.init();
122
+ this.mesh = buildParticleMesh({
123
+ renderer: this.renderer,
124
+ particleBuffer: this.sim.particleBuffer,
125
+ maxParticles: this.config.maxParticles,
126
+ render: mergeRender(this.config.render)
127
+ });
128
+ if (this.scene) this.scene.add(this.mesh);
129
+ return this;
130
+ }
131
+ /** Swap to a new effect config at runtime. */
132
+ async setConfig(config) {
133
+ this.config = resolveEffect(config);
134
+ await this.sim.setConfig(this.config);
135
+ if (this.mesh) {
136
+ this.mesh.parent?.remove(this.mesh);
137
+ this.mesh.geometry.dispose();
138
+ this.mesh.material.dispose();
139
+ }
140
+ this.mesh = buildParticleMesh({
141
+ renderer: this.renderer,
142
+ particleBuffer: this.sim.particleBuffer,
143
+ maxParticles: this.config.maxParticles,
144
+ render: mergeRender(this.config.render)
145
+ });
146
+ if (this.scene) this.scene.add(this.mesh);
147
+ }
148
+ play() {
149
+ this._playing = true;
150
+ }
151
+ pause() {
152
+ this._playing = false;
153
+ }
154
+ reset() {
155
+ this.sim?.reset();
156
+ }
157
+ /**
158
+ * Advance the simulation. Call once per frame before `renderer.render`.
159
+ * @param {number} [dt] seconds since last update; clamped to 1/60 for stability
160
+ */
161
+ update(dt) {
162
+ if (this._disposed || !this._playing || !this.sim) return;
163
+ const clamped = Math.min(dt > 0 ? dt : 1 / 60, FRAME_DT_CLAMP);
164
+ this.sim.step(clamped);
165
+ this.mesh?.onSimStep?.();
166
+ }
167
+ dispose() {
168
+ this._disposed = true;
169
+ if (this.mesh) {
170
+ this.mesh.parent?.remove(this.mesh);
171
+ this.mesh.geometry.dispose();
172
+ this.mesh.material.dispose();
173
+ this.mesh = null;
174
+ }
175
+ this.sim?.dispose(true);
176
+ this.sim = null;
177
+ }
178
+ };
179
+ function getDevice(renderer) {
180
+ return renderer?.backend?.device || renderer?.backend?.data?.device || renderer?._device || null;
181
+ }
182
+ export {
183
+ PARTICLE_BYTES2 as PARTICLE_BYTES,
184
+ PARTICLE_STRIDE_FLOATS2 as PARTICLE_STRIDE_FLOATS,
185
+ ParticleSystem
186
+ };
package/package.json ADDED
@@ -0,0 +1,46 @@
1
+ {
2
+ "name": "@khudiiash/particles-three",
3
+ "version": "0.2.0",
4
+ "description": "three.js WebGPU runtime player for @khudiiash/particles-core effect configs.",
5
+ "license": "MIT",
6
+ "author": "khudiiash",
7
+ "keywords": [
8
+ "webgpu",
9
+ "particles",
10
+ "three",
11
+ "threejs",
12
+ "gpu",
13
+ "wgsl",
14
+ "vfx"
15
+ ],
16
+ "type": "module",
17
+ "main": "./src/index.js",
18
+ "module": "./src/index.js",
19
+ "unpkg": "./dist/index.mjs",
20
+ "jsdelivr": "./dist/index.mjs",
21
+ "exports": {
22
+ ".": {
23
+ "default": "./src/index.js"
24
+ }
25
+ },
26
+ "files": [
27
+ "src",
28
+ "dist"
29
+ ],
30
+ "scripts": {
31
+ "build": "esbuild src/index.js --bundle --format=esm --outfile=dist/index.mjs --external:three --external:three/* --external:@khudiiash/particles-core",
32
+ "prepublishOnly": "npm run build"
33
+ },
34
+ "peerDependencies": {
35
+ "three": ">=0.160.0"
36
+ },
37
+ "dependencies": {
38
+ "@khudiiash/particles-core": "^0.2.0"
39
+ },
40
+ "devDependencies": {
41
+ "esbuild": "^0.21.5"
42
+ },
43
+ "publishConfig": {
44
+ "access": "public"
45
+ }
46
+ }
package/src/index.js ADDED
@@ -0,0 +1,146 @@
1
+ /**
2
+ * @khudiiash/particles-three
3
+ *
4
+ * Plays a `@khudiiash/particles-core` effect config inside a three.js scene
5
+ * using the WebGPU backend. The simulation runs as a compute pass on the
6
+ * renderer's own `GPUDevice`, and particles are drawn straight from the GPU
7
+ * storage buffer — there is no CPU readback.
8
+ *
9
+ * Usage:
10
+ * import * as THREE from 'three';
11
+ * import { WebGPURenderer } from 'three/webgpu';
12
+ * import { ParticleSystem } from '@khudiiash/particles-three';
13
+ *
14
+ * const renderer = new WebGPURenderer({ antialias: true });
15
+ * await renderer.init();
16
+ * const ps = await ParticleSystem.create(effectConfig, { renderer, scene, camera });
17
+ * renderer.setAnimationLoop((t) => { ps.update(dt); renderer.render(scene, camera); });
18
+ */
19
+ import * as THREE from "three";
20
+ import {
21
+ ParticleSimulation,
22
+ PARTICLE_BYTES,
23
+ resolveEffect,
24
+ mergeRender,
25
+ PARTICLE_STRIDE_FLOATS,
26
+ } from "@khudiiash/particles-core";
27
+ import { buildParticleMesh } from "./mesh.js";
28
+
29
+ const FRAME_DT_CLAMP = 1 / 60;
30
+
31
+ export class ParticleSystem {
32
+ /**
33
+ * Async factory — preferred entry point.
34
+ * @param {object} config effect config (raw or resolved)
35
+ * @param {object} opts
36
+ * @param {import('three').WebGPURenderer} opts.renderer initialized WebGPU renderer
37
+ * @param {import('three').Object3D} [opts.scene] object to add the particle mesh to
38
+ * @param {import('three').Camera} [opts.camera]
39
+ */
40
+ static async create(config, opts) {
41
+ const system = new ParticleSystem(config, opts);
42
+ await system.init();
43
+ return system;
44
+ }
45
+
46
+ constructor(config, { renderer, scene, camera } = {}) {
47
+ if (!renderer) throw new Error("ParticleSystem requires a three.js WebGPURenderer");
48
+ this.renderer = renderer;
49
+ this.scene = scene || null;
50
+ this.camera = camera || null;
51
+ this._rawConfig = config;
52
+ this.config = null;
53
+ this.mesh = null;
54
+ this.sim = null;
55
+ this._lastTime = 0;
56
+ this._playing = true;
57
+ this._disposed = false;
58
+ }
59
+
60
+ async init() {
61
+ const device = getDevice(this.renderer);
62
+ if (!device) {
63
+ throw new Error(
64
+ "WebGPU device unavailable — call `await renderer.init()` before creating the ParticleSystem",
65
+ );
66
+ }
67
+ this.config = resolveEffect(this._rawConfig);
68
+ this.sim = new ParticleSimulation(device, this.config);
69
+ await this.sim.init();
70
+
71
+ this.mesh = buildParticleMesh({
72
+ renderer: this.renderer,
73
+ particleBuffer: this.sim.particleBuffer,
74
+ maxParticles: this.config.maxParticles,
75
+ render: mergeRender(this.config.render),
76
+ });
77
+ if (this.scene) this.scene.add(this.mesh);
78
+ return this;
79
+ }
80
+
81
+ /** Swap to a new effect config at runtime. */
82
+ async setConfig(config) {
83
+ this.config = resolveEffect(config);
84
+ await this.sim.setConfig(this.config);
85
+ // Rebuild the mesh so it points at the (possibly new) particle buffer.
86
+ if (this.mesh) {
87
+ this.mesh.parent?.remove(this.mesh);
88
+ this.mesh.geometry.dispose();
89
+ this.mesh.material.dispose();
90
+ }
91
+ this.mesh = buildParticleMesh({
92
+ renderer: this.renderer,
93
+ particleBuffer: this.sim.particleBuffer,
94
+ maxParticles: this.config.maxParticles,
95
+ render: mergeRender(this.config.render),
96
+ });
97
+ if (this.scene) this.scene.add(this.mesh);
98
+ }
99
+
100
+ play() {
101
+ this._playing = true;
102
+ }
103
+
104
+ pause() {
105
+ this._playing = false;
106
+ }
107
+
108
+ reset() {
109
+ this.sim?.reset();
110
+ }
111
+
112
+ /**
113
+ * Advance the simulation. Call once per frame before `renderer.render`.
114
+ * @param {number} [dt] seconds since last update; clamped to 1/60 for stability
115
+ */
116
+ update(dt) {
117
+ if (this._disposed || !this._playing || !this.sim) return;
118
+ const clamped = Math.min(dt > 0 ? dt : 1 / 60, FRAME_DT_CLAMP);
119
+ this.sim.step(clamped);
120
+ this.mesh?.onSimStep?.();
121
+ }
122
+
123
+ dispose() {
124
+ this._disposed = true;
125
+ if (this.mesh) {
126
+ this.mesh.parent?.remove(this.mesh);
127
+ this.mesh.geometry.dispose();
128
+ this.mesh.material.dispose();
129
+ this.mesh = null;
130
+ }
131
+ this.sim?.dispose(true);
132
+ this.sim = null;
133
+ }
134
+ }
135
+
136
+ /** Resolve the underlying GPUDevice from a three WebGPURenderer across versions. */
137
+ function getDevice(renderer) {
138
+ return (
139
+ renderer?.backend?.device ||
140
+ renderer?.backend?.data?.device ||
141
+ renderer?._device ||
142
+ null
143
+ );
144
+ }
145
+
146
+ export { PARTICLE_BYTES, PARTICLE_STRIDE_FLOATS };
package/src/mesh.js ADDED
@@ -0,0 +1,107 @@
1
+ /**
2
+ * Builds the renderable particle mesh for the three.js WebGPU runtime.
3
+ *
4
+ * The mesh reads particle state directly from a GPU storage buffer (the buffer
5
+ * the core `ParticleSimulation` compute pass writes to) using TSL storage nodes,
6
+ * so no per-frame CPU readback is required. The mesh is a billboarded instanced
7
+ * quad — one instance per particle — driven by `SpriteNodeMaterial`.
8
+ *
9
+ * Particle struct layout (16 floats / 4 vec4 per particle), see core/layout.js:
10
+ * vec4 0: position.xyz, life
11
+ * vec4 1: velocity.xyz, maxLife
12
+ * vec4 2: size, opacity, pathPhase, pathSpread
13
+ * vec4 3: color.rgb, seed
14
+ */
15
+ import * as THREE from "three";
16
+ import { StorageInstancedBufferAttribute } from "three";
17
+ import {
18
+ Fn,
19
+ storage,
20
+ instanceIndex,
21
+ vec3,
22
+ vec4,
23
+ float,
24
+ uniform,
25
+ positionLocal,
26
+ } from "three/tsl";
27
+ import { SpriteNodeMaterial } from "three/webgpu";
28
+ import { PARTICLE_STRIDE_FLOATS, PARTICLE_BYTES } from "@khudiiash/particles-core";
29
+
30
+ const VEC4_PER_PARTICLE = PARTICLE_STRIDE_FLOATS / 4; // 4
31
+
32
+ const BLEND = {
33
+ additive: THREE.AdditiveBlending,
34
+ additiveAlpha: THREE.AdditiveBlending,
35
+ normal: THREE.NormalBlending,
36
+ premultiplied: THREE.CustomBlending,
37
+ multiply: THREE.MultiplyBlending,
38
+ screen: THREE.AdditiveBlending,
39
+ };
40
+
41
+ export function buildParticleMesh({ renderer, particleBuffer, maxParticles, render }) {
42
+ const count = maxParticles;
43
+
44
+ // three-owned storage buffer that the material reads from. We mirror the
45
+ // simulation buffer into it every frame with a GPU->GPU copy.
46
+ const storageArray = new Float32Array(count * PARTICLE_STRIDE_FLOATS);
47
+ const storageAttr = new StorageInstancedBufferAttribute(storageArray, 4);
48
+ storageAttr.setUsage?.(THREE.DynamicDrawUsage);
49
+
50
+ const particles = storage(storageAttr, "vec4", count * VEC4_PER_PARTICLE);
51
+
52
+ const base = instanceIndex.mul(VEC4_PER_PARTICLE);
53
+ const p0 = particles.element(base);
54
+ const p2 = particles.element(base.add(2));
55
+ const p3 = particles.element(base.add(3));
56
+
57
+ const lifeT = p0.w; // remaining life > 0 means alive (sim writes life)
58
+ const sizeAttr = p2.x;
59
+ const opacityAttr = p2.y;
60
+
61
+ const sizeScale = uniform(1);
62
+
63
+ const material = new SpriteNodeMaterial();
64
+ material.transparent = true;
65
+ material.depthWrite = !!render.depthWrite;
66
+ material.depthTest = true;
67
+ material.blending = BLEND[render.blendMode] ?? THREE.NormalBlending;
68
+
69
+ // Per-instance world position = particle position.
70
+ material.positionNode = p0.xyz;
71
+ // Per-instance billboard scale.
72
+ material.scaleNode = sizeAttr.mul(sizeScale);
73
+
74
+ // Soft round sprite alpha from quad-local UV, modulated by particle opacity.
75
+ const alphaCutoff = float(render.alphaCutoff ?? 0.05);
76
+ material.colorNode = Fn(() => {
77
+ const uv = positionLocal.xy; // [-0.5, 0.5]
78
+ const d = uv.length().mul(2.0);
79
+ const soft = float(1.0).sub(d).clamp(0.0, 1.0);
80
+ const a = soft.mul(opacityAttr);
81
+ return vec4(p3.xyz, a);
82
+ })();
83
+
84
+ // Hide dead particles (life <= 0) by collapsing their scale.
85
+ material.scaleNode = sizeAttr.mul(sizeScale).mul(lifeT.greaterThan(0.0).select(float(1), float(0)));
86
+
87
+ const geometry = new THREE.PlaneGeometry(1, 1);
88
+ const instanced = new THREE.InstancedBufferGeometry().copy(geometry);
89
+ instanced.instanceCount = count;
90
+
91
+ const mesh = new THREE.Mesh(instanced, material);
92
+ mesh.frustumCulled = false;
93
+
94
+ // Mirror the simulation buffer into the storage attribute each step.
95
+ const device = renderer?.backend?.device;
96
+ mesh.onSimStep = () => {
97
+ if (!device) return;
98
+ const dst = renderer.backend.get(storageAttr)?.buffer;
99
+ if (!dst) return; // not allocated until first render
100
+ const enc = device.createCommandEncoder();
101
+ enc.copyBufferToBuffer(particleBuffer, 0, dst, 0, count * PARTICLE_BYTES);
102
+ device.queue.submit([enc.finish()]);
103
+ };
104
+
105
+ mesh.userData.sizeScale = sizeScale;
106
+ return mesh;
107
+ }