VisuTwin Canvas
C++ 3D Engine — Metal Backend
Loading...
Searching...
No Matches
assimpParser.cpp
Go to the documentation of this file.
1// SPDX-License-Identifier: Apache-2.0
2// Copyright 2025-2026 Arnis Lektauers
3//
4// Assimp-based multi-format 3D model parser for VisuTwin Canvas.
5//
6// Uses Assimp (Open Asset Import Library) to parse Collada (.dae), FBX (.fbx),
7// 3DS, PLY, and other formats. Produces a GlbContainerResource with the
8// engine's standard 14-float interleaved vertex layout (matching GlbParser
9// and ObjParser).
10//
11// Key design decisions:
12// - Reuses GlbContainerResource for unified instantiateRenderEntity() path
13// - aiProcess_FlipUVs handles Metal's top-left UV origin (no manual V flip)
14// - Two-path material conversion: native PBR (glTF/FBX) and legacy Phong→PBR
15// - Tangent generation: Assimp first, Lengyel fallback, Gram-Schmidt last
16// - Pre-order DFS node traversal maps to GlbNodePayload flat array
17//
18// Custom loader (not derived from upstream).
19//
20#include "assimpParser.h"
21
22#include <assimp/Importer.hpp>
23#include <assimp/scene.h>
24#include <assimp/postprocess.h>
25#include <assimp/material.h>
26#include <assimp/config.h>
27
28#include <algorithm>
29#include <cmath>
30#include <cstring>
31#include <filesystem>
32#include <functional>
33#include <limits>
34#include <unordered_map>
35#include <vector>
36
37#include "core/math/vector3.h"
38#include "core/math/vector4.h"
46#include "spdlog/spdlog.h"
47#include "stb_image.h" // declarations only -- STB_IMAGE_IMPLEMENTATION is in asset.cpp
48
49namespace visutwin::canvas
50{
51 namespace
52 {
53 // ── Vertex layout (must match GlbParser / ObjParser PackedVertex) ──
54
55 struct PackedVertex
56 {
57 float px, py, pz; // position
58 float nx, ny, nz; // normal
59 float u, v; // uv0
60 float tx, ty, tz, tw; // tangent + handedness
61 float u1, v1; // uv1
62 };
63
64 static_assert(sizeof(PackedVertex) == 56, "PackedVertex must be 56 bytes (14 floats)");
65
66 // ── Tangent generation (same Lengyel algorithm as ObjParser) ───────
67
68 void generateTangents(std::vector<PackedVertex>& vertices, const std::vector<uint32_t>& indices)
69 {
70 const size_t vertexCount = vertices.size();
71 if (vertexCount == 0) return;
72
73 std::vector<Vector3> tan1(vertexCount, Vector3(0.0f, 0.0f, 0.0f));
74 std::vector<Vector3> tan2(vertexCount, Vector3(0.0f, 0.0f, 0.0f));
75
76 auto accumulateTriangle = [&](uint32_t i0, uint32_t i1, uint32_t i2) {
77 if (i0 >= vertexCount || i1 >= vertexCount || i2 >= vertexCount) return;
78
79 const auto& v0 = vertices[i0];
80 const auto& v1 = vertices[i1];
81 const auto& v2 = vertices[i2];
82
83 const float du1 = v1.u - v0.u;
84 const float dv1 = v1.v - v0.v;
85 const float du2 = v2.u - v0.u;
86 const float dv2 = v2.v - v0.v;
87
88 const float det = du1 * dv2 - dv1 * du2;
89 if (std::abs(det) <= 1e-8f) return;
90
91 const float invDet = 1.0f / det;
92 const Vector3 e1(v1.px - v0.px, v1.py - v0.py, v1.pz - v0.pz);
93 const Vector3 e2(v2.px - v0.px, v2.py - v0.py, v2.pz - v0.pz);
94
95 const Vector3 sdir = (e1 * dv2 - e2 * dv1) * invDet;
96 const Vector3 tdir = (e2 * du1 - e1 * du2) * invDet;
97
98 tan1[i0] += sdir; tan1[i1] += sdir; tan1[i2] += sdir;
99 tan2[i0] += tdir; tan2[i1] += tdir; tan2[i2] += tdir;
100 };
101
102 if (!indices.empty()) {
103 for (size_t i = 0; i + 2 < indices.size(); i += 3)
104 accumulateTriangle(indices[i], indices[i + 1], indices[i + 2]);
105 } else {
106 for (uint32_t i = 0; i + 2 < static_cast<uint32_t>(vertexCount); i += 3)
107 accumulateTriangle(i, i + 1, i + 2);
108 }
109
110 for (size_t i = 0; i < vertexCount; ++i) {
111 const Vector3 n(vertices[i].nx, vertices[i].ny, vertices[i].nz);
112 Vector3 t = tan1[i] - n * n.dot(tan1[i]);
113 if (t.lengthSquared() <= 1e-8f) {
114 t = std::abs(n.getY()) < 0.999f
115 ? n.cross(Vector3(0.0f, 1.0f, 0.0f))
116 : n.cross(Vector3(1.0f, 0.0f, 0.0f));
117 }
118 t = t.normalized();
119
120 const float handedness = (n.cross(t).dot(tan2[i]) < 0.0f) ? -1.0f : 1.0f;
121
122 vertices[i].tx = t.getX();
123 vertices[i].ty = t.getY();
124 vertices[i].tz = t.getZ();
125 vertices[i].tw = handedness;
126 }
127 }
128
129 // ── Tangent-from-normal fallback (no UVs available) ─────────────
130
131 void tangentFromNormal(float nx, float ny, float nz,
132 float& tx, float& ty, float& tz, float& tw)
133 {
134 Vector3 n(nx, ny, nz);
135 Vector3 up = std::abs(ny) < 0.999f ? Vector3(0.0f, 1.0f, 0.0f) : Vector3(1.0f, 0.0f, 0.0f);
136 Vector3 t = n.cross(up).normalized();
137 tx = t.getX();
138 ty = t.getY();
139 tz = t.getZ();
140 tw = 1.0f;
141 }
142
143 // ── Texture loading (external files + embedded) ────────────────
144
145 std::shared_ptr<Texture> loadAssimpTexture(
146 const aiMaterial* aiMat,
147 aiTextureType type,
148 const aiScene* scene,
149 const std::filesystem::path& basedir,
150 GraphicsDevice* device,
151 std::unordered_map<std::string, std::shared_ptr<Texture>>& cache)
152 {
153 aiString texPath;
154 if (aiGetMaterialTexture(aiMat, type, 0, &texPath) != AI_SUCCESS)
155 return nullptr;
156
157 std::string pathStr = texPath.C_Str();
158 if (pathStr.empty())
159 return nullptr;
160
161 // Check cache
162 auto cacheIt = cache.find(pathStr);
163 if (cacheIt != cache.end())
164 return cacheIt->second;
165
166 int w = 0, h = 0, channels = 0;
167 stbi_uc* pixels = nullptr;
168
169 if (pathStr[0] == '*') {
170 // Embedded texture: "*N" indexes into scene->mTextures
171 int texIndex = std::atoi(pathStr.c_str() + 1);
172 if (texIndex < 0 || texIndex >= static_cast<int>(scene->mNumTextures)) {
173 spdlog::warn("Assimp: embedded texture index out of range: {}", pathStr);
174 cache[pathStr] = nullptr;
175 return nullptr;
176 }
177 const aiTexture* aiTex = scene->mTextures[texIndex];
178 if (aiTex->mHeight == 0) {
179 // Compressed format (PNG/JPEG) -- mWidth is byte length
180 stbi_set_flip_vertically_on_load(false);
181 pixels = stbi_load_from_memory(
182 reinterpret_cast<const unsigned char*>(aiTex->pcData),
183 static_cast<int>(aiTex->mWidth),
184 &w, &h, &channels, 4);
185 } else {
186 // Uncompressed BGRA (aiTexel: {b, g, r, a})
187 w = static_cast<int>(aiTex->mWidth);
188 h = static_cast<int>(aiTex->mHeight);
189 const size_t pixelCount = static_cast<size_t>(w) * h;
190 pixels = static_cast<stbi_uc*>(malloc(pixelCount * 4));
191 for (size_t i = 0; i < pixelCount; ++i) {
192 const auto& texel = aiTex->pcData[i];
193 pixels[i * 4 + 0] = texel.r;
194 pixels[i * 4 + 1] = texel.g;
195 pixels[i * 4 + 2] = texel.b;
196 pixels[i * 4 + 3] = texel.a;
197 }
198 channels = 4;
199 }
200 } else {
201 // External texture file
202 std::filesystem::path fullPath;
203 std::filesystem::path texFilePath(pathStr);
204 if (texFilePath.is_absolute()) {
205 fullPath = texFilePath;
206 } else {
207 fullPath = basedir / texFilePath;
208 }
209 if (!std::filesystem::exists(fullPath)) {
210 spdlog::warn("Assimp texture not found: {}", fullPath.string());
211 cache[pathStr] = nullptr;
212 return nullptr;
213 }
214 // UVs already flipped by aiProcess_FlipUVs -- do NOT flip texture data
215 stbi_set_flip_vertically_on_load(false);
216 pixels = stbi_load(fullPath.string().c_str(), &w, &h, &channels, 4);
217 }
218
219 if (!pixels || w <= 0 || h <= 0) {
220 spdlog::warn("Assimp texture decode failed: {}", pathStr);
221 if (pixels) stbi_image_free(pixels);
222 cache[pathStr] = nullptr;
223 return nullptr;
224 }
225
226 TextureOptions opts;
227 opts.width = static_cast<uint32_t>(w);
228 opts.height = static_cast<uint32_t>(h);
229 opts.format = PixelFormat::PIXELFORMAT_RGBA8;
230 opts.mipmaps = false;
231 opts.numLevels = 1;
232 opts.minFilter = FilterMode::FILTER_LINEAR;
233 opts.magFilter = FilterMode::FILTER_LINEAR;
234 opts.name = pathStr;
235
236 auto texture = std::make_shared<Texture>(device, opts);
237 const size_t dataSize = static_cast<size_t>(w) * h * 4;
238 texture->setLevelData(0, pixels, dataSize);
239 stbi_image_free(pixels);
240 texture->upload();
241
242 spdlog::info("Assimp texture loaded: {} ({}x{})", pathStr, w, h);
243 cache[pathStr] = texture;
244 return texture;
245 }
246
247 // ── PBR material conversion (glTF, FBX with PBR) ──────────────
248
249 void convertPbrMaterial(
250 const aiMaterial* aiMat,
251 const aiScene* scene,
252 const std::filesystem::path& basedir,
253 GraphicsDevice* device,
254 StandardMaterial& material,
255 std::unordered_map<std::string, std::shared_ptr<Texture>>& texCache,
256 std::vector<std::shared_ptr<Texture>>& ownedTextures)
257 {
258 auto loadAndOwn = [&](aiTextureType type) -> Texture* {
259 auto tex = loadAssimpTexture(aiMat, type, scene, basedir, device, texCache);
260 if (tex) ownedTextures.push_back(tex);
261 return tex.get();
262 };
263
264 // Base color
265 aiColor4D baseColor(1.0f, 1.0f, 1.0f, 1.0f);
266 aiGetMaterialColor(aiMat, AI_MATKEY_BASE_COLOR, &baseColor);
267 material.setDiffuse(Color(baseColor.r, baseColor.g, baseColor.b, baseColor.a));
268 material.setBaseColorFactor(Color(baseColor.r, baseColor.g, baseColor.b, baseColor.a));
269 material.setOpacity(baseColor.a);
270
271 // Metallic + roughness
272 float metallic = 0.0f, roughness = 1.0f;
273 aiGetMaterialFloat(aiMat, AI_MATKEY_METALLIC_FACTOR, &metallic);
274 aiGetMaterialFloat(aiMat, AI_MATKEY_ROUGHNESS_FACTOR, &roughness);
275 material.setMetalness(metallic);
276 material.setMetallicFactor(metallic);
277 material.setGloss(1.0f - roughness);
278 material.setRoughnessFactor(roughness);
279 material.setUseMetalness(true);
280
281 // Emissive
282 aiColor4D emissive(0.0f, 0.0f, 0.0f, 1.0f);
283 aiGetMaterialColor(aiMat, AI_MATKEY_COLOR_EMISSIVE, &emissive);
284 if (emissive.r > 0.0f || emissive.g > 0.0f || emissive.b > 0.0f) {
285 material.setEmissive(Color(emissive.r, emissive.g, emissive.b, 1.0f));
286 material.setEmissiveFactor(Color(emissive.r, emissive.g, emissive.b, 1.0f));
287 }
288
289 // Opacity
290 float opacity = 1.0f;
291 aiGetMaterialFloat(aiMat, AI_MATKEY_OPACITY, &opacity);
292 material.setOpacity(opacity);
293 if (opacity < 0.99f) {
294 material.setAlphaMode(AlphaMode::BLEND);
295 material.setTransparent(true);
296 }
297
298 // Textures
299 if (auto* tex = loadAndOwn(aiTextureType_BASE_COLOR)) {
300 material.setDiffuseMap(tex);
301 material.setBaseColorTexture(tex);
302 material.setHasBaseColorTexture(true);
303 } else if (auto* tex2 = loadAndOwn(aiTextureType_DIFFUSE)) {
304 material.setDiffuseMap(tex2);
305 material.setBaseColorTexture(tex2);
306 material.setHasBaseColorTexture(true);
307 }
308
309 if (auto* tex = loadAndOwn(aiTextureType_NORMALS)) {
310 material.setNormalMap(tex);
311 material.setNormalTexture(tex);
312 material.setHasNormalTexture(true);
313 }
314
315 // Metallic-roughness combined texture (glTF aiTextureType_UNKNOWN)
316 if (auto* tex = loadAndOwn(aiTextureType_UNKNOWN)) {
317 material.setMetallicRoughnessTexture(tex);
318 material.setHasMetallicRoughnessTexture(true);
319 }
320
321 // AO (glTF uses aiTextureType_LIGHTMAP)
322 if (auto* tex = loadAndOwn(aiTextureType_LIGHTMAP)) {
323 material.setAoMap(tex);
324 material.setOcclusionTexture(tex);
325 material.setHasOcclusionTexture(true);
326 }
327
328 // Emissive map
329 if (auto* tex = loadAndOwn(aiTextureType_EMISSIVE)) {
330 material.setEmissiveMap(tex);
331 material.setEmissiveTexture(tex);
332 material.setHasEmissiveTexture(true);
333 }
334 }
335
336 // ── Legacy material conversion (Phong/Lambert/Blinn → PBR) ─────
337
338 void convertLegacyMaterial(
339 const aiMaterial* aiMat,
340 const aiScene* scene,
341 const std::filesystem::path& basedir,
342 GraphicsDevice* device,
343 StandardMaterial& material,
344 std::unordered_map<std::string, std::shared_ptr<Texture>>& texCache,
345 std::vector<std::shared_ptr<Texture>>& ownedTextures)
346 {
347 auto loadAndOwn = [&](aiTextureType type) -> Texture* {
348 auto tex = loadAssimpTexture(aiMat, type, scene, basedir, device, texCache);
349 if (tex) ownedTextures.push_back(tex);
350 return tex.get();
351 };
352
353 // Diffuse -> Base Color
354 aiColor4D diffuse(0.8f, 0.8f, 0.8f, 1.0f);
355 aiGetMaterialColor(aiMat, AI_MATKEY_COLOR_DIFFUSE, &diffuse);
356 material.setDiffuse(Color(diffuse.r, diffuse.g, diffuse.b, diffuse.a));
357 material.setBaseColorFactor(Color(diffuse.r, diffuse.g, diffuse.b, diffuse.a));
358
359 // Specular
360 aiColor4D specular(0.0f, 0.0f, 0.0f, 1.0f);
361 aiGetMaterialColor(aiMat, AI_MATKEY_COLOR_SPECULAR, &specular);
362 material.setSpecular(Color(specular.r, specular.g, specular.b, 1.0f));
363
364 // Shininess -> Roughness (physically-motivated formula)
365 float shininess = 0.0f;
366 aiGetMaterialFloat(aiMat, AI_MATKEY_SHININESS, &shininess);
367 float roughness = (shininess > 0.0f)
368 ? std::sqrt(2.0f / (shininess + 2.0f))
369 : 1.0f;
370 material.setGloss(1.0f - roughness);
371 material.setRoughnessFactor(roughness);
372
373 // Metalness heuristic (same algorithm as ObjParser)
374 float kdLum = 0.2126f * diffuse.r + 0.7152f * diffuse.g + 0.0722f * diffuse.b;
375 float ksLum = 0.2126f * specular.r + 0.7152f * specular.g + 0.0722f * specular.b;
376 float metalness = 0.0f;
377 if (kdLum < 0.04f && ksLum > 0.5f) {
378 metalness = 1.0f;
379 } else if (ksLum > 0.25f) {
380 float ksMax = std::max({specular.r, specular.g, specular.b});
381 float ksMin = std::min({specular.r, specular.g, specular.b});
382 float sat = (ksMax > 0.001f) ? (ksMax - ksMin) / ksMax : 0.0f;
383 if (sat > 0.2f) metalness = 0.8f;
384 }
385 material.setMetalness(metalness);
386 material.setMetallicFactor(metalness);
387 material.setUseMetalness(true);
388
389 // Emissive
390 aiColor4D emissive(0.0f, 0.0f, 0.0f, 1.0f);
391 aiGetMaterialColor(aiMat, AI_MATKEY_COLOR_EMISSIVE, &emissive);
392 if (emissive.r > 0.0f || emissive.g > 0.0f || emissive.b > 0.0f) {
393 material.setEmissive(Color(emissive.r, emissive.g, emissive.b, 1.0f));
394 material.setEmissiveFactor(Color(emissive.r, emissive.g, emissive.b, 1.0f));
395 }
396
397 // Opacity
398 float opacity = 1.0f;
399 aiGetMaterialFloat(aiMat, AI_MATKEY_OPACITY, &opacity);
400 material.setOpacity(opacity);
401 if (opacity < 0.99f) {
402 material.setAlphaMode(AlphaMode::BLEND);
403 material.setTransparent(true);
404 }
405
406 material.setCullMode(CullMode::CULLFACE_BACK);
407
408 // Textures: diffuse / base color map
409 if (auto* tex = loadAndOwn(aiTextureType_DIFFUSE)) {
410 material.setDiffuseMap(tex);
411 material.setBaseColorTexture(tex);
412 material.setHasBaseColorTexture(true);
413 }
414
415 // Normal map: check NORMALS first, fall back to HEIGHT (bump maps)
416 if (auto* tex = loadAndOwn(aiTextureType_NORMALS)) {
417 material.setNormalMap(tex);
418 material.setNormalTexture(tex);
419 material.setHasNormalTexture(true);
420 } else if (auto* tex2 = loadAndOwn(aiTextureType_HEIGHT)) {
421 material.setNormalMap(tex2);
422 material.setNormalTexture(tex2);
423 material.setHasNormalTexture(true);
424 material.setBumpiness(0.5f);
425 material.setNormalScale(0.5f);
426 }
427
428 // AO (Collada ambient maps)
429 if (auto* tex = loadAndOwn(aiTextureType_AMBIENT)) {
430 material.setAoMap(tex);
431 material.setOcclusionTexture(tex);
432 material.setHasOcclusionTexture(true);
433 }
434
435 // Emissive map
436 if (auto* tex = loadAndOwn(aiTextureType_EMISSIVE)) {
437 material.setEmissiveMap(tex);
438 material.setEmissiveTexture(tex);
439 material.setHasEmissiveTexture(true);
440 }
441
442 // Opacity map
443 if (auto* tex = loadAndOwn(aiTextureType_OPACITY)) {
444 material.setOpacityMap(tex);
445 material.setAlphaMode(AlphaMode::MASK);
446 }
447 }
448
449 // ── Shader variant key (same bit assignments as GlbParser) ─────
450
451 uint64_t computeShaderVariantKey(const Material& material)
452 {
453 uint64_t variant = 1; // base bit always set
454 if (material.hasBaseColorTexture()) variant |= (1ull << 1);
455 if (material.alphaMode() == AlphaMode::BLEND) variant |= (1ull << 2);
456 else if (material.alphaMode() == AlphaMode::MASK) variant |= (1ull << 3);
457 if (material.hasNormalTexture()) variant |= (1ull << 4);
458 if (material.hasMetallicRoughnessTexture()) variant |= (1ull << 5);
459 if (material.hasOcclusionTexture()) variant |= (1ull << 6);
460 if (material.hasEmissiveTexture()) variant |= (1ull << 7);
461 return variant;
462 }
463
464 // ── Mesh conversion (aiMesh → Mesh + VertexBuffer + IndexBuffer) ──
465
466 std::shared_ptr<Mesh> convertAssimpMesh(
467 const aiMesh* aiM,
468 const std::shared_ptr<VertexFormat>& vertexFormat,
469 const std::shared_ptr<GraphicsDevice>& device,
470 const AssimpParserConfig& config)
471 {
472 const unsigned int vertexCount = aiM->mNumVertices;
473 if (vertexCount == 0) return nullptr;
474
475 std::vector<PackedVertex> vertices(vertexCount);
476
477 float minX = std::numeric_limits<float>::max();
478 float minY = std::numeric_limits<float>::max();
479 float minZ = std::numeric_limits<float>::max();
480 float maxX = std::numeric_limits<float>::lowest();
481 float maxY = std::numeric_limits<float>::lowest();
482 float maxZ = std::numeric_limits<float>::lowest();
483
484 const bool hasNormals = aiM->HasNormals();
485 const bool hasUVs = aiM->HasTextureCoords(0);
486 const bool hasUV1 = aiM->HasTextureCoords(1);
487 const bool hasTangents = aiM->HasTangentsAndBitangents();
488
489 for (unsigned int i = 0; i < vertexCount; ++i) {
490 float px = aiM->mVertices[i].x;
491 float py = aiM->mVertices[i].y;
492 float pz = aiM->mVertices[i].z;
493
494 // Apply config transforms
495 px *= config.uniformScale;
496 py *= config.uniformScale;
497 pz *= config.uniformScale;
498 if (config.flipYZ) {
499 std::swap(py, pz);
500 pz = -pz;
501 }
502
503 float nx = 0.0f, ny = 1.0f, nz = 0.0f;
504 if (hasNormals) {
505 nx = aiM->mNormals[i].x;
506 ny = aiM->mNormals[i].y;
507 nz = aiM->mNormals[i].z;
508 if (config.flipYZ) {
509 std::swap(ny, nz);
510 nz = -nz;
511 }
512 }
513
514 // UVs already flipped by aiProcess_FlipUVs -- no manual flip needed
515 float u = 0.0f, v = 0.0f;
516 if (hasUVs) {
517 u = aiM->mTextureCoords[0][i].x;
518 v = aiM->mTextureCoords[0][i].y;
519 }
520
521 float u1 = u, v1 = v;
522 if (hasUV1) {
523 u1 = aiM->mTextureCoords[1][i].x;
524 v1 = aiM->mTextureCoords[1][i].y;
525 }
526
527 float tx = 0.0f, ty = 0.0f, tz = 0.0f, tw = 1.0f;
528 if (hasTangents) {
529 tx = aiM->mTangents[i].x;
530 ty = aiM->mTangents[i].y;
531 tz = aiM->mTangents[i].z;
532 if (config.flipYZ) {
533 std::swap(ty, tz);
534 tz = -tz;
535 }
536 // Compute handedness from bitangent
537 Vector3 n(nx, ny, nz);
538 Vector3 t(tx, ty, tz);
539 float bx = aiM->mBitangents[i].x;
540 float by = aiM->mBitangents[i].y;
541 float bz = aiM->mBitangents[i].z;
542 if (config.flipYZ) {
543 std::swap(by, bz);
544 bz = -bz;
545 }
546 Vector3 b(bx, by, bz);
547 tw = (n.cross(t).dot(b) < 0.0f) ? -1.0f : 1.0f;
548 } else {
549 tangentFromNormal(nx, ny, nz, tx, ty, tz, tw);
550 }
551
552 vertices[i] = PackedVertex{
553 px, py, pz,
554 nx, ny, nz,
555 u, v,
556 tx, ty, tz, tw,
557 u1, v1
558 };
559
560 minX = std::min(minX, px); minY = std::min(minY, py); minZ = std::min(minZ, pz);
561 maxX = std::max(maxX, px); maxY = std::max(maxY, py); maxZ = std::max(maxZ, pz);
562 }
563
564 // Build index buffer
565 std::vector<uint32_t> indices;
566 indices.reserve(static_cast<size_t>(aiM->mNumFaces) * 3);
567 for (unsigned int f = 0; f < aiM->mNumFaces; ++f) {
568 const aiFace& face = aiM->mFaces[f];
569 if (face.mNumIndices == 3) {
570 if (config.flipWinding) {
571 indices.push_back(face.mIndices[0]);
572 indices.push_back(face.mIndices[2]);
573 indices.push_back(face.mIndices[1]);
574 } else {
575 indices.push_back(face.mIndices[0]);
576 indices.push_back(face.mIndices[1]);
577 indices.push_back(face.mIndices[2]);
578 }
579 }
580 }
581
582 if (indices.empty()) return nullptr;
583
584 // Generate tangents via Lengyel if Assimp didn't provide them and UVs exist
585 if (!hasTangents && hasUVs && config.generateTangents) {
586 generateTangents(vertices, indices);
587 }
588
589 // Create GPU vertex buffer
590 const int vCount = static_cast<int>(vertices.size());
591 std::vector<uint8_t> vertexBytes(vertices.size() * sizeof(PackedVertex));
592 std::memcpy(vertexBytes.data(), vertices.data(), vertexBytes.size());
593
594 VertexBufferOptions vbOpts;
595 vbOpts.usage = BUFFER_STATIC;
596 vbOpts.data = std::move(vertexBytes);
597 auto vb = device->createVertexBuffer(vertexFormat, vCount, vbOpts);
598
599 // Create GPU index buffer (uint16 for small, uint32 for large)
600 const int indexCount = static_cast<int>(indices.size());
601 IndexFormat idxFmt = (vCount <= 65535) ? INDEXFORMAT_UINT16 : INDEXFORMAT_UINT32;
602
603 std::vector<uint8_t> indexBytes;
604 if (idxFmt == INDEXFORMAT_UINT16) {
605 indexBytes.resize(indices.size() * sizeof(uint16_t));
606 auto* dst = reinterpret_cast<uint16_t*>(indexBytes.data());
607 for (size_t i = 0; i < indices.size(); ++i) {
608 dst[i] = static_cast<uint16_t>(indices[i]);
609 }
610 } else {
611 indexBytes.resize(indices.size() * sizeof(uint32_t));
612 std::memcpy(indexBytes.data(), indices.data(), indexBytes.size());
613 }
614 auto ib = device->createIndexBuffer(idxFmt, indexCount, indexBytes);
615
616 // Create Mesh
617 auto meshResource = std::make_shared<Mesh>();
618 meshResource->setVertexBuffer(vb);
619 meshResource->setIndexBuffer(ib, 0);
620
621 Primitive prim;
623 prim.base = 0;
624 prim.baseVertex = 0;
625 prim.count = indexCount;
626 prim.indexed = true;
627 meshResource->setPrimitive(prim, 0);
628
629 // Use Assimp's pre-computed AABB if valid, otherwise use our computed bounds
630 if (aiM->mAABB.mMin.x <= aiM->mAABB.mMax.x &&
631 aiM->mAABB.mMin.y <= aiM->mAABB.mMax.y &&
632 aiM->mAABB.mMin.z <= aiM->mAABB.mMax.z &&
633 config.uniformScale == 1.0f && !config.flipYZ)
634 {
635 minX = aiM->mAABB.mMin.x; minY = aiM->mAABB.mMin.y; minZ = aiM->mAABB.mMin.z;
636 maxX = aiM->mAABB.mMax.x; maxY = aiM->mAABB.mMax.y; maxZ = aiM->mAABB.mMax.z;
637 }
638
639 BoundingBox bounds;
640 bounds.setCenter(
641 (minX + maxX) * 0.5f,
642 (minY + maxY) * 0.5f,
643 (minZ + maxZ) * 0.5f);
644 bounds.setHalfExtents(
645 (maxX - minX) * 0.5f,
646 (maxY - minY) * 0.5f,
647 (maxZ - minZ) * 0.5f);
648 meshResource->setAabb(bounds);
649
650 return meshResource;
651 }
652
653 } // anonymous namespace
654
655 // ── AssimpParser::parse ─────────────────────────────────────────────
656
657 std::unique_ptr<GlbContainerResource> AssimpParser::parse(
658 const std::string& path,
659 const std::shared_ptr<GraphicsDevice>& device,
660 const AssimpParserConfig& config)
661 {
662 if (!device) {
663 spdlog::error("Assimp parse failed: graphics device is null");
664 return nullptr;
665 }
666
667 // ── Configure importer ─────────────────────────────────────────
668
669 Assimp::Importer importer;
670 importer.SetPropertyFloat(AI_CONFIG_PP_GSN_MAX_SMOOTHING_ANGLE, config.smoothingAngle);
671 importer.SetPropertyFloat(AI_CONFIG_PP_CT_MAX_SMOOTHING_ANGLE, 45.0f);
672
673 if (config.uniformScale != 1.0f) {
674 importer.SetPropertyFloat(AI_CONFIG_GLOBAL_SCALE_FACTOR_KEY, config.uniformScale);
675 }
676
677 // ── Build post-processing flags ────────────────────────────────
678
679 unsigned int processFlags =
680 aiProcess_Triangulate |
681 aiProcess_GenSmoothNormals |
682 aiProcess_JoinIdenticalVertices |
683 aiProcess_FlipUVs |
684 aiProcess_ImproveCacheLocality |
685 aiProcess_RemoveRedundantMaterials |
686 aiProcess_FindDegenerates |
687 aiProcess_FindInvalidData |
688 aiProcess_GenUVCoords |
689 aiProcess_SortByPType |
690 aiProcess_GenBoundingBoxes;
691
692 if (config.generateTangents) {
693 processFlags |= aiProcess_CalcTangentSpace;
694 }
695 if (config.optimizeMeshes) {
696 processFlags |= aiProcess_OptimizeMeshes;
697 }
698 if (config.uniformScale != 1.0f) {
699 processFlags |= aiProcess_GlobalScale;
700 }
701
702 // ── Import ─────────────────────────────────────────────────────
703
704 const aiScene* scene = importer.ReadFile(path, processFlags);
705 if (!scene || (scene->mFlags & AI_SCENE_FLAGS_INCOMPLETE) || !scene->mRootNode) {
706 spdlog::error("Assimp parse failed [{}]: {}", path, importer.GetErrorString());
707 return nullptr;
708 }
709
710 const std::filesystem::path modelPath(path);
711 const std::filesystem::path basedir = modelPath.parent_path();
712
713 auto container = std::make_unique<GlbContainerResource>();
714 auto vertexFormat = std::make_shared<VertexFormat>(
715 static_cast<int>(sizeof(PackedVertex)), true, false);
716
717 // ── Convert materials ──────────────────────────────────────────
718
719 std::unordered_map<std::string, std::shared_ptr<Texture>> texCache;
720 std::vector<std::shared_ptr<Texture>> ownedTextures;
721 std::vector<std::shared_ptr<Material>> materials;
722
723 for (unsigned int i = 0; i < scene->mNumMaterials; ++i) {
724 const aiMaterial* aiMat = scene->mMaterials[i];
725 auto material = std::make_shared<StandardMaterial>();
726
727 // Name
728 aiString matName;
729 if (aiGetMaterialString(aiMat, AI_MATKEY_NAME, &matName) == AI_SUCCESS
730 && matName.length > 0) {
731 material->setName(matName.C_Str());
732 } else {
733 material->setName("assimp-material-" + std::to_string(i));
734 }
735
736 // Detect shading model
737 int shadingModel = 0;
738 aiGetMaterialInteger(aiMat, AI_MATKEY_SHADING_MODEL, &shadingModel);
739
740 if (shadingModel == aiShadingMode_PBR_BRDF) {
741 convertPbrMaterial(aiMat, scene, basedir, device.get(),
742 *material, texCache, ownedTextures);
743 } else {
744 convertLegacyMaterial(aiMat, scene, basedir, device.get(),
745 *material, texCache, ownedTextures);
746 }
747
748 // Double-sided
749 int twoSided = 0;
750 if (aiGetMaterialInteger(aiMat, AI_MATKEY_TWOSIDED, &twoSided) == AI_SUCCESS && twoSided) {
751 material->setCullMode(CullMode::CULLFACE_NONE);
752 }
753
754 // Shader variant key
755 material->setShaderVariantKey(computeShaderVariantKey(*material));
756
757 materials.push_back(material);
758 }
759
760 // Default material if scene has no materials
761 if (materials.empty()) {
762 auto mat = std::make_shared<StandardMaterial>();
763 mat->setName("assimp-default");
764 mat->setDiffuse(Color(0.8f, 0.8f, 0.8f, 1.0f));
765 mat->setBaseColorFactor(Color(0.8f, 0.8f, 0.8f, 1.0f));
766 mat->setMetalness(0.0f);
767 mat->setMetallicFactor(0.0f);
768 mat->setGloss(0.5f);
769 mat->setRoughnessFactor(0.5f);
770 mat->setUseMetalness(true);
771 mat->setShaderVariantKey(1);
772 materials.push_back(mat);
773 }
774
775 // ── Convert meshes ─────────────────────────────────────────────
776
777 // Map from aiScene mesh index to GlbMeshPayload index
778 std::vector<size_t> meshToPayloadIndex(scene->mNumMeshes, SIZE_MAX);
779 size_t nextPayloadIndex = 0;
780
781 for (unsigned int i = 0; i < scene->mNumMeshes; ++i) {
782 const aiMesh* aiM = scene->mMeshes[i];
783
784 // Skip non-triangle meshes (points/lines from aiProcess_SortByPType)
785 if (aiM->mPrimitiveTypes != aiPrimitiveType_TRIANGLE)
786 continue;
787
788 auto meshResource = convertAssimpMesh(aiM, vertexFormat, device, config);
789 if (!meshResource)
790 continue;
791
792 GlbMeshPayload payload;
793 payload.mesh = meshResource;
794 unsigned int matIdx = aiM->mMaterialIndex;
795 payload.material = (matIdx < materials.size())
796 ? materials[matIdx]
797 : materials[0];
798 container->addMeshPayload(payload);
799 meshToPayloadIndex[i] = nextPayloadIndex++;
800 }
801
802 // ── Build node hierarchy ───────────────────────────────────────
803
804 // Pre-order DFS to collect all nodes into a flat array
805 std::vector<const aiNode*> allNodes;
806 std::unordered_map<const aiNode*, int> nodeIndexMap;
807
808 std::function<void(const aiNode*)> collectNodes = [&](const aiNode* node) {
809 int idx = static_cast<int>(allNodes.size());
810 allNodes.push_back(node);
811 nodeIndexMap[node] = idx;
812 for (unsigned int c = 0; c < node->mNumChildren; ++c) {
813 collectNodes(node->mChildren[c]);
814 }
815 };
816 collectNodes(scene->mRootNode);
817
818 // Build GlbNodePayloads
819 for (size_t i = 0; i < allNodes.size(); ++i) {
820 const aiNode* node = allNodes[i];
821 GlbNodePayload nodePayload;
822 nodePayload.name = node->mName.C_Str();
823
824 // Decompose local transform
825 aiVector3D scaling, position;
826 aiQuaternion rotation;
827 node->mTransformation.Decompose(scaling, rotation, position);
828
829 nodePayload.translation = Vector3(position.x, position.y, position.z);
830 nodePayload.rotation = Quaternion(rotation.x, rotation.y, rotation.z, rotation.w);
831 nodePayload.scale = Vector3(scaling.x, scaling.y, scaling.z);
832
833 // Map node's meshes to payload indices
834 for (unsigned int m = 0; m < node->mNumMeshes; ++m) {
835 unsigned int meshIdx = node->mMeshes[m];
836 if (meshIdx < meshToPayloadIndex.size() && meshToPayloadIndex[meshIdx] != SIZE_MAX) {
837 nodePayload.meshPayloadIndices.push_back(meshToPayloadIndex[meshIdx]);
838 }
839 }
840
841 // Set children indices
842 for (unsigned int c = 0; c < node->mNumChildren; ++c) {
843 auto childIt = nodeIndexMap.find(node->mChildren[c]);
844 if (childIt != nodeIndexMap.end()) {
845 nodePayload.children.push_back(childIt->second);
846 }
847 }
848
849 container->addNodePayload(nodePayload);
850 }
851
852 // Root node is index 0
853 container->addRootNodeIndex(0);
854
855 // Transfer texture ownership
856 for (auto& tex : ownedTextures) {
857 container->addOwnedTexture(tex);
858 }
859
860 spdlog::info("Assimp parse complete [{}]: {} meshes, {} materials, {} nodes, {} textures",
861 path, nextPayloadIndex, materials.size(), allNodes.size(), ownedTextures.size());
862
863 return container;
864 }
865
866} // namespace visutwin::canvas
static std::unique_ptr< GlbContainerResource > parse(const std::string &path, const std::shared_ptr< GraphicsDevice > &device, const AssimpParserConfig &config=AssimpParserConfig{})
Axis-Aligned Bounding Box defined by center and half-extents.
Definition boundingBox.h:21
void setCenter(const Vector3 &center)
Definition boundingBox.h:28
Abstract GPU interface for resource creation, state management, and draw submission.
Base class for GPU materials — owns uniform data, texture bindings, blend/depth state,...
Definition material.h:143
Full PBR material with metalness/roughness workflow and advanced surface features.
GPU texture resource supporting 2D, cubemap, volume, and array formats with mipmap management.
Definition texture.h:57
@ PRIMITIVE_TRIANGLES
Definition mesh.h:23
Configuration options for Assimp-based model loading.
float uniformScale
Uniform scale applied to all vertex positions (e.g., 0.01 for cm -> m).
bool optimizeMeshes
Merge small meshes sharing the same material to reduce draw calls.
RGBA color with floating-point components in [0, 1].
Definition color.h:18
std::shared_ptr< Material > material
Describes how vertex and index data should be interpreted for a draw call.
Definition mesh.h:33
PrimitiveType type
Definition mesh.h:34
Unit quaternion for rotation representation with SIMD-accelerated slerp and multiply.
Definition quaternion.h:20
3D vector for positions, directions, and normals with multi-backend SIMD acceleration.
Definition vector3.h:29
Vector3 cross(const Vector3 &other) const
float dot(const Vector3 &other) const