feat: Add support for loading meshes from PK3 archives

- Updated CMakeLists.txt to find and link libzip.
- Modified conanfile.py to include libzip as a dependency.
- Created a new configuration file for Quake 3 runtime settings.
- Implemented loading of Quake 3 maps from PK3 archives in mesh_service.
- Added new Lua bindings for loading meshes from archives in script_engine_service.
- Enhanced material handling in materialx_shader_generator to support vertex data blocks.
This commit is contained in:
2026-01-06 17:33:43 +00:00
parent 7ec101cbf6
commit 7b6f2d4567
10 changed files with 823 additions and 106 deletions

View File

@@ -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

View File

@@ -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",

View File

@@ -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"
}

306
scripts/quake3_arena.lua Normal file
View File

@@ -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

View File

@@ -13,39 +13,52 @@
#include <optional>
#include <stdexcept>
#include <string>
#include <string_view>
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<std::string> 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<std::string> 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<ILogger> 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;
}

View File

@@ -6,91 +6,109 @@
#include <assimp/postprocess.h>
#include <assimp/scene.h>
#include <filesystem>
#include <limits>
#include <lua.hpp>
#include <system_error>
#include <zip.h>
namespace sdl3cpp::services::impl {
namespace {
constexpr unsigned int kAssimpLoadFlags =
aiProcess_Triangulate |
aiProcess_JoinIdenticalVertices |
aiProcess_PreTransformVertices |
aiProcess_GenNormals;
MeshService::MeshService(std::shared_ptr<IConfigService> configService,
std::shared_ptr<ILogger> 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<uint32_t>::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<uint32_t>(face.mIndices[0]) +
static_cast<uint32_t>(vertexOffset));
outPayload.indices.push_back(static_cast<uint32_t>(face.mIndices[1]) +
static_cast<uint32_t>(vertexOffset));
outPayload.indices.push_back(static_cast<uint32_t>(face.mIndices[2]) +
static_cast<uint32_t>(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<ILogger>& 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<IConfigService> configService,
std::shared_ptr<ILogger> 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<zip_t, ZipArchiveDeleter> 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<size_t>::max()) {
outError = "Archive entry exceeds addressable size: " + entryPath;
return false;
}
std::unique_ptr<zip_file_t, ZipFileDeleter> 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<size_t>(entryStat.size);
std::vector<uint8_t> buffer(entrySize);
zip_int64_t totalRead = 0;
while (static_cast<size_t>(totalRead) < entrySize) {
zip_int64_t bytesRead = zip_fread(file.get(),
buffer.data() + totalRead,
entrySize - static_cast<size_t>(totalRead));
if (bytesRead < 0) {
outError = "Failed to read archive entry: " + BuildZipArchiveErrorMessage(archive.get());
return false;
}
if (bytesRead == 0) {
break;
}
totalRead += bytesRead;
}
if (static_cast<size_t>(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<uint16_t>::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",

View File

@@ -3,6 +3,7 @@
#include "../interfaces/i_mesh_service.hpp"
#include "../interfaces/i_config_service.hpp"
#include "../interfaces/i_logger.hpp"
#include <filesystem>
#include <memory>
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<IConfigService> configService_;
std::shared_ptr<ILogger> logger_;
};

View File

@@ -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<LuaBindingContext*>(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<LuaBindingContext*>(lua_touserdata(L, lua_upvalueindex(1)));
auto logger = context ? context->logger : nullptr;

View File

@@ -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);

View File

@@ -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;
};