Frame edge bleeding #47

Closed
opened 2026-09-12 15:03:59 +02:00 by meleki · 1 comment
Owner

Ein Sprite, das ein Frame aus einem Sheet zeigt, zeichnet am Rand eine Pixelzeile, die zum
Nachbarframe gehört. Sichtbar im Projekt Actor demo als heller Streifen über dem Kopf.

Gemessen (__gm.pixelAt, Spielansicht): Beim Stehen nach unten (Sheet-Zeile 0, deren oberste
Pixelzeile in der Datei komplett leer ist) liegt dort #394349 – bläulich. Das passt zur
untersten Sheet-Zeile (Schuhe von Character 6, (87,87,154)) über den grünen Hintergrund
gemischt, die Kante bricht also auf die Gegenseite um. Beim Laufen nach rechts (Zeile 2) ist der
Streifen stattdessen hellgrau – die Füße aus Zeile 1 direkt darüber.

Ursache: frameFillMatrix (src/viewport/textureFill.ts) bildet die ganze Textur auf die Shape
ab, weil Pixi-v8-Graphics-Fills Texture.frame ignorieren. Eine Fill-Matrix kann keine Grenze
ausdrücken, also sampelt die Randzeile in den Nachbarn, sobald die Skalierung nicht ganzzahlig ist
(die Spielansicht streckt 640×360 auf etwa 735×409, Faktor ~1,13).

Nicht die Ursache: die Frame-Rechtecke. Die sind exakt ganzzahlig; den fraktionalen Fall behandelt
normalizeFrameRect bereits.

Abzuklären: halber Pixel Einzug in der Matrix (kostet einen halben Pixel Bildinhalt, trifft jedes
Frame) oder ein echtes Sprite mit new Texture({source, frame}) statt des Graphics-Fills (Pixi
klemmt dann selbst, ändert aber den Zeichenweg für alle Sprites).


Übernommen aus todo-notes/frame-edge-bleeding.md beim Umzug auf das Issue-System (2026-09-12).

  • Angelegt: 2026-09-12 · e147dd6 — Add a four-direction walk demo that seeds its own sprite sheet
  • Gemeldet von: Claude (Assistent)
Ein Sprite, das ein Frame aus einem Sheet zeigt, zeichnet am Rand eine Pixelzeile, die zum Nachbarframe gehört. Sichtbar im Projekt *Actor demo* als heller Streifen über dem Kopf. Gemessen (`__gm.pixelAt`, Spielansicht): Beim Stehen nach unten (Sheet-Zeile 0, deren oberste Pixelzeile in der Datei komplett leer ist) liegt dort `#394349` – bläulich. Das passt zur *untersten* Sheet-Zeile (Schuhe von Character 6, `(87,87,154)`) über den grünen Hintergrund gemischt, die Kante bricht also auf die Gegenseite um. Beim Laufen nach rechts (Zeile 2) ist der Streifen stattdessen hellgrau – die Füße aus Zeile 1 direkt darüber. Ursache: `frameFillMatrix` (`src/viewport/textureFill.ts`) bildet die **ganze** Textur auf die Shape ab, weil Pixi-v8-Graphics-Fills `Texture.frame` ignorieren. Eine Fill-Matrix kann keine Grenze ausdrücken, also sampelt die Randzeile in den Nachbarn, sobald die Skalierung nicht ganzzahlig ist (die Spielansicht streckt 640×360 auf etwa 735×409, Faktor ~1,13). Nicht die Ursache: die Frame-Rechtecke. Die sind exakt ganzzahlig; den fraktionalen Fall behandelt `normalizeFrameRect` bereits. Abzuklären: halber Pixel Einzug in der Matrix (kostet einen halben Pixel Bildinhalt, trifft jedes Frame) oder ein echtes `Sprite` mit `new Texture({source, frame})` statt des Graphics-Fills (Pixi klemmt dann selbst, ändert aber den Zeichenweg für alle Sprites). --- *Übernommen aus `todo-notes/frame-edge-bleeding.md` beim Umzug auf das Issue-System (2026-09-12).* - **Angelegt:** 2026-09-12 · `e147dd6` — Add a four-direction walk demo that seeds its own sprite sheet - **Gemeldet von:** Claude (Assistent)
meleki added the todo-note label 2026-09-12 15:03:59 +02:00
Author
Owner

