render map objects: decode compiled W3DMesh art, skin and draw over terrain

Read the buildings and props a map places from its compiled art (the
uncompressed worldbuilder stream + embedded DDS textures):

- ra3.models: BAB asset-stream parser (lazy slices), W3DMesh + D3DHierarchy
  decode, DDS (DXT1/3/5 + uncompressed) decode, per-mesh bone remap and
  bind-pose single-joint skinning, flattened to one world-space triangle soup.
- ra3.map: parse the ObjectsList chunk into (type, x, y, z, angle).
- ra3.terrain: render3d rasterises the scene over the raymarched terrain with
  a z-buffer; gpu_terrain carries the scene for the GPU backends.
- ra3.vulkan: second pipeline + depth attachment, terrain.frag writes
  gl_FragDepth, and a small depth bias keeps ground decals from z-fighting.
- objects.fx shaders (compiled to SPIR-V), embedded like scene/terrain.

Only opaque parts are drawn: FX-light billboards (DefaultW3D.fx / BasicW3D.fx)
and damage-fill shells (BuildingsGenericDamageFill.fx) are skipped, since the
latter paint the wrecked interior (e.g. orange CBBuilding_Wood) over the shell.

Ground-decal meshes with no diffuse role and the Road templates themselves are
still not drawn.
This commit is contained in:
EnderTheCoder
2026-09-29 18:08:11 +08:00
parent 6e5df350c7
commit 70f35d8382
18 changed files with 2027 additions and 45 deletions
+161 -6
View File
@@ -5,6 +5,7 @@ import std;
export import ra3.core;
export import ra3.fs;
export import ra3.render;
export import ra3.models;
/**
* The map's real terrain, read from the compiled `CkMp` chunk tree.
@@ -481,6 +482,7 @@ export namespace ra3::terrain {
float z_scale = 0.0390625F;
bool has_water = false;
float water_z = 0.0F;
ra3::models::scene objects; ///< Buildings and props placed on the map (world-space triangle soup).
};
/**
@@ -492,14 +494,15 @@ export namespace ra3::terrain {
* base texture layer, the blend (and three-way) secondary layer and the
* packed blend direction/flags, which the shader ramps across the cell.
*/
[[nodiscard]] inline auto build_gpu_terrain(const map_data &map, const texture_set &set, const render_options &options = {},
const std::function<void(float)> &progress = {}) -> gpu_terrain {
[[nodiscard]] inline auto build_gpu_terrain(const map_data &map, const texture_set &set, const render_options &options,
const ra3::models::scene &objects, const std::function<void(float)> &progress = {}) -> gpu_terrain {
gpu_terrain out;
out.width = map.width;
out.height = map.height;
out.z_scale = options.z_scale;
out.has_water = map.has_water;
out.water_z = map.water_plane_z;
out.objects = objects;
// Cell index -> texture layer.
const auto layer_of = [&](uint32 cell_index) -> uint16 {
@@ -566,6 +569,12 @@ export namespace ra3::terrain {
return out;
}
/** Terrain without any placed objects. */
[[nodiscard]] inline auto build_gpu_terrain(const map_data &map, const texture_set &set, const render_options &options = {},
const std::function<void(float)> &progress = {}) -> gpu_terrain {
return build_gpu_terrain(map, set, options, ra3::models::scene{}, progress);
}
namespace detail {
/**
* The source texture a tile cell maps to. The texture is sampled
@@ -721,6 +730,144 @@ export namespace ra3::terrain {
const auto c2 = f2 > 0.0F ? sample_layer(layers.three, wx, wy) : c0;
return mix_color(mix_color(c0, c1, f1), c2, f2);
}
/**
* Rasterise the map's building/prop scene over an already ray-marched
* terrain image, depth-testing against it.
*
* The camera basis is the one `render3d` used, so the two passes agree;
* `zbuf` holds the terrain's view-space depth per pixel (large where the
* ray hit nothing). Triangles are z-tested and perspective-correct.
*/
inline auto rasterize_objects(image &hi, std::vector<float> &zbuf, const ra3::models::scene &scene, const std::array<float, 3> &cam,
const std::array<float, 3> &f, const std::array<float, 3> &r, const std::array<float, 3> &u, float tan_half,
float aspect) -> void {
const auto rw = static_cast<int>(hi.width());
const auto rh = static_cast<int>(hi.height());
if (rw <= 0 || rh <= 0) return;
const auto dot3 = [](const std::array<float, 3> &a, const std::array<float, 3> &b) { return a[0] * b[0] + a[1] * b[1] + a[2] * b[2]; };
float sun[3] = {0.45F, 0.35F, 0.82F};
const auto sl = std::sqrt(sun[0] * sun[0] + sun[1] * sun[1] + sun[2] * sun[2]);
sun[0] /= sl;
sun[1] /= sl;
sun[2] /= sl;
constexpr float ambient = 0.38F;
constexpr float near_plane = 10.0F;
const auto sample = [&](uint32 layer, float tu, float tv) -> uint32 {
if (layer >= scene.textures.size() || scene.textures[layer].empty()) return argb(140, 140, 140);
const auto &img = scene.textures[layer];
const auto wrap = [](float x) { return x - std::floor(x); };
const auto sx = std::min(img.width() - 1U, static_cast<uint32>(wrap(tu) * static_cast<float>(img.width())));
const auto sy = std::min(img.height() - 1U, static_cast<uint32>(wrap(tv) * static_cast<float>(img.height())));
return img.data()[static_cast<usize>(sy) * img.width() + sx];
};
// Average colour per texture, used when a triangle covers fewer pixels
// than texels (minification) — a cheap mip-0-away fallback that keeps
// distant props from shimmering.
std::vector<uint32> average(scene.textures.size(), argb(140, 140, 140));
for (usize i = 0; i < scene.textures.size(); ++i) {
const auto &img = scene.textures[i];
if (img.empty()) continue;
usize r = 0;
usize g = 0;
usize b = 0;
for (usize p = 0; p < static_cast<usize>(img.width()) * img.height(); ++p) {
r += (img.data()[p] >> 16U) & 0xFFU;
g += (img.data()[p] >> 8U) & 0xFFU;
b += img.data()[p] & 0xFFU;
}
const auto n = static_cast<usize>(img.width()) * img.height();
average[i] = argb(static_cast<uint8>(r / n), static_cast<uint8>(g / n), static_cast<uint8>(b / n));
}
struct projected {
float sx = 0.0F;
float sy = 0.0F;
float inv_a = 0.0F; ///< 1 / view-space depth
};
const auto project = [&](const ra3::models::vertex &v, projected &out) -> bool {
const std::array<float, 3> rel{v.x - cam[0], v.y - cam[1], v.z - cam[2]};
const auto a = dot3(rel, f);
if (a <= near_plane) return false;
const auto ndc_x = (dot3(rel, r) / a) / (tan_half * aspect);
const auto ndc_y = (dot3(rel, u) / a) / tan_half;
out.sx = (ndc_x * 0.5F + 0.5F) * static_cast<float>(rw);
out.sy = (0.5F - ndc_y * 0.5F) * static_cast<float>(rh);
out.inv_a = 1.0F / a;
return true;
};
for (usize t = 0; t + 2U < scene.indices.size(); t += 3U) {
const auto &v0 = scene.vertices[scene.indices[t]];
const auto &v1 = scene.vertices[scene.indices[t + 1U]];
const auto &v2 = scene.vertices[scene.indices[t + 2U]];
projected p0, p1, p2;
if (!project(v0, p0) || !project(v1, p1) || !project(v2, p2)) continue;
const auto area = (p1.sx - p0.sx) * (p2.sy - p0.sy) - (p1.sy - p0.sy) * (p2.sx - p0.sx);
if (std::abs(area) < 1.0e-6F) continue;
const auto sign = area < 0.0F ? -1.0F : 1.0F;
// Texture footprint: if the triangle covers more texels than
// pixels it is minified, so fall back to the texture average.
const auto uv_area = std::abs((v1.u - v0.u) * (v2.v - v0.v) - (v2.u - v0.u) * (v1.v - v0.v));
const auto texel_footprint = uv_area * static_cast<float>(scene.texture_size) * static_cast<float>(scene.texture_size);
const auto minified = texel_footprint > 2.0F * std::abs(area);
const auto flat_layer = static_cast<uint32>(v0.layer + 0.5F);
const auto flat_color = flat_layer < average.size() ? average[flat_layer] : argb(140, 140, 140);
const auto min_x = std::max(0, static_cast<int>(std::floor(std::min({p0.sx, p1.sx, p2.sx}))));
const auto max_x = std::min(rw - 1, static_cast<int>(std::ceil(std::max({p0.sx, p1.sx, p2.sx}))));
const auto min_y = std::max(0, static_cast<int>(std::floor(std::min({p0.sy, p1.sy, p2.sy}))));
const auto max_y = std::min(rh - 1, static_cast<int>(std::ceil(std::max({p0.sy, p1.sy, p2.sy}))));
for (int y = min_y; y <= max_y; ++y) {
for (int x = min_x; x <= max_x; ++x) {
const auto px = static_cast<float>(x) + 0.5F;
const auto py = static_cast<float>(y) + 0.5F;
auto w0 = ((p1.sx - p0.sx) * (py - p0.sy) - (p1.sy - p0.sy) * (px - p0.sx)) * sign;
auto w1 = ((p2.sx - p1.sx) * (py - p1.sy) - (p2.sy - p1.sy) * (px - p1.sx)) * sign;
auto w2 = ((p0.sx - p2.sx) * (py - p2.sy) - (p0.sy - p2.sy) * (px - p2.sx)) * sign;
if (w0 < 0.0F || w1 < 0.0F || w2 < 0.0F) continue;
const auto sum = w0 + w1 + w2;
if (sum <= 0.0F) continue;
w0 /= sum;
w1 /= sum;
w2 /= sum;
// Perspective-correct depth and attributes.
const auto inv_a = w0 * p0.inv_a + w1 * p1.inv_a + w2 * p2.inv_a;
const auto depth = 1.0F / inv_a;
const auto pixel = static_cast<usize>(y) * static_cast<usize>(rw) + static_cast<usize>(x);
// Ground decals sit exactly on the terrain; a small bias
// (mirrors the GPU depth bias) keeps them from z-fighting.
if (depth - 0.5F >= zbuf[pixel]) continue;
const auto tu = (w0 * v0.u * p0.inv_a + w1 * v1.u * p1.inv_a + w2 * v2.u * p2.inv_a) * depth;
const auto tv = (w0 * v0.v * p0.inv_a + w1 * v1.v * p1.inv_a + w2 * v2.v * p2.inv_a) * depth;
float nx = w0 * v0.nx + w1 * v1.nx + w2 * v2.nx;
float ny = w0 * v0.ny + w1 * v1.ny + w2 * v2.ny;
float nz = w0 * v0.nz + w1 * v1.nz + w2 * v2.nz;
const auto nl = std::sqrt(nx * nx + ny * ny + nz * nz);
if (nl > 1.0e-6F) {
nx /= nl;
ny /= nl;
nz /= nl;
}
const auto lambert = std::max(0.0F, std::abs(nx * sun[0] + ny * sun[1] + nz * sun[2]));
const auto shade = ambient + (1.0F - ambient) * lambert;
const auto texel = minified ? flat_color : sample(flat_layer, tu, tv);
const auto mod = [&](uint32 shift) {
return static_cast<uint8>(std::clamp(static_cast<float>((texel >> shift) & 0xFFU) * shade, 0.0F, 255.0F));
};
hi.data()[pixel] = argb(mod(16U), mod(8U), mod(0U));
zbuf[pixel] = depth;
}
}
}
}
}
/**
@@ -851,7 +998,7 @@ export namespace ra3::terrain {
* a transformed 2D image.
*/
[[nodiscard]] inline auto render3d(const map_data &map, const texture_set &set, const camera3d &camera, uint32 out_w, uint32 out_h,
const render_options &options = {}) -> image {
const render_options &options = {}, const ra3::models::scene *objects = nullptr) -> image {
if (!map.valid) throw terrain_error("terrain not parsed");
out_w = std::max(1U, out_w);
out_h = std::max(1U, out_h);
@@ -968,6 +1115,7 @@ export namespace ra3::terrain {
};
image hi(rw, rh, argb(0, 0, 0));
std::vector<float> zbuf(static_cast<usize>(rw) * rh, 1.0e30F);
for (uint32 py = 0; py < rh; ++py) {
const auto ndc_y = 1.0F - 2.0F * (static_cast<float>(py) + 0.5F) / static_cast<float>(rh);
for (uint32 px = 0; px < rw; ++px) {
@@ -979,10 +1127,11 @@ export namespace ra3::terrain {
dx /= dlen;
dy /= dlen;
dz /= dlen;
const auto pixel = static_cast<usize>(py) * rw + px;
if (dz >= -1.0e-4F) {
const auto t = std::clamp(std::abs(dz) * 2.0F, 0.0F, 1.0F);
hi.data()[static_cast<usize>(py) * rw + px] =
hi.data()[pixel] =
argb(static_cast<uint8>(120.0F + 60.0F * t), static_cast<uint8>(150.0F + 45.0F * t), static_cast<uint8>(190.0F + 40.0F * t));
continue;
}
@@ -1017,7 +1166,7 @@ export namespace ra3::terrain {
t += dt;
}
if (!hit) {
hi.data()[static_cast<usize>(py) * rw + px] = argb(150, 170, 200);
hi.data()[pixel] = argb(150, 170, 200);
continue;
}
@@ -1035,10 +1184,16 @@ export namespace ra3::terrain {
lo = mid;
}
}
hi.data()[static_cast<usize>(py) * rw + px] = hit_color(cam_x + dx * up, cam_y + dy * up, cam_z + dz * up);
hi.data()[pixel] = hit_color(cam_x + dx * up, cam_y + dy * up, cam_z + dz * up);
// View-space depth of the hit, for the object pass below.
zbuf[pixel] = up * (dx * fx + dy * fy + dz * fz);
}
}
if (objects != nullptr && !objects->empty()) {
detail::rasterize_objects(hi, zbuf, *objects, {cam_x, cam_y, cam_z}, {fx, fy, fz}, {rx, ry, rz}, {ux, uy, uz}, tan_half, aspect);
}
if (ss == 1U) return hi;
image out(out_w, out_h, argb(0, 0, 0));
for (uint32 y = 0; y < out_h; ++y) {