17
0
mirror of https://github.com/MaSzyna-EU07/maszyna.git synced 2026-10-17 10:59:20 +02:00
Files
maszyna/binary-scene-format-plan.md
maj00r 2660d14565 Stream the scenery: a baked terrain heightfield, a logic container, and prefabs
Today's work on the binary scene format plan (binary-scene-format-plan.md), from the
first measurement to a terrain that bakes itself on first run and streams from a
thread. The step-by-step history is kept on backup/scene-streaming-format-presquash-0916.

Measuring first. -loadprofile times a scenery load by stage and by node kind and reports
node counts and resident memory. On l204 terrain is 95% of the bytes and 99.8% of the
nodes; on quarkmce2007 vehicles, not models, dominate what is left. The terrain turned
out to be a heightfield in all but name, a ribbon covering 12% of its bounding box.

The heightfield. scene/heightfieldformat.h is the .ehf layout: Morton-ordered tiles
of 128 samples with one shared edge sample, a point-sampled mip chain, zstd per tile,
a nodata sentinel, a material palette with a measured tiling per material, and a stamp
of the rules that decided what counted as terrain. scene/heightfieldreader.h reads it
for the tool, the renderer and the loader alike, and scene/terraincooker.h writes it,
shared by the terraincook tool and the engine so a first-run bake is the file the tool
would have made. The cooker rasterises every triangle where it is drawn (through
scene::place_in_world, the placement the region applies too), keeps an upper surface
laid within half a metre over another and the lower of two further apart, and then
asks each triangle whether the heightfield shows it: laid, reaching a sample, keeping
nearly all it claimed, and with the ground around it drawn at every level out to 2 km.

Baking on first run. simulation/terrainbake bakes <scenario>.ehf the first time a
scenery is opened, choosing the grid step from the first 200,000 vertices, and on later
loads leaves out the triangles its node table says the heightfield shows. Everything
else - trees, grass tufts, gantries, bridge decks - is drawn as written, so a bad bake
can at worst draw something twice, and the heightfield sits 2 cm under the triangles so
the original wins there. Nodes are keyed by file, line, include parameters and
placement; an edited file changes its keys and those nodes are drawn again. A bake under
older rules, or without its table, is thrown away and redone. A launcher copy named
$scenario shares the scenario's files. -nobaketerrain shows the triangles as written.
A scenery can still name hand-cooked fields, several at different steps.

Drawing and streaming. rendering/terrainclipmap draws resident tiles at the mip level
their distance earns, deriving the grid from gl_VertexID and texturing in world space
with the scenery's own materials. rendering/terraintileloader reads and decodes tiles on
a thread of its own; the renderer rescans what is in range only after moving a quarter
of a tile, asks for what is missing nearest first, uploads at most 32 tiles a frame,
drops arrivals the camera has moved past, and keeps drawing a tile at the level it has
until the right one arrives. The terrain has its own range, terrainrange in the ini and
a slider in the debug panel, 20 km by default and independent of basedrawrange; the far
plane of an ordinary view is pushed out to it. The debug panel shows the range, tiles in
view, on the gpu, wanted and queued, and how many sit at each level.

Events. The event queue moved out of the events into a binary heap with a deterministic
tie-break, and an event is handed its activator instead of storing it, so basic_event
is a definition and nothing else.

The logic container. scene/logicformat.h and scene/logiccontainer write a scenery's
events, their targets and sibling chains as flat, deterministic arrays with a schema
hash that refuses a mismatched build. simulation/logiccook cooks it from the loaded
state whenever it is missing, stale or unreadable, reads it straight back to check it,
and resolves each target to a position in the registry its event kind searches.
-cooklogic forces a cook, -verifylogic only checks. Nothing reads it at load yet.

Prefabs. scene/prefab describes a signal by the aspects it can show - lights, speeds,
animation channels, a sound - instead of pasting an .inc per variant, and expands an
instance to ordinary scenery text. Tried on a light signal and a shape signal.

Screenshots without a person present: -shot x,y,z[,yaw[,pitch]], -shotsettle, -shotstay.
2026-09-16 23:18:09 +02:00

