From 26e1934a5dd321db20c51f2cc581233d1d9b236f Mon Sep 17 00:00:00 2001 From: EnderTheCoder Date: Wed, 30 Sep 2026 22:18:08 +0800 Subject: [PATCH] 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..log; the FPS label shows the active backend; middle-drag camera reset; objects and roads below the water plane are culled. --- README.md | 11 +- apps/openra3/main.cpp | 120 +++++++++--- docs/ARCHITECTURE.md | 30 ++- docs/REVERSE_ENGINEERING.md | 42 +++++ shaders/dx_terrain.hlsl | 204 ++++++++++++++++----- shaders/generated/terrain.frag.spv | Bin 23384 -> 34712 bytes shaders/terrain.frag | 193 +++++++++++++++---- shaders/webgl_terrain_frag.glsl | 184 +++++++++++++++---- shaders/webgpu_terrain.wgsl | 183 ++++++++++++++---- src/client/ra3.client.cppm | 38 ++-- src/models/ra3.models.cppm | 18 +- src/render/ra3.render.cppm | 17 +- src/terrain/ra3.terrain.cppm | 149 ++++++++++----- tests/ra3_tests.cpp | 6 + third_party/libenderlog/README.md | 4 +- third_party/libenderlog/src/ender.log.cppm | 19 +- 16 files changed, 969 insertions(+), 249 deletions(-) diff --git a/README.md b/README.md index 2eed0dc..78cb447 100644 --- a/README.md +++ b/README.md @@ -243,14 +243,17 @@ wixl 0.106 has no arm64 support, so Windows/ARM64 ships the portable `.zip` ## Logging -Every run writes `openra3.log` next to the executable through the vendored +Every run writes `openra3.log` in a per-user `logs/` folder — on Windows +`%LOCALAPPDATA%\OpenRA3\logs`, elsewhere `$XDG_STATE_HOME/openra3/logs` +(falling back to `~/.local/state/openra3/logs`) — created on first use and kept +out of the binary's own directory, through the vendored [`libenderlog`](third_party/libenderlog) module (`import ender.log;`). A file -sink archives the previous log to `openra3.log.` on open, so +sink archives the previous log to `openra3..log` on open, so each run gets its own file; the active file rotates at 4 MiB and the last 10 archives are kept. Records at **`warn` and above** carry a call stack (Windows `CaptureStackBackTrace` / POSIX `execinfo`, because libc++ has no -``). A hard crash also writes `openra3_crash.log` with the faulting -module and a raw backtrace. +``). A hard crash also writes `openra3_crash.log` alongside it with +the faulting module and a raw backtrace. ## Running a skirmish diff --git a/apps/openra3/main.cpp b/apps/openra3/main.cpp index 2d23935..52e2c7f 100644 --- a/apps/openra3/main.cpp +++ b/apps/openra3/main.cpp @@ -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(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(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(std::tolower(static_cast(a[i]))); + const auto cb = static_cast(std::tolower(static_cast(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 &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 `_art.tga` anywhere under `root`. */ auto find_art(const std::filesystem::path &root, std::string_view id) -> std::optional { 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(x / ra3::terrain::cell_size)); const auto cy = std::min(terrain.height - 1U, static_cast((world_h - y) / ra3::terrain::cell_size)); return static_cast(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 &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 &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 &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 &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 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 diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index bbe0c4a..0d14c90 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -533,7 +533,8 @@ Mirrors SAGE `GameEngine/Common`. Everything else speaks this. - `[ ]` pass scheduling, barriers, transient/aliased resources `(v0.6)` - **F3 Terrain render** `[~]` - `[x]` top-down software + GPU heightfield with blend ramp + gutter atlas - - `[ ]` perspective terrain mesh, LOD, cliff, water `(v0.6)` + - `[ ]` perspective terrain mesh, LOD, cliff `(v0.6)` + - `[x]` water surface in the raymarch: SAGE `Water.frag` port (ocean/river) `[~]` - **F4 Model render** `[~]` - `[x]` static map-object triangle soup, world-space, depth-tested over the terrain (Vulkan + software) - `[x]` bind-pose skinning (bone-space vertices) + ground-decal depth bias @@ -550,8 +551,35 @@ Mirrors SAGE `GameEngine/Common`. Everything else speaks this. - `[x]` ARGB framebuffer, blit/line/circle/text, TGA decode, BMP encode - `[x]` map compositing, grid, markers; headless output - **F9 Post-processing** `[ ]` + - `[x]` underwater tint/fog in the terrain pass (retail `UnderwaterDeferred.fx`) `[~]` - `[ ]` bloom, color grading, AA, resolution scaling `(v0.7)` +### M16b shader porting status (retail `Data\Shaders.big` → `*.fxo`) + +The retail set is **88 compiled effects** (`Core12\shaders\compiled`, duplicated +in `Misc`/`Shaders`; `Core5`/`Core8` ship only `terrain`; plus a 60-byte +`null` stub). OpenRA3 implements the static-map subset only: + +- **Ported (approximate stand-ins, shared by all backends):** `Terrain.fx` + (`terrain.*`), the opaque diffuse subset of `BuildingsGeneric.fx` / + `BasicW3D.fx` / `ObjectsGeneric.fx` (`object.*`), and the SAGE water model + `Ocean.fx` (+ `OceanDisplacement`/`OceanNoVertexTexture`/`RiverWater`/ + `RiverReflection`/`UnderwaterDeferred`, folded into the `terrain.*` water + branch — see `docs/REVERSE_ENGINEERING.md`). The retail water flow and bump + maps (`art/terrain/ra3_deepocean.tga`, `ra3_deepocean_nrm.tga`) ride as the + last two terrain-atlas layers, so no backend adds a binding. +- **Not ported:** all faction/variant model shaders (`buildings*`, `objects*`, + `basicw3d*`, `defaultw3d*`, `normalmapped`, `tree`/`treesway`), instances/ + animation (`infantry*`), particles and beams (`cpuparticle`, `gpuparticle*`, + `swarmparticle`, `laser*`, `lightning`, `fx*`, `tracer`, `trail`, + `connectionline`, `linerenderers`, `stream`, `rain`, `simple*`), shadows and + ground decals (`shadow`, `decal`, `outlines`, `occlusion`, `terraintracks`), + post-processing (`postfx_*`), and the 2D/misc shaders (`render2d`, `video`, + `bootupscreen`, `debug`, `errormissing`, `rotateenvironmentmap`, + `distortingobject`). The shared retail includes `shadowmap.fxh`, `ssao.fxh`, + `macrotexture.fxh`, `gamma.fxh` are likewise absent (`skinning.fxh` is done at + bind pose only). + ### M17 `ra3.ui` — platform layer & backends `[D]` - **F1 Display abstraction** `[D]` diff --git a/docs/REVERSE_ENGINEERING.md b/docs/REVERSE_ENGINEERING.md index be24d96..5a6bf80 100644 --- a/docs/REVERSE_ENGINEERING.md +++ b/docs/REVERSE_ENGINEERING.md @@ -324,6 +324,48 @@ vertex color and `TintColor`; `basicw3d.fxo` instead modulates a single macro/lightmap with `(vertexColor + additive) * diffuse * 2` and has no normal map. (Recovered by disassembling the embedded `vs_3_0`/`ps_3_0` bytecode.) +### Water / ocean (`Ocean.fx`, `OceanDisplacement.fx`, `RiverWater.fx`, `UnderwaterDeferred.fx`) + +The SAGE water surface is reconstructed by OpenSAGE as +`Assets/Shaders/Water.vert`+`Water.frag` (same family as retail `Ocean.fx`). The +surface is a world-space mesh drawn with alpha blending, fed by two render +targets rendered before it: a **reflection** map (scene from the mirrored camera +about `GlobalWaterSettings.ReflectionPlaneZ`) and a **refraction** map + depth. +The fragment model (`Water.frag`) is: + +- `waterUV = worldPos.xy / 320`; a scrolling `WaterTexture` supplies both a + flow distortion (`(tex.xy*2-1)*0.05`) and the flow layer; a `BumpTexture` + supplies the surface `worldNormal`. +- `fresnelFactor = dot(viewVector, +Z)`; reflection/refraction are sampled in + screen space (`gl_FragCoord / ViewportSize`), each displaced by the distortion. +- `linearWaterDepth = linearize(RefractionDepth) - linearize(gl_FragCoord.z)`; + `alpha = clamp((linearWaterDepth/2)/TransparentWaterDepth, 0, TransparentWaterMinOpacity)`. +- `final = diffuseColor * textureColor * cloudColor`, then mixed with + `mix(reflectionColor, refractionColor, fresnelFactor)` (both maps on) or just + one of them, per `IsRenderReflection` / `IsRenderRefraction`. +- Per-time-of-day `WaterSet`: `WaterTexture`, `UScrollPerMS`/`VScrollPerMS`, + `DiffuseColor`, `TransparentDiffuseColor`; `WaterTransparency` supplies + `TransparentWaterDepth`/`TransparentWaterMinOpacity`, the skybox faces, + `RiverTransparencyMultiplier`, `ReflectionPlaneZ`/`ReflectionOn`. + +OpenRA3 has no water mesh or reflection/refraction targets (its terrain is +raymarched), so the model is folded into the terrain pass (`shaders/terrain.frag` +and its HLSL/GLSL-ES/WGSL twins): the ray's water-plane hit takes a scrolling +wave normal built from the retail bump map combined with a de-gridded procedural +wave, Schlick fresnel (F0 = 0.02), a sky reflection and a depth-graded +refraction, SAGE diffuse + specular lighting, a depth-based transparency fade, +and — when the camera is below `ReflectionPlaneZ` — an underwater tint/fog +(`UnderwaterDeferred.fx`). The two retail maps +`art/terrain/ra3_deepocean.tga` (flow/distortion) and `ra3_deepocean_nrm.tga` +(bump normal) are appended as the **last two layers of the terrain atlas** +(`ra3::terrain::build_gpu_terrain`, `water_flow = layer_count - 2`, +`water_normal = layer_count - 1`), so every backend samples them with the +existing atlas binding; absent maps fall back to a neutral layer. True +reflection/refraction render targets are still the next step. +`GPUParticleOceanDisplacement.fx` drives wave displacement from a GPU particle +buffer and has no analogue here. + + The map's `ObjectsList` chunk is a list of nested `Object` assets — `Coord3D`, Z `angle`, `RoadType` u32, a `u16`-prefixed type-name and a property list whose keys index the shared name table (`ra3.map::parse_objects`). Each diff --git a/shaders/dx_terrain.hlsl b/shaders/dx_terrain.hlsl index b600b9a..3c82d3d 100644 --- a/shaders/dx_terrain.hlsl +++ b/shaders/dx_terrain.hlsl @@ -8,6 +8,10 @@ // `Terrain.fx` / OpenSAGE `Terrain.frag` do, so it never restarts at a cell // edge; material boundaries cross-fade with the SAGE blend ramp. // +// The water plane is shaded with a port of the SAGE water effect +// (`Ocean.fx` / OpenSAGE `Water.frag`); the camera-below-water tint stands in +// for `UnderwaterDeferred.fx`. See docs/REVERSE_ENGINEERING.md. +// // The Vulkan push constants (20 floats) become a constant buffer. cbuffer TerrainCB : register(b0) { @@ -15,7 +19,7 @@ cbuffer TerrainCB : register(b0) { float4 params; // x=pitch, y=fov, z=water_z, w=has_water float4 sun; // xyz=sun dir, w=ambient float4 mapinfo; // x=W, y=H, z=unused, w=z_scale - float4 misc; // x=time, y=unused, z=cells per texture repeat, w=aspect + float4 misc; // x=time, y=water kind (0=ocean,1=river), z=cells per repeat, w=aspect }; Texture2D heightmap : register(t0); @@ -27,6 +31,12 @@ SamplerState atlas_smp : register(s2); static const float CELL = 10.0; // must match ra3::terrain::cell_size +// SAGE water model constants (see docs/REVERSE_ENGINEERING.md). +static const float WATER_SCALE = 1.0 / 320.0; +static const float WATER_TRANSPARENT_DEPTH = 10.0; +static const float WATER_MIN_OPACITY = 0.70; +static const float WATER_RIVER_MULTIPLIER = 1.0; + struct VSOut { float4 pos : SV_Position; float2 uv : TEXCOORD0; @@ -104,6 +114,105 @@ float3 sample_layer(uint layer, float wx, float wy) { return atlas.Sample(atlas_smp, float3(float2(wx, wy) / span, l)).rgb; } +// ---- SAGE water (Ocean.fx / RiverWater.fx / Water.frag port) ---------------- + +// Sample an atlas layer by an explicit layer index (the water flow/bump maps +// are appended as the last two layers of the tile atlas). +float3 water_tex(int layer, float2 uv) { + uint w = 0; + uint h = 0; + uint lc = 0; + atlas.GetDimensions(w, h, lc); + float l = (float) clamp(layer, 0, (int) lc - 1); + return atlas.Sample(atlas_smp, float3(uv, l)).rgb; +} + +// Scrolling wave normal on the water plane: the retail bump map (atlas's last +// layer), offset by the flow map (second-last layer) and combined with a +// de-gridded procedural wave so the sun glint is irregular and always moving. +float3 water_normal(float2 world_xy, float time) { + float2 q = world_xy * (WATER_SCALE * 6.0); + float a1 = q.x * 0.80 + q.y * 0.30 + time * 1.10; + float a2 = q.y * 0.95 - q.x * 0.45 - time * 1.30; + float a3 = (q.x + q.y) * 1.60 + time * 2.10; + float a4 = (q.x - q.y) * 2.30 - time * 1.70; + float dx = 0.224 * cos(a1) - 0.099 * cos(a2) + 0.256 * cos(a3) + 0.230 * cos(a4); + float dy = 0.084 * cos(a1) + 0.209 * cos(a2) + 0.256 * cos(a3) - 0.230 * cos(a4); + uint w = 0; + uint h = 0; + uint lc = 0; + atlas.GetDimensions(w, h, lc); + float3 flow = water_tex((int) lc - 2, q - float2(time * 0.010, time * 0.014)) * 2.0 - 1.0; + float3 bump = water_tex((int) lc - 1, q + flow.xy * 0.05 + float2(time * 0.006, time * 0.008)) * 2.0 - 1.0; + float sx = -dx * 0.30 + bump.x * 0.45; + float sy = -dy * 0.30 + bump.y * 0.45; + return normalize(float3(sx, sy, 1.0)); +} + +// Water.frag distortionPower * the flow texture: a small scrolling UV offset. +float water_distortion(float2 world_xy, float time) { + float2 q = world_xy * (WATER_SCALE * 6.0); + return 0.05 * (sin(q.x * 0.9 + time * 0.7) + sin(q.y * 1.1 - time * 0.5)); +} + +// Water.frag GetCloudColor: no cloud texture is bound, so a slow low-frequency +// scroll stands in. +float3 water_cloud(float2 world_xy, float time) { + return float3(0.85 + 0.15 * sin((world_xy.x + world_xy.y) * 0.0007 - time * 0.05)); +} + +// Retail UnderwaterDeferred.fx: absorbs red and fogs with distance. Applied to +// every output while the camera is below the water plane. +float3 apply_underwater(float3 color, float dist, float cam_z, float water_z) { + if (cam_z >= water_z - 0.5) return color; + const float3 absorb = float3(0.35, 0.62, 0.75); + float fog = clamp(1.0 - exp(-dist * 0.00022), 0.0, 0.9); + return lerp(color * absorb, float3(0.02, 0.10, 0.16), fog); +} + +// Water.frag: fresnel-mixed reflection (sky) and refraction (depth-graded +// seabed), SAGE diffuse + specular lighting, cloud term, and a depth-based +// transparency fade. `river` (>0.5) applies RiverTransparencyMultiplier. +float3 water_shade(float3 hitpos, float3 dir, float dist) { + float time = misc.x; + float river = misc.y; + float seabed = world_height(hitpos.x, hitpos.y); + float depth = max(0.0, params.z - seabed); + + float3 n = water_normal(hitpos.xy, time + water_distortion(hitpos.xy, time)); + float3 sun_dir = normalize(sun.xyz); + + // Schlick fresnel, water F0 = 0.02. + float cos_theta = clamp(dot(-dir, n), 0.0, 1.0); + float fresnel = 0.02 + 0.98 * pow(1.0 - cos_theta, 5.0); + + // Reflection: the sky the surface mirrors. Refraction: the seabed, graded + // from shallow to deep and lit by the SAGE diffuse + specular model. + float3 reflection = sky_color(reflect(dir, n)); + float3 shallow = float3(0.10, 0.34, 0.38); + float3 deep = float3(0.02, 0.12, 0.22); + float3 refraction = lerp(shallow, deep, clamp(depth / 40.0, 0.0, 1.0)); + + float ndotl = max(dot(n, sun_dir), 0.0); + float ambient = sun.w; + float3 diffuse = float3(ambient + (1.0 - ambient) * ndotl); + float3 half_v = normalize(sun_dir - dir); + float spec = pow(max(dot(n, half_v), 0.0), 90.0); + + float3 color = lerp(refraction, reflection, clamp(fresnel, 0.0, 1.0)) * diffuse * water_cloud(hitpos.xy, time); + color += float3(1.0, 0.97, 0.9) * spec * 0.45; + + // Depth-based transparency: shallow water shows the seabed, deep water goes + // opaque toward the deep colour. + float alpha = clamp(depth / WATER_TRANSPARENT_DEPTH, 0.0, 1.0) * WATER_MIN_OPACITY; + if (river > 0.5) alpha *= WATER_RIVER_MULTIPLIER; + color = lerp(refraction, color, clamp(alpha + 0.15, 0.0, 1.0)); + + // Distance haze toward the horizon, as the terrain. + float wfog = clamp(1.0 - exp(-dist * 0.00009), 0.0, 0.75); + return lerp(color, sky_color(float3(dir.x, dir.y, 0.0)), wfog); +} + float4 PSMain(VSOut input) : SV_Target { float4 p = cam; float pitch = clamp(params.x, 0.15, 1.45); @@ -127,50 +236,75 @@ float4 PSMain(VSOut input) : SV_Target { float3 dir = normalize(fwd + right * ndc.x * th * aspect + up * ndc.y * th); if (dir.z >= -1e-4) { - return float4(sky_color(dir), 1.0); + return float4(apply_underwater(sky_color(dir), 0.0, cam_pos.z, params.z), 1.0); } - // March the heightfield. The step grows quickly: the map diagonal is only - // ~9000 world units, so marching past ~20000 adds cost without detail, and - // a 6% growth rate more than doubles the worst-case iteration count. - float t = CELL * 0.5; - float dt = CELL * 0.5; + // 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. + float t_enter = 0.0; + float t_exit = 1.0e30; + bool inside = true; + if (abs(dir.x) < 1e-6) { + inside = (cam_pos.x >= 0.0 && cam_pos.x <= world_w); + } else { + float a = (0.0 - cam_pos.x) / dir.x; + float b = (world_w - cam_pos.x) / dir.x; + t_enter = max(t_enter, min(a, b)); + t_exit = min(t_exit, max(a, b)); + } + if (inside) { + if (abs(dir.y) < 1e-6) { + inside = (cam_pos.y >= 0.0 && cam_pos.y <= world_h); + } else { + float a = (0.0 - cam_pos.y) / dir.y; + float b = (world_h - cam_pos.y) / dir.y; + t_enter = max(t_enter, min(a, b)); + t_exit = min(t_exit, max(a, b)); + } + } + if (!inside || t_exit <= 0.0) { + return float4(apply_underwater(sky_color(dir), 0.0, cam_pos.z, params.z), 1.0); + } + + // March the heightfield cell by cell: the step is never longer than the + // time to cross one cell (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 as the camera pans. + float horiz = max(abs(dir.x), abs(dir.y)); + float cell_step = min(CELL / max(horiz, 1e-4), CELL * 32.0); + float t = max(t_enter, CELL * 0.5); float prev = t; bool hit = false; float hit_t = 0.0; - for (int i = 0; i < 256 && t < 20000.0; ++i) { + for (int i = 0; i < 1024 && t <= t_exit; ++i) { float3 w = cam_pos + dir * t; - if (w.x < 0.0 || w.y < 0.0 || w.x >= world_w || w.y >= world_h) { - prev = t; - dt *= 1.10; - t += dt; - continue; - } - if (params.w > 0.5 && w.z <= params.z) { - hit = true; - hit_t = t; - break; - } - if (w.z <= world_height(w.x, w.y)) { + float h = world_height(w.x, w.y); + float surface = (params.w > 0.5) ? max(h, params.z) : h; + if (w.z <= surface) { hit = true; hit_t = t; break; } + float clearance = (w.z - surface) / max(-dir.z, 1e-4); prev = t; - dt *= 1.10; - t += dt; + t += clamp(clearance, cell_step, cell_step * 8.0); } if (!hit) { - return float4(sky_color(dir), 1.0); + return float4(apply_underwater(sky_color(dir), 0.0, cam_pos.z, params.z), 1.0); } + // Refine the first crossing; with a sub-cell bracket this converges to the + // exact surface point. float lo = prev; float hi = hit_t; - for (int i = 0; i < 6; ++i) { + for (int i = 0; i < 18; ++i) { float mid = 0.5 * (lo + hi); float3 w = cam_pos + dir * mid; - bool water = params.w > 0.5 && w.z <= params.z; - if (water || w.z <= world_height(w.x, w.y)) { + float h = world_height(w.x, w.y); + float surface = (params.w > 0.5) ? max(h, params.z) : h; + if (w.z <= surface) { hi = mid; } else { lo = mid; @@ -182,21 +316,7 @@ float4 PSMain(VSOut input) : SV_Target { float ambient = sun.w; if (params.w > 0.5 && hitpos.z <= params.z + 0.01) { - // Water: animated normal from a procedural wave, sky reflection + fresnel. - float time = misc.x; - float2 q = hitpos.xy * 0.015; - float nx = sin(q.x * 1.3 + time * 1.7) + 0.5 * sin(q.x * 3.1 - time * 2.3); - float ny = sin(q.y * 1.1 - time * 1.3) + 0.5 * sin(q.y * 2.7 + time * 1.9); - float3 n = normalize(float3(nx * 0.06, ny * 0.06, 1.0)); - float fres = pow(1.0 - clamp(-dir.z, 0.0, 1.0), 3.0); - float3 deep = float3(0.03, 0.16, 0.28); - float3 refl = sky_color(reflect(dir, n)); - float lam = max(0.0, dot(n, sun_dir)); - float3 water = lerp(deep, refl, clamp(0.25 + 0.55 * fres, 0.0, 0.9)); - water += float3(1.0, 0.98, 0.9) * pow(lam, 64.0) * 0.6; // sun glint - float wfog = clamp(1.0 - exp(-hi * 0.00009), 0.0, 0.75); - water = lerp(water, sky_color(float3(dir.x, dir.y, 0.0)), wfog); - return float4(water, 1.0); + return float4(apply_underwater(water_shade(hitpos, dir, hi), hi, cam_pos.z, params.z), 1.0); } // Terrain: read the per-cell blend record, sample the base/blend/three-way @@ -234,5 +354,5 @@ float4 PSMain(VSOut input) : SV_Target { // Distance haze toward the horizon so the map edge blends into the sky. float fog = clamp(1.0 - exp(-hi * 0.00009), 0.0, 0.75); lit = lerp(lit, sky_color(float3(dir.x, dir.y, 0.0)), fog); - return float4(lit, 1.0); + return float4(apply_underwater(lit, hi, cam_pos.z, params.z), 1.0); } diff --git a/shaders/generated/terrain.frag.spv b/shaders/generated/terrain.frag.spv index 758e58935b9fd698112926bb0575ba422dfbb613..b291cdf67256dc5a2102b8e54df457282059d41c 100644 GIT binary patch literal 34712 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 JQ=4n^{{RRn5-k7# diff --git a/shaders/terrain.frag b/shaders/terrain.frag index 3e0d112..3215a81 100644 --- a/shaders/terrain.frag +++ b/shaders/terrain.frag @@ -8,6 +8,10 @@ // The material is sampled **continuously** (`uv = cell / (2 * cellSize)`), as the // retail `Terrain.fx` / OpenSAGE `Terrain.frag` do, so it never restarts at a // cell edge; material boundaries cross-fade with the SAGE blend ramp. +// +// The water plane is shaded with a port of the SAGE water effect +// (`Ocean.fx` / OpenSAGE `Water.frag`); the camera-below-water tint stands in +// for `UnderwaterDeferred.fx`. See docs/REVERSE_ENGINEERING.md. layout(binding = 0) uniform sampler2D heightmap; layout(binding = 1) uniform sampler2D celldata; layout(binding = 2) uniform sampler2DArray atlas; @@ -17,7 +21,7 @@ layout(push_constant) uniform Push { vec4 params; // x=pitch, y=fov, z=water_z, w=has_water vec4 sun; // xyz=sun dir, w=ambient vec4 mapinfo; // x=W, y=H, z=unused, w=z_scale - vec4 misc; // x=time, y=unused, z=cells per texture repeat, w=aspect + vec4 misc; // x=time, y=water kind (0=ocean,1=river), z=cells per repeat, w=aspect } pc; layout(location = 0) in vec2 in_uv; @@ -30,6 +34,12 @@ const float CELL = 10.0; // must match ra3::terrain::cell_size const float NEAR = 10.0; const float FAR = 60000.0; +// SAGE water model constants (see docs/REVERSE_ENGINEERING.md). +const float WATER_SCALE = 1.0 / 320.0; // Water.frag: worldPos.xy / 320 +const float WATER_TRANSPARENT_DEPTH = 10.0; // WaterTransparency.TransparentWaterDepth +const float WATER_MIN_OPACITY = 0.70; // WaterTransparency.TransparentWaterMinOpacity +const float WATER_RIVER_MULTIPLIER = 1.0; // WaterTransparency.RiverTransparencyMultiplier + float height_at(ivec2 c) { c = clamp(c, ivec2(0), ivec2(pc.mapinfo.xy) - 1); return texelFetch(heightmap, c, 0).r * 65535.0 * pc.mapinfo.w; @@ -92,6 +102,99 @@ vec3 sample_layer(uint layer, float wx, float wy) { return texture(atlas, vec3(vec2(wx, wy) / span, l)).rgb; } +// ---- SAGE water (Ocean.fx / RiverWater.fx / Water.frag port) ---------------- + +// Sample an atlas layer by an explicit float layer index (the water flow/bump +// maps are appended as the last two layers of the tile atlas). +vec3 water_tex(int layer, vec2 uv) { + int lc = textureSize(atlas, 0).z; + float l = float(clamp(layer, 0, max(lc - 1, 0))); + return texture(atlas, vec3(uv, l)).rgb; +} + +// Scrolling wave normal on the water plane: the retail bump map (atlas's last +// layer), offset by the flow map (second-last layer) and combined with a +// de-gridded procedural wave so the sun glint is irregular and always moving. +vec3 water_normal(vec2 world_xy, float time) { + vec2 q = world_xy * (WATER_SCALE * 6.0); + float a1 = q.x * 0.80 + q.y * 0.30 + time * 1.10; + float a2 = q.y * 0.95 - q.x * 0.45 - time * 1.30; + float a3 = (q.x + q.y) * 1.60 + time * 2.10; + float a4 = (q.x - q.y) * 2.30 - time * 1.70; + float dx = 0.224 * cos(a1) - 0.099 * cos(a2) + 0.256 * cos(a3) + 0.230 * cos(a4); + float dy = 0.084 * cos(a1) + 0.209 * cos(a2) + 0.256 * cos(a3) - 0.230 * cos(a4); + int lc = textureSize(atlas, 0).z; + vec3 flow = water_tex(lc - 2, q - vec2(time * 0.010, time * 0.014)) * 2.0 - 1.0; + vec3 bump = water_tex(lc - 1, q + flow.xy * 0.05 + vec2(time * 0.006, time * 0.008)) * 2.0 - 1.0; + float sx = -dx * 0.30 + bump.x * 0.45; + float sy = -dy * 0.30 + bump.y * 0.45; + return normalize(vec3(sx, sy, 1.0)); +} + +// Water.frag distortionPower * the flow texture: a small scrolling UV offset. +float water_distortion(vec2 world_xy, float time) { + vec2 q = world_xy * (WATER_SCALE * 6.0); + return 0.05 * (sin(q.x * 0.9 + time * 0.7) + sin(q.y * 1.1 - time * 0.5)); +} + +// Water.frag GetCloudColor: no cloud texture is bound, so a slow low-frequency +// scroll stands in. +vec3 water_cloud(vec2 world_xy, float time) { + return vec3(0.85 + 0.15 * sin((world_xy.x + world_xy.y) * 0.0007 - time * 0.05)); +} + +// Retail UnderwaterDeferred.fx: absorbs red and fogs with distance. Applied to +// every output while the camera is below the water plane. +vec3 apply_underwater(vec3 color, float dist, float cam_z, float water_z) { + if (cam_z >= water_z - 0.5) return color; + const vec3 absorb = vec3(0.35, 0.62, 0.75); + float fog = clamp(1.0 - exp(-dist * 0.00022), 0.0, 0.9); + return mix(color * absorb, vec3(0.02, 0.10, 0.16), fog); +} + +// Water.frag: fresnel-mixed reflection (sky) and refraction (depth-graded +// seabed), SAGE diffuse + specular lighting, cloud term, and a depth-based +// transparency fade. `river` (>0.5) applies RiverTransparencyMultiplier. +vec3 water_shade(vec3 hitpos, vec3 dir, float dist) { + float time = pc.misc.x; + float river = pc.misc.y; + float seabed = world_height(hitpos.x, hitpos.y); + float depth = max(0.0, pc.params.z - seabed); + + vec3 n = water_normal(hitpos.xy, time + water_distortion(hitpos.xy, time)); + vec3 sun = normalize(pc.sun.xyz); + + // Schlick fresnel, water F0 = 0.02. + float cos_theta = clamp(dot(-dir, n), 0.0, 1.0); + float fresnel = 0.02 + 0.98 * pow(1.0 - cos_theta, 5.0); + + // Reflection: the sky the surface mirrors. Refraction: the seabed, graded + // from shallow to deep and lit by the SAGE diffuse + specular model. + vec3 reflection = sky_color(reflect(dir, n)); + vec3 shallow = vec3(0.10, 0.34, 0.38); + vec3 deep = vec3(0.02, 0.12, 0.22); + vec3 refraction = mix(shallow, deep, clamp(depth / 40.0, 0.0, 1.0)); + + float ndotl = max(dot(n, sun), 0.0); + float ambient = pc.sun.w; + vec3 diffuse = vec3(ambient + (1.0 - ambient) * ndotl); + vec3 half_v = normalize(sun - dir); + float spec = pow(max(dot(n, half_v), 0.0), 90.0); + + vec3 color = mix(refraction, reflection, clamp(fresnel, 0.0, 1.0)) * diffuse * water_cloud(hitpos.xy, time); + color += vec3(1.0, 0.97, 0.9) * spec * 0.45; + + // Depth-based transparency: shallow water shows the seabed, deep water goes + // opaque toward the deep colour. + float alpha = clamp(depth / WATER_TRANSPARENT_DEPTH, 0.0, 1.0) * WATER_MIN_OPACITY; + if (river > 0.5) alpha *= WATER_RIVER_MULTIPLIER; + color = mix(refraction, color, clamp(alpha + 0.15, 0.0, 1.0)); + + // Distance haze toward the horizon, as the terrain. + float wfog = clamp(1.0 - exp(-dist * 0.00009), 0.0, 0.75); + return mix(color, sky_color(vec3(dir.x, dir.y, 0.0)), wfog); +} + void main() { vec4 p = pc.cam; float pitch = clamp(pc.params.x, 0.15, 1.45); @@ -118,35 +221,71 @@ void main() { if (dir.z >= -1e-4) { gl_FragDepth = 1.0; - out_color = vec4(sky_color(dir), 1.0); + out_color = vec4(apply_underwater(sky_color(dir), 0.0, cam.z, pc.params.z), 1.0); return; } - // March the heightfield. The step grows quickly: the map diagonal is only - // ~9000 world units, so marching past ~20000 adds cost without detail, and - // a 6% growth rate more than doubles the worst-case iteration count. - float t = CELL * 0.5; - float dt = CELL * 0.5; + // 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. + float t_enter = 0.0; + float t_exit = 1.0e30; + bool inside = true; + if (abs(dir.x) < 1e-6) { + inside = (cam.x >= 0.0 && cam.x <= world_w); + } else { + float a = (0.0 - cam.x) / dir.x; + float b = (world_w - cam.x) / dir.x; + t_enter = max(t_enter, min(a, b)); + t_exit = min(t_exit, max(a, b)); + } + if (inside) { + if (abs(dir.y) < 1e-6) { + inside = (cam.y >= 0.0 && cam.y <= world_h); + } else { + float a = (0.0 - cam.y) / dir.y; + float b = (world_h - cam.y) / dir.y; + t_enter = max(t_enter, min(a, b)); + t_exit = min(t_exit, max(a, b)); + } + } + if (!inside || t_exit <= 0.0) { + gl_FragDepth = 1.0; + out_color = vec4(apply_underwater(sky_color(dir), 0.0, cam.z, pc.params.z), 1.0); + return; + } + + // March the heightfield cell by cell: the step is never longer than the + // time to cross one cell (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 as the camera pans. + float horiz = max(abs(dir.x), abs(dir.y)); + float cell_step = min(CELL / max(horiz, 1e-4), CELL * 32.0); + float t = max(t_enter, CELL * 0.5); float prev = t; bool hit = false; float hit_t = 0.0; - for (int i = 0; i < 256 && t < 20000.0; ++i) { + for (int i = 0; i < 1024 && t <= t_exit; ++i) { vec3 w = cam + dir * t; - if (w.x < 0.0 || w.y < 0.0 || w.x >= world_w || w.y >= world_h) { - prev = t; dt *= 1.10; t += dt; continue; - } - if (pc.params.w > 0.5 && w.z <= pc.params.z) { hit = true; hit_t = t; break; } - if (w.z <= world_height(w.x, w.y)) { hit = true; hit_t = t; break; } - prev = t; dt *= 1.10; t += dt; + float h = world_height(w.x, w.y); + float surface = (pc.params.w > 0.5) ? max(h, pc.params.z) : h; + if (w.z <= surface) { hit = true; hit_t = t; break; } + float clearance = (w.z - surface) / max(-dir.z, 1e-4); + prev = t; + t += clamp(clearance, cell_step, cell_step * 8.0); } - if (!hit) { gl_FragDepth = 1.0; out_color = vec4(sky_color(dir), 1.0); return; } + if (!hit) { gl_FragDepth = 1.0; out_color = vec4(apply_underwater(sky_color(dir), 0.0, cam.z, pc.params.z), 1.0); return; } + // Refine the first crossing; with a sub-cell bracket this converges to the + // exact surface point. float lo = prev, hi = hit_t; - for (int i = 0; i < 6; ++i) { + for (int i = 0; i < 18; ++i) { float mid = 0.5 * (lo + hi); vec3 w = cam + dir * mid; - bool water = pc.params.w > 0.5 && w.z <= pc.params.z; - if (water || w.z <= world_height(w.x, w.y)) hi = mid; else lo = mid; + float h = world_height(w.x, w.y); + float surface = (pc.params.w > 0.5) ? max(h, pc.params.z) : h; + if (w.z <= surface) hi = mid; else lo = mid; } vec3 hitpos = cam + dir * hi; @@ -158,21 +297,7 @@ void main() { float ambient = pc.sun.w; if (pc.params.w > 0.5 && hitpos.z <= pc.params.z + 0.01) { - // Water: animated normal from a procedural wave, sky reflection + fresnel. - float time = pc.misc.x; - vec2 q = hitpos.xy * 0.015; - float nx = sin(q.x * 1.3 + time * 1.7) + 0.5 * sin(q.x * 3.1 - time * 2.3); - float ny = sin(q.y * 1.1 - time * 1.3) + 0.5 * sin(q.y * 2.7 + time * 1.9); - vec3 n = normalize(vec3(nx * 0.06, ny * 0.06, 1.0)); - float fres = pow(1.0 - clamp(-dir.z, 0.0, 1.0), 3.0); - vec3 deep = vec3(0.03, 0.16, 0.28); - vec3 refl = sky_color(reflect(dir, n)); - float lam = max(0.0, dot(n, sun)); - vec3 water = mix(deep, refl, clamp(0.25 + 0.55 * fres, 0.0, 0.9)); - water += vec3(1.0, 0.98, 0.9) * pow(lam, 64.0) * 0.6; // sun glint - float wfog = clamp(1.0 - exp(-hi * 0.00009), 0.0, 0.75); - water = mix(water, sky_color(vec3(dir.x, dir.y, 0.0)), wfog); - out_color = vec4(water, 1.0); + out_color = vec4(apply_underwater(water_shade(hitpos, dir, hi), hi, cam.z, pc.params.z), 1.0); return; } @@ -211,5 +336,5 @@ void main() { // Distance haze toward the horizon so the map edge blends into the sky. float fog = clamp(1.0 - exp(-hi * 0.00009), 0.0, 0.75); lit = mix(lit, sky_color(vec3(dir.x, dir.y, 0.0)), fog); - out_color = vec4(lit, 1.0); + out_color = vec4(apply_underwater(lit, hi, cam.z, pc.params.z), 1.0); } diff --git a/shaders/webgl_terrain_frag.glsl b/shaders/webgl_terrain_frag.glsl index 732339d..d4d87ab 100644 --- a/shaders/webgl_terrain_frag.glsl +++ b/shaders/webgl_terrain_frag.glsl @@ -24,6 +24,12 @@ out vec4 frag_color; const float CELL = 10.0; // must match ra3::terrain::cell_size +// SAGE water model constants (see docs/REVERSE_ENGINEERING.md). +const float WATER_SCALE = 1.0 / 320.0; +const float WATER_TRANSPARENT_DEPTH = 10.0; +const float WATER_MIN_OPACITY = 0.70; +const float WATER_RIVER_MULTIPLIER = 1.0; + float height_at(ivec2 c) { c = clamp(c, ivec2(0), ivec2(u_mapinfo.xy) - 1); return texelFetch(u_heightmap, c, 0).r * 65535.0 * u_mapinfo.w; @@ -82,6 +88,91 @@ vec3 sample_layer(uint layer, float wx, float wy) { return texture(u_atlas, vec3(vec2(wx, wy) / span, l)).bgr; } +// ---- SAGE water (Ocean.fx / RiverWater.fx / Water.frag port) ---------------- + +// Sample an atlas layer by an explicit layer index (the water flow/bump maps +// are appended as the last two layers of the tile atlas). The atlas is +// 0xAARRGGBB, so `.bgr` restores RGB (as `sample_layer`). +vec3 water_tex(int layer, vec2 uv) { + int lc = textureSize(u_atlas, 0).z; + float l = float(clamp(layer, 0, max(lc - 1, 0))); + return texture(u_atlas, vec3(uv, l)).bgr; +} + +// Scrolling wave normal on the water plane: the retail bump map (atlas's last +// layer), offset by the flow map (second-last layer) and combined with a +// de-gridded procedural wave so the sun glint is irregular and always moving. +vec3 water_normal(vec2 world_xy, float time) { + vec2 q = world_xy * (WATER_SCALE * 6.0); + float a1 = q.x * 0.80 + q.y * 0.30 + time * 1.10; + float a2 = q.y * 0.95 - q.x * 0.45 - time * 1.30; + float a3 = (q.x + q.y) * 1.60 + time * 2.10; + float a4 = (q.x - q.y) * 2.30 - time * 1.70; + float dx = 0.224 * cos(a1) - 0.099 * cos(a2) + 0.256 * cos(a3) + 0.230 * cos(a4); + float dy = 0.084 * cos(a1) + 0.209 * cos(a2) + 0.256 * cos(a3) - 0.230 * cos(a4); + int lc = textureSize(u_atlas, 0).z; + vec3 flow = water_tex(lc - 2, q - vec2(time * 0.010, time * 0.014)) * 2.0 - 1.0; + vec3 bump = water_tex(lc - 1, q + flow.xy * 0.05 + vec2(time * 0.006, time * 0.008)) * 2.0 - 1.0; + float sx = -dx * 0.30 + bump.x * 0.45; + float sy = -dy * 0.30 + bump.y * 0.45; + return normalize(vec3(sx, sy, 1.0)); +} + +// Water.frag distortionPower * the flow texture: a small scrolling UV offset. +float water_distortion(vec2 world_xy, float time) { + vec2 q = world_xy * (WATER_SCALE * 6.0); + return 0.05 * (sin(q.x * 0.9 + time * 0.7) + sin(q.y * 1.1 - time * 0.5)); +} + +// Water.frag GetCloudColor: no cloud texture is bound, so a slow scroll stands in. +vec3 water_cloud(vec2 world_xy, float time) { + return vec3(0.85 + 0.15 * sin((world_xy.x + world_xy.y) * 0.0007 - time * 0.05)); +} + +// Retail UnderwaterDeferred.fx: absorbs red and fogs with distance. +vec3 apply_underwater(vec3 color, float dist, float cam_z, float water_z) { + if (cam_z >= water_z - 0.5) return color; + const vec3 absorb = vec3(0.35, 0.62, 0.75); + float fog = clamp(1.0 - exp(-dist * 0.00022), 0.0, 0.9); + return mix(color * absorb, vec3(0.02, 0.10, 0.16), fog); +} + +// Water.frag: fresnel-mixed sky reflection and depth-graded seabed refraction, +// SAGE diffuse + specular lighting, cloud term, depth-based transparency fade. +vec3 water_shade(vec3 hitpos, vec3 dir, float dist) { + float time = u_misc.x; + float river = u_misc.y; + float seabed = world_height(hitpos.x, hitpos.y); + float depth = max(0.0, u_params.z - seabed); + + vec3 n = water_normal(hitpos.xy, time + water_distortion(hitpos.xy, time)); + vec3 sun_dir = normalize(u_sun.xyz); + + float cos_theta = clamp(dot(-dir, n), 0.0, 1.0); + float fresnel = 0.02 + 0.98 * pow(1.0 - cos_theta, 5.0); + + vec3 reflection = sky_color(reflect(dir, n)); + vec3 shallow = vec3(0.10, 0.34, 0.38); + vec3 deep = vec3(0.02, 0.12, 0.22); + vec3 refraction = mix(shallow, deep, clamp(depth / 40.0, 0.0, 1.0)); + + float ndotl = max(dot(n, sun_dir), 0.0); + float ambient = u_sun.w; + vec3 diffuse = vec3(ambient + (1.0 - ambient) * ndotl); + vec3 half_v = normalize(sun_dir - dir); + float spec = pow(max(dot(n, half_v), 0.0), 90.0); + + vec3 color = mix(refraction, reflection, clamp(fresnel, 0.0, 1.0)) * diffuse * water_cloud(hitpos.xy, time); + color += vec3(1.0, 0.97, 0.9) * spec * 0.45; + + float alpha = clamp(depth / WATER_TRANSPARENT_DEPTH, 0.0, 1.0) * WATER_MIN_OPACITY; + if (river > 0.5) alpha *= WATER_RIVER_MULTIPLIER; + color = mix(refraction, color, clamp(alpha + 0.15, 0.0, 1.0)); + + float wfog = clamp(1.0 - exp(-dist * 0.00009), 0.0, 0.75); + return mix(color, sky_color(vec3(dir.x, dir.y, 0.0)), wfog); +} + void main() { vec4 p = u_cam; float pitch = clamp(u_params.x, 0.15, 1.45); @@ -105,52 +196,78 @@ void main() { vec3 dir = normalize(fwd + right * ndc.x * th * aspect + up * ndc.y * th); if (dir.z >= -1e-4) { - frag_color = vec4(sky_color(dir), 1.0); + frag_color = vec4(apply_underwater(sky_color(dir), 0.0, cam.z, u_params.z), 1.0); return; } - // March the heightfield. The step grows quickly: the map diagonal is only - // ~9000 world units, so marching past ~20000 adds cost without detail, and - // a 6% growth rate more than doubles the worst-case iteration count. - float t = CELL * 0.5; - float dt = CELL * 0.5; + // 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. + float t_enter = 0.0; + float t_exit = 1.0e30; + bool inside = true; + if (abs(dir.x) < 1e-6) { + inside = (cam.x >= 0.0 && cam.x <= world_w); + } else { + float a = (0.0 - cam.x) / dir.x; + float b = (world_w - cam.x) / dir.x; + t_enter = max(t_enter, min(a, b)); + t_exit = min(t_exit, max(a, b)); + } + if (inside) { + if (abs(dir.y) < 1e-6) { + inside = (cam.y >= 0.0 && cam.y <= world_h); + } else { + float a = (0.0 - cam.y) / dir.y; + float b = (world_h - cam.y) / dir.y; + t_enter = max(t_enter, min(a, b)); + t_exit = min(t_exit, max(a, b)); + } + } + if (!inside || t_exit <= 0.0) { + frag_color = vec4(apply_underwater(sky_color(dir), 0.0, cam.z, u_params.z), 1.0); + return; + } + + // March the heightfield cell by cell: the step is never longer than the + // time to cross one cell (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 as the camera pans. + float horiz = max(abs(dir.x), abs(dir.y)); + float cell_step = min(CELL / max(horiz, 1e-4), CELL * 32.0); + float t = max(t_enter, CELL * 0.5); float prev = t; bool hit = false; float hit_t = 0.0; - for (int i = 0; i < 256 && t < 20000.0; ++i) { + for (int i = 0; i < 1024 && t <= t_exit; ++i) { vec3 w = cam + dir * t; - if (w.x < 0.0 || w.y < 0.0 || w.x >= world_w || w.y >= world_h) { - prev = t; - dt *= 1.10; - t += dt; - continue; - } - if (u_params.w > 0.5 && w.z <= u_params.z) { - hit = true; - hit_t = t; - break; - } - if (w.z <= world_height(w.x, w.y)) { + float h = world_height(w.x, w.y); + float surface = (u_params.w > 0.5) ? max(h, u_params.z) : h; + if (w.z <= surface) { hit = true; hit_t = t; break; } + float clearance = (w.z - surface) / max(-dir.z, 1e-4); prev = t; - dt *= 1.10; - t += dt; + t += clamp(clearance, cell_step, cell_step * 8.0); } if (!hit) { - frag_color = vec4(sky_color(dir), 1.0); + frag_color = vec4(apply_underwater(sky_color(dir), 0.0, cam.z, u_params.z), 1.0); return; } + // Refine the first crossing; with a sub-cell bracket this converges to the + // exact surface point. float lo = prev; float hi = hit_t; - for (int i = 0; i < 6; ++i) { + for (int i = 0; i < 18; ++i) { float mid = 0.5 * (lo + hi); vec3 w = cam + dir * mid; - bool water = u_params.w > 0.5 && w.z <= u_params.z; - if (water || w.z <= world_height(w.x, w.y)) { + float h = world_height(w.x, w.y); + float surface = (u_params.w > 0.5) ? max(h, u_params.z) : h; + if (w.z <= surface) { hi = mid; } else { lo = mid; @@ -162,20 +279,7 @@ void main() { float ambient = u_sun.w; if (u_params.w > 0.5 && hitpos.z <= u_params.z + 0.01) { - float time = u_misc.x; - vec2 q = hitpos.xy * 0.015; - float nx = sin(q.x * 1.3 + time * 1.7) + 0.5 * sin(q.x * 3.1 - time * 2.3); - float ny = sin(q.y * 1.1 - time * 1.3) + 0.5 * sin(q.y * 2.7 + time * 1.9); - vec3 n = normalize(vec3(nx * 0.06, ny * 0.06, 1.0)); - float fres = pow(1.0 - clamp(-dir.z, 0.0, 1.0), 3.0); - vec3 deep = vec3(0.03, 0.16, 0.28); - vec3 refl = sky_color(reflect(dir, n)); - float lam = max(0.0, dot(n, sun)); - vec3 water = mix(deep, refl, clamp(0.25 + 0.55 * fres, 0.0, 0.9)); - water += vec3(1.0, 0.98, 0.9) * pow(lam, 64.0) * 0.6; - float wfog = clamp(1.0 - exp(-hi * 0.00009), 0.0, 0.75); - water = mix(water, sky_color(vec3(dir.x, dir.y, 0.0)), wfog); - frag_color = vec4(water, 1.0); + frag_color = vec4(apply_underwater(water_shade(hitpos, dir, hi), hi, cam.z, u_params.z), 1.0); return; } @@ -210,5 +314,5 @@ void main() { vec3 lit = albedo * (ambient + (1.0 - ambient) * lambert); float fog = clamp(1.0 - exp(-hi * 0.00009), 0.0, 0.75); lit = mix(lit, sky_color(vec3(dir.x, dir.y, 0.0)), fog); - frag_color = vec4(lit, 1.0); + frag_color = vec4(apply_underwater(lit, hi, cam.z, u_params.z), 1.0); } diff --git a/shaders/webgpu_terrain.wgsl b/shaders/webgpu_terrain.wgsl index 3fa072a..d695295 100644 --- a/shaders/webgpu_terrain.wgsl +++ b/shaders/webgpu_terrain.wgsl @@ -17,6 +17,12 @@ struct TerrainUniforms { const CELL: f32 = 10.0; // must match ra3::terrain::cell_size +// SAGE water model constants (see docs/REVERSE_ENGINEERING.md). +const WATER_SCALE: f32 = 1.0 / 320.0; +const WATER_TRANSPARENT_DEPTH: f32 = 10.0; +const WATER_MIN_OPACITY: f32 = 0.70; +const WATER_RIVER_MULTIPLIER: f32 = 1.0; + fn cam_uniform() -> vec4 { return u.data[0]; } // x=target_x, y=target_y, z=yaw, w=height fn params_uniform() -> vec4 { return u.data[1]; } // x=pitch, y=fov, z=water_z, w=has_water fn sun_uniform() -> vec4 { return u.data[2]; } // xyz=sun dir, w=ambient @@ -100,6 +106,92 @@ fn sample_layer(layer: u32, wx: f32, wy: f32) -> vec3 { return vec3(c.b, c.g, c.r); } +// ---- SAGE water (Ocean.fx / RiverWater.fx / Water.frag port) ---------------- + +// Sample an atlas layer by an explicit layer index (the water flow/bump maps +// are appended as the last two layers of the tile atlas). The atlas is +// 0xAARRGGBB, so the b/g/r swap restores RGB (as `sample_layer`). +fn water_tex(layer: i32, uv: vec2) -> vec3 { + let lc = i32(textureNumLayers(u_atlas)); + let l = clamp(layer, 0, max(lc - 1, 0)); + let c = textureSampleLevel(u_atlas, u_atlas_samp, uv, l, 0.0); + return vec3(c.b, c.g, c.r); +} + +// Scrolling wave normal on the water plane: the retail bump map (atlas's last +// layer), offset by the flow map (second-last layer) and combined with a +// de-gridded procedural wave so the sun glint is irregular and always moving. +fn water_normal(world_xy: vec2, time: f32) -> vec3 { + let q = world_xy * (WATER_SCALE * 6.0); + let a1 = q.x * 0.80 + q.y * 0.30 + time * 1.10; + let a2 = q.y * 0.95 - q.x * 0.45 - time * 1.30; + let a3 = (q.x + q.y) * 1.60 + time * 2.10; + let a4 = (q.x - q.y) * 2.30 - time * 1.70; + let dx = 0.224 * cos(a1) - 0.099 * cos(a2) + 0.256 * cos(a3) + 0.230 * cos(a4); + let dy = 0.084 * cos(a1) + 0.209 * cos(a2) + 0.256 * cos(a3) - 0.230 * cos(a4); + let lc = i32(textureNumLayers(u_atlas)); + let flow = water_tex(lc - 2, q - vec2(time * 0.010, time * 0.014)) * 2.0 - 1.0; + let bump = water_tex(lc - 1, q + flow.xy * 0.05 + vec2(time * 0.006, time * 0.008)) * 2.0 - 1.0; + let sx = -dx * 0.30 + bump.x * 0.45; + let sy = -dy * 0.30 + bump.y * 0.45; + return normalize(vec3(sx, sy, 1.0)); +} + +// Water.frag distortionPower * the flow texture: a small scrolling UV offset. +fn water_distortion(world_xy: vec2, time: f32) -> f32 { + let q = world_xy * (WATER_SCALE * 6.0); + return 0.05 * (sin(q.x * 0.9 + time * 0.7) + sin(q.y * 1.1 - time * 0.5)); +} + +// Water.frag GetCloudColor: no cloud texture is bound, so a slow scroll stands in. +fn water_cloud(world_xy: vec2, time: f32) -> vec3 { + return vec3(0.85 + 0.15 * sin((world_xy.x + world_xy.y) * 0.0007 - time * 0.05)); +} + +// Retail UnderwaterDeferred.fx: absorbs red and fogs with distance. +fn apply_underwater(color: vec3, distance: f32, cam_z: f32, water_z: f32) -> vec3 { + if (cam_z >= water_z - 0.5) { return color; } + let absorb = vec3(0.35, 0.62, 0.75); + let fog = clamp(1.0 - exp(-distance * 0.00022), 0.0, 0.9); + return mix(color * absorb, vec3(0.02, 0.10, 0.16), fog); +} + +// Water.frag: fresnel-mixed sky reflection and depth-graded seabed refraction, +// SAGE diffuse + specular lighting, cloud term, depth-based transparency fade. +fn water_shade(hitpos: vec3, dir: vec3, distance: f32) -> vec3 { + let time = misc_uniform().x; + let river = misc_uniform().y; + let seabed = world_height(hitpos.x, hitpos.y); + let depth = max(0.0, params_uniform().z - seabed); + + let n = water_normal(hitpos.xy, time + water_distortion(hitpos.xy, time)); + let sun_dir = normalize(sun_uniform().xyz); + + let cos_theta = clamp(dot(-dir, n), 0.0, 1.0); + let fresnel = 0.02 + 0.98 * pow(1.0 - cos_theta, 5.0); + + let reflection = sky_color(reflect(dir, n)); + let shallow = vec3(0.10, 0.34, 0.38); + let deep = vec3(0.02, 0.12, 0.22); + let refraction = mix(shallow, deep, clamp(depth / 40.0, 0.0, 1.0)); + + let ndotl = max(dot(n, sun_dir), 0.0); + let ambient = sun_uniform().w; + let diffuse = vec3(ambient + (1.0 - ambient) * ndotl); + let half_v = normalize(sun_dir - dir); + let spec = pow(max(dot(n, half_v), 0.0), 90.0); + + var color = mix(refraction, reflection, clamp(fresnel, 0.0, 1.0)) * diffuse * water_cloud(hitpos.xy, time); + color += vec3(1.0, 0.97, 0.9) * spec * 0.45; + + var alpha = clamp(depth / WATER_TRANSPARENT_DEPTH, 0.0, 1.0) * WATER_MIN_OPACITY; + if (river > 0.5) { alpha *= WATER_RIVER_MULTIPLIER; } + color = mix(refraction, color, clamp(alpha + 0.15, 0.0, 1.0)); + + let wfog = clamp(1.0 - exp(-distance * 0.00009), 0.0, 0.75); + return mix(color, sky_color(vec3(dir.x, dir.y, 0.0)), wfog); +} + @fragment fn fs_main(in: TerrainOut) -> @location(0) vec4 { let p = cam_uniform(); @@ -124,49 +216,75 @@ fn fs_main(in: TerrainOut) -> @location(0) vec4 { let dir = normalize(fwd + right * ndc.x * th * aspect + up * ndc.y * th); if (dir.z >= -1e-4) { - return vec4(sky_color(dir), 1.0); + return vec4(apply_underwater(sky_color(dir), 0.0, cam.z, params_uniform().z), 1.0); } - // March the heightfield. The step grows quickly: the map diagonal is only - // ~9000 world units, so marching past ~20000 adds cost without detail. - var t = CELL * 0.5; - var dt = CELL * 0.5; + // 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. + var t_enter = 0.0; + var t_exit = 1.0e30; + var inside = true; + if (abs(dir.x) < 1e-6) { + inside = (cam.x >= 0.0 && cam.x <= world_w); + } else { + let a = (0.0 - cam.x) / dir.x; + let b = (world_w - cam.x) / dir.x; + t_enter = max(t_enter, min(a, b)); + t_exit = min(t_exit, max(a, b)); + } + if (inside) { + if (abs(dir.y) < 1e-6) { + inside = (cam.y >= 0.0 && cam.y <= world_h); + } else { + let a = (0.0 - cam.y) / dir.y; + let b = (world_h - cam.y) / dir.y; + t_enter = max(t_enter, min(a, b)); + t_exit = min(t_exit, max(a, b)); + } + } + if (!inside || t_exit <= 0.0) { + return vec4(apply_underwater(sky_color(dir), 0.0, cam.z, params_uniform().z), 1.0); + } + + // March the heightfield cell by cell: the step is never longer than the + // time to cross one cell (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 as the camera pans. + let horiz = max(abs(dir.x), abs(dir.y)); + let cell_step = min(CELL / max(horiz, 1e-4), CELL * 32.0); + var t = max(t_enter, CELL * 0.5); var prev = t; var hit = false; var hit_t = 0.0; - for (var i = 0; i < 256 && t < 20000.0; i = i + 1) { + for (var i = 0; i < 1024 && t <= t_exit; i = i + 1) { let w = cam + dir * t; - if (w.x < 0.0 || w.y < 0.0 || w.x >= world_w || w.y >= world_h) { - prev = t; - dt *= 1.10; - t += dt; - continue; - } - if (params_uniform().w > 0.5 && w.z <= params_uniform().z) { - hit = true; - hit_t = t; - break; - } - if (w.z <= world_height(w.x, w.y)) { + let h = world_height(w.x, w.y); + let surface = select(h, max(h, params_uniform().z), params_uniform().w > 0.5); + if (w.z <= surface) { hit = true; hit_t = t; break; } + let clearance = (w.z - surface) / max(-dir.z, 1e-4); prev = t; - dt *= 1.10; - t += dt; + t += clamp(clearance, cell_step, cell_step * 8.0); } if (!hit) { - return vec4(sky_color(dir), 1.0); + return vec4(apply_underwater(sky_color(dir), 0.0, cam.z, params_uniform().z), 1.0); } + // Refine the first crossing; with a sub-cell bracket this converges to the + // exact surface point. var lo = prev; var hi = hit_t; - for (var i = 0; i < 6; i = i + 1) { + for (var i = 0; i < 18; i = i + 1) { let mid = 0.5 * (lo + hi); let w = cam + dir * mid; - let water = params_uniform().w > 0.5 && w.z <= params_uniform().z; - if (water || w.z <= world_height(w.x, w.y)) { + let h = world_height(w.x, w.y); + let surface = select(h, max(h, params_uniform().z), params_uniform().w > 0.5); + if (w.z <= surface) { hi = mid; } else { lo = mid; @@ -178,20 +296,7 @@ fn fs_main(in: TerrainOut) -> @location(0) vec4 { let ambient = sun_uniform().w; if (params_uniform().w > 0.5 && hitpos.z <= params_uniform().z + 0.01) { - let time = misc_uniform().x; - let q = hitpos.xy * 0.015; - let nx = sin(q.x * 1.3 + time * 1.7) + 0.5 * sin(q.x * 3.1 - time * 2.3); - let ny = sin(q.y * 1.1 - time * 1.3) + 0.5 * sin(q.y * 2.7 + time * 1.9); - let n = normalize(vec3(nx * 0.06, ny * 0.06, 1.0)); - let fres = pow(1.0 - clamp(-dir.z, 0.0, 1.0), 3.0); - let deep = vec3(0.03, 0.16, 0.28); - let refl = sky_color(reflect(dir, n)); - let lam = max(0.0, dot(n, sun)); - var water = mix(deep, refl, clamp(0.25 + 0.55 * fres, 0.0, 0.9)); - water += vec3(1.0, 0.98, 0.9) * pow(lam, 64.0) * 0.6; - let wfog = clamp(1.0 - exp(-hi * 0.00009), 0.0, 0.75); - water = mix(water, sky_color(vec3(dir.x, dir.y, 0.0)), wfog); - return vec4(water, 1.0); + return vec4(apply_underwater(water_shade(hitpos, dir, hi), hi, cam.z, params_uniform().z), 1.0); } let wx = hitpos.x / CELL; @@ -225,5 +330,5 @@ fn fs_main(in: TerrainOut) -> @location(0) vec4 { var lit = albedo * (ambient + (1.0 - ambient) * lambert); let fog = clamp(1.0 - exp(-hi * 0.00009), 0.0, 0.75); lit = mix(lit, sky_color(vec3(dir.x, dir.y, 0.0)), fog); - return vec4(lit, 1.0); + return vec4(apply_underwater(lit, hi, cam.z, params_uniform().z), 1.0); } diff --git a/src/client/ra3.client.cppm b/src/client/ra3.client.cppm index 6bd2349..53248e8 100644 --- a/src/client/ra3.client.cppm +++ b/src/client/ra3.client.cppm @@ -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(std::lround(static_cast(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 { diff --git a/src/models/ra3.models.cppm b/src/models/ra3.models.cppm index 4b59a49..a54f422 100644 --- a/src/models/ra3.models.cppm +++ b/src/models/ra3.models.cppm @@ -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 placements, - const std::function &ground_height = {}, uint32 texture_size = 128U) -> scene { + const std::function &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 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 diff --git a/src/render/ra3.render.cppm b/src/render/ra3.render.cppm index dbdb507..d19e9b4 100644 --- a/src/render/ra3.render.cppm +++ b/src/render/ra3.render.cppm @@ -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; } diff --git a/src/terrain/ra3.terrain.cppm b/src/terrain/ra3.terrain.cppm index 5546c85..e5c2662 100644 --- a/src/terrain/ra3.terrain.cppm +++ b/src/terrain/ra3.terrain.cppm @@ -259,6 +259,8 @@ export namespace ra3::terrain { /** The decoded terrain textures, parallel to `map_data::textures`. */ struct texture_set { std::vector 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 archives; std::unordered_map 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::unordered_map 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(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 { - 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 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 &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 raw((std::istreambuf_iterator(in)), std::istreambuf_iterator()); + 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 raw((std::istreambuf_iterator(in)), std::istreambuf_iterator()); - 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(i + 1U) / static_cast(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(packed); } - out.layer_count = static_cast(std::max(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(std::max(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(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(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(sy) * img.width() + sx]; + } + out.layers[(static_cast(index) * layer_size + y) * layer_size + x] = px; } } + }; + for (usize i = 0; i < map.textures.size(); ++i) { + blit_layer(static_cast(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(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; diff --git a/tests/ra3_tests.cpp b/tests/ra3_tests.cpp index 1f819e3..c686cf7 100644 --- a/tests/ra3_tests.cpp +++ b/tests/ra3_tests.cpp @@ -194,6 +194,12 @@ auto main() -> int { } check(!space_lit, "a space paints nothing"); + // FPS label: the active backend name is appended (e.g. `[vulkan]`). + const auto fps_label = render::compose_fps_label(155U, 0, "vulkan"); + const auto fps_plain = render::compose_fps_label(155U, 0); + check(fps_label.width() > fps_plain.width(), "FPS label grows when the backend is shown"); + check(fps_label.width() == render::text_width("FPS: 155/vsync [vulkan]") + 8U, "FPS label includes the backend name"); + const auto box = render::fit_rect(1280.0F, 720.0F, 1920.0F, 1080.0F); check(box.x == 0.0F && box.y == 0.0F && box.w == 1920.0F && box.h == 1080.0F, "fit_rect fills a same-aspect window"); diff --git a/third_party/libenderlog/README.md b/third_party/libenderlog/README.md index 8d7f57f..44e7aec 100644 --- a/third_party/libenderlog/README.md +++ b/third_party/libenderlog/README.md @@ -131,13 +131,13 @@ and `log::set_sinks({...})` replaces them. ```cpp namespace log = ender::log; -// Archive any existing enderlog.log to enderlog.log., then start a +// Archive any existing enderlog.log to enderlog..log, then start a // fresh file for this run. Rotate at 64 KiB and keep the last 5 archives. auto sink = log::add_file_sink("enderlog.log", {.max_file_size = 64 * 1024, .max_archives = 5}); ``` - **No appending onto a previous run.** On open, an existing non-empty - `enderlog.log` is renamed to `enderlog.log.` before the new + `enderlog.log` is renamed to `enderlog..log` before the new file is created, so every run gets its own file and the previous run's log is preserved. A leftover empty file is simply replaced. - `file_options::max_file_size` (0 disables) rotates the active file mid-run the diff --git a/third_party/libenderlog/src/ender.log.cppm b/third_party/libenderlog/src/ender.log.cppm index f842953..684eb6e 100644 --- a/third_party/libenderlog/src/ender.log.cppm +++ b/third_party/libenderlog/src/ender.log.cppm @@ -182,7 +182,8 @@ export namespace ender::log { * `path` is the active file. When the sink opens it and the file already * holds data, that file is renamed to a timestamped archive first, so a run * never appends onto a previous run's log: every start begins a fresh file - * and the old one is preserved as `.`. The same + * and the old one is preserved with the timestamp before its extension, as + * `..log`. The same * happens mid-run once the active file passes `file_options::max_file_size`. * `file_options::max_archives` bounds how many archives are kept. * @@ -238,13 +239,21 @@ export namespace ender::log { if (stream_.is_open()) stream_.close(); const auto stamp = std::format("{:%Y%m%d-%H%M%S}", std::chrono::floor(std::chrono::system_clock::now())); - auto archive = path_; - archive += "." + stamp; + // Keep the original extension last, with the timestamp in the + // middle: `.[.]`. + const auto name = [&](const std::size_t counter) { + auto candidate = path_.parent_path() / path_.stem(); + candidate += "."; + candidate += stamp; + if (counter > 0) candidate += std::format(".{}", counter); + candidate += path_.extension(); + return candidate; + }; + auto archive = name(0); // Two rotations can land in the same second; disambiguate with a // counter rather than overwrite the earlier archive. for (auto counter = 1; std::filesystem::exists(archive); ++counter) { - archive = path_; - archive += std::format(".{}.{}", stamp, counter); + archive = name(counter); } std::filesystem::rename(path_, archive); archives_.push_back(archive);