VisuTwin Canvas
C++ 3D Engine — Metal Backend
Loading...
Searching...
No Matches
resourceLoader.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 12.10.2025.
5//
6
7#include "resourceLoader.h"
8
9#include <algorithm>
10#include <fstream>
11
12#define TINYGLTF_NO_STB_IMAGE
13#define TINYGLTF_NO_STB_IMAGE_WRITE
14#include <tiny_gltf.h>
15
17#include "spdlog/spdlog.h"
18#include "stb_image.h"
19
20namespace visutwin::canvas
21{
22 // ── ResourceLoader ─────────────────────────────────────────────────────
23
24 ResourceLoader::ResourceLoader(const std::shared_ptr<Engine>& engine)
25 : _engine(engine)
26 {
27 _running = true;
28 _worker = std::thread(&ResourceLoader::workerLoop, this);
29 spdlog::info("ResourceLoader: background I/O thread started");
30 }
31
36
37 void ResourceLoader::addHandler(const std::string& type, std::unique_ptr<ResourceHandler> handler)
38 {
39 _handlers[type] = std::move(handler);
40 }
41
42 void ResourceLoader::removeHandler(const std::string& type)
43 {
44 _handlers.erase(type);
45 }
46
47 void ResourceLoader::load(const std::string& url, const std::string& type,
48 LoadSuccessCallback onSuccess, LoadErrorCallback onError)
49 {
50 _pendingCount.fetch_add(1, std::memory_order_relaxed);
51 {
52 std::lock_guard lock(_requestMutex);
53 _requests.push_back({url, type, std::move(onSuccess), std::move(onError)});
54 }
55 _requestCV.notify_one();
56 }
57
58 void ResourceLoader::processCompletions(int maxCompletions)
59 {
60 // Move up to `maxCompletions` items out of the shared queue (0 = all).
61 // Processing one heavy callback per frame (maxCompletions == 1) prevents
62 // long main-thread stalls when several large assets finish on the same
63 // frame (e.g. a 70 MB GLB whose parseFromMemory blocks for seconds).
64 std::deque<Completion> batch;
65 {
66 std::lock_guard lock(_completionMutex);
67 if (maxCompletions <= 0 || static_cast<int>(_completions.size()) <= maxCompletions) {
68 batch.swap(_completions);
69 } else {
70 // Move only the first N completions into the local batch.
71 auto end = _completions.begin() + maxCompletions;
72 batch.assign(std::make_move_iterator(_completions.begin()),
73 std::make_move_iterator(end));
74 _completions.erase(_completions.begin(), end);
75 }
76 }
77
78 for (auto& c : batch) {
79 if (!c.error.empty()) {
80 if (c.onError) {
81 c.onError(c.error);
82 } else {
83 spdlog::error("ResourceLoader: unhandled error: {}", c.error);
84 }
85 } else {
86 if (c.onSuccess) {
87 c.onSuccess(std::move(c.data));
88 }
89 }
90 }
91 }
92
94 {
95 if (!_running.exchange(false)) {
96 return; // Already shut down.
97 }
98
99 _requestCV.notify_all();
100 if (_worker.joinable()) {
101 _worker.join();
102 }
103 spdlog::info("ResourceLoader: background I/O thread stopped");
104 }
105
107 {
108 return _pendingCount.load(std::memory_order_relaxed) > 0;
109 }
110
111 void ResourceLoader::workerLoop()
112 {
113 while (true) {
114 Request req;
115 {
116 std::unique_lock lock(_requestMutex);
117 _requestCV.wait(lock, [this] {
118 return !_requests.empty() || !_running;
119 });
120
121 if (!_running && _requests.empty()) {
122 break;
123 }
124 if (_requests.empty()) {
125 continue;
126 }
127
128 req = std::move(_requests.front());
129 _requests.pop_front();
130 }
131
132 Completion completion;
133 completion.onSuccess = std::move(req.onSuccess);
134 completion.onError = std::move(req.onError);
135
136 // Look up the handler for this asset type.
137 auto it = _handlers.find(req.type);
138 if (it == _handlers.end()) {
139 completion.error = "No resource handler registered for type '" + req.type + "'";
140 spdlog::error("ResourceLoader: {}", completion.error);
141 } else {
142 try {
143 auto loaded = it->second->load(req.url);
144 if (loaded) {
145 completion.data = std::move(loaded);
146 } else {
147 completion.error = "Handler returned null for '" + req.url + "'";
148 }
149 } catch (const std::exception& e) {
150 completion.error = "Exception loading '" + req.url + "': " + e.what();
151 spdlog::error("ResourceLoader: {}", completion.error);
152 }
153 }
154
155 {
156 std::lock_guard lock(_completionMutex);
157 _completions.push_back(std::move(completion));
158 }
159 _pendingCount.fetch_sub(1, std::memory_order_relaxed);
160 }
161 }
162
163 // ── TextureResourceHandler ─────────────────────────────────────────────
164
165 std::unique_ptr<LoadedData> TextureResourceHandler::load(const std::string& url)
166 {
167 // Use per-thread flip state for thread safety.
168 stbi_set_flip_vertically_on_load_thread(false);
169
170 const bool isHdr = url.size() >= 4 &&
171 url.compare(url.size() - 4, 4, ".hdr") == 0;
172
173 auto result = std::make_unique<LoadedData>();
174 result->url = url;
175
177 pd.isHdr = isHdr;
178
179 if (isHdr) {
180 // ── HDR path: decode to RGBA32F ──
181 float* hdrPixels = stbi_loadf(url.c_str(), &pd.width, &pd.height, &pd.channels, 0);
182 if (!hdrPixels || pd.width <= 0 || pd.height <= 0) {
183 spdlog::error("TextureResourceHandler: failed to decode HDR '{}'", url);
184 if (hdrPixels) stbi_image_free(hdrPixels);
185 return nullptr;
186 }
187
188 const size_t pixelCount = static_cast<size_t>(pd.width) * static_cast<size_t>(pd.height);
189 pd.hdrPixels.resize(pixelCount * 4);
190 for (size_t i = 0; i < pixelCount; ++i) {
191 pd.hdrPixels[i * 4 + 0] = hdrPixels[i * pd.channels + 0];
192 pd.hdrPixels[i * 4 + 1] = pd.channels > 1
193 ? hdrPixels[i * pd.channels + 1] : hdrPixels[i * pd.channels + 0];
194 pd.hdrPixels[i * 4 + 2] = pd.channels > 2
195 ? hdrPixels[i * pd.channels + 2] : hdrPixels[i * pd.channels + 0];
196 pd.hdrPixels[i * 4 + 3] = 1.0f;
197 }
198 stbi_image_free(hdrPixels);
199
200 spdlog::info("TextureResourceHandler: decoded HDR '{}' {}x{} ch={}",
201 url, pd.width, pd.height, pd.channels);
202 } else {
203 // ── LDR path: decode to RGBA8 ──
204 stbi_uc* pixels = stbi_load(url.c_str(), &pd.width, &pd.height, &pd.channels, STBI_rgb_alpha);
205 if (!pixels || pd.width <= 0 || pd.height <= 0) {
206 spdlog::error("TextureResourceHandler: failed to decode '{}'", url);
207 if (pixels) stbi_image_free(pixels);
208 return nullptr;
209 }
210
211 const size_t dataSize = static_cast<size_t>(pd.width) * static_cast<size_t>(pd.height) * 4;
212 pd.pixels.assign(pixels, pixels + dataSize);
213 stbi_image_free(pixels);
214
215 spdlog::info("TextureResourceHandler: decoded '{}' {}x{} ch={}",
216 url, pd.width, pd.height, pd.channels);
217 }
218
219 result->pixelData = std::move(pd);
220 return result;
221 }
222
223 // ── ContainerResourceHandler ───────────────────────────────────────────
224
225 namespace
226 {
227 bool hasExtension(const std::string& path, const std::string& ext)
228 {
229 if (path.size() < ext.size()) return false;
230 auto pathExt = path.substr(path.size() - ext.size());
231 std::transform(pathExt.begin(), pathExt.end(), pathExt.begin(), ::tolower);
232 return pathExt == ext;
233 }
234 }
235
236 std::unique_ptr<LoadedData> ContainerResourceHandler::load(const std::string& url)
237 {
238 std::ifstream file(url, std::ios::binary | std::ios::ate);
239 if (!file) {
240 spdlog::error("ContainerResourceHandler: cannot open '{}'", url);
241 return nullptr;
242 }
243
244 const auto size = file.tellg();
245 file.seekg(0);
246
247 auto result = std::make_unique<LoadedData>();
248 result->url = url;
249 result->bytes.resize(static_cast<size_t>(size));
250 file.read(reinterpret_cast<char*>(result->bytes.data()), size);
251
252 if (!file) {
253 spdlog::error("ContainerResourceHandler: read error for '{}'", url);
254 return nullptr;
255 }
256
257 spdlog::info("ContainerResourceHandler: read {} bytes from '{}'", result->bytes.size(), url);
258
259 // ── Pre-parse GLB on the background thread ──────────────────────
260 // tinygltf::LoadBinaryFromMemory does the heavy CPU work: JSON
261 // parsing, embedded image decoding via stb_image, and buffer view
262 // resolution. By running it here (on the I/O thread), only the
263 // fast GPU resource creation remains for the main-thread callback.
264 const bool isGlb = hasExtension(url, ".glb");
265 const bool isGltf = hasExtension(url, ".gltf");
266
267 if ((isGlb || isGltf) && !result->bytes.empty()) {
268 auto model = std::make_shared<tinygltf::Model>();
269 tinygltf::TinyGLTF loader;
270 loader.SetImageLoader(GlbParser::loadImageData, nullptr);
271 std::string warn, err;
272
273 bool ok = false;
274 if (isGlb) {
275 ok = loader.LoadBinaryFromMemory(
276 model.get(), &err, &warn,
277 result->bytes.data(),
278 static_cast<unsigned int>(result->bytes.size()));
279 } else {
280 // GLTF is JSON text — parse from the byte buffer as a string.
281 const std::string gltfString(
282 reinterpret_cast<const char*>(result->bytes.data()),
283 result->bytes.size());
284 // Base dir for resolving relative URIs (external .bin/.png).
285 std::string baseDir;
286 if (auto pos = url.find_last_of("/\\"); pos != std::string::npos) {
287 baseDir = url.substr(0, pos + 1);
288 }
289 ok = loader.LoadASCIIFromString(
290 model.get(), &err, &warn,
291 gltfString.c_str(),
292 static_cast<unsigned int>(gltfString.size()),
293 baseDir);
294 }
295
296 if (!warn.empty()) {
297 spdlog::warn("ContainerResourceHandler: tinygltf warning [{}]: {}", url, warn);
298 }
299 if (ok) {
300 // Phase 2: pre-process all CPU-heavy work on the bg thread
301 // (Draco decompression, vertex extraction, tangent generation,
302 // pixel format conversion, animation parsing).
303 auto prepared = std::make_shared<PreparedGlbData>(
305 result->preparsed = std::move(model);
306 result->preparedData = std::move(prepared);
307 spdlog::info("ContainerResourceHandler: pre-parsed + prepared {} on bg thread [{}]",
308 isGlb ? "GLB" : "GLTF", url);
309 } else {
310 // Pre-parse failed — fall back to main-thread parse.
311 spdlog::warn("ContainerResourceHandler: bg pre-parse failed [{}]: {}", url, err);
312 }
313 }
314
315 return result;
316 }
317
318 // ── FontResourceHandler ────────────────────────────────────────────────
319
320 std::unique_ptr<LoadedData> FontResourceHandler::load(const std::string& url)
321 {
322 std::ifstream file(url, std::ios::binary | std::ios::ate);
323 if (!file) {
324 spdlog::error("FontResourceHandler: cannot open '{}'", url);
325 return nullptr;
326 }
327
328 const auto size = file.tellg();
329 file.seekg(0);
330
331 auto result = std::make_unique<LoadedData>();
332 result->url = url;
333 result->bytes.resize(static_cast<size_t>(size));
334 file.read(reinterpret_cast<char*>(result->bytes.data()), size);
335
336 if (!file) {
337 spdlog::error("FontResourceHandler: read error for '{}'", url);
338 return nullptr;
339 }
340
341 spdlog::info("FontResourceHandler: read {} bytes from '{}'", result->bytes.size(), url);
342 return result;
343 }
344} // visutwin::canvas
std::unique_ptr< LoadedData > load(const std::string &url) override
std::unique_ptr< LoadedData > load(const std::string &url) override
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)
ResourceLoader(const std::shared_ptr< Engine > &engine)
void processCompletions(int maxCompletions=0)
void removeHandler(const std::string &type)
void load(const std::string &url, const std::string &type, LoadSuccessCallback onSuccess, LoadErrorCallback onError=nullptr)
void addHandler(const std::string &type, std::unique_ptr< ResourceHandler > handler)
std::unique_ptr< LoadedData > load(const std::string &url) override
std::function< void(std::unique_ptr< LoadedData >)> LoadSuccessCallback
std::function< void(const std::string &error)> LoadErrorCallback
Decoded pixel data — populated only by TextureResourceHandler.
std::vector< uint8_t > pixels
LDR: RGBA8 interleaved.
std::vector< float > hdrPixels
HDR: RGBA32F interleaved.