feat(render): SAGE water/ocean shaders, per-user logs, camera & culling

Terrain pass now ports the SAGE water model (Ocean.fx / OpenSAGE Water.frag): a de-gridded procedural wave normal combined with the retail ra3_deepocean flow and ra3_deepocean_nrm bump maps (appended as the last two terrain-atlas layers, no new backend binding), Schlick fresnel, sky reflection + depth-graded refraction, SAGE diffuse/specular lighting, depth-based transparency, and an underwater tint (UnderwaterDeferred.fx). Mirrored across terrain.frag / dx_terrain.hlsl / webgl_terrain_frag.glsl / webgpu_terrain.wgsl.

Also: logs move to the per-user state dir (%LOCALAPPDATA%\\OpenRA3\\logs, else XDG) and archive as openra3.<stamp>.log; the FPS label shows the active backend; middle-drag camera reset; objects and roads below the water plane are culled.
This commit is contained in:
EnderTheCoder
2026-09-30 22:18:08 +08:00
parent 10a1963eec
commit 26e1934a5d
16 changed files with 969 additions and 249 deletions
+25 -13
View File
@@ -277,13 +277,15 @@ export namespace ra3::client {
} else if (event.type == ui_event_type::mouse_move) {
mouse_x = event.x;
mouse_y = event.y;
if (event.left) {
if (event.middle) {
drag_x += event.dx;
drag_y += event.dy;
}
} 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);
dirty = true;
if (event.wheel != 0.0F) {
camera.height = std::clamp(camera.height * (event.wheel > 0.0F ? (1.0F / 1.15F) : 1.15F), camera.min_height, camera.max_height);
dirty = true;
}
}
}
if (!running) break;
@@ -345,8 +347,9 @@ export namespace ra3::client {
/**
* GPU terrain viewer. `present_terrain` draws the heightfield; the loop
* here owns the camera controls. The top-left shows the FPS (current /
* cap) and, when `minimap_overview` is not empty, a corner minimap with
* the camera location is drawn.
* cap) tagged with the active backend name (e.g. `[vulkan]`) and, when
* `minimap_overview` is not empty, a corner minimap with the camera
* location is drawn.
*/
[[nodiscard]] auto terrain_loop(const ra3::terrain::gpu_terrain &terrain, ra3::render::camera3d camera, const image &minimap_overview) -> bool {
if (terrain.width == 0U || terrain.height == 0U || !this->supports_terrain()) return false;
@@ -372,6 +375,7 @@ export namespace ra3::client {
bool presented = false;
const auto default_camera = camera;
bool middle_dragged = false;
bool middle_down = false;
while (running) {
ui_event event;
float drag_x = 0.0F;
@@ -398,15 +402,23 @@ export namespace ra3::client {
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;
if (event.wheel != 0.0F) {
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) {
if (event.released) {
if (middle_down && !middle_dragged) {
camera.yaw = default_camera.yaw;
camera.pitch = default_camera.pitch;
camera.height = default_camera.height;
camera_moved = true;
}
middle_down = false;
} else {
middle_down = true;
middle_dragged = false;
}
}
}
if (!running) break;
@@ -466,7 +478,7 @@ export namespace ra3::client {
fps = static_cast<uint32>(std::lround(static_cast<float>(fps_frames) / window_s));
fps_frames = 0;
fps_window = now;
overlay.label = ra3::render::compose_fps_label(fps, fps_limit_);
overlay.label = ra3::render::compose_fps_label(fps, fps_limit_, this->name());
overlay.label_changed = true;
}
} else {
+17 -1
View File
@@ -765,6 +765,7 @@ export namespace ra3::models {
usize placed = 0;
usize missing = 0;
usize roads = 0;
usize hidden = 0; ///< Objects/roads below the water plane, not drawn.
[[nodiscard]] auto empty() const -> bool { return indices.empty(); }
[[nodiscard]] auto triangle_count() const -> usize { return indices.size() / 3U; }
@@ -823,9 +824,14 @@ export namespace ra3::models {
* @param ground_height Terrain height (world Z) at a world `(x, y)`, so
* objects sit on the relief instead of a flat plane.
* @param texture_size Edge length of the shared texture array (0 = auto).
* @param cull_below_z Skip placements whose base sits below this world Z
* (the water plane): an opaque water surface hides
* submerged objects, so drawing them would float them
* on top of the sea.
*/
[[nodiscard]] inline auto build_scene(const asset_stream &stream, std::span<const placement> placements,
const std::function<float(float, float)> &ground_height = {}, uint32 texture_size = 128U) -> scene {
const std::function<float(float, float)> &ground_height = {}, uint32 texture_size = 128U,
float cull_below_z = -3.4e38F) -> scene {
scene out;
out.texture_size = texture_size == 0U ? 128U : texture_size;
std::unordered_map<std::string, uint32> texture_layers;
@@ -972,6 +978,12 @@ export namespace ra3::models {
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
const auto road_base = (ground_height ? ground_height(a.x, a.y) : 0.0F) + a.z;
if (road_base < cull_below_z) {
++out.hidden;
++i;
continue;
}
emit_road(a, b);
++i;
}
@@ -986,6 +998,10 @@ export namespace ra3::models {
const auto cos_a = std::cos(item.angle);
const auto sin_a = std::sin(item.angle);
const auto base_z = (ground_height ? ground_height(item.x, item.y) : 0.0F) + item.z;
if (base_z < cull_below_z) {
++out.hidden;
continue;
}
bool drawn = false;
for (const auto *mesh_asset: meshes) {
// Only opaque material parts are drawn; meshes with no diffuse
+12 -5
View File
@@ -314,10 +314,11 @@ export namespace ra3::render {
}
/**
* A small translucent label for the top-left corner, e.g. `FPS: 155/160`.
* `cap == 0` means vertical sync, `cap < 0` means uncapped.
* A small translucent label for the top-left corner, e.g.
* `FPS: 155/160 [vulkan]`. `cap == 0` means vertical sync, `cap < 0` means
* uncapped. `backend` is the active renderer backend name (empty omits it).
*/
[[nodiscard]] inline auto compose_fps_label(uint32 fps, int cap) -> image {
[[nodiscard]] inline auto compose_fps_label(uint32 fps, int cap, std::string_view backend = {}) -> image {
char text[64];
if (cap == 0) {
std::snprintf(text, sizeof(text), "FPS: %u/vsync", fps);
@@ -326,10 +327,16 @@ export namespace ra3::render {
} else {
std::snprintf(text, sizeof(text), "FPS: %u/%d", fps, cap);
}
const auto w = text_width(text, 1U) + 8U;
std::string label{text};
if (!backend.empty()) {
label += " [";
label += backend;
label += ']';
}
const auto w = text_width(label, 1U) + 8U;
const auto h = detail::glyph_height + 6U;
image img(w, h, argb(30, 6, 6, 200)); // warm translucent backing
draw_text(img, 4, 3, text, argb(240, 200, 90), 1U);
draw_text(img, 4, 3, label, argb(240, 200, 90), 1U);
return img;
}
+107 -42
View File
@@ -259,6 +259,8 @@ export namespace ra3::terrain {
/** The decoded terrain textures, parallel to `map_data::textures`. */
struct texture_set {
std::vector<image> images;
image water_flow; ///< `ra3_deepocean.tga`: SAGE water flow/distortion (RG), optional.
image water_normal; ///< `ra3_deepocean_nrm.tga`: SAGE water bump normal, optional.
[[nodiscard]] auto resolved() const -> usize {
usize n = 0;
@@ -294,6 +296,10 @@ export namespace ra3::terrain {
};
std::vector<fs::big_archive> archives;
std::unordered_map<std::string, source> files;
source flow_src{};
source nrm_src{};
bool has_flow = false;
bool has_nrm = false;
for (const auto &name: {"Terrain.big", "Core11.big"}) {
const auto path = data_dir / name;
std::error_code ec;
@@ -302,9 +308,13 @@ export namespace ra3::terrain {
}
for (const auto &archive: archives) {
for (const auto &entry: archive.entries()) {
auto stem = detail::tga_stem(entry.name);
if (stem.size() > 4U && stem.ends_with("_nrm")) continue;
if (!entry.name.ends_with(".tga") && !entry.name.ends_with(".TGA")) continue;
auto stem = detail::tga_stem(entry.name);
// The global ocean flow/normal pair is grabbed separately: the
// tile index deliberately drops `_nrm` files.
if (stem == "ra3_deepocean") { flow_src = source{&archive, entry.name}; has_flow = true; continue; }
if (stem == "ra3_deepocean_nrm") { nrm_src = source{&archive, entry.name}; has_nrm = true; continue; }
if (stem.size() > 4U && stem.ends_with("_nrm")) continue;
files.try_emplace(stem, source{&archive, entry.name});
}
}
@@ -336,6 +346,16 @@ export namespace ra3::terrain {
// Leave the slot empty; the renderer falls back to a palette.
}
}
const auto decode_water = [](const source &src, bool present) -> image {
if (!present) return {};
try {
return ra3::render::decode_tga(src.archive->read(src.entry, true));
} catch (const std::exception &) {
return {};
}
};
set.water_flow = decode_water(flow_src, has_flow);
set.water_normal = decode_water(nrm_src, has_nrm);
return set;
}
@@ -354,8 +374,8 @@ export namespace ra3::terrain {
};
namespace detail {
/** Index `*.tga` under a terrain dir by stem (lower-cased), ignoring normals. */
[[nodiscard]] inline auto terrain_file_index(const std::filesystem::path &dir)
/** Index `*.tga` under a terrain dir by stem (lower-cased). */
[[nodiscard]] inline auto terrain_file_index(const std::filesystem::path &dir, bool include_normals = false)
-> std::unordered_map<std::string, std::filesystem::path> {
std::unordered_map<std::string, std::filesystem::path> files;
std::error_code ec;
@@ -370,7 +390,7 @@ export namespace ra3::terrain {
std::transform(parent.begin(), parent.end(), parent.begin(), [](unsigned char ch) { return static_cast<char>(std::tolower(ch)); });
if (parent != "terrain") continue;
auto stem = tga_stem(path.filename().string());
if (stem.size() > 4U && stem.ends_with("_nrm")) continue;
if (!include_normals && stem.size() > 4U && stem.ends_with("_nrm")) continue;
files.try_emplace(stem, path);
}
return files;
@@ -396,11 +416,16 @@ export namespace ra3::terrain {
* Used to stage just the tiles a single map needs (e.g. the wasm preload).
*/
[[nodiscard]] inline auto resolve_texture_files(const map_data &map, const std::filesystem::path &dir) -> std::vector<std::filesystem::path> {
const auto files = detail::terrain_file_index(dir);
// Include normals so the global ocean flow/normal pair is staged too:
// the terrain pass appends them to the atlas.
const auto files = detail::terrain_file_index(dir, true);
std::vector<std::filesystem::path> resolved;
for (const auto &texture: map.textures) {
if (auto found = detail::match_terrain_file(files, texture.name); !found.empty()) resolved.push_back(std::move(found));
}
for (const auto *water: {"ra3_deepocean", "ra3_deepocean_nrm"}) {
if (const auto it = files.find(water); it != files.end()) resolved.push_back(it->second);
}
std::sort(resolved.begin(), resolved.end());
resolved.erase(std::unique(resolved.begin(), resolved.end()), resolved.end());
return resolved;
@@ -409,21 +434,26 @@ export namespace ra3::terrain {
/** Load tile textures from a directory of loose `*.tga` files (extracted assets). */
[[nodiscard]] inline auto load_textures_from_dir(const map_data &map, const std::filesystem::path &dir,
const std::function<void(float)> &progress = {}) -> texture_set {
const auto files = detail::terrain_file_index(dir);
const auto files = detail::terrain_file_index(dir, true);
texture_set set;
set.images.resize(map.textures.size());
const auto decode_file = [](const std::filesystem::path &path) -> image {
if (path.empty()) return {};
try {
std::ifstream in(path, std::ios::binary);
std::vector<uint8> raw((std::istreambuf_iterator<char>(in)), std::istreambuf_iterator<char>());
return ra3::render::decode_tga(raw);
} catch (const std::exception &) {
return {};
}
};
for (usize i = 0; i < map.textures.size(); ++i) {
const auto found = detail::match_terrain_file(files, map.textures[i].name);
if (!found.empty()) {
try {
std::ifstream in(found, std::ios::binary);
std::vector<uint8> raw((std::istreambuf_iterator<char>(in)), std::istreambuf_iterator<char>());
set.images[i] = ra3::render::decode_tga(raw);
} catch (const std::exception &) {
}
}
set.images[i] = decode_file(found);
if (progress && !map.textures.empty()) progress(static_cast<float>(i + 1U) / static_cast<float>(map.textures.size()));
}
if (const auto it = files.find("ra3_deepocean"); it != files.end()) set.water_flow = decode_file(it->second);
if (const auto it = files.find("ra3_deepocean_nrm"); it != files.end()) set.water_normal = decode_file(it->second);
return set;
}
@@ -497,7 +527,12 @@ export namespace ra3::terrain {
out.cell_data[i * 4U + 3U] = static_cast<uint16>(packed);
}
out.layer_count = static_cast<uint32>(std::max<usize>(1U, map.textures.size()));
// Two extra atlas layers hold the SAGE water flow map and bump normal so
// the water shader can sample them without a new binding on any backend:
// they are always the last two layers (water_flow = layer_count - 2,
// water_normal = layer_count - 1).
const auto tile_layers = static_cast<uint32>(std::max<usize>(1U, map.textures.size()));
out.layer_count = tile_layers + 2U;
uint32 layer_size = 64U;
for (const auto &img: set.images) {
if (!img.empty()) layer_size = std::max(layer_size, img.width());
@@ -512,17 +547,26 @@ export namespace ra3::terrain {
}
out.layers.assign(static_cast<usize>(out.layer_count) * layer_size * layer_size, 0xFF3A4550U);
for (usize i = 0; i < map.textures.size(); ++i) {
const auto &img = set.images[i];
if (img.empty()) continue;
// Copy `img` into atlas layer `index`, box-nearest downscaled to
// `layer_size`; `fallback` is the ARGB used when the image is absent.
const auto blit_layer = [&](uint32 index, const image &img, uint32 fallback) {
for (uint32 y = 0; y < layer_size; ++y) {
const auto sy = std::min(img.height() - 1U, y * img.height() / layer_size);
for (uint32 x = 0; x < layer_size; ++x) {
const auto sx = std::min(img.width() - 1U, x * img.width() / layer_size);
out.layers[(i * layer_size + y) * layer_size + x] = img.data()[static_cast<usize>(sy) * img.width() + sx];
uint32 px = fallback;
if (!img.empty()) {
const auto sy = std::min(img.height() - 1U, y * img.height() / layer_size);
const auto sx = std::min(img.width() - 1U, x * img.width() / layer_size);
px = img.data()[static_cast<usize>(sy) * img.width() + sx];
}
out.layers[(static_cast<usize>(index) * layer_size + y) * layer_size + x] = px;
}
}
};
for (usize i = 0; i < map.textures.size(); ++i) {
blit_layer(static_cast<uint32>(i), set.images[i], 0xFF3A4550U);
}
blit_layer(tile_layers, set.water_flow, 0xFF808080U); // neutral flow (0, 0)
blit_layer(tile_layers + 1U, set.water_normal, 0xFF8080FFU); // flat normal (0, 0, 1)
if (progress) progress(1.0F);
return out;
}
@@ -1096,49 +1140,70 @@ export namespace ra3::terrain {
continue;
}
auto t = cell_size * 0.5F;
auto dt = cell_size * 0.5F;
// Clip the ray to the map's XY rectangle. The boundary is an
// exact plane, so the silhouette there stays razor-sharp
// instead of stair-stepping across it; outside the map is sky.
auto t_enter = 0.0F;
auto t_exit = 1.0e30F;
const auto slab = [](float origin, float dir, float span, float &lo_t, float &hi_t) -> bool {
if (std::abs(dir) < 1.0e-6F) return origin >= 0.0F && origin <= span;
const auto a = (0.0F - origin) / dir;
const auto b = (span - origin) / dir;
lo_t = std::max(lo_t, std::min(a, b));
hi_t = std::min(hi_t, std::max(a, b));
return true;
};
if (!slab(cam_x, dx, world_w, t_enter, t_exit) || !slab(cam_y, dy, world_h, t_enter, t_exit) || t_exit <= 0.0F) {
hi.data()[pixel] = argb(150, 170, 200);
continue;
}
const auto surface_at = [&](float wx, float wy) -> float {
const auto h = sample_height(wx, wy);
return map.has_water ? std::max(h, static_cast<float>(map.water_plane_z)) : h;
};
// March the heightfield cell by cell: the step is never longer
// than the time to cross one cell (in the dominant horizontal
// axis), while a clearance term lets the ray skip the empty air
// above the surface. Resolving every cell is what keeps cliff and
// map-edge silhouettes from quantising into huge stair-steps that
// crawl/wave as the camera pans.
const auto horiz = std::max(std::abs(dx), std::abs(dy));
const auto cell_step = std::min(cell_size / std::max(horiz, 1.0e-4F), cell_size * 32.0F);
auto t = std::max(t_enter, cell_size * 0.5F);
auto prev_t = t;
bool hit = false;
float hit_t = 0.0F;
for (int iter = 0; iter < 4000 && t < 60000.0F; ++iter) {
for (int iter = 0; iter < 4096 && t <= t_exit; ++iter) {
const auto wx = cam_x + dx * t;
const auto wy = cam_y + dy * t;
const auto wz = cam_z + dz * t;
if (wx < 0.0F || wy < 0.0F || wx >= world_w || wy >= world_h) {
prev_t = t;
dt *= 1.03F;
t += dt;
continue;
}
if (map.has_water && wz <= map.water_plane_z) {
hit = true;
hit_t = t;
break;
}
if (wz <= sample_height(wx, wy)) {
const auto surface = surface_at(wx, wy);
if (wz <= surface) {
hit = true;
hit_t = t;
break;
}
const auto clearance = (wz - surface) / std::max(-dz, 1.0e-4F);
prev_t = t;
dt *= 1.03F;
t += dt;
t += std::clamp(clearance, cell_step, cell_step * 8.0F);
}
if (!hit) {
hi.data()[pixel] = argb(150, 170, 200);
continue;
}
// Refine the first crossing; with a sub-cell bracket this
// converges to the exact surface point.
auto lo = prev_t;
auto up = hit_t;
for (int i = 0; i < 6; ++i) {
for (int i = 0; i < 18; ++i) {
const auto mid = 0.5F * (lo + up);
const auto wx = cam_x + dx * mid;
const auto wy = cam_y + dy * mid;
const auto wz = cam_z + dz * mid;
const auto water = map.has_water && wz <= map.water_plane_z;
if (water || wz <= sample_height(wx, wy)) {
if (wz <= surface_at(wx, wy)) {
up = mid;
} else {
lo = mid;