sparcoon 0.9.1 → 0.9.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.
Files changed (36) hide show
  1. package/README.md +176 -76
  2. package/dist/artifact/FXArtifact.d.ts +51 -3
  3. package/dist/behaviorTransformFeedbackLayout.d.ts +24 -0
  4. package/dist/behaviorTransformFeedbackLayout.js +24 -0
  5. package/dist/coreLayout.d.ts +9 -5
  6. package/dist/coreLayout.js +17 -4
  7. package/dist/editor/index.d.ts +1 -0
  8. package/dist/editor/index.js +1 -1
  9. package/dist/effect/FXEffect.d.ts +4 -0
  10. package/dist/effect/FXEffect.js +47 -14
  11. package/dist/effect/FXEffectSpec.d.ts +21 -4
  12. package/dist/emitter/FXApplyRenderArtifactOptions.d.ts +15 -0
  13. package/dist/emitter/FXApplyRenderArtifactOptions.js +1 -0
  14. package/dist/emitter/FXEmitter.d.ts +44 -14
  15. package/dist/emitter/FXEmitter.js +132 -156
  16. package/dist/emitter/FXEmitterDriver.Internal.d.ts +26 -0
  17. package/dist/emitter/FXEmitterDriver.Internal.js +70 -0
  18. package/dist/emitter/FXFromArtifactsOptions.d.ts +12 -1
  19. package/dist/emitter/FXGPUEmitterDriver.Internal.d.ts +1 -0
  20. package/dist/emitter/FXGPUEmitterDriver.Internal.js +137 -0
  21. package/dist/emitter/FXJSEmitterDriver.Internal.d.ts +1 -0
  22. package/dist/emitter/FXJSEmitterDriver.Internal.js +133 -0
  23. package/dist/emitter/FXSimulationHolder.js +7 -5
  24. package/dist/emitter/FXTransformFeedbackSimulationHolder.d.ts +1 -0
  25. package/dist/emitter/FXTransformFeedbackSimulationHolder.js +249 -0
  26. package/dist/index.d.ts +4 -3
  27. package/dist/index.js +3 -2
  28. package/dist/instancedParticle/FXInstancedParticle.js +28 -1
  29. package/dist/instancedParticle/FXTransformFeedbackParticle.d.ts +1 -0
  30. package/dist/instancedParticle/FXTransformFeedbackParticle.js +169 -0
  31. package/dist/miscellaneous/fxMath.d.ts +18 -11
  32. package/dist/miscellaneous/fxMath.js +89 -209
  33. package/dist/render/FXArtifactMaterial.Internal.js +66 -40
  34. package/dist/render/FXArtifactMaterial.js +1 -1
  35. package/dist/render/FXMesh.js +2 -4
  36. package/package.json +11 -3
