feat(gameengine): Quake 3 BSP map loader with Bullet physics collision

- bsp.load: parse Q3 BSP from pk3 (zip) archives via libzip
- Extracts vertices, faces, meshverts from BSP lumps
- Coordinate conversion: Q3 Z-up → engine Y-up
- Configurable scale (default 1/32 = Q3 units to meters)
- Skips sky, clip, trigger, caulk, hint textures
- Bullet btBvhTriangleMeshShape collision from BSP geometry
- draw.map: graceful fallback when shadow texture missing
- Q3 game workflow: FPS controls, 90° FOV, walk around maps
- Tested with q3dm17 (The Longest Yard): 8486 verts, 5128 tris
- libzip added to cmake dependencies

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-03-18 10:53:52 +00:00
parent 13d04d0767
commit bd4448c6cc
9 changed files with 567 additions and 8 deletions

View File

@@ -104,6 +104,8 @@ find_package(EnTT CONFIG REQUIRED)
find_package(assimp CONFIG REQUIRED)
find_package(libzip CONFIG REQUIRED)
# Build render stack library group
set(SDL3CPP_RENDER_STACK_LIBS EnTT::EnTT)
@@ -281,6 +283,7 @@ if(BUILD_SDL3_APP)
src/services/impl/workflow/geometry/workflow_geometry_create_cube_step.cpp
src/services/impl/workflow/geometry/workflow_geometry_create_plane_step.cpp
src/services/impl/workflow/geometry/workflow_geometry_cube_generate_step.cpp
src/services/impl/workflow/rendering/workflow_bsp_load_step.cpp
src/services/impl/workflow/rendering/workflow_draw_map_step.cpp
src/services/impl/workflow/rendering/workflow_draw_textured_box_step.cpp
src/services/impl/workflow/rendering/workflow_draw_textured_step.cpp
@@ -370,6 +373,7 @@ if(BUILD_SDL3_APP)
glm::glm
stb::stb
EnTT::EnTT
libzip::zip
assimp::assimp
)
endif()

View File

@@ -51,7 +51,8 @@
"glm",
"stb",
"EnTT",
"assimp"
"assimp",
"libzip"
]
},
"source_exclusions": [
@@ -128,6 +129,7 @@
"glm::glm",
"stb::stb",
"EnTT::EnTT",
"libzip::zip",
"assimp::assimp"
],
"compile_definitions": [],

View File

