17
0
mirror of https://github.com/MaSzyna-EU07/maszyna.git synced 2026-10-17 02:49:19 +02:00

Heightmap terrain: .tch chunk packs, streaming with LOD, terrain painting, water and modifiers - terrain/<name>/terrain.yaml + <px>_<pz>.tch packs of 16x16 chunks (250 m, 0.5/1/2/5 m spacing), slices with crc-32 and optional zstd (vendored zstd 1.5.6 in ref/zstd) - heightmap_terrain [name] endheightmap_terrain directive replaces editorterrain - streamer: worker threads, camera + look-ahead loading, geomipmap LOD with skirts, pooled geometry - mat_terrain shader: up to 8 painted layer materials per chunk via texture arrays (opengl33); other renderers fall back to the dominant material - water bodies (outline + level), terrain modifiers (vertical profile shaping kept non-destructive) - F11 Terrain work mode rebuilt: chunks, sculpt, smooth, paint, point spacing, water tools

This commit is contained in:
2026-10-09 21:02:25 +02:00
parent bbccce0e29
commit 35bda3778c
26 changed files with 52888 additions and 1624 deletions

View File

@@ -488,44 +488,53 @@ std::pair<int, int> editor_mode::profile_generate_ground(std::vector<std::pair<d
auto &state{m_profile};
auto &works{state.earthworks};
auto const &samples{state.samples};
bool const streaming{m_streamer.active()};
double const size{streaming ? m_streamer.cells() * m_streamer.cellsize() : chunk_grid_size()};
int const cells{streaming ? m_streamer.cells() : std::clamp(m_terrain_cells, 1, 256)};
if (size <= 0.0 || cells <= 0 || samples.empty())
if (samples.empty() || false == ensure_terrain())
return {0, 0};
double const cellsize{size / cells};
std::set<std::pair<int, int>> keys;
auto const size{heightmap::chunk_size};
std::set<heightmap::chunk_key> keys;
for (auto const &sample : samples)
{
auto const &p{sample.position};
for (auto x = static_cast<int>(std::floor((p.x - Reach) / size)); x <= static_cast<int>(std::floor((p.x + Reach) / size)); ++x)
for (auto z = static_cast<int>(std::floor((p.z - Reach) / size)); z <= static_cast<int>(std::floor((p.z + Reach) / size)); ++z)
auto const low{heightmap::chunk_at(p.x - Reach, p.z - Reach)};
auto const high{heightmap::chunk_at(p.x + Reach, p.z + Reach)};
for (auto x = low.first; x <= high.first; ++x)
for (auto z = low.second; z <= high.second; ++z)
keys.insert({x, z});
}
auto const terrains{active_terrains()};
std::string const texture{m_terrain_texture};
auto const cells{heightmap::cells_for(m_terrain_spacing)};
auto const cellsize{size / cells};
auto const side{static_cast<std::size_t>(cells) + 1};
int made{0}, skipped{0};
std::size_t const side{static_cast<std::size_t>(cells) + 1};
for (auto const &key : keys)
{
glm::dvec2 const low{key.first * size, key.second * size};
glm::dvec2 const middle{low + glm::dvec2{size * 0.5}};
if (streaming ? m_streamer.terrain_at(middle.x, middle.y) != nullptr : m_grid_chunks.count(key) > 0)
if (m_streamer.exists(key))
continue;
auto const low{heightmap::chunk_corner(key)};
glm::dvec2 const middle{low + glm::dvec2{size * 0.5}};
std::vector<std::size_t> around;
for (std::size_t i = 0; i < samples.size(); ++i)
if (std::abs(samples[i].position.x - middle.x) <= size * 0.5 + Reach && std::abs(samples[i].position.z - middle.y) <= size * 0.5 + Reach)
around.push_back(i);
if (around.empty())
continue;
std::vector<glm::dvec3> points;
std::vector<std::size_t> nearest;
points.reserve(side * side);
nearest.reserve(side * side);
// where the scenery has its own ground, the chunk isn't made: the two would cover each other
std::vector<glm::dvec3> probes;
for (int iz = 1; iz < 10; ++iz)
for (int ix = 1; ix < 10; ++ix)
probes.emplace_back(low.x + ix * size / 10.0, profile::elevation(state.line, samples[around.front()].chainage), low.y + iz * size / 10.0);
std::vector<char> found;
ground_heights(probes, true, &found);
if (std::count(found.begin(), found.end(), 1) * 20 > static_cast<long>(probes.size()))
{
++skipped;
continue;
}
// the ground of the chunk: the ground under the nearest point of the route, or the formation there
std::vector<float> heights(side * side);
for (std::size_t iz = 0; iz < side; ++iz)
for (std::size_t ix = 0; ix < side; ++ix)
{
glm::dvec3 point{low.x + ix * cellsize, 0.0, low.y + iz * cellsize};
glm::dvec3 const point{low.x + ix * cellsize, 0.0, low.y + iz * cellsize};
auto best{around.front()};
auto distance{std::numeric_limits<double>::max()};
for (auto const i : around)
@@ -537,56 +546,16 @@ std::pair<int, int> editor_mode::profile_generate_ground(std::vector<std::pair<d
best = i;
}
}
point.y = profile::elevation(state.line, samples[best].chainage);
points.push_back(point);
nearest.push_back(best);
heights[iz * side + ix] = static_cast<float>(best < state.terrain.size() && std::isfinite(state.terrain[best]) ? state.terrain[best] : profile::elevation(state.line, samples[best].chainage) - Beds[best].first);
}
std::vector<char> found;
auto heights{ground_heights(points, true, &found)};
std::size_t own{0};
for (std::size_t i = 0; i < points.size(); ++i)
if (found[i] != 0 && std::none_of(terrains.begin(), terrains.end(), [&](editor_terrain const *Terrain) { return Terrain->contains(points[i].x, points[i].z); }))
++own;
if (own * 20 > points.size())
{
++skipped;
continue;
}
for (std::size_t i = 0; i < points.size(); ++i)
{
if (found[i] != 0)
continue;
auto const sample{nearest[i]};
heights[i] = sample < state.terrain.size() && std::isfinite(state.terrain[sample]) ? state.terrain[sample] : profile::elevation(state.line, samples[sample].chainage) - Beds[sample].first;
}
auto const sampler = [&](double const X, double const Z, double &Height) {
auto const added{m_streamer.add_chunk(key.first, key.second, m_terrain_spacing, [&](double const X, double const Z) {
auto const ix{static_cast<std::size_t>(std::clamp<long>(std::lround((X - low.x) / cellsize), 0, cells))};
auto const iz{static_cast<std::size_t>(std::clamp<long>(std::lround((Z - low.y) / cellsize), 0, cells))};
Height = heights[iz * side + ix];
return true;
};
if (streaming)
{
m_streamer.add_chunk(key.first, key.second);
auto *terrain{m_streamer.terrain_at(middle.x, middle.y)};
if (terrain == nullptr)
continue;
terrain->reshape([&](double const X, double const Z, float &Height) {
double height;
sampler(X, Z, height);
Height = static_cast<float>(height);
return true;
});
}
else
{
auto chunk{std::make_unique<editor_terrain>()};
if (false == chunk->create(glm::dvec3{middle.x, m_terrain_baseheight, middle.y}, cells, static_cast<float>(cellsize), texture, sampler))
continue;
m_grid_chunks[key] = std::move(chunk);
}
return heights[iz * side + ix];
})};
if (false == added)
continue;
works.generated.push_back(key);
works.generated_streaming = streaming;
++made;
}
return {made, skipped};
@@ -599,6 +568,11 @@ void editor_mode::profile_shape_ground()
auto const &samples{state.samples};
if (samples.size() < 2 || state.line.points.size() < 2)
return;
if (false == ensure_terrain())
{
state.error = STR_C("The heightmap terrain can't be opened");
return;
}
auto const slope{std::max(0.1, works.slope)};
std::vector<std::pair<double, double>> beds(samples.size(), {works.depth, works.half_width});
if (works.from_ballast)
@@ -610,137 +584,91 @@ void editor_mode::profile_shape_ground()
for (auto const &bed : beds)
widest = std::max(widest, bed.second);
auto const reach{widest + works.reach};
works.undo.clear();
works.generated.clear();
auto const generated{works.generate ? profile_generate_ground(beds, reach) : std::pair<int, int>{0, 0}};
auto const terrains{active_terrains()};
if (terrains.empty())
{
state.error = generated.second > 0 ? STR_C("The scenery has its own ground under the route: convert it to editor terrain first") : STR_C("There's no editor terrain to shape: convert the scenery ground first");
return;
}
// the pieces of the axis of the route, found by the cell of a grid over the plan
double constexpr cell{16.0};
auto const key = [&](double const X, double const Z) { return std::make_pair(static_cast<int>(std::floor(X / cell)), static_cast<int>(std::floor(Z / cell))); };
std::map<std::pair<int, int>, std::vector<std::size_t>> grid;
glm::dvec2 low{std::numeric_limits<double>::max()}, high{-std::numeric_limits<double>::max()};
for (std::size_t i = 0; i + 1 < samples.size(); ++i)
// the shaping is a modifier of the terrain: the formation along the axis of the route, followed by the ground
// around it. shaped again, the route replaces its modifier, and the terrain follows the new grade line
heightmap::modifier modifier;
auto const name = [](TTrack const *Track) { return Track != nullptr && false == Track->name().empty() ? Track->name() : std::string{"?"}; };
modifier.name = "route " + name(state.from) + " - " + name(state.to);
modifier.slope = slope;
modifier.reach = works.reach;
modifier.rounding = std::max(0.0, works.rounding);
std::vector<heightmap::corridor_point> points;
points.reserve(samples.size());
for (std::size_t i = 0; i < samples.size(); ++i)
points.push_back({{samples[i].position.x, samples[i].position.z}, profile::elevation(state.line, samples[i].chainage) - beds[i].first, beds[i].second});
// the points the line between their neighbours passes within a few millimetres of are left out
double constexpr tolerance{0.005};
modifier.points.push_back(points.front());
std::size_t anchor{0};
for (std::size_t next = 2; next < points.size(); ++next)
{
auto const &a{samples[i].position};
auto const &b{samples[i + 1].position};
auto const from{key(std::min(a.x, b.x), std::min(a.z, b.z))};
auto const to{key(std::max(a.x, b.x), std::max(a.z, b.z))};
for (auto x = from.first; x <= to.first; ++x)
for (auto z = from.second; z <= to.second; ++z)
grid[{x, z}].push_back(i);
low = glm::min(low, glm::dvec2{std::min(a.x, b.x), std::min(a.z, b.z)});
high = glm::max(high, glm::dvec2{std::max(a.x, b.x), std::max(a.z, b.z)});
auto const &a{points[anchor]};
auto const &b{points[next]};
auto const run{b.position - a.position};
auto const length{glm::length(run)};
bool straight{true};
for (auto i = anchor + 1; i < next && straight; ++i)
{
auto const t{length > 1e-9 ? glm::dot(points[i].position - a.position, run) / (length * length) : 0.0};
auto const offset{glm::length(points[i].position - (a.position + run * t))};
auto const formation{a.formation + (b.formation - a.formation) * t};
auto const width{a.half_width + (b.half_width - a.half_width) * t};
straight = offset <= tolerance && std::abs(points[i].formation - formation) <= tolerance && std::abs(points[i].half_width - width) <= tolerance;
}
if (false == straight)
{
anchor = next - 1;
modifier.points.push_back(points[anchor]);
}
}
modifier.points.push_back(points.back());
std::size_t moved{0};
double fill{0.0}, cut{0.0};
for (auto *terrain : terrains)
for (std::size_t i = 0; i < samples.size(); ++i)
{
auto const before{terrain->heights()};
auto const shaped{terrain->reshape([&](double const X, double const Z, float &Height) {
if (X < low.x - reach || X > high.x + reach || Z < low.y - reach || Z > high.y + reach)
return false;
double distance{reach};
double chainage{-1.0};
std::size_t nearest{0};
auto const from{key(X - reach, Z - reach)};
auto const to{key(X + reach, Z + reach)};
for (auto x = from.first; x <= to.first; ++x)
for (auto z = from.second; z <= to.second; ++z)
{
auto const found{grid.find({x, z})};
if (found == grid.end())
continue;
for (auto const i : found->second)
{
glm::dvec2 const a{samples[i].position.x, samples[i].position.z};
glm::dvec2 const b{samples[i + 1].position.x, samples[i + 1].position.z};
auto const run{b - a};
auto const length{glm::dot(run, run)};
auto const t{length > 1e-12 ? std::clamp(glm::dot(glm::dvec2{X, Z} - a, run) / length, 0.0, 1.0) : 0.0};
auto const offset{glm::length(glm::dvec2{X, Z} - (a + run * t))};
if (offset < distance)
{
distance = offset;
chainage = samples[i].chainage + (samples[i + 1].chainage - samples[i].chainage) * t;
nearest = t < 0.5 ? i : i + 1;
}
}
}
if (chainage < 0.0)
return false;
auto const &bed{beds[nearest]};
auto const formation{profile::elevation(state.line, chainage) - bed.first};
auto const ground{static_cast<double>(Height)};
// the bends at the edge of the formation and at the foot of the slope are rounded off over the given length
auto const rounding{std::max(0.0, works.rounding)};
auto const soft_max = [](double const A, double const B, double const K) {
if (K <= 1e-6)
return std::max(A, B);
auto const h{std::max(K - std::abs(A - B), 0.0) / K};
return std::max(A, B) + h * h * K * 0.25;
};
auto const beyond{soft_max(0.0, distance - bed.second, rounding)};
auto const bend{rounding / slope};
auto const target{ground > formation ? -soft_max(-ground, -(formation + beyond / slope), bend) : soft_max(ground, formation - beyond / slope, bend)};
if (std::abs(target - ground) < 0.005)
return false;
fill = std::max(fill, target - ground);
cut = std::max(cut, ground - target);
Height = static_cast<float>(target);
++moved;
return true;
})};
if (shaped)
works.undo.emplace_back(terrain, before);
}
auto const chunks = [&](std::string Text) {
if (generated.first > 0)
Text += format(STR_C("; %d chunk(s) of terrain made under the route"), generated.first);
if (generated.second > 0)
Text += format(STR_C("; %d chunk(s) left out: the scenery has its own ground there, convert it to shape it"), generated.second);
return Text;
};
if (moved == 0)
{
state.status = chunks(STR_C("The editor terrain doesn't reach the route, or it's shaped already"));
return;
if (i >= state.terrain.size() || false == std::isfinite(state.terrain[i]))
continue;
fill = std::max(fill, points[i].formation - state.terrain[i]);
cut = std::max(cut, state.terrain[i] - points[i].formation);
}
if (false == works.modifier.empty() && works.modifier != modifier.name)
m_streamer.remove_modifier(works.modifier);
works.modifier = modifier.name;
auto const count{modifier.points.size()};
m_streamer.modifier(std::move(modifier));
forget_ground();
auto status{format(STR_C("Terrain shaped by the modifier \"%s\" (%zu points): embankment up to %.2f m, cutting up to %.2f m"), works.modifier.c_str(), count, fill, cut)};
if (generated.first > 0)
status += format(STR_C("; %d chunk(s) of terrain made under the route"), generated.first);
if (generated.second > 0)
status += format(STR_C("; %d chunk(s) left out: the scenery has its own ground there, convert it to shape it"), generated.second);
state.error.clear();
profile_resample();
state.status = chunks(format(STR_C("Terrain shaped at %zu points: embankment up to %.2f m, cutting up to %.2f m"), moved, fill, cut));
state.status = status;
WriteLog("Editor: vertical profile - " + state.status, logtype::generic);
}
void editor_mode::profile_restore_ground()
{
auto &state{m_profile};
auto const terrains{active_terrains()};
auto &works{state.earthworks};
std::size_t restored{0};
for (auto const &entry : state.earthworks.undo)
if (std::find(terrains.begin(), terrains.end(), entry.first) != terrains.end())
{
entry.first->restore(entry.second);
++restored;
}
state.earthworks.undo.clear();
for (auto const &key : state.earthworks.generated)
if (false == works.modifier.empty() && m_streamer.remove_modifier(works.modifier))
++restored;
works.modifier.clear();
for (auto const &key : works.generated)
{
if (state.earthworks.generated_streaming)
m_streamer.remove_chunk(key.first, key.second);
else
remove_grid_chunk(key.first, key.second);
m_streamer.remove_chunk(key.first, key.second);
++restored;
}
state.earthworks.generated.clear();
works.generated.clear();
forget_ground();
profile_resample();
state.status = restored > 0 ? STR_C("The terrain is back as it was before the shaping") : STR_C("The shaped terrain isn't loaded any more");
state.status = restored > 0 ? STR_C("The terrain is back as it was before the shaping") : STR_C("The shaping of the terrain is gone already");
}
void editor_mode::render_ground_conversion()
@@ -748,7 +676,7 @@ void editor_mode::render_ground_conversion()
auto &conversion{m_ground_conversion};
if (false == conversion.open)
{
if (ImGui::Button(STR_C("Convert the scenery ground to editor terrain...")))
if (ImGui::Button(STR_C("Convert the scenery ground to heightmap terrain...")))
{
conversion.open = true;
conversion.materials = ground_materials();
@@ -759,7 +687,7 @@ void editor_mode::render_ground_conversion()
conversion.chosen.insert(most->first);
}
if (ImGui::IsItemHovered())
ImGui::SetTooltip("%s", STR_C("Replaces the triangles of the chosen materials with editor terrain of the same shape,\nwhich can be shaped and sculpted; the scenery files lose these triangles on save"));
ImGui::SetTooltip("%s", STR_C("Replaces the triangles of the chosen materials with heightmap terrain of the same shape,\nwhich can be shaped, sculpted and painted; the scenery files lose these triangles on save"));
if (false == conversion.status.empty())
ImGui::TextWrapped("%s", conversion.status.c_str());
return;
@@ -782,7 +710,7 @@ void editor_mode::render_ground_conversion()
}
ImGui::PopID();
}
ImGui::TextWrapped("%s", STR_C("The terrain gets a single texture: that of the material which covers the most. Until saved, loading the scenery again takes it all back."));
ImGui::TextWrapped("%s", STR_C("The chunks are covered with the material which covers the most, added to the palette of the terrain. Until saved, loading the scenery again takes it all back."));
if (conversion.chosen.empty())
ImGui::TextDisabled("%s", STR_C("Tick at least one material"));
else if (ImGui::Button(STR_C("Convert")))
@@ -802,13 +730,13 @@ void editor_mode::render_profile_earthworks()
auto &works{state.earthworks};
if (false == ImGui::TreeNode(STR_C("Shape the terrain to the grade line")))
return;
ImGui::TextWrapped("%s", STR_C("Only the editor terrain changes: a formation under the track and slopes to the ground; the triangles and models of the scenery stay as they are."));
ImGui::TextWrapped("%s", STR_C("Only the heightmap terrain changes: a formation under the track and slopes to the ground, kept as a modifier of the terrain which follows the grade line when it's shaped again; the triangles and models of the scenery stay as they are."));
render_ground_conversion();
auto const drag = [](char const *Label, double &Value, float const Speed, char const *Format) { return ImGui::DragScalar(STR_C(Label), ImGuiDataType_Double, &Value, Speed, nullptr, nullptr, Format); };
ImGui::PushItemWidth(90.0f);
ImGui::Checkbox(STR_C("Make terrain where the route has none"), &works.generate);
if (ImGui::IsItemHovered())
ImGui::SetTooltip("%s", STR_C("Chunks of editor terrain are made along the route where there's none, from the ground around,\nthen shaped. Where the scenery has its own ground triangles the chunk is left out: convert them instead"));
ImGui::SetTooltip("%s", STR_C("Chunks of heightmap terrain are made along the route where there's none, from the ground around,\nthen shaped. Where the scenery has its own ground triangles the chunk is left out: convert them instead"));
ImGui::Checkbox(STR_C("Formation under the ballast of the track"), &works.from_ballast);
if (ImGui::IsItemHovered())
ImGui::SetTooltip("%s", STR_C("The terrain goes as deep under the track as its ballast reaches and as wide as its foot,\nread from each path of the route; off: the values given below"));
@@ -842,15 +770,15 @@ void editor_mode::render_profile_earthworks()
ImGui::TextDisabled("%s", STR_C("Correct the errors of the grade line first"));
else if (ImGui::Button(STR_C("Shape the terrain")))
profile_shape_ground();
if (false == works.undo.empty() || false == works.generated.empty())
if (false == works.modifier.empty() || false == works.generated.empty())
{
ImGui::SameLine();
if (ImGui::Button(STR_C("Restore the terrain")))
profile_restore_ground();
if (ImGui::IsItemHovered())
ImGui::SetTooltip("%s", STR_C("Puts back the terrain as it was before the last shaping, and takes away the terrain it made"));
ImGui::SetTooltip("%s", STR_C("Removes the modifier of the route, and takes away the terrain the shaping made"));
}
if (false == active_terrains().empty())
if (m_streamer.active())
{
// the terrain tools take the tool options window over, the profile stays open under them
if (ImGui::Button(STR_C("Sculpting...")))