@@ -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 capacity).
19
+ // Spawns inside a tick (a delayed burst expiring, a play's rate accumulator) all share that
20
+ // tick's pose and parameters, so `gpuSpawnRangeStart`/`gpuPendingSpawnCount` gather them into
21
+ // one contiguous range the tick's own pass seeds. A spawn outside a tick - an immediate
22
+ // `burst()` from host code or from an effect's timeline - is seeded right away by a pass of its
23
+ // own instead (see spawn()), so the range is always empty between ticks.
24
+ this.gpuSpawnCursor = 0;
25
+ this.gpuSpawnRangeStart = 0;
26
+ this.gpuPendingSpawnCount = 0;
27
+ // Between beginTick() and endTick(): spawns join the tick's pass instead of running their own.
28
+ this.ticking = false;
29
+ // Depth sorting has no GPU-driven form - warned once, not every tick, if a host sets
30
+ // sortCamera on one anyway.
31
+ this.warnedAboutSortCamera = false;
32
+ const attributeBuffers = kernel.buffers.filter((buffer) => !isCoreBufferName(buffer.name));
33
+ this.behaviorAttributeWidths = attributeBuffers.map((buffer) => ({
34
+ name: buffer.name,
35
+ components: buffer.stride,
36
+ }));
37
+ const components = mergeAttributeWidths(this.behaviorAttributeWidths, material.attributeReads);
38
+ this.varyings = varyingsFromComponents(components);
39
+ const threeMaterial = material.buildThreeMaterial(this.varyings);
40
+ let mesh;
41
+ try {
42
+ mesh = new FXTransformFeedbackParticle(renderer.getContext(), this.varyings, capacity, threeMaterial, baseGeometry);
43
+ this.simulation = new FXTransformFeedbackSimulationHolder(renderer, kernel);
44
+ }
45
+ catch (error) {
46
+ // `mesh` can have already succeeded (real WebGL buffers/VAO-backing state allocated) when the
47
+ // simulation holder construction right after it is what actually throws - release those
48
+ // before re-throwing, or they leak for the GL context's lifetime. The caller (FXEmitter.
49
+ // fromArtifacts) decides what happens next - falling back to FXJSEmitterDriver.
50
+ mesh?.destroy();
51
+ throw error;
52
+ }
53
+ this.mesh = mesh;
54
+ }
55
+ get particleCount() {
56
+ return this.mesh.capacity;
57
+ }
58
+ get particleCapacity() {
59
+ return this.mesh.capacity;
60
+ }
61
+ // A fused transform-feedback program always has a spawn branch - assembleTransformFeedbackProgram
62
+ // (the editor's assembler) refuses to build one without it, so a constructed FXParticleKernelArtifact
63
+ // is this guarantee already checked, not something to re-derive here.
64
+ get canSpawn() {
65
+ return true;
66
+ }
67
+ spawn(count, transform) {
68
+ // Cursor advances by `count`, wrapping at capacity - the buffer slots this claims may already
69
+ // be occupied by still-alive particles, which are simply overwritten (the scheme's cheapness is
70
+ // not checking occupancy at all).
71
+ const start = this.gpuSpawnCursor;
72
+ this.gpuSpawnCursor = (this.gpuSpawnCursor + count) % this.mesh.capacity;
73
+ if (this.ticking) {
74
+ this.gpuPendingSpawnCount += count;
75
+ return;
76
+ }
77
+ // Outside a tick, the emitter may be moved or re-parametrized before the next one (two
78
+ // bursts from two poses in one frame is an ordinary way to author an effect), so this spawn
79
+ // is seeded now, from the pose and bindings of this call. More than `capacity` at once
80
+ // overwrites the whole ring; the extra births would only overwrite each other.
81
+ this.simulation.spawn(this.mesh, { start, count: Math.min(count, this.mesh.capacity) }, transform);
82
+ this.gpuSpawnRangeStart = this.gpuSpawnCursor;
83
+ }
84
+ beginTick() {
85
+ this.ticking = true;
86
+ }
87
+ endTick(deltaTime, transform) {
88
+ this.ticking = false;
89
+ // Every particle slot is always processed - there is no CPU-visible live count to skip on;
90
+ // the render shader hides dead slots (FXArtifactMaterial.Internal.ts's
91
+ // projectFromModelPos degenerate-position injection).
92
+ this.simulation.tick(this.mesh, deltaTime, { start: this.gpuSpawnRangeStart, count: this.gpuPendingSpawnCount }, transform);
93
+ // Consumed - the next tick's range starts wherever the cursor now sits.
94
+ this.gpuSpawnRangeStart = this.gpuSpawnCursor;
95
+ this.gpuPendingSpawnCount = 0;
96
+ }
97
+ sortByDistance() {
98
+ // No CPU-visible particle positions to sort, and no cheap way to sort GPU-resident data without
99
+ // a readback that would defeat the point of running on the GPU at all (an accepted loss, not a
100
+ // bug to work around). Never thrown - a host that sets sortCamera on a GPU
101
+ // emitter out of habit should not crash the world tick over it.
102
+ if (!this.warnedAboutSortCamera) {
103
+ this.warnedAboutSortCamera = true;
104
+ console.warn("FXEmitter: sortCamera has no effect on a GPU-driven (transform-feedback) emitter - " +
105
+ "back-to-front depth sorting is not supported for that backend.");
106
+ }
107
+ }
108
+ applyBindingValues(values) {
109
+ this.simulation.applyBindingValues(values);
110
+ }
111
+ reset() {
112
+ this.mesh.drop();
113
+ this.gpuSpawnCursor = 0;
114
+ // Also drops a range gathered but not yet seeded, so nothing cancelled here spawns later.
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 {};
@@ -0,0 +1,133 @@
1
+ import { FX_AGE, FX_CORE_LIFECYCLE, FX_CORE_POSITION, FX_ID, meshPropertyKeyFor, } from "../coreLayout.js";
2
+ import { FXInstancedParticle } from "../instancedParticle/FXInstancedParticle.js";
3
+ import { mergeAttributeWidths, sameVaryings, varyingsFromComponents, } from "./FXEmitterDriver.Internal.js";
4
+ import { FXSimulationHolder } from "./FXSimulationHolder.js";
5
+ /**
6
+ * @internal The CPU (JS) particle-simulation backend: a dense, compacting `FXInstancedParticle`
7
+ * mesh driven by a precompiled `FXBehaviorArtifact` through `FXSimulationHolder`. Self-sufficient
8
+ * from its own artifact - its varyings come from `behavior.attributeWrites` (+ the render
9
+ * artifact's `attributeReads`), never from a GPU kernel artifact.
10
+ */
11
+ export class FXJSEmitterDriver {
12
+ constructor(behavior, material, capacity, capacityStep, baseGeometry) {
13
+ this.kernelBuffersCacheVersion = -1;
14
+ // Host-owned, monotonic per-emitter counter (like age, never touched by the compiled kernel/
15
+ // graph) - persists across reset() the same way FXTransformFeedbackSimulationHolder's
16
+ // randSeedCounter does, only restarting when this driver itself is rebuilt from artifacts.
17
+ this.nextParticleId = 0;
18
+ this.simulation = new FXSimulationHolder(behavior);
19
+ const components = mergeAttributeWidths(this.simulation.attributeWrites, material.attributeReads);
20
+ this.varyings = varyingsFromComponents(components);
21
+ const threeMaterial = material.buildThreeMaterial(this.varyings);
22
+ this.mesh = new FXInstancedParticle(this.varyings, capacity, capacityStep, threeMaterial, baseGeometry);
23
+ }
24
+ get particleCount() {
25
+ return this.mesh.instanceCount;
26
+ }
27
+ get particleCapacity() {
28
+ return this.mesh.instanceCapacity;
29
+ }
30
+ get canSpawn() {
31
+ return this.simulation.canSpawn;
32
+ }
33
+ spawn(count, transform) {
34
+ const instanceBegin = this.mesh.instanceCount;
35
+ this.mesh.createInstances(count);
36
+ const instanceEnd = this.mesh.instanceCount;
37
+ // Host-owned, like age: no write slot exists for id (the editor's FXParticleBehaviorTarget.ts
38
+ // scalarWriteSlots never lists it), so no graph can ever collide with this assignment.
39
+ const lifecycle = this.mesh.propertyBuffers[FX_CORE_LIFECYCLE];
40
+ const { array: lifecycleArray, itemSize: lifecycleItemSize } = lifecycle;
41
+ for (let i = instanceBegin; i < instanceEnd; i++) {
42
+ lifecycleArray[i * lifecycleItemSize + FX_ID] = this.nextParticleId++;
43
+ }
44
+ // Re-checked (defensive): no spawn function would leave the freshly zeroed rows born dead.
45
+ if (this.simulation.canSpawn) {
46
+ this.simulation.spawn(this.kernelBuffers(), instanceBegin, instanceEnd - instanceBegin, transform);
47
+ this.markBuffersNeedUpdate(this.simulation.spawnWrittenBuffers);
48
+ }
49
+ }
50
+ beginTick(deltaTime) {
51
+ // Age increment + cull. Age is owned by this fixed loop, not the graph - same for every effect.
52
+ const lifecycle = this.mesh.propertyBuffers[FX_CORE_LIFECYCLE];
53
+ const { array, itemSize } = lifecycle;
54
+ const instanceCount = this.mesh.instanceCount;
55
+ for (let i = 0; i < instanceCount; i++) {
56
+ array[i * itemSize + FX_AGE] += deltaTime;
57
+ }
58
+ this.mesh.removeDeadParticles();
59
+ }
60
+ endTick(deltaTime, transform) {
61
+ if (this.mesh.instanceCount === 0) {
62
+ return;
63
+ }
64
+ // Motion integration lives in the graph (an `integrate-motion` node writes
65
+ // `position += velocity * dt`), not in this host loop.
66
+ this.simulation.update(this.kernelBuffers(), this.mesh.instanceCount, deltaTime, transform);
67
+ this.markBuffersNeedUpdate(this.simulation.updateWrittenBuffers);
68
+ // Age (written in beginTick) must reach the GPU this frame; the update phase's buffers were
69
+ // flagged already.
70
+ this.mesh.propertyBuffers[FX_CORE_LIFECYCLE].needsUpdate = true;
71
+ }
72
+ sortByDistance(cameraWorldPosition) {
73
+ if (this.mesh.instanceCount === 0) {
74
+ return;
75
+ }
76
+ // Sort in world space to match the shader's `p_cameraDistance`, correct under any transform.
77
+ this.mesh.updateWorldMatrix(true, false);
78
+ this.mesh.sortByDistance(cameraWorldPosition, this.mesh.matrixWorld);
79
+ }
80
+ applyBindingValues(values) {
81
+ this.simulation.applyBindingValues(values);
82
+ }
83
+ reset() {
84
+ this.mesh.drop();
85
+ }
86
+ applyRenderArtifact(material, baseGeometry) {
87
+ // Recomputed exactly as the constructor does, against this driver's OWN behavior writes
88
+ // (never the other backend's) - a stale merge here would wrongly pass a real layout change.
89
+ const components = mergeAttributeWidths(this.simulation.attributeWrites, material.attributeReads);
90
+ const nextVaryings = varyingsFromComponents(components);
91
+ if (!sameVaryings(this.varyings, nextVaryings)) {
92
+ throw new Error("FXJSEmitterDriver.applyRenderArtifact: the new render artifact's attribute reads no " +
93
+ "longer match this emitter's allocated buffers - a full rebuild (fromArtifacts) is required");
94
+ }
95
+ // this.varyings is value-equal to nextVaryings (just proved by sameVaryings above) - built
96
+ // from nextVaryings so a future reader never has to re-derive that equivalence to trust this.
97
+ const threeMaterial = material.buildThreeMaterial(nextVaryings);
98
+ this.mesh.replaceMaterialAndBaseGeometry(threeMaterial, baseGeometry);
99
+ }
100
+ destroy() {
101
+ this.kernelBuffersCache = undefined;
102
+ this.mesh.destroy();
103
+ }
104
+ // Maps each kernel state-buffer name to its backing Float32Array. Cached across ticks/bursts and
105
+ // rebuilt only when the mesh's buffer set changes (`bufferVersion` - capacity growth).
106
+ kernelBuffers() {
107
+ const version = this.mesh.bufferVersion;
108
+ if (this.kernelBuffersCache !== undefined && this.kernelBuffersCacheVersion === version) {
109
+ return this.kernelBuffersCache;
110
+ }
111
+ const buffers = {
112
+ [FX_CORE_POSITION]: this.mesh.propertyBuffers[FX_CORE_POSITION].array,
113
+ [FX_CORE_LIFECYCLE]: this.mesh.propertyBuffers[FX_CORE_LIFECYCLE].array,
114
+ };
115
+ for (const buffer of this.simulation.attributeBuffers) {
116
+ const attribute = this.mesh.propertyBuffers[meshPropertyKeyFor(buffer.name)];
117
+ if (attribute !== undefined) {
118
+ buffers[buffer.name] = attribute.array;
119
+ }
120
+ }
121
+ this.kernelBuffersCache = buffers;
122
+ this.kernelBuffersCacheVersion = version;
123
+ return buffers;
124
+ }
125
+ markBuffersNeedUpdate(names) {
126
+ for (const name of names) {
127
+ const attribute = this.mesh.propertyBuffers[meshPropertyKeyFor(name)];
128
+ if (attribute !== undefined) {
129
+ attribute.needsUpdate = true;
130
+ }
131
+ }
132
+ }
133
+ }
@@ -1,7 +1,7 @@
1
- import { FX_CORE_LIFECYCLE, FX_CORE_POSITION } from "../coreLayout.js";
2
- const CORE_BUFFER_NAMES = new Set([FX_CORE_POSITION, FX_CORE_LIFECYCLE]);
1
+ import { isCoreBufferName } from "../coreLayout.js";
3
2
  /**
4
- * @internal The runtime's behavior driver: holds a precompiled {@link FXBehaviorArtifact} and just
3
+ * @internal The runtime's JS (CPU) behavior driver - the GPU sibling is
4
+ * `FXTransformFeedbackSimulationHolder`. Holds a precompiled {@link FXBehaviorArtifact} and just
5
5
  * calls its `spawn`/`update` each tick (no graph, compiler, or `new Function`). The artifact's
6
6
  * `bindings` record is passed through on every call, so a value scrub is read next tick.
7
7
  */
@@ -11,7 +11,7 @@ export class FXSimulationHolder {
11
11
  }
12
12
  /** Declared buffers minus the fixed core `position`/`lifecycle` - one per user attribute. */
13
13
  get attributeBuffers() {
14
- return this.artifact.buffers.filter((buffer) => !CORE_BUFFER_NAMES.has(buffer.name));
14
+ return this.artifact.buffers.filter((buffer) => !isCoreBufferName(buffer.name));
15
15
  }
16
16
  get attributeWrites() {
17
17
  return this.artifact.attributeWrites;
@@ -32,7 +32,9 @@ export class FXSimulationHolder {
32
32
  update(buffers, count, deltaTime, emitter) {
33
33
  this.artifact.update(buffers, count, deltaTime, this.artifact.bindings, emitter);
34
34
  }
35
- /** Behavior half of {@link FXEmitter.applyValues}: mutate each named slot in place; unknown name = no-op. */
35
+ /** Behavior half of {@link FXEmitter.applyValues}: mutate each named slot in place; unknown name
36
+ * = no-op. Mirrored by `FXTransformFeedbackSimulationHolder.applyBindingValues` for the GPU
37
+ * tier - `FXEmitter.applyValues` scrubs both, since either backend may be the active one. */
36
38
  applyBindingValues(values) {
37
39
  for (const name in values) {
38
40
  if (name in this.artifact.bindings) {
@@ -0,0 +1,249 @@
1
+ import { meshPropertyKeyFor } from "../coreLayout.js";
2
+ import { FX_TRANSFORM_FEEDBACK_CAPACITY_UNIFORM, FX_TRANSFORM_FEEDBACK_DELTA_TIME_UNIFORM, FX_TRANSFORM_FEEDBACK_MODEL_MATRIX_UNIFORM, FX_TRANSFORM_FEEDBACK_OBJECT_ANGULAR_VELOCITY_UNIFORM, FX_TRANSFORM_FEEDBACK_OBJECT_VELOCITY_UNIFORM, FX_TRANSFORM_FEEDBACK_RAND_SEED_UNIFORM, FX_TRANSFORM_FEEDBACK_SPAWN_ID_BASE_UNIFORM, FX_TRANSFORM_FEEDBACK_SPAWN_RANGE_COUNT_UNIFORM, FX_TRANSFORM_FEEDBACK_SPAWN_RANGE_START_UNIFORM, } from "../behaviorTransformFeedbackLayout.js";
3
+ /**
4
+ * @internal The GPU sibling of `FXSimulationHolder`: holds a precompiled {@link FXParticleKernelArtifact}
5
+ * (one fused GLSL program covering both spawn and update) and drives it via raw WebGL2 transform
6
+ * feedback each tick. Bypasses three.js's `Material`/
7
+ * `WebGLProgram` machinery entirely - three has no transform-feedback support at all - the same
8
+ * way the render side never touches it either.
9
+ */
10
+ export class FXTransformFeedbackSimulationHolder {
11
+ constructor(renderer, artifact) {
12
+ this.renderer = renderer;
13
+ this.artifact = artifact;
14
+ this.uniformLocations = {};
15
+ // A fresh, monotonic seed every tick - decorrelates `rand()` draws across ticks (and, crucially,
16
+ // across a buffer slot's successive rebirths under the cursor/overwrite scheme - see the `rand`
17
+ // registry entry's own doc comment in the editor's core/ir/FXFunctions.Internal.ts for why this
18
+ // matters). Wraparound is harmless: it only ever feeds a hash's bit-mixing, never arithmetic
19
+ // that needs exact precision.
20
+ this.randSeedCounter = 0;
21
+ // Running total of particles ever spawned by this emitter - the base a newborn's id offsets
22
+ // from this tick's relative spawn index. Never reset (no reset() exists on this class, only
23
+ // destroy()): a stop/replay must not reissue ids already handed out, same reasoning as
24
+ // randSeedCounter above.
25
+ this.spawnIdBase = 0;
26
+ // Every emitter driven by this class already required the live render tier to be "standard"
27
+ // (a real WebGL2 context) before this class is ever constructed (FXEmitter.fromArtifacts's
28
+ // driver-selection rule) - the cast reflects an already-established precondition, not a new
29
+ // capability check.
30
+ const gl = this.renderer.getContext();
31
+ this.gl = gl;
32
+ // `artifact.buffers[].name` is the raw, unprefixed kernel-compiled name (e.g. "velocity") -
33
+ // `mesh.readBuffer`/`writeBuffer`/`swapBuffers` key by the mesh's own property-buffer key
34
+ // instead (`fx_velocity`, matching `FXGPUEmitterDriver`'s own `varyings`), so every name must go
35
+ // through the same mapping the JS-driven path also uses (`meshPropertyKeyFor`) before reaching
36
+ // the mesh - the two core buffers pass through unchanged either way, which is why this only
37
+ // actually mattered for attribute-buffer emitters, not the position/lifetime-only fixtures this
38
+ // class's own tests use.
39
+ this.bufferNames = artifact.buffers.map((buffer) => meshPropertyKeyFor(buffer.name));
40
+ this.strides = artifact.buffers.map((buffer) => buffer.stride);
41
+ const vertexShader = compileShader(gl, gl.VERTEX_SHADER, artifact.vertexSource);
42
+ const fragmentShader = compileShader(gl, gl.FRAGMENT_SHADER, artifact.fragmentSource);
43
+ const program = gl.createProgram();
44
+ gl.attachShader(program, vertexShader);
45
+ gl.attachShader(program, fragmentShader);
46
+ // Must be called before linking (WebGL2 spec) - SEPARATE_ATTRIBS matches this class's one
47
+ // dedicated WebGLBuffer per state buffer (never one interleaved buffer for all of them).
48
+ gl.transformFeedbackVaryings(program, [...artifact.transformFeedbackVaryings], gl.SEPARATE_ATTRIBS);
49
+ gl.linkProgram(program);
50
+ // Shaders are safe to delete once linked - GL keeps them alive by refcount until the program
51
+ // itself is deleted, regardless of link success.
52
+ gl.deleteShader(vertexShader);
53
+ gl.deleteShader(fragmentShader);
54
+ if (gl.getProgramParameter(program, gl.LINK_STATUS) !== true) {
55
+ const info = gl.getProgramInfoLog(program);
56
+ gl.deleteProgram(program);
57
+ throw new Error(`FXTransformFeedbackSimulationHolder: program failed to link: ${info ?? ""}`);
58
+ }
59
+ this.program = program;
60
+ for (const name of FIXED_UNIFORM_NAMES) {
61
+ this.uniformLocations[name] = gl.getUniformLocation(program, name);
62
+ }
63
+ for (const name of Object.keys(artifact.bindings)) {
64
+ this.uniformLocations[name] = gl.getUniformLocation(program, name);
65
+ }
66
+ // A dedicated VAO for this simulation pass only - never one three.js's own WebGLBindingStates
67
+ // manages, so raw attribute setup here can never corrupt a mesh's cached vertex state. This
68
+ // is exactly the raw gl.bindVertexArray() call whose effect on three's VAO cache `tick()`'s
69
+ // closing `resetState()` call exists to correct (see that call site's own doc comment).
70
+ this.vao = gl.createVertexArray();
71
+ }
72
+ /**
73
+ * Runs one transform-feedback pass: reads `mesh`'s current buffers, writes the fused spawn+
74
+ * update program's result into the other half of each ping-pong pair, then swaps. `spawnRange`
75
+ * is this tick's birth range under the buffer's cursor/overwrite scheme - `count === 0` is a
76
+ * valid, ordinary "no births this tick" case, not skipped specially.
77
+ */
78
+ tick(mesh, deltaTime, spawnRange, emitter) {
79
+ const gl = this.gl;
80
+ this.randSeedCounter += 1;
81
+ this.bindProgramAndInputs(mesh);
82
+ this.bufferNames.forEach((name, index) => {
83
+ gl.bindBufferBase(gl.TRANSFORM_FEEDBACK_BUFFER, index, mesh.writeBuffer(name));
84
+ });
85
+ this.setUniforms(deltaTime, spawnRange, emitter, mesh.capacity);
86
+ gl.enable(gl.RASTERIZER_DISCARD);
87
+ gl.beginTransformFeedback(gl.POINTS);
88
+ gl.drawArrays(gl.POINTS, 0, mesh.capacity);
89
+ gl.endTransformFeedback();
90
+ gl.disable(gl.RASTERIZER_DISCARD);
91
+ this.releaseFeedbackBuffers();
92
+ for (const name of this.bufferNames) {
93
+ mesh.swapBuffers(name);
94
+ }
95
+ // Consumed - advance past every id this tick's spawn range just claimed.
96
+ this.spawnIdBase += spawnRange.count;
97
+ // Our raw gl.bindVertexArray() call above is invisible to three's own VAO-identity cache
98
+ // (WebGLBindingStates has no scoped, public way to invalidate just this mesh's entry) -
99
+ // resetState() is the only public lever, so it runs every tick. Per-emitter for now, not
100
+ // batched across every GPU-driven emitter into one call per frame - a real, deferred
101
+ // optimization, not required for correctness.
102
+ this.renderer.resetState();
103
+ }
104
+ /**
105
+ * Seeds the slots of `spawnRange` right now, from `emitter` and the current binding values, and
106
+ * touches no other slot: the spawn half of {@link tick}, run on its own. Lets every `burst()`
107
+ * outside the emitter's tick spawn from the pose and parameters of the moment it was called,
108
+ * instead of sharing one pass - and so one pose - with everything else spawned that frame.
109
+ *
110
+ * Transform feedback cannot write the buffer it reads, so the seeded range is drawn into the
111
+ * write half of each pair (`drawArrays(first, count)`: `gl_VertexID` is the slot index, which
112
+ * makes every drawn vertex a birth under the kernel's own range test) and then copied into the
113
+ * read half - the side the renderer draws and the next {@link tick} reads. No swap: every slot
114
+ * outside the range keeps its state untouched. A range that wraps past the end of the ring is
115
+ * drawn as two segments. `spawnRange.count` must not exceed the mesh's capacity.
116
+ */
117
+ spawn(mesh, spawnRange, emitter) {
118
+ if (spawnRange.count <= 0) {
119
+ return;
120
+ }
121
+ const gl = this.gl;
122
+ this.randSeedCounter += 1;
123
+ const capacity = mesh.capacity;
124
+ const head = Math.min(spawnRange.count, capacity - spawnRange.start);
125
+ const segments = head < spawnRange.count
126
+ ? [
127
+ [spawnRange.start, head],
128
+ [0, spawnRange.count - head],
129
+ ]
130
+ : [[spawnRange.start, spawnRange.count]];
131
+ this.bindProgramAndInputs(mesh);
132
+ // dt 0: the spawn branch has no use for it, and nothing else runs in this pass.
133
+ this.setUniforms(0, spawnRange, emitter, capacity);
134
+ gl.enable(gl.RASTERIZER_DISCARD);
135
+ for (const [first, count] of segments) {
136
+ this.bufferNames.forEach((name, index) => {
137
+ const bytesPerSlot = this.strides[index] * Float32Array.BYTES_PER_ELEMENT;
138
+ gl.bindBufferRange(gl.TRANSFORM_FEEDBACK_BUFFER, index, mesh.writeBuffer(name), first * bytesPerSlot, count * bytesPerSlot);
139
+ });
140
+ gl.beginTransformFeedback(gl.POINTS);
141
+ gl.drawArrays(gl.POINTS, first, count);
142
+ gl.endTransformFeedback();
143
+ }
144
+ gl.disable(gl.RASTERIZER_DISCARD);
145
+ // Released before the copy: a buffer still bound for transform feedback cannot be a copy
146
+ // source.
147
+ this.releaseFeedbackBuffers();
148
+ this.bufferNames.forEach((name, index) => {
149
+ const bytesPerSlot = this.strides[index] * Float32Array.BYTES_PER_ELEMENT;
150
+ gl.bindBuffer(gl.COPY_READ_BUFFER, mesh.writeBuffer(name));
151
+ gl.bindBuffer(gl.COPY_WRITE_BUFFER, mesh.readBuffer(name));
152
+ for (const [first, count] of segments) {
153
+ gl.copyBufferSubData(gl.COPY_READ_BUFFER, gl.COPY_WRITE_BUFFER, first * bytesPerSlot, first * bytesPerSlot, count * bytesPerSlot);
154
+ }
155
+ });
156
+ gl.bindBuffer(gl.COPY_READ_BUFFER, null);
157
+ gl.bindBuffer(gl.COPY_WRITE_BUFFER, null);
158
+ this.spawnIdBase += spawnRange.count;
159
+ // Same reason as in tick(): the raw VAO bind is invisible to three's caches.
160
+ this.renderer.resetState();
161
+ }
162
+ destroy() {
163
+ this.gl.deleteProgram(this.program);
164
+ this.gl.deleteVertexArray(this.vao);
165
+ }
166
+ /** GPU-tier mirror of `FXSimulationHolder.applyBindingValues`: mutate each named uniform's live
167
+ * value in place; unknown name = no-op. `FXEmitter.applyValues` calls both mirrors, since
168
+ * either backend may be the active one. */
169
+ applyBindingValues(values) {
170
+ for (const name in values) {
171
+ if (name in this.artifact.bindings) {
172
+ this.artifact.bindings[name].value = values[name];
173
+ }
174
+ }
175
+ }
176
+ /** Binds the program and wires every state buffer's read half as its vertex input. */
177
+ bindProgramAndInputs(mesh) {
178
+ const gl = this.gl;
179
+ // Routed through the renderer's own cache-aware setter (not raw gl.useProgram()) so three's
180
+ // `WebGLState.currentProgram` cache stays truthful - its own next draw then correctly detects
181
+ // the mismatch and re-binds, with no reset needed for this half of the state-hygiene problem.
182
+ // The other half (the VAO-identity cache) has no such setter - see the raw
183
+ // gl.bindVertexArray() call below and the resetState() that ends every pass.
184
+ this.renderer.state.useProgram(this.program);
185
+ gl.bindVertexArray(this.vao);
186
+ this.bufferNames.forEach((name, index) => {
187
+ gl.bindBuffer(gl.ARRAY_BUFFER, mesh.readBuffer(name));
188
+ gl.vertexAttribPointer(index, this.strides[index], gl.FLOAT, false, 0, 0);
189
+ gl.enableVertexAttribArray(index);
190
+ });
191
+ }
192
+ /**
193
+ * Unbinds every transform-feedback binding point. A bound base stays "active" for its point
194
+ * otherwise; released rather than left as a stale reference between passes.
195
+ */
196
+ releaseFeedbackBuffers() {
197
+ for (let index = 0; index < this.bufferNames.length; index += 1) {
198
+ this.gl.bindBufferBase(this.gl.TRANSFORM_FEEDBACK_BUFFER, index, null);
199
+ }
200
+ }
201
+ setUniforms(deltaTime, spawnRange, emitter, capacity) {
202
+ const gl = this.gl;
203
+ gl.uniform1i(this.uniformLocations[FX_TRANSFORM_FEEDBACK_RAND_SEED_UNIFORM], this.randSeedCounter);
204
+ gl.uniform1i(this.uniformLocations[FX_TRANSFORM_FEEDBACK_SPAWN_RANGE_START_UNIFORM], spawnRange.start);
205
+ gl.uniform1i(this.uniformLocations[FX_TRANSFORM_FEEDBACK_SPAWN_RANGE_COUNT_UNIFORM], spawnRange.count);
206
+ gl.uniform1i(this.uniformLocations[FX_TRANSFORM_FEEDBACK_SPAWN_ID_BASE_UNIFORM], this.spawnIdBase);
207
+ gl.uniform1i(this.uniformLocations[FX_TRANSFORM_FEEDBACK_CAPACITY_UNIFORM], capacity);
208
+ gl.uniform1f(this.uniformLocations[FX_TRANSFORM_FEEDBACK_DELTA_TIME_UNIFORM], deltaTime);
209
+ gl.uniformMatrix4fv(this.uniformLocations[FX_TRANSFORM_FEEDBACK_MODEL_MATRIX_UNIFORM], false, [
210
+ ...emitter.worldMatrix,
211
+ ]);
212
+ gl.uniform3fv(this.uniformLocations[FX_TRANSFORM_FEEDBACK_OBJECT_VELOCITY_UNIFORM], [
213
+ ...emitter.velocity,
214
+ ]);
215
+ gl.uniform3fv(this.uniformLocations[FX_TRANSFORM_FEEDBACK_OBJECT_ANGULAR_VELOCITY_UNIFORM], [
216
+ ...emitter.angularVelocity,
217
+ ]);
218
+ for (const [name, slot] of Object.entries(this.artifact.bindings)) {
219
+ // Float32Array-valued (sampler2D/LUT) bindings are reserved for a future node - see
220
+ // FXParticleKernelArtifact.bindings's own doc comment - never produced by any node today.
221
+ gl.uniform1f(this.uniformLocations[name], slot.value);
222
+ }
223
+ }
224
+ }
225
+ const FIXED_UNIFORM_NAMES = [
226
+ FX_TRANSFORM_FEEDBACK_RAND_SEED_UNIFORM,
227
+ FX_TRANSFORM_FEEDBACK_SPAWN_RANGE_START_UNIFORM,
228
+ FX_TRANSFORM_FEEDBACK_SPAWN_RANGE_COUNT_UNIFORM,
229
+ FX_TRANSFORM_FEEDBACK_SPAWN_ID_BASE_UNIFORM,
230
+ FX_TRANSFORM_FEEDBACK_CAPACITY_UNIFORM,
231
+ FX_TRANSFORM_FEEDBACK_DELTA_TIME_UNIFORM,
232
+ FX_TRANSFORM_FEEDBACK_MODEL_MATRIX_UNIFORM,
233
+ FX_TRANSFORM_FEEDBACK_OBJECT_VELOCITY_UNIFORM,
234
+ FX_TRANSFORM_FEEDBACK_OBJECT_ANGULAR_VELOCITY_UNIFORM,
235
+ ];
236
+ function compileShader(gl, type, source) {
237
+ const shader = gl.createShader(type);
238
+ if (shader === null) {
239
+ throw new Error("FXTransformFeedbackSimulationHolder: gl.createShader() failed");
240
+ }
241
+ gl.shaderSource(shader, source);
242
+ gl.compileShader(shader);
243
+ if (gl.getShaderParameter(shader, gl.COMPILE_STATUS) !== true) {
244
+ const info = gl.getShaderInfoLog(shader);
245
+ gl.deleteShader(shader);
246
+ throw new Error(`FXTransformFeedbackSimulationHolder: shader failed to compile: ${info ?? ""}`);
247
+ }
248
+ return shader;
249
+ }
package/dist/index.d.ts CHANGED
@@ -1,11 +1,12 @@
1
- export type { FXAttributeDecl, FXBehaviorArtifact, FXBufferLayout, FXGeometrySource, FXKernelBuffers, FXRenderArtifact, FXShaderStageSource, FXUniformInit, FXValueSlot, } from "./artifact/FXArtifact.js";
2
- export { FX_AGE, FX_CORE_LIFECYCLE, FX_CORE_LIFECYCLE_STRIDE, FX_CORE_LIFECYCLE_VARYING, FX_CORE_PARTICLE_DEFINES, FX_CORE_POSITION, FX_CORE_POSITION_STRIDE, FX_CORE_POSITION_VARYING, FX_LIFETIME, FX_POSITION_X, FX_POSITION_Y, FX_POSITION_Z, } from "./coreLayout.js";
1
+ export type { FXAttributeDecl, FXBehaviorArtifact, FXBufferLayout, FXGeometrySource, FXKernelBuffers, FXParticleKernelArtifact, FXRenderArtifact, FXShaderStageSource, FXUniformInit, FXValueSlot, } from "./artifact/FXArtifact.js";
2
+ export { FX_AGE, FX_CORE_LIFECYCLE, FX_CORE_LIFECYCLE_STRIDE, FX_CORE_LIFECYCLE_VARYING, FX_CORE_PARTICLE_DEFINES, FX_CORE_POSITION, FX_CORE_POSITION_STRIDE, FX_CORE_POSITION_VARYING, FX_ID, FX_LIFETIME, FX_POSITION_X, FX_POSITION_Y, FX_POSITION_Z, } from "./coreLayout.js";
3
+ export { FX_TRANSFORM_FEEDBACK_CAPACITY_UNIFORM, FX_TRANSFORM_FEEDBACK_DELTA_TIME_UNIFORM, FX_TRANSFORM_FEEDBACK_MODEL_MATRIX_UNIFORM, FX_TRANSFORM_FEEDBACK_OBJECT_ANGULAR_VELOCITY_UNIFORM, FX_TRANSFORM_FEEDBACK_OBJECT_VELOCITY_UNIFORM, FX_TRANSFORM_FEEDBACK_RAND_SEED_UNIFORM, FX_TRANSFORM_FEEDBACK_SPAWN_ID_BASE_UNIFORM, FX_TRANSFORM_FEEDBACK_SPAWN_RANGE_COUNT_UNIFORM, FX_TRANSFORM_FEEDBACK_SPAWN_RANGE_START_UNIFORM, } from "./behaviorTransformFeedbackLayout.js";
3
4
  export type { FXGraphLambertMaterialOptions, FXGraphUnlitMaterialOptions, FXRenderMode, } from "./render/FXMaterialOptions.js";
4
5
  export type { FXGeometryPrimitive } from "./artifact/FXArtifact.js";
5
6
  export { FXEffect } from "./effect/FXEffect.js";
6
7
  export type { FXEffectOptions, FXEffectSpec } from "./effect/FXEffectSpec.js";
7
8
  export type { FXEmitter } from "./emitter/FXEmitter.js";
8
9
  export { FXWorld } from "./world/FXWorld.js";
9
- export { fxFbm, fxFract, fxHash, fxMix, fxMod, fxSampleLut, fxSmoothstep, fxSnoise2, fxSnoise3, fxValueNoise, } from "./miscellaneous/fxMath.js";
10
+ export { fxFbm, fxFbm3, fxFract, fxMix, fxMod, fxNoise1, fxNoise2, fxNoise3, fxSampleLut, fxSmoothstep, } from "./miscellaneous/fxMath.js";
10
11
  export { fxDataTexture } from "./miscellaneous/texture/fxDataTexture.js";
11
12
  export type { FXDataTextureOptions } from "./miscellaneous/texture/fxDataTexture.js";
package/dist/index.js CHANGED
@@ -2,12 +2,13 @@
2
2
  // FXWorld, and is driven by a single FXWorld.update per frame - plus the runtime-owned types the
3
3
  // editor imports (artifact contracts and the core layout). No graph, compiler, validator, or live
4
4
  // protocol lives here.
5
- export { FX_AGE, FX_CORE_LIFECYCLE, FX_CORE_LIFECYCLE_STRIDE, FX_CORE_LIFECYCLE_VARYING, FX_CORE_PARTICLE_DEFINES, FX_CORE_POSITION, FX_CORE_POSITION_STRIDE, FX_CORE_POSITION_VARYING, FX_LIFETIME, FX_POSITION_X, FX_POSITION_Y, FX_POSITION_Z, } from "./coreLayout.js";
5
+ export { FX_AGE, FX_CORE_LIFECYCLE, FX_CORE_LIFECYCLE_STRIDE, FX_CORE_LIFECYCLE_VARYING, FX_CORE_PARTICLE_DEFINES, FX_CORE_POSITION, FX_CORE_POSITION_STRIDE, FX_CORE_POSITION_VARYING, FX_ID, FX_LIFETIME, FX_POSITION_X, FX_POSITION_Y, FX_POSITION_Z, } from "./coreLayout.js";
6
+ export { FX_TRANSFORM_FEEDBACK_CAPACITY_UNIFORM, FX_TRANSFORM_FEEDBACK_DELTA_TIME_UNIFORM, FX_TRANSFORM_FEEDBACK_MODEL_MATRIX_UNIFORM, FX_TRANSFORM_FEEDBACK_OBJECT_ANGULAR_VELOCITY_UNIFORM, FX_TRANSFORM_FEEDBACK_OBJECT_VELOCITY_UNIFORM, FX_TRANSFORM_FEEDBACK_RAND_SEED_UNIFORM, FX_TRANSFORM_FEEDBACK_SPAWN_ID_BASE_UNIFORM, FX_TRANSFORM_FEEDBACK_SPAWN_RANGE_COUNT_UNIFORM, FX_TRANSFORM_FEEDBACK_SPAWN_RANGE_START_UNIFORM, } from "./behaviorTransformFeedbackLayout.js";
6
7
  // Whole-project playback: the generic runtime behind every editor-emitted project module (base
7
8
  // class + scene specification). An emitted module supplies only its `FXEffectSpec` data and a thin subclass.
8
9
  export { FXEffect } from "./effect/FXEffect.js";
9
10
  // The tick domain: an effect joins one on construction; a single FXWorld.update per frame drives
10
11
  // every effect's timeline and the shared particle pool.
11
12
  export { FXWorld } from "./world/FXWorld.js";
12
- export { fxFbm, fxFract, fxHash, fxMix, fxMod, fxSampleLut, fxSmoothstep, fxSnoise2, fxSnoise3, fxValueNoise, } from "./miscellaneous/fxMath.js";
13
+ export { fxFbm, fxFbm3, fxFract, fxMix, fxMod, fxNoise1, fxNoise2, fxNoise3, fxSampleLut, fxSmoothstep, } from "./miscellaneous/fxMath.js";
13
14
  export { fxDataTexture } from "./miscellaneous/texture/fxDataTexture.js";
@@ -68,7 +68,7 @@ export class FXInstancedParticle extends Mesh {
68
68
  this.instancedGeometry.instanceCount += count;
69
69
  }
70
70
  removeDeadParticles() {
71
- // Cull against core `lifecycle` (vec2 [age, lifetime]); alive while `age < lifetime`. Rides the
71
+ // Cull against core `lifecycle` (vec3 [age, lifetime, id]); alive while `age < lifetime`. Rides the
72
72
  // same copy-down as every other property buffer.
73
73
  const lifecycleBuffer = this.propertyBuffers[FX_CORE_LIFECYCLE];
74
74
  if (lifecycleBuffer === undefined) {
@@ -183,6 +183,33 @@ export class FXInstancedParticle extends Mesh {
183
183
  drop() {
184
184
  this.instancedGeometry.instanceCount = 0;
185
185
  }
186
+ /**
187
+ * Swaps the mounted material and base geometry in place for a render-only structural edit -
188
+ * every per-particle buffer in {@link propertyBuffers} and the current instance count are left
189
+ * completely untouched, so live particles never reset. Never called when the varyings shape
190
+ * itself changed (the driver's own `applyRenderArtifact` guards that) - only the constructor
191
+ * path handles a real buffer-layout change.
192
+ */
193
+ replaceMaterialAndBaseGeometry(material, baseGeometry) {
194
+ // Detach the previous base attributes first, same as destroy()'s dance: they were borrowed by
195
+ // reference from a caller-owned geometry (possibly shared with other live consumers), so they
196
+ // must never be disposed here.
197
+ this.instancedGeometry.deleteAttribute("position");
198
+ this.instancedGeometry.deleteAttribute("uv");
199
+ this.instancedGeometry.deleteAttribute("normal");
200
+ this.instancedGeometry.setIndex(null);
201
+ const baseIndex = baseGeometry.getIndex();
202
+ if (baseIndex !== null) {
203
+ this.instancedGeometry.setIndex(baseIndex);
204
+ }
205
+ this.instancedGeometry.setAttribute("position", baseGeometry.getAttribute("position"));
206
+ this.instancedGeometry.setAttribute("uv", baseGeometry.getAttribute("uv"));
207
+ this.instancedGeometry.setAttribute("normal", baseGeometry.getAttribute("normal"));
208
+ // The mounted material IS disposable here (unlike the base geometry attributes above) - it was
209
+ // built exclusively for this mesh, never shared.
210
+ this.material?.dispose();
211
+ this.material = material;
212
+ }
186
213
  // Disposes the instanced geometry and the currently mounted material - not the constructor one,
187
214
  // which a live material hot-swap may have replaced and disposed.
188
215
  destroy() {