VisuTwin Canvas
C++ 3D Engine — Metal Backend
Loading...
Searching...
No Matches
objParser.cpp
Go to the documentation of this file.
1// SPDX-License-Identifier: Apache-2.0
2// Copyright 2025-2026 Arnis Lektauers
3//
4// OBJ + MTL file parser for VisuTwin Canvas.
5//
6// Uses tinyobjloader (v2 ObjReader API) to parse Wavefront OBJ geometry and
7// companion MTL material definitions. Produces a GlbContainerResource with
8// the engine's standard 14-float interleaved vertex layout (matching GlbParser).
9//
10// Key design decisions:
11// - Reuses GlbContainerResource for unified instantiateRenderEntity() path
12// - Material-based sub-mesh splitting (one draw call per material per shape)
13// - Vertex deduplication via (position, normal, texcoord) index hash
14// - Phong-to-PBR material conversion for classic MTL files
15// - Smooth normal generation when OBJ has no normals
16// - Tangent generation from UV seams (Lengyel algorithm, same as GlbParser)
17//
18// Custom loader (not derived from upstream).
19//
20#include "objParser.h"
21
22#include <tiny_obj_loader.h>
23
24#include <algorithm>
25#include <cmath>
26#include <cstring>
27#include <filesystem>
28#include <limits>
29#include <unordered_map>
30#include <vector>
31
32#include "core/math/vector3.h"
33#include "core/math/vector4.h"
40#include "spdlog/spdlog.h"
41#include "stb_image.h"
42
43namespace visutwin::canvas
44{
45 namespace
46 {
47 // ── Vertex layout (must match GlbParser::PackedVertex) ──────────
48
49 struct PackedVertex
50 {
51 float px, py, pz; // position
52 float nx, ny, nz; // normal
53 float u, v; // uv0
54 float tx, ty, tz, tw; // tangent + handedness
55 float u1, v1; // uv1
56 };
57
58 static_assert(sizeof(PackedVertex) == 56, "PackedVertex must be 56 bytes (14 floats)");
59
60 // ── Vertex deduplication key ────────────────────────────────────
61
62 struct VertexKey
63 {
64 int vi, ni, ti; // position, normal, texcoord indices
65
66 bool operator==(const VertexKey& o) const
67 {
68 return vi == o.vi && ni == o.ni && ti == o.ti;
69 }
70 };
71
72 struct VertexKeyHash
73 {
74 size_t operator()(const VertexKey& k) const
75 {
76 size_t h = std::hash<int>()(k.vi);
77 h ^= std::hash<int>()(k.ni) + 0x9e3779b9 + (h << 6) + (h >> 2);
78 h ^= std::hash<int>()(k.ti) + 0x9e3779b9 + (h << 6) + (h >> 2);
79 return h;
80 }
81 };
82
83 // ── Tangent generation (same algorithm as GlbParser) ────────────
84
85 void generateTangents(std::vector<PackedVertex>& vertices, const std::vector<uint32_t>& indices)
86 {
87 const size_t vertexCount = vertices.size();
88 if (vertexCount == 0) return;
89
90 std::vector<Vector3> tan1(vertexCount, Vector3(0.0f, 0.0f, 0.0f));
91 std::vector<Vector3> tan2(vertexCount, Vector3(0.0f, 0.0f, 0.0f));
92
93 auto accumulateTriangle = [&](uint32_t i0, uint32_t i1, uint32_t i2) {
94 if (i0 >= vertexCount || i1 >= vertexCount || i2 >= vertexCount) return;
95
96 const auto& v0 = vertices[i0];
97 const auto& v1 = vertices[i1];
98 const auto& v2 = vertices[i2];
99
100 const float du1 = v1.u - v0.u;
101 const float dv1 = v1.v - v0.v;
102 const float du2 = v2.u - v0.u;
103 const float dv2 = v2.v - v0.v;
104
105 const float det = du1 * dv2 - dv1 * du2;
106 if (std::abs(det) <= 1e-8f) return;
107
108 const float invDet = 1.0f / det;
109 const Vector3 e1(v1.px - v0.px, v1.py - v0.py, v1.pz - v0.pz);
110 const Vector3 e2(v2.px - v0.px, v2.py - v0.py, v2.pz - v0.pz);
111
112 const Vector3 sdir = (e1 * dv2 - e2 * dv1) * invDet;
113 const Vector3 tdir = (e2 * du1 - e1 * du2) * invDet;
114
115 tan1[i0] += sdir; tan1[i1] += sdir; tan1[i2] += sdir;
116 tan2[i0] += tdir; tan2[i1] += tdir; tan2[i2] += tdir;
117 };
118
119 if (!indices.empty()) {
120 for (size_t i = 0; i + 2 < indices.size(); i += 3)
121 accumulateTriangle(indices[i], indices[i + 1], indices[i + 2]);
122 } else {
123 for (uint32_t i = 0; i + 2 < static_cast<uint32_t>(vertexCount); i += 3)
124 accumulateTriangle(i, i + 1, i + 2);
125 }
126
127 for (size_t i = 0; i < vertexCount; ++i) {
128 const Vector3 n(vertices[i].nx, vertices[i].ny, vertices[i].nz);
129 Vector3 t = tan1[i] - n * n.dot(tan1[i]);
130 if (t.lengthSquared() <= 1e-8f) {
131 t = std::abs(n.getY()) < 0.999f
132 ? n.cross(Vector3(0.0f, 1.0f, 0.0f))
133 : n.cross(Vector3(1.0f, 0.0f, 0.0f));
134 }
135 t = t.normalized();
136
137 const float handedness = (n.cross(t).dot(tan2[i]) < 0.0f) ? -1.0f : 1.0f;
138
139 vertices[i].tx = t.getX();
140 vertices[i].ty = t.getY();
141 vertices[i].tz = t.getZ();
142 vertices[i].tw = handedness;
143 }
144 }
145
146 // ── Tangent-from-normal fallback (no UVs available) ─────────────
147
148 void tangentFromNormal(float nx, float ny, float nz,
149 float& tx, float& ty, float& tz, float& tw)
150 {
151 // Gram-Schmidt cross product to get an arbitrary tangent perpendicular to normal
152 Vector3 n(nx, ny, nz);
153 Vector3 up = std::abs(ny) < 0.999f ? Vector3(0.0f, 1.0f, 0.0f) : Vector3(1.0f, 0.0f, 0.0f);
154 Vector3 t = n.cross(up).normalized();
155 tx = t.getX();
156 ty = t.getY();
157 tz = t.getZ();
158 tw = 1.0f;
159 }
160
161 // ── Smooth normal generation ────────────────────────────────────
162
163 void generateSmoothNormals(
164 const tinyobj::attrib_t& attrib,
165 const tinyobj::mesh_t& mesh,
166 std::vector<float>& outNormals) // 3 floats per face-vertex (same count as mesh.indices)
167 {
168 const size_t totalFaceVertices = mesh.indices.size();
169 outNormals.resize(totalFaceVertices * 3, 0.0f);
170
171 // Accumulate area-weighted face normals per vertex per smoothing group
172 // Key: (vertex_index, smoothing_group) -> accumulated normal
173 struct SmoothKey {
174 int vertexIndex;
175 unsigned int smoothingGroup;
176 bool operator==(const SmoothKey& o) const {
177 return vertexIndex == o.vertexIndex && smoothingGroup == o.smoothingGroup;
178 }
179 };
180 struct SmoothKeyHash {
181 size_t operator()(const SmoothKey& k) const {
182 size_t h = std::hash<int>()(k.vertexIndex);
183 h ^= std::hash<unsigned int>()(k.smoothingGroup) + 0x9e3779b9 + (h << 6) + (h >> 2);
184 return h;
185 }
186 };
187
188 std::unordered_map<SmoothKey, Vector3, SmoothKeyHash> accum;
189
190 // Pre-compute face offsets for O(1) access
191 const size_t faceCount = mesh.num_face_vertices.size();
192 std::vector<size_t> faceOffsets(faceCount);
193 {
194 size_t offset = 0;
195 for (size_t f = 0; f < faceCount; ++f) {
196 faceOffsets[f] = offset;
197 offset += mesh.num_face_vertices[f];
198 }
199 }
200
201 // Pass 1: accumulate face normals
202 for (size_t f = 0; f < faceCount; ++f) {
203 const unsigned int fv = mesh.num_face_vertices[f];
204 if (fv < 3) continue;
205
206 const size_t base = faceOffsets[f];
207 const unsigned int sg = (f < mesh.smoothing_group_ids.size())
208 ? mesh.smoothing_group_ids[f] : 0;
209
210 // Compute face normal from first 3 vertices
211 const auto& i0 = mesh.indices[base + 0];
212 const auto& i1 = mesh.indices[base + 1];
213 const auto& i2 = mesh.indices[base + 2];
214
215 Vector3 p0(attrib.vertices[3 * i0.vertex_index + 0],
216 attrib.vertices[3 * i0.vertex_index + 1],
217 attrib.vertices[3 * i0.vertex_index + 2]);
218 Vector3 p1(attrib.vertices[3 * i1.vertex_index + 0],
219 attrib.vertices[3 * i1.vertex_index + 1],
220 attrib.vertices[3 * i1.vertex_index + 2]);
221 Vector3 p2(attrib.vertices[3 * i2.vertex_index + 0],
222 attrib.vertices[3 * i2.vertex_index + 1],
223 attrib.vertices[3 * i2.vertex_index + 2]);
224
225 Vector3 faceNormal = (p1 - p0).cross(p2 - p0);
226 // Area-weighted (un-normalized cross product magnitude ~ 2x triangle area)
227
228 if (sg == 0) {
229 // Flat shading: assign face normal directly to each vertex
230 float len = faceNormal.length();
231 if (len > 1e-8f) {
232 faceNormal = faceNormal * (1.0f / len);
233 } else {
234 faceNormal = Vector3(0.0f, 1.0f, 0.0f);
235 }
236 for (unsigned int j = 0; j < fv; ++j) {
237 outNormals[(base + j) * 3 + 0] = faceNormal.getX();
238 outNormals[(base + j) * 3 + 1] = faceNormal.getY();
239 outNormals[(base + j) * 3 + 2] = faceNormal.getZ();
240 }
241 } else {
242 // Smooth shading: accumulate for later normalization
243 for (unsigned int j = 0; j < fv; ++j) {
244 int vi = mesh.indices[base + j].vertex_index;
245 SmoothKey key{vi, sg};
246 accum[key] += faceNormal;
247 }
248 }
249 }
250
251 // Pass 2: normalize accumulated normals and write to output
252 for (size_t f = 0; f < faceCount; ++f) {
253 const unsigned int fv = mesh.num_face_vertices[f];
254 const size_t base = faceOffsets[f];
255 const unsigned int sg = (f < mesh.smoothing_group_ids.size())
256 ? mesh.smoothing_group_ids[f] : 0;
257
258 if (sg == 0) continue; // already written in pass 1
259
260 for (unsigned int j = 0; j < fv; ++j) {
261 int vi = mesh.indices[base + j].vertex_index;
262 SmoothKey key{vi, sg};
263 auto it = accum.find(key);
264 if (it != accum.end()) {
265 Vector3 n = it->second.normalized();
266 outNormals[(base + j) * 3 + 0] = n.getX();
267 outNormals[(base + j) * 3 + 1] = n.getY();
268 outNormals[(base + j) * 3 + 2] = n.getZ();
269 } else {
270 outNormals[(base + j) * 3 + 0] = 0.0f;
271 outNormals[(base + j) * 3 + 1] = 1.0f;
272 outNormals[(base + j) * 3 + 2] = 0.0f;
273 }
274 }
275 }
276 }
277
278 // ── Texture loading ─────────────────────────────────────────────
279
280 std::shared_ptr<Texture> loadObjTexture(
281 GraphicsDevice* device,
282 const std::string& texname,
283 const std::filesystem::path& basedir,
284 std::unordered_map<std::string, std::shared_ptr<Texture>>& cache)
285 {
286 if (texname.empty()) return nullptr;
287
288 auto it = cache.find(texname);
289 if (it != cache.end()) return it->second;
290
291 auto texPath = basedir / texname;
292 if (!std::filesystem::exists(texPath)) {
293 spdlog::warn("OBJ texture not found: {}", texPath.string());
294 cache[texname] = nullptr;
295 return nullptr;
296 }
297
298 // OBJ textures typically use bottom-left origin (OpenGL convention)
299 stbi_set_flip_vertically_on_load(true);
300 int w, h, comp;
301 stbi_uc* pixels = stbi_load(texPath.string().c_str(), &w, &h, &comp, 4);
302 if (!pixels) {
303 spdlog::warn("OBJ texture decode failed: {}", texPath.string());
304 cache[texname] = nullptr;
305 return nullptr;
306 }
307
308 TextureOptions opts;
309 opts.width = static_cast<uint32_t>(w);
310 opts.height = static_cast<uint32_t>(h);
311 opts.format = PixelFormat::PIXELFORMAT_RGBA8;
312 opts.mipmaps = false;
313 opts.numLevels = 1;
314 opts.minFilter = FilterMode::FILTER_LINEAR;
315 opts.magFilter = FilterMode::FILTER_LINEAR;
316 opts.name = texname;
317
318 auto texture = std::make_shared<Texture>(device, opts);
319 const size_t dataSize = static_cast<size_t>(w) * h * 4;
320 texture->setLevelData(0, pixels, dataSize);
321 stbi_image_free(pixels);
322 texture->upload();
323
324 spdlog::info("OBJ texture loaded: {} ({}x{})", texname, w, h);
325 cache[texname] = texture;
326 return texture;
327 }
328
329 // ── Phong-to-PBR material conversion ────────────────────────────
330
331 float roughnessFromShininess(float ns)
332 {
333 ns = std::clamp(ns, 0.0f, 1000.0f);
334 return 1.0f - std::sqrt(ns / 1000.0f);
335 }
336
337 bool hasPbrData(const tinyobj::material_t& mat)
338 {
339 return mat.roughness > 0.0f || mat.metallic > 0.0f ||
340 !mat.roughness_texname.empty() || !mat.metallic_texname.empty() ||
341 !mat.normal_texname.empty();
342 }
343
344 std::shared_ptr<StandardMaterial> convertMtlMaterial(
345 const tinyobj::material_t& mtl,
346 GraphicsDevice* device,
347 const std::filesystem::path& basedir,
348 std::unordered_map<std::string, std::shared_ptr<Texture>>& texCache,
349 std::vector<std::shared_ptr<Texture>>& ownedTextures)
350 {
351 auto material = std::make_shared<StandardMaterial>();
352 material->setName(mtl.name.empty() ? "obj-material" : mtl.name);
353
354 const bool usePbr = hasPbrData(mtl);
355
356 if (usePbr) {
357 // Direct PBR path
358 Color baseColor(mtl.diffuse[0], mtl.diffuse[1], mtl.diffuse[2], mtl.dissolve);
359 material->setDiffuse(baseColor);
360 material->setBaseColorFactor(baseColor);
361 material->setMetalness(mtl.metallic);
362 material->setMetallicFactor(mtl.metallic);
363 material->setGloss(1.0f - mtl.roughness);
364 material->setRoughnessFactor(mtl.roughness);
365 } else {
366 // Phong -> PBR conversion
367 Color diffuse(mtl.diffuse[0], mtl.diffuse[1], mtl.diffuse[2], mtl.dissolve);
368 material->setDiffuse(diffuse);
369 material->setBaseColorFactor(diffuse);
370 material->setSpecular(Color(mtl.specular[0], mtl.specular[1], mtl.specular[2], 1.0f));
371
372 float roughness = roughnessFromShininess(mtl.shininess);
373 material->setGloss(1.0f - roughness);
374 material->setRoughnessFactor(roughness);
375
376 // Metallic heuristic: if diffuse is very dark and specular is bright + colored -> metallic
377 float kdLum = 0.2126f * mtl.diffuse[0] + 0.7152f * mtl.diffuse[1] + 0.0722f * mtl.diffuse[2];
378 float ksLum = 0.2126f * mtl.specular[0] + 0.7152f * mtl.specular[1] + 0.0722f * mtl.specular[2];
379 float metallic = 0.0f;
380 if (kdLum < 0.04f && ksLum > 0.5f) {
381 metallic = 1.0f;
382 } else if (ksLum > 0.25f) {
383 float ksMax = std::max({mtl.specular[0], mtl.specular[1], mtl.specular[2]});
384 float ksMin = std::min({mtl.specular[0], mtl.specular[1], mtl.specular[2]});
385 float sat = (ksMax > 0.001f) ? (ksMax - ksMin) / ksMax : 0.0f;
386 if (sat > 0.2f) metallic = 0.8f;
387 }
388 material->setMetalness(metallic);
389 material->setMetallicFactor(metallic);
390 }
391
392 material->setUseMetalness(true);
393 material->setOpacity(mtl.dissolve);
394
395 // Emissive
396 if (mtl.emission[0] > 0.0f || mtl.emission[1] > 0.0f || mtl.emission[2] > 0.0f) {
397 material->setEmissive(Color(mtl.emission[0], mtl.emission[1], mtl.emission[2], 1.0f));
398 material->setEmissiveFactor(Color(mtl.emission[0], mtl.emission[1], mtl.emission[2], 1.0f));
399 }
400
401 // Alpha mode
402 if (mtl.dissolve < 0.99f) {
403 material->setAlphaMode(AlphaMode::BLEND);
404 material->setTransparent(true);
405 }
406
407 material->setCullMode(CullMode::CULLFACE_BACK);
408
409 // ── Texture loading ─────────────────────────────────────────
410
411 auto loadAndOwn = [&](const std::string& name) -> Texture* {
412 auto tex = loadObjTexture(device, name, basedir, texCache);
413 if (tex) ownedTextures.push_back(tex);
414 return tex.get();
415 };
416
417 // Diffuse / base color map
418 if (auto* tex = loadAndOwn(mtl.diffuse_texname)) {
419 material->setDiffuseMap(tex);
420 material->setBaseColorTexture(tex);
421 material->setHasBaseColorTexture(true);
422 }
423
424 // Normal map (prefer PBR norm, fall back to bump)
425 std::string normalTexName = mtl.normal_texname.empty() ? mtl.bump_texname : mtl.normal_texname;
426 if (auto* tex = loadAndOwn(normalTexName)) {
427 material->setNormalMap(tex);
428 material->setNormalTexture(tex);
429 material->setHasNormalTexture(true);
430 if (!mtl.bump_texname.empty() && mtl.normal_texname.empty()) {
431 material->setBumpiness(mtl.bump_texopt.bump_multiplier);
432 material->setNormalScale(mtl.bump_texopt.bump_multiplier);
433 }
434 }
435
436 // AO map (map_Ka used as ambient occlusion)
437 if (auto* tex = loadAndOwn(mtl.ambient_texname)) {
438 material->setAoMap(tex);
439 material->setOcclusionTexture(tex);
440 material->setHasOcclusionTexture(true);
441 }
442
443 // Emissive map
444 if (auto* tex = loadAndOwn(mtl.emissive_texname)) {
445 material->setEmissiveMap(tex);
446 material->setEmissiveTexture(tex);
447 material->setHasEmissiveTexture(true);
448 }
449
450 // Opacity map
451 if (auto* tex = loadAndOwn(mtl.alpha_texname)) {
452 material->setOpacityMap(tex);
453 material->setAlphaMode(AlphaMode::MASK);
454 }
455
456 return material;
457 }
458
459 } // anonymous namespace
460
461 // ── ObjParser::parse ────────────────────────────────────────────────
462
463 std::unique_ptr<GlbContainerResource> ObjParser::parse(
464 const std::string& path,
465 const std::shared_ptr<GraphicsDevice>& device,
466 const ObjParserConfig& config)
467 {
468 if (!device) {
469 spdlog::error("OBJ parse failed: graphics device is null");
470 return nullptr;
471 }
472
473 // Resolve base directory for MTL and texture file lookup
474 const std::filesystem::path objPath(path);
475 const std::filesystem::path basedir = config.mtlSearchPath.empty()
476 ? objPath.parent_path()
477 : std::filesystem::path(config.mtlSearchPath);
478
479 // Parse with tinyobjloader v2 API
480 tinyobj::ObjReaderConfig readerConfig;
481 readerConfig.triangulate = true;
482 readerConfig.vertex_color = true;
483 readerConfig.mtl_search_path = basedir.string();
484
485 tinyobj::ObjReader reader;
486 if (!reader.ParseFromFile(path, readerConfig)) {
487 spdlog::error("OBJ parse failed [{}]: {}", path, reader.Error());
488 return nullptr;
489 }
490 if (!reader.Warning().empty()) {
491 spdlog::warn("OBJ parse warning [{}]: {}", path, reader.Warning());
492 }
493
494 const auto& attrib = reader.GetAttrib();
495 const auto& shapes = reader.GetShapes();
496 const auto& materials = reader.GetMaterials();
497
498 const bool hasNormals = !attrib.normals.empty();
499 const bool hasTexcoords = !attrib.texcoords.empty();
500
501 spdlog::info("OBJ loaded [{}]: {} vertices, {} normals, {} texcoords, "
502 "{} shapes, {} materials",
503 path,
504 attrib.vertices.size() / 3,
505 attrib.normals.size() / 3,
506 attrib.texcoords.size() / 2,
507 shapes.size(),
508 materials.size());
509
510 // ── Convert materials ───────────────────────────────────────────
511
512 auto container = std::make_unique<GlbContainerResource>();
513 std::unordered_map<std::string, std::shared_ptr<Texture>> texCache;
514 std::vector<std::shared_ptr<Texture>> ownedTextures;
515
516 std::vector<std::shared_ptr<Material>> objMaterials;
517 if (materials.empty()) {
518 // Default gray material when no MTL file
519 auto mat = std::make_shared<StandardMaterial>();
520 mat->setName("obj-default");
521 mat->setDiffuse(Color(0.8f, 0.8f, 0.8f, 1.0f));
522 mat->setBaseColorFactor(Color(0.8f, 0.8f, 0.8f, 1.0f));
523 mat->setMetalness(0.0f);
524 mat->setMetallicFactor(0.0f);
525 mat->setGloss(0.5f);
526 mat->setRoughnessFactor(0.5f);
527 mat->setUseMetalness(true);
528 objMaterials.push_back(mat);
529 } else {
530 for (const auto& mtl : materials) {
531 objMaterials.push_back(
532 convertMtlMaterial(mtl, device.get(), basedir, texCache, ownedTextures));
533 }
534 }
535
536 // ── Process shapes ──────────────────────────────────────────────
537
538 constexpr int BYTES_PER_VERTEX = static_cast<int>(sizeof(PackedVertex)); // 56
539 auto vertexFormat = std::make_shared<VertexFormat>(BYTES_PER_VERTEX, true, false);
540
541 size_t meshPayloadIndex = 0;
542
543 for (size_t shapeIdx = 0; shapeIdx < shapes.size(); ++shapeIdx) {
544 const auto& shape = shapes[shapeIdx];
545 const auto& mesh = shape.mesh;
546
547 // Pre-compute face offsets (prefix sum) for O(1) access
548 const size_t faceCount = mesh.num_face_vertices.size();
549 std::vector<size_t> faceOffsets(faceCount);
550 {
551 size_t offset = 0;
552 for (size_t f = 0; f < faceCount; ++f) {
553 faceOffsets[f] = offset;
554 offset += mesh.num_face_vertices[f];
555 }
556 }
557
558 // Generate smooth normals if OBJ has none
559 std::vector<float> generatedNormals;
560 if (!hasNormals && config.generateNormals) {
561 generateSmoothNormals(attrib, mesh, generatedNormals);
562 }
563
564 // Group faces by material_id
565 std::unordered_map<int, std::vector<size_t>> materialFaceGroups;
566 for (size_t f = 0; f < faceCount; ++f) {
567 int matId = (f < mesh.material_ids.size()) ? mesh.material_ids[f] : -1;
568 if (matId < 0 || matId >= static_cast<int>(objMaterials.size())) matId = 0;
569 materialFaceGroups[matId].push_back(f);
570 }
571
572 // For each material group, build vertex/index buffers
573 for (auto& [matId, faceIndices] : materialFaceGroups) {
574 std::unordered_map<VertexKey, uint32_t, VertexKeyHash> vertexMap;
575 std::vector<PackedVertex> vertices;
576 std::vector<uint32_t> indices;
577 float minX = std::numeric_limits<float>::max();
578 float minY = std::numeric_limits<float>::max();
579 float minZ = std::numeric_limits<float>::max();
580 float maxX = std::numeric_limits<float>::lowest();
581 float maxY = std::numeric_limits<float>::lowest();
582 float maxZ = std::numeric_limits<float>::lowest();
583
584 for (size_t fi : faceIndices) {
585 const size_t base = faceOffsets[fi];
586 const unsigned int fv = mesh.num_face_vertices[fi];
587
588 for (unsigned int j = 0; j < fv; ++j) {
589 const size_t idx = base + j;
590 const auto& objIdx = mesh.indices[idx];
591
592 // Position
593 float px = attrib.vertices[3 * objIdx.vertex_index + 0];
594 float py = attrib.vertices[3 * objIdx.vertex_index + 1];
595 float pz = attrib.vertices[3 * objIdx.vertex_index + 2];
596
597 // Apply config transforms
598 px *= config.uniformScale;
599 py *= config.uniformScale;
600 pz *= config.uniformScale;
601 if (config.flipYZ) {
602 std::swap(py, pz);
603 pz = -pz;
604 }
605
606 // Normal
607 float nx, ny, nz;
608 if (hasNormals && objIdx.normal_index >= 0) {
609 nx = attrib.normals[3 * objIdx.normal_index + 0];
610 ny = attrib.normals[3 * objIdx.normal_index + 1];
611 nz = attrib.normals[3 * objIdx.normal_index + 2];
612 if (config.flipYZ) {
613 std::swap(ny, nz);
614 nz = -nz;
615 }
616 // Re-normalize
617 float len = std::sqrt(nx * nx + ny * ny + nz * nz);
618 if (len > 1e-8f) {
619 float inv = 1.0f / len;
620 nx *= inv; ny *= inv; nz *= inv;
621 }
622 } else if (!generatedNormals.empty()) {
623 nx = generatedNormals[idx * 3 + 0];
624 ny = generatedNormals[idx * 3 + 1];
625 nz = generatedNormals[idx * 3 + 2];
626 if (config.flipYZ) {
627 std::swap(ny, nz);
628 nz = -nz;
629 }
630 } else {
631 nx = 0.0f; ny = 1.0f; nz = 0.0f;
632 }
633
634 // Texcoord
635 float u = 0.0f, vt = 0.0f;
636 if (hasTexcoords && objIdx.texcoord_index >= 0) {
637 u = attrib.texcoords[2 * objIdx.texcoord_index + 0];
638 vt = attrib.texcoords[2 * objIdx.texcoord_index + 1];
639 // Metal uses top-left origin; OBJ uses bottom-left
640 vt = 1.0f - vt;
641 }
642
643 // Vertex deduplication
644 VertexKey key{objIdx.vertex_index, objIdx.normal_index, objIdx.texcoord_index};
645 auto it = vertexMap.find(key);
646 if (it != vertexMap.end()) {
647 indices.push_back(it->second);
648 } else {
649 uint32_t newIdx = static_cast<uint32_t>(vertices.size());
650
651 // Tangent placeholder (will be overwritten by generateTangents if UVs exist)
652 float tx, ty, tz, tw;
653 tangentFromNormal(nx, ny, nz, tx, ty, tz, tw);
654
655 PackedVertex vert{};
656 vert.px = px; vert.py = py; vert.pz = pz;
657 vert.nx = nx; vert.ny = ny; vert.nz = nz;
658 vert.u = u; vert.v = vt;
659 vert.tx = tx; vert.ty = ty; vert.tz = tz; vert.tw = tw;
660 vert.u1 = u; vert.v1 = vt; // UV1 = UV0 for OBJ
661
662 vertices.push_back(vert);
663 vertexMap[key] = newIdx;
664 indices.push_back(newIdx);
665
666 minX = std::min(minX, px); minY = std::min(minY, py); minZ = std::min(minZ, pz);
667 maxX = std::max(maxX, px); maxY = std::max(maxY, py); maxZ = std::max(maxZ, pz);
668 }
669 }
670
671 // Flip winding if requested (triangulated, so always 3 verts)
672 if (config.flipWinding && fv == 3) {
673 size_t last = indices.size();
674 std::swap(indices[last - 2], indices[last - 1]);
675 }
676 }
677
678 if (vertices.empty()) continue;
679
680 // Generate proper tangents from UVs if available
681 if (hasTexcoords && config.generateTangents) {
682 generateTangents(vertices, indices);
683 }
684
685 // ── Create GPU buffers ──────────────────────────────────
686
687 const int vertexCount = static_cast<int>(vertices.size());
688 std::vector<uint8_t> vertexBytes(vertices.size() * sizeof(PackedVertex));
689 std::memcpy(vertexBytes.data(), vertices.data(), vertexBytes.size());
690
691 VertexBufferOptions vbOpts;
692 vbOpts.usage = BUFFER_STATIC;
693 vbOpts.data = std::move(vertexBytes);
694 auto vb = device->createVertexBuffer(vertexFormat, vertexCount, vbOpts);
695
696 const int indexCount = static_cast<int>(indices.size());
697 // Use uint16 for small meshes, uint32 for large
698 IndexFormat idxFmt = (vertexCount <= 65535) ? INDEXFORMAT_UINT16 : INDEXFORMAT_UINT32;
699
700 std::vector<uint8_t> indexBytes;
701 if (idxFmt == INDEXFORMAT_UINT16) {
702 indexBytes.resize(indices.size() * sizeof(uint16_t));
703 auto* dst = reinterpret_cast<uint16_t*>(indexBytes.data());
704 for (size_t i = 0; i < indices.size(); ++i) {
705 dst[i] = static_cast<uint16_t>(indices[i]);
706 }
707 } else {
708 indexBytes.resize(indices.size() * sizeof(uint32_t));
709 std::memcpy(indexBytes.data(), indices.data(), indexBytes.size());
710 }
711 auto ib = device->createIndexBuffer(idxFmt, indexCount, indexBytes);
712
713 auto meshResource = std::make_shared<Mesh>();
714 meshResource->setVertexBuffer(vb);
715 meshResource->setIndexBuffer(ib, 0);
716
717 Primitive prim;
719 prim.base = 0;
720 prim.baseVertex = 0;
721 prim.count = indexCount;
722 prim.indexed = true;
723 meshResource->setPrimitive(prim, 0);
724
725 BoundingBox bounds;
726 bounds.setCenter(
727 (minX + maxX) * 0.5f,
728 (minY + maxY) * 0.5f,
729 (minZ + maxZ) * 0.5f);
730 bounds.setHalfExtents(
731 (maxX - minX) * 0.5f,
732 (maxY - minY) * 0.5f,
733 (maxZ - minZ) * 0.5f);
734 meshResource->setAabb(bounds);
735
736 // ── Add payload ─────────────────────────────────────────
737
738 GlbMeshPayload payload;
739 payload.mesh = meshResource;
740 int safeMat = std::clamp(matId, 0, static_cast<int>(objMaterials.size()) - 1);
741 payload.material = objMaterials[static_cast<size_t>(safeMat)];
742 container->addMeshPayload(payload);
743 ++meshPayloadIndex;
744 }
745 }
746
747 // ── Build node structure ────────────────────────────────────────
748 // OBJ is flat (no hierarchy), so create one node per shape with all
749 // its material sub-meshes, or a single root node if only one shape.
750
751 if (shapes.size() == 1) {
752 // Single shape: all mesh payloads go into one root node
753 GlbNodePayload rootNode;
754 rootNode.name = objPath.stem().string();
755 rootNode.scale = Vector3(1.0f, 1.0f, 1.0f);
756 for (size_t i = 0; i < meshPayloadIndex; ++i) {
757 rootNode.meshPayloadIndices.push_back(i);
758 }
759 container->addNodePayload(rootNode);
760 container->addRootNodeIndex(0);
761 } else {
762 // Multiple shapes: one node per shape, with mesh indices distributed
763 size_t payloadCursor = 0;
764 for (size_t shapeIdx = 0; shapeIdx < shapes.size(); ++shapeIdx) {
765 const auto& shape = shapes[shapeIdx];
766 const auto& mesh = shape.mesh;
767
768 // Count how many material groups this shape has
769 std::unordered_map<int, bool> matGroups;
770 for (size_t f = 0; f < mesh.num_face_vertices.size(); ++f) {
771 int matId = (f < mesh.material_ids.size()) ? mesh.material_ids[f] : 0;
772 if (matId < 0 || matId >= static_cast<int>(objMaterials.size())) matId = 0;
773 matGroups[matId] = true;
774 }
775 size_t groupCount = matGroups.size();
776
777 GlbNodePayload node;
778 node.name = shape.name.empty()
779 ? "shape_" + std::to_string(shapeIdx)
780 : shape.name;
781 node.scale = Vector3(1.0f, 1.0f, 1.0f);
782 for (size_t i = 0; i < groupCount && payloadCursor < meshPayloadIndex; ++i) {
783 node.meshPayloadIndices.push_back(payloadCursor++);
784 }
785 container->addNodePayload(node);
786 container->addRootNodeIndex(static_cast<int>(shapeIdx));
787 }
788 }
789
790 // Transfer texture ownership
791 for (auto& tex : ownedTextures) {
792 container->addOwnedTexture(tex);
793 }
794
795 spdlog::info("OBJ parse complete [{}]: {} mesh payloads, {} materials, {} textures",
796 path, meshPayloadIndex, objMaterials.size(), ownedTextures.size());
797
798 return container;
799 }
800
801} // namespace visutwin::canvas
Axis-Aligned Bounding Box defined by center and half-extents.
Definition boundingBox.h:21
void setHalfExtents(const Vector3 &halfExtents)
Definition boundingBox.h:36
void setCenter(const Vector3 &center)
Definition boundingBox.h:28
Abstract GPU interface for resource creation, state management, and draw submission.
static std::unique_ptr< GlbContainerResource > parse(const std::string &path, const std::shared_ptr< GraphicsDevice > &device, const ObjParserConfig &config=ObjParserConfig{})
GPU texture resource supporting 2D, cubemap, volume, and array formats with mipmap management.
Definition texture.h:57
@ PRIMITIVE_TRIANGLES
Definition mesh.h:23
RGBA color with floating-point components in [0, 1].
Definition color.h:18
std::shared_ptr< Material > material
Configuration options for OBJ loading.
Definition objParser.h:26
bool flipYZ
If true, swap Y and Z axes (Z-up CAD -> Y-up engine) and negate new Z.
Definition objParser.h:31
float uniformScale
Uniform scale applied to all vertex positions (e.g., 0.001 for mm -> m).
Definition objParser.h:28
bool generateNormals
Generate smooth normals when the OBJ file has no normals.
Definition objParser.h:37
bool flipWinding
If true, reverse face winding order.
Definition objParser.h:34
bool generateTangents
Generate tangents for normal mapping.
Definition objParser.h:40
std::string mtlSearchPath
Override MTL search path (empty = same directory as .obj file).
Definition objParser.h:43
Describes how vertex and index data should be interpreted for a draw call.
Definition mesh.h:33
PrimitiveType type
Definition mesh.h:34
3D vector for positions, directions, and normals with multi-backend SIMD acceleration.
Definition vector3.h:29
Vector3 cross(const Vector3 &other) const
Vector3 normalized() const
float dot(const Vector3 &other) const