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>
A text could not wear a shader, and a pixel font was always smoothed.
Pixi's Text takes no custom shader. It also shares one texture between
texts of the same content and style, and the filter is not part of that
key, so two equal texts with different filters would overwrite each
other.
- TextPicture rasters a text into a texture of its own and draws it
like a sprite. Shader, time animation and edge mode work without a
special path. A new text, font or size rasters again and keeps the
shader.
- The old texture is dropped only after the shader holds the new one.
Before, Pixi warned about a destroyed texture that was still bound.
- A text has a Shader row with the shader's property rows below, as a
sprite has. Overrides, saving, animation and the Properties node
(type Text) know them. A shader parameter named like an own row of
the text loses to that row, as on the particle source.
- The font asset has a Filter row, Linear or Nearest. The row existed
three times already (texture, vector art, pixel art) and is now one
helper filterRow.
- Nearest rasters the text 8 times finer and cuts every 8x8 block to
one texel: at least half covered is full, else empty. Chrome on
Windows smooths canvas text sideways through DirectWrite whatever
filter we set later. The cut removes those half pixels on every
system, and the texture stays one texel per world unit.
Found on the way:
- On a font that is no pixel font thin strokes break apart under
Nearest. For a pixel font at its own size this does not matter.
Not done:
- The preview in the Shader tab does not take a selected text as its
picture, it shows the test picture then.
Tests:
- ./check.sh: 189 files, 2759 tests, CHECK EXIT 0.
- text-shader.test.ts with 10 tests: rows and name precedence, a
clamped override, save and load, animation, the font filter, and
sharpCoverage on a smoothed edge and at the texture border.
- e2e 30 has 7 new checks: red glyphs through the shader picked with
the mouse, the shader survives a new text, red in the game, soft
edges under Linear, clean 4x4 blocks under Nearest, and half covered
texels (Linear 238, Nearest 0).
- Full local run before the Nearest cut: all passed, 1543 checks.
GM_STEPS=1,30 on the final bundle: all passed, 32 checks. The sum
1544 in the README is counted from these two runs, not measured.
- Counter-checks fail as expected: in the browser no shader on a text
fails 30h-30j, an always linear sampler fails the Nearest check, the
old 1:1 raster leaves 107 half covered texels and fails 30k-ii.
Co-Authored-By: Claude Opus 5.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.
`Variables` pushed `Font` under the menu's mask edge: `locate` still finds the entry, a click there
lands on nothing, `__gm.fontPicker()` is null and reading `.captions` throws. Only step 31 carried
the scroll workaround, so step 30 fell. The helper moves to `lib/scaffold.mjs` as `pickAssetType()`
and both steps use it.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
An upload sends its file name next to the raw bytes, and the server keeps
it in a sidecar next to the content-addressed file. Both pickers caption
every tile and row with it, and an asset made there is named after the
file instead of "Texture" / "Font". Last upload of the same content wins.
The API's own set_error_handler ignores error_reporting(), so @-silenced
warnings still throw: a missing sidecar took the whole listing down with a
500. Existence is checked instead of suppressed.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
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>