VisuTwin Canvas
C++ 3D Engine — Metal Backend
Loading...
Searching...
No Matches
glbParser.cpp
Go to the documentation of this file.
1// SPDX-License-Identifier: Apache-2.0
2// Copyright 2025-2026 Arnis Lektauers
3//
4// Created by Arnis Lektauers on 09.02.2026.
5//
6#define TINYGLTF_IMPLEMENTATION
7#define TINYGLTF_NO_STB_IMAGE
8#define TINYGLTF_NO_STB_IMAGE_WRITE
9#include <tiny_gltf.h>
10
11#include "glbParser.h"
12
13#include <algorithm>
14#include <array>
15#include <cmath>
16#include <cstdint>
17#include <cstring>
18#include <limits>
19#include <queue>
20#include <vector>
21
22#include <draco/compression/decode.h>
23#include <draco/core/decoder_buffer.h>
24#include <draco/mesh/mesh.h>
25
27#include "core/math/matrix4.h"
28#include "core/math/vector4.h"
29#include "core/math/vector3.h"
35#include "spdlog/spdlog.h"
36#include "stb_image.h"
37
38namespace visutwin::canvas
39{
40 namespace
41 {
42 struct PackedVertex
43 {
44 float px, py, pz;
45 float nx, ny, nz;
46 float u, v;
47 float tx, ty, tz, tw;
48 float u1, v1;
49 };
50
51 struct PackedPointVertex
52 {
53 float px, py, pz; // position
54 float cr, cg, cb, ca; // vertex color (RGBA)
55 };
56
57 void generateTangents(std::vector<PackedVertex>& vertices, const std::vector<uint32_t>* indices)
58 {
59 const size_t vertexCount = vertices.size();
60 if (vertexCount == 0) {
61 return;
62 }
63
64 std::vector<Vector3> tan1(vertexCount, Vector3(0.0f, 0.0f, 0.0f));
65 std::vector<Vector3> tan2(vertexCount, Vector3(0.0f, 0.0f, 0.0f));
66
67 auto accumulateTriangle = [&](const uint32_t i0, const uint32_t i1, const uint32_t i2) {
68 if (i0 >= vertexCount || i1 >= vertexCount || i2 >= vertexCount) {
69 return;
70 }
71
72 const auto& v0 = vertices[i0];
73 const auto& v1 = vertices[i1];
74 const auto& v2 = vertices[i2];
75
76 const Vector3 p0(v0.px, v0.py, v0.pz);
77 const Vector3 p1(v1.px, v1.py, v1.pz);
78 const Vector3 p2(v2.px, v2.py, v2.pz);
79
80 const float du1 = v1.u - v0.u;
81 const float dv1 = v1.v - v0.v;
82 const float du2 = v2.u - v0.u;
83 const float dv2 = v2.v - v0.v;
84
85 const float det = du1 * dv2 - dv1 * du2;
86 if (std::abs(det) <= 1e-8f) {
87 return;
88 }
89
90 const float invDet = 1.0f / det;
91 const Vector3 e1 = p1 - p0;
92 const Vector3 e2 = p2 - p0;
93
94 const Vector3 sdir = (e1 * dv2 - e2 * dv1) * invDet;
95 const Vector3 tdir = (e2 * du1 - e1 * du2) * invDet;
96
97 tan1[i0] += sdir;
98 tan1[i1] += sdir;
99 tan1[i2] += sdir;
100
101 tan2[i0] += tdir;
102 tan2[i1] += tdir;
103 tan2[i2] += tdir;
104 };
105
106 if (indices && !indices->empty()) {
107 for (size_t i = 0; i + 2 < indices->size(); i += 3) {
108 accumulateTriangle((*indices)[i], (*indices)[i + 1], (*indices)[i + 2]);
109 }
110 } else {
111 for (uint32_t i = 0; i + 2 < vertexCount; i += 3) {
112 accumulateTriangle(i, i + 1, i + 2);
113 }
114 }
115
116 for (size_t i = 0; i < vertexCount; ++i) {
117 const Vector3 n(vertices[i].nx, vertices[i].ny, vertices[i].nz);
118 Vector3 t = tan1[i] - n * n.dot(tan1[i]);
119 if (t.lengthSquared() <= 1e-8f) {
120 // Fallback axis in case UVs are degenerate on this vertex.
121 t = std::abs(n.getY()) < 0.999f ? n.cross(Vector3(0.0f, 1.0f, 0.0f)) : n.cross(Vector3(1.0f, 0.0f, 0.0f));
122 }
123 t = t.normalized();
124
125 const float handedness = (n.cross(t).dot(tan2[i]) < 0.0f) ? -1.0f : 1.0f;
126
127 vertices[i].tx = t.getX();
128 vertices[i].ty = t.getY();
129 vertices[i].tz = t.getZ();
130 vertices[i].tw = handedness;
131 }
132 }
133
134 } // close anonymous namespace
135
136 // ── Public image-loader callback ─────────────────────────────────────
137
138 bool GlbParser::loadImageData(tinygltf::Image* image,
139 const int imageIndex,
140 std::string* err,
141 std::string* warn,
142 const int reqWidth,
143 const int reqHeight,
144 const unsigned char* bytes,
145 const int size,
146 void* userData)
147 {
148 (void)imageIndex;
149 (void)warn;
150 (void)reqWidth;
151 (void)reqHeight;
152 (void)userData;
153
154 if (!image || !bytes || size <= 0) {
155 if (err) {
156 *err = "Invalid image payload";
157 }
158 return false;
159 }
160
161 int width = 0;
162 int height = 0;
163 int components = 0;
164 // Per-thread flip state — safe to call from both main and bg threads.
165 stbi_set_flip_vertically_on_load_thread(true);
166 stbi_uc* decoded = stbi_load_from_memory(bytes, size, &width, &height, &components, 0);
167 if (!decoded) {
168 // Unsupported image format (e.g. KTX2, Basis).
169 // Generate a 1x1 magenta placeholder so the model geometry still loads.
170 const char* reason = stbi_failure_reason();
171 spdlog::warn("GLB image #{}: stb_image cannot decode ({}), mimeType={} — using placeholder",
172 imageIndex, reason ? reason : "unknown", image->mimeType);
173
174 image->width = 1;
175 image->height = 1;
176 image->component = 4;
177 image->bits = 8;
178 image->pixel_type = TINYGLTF_COMPONENT_TYPE_UNSIGNED_BYTE;
179 image->image = {255, 0, 255, 255}; // magenta RGBA
180 return true;
181 }
182
183 image->width = width;
184 image->height = height;
185 image->component = components;
186 image->bits = 8;
187 image->pixel_type = TINYGLTF_COMPONENT_TYPE_UNSIGNED_BYTE;
188
189 const size_t decodedSize = static_cast<size_t>(width) * static_cast<size_t>(height) * static_cast<size_t>(components);
190 image->image.assign(decoded, decoded + decodedSize);
191 stbi_image_free(decoded);
192 return true;
193 }
194
195 namespace { // reopen anonymous namespace
196
197 const tinygltf::Accessor* getAccessor(const tinygltf::Model& model, const int accessorIndex)
198 {
199 if (accessorIndex < 0 || accessorIndex >= static_cast<int>(model.accessors.size())) {
200 return nullptr;
201 }
202 return &model.accessors[accessorIndex];
203 }
204
205 const tinygltf::BufferView* getBufferView(const tinygltf::Model& model, const tinygltf::Accessor& accessor)
206 {
207 if (accessor.bufferView < 0 || accessor.bufferView >= static_cast<int>(model.bufferViews.size())) {
208 return nullptr;
209 }
210 return &model.bufferViews[accessor.bufferView];
211 }
212
213 const uint8_t* getAccessorBase(const tinygltf::Model& model, const tinygltf::Accessor& accessor)
214 {
215 const auto* view = getBufferView(model, accessor);
216 if (!view) {
217 return nullptr;
218 }
219 if (view->buffer < 0 || view->buffer >= static_cast<int>(model.buffers.size())) {
220 return nullptr;
221 }
222 const auto& buffer = model.buffers[view->buffer];
223 const auto offset = static_cast<size_t>(view->byteOffset + accessor.byteOffset);
224 if (offset > buffer.data.size()) {
225 return nullptr;
226 }
227 return buffer.data.data() + offset;
228 }
229
230 int componentBytes(const int componentType)
231 {
232 switch (componentType) {
233 case TINYGLTF_COMPONENT_TYPE_UNSIGNED_BYTE:
234 case TINYGLTF_COMPONENT_TYPE_BYTE:
235 return 1;
236 case TINYGLTF_COMPONENT_TYPE_UNSIGNED_SHORT:
237 case TINYGLTF_COMPONENT_TYPE_SHORT:
238 return 2;
239 case TINYGLTF_COMPONENT_TYPE_UNSIGNED_INT:
240 case TINYGLTF_COMPONENT_TYPE_INT:
241 case TINYGLTF_COMPONENT_TYPE_FLOAT:
242 return 4;
243 default:
244 return 0;
245 }
246 }
247
248 int accessorStride(const tinygltf::Model& model, const tinygltf::Accessor& accessor)
249 {
250 const auto* view = getBufferView(model, accessor);
251 const auto inferred = tinygltf::GetNumComponentsInType(accessor.type) * componentBytes(accessor.componentType);
252 if (!view || view->byteStride == 0) {
253 return inferred;
254 }
255 return view->byteStride;
256 }
257
258 bool readFloatVec3(const tinygltf::Model& model, const tinygltf::Accessor& accessor, const size_t index, Vector3& out)
259 {
260 if (accessor.componentType != TINYGLTF_COMPONENT_TYPE_FLOAT ||
261 accessor.type != TINYGLTF_TYPE_VEC3 ||
262 index >= static_cast<size_t>(accessor.count)) {
263 return false;
264 }
265 const auto* base = getAccessorBase(model, accessor);
266 if (!base) {
267 return false;
268 }
269 const auto stride = accessorStride(model, accessor);
270 const auto* ptr = reinterpret_cast<const float*>(base + index * static_cast<size_t>(stride));
271 out = Vector3(ptr[0], ptr[1], ptr[2]);
272 return true;
273 }
274
275 bool readFloatVec2(const tinygltf::Model& model, const tinygltf::Accessor& accessor, const size_t index, float& u, float& v)
276 {
277 if (accessor.componentType != TINYGLTF_COMPONENT_TYPE_FLOAT ||
278 accessor.type != TINYGLTF_TYPE_VEC2 ||
279 index >= static_cast<size_t>(accessor.count)) {
280 return false;
281 }
282 const auto* base = getAccessorBase(model, accessor);
283 if (!base) {
284 return false;
285 }
286 const auto stride = accessorStride(model, accessor);
287 const auto* ptr = reinterpret_cast<const float*>(base + index * static_cast<size_t>(stride));
288 u = ptr[0];
289 v = ptr[1];
290 return true;
291 }
292
293 bool readFloatVec4(const tinygltf::Model& model, const tinygltf::Accessor& accessor, const size_t index, Vector4& out)
294 {
295 if (accessor.componentType != TINYGLTF_COMPONENT_TYPE_FLOAT ||
296 accessor.type != TINYGLTF_TYPE_VEC4 ||
297 index >= static_cast<size_t>(accessor.count)) {
298 return false;
299 }
300 const auto* base = getAccessorBase(model, accessor);
301 if (!base) {
302 return false;
303 }
304 const auto stride = accessorStride(model, accessor);
305 const auto* ptr = reinterpret_cast<const float*>(base + index * static_cast<size_t>(stride));
306 out = Vector4(ptr[0], ptr[1], ptr[2], ptr[3]);
307 return true;
308 }
309
310 bool readFloatScalar(const tinygltf::Model& model, const tinygltf::Accessor& accessor, const size_t index, float& out)
311 {
312 if (accessor.componentType != TINYGLTF_COMPONENT_TYPE_FLOAT ||
313 accessor.type != TINYGLTF_TYPE_SCALAR ||
314 index >= static_cast<size_t>(accessor.count)) {
315 return false;
316 }
317 const auto* base = getAccessorBase(model, accessor);
318 if (!base) {
319 return false;
320 }
321 const auto stride = accessorStride(model, accessor);
322 const auto* ptr = reinterpret_cast<const float*>(base + index * static_cast<size_t>(stride));
323 out = ptr[0];
324 return true;
325 }
326
327 // Read all float data from an accessor into a flat vector.
328 // Works for SCALAR, VEC2, VEC3, VEC4 — all written as sequential floats.
329 bool readFloatArray(const tinygltf::Model& model, const tinygltf::Accessor& accessor, std::vector<float>& out)
330 {
331 if (accessor.componentType != TINYGLTF_COMPONENT_TYPE_FLOAT) {
332 return false;
333 }
334 const auto* base = getAccessorBase(model, accessor);
335 if (!base) {
336 return false;
337 }
338 const auto stride = accessorStride(model, accessor);
339 const int numComponents = tinygltf::GetNumComponentsInType(accessor.type);
340 const size_t count = static_cast<size_t>(accessor.count);
341 out.resize(count * static_cast<size_t>(numComponents));
342
343 for (size_t i = 0; i < count; ++i) {
344 const auto* ptr = reinterpret_cast<const float*>(base + i * static_cast<size_t>(stride));
345 for (int c = 0; c < numComponents; ++c) {
346 out[i * static_cast<size_t>(numComponents) + static_cast<size_t>(c)] = ptr[c];
347 }
348 }
349 return true;
350 }
351
352 // Parse glTF animations into AnimTrack objects stored on the container.
353 //
354 // Overload: output animation tracks to a map (thread-safe — no container needed).
355 void parseAnimations(const tinygltf::Model& model,
356 std::unordered_map<std::string, std::shared_ptr<AnimTrack>>& outTracks)
357 {
358 if (model.animations.empty()) {
359 return;
360 }
361
362 for (size_t animIdx = 0; animIdx < model.animations.size(); ++animIdx) {
363 const auto& anim = model.animations[animIdx];
364
365 std::string trackName = anim.name.empty()
366 ? ("animation_" + std::to_string(animIdx))
367 : anim.name;
368
369 float duration = 0.0f;
370 auto track = std::make_shared<AnimTrack>();
371
372 for (const auto& channel : anim.channels) {
373 if (channel.target_node < 0 ||
374 channel.target_node >= static_cast<int>(model.nodes.size())) {
375 continue;
376 }
377 if (channel.sampler < 0 ||
378 channel.sampler >= static_cast<int>(anim.samplers.size())) {
379 continue;
380 }
381
382 const auto& sampler = anim.samplers[channel.sampler];
383 const auto* inputAccessor = getAccessor(model, sampler.input);
384 const auto* outputAccessor = getAccessor(model, sampler.output);
385 if (!inputAccessor || !outputAccessor) {
386 continue;
387 }
388
389 // Map glTF target path to upstream property path.
390 std::string propertyPath;
391 int outputComponents = 0;
392 if (channel.target_path == "translation") {
393 propertyPath = "localPosition";
394 outputComponents = 3;
395 } else if (channel.target_path == "rotation") {
396 propertyPath = "localRotation";
397 outputComponents = 4;
398 } else if (channel.target_path == "scale") {
399 propertyPath = "localScale";
400 outputComponents = 3;
401 } else {
402 continue; // "weights" (morph targets) — skip for now.
403 }
404
405 // Map interpolation mode.
407 if (sampler.interpolation == "STEP") {
408 interpMode = AnimInterpolation::STEP;
409 } else if (sampler.interpolation == "CUBICSPLINE") {
410 interpMode = AnimInterpolation::CUBIC;
411 }
412
413 // Read input (keyframe times).
414 AnimData inputData;
415 inputData.components = 1;
416 if (!readFloatArray(model, *inputAccessor, inputData.data)) {
417 continue;
418 }
419
420 // Track max time for duration.
421 if (!inputData.data.empty()) {
422 duration = std::max(duration, inputData.data.back());
423 }
424
425 // Read output (values).
426 AnimData outputData;
427 if (interpMode == AnimInterpolation::CUBIC) {
428 // CUBICSPLINE stores 3 values per keyframe: [inTangent, value, outTangent].
429 // The accessor has count == keyframe_count, but each element has
430 // 3 * outputComponents floats.
431 outputData.components = outputComponents;
432 if (!readFloatArray(model, *outputAccessor, outputData.data)) {
433 continue;
434 }
435 } else {
436 outputData.components = outputComponents;
437 if (!readFloatArray(model, *outputAccessor, outputData.data)) {
438 continue;
439 }
440 }
441
442 // Quaternion winding normalization for rotation channels.
443 // Ensures shortest-path slerp: if dot(q[i], q[i+1]) < 0, negate q[i+1].
444 if (propertyPath == "localRotation" && interpMode != AnimInterpolation::CUBIC) {
445 const size_t quatCount = outputData.count();
446 for (size_t i = 1; i < quatCount; ++i) {
447 const size_t prev = (i - 1) * 4;
448 const size_t curr = i * 4;
449 const float dot = outputData.data[prev] * outputData.data[curr] +
450 outputData.data[prev + 1] * outputData.data[curr + 1] +
451 outputData.data[prev + 2] * outputData.data[curr + 2] +
452 outputData.data[prev + 3] * outputData.data[curr + 3];
453 if (dot < 0.0f) {
454 outputData.data[curr] = -outputData.data[curr];
455 outputData.data[curr + 1] = -outputData.data[curr + 1];
456 outputData.data[curr + 2] = -outputData.data[curr + 2];
457 outputData.data[curr + 3] = -outputData.data[curr + 3];
458 }
459 }
460 }
461
462 // Get target node name.
463 const auto& nodeName = model.nodes[static_cast<size_t>(channel.target_node)].name;
464 if (nodeName.empty()) {
465 continue; // Can't bind unnamed nodes via DefaultAnimBinder.
466 }
467
468 // Create curve referencing the input/output by index.
469 const size_t inputIndex = track->inputs().size();
470 const size_t outputIndex = track->outputs().size();
471
472 track->addInput(std::move(inputData));
473 track->addOutput(std::move(outputData));
474
475 AnimCurve curve;
476 curve.nodeName = nodeName;
477 curve.propertyPath = propertyPath;
478 curve.inputIndex = inputIndex;
479 curve.outputIndex = outputIndex;
480 curve.interpolation = interpMode;
481 track->addCurve(curve);
482 }
483
484 track->setName(trackName);
485 track->setDuration(duration);
486
487 if (!track->curves().empty()) {
488 outTracks[trackName] = track;
489 spdlog::info(" Parsed animation '{}': {:.2f}s, {} curves",
490 trackName, duration, track->curves().size());
491 }
492 }
493 }
494
495 // Overload: output animation tracks to a container (existing behavior).
496 void parseAnimations(const tinygltf::Model& model, GlbContainerResource* container)
497 {
498 if (!container) return;
499 std::unordered_map<std::string, std::shared_ptr<AnimTrack>> tracks;
500 parseAnimations(model, tracks);
501 for (auto& [name, track] : tracks) {
502 container->addAnimTrack(name, track);
503 }
504 }
505
506 bool readIndices(const tinygltf::Model& model, const tinygltf::Accessor& accessor, std::vector<uint32_t>& out)
507 {
508 if (accessor.type != TINYGLTF_TYPE_SCALAR) {
509 return false;
510 }
511 const auto* base = getAccessorBase(model, accessor);
512 if (!base) {
513 return false;
514 }
515 const auto stride = accessorStride(model, accessor);
516 out.resize(static_cast<size_t>(accessor.count));
517
518 for (size_t i = 0; i < out.size(); ++i) {
519 const auto* src = base + i * static_cast<size_t>(stride);
520 uint32_t value = 0;
521 switch (accessor.componentType) {
522 case TINYGLTF_COMPONENT_TYPE_UNSIGNED_BYTE:
523 value = *reinterpret_cast<const uint8_t*>(src);
524 break;
525 case TINYGLTF_COMPONENT_TYPE_UNSIGNED_SHORT:
526 value = *reinterpret_cast<const uint16_t*>(src);
527 break;
528 case TINYGLTF_COMPONENT_TYPE_UNSIGNED_INT:
529 value = *reinterpret_cast<const uint32_t*>(src);
530 break;
531 default:
532 return false;
533 }
534 out[i] = value;
535 }
536 return true;
537 }
538
539 bool primitiveUsesDraco(const tinygltf::Primitive& primitive)
540 {
541 return primitive.extensions.contains("KHR_draco_mesh_compression");
542 }
543
544 int readDracoAttributeId(const tinygltf::Value& dracoExtension, const std::string& semantic)
545 {
546 if (!dracoExtension.IsObject() || !dracoExtension.Has("attributes")) {
547 return -1;
548 }
549 const auto attrs = dracoExtension.Get("attributes");
550 if (!attrs.IsObject() || !attrs.Has(semantic)) {
551 return -1;
552 }
553 const auto& idValue = attrs.Get(semantic);
554 if (idValue.IsInt()) {
555 return idValue.Get<int>();
556 }
557 if (idValue.IsNumber()) {
558 return idValue.GetNumberAsInt();
559 }
560 return -1;
561 }
562
563 const draco::PointAttribute* getDracoAttribute(const draco::Mesh& mesh, const tinygltf::Value& dracoExtension,
564 const std::string& semantic)
565 {
566 const int uniqueId = readDracoAttributeId(dracoExtension, semantic);
567 if (uniqueId < 0) {
568 return nullptr;
569 }
570 return mesh.GetAttributeByUniqueId(uniqueId);
571 }
572
573 bool decodeDracoPrimitive(const tinygltf::Model& model, const tinygltf::Primitive& primitive,
574 std::vector<PackedVertex>& outVertices, std::vector<uint32_t>& outIndices, Vector3& outMinPos, Vector3& outMaxPos)
575 {
576 const auto extIt = primitive.extensions.find("KHR_draco_mesh_compression");
577 if (extIt == primitive.extensions.end()) {
578 return false;
579 }
580 const auto& dracoExt = extIt->second;
581 if (!dracoExt.IsObject() || !dracoExt.Has("bufferView")) {
582 spdlog::warn("glTF primitive has malformed KHR_draco_mesh_compression extension");
583 return false;
584 }
585
586 const auto bufferViewVal = dracoExt.Get("bufferView");
587 const int bufferViewIndex = bufferViewVal.IsInt() ? bufferViewVal.Get<int>() :
588 (bufferViewVal.IsNumber() ? bufferViewVal.GetNumberAsInt() : -1);
589 if (bufferViewIndex < 0 || bufferViewIndex >= static_cast<int>(model.bufferViews.size())) {
590 spdlog::warn("Draco primitive references invalid bufferView {}", bufferViewIndex);
591 return false;
592 }
593
594 const auto& view = model.bufferViews[static_cast<size_t>(bufferViewIndex)];
595 if (view.buffer < 0 || view.buffer >= static_cast<int>(model.buffers.size())) {
596 spdlog::warn("Draco primitive bufferView references invalid buffer {}", view.buffer);
597 return false;
598 }
599
600 const auto& buffer = model.buffers[static_cast<size_t>(view.buffer)];
601 const size_t byteOffset = static_cast<size_t>(view.byteOffset);
602 const size_t byteLength = static_cast<size_t>(view.byteLength);
603 if (byteOffset + byteLength > buffer.data.size()) {
604 spdlog::warn("Draco primitive compressed payload out of bounds");
605 return false;
606 }
607
608 draco::DecoderBuffer decoderBuffer;
609 decoderBuffer.Init(reinterpret_cast<const char*>(buffer.data.data() + byteOffset),
610 static_cast<int64_t>(byteLength));
611
612 draco::Decoder decoder;
613 auto meshStatus = decoder.DecodeMeshFromBuffer(&decoderBuffer);
614 if (!meshStatus.ok()) {
615 spdlog::warn("Failed to decode Draco mesh: {}", meshStatus.status().error_msg_string());
616 return false;
617 }
618
619 std::unique_ptr<draco::Mesh> dracoMesh = std::move(meshStatus).value();
620 if (!dracoMesh || dracoMesh->num_points() <= 0) {
621 spdlog::warn("Decoded Draco mesh has no points");
622 return false;
623 }
624
625 const auto* positionAttr = getDracoAttribute(*dracoMesh, dracoExt, "POSITION");
626 if (!positionAttr || positionAttr->num_components() < 3) {
627 spdlog::warn("Decoded Draco mesh missing POSITION attribute");
628 return false;
629 }
630 const auto* normalAttr = getDracoAttribute(*dracoMesh, dracoExt, "NORMAL");
631 const auto* uvAttr = getDracoAttribute(*dracoMesh, dracoExt, "TEXCOORD_0");
632 const auto* uv1Attr = getDracoAttribute(*dracoMesh, dracoExt, "TEXCOORD_1");
633 const auto* tangentAttr = getDracoAttribute(*dracoMesh, dracoExt, "TANGENT");
634
635 const int32_t pointCount = dracoMesh->num_points();
636 outVertices.resize(static_cast<size_t>(pointCount));
637
638 outMinPos = Vector3(std::numeric_limits<float>::max());
639 outMaxPos = Vector3(std::numeric_limits<float>::lowest());
640
641 for (int32_t i = 0; i < pointCount; ++i) {
642 const draco::PointIndex pointIndex(i);
643 const draco::AttributeValueIndex positionValueIndex = positionAttr->mapped_index(pointIndex);
644 if (positionValueIndex < 0) {
645 spdlog::warn("Decoded Draco POSITION has invalid mapped index at point {}", i);
646 return false;
647 }
648
649 std::array<float, 3> pos{0.0f, 0.0f, 0.0f};
650 if (!positionAttr->ConvertValue<float, 3>(positionValueIndex, pos.data())) {
651 spdlog::warn("Failed to decode Draco POSITION at vertex {}", i);
652 return false;
653 }
654
655 std::array<float, 3> normal{0.0f, 1.0f, 0.0f};
656 if (normalAttr && normalAttr->num_components() >= 3) {
657 const draco::AttributeValueIndex normalValueIndex = normalAttr->mapped_index(pointIndex);
658 if (normalValueIndex >= 0) {
659 normalAttr->ConvertValue<float, 3>(normalValueIndex, normal.data());
660 }
661 }
662
663 std::array<float, 2> uv{0.0f, 0.0f};
664 if (uvAttr && uvAttr->num_components() >= 2) {
665 const draco::AttributeValueIndex uvValueIndex = uvAttr->mapped_index(pointIndex);
666 if (uvValueIndex >= 0) {
667 uvAttr->ConvertValue<float, 2>(uvValueIndex, uv.data());
668 uv[1] = 1.0f - uv[1];
669 }
670 }
671
672 std::array<float, 2> uv1{uv[0], uv[1]};
673 if (uv1Attr && uv1Attr->num_components() >= 2) {
674 const draco::AttributeValueIndex uv1ValueIndex = uv1Attr->mapped_index(pointIndex);
675 if (uv1ValueIndex >= 0) {
676 uv1Attr->ConvertValue<float, 2>(uv1ValueIndex, uv1.data());
677 uv1[1] = 1.0f - uv1[1];
678 }
679 }
680
681 std::array<float, 4> tangent{0.0f, 0.0f, 0.0f, 1.0f};
682 if (tangentAttr && tangentAttr->num_components() >= 4) {
683 const draco::AttributeValueIndex tangentValueIndex = tangentAttr->mapped_index(pointIndex);
684 if (tangentValueIndex >= 0) {
685 tangentAttr->ConvertValue<float, 4>(tangentValueIndex, tangent.data());
686 // Flip tangent handedness when flipping V.
687 tangent[3] = -tangent[3];
688 }
689 }
690
691 outVertices[static_cast<size_t>(i)] = PackedVertex{
692 pos[0], pos[1], pos[2],
693 normal[0], normal[1], normal[2],
694 uv[0], uv[1],
695 tangent[0], tangent[1], tangent[2], tangent[3],
696 uv1[0], uv1[1]
697 };
698
699 outMinPos = Vector3(
700 std::min(outMinPos.getX(), pos[0]),
701 std::min(outMinPos.getY(), pos[1]),
702 std::min(outMinPos.getZ(), pos[2])
703 );
704 outMaxPos = Vector3(
705 std::max(outMaxPos.getX(), pos[0]),
706 std::max(outMaxPos.getY(), pos[1]),
707 std::max(outMaxPos.getZ(), pos[2])
708 );
709 }
710
711 outIndices.clear();
712 outIndices.reserve(static_cast<size_t>(dracoMesh->num_faces()) * 3);
713 for (draco::FaceIndex faceIndex(0); faceIndex < dracoMesh->num_faces(); ++faceIndex) {
714 const auto& face = dracoMesh->face(faceIndex);
715 outIndices.push_back(face[0].value());
716 outIndices.push_back(face[1].value());
717 outIndices.push_back(face[2].value());
718 }
719
720 if (!tangentAttr && primitive.mode == TINYGLTF_MODE_TRIANGLES) {
721 generateTangents(outVertices, outIndices.empty() ? nullptr : &outIndices);
722 }
723
724 return true;
725 }
726
727 PrimitiveType mapPrimitiveType(const int mode)
728 {
729 switch (mode) {
730 case TINYGLTF_MODE_POINTS:
731 return PRIMITIVE_POINTS;
732 case TINYGLTF_MODE_LINE:
733 return PRIMITIVE_LINES;
734 case TINYGLTF_MODE_LINE_LOOP:
735 return PRIMITIVE_LINELOOP;
736 case TINYGLTF_MODE_LINE_STRIP:
737 return PRIMITIVE_LINESTRIP;
738 case TINYGLTF_MODE_TRIANGLE_STRIP:
739 return PRIMITIVE_TRISTRIP;
740 case TINYGLTF_MODE_TRIANGLE_FAN:
741 return PRIMITIVE_TRIFAN;
742 case TINYGLTF_MODE_TRIANGLES:
743 default:
744 return PRIMITIVE_TRIANGLES;
745 }
746 }
747
748 FilterMode mapMinFilter(const int minFilter)
749 {
750 switch (minFilter) {
751 case 9728: // NEAREST
753 case 9729: // LINEAR
755 case 9984: // NEAREST_MIPMAP_NEAREST
757 case 9985: // LINEAR_MIPMAP_NEAREST
759 case 9986: // NEAREST_MIPMAP_LINEAR
761 case 9987: // LINEAR_MIPMAP_LINEAR
762 default:
764 }
765 }
766
767 FilterMode mapMagFilter(const int magFilter)
768 {
769 switch (magFilter) {
770 case 9728: // NEAREST
772 case 9729: // LINEAR
773 default:
775 }
776 }
777
778 AddressMode mapWrapMode(const int wrapMode)
779 {
780 switch (wrapMode) {
781 case 33071: // CLAMP_TO_EDGE
783 case 33648: // MIRRORED_REPEAT
785 case 10497: // REPEAT
786 default:
787 return ADDRESS_REPEAT;
788 }
789 }
790
791 bool buildRgba8Image(const tinygltf::Image& image, std::vector<uint8_t>& outRgba)
792 {
793 if (image.width <= 0 || image.height <= 0 || image.image.empty()) {
794 return false;
795 }
796 if (image.bits != 8 || image.pixel_type != TINYGLTF_COMPONENT_TYPE_UNSIGNED_BYTE) {
797 return false;
798 }
799 if (image.component < 1 || image.component > 4) {
800 return false;
801 }
802
803 const auto pixelCount = static_cast<size_t>(image.width) * static_cast<size_t>(image.height);
804 outRgba.resize(pixelCount * 4);
805
806 for (size_t i = 0; i < pixelCount; ++i) {
807 const auto srcOffset = i * static_cast<size_t>(image.component);
808 const auto dstOffset = i * 4;
809 const auto* src = image.image.data() + srcOffset;
810 auto* dst = outRgba.data() + dstOffset;
811 switch (image.component) {
812 case 1:
813 dst[0] = src[0];
814 dst[1] = src[0];
815 dst[2] = src[0];
816 dst[3] = 255;
817 break;
818 case 2:
819 dst[0] = src[0];
820 dst[1] = src[0];
821 dst[2] = src[0];
822 dst[3] = src[1];
823 break;
824 case 3:
825 dst[0] = src[0];
826 dst[1] = src[1];
827 dst[2] = src[2];
828 dst[3] = 255;
829 break;
830 case 4:
831 dst[0] = src[0];
832 dst[1] = src[1];
833 dst[2] = src[2];
834 dst[3] = src[3];
835 break;
836 default:
837 return false;
838 }
839 }
840
841 return true;
842 }
843
844 void decomposeNodeMatrix(const std::vector<double>& matrix, Vector3& outT, Quaternion& outR, Vector3& outS)
845 {
846 if (matrix.size() != 16) {
847 return;
848 }
849
850 // glTF matrices are column-major.
851 const Vector3 col0(static_cast<float>(matrix[0]), static_cast<float>(matrix[1]), static_cast<float>(matrix[2]));
852 const Vector3 col1(static_cast<float>(matrix[4]), static_cast<float>(matrix[5]), static_cast<float>(matrix[6]));
853 const Vector3 col2(static_cast<float>(matrix[8]), static_cast<float>(matrix[9]), static_cast<float>(matrix[10]));
854
855 float sx = col0.length();
856 const float sy = col1.length();
857 const float sz = col2.length();
858 if (sx <= 0.0f) sx = 1.0f;
859
860 // Match upstream / Quat.setFromMat4 convention for mirrored transforms:
861 // keep rotation right-handed and encode mirror sign into X scale.
862 const float det = col0.getX() * (col1.getY() * col2.getZ() - col1.getZ() * col2.getY())
863 - col1.getX() * (col0.getY() * col2.getZ() - col0.getZ() * col2.getY())
864 + col2.getX() * (col0.getY() * col1.getZ() - col0.getZ() * col1.getY());
865 if (det < 0.0f) {
866 sx = -sx;
867 }
868
869 const Matrix4 trs = Matrix4(
870 Vector4(col0.getX(), col0.getY(), col0.getZ(), 0.0f),
871 Vector4(col1.getX(), col1.getY(), col1.getZ(), 0.0f),
872 Vector4(col2.getX(), col2.getY(), col2.getZ(), 0.0f),
873 Vector4(0.0f, 0.0f, 0.0f, 1.0f)
874 );
876 outS = Vector3(sx, sy > 0.0f ? sy : 1.0f, sz > 0.0f ? sz : 1.0f);
877 outT = Vector3(
878 static_cast<float>(matrix[12]),
879 static_cast<float>(matrix[13]),
880 static_cast<float>(matrix[14])
881 );
882 }
883 }
884
885 std::unique_ptr<GlbContainerResource> GlbParser::parse(const std::string& path,
886 const std::shared_ptr<GraphicsDevice>& device)
887 {
888 if (!device) {
889 spdlog::error("GLB parse failed: graphics device is null");
890 return nullptr;
891 }
892
893 tinygltf::TinyGLTF loader;
894 loader.SetImageLoader(GlbParser::loadImageData, nullptr);
895 tinygltf::Model model;
896 std::string warn;
897 std::string err;
898
899 // Detect file format: .gltf (JSON text) vs .glb (binary)
900 bool ok = false;
901 const auto dot = path.rfind('.');
902 const bool isAscii = (dot != std::string::npos &&
903 (path.substr(dot) == ".gltf" || path.substr(dot) == ".GLTF"));
904 if (isAscii) {
905 ok = loader.LoadASCIIFromFile(&model, &err, &warn, path);
906 } else {
907 ok = loader.LoadBinaryFromFile(&model, &err, &warn, path);
908 }
909 if (!warn.empty()) {
910 spdlog::warn("GLB parse warning [{}]: {}", path, warn);
911 }
912 if (!ok) {
913 spdlog::error("GLB parse failed [{}]: {}", path, err);
914 return nullptr;
915 }
916
917 auto container = std::make_unique<GlbContainerResource>();
918 auto vertexFormat = std::make_shared<VertexFormat>(sizeof(PackedVertex), true, false);
919 size_t dracoPrimitiveCount = 0;
920 size_t dracoDecodeSuccessCount = 0;
921 size_t dracoDecodeFailureCount = 0;
922
923 auto makeDefaultMaterial = []() {
924 auto material = std::make_shared<StandardMaterial>();
925 material->setName("glTF-default");
926 material->setTransparent(false);
927 material->setAlphaMode(AlphaMode::OPAQUE);
928 material->setMetallicFactor(0.0f);
929 material->setRoughnessFactor(1.0f);
930 material->setShaderVariantKey(1);
931 return material;
932 };
933
934 std::vector<std::shared_ptr<Material>> gltfMaterials;
935 gltfMaterials.reserve(std::max<size_t>(1, model.materials.size()));
936 std::vector<std::shared_ptr<Texture>> gltfTextures(model.textures.size());
937
938 auto getOrCreateTexture = [&](const int textureIndex) -> std::shared_ptr<Texture> {
939 if (textureIndex < 0 || textureIndex >= static_cast<int>(model.textures.size())) {
940 return nullptr;
941 }
942
943 auto& cached = gltfTextures[static_cast<size_t>(textureIndex)];
944 if (cached) {
945 return cached;
946 }
947
948 const auto& srcTexture = model.textures[static_cast<size_t>(textureIndex)];
949
950 // Resolve image source index — check KHR_texture_basisu extension first,
951 // then fall back to the standard source field
952 int imageSource = srcTexture.source;
953 if (imageSource < 0) {
954 // KTX2/Basis Universal textures store the image index in the extension
955 auto it = srcTexture.extensions.find("KHR_texture_basisu");
956 if (it != srcTexture.extensions.end() && it->second.IsObject()) {
957 auto sourceVal = it->second.Get("source");
958 if (sourceVal.IsInt()) {
959 imageSource = sourceVal.GetNumberAsInt();
960 }
961 }
962 }
963
964 if (imageSource < 0 || imageSource >= static_cast<int>(model.images.size())) {
965 spdlog::warn("glTF texture {} has no valid image source (source={}, no basisu fallback)",
966 textureIndex, srcTexture.source);
967 return nullptr;
968 }
969
970 const auto& srcImage = model.images[static_cast<size_t>(imageSource)];
971 std::vector<uint8_t> rgbaPixels;
972 if (!buildRgba8Image(srcImage, rgbaPixels)) {
973 spdlog::warn("glTF image '{}' unsupported format (bits={}, components={}, pixelType={})",
974 srcImage.name, srcImage.bits, srcImage.component, srcImage.pixel_type);
975 return nullptr;
976 }
977
978 TextureOptions options;
979 options.width = static_cast<uint32_t>(srcImage.width);
980 options.height = static_cast<uint32_t>(srcImage.height);
982 options.mipmaps = false;
983 options.numLevels = 1;
986 options.name = srcImage.name.empty() ? srcTexture.name : srcImage.name;
987
988 auto texture = std::make_shared<Texture>(device.get(), options);
989 texture->setLevelData(0, rgbaPixels.data(), rgbaPixels.size());
990
991 if (srcTexture.sampler >= 0 && srcTexture.sampler < static_cast<int>(model.samplers.size())) {
992 const auto& sampler = model.samplers[static_cast<size_t>(srcTexture.sampler)];
993 if (sampler.minFilter != -1) {
994 auto minFilter = mapMinFilter(sampler.minFilter);
999 minFilter = FilterMode::FILTER_LINEAR;
1000 }
1001 texture->setMinFilter(minFilter);
1002 }
1003 if (sampler.magFilter != -1) {
1004 texture->setMagFilter(mapMagFilter(sampler.magFilter));
1005 }
1006 texture->setAddressU(mapWrapMode(sampler.wrapS));
1007 texture->setAddressV(mapWrapMode(sampler.wrapT));
1008 }
1009
1010 texture->upload();
1011 container->addOwnedTexture(texture);
1012 cached = texture;
1013 return cached;
1014 };
1015
1016 if (model.materials.empty()) {
1017 gltfMaterials.push_back(makeDefaultMaterial());
1018 } else {
1019 for (size_t materialIndex = 0; materialIndex < model.materials.size(); ++materialIndex) {
1020 const auto& srcMaterial = model.materials[materialIndex];
1021 auto material = std::make_shared<StandardMaterial>();
1022 material->setName(srcMaterial.name.empty() ? "glTF-material" : srcMaterial.name);
1023
1024 const auto& pbr = srcMaterial.pbrMetallicRoughness;
1025 if (pbr.baseColorFactor.size() == 4) {
1026 const Color baseColor(
1027 static_cast<float>(pbr.baseColorFactor[0]),
1028 static_cast<float>(pbr.baseColorFactor[1]),
1029 static_cast<float>(pbr.baseColorFactor[2]),
1030 static_cast<float>(pbr.baseColorFactor[3])
1031 );
1032 material->setBaseColorFactor(baseColor);
1033 // Also set StandardMaterial diffuse + opacity so that
1034 // updateUniforms() uses the glTF base color (not white default).
1035 // glTF baseColorFactor is linear; StandardMaterial.diffuse
1036 // expects sRGB (the shader applies srgbToLinear). Convert with .gamma().
1037 Color diffuseColor(baseColor);
1038 diffuseColor.gamma();
1039 material->setDiffuse(diffuseColor);
1040 material->setOpacity(baseColor.a);
1041 }
1042 float metallicFactor = static_cast<float>(pbr.metallicFactor);
1043 float roughnessFactor = static_cast<float>(pbr.roughnessFactor);
1044 material->setMetallicFactor(metallicFactor);
1045 material->setRoughnessFactor(roughnessFactor);
1046 // Also set StandardMaterial metalness + gloss so that
1047 // updateUniforms() uses the glTF values (not defaults).
1048 // StandardMaterial convention: gloss = 1 - roughness (glossInvert=false).
1049 material->setMetalness(metallicFactor);
1050 material->setGloss(1.0f - roughnessFactor);
1051
1052 if (!srcMaterial.alphaMode.empty()) {
1053 if (srcMaterial.alphaMode == "BLEND") {
1054 material->setAlphaMode(AlphaMode::BLEND);
1055 material->setTransparent(true);
1056 } else if (srcMaterial.alphaMode == "MASK") {
1057 material->setAlphaMode(AlphaMode::MASK);
1058 material->setTransparent(false);
1059 } else {
1060 material->setAlphaMode(AlphaMode::OPAQUE);
1061 material->setTransparent(false);
1062 }
1063 }
1064 material->setCullMode(srcMaterial.doubleSided ? CullMode::CULLFACE_NONE : CullMode::CULLFACE_BACK);
1065 material->setAlphaCutoff(static_cast<float>(srcMaterial.alphaCutoff));
1066
1067 if (pbr.baseColorTexture.index >= 0) {
1068 if (auto baseColorTexture = getOrCreateTexture(pbr.baseColorTexture.index)) {
1069 material->setBaseColorTexture(baseColorTexture.get());
1070 material->setHasBaseColorTexture(true);
1071 material->setBaseColorUvSet(pbr.baseColorTexture.texCoord);
1072 }
1073 }
1074
1075 if (srcMaterial.normalTexture.index >= 0) {
1076 if (auto normalTexture = getOrCreateTexture(srcMaterial.normalTexture.index)) {
1077 material->setNormalTexture(normalTexture.get());
1078 material->setHasNormalTexture(true);
1079 material->setNormalUvSet(srcMaterial.normalTexture.texCoord);
1080 }
1081 material->setNormalScale(static_cast<float>(srcMaterial.normalTexture.scale));
1082 }
1083 if (pbr.metallicRoughnessTexture.index >= 0) {
1084 if (auto mrTexture = getOrCreateTexture(pbr.metallicRoughnessTexture.index)) {
1085 material->setMetallicRoughnessTexture(mrTexture.get());
1086 material->setHasMetallicRoughnessTexture(true);
1087 material->setMetallicRoughnessUvSet(pbr.metallicRoughnessTexture.texCoord);
1088 }
1089 }
1090 if (srcMaterial.occlusionTexture.index >= 0) {
1091 if (auto occlusionTexture = getOrCreateTexture(srcMaterial.occlusionTexture.index)) {
1092 material->setOcclusionTexture(occlusionTexture.get());
1093 material->setHasOcclusionTexture(true);
1094 material->setOcclusionUvSet(srcMaterial.occlusionTexture.texCoord);
1095 }
1096 material->setOcclusionStrength(static_cast<float>(srcMaterial.occlusionTexture.strength));
1097 }
1098 if (srcMaterial.emissiveFactor.size() == 3) {
1099 // glTF emissiveFactor is linear;
1100 // material.emissive expects sRGB. Convert with .gamma().
1101 Color emissiveColor(
1102 static_cast<float>(srcMaterial.emissiveFactor[0]),
1103 static_cast<float>(srcMaterial.emissiveFactor[1]),
1104 static_cast<float>(srcMaterial.emissiveFactor[2]),
1105 1.0f
1106 );
1107 emissiveColor.gamma();
1108 material->setEmissiveFactor(emissiveColor);
1109 }
1110 if (srcMaterial.emissiveTexture.index >= 0) {
1111 if (auto emissiveTexture = getOrCreateTexture(srcMaterial.emissiveTexture.index)) {
1112 material->setEmissiveTexture(emissiveTexture.get());
1113 material->setHasEmissiveTexture(true);
1114 material->setEmissiveUvSet(srcMaterial.emissiveTexture.texCoord);
1115 }
1116 }
1117
1118 // Detect KHR_materials_unlit extension.
1119 const bool isUnlit = srcMaterial.extensions.contains("KHR_materials_unlit");
1120
1121 uint64_t variant = 1;
1122 if (material->hasBaseColorTexture()) {
1123 variant |= (1ull << 1);
1124 }
1125 if (material->hasNormalTexture()) {
1126 variant |= (1ull << 4);
1127 }
1128 if (material->hasMetallicRoughnessTexture()) {
1129 variant |= (1ull << 5);
1130 }
1131 if (material->hasOcclusionTexture()) {
1132 variant |= (1ull << 6);
1133 }
1134 if (material->hasEmissiveTexture()) {
1135 variant |= (1ull << 7);
1136 }
1137 if (material->alphaMode() == AlphaMode::BLEND) {
1138 variant |= (1ull << 2);
1139 } else if (material->alphaMode() == AlphaMode::MASK) {
1140 variant |= (1ull << 3);
1141 }
1142 if (isUnlit) {
1143 variant |= (1ull << 32); // VT_FEATURE_UNLIT
1144 }
1145 material->setShaderVariantKey(variant);
1146 gltfMaterials.push_back(material);
1147 }
1148 }
1149
1150 // ---------------------------------------------------------------
1151 // Pre-compute per-node world matrices so POINTS vertices can be
1152 // baked into world space when merging into a single draw call.
1153 // ---------------------------------------------------------------
1154 std::vector<Matrix4> nodeWorldMatrices(model.nodes.size(), Matrix4::identity());
1155
1156 // 1) Build each node's local matrix from TRS (or direct matrix).
1157 for (size_t i = 0; i < model.nodes.size(); ++i) {
1158 const auto& node = model.nodes[i];
1159 if (!node.matrix.empty() && node.matrix.size() == 16) {
1160 // glTF stores matrices in column-major order.
1161 Matrix4 m;
1162 for (int col = 0; col < 4; ++col)
1163 for (int row = 0; row < 4; ++row)
1164 m.setElement(row, col, static_cast<float>(node.matrix[static_cast<size_t>(col * 4 + row)]));
1165 nodeWorldMatrices[i] = m;
1166 } else {
1167 Vector3 t(0.0f, 0.0f, 0.0f);
1168 Quaternion q(0.0f, 0.0f, 0.0f, 1.0f);
1169 Vector3 s(1.0f, 1.0f, 1.0f);
1170 if (node.translation.size() == 3) {
1171 t = Vector3(static_cast<float>(node.translation[0]),
1172 static_cast<float>(node.translation[1]),
1173 static_cast<float>(node.translation[2]));
1174 }
1175 if (node.rotation.size() == 4) {
1176 q = Quaternion(static_cast<float>(node.rotation[0]),
1177 static_cast<float>(node.rotation[1]),
1178 static_cast<float>(node.rotation[2]),
1179 static_cast<float>(node.rotation[3])).normalized();
1180 }
1181 if (node.scale.size() == 3) {
1182 s = Vector3(static_cast<float>(node.scale[0]),
1183 static_cast<float>(node.scale[1]),
1184 static_cast<float>(node.scale[2]));
1185 }
1186 // Compose T * R * S (column-major: col c = scale_c * col c of R).
1187 Matrix4 rotMat = q.toRotationMatrix();
1188 const float sc[3] = {s.getX(), s.getY(), s.getZ()};
1189 Matrix4 trs;
1190 for (int c = 0; c < 3; ++c) {
1191 for (int r = 0; r < 3; ++r)
1192 trs.setElement(c, r, rotMat.getElement(c, r) * sc[c]);
1193 trs.setElement(c, 3, 0.0f); // row 3 of rotation/scale columns
1194 }
1195 // Column 3 = translation.
1196 trs.setElement(3, 0, t.getX());
1197 trs.setElement(3, 1, t.getY());
1198 trs.setElement(3, 2, t.getZ());
1199 trs.setElement(3, 3, 1.0f);
1200 nodeWorldMatrices[i] = trs;
1201 }
1202 }
1203
1204 // 2) Propagate: world = parent_world * local.
1205 // BFS from root nodes (nodes that are not children of any other node).
1206 {
1207 std::vector<bool> isChild(model.nodes.size(), false);
1208 for (const auto& node : model.nodes) {
1209 for (int childIdx : node.children) {
1210 if (childIdx >= 0 && childIdx < static_cast<int>(model.nodes.size())) {
1211 isChild[static_cast<size_t>(childIdx)] = true;
1212 }
1213 }
1214 }
1215 std::queue<size_t> bfs;
1216 for (size_t i = 0; i < model.nodes.size(); ++i) {
1217 if (!isChild[i]) bfs.push(i);
1218 }
1219 while (!bfs.empty()) {
1220 const size_t idx = bfs.front();
1221 bfs.pop();
1222 for (int childIdx : model.nodes[idx].children) {
1223 if (childIdx >= 0 && childIdx < static_cast<int>(model.nodes.size())) {
1224 const auto ci = static_cast<size_t>(childIdx);
1225 nodeWorldMatrices[ci] = nodeWorldMatrices[idx] * nodeWorldMatrices[ci];
1226 bfs.push(ci);
1227 }
1228 }
1229 }
1230 }
1231
1232 // 3) Build mesh→node map (last node referencing the mesh wins).
1233 std::vector<int> meshToNodeIndex(model.meshes.size(), -1);
1234 for (size_t i = 0; i < model.nodes.size(); ++i) {
1235 const int meshRef = model.nodes[i].mesh;
1236 if (meshRef >= 0 && meshRef < static_cast<int>(model.meshes.size())) {
1237 meshToNodeIndex[static_cast<size_t>(meshRef)] = static_cast<int>(i);
1238 }
1239 }
1240
1241 // When the GLB contains animations, skip the POINTS merge optimisation.
1242 // The merge bakes vertex positions into world space and marks leaf nodes
1243 // as skip, which prevents per-node animation (e.g. scale) from working.
1244 const bool hasAnimations = !model.animations.empty();
1245
1246 // Accumulators for merging all POINTS primitives into a single draw call.
1247 // Individual point meshes are appended here; a single merged mesh payload
1248 // is created after the mesh loop to avoid per-mesh draw call overhead.
1249 // Only used when !hasAnimations.
1250 std::vector<PackedPointVertex> mergedPointVertices;
1251 Vector3 mergedPtMin(std::numeric_limits<float>::max());
1252 Vector3 mergedPtMax(std::numeric_limits<float>::lowest());
1253 int mergedPointMaterialIndex = -1;
1254 size_t mergedPointPayloadIndex = SIZE_MAX;
1255
1256 std::vector<std::vector<size_t>> meshToPayloadIndices(model.meshes.size());
1257 size_t nextPayloadIndex = 0;
1258 for (size_t meshIndex = 0; meshIndex < model.meshes.size(); ++meshIndex) {
1259 const auto& mesh = model.meshes[meshIndex];
1260 for (const auto& primitive : mesh.primitives) {
1261 // ---------------------------------------------------------------
1262 // POINTS primitive handling.
1263 // When no animations: accumulate vertices for merged draw call
1264 // (transforms baked to world space, single draw call).
1265 // When animations present: create individual per-node point
1266 // meshes in local space so scale/transform animation works.
1267 // ---------------------------------------------------------------
1268 if (primitive.mode == TINYGLTF_MODE_POINTS) {
1269 if (!primitive.attributes.contains("POSITION")) {
1270 continue;
1271 }
1272 const auto* positionAccessor = getAccessor(model, primitive.attributes.at("POSITION"));
1273 if (!positionAccessor || positionAccessor->count <= 0) {
1274 continue;
1275 }
1276 if (positionAccessor->componentType != TINYGLTF_COMPONENT_TYPE_FLOAT ||
1277 positionAccessor->type != TINYGLTF_TYPE_VEC3) {
1278 continue;
1279 }
1280
1281 const auto* colorAccessor = primitive.attributes.contains("COLOR_0")
1282 ? getAccessor(model, primitive.attributes.at("COLOR_0")) : nullptr;
1283
1284 const auto pointVertexCount = static_cast<size_t>(positionAccessor->count);
1285
1286 if (hasAnimations) {
1287 // -------------------------------------------------------
1288 // ANIMATED path: individual per-node point mesh (local space).
1289 // No transform baking — the Entity hierarchy handles it.
1290 // -------------------------------------------------------
1291 std::vector<PackedPointVertex> pointVertices(pointVertexCount);
1292 Vector3 ptMin(std::numeric_limits<float>::max());
1293 Vector3 ptMax(std::numeric_limits<float>::lowest());
1294
1295 for (size_t i = 0; i < pointVertexCount; ++i) {
1296 Vector3 pos;
1297 if (!readFloatVec3(model, *positionAccessor, i, pos)) {
1298 continue;
1299 }
1300 // NO transform baking — keep local-space positions.
1301
1302 float cr = 1.0f, cg = 1.0f, cb = 1.0f, ca = 1.0f;
1303 if (colorAccessor) {
1304 if (colorAccessor->type == TINYGLTF_TYPE_VEC4) {
1305 Vector4 color;
1306 if (readFloatVec4(model, *colorAccessor, i, color)) {
1307 cr = color.getX(); cg = color.getY();
1308 cb = color.getZ(); ca = color.getW();
1309 }
1310 } else if (colorAccessor->type == TINYGLTF_TYPE_VEC3) {
1311 Vector3 color;
1312 if (readFloatVec3(model, *colorAccessor, i, color)) {
1313 cr = color.getX(); cg = color.getY();
1314 cb = color.getZ(); ca = 1.0f;
1315 }
1316 }
1317 }
1318
1319 pointVertices[i] = PackedPointVertex{
1320 pos.getX(), pos.getY(), pos.getZ(),
1321 cr, cg, cb, ca
1322 };
1323
1324 ptMin = Vector3(
1325 std::min(ptMin.getX(), pos.getX()),
1326 std::min(ptMin.getY(), pos.getY()),
1327 std::min(ptMin.getZ(), pos.getZ())
1328 );
1329 ptMax = Vector3(
1330 std::max(ptMax.getX(), pos.getX()),
1331 std::max(ptMax.getY(), pos.getY()),
1332 std::max(ptMax.getZ(), pos.getZ())
1333 );
1334 }
1335
1336 auto pointVertexFormat = std::make_shared<VertexFormat>(
1337 static_cast<int>(sizeof(PackedPointVertex)), true, false);
1338
1339 std::vector<uint8_t> pointVertexBytes(pointVertices.size() * sizeof(PackedPointVertex));
1340 std::memcpy(pointVertexBytes.data(), pointVertices.data(), pointVertexBytes.size());
1341
1342 VertexBufferOptions vbOpts;
1343 vbOpts.data = std::move(pointVertexBytes);
1344 auto pointVB = device->createVertexBuffer(
1345 pointVertexFormat,
1346 static_cast<int>(pointVertices.size()),
1347 vbOpts);
1348
1349 if (pointVB) {
1350 auto meshResource = std::make_shared<Mesh>();
1351 meshResource->setVertexBuffer(pointVB);
1352
1353 Primitive drawPrimitive;
1354 drawPrimitive.type = PRIMITIVE_POINTS;
1355 drawPrimitive.base = 0;
1356 drawPrimitive.baseVertex = 0;
1357 drawPrimitive.count = static_cast<int>(pointVertices.size());
1358 drawPrimitive.indexed = false;
1359 meshResource->setPrimitive(drawPrimitive, 0);
1360
1361 BoundingBox bounds;
1362 bounds.setCenter((ptMin + ptMax) * 0.5f);
1363 bounds.setHalfExtents((ptMax - ptMin) * 0.5f);
1364 meshResource->setAabb(bounds);
1365
1366 // Clone material with point-specific variant bits.
1367 std::shared_ptr<Material> pointMaterial;
1368 const int matIdx = primitive.material >= 0 ? primitive.material : 0;
1369 if (matIdx < static_cast<int>(gltfMaterials.size())) {
1370 pointMaterial = std::make_shared<StandardMaterial>(
1371 *std::static_pointer_cast<StandardMaterial>(
1372 gltfMaterials[static_cast<size_t>(matIdx)]));
1373 } else {
1374 pointMaterial = std::make_shared<StandardMaterial>(
1375 *std::static_pointer_cast<StandardMaterial>(gltfMaterials.front()));
1376 }
1377 uint64_t ptVariant = pointMaterial->shaderVariantKey();
1378 ptVariant |= (1ull << 21); // VT_FEATURE_VERTEX_COLORS
1379 ptVariant |= (1ull << 31); // VT_FEATURE_POINT_SIZE
1380 ptVariant |= (1ull << 32); // VT_FEATURE_UNLIT
1381 pointMaterial->setShaderVariantKey(ptVariant);
1382
1383 // Additive blending for animated point clouds — particles glow and
1384 // accumulate light. Disable depth write so particles don't z-fight.
1385 pointMaterial->setTransparent(true);
1386 pointMaterial->setBlendState(std::make_shared<BlendState>(BlendState::additiveBlend()));
1387 pointMaterial->setDepthState(std::make_shared<DepthState>(DepthState::noWrite()));
1388
1389 GlbMeshPayload payload;
1390 payload.mesh = meshResource;
1391 payload.material = pointMaterial;
1392 payload.castShadow = false;
1393 container->addMeshPayload(payload);
1394 meshToPayloadIndices[meshIndex].push_back(nextPayloadIndex++);
1395 }
1396 } else {
1397 // -------------------------------------------------------
1398 // STATIC path: accumulate into merged buffer (world space).
1399 // -------------------------------------------------------
1400 if (mergedPointMaterialIndex < 0) {
1401 mergedPointMaterialIndex = primitive.material >= 0 ? primitive.material : 0;
1402 }
1403
1404 // Look up the world matrix for this mesh's node to bake transforms.
1405 const Matrix4 meshWorldMatrix =
1406 (meshToNodeIndex[meshIndex] >= 0)
1407 ? nodeWorldMatrices[static_cast<size_t>(meshToNodeIndex[meshIndex])]
1409
1410 const size_t baseIndex = mergedPointVertices.size();
1411 mergedPointVertices.resize(baseIndex + pointVertexCount);
1412
1413 for (size_t i = 0; i < pointVertexCount; ++i) {
1414 Vector3 pos;
1415 if (!readFloatVec3(model, *positionAccessor, i, pos)) {
1416 continue;
1417 }
1418 // Bake node transform into vertex position (world space).
1419 pos = meshWorldMatrix.transformPoint(pos);
1420
1421 float cr = 1.0f, cg = 1.0f, cb = 1.0f, ca = 1.0f;
1422 if (colorAccessor) {
1423 if (colorAccessor->type == TINYGLTF_TYPE_VEC4) {
1424 Vector4 color;
1425 if (readFloatVec4(model, *colorAccessor, i, color)) {
1426 cr = color.getX(); cg = color.getY();
1427 cb = color.getZ(); ca = color.getW();
1428 }
1429 } else if (colorAccessor->type == TINYGLTF_TYPE_VEC3) {
1430 Vector3 color;
1431 if (readFloatVec3(model, *colorAccessor, i, color)) {
1432 cr = color.getX(); cg = color.getY();
1433 cb = color.getZ(); ca = 1.0f;
1434 }
1435 }
1436 }
1437
1438 mergedPointVertices[baseIndex + i] = PackedPointVertex{
1439 pos.getX(), pos.getY(), pos.getZ(),
1440 cr, cg, cb, ca
1441 };
1442
1443 mergedPtMin = Vector3(
1444 std::min(mergedPtMin.getX(), pos.getX()),
1445 std::min(mergedPtMin.getY(), pos.getY()),
1446 std::min(mergedPtMin.getZ(), pos.getZ())
1447 );
1448 mergedPtMax = Vector3(
1449 std::max(mergedPtMax.getX(), pos.getX()),
1450 std::max(mergedPtMax.getY(), pos.getY()),
1451 std::max(mergedPtMax.getZ(), pos.getZ())
1452 );
1453 }
1454 }
1455 continue;
1456 }
1457
1458 // ---------------------------------------------------------------
1459 // Non-POINTS primitives: triangles, lines, etc.
1460 // ---------------------------------------------------------------
1461 std::vector<PackedVertex> vertices;
1462 std::vector<uint32_t> parsedIndices;
1463 Vector3 minPos(std::numeric_limits<float>::max());
1464 Vector3 maxPos(std::numeric_limits<float>::lowest());
1465
1466 bool decodedDraco = false;
1467 if (primitiveUsesDraco(primitive)) {
1468 dracoPrimitiveCount++;
1469 decodedDraco = decodeDracoPrimitive(model, primitive, vertices, parsedIndices, minPos, maxPos);
1470 if (!decodedDraco) {
1471 dracoDecodeFailureCount++;
1472 spdlog::warn("Skipping glTF primitive due to Draco decode failure (mesh={})", meshIndex);
1473 continue;
1474 }
1475 dracoDecodeSuccessCount++;
1476 }
1477
1478 if (!decodedDraco) {
1479 if (!primitive.attributes.contains("POSITION")) {
1480 continue;
1481 }
1482
1483 const auto* positionAccessor = getAccessor(model, primitive.attributes.at("POSITION"));
1484 if (!positionAccessor || positionAccessor->count <= 0) {
1485 continue;
1486 }
1487 if (positionAccessor->componentType != TINYGLTF_COMPONENT_TYPE_FLOAT ||
1488 positionAccessor->type != TINYGLTF_TYPE_VEC3) {
1489 continue;
1490 }
1491
1492 const auto* normalAccessor = primitive.attributes.contains("NORMAL")
1493 ? getAccessor(model, primitive.attributes.at("NORMAL")) : nullptr;
1494 const auto* uvAccessor = primitive.attributes.contains("TEXCOORD_0")
1495 ? getAccessor(model, primitive.attributes.at("TEXCOORD_0")) : nullptr;
1496 const auto* uv1Accessor = primitive.attributes.contains("TEXCOORD_1")
1497 ? getAccessor(model, primitive.attributes.at("TEXCOORD_1")) : nullptr;
1498 const auto* tangentAccessor = primitive.attributes.contains("TANGENT")
1499 ? getAccessor(model, primitive.attributes.at("TANGENT")) : nullptr;
1500
1501 const auto vertexCount = static_cast<size_t>(positionAccessor->count);
1502 vertices.resize(vertexCount);
1503 for (size_t i = 0; i < vertexCount; ++i) {
1504 Vector3 pos;
1505 if (!readFloatVec3(model, *positionAccessor, i, pos)) {
1506 continue;
1507 }
1508
1509 Vector3 normal(0.0f, 1.0f, 0.0f);
1510 if (normalAccessor) {
1511 Vector3 n;
1512 if (readFloatVec3(model, *normalAccessor, i, n)) {
1513 normal = n;
1514 }
1515 }
1516
1517 float u = 0.0f;
1518 float v = 0.0f;
1519 if (uvAccessor) {
1520 readFloatVec2(model, *uvAccessor, i, u, v);
1521 // glTF UVs are authored for GL-style sampling conventions.
1522 // Metal texture sampling uses top-left origin, so flip V here.
1523 v = 1.0f - v;
1524 }
1525 float u1 = u;
1526 float v1 = v;
1527 if (uv1Accessor) {
1528 readFloatVec2(model, *uv1Accessor, i, u1, v1);
1529 v1 = 1.0f - v1;
1530 }
1531
1532 // If glTF tangents are missing, keep tangent zero so shader can use
1533 // derivative-based fallback TBN instead of a bogus fixed tangent basis.
1534 Vector4 tangent(0.0f, 0.0f, 0.0f, 1.0f);
1535 if (tangentAccessor) {
1536 Vector4 t;
1537 if (readFloatVec4(model, *tangentAccessor, i, t)) {
1538 // We flip V to match the runtime texture sampling convention.
1539 // For imported glTF tangents this requires flipping handedness.
1540 t = Vector4(t.getX(), t.getY(), t.getZ(), -t.getW());
1541 tangent = t;
1542 }
1543 }
1544
1545 vertices[i] = PackedVertex{
1546 pos.getX(), pos.getY(), pos.getZ(),
1547 normal.getX(), normal.getY(), normal.getZ(),
1548 u, v,
1549 tangent.getX(), tangent.getY(), tangent.getZ(), tangent.getW(),
1550 u1, v1
1551 };
1552
1553 minPos = Vector3(
1554 std::min(minPos.getX(), pos.getX()),
1555 std::min(minPos.getY(), pos.getY()),
1556 std::min(minPos.getZ(), pos.getZ())
1557 );
1558 maxPos = Vector3(
1559 std::max(maxPos.getX(), pos.getX()),
1560 std::max(maxPos.getY(), pos.getY()),
1561 std::max(maxPos.getZ(), pos.getZ())
1562 );
1563 }
1564
1565 if (!tangentAccessor && primitive.mode == TINYGLTF_MODE_TRIANGLES) {
1566 if (primitive.indices >= 0) {
1567 if (const auto* indexAccessor = getAccessor(model, primitive.indices)) {
1568 readIndices(model, *indexAccessor, parsedIndices);
1569 }
1570 }
1571 generateTangents(vertices, parsedIndices.empty() ? nullptr : &parsedIndices);
1572 }
1573 }
1574
1575 const auto vertexCount = vertices.size();
1576 if (vertexCount == 0) {
1577 continue;
1578 }
1579
1580 if (!decodedDraco && primitive.indices >= 0 && parsedIndices.empty()) {
1581 if (primitive.indices >= 0) {
1582 if (const auto* indexAccessor = getAccessor(model, primitive.indices)) {
1583 readIndices(model, *indexAccessor, parsedIndices);
1584 }
1585 }
1586 }
1587
1588 std::vector<uint8_t> vertexBytes(vertices.size() * sizeof(PackedVertex));
1589 std::memcpy(vertexBytes.data(), vertices.data(), vertexBytes.size());
1590 VertexBufferOptions vbOptions;
1591 vbOptions.data = std::move(vertexBytes);
1592 auto vertexBuffer = device->createVertexBuffer(vertexFormat, static_cast<int>(vertexCount), vbOptions);
1593 if (!vertexBuffer) {
1594 spdlog::warn("GLB parse: vertex buffer creation failed mesh={} primitive", meshIndex);
1595 continue;
1596 }
1597
1598 std::shared_ptr<IndexBuffer> indexBuffer;
1599 int drawCount = static_cast<int>(vertexCount);
1600 bool indexed = false;
1601 if (primitive.indices >= 0) {
1602 if (parsedIndices.empty()) {
1603 const auto* indexAccessor = getAccessor(model, primitive.indices);
1604 if (indexAccessor) {
1605 readIndices(model, *indexAccessor, parsedIndices);
1606 }
1607 }
1608 if (!parsedIndices.empty()) {
1609 std::vector<uint8_t> indexBytes(parsedIndices.size() * sizeof(uint32_t));
1610 std::memcpy(indexBytes.data(), parsedIndices.data(), indexBytes.size());
1611 indexBuffer = device->createIndexBuffer(INDEXFORMAT_UINT32, static_cast<int>(parsedIndices.size()), indexBytes);
1612 drawCount = static_cast<int>(parsedIndices.size());
1613 indexed = true;
1614 }
1615 }
1616
1617 auto meshResource = std::make_shared<Mesh>();
1618 meshResource->setVertexBuffer(vertexBuffer);
1619 meshResource->setIndexBuffer(indexBuffer, 0);
1620
1621 Primitive drawPrimitive;
1622 drawPrimitive.type = mapPrimitiveType(primitive.mode);
1623 drawPrimitive.base = 0;
1624 drawPrimitive.baseVertex = 0;
1625 drawPrimitive.count = drawCount;
1626 drawPrimitive.indexed = indexed;
1627 meshResource->setPrimitive(drawPrimitive, 0);
1628
1629 BoundingBox bounds;
1630 bounds.setCenter((minPos + maxPos) * 0.5f);
1631 bounds.setHalfExtents((maxPos - minPos) * 0.5f);
1632 meshResource->setAabb(bounds);
1633
1634 GlbMeshPayload payload;
1635 payload.mesh = meshResource;
1636 if (primitive.material >= 0 && primitive.material < static_cast<int>(gltfMaterials.size())) {
1637 payload.material = gltfMaterials[static_cast<size_t>(primitive.material)];
1638 } else {
1639 payload.material = gltfMaterials.front();
1640 }
1641 container->addMeshPayload(payload);
1642 meshToPayloadIndices[meshIndex].push_back(nextPayloadIndex++);
1643 }
1644 }
1645
1646 // ---------------------------------------------------------------
1647 // Post-loop: create a single merged draw call for all POINTS primitives.
1648 // Only when the model has no animations (static merge path).
1649 // ---------------------------------------------------------------
1650 if (!hasAnimations && !mergedPointVertices.empty()) {
1651 auto pointVertexFormat = std::make_shared<VertexFormat>(
1652 static_cast<int>(sizeof(PackedPointVertex)), true, false);
1653
1654 std::vector<uint8_t> pointVertexBytes(mergedPointVertices.size() * sizeof(PackedPointVertex));
1655 std::memcpy(pointVertexBytes.data(), mergedPointVertices.data(), pointVertexBytes.size());
1656
1657 VertexBufferOptions vbOptions;
1658 vbOptions.data = std::move(pointVertexBytes);
1659 auto pointVB = device->createVertexBuffer(
1660 pointVertexFormat,
1661 static_cast<int>(mergedPointVertices.size()),
1662 vbOptions);
1663
1664 if (pointVB) {
1665 auto meshResource = std::make_shared<Mesh>();
1666 meshResource->setVertexBuffer(pointVB);
1667
1668 Primitive drawPrimitive;
1669 drawPrimitive.type = PRIMITIVE_POINTS;
1670 drawPrimitive.base = 0;
1671 drawPrimitive.baseVertex = 0;
1672 drawPrimitive.count = static_cast<int>(mergedPointVertices.size());
1673 drawPrimitive.indexed = false;
1674 meshResource->setPrimitive(drawPrimitive, 0);
1675
1676 BoundingBox bounds;
1677 bounds.setCenter((mergedPtMin + mergedPtMax) * 0.5f);
1678 bounds.setHalfExtents((mergedPtMax - mergedPtMin) * 0.5f);
1679 meshResource->setAabb(bounds);
1680
1681 // Clone the material so point-specific bits don't leak to triangle meshes.
1682 std::shared_ptr<Material> pointMaterial;
1683 if (mergedPointMaterialIndex >= 0 &&
1684 mergedPointMaterialIndex < static_cast<int>(gltfMaterials.size())) {
1685 pointMaterial = std::make_shared<StandardMaterial>(
1686 *std::static_pointer_cast<StandardMaterial>(
1687 gltfMaterials[static_cast<size_t>(mergedPointMaterialIndex)]));
1688 } else {
1689 pointMaterial = std::make_shared<StandardMaterial>(
1690 *std::static_pointer_cast<StandardMaterial>(gltfMaterials.front()));
1691 }
1692
1693 // Add vertexColors (bit 21) + pointSize (bit 31) to variant key.
1694 uint64_t ptVariant = pointMaterial->shaderVariantKey();
1695 ptVariant |= (1ull << 21); // VT_FEATURE_VERTEX_COLORS
1696 ptVariant |= (1ull << 31); // VT_FEATURE_POINT_SIZE
1697 pointMaterial->setShaderVariantKey(ptVariant);
1698
1699 GlbMeshPayload payload;
1700 payload.mesh = meshResource;
1701 payload.material = pointMaterial;
1702 payload.castShadow = false; // Points don't cast meaningful shadows.
1703 container->addMeshPayload(payload);
1704 // Remember the payload index for the synthetic node created after glTF nodes.
1705 mergedPointPayloadIndex = nextPayloadIndex++;
1706
1707 spdlog::info("GLB merged {} point vertices into 1 draw call (AABB {:.2f}–{:.2f})",
1708 mergedPointVertices.size(),
1709 mergedPtMin.getX(), mergedPtMax.getX());
1710 }
1711 }
1712
1713 // Identify which meshes are fully consumed by the POINTS merge (all primitives are POINTS).
1714 // Only relevant when not animated — animated models keep individual entities.
1715 std::vector<bool> meshFullyConsumed(model.meshes.size(), false);
1716 if (!hasAnimations) {
1717 for (size_t mi = 0; mi < model.meshes.size(); ++mi) {
1718 const auto& m = model.meshes[mi];
1719 bool allPoints = !m.primitives.empty();
1720 for (const auto& prim : m.primitives) {
1721 if (prim.mode != TINYGLTF_MODE_POINTS) {
1722 allPoints = false;
1723 break;
1724 }
1725 }
1726 meshFullyConsumed[mi] = allPoints;
1727 }
1728 }
1729
1730 // Build node payloads preserving glTF hierarchy / local transforms.
1731 for (const auto& node : model.nodes) {
1732 GlbNodePayload nodePayload;
1733 nodePayload.name = node.name;
1734
1735 if (!node.matrix.empty()) {
1736 decomposeNodeMatrix(node.matrix, nodePayload.translation, nodePayload.rotation, nodePayload.scale);
1737 }
1738 if (node.translation.size() == 3) {
1739 nodePayload.translation = Vector3(
1740 static_cast<float>(node.translation[0]),
1741 static_cast<float>(node.translation[1]),
1742 static_cast<float>(node.translation[2])
1743 );
1744 }
1745 if (node.rotation.size() == 4) {
1746 nodePayload.rotation = Quaternion(
1747 static_cast<float>(node.rotation[0]),
1748 static_cast<float>(node.rotation[1]),
1749 static_cast<float>(node.rotation[2]),
1750 static_cast<float>(node.rotation[3])
1751 ).normalized();
1752 }
1753 if (node.scale.size() == 3) {
1754 nodePayload.scale = Vector3(
1755 static_cast<float>(node.scale[0]),
1756 static_cast<float>(node.scale[1]),
1757 static_cast<float>(node.scale[2])
1758 );
1759 }
1760
1761 if (node.mesh >= 0 && node.mesh < static_cast<int>(meshToPayloadIndices.size())) {
1762 const auto& mapped = meshToPayloadIndices[static_cast<size_t>(node.mesh)];
1763 nodePayload.meshPayloadIndices.insert(nodePayload.meshPayloadIndices.end(), mapped.begin(), mapped.end());
1764 }
1765
1766 // Skip leaf nodes whose mesh was fully consumed by the POINTS merge.
1767 // Transforms are already baked into the merged vertex buffer, so these
1768 // nodes serve no purpose and only add scene graph overhead.
1769 if (node.children.empty() && node.mesh >= 0 &&
1770 node.mesh < static_cast<int>(meshFullyConsumed.size()) &&
1771 meshFullyConsumed[static_cast<size_t>(node.mesh)]) {
1772 nodePayload.skip = true;
1773 }
1774
1775 nodePayload.children = node.children;
1776 container->addNodePayload(nodePayload);
1777 }
1778
1779 // Append synthetic node for the merged point cloud (identity transform).
1780 if (mergedPointPayloadIndex != SIZE_MAX) {
1781 GlbNodePayload pointNode;
1782 pointNode.name = "__merged_point_cloud";
1783 pointNode.meshPayloadIndices.push_back(mergedPointPayloadIndex);
1784 container->addNodePayload(pointNode);
1785 // Add as root so instantiateRenderEntity() picks it up.
1786 container->addRootNodeIndex(static_cast<int>(model.nodes.size()));
1787 }
1788
1789 int sceneIndex = model.defaultScene;
1790 if (sceneIndex < 0 && !model.scenes.empty()) {
1791 sceneIndex = 0;
1792 }
1793 if (sceneIndex >= 0 && sceneIndex < static_cast<int>(model.scenes.size())) {
1794 const auto& scene = model.scenes[static_cast<size_t>(sceneIndex)];
1795 for (const auto nodeIndex : scene.nodes) {
1796 container->addRootNodeIndex(nodeIndex);
1797 }
1798 }
1799
1800 if (dracoPrimitiveCount > 0) {
1801 spdlog::info(
1802 "GLB Draco summary [{}]: primitives={}, decoded={}, failed={}",
1803 path,
1804 dracoPrimitiveCount,
1805 dracoDecodeSuccessCount,
1806 dracoDecodeFailureCount
1807 );
1808 }
1809
1810 // Parse glTF animations into AnimTrack objects.
1811 parseAnimations(model, container.get());
1812
1813 return container;
1814 }
1815
1816 std::unique_ptr<GlbContainerResource> GlbParser::parseFromMemory(
1817 const std::uint8_t* data, const std::size_t length,
1818 const std::shared_ptr<GraphicsDevice>& device,
1819 const std::string& debugName)
1820 {
1821 if (!device) {
1822 spdlog::error("GLB parseFromMemory failed: graphics device is null");
1823 return nullptr;
1824 }
1825 if (!data || length == 0) {
1826 spdlog::error("GLB parseFromMemory failed [{}]: empty data", debugName);
1827 return nullptr;
1828 }
1829
1830 tinygltf::TinyGLTF loader;
1831 loader.SetImageLoader(GlbParser::loadImageData, nullptr);
1832 tinygltf::Model model;
1833 std::string warn;
1834 std::string err;
1835 const bool ok = loader.LoadBinaryFromMemory(
1836 &model, &err, &warn,
1837 data, static_cast<unsigned int>(length));
1838 if (!warn.empty()) {
1839 spdlog::warn("GLB parse warning [{}]: {}", debugName, warn);
1840 }
1841 if (!ok) {
1842 spdlog::error("GLB parseFromMemory failed [{}]: {}", debugName, err);
1843 return nullptr;
1844 }
1845
1846 return createFromModel(model, device, debugName);
1847 }
1848
1849 // ── createFromModel: GPU resource creation from pre-parsed model ────
1850
1851 std::unique_ptr<GlbContainerResource> GlbParser::createFromModel(
1852 tinygltf::Model& model,
1853 const std::shared_ptr<GraphicsDevice>& device,
1854 const std::string& debugName)
1855 {
1856 if (!device) {
1857 spdlog::error("GLB createFromModel failed: graphics device is null");
1858 return nullptr;
1859 }
1860
1861 auto container = std::make_unique<GlbContainerResource>();
1862 auto vertexFormat = std::make_shared<VertexFormat>(sizeof(PackedVertex), true, false);
1863 size_t dracoPrimitiveCount = 0;
1864 size_t dracoDecodeSuccessCount = 0;
1865 size_t dracoDecodeFailureCount = 0;
1866
1867 auto makeDefaultMaterial = []() {
1868 auto material = std::make_shared<StandardMaterial>();
1869 material->setName("glTF-default");
1870 material->setTransparent(false);
1871 material->setAlphaMode(AlphaMode::OPAQUE);
1872 material->setMetallicFactor(0.0f);
1873 material->setRoughnessFactor(1.0f);
1874 material->setShaderVariantKey(1);
1875 return material;
1876 };
1877
1878 std::vector<std::shared_ptr<Material>> gltfMaterials;
1879 gltfMaterials.reserve(std::max<size_t>(1, model.materials.size()));
1880 std::vector<std::shared_ptr<Texture>> gltfTextures(model.textures.size());
1881
1882 auto getOrCreateTexture = [&](const int textureIndex) -> std::shared_ptr<Texture> {
1883 if (textureIndex < 0 || textureIndex >= static_cast<int>(model.textures.size())) {
1884 return nullptr;
1885 }
1886 auto& cached = gltfTextures[static_cast<size_t>(textureIndex)];
1887 if (cached) return cached;
1888
1889 const auto& srcTexture = model.textures[static_cast<size_t>(textureIndex)];
1890 int imageSource = srcTexture.source;
1891 if (imageSource < 0) {
1892 auto it = srcTexture.extensions.find("KHR_texture_basisu");
1893 if (it != srcTexture.extensions.end() && it->second.IsObject()) {
1894 auto sourceVal = it->second.Get("source");
1895 if (sourceVal.IsInt()) imageSource = sourceVal.GetNumberAsInt();
1896 }
1897 }
1898 if (imageSource < 0 || imageSource >= static_cast<int>(model.images.size())) return nullptr;
1899
1900 const auto& srcImage = model.images[static_cast<size_t>(imageSource)];
1901 std::vector<uint8_t> rgbaPixels;
1902 if (!buildRgba8Image(srcImage, rgbaPixels)) return nullptr;
1903
1904 TextureOptions options;
1905 options.width = static_cast<uint32_t>(srcImage.width);
1906 options.height = static_cast<uint32_t>(srcImage.height);
1908 options.mipmaps = false;
1909 options.numLevels = 1;
1912 options.name = srcImage.name.empty() ? srcTexture.name : srcImage.name;
1913
1914 auto texture = std::make_shared<Texture>(device.get(), options);
1915 texture->setLevelData(0, rgbaPixels.data(), rgbaPixels.size());
1916
1917 if (srcTexture.sampler >= 0 && srcTexture.sampler < static_cast<int>(model.samplers.size())) {
1918 const auto& sampler = model.samplers[static_cast<size_t>(srcTexture.sampler)];
1919 if (sampler.minFilter != -1) {
1920 auto minFilter = mapMinFilter(sampler.minFilter);
1925 minFilter = FilterMode::FILTER_LINEAR;
1926 }
1927 texture->setMinFilter(minFilter);
1928 }
1929 if (sampler.magFilter != -1) texture->setMagFilter(mapMagFilter(sampler.magFilter));
1930 texture->setAddressU(mapWrapMode(sampler.wrapS));
1931 texture->setAddressV(mapWrapMode(sampler.wrapT));
1932 }
1933
1934 texture->upload();
1935 container->addOwnedTexture(texture);
1936 cached = texture;
1937 return cached;
1938 };
1939
1940 if (model.materials.empty()) {
1941 gltfMaterials.push_back(makeDefaultMaterial());
1942 } else {
1943 for (size_t materialIndex = 0; materialIndex < model.materials.size(); ++materialIndex) {
1944 const auto& srcMaterial = model.materials[materialIndex];
1945 auto material = std::make_shared<StandardMaterial>();
1946 material->setName(srcMaterial.name.empty() ? "glTF-material" : srcMaterial.name);
1947
1948 const auto& pbr = srcMaterial.pbrMetallicRoughness;
1949 if (pbr.baseColorFactor.size() == 4) {
1950 const Color baseColor(
1951 static_cast<float>(pbr.baseColorFactor[0]),
1952 static_cast<float>(pbr.baseColorFactor[1]),
1953 static_cast<float>(pbr.baseColorFactor[2]),
1954 static_cast<float>(pbr.baseColorFactor[3]));
1955 material->setBaseColorFactor(baseColor);
1956 Color diffuseColor(baseColor);
1957 diffuseColor.gamma();
1958 material->setDiffuse(diffuseColor);
1959 material->setOpacity(baseColor.a);
1960 }
1961 material->setMetallicFactor(static_cast<float>(pbr.metallicFactor));
1962 material->setRoughnessFactor(static_cast<float>(pbr.roughnessFactor));
1963 material->setMetalness(static_cast<float>(pbr.metallicFactor));
1964 material->setGloss(1.0f - static_cast<float>(pbr.roughnessFactor));
1965
1966 if (!srcMaterial.alphaMode.empty()) {
1967 if (srcMaterial.alphaMode == "BLEND") {
1968 material->setAlphaMode(AlphaMode::BLEND);
1969 material->setTransparent(true);
1970 } else if (srcMaterial.alphaMode == "MASK") {
1971 material->setAlphaMode(AlphaMode::MASK);
1972 } else {
1973 material->setAlphaMode(AlphaMode::OPAQUE);
1974 }
1975 }
1976 material->setCullMode(srcMaterial.doubleSided ? CullMode::CULLFACE_NONE : CullMode::CULLFACE_BACK);
1977 material->setAlphaCutoff(static_cast<float>(srcMaterial.alphaCutoff));
1978
1979 if (pbr.baseColorTexture.index >= 0) {
1980 if (auto tex = getOrCreateTexture(pbr.baseColorTexture.index)) {
1981 material->setBaseColorTexture(tex.get());
1982 material->setHasBaseColorTexture(true);
1983 material->setBaseColorUvSet(pbr.baseColorTexture.texCoord);
1984 }
1985 }
1986 if (srcMaterial.normalTexture.index >= 0) {
1987 if (auto tex = getOrCreateTexture(srcMaterial.normalTexture.index)) {
1988 material->setNormalTexture(tex.get());
1989 material->setHasNormalTexture(true);
1990 material->setNormalUvSet(srcMaterial.normalTexture.texCoord);
1991 }
1992 material->setNormalScale(static_cast<float>(srcMaterial.normalTexture.scale));
1993 }
1994 if (pbr.metallicRoughnessTexture.index >= 0) {
1995 if (auto tex = getOrCreateTexture(pbr.metallicRoughnessTexture.index)) {
1996 material->setMetallicRoughnessTexture(tex.get());
1997 material->setHasMetallicRoughnessTexture(true);
1998 }
1999 }
2000 if (srcMaterial.emissiveFactor.size() == 3) {
2001 Color emissiveColor(
2002 static_cast<float>(srcMaterial.emissiveFactor[0]),
2003 static_cast<float>(srcMaterial.emissiveFactor[1]),
2004 static_cast<float>(srcMaterial.emissiveFactor[2]), 1.0f);
2005 emissiveColor.gamma();
2006 material->setEmissiveFactor(emissiveColor);
2007 }
2008
2009 uint64_t variant = 1;
2010 if (material->hasBaseColorTexture()) variant |= (1ull << 1);
2011 if (material->hasNormalTexture()) variant |= (1ull << 4);
2012 if (material->hasMetallicRoughnessTexture()) variant |= (1ull << 5);
2013 if (material->alphaMode() == AlphaMode::BLEND) variant |= (1ull << 2);
2014 else if (material->alphaMode() == AlphaMode::MASK) variant |= (1ull << 3);
2015 material->setShaderVariantKey(variant);
2016 gltfMaterials.push_back(material);
2017 }
2018 }
2019
2020 std::vector<std::vector<size_t>> meshToPayloadIndices(model.meshes.size());
2021 size_t nextPayloadIndex = 0;
2022 for (size_t meshIndex = 0; meshIndex < model.meshes.size(); ++meshIndex) {
2023 const auto& mesh = model.meshes[meshIndex];
2024 for (const auto& primitive : mesh.primitives) {
2025 std::vector<PackedVertex> vertices;
2026 std::vector<uint32_t> parsedIndices;
2027 Vector3 minPos(std::numeric_limits<float>::max());
2028 Vector3 maxPos(std::numeric_limits<float>::lowest());
2029
2030 bool decodedDraco = false;
2031 if (primitiveUsesDraco(primitive)) {
2032 dracoPrimitiveCount++;
2033 decodedDraco = decodeDracoPrimitive(model, primitive, vertices, parsedIndices, minPos, maxPos);
2034 if (!decodedDraco) { dracoDecodeFailureCount++; continue; }
2035 dracoDecodeSuccessCount++;
2036 }
2037
2038 if (!decodedDraco) {
2039 if (!primitive.attributes.contains("POSITION")) continue;
2040 const auto* posAcc = getAccessor(model, primitive.attributes.at("POSITION"));
2041 if (!posAcc || posAcc->count <= 0) continue;
2042 if (posAcc->componentType != TINYGLTF_COMPONENT_TYPE_FLOAT || posAcc->type != TINYGLTF_TYPE_VEC3) continue;
2043
2044 const auto* normalAcc = primitive.attributes.contains("NORMAL") ? getAccessor(model, primitive.attributes.at("NORMAL")) : nullptr;
2045 const auto* uvAcc = primitive.attributes.contains("TEXCOORD_0") ? getAccessor(model, primitive.attributes.at("TEXCOORD_0")) : nullptr;
2046 const auto* uv1Acc = primitive.attributes.contains("TEXCOORD_1") ? getAccessor(model, primitive.attributes.at("TEXCOORD_1")) : nullptr;
2047 const auto* tanAcc = primitive.attributes.contains("TANGENT") ? getAccessor(model, primitive.attributes.at("TANGENT")) : nullptr;
2048
2049 const auto vCount = static_cast<size_t>(posAcc->count);
2050 vertices.resize(vCount);
2051 for (size_t i = 0; i < vCount; ++i) {
2052 Vector3 pos;
2053 if (!readFloatVec3(model, *posAcc, i, pos)) continue;
2054 Vector3 normal(0.0f, 1.0f, 0.0f);
2055 if (normalAcc) { Vector3 n; if (readFloatVec3(model, *normalAcc, i, n)) normal = n; }
2056 float u = 0.0f, v = 0.0f;
2057 if (uvAcc) { readFloatVec2(model, *uvAcc, i, u, v); v = 1.0f - v; }
2058 float u1 = u, v1 = v;
2059 if (uv1Acc) { readFloatVec2(model, *uv1Acc, i, u1, v1); v1 = 1.0f - v1; }
2060 Vector4 tangent(0.0f, 0.0f, 0.0f, 1.0f);
2061 if (tanAcc) { Vector4 t; if (readFloatVec4(model, *tanAcc, i, t)) { tangent = Vector4(t.getX(), t.getY(), t.getZ(), -t.getW()); } }
2062
2063 vertices[i] = PackedVertex{
2064 pos.getX(), pos.getY(), pos.getZ(),
2065 normal.getX(), normal.getY(), normal.getZ(),
2066 u, v, tangent.getX(), tangent.getY(), tangent.getZ(), tangent.getW(),
2067 u1, v1
2068 };
2069 minPos = Vector3(std::min(minPos.getX(), pos.getX()), std::min(minPos.getY(), pos.getY()), std::min(minPos.getZ(), pos.getZ()));
2070 maxPos = Vector3(std::max(maxPos.getX(), pos.getX()), std::max(maxPos.getY(), pos.getY()), std::max(maxPos.getZ(), pos.getZ()));
2071 }
2072 if (!tanAcc && primitive.mode == TINYGLTF_MODE_TRIANGLES) {
2073 if (primitive.indices >= 0) { if (const auto* ia = getAccessor(model, primitive.indices)) readIndices(model, *ia, parsedIndices); }
2074 generateTangents(vertices, parsedIndices.empty() ? nullptr : &parsedIndices);
2075 }
2076 }
2077
2078 if (vertices.empty()) continue;
2079
2080 if (!decodedDraco && primitive.indices >= 0 && parsedIndices.empty()) {
2081 if (const auto* ia = getAccessor(model, primitive.indices)) readIndices(model, *ia, parsedIndices);
2082 }
2083
2084 std::vector<uint8_t> vertexBytes(vertices.size() * sizeof(PackedVertex));
2085 std::memcpy(vertexBytes.data(), vertices.data(), vertexBytes.size());
2086 VertexBufferOptions vbOptions;
2087 vbOptions.data = std::move(vertexBytes);
2088 auto vb = device->createVertexBuffer(vertexFormat, static_cast<int>(vertices.size()), vbOptions);
2089 if (!vb) continue;
2090
2091 std::shared_ptr<IndexBuffer> ib;
2092 int drawCount = static_cast<int>(vertices.size());
2093 bool indexed = false;
2094 if (!parsedIndices.empty()) {
2095 std::vector<uint8_t> indexBytes(parsedIndices.size() * sizeof(uint32_t));
2096 std::memcpy(indexBytes.data(), parsedIndices.data(), indexBytes.size());
2097 ib = device->createIndexBuffer(INDEXFORMAT_UINT32, static_cast<int>(parsedIndices.size()), indexBytes);
2098 drawCount = static_cast<int>(parsedIndices.size());
2099 indexed = true;
2100 }
2101
2102 auto meshResource = std::make_shared<Mesh>();
2103 meshResource->setVertexBuffer(vb);
2104 meshResource->setIndexBuffer(ib, 0);
2105 Primitive drawPrimitive;
2106 drawPrimitive.type = mapPrimitiveType(primitive.mode);
2107 drawPrimitive.base = 0;
2108 drawPrimitive.baseVertex = 0;
2109 drawPrimitive.count = drawCount;
2110 drawPrimitive.indexed = indexed;
2111 meshResource->setPrimitive(drawPrimitive, 0);
2112
2113 BoundingBox bounds;
2114 bounds.setCenter((minPos + maxPos) * 0.5f);
2115 bounds.setHalfExtents((maxPos - minPos) * 0.5f);
2116 meshResource->setAabb(bounds);
2117
2118 GlbMeshPayload payload;
2119 payload.mesh = meshResource;
2120 payload.material = (primitive.material >= 0 && primitive.material < static_cast<int>(gltfMaterials.size()))
2121 ? gltfMaterials[static_cast<size_t>(primitive.material)] : gltfMaterials.front();
2122 container->addMeshPayload(payload);
2123 meshToPayloadIndices[meshIndex].push_back(nextPayloadIndex++);
2124 }
2125 }
2126
2127 for (const auto& node : model.nodes) {
2128 GlbNodePayload nodePayload;
2129 nodePayload.name = node.name;
2130 if (!node.matrix.empty()) decomposeNodeMatrix(node.matrix, nodePayload.translation, nodePayload.rotation, nodePayload.scale);
2131 if (node.translation.size() == 3) nodePayload.translation = Vector3(static_cast<float>(node.translation[0]), static_cast<float>(node.translation[1]), static_cast<float>(node.translation[2]));
2132 if (node.rotation.size() == 4) nodePayload.rotation = Quaternion(static_cast<float>(node.rotation[0]), static_cast<float>(node.rotation[1]), static_cast<float>(node.rotation[2]), static_cast<float>(node.rotation[3])).normalized();
2133 if (node.scale.size() == 3) nodePayload.scale = Vector3(static_cast<float>(node.scale[0]), static_cast<float>(node.scale[1]), static_cast<float>(node.scale[2]));
2134 if (node.mesh >= 0 && node.mesh < static_cast<int>(meshToPayloadIndices.size())) {
2135 const auto& mapped = meshToPayloadIndices[static_cast<size_t>(node.mesh)];
2136 nodePayload.meshPayloadIndices.insert(nodePayload.meshPayloadIndices.end(), mapped.begin(), mapped.end());
2137 }
2138 nodePayload.children = node.children;
2139 container->addNodePayload(nodePayload);
2140 }
2141
2142 int sceneIndex = model.defaultScene;
2143 if (sceneIndex < 0 && !model.scenes.empty()) sceneIndex = 0;
2144 if (sceneIndex >= 0 && sceneIndex < static_cast<int>(model.scenes.size())) {
2145 for (const auto nodeIndex : model.scenes[static_cast<size_t>(sceneIndex)].nodes)
2146 container->addRootNodeIndex(nodeIndex);
2147 }
2148
2149 if (dracoPrimitiveCount > 0) {
2150 spdlog::info("GLB Draco summary [{}]: primitives={}, decoded={}, failed={}",
2151 debugName, dracoPrimitiveCount, dracoDecodeSuccessCount, dracoDecodeFailureCount);
2152 }
2153
2154 // Parse glTF animations into AnimTrack objects.
2155 parseAnimations(model, container.get());
2156
2157 return container;
2158 }
2159
2160 // ── prepareFromModel: CPU-heavy work on background thread ────────
2161
2163 {
2164 PreparedGlbData result;
2165
2166 // ── Pre-convert all images to RGBA8 ──────────────────────────
2167 result.images.resize(model.images.size());
2168 for (size_t i = 0; i < model.images.size(); ++i) {
2169 auto& img = result.images[i];
2170 const auto& srcImage = model.images[i];
2171 img.valid = buildRgba8Image(srcImage, img.rgbaPixels);
2172 if (img.valid) {
2173 img.width = srcImage.width;
2174 img.height = srcImage.height;
2175 }
2176 }
2177
2178 // ── Pre-extract mesh primitive vertices/indices ──────────────
2179 result.meshPrimitives.resize(model.meshes.size());
2180 for (size_t meshIndex = 0; meshIndex < model.meshes.size(); ++meshIndex) {
2181 const auto& mesh = model.meshes[meshIndex];
2182 auto& primResults = result.meshPrimitives[meshIndex];
2183
2184 for (const auto& primitive : mesh.primitives) {
2186 pd.mode = primitive.mode;
2187 pd.materialIndex = primitive.material;
2188
2189 std::vector<PackedVertex> vertices;
2190 std::vector<uint32_t> parsedIndices;
2191 Vector3 minPos(std::numeric_limits<float>::max());
2192 Vector3 maxPos(std::numeric_limits<float>::lowest());
2193
2194 bool decodedDraco = false;
2195 if (primitiveUsesDraco(primitive)) {
2196 result.dracoPrimitiveCount++;
2197 decodedDraco = decodeDracoPrimitive(model, primitive, vertices, parsedIndices, minPos, maxPos);
2198 if (!decodedDraco) {
2199 result.dracoDecodeFailureCount++;
2200 continue;
2201 }
2202 result.dracoDecodeSuccessCount++;
2203 }
2204
2205 if (!decodedDraco) {
2206 if (!primitive.attributes.contains("POSITION")) continue;
2207 const auto* posAcc = getAccessor(model, primitive.attributes.at("POSITION"));
2208 if (!posAcc || posAcc->count <= 0) continue;
2209 if (posAcc->componentType != TINYGLTF_COMPONENT_TYPE_FLOAT || posAcc->type != TINYGLTF_TYPE_VEC3) continue;
2210
2211 const auto* normalAcc = primitive.attributes.contains("NORMAL") ? getAccessor(model, primitive.attributes.at("NORMAL")) : nullptr;
2212 const auto* uvAcc = primitive.attributes.contains("TEXCOORD_0") ? getAccessor(model, primitive.attributes.at("TEXCOORD_0")) : nullptr;
2213 const auto* uv1Acc = primitive.attributes.contains("TEXCOORD_1") ? getAccessor(model, primitive.attributes.at("TEXCOORD_1")) : nullptr;
2214 const auto* tanAcc = primitive.attributes.contains("TANGENT") ? getAccessor(model, primitive.attributes.at("TANGENT")) : nullptr;
2215
2216 const auto vCount = static_cast<size_t>(posAcc->count);
2217 vertices.resize(vCount);
2218 for (size_t i = 0; i < vCount; ++i) {
2219 Vector3 pos;
2220 if (!readFloatVec3(model, *posAcc, i, pos)) continue;
2221 Vector3 normal(0.0f, 1.0f, 0.0f);
2222 if (normalAcc) { Vector3 n; if (readFloatVec3(model, *normalAcc, i, n)) normal = n; }
2223 float u = 0.0f, v = 0.0f;
2224 if (uvAcc) { readFloatVec2(model, *uvAcc, i, u, v); v = 1.0f - v; }
2225 float u1 = u, v1 = v;
2226 if (uv1Acc) { readFloatVec2(model, *uv1Acc, i, u1, v1); v1 = 1.0f - v1; }
2227 Vector4 tangent(0.0f, 0.0f, 0.0f, 1.0f);
2228 if (tanAcc) { Vector4 t; if (readFloatVec4(model, *tanAcc, i, t)) { tangent = Vector4(t.getX(), t.getY(), t.getZ(), -t.getW()); } }
2229
2230 vertices[i] = PackedVertex{
2231 pos.getX(), pos.getY(), pos.getZ(),
2232 normal.getX(), normal.getY(), normal.getZ(),
2233 u, v, tangent.getX(), tangent.getY(), tangent.getZ(), tangent.getW(),
2234 u1, v1
2235 };
2236 minPos = Vector3(std::min(minPos.getX(), pos.getX()), std::min(minPos.getY(), pos.getY()), std::min(minPos.getZ(), pos.getZ()));
2237 maxPos = Vector3(std::max(maxPos.getX(), pos.getX()), std::max(maxPos.getY(), pos.getY()), std::max(maxPos.getZ(), pos.getZ()));
2238 }
2239 if (!tanAcc && primitive.mode == TINYGLTF_MODE_TRIANGLES) {
2240 if (primitive.indices >= 0) { if (const auto* ia = getAccessor(model, primitive.indices)) readIndices(model, *ia, parsedIndices); }
2241 generateTangents(vertices, parsedIndices.empty() ? nullptr : &parsedIndices);
2242 }
2243 }
2244
2245 if (vertices.empty()) continue;
2246
2247 if (!decodedDraco && primitive.indices >= 0 && parsedIndices.empty()) {
2248 if (const auto* ia = getAccessor(model, primitive.indices)) readIndices(model, *ia, parsedIndices);
2249 }
2250
2251 // Pack into byte arrays.
2252 pd.vertexCount = static_cast<int>(vertices.size());
2253 pd.vertexBytes.resize(vertices.size() * sizeof(PackedVertex));
2254 std::memcpy(pd.vertexBytes.data(), vertices.data(), pd.vertexBytes.size());
2255
2256 pd.drawCount = static_cast<int>(vertices.size());
2257 pd.indexed = false;
2258 if (!parsedIndices.empty()) {
2259 pd.indexBytes.resize(parsedIndices.size() * sizeof(uint32_t));
2260 std::memcpy(pd.indexBytes.data(), parsedIndices.data(), pd.indexBytes.size());
2261 pd.drawCount = static_cast<int>(parsedIndices.size());
2262 pd.indexed = true;
2263 }
2264
2265 pd.boundsMin = minPos;
2266 pd.boundsMax = maxPos;
2267 primResults.push_back(std::move(pd));
2268 }
2269 }
2270
2271 // ── Parse animations ─────────────────────────────────────────
2272 parseAnimations(model, result.animTracks);
2273
2274 return result;
2275 }
2276
2277 // ── createFromPrepared: fast GPU resource creation on main thread ─
2278
2279 std::unique_ptr<GlbContainerResource> GlbParser::createFromPrepared(
2280 tinygltf::Model& model,
2281 PreparedGlbData&& prepared,
2282 const std::shared_ptr<GraphicsDevice>& device,
2283 const std::string& debugName)
2284 {
2285 if (!device) {
2286 spdlog::error("GLB createFromPrepared failed: graphics device is null");
2287 return nullptr;
2288 }
2289
2290 auto container = std::make_unique<GlbContainerResource>();
2291 auto vertexFormat = std::make_shared<VertexFormat>(sizeof(PackedVertex), true, false);
2292
2293 auto makeDefaultMaterial = []() {
2294 auto material = std::make_shared<StandardMaterial>();
2295 material->setName("glTF-default");
2296 material->setTransparent(false);
2297 material->setAlphaMode(AlphaMode::OPAQUE);
2298 material->setMetallicFactor(0.0f);
2299 material->setRoughnessFactor(1.0f);
2300 material->setShaderVariantKey(1);
2301 return material;
2302 };
2303
2304 std::vector<std::shared_ptr<Material>> gltfMaterials;
2305 gltfMaterials.reserve(std::max<size_t>(1, model.materials.size()));
2306 std::vector<std::shared_ptr<Texture>> gltfTextures(model.textures.size());
2307
2308 // ── Create GPU textures from pre-converted RGBA data ─────────
2309 auto getOrCreateTexture = [&](const int textureIndex) -> std::shared_ptr<Texture> {
2310 if (textureIndex < 0 || textureIndex >= static_cast<int>(model.textures.size())) return nullptr;
2311 auto& cached = gltfTextures[static_cast<size_t>(textureIndex)];
2312 if (cached) return cached;
2313
2314 const auto& srcTexture = model.textures[static_cast<size_t>(textureIndex)];
2315 int imageSource = srcTexture.source;
2316 if (imageSource < 0) {
2317 auto it = srcTexture.extensions.find("KHR_texture_basisu");
2318 if (it != srcTexture.extensions.end() && it->second.IsObject()) {
2319 auto sourceVal = it->second.Get("source");
2320 if (sourceVal.IsInt()) imageSource = sourceVal.GetNumberAsInt();
2321 }
2322 }
2323 if (imageSource < 0 || imageSource >= static_cast<int>(prepared.images.size())) return nullptr;
2324
2325 const auto& prepImg = prepared.images[static_cast<size_t>(imageSource)];
2326 if (!prepImg.valid || prepImg.rgbaPixels.empty()) return nullptr;
2327
2328 TextureOptions options;
2329 options.width = static_cast<uint32_t>(prepImg.width);
2330 options.height = static_cast<uint32_t>(prepImg.height);
2332 options.mipmaps = false;
2333 options.numLevels = 1;
2336
2337 const auto& srcImage = model.images[static_cast<size_t>(imageSource)];
2338 options.name = srcImage.name.empty() ? srcTexture.name : srcImage.name;
2339
2340 auto texture = std::make_shared<Texture>(device.get(), options);
2341 texture->setLevelData(0, prepImg.rgbaPixels.data(), prepImg.rgbaPixels.size());
2342
2343 if (srcTexture.sampler >= 0 && srcTexture.sampler < static_cast<int>(model.samplers.size())) {
2344 const auto& sampler = model.samplers[static_cast<size_t>(srcTexture.sampler)];
2345 if (sampler.minFilter != -1) {
2346 auto minFilter = mapMinFilter(sampler.minFilter);
2351 minFilter = FilterMode::FILTER_LINEAR;
2352 }
2353 texture->setMinFilter(minFilter);
2354 }
2355 if (sampler.magFilter != -1) texture->setMagFilter(mapMagFilter(sampler.magFilter));
2356 texture->setAddressU(mapWrapMode(sampler.wrapS));
2357 texture->setAddressV(mapWrapMode(sampler.wrapT));
2358 }
2359
2360 texture->upload();
2361 container->addOwnedTexture(texture);
2362 cached = texture;
2363 return cached;
2364 };
2365
2366 // ── Create materials ─────────────────────────────────────────
2367 if (model.materials.empty()) {
2368 gltfMaterials.push_back(makeDefaultMaterial());
2369 } else {
2370 for (size_t materialIndex = 0; materialIndex < model.materials.size(); ++materialIndex) {
2371 const auto& srcMaterial = model.materials[materialIndex];
2372 auto material = std::make_shared<StandardMaterial>();
2373 material->setName(srcMaterial.name.empty() ? "glTF-material" : srcMaterial.name);
2374
2375 const auto& pbr = srcMaterial.pbrMetallicRoughness;
2376 if (pbr.baseColorFactor.size() == 4) {
2377 const Color baseColor(
2378 static_cast<float>(pbr.baseColorFactor[0]),
2379 static_cast<float>(pbr.baseColorFactor[1]),
2380 static_cast<float>(pbr.baseColorFactor[2]),
2381 static_cast<float>(pbr.baseColorFactor[3]));
2382 material->setBaseColorFactor(baseColor);
2383 Color diffuseColor(baseColor);
2384 diffuseColor.gamma();
2385 material->setDiffuse(diffuseColor);
2386 material->setOpacity(baseColor.a);
2387 }
2388 material->setMetallicFactor(static_cast<float>(pbr.metallicFactor));
2389 material->setRoughnessFactor(static_cast<float>(pbr.roughnessFactor));
2390 material->setMetalness(static_cast<float>(pbr.metallicFactor));
2391 material->setGloss(1.0f - static_cast<float>(pbr.roughnessFactor));
2392
2393 if (!srcMaterial.alphaMode.empty()) {
2394 if (srcMaterial.alphaMode == "BLEND") {
2395 material->setAlphaMode(AlphaMode::BLEND);
2396 material->setTransparent(true);
2397 } else if (srcMaterial.alphaMode == "MASK") {
2398 material->setAlphaMode(AlphaMode::MASK);
2399 } else {
2400 material->setAlphaMode(AlphaMode::OPAQUE);
2401 }
2402 }
2403 material->setCullMode(srcMaterial.doubleSided ? CullMode::CULLFACE_NONE : CullMode::CULLFACE_BACK);
2404 material->setAlphaCutoff(static_cast<float>(srcMaterial.alphaCutoff));
2405
2406 if (pbr.baseColorTexture.index >= 0) {
2407 if (auto tex = getOrCreateTexture(pbr.baseColorTexture.index)) {
2408 material->setBaseColorTexture(tex.get());
2409 material->setHasBaseColorTexture(true);
2410 material->setBaseColorUvSet(pbr.baseColorTexture.texCoord);
2411 }
2412 }
2413 if (srcMaterial.normalTexture.index >= 0) {
2414 if (auto tex = getOrCreateTexture(srcMaterial.normalTexture.index)) {
2415 material->setNormalTexture(tex.get());
2416 material->setHasNormalTexture(true);
2417 material->setNormalUvSet(srcMaterial.normalTexture.texCoord);
2418 }
2419 material->setNormalScale(static_cast<float>(srcMaterial.normalTexture.scale));
2420 }
2421 if (pbr.metallicRoughnessTexture.index >= 0) {
2422 if (auto tex = getOrCreateTexture(pbr.metallicRoughnessTexture.index)) {
2423 material->setMetallicRoughnessTexture(tex.get());
2424 material->setHasMetallicRoughnessTexture(true);
2425 }
2426 }
2427 if (srcMaterial.emissiveFactor.size() == 3) {
2428 Color emissiveColor(
2429 static_cast<float>(srcMaterial.emissiveFactor[0]),
2430 static_cast<float>(srcMaterial.emissiveFactor[1]),
2431 static_cast<float>(srcMaterial.emissiveFactor[2]), 1.0f);
2432 emissiveColor.gamma();
2433 material->setEmissiveFactor(emissiveColor);
2434 }
2435
2436 uint64_t variant = 1;
2437 if (material->hasBaseColorTexture()) variant |= (1ull << 1);
2438 if (material->hasNormalTexture()) variant |= (1ull << 4);
2439 if (material->hasMetallicRoughnessTexture()) variant |= (1ull << 5);
2440 if (material->alphaMode() == AlphaMode::BLEND) variant |= (1ull << 2);
2441 else if (material->alphaMode() == AlphaMode::MASK) variant |= (1ull << 3);
2442 material->setShaderVariantKey(variant);
2443 gltfMaterials.push_back(material);
2444 }
2445 }
2446
2447 // ── Create GPU mesh resources from pre-extracted byte data ────
2448 std::vector<std::vector<size_t>> meshToPayloadIndices(model.meshes.size());
2449 size_t nextPayloadIndex = 0;
2450
2451 for (size_t meshIndex = 0; meshIndex < prepared.meshPrimitives.size(); ++meshIndex) {
2452 for (auto& pd : prepared.meshPrimitives[meshIndex]) {
2453 if (pd.vertexBytes.empty()) continue;
2454
2455 VertexBufferOptions vbOptions;
2456 vbOptions.data = std::move(pd.vertexBytes);
2457 auto vb = device->createVertexBuffer(vertexFormat, pd.vertexCount, vbOptions);
2458 if (!vb) continue;
2459
2460 std::shared_ptr<IndexBuffer> ib;
2461 if (pd.indexed && !pd.indexBytes.empty()) {
2462 const int indexCount = static_cast<int>(pd.indexBytes.size() / sizeof(uint32_t));
2463 ib = device->createIndexBuffer(INDEXFORMAT_UINT32, indexCount, pd.indexBytes);
2464 }
2465
2466 auto meshResource = std::make_shared<Mesh>();
2467 meshResource->setVertexBuffer(vb);
2468 meshResource->setIndexBuffer(ib, 0);
2469
2470 Primitive drawPrimitive;
2471 drawPrimitive.type = mapPrimitiveType(pd.mode);
2472 drawPrimitive.base = 0;
2473 drawPrimitive.baseVertex = 0;
2474 drawPrimitive.count = pd.drawCount;
2475 drawPrimitive.indexed = pd.indexed;
2476 meshResource->setPrimitive(drawPrimitive, 0);
2477
2478 BoundingBox bounds;
2479 bounds.setCenter((pd.boundsMin + pd.boundsMax) * 0.5f);
2480 bounds.setHalfExtents((pd.boundsMax - pd.boundsMin) * 0.5f);
2481 meshResource->setAabb(bounds);
2482
2483 GlbMeshPayload payload;
2484 payload.mesh = meshResource;
2485 payload.material = (pd.materialIndex >= 0 && pd.materialIndex < static_cast<int>(gltfMaterials.size()))
2486 ? gltfMaterials[static_cast<size_t>(pd.materialIndex)] : gltfMaterials.front();
2487 container->addMeshPayload(payload);
2488 meshToPayloadIndices[meshIndex].push_back(nextPayloadIndex++);
2489 }
2490 }
2491
2492 // ── Create node payloads ─────────────────────────────────────
2493 for (const auto& node : model.nodes) {
2494 GlbNodePayload nodePayload;
2495 nodePayload.name = node.name;
2496 if (!node.matrix.empty()) decomposeNodeMatrix(node.matrix, nodePayload.translation, nodePayload.rotation, nodePayload.scale);
2497 if (node.translation.size() == 3) nodePayload.translation = Vector3(static_cast<float>(node.translation[0]), static_cast<float>(node.translation[1]), static_cast<float>(node.translation[2]));
2498 if (node.rotation.size() == 4) nodePayload.rotation = Quaternion(static_cast<float>(node.rotation[0]), static_cast<float>(node.rotation[1]), static_cast<float>(node.rotation[2]), static_cast<float>(node.rotation[3])).normalized();
2499 if (node.scale.size() == 3) nodePayload.scale = Vector3(static_cast<float>(node.scale[0]), static_cast<float>(node.scale[1]), static_cast<float>(node.scale[2]));
2500 if (node.mesh >= 0 && node.mesh < static_cast<int>(meshToPayloadIndices.size())) {
2501 const auto& mapped = meshToPayloadIndices[static_cast<size_t>(node.mesh)];
2502 nodePayload.meshPayloadIndices.insert(nodePayload.meshPayloadIndices.end(), mapped.begin(), mapped.end());
2503 }
2504 nodePayload.children = node.children;
2505 container->addNodePayload(nodePayload);
2506 }
2507
2508 // ── Root scene nodes ─────────────────────────────────────────
2509 int sceneIndex = model.defaultScene;
2510 if (sceneIndex < 0 && !model.scenes.empty()) sceneIndex = 0;
2511 if (sceneIndex >= 0 && sceneIndex < static_cast<int>(model.scenes.size())) {
2512 for (const auto nodeIndex : model.scenes[static_cast<size_t>(sceneIndex)].nodes)
2513 container->addRootNodeIndex(nodeIndex);
2514 }
2515
2516 // ── Attach pre-parsed animation tracks ───────────────────────
2517 for (auto& [name, track] : prepared.animTracks) {
2518 container->addAnimTrack(name, track);
2519 }
2520
2521 if (prepared.dracoPrimitiveCount > 0) {
2522 spdlog::info("GLB Draco summary [{}]: primitives={}, decoded={}, failed={}",
2523 debugName, prepared.dracoPrimitiveCount, prepared.dracoDecodeSuccessCount, prepared.dracoDecodeFailureCount);
2524 }
2525
2526 spdlog::info("GLB createFromPrepared [{}]: GPU resources created (textures={}, meshes={}, nodes={})",
2527 debugName, gltfTextures.size(), nextPayloadIndex, model.nodes.size());
2528
2529 return container;
2530 }
2531}
static BlendState additiveBlend()
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
static DepthState noWrite()
Definition depthState.h:46
static std::unique_ptr< GlbContainerResource > createFromModel(tinygltf::Model &model, const std::shared_ptr< GraphicsDevice > &device, const std::string &debugName="memory")
static std::unique_ptr< GlbContainerResource > parse(const std::string &path, const std::shared_ptr< GraphicsDevice > &device)
Parse a GLB file from disk.
static std::unique_ptr< GlbContainerResource > createFromPrepared(tinygltf::Model &model, PreparedGlbData &&prepared, const std::shared_ptr< GraphicsDevice > &device, const std::string &debugName="memory")
static std::unique_ptr< GlbContainerResource > parseFromMemory(const std::uint8_t *data, std::size_t length, const std::shared_ptr< GraphicsDevice > &device, const std::string &debugName="memory")
Parse a GLB from an in-memory byte buffer (e.g. extracted from b3dm).
static bool loadImageData(tinygltf::Image *image, int imageIndex, std::string *err, std::string *warn, int reqWidth, int reqHeight, const unsigned char *bytes, int size, void *userData)
static PreparedGlbData prepareFromModel(tinygltf::Model &model)
@ PRIMITIVE_POINTS
Definition mesh.h:19
@ PRIMITIVE_LINES
Definition mesh.h:20
@ PRIMITIVE_LINESTRIP
Definition mesh.h:22
@ PRIMITIVE_LINELOOP
Definition mesh.h:21
@ PRIMITIVE_TRISTRIP
Definition mesh.h:24
@ PRIMITIVE_TRIFAN
Definition mesh.h:25
@ PRIMITIVE_TRIANGLES
Definition mesh.h:23
RGBA color with floating-point components in [0, 1].
Definition color.h:18
Color & gamma(const Color *src=nullptr)
Definition color.cpp:99
std::shared_ptr< Material > material
4x4 column-major transformation matrix with SIMD acceleration.
Definition matrix4.h:31
void setElement(const int col, int row, const float value)
Definition matrix4.h:376
static Matrix4 identity()
Definition matrix4.h:108
Vector3 transformPoint(const Vector3 &v) const
float getElement(const int col, int row) const
Definition matrix4.h:355
Pre-built vertex/index byte buffers for one mesh primitive.
Definition glbParser.h:45
std::vector< uint8_t > vertexBytes
PackedVertex data.
Definition glbParser.h:46
std::vector< uint8_t > indexBytes
uint32_t index data.
Definition glbParser.h:47
std::vector< std::vector< PrimitiveData > > meshPrimitives
Per-mesh primitives: meshPrimitives[meshIndex][primIndex].
Definition glbParser.h:61
std::vector< ImageData > images
Pre-converted images indexed by tinygltf image index.
Definition glbParser.h:58
std::unordered_map< std::string, std::shared_ptr< AnimTrack > > animTracks
Fully parsed animation tracks (keyed by animation name).
Definition glbParser.h:64
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
Matrix4 toRotationMatrix() const
static Quaternion fromMatrix4(const Matrix4 &m)
Quaternion normalized() const
3D vector for positions, directions, and normals with multi-backend SIMD acceleration.
Definition vector3.h:29
4D vector for homogeneous coordinates, color values, and SIMD operations.
Definition vector4.h:20
float getX() const
Definition vector4.h:85
float getY() const
Definition vector4.h:98