A shader could only ask its sprites for numbers. A tint, an outline
colour or a glow colour had to be built from three Property nodes.
- New node Color property next to Property: Name and Default (a
colour, white unless picked), output `color`. It becomes a colour
row below the Shader dropdown and a vec3 uniform in the program.
- Number and colour properties share one set of names. A clash is
the existing "Another property is named" problem.
- Like Color it cannot feed the Vertex output.
- Placed from the palette it is called tint, then tint2.
- The preview in the Shader tab shows a colour field for it. Like
the sliders it starts at the sprite's value and never writes into
the project.
- `declaredRows` takes the row builder, so the particle graph keeps
its number rows unchanged.
Tests:
- Gate: 222 files, 3452 tests (3445 + 7)
- e2e 28: 89 checks (28l-v: default colour drawn, then the colour
the sprite sets)
- e2e total 1704, counted (1703 + 1)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A shader could only say what colour a pixel has. Where the picture
is drawn was fixed to the four corners of its rectangle, so nothing
could wave, bend or wobble.
- The new node Vertex output takes x and y, in pixels of the
picture, and moves every corner of the wearer's grid by them. The
UVs stay with the corners, so the picture bends along.
- Subdivisions on that node says how fine the grid is: cells per
axis, 1 to 64, 8 by default. 1 is the plain quad. A shader without
the node keeps the quad every sprite shares.
- A graph holds one Vertex output at most. UV, Time, Property,
Number and the math nodes may feed it, and one node may feed both
outputs.
- Texture color, Texture alpha, Sample texture, Texture size and
Color cannot feed it. The node says "Not available per corner"
and the shader does not compile.
- Sprites, texts and the preview in the shader tab wear the grid.
A nine-slice sprite and particles draw as if the node were not
there.
- Picking, the selection frame, the size and colliders stay with
the rectangle. A wearer with a Vertex output is never culled, as
with Padding, and with Padding the grid covers the room around
the picture too.
- tools/perf-shaders.mjs takes GM_PERF_SUBDIVISIONS to measure a
grid against the plain quad.
Measured on stein2 (Intel HD 630), frames per second, one run per
row: at 800 sprites 58 with a shader on the quad, 46 with 8 cells,
41 with 32. Up to 400 sprites a grid hardly shows. Every sprite
holds its own grid.
Tests:
- Gate on melektaus-atx: 220 files, 3416 tests, CHECK EXIT 0.
- 15 new unit tests for the compiler and the grid.
- Four new e2e checks in modules 28 and 30: a fixed offset moves
the picture, 8 cells bend only the lower half where 1 cell shears
it all, Texture alpha is refused at the node, a text moves.
- e2e 28,30 on melektaus-atx: 110 checks, all pass.
- The e2e total of 1699 is counted (1695 + 4), the full suite did
not run.
- No counter-probe, no screenshot. Not looked at: the preview, Time
in the browser, flipped sprites, Padding together with the grid.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A shader ran only inside the rectangle of what wears it: the quad
has the size of the sprite, and outside of it no fragment runs. An
outline or a glow built with Sample texture was cut off at the
border. The issue still spoke of Filter.padding, but since #77 the
shader is no filter any more, the limit is the geometry.
- The Output node has a number Padding next to Edge: so many pixels
of the picture per side the wearer draws beyond it. 0 is the
default and draws as before, 256 is the most.
- The quad grows and its UVs grow with it, so the picture keeps its
place and size. In the margin the UVs run on below 0 and above 1.
- Transparent is a fourth Edge mode: outside of the picture there
is nothing. It is cut in the fragment (texelAt), so it needs no
copy of the picture with an address mode of its own.
- Edge holds everywhere alike, also for Texture color, Texture
alpha and unconnected inputs.
- Padding above 0 with Edge on Clamp puts the hint "Padding with
Clamp smears the border" on the Output. The shader still
compiles. A chosen Clamp and the default cannot be told apart.
- All four wearers draw the margin. A sprite counts in pixels of
its frame, a text in its units, a particle source grows every
particle about its centre.
- A nine-slice sprite gets a ring around its nine fields, as wide
as so many border pixels. The fields themselves stay.
- Without a picture a pixel is one unit of the wearer's own size,
or of the particle size.
- Particles now take the Edge mode of their shader. It had no
effect on them before.
- An object wearing a padded shader is never culled, or its margin
would vanish at the edge of the screen.
- The preview in the shader tab shows the margin and leaves room
for it.
- Picking, the selection frame, the size and the collider stay with
the rectangle without the margin.
Tests:
- Gate on melektaus-atx right before this commit: 216 files,
3336 tests (3319 + 17), exit 0.
- e2e 28,30,31,37,41 on melektaus-atx: 178 checks, all pass.
e2e 1-37,47,48,58 ran before the last change (no culling with
padding): 1281 checks, all pass.
- 17 new unit tests: the compiler (padding, Transparent, the hint)
and the geometry (quad, nine-slice ring).
- Seven new e2e checks: four at the sprite in module 28, one each
for text, particles and nine-slice.
- 17 unit counter-probes all fail their tests. Three browser
counter-probes fail their checks (5, 3 and 1 of them).
- Three screenshots looked at: the sprite with Clamp and margin in
the scene, the preview in the shader tab, the text.
- The e2e sum of 1687 is counted (1680 + 7), the full suite and
the smokes did not run.
- Not seen: the hint at the node in a picture, particles and
nine-slice in a picture, the game view while playing, sprites
without a picture, vector and pixel art pictures.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The result of Add, Multiply, Mix and the other arithmetic nodes of
a shader graph was drawn as any, whatever went in. The compiler
already lifts a colour on any side to a colour result, so the pin
said less than the code knew. A colour result could also be wired
into a number input, and the shader then failed to compile.
- liftedType in core/shader/nodes.ts is the one rule. The compiler
reads it when it lifts two operands, and the editor reads it
through liftedPortType for the ports it draws.
- A SharedPortGroup may bring its own rule as agree. Without one
it is agreedType, as before. The thirteen arithmetic nodes share
their type by liftedPortType.
- Only the any inputs decide. An input nothing feeds counts as the
number typed into it. t, edge, exponent, min and max have no say.
- The type travels through a chain of such nodes without code of
its own, because #210 hands a shared type on.
- In a ring the result stays any, unless a colour has settled it.
- A number may feed a colour input. Without that a number result
no longer fitted Output.color, although the compiler spreads it
over three channels. The rule sits in portsCompatible, but only
the shader graph has colour ports.
- A colour result no longer fits a number input. An edge laid
before the colour came in stays and is drawn in the danger
colour, and the node at its end names the problem.
- The nine shaders in the stored projects (108 edges) all stay
valid under the new rule.
Tests:
- Gate on melektaus-atx: 212 files, 3260 tests (3240 + 20), exit 0.
The gate right before this commit ran on the tree of this and
the next commit together, with the same numbers.
- e2e 28 on melektaus-atx: 81 checks, all pass.
- e2e 1-31: 1059 pass, 1 fail in module 18 (the editor clock read
1.97 s after a stop). A rerun of 1-18 passed with 602 checks and
0.18 s. 1-31 was not run in full again.
- No full run and no smokes. The sum of 1676 (1673 + 3) is reckoned.
- Twenty unit tests in shader-port-types.test.ts, one of them runs
all thirteen nodes through every mix of number and colour and
compares what is drawn with the code made.
- Two tests in shaders.test.ts now lay the edge first and bring the
colour in after, the only order that still wires such a graph.
- Three e2e checks in module 28 with the real mouse: a colour into
Multiply, the red edge with its message, and the colour result
refused at alpha and taken at color.
- Fifteen unit counter-probes and two browser counter-probes all
fail their tests.
- Not checked in the browser: the one-input nodes, Mix and the
ring, and a number wired straight into a colour input. Nothing
was looked at after the build.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The groups of the inspector had a small grey label as their title, in
the same grid as a property row, so with many rows one group hardly
stood apart from the next. Now every group has a band as its head,
from edge to edge of the panel, and a click on it folds the group.
- A band runs across the padding of its column in the header colour,
with the title and a mark that says whether the group is open. The
titles stay as they are, as answered in the issue.
- A click on a band folds its group: the rows go, the band stays and
the next band moves up against it. A control in the band (Open,
+ Variable, + Property) does its own job and folds nothing.
- A placed object has a group SWITCHES under its name, asset and
parent: Enabled, Keep on scene load and the switches of a behavior.
An asset has SWITCHES and PROPERTIES, and a group without rows is
left out.
- The same bands head the tilemap panel, the Variables tab, the side
of the Pixel art tab and the shape list of vector art. A list with a
title (Layers, Frames, Shapes) folds with a click on its header,
clear of its plus.
- ui/sections.ts holds the rules as pure functions, SectionStack is
the column and SectionHeader the band. Every title of the inspector
goes through addTitle or sectionHeader in inspector/host.ts.
Decided here:
- A fold is kept for good, in localStorage of this browser. The issue
asked whether for the session or for good and the answer left it
open.
- A fold is remembered by title, per panel. PROPERTIES folded once is
folded on every object, asset and node. A number in a title does
not count.
- A behavior asset has all its rows under SWITCHES. A Variables asset
and a state machine get a group SCOPE.
- A greyed out control in a band takes the click too. Without that the
click fell through to the band and folded the group.
- The Curve tab has no column of this kind and is unchanged.
Found on the way:
- The inspector of a sprite is taller than the 800 pixel window of
the checks now. The hooks that hand out a row scroll it into view
first, and module 28 scrolls before it looks for a row by its text.
Tests:
- Gate: 202 files, 3008 tests (2982 + 26), exit 0
- e2e: no complete run for this state. Modules 28 to 61 passed alone
(749 checks). A full run was cut off in the accounts module at 1492
passes without a failure, the modules after it and the smokes are
missing. The sum of 1639 (1635 + 4) is reckoned, CI measures it.
- 26 unit tests in tests/editor/sections.test.ts
- Four e2e checks with a real mouse, three in module 02 and one in
module 48
- 25 unit counter-probes all fail tests, run on stein2
- Screenshots of the bands looked at before the last build, none
after it
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Every suite run since 50bddf6 failed the same 14 pixel checks in step
28. The test sprite sat right of the camera, where the Crate pair
container from an earlier step has its symbol. Since 50bddf6 names
stand on dark plates, and that plate reaches into the sprite's upper
left corner. The thin grey name before let the sampled pixels through.
The plate covers them.
- The sprite now sits up left of the camera (-190, -105 instead of
+180, +100), a spot no earlier step fills.
- The guard that samples a 9x9 grid over the sprite stays. It named
the plate as -0.4,-0.3:#1b2f57, the sprite blue under a 70 percent
near-black plate.
- The app is right: symbols and their names draw above objects.
Tests:
- Reproduced in the CI image with an empty data folder, GM_STEPS=1-28:
the same 14 FAILs with the same colours as CI tasks 303 to 313.
- Full CI job in the CI image after the move: ALL PASSED, 1385
checks, smokes 24 / 19 / 21, E2E EXIT 0, SMOKES EXIT 0.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A single click on an asset link used to select the asset. That swapped
the inspector over to the asset, so the object being edited was gone
and the second click of a double-click landed on a different row. A
click now pings the asset in the asset panel, like Unity does in its
Project window, and a double-click opens it.
- state.pingAsset(id) emits a new `ping` event. It runs no command and
leaves the selection alone, like openAsset.
- ListView.ping(id) draws an accent overlay on the row. It stays full
for 200 ms and is gone after 1 s, driven by onFrame. destroy cancels
the animation.
- The asset panel expands the group, scrolls to the row and pings it.
- The capture listener and followedLink in the inspector are gone.
The inspector is no longer rebuilt, so pressCount on the label is
enough. followsPress and pressOf are no longer exported from ui/.
- The "Asset" and "Part of" rows of a scene object were a ghost button
that selected the asset. They are now a link label with the same
ping and open behaviour, next to a read-only field with the name.
- The link logic lives in linkLabel() in fieldFactory.ts. createFieldRow
and the asset row share it.
- TextField gets a readOnly option: no focus, no caret, no hover.
- New hook __gm.assetPing(). inspectorLink also reports the field
next to the label (value, readOnly).
Findings:
- Renaming an asset while its object was selected left the old name
in the inspector. This was already the case with the old button.
The refresh now updates the name.
Tests:
- ./check.sh: 151 files, 2228 tests, CHECK EXIT 0 (before 150 / 2226).
New: tests/ui/list-ping.test.ts for pingAlpha and pingAsset.
- e2e 1-16,28 on the final build: ALL PASSED, 575 checks.
28l-ii has 9 checks instead of 5.
- Counter-checks, each failing exactly at its check: ping also selects,
no follow-up frame (alpha stays 0.55), inspector never opens, field
takes focus, name not refreshed, label not a link.
- Check total 1345, calculated. CI last counted 1336 at fd6af84.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A sprite's texture and shader were only reachable by hunting for them
in the asset panel. Every place that shows such a reference now leads
straight to it.
- Object menu (hierarchy and scene view): "Open texture" and "Open
shader" sit right below "Open asset" when the sprite has one. The
object's own values count, so an overridden shader opens the
override.
- Asset context menu: the same entries at the top.
- Inspector: every asset field with a value shows its label as a blue,
underlined link. One click selects the asset, a double click opens
it. An override stays gold and gets the underline too.
- The first click rebuilds the inspector, so the second one lands on a
different row. The inspector catches that second click before any
row sees it.
- New pure rule `references.ts`: the asset behind a reference field. A
frame leads to its sheet. Kinds without a tab (palette, font, sound)
get no menu entry.
- `followsPress` in `clicks.ts` keeps the double-click window and
tolerance in one place.
- `activateAsset` in `actions.ts`: opening means the same everywhere
as a double click in the asset panel, so a scene also becomes the
active one. The asset panel uses it too.
Tests:
- ./check.sh: 150 files, 2226 tests, CHECK EXIT 0 (before 148/2214).
- 12 new unit tests: 6 for the reference rule, 4 for the link in
createFieldRow, 2 for followsPress.
- 5 new e2e checks in 28l-ii: both menus, the link's look, click
selects, double click opens.
- e2e 1-16,26,28,35,39,40 on the final build: ALL PASSED, 732 checks.
- Counter-probes: no "Open ..." entries, no underline and a second
click never recognised each fail exactly at their checks.
- Check total 1341 (CI counted 1336 at fd6af84), computed.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Changing min, max or default of a property rebuilt the slider rows of
the shader preview, but the rows stayed at 0 x 0. Only the labels were
visible until the preview was resized by hand.
- Widget.resize only lays out when the size changes. The row count
stays the same on such an edit, so the row stack kept its height and
never laid out its new children.
- syncParams now relayouts the row stack itself after the column
around it.
Tests:
- e2e 28n: a new max rebuilds the slider at 136 x 24, a click at its
right end sets level 2. Red against the old build (w 0, h 0).
- GM_STEPS=28,31 fresh: ALL PASSED, 106 checks (was 105)
- ./check.sh: 132 files, 1952 tests, exit 0
- Check sum 1210 in README, computed (1209 + 1), CI measures it.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
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 suite spent 88% of its step time asleep: 564 s of steps, 496 s of
them in sleep(n) - fixed spans estimated by hand and then copied from
step to step. That is why it was slow, and why it flickered.
The signal is afterFrames(count, timeout) in ui/render.ts, exposed as
__gm.afterFrames(). It wakes the loop itself, because an editor that
draws nothing would never redeem a promise of a frame, and it gives up
after a timeout instead of hanging the run. settle() in the harness
waits two drawn frames (~32 ms) instead of 150-300 ms and falls back to
sleep(150) when the hook is missing (an old build): silently not
waiting would make every later check a coin toss.
Every action goes through it now - click, doubleClick, wheel, drag,
dragAsset, press, typeText, select, mutate, undo, redo - and with that
about 550 sleep() calls behind those actions became redundant, helpers
that are nothing but a mutate in a coat included. What a frame cannot
answer got its own condition instead: waitUntil(expr), play()/stop()
waiting on __gm.state.mode rather than on 300 ms, saved() for the
autosave (the two hand-written polling loops sampled at 300 ms for
something that arrives after a second), and showTab() as a real tool -
47 places called evaluate("__gm.showTab(...)") raw, and 24 of them
waited for nothing at all and read the old panel.
564 s -> 243 s of step time, the waiting share 88% -> 76%. Three kinds
of wait were kept, and each one bit when it was taken away: loading
bytes (textures, fonts), game time that a repaint cannot stand in for
(time scale, shader and particle clocks, the live values a running
graph carries), and the inertia the scroll step measures on purpose.
Two things the new speed uncovered, both real: 25-animation read the
frame menu unfiltered and had frozen an incomplete list (the CI failure
on c990dbc), and 33-render-on-change measured idleness while the
autosave of the step before was still painting - it waits for
saveStatus to leave dirty/saving now, so it measures that nothing
happens any more instead of that it waited long enough.
#63: a shader Property node named "width" quietly took over the sprite
row of the same name. isSpriteProp lives in PropertySchema now and is
derived from FULL_SCHEMAS.sprite instead of being a second list that
drifts. isValidPropertyName rejects those ten names, and the compiler
reports it at the node as its own problem: "width" is a sprite
property, told apart from "not a word" and from a duplicate.
#64: Divide emitted (a / b) and handed NaN to the driver. It emits
divide(a, b) now, with the guard in the prologue next to straight(),
scalar and per channel: zero divided by zero is zero. That was already
the house rule in the behavior and particle graphs - only the shader
had never been told. Clamp comes along as a shader node for the same
reason: the behavior catalog has had it all along.
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>
The default of the Edge param and the address mode the shared sheet physically
carries were the same constant; changing the value would have turned every
sentinel with it and given each shader sprite a full copy of its texture.
SHEET_EDGE ('mirror', what TextureLibrary uploads) is now separate from
DEFAULT_EDGE ('clamp'). A graph whose Sample reads nothing but its own pixel
draws with the plain sheet whatever the Edge says, so the common shader costs
no second copy.
Without a shader the quad's coordinates never leave 0..1, and mirror-repeat
reads like clamp within a texel - so clamp is what a sprite shows anyway.
A font asset holds a file and nothing else, the twin of a texture: the server keeps it
content-addressed under data/fonts/{sha256}.{ext}, with the format read from the file's own
four-byte signature (finfo reports fonts inconsistently across libmagic versions). A text
wears one through its ordinary Font property, so overrides, reset, undo and the Properties
node come for free; no font means the editor's own typeface.
The picker lists everything the server holds and renders each row in the face it offers.
The family name is gm-font-<hash16>, never the name inside the file, so two "Regular" cannot
collide. WorldViewport.syncText is the single place that draws it, which covers scene, game
preview and play at once.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
shaderFingerprint(graph) covers node id/type/params/ports and edge endpoints only;
x/y, collapsed, folded and edge ids stay out. ShaderLibrary keys on it and counts
compiles (__gm.shaderCompiles()). Measured before: one compile per preview step of a
node drag (12 for a harness drag); after: 0, a param change exactly +1.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Four new rows on sprite, text, collider and container (block TRANSFORM):
scale, rotation in degrees clockwise, mirrorX, mirrorY. As properties they
get override/reset/apply, loading of old projects, write-back after play and
the Properties node for free; camera and behavior have none.
The hierarchy composes T*R*S per level (composeAlongChain replaces
sumAlongChain), so children rotate and scale with their parent. Reparenting
and grouping keep the look (carriedTransform). Drawing goes through
placeNode, the gizmo is a polygon, cull and objectRect use the AABB, drags
are computed in the parent's system.
Colliders are oriented boxes: SAT for box/box, box-circle in the box frame;
exitDistance replaces axisSeparation/axisCorrection, moveAndSlide moves along
the object's own axes.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Fragment-side coordinate nodes instead of vertex nodes: the quad stays the
quad, a UV read plus a sample at a computed coordinate covers flip, scroll,
wave, pixelate and scanlines. Time is a uniform only when a Time node reaches
the Output; a viewport that drew a Time shader redraws from the ticker so the
Scene tab animates without a running world. Edge (Mirror/Repeat/Clamp) is a
second texture variant in TextureLibrary because WebGL binds no TextureStyle
resource of the shader. The compiler now emits #version 300 es first: Pixi
only compiles as ES 3.00 when the fragment says so, and textureSize needs it.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
A sprite's picture is now a Mesh (unit quad, sibling of its Graphics node, which keeps
only the hit area); a worn shader replaces Pixi's fragment in that draw, so there is no
intermediate texture and no extra render pass. Measured on the HD 630: 400 shaded
sprites hold 60 fps (was 34), 800 hold 56 (was 20).
Two Pixi facts the code leans on: a mesh's texture is bound only for Pixi's own shader,
so GraphShader.wear hands it over; and MeshPipe caches whether a mesh batches and
re-checks it only in a frame without structure changes, so switching a shader on or
off calls mesh.unload().
The console panel mirrors console.group/groupCollapsed/groupEnd and fills the %s %d
%o %c directives, so Pixi's deprecation warning shows its head, not only its stack.
E2E 12 guards against the addChild-on-Graphics warning, 28 counts shaders instead of
filters.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The wiki carried an estimate ("fine for dozens, not for thousands"). Measured
on the Intel HD 630 that CI uses, 48x48 sprites in a 640x360 camera, neutral
values, three runs agreeing to 1 fps:
sprites 25 50 100 200 400 800
fps 58 56 54 48 34 20
The same scene without shaders holds 60 fps at every one of those counts, 800
included. Quadrupling the sprite area (96x96) measures the same, so the cost is
the render target switch, not the pixels: roughly 20 us per pass up to 200,
40 us at 800.
tools/perf-shaders.mjs drives it (not run in CI, frame times are machine
dependent). New hook __gm.filterCount() counts visible nodes carrying a filter
and proves one pass per sprite; e2e 28 checks it.
Two traps the tool guards against, both cost a wrong answer first: the median
frame time is useless here (the distribution is bimodal under vsync, so the
rate comes from frames/duration), and --disable-gpu-vsync has no effect in
headless, so what is measured is the point where the frame rate breaks down.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
No input, one Value param (step 0.05, drawn on the node), output `value`
as a float literal via floatLiteral (2 -> 2.0, NaN -> 0.0). No uniform,
no sprite row - that is the difference to Property.
The e2e negative check for "no behavior node in the palette" now uses
Boolean as its marker, and step 28 types the palette search first
because the palette scrolls from twelve entries on.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Output gains a number input `alpha`; unconnected it reads the texel's
alpha, like `color` reads the texel. The epilogue premultiplies with the
resolved alpha. New `Texture alpha` node exposes texel.a for maths.
PortDef.inline=false keeps the alpha port without an inline field: every
number port is inline by default, and a typed default of 0 would have made
every shaded sprite invisible. The compiler's texel fallback is typed:
colour ports get rgb, number ports get texel.a.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The shipped shader graph gains a `brightness - 1` offset node, so 1 stays
neutral and brightness lifts every channel by the same amount; contrast keeps
multiplying, saturation keeps mixing with the luma. Fixture, seeds, unit and
e2e expectations follow (twelve nodes, 1.25 -> #80c0ff).
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
A shader is an asset carrying a node graph (Texture color, Output, Property,
Color, Add/Subtract/Multiply/Divide, Mix, Luminance) that compiles to the GLSL
fragment of a Pixi filter. The Shader tab reuses the Behavior canvas; a sprite
wears a shader through its Shader picker, and the rows below it are the
graph's Property nodes. Property schema, resolution and deviation take the
project because a sprite's rows now live in another asset. The built-in
registry of the prototype is gone; "Adjust colors" ships as a graph in the
fixture and the three example games.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>