feat(render): SAGE water/ocean shaders, per-user logs, camera & culling

Terrain pass now ports the SAGE water model (Ocean.fx / OpenSAGE Water.frag): a de-gridded procedural wave normal combined with the retail ra3_deepocean flow and ra3_deepocean_nrm bump maps (appended as the last two terrain-atlas layers, no new backend binding), Schlick fresnel, sky reflection + depth-graded refraction, SAGE diffuse/specular lighting, depth-based transparency, and an underwater tint (UnderwaterDeferred.fx). Mirrored across terrain.frag / dx_terrain.hlsl / webgl_terrain_frag.glsl / webgpu_terrain.wgsl.

Also: logs move to the per-user state dir (%LOCALAPPDATA%\\OpenRA3\\logs, else XDG) and archive as openra3.<stamp>.log; the FPS label shows the active backend; middle-drag camera reset; objects and roads below the water plane are culled.
This commit is contained in:
EnderTheCoder
2026-09-30 22:18:08 +08:00
parent 10a1963eec
commit 26e1934a5d
16 changed files with 969 additions and 249 deletions
+7 -4
View File
@@ -243,14 +243,17 @@ wixl 0.106 has no arm64 support, so Windows/ARM64 ships the portable `.zip`
## Logging ## 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 [`libenderlog`](third_party/libenderlog) module (`import ender.log;`). A file
sink archives the previous log to `openra3.log.<YYYYmmdd-HHMMSS>` on open, so sink archives the previous log to `openra3.<YYYYmmdd-HHMMSS>.log` on open, so
each run gets its own file; the active file rotates at 4 MiB and the last 10 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 archives are kept. Records at **`warn` and above** carry a call stack (Windows
`CaptureStackBackTrace` / POSIX `execinfo`, because libc++ has no `CaptureStackBackTrace` / POSIX `execinfo`, because libc++ has no
`<stacktrace>`). A hard crash also writes `openra3_crash.log` with the faulting `<stacktrace>`). A hard crash also writes `openra3_crash.log` alongside it with
module and a raw backtrace. the faulting module and a raw backtrace.
## Running a skirmish ## Running a skirmish
+99 -21
View File
@@ -10,14 +10,56 @@ import ender.log;
namespace { namespace {
#if defined(_WIN32) #if defined(_WIN32)
/** Path of the crash report written next to the executable. */ /** `%LOCALAPPDATA%` as a wide path, or empty when the variable is unset. */
[[nodiscard]] auto crash_log_path() -> const std::filesystem::path & { auto local_appdata() -> std::filesystem::path {
static const auto path = [] { const DWORD needed = GetEnvironmentVariableW(L"LOCALAPPDATA", nullptr, 0U);
std::wstring buffer(32768U, L'\0'); if (needed == 0U || needed > 32768U) return {};
const DWORD length = GetModuleFileNameW(nullptr, buffer.data(), static_cast<DWORD>(buffer.size())); std::wstring buffer(needed, L'\0');
buffer.resize(length); const DWORD written = GetEnvironmentVariableW(L"LOCALAPPDATA", buffer.data(), needed);
return std::filesystem::path{buffer}.parent_path() / L"openra3_crash.log"; 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; return path;
} }
@@ -92,7 +134,7 @@ namespace {
* timestamped file on open, so every run gets its own log and the previous * timestamped file on open, so every run gets its own log and the previous
* run's log is preserved. * run's log is preserved.
*/ */
auto setup_logging(const std::filesystem::path &exe_dir) -> void { auto setup_logging() -> void {
namespace log = ender::log; namespace log = ender::log;
log::configure({.minimum = log::level::info, .stacktrace_from = log::level::warn}); log::configure({.minimum = log::level::info, .stacktrace_from = log::level::warn});
#if defined(__EMSCRIPTEN__) #if defined(__EMSCRIPTEN__)
@@ -100,9 +142,8 @@ namespace {
// console.error regardless of level; use stdout so INFO/WARN appear at // console.error regardless of level; use stdout so INFO/WARN appear at
// their real level. There is no file sink on the web. // their real level. There is no file sink on the web.
log::set_sinks({std::make_shared<log::console_sink>(std::cout)}); log::set_sinks({std::make_shared<log::console_sink>(std::cout)});
(void) exe_dir;
#else #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 #endif
log::info("OpenRA3 started"); log::info("OpenRA3 started");
} }
@@ -190,6 +231,32 @@ namespace {
return maps; return maps;
} }
/** Case-insensitive ASCII ordering (`a` before `b`). */
auto name_less(std::string_view a, std::string_view b) -> bool {
const auto length = std::min(a.size(), b.size());
for (std::size_t i = 0; i < length; ++i) {
const auto ca = static_cast<unsigned char>(std::tolower(static_cast<unsigned char>(a[i])));
const auto cb = static_cast<unsigned char>(std::tolower(static_cast<unsigned char>(b[i])));
if (ca != cb) return ca < cb;
}
return a.size() < b.size();
}
/**
* Order the map list the way the retail skirmish screen does: alphabetically
* by the localized display name (e.g. "Battlebase Beta" before "Cabana
* Republic"), falling back to the id for entries with equal names.
*/
auto sort_maps_by_name(std::vector<asset_map> &maps, const ra3::map::map_name_table &names) -> void {
std::sort(maps.begin(), maps.end(), [&](const asset_map &a, const asset_map &b) {
const auto name_a = names.lookup(a.id);
const auto name_b = names.lookup(b.id);
if (name_less(name_a, name_b)) return true;
if (name_less(name_b, name_a)) return false;
return a.id < b.id;
});
}
/** Find `<id>_art.tga` anywhere under `root`. */ /** Find `<id>_art.tga` anywhere under `root`. */
auto find_art(const std::filesystem::path &root, std::string_view id) -> std::optional<std::filesystem::path> { auto find_art(const std::filesystem::path &root, std::string_view id) -> std::optional<std::filesystem::path> {
const auto want = std::string{id} + "_art.tga"; const auto want = std::string{id} + "_art.tga";
@@ -259,14 +326,18 @@ namespace {
} }
const auto world_w = terrain.world_width(); const auto world_w = terrain.world_width();
const auto world_h = terrain.world_height(); 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 { 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; if (x < 0.0F || y < 0.0F || x >= world_w || y >= world_h) return 0.0F;
const auto cx = std::min(terrain.width - 1U, static_cast<ra3::core::uint32>(x / ra3::terrain::cell_size)); const auto cx = std::min(terrain.width - 1U, static_cast<ra3::core::uint32>(x / ra3::terrain::cell_size));
const auto cy = std::min(terrain.height - 1U, static_cast<ra3::core::uint32>((world_h - y) / ra3::terrain::cell_size)); const auto cy = std::min(terrain.height - 1U, static_cast<ra3::core::uint32>((world_h - y) / ra3::terrain::cell_size));
return static_cast<float>(terrain.elevation(cx, cy)) * options.z_scale; return static_cast<float>(terrain.elevation(cx, cy)) * options.z_scale;
}); }, 128U, cull_below_z);
std::printf("objects: %zu placed, %zu missing, %zu road segments, %zu triangles, %zu textures (%s)\n", scene.placed, scene.missing, std::printf("objects: %zu placed, %zu missing, %zu hidden, %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()); scene.hidden, scene.roads, scene.triangle_count(), scene.textures.size(), paths->bin.filename().string().c_str());
} catch (const std::exception &error) { } catch (const std::exception &error) {
std::printf("objects: failed to build scene (%s)\n", error.what()); 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 { auto command_maps(const std::filesystem::path &assets) -> int {
if (!ensure_assets(assets)) return 1; 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); const auto names = ra3::map::load_map_names(assets);
sort_maps_by_name(maps, names);
std::printf("maps: %zu\n", maps.size()); std::printf("maps: %zu\n", maps.size());
for (const auto &m: maps) { for (const auto &m: maps) {
std::error_code ec; std::error_code ec;
@@ -424,8 +496,9 @@ namespace {
auto command_skirmish(const std::vector<std::string> &args, const std::filesystem::path &assets) -> int { auto command_skirmish(const std::vector<std::string> &args, const std::filesystem::path &assets) -> int {
using namespace ra3; using namespace ra3;
if (!ensure_assets(assets)) return 1; if (!ensure_assets(assets)) return 1;
const auto maps = list_asset_maps(assets); auto maps = list_asset_maps(assets);
if (maps.empty()) return 1; if (maps.empty()) return 1;
sort_maps_by_name(maps, map::load_map_names(assets));
const auto requested = option_value(args, "--map"); const auto requested = option_value(args, "--map");
const asset_map *picked = &maps.front(); const asset_map *picked = &maps.front();
if (requested) { if (requested) {
@@ -463,8 +536,10 @@ namespace {
auto command_render(const std::vector<std::string> &args, const std::filesystem::path &assets) -> int { auto command_render(const std::vector<std::string> &args, const std::filesystem::path &assets) -> int {
using namespace ra3; using namespace ra3;
if (!ensure_assets(assets)) return 1; if (!ensure_assets(assets)) return 1;
const auto maps = list_asset_maps(assets); auto maps = list_asset_maps(assets);
if (maps.empty()) return 1; 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 auto requested = option_value(args, "--map");
const asset_map *picked = &maps.front(); const asset_map *picked = &maps.front();
if (requested) { if (requested) {
@@ -473,7 +548,6 @@ namespace {
} }
render::scene_options scene; render::scene_options scene;
const auto names = map::load_map_names(assets);
scene.title = "OpenRA3 - " + names.lookup(picked->id); 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); 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; const bool want_gpu = s.mode == 0 && s.out.empty() && s.thumbnail == false;
if (want_gpu) { 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()) { if (!starts.empty()) {
view.camera3d.target_x = starts[0].x; view.camera3d.target_x = starts[0].x;
view.camera3d.target_y = starts[0].y; view.camera3d.target_y = starts[0].y;
@@ -1185,12 +1261,13 @@ namespace {
auto command_menu(const std::vector<std::string> &args, const std::filesystem::path &assets) -> int { auto command_menu(const std::vector<std::string> &args, const std::filesystem::path &assets) -> int {
if (!ensure_assets(assets)) return 1; if (!ensure_assets(assets)) return 1;
const auto maps = list_asset_maps(assets); auto maps = list_asset_maps(assets);
if (maps.empty()) { if (maps.empty()) {
std::puts("no maps found"); std::puts("no maps found");
return 1; return 1;
} }
const auto names = ra3::map::load_map_names(assets); const auto names = ra3::map::load_map_names(assets);
sort_maps_by_name(maps, names);
menu_state st; menu_state st;
// Seed the menu from any command-line flags so they are all visible/editable. // 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). */ /** Render one menu frame to a BMP (headless preview of the menu layout). */
auto command_menu_preview(const std::vector<std::string> &args, const std::filesystem::path &assets) -> int { auto command_menu_preview(const std::vector<std::string> &args, const std::filesystem::path &assets) -> int {
if (!ensure_assets(assets)) return 1; 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); const auto names = ra3::map::load_map_names(assets);
sort_maps_by_name(maps, names);
menu_state st; menu_state st;
if (const auto requested = option_value(args, "--map")) { if (const auto requested = option_value(args, "--map")) {
for (std::size_t i = 0; i < maps.size(); ++i) { for (std::size_t i = 0; i < maps.size(); ++i) {
@@ -1338,7 +1416,7 @@ auto main(int argc, char **argv) -> int {
const std::vector<std::string> args{argv + 1, argv + argc}; const std::vector<std::string> args{argv + 1, argv + argc};
const auto exe_dir = executable_dir(argc > 0 ? argv[0] : "."); const auto exe_dir = executable_dir(argc > 0 ? argv[0] : ".");
const auto assets = exe_dir / "assets"; const auto assets = exe_dir / "assets";
setup_logging(exe_dir); setup_logging();
#if defined(__EMSCRIPTEN__) #if defined(__EMSCRIPTEN__)
// The wasm build runs on a Web Worker (its runtime's main thread lives // 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 // there), where synchronous XHR is legal, so the asset tree is mounted
+29 -1
View File
@@ -533,7 +533,8 @@ Mirrors SAGE `GameEngine/Common`. Everything else speaks this.
- `[ ]` pass scheduling, barriers, transient/aliased resources `(v0.6)` - `[ ]` pass scheduling, barriers, transient/aliased resources `(v0.6)`
- **F3 Terrain render** `[~]` - **F3 Terrain render** `[~]`
- `[x]` top-down software + GPU heightfield with blend ramp + gutter atlas - `[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** `[~]` - **F4 Model render** `[~]`
- `[x]` static map-object triangle soup, world-space, depth-tested over the terrain (Vulkan + software) - `[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 - `[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]` ARGB framebuffer, blit/line/circle/text, TGA decode, BMP encode
- `[x]` map compositing, grid, markers; headless output - `[x]` map compositing, grid, markers; headless output
- **F9 Post-processing** `[ ]` - **F9 Post-processing** `[ ]`
- `[x]` underwater tint/fog in the terrain pass (retail `UnderwaterDeferred.fx`) `[~]`
- `[ ]` bloom, color grading, AA, resolution scaling `(v0.7)` - `[ ]` 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]` ### M17 `ra3.ui` — platform layer & backends `[D]`
- **F1 Display abstraction** `[D]` - **F1 Display abstraction** `[D]`
+42
View File
@@ -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 macro/lightmap with `(vertexColor + additive) * diffuse * 2` and has no normal
map. (Recovered by disassembling the embedded `vs_3_0`/`ps_3_0` bytecode.) 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 — 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 `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 list whose keys index the shared name table (`ra3.map::parse_objects`). Each
+162 -42
View File
@@ -8,6 +8,10 @@
// `Terrain.fx` / OpenSAGE `Terrain.frag` do, so it never restarts at a cell // `Terrain.fx` / OpenSAGE `Terrain.frag` do, so it never restarts at a cell
// edge; material boundaries cross-fade with the SAGE blend ramp. // 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. // The Vulkan push constants (20 floats) become a constant buffer.
cbuffer TerrainCB : register(b0) { 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 params; // x=pitch, y=fov, z=water_z, w=has_water
float4 sun; // xyz=sun dir, w=ambient float4 sun; // xyz=sun dir, w=ambient
float4 mapinfo; // x=W, y=H, z=unused, w=z_scale 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<float> heightmap : register(t0); Texture2D<float> heightmap : register(t0);
@@ -27,6 +31,12 @@ SamplerState atlas_smp : register(s2);
static const float CELL = 10.0; // must match ra3::terrain::cell_size 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 { struct VSOut {
float4 pos : SV_Position; float4 pos : SV_Position;
float2 uv : TEXCOORD0; 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; 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 PSMain(VSOut input) : SV_Target {
float4 p = cam; float4 p = cam;
float pitch = clamp(params.x, 0.15, 1.45); 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); float3 dir = normalize(fwd + right * ndc.x * th * aspect + up * ndc.y * th);
if (dir.z >= -1e-4) { 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 // Clip the ray to the map's XY rectangle: the boundary is an exact plane,
// ~9000 world units, so marching past ~20000 adds cost without detail, and // so the silhouette there stays razor-sharp instead of stair-stepping
// a 6% growth rate more than doubles the worst-case iteration count. // across it. Outside the map is sky.
float t = CELL * 0.5; float t_enter = 0.0;
float dt = CELL * 0.5; 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; float prev = t;
bool hit = false; bool hit = false;
float hit_t = 0.0; 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; float3 w = cam_pos + dir * t;
if (w.x < 0.0 || w.y < 0.0 || w.x >= world_w || w.y >= world_h) { float h = world_height(w.x, w.y);
prev = t; float surface = (params.w > 0.5) ? max(h, params.z) : h;
dt *= 1.10; if (w.z <= surface) {
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)) {
hit = true; hit = true;
hit_t = t; hit_t = t;
break; break;
} }
float clearance = (w.z - surface) / max(-dir.z, 1e-4);
prev = t; prev = t;
dt *= 1.10; t += clamp(clearance, cell_step, cell_step * 8.0);
t += dt;
} }
if (!hit) { 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 lo = prev;
float hi = hit_t; float hi = hit_t;
for (int i = 0; i < 6; ++i) { for (int i = 0; i < 18; ++i) {
float mid = 0.5 * (lo + hi); float mid = 0.5 * (lo + hi);
float3 w = cam_pos + dir * mid; float3 w = cam_pos + dir * mid;
bool water = params.w > 0.5 && w.z <= params.z; float h = world_height(w.x, w.y);
if (water || w.z <= world_height(w.x, w.y)) { float surface = (params.w > 0.5) ? max(h, params.z) : h;
if (w.z <= surface) {
hi = mid; hi = mid;
} else { } else {
lo = mid; lo = mid;
@@ -182,21 +316,7 @@ float4 PSMain(VSOut input) : SV_Target {
float ambient = sun.w; float ambient = sun.w;
if (params.w > 0.5 && hitpos.z <= params.z + 0.01) { if (params.w > 0.5 && hitpos.z <= params.z + 0.01) {
// Water: animated normal from a procedural wave, sky reflection + fresnel. return float4(apply_underwater(water_shade(hitpos, dir, hi), hi, cam_pos.z, params.z), 1.0);
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);
} }
// Terrain: read the per-cell blend record, sample the base/blend/three-way // 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. // 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); 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); 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);
} }
Binary file not shown.
+159 -34
View File
@@ -8,6 +8,10 @@
// The material is sampled **continuously** (`uv = cell / (2 * cellSize)`), as the // The material is sampled **continuously** (`uv = cell / (2 * cellSize)`), as the
// retail `Terrain.fx` / OpenSAGE `Terrain.frag` do, so it never restarts at a // retail `Terrain.fx` / OpenSAGE `Terrain.frag` do, so it never restarts at a
// cell edge; material boundaries cross-fade with the SAGE blend ramp. // 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 = 0) uniform sampler2D heightmap;
layout(binding = 1) uniform sampler2D celldata; layout(binding = 1) uniform sampler2D celldata;
layout(binding = 2) uniform sampler2DArray atlas; 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 params; // x=pitch, y=fov, z=water_z, w=has_water
vec4 sun; // xyz=sun dir, w=ambient vec4 sun; // xyz=sun dir, w=ambient
vec4 mapinfo; // x=W, y=H, z=unused, w=z_scale 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; } pc;
layout(location = 0) in vec2 in_uv; 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 NEAR = 10.0;
const float FAR = 60000.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) { float height_at(ivec2 c) {
c = clamp(c, ivec2(0), ivec2(pc.mapinfo.xy) - 1); c = clamp(c, ivec2(0), ivec2(pc.mapinfo.xy) - 1);
return texelFetch(heightmap, c, 0).r * 65535.0 * pc.mapinfo.w; 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; 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() { void main() {
vec4 p = pc.cam; vec4 p = pc.cam;
float pitch = clamp(pc.params.x, 0.15, 1.45); float pitch = clamp(pc.params.x, 0.15, 1.45);
@@ -118,35 +221,71 @@ void main() {
if (dir.z >= -1e-4) { if (dir.z >= -1e-4) {
gl_FragDepth = 1.0; 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; return;
} }
// March the heightfield. The step grows quickly: the map diagonal is only // Clip the ray to the map's XY rectangle: the boundary is an exact plane,
// ~9000 world units, so marching past ~20000 adds cost without detail, and // so the silhouette there stays razor-sharp instead of stair-stepping
// a 6% growth rate more than doubles the worst-case iteration count. // across it. Outside the map is sky.
float t = CELL * 0.5; float t_enter = 0.0;
float dt = CELL * 0.5; 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; float prev = t;
bool hit = false; bool hit = false;
float hit_t = 0.0; 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; vec3 w = cam + dir * t;
if (w.x < 0.0 || w.y < 0.0 || w.x >= world_w || w.y >= world_h) { float h = world_height(w.x, w.y);
prev = t; dt *= 1.10; t += dt; continue; float surface = (pc.params.w > 0.5) ? max(h, pc.params.z) : h;
} if (w.z <= surface) { hit = true; hit_t = t; break; }
if (pc.params.w > 0.5 && w.z <= pc.params.z) { hit = true; hit_t = t; break; } float clearance = (w.z - surface) / max(-dir.z, 1e-4);
if (w.z <= world_height(w.x, w.y)) { hit = true; hit_t = t; break; } prev = t;
prev = t; dt *= 1.10; t += dt; 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; 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); float mid = 0.5 * (lo + hi);
vec3 w = cam + dir * mid; vec3 w = cam + dir * mid;
bool water = pc.params.w > 0.5 && w.z <= pc.params.z; float h = world_height(w.x, w.y);
if (water || w.z <= world_height(w.x, w.y)) hi = mid; else lo = mid; 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; vec3 hitpos = cam + dir * hi;
@@ -158,21 +297,7 @@ void main() {
float ambient = pc.sun.w; float ambient = pc.sun.w;
if (pc.params.w > 0.5 && hitpos.z <= pc.params.z + 0.01) { if (pc.params.w > 0.5 && hitpos.z <= pc.params.z + 0.01) {
// Water: animated normal from a procedural wave, sky reflection + fresnel. out_color = vec4(apply_underwater(water_shade(hitpos, dir, hi), hi, cam.z, pc.params.z), 1.0);
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);
return; return;
} }
@@ -211,5 +336,5 @@ void main() {
// Distance haze toward the horizon so the map edge blends into the sky. // 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); 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); 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);
} }
+144 -40
View File
@@ -24,6 +24,12 @@ out vec4 frag_color;
const float CELL = 10.0; // must match ra3::terrain::cell_size 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) { float height_at(ivec2 c) {
c = clamp(c, ivec2(0), ivec2(u_mapinfo.xy) - 1); c = clamp(c, ivec2(0), ivec2(u_mapinfo.xy) - 1);
return texelFetch(u_heightmap, c, 0).r * 65535.0 * u_mapinfo.w; 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; 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() { void main() {
vec4 p = u_cam; vec4 p = u_cam;
float pitch = clamp(u_params.x, 0.15, 1.45); 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); vec3 dir = normalize(fwd + right * ndc.x * th * aspect + up * ndc.y * th);
if (dir.z >= -1e-4) { 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; return;
} }
// March the heightfield. The step grows quickly: the map diagonal is only // Clip the ray to the map's XY rectangle: the boundary is an exact plane,
// ~9000 world units, so marching past ~20000 adds cost without detail, and // so the silhouette there stays razor-sharp instead of stair-stepping
// a 6% growth rate more than doubles the worst-case iteration count. // across it. Outside the map is sky.
float t = CELL * 0.5; float t_enter = 0.0;
float dt = CELL * 0.5; 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; float prev = t;
bool hit = false; bool hit = false;
float hit_t = 0.0; 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; vec3 w = cam + dir * t;
if (w.x < 0.0 || w.y < 0.0 || w.x >= world_w || w.y >= world_h) { float h = world_height(w.x, w.y);
prev = t; float surface = (u_params.w > 0.5) ? max(h, u_params.z) : h;
dt *= 1.10; if (w.z <= surface) {
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)) {
hit = true; hit = true;
hit_t = t; hit_t = t;
break; break;
} }
float clearance = (w.z - surface) / max(-dir.z, 1e-4);
prev = t; prev = t;
dt *= 1.10; t += clamp(clearance, cell_step, cell_step * 8.0);
t += dt;
} }
if (!hit) { 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; return;
} }
// Refine the first crossing; with a sub-cell bracket this converges to the
// exact surface point.
float lo = prev; float lo = prev;
float hi = hit_t; float hi = hit_t;
for (int i = 0; i < 6; ++i) { for (int i = 0; i < 18; ++i) {
float mid = 0.5 * (lo + hi); float mid = 0.5 * (lo + hi);
vec3 w = cam + dir * mid; vec3 w = cam + dir * mid;
bool water = u_params.w > 0.5 && w.z <= u_params.z; float h = world_height(w.x, w.y);
if (water || w.z <= 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; hi = mid;
} else { } else {
lo = mid; lo = mid;
@@ -162,20 +279,7 @@ void main() {
float ambient = u_sun.w; float ambient = u_sun.w;
if (u_params.w > 0.5 && hitpos.z <= u_params.z + 0.01) { if (u_params.w > 0.5 && hitpos.z <= u_params.z + 0.01) {
float time = u_misc.x; frag_color = vec4(apply_underwater(water_shade(hitpos, dir, hi), hi, cam.z, u_params.z), 1.0);
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);
return; return;
} }
@@ -210,5 +314,5 @@ void main() {
vec3 lit = albedo * (ambient + (1.0 - ambient) * lambert); vec3 lit = albedo * (ambient + (1.0 - ambient) * lambert);
float fog = clamp(1.0 - exp(-hi * 0.00009), 0.0, 0.75); 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); 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);
} }
+144 -39
View File
@@ -17,6 +17,12 @@ struct TerrainUniforms {
const CELL: f32 = 10.0; // must match ra3::terrain::cell_size 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<f32> { return u.data[0]; } // x=target_x, y=target_y, z=yaw, w=height fn cam_uniform() -> vec4<f32> { return u.data[0]; } // x=target_x, y=target_y, z=yaw, w=height
fn params_uniform() -> vec4<f32> { return u.data[1]; } // x=pitch, y=fov, z=water_z, w=has_water fn params_uniform() -> vec4<f32> { return u.data[1]; } // x=pitch, y=fov, z=water_z, w=has_water
fn sun_uniform() -> vec4<f32> { return u.data[2]; } // xyz=sun dir, w=ambient fn sun_uniform() -> vec4<f32> { return u.data[2]; } // xyz=sun dir, w=ambient
@@ -100,6 +106,92 @@ fn sample_layer(layer: u32, wx: f32, wy: f32) -> vec3<f32> {
return vec3<f32>(c.b, c.g, c.r); return vec3<f32>(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<f32>) -> vec3<f32> {
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<f32>(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<f32>, time: f32) -> vec3<f32> {
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<f32>(time * 0.010, time * 0.014)) * 2.0 - 1.0;
let bump = water_tex(lc - 1, q + flow.xy * 0.05 + vec2<f32>(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<f32>(sx, sy, 1.0));
}
// Water.frag distortionPower * the flow texture: a small scrolling UV offset.
fn water_distortion(world_xy: vec2<f32>, 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<f32>, time: f32) -> vec3<f32> {
return vec3<f32>(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<f32>, distance: f32, cam_z: f32, water_z: f32) -> vec3<f32> {
if (cam_z >= water_z - 0.5) { return color; }
let absorb = vec3<f32>(0.35, 0.62, 0.75);
let fog = clamp(1.0 - exp(-distance * 0.00022), 0.0, 0.9);
return mix(color * absorb, vec3<f32>(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<f32>, dir: vec3<f32>, distance: f32) -> vec3<f32> {
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<f32>(0.10, 0.34, 0.38);
let deep = vec3<f32>(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<f32>(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<f32>(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<f32>(dir.x, dir.y, 0.0)), wfog);
}
@fragment @fragment
fn fs_main(in: TerrainOut) -> @location(0) vec4<f32> { fn fs_main(in: TerrainOut) -> @location(0) vec4<f32> {
let p = cam_uniform(); let p = cam_uniform();
@@ -124,49 +216,75 @@ fn fs_main(in: TerrainOut) -> @location(0) vec4<f32> {
let dir = normalize(fwd + right * ndc.x * th * aspect + up * ndc.y * th); let dir = normalize(fwd + right * ndc.x * th * aspect + up * ndc.y * th);
if (dir.z >= -1e-4) { if (dir.z >= -1e-4) {
return vec4<f32>(sky_color(dir), 1.0); return vec4<f32>(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 // Clip the ray to the map's XY rectangle: the boundary is an exact plane,
// ~9000 world units, so marching past ~20000 adds cost without detail. // so the silhouette there stays razor-sharp instead of stair-stepping
var t = CELL * 0.5; // across it. Outside the map is sky.
var dt = CELL * 0.5; 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<f32>(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 prev = t;
var hit = false; var hit = false;
var hit_t = 0.0; 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; let w = cam + dir * t;
if (w.x < 0.0 || w.y < 0.0 || w.x >= world_w || w.y >= world_h) { let h = world_height(w.x, w.y);
prev = t; let surface = select(h, max(h, params_uniform().z), params_uniform().w > 0.5);
dt *= 1.10; if (w.z <= surface) {
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)) {
hit = true; hit = true;
hit_t = t; hit_t = t;
break; break;
} }
let clearance = (w.z - surface) / max(-dir.z, 1e-4);
prev = t; prev = t;
dt *= 1.10; t += clamp(clearance, cell_step, cell_step * 8.0);
t += dt;
} }
if (!hit) { if (!hit) {
return vec4<f32>(sky_color(dir), 1.0); return vec4<f32>(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 lo = prev;
var hi = hit_t; 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 mid = 0.5 * (lo + hi);
let w = cam + dir * mid; let w = cam + dir * mid;
let water = params_uniform().w > 0.5 && w.z <= params_uniform().z; let h = world_height(w.x, w.y);
if (water || w.z <= 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; hi = mid;
} else { } else {
lo = mid; lo = mid;
@@ -178,20 +296,7 @@ fn fs_main(in: TerrainOut) -> @location(0) vec4<f32> {
let ambient = sun_uniform().w; let ambient = sun_uniform().w;
if (params_uniform().w > 0.5 && hitpos.z <= params_uniform().z + 0.01) { if (params_uniform().w > 0.5 && hitpos.z <= params_uniform().z + 0.01) {
let time = misc_uniform().x; return vec4<f32>(apply_underwater(water_shade(hitpos, dir, hi), hi, cam.z, params_uniform().z), 1.0);
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<f32>(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<f32>(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<f32>(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<f32>(dir.x, dir.y, 0.0)), wfog);
return vec4<f32>(water, 1.0);
} }
let wx = hitpos.x / CELL; let wx = hitpos.x / CELL;
@@ -225,5 +330,5 @@ fn fs_main(in: TerrainOut) -> @location(0) vec4<f32> {
var lit = albedo * (ambient + (1.0 - ambient) * lambert); var lit = albedo * (ambient + (1.0 - ambient) * lambert);
let fog = clamp(1.0 - exp(-hi * 0.00009), 0.0, 0.75); let fog = clamp(1.0 - exp(-hi * 0.00009), 0.0, 0.75);
lit = mix(lit, sky_color(vec3<f32>(dir.x, dir.y, 0.0)), fog); lit = mix(lit, sky_color(vec3<f32>(dir.x, dir.y, 0.0)), fog);
return vec4<f32>(lit, 1.0); return vec4<f32>(apply_underwater(lit, hi, cam.z, params_uniform().z), 1.0);
} }
+25 -13
View File
@@ -277,13 +277,15 @@ export namespace ra3::client {
} else if (event.type == ui_event_type::mouse_move) { } else if (event.type == ui_event_type::mouse_move) {
mouse_x = event.x; mouse_x = event.x;
mouse_y = event.y; mouse_y = event.y;
if (event.left) { if (event.middle) {
drag_x += event.dx; drag_x += event.dx;
drag_y += event.dy; drag_y += event.dy;
} }
} else if (event.type == ui_event_type::wheel) { } 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); if (event.wheel != 0.0F) {
dirty = true; 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; if (!running) break;
@@ -345,8 +347,9 @@ export namespace ra3::client {
/** /**
* GPU terrain viewer. `present_terrain` draws the heightfield; the loop * GPU terrain viewer. `present_terrain` draws the heightfield; the loop
* here owns the camera controls. The top-left shows the FPS (current / * here owns the camera controls. The top-left shows the FPS (current /
* cap) and, when `minimap_overview` is not empty, a corner minimap with * cap) tagged with the active backend name (e.g. `[vulkan]`) and, when
* the camera location is drawn. * `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 { [[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; if (terrain.width == 0U || terrain.height == 0U || !this->supports_terrain()) return false;
@@ -372,6 +375,7 @@ export namespace ra3::client {
bool presented = false; bool presented = false;
const auto default_camera = camera; const auto default_camera = camera;
bool middle_dragged = false; bool middle_dragged = false;
bool middle_down = false;
while (running) { while (running) {
ui_event event; ui_event event;
float drag_x = 0.0F; 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; if (event.dx != 0.0F || event.dy != 0.0F) middle_dragged = true;
} }
} else if (event.type == ui_event_type::wheel) { } 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); if (event.wheel != 0.0F) {
camera_moved = true; camera.height = std::clamp(camera.height * (event.wheel > 0.0F ? (1.0F / 1.15F) : 1.15F), camera.min_height, camera.max_height);
} 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;
camera_moved = true; 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; if (!running) break;
@@ -466,7 +478,7 @@ export namespace ra3::client {
fps = static_cast<uint32>(std::lround(static_cast<float>(fps_frames) / window_s)); fps = static_cast<uint32>(std::lround(static_cast<float>(fps_frames) / window_s));
fps_frames = 0; fps_frames = 0;
fps_window = now; 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; overlay.label_changed = true;
} }
} else { } else {
+17 -1
View File
@@ -765,6 +765,7 @@ export namespace ra3::models {
usize placed = 0; usize placed = 0;
usize missing = 0; usize missing = 0;
usize roads = 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 empty() const -> bool { return indices.empty(); }
[[nodiscard]] auto triangle_count() const -> usize { return indices.size() / 3U; } [[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 * @param ground_height Terrain height (world Z) at a world `(x, y)`, so
* objects sit on the relief instead of a flat plane. * objects sit on the relief instead of a flat plane.
* @param texture_size Edge length of the shared texture array (0 = auto). * @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<const placement> placements, [[nodiscard]] inline auto build_scene(const asset_stream &stream, std::span<const placement> placements,
const std::function<float(float, float)> &ground_height = {}, uint32 texture_size = 128U) -> scene { const std::function<float(float, float)> &ground_height = {}, uint32 texture_size = 128U,
float cull_below_z = -3.4e38F) -> scene {
scene out; scene out;
out.texture_size = texture_size == 0U ? 128U : texture_size; out.texture_size = texture_size == 0U ? 128U : texture_size;
std::unordered_map<std::string, uint32> texture_layers; std::unordered_map<std::string, uint32> texture_layers;
@@ -972,6 +978,12 @@ export namespace ra3::models {
if ((a.road_type & 2U) == 0U) continue; // RoadType::Start if ((a.road_type & 2U) == 0U) continue; // RoadType::Start
const auto &b = placements[i + 1U]; const auto &b = placements[i + 1U];
if ((b.road_type & 4U) == 0U || b.type != a.type) continue; // RoadType::End 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); emit_road(a, b);
++i; ++i;
} }
@@ -986,6 +998,10 @@ export namespace ra3::models {
const auto cos_a = std::cos(item.angle); const auto cos_a = std::cos(item.angle);
const auto sin_a = std::sin(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; 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; bool drawn = false;
for (const auto *mesh_asset: meshes) { for (const auto *mesh_asset: meshes) {
// Only opaque material parts are drawn; meshes with no diffuse // Only opaque material parts are drawn; meshes with no diffuse
+12 -5
View File
@@ -314,10 +314,11 @@ export namespace ra3::render {
} }
/** /**
* A small translucent label for the top-left corner, e.g. `FPS: 155/160`. * A small translucent label for the top-left corner, e.g.
* `cap == 0` means vertical sync, `cap < 0` means uncapped. * `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]; char text[64];
if (cap == 0) { if (cap == 0) {
std::snprintf(text, sizeof(text), "FPS: %u/vsync", fps); std::snprintf(text, sizeof(text), "FPS: %u/vsync", fps);
@@ -326,10 +327,16 @@ export namespace ra3::render {
} else { } else {
std::snprintf(text, sizeof(text), "FPS: %u/%d", fps, cap); 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; const auto h = detail::glyph_height + 6U;
image img(w, h, argb(30, 6, 6, 200)); // warm translucent backing 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; return img;
} }
+107 -42
View File
@@ -259,6 +259,8 @@ export namespace ra3::terrain {
/** The decoded terrain textures, parallel to `map_data::textures`. */ /** The decoded terrain textures, parallel to `map_data::textures`. */
struct texture_set { struct texture_set {
std::vector<image> images; std::vector<image> images;
image water_flow; ///< `ra3_deepocean.tga`: SAGE water flow/distortion (RG), optional.
image water_normal; ///< `ra3_deepocean_nrm.tga`: SAGE water bump normal, optional.
[[nodiscard]] auto resolved() const -> usize { [[nodiscard]] auto resolved() const -> usize {
usize n = 0; usize n = 0;
@@ -294,6 +296,10 @@ export namespace ra3::terrain {
}; };
std::vector<fs::big_archive> archives; std::vector<fs::big_archive> archives;
std::unordered_map<std::string, source> files; std::unordered_map<std::string, source> files;
source flow_src{};
source nrm_src{};
bool has_flow = false;
bool has_nrm = false;
for (const auto &name: {"Terrain.big", "Core11.big"}) { for (const auto &name: {"Terrain.big", "Core11.big"}) {
const auto path = data_dir / name; const auto path = data_dir / name;
std::error_code ec; std::error_code ec;
@@ -302,9 +308,13 @@ export namespace ra3::terrain {
} }
for (const auto &archive: archives) { for (const auto &archive: archives) {
for (const auto &entry: archive.entries()) { 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; 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}); 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. // 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; return set;
} }
@@ -354,8 +374,8 @@ export namespace ra3::terrain {
}; };
namespace detail { namespace detail {
/** Index `*.tga` under a terrain dir by stem (lower-cased), ignoring normals. */ /** Index `*.tga` under a terrain dir by stem (lower-cased). */
[[nodiscard]] inline auto terrain_file_index(const std::filesystem::path &dir) [[nodiscard]] inline auto terrain_file_index(const std::filesystem::path &dir, bool include_normals = false)
-> std::unordered_map<std::string, std::filesystem::path> { -> std::unordered_map<std::string, std::filesystem::path> {
std::unordered_map<std::string, std::filesystem::path> files; std::unordered_map<std::string, std::filesystem::path> files;
std::error_code ec; std::error_code ec;
@@ -370,7 +390,7 @@ export namespace ra3::terrain {
std::transform(parent.begin(), parent.end(), parent.begin(), [](unsigned char ch) { return static_cast<char>(std::tolower(ch)); }); std::transform(parent.begin(), parent.end(), parent.begin(), [](unsigned char ch) { return static_cast<char>(std::tolower(ch)); });
if (parent != "terrain") continue; if (parent != "terrain") continue;
auto stem = tga_stem(path.filename().string()); 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); files.try_emplace(stem, path);
} }
return files; 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). * Used to stage just the tiles a single map needs (e.g. the wasm preload).
*/ */
[[nodiscard]] inline auto resolve_texture_files(const map_data &map, const std::filesystem::path &dir) -> std::vector<std::filesystem::path> { [[nodiscard]] inline auto resolve_texture_files(const map_data &map, const std::filesystem::path &dir) -> std::vector<std::filesystem::path> {
const auto files = detail::terrain_file_index(dir); // Include normals so the global ocean flow/normal pair is staged too:
// the terrain pass appends them to the atlas.
const auto files = detail::terrain_file_index(dir, true);
std::vector<std::filesystem::path> resolved; std::vector<std::filesystem::path> resolved;
for (const auto &texture: map.textures) { for (const auto &texture: map.textures) {
if (auto found = detail::match_terrain_file(files, texture.name); !found.empty()) resolved.push_back(std::move(found)); 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()); std::sort(resolved.begin(), resolved.end());
resolved.erase(std::unique(resolved.begin(), resolved.end()), resolved.end()); resolved.erase(std::unique(resolved.begin(), resolved.end()), resolved.end());
return resolved; return resolved;
@@ -409,21 +434,26 @@ export namespace ra3::terrain {
/** Load tile textures from a directory of loose `*.tga` files (extracted assets). */ /** 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, [[nodiscard]] inline auto load_textures_from_dir(const map_data &map, const std::filesystem::path &dir,
const std::function<void(float)> &progress = {}) -> texture_set { const std::function<void(float)> &progress = {}) -> texture_set {
const auto files = detail::terrain_file_index(dir); const auto files = detail::terrain_file_index(dir, true);
texture_set set; texture_set set;
set.images.resize(map.textures.size()); set.images.resize(map.textures.size());
const auto decode_file = [](const std::filesystem::path &path) -> image {
if (path.empty()) return {};
try {
std::ifstream in(path, std::ios::binary);
std::vector<uint8> raw((std::istreambuf_iterator<char>(in)), std::istreambuf_iterator<char>());
return ra3::render::decode_tga(raw);
} catch (const std::exception &) {
return {};
}
};
for (usize i = 0; i < map.textures.size(); ++i) { for (usize i = 0; i < map.textures.size(); ++i) {
const auto found = detail::match_terrain_file(files, map.textures[i].name); const auto found = detail::match_terrain_file(files, map.textures[i].name);
if (!found.empty()) { set.images[i] = decode_file(found);
try {
std::ifstream in(found, std::ios::binary);
std::vector<uint8> raw((std::istreambuf_iterator<char>(in)), std::istreambuf_iterator<char>());
set.images[i] = ra3::render::decode_tga(raw);
} catch (const std::exception &) {
}
}
if (progress && !map.textures.empty()) progress(static_cast<float>(i + 1U) / static_cast<float>(map.textures.size())); if (progress && !map.textures.empty()) progress(static_cast<float>(i + 1U) / static_cast<float>(map.textures.size()));
} }
if (const auto it = files.find("ra3_deepocean"); it != files.end()) set.water_flow = decode_file(it->second);
if (const auto it = files.find("ra3_deepocean_nrm"); it != files.end()) set.water_normal = decode_file(it->second);
return set; return set;
} }
@@ -497,7 +527,12 @@ export namespace ra3::terrain {
out.cell_data[i * 4U + 3U] = static_cast<uint16>(packed); out.cell_data[i * 4U + 3U] = static_cast<uint16>(packed);
} }
out.layer_count = static_cast<uint32>(std::max<usize>(1U, map.textures.size())); // Two extra atlas layers hold the SAGE water flow map and bump normal so
// the water shader can sample them without a new binding on any backend:
// they are always the last two layers (water_flow = layer_count - 2,
// water_normal = layer_count - 1).
const auto tile_layers = static_cast<uint32>(std::max<usize>(1U, map.textures.size()));
out.layer_count = tile_layers + 2U;
uint32 layer_size = 64U; uint32 layer_size = 64U;
for (const auto &img: set.images) { for (const auto &img: set.images) {
if (!img.empty()) layer_size = std::max(layer_size, img.width()); if (!img.empty()) layer_size = std::max(layer_size, img.width());
@@ -512,17 +547,26 @@ export namespace ra3::terrain {
} }
out.layers.assign(static_cast<usize>(out.layer_count) * layer_size * layer_size, 0xFF3A4550U); out.layers.assign(static_cast<usize>(out.layer_count) * layer_size * layer_size, 0xFF3A4550U);
for (usize i = 0; i < map.textures.size(); ++i) { // Copy `img` into atlas layer `index`, box-nearest downscaled to
const auto &img = set.images[i]; // `layer_size`; `fallback` is the ARGB used when the image is absent.
if (img.empty()) continue; const auto blit_layer = [&](uint32 index, const image &img, uint32 fallback) {
for (uint32 y = 0; y < layer_size; ++y) { 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) { for (uint32 x = 0; x < layer_size; ++x) {
const auto sx = std::min(img.width() - 1U, x * img.width() / layer_size); uint32 px = fallback;
out.layers[(i * layer_size + y) * layer_size + x] = img.data()[static_cast<usize>(sy) * img.width() + sx]; if (!img.empty()) {
const auto sy = std::min(img.height() - 1U, y * img.height() / layer_size);
const auto sx = std::min(img.width() - 1U, x * img.width() / layer_size);
px = img.data()[static_cast<usize>(sy) * img.width() + sx];
}
out.layers[(static_cast<usize>(index) * layer_size + y) * layer_size + x] = px;
} }
} }
};
for (usize i = 0; i < map.textures.size(); ++i) {
blit_layer(static_cast<uint32>(i), set.images[i], 0xFF3A4550U);
} }
blit_layer(tile_layers, set.water_flow, 0xFF808080U); // neutral flow (0, 0)
blit_layer(tile_layers + 1U, set.water_normal, 0xFF8080FFU); // flat normal (0, 0, 1)
if (progress) progress(1.0F); if (progress) progress(1.0F);
return out; return out;
} }
@@ -1096,49 +1140,70 @@ export namespace ra3::terrain {
continue; continue;
} }
auto t = cell_size * 0.5F; // Clip the ray to the map's XY rectangle. The boundary is an
auto dt = cell_size * 0.5F; // exact plane, so the silhouette there stays razor-sharp
// instead of stair-stepping across it; outside the map is sky.
auto t_enter = 0.0F;
auto t_exit = 1.0e30F;
const auto slab = [](float origin, float dir, float span, float &lo_t, float &hi_t) -> bool {
if (std::abs(dir) < 1.0e-6F) return origin >= 0.0F && origin <= span;
const auto a = (0.0F - origin) / dir;
const auto b = (span - origin) / dir;
lo_t = std::max(lo_t, std::min(a, b));
hi_t = std::min(hi_t, std::max(a, b));
return true;
};
if (!slab(cam_x, dx, world_w, t_enter, t_exit) || !slab(cam_y, dy, world_h, t_enter, t_exit) || t_exit <= 0.0F) {
hi.data()[pixel] = argb(150, 170, 200);
continue;
}
const auto surface_at = [&](float wx, float wy) -> float {
const auto h = sample_height(wx, wy);
return map.has_water ? std::max(h, static_cast<float>(map.water_plane_z)) : h;
};
// March the heightfield cell by cell: the step is never longer
// than the time to cross one cell (in the dominant horizontal
// axis), while a clearance term lets the ray skip the empty air
// above the surface. Resolving every cell is what keeps cliff and
// map-edge silhouettes from quantising into huge stair-steps that
// crawl/wave as the camera pans.
const auto horiz = std::max(std::abs(dx), std::abs(dy));
const auto cell_step = std::min(cell_size / std::max(horiz, 1.0e-4F), cell_size * 32.0F);
auto t = std::max(t_enter, cell_size * 0.5F);
auto prev_t = t; auto prev_t = t;
bool hit = false; bool hit = false;
float hit_t = 0.0F; 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 wx = cam_x + dx * t;
const auto wy = cam_y + dy * t; const auto wy = cam_y + dy * t;
const auto wz = cam_z + dz * t; const auto wz = cam_z + dz * t;
if (wx < 0.0F || wy < 0.0F || wx >= world_w || wy >= world_h) { const auto surface = surface_at(wx, wy);
prev_t = t; if (wz <= surface) {
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)) {
hit = true; hit = true;
hit_t = t; hit_t = t;
break; break;
} }
const auto clearance = (wz - surface) / std::max(-dz, 1.0e-4F);
prev_t = t; prev_t = t;
dt *= 1.03F; t += std::clamp(clearance, cell_step, cell_step * 8.0F);
t += dt;
} }
if (!hit) { if (!hit) {
hi.data()[pixel] = argb(150, 170, 200); hi.data()[pixel] = argb(150, 170, 200);
continue; continue;
} }
// Refine the first crossing; with a sub-cell bracket this
// converges to the exact surface point.
auto lo = prev_t; auto lo = prev_t;
auto up = hit_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 mid = 0.5F * (lo + up);
const auto wx = cam_x + dx * mid; const auto wx = cam_x + dx * mid;
const auto wy = cam_y + dy * mid; const auto wy = cam_y + dy * mid;
const auto wz = cam_z + dz * mid; const auto wz = cam_z + dz * mid;
const auto water = map.has_water && wz <= map.water_plane_z; if (wz <= surface_at(wx, wy)) {
if (water || wz <= sample_height(wx, wy)) {
up = mid; up = mid;
} else { } else {
lo = mid; lo = mid;
+6
View File
@@ -194,6 +194,12 @@ auto main() -> int {
} }
check(!space_lit, "a space paints nothing"); 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); 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"); check(box.x == 0.0F && box.y == 0.0F && box.w == 1920.0F && box.h == 1080.0F, "fit_rect fills a same-aspect window");
+2 -2
View File
@@ -131,13 +131,13 @@ and `log::set_sinks({...})` replaces them.
```cpp ```cpp
namespace log = ender::log; namespace log = ender::log;
// Archive any existing enderlog.log to enderlog.log.<timestamp>, then start a // Archive any existing enderlog.log to enderlog.<timestamp>.log, then start a
// fresh file for this run. Rotate at 64 KiB and keep the last 5 archives. // 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}); 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 - **No appending onto a previous run.** On open, an existing non-empty
`enderlog.log` is renamed to `enderlog.log.<YYYYmmdd-HHMMSS>` before the new `enderlog.log` is renamed to `enderlog.<YYYYmmdd-HHMMSS>.log` before the new
file is created, so every run gets its own file and the previous run's log is 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. preserved. A leftover empty file is simply replaced.
- `file_options::max_file_size` (0 disables) rotates the active file mid-run the - `file_options::max_file_size` (0 disables) rotates the active file mid-run the
+14 -5
View File
@@ -182,7 +182,8 @@ export namespace ender::log {
* `path` is the active file. When the sink opens it and the file already * `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 * 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 * never appends onto a previous run's log: every start begins a fresh file
* and the old one is preserved as `<path>.<YYYYmmdd-HHMMSS>`. The same * and the old one is preserved with the timestamp before its extension, as
* `<stem>.<YYYYmmdd-HHMMSS>.log`. The same
* happens mid-run once the active file passes `file_options::max_file_size`. * happens mid-run once the active file passes `file_options::max_file_size`.
* `file_options::max_archives` bounds how many archives are kept. * `file_options::max_archives` bounds how many archives are kept.
* *
@@ -238,13 +239,21 @@ export namespace ender::log {
if (stream_.is_open()) stream_.close(); if (stream_.is_open()) stream_.close();
const auto stamp = std::format("{:%Y%m%d-%H%M%S}", const auto stamp = std::format("{:%Y%m%d-%H%M%S}",
std::chrono::floor<std::chrono::seconds>(std::chrono::system_clock::now())); std::chrono::floor<std::chrono::seconds>(std::chrono::system_clock::now()));
auto archive = path_; // Keep the original extension last, with the timestamp in the
archive += "." + stamp; // middle: `<stem>.<stamp>[.<n>]<ext>`.
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 // Two rotations can land in the same second; disambiguate with a
// counter rather than overwrite the earlier archive. // counter rather than overwrite the earlier archive.
for (auto counter = 1; std::filesystem::exists(archive); ++counter) { for (auto counter = 1; std::filesystem::exists(archive); ++counter) {
archive = path_; archive = name(counter);
archive += std::format(".{}.{}", stamp, counter);
} }
std::filesystem::rename(path_, archive); std::filesystem::rename(path_, archive);
archives_.push_back(archive); archives_.push_back(archive);