Closed with commit 05f7a27, wiki c5bc04d.

The diagnosis in this note was not the cause

Neither non-integer scaling alone nor either of the two suggested fixes would have done it. Two mechanisms were measured, and both had to go:

  1. MSAA extrapolation. antialias: true is on. When a frame edge lands inside a screen pixel, that pixel is partly covered, but the fragment shader samples at the pixel centre, which is outside the frame. Provoked on the right-facing pose of the actor sheet: at edge fraction 0.604 the edge row reads #4b4d4d #7c807f #8e9191 (the boots of the cell above, 40 % over the background); at 0.227 and 0.375 nothing shows. So the hairline depends on the subpixel position and flickers while walking. A real Sprite with Texture({frame}) would show the same at inner frame edges, since Pixi only clamps at the sheet border.

  2. Pixi rewrites the address mode of a Graphics fill texture. generateTextureFillMatrix.mjs:47 sets the shared source to repeat at draw time, unconditionally (gradients aside), although the style default is clamp-to-edge. Measured on the GPU: wrap: "repeat". That is why the top row of sheet row 0 read the bottom sheet row, which is the grey band above character 1 in the screenshot: the boots and cloak of character 5 (up-facing, row 7).

The actor sheet makes it visible because every cell has content in its bottom row (feet and shadow down to row 47) while the top row is nearly always empty, so practically only the top edge can bleed.

What was built

A frame is a GPU texture of its own. TextureLibrary.get(ref) cuts it out of the loaded sheet with an OffscreenCanvas on first use and uploads the cut (key file|x,y,w,h, kept for the session, like the files themselves). Address mode is mirror-repeat: Pixi's rewrite only triggers on exactly clamp-to-edge, and within one texel of the border mirror behaves identically to clamp, for linear as well as nearest. Loaded sheets get it too, since whole pictures wrapped at their own borders the same way. viewport/textureFill.ts (frameFillMatrix) and its test are gone; spriteFill is a one-liner. Sheets need no padding between cells.

Price, named: one small texture per frame rectangle in use (48x48 is about 9 KB), and a frame drag in the Texture tab makes one cut per mouse step. Deliberately not reclaimed.

Measured, not claimed

./check.sh === CHECK EXIT 0 ===, 849 tests (855 minus the six matrix tests)
e2e module 25 ALL PASSED - 36 checks (+2): left and right frame edge, moved onto fraction 0.75 / 0.25 exactly, show only green over background; red/blue rise only in proportion to the cell colour
actor smoke ALL PASSED - 21 checks (+1): top edge at fraction 0.85, up-facing pose, edge row equals the background row
validity nothing in src/tools/tests newer than the bundle; rebuild after the counterproofs is md5-identical
looked at magnified crop of the right-facing pose at fraction 0.818: clean hairline, nothing above it

Counterproofs, each biting at its own spot: the old draw path (src/ stashed, rebuilt) fails both edge checks with (83,43,48) red on the left and (43,55,93) blue on the right; cutting with clamp-to-edge fails the smoke check, the edge showing #555f59 #5e6762 ..., which is the pose's own boots, wrapped.

Two wrong turns of mine

  • objectRect/gameRect return centre plus w/h, not the corner, so my first probe measured three rows too low and "found" nothing.
  • My fixed +6 threshold for the right edge was wrong, not the product: green itself carries blue 90, so the edge pixel sat 11 above the background. The check now uses the ratio rule, which a neighbour bleed violates tenfold.

Counters: e2e 725, actor smoke 21 (README, wiki Development). Wiki Textures explains the mechanism, Architecture no longer lists textureFill.

