@field/cascade 0.4.0 → 0.5.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/AGENTS.md +18 -0
- package/ARCHITECTURE.md +53 -7
- package/CHANGELOG.md +68 -0
- package/CONTRIBUTING.md +33 -0
- package/DESIGN.md +9 -6
- package/README.md +80 -4
- package/dist/assets/{CodeEditor-BNOJIDzL.js → CodeEditor-BHej8kM9.js} +7 -7
- package/dist/assets/{CodePanel-2wp2G0Fj.js → CodePanel-CT3eirK8.js} +2 -2
- package/dist/assets/{CustomNodeDialog-DNqaVBqA.js → CustomNodeDialog-BG0m7_Rv.js} +1 -1
- package/dist/assets/{ProjectSettings-DcdYF2E1.js → ProjectSettings-BxoLWJG6.js} +2 -2
- package/dist/assets/{SettingsDialog-D2xVvyiR.js → SettingsDialog-r9PzZ22z.js} +1 -1
- package/dist/assets/{VersionHistoryDialog-DW288ykF.js → VersionHistoryDialog-B6zUV6Dy.js} +1 -1
- package/dist/assets/{core-B1y38i95.js → core-wwx5881P.js} +2 -2
- package/dist/assets/{cssMode-BLucnaln.js → cssMode-DY5T2Or0.js} +1 -1
- package/dist/assets/definitions-BgCvRHyR.js +1 -0
- package/dist/assets/{editor.api-B3Et3Pph.js → editor.api-mkORHEMm.js} +1 -1
- package/dist/assets/{freemarker2-DVlPoano.js → freemarker2-CIWlk0sw.js} +1 -1
- package/dist/assets/geo-CIxgs0oE.js +1 -0
- package/dist/assets/{handlebars-B5FZBAMR.js → handlebars-t4jbj3Br.js} +1 -1
- package/dist/assets/{html-FodD8KbR.js → html-Ctx7UOlY.js} +1 -1
- package/dist/assets/{htmlMode-CsG57VCD.js → htmlMode-BKNZxVw7.js} +1 -1
- package/dist/assets/{image-B3F-nq25.js → image-BCBFz0gD.js} +3 -3
- package/dist/assets/index-CeqTfMbr.js +212 -0
- package/dist/assets/{index-Y2ock1cw.css → index-ilgbOu6G.css} +1 -1
- package/dist/assets/{javascript-B3gonF_H.js → javascript-Da_TJBzH.js} +1 -1
- package/dist/assets/{jsonMode-Y3w1ahZg.js → jsonMode-Ftt5d1nV.js} +1 -1
- package/dist/assets/{liquid-CTpWXIYO.js → liquid-D5kWYG57.js} +1 -1
- package/dist/assets/{lspLanguageFeatures-CODBdKEZ.js → lspLanguageFeatures-B-93LC_L.js} +1 -1
- package/dist/assets/{mdx-CDAQJZIz.js → mdx-DGT3C_qs.js} +1 -1
- package/dist/assets/nodeTypeUtils-BDJsc2Ks.js +1 -0
- package/dist/assets/pop-BJGN5Ac4.js +1 -0
- package/dist/assets/{python-LOIlxJU-.js → python-Bz3Eew5S.js} +1 -1
- package/dist/assets/{razor-CllPQSHo.js → razor-CFDdKpKI.js} +1 -1
- package/dist/assets/{tsMode-CO28ut8t.js → tsMode-BaCINWBI.js} +1 -1
- package/dist/assets/{typescript-ruU9wKQ_.js → typescript-BoOWspVs.js} +1 -1
- package/dist/assets/{workers-Bc1Ywp4-.js → workers-dSWyDxsQ.js} +1 -1
- package/dist/assets/{xml-DoXP2Dh1.js → xml-eFi1_vYt.js} +1 -1
- package/dist/assets/{yaml-D9YPyGiJ.js → yaml-a7xrJHxm.js} +1 -1
- package/dist/cli/index.js +16266 -12653
- package/dist/cli/runtime/io.ts +31 -25
- package/dist/contracts/definition.d.ts +21 -2
- package/dist/contracts/index.d.ts +2 -0
- package/dist/contracts/index.js +2 -0
- package/dist/contracts/particles.d.ts +54 -0
- package/dist/contracts/particles.js +167 -0
- package/dist/contracts/scene.d.ts +129 -0
- package/dist/contracts/scene.js +73 -0
- package/dist/contracts/schema.js +1 -1
- package/dist/contracts/validate.js +4 -2
- package/dist/contracts/values.d.ts +3 -2
- package/dist/contracts/values.js +9 -0
- package/dist/index.html +3 -3
- package/dist/io.d.ts +4 -16
- package/dist/io.js +26 -8
- package/dist/player/app.js +810 -0
- package/dist/player/embed.d.ts +4 -0
- package/dist/player/embed.js +77 -0
- package/dist/player/types.d.ts +35 -0
- package/dist/runtime/builtins/core/index.d.ts +3 -2
- package/dist/runtime/builtins/core/index.js +6 -2
- package/dist/runtime/builtins/core/structural.d.ts +168 -0
- package/dist/runtime/builtins/core/structural.js +175 -0
- package/dist/runtime/builtins/core/time.d.ts +165 -0
- package/dist/runtime/builtins/core/time.js +110 -0
- package/dist/runtime/builtins/geo/definitions.d.ts +247 -0
- package/dist/runtime/builtins/geo/definitions.js +70 -0
- package/dist/runtime/builtins/geo/index.d.ts +2 -1
- package/dist/runtime/builtins/geo/index.js +3 -1
- package/dist/runtime/builtins/geo/render.d.ts +87 -0
- package/dist/runtime/builtins/geo/render.js +87 -0
- package/dist/runtime/builtins/geo/svgExport.d.ts +11 -0
- package/dist/runtime/builtins/geo/svgExport.js +10 -0
- package/dist/runtime/builtins/index.d.ts +5 -2
- package/dist/runtime/builtins/index.js +13 -3
- package/dist/runtime/builtins/pop/definitions.d.ts +2120 -0
- package/dist/runtime/builtins/pop/definitions.js +815 -0
- package/dist/runtime/builtins/pop/index.d.ts +2 -0
- package/dist/runtime/builtins/pop/index.js +2 -0
- package/dist/runtime/builtins/scene/definitions.d.ts +356 -0
- package/dist/runtime/builtins/scene/definitions.js +224 -0
- package/dist/runtime/builtins/scene/index.d.ts +2 -0
- package/dist/runtime/builtins/scene/index.js +2 -0
- package/dist/runtime/geometry/render.d.ts +109 -0
- package/dist/runtime/geometry/render.js +145 -0
- package/dist/runtime/geometry/svg.d.ts +29 -0
- package/dist/runtime/geometry/svg.js +11 -5
- package/dist/runtime/index.d.ts +1 -0
- package/dist/runtime/index.js +1 -0
- package/dist/runtime/pop/cache.d.ts +27 -0
- package/dist/runtime/pop/cache.js +123 -0
- package/dist/runtime/pop/forces.d.ts +202 -0
- package/dist/runtime/pop/forces.js +519 -0
- package/dist/runtime/pop/index.d.ts +5 -0
- package/dist/runtime/pop/index.js +5 -0
- package/dist/runtime/pop/state.d.ts +136 -0
- package/dist/runtime/pop/state.js +283 -0
- package/dist/runtime/pop/trail.d.ts +62 -0
- package/dist/runtime/pop/trail.js +142 -0
- package/dist/runtime/runtime.js +221 -3
- package/dist/runtime/scene/index.d.ts +50 -0
- package/dist/runtime/scene/index.js +94 -0
- package/dist/studio/panel.d.ts +68 -1
- package/doc/HEADLESS_GPU.md +4 -4
- package/doc/NODE_REFERENCE.md +372 -1
- package/doc/PROJECT_AUTHORING.md +16 -4
- package/doc/WEB_PLAYER.md +131 -0
- package/package.json +16 -2
- package/dist/assets/geo-DhyFdBXX.js +0 -1
- package/dist/assets/index-CC6YQw2n.js +0 -212
- package/dist/assets/nodeTypeUtils-BBA1JqHx.js +0 -1
|
@@ -0,0 +1,815 @@
|
|
|
1
|
+
import { checkParticleContract, } from "@field/cascade/contracts";
|
|
2
|
+
import { ParticleState, accumulateForce, clearForce, forceAttribute, checkpointAtOrBefore, offerCheckpoint, attract, buildTrails, flowField, drag, emptyParticleGeometry, gravity, noiseField, separation, step, trailFrame, } from "../../pop/index.js";
|
|
3
|
+
/**
|
|
4
|
+
* `cascade.pop.*` - the minimum useful particle set.
|
|
5
|
+
*
|
|
6
|
+
* Step 2 of `PLAN particles.md`, and the step that carries the risk: a source,
|
|
7
|
+
* a solver, and forces, on **re-simulation only**. There is no cache. The plan
|
|
8
|
+
* argues for that order - re-simulation from the start frame is the definition
|
|
9
|
+
* of correctness, and a cache is an accelerator holding nothing re-simulation
|
|
10
|
+
* would not have produced, so it is built second and can be cleared at any
|
|
11
|
+
* time without changing a result.
|
|
12
|
+
*
|
|
13
|
+
* The arithmetic lives in `packages/runtime/src/pop`, deliberately: these
|
|
14
|
+
* nodes are thin, the risk is in the stepping, and the stepping is testable
|
|
15
|
+
* without a graph.
|
|
16
|
+
*/
|
|
17
|
+
const sourceDefinition = {
|
|
18
|
+
apiVersion: 1,
|
|
19
|
+
label: "POP Source",
|
|
20
|
+
description: "Birth particles from a geometry's points.",
|
|
21
|
+
icon: "Sparkles",
|
|
22
|
+
runsOn: "portable",
|
|
23
|
+
inputs: {
|
|
24
|
+
geometry: { kind: "data", type: "geometry" },
|
|
25
|
+
particles: { kind: "data", type: "geometry" },
|
|
26
|
+
},
|
|
27
|
+
outputs: { geometry: { kind: "data", type: "geometry" } },
|
|
28
|
+
props: {
|
|
29
|
+
impulse: { type: "int", default: 8, min: 0, max: 100000 },
|
|
30
|
+
life: { type: "float", default: 3, min: 0, max: 3600, step: 0.1 },
|
|
31
|
+
lifevar: { type: "float", default: 0.3, min: 0, max: 1, step: 0.01 },
|
|
32
|
+
velocity: { type: "vec2", default: [0, 0] },
|
|
33
|
+
seed: { type: "int", default: 7, min: 0, max: 999999 },
|
|
34
|
+
},
|
|
35
|
+
};
|
|
36
|
+
/** HSV in, RGB out. Written here rather than imported because a colour
|
|
37
|
+
* conversion is four lines and an import across package boundaries for four
|
|
38
|
+
* lines is worse. */
|
|
39
|
+
function hsvToRgb(h, s, v) {
|
|
40
|
+
const turn = ((h % 1) + 1) % 1;
|
|
41
|
+
const sector = turn * 6;
|
|
42
|
+
const c = v * s;
|
|
43
|
+
const x = c * (1 - Math.abs((sector % 2) - 1));
|
|
44
|
+
const m = v - c;
|
|
45
|
+
const rgb = sector < 1 ? [c, x, 0] :
|
|
46
|
+
sector < 2 ? [x, c, 0] :
|
|
47
|
+
sector < 3 ? [0, c, x] :
|
|
48
|
+
sector < 4 ? [0, x, c] :
|
|
49
|
+
sector < 5 ? [x, 0, c] : [c, 0, x];
|
|
50
|
+
return [rgb[0] + m, rgb[1] + m, rgb[2] + m];
|
|
51
|
+
}
|
|
52
|
+
/** A hash rather than a stream, so a birth is a pure function of `(seed, id)`
|
|
53
|
+
* and re-simulating reproduces the same particles. */
|
|
54
|
+
function birthRandom(seed, sample) {
|
|
55
|
+
const n = Math.sin(seed * 12.9898 + sample * 78.233) * 43758.5453;
|
|
56
|
+
return n - Math.floor(n);
|
|
57
|
+
}
|
|
58
|
+
export function executeSource(context) {
|
|
59
|
+
const props = context.props;
|
|
60
|
+
const incoming = context.inputs.particles;
|
|
61
|
+
const state = incoming
|
|
62
|
+
? ParticleState.fromGeometry(incoming)
|
|
63
|
+
: ParticleState.fromGeometry(emptyParticleGeometry(2));
|
|
64
|
+
const from = context.inputs.geometry;
|
|
65
|
+
const available = from ? from.pointCount : 0;
|
|
66
|
+
const born = available === 0 ? 0 : Math.min(props.impulse, available);
|
|
67
|
+
if (born === 0) {
|
|
68
|
+
context.outputs.geometry.set(state.toGeometry());
|
|
69
|
+
return;
|
|
70
|
+
}
|
|
71
|
+
const size = state.size;
|
|
72
|
+
const position = new Float64Array(born * size);
|
|
73
|
+
const velocity = new Float32Array(born * size);
|
|
74
|
+
const life = new Float32Array(born);
|
|
75
|
+
const source = from.point.P;
|
|
76
|
+
for (let index = 0; index < born; index += 1) {
|
|
77
|
+
// Spread across the source's points rather than taking the first N, so a
|
|
78
|
+
// small impulse from a large source still samples the whole shape.
|
|
79
|
+
const pick = available === born ? index : Math.floor((index / born) * available);
|
|
80
|
+
for (let component = 0; component < size; component += 1) {
|
|
81
|
+
position[index * size + component] =
|
|
82
|
+
Number(source.data[pick * source.size + component] ?? 0);
|
|
83
|
+
velocity[index * size + component] = props.velocity[component] ?? 0;
|
|
84
|
+
}
|
|
85
|
+
// Keyed on the id this particle is about to get, so the same particle gets
|
|
86
|
+
// the same life however many times the frame is re-simulated.
|
|
87
|
+
const jitter = birthRandom(props.seed, state.nextId + index);
|
|
88
|
+
life[index] = props.life * (1 - props.lifevar * jitter);
|
|
89
|
+
}
|
|
90
|
+
context.outputs.geometry.set(state.born({ position, velocity, life }).toGeometry());
|
|
91
|
+
}
|
|
92
|
+
const solverDefinition = {
|
|
93
|
+
apiVersion: 1,
|
|
94
|
+
label: "POP Solver",
|
|
95
|
+
description: "Advance a particle system by one timestep.",
|
|
96
|
+
icon: "Play",
|
|
97
|
+
runsOn: "portable",
|
|
98
|
+
inputs: { particles: { kind: "data", type: "geometry" } },
|
|
99
|
+
outputs: { geometry: { kind: "data", type: "geometry" } },
|
|
100
|
+
props: {
|
|
101
|
+
/**
|
|
102
|
+
* Where the timestep comes from. Marcus's ruling, 2026-09-09: *"Let the
|
|
103
|
+
* user choose nodes fixed or variable."*
|
|
104
|
+
*
|
|
105
|
+
* `fixed` is the default on the ordinary grounds that the safer of two
|
|
106
|
+
* behaviours is the one you get without asking - a simulation whose result
|
|
107
|
+
* depends on a playback setting is not reproducible from the document
|
|
108
|
+
* alone. With `fps` the render rate becomes part of what determines the
|
|
109
|
+
* output, which is correct for a mode called variable and must not be
|
|
110
|
+
* silent: it belongs in the cache key, and `cascade check` should say so.
|
|
111
|
+
*/
|
|
112
|
+
timestep_mode: {
|
|
113
|
+
type: "string",
|
|
114
|
+
default: "fixed",
|
|
115
|
+
control: "select",
|
|
116
|
+
options: [
|
|
117
|
+
{ value: "fixed", label: "Fixed - reproducible from the document" },
|
|
118
|
+
{ value: "fps", label: "Variable - derived from the render rate" },
|
|
119
|
+
],
|
|
120
|
+
},
|
|
121
|
+
timestep: { type: "float", default: 1 / 24, min: 1 / 240, max: 1, step: 1 / 240 },
|
|
122
|
+
maxspeed: { type: "float", default: 0, min: 0, max: 10000, step: 0.5 },
|
|
123
|
+
force: { type: "vec2", default: [0, 0] },
|
|
124
|
+
airresist: { type: "float", default: 0, min: 0, max: 20, step: 0.05 },
|
|
125
|
+
},
|
|
126
|
+
};
|
|
127
|
+
export function executeSolver(context) {
|
|
128
|
+
const props = context.props;
|
|
129
|
+
const incoming = context.inputs.particles;
|
|
130
|
+
if (!incoming) {
|
|
131
|
+
context.outputs.geometry.set(emptyParticleGeometry(2));
|
|
132
|
+
return;
|
|
133
|
+
}
|
|
134
|
+
const state = ParticleState.fromGeometry(incoming);
|
|
135
|
+
const forces = [];
|
|
136
|
+
/**
|
|
137
|
+
* Whatever the force nodes upstream accumulated, first.
|
|
138
|
+
*
|
|
139
|
+
* This is the half that makes a network of forces work: they add to a `force`
|
|
140
|
+
* attribute and move nothing, and integration happens **once**, here. The
|
|
141
|
+
* solver then clears the attribute, so a step never inherits the previous
|
|
142
|
+
* step's forces — a leak there would make a simulation accelerate for
|
|
143
|
+
* reasons nothing in the graph explains.
|
|
144
|
+
*/
|
|
145
|
+
forces.push(forceAttribute(incoming));
|
|
146
|
+
if (props.force[0] !== 0 || props.force[1] !== 0)
|
|
147
|
+
forces.push(gravity(props.force));
|
|
148
|
+
if (props.airresist > 0)
|
|
149
|
+
forces.push(drag(props.airresist));
|
|
150
|
+
context.outputs.geometry.set(clearForce(step(state, forces, {
|
|
151
|
+
timestep: props.timestep,
|
|
152
|
+
...(props.maxspeed > 0 ? { maxSpeed: props.maxspeed } : {}),
|
|
153
|
+
}).toGeometry()));
|
|
154
|
+
}
|
|
155
|
+
const noiseForceDefinition = {
|
|
156
|
+
apiVersion: 1,
|
|
157
|
+
label: "POP Noise Force",
|
|
158
|
+
description: "Push particles along a heading read from a noise field.",
|
|
159
|
+
icon: "Wind",
|
|
160
|
+
runsOn: "portable",
|
|
161
|
+
inputs: { particles: { kind: "data", type: "geometry" } },
|
|
162
|
+
outputs: { geometry: { kind: "data", type: "geometry" } },
|
|
163
|
+
props: {
|
|
164
|
+
amplitude: { type: "float", default: 6, min: 0, max: 500, step: 0.5 },
|
|
165
|
+
frequency: { type: "float", default: 0.3, min: 0.001, max: 10, step: 0.01 },
|
|
166
|
+
evolve: { type: "float", default: 0, min: -1000, max: 1000, step: 0.01 },
|
|
167
|
+
timestep: { type: "float", default: 1 / 24, min: 1 / 240, max: 1, step: 1 / 240 },
|
|
168
|
+
seed: { type: "int", default: 7, min: 0, max: 999999 },
|
|
169
|
+
},
|
|
170
|
+
};
|
|
171
|
+
/**
|
|
172
|
+
* Accumulates into `force` and moves nothing — Houdini's POP model.
|
|
173
|
+
*
|
|
174
|
+
* Changed 2026-09-09, and it is a correctness fix rather than a refinement.
|
|
175
|
+
* This node used to call `step()`, which integrates. Chain two force nodes
|
|
176
|
+
* inside a `cascade.core.Feedback` and time then advances **twice per step**:
|
|
177
|
+
* the particles travel further than the timestep says, reordering nodes that
|
|
178
|
+
* ought to commute changes the result, and nothing reports anything. That is
|
|
179
|
+
* unreadable from a picture, so the model has to be right rather than tested.
|
|
180
|
+
*/
|
|
181
|
+
export function executeNoiseForce(context) {
|
|
182
|
+
const incoming = context.inputs.particles;
|
|
183
|
+
if (!incoming) {
|
|
184
|
+
context.outputs.geometry.set(emptyParticleGeometry(2));
|
|
185
|
+
return;
|
|
186
|
+
}
|
|
187
|
+
const props = context.props;
|
|
188
|
+
const state = ParticleState.fromGeometry(incoming);
|
|
189
|
+
context.outputs.geometry.set(accumulateForce(incoming, noiseField({
|
|
190
|
+
seed: props.seed,
|
|
191
|
+
frequency: props.frequency,
|
|
192
|
+
amplitude: props.amplitude,
|
|
193
|
+
evolve: props.evolve,
|
|
194
|
+
}).accelerate(state, state.size), state.size));
|
|
195
|
+
}
|
|
196
|
+
const separateDefinition = {
|
|
197
|
+
apiVersion: 1,
|
|
198
|
+
label: "POP Separate",
|
|
199
|
+
description: "Push neighbours apart within a radius.",
|
|
200
|
+
icon: "Move3d",
|
|
201
|
+
runsOn: "portable",
|
|
202
|
+
inputs: { particles: { kind: "data", type: "geometry" } },
|
|
203
|
+
outputs: { geometry: { kind: "data", type: "geometry" } },
|
|
204
|
+
props: {
|
|
205
|
+
radius: { type: "float", default: 0.5, min: 0, max: 100, step: 0.01 },
|
|
206
|
+
strength: { type: "float", default: 4, min: 0, max: 500, step: 0.1 },
|
|
207
|
+
timestep: { type: "float", default: 1 / 24, min: 1 / 240, max: 1, step: 1 / 240 },
|
|
208
|
+
},
|
|
209
|
+
};
|
|
210
|
+
export function executeSeparate(context) {
|
|
211
|
+
const incoming = context.inputs.particles;
|
|
212
|
+
if (!incoming) {
|
|
213
|
+
context.outputs.geometry.set(emptyParticleGeometry(2));
|
|
214
|
+
return;
|
|
215
|
+
}
|
|
216
|
+
const props = context.props;
|
|
217
|
+
const state = ParticleState.fromGeometry(incoming);
|
|
218
|
+
context.outputs.geometry.set(accumulateForce(incoming, separation({ radius: props.radius, strength: props.strength }).accelerate(state, state.size), state.size));
|
|
219
|
+
}
|
|
220
|
+
/**
|
|
221
|
+
* `cascade.pop.FieldForce` — the flow field, as a node.
|
|
222
|
+
*
|
|
223
|
+
* Extracted from `pop.Simulate`'s `field_*` props on 2026-09-09 so that the
|
|
224
|
+
* force which actually makes the calligraphy is a node you can see inside the
|
|
225
|
+
* container, reorder, and drive from a ramp. Two components, and they are the
|
|
226
|
+
* whole effect: `normal` pulls along the field's `N` toward the spine of the
|
|
227
|
+
* type, `tangential` pushes perpendicular to it, around the outline. The
|
|
228
|
+
* balance between them is what turns a hairball into a stroke.
|
|
229
|
+
*
|
|
230
|
+
* `level` and `hold` are the reason the lines follow the letters rather than
|
|
231
|
+
* collapsing onto them: instead of climbing to the field's peak, a particle
|
|
232
|
+
* holds a chosen contour. Marcus's own note on wanting this: *"we want the
|
|
233
|
+
* lines to extend and really flow around the letters, creating an elegant
|
|
234
|
+
* calligraphy… not hairballs or knots."*
|
|
235
|
+
*/
|
|
236
|
+
const fieldForceDefinition = {
|
|
237
|
+
apiVersion: 1,
|
|
238
|
+
label: "POP Field Force",
|
|
239
|
+
description: "Pull particles along a field's gradient and around its contours. The calligraphy force.",
|
|
240
|
+
icon: "Waves",
|
|
241
|
+
runsOn: "portable",
|
|
242
|
+
inputs: {
|
|
243
|
+
particles: { kind: "data", type: "geometry" },
|
|
244
|
+
/** The field: points carrying `N` (the gradient) and optionally `level`. */
|
|
245
|
+
field: { kind: "data", type: "geometry" },
|
|
246
|
+
},
|
|
247
|
+
outputs: { geometry: { kind: "data", type: "geometry" } },
|
|
248
|
+
props: {
|
|
249
|
+
normal: {
|
|
250
|
+
type: "float",
|
|
251
|
+
default: 0,
|
|
252
|
+
min: -100000,
|
|
253
|
+
max: 100000,
|
|
254
|
+
label: "Normal",
|
|
255
|
+
description: "Along N, toward the spine. Negative pushes away.",
|
|
256
|
+
},
|
|
257
|
+
tangential: {
|
|
258
|
+
type: "float",
|
|
259
|
+
default: 2600,
|
|
260
|
+
min: -100000,
|
|
261
|
+
max: 100000,
|
|
262
|
+
label: "Tangential",
|
|
263
|
+
description: "Perpendicular to N, around the outline. Sign picks the direction.",
|
|
264
|
+
},
|
|
265
|
+
radius: {
|
|
266
|
+
type: "float",
|
|
267
|
+
default: 20,
|
|
268
|
+
min: 0,
|
|
269
|
+
max: 10000,
|
|
270
|
+
label: "Radius",
|
|
271
|
+
description: "How far a field sample reaches.",
|
|
272
|
+
},
|
|
273
|
+
level: {
|
|
274
|
+
type: "float",
|
|
275
|
+
default: 0.28,
|
|
276
|
+
min: 0,
|
|
277
|
+
max: 1,
|
|
278
|
+
step: 0.01,
|
|
279
|
+
label: "Level",
|
|
280
|
+
description: "The contour to hold, rather than climbing to the peak.",
|
|
281
|
+
},
|
|
282
|
+
hold: {
|
|
283
|
+
type: "float",
|
|
284
|
+
default: 5200,
|
|
285
|
+
min: 0,
|
|
286
|
+
max: 100000,
|
|
287
|
+
label: "Hold",
|
|
288
|
+
description: "How hard to hold that contour.",
|
|
289
|
+
},
|
|
290
|
+
},
|
|
291
|
+
};
|
|
292
|
+
export function executeFieldForce(context) {
|
|
293
|
+
const incoming = context.inputs.particles;
|
|
294
|
+
if (!incoming) {
|
|
295
|
+
context.outputs.geometry.set(emptyParticleGeometry(2));
|
|
296
|
+
return;
|
|
297
|
+
}
|
|
298
|
+
const flow = context.inputs.field;
|
|
299
|
+
const props = context.props;
|
|
300
|
+
// An absent or empty optional field contributes nothing. A present nonempty
|
|
301
|
+
// field without directions is malformed, not equivalent to no field.
|
|
302
|
+
if (!flow || flow.pointCount === 0 || (props.normal === 0 && props.tangential === 0)) {
|
|
303
|
+
context.outputs.geometry.set(incoming);
|
|
304
|
+
return;
|
|
305
|
+
}
|
|
306
|
+
if (!flow.point.N)
|
|
307
|
+
throw new Error(`${context.nodeId}.field requires point attribute N when the field contains points`);
|
|
308
|
+
const state = ParticleState.fromGeometry(incoming);
|
|
309
|
+
context.outputs.geometry.set(accumulateForce(incoming, flowField({
|
|
310
|
+
positions: flow.point.P.data,
|
|
311
|
+
positionSize: flow.point.P.size,
|
|
312
|
+
directions: flow.point.N.data,
|
|
313
|
+
...(flow.point.level && flow.point.level.storage !== "string"
|
|
314
|
+
? { levels: flow.point.level.data }
|
|
315
|
+
: {}),
|
|
316
|
+
count: flow.pointCount,
|
|
317
|
+
normal: props.normal,
|
|
318
|
+
tangential: props.tangential,
|
|
319
|
+
radius: props.radius,
|
|
320
|
+
level: props.level,
|
|
321
|
+
hold: props.hold,
|
|
322
|
+
}).accelerate(state, state.size), state.size));
|
|
323
|
+
}
|
|
324
|
+
/**
|
|
325
|
+
* `cascade.pop.Trail` — trails from a Feedback container's history.
|
|
326
|
+
*
|
|
327
|
+
* The node that makes the network version of the piece possible at all. A trail
|
|
328
|
+
* needs the *frames*, and a node downstream of a simulation sees one — which
|
|
329
|
+
* draws nothing. `cascade.core.Feedback` keeps a bounded history for exactly
|
|
330
|
+
* this, and this node consumes it.
|
|
331
|
+
*
|
|
332
|
+
* The parameter names are Houdini's Trail SOP: `result`, `length`,
|
|
333
|
+
* `increment`, `velocityscale`.
|
|
334
|
+
*/
|
|
335
|
+
const trailDefinition = {
|
|
336
|
+
apiVersion: 1,
|
|
337
|
+
label: "POP Trail",
|
|
338
|
+
description: "Build trails from a Feedback container's history.",
|
|
339
|
+
icon: "Activity",
|
|
340
|
+
runsOn: "portable",
|
|
341
|
+
inputs: {
|
|
342
|
+
/** The container's `history` output: every kept step, oldest first. */
|
|
343
|
+
history: { kind: "data", type: "array" },
|
|
344
|
+
/** The current state, for `preserve` and for the newest point of each trail. */
|
|
345
|
+
particles: { kind: "data", type: "geometry" },
|
|
346
|
+
},
|
|
347
|
+
outputs: { geometry: { kind: "data", type: "geometry" } },
|
|
348
|
+
props: {
|
|
349
|
+
result: {
|
|
350
|
+
type: "string",
|
|
351
|
+
default: "polylines",
|
|
352
|
+
control: "select",
|
|
353
|
+
options: [
|
|
354
|
+
{ value: "polylines", label: "Polylines - one line per particle" },
|
|
355
|
+
{ value: "preserve", label: "Preserve - pass the points through" },
|
|
356
|
+
],
|
|
357
|
+
label: "Result",
|
|
358
|
+
},
|
|
359
|
+
length: { type: "int", default: 12, min: 1, max: 100000, label: "Length" },
|
|
360
|
+
increment: { type: "int", default: 1, min: 1, max: 1000, label: "Increment" },
|
|
361
|
+
velocityscale: { type: "float", default: 1, min: 0, max: 100, step: 0.01, label: "Velocity Scale" },
|
|
362
|
+
},
|
|
363
|
+
};
|
|
364
|
+
export function executeTrail(context) {
|
|
365
|
+
const current = context.inputs.particles;
|
|
366
|
+
if (!current) {
|
|
367
|
+
context.outputs.geometry.set(emptyParticleGeometry(2));
|
|
368
|
+
return;
|
|
369
|
+
}
|
|
370
|
+
const history = (context.inputs.history ?? []);
|
|
371
|
+
const frames = [];
|
|
372
|
+
for (const [index, entry] of history.entries()) {
|
|
373
|
+
if (typeof entry !== "object" || entry === null || entry.kind !== "geometry")
|
|
374
|
+
throw new Error(`${context.nodeId}.history[${index}] must be particle geometry`);
|
|
375
|
+
const violations = checkParticleContract(entry);
|
|
376
|
+
if (violations.length > 0)
|
|
377
|
+
throw new Error(`${context.nodeId}.history[${index}] is not particle geometry: ${violations
|
|
378
|
+
.map((violation) => violation.reason)
|
|
379
|
+
.join("; ")}`);
|
|
380
|
+
frames.push(trailFrame(entry));
|
|
381
|
+
}
|
|
382
|
+
const props = context.props;
|
|
383
|
+
context.outputs.geometry.set(buildTrails(frames, current, {
|
|
384
|
+
result: props.result === "preserve" ? "preserve" : "polylines",
|
|
385
|
+
length: props.length,
|
|
386
|
+
increment: props.increment,
|
|
387
|
+
velocityscale: props.velocityscale,
|
|
388
|
+
}));
|
|
389
|
+
}
|
|
390
|
+
const simulateDefinition = {
|
|
391
|
+
apiVersion: 1,
|
|
392
|
+
label: "POP Simulate",
|
|
393
|
+
description: "Re-simulate a particle system from frame zero to this frame.",
|
|
394
|
+
icon: "Rewind",
|
|
395
|
+
runsOn: "portable",
|
|
396
|
+
inputs: {
|
|
397
|
+
/** Where particles are born. Its points are the birth positions. */
|
|
398
|
+
geometry: { kind: "data", type: "geometry" },
|
|
399
|
+
/** The frame to simulate up to. Drive it with `$F`. An input rather than
|
|
400
|
+
* a prop because the whole node is a function of it, and because a node
|
|
401
|
+
* that read the clock itself would be the thing `AGENTS.md` forbids. */
|
|
402
|
+
frame: { kind: "data", type: "float", default: 1 },
|
|
403
|
+
/**
|
|
404
|
+
* Points particles are drawn toward. Houdini's POP Attract.
|
|
405
|
+
*
|
|
406
|
+
* **Geometry rather than an image, and that was a correction.** The
|
|
407
|
+
* original piece renders its word to a canvas, blurs it, reads the pixels
|
|
408
|
+
* back and takes central differences — so its target really is a raster
|
|
409
|
+
* field, and an `image` input was the first thing tried here. It was wrong
|
|
410
|
+
* for this node: decoding an image needs a capability, the media
|
|
411
|
+
* capability hands back a host-specific lease rather than a portable
|
|
412
|
+
* raster, and a POP node doing IO stops being the pure arithmetic that
|
|
413
|
+
* makes it cook identically in both hosts.
|
|
414
|
+
*
|
|
415
|
+
* So the rasterising belongs upstream, in whatever node knows about text
|
|
416
|
+
* or photographs, and what reaches the solver is a set of target points.
|
|
417
|
+
* `fieldForce` in `pop/forces.ts` still takes a sampled raster for a node
|
|
418
|
+
* that has one; this input is the composable half.
|
|
419
|
+
*/
|
|
420
|
+
attract: { kind: "data", type: "geometry" },
|
|
421
|
+
/**
|
|
422
|
+
* A flow field, as points carrying `N`.
|
|
423
|
+
*
|
|
424
|
+
* Marcus's description of the original: *"a delicate balance between a
|
|
425
|
+
* force pulling the particles towards the centre spine of the type and
|
|
426
|
+
* tangentially around their outlines."* This is that field — the gradient
|
|
427
|
+
* of a BLURRED letterform, whose ridge runs down each stroke's middle, so
|
|
428
|
+
* the gradient points at the spine and its perpendicular runs along the
|
|
429
|
+
* outline. `field_normal` and `field_tangential` are the two halves of the
|
|
430
|
+
* balance.
|
|
431
|
+
*/
|
|
432
|
+
field: { kind: "data", type: "geometry" },
|
|
433
|
+
},
|
|
434
|
+
outputs: {
|
|
435
|
+
/** The particles, one point each. */
|
|
436
|
+
geometry: { kind: "data", type: "geometry" },
|
|
437
|
+
/**
|
|
438
|
+
* The trails, one open polyline per particle.
|
|
439
|
+
*
|
|
440
|
+
* Emitted here rather than by a standalone `Trail` node, and that is a
|
|
441
|
+
* consequence of re-simulation rather than a shortcut. Houdini's Trail SOP
|
|
442
|
+
* reads a rolling window the cook already holds, because Houdini's cook is
|
|
443
|
+
* stateful; Cascade's node is a pure function of its inputs, so a
|
|
444
|
+
* standalone `Trail` can only see the frame on its port — one frame, which
|
|
445
|
+
* draws nothing. This node replays every frame from zero, so it has the
|
|
446
|
+
* whole history for free, and a node that draws nothing would be worse than
|
|
447
|
+
* no node.
|
|
448
|
+
*
|
|
449
|
+
* When `CookScheduler` owns the rolling window the plan describes,
|
|
450
|
+
* `cascade.pop.Trail` becomes a real node and this output stays as the
|
|
451
|
+
* cheap path. The parameters are Houdini's either way.
|
|
452
|
+
*/
|
|
453
|
+
trails: { kind: "data", type: "geometry" },
|
|
454
|
+
},
|
|
455
|
+
props: {
|
|
456
|
+
/** Frames of history in the trails. Houdini's Trail `length`. */
|
|
457
|
+
trail_length: { type: "int", default: 14, min: 1, max: 500 },
|
|
458
|
+
/** Use every nth frame. Houdini's `increment`. */
|
|
459
|
+
trail_increment: { type: "int", default: 1, min: 1, max: 32 },
|
|
460
|
+
/**
|
|
461
|
+
* Re-simulation, and why this is the first implementation rather than a
|
|
462
|
+
* placeholder for a cached one.
|
|
463
|
+
*
|
|
464
|
+
* `PLAN particles.md`: re-simulation from the start frame is the
|
|
465
|
+
* *definition* of correctness, and a cache is an accelerator holding
|
|
466
|
+
* nothing re-simulation would not have produced. So this node is the
|
|
467
|
+
* reference: it reads a frame, replays every step from zero, and depends on
|
|
468
|
+
* no state whatsoever. Scrubbing is O(frame) and that is the honest cost —
|
|
469
|
+
* a checkpoint cache makes it fast later without changing a single output,
|
|
470
|
+
* which is exactly the property a cache should have.
|
|
471
|
+
*/
|
|
472
|
+
substeps: { type: "int", default: 1, min: 1, max: 32 },
|
|
473
|
+
timestep: { type: "float", default: 1 / 24, min: 1 / 240, max: 1, step: 1 / 240 },
|
|
474
|
+
/** Per frame, as on Source. */
|
|
475
|
+
impulse: { type: "int", default: 6, min: 0, max: 100000 },
|
|
476
|
+
life: { type: "float", default: 4, min: 0, max: 3600, step: 0.1 },
|
|
477
|
+
lifevar: { type: "float", default: 0.4, min: 0, max: 1, step: 0.01 },
|
|
478
|
+
force: { type: "vec2", default: [0, 0] },
|
|
479
|
+
airresist: { type: "float", default: 0.4, min: 0, max: 20, step: 0.05 },
|
|
480
|
+
noise_amplitude: { type: "float", default: 14, min: 0, max: 500, step: 0.5 },
|
|
481
|
+
noise_frequency: { type: "float", default: 0.02, min: 0.001, max: 10, step: 0.001 },
|
|
482
|
+
/** Turns per second of field evolution. */
|
|
483
|
+
noise_evolve: { type: "float", default: 0.08, min: -10, max: 10, step: 0.01 },
|
|
484
|
+
/** Toward the spine, up the field's gradient. */
|
|
485
|
+
field_normal: { type: "float", default: 0, min: -20000, max: 20000, step: 1 },
|
|
486
|
+
/** Around the outline, perpendicular to the gradient. Sign picks which way. */
|
|
487
|
+
field_tangential: { type: "float", default: 0, min: -20000, max: 20000, step: 1 },
|
|
488
|
+
/** Field samples beyond this are ignored, and it is the grid's cell size. */
|
|
489
|
+
field_radius: { type: "float", default: 24, min: 0.5, max: 2000, step: 0.5 },
|
|
490
|
+
/**
|
|
491
|
+
* The contour to hold, as a field level 0..1, with `field_hold` as how
|
|
492
|
+
* firmly.
|
|
493
|
+
*
|
|
494
|
+
* Marcus: *"the lines flow tangentially around the letters in a bundle …
|
|
495
|
+
* loosely describing the type."* A bundle is a contour, so holding a level
|
|
496
|
+
* and letting the tangent carry particles round it is the mechanism —
|
|
497
|
+
* pulling them to the spine instead collapses each letter to a point, and
|
|
498
|
+
* pure tangential flow lets every particle converge onto one level set.
|
|
499
|
+
*/
|
|
500
|
+
field_level: { type: "float", default: 0.35, min: 0, max: 1, step: 0.01 },
|
|
501
|
+
field_hold: { type: "float", default: 0, min: 0, max: 40000, step: 10 },
|
|
502
|
+
/**
|
|
503
|
+
* Scatter births across this box instead of on the source geometry's
|
|
504
|
+
* points, `[minX, minY, maxX, maxY]`. All zero uses the points.
|
|
505
|
+
*
|
|
506
|
+
* Houdini's POP Source has the same choice — *Scatter onto Surface* against
|
|
507
|
+
* *Points* — and Marcus's note is that the original spawns *around* the
|
|
508
|
+
* type rather than on it: *"The particles should spawn in the area around
|
|
509
|
+
* the type … and then the forces should drag them around."* Birthing on
|
|
510
|
+
* the letterforms puts every particle where it is already going, so
|
|
511
|
+
* nothing travels and the field has nothing to reveal.
|
|
512
|
+
*/
|
|
513
|
+
birth_area: { type: "vec4", default: [0, 0, 0, 0] },
|
|
514
|
+
/**
|
|
515
|
+
* A colour per particle, drawn at birth and fixed for its life.
|
|
516
|
+
*
|
|
517
|
+
* Hashed on the id, so the same particle gets the same colour however many
|
|
518
|
+
* times the frame is re-simulated — a colour drawn from a stream would
|
|
519
|
+
* change on every scrub, which reads as the render being unstable.
|
|
520
|
+
*
|
|
521
|
+
* `[from, to]` in turns around the wheel. Equal values give one hue; a
|
|
522
|
+
* narrow span is usually what a piece wants, because a full-spectrum
|
|
523
|
+
* random set reads as a test pattern rather than as a palette.
|
|
524
|
+
*/
|
|
525
|
+
birth_hue: { type: "vec2", default: [0, 0], min: 0, max: 1, step: 0.01 },
|
|
526
|
+
birth_saturation: { type: "float", default: 0.6, min: 0, max: 1, step: 0.01 },
|
|
527
|
+
birth_value: { type: "float", default: 1, min: 0, max: 1, step: 0.01 },
|
|
528
|
+
/** How hard the targets pull. Houdini's POP Attract strength. */
|
|
529
|
+
attract_amplitude: { type: "float", default: 0, min: 0, max: 20000, step: 1 },
|
|
530
|
+
/** Beyond this distance a target is ignored, which is what keeps the pull
|
|
531
|
+
* local and the cost bounded. 0 means every target pulls every particle. */
|
|
532
|
+
attract_radius: { type: "float", default: 60, min: 0, max: 5000, step: 1 },
|
|
533
|
+
separate_radius: { type: "float", default: 0, min: 0, max: 500, step: 0.5 },
|
|
534
|
+
separate_strength: { type: "float", default: 12, min: 0, max: 500, step: 0.5 },
|
|
535
|
+
maxspeed: { type: "float", default: 60, min: 0, max: 10000, step: 1 },
|
|
536
|
+
/** `[minX, minY, maxX, maxY]`, or all zero for open space. The original
|
|
537
|
+
* piece is toroidal. */
|
|
538
|
+
wrap: { type: "vec4", default: [0, 0, 0, 0] },
|
|
539
|
+
seed: { type: "int", default: 7, min: 0, max: 999999 },
|
|
540
|
+
},
|
|
541
|
+
};
|
|
542
|
+
/**
|
|
543
|
+
* Everything that determines this simulation, as one string.
|
|
544
|
+
*
|
|
545
|
+
* **The whole safety argument for the checkpoint cache is here.** A key on
|
|
546
|
+
* anything less than everything serves a stale state that looks plausible, and
|
|
547
|
+
* a wrong particle system is indistinguishable from a different-looking one. So
|
|
548
|
+
* this covers every prop the stepping reads and the *content* of every geometry
|
|
549
|
+
* it reads, and it deliberately does not try to be clever: a fingerprint that
|
|
550
|
+
* is cheap and complete beats one that is minimal and subtle.
|
|
551
|
+
*
|
|
552
|
+
* The geometries are hashed rather than counted. Counting points would miss a
|
|
553
|
+
* field whose word changed from "Cascade" to "Cascada" at the same resolution —
|
|
554
|
+
* same point count, different letters, and the cache would happily replay the
|
|
555
|
+
* old word. Hashing is O(n) once per cook against O(n·frames) of stepping, so
|
|
556
|
+
* it is not the cost that matters.
|
|
557
|
+
*
|
|
558
|
+
* `frame` is NOT in it, because the frame is what the cache is indexed by.
|
|
559
|
+
* Putting it in the key would give every frame its own entry and no reuse,
|
|
560
|
+
* which is a cache that stores everything and accelerates nothing.
|
|
561
|
+
*/
|
|
562
|
+
function simulationFingerprint(props, geometries) {
|
|
563
|
+
const parts = [];
|
|
564
|
+
for (const [name, value] of Object.entries(props)) {
|
|
565
|
+
// trail_length and trail_increment change what is DRAWN, not what is
|
|
566
|
+
// simulated, so they are excluded — including them would throw away a
|
|
567
|
+
// perfectly good simulation every time the trail length was dragged,
|
|
568
|
+
// which is exactly the parameter someone drags while watching.
|
|
569
|
+
if (name === "trail_length" || name === "trail_increment")
|
|
570
|
+
continue;
|
|
571
|
+
parts.push(`${name}=${JSON.stringify(value)}`);
|
|
572
|
+
}
|
|
573
|
+
for (const geometry of geometries) {
|
|
574
|
+
parts.push(geometry ? geometryFingerprint(geometry) : "none");
|
|
575
|
+
}
|
|
576
|
+
return parts.join("|");
|
|
577
|
+
}
|
|
578
|
+
/** A cheap content hash: counts, then every numeric attribute folded in. */
|
|
579
|
+
function geometryFingerprint(geometry) {
|
|
580
|
+
let hash = 2166136261;
|
|
581
|
+
const fold = (value) => {
|
|
582
|
+
hash ^= value | 0;
|
|
583
|
+
hash = Math.imul(hash, 16777619);
|
|
584
|
+
};
|
|
585
|
+
fold(geometry.pointCount);
|
|
586
|
+
fold(geometry.primitiveCount);
|
|
587
|
+
for (const [name, attribute] of Object.entries(geometry.point)) {
|
|
588
|
+
for (let index = 0; index < name.length; index += 1)
|
|
589
|
+
fold(name.charCodeAt(index));
|
|
590
|
+
if (attribute.storage === "string")
|
|
591
|
+
continue;
|
|
592
|
+
const data = attribute.data;
|
|
593
|
+
// Every value, because a field's whole meaning is its values — and this is
|
|
594
|
+
// the input that changes when the word does.
|
|
595
|
+
for (let index = 0; index < data.length; index += 1)
|
|
596
|
+
fold(Math.round(Number(data[index]) * 4096));
|
|
597
|
+
}
|
|
598
|
+
return (hash >>> 0).toString(36);
|
|
599
|
+
}
|
|
600
|
+
export function executeSimulate(context) {
|
|
601
|
+
const props = context.props;
|
|
602
|
+
const from = context.inputs.geometry;
|
|
603
|
+
const frames = Math.max(0, Math.floor(context.inputs.frame));
|
|
604
|
+
let state = ParticleState.fromGeometry(emptyParticleGeometry(2));
|
|
605
|
+
// Bounded by the trail length: the whole point of a window is that it is
|
|
606
|
+
// smaller than the simulation, so keeping every frame would give up the
|
|
607
|
+
// property that makes trails cheaper than a state cache.
|
|
608
|
+
const history = [];
|
|
609
|
+
let startFrame = 1;
|
|
610
|
+
const available = from ? from.pointCount : 0;
|
|
611
|
+
const source = from?.point.P;
|
|
612
|
+
// Read once for the whole replay: none of these change within a cook.
|
|
613
|
+
const targets = context.inputs.attract;
|
|
614
|
+
const flow = context.inputs.field;
|
|
615
|
+
const scatter = props.birth_area.some((value) => value !== 0)
|
|
616
|
+
? props.birth_area
|
|
617
|
+
: null;
|
|
618
|
+
// Scattering needs no source points: the box is the source. Requiring a
|
|
619
|
+
// wired geometry to scatter inside a box would be a dependency on nothing.
|
|
620
|
+
const canBirth = scatter !== null || (available > 0 && source !== undefined);
|
|
621
|
+
const wrap = props.wrap.some((value) => value !== 0)
|
|
622
|
+
? [props.wrap[0], props.wrap[1], props.wrap[2], props.wrap[3]]
|
|
623
|
+
: undefined;
|
|
624
|
+
/** Resume early enough to replay the complete bounded trail window. A
|
|
625
|
+
* checkpoint is state after its frame, so the lookup stops before the first
|
|
626
|
+
* frame the trail needs to retain. */
|
|
627
|
+
const fingerprint = simulationFingerprint(props, [from, targets, flow]);
|
|
628
|
+
const keep = props.trail_length * props.trail_increment;
|
|
629
|
+
const resumed = checkpointAtOrBefore(context.nodeId, fingerprint, Math.max(0, frames - keep));
|
|
630
|
+
if (resumed) {
|
|
631
|
+
state = resumed.state;
|
|
632
|
+
startFrame = resumed.frame + 1;
|
|
633
|
+
}
|
|
634
|
+
for (let frame = startFrame; frame <= frames; frame += 1) {
|
|
635
|
+
if (canBirth && props.impulse > 0) {
|
|
636
|
+
const born = scatter ? props.impulse : Math.min(props.impulse, available);
|
|
637
|
+
const position = new Float64Array(born * 2);
|
|
638
|
+
const life = new Float32Array(born);
|
|
639
|
+
// Only when a span is asked for: an all-white `Cd` would still be a `Cd`,
|
|
640
|
+
// and it would override the export's own stroke colour with white.
|
|
641
|
+
const colours = props.birth_hue[0] !== props.birth_hue[1]
|
|
642
|
+
|| props.birth_saturation !== 0
|
|
643
|
+
? new Float32Array(born * 4)
|
|
644
|
+
: null;
|
|
645
|
+
for (let index = 0; index < born; index += 1) {
|
|
646
|
+
const id = state.nextId + index;
|
|
647
|
+
if (scatter) {
|
|
648
|
+
// Hashed on the id, so a particle is born in the same place however
|
|
649
|
+
// many times the frame is re-simulated. Two draws from one hash
|
|
650
|
+
// stream would correlate x with y into a diagonal, so the second
|
|
651
|
+
// takes a different sample.
|
|
652
|
+
position[index * 2] = scatter[0] + birthRandom(props.seed, id * 2) * (scatter[2] - scatter[0]);
|
|
653
|
+
position[index * 2 + 1] = scatter[1] + birthRandom(props.seed, id * 2 + 1) * (scatter[3] - scatter[1]);
|
|
654
|
+
}
|
|
655
|
+
else {
|
|
656
|
+
// Walk the source's points across frames rather than restarting at
|
|
657
|
+
// zero, so a small impulse eventually covers the whole shape instead
|
|
658
|
+
// of birthing from the same handful of points forever.
|
|
659
|
+
const pick = (frame * born + index) % available;
|
|
660
|
+
const points = source.data;
|
|
661
|
+
position[index * 2] = Number(points[pick * source.size] ?? 0);
|
|
662
|
+
position[index * 2 + 1] = Number(points[pick * source.size + 1] ?? 0);
|
|
663
|
+
}
|
|
664
|
+
const jitter = birthRandom(props.seed, id);
|
|
665
|
+
life[index] = props.life * (1 - props.lifevar * jitter);
|
|
666
|
+
if (colours) {
|
|
667
|
+
// A third hash sample, distinct from the two the scatter uses and
|
|
668
|
+
// the one the life uses: drawing twice from the same sample would
|
|
669
|
+
// correlate a particle's colour with where it was born.
|
|
670
|
+
const hue = props.birth_hue[0]
|
|
671
|
+
+ birthRandom(props.seed, id * 4 + 3) * (props.birth_hue[1] - props.birth_hue[0]);
|
|
672
|
+
const [r, g, b] = hsvToRgb(hue, props.birth_saturation, props.birth_value);
|
|
673
|
+
colours[index * 4] = r;
|
|
674
|
+
colours[index * 4 + 1] = g;
|
|
675
|
+
colours[index * 4 + 2] = b;
|
|
676
|
+
colours[index * 4 + 3] = 1;
|
|
677
|
+
}
|
|
678
|
+
}
|
|
679
|
+
state = state.born({ position, life, ...(colours ? { colour: colours } : {}) });
|
|
680
|
+
}
|
|
681
|
+
const forces = [];
|
|
682
|
+
if (props.force[0] !== 0 || props.force[1] !== 0)
|
|
683
|
+
forces.push(gravity(props.force));
|
|
684
|
+
if (props.airresist > 0)
|
|
685
|
+
forces.push(drag(props.airresist));
|
|
686
|
+
if (props.noise_amplitude > 0) {
|
|
687
|
+
forces.push(noiseField({
|
|
688
|
+
seed: props.seed,
|
|
689
|
+
frequency: props.noise_frequency,
|
|
690
|
+
amplitude: props.noise_amplitude,
|
|
691
|
+
// Derived from the frame the caller stated, not from a clock — which
|
|
692
|
+
// is what keeps the whole node a pure function of `frame`.
|
|
693
|
+
evolve: frame * props.timestep * props.noise_evolve,
|
|
694
|
+
}));
|
|
695
|
+
}
|
|
696
|
+
if (flow && flow.pointCount > 0 && flow.point.N
|
|
697
|
+
&& (props.field_normal !== 0 || props.field_tangential !== 0)) {
|
|
698
|
+
forces.push(flowField({
|
|
699
|
+
positions: flow.point.P.data,
|
|
700
|
+
positionSize: flow.point.P.size,
|
|
701
|
+
directions: flow.point.N.data,
|
|
702
|
+
...(flow.point.level && flow.point.level.storage !== "string"
|
|
703
|
+
? { levels: flow.point.level.data }
|
|
704
|
+
: {}),
|
|
705
|
+
count: flow.pointCount,
|
|
706
|
+
normal: props.field_normal,
|
|
707
|
+
tangential: props.field_tangential,
|
|
708
|
+
radius: props.field_radius,
|
|
709
|
+
level: props.field_level,
|
|
710
|
+
hold: props.field_hold,
|
|
711
|
+
}));
|
|
712
|
+
}
|
|
713
|
+
if (targets && targets.pointCount > 0 && props.attract_amplitude > 0) {
|
|
714
|
+
forces.push(attract({
|
|
715
|
+
targets: targets.point.P.data,
|
|
716
|
+
targetSize: targets.point.P.size,
|
|
717
|
+
targetCount: targets.pointCount,
|
|
718
|
+
amplitude: props.attract_amplitude,
|
|
719
|
+
radius: props.attract_radius,
|
|
720
|
+
}));
|
|
721
|
+
}
|
|
722
|
+
if (props.separate_radius > 0) {
|
|
723
|
+
forces.push(separation({
|
|
724
|
+
radius: props.separate_radius,
|
|
725
|
+
strength: props.separate_strength,
|
|
726
|
+
}));
|
|
727
|
+
}
|
|
728
|
+
for (let substep = 0; substep < props.substeps; substep += 1) {
|
|
729
|
+
state = step(state, forces, {
|
|
730
|
+
timestep: props.timestep / props.substeps,
|
|
731
|
+
...(props.maxspeed > 0 ? { maxSpeed: props.maxspeed } : {}),
|
|
732
|
+
...(wrap ? { wrap } : {}),
|
|
733
|
+
});
|
|
734
|
+
}
|
|
735
|
+
history.push({
|
|
736
|
+
id: Int32Array.from(state.id),
|
|
737
|
+
position: Float64Array.from(state.position),
|
|
738
|
+
size: state.size,
|
|
739
|
+
});
|
|
740
|
+
if (history.length > keep)
|
|
741
|
+
history.splice(0, history.length - keep);
|
|
742
|
+
offerCheckpoint(context.nodeId, fingerprint, frame, state);
|
|
743
|
+
}
|
|
744
|
+
const geometry = state.toGeometry();
|
|
745
|
+
context.outputs.geometry.set(geometry);
|
|
746
|
+
context.outputs.trails.set(buildTrails(history, geometry, {
|
|
747
|
+
result: "polylines",
|
|
748
|
+
length: props.trail_length,
|
|
749
|
+
increment: props.trail_increment,
|
|
750
|
+
}));
|
|
751
|
+
}
|
|
752
|
+
export const simulateRegistration = {
|
|
753
|
+
kind: "definition-v1",
|
|
754
|
+
moduleId: "cascade.pop.Simulate",
|
|
755
|
+
definition: simulateDefinition,
|
|
756
|
+
loadExecute: async () => executeSimulate,
|
|
757
|
+
};
|
|
758
|
+
export const sourceRegistration = {
|
|
759
|
+
kind: "definition-v1",
|
|
760
|
+
moduleId: "cascade.pop.Source",
|
|
761
|
+
definition: sourceDefinition,
|
|
762
|
+
loadExecute: async () => executeSource,
|
|
763
|
+
};
|
|
764
|
+
export const solverRegistration = {
|
|
765
|
+
kind: "definition-v1",
|
|
766
|
+
moduleId: "cascade.pop.Solver",
|
|
767
|
+
definition: solverDefinition,
|
|
768
|
+
loadExecute: async () => executeSolver,
|
|
769
|
+
};
|
|
770
|
+
export const noiseForceRegistration = {
|
|
771
|
+
kind: "definition-v1",
|
|
772
|
+
moduleId: "cascade.pop.NoiseForce",
|
|
773
|
+
definition: noiseForceDefinition,
|
|
774
|
+
loadExecute: async () => executeNoiseForce,
|
|
775
|
+
};
|
|
776
|
+
export const separateRegistration = {
|
|
777
|
+
kind: "definition-v1",
|
|
778
|
+
moduleId: "cascade.pop.Separate",
|
|
779
|
+
definition: separateDefinition,
|
|
780
|
+
loadExecute: async () => executeSeparate,
|
|
781
|
+
};
|
|
782
|
+
export const fieldForceRegistration = {
|
|
783
|
+
kind: "definition-v1",
|
|
784
|
+
moduleId: "cascade.pop.FieldForce",
|
|
785
|
+
definition: fieldForceDefinition,
|
|
786
|
+
loadExecute: async () => executeFieldForce,
|
|
787
|
+
};
|
|
788
|
+
export const trailRegistration = {
|
|
789
|
+
kind: "definition-v1",
|
|
790
|
+
moduleId: "cascade.pop.Trail",
|
|
791
|
+
definition: trailDefinition,
|
|
792
|
+
loadExecute: async () => executeTrail,
|
|
793
|
+
};
|
|
794
|
+
export const popNodeRegistrations = Object.freeze([
|
|
795
|
+
simulateRegistration,
|
|
796
|
+
sourceRegistration,
|
|
797
|
+
solverRegistration,
|
|
798
|
+
noiseForceRegistration,
|
|
799
|
+
separateRegistration,
|
|
800
|
+
fieldForceRegistration,
|
|
801
|
+
trailRegistration,
|
|
802
|
+
]);
|
|
803
|
+
/** `[moduleId, definition]` pairs, for a host building a node palette. The
|
|
804
|
+
* same shape `geoNodeDefinitions` has, so Studio's library builder needs no
|
|
805
|
+
* second branch. */
|
|
806
|
+
export const popNodeDefinitions = Object.freeze([
|
|
807
|
+
["cascade.pop.Simulate", simulateDefinition],
|
|
808
|
+
["cascade.pop.Source", sourceDefinition],
|
|
809
|
+
["cascade.pop.Solver", solverDefinition],
|
|
810
|
+
["cascade.pop.NoiseForce", noiseForceDefinition],
|
|
811
|
+
["cascade.pop.Separate", separateDefinition],
|
|
812
|
+
["cascade.pop.FieldForce", fieldForceDefinition],
|
|
813
|
+
["cascade.pop.Trail", trailDefinition],
|
|
814
|
+
]);
|
|
815
|
+
//# sourceMappingURL=definitions.js.map
|