Shader ohne Extra-Pass: das Fragment in den Zeichenweg des Sprites statt in einen Filter #77

Closed
opened 2026-09-13 14:38:55 +02:00 by meleki · 1 comment
Owner

Befund (gemessen in #67, tools/perf-shaders.mjs)

Jedes Sprite mit Shader traegt einen eigenen Pixi-Filter (syncShader in src/viewport/symbols.ts), also einen Render-Pass in eine Zwischentextur und zurueck. Gemessen auf der Intel HD 630, 48x48-Sprites in einer 640x360-Kamera, neutrale Werte, drei Laeufe auf 1 fps gleich:

beschattete Sprites 25 50 100 200 400 800
fps 58 56 54 48 34 20

Dieselbe Szene ohne Shader haelt bei jeder dieser Zahlen 60 fps, 800 eingeschlossen.

Der Preis ist der Pass, nicht die Fragment-Arbeit. Vierfache Sprite-Flaeche (96x96) misst dasselbe: 51,6 / 49,5 / 34,2 fps bei 100 / 200 / 400 gegen 53,2 / 48,5 / 34,5 bei 48x48. Es kostet der Wechsel des Render-Ziels, nicht das Rechnen pro Pixel.

Der in #67 angedachte Weg "Sprites teilen sich einen Filter" bringt deshalb nichts. Das GLSL-Programm ist bereits geteilt (GlProgram.from cached nach Quelltext, ein Programm fuer alle Sprites); geteilt wuerden nur die Filter-Objekte. Pixi legt trotzdem fuer jeden Container mit filters einen eigenen Pass an.

Aufgabe

Das kompilierte Fragment dorthin bringen, wo das Sprite ohnehin gezeichnet wird, statt in einen nachgelagerten Filter — dann gibt es gar keinen Extra-Pass.

Zu klaeren, bevor gebaut wird:

  • Sprites sind heute keine Pixi-Sprites, sondern Graphics-Rechtecke mit Texturfuellung (symbols.ts). Ein eigener Fragment-Shader pro Fuellung ist in dieser Bauweise nicht vorgesehen — der Umbau geht vermutlich ueber Mesh mit eigenem Shader (oder einen eigenen Renderable), und das beruehrt Szene- und Spielansicht, weil beide denselben Zeichenweg teilen (WorldViewport.redraw).
  • Der Prelude/Epilog des Compilers (src/core/shader/compile.ts) rechnet heute mit premultiplizierten Pixeln, weil ein Filter sie so bekommt. Im Zeichen-Shader liegt die Farbe anders vor; der Compiler braucht dann zwei Rahmen oder einen neutralen.
  • Batching: Sprites mit verschiedenen Shadern koennen nicht im selben Draw-Call liegen. Der Gewinn haengt daran, dass gleiche Shader zusammen gezeichnet werden.

Beweis beim Schliessen

  • node tools/seed-e2e.mjs && node tools/perf-shaders.mjs zeigt bei 400 und 800 beschatteten Sprites die Baseline-Rate (rund 60 fps) statt 34 / 20.
  • e2e-Modul 28 bleibt gruen: dieselben Pixel wie heute (brightness 0.5 -> #204080, contrast 0 -> #808080, neutral = byte-gleich mit "kein Shader"), und Pixel-Art bleibt bei gebrochenem Kamera-Zoom scharf (Modul 12).
  • Der Check "a shaded sprite carries one filter" in Modul 28 beschreibt dann den alten Zustand und muss mitwandern.
## Befund (gemessen in #67, `tools/perf-shaders.mjs`) Jedes Sprite mit Shader traegt einen eigenen Pixi-`Filter` (`syncShader` in `src/viewport/symbols.ts`), also einen Render-Pass in eine Zwischentextur und zurueck. Gemessen auf der Intel HD 630, 48x48-Sprites in einer 640x360-Kamera, neutrale Werte, drei Laeufe auf 1 fps gleich: | beschattete Sprites | 25 | 50 | 100 | 200 | 400 | 800 | | --- | --- | --- | --- | --- | --- | --- | | fps | 58 | 56 | 54 | 48 | 34 | 20 | Dieselbe Szene **ohne** Shader haelt bei jeder dieser Zahlen 60 fps, 800 eingeschlossen. **Der Preis ist der Pass, nicht die Fragment-Arbeit.** Vierfache Sprite-Flaeche (96x96) misst dasselbe: 51,6 / 49,5 / 34,2 fps bei 100 / 200 / 400 gegen 53,2 / 48,5 / 34,5 bei 48x48. Es kostet der Wechsel des Render-Ziels, nicht das Rechnen pro Pixel. **Der in #67 angedachte Weg "Sprites teilen sich einen Filter" bringt deshalb nichts.** Das GLSL-Programm ist bereits geteilt (`GlProgram.from` cached nach Quelltext, ein Programm fuer alle Sprites); geteilt wuerden nur die Filter-*Objekte*. Pixi legt trotzdem fuer jeden Container mit `filters` einen eigenen Pass an. ## Aufgabe Das kompilierte Fragment dorthin bringen, wo das Sprite ohnehin gezeichnet wird, statt in einen nachgelagerten Filter — dann gibt es gar keinen Extra-Pass. Zu klaeren, bevor gebaut wird: - Sprites sind heute **keine** Pixi-`Sprite`s, sondern `Graphics`-Rechtecke mit Texturfuellung (`symbols.ts`). Ein eigener Fragment-Shader pro Fuellung ist in dieser Bauweise nicht vorgesehen — der Umbau geht vermutlich ueber `Mesh` mit eigenem Shader (oder einen eigenen Renderable), und das beruehrt Szene- *und* Spielansicht, weil beide denselben Zeichenweg teilen (`WorldViewport.redraw`). - Der Prelude/Epilog des Compilers (`src/core/shader/compile.ts`) rechnet heute mit **premultiplizierten** Pixeln, weil ein Filter sie so bekommt. Im Zeichen-Shader liegt die Farbe anders vor; der Compiler braucht dann zwei Rahmen oder einen neutralen. - Batching: Sprites mit verschiedenen Shadern koennen nicht im selben Draw-Call liegen. Der Gewinn haengt daran, dass gleiche Shader zusammen gezeichnet werden. ## Beweis beim Schliessen - `node tools/seed-e2e.mjs && node tools/perf-shaders.mjs` zeigt bei 400 und 800 beschatteten Sprites die Baseline-Rate (rund 60 fps) statt 34 / 20. - e2e-Modul 28 bleibt gruen: dieselben Pixel wie heute (`brightness 0.5` -> `#204080`, `contrast 0` -> `#808080`, neutral = byte-gleich mit "kein Shader"), und Pixel-Art bleibt bei gebrochenem Kamera-Zoom scharf (Modul 12). - Der Check "a shaded sprite carries one filter" in Modul 28 beschreibt dann den alten Zustand und muss mitwandern.
meleki added the shader label 2026-09-13 14:38:55 +02:00
admin added the high-priority label 2026-09-13 14:40:33 +02:00
admin added this to the Development project 2026-09-13 14:40:36 +02:00
admin moved this to Next in Development on 2026-09-13 14:40:44 +02:00
Author
Owner

Gebaut (Commit 0e5e5ef, Wiki 01d0602)

Ein Sprite ist jetzt ein Mesh (Unit-Quad, Pivot 0,5, Scale = Groesse) als Geschwister seines Graphics-Knotens unter demselben Parent; der Knoten traegt nur noch die HitArea. Ein getragener Shader ist ein GraphShader extends Shader mit eigenem Vertex-Stage, der Pixis Fragment im Zeichenweg des Sprites ersetzt — keine Zwischentextur, kein Filter, kein Extra-Pass. Der Compiler-Rahmen bleibt premultipliziert: texel = texture(...) * vColor (Tint, premultipliziert wie das Texel) vor dem Graphen, entpremultiplizieren, Graph, zurueckmultiplizieren.

Die drei Klaerungen aus dem Issue, gemessen: (1) Graphics mit Texturfuellung ist ersetzt, nicht erweitert — Pixi 8 erlaubt Kinder nur an reinen Containern (allowChildren false, sonst die Deprecation-Warnung), deshalb Geschwister statt Kind; Szene und Spiel teilen den Weg (WorldViewport.syncSprite). (2) Ein Rahmen genuegt, weil vColor premultipliziert eingeht. (3) Batching: ein beschattetes Sprite ist ein eigener Draw-Call, die plain Sprites bleiben im Batch — das ist der ganze verbleibende Preis.

Messung (tools/perf-shaders.mjs, HD 630, 48x48 in 640x360, neutrale Werte, drei Laeufe auf 2 fps gleich)

Sprites 50 100 200 400 800 1600 3200
alle mit Shader 60 60 60 60 56 34 17
dieselbe Szene ohne 60 60 60 60 60 39 21
vorher (Filter-Pass, #67) 56 54 48 34 20 - -

Bis 400 kostet ein Shader nichts Messbares, bei 800 rund 1,5 us pro Sprite, ab 1600 rund 3 us — dort verpasst schon die shaderlose Szene Takte. shaders == n, filters == 0 an jedem Punkt (neuer Hook __gm.shaderCount()).

Beweis

  • ./check.sh: === CHECK EXIT 0 ===, 854 Tests (850 + 4: Konsolen-Direktiven, Gruppen)
  • e2e Module 10, 12, 25, 27, 28: ALL PASSED – 136 Checks; Actor-Smoke 21. Modul 28 misst dieselben Pixel wie zuvor (brightness 0.5 → #204080, contrast 0 → #808080, neutral byte-gleich mit „kein Shader"), Modul 12c-ii: Nearest-Kante bei Skala 1,43 pixelgleich durch den Shader. Der Check „a shaded sprite carries one filter" ist mitgewandert: jetzt shaders 1 / filters 0 an, 0 / 0 aus.
  • Gegenproben: Mesh wieder als Kind der Graphics → neue Wache in Modul 12 faellt mit der Kopfzeile PixiJS Deprecation Warning: addChild: …; ohne mesh.unload() beim Wechsel → 7 FAIL (28e on→off→on, 12c-ii), mit → gruen.

Zwei Pixi-Fallen, die den Bau gepraegt haben

  1. Ein Mesh bindet seine Textur nur fuer Pixis eigenen Shader — GraphShader.wear setzt uTexture/uSampler selbst.
  2. MeshPipe cached batched in meshData und prueft es nur in validateRenderable, das Pixi in jedem Frame mit Strukturaenderung ueberspringt — und syncDrawOrder haengt bei jedem Redraw alles neu an. Ein einmal gebatchtes Mesh zeichnete danach fuer immer plain, Shader ignoriert. syncShader ruft beim Wechsel getragen↔nicht getragen mesh.unload().

Nebenbei: Pixis Deprecation-Warnung kam per console.groupCollapsed mit %c-Direktiven — das Konsolen-Panel spiegelt jetzt group/groupCollapsed/groupEnd und fuellt %s %d %i %f %o %O %c %%, vorher fehlte der Kopf der Meldung.

Wiki Shaders: Tabelle und Abschnitt „In the code" auf den Mesh-Weg umgeschrieben. Zaehler 730 (Basis: CI-Lauf 19 mit 728 gemessen). Das Messwerkzeug laeuft weiterhin nicht in CI.

## Gebaut (Commit `0e5e5ef`, Wiki `01d0602`) **Ein Sprite ist jetzt ein `Mesh`** (Unit-Quad, Pivot 0,5, Scale = Groesse) als Geschwister seines `Graphics`-Knotens unter demselben Parent; der Knoten traegt nur noch die HitArea. Ein getragener Shader ist ein `GraphShader extends Shader` mit eigenem Vertex-Stage, der Pixis Fragment **im Zeichenweg des Sprites** ersetzt — keine Zwischentextur, kein Filter, kein Extra-Pass. Der Compiler-Rahmen bleibt premultipliziert: `texel = texture(...) * vColor` (Tint, premultipliziert wie das Texel) vor dem Graphen, entpremultiplizieren, Graph, zurueckmultiplizieren. Die drei Klaerungen aus dem Issue, gemessen: (1) `Graphics` mit Texturfuellung ist ersetzt, nicht erweitert — Pixi 8 erlaubt Kinder nur an reinen Containern (`allowChildren` false, sonst die Deprecation-Warnung), deshalb Geschwister statt Kind; Szene und Spiel teilen den Weg (`WorldViewport.syncSprite`). (2) Ein Rahmen genuegt, weil `vColor` premultipliziert eingeht. (3) Batching: ein beschattetes Sprite ist ein eigener Draw-Call, die plain Sprites bleiben im Batch — das ist der ganze verbleibende Preis. ## Messung (`tools/perf-shaders.mjs`, HD 630, 48x48 in 640x360, neutrale Werte, drei Laeufe auf 2 fps gleich) | Sprites | 50 | 100 | 200 | 400 | 800 | 1600 | 3200 | | --- | --- | --- | --- | --- | --- | --- | --- | | alle mit Shader | 60 | 60 | 60 | 60 | 56 | 34 | 17 | | dieselbe Szene ohne | 60 | 60 | 60 | 60 | 60 | 39 | 21 | | vorher (Filter-Pass, #67) | 56 | 54 | 48 | 34 | 20 | - | - | Bis 400 kostet ein Shader nichts Messbares, bei 800 rund 1,5 us pro Sprite, ab 1600 rund 3 us — dort verpasst schon die shaderlose Szene Takte. `shaders == n`, `filters == 0` an jedem Punkt (neuer Hook `__gm.shaderCount()`). ## Beweis - `./check.sh`: `=== CHECK EXIT 0 ===`, **854 Tests** (850 + 4: Konsolen-Direktiven, Gruppen) - e2e Module 10, 12, 25, 27, 28: **ALL PASSED – 136 Checks**; Actor-Smoke **21**. Modul 28 misst dieselben Pixel wie zuvor (brightness 0.5 → `#204080`, contrast 0 → `#808080`, neutral byte-gleich mit „kein Shader"), Modul 12c-ii: Nearest-Kante bei Skala 1,43 pixelgleich durch den Shader. Der Check „a shaded sprite carries one filter" ist mitgewandert: jetzt `shaders 1 / filters 0` an, `0 / 0` aus. - Gegenproben: Mesh wieder als Kind der Graphics → neue Wache in Modul 12 faellt mit der Kopfzeile `PixiJS Deprecation Warning: addChild: …`; ohne `mesh.unload()` beim Wechsel → 7 FAIL (28e on→off→on, 12c-ii), mit → gruen. ## Zwei Pixi-Fallen, die den Bau gepraegt haben 1. **Ein Mesh bindet seine Textur nur fuer Pixis eigenen Shader** — `GraphShader.wear` setzt `uTexture`/`uSampler` selbst. 2. **`MeshPipe` cached `batched` in `meshData` und prueft es nur in `validateRenderable`**, das Pixi in jedem Frame mit Strukturaenderung ueberspringt — und `syncDrawOrder` haengt bei jedem Redraw alles neu an. Ein einmal gebatchtes Mesh zeichnete danach fuer immer plain, Shader ignoriert. `syncShader` ruft beim Wechsel getragen↔nicht getragen `mesh.unload()`. Nebenbei: Pixis Deprecation-Warnung kam per `console.groupCollapsed` mit `%c`-Direktiven — das Konsolen-Panel spiegelt jetzt `group`/`groupCollapsed`/`groupEnd` und fuellt `%s %d %i %f %o %O %c %%`, vorher fehlte der Kopf der Meldung. Wiki *Shaders*: Tabelle und Abschnitt „In the code" auf den Mesh-Weg umgeschrieben. Zaehler 730 (Basis: CI-Lauf 19 mit 728 gemessen). Das Messwerkzeug laeuft weiterhin nicht in CI.
admin moved this to Done in Development on 2026-09-13 15:34:37 +02:00
Sign in to join this conversation.
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: meleki/feuerwerk#77