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
+15 -2
View File
@@ -255,6 +255,15 @@ target_sources(ra3_terrain PUBLIC FILE_SET CXX_MODULES FILES src/terrain/ra3.ter
target_link_libraries(ra3_terrain PUBLIC ra3_core ra3_fs ra3_render) target_link_libraries(ra3_terrain PUBLIC ra3_core ra3_fs ra3_render)
openra3_target_defaults(ra3_terrain) openra3_target_defaults(ra3_terrain)
# --- map static art (compiled W3D meshes) ------------------------------------
add_library(ra3_models STATIC)
target_sources(ra3_models PUBLIC FILE_SET CXX_MODULES FILES src/models/ra3.models.cppm)
target_link_libraries(ra3_models PUBLIC ra3_core ra3_fs ra3_render)
openra3_target_defaults(ra3_models)
# A map's terrain carries the objects placed on it (props/buildings).
target_link_libraries(ra3_terrain PUBLIC ra3_models)
# The presentation facade imports render + terrain (for the terrain capability). # The presentation facade imports render + terrain (for the terrain capability).
target_link_libraries(ra3_client PUBLIC ra3_render ra3_terrain) target_link_libraries(ra3_client PUBLIC ra3_render ra3_terrain)
@@ -278,13 +287,17 @@ if(OPENRA3_HAS_VULKAN AND OPENRA3_HAS_SDL3)
"${CMAKE_CURRENT_SOURCE_DIR}/shaders/generated/scene.frag.spv" "${CMAKE_CURRENT_SOURCE_DIR}/shaders/generated/scene.frag.spv"
"${CMAKE_CURRENT_SOURCE_DIR}/shaders/generated/terrain.vert.spv" "${CMAKE_CURRENT_SOURCE_DIR}/shaders/generated/terrain.vert.spv"
"${CMAKE_CURRENT_SOURCE_DIR}/shaders/generated/terrain.frag.spv" "${CMAKE_CURRENT_SOURCE_DIR}/shaders/generated/terrain.frag.spv"
"${CMAKE_CURRENT_SOURCE_DIR}/shaders/generated/object.vert.spv"
"${CMAKE_CURRENT_SOURCE_DIR}/shaders/generated/object.frag.spv"
) )
# The blobs are read at configure time, so re-run CMake when they change. # The blobs are read at configure time, so re-run CMake when they change.
set_property(DIRECTORY APPEND PROPERTY CMAKE_CONFIGURE_DEPENDS set_property(DIRECTORY APPEND PROPERTY CMAKE_CONFIGURE_DEPENDS
"${CMAKE_CURRENT_SOURCE_DIR}/shaders/generated/scene.vert.spv" "${CMAKE_CURRENT_SOURCE_DIR}/shaders/generated/scene.vert.spv"
"${CMAKE_CURRENT_SOURCE_DIR}/shaders/generated/scene.frag.spv" "${CMAKE_CURRENT_SOURCE_DIR}/shaders/generated/scene.frag.spv"
"${CMAKE_CURRENT_SOURCE_DIR}/shaders/generated/terrain.vert.spv" "${CMAKE_CURRENT_SOURCE_DIR}/shaders/generated/terrain.vert.spv"
"${CMAKE_CURRENT_SOURCE_DIR}/shaders/generated/terrain.frag.spv") "${CMAKE_CURRENT_SOURCE_DIR}/shaders/generated/terrain.frag.spv"
"${CMAKE_CURRENT_SOURCE_DIR}/shaders/generated/object.vert.spv"
"${CMAKE_CURRENT_SOURCE_DIR}/shaders/generated/object.frag.spv")
target_sources(ra3_vulkan PUBLIC FILE_SET CXX_MODULES FILES src/vulkan/ra3.vulkan.sdl.cppm) target_sources(ra3_vulkan PUBLIC FILE_SET CXX_MODULES FILES src/vulkan/ra3.vulkan.sdl.cppm)
target_include_directories(ra3_vulkan PRIVATE "${CMAKE_CURRENT_BINARY_DIR}/generated") target_include_directories(ra3_vulkan PRIVATE "${CMAKE_CURRENT_BINARY_DIR}/generated")
target_link_libraries(ra3_vulkan PUBLIC ra3_core ra3_render ra3_terrain ra3_client openra3_sdl3 ra3_volk) target_link_libraries(ra3_vulkan PUBLIC ra3_core ra3_render ra3_terrain ra3_client openra3_sdl3 ra3_volk)
@@ -410,7 +423,7 @@ openra3_target_defaults(ra3_display)
# --- umbrella ----------------------------------------------------------------- # --- umbrella -----------------------------------------------------------------
add_library(ra3 STATIC) add_library(ra3 STATIC)
target_sources(ra3 PUBLIC FILE_SET CXX_MODULES FILES src/ra3.cppm) target_sources(ra3 PUBLIC FILE_SET CXX_MODULES FILES src/ra3.cppm)
target_link_libraries(ra3 PUBLIC ra3_core ra3_logic ra3_client ra3_data ra3_game ra3_fs ra3_map ra3_skirmish ra3_render ra3_terrain ra3_display target_link_libraries(ra3 PUBLIC ra3_core ra3_logic ra3_client ra3_data ra3_game ra3_fs ra3_map ra3_skirmish ra3_render ra3_terrain ra3_models ra3_display
ra3_ui ra3_vulkan ra3_dx ra3_wasmgl ra3_webgpu ra3_enderlog) ra3_ui ra3_vulkan ra3_dx ra3_wasmgl ra3_webgpu ra3_enderlog)
openra3_target_defaults(ra3) openra3_target_defaults(ra3)
+22 -5
View File
@@ -18,7 +18,7 @@ Ghidra.
## Status ## Status
OpenRA3 is at **v0.6.0**. The engine compiles and runs headless, and a **minimal OpenRA3 is at **v0.7.0**. The engine compiles and runs headless, and a **minimal
skirmish** is playable: it reads a real multiplayer map out of your install, skirmish** is playable: it reads a real multiplayer map out of your install,
recovers the player start waypoints, and simulates two sides building a base, recovers the player start waypoints, and simulates two sides building a base,
extracting ore and fighting until one side is wiped out. The balance is the extracting ore and fighting until one side is wiped out. The balance is the
@@ -31,7 +31,10 @@ software blit and null fallbacks; the backend is selectable from the in-game men
and the software renderer still produces headless images. Terrain tiles and the software renderer still produces headless images. Terrain tiles
cross-fade the way the retail `Terrain.fx` does (a per-cell blend ramp plus a cross-fade the way the retail `Terrain.fx` does (a per-cell blend ramp plus a
gutter-padded atlas), so material boundaries are smooth instead of a grid of gutter-padded atlas), so material boundaries are smooth instead of a grid of
hard lines. An in-window **menu** lists the maps by their localized name and hard lines; the **buildings and props a map places** are decoded from the retail
compiled `W3DMesh` art (the uncompressed `worldbuilder.bin` stream and its
embedded DDS textures) and drawn with a depth test over the terrain. An
in-window **menu** lists the maps by their localized name and
exposes every render/skirmish option for tweaking before launch. The whole tree exposes every render/skirmish option for tweaking before launch. The whole tree
builds for **Linux** (clang + libc++) and cross-compiles to **Windows** builds for **Linux** (clang + libc++) and cross-compiles to **Windows**
(`openra3.exe` + `SDL3.dll`) with llvm-mingw — both using C++26 modules and (`openra3.exe` + `SDL3.dll`) with llvm-mingw — both using C++26 modules and
@@ -93,6 +96,7 @@ built-in test map so the project still builds and runs in CI.
| `src/map/ra3.map.cppm` | map catalog, `EAR`/RefPack unwrap, start waypoints, `gamestrings.csf` display names | | `src/map/ra3.map.cppm` | map catalog, `EAR`/RefPack unwrap, start waypoints, `gamestrings.csf` display names |
| `src/skirmish/ra3.skirmish.cppm` | base building, economy, AI, combat, win condition | | `src/skirmish/ra3.skirmish.cppm` | base building, economy, AI, combat, win condition |
| `src/terrain/ra3.terrain.cppm` | `CkMp` terrain: `HeightMapData`, `BlendTileData` (tiles + blends), `Terrain.big` tiles | | `src/terrain/ra3.terrain.cppm` | `CkMp` terrain: `HeightMapData`, `BlendTileData` (tiles + blends), `Terrain.big` tiles |
| `src/models/ra3.models.cppm` | compiled `W3DMesh` art (BAB stream + DDS textures) for the objects a map places |
| `src/render/ra3.render.cppm` | ARGB framebuffer, TGA decode, BMP encode, map compositing, bitmap-font text | | `src/render/ra3.render.cppm` | ARGB framebuffer, TGA decode, BMP encode, map compositing, bitmap-font text |
| `src/ui/ra3.ui.*.cppm` | SDL3 window viewer and menu (null backend when SDL3 is absent) | | `src/ui/ra3.ui.*.cppm` | SDL3 window viewer and menu (null backend when SDL3 is absent) |
| `src/vulkan/ra3.vulkan.*.cppm` | Vulkan presentation backend and menu (null fallback without a loader) | | `src/vulkan/ra3.vulkan.*.cppm` | Vulkan presentation backend and menu (null fallback without a loader) |
@@ -290,9 +294,22 @@ textures from `Data\Terrain.big` (RefPack + TGA), and rasterises the map with an
elevation shade. Each cell cross-fades into its blend neighbour with the same elevation shade. Each cell cross-fades into its blend neighbour with the same
linear ramp the retail `Terrain.fx` uses, so material transitions are smooth; on linear ramp the retail `Terrain.fx` uses, so material transitions are smooth; on
the GPU path the tile atlas is padded with a replicated gutter so filtering the GPU path the tile atlas is padded with a replicated gutter so filtering
never bleeds between tiles. Match state is overlaid (start markers in yellow, never bleeds between tiles. It also draws the **buildings and props the map
player 0 in blue, player 1 in red). `--thumbnail` uses the old `<map>_art.tga` places**: the `ObjectsList` chunk is decoded into `(type, position, angle)`
overview instead. instances, the type resolves to its compiled `W3DMesh` parts in the map's art
stream (`Data\WBData.big`'s uncompressed `worldbuilder.bin`), the embedded DDS
textures are decoded, bind-pose skinning places each mesh's bone-space vertices
(`W3DHierarchy`), and the scene is flattened into one world-space triangle soup.
The terrain raymarcher writes a depth value so the model pass depth-tests
against the relief; the GPU path draws it in a second pipeline (shared camera
basis, with a small depth bias so ground decals do not z-fight) and the software
path rasterises it with a z-buffer. Match state is
overlaid (start markers in yellow, player 0 in blue, player 1 in red).
`--thumbnail` uses the old `<map>_art.tga` overview instead.
> **Not yet drawn:** the map's `Road` objects (the sidewalk/road segments that
> reference a `Road` template rather than a mesh) need the retail road-network
> mesher and are skipped; they are reported in the `objects: … missing` count.
**Offscreen image** (works anywhere, no display needed): **Offscreen image** (works anywhere, no display needed):
+76 -2
View File
@@ -200,6 +200,79 @@ namespace {
return std::nullopt; return std::nullopt;
} }
/** A map's compiled art stream (`map.manifest` + `map.bin`). */
struct model_stream_paths {
std::filesystem::path manifest;
std::filesystem::path bin;
};
/**
* Locate the compiled art stream that holds a map's buildings/props.
*
* Retail bakes the shared static art (the props a map places) into
* `Data\WBData.big`'s uncompressed `worldbuilder.bin`, so that stream is
* preferred; the per-map `map.bin` (which links to it) is the fallback.
*/
auto find_map_stream(const std::filesystem::path &root, std::string_view id) -> std::optional<model_stream_paths> {
std::error_code ec;
const auto consider = [&](const std::filesystem::path &dir) -> std::optional<model_stream_paths> {
const auto manifest = dir / "map.manifest";
const auto bin = dir / "map.bin";
if (std::filesystem::exists(manifest, ec) && std::filesystem::exists(bin, ec)) return model_stream_paths{manifest, bin};
return std::nullopt;
};
// A `worldbuilder` stream (the full prop art) takes precedence.
for (const auto &dir: {root / "models", root / "raw" / "WBData" / "data", root}) {
const auto manifest = dir / "worldbuilder.manifest";
const auto bin = dir / "worldbuilder.bin";
if (std::filesystem::exists(manifest, ec) && std::filesystem::exists(bin, ec)) return model_stream_paths{manifest, bin};
}
for (const auto &entry: std::filesystem::recursive_directory_iterator(root, ec)) {
if (entry.is_regular_file() && entry.path().filename() == "worldbuilder.bin") {
const auto manifest = entry.path().parent_path() / "worldbuilder.manifest";
if (std::filesystem::exists(manifest, ec)) return model_stream_paths{manifest, entry.path()};
}
}
if (auto found = consider(root / "models" / id); found) return found;
if (auto found = consider(root / "maps" / id); found) return found;
for (const auto &entry: std::filesystem::recursive_directory_iterator(root, ec)) {
if (!entry.is_directory() || entry.path().filename().string() != id) continue;
if (auto found = consider(entry.path()); found) return found;
}
return std::nullopt;
}
/** Build the map's building/prop scene from its compiled art stream. */
auto build_object_scene(const std::filesystem::path &assets, std::string_view id, const ra3::terrain::map_data &terrain,
const ra3::terrain::render_options &options, std::span<const ra3::core::uint8> ckmp) -> ra3::models::scene {
ra3::models::scene scene;
try {
const auto paths = find_map_stream(assets, id);
if (!paths) {
std::puts("objects: no compiled art stream found");
return scene;
}
const auto stream = ra3::models::asset_stream::load_files(paths->manifest, paths->bin);
std::vector<ra3::models::placement> placements;
for (const auto &object: ra3::map::parse_objects(ckmp)) {
placements.push_back({object.type, object.x, object.y, object.z, object.angle, object.scale});
}
const auto world_w = terrain.world_width();
const auto world_h = terrain.world_height();
scene = ra3::models::build_scene(stream, placements, [&](float x, float y) -> float {
if (x < 0.0F || y < 0.0F || x >= world_w || y >= world_h) return 0.0F;
const auto cx = std::min(terrain.width - 1U, static_cast<ra3::core::uint32>(x / ra3::terrain::cell_size));
const auto cy = std::min(terrain.height - 1U, static_cast<ra3::core::uint32>((world_h - y) / ra3::terrain::cell_size));
return static_cast<float>(terrain.elevation(cx, cy)) * options.z_scale;
});
std::printf("objects: %zu placed, %zu missing, %zu triangles, %zu textures (%s)\n", scene.placed, scene.missing, scene.triangle_count(),
scene.textures.size(), paths->bin.filename().string().c_str());
} catch (const std::exception &error) {
std::printf("objects: failed to build scene (%s)\n", error.what());
}
return scene;
}
/** Write `<assets>/maps/map_names.tsv` from the install's localized string table. */ /** Write `<assets>/maps/map_names.tsv` from the install's localized string table. */
auto write_map_names(const std::filesystem::path &data_dir, const std::filesystem::path &assets) -> bool { auto write_map_names(const std::filesystem::path &data_dir, const std::filesystem::path &assets) -> bool {
try { try {
@@ -465,8 +538,9 @@ namespace {
const bool offscreen = option_value(args, "--out").has_value() || has_flag(args, "--no-window"); const bool offscreen = option_value(args, "--out").has_value() || has_flag(args, "--no-window");
bool gpu_shown = false; bool gpu_shown = false;
const auto objects = build_object_scene(assets, picked->id, terrain, terrain_options, bytes);
if (!offscreen) { if (!offscreen) {
const auto gpu = terrain::build_gpu_terrain(terrain, textures, terrain_options); const auto gpu = terrain::build_gpu_terrain(terrain, textures, terrain_options, objects);
ra3::client::display_options options; ra3::client::display_options options;
options.title = scene.title; options.title = scene.title;
options.width = static_cast<int>(width); options.width = static_cast<int>(width);
@@ -486,7 +560,7 @@ namespace {
} }
if (offscreen) { if (offscreen) {
// Offscreen: the software raymarcher produces the 3D image. // Offscreen: the software raymarcher produces the 3D image.
composed = terrain::render3d(terrain, textures, camera, width, height, terrain_options); composed = terrain::render3d(terrain, textures, camera, width, height, terrain_options, &objects);
perspective = true; perspective = true;
} else { } else {
// Windowed without a GPU: the cheap top-down raster. // Windowed without a GPU: the cheap top-down raster.
+13 -9
View File
@@ -79,7 +79,7 @@ umbrella module re-exports the SDK; applications import `ra3` only.
``` ```
The current concrete modules (`ra3.core`, `ra3.logic`, `ra3.data`, The current concrete modules (`ra3.core`, `ra3.logic`, `ra3.data`,
`ra3.skirmish`, `ra3.fs`, `ra3.map`, `ra3.terrain`, `ra3.render`, `ra3.ui.*`, `ra3.skirmish`, `ra3.fs`, `ra3.map`, `ra3.terrain`, `ra3.models`, `ra3.render`, `ra3.ui.*`,
`ra3.vulkan.*`, `ra3.dx.*`, `ra3.webgpu.*`, `ra3.wasmgl.*`, `ra3.display`, `ra3.game`, `ra3.client`, and the `ra3.vulkan.*`, `ra3.dx.*`, `ra3.webgpu.*`, `ra3.wasmgl.*`, `ra3.display`, `ra3.game`, `ra3.client`, and the
vendored `ender.log`) are the **seeds** of the vendored `ender.log`) are the **seeds** of the
target modules below. `ra3.skirmish` and `ra3.game` will be absorbed into target modules below. `ra3.skirmish` and `ra3.game` will be absorbed into
@@ -206,10 +206,11 @@ Mirrors SAGE `GameEngine/Common`. Everything else speaks this.
- `[ ]` DDS / DXT compressed textures `(v0.5)` - `[ ]` DDS / DXT compressed textures `(v0.5)`
- `[ ]` atlas + mip generation, gutter padding (GPU) `[~]` - `[ ]` atlas + mip generation, gutter padding (GPU) `[~]`
- `[ ]` async upload / streaming `(v0.6)` - `[ ]` async upload / streaming `(v0.6)`
- **F2 Models** `[ ]` `!!` - **F2 Models** `[~]`
- `[ ]` W3D container parse (chunks, hierarchy, meshes) `(v0.6)` - `[x]` compiled `W3DMesh` decode (vertex buffer + D3D9 declaration + triangle list) from the BAB static/worldbuilder stream
- `[ ]` materials, shaders, texture references - `[x]` embedded DDS textures (DXT1/3/5 + uncompressed RGB) → ARGB
- `[ ]` LOD sets, collision meshes - `[x]` `W3DHierarchy` decode + static bind-pose skinning (bone-space vertices)
- `[ ]` W3D container/hierarchy animation, LOD sets, collision meshes `!!` `(v0.6)`
- **F3 Animation** `[ ]` `!!` - **F3 Animation** `[ ]` `!!`
- `[ ]` W3D animation chunks, bone poses `(v0.6)` - `[ ]` W3D animation chunks, bone poses `(v0.6)`
- `[ ]` blend trees / transition animations - `[ ]` blend trees / transition animations
@@ -533,8 +534,10 @@ Mirrors SAGE `GameEngine/Common`. Everything else speaks this.
- **F3 Terrain render** `[~]` - **F3 Terrain render** `[~]`
- `[x]` top-down software + GPU heightfield with blend ramp + gutter atlas - `[x]` top-down software + GPU heightfield with blend ramp + gutter atlas
- `[ ]` perspective terrain mesh, LOD, cliff, water `(v0.6)` - `[ ]` perspective terrain mesh, LOD, cliff, water `(v0.6)`
- **F4 Model render** `[ ]` `!!` - **F4 Model render** `[~]`
- `[ ]` W3D draw, skinning, materials, team colors `(v0.6)` - `[x]` static map-object triangle soup, world-space, depth-tested over the terrain (Vulkan + software)
- `[x]` bind-pose skinning (bone-space vertices) + ground-decal depth bias
- `[ ]` animated W3D draw, materials, team colors `!!` `(v0.6)`
- `[ ]` shadows, decals, ground marks `(v0.6)` - `[ ]` shadows, decals, ground marks `(v0.6)`
- **F5 VFX** `[ ]` - **F5 VFX** `[ ]`
- `[ ]` particle systems, beams, muzzle flashes, explosions `(v0.6)` - `[ ]` particle systems, beams, muzzle flashes, explosions `(v0.6)`
@@ -575,8 +578,9 @@ Mirrors SAGE `GameEngine/Common`. Everything else speaks this.
- `[ ]` formal RHI integration (see M16 F1) `(v0.6)` - `[ ]` formal RHI integration (see M16 F1) `(v0.6)`
- **F2 Terrain presentation** `[D]` - **F2 Terrain presentation** `[D]`
- `[x]` GPU heightfield raymarch, mipmapped atlas, gutter, retail blend ramp - `[x]` GPU heightfield raymarch, mipmapped atlas, gutter, retail blend ramp
- **F3 Materials & pipelines** `[ ]` - **F3 Materials & pipelines** `[~]`
- `[ ]` model/particle/HUD pipelines `(v0.6)` - `[x]` static-model pipeline (vertex/index buffers, texture array, depth test)
- `[ ]` particle/HUD pipelines `(v0.6)`
- **F4 Null fallback** `[D]` - **F4 Null fallback** `[D]`
- `[x]` report failure when no Vulkan loader is present - `[x]` report failure when no Vulkan loader is present
+123
View File
@@ -212,6 +212,125 @@ per-cell restart is the remaining source of grid lines. Switching the atlas
from 64 px blocks to continuous 32 px regions (or the Morton 8x8 layout) is the from 64 px blocks to continuous 32 px regions (or the Morton 8x8 layout) is the
next step, pending an art correlation check. next step, pending an art correlation check.
### Compiled art (BinaryAssetBuilder) and map objects
Retail RA3 ships no `.w3x`/`.w3d` files: BinaryAssetBuilder bakes every model,
texture and script into a *binary asset stream* — a `.manifest` index plus a
`.bin` of relocatable instance data (and optional `.relo`/`.imp` fixups). The
layout (little-endian) is:
```
ManifestHeader (48 B) isBigEndian u8, isLinked u8, version u16,
streamChecksum, allTypesHash, assetCount u32,
totalInstanceDataSize, maxInstance/maxRelocation/
maxImportsChunkSize, assetReferenceBufferSize,
referenceManifestNameBufferSize, assetNameBufferSize,
sourceFileNameBufferSize
AssetEntry (48 B) * count
typeId, instanceId, typeHash, instanceHash,
assetReferenceOffset i32, assetReferenceCount i32,
nameOffset i32, sourceFileNameOffset i32,
instanceDataSize i32, relocationDataSize i32,
importsDataSize i32, tokenized u32
then the reference / referenced-name / asset-name / source-name buffers
```
Asset names are `Type:Instance` (e.g. `W3DMesh:BB_GRASS02`). Instance pointers
are stored as offsets from the start of the instance data (which begins at byte
4 of `.bin`, after the stream checksum), so a slice is readable without the
relocation stream.
Each multiplayer map carries its own stream (`data\maps\official\<id>\map.bin`)
but it is **linked**: only map-specific assets (the terrain texture atlas,
scripts, `GameMap`) have data; the rendered props are imported and therefore
have `instanceDataSize == 0`. The complete prop art (meshes + textures) lives in
`Data\WBData.big`'s `data\worldbuilder.bin`, which is **uncompressed** (first
four bytes are the stream checksum, not `10 FB`), so `ra3.models` reads the
1.1 GB stream lazily — the manifest is parsed and only the needed instance
slices are read.
`W3DMesh` compiled layout (offsets from the instance start):
```
+4 vertexBufferPtr +52 triangleCount +56 triangleItemPtr
+60 shaderNameLength +64 shaderNamePtr
vertexBuffer: +0 numVertices, +4 stride, +8 elementDataPtr,
+12 declarationBytes, +16 declarationPtr
declaration: text "p0:00:3f32 n0:0C:3f32 t0:1C:2f32" (D3D9 usage:index:offset:type)
triangles: triangleCount * { u32 indexCount, u32 indexPtr } (24 B each), u32 indices
```
The diffuse texture is found through the mesh's `FXShaderConstant`s
(`+76` count, `+80` items): a texture-valued constant (TypeId `0xA59096A6`)
names its role (`DiffuseTexture`, `NormalMap`, `SpecMap`) and points at a
1-based index into the mesh's cross-asset references, which resolve by
`(typeId, instanceId)`. The `Texture` instance embeds a standard DDS file (at
`u32@+4`, or scan for `"DDS "`); `ra3.models::decode_dds` decodes DXT1/3/5 and
uncompressed 16/24/32-bit.
**Vertices are stored in bone space, not object space.** A mesh whose vertex
declaration carries blend data (`i0:..:4u8 w0:..:4u8n`, e.g. buildings and
vehicles) must be skinned; props without it (`BB_GRASS02`, `IF_STREETSEGMENT01`)
are already in object space. The skeleton is a `W3DHierarchy` asset named after
the mesh's instance prefix (`W3DMesh:FI_STRUCTURE_02.NEWSKIN_CIV01` →
`W3DHierarchy:FI_STRUCTURE_02`). Compiled layout:
```
W3DHierarchy: u32 pad, u32 boneCount, u32 headerBytes, then boneCount records
100 B each:
u32 nameHash, i32 parent (-1 = root), f32 translation[3],
f32 quaternion[4] (x, y, z, w), f32 matrix[12]
```
The default (bind) pose is rebuilt by composing each bone's local
translation/quaternion down the parent chain, then `skinnedPos = Σ wᵢ ·
(Rᵢ·p + Tᵢ)` (and the normal by the rotation only). `ra3.models` does this before
placing the mesh at the map object's `(x, y, angle)`.
The compiled shader (below) binds **one joint per vertex** — `WorldBones` holds
64 bones as 2 `float4` each (quaternion `c[128+2j]`, translation `c[129+2j]`) —
so the skin is rigid: `blendindices.x` selects the joint, remapped through the
mesh's per-model **bone table** (vertex-descriptor `+0x14` = bone count,
`+0x18` = `u16` bone indices into the `W3DHierarchy`). Applying the raw blend
index without that remap tears models apart (`FI_BUILDING01`'s table is
`[0,14,15,16,17,18]`, not `[0..5]`).
The map objects that lie flat on the ground (sidewalks, deck pieces) are
coplanar with the terrain; retail biases their depth in the shader so they do
not z-fight. The object pass reproduces that with a small negative depth bias
(Vulkan `depthBiasConstant/SlopeFactor`, and a half-unit bias in the software
rasteriser).
Meshes whose material has no diffuse texture (`DefaultW3D.fx`, `BasicW3D.fx` —
`FXLIGHTS`/ambient helper billboards) are not opaque geometry and are skipped;
drawing them fills the frame with garbage triangles. Likewise the
`BuildingsGenericDamageFill.fx` **damage-fill** sub-meshes are skipped: they are
the wrecked-interior shell (e.g. `CBBuilding_Wood`, an orange plank texture) that
retail only reveals through damage holes, but our opaque pass would paint it over
the main shell and tint whole buildings warm.
### Official shader behaviour (`Shaders.big` → `*.fxo`)
The compiled D3D9 effects in `Data\Shaders.big` name their parameters, so the
model pipeline is recoverable. `buildingsgeneric.fxo` (`BuildingsGeneric.fx`)
vertex stage: skinning (above), `World`/`ViewProjection`, and vertex color
`c0` multiplied into the lit color
(`(Ambient·AmbientColor + Σ DLᵢ.Color·max(N·DLᵢ,0)) · DiffuseColor · vertexColor`).
Pixel stage samples `DiffuseTexture`/`NormalMap`/`SpecMap`/`DamagedTexture`/
`CloudTexture` (all at **UV0**, except `DamagedTexture` at `v0.wz` = transposed
UV1), then `final.rgb *= TintColor` and `*= ShroudTexture.rgb`. `DiffuseVelocity`
is not used for static structures. So the diffuse texture is UV0 and is tinted by
vertex color and `TintColor`; `basicw3d.fxo` instead modulates a single
macro/lightmap with `(vertexColor + additive) * diffuse * 2` and has no normal
map. (Recovered by disassembling the embedded `vs_3_0`/`ps_3_0` bytecode.)
The map's `ObjectsList` chunk is a list of nested `Object` assets —
`Coord3D`, Z `angle`, `RoadType` u32, a `u16`-prefixed type-name and a property
list whose keys index the shared name table (`ra3.map::parse_objects`). Each
type resolves to the `W3DMesh` parts whose instance name equals it or starts
with `<type>.`. Objects whose type is a `Road` template (the sidewalk/road
segments) are not meshes and are not drawn yet.
### Map display names ### Map display names
The skirmish map list labels live in `Data\English.big`'s The skirmish map list labels live in `Data\English.big`'s
@@ -227,5 +346,9 @@ values are UTF-16 code units whose low byte is XORed with `0xFF`
- `MPPositionList` layout (per-player starts for maps without waypoints). - `MPPositionList` layout (per-player starts for maps without waypoints).
- Cliff textures and the `CliffTextureMapping` UV remap (`CliffTextures` is - Cliff textures and the `CliffTextureMapping` UV remap (`CliffTextures` is
parsed but not yet drawn). parsed but not yet drawn).
- The `Road` network mesher (the map's sidewalk/road objects reference `Road`
templates, not `W3DMesh` assets).
- W3D container/hierarchy assembly and animation (props are drawn as their
static mesh parts; skinned/animated in-game models are not).
- Compiled asset blobs (`global.bin`, `static.*.bin`) and the `.manifest` - Compiled asset blobs (`global.bin`, `static.*.bin`) and the `.manifest`
schema used to deserialise them. schema used to deserialise them.
+2 -2
View File
@@ -25,9 +25,9 @@ compile() {
fi fi
} }
for name in scene terrain; do for name in scene terrain object; do
compile "$DIR/$name.vert" "$OUT/$name.vert.spv" compile "$DIR/$name.vert" "$OUT/$name.vert.spv"
compile "$DIR/$name.frag" "$OUT/$name.frag.spv" compile "$DIR/$name.frag" "$OUT/$name.frag.spv"
done done
echo "wrote $OUT/{scene,terrain}.{vert,frag}.spv" echo "wrote $OUT/{scene,terrain,object}.{vert,frag}.spv"
Binary file not shown.
Binary file not shown.
Binary file not shown.
+36
View File
@@ -0,0 +1,36 @@
#version 450
// Static-map model fragment stage: sample the shared texture array and apply
// the same directional sun the terrain uses. Cut-out props (trees, fences)
// carry an alpha mask in their diffuse texture; discard the transparent texels
// so the ground shows through.
layout(binding = 0) uniform sampler2DArray atlas;
layout(push_constant) uniform Push {
vec4 cam_pos;
vec4 fwd;
vec4 right;
vec4 up;
vec4 sun;
} pc;
layout(location = 0) in vec2 in_uv;
layout(location = 1) in vec3 in_normal;
layout(location = 2) flat in float in_layer;
layout(location = 0) out vec4 out_color;
void main() {
int layers = textureSize(atlas, 0).z;
int layer = clamp(int(in_layer + 0.5), 0, layers - 1);
vec4 tex = texture(atlas, vec3(in_uv, float(layer)));
if (tex.a < 0.5) discard;
vec3 n = normalize(in_normal);
if (!gl_FrontFacing) n = -n;
float lambert = max(0.0, dot(n, normalize(pc.sun.xyz)));
float ambient = pc.sun.w;
vec3 lit = tex.rgb * (ambient + (1.0 - ambient) * lambert);
out_color = vec4(lit, 1.0);
}
+44
View File
@@ -0,0 +1,44 @@
#version 450
// Static-map model vertex stage. Draws the world-space triangle soup built by
// ra3::models (buildings and props the map places) against the exact camera the
// terrain raymarcher uses, so both passes share one projection and depth test.
//
// The camera is passed as its orthonormal basis so the same math as
// terrain.frag applies: ndc.x = dot(r, right) / (a * tan(fov/2) * aspect),
// ndc.y = -dot(r, up) / (a * tan(fov/2)), depth = (a - NEAR) / (FAR - NEAR)
// with `a = dot(r, fwd)` the view-space depth.
layout(location = 0) in vec3 in_pos;
layout(location = 1) in vec3 in_normal;
layout(location = 2) in vec2 in_uv;
layout(location = 3) in float in_layer;
layout(push_constant) uniform Push {
vec4 cam_pos; // xyz = eye position
vec4 fwd; // xyz = forward
vec4 right; // xyz = right, w = tan(fov / 2)
vec4 up; // xyz = up, w = tan(fov / 2) * aspect
vec4 sun; // xyz = sun direction, w = ambient
} pc;
layout(location = 0) out vec2 out_uv;
layout(location = 1) out vec3 out_normal;
layout(location = 2) flat out float out_layer;
const float NEAR = 10.0;
const float FAR = 60000.0;
void main() {
vec3 r = in_pos - pc.cam_pos.xyz;
float a = dot(r, pc.fwd.xyz);
float b = dot(r, pc.right.xyz);
float c = dot(r, pc.up.xyz);
float th = pc.right.w;
float th_aspect = pc.up.w;
float depth = clamp((a - NEAR) / (FAR - NEAR), 0.0, 1.0);
gl_Position = vec4(b / th_aspect, -c / th, depth * a, a);
out_uv = in_uv;
out_normal = in_normal;
out_layer = in_layer;
}
+11 -1
View File
@@ -25,6 +25,11 @@ layout(location = 0) out vec4 out_color;
const float CELL = 10.0; // must match ra3::terrain::cell_size const float CELL = 10.0; // must match ra3::terrain::cell_size
// Must match object.vert: the shared projection writes the view-space depth
// into gl_FragDepth so the static-map models depth-test against the terrain.
const float NEAR = 10.0;
const float FAR = 60000.0;
float height_at(ivec2 c) { float height_at(ivec2 c) {
c = clamp(c, ivec2(0), ivec2(pc.mapinfo.xy) - 1); c = clamp(c, ivec2(0), ivec2(pc.mapinfo.xy) - 1);
return texelFetch(heightmap, c, 0).r * 65535.0 * pc.mapinfo.w; return texelFetch(heightmap, c, 0).r * 65535.0 * pc.mapinfo.w;
@@ -112,6 +117,7 @@ void main() {
vec3 dir = normalize(fwd + right * ndc.x * th * aspect + up * ndc.y * th); vec3 dir = normalize(fwd + right * ndc.x * th * aspect + up * ndc.y * th);
if (dir.z >= -1e-4) { if (dir.z >= -1e-4) {
gl_FragDepth = 1.0;
out_color = vec4(sky_color(dir), 1.0); out_color = vec4(sky_color(dir), 1.0);
return; return;
} }
@@ -133,7 +139,7 @@ void main() {
if (w.z <= world_height(w.x, w.y)) { hit = true; hit_t = t; break; } if (w.z <= world_height(w.x, w.y)) { hit = true; hit_t = t; break; }
prev = t; dt *= 1.10; t += dt; prev = t; dt *= 1.10; t += dt;
} }
if (!hit) { out_color = vec4(sky_color(dir), 1.0); return; } if (!hit) { gl_FragDepth = 1.0; out_color = vec4(sky_color(dir), 1.0); return; }
float lo = prev, hi = hit_t; float lo = prev, hi = hit_t;
for (int i = 0; i < 6; ++i) { for (int i = 0; i < 6; ++i) {
@@ -144,6 +150,10 @@ void main() {
} }
vec3 hitpos = cam + dir * hi; vec3 hitpos = cam + dir * hi;
// View-space depth of the hit (project onto the forward axis), matching the
// projection object.vert applies to the model vertices.
gl_FragDepth = clamp((dot(hitpos - cam, fwd) - NEAR) / (FAR - NEAR), 0.0, 1.0);
vec3 sun = normalize(pc.sun.xyz); vec3 sun = normalize(pc.sun.xyz);
float ambient = pc.sun.w; float ambient = pc.sun.w;
+133 -1
View File
@@ -19,8 +19,9 @@ export import ra3.fs;
export namespace ra3::map { export namespace ra3::map {
using ra3::core::coord3d; using ra3::core::coord3d;
using ra3::core::real; using ra3::core::real;
using ra3::core::uint32;
using ra3::core::uint8; using ra3::core::uint8;
using ra3::core::uint16;
using ra3::core::uint32;
using ra3::core::usize; using ra3::core::usize;
/** A player start location extracted from a map's waypoints. */ /** A player start location extracted from a map's waypoints. */
@@ -202,6 +203,137 @@ export namespace ra3::map {
return starts; return starts;
} }
/**
* One object the map places on the ground: a building, a prop, a lamp, a
* tree — anything in the `ObjectsList` chunk.
*
* `type` is the SAGE `ThingTemplate` name (e.g. `BB_GRASS02`,
* `AlliedBarracks`); the per-map compiled art stream resolves it to the
* `W3DMesh` assets that draw it. `angle` is the Z rotation in radians.
*/
struct map_object {
std::string type;
real x = 0.0F;
real y = 0.0F;
real z = 0.0F;
real angle = 0.0F;
real scale = 1.0F;
};
namespace detail {
struct ckmp_chunk {
std::string name;
uint16 version = 0;
usize offset = 0;
usize size = 0;
};
/** Parse the `CkMp` chunk tree and its `index -> name` table. */
[[nodiscard]] inline auto parse_ckmp_chunks(std::span<const uint8> data) -> std::pair<std::vector<std::string>, std::vector<ckmp_chunk>> {
if (data.size() < 8U || std::memcmp(data.data(), "CkMp", 4) != 0) return {};
usize pos = 4;
const auto read_u32 = [&](usize at) {
return static_cast<uint32>(data[at]) | (static_cast<uint32>(data[at + 1U]) << 8U) | (static_cast<uint32>(data[at + 2U]) << 16U) |
(static_cast<uint32>(data[at + 3U]) << 24U);
};
const auto read_u16 = [&](usize at) { return static_cast<uint16>(static_cast<uint32>(data[at]) | (static_cast<uint32>(data[at + 1U]) << 8U)); };
const auto count = read_u32(pos);
pos += 4;
std::vector<std::string> names(count + 1U);
for (uint32 i = count; i >= 1U && pos < data.size(); --i) {
const auto len = data[pos++];
if (pos + len + 4U > data.size()) break;
names[i] = std::string{reinterpret_cast<const char *>(data.data() + pos), len};
pos += len + 4U;
}
std::vector<ckmp_chunk> chunks;
while (pos + 10U <= data.size()) {
const auto index = read_u32(pos);
const auto version = read_u16(pos + 4U);
const auto size = read_u32(pos + 6U);
pos += 10U;
if (index >= names.size() || pos + size > data.size()) break;
chunks.push_back({names[index], version, pos, size});
pos += size;
}
return {std::move(names), std::move(chunks)};
}
}
/**
* Every object the map places (the `ObjectsList` chunk).
*
* Layout (OpenSAGE `Data/Map/{ObjectsList,MapObject,AssetProperty}.cs`):
* the chunk is a list of nested `Object` assets, each a `Coord3D`, a Z
* `angle`, a `RoadType`, a `u16`-prefixed type-name and a property list
* whose keys index the shared name table.
*
* @return The objects in chunk order; empty when the map has no object list.
*/
[[nodiscard]] inline auto parse_objects(std::span<const uint8> ckmp) -> std::vector<map_object> {
const auto [names, chunks] = detail::parse_ckmp_chunks(ckmp);
(void) names;
std::vector<map_object> objects;
const auto read_u16 = [&](usize at) { return static_cast<uint16>(static_cast<uint32>(ckmp[at]) | (static_cast<uint32>(ckmp[at + 1U]) << 8U)); };
const auto read_u32 = [&](usize at) {
return static_cast<uint32>(ckmp[at]) | (static_cast<uint32>(ckmp[at + 1U]) << 8U) | (static_cast<uint32>(ckmp[at + 2U]) << 16U) |
(static_cast<uint32>(ckmp[at + 3U]) << 24U);
};
const auto read_f32 = [&](usize at) {
const auto bits = read_u32(at);
real value = 0.0F;
std::memcpy(&value, &bits, sizeof(value));
return value;
};
for (const auto &chunk: chunks) {
if (chunk.name != "ObjectsList") continue;
usize p = chunk.offset;
const auto end = chunk.offset + chunk.size;
while (p + 6U <= end) {
p += 4U; // asset index (always `Object`)
p += 2U; // asset version
const auto asset_size = read_u32(p);
p += 4U;
if (asset_size < 24U || p + asset_size > end) break;
const auto asset_end = p + asset_size;
map_object object;
object.x = read_f32(p);
object.y = read_f32(p + 4U);
object.z = read_f32(p + 8U);
object.angle = read_f32(p + 12U);
p += 20U; // Coord3D + angle + road type
const auto name_len = read_u16(p);
p += 2U;
if (p + name_len > asset_end) break;
object.type = std::string{reinterpret_cast<const char *>(ckmp.data() + p), name_len};
p += name_len;
const auto property_count = static_cast<uint32>(read_u16(p));
p += 2U;
for (uint32 i = 0; i < property_count && p + 4U <= asset_end; ++i) {
const auto type = ckmp[p++];
p += 3U; // property name index (u24) into the shared name table
usize value_size = 0;
if (type == 0U) {
value_size = 1U; // boolean
} else if (type == 1U || type == 2U) {
value_size = 4U; // integer / real
} else if (p + 2U <= asset_end) {
const auto len = read_u16(p);
value_size = 2U + static_cast<usize>(len) * ((type == 4U) ? 2U : 1U); // ascii/unicode/unknown
}
p += value_size;
if (p > asset_end) break;
}
objects.push_back(std::move(object));
p = asset_end;
}
}
return objects;
}
/** Map id -> localized display name, keyed by lowercased id. */ /** Map id -> localized display name, keyed by lowercased id. */
struct map_name_table { struct map_name_table {
std::unordered_map<std::string, std::string> names; std::unordered_map<std::string, std::string> names;
+939
View File
@@ -0,0 +1,939 @@
export module ra3.models;
import std;
export import ra3.core;
export import ra3.render;
export import ra3.fs;
/**
* The map's static art: the compiled W3D models that draw the buildings and
* props a map places on the ground.
*
* Retail RA3 does not ship `.w3x`/`.w3d` files. BinaryAssetBuilder bakes every
* model into a *binary asset stream* — a `.manifest` index plus a `.bin` of
* relocatable instance data — and each map carries its own stream in
* `Maps*.big` under `data\maps\official\<id>\map.{manifest,bin}`. Those streams
* hold the `W3DMesh` assets for the map's props (sidewalks, deck props, lights,
* walls, civilian buildings, ...) and the `Texture` assets they sample.
*
* This module parses that stream, decodes the compiled `W3DMesh` vertex/index
* buffers and the embedded DDS textures, and flattens the map's objects into a
* single world-space triangle soup the renderers upload directly.
*
* Format references: ra3-headless `ra3tools/ra3_binary.py` (BAB
* `ManifestHeader`/`AssetEntry`, `SageBinaryData/W3D.cs`) and OpenSAGE
* `Data/Map/MapObject.cs`. Instance pointers are stored as offsets from the
* start of the instance data, so no relocation pass is needed.
*/
export namespace ra3::models {
using ra3::core::uint8;
using ra3::core::uint16;
using ra3::core::uint32;
using ra3::core::int32;
using ra3::core::usize;
using ra3::render::argb;
using ra3::render::image;
/** Thrown when a compiled asset stream or a model payload is malformed. */
class model_error : public std::runtime_error {
public:
using std::runtime_error::runtime_error;
};
namespace detail {
[[nodiscard]] inline auto u16(std::span<const uint8> b, usize off) -> uint16 {
return off + 2U <= b.size() ? static_cast<uint16>(b[off]) | (static_cast<uint16>(b[off + 1U]) << 8U) : 0U;
}
[[nodiscard]] inline auto u32(std::span<const uint8> b, usize off) -> uint32 {
return off + 4U <= b.size() ? static_cast<uint32>(b[off]) | (static_cast<uint32>(b[off + 1U]) << 8U) | (static_cast<uint32>(b[off + 2U]) << 16U) |
(static_cast<uint32>(b[off + 3U]) << 24U)
: 0U;
}
[[nodiscard]] inline auto i32(std::span<const uint8> b, usize off) -> ra3::core::int32 { return static_cast<ra3::core::int32>(u32(b, off)); }
[[nodiscard]] inline auto f32(std::span<const uint8> b, usize off) -> float {
const auto bits = u32(b, off);
float value = 0.0F;
std::memcpy(&value, &bits, sizeof(value));
return value;
}
[[nodiscard]] inline auto cstr(std::span<const uint8> b, usize off, ra3::core::int32 length) -> std::string {
if (off == 0U || length <= 0 || off + static_cast<usize>(length) > b.size()) return {};
return std::string{reinterpret_cast<const char *>(b.data() + off), static_cast<usize>(length)};
}
/** NUL-terminated string in the manifest name/source buffers. */
[[nodiscard]] inline auto nul_string(std::span<const uint8> b, usize off) -> std::string {
if (off >= b.size()) return {};
usize end = off;
while (end < b.size() && b[end] != 0U) ++end;
return std::string{reinterpret_cast<const char *>(b.data() + off), end - off};
}
}
/**
* A parsed `BinaryAsset` stream: the manifest index plus the concatenated
* instance data. Assets are addressed by `Type:Instance` or by their
* `(typeId, instanceId)` pair (used by cross-asset references).
*/
class asset_stream {
public:
struct asset {
std::string name; ///< `Type:Instance`
std::string source;
uint32 type_id = 0;
uint32 instance_id = 0;
usize instance_offset = 0;
usize instance_size = 0;
std::vector<std::pair<uint32, uint32>> references; ///< `(typeId, instanceId)` targets.
[[nodiscard]] auto type_name() const -> std::string_view { return std::string_view{name}.substr(0, name.find(':')); }
[[nodiscard]] auto instance_name() const -> std::string_view {
const auto at = name.find(':');
return at == std::string::npos ? std::string_view{name} : std::string_view{name}.substr(at + 1U);
}
};
static constexpr uint32 header_size = 48U;
static constexpr uint32 entry_size = 48U;
/** Parse an in-memory `.manifest` + `.bin` pair. */
[[nodiscard]] static auto load(std::span<const uint8> manifest_raw, std::span<const uint8> data_raw) -> asset_stream {
asset_stream stream;
stream.data_ = fs::maybe_decompress(data_raw);
stream.parse(fs::maybe_decompress(manifest_raw));
return stream;
}
/**
* Parse a `.manifest` + `.bin` pair from disk.
*
* Retail's worldbuilder stream is ~1.1 GB and uncompressed, so a small
* `.bin` is read into memory but a large one is read lazily per asset
* (its pointers are offsets, so a slice needs no random-access decode).
*/
[[nodiscard]] static auto load_files(const std::filesystem::path &manifest_path, const std::filesystem::path &bin_path,
usize inline_limit = 128U * 1024U * 1024U) -> asset_stream {
const auto read_all = [](const std::filesystem::path &path) {
std::ifstream in(path, std::ios::binary);
if (!in) throw model_error("cannot open " + path.string());
return std::vector<uint8>{std::istreambuf_iterator<char>(in), std::istreambuf_iterator<char>()};
};
std::error_code ec;
const auto bin_size = std::filesystem::file_size(bin_path, ec);
asset_stream stream;
if (ec || bin_size <= inline_limit) {
stream.data_ = fs::maybe_decompress(read_all(bin_path));
} else {
stream.bin_path_ = bin_path;
// The large retail streams (worldbuilder.bin) are stored raw; a
// RefPack stream would not be randomly addressable anyway.
const auto probe = [&] {
std::ifstream in(bin_path, std::ios::binary);
std::array<uint8, 16> head{};
in.read(reinterpret_cast<char *>(head.data()), static_cast<std::streamsize>(head.size()));
return fs::is_refpack(head);
}();
if (probe) throw model_error("compressed asset stream is too large to map");
}
stream.parse(read_all(manifest_path));
return stream;
}
[[nodiscard]] auto assets() const -> const std::vector<asset> & { return assets_; }
[[nodiscard]] auto find(uint32 type_id, uint32 instance_id) const -> const asset * {
const auto it = index_.find((static_cast<unsigned long long>(type_id) << 32U) | instance_id);
return it == index_.end() ? nullptr : &assets_[it->second];
}
/** First asset whose `Type:Instance` name equals `name` (case-insensitive). */
[[nodiscard]] auto find_name(std::string_view name) const -> const asset * {
const auto want = lower(name);
for (const auto &a: assets_) {
if (lower(a.name) == want) return &a;
}
return nullptr;
}
/** Every `W3DMesh` whose instance name equals `base` or starts with `base.`. */
[[nodiscard]] auto meshes_for(std::string_view base) const -> const std::vector<const asset *> & {
static const std::vector<const asset *> none;
const auto it = mesh_index_.find(lower(base));
return it == mesh_index_.end() ? none : it->second;
}
/** The asset's instance bytes (from memory, or lazily from disk). */
[[nodiscard]] auto read_instance(const asset &a) const -> std::vector<uint8> {
if (!data_.empty()) {
if (a.instance_offset + a.instance_size > data_.size()) return {};
return {data_.begin() + static_cast<std::ptrdiff_t>(a.instance_offset),
data_.begin() + static_cast<std::ptrdiff_t>(a.instance_offset + a.instance_size)};
}
if (bin_path_.empty() || a.instance_size == 0U) return {};
std::ifstream in(bin_path_, std::ios::binary);
if (!in) return {};
in.seekg(static_cast<std::streamoff>(a.instance_offset));
std::vector<uint8> bytes(a.instance_size);
in.read(reinterpret_cast<char *>(bytes.data()), static_cast<std::streamsize>(bytes.size()));
return bytes;
}
[[nodiscard]] static auto lower(std::string_view text) -> std::string {
std::string out{text};
std::transform(out.begin(), out.end(), out.begin(), [](unsigned char ch) { return static_cast<char>(std::tolower(ch)); });
return out;
}
private:
/** Parse the manifest into `assets_` + the lookup indexes. */
auto parse(std::span<const uint8> manifest) -> void {
if (manifest.size() < header_size) throw model_error("asset manifest is too short");
if (manifest[0] != 0U) throw model_error("big-endian asset manifest is unsupported");
const auto count = detail::u32(manifest, 12U);
const auto ref_buf_size = detail::u32(manifest, 32U);
const auto ref_name_buf_size = detail::u32(manifest, 36U);
const auto name_buf_size = detail::u32(manifest, 40U);
const auto src_buf_size = detail::u32(manifest, 44U);
(void) src_buf_size;
if (header_size + static_cast<usize>(count) * entry_size > manifest.size()) throw model_error("asset manifest entry table is truncated");
const usize entries_off = header_size;
const usize ref_off = entries_off + static_cast<usize>(count) * entry_size;
const usize refname_off = ref_off + ref_buf_size;
const usize name_off = refname_off + ref_name_buf_size;
const usize src_off = name_off + name_buf_size;
assets_.reserve(count);
usize instance_offset = 4U; // the first four bytes of `.bin` are the stream checksum
for (uint32 i = 0; i < count; ++i) {
const usize e = entries_off + static_cast<usize>(i) * entry_size;
asset a;
a.type_id = detail::u32(manifest, e);
a.instance_id = detail::u32(manifest, e + 4U);
const auto aref_off = detail::i32(manifest, e + 16U);
const auto aref_count = detail::i32(manifest, e + 20U);
a.instance_size = detail::u32(manifest, e + 32U);
a.instance_offset = instance_offset;
a.name = detail::nul_string(manifest, name_off + static_cast<usize>(std::max(0, detail::i32(manifest, e + 24U))));
a.source = detail::nul_string(manifest, src_off + static_cast<usize>(std::max(0, detail::i32(manifest, e + 28U))));
if (aref_off >= 0 && aref_count > 0 && ref_off + static_cast<usize>(aref_off) + static_cast<usize>(aref_count) * 8U <= manifest.size()) {
a.references.reserve(static_cast<usize>(aref_count));
for (int r = 0; r < aref_count; ++r) {
const usize ro = ref_off + static_cast<usize>(aref_off) + static_cast<usize>(r) * 8U;
a.references.emplace_back(detail::u32(manifest, ro), detail::u32(manifest, ro + 4U));
}
}
index_.try_emplace((static_cast<unsigned long long>(a.type_id) << 32U) | a.instance_id, assets_.size());
assets_.push_back(std::move(a));
instance_offset += assets_.back().instance_size;
}
// Index meshes by the base of their instance name (`A.B` -> `a` and
// `a.b`) so a map object type resolves to its mesh parts in one look.
for (const auto &a: assets_) {
if (a.type_name() != "W3DMesh") continue;
const auto instance = lower(a.instance_name());
mesh_index_[instance].push_back(&a);
const auto dot = instance.find('.');
if (dot != std::string::npos) mesh_index_[instance.substr(0, dot)].push_back(&a);
}
}
std::vector<asset> assets_;
std::unordered_map<unsigned long long, usize> index_;
std::unordered_map<std::string, std::vector<const asset *>> mesh_index_;
std::vector<uint8> data_;
std::filesystem::path bin_path_;
};
/** A decoded compiled `W3DMesh`: a vertex buffer plus a triangle list, in model space. */
struct mesh {
std::vector<float> positions; ///< 3 floats per vertex.
std::vector<float> normals; ///< 3 floats per vertex, or empty.
std::vector<float> uvs; ///< 2 floats per vertex, or empty.
std::vector<uint32> indices;
std::string shader; ///< FX shader name (`BuildingsGeneric.fx`, ...).
std::vector<uint8> blend_indices; ///< 4 bone indices per vertex, or empty (static mesh).
std::vector<uint16> bone_remap; ///< Per-mesh blend index -> hierarchy bone map, or empty.
std::vector<float> blend_weights; ///< 4 weights per vertex, or empty.
[[nodiscard]] auto skinned() const -> bool { return !blend_indices.empty(); }
};
/** One bone of a `W3DHierarchy`: name hash, parent and the local (bind) transform. */
struct bone {
uint32 name_hash = 0;
int32 parent = -1; ///< -1 for the root.
float tx = 0.0F;
float ty = 0.0F;
float tz = 0.0F;
float qx = 0.0F;
float qy = 0.0F;
float qz = 0.0F;
float qw = 1.0F;
};
/** A decoded `W3DHierarchy` (the skeleton the meshes are bound to). */
struct hierarchy {
std::vector<bone> bones;
};
/**
* Decode a compiled `W3DHierarchy`.
*
* Layout (from the retail data): `u32 pad, u32 boneCount, u32 headerBytes`,
* then `boneCount` 100-byte records: `u32 nameHash, i32 parent, f32 t[3],
* f32 quaternion[4] (x,y,z,w), f32 matrix[12]`. Meshes are bound to bones in
* *bone space*, so a static render must rebuild each bone's world transform
* from this default pose (see `bone_world_matrices`).
*/
[[nodiscard]] inline auto decode_hierarchy(std::span<const uint8> data) -> hierarchy {
hierarchy out;
const auto bone_count = detail::u32(data, 4U);
const auto header = detail::u32(data, 8U);
if (bone_count == 0U || bone_count > 4096U || header + static_cast<usize>(bone_count) * 100U > data.size()) return out;
out.bones.reserve(bone_count);
for (uint32 i = 0; i < bone_count; ++i) {
const auto o = header + static_cast<usize>(i) * 100U;
bone b;
b.name_hash = detail::u32(data, o);
b.parent = detail::i32(data, o + 4U);
b.tx = detail::f32(data, o + 8U);
b.ty = detail::f32(data, o + 12U);
b.tz = detail::f32(data, o + 16U);
b.qx = detail::f32(data, o + 20U);
b.qy = detail::f32(data, o + 24U);
b.qz = detail::f32(data, o + 28U);
b.qw = detail::f32(data, o + 32U);
out.bones.push_back(b);
}
return out;
}
/** A bone's world transform: 3x3 rotation (row-major) followed by a translation. */
struct bone_matrix {
std::array<float, 9> rotation{1.0F, 0.0F, 0.0F, 0.0F, 1.0F, 0.0F, 0.0F, 0.0F, 1.0F};
std::array<float, 3> translation{0.0F, 0.0F, 0.0F};
};
namespace detail {
/** Quaternion `(x, y, z, w)` to a row-major 3x3 rotation matrix. */
[[nodiscard]] inline auto quaternion_matrix(float x, float y, float z, float w) -> std::array<float, 9> {
const auto n = std::sqrt(x * x + y * y + z * z + w * w);
if (n <= 1.0e-8F) return {1.0F, 0.0F, 0.0F, 0.0F, 1.0F, 0.0F, 0.0F, 0.0F, 1.0F};
x /= n;
y /= n;
z /= n;
w /= n;
return {1.0F - 2.0F * (y * y + z * z), 2.0F * (x * y - z * w), 2.0F * (x * z + y * w),
2.0F * (x * y + z * w), 1.0F - 2.0F * (x * x + z * z), 2.0F * (y * z - x * w),
2.0F * (x * z - y * w), 2.0F * (y * z + x * w), 1.0F - 2.0F * (x * x + y * y)};
}
[[nodiscard]] inline auto multiply(const std::array<float, 9> &a, const std::array<float, 9> &b) -> std::array<float, 9> {
std::array<float, 9> out{};
for (int i = 0; i < 3; ++i) {
for (int j = 0; j < 3; ++j) {
out[static_cast<usize>(i) * 3U + static_cast<usize>(j)] = a[static_cast<usize>(i) * 3U] * b[static_cast<usize>(j)] +
a[static_cast<usize>(i) * 3U + 1U] * b[3U + static_cast<usize>(j)] +
a[static_cast<usize>(i) * 3U + 2U] * b[6U + static_cast<usize>(j)];
}
}
return out;
}
[[nodiscard]] inline auto rotate(const std::array<float, 9> &m, const std::array<float, 3> &v) -> std::array<float, 3> {
return {m[0] * v[0] + m[1] * v[1] + m[2] * v[2], m[3] * v[0] + m[4] * v[1] + m[5] * v[2], m[6] * v[0] + m[7] * v[1] + m[8] * v[2]};
}
}
/** Each bone's world transform in the hierarchy's default (bind) pose. */
[[nodiscard]] inline auto bone_world_matrices(const hierarchy &h) -> std::vector<bone_matrix> {
std::vector<bone_matrix> world(h.bones.size());
for (usize i = 0; i < h.bones.size(); ++i) {
const auto &b = h.bones[i];
const auto local_rotation = detail::quaternion_matrix(b.qx, b.qy, b.qz, b.qw);
const std::array<float, 3> local_translation{b.tx, b.ty, b.tz};
if (b.parent >= 0 && static_cast<usize>(b.parent) < i) {
const auto &parent = world[static_cast<usize>(b.parent)];
world[i].rotation = detail::multiply(parent.rotation, local_rotation);
const auto rotated = detail::rotate(parent.rotation, local_translation);
world[i].translation = {parent.translation[0] + rotated[0], parent.translation[1] + rotated[1], parent.translation[2] + rotated[2]};
} else {
world[i].rotation = local_rotation;
world[i].translation = local_translation;
}
}
return world;
}
namespace detail {
/** One vertex element: which attribute, its byte offset and its type code. */
struct vertex_element {
char usage = 0;
uint32 index = 0;
uint32 offset = 0;
std::string type;
};
[[nodiscard]] inline auto usage_of(char letter) -> bool {
return letter == 'p' || letter == 'n' || letter == 't' || letter == 'c' || letter == 'g' || letter == 'b' || letter == 'i' || letter == 'w';
}
/** Parse the D3D9 text vertex declaration `"p0:00:3f32 n0:0C:3f32 t0:1C:2f32"`. */
[[nodiscard]] inline auto parse_vertex_format(std::string_view decl) -> std::vector<vertex_element> {
std::vector<vertex_element> elements;
for (usize start = 0; start < decl.size();) {
const auto end = decl.find(' ', start);
const auto token = decl.substr(start, end == std::string_view::npos ? std::string_view::npos : end - start);
start = end == std::string_view::npos ? decl.size() : end + 1U;
const auto c1 = token.find(':');
if (c1 == std::string_view::npos) continue;
const auto c2 = token.find(':', c1 + 1U);
if (c2 == std::string_view::npos) continue;
const auto name = token.substr(0, c1);
if (name.empty() || !usage_of(name[0])) continue;
vertex_element element;
element.usage = name[0];
element.index = name.size() > 1U ? static_cast<uint32>(std::strtoul(std::string{name.substr(1)}.c_str(), nullptr, 10)) : 0U;
element.offset = static_cast<uint32>(std::strtoul(std::string{token.substr(c1 + 1U, c2 - c1 - 1U)}.c_str(), nullptr, 16));
element.type = std::string{token.substr(c2 + 1U)};
elements.push_back(std::move(element));
}
return elements;
}
/** Decode one vertex element (a small type grammar: `<count><kind>`). */
[[nodiscard]] inline auto decode_vertex_element(std::span<const uint8> buf, usize off, std::string_view type, float *out, uint32 wanted) -> uint32 {
usize digits = 0;
while (digits < type.size() && std::isdigit(static_cast<unsigned char>(type[digits]))) ++digits;
if (digits == 0U) return 0U;
const auto count = static_cast<uint32>(std::strtoul(std::string{type.substr(0, digits)}.c_str(), nullptr, 10));
const auto kind = type.substr(digits);
const auto take = std::min(count, wanted);
const auto bytes_per_element = (kind == "f32") ? 4U : ((kind == "u8n" || kind == "u8") ? 1U : 2U);
const auto lerp = [&](uint32 i, float value) { out[i] = value; };
for (uint32 i = 0; i < take; ++i) {
const auto at = off + static_cast<usize>(i) * bytes_per_element;
if (kind == "f32") {
lerp(i, f32(buf, off + static_cast<usize>(i) * 4U));
} else if (kind == "u8n") {
lerp(i, at < buf.size() ? static_cast<float>(buf[at]) / 255.0F : 0.0F);
} else if (kind == "u8") {
lerp(i, at < buf.size() ? static_cast<float>(buf[at]) : 0.0F);
} else if (kind == "s16n") {
lerp(i, at + 2U <= buf.size() ? static_cast<float>(static_cast<std::int16_t>(u16(buf, at))) / 32767.0F : 0.0F);
} else if (kind == "u16n") {
lerp(i, at + 2U <= buf.size() ? static_cast<float>(u16(buf, at)) / 65535.0F : 0.0F);
} else if (kind == "s16") {
lerp(i, at + 2U <= buf.size() ? static_cast<float>(static_cast<std::int16_t>(u16(buf, at))) : 0.0F);
} else if (kind == "u16") {
lerp(i, at + 2U <= buf.size() ? static_cast<float>(u16(buf, at)) : 0.0F);
} else if (kind == "f16") {
const auto raw = at + 2U <= buf.size() ? u16(buf, at) : 0U;
const auto sign = (raw & 0x8000U) != 0U ? -1.0F : 1.0F;
const auto exp = (raw >> 10U) & 0x1FU;
const auto mant = raw & 0x3FFU;
float value = exp == 0U ? static_cast<float>(mant) / 1024.0F * std::pow(2.0F, -14.0F)
: (1.0F + static_cast<float>(mant) / 1024.0F) * std::pow(2.0F, static_cast<float>(exp) - 15.0F);
lerp(i, sign * value);
}
}
return take;
}
}
/**
* Decode a compiled `W3DMesh` instance.
*
* The compiled struct stores a GPU vertex buffer (declaration string +
* packed vertices) and a triangle list; every pointer is an offset from the
* instance start, so the payload is read in place.
*/
[[nodiscard]] inline auto decode_mesh(std::span<const uint8> data) -> mesh {
mesh out;
const auto vertex_off = detail::u32(data, 4U);
const auto triangle_count = detail::u32(data, 52U);
const auto triangle_items = detail::u32(data, 56U);
if (vertex_off == 0U || vertex_off + 20U > data.size()) return out;
const auto vertex_count = detail::u32(data, vertex_off);
const auto stride = detail::u32(data, vertex_off + 4U);
const auto element_items = detail::u32(data, vertex_off + 8U);
const auto decl_bytes = detail::u32(data, vertex_off + 12U);
const auto decl_items = detail::u32(data, vertex_off + 16U);
if (vertex_count == 0U || stride == 0U || decl_items + decl_bytes > data.size()) return out;
const std::string_view decl{reinterpret_cast<const char *>(data.data() + decl_items), decl_bytes};
const auto elements = detail::parse_vertex_format(decl);
const auto shader_len = detail::i32(data, 60U);
const auto shader_ptr = detail::u32(data, 64U);
if (shader_len > 0 && shader_ptr != 0U && shader_ptr + static_cast<usize>(shader_len) <= data.size()) {
out.shader.assign(reinterpret_cast<const char *>(data.data() + shader_ptr), static_cast<usize>(shader_len));
}
// Per-mesh bone remap: a vertex's blend index selects an entry here, and
// that entry is the hierarchy bone. Without it the wrong bones are used
// and skinned models tear apart.
const auto bone_count = detail::u32(data, vertex_off + 0x14U);
const auto bone_ptr = detail::u32(data, vertex_off + 0x18U);
if (bone_count > 0U && bone_count < 4096U && bone_ptr != 0U && bone_ptr + static_cast<usize>(bone_count) * 2U <= data.size()) {
out.bone_remap.resize(bone_count);
for (uint32 i = 0; i < bone_count; ++i) out.bone_remap[i] = detail::u16(data, bone_ptr + static_cast<usize>(i) * 2U);
}
out.positions.assign(static_cast<usize>(vertex_count) * 3U, 0.0F);
std::vector<float> normals(static_cast<usize>(vertex_count) * 3U, 0.0F);
std::vector<float> uvs(static_cast<usize>(vertex_count) * 2U, 0.0F);
std::vector<uint8> blend_indices(static_cast<usize>(vertex_count) * 4U, 0U);
std::vector<float> blend_weights(static_cast<usize>(vertex_count) * 4U, 0.0F);
bool have_normals = false;
bool have_uvs = false;
bool have_blend = false;
for (uint32 i = 0; i < vertex_count; ++i) {
const auto base = static_cast<usize>(element_items) + static_cast<usize>(i) * stride;
float normal[3] = {0.0F, 0.0F, 1.0F};
float uv[2] = {0.0F, 0.0F};
for (const auto &element: elements) {
float value[4] = {0.0F, 0.0F, 0.0F, 0.0F};
if (element.usage == 'p' && element.index == 0U) {
if (detail::decode_vertex_element(data, base + element.offset, element.type, value, 3U) >= 3U) {
out.positions[static_cast<usize>(i) * 3U + 0U] = value[0];
out.positions[static_cast<usize>(i) * 3U + 1U] = value[1];
out.positions[static_cast<usize>(i) * 3U + 2U] = value[2];
}
} else if (element.usage == 'n' && element.index == 0U) {
if (detail::decode_vertex_element(data, base + element.offset, element.type, value, 3U) >= 3U) {
normal[0] = value[0];
normal[1] = value[1];
normal[2] = value[2];
have_normals = true;
}
} else if (element.usage == 't' && element.index == 0U) {
if (detail::decode_vertex_element(data, base + element.offset, element.type, value, 2U) >= 2U) {
uv[0] = value[0];
uv[1] = value[1];
have_uvs = true;
}
} else if (element.usage == 'i' && element.index == 0U) {
if (detail::decode_vertex_element(data, base + element.offset, element.type, value, 4U) >= 4U) {
for (usize k = 0; k < 4U; ++k) blend_indices[static_cast<usize>(i) * 4U + k] = static_cast<uint8>(std::clamp(value[k], 0.0F, 255.0F));
have_blend = true;
}
} else if (element.usage == 'w' && element.index == 0U) {
if (detail::decode_vertex_element(data, base + element.offset, element.type, value, 4U) >= 4U) {
for (usize k = 0; k < 4U; ++k) blend_weights[static_cast<usize>(i) * 4U + k] = value[k];
}
}
}
normals[static_cast<usize>(i) * 3U + 0U] = normal[0];
normals[static_cast<usize>(i) * 3U + 1U] = normal[1];
normals[static_cast<usize>(i) * 3U + 2U] = normal[2];
uvs[static_cast<usize>(i) * 2U + 0U] = uv[0];
uvs[static_cast<usize>(i) * 2U + 1U] = uv[1];
}
for (uint32 i = 0; i < triangle_count; ++i) {
const auto base = static_cast<usize>(triangle_items) + static_cast<usize>(i) * 24U;
const auto count = detail::u32(data, base);
const auto ptr = detail::u32(data, base + 4U);
if (count == 0U || ptr == 0U || ptr + static_cast<usize>(count) * 4U > data.size()) continue;
std::vector<uint32> ring(count);
for (uint32 k = 0; k < count; ++k) ring[k] = detail::u32(data, ptr + static_cast<usize>(k) * 4U);
for (uint32 k = 1; k + 1U < count; ++k) { // fan any n-gon
out.indices.push_back(ring[0]);
out.indices.push_back(ring[k]);
out.indices.push_back(ring[k + 1U]);
}
}
if (have_normals) out.normals = std::move(normals);
if (have_uvs) out.uvs = std::move(uvs);
if (have_blend) {
out.blend_indices = std::move(blend_indices);
out.blend_weights = std::move(blend_weights);
}
return out;
}
// ---- DDS --------------------------------------------------------------
namespace detail {
inline auto decode_dxt_color(std::span<const uint8> block, usize offset, std::vector<uint32> &out, uint32 w, uint32 h, uint32 x, uint32 y, bool force_four) -> void {
const auto c0 = static_cast<uint32>(block[offset]) | (static_cast<uint32>(block[offset + 1U]) << 8U);
const auto c1 = static_cast<uint32>(block[offset + 2U]) | (static_cast<uint32>(block[offset + 3U]) << 8U);
const auto hilo = [](uint32 c) -> std::array<uint8, 3> {
return {static_cast<uint8>(((c >> 11U) & 0x1FU) * 255U / 31U), static_cast<uint8>(((c >> 5U) & 0x3FU) * 255U / 63U),
static_cast<uint8>((c & 0x1FU) * 255U / 31U)};
};
const auto a = hilo(c0);
const auto b = hilo(c1);
std::array<uint32, 4> colors{};
colors[0] = argb(a[0], a[1], a[2]);
colors[1] = argb(b[0], b[1], b[2]);
if (c0 > c1 || force_four) {
colors[2] = argb(static_cast<uint8>((2U * a[0] + b[0]) / 3U), static_cast<uint8>((2U * a[1] + b[1]) / 3U), static_cast<uint8>((2U * a[2] + b[2]) / 3U));
colors[3] = argb(static_cast<uint8>((a[0] + 2U * b[0]) / 3U), static_cast<uint8>((a[1] + 2U * b[1]) / 3U), static_cast<uint8>((a[2] + 2U * b[2]) / 3U));
} else {
colors[2] = argb(static_cast<uint8>((a[0] + b[0]) / 2U), static_cast<uint8>((a[1] + b[1]) / 2U), static_cast<uint8>((a[2] + b[2]) / 2U));
colors[3] = 0x00000000U;
}
uint32 indices = 0;
for (uint32 k = 0; k < 4U; ++k) indices |= static_cast<uint32>(block[offset + 4U + k]) << (8U * k);
for (uint32 py = 0; py < 4U; ++py) {
for (uint32 px = 0; px < 4U; ++px) {
if (x + px >= w || y + py >= h) continue;
out[static_cast<usize>(y + py) * w + (x + px)] = colors[(indices >> (2U * (py * 4U + px))) & 3U];
}
}
}
}
/** Decode a DDS (DXT1/3/5 or uncompressed RGB) to an ARGB image; empty when unsupported. */
[[nodiscard]] inline auto decode_dds(std::span<const uint8> data) -> image {
if (data.size() < 128U || std::memcmp(data.data(), "DDS ", 4) != 0) return {};
const auto height = detail::u32(data, 12U);
const auto width = detail::u32(data, 16U);
const auto pfflags = detail::u32(data, 80U);
const auto bits = detail::u32(data, 88U);
const auto rmask = detail::u32(data, 92U);
const auto gmask = detail::u32(data, 96U);
const auto bmask = detail::u32(data, 100U);
const auto amask = detail::u32(data, 104U);
if (width == 0U || height == 0U || width > 8192U || height > 8192U) return {};
const auto fourcc = std::string_view{reinterpret_cast<const char *>(data.data() + 84U), 4U};
std::vector<uint32> pixels(static_cast<usize>(width) * height, 0xFF000000U);
if (fourcc == "DXT1" || fourcc == "DXT3" || fourcc == "DXT5") {
const auto stride = fourcc == "DXT1" ? 8U : 16U;
usize pos = 128U;
const auto blocks_x = (width + 3U) / 4U;
const auto blocks_y = (height + 3U) / 4U;
for (uint32 by = 0; by < blocks_y; ++by) {
for (uint32 bx = 0; bx < blocks_x; ++bx) {
if (pos + stride > data.size()) goto dds_done; // truncated: keep what we decoded
const auto block = data.subspan(pos, stride);
pos += stride;
const uint32 x = bx * 4U;
const uint32 y = by * 4U;
if (fourcc == "DXT1") {
detail::decode_dxt_color(block, 0U, pixels, width, height, x, y, false);
} else {
if (fourcc == "DXT3") {
for (uint32 py = 0; py < 4U; ++py) {
for (uint32 px = 0; px < 4U; ++px) {
const auto i = py * 4U + px;
const auto byte = block[i / 2U];
const auto alpha = static_cast<uint8>((i % 2U == 0U) ? (byte & 0x0FU) : (byte >> 4U));
const auto at = static_cast<usize>(y + py) * width + (x + px);
if (y + py < height && x + px < width) pixels[at] = (pixels[at] & 0x00FFFFFFU) | (static_cast<uint32>(alpha) * 17U << 24U);
}
}
} else { // DXT5
const auto a0 = block[0];
const auto a1 = block[1];
std::array<uint8, 8> palette{};
palette[0] = a0;
palette[1] = a1;
if (a0 > a1) {
for (uint32 i = 1U; i < 7U; ++i) palette[i + 1U] = static_cast<uint8>(((7U - i) * a0 + i * a1) / 7U);
} else {
for (uint32 i = 1U; i < 5U; ++i) palette[i + 1U] = static_cast<uint8>(((5U - i) * a0 + i * a1) / 5U);
palette[6] = 0U;
palette[7] = 255U;
}
std::uint64_t abits = 0;
for (uint32 k = 0; k < 6U; ++k) abits |= static_cast<std::uint64_t>(block[2U + k]) << (8U * k);
for (uint32 py = 0; py < 4U; ++py) {
for (uint32 px = 0; px < 4U; ++px) {
const auto i = py * 4U + px;
const auto alpha = palette[(abits >> (3U * i)) & 7U];
const auto at = static_cast<usize>(y + py) * width + (x + px);
if (y + py < height && x + px < width) pixels[at] = (pixels[at] & 0x00FFFFFFU) | (static_cast<uint32>(alpha) << 24U);
}
}
}
detail::decode_dxt_color(block, 8U, pixels, width, height, x, y, true);
}
}
}
} else if ((pfflags & 0x40U) != 0U && (bits == 16U || bits == 24U || bits == 32U)) {
const auto shift_of = [](uint32 mask) -> std::pair<uint32, uint32> {
if (mask == 0U) return {0U, 0U};
uint32 shift = 0;
while (((mask >> shift) & 1U) == 0U) ++shift;
uint32 size = 0;
while (((mask >> (shift + size)) & 1U) != 0U) ++size;
return {shift, size};
};
const auto channels = {shift_of(rmask), shift_of(gmask), shift_of(bmask), shift_of(amask)};
const auto bpp = bits / 8U;
const auto *raw = data.data() + 128U;
const auto available = data.size() > 128U ? (data.size() - 128U) / bpp : 0U;
for (usize i = 0; i < static_cast<usize>(width) * height && i < available; ++i) {
uint32 pixel = 0;
for (uint32 b = 0; b < bpp; ++b) pixel |= static_cast<uint32>(raw[i * bpp + b]) << (8U * b);
uint32 out = 0xFF000000U;
uint32 channel = 0;
for (const auto [shift, size]: channels) {
const auto maxv = size != 0U ? ((1U << size) - 1U) : 0U;
const auto value = maxv != 0U ? (((pixel >> shift) & maxv) * 255U / maxv) : 255U;
if (channel < 3U) {
out |= value << (16U - channel * 8U);
} else if (size != 0U) {
out = (out & 0x00FFFFFFU) | (value << 24U);
}
++channel;
}
pixels[i] = out;
}
} else {
return {};
}
dds_done:;
image result(width, height);
std::copy(pixels.begin(), pixels.end(), result.data());
return result;
}
/** Decode the DDS embedded in a compiled `Texture` instance; empty when none. */
[[nodiscard]] inline auto decode_texture(std::span<const uint8> instance) -> image {
const auto off = detail::u32(instance, 4U);
std::span<const uint8> dds{};
if (off > 0U && off < instance.size() && off + 4U <= instance.size() && std::memcmp(instance.data() + off, "DDS ", 4) == 0) {
dds = instance.subspan(off);
} else {
for (usize i = 0; i + 4U <= instance.size(); ++i) {
if (std::memcmp(instance.data() + i, "DDS ", 4) == 0) {
dds = instance.subspan(i);
break;
}
}
}
return dds.empty() ? image{} : decode_dds(dds);
}
/**
* The diffuse texture a mesh samples, or `nullptr`.
*
* A `W3DMesh` stores `FXShaderConstant`s at `+76` (count) / `+80` (items);
* a texture-valued constant (`0xA59096A6`) names its role and points at a
* 1-based index into the mesh's cross-asset references.
*/
[[nodiscard]] inline auto mesh_diffuse_texture(const asset_stream &stream, const asset_stream::asset &mesh_asset) -> const asset_stream::asset * {
const auto instance = stream.read_instance(mesh_asset);
if (instance.empty()) return nullptr;
const auto count = detail::u32(instance, 76U);
const auto items = detail::u32(instance, 80U);
for (uint32 i = 0; i < count; ++i) {
const auto constant = detail::u32(instance, items + static_cast<usize>(i) * 4U);
if (constant == 0U || detail::u32(instance, constant) != 0xA59096A6U) continue;
const auto name = asset_stream::lower(detail::cstr(instance, detail::u32(instance, constant + 8U), detail::i32(instance, constant + 4U)));
const bool is_diffuse = name.contains("diffuse") || name.contains("albedo") || name.contains("base") || name == "texture_0" || name == "texture0";
if (!is_diffuse) continue;
const auto reference = detail::u32(instance, constant + 12U);
if (reference >= 1U && reference <= mesh_asset.references.size()) {
const auto [type_id, instance_id] = mesh_asset.references[reference - 1U];
if (const auto *texture = stream.find(type_id, instance_id); texture != nullptr) return texture;
}
}
return nullptr;
}
// ---- scene ------------------------------------------------------------
/** A world-space triangle-soup vertex (position, normal, uv, texture layer). */
struct vertex {
float x = 0.0F;
float y = 0.0F;
float z = 0.0F;
float nx = 0.0F;
float ny = 0.0F;
float nz = 1.0F;
float u = 0.0F;
float v = 0.0F;
float layer = 0.0F;
};
/** Every model the map draws, flattened to world space and ready to upload. */
struct scene {
std::vector<vertex> vertices;
std::vector<uint32> indices;
std::vector<image> textures; ///< All `texture_size` square, ARGB.
uint32 texture_size = 0;
usize placed = 0;
usize missing = 0;
[[nodiscard]] auto empty() const -> bool { return indices.empty(); }
[[nodiscard]] auto triangle_count() const -> usize { return indices.size() / 3U; }
};
/** One placement request: an object type at a world position with a Z rotation. */
struct placement {
std::string type;
float x = 0.0F;
float y = 0.0F;
float z = 0.0F;
float angle = 0.0F;
float scale = 1.0F;
};
namespace detail {
/** Box-downscale `src` to a `size` square (averages, so atlas detail survives). */
[[nodiscard]] inline auto to_square(const image &src, uint32 size) -> image {
image out(size, size);
if (src.empty() || size == 0U) return out;
for (uint32 y = 0; y < size; ++y) {
const auto y0 = y * src.height() / size;
const auto y1 = std::max(y0 + 1U, (y + 1U) * src.height() / size);
for (uint32 x = 0; x < size; ++x) {
const auto x0 = x * src.width() / size;
const auto x1 = std::max(x0 + 1U, (x + 1U) * src.width() / size);
uint32 r = 0;
uint32 g = 0;
uint32 b = 0;
uint32 a = 0;
uint32 count = 0;
for (uint32 sy = y0; sy < y1; ++sy) {
for (uint32 sx = x0; sx < x1; ++sx) {
const auto texel = src.data()[static_cast<usize>(sy) * src.width() + sx];
r += (texel >> 16U) & 0xFFU;
g += (texel >> 8U) & 0xFFU;
b += texel & 0xFFU;
a += (texel >> 24U) & 0xFFU;
++count;
}
}
out.data()[static_cast<usize>(y) * size + x] =
argb(static_cast<uint8>(r / count), static_cast<uint8>(g / count), static_cast<uint8>(b / count), static_cast<uint8>(a / count));
}
}
return out;
}
}
/**
* Flatten every placement into one world-space scene.
*
* @param stream The map's compiled art stream.
* @param placements The objects to draw.
* @param ground_height Terrain height (world Z) at a world `(x, y)`, so
* objects sit on the relief instead of a flat plane.
* @param texture_size Edge length of the shared texture array (0 = auto).
*/
[[nodiscard]] inline auto build_scene(const asset_stream &stream, std::span<const placement> placements,
const std::function<float(float, float)> &ground_height = {}, uint32 texture_size = 128U) -> scene {
scene out;
out.texture_size = texture_size == 0U ? 128U : texture_size;
std::unordered_map<std::string, uint32> texture_layers;
const auto layer_for = [&](const asset_stream::asset *texture) -> uint32 {
if (texture == nullptr) return 0xFFFFFFFFU;
const auto key = asset_stream::lower(texture->name);
if (const auto it = texture_layers.find(key); it != texture_layers.end()) return it->second;
auto decoded = decode_texture(stream.read_instance(*texture));
if (decoded.empty()) return 0xFFFFFFFFU;
const auto layer = static_cast<uint32>(out.textures.size());
out.textures.push_back(detail::to_square(decoded, out.texture_size));
texture_layers.emplace(key, layer);
return layer;
};
// Bone world transforms per skeleton, cached by hierarchy name. Meshes are
// skinned; their vertices are stored in *bone space*, so a static render
// must rebuild the default pose and blend.
std::unordered_map<std::string, std::vector<bone_matrix>> skeletons;
const auto skeleton_for = [&](std::string_view mesh_instance) -> const std::vector<bone_matrix> * {
const auto dot = mesh_instance.find('.');
const auto base = std::string{mesh_instance.substr(0, dot)};
const auto key = asset_stream::lower(base);
if (const auto it = skeletons.find(key); it != skeletons.end()) return it->second.empty() ? nullptr : &it->second;
std::vector<bone_matrix> matrices;
if (const auto *asset = stream.find_name("W3DHierarchy:" + base); asset != nullptr) {
matrices = bone_world_matrices(decode_hierarchy(stream.read_instance(*asset)));
}
const auto it = skeletons.emplace(key, std::move(matrices)).first;
return it->second.empty() ? nullptr : &it->second;
};
for (const auto &item: placements) {
const auto meshes = stream.meshes_for(item.type);
if (meshes.empty()) {
++out.missing;
continue;
}
const auto cos_a = std::cos(item.angle);
const auto sin_a = std::sin(item.angle);
const auto base_z = (ground_height ? ground_height(item.x, item.y) : 0.0F) + item.z;
bool drawn = false;
for (const auto *mesh_asset: meshes) {
// Only opaque material parts are drawn; meshes with no diffuse
// role are effects (FX-light billboards, `DefaultW3D.fx`) or
// ambient helpers and would otherwise render as garbage.
const auto *diffuse = mesh_diffuse_texture(stream, *mesh_asset);
if (diffuse == nullptr) continue;
const auto geometry = decode_mesh(stream.read_instance(*mesh_asset));
if (geometry.indices.empty()) continue;
// Damage-fill shells (damaged interior/wreckage) are drawn on top
// of the main shell in our opaque pass and would show its
// interior texture (e.g. orange `CBBuilding_Wood`); skip them.
if (asset_stream::lower(geometry.shader).contains("damagefill")) continue;
drawn = true;
const auto layer = layer_for(diffuse);
const auto *skeleton = geometry.skinned() ? skeleton_for(mesh_asset->instance_name()) : nullptr;
const auto base_vertex = static_cast<uint32>(out.vertices.size());
const auto vertex_count = geometry.positions.size() / 3U;
for (usize i = 0; i < vertex_count; ++i) {
auto px = geometry.positions[i * 3U + 0U];
auto py = geometry.positions[i * 3U + 1U];
auto pz = geometry.positions[i * 3U + 2U];
auto nx = geometry.normals.empty() ? 0.0F : geometry.normals[i * 3U + 0U];
auto ny = geometry.normals.empty() ? 0.0F : geometry.normals[i * 3U + 1U];
auto nz = geometry.normals.empty() ? 1.0F : geometry.normals[i * 3U + 2U];
if (skeleton != nullptr) {
// The compiled shader binds one joint per vertex
// (`blendindices.x`), remapped through the mesh's bone
// table, then transforms by the joint's world matrix.
auto joint = static_cast<uint32>(geometry.blend_indices[i * 4U + 0U]);
if (joint < geometry.bone_remap.size()) joint = geometry.bone_remap[joint];
if (joint < skeleton->size()) {
const auto &m = (*skeleton)[joint];
const std::array<float, 3> p{px, py, pz};
const auto rp = detail::rotate(m.rotation, p);
const std::array<float, 3> n{nx, ny, nz};
const auto rn = detail::rotate(m.rotation, n);
px = rp[0] + m.translation[0];
py = rp[1] + m.translation[1];
pz = rp[2] + m.translation[2];
nx = rn[0];
ny = rn[1];
nz = rn[2];
}
}
vertex v;
v.x = item.x + (px * item.scale) * cos_a - (py * item.scale) * sin_a;
v.y = item.y + (px * item.scale) * sin_a + (py * item.scale) * cos_a;
v.z = base_z + pz * item.scale;
v.nx = nx * cos_a - ny * sin_a;
v.ny = nx * sin_a + ny * cos_a;
v.nz = nz;
if (!geometry.uvs.empty()) {
v.u = geometry.uvs[i * 2U + 0U];
v.v = geometry.uvs[i * 2U + 1U];
}
v.layer = layer == 0xFFFFFFFFU ? 0.0F : static_cast<float>(layer);
out.vertices.push_back(v);
}
for (const auto index: geometry.indices) out.indices.push_back(base_vertex + index);
}
if (drawn) {
++out.placed;
} else {
++out.missing;
}
}
return out;
}
}
+1
View File
@@ -16,6 +16,7 @@ export import ra3.map;
export import ra3.skirmish; export import ra3.skirmish;
export import ra3.render; export import ra3.render;
export import ra3.terrain; export import ra3.terrain;
export import ra3.models;
export import ra3.ui; export import ra3.ui;
export import ra3.vulkan; export import ra3.vulkan;
export import ra3.dx; export import ra3.dx;
+161 -6
View File
@@ -5,6 +5,7 @@ import std;
export import ra3.core; export import ra3.core;
export import ra3.fs; export import ra3.fs;
export import ra3.render; export import ra3.render;
export import ra3.models;
/** /**
* The map's real terrain, read from the compiled `CkMp` chunk tree. * 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; float z_scale = 0.0390625F;
bool has_water = false; bool has_water = false;
float water_z = 0.0F; 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 * base texture layer, the blend (and three-way) secondary layer and the
* packed blend direction/flags, which the shader ramps across the cell. * 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 = {}, [[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 { const ra3::models::scene &objects, const std::function<void(float)> &progress = {}) -> gpu_terrain {
gpu_terrain out; gpu_terrain out;
out.width = map.width; out.width = map.width;
out.height = map.height; out.height = map.height;
out.z_scale = options.z_scale; out.z_scale = options.z_scale;
out.has_water = map.has_water; out.has_water = map.has_water;
out.water_z = map.water_plane_z; out.water_z = map.water_plane_z;
out.objects = objects;
// Cell index -> texture layer. // Cell index -> texture layer.
const auto layer_of = [&](uint32 cell_index) -> uint16 { const auto layer_of = [&](uint32 cell_index) -> uint16 {
@@ -566,6 +569,12 @@ export namespace ra3::terrain {
return out; 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 { namespace detail {
/** /**
* The source texture a tile cell maps to. The texture is sampled * 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; const auto c2 = f2 > 0.0F ? sample_layer(layers.three, wx, wy) : c0;
return mix_color(mix_color(c0, c1, f1), c2, f2); 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. * 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, [[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"); if (!map.valid) throw terrain_error("terrain not parsed");
out_w = std::max(1U, out_w); out_w = std::max(1U, out_w);
out_h = std::max(1U, out_h); out_h = std::max(1U, out_h);
@@ -968,6 +1115,7 @@ export namespace ra3::terrain {
}; };
image hi(rw, rh, argb(0, 0, 0)); 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) { 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); 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) { for (uint32 px = 0; px < rw; ++px) {
@@ -979,10 +1127,11 @@ export namespace ra3::terrain {
dx /= dlen; dx /= dlen;
dy /= dlen; dy /= dlen;
dz /= dlen; dz /= dlen;
const auto pixel = static_cast<usize>(py) * rw + px;
if (dz >= -1.0e-4F) { if (dz >= -1.0e-4F) {
const auto t = std::clamp(std::abs(dz) * 2.0F, 0.0F, 1.0F); 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)); 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; continue;
} }
@@ -1017,7 +1166,7 @@ export namespace ra3::terrain {
t += dt; t += dt;
} }
if (!hit) { if (!hit) {
hi.data()[static_cast<usize>(py) * rw + px] = argb(150, 170, 200); hi.data()[pixel] = argb(150, 170, 200);
continue; continue;
} }
@@ -1035,10 +1184,16 @@ export namespace ra3::terrain {
lo = mid; 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; if (ss == 1U) return hi;
image out(out_w, out_h, argb(0, 0, 0)); image out(out_w, out_h, argb(0, 0, 0));
for (uint32 y = 0; y < out_h; ++y) { for (uint32 y = 0; y < out_h; ++y) {
+373 -17
View File
@@ -92,6 +92,7 @@ export namespace ra3::vulkan {
if (!this->create_device()) return false; if (!this->create_device()) return false;
if (!this->create_swapchain()) return false; if (!this->create_swapchain()) return false;
if (!this->create_render_pass()) return false; if (!this->create_render_pass()) return false;
if (!this->create_framebuffers()) return false;
if (!this->create_pipeline()) return false; if (!this->create_pipeline()) return false;
if (!this->create_commands()) return false; if (!this->create_commands()) return false;
if (!this->create_sync()) return false; if (!this->create_sync()) return false;
@@ -341,6 +342,12 @@ export namespace ra3::vulkan {
if (!this->create_terrain_pipeline()) return false; if (!this->create_terrain_pipeline()) return false;
if (!this->create_terrain_images(terrain)) return false; if (!this->create_terrain_images(terrain)) return false;
if (!this->create_terrain_descriptors()) return false; if (!this->create_terrain_descriptors()) return false;
if (!terrain.objects.empty()) {
if (!this->create_object_pipeline()) return false;
if (!this->create_object_buffers(terrain)) return false;
if (!this->create_object_descriptors()) return false;
objects_ready_ = true;
}
terrain_ready_ = true; terrain_ready_ = true;
} }
if (overlay.label_changed) this->update_overlay(overlay_label_, overlay_label_set_, overlay_label_w_, overlay_label_h_, overlay.label); if (overlay.label_changed) this->update_overlay(overlay_label_, overlay_label_set_, overlay_label_w_, overlay_label_h_, overlay.label);
@@ -669,6 +676,11 @@ export namespace ra3::vulkan {
VkPipelineMultisampleStateCreateInfo multisample{}; VkPipelineMultisampleStateCreateInfo multisample{};
multisample.sType = VK_STRUCTURE_TYPE_PIPELINE_MULTISAMPLE_STATE_CREATE_INFO; multisample.sType = VK_STRUCTURE_TYPE_PIPELINE_MULTISAMPLE_STATE_CREATE_INFO;
multisample.rasterizationSamples = VK_SAMPLE_COUNT_1_BIT; multisample.rasterizationSamples = VK_SAMPLE_COUNT_1_BIT;
VkPipelineDepthStencilStateCreateInfo depth_stencil{};
depth_stencil.sType = VK_STRUCTURE_TYPE_PIPELINE_DEPTH_STENCIL_STATE_CREATE_INFO;
depth_stencil.depthTestEnable = VK_TRUE;
depth_stencil.depthWriteEnable = VK_TRUE;
depth_stencil.depthCompareOp = VK_COMPARE_OP_LESS_OR_EQUAL;
VkPipelineColorBlendAttachmentState blend_attachment{}; VkPipelineColorBlendAttachmentState blend_attachment{};
blend_attachment.colorWriteMask = VK_COLOR_COMPONENT_R_BIT | VK_COLOR_COMPONENT_G_BIT | VK_COLOR_COMPONENT_B_BIT | VK_COLOR_COMPONENT_A_BIT; blend_attachment.colorWriteMask = VK_COLOR_COMPONENT_R_BIT | VK_COLOR_COMPONENT_G_BIT | VK_COLOR_COMPONENT_B_BIT | VK_COLOR_COMPONENT_A_BIT;
VkPipelineColorBlendStateCreateInfo blend{}; VkPipelineColorBlendStateCreateInfo blend{};
@@ -690,6 +702,7 @@ export namespace ra3::vulkan {
pipeline_info.pViewportState = &viewport; pipeline_info.pViewportState = &viewport;
pipeline_info.pRasterizationState = &raster; pipeline_info.pRasterizationState = &raster;
pipeline_info.pMultisampleState = &multisample; pipeline_info.pMultisampleState = &multisample;
pipeline_info.pDepthStencilState = &depth_stencil;
pipeline_info.pColorBlendState = &blend; pipeline_info.pColorBlendState = &blend;
pipeline_info.pDynamicState = &dynamic; pipeline_info.pDynamicState = &dynamic;
pipeline_info.layout = terrain_pipeline_layout_; pipeline_info.layout = terrain_pipeline_layout_;
@@ -737,6 +750,197 @@ export namespace ra3::vulkan {
return true; return true;
} }
/** Host-visible vertex/index buffer, filled once (the map's geometry is static). */
[[nodiscard]] auto make_host_buffer(const void *data, VkDeviceSize bytes, VkBufferUsageFlags usage, VkBuffer &buffer,
VkDeviceMemory &memory) -> bool {
VkBufferCreateInfo info{};
info.sType = VK_STRUCTURE_TYPE_BUFFER_CREATE_INFO;
info.size = bytes;
info.usage = usage;
info.sharingMode = VK_SHARING_MODE_EXCLUSIVE;
if (!detail::check(vkCreateBuffer(device_, &info, nullptr, &buffer), "vkCreateBuffer(object)")) return false;
VkMemoryRequirements requirements{};
vkGetBufferMemoryRequirements(device_, buffer, &requirements);
VkMemoryAllocateInfo allocate{};
allocate.sType = VK_STRUCTURE_TYPE_MEMORY_ALLOCATE_INFO;
allocate.allocationSize = requirements.size;
allocate.memoryTypeIndex = this->memory_type(requirements.memoryTypeBits, VK_MEMORY_PROPERTY_HOST_VISIBLE_BIT | VK_MEMORY_PROPERTY_HOST_COHERENT_BIT);
if (!detail::check(vkAllocateMemory(device_, &allocate, nullptr, &memory), "vkAllocateMemory(object)")) return false;
if (!detail::check(vkBindBufferMemory(device_, buffer, memory, 0), "vkBindBufferMemory(object)")) return false;
void *mapped = nullptr;
if (!detail::check(vkMapMemory(device_, memory, 0, bytes, 0, &mapped), "vkMapMemory(object)")) return false;
std::memcpy(mapped, data, static_cast<std::size_t>(bytes));
vkUnmapMemory(device_, memory);
return true;
}
/** Upload the static-map geometry and its texture array. */
[[nodiscard]] auto create_object_buffers(const ra3::terrain::gpu_terrain &terrain) -> bool {
const auto &scene = terrain.objects;
if (scene.empty()) return true;
const auto vertex_bytes = static_cast<VkDeviceSize>(scene.vertices.size()) * sizeof(ra3::models::vertex);
const auto index_bytes = static_cast<VkDeviceSize>(scene.indices.size()) * sizeof(uint32_t);
if (!this->make_host_buffer(scene.vertices.data(), vertex_bytes, VK_BUFFER_USAGE_VERTEX_BUFFER_BIT, object_vertex_buffer_, object_vertex_memory_)) return false;
if (!this->make_host_buffer(scene.indices.data(), index_bytes, VK_BUFFER_USAGE_INDEX_BUFFER_BIT, object_index_buffer_, object_index_memory_)) return false;
object_index_count_ = static_cast<uint32_t>(scene.indices.size());
const auto size = std::max(1U, scene.texture_size);
const auto layers = std::max<size_t>(1U, scene.textures.size());
std::vector<uint32_t> pixels(static_cast<size_t>(size) * size * layers, 0xFFFFFFFFU);
for (size_t layer = 0; layer < scene.textures.size(); ++layer) {
const auto &texture = scene.textures[layer];
if (texture.empty()) continue;
// The scene already resized every texture to `texture_size`.
for (uint32_t y = 0; y < std::min(size, texture.height()); ++y) {
for (uint32_t x = 0; x < std::min(size, texture.width()); ++x) {
pixels[(static_cast<size_t>(layer) * size + y) * size + x] = texture.data()[static_cast<size_t>(y) * texture.width() + x];
}
}
}
return this->make_gpu_image(object_texture_, size, size, VK_FORMAT_B8G8R8A8_UNORM, pixels.data(),
static_cast<VkDeviceSize>(pixels.size()) * 4U, this->mip_count(size), VK_FILTER_LINEAR, VK_SAMPLER_MIPMAP_MODE_LINEAR,
static_cast<uint32_t>(layers), VK_SAMPLER_ADDRESS_MODE_CLAMP_TO_EDGE);
}
/** Full mip chain length for a square texture. */
[[nodiscard]] static auto mip_count(uint32_t size) -> uint32_t {
uint32_t mips = 1U;
while (size > 1U) {
size >>= 1U;
++mips;
}
return mips;
}
[[nodiscard]] auto create_object_pipeline() -> bool {
VkPushConstantRange push_range{};
push_range.stageFlags = VK_SHADER_STAGE_VERTEX_BIT | VK_SHADER_STAGE_FRAGMENT_BIT;
push_range.size = sizeof(float) * 20U;
VkPipelineLayoutCreateInfo layout_info{};
layout_info.sType = VK_STRUCTURE_TYPE_PIPELINE_LAYOUT_CREATE_INFO;
layout_info.setLayoutCount = 1U;
layout_info.pSetLayouts = &descriptor_layout_; // reused: one combined image sampler
layout_info.pushConstantRangeCount = 1U;
layout_info.pPushConstantRanges = &push_range;
if (!detail::check(vkCreatePipelineLayout(device_, &layout_info, nullptr, &object_pipeline_layout_), "vkCreatePipelineLayout(object)")) return false;
VkShaderModule vertex = VK_NULL_HANDLE;
VkShaderModule fragment = VK_NULL_HANDLE;
if (!this->make_shader_module(ra3_shaders::object_vert_spv, ra3_shaders::object_vert_spv_words, vertex)) return false;
if (!this->make_shader_module(ra3_shaders::object_frag_spv, ra3_shaders::object_frag_spv_words, fragment)) return false;
VkPipelineShaderStageCreateInfo stages[2]{};
stages[0].sType = VK_STRUCTURE_TYPE_PIPELINE_SHADER_STAGE_CREATE_INFO;
stages[0].stage = VK_SHADER_STAGE_VERTEX_BIT;
stages[0].module = vertex;
stages[0].pName = "main";
stages[1].sType = VK_STRUCTURE_TYPE_PIPELINE_SHADER_STAGE_CREATE_INFO;
stages[1].stage = VK_SHADER_STAGE_FRAGMENT_BIT;
stages[1].module = fragment;
stages[1].pName = "main";
VkVertexInputBindingDescription binding{};
binding.binding = 0U;
binding.stride = sizeof(ra3::models::vertex);
binding.inputRate = VK_VERTEX_INPUT_RATE_VERTEX;
VkVertexInputAttributeDescription attributes[4]{};
attributes[0] = {0U, 0U, VK_FORMAT_R32G32B32_SFLOAT, 0U};
attributes[1] = {1U, 0U, VK_FORMAT_R32G32B32_SFLOAT, 12U};
attributes[2] = {2U, 0U, VK_FORMAT_R32G32_SFLOAT, 24U};
attributes[3] = {3U, 0U, VK_FORMAT_R32_SFLOAT, 32U};
static_assert(sizeof(ra3::models::vertex) == 36U, "object vertex layout changed");
VkPipelineVertexInputStateCreateInfo vertex_input{};
vertex_input.sType = VK_STRUCTURE_TYPE_PIPELINE_VERTEX_INPUT_STATE_CREATE_INFO;
vertex_input.vertexBindingDescriptionCount = 1U;
vertex_input.pVertexBindingDescriptions = &binding;
vertex_input.vertexAttributeDescriptionCount = 4U;
vertex_input.pVertexAttributeDescriptions = attributes;
VkPipelineInputAssemblyStateCreateInfo assembly{};
assembly.sType = VK_STRUCTURE_TYPE_PIPELINE_INPUT_ASSEMBLY_STATE_CREATE_INFO;
assembly.topology = VK_PRIMITIVE_TOPOLOGY_TRIANGLE_LIST;
VkPipelineViewportStateCreateInfo viewport{};
viewport.sType = VK_STRUCTURE_TYPE_PIPELINE_VIEWPORT_STATE_CREATE_INFO;
viewport.viewportCount = 1U;
viewport.scissorCount = 1U;
VkPipelineRasterizationStateCreateInfo raster{};
raster.sType = VK_STRUCTURE_TYPE_PIPELINE_RASTERIZATION_STATE_CREATE_INFO;
raster.polygonMode = VK_POLYGON_MODE_FILL;
raster.cullMode = VK_CULL_MODE_NONE;
raster.frontFace = VK_FRONT_FACE_COUNTER_CLOCKWISE;
raster.lineWidth = 1.0F;
// Ground decals (sidewalks/roads/deck pieces) sit exactly on the
// terrain; a small negative depth bias keeps them from z-fighting.
raster.depthBiasEnable = VK_TRUE;
raster.depthBiasConstantFactor = -1.0F;
raster.depthBiasSlopeFactor = -1.0F;
VkPipelineMultisampleStateCreateInfo multisample{};
multisample.sType = VK_STRUCTURE_TYPE_PIPELINE_MULTISAMPLE_STATE_CREATE_INFO;
multisample.rasterizationSamples = VK_SAMPLE_COUNT_1_BIT;
VkPipelineDepthStencilStateCreateInfo depth_stencil{};
depth_stencil.sType = VK_STRUCTURE_TYPE_PIPELINE_DEPTH_STENCIL_STATE_CREATE_INFO;
depth_stencil.depthTestEnable = VK_TRUE;
depth_stencil.depthWriteEnable = VK_TRUE;
depth_stencil.depthCompareOp = VK_COMPARE_OP_LESS_OR_EQUAL;
VkPipelineColorBlendAttachmentState blend_attachment{};
blend_attachment.colorWriteMask = VK_COLOR_COMPONENT_R_BIT | VK_COLOR_COMPONENT_G_BIT | VK_COLOR_COMPONENT_B_BIT | VK_COLOR_COMPONENT_A_BIT;
blend_attachment.blendEnable = VK_FALSE;
VkPipelineColorBlendStateCreateInfo blend{};
blend.sType = VK_STRUCTURE_TYPE_PIPELINE_COLOR_BLEND_STATE_CREATE_INFO;
blend.attachmentCount = 1U;
blend.pAttachments = &blend_attachment;
const VkDynamicState dynamic_states[] = {VK_DYNAMIC_STATE_VIEWPORT, VK_DYNAMIC_STATE_SCISSOR};
VkPipelineDynamicStateCreateInfo dynamic{};
dynamic.sType = VK_STRUCTURE_TYPE_PIPELINE_DYNAMIC_STATE_CREATE_INFO;
dynamic.dynamicStateCount = 2U;
dynamic.pDynamicStates = dynamic_states;
VkGraphicsPipelineCreateInfo pipeline_info{};
pipeline_info.sType = VK_STRUCTURE_TYPE_GRAPHICS_PIPELINE_CREATE_INFO;
pipeline_info.stageCount = 2U;
pipeline_info.pStages = stages;
pipeline_info.pVertexInputState = &vertex_input;
pipeline_info.pInputAssemblyState = &assembly;
pipeline_info.pViewportState = &viewport;
pipeline_info.pRasterizationState = &raster;
pipeline_info.pMultisampleState = &multisample;
pipeline_info.pDepthStencilState = &depth_stencil;
pipeline_info.pColorBlendState = &blend;
pipeline_info.pDynamicState = &dynamic;
pipeline_info.layout = object_pipeline_layout_;
pipeline_info.renderPass = render_pass_;
pipeline_info.subpass = 0U;
const auto created = detail::check(vkCreateGraphicsPipelines(device_, VK_NULL_HANDLE, 1U, &pipeline_info, nullptr, &object_pipeline_),
"vkCreateGraphicsPipelines(object)");
vkDestroyShaderModule(device_, vertex, nullptr);
vkDestroyShaderModule(device_, fragment, nullptr);
return created;
}
[[nodiscard]] auto create_object_descriptors() -> bool {
if (object_texture_.view == VK_NULL_HANDLE) return false;
VkDescriptorSetAllocateInfo set_info{};
set_info.sType = VK_STRUCTURE_TYPE_DESCRIPTOR_SET_ALLOCATE_INFO;
set_info.descriptorPool = descriptor_pool_;
set_info.descriptorSetCount = 1U;
set_info.pSetLayouts = &descriptor_layout_;
if (!detail::check(vkAllocateDescriptorSets(device_, &set_info, &object_set_), "vkAllocateDescriptorSets(object)")) return false;
VkDescriptorImageInfo info{};
info.imageLayout = VK_IMAGE_LAYOUT_SHADER_READ_ONLY_OPTIMAL;
info.imageView = object_texture_.view;
info.sampler = object_texture_.sampler;
VkWriteDescriptorSet write{};
write.sType = VK_STRUCTURE_TYPE_WRITE_DESCRIPTOR_SET;
write.dstSet = object_set_;
write.dstBinding = 0U;
write.descriptorCount = 1U;
write.descriptorType = VK_DESCRIPTOR_TYPE_COMBINED_IMAGE_SAMPLER;
write.pImageInfo = &info;
vkUpdateDescriptorSets(device_, 1U, &write, 0U, nullptr);
return true;
}
[[nodiscard]] auto draw_terrain(const ra3::terrain::gpu_terrain &terrain, const ra3::render::camera3d &camera, float time_s, float aspect) -> bool { [[nodiscard]] auto draw_terrain(const ra3::terrain::gpu_terrain &terrain, const ra3::render::camera3d &camera, float time_s, float aspect) -> bool {
vkWaitForFences(device_, 1U, &in_flight_[current_frame_], VK_TRUE, UINT64_MAX); vkWaitForFences(device_, 1U, &in_flight_[current_frame_], VK_TRUE, UINT64_MAX);
@@ -754,13 +958,16 @@ export namespace ra3::vulkan {
VkClearValue clear{}; VkClearValue clear{};
clear.color = {{0.45F, 0.55F, 0.70F, 1.0F}}; clear.color = {{0.45F, 0.55F, 0.70F, 1.0F}};
VkClearValue depth_clear{};
depth_clear.depthStencil = {1.0F, 0U};
const VkClearValue clears[2] = {clear, depth_clear};
VkRenderPassBeginInfo render_pass_begin{}; VkRenderPassBeginInfo render_pass_begin{};
render_pass_begin.sType = VK_STRUCTURE_TYPE_RENDER_PASS_BEGIN_INFO; render_pass_begin.sType = VK_STRUCTURE_TYPE_RENDER_PASS_BEGIN_INFO;
render_pass_begin.renderPass = render_pass_; render_pass_begin.renderPass = render_pass_;
render_pass_begin.framebuffer = framebuffers_[image_index]; render_pass_begin.framebuffer = framebuffers_[image_index];
render_pass_begin.renderArea = {{0U, 0U}, swapchain_extent_}; render_pass_begin.renderArea = {{0U, 0U}, swapchain_extent_};
render_pass_begin.clearValueCount = 1U; render_pass_begin.clearValueCount = 2U;
render_pass_begin.pClearValues = &clear; render_pass_begin.pClearValues = clears;
vkCmdBeginRenderPass(cmd, &render_pass_begin, VK_SUBPASS_CONTENTS_INLINE); vkCmdBeginRenderPass(cmd, &render_pass_begin, VK_SUBPASS_CONTENTS_INLINE);
VkViewport viewport{0.0F, 0.0F, static_cast<float>(swapchain_extent_.width), static_cast<float>(swapchain_extent_.height), 0.0F, 1.0F}; VkViewport viewport{0.0F, 0.0F, static_cast<float>(swapchain_extent_.width), static_cast<float>(swapchain_extent_.height), 0.0F, 1.0F};
@@ -793,6 +1000,49 @@ export namespace ra3::vulkan {
vkCmdPushConstants(cmd, terrain_pipeline_layout_, VK_SHADER_STAGE_FRAGMENT_BIT, 0U, sizeof(push), push); vkCmdPushConstants(cmd, terrain_pipeline_layout_, VK_SHADER_STAGE_FRAGMENT_BIT, 0U, sizeof(push), push);
vkCmdDraw(cmd, 3U, 1U, 0U, 0U); vkCmdDraw(cmd, 3U, 1U, 0U, 0U);
// Static-map models (buildings/props) depth-test against the terrain.
if (objects_ready_ && object_index_count_ > 0U) {
const auto z_scale = terrain.z_scale;
const auto world_w = static_cast<float>(terrain.width) * 10.0F;
const auto world_h = static_cast<float>(terrain.height) * 10.0F;
const auto sample = [&](float wx, float wy) -> float {
if (wx < 0.0F || wy < 0.0F || wx >= world_w || wy >= world_h) return -1.0e9F;
const auto cx = std::min(terrain.width - 1U, static_cast<uint32_t>(wx / 10.0F));
const auto cy = std::min(terrain.height - 1U, static_cast<uint32_t>((world_h - wy) / 10.0F));
return static_cast<float>(terrain.heights[static_cast<size_t>(cy) * terrain.width + cx]) * z_scale;
};
float target_z = sample(camera.target_x, camera.target_y);
if (target_z < -1.0e8F) target_z = 0.0F;
const auto pitch = std::clamp(camera.pitch, 0.15F, 1.45F);
const auto fov = std::clamp(camera.fov, 0.3F, 1.4F);
const auto cp = std::cos(pitch);
const float fwd[3] = {cp * std::sin(camera.yaw), cp * std::cos(camera.yaw), -std::sin(pitch)};
float right[3] = {fwd[1], -fwd[0], 0.0F};
const auto rl = std::sqrt(right[0] * right[0] + right[1] * right[1]);
right[0] /= rl;
right[1] /= rl;
float up[3] = {right[1] * fwd[2], -right[0] * fwd[2], right[0] * fwd[1] - right[1] * fwd[0]};
const auto ul = std::sqrt(up[0] * up[0] + up[1] * up[1] + up[2] * up[2]);
up[0] /= ul;
up[1] /= ul;
up[2] /= ul;
const auto dist = camera.height / std::sin(pitch);
const float cam[3] = {camera.target_x - fwd[0] * dist, camera.target_y - fwd[1] * dist, target_z + camera.height - fwd[2] * dist};
const auto th = std::tan(fov * 0.5F);
const float obj_push[20] = {cam[0], cam[1], cam[2], 0.0F,
fwd[0], fwd[1], fwd[2], 0.0F,
right[0], right[1], right[2], th,
up[0], up[1], up[2], th * aspect,
0.45F, 0.35F, 0.82F, 0.38F};
vkCmdBindPipeline(cmd, VK_PIPELINE_BIND_POINT_GRAPHICS, object_pipeline_);
vkCmdBindDescriptorSets(cmd, VK_PIPELINE_BIND_POINT_GRAPHICS, object_pipeline_layout_, 0U, 1U, &object_set_, 0U, nullptr);
vkCmdPushConstants(cmd, object_pipeline_layout_, VK_SHADER_STAGE_VERTEX_BIT | VK_SHADER_STAGE_FRAGMENT_BIT, 0U, sizeof(obj_push), obj_push);
const VkDeviceSize offset = 0U;
vkCmdBindVertexBuffers(cmd, 0U, 1U, &object_vertex_buffer_, &offset);
vkCmdBindIndexBuffer(cmd, object_index_buffer_, 0U, VK_INDEX_TYPE_UINT32);
vkCmdDrawIndexed(cmd, object_index_count_, 1U, 0U, 0, 0U);
}
// Overlays (top-left FPS label, bottom-right minimap) use the image // Overlays (top-left FPS label, bottom-right minimap) use the image
// pipeline with alpha blending, drawn into the same render pass. // pipeline with alpha blending, drawn into the same render pass.
const auto draw_overlay = [&](const gpu_image &overlay, VkDescriptorSet set, uint32_t w, uint32_t h, float x, float y) { const auto draw_overlay = [&](const gpu_image &overlay, VkDescriptorSet set, uint32_t w, uint32_t h, float x, float y) {
@@ -861,6 +1111,27 @@ export namespace ra3::vulkan {
terrain_set_ = VK_NULL_HANDLE; terrain_set_ = VK_NULL_HANDLE;
overlay_label_set_ = VK_NULL_HANDLE; overlay_label_set_ = VK_NULL_HANDLE;
overlay_minimap_set_ = VK_NULL_HANDLE; overlay_minimap_set_ = VK_NULL_HANDLE;
if (object_vertex_buffer_ != VK_NULL_HANDLE) vkDestroyBuffer(device_, object_vertex_buffer_, nullptr);
if (object_index_buffer_ != VK_NULL_HANDLE) vkDestroyBuffer(device_, object_index_buffer_, nullptr);
if (object_vertex_memory_ != VK_NULL_HANDLE) vkFreeMemory(device_, object_vertex_memory_, nullptr);
if (object_index_memory_ != VK_NULL_HANDLE) vkFreeMemory(device_, object_index_memory_, nullptr);
if (object_texture_.sampler != VK_NULL_HANDLE) vkDestroySampler(device_, object_texture_.sampler, nullptr);
if (object_texture_.view != VK_NULL_HANDLE) vkDestroyImageView(device_, object_texture_.view, nullptr);
if (object_texture_.image != VK_NULL_HANDLE) vkDestroyImage(device_, object_texture_.image, nullptr);
if (object_texture_.memory != VK_NULL_HANDLE) vkFreeMemory(device_, object_texture_.memory, nullptr);
if (object_pipeline_ != VK_NULL_HANDLE) vkDestroyPipeline(device_, object_pipeline_, nullptr);
if (object_pipeline_layout_ != VK_NULL_HANDLE) vkDestroyPipelineLayout(device_, object_pipeline_layout_, nullptr);
object_vertex_buffer_ = VK_NULL_HANDLE;
object_index_buffer_ = VK_NULL_HANDLE;
object_vertex_memory_ = VK_NULL_HANDLE;
object_index_memory_ = VK_NULL_HANDLE;
object_texture_ = {};
object_pipeline_ = VK_NULL_HANDLE;
object_pipeline_layout_ = VK_NULL_HANDLE;
object_set_ = VK_NULL_HANDLE;
object_index_count_ = 0U;
objects_ready_ = false;
} }
@@ -1009,23 +1280,78 @@ export namespace ra3::vulkan {
} }
framebuffers_.resize(actual); framebuffers_.resize(actual);
for (uint32_t i = 0; i < actual; ++i) { return true;
VkImageView attachments[] = {swapchain_views_[i]}; }
/** Create one color+depth framebuffer per swapchain image (needs `render_pass_`). */
[[nodiscard]] auto create_framebuffers() -> bool {
this->create_depth_resources();
for (size_t i = 0; i < framebuffers_.size(); ++i) {
VkImageView attachments[] = {swapchain_views_[i], depth_views_[i]};
VkFramebufferCreateInfo framebuffer_info{}; VkFramebufferCreateInfo framebuffer_info{};
framebuffer_info.sType = VK_STRUCTURE_TYPE_FRAMEBUFFER_CREATE_INFO; framebuffer_info.sType = VK_STRUCTURE_TYPE_FRAMEBUFFER_CREATE_INFO;
framebuffer_info.renderPass = render_pass_; framebuffer_info.renderPass = render_pass_;
framebuffer_info.attachmentCount = 1U; framebuffer_info.attachmentCount = 2U;
framebuffer_info.pAttachments = attachments; framebuffer_info.pAttachments = attachments;
framebuffer_info.width = extent.width; framebuffer_info.width = swapchain_extent_.width;
framebuffer_info.height = extent.height; framebuffer_info.height = swapchain_extent_.height;
framebuffer_info.layers = 1U; framebuffer_info.layers = 1U;
if (!detail::check(vkCreateFramebuffer(device_, &framebuffer_info, nullptr, &framebuffers_[i]), "vkCreateFramebuffer")) return false; if (!detail::check(vkCreateFramebuffer(device_, &framebuffer_info, nullptr, &framebuffers_[i]), "vkCreateFramebuffer")) return false;
} }
return true; return true;
} }
/** Allocate a depth image + view per swapchain image (static-map models depth-test against the terrain). */
auto create_depth_resources() -> void {
this->destroy_depth_resources();
depth_images_.resize(swapchain_images_.size());
depth_memories_.resize(swapchain_images_.size());
depth_views_.resize(swapchain_images_.size());
for (size_t i = 0; i < swapchain_images_.size(); ++i) {
VkImageCreateInfo image_info{};
image_info.sType = VK_STRUCTURE_TYPE_IMAGE_CREATE_INFO;
image_info.imageType = VK_IMAGE_TYPE_2D;
image_info.extent = {swapchain_extent_.width, swapchain_extent_.height, 1U};
image_info.mipLevels = 1U;
image_info.arrayLayers = 1U;
image_info.format = VK_FORMAT_D32_SFLOAT;
image_info.tiling = VK_IMAGE_TILING_OPTIMAL;
image_info.initialLayout = VK_IMAGE_LAYOUT_UNDEFINED;
image_info.usage = VK_IMAGE_USAGE_DEPTH_STENCIL_ATTACHMENT_BIT;
image_info.samples = VK_SAMPLE_COUNT_1_BIT;
image_info.sharingMode = VK_SHARING_MODE_EXCLUSIVE;
if (vkCreateImage(device_, &image_info, nullptr, &depth_images_[i]) != VK_SUCCESS) return;
VkMemoryRequirements requirements{};
vkGetImageMemoryRequirements(device_, depth_images_[i], &requirements);
VkMemoryAllocateInfo allocate{};
allocate.sType = VK_STRUCTURE_TYPE_MEMORY_ALLOCATE_INFO;
allocate.allocationSize = requirements.size;
allocate.memoryTypeIndex = this->memory_type(requirements.memoryTypeBits, VK_MEMORY_PROPERTY_DEVICE_LOCAL_BIT);
if (vkAllocateMemory(device_, &allocate, nullptr, &depth_memories_[i]) != VK_SUCCESS) return;
vkBindImageMemory(device_, depth_images_[i], depth_memories_[i], 0);
VkImageViewCreateInfo view_info{};
view_info.sType = VK_STRUCTURE_TYPE_IMAGE_VIEW_CREATE_INFO;
view_info.image = depth_images_[i];
view_info.viewType = VK_IMAGE_VIEW_TYPE_2D;
view_info.format = VK_FORMAT_D32_SFLOAT;
view_info.subresourceRange = {VK_IMAGE_ASPECT_DEPTH_BIT, 0U, 1U, 0U, 1U};
if (vkCreateImageView(device_, &view_info, nullptr, &depth_views_[i]) != VK_SUCCESS) return;
}
}
auto destroy_depth_resources() -> void {
if (device_ == VK_NULL_HANDLE) return;
for (const auto view: depth_views_) vkDestroyImageView(device_, view, nullptr);
for (const auto image: depth_images_) vkDestroyImage(device_, image, nullptr);
for (const auto memory: depth_memories_) vkFreeMemory(device_, memory, nullptr);
depth_views_.clear();
depth_images_.clear();
depth_memories_.clear();
}
auto cleanup_swapchain() -> void { auto cleanup_swapchain() -> void {
for (const auto framebuffer: framebuffers_) vkDestroyFramebuffer(device_, framebuffer, nullptr); for (const auto framebuffer: framebuffers_) vkDestroyFramebuffer(device_, framebuffer, nullptr);
this->destroy_depth_resources();
for (const auto view: swapchain_views_) vkDestroyImageView(device_, view, nullptr); for (const auto view: swapchain_views_) vkDestroyImageView(device_, view, nullptr);
framebuffers_.clear(); framebuffers_.clear();
swapchain_views_.clear(); swapchain_views_.clear();
@@ -1041,7 +1367,7 @@ export namespace ra3::vulkan {
if (width == 0 || height == 0) return false; if (width == 0 || height == 0) return false;
vkDeviceWaitIdle(device_); vkDeviceWaitIdle(device_);
this->cleanup_swapchain(); this->cleanup_swapchain();
return this->create_swapchain(); return this->create_swapchain() && this->create_framebuffers();
} }
// ---- pipeline -------------------------------------------------------- // ---- pipeline --------------------------------------------------------
@@ -1058,22 +1384,36 @@ export namespace ra3::vulkan {
color.finalLayout = VK_IMAGE_LAYOUT_PRESENT_SRC_KHR; color.finalLayout = VK_IMAGE_LAYOUT_PRESENT_SRC_KHR;
VkAttachmentReference color_ref{0U, VK_IMAGE_LAYOUT_COLOR_ATTACHMENT_OPTIMAL}; VkAttachmentReference color_ref{0U, VK_IMAGE_LAYOUT_COLOR_ATTACHMENT_OPTIMAL};
VkAttachmentDescription depth{};
depth.format = VK_FORMAT_D32_SFLOAT;
depth.samples = VK_SAMPLE_COUNT_1_BIT;
depth.loadOp = VK_ATTACHMENT_LOAD_OP_CLEAR;
depth.storeOp = VK_ATTACHMENT_STORE_OP_DONT_CARE;
depth.stencilLoadOp = VK_ATTACHMENT_LOAD_OP_DONT_CARE;
depth.stencilStoreOp = VK_ATTACHMENT_STORE_OP_DONT_CARE;
depth.initialLayout = VK_IMAGE_LAYOUT_UNDEFINED;
depth.finalLayout = VK_IMAGE_LAYOUT_DEPTH_STENCIL_ATTACHMENT_OPTIMAL;
VkAttachmentReference depth_ref{1U, VK_IMAGE_LAYOUT_DEPTH_STENCIL_ATTACHMENT_OPTIMAL};
VkSubpassDescription subpass{}; VkSubpassDescription subpass{};
subpass.pipelineBindPoint = VK_PIPELINE_BIND_POINT_GRAPHICS; subpass.pipelineBindPoint = VK_PIPELINE_BIND_POINT_GRAPHICS;
subpass.colorAttachmentCount = 1U; subpass.colorAttachmentCount = 1U;
subpass.pColorAttachments = &color_ref; subpass.pColorAttachments = &color_ref;
subpass.pDepthStencilAttachment = &depth_ref;
VkSubpassDependency dependency{}; VkSubpassDependency dependency{};
dependency.srcSubpass = VK_SUBPASS_EXTERNAL; dependency.srcSubpass = VK_SUBPASS_EXTERNAL;
dependency.dstSubpass = 0U; dependency.dstSubpass = 0U;
dependency.srcStageMask = VK_PIPELINE_STAGE_COLOR_ATTACHMENT_OUTPUT_BIT; dependency.srcStageMask = VK_PIPELINE_STAGE_COLOR_ATTACHMENT_OUTPUT_BIT | VK_PIPELINE_STAGE_EARLY_FRAGMENT_TESTS_BIT;
dependency.dstStageMask = VK_PIPELINE_STAGE_COLOR_ATTACHMENT_OUTPUT_BIT; dependency.dstStageMask = VK_PIPELINE_STAGE_COLOR_ATTACHMENT_OUTPUT_BIT | VK_PIPELINE_STAGE_EARLY_FRAGMENT_TESTS_BIT;
dependency.dstAccessMask = VK_ACCESS_COLOR_ATTACHMENT_WRITE_BIT; dependency.dstAccessMask = VK_ACCESS_COLOR_ATTACHMENT_WRITE_BIT | VK_ACCESS_DEPTH_STENCIL_ATTACHMENT_WRITE_BIT;
const VkAttachmentDescription attachments[] = {color, depth};
VkRenderPassCreateInfo render_pass_info{}; VkRenderPassCreateInfo render_pass_info{};
render_pass_info.sType = VK_STRUCTURE_TYPE_RENDER_PASS_CREATE_INFO; render_pass_info.sType = VK_STRUCTURE_TYPE_RENDER_PASS_CREATE_INFO;
render_pass_info.attachmentCount = 1U; render_pass_info.attachmentCount = 2U;
render_pass_info.pAttachments = &color; render_pass_info.pAttachments = attachments;
render_pass_info.subpassCount = 1U; render_pass_info.subpassCount = 1U;
render_pass_info.pSubpasses = &subpass; render_pass_info.pSubpasses = &subpass;
render_pass_info.dependencyCount = 1U; render_pass_info.dependencyCount = 1U;
@@ -1211,10 +1551,10 @@ export namespace ra3::vulkan {
command_buffers_.resize(frames_in_flight); command_buffers_.resize(frames_in_flight);
if (!detail::check(vkAllocateCommandBuffers(device_, &allocate, command_buffers_.data()), "vkAllocateCommandBuffers")) return false; if (!detail::check(vkAllocateCommandBuffers(device_, &allocate, command_buffers_.data()), "vkAllocateCommandBuffers")) return false;
VkDescriptorPoolSize pool_size{VK_DESCRIPTOR_TYPE_COMBINED_IMAGE_SAMPLER, 4U}; VkDescriptorPoolSize pool_size{VK_DESCRIPTOR_TYPE_COMBINED_IMAGE_SAMPLER, 8U};
VkDescriptorPoolCreateInfo descriptor_pool_info{}; VkDescriptorPoolCreateInfo descriptor_pool_info{};
descriptor_pool_info.sType = VK_STRUCTURE_TYPE_DESCRIPTOR_POOL_CREATE_INFO; descriptor_pool_info.sType = VK_STRUCTURE_TYPE_DESCRIPTOR_POOL_CREATE_INFO;
descriptor_pool_info.maxSets = 4U; descriptor_pool_info.maxSets = 8U;
descriptor_pool_info.poolSizeCount = 1U; descriptor_pool_info.poolSizeCount = 1U;
descriptor_pool_info.pPoolSizes = &pool_size; descriptor_pool_info.pPoolSizes = &pool_size;
if (!detail::check(vkCreateDescriptorPool(device_, &descriptor_pool_info, nullptr, &descriptor_pool_), "vkCreateDescriptorPool")) return false; if (!detail::check(vkCreateDescriptorPool(device_, &descriptor_pool_info, nullptr, &descriptor_pool_), "vkCreateDescriptorPool")) return false;
@@ -1307,13 +1647,16 @@ export namespace ra3::vulkan {
VkClearValue clear{}; VkClearValue clear{};
clear.color = {{0.05F, 0.06F, 0.08F, 1.0F}}; clear.color = {{0.05F, 0.06F, 0.08F, 1.0F}};
VkClearValue depth_clear{};
depth_clear.depthStencil = {1.0F, 0U};
const VkClearValue clears[2] = {clear, depth_clear};
VkRenderPassBeginInfo render_pass_begin{}; VkRenderPassBeginInfo render_pass_begin{};
render_pass_begin.sType = VK_STRUCTURE_TYPE_RENDER_PASS_BEGIN_INFO; render_pass_begin.sType = VK_STRUCTURE_TYPE_RENDER_PASS_BEGIN_INFO;
render_pass_begin.renderPass = render_pass_; render_pass_begin.renderPass = render_pass_;
render_pass_begin.framebuffer = framebuffers_[image_index]; render_pass_begin.framebuffer = framebuffers_[image_index];
render_pass_begin.renderArea = {{0U, 0U}, swapchain_extent_}; render_pass_begin.renderArea = {{0U, 0U}, swapchain_extent_};
render_pass_begin.clearValueCount = 1U; render_pass_begin.clearValueCount = 2U;
render_pass_begin.pClearValues = &clear; render_pass_begin.pClearValues = clears;
vkCmdBeginRenderPass(cmd, &render_pass_begin, VK_SUBPASS_CONTENTS_INLINE); vkCmdBeginRenderPass(cmd, &render_pass_begin, VK_SUBPASS_CONTENTS_INLINE);
VkViewport viewport{0.0F, 0.0F, static_cast<float>(swapchain_extent_.width), static_cast<float>(swapchain_extent_.height), 0.0F, 1.0F}; VkViewport viewport{0.0F, 0.0F, static_cast<float>(swapchain_extent_.width), static_cast<float>(swapchain_extent_.height), 0.0F, 1.0F};
@@ -1433,6 +1776,9 @@ export namespace ra3::vulkan {
std::vector<VkImage> swapchain_images_; std::vector<VkImage> swapchain_images_;
std::vector<VkImageView> swapchain_views_; std::vector<VkImageView> swapchain_views_;
std::vector<VkFramebuffer> framebuffers_; std::vector<VkFramebuffer> framebuffers_;
std::vector<VkImage> depth_images_;
std::vector<VkDeviceMemory> depth_memories_;
std::vector<VkImageView> depth_views_;
VkRenderPass render_pass_ = VK_NULL_HANDLE; VkRenderPass render_pass_ = VK_NULL_HANDLE;
VkDescriptorSetLayout descriptor_layout_ = VK_NULL_HANDLE; VkDescriptorSetLayout descriptor_layout_ = VK_NULL_HANDLE;
VkPipelineLayout pipeline_layout_ = VK_NULL_HANDLE; VkPipelineLayout pipeline_layout_ = VK_NULL_HANDLE;
@@ -1464,6 +1810,16 @@ export namespace ra3::vulkan {
VkPipeline terrain_pipeline_ = VK_NULL_HANDLE; VkPipeline terrain_pipeline_ = VK_NULL_HANDLE;
VkDescriptorPool terrain_pool_ = VK_NULL_HANDLE; VkDescriptorPool terrain_pool_ = VK_NULL_HANDLE;
VkDescriptorSet terrain_set_ = VK_NULL_HANDLE; VkDescriptorSet terrain_set_ = VK_NULL_HANDLE;
VkBuffer object_vertex_buffer_ = VK_NULL_HANDLE;
VkDeviceMemory object_vertex_memory_ = VK_NULL_HANDLE;
VkBuffer object_index_buffer_ = VK_NULL_HANDLE;
VkDeviceMemory object_index_memory_ = VK_NULL_HANDLE;
uint32_t object_index_count_ = 0U;
gpu_image object_texture_;
VkPipelineLayout object_pipeline_layout_ = VK_NULL_HANDLE;
VkPipeline object_pipeline_ = VK_NULL_HANDLE;
VkDescriptorSet object_set_ = VK_NULL_HANDLE;
bool objects_ready_ = false;
std::vector<VkSemaphore> image_available_; std::vector<VkSemaphore> image_available_;
std::vector<VkSemaphore> render_finished_; std::vector<VkSemaphore> render_finished_;
std::vector<VkFence> in_flight_; std::vector<VkFence> in_flight_;
+78
View File
@@ -222,6 +222,84 @@ auto main() -> int {
check(names.lookup("map_mp_2_feasel4") == "Battlebase Beta", "CSF map names decode (byte-XOR 0xFF)"); check(names.lookup("map_mp_2_feasel4") == "Battlebase Beta", "CSF map names decode (byte-XOR 0xFF)");
check(names.lookup("unknown_map") == "unknown_map", "an unknown id falls back to itself"); check(names.lookup("unknown_map") == "unknown_map", "an unknown id falls back to itself");
// Map objects (`ObjectsList`): decode a synthetic one-object chunk.
const auto put16 = [](std::vector<core::uint8> &out, std::uint16_t v) {
out.push_back(static_cast<core::uint8>(v));
out.push_back(static_cast<core::uint8>(v >> 8U));
};
const auto put32 = [](std::vector<core::uint8> &out, std::uint32_t v) {
for (int i = 0; i < 4; ++i) out.push_back(static_cast<core::uint8>(v >> (8 * i)));
};
const auto putf = [&put32](std::vector<core::uint8> &out, float f) {
std::uint32_t bits = 0;
std::memcpy(&bits, &f, sizeof(bits));
put32(out, bits);
};
std::vector<core::uint8> object_data;
putf(object_data, 100.0F);
putf(object_data, 200.0F);
putf(object_data, 0.0F);
putf(object_data, 0.5F);
put32(object_data, 0U); // road type
const std::string object_type = "BB_TEST";
put16(object_data, static_cast<std::uint16_t>(object_type.size()));
object_data.insert(object_data.end(), object_type.begin(), object_type.end());
put16(object_data, 0U); // no properties
std::vector<core::uint8> object_asset;
put32(object_asset, 1U); // asset index
put16(object_asset, 1U); // version
put32(object_asset, static_cast<std::uint32_t>(object_data.size()));
object_asset.insert(object_asset.end(), object_data.begin(), object_data.end());
std::vector<core::uint8> ckmp;
ckmp.insert(ckmp.end(), {'C', 'k', 'M', 'p'});
put32(ckmp, 1U); // one asset name
const std::string chunk_name = "ObjectsList";
ckmp.push_back(static_cast<core::uint8>(chunk_name.size()));
ckmp.insert(ckmp.end(), chunk_name.begin(), chunk_name.end());
put32(ckmp, 1U); // name index
put32(ckmp, 1U); // chunk index
put16(ckmp, 3U); // chunk version
put32(ckmp, static_cast<std::uint32_t>(object_asset.size()));
ckmp.insert(ckmp.end(), object_asset.begin(), object_asset.end());
const auto objects = map::parse_objects(ckmp);
check(objects.size() == 1U, "ObjectsList parses one object");
check(!objects.empty() && objects[0].type == "BB_TEST", "object type-name decodes");
check(!objects.empty() && objects[0].x == 100.0F && objects[0].y == 200.0F && objects[0].angle == 0.5F, "object position/angle decode");
// DDS: a 1x1 uncompressed RGB32 image decodes with correct channels.
std::vector<core::uint8> dds(128U, 0U);
dds[0] = 'D';
dds[1] = 'D';
dds[2] = 'S';
dds[3] = ' ';
const auto put_dds = [&dds](std::size_t off, std::uint32_t v) {
for (int i = 0; i < 4; ++i) dds[off + static_cast<std::size_t>(i)] = static_cast<core::uint8>(v >> (8 * i));
};
put_dds(4U, 124U);
put_dds(8U, 0x1U); // flags: DDSD_CAPS|... (unused)
put_dds(12U, 1U); // height
put_dds(16U, 1U); // width
put_dds(76U, 32U); // pixel format size
put_dds(80U, 0x40U); // DDPF_RGB
put_dds(84U, 0U); // no fourcc
put_dds(88U, 32U); // bits per pixel
put_dds(92U, 0x00FF0000U);
put_dds(96U, 0x0000FF00U);
put_dds(100U, 0x000000FFU);
put_dds(104U, 0xFF000000U);
dds.push_back(0x00U); // B
dds.push_back(0x00U); // G
dds.push_back(0xFFU); // R
dds.push_back(0xFFU); // A
const auto decoded_dds = models::decode_dds(dds);
check(decoded_dds.width() == 1U && decoded_dds.height() == 1U, "DDS dimensions decode");
check(!decoded_dds.empty() && decoded_dds.data()[0] == render::argb(255, 0, 0), "DDS RGB32 channels map to ARGB");
check(models::decode_dds(std::vector<core::uint8>{1U, 2U, 3U}).empty(), "a non-DDS payload yields no image");
if (failures == 0) { if (failures == 0) {
std::puts("ra3_tests: OK"); std::puts("ra3_tests: OK");
} }