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:
@@ -370,6 +370,8 @@ export namespace ra3::client {
|
||||
|
||||
bool running = true;
|
||||
bool presented = false;
|
||||
const auto default_camera = camera;
|
||||
bool middle_dragged = false;
|
||||
while (running) {
|
||||
ui_event event;
|
||||
float drag_x = 0.0F;
|
||||
@@ -388,13 +390,23 @@ export namespace ra3::client {
|
||||
} else if (event.type == ui_event_type::mouse_move) {
|
||||
mouse_x = event.x;
|
||||
mouse_y = event.y;
|
||||
if (event.left) {
|
||||
// Retail RA3: the middle button orbits the camera; the
|
||||
// left button is selection only, not camera rotation.
|
||||
if (event.middle) {
|
||||
drag_x += event.dx;
|
||||
drag_y += event.dy;
|
||||
if (event.dx != 0.0F || event.dy != 0.0F) middle_dragged = true;
|
||||
}
|
||||
} else if (event.type == ui_event_type::wheel) {
|
||||
camera.height = std::clamp(camera.height * (event.wheel > 0.0F ? (1.0F / 1.15F) : 1.15F), camera.min_height, camera.max_height);
|
||||
camera_moved = true;
|
||||
} else if (event.type == ui_event_type::mouse_button && event.middle && event.released) {
|
||||
if (!middle_dragged) {
|
||||
camera.yaw = default_camera.yaw;
|
||||
camera.pitch = default_camera.pitch;
|
||||
camera.height = default_camera.height;
|
||||
camera_moved = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!running) break;
|
||||
@@ -404,8 +416,10 @@ export namespace ra3::client {
|
||||
last = now;
|
||||
|
||||
if (drag_x != 0.0F || drag_y != 0.0F) {
|
||||
camera.yaw -= drag_x * 0.005F;
|
||||
camera.pitch = std::clamp(camera.pitch + drag_y * 0.004F, 0.15F, 1.45F);
|
||||
// SAGE LookAtTranslator: middle-drag rotates yaw (X) and
|
||||
// pitch (Y) at 0.01 rad per pixel; pitch is clamped to +-36 deg.
|
||||
camera.yaw -= drag_x * 0.01F;
|
||||
camera.pitch = std::clamp(camera.pitch + drag_y * 0.01F, 0.15F, 1.45F);
|
||||
camera_moved = true;
|
||||
}
|
||||
|
||||
|
||||
@@ -218,6 +218,7 @@ export namespace ra3::map {
|
||||
real z = 0.0F;
|
||||
real angle = 0.0F;
|
||||
real scale = 1.0F;
|
||||
uint32 road_type = 0U; ///< SAGE `RoadType` flags; 0 for a non-road object.
|
||||
};
|
||||
|
||||
namespace detail {
|
||||
@@ -303,6 +304,7 @@ export namespace ra3::map {
|
||||
object.y = read_f32(p + 4U);
|
||||
object.z = read_f32(p + 8U);
|
||||
object.angle = read_f32(p + 12U);
|
||||
object.road_type = read_u32(p + 16U);
|
||||
p += 20U; // Coord3D + angle + road type
|
||||
const auto name_len = read_u16(p);
|
||||
p += 2U;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -277,6 +277,9 @@ export namespace ra3::render {
|
||||
float dy = 0.0F;
|
||||
float wheel = 0.0F;
|
||||
bool left = false;
|
||||
bool middle = false;
|
||||
bool right = false;
|
||||
bool released = false; ///< mouse_button: true for a button-up event.
|
||||
};
|
||||
|
||||
/** A warm red/gold loading screen with a progress bar (0..1). */
|
||||
@@ -675,7 +678,9 @@ export namespace ra3::render {
|
||||
float pitch = 1.02F; ///< Radians above the horizon (~58 degrees down).
|
||||
float height = 420.0F; ///< Camera height above the target's ground.
|
||||
float fov = 0.85F; ///< Vertical field of view, radians.
|
||||
float min_height = 120.0F;
|
||||
float max_height = 1600.0F;
|
||||
// Zoom range mirrors the retail TacticalView (zoom 0.2..1.3 around the
|
||||
// default height): closer/farther than that is clamped.
|
||||
float min_height = 320.0F;
|
||||
float max_height = 2100.0F;
|
||||
};
|
||||
}
|
||||
|
||||
@@ -841,9 +841,11 @@ export namespace ra3::terrain {
|
||||
const auto inv_a = w0 * p0.inv_a + w1 * p1.inv_a + w2 * p2.inv_a;
|
||||
const auto depth = 1.0F / inv_a;
|
||||
const auto pixel = static_cast<usize>(y) * static_cast<usize>(rw) + static_cast<usize>(x);
|
||||
// Ground decals sit exactly on the terrain; a small bias
|
||||
// (mirrors the GPU depth bias) keeps them from z-fighting.
|
||||
if (depth - 0.5F >= zbuf[pixel]) continue;
|
||||
// Ground decals carry a per-vertex bias toward the camera
|
||||
// (the retail shader's screen-space depth offset); it
|
||||
// keeps roads/sidewalks from z-fighting the terrain.
|
||||
const auto bias = w0 * v0.bias + w1 * v1.bias + w2 * v2.bias;
|
||||
if (depth - bias - 1.0F >= zbuf[pixel]) continue;
|
||||
|
||||
const auto tu = (w0 * v0.u * p0.inv_a + w1 * v1.u * p1.inv_a + w2 * v2.u * p2.inv_a) * depth;
|
||||
const auto tv = (w0 * v0.v * p0.inv_a + w1 * v1.v * p1.inv_a + w2 * v2.v * p2.inv_a) * depth;
|
||||
|
||||
+18
-4
@@ -101,6 +101,8 @@ export namespace ra3::ui {
|
||||
out.dx = event.motion.xrel;
|
||||
out.dy = event.motion.yrel;
|
||||
out.left = (event.motion.state & SDL_BUTTON_LMASK) != 0U;
|
||||
out.middle = (event.motion.state & SDL_BUTTON_MMASK) != 0U;
|
||||
out.right = (event.motion.state & SDL_BUTTON_RMASK) != 0U;
|
||||
return true;
|
||||
case SDL_EVENT_MOUSE_BUTTON_DOWN:
|
||||
out = {};
|
||||
@@ -108,6 +110,18 @@ export namespace ra3::ui {
|
||||
out.x = event.button.x;
|
||||
out.y = event.button.y;
|
||||
out.left = event.button.button == SDL_BUTTON_LEFT;
|
||||
out.middle = event.button.button == SDL_BUTTON_MIDDLE;
|
||||
out.right = event.button.button == SDL_BUTTON_RIGHT;
|
||||
return true;
|
||||
case SDL_EVENT_MOUSE_BUTTON_UP:
|
||||
out = {};
|
||||
out.type = ra3::render::ui_event_type::mouse_button;
|
||||
out.x = event.button.x;
|
||||
out.y = event.button.y;
|
||||
out.released = true;
|
||||
out.left = event.button.button == SDL_BUTTON_LEFT;
|
||||
out.middle = event.button.button == SDL_BUTTON_MIDDLE;
|
||||
out.right = event.button.button == SDL_BUTTON_RIGHT;
|
||||
return true;
|
||||
case SDL_EVENT_MOUSE_WHEEL:
|
||||
out = {};
|
||||
@@ -132,10 +146,10 @@ export namespace ra3::ui {
|
||||
const bool *keys = SDL_GetKeyboardState(nullptr);
|
||||
if (keys == nullptr) return false;
|
||||
switch (key) {
|
||||
case ra3::render::ui_key::up: return keys[SDL_SCANCODE_W] || keys[SDL_SCANCODE_UP];
|
||||
case ra3::render::ui_key::down: return keys[SDL_SCANCODE_S] || keys[SDL_SCANCODE_DOWN];
|
||||
case ra3::render::ui_key::left: return keys[SDL_SCANCODE_A] || keys[SDL_SCANCODE_LEFT];
|
||||
case ra3::render::ui_key::right: return keys[SDL_SCANCODE_D] || keys[SDL_SCANCODE_RIGHT];
|
||||
case ra3::render::ui_key::up: return keys[SDL_SCANCODE_UP];
|
||||
case ra3::render::ui_key::down: return keys[SDL_SCANCODE_DOWN];
|
||||
case ra3::render::ui_key::left: return keys[SDL_SCANCODE_LEFT];
|
||||
case ra3::render::ui_key::right: return keys[SDL_SCANCODE_RIGHT];
|
||||
default: return false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -295,6 +295,8 @@ export namespace ra3::vulkan {
|
||||
out.dx = event.motion.xrel;
|
||||
out.dy = event.motion.yrel;
|
||||
out.left = (event.motion.state & SDL_BUTTON_LMASK) != 0U;
|
||||
out.middle = (event.motion.state & SDL_BUTTON_MMASK) != 0U;
|
||||
out.right = (event.motion.state & SDL_BUTTON_RMASK) != 0U;
|
||||
return true;
|
||||
case SDL_EVENT_MOUSE_BUTTON_DOWN:
|
||||
out = {};
|
||||
@@ -302,6 +304,18 @@ export namespace ra3::vulkan {
|
||||
out.x = event.button.x;
|
||||
out.y = event.button.y;
|
||||
out.left = event.button.button == SDL_BUTTON_LEFT;
|
||||
out.middle = event.button.button == SDL_BUTTON_MIDDLE;
|
||||
out.right = event.button.button == SDL_BUTTON_RIGHT;
|
||||
return true;
|
||||
case SDL_EVENT_MOUSE_BUTTON_UP:
|
||||
out = {};
|
||||
out.type = ra3::render::ui_event_type::mouse_button;
|
||||
out.x = event.button.x;
|
||||
out.y = event.button.y;
|
||||
out.released = true;
|
||||
out.left = event.button.button == SDL_BUTTON_LEFT;
|
||||
out.middle = event.button.button == SDL_BUTTON_MIDDLE;
|
||||
out.right = event.button.button == SDL_BUTTON_RIGHT;
|
||||
return true;
|
||||
case SDL_EVENT_MOUSE_WHEEL:
|
||||
out = {};
|
||||
@@ -326,10 +340,10 @@ export namespace ra3::vulkan {
|
||||
const bool *keys = SDL_GetKeyboardState(nullptr);
|
||||
if (keys == nullptr) return false;
|
||||
switch (key) {
|
||||
case ra3::render::ui_key::up: return keys[SDL_SCANCODE_W] || keys[SDL_SCANCODE_UP];
|
||||
case ra3::render::ui_key::down: return keys[SDL_SCANCODE_S] || keys[SDL_SCANCODE_DOWN];
|
||||
case ra3::render::ui_key::left: return keys[SDL_SCANCODE_A] || keys[SDL_SCANCODE_LEFT];
|
||||
case ra3::render::ui_key::right: return keys[SDL_SCANCODE_D] || keys[SDL_SCANCODE_RIGHT];
|
||||
case ra3::render::ui_key::up: return keys[SDL_SCANCODE_UP];
|
||||
case ra3::render::ui_key::down: return keys[SDL_SCANCODE_DOWN];
|
||||
case ra3::render::ui_key::left: return keys[SDL_SCANCODE_LEFT];
|
||||
case ra3::render::ui_key::right: return keys[SDL_SCANCODE_RIGHT];
|
||||
default: return false;
|
||||
}
|
||||
}
|
||||
@@ -844,17 +858,18 @@ export namespace ra3::vulkan {
|
||||
binding.binding = 0U;
|
||||
binding.stride = sizeof(ra3::models::vertex);
|
||||
binding.inputRate = VK_VERTEX_INPUT_RATE_VERTEX;
|
||||
VkVertexInputAttributeDescription attributes[4]{};
|
||||
VkVertexInputAttributeDescription attributes[5]{};
|
||||
attributes[0] = {0U, 0U, VK_FORMAT_R32G32B32_SFLOAT, 0U};
|
||||
attributes[1] = {1U, 0U, VK_FORMAT_R32G32B32_SFLOAT, 12U};
|
||||
attributes[2] = {2U, 0U, VK_FORMAT_R32G32_SFLOAT, 24U};
|
||||
attributes[3] = {3U, 0U, VK_FORMAT_R32_SFLOAT, 32U};
|
||||
static_assert(sizeof(ra3::models::vertex) == 36U, "object vertex layout changed");
|
||||
attributes[4] = {4U, 0U, VK_FORMAT_R32_SFLOAT, 36U};
|
||||
static_assert(sizeof(ra3::models::vertex) == 40U, "object vertex layout changed");
|
||||
VkPipelineVertexInputStateCreateInfo vertex_input{};
|
||||
vertex_input.sType = VK_STRUCTURE_TYPE_PIPELINE_VERTEX_INPUT_STATE_CREATE_INFO;
|
||||
vertex_input.vertexBindingDescriptionCount = 1U;
|
||||
vertex_input.pVertexBindingDescriptions = &binding;
|
||||
vertex_input.vertexAttributeDescriptionCount = 4U;
|
||||
vertex_input.vertexAttributeDescriptionCount = 5U;
|
||||
vertex_input.pVertexAttributeDescriptions = attributes;
|
||||
|
||||
VkPipelineInputAssemblyStateCreateInfo assembly{};
|
||||
@@ -873,8 +888,8 @@ export namespace ra3::vulkan {
|
||||
// Ground decals (sidewalks/roads/deck pieces) sit exactly on the
|
||||
// terrain; a small negative depth bias keeps them from z-fighting.
|
||||
raster.depthBiasEnable = VK_TRUE;
|
||||
raster.depthBiasConstantFactor = -1.0F;
|
||||
raster.depthBiasSlopeFactor = -1.0F;
|
||||
raster.depthBiasConstantFactor = -4.0F;
|
||||
raster.depthBiasSlopeFactor = -4.0F;
|
||||
VkPipelineMultisampleStateCreateInfo multisample{};
|
||||
multisample.sType = VK_STRUCTURE_TYPE_PIPELINE_MULTISAMPLE_STATE_CREATE_INFO;
|
||||
multisample.rasterizationSamples = VK_SAMPLE_COUNT_1_BIT;
|
||||
|
||||
Reference in New Issue
Block a user