From 2660d14565444a0a946359103d85103005d93b2c Mon Sep 17 00:00:00 2001 From: maj00r Date: Wed, 16 Sep 2026 23:18:09 +0200 Subject: [PATCH] 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 .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. --- CMakeLists.txt | 17 + application/application.cpp | 80 ++ application/drivermode.cpp | 35 + application/driveruipanels.cpp | 61 +- application/shotrunner.cpp | 105 +++ application/shotrunner.cppm | 76 ++ binary-scene-format-plan.md | 773 +++++++++++++++++++ rendering/opengl33renderer.cpp | 89 ++- rendering/opengl33renderer.cppm | 9 + rendering/terrainclipmap.cpp | 516 +++++++++++++ rendering/terrainclipmap.cppm | 179 +++++ rendering/terrainstatus.cppm | 57 ++ rendering/terraintileloader.cpp | 129 ++++ rendering/terraintileloader.cppm | 90 +++ scene/heightfieldformat.h | 167 ++++ scene/heightfieldreader.h | 237 ++++++ scene/logiccontainer.cpp | 283 +++++++ scene/logiccontainer.cppm | 84 +++ scene/logicformat.h | 172 +++++ scene/prefab.cpp | 278 +++++++ scene/prefab.cppm | 118 +++ scene/scene.cpp | 79 +- scene/scene.cppm | 9 + scene/scenenode.cppm | 13 + scene/terraincooker.h | 789 +++++++++++++++++++ shaders/terrainclipmap.frag | 57 ++ shaders/terrainclipmap.vert | 90 +++ simulation/loadprofile.cpp | 231 ++++++ simulation/loadprofile.cppm | 108 +++ simulation/logiccook.cpp | 264 +++++++ simulation/logiccook.cppm | 42 ++ simulation/simulationstateserializer.cpp | 158 +++- simulation/simulationstateserializer.cppm | 5 + simulation/terrainbake.cpp | 463 ++++++++++++ simulation/terrainbake.cppm | 85 +++ tools/terraincook/CMakeLists.txt | 24 + tools/terraincook/height.pgm | Bin 0 -> 549375 bytes tools/terraincook/main.cpp | 879 ++++++++++++++++++++++ tools/terraincook/overlay.pgm | 13 + utilities/Globals.cpp | 7 + utilities/Globals.cppm | 16 + utilities/parser.cpp | 5 + utilities/parser.cppm | 2 + utilities/utilities.cpp | 18 + utilities/utilities.cppm | 8 + world/Event.cpp | 336 +++++---- world/Event.cppm | 101 ++- 47 files changed, 7117 insertions(+), 240 deletions(-) create mode 100644 application/shotrunner.cpp create mode 100644 application/shotrunner.cppm create mode 100644 binary-scene-format-plan.md create mode 100644 rendering/terrainclipmap.cpp create mode 100644 rendering/terrainclipmap.cppm create mode 100644 rendering/terrainstatus.cppm create mode 100644 rendering/terraintileloader.cpp create mode 100644 rendering/terraintileloader.cppm create mode 100644 scene/heightfieldformat.h create mode 100644 scene/heightfieldreader.h create mode 100644 scene/logiccontainer.cpp create mode 100644 scene/logiccontainer.cppm create mode 100644 scene/logicformat.h create mode 100644 scene/prefab.cpp create mode 100644 scene/prefab.cppm create mode 100644 scene/terraincooker.h create mode 100644 shaders/terrainclipmap.frag create mode 100644 shaders/terrainclipmap.vert create mode 100644 simulation/loadprofile.cpp create mode 100644 simulation/loadprofile.cppm create mode 100644 simulation/logiccook.cpp create mode 100644 simulation/logiccook.cppm create mode 100644 simulation/terrainbake.cpp create mode 100644 simulation/terrainbake.cppm create mode 100644 tools/terraincook/CMakeLists.txt create mode 100644 tools/terraincook/height.pgm create mode 100644 tools/terraincook/main.cpp create mode 100644 tools/terraincook/overlay.pgm diff --git a/CMakeLists.txt b/CMakeLists.txt index 7ec662cd..62498027 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -176,6 +176,8 @@ set(SOURCES "model/material.cpp" "input/messaging.cpp" "scene/scene.cpp" +"scene/logiccontainer.cpp" +"scene/prefab.cpp" "scene/scenenode.cpp" "simulation/simulation.cpp" "model/vertex.cpp" @@ -186,6 +188,12 @@ set(SOURCES "application/uitranscripts.cpp" "world/station.cpp" "application/application.cpp" +"application/shotrunner.cpp" +"rendering/terrainclipmap.cpp" +"rendering/terraintileloader.cpp" +"simulation/loadprofile.cpp" +"simulation/logiccook.cpp" +"simulation/terrainbake.cpp" "simulation/simulationtime.cpp" "scene/sceneeditor.cpp" "rendering/screenshot.cpp" @@ -598,6 +606,8 @@ if (WITH_CRASHPAD) endif() target_link_libraries(${PROJECT_NAME} glfw) +# the cooked terrain heightfield stores its tiles compressed +target_link_libraries(${PROJECT_NAME} zstd::libzstd) if(WITH_PYTHON) find_package(Python3 3.14 REQUIRED COMPONENTS Development) @@ -626,6 +636,13 @@ else() target_link_libraries(${PROJECT_NAME} ${PNG_LIBRARIES}) endif() +find_package(zstd CONFIG QUIET) +if(NOT zstd_FOUND) + find_package(PkgConfig REQUIRED) + pkg_check_modules(ZSTD REQUIRED IMPORTED_TARGET libzstd) + add_library(zstd::libzstd ALIAS PkgConfig::ZSTD) +endif() + find_package(Threads REQUIRED) target_link_libraries(${PROJECT_NAME} Threads::Threads) diff --git a/application/application.cpp b/application/application.cpp index cdc99296..47f0f1ea 100644 --- a/application/application.cpp +++ b/application/application.cpp @@ -66,6 +66,8 @@ import eu07.simcore; import eu07.utilities.globals; import eu07.utilities.utilities; import eu07.simulation.simulation; +import eu07.simulation.loadprofile; +import eu07.application.shotrunner; import eu07.simulation.simulationsounds; import eu07.vehicle.train; import eu07.utilities.dictionary; @@ -1171,6 +1173,9 @@ int eu07_application::init_settings(int Argc, char *Argv[]) // process command line arguments auto scenerywasasked = false; + std::vector shots; + auto shotsettle = 3.0; + auto shotquit = true; for (int i = 1; i < Argc; ++i) { @@ -1202,16 +1207,91 @@ int eu07_application::init_settings(int Argc, char *Argv[]) { Global.editor_startup = true; } + else if (token == "-terrain") + { + if (i + 1 < Argc) + { + // cooked heightfield produced by tools/terraincook; may be repeated + Global.terrain_heightfields.push_back(Argv[++i]); + } + } + else if (token == "-shot") + { + // unattended screenshot: "x,y,z[,yaw[,pitch]]", repeatable for a sequence + if (i + 1 < Argc) + { + shot_runner::shot shot; + if (shot_runner::parse(Argv[++i], shot)) + { + shots.push_back(shot); + } + else + { + std::cout << "bad -shot viewpoint: " << Argv[i] << std::endl; + return -1; + } + } + } + else if (token == "-shotsettle") + { + // seconds to wait at each viewpoint before saving, so terrain tiles and + // models have time to stream in + if (i + 1 < Argc) + { + shotsettle = std::atof(Argv[++i]); + } + } + else if (token == "-shotstay") + { + // leave the simulation running once the sequence is done + shotquit = false; + } + else if (token == "-loadprofile") + { + // scenery load profiling: where the load time goes, what the scenery holds, + // what it costs in memory. writes to the log and to loadprofile.txt + loadprofile::enable(true); + } + else if (token == "-cooklogic") + { + // cook the scenery's LOGIC container once it has loaded, then read it back and + // check it against the events in memory + Global.cook_logic = true; + } + else if (token == "-nobaketerrain") + { + // load the scenery's terrain triangles as written, without baking or using a + // heightfield baked beside it. the way to see the original for comparison + Global.bake_terrain = false; + } + else if (token == "-verifylogic") + { + // read the LOGIC container that is already on disk and check it against the + // scenery, writing nothing + Global.verify_logic = true; + } else { std::cout << "usage: " << std::string(Argv[0]) << " [-s sceneryfilepath]" << " [-v vehiclename]" << " [-editor]" + << " [-terrain terrain.ehf]" + << " [-shot x,y,z[,yaw[,pitch]]]" + << " [-shotsettle seconds] [-shotstay]" + << " [-loadprofile]" + << " [-cooklogic]" + << " [-verifylogic]" + << " [-nobaketerrain]" << " (every switch also takes a double dash: --editor)" << std::endl; return -1; } } + if (false == shots.empty()) + { + Shots.configure(shots, shotsettle, shotquit); + } + if (Global.editor_startup && false == scenerywasasked) { // the editor opens on empty ground unless a scenery was actually asked for. whatever the ini diff --git a/application/drivermode.cpp b/application/drivermode.cpp index d6c6404e..abf5c6ae 100644 --- a/application/drivermode.cpp +++ b/application/drivermode.cpp @@ -22,6 +22,7 @@ module; #include "vehicle/DynObj_macros.h" module eu07.application.drivermode; +import eu07.application.shotrunner; import eu07.console; // Console:: is used under _WIN32 import eu07.application.uitranscripts; import eu07.application.uilayer; @@ -194,6 +195,40 @@ bool driver_mode::update() simulation::State.update_scripting_interface(); simulation::Environment.update(); + // unattended screenshot sequence, when one was asked for on the command line + if (true == Shots.active()) + { + switch (Shots.update(Timer::GetDeltaTime())) + { + case shot_runner::action::place: + { + Camera.Init(Shots.current().position, Shots.current().angle, nullptr); + Global.pCamera = Camera; + FreeFlyModeFlag = true; + break; + } + case shot_runner::action::capture: + { + // where the lens actually is, which need not be where it was put: anything + // that moves the camera between placing and capturing would otherwise make + // the sequence quietly unrepeatable + WriteLog( + "Shot: camera at " + to_string(Camera.Pos) + + " looking " + to_string(glm::dvec3{Camera.Angle} * 180.0 / M_PI) + + (FreeFlyModeFlag ? " (free)" : " (attached)")); + Application.queue_screenshot(); + break; + } + case shot_runner::action::finish: + { + Application.queue_quit(false); + break; + } + case shot_runner::action::none: + break; + } + } + if (deltatime != 0.0 || false == simulation::is_ready) { // jak pauza, to nie ma po co tego przeliczać diff --git a/application/driveruipanels.cpp b/application/driveruipanels.cpp index 4c93429c..68ff5965 100644 --- a/application/driveruipanels.cpp +++ b/application/driveruipanels.cpp @@ -47,6 +47,7 @@ import eu07.utilities.logs; import eu07.widgets.vehicleparams; import eu07.utilities.u8; import eu07.application.driverhints; +import eu07.rendering.terrainstatus; #ifdef WITH_UART import eu07.utilities.uart; using namespace Mtable; @@ -765,6 +766,14 @@ debug_panel::render_section_scenario() { Global.BaseDrawRange = std::clamp(std::exp(drawrange), 100.0f, 50000.0f); } } + // terrain streaming range slider, separate from the draw range + { + auto terrainrange = std::log(Global.TerrainRange); + if (ImGui::SliderFloat( + (to_string(std::exp(terrainrange), 0, 5) + " m###terrainrange").c_str(), &terrainrange, std::log(500.0f), std::log(100000.0f), "Terrain range")) { + Global.TerrainRange = std::clamp(std::exp(terrainrange), 500.0f, 100000.0f); + } + } } return true; @@ -1368,28 +1377,33 @@ debug_panel::update_section_eventqueue( std::vector &Output ) { // current event queue auto const time { Timer::GetTime() }; - auto const *event { simulation::Events.begin() }; auto const searchfilter { std::string( m_eventsearch.data() ) }; + // a copy in running order; when and by whom an event was queued belongs to the + // queue entry now, not to the event + auto const queue { simulation::Events.queue_snapshot() }; Output.emplace_back( "Delay: Event:", Global.UITextColor ); - while( event != nullptr - && Output.size() < 30 ) { + for( auto const &entry : queue ) { + + if( Output.size() >= 30 ) { break; } + + auto const *event { simulation::Events.event_at( entry.index ) }; + if( event == nullptr ) { continue; } if( false == event->m_ignored && false == event->m_passive && ( false == m_eventqueueactivevehicleonly - || event->m_activator == m_input.vehicle ) ) { + || entry.activator == m_input.vehicle ) ) { - auto const label { event->m_name + ( event->m_activator ? " (by: " + event->m_activator->asName + ")" : "" ) }; + auto const label { event->m_name + ( entry.activator ? " (by: " + entry.activator->asName + ")" : "" ) }; if( false == searchfilter.empty() && false == contains(label, searchfilter) ) { - event = event->m_next; continue; } - auto const delay { " " + to_string( std::max( 0.0, event->m_launchtime - time ), 1 ) }; + auto const delay { " " + to_string( std::max( 0.0, entry.launchtime - time ), 1 ) }; textline = delay.substr( delay.length() - 6 ) + " " @@ -1397,11 +1411,10 @@ debug_panel::update_section_eventqueue( std::vector &Output ) { Output.emplace_back( textline, Global.UITextColor ); } - event = event->m_next; } if( Output.size() == 1 ) { // event queue can be empty either because no event got through active filters, or because it is genuinely empty - Output.front().data = simulation::Events.begin() == nullptr ? "(no queued events)" : "(no matching events)"; + Output.front().data = simulation::Events.queue_empty() ? "(no queued events)" : "(no matching events)"; } } @@ -1490,6 +1503,36 @@ debug_panel::update_section_renderer( std::vector &Output ) { Output.emplace_back( textline, Global.UITextColor ); + // terrain streaming: how far tiles are loaded, how many are held, and how far + // behind the camera the loader is + for( auto const &field : TerrainStatus ) { + auto const &stats { field.stats }; + auto const slash { field.path.find_last_of( "/\\" ) }; + Output.emplace_back( + "Terrain: " + ( slash == std::string::npos ? field.path : field.path.substr( slash + 1 ) ) + + ", grid " + to_string( field.gridstep, 1 ) + " m, tile " + to_string( field.tilesize, 0 ) + " m" + + ", loaded out to " + to_string( field.range, 0 ) + " m", + Global.UITextColor ); + Output.emplace_back( + " tiles: " + std::to_string( stats.inview ) + " in view, " + + std::to_string( stats.resident ) + " on gpu (" + std::to_string( stats.texturebytes / 1048576 ) + " MB), " + + std::to_string( stats.wanted ) + " wanted, " + std::to_string( field.backlog ) + " in loader, " + + std::to_string( stats.uploaded ) + " uploaded, " + std::to_string( stats.dropped ) + " dropped stale", + Global.UITextColor ); + std::string levels { " levels:" }; + for( std::uint32_t level = 0; ( level < field.levels ) && ( level < stats.perlevel.size() ); ++level ) { + auto const last { level + 1 == field.levels }; + levels += + " L" + std::to_string( level ) + + ( last ? " beyond " + to_string( terrain_level_distance( level > 0 ? level - 1 : 0 ), 0 ) + : " <" + to_string( terrain_level_distance( level ), 0 ) ) + + " m (" + to_string( field.gridstep * static_cast( 1u << level ), 0 ) + " m grid): " + + std::to_string( stats.perlevel[ level ] ); + if( false == last ) { levels += " |"; } + } + Output.emplace_back( levels, Global.UITextColor ); + } + textline += "\nRendering mode: "; if (Global.GfxRenderer == "default") diff --git a/application/shotrunner.cpp b/application/shotrunner.cpp new file mode 100644 index 00000000..3a1d917c --- /dev/null +++ b/application/shotrunner.cpp @@ -0,0 +1,105 @@ +/* +This Source Code Form is subject to the +terms of the Mozilla Public License, v. +2.0. If a copy of the MPL was not +distributed with this file, You can +obtain one at +http://mozilla.org/MPL/2.0/. +*/ + +module; +#include +#include +#include + +module eu07.application.shotrunner; +import eu07.glm; +import eu07.utilities.logs; +import eu07.utilities.utilities; + +shot_runner Shots; + +bool +shot_runner::parse( std::string const &Text, shot &Result ) { + + std::vector fields; + for( auto const &field : Split( Text, ',' ) ) { + try { + fields.push_back( std::stod( field ) ); + } + catch( ... ) { + return false; + } + } + + if( fields.size() < 3 ) { return false; } + + Result.position = { fields[ 0 ], fields[ 1 ], fields[ 2 ] }; + // the free camera takes its angles as pitch, yaw, roll in radians; a viewpoint is + // written in degrees, because nobody wants to type radians on a command line + Result.angle = { + static_cast( glm::radians( fields.size() > 4 ? fields[ 4 ] : 0.0 ) ), + static_cast( glm::radians( fields.size() > 3 ? fields[ 3 ] : 0.0 ) ), + 0.f }; + Result.label = Text; + return true; +} + +void +shot_runner::configure( std::vector Shots, double const Settle, bool const Quit ) { + + m_shots = std::move( Shots ); + m_settle = Settle; + m_quit = Quit; + m_index = 0; + m_stage = stage::placing; + m_elapsed = 0.0; + m_done = m_shots.empty(); +} + +shot_runner::action +shot_runner::update( double const Deltatime ) { + + if( false == active() ) { return action::none; } + + switch( m_stage ) { + + case stage::placing: { + m_stage = stage::settling; + m_elapsed = 0.0; + WriteLog( "Shot " + std::to_string( m_index + 1 ) + "/" + std::to_string( m_shots.size() ) + + ": moving to " + current().label ); + return action::place; + } + + case stage::settling: { + m_elapsed += Deltatime; + if( m_elapsed < m_settle ) { return action::none; } + m_stage = stage::capturing; + return action::none; + } + + case stage::capturing: { + m_stage = stage::advancing; + WriteLog( "Shot " + std::to_string( m_index + 1 ) + ": capturing" ); + return action::capture; + } + + case stage::advancing: { + // a frame's grace after the capture, since the image is read back and written + // on another thread and quitting immediately would cut it short + m_elapsed += Deltatime; + if( m_elapsed < m_settle + 1.0 ) { return action::none; } + ++m_index; + if( m_index < m_shots.size() ) { + m_stage = stage::placing; + return action::none; + } + m_done = true; + WriteLog( "Shot sequence finished" ); + return m_quit ? action::finish : action::none; + } + } + + return action::none; +} diff --git a/application/shotrunner.cppm b/application/shotrunner.cppm new file mode 100644 index 00000000..3f066f2d --- /dev/null +++ b/application/shotrunner.cppm @@ -0,0 +1,76 @@ +/* +This Source Code Form is subject to the +terms of the Mozilla Public License, v. +2.0. If a copy of the MPL was not +distributed with this file, You can +obtain one at +http://mozilla.org/MPL/2.0/. +*/ + +module; +#include +#include +#include + +export module eu07.application.shotrunner; +import eu07.glm; + +export { + +// Drives an unattended sequence of screenshots: place the camera, let the world settle, +// save a frame, move on, quit. Exists so that a visual change can be checked without a +// person sitting at the machine pressing keys, and checked the same way twice. +// +// The runner owns the sequence and its timing and nothing else. It does not know what a +// camera is or how a frame is saved: it reports what should happen next and the caller, +// which does know, carries it out. +class shot_runner { + +public: + struct shot { + glm::dvec3 position { 0.0, 0.0, 0.0 }; + glm::vec3 angle { 0.f, 0.f, 0.f }; // pitch, yaw, roll in degrees, as the free camera takes them + std::string label; // for the log, so a saved frame can be tied to a viewpoint + }; + + enum class action { + none, // nothing to do this frame + place, // move the camera to current() + capture, // save a frame + finish // the sequence is done + }; + + // parses a "x,y,z[,yaw[,pitch]]" viewpoint. returns false on anything malformed, + // leaving Result untouched + static bool parse( std::string const &Text, shot &Result ); + + void configure( std::vector Shots, double const Settle, bool const Quit ); + bool active() const { return ( false == m_shots.empty() ) && ( false == m_done ); } + shot const ¤t() const { return m_shots[ m_index ]; } + + // advances the sequence by one frame. Deltatime is wall time, so that the settling + // period covers tiles streaming in rather than a fixed number of frames + action update( double const Deltatime ); + +private: + enum class stage { + placing, + settling, + capturing, + advancing + }; + + std::vector m_shots; + std::size_t m_index { 0 }; + stage m_stage { stage::placing }; + double m_settle { 3.0 }; + double m_elapsed { 0.0 }; + bool m_quit { true }; + bool m_done { false }; +}; + +// the session's sequence, configured from the command line and driven by the driver mode. +// idle and harmless when no viewpoints were given +extern shot_runner Shots; + +} // export diff --git a/binary-scene-format-plan.md b/binary-scene-format-plan.md new file mode 100644 index 00000000..0442162b --- /dev/null +++ b/binary-scene-format-plan.md @@ -0,0 +1,773 @@ +# 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ń `_`**, 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**. diff --git a/rendering/opengl33renderer.cpp b/rendering/opengl33renderer.cpp index 595d2e8c..6550faa4 100644 --- a/rendering/opengl33renderer.cpp +++ b/rendering/opengl33renderer.cpp @@ -1627,7 +1627,15 @@ void opengl33_renderer::setup_pass(viewport_config &Viewport, renderpass_config Config.draw_range = std::max( 2000.f, Config.draw_range ); // projection - auto const zfar = ( Zfar > 1.f ? Zfar : Config.draw_range * Zfar ); + auto zfar = ( Zfar > 1.f ? Zfar : Config.draw_range * Zfar ); + if ((false == Ignoredebug) && (Zfar == 1.f)) + { + // the far plane of an ordinary view reaches as far as the terrain is streamed, which + // is its own setting rather than the draw range. only the projection moves: what + // else gets drawn is still decided by draw_range. reverse z on a float depth + // buffer keeps the precision this costs negligible + zfar = std::max(zfar, Global.TerrainRange); + } auto const znear = ( Znear > 1.f ? Znear : Znear > 0.f ? Znear * zfar : 0.1f * Global.ZoomFactor); // the editor's top-down plan view drops the perspective, so distances read the same wherever @@ -2467,6 +2475,7 @@ void opengl33_renderer::Render(scene::basic_region *Region) { case rendermode::color: { + Render_terrain(); Render(std::begin(m_sectionqueue), std::end(m_sectionqueue)); // draw queue is filled while rendering sections if (EditorModeFlag && m_current_viewport->main) @@ -2851,6 +2860,84 @@ void opengl33_renderer::Render(cell_sequence::iterator First, cell_sequence::ite m_frame_instance_buckets.clear(); } +// The heightfield draws itself; what it needs from here is the state every other draw in +// this pass gets - the camera-relative modelview in the model block - and the viewpoint +// the world is being rendered around, so it can place its tiles relative to it. +void opengl33_renderer::Render_terrain() +{ + // opened on first use rather than at init: a scenery names its heightfields in + // terrain directives, which are not read until the scenario is parsed, long after the + // renderer starts. attempted once either way, so a bad path does not retry per frame + if (false == m_terrainattempted) + { + m_terrainattempted = true; + for (auto const &path : Global.terrain_heightfields) + { + auto field{std::make_unique()}; + if (true == field->open(path)) + { + m_terrain.emplace_back(std::move(field)); + } + } + // finest first: drawn in this order, the depth test keeps the better surface + // where two fields describe the same ground + std::sort(std::begin(m_terrain), std::end(m_terrain), + [](auto const &Left, auto const &Right) { return Left->gridstep() < Right->gridstep(); }); + for (std::size_t index = 0; index < m_terrain.size(); ++index) + { + // every field sits a little under the triangles, so where a baked heightfield and + // the geometry it was baked from are both drawn, the geometry is what shows. a + // coarser field is sunk further still, far enough that rounding cannot let it + // through, and little enough to be invisible at the spacing it is drawn at + m_terrain[index]->depthbias(index == 0 ? 0.02f : 0.1f); + } + } + + if (true == m_terrain.empty()) + { + return; + } + + glm::dvec3 const viewpoint{m_renderpass.pass_camera.position()}; + + model_ubs.set_modelview(OpenGLMatrices.data(GL_MODELVIEW)); + model_ubo->update(model_ubs); + + for (auto const &field : m_terrain) + { + // what stays on the gpu is decided by the main view alone. any other colour pass + // draws what is there: letting a second viewport with its own camera and range + // steer residency would evict what the main view is looking at + if (m_current_viewport->main) + { + // the terrain has a range of its own, not the pass draw range + field->range(Global.TerrainRange); + field->update(viewpoint); + } + field->render(viewpoint); + } + + if (m_current_viewport->main) + { + TerrainStatus.resize(m_terrain.size()); + for (std::size_t index = 0; index < m_terrain.size(); ++index) + { + auto const &field{*m_terrain[index]}; + auto &status{TerrainStatus[index]}; + if (status.path.empty()) + { + status.path = field.path(); + status.gridstep = field.gridstep(); + status.tilesize = field.tilesize(); + status.levels = field.levels(); + } + status.range = field.range(); + status.backlog = field.backlog(); + status.stats = field.stats(); + } + } +} + void opengl33_renderer::Draw_Geometry(std::vector::iterator begin, std::vector::iterator end) { m_geometry.draw(begin, end); diff --git a/rendering/opengl33renderer.cppm b/rendering/opengl33renderer.cppm index 2c8e6e03..9d0551d9 100644 --- a/rendering/opengl33renderer.cppm +++ b/rendering/opengl33renderer.cppm @@ -36,6 +36,8 @@ import eu07.rendering.openglcamera; import eu07.rendering.opengl33light; import eu07.rendering.opengl33particles; import eu07.rendering.opengl33skydome; +import eu07.rendering.terrainclipmap; +import eu07.rendering.terrainstatus; import eu07.rendering.opengl33precipitation; import eu07.simulation.simulationenvironment; import eu07.simcore; @@ -46,6 +48,7 @@ import eu07.gl.framebuffer; import eu07.gl.renderbuffer; import eu07.gl.postfx; import eu07.gl.shader; +import eu07.rendering.terrainclipmap; import eu07.gl.cubemap; import eu07.gl.glsl_common; import eu07.gl.pbo; @@ -301,6 +304,8 @@ class opengl33_renderer : public gfx_renderer { void setup_sunlight_intensity( float const Factor = 1.f); // runs jobs needed to generate graphics for specified render pass void Render_pass(viewport_config &vp, rendermode const Mode); + // draws the cooked terrain heightfield, if one is open + void Render_terrain(); // creates dynamic environment cubemap bool Render_reflections(viewport_config &vp); bool Render(world_environment *Environment); @@ -379,6 +384,10 @@ class opengl33_renderer : public gfx_renderer { double m_environmentupdatetime{0}; // time of the most recent environment map update glm::dvec3 m_environmentupdatelocation; // coordinates of most recent environment map update opengl33_skydome m_skydomerenderer; + // cooked terrain heightfields, ordered finest first. a scenery carries a coarse + // regional model and a fine corridor per line, and they overlap + std::vector> m_terrain; + bool m_terrainattempted { false }; opengl33_precipitation m_precipitationrenderer; opengl33_particles m_particlerenderer; // particle visualization subsystem diff --git a/rendering/terrainclipmap.cpp b/rendering/terrainclipmap.cpp new file mode 100644 index 00000000..ef1ec798 --- /dev/null +++ b/rendering/terrainclipmap.cpp @@ -0,0 +1,516 @@ +/* +This Source Code Form is subject to the +terms of the Mozilla Public License, v. +2.0. If a copy of the MPL was not +distributed with this file, You can +obtain one at +http://mozilla.org/MPL/2.0/. +*/ + +module; +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "glad/glad.h" + +#include "scene/heightfieldformat.h" +#include "global_include/interfaces/ITexture_macros.h" +#include "scene/heightfieldreader.h" + +module eu07.rendering.terrainclipmap; +import eu07.glm; +import eu07.utilities.logs; +import eu07.rendering.renderer; +import eu07.model.material; + +namespace { + + +// NOTE: glTexImage2D rather than glTexStorage2D. Immutable storage arrives with OpenGL +// 4.2 and this renderer holds a 3.3 context, where the entry point is simply not there - +// calling it jumps through a null pointer instead of failing in any visible way +GLuint +create_texture( GLenum const Internalformat, GLenum const Type, GLsizei const Side, void const *Data ) { + + GLuint texture { 0 }; + ::glGenTextures( 1, &texture ); + ::glBindTexture( GL_TEXTURE_2D, texture ); + // integer samples carry the cooker's values, no-data included, so nothing may filter them + ::glTexParameteri( GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_NEAREST ); + ::glTexParameteri( GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_NEAREST ); + ::glTexParameteri( GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, GL_CLAMP_TO_EDGE ); + ::glTexParameteri( GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, GL_CLAMP_TO_EDGE ); + ::glTexImage2D( GL_TEXTURE_2D, 0, Internalformat, Side, Side, 0, GL_RED_INTEGER, Type, Data ); + return texture; +} + +} // namespace + +terrain_clipmap::~terrain_clipmap() { + close(); +} + +bool +terrain_clipmap::open( std::string const &Path ) { + + close(); + + if( ( false == m_reader.open( Path ) ) + || ( false == m_loader.open( Path ) ) ) { + ErrorLog( "Terrain: cannot open heightfield \"" + Path + "\"" ); + close(); + return false; + } + + m_path = Path; + build_indices(); + build_palette(); + resolve_materials(); + + try { + gl::shader vertex( "terrainclipmap.vert" ); + gl::shader fragment( "terrainclipmap.frag" ); + m_shader.emplace( std::vector>( { vertex, fragment } ) ); + } + catch( gl::shader_exception const &error ) { + ErrorLog( std::string( "Terrain: shader failed, " ) + error.what() ); + close(); + return false; + } + + m_vao.emplace(); + resolve_uniforms(); + m_ready = true; + + WriteLog( + "Terrain: " + Path + ", " + std::to_string( m_reader.tiles().size() ) + " tiles, " + + std::to_string( m_reader.levels() ) + " levels, grid " + + std::to_string( m_reader.gridstep() ) + " m, tile " + std::to_string( m_reader.tilesize() ) + " m" ); + + return true; +} + +// uniform locations do not change once the program is linked, so they are looked up +// here rather than per tile per frame +void +terrain_clipmap::resolve_uniforms() { + + auto const program { static_cast( *m_shader ) }; + m_uniforms.tileorigin = ::glGetUniformLocation( program, "tileorigin" ); + m_uniforms.samplestep = ::glGetUniformLocation( program, "samplestep" ); + m_uniforms.heightbias = ::glGetUniformLocation( program, "heightbias" ); + m_uniforms.heightscale = ::glGetUniformLocation( program, "heightscale" ); + m_uniforms.nodata = ::glGetUniformLocation( program, "nodata" ); + m_uniforms.side = ::glGetUniformLocation( program, "side" ); + m_uniforms.heights = ::glGetUniformLocation( program, "heights" ); + m_uniforms.materials = ::glGetUniformLocation( program, "materials" ); + m_uniforms.palette = ::glGetUniformLocation( program, "palette" ); + m_uniforms.groundtexture = ::glGetUniformLocation( program, "ground" ); + m_uniforms.groundscale = ::glGetUniformLocation( program, "groundscale" ); + m_uniforms.drawnmaterial = ::glGetUniformLocation( program, "drawnmaterial" ); + m_uniforms.tileworld = ::glGetUniformLocation( program, "tileworld" ); + m_uniforms.hastexture = ::glGetUniformLocation( program, "hastexture" ); + m_uniforms.fallback = ::glGetUniformLocation( program, "fallback" ); +} + +void +terrain_clipmap::close() { + + // the loader first, so nothing arrives for tiles that are being torn down + m_loader.close(); + m_wanted.clear(); + m_arrived.clear(); + m_inrange.clear(); + m_wantedlevel.clear(); + m_scanrange = -1.f; + for( auto & [ tilekey, tile ] : m_tiles ) { retire( tile ); } + m_tiles.clear(); + m_indices.clear(); + m_indexcounts.clear(); + m_vao.reset(); + m_shader.reset(); + m_palette.clear(); + m_reader.close(); + m_stats = {}; + m_ready = false; +} + +// index buffers, one per mip level. the grid they describe is the same shape at every +// level, only smaller, and the vertices themselves never exist in memory: the shader +// derives a sample's row and column from gl_VertexID +void +terrain_clipmap::build_indices() { + + m_indices.resize( m_reader.levels() ); + m_indexcounts.resize( m_reader.levels() ); + + std::vector indices; + for( std::uint32_t level = 0; level < m_reader.levels(); ++level ) { + + auto const side { m_reader.samples_per_side( level ) }; + indices.clear(); + indices.reserve( static_cast( side - 1 ) * ( side - 1 ) * 6 ); + + for( std::uint32_t row = 0; row + 1 < side; ++row ) { + for( std::uint32_t column = 0; column + 1 < side; ++column ) { + auto const topleft { row * side + column }; + auto const topright { topleft + 1 }; + auto const bottomleft { topleft + side }; + auto const bottomright { bottomleft + 1 }; + indices.push_back( topleft ); indices.push_back( bottomleft ); indices.push_back( topright ); + indices.push_back( topright ); indices.push_back( bottomleft ); indices.push_back( bottomright ); + } + } + + m_indexcounts[ level ] = static_cast( indices.size() ); + m_indices[ level ] = std::make_unique(); + m_indices[ level ]->allocate( + gl::buffer::ELEMENT_ARRAY_BUFFER, indices.size() * sizeof( std::uint32_t ), GL_STATIC_DRAW ); + m_indices[ level ]->upload( + gl::buffer::ELEMENT_ARRAY_BUFFER, indices.data(), 0, indices.size() * sizeof( std::uint32_t ) ); + } +} + +// placeholder colours, one per cooked material, so that the shape of the terrain and the +// division into surfaces can be judged before the real materials are wired in +void +terrain_clipmap::build_palette() { + + m_palette.clear(); + m_palette.reserve( std::max( 1, m_reader.materials().size() ) ); + for( std::size_t index = 0; index < m_reader.materials().size(); ++index ) { + auto const hue { static_cast( index ) * 0.61803399f }; + auto const phase { ( hue - std::floor( hue ) ) * 6.f }; + auto const rising { phase - std::floor( phase ) }; + glm::vec3 colour { 0.45f, 0.5f, 0.4f }; + switch( static_cast( phase ) ) { + case 0: colour = { 0.55f, 0.45f + 0.2f * rising, 0.3f }; break; + case 1: colour = { 0.55f - 0.2f * rising, 0.6f, 0.3f }; break; + case 2: colour = { 0.35f, 0.6f, 0.3f + 0.2f * rising }; break; + case 3: colour = { 0.35f, 0.6f - 0.15f * rising, 0.5f }; break; + case 4: colour = { 0.35f + 0.2f * rising, 0.45f, 0.5f }; break; + default: colour = { 0.55f, 0.45f, 0.5f - 0.15f * rising }; break; + } + m_palette.push_back( colour ); + } + if( true == m_palette.empty() ) { m_palette.push_back( { 0.45f, 0.5f, 0.4f } ); } +} + +std::uint32_t +terrain_clipmap::level_for( double const Distance ) const { + + if( Distance <= terrain_finest_range ) { return 0; } + auto const steps { static_cast( std::log2( Distance / terrain_finest_range ) ) + 1 }; + return std::min( steps, m_reader.levels() - 1 ); +} + +void +terrain_clipmap::upload( terrain_tile_loader::payload const &Tile ) { + + auto const tilekey { key( Tile.tile.x, Tile.tile.z ) }; + auto const side { m_reader.samples_per_side( Tile.tile.level ) }; + + auto const existing { m_tiles.find( tilekey ) }; + if( existing != m_tiles.end() ) { + // a level change means new textures: the sample count differs + retire( existing->second ); + m_tiles.erase( existing ); + } + + resident_tile tile; + tile.x = Tile.tile.x; + tile.z = Tile.tile.z; + tile.level = Tile.tile.level; + tile.side = side; + tile.lastused = m_frame; + tile.heighttexture = create_texture( + GL_R16UI, GL_UNSIGNED_SHORT, static_cast( side ), Tile.heights.data() ); + tile.materialtexture = create_texture( + GL_R8UI, GL_UNSIGNED_BYTE, static_cast( side ), Tile.materials.data() ); + + // the tile is drawn once per material it contains, so the set is worked out here + // rather than rescanned every frame + std::array present {}; + for( auto const material : Tile.materials ) { present[ material ] = true; } + for( std::size_t material = 0; material < present.size(); ++material ) { + if( true == present[ material ] ) { tile.materials.push_back( static_cast( material ) ); } + } + + if( auto const *description { m_reader.find( Tile.tile.x, Tile.tile.z ) } ) { + tile.minheight = description->minheight; + tile.maxheight = description->maxheight; + } + + m_stats.texturebytes += static_cast( side ) * side * 3u; + ++m_stats.uploaded; + m_tiles.emplace( tilekey, std::move( tile ) ); +} + +void +terrain_clipmap::retire( resident_tile &Tile ) { + + if( Tile.heighttexture != 0 ) { ::glDeleteTextures( 1, &Tile.heighttexture ); Tile.heighttexture = 0; } + if( Tile.materialtexture != 0 ) { ::glDeleteTextures( 1, &Tile.materialtexture ); Tile.materialtexture = 0; } + auto const bytes { static_cast( Tile.side ) * Tile.side * 3u }; + m_stats.texturebytes -= std::min( m_stats.texturebytes, bytes ); +} + +void +terrain_clipmap::scan( glm::dvec3 const &Viewpoint ) { + + m_scanpoint = Viewpoint; + m_scanrange = m_range; + m_inrange.clear(); + m_wantedlevel.clear(); + + auto const tilesize { static_cast( m_reader.tilesize() ) }; + auto const reach { std::max( 1, static_cast( m_range / tilesize ) + 1 ) }; + auto const centrex { m_reader.tile_x( Viewpoint.x ) }; + auto const centrez { m_reader.tile_z( Viewpoint.z ) }; + std::vector> wishes; + + for( std::int32_t z = centrez - reach; z <= centrez + reach; ++z ) { + for( std::int32_t x = centrex - reach; x <= centrex + reach; ++x ) { + + if( false == m_reader.contains( x, z ) ) { continue; } + + // distance to the nearest point of the tile, not to its centre. a regional + // field has tiles kilometres across, and a tile the camera is standing on + // can have its centre far outside the draw range + auto const west { x * tilesize }; + auto const north { z * tilesize }; + auto const nearestx { std::clamp( Viewpoint.x, west, west + tilesize ) }; + auto const nearestz { std::clamp( Viewpoint.z, north, north + tilesize ) }; + auto const distance { + glm::length( glm::dvec2 { nearestx - Viewpoint.x, nearestz - Viewpoint.z } ) }; + if( distance > m_range ) { continue; } + + auto const tilekey { key( x, z ) }; + auto const level { level_for( distance ) }; + m_inrange.push_back( { tilekey, level } ); + m_wantedlevel.emplace( tilekey, level ); + + auto const resident { m_tiles.find( tilekey ) }; + if( ( resident == m_tiles.end() ) || ( resident->second.level != level ) ) { + wishes.push_back( { distance, { x, z, level } } ); + } + } + } + + // nearest first, so the ground under the camera is never waiting behind the horizon + std::sort( wishes.begin(), wishes.end(), + []( auto const &Left, auto const &Right ) { return Left.first < Right.first; } ); + m_wanted.clear(); + for( auto const &wish : wishes ) { m_wanted.push_back( wish.second ); } + m_loader.want( m_wanted ); +} + +void +terrain_clipmap::update( glm::dvec3 const &Viewpoint ) { + + if( false == m_ready ) { return; } + + ++m_frame; + + auto const moved { + glm::length( glm::dvec2 { Viewpoint.x - m_scanpoint.x, Viewpoint.z - m_scanpoint.z } ) }; + if( ( m_range != m_scanrange ) + || ( moved > m_reader.tilesize() * 0.25 ) ) { + scan( Viewpoint ); + } + + // the tiles in range are touched every frame, which is what render() draws and what + // eviction spares + m_stats.inview = 0; + m_stats.wanted = 0; + m_stats.perlevel.fill( 0 ); + for( auto const &tile : m_inrange ) { + auto const resident { m_tiles.find( tile.key ) }; + if( resident == m_tiles.end() ) { + ++m_stats.wanted; + continue; + } + // still wanted, at whatever level it has until the right one arrives + resident->second.lastused = m_frame; + ++m_stats.inview; + ++m_stats.perlevel[ std::min( resident->second.level, m_stats.perlevel.size() - 1 ) ]; + if( resident->second.level != tile.level ) { ++m_stats.wanted; } + } + + m_arrived.clear(); + m_loader.collect( m_arrived, m_uploadsperframe ); + for( auto const &tile : m_arrived ) { + if( false == tile.valid ) { continue; } + // a tile read for where the camera was is put up only if nothing of it is on the gpu + // yet - some level is better than a hole. otherwise it is dropped, and the level + // wanted now is already queued + auto const tilekey { key( tile.tile.x, tile.tile.z ) }; + auto const wanted { m_wantedlevel.find( tilekey ) }; + auto const stillwanted { ( wanted != m_wantedlevel.end() ) && ( wanted->second == tile.tile.level ) }; + if( stillwanted || ( false == m_tiles.contains( tilekey ) ) ) { + upload( tile ); + // put up this frame, so drawn this frame + m_tiles.find( tilekey )->second.lastused = m_frame; + } + else { + ++m_stats.dropped; + } + } + + if( ( m_tiles.size() > m_budget ) && ( m_tiles.size() > m_stats.inview ) ) { + // over budget, and not everything is in view: drop whatever has gone longest without + // being asked for + std::vector> aged; + for( auto const & [ tilekey, tile ] : m_tiles ) { + if( tile.lastused != m_frame ) { aged.emplace_back( tile.lastused, tilekey ); } + } + auto const excess { std::min( aged.size(), m_tiles.size() - m_budget ) }; + std::partial_sort( aged.begin(), aged.begin() + excess, aged.end() ); + for( std::size_t index = 0; index < excess; ++index ) { + auto const victim { m_tiles.find( aged[ index ].second ) }; + retire( victim->second ); + m_tiles.erase( victim ); + } + } + + m_stats.resident = m_tiles.size(); + report_residency(); +} + +// the cooked material names are the ones the scenery used, so the engine resolves them +// exactly as it does for any other geometry. only the diffuse texture is taken: the +// terrain has its own shader and none of the rest of a material applies to it +void +terrain_clipmap::resolve_materials() { + + m_materials.clear(); + auto const &names { m_reader.materials() }; + auto const &scales { m_reader.material_scales() }; + m_materials.reserve( names.size() ); + + std::size_t resolved { 0 }; + std::string unresolved; + for( std::size_t index = 0; index < names.size(); ++index ) { + + material_binding binding; + binding.scale = ( index < scales.size() ? scales[ index ] : 4.f ); + if( binding.scale < 0.01f ) { binding.scale = 4.f; } + + auto const material { GfxRenderer->Fetch_Material( names[ index ] ) }; + if( material != null_handle ) { + binding.texture = GfxRenderer->Material( material )->GetTexture( 0 ); + } + if( binding.texture != null_handle ) { + ++resolved; + } + else { + // drawn in its placeholder colour instead, so a missing texture shows up as + // an obvious flat patch rather than as black ground + unresolved += ( unresolved.empty() ? "" : ", " ) + names[ index ]; + } + m_materials.push_back( binding ); + } + + WriteLog( + "Terrain: " + std::to_string( resolved ) + " of " + std::to_string( names.size() ) + + " materials resolved to textures" ); + if( false == unresolved.empty() ) { + WriteLog( "Terrain: no texture for " + unresolved ); + } +} + +// residency settles within a second of the camera stopping and then barely moves, so a +// line is written when it shifts by a noticeable amount rather than every frame. that +// makes the warm figure - what the terrain actually costs - readable from the log +void +terrain_clipmap::report_residency() { + + auto const difference { + m_stats.resident > m_reportedresidency ? + m_stats.resident - m_reportedresidency : + m_reportedresidency - m_stats.resident }; + if( ( m_reported == true ) && ( difference < 16 ) ) { return; } + + m_reported = true; + m_reportedresidency = m_stats.resident; + WriteLog( + "Terrain: " + std::to_string( m_stats.resident ) + " tiles resident, " + + std::to_string( m_stats.texturebytes / 1048576 ) + " MB of samples, " + + std::to_string( m_stats.uploaded ) + " uploads so far" ); +} + +void +terrain_clipmap::render( glm::dvec3 const &Viewpoint ) { + + if( ( false == m_ready ) || ( true == m_tiles.empty() ) ) { return; } + + m_shader->bind(); + m_vao->bind(); + + ::glUniform1f( m_uniforms.heightbias, m_reader.height( 0 ) ); + ::glUniform1f( m_uniforms.heightscale, m_reader.height( 1 ) - m_reader.height( 0 ) ); + ::glUniform1ui( m_uniforms.nodata, heightfield::nodata ); + ::glUniform1i( m_uniforms.heights, 0 ); + ::glUniform1i( m_uniforms.materials, 1 ); + ::glUniform1i( m_uniforms.groundtexture, 2 ); + ::glUniform3fv( + m_uniforms.palette, static_cast( m_palette.size() ), &m_palette.front().x ); + + auto const tilesize { static_cast( m_reader.tilesize() ) }; + auto const gridstep { m_reader.gridstep() }; + + for( auto const & [ tilekey, tile ] : m_tiles ) { + + if( tile.lastused != m_frame ) { continue; } + + // tile position relative to the viewpoint, so that the shader never sees a + // coordinate large enough for single precision to matter + glm::vec3 const origin { + static_cast( tile.x * tilesize - Viewpoint.x ), + static_cast( -Viewpoint.y ) - m_depthbias, + static_cast( tile.z * tilesize - Viewpoint.z ) }; + ::glUniform3fv( m_uniforms.tileorigin, 1, &origin.x ); + ::glUniform1f( m_uniforms.samplestep, gridstep * static_cast( 1u << tile.level ) ); + ::glUniform1i( m_uniforms.side, static_cast( tile.side ) ); + ::glUniform2f( + m_uniforms.tileworld, + static_cast( tile.x * tilesize ), static_cast( tile.z * tilesize ) ); + + ::glActiveTexture( GL_TEXTURE0 ); + ::glBindTexture( GL_TEXTURE_2D, tile.heighttexture ); + ::glActiveTexture( GL_TEXTURE1 ); + ::glBindTexture( GL_TEXTURE_2D, tile.materialtexture ); + + m_indices[ tile.level ]->bind( gl::buffer::ELEMENT_ARRAY_BUFFER ); + + // one pass per material in the tile: each wants a different texture bound, and + // a fragment belonging to any other material is dropped + for( auto const material : tile.materials ) { + + auto const &binding { + material < m_materials.size() ? m_materials[ material ] : material_binding {} }; + GfxRenderer->Bind_Texture( 2, binding.texture ); + ::glUniform1f( m_uniforms.groundscale, binding.scale ); + ::glUniform1ui( m_uniforms.drawnmaterial, material ); + ::glUniform1i( m_uniforms.hastexture, binding.texture != null_handle ? 1 : 0 ); + auto const &fallback { + m_palette[ std::min( material, m_palette.size() - 1 ) ] }; + ::glUniform3fv( m_uniforms.fallback, 1, &fallback.x ); + + ::glDrawElements( + GL_TRIANGLES, static_cast( m_indexcounts[ tile.level ] ), GL_UNSIGNED_INT, nullptr ); + } + } + + ::glActiveTexture( GL_TEXTURE0 ); + m_vao->unbind(); + gl::program::bind( 0 ); +} diff --git a/rendering/terrainclipmap.cppm b/rendering/terrainclipmap.cppm new file mode 100644 index 00000000..c344a526 --- /dev/null +++ b/rendering/terrainclipmap.cppm @@ -0,0 +1,179 @@ +/* +This Source Code Form is subject to the +terms of the Mozilla Public License, v. +2.0. If a copy of the MPL was not +distributed with this file, You can +obtain one at +http://mozilla.org/MPL/2.0/. +*/ + +module; +#include +#include +#include +#include +#include +#include +#include + +#include "global_include/interfaces/ITexture_macros.h" +#include "scene/heightfieldreader.h" + +export module eu07.rendering.terrainclipmap; +import eu07.gl.shader; +import eu07.gl.buffer; +import eu07.gl.vao; +import eu07.glm; +import eu07.rendering.terraintileloader; +import eu07.rendering.terrainstatus; +import eu07.model.material; +import eu07.global_include.interfaces.itexture; + +export { + +// Draws the cooked terrain heightfield. +// +// Not a toroidal clipmap, despite what the plan called it. The measurements say the +// terrain is a ribbon: 390 km2 of surface inside a 51 x 46 km rectangle, twelve percent +// of the tile bounding box occupied. Clipmap rings sized for that rectangle would be +// mostly empty, so this draws the cooked tiles themselves, each at the mip level its +// distance earns. The tiles were laid out for exactly this - sorted by morton code, one +// shared sample of overlap so neighbouring levels meet without skirts. +// +// The class owns its gl resources and the file handle, and knows nothing about the +// scenery, the camera class or the rest of the renderer: it is given a viewpoint and a +// pair of matrices, and draws. +class terrain_clipmap { + +public: + terrain_clipmap() = default; + ~terrain_clipmap(); + terrain_clipmap( terrain_clipmap const & ) = delete; + terrain_clipmap &operator=( terrain_clipmap const & ) = delete; + + // opens a cooked heightfield and builds the gl resources. returns false when the + // file cannot be read, leaving the object unusable but safe to destroy + bool open( std::string const &Path ); + bool ready() const { return m_ready; } + void close(); + + // asks for the tiles around the viewpoint, uploads a bounded number of those that have + // arrived, and retires the ones that left range. safe to call every frame: the reading + // and decoding happen on the loader's thread, and a tile waiting for a finer level keeps + // being drawn at the level it has + void update( glm::dvec3 const &Viewpoint ); + // draws the resident tiles. the caller is expected to have the scene and model + // uniform blocks already set for a camera-relative pass, the way the rest of the + // renderer does: tile positions reach the shader relative to the viewpoint, so + // nothing here needs a matrix of its own + void render( glm::dvec3 const &Viewpoint ); + + // how far from the viewpoint terrain is drawn, in metres + void range( float const Range ) { m_range = Range; } + float range() const { return m_range; } + + // metres between samples at the finest level; a scenery's fields are drawn from the + // finest to the coarsest so that the depth test keeps the better surface + float gridstep() const { return m_reader.gridstep(); } + // sinks this field by the given amount, so a coarse field cannot win fragments from + // a finer one describing the same ground + void depthbias( float const Metres ) { m_depthbias = Metres; } + + terrain_statistics const &stats() const { return m_stats; } + std::string const &path() const { return m_path; } + float tilesize() const { return static_cast( m_reader.tilesize() ); } + std::uint32_t levels() const { return m_reader.levels(); } + std::size_t backlog() const { return m_loader.backlog(); } + +private: + // one tile resident on the gpu. heights are unsigned so that the no-data value stays + // exactly what the cooker wrote instead of being normalised into a float + struct resident_tile { + std::int32_t x { 0 }, z { 0 }; + std::uint32_t level { 0 }; // mip level currently uploaded + std::uint32_t side { 0 }; // samples per side at that level + std::uint32_t heighttexture { 0 }; + std::uint32_t materialtexture { 0 }; + float minheight { 0.f }, maxheight { 0.f }; + std::uint64_t lastused { 0 }; + // materials appearing in this tile. a tile is drawn once per entry, since each + // needs its own texture bound, and most tiles carry only one or two + std::vector materials; + }; + + // mip level a tile at the given distance is drawn at + std::uint32_t level_for( double const Distance ) const; + // works out which tiles are in range and at what level, and tells the loader what is + // missing. the answer only changes once the camera has moved a fair part of a tile + void scan( glm::dvec3 const &Viewpoint ); + // puts a tile the loader has read on the gpu, replacing whatever level it had before + void upload( terrain_tile_loader::payload const &Tile ); + void retire( resident_tile &Tile ); + void build_indices(); + void build_palette(); + // resolves the cooked material names to the engine's materials, once + void resolve_materials(); + void resolve_uniforms(); + void report_residency(); + + static std::int64_t key( std::int32_t const X, std::int32_t const Z ) { return heightfield::tile_key( X, Z ); } + + // for the file's layout only: tile coordinates, levels, materials. tiles themselves are + // read by the loader, on its own handle + heightfield::reader m_reader; + terrain_tile_loader m_loader; + std::vector m_wanted; + std::vector m_arrived; + // one tile within range, and the level its distance earns + struct in_range { + std::int64_t key; + std::uint32_t level; + }; + // what the last scan found in range, and the level wanted for each tile, to tell a useful + // arrival from one the camera has already moved past + std::vector m_inrange; + std::unordered_map m_wantedlevel; + glm::dvec3 m_scanpoint { 0.0 }; + float m_scanrange { -1.f }; + std::unordered_map m_tiles; + // one index buffer per mip level, over a grid of that level's sample count. vertices + // are computed in the shader from gl_VertexID, so there is no vertex buffer at all. + // held by pointer because a gl::buffer owns a name it cannot hand to a copy + std::vector> m_indices; + std::vector m_indexcounts; + std::optional m_vao; + std::optional m_shader; + // resolved once at link time; -1 for a uniform the compiler dropped, which gl ignores + struct uniform_locations { + int tileorigin { -1 }, samplestep { -1 }; + int heightbias { -1 }, heightscale { -1 }, nodata { -1 }, side { -1 }; + int heights { -1 }, materials { -1 }, palette { -1 }; + int groundtexture { -1 }, groundscale { -1 }, drawnmaterial { -1 }, tileworld { -1 }; + int hastexture { -1 }, fallback { -1 }; + } m_uniforms; + std::vector m_palette; + // one entry per cooked material: the engine texture to bind and how much ground one + // repeat of it covers + struct material_binding { + texture_handle texture { null_handle }; + float scale { 4.f }; + }; + std::vector m_materials; + + terrain_statistics m_stats; + std::uint64_t m_frame { 0 }; + std::size_t m_reportedresidency { 0 }; + bool m_reported { false }; + float m_range { 6000.f }; + float m_depthbias { 0.f }; + // tiles allowed to stay resident; at 129 samples a tile costs about 50 kB of texture + std::size_t m_budget { 2048 }; + // tiles put on the gpu per frame. a tile is about 50 kB of texture, so this is not about + // the upload itself but about never spending a frame on terrain the camera will reach + // later anyway + std::size_t m_uploadsperframe { 32 }; + bool m_ready { false }; + std::string m_path; +}; + +} // export diff --git a/rendering/terrainstatus.cppm b/rendering/terrainstatus.cppm new file mode 100644 index 00000000..0e7d45d8 --- /dev/null +++ b/rendering/terrainstatus.cppm @@ -0,0 +1,57 @@ +/* +This Source Code Form is subject to the +terms of the Mozilla Public License, v. +2.0. If a copy of the MPL was not +distributed with this file, You can +obtain one at +http://mozilla.org/MPL/2.0/. +*/ + +module; +#include +#include +#include +#include +#include +#include + +export module eu07.rendering.terrainstatus; + +export { + +// distance out to which a terrain tile is drawn at its finest level; each further level +// doubles it +inline constexpr double terrain_finest_range { 512.0 }; + +// the distance up to which a level is used +inline double +terrain_level_distance( std::uint32_t const Level ) { + return terrain_finest_range * std::pow( 2.0, Level ); +} + +// what one terrain field's streamer is doing, kept by the field and read by the debug panel +struct terrain_statistics { + std::size_t resident { 0 }; // tiles holding gl textures + std::size_t inview { 0 }; // resident tiles within range in the last update + std::size_t wanted { 0 }; // tiles in range missing, or at the wrong level + std::size_t uploaded { 0 }; // uploads since opening + std::size_t dropped { 0 }; // arrivals discarded as stale since opening + std::size_t texturebytes { 0 }; + std::array perlevel {}; // tiles in view at each level +}; + +// one field as the debug panel shows it. the renderer writes it once a frame from the main +// view, the panel reads it; neither needs to know the other's types +struct terrain_field_status { + std::string path; + float gridstep { 0.f }; + float tilesize { 0.f }; + float range { 0.f }; + std::uint32_t levels { 0 }; + std::size_t backlog { 0 }; // tiles the loader has queued or finished and not handed over + terrain_statistics stats; +}; + +inline std::vector TerrainStatus; + +} diff --git a/rendering/terraintileloader.cpp b/rendering/terraintileloader.cpp new file mode 100644 index 00000000..f8452ba7 --- /dev/null +++ b/rendering/terraintileloader.cpp @@ -0,0 +1,129 @@ +/* +This Source Code Form is subject to the +terms of the Mozilla Public License, v. +2.0. If a copy of the MPL was not +distributed with this file, You can +obtain one at +http://mozilla.org/MPL/2.0/. +*/ + +module; +#include +#include +#include +#include +#include +#include +#include +#include "scene/heightfieldreader.h" + +module eu07.rendering.terraintileloader; + +terrain_tile_loader::~terrain_tile_loader() { + + close(); +} + +bool +terrain_tile_loader::open( std::string const &Path ) { + + close(); + if( false == m_reader.open( Path ) ) { return false; } + m_stop = false; + m_thread = std::thread( &terrain_tile_loader::work, this ); + return true; +} + +void +terrain_tile_loader::close() { + + if( true == m_thread.joinable() ) { + { + std::lock_guard lock( m_mutex ); + m_stop = true; + } + m_wake.notify_all(); + m_thread.join(); + } + std::lock_guard lock( m_mutex ); + m_pending.clear(); + m_done.clear(); + m_running.reset(); + m_reader.close(); +} + +bool +terrain_tile_loader::underway( request const &Tile ) const { + + if( ( m_running.has_value() ) && ( *m_running == Tile ) ) { return true; } + return std::any_of( + m_done.begin(), m_done.end(), + [ &Tile ]( payload const &Done ) { return Done.tile == Tile; } ); +} + +void +terrain_tile_loader::want( std::vector const &Requests ) { + + { + std::lock_guard lock( m_mutex ); + m_pending.clear(); + for( auto const &tile : Requests ) { + if( false == underway( tile ) ) { + m_pending.push_back( tile ); + } + } + } + m_wake.notify_one(); +} + +std::size_t +terrain_tile_loader::collect( std::vector &Out, std::size_t const Count ) { + + std::unique_lock lock( m_mutex ); + std::size_t moved { 0 }; + while( ( moved < Count ) && ( false == m_done.empty() ) ) { + Out.emplace_back( std::move( m_done.front() ) ); + m_done.pop_front(); + ++moved; + } + lock.unlock(); + if( moved > 0 ) { + // room was made; the thread may be waiting for it + m_wake.notify_one(); + } + return moved; +} + +std::size_t +terrain_tile_loader::backlog() const { + + std::lock_guard lock( m_mutex ); + return m_pending.size() + m_done.size() + ( m_running.has_value() ? 1 : 0 ); +} + +void +terrain_tile_loader::work() { + + while( true ) { + request tile; + { + std::unique_lock lock( m_mutex ); + m_wake.wait( lock, [ this ]() { + return m_stop || ( ( false == m_pending.empty() ) && ( m_done.size() < donelimit ) ); } ); + if( true == m_stop ) { return; } + tile = m_pending.front(); + m_pending.pop_front(); + m_running = tile; + } + + // the read itself happens outside the lock, so the render thread can replace the + // wish list while a tile is being decoded + payload result; + result.tile = tile; + result.valid = m_reader.read_level( tile.x, tile.z, tile.level, result.heights, result.materials ); + + std::lock_guard lock( m_mutex ); + m_running.reset(); + m_done.emplace_back( std::move( result ) ); + } +} diff --git a/rendering/terraintileloader.cppm b/rendering/terraintileloader.cppm new file mode 100644 index 00000000..62f88100 --- /dev/null +++ b/rendering/terraintileloader.cppm @@ -0,0 +1,90 @@ +/* +This Source Code Form is subject to the +terms of the Mozilla Public License, v. +2.0. If a copy of the MPL was not +distributed with this file, You can +obtain one at +http://mozilla.org/MPL/2.0/. +*/ + +module; +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include "scene/heightfieldreader.h" + +export module eu07.rendering.terraintileloader; + +export { + +// Reads and decodes heightfield tiles on a thread of its own. +// +// Reading a tile means a seek, a read and a zstd decode, which is too much to do on the render +// thread for the dozens of tiles a moving camera asks for at once. This does that part and +// nothing else: it knows no gl, keeps its own file handle, and hands back plain sample arrays +// for the renderer to upload at a pace it chooses. +// +// The renderer says each frame what it wants, nearest first, and that list replaces whatever +// was asked for before and has not been started. A tile the camera has already left is never +// read, and the nearest tile is always the next one read. +class terrain_tile_loader { + +public: + struct request { + std::int32_t x { 0 }, z { 0 }; + std::uint32_t level { 0 }; + + bool operator==( request const & ) const = default; + }; + + struct payload { + request tile; + bool valid { false }; + std::vector heights; + std::vector materials; + }; + + terrain_tile_loader() = default; + ~terrain_tile_loader(); + terrain_tile_loader( terrain_tile_loader const & ) = delete; + terrain_tile_loader &operator=( terrain_tile_loader const & ) = delete; + + // opens the file on the loader's own handle and starts the thread + bool open( std::string const &Path ); + // stops the thread; anything not collected is dropped + void close(); + + // replaces the list of wanted tiles, nearest first. tiles being read or already read and + // not yet collected are left out of it, so asking again every frame costs nothing + void want( std::vector const &Requests ); + // moves up to Count finished tiles into Out, returning how many + std::size_t collect( std::vector &Out, std::size_t const Count ); + // tiles queued or finished and not yet collected + std::size_t backlog() const; + +private: + void work(); + // whether the tile is being read or waits to be collected. caller holds the lock + bool underway( request const &Tile ) const; + + heightfield::reader m_reader; + std::thread m_thread; + mutable std::mutex m_mutex; + std::condition_variable m_wake; + std::deque m_pending; + std::optional m_running; + std::deque m_done; + bool m_stop { false }; + // finished tiles allowed to wait for collection. past this the thread stops reading: + // the renderer uploads at its own pace, and a backlog read for where the camera was a + // second ago is work spent on tiles nobody wants any more + static constexpr std::size_t donelimit { 32 }; +}; + +} diff --git a/scene/heightfieldformat.h b/scene/heightfieldformat.h new file mode 100644 index 00000000..8f66a734 --- /dev/null +++ b/scene/heightfieldformat.h @@ -0,0 +1,167 @@ +/* +This Source Code Form is subject to the +terms of the Mozilla Public License, v. +2.0. If a copy of the MPL was not +distributed with this file, You can +obtain one at +http://mozilla.org/MPL/2.0/. +*/ + +#pragma once + +// On-disk layout of the cooked terrain heightfield. Single source of truth: the cooker +// (tools/terraincook) writes it, the engine reads it, and neither owns a private copy. +// Plain structs and free functions only - no engine types, no allocation, no i/o - so +// that the tool keeps building without the engine and the format stays readable on its own. +// +// File layout: +// header +// tile directory, one entry per tile, ordered by morton code +// material table, each entry a uint16 length, that many name bytes, and a float +// giving how many metres of ground one repeat of the texture covers +// tile payloads, at the offsets the directory gives +// +// A tile payload holds a mip chain, finest level first. Level l has +// samples_at_level( tilesamples, l ) values along a side, laid out as that many rows of +// uint16 heights, followed by the same grid of uint8 material indices. + +#include + +namespace heightfield { + +// "EU07HFLD", spelled out so the header needs no string functions +inline constexpr char magic[ 8 ] { 'E', 'U', '0', '7', 'H', 'F', 'L', 'D' }; +inline constexpr std::uint32_t version { 5 }; +// terrain selection rules, stamped into every cooked file. +// 0 - every triangle node the scenery holds +// 1 - world space triangle nodes only: anything under origin, rotate or scale is an object +// 2 - every triangle as drawn; a triangle stays drawn unless the heightfield shows it, and a +// triangle bordering a hole always stays drawn +// 3 - as 2, with a hole checked for diagonally and at the coarser levels too +inline constexpr std::uint32_t selection_rules { 3 }; + +// height value reserved to mean "this sample carries no terrain"; a cooked map is a +// ribbon inside a much larger rectangle, so most edge tiles are partly empty +inline constexpr std::uint16_t nodata { 0xffff }; +// the range the remaining values span, shared by every tile through the header's bias +// and scale. over l204's 107 m of relief this puts a sample within 2 mm of the source +inline constexpr std::uint32_t heightrange { 0xfffe }; + +enum class codec : std::uint16_t { + raw = 0, + zstd = 1 +}; + +#pragma pack( push, 1 ) + +struct file_header { + char magic[ 8 ]; + std::uint32_t version; + // samples along a tile side, not counting the shared edge. a tile therefore stores + // one more sample in each direction than it owns + std::uint32_t tilesamples; + float gridstep; // metres between samples at the finest level + float heightbias; // world height of stored value 0 + float heightscale; // metres per stored unit + std::int32_t minx, minz, maxx, maxz; // tile coordinates present in the file, inclusive + std::uint32_t tilecount; + std::uint32_t materialcount; + std::uint32_t miplevels; // levels stored per tile, level 0 being the finest + // which rules decided what counted as terrain when this was cooked. the layout does + // not depend on it, so a file with an older value still reads; a heightfield the + // engine baked by itself is rebaked when it disagrees, one named by the scenery is not + std::uint32_t selection; + std::uint32_t reserved; +}; + +struct tile_entry { + std::int32_t x, z; + std::uint64_t offset; // payload position in the file + std::uint32_t storedsize; // bytes occupied in the file + std::uint32_t plainsize; // bytes once decoded; equals storedsize for codec::raw + float minheight, maxheight; + std::uint32_t coverage; // samples carrying terrain at the finest level + std::uint16_t tilecodec; // codec enumerator + std::uint16_t reserved; +}; + +#pragma pack( pop ) + +static_assert( sizeof( file_header ) == 64 ); +static_assert( sizeof( tile_entry ) == 40 ); + +// identity of a tile in any map keyed by tile coordinates. the cooker, the reader and the +// renderer all key tiles this way, so it is spelled out once +constexpr std::int64_t +tile_key( std::int32_t const X, std::int32_t const Z ) { + return ( static_cast( X ) << 32 ) ^ static_cast( Z ); +} + +// integer division rounding towards negative infinity. tile and sample coordinates go +// negative, and every place that maps one onto the other has to round the same way for +// neighbouring tiles to share an edge exactly +constexpr std::int64_t +floor_div( std::int64_t const Value, std::int64_t const Divisor ) { + auto const quotient { Value / Divisor }; + return ( ( Value % Divisor != 0 ) && ( ( Value < 0 ) != ( Divisor < 0 ) ) ) ? quotient - 1 : quotient; +} + +// samples along a tile side at the given mip level, shared edge included +constexpr std::uint32_t +samples_at_level( std::uint32_t const Tilesamples, std::uint32_t const Level ) { + return ( Tilesamples >> Level ) + 1; +} + +// bytes one mip level occupies: a grid of heights followed by a grid of material indices +constexpr std::uint64_t +level_bytes( std::uint32_t const Tilesamples, std::uint32_t const Level ) { + std::uint64_t const side { samples_at_level( Tilesamples, Level ) }; + return side * side * ( sizeof( std::uint16_t ) + sizeof( std::uint8_t ) ); +} + +// bytes a decoded tile payload occupies, mip chain included +constexpr std::uint64_t +tile_bytes( std::uint32_t const Tilesamples, std::uint32_t const Miplevels ) { + std::uint64_t total { 0 }; + for( std::uint32_t level = 0; level < Miplevels; ++level ) { + total += level_bytes( Tilesamples, level ); + } + return total; +} + +// byte offset of a mip level inside a decoded tile payload +constexpr std::uint64_t +level_offset( std::uint32_t const Tilesamples, std::uint32_t const Level ) { + return tile_bytes( Tilesamples, Level ); +} + +// deepest level that still has at least Minimumside samples along a side +constexpr std::uint32_t +level_count( std::uint32_t const Tilesamples, std::uint32_t const Minimumside ) { + std::uint32_t levels { 1 }; + while( ( ( Tilesamples >> levels ) >= 1 ) + && ( samples_at_level( Tilesamples, levels ) >= Minimumside ) ) { + ++levels; + } + return levels; +} + +// interleaves tile coordinates so that tiles sorted by this value are read in an order +// that keeps a moving camera reading the file forwards rather than seeking +constexpr std::uint64_t +morton( std::int32_t const X, std::int32_t const Z ) { + auto const spread = []( std::uint32_t Value ) constexpr -> std::uint64_t { + std::uint64_t result { Value }; + result = ( result | ( result << 16 ) ) & 0x0000ffff0000ffffull; + result = ( result | ( result << 8 ) ) & 0x00ff00ff00ff00ffull; + result = ( result | ( result << 4 ) ) & 0x0f0f0f0f0f0f0f0full; + result = ( result | ( result << 2 ) ) & 0x3333333333333333ull; + result = ( result | ( result << 1 ) ) & 0x5555555555555555ull; + return result; }; + // biased into unsigned so that negative tile coordinates keep their relative order + std::uint32_t const x { static_cast( X + ( 1 << 20 ) ) }; + std::uint32_t const z { static_cast( Z + ( 1 << 20 ) ) }; + return spread( x ) | ( spread( z ) << 1 ); +} + +} // namespace heightfield diff --git a/scene/heightfieldreader.h b/scene/heightfieldreader.h new file mode 100644 index 00000000..4f41acd8 --- /dev/null +++ b/scene/heightfieldreader.h @@ -0,0 +1,237 @@ +/* +This Source Code Form is subject to the +terms of the Mozilla Public License, v. +2.0. If a copy of the MPL was not +distributed with this file, You can +obtain one at +http://mozilla.org/MPL/2.0/. +*/ + +#pragma once + +// Reader for the cooked terrain heightfield. Header-only and free of engine types, so +// that the cooker can verify its own output with the same code the engine runs - one +// reader, not two that drift. +// +// The class owns the file handle and the directory; sample data it decodes into buffers +// the caller provides and recycles. Nothing about the on-disk layout escapes past +// read_level(): callers see tile coordinates, mip levels and samples. + +#include +#include +#include +#include +#include +#include +#include + +#include + +#include "scene/heightfieldformat.h" + +namespace heightfield { + +class reader { + +public: + struct tile_description { + std::int32_t x { 0 }, z { 0 }; + float minheight { 0.f }, maxheight { 0.f }; + std::uint32_t coverage { 0 }; // samples carrying terrain at the finest level + }; + + reader() = default; + ~reader() { ZSTD_freeDCtx( m_context ); } + reader( reader const & ) = delete; + reader &operator=( reader const & ) = delete; + + // returns false and leaves the reader closed when the file is missing, truncated, + // or written by a version this build does not know + bool + open( std::string const &Path ) { + + close(); + m_file.open( Path, std::ios::binary ); + if( false == m_file.good() ) { return false; } + + if( false == read_at( 0, &m_header, sizeof( m_header ) ) ) { return close_failed(); } + if( 0 != std::memcmp( m_header.magic, magic, sizeof( magic ) ) ) { return close_failed(); } + if( m_header.version != version ) { return close_failed(); } + if( ( m_header.tilesamples == 0 ) || ( m_header.miplevels == 0 ) ) { return close_failed(); } + + std::vector directory( m_header.tilecount ); + if( false == read_at( + sizeof( file_header ), directory.data(), + directory.size() * sizeof( tile_entry ) ) ) { return close_failed(); } + + m_entries.reserve( directory.size() ); + m_index.reserve( directory.size() ); + m_descriptions.reserve( directory.size() ); + for( auto const &entry : directory ) { + m_index.emplace( tile_key( entry.x, entry.z ), m_entries.size() ); + m_entries.push_back( entry ); + m_descriptions.push_back( { entry.x, entry.z, entry.minheight, entry.maxheight, entry.coverage } ); + } + + auto offset { sizeof( file_header ) + directory.size() * sizeof( tile_entry ) }; + m_materials.reserve( m_header.materialcount ); + for( std::uint32_t index = 0; index < m_header.materialcount; ++index ) { + std::uint16_t length { 0 }; + if( false == read_at( offset, &length, sizeof( length ) ) ) { return close_failed(); } + offset += sizeof( length ); + std::string name( length, '\0' ); + if( ( length > 0 ) && ( false == read_at( offset, name.data(), length ) ) ) { return close_failed(); } + offset += length; + float scale { 4.f }; + if( false == read_at( offset, &scale, sizeof( scale ) ) ) { return close_failed(); } + offset += sizeof( scale ); + m_materials.push_back( std::move( name ) ); + m_materialscales.push_back( scale ); + } + + m_open = true; + return true; + } + + void + close() { + m_file.close(); + m_file.clear(); + m_entries.clear(); + m_descriptions.clear(); + m_index.clear(); + m_materials.clear(); + m_materialscales.clear(); + m_header = {}; + m_open = false; } + + bool ready() const { return m_open; } + + // metres between samples at the finest level + float gridstep() const { return m_header.gridstep; } + // samples a tile owns along a side, shared edge excluded + std::uint32_t tilesamples() const { return m_header.tilesamples; } + // side of a tile in metres + float tilesize() const { return m_header.gridstep * static_cast( m_header.tilesamples ); } + std::uint32_t levels() const { return m_header.miplevels; } + std::uint32_t samples_per_side( std::uint32_t const Level ) const { + return samples_at_level( m_header.tilesamples, Level ); } + + std::vector const &tiles() const { return m_descriptions; } + std::vector const &materials() const { return m_materials; } + // metres of ground covered by one repeat of a material's texture, measured by the + // cooker from the texture coordinates the source geometry carried + std::vector const &material_scales() const { return m_materialscales; } + + // tile coordinates covering a world position + std::int32_t tile_x( double const X ) const { return tile_of( X ); } + std::int32_t tile_z( double const Z ) const { return tile_of( Z ); } + + // the terrain selection rules the file was cooked under + std::uint32_t + selection() const { return m_header.selection; } + + bool + contains( std::int32_t const X, std::int32_t const Z ) const { + return m_index.find( tile_key( X, Z ) ) != m_index.end(); } + + // description of one tile, or nullptr when the file does not hold it + tile_description const * + find( std::int32_t const X, std::int32_t const Z ) const { + auto const lookup { m_index.find( tile_key( X, Z ) ) }; + return lookup == m_index.end() ? nullptr : &m_descriptions[ lookup->second ]; } + + // world height of a stored sample; Value must not be nodata + float + height( std::uint16_t const Value ) const { + return m_header.heightbias + static_cast( Value ) * m_header.heightscale; } + + // decodes one mip level of one tile into the caller's buffers, which are resized to + // the level's sample count. returns false when the tile is absent or the payload is + // damaged; the buffers are then left untouched + bool + read_level( + std::int32_t const X, std::int32_t const Z, std::uint32_t const Level, + std::vector &Heights, std::vector &Materials ) { + + if( false == m_open ) { return false; } + if( Level >= m_header.miplevels ) { return false; } + auto const lookup { m_index.find( tile_key( X, Z ) ) }; + if( lookup == m_index.end() ) { return false; } + auto const &entry { m_entries[ lookup->second ] }; + + if( false == decode( entry ) ) { return false; } + + auto const side { samples_at_level( m_header.tilesamples, Level ) }; + auto const samples { static_cast( side ) * side }; + auto const offset { static_cast( level_offset( m_header.tilesamples, Level ) ) }; + if( offset + samples * 3u > m_plain.size() ) { return false; } + + Heights.resize( samples ); + Materials.resize( samples ); + std::memcpy( Heights.data(), m_plain.data() + offset, samples * sizeof( std::uint16_t ) ); + std::memcpy( Materials.data(), m_plain.data() + offset + samples * sizeof( std::uint16_t ), samples ); + return true; + } + +private: + std::int32_t + tile_of( double const World ) const { + return static_cast( std::floor( World / static_cast( tilesize() ) ) ); } + + bool + read_at( std::uint64_t const Offset, void *Target, std::size_t const Bytes ) { + if( Bytes == 0 ) { return true; } + m_file.seekg( static_cast( Offset ), std::ios::beg ); + m_file.read( static_cast( Target ), static_cast( Bytes ) ); + return m_file.good(); } + + bool + close_failed() { + close(); + return false; } + + // keeps the most recently decoded payload, because a caller walking the mip chain of + // one tile would otherwise decompress it once per level + bool + decode( tile_entry const &Entry ) { + + if( ( true == m_decoded ) && ( m_decodedoffset == Entry.offset ) ) { return true; } + m_decoded = false; + + m_stored.resize( Entry.storedsize ); + if( false == read_at( Entry.offset, m_stored.data(), Entry.storedsize ) ) { return false; } + + if( static_cast( Entry.tilecodec ) == codec::raw ) { + m_plain.swap( m_stored ); + } + else { + m_plain.resize( Entry.plainsize ); + // one context for the life of the reader: creating one per tile costs more than + // decoding a small tile does + if( m_context == nullptr ) { m_context = ZSTD_createDCtx(); } + auto const written { + ZSTD_decompressDCtx( m_context, m_plain.data(), m_plain.size(), m_stored.data(), m_stored.size() ) }; + if( ( 0 != ZSTD_isError( written ) ) || ( written != Entry.plainsize ) ) { return false; } + } + + m_decoded = true; + m_decodedoffset = Entry.offset; + return true; + } + + std::ifstream m_file; + ZSTD_DCtx *m_context { nullptr }; + file_header m_header {}; + std::vector m_entries; + std::vector m_descriptions; + std::unordered_map m_index; + std::vector m_materials; + std::vector m_materialscales; + std::vector m_stored, m_plain; + std::uint64_t m_decodedoffset { 0 }; + bool m_decoded { false }; + bool m_open { false }; +}; + +} // namespace heightfield diff --git a/scene/logiccontainer.cpp b/scene/logiccontainer.cpp new file mode 100644 index 00000000..e90a8db5 --- /dev/null +++ b/scene/logiccontainer.cpp @@ -0,0 +1,283 @@ +/* +This Source Code Form is subject to the +terms of the Mozilla Public License, v. +2.0. If a copy of the MPL was not +distributed with this file, You can +obtain one at +http://mozilla.org/MPL/2.0/. +*/ + +module; +#include +#include +#include +#include +#include +#include +#include +#include "scene/logicformat.h" + +module eu07.scene.logiccontainer; +import eu07.utilities.logs; +import eu07.utilities.utilities; + +namespace scene::logic { + +namespace { + +// pads the stream out to the next section boundary with zeroes, so a cooked file has no +// uninitialised bytes in it and two cooks of the same scenery compare equal +void +pad( std::ofstream &Output ) { + + std::array zeroes {}; + auto const position { static_cast( Output.tellp() ) }; + auto const padding { aligned( position ) - position }; + if( padding > 0 ) { + Output.write( zeroes.data(), static_cast( padding ) ); + } +} + +template +void +write_array( std::ofstream &Output, std::vector const &Data ) { + + if( false == Data.empty() ) { + Output.write( reinterpret_cast( Data.data() ), static_cast( Data.size() * sizeof( Type_ ) ) ); + } +} + +template +bool +read_array( std::ifstream &Input, section_entry const &Section, std::vector &Data ) { + + if( Section.elementsize != sizeof( Type_ ) ) { return false; } + if( Section.bytes != Section.count * sizeof( Type_ ) ) { return false; } + Data.resize( Section.count ); + Input.seekg( static_cast( Section.offset ) ); + if( Section.count > 0 ) { + Input.read( reinterpret_cast( Data.data() ), static_cast( Section.bytes ) ); + } + return static_cast( Input ); +} + +} // anonymous namespace + +std::uint32_t +container_writer::intern( std::string const &Text ) { + + auto const lookup { m_interned.find( Text ) }; + if( lookup != m_interned.end() ) { + return lookup->second; + } + auto const index { static_cast( m_strings.size() ) }; + m_strings.emplace_back( Text ); + m_interned.emplace( Text, index ); + return index; +} + +void +container_writer::add_event( event_record Record, std::vector const &Targets ) { + + Record.targetfirst = static_cast( m_targets.size() ); + Record.targetcount = static_cast( Targets.size() ); + m_targets.insert( m_targets.end(), Targets.begin(), Targets.end() ); + m_events.emplace_back( Record ); +} + +bool +container_writer::write( std::string const &Path ) const { + + std::ofstream output( Path, std::ios::binary | std::ios::trunc ); + if( false == output.good() ) { + ErrorLog( "Logic container: cannot write \"" + Path + "\"" ); + return false; + } + + // the strings blob and its table are built first, since the header has to know how big + // every section is before any of them is written + std::vector blob; + std::vector table; + table.reserve( m_strings.size() ); + for( auto const &text : m_strings ) { + table.emplace_back( string_entry{ static_cast( blob.size() ), static_cast( text.size() ) } ); + blob.insert( blob.end(), text.begin(), text.end() ); + } + + std::array( section::count )> sections {}; + auto offset { aligned( sizeof( file_header ) + sizeof( sections ) ) }; + + auto const place { + [ &offset, §ions ]( section const Kind, std::size_t const Elementsize, std::size_t const Count ) { + auto &entry { sections[ static_cast( Kind ) ] }; + entry.kind = static_cast( Kind ); + entry.elementsize = static_cast( Elementsize ); + entry.count = Count; + entry.bytes = Elementsize * Count; + entry.offset = offset; + offset = aligned( offset + entry.bytes ); + } }; + + place( section::strings, 1, blob.size() ); + place( section::stringtable, sizeof( string_entry ), table.size() ); + place( section::events, sizeof( event_record ), m_events.size() ); + place( section::targets, sizeof( target_entry ), m_targets.size() ); + + file_header header {}; + header.magic = magic; + header.version = version; + header.schema_hash = schema_hash; + header.endian = endian_marker; + header.sectioncount = static_cast( section::count ); + header.filesize = offset; + + output.write( reinterpret_cast( &header ), sizeof( header ) ); + output.write( reinterpret_cast( sections.data() ), sizeof( sections ) ); + pad( output ); + write_array( output, blob ); + pad( output ); + write_array( output, table ); + pad( output ); + write_array( output, m_events ); + pad( output ); + write_array( output, m_targets ); + pad( output ); + + if( false == output.good() ) { + ErrorLog( "Logic container: failed while writing \"" + Path + "\"" ); + return false; + } + WriteLog( + "Logic container: wrote \"" + Path + "\", " + + std::to_string( m_events.size() ) + " events, " + + std::to_string( m_targets.size() ) + " targets, " + + std::to_string( m_strings.size() ) + " strings, " + + std::to_string( offset ) + " bytes" ); + return true; +} + +// reads the header and checks it, and the file's size, against what this build writes. a +// cooked container is a cache, so anything unexpected means fall back to the text, not guess +// at what an older writer meant +static bool +accepted( std::ifstream &Input, std::string const &Path ) { + + if( false == Input.good() ) { return false; } + + file_header header {}; + Input.read( reinterpret_cast( &header ), sizeof( header ) ); + if( false == static_cast( Input ) ) { return false; } + + if( header.magic != magic ) { + ErrorLog( "Logic container \"" + Path + "\" is not one" ); + return false; + } + if( ( header.version != version ) || ( header.schema_hash != schema_hash ) ) { + WriteLog( "Logic container \"" + Path + "\" was cooked by a different build, ignoring it" ); + return false; + } + if( header.endian != endian_marker ) { + ErrorLog( "Logic container \"" + Path + "\" was cooked on the other endianness" ); + return false; + } + if( header.sectioncount != static_cast( section::count ) ) { + WriteLog( "Logic container \"" + Path + "\" holds a different set of sections, ignoring it" ); + return false; + } + Input.seekg( 0, std::ios::end ); + if( static_cast( Input.tellg() ) != header.filesize ) { + ErrorLog( "Logic container \"" + Path + "\" is truncated" ); + return false; + } + return true; +} + +bool +container_reader::readable( std::string const &Path ) { + + std::ifstream input( Path, std::ios::binary ); + return accepted( input, Path ); +} + +bool +container_reader::open( std::string const &Path ) { + + close(); + + std::ifstream input( Path, std::ios::binary ); + if( false == accepted( input, Path ) ) { return false; } + input.seekg( sizeof( file_header ) ); + + std::array( section::count )> sections {}; + input.read( reinterpret_cast( sections.data() ), sizeof( sections ) ); + if( false == static_cast( input ) ) { return false; } + + auto const &strings { sections[ static_cast( section::strings ) ] }; + m_strings.resize( strings.count ); + input.seekg( static_cast( strings.offset ) ); + if( strings.count > 0 ) { + input.read( m_strings.data(), static_cast( strings.bytes ) ); + } + + if( ( false == read_array( input, sections[ static_cast( section::stringtable ) ], m_stringtable ) ) + || ( false == read_array( input, sections[ static_cast( section::events ) ], m_events ) ) + || ( false == read_array( input, sections[ static_cast( section::targets ) ], m_targets ) ) ) { + ErrorLog( "Logic container \"" + Path + "\" has a section this build cannot read" ); + close(); + return false; + } + + m_ready = true; + return true; +} + +void +container_reader::close() { + + m_ready = false; + m_strings.clear(); + m_stringtable.clear(); + m_events.clear(); + m_targets.clear(); +} + +std::string_view +container_reader::string( std::uint32_t const Index ) const { + + if( Index >= m_stringtable.size() ) { return {}; } + auto const &entry { m_stringtable[ Index ] }; + return { m_strings.data() + entry.offset, entry.length }; +} + +std::string +container_path( std::string const &Sceneryfile ) { + + return scenery_sidecar( Sceneryfile, ".logic" ); +} + +event_kind +kind_of( std::string const &Type ) { + + if( Type == "updatevalues" ) { return event_kind::updatevalues; } + else if( Type == "addvalues" ) { return event_kind::addvalues; } + else if( Type == "copyvalues" ) { return event_kind::copyvalues; } + else if( Type == "getvalues" ) { return event_kind::getvalues; } + else if( Type == "putvalues" ) { return event_kind::putvalues; } + else if( Type == "whois" ) { return event_kind::whois; } + else if( Type == "logvalues" ) { return event_kind::logvalues; } + else if( Type == "multiple" ) { return event_kind::multiple; } + else if( Type == "switch" ) { return event_kind::switchevent; } + else if( Type == "trackvel" ) { return event_kind::trackvel; } + else if( Type == "sound" ) { return event_kind::sound; } + else if( Type == "texture" ) { return event_kind::texture; } + else if( Type == "animation" ) { return event_kind::animation; } + else if( Type == "lights" ) { return event_kind::lights; } + else if( Type == "voltage" ) { return event_kind::voltage; } + else if( Type == "visible" ) { return event_kind::visible; } + else if( Type == "friction" ) { return event_kind::friction; } + else if( Type == "message" ) { return event_kind::message; } + else if( Type == "lua" ) { return event_kind::lua; } + return event_kind::unknown; +} + +} // namespace scene::logic diff --git a/scene/logiccontainer.cppm b/scene/logiccontainer.cppm new file mode 100644 index 00000000..56fa87c1 --- /dev/null +++ b/scene/logiccontainer.cppm @@ -0,0 +1,84 @@ +/* +This Source Code Form is subject to the +terms of the Mozilla Public License, v. +2.0. If a copy of the MPL was not +distributed with this file, You can +obtain one at +http://mozilla.org/MPL/2.0/. +*/ + +module; +#include +#include +#include +#include +#include +#include "scene/logicformat.h" + +export module eu07.scene.logiccontainer; + +export { + +// Writing and reading the LOGIC container. The layout it produces is described entirely by +// logicformat.h; this is the part that knows how to get bytes in and out of it. +// +// The writer is deterministic: the same scenery cooked twice produces the same file, byte for +// byte. Strings are interned in first-seen order rather than by a hash, records are written in +// the order the manager holds them, and the padding between sections is zeroed. That is worth +// the small cost, because it is what makes a cooked file comparable - a diff that is empty +// proves a refactor changed nothing. +namespace scene::logic { + +class container_writer { + +public: + // interns a string, returning its index. the same text always gets the same index + std::uint32_t intern( std::string const &Text ); + // adds one event and its targets, already resolved to the registry that holds them + void add_event( event_record Record, std::vector const &Targets ); + // writes the container. returns false and logs on any failure + bool write( std::string const &Path ) const; + + std::size_t events() const { return m_events.size(); } + +private: + std::vector m_strings; + std::map m_interned; + std::vector m_events; + std::vector m_targets; +}; + +class container_reader { + +public: + // reads the whole container into memory. refuses anything whose magic, version, schema + // hash, endianness or size disagrees with what this build expects + bool open( std::string const &Path ); + // whether this build can read the container, judged from its header and size alone + static bool readable( std::string const &Path ); + void close(); + bool ready() const { return m_ready; } + + std::size_t events() const { return m_events.size(); } + event_record const & event( std::size_t const Index ) const { return m_events[ Index ]; } + std::size_t targets() const { return m_targets.size(); } + target_entry const & target( std::size_t const Index ) const { return m_targets[ Index ]; } + // a view into the string blob, valid while the reader is open + std::string_view string( std::uint32_t const Index ) const; + +private: + bool m_ready { false }; + std::vector m_strings; + std::vector m_stringtable; + std::vector m_events; + std::vector m_targets; +}; + +// the container's name for a scenery file: alongside it, with the extension replaced +std::string container_path( std::string const &Sceneryfile ); +// maps the word an event writes as its type onto the cooked kind +event_kind kind_of( std::string const &Type ); + +} // namespace scene::logic + +} diff --git a/scene/logicformat.h b/scene/logicformat.h new file mode 100644 index 00000000..8191a990 --- /dev/null +++ b/scene/logicformat.h @@ -0,0 +1,172 @@ +/* +This Source Code Form is subject to the +terms of the Mozilla Public License, v. +2.0. If a copy of the MPL was not +distributed with this file, You can +obtain one at +http://mozilla.org/MPL/2.0/. +*/ + +#pragma once + +#include +#include + +// On-disk layout of the scenery's LOGIC container. Single source of truth for both the +// cooker and the engine, so it deliberately knows nothing about either: no engine types, no +// includes beyond the fixed-width integers. +// +// The container holds what the simulation needs before it can run - events, and the tables +// that bind them to each other - as flat arrays indexed by position. Nothing here is a +// pointer and nothing is looked up by name at load: an event's targets are a range in a +// shared array, and a sibling is an index. That is the whole point of the section. The text +// path resolves those names in init_targets() every time a scenery loads, and it is the part +// of the load that cannot be made cheaper while the names are still there. +// +// Refusing to load is the only migration story. A container whose magic, version or schema +// hash disagrees with this header is rejected and the scenery falls back to the text path, +// because a cooked file is a cache and can always be rebuilt. +namespace scene::logic { + +// "EU07LOGC", little-endian, so a file opened in a hex editor names itself +inline constexpr std::uint64_t magic { 0x43474f4c37305545ull }; +inline constexpr std::uint32_t version { 1 }; +// stamped into the header and compared on load. bump it whenever a record below changes +// shape, so a stale container is refused rather than misread +inline constexpr std::uint32_t schema_hash { 0x2f74be09u }; +// written as 1; a reader seeing 0x01000000 knows it is looking at the wrong endianness +inline constexpr std::uint32_t endian_marker { 1 }; +// every section starts on this boundary, so records can be read in place +inline constexpr std::size_t alignment { 16 }; +// index meaning "no such element", used where a record refers to another and need not +inline constexpr std::uint32_t no_index { 0xffffffffu }; + +enum class section : std::uint32_t { + strings = 0, // the string blob every name indexes into + stringtable = 1, // one string_entry per interned string + events = 2, // one event_record per event, in the order the manager holds them + targets = 3, // target_entry array; an event owns a contiguous run of it + count +}; + +struct file_header { + std::uint64_t magic; + std::uint32_t version; + std::uint32_t schema_hash; + std::uint32_t endian; + std::uint32_t sectioncount; + // total size of the file as written, so a truncated container is caught before it is read + std::uint64_t filesize; + std::uint64_t reserved[ 3 ]; +}; + +struct section_entry { + std::uint32_t kind; + std::uint32_t elementsize; + std::uint64_t offset; + std::uint64_t bytes; + std::uint64_t count; +}; + +// one interned string: where it starts in the strings blob and how long it is. strings are +// not terminated, so a reader hands out views straight into the mapped buffer +struct string_entry { + std::uint32_t offset; + std::uint32_t length; +}; + +// what an event does. the values are written to disk, so they are assigned explicitly and +// never reordered - a new kind goes on the end +enum class event_kind : std::uint16_t { + unknown = 0, + updatevalues = 1, + copyvalues = 2, + getvalues = 3, + putvalues = 4, + whois = 5, + logvalues = 6, + multiple = 7, + switchevent = 8, // written "switch" in the scenery, which is a keyword here + trackvel = 9, + sound = 10, + texture = 11, + animation = 12, + lights = 13, + voltage = 14, + visible = 15, + friction = 16, + message = 17, + lua = 18, + // the same class as updatevalues, told apart by a flag the base does not hold. it gets + // its own value rather than being cooked as updatevalues, because until the flag word is + // cooked too that is the only thing keeping the two apart + addvalues = 19, +}; + +// the part of an event that every kind has. what the kind itself needs beyond this lives in +// the payload range, which the cooker writes and the matching subclass reads back +struct event_record { + std::uint16_t kind; + // the per-kind flag word lives in the subclasses, not in the base, so it is cooked with + // the rest of the payload once the sections for it exist. zero until then + std::uint16_t reserved0; + std::uint32_t name; // index into the string table + std::uint32_t targetfirst; // first entry in the targets section + std::uint32_t targetcount; + std::uint32_t sibling; // index of the next event sharing this name, or no_index + std::uint32_t group; // scene group handle as cooked, or no_index + double delay; + double delayrandom; + // NaN when the event is not tied to a departure. written as the exact bit pattern the + // text path produces, so a round trip compares equal bit for bit + double delaydeparture; + std::uint8_t ignored; + std::uint8_t passive; + std::uint8_t padding[ 6 ]; +}; + +// one target of one event: the name it was written with, and the index of whatever it +// resolved to. the name is kept because the editor exports text again, and because an +// unresolved target has to be reported by the name the author wrote +// which of the engine's node registries a target was found in. an event resolves its +// targets against one or two of them, decided by what kind of event it is - and for a +// visible event, decided per target, since it looks in the instances first and the tracks +// after. the values are written to disk, so they are assigned explicitly +enum class node_table : std::uint16_t { + none = 0, + memory = 1, + instance = 2, + path = 3, + sound = 4, + powergrid = 5, +}; + +// one target of one event: which registry held it and where in that registry, plus the +// name it was written with. +// +// The index is the target's position in that registry, which is the order the scenery +// built it in - so it is only meaningful for the parse this container was cooked from. +// The name is kept as the check on that: a loader compares it against the node the index +// lands on and falls back to a lookup when they disagree, which is what stops a container +// that went stale without being noticed from binding events to the wrong nodes. +struct target_entry { + std::uint32_t name; + std::uint16_t table; + std::uint16_t reserved; + std::uint32_t index; +}; +static_assert( sizeof( file_header ) == 56, "logic file_header changed shape" ); +static_assert( sizeof( section_entry ) == 32, "logic section_entry changed shape" ); +static_assert( sizeof( string_entry ) == 8, "logic string_entry changed shape" ); +static_assert( sizeof( event_record ) == 56, "logic event_record changed shape" ); +static_assert( sizeof( target_entry ) == 12, "logic target_entry changed shape" ); +static_assert( alignof( event_record ) <= alignment, "logic records must fit the section alignment" ); + +// where a section's data starts, given what has been written so far +inline constexpr std::size_t +aligned( std::size_t const Offset ) { + + return ( Offset + alignment - 1 ) & ~( alignment - 1 ); +} + +} // namespace scene::logic diff --git a/scene/prefab.cpp b/scene/prefab.cpp new file mode 100644 index 00000000..ba5d1ad8 --- /dev/null +++ b/scene/prefab.cpp @@ -0,0 +1,278 @@ +/* +This Source Code Form is subject to the +terms of the Mozilla Public License, v. +2.0. If a copy of the MPL was not +distributed with this file, You can +obtain one at +http://mozilla.org/MPL/2.0/. +*/ + +module; +#include +#include +#include +#include "utilities/Globals_macros.h" + +module eu07.scene.prefab; +import eu07.utilities.parser; +import eu07.utilities.logs; +import eu07.utilities.globals; + +namespace scene { + +namespace { + +// a light pattern as a lights event writes it: one value per channel, each preceded by a +// space, channels the given values do not reach set to Fill +std::string +pattern( std::vector const &Lights, std::size_t const Chambers, int const Fill = 0 ) { + + std::string result; + for( std::size_t chamber = 0; chamber < Chambers; ++chamber ) { + result += ' ' + std::to_string( chamber < Lights.size() ? Lights[ chamber ] : Fill ); + } + return result; +} + +void +parse_aspect( cParser &Input, prefab_definition const &Definition, prefab_aspect &Aspect ) { + + auto token { Input.getToken() }; + while( ( false == token.empty() ) && ( token != "endaspect" ) ) { + + if( token == "lights" ) { + for( std::size_t chamber = 0; chamber < Definition.chambers; ++chamber ) { + Aspect.lights.emplace_back( Input.getToken() ); + } + } + else if( token == "lightdelay" ) { + Aspect.lightdelay = Input.getToken(); + } + else if( token == "velocity" ) { + Aspect.velocity = Input.getToken(); + Aspect.velocitynext = Input.getToken(); + } + else if( token == "animate" ) { + prefab_animation animation; + animation.kind = Input.getToken(); + animation.submodel = Input.getToken( false ); + animation.x = Input.getToken(); + animation.y = Input.getToken(); + animation.z = Input.getToken(); + animation.speed = Input.getToken(); + Aspect.animations.emplace_back( animation ); + } + else { + ErrorLog( "Prefab \"" + Definition.name + "\": unexpected \"" + token + "\" in aspect \"" + Aspect.name + "\"" ); + } + token = Input.getToken(); + } +} + +} // anonymous namespace + +bool +prefab_registry::load( std::string const &Name ) { + + auto const path { Global.asCurrentSceneryPath + Name + ".pfb" }; + cParser input( path, cParser::buffer_FILE ); + if( false == input.ok() ) { + ErrorLog( "Prefab \"" + Name + "\" not found (" + path + ")" ); + return false; + } + + prefab_definition definition; + definition.name = Name; + + input.expectToken( "prefab" ); + auto token { input.getToken() }; + while( ( false == token.empty() ) && ( token != "endprefab" ) ) { + + if( token == "model" ) { + definition.model = input.getToken( false ); + } + else if( token == "modelangle" ) { + definition.modelangle = input.getToken(); + } + else if( token == "attach" ) { + prefab_attachment attachment; + attachment.rangemax = input.getToken(); + attachment.rangemin = input.getToken(); + attachment.model = input.getToken( false ); + attachment.texture = input.getToken( false ); + definition.attachments.emplace_back( attachment ); + } + else if( token == "chambers" ) { + definition.chambers = input.getToken(); + } + else if( token == "initial" ) { + definition.initial = input.getToken(); + } + else if( token == "initiallights" ) { + for( std::size_t chamber = 0; chamber < definition.chambers; ++chamber ) { + definition.initiallights.emplace_back( input.getToken() ); + } + } + else if( token == "sound" ) { + definition.sound = input.getToken( false ); + } + else if( token == "lightmodes" ) { + definition.lightmodes = true; + } + else if( token == "aspect" ) { + prefab_aspect aspect; + aspect.name = input.getToken(); + parse_aspect( input, definition, aspect ); + definition.aspects.emplace_back( aspect ); + } + else { + ErrorLog( "Prefab \"" + Name + "\": unexpected \"" + token + "\"" ); + } + token = input.getToken(); + } + + if( definition.chambers == 0 ) { + // the chamber count has to come before the aspects, since it says how many light + // values each of them reads. an aspect list with no lights in it means it did not + ErrorLog( "Prefab \"" + Name + "\" declares no chambers" ); + return false; + } + + m_definitions.emplace( Name, definition ); + WriteLog( "Prefab \"" + Name + "\": " + std::to_string( definition.aspects.size() ) + " aspects, " + std::to_string( definition.chambers ) + " chambers" ); + return true; +} + +prefab_definition const * +prefab_registry::definition( std::string const &Name ) { + + auto const lookup { m_definitions.find( Name ) }; + if( lookup != m_definitions.end() ) { + return &( lookup->second ); + } + if( true == m_missing[ Name ] ) { + return nullptr; + } + if( false == load( Name ) ) { + m_missing[ Name ] = true; + return nullptr; + } + return &( m_definitions.find( Name )->second ); +} + +std::string +prefab_registry::expand( std::string const &Name, prefab_placement const &Placement ) { + + auto const *definition { this->definition( Name ) }; + if( definition == nullptr ) { + return {}; + } + + auto const &instance { Placement.name }; + auto const position { + std::to_string( Placement.x ) + " " + + std::to_string( Placement.y ) + " " + + std::to_string( Placement.z ) }; + auto const texture { Placement.texture.empty() ? std::string( "none" ) : Placement.texture }; + + // what the signal shows until something sets it: the node's own pattern if the + // definition gives one, otherwise the initial aspect's, otherwise dark + auto const *initiallights { &definition->initiallights }; + if( true == initiallights->empty() ) { + for( auto const &aspect : definition->aspects ) { + if( aspect.name == definition->initial ) { + initiallights = &aspect.lights; + } + } + } + auto const initial { pattern( *initiallights, definition->chambers ) }; + + std::ostringstream scenery; + scenery + << "origin " << position << "\n" + << "rotate 0 " << Placement.yaw << " 0\n" + << "node 1000 0 " << instance << " model 0 0 0 " << definition->modelangle << " " + << definition->model << " " << texture << " lights" << initial << " endmodel\n"; + + for( auto const &attachment : definition->attachments ) { + scenery + << "node " << attachment.rangemax << " " << attachment.rangemin + << " none model 0 0 0 " << definition->modelangle << " " << attachment.model << " " + << ( attachment.texture == "instance" ? texture : attachment.texture ) << " endmodel\n"; + } + + scenery + << "rotate 0 0 0\n" + << "endorigin\n" + // the speed the driver reads off the signal. the aspect events write it, the track + // event beside the signal reads it + << "node -1 0 " << instance << "_sem_mem memcell " << position << " SetVelocity 0.0 0.0 none endmemcell\n" + << "event " << instance << "_sem_info getvalues 0 " << instance << "_sem_mem endevent\n"; + + if( false == definition->sound.empty() ) { + scenery + << "node 50 0 " << instance << "_dzwiek sound " << position << " " << definition->sound << " endsound\n" + << "event " << instance << "_dzwiek sound 0.0 " << instance << "_dzwiek 1 endevent\n"; + } + + // one state event per aspect, and that is the whole public surface of the instance: + // whatever sets the signal launches _, and the lights, speeds, animation + // and sound behind it belong to the prefab + for( auto const &aspect : definition->aspects ) { + scenery + << "event " << instance << "_" << aspect.name << " multiple 0 none " + << instance << "_sem_ligh_" << aspect.name << " " + << instance << "_sem_vel_" << aspect.name; + if( false == aspect.animations.empty() ) { + scenery << " " << instance << "_sem_anim_" << aspect.name; + } + if( false == definition->sound.empty() ) { + scenery << " " << instance << "_dzwiek"; + } + scenery + << " endevent\n" + << "event " << instance << "_sem_ligh_" << aspect.name << " lights " << aspect.lightdelay << " " + << instance << pattern( aspect.lights, definition->chambers ) << " endevent\n" + << "event " << instance << "_sem_vel_" << aspect.name << " updatevalues 0 " + << instance << "_sem_mem SetVelocity " << aspect.velocity << " " << aspect.velocitynext << " endevent\n"; + + if( false == aspect.animations.empty() ) { + // the templates split this into groups of eight, which a multi_event has not + // needed for a long time - it holds a vector. one list, one event + scenery << "event " << instance << "_sem_anim_" << aspect.name << " multiple 0 none"; + for( std::size_t channel = 0; channel < aspect.animations.size(); ++channel ) { + scenery << " " << instance << "_sem_anim_" << aspect.name << "_" << channel; + } + scenery << " endevent\n"; + std::size_t channel { 0 }; + for( auto const &animation : aspect.animations ) { + scenery + << "event " << instance << "_sem_anim_" << aspect.name << "_" << channel + << " animation 0 " << instance << " " << animation.kind << " " << animation.submodel << " " + << animation.x << " " << animation.y << " " << animation.z << " " << animation.speed << " endevent\n"; + ++channel; + } + } + } + + // a failed signal is dark and stops what comes at it + scenery + << "event " << instance << "_uszk multiple 0 none " + << instance << "_sem_ligh0 " << instance << "_sem_vel_stop endevent\n" + << "event " << instance << "_sem_ligh0 lights 0.0 " << instance << pattern( {}, definition->chambers ) << " endevent\n" + << "event " << instance << "_sem_vel_stop updatevalues 0 " + << instance << "_sem_mem SetVelocity 0.0 0.0 endevent\n"; + + if( true == definition->lightmodes ) { + // channel zero picks day, night or automatic lighting; the rest is left alone + auto const untouched { pattern( {}, definition->chambers > 0 ? definition->chambers - 1 : 0, -1 ) }; + scenery + << "event " << instance << "_dzienny lights 0.0 " << instance << " 0" << untouched << " endevent\n" + << "event " << instance << "_nocny lights 0.0 " << instance << " 1" << untouched << " endevent\n" + << "event " << instance << "_auto lights 0.0 " << instance << " 3" << untouched << " endevent\n"; + } + + return scenery.str(); +} + +} // namespace scene diff --git a/scene/prefab.cppm b/scene/prefab.cppm new file mode 100644 index 00000000..57700d26 --- /dev/null +++ b/scene/prefab.cppm @@ -0,0 +1,118 @@ +/* +This Source Code Form is subject to the +terms of the Mozilla Public License, v. +2.0. If a copy of the MPL was not +distributed with this file, You can +obtain one at +http://mozilla.org/MPL/2.0/. +*/ + +module; +#include +#include +#include + +export module eu07.scene.prefab; + +export { + +// Shared archetypes. A prefab is a model plus the events, memcell, animation channels and +// sounds that give it behaviour, described once and instanced many times. It exists because +// the .inc templates it replaces are not templates at all: cParser pastes text and +// substitutes (pN), so a signal that differs only by which aspects it can display needs a +// whole new file. The scenery directory holds over twelve hundred of them. +// +// What makes one prefab enough for the whole signal family is that its parameter is a list, +// not a number. Every signal template is the same shape - a state event, a light pattern and +// a speed the driver reads, for each aspect it can display, wrapped in a fixed core of +// _sem_info, _uszk and the dark state. Give the prefab the aspects and the rest follows. A +// shape signal adds animation channels to each aspect and a sound to the transition, which +// is more per aspect but not a different shape. +// +// Instances expand to scenery text and go through the ordinary node and event parsing. The +// binary format will compile them directly; until then this keeps one description of the +// signal family instead of one per variant. +namespace scene { + +// one submodel driven by an aspect. kind is the word the animation event takes, translate or +// rotate; target is where the submodel goes, in metres or degrees; speed is how fast +struct prefab_animation { + std::string kind; + std::string submodel; + std::string x{ "0" }, y{ "0" }, z{ "0" }; + std::string speed{ "0" }; +}; + +// one thing the signal can show. lights is the pattern the model's light channels take, one +// entry per channel, -1 leaving a channel alone; velocity is what the driver reads from the +// memcell, -1 meaning no limit +struct prefab_aspect { + std::string name; + std::vector lights; + std::string lightdelay{ "0.0" }; + std::string velocity{ "-1" }; + std::string velocitynext{ "-1" }; + std::vector animations; +}; + +// a second model hung on the instance: the lattice mast a shape signal stands on, its +// distant lod, the plate carrying the signal's symbol +struct prefab_attachment { + std::string rangemax{ "50" }; + std::string rangemin{ "0" }; + std::string model; + // the instance's own texture when "instance", otherwise a literal, "none" included + std::string texture{ "none" }; +}; + +struct prefab_definition { + std::string name; + std::string model; + std::string modelangle{ "0" }; + std::vector attachments; + // how many light channels the model has, which is how many values an aspect's pattern holds + std::size_t chambers{ 0 }; + std::vector aspects; + // aspect the signal shows before anything sets it, by name; empty lights every channel off + std::string initial; + // the node's own starting pattern, when it is not simply the initial aspect: a shape + // signal starts with channel zero on automatic, which is not a thing any aspect says + std::vector initiallights; + // played when the signal changes aspect. shape signals have one, light signals do not + std::string sound; + // emit the _dzienny / _nocny / _auto events which switch channel zero between day, + // night and automatic lighting. only models with that channel want them + bool lightmodes{ false }; +}; + +// arguments one instance supplies +struct prefab_placement { + std::string name; + double x{ 0.0 }, y{ 0.0 }, z{ 0.0 }; + double yaw{ 0.0 }; + std::string texture; +}; + +class prefab_registry { + +public: + // looks the definition up, loading .pfb from the scenery directory on first use. + // returns nullptr and logs if there is no such prefab + prefab_definition const * definition( std::string const &Name ); + // scenery text realising the instance: the model node, its attachments, memcell, sound + // and events. empty if the definition is missing + std::string expand( std::string const &Name, prefab_placement const &Placement ); + +private: + bool load( std::string const &Name ); + + std::unordered_map m_definitions; + // names which failed to load, so a scenery full of them complains once each + std::unordered_map m_missing; +}; + +} // namespace scene + +inline scene::prefab_registry Prefabs; + +} diff --git a/scene/scene.cpp b/scene/scene.cpp index f52040b5..db6b0a67 100644 --- a/scene/scene.cpp +++ b/scene/scene.cpp @@ -34,6 +34,7 @@ module; module eu07.simcore; import eu07.simulation.simulation; +import eu07.simulation.loadprofile; import eu07.rendering.renderer; import :mover; import eu07.utilities.globals; @@ -1306,17 +1307,50 @@ std::vector switchtrackbedtextures { "zwrotl65r1200pods-new", "zwrotp65r1200pods-new" }; +bool +is_switch_trackbed( std::string const &Materialname ) { + + for( auto const &switchtrackbedtexture : switchtrackbedtextures ) { + if( contains( Materialname, switchtrackbedtexture ) ) { + return true; + } + } + return false; +} + +void +place_in_world( std::vector &Vertices, scratch_data const &Scratchpad ) { + + if( Scratchpad.location.rotation != glm::vec3( 0, 0, 0 ) ) { + // rotate... + auto const rotation = glm::radians( Scratchpad.location.rotation ); + for( auto &vertex : Vertices ) { + vertex.position = glm::rotateZ( vertex.position, rotation.z ); + vertex.position = glm::rotateX( vertex.position, rotation.x ); + vertex.position = glm::rotateY( vertex.position, rotation.y ); + vertex.normal = glm::rotateZ( vertex.normal, rotation.z ); + vertex.normal = glm::rotateX( vertex.normal, rotation.x ); + vertex.normal = glm::rotateY( vertex.normal, rotation.y ); + } + } + if( false == Scratchpad.location.offset.empty() + && Scratchpad.location.offset.top() != glm::dvec3( 0, 0, 0 ) ) { + // ...and move + auto const &offset = Scratchpad.location.offset.top(); + for( auto &vertex : Vertices ) { + vertex.position += offset; + } + } +} + void basic_region::insert( shape_node Shape, scratch_data &Scratchpad, bool const Transform ) { if( Global.CreateSwitchTrackbeds ) { - auto const materialname{GfxRenderer->Material(Shape.data().material)->GetName()}; - for( auto const &switchtrackbedtexture : switchtrackbedtextures ) { - if( contains( materialname, switchtrackbedtexture ) ) { - // geometry with blacklisted texture, part of old switch trackbed; ignore it - return; - } + if( true == is_switch_trackbed( GfxRenderer->Material( Shape.data().material )->GetName() ) ) { + // geometry with blacklisted texture, part of old switch trackbed; ignore it + return; } } // shape might need to be split into smaller pieces, so we create list of nodes instead of just single one @@ -1328,37 +1362,24 @@ basic_region::insert( shape_node Shape, scratch_data &Scratchpad, bool const Tra // adjust input if necessary: if( true == Transform ) { // shapes generated from legacy terrain come with world space coordinates and don't need processing - if( Scratchpad.location.rotation != glm::vec3( 0, 0, 0 ) ) { - // rotate... - auto const rotation = glm::radians( Scratchpad.location.rotation ); - for( auto &vertex : shape.m_data.vertices ) { - vertex.position = glm::rotateZ( vertex.position, rotation.z ); - vertex.position = glm::rotateX( vertex.position, rotation.x ); - vertex.position = glm::rotateY( vertex.position, rotation.y ); - vertex.normal = glm::rotateZ( vertex.normal, rotation.z ); - vertex.normal = glm::rotateX( vertex.normal, rotation.x ); - vertex.normal = glm::rotateY( vertex.normal, rotation.y ); - } - } - if( false == Scratchpad.location.offset.empty() - && Scratchpad.location.offset.top() != glm::dvec3(0, 0, 0) ) { - // ...and move - auto const& offset = Scratchpad.location.offset.top(); - for( auto &vertex : shape.m_data.vertices ) { - vertex.position += offset; - } - } + place_in_world( shape.m_data.vertices, Scratchpad ); // calculate bounding area for( auto const &vertex : shape.m_data.vertices ) { shape.m_data.area.center += vertex.position; } shape.m_data.area.center /= shape.m_data.vertices.size(); // trim the shape if needed. trimmed parts will be added to list as separate nodes - for( std::size_t index = 0; index < shapes.size(); ++index ) { - while( true == RaTriangleDivider( shapes[ index ], shapes ) ) { - ; // all work is done during expression check + { + loadprofile::scoped_timer const dividetime { loadprofile::stage::shape_divide }; + for( std::size_t index = 0; index < shapes.size(); ++index ) { + while( true == RaTriangleDivider( shapes[ index ], shapes ) ) { + ; // all work is done during expression check + } } } + if( shapes.size() > 1 ) { + loadprofile::add_split( shapes.size() - 1 ); + } } // move the data into appropriate section(s) for( auto &shape : shapes ) { diff --git a/scene/scene.cppm b/scene/scene.cppm index 68c268b7..6b82d535 100644 --- a/scene/scene.cppm +++ b/scene/scene.cppm @@ -371,6 +371,15 @@ public: }; // top-level of scene spatial structure, holds collection of sections +// whether a material belongs to the old hand-made switch trackbeds, which the engine +// replaces with generated ones when CreateSwitchTrackbeds is on +bool is_switch_trackbed( std::string const &Materialname ); + +// moves shape vertices from the coordinates they were written in to world space, applying the +// rotate and origin in force where the node stood. the one place this happens, so that anything +// else looking at a shape - the terrain bake - sees it where it is drawn +void place_in_world( std::vector &Vertices, scratch_data const &Scratchpad ); + class basic_region { public: // constructors diff --git a/scene/scenenode.cppm b/scene/scenenode.cppm index 49f6bd47..68f2d0d5 100644 --- a/scene/scenenode.cppm +++ b/scene/scenenode.cppm @@ -167,6 +167,19 @@ public: // data access shapenode_data const & data() const; + // keeps only the triangles a predicate accepts, by their position in the vertex list. + // used to leave out what a baked heightfield already shows + template + void + keep_triangles( Predicate_ &&Keep ) { + std::vector kept; + kept.reserve( m_data.vertices.size() ); + for( std::size_t index = 0; index + 2 < m_data.vertices.size(); index += 3 ) { + if( true == Keep( index / 3 ) ) { + kept.insert( kept.end(), m_data.vertices.begin() + index, m_data.vertices.begin() + index + 3 ); + } + } + m_data.vertices = std::move( kept ); } // get bounding radius // NOTE: use this method instead of direct access to the data member, due to lazy radius evaluation float radius(); diff --git a/scene/terraincooker.h b/scene/terraincooker.h new file mode 100644 index 00000000..f9ad69ad --- /dev/null +++ b/scene/terraincooker.h @@ -0,0 +1,789 @@ +/* +This Source Code Form is subject to the +terms of the Mozilla Public License, v. +2.0. If a copy of the MPL was not +distributed with this file, You can +obtain one at +http://mozilla.org/MPL/2.0/. +*/ + +#pragma once + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "scene/heightfieldformat.h" + +// Rasterising a scenery's terrain triangles into a tiled heightfield. +// +// Both the standalone terraincook tool and the engine cook with this, which is the point of +// it being here: a heightfield baked on first run has to come out identical to one baked by +// the tool, and the only way to be sure of that is for there to be one implementation. +// +// The cooker knows nothing about where triangles come from. Feed it rasterize() per triangle +// and call finish(); the tool reads them out of scenery text, the engine hands them over as +// it parses. +namespace terrain { + + +// candidate grid steps tried when the step is not given; the coarsest one that still holds +// for practically every vertex names the grid (see detect_step). +// the range has to reach far past a corridor's metre grid: a regional elevation model +// is sampled every hundred metres, and a candidate list that stops at ten would report +// it as a ten metre grid, because every point of a 100 m grid sits on a 10 m one too +constexpr double gridsteps[] { 0.5, 1.0, 2.0, 2.5, 4.0, 5.0, 10.0, 20.0, 25.0, 50.0, 100.0, 200.0 }; +// how far off a grid line a coordinate may sit and still count as on it +constexpr double gridepsilon { 0.01 }; +// height difference at one grid point above which the samples are treated as a genuine +// conflict - two surfaces stacked over each other, which a heightfield cannot express +constexpr double conflictepsilon { 0.25 }; +// share of sampled vertices a candidate step has to cover to count as the base grid +constexpr double gridthreshold { 99.0 }; + +struct vertex { + double x, y, z; + double u { 0.0 }, v { 0.0 }; // texture coordinates, kept to measure material scale +}; + +// length of the segment between two vertices as seen from above +inline double +planar_length( vertex const &From, vertex const &To ) { + return std::sqrt( ( To.x - From.x ) * ( To.x - From.x ) + ( To.z - From.z ) * ( To.z - From.z ) ); +} + +// whether a coordinate lies on a grid of the given step +inline bool +on_grid( double const Value, double const Step ) { + return std::fabs( Value / Step - std::round( Value / Step ) ) * Step <= gridepsilon; +} + +// writes a greyscale preview. Values holds Width * Height samples, absent ones marked +// by the sentinel; those come out black +inline void +writepreview( + std::filesystem::path const &Path, + std::vector const &Values, + std::size_t const Width, std::size_t const Height, + float const Sentinel ) { + + auto minimum { 1e30f }, maximum { -1e30f }; + for( auto const value : Values ) { + if( value == Sentinel ) { continue; } + minimum = std::min( minimum, value ); + maximum = std::max( maximum, value ); + } + if( minimum > maximum ) { return; } + auto const range { std::max( 1e-6f, maximum - minimum ) }; + + std::ofstream file( Path, std::ios::binary ); + file << "P5\n" << Width << " " << Height << "\n255\n"; + std::vector row( Width ); + for( auto y { 0u }; y < Height; ++y ) { + for( auto x { 0u }; x < Width; ++x ) { + auto const value { Values[ y * Width + x ] }; + row[ x ] = ( + value == Sentinel ? + 0u : + static_cast( 1.0f + 254.0f * ( value - minimum ) / range ) ); + } + file.write( reinterpret_cast( row.data() ), static_cast( Width ) ); + } +} + +class cooker { + +public: + // samples along a tile side, shared edge excluded; with a 2 m grid this is a 256 m tile + static constexpr std::uint32_t tilesamples { 128 }; + static constexpr std::uint32_t tilestride { heightfield::samples_at_level( tilesamples, 0 ) }; + // mip chain down to a 5 sample side, which is where a tile stops being worth its entry + static constexpr std::uint32_t miplevels { heightfield::level_count( tilesamples, 5 ) }; + // triangles larger than this are not terrain: the water plane spans the whole map + static constexpr double maxedge { 200.0 }; + // two surfaces closer than this are the same ground, one laid over the other, rather + // than a deck carried above it. a kerb or a slab stands centimetres proud; a viaduct + // stands metres + static constexpr double surfaceepsilon { 0.5 }; + + // stop printing the cooking report. the tool wants the tables; the engine, baking on + // a first run, wants one line in its own log instead + void + quiet() { m_verbose = false; } + + void + configure( double const Step, bool const Compress ) { + m_step = Step; + m_compress = Compress; } + + // triangle identity for representation tracking. callers that do not track it pass + // nothing and get notriangle, which is never reported as represented + static constexpr std::uint32_t notriangle { 0xffffffffu }; + + void + rasterize( vertex const &A, vertex const &B, vertex const &C, std::string_view const Material, + std::uint32_t const Triangle = notriangle ) { + + m_current = Triangle; + if( Triangle != notriangle ) { + if( Triangle >= m_triangleclaimed.size() ) { + auto const size { static_cast( Triangle ) + 1u }; + m_triangleclaimed.resize( size, 0u ); + m_trianglekept.resize( size, 0u ); + m_trianglerejected.resize( size, 0u ); + } + } + + vertex const points[ 3 ] { A, B, C }; + + for( auto index { 0u }; index < 3u; ++index ) { + if( planar_length( points[ index ], points[ ( index + 1 ) % 3 ] ) > maxedge ) { + ++m_oversized; + reject(); + return; + } + } + + // barycentric setup in the horizontal plane; a triangle standing on its edge + // covers no samples and would divide by zero + auto const area { ( B.x - A.x ) * ( C.z - A.z ) - ( C.x - A.x ) * ( B.z - A.z ) }; + if( std::abs( area ) < 1e-9 ) { ++m_degenerate; reject(); return; } + auto const inversearea { 1.0 / area }; + // a palette slot is taken only by a material that reaches this far, so triangles + // standing on their edge do not fill the palette + auto const material { material_index( Material ) }; + measure_scale( material, points ); + + auto const firstcolumn { static_cast( std::ceil( std::min( { A.x, B.x, C.x } ) / m_step ) ) }; + auto const lastcolumn { static_cast( std::floor( std::max( { A.x, B.x, C.x } ) / m_step ) ) }; + auto const firstrow { static_cast( std::ceil( std::min( { A.z, B.z, C.z } ) / m_step ) ) }; + auto const lastrow { static_cast( std::floor( std::max( { A.z, B.z, C.z } ) / m_step ) ) }; + + std::size_t claimed { 0 }; + for( auto row { firstrow }; row <= lastrow; ++row ) { + for( auto column { firstcolumn }; column <= lastcolumn; ++column ) { + + auto const x { column * m_step }; + auto const z { row * m_step }; + auto const weightb { ( ( x - A.x ) * ( C.z - A.z ) - ( C.x - A.x ) * ( z - A.z ) ) * inversearea }; + auto const weightc { ( ( B.x - A.x ) * ( z - A.z ) - ( x - A.x ) * ( B.z - A.z ) ) * inversearea }; + auto const weighta { 1.0 - weightb - weightc }; + constexpr double slack { -1e-9 }; + if( ( weighta < slack ) || ( weightb < slack ) || ( weightc < slack ) ) { continue; } + + ++claimed; + store( column, row, weighta * A.y + weightb * B.y + weightc * C.y, material ); + } + } + + // every sample this triangle wanted, whether or not it ended up keeping it. + // comparing this with what the finished grid holds says how much of a + // material was lost to triangles stacked over it + if( m_current != notriangle ) { m_triangleclaimed[ m_current ] += static_cast( claimed ); } + tally( m_materialclaimed, material, claimed ); + if( material >= m_materialwhere.size() ) { m_materialwhere.resize( material + 1u, { 0.0, 0.0, false } ); } + if( false == m_materialwhere[ material ].seen ) { + // one place the material was laid, so a report line can be looked at + m_materialwhere[ material ] = { ( A.x + B.x + C.x ) / 3.0, ( A.z + B.z + C.z ) / 3.0, true }; + } + if( claimed == 0 ) { + // smaller than the gap between samples: it left no trace at all + tally( m_materialmissed, material, 1 ); + ++m_toosmall; + } + } + + // whether the heightfield shows this triangle well enough for it not to be drawn on its + // own: it was laid at all, it reached at least one sample, and it still holds nearly + // every sample it wanted once everything else has been laid. valid after finish() + bool + represented( std::uint32_t const Triangle ) const { + if( Triangle >= m_triangleclaimed.size() ) { return false; } + if( m_trianglerejected[ Triangle ] != 0 ) { return false; } + auto const claimed { m_triangleclaimed[ Triangle ] }; + if( claimed == 0 ) { return false; } + return static_cast( m_trianglekept[ Triangle ] ) >= representedshare * static_cast( claimed ); } + + void + finish( std::filesystem::path const &Path, std::filesystem::path const &Previewpath ) { + + if( true == m_tiles.empty() ) { + report( " nothing to cook\n" ); + return; } + + // every sample counted once, in the tile that owns it rather than in the + // neighbours carrying a copy of the shared edge + for( auto const & [ key, entry ] : m_tiles ) { + for( std::uint32_t row = 0; row < tilesamples; ++row ) { + for( std::uint32_t column = 0; column < tilesamples; ++column ) { + auto const owner { entry.owner[ row * tilestride + column ] }; + if( ( owner == notriangle ) || ( owner >= m_trianglekept.size() ) ) { continue; } + ++m_trianglekept[ owner ]; + // the heightfield is drawn only between samples that all carry ground, + // so a triangle reaching past the last full cell leaves a strip nothing + // covers. one holding a sample next to a hole keeps being drawn itself + auto const globalcolumn { static_cast( entry.x ) * tilesamples + column }; + auto const globalrow { static_cast( entry.z ) * tilesamples + row }; + if( m_trianglerejected[ owner ] != 0 ) { continue; } + if( false == drawn_around( globalcolumn, globalrow ) ) { + m_trianglerejected[ owner ] = 1u; + } + } + } + } + + auto const scale { std::max( 1.0, m_maxheight - m_minheight ) / heightfield::heightrange }; + + std::vector ordered; + ordered.reserve( m_tiles.size() ); + for( auto const & [ key, entry ] : m_tiles ) { ordered.push_back( &entry ); } + std::sort( ordered.begin(), ordered.end(), + []( tile const *Left, tile const *Right ) { + return heightfield::morton( Left->x, Left->z ) < heightfield::morton( Right->x, Right->z ); } ); + + std::ofstream file( Path, std::ios::binary ); + if( false == file.good() ) { + report( " cannot write %s\n", Path.string().c_str() ); + return; } + + heightfield::file_header header {}; + std::memcpy( header.magic, heightfield::magic, sizeof( header.magic ) ); + header.version = heightfield::version; + header.tilesamples = tilesamples; + header.gridstep = static_cast( m_step ); + header.heightbias = static_cast( m_minheight ); + header.heightscale = static_cast( scale ); + header.tilecount = static_cast( ordered.size() ); + header.materialcount = static_cast( m_materials.size() ); + header.miplevels = miplevels; + header.selection = heightfield::selection_rules; + header.minx = header.minz = INT32_MAX; + header.maxx = header.maxz = INT32_MIN; + for( auto const *entry : ordered ) { + header.minx = std::min( header.minx, entry->x ); header.maxx = std::max( header.maxx, entry->x ); + header.minz = std::min( header.minz, entry->z ); header.maxz = std::max( header.maxz, entry->z ); + } + + std::size_t materialbytes { 0 }; + for( auto const &name : m_materials ) { + materialbytes += sizeof( std::uint16_t ) + name.size() + sizeof( float ); + } + auto const directorybytes { ordered.size() * sizeof( heightfield::tile_entry ) }; + auto const payloadstart { sizeof( heightfield::file_header ) + directorybytes + materialbytes }; + + // the payloads are encoded up front: the directory records where each one + // lands and how large it ended up, so it cannot be written before they exist + std::vector directory; + std::vector> payloads; + directory.reserve( ordered.size() ); + payloads.reserve( ordered.size() ); + + auto offset { payloadstart }; + auto const plainsize { heightfield::tile_bytes( tilesamples, miplevels ) }; + std::vector plain( plainsize ); + + for( auto const *entry : ordered ) { + + encode( *entry, scale, plain ); + auto payload { m_compress ? compress( plain ) : plain }; + m_storedbytes += payload.size(); + m_plainbytes += plain.size(); + + heightfield::tile_entry record {}; + record.x = entry->x; + record.z = entry->z; + record.offset = offset; + record.storedsize = static_cast( payload.size() ); + record.plainsize = static_cast( plain.size() ); + record.minheight = entry->minheight; + record.maxheight = entry->maxheight; + record.coverage = static_cast( entry->coverage ); + record.tilecodec = static_cast( + m_compress ? heightfield::codec::zstd : heightfield::codec::raw ); + offset += payload.size(); + + directory.push_back( record ); + payloads.push_back( std::move( payload ) ); + } + + file.write( reinterpret_cast( &header ), sizeof( header ) ); + file.write( reinterpret_cast( directory.data() ), static_cast( directorybytes ) ); + for( std::size_t index = 0; index < m_materials.size(); ++index ) { + auto const &name { m_materials[ index ] }; + std::uint16_t const length { static_cast( name.size() ) }; + file.write( reinterpret_cast( &length ), sizeof( length ) ); + file.write( name.data(), static_cast( name.size() ) ); + auto const scale { static_cast( material_scale( index ) ) }; + file.write( reinterpret_cast( &scale ), sizeof( scale ) ); + } + for( auto const &payload : payloads ) { + file.write( reinterpret_cast( payload.data() ), static_cast( payload.size() ) ); + } + + file.close(); + + report( Path, header, offset ); + if( false == Previewpath.empty() ) { + writecookedpreview( Previewpath, header ); + } + } + +private: + struct tile { + std::int32_t x { 0 }, z { 0 }; + std::vector height; + std::vector material; + std::vector covered; + // which triangle the sample came from, so that once everything is laid each + // triangle can be asked whether the heightfield still shows it + std::vector owner; + float minheight { 1e30f }, maxheight { -1e30f }; + std::size_t coverage { 0 }; + }; + + // the palette slot of a material, allocated on first use. looked up by view, so a + // material already known costs no allocation + std::uint8_t + material_index( std::string_view const Name ) { + auto const lookup { m_materialindex.find( Name ) }; + if( lookup != m_materialindex.end() ) { return lookup->second; } + if( m_materials.size() >= 255 ) { ++m_materialoverflow; return 255; } + auto const index { static_cast( m_materials.size() ) }; + m_materials.emplace_back( Name ); + m_materialindex.emplace( std::string { Name }, index ); + return index; } + + // the source carries texture coordinates, so the tiling of every material can be + // measured instead of guessed: the ratio of world distance to texture distance along + // a triangle edge is how many metres one repeat covers. edges too short or with no + // texture movement say nothing and are left out + void + measure_scale( std::uint8_t const Material, vertex const ( &Points )[ 3 ] ) { + + if( Material >= m_scalesums.size() ) { + m_scalesums.resize( Material + 1, 0.0 ); + m_scalesquares.resize( Material + 1, 0.0 ); + m_scalecounts.resize( Material + 1, 0 ); + } + for( auto index { 0u }; index < 3u; ++index ) { + auto const &from { Points[ index ] }; + auto const &to { Points[ ( index + 1 ) % 3 ] }; + auto const world { planar_length( from, to ) }; + auto const texture { + std::sqrt( ( to.u - from.u ) * ( to.u - from.u ) + ( to.v - from.v ) * ( to.v - from.v ) ) }; + if( ( world < 0.5 ) || ( texture < 1e-4 ) ) { continue; } + auto const ratio { world / texture }; + m_scalesums[ Material ] += ratio; + m_scalesquares[ Material ] += ratio * ratio; + ++m_scalecounts[ Material ]; + } + } + + void + store( + std::int64_t const Column, std::int64_t const Row, double const Height, + std::uint8_t const Material ) { + + // a sample on a tile boundary belongs to both neighbours. writing it to each + // of them is what makes the shared edge identical rather than merely close + auto const basex { static_cast( heightfield::floor_div( Column, tilesamples ) ) }; + auto const basez { static_cast( heightfield::floor_div( Row, tilesamples ) ) }; + auto const onxedge { Column == static_cast( basex ) * tilesamples }; + auto const onzedge { Row == static_cast( basez ) * tilesamples }; + + for( auto duplicate { 0u }; duplicate < 4u; ++duplicate ) { + + if( ( ( duplicate & 1u ) != 0 ) && ( false == onxedge ) ) { continue; } + if( ( ( duplicate & 2u ) != 0 ) && ( false == onzedge ) ) { continue; } + + auto const tilex { basex - ( ( duplicate & 1u ) != 0 ? 1 : 0 ) }; + auto const tilez { basez - ( ( duplicate & 2u ) != 0 ? 1 : 0 ) }; + auto const column { static_cast( Column - static_cast( tilex ) * tilesamples ) }; + auto const row { static_cast( Row - static_cast( tilez ) * tilesamples ) }; + if( ( column >= tilestride ) || ( row >= tilestride ) ) { continue; } + + auto &target { tileat( tilex, tilez ) }; + auto const index { row * tilestride + column }; + if( target.covered[ index ] == 0 ) { + target.covered[ index ] = 1; + target.height[ index ] = static_cast( Height ); + target.material[ index ] = Material; + target.owner[ index ] = m_current; + ++target.coverage; + ++m_covered; + } + else { + auto const difference { Height - static_cast( target.height[ index ] ) }; + if( std::abs( difference ) > conflictepsilon ) { ++m_stacked; } + + if( std::abs( difference ) <= surfaceepsilon ) { + // near enough to be the same ground: a slab, a path, a patch of + // gravel laid over it. what is laid on top is what one sees, so + // the upper surface takes the sample. comparing heights rather + // than arrival order keeps the result independent of file order + if( difference > 0.0 ) { + target.height[ index ] = static_cast( Height ); + target.material[ index ] = Material; + target.owner[ index ] = m_current; + } + else if( ( std::abs( difference ) <= coincident ) + && ( duplicate == 0u ) + && ( m_current != notriangle ) ) { + // two triangles meeting along an edge both claim the samples on + // it, at the same height. the one that arrived second shows there + // just as much as the first, so it is credited with the sample + ++m_trianglekept[ m_current ]; + } + } + else if( difference < 0.0 ) { + // far apart: one of them is carried above the ground on a + // structure, and the ground is the lower of the two + target.height[ index ] = static_cast( Height ); + target.material[ index ] = Material; + target.owner[ index ] = m_current; + } + } + target.minheight = std::min( target.minheight, static_cast( Height ) ); + target.maxheight = std::max( target.maxheight, static_cast( Height ) ); + } + m_minheight = std::min( m_minheight, Height ); + m_maxheight = std::max( m_maxheight, Height ); + } + + // the tile at the given coordinates, created on first use. the last one returned is + // remembered: consecutive samples nearly always fall in the same tile, and a map's + // elements stay where they are when it grows + tile & + tileat( std::int32_t const X, std::int32_t const Z ) { + auto const key { heightfield::tile_key( X, Z ) }; + if( ( m_lasttile != nullptr ) && ( m_lasttilekey == key ) ) { return *m_lasttile; } + m_lasttilekey = key; + auto const lookup { m_tiles.find( key ) }; + if( lookup != m_tiles.end() ) { return *( m_lasttile = &lookup->second ); } + tile fresh; + fresh.x = X; + fresh.z = Z; + fresh.height.assign( tilestride * tilestride, 0.0f ); + fresh.material.assign( tilestride * tilestride, 0u ); + fresh.covered.assign( tilestride * tilestride, 0u ); + fresh.owner.assign( tilestride * tilestride, notriangle ); + return *( m_lasttile = &m_tiles.emplace( key, std::move( fresh ) ).first->second ); } + + // lays the mip chain of one tile into the buffer, finest level first. + // coarse levels are point sampled rather than averaged, deliberately: every level + // keeps the samples that sit on the tile boundary exactly as its neighbour has them, + // so the seam between two tiles stays identical at every level instead of needing + // skirts to hide a mismatch. the cost is aliasing on distant terrain + void + encode( tile const &Tile, double const Scale, std::vector &Buffer ) const { + + auto *cursor { Buffer.data() }; + for( auto level { 0u }; level < miplevels; ++level ) { + + auto const side { heightfield::samples_at_level( tilesamples, level ) }; + auto const stepping { std::size_t { 1 } << level }; + auto *heights { reinterpret_cast( cursor ) }; + auto *materials { cursor + static_cast( side ) * side * sizeof( std::uint16_t ) }; + + for( auto row { 0u }; row < side; ++row ) { + for( auto column { 0u }; column < side; ++column ) { + auto const source { ( row * stepping ) * tilestride + ( column * stepping ) }; + auto const target { static_cast( row ) * side + column }; + heights[ target ] = ( + Tile.covered[ source ] == 0 ? + heightfield::nodata : + static_cast( ( Tile.height[ source ] - m_minheight ) / Scale ) ); + materials[ target ] = Tile.material[ source ]; + } + } + cursor += heightfield::level_bytes( tilesamples, level ); + } + } + + std::vector + compress( std::vector const &Plain ) { + + // one context for the whole cook: creating one per tile costs more than a tile + if( m_context == nullptr ) { m_context.reset( ZSTD_createCCtx() ); } + std::vector encoded( ZSTD_compressBound( Plain.size() ) ); + auto const written { + ZSTD_compressCCtx( m_context.get(), encoded.data(), encoded.size(), Plain.data(), Plain.size(), compressionlevel ) }; + if( 0 != ZSTD_isError( written ) ) { + // a tile that will not compress is still a valid tile; it just stays raw + return Plain; + } + encoded.resize( written ); + return encoded; + } + + void + report( + std::filesystem::path const &Path, + heightfield::file_header const &Header, + std::size_t const Bytes ) const { + + auto const tilearea { + ( static_cast( Header.maxx ) - Header.minx + 1 ) + * ( static_cast( Header.maxz ) - Header.minz + 1 ) }; + auto const storedsamples { + static_cast( Header.tilecount ) * tilestride * tilestride }; + + report( "\n-- cooked heightfield\n" ); + report( " grid step %12.2f m\n", m_step ); + report( " tiles %12u (%u x %u samples, %.0f m each)\n", + Header.tilecount, tilestride, tilestride, tilesamples * m_step ); + report( " mip levels %12u (finest %u, coarsest %u samples)\n", + miplevels, tilestride, heightfield::samples_at_level( tilesamples, miplevels - 1 ) ); + report( " tile span %12d .. %d x %d .. %d z\n", + Header.minx, Header.maxx, Header.minz, Header.maxz ); + report( " occupancy %12.1f %% of the tile bounding box\n", + 100.0 * static_cast( Header.tilecount ) / static_cast( tilearea ) ); + report( " samples with data %12zu %6.2f %% of the finest level\n", + m_covered, 100.0 * static_cast( m_covered ) / static_cast( storedsamples ) ); + report( " height range %12.2f .. %.2f m (%.4f m per unit)\n", + m_minheight, m_maxheight, Header.heightscale ); + report( " materials %12zu%s\n", m_materials.size(), + m_materialoverflow > 0 ? " (palette full, some triangles share the last slot)" : "" ); + auto const coverage { material_coverage() }; + report( "\n-- what survived, per material\n" ); + report( " %-28s %10s %10s %8s %8s %s\n", "material", "claimed", "kept", "kept %", "vanished", "seen at x,z" ); + for( std::size_t index = 0; index < m_materials.size(); ++index ) { + auto const claimed { index < m_materialclaimed.size() ? m_materialclaimed[ index ] : 0 }; + auto const kept { index < coverage.size() ? coverage[ index ] : 0 }; + auto const missed { index < m_materialmissed.size() ? m_materialmissed[ index ] : 0 }; + auto const where { index < m_materialwhere.size() ? m_materialwhere[ index ] : place{} }; + report( " %-28s %10zu %10zu %7.1f %% %8zu %.0f,%.0f\n", + m_materials[ index ].c_str(), claimed, kept, + claimed == 0 ? 0.0 : 100.0 * static_cast( kept ) / static_cast( claimed ), + missed, where.x, where.z ); + } + report( " triangles too small to reach a sample: %zu\n", m_toosmall ); + + report( "\n-- material tiling\n" ); + for( std::size_t index = 0; index < m_materials.size(); ++index ) { + report( " %-28s %6.2f m per repeat, spread %5.1f %%%s\n", + m_materials[ index ].c_str(), material_scale( index ), 100.0 * scale_spread( index ), + ( index < m_scalecounts.size() ) && ( m_scalecounts[ index ] > 0 ) ? "" : " (assumed)" ); + } + report( " oversized skipped %12zu (edges over %.0f m, the water plane)\n", m_oversized, maxedge ); + report( " degenerate skipped %12zu\n", m_degenerate ); + report( " stacked samples %12zu (surfaces more than %.2f m apart)\n", m_stacked, conflictepsilon ); + if( true == m_compress ) { + report( " payload %12.1f MB from %.1f MB (%.2fx, zstd level %d)\n", + m_storedbytes / 1048576.0, m_plainbytes / 1048576.0, + m_plainbytes > 0 ? static_cast( m_plainbytes ) / static_cast( m_storedbytes ) : 0.0, + compressionlevel ); + } + report( " file size %12.1f MB -> %s\n", Bytes / 1048576.0, Path.string().c_str() ); + } + + void + writecookedpreview( std::filesystem::path const &Path, heightfield::file_header const &Header ) const { + + auto const width { static_cast( Header.maxx - Header.minx + 1 ) }; + auto const height { static_cast( Header.maxz - Header.minz + 1 ) }; + // one preview pixel per coarsest mip sample, capped so the image stays viewable + auto pixelspertile { heightfield::samples_at_level( tilesamples, miplevels - 1 ) - 1 }; + while( ( pixelspertile > 1 ) && ( std::max( width, height ) * pixelspertile > 2048 ) ) { pixelspertile /= 2; } + auto const previewwidth { width * pixelspertile }; + auto const previewheight { height * pixelspertile }; + auto const samplesper { tilesamples / pixelspertile }; + constexpr float sentinel { -1e30f }; + std::vector preview( previewwidth * previewheight, sentinel ); + + for( auto const & [ key, tile ] : m_tiles ) { + for( auto y { 0u }; y < pixelspertile; ++y ) { + for( auto x { 0u }; x < pixelspertile; ++x ) { + auto sum { 0.0 }; + std::size_t count { 0 }; + for( auto sy { 0u }; sy < samplesper; ++sy ) { + for( auto sx { 0u }; sx < samplesper; ++sx ) { + auto const index { ( y * samplesper + sy ) * tilestride + ( x * samplesper + sx ) }; + if( tile.covered[ index ] == 0 ) { continue; } + sum += tile.height[ index ]; + ++count; + } + } + if( count == 0 ) { continue; } + auto const column { static_cast( tile.x - Header.minx ) * pixelspertile + x }; + auto const row { static_cast( tile.z - Header.minz ) * pixelspertile + y }; + if( ( column >= previewwidth ) || ( row >= previewheight ) ) { continue; } + preview[ row * previewwidth + column ] = static_cast( sum / static_cast( count ) ); + } + } + } + writepreview( Path, preview, previewwidth, previewheight, sentinel ); + } + + // metres of ground per texture repeat; materials the measurement never caught fall + // back to something plausible rather than to zero, which would divide in the shader + double + material_scale( std::size_t const Index ) const { + if( ( Index >= m_scalecounts.size() ) || ( m_scalecounts[ Index ] == 0 ) ) { return 4.0; } + return m_scalesums[ Index ] / static_cast( m_scalecounts[ Index ] ); } + + // how much the measured tiling varies within one material, relative to its mean. + // a single global scale per texture is only faithful where this is small; a material + // the source scaled differently from place to place cannot be served by one number + double + scale_spread( std::size_t const Index ) const { + if( ( Index >= m_scalecounts.size() ) || ( m_scalecounts[ Index ] < 2 ) ) { return 0.0; } + auto const count { static_cast( m_scalecounts[ Index ] ) }; + auto const mean { m_scalesums[ Index ] / count }; + auto const variance { std::max( 0.0, m_scalesquares[ Index ] / count - mean * mean ) }; + return mean > 1e-6 ? std::sqrt( variance ) / mean : 0.0; } + + static void + tally( std::vector &Counters, std::uint8_t const Material, std::size_t const Amount ) { + if( Material >= Counters.size() ) { Counters.resize( Material + 1, 0 ); } + Counters[ Material ] += Amount; } + + // what the finished grid actually holds, counted once the last triangle is in + std::vector + material_coverage() const { + std::vector counters( m_materials.size(), 0 ); + for( auto const & [ key, tile ] : m_tiles ) { + // only the samples a tile owns, so the shared edge is not counted twice + for( std::uint32_t row = 0; row < tilesamples; ++row ) { + for( std::uint32_t column = 0; column < tilesamples; ++column ) { + auto const index { row * tilestride + column }; + if( tile.covered[ index ] == 0 ) { continue; } + auto const material { tile.material[ index ] }; + if( material < counters.size() ) { ++counters[ material ]; } + } + } + } + return counters; } + + template + void + report( char const *Format, Arguments_... Values ) const { + if( true == m_verbose ) { std::printf( Format, Values... ); } } + + static constexpr int compressionlevel { 9 }; + bool m_verbose { true }; + + double m_step { 2.0 }; + bool m_compress { true }; + std::unordered_map m_tiles; + std::vector m_materials; + // looked up by string_view, so finding a material does not build a string + struct name_hash { + using is_transparent = void; + std::size_t operator()( std::string_view const Name ) const { return std::hash{}( Name ); } }; + std::unordered_map> m_materialindex; + double m_minheight { 1e30 }, m_maxheight { -1e30 }; + std::size_t m_covered { 0 }, m_stacked { 0 }, m_oversized { 0 }, m_degenerate { 0 }; + std::size_t m_materialoverflow { 0 }; + + // whether the sample at a grid position carries ground, wherever it is stored. the last + // tile looked in is remembered, since neighbouring samples nearly always share one + bool + covered_at( std::int64_t const Column, std::int64_t const Row ) const { + auto const tilex { static_cast( heightfield::floor_div( Column, tilesamples ) ) }; + auto const tilez { static_cast( heightfield::floor_div( Row, tilesamples ) ) }; + auto const tilekey { heightfield::tile_key( tilex, tilez ) }; + if( ( m_lastcovered == nullptr ) || ( m_lastcoveredkey != tilekey ) ) { + auto const lookup { m_tiles.find( tilekey ) }; + if( lookup == m_tiles.end() ) { return false; } + m_lastcovered = &lookup->second; + m_lastcoveredkey = tilekey; + } + auto const column { static_cast( Column - static_cast( tilex ) * tilesamples ) }; + auto const row { static_cast( Row - static_cast( tilez ) * tilesamples ) }; + return m_lastcovered->covered[ row * tilestride + column ] != 0; } + + // whether the ground around a sample is drawn at every level a triangle node is likely to + // be seen at. the heightfield draws a cell only when all four of its corners carry ground, + // and a coarser level has larger cells, so a sample can be drawn up close and fall into a + // hole further out. corners are checked on every cell touching the sample, diagonals too + bool + drawn_around( std::int64_t const Column, std::int64_t const Row ) const { + for( std::uint32_t level = 0; level < checkedlevels; ++level ) { + auto const stride { static_cast( 1 ) << level }; + auto const basecolumn { heightfield::floor_div( Column, stride ) * stride }; + auto const baserow { heightfield::floor_div( Row, stride ) * stride }; + // a sample on a cell line touches the cells either side of it + auto const firstcolumn { basecolumn == Column ? basecolumn - stride : basecolumn }; + auto const firstrow { baserow == Row ? baserow - stride : baserow }; + for( auto row { firstrow }; row <= baserow + stride; row += stride ) { + for( auto column { firstcolumn }; column <= basecolumn + stride; column += stride ) { + if( false == covered_at( column, row ) ) { return false; } + } + } + } + return true; } + + void + reject() { + if( m_current != notriangle ) { m_trianglerejected[ m_current ] = 1u; } } + + // levels up to 2048 m, the farthest a triangle node of the terrain is normally drawn + static constexpr std::uint32_t checkedlevels { 3 }; + mutable tile const *m_lastcovered { nullptr }; + mutable std::int64_t m_lastcoveredkey { 0 }; + + // heights this close are one surface rather than two + static constexpr double coincident { 0.05 }; + // share of its samples a triangle has to keep to count as shown by the heightfield + static constexpr double representedshare { 0.9 }; + + std::uint32_t m_current { notriangle }; + std::vector m_triangleclaimed, m_trianglekept; + std::vector m_trianglerejected; + std::vector m_materialclaimed, m_materialmissed; + struct place { double x { 0.0 }, z { 0.0 }; bool seen { false }; }; + std::vector m_materialwhere; + std::size_t m_toosmall { 0 }; + std::vector m_scalesums, m_scalesquares; + std::vector m_scalecounts; + std::size_t m_storedbytes { 0 }, m_plainbytes { 0 }; + tile *m_lasttile { nullptr }; + std::int64_t m_lasttilekey { 0 }; + struct context_release { + void operator()( ZSTD_CCtx *Context ) const { ZSTD_freeCCtx( Context ); } }; + std::unique_ptr m_context; +}; + +// the coarsest candidate step that still holds for practically every sampled vertex. +// every vertex sitting on a 2 m grid also sits on a 1 m and a 0.5 m one, so the finest +// candidate always scores best and says nothing - it is the coarsest that names the grid +// Report is called with each candidate and the share of vertices on it, for a caller that +// wants to show the working +template +double +detect_step( Sample_ const &Sample, Report_ &&Report ) { + + auto best { 0.0 }; + for( auto const candidate : gridsteps ) { + std::size_t hits { 0 }; + for( auto const &point : Sample ) { + if( on_grid( point.x, candidate ) && on_grid( point.z, candidate ) ) { ++hits; } + } + auto const share { std::empty( Sample ) ? 0.0 : 100.0 * static_cast( hits ) / static_cast( std::size( Sample ) ) }; + Report( candidate, share ); + if( share >= gridthreshold ) { best = candidate; } + } + return ( best > 0.0 ? best : 1.0 ); +} + +template +double +detect_step( Sample_ const &Sample ) { + return detect_step( Sample, []( double, double ) {} ); +} + +} // namespace terrain diff --git a/shaders/terrainclipmap.frag b/shaders/terrainclipmap.frag new file mode 100644 index 00000000..4a89041e --- /dev/null +++ b/shaders/terrainclipmap.frag @@ -0,0 +1,57 @@ +// Terrain heightfield, fragment stage. +// +// A tile is drawn once per material it contains, with that material's texture bound, and +// every fragment belonging to a different material is dropped. Texture coordinates are +// world space divided by the tiling the cooker measured from the source geometry, so the +// ground keeps the scale the scenery was authored with. + +in vec4 f_pos; +in vec3 f_normal; +in vec2 f_ground; +in float f_valid; +flat in uint f_material; + +#include +#include +#include + +uniform sampler2D ground; +uniform float groundscale; // metres of ground covered by one texture repeat +uniform uint drawnmaterial; // the material this pass is drawing +uniform bool hastexture; // false when the material has no texture to sample +uniform vec3 fallback; // colour used in that case + +layout(location = 0) out vec4 out_color; +#if MOTIONBLUR_ENABLED +layout(location = 1) out vec4 out_motion; +#endif + +void main() +{ + // f_valid is 1.0 only where every vertex of this triangle carried terrain, so a + // triangle reaching into a hole disappears. it erodes the boundary by up to one + // sample, which is the price of holes being a property of samples, not of triangles + if( f_valid < 0.999 ) { discard; } + // this pass belongs to one material; everything else is drawn by another pass + if( f_material != drawnmaterial ) { discard; } + + vec3 albedo = ( + hastexture ? + texture( ground, f_ground / max( groundscale, 0.01 ) ).rgb : + fallback ); + // a plain lambert term against the first directional light, so that relief reads + float lambert = 0.35; + if( lights_count >= 1U && lights[ 0 ].type == LIGHT_DIR ) { + lambert = 0.35 + 0.65 * max( dot( normalize( f_normal ), normalize( -lights[ 0 ].dir ) ), 0.0 ); + } + vec3 col = albedo * lambert; + +#if POSTFX_ENABLED + out_color = vec4( apply_fog( col ), 1.0 ); +#else + out_color = tonemap( vec4( apply_fog( col ), 1.0 ) ); +#endif +#if MOTIONBLUR_ENABLED + out_motion = vec4( 0.0 ); +#endif +} diff --git a/shaders/terrainclipmap.vert b/shaders/terrainclipmap.vert new file mode 100644 index 00000000..78f49cdb --- /dev/null +++ b/shaders/terrainclipmap.vert @@ -0,0 +1,90 @@ +// Terrain heightfield, vertex stage. +// +// There is no vertex buffer: the grid exists only as an index buffer, and a sample's +// row and column come from gl_VertexID. Heights are read as unsigned integers so that +// the cooker's no-data value survives unchanged instead of being normalised into a float. + +#include + +uniform usampler2D heights; +uniform usampler2D materials; + +// tile origin relative to the viewpoint, with the viewpoint height already folded into y +uniform vec3 tileorigin; +uniform float samplestep; // metres between samples at the level this tile is drawn at +uniform float heightbias; // world height of stored value 0 +uniform float heightscale; // metres per stored unit +uniform uint nodata; +uniform int side; // samples along a tile side at this level +uniform vec2 tileworld; // tile origin in world space, for texture coordinates + +out vec4 f_pos; +out vec3 f_normal; +out vec2 f_ground; +out float f_valid; +flat out uint f_material; + +// height of a neighbouring sample, for the central difference the normal is built from. +// a neighbour that carries no terrain would otherwise decode as the no-data value and +// read as a surface a hundred metres up, wrecking the normal and leaving the ground +// unlit; Fallback keeps the difference at zero there, so the edge of a hole comes out +// flat rather than black +// height of the first neighbouring sample that carries terrain, or the lowest height on +// the map when the sample is surrounded by emptiness +float borrowed_height( ivec2 Texel ) +{ + const ivec2 offsets[ 4 ] = ivec2[ 4 ]( ivec2( -1, 0 ), ivec2( 1, 0 ), ivec2( 0, -1 ), ivec2( 0, 1 ) ); + for( int index = 0; index < 4; ++index ) { + ivec2 neighbour = clamp( Texel + offsets[ index ], ivec2( 0 ), ivec2( side - 1 ) ); + uint raw = texelFetch( heights, neighbour, 0 ).r; + if( raw != nodata ) { return heightbias + float( raw ) * heightscale; } + } + return heightbias; +} + +float sample_height( ivec2 Texel, float Fallback ) +{ + ivec2 clamped = clamp( Texel, ivec2( 0 ), ivec2( side - 1 ) ); + uint raw = texelFetch( heights, clamped, 0 ).r; + if( raw == nodata ) { return Fallback; } + return heightbias + float( raw ) * heightscale; +} + +void main() +{ + int column = gl_VertexID % side; + int row = gl_VertexID / side; + ivec2 texel = ivec2( column, row ); + + uint raw = texelFetch( heights, texel, 0 ).r; + f_valid = ( raw == nodata ) ? 0.0 : 1.0; + f_material = texelFetch( materials, texel, 0 ).r; + + // a sample outside the cooked ground has no height. decoding the no-data value as + // one puts the vertex at the top of the map's range, and since f_valid is + // interpolated, the slivers of triangle nearest the valid corners survive the + // fragment test and stretch up there. so an empty sample borrows a neighbour's + // height: the fragments are still dropped, but the geometry stays on the ground + float height = heightbias + float( raw ) * heightscale; + if( raw == nodata ) { + height = borrowed_height( texel ); + } + vec3 local = tileorigin + vec3( float( column ) * samplestep, height, float( row ) * samplestep ); + + // central differences over the neighbouring samples; at a tile edge the clamp repeats + // the border sample, which is what the neighbouring tile holds there as well + float west = sample_height( texel - ivec2( 1, 0 ), height ); + float east = sample_height( texel + ivec2( 1, 0 ), height ); + float north = sample_height( texel - ivec2( 0, 1 ), height ); + float south = sample_height( texel + ivec2( 0, 1 ), height ); + vec3 normal = normalize( vec3( west - east, 2.0 * samplestep, north - south ) ); + + // world coordinates, so the ground texture stays put as the camera moves and as the + // tile changes mip level + f_ground = tileworld + vec2( float( column ), float( row ) ) * samplestep; + + f_pos = modelview * vec4( local, 1.0 ); + f_normal = normalize( modelviewnormal * normal ); + + gl_Position = projection * f_pos; +} diff --git a/simulation/loadprofile.cpp b/simulation/loadprofile.cpp new file mode 100644 index 00000000..e167dba6 --- /dev/null +++ b/simulation/loadprofile.cpp @@ -0,0 +1,231 @@ +/* +This Source Code Form is subject to the +terms of the Mozilla Public License, v. +2.0. If a copy of the MPL was not +distributed with this file, You can +obtain one at +http://mozilla.org/MPL/2.0/. +*/ + +module; +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#ifdef __linux__ +#include +#endif + +module eu07.simulation.loadprofile; +import eu07.utilities.logs; + +namespace loadprofile { + +namespace { + +bool profiling { false }; + +std::array( stage::count )> stagetime {}; +std::array( kind::count )> nodecount {}; +std::array( kind::count )> kindtime {}; + +std::size_t vertexcount { 0 }; +std::size_t splitcount { 0 }; // shapes the trimmer had to divide +std::size_t splitpieces { 0 }; // extra pieces the trimmer produced +std::size_t memoryatstart { 0 }; +double totaltime { 0.0 }; + +// resident set size in kilobytes, 0 when it cannot be read. linux only; on other +// platforms the memory lines of the report simply stay at zero +std::size_t +resident_memory() { +#ifdef __linux__ + std::ifstream statm( "/proc/self/statm" ); + if( false == statm.good() ) { return 0; } + std::size_t total { 0 }, resident { 0 }; + statm >> total >> resident; + return resident * ( static_cast( ::sysconf( _SC_PAGESIZE ) ) / 1024 ); +#else + return 0; +#endif +} + +std::string const stagenames[] { + "token loop (rest)", + "triangle import", + "triangle insert", + " of which divider", + "other node types" }; + +std::string const kindnames[] { + "triangles", "lines", "track", "traction", "model", + "dynamic", "memcell", "eventlauncher", "sound", "other" }; + +std::string +megabytes( std::size_t const Kilobytes ) { + std::ostringstream converted; + converted << std::fixed << std::setprecision( 1 ) << ( Kilobytes / 1024.0 ) << " MB"; + return converted.str(); +} + +std::string +seconds( double const Seconds ) { + std::ostringstream converted; + converted << std::fixed << std::setprecision( 2 ) << Seconds << " s"; + return converted.str(); +} + +} // namespace + +bool +enabled() { + return profiling; +} + +void +enable( bool const Enable ) { + profiling = Enable; +} + +void +reset() { + stagetime.fill( 0.0 ); + nodecount.fill( 0 ); + kindtime.fill( 0.0 ); + totaltime = 0.0; + vertexcount = 0; + splitcount = 0; + splitpieces = 0; + memoryatstart = resident_memory(); +} + +void +add_time( stage const Stage, double const Seconds ) { + if( false == profiling ) { return; } + stagetime[ static_cast( Stage ) ] += Seconds; +} + +void +add_total( double const Seconds ) { + if( false == profiling ) { return; } + totaltime += Seconds; +} + +void +add_time( kind const Kind, double const Seconds ) { + if( false == profiling ) { return; } + kindtime[ static_cast( Kind ) ] += Seconds; +} + +void +add_node( kind const Kind ) { + if( false == profiling ) { return; } + ++nodecount[ static_cast( Kind ) ]; +} + +void +add_vertices( std::size_t const Vertices ) { + if( false == profiling ) { return; } + vertexcount += Vertices; +} + +void +add_split( std::size_t const Pieces ) { + if( false == profiling ) { return; } + ++splitcount; + splitpieces += Pieces; +} + +kind +kind_of( std::string_view const Type ) { + + if( ( Type == "triangles" ) || ( Type == "triangle_strip" ) || ( Type == "triangle_fan" ) ) { return kind::triangles; } + if( ( Type == "lines" ) || ( Type == "line_strip" ) || ( Type == "line_loop" ) ) { return kind::lines; } + if( Type == "track" ) { return kind::track; } + if( Type == "traction" ) { return kind::traction; } + if( Type == "model" ) { return kind::model; } + if( Type == "dynamic" ) { return kind::dynamic; } + if( Type == "memcell" ) { return kind::memcell; } + if( Type == "eventlauncher" ) { return kind::eventlauncher; } + if( Type == "sound" ) { return kind::sound; } + + return kind::other; +} + +void +report( std::string const &Scenariofile ) { + + if( false == profiling ) { return; } + + auto const memoryatend { resident_memory() }; + + std::ostringstream output; + output << "-- scenery load profile: " << Scenariofile << "\n"; + + // the node stages are measured inside the token loop, so what is left of the loop + // time once they are taken out is the parsing itself. the divider is a subset of + // the insert stage and stays out of both sums + auto accounted { 0.0 }; + for( auto stageindex { 0u }; stageindex < static_cast( stage::count ); ++stageindex ) { + if( ( stageindex != static_cast( stage::shape_divide ) ) + && ( stageindex != static_cast( stage::parse ) ) ) { + accounted += stagetime[ stageindex ]; + } + } + stagetime[ static_cast( stage::parse ) ] = ( totaltime > accounted ? totaltime - accounted : 0.0 ); + auto const total { totaltime > accounted ? totaltime : accounted }; + + for( auto stageindex { 0u }; stageindex < static_cast( stage::count ); ++stageindex ) { + auto const share { + total > 0.0 ? + 100.0 * stagetime[ stageindex ] / total : + 0.0 }; + output + << " " << std::left << std::setw( 20 ) << stagenames[ stageindex ] + << std::right << std::setw( 10 ) << seconds( stagetime[ stageindex ] ) + << std::setw( 8 ) << std::fixed << std::setprecision( 1 ) << share << " %\n"; + } + output << " " << std::left << std::setw( 20 ) << "total" << std::right << std::setw( 10 ) << seconds( total ) << "\n"; + + output << "-- nodes\n"; + for( auto kindindex { 0u }; kindindex < static_cast( kind::count ); ++kindindex ) { + if( nodecount[ kindindex ] == 0 ) { continue; } + output + << " " << std::left << std::setw( 20 ) << kindnames[ kindindex ] + << std::right << std::setw( 12 ) << nodecount[ kindindex ] << "\n"; + } + output << "-- time by node kind\n"; + for( auto kindindex { 0u }; kindindex < static_cast( kind::count ); ++kindindex ) { + if( kindtime[ kindindex ] < 0.005 ) { continue; } + output + << " " << std::left << std::setw( 20 ) << kindnames[ kindindex ] + << std::right << std::setw( 10 ) << seconds( kindtime[ kindindex ] ) + << std::setw( 12 ) << nodecount[ kindindex ] << " nodes\n"; + } + output << "-- nodes\n"; + output << " " << std::left << std::setw( 20 ) << "shape vertices" << std::right << std::setw( 12 ) << vertexcount << "\n"; + output << " " << std::left << std::setw( 20 ) << "shapes divided" << std::right << std::setw( 12 ) << splitcount + << " (extra pieces: " << splitpieces << ")\n"; + + output << "-- memory (resident)\n"; + output << " " << std::left << std::setw( 20 ) << "before load" << std::right << std::setw( 12 ) << megabytes( memoryatstart ) << "\n"; + output << " " << std::left << std::setw( 20 ) << "after load" << std::right << std::setw( 12 ) << megabytes( memoryatend ) << "\n"; + output << " " << std::left << std::setw( 20 ) << "delta" << std::right << std::setw( 12 ) + << megabytes( memoryatend > memoryatstart ? memoryatend - memoryatstart : 0 ) << "\n"; + + WriteLog( output.str() ); + + std::ofstream file( "loadprofile.txt", std::ios::app ); + if( true == file.good() ) { + file << output.str() << std::endl; + } +} + +} // loadprofile diff --git a/simulation/loadprofile.cppm b/simulation/loadprofile.cppm new file mode 100644 index 00000000..29539d30 --- /dev/null +++ b/simulation/loadprofile.cppm @@ -0,0 +1,108 @@ +/* +This Source Code Form is subject to the +terms of the Mozilla Public License, v. +2.0. If a copy of the MPL was not +distributed with this file, You can +obtain one at +http://mozilla.org/MPL/2.0/. +*/ + +module; +#include +#include +#include +#include +#include +#include + +export module eu07.simulation.loadprofile; + +export { + +// Scenery load profiler. Answers the questions phase 0 of the binary format plan asks: +// where the load time goes, how many nodes of each kind the scenery holds, and how much +// memory the result occupies. Measurement only - nothing here changes what gets loaded. +namespace loadprofile { + +// stages the load time is split into. shape_import covers parsing the vertex data of a +// triangle node, shape_insert the region side of it: trimming to cell boundaries and +// handing the geometry to its bank +enum class stage : std::size_t { + parse, // token loop remainder: total time less every stage below + shape_import, + shape_insert, + shape_divide, // RaTriangleDivider, the subset of shape_insert spent splitting + other_nodes, // every node type other than the triangle family + count +}; + +// node kinds counted separately; anything else lands in 'other' +enum class kind : std::size_t { + triangles, + lines, + track, + traction, + model, + dynamic, + memcell, + eventlauncher, + sound, + other, + count +}; + +// discards everything gathered so far and takes the starting memory reading +void reset(); +// accumulates elapsed time in the given stage +void add_time( stage Stage, double const Seconds ); +// accumulates elapsed time against a node kind, so that "everything else" can be told +// apart into what it actually is +void add_time( kind const Kind, double const Seconds ); +// accumulates elapsed time of the whole token loop. the parse stage is reported as +// what is left of it once the node stages are taken out +void add_total( double const Seconds ); +// registers a node of the given kind +void add_node( kind const Kind ); +// adds to the tally of vertices held by shape nodes +void add_vertices( std::size_t const Vertices ); +// registers a shape split into extra pieces by the cell-boundary trimmer +void add_split( std::size_t const Pieces ); +// true when profiling is switched on; the instrumentation is skipped otherwise +bool enabled(); +// switches profiling on or off. off by default, turned on by the -loadprofile switch +void enable( bool const Enable ); +// resolves the node type name used by the scenario format to a counter +kind kind_of( std::string_view const Type ); +// writes the gathered numbers to the log and to loadprofile.txt in the working directory +void report( std::string const &Scenariofile ); + +// RAII helper accumulating the lifetime of the scope against a stage or a node kind. the +// clock is not read at all while profiling is off, since this sits on per-node paths +template +class scoped_timer { + +public: + explicit scoped_timer( Counter_ const Counter ) : + m_counter( Counter ) { + if( true == enabled() ) { + m_start = std::chrono::steady_clock::now(); + m_running = true; + } } + ~scoped_timer() { + if( false == m_running ) { return; } + add_time( + m_counter, + std::chrono::duration( std::chrono::steady_clock::now() - m_start ).count() ); } + + scoped_timer( scoped_timer const & ) = delete; + scoped_timer &operator=( scoped_timer const & ) = delete; + +private: + Counter_ m_counter; + std::chrono::steady_clock::time_point m_start; + bool m_running { false }; +}; + +} // loadprofile + +} // export diff --git a/simulation/logiccook.cpp b/simulation/logiccook.cpp new file mode 100644 index 00000000..2af54ba4 --- /dev/null +++ b/simulation/logiccook.cpp @@ -0,0 +1,264 @@ +/* +This Source Code Form is subject to the +terms of the Mozilla Public License, v. +2.0. If a copy of the MPL was not +distributed with this file, You can +obtain one at +http://mozilla.org/MPL/2.0/. +*/ + +module; +#include +#include +#include +#include +#include +#include +#include "utilities/Globals_macros.h" +#include "global_include/interfaces/ITexture_macros.h" +#include "scene/logicformat.h" + +module eu07.simulation.logiccook; +import eu07.scene.logiccontainer; +import eu07.simcore; +import eu07.simulation.simulation; +import eu07.simulation.simulationsounds; +import eu07.utilities.logs; +import eu07.utilities.globals; +import eu07.utilities.utilities; + +namespace simulation { + +namespace { + + + +// the flat record an event becomes. everything here is read off the event itself, so the +// container cannot drift from what the text path built +scene::logic::event_record +record_of( basic_event const &Event, scene::logic::event_kind const Kind, std::uint32_t const Sibling, std::uint32_t const Name ) { + + scene::logic::event_record record {}; + record.kind = static_cast( Kind ); + record.name = Name; + record.sibling = ( Sibling < simulation::Events.event_count() ? Sibling : scene::logic::no_index ); + record.group = ( Event.group() == null_handle ? scene::logic::no_index : static_cast( Event.group() ) ); + record.delay = Event.m_delay; + record.delayrandom = Event.m_delayrandom; + record.delaydeparture = Event.m_delaydeparture; + record.ignored = ( Event.m_ignored ? 1 : 0 ); + record.passive = ( Event.m_passive ? 1 : 0 ); + return record; +} + + +// where an event of this kind looks for its targets, and where it looks next. this mirrors +// each subclass's init() rather than guessing: an event that resolves against the memory +// cells will never find its target among the tracks, and saying otherwise here would bind +// it to whatever happened to share the name +std::pair +tables_for( scene::logic::event_kind const Kind ) { + + using kind = scene::logic::event_kind; + using table = scene::logic::node_table; + + switch( Kind ) { + case kind::updatevalues: + case kind::addvalues: + case kind::copyvalues: + case kind::getvalues: + case kind::putvalues: + case kind::whois: + case kind::logvalues: + case kind::multiple: return { table::memory, table::none }; + case kind::texture: + case kind::animation: + case kind::lights: return { table::instance, table::none }; + case kind::switchevent: + case kind::trackvel: return { table::path, table::none }; + case kind::voltage: return { table::powergrid, table::none }; + case kind::sound: return { table::sound, table::none }; + // a visible event takes model instances and tracks alike, and looks in that order + case kind::visible: return { table::instance, table::path }; + default: return { table::none, table::none }; + } +} + +std::uint32_t +index_in( scene::logic::node_table const Table, std::string const &Name ) { + + using table = scene::logic::node_table; + switch( Table ) { + case table::memory: return simulation::Memory.find_id( Name ); + case table::instance: return simulation::Instances.find_id( Name ); + case table::path: return simulation::Paths.find_id( Name ); + case table::sound: return simulation::Sounds.find_id( Name ); + case table::powergrid: return simulation::Powergrid.find_id( Name ); + default: return static_cast( -1 ); + } +} + +// the entries an event's targets become, each one carrying the registry it was found in +std::vector +targets_of( basic_event const &Event, scene::logic::event_kind const Kind, scene::logic::container_writer &Writer ) { + + auto const kind { Kind }; + auto const [ first, second ] { tables_for( kind ) }; + + std::vector entries; + for( auto const &name : Event.target_names() ) { + + scene::logic::target_entry entry {}; + entry.name = Writer.intern( name ); + entry.table = static_cast( scene::logic::node_table::none ); + entry.index = scene::logic::no_index; + + for( auto const table : { first, second } ) { + if( table == scene::logic::node_table::none ) { continue; } + auto const index { index_in( table, name ) }; + if( index != static_cast( -1 ) ) { + entry.table = static_cast( table ); + entry.index = index; + break; + } + } + entries.emplace_back( entry ); + } + return entries; +} + +} // anonymous namespace + +bool +cook_logic( std::string const &Sceneryfile ) { + + scene::logic::container_writer writer; + auto const count { simulation::Events.event_count() }; + std::uint32_t unknown { 0 }; + std::set unknownkinds; + + for( std::uint32_t index = 0; index < count; ++index ) { + auto const *cooked { simulation::Events.event_at( index ) }; + if( cooked == nullptr ) { continue; } + auto const name { writer.intern( cooked->name() ) }; + auto const kind { scene::logic::kind_of( cooked->type_name() ) }; + auto const record { record_of( *cooked, kind, simulation::Events.sibling_of( index ), name ) }; + if( record.kind == static_cast( scene::logic::event_kind::unknown ) ) { + ++unknown; + if( unknownkinds.insert( cooked->type_name() ).second ) { + ErrorLog( "Logic container: no cooked kind for event type \"" + cooked->type_name() + "\" (first seen on \"" + cooked->name() + "\")" ); + } + } + writer.add_event( record, targets_of( *cooked, kind, writer ) ); + } + + if( unknown > 0 ) { + // a kind the container has no value for would come back as something else entirely, + // so say so loudly rather than write a file that reads back wrong + ErrorLog( "Logic container: " + std::to_string( unknown ) + " events of a kind this build cannot cook" ); + } + + return writer.write( scene::logic::container_path( Sceneryfile ) ); +} + +bool +logic_stale( std::string const &Sceneryfile ) { + + auto const path { scene::logic::container_path( Sceneryfile ) }; + + if( false == FileExists( path ) ) { + return true; + } + if( last_modified( scenery_file( Sceneryfile ) ) > last_modified( path ) ) { + WriteLog( "Logic container: \"" + path + "\" is older than the scenery, recooking" ); + return true; + } + // the header says whether this build can read it at all, which covers a container + // cooked before a schema change + if( false == scene::logic::container_reader::readable( path ) ) { + WriteLog( "Logic container: \"" + path + "\" cannot be read by this build, recooking" ); + return true; + } + return false; +} + +bool +verify_logic( std::string const &Sceneryfile ) { + + scene::logic::container_reader reader; + auto const path { scene::logic::container_path( Sceneryfile ) }; + if( false == reader.open( path ) ) { + ErrorLog( "Logic container: cannot verify \"" + path + "\"" ); + return false; + } + + std::uint32_t mismatches { 0 }; + auto const count { simulation::Events.event_count() }; + if( reader.events() != count ) { + ErrorLog( + "Logic container: holds " + std::to_string( reader.events() ) + + " events, the scenery has " + std::to_string( count ) ); + ++mismatches; + } + + auto const shared { std::min( reader.events(), count ) }; + for( std::size_t index = 0; index < shared; ++index ) { + auto const *loaded { simulation::Events.event_at( static_cast( index ) ) }; + if( loaded == nullptr ) { continue; } + auto const &record { reader.event( index ) }; + + auto const complain { + [ &mismatches, loaded ]( std::string const &What ) { + ErrorLog( "Logic container: event \"" + loaded->name() + "\" " + What ); + ++mismatches; + } }; + + if( reader.string( record.name ) != loaded->name() ) { + complain( "is called \"" + std::string( reader.string( record.name ) ) + "\" in the container" ); + continue; + } + if( record.kind != static_cast( scene::logic::kind_of( loaded->type_name() ) ) ) { + complain( "was cooked as a different kind" ); + } + // delays are written as they are held, so they compare exactly. a difference here + // means the record lost precision, which is the whole thing worth catching + if( record.delay != loaded->m_delay ) { + complain( "has a different delay in the container" ); + } + if( record.delayrandom != loaded->m_delayrandom ) { + complain( "has a different random delay in the container" ); + } + + auto const targets { loaded->target_names() }; + if( record.targetcount != targets.size() ) { + complain( + "has " + std::to_string( record.targetcount ) + " targets in the container, " + + std::to_string( targets.size() ) + " in the scenery" ); + continue; + } + for( std::uint32_t target = 0; target < record.targetcount; ++target ) { + auto const &entry { reader.target( record.targetfirst + target ) }; + if( reader.string( entry.name ) != targets[ target ] ) { + complain( "target " + std::to_string( target ) + " is \"" + std::string( reader.string( entry.name ) ) + "\", not \"" + targets[ target ] + "\"" ); + continue; + } + // the index is only meaningful for the parse the container was cooked from, so + // the check that matters is whether it still lands on that same name + if( entry.index != scene::logic::no_index ) { + auto const table { static_cast( entry.table ) }; + if( index_in( table, targets[ target ] ) != entry.index ) { + complain( "target " + std::to_string( target ) + " \"" + targets[ target ] + "\" is at a different index now" ); + } + } + } + } + + if( mismatches == 0 ) { + WriteLog( "Logic container: \"" + path + "\" matches the loaded scenery, " + std::to_string( count ) + " events" ); + return true; + } + ErrorLog( "Logic container: \"" + path + "\" disagrees with the loaded scenery in " + std::to_string( mismatches ) + " places" ); + return false; +} + +} // namespace simulation diff --git a/simulation/logiccook.cppm b/simulation/logiccook.cppm new file mode 100644 index 00000000..4f3fd77a --- /dev/null +++ b/simulation/logiccook.cppm @@ -0,0 +1,42 @@ +/* +This Source Code Form is subject to the +terms of the Mozilla Public License, v. +2.0. If a copy of the MPL was not +distributed with this file, You can +obtain one at +http://mozilla.org/MPL/2.0/. +*/ + +module; +#include + +export module eu07.simulation.logiccook; + +export { + +// Cooking the LOGIC container out of a scenery that has just been loaded. +// +// The plan called for a standalone tools/scn-cook reading .scn directly. It is cooked from +// inside the engine instead, and on purpose: an event's targets, siblings and indices are the +// product of the whole scenery deserializer, not of the parser, so a standalone cooker would +// have to grow a second copy of it. Cooking from the loaded state guarantees the thing that +// actually matters - that what is written is exactly what the text path produces - and the +// determinism the plan asks for comes from the writer, not from where it is called. +namespace simulation { + +// writes the container next to the scenery. returns false and logs on failure +bool cook_logic( std::string const &Sceneryfile ); +// reads a cooked container back and checks it against the events currently loaded. logs +// every disagreement and returns true only when there are none +bool verify_logic( std::string const &Sceneryfile ); +// whether the container next to this scenery needs cooking: there is none, this build +// cannot read the one that is there, or the scenery has been edited since. +// +// Edits to an .inc the scenery pulls in do NOT make it stale - only the .scn's own +// timestamp is compared, because the list of files that went into a load is not kept. Cook +// by hand with -cooklogic after editing an include. +bool logic_stale( std::string const &Sceneryfile ); + +} // namespace simulation + +} diff --git a/simulation/simulationstateserializer.cpp b/simulation/simulationstateserializer.cpp index 4ba68bdf..de6aa2a8 100644 --- a/simulation/simulationstateserializer.cpp +++ b/simulation/simulationstateserializer.cpp @@ -17,6 +17,7 @@ module; #include #include #include +#include #include #include "global_include/interfaces/ITexture_macros.h" #include "utilities/Globals_macros.h" @@ -29,6 +30,7 @@ import eu07.utilities.classes; import eu07.glm; import eu07.utilities.globals; import eu07.simulation.simulation; +import eu07.simulation.loadprofile; import eu07.simulation.simulationtime; import eu07.simulation.simulationsounds; import eu07.simulation.simulationenvironment; @@ -80,6 +82,7 @@ state_serializer::deserialize_begin( std::string const &Scenariofile ) { // TODO: check first for presence of serialized binary files // if this fails, fall back on the legacy text format state->scratchpad.name = Scenariofile; + loadprofile::reset(); if( true == Global.file_binary_terrain && Scenariofile != "$.scn" ) { // compilation to binary file isn't supported for rainsted-created overrides @@ -99,6 +102,11 @@ state_serializer::deserialize_begin( std::string const &Scenariofile ) { } scene::Groups.create(); + // before a token is read: pick up a heightfield baked on an earlier run, or arm the + // bake when there is none. it has to happen here because the decision to skip terrain + // triangles is taken per node while parsing + terrainbake::begin( Scenariofile ); + if( false == state->input.ok() ) { if( false == Global.editor_startup ) { throw invalid_scenery_exception(); @@ -131,6 +139,7 @@ state_serializer::deserialize_begin( std::string const &Scenariofile ) { { "endgroup", &state_serializer::deserialize_endgroup }, { "light", &state_serializer::deserialize_light }, { "node", &state_serializer::deserialize_node }, + { "prefab", &state_serializer::deserialize_prefab }, { "origin", &state_serializer::deserialize_origin }, { "endorigin", &state_serializer::deserialize_endorigin }, { "scale", &state_serializer::deserialize_scale }, @@ -177,11 +186,14 @@ state_serializer::deserialize_continue(std::shared_ptr state auto timenow = std::chrono::steady_clock::now(); if( std::chrono::duration_cast( timenow - timelast ).count() >= 200 ) { Application.set_progress( Input.getProgress(), Input.getFullProgress() ); + loadprofile::add_total( std::chrono::duration( timenow - timelast ).count() ); return true; } token = Input.getToken(); } + loadprofile::add_total( + std::chrono::duration( std::chrono::steady_clock::now() - timelast ).count() ); if( false == Scratchpad.initialized ) { // manually perform scenario initialization @@ -202,6 +214,22 @@ state_serializer::deserialize_continue(std::shared_ptr state Region->serialize( state->scenariofile ); } + terrainbake::finish( state->scenariofile ); + + loadprofile::report( state->scenariofile ); + + if( true == Global.verify_logic ) { + verify_logic( state->scenariofile ); + } + else if( ( true == Global.cook_logic ) + || ( true == logic_stale( state->scenariofile ) ) ) { + // cooked from the loaded state rather than from the text, and immediately read back, + // so a container that disagrees with what it was cooked from says so on the spot + if( true == cook_logic( state->scenariofile ) ) { + verify_logic( state->scenariofile ); + } + } + return false; } @@ -457,6 +485,40 @@ state_serializer::deserialize_light( cParser &Input, scene::scratch_data &Scratc skip_until( Input, "endlight" ); } +void +state_serializer::deserialize_prefab( cParser &Input, scene::scratch_data &Scratchpad ) { + + // prefab [] endprefab + scene::prefab_placement placement; + Input.getTokens( 1, false ); + std::string definition; Input >> definition; + Input.getTokens( 1, false ); + Input >> placement.name; + Input.getTokens( 4 ); + Input + >> placement.x + >> placement.y + >> placement.z + >> placement.yaw; + + auto token { Input.getToken( false ) }; + if( ( false == token.empty() ) && ( token != "endprefab" ) ) { + placement.texture = token; + token = Input.getToken( false ); + } + if( token != "endprefab" ) { + ErrorLog( "Bad scenario: prefab \"" + placement.name + "\" is missing its endprefab, found \"" + token + "\" instead" ); + } + + auto const scenery { Prefabs.expand( definition, placement ) }; + if( true == scenery.empty() ) { + ErrorLog( "Bad scenario: prefab \"" + placement.name + "\" of unknown type \"" + definition + "\" was skipped" ); + return; + } + // the expansion is ordinary scenery text, and goes in as an include would + Input.injectString( scenery ); +} + void state_serializer::deserialize_node( cParser &Input, scene::scratch_data &Scratchpad ) { @@ -471,6 +533,19 @@ state_serializer::deserialize_node( cParser &Input, scene::scratch_data &Scratch >> nodedata.name >> nodedata.type; if( nodedata.name == "none" ) { nodedata.name.clear(); } + // the triangle family is timed in its own branch below; everything else is timed + // against the kind of node it is, since "not a triangle" covers models, tracks, + // traction and vehicles alike and they cost very different amounts + std::optional> profiledstage; + std::optional> profiledkindtime; + if( true == loadprofile::enabled() ) { + auto const profiledkind { loadprofile::kind_of( nodedata.type ) }; + loadprofile::add_node( profiledkind ); + if( profiledkind != loadprofile::kind::triangles ) { + profiledstage.emplace( loadprofile::stage::other_nodes ); + profiledkindtime.emplace( profiledkind ); + } + } // type-based deserialization. not elegant but it'll do if( nodedata.type == "dynamic" ) { @@ -604,25 +679,71 @@ state_serializer::deserialize_node( cParser &Input, scene::scratch_data &Scratch || nodedata.type == "triangle_strip" || nodedata.type == "triangle_fan" ) { + auto const file { Input.Name() }; + auto const switchtrackbedfile { + // crude way to detect fixed switch trackbed geometry + true == Global.CreateSwitchTrackbeds + && file.size() >= 15 + && file.starts_with( "scenery/zwr" ) + && file.ends_with( ".inc" ) }; + + auto const offset { + Scratchpad.location.offset.empty() ? glm::dvec3( 0.0 ) : Scratchpad.location.offset.top() }; + auto const worldspace { + ( offset == glm::dvec3( 0.0 ) ) && ( Scratchpad.location.rotation == glm::vec3( 0.0f ) ) }; + + // whether a heightfield stands in for this node, and whether a bake wants it + auto const decision { + ( ( true == Scratchpad.binary.terrain ) || ( true == switchtrackbedfile ) ) + ? terrainbake::node_decision{} + : terrainbake::examine( + file, Input.Line(), Input.Parameters(), nodedata.type, + offset, Scratchpad.location.rotation, worldspace ) }; + auto const skip { // all shapes will be loaded from the binary version of the file - true == Scratchpad.binary.terrain - // crude way to detect fixed switch trackbed geometry - || ( true == Global.CreateSwitchTrackbeds - && Input.Name().size() >= 15 - && Input.Name().starts_with("scenery/zwr") - && Input.Name().ends_with(".inc") ) }; + ( true == Scratchpad.binary.terrain ) + // old trackbed geometry the engine replaces + || ( true == switchtrackbedfile ) + || ( true == decision.skip ) }; - if( false == skip ) { - - simulation::Region->insert( - scene::shape_node().import( - Input, nodedata ), - Scratchpad, - true ); + if( true == skip ) { + skip_until( Input, "endtri" ); } else { - skip_until( Input, "endtri" ); + auto shape { [&]() { + loadprofile::scoped_timer const importtime { loadprofile::stage::shape_import }; + return scene::shape_node().import( Input, nodedata ); }() }; + loadprofile::add_vertices( shape.data().vertices.size() ); + + if( true == decision.bake ) { + // the bake sees the node exactly where it is drawn. placing is done on a copy: + // the region applies the same placement to the shape itself when inserting it + auto const &data { shape.data() }; + auto const material { + data.material != null_handle + ? GfxRenderer->Material( data.material )->GetName() + : "none" }; + // the old switch trackbeds are dropped from the scenery when generated ones + // replace them, so they are no part of the ground either + if( false == ( Global.CreateSwitchTrackbeds && scene::is_switch_trackbed( material ) ) ) { + auto placed { data.vertices }; + scene::place_in_world( placed, Scratchpad ); + terrainbake::add( decision.key, placed, material ); + } + } + if( decision.plan.what == terrainbake::verdict::filter ) { + auto const &drawn { decision.plan.drawn }; + shape.keep_triangles( + [ &drawn ]( std::size_t const Triangle ) { + return ( Triangle >= drawn.size() ) || drawn[ Triangle ]; } ); + } + + loadprofile::scoped_timer const inserttime { loadprofile::stage::shape_insert }; + simulation::Region->insert( + std::move( shape ), + Scratchpad, + true ); } } else if( nodedata.type == "lines" @@ -827,7 +948,14 @@ state_serializer::deserialize_terrain(cParser &Input, scene::scratch_data &Scrat std::string line; Input.getTokens(1); Input >> line; - if (Global.file_binary_terrain && line.ends_with(".sbt")) + if (line.ends_with(".ehf")) + { + // a cooked heightfield. the renderer opens it and the scenery carries no terrain + // geometry at all, which is the point: the text it replaces is most of the file + Global.terrain_heightfields.push_back(line); + WriteLog("Terrain heightfield: " + line); + } + else if (Global.file_binary_terrain && line.ends_with(".sbt")) { Scratchpad.binary.terrain = Region->is_scene(line); Global.file_binary_terrain_state = true; diff --git a/simulation/simulationstateserializer.cppm b/simulation/simulationstateserializer.cppm index 3b6e7e1c..69209d2d 100644 --- a/simulation/simulationstateserializer.cppm +++ b/simulation/simulationstateserializer.cppm @@ -28,6 +28,9 @@ import eu07.glm; import eu07.audio.sound; import eu07.utilities.parser; import eu07.simcore; +import eu07.scene.prefab; +import eu07.simulation.logiccook; +import eu07.simulation.terrainbake; export { @@ -86,6 +89,8 @@ private: void deserialize_endgroup( cParser &Input, scene::scratch_data &Scratchpad ); void deserialize_light( cParser &Input, scene::scratch_data &Scratchpad ); void deserialize_node( cParser &Input, scene::scratch_data &Scratchpad ); + void deserialize_prefab( cParser &Input, scene::scratch_data &Scratchpad ); + void deserialize_origin( cParser &Input, scene::scratch_data &Scratchpad ); void deserialize_endorigin( cParser &Input, scene::scratch_data &Scratchpad ); void deserialize_scale( cParser &Input, scene::scratch_data &Scratchpad ); diff --git a/simulation/terrainbake.cpp b/simulation/terrainbake.cpp new file mode 100644 index 00000000..d3bf9af7 --- /dev/null +++ b/simulation/terrainbake.cpp @@ -0,0 +1,463 @@ +/* +This Source Code Form is subject to the +terms of the Mozilla Public License, v. +2.0. If a copy of the MPL was not +distributed with this file, You can +obtain one at +http://mozilla.org/MPL/2.0/. +*/ + +module; +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include "utilities/Globals_macros.h" +#include "scene/terraincooker.h" +#include "scene/heightfieldreader.h" + +module eu07.simulation.terrainbake; +import eu07.utilities.logs; +import eu07.utilities.globals; +import eu07.utilities.utilities; +import eu07.simulation.loadprofile; + +namespace simulation::terrainbake { + +namespace { + +// triangles are buffered until there are enough vertices to say what the grid step is, and +// only then rasterised. the step has to be known before the first sample is placed, and it +// is a property of the whole terrain rather than of the first triangle that turns up +constexpr std::size_t samplegoal { 200000 }; + +// the node table written beside a baked heightfield +constexpr char tablemagic[ 8 ] { 'E', 'U', '0', '7', 'H', 'F', 'N', 'D' }; +// 2: nodes keyed by file, line, include parameters and placement rather than by their text +constexpr std::uint32_t tableversion { 2 }; +constexpr std::uint32_t allshown { 0xffffffffu }; + +struct table_header { + char magic[ 8 ]; + std::uint32_t version; + std::uint32_t selection; + std::uint64_t count; + std::uint64_t maskbytes; +}; + +struct table_record { + std::uint64_t key; + std::uint32_t triangles; + // offset into the mask blob, one bit per triangle set where it is still drawn; allshown + // when the heightfield shows every triangle and there is no mask + std::uint32_t mask; +}; + +struct file_stamp { + std::uint64_t size { 0 }; + std::int64_t time { 0 }; +}; + +struct buffered { + terrain::vertex v[ 3 ]; + std::uint32_t node; // the node it came from, which holds its material + std::uint32_t id; +}; + +// one node as baked: where its triangles sit in the cooker's numbering +struct baked_node { + std::uint64_t key; + std::uint32_t first; + std::uint32_t count; + // kept once per node rather than once per buffered triangle + std::string material; +}; + +struct { + // baking + bool collecting { false }; + bool configured { false }; + terrain::cooker cooker; + std::vector buffer; + std::vector nodes; + std::uint32_t triangles { 0 }; + std::chrono::steady_clock::time_point started; + // planning + std::unordered_map table; + std::vector masks; + std::size_t skipped { 0 }, filtered { 0 }, drawn { 0 }; + // size and modification time of every file a key was taken in, looked up once per load + std::unordered_map files; +} state; + +std::string +heightfield_path( std::string const &Sceneryfile ) { + + return scenery_sidecar( Sceneryfile, ".ehf" ); +} + +std::string +table_path( std::string const &Heightfield ) { + + return Heightfield + ".nodes"; +} + +bool +load_table( std::string const &Path ) { + + std::ifstream input( Path, std::ios::binary ); + if( false == input.good() ) { return false; } + + table_header header {}; + input.read( reinterpret_cast( &header ), sizeof( header ) ); + if( ( false == static_cast( input ) ) + || ( 0 != std::memcmp( header.magic, tablemagic, sizeof( tablemagic ) ) ) + || ( header.version != tableversion ) + || ( header.selection != heightfield::selection_rules ) ) { + return false; + } + + std::vector records( header.count ); + input.read( reinterpret_cast( records.data() ), static_cast( header.count * sizeof( table_record ) ) ); + state.masks.resize( header.maskbytes ); + input.read( reinterpret_cast( state.masks.data() ), static_cast( header.maskbytes ) ); + if( false == static_cast( input ) ) { return false; } + + state.table.reserve( records.size() ); + for( auto const &record : records ) { + state.table.emplace( record.key, record ); + } + return true; +} + +void +write_table( std::string const &Path ) { + + // a key met more than once is the same line of the same file, included with the same + // parameters at the same place - the same surface: a triangle counts as shown if any of + // the copies is + std::unordered_map> shown; + for( auto const &node : state.nodes ) { + auto &entry { shown[ node.key ] }; + entry.resize( node.count, false ); + for( std::uint32_t index = 0; index < node.count; ++index ) { + if( true == state.cooker.represented( node.first + index ) ) { entry[ index ] = true; } + } + } + + std::vector records; + std::vector masks; + for( auto const &node : state.nodes ) { + auto const lookup { shown.find( node.key ) }; + if( lookup == shown.end() ) { continue; } // written already + auto const &entry { lookup->second }; + std::uint32_t count { 0 }; + for( auto const flag : entry ) { count += ( flag ? 1 : 0 ); } + if( count > 0 ) { + table_record record { node.key, node.count, allshown }; + if( count < node.count ) { + record.mask = static_cast( masks.size() ); + auto const bytes { ( node.count + 7u ) / 8u }; + masks.resize( masks.size() + bytes, 0u ); + for( std::uint32_t index = 0; index < node.count; ++index ) { + if( false == entry[ index ] ) { + masks[ record.mask + index / 8u ] |= static_cast( 1u << ( index % 8u ) ); + } + } + } + records.emplace_back( record ); + } + shown.erase( lookup ); + } + + table_header header {}; + std::memcpy( header.magic, tablemagic, sizeof( tablemagic ) ); + header.version = tableversion; + header.selection = heightfield::selection_rules; + header.count = records.size(); + header.maskbytes = masks.size(); + + std::ofstream output( Path, std::ios::binary | std::ios::trunc ); + output.write( reinterpret_cast( &header ), sizeof( header ) ); + output.write( reinterpret_cast( records.data() ), static_cast( records.size() * sizeof( table_record ) ) ); + output.write( reinterpret_cast( masks.data() ), static_cast( masks.size() ) ); + + std::size_t full { 0 }; + for( auto const &record : records ) { full += ( record.mask == allshown ? 1 : 0 ); } + WriteLog( + "Terrain bake: " + std::to_string( records.size() ) + " of " + std::to_string( state.nodes.size() ) + + " triangle nodes are shown by the heightfield, " + std::to_string( full ) + " of them entirely" ); +} + +void +rasterize( buffered const &Triangle ) { + + state.cooker.rasterize( + Triangle.v[ 0 ], Triangle.v[ 1 ], Triangle.v[ 2 ], state.nodes[ Triangle.node ].material, Triangle.id ); +} + +// works the grid step out of what has been buffered, then puts the buffer through +void +configure_and_drain() { + + std::vector sample; + sample.reserve( state.buffer.size() * 3 ); + for( auto const &triangle : state.buffer ) { + sample.emplace_back( triangle.v[ 0 ] ); + sample.emplace_back( triangle.v[ 1 ] ); + sample.emplace_back( triangle.v[ 2 ] ); + } + + auto const step { terrain::detect_step( sample ) }; + WriteLog( "Terrain bake: grid step " + std::to_string( step ) + " m, from " + std::to_string( sample.size() ) + " sampled vertices" ); + + if( false == loadprofile::enabled() ) { + // the per-material survival table is what a bad bake is diagnosed from, so it is + // printed when the load is being measured anyway + state.cooker.quiet(); + } + state.cooker.configure( step, true ); + state.configured = true; + + for( auto const &triangle : state.buffer ) { + rasterize( triangle ); + } + state.buffer.clear(); + state.buffer.shrink_to_fit(); +} + +} // anonymous namespace + +namespace { + +std::uint64_t +node_key( + std::string const &File, std::size_t const Line, std::vector const &Parameters, + std::string_view const Type, glm::dvec3 const &Offset, glm::vec3 const &Rotation ) { + + // fnv-1a. stable across runs and builds, which a std::hash is not required to be + std::uint64_t hash { 0xcbf29ce484222325ull }; + auto const mix { + [ &hash ]( void const *Data, std::size_t const Size ) { + auto const *bytes { static_cast( Data ) }; + for( std::size_t index = 0; index < Size; ++index ) { + hash ^= bytes[ index ]; + hash *= 0x100000001b3ull; + } } }; + auto const text { + [ &mix ]( std::string_view const Text ) { + mix( Text.data(), Text.size() ); + mix( "\n", 1 ); } }; + + // what the file is now: an edit anywhere in it changes every key it holds + auto lookup { state.files.find( File ) }; + if( lookup == state.files.end() ) { + std::error_code error; + file_stamp stamp {}; + auto const size { std::filesystem::file_size( File, error ) }; + stamp.size = ( error ? 0 : static_cast( size ) ); + auto const time { std::filesystem::last_write_time( File, error ) }; + stamp.time = ( error ? 0 : static_cast( time.time_since_epoch().count() ) ); + lookup = state.files.emplace( File, stamp ).first; + } + + text( File ); + mix( &lookup->second.size, sizeof( lookup->second.size ) ); + mix( &lookup->second.time, sizeof( lookup->second.time ) ); + auto const line { static_cast( Line ) }; + mix( &line, sizeof( line ) ); + for( auto const ¶meter : Parameters ) { text( parameter ); } + text( Type ); + mix( &Offset.x, sizeof( Offset.x ) ); mix( &Offset.y, sizeof( Offset.y ) ); mix( &Offset.z, sizeof( Offset.z ) ); + mix( &Rotation.x, sizeof( Rotation.x ) ); mix( &Rotation.y, sizeof( Rotation.y ) ); mix( &Rotation.z, sizeof( Rotation.z ) ); + return hash; +} + +} // anonymous namespace + +void +begin( std::string const &Sceneryfile ) { + + state = {}; + + if( false == Global.terrain_heightfields.empty() ) { + // the scenery names its own, which always wins over anything baked beside it + return; + } + if( false == Global.bake_terrain ) { + // neither bake nor pick one up: the triangles as written + return; + } + + auto const path { heightfield_path( Sceneryfile ) }; + std::error_code error; + if( true == FileExists( path ) ) { + // a baked file is a cache: one this build cannot read, one cooked under older rules + // about what counts as terrain, or one whose node table is missing, is thrown away + // and baked again rather than used + heightfield::reader probe; + if( ( true == probe.open( path ) ) + && ( probe.selection() == heightfield::selection_rules ) + && ( true == load_table( table_path( path ) ) ) ) { + Global.terrain_heightfields.push_back( path ); + WriteLog( "Terrain bake: using \"" + path + "\", baked earlier, " + std::to_string( state.table.size() ) + " triangle nodes replaced by it" ); + return; + } + WriteLog( "Terrain bake: \"" + path + "\" was baked under different rules, baking it again" ); + probe.close(); + state.table.clear(); + state.masks.clear(); + std::filesystem::remove( path, error ); + std::filesystem::remove( table_path( path ), error ); + } + + WriteLog( "Terrain bake: no heightfield for this scenery, baking one. This load will be slow; the next will not" ); + state.collecting = true; + state.started = std::chrono::steady_clock::now(); +} + +namespace { + +bool +planning() { + + return false == state.table.empty(); +} + +node_plan +plan( std::uint64_t const Key ) { + + node_plan result; + auto const lookup { state.table.find( Key ) }; + if( lookup == state.table.end() ) { + ++state.drawn; + return result; + } + auto const &record { lookup->second }; + if( record.mask == allshown ) { + ++state.skipped; + result.what = verdict::skip; + return result; + } + ++state.filtered; + result.what = verdict::filter; + result.drawn.resize( record.triangles, false ); + for( std::uint32_t index = 0; index < record.triangles; ++index ) { + auto const byte { record.mask + index / 8u }; + result.drawn[ index ] = ( byte < state.masks.size() ) && ( ( state.masks[ byte ] >> ( index % 8u ) ) & 1u ) != 0; + } + return result; +} + +// collecting for a bake, and not overruled by a heightfield the scenery named part way in +bool +baking() { + + return state.collecting && Global.terrain_heightfields.empty(); +} + +} // anonymous namespace + +node_decision +examine( + std::string const &File, std::size_t const Line, std::vector const &Parameters, + std::string_view const Type, glm::dvec3 const &Offset, glm::vec3 const &Rotation, bool const Worldspace ) { + + node_decision decision; + if( ( true == baking() ) || ( true == planning() ) ) { + decision.key = node_key( File, Line, Parameters, Type, Offset, Rotation ); + decision.bake = baking(); + if( false == decision.bake ) { + decision.plan = plan( decision.key ); + decision.skip = ( decision.plan.what == verdict::skip ); + } + return decision; + } + // a heightfield the scenery names itself, cooked by hand, replaces the world space + // triangles outright, as it always has + decision.skip = ( true == Worldspace ) && ( false == Global.terrain_heightfields.empty() ); + return decision; +} + +void +add( std::uint64_t const Key, std::vector const &Vertices, std::string_view const Material ) { + + if( false == state.collecting ) { return; } + + auto const count { static_cast( Vertices.size() / 3 ) }; + auto const node { static_cast( state.nodes.size() ) }; + state.nodes.push_back( { Key, state.triangles, count, std::string { Material } } ); + + for( std::uint32_t triangle = 0; triangle < count; ++triangle ) { + auto const vertex = [ & ]( std::size_t const Index ) { + auto const &source { Vertices[ triangle * 3u + Index ] }; + return terrain::vertex{ source.position.x, source.position.y, source.position.z, source.texture.x, source.texture.y }; }; + buffered entry { { vertex( 0 ), vertex( 1 ), vertex( 2 ) }, node, state.triangles + triangle }; + + if( true == state.configured ) { + rasterize( entry ); + continue; + } + state.buffer.emplace_back( entry ); + } + state.triangles += count; + + if( ( false == state.configured ) && ( state.buffer.size() * 3 >= samplegoal ) ) { + configure_and_drain(); + } +} + +void +finish( std::string const &Sceneryfile ) { + + if( true == planning() ) { + WriteLog( + "Terrain bake: " + std::to_string( state.skipped ) + " triangle nodes left to the heightfield, " + + std::to_string( state.filtered ) + " drawn in part, " + std::to_string( state.drawn ) + " drawn as written" ); + } + if( false == state.collecting ) { return; } + + if( false == Global.terrain_heightfields.empty() ) { + // the scenery named its own heightfield part way through, and that one wins + WriteLog( "Terrain bake: the scenery names its own heightfield, nothing baked" ); + state = {}; + return; + } + + if( false == state.configured ) { + // a scenery with less terrain than the sample goal never triggered the drain + if( true == state.buffer.empty() ) { + WriteLog( "Terrain bake: the scenery holds no triangles, nothing baked" ); + state.collecting = false; + return; + } + configure_and_drain(); + } + + auto const path { heightfield_path( Sceneryfile ) }; + state.cooker.finish( path, {} ); + write_table( table_path( path ) ); + + std::error_code error; + auto const bytes { std::filesystem::file_size( path, error ) }; + auto const seconds { + std::chrono::duration( std::chrono::steady_clock::now() - state.started ).count() }; + WriteLog( + "Terrain bake: wrote \"" + path + "\" from " + std::to_string( state.triangles ) + + " triangles in " + std::to_string( seconds ) + " s" + + ( error ? std::string() : ", " + std::to_string( bytes / 1048576 ) + " MB" ) ); + + // the triangles of this load were all drawn; the table applies from the next one + state.collecting = false; + state.buffer.clear(); + state.nodes.clear(); +} + +} // namespace simulation::terrainbake diff --git a/simulation/terrainbake.cppm b/simulation/terrainbake.cppm new file mode 100644 index 00000000..7ede2d5b --- /dev/null +++ b/simulation/terrainbake.cppm @@ -0,0 +1,85 @@ +/* +This Source Code Form is subject to the +terms of the Mozilla Public License, v. +2.0. If a copy of the MPL was not +distributed with this file, You can +obtain one at +http://mozilla.org/MPL/2.0/. +*/ + +module; +#include +#include +#include +#include +#include + +export module eu07.simulation.terrainbake; +import eu07.glm; +import eu07.model.vertex; + +export { + +// Baking a scenery's heightfield on its first run, and deciding on every later run which +// triangles it replaces. +// +// A cooked heightfield is what makes a large scenery affordable - l204 loads in 25 seconds +// against 219, in 1.9 GB against 9.1 - but until now it had to be produced by hand with the +// terraincook tool and named in the scenery text. This bakes it the first time the scenery is +// opened and picks it up by itself afterwards. +// +// Nothing here guesses what terrain is. Every triangle the scenery draws is laid into the +// heightfield where it is drawn, and afterwards each one is asked whether the heightfield still +// shows it. A tree, a wall or a gantry stands on its edge and never reaches a sample; a bridge +// deck loses its samples to the ground beneath; those are drawn as they always were. Only +// triangles the heightfield really shows stop being drawn on their own, so nothing can vanish +// from the scenery - the worst a bad bake can do is draw something twice. +// +// A triangle node is recognised by the file and line it is written at - with that file's size +// and modification time - the parameters its include was given, and the origin and rotation in +// force, not by its position in the load. An edited file changes the keys of everything in it, +// and those nodes are then simply drawn again instead of the wrong ones being dropped. Hashing +// the node's text instead would cost a second parse of every triangle node in the scenery. +// +// The rasterising is scene/terraincooker.h, the same code the tool uses. +namespace simulation::terrainbake { + +// what to do with one triangle node +enum class verdict { + draw, // the heightfield does not show it: import and draw it as written + skip, // the heightfield shows all of it: do not even import it + filter // the heightfield shows some of it: import it and draw only the rest +}; + +struct node_plan { + verdict what { verdict::draw }; + // for filter: one entry per triangle in import order, true where it is still drawn + std::vector drawn; +}; + + +// called before the scenery is parsed. picks up a heightfield baked earlier together with its +// node table, or arms the bake when there is none +void begin( std::string const &Sceneryfile ); +// what the load is to do with one triangle node, decided in one place: whether it is read at +// all, whether it goes into a bake, and which of its triangles are still drawn +struct node_decision { + bool skip { false }; // step over it without importing + bool bake { false }; // hand it to add() once imported + std::uint64_t key { 0 }; + node_plan plan; +}; + +// Worldspace says whether the node stands in world coordinates, which is all that decides it +// when the scenery names a hand-cooked heightfield of its own +node_decision examine( + std::string const &File, std::size_t const Line, std::vector const &Parameters, + std::string_view const Type, glm::dvec3 const &Offset, glm::vec3 const &Rotation, bool const Worldspace ); +// one triangle node, in world space, as it is about to be drawn +void add( std::uint64_t const Key, std::vector const &Vertices, std::string_view const Material ); +// called once the scenery has been parsed. writes the heightfield and its node table +void finish( std::string const &Sceneryfile ); + +} // namespace simulation::terrainbake + +} diff --git a/tools/terraincook/CMakeLists.txt b/tools/terraincook/CMakeLists.txt new file mode 100644 index 00000000..07c1691e --- /dev/null +++ b/tools/terraincook/CMakeLists.txt @@ -0,0 +1,24 @@ +# Standalone analysis tool for phase 1 of the binary scene format plan. Deliberately +# free of engine dependencies so it configures and builds in seconds, on its own. +cmake_minimum_required(VERSION 3.20) +project(terraincook CXX) + +set(CMAKE_CXX_STANDARD 20) +set(CMAKE_CXX_STANDARD_REQUIRED ON) + +find_package(zstd CONFIG QUIET) +if(NOT zstd_FOUND) + find_package(PkgConfig REQUIRED) + pkg_check_modules(ZSTD REQUIRED IMPORTED_TARGET libzstd) + add_library(zstd::libzstd ALIAS PkgConfig::ZSTD) +endif() + +add_executable(terraincook main.cpp) + +# the on-disk format lives with the engine, so the tool and the reader cannot drift apart +target_include_directories(terraincook PRIVATE "${CMAKE_CURRENT_SOURCE_DIR}/../..") +target_link_libraries(terraincook PRIVATE zstd::libzstd) + +if(NOT MSVC) + target_compile_options(terraincook PRIVATE -O2 -Wall) +endif() diff --git a/tools/terraincook/height.pgm b/tools/terraincook/height.pgm new file mode 100644 index 0000000000000000000000000000000000000000..9bc7ec3db48dce3fd167c7ccf1c564927c70c624 GIT binary patch literal 549375 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%0D-)A3^HE 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

l~yZGo9prA^Or#Y9BQBsAE4J#(bNBX)k^&Xp= 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%0ApGtwQX~XTN*a?85sjiPY1-5tKe;fJkCvv+&~a+t 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

@XfM=;hGm<;WYTM|dWR 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 +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "scene/heightfieldformat.h" +#include "scene/heightfieldreader.h" +#include "scene/terraincooker.h" + +namespace { + +using terrain::conflictepsilon; +using terrain::on_grid; +using terrain::planar_length; +using terrain::vertex; +using terrain::writepreview; + + +struct triangle { + vertex v[ 3 ]; +}; + +struct bounds { + double minx { 1e30 }, maxx { -1e30 }; + double minz { 1e30 }, maxz { -1e30 }; + void add( double const X, double const Z ) { + minx = std::min( minx, X ); maxx = std::max( maxx, X ); + minz = std::min( minz, Z ); maxz = std::max( maxz, Z ); } +}; + +// cell sizes the covered area is measured at. vertices landing on a 1 m grid says +// nothing about how far apart neighbouring samples actually are, and the difference +// decides what a heightfield of this terrain would really cost +constexpr double coveragescales[] { 1.0, 2.0, 4.0, 8.0, 16.0, 32.0, 64.0, 128.0, 256.0, 512.0 }; +constexpr std::size_t coveragescalecount { sizeof( coveragescales ) / sizeof( coveragescales[ 0 ] ) }; + +struct statistics { + std::size_t triangles { 0 }; + std::size_t gridtriangles { 0 }; + std::size_t vertices { 0 }; + std::size_t gridvertices { 0 }; + std::size_t conflicts { 0 }; + double maxconflict { 0.0 }; + bounds extent; + bounds overlayextent; + // triangle edge lengths, bucketed by powers of two: <1, <2, <4, <8, <16, <32, <64, rest + std::size_t edgebuckets[ 8 ] {}; + double areasum { 0.0 }; +}; + +using heightfield::tile_key; + +bool +numeric( char const Character ) { + return ( Character >= '0' && Character <= '9' ) || Character == '-' || Character == '+' || Character == '.'; +} + +std::string +readfile( std::filesystem::path const &Path ) { + + std::ifstream file( Path, std::ios::binary ); + if( false == file.good() ) { return {}; } + file.seekg( 0, std::ios::end ); + auto const size { static_cast( file.tellg() ) }; + file.seekg( 0, std::ios::beg ); + std::string content; + content.resize( size ); + file.read( content.data(), static_cast( size ) ); + return content; +} + +// walks the scenery text and hands every triangle of a triangle-family node to the sink. +// deliberately forgiving: anything that is not a node header or vertex data is skipped +template +void +parse( std::string const &Content, Sink_ &&Handler ) { + + auto const *cursor { Content.data() }; + auto const *end { cursor + Content.size() }; + + auto skipspace = [ & ]() { + while( cursor < end && ( static_cast( *cursor ) <= ' ' ) ) { ++cursor; } }; + auto token = [ & ]() -> std::string_view { + skipspace(); + auto const *start { cursor }; + while( cursor < end && ( static_cast( *cursor ) > ' ' ) ) { ++cursor; } + return { start, static_cast( cursor - start ) }; }; + auto skipline = [ & ]() { + while( cursor < end && *cursor != '\n' ) { ++cursor; } }; + + std::vector numbers; + numbers.reserve( 32 ); + auto transformdepth { 0 }; + auto rotated { false }; + + while( cursor < end ) { + + auto word { token() }; + if( word.empty() ) { break; } + if( word.starts_with( "//" ) ) { skipline(); continue; } + + // the same selection the engine bakes with (heightfield::selection_rules): terrain + // is written in world space, and whatever sits under origin, rotate or scale is an + // object built from triangles rather than ground + if( word == "origin" ) { ++transformdepth; continue; } + if( word == "endorigin" ) { transformdepth = std::max( 0, transformdepth - 1 ); continue; } + if( word == "scale" ) { ++transformdepth; continue; } + if( word == "endscale" ) { transformdepth = std::max( 0, transformdepth - 1 ); continue; } + if( word == "rotate" ) { + auto const x { token() }, y { token() }, z { token() }; + auto const zero = []( std::string_view const Value ) { + return std::strtod( std::string( Value ).c_str(), nullptr ) == 0.0; }; + rotated = !( zero( x ) && zero( y ) && zero( z ) ); + continue; + } + if( word != "node" ) { continue; } + if( ( transformdepth > 0 ) || ( true == rotated ) ) { + // an object, not terrain: step over its body without handing it on + while( cursor < end ) { + auto const body { token() }; + if( body.empty() || ( body.starts_with( "end" ) && ( body != "end" ) ) ) { break; } + } + continue; + } + + // node header: range_max range_min name type + token(); token(); token(); + auto const type { token() }; + if( ( type != "triangles" ) && ( type != "triangle_strip" ) && ( type != "triangle_fan" ) ) { + continue; + } + auto const material { token() }; + + numbers.clear(); + while( cursor < end ) { + auto const value { token() }; + if( value.empty() ) { break; } + if( value == "endtri" ) { break; } + if( false == numeric( value.front() ) ) { continue; } // "end" and friends + double parsed { 0.0 }; + auto const result { + std::from_chars( value.data(), value.data() + value.size(), parsed ) }; + if( result.ec == std::errc() ) { numbers.push_back( parsed ); } + } + + // vertex is position, normal and texture coordinates; only the position matters here + auto const stride { 8u }; + auto const vertexcount { numbers.size() / stride }; + std::vector vertices; + vertices.reserve( vertexcount ); + for( auto index { 0u }; index < vertexcount; ++index ) { + vertices.push_back( { + numbers[ index * stride + 0 ], + numbers[ index * stride + 1 ], + numbers[ index * stride + 2 ], + numbers[ index * stride + 6 ], + numbers[ index * stride + 7 ] } ); + } + + if( type == "triangles" ) { + for( auto index { 0u }; index + 2 < vertices.size(); index += 3 ) { + Handler( vertices[ index ], vertices[ index + 1 ], vertices[ index + 2 ], material ); + } + } + else if( type == "triangle_strip" ) { + for( auto index { 2u }; index < vertices.size(); ++index ) { + Handler( vertices[ index - 2 ], vertices[ index - 1 ], vertices[ index ], material ); + } + } + else { // triangle_fan + for( auto index { 2u }; index < vertices.size(); ++index ) { + Handler( vertices[ 0 ], vertices[ index - 1 ], vertices[ index ], material ); + } + } + } +} + + + + + + +// --------------------------------------------------------------------------------- +// track proximity. the question is whether the fine sampling of the terrain follows the +// railway or is spread evenly: that decides whether a coarse heightfield plus a corridor +// overlay is enough, or whether the base grid has to be fine everywhere + +// distance field over a coarse grid, in metres, built from the sampled track centrelines +class distance_field { + +public: + void + build( std::vector const &Samples, double const Cell ) { + + m_cell = Cell; + if( true == Samples.empty() ) { return; } + for( auto const &point : Samples ) { m_extent.add( point.x, point.z ); } + // a margin, so that terrain lying off to the side still gets a real distance + m_extent.minx -= margin; m_extent.maxx += margin; + m_extent.minz -= margin; m_extent.maxz += margin; + m_width = static_cast( ( m_extent.maxx - m_extent.minx ) / m_cell ) + 1; + m_height = static_cast( ( m_extent.maxz - m_extent.minz ) / m_cell ) + 1; + m_distance.assign( m_width * m_height, unreachable ); + + for( auto const &point : Samples ) { + auto const index { cell( point.x, point.z ) }; + if( index != npos ) { m_distance[ index ] = 0.0f; } + } + // two-pass chamfer transform; weights 3/4 approximate euclidean within ~2% + constexpr float straight { 3.0f }, diagonal { 4.0f }; + auto relax = [ & ]( std::size_t const Target, std::size_t const Source, float const Weight ) { + m_distance[ Target ] = std::min( m_distance[ Target ], m_distance[ Source ] + Weight ); }; + for( std::size_t y = 0; y < m_height; ++y ) { + for( std::size_t x = 0; x < m_width; ++x ) { + auto const here { y * m_width + x }; + if( y > 0 ) { + relax( here, here - m_width, straight ); + if( x > 0 ) { relax( here, here - m_width - 1, diagonal ); } + if( x + 1 < m_width ) { relax( here, here - m_width + 1, diagonal ); } + } + if( x > 0 ) { relax( here, here - 1, straight ); } + } + } + for( std::size_t y = m_height; y-- > 0; ) { + for( std::size_t x = m_width; x-- > 0; ) { + auto const here { y * m_width + x }; + if( y + 1 < m_height ) { + relax( here, here + m_width, straight ); + if( x > 0 ) { relax( here, here + m_width - 1, diagonal ); } + if( x + 1 < m_width ) { relax( here, here + m_width + 1, diagonal ); } + } + if( x + 1 < m_width ) { relax( here, here + 1, straight ); } + } + } + } + + // distance in metres, or a negative value when the point falls outside the field + double + at( double const X, double const Z ) const { + auto const index { cell( X, Z ) }; + if( index == npos ) { return -1.0; } + auto const value { m_distance[ index ] }; + if( value >= unreachable ) { return -1.0; } + return value * m_cell / 3.0; } + + bool ready() const { return false == m_distance.empty(); } + +private: + static constexpr std::size_t npos { static_cast( -1 ) }; + static constexpr float unreachable { 1e9f }; + static constexpr double margin { 4000.0 }; + + std::size_t + cell( double const X, double const Z ) const { + if( ( X < m_extent.minx ) || ( X > m_extent.maxx ) + || ( Z < m_extent.minz ) || ( Z > m_extent.maxz ) ) { return npos; } + auto const x { static_cast( ( X - m_extent.minx ) / m_cell ) }; + auto const z { static_cast( ( Z - m_extent.minz ) / m_cell ) }; + if( ( x >= m_width ) || ( z >= m_height ) ) { return npos; } + return z * m_width + x; } + + bounds m_extent; + double m_cell { 16.0 }; + std::size_t m_width { 0 }, m_height { 0 }; + std::vector m_distance; +}; + + +// reads track nodes and samples their centrelines. the scenery spells a track as a cubic +// bezier: two endpoints with roll, and two control vectors relative to them +std::vector +sampletracks( std::vector const &Paths, double const Spacing ) { + + std::vector samples; + std::size_t nodes { 0 }, rejected { 0 }; + double tracklength { 0.0 }; + + for( auto const &path : Paths ) { + + auto const content { readfile( path ) }; + auto const *cursor { content.data() }; + auto const *end { cursor + content.size() }; + + auto token = [ & ]() -> std::string_view { + while( cursor < end && ( static_cast( *cursor ) <= ' ' ) ) { ++cursor; } + auto const *start { cursor }; + while( cursor < end && ( static_cast( *cursor ) > ' ' ) ) { ++cursor; } + return { start, static_cast( cursor - start ) }; }; + auto number = [ & ]() -> double { + auto const word { token() }; + if( ( true == word.empty() ) || ( false == numeric( word.front() ) ) ) { return 0.0; } + double parsed { 0.0 }; + std::from_chars( word.data(), word.data() + word.size(), parsed ); + return parsed; }; + + while( cursor < end ) { + + auto const word { token() }; + if( true == word.empty() ) { break; } + if( word != "track" ) { continue; } + + // the header has to be walked token by token, in the order TTrack::Load reads + // it: skipping to "the first numbers after the keyword" lands in the middle of + // the parameter line and yields nonsense geometry + token(); // type + for( auto field { 0u }; field < 4u; ++field ) { number(); } // length, gauge, friction, sound distance + for( auto field { 0u }; field < 2u; ++field ) { number(); } // quality, damage + token(); // environment + auto const visibility { token() }; // vis / unvis + if( visibility == "vis" ) { + token(); // rail texture + number(); // texture tile length + token(); // sub or rail texture + for( auto field { 0u }; field < 3u; ++field ) { number(); } // tex height, width, slope + } + + vertex p0 {}, c0 {}, c1 {}, p1 {}; + p0.x = number(); p0.y = number(); p0.z = number(); number(); // roll + c0.x = number(); c0.y = number(); c0.z = number(); + c1.x = number(); c1.y = number(); c1.z = number(); + p1.x = number(); p1.y = number(); p1.z = number(); number(); // roll + + vertex const control0 { p0.x + c0.x, p0.y + c0.y, p0.z + c0.z }; + vertex const control1 { p1.x + c1.x, p1.y + c1.y, p1.z + c1.z }; + + auto const chord { + std::sqrt( ( p1.x - p0.x ) * ( p1.x - p0.x ) + ( p1.z - p0.z ) * ( p1.z - p0.z ) ) }; + ++nodes; + if( ( chord <= 0.0 ) || ( chord > 5000.0 ) ) { ++rejected; continue; } + tracklength += chord; + auto const steps { std::max( 2u, static_cast( chord / Spacing ) + 1 ) }; + + for( auto step { 0u }; step <= steps; ++step ) { + auto const t { static_cast( step ) / static_cast( steps ) }; + auto const u { 1.0 - t }; + auto const w0 { u * u * u }, w1 { 3.0 * u * u * t }, w2 { 3.0 * u * t * t }, w3 { t * t * t }; + samples.push_back( { + w0 * p0.x + w1 * control0.x + w2 * control1.x + w3 * p1.x, + 0.0, + w0 * p0.z + w1 * control0.z + w2 * control1.z + w3 * p1.z } ); + } + } + } + std::printf( " track nodes %zu, rejected %zu, total chord length %.1f km\n", + nodes, rejected, tracklength / 1000.0 ); + return samples; +} + + + +// prints a cross section straight out of a cooked file. a screenshot can show that the +// ground has a cliff in it; only the samples say how tall it is and how many of them it +// takes, which is the difference between a slope that is too steep and one that is right +int +probe( std::filesystem::path const &Path, double const X, double const Z, + double const Dirx, double const Dirz, std::size_t const Count ) { + + heightfield::reader reader; + if( false == reader.open( Path.string() ) ) { + std::printf( "probe: cannot open %s\n", Path.string().c_str() ); + return 1; + } + + auto const length { std::sqrt( Dirx * Dirx + Dirz * Dirz ) }; + if( length < 1e-9 ) { std::printf( "probe: direction has no length\n" ); return 1; } + auto const stepx { Dirx / length * reader.gridstep() }; + auto const stepz { Dirz / length * reader.gridstep() }; + + std::printf( "-- cross section from %.1f, %.1f along %.2f, %.2f, %zu samples of %.2f m\n", + X, Z, Dirx / length, Dirz / length, Count, reader.gridstep() ); + + std::vector heights; + std::vector materials; + auto previous { std::numeric_limits::quiet_NaN() }; + + for( std::size_t index = 0; index < Count; ++index ) { + + auto const x { X + stepx * static_cast( index ) }; + auto const z { Z + stepz * static_cast( index ) }; + auto const tilex { reader.tile_x( x ) }; + auto const tilez { reader.tile_z( z ) }; + if( false == reader.read_level( tilex, tilez, 0, heights, materials ) ) { + std::printf( " %8.1f %8.1f no tile\n", x, z ); + continue; + } + auto const side { reader.samples_per_side( 0 ) }; + auto const local { + static_cast( std::llround( x / reader.gridstep() ) ) + - static_cast( tilex ) * reader.tilesamples() }; + auto const localrow { + static_cast( std::llround( z / reader.gridstep() ) ) + - static_cast( tilez ) * reader.tilesamples() }; + if( ( local < 0 ) || ( localrow < 0 ) || ( local >= side ) || ( localrow >= side ) ) { + std::printf( " %8.1f %8.1f outside\n", x, z ); + continue; + } + auto const sample { heights[ localrow * side + local ] }; + if( sample == heightfield::nodata ) { + std::printf( " %8.1f %8.1f hole\n", x, z ); + previous = std::numeric_limits::quiet_NaN(); + continue; + } + auto const height { reader.height( sample ) }; + auto const material { materials[ localrow * side + local ] }; + std::printf( " %8.1f %8.1f %8.3f m step %+7.3f %s\n", + x, z, height, std::isnan( previous ) ? 0.0 : height - previous, + material < reader.materials().size() ? reader.materials()[ material ].c_str() : "?" ); + previous = height; + } + return 0; +} + +// reads a cooked file back with the reader the engine uses, and checks the promises the +// format makes. the one that matters is the shared edge: neighbouring tiles must hold +// byte-identical samples along the boundary they share, at every mip level, or the +// terrain will show seams that no amount of skirting hides +int +verify( std::filesystem::path const &Path ) { + + heightfield::reader reader; + if( false == reader.open( Path.string() ) ) { + std::printf( "verify: cannot open %s as a heightfield\n", Path.string().c_str() ); + return 1; + } + + std::printf( "-- verifying %s\n", Path.string().c_str() ); + std::printf( " tiles %zu, levels %u, grid step %.2f m, tile side %.0f m, materials %zu\n", + reader.tiles().size(), reader.levels(), reader.gridstep(), reader.tilesize(), reader.materials().size() ); + + std::vector heights, neighbourheights; + std::vector materials, neighbourmaterials; + + std::size_t unreadable { 0 }, seams { 0 }, checkededges { 0 }, holes { 0 }, samples { 0 }; + + for( auto const &tile : reader.tiles() ) { + for( std::uint32_t level = 0; level < reader.levels(); ++level ) { + + if( false == reader.read_level( tile.x, tile.z, level, heights, materials ) ) { + ++unreadable; + continue; + } + auto const side { reader.samples_per_side( level ) }; + if( heights.size() != static_cast( side ) * side ) { + ++unreadable; + continue; + } + if( level == 0 ) { + samples += heights.size(); + for( auto const value : heights ) { + if( value == heightfield::nodata ) { ++holes; } + } + } + + // the tile to the east shares this tile's last column with its first + if( true == reader.contains( tile.x + 1, tile.z ) ) { + if( true == reader.read_level( tile.x + 1, tile.z, level, neighbourheights, neighbourmaterials ) ) { + ++checkededges; + for( std::uint32_t row = 0; row < side; ++row ) { + if( heights[ row * side + ( side - 1 ) ] != neighbourheights[ row * side ] ) { ++seams; break; } + } + } + // re-reading this tile, since the reader keeps one decoded payload + reader.read_level( tile.x, tile.z, level, heights, materials ); + } + // and the tile to the south shares the last row + if( true == reader.contains( tile.x, tile.z + 1 ) ) { + if( true == reader.read_level( tile.x, tile.z + 1, level, neighbourheights, neighbourmaterials ) ) { + ++checkededges; + for( std::uint32_t column = 0; column < side; ++column ) { + if( heights[ ( side - 1 ) * side + column ] != neighbourheights[ column ] ) { ++seams; break; } + } + } + reader.read_level( tile.x, tile.z, level, heights, materials ); + } + } + } + + std::printf( " unreadable tile levels %zu\n", unreadable ); + std::printf( " shared edges checked %zu\n", checkededges ); + std::printf( " mismatched edges %zu\n", seams ); + std::printf( " holes at finest level %zu of %zu samples (%.2f %%)\n", + holes, samples, samples == 0 ? 0.0 : 100.0 * static_cast( holes ) / static_cast( samples ) ); + + auto const failed { ( unreadable > 0 ) || ( seams > 0 ) }; + std::printf( " %s\n", failed ? "FAILED" : "ok" ); + return failed ? 1 : 0; +} + +} // namespace + +int +main( int argc, char *argv[] ) { + + if( argc < 2 ) { + std::cout + << "usage: terraincook \n" + << " [-step ] [-tracks

] [-out ]\n" + << " [-cook] [-cookstep ]\n" + << " analyses the terrain: grid detection, heightfield/overlay split, sampling\n" + << " density against distance from the nearest track, coverage by cell size\n" + << " -cook additionally rasterises it into a tiled heightfield (terrain.ehf)\n" + << " -raw leaves the cooked tiles uncompressed\n" + << "usage: terraincook -verify \n" + << " reads a cooked file back and checks its tiles and shared edges\n" + << "usage: terraincook -probe \n" + << " prints cooked heights along a line, for looking at a cross section\n"; + return 1; + } + + for( auto index { 1 }; index < argc; ++index ) { + if( std::string { argv[ index ] } != "-probe" ) { continue; } + if( index + 2 >= argc ) { + std::cerr << "-probe needs \n"; + return 1; + } + std::vector fields; + std::istringstream stream { std::string { argv[ index + 2 ] } }; + std::string field; + while( std::getline( stream, field, ',' ) ) { fields.push_back( std::strtod( field.c_str(), nullptr ) ); } + if( fields.size() < 5 ) { + std::cerr << "-probe wants x,z,dirx,dirz,count\n"; + return 1; + } + return probe( + argv[ index + 1 ], fields[ 0 ], fields[ 1 ], fields[ 2 ], fields[ 3 ], + static_cast( fields[ 4 ] ) ); + } + + // verification stands on its own: it reads a cooked file and needs no source data + for( auto index { 1 }; index < argc; ++index ) { + if( std::string { argv[ index ] } != "-verify" ) { continue; } + if( index + 1 >= argc ) { + std::cerr << "-verify needs a path to a cooked heightfield\n"; + return 1; + } + return verify( argv[ index + 1 ] ); + } + + std::vector inputs; + std::vector trackinputs; + std::filesystem::path outputdirectory { "." }; + double step { 0.0 }; + double cookstep { 2.0 }; + bool cooking { false }; + bool compress { true }; + + for( auto index { 1 }; index < argc; ++index ) { + std::string const argument { argv[ index ] }; + if( argument == "-step" && index + 1 < argc ) { step = std::strtod( argv[ ++index ], nullptr ); continue; } + if( argument == "-out" && index + 1 < argc ) { outputdirectory = argv[ ++index ]; continue; } + if( argument == "-cook" ) { cooking = true; continue; } + if( argument == "-raw" ) { compress = false; continue; } + if( argument == "-cookstep" && index + 1 < argc ) { cookstep = std::strtod( argv[ ++index ], nullptr ); cooking = true; continue; } + if( argument == "-tracks" && index + 1 < argc ) { + std::filesystem::path const trackpath { argv[ ++index ] }; + if( std::filesystem::is_directory( trackpath ) ) { + for( auto const &entry : std::filesystem::directory_iterator( trackpath ) ) { + if( entry.path().extension() == ".scm" ) { trackinputs.push_back( entry.path() ); } + } + } + else { trackinputs.push_back( trackpath ); } + continue; } + std::filesystem::path const path { argument }; + if( std::filesystem::is_directory( path ) ) { + for( auto const &entry : std::filesystem::directory_iterator( path ) ) { + if( entry.path().extension() == ".scm" ) { inputs.push_back( entry.path() ); } + } + } + else { + inputs.push_back( path ); + } + } + std::sort( inputs.begin(), inputs.end() ); + + if( true == inputs.empty() ) { + std::cerr << "no input files\n"; + return 1; + } + + // first pass: sample vertices to pick the grid step, unless one was given + if( step <= 0.0 ) { + std::vector sample; + auto const content { readfile( inputs.front() ) }; + parse( content, [ & ]( vertex const &A, vertex const &B, vertex const &C, std::string_view ) { + if( sample.size() >= 300000 ) { return; } + sample.push_back( A ); sample.push_back( B ); sample.push_back( C ); } ); + + std::cout << "grid step detection, on " << sample.size() << " sampled vertices:\n"; + step = terrain::detect_step( + sample, + []( double const Candidate, double const Share ) { + std::printf( " %6.2f m %6.2f %%\n", Candidate, Share ); } ); + std::cout << " chosen: " << step << " m\n\n"; + } + + // distance-to-track field, when track files were given + distance_field trackdistance; + if( false == trackinputs.empty() ) { + std::sort( trackinputs.begin(), trackinputs.end() ); + auto const samples { sampletracks( trackinputs, 8.0 ) }; + std::cout << "track centreline samples: " << samples.size() << "\n"; + trackdistance.build( samples, 16.0 ); + } + // triangles bucketed by distance from the nearest track, with their sampling scale + constexpr double distancebands[] { 25.0, 50.0, 100.0, 200.0, 400.0, 800.0, 1600.0 }; + constexpr std::size_t bandcount { sizeof( distancebands ) / sizeof( distancebands[ 0 ] ) + 2 }; + std::array bandtriangles {}; + std::array bandedgesum {}; + std::array bandfine {}; + + terrain::cooker cooker; + cooker.configure( cookstep, compress ); + + statistics stats; + std::vector> coverage( coveragescalecount ); + std::unordered_map heights; + heights.reserve( 8u << 20 ); + std::vector overlay; + + for( auto const &path : inputs ) { + + auto const content { readfile( path ) }; + if( true == content.empty() ) { + std::cerr << "cannot read " << path << "\n"; + continue; + } + auto const before { stats.triangles }; + + parse( content, [ & ]( vertex const &A, vertex const &B, vertex const &C, std::string_view Material ) { + + if( true == cooking ) { cooker.rasterize( A, B, C, Material ); } + ++stats.triangles; + stats.vertices += 3; + vertex const points[ 3 ] { A, B, C }; + + auto gridpoints { 0 }; + for( auto const &point : points ) { + stats.extent.add( point.x, point.z ); + if( on_grid( point.x, step ) && on_grid( point.z, step ) ) { ++gridpoints; } + for( auto scaleindex { 0u }; scaleindex < coveragescalecount; ++scaleindex ) { + auto const cell { coveragescales[ scaleindex ] }; + coverage[ scaleindex ].emplace( + tile_key( + static_cast( std::floor( point.x / cell ) ), + static_cast( std::floor( point.z / cell ) ) ), + char { 0 } ); + } + } + // horizontal edge lengths and footprint area, to see the real sampling density + for( auto index { 0u }; index < 3u; ++index ) { + auto const length { planar_length( points[ index ], points[ ( index + 1 ) % 3 ] ) }; + auto bucket { 0u }; + while( ( bucket < 7u ) && ( length >= ( 1.0 * ( 1u << bucket ) ) ) ) { ++bucket; } + ++stats.edgebuckets[ bucket ]; + } + if( true == trackdistance.ready() ) { + auto const centroidx { ( points[ 0 ].x + points[ 1 ].x + points[ 2 ].x ) / 3.0 }; + auto const centroidz { ( points[ 0 ].z + points[ 1 ].z + points[ 2 ].z ) / 3.0 }; + auto edgesum { 0.0 }; + for( auto index { 0u }; index < 3u; ++index ) { + edgesum += planar_length( points[ index ], points[ ( index + 1 ) % 3 ] ); + } + auto const scale { edgesum / 3.0 }; + auto const distance { trackdistance.at( centroidx, centroidz ) }; + std::size_t band { bandcount - 1 }; // outside the field + if( distance >= 0.0 ) { + band = bandcount - 2; // beyond the last threshold + for( auto index { 0u }; index < bandcount - 2; ++index ) { + if( distance < distancebands[ index ] ) { band = index; break; } + } + } + ++bandtriangles[ band ]; + bandedgesum[ band ] += scale; + if( scale < 4.0 ) { ++bandfine[ band ]; } + } + stats.areasum += 0.5 * std::abs( + ( points[ 1 ].x - points[ 0 ].x ) * ( points[ 2 ].z - points[ 0 ].z ) + - ( points[ 2 ].x - points[ 0 ].x ) * ( points[ 1 ].z - points[ 0 ].z ) ); + stats.gridvertices += static_cast( gridpoints ); + + if( gridpoints == 3 ) { + ++stats.gridtriangles; + for( auto const &point : points ) { + auto const key { + tile_key( + static_cast( std::llround( point.x / step ) ), + static_cast( std::llround( point.z / step ) ) ) }; + auto const existing { heights.find( key ) }; + if( existing == heights.end() ) { + heights.emplace( key, static_cast( point.y ) ); + } + else { + auto const difference { std::abs( static_cast( existing->second ) - point.y ) }; + if( difference > conflictepsilon ) { + ++stats.conflicts; + stats.maxconflict = std::max( stats.maxconflict, difference ); + } + } + } + } + else { + overlay.push_back( { { A, B, C } } ); + for( auto const &point : points ) { stats.overlayextent.add( point.x, point.z ); } + } + } ); + + std::printf( "%-28s %10zu triangles\n", + path.filename().string().c_str(), stats.triangles - before ); + } + + auto const share = []( std::size_t const Part, std::size_t const Whole ) { + return Whole == 0 ? 0.0 : 100.0 * static_cast( Part ) / static_cast( Whole ); }; + + // the full bounding box is set by outliers, so derive the working extent from the + // occupied grid samples themselves, trimming the sparsest 0.1% at each end + std::vector columns, rows; + columns.reserve( heights.size() ); + rows.reserve( heights.size() ); + for( auto const & [ key, height ] : heights ) { + columns.push_back( static_cast( key >> 32 ) ); + rows.push_back( static_cast( static_cast( key & 0xffffffff ) ) ); + } + auto trimmed = []( std::vector &Values ) -> std::pair { + if( true == Values.empty() ) { return { 0.0, 0.0 }; } + auto const low { Values.size() / 1000 }; + auto const high { Values.size() - 1 - low }; + std::nth_element( Values.begin(), Values.begin() + static_cast( low ), Values.end() ); + auto const minimum { Values[ low ] }; + std::nth_element( Values.begin(), Values.begin() + static_cast( high ), Values.end() ); + auto const maximum { Values[ high ] }; + return { static_cast( minimum ), static_cast( maximum ) }; }; + auto const [ columnmin, columnmax ] { trimmed( columns ) }; + auto const [ rowmin, rowmax ] { trimmed( rows ) }; + + auto const gridwidth { static_cast( columnmax - columnmin ) + 1 }; + auto const gridheight { static_cast( rowmax - rowmin ) + 1 }; + auto const overlaytriangles { stats.triangles - stats.gridtriangles }; + + std::printf( "\n-- terrain decomposition, grid step %.2f m\n", step ); + std::printf( " triangles total %12zu\n", stats.triangles ); + std::printf( " on grid (heightfield)%12zu %6.2f %%\n", stats.gridtriangles, share( stats.gridtriangles, stats.triangles ) ); + std::printf( " off grid (overlay) %12zu %6.2f %%\n", overlaytriangles, share( overlaytriangles, stats.triangles ) ); + std::printf( " vertices on grid %12zu %6.2f %%\n", stats.gridvertices, share( stats.gridvertices, stats.vertices ) ); + std::printf( " distinct grid samples%12zu\n", heights.size() ); + std::printf( " stacked samples %12zu (max height difference %.2f m)\n", stats.conflicts, stats.maxconflict ); + std::printf( " full bounding box %.0f .. %.0f x %.0f .. %.0f z\n", + stats.extent.minx, stats.extent.maxx, stats.extent.minz, stats.extent.maxz ); + std::printf( " working extent %.0f .. %.0f x %.0f .. %.0f z (%zu x %zu grid)\n", + columnmin * step, columnmax * step, rowmin * step, rowmax * step, gridwidth, gridheight ); + std::printf( " covered area %12.2f km2 (%.1f %% of the working extent)\n", + heights.size() * step * step / 1.0e6, + share( heights.size(), gridwidth * gridheight ) ); + if( overlaytriangles > 0 ) { + std::printf( " overlay extent %.0f .. %.0f x %.0f .. %.0f z\n", + stats.overlayextent.minx, stats.overlayextent.maxx, + stats.overlayextent.minz, stats.overlayextent.maxz ); + } + + std::printf( "\n-- real sampling density\n" ); + std::printf( " summed triangle footprint %8.2f km2\n", stats.areasum / 1.0e6 ); + std::printf( " mean triangle footprint %8.2f m2\n", + stats.triangles == 0 ? 0.0 : stats.areasum / static_cast( stats.triangles ) ); + char const *edgelabels[ 8 ] { "<1 m", "<2 m", "<4 m", "<8 m", "<16 m", "<32 m", "<64 m", ">=64 m" }; + std::size_t edgetotal { 0 }; + for( auto const bucket : stats.edgebuckets ) { edgetotal += bucket; } + for( auto index { 0u }; index < 8u; ++index ) { + if( stats.edgebuckets[ index ] == 0 ) { continue; } + std::printf( " edges %-7s %12zu %6.2f %%\n", + edgelabels[ index ], stats.edgebuckets[ index ], share( stats.edgebuckets[ index ], edgetotal ) ); + } + + if( true == trackdistance.ready() ) { + std::printf( "\n-- sampling density against distance from the nearest track\n" ); + std::printf( " %-14s %12s %12s %10s\n", "band", "triangles", "mean edge", "fine <4 m" ); + for( auto index { 0u }; index < bandcount; ++index ) { + if( bandtriangles[ index ] == 0 ) { continue; } + char label[ 32 ]; + if( index < bandcount - 2 ) { + std::snprintf( label, sizeof( label ), "< %.0f m", distancebands[ index ] ); + } + else if( index == bandcount - 2 ) { + std::snprintf( label, sizeof( label ), ">= %.0f m", distancebands[ bandcount - 3 ] ); + } + else { + std::snprintf( label, sizeof( label ), "off the field" ); + } + std::printf( " %-14s %12zu %10.2f m %9.2f %%\n", + label, bandtriangles[ index ], + bandedgesum[ index ] / static_cast( bandtriangles[ index ] ), + share( bandfine[ index ], bandtriangles[ index ] ) ); + } + } + + std::printf( "\n-- covered area by cell size, and what a heightfield at that resolution costs\n" ); + for( auto scaleindex { 0u }; scaleindex < coveragescalecount; ++scaleindex ) { + auto const cell { coveragescales[ scaleindex ] }; + auto const cells { coverage[ scaleindex ].size() }; + std::printf( " %5.0f m %12zu cells %9.2f km2 %9.1f MB R16\n", + cell, cells, cells * cell * cell / 1.0e6, cells * 2u / 1048576.0 ); + } + + auto const heightfieldbytes { gridwidth * gridheight * 2u }; + auto const overlaybytes { overlaytriangles * 3u * 20u }; + std::printf( "\n-- storage estimate\n" ); + std::printf( " dense heightfield R16%12.1f MB (whole bounding box)\n", heightfieldbytes / 1048576.0 ); + std::printf( " occupied samples R16 %12.1f MB (sparse, what is actually covered)\n", heights.size() * 2u / 1048576.0 ); + std::printf( " overlay at 20 B/vertex%11.1f MB\n", overlaybytes / 1048576.0 ); + + // previews, downsampled so that the wider side is at most 2048 pixels + std::filesystem::create_directories( outputdirectory ); + auto const preview { std::max( 1u, ( std::max( gridwidth, gridheight ) + 2047u ) / 2048u ) }; + auto const previewwidth { gridwidth / preview + 1 }; + auto const previewheight { gridheight / preview + 1 }; + constexpr float sentinel { -1e30f }; + + std::vector heightpreview( previewwidth * previewheight, sentinel ); + for( auto const & [ key, height ] : heights ) { + auto const x { static_cast( key >> 32 ) }; + auto const z { static_cast( static_cast( key & 0xffffffff ) ) }; + if( ( x < columnmin ) || ( x > columnmax ) || ( z < rowmin ) || ( z > rowmax ) ) { continue; } + auto const column { static_cast( x - columnmin ) / preview }; + auto const row { static_cast( z - rowmin ) / preview }; + if( column >= previewwidth || row >= previewheight ) { continue; } + auto &target { heightpreview[ row * previewwidth + column ] }; + target = ( target == sentinel ? height : std::max( target, height ) ); + } + writepreview( outputdirectory / "height.pgm", heightpreview, previewwidth, previewheight, sentinel ); + + std::vector overlaypreview( previewwidth * previewheight, 0.0f ); + for( auto const &piece : overlay ) { + for( auto const &point : piece.v ) { + auto const gridx { std::llround( point.x / step ) }; + auto const gridz { std::llround( point.z / step ) }; + if( ( gridx < columnmin ) || ( gridx > columnmax ) || ( gridz < rowmin ) || ( gridz > rowmax ) ) { continue; } + auto const column { static_cast( gridx - columnmin ) / preview }; + auto const row { static_cast( gridz - rowmin ) / preview }; + if( column >= previewwidth || row >= previewheight ) { continue; } + overlaypreview[ row * previewwidth + column ] += 1.0f; + } + } + writepreview( outputdirectory / "overlay.pgm", overlaypreview, previewwidth, previewheight, -1.0f ); + + if( true == cooking ) { + std::filesystem::create_directories( outputdirectory ); + cooker.finish( outputdirectory / "terrain.ehf", outputdirectory / "cooked.pgm" ); + } + + std::printf( "\n previews written to %s (%zux%zu, 1 px = %.0f m)\n", + outputdirectory.string().c_str(), previewwidth, previewheight, step * preview ); + + return 0; +} diff --git a/tools/terraincook/overlay.pgm b/tools/terraincook/overlay.pgm new file mode 100644 index 00000000..8817a187 --- /dev/null +++ b/tools/terraincook/overlay.pgm @@ -0,0 +1,13 @@ +P5 +840 654 +255 +#4“º 7&ï£  <†�I  ¬ + ª+  + OšÁD.ÿ&  Ü  &Fá +!“C  †  '_. ~  (i +w'u"#"i  k, "ˆ€ "+‚ 7Y%     +  + + + +                  ":MA(( +*)0l \ No newline at end of file diff --git a/utilities/Globals.cpp b/utilities/Globals.cpp index ff4612c5..ab5fb91c 100644 --- a/utilities/Globals.cpp +++ b/utilities/Globals.cpp @@ -265,6 +265,12 @@ bool global_settings::ConfigParseGraphics(cParser& Parser, const std::string& to return true; } + if (token == "terrainrange") + { + ParseOne(Parser, TerrainRange, 1); + return true; + } + if (token == "vsync") { ParseOne(Parser, VSync); @@ -1533,6 +1539,7 @@ global_settings::export_as_text( std::ostream &Output ) const { export_as_text( Output, "height", window_size.y ); export_as_text( Output, "targetfps", targetfps ); export_as_text( Output, "basedrawrange", BaseDrawRange ); + export_as_text( Output, "terrainrange", TerrainRange ); export_as_text( Output, "fullscreen", bFullScreen ); export_as_text( Output, "fullscreenmonitor", fullscreen_monitor ); export_as_text( Output, "fullscreenwindowed", fullscreen_windowed ); diff --git a/utilities/Globals.cppm b/utilities/Globals.cppm index b7cd9ebe..b840c283 100644 --- a/utilities/Globals.cppm +++ b/utilities/Globals.cppm @@ -122,6 +122,19 @@ struct global_settings { int iConvertModels{ 0 }; // tworzenie plików binarnych int iConvertIndexRange{ 1000 }; // range of duplicate vertex scan bool file_binary_terrain{ true }; // enable binary terrain (de)serialization + // -cooklogic: write the scenery's LOGIC container once it has loaded, then read it back + // and check it against what was loaded + bool cook_logic{ false }; + // -verifylogic: read the container that is already there and check it against the + // scenery, without writing anything. this is how a stale container gets caught + bool verify_logic{ false }; + // bake a heightfield for a scenery that has none, the first time it is opened, and use + // it from then on. -nobaketerrain turns it off + bool bake_terrain{ true }; + // cooked heightfields drawn in place of the legacy terrain geometry, coarsest first + // once sorted. a scenery needs several: a regional elevation model sampled every + // hundred metres, and a corridor of its own for each line, sampled every two + std::vector terrain_heightfields; bool file_binary_terrain_state{true}; // logs bool priorityLoadText3D{false}; // ladowanie T3D priorytetowo @@ -180,6 +193,9 @@ struct global_settings { bool bWireFrame{ false }; bool bAdjustScreenFreq{ true }; float BaseDrawRange{ 2500.f }; + // how far terrain tiles are streamed and drawn, independent of BaseDrawRange: a baked + // heightfield is cheap far out, and seeing it to the horizon is the point of having it + float TerrainRange{ 20000.f }; int DynamicLightCount{ 7 }; bool ScaleSpecularValues{ true }; std::string GfxRenderer{ "default" }; diff --git a/utilities/parser.cpp b/utilities/parser.cpp index 816d4cc0..22d9fe36 100644 --- a/utilities/parser.cpp +++ b/utilities/parser.cpp @@ -622,6 +622,11 @@ std::size_t cParser::Line() const } } +std::vector const & cParser::Parameters() const +{ + return mIncludeParser ? mIncludeParser->Parameters() : parameters; +} + int cParser::LineMain() const { return mIncludeParser ? -1 : mLine; diff --git a/utilities/parser.cppm b/utilities/parser.cppm index 57e28756..3f87c3bd 100644 --- a/utilities/parser.cppm +++ b/utilities/parser.cppm @@ -109,6 +109,8 @@ class cParser //: public std::stringstream std::string Name() const; // returns number of currently processed line std::size_t Line() const; + // parameters of the innermost file being read, the (pN) values its include line gave + std::vector const & Parameters() const; // returns number of currently processed line in main file, -1 if inside include int LineMain() const; bool expandIncludes = true; diff --git a/utilities/utilities.cpp b/utilities/utilities.cpp index 1e85de7c..70292b45 100644 --- a/utilities/utilities.cpp +++ b/utilities/utilities.cpp @@ -415,6 +415,24 @@ std::time_t last_modified(std::string const &Filename) } // potentially erases file extension from provided file name. returns: true if extension was removed, false otherwise +std::string scenery_file(std::string const &Sceneryfile) +{ + return Sceneryfile.find_first_of("/\\") == std::string::npos ? Global.asCurrentSceneryPath + Sceneryfile : Sceneryfile; +} + +std::string scenery_sidecar(std::string const &Sceneryfile, std::string const &Extension) +{ + auto path{scenery_file(Sceneryfile)}; + auto const slash{path.find_last_of("/\\")}; + auto const namestart{slash == std::string::npos ? 0 : slash + 1}; + if ((namestart < path.size()) && (path[namestart] == '$')) + { + path.erase(namestart, 1); + } + erase_extension(path); + return path + Extension; +} + bool erase_extension(std::string &Filename) { diff --git a/utilities/utilities.cppm b/utilities/utilities.cppm index 99f9f89d..9a237dce 100644 --- a/utilities/utilities.cppm +++ b/utilities/utilities.cppm @@ -232,6 +232,14 @@ std::time_t last_modified(std::string const &Filename); // potentially erases file extension from provided file name. returns: true if extension was removed, false otherwise bool erase_extension(std::string &Filename); +// where a scenery file lives: a bare name is looked up in the scenery directory +std::string scenery_file(std::string const &Sceneryfile); + +// a file cooked for a scenery and kept beside it, with the given extension in place of the +// scenery's. named after the scenario rather than the $-prefixed copy of it the launcher +// writes on every start, so the launcher and a direct start share one +std::string scenery_sidecar(std::string const &Sceneryfile, std::string const &Extension); + // potentially erase leading slashes from provided file path void erase_leading_slashes(std::string &Filename); diff --git a/world/Event.cpp b/world/Event.cpp index 25fb3ef9..0feeb9c3 100644 --- a/world/Event.cpp +++ b/world/Event.cpp @@ -309,6 +309,17 @@ basic_event::~basic_event() { m_sibling = nullptr; // nie usuwać podczepionych tutaj } +std::vector +basic_event::target_names() const { + + std::vector names; + names.reserve( m_targets.size() ); + for( auto const &target : m_targets ) { + names.emplace_back( std::get( target ) ); + } + return names; +} + void basic_event::deserialize( cParser &Input, scene::scratch_data &Scratchpad ) { @@ -356,10 +367,10 @@ basic_event::deserialize_targets( std::string const &Input ) { } void -basic_event::run() { +basic_event::run( TDynamicObject const *Activator ) { - WriteLog( "EVENT LAUNCHED" + ( m_activator ? " by " + m_activator->asName : "" ) + ": " + m_name ); - run_(); + WriteLog( "EVENT LAUNCHED" + ( Activator ? " by " + Activator->asName : "" ) + ": " + m_name ); + run_( Activator ); } // sends basic content of the class in legacy (text) format to provided stream @@ -537,7 +548,7 @@ updatevalues_event::deserialize_( cParser &Input, scene::scratch_data &Scratchpa // run() subclass details // TODO: update and copy values run_ methods are largely identical, refactor to a single helper void -updatevalues_event::run_() { +updatevalues_event::run_( TDynamicObject const *Activator ) { if( false == m_conditions.test() ) { return; } @@ -631,7 +642,7 @@ getvalues_event::deserialize_( cParser &Input, scene::scratch_data &Scratchpad ) // run() subclass details void -getvalues_event::run_() { +getvalues_event::run_( TDynamicObject const *Activator ) { auto const *cell { m_input.data_cell() }; @@ -641,15 +652,15 @@ getvalues_event::run_() { + to_string( cell->Value1(), 2 ) + "] [" + to_string( cell->Value2(), 2 ) + "]" ); - if( m_activator == nullptr ) { return; } + if( Activator == nullptr ) { return; } cell->PutCommand( - m_activator->Mechanik, + Activator->Mechanik, &cell->location() ); // potwierdzenie wykonania dla serwera (odczyt semafora już tak nie działa) if( Global.iMultiplayer ) { - multiplayer::WyslijEvent( m_name, m_activator->name() ); + multiplayer::WyslijEvent( m_name, Activator->name() ); } } @@ -787,7 +798,7 @@ putvalues_event::deserialize_( cParser &Input, scene::scratch_data &Scratchpad ) // run() subclass details void -putvalues_event::run_() { +putvalues_event::run_( TDynamicObject const *Activator ) { WriteLog( "Type: PutValues - [" @@ -795,7 +806,7 @@ putvalues_event::run_() { + to_string( m_input.data_value_1, 2 ) + "] [" + to_string( m_input.data_value_2, 2 ) + "]" ); - if( m_activator == nullptr ) { return; } + if( Activator == nullptr ) { return; } // zamiana, bo fizyka ma inaczej niż sceneria // NOTE: y & z swap, negative x TLocation const loc { @@ -803,22 +814,22 @@ putvalues_event::run_() { m_input.location.z, m_input.location.y }; - if( m_activator->Mechanik ) { + if( Activator->Mechanik ) { // przekazanie rozkazu do AI - WriteLog( " Vehicle: [" + m_activator->Mechanik->Vehicle()->name() + "]" ); - m_activator->Mechanik->PutCommand( + WriteLog( " Vehicle: [" + Activator->Mechanik->Vehicle()->name() + "]" ); + Activator->Mechanik->PutCommand( m_input.data_text, m_input.data_value_1, m_input.data_value_2, loc ); } - else if( m_activator->ctOwner + else if( Activator->ctOwner && is_command_for_owner(m_input) ) { // send the command to consist owner, // we're acting on presumption there's hardly ever need to issue command to unmanned vehicle // and the intended recipient moved between vehicles after the event was queued - WriteLog( " Vehicle: [" + m_activator->ctOwner->Vehicle()->name() + "]" ); - m_activator->ctOwner->PutCommand( + WriteLog( " Vehicle: [" + Activator->ctOwner->Vehicle()->name() + "]" ); + Activator->ctOwner->PutCommand( m_input.data_text, m_input.data_value_1, m_input.data_value_2, @@ -826,8 +837,8 @@ putvalues_event::run_() { } else { // przekazanie do pojazdu - WriteLog( " Vehicle: [" + m_activator->name() +"]" ); - m_activator->MoverParameters->PutCommand( + WriteLog( " Vehicle: [" + Activator->name() +"]" ); + Activator->MoverParameters->PutCommand( m_input.data_text, m_input.data_value_1, m_input.data_value_2, @@ -943,7 +954,7 @@ copyvalues_event::deserialize_( cParser &Input, scene::scratch_data &Scratchpad // run() subclass details // TODO: update and copy values run_ methods are largely identical, refactor to a single helper void -copyvalues_event::run_() { +copyvalues_event::run_( TDynamicObject const *Activator ) { // skopiowanie wartości z innej komórki auto const *datasource { static_cast( std::get( m_input.data_source ) ) }; m_input.data_text = datasource->Text(); @@ -1035,9 +1046,9 @@ whois_event::deserialize_( cParser &Input, scene::scratch_data &Scratchpad ) { // run() subclass details void -whois_event::run_() { +whois_event::run_( TDynamicObject const *Activator ) { - if( m_activator == nullptr ) { + if( Activator == nullptr ) { // no triggering vehicle to report on - most likely whois was launched from a // scenery event chain rather than a track/isolation crossing WriteLog( "Type: WhoIs (" + std::to_string( m_input.flags ) + ") - no activator, event ignored" ); @@ -1059,9 +1070,9 @@ whois_event::run_() { // next station name if( m_input.flags & flags::mode_alt ) { auto const *owner { ( - m_activator->Mechanik != nullptr && m_activator->Mechanik->primary() ? - m_activator->Mechanik : - m_activator->ctOwner ) }; + Activator->Mechanik != nullptr && Activator->Mechanik->primary() ? + Activator->Mechanik : + Activator->ctOwner ) }; auto const nextstop { ( owner != nullptr ? owner->TrainTimetable().NextStop() : @@ -1086,14 +1097,14 @@ whois_event::run_() { // vehicle name else { targetcell->UpdateValues( - m_activator->asName, // vehicle name + Activator->asName, // vehicle name 0, // unused 0, // unused m_input.flags & ( flags::text | flags::value1 | flags::value2 ) ); WriteLog( "Type: WhoIs (" + std::to_string( m_input.flags ) + ") - " - + "[name: " + m_activator->asName + "], " + + "[name: " + Activator->asName + "], " + "[X], " + "[X]" ); } @@ -1105,9 +1116,9 @@ whois_event::run_() { // jeśli typ pojazdu // TODO: define and recognize individual request types auto const owner { ( - m_activator->Mechanik != nullptr && m_activator->Mechanik->primary() ? - m_activator->Mechanik : - m_activator->ctOwner ) }; + Activator->Mechanik != nullptr && Activator->Mechanik->primary() ? + Activator->Mechanik : + Activator->ctOwner ) }; auto const consistbrakelevel { ( owner != nullptr ? owner->fReady : @@ -1118,45 +1129,45 @@ whois_event::run_() { -1.0 ) }; targetcell->UpdateValues( - m_activator->MoverParameters->TypeName, // typ pojazdu + Activator->MoverParameters->TypeName, // typ pojazdu consistbrakelevel, collisiondistance, m_input.flags & ( flags::text | flags::value1 | flags::value2 ) ); WriteLog( "Type: WhoIs (" + std::to_string( m_input.flags ) + ") - " - + "[type: " + m_activator->MoverParameters->TypeName + "], " + + "[type: " + Activator->MoverParameters->TypeName + "], " + "[consist brake level: " + to_string( consistbrakelevel, 2 ) + "], " + "[obstacle distance: " + to_string( collisiondistance, 2 ) + " m]" ); } else { // jeśli parametry ładunku targetcell->UpdateValues( - m_activator->MoverParameters->LoadType.name, // nazwa ładunku - m_activator->MoverParameters->LoadAmount, // aktualna ilość - m_activator->MoverParameters->MaxLoad, // maksymalna ilość + Activator->MoverParameters->LoadType.name, // nazwa ładunku + Activator->MoverParameters->LoadAmount, // aktualna ilość + Activator->MoverParameters->MaxLoad, // maksymalna ilość m_input.flags & ( flags::text | flags::value1 | flags::value2 ) ); WriteLog( "Type: WhoIs (" + std::to_string( m_input.flags ) + ") - " - + "[load type: " + m_activator->MoverParameters->LoadType.name + "], " - + "[current load: " + to_string( m_activator->MoverParameters->LoadAmount, 2 ) + "], " - + "[max load: " + to_string( m_activator->MoverParameters->MaxLoad, 2 ) + "]" ); + + "[load type: " + Activator->MoverParameters->LoadType.name + "], " + + "[current load: " + to_string( Activator->MoverParameters->LoadAmount, 2 ) + "], " + + "[max load: " + to_string( Activator->MoverParameters->MaxLoad, 2 ) + "]" ); } } // +8 else if( m_input.flags & flags::mode_alt ) { // jeśli miejsce docelowe pojazdu targetcell->UpdateValues( - m_activator->asDestination, // adres docelowy - m_activator->DirectionGet(), // kierunek pojazdu względem czoła składu (1=zgodny,-1=przeciwny) - m_activator->MoverParameters->Power, // moc pojazdu silnikowego: 0 dla wagonu + Activator->asDestination, // adres docelowy + Activator->DirectionGet(), // kierunek pojazdu względem czoła składu (1=zgodny,-1=przeciwny) + Activator->MoverParameters->Power, // moc pojazdu silnikowego: 0 dla wagonu m_input.flags & ( flags::text | flags::value1 | flags::value2 ) ); WriteLog( "Type: WhoIs (" + std::to_string( m_input.flags ) + ") - " - + "[destination: " + m_activator->asDestination + "], " - + "[direction: " + std::to_string( m_activator->DirectionGet() ) + "], " - + "[engine power: " + to_string( m_activator->MoverParameters->Power, 2 ) + "]" ); + + "[destination: " + Activator->asDestination + "], " + + "[direction: " + std::to_string( Activator->DirectionGet() ) + "], " + + "[engine power: " + to_string( Activator->MoverParameters->Power, 2 ) + "]" ); } // +0 else { @@ -1165,9 +1176,9 @@ whois_event::run_() { // triggered the event ahead of its locomotive) - +0 used to require the activator's // own Mechanik and silently produce nothing for a wagon-triggered whois auto const *owner { ( - m_activator->Mechanik != nullptr && m_activator->Mechanik->primary() ? - m_activator->Mechanik : - m_activator->ctOwner ) }; + Activator->Mechanik != nullptr && Activator->Mechanik->primary() ? + Activator->Mechanik : + Activator->ctOwner ) }; if( owner != nullptr ) { targetcell->UpdateValues( owner->TrainName(), @@ -1218,7 +1229,7 @@ logvalues_event::deserialize_( cParser &Input, scene::scratch_data &Scratchpad ) // run() subclass details void -logvalues_event::run_() { +logvalues_event::run_( TDynamicObject const *Activator ) { // zapisanie zawartości komórki pamięci do logu if( m_targets.empty() ) { // lista wszystkich @@ -1316,7 +1327,7 @@ multi_event::deserialize_( cParser &Input, scene::scratch_data &Scratchpad ) { // run() subclass details void -multi_event::run_() { +multi_event::run_( TDynamicObject const *Activator ) { auto const conditiontest { m_conditions.test() }; if( conditiontest @@ -1330,12 +1341,12 @@ multi_event::run_() { if( childevent != this ) { // normalnie dodać - simulation::Events.AddToQuery( childevent, m_activator ); + simulation::Events.AddToQuery( childevent, Activator ); } else { // jeśli ma być rekurencja to musi mieć sensowny okres powtarzania if( m_delay >= 5.0 ) { - simulation::Events.AddToQuery( this, m_activator ); + simulation::Events.AddToQuery( this, Activator ); } } } @@ -1343,8 +1354,8 @@ multi_event::run_() { // dajemy znać do serwera o wykonaniu if( false == m_conditions.has_else ) { // jednoznaczne tylko, gdy nie było else - if( m_activator ) { - multiplayer::WyslijEvent( m_name, m_activator->name() ); + if( Activator ) { + multiplayer::WyslijEvent( m_name, Activator->name() ); } else { multiplayer::WyslijEvent( m_name, "" ); @@ -1425,7 +1436,7 @@ sound_event::deserialize_( cParser &Input, scene::scratch_data &Scratchpad ) { // run() subclass details void -sound_event::run_() { +sound_event::run_( TDynamicObject const *Activator ) { WriteLog( "Type: Sound - [" + std::string( m_soundmode == 1 ? "play" : m_soundmode == -1 ? "loop" : "stop" ) + "]" @@ -1549,7 +1560,7 @@ texture_event::deserialize_( cParser &Input, scene::scratch_data &Scratchpad ) { // run() subclass details void -texture_event::run_() { +texture_event::run_( TDynamicObject const *Activator ) { material_handle material { null_handle }; @@ -1717,7 +1728,7 @@ animation_event::deserialize_( cParser &Input, scene::scratch_data &Scratchpad ) // run() subclass details void -animation_event::run_() { +animation_event::run_( TDynamicObject const *Activator ) { WriteLog( "Type: Animation" ); // animation modes target specific submodels @@ -1816,7 +1827,7 @@ lights_event::deserialize_( cParser &Input, scene::scratch_data &Scratchpad ) { // run() subclass details void -lights_event::run_() { +lights_event::run_( TDynamicObject const *Activator ) { for( auto &target : m_targets ) { auto *targetmodel = static_cast( std::get( target ) ); @@ -1908,7 +1919,7 @@ switch_event::deserialize_( cParser &Input, scene::scratch_data &Scratchpad ) { // run() subclass details void -switch_event::run_() { +switch_event::run_( TDynamicObject const *Activator ) { for( auto &target : m_targets ) { auto *targettrack { static_cast( std::get( target ) ) }; @@ -1975,7 +1986,7 @@ track_event::deserialize_( cParser &Input, scene::scratch_data &Scratchpad ) { // run() subclass details void -track_event::run_() { +track_event::run_( TDynamicObject const *Activator ) { WriteLog( "Type: TrackVel - [" + to_string( m_velocity, 2 ) + "]" ); for( auto &target : m_targets ) { @@ -2024,7 +2035,7 @@ voltage_event::deserialize_( cParser &Input, scene::scratch_data &Scratchpad ) { // run() subclass details void -voltage_event::run_() { +voltage_event::run_( TDynamicObject const *Activator ) { // zmiana napięcia w zasilaczu (TractionPowerSource) WriteLog( "Type: Voltage [" + to_string( m_voltage, 2 ) + "]" ); for( auto &target : m_targets ) { @@ -2087,7 +2098,7 @@ visible_event::deserialize_( cParser &Input, scene::scratch_data &Scratchpad ) { // run() subclass details void -visible_event::run_() { +visible_event::run_( TDynamicObject const *Activator ) { for( auto &target : m_targets ) { auto *targetnode = std::get( target ); @@ -2131,7 +2142,7 @@ friction_event::deserialize_( cParser &Input, scene::scratch_data &Scratchpad ) // run() subclass details void -friction_event::run_() { +friction_event::run_( TDynamicObject const *Activator ) { // zmiana tarcia na scenerii WriteLog( "Type: Friction" ); Global.fFriction = m_friction; @@ -2171,10 +2182,10 @@ void lua_event::deserialize_( cParser &Input, scene::scratch_data &Scratchpad ) } // run() subclass details -void lua_event::run_() { +void lua_event::run_( TDynamicObject const *Activator ) { try { if (lua_func != LUA_NOREF) - lua::dispatch_event(lua_state, lua_func, this, m_activator); + lua::dispatch_event(lua_state, lua_func, this, Activator); } catch (...) { ErrorLog("lua: Runtime error: " + simulation::Lua.get_error()); } @@ -2217,7 +2228,7 @@ message_event::deserialize_( cParser &Input, scene::scratch_data &Scratchpad ) { // run() subclass details void -message_event::run_() { +message_event::run_( TDynamicObject const *Activator ) { // TODO: implement } @@ -2374,6 +2385,8 @@ event_manager::insert( basic_event *Event ) { } } + // the event's identity, fixed for its lifetime; the queue refers to it by this + Event->m_index = static_cast( m_events.size() ); m_events.emplace_back( Event ); if( lookup == m_eventmap.end() ) { // if it's first event with such name, it's potential candidate for the execution queue @@ -2420,118 +2433,131 @@ event_manager::FindEvent( std::string const &Name ) bool event_manager::AddToQuery( basic_event *Event, TDynamicObject const *Owner, double delay ) { - if( Event->m_passive ) { return false; } // jeśli może być dodany do kolejki (nie używany w skanowaniu) - if( Event->m_inqueue > 0 ) { return false; } // jeśli nie dodany jeszcze do kolejki + if( Event->m_passive ) { return false; } // not queued at all: scanning uses it in place + if( true == pending( Event ) ) { return false; } // already waiting its turn - // kolejka eventów jest posortowana względem (fStartTime) - Event->m_activator = Owner; if( Event->is_instant() ) { - // eventy AddValues trzeba wykonywać natychmiastowo, inaczej kolejka może zgubić jakieś dodawanie + // AddValues and its kind have to run on the spot, or the queue loses additions if( false == Event->m_ignored ) { - Event->run(); + Event->run( Owner ); } - // jeśli jest kolejny o takiej samej nazwie, to idzie do kolejki (and if there's no joint event it'll be set to null and processing will end here) + // an event sharing its name goes to the queue instead; skip the ones that cannot do { Event = Event->m_sibling; - // NOTE: we could've received a new event from joint event above, so we need to check conditions just in case and discard the bad events - // TODO: refactor this arrangement, it's hardly optimal - } while( Event != nullptr - && ( Event->m_passive - || Event->m_inqueue > 0 ) ); - } - if( Event != nullptr - && false == Event->m_ignored ) { - // standardowe dodanie do kolejki - ++Event->m_inqueue; // zabezpieczenie przed podwójnym dodaniem do kolejki - WriteLog( "EVENT ADDED TO QUEUE" + ( Owner ? " by " + Owner->asName : "" ) + ": " + Event->m_name ); - Event->m_launchtime = delay + std::abs( Event->m_delay ) + Timer::GetTime(); // czas od uruchomienia scenerii - if( Event->m_delayrandom > 0.0 ) { - // doliczenie losowego czasu opóźnienia - Event->m_launchtime += Event->m_delayrandom * Random(); - } - if( Owner != nullptr - && false == std::isnan(Event->m_delaydeparture) ) { - auto const *timetableowner { ( - Owner->Mechanik != nullptr && Owner->Mechanik->primary() ? - Owner->Mechanik : - Owner->ctOwner ) }; - if( timetableowner != nullptr ) { - auto const &timetable { timetableowner->TrainTimetable() }; - auto const &time { simulation::Time.data() }; - Event->m_launchtime += - timetable.seconds_until_departure( time.wHour, time.wMinute + time.wSecond * 0.0167 ) - + Event->m_delaydeparture; - } - } - // NOTE: sanity check, as departure-based delay math can potentially produce negative overall delay - Event->m_launchtime = std::max( Event->m_launchtime, 0.0 ); - if( QueryRootEvent != nullptr ) { - basic_event *target { QueryRootEvent }; - basic_event *previous { nullptr }; - while( Event->m_launchtime >= target->m_launchtime - && target->m_next != nullptr ) { - previous = target; - target = target->m_next; - } - // the new event will be either before or after the one we located - if( Event->m_launchtime >= target->m_launchtime ) { - assert( target->m_next == nullptr ); - target->m_next = Event; - // if we have resurrected event land at the end of list, the link from previous run could potentially "add" unwanted events to the queue - Event->m_next = nullptr; - } - else { - if( previous != nullptr ) { - previous->m_next = Event; - Event->m_next = target; - } - else { - // special case, we're inserting our event at the very start - Event->m_next = QueryRootEvent; - QueryRootEvent = Event; - } - } - } - else { - QueryRootEvent = Event; - QueryRootEvent->m_next = nullptr; - } + } while( ( Event != nullptr ) + && ( Event->m_passive || pending( Event ) ) ); } + if( ( Event == nullptr ) || ( true == Event->m_ignored ) ) { return true; } + + WriteLog( "EVENT ADDED TO QUEUE" + ( Owner ? " by " + Owner->asName : "" ) + ": " + Event->m_name ); + + auto launchtime { delay + std::abs( Event->m_delay ) + Timer::GetTime() }; + if( Event->m_delayrandom > 0.0 ) { + launchtime += Event->m_delayrandom * Random(); + } + if( ( Owner != nullptr ) && ( false == std::isnan( Event->m_delaydeparture ) ) ) { + auto const *timetableowner { ( + Owner->Mechanik != nullptr && Owner->Mechanik->primary() ? + Owner->Mechanik : + Owner->ctOwner ) }; + if( timetableowner != nullptr ) { + auto const &timetable { timetableowner->TrainTimetable() }; + auto const &time { simulation::Time.data() }; + launchtime += + timetable.seconds_until_departure( time.wHour, time.wMinute + time.wSecond * 0.0167 ) + + Event->m_delaydeparture; + } + } + // departure-based delays can work out negative overall + launchtime = std::max( launchtime, 0.0 ); + + push( { launchtime, Event->m_index, Owner } ); return true; } -// legacy method, executes queued events +// orders the queue: soonest first, and among events due at the same instant the one that +// appears first in the scenery. ties broken by index rather than by insertion order, so +// the same scenario runs the same way twice +bool +event_manager::later( queued_event const &Left, queued_event const &Right ) { + + if( Left.launchtime != Right.launchtime ) { return Left.launchtime > Right.launchtime; } + return Left.index > Right.index; +} + +bool +event_manager::pending( basic_event const *Event ) const { + + return ( Event->m_index < m_queued.size() ) && ( m_queued[ Event->m_index ] != 0 ); +} + +void +event_manager::push( queued_event const &Entry ) { + + if( Entry.index >= m_queued.size() ) { m_queued.resize( Entry.index + 1, 0 ); } + m_queued[ Entry.index ] = 1; + m_queue.emplace_back( Entry ); + std::push_heap( std::begin( m_queue ), std::end( m_queue ), later ); +} + +std::uint32_t +event_manager::sibling_of( std::uint32_t const Index ) const { + + // every event knows its own position, so following the sibling chain costs a pointer + // hop rather than a search + auto const *event { event_at( Index ) }; + if( ( event == nullptr ) || ( event->m_sibling == nullptr ) ) { + return static_cast( m_events.size() ); + } + return event->m_sibling->m_index; +} + +std::vector +event_manager::queue_snapshot() const { + + auto ordered { m_queue }; + std::sort( std::begin( ordered ), std::end( ordered ), + []( queued_event const &Left, queued_event const &Right ) { return later( Right, Left ); } ); + return ordered; +} + bool event_manager::CheckQuery() { - while( QueryRootEvent != nullptr - && QueryRootEvent->m_launchtime < Timer::GetTime() ) - { // eventy są posortowana wg czasu wykonania - m_workevent = QueryRootEvent; // wyjęcie eventu z kolejki - if (QueryRootEvent->m_sibling) // jeśli jest kolejny o takiej samej nazwie - { // to teraz on będzie następny do wykonania - QueryRootEvent = QueryRootEvent->m_sibling; // następny będzie ten doczepiony - QueryRootEvent->m_next = m_workevent->m_next; // pamiętając o następnym z kolejki - QueryRootEvent->m_launchtime = m_workevent->m_launchtime; // czas musi być ten sam, bo nie jest aktualizowany - QueryRootEvent->m_activator = m_workevent->m_activator; // pojazd aktywujący - QueryRootEvent->m_inqueue = 1; - // w sumie można by go dodać normalnie do kolejki, ale trzeba te połączone posortować wg czasu wykonania + while( ( false == m_queue.empty() ) + && ( m_queue.front().launchtime < Timer::GetTime() ) ) { + + std::pop_heap( std::begin( m_queue ), std::end( m_queue ), later ); + auto const entry { m_queue.back() }; + m_queue.pop_back(); + m_queued[ entry.index ] = 0; + + m_workevent = event_at( entry.index ); + if( m_workevent == nullptr ) { continue; } + + // an event sharing this one's name follows it at the same instant. it goes + // through the queue like any other, rather than being spliced into the list head + auto const *sibling { m_workevent->m_sibling }; + if( ( sibling != nullptr ) && ( false == sibling->m_passive ) && ( false == pending( sibling ) ) ) { + push( { entry.launchtime, sibling->m_index, entry.activator } ); } - else // a jak nazwa jest unikalna, to kolejka idzie dalej - QueryRootEvent = QueryRootEvent->m_next; // NULL w skrajnym przypadku - if( false == m_workevent->m_ignored && false == m_workevent->m_passive ) { - // w zasadzie te wyłączone są skanowane i nie powinny się nigdy w kolejce znaleźć - --m_workevent->m_inqueue; // teraz moze być ponownie dodany do kolejki - m_workevent->run(); - } // if (tmpEvent->bEnabled) - } // while + + if( ( false == m_workevent->m_ignored ) && ( false == m_workevent->m_passive ) ) { + m_workevent->run( entry.activator ); + } + } return true; } // legacy method, initializes events after deserialization from scenario file void event_manager::InitEvents() { + + // how many events a scenery carries, which is worth knowing: they are not written by + // hand but expanded from parameterised includes, a dozen or more per signal + WriteLog( "Events: " + std::to_string( m_events.size() ) + " defined" ); + //łączenie eventów z pozostałymi obiektami for( auto *event : m_events ) { event->init(); diff --git a/world/Event.cppm b/world/Event.cppm index e4e02af0..fe81a933 100644 --- a/world/Event.cppm +++ b/world/Event.cppm @@ -79,10 +79,10 @@ public: virtual void init() = 0; - // executes event + // executes the event on behalf of the vehicle that triggered it, if any virtual void - run(); + run( TDynamicObject const *Activator ); // sends basic content of the class in legacy (text) format to provided stream virtual void @@ -110,15 +110,20 @@ public: void group( scene::group_handle Group ); scene::group_handle group() const; std::string const &name() const { return m_name; } + // what this event calls itself in a scenery, for anything that has to write it back out + std::string type_name() const { return type(); } + // the names this event was written to act on, in the order it was written with them + std::vector target_names() const; // members - basic_event *m_next { nullptr }; // następny w kolejce // TODO: replace with event list in the manager basic_event *m_sibling { nullptr }; // kolejny event z tą samą nazwą - od wersji 378 std::string m_name; bool m_ignored { false }; // replacement for tp_ignored bool m_passive { false }; // false gdy ma nie być dodawany do kolejki (skanowanie sygnałów) - int m_inqueue { 0 }; // ile razy dodany do kolejki - TDynamicObject const *m_activator { nullptr }; - double m_launchtime { 0.0 }; + // position in the manager's collection, assigned once when the event is inserted. + // it is the event's identity, not its state: the queue keys on it, so that two + // events due at the same instant run in an order that does not depend on the order + // they happened to be queued in + std::uint32_t m_index { 0 }; double m_delay { 0.0 }; double m_delayrandom { 0.0 }; // zakres dodatkowego opóźnienia // standardowo nie będzie dodatkowego losowego opóźnienia double m_delaydeparture { std::numeric_limits::quiet_NaN() }; // departure-based event delay @@ -168,7 +173,7 @@ private: // deserialize() subclass details virtual void deserialize_( cParser &Input, scene::scratch_data &Scratchpad ) = 0; // run() subclass details - virtual void run_() = 0; + virtual void run_( TDynamicObject const *Activator ) = 0; // export_as_text() subclass details virtual void export_as_text_( std::ostream &Output ) const = 0; // members @@ -220,7 +225,7 @@ private: // deserialize() subclass details void deserialize_( cParser &Input, scene::scratch_data &Scratchpad ) override; // run() subclass details - void run_() override; + void run_( TDynamicObject const *Activator ) override; // export_as_text() subclass details void export_as_text_( std::ostream &Output ) const override; // members @@ -243,7 +248,7 @@ private: // deserialize() subclass details void deserialize_( cParser &Input, scene::scratch_data &Scratchpad ) override; // run() subclass details - void run_() override; + void run_( TDynamicObject const *Activator ) override; // export_as_text() subclass details void export_as_text_( std::ostream &Output ) const override; }; @@ -273,7 +278,7 @@ private: // deserialize() subclass details void deserialize_( cParser &Input, scene::scratch_data &Scratchpad ) override; // run() subclass details - void run_() override; + void run_( TDynamicObject const *Activator ) override; // export_as_text() subclass details void export_as_text_( std::ostream &Output ) const override; }; @@ -299,7 +304,7 @@ private: // deserialize() subclass details void deserialize_( cParser &Input, scene::scratch_data &Scratchpad ) override; // run() subclass details - void run_() override; + void run_( TDynamicObject const *Activator ) override; // export_as_text() subclass details void export_as_text_( std::ostream &Output ) const override; //determines whether provided input should be passed to consist owner @@ -322,7 +327,7 @@ private: // deserialize() subclass details void deserialize_( cParser &Input, scene::scratch_data &Scratchpad ) override; // run() subclass details - void run_() override; + void run_( TDynamicObject const *Activator ) override; // export_as_text() subclass details void export_as_text_( std::ostream &Output ) const override; }; @@ -343,7 +348,7 @@ private: // deserialize() subclass details void deserialize_( cParser &Input, scene::scratch_data &Scratchpad ) override; // run() subclass details - void run_() override; + void run_( TDynamicObject const *Activator ) override; // export_as_text() subclass details void export_as_text_( std::ostream &Output ) const override; }; @@ -369,7 +374,7 @@ private: // deserialize() subclass details void deserialize_( cParser &Input, scene::scratch_data &Scratchpad ) override; // run() subclass details - void run_() override; + void run_( TDynamicObject const *Activator ) override; // export_as_text() subclass details void export_as_text_( std::ostream &Output ) const override; // members @@ -398,7 +403,7 @@ private: // deserialize() subclass details void deserialize_( cParser &Input, scene::scratch_data &Scratchpad ) override; // run() subclass details - void run_() override; + void run_( TDynamicObject const *Activator ) override; // export_as_text() subclass details void export_as_text_( std::ostream &Output ) const override; // members @@ -432,7 +437,7 @@ private: // deserialize() subclass details void deserialize_( cParser &Input, scene::scratch_data &Scratchpad ) override; // run() subclass details - void run_() override; + void run_( TDynamicObject const *Activator ) override; // export_as_text() subclass details void export_as_text_( std::ostream &Output ) const override; // members @@ -459,7 +464,7 @@ private: // deserialize() subclass details void deserialize_( cParser &Input, scene::scratch_data &Scratchpad ) override; // run() subclass details - void run_() override; + void run_( TDynamicObject const *Activator ) override; // export_as_text() subclass details void export_as_text_( std::ostream &Output ) const override; // members @@ -488,7 +493,7 @@ private: // deserialize() subclass details void deserialize_( cParser &Input, scene::scratch_data &Scratchpad ) override; // run() subclass details - void run_() override; + void run_( TDynamicObject const *Activator ) override; // export_as_text() subclass details void export_as_text_( std::ostream &Output ) const override; // members @@ -511,7 +516,7 @@ private: // deserialize() subclass details void deserialize_( cParser &Input, scene::scratch_data &Scratchpad ) override; // run() subclass details - void run_() override; + void run_( TDynamicObject const *Activator ) override; // export_as_text() subclass details void export_as_text_( std::ostream &Output ) const override; // members @@ -536,7 +541,7 @@ private: // deserialize() subclass details void deserialize_( cParser &Input, scene::scratch_data &Scratchpad ) override; // run() subclass details - void run_() override; + void run_( TDynamicObject const *Activator ) override; // export_as_text() subclass details void export_as_text_( std::ostream &Output ) const override; // members @@ -559,7 +564,7 @@ private: // deserialize() subclass details void deserialize_( cParser &Input, scene::scratch_data &Scratchpad ) override; // run() subclass details - void run_() override; + void run_( TDynamicObject const *Activator ) override; // export_as_text() subclass details void export_as_text_( std::ostream &Output ) const override; // members @@ -582,7 +587,7 @@ private: // deserialize() subclass details void deserialize_( cParser &Input, scene::scratch_data &Scratchpad ) override; // run() subclass details - void run_() override; + void run_( TDynamicObject const *Activator ) override; // export_as_text() subclass details void export_as_text_( std::ostream &Output ) const override; // members @@ -605,7 +610,7 @@ private: // deserialize() subclass details void deserialize_( cParser &Input, scene::scratch_data &Scratchpad ) override; // run() subclass details - void run_() override; + void run_( TDynamicObject const *Activator ) override; // export_as_text() subclass details void export_as_text_( std::ostream &Output ) const override; // members @@ -622,7 +627,7 @@ public: private: std::string type() const override; void deserialize_( cParser &Input, scene::scratch_data &Scratchpad ) override; - void run_() override; + void run_( TDynamicObject const *Activator ) override; void export_as_text_( std::ostream &Output ) const override; bool is_instant() const override; @@ -645,7 +650,7 @@ private: // deserialize() subclass details void deserialize_( cParser &Input, scene::scratch_data &Scratchpad ) override; // run() subclass details - void run_() override; + void run_( TDynamicObject const *Activator ) override; // export_as_text() subclass details void export_as_text_( std::ostream &Output ) const override; // members @@ -687,11 +692,39 @@ public: inline void purge (TEventLauncher *Launcher) { m_radiodrivenlaunchers.purge(Launcher); m_inputdrivenlaunchers.purge(Launcher); } - // returns first event in the queue - inline + // one entry of the pending queue. the event itself holds none of this: when it runs + // and what asked for it are properties of this request, not of the event + struct queued_event { + double launchtime { 0.0 }; + std::uint32_t index { 0 }; + TDynamicObject const *activator { nullptr }; + }; + + // the queue in the order it will run, for anything that wants to look at it. + // returned by value: this is for display and debugging, not for the hot path + std::vector + queue_snapshot() const; + // the event an entry refers to, or null if the index is stale basic_event * - begin() { - return QueryRootEvent; } + event_at( std::uint32_t const Index ) const { + return Index < m_events.size() ? m_events[ Index ] : nullptr; } + bool + queue_empty() const { return m_queue.empty(); } + // how many events the manager holds. with event_at() this is the whole read-only view + // of the collection, which is all a cooker needs + std::uint32_t + event_count() const { return static_cast( m_events.size() ); } + // the index of an event's next sibling - the next event registered under the same name + // - or an out of range value when it has none + std::uint32_t + sibling_of( std::uint32_t const Index ) const; + +private: + static bool later( queued_event const &Left, queued_event const &Right ); + bool pending( basic_event const *Event ) const; + void push( queued_event const &Entry ); + +public: basic_event* FindEventById(uint32_t id); @@ -735,8 +768,14 @@ private: // NOTE: disabled until event class refactoring event_sequence m_eventqueue; */ - // legacy version of the above - basic_event *QueryRootEvent { nullptr }; + // pending events, kept as a binary heap ordered by launch time and then by event + // index. a sorted linked list threaded through the events themselves cost a linear + // walk on every insertion, put queue state inside the event definition, and left the + // order of events due at the same instant depending on insertion history + std::vector m_queue; + // whether an event is already pending, indexed by event index. guards against an + // event being queued twice over + std::vector m_queued; basic_event *m_workevent { nullptr }; event_map m_eventmap; basic_table m_inputdrivenlaunchers;