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:
+107
-42
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user