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 758e589..b291cdf 100644 Binary files a/shaders/generated/terrain.frag.spv and b/shaders/generated/terrain.frag.spv differ 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);