sparcoon 0.9.1 → 0.9.2
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/README.md +11 -6
- package/dist/artifact/FXArtifact.d.ts +51 -3
- package/dist/behaviorTransformFeedbackLayout.d.ts +24 -0
- package/dist/behaviorTransformFeedbackLayout.js +24 -0
- package/dist/coreLayout.d.ts +9 -5
- package/dist/coreLayout.js +17 -4
- package/dist/editor/index.d.ts +1 -0
- package/dist/editor/index.js +1 -1
- package/dist/effect/FXEffect.d.ts +2 -0
- package/dist/effect/FXEffect.js +30 -10
- package/dist/effect/FXEffectSpec.d.ts +21 -4
- package/dist/emitter/FXApplyRenderArtifactOptions.d.ts +15 -0
- package/dist/emitter/FXApplyRenderArtifactOptions.js +1 -0
- package/dist/emitter/FXEmitter.d.ts +32 -13
- package/dist/emitter/FXEmitter.js +107 -148
- package/dist/emitter/FXEmitterDriver.Internal.d.ts +26 -0
- package/dist/emitter/FXEmitterDriver.Internal.js +70 -0
- package/dist/emitter/FXFromArtifactsOptions.d.ts +12 -1
- package/dist/emitter/FXGPUEmitterDriver.Internal.d.ts +1 -0
- package/dist/emitter/FXGPUEmitterDriver.Internal.js +137 -0
- package/dist/emitter/FXJSEmitterDriver.Internal.d.ts +1 -0
- package/dist/emitter/FXJSEmitterDriver.Internal.js +133 -0
- package/dist/emitter/FXSimulationHolder.js +7 -5
- package/dist/emitter/FXTransformFeedbackSimulationHolder.d.ts +1 -0
- package/dist/emitter/FXTransformFeedbackSimulationHolder.js +181 -0
- package/dist/index.d.ts +4 -3
- package/dist/index.js +3 -2
- package/dist/instancedParticle/FXInstancedParticle.js +28 -1
- package/dist/instancedParticle/FXTransformFeedbackParticle.d.ts +1 -0
- package/dist/instancedParticle/FXTransformFeedbackParticle.js +169 -0
- package/dist/miscellaneous/fxMath.d.ts +18 -11
- package/dist/miscellaneous/fxMath.js +89 -209
- package/dist/render/FXArtifactMaterial.Internal.js +66 -40
- package/dist/render/FXArtifactMaterial.js +1 -1
- package/dist/render/FXMesh.js +2 -4
- package/package.json +1 -1
|
@@ -1,37 +1,28 @@
|
|
|
1
1
|
import { Object3D, Quaternion, Vector3 } from "three";
|
|
2
2
|
import { FXWorld } from "../world/FXWorld.js";
|
|
3
3
|
import { FXObjectMotionTracker } from "./FXObjectMotion.Internal.js";
|
|
4
|
-
import { FX_AGE, FX_CORE_LIFECYCLE, FX_CORE_LIFECYCLE_STRIDE, FX_CORE_POSITION, FX_CORE_POSITION_STRIDE, } from "../coreLayout.js";
|
|
5
|
-
import { FXInstancedParticle } from "../instancedParticle/FXInstancedParticle.js";
|
|
6
4
|
import { resolveGeometrySource } from "../instancedParticle/primitiveGeometry.js";
|
|
7
5
|
import { assertValidNonNegativeNumber, assertValidPositiveInteger, assertValidPositiveNumber, } from "../miscellaneous/asserts.js";
|
|
8
6
|
import { FXArtifactMaterial } from "../render/FXArtifactMaterial.js";
|
|
9
|
-
import {
|
|
7
|
+
import { FXGPUEmitterDriver } from "./FXGPUEmitterDriver.Internal.js";
|
|
8
|
+
import { FXJSEmitterDriver } from "./FXJSEmitterDriver.Internal.js";
|
|
10
9
|
import { EMITTER_DEFAULT_CAPACITY_STEP, EMITTER_DEFAULT_CAST_SHADOW, EMITTER_DEFAULT_EXPECTED_CAPACITY, EMITTER_DEFAULT_PREWARM_MAX_STEP_COUNT, EMITTER_DEFAULT_PREWARM_MIN_STEP_DURATION, EMITTER_DEFAULT_RECEIVE_SHADOW, EMITTER_DEFAULT_SORT_FRACTION, } from "./FXEmitter.Internal.js";
|
|
11
|
-
// User attribute `name` -> mesh property-buffer key `fx_<name>`.
|
|
12
|
-
const ATTRIBUTE_BUFFER_PREFIX = "fx_";
|
|
13
|
-
// GLSL type of a float attribute, indexed by component count.
|
|
14
|
-
const GLSL_TYPE_BY_COMPONENTS = ["", "float", "vec2", "vec3", "vec4"];
|
|
15
|
-
const CORE_POSITION_TYPE = {
|
|
16
|
-
glslTypeName: "vec3",
|
|
17
|
-
bufferSize: FX_CORE_POSITION_STRIDE,
|
|
18
|
-
instantiable: true,
|
|
19
|
-
};
|
|
20
|
-
const CORE_LIFECYCLE_TYPE = {
|
|
21
|
-
glslTypeName: "vec2",
|
|
22
|
-
bufferSize: FX_CORE_LIFECYCLE_STRIDE,
|
|
23
|
-
instantiable: true,
|
|
24
|
-
};
|
|
25
10
|
/**
|
|
26
11
|
* Particle emitter - one instanced billboard/mesh draw call, per-particle state driven by a
|
|
27
12
|
* precompiled behavior artifact and appearance by a precompiled render artifact spliced into a
|
|
28
13
|
* Three material. Built and ticked by {@link FXEffect} through an {@link FXWorld}. Not exported from
|
|
29
14
|
* the package (the runtime path is running artifacts, not hand-building emitters); reached only as
|
|
30
15
|
* the live handle returned by {@link FXEffect.getEmitter}.
|
|
16
|
+
*
|
|
17
|
+
* A thin facade over one {@link FXEmitterDriver} (JS or GPU - see `FXEmitterDriver.Internal.ts`),
|
|
18
|
+
* chosen once by {@link fromArtifacts} and never switched mid-playback. This class itself owns only
|
|
19
|
+
* what is genuinely backend-independent: play/burst/stop/prewarm scheduling, sort-fraction timing,
|
|
20
|
+
* object motion tracking, and the render material - the material itself CAN be replaced in place,
|
|
21
|
+
* by {@link applyRenderArtifact}, without touching the driver or its particle state.
|
|
31
22
|
*/
|
|
32
23
|
export class FXEmitter extends Object3D {
|
|
33
24
|
/** @internal Use {@link FXEmitter.fromArtifacts}. */
|
|
34
|
-
constructor(
|
|
25
|
+
constructor(driver, material, options, world) {
|
|
35
26
|
super();
|
|
36
27
|
this.nextHandler = 0;
|
|
37
28
|
this.pendingBursts = [];
|
|
@@ -52,38 +43,22 @@ export class FXEmitter extends Object3D {
|
|
|
52
43
|
this.motionScaleScratch = new Vector3();
|
|
53
44
|
this.motionTracker = new FXObjectMotionTracker();
|
|
54
45
|
this.destroyed = false;
|
|
55
|
-
this.kernelBuffersCacheVersion = -1;
|
|
56
46
|
this.onRendering = (deltaTime) => {
|
|
57
47
|
this.tick(deltaTime);
|
|
58
|
-
if (this.sortCamera
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
}
|
|
48
|
+
if (this.sortCamera === undefined) {
|
|
49
|
+
return;
|
|
50
|
+
}
|
|
51
|
+
// `sortFraction` is public, so clamp here too: a direct assignment above 1 would grow the accumulator unbounded.
|
|
52
|
+
this.sortingAccumulator += Math.min(Math.max(this.sortFraction, 0), 1);
|
|
53
|
+
if (this.sortingAccumulator >= 1) {
|
|
54
|
+
this.sortingAccumulator -= 1;
|
|
55
|
+
this.sortCamera.getWorldPosition(this.sortingCameraWorldPosition);
|
|
56
|
+
this.driver.sortByDistance(this.sortingCameraWorldPosition);
|
|
68
57
|
}
|
|
69
58
|
};
|
|
70
59
|
this.world = world;
|
|
71
|
-
|
|
72
|
-
// Sizes the Float32Array buffers - a fractional length is a RangeError.
|
|
73
|
-
assertValidPositiveInteger(options.expectedCapacity, "FXEmitter.constructor.options.expectedCapacity");
|
|
74
|
-
}
|
|
75
|
-
if (options.capacityStep !== undefined) {
|
|
76
|
-
assertValidPositiveInteger(options.capacityStep, "FXEmitter.constructor.options.capacityStep");
|
|
77
|
-
}
|
|
60
|
+
this.driver = driver;
|
|
78
61
|
this.material = material;
|
|
79
|
-
this.simulation = simulation;
|
|
80
|
-
// Property set = the two core buffers plus one `fx_<name>` per attribute, known before the mesh
|
|
81
|
-
// and Three material are built so `a_<name>`/`p_<name>` are declared up front. A cross-artifact
|
|
82
|
-
// width disagreement fails fast here.
|
|
83
|
-
const components = this.attributeComponents();
|
|
84
|
-
this.collectedProperties = this.buildCollectedProperties(components);
|
|
85
|
-
const threeMaterial = material.buildThreeMaterial(this.collectedProperties);
|
|
86
|
-
this.mesh = new FXInstancedParticle(this.collectedProperties, options.expectedCapacity ?? EMITTER_DEFAULT_EXPECTED_CAPACITY, options.capacityStep ?? EMITTER_DEFAULT_CAPACITY_STEP, threeMaterial, baseGeometry);
|
|
87
62
|
if (options.receiveShadow ?? EMITTER_DEFAULT_RECEIVE_SHADOW) {
|
|
88
63
|
this.mesh.receiveShadow = true;
|
|
89
64
|
}
|
|
@@ -92,7 +67,7 @@ export class FXEmitter extends Object3D {
|
|
|
92
67
|
// false on the mesh, so the shadow pass's frustum check is a no-op just like the color pass.
|
|
93
68
|
if (options.castShadow ?? EMITTER_DEFAULT_CAST_SHADOW) {
|
|
94
69
|
this.mesh.castShadow = true;
|
|
95
|
-
this.mesh.customDepthMaterial = material.buildThreeDepthMaterial(this.
|
|
70
|
+
this.mesh.customDepthMaterial = material.buildThreeDepthMaterial(this.driver.varyings);
|
|
96
71
|
}
|
|
97
72
|
this.sortCamera = options.sortCamera;
|
|
98
73
|
this.sortFraction = EMITTER_DEFAULT_SORT_FRACTION;
|
|
@@ -103,11 +78,20 @@ export class FXEmitter extends Object3D {
|
|
|
103
78
|
this.add(this.mesh);
|
|
104
79
|
this.world.registerObject(this);
|
|
105
80
|
}
|
|
81
|
+
/** Exact live count for a JS-driven emitter; for a GPU-driven one, there is no CPU-visible live
|
|
82
|
+
* count to report - `capacity` is the honest upper bound reported instead. */
|
|
106
83
|
get particleCount() {
|
|
107
|
-
return this.
|
|
84
|
+
return this.driver.particleCount;
|
|
108
85
|
}
|
|
86
|
+
/** The buffer's current allocated size - grows over time for a JS-driven emitter, fixed at
|
|
87
|
+
* construction for a GPU-driven one (no capacityStep growth path there). */
|
|
109
88
|
get particleCapacity() {
|
|
110
|
-
return this.
|
|
89
|
+
return this.driver.particleCapacity;
|
|
90
|
+
}
|
|
91
|
+
// The active driver's own mesh - a thin passthrough, not a second source of truth (this.driver.mesh
|
|
92
|
+
// is authoritative; nothing caches or duplicates it here).
|
|
93
|
+
get mesh() {
|
|
94
|
+
return this.driver.mesh;
|
|
111
95
|
}
|
|
112
96
|
/**
|
|
113
97
|
* Builds an emitter into `world` from a precompiled effect - the low-level building block behind
|
|
@@ -116,10 +100,34 @@ export class FXEmitter extends Object3D {
|
|
|
116
100
|
* runtime path is running whole projects through `FXEffect`.
|
|
117
101
|
*/
|
|
118
102
|
static fromArtifacts(render, behavior, options = {}, world = FXWorld.getDefault()) {
|
|
103
|
+
if (options.expectedCapacity !== undefined) {
|
|
104
|
+
// Sizes the Float32Array buffers - a fractional length is a RangeError. Doubles as the
|
|
105
|
+
// fixed GPU capacity when a GPU kernel is present (no capacityStep growth for that path).
|
|
106
|
+
assertValidPositiveInteger(options.expectedCapacity, "FXEmitter.constructor.options.expectedCapacity");
|
|
107
|
+
}
|
|
108
|
+
if (options.capacityStep !== undefined) {
|
|
109
|
+
assertValidPositiveInteger(options.capacityStep, "FXEmitter.constructor.options.capacityStep");
|
|
110
|
+
}
|
|
119
111
|
const material = new FXArtifactMaterial(render, options.textures);
|
|
120
|
-
const holder = new FXSimulationHolder(behavior);
|
|
121
112
|
const baseGeometry = resolveGeometrySource(render.geometry, options.geometries ?? {});
|
|
122
|
-
|
|
113
|
+
const capacity = options.expectedCapacity ?? EMITTER_DEFAULT_EXPECTED_CAPACITY;
|
|
114
|
+
// The one place this emitter's backend is chosen, ever (never switched mid-playback). A real
|
|
115
|
+
// driver-level failure (program link, buffer/VAO setup) despite "Try GPU simulation" having
|
|
116
|
+
// compiled and the live render tier already being "standard" falls back to the mandatory JS
|
|
117
|
+
// artifact in place, no reload loop, matching the editor's sceneCoordinator.ts own forced-
|
|
118
|
+
// WebGL1-creation-failure fallback shape on the render side.
|
|
119
|
+
let driver;
|
|
120
|
+
if (options.gpuKernel !== undefined && options.renderer !== undefined) {
|
|
121
|
+
try {
|
|
122
|
+
driver = new FXGPUEmitterDriver(options.gpuKernel, options.renderer, material, capacity, baseGeometry);
|
|
123
|
+
}
|
|
124
|
+
catch (error) {
|
|
125
|
+
console.warn("FXEmitter: GPU (transform-feedback) behavior setup failed at runtime; falling back " +
|
|
126
|
+
"to the JS behavior artifact for this emitter.", error);
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
driver ?? (driver = new FXJSEmitterDriver(behavior, material, capacity, options.capacityStep ?? EMITTER_DEFAULT_CAPACITY_STEP, baseGeometry));
|
|
130
|
+
return new FXEmitter(driver, material, options, world);
|
|
123
131
|
}
|
|
124
132
|
/**
|
|
125
133
|
* @internal Ticks this emitter one frame. Driven by {@link FXWorld.update}, which advances the
|
|
@@ -141,14 +149,13 @@ export class FXEmitter extends Object3D {
|
|
|
141
149
|
}
|
|
142
150
|
this.destroyed = true;
|
|
143
151
|
this.world.unregisterObject(this);
|
|
144
|
-
// Drop scheduled work
|
|
152
|
+
// Drop scheduled work so nothing references the driver's buffers after teardown.
|
|
145
153
|
this.pendingBursts.length = 0;
|
|
146
154
|
this.activePlays.length = 0;
|
|
147
|
-
|
|
148
|
-
//
|
|
149
|
-
// geometry and the mounted material.
|
|
155
|
+
// The material owns no per-emitter GPU resources; the driver teardown disposes the instanced
|
|
156
|
+
// geometry and the mounted material (plus, for a GPU-driven emitter, the compiled program+VAO).
|
|
150
157
|
this.material.destroy();
|
|
151
|
-
this.
|
|
158
|
+
this.driver.destroy();
|
|
152
159
|
this.removeFromParent();
|
|
153
160
|
}
|
|
154
161
|
/**
|
|
@@ -243,14 +250,20 @@ export class FXEmitter extends Object3D {
|
|
|
243
250
|
remaining -= deltaTime;
|
|
244
251
|
}
|
|
245
252
|
}
|
|
246
|
-
/**
|
|
253
|
+
/**
|
|
254
|
+
* Kills all live particles and cancels all active plays and pending bursts. For a GPU-driven
|
|
255
|
+
* emitter this is a known-narrower reset (see the mesh's own `drop()` doc comment): there is no
|
|
256
|
+
* CPU-visible per-particle state to instantly zero, so already-alive particles keep aging out
|
|
257
|
+
* naturally rather than vanishing on this call's own frame - only the spawn cursor and pending
|
|
258
|
+
* schedule reset immediately.
|
|
259
|
+
*/
|
|
247
260
|
reset() {
|
|
248
261
|
if (this.destroyed) {
|
|
249
262
|
return;
|
|
250
263
|
}
|
|
251
264
|
this.pendingBursts.length = 0;
|
|
252
265
|
this.activePlays.length = 0;
|
|
253
|
-
this.
|
|
266
|
+
this.driver.reset();
|
|
254
267
|
// A restarted effect should not report a teleport spike from wherever the emitter was last
|
|
255
268
|
// posed - drop the velocity/angular-velocity baseline along with the particles.
|
|
256
269
|
this.motionTracker.reset();
|
|
@@ -268,45 +281,42 @@ export class FXEmitter extends Object3D {
|
|
|
268
281
|
this.material.applyUniformValues(values.uniforms);
|
|
269
282
|
}
|
|
270
283
|
if (values.bindings !== undefined) {
|
|
271
|
-
this.
|
|
284
|
+
this.driver.applyBindingValues(values.bindings);
|
|
272
285
|
}
|
|
273
286
|
}
|
|
274
287
|
/**
|
|
275
|
-
*
|
|
276
|
-
*
|
|
288
|
+
* Swaps only this emitter's render half in place - a new shader, geometry primitive/mesh, or
|
|
289
|
+
* shadow flags - leaving the behavior driver, particle buffers, instance count, and playback
|
|
290
|
+
* schedule completely untouched (unlike a structural edit that reaches {@link fromArtifacts},
|
|
291
|
+
* this never resets a running effect). The editor calls this instead of rebuilding whenever a
|
|
292
|
+
* graph edit only changed the render half.
|
|
293
|
+
*
|
|
294
|
+
* @throws If the new render artifact's attribute reads disagree with this emitter's already-
|
|
295
|
+
* allocated buffers (a genuine layout change - see {@link FXEmitterDriver.applyRenderArtifact}).
|
|
296
|
+
* This emitter is left exactly as it was before the call in that case; the caller must fall back
|
|
297
|
+
* to a fresh {@link fromArtifacts} instead, since only a new driver can safely resize buffers.
|
|
277
298
|
*/
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
const existing = components.get(name);
|
|
282
|
-
if (existing !== undefined && existing !== count) {
|
|
283
|
-
throw new Error(`FXEmitter: attribute "${name}" is written and read with conflicting widths ` +
|
|
284
|
-
`(${existing.toString()} vs ${count.toString()}); the behavior attributeWrites and ` +
|
|
285
|
-
`the render attributeReads must agree`);
|
|
286
|
-
}
|
|
287
|
-
components.set(name, count);
|
|
288
|
-
};
|
|
289
|
-
for (const write of this.simulation?.attributeWrites ?? []) {
|
|
290
|
-
add(write.name, write.components);
|
|
299
|
+
applyRenderArtifact(render, options = {}) {
|
|
300
|
+
if (this.destroyed) {
|
|
301
|
+
return;
|
|
291
302
|
}
|
|
292
|
-
|
|
293
|
-
|
|
303
|
+
const material = new FXArtifactMaterial(render, options.textures);
|
|
304
|
+
const baseGeometry = resolveGeometrySource(render.geometry, options.geometries ?? {});
|
|
305
|
+
// Throws (leaving this emitter untouched) before anything below mutates it - see this
|
|
306
|
+
// method's own @throws doc.
|
|
307
|
+
this.driver.applyRenderArtifact(material, baseGeometry);
|
|
308
|
+
this.material = material;
|
|
309
|
+
this.mesh.receiveShadow = options.receiveShadow ?? EMITTER_DEFAULT_RECEIVE_SHADOW;
|
|
310
|
+
const previousDepthMaterial = this.mesh.customDepthMaterial;
|
|
311
|
+
if (options.castShadow ?? EMITTER_DEFAULT_CAST_SHADOW) {
|
|
312
|
+
this.mesh.castShadow = true;
|
|
313
|
+
this.mesh.customDepthMaterial = material.buildThreeDepthMaterial(this.driver.varyings);
|
|
294
314
|
}
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
const properties = {
|
|
299
|
-
[FX_CORE_POSITION]: CORE_POSITION_TYPE,
|
|
300
|
-
[FX_CORE_LIFECYCLE]: CORE_LIFECYCLE_TYPE,
|
|
301
|
-
};
|
|
302
|
-
for (const [name, count] of components) {
|
|
303
|
-
properties[`${ATTRIBUTE_BUFFER_PREFIX}${name}`] = {
|
|
304
|
-
glslTypeName: GLSL_TYPE_BY_COMPONENTS[count],
|
|
305
|
-
bufferSize: count,
|
|
306
|
-
instantiable: true,
|
|
307
|
-
};
|
|
315
|
+
else {
|
|
316
|
+
this.mesh.castShadow = false;
|
|
317
|
+
this.mesh.customDepthMaterial = undefined;
|
|
308
318
|
}
|
|
309
|
-
|
|
319
|
+
previousDepthMaterial?.dispose();
|
|
310
320
|
}
|
|
311
321
|
setSortFraction(value, context) {
|
|
312
322
|
assertValidNonNegativeNumber(value, context);
|
|
@@ -316,31 +326,10 @@ export class FXEmitter extends Object3D {
|
|
|
316
326
|
// Throws when the behavior artifact cannot seed births. Checked at the scheduling points, NOT in
|
|
317
327
|
// the frame tick - a throw from the world tick would take the host's render loop down.
|
|
318
328
|
assertCanSpawn(context) {
|
|
319
|
-
if (
|
|
329
|
+
if (!this.driver.canSpawn) {
|
|
320
330
|
throw new Error(`${context}: this behavior artifact cannot seed births (update-only)`);
|
|
321
331
|
}
|
|
322
332
|
}
|
|
323
|
-
// Maps each kernel state-buffer name to its backing Float32Array. Cached across ticks/bursts and
|
|
324
|
-
// rebuilt only when the mesh's buffer set changes (`bufferVersion` - capacity growth).
|
|
325
|
-
kernelBuffers() {
|
|
326
|
-
const version = this.mesh.bufferVersion;
|
|
327
|
-
if (this.kernelBuffersCache !== undefined && this.kernelBuffersCacheVersion === version) {
|
|
328
|
-
return this.kernelBuffersCache;
|
|
329
|
-
}
|
|
330
|
-
const buffers = {
|
|
331
|
-
[FX_CORE_POSITION]: this.mesh.propertyBuffers[FX_CORE_POSITION].array,
|
|
332
|
-
[FX_CORE_LIFECYCLE]: this.mesh.propertyBuffers[FX_CORE_LIFECYCLE].array,
|
|
333
|
-
};
|
|
334
|
-
for (const buffer of this.simulation?.attributeBuffers ?? []) {
|
|
335
|
-
const attribute = this.mesh.propertyBuffers[`${ATTRIBUTE_BUFFER_PREFIX}${buffer.name}`];
|
|
336
|
-
if (attribute !== undefined) {
|
|
337
|
-
buffers[buffer.name] = attribute.array;
|
|
338
|
-
}
|
|
339
|
-
}
|
|
340
|
-
this.kernelBuffersCache = buffers;
|
|
341
|
-
this.kernelBuffersCacheVersion = version;
|
|
342
|
-
return buffers;
|
|
343
|
-
}
|
|
344
333
|
// The emitter's world matrix as the reused snapshot the kernel reads. It is the mesh's
|
|
345
334
|
// `matrixWorld` (same matrix the shader and depth sort see), so a world-space graph stays
|
|
346
335
|
// consistent under any transform. `Matrix4.elements` is already column-major.
|
|
@@ -353,16 +342,6 @@ export class FXEmitter extends Object3D {
|
|
|
353
342
|
}
|
|
354
343
|
return transform;
|
|
355
344
|
}
|
|
356
|
-
markBuffersNeedUpdate(names) {
|
|
357
|
-
for (const name of names) {
|
|
358
|
-
const isCore = name === FX_CORE_POSITION || name === FX_CORE_LIFECYCLE;
|
|
359
|
-
const key = isCore ? name : `${ATTRIBUTE_BUFFER_PREFIX}${name}`;
|
|
360
|
-
const attribute = this.mesh.propertyBuffers[key];
|
|
361
|
-
if (attribute !== undefined) {
|
|
362
|
-
attribute.needsUpdate = true;
|
|
363
|
-
}
|
|
364
|
-
}
|
|
365
|
-
}
|
|
366
345
|
tick(deltaTime) {
|
|
367
346
|
// World-space velocity/angular velocity since the previous tick, computed once here and not
|
|
368
347
|
// inside worldTransform() - the burst/play loops below and the update phase can each call
|
|
@@ -390,16 +369,13 @@ export class FXEmitter extends Object3D {
|
|
|
390
369
|
this.emitterTransform.angularVelocity[0] = motion.angularVelocity[0];
|
|
391
370
|
this.emitterTransform.angularVelocity[1] = motion.angularVelocity[1];
|
|
392
371
|
this.emitterTransform.angularVelocity[2] = motion.angularVelocity[2];
|
|
393
|
-
//
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
for (let i = 0; i < instanceCount; i++) {
|
|
399
|
-
array[i * itemSize + FX_AGE] += deltaTime;
|
|
400
|
-
}
|
|
372
|
+
// Paused (dt == 0): skip the simulation step entirely - no JS age/kernel update, no GPU
|
|
373
|
+
// transform-feedback dispatch, no schedule advance - the emitter keeps rendering whatever
|
|
374
|
+
// particle state the last active tick left behind.
|
|
375
|
+
if (deltaTime <= 0) {
|
|
376
|
+
return;
|
|
401
377
|
}
|
|
402
|
-
this.
|
|
378
|
+
this.driver.beginTick(deltaTime);
|
|
403
379
|
for (let i = this.pendingBursts.length - 1; i >= 0; i--) {
|
|
404
380
|
const pending = this.pendingBursts[i];
|
|
405
381
|
pending.delay -= deltaTime;
|
|
@@ -438,27 +414,10 @@ export class FXEmitter extends Object3D {
|
|
|
438
414
|
this.activePlays.splice(i, 1);
|
|
439
415
|
}
|
|
440
416
|
}
|
|
441
|
-
|
|
442
|
-
return;
|
|
443
|
-
}
|
|
444
|
-
if (this.simulation !== undefined) {
|
|
445
|
-
// Motion integration lives in the graph (an `integrate-motion` node writes
|
|
446
|
-
// `position += velocity * dt`), not in this host loop.
|
|
447
|
-
this.simulation.update(this.kernelBuffers(), this.mesh.instanceCount, deltaTime, this.worldTransform());
|
|
448
|
-
this.markBuffersNeedUpdate(this.simulation.updateWrittenBuffers);
|
|
449
|
-
}
|
|
450
|
-
// Age (written above) must reach the GPU this frame; the update phase's buffers were flagged already.
|
|
451
|
-
this.mesh.propertyBuffers[FX_CORE_LIFECYCLE].needsUpdate = true;
|
|
417
|
+
this.driver.endTick(deltaTime, this.worldTransform());
|
|
452
418
|
}
|
|
453
419
|
spawnBurst(count) {
|
|
454
|
-
|
|
455
|
-
this.mesh.createInstances(count);
|
|
456
|
-
const instanceEnd = this.mesh.instanceCount;
|
|
457
|
-
// Re-checked (defensive): no spawn function would leave the freshly zeroed rows born dead.
|
|
458
|
-
if (this.simulation?.canSpawn === true) {
|
|
459
|
-
this.simulation.spawn(this.kernelBuffers(), instanceBegin, instanceEnd - instanceBegin, this.worldTransform());
|
|
460
|
-
this.markBuffersNeedUpdate(this.simulation.spawnWrittenBuffers);
|
|
461
|
-
}
|
|
420
|
+
this.driver.spawn(count, this.worldTransform());
|
|
462
421
|
}
|
|
463
422
|
}
|
|
464
423
|
// Returned by post-destroy `burst`/`play` - never matches a scheduled handler.
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import type { GLTypeInfo } from "../instancedParticle/glTypeInfo.js";
|
|
2
|
+
/** A named width declaration - shape shared by `FXAttributeDecl` (`components`) and a mapped
|
|
3
|
+
* `FXBufferLayout` (`stride`), the two concrete lists {@link mergeAttributeWidths} merges. */
|
|
4
|
+
interface FXNamedWidth {
|
|
5
|
+
readonly name: string;
|
|
6
|
+
readonly components: number;
|
|
7
|
+
}
|
|
8
|
+
/**
|
|
9
|
+
* Merges width-declaring attribute lists (a driver's own state buffers, the render artifact's
|
|
10
|
+
* `attributeReads`) into one name -> component-count map, throwing on a genuine cross-source
|
|
11
|
+
* disagreement rather than sizing a buffer wrong. Shared by both drivers' constructors so the
|
|
12
|
+
* validation itself never drifts between them, even though each driver's own input list does not.
|
|
13
|
+
*/
|
|
14
|
+
export declare function mergeAttributeWidths(...sources: readonly (readonly FXNamedWidth[])[]): ReadonlyMap<string, number>;
|
|
15
|
+
/** Builds a driver's full varyings map (the two core buffers plus one `fx_<name>` per attribute)
|
|
16
|
+
* from a merged width map - the input to both `FXArtifactMaterial.buildThreeMaterial` and either
|
|
17
|
+
* mesh class's constructor. */
|
|
18
|
+
export declare function varyingsFromComponents(components: ReadonlyMap<string, number>): Record<string, GLTypeInfo>;
|
|
19
|
+
/**
|
|
20
|
+
* Whether two varyings maps declare the exact same names at the exact same width - the guard
|
|
21
|
+
* `applyRenderArtifact` needs before reusing already-allocated buffers in place: a new render
|
|
22
|
+
* artifact whose attribute reads disagree with what is already allocated (a new name, or an
|
|
23
|
+
* existing name at a different width) cannot be applied without a full rebuild.
|
|
24
|
+
*/
|
|
25
|
+
export declare function sameVaryings(current: Record<string, GLTypeInfo>, next: Record<string, GLTypeInfo>): boolean;
|
|
26
|
+
export {};
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import { FX_CORE_LIFECYCLE, FX_CORE_LIFECYCLE_STRIDE, FX_CORE_POSITION, FX_CORE_POSITION_STRIDE, meshPropertyKeyFor, } from "../coreLayout.js";
|
|
2
|
+
const CORE_POSITION_TYPE = {
|
|
3
|
+
glslTypeName: "vec3",
|
|
4
|
+
bufferSize: FX_CORE_POSITION_STRIDE,
|
|
5
|
+
instantiable: true,
|
|
6
|
+
};
|
|
7
|
+
const CORE_LIFECYCLE_TYPE = {
|
|
8
|
+
glslTypeName: "vec3",
|
|
9
|
+
bufferSize: FX_CORE_LIFECYCLE_STRIDE,
|
|
10
|
+
instantiable: true,
|
|
11
|
+
};
|
|
12
|
+
// GLSL type of a float attribute, indexed by component count.
|
|
13
|
+
const GLSL_TYPE_BY_COMPONENTS = ["", "float", "vec2", "vec3", "vec4"];
|
|
14
|
+
/**
|
|
15
|
+
* Merges width-declaring attribute lists (a driver's own state buffers, the render artifact's
|
|
16
|
+
* `attributeReads`) into one name -> component-count map, throwing on a genuine cross-source
|
|
17
|
+
* disagreement rather than sizing a buffer wrong. Shared by both drivers' constructors so the
|
|
18
|
+
* validation itself never drifts between them, even though each driver's own input list does not.
|
|
19
|
+
*/
|
|
20
|
+
export function mergeAttributeWidths(...sources) {
|
|
21
|
+
const components = new Map();
|
|
22
|
+
for (const source of sources) {
|
|
23
|
+
for (const decl of source) {
|
|
24
|
+
const existing = components.get(decl.name);
|
|
25
|
+
if (existing !== undefined && existing !== decl.components) {
|
|
26
|
+
throw new Error(`FXEmitter: attribute "${decl.name}" is declared with conflicting widths ` +
|
|
27
|
+
`(${existing.toString()} vs ${decl.components.toString()}) across its sources`);
|
|
28
|
+
}
|
|
29
|
+
components.set(decl.name, decl.components);
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
return components;
|
|
33
|
+
}
|
|
34
|
+
/** Builds a driver's full varyings map (the two core buffers plus one `fx_<name>` per attribute)
|
|
35
|
+
* from a merged width map - the input to both `FXArtifactMaterial.buildThreeMaterial` and either
|
|
36
|
+
* mesh class's constructor. */
|
|
37
|
+
export function varyingsFromComponents(components) {
|
|
38
|
+
const varyings = {
|
|
39
|
+
[FX_CORE_POSITION]: CORE_POSITION_TYPE,
|
|
40
|
+
[FX_CORE_LIFECYCLE]: CORE_LIFECYCLE_TYPE,
|
|
41
|
+
};
|
|
42
|
+
for (const [name, count] of components) {
|
|
43
|
+
varyings[meshPropertyKeyFor(name)] = {
|
|
44
|
+
glslTypeName: GLSL_TYPE_BY_COMPONENTS[count],
|
|
45
|
+
bufferSize: count,
|
|
46
|
+
instantiable: true,
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
return varyings;
|
|
50
|
+
}
|
|
51
|
+
/**
|
|
52
|
+
* Whether two varyings maps declare the exact same names at the exact same width - the guard
|
|
53
|
+
* `applyRenderArtifact` needs before reusing already-allocated buffers in place: a new render
|
|
54
|
+
* artifact whose attribute reads disagree with what is already allocated (a new name, or an
|
|
55
|
+
* existing name at a different width) cannot be applied without a full rebuild.
|
|
56
|
+
*/
|
|
57
|
+
export function sameVaryings(current, next) {
|
|
58
|
+
const currentNames = Object.keys(current);
|
|
59
|
+
if (currentNames.length !== Object.keys(next).length) {
|
|
60
|
+
return false;
|
|
61
|
+
}
|
|
62
|
+
return currentNames.every((name) => {
|
|
63
|
+
if (!Object.prototype.hasOwnProperty.call(next, name)) {
|
|
64
|
+
return false;
|
|
65
|
+
}
|
|
66
|
+
const nextEntry = next[name];
|
|
67
|
+
return (nextEntry.glslTypeName === current[name].glslTypeName &&
|
|
68
|
+
nextEntry.bufferSize === current[name].bufferSize);
|
|
69
|
+
});
|
|
70
|
+
}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import type { BufferGeometry, Texture } from "three";
|
|
1
|
+
import type { BufferGeometry, Texture, WebGLRenderer } from "three";
|
|
2
|
+
import type { FXParticleKernelArtifact } from "../artifact/FXArtifact.js";
|
|
2
3
|
import type { FXEmitterOptions } from "./FXEmitter.Internal.js";
|
|
3
4
|
/**
|
|
4
5
|
* Options for {@link FXEmitter.fromArtifacts}: the emitter options plus external textures by slot -
|
|
@@ -10,4 +11,14 @@ import type { FXEmitterOptions } from "./FXEmitter.Internal.js";
|
|
|
10
11
|
export interface FXFromArtifactsOptions extends Partial<FXEmitterOptions> {
|
|
11
12
|
readonly textures?: Record<string, Texture>;
|
|
12
13
|
readonly geometries?: Record<string, BufferGeometry>;
|
|
14
|
+
/**
|
|
15
|
+
* The standard-tier (WebGL2, transform-feedback) behavior artifact, present only when the
|
|
16
|
+
* graph's spawn node had "Try GPU simulation" on and the graph compiled to GLSL. `renderer` is
|
|
17
|
+
* required alongside it - without one, or if GPU program setup itself throws (a real
|
|
18
|
+
* driver-level failure), the emitter falls back to the mandatory JS `behavior` artifact in
|
|
19
|
+
* place, never a hard error. `expectedCapacity` becomes this emitter's fixed GPU particle
|
|
20
|
+
* capacity (no growth); `capacityStep` is meaningless for a GPU-driven emitter and is ignored.
|
|
21
|
+
*/
|
|
22
|
+
readonly gpuKernel?: FXParticleKernelArtifact;
|
|
23
|
+
readonly renderer?: WebGLRenderer;
|
|
13
24
|
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
import { isCoreBufferName } from "../coreLayout.js";
|
|
2
|
+
import { FXTransformFeedbackParticle } from "../instancedParticle/FXTransformFeedbackParticle.js";
|
|
3
|
+
import { mergeAttributeWidths, sameVaryings, varyingsFromComponents, } from "./FXEmitterDriver.Internal.js";
|
|
4
|
+
import { FXTransformFeedbackSimulationHolder } from "./FXTransformFeedbackSimulationHolder.js";
|
|
5
|
+
/**
|
|
6
|
+
* @internal The GPU (WebGL2 transform-feedback) particle-simulation backend: a fixed-capacity
|
|
7
|
+
* `FXTransformFeedbackParticle` mesh driven by a precompiled `FXParticleKernelArtifact` through
|
|
8
|
+
* `FXTransformFeedbackSimulationHolder`. Self-sufficient from its own artifact - its varyings come
|
|
9
|
+
* from `gpu.kernel.buffers` (+ the render artifact's `attributeReads`), never from a JS behavior
|
|
10
|
+
* artifact's `attributeWrites`.
|
|
11
|
+
*
|
|
12
|
+
* No sorting or dead-particle compaction: every particle slot is always drawn at the fixed
|
|
13
|
+
* `capacity`, and `particleCount`/`particleCapacity` both report that capacity rather than an
|
|
14
|
+
* exact live count, which there is no cheap way to know.
|
|
15
|
+
*/
|
|
16
|
+
export class FXGPUEmitterDriver {
|
|
17
|
+
constructor(kernel, renderer, material, capacity, baseGeometry) {
|
|
18
|
+
// Cursor/overwrite bookkeeping: `gpuSpawnCursor` is the next write position (mod
|
|
19
|
+
// capacity); `gpuSpawnRangeStart`/`gpuPendingSpawnCount` accumulate every spawn() call since the
|
|
20
|
+
// last endTick() into one contiguous range, since the GPU can only run one transform-feedback pass
|
|
21
|
+
// per tick (unlike the JS backend, which can grow its array once per call with no such constraint) -
|
|
22
|
+
// "since the last endTick()", not "since the start of this tick": an immediate `burst()` between
|
|
23
|
+
// ticks must not lose its contribution (see endTick()'s own doc comment).
|
|
24
|
+
this.gpuSpawnCursor = 0;
|
|
25
|
+
this.gpuSpawnRangeStart = 0;
|
|
26
|
+
this.gpuPendingSpawnCount = 0;
|
|
27
|
+
// Depth sorting has no GPU-driven form - warned once, not every tick, if a host sets
|
|
28
|
+
// sortCamera on one anyway.
|
|
29
|
+
this.warnedAboutSortCamera = false;
|
|
30
|
+
const attributeBuffers = kernel.buffers.filter((buffer) => !isCoreBufferName(buffer.name));
|
|
31
|
+
this.behaviorAttributeWidths = attributeBuffers.map((buffer) => ({
|
|
32
|
+
name: buffer.name,
|
|
33
|
+
components: buffer.stride,
|
|
34
|
+
}));
|
|
35
|
+
const components = mergeAttributeWidths(this.behaviorAttributeWidths, material.attributeReads);
|
|
36
|
+
this.varyings = varyingsFromComponents(components);
|
|
37
|
+
const threeMaterial = material.buildThreeMaterial(this.varyings);
|
|
38
|
+
let mesh;
|
|
39
|
+
try {
|
|
40
|
+
mesh = new FXTransformFeedbackParticle(renderer.getContext(), this.varyings, capacity, threeMaterial, baseGeometry);
|
|
41
|
+
this.simulation = new FXTransformFeedbackSimulationHolder(renderer, kernel);
|
|
42
|
+
}
|
|
43
|
+
catch (error) {
|
|
44
|
+
// `mesh` can have already succeeded (real WebGL buffers/VAO-backing state allocated) when the
|
|
45
|
+
// simulation holder construction right after it is what actually throws - release those
|
|
46
|
+
// before re-throwing, or they leak for the GL context's lifetime. The caller (FXEmitter.
|
|
47
|
+
// fromArtifacts) decides what happens next - falling back to FXJSEmitterDriver.
|
|
48
|
+
mesh?.destroy();
|
|
49
|
+
throw error;
|
|
50
|
+
}
|
|
51
|
+
this.mesh = mesh;
|
|
52
|
+
}
|
|
53
|
+
get particleCount() {
|
|
54
|
+
return this.mesh.capacity;
|
|
55
|
+
}
|
|
56
|
+
get particleCapacity() {
|
|
57
|
+
return this.mesh.capacity;
|
|
58
|
+
}
|
|
59
|
+
// A fused transform-feedback program always has a spawn branch - assembleTransformFeedbackProgram
|
|
60
|
+
// (the editor's assembler) refuses to build one without it, so a constructed FXParticleKernelArtifact
|
|
61
|
+
// is this guarantee already checked, not something to re-derive here.
|
|
62
|
+
get canSpawn() {
|
|
63
|
+
return true;
|
|
64
|
+
}
|
|
65
|
+
spawn(count) {
|
|
66
|
+
// Cursor advances by `count`, wrapping at capacity - the buffer slots this claims may already
|
|
67
|
+
// be occupied by still-alive particles, which are simply overwritten (the scheme's cheapness is
|
|
68
|
+
// not checking occupancy at all). Accumulates into
|
|
69
|
+
// `gpuPendingSpawnCount` regardless of whether this call happens during a tick's own scheduling
|
|
70
|
+
// (a delayed burst/play firing) or entirely between ticks (an immediate `burst()` call from host
|
|
71
|
+
// code) - endTick() is the only place that range is ever consumed and reset (see its own doc
|
|
72
|
+
// comment for why beginTick() must NOT also reset it).
|
|
73
|
+
this.gpuSpawnCursor = (this.gpuSpawnCursor + count) % this.mesh.capacity;
|
|
74
|
+
this.gpuPendingSpawnCount += count;
|
|
75
|
+
}
|
|
76
|
+
beginTick() {
|
|
77
|
+
// No begin-of-tick bookkeeping needed: unlike the range/count fields (reset only once consumed,
|
|
78
|
+
// in endTick()), there is nothing here that must happen before this tick's scheduled bursts run.
|
|
79
|
+
}
|
|
80
|
+
endTick(deltaTime, transform) {
|
|
81
|
+
// Every particle slot is always processed - there is no CPU-visible live count to skip on;
|
|
82
|
+
// the render shader hides dead slots (FXArtifactMaterial.Internal.ts's
|
|
83
|
+
// projectFromModelPos degenerate-position injection).
|
|
84
|
+
this.simulation.tick(this.mesh, deltaTime, { start: this.gpuSpawnRangeStart, count: this.gpuPendingSpawnCount }, transform);
|
|
85
|
+
// Consumed - reset for the NEXT accumulation window, starting from wherever the cursor now sits.
|
|
86
|
+
// Must NOT happen in beginTick() instead: an immediate `burst()` call between two ticks (the
|
|
87
|
+
// ordinary way to spawn a one-shot effect, never touching pendingBursts/activePlays at all)
|
|
88
|
+
// already advances the cursor and this count the moment it is called - resetting at the START of
|
|
89
|
+
// the NEXT tick would silently discard that count before it was ever reported as a birth range,
|
|
90
|
+
// leaving those slots' data one full transform-feedback pass behind their real cursor claim.
|
|
91
|
+
this.gpuSpawnRangeStart = this.gpuSpawnCursor;
|
|
92
|
+
this.gpuPendingSpawnCount = 0;
|
|
93
|
+
}
|
|
94
|
+
sortByDistance() {
|
|
95
|
+
// No CPU-visible particle positions to sort, and no cheap way to sort GPU-resident data without
|
|
96
|
+
// a readback that would defeat the point of running on the GPU at all (an accepted loss, not a
|
|
97
|
+
// bug to work around). Never thrown - a host that sets sortCamera on a GPU
|
|
98
|
+
// emitter out of habit should not crash the world tick over it.
|
|
99
|
+
if (!this.warnedAboutSortCamera) {
|
|
100
|
+
this.warnedAboutSortCamera = true;
|
|
101
|
+
console.warn("FXEmitter: sortCamera has no effect on a GPU-driven (transform-feedback) emitter - " +
|
|
102
|
+
"back-to-front depth sorting is not supported for that backend.");
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
applyBindingValues(values) {
|
|
106
|
+
this.simulation.applyBindingValues(values);
|
|
107
|
+
}
|
|
108
|
+
reset() {
|
|
109
|
+
this.mesh.drop();
|
|
110
|
+
this.gpuSpawnCursor = 0;
|
|
111
|
+
// Must also clear any not-yet-consumed spawn accumulation (endTick() only resets it AFTER
|
|
112
|
+
// consuming it - see that method's own doc comment), or a burst() that ran earlier this same
|
|
113
|
+
// accumulation window would still spawn on the next tick despite reset() having just cancelled
|
|
114
|
+
// it - a stale range left over from before the cursor was zeroed here.
|
|
115
|
+
this.gpuSpawnRangeStart = 0;
|
|
116
|
+
this.gpuPendingSpawnCount = 0;
|
|
117
|
+
}
|
|
118
|
+
applyRenderArtifact(material, baseGeometry) {
|
|
119
|
+
// Recomputed exactly as the constructor does, against this driver's OWN kernel buffers (never
|
|
120
|
+
// a JS behavior artifact's attributeWrites) - a stale merge here would wrongly pass a real
|
|
121
|
+
// layout change.
|
|
122
|
+
const components = mergeAttributeWidths(this.behaviorAttributeWidths, material.attributeReads);
|
|
123
|
+
const nextVaryings = varyingsFromComponents(components);
|
|
124
|
+
if (!sameVaryings(this.varyings, nextVaryings)) {
|
|
125
|
+
throw new Error("FXGPUEmitterDriver.applyRenderArtifact: the new render artifact's attribute reads no " +
|
|
126
|
+
"longer match this emitter's allocated buffers - a full rebuild (fromArtifacts) is required");
|
|
127
|
+
}
|
|
128
|
+
// this.varyings is value-equal to nextVaryings (just proved by sameVaryings above) - built
|
|
129
|
+
// from nextVaryings so a future reader never has to re-derive that equivalence to trust this.
|
|
130
|
+
const threeMaterial = material.buildThreeMaterial(nextVaryings);
|
|
131
|
+
this.mesh.replaceMaterialAndBaseGeometry(threeMaterial, baseGeometry);
|
|
132
|
+
}
|
|
133
|
+
destroy() {
|
|
134
|
+
this.mesh.destroy();
|
|
135
|
+
this.simulation.destroy();
|
|
136
|
+
}
|
|
137
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|