774 lines
36 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Binarny format scenerii pod streaming — plan działania
Dokument roboczy. Cel docelowy: format scenerii, który mapuje się do pamięci i trafia
do GPU bez transformacji danych, z niezależnym streamingiem terenu i obiektów,
gotowy pod renderer Vulkan.
Plan jest ułożony tak, że **każda faza daje wymierną korzyść samodzielnie**.
Fazy 0–4 nie wymagają żadnej decyzji o formacie binarnym ani o rendererze —
jeśli przedsięwzięcie stanie po drodze, to co zrobione, i tak zostaje na plusie.
## Pomiary wejściowe (2026-09-16)
Zmierzone na zbiorze `../pctga` (124 scenariusze, 3829 plików `.inc`, `scenery/` = 7,0 GB)
oraz na instalce Steam. Liczby są powodem, dla którego kolejność faz poniżej wygląda
tak, a nie inaczej.
Sceneria `l204` — 2,3 GB w 154 plikach `.scm`:
```
teren/ 2,2 GB (95% objętości)
citygml/ 84 MB
deko/ 15 MB
tory/ 3,0 MB
```
Populacja węzłów w `l204`:
```
8 970 715 triangles (99,8% wszystkich węzłów)
11 258 track
4 535 model
664 line_strip
474 dynamic
```
Postać danych terenu — jeden `node` na **jeden trójkąt**, ~220 bajtów ASCII:
```
node -1 0 none triangles grass_new
8812.0 35.55 -25152.0 -0.0280234 0.999585 -0.00665975 -307.251 -3449.75 end
8814.0 35.65 -25152.0 0.0115008 0.999892 -0.00910931 -307.501 -3449.75 end
8812.0 35.52 -25154.0 -0.0118979 0.999913 0.00561081 -307.251 -3450.0
endtri
```
Wnioski, które ustawiają plan:
1. **Teren dominuje wszystko.** 95% objętości i 99,8% węzłów sceny. Każdy trójkąt jest
osobnym `shape_node` przechodzącym przez `basic_region::insert()` i `RaTriangleDivider`.
Żadna inna optymalizacja nie zbliża się rzędem wielkości.
2. **Dane nie mają lokalności przestrzennej na dysku.** Kolejne trójkąty skaczą
(x = 8812 → 17898 → 6398); w pierwszych 30 000 wierzchołków zakres x to 18 km.
Streaming z takiego układu jest niemożliwy z konstrukcji — sortowanie przestrzenne
jest warunkiem wstępnym, nie optymalizacją.
3. **Hybryda heightfield + overlay potwierdzona empirycznie.** Około połowy wierzchołków
leży na regularnej siatce 2 m, druga połowa nie — baza jest siatką, nieregularne są
wcięcia torowiska i dróg.
4. **Obiektów jest mało.** 4535 modeli i 11 258 torów na ok. 40 km linii. Prefaby i
rezydencja assetów mają sens jako porządek w danych i podstawa batchowania, ale nie
jako oszczędność pamięci — populacja jest o cztery rzędy wielkości mniejsza od terenu.
5. **Sekcja LOGIC jest darmowa.** Cała topologia to ułamek procenta danych, więc trzymanie
jej rezydentnie w całości nie wymaga żadnego kompromisu.
Szacunek po zamianie bazy terenu na heightfield: 8,8 mln trójkątów na siatce 2 m to
ok. 4,4 mln próbek, czyli `R16` + splat `RGBA8` ≈ **26 MB zamiast 2,2 GB**. Nawet
z overlayem zostajemy trzy rzędy wielkości niżej.
## Pomiar fazy 0 (2026-09-16)
`braniewo_szeroki.scn` (wciąga `l204`), format tekstowy, RelWithDebInfo, clang 22.
Zebrane przełącznikiem `-loadprofile`, surowe dane w `loadprofile.txt`.
```
token loop (rest) 14.42 s 6.6 %
triangle import 125.36 s 57.2 %
triangle insert 3.63 s 1.7 %
of which divider 0.23 s 0.1 %
other node types 75.61 s 34.5 %
total 219.01 s
triangles 9 983 053
track 12 439
model 11 372
dynamic 510
shape vertices 36 799 623
shapes divided 269 (extra pieces: 92 328)
pamięć rezydentna: 403 MB -> 9 547 MB (delta 9 144 MB)
```
Co z tego wynika:
1. **Parsowanie trójkątów to 57% czasu ładowania.** Format binarny kasuje tę pozycję,
nie zmniejsza.
2. **Pamięć boli bardziej niż czas.** 9,1 GB na 36,8 mln wierzchołków to 248 B na
wierzchołek, przy danych wierzchołka rzędu 44 B (~1,6 GB łącznie). Pozostałe ~7,5 GB
to narzut na 9,98 mln osobnych `shape_node` — ok. 960 B na węzeł (`std::string` nazwy,
wektor, `bounding_area`, `lighting_data` = cztery `vec4` na trójkąt, origin, uchwyty).
Struktura zarządzająca waży prawie pięć razy tyle, co dane, którymi zarządza.
3. **`RaTriangleDivider` był w tym planie przeszacowany.** 0,23 s, czyli 0,1%; całe
wstawianie do regionu to 1,7%. Argumentem za rzadkim quadtree zostaje pamięć
i lokalność przestrzenna, nie koszt dzielenia kształtów.
4. **Drugi cel ujawniony przez pomiar:** `other node types` to 75,6 s (34,5%) przy
zaledwie 11 372 modelach — ok. 6,6 ms na model, czyli ładowanie `.e3d` i tekstur.
Wzmacnia to fazę 6 (rejestr assetów), ale dopiero po terenie.
### Porównanie: istniejąca binarka `.sbt`, ta sama sceneria
W `pctga/scenery/` leżał gotowy `braniewo_szeroki.sbt` (1,2 GB); wystarczyło włączyć
`file.binary.terrain`. To jest bezpośredni pomiar tego, ile daje **samo zbinaryzowanie
obecnej struktury**, bez jej zmiany.
```
tekst .sbt
token loop (rest) 14.42 s 7.18 s (zawiera odczyt 1,2 GB i przebiegi Init*)
triangle import 125.36 s 0.00 s
triangle insert 3.63 s 0.00 s
other node types 75.61 s 83.81 s
total 219.01 s 90.99 s
pamięć (delta) 9 144 MB 8 913 MB
```
Dwa wnioski, oba istotne dla kolejności faz:
1. **Czas: 2,4x szybciej.** Parsowanie znika co do zera, dokładnie jak zakładano.
2. **Pamięć: bez zmian (-2,5%).** I to jest najważniejsza liczba całego pomiaru.
`.sbt` deserializuje się do **tych samych 10 mln obiektów `shape_node`**, więc 9 GB
zostaje 9 GB. Zamiana tekstu na bajty nie rusza narzutu strukturalnego, bo narzut
nie brał się z kodowania. Wygrana pamięciowa może przyjść **wyłącznie** ze zmiany
struktury danych — czyli z heightfieldu (faza 2), nie z formatu jako takiego.
Dla porządku wielkości: `.sbt` to 1,2 GB na dysku, szacowany heightfield ~26 MB.
3. **Po zdjęciu terenu dominuje ładowanie assetów.** W przebiegu `.sbt`
`other node types` to 83,8 s, czyli 92% całości. Następnym wąskim gardłem po fazie 2
będzie `.e3d` i tekstury — czyli faza 6, i to z mocniejszym uzasadnieniem, niż plan
pierwotnie zakładał.
Wniosek dla całego przedsięwzięcia: **format binarny sam z siebie kupuje czas ładowania,
ale nie kupuje pamięci.** Kolejność faz (najpierw struktura terenu, potem kontener) jest
tym potwierdzona.
## Pomiar fazy 1: rozkład terenu (2026-09-16)
`tools/terraincook` na `scenery/l204/teren` (8 799 853 trójkąty, 2,2 GB tekstu).
```
-- rozkład, krok siatki 1,00 m
na siatce (heightfield) 8 781 214 99,79 %
poza siatką (overlay) 18 639 0,21 %
spiętrzone próbki 595 (maks. różnica 2,80 m)
-- realna gęstość próbkowania: rozkład długości krawędzi
< 4 m 52,58 %
< 8 m 14,94 %
< 16 m 18,27 %
< 32 m 13,30 %
>= 64 m 0,34 %
-- pokrycie wg rozmiaru komórki
1 m 4 402 162 komórek 4,40 km2
2 m 4 396 690 komórek 17,59 km2
4 m 2 700 291 komórek 43,20 km2
8 m 1 822 254 komórek 116,62 km2
16 m 1 026 922 komórek 262,89 km2
32 m 334 958 komórek 343,00 km2
64 m 95 082 komórek 389,46 km2
```
### Rozstrzygnięcia
1. **Heightfield się broni.** 99,79% trójkątów leży na regularnej siatce, overlay to 0,21%
i jest skupiony lokalnie (podgląd `overlay.pgm` jest praktycznie czarny, kilka plamek
w jednym rejonie). Główne ryzyko fazy 1 — overlay rozlany po całej mapie — nie istnieje.
2. **Mosty i wiadukty to błąd zaokrąglenia.** 595 spiętrzonych próbek na 4,4 mln, czyli
0,014%. Jedyne miejsca, których heightfield nie wyrazi, mieszczą się w garści trójkątów.
3. **Siatka bazowa to 1 m, nie 2 m.** Krok 2 m obejmuje tylko 25% wierzchołków.
4. **Ale teren NIE jest próbkowany co 1 m.** Wierzchołki mają całkowite współrzędne
metrowe, natomiast odległości między sąsiadami to 2–4 m w połowie przypadków, z ogonem
do 32 m. Teren jest zbiorem bloków o różnej gęstości, co widać wprost na `height.pgm`.
5. **Realna powierzchnia terenu to ~390 km², nie 4,4 km².** Liczba „pokrycie 4,40 km2"
z pierwszego przebiegu liczyła unikalne próbki metrowe i dla rzadziej próbkowanych
bloków liczyła ułamek ich powierzchni. Wartość zbiega dopiero przy komórce 32–64 m.
### Uczciwy koszt heightfieldu
Dla pokrycia ~390 km², sama mapa wysokości `R16`:
```
2 m 97,5 mln próbek ~186 MB
4 m 24,4 mln próbek ~47 MB
8 m 6,1 mln próbek ~12 MB
```
Z mipmapami i splat mapą w połowie rozdzielczości, przy bazie 4 m, wychodzi rząd
**70–90 MB** — wobec 2,2 GB tekstu, 1,2 GB `.sbt` i 9 GB w pamięci. Wcześniejsze
oszacowanie „~26 MB" było zbyt optymistyczne, bo opierało się na zaniżonej powierzchni.
Rząd wielkości zysku się utrzymuje, ale to jest ~30x, a nie ~100x.
Otwarte: czy drobne próbkowanie (2–4 m) skupia się przy torze, czy rozkłada się losowo
po blokach. Jeśli przy torze — baza 8 m plus overlay korytarza wystarczy. Jeśli losowo —
trzeba bazy 4 m. Do zmierzenia w fazie 2, gdy narzędzie dostanie dostęp do osi toru.
### Gęstość terenu względem toru
Oś toru wyciągnięta z `tory/*.scm` (11 258 torów, 480,1 km cięciw), próbkowana co 8 m,
odległość liczona transformatą chamfer na siatce 16 m.
```
pasmo trójkąty śr. krawędź drobne <4 m
< 25 m 3 389 721 2,75 m 91,8 %
< 50 m 621 774 5,13 m 51,5 %
< 100 m 638 680 6,80 m 32,3 %
< 200 m 823 895 8,03 m 30,4 %
< 400 m 965 577 9,59 m 30,0 %
< 800 m 1 056 106 12,23 m 20,8 %
< 1600 m 781 125 17,03 m 9,7 %
>= 1600 m 430 853 15,83 m 25,7 %
```
**Drobne próbkowanie jest przy torze i degraduje się monotonicznie.** 38,5% wszystkich
trójkątów terenu leży w pasie 25 m od osi — prawie cztery dziesiąte budżetu geometrii
idzie na wstęgę szerokości 50 m, a 390 km2 pola dostaje resztę.
Dwa ostatnie pasma odbijają lekko w górę; najpewniej to osobne dopracowane rejony daleko
od osi `l204` (stacje sąsiednich linii, obszar `citygml`). To 6% trójkątów, nie zmienia
obrazu, ale nie jest zweryfikowane.
Wniosek dla docelowego „drobno wszędzie": jest osiągalne, ale **dzisiejsze dane nie mają
szczegółu poza korytarzem**. Jednolita baza 2 m niczego nie pogorszy, natomiast poza
pasem 25 m będzie interpolacją, nie detalem. Realne zagęszczenie pola wymaga nowego
źródła wysokości — dla Polski dostępny jest publiczny NMT z ALS w siatce 1 m. I to jest
argument za heightfieldem niezależny od wydajności: **do heightfieldu można podmienić
źródło**, do zupy trójkątów nie.
Koszt jednolitej bazy 2 m dla pokrycia 390 km2: ~186 MB samych wysokości, z mipmapami
i splatem rzędu 250-300 MB. Jedna piąta dzisiejszego `.sbt`, a w pamięci bez znaczenia,
bo clipmapa trzyma tylko pierścienie.
### Ustalenia dla fazy 2 wynikające z pomiaru
1. **Jednolita baza 2 m**, bez wielorozdzielczości. Prostsze, zgodne z celem, mieści się
w budżecie.
2. **Overlay korytarza generowany z geometrii toru**, nie konwertowany ze starego terenu.
Siatka 2 m nie odda ostro skarpy 1:1,5, a na to gracz patrzy przez cały czas.
3. **Woda jako osobna warstwa** (patrz niżej).
4. **Rzadki katalog kafli**, nie gęsta tablica.
### Korekta do fazy 2
Faza 2 zakładała „jeden plik, stały `chunk_stride`, adres liczony arytmetycznie
z pozycji, bez katalogu". **To nie przejdzie na tych danych.** Pokryte ~390 km² leży
w zasięgu roboczym 51 x 46 km, czyli zajętość jest rzędu 16%, a przy pełnym bounding
boksie scenerii dużo gorsza. Gęsta tablica marnuje sześciokrotnie. Kafle terenu muszą
mieć **rzadki katalog** (kafel 256 m, tylko zajęte zapisane) — arytmetyczny offset
zostaje wewnątrz kafla, nie na poziomie całego pliku.
### Warstwa wody
Pełny bounding box danych to 206 km x 161 km, przy zasięgu roboczym 51 x 46 km, a sumaryczny
footprint trójkątów wychodzi na bezsensowne 19 600 km2. Sprawdzone: odpowiada za to
`woda_ter.scm` — 308 trójkątów, w tym kilka olbrzymich, rozciągniętych na całą mapę.
To nie są śmieci, tylko tafla wody, i nie jest to teren w sensie heightfieldu.
Wniosek dla fazy 2: **woda to osobna warstwa**, płaszczyzna z poziomem i materiałem,
nie próbki wysokości. Konwerter musi ją rozpoznawać i wydzielać przed liczeniem zasięgu,
bo inaczej jedna tafla rozciąga bounding box dziesięciokrotnie i psuje każdy szacunek
zajętości.
## Faza 2, krok 1: upieczony heightfield (2026-09-16)
`terraincook -cook` na `l204/teren`, krok siatki 2 m, kafel 128 próbek (256 m).
```
kafle 7 531 (129 x 129 próbek, wspólna krawędź)
zajętość bounding boksu 12,3 %
próbki z danymi 116 855 308 93,24 % zapisanych
zakres wysokości -3,96 .. 103,35 m (0,002 m na jednostkę)
odrzucone zbyt duże 376 (krawędź > 200 m, tafla wody)
zdegenerowane 880
spiętrzone 32 882 (zachowana niższa powierzchnia)
plik surowy 358,8 MB
plik po zstd -9 126,2 MB (kompresja 1,5 s)
czas pieczenia 20 s
szczyt pamięci 1,7 GB
```
Porównanie do dzisiejszych formatów tej samej scenerii: 2,2 GB tekstu, 1,2 GB `.sbt`.
Surowy `.ehf` to 1/6 tekstu i 1/3,4 `.sbt`; po kompresji 1/17 i 1/10.
Korekta wcześniejszego szacunku: zapowiadałem „250-300 MB z mipmapami". Surowy plik
**bez** mipmap wyszedł 358,8 MB, bo doszła płaszczyzna materiału — bajt na próbkę, czyli
jedna trzecia pliku. Kompresuje się niemal do zera (92% terenu to jeden materiał), stąd
tak dobry wynik zstd, ale w postaci surowej trzeba ją liczyć.
Wizualna kontrola (`cooked.pgm`): ciągła powierzchnia z czytelnymi dolinami rzecznymi,
bez dziur i bez szwów między kaflami, zgodna kształtem z oryginałem.
Uwaga o spiętrzeniach: 32 882 przy pieczeniu, wobec 595 znalezionych wcześniej. To nie
sprzeczność - tamto liczyło tylko wspólne wierzchołki na siatce 1 m, a rasteryzacja
porównuje interpolowane powierzchnie, więc łapie każde nałożenie trójkątów, nie tylko
dzielony wierzchołek. 0,03% próbek.
### Następne kroki fazy 2
1. Kompresja per kafel w kontenerze (katalog ma już rozmiar, dochodzi rozmiar
skompresowany i kod kodeka) - z 359 MB robi się ~126 MB bez zmiany logiki odczytu.
2. Mipmapy per kafel, pod poziomy clipmapy.
3. Strona silnika: loader `.ehf` i clipmapa pod OpenGL.
4. Overlay korytarza generowany z geometrii toru.
## Ślepa uliczka (sprawdzone, nie wracać)
**Launchery eventów jako listy 1D po torach.** Pierwotny plan stawiał to jako fazę
o najlepszym stosunku zysku do pracy, bo `basic_cell::update_events()` odpytuje je
promieniowo co klatkę i *wygląda* to na hot path. W danych hot patha nie ma:
```
65 eventlauncherów w całym zbiorze pctga (7 GB scenerii)
0 z traintriggered
```
Większość z nich ma `radius = -1` i godzinę, czyli `IsGlobal()` — nie powinny w ogóle
trafiać do przeglądu przestrzennego. Przeniesienie ich na listy 1D byłoby optymalizacją
czegoś, co nie kosztuje. Pozostaje drobiazg do sprzątnięcia przy okazji: w pętli
`train_triggered` w `scene/scene.cpp` zmienna `radius` jest skalowana prędkością, ale
porównanie i tak idzie do `launcher->dRadius`, więc skalowanie jest martwe.
## Zasady przekrojowe
Obowiązują w każdej fazie, nie są osobnym etapem.
1. **Definicja jest niemutowalna, stan jest osobno.** Plik cooked jest read-only i
mmapowany. Wszystko, co zmienia się w runtime, żyje w równoległych tablicach
alokowanych przy ładowaniu. Bez tego konsekwentnie przestrzeganego nie da się
mmapować niczego.
2. **Logika jest rezydentna, wizualia są streamowane.** Symulacja musi działać
identycznie niezależnie od tego, co jest wczytane. Żaden element symulacji nie
sprawdza rezydencji i żaden nie może się nie wykonać z powodu wyładowanego kafla.
3. **Przepływ jednokierunkowy.** Warstwa logiki zapisuje stan; warstwa wizualna
czyta go przy ładowaniu. Nigdy odwrotnie.
4. **Nazwy rozwiązywane przy kompilacji.** W runtime są indeksy. Stringi lądują
w opcjonalnej sekcji dla edytora, skryptów i debugowania.
5. **Determinizm.** Numeracja kanoniczna (sortowanie po hashu nazwy, nie po
kolejności w plikach), kolejność aktualizacji po indeksie, losowość ze strumieni
indeksowanych ID obiektu — nigdy z globalnego RNG.
6. **Brak pointerów w formacie.** Offsety względem początku chunka i indeksy tablic.
## Poza zakresem
**LOD symulacji fizyki.** Rozważany i odrzucony. Przejścia między poziomami muszą
być bezstratne w obie strony, a syntezowanie stanu pneumatyki przy awansie poziomu
jest źródłem błędów, których nie da się tanio wykryć (skład awansuje i wpada
w hamowanie nagłe). Koszt CPU po stronie AI leży w skanowaniu trasy, nie
w całkowaniu ruchu — adresuje to faza 4. Do tematu wracać tylko wtedy, gdy pomiar
z fazy 0 pokaże, że po fazie 4 fizyka nadal dominuje.
Podział pojazdu na rekord logiczny (rezydentny) i instancję wizualną (streamowaną)
zostaje — to nie jest LOD fizyki, tylko zasada 2.
---
## Faza 0 — pomiar terenu
**Cel.** Zmierzyć to, co pomiary wejściowe wskazały jako dominujące, zanim cokolwiek
zostanie przepisane. Zawężone względem pierwotnego zamysłu: nie cała ramka, tylko
ścieżka terenu.
**Zakres.**
- Czas ładowania `l204` rozbity na: parsowanie `.scm`, tworzenie `shape_node`,
`RaTriangleDivider`, wstawianie do regionu, budowa banków geometrii.
- Zużycie pamięci: dane wierzchołków terenu osobno od struktur zarządzających
(`shape_node`, komórki, sekcje).
- Liczniki populacji przy starcie: węzły wg typu, komórki niepuste, banki geometrii.
- Koszt ramki z grubsza, żeby wiedzieć, czy render terenu w ogóle jest problemem,
czy problemem jest tylko ładowanie i pamięć.
**Gotowe, gdy.** Liczby są zapisane w repo i powtarzalne jedną komendą na `l204`.
**Ryzyko.** Żadne. Po poprzedniej pomyłce z launcherami — nie pomijać.
---
## Faza 1 — konwerter terenu (offline)
**Cel.** Rozstrzygnąć najwyższe ryzyko całego planu, zanim cokolwiek w silniku się
zmieni: czy zupę trójkątów da się rozłożyć na regularny heightfield plus wąski overlay.
Narzędzie samodzielne, silnik nietknięty.
**Zakres.**
- Wejście: pliki `teren/*.scm`. Wyjście: heightfield (`R16`) + splat + lista trójkątów
nieprzystających do siatki.
- Wykrycie siatki bazowej (w `l204` jest to 2 m) i klasyfikacja każdego trójkąta:
na siatce → heightfield, poza siatką → overlay.
- Raport: jaki procent trafia do overlaya, jak overlay jest rozłożony przestrzennie,
ile waży po obu stronach.
- Wizualizacja albo eksport do czegoś oglądalnego — trzeba **zobaczyć**, czy overlay
pokrywa się z torowiskiem i drogami, czy rozsypuje się po całej mapie.
**Gotowe, gdy.** Dla jednego odcinka `l204` znamy proporcję heightfield/overlay i mamy
pewność, że overlay jest wąskim pasem wzdłuż infrastruktury, a nie połową mapy.
**Ryzyko.** **Najwyższe w całym planie i celowo wyciągnięte na początek.** Jeśli overlay
okaże się rozlany po całej powierzchni, model heightfieldu upada i trzeba wrócić do
streamowanego meshu — ale wtedy wiemy to po tygodniu pracy nad narzędziem, a nie po
przepisaniu renderera.
---
## Faza 2 — teren w silniku: clipmapa pod OpenGL
**Cel.** Zastąpić 8,97 mln węzłów `triangles` heightfieldem o stałym budżecie pamięci.
Na razie pod OpenGL — format jest niezależny od backendu.
**Zakres.**
- Teren przestaje być meshem: chunk to `height R16` + `splat RGBA8` + maska materiałów.
Mesh to stały grid albo pozycja pobierana z tekstury wysokości.
- Clipmapa: pierścienie o stałym rozmiarze wokół kamery, toroidalne adresowanie,
aktualizacja tylko wchodzącego pasa. Geomorphing między poziomami w shaderze.
- Jeden plik, stały `chunk_stride`, **adres liczony arytmetycznie z pozycji**, bez
katalogu. Konsolidacja dzisiejszego `terrain_streamer`
(`editor/editorTerrainStreamer.cpp`, tysiące plików `chunk_X_Z.etc`) do tego jednego pliku.
- Warstwa **overlay** z fazy 1 jako osobna geometria wzdłuż infrastruktury; heightfield
pod nią wygładzany przez konwerter.
- **Zapiekanie absolutnego Y instancji** z terenu w pełnej rozdzielczości. Runtime nigdy
nie odpytuje terenu o wysokość — to warunek niezależności strumieni terenu i obiektów.
- Zgodność wysokości na krawędziach poziomów bitowo identyczna z konstrukcji (brak szwów
z definicji, nie przez łatanie).
**Gotowe, gdy.** `l204` ładuje się z heightfieldu, zużycie pamięci terenu jest stałe
niezależnie od położenia, horyzont 20–30 km bez spadku wydajności, brak szwów.
**Ryzyko.** Średnie — po fazie 1 największa niewiadoma jest już zdjęta.
---
## Faza 3 — rozdzielenie definicji od stanu
**Cel.** Warunek wstępny mmapowania czegokolwiek. Efekt uboczny: ciągłe tablice
zamiast rozproszonych alokacji z wirtualnymi wywołaniami.
**Zakres.**
- `basic_event` (`world/Event.h`): `m_launchtime`, `m_inqueue`, `m_activator`,
`m_ignored` wychodzą do równoległych tablic. `m_delay`, `m_delayrandom`, `m_name`,
topologia zostają jako definicja.
- Memcelle (`world/MemCell.h`): wartości początkowe to definicja, bieżące to stan.
- Stan animacji obiektów (`TAnimContainer`) — faza animacji jako tablica, nie pole
w obiekcie.
- Stan zwrotnic i torów.
**Gotowe, gdy.** Kolejka eventów przemiata ciągłą tablicę `launchtime[]`; żadna
struktura definicji nie jest zapisywana w runtime.
**Ryzyko.** Średnie — dotyka dużo miejsc, ale mechanicznie. Robić w krokach po
jednym podsystemie, z testem scenariusza T1/T3/T5 po każdym.
---
---
## Faza 4 — profil trasy i przepisany `scan_route`
**Cel.** Zlikwidować chodzenie po grafie obiektów sceny przy każdym skanowaniu
trasy. To jest właściwy koszt AI.
**Zakres.**
- Struktura per tor, w sekcji logiki: `vmax`, `gradient`, `curvature`, zakresy
sygnałów i zmian limitu wzdłuż toru.
- `TController::scan_route` / `sSpeedTable` (`vehicle/Driver.h`) przepisane na
przemiatanie ciągłej tablicy. W runtime dochodzi tylko to, co dynamiczne:
wskazania sygnałów, zajętości, inne pojazdy.
- Zapieczony profil hamowania dla kategorii pociągu na odcinku — punkt rozpoczęcia
hamowania odczytywany, nie całkowany co klatkę.
- Na tym etapie profil liczony przy ładowaniu; przeniesienie do cookera w fazie 7.
**Gotowe, gdy.** Pomiar z fazy 0 pokazuje spadek kosztu skanowania proporcjonalny
do liczby składów, przy niezmienionym zachowaniu AI na scenariuszach testowych.
**Ryzyko.** Średnie — dotyka zachowania AI, które jest trudne do testowania
regresyjnego. Wymaga zestawu scenariuszy referencyjnych z fazy 0.
---
---
## Faza 5 — walidator jako osobne narzędzie
**Cel.** Wychwycić błędy scenerii przy kompilacji zamiast w postaci cichego
milczenia w runtime. Działa jeszcze na formacie tekstowym, chodzi po tym samym
grafie, co przyszły cooker.
**Zakres.**
- Eventy: wiszące referencje do targetów i memcelli, launchery wskazujące na
nieistniejące eventy, **eventy zadeklarowane za `FirstInit`**, niezgodność typów
slotów memcella, eventy nieosiągalne, cykle `multi` bez opóźnienia.
- Rozkłady: nieistniejąca stacja, brak wskazanego peronu, peron krótszy niż skład,
relacja prowadząca przez tor bez połączenia.
- Po fazie 4, na profilu trasy: **rozkład niewykonalny** — czas przejazdu A→B
mniejszy niż fizycznie możliwy przy tych limitach, spadkach i tej masie.
**Gotowe, gdy.** Narzędzie przechodzi po scenariuszach referencyjnych i raportuje
z numerem pliku i wiersza; znane błędy w istniejących sceneriach są wychwycone.
**Ryzyko.** Niskie. Wartość dla twórców scenerii natychmiastowa i niezależna od
reszty planu.
---
---
## Faza 6 — rejestr assetów i prefaby
**Cel.** Wprowadzić pojęcie współdzielonego archetypu, zanim zabetonuje się je
w formacie binarnym. Weryfikacja na semaforach, bo są najliczniejsze i najbardziej
powtarzalne.
**Zakres.**
- `MdlMngr`: lookup po `asset_id` (hash logicznej ścieżki) zamiast po ścieżce.
- Tabela zależności: `{ pack_id, asset_id, version, content_hash }`.
- Prefab = asset + kanały animacji + definicja stanów + schemat memcella + światła
+ dźwięki. Kanały animacji kompilowane po **indeksach submodeli**, nie po nazwach
(dziś `TAnimContainer` dowiązuje się po nazwie w runtime).
- Sceneria tekstowa zyskuje możliwość zapisu instancji prefabu zamiast modelu plus
pięciu eventów.
**Gotowe, gdy.** Semafory S1/S5/S13, tarcze i semafory kształtowe dają się opisać
parametrami jednego prefabu. **Jeśli się nie dają — warstwa prefabu jest za cienka
i trzeba ją przeprojektować przed fazą 8.** To jest właściwy punkt próby.
**Ryzyko.** Średnie, ale to ryzyko projektowe, a nie wdrożeniowe — i lepiej je
ponieść tu niż po przejściu na binarkę.
### Wynik punktu próby
**Jeden prefab wystarcza — pod warunkiem, że jego parametrem jest lista wskazań,
a nie zestaw liczb.**
Szablony semaforowe różnią się liczbą zdarzeń od 5 do 44, ale liczba parametrów
jest w nich stała (sześć, jeden wyjątek ma siedem). Ta rozbieżność to cała
diagnoza: `(pN)` nie potrafi wyrazić „semafor z wskazaniami {S1, S2, S5, S13}",
więc każda kombinacja komór, drabinki, daszka i tekstury dostaje własny plik.
Stąd ponad tysiąc `.inc` w katalogu scenerii.
Zdarzenia nie są dowolne — wynikają ze wskazań mechanicznie:
```
dwukomorowy (9): sem_info s1 s2 sem_info_stop sem_info_vmax
sem_ligh1 sem_ligh2 uszk sem_ligh0
pięciokomorowy (37): sem_info s1..s5 s10..s13 ms2 m40 sz1
sem_info_stop/vmax/v40/v20/Shunt25/Shunt40
sem_distinfo_stop/vmax/v100/v40
sem_ligh1..13 lighs2 lighz1 uszk sem_ligh0
```
Na każde wskazanie przypada jeden event stanu, jeden event świateł i jedna
prędkość wpisywana do memcella; do tego stały rdzeń (`_sem_info`, `_uszk`,
`_sem_ligh0`) i opcjonalny blok `_distinfo`, gdy semafor powtarza tarczę.
**Co z tego zbudowano.** `scene/prefab.{cppm,cpp}` — rejestr definicji `.pfb`
i rozwijanie instancji. Sceneria zyskała token:
```
prefab ss2czy sem_probny 100 0 -100 90 tabl/a1 endprefab
```
Definicja opisuje wskazania, nie tekst:
```
prefab
model sem/ps_2_l_cz_ks_ds_03.t3d
chambers 2
initial s1
aspect s1 lights 1 0 velocity 0.0 0.0 endaspect
aspect s2 lights 0 1 velocity -1 -1 endaspect
endprefab
```
Instancja rozwija się w tekst scenerii i wchodzi przez `injectString`, czyli tą
samą drogą co include — węzły i eventy powstają istniejącym parserem. Format
binarny skompiluje ją wprost, ale interfejs jest już ustalony: **publiczne są
wyłącznie eventy wskazań `<nazwa>_<wskazanie>`**, a światła, prędkości i memcell
należą do prefabu i wolno je zmienić.
Sprawdzone na `prefabtest.scn`: dziesięć eventów (rdzeń plus trzy na wskazanie),
model wczytany, semafor stoi na scenie. Odpowiednik `ss2czy.inc` ma dziewięć —
różnica bierze się z tego, że `.inc` współdzieli `_sem_info_stop` między
wskazaniem S1 a uszkodzeniem, a prefab nazywa prędkości po wskazaniach.
### Kształtowy — drugi test tej samej warstwy
Semafor kształtowy to przypadek, który miał ją rozerwać: `sk1.inc` ma sto wierszy,
dziesięć kanałów animacji na wskazanie, dźwięk przestawienia i trzy kanały światła.
**Nie rozerwał.** To wciąż ten sam kształt — na wskazanie jeden stan, jedne światła,
jedna prędkość — tylko z listą kanałów animacji doczepioną do wskazania:
```
aspect sr2
lights -1 0 1
lightdelay 0.5
velocity -1 -1
animate rotate ramie_gorne 0 -45 0 45
...
endaspect
```
Definicja urosła o `modelangle`, `attach` (kratownica i jej lod), `sound`,
`initiallights` i `lightmodes`. Wszystko to są cechy modelu, nie nowe pojęcia.
Przy okazji wyszło, że `.inc` dzielą animacje na grupy po osiem
(`_sem_anim_sr1` plus `_sem_anim_sr1cd`). `multi_event` trzyma `std::vector`, więc
ten limit dawno nie istnieje — prefab wystawia jedną listę. Dokładnie takiego
śmiecia warstwa prefabu ma nie przepuszczać dalej.
Sprawdzone na `prefabtest2.scn`: dwa semafory z jednej definicji, 72 eventy,
`sem_a` zostaje na „stój", `sem_b` po odpaleniu `sem_b_sr2` podnosi ramię.
**Czego to jeszcze nie pokrywa.** Kanały animacji idą po nazwach submodeli, tak jak
dziś — kompilacja po indeksach należy do formatu binarnego, bo dopiero tam jest
z czym indeks związać. Zostaje też `asset_id` zamiast ścieżki i tabela zależności
paczek.
---
---
## Faza 7 — cooker i kontener; sekcja LOGIC binarnie
**Cel.** Pierwsze realne wyjście binarne. Renderer nietknięty.
**Zakres.**
- Osobny target `tools/scn-cook`, wejście `.scn` (parser z `utilities/parser.h`),
wyjście kontenera. Deterministyczny bit w bit.
- Kontener: `[header][directory][LOGIC][STRINGS][chunks…]`, wyrównanie 16 B,
`static_assert` na rozmiarze każdej struktury, little-endian z markerem,
`magic + version + hash schematu`; niezgodność = odmowa ładowania, nie migracja.
- Sekcja LOGIC: tory, trakcja, eventy, memcelle, wyzwalacze, profil trasy — płaskie
tablice z indeksami zamiast pointerów. Znika `init_targets()` i `TrackJoin()`
z runtime'u.
- Rozkłady w **osobnym** kontenerze (edytowane niezależnie, przeładowywalne na
gorąco, ten sam teren obsługuje wiele scenariuszy).
**Gotowe, gdy.** Scenariusz ładuje się z binarnej sekcji LOGIC, zachowanie
identyczne, czas ładowania mierzalnie niższy.
**Ryzyko.** Średnie. Do rozstrzygnięcia przed startem: **relacja z VCS** — patrz
sekcja na końcu.
### Uwaga: na branchu `scenery-streaming` leży inny format binarny
`scene/scenerybinary_format.md` (wersja 10) opisuje **twin strumienia tokenów** —
każdy plik tekstowy kompiluje się do binarnego bliźniaka konsumowanego na poziomie
`cParser`, więc deserializer scenerii nigdy nie widzi formatu. Kod siedzi na
`scenery-streaming` (commit `6ba19dd6` i późniejsze), a sama specyfikacja przyjechała
tutaj z „Editor checkpoint" — dokument bez kodu, co jest mylące.
**To nie jest to samo co sekcja LOGIC i nie konkuruje z nią.** Twin skraca
tokenizowanie, zostawiając całą ścieżkę parsowania i budowania obiektów bez zmian.
LOGIC likwiduje samą tę ścieżkę: płaskie rekordy, indeksy zamiast nazw, brak
`init_targets()`. Można mieć oba naraz — twin dla tego, co zostaje tekstem (sceneria
w edycji), LOGIC dla tego, co jest upieczone.
Czego **nie** wolno zrobić: napisać drugiego kontenera na tokeny. Jeśli kiedyś
okaże się, że twin wystarcza, to LOGIC jest zbędny, a nie odwrotnie.
### Odstępstwo: kucharz siedzi w silniku, nie w osobnym targecie
Plan mówił `tools/scn-cook` czytający `.scn` parserem z `utilities/parser.h`.
Zrobione inaczej — `-cooklogic` piecze kontener z **załadowanego stanu**, bo cele
eventu, rodzeństwo i indeksy są produktem całego deserializera scenerii, a nie
parsera; osobne narzędzie musiałoby wyhodować jego drugą kopię. Pieczenie
z załadowanego stanu daje to, o co naprawdę chodzi: zapisane jest dokładnie to, co
produkuje ścieżka tekstowa. Determinizm bierze się z writera, nie z miejsca wywołania
— stringi internowane w kolejności pierwszego wystąpienia, rekordy w kolejności
menedżera, wyrównanie zerowane.
### Zrobione w tym kroku
- `scene/logicformat.h` — układ bajtów, bez typów silnika: `file_header`,
`section_entry`, `string_entry`, `event_record`, `target_entry`, `static_assert`
na każdym rozmiarze, `schema_hash` w nagłówku.
- `scene/logiccontainer.{cppm,cpp}` — writer i reader. Reader odrzuca wszystko, co
nie zgadza się co do magic, wersji, hasha schematu, endianu, liczby sekcji lub
rozmiaru pliku. Odmowa, nie migracja — to cache, zawsze można upiec od nowa.
- `simulation/logiccook.{cppm,cpp}` — `-cooklogic` piecze i **natychmiast czyta
z powrotem**, porównując z eventami w pamięci: nazwa, rodzaj, opóźnienia, liczba
i kolejność celów.
**Czego ten krok jeszcze nie robi.** Kolumna `node` w `target_entry` to `no_index`,
bo nie ma jeszcze sekcji węzłów, w której indeks miałby na co wskazywać —
więc `init_targets()` **nadal działa**. Ten krok stawia kontener, dyscyplinę schematu
i uprząż cook/verify, z których korzysta każda następna sekcja. Pola `reserved0`
w rekordzie nie udaję: flagi per-rodzaj leżą w podklasach i pojadą razem z resztą
ładunku, gdy będzie dla nich sekcja.
---
---
## Faza 8 — kafle wizualne i streamer obiektów
**Cel.** Streaming geometrii i instancji, niezależny od terenu.
**Zakres.**
- Rzadki quadtree po kodzie Mortona zamiast `EU07_CELLSIZE` 250 m /
`EU07_SECTIONSIZE` 1000 m / `500×500` sekcji (95% dzisiejszych sekcji jest pustych,
a stacja węzłowa dostaje ten sam budżet co pole).
- Chunki **posortowane po Mortonie** w pliku — ruch wzdłuż linii to odczyt
sekwencyjny, prefetch to jeden `pread` na kilka kafli.
- Geometria GPU-ready: pozycje `int16` względem origin kafla (rozdzielczość ~4 mm
przy kaflu 256 m, przy okazji likwiduje problem precyzji `dvec3`), normalne
i tangenty oktahedralne, UV `unorm16`, indeksy 16-bit lokalne. Przepuszczone
przez `meshoptimizer` + meshlety.
- LOD-y jako osobne chunki pod tym samym `tile_id` — pobranie lod3 bez czytania lod0.
- Instancje prefabów SoA, posortowane po `model_id`.
- Nagłówek kafla deklaruje zależności assetowe; kafel staje się widoczny dopiero
gdy assety są rezydentne.
- Rezydencja assetów: refcount po kaflach + LRU, z flagą `pinned` dla małych
i wszechobecnych (semafory, wskaźniki, słupki hektometrowe).
- Kompresja per chunk (zstd), nagłówek chunka nieskompresowany.
- **Dwie niezależne kolejki I/O i dwa budżety pamięci.** Teren ma twarde
pierwszeństwo z wywłaszczeniem; pod presją pamięci dociskane są wyłącznie obiekty.
- Streamer najpierw na GL33 (`glBufferSubData`) — format jest już GPU-ready.
**Gotowe, gdy.** Przejazd całą scenerią bez hitchy, przy stałym suficie pamięci.
**Ryzyko.** Średnie. Najwięcej pracy, ale na tym etapie wszystkie decyzje
projektowe są już zweryfikowane wcześniejszymi fazami.
---
---
## Faza 9 — backend Vulkan
**Cel.** Konsumpcja tego samego formatu bez zmian w plikach.
**Zakres.**
- Dedykowana kolejka transferu, persistent-mapped ring staging 64–128 MB.
Rozmiar chunka 0,5–4 MB (mniejsze topią się w barierach, większe dają hitch).
- Arenas przez VMA, `buffer_device_address` = baza areny + offset z katalogu, żeby
deskryptory nie zmieniały się przy load/unload.
- Bindless (`VK_EXT_descriptor_indexing`), materiał jako indeks w SSBO.
- `vkCmdDrawIndexedIndirectCount`, culling meshletów w compute.
- Tekstury poza formatem sceny: KTX2 + BC7/BC5, streamowane niezależnie, mip-tail
rezydentny. Sceneria trzyma tylko `texture_id`.
- Sparse binding rozważyć dopiero po pomiarze — komplikuje i bywa wolniejszy.
**Gotowe, gdy.** Ten sam plik scenerii działa na obu backendach.
---
---
## Do rozstrzygnięcia przed fazą 7: relacja z VCS
Dzisiejszy `.scn` jest diffowalny i cała społeczność na tym pracuje. Binarka to
zabija. Dwie drogi, trzeba wybrać świadomie:
- **Tekst jako źródło, binarka jako artefakt cookera** (jak shader → SPIR-V).
Cooker deterministyczny, uruchamiany w CI. Rekomendowane.
- **Binarka jako jedyne źródło prawdy.** Wtedy obowiązkowo `scn-diff` / `scn-merge`
i git driver — inaczej pierwszy merge dwóch osób na tej samej stacji kończy się
utratą pracy.
## Kolejność w skrócie
Faza 0 mierzy. Fazy 1–2 zdejmują teren, czyli 95% objętości i 99,8% węzłów — i to one
decydują, czy całe przedsięwzięcie ma sens.
Fazy 3–5 są opłacalne niezależnie od formatu i od renderera.
Faza 6 to punkt próby dla modelu prefabu.
Fazy 7–9 wymagają podjętej decyzji o cookerze i o VCS.
Gdyby robić tylko jedną rzecz z tej listy: **faza 1**. Gdyby dwie: **1 i 2**.