diff --git a/CMakeLists.txt b/CMakeLists.txt index 207fae0..7841bbc 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -137,6 +137,7 @@ find_package(bgfx CONFIG REQUIRED) find_package(MaterialX CONFIG REQUIRED) find_package(Freetype CONFIG REQUIRED) find_package(assimp CONFIG REQUIRED) +find_package(libzip CONFIG REQUIRED) find_package(Bullet CONFIG REQUIRED) find_package(Vorbis CONFIG REQUIRED) find_package(glm CONFIG REQUIRED) @@ -181,6 +182,13 @@ if(TARGET Freetype::Freetype) elseif(TARGET freetype::freetype) list(APPEND SDL3CPP_FREETYPE_LIBS freetype::freetype) endif() + +set(SDL3CPP_ZIP_LIBS) +if(TARGET libzip::zip) + list(APPEND SDL3CPP_ZIP_LIBS libzip::zip) +elseif(TARGET libzip::libzip) + list(APPEND SDL3CPP_ZIP_LIBS libzip::libzip) +endif() endif() if(BUILD_SDL3_APP) @@ -226,6 +234,7 @@ if(BUILD_SDL3_APP) rapidjson ${SDL3CPP_RENDER_STACK_LIBS} ${SDL3CPP_FREETYPE_LIBS} + ${SDL3CPP_ZIP_LIBS} assimp::assimp Bullet::Bullet glm::glm diff --git a/conanfile.py b/conanfile.py index 5208452..5aac29c 100644 --- a/conanfile.py +++ b/conanfile.py @@ -33,6 +33,7 @@ class SDL3CppConan(ConanFile): "freetype/2.13.2", "ffmpeg/8.0.1", "cairo/1.18.0", + "libzip/1.10.1", ) RENDER_STACK_REQUIRES = ( "bgfx/1.129.8930-495", diff --git a/config/quake3_runtime.json b/config/quake3_runtime.json new file mode 100644 index 0000000..0367381 --- /dev/null +++ b/config/quake3_runtime.json @@ -0,0 +1,83 @@ +{ + "launcher": { + "name": "Quake 3 Arena", + "description": "Loads a Quake 3 BSP from a PK3 archive", + "enabled": true + }, + "window_width": 1280, + "window_height": 720, + "lua_script": "scripts/quake3_arena.lua", + "scripts_directory": "scripts", + "project_root": "../", + "shaders_directory": "shaders", + "bgfx": { + "renderer": "vulkan" + }, + "mouse_grab": { + "enabled": true, + "grab_on_click": true, + "release_on_escape": true, + "start_grabbed": false, + "hide_cursor": true, + "relative_mode": true, + "grab_mouse_button": "left", + "release_key": "escape" + }, + "input_bindings": { + "move_forward": "W", + "move_back": "S", + "move_left": "A", + "move_right": "D", + "fly_up": "Q", + "fly_down": "Z", + "jump": "Space", + "noclip_toggle": "N", + "music_toggle": "M", + "music_toggle_gamepad": "start", + "gamepad_move_x_axis": "leftx", + "gamepad_move_y_axis": "lefty", + "gamepad_look_x_axis": "rightx", + "gamepad_look_y_axis": "righty", + "gamepad_dpad_up": "dpup", + "gamepad_dpad_down": "dpdown", + "gamepad_dpad_left": "dpleft", + "gamepad_dpad_right": "dpright", + "gamepad_button_actions": { + "a": "gamepad_a", + "b": "gamepad_b", + "x": "gamepad_x", + "y": "gamepad_y", + "leftshoulder": "gamepad_lb", + "rightshoulder": "gamepad_rb", + "leftstick": "gamepad_ls", + "rightstick": "gamepad_rs", + "back": "gamepad_back", + "start": "gamepad_start" + }, + "gamepad_axis_actions": { + "lefttrigger": "gamepad_lt", + "righttrigger": "gamepad_rt" + }, + "gamepad_axis_action_threshold": 0.5 + }, + "quake3": { + "pk3_path": "/home/rewrich/Documents/GitHub/q3/pak0.pk3", + "map_path": "q3dm1", + "scale": 0.01, + "rotate_x_degrees": -90.0, + "offset": [0.0, 0.0, 0.0], + "shader_key": "pbr", + "move_speed": 14.0, + "fly_speed": 10.0, + "mouse_sensitivity": 0.0025, + "camera": { + "position": [0.0, 48.0, 0.0], + "yaw_degrees": 0.0, + "pitch_degrees": 0.0, + "fov": 0.85, + "near": 0.1, + "far": 2000.0 + } + }, + "config_file": "config/quake3_runtime.json" +} diff --git a/scripts/quake3_arena.lua b/scripts/quake3_arena.lua new file mode 100644 index 0000000..c7e91b9 --- /dev/null +++ b/scripts/quake3_arena.lua @@ -0,0 +1,306 @@ +local math3d = require("math3d") + +local function log_debug(fmt, ...) + if not lua_debug or not fmt then + return + end + print(string.format(fmt, ...)) +end + +local function resolve_table(value) + if type(value) == "table" then + return value + end + return {} +end + +local function resolve_number(value, fallback) + if type(value) == "number" then + return value + end + return fallback +end + +local function resolve_vec3(value, fallback) + if type(value) == "table" + and type(value[1]) == "number" + and type(value[2]) == "number" + and type(value[3]) == "number" then + return {value[1], value[2], value[3]} + end + return {fallback[1], fallback[2], fallback[3]} +end + +local quake3_config = resolve_table(type(config) == "table" and config.quake3) + +local pk3_path = quake3_config.pk3_path or "/home/rewrich/Documents/GitHub/q3/pak0.pk3" +local map_entry = quake3_config.map_path or "q3dm1" +if not string.find(map_entry, "%.bsp$") then + map_entry = map_entry .. ".bsp" +end +if not string.find(map_entry, "/") then + map_entry = "maps/" .. map_entry +end + +local rotation_config = resolve_table(quake3_config.rotation_degrees) +local map_rotate_x = resolve_number(rotation_config.x, resolve_number(quake3_config.rotate_x_degrees, -90.0)) +local map_rotate_y = resolve_number(rotation_config.y, resolve_number(quake3_config.rotate_y_degrees, 0.0)) +local map_rotate_z = resolve_number(rotation_config.z, resolve_number(quake3_config.rotate_z_degrees, 0.0)) + +local map_scale = resolve_number(quake3_config.scale, 0.01) +local map_offset = resolve_vec3(quake3_config.offset, {0.0, 0.0, 0.0}) +local map_shader_key = quake3_config.shader_key or "pbr" + +local function scale_matrix(x, y, z) + return { + x, 0.0, 0.0, 0.0, + 0.0, y, 0.0, 0.0, + 0.0, 0.0, z, 0.0, + 0.0, 0.0, 0.0, 1.0, + } +end + +local function rotation_z(radians) + local c = math.cos(radians) + local s = math.sin(radians) + return { + c, s, 0.0, 0.0, + -s, c, 0.0, 0.0, + 0.0, 0.0, 1.0, 0.0, + 0.0, 0.0, 0.0, 1.0, + } +end + +local function clamp(value, min_value, max_value) + if value < min_value then + return min_value + end + if value > max_value then + return max_value + end + return value +end + +local function normalize(vec) + local x, y, z = vec[1], vec[2], vec[3] + local len = math.sqrt(x * x + y * y + z * z) + if len == 0.0 then + return {x, y, z} + end + return {x / len, y / len, z / len} +end + +local function cross(a, b) + return { + a[2] * b[3] - a[3] * b[2], + a[3] * b[1] - a[1] * b[3], + a[1] * b[2] - a[2] * b[1], + } +end + +local function forward_from_angles(yaw, pitch) + local cos_pitch = math.cos(pitch) + return { + math.cos(yaw) * cos_pitch, + math.sin(pitch), + math.sin(yaw) * cos_pitch, + } +end + +local function is_action_down(action_name, fallback_key) + if type(input_is_action_down) == "function" then + return input_is_action_down(action_name) + end + if type(input_is_key_down) == "function" and fallback_key then + return input_is_key_down(fallback_key) + end + return false +end + +local fallback_bindings = { + move_forward = "W", + move_back = "S", + move_left = "A", + move_right = "D", + fly_up = "Q", + fly_down = "Z", +} + +local input_bindings = resolve_table(type(config) == "table" and config.input_bindings) +local function get_binding(action_name) + if type(input_bindings[action_name]) == "string" then + return input_bindings[action_name] + end + return fallback_bindings[action_name] +end + +local function build_map_model_matrix() + local translation = math3d.translation(map_offset[1], map_offset[2], map_offset[3]) + local rotation_x = math3d.rotation_x(math.rad(map_rotate_x)) + local rotation_y = math3d.rotation_y(math.rad(map_rotate_y)) + local rotation = math3d.multiply(rotation_y, rotation_x) + if map_rotate_z ~= 0.0 then + local rotation_z_matrix = rotation_z(math.rad(map_rotate_z)) + rotation = math3d.multiply(rotation_z_matrix, rotation) + end + local scaling = scale_matrix(map_scale, map_scale, map_scale) + return math3d.multiply(translation, math3d.multiply(rotation, scaling)) +end + +local map_model_matrix = build_map_model_matrix() + +if type(load_mesh_from_pk3) ~= "function" then + error("load_mesh_from_pk3() is unavailable; rebuild with libzip support") +end + +local map_mesh, map_error = load_mesh_from_pk3(pk3_path, map_entry) +if not map_mesh then + error("Unable to load Quake 3 map: " .. tostring(map_error)) +end + +log_debug("Loaded Quake 3 map %s from %s (%d vertices, %d indices)", + map_entry, + pk3_path, + #map_mesh.vertices, + #map_mesh.indices) + +local camera_config = resolve_table(quake3_config.camera) +local camera = { + position = resolve_vec3(camera_config.position, {0.0, 48.0, 0.0}), + yaw = math.rad(resolve_number(camera_config.yaw_degrees, 0.0)), + pitch = math.rad(resolve_number(camera_config.pitch_degrees, 0.0)), + fov = resolve_number(camera_config.fov, 0.85), + near = resolve_number(camera_config.near, 0.1), + far = resolve_number(camera_config.far, 2000.0), +} + +local controls = { + move_speed = resolve_number(quake3_config.move_speed, 14.0), + fly_speed = resolve_number(quake3_config.fly_speed, 10.0), + mouse_sensitivity = resolve_number(quake3_config.mouse_sensitivity, 0.0025), + max_pitch = math.rad(85.0), +} + +local last_frame_time = nil +local world_up = {0.0, 1.0, 0.0} + +local function update_camera(dt) + local look_delta_x = 0.0 + local look_delta_y = 0.0 + if type(input_get_mouse_delta) == "function" then + local dx, dy = input_get_mouse_delta() + if type(dx) == "number" then + look_delta_x = dx * controls.mouse_sensitivity + end + if type(dy) == "number" then + look_delta_y = -dy * controls.mouse_sensitivity + end + end + + camera.yaw = camera.yaw + look_delta_x + camera.pitch = clamp(camera.pitch + look_delta_y, -controls.max_pitch, controls.max_pitch) + + local forward = forward_from_angles(camera.yaw, camera.pitch) + local forward_flat = normalize({forward[1], 0.0, forward[3]}) + local right = normalize(cross(forward_flat, world_up)) + + local move_x = 0.0 + local move_z = 0.0 + local move_y = 0.0 + + if is_action_down("move_forward", get_binding("move_forward")) then + move_z = move_z + 1.0 + end + if is_action_down("move_back", get_binding("move_back")) then + move_z = move_z - 1.0 + end + if is_action_down("move_right", get_binding("move_right")) then + move_x = move_x + 1.0 + end + if is_action_down("move_left", get_binding("move_left")) then + move_x = move_x - 1.0 + end + if is_action_down("fly_up", get_binding("fly_up")) then + move_y = move_y + 1.0 + end + if is_action_down("fly_down", get_binding("fly_down")) then + move_y = move_y - 1.0 + end + + local length = math.sqrt(move_x * move_x + move_z * move_z) + if length > 1.0 then + move_x = move_x / length + move_z = move_z / length + end + + local planar_speed = controls.move_speed * dt + camera.position[1] = camera.position[1] + (right[1] * move_x + forward_flat[1] * move_z) * planar_speed + camera.position[2] = camera.position[2] + (right[2] * move_x + forward_flat[2] * move_z) * planar_speed + camera.position[3] = camera.position[3] + (right[3] * move_x + forward_flat[3] * move_z) * planar_speed + + if move_y ~= 0.0 then + camera.position[2] = camera.position[2] + move_y * controls.fly_speed * dt + end +end + +local shader_variants_module = require("shader_variants") +local shader_variants = shader_variants_module.build_cube_variants(config, log_debug) + +function get_scene_objects() + return { + { + vertices = map_mesh.vertices, + indices = map_mesh.indices, + shader_key = map_shader_key, + compute_model_matrix = function() + return map_model_matrix + end, + }, + } +end + +function get_shader_paths() + return shader_variants +end + +local function build_view_state(aspect) + local now = os.clock() + local dt = 0.0 + if last_frame_time then + dt = now - last_frame_time + end + last_frame_time = now + if dt < 0.0 then + dt = 0.0 + elseif dt > 0.1 then + dt = 0.1 + end + + update_camera(dt) + + local forward = forward_from_angles(camera.yaw, camera.pitch) + local center = { + camera.position[1] + forward[1], + camera.position[2] + forward[2], + camera.position[3] + forward[3], + } + + local view = math3d.look_at(camera.position, center, world_up) + local projection = math3d.perspective(camera.fov, aspect, camera.near, camera.far) + + return { + view = view, + proj = projection, + view_proj = math3d.multiply(projection, view), + camera_pos = {camera.position[1], camera.position[2], camera.position[3]}, + } +end + +function get_view_state(aspect) + return build_view_state(aspect) +end + +function get_view_projection(aspect) + local state = build_view_state(aspect) + return state.view_proj +end diff --git a/src/services/impl/materialx_shader_generator.cpp b/src/services/impl/materialx_shader_generator.cpp index 6485693..09a55ac 100644 --- a/src/services/impl/materialx_shader_generator.cpp +++ b/src/services/impl/materialx_shader_generator.cpp @@ -13,39 +13,52 @@ #include #include #include +#include namespace sdl3cpp::services::impl { namespace mx = MaterialX; namespace { -bool HasVertexDataBlock(const std::string& source) { - return source.find("VertexData") != std::string::npos - && source.find("vd;") != std::string::npos; +std::optional FindVertexDataBlock(const std::string& source) { + const std::string blockName = "VertexData"; + const std::string instanceToken = "vd;"; + size_t searchPos = 0; + while (true) { + size_t blockPos = source.find(blockName, searchPos); + if (blockPos == std::string::npos) { + return std::nullopt; + } + size_t lineStart = source.rfind('\n', blockPos); + if (lineStart == std::string::npos) { + lineStart = 0; + } else { + ++lineStart; + } + size_t lineEnd = source.find('\n', blockPos); + if (lineEnd == std::string::npos) { + lineEnd = source.size(); + } + std::string_view header(source.data() + lineStart, lineEnd - lineStart); + if (header.find("layout") == std::string_view::npos) { + searchPos = blockPos + blockName.size(); + continue; + } + size_t instancePos = source.find(instanceToken, blockPos); + if (instancePos == std::string::npos) { + searchPos = blockPos + blockName.size(); + continue; + } + size_t blockEnd = source.find('\n', instancePos); + if (blockEnd == std::string::npos) { + blockEnd = source.size(); + } + return source.substr(lineStart, blockEnd - lineStart); + } } -std::optional ExtractVertexDataBlock(const std::string& source) { - const std::string marker = "VertexData"; - const std::string instance = "vd;"; - size_t blockPos = source.find(marker); - if (blockPos == std::string::npos) { - return std::nullopt; - } - size_t instancePos = source.find(instance, blockPos); - if (instancePos == std::string::npos) { - return std::nullopt; - } - size_t lineStart = source.rfind('\n', blockPos); - if (lineStart == std::string::npos) { - lineStart = 0; - } else { - ++lineStart; - } - size_t lineEnd = source.find('\n', instancePos); - if (lineEnd == std::string::npos) { - lineEnd = source.size(); - } - return source.substr(lineStart, lineEnd - lineStart); +bool UsesVertexDataInstance(const std::string& source) { + return source.find("vd.") != std::string::npos; } std::string ToVertexOutputBlock(std::string block) { @@ -75,6 +88,15 @@ void InsertAfterVersion(std::string& source, const std::string& block) { source.insert(lineEnd, block + "\n"); } +bool ReplaceFirstOccurrence(std::string& source, const std::string& before, const std::string& after) { + size_t pos = source.find(before); + if (pos == std::string::npos) { + return false; + } + source.replace(pos, before.size(), after); + return true; +} + } // namespace MaterialXShaderGenerator::MaterialXShaderGenerator(std::shared_ptr logger) @@ -222,25 +244,40 @@ ShaderPaths MaterialXShaderGenerator::Generate(const MaterialXConfig& config, paths.vertexSource = shader->getSourceCode(mx::Stage::VERTEX); paths.fragmentSource = shader->getSourceCode(mx::Stage::PIXEL); - const bool vertexHasBlock = HasVertexDataBlock(paths.vertexSource); - const bool fragmentHasBlock = HasVertexDataBlock(paths.fragmentSource); - if (!vertexHasBlock && fragmentHasBlock) { - auto fragmentBlock = ExtractVertexDataBlock(paths.fragmentSource); - if (fragmentBlock) { - std::string vertexBlock = ToVertexOutputBlock(*fragmentBlock); - InsertAfterVersion(paths.vertexSource, vertexBlock); - if (logger_) { - logger_->Trace("MaterialXShaderGenerator", "Generate", - "vertexDataBlock=inserted"); + auto vertexBlock = FindVertexDataBlock(paths.vertexSource); + auto fragmentBlock = FindVertexDataBlock(paths.fragmentSource); + const bool vertexUsesInstance = UsesVertexDataInstance(paths.vertexSource); + bool vertexHasBlock = vertexBlock.has_value(); + const bool fragmentHasBlock = fragmentBlock.has_value(); + + if (vertexHasBlock) { + std::string normalizedBlock = ToVertexOutputBlock(*vertexBlock); + if (normalizedBlock != *vertexBlock) { + if (ReplaceFirstOccurrence(paths.vertexSource, *vertexBlock, normalizedBlock)) { + if (logger_) { + logger_->Trace("MaterialXShaderGenerator", "Generate", + "vertexDataBlock=normalized"); + } } - } else if (logger_) { + } + } else if (fragmentHasBlock) { + std::string vertexOutBlock = ToVertexOutputBlock(*fragmentBlock); + InsertAfterVersion(paths.vertexSource, vertexOutBlock); + vertexHasBlock = true; + if (logger_) { logger_->Trace("MaterialXShaderGenerator", "Generate", - "vertexDataBlock=missing"); + "vertexDataBlock=inserted"); } } else if (logger_) { + logger_->Trace("MaterialXShaderGenerator", "Generate", + "vertexDataBlock=missing, fragmentVertexDataBlock=missing"); + } + + if (logger_) { logger_->Trace("MaterialXShaderGenerator", "Generate", "vertexDataBlock=" + std::string(vertexHasBlock ? "present" : "absent") + - ", fragmentVertexDataBlock=" + std::string(fragmentHasBlock ? "present" : "absent")); + ", fragmentVertexDataBlock=" + std::string(fragmentHasBlock ? "present" : "absent") + + ", vertexUsesVertexData=" + std::string(vertexUsesInstance ? "true" : "false")); } return paths; } diff --git a/src/services/impl/mesh_service.cpp b/src/services/impl/mesh_service.cpp index 5e0bb8b..1f8f571 100644 --- a/src/services/impl/mesh_service.cpp +++ b/src/services/impl/mesh_service.cpp @@ -6,91 +6,109 @@ #include #include #include +#include #include #include +#include namespace sdl3cpp::services::impl { +namespace { +constexpr unsigned int kAssimpLoadFlags = + aiProcess_Triangulate | + aiProcess_JoinIdenticalVertices | + aiProcess_PreTransformVertices | + aiProcess_GenNormals; -MeshService::MeshService(std::shared_ptr configService, - std::shared_ptr logger) - : configService_(std::move(configService)), - logger_(std::move(logger)) { - if (logger_) { - logger_->Trace("MeshService", "MeshService", - "configService=" + std::string(configService_ ? "set" : "null")); +struct ZipArchiveDeleter { + void operator()(zip_t* archive) const { + if (archive) { + zip_close(archive); + } } +}; + +struct ZipFileDeleter { + void operator()(zip_file_t* file) const { + if (file) { + zip_fclose(file); + } + } +}; + +std::string BuildZipErrorMessage(int errorCode) { + zip_error_t zipError; + zip_error_init_with_code(&zipError, errorCode); + std::string message = zip_error_strerror(&zipError); + zip_error_fini(&zipError); + return message; } -bool MeshService::LoadFromFile(const std::string& requestedPath, - MeshPayload& outPayload, - std::string& outError) { - if (logger_) { - logger_->Trace("MeshService", "LoadFromFile", - "requestedPath=" + requestedPath); +std::string BuildZipArchiveErrorMessage(zip_t* archive) { + if (!archive) { + return "unknown zip archive error"; } - if (!configService_) { - outError = "Config service not available"; - return false; + zip_error_t* zipError = zip_get_error(archive); + if (!zipError) { + return "unknown zip archive error"; } + return zip_error_strerror(zipError); +} - std::filesystem::path resolved(requestedPath); - if (!resolved.is_absolute()) { - resolved = configService_->GetScriptPath().parent_path() / resolved; +std::string GetExtensionHint(const std::string& entryPath, const std::string& fallback) { + std::filesystem::path entry(entryPath); + std::string ext = entry.extension().string(); + if (!ext.empty() && ext.front() == '.') { + ext.erase(ext.begin()); } - - std::error_code ec; - resolved = std::filesystem::weakly_canonical(resolved, ec); - if (ec) { - outError = "Failed to resolve mesh path: " + ec.message(); - return false; + if (!ext.empty()) { + return ext; } + return fallback; +} - if (!std::filesystem::exists(resolved)) { - outError = "Mesh file not found: " + resolved.string(); - return false; - } - - Assimp::Importer importer; - const aiScene* scene = importer.ReadFile( - resolved.string(), - aiProcess_Triangulate | aiProcess_JoinIdenticalVertices | - aiProcess_PreTransformVertices | aiProcess_GenNormals); - - if (!scene) { - outError = importer.GetErrorString() ? importer.GetErrorString() : "Assimp failed to load mesh"; - return false; - } - - if (scene->mNumMeshes == 0) { - outError = "Scene contains no meshes"; - return false; - } - - const aiMesh* mesh = scene->mMeshes[0]; - if (!mesh->mNumVertices) { - outError = "Mesh contains no vertices"; - return false; - } - - outPayload.positions.clear(); - outPayload.normals.clear(); - outPayload.colors.clear(); - outPayload.indices.clear(); - outPayload.positions.reserve(mesh->mNumVertices); - outPayload.normals.reserve(mesh->mNumVertices); - outPayload.colors.reserve(mesh->mNumVertices); - outPayload.indices.reserve(mesh->mNumFaces * 3); - +aiColor3D ResolveMaterialColor(const aiScene* scene, const aiMesh* mesh) { aiColor3D defaultColor(0.6f, 0.8f, 1.0f); - aiColor3D materialColor = defaultColor; - + if (!scene || !mesh) { + return defaultColor; + } if (mesh->mMaterialIndex < scene->mNumMaterials) { const aiMaterial* material = scene->mMaterials[mesh->mMaterialIndex]; aiColor4D diffuse; if (material && material->Get(AI_MATKEY_COLOR_DIFFUSE, diffuse) == AI_SUCCESS) { - materialColor = aiColor3D(diffuse.r, diffuse.g, diffuse.b); + return aiColor3D(diffuse.r, diffuse.g, diffuse.b); } } + return defaultColor; +} + +bool AppendMeshPayload(const aiScene* scene, + const aiMesh* mesh, + MeshPayload& outPayload, + std::string& outError, + size_t& outIndicesAdded) { + outIndicesAdded = 0; + if (!mesh || !mesh->mNumVertices) { + outError = "Mesh contains no vertices"; + return false; + } + + size_t vertexOffset = outPayload.positions.size(); + if (vertexOffset > std::numeric_limits::max()) { + outError = "Mesh vertex count exceeds uint32_t index range"; + return false; + } + + aiColor3D materialColor = ResolveMaterialColor(scene, mesh); + + size_t positionsStart = outPayload.positions.size(); + size_t normalsStart = outPayload.normals.size(); + size_t colorsStart = outPayload.colors.size(); + size_t indicesStart = outPayload.indices.size(); + + outPayload.positions.reserve(positionsStart + mesh->mNumVertices); + outPayload.normals.reserve(normalsStart + mesh->mNumVertices); + outPayload.colors.reserve(colorsStart + mesh->mNumVertices); + outPayload.indices.reserve(indicesStart + mesh->mNumFaces * 3); for (unsigned i = 0; i < mesh->mNumVertices; ++i) { const aiVector3D& vertex = mesh->mVertices[i]; @@ -115,12 +133,20 @@ bool MeshService::LoadFromFile(const std::string& requestedPath, if (face.mNumIndices != 3) { continue; } - outPayload.indices.push_back(face.mIndices[0]); - outPayload.indices.push_back(face.mIndices[1]); - outPayload.indices.push_back(face.mIndices[2]); + outPayload.indices.push_back(static_cast(face.mIndices[0]) + + static_cast(vertexOffset)); + outPayload.indices.push_back(static_cast(face.mIndices[1]) + + static_cast(vertexOffset)); + outPayload.indices.push_back(static_cast(face.mIndices[2]) + + static_cast(vertexOffset)); } - if (outPayload.indices.empty()) { + outIndicesAdded = outPayload.indices.size() - indicesStart; + if (outIndicesAdded == 0) { + outPayload.positions.resize(positionsStart); + outPayload.normals.resize(normalsStart); + outPayload.colors.resize(colorsStart); + outPayload.indices.resize(indicesStart); outError = "Mesh contains no triangle faces"; return false; } @@ -128,6 +154,216 @@ bool MeshService::LoadFromFile(const std::string& requestedPath, return true; } +bool BuildPayloadFromScene(const aiScene* scene, + bool combineMeshes, + MeshPayload& outPayload, + std::string& outError, + const std::shared_ptr& logger) { + if (!scene) { + outError = "Assimp scene is null"; + return false; + } + if (scene->mNumMeshes == 0) { + outError = "Scene contains no meshes"; + return false; + } + + outPayload.positions.clear(); + outPayload.normals.clear(); + outPayload.colors.clear(); + outPayload.indices.clear(); + + if (!combineMeshes) { + size_t indicesAdded = 0; + if (!AppendMeshPayload(scene, scene->mMeshes[0], outPayload, outError, indicesAdded)) { + return false; + } + return true; + } + + size_t totalIndicesAdded = 0; + for (unsigned meshIndex = 0; meshIndex < scene->mNumMeshes; ++meshIndex) { + const aiMesh* mesh = scene->mMeshes[meshIndex]; + std::string meshError; + size_t indicesAdded = 0; + if (!AppendMeshPayload(scene, mesh, outPayload, meshError, indicesAdded)) { + if (logger) { + logger->Trace("MeshService", "BuildPayloadFromScene", + "Skipping mesh " + std::to_string(meshIndex) + ": " + meshError); + } + continue; + } + totalIndicesAdded += indicesAdded; + } + + if (totalIndicesAdded == 0) { + outError = "Scene contains no triangle faces"; + return false; + } + + return true; +} +} // namespace + +MeshService::MeshService(std::shared_ptr configService, + std::shared_ptr logger) + : configService_(std::move(configService)), + logger_(std::move(logger)) { + if (logger_) { + logger_->Trace("MeshService", "MeshService", + "configService=" + std::string(configService_ ? "set" : "null")); + } +} + +bool MeshService::LoadFromFile(const std::string& requestedPath, + MeshPayload& outPayload, + std::string& outError) { + if (logger_) { + logger_->Trace("MeshService", "LoadFromFile", + "requestedPath=" + requestedPath); + } + std::filesystem::path resolved; + if (!ResolvePath(requestedPath, resolved, outError)) { + return false; + } + + if (!std::filesystem::exists(resolved)) { + outError = "Mesh file not found: " + resolved.string(); + return false; + } + + Assimp::Importer importer; + const aiScene* scene = importer.ReadFile(resolved.string(), kAssimpLoadFlags); + + if (!scene) { + outError = importer.GetErrorString() ? importer.GetErrorString() : "Assimp failed to load mesh"; + return false; + } + return BuildPayloadFromScene(scene, false, outPayload, outError, logger_); +} + +bool MeshService::LoadFromArchive(const std::string& archivePath, + const std::string& entryPath, + MeshPayload& outPayload, + std::string& outError) { + if (logger_) { + logger_->Trace("MeshService", "LoadFromArchive", + "archivePath=" + archivePath + + ", entryPath=" + entryPath); + } + + std::filesystem::path resolvedArchive; + if (!ResolvePath(archivePath, resolvedArchive, outError)) { + return false; + } + if (!std::filesystem::exists(resolvedArchive)) { + outError = "Archive file not found: " + resolvedArchive.string(); + return false; + } + if (entryPath.empty()) { + outError = "Archive entry path is empty"; + return false; + } + + int errorCode = 0; + std::unique_ptr archive( + zip_open(resolvedArchive.string().c_str(), ZIP_RDONLY, &errorCode)); + if (!archive) { + outError = "Failed to open archive: " + BuildZipErrorMessage(errorCode); + return false; + } + + zip_stat_t entryStat; + if (zip_stat(archive.get(), entryPath.c_str(), ZIP_FL_ENC_GUESS, &entryStat) != 0) { + outError = "Archive entry not found: " + entryPath; + return false; + } + if (entryStat.size == 0) { + outError = "Archive entry is empty: " + entryPath; + return false; + } + if (entryStat.size > std::numeric_limits::max()) { + outError = "Archive entry exceeds addressable size: " + entryPath; + return false; + } + + std::unique_ptr file( + zip_fopen(archive.get(), entryPath.c_str(), ZIP_FL_ENC_GUESS)); + if (!file) { + outError = "Failed to open archive entry: " + BuildZipArchiveErrorMessage(archive.get()); + return false; + } + + size_t entrySize = static_cast(entryStat.size); + std::vector buffer(entrySize); + zip_int64_t totalRead = 0; + while (static_cast(totalRead) < entrySize) { + zip_int64_t bytesRead = zip_fread(file.get(), + buffer.data() + totalRead, + entrySize - static_cast(totalRead)); + if (bytesRead < 0) { + outError = "Failed to read archive entry: " + BuildZipArchiveErrorMessage(archive.get()); + return false; + } + if (bytesRead == 0) { + break; + } + totalRead += bytesRead; + } + if (static_cast(totalRead) != entrySize) { + outError = "Archive entry read incomplete: " + entryPath; + return false; + } + + std::string extensionHint = GetExtensionHint(entryPath, "bsp"); + Assimp::Importer importer; + const aiScene* scene = importer.ReadFileFromMemory( + buffer.data(), + buffer.size(), + kAssimpLoadFlags, + extensionHint.c_str()); + if (!scene) { + outError = importer.GetErrorString() ? importer.GetErrorString() + : "Assimp failed to load archive entry"; + return false; + } + + if (!BuildPayloadFromScene(scene, true, outPayload, outError, logger_)) { + return false; + } + if (outPayload.positions.size() > std::numeric_limits::max()) { + outError = "Mesh vertex count exceeds uint16_t index range: " + + std::to_string(outPayload.positions.size()); + return false; + } + + return true; +} + +bool MeshService::ResolvePath(const std::string& requestedPath, + std::filesystem::path& resolvedPath, + std::string& outError) const { + if (!configService_) { + outError = "Config service not available"; + return false; + } + + std::filesystem::path resolved(requestedPath); + if (!resolved.is_absolute()) { + resolved = configService_->GetScriptPath().parent_path() / resolved; + } + + std::error_code ec; + resolved = std::filesystem::weakly_canonical(resolved, ec); + if (ec) { + outError = "Failed to resolve path: " + ec.message(); + return false; + } + + resolvedPath = std::move(resolved); + return true; +} + void MeshService::PushMeshToLua(lua_State* L, const MeshPayload& payload) { if (logger_) { logger_->Trace("MeshService", "PushMeshToLua", diff --git a/src/services/impl/mesh_service.hpp b/src/services/impl/mesh_service.hpp index 54a4655..87c9acb 100644 --- a/src/services/impl/mesh_service.hpp +++ b/src/services/impl/mesh_service.hpp @@ -3,6 +3,7 @@ #include "../interfaces/i_mesh_service.hpp" #include "../interfaces/i_config_service.hpp" #include "../interfaces/i_logger.hpp" +#include #include namespace sdl3cpp::services::impl { @@ -18,9 +19,17 @@ public: bool LoadFromFile(const std::string& requestedPath, MeshPayload& outPayload, std::string& outError) override; + bool LoadFromArchive(const std::string& archivePath, + const std::string& entryPath, + MeshPayload& outPayload, + std::string& outError) override; void PushMeshToLua(lua_State* L, const MeshPayload& payload) override; private: + bool ResolvePath(const std::string& requestedPath, + std::filesystem::path& resolvedPath, + std::string& outError) const; + std::shared_ptr configService_; std::shared_ptr logger_; }; diff --git a/src/services/impl/script_engine_service.cpp b/src/services/impl/script_engine_service.cpp index 0c24fcc..655dbce 100644 --- a/src/services/impl/script_engine_service.cpp +++ b/src/services/impl/script_engine_service.cpp @@ -471,6 +471,7 @@ void ScriptEngineService::RegisterBindings(lua_State* L) { }; bind("load_mesh_from_file", &ScriptEngineService::LoadMeshFromFile); + bind("load_mesh_from_pk3", &ScriptEngineService::LoadMeshFromArchive); bind("physics_create_box", &ScriptEngineService::PhysicsCreateBox); bind("physics_step_simulation", &ScriptEngineService::PhysicsStepSimulation); bind("physics_get_transform", &ScriptEngineService::PhysicsGetTransform); @@ -528,6 +529,36 @@ int ScriptEngineService::LoadMeshFromFile(lua_State* L) { return 2; } +int ScriptEngineService::LoadMeshFromArchive(lua_State* L) { + auto* context = static_cast(lua_touserdata(L, lua_upvalueindex(1))); + auto logger = context ? context->logger : nullptr; + if (!context || !context->meshService) { + lua_pushnil(L); + lua_pushstring(L, "Mesh service not available"); + return 2; + } + + const char* archivePath = luaL_checkstring(L, 1); + const char* entryPath = luaL_checkstring(L, 2); + if (logger) { + logger->Trace("ScriptEngineService", "LoadMeshFromArchive", + "archivePath=" + std::string(archivePath) + + ", entryPath=" + std::string(entryPath)); + } + + MeshPayload payload; + std::string error; + if (!context->meshService->LoadFromArchive(archivePath, entryPath, payload, error)) { + lua_pushnil(L); + lua_pushstring(L, error.c_str()); + return 2; + } + + context->meshService->PushMeshToLua(L, payload); + lua_pushnil(L); + return 2; +} + int ScriptEngineService::PhysicsCreateBox(lua_State* L) { auto* context = static_cast(lua_touserdata(L, lua_upvalueindex(1))); auto logger = context ? context->logger : nullptr; diff --git a/src/services/impl/script_engine_service.hpp b/src/services/impl/script_engine_service.hpp index 59f9646..2294e7b 100644 --- a/src/services/impl/script_engine_service.hpp +++ b/src/services/impl/script_engine_service.hpp @@ -61,6 +61,7 @@ private: void RegisterBindings(lua_State* L); static int LoadMeshFromFile(lua_State* L); + static int LoadMeshFromArchive(lua_State* L); static int PhysicsCreateBox(lua_State* L); static int PhysicsStepSimulation(lua_State* L); static int PhysicsGetTransform(lua_State* L); diff --git a/src/services/interfaces/i_mesh_service.hpp b/src/services/interfaces/i_mesh_service.hpp index 398d4a4..97a35cb 100644 --- a/src/services/interfaces/i_mesh_service.hpp +++ b/src/services/interfaces/i_mesh_service.hpp @@ -17,6 +17,10 @@ public: virtual bool LoadFromFile(const std::string& requestedPath, MeshPayload& outPayload, std::string& outError) = 0; + virtual bool LoadFromArchive(const std::string& archivePath, + const std::string& entryPath, + MeshPayload& outPayload, + std::string& outError) = 0; virtual void PushMeshToLua(lua_State* L, const MeshPayload& payload) = 0; };