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(a) << 24U) | (static_cast(r) << 16U) | (static_cast(g) << 8U) | static_cast(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(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(width_) || y >= static_cast(height_)) return; pixels_[static_cast(y) * width_ + static_cast(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(width_) || y >= static_cast(height_)) return; auto &dst = pixels_[static_cast(y) * width_ + static_cast(x)]; const auto inv = static_cast(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(sx), dst_y + static_cast(sy), src.pixels_[static_cast(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(a) * 3.14159265358979323846 / 180.0; this->set(cx + static_cast(std::lround(std::cos(rad) * radius)), cy + static_cast(std::lround(std::sin(rad) * radius)), color); } } private: uint32 width_ = 0; uint32 height_ = 0; std::vector 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, 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(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(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(gx * scale + sx), y + static_cast(gy * scale + sy), color); } } } } } cursor += static_cast(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; }; /** 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(height) / 720.0; const auto S = [k](double v) { return static_cast(std::lround(v * k)); }; const auto scale = static_cast(std::max(1, static_cast(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(width) - static_cast(text_width(title, scale + 1U))) / 2, S(250), title, gold, scale + 1U); draw_text(img, (static_cast(width) - static_cast(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(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(static_cast(bar_w) * t), bar_h, gold); char percent[32]; std::snprintf(percent, sizeof(percent), "%d%%", static_cast(t * 100.0F + 0.5F)); draw_text(img, (static_cast(width) - static_cast(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`. * `cap == 0` means vertical sync, `cap < 0` means uncapped. */ [[nodiscard]] inline auto compose_fps_label(uint32 fps, int cap) -> 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); } const auto w = text_width(text, 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); 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(std::ceil(static_cast(source.width()) / static_cast(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(sy) * source.width() + sx]; r += (px >> 16U) & 0xFFU; g += (px >> 8U) & 0xFFU; b += px & 0xFFU; ++n; } } out.set(static_cast(x), static_cast(y), argb(static_cast(r / n), static_cast(g / n), static_cast(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(full.width()) / 48); const int x = std::clamp(static_cast(u * static_cast(full.width())), r, static_cast(full.width()) - r - 1); const int y = std::clamp(static_cast(v * static_cast(full.height())), r, static_cast(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 data, usize at) -> uint32 { return static_cast(data[at]) | (static_cast(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 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(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 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(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(x), static_cast(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 { const auto width = static_cast(source.width()); const auto height = static_cast(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 out(file_size, 0U); auto put32 = [&out](usize at, uint32 value) { out[at] = static_cast(value); out[at + 1U] = static_cast(value >> 8U); out[at + 2U] = static_cast(value >> 16U); out[at + 3U] = static_cast(value >> 24U); }; auto put16 = [&out](usize at, std::uint16_t value) { out[at] = static_cast(value); out[at + 1U] = static_cast(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(y) * row_bytes; for (uint32 x = 0; x < width; ++x) { const auto px = source.data()[static_cast(src_y) * width + x]; out[row_base + x * 3U] = static_cast(px & 0xFFU); out[row_base + x * 3U + 1U] = static_cast((px >> 8U) & 0xFFU); out[row_base + x * 3U + 2U] = static_cast((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 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 { 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(std::lround(fx * static_cast(base.width()))), static_cast(std::lround((1.0 - fy) * static_cast(base.height())))}; }; if (options.show_grid && options.grid_divisions > 0) { for (int i = 0; i <= options.grid_divisions; ++i) { const auto fx = static_cast(i) / options.grid_divisions; const auto x = static_cast(std::lround(fx * base.width())); const auto y = static_cast(std::lround(fx * base.height())); scene.draw_line(x, 0, x, static_cast(base.height()) - 1, options.grid_color); scene.draw_line(0, y, static_cast(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. float min_height = 120.0F; float max_height = 1600.0F; }; }