@@ -2,11 +2,11 @@
"name": "Quake 3 Support",
"version": "1.0.0",
"id": "quake3",
"type": "game-format-support",
"category": "asset-loaders",
"description": "Load Quake 3 BSP maps with automatic unit conversion and Q3-correct camera/physics settings",
"type": "game",
"category": "demo",
"description": "Quake 3 BSP map viewer with FPS controls",
"defaultWorkflow": "workflows/load_map_with_unit_conversion.json",
"defaultWorkflow": "workflows/q3_game.json",
"workflows": [
"workflows/load_map_with_unit_conversion.json"

View File

@@ -0,0 +1,129 @@
{
"name": "Q3 Frame Tick",
"description": "Per-frame: poll input, FPS move, render BSP map with post-FX.",
"nodes": [
{
"id": "input_poll",
"type": "input.poll",
"typeVersion": 1,
"position": [0, 0]
},
{
"id": "physics_move",
"type": "physics.fps.move",
"typeVersion": 1,
"position": [200, 0],
"parameters": {
"move_speed": 8.0,
"sprint_multiplier": 2.0,
"crouch_multiplier": 0.4,
"jump_height": 2.0,
"jump_duration": 0.4,
"air_control": 0.5,
"gravity_scale": 0.3,
"crouch_height": 0.5,
"stand_height": 1.4
}
},
{
"id": "physics_step",
"type": "physics.step",
"typeVersion": 1,
"position": [400, 0]
},
{
"id": "sync_transforms",
"type": "physics.sync_transforms",
"typeVersion": 1,
"position": [500, 0]
},
{
"id": "camera_update",
"type": "camera.fps.update",
"typeVersion": 1,
"position": [600, 0],
"parameters": {
"sensitivity": 0.003,
"eye_height": 1.4,
"fov": 90.0,
"near": 0.05,
"far": 1000.0
}
},
{
"id": "render_prepare",
"type": "render.prepare",
"typeVersion": 1,
"position": [750, 0]
},
{
"id": "frame_begin",
"type": "frame.gpu.begin",
"typeVersion": 1,
"position": [800, 0],
"parameters": {
"clear_r": 0.3,
"clear_g": 0.5,
"clear_b": 0.8
}
},
{
"id": "draw_map",
"type": "draw.map",
"typeVersion": 1,
"position": [1000, 0],
"parameters": {
"default_texture": "walls_texture",
"roughness": 0.7,
"metallic": 0.0
}
},
{
"id": "end_scene",
"type": "frame.gpu.end_scene",
"typeVersion": 1,
"position": [1200, 0]
},
{
"id": "postfx_taa",
"type": "postfx.taa",
"typeVersion": 1,
"position": [1250, 0],
"parameters": { "blend_factor": 0.05 }
},
{
"id": "postfx_ssao",
"type": "postfx.ssao",
"typeVersion": 1,
"position": [1300, 0]
},
{
"id": "bloom_extract",
"type": "postfx.bloom_extract",
"typeVersion": 1,
"position": [1400, 0]
},
{
"id": "bloom_blur",
"type": "postfx.bloom_blur",
"typeVersion": 1,
"position": [1500, 0]
},
{
"id": "postfx_composite",
"type": "postfx.composite",
"typeVersion": 1,
"position": [1600, 0]
}
],
"connections": {
"input_poll": { "main": { "0": [{ "node": "physics_move", "type": "main", "index": 0 }] } },
"physics_move": { "main": { "0": [{ "node": "physics_step", "type": "main", "index": 0 }] } },
"physics_step": { "main": { "0": [{ "node": "sync_transforms", "type": "main", "index": 0 }] } },
"sync_transforms": { "main": { "0": [{ "node": "camera_update", "type": "main", "index": 0 }] } },
"camera_update": { "main": { "0": [{ "node": "render_prepare", "type": "main", "index": 0 }] } },
"render_prepare": { "main": { "0": [{ "node": "frame_begin", "type": "main", "index": 0 }] } },
"frame_begin": { "main": { "0": [{ "node": "draw_map", "type": "main", "index": 0 }] } },
"draw_map": { "main": { "0": [{ "node": "end_scene", "type": "main", "index": 0 }] } }
}
}

View File

@@ -0,0 +1,68 @@
{
"name": "Quake 3 Map Viewer",
"active": true,
"settings": { "executionTimeout": 0 },
"variables": {
"window_width": { "name": "window_width", "type": "number", "defaultValue": 1280 },
"window_height": { "name": "window_height", "type": "number", "defaultValue": 960 },
"window_title": { "name": "window_title", "type": "string", "defaultValue": "Quake 3 - Map Viewer" },
"renderer_type": { "name": "renderer_type", "type": "string", "defaultValue": "auto" },
"shader_vertex_path": { "name": "shader_vertex_path", "type": "string", "defaultValue": "packages/seed/shaders/msl/constant_color.vert.metal" },
"shader_fragment_path": { "name": "shader_fragment_path", "type": "string", "defaultValue": "packages/seed/shaders/msl/constant_color.frag.metal" },
"shader_textured_vert_path": { "name": "shader_textured_vert_path", "type": "string", "defaultValue": "packages/seed/shaders/msl/textured.vert.metal" },
"shader_textured_frag_path": { "name": "shader_textured_frag_path", "type": "string", "defaultValue": "packages/seed/shaders/msl/textured.frag.metal" },
"tex_walls_path": { "name": "tex_walls_path", "type": "string", "defaultValue": "packages/seed/assets/textures/walls/Bricks058_1K-JPG_Color.jpg" }
},
"nodes": [
{ "id": "sdl_init", "type": "sdl.init", "typeVersion": 1, "position": [0, 0] },
{ "id": "sdl_window", "type": "sdl.window.create", "typeVersion": 1, "position": [200, 0] },
{ "id": "gpu_init_viewport", "type": "graphics.gpu.init_viewport", "typeVersion": 1, "position": [400, 0],
"parameters": { "inputs": { "width": "window_width", "height": "window_height" }, "outputs": { "viewport_config": "viewport_config" } } },
{ "id": "gpu_init_renderer", "type": "graphics.gpu.init_renderer", "typeVersion": 1, "position": [600, 0],
"parameters": { "inputs": { "renderer_type": "renderer_type" }, "outputs": { "selected_renderer": "selected_renderer" } } },
{ "id": "gpu_init", "type": "graphics.gpu.init", "typeVersion": 1, "position": [800, 0],
"parameters": { "inputs": { "viewport_config": "viewport_config", "selected_renderer": "selected_renderer" }, "outputs": { "gpu_handle": "gpu_handle" } } },
{ "id": "compile_tex_vert", "type": "graphics.gpu.shader.compile", "typeVersion": 1, "position": [1000, 0],
"parameters": { "stage": "vertex", "output_key": "textured_vertex_shader", "num_uniform_buffers": 1, "num_samplers": 0 },
"inputs": { "shader_path": "shader_textured_vert_path" } },
{ "id": "compile_tex_frag", "type": "graphics.gpu.shader.compile", "typeVersion": 1, "position": [1100, 0],
"parameters": { "stage": "fragment", "output_key": "textured_fragment_shader", "num_uniform_buffers": 1, "num_samplers": 2 },
"inputs": { "shader_path": "shader_textured_frag_path" } },
{ "id": "create_tex_pipeline", "type": "graphics.gpu.pipeline.create", "typeVersion": 1, "position": [1200, 0],
"parameters": { "vertex_shader_key": "textured_vertex_shader", "fragment_shader_key": "textured_fragment_shader", "vertex_format": "position_uv", "pipeline_key": "gpu_pipeline_textured" } },
{ "id": "tex_walls", "name": "Load Texture", "type": "texture.load", "typeVersion": 1, "position": [1300, 0],
"parameters": { "inputs": { "image_path": "tex_walls_path" }, "outputs": { "texture": "walls_texture" } } },
{ "id": "load_bsp", "name": "Load Q3 BSP", "type": "bsp.load", "typeVersion": 1, "position": [1400, 0],
"parameters": { "pk3_path": "C:/baseq3/pak0.pk3", "map_name": "q3dm17", "scale": 0.03125 } },
{ "id": "physics_world", "type": "physics.world.create", "typeVersion": 1, "position": [0, 200] },
{ "id": "player", "type": "physics.body.add", "typeVersion": 1, "position": [200, 200],
"parameters": { "name": "player", "shape": "capsule", "mass": 80, "pos_x": 0, "pos_y": 5, "pos_z": 0, "radius": 0.3, "height": 1.0, "lock_rotation": 1, "is_player": 1 } },
{ "id": "camera_setup", "type": "camera.setup", "typeVersion": 1, "position": [400, 200],
"parameters": { "outputs": { "camera_state": "camera.state" } } },
{ "id": "lighting", "type": "lighting.setup", "typeVersion": 1, "position": [600, 200],
"parameters": { "light_dir_x": -0.5, "light_dir_y": -0.8, "light_dir_z": -0.3, "light_intensity": 2.0, "ambient_r": 0.2, "ambient_g": 0.2, "ambient_b": 0.25, "ambient_intensity": 1.5, "exposure": 1.0 } },
{ "id": "set_running", "type": "value.literal", "typeVersion": 1, "position": [800, 200],
"parameters": { "value": true, "outputs": { "value": "game_running" } } },
{ "id": "game_loop", "type": "control.loop.while", "typeVersion": 1, "position": [1000, 200],
"parameters": { "condition_key": "game_running", "package": "quake3", "workflow": "q3_frame" } },
{ "id": "exit", "type": "system.exit", "typeVersion": 1, "position": [1200, 200] }
],
"connections": {
"sdl_init": { "main": { "0": [{ "node": "sdl_window", "type": "main", "index": 0 }] } },
"sdl_window": { "main": { "0": [{ "node": "gpu_init_viewport", "type": "main", "index": 0 }] } },
"gpu_init_viewport": { "main": { "0": [{ "node": "gpu_init_renderer", "type": "main", "index": 0 }] } },
"gpu_init_renderer": { "main": { "0": [{ "node": "gpu_init", "type": "main", "index": 0 }] } },
"gpu_init": { "main": { "0": [{ "node": "compile_tex_vert", "type": "main", "index": 0 }] } },
"compile_tex_vert": { "main": { "0": [{ "node": "compile_tex_frag", "type": "main", "index": 0 }] } },
"compile_tex_frag": { "main": { "0": [{ "node": "create_tex_pipeline", "type": "main", "index": 0 }] } },
"create_tex_pipeline": { "main": { "0": [{ "node": "tex_walls", "type": "main", "index": 0 }] } },
"tex_walls": { "main": { "0": [{ "node": "physics_world", "type": "main", "index": 0 }] } },
"physics_world": { "main": { "0": [{ "node": "load_bsp", "type": "main", "index": 0 }] } },
"load_bsp": { "main": { "0": [{ "node": "player", "type": "main", "index": 0 }] } },
"player": { "main": { "0": [{ "node": "camera_setup", "type": "main", "index": 0 }] } },
"camera_setup": { "main": { "0": [{ "node": "lighting", "type": "main", "index": 0 }] } },
"lighting": { "main": { "0": [{ "node": "set_running", "type": "main", "index": 0 }] } },
"set_running": { "main": { "0": [{ "node": "game_loop", "type": "main", "index": 0 }] } },
"game_loop": { "main": { "0": [{ "node": "exit", "type": "main", "index": 0 }] } }
}
}

View File

@@ -0,0 +1,334 @@
#include "services/interfaces/workflow/rendering/workflow_bsp_load_step.hpp"
#include "services/interfaces/workflow/workflow_step_parameter_resolver.hpp"
#include <SDL3/SDL_gpu.h>
#include <btBulletDynamicsCommon.h>
#include <nlohmann/json.hpp>
#include <zip.h>
#include <cstring>
#include <cmath>
#include <fstream>
#include <stdexcept>
#include <vector>
#include <string>
#include <algorithm>
namespace sdl3cpp::services::impl {
// Q3 BSP file format structures
#pragma pack(push, 1)
struct BspHeader {
char magic[4]; // "IBSP"
int32_t version; // 0x2E (46) for Q3
};
struct BspLump {
int32_t offset;
int32_t length;
};
// Lump indices
enum {
LUMP_ENTITIES = 0,
LUMP_TEXTURES = 1,
LUMP_PLANES = 2,
LUMP_NODES = 3,
LUMP_LEAFS = 4,
LUMP_LEAFFACES = 5,
LUMP_LEAFBRUSHES = 6,
LUMP_MODELS = 7,
LUMP_BRUSHES = 8,
LUMP_BRUSHSIDES = 9,
LUMP_VERTICES = 10,
LUMP_MESHVERTS = 11,
LUMP_EFFECTS = 12,
LUMP_FACES = 13,
LUMP_LIGHTMAPS = 14,
LUMP_LIGHTVOLS = 15,
LUMP_VISDATA = 16,
NUM_LUMPS = 17
};
struct BspVertex {
float position[3];
float texcoord[2][2]; // [0]=surface, [1]=lightmap
float normal[3];
uint8_t color[4];
};
struct BspFace {
int32_t texture;
int32_t effect;
int32_t type; // 1=polygon, 2=patch, 3=mesh, 4=billboard
int32_t vertex;
int32_t n_vertices;
int32_t meshvert;
int32_t n_meshverts;
int32_t lm_index;
int32_t lm_start[2];
int32_t lm_size[2];
float lm_origin[3];
float lm_vecs[2][3];
float normal[3];
int32_t size[2]; // patch dimensions
};
struct BspTexture {
char name[64];
int32_t flags;
int32_t contents;
};
#pragma pack(pop)
WorkflowBspLoadStep::WorkflowBspLoadStep(std::shared_ptr<ILogger> logger)
: logger_(std::move(logger)) {}
std::string WorkflowBspLoadStep::GetPluginId() const {
return "bsp.load";
}
void WorkflowBspLoadStep::Execute(const WorkflowStepDefinition& step, WorkflowContext& context) {
WorkflowStepParameterResolver params;
auto getStr = [&](const char* name, const std::string& def) -> std::string {
const auto* p = params.FindParameter(step, name);
if (p && p->type == WorkflowParameterValue::Type::String) return p->stringValue;
auto it = step.inputs.find(name);
if (it != step.inputs.end()) {
const auto* ctx = context.TryGet<std::string>(it->second);
if (ctx) return *ctx;
}
return def;
};
auto getNum = [&](const char* name, float def) -> float {
const auto* p = params.FindParameter(step, name);
return (p && p->type == WorkflowParameterValue::Type::Number) ? static_cast<float>(p->numberValue) : def;
};
const std::string pk3_path = getStr("pk3_path", "");
const std::string map_name = getStr("map_name", "q3dm17");
const float scale = getNum("scale", 1.0f / 32.0f); // Q3 units to meters (32 units = 1m)
if (pk3_path.empty()) {
throw std::runtime_error("bsp.load: 'pk3_path' parameter required");
}
SDL_GPUDevice* device = context.Get<SDL_GPUDevice*>("gpu_device", nullptr);
if (!device) throw std::runtime_error("bsp.load: GPU device not found");
// Open pk3 (zip) and extract BSP
int zip_err = 0;
zip_t* archive = zip_open(pk3_path.c_str(), ZIP_RDONLY, &zip_err);
if (!archive) {
throw std::runtime_error("bsp.load: Failed to open pk3: " + pk3_path);
}
std::string bsp_entry = "maps/" + map_name + ".bsp";
zip_stat_t st;
if (zip_stat(archive, bsp_entry.c_str(), 0, &st) != 0) {
zip_close(archive);
throw std::runtime_error("bsp.load: Map '" + bsp_entry + "' not found in " + pk3_path);
}
std::vector<uint8_t> bspData(st.size);
zip_file_t* zf = zip_fopen(archive, bsp_entry.c_str(), 0);
if (!zf) {
zip_close(archive);
throw std::runtime_error("bsp.load: Failed to open " + bsp_entry);
}
zip_fread(zf, bspData.data(), st.size);
zip_fclose(zf);
zip_close(archive);
if (logger_) {
logger_->Info("bsp.load: Read " + bsp_entry + " (" + std::to_string(bspData.size()) + " bytes)");
}
// Parse BSP header
if (bspData.size() < sizeof(BspHeader) + sizeof(BspLump) * NUM_LUMPS) {
throw std::runtime_error("bsp.load: BSP file too small");
}
auto* header = reinterpret_cast<const BspHeader*>(bspData.data());
if (std::memcmp(header->magic, "IBSP", 4) != 0 || header->version != 46) {
throw std::runtime_error("bsp.load: Not a valid Q3 BSP (magic/version mismatch)");
}
auto* lumps = reinterpret_cast<const BspLump*>(bspData.data() + sizeof(BspHeader));
// Extract vertices
const auto& vtxLump = lumps[LUMP_VERTICES];
int numVertices = vtxLump.length / static_cast<int>(sizeof(BspVertex));
auto* bspVertices = reinterpret_cast<const BspVertex*>(bspData.data() + vtxLump.offset);
// Extract faces
const auto& faceLump = lumps[LUMP_FACES];
int numFaces = faceLump.length / static_cast<int>(sizeof(BspFace));
auto* bspFaces = reinterpret_cast<const BspFace*>(bspData.data() + faceLump.offset);
// Extract mesh vertices (index offsets)
const auto& mvLump = lumps[LUMP_MESHVERTS];
int numMeshVerts = mvLump.length / 4;
auto* meshVerts = reinterpret_cast<const int32_t*>(bspData.data() + mvLump.offset);
// Extract textures
const auto& texLump = lumps[LUMP_TEXTURES];
int numTextures = texLump.length / static_cast<int>(sizeof(BspTexture));
auto* bspTextures = reinterpret_cast<const BspTexture*>(bspData.data() + texLump.offset);
// Convert to our vertex format: float3 pos + float2 uv = 20 bytes
struct PosUvVertex { float x, y, z, u, v; };
std::vector<PosUvVertex> vertices;
std::vector<uint16_t> indices;
vertices.reserve(numVertices);
// Convert Q3 vertices (swap Y/Z for Q3→OpenGL coordinate system)
for (int i = 0; i < numVertices; ++i) {
PosUvVertex v;
v.x = bspVertices[i].position[0] * scale;
v.y = bspVertices[i].position[2] * scale; // Q3 Z-up → Y-up
v.z = -bspVertices[i].position[1] * scale; // Q3 Y → -Z
v.u = bspVertices[i].texcoord[0][0];
v.v = bspVertices[i].texcoord[0][1];
vertices.push_back(v);
}
// Build indices from faces (type 1=polygon, type 3=mesh - both use meshverts)
int skippedPatches = 0;
for (int f = 0; f < numFaces; ++f) {
const auto& face = bspFaces[f];
// Skip sky, trigger, clip textures
if (face.texture >= 0 && face.texture < numTextures) {
const auto& tex = bspTextures[face.texture];
if (tex.contents & 0x200000) continue; // CONTENTS_TRANSLUCENT
std::string texName(tex.name);
if (texName.find("sky") != std::string::npos) continue;
if (texName.find("clip") != std::string::npos) continue;
if (texName.find("trigger") != std::string::npos) continue;
if (texName.find("hint") != std::string::npos) continue;
if (texName.find("caulk") != std::string::npos) continue;
}
if (face.type == 1 || face.type == 3) {
// Polygon or mesh face - use meshvert indices
for (int mv = 0; mv < face.n_meshverts; ++mv) {
int idx = face.vertex + meshVerts[face.meshvert + mv];
if (idx >= 0 && idx < static_cast<int>(vertices.size()) && idx <= 65535) {
indices.push_back(static_cast<uint16_t>(idx));
}
}
} else if (face.type == 2) {
skippedPatches++;
// TODO: tessellate bezier patches
}
}
if (logger_) {
logger_->Info("bsp.load: " + std::to_string(numVertices) + " vertices, " +
std::to_string(indices.size()) + " indices, " +
std::to_string(numFaces) + " faces (" +
std::to_string(skippedPatches) + " patches skipped)");
}
if (vertices.empty() || indices.empty()) {
throw std::runtime_error("bsp.load: No renderable geometry found");
}
// Upload to GPU as a single mesh
uint32_t vtxSize = static_cast<uint32_t>(vertices.size() * sizeof(PosUvVertex));
uint32_t idxSize = static_cast<uint32_t>(indices.size() * sizeof(uint16_t));
SDL_GPUBufferCreateInfo vbInfo = {};
vbInfo.usage = SDL_GPU_BUFFERUSAGE_VERTEX;
vbInfo.size = vtxSize;
SDL_GPUBuffer* vb = SDL_CreateGPUBuffer(device, &vbInfo);
SDL_GPUBufferCreateInfo ibInfo = {};
ibInfo.usage = SDL_GPU_BUFFERUSAGE_INDEX;
ibInfo.size = idxSize;
SDL_GPUBuffer* ib = SDL_CreateGPUBuffer(device, &ibInfo);
SDL_GPUTransferBufferCreateInfo tbInfo = {};
tbInfo.usage = SDL_GPU_TRANSFERBUFFERUSAGE_UPLOAD;
tbInfo.size = vtxSize + idxSize;
SDL_GPUTransferBuffer* tb = SDL_CreateGPUTransferBuffer(device, &tbInfo);
auto* mapped = static_cast<uint8_t*>(SDL_MapGPUTransferBuffer(device, tb, false));
std::memcpy(mapped, vertices.data(), vtxSize);
std::memcpy(mapped + vtxSize, indices.data(), idxSize);
SDL_UnmapGPUTransferBuffer(device, tb);
SDL_GPUCommandBuffer* cmd = SDL_AcquireGPUCommandBuffer(device);
SDL_GPUCopyPass* cp = SDL_BeginGPUCopyPass(cmd);
SDL_GPUTransferBufferLocation srcV = {}; srcV.transfer_buffer = tb;
SDL_GPUBufferRegion dstV = {}; dstV.buffer = vb; dstV.size = vtxSize;
SDL_UploadToGPUBuffer(cp, &srcV, &dstV, false);
SDL_GPUTransferBufferLocation srcI = {}; srcI.transfer_buffer = tb; srcI.offset = vtxSize;
SDL_GPUBufferRegion dstI = {}; dstI.buffer = ib; dstI.size = idxSize;
SDL_UploadToGPUBuffer(cp, &srcI, &dstI, false);
SDL_EndGPUCopyPass(cp);
SDL_SubmitGPUCommandBuffer(cmd);
SDL_ReleaseGPUTransferBuffer(device, tb);
// Store as map mesh (compatible with draw.map)
std::string meshName = "bsp_" + map_name;
context.Set<SDL_GPUBuffer*>("plane_" + meshName + "_vb", vb);
context.Set<SDL_GPUBuffer*>("plane_" + meshName + "_ib", ib);
context.Set("plane_" + meshName, nlohmann::json{
{"vertex_count", vertices.size()},
{"index_count", indices.size()},
{"stride", 20}
});
// Create Bullet physics triangle mesh from BSP geometry
auto* world = context.Get<btDiscreteDynamicsWorld*>("physics_world", nullptr);
if (world) {
auto* triMesh = new btTriangleMesh();
for (size_t i = 0; i + 2 < indices.size(); i += 3) {
const auto& v0 = vertices[indices[i]];
const auto& v1 = vertices[indices[i + 1]];
const auto& v2 = vertices[indices[i + 2]];
triMesh->addTriangle(
btVector3(v0.x, v0.y, v0.z),
btVector3(v1.x, v1.y, v1.z),
btVector3(v2.x, v2.y, v2.z));
}
auto* triShape = new btBvhTriangleMeshShape(triMesh, true);
btTransform startTransform;
startTransform.setIdentity();
auto* motionState = new btDefaultMotionState(startTransform);
btRigidBody::btRigidBodyConstructionInfo rbInfo(0.0f, motionState, triShape);
auto* body = new btRigidBody(rbInfo);
world->addRigidBody(body);
context.Set<btRigidBody*>("physics_body_bsp_" + map_name, body);
if (logger_) {
logger_->Info("bsp.load: Created collision mesh (" +
std::to_string(triMesh->getNumTriangles()) + " triangles)");
}
}
// Store as map.nodes array for draw.map compatibility
nlohmann::json mapNodes = nlohmann::json::array();
mapNodes.push_back({
{"name", meshName},
{"index_count", indices.size()}
});
context.Set("map.nodes", mapNodes);
if (logger_) {
logger_->Info("bsp.load: '" + map_name + "' ready (" +
std::to_string(vertices.size()) + " verts, " +
std::to_string(indices.size() / 3) + " triangles)");
}
}
} // namespace sdl3cpp::services::impl

View File

@@ -103,12 +103,13 @@ void WorkflowDrawMapStep::Execute(const WorkflowStepDefinition& step, WorkflowCo
auto* meshSamp = context.Get<SDL_GPUSampler*>(texKey + "_sampler", nullptr);
if (!meshTex || !meshSamp) continue;
if (shadow_tex && shadow_samp) {
{
SDL_GPUTextureSamplerBinding bindings[2] = {};
bindings[0].texture = meshTex;
bindings[0].sampler = meshSamp;
bindings[1].texture = shadow_tex;
bindings[1].sampler = shadow_samp;
// Use shadow map if available, otherwise dummy bind the same texture
bindings[1].texture = shadow_tex ? shadow_tex : meshTex;
bindings[1].sampler = shadow_samp ? shadow_samp : meshSamp;
SDL_BindGPUFragmentSamplers(pass, 0, bindings, 2);
}

View File

@@ -44,6 +44,7 @@
#include "services/interfaces/workflow/rendering/workflow_map_load_step.hpp"
#include "services/interfaces/workflow/rendering/workflow_draw_map_step.hpp"
#include "services/interfaces/workflow/rendering/workflow_postfx_taa_step.hpp"
#include "services/interfaces/workflow/rendering/workflow_bsp_load_step.hpp"
#include "services/interfaces/workflow/rendering/workflow_draw_textured_box_step.hpp"
#include "services/interfaces/workflow/rendering/workflow_shadow_setup_step.hpp"
#include "services/interfaces/workflow/rendering/workflow_shadow_pass_step.hpp"
@@ -282,6 +283,7 @@ void WorkflowRegistrar::RegisterSteps(std::shared_ptr<IWorkflowStepRegistry> reg
registry->RegisterStep(std::make_shared<WorkflowMapLoadStep>(logger_));
registry->RegisterStep(std::make_shared<WorkflowDrawMapStep>(logger_));
registry->RegisterStep(std::make_shared<WorkflowPostfxTaaStep>(logger_));
registry->RegisterStep(std::make_shared<WorkflowBspLoadStep>(logger_));
registry->RegisterStep(std::make_shared<WorkflowShadowSetupStep>(logger_));
registry->RegisterStep(std::make_shared<WorkflowShadowPassStep>(logger_));
registry->RegisterStep(std::make_shared<WorkflowRenderPrepareStep>(logger_));

View File

@@ -0,0 +1,19 @@
#pragma once
#include "services/interfaces/i_workflow_step.hpp"
#include "services/interfaces/i_logger.hpp"
#include <memory>
namespace sdl3cpp::services::impl {
class WorkflowBspLoadStep : public IWorkflowStep {
public:
explicit WorkflowBspLoadStep(std::shared_ptr<ILogger> logger);
std::string GetPluginId() const override;
void Execute(const WorkflowStepDefinition& step, WorkflowContext& context) override;
private:
std::shared_ptr<ILogger> logger_;
};
} // namespace sdl3cpp::services::impl