Closed with commit `05f7a27`, wiki `c5bc04d`. ## The diagnosis in this note was not the cause Neither non-integer scaling alone nor either of the two suggested fixes would have done it. Two mechanisms were measured, and both had to go: 1. **MSAA extrapolation.** `antialias: true` is on. When a frame edge lands inside a screen pixel, that pixel is partly covered, but the fragment shader samples at the pixel centre, which is *outside* the frame. Provoked on the right-facing pose of the actor sheet: at edge fraction **0.604** the edge row reads `#4b4d4d #7c807f #8e9191` (the boots of the cell above, 40 % over the background); at 0.227 and 0.375 nothing shows. So the hairline depends on the subpixel position and flickers while walking. A real `Sprite` with `Texture({frame})` would show the same at inner frame edges, since Pixi only clamps at the *sheet* border. 2. **Pixi rewrites the address mode of a Graphics fill texture.** `generateTextureFillMatrix.mjs:47` sets the shared source to `repeat` at draw time, unconditionally (gradients aside), although the style default is `clamp-to-edge`. Measured on the GPU: `wrap: "repeat"`. That is why the top row of sheet row 0 read the *bottom* sheet row, which is the grey band above character 1 in the screenshot: the boots and cloak of character 5 (up-facing, row 7). The actor sheet makes it visible because every cell has content in its bottom row (feet and shadow down to row 47) while the top row is nearly always empty, so practically only the top edge can bleed. ## What was built **A frame is a GPU texture of its own.** `TextureLibrary.get(ref)` cuts it out of the loaded sheet with an `OffscreenCanvas` on first use and uploads the cut (key `file|x,y,w,h`, kept for the session, like the files themselves). Address mode is **`mirror-repeat`**: Pixi's rewrite only triggers on exactly `clamp-to-edge`, and within one texel of the border mirror behaves identically to clamp, for `linear` as well as `nearest`. Loaded sheets get it too, since whole pictures wrapped at their own borders the same way. `viewport/textureFill.ts` (`frameFillMatrix`) and its test are gone; `spriteFill` is a one-liner. Sheets need no padding between cells. Price, named: one small texture per frame rectangle in use (48x48 is about 9 KB), and a frame drag in the Texture tab makes one cut per mouse step. Deliberately not reclaimed. ## Measured, not claimed | | | |---|---| | `./check.sh` | `=== CHECK EXIT 0 ===`, **849 tests** (855 minus the six matrix tests) | | e2e module 25 | **ALL PASSED - 36 checks** (+2): left and right frame edge, moved onto fraction 0.75 / 0.25 exactly, show only green over background; red/blue rise only in proportion to the cell colour | | actor smoke | **ALL PASSED - 21 checks** (+1): top edge at fraction 0.85, up-facing pose, edge row equals the background row | | validity | nothing in `src`/`tools`/`tests` newer than the bundle; rebuild after the counterproofs is md5-identical | | looked at | magnified crop of the right-facing pose at fraction 0.818: clean hairline, nothing above it | **Counterproofs, each biting at its own spot:** the old draw path (`src/` stashed, rebuilt) fails both edge checks with `(83,43,48)` red on the left and `(43,55,93)` blue on the right; cutting with `clamp-to-edge` fails the smoke check, the edge showing `#555f59 #5e6762 ...`, which is the pose's *own* boots, wrapped. ## Two wrong turns of mine - `objectRect`/`gameRect` return centre plus w/h, not the corner, so my first probe measured three rows too low and "found" nothing. - My fixed `+6` threshold for the right edge was wrong, not the product: green itself carries blue 90, so the edge pixel sat 11 above the background. The check now uses the ratio rule, which a neighbour bleed violates tenfold. Counters: e2e 725, actor smoke 21 (README, wiki *Development*). Wiki *Textures* explains the mechanism, *Architecture* no longer lists `textureFill`.
admin added this to the Development project 2026-09-13 13:43:19 +02:00
admin moved this to Done in Development on 2026-09-13 13:44:06 +02:00
Sign in to join this conversation.
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: meleki/feuerwerk#47