nova64 0.2.2 → 0.2.3

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nova64",
3
- "version": "0.2.2",
3
+ "version": "0.2.3",
4
4
  "description": "Nova64 — Ultimate 3D Fantasy Console runtime for JavaScript games powered by Three.js",
5
5
  "keywords": [
6
6
  "fantasy-console",
@@ -212,6 +212,13 @@
212
212
  "setInstanceColor": "readonly",
213
213
  "finalizeInstances": "readonly",
214
214
  "removeInstancedMesh": "readonly",
215
+ "createParticleSystem": "readonly",
216
+ "setParticleEmitter": "readonly",
217
+ "emitParticle": "readonly",
218
+ "burstParticles": "readonly",
219
+ "updateParticles": "readonly",
220
+ "removeParticleSystem": "readonly",
221
+ "getParticleStats": "readonly",
215
222
  "createLODMesh": "readonly",
216
223
  "setLODPosition": "readonly",
217
224
  "removeLODMesh": "readonly",
@@ -0,0 +1,296 @@
1
+ // runtime/api-3d/particles.js
2
+ // GPU particle system: typed-array physics simulation, single InstancedMesh draw call
3
+ //
4
+ // API:
5
+ // createParticleSystem(maxParticles, options) → systemId
6
+ // setParticleEmitter(systemId, emitter) configure emission
7
+ // emitParticle(systemId, overrides?) fire one particle
8
+ // burstParticles(systemId, count, overrides?) fire N particles at once
9
+ // updateParticles(dt) step all systems (call each frame)
10
+ // removeParticleSystem(systemId) cleanup
11
+
12
+ import * as THREE from 'three';
13
+
14
+ export function particlesModule({ scene, counters }) {
15
+ // Per-particle typed arrays layout (indices into Float32Array pools):
16
+ // px py pz — position
17
+ // vx vy vz — velocity
18
+ // age life — lifetime tracking
19
+ // r g b — color (0-1)
20
+ // size — scale
21
+ // active — 1 = alive, 0 = dead
22
+
23
+ const STRIDE = 13; // slots per particle
24
+
25
+ const particleSystems = new Map();
26
+ let psCounter = 0;
27
+
28
+ const _dummy = new THREE.Object3D();
29
+ const _color = new THREE.Color();
30
+
31
+ function createParticleSystem(maxParticles = 200, options = {}) {
32
+ const {
33
+ shape = 'sphere',
34
+ size = 0.15,
35
+ segments = 4,
36
+ color = 0xffaa00,
37
+ emissive = 0xff6600,
38
+ emissiveIntensity = 2.0,
39
+ gravity = -9.8,
40
+ drag = 0.95,
41
+ // Emitter defaults
42
+ emitterX = 0,
43
+ emitterY = 0,
44
+ emitterZ = 0,
45
+ emitRate = 20, // particles per second
46
+ minLife = 0.8,
47
+ maxLife = 2.0,
48
+ minSpeed = 2,
49
+ maxSpeed = 8,
50
+ spread = Math.PI, // half-angle cone spread (PI = hemisphere)
51
+ minSize = 0.05,
52
+ maxSize = 0.3,
53
+ startColor = color,
54
+ endColor = 0x000000,
55
+ } = options;
56
+
57
+ // Geometry
58
+ let geometry;
59
+ if (shape === 'cube') {
60
+ geometry = new THREE.BoxGeometry(size, size, size);
61
+ } else {
62
+ geometry = new THREE.SphereGeometry(size, segments, segments);
63
+ }
64
+
65
+ const material = new THREE.MeshBasicMaterial({ color, vertexColors: false });
66
+ material.emissive = new THREE.Color(emissive);
67
+ // MeshBasicMaterial doesn't have emissive — use MeshStandardMaterial instead
68
+ const stdMat = new THREE.MeshStandardMaterial({
69
+ color,
70
+ emissive: new THREE.Color(emissive),
71
+ emissiveIntensity,
72
+ roughness: 0.8,
73
+ metalness: 0.0,
74
+ });
75
+
76
+ const mesh = new THREE.InstancedMesh(geometry, stdMat, maxParticles);
77
+ mesh.instanceMatrix.setUsage(THREE.DynamicDrawUsage);
78
+ mesh.castShadow = false;
79
+ mesh.receiveShadow = false;
80
+
81
+ // Hide all instances initially by scaling to zero
82
+ _dummy.position.set(0, -9999, 0);
83
+ _dummy.scale.set(0, 0, 0);
84
+ _dummy.updateMatrix();
85
+ for (let i = 0; i < maxParticles; i++) {
86
+ mesh.setMatrixAt(i, _dummy.matrix);
87
+ }
88
+ mesh.instanceMatrix.needsUpdate = true;
89
+
90
+ // Enable per-instance color
91
+ mesh.instanceColor = new THREE.InstancedBufferAttribute(new Float32Array(maxParticles * 3), 3);
92
+
93
+ scene.add(mesh);
94
+
95
+ // Typed-array particle state pool
96
+ const pool = new Float32Array(maxParticles * STRIDE);
97
+ // All start inactive (age = life = 0, active slot = 0)
98
+
99
+ const id = ++psCounter;
100
+ particleSystems.set(id, {
101
+ mesh,
102
+ pool,
103
+ maxParticles,
104
+ freeList: Array.from({ length: maxParticles }, (_, i) => i),
105
+ activeCount: 0,
106
+ emitAccum: 0,
107
+ emitter: {
108
+ x: emitterX,
109
+ y: emitterY,
110
+ z: emitterZ,
111
+ emitRate,
112
+ minLife,
113
+ maxLife,
114
+ minSpeed,
115
+ maxSpeed,
116
+ spread,
117
+ minSize,
118
+ maxSize,
119
+ },
120
+ config: {
121
+ gravity,
122
+ drag,
123
+ startColor: new THREE.Color(startColor),
124
+ endColor: new THREE.Color(endColor),
125
+ },
126
+ });
127
+
128
+ counters.particle = (counters.particle || 0) + 1;
129
+ return id;
130
+ }
131
+
132
+ function setParticleEmitter(systemId, emitter = {}) {
133
+ const sys = particleSystems.get(systemId);
134
+ if (!sys) return;
135
+ Object.assign(sys.emitter, emitter);
136
+ }
137
+
138
+ function _spawnParticle(sys, overrides = {}) {
139
+ if (sys.freeList.length === 0) return; // pool full
140
+ const idx = sys.freeList.pop();
141
+ const base = idx * STRIDE;
142
+ const { pool, emitter } = sys;
143
+
144
+ const ex = overrides.x ?? emitter.x;
145
+ const ey = overrides.y ?? emitter.y;
146
+ const ez = overrides.z ?? emitter.z;
147
+
148
+ // Random direction within spread cone (pointing up by default)
149
+ const phi = Math.random() * Math.PI * 2;
150
+ const theta = Math.random() * (overrides.spread ?? emitter.spread);
151
+ const ct = Math.cos(theta);
152
+ const st = Math.sin(theta);
153
+ const speed = emitter.minSpeed + Math.random() * (emitter.maxSpeed - emitter.minSpeed);
154
+ const vx = overrides.vx ?? st * Math.cos(phi) * speed;
155
+ const vy = overrides.vy ?? ct * speed;
156
+ const vz = overrides.vz ?? st * Math.sin(phi) * speed;
157
+
158
+ const life = emitter.minLife + Math.random() * (emitter.maxLife - emitter.minLife);
159
+ const sz = emitter.minSize + Math.random() * (emitter.maxSize - emitter.minSize);
160
+
161
+ // position
162
+ pool[base + 0] = ex;
163
+ pool[base + 1] = ey;
164
+ pool[base + 2] = ez;
165
+ // velocity
166
+ pool[base + 3] = vx;
167
+ pool[base + 4] = vy;
168
+ pool[base + 5] = vz;
169
+ // age, life
170
+ pool[base + 6] = 0;
171
+ pool[base + 7] = life > 0 ? life : 1;
172
+ // color (start)
173
+ const sc = sys.config.startColor;
174
+ pool[base + 8] = overrides.r ?? sc.r;
175
+ pool[base + 9] = overrides.g ?? sc.g;
176
+ pool[base + 10] = overrides.b ?? sc.b;
177
+ // size
178
+ pool[base + 11] = sz;
179
+ // active
180
+ pool[base + 12] = 1;
181
+
182
+ sys.activeCount++;
183
+ }
184
+
185
+ function emitParticle(systemId, overrides = {}) {
186
+ const sys = particleSystems.get(systemId);
187
+ if (sys) _spawnParticle(sys, overrides);
188
+ }
189
+
190
+ function burstParticles(systemId, count = 10, overrides = {}) {
191
+ const sys = particleSystems.get(systemId);
192
+ if (!sys) return;
193
+ for (let i = 0; i < count; i++) _spawnParticle(sys, overrides);
194
+ }
195
+
196
+ function updateParticles(dt) {
197
+ particleSystems.forEach(sys => {
198
+ const { pool, maxParticles, mesh, config, emitter } = sys;
199
+
200
+ // Auto-emit based on rate
201
+ sys.emitAccum += emitter.emitRate * dt;
202
+ while (sys.emitAccum >= 1 && sys.freeList.length > 0) {
203
+ _spawnParticle(sys);
204
+ sys.emitAccum -= 1;
205
+ }
206
+
207
+ let needsUpdate = false;
208
+
209
+ for (let i = 0; i < maxParticles; i++) {
210
+ const base = i * STRIDE;
211
+ if (pool[base + 12] === 0) continue; // inactive
212
+
213
+ // Step age
214
+ pool[base + 6] += dt;
215
+ const age = pool[base + 6];
216
+ const life = pool[base + 7];
217
+
218
+ if (age >= life) {
219
+ // Kill particle
220
+ pool[base + 12] = 0;
221
+ sys.freeList.push(i);
222
+ sys.activeCount--;
223
+
224
+ // Scale to zero
225
+ _dummy.position.set(0, -9999, 0);
226
+ _dummy.scale.set(0, 0, 0);
227
+ _dummy.updateMatrix();
228
+ mesh.setMatrixAt(i, _dummy.matrix);
229
+ needsUpdate = true;
230
+ continue;
231
+ }
232
+
233
+ // Physics integration
234
+ pool[base + 3] *= config.drag; // drag on vx
235
+ pool[base + 5] *= config.drag; // drag on vz
236
+ pool[base + 4] += config.gravity * dt; // gravity on vy
237
+
238
+ pool[base + 0] += pool[base + 3] * dt; // px
239
+ pool[base + 1] += pool[base + 4] * dt; // py
240
+ pool[base + 2] += pool[base + 5] * dt; // pz
241
+
242
+ // Interpolate color start→end
243
+ const t = age / life;
244
+ const r = pool[base + 8] * (1 - t) + config.endColor.r * t;
245
+ const g = pool[base + 9] * (1 - t) + config.endColor.g * t;
246
+ const b = pool[base + 10] * (1 - t) + config.endColor.b * t;
247
+ _color.setRGB(r, g, b);
248
+ mesh.setColorAt(i, _color);
249
+
250
+ // Shrink as particle ages
251
+ const sz = pool[base + 11] * (1 - t * 0.8);
252
+
253
+ _dummy.position.set(pool[base + 0], pool[base + 1], pool[base + 2]);
254
+ _dummy.scale.set(sz, sz, sz);
255
+ _dummy.updateMatrix();
256
+ mesh.setMatrixAt(i, _dummy.matrix);
257
+ needsUpdate = true;
258
+ }
259
+
260
+ if (needsUpdate) {
261
+ mesh.instanceMatrix.needsUpdate = true;
262
+ if (mesh.instanceColor) mesh.instanceColor.needsUpdate = true;
263
+ }
264
+ });
265
+ }
266
+
267
+ function removeParticleSystem(systemId) {
268
+ const sys = particleSystems.get(systemId);
269
+ if (!sys) return false;
270
+ scene.remove(sys.mesh);
271
+ sys.mesh.geometry?.dispose();
272
+ sys.mesh.material?.dispose();
273
+ particleSystems.delete(systemId);
274
+ return true;
275
+ }
276
+
277
+ function getParticleStats(systemId) {
278
+ const sys = particleSystems.get(systemId);
279
+ if (!sys) return null;
280
+ return {
281
+ active: sys.activeCount,
282
+ max: sys.maxParticles,
283
+ free: sys.freeList.length,
284
+ };
285
+ }
286
+
287
+ return {
288
+ createParticleSystem,
289
+ setParticleEmitter,
290
+ emitParticle,
291
+ burstParticles,
292
+ updateParticles,
293
+ removeParticleSystem,
294
+ getParticleStats,
295
+ };
296
+ }
package/runtime/api-3d.js CHANGED
@@ -11,6 +11,7 @@ import { modelsModule } from './api-3d/models.js';
11
11
  import { instancingModule } from './api-3d/instancing.js';
12
12
  import { pbrModule } from './api-3d/pbr.js';
13
13
  import { sceneModule } from './api-3d/scene.js';
14
+ import { particlesModule } from './api-3d/particles.js';
14
15
 
15
16
  export function threeDApi(gpu) {
16
17
  if (!gpu.getScene) return { exposeTo: () => {} };
@@ -53,6 +54,7 @@ export function threeDApi(gpu) {
53
54
  Object.assign(ctx, modelsModule(ctx));
54
55
  Object.assign(ctx, instancingModule(ctx));
55
56
  Object.assign(ctx, pbrModule(ctx));
57
+ Object.assign(ctx, particlesModule(ctx));
56
58
  Object.assign(ctx, sceneModule(ctx)); // last: uses late binding to call other modules
57
59
 
58
60
  return {
@@ -138,6 +140,15 @@ export function threeDApi(gpu) {
138
140
  setNormalMap: ctx.setNormalMap,
139
141
  setPBRMaps: ctx.setPBRMaps,
140
142
 
143
+ // GPU particle system
144
+ createParticleSystem: ctx.createParticleSystem,
145
+ setParticleEmitter: ctx.setParticleEmitter,
146
+ emitParticle: ctx.emitParticle,
147
+ burstParticles: ctx.burstParticles,
148
+ updateParticles: ctx.updateParticles,
149
+ removeParticleSystem: ctx.removeParticleSystem,
150
+ getParticleStats: ctx.getParticleStats,
151
+
141
152
  // Interaction / stats / convenience
142
153
  raycastFromCamera: ctx.raycastFromCamera,
143
154
  get3DStats: ctx.get3DStats,
@@ -20,6 +20,60 @@ export type InstancedMeshId = number;
20
20
  /** LOD identifier returned by createLODMesh. */
21
21
  export type LODId = number;
22
22
 
23
+ /** Particle system identifier returned by createParticleSystem. */
24
+ export type ParticleSystemId = number;
25
+
26
+ export interface ParticleSystemOptions {
27
+ shape?: 'sphere' | 'cube';
28
+ size?: number;
29
+ segments?: number;
30
+ color?: Color;
31
+ emissive?: Color;
32
+ emissiveIntensity?: number;
33
+ gravity?: number;
34
+ drag?: number;
35
+ emitterX?: number;
36
+ emitterY?: number;
37
+ emitterZ?: number;
38
+ emitRate?: number;
39
+ minLife?: number;
40
+ maxLife?: number;
41
+ minSpeed?: number;
42
+ maxSpeed?: number;
43
+ spread?: number;
44
+ minSize?: number;
45
+ maxSize?: number;
46
+ startColor?: Color;
47
+ endColor?: Color;
48
+ }
49
+
50
+ export interface ParticleEmitter {
51
+ x: number;
52
+ y: number;
53
+ z: number;
54
+ emitRate: number;
55
+ minLife: number;
56
+ maxLife: number;
57
+ minSpeed: number;
58
+ maxSpeed: number;
59
+ spread: number;
60
+ minSize: number;
61
+ maxSize: number;
62
+ }
63
+
64
+ export interface ParticleOverrides {
65
+ x: number;
66
+ y: number;
67
+ z: number;
68
+ vx: number;
69
+ vy: number;
70
+ vz: number;
71
+ spread: number;
72
+ r: number;
73
+ g: number;
74
+ b: number;
75
+ }
76
+
23
77
  /** Panel object returned by createPanel. */
24
78
  export interface Panel {
25
79
  x: number;
@@ -320,6 +374,21 @@ export interface ThreeDApiInstance {
320
374
  finalizeInstances(instancedId: InstancedMeshId): boolean;
321
375
  removeInstancedMesh(instancedId: InstancedMeshId): boolean;
322
376
 
377
+ // GPU particle system
378
+ createParticleSystem(maxParticles?: number, options?: ParticleSystemOptions): ParticleSystemId;
379
+ setParticleEmitter(systemId: ParticleSystemId, emitter: Partial<ParticleEmitter>): void;
380
+ emitParticle(systemId: ParticleSystemId, overrides?: Partial<ParticleOverrides>): void;
381
+ burstParticles(
382
+ systemId: ParticleSystemId,
383
+ count?: number,
384
+ overrides?: Partial<ParticleOverrides>
385
+ ): void;
386
+ updateParticles(dt: number): void;
387
+ removeParticleSystem(systemId: ParticleSystemId): boolean;
388
+ getParticleStats(
389
+ systemId: ParticleSystemId
390
+ ): { active: number; max: number; free: number } | null;
391
+
323
392
  // LOD system
324
393
  createLODMesh(levels?: LODLevel[], position?: [number, number, number]): LODId;
325
394
  setLODPosition(lodId: LODId, x: number, y: number, z: number): void;
@@ -533,6 +602,13 @@ export interface Nova64CartGlobals {
533
602
  setInstanceColor: ThreeDApiInstance['setInstanceColor'];
534
603
  finalizeInstances: ThreeDApiInstance['finalizeInstances'];
535
604
  removeInstancedMesh: ThreeDApiInstance['removeInstancedMesh'];
605
+ createParticleSystem: ThreeDApiInstance['createParticleSystem'];
606
+ setParticleEmitter: ThreeDApiInstance['setParticleEmitter'];
607
+ emitParticle: ThreeDApiInstance['emitParticle'];
608
+ burstParticles: ThreeDApiInstance['burstParticles'];
609
+ updateParticles: ThreeDApiInstance['updateParticles'];
610
+ removeParticleSystem: ThreeDApiInstance['removeParticleSystem'];
611
+ getParticleStats: ThreeDApiInstance['getParticleStats'];
536
612
  createLODMesh: ThreeDApiInstance['createLODMesh'];
537
613
  setLODPosition: ThreeDApiInstance['setLODPosition'];
538
614
  removeLODMesh: ThreeDApiInstance['removeLODMesh'];