Particles: a particle is a formula of its age (#73)

New page. The clock-raster idea, the seven outputs, the two comfort nodes (Gravity,
Start velocity) that hand out offsets instead of forces, and the limits the formula
model buys: no collision, no wind, no braking in flight.
root committed 2026-09-14 05:44:34 +02:00
1 parent aa47bdd0b8
commit 1f2527386e
5 files changed
+131 -6

No files matched your search

+12 -1
@@ -51,12 +51,16 @@ src/core/commands/ undoable commands, CommandStack, SetBagValueCommand base,
src/core/graph/ graph model, NodeRegistry/NodeContext/values, interpreter, dropTarget, nested (behaviors as nodes),
stateMachine (the State node, a machine's states, the ports they become), variablePorts, propertyPorts,
lookup (what a node may look up: behaviors, state machines), keyRepeat; nodes/{events,flow,data,action,variable}/<Name>.ts
src/core/particles/ nodes (the catalogue), compile (a graph → one function per output),
emission (which particles are alive at a second — a pure rule, no state)
src/core/store/ serialize (untrusted JSON → Project), http (one API call), ProjectStore/HttpProjectStore,
TextureStore/HttpTextureStore, FontStore/HttpFontStore
src/runtime/ World, Entity, System interface, Input tracker, CollisionSystem, AnimationSystem, BehaviorSystem
src/ui/ widget toolkit (generic, knows nothing about assets)
src/viewport/ view (transforms), items (what to draw), symbols (how to draw it),
TextureLibrary, FontLibrary, activeCamera — no editor concepts, so tests reach it freely
TextureLibrary, FontLibrary, ShaderLibrary, ParticleLibrary,
particles (one ParticleContainer per source), activeCamera — no editor concepts,
so tests reach it freely
src/editor/ App → Editor (layout + ticker), PlaySession (the run), ProjectSession (load/create),
EditorRun (run in editor), state, actions, shortcuts, debugHook, autosave,
fileImport (drop/paste) → textureImport / fontImport,
@@ -75,6 +79,8 @@ src/editor/texture/ TextureEditor (header + picker), TextureCanvas (picture +
ImagePicker + imageGrid (the "+ → Texture" dialog), checker (the transparency board)
src/editor/animation/ AnimationEditor (header + picker), AnimationCanvas (preview + frame strip), strip (its geometry)
src/editor/statemachine/ StateMachineEditor (header + picker, on the graph canvas)
src/editor/particles/ ParticlesEditor (graph canvas above a preview strip, splitter between them),
ParticlePreview (the strip: the asset's own particles at the type's defaults)
src/editor/list/ ListEditor (a row per item), slot (which list is being edited, and how it is written back)
src/editor/console/ log (ring buffer, argument formatting, the console.* capture), ConsolePanel (the tail under the tabs)
api/ PHP backend: index.php (routing), lib/{Store,TextureStore,FontStore,OriginalName,Response}.php
@@ -92,6 +98,11 @@ property is one schema line; a new node is one file **plus a row in the table in
`tests/core/node-catalog.test.ts`**, which fails if a node has no case; a canvas that pans or
zooms puts **everything** the view moves into one masked container, not just the picture — an
outline, a name plate or a checkerboard left outside it is drawn over the panel next door.
**A rule that produces something over time is written as a function of the clock, not stepped
per frame** — `core/particles/emission.ts` says which particles are alive at second *t* and
`core/particles/compile.ts` turns a graph into what one of them looks like at its age, so a field
of 500 particles is reproducible, holds still for a check, and needs no state beyond where its
source stood when each one was born. Both are pure and tested without a browser.
Comments state contracts
and reasons, not what the code does: a `/** */` on every export whose name does not say it all,
`//` only for a decision or gotcha.
+3 -2
@@ -1,10 +1,11 @@
| Term | Meaning |
|---|---|
| **Asset** | Named thing with a type (`scene`, `camera`, `sprite`, `text`, `behavior`, `collider`, `container`, `texture`, `animation`, `statemachine`, `shader`, `font`) and typed properties. Assets have **no position**. |
| **Asset** | Named thing with a type (`scene`, `camera`, `sprite`, `text`, `behavior`, `collider`, `container`, `texture`, `animation`, `statemachine`, `shader`, `font`, `particles`) and typed properties. Assets have **no position**. |
| **Texture** | An uploaded image. Scenes, textures, animations, state machines, shaders and fonts are the six types that cannot be placed: a texture has no position, it is *worn* by a sprite through the ordinary property `texture`. See [Textures](Textures). |
| **Font** | An uploaded typeface, worn by a text through the ordinary property `font`; no font means the editor's own. Uploaded and picked exactly like a texture, and stored the same way. See [Fonts](Fonts). |
| **State machine** | A named set of **states**, edited as nodes in its own tab. A behavior puts it into a state with `Set state` and branches on it with `State branch`; each behavior instance keeps its own current state. See [State machines](State-machines). |
| **Shader** | A node graph that becomes the GLSL a sprite is drawn with, edited in its own tab. A sprite wears it through `shader`; the numbers the graph declares are rows below that dropdown. See [Shaders](Shaders). |
| **Particles** | A node graph that says what *one* particle is as a function of its age and of a random number drawn once for it — no forces, no simulation. Unlike the other graph assets it **is** placeable: the object made from it is a source, whose rows say how many go out and for how long they live. Because a particle is a formula, the same second always draws the same picture and the tab's slider can jump to any of them exactly. See [Particles](Particles). |
| **Text** | An asset that draws a line of text (`text`, `font`, `fontSize`, `color`, `align`) — titles, HUD readouts, button labels. Its content is an ordinary property, so a `Properties` node of type Text with `text` switched to Set changes it while playing. |
| **Enabled** | Every placeable asset has the switch `enabled` (default on). A disabled object is not drawn, does not collide and its behavior does not run — **and neither does anything parented below it**. Switching one instance off is a normal override, which makes a container a *screen*: switch it on to show it, off to hide it. From a graph, `Properties` with `enabled` switched to Set does it, and `Is enabled` asks (including the parents); a behavior hears the switch as `On enable` / `On disable`. |
| **Scene object** | An asset placed into a scene: a link to the asset + a **position** (`x`, `y`, `z`) + sparse **overrides** of the asset's properties + an optional **parent** (another object of the scene). Drawn objects also carry **transform** rows (scale, rotation, mirror) — properties like any other, and they cascade to children the way a position does (see [Scene editing](Scene-editing)). Scenes cannot be placed. |
@@ -35,7 +36,7 @@ no movement): everything that happens is defined by behavior graphs.
scene, Undo/Redo, Play/Pause, and on the right the notice line, the save status and the frame
rate. The **Assets** panel's `+` creates an asset and opens its tab. A click on a row only
selects (the inspector shows it, no tab moves); a **double-click** opens the tab the asset lives
in — Scene, Behavior, State machine, Shader, Texture, Animation or Container — and on a scene
in — Scene, Behavior, State machine, Shader, Particles, Texture, Animation or Container — and on a scene
also makes it the active one. `Enter` does the same once the list has keyboard focus (`Tab`).
A sprite, camera, text or collider has no tab of its own, so a double-click on one does nothing.
Right-clicking a row offers *Set as active scene*, *Create sprite from texture*, *Create animation
+3 -3
@@ -12,7 +12,7 @@ npm run check # typecheck + lint + format:check + tests, the gate CI runs
# code is lost (tail's is reported), so a red run reads as green
npm run build # writes dist/ and regenerates index.html
./build.sh # the same under `set -o pipefail`, for callers that pipe the output
node tools/seed-e2e.mjs && node tools/e2e.mjs # 790 editor checks, screenshots in /tmp/gm-e2e-*.png
node tools/seed-e2e.mjs && node tools/e2e.mjs # 809 editor checks, screenshots in /tmp/gm-e2e-*.png
GM_STEPS=20 node tools/e2e.mjs # or 1-12, or 6,20 — a subset while working on one step
./e2e.sh start [GM_STEPS] # the same run detached, see below
./e2e.sh status # live progress, or the last run's result
@@ -63,7 +63,7 @@ to touch it.
The e2e run is a list of steps in `tools/e2e/` (hierarchy, inspector, play, assets, drawing,
graph, parenting, colliders, containers, text/enabled, menus, textures, frames, overflow, aspect,
container tab, run-in-editor, pause, nested behaviors, key repeat, list tab, struct variable,
variable nodes, property nodes, animations, state machines, console, shaders, transforms, fonts) that build on each other's
variable nodes, property nodes, animations, state machines, console, shaders, transforms, fonts, particles) that build on each other's
scene state and share the CDP driver in `tools/lib/harness.mjs` — `press`, `undo`, `play`,
`object(id)`, `world()`, `mutate(js)` and friends, so a check reads like the interaction it
exercises.
@@ -151,4 +151,4 @@ widths to find click points. The **render device is not in this repo** — `/dev
and a 1 GB `/dev/shm` come from `container.options` in the runner's own `config.yaml`, which
applies to every job on that runner. Without them chromium falls back to llvmpipe, which gets
WebGL 1.0 only. Measured in the job container: `ANGLE (Intel, Vulkan 1.3.230, HD Graphics 630)`,
WebGL 2.0, and the same 790 checks as here.
WebGL 2.0, and the same 809 checks as here.
+112
@@ -0,0 +1,112 @@
A **particle system** is an asset: a node graph that says what *one* particle is — where it sits,
how big it is, how it is turned, what colour it has — as a function of its **age** and of a random
number drawn once for it. Unlike a texture or a shader it **can be placed**: an object made from it
is a *source*, and its rows say how many particles go out and for how long they live.
Nothing is simulated. A particle is not pushed by forces frame after frame; it is a formula
evaluated at whatever second is being drawn. Three things follow from that, and they are the reason
the model was chosen:
- **The same second always looks the same.** Two runs at the same frame rate, or a different one,
draw the same picture — the field is a function of the clock, not of how many frames have gone by.
- **Any second can be shown at once.** The preview strip in the tab has a slider: drag it to 2 s and
the particles stand where they are at 2 s, exactly, with nothing simulated on the way there.
- **Collision is out, for good.** "Sparks bounce off the ground", wind, drag mid-flight — none of
these can be written as a function of age, and no later addition will change that.
## The Particles tab
`+` in the **Assets** panel makes a particle asset and opens the **Particles** tab on it (a
double-click on an existing one does the same; a click only selects). The upper part is the same
canvas the Behavior and Shader tabs have: right-click places a node, drag moves, drag from a port
links, `Snap` snaps to the 24 px grid, `Fit` frames the graph, undo takes every step back. The
readout on the right says `N parameters` while the graph compiles, otherwise why it does not
(`No Output node`, a bad parameter name, `Feeds itself`). A node that keeps the graph from
compiling shows the reason on its body and a red frame; a source whose graph does not compile emits
nothing.
Below it, the **preview** strip emits the asset's own particles from its middle, with the type's
default rows (20 a second, one second each, a plain 16 px square). `Hold` stops the clock where it
is and turns into `Run`; the slider next to it jumps to any second in the first three lifetimes;
`Restart` begins the stream again at particle 0. The strip deliberately draws plain squares: which
picture a source throws belongs to the placed object, not to the asset the tab is showing.
## The nodes
Every value is a **number**. A colour is a number too — packed `0xRRGGBB`, as everywhere else in the
project — which is why blending two of them needs *Mix color* rather than *Mix*: mixing the packed
numbers of red and blue would run through green.
| Node | Ports | What it does |
|---|---|---|
| **Age** | out `age` | How far through its life the particle is: 0 at birth, 1 when it dies. The axis most graphs are drawn along. |
| **Age in seconds** | out `seconds` | The same in seconds — the *t* of a formula of motion. |
| **Time** | out `seconds` | Seconds since the source started emitting. The same for every particle alive, so it drifts them together rather than apart. |
| **Index** | out `index` | Which particle this is, counting up from the first the source emitted: a fan or a ring instead of a scatter. |
| **Random** | in `min`, `max` · out `value` | A number drawn once per particle and kept for its whole life. **Two Random nodes draw differently**, so the direction and the speed of one particle scatter apart instead of being the same number. |
| **Gravity** | in `strength` · out `x`, `y` | How far a steady pull has moved the particle by now: `strength * age * age / 2`, down positive. Add it to the offset. |
| **Start velocity** | in `angle`, `speed` · out `x`, `y` | How far a constant speed has carried it: angle in degrees (0 right, 90 down), speed in pixels per second. |
| **Output** | in `x`, `y`, `scaleX`, `scaleY`, `rotation`, `color`, `alpha` | What the particle is drawn as, all of it relative to the source. One per graph. An input left unconnected stays what a particle without a graph would be: at the source, full size, upright, the source's own colour, opaque. |
| **Property** | out `value` | Declares a number the *source* sets: `Name` is a row on every object placed from this asset, `Default`, `Min`, `Max` are its range. One word (letters, digits, `_`), unique in the graph, and not the name of one of the source's own rows. |
| **Number**, **Color** | out `value` / `color` | Constants, typed or picked on the node. |
| **Add**, **Subtract**, **Multiply**, **Divide** | in `a`, `b` · out `result` | Arithmetic. Division by zero reads as 0 — infinity would draw as nothing at all. |
| **Min**, **Max**, **Clamp** | · out `result` | The smaller, the larger, and a value held between two bounds. |
| **Mix** | in `a`, `b`, `t` · out `result` | Blends two numbers: `t` = 0 is `a`, 1 is `b`, beyond that it carries on. |
| **Mix color** | in `a`, `b` (colour), `t` · out `color` | The same for colours, channel by channel — a flame fading to smoke. |
| **Sin**, **Cos** | in `value` · out `result` | Of an **angle in degrees**, one full wave every 360. Degrees, not radians, because `rotation` and *Start velocity* are in degrees too. |
| **Sqrt** | in `value` · out `result` | Rises fast and then slows — how smoke drifts up. Below 0 it is 0. |
| **Abs**, **Floor**, **Fract** | in `value` · out `result` | Distance from 0; rounded down; the part after the decimal point (a rising value becomes a sawtooth). |
| **Step** | in `edge`, `value` · out `result` | 0 below the edge, 1 from it on: a hard cut. |
| **Pow** | in `value`, `exponent` · out `result` | To a power: 2 squares, 0.5 is the square root. Below 0 the value counts as 0. |
## The source
A placed source carries a position and the transform rows every drawn object has (scale, rotation,
mirror), so hanging one on a moving ship turns the whole spray with it. Its own rows are:
| Row | Default | What it means |
|---|---|---|
| **Emitting** | on | Whether new particles go out. Switched off, the ones in flight live out their lives — unlike `Enabled`, which takes them away at once along with everything parented below. |
| **Mode** | Continuous | *Continuous* is a steady stream; *Burst* throws the whole count at once. |
| **Rate** | 20 | Particles per second, for a continuous source. |
| **Burst count** | 20 | How many go out per burst. |
| **Lifetime** | 1 s | How long one particle lives — **the scale that gives `Age` its meaning**. |
| **Max particles** | 500 | The most alive at once; past it the oldest are dropped. |
| **Size** | 16 | The particle's size in pixels, which the graph's `scaleX`/`scaleY` multiply. |
| **Texture** | none | The picture each particle is drawn with. Without one it is a plain square. |
| **Color** | white | The tint, and what an unconnected `color` output reads. |
| **Move with source** | off | Off: a particle stays where it was born, so a moving source leaves a trail (rocket smoke). On: it hangs on the source and travels with it (sparks on a carried torch). The difference shows only while the source moves. |
Below them come the rows the asset's graph declares, one per `Property` node — so two fountains of
the same asset can spray at different strengths, exactly the way two sprites wearing the same shader
can. Overriding one of them on a placed object is an ordinary override, with reset and apply.
A **behavior** can start a source over with the `Emit` node (its `source` input names which one): in *Burst*
mode that is another burst, in *Continuous* mode the stream begins again at particle 0.
## Recipes
**Rain.** `Age in seconds → Multiply(b: 400) → Output.y`, and a `Random(-300, 300)` into
`Output.x` to spread the drops across the screen. Three nodes.
**A spark fountain.** `Random(0, 360) → Start velocity.angle`, `Random(100, 300) → .speed`, its
`x`/`y` into two `Add`s together with a `Gravity(800)`, and `Age → Subtract(a: 1) → Output.alpha`
to fade them. This is where the formula model asks its price: gravity has to be written as the
offset it has produced (`g * t * t / 2`), not as a force — which is what the *Gravity* node is for.
**Smoke.** `Age → Sqrt → Multiply(b: -60) → Output.y` — rising and slowing without a damping
constant anyone has to guess. Add `Time → Multiply(b: 180) → Sin → Multiply(b: 20) → Output.x` for
drift, `Age → Add(b: 1) → Output.scaleX`/`scaleY` to let it grow, and `Mix color` from a warm grey
to a cold one over `Age`.
## What it cannot do
- **No collision, no wind, no drag.** See the top of this page: they are not functions of age.
- **A source is culled by nothing.** Its particles may be anywhere on screen, so the source itself is
never skipped for being out of frame — with many sources in one scene that costs.
- **Particle time is the editor's clock, not the game's.** Pausing the game does not freeze a spray.
- **The preview shows the asset, not an instance**: the type's default rows and a plain square, never
the texture or the rate a particular placed source was given.
- **A source with `Emitting` off draws nothing at all** once the last particle has died, so it
disappears from the scene view; it is still in the hierarchy panel, and selecting it there shows
its handle.
+1
@@ -9,6 +9,7 @@
- [Animations](Animations)
- [State machines](State-machines)
- [Shaders](Shaders)
- [Particles](Particles)
- [Containers](Containers)
- [Keyboard](Keyboard)