From 70f35d8382cfcc5c2bc6e42bea7ec1e81940778f Mon Sep 17 00:00:00 2001 From: EnderTheCoder Date: Tue, 29 Sep 2026 18:08:11 +0800 Subject: [PATCH] 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. --- CMakeLists.txt | 17 +- README.md | 27 +- apps/openra3/main.cpp | 78 ++- docs/ARCHITECTURE.md | 22 +- docs/REVERSE_ENGINEERING.md | 123 ++++ shaders/compile.sh | 4 +- shaders/generated/object.frag.spv | Bin 0 -> 2712 bytes shaders/generated/object.vert.spv | Bin 0 -> 2812 bytes shaders/generated/terrain.frag.spv | Bin 23068 -> 23384 bytes shaders/object.frag | 36 ++ shaders/object.vert | 44 ++ shaders/terrain.frag | 12 +- src/map/ra3.map.cppm | 134 +++- src/models/ra3.models.cppm | 939 +++++++++++++++++++++++++++++ src/ra3.cppm | 1 + src/terrain/ra3.terrain.cppm | 167 ++++- src/vulkan/ra3.vulkan.sdl.cppm | 390 +++++++++++- tests/ra3_tests.cpp | 78 +++ 18 files changed, 2027 insertions(+), 45 deletions(-) create mode 100644 shaders/generated/object.frag.spv create mode 100644 shaders/generated/object.vert.spv create mode 100644 shaders/object.frag create mode 100644 shaders/object.vert create mode 100644 src/models/ra3.models.cppm diff --git a/CMakeLists.txt b/CMakeLists.txt index 0452a38..2a501d7 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -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) 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). 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/terrain.vert.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. set_property(DIRECTORY APPEND PROPERTY CMAKE_CONFIGURE_DEPENDS "${CMAKE_CURRENT_SOURCE_DIR}/shaders/generated/scene.vert.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.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_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) @@ -410,7 +423,7 @@ openra3_target_defaults(ra3_display) # --- umbrella ----------------------------------------------------------------- add_library(ra3 STATIC) 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) openra3_target_defaults(ra3) diff --git a/README.md b/README.md index 50e23fe..4b20249 100644 --- a/README.md +++ b/README.md @@ -18,7 +18,7 @@ Ghidra. ## 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, 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 @@ -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 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 -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 builds for **Linux** (clang + libc++) and cross-compiles to **Windows** (`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/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/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/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) | @@ -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 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 -never bleeds between tiles. Match state is overlaid (start markers in yellow, -player 0 in blue, player 1 in red). `--thumbnail` uses the old `_art.tga` -overview instead. +never bleeds between tiles. It also draws the **buildings and props the map +places**: the `ObjectsList` chunk is decoded into `(type, position, angle)` +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 `_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): diff --git a/apps/openra3/main.cpp b/apps/openra3/main.cpp index ec3e9c2..d4598d3 100644 --- a/apps/openra3/main.cpp +++ b/apps/openra3/main.cpp @@ -200,6 +200,79 @@ namespace { 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 { + std::error_code ec; + const auto consider = [&](const std::filesystem::path &dir) -> std::optional { + 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 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 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(x / ra3::terrain::cell_size)); + const auto cy = std::min(terrain.height - 1U, static_cast((world_h - y) / ra3::terrain::cell_size)); + return static_cast(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 `/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 { try { @@ -465,8 +538,9 @@ namespace { const bool offscreen = option_value(args, "--out").has_value() || has_flag(args, "--no-window"); bool gpu_shown = false; + const auto objects = build_object_scene(assets, picked->id, terrain, terrain_options, bytes); 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; options.title = scene.title; options.width = static_cast(width); @@ -486,7 +560,7 @@ namespace { } if (offscreen) { // 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; } else { // Windowed without a GPU: the cheap top-down raster. diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index eca8083..bbe0c4a 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -79,7 +79,7 @@ umbrella module re-exports the SDK; applications import `ra3` only. ``` 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 vendored `ender.log`) are the **seeds** of the 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)` - `[ ]` atlas + mip generation, gutter padding (GPU) `[~]` - `[ ]` async upload / streaming `(v0.6)` -- **F2 Models** `[ ]` `!!` - - `[ ]` W3D container parse (chunks, hierarchy, meshes) `(v0.6)` - - `[ ]` materials, shaders, texture references - - `[ ]` LOD sets, collision meshes +- **F2 Models** `[~]` + - `[x]` compiled `W3DMesh` decode (vertex buffer + D3D9 declaration + triangle list) from the BAB static/worldbuilder stream + - `[x]` embedded DDS textures (DXT1/3/5 + uncompressed RGB) → ARGB + - `[x]` `W3DHierarchy` decode + static bind-pose skinning (bone-space vertices) + - `[ ]` W3D container/hierarchy animation, LOD sets, collision meshes `!!` `(v0.6)` - **F3 Animation** `[ ]` `!!` - `[ ]` W3D animation chunks, bone poses `(v0.6)` - `[ ]` blend trees / transition animations @@ -533,8 +534,10 @@ Mirrors SAGE `GameEngine/Common`. Everything else speaks this. - **F3 Terrain render** `[~]` - `[x]` top-down software + GPU heightfield with blend ramp + gutter atlas - `[ ]` perspective terrain mesh, LOD, cliff, water `(v0.6)` -- **F4 Model render** `[ ]` `!!` - - `[ ]` W3D draw, skinning, materials, team colors `(v0.6)` +- **F4 Model render** `[~]` + - `[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)` - **F5 VFX** `[ ]` - `[ ]` 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)` - **F2 Terrain presentation** `[D]` - `[x]` GPU heightfield raymarch, mipmapped atlas, gutter, retail blend ramp -- **F3 Materials & pipelines** `[ ]` - - `[ ]` model/particle/HUD pipelines `(v0.6)` +- **F3 Materials & pipelines** `[~]` + - `[x]` static-model pipeline (vertex/index buffers, texture array, depth test) + - `[ ]` particle/HUD pipelines `(v0.6)` - **F4 Null fallback** `[D]` - `[x]` report failure when no Vulkan loader is present diff --git a/docs/REVERSE_ENGINEERING.md b/docs/REVERSE_ENGINEERING.md index c2fdd69..07dbbae 100644 --- a/docs/REVERSE_ENGINEERING.md +++ b/docs/REVERSE_ENGINEERING.md @@ -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 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\\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 `.`. Objects whose type is a `Road` template (the sidewalk/road +segments) are not meshes and are not drawn yet. + ### Map display names 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). - Cliff textures and the `CliffTextureMapping` UV remap (`CliffTextures` is 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` schema used to deserialise them. diff --git a/shaders/compile.sh b/shaders/compile.sh index 6706b7f..69e0642 100644 --- a/shaders/compile.sh +++ b/shaders/compile.sh @@ -25,9 +25,9 @@ compile() { 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.frag" "$OUT/$name.frag.spv" done -echo "wrote $OUT/{scene,terrain}.{vert,frag}.spv" +echo "wrote $OUT/{scene,terrain,object}.{vert,frag}.spv" diff --git a/shaders/generated/object.frag.spv b/shaders/generated/object.frag.spv new file mode 100644 index 0000000000000000000000000000000000000000..19b4c259210798ced5141694843e70e9a42392b4 GIT binary patch literal 2712 zcmZvdX?N3A5Qi^>6r`0!HW9^ys;Fq)02hR&E^XBUMFiYKNJAP;lb$4XdF6I|>nlHm zAHmmtDVL+?_&opI!5oga=XYl2H*;s^&b|M%w0(AGO54(O+Lb;}RxhJJ7{OqtlbWADjfQf*NRnee5xYqG%;5r1Trb z%CM7rwRhp`Hok5RhGYM|*o{%Qfmy^K4u4^ni+Y`2z1RXpOexJGN3E}lczeOW^+oo* zg0#^xfA)y4*B@*&x&=2?^xW>&7YF^`XtB}k^xEUPOZdBuja#k3sL1;^_UhJf9l@l^ zN!!(IY}7aVL%1CU=S**ZxppVUvkyA$^${{+oaol(f3`CjZuQ7oN-OA_&2bOc&}pI5 z>e)jneSqEVsF#a5_qRs%X207Xz%NnX*;Ron>=R=XyHw0>Pxg6l$ASGB>)f_*K?MBx&)^)c$M!e@W&RjDFvECC^JdG{N44rxwp>uCG7 z&dG@1?tJ ze;>kcyuT0H=czZ1^jr6hu3`I*{SM{YbmjZJFLS=zD{#KoLz(w17dVAI%bC-890k71 z?}*nvmO0;Mj~H`2n0fbTKLpJ4eXj9v=6t_1c#NrlZQyC(etR;`9-LDJ#JL_p_gvb( z-^anpY@6!|;GTZ&`N_-|~m5zL`bHG@A=D5NBD)Jd%5BlxTdy`wh z?>+hdRL<`u=JrD7eG$-Bq83!nym$@XiRvxhg(UG^aVI*_xjdek+)dB4ehETYRrJ?%f)6ML1h z_bqhyI;%^_%fN5+A~44Jt^)bXybaHJC3EsF&*=M%bHAH8docEAa(mBLf%Wvy@MXxo z2i)&nF9T!j+iy$X0<*l1yaA%G$$NZ1``mL4T|4^z5Z!x^zCS{j6RzmnczgL6m?P{@ zv;9NvzmDzik9@@3M7P%;b4&x>xSJrp$y?~=*7p-}&aesWN1wg>TOns&{kMT!d=Kr+ mA?|SNne!YG*U6msvC53A$j^ZLjo0rPtl2{9`-e%q1pWnpKd6WR literal 0 HcmV?d00001 diff --git a/shaders/generated/object.vert.spv b/shaders/generated/object.vert.spv new file mode 100644 index 0000000000000000000000000000000000000000..ff09037d419eb0497fb5de7aa6c8a04a555a7db4 GIT binary patch literal 2812 zcmZ9NdvlXj5QkryrbW4l2zbF7s9F)NsDL0A+JXkPO0`nFl7u$G%cMyr7jOUQjN^>_P1aKO6n2*l(S8oQfoNCI_mjPVCZymO(F0ah=69j6YmI)lxn({3cn`hZ z>>2`|$yU)1TDA3NZ?oAOG=BlYj(BZ-oqj&ZJ8jt6BHo?~K5q{;^52@qh0p%*-Du_A zn|XiGXm2%lefFtu*lO+i?8P&3xIWLSGaS^0KaH^G#=Z;jmy0vh-|qBw8m$rMnGzFJ z5$72j*J}LQ9Gzh~tB>M>%BtyH=inVx*5aAj!!5GJna=BE1ZV$po=tGhk*kbxrNntz zBhIrmZW*!ODmvzM0^7)tin6_4B|cZ zt{USx97p7DvqI;bNSwUG{_X2ea_B#anAf;#bZJj=QS*6hxu|&x+nV0@sQDtceAM)t zA*VQ^=0DVQ?_Wa9Yn(ORH@T>J8hgCvJlL9NQjC4RjL1jLR}v@hn5_9KoOzA2W(6%5 zHDALXulYLInrBmtHP0dPQS*(&$vY-%z6obu0h`u`s f>zl>?9`XC6&+nEv*3iJNBl`SCiT`7_FC+g06Roay literal 0 HcmV?d00001 diff --git a/shaders/generated/terrain.frag.spv b/shaders/generated/terrain.frag.spv index 1a5c5a271d580b3008f664c6e5b4f2ac90745b63..758e58935b9fd698112926bb0575ba422dfbb613 100644 GIT binary patch literal 23384 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 JQ=4n^{{RRn5-k7# literal 23068 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 diff --git a/shaders/object.frag b/shaders/object.frag new file mode 100644 index 0000000..c782231 --- /dev/null +++ b/shaders/object.frag @@ -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); +} diff --git a/shaders/object.vert b/shaders/object.vert new file mode 100644 index 0000000..f42941a --- /dev/null +++ b/shaders/object.vert @@ -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; +} diff --git a/shaders/terrain.frag b/shaders/terrain.frag index 176afce..3e0d112 100644 --- a/shaders/terrain.frag +++ b/shaders/terrain.frag @@ -25,6 +25,11 @@ layout(location = 0) out vec4 out_color; 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) { c = clamp(c, ivec2(0), ivec2(pc.mapinfo.xy) - 1); 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); if (dir.z >= -1e-4) { + gl_FragDepth = 1.0; out_color = vec4(sky_color(dir), 1.0); return; } @@ -133,7 +139,7 @@ void main() { if (w.z <= world_height(w.x, w.y)) { hit = true; hit_t = t; break; } 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; for (int i = 0; i < 6; ++i) { @@ -144,6 +150,10 @@ void main() { } 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); float ambient = pc.sun.w; diff --git a/src/map/ra3.map.cppm b/src/map/ra3.map.cppm index e725c62..f59781c 100644 --- a/src/map/ra3.map.cppm +++ b/src/map/ra3.map.cppm @@ -19,8 +19,9 @@ export import ra3.fs; export namespace ra3::map { using ra3::core::coord3d; using ra3::core::real; - using ra3::core::uint32; using ra3::core::uint8; + using ra3::core::uint16; + using ra3::core::uint32; using ra3::core::usize; /** A player start location extracted from a map's waypoints. */ @@ -202,6 +203,137 @@ export namespace ra3::map { 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 data) -> std::pair, std::vector> { + if (data.size() < 8U || std::memcmp(data.data(), "CkMp", 4) != 0) return {}; + usize pos = 4; + const auto read_u32 = [&](usize at) { + return static_cast(data[at]) | (static_cast(data[at + 1U]) << 8U) | (static_cast(data[at + 2U]) << 16U) | + (static_cast(data[at + 3U]) << 24U); + }; + const auto read_u16 = [&](usize at) { return static_cast(static_cast(data[at]) | (static_cast(data[at + 1U]) << 8U)); }; + const auto count = read_u32(pos); + pos += 4; + std::vector 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(data.data() + pos), len}; + pos += len + 4U; + } + std::vector 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 ckmp) -> std::vector { + const auto [names, chunks] = detail::parse_ckmp_chunks(ckmp); + (void) names; + std::vector objects; + const auto read_u16 = [&](usize at) { return static_cast(static_cast(ckmp[at]) | (static_cast(ckmp[at + 1U]) << 8U)); }; + const auto read_u32 = [&](usize at) { + return static_cast(ckmp[at]) | (static_cast(ckmp[at + 1U]) << 8U) | (static_cast(ckmp[at + 2U]) << 16U) | + (static_cast(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(ckmp.data() + p), name_len}; + p += name_len; + + const auto property_count = static_cast(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(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. */ struct map_name_table { std::unordered_map names; diff --git a/src/models/ra3.models.cppm b/src/models/ra3.models.cppm new file mode 100644 index 0000000..55aba22 --- /dev/null +++ b/src/models/ra3.models.cppm @@ -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\\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 b, usize off) -> uint16 { + return off + 2U <= b.size() ? static_cast(b[off]) | (static_cast(b[off + 1U]) << 8U) : 0U; + } + [[nodiscard]] inline auto u32(std::span b, usize off) -> uint32 { + return off + 4U <= b.size() ? static_cast(b[off]) | (static_cast(b[off + 1U]) << 8U) | (static_cast(b[off + 2U]) << 16U) | + (static_cast(b[off + 3U]) << 24U) + : 0U; + } + [[nodiscard]] inline auto i32(std::span b, usize off) -> ra3::core::int32 { return static_cast(u32(b, off)); } + [[nodiscard]] inline auto f32(std::span 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 b, usize off, ra3::core::int32 length) -> std::string { + if (off == 0U || length <= 0 || off + static_cast(length) > b.size()) return {}; + return std::string{reinterpret_cast(b.data() + off), static_cast(length)}; + } + /** NUL-terminated string in the manifest name/source buffers. */ + [[nodiscard]] inline auto nul_string(std::span 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(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> 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 manifest_raw, std::span 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{std::istreambuf_iterator(in), std::istreambuf_iterator()}; + }; + 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 head{}; + in.read(reinterpret_cast(head.data()), static_cast(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 & { return assets_; } + + [[nodiscard]] auto find(uint32 type_id, uint32 instance_id) const -> const asset * { + const auto it = index_.find((static_cast(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 & { + static const std::vector 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 { + if (!data_.empty()) { + if (a.instance_offset + a.instance_size > data_.size()) return {}; + return {data_.begin() + static_cast(a.instance_offset), + data_.begin() + static_cast(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(a.instance_offset)); + std::vector bytes(a.instance_size); + in.read(reinterpret_cast(bytes.data()), static_cast(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(std::tolower(ch)); }); + return out; + } + + private: + /** Parse the manifest into `assets_` + the lookup indexes. */ + auto parse(std::span 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(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(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(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(std::max(0, detail::i32(manifest, e + 24U)))); + a.source = detail::nul_string(manifest, src_off + static_cast(std::max(0, detail::i32(manifest, e + 28U)))); + if (aref_off >= 0 && aref_count > 0 && ref_off + static_cast(aref_off) + static_cast(aref_count) * 8U <= manifest.size()) { + a.references.reserve(static_cast(aref_count)); + for (int r = 0; r < aref_count; ++r) { + const usize ro = ref_off + static_cast(aref_off) + static_cast(r) * 8U; + a.references.emplace_back(detail::u32(manifest, ro), detail::u32(manifest, ro + 4U)); + } + } + index_.try_emplace((static_cast(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 assets_; + std::unordered_map index_; + std::unordered_map> mesh_index_; + std::vector data_; + std::filesystem::path bin_path_; + }; + + /** A decoded compiled `W3DMesh`: a vertex buffer plus a triangle list, in model space. */ + struct mesh { + std::vector positions; ///< 3 floats per vertex. + std::vector normals; ///< 3 floats per vertex, or empty. + std::vector uvs; ///< 2 floats per vertex, or empty. + std::vector indices; + std::string shader; ///< FX shader name (`BuildingsGeneric.fx`, ...). + std::vector blend_indices; ///< 4 bone indices per vertex, or empty (static mesh). + std::vector bone_remap; ///< Per-mesh blend index -> hierarchy bone map, or empty. + std::vector 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 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 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(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(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 rotation{1.0F, 0.0F, 0.0F, 0.0F, 1.0F, 0.0F, 0.0F, 0.0F, 1.0F}; + std::array 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 { + 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 &a, const std::array &b) -> std::array { + std::array out{}; + for (int i = 0; i < 3; ++i) { + for (int j = 0; j < 3; ++j) { + out[static_cast(i) * 3U + static_cast(j)] = a[static_cast(i) * 3U] * b[static_cast(j)] + + a[static_cast(i) * 3U + 1U] * b[3U + static_cast(j)] + + a[static_cast(i) * 3U + 2U] * b[6U + static_cast(j)]; + } + } + return out; + } + + [[nodiscard]] inline auto rotate(const std::array &m, const std::array &v) -> std::array { + 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 { + std::vector 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 local_translation{b.tx, b.ty, b.tz}; + if (b.parent >= 0 && static_cast(b.parent) < i) { + const auto &parent = world[static_cast(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 { + std::vector 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(std::strtoul(std::string{name.substr(1)}.c_str(), nullptr, 10)) : 0U; + element.offset = static_cast(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: ``). */ + [[nodiscard]] inline auto decode_vertex_element(std::span buf, usize off, std::string_view type, float *out, uint32 wanted) -> uint32 { + usize digits = 0; + while (digits < type.size() && std::isdigit(static_cast(type[digits]))) ++digits; + if (digits == 0U) return 0U; + const auto count = static_cast(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(i) * bytes_per_element; + if (kind == "f32") { + lerp(i, f32(buf, off + static_cast(i) * 4U)); + } else if (kind == "u8n") { + lerp(i, at < buf.size() ? static_cast(buf[at]) / 255.0F : 0.0F); + } else if (kind == "u8") { + lerp(i, at < buf.size() ? static_cast(buf[at]) : 0.0F); + } else if (kind == "s16n") { + lerp(i, at + 2U <= buf.size() ? static_cast(static_cast(u16(buf, at))) / 32767.0F : 0.0F); + } else if (kind == "u16n") { + lerp(i, at + 2U <= buf.size() ? static_cast(u16(buf, at)) / 65535.0F : 0.0F); + } else if (kind == "s16") { + lerp(i, at + 2U <= buf.size() ? static_cast(static_cast(u16(buf, at))) : 0.0F); + } else if (kind == "u16") { + lerp(i, at + 2U <= buf.size() ? static_cast(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(mant) / 1024.0F * std::pow(2.0F, -14.0F) + : (1.0F + static_cast(mant) / 1024.0F) * std::pow(2.0F, static_cast(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 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(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(shader_len) <= data.size()) { + out.shader.assign(reinterpret_cast(data.data() + shader_ptr), static_cast(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(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(i) * 2U); + } + + out.positions.assign(static_cast(vertex_count) * 3U, 0.0F); + std::vector normals(static_cast(vertex_count) * 3U, 0.0F); + std::vector uvs(static_cast(vertex_count) * 2U, 0.0F); + std::vector blend_indices(static_cast(vertex_count) * 4U, 0U); + std::vector blend_weights(static_cast(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(element_items) + static_cast(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(i) * 3U + 0U] = value[0]; + out.positions[static_cast(i) * 3U + 1U] = value[1]; + out.positions[static_cast(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(i) * 4U + k] = static_cast(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(i) * 4U + k] = value[k]; + } + } + } + normals[static_cast(i) * 3U + 0U] = normal[0]; + normals[static_cast(i) * 3U + 1U] = normal[1]; + normals[static_cast(i) * 3U + 2U] = normal[2]; + uvs[static_cast(i) * 2U + 0U] = uv[0]; + uvs[static_cast(i) * 2U + 1U] = uv[1]; + } + + for (uint32 i = 0; i < triangle_count; ++i) { + const auto base = static_cast(triangle_items) + static_cast(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(count) * 4U > data.size()) continue; + std::vector ring(count); + for (uint32 k = 0; k < count; ++k) ring[k] = detail::u32(data, ptr + static_cast(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 block, usize offset, std::vector &out, uint32 w, uint32 h, uint32 x, uint32 y, bool force_four) -> void { + const auto c0 = static_cast(block[offset]) | (static_cast(block[offset + 1U]) << 8U); + const auto c1 = static_cast(block[offset + 2U]) | (static_cast(block[offset + 3U]) << 8U); + const auto hilo = [](uint32 c) -> std::array { + return {static_cast(((c >> 11U) & 0x1FU) * 255U / 31U), static_cast(((c >> 5U) & 0x3FU) * 255U / 63U), + static_cast((c & 0x1FU) * 255U / 31U)}; + }; + const auto a = hilo(c0); + const auto b = hilo(c1); + std::array 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((2U * a[0] + b[0]) / 3U), static_cast((2U * a[1] + b[1]) / 3U), static_cast((2U * a[2] + b[2]) / 3U)); + colors[3] = argb(static_cast((a[0] + 2U * b[0]) / 3U), static_cast((a[1] + 2U * b[1]) / 3U), static_cast((a[2] + 2U * b[2]) / 3U)); + } else { + colors[2] = argb(static_cast((a[0] + b[0]) / 2U), static_cast((a[1] + b[1]) / 2U), static_cast((a[2] + b[2]) / 2U)); + colors[3] = 0x00000000U; + } + uint32 indices = 0; + for (uint32 k = 0; k < 4U; ++k) indices |= static_cast(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(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 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(data.data() + 84U), 4U}; + std::vector pixels(static_cast(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((i % 2U == 0U) ? (byte & 0x0FU) : (byte >> 4U)); + const auto at = static_cast(y + py) * width + (x + px); + if (y + py < height && x + px < width) pixels[at] = (pixels[at] & 0x00FFFFFFU) | (static_cast(alpha) * 17U << 24U); + } + } + } else { // DXT5 + const auto a0 = block[0]; + const auto a1 = block[1]; + std::array palette{}; + palette[0] = a0; + palette[1] = a1; + if (a0 > a1) { + for (uint32 i = 1U; i < 7U; ++i) palette[i + 1U] = static_cast(((7U - i) * a0 + i * a1) / 7U); + } else { + for (uint32 i = 1U; i < 5U; ++i) palette[i + 1U] = static_cast(((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(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(y + py) * width + (x + px); + if (y + py < height && x + px < width) pixels[at] = (pixels[at] & 0x00FFFFFFU) | (static_cast(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 { + 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(width) * height && i < available; ++i) { + uint32 pixel = 0; + for (uint32 b = 0; b < bpp; ++b) pixel |= static_cast(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 instance) -> image { + const auto off = detail::u32(instance, 4U); + std::span 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(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 vertices; + std::vector indices; + std::vector 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(sy) * src.width() + sx]; + r += (texel >> 16U) & 0xFFU; + g += (texel >> 8U) & 0xFFU; + b += texel & 0xFFU; + a += (texel >> 24U) & 0xFFU; + ++count; + } + } + out.data()[static_cast(y) * size + x] = + argb(static_cast(r / count), static_cast(g / count), static_cast(b / count), static_cast(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 placements, + const std::function &ground_height = {}, uint32 texture_size = 128U) -> scene { + scene out; + out.texture_size = texture_size == 0U ? 128U : texture_size; + std::unordered_map 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(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> skeletons; + const auto skeleton_for = [&](std::string_view mesh_instance) -> const std::vector * { + 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 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(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(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 p{px, py, pz}; + const auto rp = detail::rotate(m.rotation, p); + const std::array 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(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; + } +} diff --git a/src/ra3.cppm b/src/ra3.cppm index f2e024e..fe0a0df 100644 --- a/src/ra3.cppm +++ b/src/ra3.cppm @@ -16,6 +16,7 @@ export import ra3.map; export import ra3.skirmish; export import ra3.render; export import ra3.terrain; +export import ra3.models; export import ra3.ui; export import ra3.vulkan; export import ra3.dx; diff --git a/src/terrain/ra3.terrain.cppm b/src/terrain/ra3.terrain.cppm index 9562d00..7804f87 100644 --- a/src/terrain/ra3.terrain.cppm +++ b/src/terrain/ra3.terrain.cppm @@ -5,6 +5,7 @@ import std; export import ra3.core; export import ra3.fs; export import ra3.render; +export import ra3.models; /** * The map's real terrain, read from the compiled `CkMp` chunk tree. @@ -481,6 +482,7 @@ export namespace ra3::terrain { float z_scale = 0.0390625F; bool has_water = false; float water_z = 0.0F; + ra3::models::scene objects; ///< Buildings and props placed on the map (world-space triangle soup). }; /** @@ -492,14 +494,15 @@ export namespace ra3::terrain { * base texture layer, the blend (and three-way) secondary layer and the * packed blend direction/flags, which the shader ramps across the cell. */ - [[nodiscard]] inline auto build_gpu_terrain(const map_data &map, const texture_set &set, const render_options &options = {}, - const std::function &progress = {}) -> gpu_terrain { + [[nodiscard]] inline auto build_gpu_terrain(const map_data &map, const texture_set &set, const render_options &options, + const ra3::models::scene &objects, const std::function &progress = {}) -> gpu_terrain { gpu_terrain out; out.width = map.width; out.height = map.height; out.z_scale = options.z_scale; out.has_water = map.has_water; out.water_z = map.water_plane_z; + out.objects = objects; // Cell index -> texture layer. const auto layer_of = [&](uint32 cell_index) -> uint16 { @@ -566,6 +569,12 @@ export namespace ra3::terrain { return out; } + /** Terrain without any placed objects. */ + [[nodiscard]] inline auto build_gpu_terrain(const map_data &map, const texture_set &set, const render_options &options = {}, + const std::function &progress = {}) -> gpu_terrain { + return build_gpu_terrain(map, set, options, ra3::models::scene{}, progress); + } + namespace detail { /** * The source texture a tile cell maps to. The texture is sampled @@ -721,6 +730,144 @@ export namespace ra3::terrain { const auto c2 = f2 > 0.0F ? sample_layer(layers.three, wx, wy) : c0; return mix_color(mix_color(c0, c1, f1), c2, f2); } + + /** + * Rasterise the map's building/prop scene over an already ray-marched + * terrain image, depth-testing against it. + * + * The camera basis is the one `render3d` used, so the two passes agree; + * `zbuf` holds the terrain's view-space depth per pixel (large where the + * ray hit nothing). Triangles are z-tested and perspective-correct. + */ + inline auto rasterize_objects(image &hi, std::vector &zbuf, const ra3::models::scene &scene, const std::array &cam, + const std::array &f, const std::array &r, const std::array &u, float tan_half, + float aspect) -> void { + const auto rw = static_cast(hi.width()); + const auto rh = static_cast(hi.height()); + if (rw <= 0 || rh <= 0) return; + const auto dot3 = [](const std::array &a, const std::array &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(wrap(tu) * static_cast(img.width()))); + const auto sy = std::min(img.height() - 1U, static_cast(wrap(tv) * static_cast(img.height()))); + return img.data()[static_cast(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 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(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(img.width()) * img.height(); + average[i] = argb(static_cast(r / n), static_cast(g / n), static_cast(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 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(rw); + out.sy = (0.5F - ndc_y * 0.5F) * static_cast(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(scene.texture_size) * static_cast(scene.texture_size); + const auto minified = texel_footprint > 2.0F * std::abs(area); + const auto flat_layer = static_cast(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(std::floor(std::min({p0.sx, p1.sx, p2.sx})))); + const auto max_x = std::min(rw - 1, static_cast(std::ceil(std::max({p0.sx, p1.sx, p2.sx})))); + const auto min_y = std::max(0, static_cast(std::floor(std::min({p0.sy, p1.sy, p2.sy})))); + const auto max_y = std::min(rh - 1, static_cast(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(x) + 0.5F; + const auto py = static_cast(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(y) * static_cast(rw) + static_cast(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(std::clamp(static_cast((texel >> shift) & 0xFFU) * shade, 0.0F, 255.0F)); + }; + hi.data()[pixel] = argb(mod(16U), mod(8U), mod(0U)); + zbuf[pixel] = depth; + } + } + } + } } /** @@ -851,7 +998,7 @@ export namespace ra3::terrain { * a transformed 2D image. */ [[nodiscard]] inline auto render3d(const map_data &map, const texture_set &set, const camera3d &camera, uint32 out_w, uint32 out_h, - const render_options &options = {}) -> image { + const render_options &options = {}, const ra3::models::scene *objects = nullptr) -> image { if (!map.valid) throw terrain_error("terrain not parsed"); out_w = std::max(1U, out_w); out_h = std::max(1U, out_h); @@ -968,6 +1115,7 @@ export namespace ra3::terrain { }; image hi(rw, rh, argb(0, 0, 0)); + std::vector zbuf(static_cast(rw) * rh, 1.0e30F); for (uint32 py = 0; py < rh; ++py) { const auto ndc_y = 1.0F - 2.0F * (static_cast(py) + 0.5F) / static_cast(rh); for (uint32 px = 0; px < rw; ++px) { @@ -979,10 +1127,11 @@ export namespace ra3::terrain { dx /= dlen; dy /= dlen; dz /= dlen; + const auto pixel = static_cast(py) * rw + px; if (dz >= -1.0e-4F) { const auto t = std::clamp(std::abs(dz) * 2.0F, 0.0F, 1.0F); - hi.data()[static_cast(py) * rw + px] = + hi.data()[pixel] = argb(static_cast(120.0F + 60.0F * t), static_cast(150.0F + 45.0F * t), static_cast(190.0F + 40.0F * t)); continue; } @@ -1017,7 +1166,7 @@ export namespace ra3::terrain { t += dt; } if (!hit) { - hi.data()[static_cast(py) * rw + px] = argb(150, 170, 200); + hi.data()[pixel] = argb(150, 170, 200); continue; } @@ -1035,10 +1184,16 @@ export namespace ra3::terrain { lo = mid; } } - hi.data()[static_cast(py) * rw + px] = hit_color(cam_x + dx * up, cam_y + dy * up, cam_z + dz * up); + hi.data()[pixel] = hit_color(cam_x + dx * up, cam_y + dy * up, cam_z + dz * up); + // View-space depth of the hit, for the object pass below. + zbuf[pixel] = up * (dx * fx + dy * fy + dz * fz); } } + if (objects != nullptr && !objects->empty()) { + detail::rasterize_objects(hi, zbuf, *objects, {cam_x, cam_y, cam_z}, {fx, fy, fz}, {rx, ry, rz}, {ux, uy, uz}, tan_half, aspect); + } + if (ss == 1U) return hi; image out(out_w, out_h, argb(0, 0, 0)); for (uint32 y = 0; y < out_h; ++y) { diff --git a/src/vulkan/ra3.vulkan.sdl.cppm b/src/vulkan/ra3.vulkan.sdl.cppm index cf75aee..b0c62af 100644 --- a/src/vulkan/ra3.vulkan.sdl.cppm +++ b/src/vulkan/ra3.vulkan.sdl.cppm @@ -92,6 +92,7 @@ export namespace ra3::vulkan { if (!this->create_device()) return false; if (!this->create_swapchain()) return false; if (!this->create_render_pass()) return false; + if (!this->create_framebuffers()) return false; if (!this->create_pipeline()) return false; if (!this->create_commands()) 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_images(terrain)) 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; } 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{}; 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; VkPipelineColorBlendStateCreateInfo blend{}; @@ -690,6 +702,7 @@ export namespace ra3::vulkan { 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 = terrain_pipeline_layout_; @@ -737,6 +750,197 @@ export namespace ra3::vulkan { 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(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(scene.vertices.size()) * sizeof(ra3::models::vertex); + const auto index_bytes = static_cast(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(scene.indices.size()); + + const auto size = std::max(1U, scene.texture_size); + const auto layers = std::max(1U, scene.textures.size()); + std::vector pixels(static_cast(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(layer) * size + y) * size + x] = texture.data()[static_cast(y) * texture.width() + x]; + } + } + } + return this->make_gpu_image(object_texture_, size, size, VK_FORMAT_B8G8R8A8_UNORM, pixels.data(), + static_cast(pixels.size()) * 4U, this->mip_count(size), VK_FILTER_LINEAR, VK_SAMPLER_MIPMAP_MODE_LINEAR, + static_cast(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 { vkWaitForFences(device_, 1U, &in_flight_[current_frame_], VK_TRUE, UINT64_MAX); @@ -754,13 +958,16 @@ export namespace ra3::vulkan { VkClearValue clear{}; 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{}; render_pass_begin.sType = VK_STRUCTURE_TYPE_RENDER_PASS_BEGIN_INFO; render_pass_begin.renderPass = render_pass_; render_pass_begin.framebuffer = framebuffers_[image_index]; render_pass_begin.renderArea = {{0U, 0U}, swapchain_extent_}; - render_pass_begin.clearValueCount = 1U; - render_pass_begin.pClearValues = &clear; + render_pass_begin.clearValueCount = 2U; + render_pass_begin.pClearValues = clears; vkCmdBeginRenderPass(cmd, &render_pass_begin, VK_SUBPASS_CONTENTS_INLINE); VkViewport viewport{0.0F, 0.0F, static_cast(swapchain_extent_.width), static_cast(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); 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(terrain.width) * 10.0F; + const auto world_h = static_cast(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(wx / 10.0F)); + const auto cy = std::min(terrain.height - 1U, static_cast((world_h - wy) / 10.0F)); + return static_cast(terrain.heights[static_cast(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 // 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) { @@ -861,6 +1111,27 @@ export namespace ra3::vulkan { terrain_set_ = VK_NULL_HANDLE; overlay_label_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); - for (uint32_t i = 0; i < actual; ++i) { - VkImageView attachments[] = {swapchain_views_[i]}; + return true; + } + + /** 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{}; framebuffer_info.sType = VK_STRUCTURE_TYPE_FRAMEBUFFER_CREATE_INFO; framebuffer_info.renderPass = render_pass_; - framebuffer_info.attachmentCount = 1U; + framebuffer_info.attachmentCount = 2U; framebuffer_info.pAttachments = attachments; - framebuffer_info.width = extent.width; - framebuffer_info.height = extent.height; + framebuffer_info.width = swapchain_extent_.width; + framebuffer_info.height = swapchain_extent_.height; framebuffer_info.layers = 1U; if (!detail::check(vkCreateFramebuffer(device_, &framebuffer_info, nullptr, &framebuffers_[i]), "vkCreateFramebuffer")) return false; } 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 { for (const auto framebuffer: framebuffers_) vkDestroyFramebuffer(device_, framebuffer, nullptr); + this->destroy_depth_resources(); for (const auto view: swapchain_views_) vkDestroyImageView(device_, view, nullptr); framebuffers_.clear(); swapchain_views_.clear(); @@ -1041,7 +1367,7 @@ export namespace ra3::vulkan { if (width == 0 || height == 0) return false; vkDeviceWaitIdle(device_); this->cleanup_swapchain(); - return this->create_swapchain(); + return this->create_swapchain() && this->create_framebuffers(); } // ---- pipeline -------------------------------------------------------- @@ -1058,22 +1384,36 @@ export namespace ra3::vulkan { color.finalLayout = VK_IMAGE_LAYOUT_PRESENT_SRC_KHR; 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{}; subpass.pipelineBindPoint = VK_PIPELINE_BIND_POINT_GRAPHICS; subpass.colorAttachmentCount = 1U; subpass.pColorAttachments = &color_ref; + subpass.pDepthStencilAttachment = &depth_ref; VkSubpassDependency dependency{}; dependency.srcSubpass = VK_SUBPASS_EXTERNAL; dependency.dstSubpass = 0U; - dependency.srcStageMask = VK_PIPELINE_STAGE_COLOR_ATTACHMENT_OUTPUT_BIT; - dependency.dstStageMask = VK_PIPELINE_STAGE_COLOR_ATTACHMENT_OUTPUT_BIT; - dependency.dstAccessMask = VK_ACCESS_COLOR_ATTACHMENT_WRITE_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 | VK_PIPELINE_STAGE_EARLY_FRAGMENT_TESTS_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{}; render_pass_info.sType = VK_STRUCTURE_TYPE_RENDER_PASS_CREATE_INFO; - render_pass_info.attachmentCount = 1U; - render_pass_info.pAttachments = &color; + render_pass_info.attachmentCount = 2U; + render_pass_info.pAttachments = attachments; render_pass_info.subpassCount = 1U; render_pass_info.pSubpasses = &subpass; render_pass_info.dependencyCount = 1U; @@ -1211,10 +1551,10 @@ export namespace ra3::vulkan { command_buffers_.resize(frames_in_flight); 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{}; 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.pPoolSizes = &pool_size; if (!detail::check(vkCreateDescriptorPool(device_, &descriptor_pool_info, nullptr, &descriptor_pool_), "vkCreateDescriptorPool")) return false; @@ -1307,13 +1647,16 @@ export namespace ra3::vulkan { VkClearValue clear{}; 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{}; render_pass_begin.sType = VK_STRUCTURE_TYPE_RENDER_PASS_BEGIN_INFO; render_pass_begin.renderPass = render_pass_; render_pass_begin.framebuffer = framebuffers_[image_index]; render_pass_begin.renderArea = {{0U, 0U}, swapchain_extent_}; - render_pass_begin.clearValueCount = 1U; - render_pass_begin.pClearValues = &clear; + render_pass_begin.clearValueCount = 2U; + render_pass_begin.pClearValues = clears; vkCmdBeginRenderPass(cmd, &render_pass_begin, VK_SUBPASS_CONTENTS_INLINE); VkViewport viewport{0.0F, 0.0F, static_cast(swapchain_extent_.width), static_cast(swapchain_extent_.height), 0.0F, 1.0F}; @@ -1433,6 +1776,9 @@ export namespace ra3::vulkan { std::vector swapchain_images_; std::vector swapchain_views_; std::vector framebuffers_; + std::vector depth_images_; + std::vector depth_memories_; + std::vector depth_views_; VkRenderPass render_pass_ = VK_NULL_HANDLE; VkDescriptorSetLayout descriptor_layout_ = VK_NULL_HANDLE; VkPipelineLayout pipeline_layout_ = VK_NULL_HANDLE; @@ -1464,6 +1810,16 @@ export namespace ra3::vulkan { VkPipeline terrain_pipeline_ = VK_NULL_HANDLE; VkDescriptorPool terrain_pool_ = 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 image_available_; std::vector render_finished_; std::vector in_flight_; diff --git a/tests/ra3_tests.cpp b/tests/ra3_tests.cpp index d4939cf..95938ba 100644 --- a/tests/ra3_tests.cpp +++ b/tests/ra3_tests.cpp @@ -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("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 &out, std::uint16_t v) { + out.push_back(static_cast(v)); + out.push_back(static_cast(v >> 8U)); + }; + const auto put32 = [](std::vector &out, std::uint32_t v) { + for (int i = 0; i < 4; ++i) out.push_back(static_cast(v >> (8 * i))); + }; + const auto putf = [&put32](std::vector &out, float f) { + std::uint32_t bits = 0; + std::memcpy(&bits, &f, sizeof(bits)); + put32(out, bits); + }; + + std::vector 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(object_type.size())); + object_data.insert(object_data.end(), object_type.begin(), object_type.end()); + put16(object_data, 0U); // no properties + std::vector object_asset; + put32(object_asset, 1U); // asset index + put16(object_asset, 1U); // version + put32(object_asset, static_cast(object_data.size())); + object_asset.insert(object_asset.end(), object_data.begin(), object_data.end()); + + std::vector 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(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(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 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(i)] = static_cast(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{1U, 2U, 3U}).empty(), "a non-DDS payload yields no image"); + if (failures == 0) { std::puts("ra3_tests: OK"); }