v0.1.0: software map renderer + SDL3 window viewer

This commit is contained in:
EnderTheCoder
2026-09-12 01:02:49 +08:00
parent 3ad380c81e
commit b35372da52
13 changed files with 659 additions and 31 deletions
+311
View File
@@ -0,0 +1,311 @@
module;
#include <algorithm>
#include <cmath>
#include <cstddef>
#include <cstdint>
#include <cstring>
#include <span>
#include <stdexcept>
#include <string>
#include <utility>
#include <vector>
export module ra3.render;
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_;
};
[[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, 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;
}
}