A shader could only be judged by placing it on a sprite and running
the scene. The shader tab now shows its effect next to the graph, on
the image the user picked last, without a picker to maintain.
- The preview follows the last selection that has an image: a placed
sprite with its overrides, size and tint, a sprite asset, a texture
or a frame. Clicking the shader, a node, a behavior or a scene keeps
the picture.
- Without such a selection, or when the picked image is deleted, a
built-in 64 px test image is shown. Its colour runs with u and v, a
checkerboard shows where a UV lands and the border fades for alpha.
- Each property gets a slider. The sliders belong to the preview and
never write to the project. They start at the sprite's values when
it carries this shader, otherwise at the default.
- The preview has its own clock. Hold and Restart are only enabled
when a Time node is in the graph.
- The graph | splitter | panel layout of the particles tab moves into
PreviewSplit, and both tabs use it.
- A nine-sliced frame is previewed unsliced. This is documented.
Findings:
- The slider row stayed squeezed to a few pixels when the properties
changed while the tab was hidden. The layout was triggered one level
too high. The slider check in block 28n holds this.
- The graph canvas is narrower now, 385 px at a 1280 px window, the
same as in the particles tab. Block 28b clicked at a fixed x+420 and
now uses the measured width.
Tests:
- ./check.sh: 132 files, 1952 tests (was 131 / 1941), exit 0
- npm run build: exit 0
- GM_STEPS=28,31 freshly seeded: ALL PASSED, 105 checks
- 11 new unit tests in tests/editor/shader-preview.test.ts
- 7 new e2e checks in block 28n
- Counter-probes: start values ignoring the sprite fail 1 unit test,
a preview ignoring the selection fails 2 e2e checks
- Check total 1209 in README is computed (1202 + 7), CI measures it
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The snap toggle was a checkbox with the word "Snap" next to Fit, and
the vector art tab had its own "Grid" button for the same idea. Both
now share one square icon button, so snapping looks and works the
same wherever it exists.
- SnapToggle is a square toggle button with a magnet icon and no
text. It lights up blue when snapping is on, like the other toggle
buttons.
- The magnet is drawn with cubic curves instead of arcs, because the
Pixi SVG parser draws arcs as jagged edges.
- The vector art tab drops its Grid button and uses the same
SnapToggle. It still snaps to 8 px, the graph tabs to 24 px.
- SnapToggle accepts anything with setSnapping(on). The vector art
canvas method is renamed from setSnap to setSnapping for that.
- Every tab exposes the button as __gm.<tab>.snapButton(). Modules
06, 26 and 36 click through that hook instead of the label "Snap"
plus 30 px.
Tests:
- ./check.sh: 129 files, 1903 tests (was 128 / 1897)
- 3 new tests in tests/editor/snap-toggle.test.ts, 3 in the icon tests
- GM_STEPS=1-6,26,36,47 fresh: ALL PASSED, 346 checks
- GM_STEPS=47: ALL PASSED, 32 checks (was 29)
- Counter-checks: toggle not switching its target fails 2 unit
tests, the canvas ignoring the switch fails module 47
- Check total 1188 in README and wiki is computed (1184 + 4), the
full run is left to CI
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A particle source can wear a shader asset the way a sprite does, and it is the
cheapest place in the editor to wear one: a shaded sprite gives up its batch and
costs a draw call of its own, while `ParticleContainerPipe` breaks the batch for
every field regardless. Five hundred shaded particles are the one call five
hundred plain ones were.
The fragment the compiler emits needed no change, only a second vertex stage:
Pixi's particle vertex is GLSL ES 1.00 and ours is ES 3.00, so it is translated
rather than invented. What it sees is a particle's picture, not its life — the
buffer carries five fixed attributes and takes no sixth, so age, index and seed
stay on the graph, which is what the graph is for.
Two things the issue did not ask about:
`Shader.resources` is an accessor over the names its constructor declared, so a
slot that is not declared becomes a loose JS property and is never bound. The
adaptor writes `uTranslationMatrix` into whichever shader the container names —
without an empty block for it the vertex gets no matrix and the particles land
elsewhere.
`schemaOf` in serialize.ts hung on `schema !== schemaFor('sprite')`, an identity
check a source never satisfies, so its shader values would have been dropped on
save.
A source is the one asset drawing rows from two graphs, so a name can arrive
twice. Its own row wins and the uniform keeps its default; nothing is rejected
on the shader's side, because the collision exists only at this one wearer.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Particle age and shader time each read performance.now() off their own
library, and the world got the frame's delta straight from the ticker. Three
clocks, so a global time scale would have been three multiplications and a
pause three separate freezes.
core/time.ts holds one clock instead: it accumulates dt x scale per frame
rather than reading the wall clock, and Editor.tick hands the very step it
took to the play session, the editor run and the animation preview. Delta
time, particle age, shader time and that preview are the same quantity now,
so the scale is a single multiplication and a pause is that scale at 0 —
nothing underneath needs rebasing, because the clock itself stands still.
The Math.min(dt, 0.1) caps in PlaySession and EditorRun are gone: the cap
(MAX_FRAME) now sits on the raw frame before scaling, and capping twice would
make the world run slower than the particles at scale 2.
A pause gates the scale rather than overwriting it — effectiveScale() answers
0 while paused, and for anything that is not a positive finite number, while
gameClock.scale keeps its value. A slider later survives a pause that way;
the issue asks for none yet.
The clock goes back to zero at both ends of a run, so a run is reproducible
from second 0 and the scene view looks freshly loaded after Stop. Resetting
only on Stop would not do it: the editor clock runs on afterwards, and the
next Play would start at "seconds since the last Stop".
The trap in that: ParticleField keeps its origin as an absolute second of
this clock, so after a reset it lies in the future and every particle would
age backwards. One line in sync() starts such a field over, which covers the
scene, the game and the tab preview at once — and fixes an older bug of the
same shape, where releasing a particleTime() freeze left the preview empty
for good.
Found while measuring that: ParticlePreview stood at second 0.5 by computing
the clock reading that would mean 0.5 since the field started. Over 200000
random origins the round trip misses the last bit in 18.8% of them and shows
one particle too few in 9.4%, because floor(0.5 * 20) comes out 9. The check
had been flaky since it was written. The panel hands the field the age itself
now (ParticleField.hold), and the faked clock is gone.
833 checks (824 + 9), 931 unit tests (921 + 10).
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Speed and Prewarm are not state and not a second loop: both are arithmetic
on the clock argument the field already passes to every particle formula.
fieldTime = elapsed * timeScale + prewarmOffset, a pure rule next to
liveRange, called at the one place ParticleField.sync forms its time, so
preview and world inherit it together.
Prewarm offsets by one lifetime because that is the smallest offset which
fills a continuous emitter; a burst is dead at time >= lifetime, so the
same offset would skip it entirely and it is suppressed there.
The tab's slider counts wall-clock seconds, so its span divides by the
scale. A missing row falls back to 1 / off, so old project files need no
migration.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Three reports plus one found on the way.
Hold only froze the *picture*: the preview computed a fake second while drawing,
the library clock kept counting underneath, so letting go jumped to "how much
would have passed by now". That is the flip side of the formula model — a thing
that is a function of the clock is not stopped, its origin is moved.
ParticleField.rebase() does that; hold(null) calls it with the second stood at.
The Hold label now mirrors the preview's state instead of its own click history,
so dragging the slider (which holds too) reads `Run`, and Restart puts a held
preview back to 0.00 s rather than leaving it on the second it stopped at.
previewProps only read defaultsFor('particles'), so the panel showed the type's
defaults while the inspector showed the asset's rows. AssetBase.props is a full
bag on a graph asset as well — asset.props now lie over the defaults, the
asset's texture is resolved instead of always drawing a white square, and the
slider's span follows its lifetime. The span is re-read in tick(), because the
rows are edited in the inspector and the tab hears nothing about it.
The preview moves from under the graph to beside it, past a draggable splitter:
a graph is read across, so width is what it can spare and height is not.
AssetTabEditor grows a body() hook for that — the base class still stacks header,
strip and canvas, a subclass may put something next to the canvas.
Found by the full run, not reported: the graph canvas's empty-state hint ran
38 px past its frame now that the preview takes 280 px away. It is cut with
truncate() like any other label.
816 checks (809 + 7).
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The asset type `particles` is placeable and carries a graph at the same time —
`behavior` was the blueprint. Because a continuous emitter gives birth to particle n
at time n/rate, the whole field is a function of the clock rather than of the frames:
no ring-buffer bookkeeping, exact time jumps, reproducibility as a measurable property.
compile.ts follows compileShader's frame but emits JS closures into a Float64Array
instead of GLSL, and without eval; a node with two consumers is evaluated once.
Drawing is one Pixi ParticleContainer per source, so one draw call.
Collision, wind and braking in flight are out for good — that is the price of the
formula model, not an omission.
The tab count goes from nine to ten, which is exactly the case #84 was built for one
day earlier: at 1280 px the row no longer holds every tab, so guard 14b now asks for
hidden.length > 0 instead of === 0.