2 Commits
Author SHA1 Message Date
EnderTheCoder 2c348cb590 chore: release v0.10.0 2026-09-30 22:18:27 +08:00
EnderTheCoder 26e1934a5d 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.
2026-09-30 22:18:08 +08:00
17 changed files with 970 additions and 250 deletions
+7 -4
View File
@@ -243,14 +243,17 @@ wixl 0.106 has no arm64 support, so Windows/ARM64 ships the portable `.zip`
## Logging
Every run writes `openra3.log` next to the executable through the vendored
Every run writes `openra3.log` in a per-user `logs/` folder — on Windows
`%LOCALAPPDATA%\OpenRA3\logs`, elsewhere `$XDG_STATE_HOME/openra3/logs`
(falling back to `~/.local/state/openra3/logs`) — created on first use and kept
out of the binary's own directory, through the vendored
[`libenderlog`](third_party/libenderlog) module (`import ender.log;`). A file
sink archives the previous log to `openra3.log.<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
archives are kept. Records at **`warn` and above** carry a call stack (Windows
`CaptureStackBackTrace` / POSIX `execinfo`, because libc++ has no
`<stacktrace>`). A hard crash also writes `openra3_crash.log` with the faulting
module and a raw backtrace.
`<stacktrace>`). A hard crash also writes `openra3_crash.log` alongside it with
the faulting module and a raw backtrace.
## Running a skirmish
+1 -1
View File
@@ -1 +1 @@
0.9.0
0.10.0
+99 -21
View File
@@ -10,14 +10,56 @@ import ender.log;
namespace {
#if defined(_WIN32)
/** Path of the crash report written next to the executable. */
[[nodiscard]] auto crash_log_path() -> const std::filesystem::path & {
static const auto path = [] {
std::wstring buffer(32768U, L'\0');
const DWORD length = GetModuleFileNameW(nullptr, buffer.data(), static_cast<DWORD>(buffer.size()));
buffer.resize(length);
return std::filesystem::path{buffer}.parent_path() / L"openra3_crash.log";
/** `%LOCALAPPDATA%` as a wide path, or empty when the variable is unset. */
auto local_appdata() -> std::filesystem::path {
const DWORD needed = GetEnvironmentVariableW(L"LOCALAPPDATA", nullptr, 0U);
if (needed == 0U || needed > 32768U) return {};
std::wstring buffer(needed, L'\0');
const DWORD written = GetEnvironmentVariableW(L"LOCALAPPDATA", buffer.data(), needed);
if (written == 0U || written >= needed) return {};
buffer.resize(written);
return std::filesystem::path{buffer};
}
#endif
/**
* Directory that holds per-run logs and crash reports: a `logs` folder
* under the platform's per-user state location, created on first use. It
* deliberately does not sit beside the executable, which may be read-only
* or a shared build tree.
*
* Windows: `%LOCALAPPDATA%\OpenRA3\logs`; elsewhere
* `$XDG_STATE_HOME/openra3/logs` (falling back to
* `~/.local/state/openra3/logs`).
*/
[[maybe_unused]] auto log_directory() -> const std::filesystem::path & {
static const auto directory = [] {
#if defined(_WIN32)
auto base = local_appdata();
if (base.empty()) base = std::filesystem::temp_directory_path();
base /= L"OpenRA3";
#else
std::filesystem::path base;
if (const char *state = std::getenv("XDG_STATE_HOME"); state != nullptr && *state != '\0')
base = state;
else if (const char *home = std::getenv("HOME"); home != nullptr && *home != '\0')
base = std::filesystem::path{home} / ".local" / "state";
else
base = std::filesystem::temp_directory_path();
base /= "openra3";
#endif
auto result = base / "logs";
std::error_code ec;
std::filesystem::create_directories(result, ec);
return result;
}();
return directory;
}
#if defined(_WIN32)
/** Path of the crash report, under the per-user `logs` folder. */
[[nodiscard]] auto crash_log_path() -> const std::filesystem::path & {
static const auto path = log_directory() / L"openra3_crash.log";
return path;
}
@@ -92,7 +134,7 @@ namespace {
* timestamped file on open, so every run gets its own log and the previous
* run's log is preserved.
*/
auto setup_logging(const std::filesystem::path &exe_dir) -> void {
auto setup_logging() -> void {
namespace log = ender::log;
log::configure({.minimum = log::level::info, .stacktrace_from = log::level::warn});
#if defined(__EMSCRIPTEN__)
@@ -100,9 +142,8 @@ namespace {
// console.error regardless of level; use stdout so INFO/WARN appear at
// their real level. There is no file sink on the web.
log::set_sinks({std::make_shared<log::console_sink>(std::cout)});
(void) exe_dir;
#else
log::add_file_sink(exe_dir / "openra3.log", {.max_file_size = 4U * 1024U * 1024U, .max_archives = 10U});
log::add_file_sink(log_directory() / "openra3.log", {.max_file_size = 4U * 1024U * 1024U, .max_archives = 10U});
#endif
log::info("OpenRA3 started");
}
@@ -190,6 +231,32 @@ namespace {
return maps;
}
/** Case-insensitive ASCII ordering (`a` before `b`). */
auto name_less(std::string_view a, std::string_view b) -> bool {
const auto length = std::min(a.size(), b.size());
for (std::size_t i = 0; i < length; ++i) {
const auto ca = static_cast<unsigned char>(std::tolower(static_cast<unsigned char>(a[i])));
const auto cb = static_cast<unsigned char>(std::tolower(static_cast<unsigned char>(b[i])));
if (ca != cb) return ca < cb;
}
return a.size() < b.size();
}
/**
* Order the map list the way the retail skirmish screen does: alphabetically
* by the localized display name (e.g. "Battlebase Beta" before "Cabana
* Republic"), falling back to the id for entries with equal names.
*/
auto sort_maps_by_name(std::vector<asset_map> &maps, const ra3::map::map_name_table &names) -> void {
std::sort(maps.begin(), maps.end(), [&](const asset_map &a, const asset_map &b) {
const auto name_a = names.lookup(a.id);
const auto name_b = names.lookup(b.id);
if (name_less(name_a, name_b)) return true;
if (name_less(name_b, name_a)) return false;
return a.id < b.id;
});
}
/** Find `<id>_art.tga` anywhere under `root`. */
auto find_art(const std::filesystem::path &root, std::string_view id) -> std::optional<std::filesystem::path> {
const auto want = std::string{id} + "_art.tga";
@@ -259,14 +326,18 @@ namespace {
}
const auto world_w = terrain.world_width();
const auto world_h = terrain.world_height();
// An opaque water surface hides anything below it, so cull objects
// and roads submerged under the map's water plane (sunken ships,
// underwater props, ...) instead of drawing them on top of the sea.
const auto cull_below_z = terrain.has_water ? terrain.water_plane_z : -3.4e38F;
scene = ra3::models::build_scene(stream, placements, [&](float x, float y) -> float {
if (x < 0.0F || y < 0.0F || x >= world_w || y >= world_h) return 0.0F;
const auto cx = std::min(terrain.width - 1U, static_cast<ra3::core::uint32>(x / ra3::terrain::cell_size));
const auto cy = std::min(terrain.height - 1U, static_cast<ra3::core::uint32>((world_h - y) / ra3::terrain::cell_size));
return static_cast<float>(terrain.elevation(cx, cy)) * options.z_scale;
});
std::printf("objects: %zu placed, %zu missing, %zu road segments, %zu triangles, %zu textures (%s)\n", scene.placed, scene.missing,
scene.roads, scene.triangle_count(), scene.textures.size(), paths->bin.filename().string().c_str());
}, 128U, cull_below_z);
std::printf("objects: %zu placed, %zu missing, %zu hidden, %zu road segments, %zu triangles, %zu textures (%s)\n", scene.placed, scene.missing,
scene.hidden, scene.roads, scene.triangle_count(), scene.textures.size(), paths->bin.filename().string().c_str());
} catch (const std::exception &error) {
std::printf("objects: failed to build scene (%s)\n", error.what());
}
@@ -410,8 +481,9 @@ namespace {
auto command_maps(const std::filesystem::path &assets) -> int {
if (!ensure_assets(assets)) return 1;
const auto maps = list_asset_maps(assets);
auto maps = list_asset_maps(assets);
const auto names = ra3::map::load_map_names(assets);
sort_maps_by_name(maps, names);
std::printf("maps: %zu\n", maps.size());
for (const auto &m: maps) {
std::error_code ec;
@@ -424,8 +496,9 @@ namespace {
auto command_skirmish(const std::vector<std::string> &args, const std::filesystem::path &assets) -> int {
using namespace ra3;
if (!ensure_assets(assets)) return 1;
const auto maps = list_asset_maps(assets);
auto maps = list_asset_maps(assets);
if (maps.empty()) return 1;
sort_maps_by_name(maps, map::load_map_names(assets));
const auto requested = option_value(args, "--map");
const asset_map *picked = &maps.front();
if (requested) {
@@ -463,8 +536,10 @@ namespace {
auto command_render(const std::vector<std::string> &args, const std::filesystem::path &assets) -> int {
using namespace ra3;
if (!ensure_assets(assets)) return 1;
const auto maps = list_asset_maps(assets);
auto maps = list_asset_maps(assets);
if (maps.empty()) return 1;
const auto names = map::load_map_names(assets);
sort_maps_by_name(maps, names);
const auto requested = option_value(args, "--map");
const asset_map *picked = &maps.front();
if (requested) {
@@ -473,7 +548,6 @@ namespace {
}
render::scene_options scene;
const auto names = map::load_map_names(assets);
scene.title = "OpenRA3 - " + names.lookup(picked->id);
if (const auto size = option_value(args, "--world-size")) scene.world_width = scene.world_height = std::stod(*size);
@@ -1141,7 +1215,9 @@ namespace {
const bool want_gpu = s.mode == 0 && s.out.empty() && s.thumbnail == false;
if (want_gpu) {
view.gpu = terrain::build_gpu_terrain(view.map, view.textures, {}, [&](float t) { report(0.70F + 0.18F * t, "Building terrain..."); });
report(0.62F, "Loading objects...");
const auto objects = build_object_scene(assets, map_file.stem().string(), view.map, terrain::render_options{}, bytes);
view.gpu = terrain::build_gpu_terrain(view.map, view.textures, {}, objects, [&](float t) { report(0.70F + 0.18F * t, "Building terrain..."); });
if (!starts.empty()) {
view.camera3d.target_x = starts[0].x;
view.camera3d.target_y = starts[0].y;
@@ -1185,12 +1261,13 @@ namespace {
auto command_menu(const std::vector<std::string> &args, const std::filesystem::path &assets) -> int {
if (!ensure_assets(assets)) return 1;
const auto maps = list_asset_maps(assets);
auto maps = list_asset_maps(assets);
if (maps.empty()) {
std::puts("no maps found");
return 1;
}
const auto names = ra3::map::load_map_names(assets);
sort_maps_by_name(maps, names);
menu_state st;
// Seed the menu from any command-line flags so they are all visible/editable.
@@ -1307,8 +1384,9 @@ namespace {
/** Render one menu frame to a BMP (headless preview of the menu layout). */
auto command_menu_preview(const std::vector<std::string> &args, const std::filesystem::path &assets) -> int {
if (!ensure_assets(assets)) return 1;
const auto maps = list_asset_maps(assets);
auto maps = list_asset_maps(assets);
const auto names = ra3::map::load_map_names(assets);
sort_maps_by_name(maps, names);
menu_state st;
if (const auto requested = option_value(args, "--map")) {
for (std::size_t i = 0; i < maps.size(); ++i) {
@@ -1338,7 +1416,7 @@ auto main(int argc, char **argv) -> int {
const std::vector<std::string> args{argv + 1, argv + argc};
const auto exe_dir = executable_dir(argc > 0 ? argv[0] : ".");
const auto assets = exe_dir / "assets";
setup_logging(exe_dir);
setup_logging();
#if defined(__EMSCRIPTEN__)
// The wasm build runs on a Web Worker (its runtime's main thread lives
// there), where synchronous XHR is legal, so the asset tree is mounted
+29 -1
View File
@@ -533,7 +533,8 @@ Mirrors SAGE `GameEngine/Common`. Everything else speaks this.
- `[ ]` pass scheduling, barriers, transient/aliased resources `(v0.6)`
- **F3 Terrain render** `[~]`
- `[x]` top-down software + GPU heightfield with blend ramp + gutter atlas
- `[ ]` perspective terrain mesh, LOD, cliff, water `(v0.6)`
- `[ ]` perspective terrain mesh, LOD, cliff `(v0.6)`
- `[x]` water surface in the raymarch: SAGE `Water.frag` port (ocean/river) `[~]`
- **F4 Model render** `[~]`
- `[x]` static map-object triangle soup, world-space, depth-tested over the terrain (Vulkan + software)
- `[x]` bind-pose skinning (bone-space vertices) + ground-decal depth bias
@@ -550,8 +551,35 @@ Mirrors SAGE `GameEngine/Common`. Everything else speaks this.
- `[x]` ARGB framebuffer, blit/line/circle/text, TGA decode, BMP encode
- `[x]` map compositing, grid, markers; headless output
- **F9 Post-processing** `[ ]`
- `[x]` underwater tint/fog in the terrain pass (retail `UnderwaterDeferred.fx`) `[~]`
- `[ ]` bloom, color grading, AA, resolution scaling `(v0.7)`
### M16b shader porting status (retail `Data\Shaders.big` → `*.fxo`)
The retail set is **88 compiled effects** (`Core12\shaders\compiled`, duplicated
in `Misc`/`Shaders`; `Core5`/`Core8` ship only `terrain`; plus a 60-byte
`null` stub). OpenRA3 implements the static-map subset only:
- **Ported (approximate stand-ins, shared by all backends):** `Terrain.fx`
(`terrain.*`), the opaque diffuse subset of `BuildingsGeneric.fx` /
`BasicW3D.fx` / `ObjectsGeneric.fx` (`object.*`), and the SAGE water model
`Ocean.fx` (+ `OceanDisplacement`/`OceanNoVertexTexture`/`RiverWater`/
`RiverReflection`/`UnderwaterDeferred`, folded into the `terrain.*` water
branch — see `docs/REVERSE_ENGINEERING.md`). The retail water flow and bump
maps (`art/terrain/ra3_deepocean.tga`, `ra3_deepocean_nrm.tga`) ride as the
last two terrain-atlas layers, so no backend adds a binding.
- **Not ported:** all faction/variant model shaders (`buildings*`, `objects*`,
`basicw3d*`, `defaultw3d*`, `normalmapped`, `tree`/`treesway`), instances/
animation (`infantry*`), particles and beams (`cpuparticle`, `gpuparticle*`,
`swarmparticle`, `laser*`, `lightning`, `fx*`, `tracer`, `trail`,
`connectionline`, `linerenderers`, `stream`, `rain`, `simple*`), shadows and
ground decals (`shadow`, `decal`, `outlines`, `occlusion`, `terraintracks`),
post-processing (`postfx_*`), and the 2D/misc shaders (`render2d`, `video`,
`bootupscreen`, `debug`, `errormissing`, `rotateenvironmentmap`,
`distortingobject`). The shared retail includes `shadowmap.fxh`, `ssao.fxh`,
`macrotexture.fxh`, `gamma.fxh` are likewise absent (`skinning.fxh` is done at
bind pose only).
### M17 `ra3.ui` — platform layer & backends `[D]`
- **F1 Display abstraction** `[D]`
+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
map. (Recovered by disassembling the embedded `vs_3_0`/`ps_3_0` bytecode.)
### Water / ocean (`Ocean.fx`, `OceanDisplacement.fx`, `RiverWater.fx`, `UnderwaterDeferred.fx`)
The SAGE water surface is reconstructed by OpenSAGE as
`Assets/Shaders/Water.vert`+`Water.frag` (same family as retail `Ocean.fx`). The
surface is a world-space mesh drawn with alpha blending, fed by two render
targets rendered before it: a **reflection** map (scene from the mirrored camera
about `GlobalWaterSettings.ReflectionPlaneZ`) and a **refraction** map + depth.
The fragment model (`Water.frag`) is:
- `waterUV = worldPos.xy / 320`; a scrolling `WaterTexture` supplies both a
flow distortion (`(tex.xy*2-1)*0.05`) and the flow layer; a `BumpTexture`
supplies the surface `worldNormal`.
- `fresnelFactor = dot(viewVector, +Z)`; reflection/refraction are sampled in
screen space (`gl_FragCoord / ViewportSize`), each displaced by the distortion.
- `linearWaterDepth = linearize(RefractionDepth) - linearize(gl_FragCoord.z)`;
`alpha = clamp((linearWaterDepth/2)/TransparentWaterDepth, 0, TransparentWaterMinOpacity)`.
- `final = diffuseColor * textureColor * cloudColor`, then mixed with
`mix(reflectionColor, refractionColor, fresnelFactor)` (both maps on) or just
one of them, per `IsRenderReflection` / `IsRenderRefraction`.
- Per-time-of-day `WaterSet`: `WaterTexture`, `UScrollPerMS`/`VScrollPerMS`,
`DiffuseColor`, `TransparentDiffuseColor`; `WaterTransparency` supplies
`TransparentWaterDepth`/`TransparentWaterMinOpacity`, the skybox faces,
`RiverTransparencyMultiplier`, `ReflectionPlaneZ`/`ReflectionOn`.
OpenRA3 has no water mesh or reflection/refraction targets (its terrain is
raymarched), so the model is folded into the terrain pass (`shaders/terrain.frag`
and its HLSL/GLSL-ES/WGSL twins): the ray's water-plane hit takes a scrolling
wave normal built from the retail bump map combined with a de-gridded procedural
wave, Schlick fresnel (F0 = 0.02), a sky reflection and a depth-graded
refraction, SAGE diffuse + specular lighting, a depth-based transparency fade,
and — when the camera is below `ReflectionPlaneZ` — an underwater tint/fog
(`UnderwaterDeferred.fx`). The two retail maps
`art/terrain/ra3_deepocean.tga` (flow/distortion) and `ra3_deepocean_nrm.tga`
(bump normal) are appended as the **last two layers of the terrain atlas**
(`ra3::terrain::build_gpu_terrain`, `water_flow = layer_count - 2`,
`water_normal = layer_count - 1`), so every backend samples them with the
existing atlas binding; absent maps fall back to a neutral layer. True
reflection/refraction render targets are still the next step.
`GPUParticleOceanDisplacement.fx` drives wave displacement from a GPU particle
buffer and has no analogue here.
The map's `ObjectsList` chunk is a list of nested `Object` assets —
`Coord3D`, Z `angle`, `RoadType` u32, a `u16`-prefixed type-name and a property
list whose keys index the shared name table (`ra3.map::parse_objects`). Each
+162 -42
View File
@@ -8,6 +8,10 @@
// `Terrain.fx` / OpenSAGE `Terrain.frag` do, so it never restarts at a cell
// edge; material boundaries cross-fade with the SAGE blend ramp.
//
// The water plane is shaded with a port of the SAGE water effect
// (`Ocean.fx` / OpenSAGE `Water.frag`); the camera-below-water tint stands in
// for `UnderwaterDeferred.fx`. See docs/REVERSE_ENGINEERING.md.
//
// The Vulkan push constants (20 floats) become a constant buffer.
cbuffer TerrainCB : register(b0) {
@@ -15,7 +19,7 @@ cbuffer TerrainCB : register(b0) {
float4 params; // x=pitch, y=fov, z=water_z, w=has_water
float4 sun; // xyz=sun dir, w=ambient
float4 mapinfo; // x=W, y=H, z=unused, w=z_scale
float4 misc; // x=time, y=unused, z=cells per texture repeat, w=aspect
float4 misc; // x=time, y=water kind (0=ocean,1=river), z=cells per repeat, w=aspect
};
Texture2D<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
// SAGE water model constants (see docs/REVERSE_ENGINEERING.md).
static const float WATER_SCALE = 1.0 / 320.0;
static const float WATER_TRANSPARENT_DEPTH = 10.0;
static const float WATER_MIN_OPACITY = 0.70;
static const float WATER_RIVER_MULTIPLIER = 1.0;
struct VSOut {
float4 pos : SV_Position;
float2 uv : TEXCOORD0;
@@ -104,6 +114,105 @@ float3 sample_layer(uint layer, float wx, float wy) {
return atlas.Sample(atlas_smp, float3(float2(wx, wy) / span, l)).rgb;
}
// ---- SAGE water (Ocean.fx / RiverWater.fx / Water.frag port) ----------------
// Sample an atlas layer by an explicit layer index (the water flow/bump maps
// are appended as the last two layers of the tile atlas).
float3 water_tex(int layer, float2 uv) {
uint w = 0;
uint h = 0;
uint lc = 0;
atlas.GetDimensions(w, h, lc);
float l = (float) clamp(layer, 0, (int) lc - 1);
return atlas.Sample(atlas_smp, float3(uv, l)).rgb;
}
// Scrolling wave normal on the water plane: the retail bump map (atlas's last
// layer), offset by the flow map (second-last layer) and combined with a
// de-gridded procedural wave so the sun glint is irregular and always moving.
float3 water_normal(float2 world_xy, float time) {
float2 q = world_xy * (WATER_SCALE * 6.0);
float a1 = q.x * 0.80 + q.y * 0.30 + time * 1.10;
float a2 = q.y * 0.95 - q.x * 0.45 - time * 1.30;
float a3 = (q.x + q.y) * 1.60 + time * 2.10;
float a4 = (q.x - q.y) * 2.30 - time * 1.70;
float dx = 0.224 * cos(a1) - 0.099 * cos(a2) + 0.256 * cos(a3) + 0.230 * cos(a4);
float dy = 0.084 * cos(a1) + 0.209 * cos(a2) + 0.256 * cos(a3) - 0.230 * cos(a4);
uint w = 0;
uint h = 0;
uint lc = 0;
atlas.GetDimensions(w, h, lc);
float3 flow = water_tex((int) lc - 2, q - float2(time * 0.010, time * 0.014)) * 2.0 - 1.0;
float3 bump = water_tex((int) lc - 1, q + flow.xy * 0.05 + float2(time * 0.006, time * 0.008)) * 2.0 - 1.0;
float sx = -dx * 0.30 + bump.x * 0.45;
float sy = -dy * 0.30 + bump.y * 0.45;
return normalize(float3(sx, sy, 1.0));
}
// Water.frag distortionPower * the flow texture: a small scrolling UV offset.
float water_distortion(float2 world_xy, float time) {
float2 q = world_xy * (WATER_SCALE * 6.0);
return 0.05 * (sin(q.x * 0.9 + time * 0.7) + sin(q.y * 1.1 - time * 0.5));
}
// Water.frag GetCloudColor: no cloud texture is bound, so a slow low-frequency
// scroll stands in.
float3 water_cloud(float2 world_xy, float time) {
return float3(0.85 + 0.15 * sin((world_xy.x + world_xy.y) * 0.0007 - time * 0.05));
}
// Retail UnderwaterDeferred.fx: absorbs red and fogs with distance. Applied to
// every output while the camera is below the water plane.
float3 apply_underwater(float3 color, float dist, float cam_z, float water_z) {
if (cam_z >= water_z - 0.5) return color;
const float3 absorb = float3(0.35, 0.62, 0.75);
float fog = clamp(1.0 - exp(-dist * 0.00022), 0.0, 0.9);
return lerp(color * absorb, float3(0.02, 0.10, 0.16), fog);
}
// Water.frag: fresnel-mixed reflection (sky) and refraction (depth-graded
// seabed), SAGE diffuse + specular lighting, cloud term, and a depth-based
// transparency fade. `river` (>0.5) applies RiverTransparencyMultiplier.
float3 water_shade(float3 hitpos, float3 dir, float dist) {
float time = misc.x;
float river = misc.y;
float seabed = world_height(hitpos.x, hitpos.y);
float depth = max(0.0, params.z - seabed);
float3 n = water_normal(hitpos.xy, time + water_distortion(hitpos.xy, time));
float3 sun_dir = normalize(sun.xyz);
// Schlick fresnel, water F0 = 0.02.
float cos_theta = clamp(dot(-dir, n), 0.0, 1.0);
float fresnel = 0.02 + 0.98 * pow(1.0 - cos_theta, 5.0);
// Reflection: the sky the surface mirrors. Refraction: the seabed, graded
// from shallow to deep and lit by the SAGE diffuse + specular model.
float3 reflection = sky_color(reflect(dir, n));
float3 shallow = float3(0.10, 0.34, 0.38);
float3 deep = float3(0.02, 0.12, 0.22);
float3 refraction = lerp(shallow, deep, clamp(depth / 40.0, 0.0, 1.0));
float ndotl = max(dot(n, sun_dir), 0.0);
float ambient = sun.w;
float3 diffuse = float3(ambient + (1.0 - ambient) * ndotl);
float3 half_v = normalize(sun_dir - dir);
float spec = pow(max(dot(n, half_v), 0.0), 90.0);
float3 color = lerp(refraction, reflection, clamp(fresnel, 0.0, 1.0)) * diffuse * water_cloud(hitpos.xy, time);
color += float3(1.0, 0.97, 0.9) * spec * 0.45;
// Depth-based transparency: shallow water shows the seabed, deep water goes
// opaque toward the deep colour.
float alpha = clamp(depth / WATER_TRANSPARENT_DEPTH, 0.0, 1.0) * WATER_MIN_OPACITY;
if (river > 0.5) alpha *= WATER_RIVER_MULTIPLIER;
color = lerp(refraction, color, clamp(alpha + 0.15, 0.0, 1.0));
// Distance haze toward the horizon, as the terrain.
float wfog = clamp(1.0 - exp(-dist * 0.00009), 0.0, 0.75);
return lerp(color, sky_color(float3(dir.x, dir.y, 0.0)), wfog);
}
float4 PSMain(VSOut input) : SV_Target {
float4 p = cam;
float pitch = clamp(params.x, 0.15, 1.45);
@@ -127,50 +236,75 @@ float4 PSMain(VSOut input) : SV_Target {
float3 dir = normalize(fwd + right * ndc.x * th * aspect + up * ndc.y * th);
if (dir.z >= -1e-4) {
return float4(sky_color(dir), 1.0);
return float4(apply_underwater(sky_color(dir), 0.0, cam_pos.z, params.z), 1.0);
}
// March the heightfield. The step grows quickly: the map diagonal is only
// ~9000 world units, so marching past ~20000 adds cost without detail, and
// a 6% growth rate more than doubles the worst-case iteration count.
float t = CELL * 0.5;
float dt = CELL * 0.5;
// Clip the ray to the map's XY rectangle: the boundary is an exact plane,
// so the silhouette there stays razor-sharp instead of stair-stepping
// across it. Outside the map is sky.
float t_enter = 0.0;
float t_exit = 1.0e30;
bool inside = true;
if (abs(dir.x) < 1e-6) {
inside = (cam_pos.x >= 0.0 && cam_pos.x <= world_w);
} else {
float a = (0.0 - cam_pos.x) / dir.x;
float b = (world_w - cam_pos.x) / dir.x;
t_enter = max(t_enter, min(a, b));
t_exit = min(t_exit, max(a, b));
}
if (inside) {
if (abs(dir.y) < 1e-6) {
inside = (cam_pos.y >= 0.0 && cam_pos.y <= world_h);
} else {
float a = (0.0 - cam_pos.y) / dir.y;
float b = (world_h - cam_pos.y) / dir.y;
t_enter = max(t_enter, min(a, b));
t_exit = min(t_exit, max(a, b));
}
}
if (!inside || t_exit <= 0.0) {
return float4(apply_underwater(sky_color(dir), 0.0, cam_pos.z, params.z), 1.0);
}
// March the heightfield cell by cell: the step is never longer than the
// time to cross one cell (dominant horizontal axis), while a clearance term
// lets the ray skip the empty air above the surface. Resolving every cell is
// what keeps cliff and map-edge silhouettes from quantising into huge
// stair-steps that crawl as the camera pans.
float horiz = max(abs(dir.x), abs(dir.y));
float cell_step = min(CELL / max(horiz, 1e-4), CELL * 32.0);
float t = max(t_enter, CELL * 0.5);
float prev = t;
bool hit = false;
float hit_t = 0.0;
for (int i = 0; i < 256 && t < 20000.0; ++i) {
for (int i = 0; i < 1024 && t <= t_exit; ++i) {
float3 w = cam_pos + dir * t;
if (w.x < 0.0 || w.y < 0.0 || w.x >= world_w || w.y >= world_h) {
prev = t;
dt *= 1.10;
t += dt;
continue;
}
if (params.w > 0.5 && w.z <= params.z) {
hit = true;
hit_t = t;
break;
}
if (w.z <= world_height(w.x, w.y)) {
float h = world_height(w.x, w.y);
float surface = (params.w > 0.5) ? max(h, params.z) : h;
if (w.z <= surface) {
hit = true;
hit_t = t;
break;
}
float clearance = (w.z - surface) / max(-dir.z, 1e-4);
prev = t;
dt *= 1.10;
t += dt;
t += clamp(clearance, cell_step, cell_step * 8.0);
}
if (!hit) {
return float4(sky_color(dir), 1.0);
return float4(apply_underwater(sky_color(dir), 0.0, cam_pos.z, params.z), 1.0);
}
// Refine the first crossing; with a sub-cell bracket this converges to the
// exact surface point.
float lo = prev;
float hi = hit_t;
for (int i = 0; i < 6; ++i) {
for (int i = 0; i < 18; ++i) {
float mid = 0.5 * (lo + hi);
float3 w = cam_pos + dir * mid;
bool water = params.w > 0.5 && w.z <= params.z;
if (water || w.z <= world_height(w.x, w.y)) {
float h = world_height(w.x, w.y);
float surface = (params.w > 0.5) ? max(h, params.z) : h;
if (w.z <= surface) {
hi = mid;
} else {
lo = mid;
@@ -182,21 +316,7 @@ float4 PSMain(VSOut input) : SV_Target {
float ambient = sun.w;
if (params.w > 0.5 && hitpos.z <= params.z + 0.01) {
// Water: animated normal from a procedural wave, sky reflection + fresnel.
float time = misc.x;
float2 q = hitpos.xy * 0.015;
float nx = sin(q.x * 1.3 + time * 1.7) + 0.5 * sin(q.x * 3.1 - time * 2.3);
float ny = sin(q.y * 1.1 - time * 1.3) + 0.5 * sin(q.y * 2.7 + time * 1.9);
float3 n = normalize(float3(nx * 0.06, ny * 0.06, 1.0));
float fres = pow(1.0 - clamp(-dir.z, 0.0, 1.0), 3.0);
float3 deep = float3(0.03, 0.16, 0.28);
float3 refl = sky_color(reflect(dir, n));
float lam = max(0.0, dot(n, sun_dir));
float3 water = lerp(deep, refl, clamp(0.25 + 0.55 * fres, 0.0, 0.9));
water += float3(1.0, 0.98, 0.9) * pow(lam, 64.0) * 0.6; // sun glint
float wfog = clamp(1.0 - exp(-hi * 0.00009), 0.0, 0.75);
water = lerp(water, sky_color(float3(dir.x, dir.y, 0.0)), wfog);
return float4(water, 1.0);
return float4(apply_underwater(water_shade(hitpos, dir, hi), hi, cam_pos.z, params.z), 1.0);
}
// Terrain: read the per-cell blend record, sample the base/blend/three-way
@@ -234,5 +354,5 @@ float4 PSMain(VSOut input) : SV_Target {
// Distance haze toward the horizon so the map edge blends into the sky.
float fog = clamp(1.0 - exp(-hi * 0.00009), 0.0, 0.75);
lit = lerp(lit, sky_color(float3(dir.x, dir.y, 0.0)), fog);
return float4(lit, 1.0);
return float4(apply_underwater(lit, hi, cam_pos.z, params.z), 1.0);
}
Binary file not shown.
+159 -34
View File
@@ -8,6 +8,10 @@
// The material is sampled **continuously** (`uv = cell / (2 * cellSize)`), as the
// retail `Terrain.fx` / OpenSAGE `Terrain.frag` do, so it never restarts at a
// cell edge; material boundaries cross-fade with the SAGE blend ramp.
//
// The water plane is shaded with a port of the SAGE water effect
// (`Ocean.fx` / OpenSAGE `Water.frag`); the camera-below-water tint stands in
// for `UnderwaterDeferred.fx`. See docs/REVERSE_ENGINEERING.md.
layout(binding = 0) uniform sampler2D heightmap;
layout(binding = 1) uniform sampler2D celldata;
layout(binding = 2) uniform sampler2DArray atlas;
@@ -17,7 +21,7 @@ layout(push_constant) uniform Push {
vec4 params; // x=pitch, y=fov, z=water_z, w=has_water
vec4 sun; // xyz=sun dir, w=ambient
vec4 mapinfo; // x=W, y=H, z=unused, w=z_scale
vec4 misc; // x=time, y=unused, z=cells per texture repeat, w=aspect
vec4 misc; // x=time, y=water kind (0=ocean,1=river), z=cells per repeat, w=aspect
} pc;
layout(location = 0) in vec2 in_uv;
@@ -30,6 +34,12 @@ const float CELL = 10.0; // must match ra3::terrain::cell_size
const float NEAR = 10.0;
const float FAR = 60000.0;
// SAGE water model constants (see docs/REVERSE_ENGINEERING.md).
const float WATER_SCALE = 1.0 / 320.0; // Water.frag: worldPos.xy / 320
const float WATER_TRANSPARENT_DEPTH = 10.0; // WaterTransparency.TransparentWaterDepth
const float WATER_MIN_OPACITY = 0.70; // WaterTransparency.TransparentWaterMinOpacity
const float WATER_RIVER_MULTIPLIER = 1.0; // WaterTransparency.RiverTransparencyMultiplier
float height_at(ivec2 c) {
c = clamp(c, ivec2(0), ivec2(pc.mapinfo.xy) - 1);
return texelFetch(heightmap, c, 0).r * 65535.0 * pc.mapinfo.w;
@@ -92,6 +102,99 @@ vec3 sample_layer(uint layer, float wx, float wy) {
return texture(atlas, vec3(vec2(wx, wy) / span, l)).rgb;
}
// ---- SAGE water (Ocean.fx / RiverWater.fx / Water.frag port) ----------------
// Sample an atlas layer by an explicit float layer index (the water flow/bump
// maps are appended as the last two layers of the tile atlas).
vec3 water_tex(int layer, vec2 uv) {
int lc = textureSize(atlas, 0).z;
float l = float(clamp(layer, 0, max(lc - 1, 0)));
return texture(atlas, vec3(uv, l)).rgb;
}
// Scrolling wave normal on the water plane: the retail bump map (atlas's last
// layer), offset by the flow map (second-last layer) and combined with a
// de-gridded procedural wave so the sun glint is irregular and always moving.
vec3 water_normal(vec2 world_xy, float time) {
vec2 q = world_xy * (WATER_SCALE * 6.0);
float a1 = q.x * 0.80 + q.y * 0.30 + time * 1.10;
float a2 = q.y * 0.95 - q.x * 0.45 - time * 1.30;
float a3 = (q.x + q.y) * 1.60 + time * 2.10;
float a4 = (q.x - q.y) * 2.30 - time * 1.70;
float dx = 0.224 * cos(a1) - 0.099 * cos(a2) + 0.256 * cos(a3) + 0.230 * cos(a4);
float dy = 0.084 * cos(a1) + 0.209 * cos(a2) + 0.256 * cos(a3) - 0.230 * cos(a4);
int lc = textureSize(atlas, 0).z;
vec3 flow = water_tex(lc - 2, q - vec2(time * 0.010, time * 0.014)) * 2.0 - 1.0;
vec3 bump = water_tex(lc - 1, q + flow.xy * 0.05 + vec2(time * 0.006, time * 0.008)) * 2.0 - 1.0;
float sx = -dx * 0.30 + bump.x * 0.45;
float sy = -dy * 0.30 + bump.y * 0.45;
return normalize(vec3(sx, sy, 1.0));
}
// Water.frag distortionPower * the flow texture: a small scrolling UV offset.
float water_distortion(vec2 world_xy, float time) {
vec2 q = world_xy * (WATER_SCALE * 6.0);
return 0.05 * (sin(q.x * 0.9 + time * 0.7) + sin(q.y * 1.1 - time * 0.5));
}
// Water.frag GetCloudColor: no cloud texture is bound, so a slow low-frequency
// scroll stands in.
vec3 water_cloud(vec2 world_xy, float time) {
return vec3(0.85 + 0.15 * sin((world_xy.x + world_xy.y) * 0.0007 - time * 0.05));
}
// Retail UnderwaterDeferred.fx: absorbs red and fogs with distance. Applied to
// every output while the camera is below the water plane.
vec3 apply_underwater(vec3 color, float dist, float cam_z, float water_z) {
if (cam_z >= water_z - 0.5) return color;
const vec3 absorb = vec3(0.35, 0.62, 0.75);
float fog = clamp(1.0 - exp(-dist * 0.00022), 0.0, 0.9);
return mix(color * absorb, vec3(0.02, 0.10, 0.16), fog);
}
// Water.frag: fresnel-mixed reflection (sky) and refraction (depth-graded
// seabed), SAGE diffuse + specular lighting, cloud term, and a depth-based
// transparency fade. `river` (>0.5) applies RiverTransparencyMultiplier.
vec3 water_shade(vec3 hitpos, vec3 dir, float dist) {
float time = pc.misc.x;
float river = pc.misc.y;
float seabed = world_height(hitpos.x, hitpos.y);
float depth = max(0.0, pc.params.z - seabed);
vec3 n = water_normal(hitpos.xy, time + water_distortion(hitpos.xy, time));
vec3 sun = normalize(pc.sun.xyz);
// Schlick fresnel, water F0 = 0.02.
float cos_theta = clamp(dot(-dir, n), 0.0, 1.0);
float fresnel = 0.02 + 0.98 * pow(1.0 - cos_theta, 5.0);
// Reflection: the sky the surface mirrors. Refraction: the seabed, graded
// from shallow to deep and lit by the SAGE diffuse + specular model.
vec3 reflection = sky_color(reflect(dir, n));
vec3 shallow = vec3(0.10, 0.34, 0.38);
vec3 deep = vec3(0.02, 0.12, 0.22);
vec3 refraction = mix(shallow, deep, clamp(depth / 40.0, 0.0, 1.0));
float ndotl = max(dot(n, sun), 0.0);
float ambient = pc.sun.w;
vec3 diffuse = vec3(ambient + (1.0 - ambient) * ndotl);
vec3 half_v = normalize(sun - dir);
float spec = pow(max(dot(n, half_v), 0.0), 90.0);
vec3 color = mix(refraction, reflection, clamp(fresnel, 0.0, 1.0)) * diffuse * water_cloud(hitpos.xy, time);
color += vec3(1.0, 0.97, 0.9) * spec * 0.45;
// Depth-based transparency: shallow water shows the seabed, deep water goes
// opaque toward the deep colour.
float alpha = clamp(depth / WATER_TRANSPARENT_DEPTH, 0.0, 1.0) * WATER_MIN_OPACITY;
if (river > 0.5) alpha *= WATER_RIVER_MULTIPLIER;
color = mix(refraction, color, clamp(alpha + 0.15, 0.0, 1.0));
// Distance haze toward the horizon, as the terrain.
float wfog = clamp(1.0 - exp(-dist * 0.00009), 0.0, 0.75);
return mix(color, sky_color(vec3(dir.x, dir.y, 0.0)), wfog);
}
void main() {
vec4 p = pc.cam;
float pitch = clamp(pc.params.x, 0.15, 1.45);
@@ -118,35 +221,71 @@ void main() {
if (dir.z >= -1e-4) {
gl_FragDepth = 1.0;
out_color = vec4(sky_color(dir), 1.0);
out_color = vec4(apply_underwater(sky_color(dir), 0.0, cam.z, pc.params.z), 1.0);
return;
}
// March the heightfield. The step grows quickly: the map diagonal is only
// ~9000 world units, so marching past ~20000 adds cost without detail, and
// a 6% growth rate more than doubles the worst-case iteration count.
float t = CELL * 0.5;
float dt = CELL * 0.5;
// Clip the ray to the map's XY rectangle: the boundary is an exact plane,
// so the silhouette there stays razor-sharp instead of stair-stepping
// across it. Outside the map is sky.
float t_enter = 0.0;
float t_exit = 1.0e30;
bool inside = true;
if (abs(dir.x) < 1e-6) {
inside = (cam.x >= 0.0 && cam.x <= world_w);
} else {
float a = (0.0 - cam.x) / dir.x;
float b = (world_w - cam.x) / dir.x;
t_enter = max(t_enter, min(a, b));
t_exit = min(t_exit, max(a, b));
}
if (inside) {
if (abs(dir.y) < 1e-6) {
inside = (cam.y >= 0.0 && cam.y <= world_h);
} else {
float a = (0.0 - cam.y) / dir.y;
float b = (world_h - cam.y) / dir.y;
t_enter = max(t_enter, min(a, b));
t_exit = min(t_exit, max(a, b));
}
}
if (!inside || t_exit <= 0.0) {
gl_FragDepth = 1.0;
out_color = vec4(apply_underwater(sky_color(dir), 0.0, cam.z, pc.params.z), 1.0);
return;
}
// March the heightfield cell by cell: the step is never longer than the
// time to cross one cell (dominant horizontal axis), while a clearance term
// lets the ray skip the empty air above the surface. Resolving every cell is
// what keeps cliff and map-edge silhouettes from quantising into huge
// stair-steps that crawl as the camera pans.
float horiz = max(abs(dir.x), abs(dir.y));
float cell_step = min(CELL / max(horiz, 1e-4), CELL * 32.0);
float t = max(t_enter, CELL * 0.5);
float prev = t;
bool hit = false;
float hit_t = 0.0;
for (int i = 0; i < 256 && t < 20000.0; ++i) {
for (int i = 0; i < 1024 && t <= t_exit; ++i) {
vec3 w = cam + dir * t;
if (w.x < 0.0 || w.y < 0.0 || w.x >= world_w || w.y >= world_h) {
prev = t; dt *= 1.10; t += dt; continue;
float h = world_height(w.x, w.y);
float surface = (pc.params.w > 0.5) ? max(h, pc.params.z) : h;
if (w.z <= surface) { hit = true; hit_t = t; break; }
float clearance = (w.z - surface) / max(-dir.z, 1e-4);
prev = t;
t += clamp(clearance, cell_step, cell_step * 8.0);
}
if (pc.params.w > 0.5 && w.z <= pc.params.z) { hit = true; hit_t = t; break; }
if (w.z <= world_height(w.x, w.y)) { hit = true; hit_t = t; break; }
prev = t; dt *= 1.10; t += dt;
}
if (!hit) { gl_FragDepth = 1.0; out_color = vec4(sky_color(dir), 1.0); return; }
if (!hit) { gl_FragDepth = 1.0; out_color = vec4(apply_underwater(sky_color(dir), 0.0, cam.z, pc.params.z), 1.0); return; }
// Refine the first crossing; with a sub-cell bracket this converges to the
// exact surface point.
float lo = prev, hi = hit_t;
for (int i = 0; i < 6; ++i) {
for (int i = 0; i < 18; ++i) {
float mid = 0.5 * (lo + hi);
vec3 w = cam + dir * mid;
bool water = pc.params.w > 0.5 && w.z <= pc.params.z;
if (water || w.z <= world_height(w.x, w.y)) hi = mid; else lo = mid;
float h = world_height(w.x, w.y);
float surface = (pc.params.w > 0.5) ? max(h, pc.params.z) : h;
if (w.z <= surface) hi = mid; else lo = mid;
}
vec3 hitpos = cam + dir * hi;
@@ -158,21 +297,7 @@ void main() {
float ambient = pc.sun.w;
if (pc.params.w > 0.5 && hitpos.z <= pc.params.z + 0.01) {
// Water: animated normal from a procedural wave, sky reflection + fresnel.
float time = pc.misc.x;
vec2 q = hitpos.xy * 0.015;
float nx = sin(q.x * 1.3 + time * 1.7) + 0.5 * sin(q.x * 3.1 - time * 2.3);
float ny = sin(q.y * 1.1 - time * 1.3) + 0.5 * sin(q.y * 2.7 + time * 1.9);
vec3 n = normalize(vec3(nx * 0.06, ny * 0.06, 1.0));
float fres = pow(1.0 - clamp(-dir.z, 0.0, 1.0), 3.0);
vec3 deep = vec3(0.03, 0.16, 0.28);
vec3 refl = sky_color(reflect(dir, n));
float lam = max(0.0, dot(n, sun));
vec3 water = mix(deep, refl, clamp(0.25 + 0.55 * fres, 0.0, 0.9));
water += vec3(1.0, 0.98, 0.9) * pow(lam, 64.0) * 0.6; // sun glint
float wfog = clamp(1.0 - exp(-hi * 0.00009), 0.0, 0.75);
water = mix(water, sky_color(vec3(dir.x, dir.y, 0.0)), wfog);
out_color = vec4(water, 1.0);
out_color = vec4(apply_underwater(water_shade(hitpos, dir, hi), hi, cam.z, pc.params.z), 1.0);
return;
}
@@ -211,5 +336,5 @@ void main() {
// Distance haze toward the horizon so the map edge blends into the sky.
float fog = clamp(1.0 - exp(-hi * 0.00009), 0.0, 0.75);
lit = mix(lit, sky_color(vec3(dir.x, dir.y, 0.0)), fog);
out_color = vec4(lit, 1.0);
out_color = vec4(apply_underwater(lit, hi, cam.z, pc.params.z), 1.0);
}
+144 -40
View File
@@ -24,6 +24,12 @@ out vec4 frag_color;
const float CELL = 10.0; // must match ra3::terrain::cell_size
// SAGE water model constants (see docs/REVERSE_ENGINEERING.md).
const float WATER_SCALE = 1.0 / 320.0;
const float WATER_TRANSPARENT_DEPTH = 10.0;
const float WATER_MIN_OPACITY = 0.70;
const float WATER_RIVER_MULTIPLIER = 1.0;
float height_at(ivec2 c) {
c = clamp(c, ivec2(0), ivec2(u_mapinfo.xy) - 1);
return texelFetch(u_heightmap, c, 0).r * 65535.0 * u_mapinfo.w;
@@ -82,6 +88,91 @@ vec3 sample_layer(uint layer, float wx, float wy) {
return texture(u_atlas, vec3(vec2(wx, wy) / span, l)).bgr;
}
// ---- SAGE water (Ocean.fx / RiverWater.fx / Water.frag port) ----------------
// Sample an atlas layer by an explicit layer index (the water flow/bump maps
// are appended as the last two layers of the tile atlas). The atlas is
// 0xAARRGGBB, so `.bgr` restores RGB (as `sample_layer`).
vec3 water_tex(int layer, vec2 uv) {
int lc = textureSize(u_atlas, 0).z;
float l = float(clamp(layer, 0, max(lc - 1, 0)));
return texture(u_atlas, vec3(uv, l)).bgr;
}
// Scrolling wave normal on the water plane: the retail bump map (atlas's last
// layer), offset by the flow map (second-last layer) and combined with a
// de-gridded procedural wave so the sun glint is irregular and always moving.
vec3 water_normal(vec2 world_xy, float time) {
vec2 q = world_xy * (WATER_SCALE * 6.0);
float a1 = q.x * 0.80 + q.y * 0.30 + time * 1.10;
float a2 = q.y * 0.95 - q.x * 0.45 - time * 1.30;
float a3 = (q.x + q.y) * 1.60 + time * 2.10;
float a4 = (q.x - q.y) * 2.30 - time * 1.70;
float dx = 0.224 * cos(a1) - 0.099 * cos(a2) + 0.256 * cos(a3) + 0.230 * cos(a4);
float dy = 0.084 * cos(a1) + 0.209 * cos(a2) + 0.256 * cos(a3) - 0.230 * cos(a4);
int lc = textureSize(u_atlas, 0).z;
vec3 flow = water_tex(lc - 2, q - vec2(time * 0.010, time * 0.014)) * 2.0 - 1.0;
vec3 bump = water_tex(lc - 1, q + flow.xy * 0.05 + vec2(time * 0.006, time * 0.008)) * 2.0 - 1.0;
float sx = -dx * 0.30 + bump.x * 0.45;
float sy = -dy * 0.30 + bump.y * 0.45;
return normalize(vec3(sx, sy, 1.0));
}
// Water.frag distortionPower * the flow texture: a small scrolling UV offset.
float water_distortion(vec2 world_xy, float time) {
vec2 q = world_xy * (WATER_SCALE * 6.0);
return 0.05 * (sin(q.x * 0.9 + time * 0.7) + sin(q.y * 1.1 - time * 0.5));
}
// Water.frag GetCloudColor: no cloud texture is bound, so a slow scroll stands in.
vec3 water_cloud(vec2 world_xy, float time) {
return vec3(0.85 + 0.15 * sin((world_xy.x + world_xy.y) * 0.0007 - time * 0.05));
}
// Retail UnderwaterDeferred.fx: absorbs red and fogs with distance.
vec3 apply_underwater(vec3 color, float dist, float cam_z, float water_z) {
if (cam_z >= water_z - 0.5) return color;
const vec3 absorb = vec3(0.35, 0.62, 0.75);
float fog = clamp(1.0 - exp(-dist * 0.00022), 0.0, 0.9);
return mix(color * absorb, vec3(0.02, 0.10, 0.16), fog);
}
// Water.frag: fresnel-mixed sky reflection and depth-graded seabed refraction,
// SAGE diffuse + specular lighting, cloud term, depth-based transparency fade.
vec3 water_shade(vec3 hitpos, vec3 dir, float dist) {
float time = u_misc.x;
float river = u_misc.y;
float seabed = world_height(hitpos.x, hitpos.y);
float depth = max(0.0, u_params.z - seabed);
vec3 n = water_normal(hitpos.xy, time + water_distortion(hitpos.xy, time));
vec3 sun_dir = normalize(u_sun.xyz);
float cos_theta = clamp(dot(-dir, n), 0.0, 1.0);
float fresnel = 0.02 + 0.98 * pow(1.0 - cos_theta, 5.0);
vec3 reflection = sky_color(reflect(dir, n));
vec3 shallow = vec3(0.10, 0.34, 0.38);
vec3 deep = vec3(0.02, 0.12, 0.22);
vec3 refraction = mix(shallow, deep, clamp(depth / 40.0, 0.0, 1.0));
float ndotl = max(dot(n, sun_dir), 0.0);
float ambient = u_sun.w;
vec3 diffuse = vec3(ambient + (1.0 - ambient) * ndotl);
vec3 half_v = normalize(sun_dir - dir);
float spec = pow(max(dot(n, half_v), 0.0), 90.0);
vec3 color = mix(refraction, reflection, clamp(fresnel, 0.0, 1.0)) * diffuse * water_cloud(hitpos.xy, time);
color += vec3(1.0, 0.97, 0.9) * spec * 0.45;
float alpha = clamp(depth / WATER_TRANSPARENT_DEPTH, 0.0, 1.0) * WATER_MIN_OPACITY;
if (river > 0.5) alpha *= WATER_RIVER_MULTIPLIER;
color = mix(refraction, color, clamp(alpha + 0.15, 0.0, 1.0));
float wfog = clamp(1.0 - exp(-dist * 0.00009), 0.0, 0.75);
return mix(color, sky_color(vec3(dir.x, dir.y, 0.0)), wfog);
}
void main() {
vec4 p = u_cam;
float pitch = clamp(u_params.x, 0.15, 1.45);
@@ -105,52 +196,78 @@ void main() {
vec3 dir = normalize(fwd + right * ndc.x * th * aspect + up * ndc.y * th);
if (dir.z >= -1e-4) {
frag_color = vec4(sky_color(dir), 1.0);
frag_color = vec4(apply_underwater(sky_color(dir), 0.0, cam.z, u_params.z), 1.0);
return;
}
// March the heightfield. The step grows quickly: the map diagonal is only
// ~9000 world units, so marching past ~20000 adds cost without detail, and
// a 6% growth rate more than doubles the worst-case iteration count.
float t = CELL * 0.5;
float dt = CELL * 0.5;
// Clip the ray to the map's XY rectangle: the boundary is an exact plane,
// so the silhouette there stays razor-sharp instead of stair-stepping
// across it. Outside the map is sky.
float t_enter = 0.0;
float t_exit = 1.0e30;
bool inside = true;
if (abs(dir.x) < 1e-6) {
inside = (cam.x >= 0.0 && cam.x <= world_w);
} else {
float a = (0.0 - cam.x) / dir.x;
float b = (world_w - cam.x) / dir.x;
t_enter = max(t_enter, min(a, b));
t_exit = min(t_exit, max(a, b));
}
if (inside) {
if (abs(dir.y) < 1e-6) {
inside = (cam.y >= 0.0 && cam.y <= world_h);
} else {
float a = (0.0 - cam.y) / dir.y;
float b = (world_h - cam.y) / dir.y;
t_enter = max(t_enter, min(a, b));
t_exit = min(t_exit, max(a, b));
}
}
if (!inside || t_exit <= 0.0) {
frag_color = vec4(apply_underwater(sky_color(dir), 0.0, cam.z, u_params.z), 1.0);
return;
}
// March the heightfield cell by cell: the step is never longer than the
// time to cross one cell (dominant horizontal axis), while a clearance term
// lets the ray skip the empty air above the surface. Resolving every cell is
// what keeps cliff and map-edge silhouettes from quantising into huge
// stair-steps that crawl as the camera pans.
float horiz = max(abs(dir.x), abs(dir.y));
float cell_step = min(CELL / max(horiz, 1e-4), CELL * 32.0);
float t = max(t_enter, CELL * 0.5);
float prev = t;
bool hit = false;
float hit_t = 0.0;
for (int i = 0; i < 256 && t < 20000.0; ++i) {
for (int i = 0; i < 1024 && t <= t_exit; ++i) {
vec3 w = cam + dir * t;
if (w.x < 0.0 || w.y < 0.0 || w.x >= world_w || w.y >= world_h) {
prev = t;
dt *= 1.10;
t += dt;
continue;
}
if (u_params.w > 0.5 && w.z <= u_params.z) {
hit = true;
hit_t = t;
break;
}
if (w.z <= world_height(w.x, w.y)) {
float h = world_height(w.x, w.y);
float surface = (u_params.w > 0.5) ? max(h, u_params.z) : h;
if (w.z <= surface) {
hit = true;
hit_t = t;
break;
}
float clearance = (w.z - surface) / max(-dir.z, 1e-4);
prev = t;
dt *= 1.10;
t += dt;
t += clamp(clearance, cell_step, cell_step * 8.0);
}
if (!hit) {
frag_color = vec4(sky_color(dir), 1.0);
frag_color = vec4(apply_underwater(sky_color(dir), 0.0, cam.z, u_params.z), 1.0);
return;
}
// Refine the first crossing; with a sub-cell bracket this converges to the
// exact surface point.
float lo = prev;
float hi = hit_t;
for (int i = 0; i < 6; ++i) {
for (int i = 0; i < 18; ++i) {
float mid = 0.5 * (lo + hi);
vec3 w = cam + dir * mid;
bool water = u_params.w > 0.5 && w.z <= u_params.z;
if (water || w.z <= world_height(w.x, w.y)) {
float h = world_height(w.x, w.y);
float surface = (u_params.w > 0.5) ? max(h, u_params.z) : h;
if (w.z <= surface) {
hi = mid;
} else {
lo = mid;
@@ -162,20 +279,7 @@ void main() {
float ambient = u_sun.w;
if (u_params.w > 0.5 && hitpos.z <= u_params.z + 0.01) {
float time = u_misc.x;
vec2 q = hitpos.xy * 0.015;
float nx = sin(q.x * 1.3 + time * 1.7) + 0.5 * sin(q.x * 3.1 - time * 2.3);
float ny = sin(q.y * 1.1 - time * 1.3) + 0.5 * sin(q.y * 2.7 + time * 1.9);
vec3 n = normalize(vec3(nx * 0.06, ny * 0.06, 1.0));
float fres = pow(1.0 - clamp(-dir.z, 0.0, 1.0), 3.0);
vec3 deep = vec3(0.03, 0.16, 0.28);
vec3 refl = sky_color(reflect(dir, n));
float lam = max(0.0, dot(n, sun));
vec3 water = mix(deep, refl, clamp(0.25 + 0.55 * fres, 0.0, 0.9));
water += vec3(1.0, 0.98, 0.9) * pow(lam, 64.0) * 0.6;
float wfog = clamp(1.0 - exp(-hi * 0.00009), 0.0, 0.75);
water = mix(water, sky_color(vec3(dir.x, dir.y, 0.0)), wfog);
frag_color = vec4(water, 1.0);
frag_color = vec4(apply_underwater(water_shade(hitpos, dir, hi), hi, cam.z, u_params.z), 1.0);
return;
}
@@ -210,5 +314,5 @@ void main() {
vec3 lit = albedo * (ambient + (1.0 - ambient) * lambert);
float fog = clamp(1.0 - exp(-hi * 0.00009), 0.0, 0.75);
lit = mix(lit, sky_color(vec3(dir.x, dir.y, 0.0)), fog);
frag_color = vec4(lit, 1.0);
frag_color = vec4(apply_underwater(lit, hi, cam.z, u_params.z), 1.0);
}
+144 -39
View File
@@ -17,6 +17,12 @@ struct TerrainUniforms {
const CELL: f32 = 10.0; // must match ra3::terrain::cell_size
// SAGE water model constants (see docs/REVERSE_ENGINEERING.md).
const WATER_SCALE: f32 = 1.0 / 320.0;
const WATER_TRANSPARENT_DEPTH: f32 = 10.0;
const WATER_MIN_OPACITY: f32 = 0.70;
const WATER_RIVER_MULTIPLIER: f32 = 1.0;
fn cam_uniform() -> vec4<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 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);
}
// ---- 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
fn fs_main(in: TerrainOut) -> @location(0) vec4<f32> {
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);
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
// ~9000 world units, so marching past ~20000 adds cost without detail.
var t = CELL * 0.5;
var dt = CELL * 0.5;
// Clip the ray to the map's XY rectangle: the boundary is an exact plane,
// so the silhouette there stays razor-sharp instead of stair-stepping
// across it. Outside the map is sky.
var t_enter = 0.0;
var t_exit = 1.0e30;
var inside = true;
if (abs(dir.x) < 1e-6) {
inside = (cam.x >= 0.0 && cam.x <= world_w);
} else {
let a = (0.0 - cam.x) / dir.x;
let b = (world_w - cam.x) / dir.x;
t_enter = max(t_enter, min(a, b));
t_exit = min(t_exit, max(a, b));
}
if (inside) {
if (abs(dir.y) < 1e-6) {
inside = (cam.y >= 0.0 && cam.y <= world_h);
} else {
let a = (0.0 - cam.y) / dir.y;
let b = (world_h - cam.y) / dir.y;
t_enter = max(t_enter, min(a, b));
t_exit = min(t_exit, max(a, b));
}
}
if (!inside || t_exit <= 0.0) {
return vec4<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 hit = false;
var hit_t = 0.0;
for (var i = 0; i < 256 && t < 20000.0; i = i + 1) {
for (var i = 0; i < 1024 && t <= t_exit; i = i + 1) {
let w = cam + dir * t;
if (w.x < 0.0 || w.y < 0.0 || w.x >= world_w || w.y >= world_h) {
prev = t;
dt *= 1.10;
t += dt;
continue;
}
if (params_uniform().w > 0.5 && w.z <= params_uniform().z) {
hit = true;
hit_t = t;
break;
}
if (w.z <= world_height(w.x, w.y)) {
let h = world_height(w.x, w.y);
let surface = select(h, max(h, params_uniform().z), params_uniform().w > 0.5);
if (w.z <= surface) {
hit = true;
hit_t = t;
break;
}
let clearance = (w.z - surface) / max(-dir.z, 1e-4);
prev = t;
dt *= 1.10;
t += dt;
t += clamp(clearance, cell_step, cell_step * 8.0);
}
if (!hit) {
return vec4<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 hi = hit_t;
for (var i = 0; i < 6; i = i + 1) {
for (var i = 0; i < 18; i = i + 1) {
let mid = 0.5 * (lo + hi);
let w = cam + dir * mid;
let water = params_uniform().w > 0.5 && w.z <= params_uniform().z;
if (water || w.z <= world_height(w.x, w.y)) {
let h = world_height(w.x, w.y);
let surface = select(h, max(h, params_uniform().z), params_uniform().w > 0.5);
if (w.z <= surface) {
hi = mid;
} else {
lo = mid;
@@ -178,20 +296,7 @@ fn fs_main(in: TerrainOut) -> @location(0) vec4<f32> {
let ambient = sun_uniform().w;
if (params_uniform().w > 0.5 && hitpos.z <= params_uniform().z + 0.01) {
let time = misc_uniform().x;
let q = hitpos.xy * 0.015;
let nx = sin(q.x * 1.3 + time * 1.7) + 0.5 * sin(q.x * 3.1 - time * 2.3);
let ny = sin(q.y * 1.1 - time * 1.3) + 0.5 * sin(q.y * 2.7 + time * 1.9);
let n = normalize(vec3<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);
return vec4<f32>(apply_underwater(water_shade(hitpos, dir, hi), hi, cam.z, params_uniform().z), 1.0);
}
let wx = hitpos.x / CELL;
@@ -225,5 +330,5 @@ fn fs_main(in: TerrainOut) -> @location(0) vec4<f32> {
var lit = albedo * (ambient + (1.0 - ambient) * lambert);
let fog = clamp(1.0 - exp(-hi * 0.00009), 0.0, 0.75);
lit = mix(lit, sky_color(vec3<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);
}
+18 -6
View File
@@ -277,15 +277,17 @@ export namespace ra3::client {
} else if (event.type == ui_event_type::mouse_move) {
mouse_x = event.x;
mouse_y = event.y;
if (event.left) {
if (event.middle) {
drag_x += event.dx;
drag_y += event.dy;
}
} else if (event.type == ui_event_type::wheel) {
if (event.wheel != 0.0F) {
camera.height = std::clamp(camera.height * (event.wheel > 0.0F ? (1.0F / 1.15F) : 1.15F), camera.min_height, camera.max_height);
dirty = true;
}
}
}
if (!running) break;
const auto now = std::chrono::steady_clock::now();
@@ -345,8 +347,9 @@ export namespace ra3::client {
/**
* GPU terrain viewer. `present_terrain` draws the heightfield; the loop
* here owns the camera controls. The top-left shows the FPS (current /
* cap) and, when `minimap_overview` is not empty, a corner minimap with
* the camera location is drawn.
* cap) tagged with the active backend name (e.g. `[vulkan]`) and, when
* `minimap_overview` is not empty, a corner minimap with the camera
* location is drawn.
*/
[[nodiscard]] auto terrain_loop(const ra3::terrain::gpu_terrain &terrain, ra3::render::camera3d camera, const image &minimap_overview) -> bool {
if (terrain.width == 0U || terrain.height == 0U || !this->supports_terrain()) return false;
@@ -372,6 +375,7 @@ export namespace ra3::client {
bool presented = false;
const auto default_camera = camera;
bool middle_dragged = false;
bool middle_down = false;
while (running) {
ui_event event;
float drag_x = 0.0F;
@@ -398,15 +402,23 @@ export namespace ra3::client {
if (event.dx != 0.0F || event.dy != 0.0F) middle_dragged = true;
}
} else if (event.type == ui_event_type::wheel) {
if (event.wheel != 0.0F) {
camera.height = std::clamp(camera.height * (event.wheel > 0.0F ? (1.0F / 1.15F) : 1.15F), camera.min_height, camera.max_height);
camera_moved = true;
} else if (event.type == ui_event_type::mouse_button && event.middle && event.released) {
if (!middle_dragged) {
}
} else if (event.type == ui_event_type::mouse_button && event.middle) {
if (event.released) {
if (middle_down && !middle_dragged) {
camera.yaw = default_camera.yaw;
camera.pitch = default_camera.pitch;
camera.height = default_camera.height;
camera_moved = true;
}
middle_down = false;
} else {
middle_down = true;
middle_dragged = false;
}
}
}
if (!running) break;
@@ -466,7 +478,7 @@ export namespace ra3::client {
fps = static_cast<uint32>(std::lround(static_cast<float>(fps_frames) / window_s));
fps_frames = 0;
fps_window = now;
overlay.label = ra3::render::compose_fps_label(fps, fps_limit_);
overlay.label = ra3::render::compose_fps_label(fps, fps_limit_, this->name());
overlay.label_changed = true;
}
} else {
+17 -1
View File
@@ -765,6 +765,7 @@ export namespace ra3::models {
usize placed = 0;
usize missing = 0;
usize roads = 0;
usize hidden = 0; ///< Objects/roads below the water plane, not drawn.
[[nodiscard]] auto empty() const -> bool { return indices.empty(); }
[[nodiscard]] auto triangle_count() const -> usize { return indices.size() / 3U; }
@@ -823,9 +824,14 @@ export namespace ra3::models {
* @param ground_height Terrain height (world Z) at a world `(x, y)`, so
* objects sit on the relief instead of a flat plane.
* @param texture_size Edge length of the shared texture array (0 = auto).
* @param cull_below_z Skip placements whose base sits below this world Z
* (the water plane): an opaque water surface hides
* submerged objects, so drawing them would float them
* on top of the sea.
*/
[[nodiscard]] inline auto build_scene(const asset_stream &stream, std::span<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;
out.texture_size = texture_size == 0U ? 128U : texture_size;
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
const auto &b = placements[i + 1U];
if ((b.road_type & 4U) == 0U || b.type != a.type) continue; // RoadType::End
const auto road_base = (ground_height ? ground_height(a.x, a.y) : 0.0F) + a.z;
if (road_base < cull_below_z) {
++out.hidden;
++i;
continue;
}
emit_road(a, b);
++i;
}
@@ -986,6 +998,10 @@ export namespace ra3::models {
const auto cos_a = std::cos(item.angle);
const auto sin_a = std::sin(item.angle);
const auto base_z = (ground_height ? ground_height(item.x, item.y) : 0.0F) + item.z;
if (base_z < cull_below_z) {
++out.hidden;
continue;
}
bool drawn = false;
for (const auto *mesh_asset: meshes) {
// Only opaque material parts are drawn; meshes with no diffuse
+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`.
* `cap == 0` means vertical sync, `cap < 0` means uncapped.
* A small translucent label for the top-left corner, e.g.
* `FPS: 155/160 [vulkan]`. `cap == 0` means vertical sync, `cap < 0` means
* uncapped. `backend` is the active renderer backend name (empty omits it).
*/
[[nodiscard]] inline auto compose_fps_label(uint32 fps, int cap) -> image {
[[nodiscard]] inline auto compose_fps_label(uint32 fps, int cap, std::string_view backend = {}) -> image {
char text[64];
if (cap == 0) {
std::snprintf(text, sizeof(text), "FPS: %u/vsync", fps);
@@ -326,10 +327,16 @@ export namespace ra3::render {
} else {
std::snprintf(text, sizeof(text), "FPS: %u/%d", fps, cap);
}
const auto w = text_width(text, 1U) + 8U;
std::string label{text};
if (!backend.empty()) {
label += " [";
label += backend;
label += ']';
}
const auto w = text_width(label, 1U) + 8U;
const auto h = detail::glyph_height + 6U;
image img(w, h, argb(30, 6, 6, 200)); // warm translucent backing
draw_text(img, 4, 3, text, argb(240, 200, 90), 1U);
draw_text(img, 4, 3, label, argb(240, 200, 90), 1U);
return img;
}
+106 -41
View File
@@ -259,6 +259,8 @@ export namespace ra3::terrain {
/** The decoded terrain textures, parallel to `map_data::textures`. */
struct texture_set {
std::vector<image> images;
image water_flow; ///< `ra3_deepocean.tga`: SAGE water flow/distortion (RG), optional.
image water_normal; ///< `ra3_deepocean_nrm.tga`: SAGE water bump normal, optional.
[[nodiscard]] auto resolved() const -> usize {
usize n = 0;
@@ -294,6 +296,10 @@ export namespace ra3::terrain {
};
std::vector<fs::big_archive> archives;
std::unordered_map<std::string, source> files;
source flow_src{};
source nrm_src{};
bool has_flow = false;
bool has_nrm = false;
for (const auto &name: {"Terrain.big", "Core11.big"}) {
const auto path = data_dir / name;
std::error_code ec;
@@ -302,9 +308,13 @@ export namespace ra3::terrain {
}
for (const auto &archive: archives) {
for (const auto &entry: archive.entries()) {
auto stem = detail::tga_stem(entry.name);
if (stem.size() > 4U && stem.ends_with("_nrm")) continue;
if (!entry.name.ends_with(".tga") && !entry.name.ends_with(".TGA")) continue;
auto stem = detail::tga_stem(entry.name);
// The global ocean flow/normal pair is grabbed separately: the
// tile index deliberately drops `_nrm` files.
if (stem == "ra3_deepocean") { flow_src = source{&archive, entry.name}; has_flow = true; continue; }
if (stem == "ra3_deepocean_nrm") { nrm_src = source{&archive, entry.name}; has_nrm = true; continue; }
if (stem.size() > 4U && stem.ends_with("_nrm")) continue;
files.try_emplace(stem, source{&archive, entry.name});
}
}
@@ -336,6 +346,16 @@ export namespace ra3::terrain {
// Leave the slot empty; the renderer falls back to a palette.
}
}
const auto decode_water = [](const source &src, bool present) -> image {
if (!present) return {};
try {
return ra3::render::decode_tga(src.archive->read(src.entry, true));
} catch (const std::exception &) {
return {};
}
};
set.water_flow = decode_water(flow_src, has_flow);
set.water_normal = decode_water(nrm_src, has_nrm);
return set;
}
@@ -354,8 +374,8 @@ export namespace ra3::terrain {
};
namespace detail {
/** Index `*.tga` under a terrain dir by stem (lower-cased), ignoring normals. */
[[nodiscard]] inline auto terrain_file_index(const std::filesystem::path &dir)
/** Index `*.tga` under a terrain dir by stem (lower-cased). */
[[nodiscard]] inline auto terrain_file_index(const std::filesystem::path &dir, bool include_normals = false)
-> std::unordered_map<std::string, std::filesystem::path> {
std::unordered_map<std::string, std::filesystem::path> files;
std::error_code ec;
@@ -370,7 +390,7 @@ export namespace ra3::terrain {
std::transform(parent.begin(), parent.end(), parent.begin(), [](unsigned char ch) { return static_cast<char>(std::tolower(ch)); });
if (parent != "terrain") continue;
auto stem = tga_stem(path.filename().string());
if (stem.size() > 4U && stem.ends_with("_nrm")) continue;
if (!include_normals && stem.size() > 4U && stem.ends_with("_nrm")) continue;
files.try_emplace(stem, path);
}
return files;
@@ -396,11 +416,16 @@ export namespace ra3::terrain {
* Used to stage just the tiles a single map needs (e.g. the wasm preload).
*/
[[nodiscard]] inline auto resolve_texture_files(const map_data &map, const std::filesystem::path &dir) -> std::vector<std::filesystem::path> {
const auto files = detail::terrain_file_index(dir);
// Include normals so the global ocean flow/normal pair is staged too:
// the terrain pass appends them to the atlas.
const auto files = detail::terrain_file_index(dir, true);
std::vector<std::filesystem::path> resolved;
for (const auto &texture: map.textures) {
if (auto found = detail::match_terrain_file(files, texture.name); !found.empty()) resolved.push_back(std::move(found));
}
for (const auto *water: {"ra3_deepocean", "ra3_deepocean_nrm"}) {
if (const auto it = files.find(water); it != files.end()) resolved.push_back(it->second);
}
std::sort(resolved.begin(), resolved.end());
resolved.erase(std::unique(resolved.begin(), resolved.end()), resolved.end());
return resolved;
@@ -409,21 +434,26 @@ export namespace ra3::terrain {
/** Load tile textures from a directory of loose `*.tga` files (extracted assets). */
[[nodiscard]] inline auto load_textures_from_dir(const map_data &map, const std::filesystem::path &dir,
const std::function<void(float)> &progress = {}) -> texture_set {
const auto files = detail::terrain_file_index(dir);
const auto files = detail::terrain_file_index(dir, true);
texture_set set;
set.images.resize(map.textures.size());
const auto decode_file = [](const std::filesystem::path &path) -> image {
if (path.empty()) return {};
try {
std::ifstream in(path, std::ios::binary);
std::vector<uint8> raw((std::istreambuf_iterator<char>(in)), std::istreambuf_iterator<char>());
return ra3::render::decode_tga(raw);
} catch (const std::exception &) {
return {};
}
};
for (usize i = 0; i < map.textures.size(); ++i) {
const auto found = detail::match_terrain_file(files, map.textures[i].name);
if (!found.empty()) {
try {
std::ifstream in(found, std::ios::binary);
std::vector<uint8> raw((std::istreambuf_iterator<char>(in)), std::istreambuf_iterator<char>());
set.images[i] = ra3::render::decode_tga(raw);
} catch (const std::exception &) {
}
}
set.images[i] = decode_file(found);
if (progress && !map.textures.empty()) progress(static_cast<float>(i + 1U) / static_cast<float>(map.textures.size()));
}
if (const auto it = files.find("ra3_deepocean"); it != files.end()) set.water_flow = decode_file(it->second);
if (const auto it = files.find("ra3_deepocean_nrm"); it != files.end()) set.water_normal = decode_file(it->second);
return set;
}
@@ -497,7 +527,12 @@ export namespace ra3::terrain {
out.cell_data[i * 4U + 3U] = static_cast<uint16>(packed);
}
out.layer_count = static_cast<uint32>(std::max<usize>(1U, map.textures.size()));
// Two extra atlas layers hold the SAGE water flow map and bump normal so
// the water shader can sample them without a new binding on any backend:
// they are always the last two layers (water_flow = layer_count - 2,
// water_normal = layer_count - 1).
const auto tile_layers = static_cast<uint32>(std::max<usize>(1U, map.textures.size()));
out.layer_count = tile_layers + 2U;
uint32 layer_size = 64U;
for (const auto &img: set.images) {
if (!img.empty()) layer_size = std::max(layer_size, img.width());
@@ -512,17 +547,26 @@ export namespace ra3::terrain {
}
out.layers.assign(static_cast<usize>(out.layer_count) * layer_size * layer_size, 0xFF3A4550U);
for (usize i = 0; i < map.textures.size(); ++i) {
const auto &img = set.images[i];
if (img.empty()) continue;
// Copy `img` into atlas layer `index`, box-nearest downscaled to
// `layer_size`; `fallback` is the ARGB used when the image is absent.
const auto blit_layer = [&](uint32 index, const image &img, uint32 fallback) {
for (uint32 y = 0; y < layer_size; ++y) {
const auto sy = std::min(img.height() - 1U, y * img.height() / layer_size);
for (uint32 x = 0; x < layer_size; ++x) {
uint32 px = fallback;
if (!img.empty()) {
const auto sy = std::min(img.height() - 1U, y * img.height() / layer_size);
const auto sx = std::min(img.width() - 1U, x * img.width() / layer_size);
out.layers[(i * layer_size + y) * layer_size + x] = img.data()[static_cast<usize>(sy) * img.width() + sx];
px = img.data()[static_cast<usize>(sy) * img.width() + sx];
}
out.layers[(static_cast<usize>(index) * layer_size + y) * layer_size + x] = px;
}
}
};
for (usize i = 0; i < map.textures.size(); ++i) {
blit_layer(static_cast<uint32>(i), set.images[i], 0xFF3A4550U);
}
blit_layer(tile_layers, set.water_flow, 0xFF808080U); // neutral flow (0, 0)
blit_layer(tile_layers + 1U, set.water_normal, 0xFF8080FFU); // flat normal (0, 0, 1)
if (progress) progress(1.0F);
return out;
}
@@ -1096,49 +1140,70 @@ export namespace ra3::terrain {
continue;
}
auto t = cell_size * 0.5F;
auto dt = cell_size * 0.5F;
// Clip the ray to the map's XY rectangle. The boundary is an
// exact plane, so the silhouette there stays razor-sharp
// instead of stair-stepping across it; outside the map is sky.
auto t_enter = 0.0F;
auto t_exit = 1.0e30F;
const auto slab = [](float origin, float dir, float span, float &lo_t, float &hi_t) -> bool {
if (std::abs(dir) < 1.0e-6F) return origin >= 0.0F && origin <= span;
const auto a = (0.0F - origin) / dir;
const auto b = (span - origin) / dir;
lo_t = std::max(lo_t, std::min(a, b));
hi_t = std::min(hi_t, std::max(a, b));
return true;
};
if (!slab(cam_x, dx, world_w, t_enter, t_exit) || !slab(cam_y, dy, world_h, t_enter, t_exit) || t_exit <= 0.0F) {
hi.data()[pixel] = argb(150, 170, 200);
continue;
}
const auto surface_at = [&](float wx, float wy) -> float {
const auto h = sample_height(wx, wy);
return map.has_water ? std::max(h, static_cast<float>(map.water_plane_z)) : h;
};
// March the heightfield cell by cell: the step is never longer
// than the time to cross one cell (in the dominant horizontal
// axis), while a clearance term lets the ray skip the empty air
// above the surface. Resolving every cell is what keeps cliff and
// map-edge silhouettes from quantising into huge stair-steps that
// crawl/wave as the camera pans.
const auto horiz = std::max(std::abs(dx), std::abs(dy));
const auto cell_step = std::min(cell_size / std::max(horiz, 1.0e-4F), cell_size * 32.0F);
auto t = std::max(t_enter, cell_size * 0.5F);
auto prev_t = t;
bool hit = false;
float hit_t = 0.0F;
for (int iter = 0; iter < 4000 && t < 60000.0F; ++iter) {
for (int iter = 0; iter < 4096 && t <= t_exit; ++iter) {
const auto wx = cam_x + dx * t;
const auto wy = cam_y + dy * t;
const auto wz = cam_z + dz * t;
if (wx < 0.0F || wy < 0.0F || wx >= world_w || wy >= world_h) {
prev_t = t;
dt *= 1.03F;
t += dt;
continue;
}
if (map.has_water && wz <= map.water_plane_z) {
hit = true;
hit_t = t;
break;
}
if (wz <= sample_height(wx, wy)) {
const auto surface = surface_at(wx, wy);
if (wz <= surface) {
hit = true;
hit_t = t;
break;
}
const auto clearance = (wz - surface) / std::max(-dz, 1.0e-4F);
prev_t = t;
dt *= 1.03F;
t += dt;
t += std::clamp(clearance, cell_step, cell_step * 8.0F);
}
if (!hit) {
hi.data()[pixel] = argb(150, 170, 200);
continue;
}
// Refine the first crossing; with a sub-cell bracket this
// converges to the exact surface point.
auto lo = prev_t;
auto up = hit_t;
for (int i = 0; i < 6; ++i) {
for (int i = 0; i < 18; ++i) {
const auto mid = 0.5F * (lo + up);
const auto wx = cam_x + dx * mid;
const auto wy = cam_y + dy * mid;
const auto wz = cam_z + dz * mid;
const auto water = map.has_water && wz <= map.water_plane_z;
if (water || wz <= sample_height(wx, wy)) {
if (wz <= surface_at(wx, wy)) {
up = mid;
} else {
lo = mid;
+6
View File
@@ -194,6 +194,12 @@ auto main() -> int {
}
check(!space_lit, "a space paints nothing");
// FPS label: the active backend name is appended (e.g. `[vulkan]`).
const auto fps_label = render::compose_fps_label(155U, 0, "vulkan");
const auto fps_plain = render::compose_fps_label(155U, 0);
check(fps_label.width() > fps_plain.width(), "FPS label grows when the backend is shown");
check(fps_label.width() == render::text_width("FPS: 155/vsync [vulkan]") + 8U, "FPS label includes the backend name");
const auto box = render::fit_rect(1280.0F, 720.0F, 1920.0F, 1080.0F);
check(box.x == 0.0F && box.y == 0.0F && box.w == 1920.0F && box.h == 1080.0F, "fit_rect fills a same-aspect window");
+2 -2
View File
@@ -131,13 +131,13 @@ and `log::set_sinks({...})` replaces them.
```cpp
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.
auto sink = log::add_file_sink("enderlog.log", {.max_file_size = 64 * 1024, .max_archives = 5});
```
- **No appending onto a previous run.** On open, an existing non-empty
`enderlog.log` is renamed to `enderlog.log.<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
preserved. A leftover empty file is simply replaced.
- `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
* holds data, that file is renamed to a timestamped archive first, so a run
* never appends onto a previous run's log: every start begins a fresh file
* and the old one is preserved as `<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`.
* `file_options::max_archives` bounds how many archives are kept.
*
@@ -238,13 +239,21 @@ export namespace ender::log {
if (stream_.is_open()) stream_.close();
const auto stamp = std::format("{:%Y%m%d-%H%M%S}",
std::chrono::floor<std::chrono::seconds>(std::chrono::system_clock::now()));
auto archive = path_;
archive += "." + stamp;
// Keep the original extension last, with the timestamp in the
// 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
// counter rather than overwrite the earlier archive.
for (auto counter = 1; std::filesystem::exists(archive); ++counter) {
archive = path_;
archive += std::format(".{}.{}", stamp, counter);
archive = name(counter);
}
std::filesystem::rename(path_, archive);
archives_.push_back(archive);