Terrain pass now ports the SAGE water model (Ocean.fx / OpenSAGE Water.frag): a de-gridded procedural wave normal combined with the retail ra3_deepocean flow and ra3_deepocean_nrm bump maps (appended as the last two terrain-atlas layers, no new backend binding), Schlick fresnel, sky reflection + depth-graded refraction, SAGE diffuse/specular lighting, depth-based transparency, and an underwater tint (UnderwaterDeferred.fx). Mirrored across terrain.frag / dx_terrain.hlsl / webgl_terrain_frag.glsl / webgpu_terrain.wgsl. Also: logs move to the per-user state dir (%LOCALAPPDATA%\\OpenRA3\\logs, else XDG) and archive as openra3.<stamp>.log; the FPS label shows the active backend; middle-drag camera reset; objects and roads below the water plane are culled.
694 lines
33 KiB
C++
694 lines
33 KiB
C++
export module ra3.render;
|
|
|
|
import std;
|
|
|
|
export import ra3.core;
|
|
|
|
/**
|
|
* A dependency-free software renderer: an ARGB framebuffer, a TGA decoder for
|
|
* the game's map art, a BMP encoder for headless output, and map compositing.
|
|
*
|
|
* The windowed viewer lives in `ra3.ui`; this module is pure computation so it
|
|
* builds and runs anywhere, including CI.
|
|
*/
|
|
export namespace ra3::render {
|
|
using ra3::core::uint8;
|
|
using ra3::core::uint32;
|
|
using ra3::core::usize;
|
|
|
|
/** Thrown when an image payload is malformed. */
|
|
class image_error : public std::runtime_error {
|
|
public:
|
|
using std::runtime_error::runtime_error;
|
|
};
|
|
|
|
/** Pack 8-bit channels into the engine's 0xAARRGGBB pixel format. */
|
|
[[nodiscard]] constexpr auto argb(uint8 r, uint8 g, uint8 b, uint8 a = 255U) -> uint32 {
|
|
return (static_cast<uint32>(a) << 24U) | (static_cast<uint32>(r) << 16U) | (static_cast<uint32>(g) << 8U) | static_cast<uint32>(b);
|
|
}
|
|
|
|
inline constexpr uint32 black = argb(0, 0, 0);
|
|
inline constexpr uint32 white = argb(255, 255, 255);
|
|
inline constexpr uint32 red = argb(220, 40, 40);
|
|
inline constexpr uint32 green = argb(60, 200, 80);
|
|
inline constexpr uint32 blue = argb(70, 120, 230);
|
|
inline constexpr uint32 yellow = argb(230, 210, 60);
|
|
|
|
/**
|
|
* A top-left-origin ARGB8888 image.
|
|
*/
|
|
class image {
|
|
public:
|
|
image() = default;
|
|
|
|
image(uint32 width, uint32 height, uint32 fill = black) : width_(width), height_(height), pixels_(static_cast<usize>(width) * height, fill) {}
|
|
|
|
[[nodiscard]] auto width() const -> uint32 { return width_; }
|
|
[[nodiscard]] auto height() const -> uint32 { return height_; }
|
|
[[nodiscard]] auto empty() const -> bool { return pixels_.empty(); }
|
|
[[nodiscard]] auto data() const -> const uint32 * { return pixels_.data(); }
|
|
[[nodiscard]] auto data() -> uint32 * { return pixels_.data(); }
|
|
|
|
auto set(int x, int y, uint32 color) -> void {
|
|
if (x < 0 || y < 0 || x >= static_cast<int>(width_) || y >= static_cast<int>(height_)) return;
|
|
pixels_[static_cast<usize>(y) * width_ + static_cast<usize>(x)] = color;
|
|
}
|
|
|
|
auto fill(uint32 color) -> void { std::fill(pixels_.begin(), pixels_.end(), color); }
|
|
|
|
auto blend(int x, int y, uint32 color, uint8 alpha) -> void {
|
|
if (alpha == 0U) return;
|
|
if (x < 0 || y < 0 || x >= static_cast<int>(width_) || y >= static_cast<int>(height_)) return;
|
|
auto &dst = pixels_[static_cast<usize>(y) * width_ + static_cast<usize>(x)];
|
|
const auto inv = static_cast<uint32>(255U - alpha);
|
|
const auto mix = [alpha, inv](uint32 lo, uint32 hi) { return (hi * alpha + lo * inv) / 255U; };
|
|
dst = (0xFF000000U) | (mix((dst >> 16U) & 0xFFU, (color >> 16U) & 0xFFU) << 16U) | (mix((dst >> 8U) & 0xFFU, (color >> 8U) & 0xFFU) << 8U) |
|
|
mix(dst & 0xFFU, color & 0xFFU);
|
|
}
|
|
|
|
/** Copy `src` with its top-left at (dst_x, dst_y). */
|
|
auto blit(const image &src, int dst_x, int dst_y) -> void {
|
|
for (uint32 sy = 0; sy < src.height_; ++sy) {
|
|
for (uint32 sx = 0; sx < src.width_; ++sx) {
|
|
this->set(dst_x + static_cast<int>(sx), dst_y + static_cast<int>(sy), src.pixels_[static_cast<usize>(sy) * src.width_ + sx]);
|
|
}
|
|
}
|
|
}
|
|
|
|
auto draw_rect(int x, int y, int w, int h, uint32 color) -> void {
|
|
for (int i = 0; i < h; ++i) {
|
|
for (int j = 0; j < w; ++j) this->set(x + j, y + i, color);
|
|
}
|
|
}
|
|
|
|
auto draw_line(int x0, int y0, int x1, int y1, uint32 color) -> void {
|
|
const int dx = std::abs(x1 - x0);
|
|
const int dy = -std::abs(y1 - y0);
|
|
const int sx = x0 < x1 ? 1 : -1;
|
|
const int sy = y0 < y1 ? 1 : -1;
|
|
int error = dx + dy;
|
|
for (;;) {
|
|
this->set(x0, y0, color);
|
|
if (x0 == x1 && y0 == y1) break;
|
|
const int twice = 2 * error;
|
|
if (twice >= dy) {
|
|
error += dy;
|
|
x0 += sx;
|
|
}
|
|
if (twice <= dx) {
|
|
error += dx;
|
|
y0 += sy;
|
|
}
|
|
}
|
|
}
|
|
|
|
auto fill_circle(int cx, int cy, int radius, uint32 color) -> void {
|
|
for (int y = -radius; y <= radius; ++y) {
|
|
for (int x = -radius; x <= radius; ++x) {
|
|
if (x * x + y * y <= radius * radius) this->set(cx + x, cy + y, color);
|
|
}
|
|
}
|
|
}
|
|
|
|
auto draw_circle(int cx, int cy, int radius, uint32 color) -> void {
|
|
for (int a = 0; a < 360; ++a) {
|
|
const auto rad = static_cast<double>(a) * 3.14159265358979323846 / 180.0;
|
|
this->set(cx + static_cast<int>(std::lround(std::cos(rad) * radius)), cy + static_cast<int>(std::lround(std::sin(rad) * radius)), color);
|
|
}
|
|
}
|
|
|
|
private:
|
|
uint32 width_ = 0;
|
|
uint32 height_ = 0;
|
|
std::vector<uint32> pixels_;
|
|
};
|
|
|
|
namespace detail {
|
|
// 8x12 bitmap font for ASCII 32..126 (baseline at row 9 so descenders fit);
|
|
// each row byte has bit 7 as the leftmost column, top row first.
|
|
inline constexpr std::array<std::array<uint8, 12>, 95> font8x8 = {{
|
|
{0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00}, // ' '
|
|
{0x00,0x40,0x40,0x40,0x40,0x40,0x00,0x00,0x40,0x00,0x00,0x00}, // '!'
|
|
{0x00,0x6C,0x48,0x48,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00}, // '"'
|
|
{0x00,0x14,0x14,0x28,0x7C,0x28,0x7C,0x28,0x50,0x50,0x00,0x00}, // '#'
|
|
{0x00,0x10,0x38,0x40,0x40,0x38,0x48,0x70,0x10,0x10,0x00,0x00}, // '$'
|
|
{0x00,0x20,0x50,0x20,0x0C,0x70,0x08,0x14,0x08,0x00,0x00,0x00}, // '%'
|
|
{0x00,0x00,0x00,0x18,0x20,0x20,0x54,0x48,0x34,0x00,0x00,0x00}, // '&'
|
|
{0x00,0x40,0x40,0x40,0x40,0x00,0x00,0x00,0x00,0x00,0x00,0x00}, // "'"
|
|
{0x00,0x20,0x20,0x40,0x40,0x40,0x40,0x40,0x40,0x20,0x20,0x00}, // '('
|
|
{0x00,0x40,0x40,0x20,0x20,0x20,0x20,0x20,0x20,0x40,0x40,0x00}, // ')'
|
|
{0x00,0x10,0x7C,0x10,0x28,0x08,0x00,0x00,0x00,0x00,0x00,0x00}, // '*'
|
|
{0x00,0x00,0x08,0x08,0x08,0x7F,0x08,0x08,0x08,0x00,0x00,0x00}, // '+'
|
|
{0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x30,0x20,0x60,0x40,0x00}, // ','
|
|
{0x00,0x00,0x00,0x00,0x00,0x7C,0x00,0x00,0x00,0x00,0x00,0x00}, // '-'
|
|
{0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x60,0x60,0x00,0x00,0x00}, // '.'
|
|
{0x00,0x04,0x04,0x08,0x08,0x10,0x10,0x20,0x20,0x40,0x00,0x00}, // '/'
|
|
{0x00,0x38,0x44,0x44,0x44,0x44,0x44,0x44,0x38,0x00,0x00,0x00}, // '0'
|
|
{0x00,0x30,0x10,0x10,0x10,0x10,0x10,0x10,0x7C,0x00,0x00,0x00}, // '1'
|
|
{0x00,0x38,0x44,0x04,0x08,0x10,0x20,0x44,0x7C,0x00,0x00,0x00}, // '2'
|
|
{0x00,0x38,0x44,0x04,0x18,0x04,0x04,0x44,0x38,0x00,0x00,0x00}, // '3'
|
|
{0x00,0x0C,0x14,0x14,0x24,0x44,0x7E,0x04,0x0E,0x00,0x00,0x00}, // '4'
|
|
{0x00,0x3C,0x20,0x20,0x38,0x04,0x04,0x44,0x38,0x00,0x00,0x00}, // '5'
|
|
{0x00,0x1C,0x20,0x40,0x78,0x44,0x44,0x44,0x38,0x00,0x00,0x00}, // '6'
|
|
{0x00,0x7C,0x44,0x04,0x08,0x08,0x08,0x10,0x10,0x00,0x00,0x00}, // '7'
|
|
{0x00,0x38,0x44,0x44,0x38,0x44,0x44,0x44,0x38,0x00,0x00,0x00}, // '8'
|
|
{0x00,0x38,0x44,0x44,0x44,0x3C,0x04,0x08,0x70,0x00,0x00,0x00}, // '9'
|
|
{0x00,0x00,0x00,0x60,0x60,0x00,0x00,0x60,0x60,0x00,0x00,0x00}, // ':'
|
|
{0x00,0x00,0x00,0x30,0x30,0x00,0x00,0x30,0x60,0x40,0x00,0x00}, // ';'
|
|
{0x00,0x06,0x08,0x30,0x40,0x30,0x08,0x06,0x00,0x00,0x00,0x00}, // '<'
|
|
{0x00,0x00,0x00,0x00,0x7C,0x00,0x7C,0x00,0x00,0x00,0x00,0x00}, // '='
|
|
{0x00,0x60,0x10,0x0C,0x02,0x0C,0x10,0x60,0x00,0x00,0x00,0x00}, // '>'
|
|
{0x00,0x00,0x38,0x44,0x04,0x08,0x10,0x00,0x30,0x00,0x00,0x00}, // '?'
|
|
{0x38,0x44,0x44,0x4C,0x54,0x54,0x4C,0x40,0x44,0x38,0x00,0x00}, // '@'
|
|
{0x00,0x18,0x08,0x14,0x14,0x14,0x3E,0x22,0x77,0x00,0x00,0x00}, // 'A'
|
|
{0x00,0x7C,0x22,0x22,0x3C,0x22,0x22,0x22,0x7C,0x00,0x00,0x00}, // 'B'
|
|
{0x00,0x3C,0x44,0x40,0x40,0x40,0x40,0x44,0x38,0x00,0x00,0x00}, // 'C'
|
|
{0x00,0x78,0x24,0x22,0x22,0x22,0x22,0x24,0x78,0x00,0x00,0x00}, // 'D'
|
|
{0x00,0x7E,0x22,0x28,0x38,0x28,0x20,0x22,0x7E,0x00,0x00,0x00}, // 'E'
|
|
{0x00,0x7E,0x22,0x28,0x38,0x28,0x20,0x20,0x70,0x00,0x00,0x00}, // 'F'
|
|
{0x00,0x3C,0x44,0x40,0x40,0x4E,0x44,0x44,0x38,0x00,0x00,0x00}, // 'G'
|
|
{0x00,0x77,0x22,0x22,0x3E,0x22,0x22,0x22,0x77,0x00,0x00,0x00}, // 'H'
|
|
{0x00,0x7C,0x10,0x10,0x10,0x10,0x10,0x10,0x7C,0x00,0x00,0x00}, // 'I'
|
|
{0x00,0x3C,0x08,0x08,0x08,0x48,0x48,0x48,0x30,0x00,0x00,0x00}, // 'J'
|
|
{0x00,0x77,0x22,0x24,0x28,0x38,0x24,0x22,0x73,0x00,0x00,0x00}, // 'K'
|
|
{0x00,0x70,0x20,0x20,0x20,0x20,0x24,0x24,0x7C,0x00,0x00,0x00}, // 'L'
|
|
{0x00,0x77,0x36,0x36,0x2A,0x2A,0x22,0x22,0x77,0x00,0x00,0x00}, // 'M'
|
|
{0x00,0x77,0x32,0x32,0x2A,0x2A,0x2A,0x26,0x76,0x00,0x00,0x00}, // 'N'
|
|
{0x00,0x38,0x44,0x44,0x44,0x44,0x44,0x44,0x38,0x00,0x00,0x00}, // 'O'
|
|
{0x00,0x78,0x24,0x24,0x24,0x38,0x20,0x20,0x70,0x00,0x00,0x00}, // 'P'
|
|
{0x00,0x38,0x44,0x44,0x44,0x44,0x44,0x44,0x38,0x1C,0x00,0x00}, // 'Q'
|
|
{0x00,0x7C,0x22,0x22,0x22,0x3C,0x24,0x22,0x71,0x00,0x00,0x00}, // 'R'
|
|
{0x00,0x34,0x4C,0x40,0x38,0x04,0x04,0x64,0x58,0x00,0x00,0x00}, // 'S'
|
|
{0x00,0x7F,0x49,0x08,0x08,0x08,0x08,0x08,0x1C,0x00,0x00,0x00}, // 'T'
|
|
{0x00,0x77,0x22,0x22,0x22,0x22,0x22,0x22,0x1C,0x00,0x00,0x00}, // 'U'
|
|
{0x00,0x77,0x22,0x22,0x14,0x14,0x14,0x08,0x08,0x00,0x00,0x00}, // 'V'
|
|
{0x00,0x77,0x22,0x22,0x2A,0x2A,0x2A,0x2A,0x14,0x00,0x00,0x00}, // 'W'
|
|
{0x00,0x63,0x22,0x14,0x08,0x08,0x14,0x22,0x63,0x00,0x00,0x00}, // 'X'
|
|
{0x00,0x77,0x22,0x14,0x14,0x08,0x08,0x08,0x1C,0x00,0x00,0x00}, // 'Y'
|
|
{0x00,0x7C,0x44,0x08,0x10,0x10,0x20,0x44,0x7C,0x00,0x00,0x00}, // 'Z'
|
|
{0x00,0x70,0x40,0x40,0x40,0x40,0x40,0x40,0x40,0x40,0x70,0x00}, // '['
|
|
{0x00,0x00,0x40,0x40,0x40,0x20,0x20,0x10,0x10,0x00,0x00,0x00}, // '\\'
|
|
{0x00,0x70,0x10,0x10,0x10,0x10,0x10,0x10,0x10,0x10,0x70,0x00}, // ']'
|
|
{0x00,0x10,0x10,0x28,0x44,0x00,0x00,0x00,0x00,0x00,0x00,0x00}, // '^'
|
|
{0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x7F}, // '_'
|
|
{0x00,0x40,0x20,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00}, // '`'
|
|
{0x00,0x00,0x00,0x38,0x04,0x3C,0x44,0x44,0x3E,0x00,0x00,0x00}, // 'a'
|
|
{0x00,0x60,0x20,0x2C,0x32,0x22,0x22,0x22,0x7C,0x00,0x00,0x00}, // 'b'
|
|
{0x00,0x00,0x00,0x3C,0x44,0x40,0x40,0x44,0x38,0x00,0x00,0x00}, // 'c'
|
|
{0x00,0x0C,0x04,0x34,0x4C,0x44,0x44,0x44,0x3E,0x00,0x00,0x00}, // 'd'
|
|
{0x00,0x00,0x00,0x38,0x44,0x7C,0x40,0x40,0x3C,0x00,0x00,0x00}, // 'e'
|
|
{0x00,0x1C,0x20,0x7C,0x20,0x20,0x20,0x20,0x7C,0x00,0x00,0x00}, // 'f'
|
|
{0x00,0x00,0x00,0x36,0x4C,0x44,0x44,0x44,0x3C,0x04,0x38,0x00}, // 'g'
|
|
{0x00,0x60,0x20,0x2C,0x32,0x22,0x22,0x22,0x77,0x00,0x00,0x00}, // 'h'
|
|
{0x00,0x10,0x00,0x70,0x10,0x10,0x10,0x10,0x7C,0x00,0x00,0x00}, // 'i'
|
|
{0x00,0x10,0x00,0x78,0x08,0x08,0x08,0x08,0x08,0x08,0x70,0x00}, // 'j'
|
|
{0x00,0x60,0x20,0x2E,0x24,0x38,0x28,0x24,0x6E,0x00,0x00,0x00}, // 'k'
|
|
{0x00,0x30,0x10,0x10,0x10,0x10,0x10,0x10,0x7C,0x00,0x00,0x00}, // 'l'
|
|
{0x00,0x00,0x00,0x74,0x2A,0x2A,0x2A,0x2A,0x7F,0x00,0x00,0x00}, // 'm'
|
|
{0x00,0x00,0x00,0x6C,0x32,0x22,0x22,0x22,0x77,0x00,0x00,0x00}, // 'n'
|
|
{0x00,0x00,0x00,0x38,0x44,0x44,0x44,0x44,0x38,0x00,0x00,0x00}, // 'o'
|
|
{0x00,0x00,0x00,0x6C,0x32,0x22,0x22,0x22,0x3C,0x20,0x70,0x00}, // 'p'
|
|
{0x00,0x00,0x00,0x36,0x4C,0x44,0x44,0x44,0x3C,0x04,0x0E,0x00}, // 'q'
|
|
{0x00,0x00,0x00,0x6C,0x30,0x20,0x20,0x20,0x7C,0x00,0x00,0x00}, // 'r'
|
|
{0x00,0x00,0x00,0x3C,0x44,0x38,0x04,0x44,0x78,0x00,0x00,0x00}, // 's'
|
|
{0x00,0x00,0x20,0x7C,0x20,0x20,0x20,0x22,0x1C,0x00,0x00,0x00}, // 't'
|
|
{0x00,0x00,0x00,0x66,0x22,0x22,0x22,0x26,0x1B,0x00,0x00,0x00}, // 'u'
|
|
{0x00,0x00,0x00,0x77,0x22,0x22,0x14,0x14,0x08,0x00,0x00,0x00}, // 'v'
|
|
{0x00,0x00,0x00,0x77,0x22,0x2A,0x2A,0x2A,0x14,0x00,0x00,0x00}, // 'w'
|
|
{0x00,0x00,0x00,0x66,0x24,0x18,0x18,0x24,0x66,0x00,0x00,0x00}, // 'x'
|
|
{0x00,0x00,0x00,0x77,0x22,0x12,0x14,0x0C,0x08,0x08,0x3C,0x00}, // 'y'
|
|
{0x00,0x00,0x00,0x7C,0x48,0x10,0x20,0x44,0x7C,0x00,0x00,0x00}, // 'z'
|
|
{0x00,0x10,0x20,0x20,0x20,0x20,0x40,0x20,0x20,0x20,0x10,0x00}, // '{'
|
|
{0x00,0x40,0x40,0x40,0x40,0x40,0x40,0x40,0x40,0x40,0x00,0x00}, // '|'
|
|
{0x00,0x40,0x20,0x20,0x20,0x20,0x10,0x20,0x20,0x20,0x40,0x00}, // '}'
|
|
{0x00,0x00,0x00,0x00,0x00,0x24,0x58,0x00,0x00,0x00,0x00,0x00}, // '~'
|
|
}};
|
|
|
|
inline constexpr uint32 glyph_width = 8;
|
|
inline constexpr uint32 glyph_height = 12;
|
|
}
|
|
|
|
/** Pixel width of `text` drawn at `scale`. */
|
|
[[nodiscard]] inline auto text_width(std::string_view text, uint32 scale = 1U) -> uint32 {
|
|
return static_cast<uint32>(text.size()) * detail::glyph_width * std::max(1U, scale);
|
|
}
|
|
|
|
/** Draw `text` with its top-left at `(x, y)`, glyphs scaled by `scale`. */
|
|
inline auto draw_text(image &target, int x, int y, std::string_view text, uint32 color, uint32 scale = 1U) -> void {
|
|
scale = std::max(1U, scale);
|
|
int cursor = x;
|
|
for (const auto ch: text) {
|
|
const auto code = static_cast<unsigned char>(ch);
|
|
if (code >= 32U && code < 127U) {
|
|
const auto &glyph = detail::font8x8[code - 32U];
|
|
for (uint32 gy = 0; gy < detail::glyph_height; ++gy) {
|
|
const auto bits = glyph[gy];
|
|
if (bits == 0U) continue;
|
|
for (uint32 gx = 0; gx < detail::glyph_width; ++gx) {
|
|
if ((bits & (1U << (7U - gx))) == 0U) continue;
|
|
for (uint32 sy = 0; sy < scale; ++sy) {
|
|
for (uint32 sx = 0; sx < scale; ++sx) {
|
|
target.set(cursor + static_cast<int>(gx * scale + sx), y + static_cast<int>(gy * scale + sy), color);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
cursor += static_cast<int>(detail::glyph_width * scale);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Backend-agnostic input for the interactive loops. The windowing backends
|
|
* (`ra3.vulkan` / `ra3.ui`) translate their native events into this and hand
|
|
* it to the shared `ra3::client::display` loops.
|
|
*/
|
|
enum class ui_key : uint8 { none, up, down, left, right, page_up, page_down, confirm, cancel, tab, backspace };
|
|
|
|
enum class ui_event_type : uint8 { none, quit, key, text, mouse_move, mouse_button, wheel };
|
|
|
|
struct ui_event {
|
|
ui_event_type type = ui_event_type::none;
|
|
ui_key key = ui_key::none;
|
|
char character = '\0';
|
|
float x = 0.0F;
|
|
float y = 0.0F;
|
|
float dx = 0.0F;
|
|
float dy = 0.0F;
|
|
float wheel = 0.0F;
|
|
bool left = false;
|
|
bool middle = false;
|
|
bool right = false;
|
|
bool released = false; ///< mouse_button: true for a button-up event.
|
|
};
|
|
|
|
/** A warm red/gold loading screen with a progress bar (0..1). */
|
|
[[nodiscard]] inline auto compose_progress(uint32 width, uint32 height, float progress, std::string_view label) -> image {
|
|
const auto background = argb(34, 9, 8);
|
|
const auto panel = argb(58, 14, 12);
|
|
const auto edge = argb(120, 30, 22);
|
|
const auto gold = argb(236, 190, 80);
|
|
const auto cream = argb(246, 228, 192);
|
|
image img(width, height, background);
|
|
const auto k = static_cast<double>(height) / 720.0;
|
|
const auto S = [k](double v) { return static_cast<int>(std::lround(v * k)); };
|
|
const auto scale = static_cast<uint32>(std::max(1, static_cast<int>(std::lround(2.0 * k))));
|
|
const auto t = std::clamp(progress, 0.0F, 1.0F);
|
|
|
|
const auto title = std::string_view{"OpenRA3"};
|
|
draw_text(img, (static_cast<int>(width) - static_cast<int>(text_width(title, scale + 1U))) / 2, S(250), title, gold, scale + 1U);
|
|
draw_text(img, (static_cast<int>(width) - static_cast<int>(text_width(label, scale))) / 2, S(300), label, cream, scale);
|
|
|
|
const int bar_x = S(240);
|
|
const int bar_y = S(360);
|
|
const int bar_w = static_cast<int>(width) - 2 * bar_x;
|
|
const int bar_h = S(28);
|
|
img.draw_rect(bar_x - S(3), bar_y - S(3), bar_w + S(6), bar_h + S(6), edge);
|
|
img.draw_rect(bar_x, bar_y, bar_w, bar_h, panel);
|
|
img.draw_rect(bar_x, bar_y, static_cast<int>(static_cast<float>(bar_w) * t), bar_h, gold);
|
|
|
|
char percent[32];
|
|
std::snprintf(percent, sizeof(percent), "%d%%", static_cast<int>(t * 100.0F + 0.5F));
|
|
draw_text(img, (static_cast<int>(width) - static_cast<int>(text_width(percent, scale))) / 2, bar_y + bar_h + S(18), percent, cream, scale);
|
|
return img;
|
|
}
|
|
|
|
/**
|
|
* 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, std::string_view backend = {}) -> image {
|
|
char text[64];
|
|
if (cap == 0) {
|
|
std::snprintf(text, sizeof(text), "FPS: %u/vsync", fps);
|
|
} else if (cap < 0) {
|
|
std::snprintf(text, sizeof(text), "FPS: %u", fps);
|
|
} else {
|
|
std::snprintf(text, sizeof(text), "FPS: %u/%d", fps, cap);
|
|
}
|
|
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, label, argb(240, 200, 90), 1U);
|
|
return img;
|
|
}
|
|
|
|
/**
|
|
* Box-downsample `source` so its width is at most `max_width` (aspect
|
|
* preserved). Returns a copy when it is already small enough.
|
|
*/
|
|
[[nodiscard]] inline auto downscale(const image &source, uint32 max_width) -> image {
|
|
if (source.empty() || source.width() <= max_width) return source;
|
|
const auto scale = static_cast<uint32>(std::ceil(static_cast<double>(source.width()) / static_cast<double>(max_width)));
|
|
const auto w = std::max(1U, source.width() / scale);
|
|
const auto h = std::max(1U, source.height() / scale);
|
|
image out(w, h);
|
|
for (uint32 y = 0; y < h; ++y) {
|
|
for (uint32 x = 0; x < w; ++x) {
|
|
uint32 r = 0;
|
|
uint32 g = 0;
|
|
uint32 b = 0;
|
|
uint32 n = 0;
|
|
for (uint32 dy = 0; dy < scale; ++dy) {
|
|
for (uint32 dx = 0; dx < scale; ++dx) {
|
|
const auto sx = std::min(source.width() - 1U, x * scale + dx);
|
|
const auto sy = std::min(source.height() - 1U, y * scale + dy);
|
|
const auto px = source.data()[static_cast<usize>(sy) * source.width() + sx];
|
|
r += (px >> 16U) & 0xFFU;
|
|
g += (px >> 8U) & 0xFFU;
|
|
b += px & 0xFFU;
|
|
++n;
|
|
}
|
|
}
|
|
out.set(static_cast<int>(x), static_cast<int>(y), argb(static_cast<uint8>(r / n), static_cast<uint8>(g / n), static_cast<uint8>(b / n)));
|
|
}
|
|
}
|
|
return out;
|
|
}
|
|
|
|
/**
|
|
* Copy `full` (a whole-map overview) and mark the camera's location with a
|
|
* gold rectangle. `u`/`v` are the normalized map position (v is y-down).
|
|
*/
|
|
[[nodiscard]] inline auto compose_minimap(const image &full, float u, float v) -> image {
|
|
image out = full;
|
|
if (full.empty()) return out;
|
|
const int r = std::max(4, static_cast<int>(full.width()) / 48);
|
|
const int x = std::clamp(static_cast<int>(u * static_cast<float>(full.width())), r, static_cast<int>(full.width()) - r - 1);
|
|
const int y = std::clamp(static_cast<int>(v * static_cast<float>(full.height())), r, static_cast<int>(full.height()) - r - 1);
|
|
const auto gold = argb(240, 200, 90);
|
|
out.draw_rect(x - r, y - r, 2 * r, 2, gold);
|
|
out.draw_rect(x - r, y + r - 2, 2 * r, 2, gold);
|
|
out.draw_rect(x - r, y - r, 2, 2 * r, gold);
|
|
out.draw_rect(x + r - 2, y - r, 2, 2 * r, gold);
|
|
return out;
|
|
}
|
|
|
|
[[nodiscard]] inline auto read_u16(std::span<const uint8> data, usize at) -> uint32 {
|
|
return static_cast<uint32>(data[at]) | (static_cast<uint32>(data[at + 1U]) << 8U);
|
|
}
|
|
|
|
/**
|
|
* Decode a Truevision TGA image (types 2/3/10/11, 8/24/32-bit).
|
|
*
|
|
* RA3's map art and minimaps are uncompressed 24/32-bit TGAs with a
|
|
* bottom-left origin; the decoder handles both origins.
|
|
*
|
|
* @throws image_error if the header is short or the type is unsupported.
|
|
*/
|
|
[[nodiscard]] inline auto decode_tga(std::span<const uint8> data) -> image {
|
|
if (data.size() < 18U) throw image_error("TGA too short");
|
|
const auto id_length = data[0];
|
|
const auto color_map_type = data[1];
|
|
const auto image_type = data[2];
|
|
const auto width = read_u16(data, 12);
|
|
const auto height = read_u16(data, 14);
|
|
const auto depth = data[16];
|
|
const auto descriptor = data[17];
|
|
|
|
if (color_map_type != 0U) throw image_error("color-mapped TGA unsupported");
|
|
const bool rle = image_type == 10U || image_type == 11U;
|
|
const bool grayscale = image_type == 3U || image_type == 11U;
|
|
if (!grayscale && image_type != 2U && image_type != 10U) throw image_error("unsupported TGA image type");
|
|
|
|
const usize bytes_per_pixel = static_cast<usize>(depth / 8U);
|
|
if (bytes_per_pixel < 1U || bytes_per_pixel > 4U) throw image_error("unsupported TGA depth");
|
|
|
|
const bool top_origin = (descriptor & 0x20U) != 0U;
|
|
image out(width, height);
|
|
std::vector<uint32> row(width);
|
|
|
|
usize pos = 18U + id_length;
|
|
auto read_pixel = [&]() -> uint32 {
|
|
uint8 r = 0;
|
|
uint8 g = 0;
|
|
uint8 b = 0;
|
|
if (grayscale) {
|
|
r = g = b = data[pos];
|
|
} else {
|
|
b = data[pos];
|
|
g = data[pos + 1U];
|
|
r = data[pos + 2U];
|
|
}
|
|
pos += bytes_per_pixel;
|
|
return argb(r, g, b);
|
|
};
|
|
|
|
for (uint32 y = 0; y < height; ++y) {
|
|
if (rle) {
|
|
uint32 x = 0;
|
|
while (x < width) {
|
|
const auto packet = data[pos++];
|
|
const uint32 run = (static_cast<uint32>(packet) & 0x7FU) + 1U;
|
|
if ((packet & 0x80U) != 0U) {
|
|
const auto value = read_pixel();
|
|
for (uint32 i = 0; i < run && x < width; ++i, ++x) row[x] = value;
|
|
} else {
|
|
for (uint32 i = 0; i < run && x < width; ++i, ++x) row[x] = read_pixel();
|
|
}
|
|
}
|
|
} else {
|
|
for (uint32 x = 0; x < width; ++x) row[x] = read_pixel();
|
|
}
|
|
const auto dest_y = top_origin ? y : (height - 1U - y);
|
|
for (uint32 x = 0; x < width; ++x) out.set(static_cast<int>(x), static_cast<int>(dest_y), row[x]);
|
|
}
|
|
return out;
|
|
}
|
|
|
|
/**
|
|
* Encode an image as a 24-bit bottom-up BMP.
|
|
*/
|
|
[[nodiscard]] inline auto encode_bmp(const image &source) -> std::vector<uint8> {
|
|
const auto width = static_cast<uint32>(source.width());
|
|
const auto height = static_cast<uint32>(source.height());
|
|
const uint32 row_bytes = ((width * 3U + 3U) / 4U) * 4U;
|
|
const uint32 pixel_bytes = row_bytes * height;
|
|
const uint32 file_size = 54U + pixel_bytes;
|
|
|
|
std::vector<uint8> out(file_size, 0U);
|
|
auto put32 = [&out](usize at, uint32 value) {
|
|
out[at] = static_cast<uint8>(value);
|
|
out[at + 1U] = static_cast<uint8>(value >> 8U);
|
|
out[at + 2U] = static_cast<uint8>(value >> 16U);
|
|
out[at + 3U] = static_cast<uint8>(value >> 24U);
|
|
};
|
|
auto put16 = [&out](usize at, std::uint16_t value) {
|
|
out[at] = static_cast<uint8>(value);
|
|
out[at + 1U] = static_cast<uint8>(value >> 8U);
|
|
};
|
|
|
|
out[0] = 'B';
|
|
out[1] = 'M';
|
|
put32(2, file_size);
|
|
put32(10, 54U); // pixel data offset
|
|
put32(14, 40U); // DIB header size
|
|
put32(18, width);
|
|
put32(22, height);
|
|
put16(26, 1U); // planes
|
|
put16(28, 24U); // bits per pixel
|
|
put32(34, pixel_bytes);
|
|
put32(38, 2835U); // 72 DPI
|
|
put32(42, 2835U);
|
|
|
|
for (uint32 y = 0; y < height; ++y) {
|
|
const auto src_y = height - 1U - y; // BMP is bottom-up
|
|
const auto row_base = 54U + static_cast<usize>(y) * row_bytes;
|
|
for (uint32 x = 0; x < width; ++x) {
|
|
const auto px = source.data()[static_cast<usize>(src_y) * width + x];
|
|
out[row_base + x * 3U] = static_cast<uint8>(px & 0xFFU);
|
|
out[row_base + x * 3U + 1U] = static_cast<uint8>((px >> 8U) & 0xFFU);
|
|
out[row_base + x * 3U + 2U] = static_cast<uint8>((px >> 16U) & 0xFFU);
|
|
}
|
|
}
|
|
return out;
|
|
}
|
|
|
|
/** A point to overlay on a map, in world coordinates. */
|
|
struct marker {
|
|
double x = 0.0;
|
|
double y = 0.0;
|
|
uint32 color = white;
|
|
int radius = 4;
|
|
};
|
|
|
|
/** How to composite a map scene. */
|
|
struct scene_options {
|
|
std::string title;
|
|
double world_width = 5120.0;
|
|
double world_height = 5120.0;
|
|
bool show_grid = true;
|
|
int grid_divisions = 8;
|
|
uint32 grid_color = argb(120, 140, 170);
|
|
uint32 background = argb(24, 28, 36);
|
|
};
|
|
|
|
/**
|
|
* Composite a map image with a world grid and marker overlays.
|
|
*
|
|
* World `(0,0)` maps to the bottom-left of `base`; world `+Y` points up, so
|
|
* the image is flipped vertically. This is the engine's own top-down view
|
|
* of the map; precise world calibration is a later milestone.
|
|
*/
|
|
[[nodiscard]] inline auto compose(const image &base, std::span<const marker> markers, const scene_options &options) -> image {
|
|
image scene(base.width(), base.height(), options.background);
|
|
scene.blit(base, 0, 0);
|
|
|
|
const auto to_px = [&](double world_x, double world_y) -> std::pair<int, int> {
|
|
const auto fx = options.world_width > 0.0 ? world_x / options.world_width : 0.0;
|
|
const auto fy = options.world_height > 0.0 ? world_y / options.world_height : 0.0;
|
|
return {static_cast<int>(std::lround(fx * static_cast<double>(base.width()))),
|
|
static_cast<int>(std::lround((1.0 - fy) * static_cast<double>(base.height())))};
|
|
};
|
|
|
|
if (options.show_grid && options.grid_divisions > 0) {
|
|
for (int i = 0; i <= options.grid_divisions; ++i) {
|
|
const auto fx = static_cast<double>(i) / options.grid_divisions;
|
|
const auto x = static_cast<int>(std::lround(fx * base.width()));
|
|
const auto y = static_cast<int>(std::lround(fx * base.height()));
|
|
scene.draw_line(x, 0, x, static_cast<int>(base.height()) - 1, options.grid_color);
|
|
scene.draw_line(0, y, static_cast<int>(base.width()) - 1, y, options.grid_color);
|
|
}
|
|
}
|
|
|
|
for (const auto &point: markers) {
|
|
const auto [px, py] = to_px(point.x, point.y);
|
|
scene.fill_circle(px, py, point.radius, point.color);
|
|
scene.draw_circle(px, py, point.radius + 1, black);
|
|
}
|
|
return scene;
|
|
}
|
|
|
|
/**
|
|
* The destination of the map image on screen, in window coordinates.
|
|
*
|
|
* `x`/`y` is the top-left corner and `w`/`h` the size, in the same units as
|
|
* the window. The viewer draws `image * w` into this rectangle; whatever is
|
|
* left of the window stays background, so the map is letterboxed instead of
|
|
* stretched.
|
|
*/
|
|
struct view_rect {
|
|
float x = 0.0F;
|
|
float y = 0.0F;
|
|
float w = 0.0F;
|
|
float h = 0.0F;
|
|
};
|
|
|
|
/** Letterbox an `iw`x`ih` image into a `ww`x`wh` window, preserving aspect. */
|
|
[[nodiscard]] inline auto fit_rect(float iw, float ih, float ww, float wh) -> view_rect {
|
|
if (iw <= 0.0F || ih <= 0.0F || ww <= 0.0F || wh <= 0.0F) return {};
|
|
const auto scale = std::min(ww / iw, wh / ih);
|
|
const auto w = iw * scale;
|
|
const auto h = ih * scale;
|
|
return {(ww - w) * 0.5F, (wh - h) * 0.5F, w, h};
|
|
}
|
|
|
|
/**
|
|
* A 2D pan/zoom camera over a raster map, reproducing the Red Alert 3
|
|
* tactical view controls:
|
|
*
|
|
* - the wheel zooms (zoom 1 === the whole map fits the window);
|
|
* - pushing the cursor against a screen edge scrolls the view;
|
|
* - the view is clamped so it never leaves the map.
|
|
*
|
|
* `center_x`/`center_y` are the normalized image position (0..1, y down)
|
|
* held at the centre of the viewport. The camera mirrors the retail view
|
|
* object (`TheTacticalView`, retail `ra3_1.12.game` `0x00cdb7b4`), whose
|
|
* zoom is the scalar the debug overlay prints at `0x00c0b900`; the
|
|
* per-map scroll scaling is `cameraScrollSpeedScalar` (map data table at
|
|
* `0x00c11a54`).
|
|
*/
|
|
struct view_camera {
|
|
float zoom = 1.0F;
|
|
float center_x = 0.5F;
|
|
float center_y = 0.5F;
|
|
float min_zoom = 1.0F;
|
|
float max_zoom = 24.0F;
|
|
float zoom_step = 1.15F;
|
|
float edge_scroll_viewports_per_second = 0.55F; ///< speed of a full edge push
|
|
float edge_margin = 24.0F; ///< pixels from the border
|
|
|
|
/** Keep the visible window inside the image. */
|
|
auto clamp_center() -> void {
|
|
const float half = 0.5F / zoom;
|
|
center_x = std::clamp(center_x, half, 1.0F - half);
|
|
center_y = std::clamp(center_y, half, 1.0F - half);
|
|
}
|
|
|
|
/** Zoom by `factor` (wheel up > 1) about the viewport centre. */
|
|
auto zoom_by(float factor) -> void {
|
|
zoom = std::clamp(zoom * factor, min_zoom, max_zoom);
|
|
this->clamp_center();
|
|
}
|
|
|
|
/** Scroll directly by a normalized image delta. */
|
|
auto scroll(float dx, float dy) -> void {
|
|
center_x += dx;
|
|
center_y += dy;
|
|
this->clamp_center();
|
|
}
|
|
|
|
/**
|
|
* Push the camera when the cursor `(mouse_x, mouse_y)` is within
|
|
* `edge_margin` of a window edge. `dt` is the frame time in seconds.
|
|
*/
|
|
auto edge_scroll(float mouse_x, float mouse_y, float window_w, float window_h, float dt) -> void {
|
|
if (zoom <= min_zoom + 1.0e-4F || window_w <= 0.0F || window_h <= 0.0F) return;
|
|
float dir_x = 0.0F;
|
|
float dir_y = 0.0F;
|
|
if (mouse_x <= edge_margin) {
|
|
dir_x = -1.0F;
|
|
} else if (mouse_x >= window_w - edge_margin) {
|
|
dir_x = 1.0F;
|
|
}
|
|
if (mouse_y <= edge_margin) {
|
|
dir_y = -1.0F;
|
|
} else if (mouse_y >= window_h - edge_margin) {
|
|
dir_y = 1.0F;
|
|
}
|
|
if (dir_x == 0.0F && dir_y == 0.0F) return;
|
|
const auto step = (1.0F / zoom) * edge_scroll_viewports_per_second * dt;
|
|
this->scroll(dir_x * step, dir_y * step);
|
|
}
|
|
|
|
/** Resolve the image destination rectangle for a window of `window_w` x `window_h`. */
|
|
[[nodiscard]] auto rect(float image_w, float image_h, float window_w, float window_h) const -> view_rect {
|
|
if (image_w <= 0.0F || image_h <= 0.0F) return {};
|
|
const auto fit = std::min(window_w / image_w, window_h / image_h);
|
|
const auto scale = fit * zoom;
|
|
const auto w = image_w * scale;
|
|
const auto h = image_h * scale;
|
|
auto x = window_w * 0.5F - w * center_x;
|
|
auto y = window_h * 0.5F - h * center_y;
|
|
x = w <= window_w ? (window_w - w) * 0.5F : std::clamp(x, window_w - w, 0.0F);
|
|
y = h <= window_h ? (window_h - h) * 0.5F : std::clamp(y, window_h - h, 0.0F);
|
|
return {x, y, w, h};
|
|
}
|
|
};
|
|
|
|
/**
|
|
* A perspective camera aimed at a ground target, like the retail tactical
|
|
* view. The wheel changes `height` (moving the camera closer/farther), not
|
|
* an image scale; `yaw`/`pitch` orbit it.
|
|
*/
|
|
struct camera3d {
|
|
float target_x = 0.0F; ///< World position the camera looks at.
|
|
float target_y = 0.0F;
|
|
float yaw = 0.0F; ///< Radians; 0 looks toward +Y.
|
|
float pitch = 1.02F; ///< Radians above the horizon (~58 degrees down).
|
|
float height = 420.0F; ///< Camera height above the target's ground.
|
|
float fov = 0.85F; ///< Vertical field of view, radians.
|
|
// Zoom range mirrors the retail TacticalView (zoom 0.2..1.3 around the
|
|
// default height): closer/farther than that is clamped.
|
|
float min_height = 320.0F;
|
|
float max_height = 2100.0F;
|
|
};
|
|
}
|