Unravel Engine C++ Reference
Loading...
Searching...
No Matches
mesh_importer.cpp
Go to the documentation of this file.
1#include "mesh_importer.h"
2#include "bimg/bimg.h"
3#include "bimg/decode.h"
4#include "bimg/encode.h"
5
7#include "../asset_writer.h"
8
9#include <graphics/graphics.h>
10#include <logging/logging.h>
11#include <math/math.h>
12#include <string_utils/utils.h>
13
14#include <assimp/DefaultLogger.hpp>
15#include <assimp/GltfMaterial.h>
16#include <assimp/IOStream.hpp>
17#include <assimp/IOSystem.hpp>
18#include <assimp/Importer.hpp>
19#include <assimp/LogStream.hpp>
20#include <assimp/ProgressHandler.hpp>
21#include <assimp/material.h>
22#include <assimp/postprocess.h>
23#include <assimp/scene.h>
24#include <array>
25#include <bx/allocator.h>
26#include <bx/file.h>
28
29#include <algorithm>
30#include <cmath>
32#include <functional>
33#include <optional>
34#include <numeric>
35#include <queue>
36#include <string_view>
37#include <tuple>
38#include <unordered_set>
39#include <unordered_map>
40
41#define POOLSTL_STD_SUPPLEMENT 1
42#include <poolstl/poolstl.hpp>
43
44namespace unravel
45{
46namespace importer
47{
48namespace
49{
50
51// Forward declarations
52void apply_texture_conversion(bimg::ImageContainer* image, const std::string& semantic, bool inverse);
53void process_raw_texture_data(const aiTexture* assimp_tex, const fs::path& output_file,
54 const std::string& semantic, bool inverse);
55void apply_specular_to_metallic_roughness_conversion(bimg::ImageContainer* image);
56auto atomic_image_save(const fs::path& output_file, bimg::ImageContainer* image) -> bool;
57
64struct spec_gloss_factors_t
65{
66 float diffuse_r{1.0f};
67 float diffuse_g{1.0f};
68 float diffuse_b{1.0f};
69 float diffuse_a{1.0f};
70 float specular_r{1.0f};
71 float specular_g{1.0f};
72 float specular_b{1.0f};
73 float glossiness{1.0f};
74};
75
76void apply_diffuse_to_base_color_conversion(bimg::ImageContainer* diffuse_image,
77 const bimg::ImageContainer* specular_image,
78 const spec_gloss_factors_t& factors,
79 std::vector<uint8_t>* out_mr_rgba8 = nullptr,
80 bool rewrite_diffuse_to_base_color = true);
81auto perceived_brightness(float r, float g, float b) -> float;
82auto solve_metallic(float perceived_diffuse, float perceived_specular, float one_minus_specular_strength) -> float;
83auto convert_specular_gloss_to_metallic_roughness(const aiColor3D& diffuse_color,
84 const aiColor3D& specular_color,
85 float glossiness_factor) -> std::tuple<aiColor3D, float, float>;
86
92inline auto is_supported_ldr_format(bimg::TextureFormat::Enum format) -> bool
93{
94 if(bimg::isCompressed(format) || bimg::isFloat(format))
95 {
96 return false;
97 }
98 uint32_t bpp = bimg::getBitsPerPixel(format);
99 return bpp == 8 || bpp == 16 || bpp == 24 || bpp == 32;
100}
101
107inline auto get_bimg_allocator() -> bx::AllocatorI*
108{
109 static thread_local bx::DefaultAllocator allocator;
110 return &allocator;
111}
112
122auto ensure_rgba8(bimg::ImageContainer* image, bool& owns_result) -> bimg::ImageContainer*
123{
124 owns_result = false;
125 if(!image)
126 {
127 return nullptr;
128 }
129 if(image->m_format == bimg::TextureFormat::RGBA8)
130 {
131 return image;
132 }
133 auto* converted = bimg::imageConvert(get_bimg_allocator(), bimg::TextureFormat::RGBA8, *image);
134 if(!converted)
135 {
136 APPLOG_WARNING("Mesh Importer: Failed to convert image to RGBA8 (source format = {})",
137 bimg::getName(image->m_format));
138 return nullptr;
139 }
140 owns_result = true;
141 return converted;
142}
143
148auto resize_rgba8_image_to(const bimg::ImageContainer* src, uint32_t target_w, uint32_t target_h) -> bimg::ImageContainer*
149{
150 if(!src || !src->m_data || target_w == 0 || target_h == 0)
151 {
152 return nullptr;
153 }
154 if(src->m_width == target_w && src->m_height == target_h)
155 {
156 return nullptr;
157 }
158
159 bimg::ImageContainer* src32f =
160 bimg::imageConvert(get_bimg_allocator(), bimg::TextureFormat::RGBA32F, *src, false);
161 if(!src32f)
162 {
163 return nullptr;
164 }
165
166 bimg::ImageContainer* dst32f = bimg::imageAlloc(get_bimg_allocator(),
167 bimg::TextureFormat::RGBA32F,
168 static_cast<uint16_t>(target_w),
169 static_cast<uint16_t>(target_h),
170 1,
171 1,
172 false,
173 false);
174 if(!dst32f || !bimg::imageResizeRgba32fLinear(dst32f, src32f))
175 {
176 bimg::imageFree(src32f);
177 if(dst32f)
178 {
179 bimg::imageFree(dst32f);
180 }
181 return nullptr;
182 }
183
184 bimg::imageFree(src32f);
185
186 auto* dst8 = bimg::imageConvert(get_bimg_allocator(), bimg::TextureFormat::RGBA8, *dst32f, false);
187 bimg::imageFree(dst32f);
188 return dst8;
189}
190
191auto has_rotation_channel(const aiAnimation* animation, const std::string& nodeName) -> bool
192{
193 if(!animation)
194 {
195 return false;
196 }
197
198 for(unsigned int ch = 0; ch < animation->mNumChannels; ++ch)
199 {
200 aiNodeAnim* channel = animation->mChannels[ch];
201 if(!channel)
202 {
203 continue;
204 }
205
206 // Compare the channel's node name with the given nodeName.
207 if(std::string(channel->mNodeName.C_Str()) == nodeName)
208 {
209 // If the channel has position keys, then the node is animated in translation.
210 if(channel->mNumRotationKeys > 1)
211 {
212 return true;
213 }
214 }
215 }
216
217 return false;
218}
219
220auto has_rotation_channel(const aiScene* scene, const std::string& nodeName) -> bool
221{
222 for(unsigned int animIdx = 0; animIdx < scene->mNumAnimations; ++animIdx)
223 {
224 aiAnimation* animation = scene->mAnimations[animIdx];
225
226 if(has_rotation_channel(animation, nodeName))
227 {
228 return true;
229 }
230 }
231 return false;
232}
233
234auto has_trannslation_channel(const aiAnimation* animation, const std::string& nodeName) -> bool
235{
236 if(!animation)
237 {
238 return false;
239 }
240
241 for(unsigned int ch = 0; ch < animation->mNumChannels; ++ch)
242 {
243 aiNodeAnim* channel = animation->mChannels[ch];
244 if(!channel)
245 {
246 continue;
247 }
248
249 // Compare the channel's node name with the given nodeName.
250 if(std::string(channel->mNodeName.C_Str()) == nodeName)
251 {
252 // If the channel has position keys, then the node is animated in translation.
253 if(channel->mNumPositionKeys > 1)
254 {
255 return true;
256 }
257 }
258 }
259
260 return false;
261}
262
263// Helper function to check whether a given node name has an animation channel
264// with translation (position) keys.
265auto has_trannslation_channel(const aiScene* scene, const std::string& nodeName) -> bool
266{
267 for(unsigned int animIdx = 0; animIdx < scene->mNumAnimations; ++animIdx)
268 {
269 aiAnimation* animation = scene->mAnimations[animIdx];
270
271 if(has_trannslation_channel(animation, nodeName))
272 {
273 return true;
274 }
275 }
276 return false;
277}
278
279enum channel_requirement
280{
281 translation,
283};
284
285// Recursive, top-down search: returns the first node (in a depth-first search)
286// that has an animation channel with translation keys.
287auto find_first_animated_node_dfs(aiNode* node,
288 const aiScene* scene,
289 const aiAnimation* animation,
290 channel_requirement req) -> aiNode*
291{
292 if(!node)
293 return nullptr;
294
295 switch(req)
296 {
297 case channel_requirement::translation:
298 // Check if the current node is animated (i.e. has translation keys).
299 if(has_trannslation_channel(animation, std::string(node->mName.C_Str())))
300 {
301 return node;
302 }
303 break;
304 case channel_requirement::rotation:
305 // Check if the current node is animated (i.e. has translation keys).
306 if(has_rotation_channel(animation, std::string(node->mName.C_Str())))
307 {
308 return node;
309 }
310 break;
311 default:
312 // Check if the current node is animated (i.e. has translation keys).
313 if(has_trannslation_channel(animation, std::string(node->mName.C_Str())))
314 {
315 return node;
316 }
317 break;
318 }
319
320 // Recursively search in the children.
321 for(unsigned int i = 0; i < node->mNumChildren; ++i)
322 {
323 aiNode* found = find_first_animated_node_dfs(node->mChildren[i], scene, animation, req);
324 if(found)
325 {
326 return found;
327 }
328 }
329
330 // No matching node found in this branch.
331 return nullptr;
332}
333
334// Top-level function that starts at the scene's root node.
335auto find_root_motion_node_dfs(const aiScene* scene, const aiAnimation* animation, channel_requirement req) -> aiNode*
336{
337 if(!scene || !scene->mRootNode)
338 {
339 return nullptr;
340 }
341
342 return find_first_animated_node_dfs(scene->mRootNode, scene, animation, req);
343}
344
345// Breadth-first search to find the first node (level-by-level) with translation animation.
346auto find_first_animated_node_bfs(const aiScene* scene, const aiAnimation* animation, channel_requirement req)
347 -> aiNode*
348{
349 if(!scene || !scene->mRootNode)
350 {
351 return nullptr;
352 }
353
354 std::queue<aiNode*> nodeQueue;
355 nodeQueue.push(scene->mRootNode);
356
357 while(!nodeQueue.empty())
358 {
359 aiNode* current = nodeQueue.front();
360 nodeQueue.pop();
361
362 switch(req)
363 {
364 case channel_requirement::translation:
365 // Check if the current node is animated (i.e. has translation keys).
366 if(has_trannslation_channel(animation, std::string(current->mName.C_Str())))
367 {
368 return current;
369 }
370 break;
371 case channel_requirement::rotation:
372 // Check if the current node is animated (i.e. has translation keys).
373 if(has_rotation_channel(animation, std::string(current->mName.C_Str())))
374 {
375 return current;
376 }
377 break;
378 default:
379 // Check if the current node is animated (i.e. has translation keys).
380 if(has_trannslation_channel(animation, std::string(current->mName.C_Str())))
381 {
382 return current;
383 }
384 break;
385 }
386
387 // Check if the current node is animated (i.e. has translation keys).
388 if(has_trannslation_channel(animation, std::string(current->mName.C_Str())))
389 {
390 return current;
391 }
392
393 // Enqueue all children of the current node.
394 for(unsigned int i = 0; i < current->mNumChildren; ++i)
395 {
396 nodeQueue.push(current->mChildren[i]);
397 }
398 }
399
400 // If no matching node is found, return nullptr.
401 return nullptr;
402}
403
404// Top-level function that returns the root motion node using breadth-first search.
405auto find_root_motion_node_bfs(const aiScene* scene, const aiAnimation* animation, channel_requirement req) -> aiNode*
406{
407 return find_first_animated_node_bfs(scene, animation, req);
408}
409
410void apply_import_facing_correction_to_load_data(mesh::load_data& load_data)
411{
412 if(!load_data.root_node)
413 {
414 return;
415 }
416
417 load_data.root_node->local_transform.rotate(math::radians(math::vec3{0.0f, 180.0f, 0.0f}));
418
419 if(!load_data.bbox.is_populated())
420 {
421 return;
422 }
423
424 math::transform correction;
425 correction.set_rotation(glm::angleAxis(math::pi<float>(), math::vec3(0.0f, 1.0f, 0.0f)));
426 load_data.bbox = math::bbox::mul(load_data.bbox, correction);
427}
428
429void accumulate_bounds_from_armature(const mesh::load_data& load_data, math::bbox& out)
430{
431 if(!load_data.root_node)
432 {
433 return;
434 }
435
436 const std::function<void(const mesh::armature_node&, const math::transform&)> visit =
437 [&](const mesh::armature_node& node, const math::transform& parent_transform)
438 {
439 const math::transform world_transform = parent_transform * node.local_transform;
440
441 for(uint32_t submesh_index : node.submeshes)
442 {
443 if(submesh_index >= load_data.submeshes.size())
444 {
445 continue;
446 }
447
448 const auto transformed_bbox =
449 math::bbox::mul(load_data.submeshes[submesh_index].bbox, world_transform);
450 out.add_point(transformed_bbox.min);
451 out.add_point(transformed_bbox.max);
452 }
453
454 for(const auto& child : node.children)
455 {
456 if(child)
457 {
458 visit(*child, world_transform);
459 }
460 }
461 };
462
463 visit(*load_data.root_node, math::transform::identity());
464}
465
466// Helper function to get the file extension from the compressed texture format
467
468auto get_texture_extension_from_texture(const aiTexture* texture) -> std::string
469{
470 if(texture->achFormatHint[0] != '\0')
471 {
472 return std::string(".") + texture->achFormatHint;
473 }
474 return ".tga"; // Fallback extension raw
475}
476
477auto get_texture_extension(const aiTexture* texture) -> std::string
478{
479 auto extension = get_texture_extension_from_texture(texture);
480
481 if(extension == ".jpg" || extension == ".jpeg")
482 {
483 extension = ".dds";
484 }
485
486 return extension;
487}
488
497auto normalize_assimp_path(const std::string& path) -> fs::path
498{
499 if(path.empty())
500 {
501 return {};
502 }
503
504 auto normalized_path = string_utils::replace(path, "\\", "/");
505 return fs::path(normalized_path).lexically_normal();
506}
507
508auto normalize_assimp_path(const fs::path& path) -> fs::path
509{
510 if(path.empty())
511 {
512 return {};
513 }
514 return normalize_assimp_path(path.generic_string());
515}
516
517auto normalize_assimp_path(const char* path) -> fs::path
518{
519 if(path == nullptr || path[0] == '\0')
520 {
521 return {};
522 }
523 return normalize_assimp_path(std::string(path));
524}
525
530auto resolve_external_texture_path(const fs::path& base_dir, fs::path relative_path) -> fs::path
531{
532 relative_path = normalize_assimp_path(relative_path);
533 fs::error_code ec;
534 if(fs::exists(base_dir / relative_path, ec))
535 {
536 return relative_path;
537 }
538
539 const auto& extensions = ex::get_suported_formats<gfx::texture>();
540 const auto parent = relative_path.parent_path();
541 const auto stem = relative_path.stem().string();
542 const auto requested_ext = string_utils::to_lower(relative_path.extension().string());
543
544 for(const auto& ext : extensions)
545 {
546 if(ext == requested_ext)
547 {
548 continue;
549 }
550 fs::path alternate = parent / (stem + ext);
551 if(fs::exists(base_dir / alternate, ec))
552 {
553 APPLOG_WARNING("Mesh Importer: Texture '{}' not found, using '{}' instead",
554 relative_path.generic_string(),
555 alternate.generic_string());
556 return alternate;
557 }
558 }
559
560 return relative_path;
561}
562
563auto get_embedded_texture_name(const aiTexture* texture,
564 size_t index,
565 const fs::path& filename,
566 const std::string& semantic) -> std::string
567{
568 return fmt::format("[{}] {} {}{}", index, semantic, filename.string(), get_texture_extension(texture));
569}
570
571auto process_matrix(const aiMatrix4x4& assimp_matrix) -> math::mat4
572{
573 math::mat4 matrix;
574
575 matrix[0][0] = assimp_matrix.a1;
576 matrix[1][0] = assimp_matrix.a2;
577 matrix[2][0] = assimp_matrix.a3;
578 matrix[3][0] = assimp_matrix.a4;
579
580 matrix[0][1] = assimp_matrix.b1;
581 matrix[1][1] = assimp_matrix.b2;
582 matrix[2][1] = assimp_matrix.b3;
583 matrix[3][1] = assimp_matrix.b4;
584
585 matrix[0][2] = assimp_matrix.c1;
586 matrix[1][2] = assimp_matrix.c2;
587 matrix[2][2] = assimp_matrix.c3;
588 matrix[3][2] = assimp_matrix.c4;
589
590 matrix[0][3] = assimp_matrix.d1;
591 matrix[1][3] = assimp_matrix.d2;
592 matrix[2][3] = assimp_matrix.d3;
593 matrix[3][3] = assimp_matrix.d4;
594
595 return matrix;
596}
597
598void process_vertices(aiMesh* mesh, mesh::load_data& load_data)
599{
600 auto& submesh = load_data.submeshes.back();
601
602 // Determine the correct offset to any relevant elements in the vertex
603 bool has_position = load_data.vertex_format.has(gfx::attribute::Position);
604 bool has_normal = load_data.vertex_format.has(gfx::attribute::Normal);
605 bool has_bitangent = load_data.vertex_format.has(gfx::attribute::Bitangent);
606 bool has_tangent = load_data.vertex_format.has(gfx::attribute::Tangent);
607 bool has_texcoord0 = load_data.vertex_format.has(gfx::attribute::TexCoord0);
608 auto vertex_stride = load_data.vertex_format.getStride();
609
610 std::uint32_t current_vertex = load_data.vertex_count;
611 load_data.vertex_count += mesh->mNumVertices;
612 load_data.vertex_data.resize(load_data.vertex_count * vertex_stride);
613
614 std::uint8_t* current_vertex_ptr = load_data.vertex_data.data() + current_vertex * vertex_stride;
615
616 for(size_t i = 0; i < mesh->mNumVertices; ++i, current_vertex_ptr += vertex_stride)
617 {
618 // position
619 if(mesh->HasPositions() && has_position)
620 {
621 float position[4];
622 std::memcpy(position, &mesh->mVertices[i], sizeof(aiVector3D));
623
624 gfx::vertex_pack(position, false, gfx::attribute::Position, load_data.vertex_format, current_vertex_ptr);
625
626 submesh.bbox.add_point(math::vec3(position[0], position[1], position[2]));
627 }
628
629 // tex coords
630
631 if(has_texcoord0)
632 {
633 float textureCoords[4] = {0.0f, 0.0f, 0.0f, 0.0f};
634 if(mesh->HasTextureCoords(0))
635 {
636 std::memcpy(textureCoords, &mesh->mTextureCoords[0][i], sizeof(aiVector2D));
637
638 gfx::vertex_pack(textureCoords,
639 true,
640 gfx::attribute::TexCoord0,
641 load_data.vertex_format,
642 current_vertex_ptr);
643 }
644 else
645 {
646 gfx::vertex_pack(textureCoords,
647 true,
648 gfx::attribute::TexCoord0,
649 load_data.vertex_format,
650 current_vertex_ptr);
651 }
652 }
653
654
656 math::vec4 normal{};
657 if(mesh->HasNormals() && has_normal)
658 {
659 std::memcpy(math::value_ptr(normal), &mesh->mNormals[i], sizeof(aiVector3D));
660
661 gfx::vertex_pack(math::value_ptr(normal),
662 true,
663 gfx::attribute::Normal,
664 load_data.vertex_format,
665 current_vertex_ptr);
666 }
667
668 math::vec4 tangent{};
669 // tangents
670 if(has_tangent)
671 {
672 if(mesh->HasTangentsAndBitangents())
673 {
674 std::memcpy(math::value_ptr(tangent), &mesh->mTangents[i], sizeof(aiVector3D));
675 tangent.w = 1.0f;
676
677 }
678 else
679 {
680 tangent = math::vec4(0.0f, 0.0f, 0.0f, 0.0f);
681 }
682
683 gfx::vertex_pack(math::value_ptr(tangent),
684 true,
685 gfx::attribute::Tangent,
686 load_data.vertex_format,
687 current_vertex_ptr);
688 }
689
690
691 // binormals
692 math::vec4 bitangent{};
693 if(has_bitangent)
694 {
695 if(mesh->HasTangentsAndBitangents())
696 {
697 std::memcpy(math::value_ptr(bitangent), &mesh->mBitangents[i], sizeof(aiVector3D));
698 }
699 else
700 {
701 bitangent = math::vec4(0.0f, 0.0f, 0.0f, 0.0f);
702 }
703
704 // float handedness =
705 // math::dot(math::vec3(bitangent), math::normalize(math::cross(math::vec3(normal), math::vec3(tangent))));
706 // tangent.w = handedness;
707
708 gfx::vertex_pack(math::value_ptr(bitangent),
709 true,
710 gfx::attribute::Bitangent,
711 load_data.vertex_format,
712 current_vertex_ptr);
713 }
714 }
715}
716
717void process_faces(aiMesh* mesh, std::uint32_t submesh_offset, mesh::load_data& load_data)
718{
719 load_data.triangle_count += mesh->mNumFaces;
720
721 load_data.triangle_data.reserve(load_data.triangle_data.size() + mesh->mNumFaces);
722
723 for(size_t i = 0; i < mesh->mNumFaces; ++i)
724 {
725 aiFace face = mesh->mFaces[i];
726
727 auto& triangle = load_data.triangle_data.emplace_back();
728 triangle.data_group_id = mesh->mMaterialIndex;
729
730 auto num_indices = std::min<size_t>(face.mNumIndices, 3);
731 for(size_t j = 0; j < num_indices; ++j)
732 {
733 triangle.indices[j] = face.mIndices[j] + submesh_offset;
734 }
735 }
736}
737
738void process_bones(aiMesh* mesh, std::uint32_t submesh_offset, mesh::load_data& load_data)
739{
740 if(mesh->HasBones())
741 {
742 auto& bone_influences = load_data.skin_data.get_bones();
743
744 for(size_t i = 0; i < mesh->mNumBones; ++i)
745 {
746 aiBone* assimp_bone = mesh->mBones[i];
747 const std::string bone_name = assimp_bone->mName.C_Str();
748
749 auto it = std::find_if(std::begin(bone_influences),
750 std::end(bone_influences),
751 [&bone_name](const auto& bone)
752 {
753 return bone_name == bone.bone_id;
754 });
755
756 skin_bind_data::bone_influence* bone_ptr = nullptr;
757 if(it != std::end(bone_influences))
758 {
759 bone_ptr = &(*it);
760 }
761 else
762 {
763 const auto& assimp_matrix = assimp_bone->mOffsetMatrix;
764 skin_bind_data::bone_influence bone_influence;
765 bone_influence.bone_id = bone_name;
766 bone_influence.bind_pose_transform = process_matrix(assimp_matrix);
767 bone_influences.emplace_back(std::move(bone_influence));
768 bone_ptr = &bone_influences.back();
769 }
770
771 if(bone_ptr == nullptr)
772 {
773 continue;
774 }
775
776 for(size_t j = 0; j < assimp_bone->mNumWeights; ++j)
777 {
778 aiVertexWeight assimp_influence = assimp_bone->mWeights[j];
779
780 skin_bind_data::vertex_influence influence;
781 influence.vertex_index = assimp_influence.mVertexId + submesh_offset;
782 influence.weight = assimp_influence.mWeight;
783
784 bone_ptr->influences.emplace_back(influence);
785
786 // Accumulate the bone-space bounds of every influenced vertex (mesh-space
787 // position pre-multiplied by the offset/bind-pose matrix). At runtime,
788 // bone_world_transform * bounds yields a conservative world-space bound of
789 // the skinned geometry for frustum culling of animated meshes.
790 if(assimp_influence.mVertexId < mesh->mNumVertices && assimp_influence.mWeight > 0.0f)
791 {
792 const auto& vertex = mesh->mVertices[assimp_influence.mVertexId];
793 const math::vec3 mesh_space_position(vertex.x, vertex.y, vertex.z);
794 const math::vec3 bone_space_position =
795 bone_ptr->bind_pose_transform.transform_coord(mesh_space_position);
796 bone_ptr->bounds.add_point(bone_space_position);
797 }
798 }
799 }
800 }
801}
802
803auto make_stable_submesh_id(const char* name, const mesh::load_data& load_data) -> uint32_t
804{
805 // FNV-1a hash of the source mesh name. The id must be deterministic across reimports so
806 // scene/prefab references (submesh_component entries) survive submesh reordering.
807 uint32_t hash = 2166136261u;
808 bool empty = true;
809 for(const char* c = name; *c != '\0'; ++c)
810 {
811 hash ^= static_cast<uint8_t>(*c);
812 hash *= 16777619u;
813 empty = false;
814 }
815 if(empty)
816 {
817 // Unnamed meshes fall back to an ordinal-derived id (still deterministic as long as
818 // the exporter emits meshes in a stable order).
819 hash = 2166136261u ^ static_cast<uint32_t>(load_data.submeshes.size() + 1);
820 }
821 if(hash == 0)
822 {
823 hash = 1;
824 }
825 // Disambiguate duplicate names with deterministic probing.
826 auto collides = [&](uint32_t candidate)
827 {
828 return std::any_of(load_data.submeshes.begin(),
829 load_data.submeshes.end(),
830 [candidate](const mesh::submesh& sm)
831 {
832 return sm.stable_id == candidate;
833 });
834 };
835 while(collides(hash))
836 {
837 hash = hash * 16777619u + 1u;
838 if(hash == 0)
839 {
840 hash = 1;
841 }
842 }
843 return hash;
844}
845
846void process_mesh(aiMesh* mesh, mesh::load_data& load_data)
847{
848 load_data.submeshes.emplace_back();
849 auto& submesh = load_data.submeshes.back();
850 submesh.vertex_start = load_data.vertex_count;
851 submesh.vertex_count = mesh->mNumVertices;
852 submesh.face_start = load_data.triangle_count;
853 submesh.face_count = mesh->mNumFaces;
854 submesh.data_group_id = mesh->mMaterialIndex;
855 submesh.skinned = mesh->HasBones();
856 submesh.stable_id = make_stable_submesh_id(mesh->mName.C_Str(), load_data);
857 load_data.material_count = std::max(load_data.material_count, submesh.data_group_id + 1);
858
859 process_faces(mesh, submesh.vertex_start, load_data);
860 process_bones(mesh, submesh.vertex_start, load_data);
861 process_vertices(mesh, load_data);
862}
863
864void process_meshes(const aiScene* scene, mesh::load_data& load_data)
865{
866 for(size_t i = 0; i < scene->mNumMeshes; ++i)
867 {
868 aiMesh* mesh = scene->mMeshes[i];
869 process_mesh(mesh, load_data);
870 }
871}
872
873void process_node(const aiScene* scene,
874 mesh::load_data& load_data,
875 const aiNode* node,
876 const std::unique_ptr<mesh::armature_node>& armature_node,
877 const math::transform& parent_transform,
878 std::unordered_map<std::string, unsigned int>& node_to_index_lut)
879{
880 armature_node->name = node->mName.C_Str();
881 armature_node->local_transform = process_matrix(node->mTransformation);
882 armature_node->children.resize(node->mNumChildren);
883 armature_node->index = node_to_index_lut[armature_node->name];
884 auto resolved_transform = parent_transform * armature_node->local_transform;
885
886 for(uint32_t i = 0; i < node->mNumMeshes; ++i)
887 {
888 uint32_t submesh_index = node->mMeshes[i];
889 armature_node->submeshes.emplace_back(submesh_index);
890
891 auto& submesh = load_data.submeshes[submesh_index];
892
893 auto transformed_bbox = math::bbox::mul(submesh.bbox, resolved_transform);
894 load_data.bbox.add_point(transformed_bbox.min);
895 load_data.bbox.add_point(transformed_bbox.max);
896 }
897
898 for(size_t i = 0; i < node->mNumChildren; ++i)
899 {
900 armature_node->children[i] = std::make_unique<mesh::armature_node>();
901 process_node(scene,
902 load_data,
903 node->mChildren[i],
904 armature_node->children[i],
905 resolved_transform,
906 node_to_index_lut);
907 }
908}
909
910void process_nodes(const aiScene* scene,
911 mesh::load_data& load_data,
912 std::unordered_map<std::string, unsigned int>& node_to_index_lut)
913{
914 size_t index = 0;
915 if(scene->mRootNode != nullptr)
916 {
917 load_data.bbox = {};
918 load_data.root_node = std::make_unique<mesh::armature_node>();
919
920 process_node(scene,
921 load_data,
922 scene->mRootNode,
923 load_data.root_node,
925 node_to_index_lut);
926
927 auto get_axis = [&](const std::string& name, math::vec3 fallback)
928 {
929 if(!scene->mMetaData)
930 {
931 return fallback;
932 }
933
934 int axis = 0;
935 if(!scene->mMetaData->Get<int>(name, axis))
936 {
937 return fallback;
938 }
939 int axis_sign = 1;
940 if(!scene->mMetaData->Get<int>(name + "Sign", axis_sign))
941 {
942 return fallback;
943 }
944 math::vec3 result{0.0f, 0.0f, 0.0f};
945
946 if(axis < 0 || axis >= 3)
947 {
948 return fallback;
949 }
950
951 result[axis] = float(axis_sign);
952
953 return result;
954 };
955 auto x_axis = get_axis("CoordAxis", {1.0f, 0.0f, 0.0f});
956 auto y_axis = get_axis("UpAxis", {0.0f, 1.0f, 0.0f});
957 auto z_axis = get_axis("FrontAxis", {0.0f, 0.0f, 1.0f});
958 // load_data.root_node->local_transform.set_rotation(x_axis, y_axis, z_axis);
959 }
960}
961
962void dfs_assign_indices(const aiNode* node,
963 std::unordered_map<std::string, unsigned int>& node_indices,
964 unsigned int& current_index)
965{
966 // Assign the current index to this node
967 node_indices[node->mName.C_Str()] = current_index;
968
969 // Increment the index for the next node
970 current_index++;
971
972 // Recursively visit all children (DFS)
973 for(unsigned int i = 0; i < node->mNumChildren; ++i)
974 {
975 dfs_assign_indices(node->mChildren[i], node_indices, current_index);
976 }
977}
978
979auto assign_node_indices(const aiScene* scene) -> std::unordered_map<std::string, unsigned int>
980{
981 std::unordered_map<std::string, unsigned int> node_indices;
982 unsigned int current_index = 0;
983
984 // Start DFS traversal from the root node
985 if(scene->mRootNode)
986 {
987 dfs_assign_indices(scene->mRootNode, node_indices, current_index);
988 }
989
990 return node_indices;
991}
992
993auto is_node_a_bone(const std::string& node_name, const aiScene* scene) -> bool
994{
995 for(unsigned int i = 0; i < scene->mNumMeshes; ++i)
996 {
997 const aiMesh* mesh = scene->mMeshes[i];
998 for(unsigned int j = 0; j < mesh->mNumBones; ++j)
999 {
1000 if(mesh->mBones[j]->mName.C_Str() == node_name)
1001 {
1002 return true;
1003 }
1004 }
1005 }
1006 return false;
1007}
1008
1009auto is_node_a_parent_of_bone(const std::string& node_name, const aiScene* scene) -> bool
1010{
1011 for(unsigned int i = 0; i < scene->mNumMeshes; ++i)
1012 {
1013 const aiMesh* mesh = scene->mMeshes[i];
1014 for(unsigned int j = 0; j < mesh->mNumBones; ++j)
1015 {
1016 const aiNode* bone_node = scene->mRootNode->FindNode(mesh->mBones[j]->mName);
1017 const aiNode* current_node = bone_node;
1018
1019 while(current_node != nullptr)
1020 {
1021 if(current_node->mName.C_Str() == node_name)
1022 {
1023 return true;
1024 }
1025 current_node = current_node->mParent;
1026 }
1027 }
1028 }
1029 return false;
1030}
1031
1032auto is_node_a_submesh(const std::string& node_name, const aiScene* scene) -> bool
1033{
1034 const aiNode* node = scene->mRootNode->FindNode(node_name.c_str());
1035 return node != nullptr && node->mNumMeshes > 0;
1036}
1037
1038auto is_node_a_parent_of_submesh(const std::string& node_name, const aiScene* scene) -> bool
1039{
1040 const aiNode* root = scene->mRootNode;
1041
1042 for(unsigned int i = 0; i < scene->mNumMeshes; ++i)
1043 {
1044 const aiMesh* mesh = scene->mMeshes[i];
1045 const aiNode* submesh_node = root->FindNode(mesh->mName);
1046 const aiNode* current_node = submesh_node;
1047
1048 while(current_node != nullptr)
1049 {
1050 if(current_node->mName.C_Str() == node_name)
1051 {
1052 return true;
1053 }
1054 current_node = current_node->mParent;
1055 }
1056 }
1057 return false;
1058}
1059
1060void process_animation(const aiScene* scene,
1061 const fs::path& filename,
1062 const aiAnimation* assimp_anim,
1063 mesh::load_data& load_data,
1064 std::unordered_map<std::string, unsigned int>& node_to_index_lut,
1065 animation_clip& anim)
1066{
1067 auto fixed_name = filename.string() + "_" + string_utils::replace(assimp_anim->mName.C_Str(), ".", "_");
1068 anim.name = fixed_name;
1069 auto ticks_per_second = assimp_anim->mTicksPerSecond;
1070 if(ticks_per_second < 0.001)
1071 {
1072 ticks_per_second = 25.0;
1073 }
1074
1075 auto ticks = assimp_anim->mDuration;
1076
1077 anim.duration = decltype(anim.duration)(ticks / ticks_per_second);
1078
1079 if(assimp_anim->mNumChannels > 0)
1080 {
1081 anim.channels.reserve(assimp_anim->mNumChannels);
1082 }
1083 bool needs_sort = false;
1084
1085 size_t skipped = 0;
1086 for(size_t i = 0; i < assimp_anim->mNumChannels; ++i)
1087 {
1088 const aiNodeAnim* assimp_node_anim = assimp_anim->mChannels[i];
1089
1090 bool is_bone = is_node_a_bone(assimp_node_anim->mNodeName.C_Str(), scene);
1091 bool is_parent_of_bone = is_node_a_parent_of_bone(assimp_node_anim->mNodeName.C_Str(), scene);
1092 bool is_submesh = is_node_a_submesh(assimp_node_anim->mNodeName.C_Str(), scene);
1093 bool is_parent_of_submesh = is_node_a_parent_of_submesh(assimp_node_anim->mNodeName.C_Str(), scene);
1094
1095 bool is_relevant = is_bone || is_parent_of_bone || is_submesh || is_parent_of_submesh;
1096
1097 // skip frames for non relevant nodes
1098 if(!is_relevant)
1099 {
1100 skipped++;
1101 continue;
1102 }
1103
1104 auto& node_anim = anim.channels.emplace_back();
1105 node_anim.node_name = assimp_node_anim->mNodeName.C_Str();
1106 node_anim.node_index = node_to_index_lut[node_anim.node_name];
1107 if(!needs_sort && anim.channels.size() > 1)
1108 {
1109 auto& prev_node_anim = anim.channels[anim.channels.size() - 2];
1110 if(node_anim.node_index < prev_node_anim.node_index)
1111 {
1112 needs_sort = true;
1113 }
1114 }
1115
1116 if(assimp_node_anim->mNumPositionKeys > 0)
1117 {
1118 node_anim.position_keys.resize(assimp_node_anim->mNumPositionKeys);
1119 }
1120
1121 for(size_t idx = 0; idx < assimp_node_anim->mNumPositionKeys; ++idx)
1122 {
1123 const auto& anim_key = assimp_node_anim->mPositionKeys[idx];
1124 auto& key = node_anim.position_keys[idx];
1125 key.time = decltype(key.time)(anim_key.mTime / ticks_per_second);
1126 key.value.x = anim_key.mValue.x;
1127 key.value.y = anim_key.mValue.y;
1128 key.value.z = anim_key.mValue.z;
1129 }
1130
1131 if(assimp_node_anim->mNumRotationKeys > 0)
1132 {
1133 node_anim.rotation_keys.resize(assimp_node_anim->mNumRotationKeys);
1134 }
1135
1136 for(size_t idx = 0; idx < assimp_node_anim->mNumRotationKeys; ++idx)
1137 {
1138 const auto& anim_key = assimp_node_anim->mRotationKeys[idx];
1139 auto& key = node_anim.rotation_keys[idx];
1140 key.time = decltype(key.time)(anim_key.mTime / ticks_per_second);
1141 key.value.x = anim_key.mValue.x;
1142 key.value.y = anim_key.mValue.y;
1143 key.value.z = anim_key.mValue.z;
1144 key.value.w = anim_key.mValue.w;
1145 }
1146
1147 if(assimp_node_anim->mNumScalingKeys > 0)
1148 {
1149 node_anim.scaling_keys.resize(assimp_node_anim->mNumScalingKeys);
1150 }
1151
1152 for(size_t idx = 0; idx < assimp_node_anim->mNumScalingKeys; ++idx)
1153 {
1154 const auto& anim_key = assimp_node_anim->mScalingKeys[idx];
1155 auto& key = node_anim.scaling_keys[idx];
1156 key.time = decltype(key.time)(anim_key.mTime / ticks_per_second);
1157 key.value.x = anim_key.mValue.x;
1158 key.value.y = anim_key.mValue.y;
1159 key.value.z = anim_key.mValue.z;
1160 }
1161 }
1162
1163 auto root_motion_translation_candidate =
1164 find_root_motion_node_bfs(scene, assimp_anim, channel_requirement::translation);
1165 auto root_motion_rotation_candidate = find_root_motion_node_bfs(scene, assimp_anim, channel_requirement::rotation);
1166
1167 if(root_motion_translation_candidate)
1168 {
1169 anim.root_motion.position_node_name = root_motion_translation_candidate->mName.C_Str();
1170 anim.root_motion.position_node_index = node_to_index_lut[anim.root_motion.position_node_name];
1171 }
1172 if(root_motion_rotation_candidate)
1173 {
1174 anim.root_motion.rotation_node_name = root_motion_rotation_candidate->mName.C_Str();
1175 anim.root_motion.rotation_node_index = node_to_index_lut[anim.root_motion.rotation_node_name];
1176 }
1177
1178 if(needs_sort)
1179 {
1180 std::sort(anim.channels.begin(),
1181 anim.channels.end(),
1182 [](const auto& lhs, const auto& rhs)
1183 {
1184 return lhs.node_index < rhs.node_index;
1185 });
1186 }
1187
1188 APPLOG_TRACE("Mesh Importer : Animation {} discarded {} non relevat node keys", anim.name, skipped);
1189}
1190void process_animations(const aiScene* scene,
1191 const fs::path& filename,
1192 mesh::load_data& load_data,
1193 std::unordered_map<std::string, unsigned int>& node_to_index_lut,
1194 std::vector<animation_clip>& animations)
1195{
1196 if(scene->mNumAnimations > 0)
1197 {
1198 animations.resize(scene->mNumAnimations);
1199 }
1200
1201 for(size_t i = 0; i < scene->mNumAnimations; ++i)
1202 {
1203 const aiAnimation* assimp_anim = scene->mAnimations[i];
1204 auto& anim = animations[i];
1205 process_animation(scene, filename, assimp_anim, load_data, node_to_index_lut, anim);
1206 }
1207}
1208
1209void process_embedded_texture(const aiTexture* assimp_tex,
1210 size_t assimp_tex_idx,
1211 const fs::path& filename,
1212 const fs::path& output_dir,
1213 std::vector<imported_texture>& textures)
1214{
1215 imported_texture texture{};
1216 // Search backwards: the caller just pushed the target entry at the back.
1217 auto rit = std::find_if(textures.rbegin(),
1218 textures.rend(),
1219 [&](const imported_texture& texture)
1220 {
1221 return texture.embedded_index == static_cast<int>(assimp_tex_idx);
1222 });
1223 if(rit != textures.rend())
1224 {
1225 if(rit->process_count > 0)
1226 {
1227 return;
1228 }
1229
1230 rit->process_count++;
1231 texture = *rit;
1232 }
1233 else if(assimp_tex->mFilename.length > 0)
1234 {
1235 texture.name = normalize_assimp_path(assimp_tex->mFilename.C_Str()).filename().string();
1236 }
1237 else
1238 {
1239 texture.name = get_embedded_texture_name(assimp_tex, assimp_tex_idx, filename, "Texture");
1240 }
1241
1242 fs::path output_file = output_dir / texture.name;
1243
1244 if(assimp_tex->pcData)
1245 {
1246 bool compressed = assimp_tex->mHeight == 0;
1247 bool raw = assimp_tex->mHeight > 0;
1248
1249 if(compressed)
1250 {
1251 // Compressed texture (e.g., PNG, JPEG)
1252 size_t texture_size = assimp_tex->mWidth;
1253
1254 // Parse the image using bimg
1255 bimg::ImageContainer* image = imageLoad(assimp_tex->pcData, static_cast<uint32_t>(texture_size));
1256 if(image)
1257 {
1258 // Apply workflow-specific texture conversions
1259 apply_texture_conversion(image, texture.semantic, texture.inverse);
1260
1261 atomic_image_save(output_file, image);
1262
1263 bimg::imageFree(image);
1264 }
1265 }
1266 else if(raw)
1267 {
1268 // Uncompressed texture (e.g., raw RGBA)
1269 // For raw data, we need to process it differently
1270 process_raw_texture_data(assimp_tex, output_file, texture.semantic, texture.inverse);
1271 }
1272 }
1273}
1274
1278namespace pixel_transforms
1279{
1285 inline auto to_uint8(float value) -> uint8_t
1286 {
1287 return static_cast<uint8_t>(std::lround(math::clamp(value, 0.0f, 1.0f) * 255.0f));
1288 }
1289
1293 template<typename TransformFunc>
1294 void transform_pixel(uint8_t* pixel_data, uint32_t bytes_per_pixel, TransformFunc transform_func)
1295 {
1296 if (bytes_per_pixel >= 4)
1297 {
1298 // RGBA format
1299 float r = pixel_data[0] / 255.0f;
1300 float g = pixel_data[1] / 255.0f;
1301 float b = pixel_data[2] / 255.0f;
1302 float a = pixel_data[3] / 255.0f;
1303
1304 auto [new_r, new_g, new_b, new_a] = transform_func(r, g, b, a);
1305
1306 pixel_data[0] = to_uint8(new_r);
1307 pixel_data[1] = to_uint8(new_g);
1308 pixel_data[2] = to_uint8(new_b);
1309 pixel_data[3] = to_uint8(new_a);
1310 }
1311 else if (bytes_per_pixel >= 3)
1312 {
1313 // RGB format
1314 float r = pixel_data[0] / 255.0f;
1315 float g = pixel_data[1] / 255.0f;
1316 float b = pixel_data[2] / 255.0f;
1317 float a = 1.0f; // Default alpha
1318
1319 auto [new_r, new_g, new_b, new_a] = transform_func(r, g, b, a);
1320
1321 pixel_data[0] = to_uint8(new_r);
1322 pixel_data[1] = to_uint8(new_g);
1323 pixel_data[2] = to_uint8(new_b);
1324 }
1325 else if (bytes_per_pixel == 2)
1326 {
1327 // Grayscale + Alpha format
1328 float luminance = pixel_data[0] / 255.0f;
1329 float a = pixel_data[1] / 255.0f;
1330
1331 auto [new_r, new_g, new_b, new_a] = transform_func(luminance, luminance, luminance, a);
1332
1333 pixel_data[0] = to_uint8(new_r); // Use red as luminance
1334 pixel_data[1] = to_uint8(new_a);
1335 }
1336 else if (bytes_per_pixel == 1)
1337 {
1338 // Grayscale format
1339 float luminance = pixel_data[0] / 255.0f;
1340
1341 auto [new_r, new_g, new_b, new_a] = transform_func(luminance, luminance, luminance, 1.0f);
1342
1343 pixel_data[0] = to_uint8(new_r);
1344 }
1345 }
1346
1351 auto shininess_to_roughness_pixel(float r, float g, float b, float a) -> std::tuple<float, float, float, float>
1352 {
1353 constexpr float k_reference_max_shininess = 128.0f;
1354 const float shininess = std::max(std::max({r, g, b}) * k_reference_max_shininess, 1.0f);
1355 const float roughness = std::sqrt(2.0f / (shininess + 2.0f));
1356 return std::make_tuple(roughness, roughness, roughness, 1.0f);
1357 }
1358
1363 auto compute_metallic_from_specular(float r, float g, float b) -> float
1364 {
1365 constexpr float assumed_diffuse = 0.5f;
1366 float max_specular = std::max({r, g, b});
1367 float one_minus_specular_strength = 1.0f - max_specular;
1368 float perc_diffuse = perceived_brightness(assumed_diffuse, assumed_diffuse, assumed_diffuse);
1369 float perc_specular = perceived_brightness(r, g, b);
1370 return solve_metallic(perc_diffuse, perc_specular, one_minus_specular_strength);
1371 }
1372
1377 auto specular_to_metallic_roughness_alpha_pixel(float r, float g, float b, float a) -> std::tuple<float, float, float, float>
1378 {
1379 float metallic = compute_metallic_from_specular(r, g, b);
1380 float roughness = 1.0f - a;
1381 return std::make_tuple(1.0f, roughness, metallic, 1.0f);
1382 }
1383
1388 auto specular_to_metallic_roughness_intensity_pixel(float r, float g, float b, float a) -> std::tuple<float, float, float, float>
1389 {
1390 float metallic = compute_metallic_from_specular(r, g, b);
1391 float avg_specular = (r + g + b) / 3.0f;
1392 float roughness = 1.0f - avg_specular;
1393 return std::make_tuple(1.0f, roughness, metallic, 1.0f);
1394 }
1395
1399 auto simple_invert_pixel(float r, float g, float b, float a) -> std::tuple<float, float, float, float>
1400 {
1401 return std::make_tuple(1.0f - r, 1.0f - g, 1.0f - b, 1.0f - a);
1402 }
1403}
1404
1408void apply_texture_conversion(bimg::ImageContainer* image, const std::string& semantic, bool inverse)
1409{
1410 if(!image || !image->m_data)
1411 {
1412 return;
1413 }
1414 if(!is_supported_ldr_format(image->m_format))
1415 {
1416 APPLOG_WARNING("Mesh Importer: Skipping {} conversion on unsupported texture format (compressed/float/non-byte-aligned)", semantic);
1417 return;
1418 }
1419
1420 uint8_t* image_data = static_cast<uint8_t*>(image->m_data);
1421 uint32_t pixel_count = image->m_width * image->m_height;
1422 uint32_t bpp = bimg::getBitsPerPixel(image->m_format);
1423 uint32_t bytes_per_pixel = bpp / 8;
1424
1425 if(semantic == "SpecularToMetallicRoughness")
1426 {
1427 apply_specular_to_metallic_roughness_conversion(image);
1428 return;
1429 }
1430 else if(semantic == "ShininessToRoughness")
1431 {
1432 for(uint32_t i = 0; i < pixel_count; ++i)
1433 {
1434 uint32_t pixel_index = i * bytes_per_pixel;
1435 pixel_transforms::transform_pixel(&image_data[pixel_index], bytes_per_pixel,
1436 pixel_transforms::shininess_to_roughness_pixel);
1437 }
1438 APPLOG_TRACE("Mesh Importer: Applied ShininessToRoughness conversion to texture");
1439 }
1440 else if(semantic == "ExtractMetallicChannel")
1441 {
1442 // Extract metallic channel from combined texture (Blue channel in glTF standard)
1443 for(uint32_t i = 0; i < pixel_count; ++i)
1444 {
1445 uint32_t pixel_index = i * bytes_per_pixel;
1446 pixel_transforms::transform_pixel(&image_data[pixel_index], bytes_per_pixel,
1447 [](float r, float g, float b, float a) {
1448 // Extract metallic from blue channel and make it grayscale
1449 return std::make_tuple(b, b, b, 1.0f);
1450 });
1451 }
1452 APPLOG_TRACE("Mesh Importer: Extracted metallic channel for debugging");
1453 }
1454 else if(semantic == "ExtractRoughnessChannel")
1455 {
1456 // Extract roughness channel from combined texture (Green channel in glTF standard)
1457 for(uint32_t i = 0; i < pixel_count; ++i)
1458 {
1459 uint32_t pixel_index = i * bytes_per_pixel;
1460 pixel_transforms::transform_pixel(&image_data[pixel_index], bytes_per_pixel,
1461 [](float r, float g, float b, float a) {
1462 // Extract roughness from green channel and make it grayscale
1463 return std::make_tuple(g, g, g, 1.0f);
1464 });
1465 }
1466 APPLOG_TRACE("Mesh Importer: Extracted roughness channel for debugging");
1467 }
1468 else if(inverse)
1469 {
1470 // Simple inversion for other cases where inverse flag is set
1471 for(uint32_t i = 0; i < pixel_count; ++i)
1472 {
1473 uint32_t pixel_index = i * bytes_per_pixel;
1474 pixel_transforms::transform_pixel(&image_data[pixel_index], bytes_per_pixel,
1475 pixel_transforms::simple_invert_pixel);
1476 }
1477 APPLOG_TRACE("Mesh Importer: Applied simple inversion to texture");
1478 }
1479}
1480
1494void apply_specular_to_metallic_roughness_conversion(bimg::ImageContainer* image)
1495{
1496 if(!image || !image->m_data)
1497 {
1498 return;
1499 }
1500 if(!is_supported_ldr_format(image->m_format))
1501 {
1502 APPLOG_WARNING("Mesh Importer: Skipping SpecularToMetallicRoughness conversion on unsupported texture format");
1503 return;
1504 }
1505
1506 uint8_t* image_data = static_cast<uint8_t*>(image->m_data);
1507 uint32_t pixel_count = image->m_width * image->m_height;
1508 uint32_t bpp = bimg::getBitsPerPixel(image->m_format);
1509 uint32_t bytes_per_pixel = bpp / 8;
1510
1511 // The MR pack uses three distinct channels (R/G/B), so a single-channel or
1512 // luminance+alpha source cannot represent the result. Bail loudly rather than
1513 // silently dropping the metallic / roughness data via transform_pixel's
1514 // channel-reduction fallback.
1515 if(bytes_per_pixel < 3)
1516 {
1517 APPLOG_WARNING("Mesh Importer: Skipping SpecularToMetallicRoughness conversion on <3-channel source (cannot pack R/G/B)");
1518 return;
1519 }
1520
1521 // KHR spec/gloss combined maps: alpha is glossiness when RGBA (not cut-out opacity).
1522 const bool alpha_has_gloss = (bytes_per_pixel >= 4);
1523
1524 if(alpha_has_gloss)
1525 {
1526 for(uint32_t i = 0; i < pixel_count; ++i)
1527 {
1528 uint32_t pixel_index = i * bytes_per_pixel;
1529 pixel_transforms::transform_pixel(&image_data[pixel_index],
1530 bytes_per_pixel,
1531 pixel_transforms::specular_to_metallic_roughness_alpha_pixel);
1532 }
1533 APPLOG_TRACE("Mesh Importer: Applied SpecularToMetallicRoughness conversion (alpha=gloss) to texture");
1534 }
1535 else
1536 {
1537 for(uint32_t i = 0; i < pixel_count; ++i)
1538 {
1539 uint32_t pixel_index = i * bytes_per_pixel;
1540 pixel_transforms::transform_pixel(&image_data[pixel_index],
1541 bytes_per_pixel,
1542 pixel_transforms::specular_to_metallic_roughness_intensity_pixel);
1543 }
1544 APPLOG_TRACE("Mesh Importer: Applied SpecularToMetallicRoughness conversion (intensity) to texture");
1545 }
1546}
1547
1573void apply_diffuse_to_base_color_conversion(bimg::ImageContainer* diffuse_image,
1574 const bimg::ImageContainer* specular_image,
1575 const spec_gloss_factors_t& factors,
1576 std::vector<uint8_t>* out_mr_rgba8,
1577 bool rewrite_diffuse_to_base_color)
1578{
1579 if(!diffuse_image || !diffuse_image->m_data || !specular_image || !specular_image->m_data)
1580 {
1581 return;
1582 }
1583 if(diffuse_image->m_width != specular_image->m_width || diffuse_image->m_height != specular_image->m_height)
1584 {
1585 APPLOG_WARNING("Mesh Importer: Diffuse/specular texture size mismatch for base color conversion");
1586 return;
1587 }
1588 if(!is_supported_ldr_format(diffuse_image->m_format) || !is_supported_ldr_format(specular_image->m_format))
1589 {
1590 APPLOG_WARNING("Mesh Importer: Diffuse-to-base-color conversion requires uncompressed LDR textures; skipping");
1591 return;
1592 }
1593
1594 uint32_t d_bpp = bimg::getBitsPerPixel(diffuse_image->m_format) / 8;
1595 uint32_t s_bpp = bimg::getBitsPerPixel(specular_image->m_format) / 8;
1596 if(d_bpp < 3 || s_bpp < 3)
1597 {
1598 // We need actual RGB channels in both inputs; grayscale/L+A sources do not
1599 // carry the chromatic information the spec-gloss identity needs.
1600 APPLOG_WARNING("Mesh Importer: Diffuse-to-base-color conversion requires RGB inputs (diffuse={} bpp, specular={} bpp); skipping",
1601 d_bpp * 8, s_bpp * 8);
1602 return;
1603 }
1604
1605 constexpr float dielectric_f0 = 0.04f;
1606 constexpr float epsilon = 1e-6f;
1607
1608 uint32_t width = diffuse_image->m_width;
1609 uint32_t height = diffuse_image->m_height;
1610 uint32_t pixel_count = width * height;
1611 auto* d_data = static_cast<uint8_t*>(diffuse_image->m_data);
1612 const auto* s_data = static_cast<const uint8_t*>(specular_image->m_data);
1613
1614 // KHR_materials_pbrSpecularGlossiness: RGB = specular, A = glossiness.
1615 // When the specular map has alpha, always use it for per-pixel gloss (not cut-out opacity).
1616 // Fall back to specular RGB intensity only for RGB-only spec sources.
1617 bool spec_alpha_has_gloss = (s_bpp >= 4);
1618
1619 if(out_mr_rgba8 != nullptr)
1620 {
1621 out_mr_rgba8->assign(static_cast<size_t>(pixel_count) * 4, 0);
1622 }
1623
1624 for(uint32_t i = 0; i < pixel_count; ++i)
1625 {
1626 // Apply per-material multipliers from KHR_materials_pbrSpecularGlossiness:
1627 // final_diffuse_color = diffuseTexture.rgb * diffuseFactor.rgb
1628 // final_specular_color = specularTexture.rgb * specularFactor
1629 // final_glossiness = specularTexture.a * glossinessFactor
1630 // Baking the factors in here lets the caller set the material's base-color /
1631 // metallic / roughness factor uniforms to identity and avoid double-application
1632 // (the deferred shader does `albedo *= u_base_color` and `roughness *= tex.g`).
1633 float dr = static_cast<float>(d_data[i * d_bpp + 0]) / 255.0f * factors.diffuse_r;
1634 float dg = static_cast<float>(d_data[i * d_bpp + 1]) / 255.0f * factors.diffuse_g;
1635 float db = static_cast<float>(d_data[i * d_bpp + 2]) / 255.0f * factors.diffuse_b;
1636
1637 float sr = static_cast<float>(s_data[i * s_bpp + 0]) / 255.0f * factors.specular_r;
1638 float sg = static_cast<float>(s_data[i * s_bpp + 1]) / 255.0f * factors.specular_g;
1639 float sb = static_cast<float>(s_data[i * s_bpp + 2]) / 255.0f * factors.specular_b;
1640 float sa = (s_bpp >= 4) ? static_cast<float>(s_data[i * s_bpp + 3]) / 255.0f * factors.glossiness
1641 : factors.glossiness;
1642
1643 float max_specular = std::max({sr, sg, sb});
1644 float one_minus_spec_str = 1.0f - max_specular;
1645 float perc_d = perceived_brightness(dr, dg, db);
1646 float perc_s = perceived_brightness(sr, sg, sb);
1647 float metallic = solve_metallic(perc_d, perc_s, one_minus_spec_str);
1648
1649 // Exact Khronos/Babylon reference formula for base color reconstruction:
1650 // baseColorFromDiffuse = diffuse * (1 - F0) / (1 - metallic * F0)
1651 // baseColorFromSpecular = specular - F0 * (1 - metallic)
1652 // baseColor = mix(baseColorFromDiffuse, baseColorFromSpecular, metallic²)
1653 // Earlier we used a `one_minus_spec_str / (1 - metallic)` factor in the diffuse
1654 // term, which over-weighted the diffuse for high-metallic pixels and let things
1655 // like the rust tones on a metal helm bleed into the final base color.
1656 float denom = std::max(1.0f - metallic * dielectric_f0, epsilon);
1657 float spec_offset = dielectric_f0 * (1.0f - metallic);
1658 float t = metallic * metallic;
1659
1660 auto base_d = [&](float d) -> float { return d * (1.0f - dielectric_f0) / denom; };
1661 auto base_s = [&](float s) -> float { return s - spec_offset; };
1662
1663 if(rewrite_diffuse_to_base_color)
1664 {
1665 float br = math::mix(base_d(dr), base_s(sr), t);
1666 float bg = math::mix(base_d(dg), base_s(sg), t);
1667 float bb = math::mix(base_d(db), base_s(sb), t);
1668
1669 d_data[i * d_bpp + 0] = pixel_transforms::to_uint8(br);
1670 d_data[i * d_bpp + 1] = pixel_transforms::to_uint8(bg);
1671 d_data[i * d_bpp + 2] = pixel_transforms::to_uint8(bb);
1672
1673 // Bake diffuseFactor.a into the base color alpha so material transparency
1674 // doesn't get lost. Skip when no alpha channel is present.
1675 if(d_bpp >= 4)
1676 {
1677 float da = static_cast<float>(d_data[i * d_bpp + 3]) / 255.0f * factors.diffuse_a;
1678 d_data[i * d_bpp + 3] = pixel_transforms::to_uint8(da);
1679 }
1680 }
1681
1682 if(out_mr_rgba8 != nullptr)
1683 {
1684 float roughness = spec_alpha_has_gloss
1685 ? (1.0f - sa)
1686 : (1.0f - (sr + sg + sb) / 3.0f);
1687
1688 uint8_t* mr = out_mr_rgba8->data() + static_cast<size_t>(i) * 4;
1689 mr[0] = 255; // R = occlusion placeholder
1690 mr[1] = pixel_transforms::to_uint8(roughness);
1691 mr[2] = pixel_transforms::to_uint8(metallic);
1692 mr[3] = 255;
1693 }
1694 }
1695
1696 APPLOG_TRACE("Mesh Importer: Applied spec-gloss conversion{} (rewrite base color: {})",
1697 out_mr_rgba8 ? " with sibling metallic-roughness map" : "",
1698 rewrite_diffuse_to_base_color);
1699}
1700
1705auto atomic_image_save(const fs::path& output_file, bimg::ImageContainer* image) -> bool
1706{
1707 fs::error_code ec;
1709 output_file,
1710 [&](const fs::path& temp)
1711 {
1712 imageSave(temp.string().c_str(), image);
1713 },
1714 ec);
1715 return !ec;
1716}
1717
1732auto write_rgba8_png(const fs::path& output_file,
1733 uint32_t width,
1734 uint32_t height,
1735 const uint8_t* rgba8_data) -> bool
1736{
1737 fs::error_code ec;
1739 output_file,
1740 [&](const fs::path& temp)
1741 {
1742 bx::FileWriter writer;
1743 bx::Error err;
1744 if(!bx::open(&writer, temp.string().c_str(), false, &err))
1745 {
1746 return;
1747 }
1748 bimg::imageWritePng(&writer,
1749 width,
1750 height,
1751 width * 4,
1752 rgba8_data,
1753 bimg::TextureFormat::RGBA8,
1754 false,
1755 &err);
1756 bx::close(&writer);
1757 },
1758 ec);
1759 return !ec;
1760}
1761
1765struct spec_gloss_pbr_result
1766{
1767 bool diffuse_converted{false};
1769 std::string mr_relative;
1770};
1771
1772auto convert_spec_gloss_to_pbr_textures(const fs::path& output_dir,
1773 const std::string& base_color_relative,
1774 const std::string& mr_relative,
1775 bimg::ImageContainer* diffuse_img,
1776 const bimg::ImageContainer* specular_img,
1777 const spec_gloss_factors_t& factors,
1778 bool bake_base_color = true) -> spec_gloss_pbr_result
1779{
1780 spec_gloss_pbr_result result{};
1781
1782 if(!diffuse_img || !specular_img)
1783 {
1784 return result;
1785 }
1786
1787 // Normalize both inputs to RGBA8 so byte-offset reads and the PNG writer see
1788 // a consistent layout. imageConvert may return the same pointer if the source
1789 // is already RGBA8.
1790 bool diffuse_was_converted = false;
1791 bool specular_was_converted = false;
1792 bimg::ImageContainer* diffuse_rgba8 = ensure_rgba8(diffuse_img, diffuse_was_converted);
1793 bimg::ImageContainer* specular_rgba8 = ensure_rgba8(const_cast<bimg::ImageContainer*>(specular_img), specular_was_converted);
1794
1795 bool specular_resized = false;
1796 bimg::ImageContainer* specular_work = specular_rgba8;
1797
1798 auto free_intermediates = [&]()
1799 {
1800 if(specular_resized && specular_work)
1801 {
1802 bimg::imageFree(specular_work);
1803 }
1804 if(diffuse_was_converted && diffuse_rgba8)
1805 {
1806 bimg::imageFree(diffuse_rgba8);
1807 }
1808 if(specular_was_converted && specular_rgba8)
1809 {
1810 bimg::imageFree(specular_rgba8);
1811 }
1812 };
1813
1814 if(!diffuse_rgba8 || !specular_rgba8)
1815 {
1816 APPLOG_WARNING("Mesh Importer: Spec-gloss conversion skipped — could not normalize inputs to RGBA8");
1817 free_intermediates();
1818 return result;
1819 }
1820
1821 if(diffuse_rgba8->m_width != specular_rgba8->m_width || diffuse_rgba8->m_height != specular_rgba8->m_height)
1822 {
1823 bimg::ImageContainer* resized =
1824 resize_rgba8_image_to(specular_rgba8, diffuse_rgba8->m_width, diffuse_rgba8->m_height);
1825 if(!resized)
1826 {
1827 APPLOG_WARNING("Mesh Importer: Failed to resize specular {}x{} to match diffuse {}x{} for spec-gloss conversion",
1828 specular_rgba8->m_width,
1829 specular_rgba8->m_height,
1830 diffuse_rgba8->m_width,
1831 diffuse_rgba8->m_height);
1832 free_intermediates();
1833 return result;
1834 }
1835 specular_work = resized;
1836 specular_resized = true;
1837 APPLOG_TRACE("Mesh Importer: Upscaled specular {}x{} -> {}x{} for spec-gloss pair conversion",
1838 specular_rgba8->m_width,
1839 specular_rgba8->m_height,
1840 diffuse_rgba8->m_width,
1841 diffuse_rgba8->m_height);
1842 }
1843
1844 std::vector<uint8_t> mr_buffer;
1845 apply_diffuse_to_base_color_conversion(diffuse_rgba8,
1846 specular_work,
1847 factors,
1848 &mr_buffer,
1850
1851 // The conversion bails (logged) if formats are incompatible or sizes mismatch.
1852 if(mr_buffer.empty())
1853 {
1854 APPLOG_WARNING("Mesh Importer: Spec-gloss conversion produced no output (size mismatch or unsupported format)");
1855 free_intermediates();
1856 return result;
1857 }
1858
1859 if(bake_base_color)
1860 {
1861 if(!write_rgba8_png(output_dir / base_color_relative,
1862 diffuse_rgba8->m_width,
1863 diffuse_rgba8->m_height,
1864 static_cast<const uint8_t*>(diffuse_rgba8->m_data)))
1865 {
1866 APPLOG_WARNING("Mesh Importer: Failed to save converted base color texture: {}", base_color_relative);
1867 free_intermediates();
1868 return result;
1869 }
1870 result.diffuse_converted = true;
1871 result.base_color_relative = base_color_relative;
1872 }
1873
1874 if(write_rgba8_png(output_dir / mr_relative, diffuse_rgba8->m_width, diffuse_rgba8->m_height, mr_buffer.data()))
1875 {
1876 result.mr_relative = mr_relative;
1877 }
1878 else
1879 {
1880 APPLOG_WARNING("Mesh Importer: Failed to save sibling metallic-roughness texture for spec-gloss conversion");
1881 }
1882
1883 free_intermediates();
1884 return result;
1885}
1886
1890void process_raw_texture_data(const aiTexture* assimp_tex, const fs::path& output_file,
1891 const std::string& semantic, bool inverse)
1892{
1893 // For raw textures, we need to create a temporary image container to apply conversions
1894 uint32_t width = assimp_tex->mWidth;
1895 uint32_t height = assimp_tex->mHeight;
1896
1897 // Create a copy of the raw data to modify
1898 std::vector<uint8_t> data(width * height * 4);
1899 std::memcpy(data.data(), assimp_tex->pcData, width * height * 4);
1900
1901 // Apply conversions to the copied data
1902 if(semantic == "ShininessToRoughness" || semantic == "SpecularToMetallicRoughness")
1903 {
1904 // Create a temporary image container for conversion
1905 bimg::ImageContainer image;
1906 image.m_data = data.data();
1907 image.m_width = width;
1908 image.m_height = height;
1909 image.m_depth = 1;
1910 image.m_format = bimg::TextureFormat::RGBA8;
1911 image.m_numMips = 1;
1912 image.m_hasAlpha = true;
1913
1914 apply_texture_conversion(&image, semantic, inverse);
1915 }
1916 else if(inverse)
1917 {
1918 // Simple inversion
1919 for(size_t i = 0; i < data.size(); ++i)
1920 {
1921 data[i] = 255 - data[i];
1922 }
1923 }
1924
1925 // Write the processed data as PNG. Avoid TGA here for the same reason as
1926 // write_rgba8_png: bimg::imageWriteTga writes the buffer raw under a Type-2
1927 // header but TGA's wire format is BGRA, so RGBA bytes load back R↔B-swapped.
1928 write_rgba8_png(output_file, width, height, data.data());
1929}
1930
1931template<typename T>
1932void log_prop_value(aiMaterialProperty* prop, const char* name1)
1933{
1934 auto data = (T*)prop->mData;
1935
1936 auto count = prop->mDataLength / sizeof(T);
1937
1938 if(count == 1)
1939 {
1940 APPLOG_TRACE(" {} = {}", name1, data[0]);
1941 }
1942 else
1943 {
1944 std::vector<T> vals(count);
1945 std::memcpy(vals.data(), data, count * sizeof(T));
1946 APPLOG_TRACE(" {}[{}] = {}", name1, count, vals);
1947 }
1948}
1949
1950void log_materials(const aiMaterial* material)
1951{
1952 for(uint32_t i = 0; i < material->mNumProperties; i++)
1953 {
1954 auto prop = material->mProperties[i];
1955
1956 APPLOG_TRACE("Material Property:");
1957 APPLOG_TRACE(" name = {0}", prop->mKey.C_Str());
1958
1959 if(prop->mDataLength > 0 && prop->mData)
1960 {
1961 auto semantic = aiTextureType(prop->mSemantic);
1962 if(semantic != aiTextureType_NONE && semantic != aiTextureType_UNKNOWN)
1963 {
1964 APPLOG_TRACE(" semantic = {0}", aiTextureTypeToString(semantic));
1965 }
1966
1967 switch(prop->mType)
1968 {
1969 case aiPropertyTypeInfo::aiPTI_Float:
1970 {
1971 log_prop_value<float>(prop, "float");
1972 break;
1973 }
1974
1975 case aiPropertyTypeInfo::aiPTI_Double:
1976 {
1977 log_prop_value<double>(prop, "double");
1978 break;
1979 }
1980 case aiPropertyTypeInfo::aiPTI_Integer:
1981 {
1982 log_prop_value<int32_t>(prop, "int");
1983 break;
1984 }
1985
1986 case aiPropertyTypeInfo::aiPTI_Buffer:
1987 {
1988 log_prop_value<uint8_t>(prop, "buffer");
1989 break;
1990 }
1991 case aiPropertyTypeInfo::aiPTI_String:
1992 {
1993 aiString str;
1994 if(aiGetMaterialString(material, prop->mKey.C_Str(), prop->mSemantic, prop->mIndex, &str) ==
1995 AI_SUCCESS)
1996 {
1997 APPLOG_TRACE(" string = {0}", str.C_Str());
1998 }
1999 break;
2000 }
2001 default:
2002 {
2003 break;
2004 }
2005 }
2006 }
2007 }
2008}
2009
2010// Material input workflows (all compile to engine metallic-roughness + base color).
2011enum class material_workflow
2012{
2013 unknown,
2014 metallic_roughness, // glTF/FBX PBR MR: base color + metallic/roughness maps/factors
2015 khr_specular_glossiness, // KHR_materials_pbrSpecularGlossiness: Khronos bake diffuse+spec textures
2016 phong_specular_gloss, // Legacy Phong/Blinn: diffuse pass-through, shininess -> roughness
2017};
2018
2019auto phong_shininess_exponent_to_roughness(float shininess) -> float
2020{
2021 return math::clamp(std::sqrt(2.0f / (shininess + 2.0f)), 0.0f, 1.0f);
2022}
2023
2027auto fbx_roughness_texture_is_native_roughness(const aiMaterial* material) -> bool
2028{
2029 int use_glossiness = 0;
2030 return material != nullptr
2031 && material->Get("$raw.3dsMax|main|useGlossiness", aiTextureType_NONE, 0, use_glossiness) == AI_SUCCESS
2032 && use_glossiness == 2;
2033}
2034
2035auto material_workflow_label(material_workflow workflow) -> const char*
2036{
2037 switch(workflow)
2038 {
2039 case material_workflow::metallic_roughness:
2040 return "Metallic/Roughness";
2041 case material_workflow::khr_specular_glossiness:
2042 return "KHR Specular/Glossiness";
2043 case material_workflow::phong_specular_gloss:
2044 return "Phong Specular/Gloss";
2045 default:
2046 return "Unknown";
2047 }
2048}
2049
2050auto normalize_material_texture_path(const fs::path& path) -> std::string
2051{
2052 return string_utils::to_lower(normalize_assimp_path(path).generic_string());
2053}
2054
2055auto material_texture_paths_equal(const fs::path& left, const fs::path& right) -> bool
2056{
2057 if(left.empty() || right.empty())
2058 {
2059 return false;
2060 }
2061 return normalize_material_texture_path(left) == normalize_material_texture_path(right);
2062}
2063
2073auto base_color_texture_is_authoritative(const aiMaterial* material) -> bool
2074{
2075 aiString base_path{};
2076 aiString diffuse_path{};
2077 const bool has_base =
2078 (material->GetTexture(aiTextureType_BASE_COLOR, 0, &base_path) == AI_SUCCESS && base_path.length > 0)
2079 || (material->GetTexture(AI_MATKEY_BASE_COLOR_TEXTURE, &base_path) == AI_SUCCESS && base_path.length > 0);
2080 const bool has_diffuse =
2081 material->GetTexture(aiTextureType_DIFFUSE, 0, &diffuse_path) == AI_SUCCESS && diffuse_path.length > 0;
2082
2083 if(has_base && has_diffuse)
2084 {
2085 return material_texture_paths_equal(normalize_assimp_path(base_path.C_Str()),
2086 normalize_assimp_path(diffuse_path.C_Str()));
2087 }
2088
2089 return has_base;
2090}
2091
2092auto texture_path_indicates_base_color(const std::string& relative_path) -> bool
2093{
2094 const std::string lower = string_utils::to_lower(normalize_assimp_path(relative_path).stem().string());
2095 static constexpr std::array<const char*, 3> markers = {
2096 "basecolor",
2097 "base_color",
2098 "base-color",
2099 };
2100 for(const char* marker : markers)
2101 {
2102 if(lower.find(marker) != std::string::npos)
2103 {
2104 return true;
2105 }
2106 }
2107 return false;
2108}
2109
2110auto albedo_texture_is_explicit_base_color(const imported_texture& albedo_tex) -> bool
2111{
2112 return albedo_tex.semantic == "BaseColor" || texture_path_indicates_base_color(albedo_tex.name);
2113}
2114
2115auto material_shading_is_phong_family(aiShadingMode shading) -> bool
2116{
2117 switch(shading)
2118 {
2119 case aiShadingMode_Phong:
2120 case aiShadingMode_Blinn:
2121 case aiShadingMode_Minnaert:
2122 case aiShadingMode_Gouraud:
2123 case aiShadingMode_Flat:
2124 return true;
2125 default:
2126 return false;
2127 }
2128}
2129
2130auto material_has_pbr_brdf_shading(const aiMaterial* material) -> bool
2131{
2132 aiShadingMode shading = aiShadingMode_Flat;
2133 return material->Get(AI_MATKEY_SHADING_MODEL, shading) == AI_SUCCESS && shading == aiShadingMode_PBR_BRDF;
2134}
2135
2139auto material_has_glossiness_factor(const aiMaterial* material) -> bool
2140{
2141 ai_real glossiness = 0.0f;
2142 return material->Get(AI_MATKEY_GLOSSINESS_FACTOR, glossiness) == AI_SUCCESS;
2143}
2144
2148auto should_reconstruct_base_color_for_spec_gloss_pair(material_workflow workflow,
2149 const aiMaterial* material) -> bool
2150{
2151 return workflow == material_workflow::khr_specular_glossiness
2152 && !base_color_texture_is_authoritative(material);
2153}
2154
2155auto string_ends_with(std::string_view value, std::string_view suffix) -> bool
2156{
2157 return value.size() >= suffix.size()
2158 && value.compare(value.size() - suffix.size(), suffix.size(), suffix) == 0;
2159}
2160
2164auto lumberyard_bistro_basecolor_specular_pair(const aiMaterial* material) -> bool
2165{
2166 if(material == nullptr)
2167 {
2168 return false;
2169 }
2170
2171 aiString diffuse_path{};
2172 aiString specular_path{};
2173 if(material->GetTexture(aiTextureType_DIFFUSE, 0, &diffuse_path) != AI_SUCCESS || diffuse_path.length == 0)
2174 {
2175 return false;
2176 }
2177 if(material->GetTexture(aiTextureType_SPECULAR, 0, &specular_path) != AI_SUCCESS || specular_path.length == 0)
2178 {
2179 return false;
2180 }
2181
2182 const std::string diffuse_stem =
2183 string_utils::to_lower(normalize_assimp_path(diffuse_path.C_Str()).stem().string());
2184 const std::string specular_stem =
2185 string_utils::to_lower(normalize_assimp_path(specular_path.C_Str()).stem().string());
2186
2187 if(!string_ends_with(diffuse_stem, "_basecolor") || !string_ends_with(specular_stem, "_specular"))
2188 {
2189 return false;
2190 }
2191
2192 constexpr std::string_view k_basecolor_suffix = "_basecolor";
2193 constexpr std::string_view k_specular_suffix = "_specular";
2194 const std::string diffuse_prefix =
2195 diffuse_stem.substr(0, diffuse_stem.size() - k_basecolor_suffix.size());
2196 const std::string specular_prefix =
2197 specular_stem.substr(0, specular_stem.size() - k_specular_suffix.size());
2198 return diffuse_prefix == specular_prefix;
2199}
2200
2205auto specular_texture_path_looks_like_packed_mr(const aiMaterial* material) -> bool
2206{
2207 if(material == nullptr)
2208 {
2209 return false;
2210 }
2211
2212 if(lumberyard_bistro_basecolor_specular_pair(material))
2213 {
2214 return true;
2215 }
2216
2217 aiString path{};
2218 if(material->GetTexture(aiTextureType_SPECULAR, 0, &path) != AI_SUCCESS || path.length == 0)
2219 {
2220 return false;
2221 }
2222
2223 const std::string stem = string_utils::to_lower(normalize_assimp_path(path.C_Str()).stem().string());
2224 if(string_ends_with(stem, "_spec"))
2225 {
2226 return true;
2227 }
2228 return stem.find("_spec_") != std::string::npos;
2229}
2230
2235auto material_has_packed_mr_in_specular_slot(const aiMaterial* material) -> bool
2236{
2237 if(material == nullptr || material_has_glossiness_factor(material))
2238 {
2239 return false;
2240 }
2241 if(material->GetTextureCount(aiTextureType_SPECULAR) == 0)
2242 {
2243 return false;
2244 }
2245 if(material->GetTextureCount(aiTextureType_SHININESS) > 0)
2246 {
2247 return false;
2248 }
2249
2250 const bool has_albedo_texture = material->GetTextureCount(aiTextureType_DIFFUSE) > 0
2251 || material->GetTextureCount(aiTextureType_BASE_COLOR) > 0;
2252 if(!has_albedo_texture)
2253 {
2254 return false;
2255 }
2256
2257 // Path naming is the reliable Bistro/CryEngine signal. Do not require COLOR_SPECULAR ≈ white:
2258 // Assimp FBX keeps Phong defaults (often ~0.2–0.5) even though the engine multiplies by white.
2259 return specular_texture_path_looks_like_packed_mr(material);
2260}
2261
2266auto has_metallic_roughness_texture_evidence(const aiMaterial* material) -> bool
2267{
2268 aiString path1;
2269 if(material->GetTexture(AI_MATKEY_GLTF_PBRMETALLICROUGHNESS_METALLICROUGHNESS_TEXTURE, &path1) == AI_SUCCESS)
2270 {
2271 return true;
2272 }
2273
2274 aiString path2;
2275 if(material->GetTexture(AI_MATKEY_METALLIC_TEXTURE, &path2) == AI_SUCCESS)
2276 {
2277 return true;
2278 }
2279
2280 if(material->GetTextureCount(aiTextureType_METALNESS) > 0)
2281 {
2282 return true;
2283 }
2284
2285 if(material->GetTextureCount(aiTextureType_GLTF_METALLIC_ROUGHNESS) > 0)
2286 {
2287 return true;
2288 }
2289
2290 if(material->GetTextureCount(aiTextureType_DIFFUSE_ROUGHNESS) > 0)
2291 {
2292 return true;
2293 }
2294
2295 if(material->GetTextureCount(aiTextureType_MAYA_SPECULAR_ROUGHNESS) > 0)
2296 {
2297 return true;
2298 }
2299
2300 // FBX / legacy exporters sometimes park combined MR maps in UNKNOWN (see Assimp #5969).
2301 if(material->GetTextureCount(aiTextureType_UNKNOWN) > 0)
2302 {
2303 ai_real metallic_dummy = 0.0f;
2304 if(material_has_pbr_brdf_shading(material)
2305 || material->Get(AI_MATKEY_METALLIC_FACTOR, metallic_dummy) == AI_SUCCESS)
2306 {
2307 return true;
2308 }
2309 }
2310
2311 if(material_has_packed_mr_in_specular_slot(material))
2312 {
2313 return true;
2314 }
2315
2316 return false;
2317}
2318
2322auto has_native_mr_factor_evidence(const aiMaterial* material) -> bool
2323{
2324 if(material_has_glossiness_factor(material))
2325 {
2326 return false;
2327 }
2328 if(!material_has_pbr_brdf_shading(material))
2329 {
2330 return false;
2331 }
2332 ai_real dummy = 0.0f;
2333 return material->Get(AI_MATKEY_METALLIC_FACTOR, dummy) == AI_SUCCESS
2334 || material->Get(AI_MATKEY_ROUGHNESS_FACTOR, dummy) == AI_SUCCESS;
2335}
2336
2340auto is_phong_legacy_material(const aiMaterial* material) -> bool
2341{
2342 if(material_has_glossiness_factor(material))
2343 {
2344 return false;
2345 }
2346 if(has_metallic_roughness_texture_evidence(material))
2347 {
2348 return false;
2349 }
2350 if(has_native_mr_factor_evidence(material))
2351 {
2352 return false;
2353 }
2354
2355 aiShadingMode shading = aiShadingMode_Flat;
2356 if(material->Get(AI_MATKEY_SHADING_MODEL, shading) == AI_SUCCESS
2357 && material_shading_is_phong_family(shading))
2358 {
2359 return true;
2360 }
2361
2362 ai_real shininess = 0.0f;
2363 if(material->Get(AI_MATKEY_SHININESS, shininess) == AI_SUCCESS)
2364 {
2365 return true;
2366 }
2367
2368 if(material->GetTextureCount(aiTextureType_SHININESS) > 0)
2369 {
2370 return true;
2371 }
2372
2373 if(!material_has_pbr_brdf_shading(material) && material->GetTextureCount(aiTextureType_SPECULAR) > 0)
2374 {
2375 return true;
2376 }
2377
2378 return false;
2379}
2380
2384auto khr_needs_combined_specular_mr(const aiMaterial* material, material_workflow workflow) -> bool
2385{
2386 if(workflow != material_workflow::khr_specular_glossiness)
2387 {
2388 return false;
2389 }
2390
2391 if(material->GetTextureCount(aiTextureType_METALNESS) > 0
2392 || material->GetTextureCount(aiTextureType_GLTF_METALLIC_ROUGHNESS) > 0
2393 || material->GetTextureCount(aiTextureType_DIFFUSE_ROUGHNESS) > 0)
2394 {
2395 return false;
2396 }
2397
2398 if(material->GetTextureCount(aiTextureType_SPECULAR) > 0)
2399 {
2400 APPLOG_TRACE("Mesh Importer: KHR specular-only -> combined SpecularToMetallicRoughness conversion");
2401 return true;
2402 }
2403
2404 return false;
2405}
2406
2412auto detect_material_workflow(const aiMaterial* material) -> material_workflow
2413{
2414 if(material_has_glossiness_factor(material))
2415 {
2416 APPLOG_TRACE("Mesh Importer: Workflow=KHR (AI_MATKEY_GLOSSINESS_FACTOR present)");
2417 return material_workflow::khr_specular_glossiness;
2418 }
2419
2420 if(has_metallic_roughness_texture_evidence(material))
2421 {
2422 if(material_has_packed_mr_in_specular_slot(material))
2423 {
2424 APPLOG_TRACE("Mesh Importer: Workflow=MR (packed metallic-roughness in aiTextureType_SPECULAR)");
2425 }
2426 else
2427 {
2428 APPLOG_TRACE("Mesh Importer: Workflow=MR (dedicated metallic/roughness texture slots)");
2429 }
2430 return material_workflow::metallic_roughness;
2431 }
2432
2433 if(has_native_mr_factor_evidence(material))
2434 {
2435 APPLOG_TRACE("Mesh Importer: Workflow=MR (PBR_BRDF + metallic/roughness factors, no glossiness)");
2436 return material_workflow::metallic_roughness;
2437 }
2438
2439 if(is_phong_legacy_material(material))
2440 {
2441 APPLOG_TRACE("Mesh Importer: Workflow=Phong (shininess/specular legacy signals)");
2442 return material_workflow::phong_specular_gloss;
2443 }
2444
2445 APPLOG_TRACE("Mesh Importer: Workflow=Unknown (no KHR/MR/Phong signals)");
2446 return material_workflow::unknown;
2447}
2448
2449auto make_texture_catalog_key(const imported_texture& tex) -> std::string
2450{
2451 if(tex.embedded_index >= 0)
2452 {
2453 return fmt::format("e:{}:{}", tex.embedded_index, tex.semantic);
2454 }
2455 return fmt::format("x:{}:{}", normalize_material_texture_path(normalize_assimp_path(tex.name)), tex.semantic);
2456}
2457
2458auto needs_external_texture_conversion(const imported_texture& tex) -> bool
2459{
2460 return tex.embedded_index < 0 &&
2461 (tex.semantic == "ShininessToRoughness"
2462 || tex.semantic == "SpecularToMetallicRoughness" || tex.inverse);
2463}
2464
2465auto make_converted_texture_name(const std::string& original_name, const std::string& semantic) -> std::string
2466{
2467 fs::path p(original_name);
2468 const std::string suffix = semantic.empty() ? "converted" : semantic;
2469 return (p.parent_path() / (p.stem().string() + "_" + suffix + ".png")).generic_string();
2470}
2471
2472auto build_converted_texture_name(const fs::path& filename,
2473 const aiScene* scene,
2474 int embedded_idx,
2475 const std::string& source_relative,
2476 const std::string& target_semantic) -> std::string
2477{
2478 if(embedded_idx >= 0 && embedded_idx < static_cast<int>(scene->mNumTextures))
2479 {
2480 return fmt::format("[{}] {} {}.png", embedded_idx, target_semantic, filename.string());
2481 }
2482 fs::path src(source_relative);
2483 return (src.parent_path() / (src.stem().string() + "_" + target_semantic + ".png")).generic_string();
2484}
2485
2486auto spec_gloss_factors_key(const spec_gloss_factors_t& factors) -> std::string
2487{
2488 return fmt::format("{:.4f}_{:.4f}_{:.4f}_{:.4f}_{:.4f}_{:.4f}_{:.4f}_{:.4f}",
2489 factors.diffuse_r,
2490 factors.diffuse_g,
2491 factors.diffuse_b,
2492 factors.diffuse_a,
2493 factors.specular_r,
2494 factors.specular_g,
2495 factors.specular_b,
2496 factors.glossiness);
2497}
2498
2499auto gather_spec_gloss_factors(const aiMaterial* material) -> spec_gloss_factors_t
2500{
2501 spec_gloss_factors_t factors{};
2502 if(!material)
2503 {
2504 return factors;
2505 }
2506
2507 aiColor4D diffuse_factor_color{1.0f, 1.0f, 1.0f, 1.0f};
2508 if(material->Get(AI_MATKEY_COLOR_DIFFUSE, diffuse_factor_color) == AI_SUCCESS)
2509 {
2510 factors.diffuse_r = diffuse_factor_color.r;
2511 factors.diffuse_g = diffuse_factor_color.g;
2512 factors.diffuse_b = diffuse_factor_color.b;
2513 factors.diffuse_a = diffuse_factor_color.a;
2514 }
2515
2516 aiColor3D specular_factor_color{1.0f, 1.0f, 1.0f};
2517 material->Get(AI_MATKEY_COLOR_SPECULAR, specular_factor_color);
2518 float specular_factor_scalar = 1.0f;
2519 material->Get(AI_MATKEY_SPECULAR_FACTOR, specular_factor_scalar);
2520 factors.specular_r = specular_factor_color.r * specular_factor_scalar;
2521 factors.specular_g = specular_factor_color.g * specular_factor_scalar;
2522 factors.specular_b = specular_factor_color.b * specular_factor_scalar;
2523
2524 factors.glossiness = 1.0f;
2525 if(material->Get(AI_MATKEY_GLOSSINESS_FACTOR, factors.glossiness) != AI_SUCCESS)
2526 {
2527 float shininess = 32.0f;
2528 if(material->Get(AI_MATKEY_SHININESS, shininess) == AI_SUCCESS)
2529 {
2530 factors.glossiness = math::clamp(1.0f - std::sqrt(2.0f / (shininess + 2.0f)), 0.0f, 1.0f);
2531 }
2532 }
2533
2534 return factors;
2535}
2536
2537class texture_catalog
2538{
2539public:
2540 auto resolve(imported_texture& tex) const -> bool
2541 {
2542 const auto it = entries_.find(make_texture_catalog_key(tex));
2543 if(it == entries_.end())
2544 {
2545 return false;
2546 }
2547 tex.name = it->second.name;
2548 tex.flags = it->second.flags;
2549 tex.inverse = it->second.inverse;
2550 tex.process_count = it->second.process_count;
2551 tex.semantic = it->second.semantic;
2552 tex.embedded_index = it->second.embedded_index;
2553 return true;
2554 }
2555
2556 void register_entry(const imported_texture& lookup_key, imported_texture result)
2557 {
2558 entries_[make_texture_catalog_key(lookup_key)] = std::move(result);
2559 }
2560
2561 void append_to_manifest(std::vector<imported_texture>& textures) const
2562 {
2563 for(const auto& kvp : entries_)
2564 {
2565 const auto& entry = kvp.second;
2566 const auto exists = std::find_if(textures.begin(),
2567 textures.end(),
2568 [&](const imported_texture& rhs)
2569 {
2570 return rhs.embedded_index == entry.embedded_index
2571 && rhs.name == entry.name && rhs.semantic == entry.semantic;
2572 });
2573 if(exists == textures.end())
2574 {
2575 textures.push_back(entry);
2576 }
2577 }
2578 }
2579
2580 void merge_from(const texture_catalog& other)
2581 {
2582 for(const auto& kvp : other.entries_)
2583 {
2584 entries_[kvp.first] = kvp.second;
2585 }
2586 }
2587
2588 auto has_output_for_lookup(const imported_texture& lookup_key, const std::string& expected_output_relative) const -> bool
2589 {
2590 imported_texture probe = lookup_key;
2591 if(!resolve(probe))
2592 {
2593 return false;
2594 }
2595 return probe.name == expected_output_relative;
2596 }
2597
2598private:
2599 std::unordered_map<std::string, imported_texture> entries_;
2600};
2601
2602enum class texture_job_type : uint8_t
2603{
2604 embedded_extract,
2605 external_convert,
2606 spec_gloss_pair,
2607};
2608
2609struct texture_job
2610{
2611 texture_job_type type{};
2612 imported_texture desc{};
2613 imported_texture specular_desc{};
2614 spec_gloss_factors_t spec_gloss_factors{};
2619};
2620
2621class texture_job_store
2622{
2623public:
2624 auto jobs() const -> const std::vector<texture_job>&
2625 {
2626 return jobs_;
2627 }
2628
2629 auto try_add(texture_job job) -> bool
2630 {
2631 const std::string dedupe = make_dedupe_key(job);
2632 if(!dedupe_keys_.insert(dedupe).second)
2633 {
2634 return false;
2635 }
2636 jobs_.push_back(std::move(job));
2637 return true;
2638 }
2639
2640private:
2641 static auto make_dedupe_key(const texture_job& job) -> std::string
2642 {
2643 switch(job.type)
2644 {
2645 case texture_job_type::embedded_extract:
2646 case texture_job_type::external_convert:
2647 return make_texture_catalog_key(job.desc);
2648 case texture_job_type::spec_gloss_pair:
2649 return fmt::format("sg:{}:{}:{}:{}",
2650 make_texture_catalog_key(job.desc),
2651 make_texture_catalog_key(job.specular_desc),
2652 spec_gloss_factors_key(job.spec_gloss_factors),
2653 job.bake_base_color ? "bc" : "mr");
2654 default:
2655 return {};
2656 }
2657 }
2658
2659 std::vector<texture_job> jobs_;
2660 std::unordered_set<std::string> dedupe_keys_;
2661};
2662
2663struct material_import_env
2664{
2665 enum class phase_t
2666 {
2667 collect,
2668 bind
2669 };
2670
2671 phase_t phase{phase_t::bind};
2672 const fs::path* filename{};
2673 const fs::path* output_dir{};
2674 const aiScene* scene{};
2675 texture_job_store* job_store{};
2676 texture_catalog* catalog{};
2677};
2678
2679auto load_image_for_texture_desc(const aiScene* scene,
2680 const fs::path& output_dir,
2681 const imported_texture& tex,
2682 const char* assimp_path_cstr = nullptr) -> bimg::ImageContainer*
2683{
2684 if(tex.embedded_index >= 0 && tex.embedded_index < static_cast<int>(scene->mNumTextures))
2685 {
2686 const auto* embedded = scene->mTextures[tex.embedded_index];
2687 if(embedded->pcData && embedded->mHeight == 0)
2688 {
2689 return imageLoad(embedded->pcData, static_cast<uint32_t>(embedded->mWidth));
2690 }
2691 return nullptr;
2692 }
2693
2694 if(assimp_path_cstr != nullptr && assimp_path_cstr[0] != '\0')
2695 {
2696 const fs::path relative = resolve_external_texture_path(output_dir, normalize_assimp_path(assimp_path_cstr));
2697 return imageLoad(bx::FilePath((output_dir / relative).string().c_str()));
2698 }
2699
2700 const fs::path relative = resolve_external_texture_path(output_dir, normalize_assimp_path(tex.name));
2701 return imageLoad(bx::FilePath((output_dir / relative).string().c_str()));
2702}
2703
2704auto try_synchronous_spec_gloss_pair_mr(const fs::path& output_dir,
2705 const aiScene* scene,
2706 const imported_texture& pair_source,
2707 const imported_texture& specular_tex,
2708 const spec_gloss_factors_t& factors,
2709 const std::string& expected_mr,
2710 bool bake_base_color) -> std::string
2711{
2712 bimg::ImageContainer* diffuse_img = load_image_for_texture_desc(scene, output_dir, pair_source);
2713 bimg::ImageContainer* specular_img = load_image_for_texture_desc(scene, output_dir, specular_tex);
2714
2715 if(!diffuse_img)
2716 {
2717 APPLOG_WARNING("Mesh Importer: Bind-time pair MR could not load diffuse: {}", pair_source.name);
2718 }
2719 if(!specular_img)
2720 {
2721 APPLOG_WARNING("Mesh Importer: Bind-time pair MR could not load specular: {}", specular_tex.name);
2722 }
2723
2724 if(!diffuse_img || !specular_img)
2725 {
2726 if(diffuse_img)
2727 {
2728 bimg::imageFree(diffuse_img);
2729 }
2730 if(specular_img)
2731 {
2732 bimg::imageFree(specular_img);
2733 }
2734 return {};
2735 }
2736
2737 auto conv = convert_spec_gloss_to_pbr_textures(output_dir,
2738 std::string{},
2739 expected_mr,
2740 diffuse_img,
2741 specular_img,
2742 factors,
2744 bimg::imageFree(diffuse_img);
2745 bimg::imageFree(specular_img);
2746
2747 if(!conv.mr_relative.empty())
2748 {
2749 APPLOG_TRACE("Mesh Importer: Bind-time pair metallic-roughness bake: {}", conv.mr_relative);
2750 }
2751 return conv.mr_relative;
2752}
2753
2754auto resolve_texture_on_disk(const fs::path& output_dir, fs::path relative) -> std::optional<fs::path>
2755{
2756 if(relative.empty())
2757 {
2758 return std::nullopt;
2759 }
2760
2761 relative = resolve_external_texture_path(output_dir, relative);
2762
2763 fs::error_code err;
2764 fs::path absolute = relative.is_absolute() ? relative : (output_dir / relative);
2765 absolute = fs::weakly_canonical(absolute, err);
2766 if(err || !fs::exists(absolute, err))
2767 {
2768 return std::nullopt;
2769 }
2770
2771 return absolute;
2772}
2773
2774auto texture_file_exists(const fs::path& output_dir, const std::string& relative) -> bool
2775{
2776 return resolve_texture_on_disk(output_dir, normalize_assimp_path(relative)).has_value();
2777}
2778
2779auto try_make_texture_asset_key(const fs::path& output_dir, const std::string& relative) -> std::optional<std::string>
2780{
2781 const auto absolute = resolve_texture_on_disk(output_dir, normalize_assimp_path(relative));
2782 if(!absolute)
2783 {
2784 return std::nullopt;
2785 }
2786
2787 const fs::path key = fs::convert_to_protocol(*absolute);
2788 if(!fs::has_known_protocol(key))
2789 {
2790 return std::nullopt;
2791 }
2792
2793 return key.generic_string();
2794}
2795
2796auto find_spec_gloss_mr_relative(const texture_catalog* catalog,
2797 const fs::path& output_dir,
2798 const imported_texture& specular_tex,
2799 const std::string& expected_mr) -> std::string
2800{
2801 if(catalog)
2802 {
2803 imported_texture probe = specular_tex;
2804 if(catalog->resolve(probe) && !probe.name.empty() && texture_file_exists(output_dir, probe.name))
2805 {
2806 return probe.name;
2807 }
2808 }
2809 if(texture_file_exists(output_dir, expected_mr))
2810 {
2811 return expected_mr;
2812 }
2813 return {};
2814}
2815
2816void mark_embedded_consumed_index(int idx, std::unordered_set<int>& consumed, texture_catalog& catalog)
2817{
2818 if(idx < 0)
2819 {
2820 return;
2821 }
2822 consumed.insert(idx);
2823 imported_texture entry{};
2824 entry.embedded_index = idx;
2825 entry.process_count = 1;
2826 catalog.register_entry(entry, entry);
2827}
2828
2829auto get_texture_job_output_paths(const texture_job& job) -> std::vector<std::string>
2830{
2831 std::vector<std::string> paths;
2832 switch(job.type)
2833 {
2834 case texture_job_type::embedded_extract:
2835 if(!job.desc.name.empty())
2836 {
2837 paths.push_back(job.desc.name);
2838 }
2839 break;
2840 case texture_job_type::external_convert:
2841 paths.push_back(make_converted_texture_name(job.desc.name, job.desc.semantic));
2842 break;
2843 case texture_job_type::spec_gloss_pair:
2844 if(!job.output_base_color_relative.empty())
2845 {
2846 paths.push_back(job.output_base_color_relative);
2847 }
2848 if(!job.output_mr_relative.empty())
2849 {
2850 paths.push_back(job.output_mr_relative);
2851 }
2852 break;
2853 default:
2854 break;
2855 }
2856 return paths;
2857}
2858
2859struct texture_job_disjoint_set
2860{
2861 explicit texture_job_disjoint_set(size_t count) : parent_(count)
2862 {
2863 std::iota(parent_.begin(), parent_.end(), size_t{0});
2864 }
2865
2866 auto find(size_t index) -> size_t
2867 {
2868 while(parent_[index] != index)
2869 {
2871 index = parent_[index];
2872 }
2873 return index;
2874 }
2875
2876 void unite(size_t a, size_t b)
2877 {
2878 a = find(a);
2879 b = find(b);
2880 if(a != b)
2881 {
2882 parent_[b] = a;
2883 }
2884 }
2885
2886 std::vector<size_t> parent_;
2887};
2888
2889auto build_texture_job_composite_groups(const std::vector<texture_job>& jobs) -> std::vector<std::vector<size_t>>
2890{
2891 if(jobs.empty())
2892 {
2893 return {};
2894 }
2895
2896 texture_job_disjoint_set disjoint_set(jobs.size());
2897 std::unordered_map<std::string, size_t> path_to_job_index;
2898
2899 for(size_t job_index = 0; job_index < jobs.size(); ++job_index)
2900 {
2901 for(const auto& output_path : get_texture_job_output_paths(jobs[job_index]))
2902 {
2903 if(output_path.empty())
2904 {
2905 continue;
2906 }
2907
2908 const auto existing = path_to_job_index.find(output_path);
2909 if(existing == path_to_job_index.end())
2910 {
2911 path_to_job_index.emplace(output_path, job_index);
2912 }
2913 else
2914 {
2915 disjoint_set.unite(job_index, existing->second);
2916 }
2917 }
2918 }
2919
2920 std::unordered_map<size_t, std::vector<size_t>> groups_by_root;
2921 groups_by_root.reserve(jobs.size());
2922 for(size_t job_index = 0; job_index < jobs.size(); ++job_index)
2923 {
2924 groups_by_root[disjoint_set.find(job_index)].push_back(job_index);
2925 }
2926
2927 std::vector<std::vector<size_t>> composites;
2928 composites.reserve(groups_by_root.size());
2929 for(auto& kvp : groups_by_root)
2930 {
2931 auto& group = kvp.second;
2932 std::sort(group.begin(), group.end());
2933 composites.push_back(std::move(group));
2934 }
2935
2936 std::sort(composites.begin(),
2937 composites.end(),
2938 [](const std::vector<size_t>& lhs, const std::vector<size_t>& rhs)
2939 {
2940 return lhs.front() < rhs.front();
2941 });
2942
2943 return composites;
2944}
2945
2946void sort_imported_textures(std::vector<imported_texture>& textures)
2947{
2948 std::sort(textures.begin(),
2949 textures.end(),
2950 [](const imported_texture& lhs, const imported_texture& rhs)
2951 {
2952 if(lhs.embedded_index != rhs.embedded_index)
2953 {
2954 return lhs.embedded_index < rhs.embedded_index;
2955 }
2956 if(lhs.semantic != rhs.semantic)
2957 {
2958 return lhs.semantic < rhs.semantic;
2959 }
2960 return lhs.name < rhs.name;
2961 });
2962}
2963
2964void execute_texture_job(const texture_job& job,
2965 const fs::path& filename,
2966 const fs::path& output_dir,
2967 const aiScene* scene,
2968 texture_catalog& catalog,
2969 std::unordered_set<int>& consumed_embedded)
2970{
2971 switch(job.type)
2972 {
2973 case texture_job_type::embedded_extract:
2974 {
2975 imported_texture result = job.desc;
2976 std::vector<imported_texture> scratch;
2977 scratch.push_back(result);
2978 const auto* embedded = scene->mTextures[result.embedded_index];
2979 process_embedded_texture(embedded, static_cast<size_t>(result.embedded_index), filename, output_dir, scratch);
2980 result = scratch.back();
2981 catalog.register_entry(job.desc, result);
2982 consumed_embedded.insert(result.embedded_index);
2983 break;
2984 }
2985 case texture_job_type::external_convert:
2986 {
2987 imported_texture result = job.desc;
2988 fs::path original_file = output_dir / result.name;
2989 const auto converted_name = make_converted_texture_name(result.name, result.semantic);
2990 fs::path converted_file = output_dir / converted_name;
2991 bimg::ImageContainer* image = imageLoad(bx::FilePath(original_file.string().c_str()));
2992 if(image)
2993 {
2994 apply_texture_conversion(image, result.semantic, result.inverse);
2995 atomic_image_save(converted_file, image);
2996 bimg::imageFree(image);
2997 result.name = converted_name;
2998 APPLOG_TRACE("Mesh Importer: Applied {} conversion to external texture: {}", result.semantic, result.name);
2999 }
3000 catalog.register_entry(job.desc, result);
3001 break;
3002 }
3003 case texture_job_type::spec_gloss_pair:
3004 {
3005 bimg::ImageContainer* diffuse_img = load_image_for_texture_desc(scene, output_dir, job.desc);
3006 bimg::ImageContainer* specular_img =
3007 load_image_for_texture_desc(scene, output_dir, job.specular_desc, nullptr);
3008
3009 if(!diffuse_img)
3010 {
3011 APPLOG_WARNING("Mesh Importer: Spec-gloss pair job could not load diffuse texture: {}",
3012 job.desc.name);
3013 }
3014 if(!specular_img)
3015 {
3016 APPLOG_WARNING("Mesh Importer: Spec-gloss pair job could not load specular texture: {}",
3017 job.specular_desc.name);
3018 }
3019
3020 if(diffuse_img && specular_img)
3021 {
3022 auto conv = convert_spec_gloss_to_pbr_textures(output_dir,
3023 job.output_base_color_relative,
3024 job.output_mr_relative,
3025 diffuse_img,
3026 specular_img,
3027 job.spec_gloss_factors,
3028 job.bake_base_color);
3029 if(conv.diffuse_converted)
3030 {
3031 imported_texture base_result = job.desc;
3032 base_result.name = conv.base_color_relative;
3033 catalog.register_entry(job.desc, base_result);
3034 mark_embedded_consumed_index(job.desc.embedded_index, consumed_embedded, catalog);
3035 }
3036 if(!conv.mr_relative.empty())
3037 {
3038 imported_texture mr_lookup = job.specular_desc;
3039 imported_texture mr_result = job.specular_desc;
3040 mr_result.name = conv.mr_relative;
3041 catalog.register_entry(mr_lookup, mr_result);
3042 mark_embedded_consumed_index(job.specular_desc.embedded_index, consumed_embedded, catalog);
3043 APPLOG_TRACE("Mesh Importer: Wrote pair metallic-roughness: {}", conv.mr_relative);
3044 }
3045 else
3046 {
3047 APPLOG_WARNING("Mesh Importer: Spec-gloss pair job produced no metallic-roughness output ({} + {})",
3048 job.desc.name,
3049 job.specular_desc.name);
3050 }
3051 }
3052 if(specular_img)
3053 {
3054 bimg::imageFree(specular_img);
3055 }
3056 if(diffuse_img)
3057 {
3058 bimg::imageFree(diffuse_img);
3059 }
3060 break;
3061 }
3062 default:
3063 break;
3064 }
3065}
3066
3067void execute_texture_job_composite(const std::vector<texture_job>& jobs,
3068 const std::vector<size_t>& job_indices,
3069 const fs::path& filename,
3070 const fs::path& output_dir,
3071 const aiScene* scene,
3072 texture_catalog& catalog,
3073 std::unordered_set<int>& consumed_embedded)
3074{
3075 for(const size_t job_index : job_indices)
3076 {
3077 execute_texture_job(jobs[job_index], filename, output_dir, scene, catalog, consumed_embedded);
3078 }
3079}
3080
3081void run_texture_jobs_parallel(texture_job_store& store,
3082 const fs::path& filename,
3083 const fs::path& output_dir,
3084 const aiScene* scene,
3085 texture_catalog& catalog,
3086 std::unordered_set<int>& consumed_embedded)
3087{
3088 const auto& jobs = store.jobs();
3089 if(jobs.empty())
3090 {
3091 return;
3092 }
3093
3094 const auto composites = build_texture_job_composite_groups(jobs);
3095 APPLOG_TRACE("Mesh Importer: Running {} texture job composites ({} jobs) in parallel",
3096 composites.size(),
3097 jobs.size());
3098
3099 struct composite_result_t
3100 {
3101 texture_catalog catalog;
3102 std::unordered_set<int> consumed_embedded;
3103 };
3104
3105 std::vector<composite_result_t> composite_results(composites.size());
3106
3107 std::vector<size_t> composite_order(composites.size());
3108 std::iota(composite_order.begin(), composite_order.end(), size_t{0});
3109
3110 std::for_each(poolstl::par,
3111 composite_order.begin(),
3112 composite_order.end(),
3113 [&](const size_t composite_index)
3114 {
3115 auto& result = composite_results[composite_index];
3116 execute_texture_job_composite(jobs,
3117 composites[composite_index],
3118 filename,
3119 output_dir,
3120 scene,
3121 result.catalog,
3122 result.consumed_embedded);
3123 });
3124
3125 for(auto& result : composite_results)
3126 {
3127 catalog.merge_from(result.catalog);
3128 consumed_embedded.insert(result.consumed_embedded.begin(), result.consumed_embedded.end());
3129 }
3130}
3131
3132void mark_embedded_consumed_from_textures(const std::vector<imported_texture>& textures,
3133 std::unordered_set<int>& consumed_embedded)
3134{
3135 for(const auto& tex : textures)
3136 {
3137 if(tex.embedded_index >= 0 && tex.process_count > 0)
3138 {
3139 consumed_embedded.insert(tex.embedded_index);
3140 }
3141 }
3142}
3143
3144void collect_orphan_embedded_texture_jobs(const aiScene* scene,
3145 const fs::path& filename,
3146 texture_job_store& store,
3147 const std::unordered_set<int>& consumed_embedded)
3148{
3149 for(size_t i = 0; i < scene->mNumTextures; ++i)
3150 {
3151 if(consumed_embedded.count(static_cast<int>(i)) > 0)
3152 {
3153 continue;
3154 }
3155
3156 imported_texture tex{};
3157 tex.embedded_index = static_cast<int>(i);
3158 tex.semantic = "Texture";
3159 tex.name = get_embedded_texture_name(scene->mTextures[i], i, filename, tex.semantic);
3160
3161 texture_job job{};
3162 job.type = texture_job_type::embedded_extract;
3163 job.desc = tex;
3164 store.try_add(std::move(job));
3165 }
3166}
3167
3171template<typename GetTextureFunc>
3172auto get_workflow_aware_texture(const aiMaterial* material,
3173 material_workflow workflow,
3174 const std::string& target_semantic,
3175 imported_texture& tex,
3176 GetTextureFunc get_imported_texture) -> bool
3177{
3178 if(target_semantic == "BaseColor")
3179 {
3180 // KHR and Phong: extension/legacy albedo lives in DIFFUSE; BASE_COLOR is often MR fallback.
3181 if(workflow == material_workflow::khr_specular_glossiness
3182 || workflow == material_workflow::phong_specular_gloss)
3183 {
3184 if(get_imported_texture(material, aiTextureType_DIFFUSE, 0, "BaseColor", tex))
3185 {
3186 return true;
3187 }
3188 }
3189 else if(get_imported_texture(material, AI_MATKEY_BASE_COLOR_TEXTURE, "BaseColor", tex))
3190 {
3191 return true;
3192 }
3193
3194 if(get_imported_texture(material, aiTextureType_DIFFUSE, 0, "BaseColor", tex))
3195 {
3196 return true;
3197 }
3198 }
3199 else if(target_semantic == "Metallic")
3200 {
3201 // Dual-authored KHR glTF carries MR fallback textures — ignore them when glossiness is present.
3202 if(workflow != material_workflow::khr_specular_glossiness)
3203 {
3204 if(get_imported_texture(material, AI_MATKEY_GLTF_PBRMETALLICROUGHNESS_METALLICROUGHNESS_TEXTURE, "MetallicRoughness", tex))
3205 {
3206 return true;
3207 }
3208 if(get_imported_texture(material, AI_MATKEY_METALLIC_TEXTURE, "Metallic", tex))
3209 {
3210 return true;
3211 }
3212 // FBX exporters often emit combined MR under aiTextureType_UNKNOWN when canonical
3213 // slots are empty. Allow recovery for native MR and unknown workflows, not KHR/Phong.
3214 if(workflow != material_workflow::phong_specular_gloss
3215 && get_imported_texture(material, aiTextureType_UNKNOWN, 0, "MetallicRoughness", tex))
3216 {
3217 APPLOG_TRACE("Mesh Importer: Recovering metallic-roughness texture from aiTextureType_UNKNOWN slot");
3218 return true;
3219 }
3220 if(material_has_packed_mr_in_specular_slot(material)
3221 && get_imported_texture(material, aiTextureType_SPECULAR, 0, "MetallicRoughness", tex))
3222 {
3223 APPLOG_TRACE("Mesh Importer: Using packed metallic-roughness from aiTextureType_SPECULAR");
3224 return true;
3225 }
3226 }
3227 }
3228 else if(target_semantic == "Roughness")
3229 {
3230 if(workflow != material_workflow::khr_specular_glossiness)
3231 {
3232 if(get_imported_texture(material, AI_MATKEY_GLTF_PBRMETALLICROUGHNESS_METALLICROUGHNESS_TEXTURE, "MetallicRoughness", tex))
3233 {
3234 return true;
3235 }
3236 if(get_imported_texture(material, AI_MATKEY_ROUGHNESS_TEXTURE, "Roughness", tex))
3237 {
3238 return true;
3239 }
3240 if(workflow != material_workflow::phong_specular_gloss
3241 && get_imported_texture(material, aiTextureType_UNKNOWN, 0, "MetallicRoughness", tex))
3242 {
3243 APPLOG_TRACE("Mesh Importer: Recovering metallic-roughness texture from aiTextureType_UNKNOWN slot");
3244 return true;
3245 }
3246 if(material_has_packed_mr_in_specular_slot(material)
3247 && get_imported_texture(material, aiTextureType_SPECULAR, 0, "MetallicRoughness", tex))
3248 {
3249 APPLOG_TRACE("Mesh Importer: Using packed metallic-roughness from aiTextureType_SPECULAR");
3250 return true;
3251 }
3252 }
3253
3254 if(workflow == material_workflow::phong_specular_gloss)
3255 {
3256 if(fbx_roughness_texture_is_native_roughness(material)
3257 && get_imported_texture(material, aiTextureType_DIFFUSE_ROUGHNESS, 0, "Roughness", tex))
3258 {
3259 return true;
3260 }
3261 if(get_imported_texture(material, aiTextureType_SHININESS, 0, "ShininessToRoughness", tex))
3262 {
3263 return true;
3264 }
3265 }
3266 }
3267
3268 return false;
3269}
3270
3276void process_material_with_workflow_conversion(const aiMaterial* material,
3277 material_workflow workflow,
3278 aiColor3D& base_color,
3279 float& metallic,
3280 float& roughness)
3281{
3282 bool has_base_color = (material->Get(AI_MATKEY_BASE_COLOR, base_color) == AI_SUCCESS);
3283 bool has_metallic = (material->Get(AI_MATKEY_METALLIC_FACTOR, metallic) == AI_SUCCESS);
3284 bool has_roughness = (material->Get(AI_MATKEY_ROUGHNESS_FACTOR, roughness) == AI_SUCCESS);
3285
3286 // KHR spec-gloss: derive MR (+ optional base color) from diffuse/specular/gloss factors when textures are absent.
3287 if(workflow == material_workflow::khr_specular_glossiness)
3288 {
3289 aiColor3D diffuse_color{1.0f, 1.0f, 1.0f};
3290 material->Get(AI_MATKEY_COLOR_DIFFUSE, diffuse_color);
3291
3292 aiColor3D specular_color{0.04f, 0.04f, 0.04f};
3293 float specular_factor = 1.0f;
3294 material->Get(AI_MATKEY_COLOR_SPECULAR, specular_color);
3295 material->Get(AI_MATKEY_SPECULAR_FACTOR, specular_factor);
3296 specular_color.r *= specular_factor;
3297 specular_color.g *= specular_factor;
3298 specular_color.b *= specular_factor;
3299
3300 float glossiness = 0.5f;
3301 if(material->Get(AI_MATKEY_GLOSSINESS_FACTOR, glossiness) != AI_SUCCESS)
3302 {
3303 float shininess = 32.0f;
3304 if(material->Get(AI_MATKEY_SHININESS, shininess) == AI_SUCCESS)
3305 {
3306 glossiness = math::clamp(1.0f - std::sqrt(2.0f / (shininess + 2.0f)), 0.0f, 1.0f);
3307 }
3308 }
3309
3310 auto [converted_base_color, converted_metallic, converted_roughness] =
3311 convert_specular_gloss_to_metallic_roughness(diffuse_color, specular_color, glossiness);
3312
3313 if(!has_base_color)
3314 {
3315 base_color = converted_base_color;
3316 APPLOG_TRACE("Mesh Importer: Converted base color from specular/diffuse workflow");
3317 }
3318 metallic = converted_metallic;
3319 roughness = converted_roughness;
3320 APPLOG_TRACE("Mesh Importer: Converted PBR factors from KHR spec/gloss: metallic={:.3f}, roughness={:.3f}",
3321 metallic, roughness);
3322 }
3323 else if(workflow == material_workflow::phong_specular_gloss)
3324 {
3325 // Albedo tint is COLOR_DIFFUSE; BASE_COLOR is often a white MR fallback on dual-authored assets.
3326 aiColor3D diffuse_tint{1.0f, 1.0f, 1.0f};
3327 if(material->Get(AI_MATKEY_COLOR_DIFFUSE, diffuse_tint) != AI_SUCCESS)
3328 {
3329 diffuse_tint = aiColor3D{1.0f, 1.0f, 1.0f};
3330 }
3331 base_color = diffuse_tint;
3332
3333 metallic = 0.0f;
3334 float shininess = 32.0f;
3335 if(material->Get(AI_MATKEY_SHININESS, shininess) == AI_SUCCESS)
3336 {
3337 roughness = phong_shininess_exponent_to_roughness(shininess);
3338 }
3339 else
3340 {
3341 roughness = 0.5f;
3342 }
3343 APPLOG_TRACE("Mesh Importer: Phong -> MR factors: metallic={:.3f}, roughness={:.3f}",
3344 metallic, roughness);
3345 }
3346 else if(workflow == material_workflow::metallic_roughness)
3347 {
3348 if(!has_base_color)
3349 {
3350 if(material->Get(AI_MATKEY_COLOR_DIFFUSE, base_color) != AI_SUCCESS)
3351 {
3352 base_color = aiColor3D{1.0f, 1.0f, 1.0f};
3353 }
3354 }
3355
3356 if(!has_metallic)
3357 {
3358 metallic = 0.0f;
3359 }
3360
3361 if(!has_roughness)
3362 {
3363 float shininess = 32.0f;
3364 if(material->Get(AI_MATKEY_SHININESS, shininess) == AI_SUCCESS)
3365 {
3366 roughness = std::sqrt(2.0f / (shininess + 2.0f));
3367 }
3368 else
3369 {
3370 roughness = 0.5f;
3371 }
3372 }
3373 }
3374 else
3375 {
3376 if(!has_base_color)
3377 {
3378 if(material->Get(AI_MATKEY_COLOR_DIFFUSE, base_color) != AI_SUCCESS)
3379 {
3380 base_color = aiColor3D{1.0f, 1.0f, 1.0f};
3381 }
3382 }
3383
3384 metallic = 0.0f;
3385 roughness = 0.5f;
3386 }
3387
3388 APPLOG_TRACE("Mesh Importer: Final PBR values - BaseColor: ({:.3f}, {:.3f}, {:.3f}), "
3389 "Metallic: {:.3f}, Roughness: {:.3f} [{}{}{}]",
3390 base_color.r, base_color.g, base_color.b, metallic, roughness,
3391 has_base_color ? "B" : "b",
3392 has_metallic ? "M" : "m",
3393 has_roughness ? "R" : "r");
3394}
3395
3396auto is_material_two_sided(const aiMaterial* material) -> bool
3397{
3398 if(!material)
3399 {
3400 return false;
3401 }
3402
3403 // Assimp stores TWOSIDED as bool/int depending on the importer; the bool
3404 // Get() specialization handles all of those. ai_real does not.
3405 bool two_sided = false;
3406 if(material->Get(AI_MATKEY_TWOSIDED, two_sided) == AI_SUCCESS && two_sided)
3407 {
3408 return true;
3409 }
3410
3411 // FBX (and some Unity exports) encode double-sided in the material name but
3412 // never set AI_MATKEY_TWOSIDED — Assimp's FBX converter ignores Model::Culling.
3413 aiString mat_name{};
3414 if(material->Get(AI_MATKEY_NAME, mat_name) == AI_SUCCESS && mat_name.length > 0)
3415 {
3416 const std::string name = string_utils::to_lower(mat_name.C_Str());
3417 static constexpr std::array<const char*, 6> markers = {
3418 "doublesided",
3419 "double-sided",
3420 "double sided",
3421 "twosided",
3422 "two-sided",
3423 "two sided",
3424 };
3425 for(const char* marker : markers)
3426 {
3427 if(name.find(marker) != std::string::npos)
3428 {
3429 return true;
3430 }
3431 }
3432 }
3433
3434 return false;
3435}
3436
3437constexpr float k_import_opaque_opacity_threshold = 0.999f;
3438
3439auto material_opacity_factor_suggests_cutout(const aiMaterial* material, ai_real& out_opacity) -> bool
3440{
3441 out_opacity = 1.0f;
3442 return material
3443 && material->Get(AI_MATKEY_OPACITY, out_opacity) == AI_SUCCESS
3444 && out_opacity < k_import_opaque_opacity_threshold;
3445}
3446
3447auto resolve_import_alpha_cutoff(const aiMaterial* material, ai_real fallback = 0.5f) -> ai_real
3448{
3449 ai_real cutoff = fallback;
3450 if(material && material->Get(AI_MATKEY_GLTF_ALPHACUTOFF, cutoff) == AI_SUCCESS && cutoff > 0.0f)
3451 {
3452 return math::clamp(cutoff, 0.0f, 1.0f);
3453 }
3454 return fallback;
3455}
3456
3457constexpr float k_import_border_alpha_opaque_threshold = 0.95f;
3458
3459auto compressed_texture_format_has_alpha(bimg::TextureFormat::Enum format) -> bool
3460{
3461 switch(format)
3462 {
3463 case bimg::TextureFormat::BC2: // DXT3 — explicit alpha
3464 case bimg::TextureFormat::BC3: // DXT5 — interpolated alpha
3465 case bimg::TextureFormat::BC7:
3466 case bimg::TextureFormat::ETC2A:
3467 case bimg::TextureFormat::ETC2A1:
3468 case bimg::TextureFormat::PTC12A:
3469 case bimg::TextureFormat::PTC14A:
3470 case bimg::TextureFormat::ATCE:
3471 case bimg::TextureFormat::ATCI:
3472 return true;
3473 default:
3474 break;
3475 }
3476
3477 if(format >= bimg::TextureFormat::ASTC4x4 && format <= bimg::TextureFormat::ASTC12x12)
3478 {
3479 return true;
3480 }
3481
3482 return false;
3483}
3484
3485auto texture_format_has_alpha(bimg::TextureFormat::Enum format, bool parser_reported_alpha) -> bool
3486{
3487 if(parser_reported_alpha)
3488 {
3489 return true;
3490 }
3491
3492 if(!bimg::isValid(format))
3493 {
3494 return false;
3495 }
3496
3497 if(bimg::getBlockInfo(format).aBits > 0)
3498 {
3499 return true;
3500 }
3501
3502 // bimg block info has aBits=0 for block-compressed formats; DDS DXT5/BC3 also omits
3503 // DDPF_ALPHAPIXELS so m_hasAlpha stays false even though the block encoding has alpha.
3504 if(bimg::isCompressed(format) && compressed_texture_format_has_alpha(format))
3505 {
3506 return true;
3507 }
3508
3509 return false;
3510}
3511
3512auto image_mip_border_has_transparency(const bimg::ImageMip& mip,
3513 bimg::TextureFormat::Enum format,
3514 float opaque_threshold) -> bool
3515{
3516 if(mip.m_width == 0 || mip.m_height == 0 || !mip.m_data)
3517 {
3518 return false;
3519 }
3520
3521 const bimg::UnpackFn unpack = bimg::getUnpack(format);
3522 if(!unpack)
3523 {
3524 return false;
3525 }
3526
3527 const uint32_t bpp = bimg::getBitsPerPixel(format);
3528 if(bpp == 0 || (bpp % 8) != 0)
3529 {
3530 return false;
3531 }
3532
3533 const uint32_t bytes_per_pixel = bpp / 8;
3534 const uint32_t width = mip.m_width;
3535 const uint32_t height = mip.m_height;
3536 const uint32_t row_stride = width * bytes_per_pixel;
3537
3538 auto alpha_below_threshold = [&](uint32_t x, uint32_t y) -> bool
3539 {
3540 const uint8_t* pixel = mip.m_data + (static_cast<size_t>(y) * row_stride + x * bytes_per_pixel);
3541 float rgba[4];
3542 unpack(rgba, pixel);
3543 return rgba[3] < opaque_threshold;
3544 };
3545
3546 for(uint32_t x = 0; x < width; ++x)
3547 {
3548 if(alpha_below_threshold(x, 0) || alpha_below_threshold(x, height - 1))
3549 {
3550 return true;
3551 }
3552 }
3553
3554 for(uint32_t y = 1; y + 1 < height; ++y)
3555 {
3556 if(alpha_below_threshold(0, y) || alpha_below_threshold(width - 1, y))
3557 {
3558 return true;
3559 }
3560 }
3561
3562 return false;
3563}
3564
3565auto image_border_has_transparency(const bimg::ImageContainer& image, float opaque_threshold) -> bool
3566{
3567 if(image.m_width == 0 || image.m_height == 0 || !image.m_data)
3568 {
3569 return false;
3570 }
3571
3572 if(!texture_format_has_alpha(image.m_format, image.m_hasAlpha))
3573 {
3574 return false;
3575 }
3576
3577 bimg::ImageMip mip{};
3578 if(!bimg::imageGetRawData(image, 0, 0, image.m_data, image.m_size, mip))
3579 {
3580 return false;
3581 }
3582
3583 if(!bimg::isCompressed(image.m_format) && bimg::getUnpack(image.m_format) != nullptr)
3584 {
3585 return image_mip_border_has_transparency(mip, image.m_format, opaque_threshold);
3586 }
3587
3588 if(bimg::isCompressed(image.m_format))
3589 {
3590 const uint32_t width = mip.m_width;
3591 const uint32_t height = mip.m_height;
3592 if(width == 0 || height == 0)
3593 {
3594 return false;
3595 }
3596
3597 std::vector<uint8_t> decoded(static_cast<size_t>(width) * height * 4);
3598 bimg::imageDecodeToRgba8(get_bimg_allocator(),
3599 decoded.data(),
3600 mip.m_data,
3601 width,
3602 height,
3603 width * 4,
3604 image.m_format);
3605
3606 bimg::ImageMip decoded_mip{};
3607 decoded_mip.m_format = bimg::TextureFormat::RGBA8;
3608 decoded_mip.m_width = width;
3609 decoded_mip.m_height = height;
3610 decoded_mip.m_depth = 1;
3611 decoded_mip.m_bpp = 32;
3612 decoded_mip.m_hasAlpha = true;
3613 decoded_mip.m_data = decoded.data();
3614
3615 return image_mip_border_has_transparency(decoded_mip, bimg::TextureFormat::RGBA8, opaque_threshold);
3616 }
3617
3618 bimg::ImageContainer* converted =
3619 bimg::imageConvert(get_bimg_allocator(), bimg::TextureFormat::RGBA8, image, false);
3620 if(!converted)
3621 {
3622 return false;
3623 }
3624
3625 const bool suggests_cutout = image_border_has_transparency(*converted, opaque_threshold);
3626 bimg::imageFree(converted);
3627 return suggests_cutout;
3628}
3629
3630auto color_map_border_suggests_alpha_cutout(const fs::path& output_dir, const std::string& relative) -> bool
3631{
3632 if(relative.empty() || !texture_file_exists(output_dir, relative))
3633 {
3634 return false;
3635 }
3636
3637 const fs::path filepath =
3638 output_dir / resolve_external_texture_path(output_dir, normalize_assimp_path(relative));
3639 const bx::FilePath bimg_path(filepath.string().c_str());
3640
3641 bimg::ImageContainer header{};
3642 if(imageParseInfo(bimg_path, header)
3643 && !texture_format_has_alpha(header.m_format, header.m_hasAlpha))
3644 {
3645 APPLOG_TRACE("Mesh Importer: Texture format does not have alpha: {}", relative);
3646 return false;
3647 }
3648
3649 bimg::ImageContainer* loaded = imageLoad(bimg_path);
3650 if(!loaded)
3651 {
3652 APPLOG_TRACE("Mesh Importer: Failed to load image: {}", relative);
3653 return false;
3654 }
3655
3656 const bool suggests_cutout = image_border_has_transparency(*loaded, k_import_border_alpha_opaque_threshold);
3657 APPLOG_TRACE("Mesh Importer: Probing base color map border for transparency: {}", suggests_cutout);
3658 bimg::imageFree(loaded);
3659 return suggests_cutout;
3660}
3661
3662void process_material(asset_manager& am,
3663 const fs::path& filename,
3664 const fs::path& output_dir,
3665 const aiScene* scene,
3666 const aiMaterial* material,
3667 pbr_material& mat,
3668 std::vector<imported_texture>& textures,
3669 material_import_env& env)
3670{
3671 if(!material)
3672 {
3673 return;
3674 }
3675
3676 const bool collecting = (env.phase == material_import_env::phase_t::collect);
3677 const bool binding = (env.phase == material_import_env::phase_t::bind);
3678
3679 // Diagnostic: enumerate every texture slot Assimp populated on this material.
3680 // Crucial for triaging "why isn't this texture assigned" cases — different exporters
3681 // (Blender, Maya, 3DSMax, glTF) park PBR textures under wildly different aiTextureType
3682 // values, and a single look at this log makes the routing obvious.
3683 {
3684 struct slot_info
3685 {
3686 aiTextureType type;
3687 const char* name;
3688 };
3689 static constexpr std::array<slot_info, 21> slot_table = {{
3690 {aiTextureType_DIFFUSE, "DIFFUSE"},
3691 {aiTextureType_SPECULAR, "SPECULAR"},
3692 {aiTextureType_AMBIENT, "AMBIENT"},
3693 {aiTextureType_EMISSIVE, "EMISSIVE"},
3694 {aiTextureType_HEIGHT, "HEIGHT"},
3695 {aiTextureType_NORMALS, "NORMALS"},
3696 {aiTextureType_SHININESS, "SHININESS"},
3697 {aiTextureType_OPACITY, "OPACITY"},
3698 {aiTextureType_DISPLACEMENT, "DISPLACEMENT"},
3699 {aiTextureType_LIGHTMAP, "LIGHTMAP"},
3700 {aiTextureType_REFLECTION, "REFLECTION"},
3701 {aiTextureType_BASE_COLOR, "BASE_COLOR"},
3702 {aiTextureType_NORMAL_CAMERA, "NORMAL_CAMERA"},
3703 {aiTextureType_EMISSION_COLOR, "EMISSION_COLOR"},
3704 {aiTextureType_METALNESS, "METALNESS"},
3705 {aiTextureType_DIFFUSE_ROUGHNESS, "DIFFUSE_ROUGHNESS"},
3706 {aiTextureType_AMBIENT_OCCLUSION, "AMBIENT_OCCLUSION"},
3707 {aiTextureType_SHEEN, "SHEEN"},
3708 {aiTextureType_CLEARCOAT, "CLEARCOAT"},
3709 {aiTextureType_TRANSMISSION, "TRANSMISSION"},
3710 {aiTextureType_UNKNOWN, "UNKNOWN"},
3711 }};
3712
3713 std::string slot_log;
3714 for(const auto& slot : slot_table)
3715 {
3716 auto count = material->GetTextureCount(slot.type);
3717 if(count == 0)
3718 {
3719 continue;
3720 }
3721 for(unsigned int i = 0; i < count; ++i)
3722 {
3723 aiString path{};
3724 if(material->GetTexture(slot.type, i, &path) == AI_SUCCESS && path.length > 0)
3725 {
3726 if(!slot_log.empty())
3727 {
3728 slot_log += ", ";
3729 }
3730 slot_log += fmt::format("{}[{}]={}", slot.name, i, normalize_assimp_path(path.C_Str()).generic_string());
3731 }
3732 }
3733 }
3734 aiString mat_name{};
3735 material->Get(AI_MATKEY_NAME, mat_name);
3736 APPLOG_TRACE("Mesh Importer: Material '{}' texture slots: {}",
3737 mat_name.length > 0 ? mat_name.C_Str() : "<unnamed>",
3738 slot_log.empty() ? "<none>" : slot_log);
3739 }
3740
3741 // Detect the material workflow before processing
3742 auto workflow = detect_material_workflow(material);
3743
3744 APPLOG_TRACE("Mesh Importer: Material workflow detected: {}", material_workflow_label(workflow));
3745
3746 // log_materials(material);
3747
3748 auto get_imported_texture = [&](const aiMaterial* material,
3749 aiTextureType type,
3750 unsigned int index,
3751 const std::string& semantic,
3752 imported_texture& tex) -> bool
3753 {
3754 aiString path{};
3755 aiTextureMapping mapping{};
3756 unsigned int uvindex{};
3757 float blend{};
3758 aiTextureOp op{};
3759 aiTextureMapMode mapmode{};
3760 unsigned int flags{};
3761
3762 // Call the function
3763 aiReturn result = aiGetMaterialTexture(material, // The material pointer
3764 type, // The type of texture (e.g., diffuse)
3765 index, // The texture index
3766 &path // The path where the texture file path will be stored
3767 // &mapping, // The mapping method
3768 // &uvindex, // The UV index
3769 // &blend, // The blend factor
3770 // &op, // The texture operation
3771 // &mapmode, // The texture map mode
3772 // &flags // Additional flags
3773 );
3774
3775 if(path.length > 0)
3776 {
3777 auto tex_pair = scene->GetEmbeddedTextureAndIndex(path.C_Str());
3778
3779 const auto embedded_texture = tex_pair.first;
3780 if(embedded_texture)
3781 {
3782 const auto index = tex_pair.second;
3783
3784 // std::string s = aiTextureTypeToString(type);
3785 tex.name = get_embedded_texture_name(embedded_texture, index, filename, semantic);
3786 tex.embedded_index = index;
3787 }
3788 else
3789 {
3790 const fs::path assimp_path = normalize_assimp_path(path.C_Str());
3791 tex.name = assimp_path.generic_string();
3792
3793 const auto extension = assimp_path.extension().string();
3794 const auto texture_dir = assimp_path.parent_path();
3795 const auto texture_filename = assimp_path.filename().stem().string();
3796 const auto fixed_name = string_utils::replace(texture_filename, ".", "_");
3797 if(fixed_name != texture_filename)
3798 {
3799 fs::path fixed_relative = texture_dir / (fixed_name + extension);
3800 fs::path old_filepath = output_dir / assimp_path;
3801 fs::path fixed_filepath = output_dir / fixed_relative;
3802
3803 fs::error_code ec;
3804 if(fs::exists(old_filepath, ec))
3805 {
3806 asset_writer::atomic_rename_file(old_filepath, fixed_filepath, ec);
3807 }
3808 else
3809 {
3810 old_filepath = output_dir / resolve_external_texture_path(output_dir, assimp_path);
3811 fixed_relative = resolve_external_texture_path(output_dir, fixed_relative);
3812 fixed_filepath = output_dir / fixed_relative;
3813 if(fs::exists(old_filepath, ec))
3814 {
3815 asset_writer::atomic_copy_file(old_filepath, fixed_filepath, ec);
3816 }
3817 }
3818 tex.name = fixed_relative.generic_string();
3819 }
3820 tex.name = resolve_external_texture_path(output_dir, assimp_path).generic_string();
3821
3822 if(!texture_file_exists(output_dir, tex.name))
3823 {
3824 APPLOG_WARNING("Mesh Importer: External texture '{}' not found on disk — skipping '{}'",
3825 assimp_path.generic_string(),
3826 semantic);
3827 return false;
3828 }
3829 }
3830 tex.semantic = semantic;
3831 bool use_alpha = flags & aiTextureFlags_UseAlpha;
3832 bool ignore_alpha = flags & aiTextureFlags_IgnoreAlpha;
3833 bool invert = flags & aiTextureFlags_Invert;
3834 tex.inverse = invert;
3835
3836 switch(mapmode)
3837 {
3838 case aiTextureMapMode_Mirror:
3839 tex.flags = BGFX_SAMPLER_UVW_MIRROR;
3840 break;
3841 case aiTextureMapMode_Clamp:
3842 tex.flags = BGFX_SAMPLER_UVW_CLAMP;
3843 break;
3844 case aiTextureMapMode_Decal:
3845 tex.flags = BGFX_SAMPLER_UVW_BORDER;
3846 break;
3847 default:
3848 break;
3849 }
3850
3851 return true;
3852 }
3853
3854 return false;
3855 };
3856
3857 auto enqueue_simple_texture_job = [&](const imported_texture& texture)
3858 {
3859 if(!env.job_store)
3860 {
3861 return;
3862 }
3863 texture_job job{};
3864 job.desc = texture;
3865 if(texture.embedded_index >= 0)
3866 {
3867 job.type = texture_job_type::embedded_extract;
3868 }
3869 else if(needs_external_texture_conversion(texture))
3870 {
3871 job.type = texture_job_type::external_convert;
3872 }
3873 else
3874 {
3875 return;
3876 }
3877 env.job_store->try_add(std::move(job));
3878 };
3879
3880 auto process_texture = [&](imported_texture& texture, std::vector<imported_texture>& textures_vec, bool /*force_process*/ = false)
3881 {
3882 if(collecting)
3883 {
3884 enqueue_simple_texture_job(texture);
3885 if(texture.embedded_index < 0 && !needs_external_texture_conversion(texture) && env.catalog
3886 && texture_file_exists(*env.output_dir, texture.name))
3887 {
3888 env.catalog->register_entry(texture, texture);
3889 }
3890 return;
3891 }
3892
3893 if(binding && env.catalog && env.catalog->resolve(texture))
3894 {
3895 return;
3896 }
3897
3898 if(texture.embedded_index >= 0)
3899 {
3900 auto it = std::find_if(std::begin(textures_vec),
3901 std::end(textures_vec),
3902 [&](const imported_texture& rhs)
3903 {
3904 return rhs.embedded_index == texture.embedded_index
3905 && rhs.semantic == texture.semantic;
3906 });
3907 if(it != std::end(textures_vec))
3908 {
3909 texture.name = it->name;
3910 texture.flags = it->flags;
3911 texture.inverse = it->inverse;
3912 texture.process_count = it->process_count;
3913 return;
3914 }
3915 }
3916 else
3917 {
3918 auto it = std::find_if(std::begin(textures_vec),
3919 std::end(textures_vec),
3920 [&](const imported_texture& rhs)
3921 {
3922 return rhs.embedded_index < 0 && rhs.name == texture.name
3923 && rhs.semantic == texture.semantic;
3924 });
3925 if(it != std::end(textures_vec))
3926 {
3927 if(needs_external_texture_conversion(texture))
3928 {
3929 texture.name = make_converted_texture_name(texture.name, texture.semantic);
3930 }
3931 return;
3932 }
3933 }
3934
3935 textures_vec.emplace_back(texture);
3936
3937 if(texture.embedded_index >= 0)
3938 {
3939 const auto& embedded_texture = scene->mTextures[texture.embedded_index];
3940 process_embedded_texture(embedded_texture, texture.embedded_index, filename, output_dir, textures_vec);
3941 }
3942 else if(needs_external_texture_conversion(texture))
3943 {
3944 fs::path original_file = output_dir / texture.name;
3945 const auto converted_name = make_converted_texture_name(texture.name, texture.semantic);
3946 fs::path converted_file = output_dir / converted_name;
3947 bimg::ImageContainer* image = imageLoad(bx::FilePath(original_file.string().c_str()));
3948 if(image)
3949 {
3950 apply_texture_conversion(image, texture.semantic, texture.inverse);
3951 atomic_image_save(converted_file, image);
3952 bimg::imageFree(image);
3953 texture.name = converted_name;
3954 APPLOG_TRACE("Mesh Importer: Applied {} conversion to external texture: {}", texture.semantic, texture.name);
3955 }
3956 }
3957 };
3958
3959 if(binding && is_material_two_sided(material))
3960 {
3961 mat.set_cull_type(cull_type::none);
3962 }
3963
3964 // KHR spec-gloss: diffuse + specular pair -> Khronos bake (base color + MR).
3965 std::string combined_mr_relative;
3966 std::string base_color_map_relative;
3967
3968 bool khr_textures_baked = false;
3969 bool spec_gloss_mr_baked = false;
3970
3971 // BASE COLOR TEXTURE - Use workflow-aware detection
3972 {
3973 imported_texture texture;
3974 if(get_workflow_aware_texture(material, workflow, "BaseColor", texture, get_imported_texture))
3975 {
3976 if(workflow == material_workflow::khr_specular_glossiness)
3977 {
3978 aiString specular_path{};
3979 bool has_specular = (material->GetTexture(aiTextureType_SPECULAR, 0, &specular_path) == AI_SUCCESS)
3980 && specular_path.length > 0;
3981
3982 const spec_gloss_factors_t factors = gather_spec_gloss_factors(material);
3983 const bool bake_base_color =
3984 should_reconstruct_base_color_for_spec_gloss_pair(workflow, material);
3985
3986 imported_texture pair_albedo{};
3987 const bool has_pair_albedo =
3988 get_imported_texture(material,
3989 aiTextureType_DIFFUSE,
3990 0,
3991 bake_base_color ? "Diffuse" : "BaseColor",
3992 pair_albedo);
3993 const imported_texture& pair_source = has_pair_albedo ? pair_albedo : texture;
3994
3995 imported_texture specular_tex{};
3996 if(has_specular
3997 && !get_imported_texture(material, aiTextureType_SPECULAR, 0, "Specular", specular_tex))
3998 {
3999 APPLOG_WARNING("Mesh Importer: Material has SPECULAR slot but texture path could not be resolved");
4000 has_specular = false;
4001 }
4002
4003 if(has_specular)
4004 {
4005 if(bake_base_color)
4006 {
4007 APPLOG_TRACE("Mesh Importer: {} - extension diffuse + specular pair -> PBR bake",
4008 material_workflow_label(workflow));
4009 }
4010 else
4011 {
4012 APPLOG_TRACE("Mesh Importer: {} - diffuse pass-through, specular pair -> MR only",
4013 material_workflow_label(workflow));
4014 }
4015
4016 if(collecting && env.job_store)
4017 {
4018 texture_job job{};
4019 job.type = texture_job_type::spec_gloss_pair;
4020 job.desc = pair_source;
4021 job.specular_desc = specular_tex;
4022 job.spec_gloss_factors = factors;
4023 job.bake_base_color = bake_base_color;
4024 job.output_base_color_relative = build_converted_texture_name(filename,
4025 scene,
4026 pair_source.embedded_index,
4027 pair_source.name,
4028 "BaseColor");
4029 job.output_mr_relative = build_converted_texture_name(filename,
4030 scene,
4031 specular_tex.embedded_index,
4032 specular_tex.name,
4033 "MetallicRoughness");
4034 env.job_store->try_add(std::move(job));
4035 }
4036 else if(binding && env.catalog)
4037 {
4038 const std::string expected_base = build_converted_texture_name(filename,
4039 scene,
4040 pair_source.embedded_index,
4041 pair_source.name,
4042 "BaseColor");
4043 const std::string expected_mr = build_converted_texture_name(filename,
4044 scene,
4045 specular_tex.embedded_index,
4046 specular_tex.name,
4047 "MetallicRoughness");
4048
4049 if(bake_base_color && env.catalog->has_output_for_lookup(pair_source, expected_base))
4050 {
4051 texture.name = expected_base;
4052 khr_textures_baked = true;
4053 APPLOG_TRACE("Mesh Importer: Wrote converted base color (factors baked: D[{:.2f},{:.2f},{:.2f},{:.2f}] S[{:.2f},{:.2f},{:.2f}] G[{:.2f}]): {}",
4054 factors.diffuse_r,
4055 factors.diffuse_g,
4056 factors.diffuse_b,
4057 factors.diffuse_a,
4058 factors.specular_r,
4059 factors.specular_g,
4060 factors.specular_b,
4061 factors.glossiness,
4062 texture.name);
4063 }
4064
4065 const std::string found_mr =
4066 find_spec_gloss_mr_relative(env.catalog, output_dir, specular_tex, expected_mr);
4067 if(!found_mr.empty())
4068 {
4069 combined_mr_relative = found_mr;
4070 spec_gloss_mr_baked = true;
4071 if(found_mr == expected_mr)
4072 {
4073 APPLOG_TRACE("Mesh Importer: Using pair metallic-roughness: {}", combined_mr_relative);
4074 }
4075 else
4076 {
4077 APPLOG_TRACE("Mesh Importer: Using catalog metallic-roughness: {} (expected {})",
4078 combined_mr_relative,
4079 expected_mr);
4080 }
4081 }
4082 else
4083 {
4084 const std::string synced_mr = try_synchronous_spec_gloss_pair_mr(output_dir,
4085 scene,
4086 pair_source,
4087 specular_tex,
4088 factors,
4089 expected_mr,
4091 if(!synced_mr.empty())
4092 {
4093 combined_mr_relative = synced_mr;
4094 spec_gloss_mr_baked = true;
4095 imported_texture mr_result = specular_tex;
4096 mr_result.name = synced_mr;
4097 env.catalog->register_entry(specular_tex, mr_result);
4098 }
4099 else
4100 {
4101 APPLOG_WARNING("Mesh Importer: Pair metallic-roughness not found (expected {}) and bind-time bake failed",
4102 expected_mr);
4103 }
4104 }
4105
4106 if(!khr_textures_baked)
4107 {
4108 process_texture(texture, textures);
4109 }
4110 }
4111 else
4112 {
4113 process_texture(texture, textures);
4114 }
4115 }
4116 else
4117 {
4118 process_texture(texture, textures);
4119 }
4120 }
4121 else
4122 {
4123 process_texture(texture, textures);
4124 }
4125
4126 base_color_map_relative = texture.name;
4127
4128 if(binding)
4129 {
4130 if(const auto key = try_make_texture_asset_key(output_dir, texture.name))
4131 {
4132 mat.set_color_map(am.get_asset<gfx::texture>(*key));
4133 }
4134 else
4135 {
4136 APPLOG_WARNING("Mesh Importer: Could not bind base color texture '{}'", texture.name);
4137 }
4138 }
4139 }
4140 }
4141 // BASE COLOR PROPERTY - Use workflow-aware conversion
4142 {
4143 aiColor3D base_color_property{1.0f, 1.0f, 1.0f};
4144 float metallic_property = 0.0f;
4145 float roughness_property = 0.5f;
4146
4147 if(khr_textures_baked)
4148 {
4149 // KHR pair bake baked diffuse/specular/gloss into the base-color and MR textures.
4150 base_color_property = {1.0f, 1.0f, 1.0f};
4151 metallic_property = 1.0f;
4152 roughness_property = 1.0f;
4153 }
4154 else
4155 {
4156 process_material_with_workflow_conversion(material, workflow,
4157 base_color_property,
4158 metallic_property,
4159 roughness_property);
4160 if(spec_gloss_mr_baked)
4161 {
4162 // MR pair bake baked specular/gloss into the texture; diffuse tint stays on uniforms.
4163 metallic_property = 1.0f;
4164 roughness_property = 1.0f;
4165 APPLOG_TRACE("Mesh Importer: MR texture drives shading — uniform multipliers: metallic={:.3f}, roughness={:.3f}",
4166 metallic_property,
4167 roughness_property);
4168 }
4169 }
4170
4171 math::color base_color{};
4172 base_color = {base_color_property.r, base_color_property.g, base_color_property.b};
4173 base_color = math::clamp(base_color.value, 0.0f, 1.0f);
4174 mat.set_base_color(base_color);
4175
4176 mat.set_metalness(math::clamp(metallic_property, 0.0f, 1.0f));
4177 mat.set_roughness(math::clamp(roughness_property, 0.0f, 1.0f));
4178 }
4179
4180 // METALLIC & ROUGHNESS TEXTURES
4181 const bool khr_combined_specular_mr = khr_needs_combined_specular_mr(material, workflow);
4182 bool has_metallic_tex = false;
4183 bool has_roughness_tex = false;
4184
4185 if(!combined_mr_relative.empty())
4186 {
4187 if(binding)
4188 {
4189 if(const auto key = try_make_texture_asset_key(output_dir, combined_mr_relative))
4190 {
4191 auto texture_asset = am.get_asset<gfx::texture>(*key);
4192
4193 mat.set_metalness_map(texture_asset);
4194 mat.set_roughness_map(texture_asset);
4195 has_metallic_tex = true;
4196 has_roughness_tex = true;
4197
4198 APPLOG_TRACE("Mesh Importer: Using sibling metallic-roughness map from spec-gloss conversion: {}",
4199 combined_mr_relative);
4200 }
4201 else
4202 {
4203 APPLOG_WARNING("Mesh Importer: Could not bind metallic-roughness texture '{}'", combined_mr_relative);
4204 }
4205 }
4206 }
4207 else if(khr_combined_specular_mr && combined_mr_relative.empty())
4208 {
4209 imported_texture combined_texture;
4210 if(get_imported_texture(material, aiTextureType_SPECULAR, 0, "SpecularToMetallicRoughness", combined_texture))
4211 {
4212 process_texture(combined_texture, textures);
4213
4214 if(binding)
4215 {
4216 if(const auto key = try_make_texture_asset_key(output_dir, combined_texture.name))
4217 {
4218 auto texture_asset = am.get_asset<gfx::texture>(*key);
4219
4220 mat.set_metalness_map(texture_asset);
4221 mat.set_roughness_map(texture_asset);
4222 has_metallic_tex = true;
4223 has_roughness_tex = true;
4224
4225 APPLOG_TRACE("Mesh Importer: Converting single specular texture to combined metallic/roughness: {}",
4226 combined_texture.name);
4227 }
4228 else
4229 {
4230 APPLOG_WARNING("Mesh Importer: Could not bind specular-to-MR texture '{}'",
4231 combined_texture.name);
4232 }
4233 }
4234 }
4235 }
4236 else
4237 {
4238 {
4239 imported_texture texture;
4240 if(get_workflow_aware_texture(material, workflow, "Metallic", texture, get_imported_texture))
4241 {
4242 process_texture(texture, textures);
4243
4244 if(binding)
4245 {
4246 if(const auto key = try_make_texture_asset_key(output_dir, texture.name))
4247 {
4248 mat.set_metalness_map(am.get_asset<gfx::texture>(*key));
4249 has_metallic_tex = true;
4250 }
4251 else
4252 {
4253 APPLOG_WARNING("Mesh Importer: Could not bind metallic texture '{}'", texture.name);
4254 }
4255 }
4256 }
4257 }
4258
4259 {
4260 imported_texture texture;
4261 if(get_workflow_aware_texture(material, workflow, "Roughness", texture, get_imported_texture))
4262 {
4263 process_texture(texture, textures);
4264
4265 if(binding)
4266 {
4267 if(const auto key = try_make_texture_asset_key(output_dir, texture.name))
4268 {
4269 mat.set_roughness_map(am.get_asset<gfx::texture>(*key));
4270 has_roughness_tex = true;
4271
4272 if(texture.semantic == "ShininessToRoughness")
4273 {
4274 APPLOG_TRACE("Mesh Importer: Converting shininess texture to roughness: {}", texture.name);
4275 }
4276 }
4277 else
4278 {
4279 APPLOG_WARNING("Mesh Importer: Could not bind roughness texture '{}'", texture.name);
4280 }
4281 }
4282 }
4283 }
4284 }
4285
4286 // Combined MR textures store per-pixel metal/rough; the scalar uniform is only a multiplier.
4287 const bool packed_mr_specular = material_has_packed_mr_in_specular_slot(material);
4288 if(has_metallic_tex && has_roughness_tex
4289 && (khr_textures_baked || spec_gloss_mr_baked || workflow == material_workflow::khr_specular_glossiness
4290 || packed_mr_specular))
4291 {
4292 mat.set_metalness(1.0f);
4293 mat.set_roughness(1.0f);
4294 }
4295
4296 // NORMAL TEXTURE
4297 aiTextureType normals_type = aiTextureType_NORMALS;
4298 {
4299 static const std::string semantic = "Normals";
4300
4301 imported_texture texture;
4302 bool has_texture = false;
4303
4304 if(!has_texture)
4305 {
4306 has_texture |= get_imported_texture(material, aiTextureType_NORMALS, 0, semantic, texture);
4307 }
4308
4309 if(!has_texture)
4310 {
4311 has_texture |= get_imported_texture(material, aiTextureType_NORMAL_CAMERA, 0, semantic, texture);
4312
4313 if(has_texture)
4314 {
4315 normals_type = aiTextureType_NORMAL_CAMERA;
4316 }
4317 }
4318
4319 if(has_texture)
4320 {
4321 process_texture(texture, textures);
4322
4323 if(binding)
4324 {
4325 if(const auto key = try_make_texture_asset_key(output_dir, texture.name))
4326 {
4327 mat.set_normal_map(am.get_asset<gfx::texture>(*key));
4328 }
4329 else
4330 {
4331 APPLOG_WARNING("Mesh Importer: Could not bind normal texture '{}'", texture.name);
4332 }
4333 }
4334 }
4335 }
4336 // NORMAL BUMP PROPERTY
4337 {
4338 ai_real property{};
4339 bool has_property = false;
4340
4341 if(!has_property)
4342 {
4343 has_property |= material->Get(AI_MATKEY_GLTF_TEXTURE_SCALE(normals_type, 0), property) == AI_SUCCESS;
4344 }
4345
4346 if(!has_property)
4347 {
4348 has_property |= material->Get(AI_MATKEY_BUMPSCALING, property) == AI_SUCCESS;
4349 }
4350
4351 if(has_property)
4352 {
4353 mat.set_bumpiness(property);
4354 }
4355 }
4356
4357 // OCCLUSION TEXTURE
4358 aiTextureType occlusion_type = aiTextureType_AMBIENT_OCCLUSION;
4359 {
4360 static const std::string semantic = "Occlusion";
4361
4362 imported_texture texture;
4363 bool has_texture = false;
4364
4365 if(!has_texture)
4366 {
4367 has_texture |= get_imported_texture(material, aiTextureType_AMBIENT_OCCLUSION, 0, semantic, texture);
4368 }
4369
4370 if(!has_texture)
4371 {
4372 has_texture |= get_imported_texture(material, aiTextureType_AMBIENT, 0, semantic, texture);
4373
4374 if(has_texture)
4375 {
4376 occlusion_type = aiTextureType_AMBIENT;
4377 }
4378 }
4379
4380 if(!has_texture)
4381 {
4382 has_texture |= get_imported_texture(material, aiTextureType_LIGHTMAP, 0, semantic, texture);
4383 if(has_texture)
4384 {
4385 occlusion_type = aiTextureType_LIGHTMAP;
4386 }
4387 }
4388
4389 if(has_texture)
4390 {
4391 process_texture(texture, textures);
4392
4393 if(binding)
4394 {
4395 if(const auto key = try_make_texture_asset_key(output_dir, texture.name))
4396 {
4397 mat.set_ao_map(am.get_asset<gfx::texture>(*key));
4398 }
4399 else
4400 {
4401 APPLOG_WARNING("Mesh Importer: Could not bind occlusion texture '{}'", texture.name);
4402 }
4403 }
4404 }
4405 }
4406
4407 // OCCLUSION STERNGTH PROPERTY
4408 {
4409 ai_real property{};
4410 bool has_property = false;
4411
4412 if(!has_property)
4413 {
4414 has_property |= material->Get(AI_MATKEY_GLTF_TEXTURE_STRENGTH(occlusion_type, 0), property) == AI_SUCCESS;
4415 }
4416
4417 if(has_property)
4418 {
4419 }
4420 }
4421
4422 // EMISSIVE TEXTURE
4423 {
4424 static const std::string semantic = "Emissive";
4425
4426 imported_texture texture;
4427 bool has_texture = false;
4428
4429 if(!has_texture)
4430 {
4431 has_texture |= get_imported_texture(material, aiTextureType_EMISSION_COLOR, 0, semantic, texture);
4432 }
4433
4434 if(!has_texture)
4435 {
4436 has_texture |= get_imported_texture(material, aiTextureType_EMISSIVE, 0, semantic, texture);
4437 }
4438
4439 if(has_texture)
4440 {
4441 process_texture(texture, textures);
4442
4443 if(binding)
4444 {
4445 if(const auto key = try_make_texture_asset_key(output_dir, texture.name))
4446 {
4447 mat.set_emissive_map(am.get_asset<gfx::texture>(*key));
4448 }
4449 else
4450 {
4451 APPLOG_WARNING("Mesh Importer: Could not bind emissive texture '{}'", texture.name);
4452 }
4453 }
4454 }
4455 }
4456
4457 if(collecting)
4458 {
4459 return;
4460 }
4461
4462 // EMISSIVE COLOR PROPERTY
4463 {
4464 aiColor3D property{};
4465 bool has_property = false;
4466
4467 if(!has_property)
4468 {
4469 has_property |= material->Get(AI_MATKEY_COLOR_EMISSIVE, property) == AI_SUCCESS;
4470 }
4471
4472 if(has_property)
4473 {
4474 math::color emissive{};
4475 emissive = {property.r, property.g, property.b};
4476 emissive = math::clamp(emissive.value, 0.0f, 1.0f);
4477 mat.set_emissive_color(emissive);
4478 }
4479 }
4480 // EMISSIVE INTENSITY (glTF KHR: emissiveIntensity; premultiplied in deferred submit)
4481 {
4482 ai_real intensity = 1.0f;
4483 if(material->Get(AI_MATKEY_EMISSIVE_INTENSITY, intensity) == AI_SUCCESS)
4484 {
4485 mat.set_emissive_intensity(math::clamp(intensity, 0.0f, 100.0f));
4486 }
4487
4488 ai_real texture_strength = 1.0f;
4489 if(material->Get(AI_MATKEY_GLTF_TEXTURE_STRENGTH(aiTextureType_EMISSION_COLOR, 0), texture_strength) == AI_SUCCESS
4490 || material->Get(AI_MATKEY_GLTF_TEXTURE_STRENGTH(aiTextureType_EMISSIVE, 0), texture_strength) == AI_SUCCESS)
4491 {
4492 mat.set_emissive_intensity(math::clamp(mat.get_emissive_intensity() * texture_strength, 0.0f, 100.0f));
4493 }
4494 }
4495 // ALPHA MODE / CUTOFF (glTF alphaMode + alphaCutoff, legacy opacity, border alpha probe)
4496 {
4497 alpha_mode resolved = alpha_mode::opaque;
4498 ai_real resolved_cutoff = 0.5f;
4499
4500 aiString alpha_mode_str;
4501 const bool has_alpha_mode = material->Get(AI_MATKEY_GLTF_ALPHAMODE, alpha_mode_str) == AI_SUCCESS;
4502
4503 if(has_alpha_mode)
4504 {
4505 APPLOG_TRACE("Mesh Importer: glTF alphaMode: {}", alpha_mode_str.C_Str());
4506
4507 if(alpha_mode_str == aiString("MASK"))
4508 {
4509 resolved = alpha_mode::mask;
4510 material->Get(AI_MATKEY_GLTF_ALPHACUTOFF, resolved_cutoff);
4511 if(resolved_cutoff <= 0.0f)
4512 {
4513 resolved_cutoff = 0.5f;
4514 }
4515 }
4516 else if(alpha_mode_str == aiString("BLEND"))
4517 {
4518 resolved = alpha_mode::blend;
4519 }
4520 }
4521 else
4522 {
4523 ai_real opacity = 1.0f;
4524 if(material_opacity_factor_suggests_cutout(material, opacity))
4525 {
4526 resolved = alpha_mode::mask;
4527 resolved_cutoff = 1.0f - opacity;
4528 }
4529 }
4530
4531 // When mode is still opaque, probe the base color map border for transparency (common on
4532 // foliage/fences exported without alphaMode). Skipped when glTF declares BLEND/MASK.
4533 if(binding && resolved == alpha_mode::opaque && !base_color_map_relative.empty())
4534 {
4535 APPLOG_TRACE("Mesh Importer: Probing base color map border for transparency: {}", base_color_map_relative);
4536 if(color_map_border_suggests_alpha_cutout(output_dir, base_color_map_relative))
4537 {
4539 "Mesh Importer: Promoting to alpha cutout — base color map '{}' has transparent border pixels",
4540 base_color_map_relative);
4541 resolved = alpha_mode::mask;
4542 resolved_cutoff = resolve_import_alpha_cutoff(material);
4543 }
4544 }
4545
4546 mat.set_alpha_mode(resolved);
4547 if(resolved == alpha_mode::mask)
4548 {
4549 mat.set_alpha_cutoff(math::clamp(resolved_cutoff, 0.0f, 1.0f));
4550 }
4551 }
4552}
4553
4554void process_materials(asset_manager& am,
4555 const fs::path& filename,
4556 const fs::path& output_dir,
4557 const aiScene* scene,
4558 std::vector<imported_material>& materials,
4559 std::vector<imported_texture>& textures)
4560{
4561 if(scene->mNumMaterials == 0)
4562 {
4563 return;
4564 }
4565
4566 materials.resize(scene->mNumMaterials);
4567
4568 texture_job_store job_store;
4569 texture_catalog catalog;
4570 std::unordered_set<int> consumed_embedded;
4571 std::vector<imported_texture> collect_scratch;
4572
4573 material_import_env collect_env{};
4574 collect_env.phase = material_import_env::phase_t::collect;
4575 collect_env.filename = &filename;
4576 collect_env.output_dir = &output_dir;
4577 collect_env.scene = scene;
4578 collect_env.job_store = &job_store;
4579 collect_env.catalog = &catalog;
4580
4581 APPLOG_TRACE("Mesh Importer: Collecting texture import jobs for {} materials ...", scene->mNumMaterials);
4582 for(size_t i = 0; i < scene->mNumMaterials; ++i)
4583 {
4584 pbr_material dummy;
4585 process_material(am,
4586 filename,
4587 output_dir,
4588 scene,
4589 scene->mMaterials[i],
4590 dummy,
4591 collect_scratch,
4592 collect_env);
4593 }
4594
4595 APPLOG_TRACE("Mesh Importer: Running {} texture import jobs ...", job_store.jobs().size());
4596 run_texture_jobs_parallel(job_store, filename, output_dir, scene, catalog, consumed_embedded);
4597
4598 textures.clear();
4599 catalog.append_to_manifest(textures);
4600 sort_imported_textures(textures);
4601
4602 material_import_env bind_env{};
4603 bind_env.phase = material_import_env::phase_t::bind;
4604 bind_env.filename = &filename;
4605 bind_env.output_dir = &output_dir;
4606 bind_env.scene = scene;
4607 bind_env.catalog = &catalog;
4608
4609 APPLOG_TRACE("Mesh Importer: Binding {} materials to textures ...", scene->mNumMaterials);
4610 for(size_t i = 0; i < scene->mNumMaterials; ++i)
4611 {
4612 const aiMaterial* assimp_mat = scene->mMaterials[i];
4613
4614 auto mat = std::make_shared<pbr_material>();
4615 process_material(am, filename, output_dir, scene, assimp_mat, *mat, textures, bind_env);
4616
4617 std::string assimp_mat_name = assimp_mat->GetName().C_Str();
4618 if(assimp_mat_name.empty())
4619 {
4620 assimp_mat_name = fmt::format("Material {}", filename.string());
4621 }
4622 materials[i].mat = mat;
4623 materials[i].name = string_utils::replace(fmt::format("[{}] {}", i, assimp_mat_name), ".", "_");
4624 }
4625
4626 mark_embedded_consumed_from_textures(textures, consumed_embedded);
4627
4628 texture_job_store orphan_job_store;
4629 collect_orphan_embedded_texture_jobs(scene, filename, orphan_job_store, consumed_embedded);
4630 if(!orphan_job_store.jobs().empty())
4631 {
4632 APPLOG_TRACE("Mesh Importer: Running {} orphan embedded texture jobs ...", orphan_job_store.jobs().size());
4633 run_texture_jobs_parallel(orphan_job_store, filename, output_dir, scene, catalog, consumed_embedded);
4634 catalog.append_to_manifest(textures);
4635 }
4636
4637 sort_imported_textures(textures);
4638}
4639
4640void process_embedded_textures(asset_manager& am,
4641 const fs::path& filename,
4642 const fs::path& output_dir,
4643 const aiScene* scene,
4644 std::vector<imported_texture>& textures)
4645{
4646 if(scene->mNumTextures > 0)
4647 {
4648 for(size_t i = 0; i < scene->mNumTextures; ++i)
4649 {
4650 const aiTexture* assimp_tex = scene->mTextures[i];
4651
4652 process_embedded_texture(assimp_tex, i, filename, output_dir, textures);
4653 }
4654 }
4655}
4656
4657void process_imported_scene(asset_manager& am,
4658 const fs::path& filename,
4659 const fs::path& output_dir,
4660 const aiScene* scene,
4661 mesh::load_data& load_data,
4662 std::vector<animation_clip>& animations,
4663 std::vector<imported_material>& materials,
4664 std::vector<imported_texture>& textures)
4665{
4666 int meshes_with_bones = 0;
4667 int meshes_without_bones = 0;
4668
4669 APPLOG_TRACE_PERF_NAMED(std::chrono::milliseconds, "Mesh Importer: Parse Imported Data");
4670
4671 load_data.vertex_format = gfx::mesh_vertex::get_layout();
4672
4673 auto name_to_index_lut = assign_node_indices(scene);
4674
4675 APPLOG_TRACE("Mesh Importer: Processing materials (collect jobs → run jobs → bind) ...");
4676 process_materials(am, filename, output_dir, scene, materials, textures);
4677
4678 APPLOG_TRACE("Mesh Importer: Processing meshes ...");
4679 process_meshes(scene, load_data);
4680
4681 APPLOG_TRACE("Mesh Importer: Processing nodes ...");
4682 process_nodes(scene, load_data, name_to_index_lut);
4683
4684 APPLOG_TRACE("Mesh Importer: Processing animations ...");
4685 process_animations(scene, filename, load_data, name_to_index_lut, animations);
4686
4687 // Note: bounds are intentionally bind-pose only. Animation-driven expansion happens at
4688 // runtime from per-bone bind-space bounds (skinned) and per-node submesh proxy bounds
4689 // (rigid attachments), which track the actual pose instead of pre-sampled clips.
4690 if(!load_data.bbox.is_populated())
4691 {
4692 load_data.bbox = {};
4693 accumulate_bounds_from_armature(load_data, load_data.bbox);
4694 }
4695
4696 apply_import_facing_correction_to_load_data(load_data);
4697
4698 APPLOG_TRACE("Mesh Importer: bbox min {}, max {}", load_data.bbox.min, load_data.bbox.max);
4699}
4700
4701auto read_file(Assimp::Importer& importer, const fs::path& file, uint32_t flags) -> const aiScene*
4702{
4703 APPLOG_TRACE_PERF_NAMED(std::chrono::milliseconds, "Importer Read File");
4704 return importer.ReadFile(file.string(), flags);
4705}
4706
4711auto perceived_brightness(float r, float g, float b) -> float
4712{
4713 return std::sqrt(0.299f * r * r + 0.587f * g * g + 0.114f * b * b);
4714}
4715
4729auto solve_metallic(float perceived_diffuse, float perceived_specular, float one_minus_specular_strength) -> float
4730{
4731 constexpr float dielectric_f0 = 0.04f;
4732
4733 if(perceived_specular < dielectric_f0)
4734 {
4735 return 0.0f;
4736 }
4737
4738 float a = dielectric_f0;
4739 float b = perceived_diffuse * one_minus_specular_strength / (1.0f - dielectric_f0) + perceived_specular - 2.0f * dielectric_f0;
4740 float c = dielectric_f0 - perceived_specular;
4741 float discriminant = std::max(b * b - 4.0f * a * c, 0.0f);
4742
4743 return math::clamp((-b + std::sqrt(discriminant)) / (2.0f * a), 0.0f, 1.0f);
4744}
4745
4751auto convert_specular_gloss_to_metallic_roughness(const aiColor3D& diffuse_color,
4752 const aiColor3D& specular_color,
4753 float glossiness_factor) -> std::tuple<aiColor3D, float, float>
4754{
4755 constexpr float dielectric_f0 = 0.04f;
4756 constexpr float epsilon = 1e-6f;
4757
4758 float max_specular = std::max({specular_color.r, specular_color.g, specular_color.b});
4759 float one_minus_specular_strength = 1.0f - max_specular;
4760
4761 float perceived_diffuse = perceived_brightness(diffuse_color.r, diffuse_color.g, diffuse_color.b);
4762 float perceived_specular = perceived_brightness(specular_color.r, specular_color.g, specular_color.b);
4763
4764 float metallic = solve_metallic(perceived_diffuse, perceived_specular, one_minus_specular_strength);
4765
4766 // Khronos/Babylon reference formula for base color reconstruction:
4767 // baseColorFromDiffuse = diffuse * (1 - F0) / (1 - metallic * F0)
4768 // baseColorFromSpecular = specular - F0 * (1 - metallic)
4769 // baseColor = mix(baseColorFromDiffuse, baseColorFromSpecular, metallic²)
4770 float denom = std::max(1.0f - metallic * dielectric_f0, epsilon);
4771 float spec_offset = dielectric_f0 * (1.0f - metallic);
4772
4773 auto base_from_diffuse = [&](float d) -> float { return d * (1.0f - dielectric_f0) / denom; };
4774 auto base_from_specular = [&](float s) -> float { return s - spec_offset; };
4775
4776 float t = metallic * metallic;
4777 aiColor3D base_color;
4778 base_color.r = math::mix(base_from_diffuse(diffuse_color.r), base_from_specular(specular_color.r), t);
4779 base_color.g = math::mix(base_from_diffuse(diffuse_color.g), base_from_specular(specular_color.g), t);
4780 base_color.b = math::mix(base_from_diffuse(diffuse_color.b), base_from_specular(specular_color.b), t);
4781
4782 float roughness = 1.0f - glossiness_factor;
4783
4784 base_color.r = math::clamp(base_color.r, 0.0f, 1.0f);
4785 base_color.g = math::clamp(base_color.g, 0.0f, 1.0f);
4786 base_color.b = math::clamp(base_color.b, 0.0f, 1.0f);
4787 metallic = math::clamp(metallic, 0.0f, 1.0f);
4788 roughness = math::clamp(roughness, 0.0f, 1.0f);
4789
4790 return std::make_tuple(base_color, metallic, roughness);
4791}
4792
4793
4794
4795} // namespace
4796
4798{
4799 struct log_stream : public Assimp::LogStream
4800 {
4801 log_stream(Assimp::Logger::ErrorSeverity s) : severity(s)
4802 {
4803 }
4804
4805 void write(const char* message) override
4806 {
4807 switch(severity)
4808 {
4809 case Assimp::Logger::Info:
4810 APPLOG_INFO("Mesh Importer: {0}", message);
4811 break;
4812 case Assimp::Logger::Warn:
4813 APPLOG_WARNING("Mesh Importer: {0}", message);
4814 break;
4815 case Assimp::Logger::Err:
4816 APPLOG_ERROR("Mesh Importer: {0}", message);
4817 break;
4818 default:
4819 APPLOG_TRACE("Mesh Importer: {0}", message);
4820 break;
4821 }
4822 }
4823
4824 Assimp::Logger::ErrorSeverity severity{};
4825 };
4826
4827 // if(Assimp::DefaultLogger::isNullLogger())
4828 // {
4829 // auto logger = Assimp::DefaultLogger::create("", Assimp::Logger::VERBOSE);
4830
4831 // logger->attachStream(new log_stream(Assimp::Logger::Debugging), Assimp::Logger::Debugging);
4832 // logger->attachStream(new log_stream(Assimp::Logger::Info), Assimp::Logger::Info);
4833 // logger->attachStream(new log_stream(Assimp::Logger::Warn), Assimp::Logger::Warn);
4834 // logger->attachStream(new log_stream(Assimp::Logger::Err), Assimp::Logger::Err);
4835 // }
4836}
4837
4839 const fs::path& path,
4840 const mesh_importer_meta& import_meta,
4841 mesh::load_data& load_data,
4842 std::vector<animation_clip>& animations,
4843 std::vector<imported_material>& materials,
4844 std::vector<imported_texture>& textures) -> bool
4845{
4846 Assimp::Importer importer;
4847
4848 int rvc_flags = aiComponent_CAMERAS | aiComponent_LIGHTS;
4849
4850 if(!import_meta.model.import_meshes)
4851 {
4852 rvc_flags |= aiComponent_MESHES;
4853 }
4854
4855 if(!import_meta.animations.import_animations)
4856 {
4857 rvc_flags |= aiComponent_ANIMATIONS;
4858 }
4859
4860 if(!import_meta.materials.import_materials)
4861 {
4862 rvc_flags |= aiComponent_MATERIALS;
4863 }
4864
4865 importer.SetPropertyInteger(AI_CONFIG_PP_RVC_FLAGS, rvc_flags);
4866 importer.SetPropertyInteger(AI_CONFIG_PP_SBP_REMOVE, aiPrimitiveType_LINE | aiPrimitiveType_POINT);
4867 importer.SetPropertyBool(AI_CONFIG_FBX_CONVERT_TO_M, true);
4868 importer.SetPropertyBool(AI_CONFIG_IMPORT_FBX_PRESERVE_PIVOTS, false);
4869
4870 fs::path file = path.stem();
4871 fs::path output_dir = path.parent_path();
4872
4873 // clang-format off
4874
4875 uint32_t flags = aiProcess_ConvertToLeftHanded |
4876 aiProcess_RemoveComponent |
4877 aiProcess_Triangulate |
4878 aiProcess_CalcTangentSpace |
4879 aiProcess_GenUVCoords |
4880 aiProcess_GenSmoothNormals |
4881 aiProcess_GenBoundingBoxes |
4882 aiProcess_ImproveCacheLocality |
4883 aiProcess_LimitBoneWeights |
4884 aiProcess_SortByPType |
4885 aiProcess_TransformUVCoords |
4886 aiProcess_GlobalScale;
4887
4888 // clang-format on
4889
4890 if(import_meta.model.weld_vertices)
4891 {
4892 flags |= aiProcess_JoinIdenticalVertices;
4893 }
4894 if(import_meta.model.optimize_meshes)
4895 {
4896 flags |= aiProcess_OptimizeMeshes;
4897 }
4898 if(import_meta.model.split_large_meshes)
4899 {
4900 flags |= aiProcess_SplitLargeMeshes;
4901 }
4902 if(import_meta.model.find_degenerates)
4903 {
4904 flags |= aiProcess_FindDegenerates;
4905 }
4906 if(import_meta.model.find_invalid_data)
4907 {
4908 flags |= aiProcess_FindInvalidData;
4909 }
4910 if(import_meta.materials.remove_redundant_materials)
4911 {
4912 flags |= aiProcess_RemoveRedundantMaterials;
4913 }
4914
4915 APPLOG_TRACE("Mesh Importer: Loading {}", path.generic_string());
4916
4917 const aiScene* scene = read_file(importer, path, flags);
4918
4919 if(scene == nullptr)
4920 {
4921 APPLOG_ERROR(importer.GetErrorString());
4922 return false;
4923 }
4924
4925 // We need to modify the scene, so we cast away const (be cautious in production).
4926 aiScene* modScene = const_cast<aiScene*>(scene);
4927
4928 //CollapseAssimpFBXPivotsAndAnimations(modScene);
4929 process_imported_scene(am, file, output_dir, modScene, load_data, animations, materials, textures);
4930
4931 APPLOG_TRACE("Mesh Importer: Done with {}", path.generic_string());
4932
4933 return true;
4934}
4935} // namespace importer
4936
4937} // namespace unravel
uint32_t width
uint32_t height
gfx::texture_format format
bool imageParseInfo(const void *_data, uint32_t _size, bimg::ImageContainer &_info, bx::Error *_err)
bimg::ImageContainer * imageLoad(const void *data, uint32_t size, bgfx::TextureFormat::Enum _dstFormat)
bool imageSave(const char *saveAs, bimg::ImageContainer *image)
entt::handle b
entt::handle a
General purpose transformation class designed to maintain each component of the transformation separa...
Definition transform.hpp:27
void set_rotation(const quat_t &rotation) noexcept
Set the rotation component.
static auto identity() noexcept -> const transform_t &
Get the identity transform.
Manages assets, including loading, unloading, and storage.
float y
float x
math::vec3 position
Definition defaults.cpp:52
math::vec3 normal
Definition defaults.cpp:53
uint16_t index
std::string name
Definition hub.cpp:33
#define APPLOG_WARNING(...)
Definition logging.h:19
#define APPLOG_ERROR(...)
Definition logging.h:20
#define APPLOG_INFO(...)
Definition logging.h:18
#define APPLOG_TRACE_PERF_NAMED(T, name)
Definition logging.h:120
#define APPLOG_TRACE(...)
Definition logging.h:17
bool bake_base_color
When true, KHR pair bake rewrites diffuse into reconstructed base color.
texture_job_type type
spec_gloss_factors_t spec_gloss_factors
phase_t phase
const fs::path * output_dir
std::vector< size_t > parent_
texture_catalog * catalog
float diffuse_g
std::string mr_relative
Relative path of the converted base color file (empty if not produced).
std::string output_mr_relative
float diffuse_b
float diffuse_r
std::string output_base_color_relative
const aiScene * scene
texture_job_store * job_store
const fs::path * filename
float glossiness
std::string base_color_relative
Diffuse mutated to base color and saved to base_color_relative.
float specular_b
imported_texture specular_desc
float diffuse_a
float specular_g
imported_texture desc
bool diffuse_converted
float specular_r
auto get_suported_formats< gfx::texture >() -> const std::vector< std::string > &
bool has_known_protocol(const path &_path)
Checks whether the path has a known protocol.
path convert_to_protocol(const path &_path)
Oposite of the resolve_protocol this function tries to convert to protocol path from an absolute one.
void vertex_pack(const float _input[4], bool _inputNormalized, attribute _attr, const vertex_layout &_decl, void *_data, uint32_t _index)
Definition graphics.cpp:354
Hash specialization for batch_key to enable use in std::unordered_map.
auto replace(const std::string &str, const std::string &search, const std::string &replace) -> std::string
Definition utils.cpp:28
auto to_lower(const std::string &str) -> std::string
Definition utils.cpp:42
auto atomic_rename_file(const fs::path &src, const fs::path &dst, fs::error_code &ec) noexcept -> bool
auto atomic_copy_file(const fs::path &src, const fs::path &dst, fs::error_code &ec) noexcept -> bool
void atomic_write_file(const fs::path &dst, const std::function< void(const fs::path &)> &callback, fs::error_code &ec) noexcept
auto load_mesh_data_from_file(asset_manager &am, const fs::path &path, const mesh_importer_meta &import_meta, mesh::load_data &load_data, std::vector< animation_clip > &animations, std::vector< imported_material > &materials, std::vector< imported_texture > &textures) -> bool
@ none
No culling.
alpha_mode
glTF-aligned surface alpha behavior (default: opaque).
Definition material.h:33
@ opaque
No alpha clip; casts solid shadows.
@ blend
Transparent (lit pass); does not cast shadows.
@ mask
Hard alpha cutoff; casts cutout shadows.
std::vector< math::quat > rotation
static auto get_layout() -> const vertex_layout &
Definition vertex_decl.h:15
Storage for box vector values and wraps up common functionality.
Definition bbox.h:21
bbox & mul(const transform &t)
Transforms an axis aligned bounding box by the specified matrix.
Definition bbox.cpp:876
Struct used for mesh construction.
Definition mesh.h:451
Represents a scene in the ACE framework, managing entities and their relationships.
Definition scene.h:70