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.
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)
|
||||
|
||||
|
||||
Reference in new issue
Block a user