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
+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;