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.
36 KiB
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:
- Teren dominuje wszystko. 95% objętości i 99,8% węzłów sceny. Każdy trójkąt jest
osobnym
shape_nodeprzechodzącym przezbasic_region::insert()iRaTriangleDivider. Żadna inna optymalizacja nie zbliża się rzędem wielkości. - 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ą.
- 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.
- 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.
- 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:
- Parsowanie trójkątów to 57% czasu ładowania. Format binarny kasuje tę pozycję, nie zmniejsza.
- 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::stringnazwy, wektor,bounding_area,lighting_data= czteryvec4na trójkąt, origin, uchwyty). Struktura zarządzająca waży prawie pięć razy tyle, co dane, którymi zarządza. RaTriangleDividerbył 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.- Drugi cel ujawniony przez pomiar:
other node typesto 75,6 s (34,5%) przy zaledwie 11 372 modelach — ok. 6,6 ms na model, czyli ładowanie.e3di 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:
- Czas: 2,4x szybciej. Parsowanie znika co do zera, dokładnie jak zakładano.
- Pamięć: bez zmian (-2,5%). I to jest najważniejsza liczba całego pomiaru.
.sbtdeserializuje się do tych samych 10 mln obiektówshape_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:.sbtto 1,2 GB na dysku, szacowany heightfield ~26 MB. - Po zdjęciu terenu dominuje ładowanie assetów. W przebiegu
.sbtother node typesto 83,8 s, czyli 92% całości. Następnym wąskim gardłem po fazie 2 będzie.e3di 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
- 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.pgmjest praktycznie czarny, kilka plamek w jednym rejonie). Główne ryzyko fazy 1 — overlay rozlany po całej mapie — nie istnieje. - 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.
- Siatka bazowa to 1 m, nie 2 m. Krok 2 m obejmuje tylko 25% wierzchołków.
- 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. - 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
- Jednolita baza 2 m, bez wielorozdzielczości. Prostsze, zgodne z celem, mieści się w budżecie.
- 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.
- Woda jako osobna warstwa (patrz niżej).
- 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
- 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.
- Mipmapy per kafel, pod poziomy clipmapy.
- Strona silnika: loader
.ehfi clipmapa pod OpenGL. - 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.
- 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.
- 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.
- Przepływ jednokierunkowy. Warstwa logiki zapisuje stan; warstwa wizualna czyta go przy ładowaniu. Nigdy odwrotnie.
- Nazwy rozwiązywane przy kompilacji. W runtime są indeksy. Stringi lądują w opcjonalnej sekcji dla edytora, skryptów i debugowania.
- 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.
- 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
l204rozbity na: parsowanie.scm, tworzenieshape_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
l204jest 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 dzisiejszegoterrain_streamer(editor/editorTerrainStreamer.cpp, tysiące plikówchunk_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_ignoredwychodzą 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, cyklemultibez 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 poasset_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ś
TAnimContainerdowiązuje się po nazwie w runtime).
- dźwięki. Kanały animacji kompilowane po indeksach submodeli, nie po nazwach
(dziś
- 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 zutilities/parser.h), wyjście kontenera. Deterministyczny bit w bit. - Kontener:
[header][directory][LOGIC][STRINGS][chunks…], wyrównanie 16 B,static_assertna 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()iTrackJoin()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_assertna każdym rozmiarze,schema_hashw 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}—-cooklogicpiecze 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_CELLSIZE250 m /EU07_SECTIONSIZE1000 m /500×500sekcji (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
preadna kilka kafli. - Geometria GPU-ready: pozycje
int16względem origin kafla (rozdzielczość ~4 mm przy kaflu 256 m, przy okazji likwiduje problem precyzjidvec3), normalne i tangenty oktahedralne, UVunorm16, indeksy 16-bit lokalne. Przepuszczone przezmeshoptimizer+ 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ą
pinneddla 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-mergei 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.