v0.3.0: display abstraction, map-browser menu, SAGE terrain tiling/blends

- ra3.client::display: shared interactive loops; SDL and Vulkan backends
  implement only the primitives (init/present/poll_event/window_size/
  key_down/present_terrain). ra3.display picks the backend.
- Menu: maps by localized name (gamestrings.csf), red/gold theme, hover
  highlight, mouse + keyboard, wheel scroll, fullscreen and FPS/vsync
  options, loading progress bar.
- Terrain: continuous tile sampling via a texture array (REPEAT, uv =
  cell/(2*cellSize)) removes per-cell grid seams; SAGE blend ramp for
  material transitions; FPS label + top-right minimap overlays.
- Skip the skirmish sim for map views; reuse the Vulkan texture; no idle
  terrain redraw.
This commit is contained in:
EnderTheCoder
2026-09-20 02:20:39 +08:00
parent 702b4e29aa
commit b6d619254d
66 changed files with 37661 additions and 406 deletions
+485 -5
View File
@@ -4,27 +4,501 @@ import std;
import ra3.core;
import ra3.logic;
import ra3.render;
import ra3.terrain;
/**
* The presentation layer: an abstract output surface plus the client facade
* that owns the simulation and drives the frame loop.
*
* A future W3D/D3D9 backend implements the same `display` interface; the
* default headless backend keeps OpenRA3 runnable on a CPU-only box.
* Backends implement only the low-level primitives (`init`, `present`,
* `poll_event`, `window_size`, `key_down`, `shutdown`) and, optionally,
* `present_terrain`; the interactive loops (menu, image viewer, camera viewer,
* GPU terrain) and the loading screen live here once, so input mapping and the
* frame loop never diverge between the SDL and Vulkan backends. The loops
* assume the display is already initialized, which lets one window host a menu,
* a loading bar and a viewer in sequence.
*/
export namespace ra3::client {
using ra3::core::uint32;
using ra3::render::image;
using ra3::render::ui_event;
using ra3::render::ui_event_type;
using ra3::render::ui_key;
using ra3::render::view_rect;
/** Window parameters for a display. */
struct display_options {
std::string title = "OpenRA3";
int width = 1280;
int height = 720;
bool fullscreen = false;
int fps_limit = 0; ///< 0 = vertical sync (present throttled to the display).
};
/** Small overlays drawn on top of the GPU terrain (top-left FPS, corner minimap). */
struct terrain_overlay {
image label; ///< top-left label (may be empty)
image minimap; ///< bottom-right overview (may be empty)
bool label_changed = true;
bool minimap_changed = true;
};
/**
* Abstract output surface presented once per logic frame.
* Abstract presentation surface.
*/
class display {
public:
virtual ~display() = default;
virtual auto begin_frame() -> void = 0;
virtual auto end_frame() -> void = 0;
virtual auto begin_frame() -> void {}
virtual auto end_frame() -> void {}
/** Create the window and rendering objects. */
[[nodiscard]] virtual auto init(const display_options &options) -> bool = 0;
/** Draw `frame` scaled into `dest` (window coordinates). `changed` is
* false when `frame` is the same image as the previous call, so the
* backend may skip re-uploading its texture. */
[[nodiscard]] virtual auto present(const image &frame, const view_rect &dest, bool changed) -> bool = 0;
/** Pop one OS event into `out`; returns false when the queue is empty. */
[[nodiscard]] virtual auto poll_event(ui_event &out) -> bool = 0;
/** Current drawable size in pixels. */
[[nodiscard]] virtual auto window_size() const -> std::pair<int, int> = 0;
/** Whether a key is currently held (for panning). */
[[nodiscard]] virtual auto key_down(ui_key key) const -> bool = 0;
virtual auto shutdown() -> void = 0;
[[nodiscard]] virtual auto name() const -> std::string_view = 0;
/**
* Optional GPU terrain path. The default reports `false`, so a low-end
* backend simply does not offer it; the Vulkan backend overrides it.
* `overlays` (FPS label, minimap) are drawn on top of the terrain.
*/
[[nodiscard]] virtual auto present_terrain(const ra3::terrain::gpu_terrain &, const ra3::render::camera3d &, float, const terrain_overlay &) -> bool {
return false;
}
/** Whether this backend can draw the GPU terrain path. */
[[nodiscard]] virtual auto supports_terrain() const -> bool { return false; }
int fps_limit_ = 0; ///< 0 = vsync, >0 = target FPS cap, <0 = uncapped.
/** Throttle an idle frame according to the configured limit. */
auto sleep_frame() const -> void {
if (fps_limit_ > 0) {
std::this_thread::sleep_for(std::chrono::milliseconds(std::max(1, 1000 / fps_limit_)));
} else if (fps_limit_ < 0) {
std::this_thread::yield();
}
// fps_limit_ == 0: vertical sync already blocks in present().
}
// ---- shared interactive loops (assume `init` succeeded) --------------
/** Letterbox `frame` into the current window. */
[[nodiscard]] auto present_fit(const image &frame, bool changed) -> bool {
const auto [w, h] = this->window_size();
return this->present(frame, ra3::render::fit_rect(static_cast<float>(frame.width()), static_cast<float>(frame.height()), static_cast<float>(w),
static_cast<float>(h)),
changed);
}
/** Draw a warm loading screen with a progress bar (0..1). */
auto present_progress(float progress, std::string_view label) -> void {
const auto [w, h] = this->window_size();
const auto frame = ra3::render::compose_progress(static_cast<uint32>(std::max(1, w)), static_cast<uint32>(std::max(1, h)), progress, label);
this->present_fit(frame, true);
}
/**
* Run a staged loader on this display: `load` is called with a `report`
* callback that draws a progress bar and pumps events (returning false
* if the user closed the window).
*/
template<typename Loader>
[[nodiscard]] auto run_with_progress(Loader &&load) -> bool {
this->present_progress(0.0F, "Loading...");
const auto report = [&](float progress, std::string_view label) -> bool {
this->present_progress(progress, label);
ui_event event;
while (this->poll_event(event)) {
if (event.type == ui_event_type::quit) return false;
}
return true;
};
return load(report);
}
/**
* Interactive menu. `frame` returns the image to display for an input
* event, or `nullopt` when nothing changed (so the previous frame is
* kept and nothing is recomposed/re-uploaded). It sets `done` to end
* the loop. `frame` is called with the current drawable size so text
* stays crisp on HiDPI displays.
*/
[[nodiscard]] auto menu_loop(const std::function<std::optional<image>(const ui_event &, uint32, uint32, bool &)> &frame) -> bool {
auto [sw, sh] = this->window_size();
const auto w = static_cast<uint32>(std::max(1, sw));
const auto h = static_cast<uint32>(std::max(1, sh));
bool done = false;
auto first = frame({}, w, h, done);
if (!first) return false;
image current = std::move(*first);
if (!this->present_fit(current, true)) return false;
while (!done) {
ui_event event;
bool idle = true;
bool changed = false;
while (this->poll_event(event)) {
idle = false;
if (event.type == ui_event_type::quit) {
done = true;
break;
}
auto result = frame(event, w, h, done);
if (done) break;
if (result) {
current = std::move(*result);
changed = true;
}
}
if (done) break;
if (changed) {
if (!this->present_fit(current, true)) break;
} else if (idle) {
if (!this->present_fit(current, false)) break;
this->sleep_frame();
}
}
return true;
}
/**
* Pan/zoom viewer over a raster image, reproducing the retail tactical
* view controls (wheel zoom, edge scroll, drag pan).
*/
[[nodiscard]] auto image_loop(const image &scene, ra3::render::view_camera camera) -> bool {
if (scene.empty()) return false;
camera.min_zoom = 1.0F;
camera.clamp_center();
float mouse_x = 0.0F;
float mouse_y = 0.0F;
bool running = true;
auto last = std::chrono::steady_clock::now();
while (running) {
ui_event event;
float drag_x = 0.0F;
float drag_y = 0.0F;
while (this->poll_event(event)) {
if (event.type == ui_event_type::quit) {
running = false;
} else if (event.type == ui_event_type::key) {
if (event.key == ui_key::cancel) {
running = false;
} else if (event.key == ui_key::page_up) {
camera.zoom_by(camera.zoom_step);
} else if (event.key == ui_key::page_down) {
camera.zoom_by(1.0F / camera.zoom_step);
}
} else if (event.type == ui_event_type::mouse_move) {
mouse_x = event.x;
mouse_y = event.y;
if (event.left) {
drag_x += event.dx;
drag_y += event.dy;
}
} else if (event.type == ui_event_type::wheel) {
camera.zoom_by(event.wheel > 0.0F ? camera.zoom_step : (1.0F / camera.zoom_step));
}
}
if (!running) break;
const auto now = std::chrono::steady_clock::now();
const auto dt = std::min(0.1F, std::chrono::duration<float>(now - last).count());
last = now;
const auto [w, h] = this->window_size();
auto view = camera.rect(static_cast<float>(scene.width()), static_cast<float>(scene.height()), static_cast<float>(w), static_cast<float>(h));
if (view.w > 0.0F && view.h > 0.0F && (drag_x != 0.0F || drag_y != 0.0F)) {
camera.scroll(-drag_x / view.w, -drag_y / view.h);
}
camera.edge_scroll(mouse_x, mouse_y, static_cast<float>(w), static_cast<float>(h), dt);
view = camera.rect(static_cast<float>(scene.width()), static_cast<float>(scene.height()), static_cast<float>(w), static_cast<float>(h));
if (!this->present(scene, view, false)) break;
this->sleep_frame();
}
return true;
}
/**
* Interactive 3D camera over a software-rendered scene: the provider
* re-renders whenever the camera moves.
*/
[[nodiscard]] auto camera_loop(const std::function<image(const ra3::render::camera3d &, uint32, uint32)> &provider, ra3::render::camera3d camera) -> bool {
auto [window_w, window_h] = this->window_size();
image current = provider(camera, static_cast<uint32>(std::max(1, window_w)), static_cast<uint32>(std::max(1, window_h)));
if (current.empty()) return false;
if (!this->present(current, {0.0F, 0.0F, static_cast<float>(window_w), static_cast<float>(window_h)}, true)) return false;
float mouse_x = 0.5F * static_cast<float>(window_w);
float mouse_y = 0.5F * static_cast<float>(window_h);
bool running = true;
auto last = std::chrono::steady_clock::now();
while (running) {
ui_event event;
float drag_x = 0.0F;
float drag_y = 0.0F;
bool dirty = false;
while (this->poll_event(event)) {
if (event.type == ui_event_type::quit) {
running = false;
} else if (event.type == ui_event_type::key) {
switch (event.key) {
case ui_key::cancel: running = false; break;
case ui_key::page_up: camera.height = std::clamp(camera.height / 1.15F, camera.min_height, camera.max_height); dirty = true; break;
case ui_key::page_down: camera.height = std::clamp(camera.height * 1.15F, camera.min_height, camera.max_height); dirty = true; break;
default: break;
}
} else if (event.type == ui_event_type::mouse_move) {
mouse_x = event.x;
mouse_y = event.y;
if (event.left) {
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 (!running) break;
const auto now = std::chrono::steady_clock::now();
const auto dt = std::min(0.1F, std::chrono::duration<float>(now - last).count());
last = now;
if (drag_x != 0.0F || drag_y != 0.0F) {
camera.yaw -= drag_x * 0.005F;
camera.pitch = std::clamp(camera.pitch + drag_y * 0.004F, 0.15F, 1.45F);
dirty = true;
}
float move_x = 0.0F;
float move_y = 0.0F;
if (this->key_down(ui_key::up)) move_y += 1.0F;
if (this->key_down(ui_key::down)) move_y -= 1.0F;
if (this->key_down(ui_key::left)) move_x -= 1.0F;
if (this->key_down(ui_key::right)) move_x += 1.0F;
if (mouse_x <= 24.0F) {
move_x -= 1.0F;
} else if (mouse_x >= static_cast<float>(window_w) - 24.0F) {
move_x += 1.0F;
}
if (mouse_y <= 24.0F) {
move_y += 1.0F;
} else if (mouse_y >= static_cast<float>(window_h) - 24.0F) {
move_y -= 1.0F;
}
if (move_x != 0.0F || move_y != 0.0F) {
const auto fwd_x = std::sin(camera.yaw);
const auto fwd_y = std::cos(camera.yaw);
const auto right_x = std::cos(camera.yaw);
const auto right_y = -std::sin(camera.yaw);
const auto step = camera.height * 0.9F * dt;
camera.target_x += (fwd_x * move_y + right_x * move_x) * step;
camera.target_y += (fwd_y * move_y + right_y * move_x) * step;
dirty = true;
}
const auto latest = this->window_size();
if (latest.first != window_w || latest.second != window_h) {
window_w = latest.first;
window_h = latest.second;
dirty = true;
}
if (dirty) {
current = provider(camera, static_cast<uint32>(std::max(1, window_w)), static_cast<uint32>(std::max(1, window_h)));
if (current.empty()) break;
dirty = false;
}
if (!this->present(current, {0.0F, 0.0F, static_cast<float>(window_w), static_cast<float>(window_h)}, true)) break;
this->sleep_frame();
}
return true;
}
/**
* 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.
*/
[[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;
const auto world_w = static_cast<float>(terrain.width) * 10.0F;
const auto world_h = static_cast<float>(terrain.height) * 10.0F;
camera.target_x = std::clamp(camera.target_x, 0.0F, world_w);
camera.target_y = std::clamp(camera.target_y, 0.0F, world_h);
auto [window_w, window_h] = this->window_size();
const auto start = std::chrono::steady_clock::now();
auto last = start;
float mouse_x = 0.5F * static_cast<float>(window_w);
float mouse_y = 0.5F * static_cast<float>(window_h);
terrain_overlay overlay;
uint32 fps = 0;
int fps_frames = 0;
auto fps_window = start;
auto minimap_time = start;
bool running = true;
bool presented = false;
while (running) {
ui_event event;
float drag_x = 0.0F;
float drag_y = 0.0F;
bool camera_moved = false;
while (this->poll_event(event)) {
if (event.type == ui_event_type::quit) {
running = false;
} else if (event.type == ui_event_type::key) {
switch (event.key) {
case ui_key::cancel: running = false; break;
case ui_key::page_up: camera.height = std::clamp(camera.height / 1.15F, camera.min_height, camera.max_height); camera_moved = true; break;
case ui_key::page_down: camera.height = std::clamp(camera.height * 1.15F, camera.min_height, camera.max_height); camera_moved = true; break;
default: break;
}
} else if (event.type == ui_event_type::mouse_move) {
mouse_x = event.x;
mouse_y = event.y;
if (event.left) {
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);
camera_moved = true;
}
}
if (!running) break;
const auto now = std::chrono::steady_clock::now();
const auto dt = std::min(0.1F, std::chrono::duration<float>(now - last).count());
last = now;
if (drag_x != 0.0F || drag_y != 0.0F) {
camera.yaw -= drag_x * 0.005F;
camera.pitch = std::clamp(camera.pitch + drag_y * 0.004F, 0.15F, 1.45F);
camera_moved = true;
}
float move_x = 0.0F;
float move_y = 0.0F;
if (this->key_down(ui_key::up)) move_y += 1.0F;
if (this->key_down(ui_key::down)) move_y -= 1.0F;
if (this->key_down(ui_key::left)) move_x -= 1.0F;
if (this->key_down(ui_key::right)) move_x += 1.0F;
if (mouse_x <= 24.0F) {
move_x -= 1.0F;
} else if (mouse_x >= static_cast<float>(window_w) - 24.0F) {
move_x += 1.0F;
}
if (mouse_y <= 24.0F) {
move_y += 1.0F;
} else if (mouse_y >= static_cast<float>(window_h) - 24.0F) {
move_y -= 1.0F;
}
if (move_x != 0.0F || move_y != 0.0F) {
const auto fwd_x = std::sin(camera.yaw);
const auto fwd_y = std::cos(camera.yaw);
const auto right_x = std::cos(camera.yaw);
const auto right_y = -std::sin(camera.yaw);
const auto step = camera.height * 0.9F * dt;
camera.target_x += (fwd_x * move_y + right_x * move_x) * step;
camera.target_y += (fwd_y * move_y + right_y * move_x) * step;
camera_moved = true;
}
camera.target_x = std::clamp(camera.target_x, 0.0F, world_w);
camera.target_y = std::clamp(camera.target_y, 0.0F, world_h);
const auto latest = this->window_size();
if (latest.first != window_w || latest.second != window_h) {
window_w = latest.first;
window_h = latest.second;
camera_moved = true;
}
if (camera_moved) {
++fps_frames;
const auto window_s = std::chrono::duration<float>(now - fps_window).count();
if (window_s >= 0.4F) {
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_changed = true;
}
} else {
fps_frames = 0;
fps_window = now;
}
const auto since_minimap = std::chrono::duration<float>(now - minimap_time).count();
if (!minimap_overview.empty() && (overlay.minimap.empty() || (camera_moved && since_minimap >= 0.15F))) {
const auto u = camera.target_x / world_w;
const auto v = 1.0F - camera.target_y / world_h;
overlay.minimap = ra3::render::compose_minimap(minimap_overview, u, v);
overlay.minimap_changed = true;
minimap_time = now;
}
// Render only when the view changes: an idle terrain costs nothing.
if (camera_moved || !presented) {
const auto time_s = std::chrono::duration<float>(now - start).count();
if (!this->present_terrain(terrain, camera, time_s, overlay)) {
return presented;
}
overlay.label_changed = false;
overlay.minimap_changed = false;
presented = true;
}
this->sleep_frame();
}
return true;
}
// ---- one-shot helpers (own the lifecycle) ----------------------------
[[nodiscard]] auto run_menu(const display_options &options, const std::function<std::optional<image>(const ui_event &, uint32, uint32, bool &)> &frame) -> bool {
if (!this->init(options)) return false;
(void)this->menu_loop(frame);
this->shutdown();
return true;
}
[[nodiscard]] auto run_image(const display_options &options, const image &scene, ra3::render::view_camera camera) -> bool {
if (scene.empty() || !this->init(options)) return false;
(void)this->image_loop(scene, camera);
this->shutdown();
return true;
}
[[nodiscard]] auto run_camera(const display_options &options, const std::function<image(const ra3::render::camera3d &, uint32, uint32)> &provider,
ra3::render::camera3d camera) -> bool {
if (!this->init(options)) return false;
(void)this->camera_loop(provider, camera);
this->shutdown();
return true;
}
[[nodiscard]] auto run_terrain(const display_options &options, const ra3::terrain::gpu_terrain &terrain, ra3::render::camera3d camera) -> bool {
if (!this->init(options)) return false;
(void)this->terrain_loop(terrain, camera, {});
this->shutdown();
return true;
}
};
/**
@@ -34,6 +508,12 @@ export namespace ra3::client {
public:
auto begin_frame() -> void override {}
auto end_frame() -> void override {}
[[nodiscard]] auto init(const display_options &) -> bool override { return false; }
[[nodiscard]] auto present(const image &, const view_rect &, bool) -> bool override { return false; }
[[nodiscard]] auto poll_event(ui_event &) -> bool override { return false; }
[[nodiscard]] auto window_size() const -> std::pair<int, int> override { return {0, 0}; }
[[nodiscard]] auto key_down(ui_key) const -> bool override { return false; }
auto shutdown() -> void override {}
[[nodiscard]] auto name() const -> std::string_view override { return "headless"; }
};