Compare commits
4
Commits
10a1963eec
...
v0.10.1
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2e284a16e5 | ||
|
|
2f6ff74670 | ||
|
|
2c348cb590 | ||
|
|
26e1934a5d |
@@ -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
|
||||
|
||||
|
||||
+99
-21
@@ -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
@@ -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]`
|
||||
|
||||
@@ -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
@@ -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
@@ -8,6 +8,10 @@
|
||||
// The material is sampled **continuously** (`uv = cell / (2 * cellSize)`), as the
|
||||
// retail `Terrain.fx` / OpenSAGE `Terrain.frag` do, so it never restarts at a
|
||||
// cell edge; material boundaries cross-fade with the SAGE blend ramp.
|
||||
//
|
||||
// The water plane is shaded with a port of the SAGE water effect
|
||||
// (`Ocean.fx` / OpenSAGE `Water.frag`); the camera-below-water tint stands in
|
||||
// for `UnderwaterDeferred.fx`. See docs/REVERSE_ENGINEERING.md.
|
||||
layout(binding = 0) uniform sampler2D heightmap;
|
||||
layout(binding = 1) uniform sampler2D celldata;
|
||||
layout(binding = 2) uniform sampler2DArray atlas;
|
||||
@@ -17,7 +21,7 @@ layout(push_constant) uniform Push {
|
||||
vec4 params; // x=pitch, y=fov, z=water_z, w=has_water
|
||||
vec4 sun; // xyz=sun dir, w=ambient
|
||||
vec4 mapinfo; // x=W, y=H, z=unused, w=z_scale
|
||||
vec4 misc; // x=time, y=unused, z=cells per texture repeat, w=aspect
|
||||
vec4 misc; // x=time, y=water kind (0=ocean,1=river), z=cells per repeat, w=aspect
|
||||
} pc;
|
||||
|
||||
layout(location = 0) in vec2 in_uv;
|
||||
@@ -30,6 +34,12 @@ const float CELL = 10.0; // must match ra3::terrain::cell_size
|
||||
const float NEAR = 10.0;
|
||||
const float FAR = 60000.0;
|
||||
|
||||
// SAGE water model constants (see docs/REVERSE_ENGINEERING.md).
|
||||
const float WATER_SCALE = 1.0 / 320.0; // Water.frag: worldPos.xy / 320
|
||||
const float WATER_TRANSPARENT_DEPTH = 10.0; // WaterTransparency.TransparentWaterDepth
|
||||
const float WATER_MIN_OPACITY = 0.70; // WaterTransparency.TransparentWaterMinOpacity
|
||||
const float WATER_RIVER_MULTIPLIER = 1.0; // WaterTransparency.RiverTransparencyMultiplier
|
||||
|
||||
float height_at(ivec2 c) {
|
||||
c = clamp(c, ivec2(0), ivec2(pc.mapinfo.xy) - 1);
|
||||
return texelFetch(heightmap, c, 0).r * 65535.0 * pc.mapinfo.w;
|
||||
@@ -92,6 +102,99 @@ vec3 sample_layer(uint layer, float wx, float wy) {
|
||||
return texture(atlas, vec3(vec2(wx, wy) / span, l)).rgb;
|
||||
}
|
||||
|
||||
// ---- SAGE water (Ocean.fx / RiverWater.fx / Water.frag port) ----------------
|
||||
|
||||
// Sample an atlas layer by an explicit float layer index (the water flow/bump
|
||||
// maps are appended as the last two layers of the tile atlas).
|
||||
vec3 water_tex(int layer, vec2 uv) {
|
||||
int lc = textureSize(atlas, 0).z;
|
||||
float l = float(clamp(layer, 0, max(lc - 1, 0)));
|
||||
return texture(atlas, vec3(uv, l)).rgb;
|
||||
}
|
||||
|
||||
// Scrolling wave normal on the water plane: the retail bump map (atlas's last
|
||||
// layer), offset by the flow map (second-last layer) and combined with a
|
||||
// de-gridded procedural wave so the sun glint is irregular and always moving.
|
||||
vec3 water_normal(vec2 world_xy, float time) {
|
||||
vec2 q = world_xy * (WATER_SCALE * 6.0);
|
||||
float a1 = q.x * 0.80 + q.y * 0.30 + time * 1.10;
|
||||
float a2 = q.y * 0.95 - q.x * 0.45 - time * 1.30;
|
||||
float a3 = (q.x + q.y) * 1.60 + time * 2.10;
|
||||
float a4 = (q.x - q.y) * 2.30 - time * 1.70;
|
||||
float dx = 0.224 * cos(a1) - 0.099 * cos(a2) + 0.256 * cos(a3) + 0.230 * cos(a4);
|
||||
float dy = 0.084 * cos(a1) + 0.209 * cos(a2) + 0.256 * cos(a3) - 0.230 * cos(a4);
|
||||
int lc = textureSize(atlas, 0).z;
|
||||
vec3 flow = water_tex(lc - 2, q - vec2(time * 0.010, time * 0.014)) * 2.0 - 1.0;
|
||||
vec3 bump = water_tex(lc - 1, q + flow.xy * 0.05 + vec2(time * 0.006, time * 0.008)) * 2.0 - 1.0;
|
||||
float sx = -dx * 0.30 + bump.x * 0.45;
|
||||
float sy = -dy * 0.30 + bump.y * 0.45;
|
||||
return normalize(vec3(sx, sy, 1.0));
|
||||
}
|
||||
|
||||
// Water.frag distortionPower * the flow texture: a small scrolling UV offset.
|
||||
float water_distortion(vec2 world_xy, float time) {
|
||||
vec2 q = world_xy * (WATER_SCALE * 6.0);
|
||||
return 0.05 * (sin(q.x * 0.9 + time * 0.7) + sin(q.y * 1.1 - time * 0.5));
|
||||
}
|
||||
|
||||
// Water.frag GetCloudColor: no cloud texture is bound, so a slow low-frequency
|
||||
// scroll stands in.
|
||||
vec3 water_cloud(vec2 world_xy, float time) {
|
||||
return vec3(0.85 + 0.15 * sin((world_xy.x + world_xy.y) * 0.0007 - time * 0.05));
|
||||
}
|
||||
|
||||
// Retail UnderwaterDeferred.fx: absorbs red and fogs with distance. Applied to
|
||||
// every output while the camera is below the water plane.
|
||||
vec3 apply_underwater(vec3 color, float dist, float cam_z, float water_z) {
|
||||
if (cam_z >= water_z - 0.5) return color;
|
||||
const vec3 absorb = vec3(0.35, 0.62, 0.75);
|
||||
float fog = clamp(1.0 - exp(-dist * 0.00022), 0.0, 0.9);
|
||||
return mix(color * absorb, vec3(0.02, 0.10, 0.16), fog);
|
||||
}
|
||||
|
||||
// Water.frag: fresnel-mixed reflection (sky) and refraction (depth-graded
|
||||
// seabed), SAGE diffuse + specular lighting, cloud term, and a depth-based
|
||||
// transparency fade. `river` (>0.5) applies RiverTransparencyMultiplier.
|
||||
vec3 water_shade(vec3 hitpos, vec3 dir, float dist) {
|
||||
float time = pc.misc.x;
|
||||
float river = pc.misc.y;
|
||||
float seabed = world_height(hitpos.x, hitpos.y);
|
||||
float depth = max(0.0, pc.params.z - seabed);
|
||||
|
||||
vec3 n = water_normal(hitpos.xy, time + water_distortion(hitpos.xy, time));
|
||||
vec3 sun = normalize(pc.sun.xyz);
|
||||
|
||||
// Schlick fresnel, water F0 = 0.02.
|
||||
float cos_theta = clamp(dot(-dir, n), 0.0, 1.0);
|
||||
float fresnel = 0.02 + 0.98 * pow(1.0 - cos_theta, 5.0);
|
||||
|
||||
// Reflection: the sky the surface mirrors. Refraction: the seabed, graded
|
||||
// from shallow to deep and lit by the SAGE diffuse + specular model.
|
||||
vec3 reflection = sky_color(reflect(dir, n));
|
||||
vec3 shallow = vec3(0.10, 0.34, 0.38);
|
||||
vec3 deep = vec3(0.02, 0.12, 0.22);
|
||||
vec3 refraction = mix(shallow, deep, clamp(depth / 40.0, 0.0, 1.0));
|
||||
|
||||
float ndotl = max(dot(n, sun), 0.0);
|
||||
float ambient = pc.sun.w;
|
||||
vec3 diffuse = vec3(ambient + (1.0 - ambient) * ndotl);
|
||||
vec3 half_v = normalize(sun - dir);
|
||||
float spec = pow(max(dot(n, half_v), 0.0), 90.0);
|
||||
|
||||
vec3 color = mix(refraction, reflection, clamp(fresnel, 0.0, 1.0)) * diffuse * water_cloud(hitpos.xy, time);
|
||||
color += vec3(1.0, 0.97, 0.9) * spec * 0.45;
|
||||
|
||||
// Depth-based transparency: shallow water shows the seabed, deep water goes
|
||||
// opaque toward the deep colour.
|
||||
float alpha = clamp(depth / WATER_TRANSPARENT_DEPTH, 0.0, 1.0) * WATER_MIN_OPACITY;
|
||||
if (river > 0.5) alpha *= WATER_RIVER_MULTIPLIER;
|
||||
color = mix(refraction, color, clamp(alpha + 0.15, 0.0, 1.0));
|
||||
|
||||
// Distance haze toward the horizon, as the terrain.
|
||||
float wfog = clamp(1.0 - exp(-dist * 0.00009), 0.0, 0.75);
|
||||
return mix(color, sky_color(vec3(dir.x, dir.y, 0.0)), wfog);
|
||||
}
|
||||
|
||||
void main() {
|
||||
vec4 p = pc.cam;
|
||||
float pitch = clamp(pc.params.x, 0.15, 1.45);
|
||||
@@ -118,35 +221,71 @@ void main() {
|
||||
|
||||
if (dir.z >= -1e-4) {
|
||||
gl_FragDepth = 1.0;
|
||||
out_color = vec4(sky_color(dir), 1.0);
|
||||
out_color = vec4(apply_underwater(sky_color(dir), 0.0, cam.z, pc.params.z), 1.0);
|
||||
return;
|
||||
}
|
||||
|
||||
// March the heightfield. The step grows quickly: the map diagonal is only
|
||||
// ~9000 world units, so marching past ~20000 adds cost without detail, and
|
||||
// a 6% growth rate more than doubles the worst-case iteration count.
|
||||
float t = CELL * 0.5;
|
||||
float dt = CELL * 0.5;
|
||||
// Clip the ray to the map's XY rectangle: the boundary is an exact plane,
|
||||
// so the silhouette there stays razor-sharp instead of stair-stepping
|
||||
// across it. Outside the map is sky.
|
||||
float t_enter = 0.0;
|
||||
float t_exit = 1.0e30;
|
||||
bool inside = true;
|
||||
if (abs(dir.x) < 1e-6) {
|
||||
inside = (cam.x >= 0.0 && cam.x <= world_w);
|
||||
} else {
|
||||
float a = (0.0 - cam.x) / dir.x;
|
||||
float b = (world_w - cam.x) / dir.x;
|
||||
t_enter = max(t_enter, min(a, b));
|
||||
t_exit = min(t_exit, max(a, b));
|
||||
}
|
||||
if (inside) {
|
||||
if (abs(dir.y) < 1e-6) {
|
||||
inside = (cam.y >= 0.0 && cam.y <= world_h);
|
||||
} else {
|
||||
float a = (0.0 - cam.y) / dir.y;
|
||||
float b = (world_h - cam.y) / dir.y;
|
||||
t_enter = max(t_enter, min(a, b));
|
||||
t_exit = min(t_exit, max(a, b));
|
||||
}
|
||||
}
|
||||
if (!inside || t_exit <= 0.0) {
|
||||
gl_FragDepth = 1.0;
|
||||
out_color = vec4(apply_underwater(sky_color(dir), 0.0, cam.z, pc.params.z), 1.0);
|
||||
return;
|
||||
}
|
||||
|
||||
// March the heightfield cell by cell: the step is never longer than the
|
||||
// time to cross one cell (dominant horizontal axis), while a clearance term
|
||||
// lets the ray skip the empty air above the surface. Resolving every cell is
|
||||
// what keeps cliff and map-edge silhouettes from quantising into huge
|
||||
// stair-steps that crawl as the camera pans.
|
||||
float horiz = max(abs(dir.x), abs(dir.y));
|
||||
float cell_step = min(CELL / max(horiz, 1e-4), CELL * 32.0);
|
||||
float t = max(t_enter, CELL * 0.5);
|
||||
float prev = t;
|
||||
bool hit = false;
|
||||
float hit_t = 0.0;
|
||||
for (int i = 0; i < 256 && t < 20000.0; ++i) {
|
||||
for (int i = 0; i < 1024 && t <= t_exit; ++i) {
|
||||
vec3 w = cam + dir * t;
|
||||
if (w.x < 0.0 || w.y < 0.0 || w.x >= world_w || w.y >= world_h) {
|
||||
prev = t; dt *= 1.10; t += dt; continue;
|
||||
}
|
||||
if (pc.params.w > 0.5 && w.z <= pc.params.z) { hit = true; hit_t = t; break; }
|
||||
if (w.z <= world_height(w.x, w.y)) { hit = true; hit_t = t; break; }
|
||||
prev = t; dt *= 1.10; t += dt;
|
||||
float h = world_height(w.x, w.y);
|
||||
float surface = (pc.params.w > 0.5) ? max(h, pc.params.z) : h;
|
||||
if (w.z <= surface) { hit = true; hit_t = t; break; }
|
||||
float clearance = (w.z - surface) / max(-dir.z, 1e-4);
|
||||
prev = t;
|
||||
t += clamp(clearance, cell_step, cell_step * 8.0);
|
||||
}
|
||||
if (!hit) { gl_FragDepth = 1.0; out_color = vec4(sky_color(dir), 1.0); return; }
|
||||
if (!hit) { gl_FragDepth = 1.0; out_color = vec4(apply_underwater(sky_color(dir), 0.0, cam.z, pc.params.z), 1.0); return; }
|
||||
|
||||
// Refine the first crossing; with a sub-cell bracket this converges to the
|
||||
// exact surface point.
|
||||
float lo = prev, hi = hit_t;
|
||||
for (int i = 0; i < 6; ++i) {
|
||||
for (int i = 0; i < 18; ++i) {
|
||||
float mid = 0.5 * (lo + hi);
|
||||
vec3 w = cam + dir * mid;
|
||||
bool water = pc.params.w > 0.5 && w.z <= pc.params.z;
|
||||
if (water || w.z <= world_height(w.x, w.y)) hi = mid; else lo = mid;
|
||||
float h = world_height(w.x, w.y);
|
||||
float surface = (pc.params.w > 0.5) ? max(h, pc.params.z) : h;
|
||||
if (w.z <= surface) hi = mid; else lo = mid;
|
||||
}
|
||||
vec3 hitpos = cam + dir * hi;
|
||||
|
||||
@@ -158,21 +297,7 @@ void main() {
|
||||
float ambient = pc.sun.w;
|
||||
|
||||
if (pc.params.w > 0.5 && hitpos.z <= pc.params.z + 0.01) {
|
||||
// Water: animated normal from a procedural wave, sky reflection + fresnel.
|
||||
float time = pc.misc.x;
|
||||
vec2 q = hitpos.xy * 0.015;
|
||||
float nx = sin(q.x * 1.3 + time * 1.7) + 0.5 * sin(q.x * 3.1 - time * 2.3);
|
||||
float ny = sin(q.y * 1.1 - time * 1.3) + 0.5 * sin(q.y * 2.7 + time * 1.9);
|
||||
vec3 n = normalize(vec3(nx * 0.06, ny * 0.06, 1.0));
|
||||
float fres = pow(1.0 - clamp(-dir.z, 0.0, 1.0), 3.0);
|
||||
vec3 deep = vec3(0.03, 0.16, 0.28);
|
||||
vec3 refl = sky_color(reflect(dir, n));
|
||||
float lam = max(0.0, dot(n, sun));
|
||||
vec3 water = mix(deep, refl, clamp(0.25 + 0.55 * fres, 0.0, 0.9));
|
||||
water += vec3(1.0, 0.98, 0.9) * pow(lam, 64.0) * 0.6; // sun glint
|
||||
float wfog = clamp(1.0 - exp(-hi * 0.00009), 0.0, 0.75);
|
||||
water = mix(water, sky_color(vec3(dir.x, dir.y, 0.0)), wfog);
|
||||
out_color = vec4(water, 1.0);
|
||||
out_color = vec4(apply_underwater(water_shade(hitpos, dir, hi), hi, cam.z, pc.params.z), 1.0);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -211,5 +336,5 @@ void main() {
|
||||
// Distance haze toward the horizon so the map edge blends into the sky.
|
||||
float fog = clamp(1.0 - exp(-hi * 0.00009), 0.0, 0.75);
|
||||
lit = mix(lit, sky_color(vec3(dir.x, dir.y, 0.0)), fog);
|
||||
out_color = vec4(lit, 1.0);
|
||||
out_color = vec4(apply_underwater(lit, hi, cam.z, pc.params.z), 1.0);
|
||||
}
|
||||
|
||||
+144
-40
@@ -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
@@ -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);
|
||||
}
|
||||
|
||||
+25
-13
@@ -277,13 +277,15 @@ export namespace ra3::client {
|
||||
} else if (event.type == ui_event_type::mouse_move) {
|
||||
mouse_x = event.x;
|
||||
mouse_y = event.y;
|
||||
if (event.left) {
|
||||
if (event.middle) {
|
||||
drag_x += event.dx;
|
||||
drag_y += event.dy;
|
||||
}
|
||||
} else if (event.type == ui_event_type::wheel) {
|
||||
camera.height = std::clamp(camera.height * (event.wheel > 0.0F ? (1.0F / 1.15F) : 1.15F), camera.min_height, camera.max_height);
|
||||
dirty = true;
|
||||
if (event.wheel != 0.0F) {
|
||||
camera.height = std::clamp(camera.height * (event.wheel > 0.0F ? (1.0F / 1.15F) : 1.15F), camera.min_height, camera.max_height);
|
||||
dirty = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!running) break;
|
||||
@@ -345,8 +347,9 @@ export namespace ra3::client {
|
||||
/**
|
||||
* GPU terrain viewer. `present_terrain` draws the heightfield; the loop
|
||||
* here owns the camera controls. The top-left shows the FPS (current /
|
||||
* cap) and, when `minimap_overview` is not empty, a corner minimap with
|
||||
* the camera location is drawn.
|
||||
* cap) tagged with the active backend name (e.g. `[vulkan]`) and, when
|
||||
* `minimap_overview` is not empty, a corner minimap with the camera
|
||||
* location is drawn.
|
||||
*/
|
||||
[[nodiscard]] auto terrain_loop(const ra3::terrain::gpu_terrain &terrain, ra3::render::camera3d camera, const image &minimap_overview) -> bool {
|
||||
if (terrain.width == 0U || terrain.height == 0U || !this->supports_terrain()) return false;
|
||||
@@ -372,6 +375,7 @@ export namespace ra3::client {
|
||||
bool presented = false;
|
||||
const auto default_camera = camera;
|
||||
bool middle_dragged = false;
|
||||
bool middle_down = false;
|
||||
while (running) {
|
||||
ui_event event;
|
||||
float drag_x = 0.0F;
|
||||
@@ -398,15 +402,23 @@ export namespace ra3::client {
|
||||
if (event.dx != 0.0F || event.dy != 0.0F) middle_dragged = true;
|
||||
}
|
||||
} else if (event.type == ui_event_type::wheel) {
|
||||
camera.height = std::clamp(camera.height * (event.wheel > 0.0F ? (1.0F / 1.15F) : 1.15F), camera.min_height, camera.max_height);
|
||||
camera_moved = true;
|
||||
} else if (event.type == ui_event_type::mouse_button && event.middle && event.released) {
|
||||
if (!middle_dragged) {
|
||||
camera.yaw = default_camera.yaw;
|
||||
camera.pitch = default_camera.pitch;
|
||||
camera.height = default_camera.height;
|
||||
if (event.wheel != 0.0F) {
|
||||
camera.height = std::clamp(camera.height * (event.wheel > 0.0F ? (1.0F / 1.15F) : 1.15F), camera.min_height, camera.max_height);
|
||||
camera_moved = true;
|
||||
}
|
||||
} else if (event.type == ui_event_type::mouse_button && event.middle) {
|
||||
if (event.released) {
|
||||
if (middle_down && !middle_dragged) {
|
||||
camera.yaw = default_camera.yaw;
|
||||
camera.pitch = default_camera.pitch;
|
||||
camera.height = default_camera.height;
|
||||
camera_moved = true;
|
||||
}
|
||||
middle_down = false;
|
||||
} else {
|
||||
middle_down = true;
|
||||
middle_dragged = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!running) break;
|
||||
@@ -466,7 +478,7 @@ export namespace ra3::client {
|
||||
fps = static_cast<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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
+107
-42
@@ -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) {
|
||||
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];
|
||||
uint32 px = fallback;
|
||||
if (!img.empty()) {
|
||||
const auto sy = std::min(img.height() - 1U, y * img.height() / layer_size);
|
||||
const auto sx = std::min(img.width() - 1U, x * img.width() / layer_size);
|
||||
px = img.data()[static_cast<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;
|
||||
|
||||
@@ -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");
|
||||
|
||||
|
||||
Vendored
+14
-7
@@ -23,8 +23,8 @@ engine, not on a logging framework.
|
||||
(for tests and in-game consoles) ship; `sink` is a small interface.
|
||||
- **File output with archiving.** `file_sink` writes to a file and, on open,
|
||||
moves an existing log aside to a timestamped archive, so a run never appends
|
||||
onto a previous run's log. It can also rotate by size and bound how many
|
||||
archives are kept.
|
||||
onto a previous run's log. It can also rotate by size, bound how many archives
|
||||
are kept, and format both the record timestamp and the archive names.
|
||||
- **No stacktrace? No problem.** Where `<stacktrace>` is missing (libc++, and
|
||||
therefore every cross target), the module still compiles and records still
|
||||
carry their call site — they simply have no stack.
|
||||
@@ -104,7 +104,7 @@ auto main() -> int {
|
||||
Example output:
|
||||
|
||||
```
|
||||
[11:32:18] ERROR example: a body left the world (examples/main.cpp:8)
|
||||
[2026-10-01 11:32:18] ERROR example: a body left the world (examples/main.cpp:8)
|
||||
#0 simulate_one_step (examples/main.cpp:8)
|
||||
#1 main (examples/main.cpp:20)
|
||||
#2 <unknown>
|
||||
@@ -131,20 +131,27 @@ 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
|
||||
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.
|
||||
`enderlog.log` is renamed to `enderlog.<timestamp>.log` before the new file is
|
||||
created, so every run gets its own file and the previous run's log is
|
||||
preserved. The timestamp is inserted before the extension, which stays last
|
||||
(`.log` when the active file has none). A leftover empty file is simply
|
||||
replaced.
|
||||
- `file_options::max_file_size` (0 disables) rotates the active file mid-run the
|
||||
same way, and never archives an empty file. `file_options::max_archives`
|
||||
(0 keeps all) deletes the oldest archives beyond the limit.
|
||||
- `file_options::flush_each_record` (on by default) flushes after every record so
|
||||
a crash keeps the tail.
|
||||
- `file_options::timestamp_format` (chrono syntax, default `%Y-%m-%d %H:%M:%S`)
|
||||
controls the timestamp on each record's header line;
|
||||
`file_options::archive_time_format` (chrono syntax, default `%Y%m%d-%H%M%S`)
|
||||
controls the timestamp inserted into archive names. A chrono format string must
|
||||
begin with `%` (e.g. `%Y-%m-%d_%H%M%S`).
|
||||
- `add_file_sink` adds the sink to the global logger and returns it; `path()` and
|
||||
`archives()` expose what it wrote. The `file_sink` class can also be used
|
||||
directly and installed with `set_sinks`.
|
||||
|
||||
+46
-15
@@ -54,6 +54,9 @@ export namespace ender::log {
|
||||
return "?";
|
||||
}
|
||||
|
||||
/** Default timestamp rendered on a record, chrono format syntax. */
|
||||
inline constexpr std::string_view default_time_format{"%Y-%m-%d %H:%M:%S"};
|
||||
|
||||
/** Logger configuration. */
|
||||
struct options {
|
||||
/** Records below this level are dropped before anything is built. */
|
||||
@@ -90,15 +93,32 @@ export namespace ender::log {
|
||||
};
|
||||
|
||||
namespace detail {
|
||||
/**
|
||||
* Render a time point with a runtime chrono format string.
|
||||
*
|
||||
* `std::format`'s format string is compile-time only, so the spec is
|
||||
* wrapped in a replacement field and fed to `std::vformat`; a bare spec
|
||||
* would be read as literal text rather than a chrono conversion.
|
||||
*/
|
||||
[[nodiscard]] inline auto format_time(const std::chrono::system_clock::time_point time,
|
||||
const std::string_view time_format) -> std::string {
|
||||
const auto moment = std::chrono::floor<std::chrono::seconds>(time);
|
||||
const auto pattern = std::string{"{:"}.append(time_format).append("}");
|
||||
return std::vformat(pattern, std::make_format_args(moment));
|
||||
}
|
||||
|
||||
/**
|
||||
* Render one record as a human-readable block: a header line and, when
|
||||
* present, the indented stack frames. Shared by the stream sinks.
|
||||
*
|
||||
* @param time_format A chrono format string applied to the record's
|
||||
* timestamp; defaults to the date and time of day.
|
||||
*/
|
||||
[[nodiscard]] inline auto format_record(const record &entry) -> std::string {
|
||||
auto text = std::format("[{:%H:%M:%S}] {:<8} {}",
|
||||
std::chrono::floor<std::chrono::seconds>(entry.time),
|
||||
to_string(entry.severity),
|
||||
entry.message);
|
||||
[[nodiscard]] inline auto format_record(const record &entry,
|
||||
const std::string_view time_format = default_time_format)
|
||||
-> std::string {
|
||||
const auto stamp = format_time(entry.time, time_format);
|
||||
auto text = std::format("[{}] {:<8} {}", stamp, to_string(entry.severity), entry.message);
|
||||
if (!entry.file.empty()) {
|
||||
text += std::format(" ({}:{})", entry.file, entry.line);
|
||||
}
|
||||
@@ -125,15 +145,18 @@ export namespace ender::log {
|
||||
/** Writes a human-readable line per record to a stream (stderr by default). */
|
||||
class console_sink final: public sink {
|
||||
public:
|
||||
explicit console_sink(std::ostream &stream = std::cerr): stream_(&stream) {}
|
||||
explicit console_sink(std::ostream &stream = std::cerr,
|
||||
std::string time_format = std::string{default_time_format})
|
||||
: stream_(&stream), time_format_(std::move(time_format)) {}
|
||||
|
||||
auto write(const record &entry) -> void override {
|
||||
*stream_ << detail::format_record(entry);
|
||||
*stream_ << detail::format_record(entry, time_format_);
|
||||
stream_->flush();
|
||||
}
|
||||
|
||||
private:
|
||||
std::ostream *stream_;
|
||||
std::string time_format_;
|
||||
};
|
||||
|
||||
/** Keeps every record in memory; useful for tests and in-game consoles. */
|
||||
@@ -174,6 +197,10 @@ export namespace ender::log {
|
||||
std::size_t max_file_size{0};
|
||||
/** Keep at most this many archives, dropping the oldest first; 0 keeps them all. */
|
||||
std::size_t max_archives{0};
|
||||
/** Timestamp format used on each record's header line (chrono syntax). */
|
||||
std::string timestamp_format{std::string{default_time_format}};
|
||||
/** Chrono format for the timestamp inserted into archive names. */
|
||||
std::string archive_time_format{"%Y%m%d-%H%M%S"};
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -182,7 +209,9 @@ 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 as `<stem>.<timestamp><extension>` (`.log`
|
||||
* when the active file has no extension), where the timestamp is rendered by
|
||||
* `file_options::archive_time_format` (default `<YYYYmmdd-HHMMSS>`). 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.
|
||||
*
|
||||
@@ -205,7 +234,7 @@ export namespace ender::log {
|
||||
}
|
||||
|
||||
auto write(const record &entry) -> void override {
|
||||
const auto block = detail::format_record(entry);
|
||||
const auto block = detail::format_record(entry, options_.timestamp_format);
|
||||
// Rotate before writing, but never rotate an empty file: that would
|
||||
// archive nothing and lose the record that is about to be written.
|
||||
if (options_.max_file_size > 0 && size_ > 0 && size_ + block.size() > options_.max_file_size) {
|
||||
@@ -236,15 +265,17 @@ export namespace ender::log {
|
||||
|
||||
auto archive_current() -> void {
|
||||
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;
|
||||
const auto stamp = detail::format_time(std::chrono::system_clock::now(), options_.archive_time_format);
|
||||
// Keep the extension last: `<stem>.<timestamp><extension>`, falling
|
||||
// back to `.log` when the active file has none.
|
||||
const auto stem = path_.stem().string();
|
||||
const auto extension = path_.has_extension() ? path_.extension().string() : std::string{".log"};
|
||||
auto archive = path_.parent_path() / (stem + "." + stamp + extension);
|
||||
// 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 = path_.parent_path() /
|
||||
(stem + "." + stamp + "." + std::to_string(counter) + extension);
|
||||
}
|
||||
std::filesystem::rename(path_, archive);
|
||||
archives_.push_back(archive);
|
||||
|
||||
Reference in New Issue
Block a user