v0.8.0: map roads/sidewalks, per-vertex depth bias, retail camera controls

- ra3.map: capture the object RoadType; ra3.models pairs the map's
  Start/End road objects and drapes a flat textured ribbon along each
  segment (tessellated to the relief).
- Ground decals (roads/sidewalks) get a per-vertex screen-space depth
  offset (scene::vertex.bias) applied in the object shader and the
  software rasteriser, so they no longer z-fight or clip into the
  terrain at top-down angles.
- Tactical view aligned with retail (SAGE LookAtTranslator): middle-drag
  orbits yaw/pitch, a middle click resets angle/pitch/zoom, arrow keys
  pan, wheel zooms within the retail zoom range; WASD pan removed and
  left-drag no longer rotates.
- ui_event gains middle/right/released buttons; Vulkan + SDL map them.
- docs: roads, depth bias and camera controls.
This commit is contained in:
EnderTheCoder
2026-09-29 20:37:24 +08:00
parent 70f35d8382
commit 8047e80ed6
14 changed files with 229 additions and 33 deletions
+121
View File
@@ -753,6 +753,7 @@ export namespace ra3::models {
float u = 0.0F;
float v = 0.0F;
float layer = 0.0F;
float bias = 0.0F; ///< Depth offset toward the camera (world units) for ground decals.
};
/** Every model the map draws, flattened to world space and ready to upload. */
@@ -763,6 +764,7 @@ export namespace ra3::models {
uint32 texture_size = 0;
usize placed = 0;
usize missing = 0;
usize roads = 0;
[[nodiscard]] auto empty() const -> bool { return indices.empty(); }
[[nodiscard]] auto triangle_count() const -> usize { return indices.size() / 3U; }
@@ -776,6 +778,7 @@ export namespace ra3::models {
float z = 0.0F;
float angle = 0.0F;
float scale = 1.0F;
uint32 road_type = 0U; ///< SAGE `RoadType` flags (0 = a normal object).
};
namespace detail {
@@ -856,7 +859,125 @@ export namespace ra3::models {
return it->second.empty() ? nullptr : &it->second;
};
// Road/sidewalk decals: the map stores each segment as a consecutive
// start/end pair of objects. They are flat ribbons laid on the terrain
// (the GPU/software pass biases their depth so they do not clip).
std::unordered_map<std::string, uint32> road_layers;
const auto road_layer_for = [&](const std::string &type) -> uint32 {
if (const auto it = road_layers.find(type); it != road_layers.end()) return it->second;
uint32 layer = 0xFFFFFFFFU;
if (const auto *road = stream.find_name("Road:" + type); road != nullptr && !road->references.empty()) {
const auto [type_id, instance_id] = road->references.front();
if (const auto *texture = stream.find(type_id, instance_id); texture != nullptr) layer = layer_for(texture);
}
road_layers.emplace(type, layer);
return layer;
};
const auto road_width = [&](const std::string &type) -> float {
if (const auto *road = stream.find_name("Road:" + type); road != nullptr) {
const auto instance = stream.read_instance(*road);
if (instance.size() >= 16U) return detail::f32(instance, 12U);
}
return 5.0F;
};
const auto emit_road = [&](const placement &a, const placement &b) {
const auto dx = b.x - a.x;
const auto dy = b.y - a.y;
const auto length = std::sqrt(dx * dx + dy * dy);
if (length < 1.0e-3F) return;
const auto dir_x = dx / length;
const auto dir_y = dy / length;
const auto perp_x = -dir_y;
const auto perp_y = dir_x;
const auto half = 0.5F * road_width(a.type);
const auto tile = std::max(1.0F, 2.0F * half);
const auto extent = 0.0F; // no overlap: overlapping ribbons z-fight
const auto total = length + 2.0F * extent;
const auto start_x = a.x - dir_x * extent;
const auto start_y = a.y - dir_y * extent;
const auto height = [&](float x, float y) {
// Take the highest terrain sample in the ribbon's footprint so it
// never sinks into a cell step; the object pass adds the small
// depth bias that retail uses for coplanar ground decals.
const auto radius = std::max(2.0F, half);
const auto sample = [&](float px, float py) { return ground_height ? ground_height(px, py) : 0.0F; };
auto best = sample(x, y);
best = std::max(best, sample(x + radius, y));
best = std::max(best, sample(x - radius, y));
best = std::max(best, sample(x, y + radius));
best = std::max(best, sample(x, y - radius));
return best + 1.0F;
};
const auto layer = road_layer_for(a.type);
const auto layer_value = layer == 0xFFFFFFFFU ? 0.0F : static_cast<float>(layer);
// Drape the ribbon on the terrain: the segment is tessellated along
// its length (one sample per terrain cell) so it follows the relief
// instead of cutting through hills. The remaining coplanar z-fight is
// handled by the object pass's depth bias.
constexpr float sample_step = 10.0F;
const auto steps = std::clamp(static_cast<int>(std::ceil(total / sample_step)), 1, 512);
const auto vspan = total / tile;
uint32 previous_left = 0U;
uint32 previous_right = 0U;
for (int k = 0; k <= steps; ++k) {
const auto t = static_cast<float>(k) / static_cast<float>(steps);
const auto cx = start_x + dir_x * total * t;
const auto cy = start_y + dir_y * total * t;
const auto lx = cx + perp_x * half;
const auto ly = cy + perp_y * half;
const auto rx = cx - perp_x * half;
const auto ry = cy - perp_y * half;
const auto left_index = static_cast<uint32>(out.vertices.size());
vertex left;
left.x = lx;
left.y = ly;
left.z = height(lx, ly);
left.nx = 0.0F;
left.ny = 0.0F;
left.nz = 1.0F;
left.u = 0.0F;
left.v = vspan * t;
left.layer = layer_value;
left.bias = 2.0F;
out.vertices.push_back(left);
vertex right;
right.x = rx;
right.y = ry;
right.z = height(rx, ry);
right.nx = 0.0F;
right.ny = 0.0F;
right.nz = 1.0F;
right.u = 1.0F;
right.v = vspan * t;
right.layer = layer_value;
right.bias = 2.0F;
out.vertices.push_back(right);
const auto right_index = left_index + 1U;
if (k > 0) {
out.indices.push_back(previous_left);
out.indices.push_back(previous_right);
out.indices.push_back(right_index);
out.indices.push_back(previous_left);
out.indices.push_back(right_index);
out.indices.push_back(left_index);
}
previous_left = left_index;
previous_right = right_index;
}
++out.roads;
};
for (usize i = 0; i + 1U < placements.size(); ++i) {
const auto &a = placements[i];
if ((a.road_type & 2U) == 0U) continue; // RoadType::Start
const auto &b = placements[i + 1U];
if ((b.road_type & 4U) == 0U || b.type != a.type) continue; // RoadType::End
emit_road(a, b);
++i;
}
for (const auto &item: placements) {
if (item.road_type != 0U) continue; // handled as a road decal above
const auto meshes = stream.meshes_for(item.type);
if (meshes.empty()) {
++out.missing;