v0.1.0: software map renderer + SDL3 window viewer
This commit is contained in:
@@ -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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user