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
+99 -21
View File
@@ -10,14 +10,56 @@ import ender.log;
namespace {
#if defined(_WIN32)
/** Path of the crash report written next to the executable. */
[[nodiscard]] auto crash_log_path() -> const std::filesystem::path & {
static const auto path = [] {
std::wstring buffer(32768U, L'\0');
const DWORD length = GetModuleFileNameW(nullptr, buffer.data(), static_cast<DWORD>(buffer.size()));
buffer.resize(length);
return std::filesystem::path{buffer}.parent_path() / L"openra3_crash.log";
/** `%LOCALAPPDATA%` as a wide path, or empty when the variable is unset. */
auto local_appdata() -> std::filesystem::path {
const DWORD needed = GetEnvironmentVariableW(L"LOCALAPPDATA", nullptr, 0U);
if (needed == 0U || needed > 32768U) return {};
std::wstring buffer(needed, L'\0');
const DWORD written = GetEnvironmentVariableW(L"LOCALAPPDATA", buffer.data(), needed);
if (written == 0U || written >= needed) return {};
buffer.resize(written);
return std::filesystem::path{buffer};
}
#endif
/**
* Directory that holds per-run logs and crash reports: a `logs` folder
* under the platform's per-user state location, created on first use. It
* deliberately does not sit beside the executable, which may be read-only
* or a shared build tree.
*
* Windows: `%LOCALAPPDATA%\OpenRA3\logs`; elsewhere
* `$XDG_STATE_HOME/openra3/logs` (falling back to
* `~/.local/state/openra3/logs`).
*/
[[maybe_unused]] auto log_directory() -> const std::filesystem::path & {
static const auto directory = [] {
#if defined(_WIN32)
auto base = local_appdata();
if (base.empty()) base = std::filesystem::temp_directory_path();
base /= L"OpenRA3";
#else
std::filesystem::path base;
if (const char *state = std::getenv("XDG_STATE_HOME"); state != nullptr && *state != '\0')
base = state;
else if (const char *home = std::getenv("HOME"); home != nullptr && *home != '\0')
base = std::filesystem::path{home} / ".local" / "state";
else
base = std::filesystem::temp_directory_path();
base /= "openra3";
#endif
auto result = base / "logs";
std::error_code ec;
std::filesystem::create_directories(result, ec);
return result;
}();
return directory;
}
#if defined(_WIN32)
/** Path of the crash report, under the per-user `logs` folder. */
[[nodiscard]] auto crash_log_path() -> const std::filesystem::path & {
static const auto path = log_directory() / L"openra3_crash.log";
return path;
}
@@ -92,7 +134,7 @@ namespace {
* timestamped file on open, so every run gets its own log and the previous
* run's log is preserved.
*/
auto setup_logging(const std::filesystem::path &exe_dir) -> void {
auto setup_logging() -> void {
namespace log = ender::log;
log::configure({.minimum = log::level::info, .stacktrace_from = log::level::warn});
#if defined(__EMSCRIPTEN__)
@@ -100,9 +142,8 @@ namespace {
// console.error regardless of level; use stdout so INFO/WARN appear at
// their real level. There is no file sink on the web.
log::set_sinks({std::make_shared<log::console_sink>(std::cout)});
(void) exe_dir;
#else
log::add_file_sink(exe_dir / "openra3.log", {.max_file_size = 4U * 1024U * 1024U, .max_archives = 10U});
log::add_file_sink(log_directory() / "openra3.log", {.max_file_size = 4U * 1024U * 1024U, .max_archives = 10U});
#endif
log::info("OpenRA3 started");
}
@@ -190,6 +231,32 @@ namespace {
return maps;
}
/** Case-insensitive ASCII ordering (`a` before `b`). */
auto name_less(std::string_view a, std::string_view b) -> bool {
const auto length = std::min(a.size(), b.size());
for (std::size_t i = 0; i < length; ++i) {
const auto ca = static_cast<unsigned char>(std::tolower(static_cast<unsigned char>(a[i])));
const auto cb = static_cast<unsigned char>(std::tolower(static_cast<unsigned char>(b[i])));
if (ca != cb) return ca < cb;
}
return a.size() < b.size();
}
/**
* Order the map list the way the retail skirmish screen does: alphabetically
* by the localized display name (e.g. "Battlebase Beta" before "Cabana
* Republic"), falling back to the id for entries with equal names.
*/
auto sort_maps_by_name(std::vector<asset_map> &maps, const ra3::map::map_name_table &names) -> void {
std::sort(maps.begin(), maps.end(), [&](const asset_map &a, const asset_map &b) {
const auto name_a = names.lookup(a.id);
const auto name_b = names.lookup(b.id);
if (name_less(name_a, name_b)) return true;
if (name_less(name_b, name_a)) return false;
return a.id < b.id;
});
}
/** Find `<id>_art.tga` anywhere under `root`. */
auto find_art(const std::filesystem::path &root, std::string_view id) -> std::optional<std::filesystem::path> {
const auto want = std::string{id} + "_art.tga";
@@ -259,14 +326,18 @@ namespace {
}
const auto world_w = terrain.world_width();
const auto world_h = terrain.world_height();
// An opaque water surface hides anything below it, so cull objects
// and roads submerged under the map's water plane (sunken ships,
// underwater props, ...) instead of drawing them on top of the sea.
const auto cull_below_z = terrain.has_water ? terrain.water_plane_z : -3.4e38F;
scene = ra3::models::build_scene(stream, placements, [&](float x, float y) -> float {
if (x < 0.0F || y < 0.0F || x >= world_w || y >= world_h) return 0.0F;
const auto cx = std::min(terrain.width - 1U, static_cast<ra3::core::uint32>(x / ra3::terrain::cell_size));
const auto cy = std::min(terrain.height - 1U, static_cast<ra3::core::uint32>((world_h - y) / ra3::terrain::cell_size));
return static_cast<float>(terrain.elevation(cx, cy)) * options.z_scale;
});
std::printf("objects: %zu placed, %zu missing, %zu road segments, %zu triangles, %zu textures (%s)\n", scene.placed, scene.missing,
scene.roads, scene.triangle_count(), scene.textures.size(), paths->bin.filename().string().c_str());
}, 128U, cull_below_z);
std::printf("objects: %zu placed, %zu missing, %zu hidden, %zu road segments, %zu triangles, %zu textures (%s)\n", scene.placed, scene.missing,
scene.hidden, scene.roads, scene.triangle_count(), scene.textures.size(), paths->bin.filename().string().c_str());
} catch (const std::exception &error) {
std::printf("objects: failed to build scene (%s)\n", error.what());
}
@@ -410,8 +481,9 @@ namespace {
auto command_maps(const std::filesystem::path &assets) -> int {
if (!ensure_assets(assets)) return 1;
const auto maps = list_asset_maps(assets);
auto maps = list_asset_maps(assets);
const auto names = ra3::map::load_map_names(assets);
sort_maps_by_name(maps, names);
std::printf("maps: %zu\n", maps.size());
for (const auto &m: maps) {
std::error_code ec;
@@ -424,8 +496,9 @@ namespace {
auto command_skirmish(const std::vector<std::string> &args, const std::filesystem::path &assets) -> int {
using namespace ra3;
if (!ensure_assets(assets)) return 1;
const auto maps = list_asset_maps(assets);
auto maps = list_asset_maps(assets);
if (maps.empty()) return 1;
sort_maps_by_name(maps, map::load_map_names(assets));
const auto requested = option_value(args, "--map");
const asset_map *picked = &maps.front();
if (requested) {
@@ -463,8 +536,10 @@ namespace {
auto command_render(const std::vector<std::string> &args, const std::filesystem::path &assets) -> int {
using namespace ra3;
if (!ensure_assets(assets)) return 1;
const auto maps = list_asset_maps(assets);
auto maps = list_asset_maps(assets);
if (maps.empty()) return 1;
const auto names = map::load_map_names(assets);
sort_maps_by_name(maps, names);
const auto requested = option_value(args, "--map");
const asset_map *picked = &maps.front();
if (requested) {
@@ -473,7 +548,6 @@ namespace {
}
render::scene_options scene;
const auto names = map::load_map_names(assets);
scene.title = "OpenRA3 - " + names.lookup(picked->id);
if (const auto size = option_value(args, "--world-size")) scene.world_width = scene.world_height = std::stod(*size);
@@ -1141,7 +1215,9 @@ namespace {
const bool want_gpu = s.mode == 0 && s.out.empty() && s.thumbnail == false;
if (want_gpu) {
view.gpu = terrain::build_gpu_terrain(view.map, view.textures, {}, [&](float t) { report(0.70F + 0.18F * t, "Building terrain..."); });
report(0.62F, "Loading objects...");
const auto objects = build_object_scene(assets, map_file.stem().string(), view.map, terrain::render_options{}, bytes);
view.gpu = terrain::build_gpu_terrain(view.map, view.textures, {}, objects, [&](float t) { report(0.70F + 0.18F * t, "Building terrain..."); });
if (!starts.empty()) {
view.camera3d.target_x = starts[0].x;
view.camera3d.target_y = starts[0].y;
@@ -1185,12 +1261,13 @@ namespace {
auto command_menu(const std::vector<std::string> &args, const std::filesystem::path &assets) -> int {
if (!ensure_assets(assets)) return 1;
const auto maps = list_asset_maps(assets);
auto maps = list_asset_maps(assets);
if (maps.empty()) {
std::puts("no maps found");
return 1;
}
const auto names = ra3::map::load_map_names(assets);
sort_maps_by_name(maps, names);
menu_state st;
// Seed the menu from any command-line flags so they are all visible/editable.
@@ -1307,8 +1384,9 @@ namespace {
/** Render one menu frame to a BMP (headless preview of the menu layout). */
auto command_menu_preview(const std::vector<std::string> &args, const std::filesystem::path &assets) -> int {
if (!ensure_assets(assets)) return 1;
const auto maps = list_asset_maps(assets);
auto maps = list_asset_maps(assets);
const auto names = ra3::map::load_map_names(assets);
sort_maps_by_name(maps, names);
menu_state st;
if (const auto requested = option_value(args, "--map")) {
for (std::size_t i = 0; i < maps.size(); ++i) {
@@ -1338,7 +1416,7 @@ auto main(int argc, char **argv) -> int {
const std::vector<std::string> args{argv + 1, argv + argc};
const auto exe_dir = executable_dir(argc > 0 ? argv[0] : ".");
const auto assets = exe_dir / "assets";
setup_logging(exe_dir);
setup_logging();
#if defined(__EMSCRIPTEN__)
// The wasm build runs on a Web Worker (its runtime's main thread lives
// there), where synchronous XHR is legal, so the asset tree is mounted