diff --git a/12_MeshLoaders/CMakeLists.txt b/12_MeshLoaders/CMakeLists.txt index 709b7d40b..532683229 100644 --- a/12_MeshLoaders/CMakeLists.txt +++ b/12_MeshLoaders/CMakeLists.txt @@ -1,10 +1,30 @@ -set(NBL_INCLUDE_SERACH_DIRECTORIES +set(SRCs + main.cpp + MeshLoadersApp.hpp + MeshLoadersAppLifecycle.cpp + MeshLoadersAppLoad.cpp + MeshLoadersAppRuntime.cpp + inputs.json + README.md +) + +option(NBL_MESHLOADERS_ENABLE_BENCHMARK_DATASETS "Enable benchmark dataset clone + benchmark payload setup for 12_MeshLoaders." OFF) +option(NBL_MESHLOADERS_DEFAULT_START_WITH_BENCHMARK_TESTLIST "When benchmark datasets are enabled, use benchmark payload as default startup test list in batch mode." OFF) +option(NBL_MESHLOADERS_ENABLE_HASH_CTESTS "Enable hash parity CTests (legacy_seq/new_seq/new_parallel) for 12_MeshLoaders." OFF) +set(NBL_MESHLOADERS_BENCHMARK_DATASET_DIR "${CMAKE_BINARY_DIR}/meshloaders_benchmark_datasets" CACHE PATH "Destination directory for cloned 12_MeshLoaders benchmark datasets.") +set(NBL_MESHLOADERS_BENCHMARK_DATASET_REPO "https://github.com/Devsh-Graphics-Programming/Nabla-Benchmark-Datasets.git" CACHE STRING "Git repository URL for 12_MeshLoaders benchmark datasets.") +set(NBL_MESHLOADERS_BENCHMARK_PAYLOAD_RELATIVE_PATH "inputs_benchmark.json" CACHE STRING "Relative path to committed benchmark payload JSON inside dataset repo.") + +set(NBL_INCLUDE_SEARCH_DIRECTORIES "${CMAKE_CURRENT_SOURCE_DIR}/include" + "${CMAKE_SOURCE_DIR}/3rdparty" +) +set(NBL_LIBRARIES + nlohmann_json::nlohmann_json ) -set(NBL_LIBRARIES) if (NBL_BUILD_MITSUBA_LOADER) - list(APPEND NBL_INCLUDE_SERACH_DIRECTORIES + list(APPEND NBL_INCLUDE_SEARCH_DIRECTORIES "${NBL_EXT_MITSUBA_LOADER_INCLUDE_DIRS}" ) list(APPEND NBL_LIBRARIES @@ -12,10 +32,7 @@ if (NBL_BUILD_MITSUBA_LOADER) ) endif() - # TODO; Arek I removed `NBL_EXECUTABLE_PROJECT_CREATION_PCH_TARGET` from the last parameter here, doesn't this macro have 4 arguments anyway !? -nbl_create_executable_project("" "" "${NBL_INCLUDE_SERACH_DIRECTORIES}" "${NBL_LIBRARIES}") -# TODO: Arek temporarily disabled cause I haven't figured out how to make this target yet -# LINK_BUILTIN_RESOURCES_TO_TARGET(${EXECUTABLE_NAME} nblExamplesGeometrySpirvBRD) +nbl_create_executable_project("${SRCs}" "" "${NBL_INCLUDE_SEARCH_DIRECTORIES}" "${NBL_LIBRARIES}") if (NBL_BUILD_DEBUG_DRAW) target_link_libraries(${EXECUTABLE_NAME} PRIVATE Nabla::ext::DebugDraw) @@ -23,4 +40,108 @@ endif() add_dependencies(${EXECUTABLE_NAME} argparse) -target_include_directories(${EXECUTABLE_NAME} PUBLIC $) \ No newline at end of file +target_include_directories(${EXECUTABLE_NAME} PUBLIC $) + +add_dependencies(${EXECUTABLE_NAME} nlohmann_json::nlohmann_json) +target_include_directories(${EXECUTABLE_NAME} PUBLIC $) + +if (NBL_MESHLOADERS_ENABLE_BENCHMARK_DATASETS) + set(NBL_MESHLOADERS_BENCHMARK_INPUTS_JSON "${NBL_MESHLOADERS_BENCHMARK_DATASET_DIR}/${NBL_MESHLOADERS_BENCHMARK_PAYLOAD_RELATIVE_PATH}" CACHE FILEPATH "Committed benchmark testlist for 12_MeshLoaders." FORCE) + if (NOT EXISTS "${NBL_MESHLOADERS_BENCHMARK_INPUTS_JSON}") + if (EXISTS "${NBL_MESHLOADERS_BENCHMARK_DATASET_DIR}" AND NOT EXISTS "${NBL_MESHLOADERS_BENCHMARK_DATASET_DIR}/.git") + message(STATUS "[meshloaders-bench] Dataset dir exists without .git, skipping fetch and using local files: ${NBL_MESHLOADERS_BENCHMARK_DATASET_DIR}") + else() + include(FetchContent) + FetchContent_Declare(nbl_meshloaders_benchmark_dataset + GIT_REPOSITORY "${NBL_MESHLOADERS_BENCHMARK_DATASET_REPO}" + GIT_TAG "master" + GIT_SHALLOW TRUE + GIT_PROGRESS TRUE + SOURCE_DIR "${NBL_MESHLOADERS_BENCHMARK_DATASET_DIR}" + BINARY_DIR "${CMAKE_BINARY_DIR}/CMakeFiles/nbl_meshloaders_benchmark_dataset-build" + ) + FetchContent_GetProperties(nbl_meshloaders_benchmark_dataset) + if (NOT nbl_meshloaders_benchmark_dataset_POPULATED) + FetchContent_Populate(nbl_meshloaders_benchmark_dataset) + endif () + endif () + endif () + if (NOT EXISTS "${NBL_MESHLOADERS_BENCHMARK_INPUTS_JSON}") + message(FATAL_ERROR "Benchmark payload JSON not found: ${NBL_MESHLOADERS_BENCHMARK_INPUTS_JSON}. Commit a full payload file into dataset repo and point NBL_MESHLOADERS_BENCHMARK_PAYLOAD_RELATIVE_PATH to it.") + endif () + file(READ "${NBL_MESHLOADERS_BENCHMARK_INPUTS_JSON}" _meshloaders_payload_probe LIMIT 256) + string(FIND "${_meshloaders_payload_probe}" "version https://git-lfs.github.com/spec/v1" _meshloaders_payload_lfs_ix) + if (NOT _meshloaders_payload_lfs_ix EQUAL -1) + message(FATAL_ERROR "Benchmark payload JSON must be a normal Git file, not an LFS pointer: ${NBL_MESHLOADERS_BENCHMARK_INPUTS_JSON}") + endif () + message(STATUS "[meshloaders-bench] Benchmark inputs payload: ${NBL_MESHLOADERS_BENCHMARK_INPUTS_JSON}") + if (NBL_MESHLOADERS_DEFAULT_START_WITH_BENCHMARK_TESTLIST) + file(TO_CMAKE_PATH "${NBL_MESHLOADERS_BENCHMARK_INPUTS_JSON}" _NBL_MESHLOADERS_BENCHMARK_INPUTS_JSON_CMAKE) + target_compile_definitions(${EXECUTABLE_NAME} PRIVATE NBL_MESHLOADERS_DEFAULT_BENCHMARK_TESTLIST_PATH="${_NBL_MESHLOADERS_BENCHMARK_INPUTS_JSON_CMAKE}") + message(STATUS "[meshloaders-bench] Default batch startup test list: benchmark payload") + else() + message(STATUS "[meshloaders-bench] Default batch startup test list: local inputs.json") + endif() +endif() + +enable_testing() + +add_test(NAME NBL_MESHLOADERS_CI + COMMAND "$" --ci + WORKING_DIRECTORY "$" + COMMAND_EXPAND_LISTS +) + +if (NBL_MESHLOADERS_ENABLE_HASH_CTESTS) + set(_NBL_MESHLOADERS_HASH_TEST_COMMON_ARGS + --hash-test + ) + + if (NBL_MESHLOADERS_ENABLE_BENCHMARK_DATASETS) + list(APPEND _NBL_MESHLOADERS_HASH_TEST_COMMON_ARGS --testlist "${NBL_MESHLOADERS_BENCHMARK_INPUTS_JSON}") + endif() + + add_test(NAME NBL_MESHLOADERS_HASH_TEST_HEURISTIC + COMMAND "$" ${_NBL_MESHLOADERS_HASH_TEST_COMMON_ARGS} --runtime-tuning heuristic + WORKING_DIRECTORY "$" + COMMAND_EXPAND_LISTS + ) + add_test(NAME NBL_MESHLOADERS_HASH_TEST_HYBRID + COMMAND "$" ${_NBL_MESHLOADERS_HASH_TEST_COMMON_ARGS} --runtime-tuning hybrid + WORKING_DIRECTORY "$" + COMMAND_EXPAND_LISTS + ) + set_tests_properties( + NBL_MESHLOADERS_HASH_TEST_HEURISTIC + NBL_MESHLOADERS_HASH_TEST_HYBRID + PROPERTIES + LABELS "meshloaders;hash;ci" + ) +endif() + +if (NBL_MESHLOADERS_ENABLE_BENCHMARK_DATASETS) + set(_NBL_MESHLOADERS_BENCHMARK_CI_COMMON_ARGS + --ci + --update-references + --testlist "${NBL_MESHLOADERS_BENCHMARK_INPUTS_JSON}" + --loader-content-hashes + ) + + add_test(NAME NBL_MESHLOADERS_CI_BENCHMARK_HEURISTIC + COMMAND "$" ${_NBL_MESHLOADERS_BENCHMARK_CI_COMMON_ARGS} --runtime-tuning heuristic + WORKING_DIRECTORY "$" + COMMAND_EXPAND_LISTS + ) + add_test(NAME NBL_MESHLOADERS_CI_BENCHMARK_HYBRID + COMMAND "$" ${_NBL_MESHLOADERS_BENCHMARK_CI_COMMON_ARGS} --runtime-tuning hybrid + WORKING_DIRECTORY "$" + COMMAND_EXPAND_LISTS + ) + set_tests_properties( + NBL_MESHLOADERS_CI_BENCHMARK_HEURISTIC + NBL_MESHLOADERS_CI_BENCHMARK_HYBRID + PROPERTIES + TIMEOUT 21600 + LABELS "meshloaders;benchmark;ci" + ) +endif() diff --git a/12_MeshLoaders/MeshLoadersApp.hpp b/12_MeshLoaders/MeshLoadersApp.hpp new file mode 100644 index 000000000..f563a1706 --- /dev/null +++ b/12_MeshLoaders/MeshLoadersApp.hpp @@ -0,0 +1,404 @@ +#ifndef _NBL_EXAMPLES_12_MESHLOADERS_APP_H_INCLUDED_ +#define _NBL_EXAMPLES_12_MESHLOADERS_APP_H_INCLUDED_ + +// Copyright (C) 2018-2025 - DevSH Graphics Programming Sp. z O.O. +// This file is part of the "Nabla Engine". +// For conditions of distribution and use, see copyright notice in nabla.h + +#include "common.hpp" +#include "nbl/examples/common/SimpleWindowedApplication.hpp" +#include "nbl/examples/common/CSwapchainFramebuffersAndDepth.hpp" +#include "nbl/examples/common/CEventCallback.hpp" + +#include +#include +#include +#include +#include +#include + +#ifdef NBL_BUILD_DEBUG_DRAW +#include "nbl/ext/DebugDraw/CDrawAABB.h" +#endif + +class MeshLoadersWindowedApplication : public virtual SimpleWindowedApplication +{ + using base_t = SimpleWindowedApplication; + +public: + constexpr static inline uint8_t MaxFramesInFlight = 3; + + template + MeshLoadersWindowedApplication(const hlsl::uint16_t2 initialResolution, const asset::E_FORMAT depthFormat, Args&&... args) + : base_t(std::forward(args)...), m_initialResolution(initialResolution), m_depthFormat(depthFormat) {} + + core::vector getSurfaces() const override final + { + if (!m_surface) + { + auto windowCallback = make_smart_refctd_ptr(smart_refctd_ptr(m_inputSystem), smart_refctd_ptr(m_logger)); + IWindow::SCreationParams params = {}; + params.callback = make_smart_refctd_ptr(); + params.width = m_initialResolution[0]; + params.height = m_initialResolution[1]; + params.x = 32; + params.y = 32; + params.flags = ui::IWindow::ECF_HIDDEN | IWindow::ECF_BORDERLESS | IWindow::ECF_RESIZABLE | IWindow::ECF_CAN_MINIMIZE; + params.windowCaption = "MeshLoaders"; + params.callback = windowCallback; + const_cast&>(m_window) = m_winMgr->createWindow(std::move(params)); + + auto surface = CSurfaceVulkanWin32::create(smart_refctd_ptr(m_api), smart_refctd_ptr_static_cast(m_window)); + const_cast&>(m_surface) = CSimpleResizeSurface::create(std::move(surface)); + } + + if (m_surface) + return { {m_surface->getSurface()} }; + + return {}; + } + + bool onAppInitialized(core::smart_refctd_ptr&& system) override + { + if (!MonoSystemMonoLoggerApplication::onAppInitialized(std::move(system))) + return false; + + m_inputSystem = make_smart_refctd_ptr(system::logger_opt_smart_ptr(smart_refctd_ptr(m_logger))); + if (!base_t::onAppInitialized(std::move(system))) + return false; + + ISwapchain::SCreationParams swapchainParams = { .surface = smart_refctd_ptr(m_surface->getSurface()) }; + swapchainParams.sharedParams.imageUsage |= IGPUImage::E_USAGE_FLAGS::EUF_TRANSFER_SRC_BIT; + if (!swapchainParams.deduceFormat(m_physicalDevice)) + return logFail("Could not choose a Surface Format for the Swapchain!"); + + auto scResources = std::make_unique(m_device.get(), m_depthFormat, swapchainParams.surfaceFormat.format, getDefaultSubpassDependencies()); + auto* renderpass = scResources->getRenderpass(); + if (!renderpass) + return logFail("Failed to create Renderpass!"); + + auto gQueue = getGraphicsQueue(); + if (!m_surface || !m_surface->init(gQueue, std::move(scResources), swapchainParams.sharedParams)) + return logFail("Could not create Window & Surface or initialize the Surface!"); + + m_winMgr->setWindowSize(m_window.get(), m_initialResolution[0], m_initialResolution[1]); + m_surface->recreateSwapchain(); + return true; + } + + void workLoopBody() override final + { + const uint32_t framesInFlightCount = hlsl::min(MaxFramesInFlight, m_surface->getMaxAcquiresInFlight()); + if (m_framesInFlight.size() >= framesInFlightCount) + { + const ISemaphore::SWaitInfo framesDone[] = { {.semaphore = m_framesInFlight.front().semaphore.get(), .value = m_framesInFlight.front().value} }; + if (m_device->blockForSemaphores(framesDone) != ISemaphore::WAIT_RESULT::SUCCESS) + return; + m_framesInFlight.pop_front(); + } + + auto updatePresentationTimestamp = [&]() + { + m_currentImageAcquire = m_surface->acquireNextImage(); + oracle.reportEndFrameRecord(); + const auto timestamp = oracle.getNextPresentationTimeStamp(); + oracle.reportBeginFrameRecord(); + return timestamp; + }; + + const auto nextPresentationTimestamp = updatePresentationTimestamp(); + if (!m_currentImageAcquire) + return; + + const IQueue::SSubmitInfo::SSemaphoreInfo rendered[] = { renderFrame(nextPresentationTimestamp) }; + m_surface->present(m_currentImageAcquire.imageIndex, rendered); + if (rendered->semaphore) + m_framesInFlight.emplace_back(smart_refctd_ptr(rendered->semaphore), rendered->value); + } + + bool keepRunning() override + { + if (m_surface->irrecoverable()) + return false; + return true; + } + + bool onAppTerminated() override + { + m_inputSystem = nullptr; + m_device->waitIdle(); + m_framesInFlight.clear(); + m_surface = nullptr; + m_window = nullptr; + return base_t::onAppTerminated(); + } + +protected: + inline void onAppInitializedFinish() + { + m_winMgr->show(m_window.get()); + oracle.reportBeginFrameRecord(); + } + + inline const auto& getCurrentAcquire() const { return m_currentImageAcquire; } + + virtual const video::IGPURenderpass::SCreationParams::SSubpassDependency* getDefaultSubpassDependencies() const = 0; + virtual video::IQueue::SSubmitInfo::SSemaphoreInfo renderFrame(const std::chrono::microseconds nextPresentationTimestamp) = 0; + + const hlsl::uint16_t2 m_initialResolution; + const asset::E_FORMAT m_depthFormat; + core::smart_refctd_ptr m_inputSystem; + core::smart_refctd_ptr m_window; + core::smart_refctd_ptr> m_surface; + +private: + struct SSubmittedFrame + { + core::smart_refctd_ptr semaphore; + uint64_t value; + }; + core::deque m_framesInFlight; + video::ISimpleManagedSurface::SAcquireResult m_currentImageAcquire = {}; + video::CDumbPresentationOracle oracle; +}; + +class MeshLoadersApp final : public MeshLoadersWindowedApplication, public BuiltinResourcesApplication +{ + using device_base_t = MeshLoadersWindowedApplication; + using asset_base_t = BuiltinResourcesApplication; + + enum DrawBoundingBoxMode + { + DBBM_NONE, + DBBM_AABB, + DBBM_OBB + }; + + enum class RunMode + { + Interactive, + Batch, + CI + }; + + enum class Phase + { + RenderOriginal, + RenderWritten + }; + + enum class RowViewReloadMode + { + Full, + Incremental + }; + + struct TestCase + { + std::string name; + nbl::system::path path; + }; + + struct CachedGeometryEntry + { + smart_refctd_ptr cpu; + video::asset_cached_t gpu; + hlsl::shapes::AABB<3, double> aabb = hlsl::shapes::AABB<3, double>::create(); + bool hasAabb = false; + }; + + struct RowViewPerfStats + { + double totalMs = 0.0; + double clearMs = 0.0; + double loadMs = 0.0; + double extractMs = 0.0; + double aabbMs = 0.0; + double convertMs = 0.0; + double addGeoMs = 0.0; + double layoutMs = 0.0; + double instanceMs = 0.0; + double cameraMs = 0.0; + size_t cases = 0u; + size_t cpuHits = 0u; + size_t cpuMisses = 0u; + size_t gpuHits = 0u; + size_t gpuMisses = 0u; + size_t convertCount = 0u; + size_t addCount = 0u; + bool incremental = false; + }; + + struct CameraState + { + core::vectorSIMDf position; + core::vectorSIMDf target; + nbl::hlsl::float32_t4x4 projection; + float moveSpeed = 1.0f; + }; + + struct AssetLoadCallResult + { + asset::SAssetBundle bundle = {}; + double getAssetMs = 0.0; + uintmax_t inputSize = 0u; + }; + +public: + MeshLoadersApp(const path& localInputCWD, const path& localOutputCWD, const path& sharedInputCWD, const path& sharedOutputCWD); + + bool onAppInitialized(smart_refctd_ptr&& system) override; + IQueue::SSubmitInfo::SSemaphoreInfo renderFrame(const std::chrono::microseconds nextPresentationTimestamp) override; + bool onAppTerminated() override; + bool keepRunning() override; + +protected: + core::bitflag getLogLevelMask() override + { + return system::ILogger::DefaultLogMask() | system::ILogger::ELL_INFO; + } + + const video::IGPURenderpass::SCreationParams::SSubpassDependency* getDefaultSubpassDependencies() const override; + +private: + [[noreturn]] void failExit(const char* msg, ...); + + bool initTestCases(); + bool pickModelPath(system::path& outPath); + bool loadTestList(const system::path& jsonPath); + bool isRowViewActive() const; + + static std::string normalizeExtension(const system::path& path); + bool isWriteExtensionSupported(const std::string& ext) const; + system::path resolveSavePath(const system::path& modelPath) const; + + static std::string sanitizeCaseNameForFilename(std::string name); + system::path getGeometryHashReferencePath(const std::string& caseName) const; + static std::string geometryHashToHex(const core::blake3_hash_t& hash); + static bool tryParseNibble(char c, uint8_t& out); + static bool tryParseGeometryHashHex(std::string hex, core::blake3_hash_t& outHash); + bool readGeometryHashReference(const system::path& refPath, core::blake3_hash_t& outHash) const; + bool writeGeometryHashReference(const system::path& refPath, const core::blake3_hash_t& hash) const; + + bool startCase(size_t index); + bool advanceToNextCase(); + void reloadInteractive(); + bool addRowViewCase(); + bool addRowViewCaseFromPath(const system::path& picked); + bool reloadFromTestList(); + void resetRowViewScene(); + + bool loadModel(const system::path& modelPath, bool updateCamera, bool storeCamera); + bool loadRowView(RowViewReloadMode mode); + bool writeGeometry(smart_refctd_ptr geometry, const std::string& savePath); + bool runHashConsistencyChecks(); + + void setupCameraFromAABB(const hlsl::shapes::AABB<3, double>& bound); + static hlsl::shapes::AABB<3, double> translateAABB(const hlsl::shapes::AABB<3, double>& aabb, const hlsl::float64_t3& translation); + static hlsl::shapes::AABB<3, double> scaleAABB(const hlsl::shapes::AABB<3, double>& aabb, double scale); + + void storeCameraState(); + void applyCameraState(const CameraState& state); + + static bool isValidAABB(const hlsl::shapes::AABB<3, double>& aabb); + hlsl::shapes::AABB<3, double> getGeometryAABB(const ICPUPolygonGeometry* geometry) const; + + system::ILogger* getAssetLoadLogger() const; + IAssetLoader::SAssetLoadParams makeLoadParams() const; + bool loadAssetCallFromPath(const system::path& modelPath, const IAssetLoader::SAssetLoadParams& params, AssetLoadCallResult& out); + bool initLoaderPerfLogger(const system::path& logPath); + + std::string makeUniqueCaseName(const system::path& path); + static double toMs(const std::chrono::high_resolution_clock::duration& d); + std::string makeCacheKey(const system::path& path) const; + + void logRowViewPerf(const RowViewPerfStats& stats) const; + void logRowViewAssetLoad(const system::path& path, double ms, bool cached) const; + void logRowViewLoadTotal(double ms, size_t hits, size_t misses) const; + + core::blake3_hash_t hashGeometry(const ICPUPolygonGeometry* geo); + bool validateWrittenAsset(const system::path& path); + bool captureScreenshot(const system::path& path, core::smart_refctd_ptr& outImage); + bool appendGeometriesFromBundle(const asset::SAssetBundle& bundle, core::vector>& out) const; + bool compareImages(const asset::ICPUImageView* a, const asset::ICPUImageView* b, uint64_t& diffCount, uint8_t& maxDiff); + + void advanceCase(); + + constexpr static inline uint32_t MaxFramesInFlight = 3u; + constexpr static inline uint32_t CiFramesBeforeCapture = 10u; + constexpr static inline uint32_t NonCiFramesPerCase = 120u; + constexpr static inline uint32_t RowViewFramesBeforeCapture = 10u; + constexpr static inline uint64_t MaxImageDiffBytes = 16u; + constexpr static inline uint8_t MaxImageDiffValue = 1u; + + smart_refctd_ptr m_renderer; + smart_refctd_ptr m_semaphore; + uint64_t m_realFrameIx = 0; + std::array, MaxFramesInFlight> m_cmdBufs; + + InputSystem::ChannelReader mouse; + InputSystem::ChannelReader keyboard; + + Camera camera = Camera( + core::vectorSIMDf(0, 0, 0), + core::vectorSIMDf(0, 0, -1), + nbl::hlsl::math::linalg::diagonal(1.0f)); + + std::string m_modelPath; + std::string m_caseName; + + DrawBoundingBoxMode m_drawBBMode = DBBM_AABB; +#ifdef NBL_BUILD_DEBUG_DRAW + smart_refctd_ptr m_drawAABB; + std::vector m_aabbInstances; + std::vector m_obbInstances; +#endif + + bool m_nonInteractiveTest = false; + bool m_rowViewEnabled = true; + bool m_forceRowViewForCurrentTestList = false; + bool m_rowViewScreenshotCaptured = false; + bool m_fileDialogOpen = false; + + bool m_saveGeom = true; + std::optional m_specifiedGeomSavePath; + nbl::system::path m_saveGeomPrefixPath; + nbl::system::path m_screenshotPrefixPath; + nbl::system::path m_rowViewScreenshotPath; + nbl::system::path m_testListPath; + nbl::system::path m_geometryHashReferenceDir; + nbl::system::path m_caseGeometryHashReferencePath; + std::optional m_loaderPerfLogPath; + std::optional m_rowAddPath; + uint32_t m_rowDuplicateCount = 0u; + smart_refctd_ptr m_assetLoadLogger; + smart_refctd_ptr m_loaderPerfLogger; + bool m_updateGeometryHashReferences = false; + bool m_forceLoaderContentHashes = true; + bool m_hashTestOnly = false; + asset::SFileIOPolicy::SRuntimeTuning::Mode m_runtimeTuningMode = asset::SFileIOPolicy::SRuntimeTuning::Mode::Heuristic; + + RunMode m_runMode = RunMode::Batch; + Phase m_phase = Phase::RenderOriginal; + uint32_t m_phaseFrameCounter = 0u; + size_t m_caseIndex = 0u; + core::vector m_cases; + std::unordered_map m_caseNameCounts; + std::unordered_map m_rowViewCache; + bool m_shouldQuit = false; + + nbl::system::path m_writtenPath; + nbl::system::path m_loadedScreenshotPath; + nbl::system::path m_writtenScreenshotPath; + + core::smart_refctd_ptr m_currentCpuGeom; + core::blake3_hash_t m_referenceGeometryHash = {}; + bool m_hasReferenceGeometryHash = false; + + core::smart_refctd_ptr m_loadedScreenshot; + core::smart_refctd_ptr m_writtenScreenshot; + + std::optional m_referenceCamera; +}; + +#endif diff --git a/12_MeshLoaders/MeshLoadersAppLifecycle.cpp b/12_MeshLoaders/MeshLoadersAppLifecycle.cpp new file mode 100644 index 000000000..ac25a5585 --- /dev/null +++ b/12_MeshLoaders/MeshLoadersAppLifecycle.cpp @@ -0,0 +1,1017 @@ +// Copyright (C) 2018-2025 - DevSH Graphics Programming Sp. z O.O. +// This file is part of the "Nabla Engine". +// For conditions of distribution and use, see copyright notice in nabla.h + +#include "argparse/argparse.hpp" +#include "portable-file-dialogs/portable-file-dialogs.h" +#include "nlohmann/json.hpp" +#include "MeshLoadersApp.hpp" + +#include +#include +#include +#include +#include +#include +#include + +#ifdef NBL_BUILD_MITSUBA_LOADER +#include "nbl/ext/MitsubaLoader/CSerializedLoader.h" +#endif + +#include "nbl/system/CFileLogger.h" + +namespace +{ + +template +std::string makeCaptionModelPath(const std::string& modelPath, const ArgContainer& argv) +{ + if (modelPath.empty()) + return {}; + + std::error_code ec; + if (modelPath.find('/') == std::string::npos && modelPath.find('\\') == std::string::npos) + { + if (!std::filesystem::exists(std::filesystem::path(modelPath), ec)) + { + ec.clear(); + return modelPath; + } + ec.clear(); + } + std::filesystem::path targetPath(modelPath); + targetPath = targetPath.lexically_normal(); + const auto canonicalTarget = std::filesystem::weakly_canonical(targetPath, ec); + if (!ec) + targetPath = canonicalTarget; + else + ec.clear(); + + if (!targetPath.is_absolute()) + { + const auto absoluteTarget = std::filesystem::absolute(targetPath, ec); + if (!ec) + targetPath = absoluteTarget.lexically_normal(); + else + ec.clear(); + } + if (!targetPath.is_absolute()) + return targetPath.generic_string(); + + auto relativeFromBase = [&](const std::filesystem::path& basePath) -> std::string + { + if (basePath.empty()) + return {}; + auto canonicalBase = std::filesystem::weakly_canonical(basePath, ec); + if (ec) + { + ec.clear(); + canonicalBase = std::filesystem::absolute(basePath, ec); + } + if (ec) + { + ec.clear(); + return {}; + } + const auto relativePath = std::filesystem::relative(targetPath, canonicalBase, ec); + if (ec || relativePath.empty() || relativePath.is_absolute()) + { + ec.clear(); + return {}; + } + return relativePath.lexically_normal().generic_string(); + }; + + std::string bestRelativePath; + if (!argv.empty() && !argv[0].empty()) + { + const auto exePath = std::filesystem::absolute(std::filesystem::path(argv[0]), ec); + if (!ec) + { + const auto relativeToExe = relativeFromBase(exePath.parent_path()); + if (!relativeToExe.empty()) + bestRelativePath = relativeToExe; + } + else + ec.clear(); + } + + const auto cwd = std::filesystem::current_path(ec); + if (!ec) + { + const auto relativeToCwd = relativeFromBase(cwd); + if (!relativeToCwd.empty() && (bestRelativePath.empty() || relativeToCwd.size() < bestRelativePath.size())) + bestRelativePath = relativeToCwd; + } + else + ec.clear(); + + if (!bestRelativePath.empty()) + return bestRelativePath; + return targetPath.generic_string(); +} + +} + +MeshLoadersApp::MeshLoadersApp( + const path& localInputCWD, + const path& localOutputCWD, + const path& sharedInputCWD, + const path& sharedOutputCWD) + : IApplicationFramework(localInputCWD, localOutputCWD, sharedInputCWD, sharedOutputCWD) + , device_base_t({1280, 720}, EF_D32_SFLOAT, localInputCWD, localOutputCWD, sharedInputCWD, sharedOutputCWD) +{ +} + +bool MeshLoadersApp::onAppInitialized(smart_refctd_ptr&& system) +{ + if (!asset_base_t::onAppInitialized(smart_refctd_ptr(system))) + return false; +#ifdef NBL_BUILD_MITSUBA_LOADER + m_assetMgr->addAssetLoader(make_smart_refctd_ptr()); +#endif + if (!device_base_t::onAppInitialized(smart_refctd_ptr(system))) + return false; + + const auto resolveRuntimeCWD = [](const path& preferred)->path + { + if (preferred.empty() || preferred == path("/") || preferred == path("\\")) + return path(std::filesystem::current_path()); + return preferred; + }; + const path effectiveInputCWD = resolveRuntimeCWD(localInputCWD); + const path effectiveOutputCWD = resolveRuntimeCWD(localOutputCWD); + + m_runMode = RunMode::Batch; + m_saveGeomPrefixPath = effectiveOutputCWD / "saved"; + m_screenshotPrefixPath = effectiveOutputCWD / "screenshots"; + m_testListPath = effectiveInputCWD / "inputs.json"; + m_forceRowViewForCurrentTestList = false; +#if defined(NBL_MESHLOADERS_DEFAULT_BENCHMARK_TESTLIST_PATH) + const path defaultBenchmarkTestListPath = path(NBL_MESHLOADERS_DEFAULT_BENCHMARK_TESTLIST_PATH); +#else + const path defaultBenchmarkTestListPath; +#endif + + argparse::ArgumentParser parser("12_meshloaders"); + parser.add_argument("--savegeometry") + .help("Save the mesh on exit or reload") + .flag(); + + parser.add_argument("--savepath") + .nargs(1) + .help("Specify the file to which the mesh will be saved"); + parser.add_argument("--ci") + .help("Run in CI mode: load test list, write .ply, capture screenshots, compare data, and exit.") + .flag(); + parser.add_argument("--hash-test") + .help("Run headless hash consistency check: parallel vs sequential content hash recompute, then exit.") + .flag(); + parser.add_argument("--interactive") + .help("Use file dialog to select a single model.") + .flag(); + parser.add_argument("--testlist") + .nargs(1) + .help("JSON file with test cases. Relative paths are resolved against local input CWD."); + parser.add_argument("--row-add") + .nargs(1) + .help("Add a model path to row view on startup without using a dialog."); + parser.add_argument("--row-duplicate") + .nargs(1) + .help("Duplicate the last case N times on startup."); + parser.add_argument("--loader-perf-log") + .nargs(1) + .help("Write loader diagnostics to a file instead of stdout."); + parser.add_argument("--loader-content-hashes") + .help("Force loaders to compute CPU buffer content hashes before returning. Enabled by default.") + .flag(); + parser.add_argument("--runtime-tuning") + .nargs(1) + .help("Runtime tuning mode for loaders: none|heuristic|hybrid. Default: heuristic."); + parser.add_argument("--update-references") + .help("Update or create geometry hash references for CI validation.") + .flag(); + + try + { + parser.parse_args({ argv.data(), argv.data() + argv.size() }); + } + catch (const std::exception& e) + { + return logFail(e.what()); + } + + if (parser["--savegeometry"] == true) + m_saveGeom = true; + if (parser["--interactive"] == true) + m_runMode = RunMode::Interactive; + if (parser["--ci"] == true) + m_runMode = RunMode::CI; + if (parser["--hash-test"] == true) + { + m_hashTestOnly = true; + m_runMode = RunMode::CI; + } + const bool hasExplicitTestListArg = parser.present("--testlist").has_value(); + + if (parser.present("--savepath")) + { + auto tmp = path(parser.get("--savepath")); + + if (tmp.empty() || !tmp.has_filename()) + return logFail("Invalid path has been specified in --savepath argument"); + + if (!std::filesystem::exists(tmp.parent_path())) + return logFail("Path specified in --savepath argument doesn't exist"); + + m_specifiedGeomSavePath.emplace(std::move(tmp.generic_string())); + } + + if (hasExplicitTestListArg) + { + auto tmp = path(parser.get("--testlist")); + if (tmp.empty()) + return logFail("Invalid path has been specified in --testlist argument"); + if (tmp.is_relative()) + tmp = effectiveInputCWD / tmp; + m_testListPath = tmp; + } + else if (m_runMode == RunMode::Batch && !defaultBenchmarkTestListPath.empty()) + { + std::error_code benchmarkPathEc; + if (std::filesystem::exists(defaultBenchmarkTestListPath, benchmarkPathEc) && !benchmarkPathEc) + { + m_testListPath = defaultBenchmarkTestListPath; + m_forceRowViewForCurrentTestList = true; + m_logger->log("Using benchmark test list for default batch startup: %s", ILogger::ELL_INFO, m_testListPath.string().c_str()); + } + } + if (parser.present("--row-add")) + { + auto tmp = path(parser.get("--row-add")); + if (tmp.is_relative()) + tmp = effectiveInputCWD / tmp; + m_rowAddPath = tmp; + } + if (parser.present("--row-duplicate")) + { + auto countStr = parser.get("--row-duplicate"); + try + { + m_rowDuplicateCount = static_cast(std::stoul(countStr)); + } + catch (const std::exception&) + { + return logFail("Invalid --row-duplicate value."); + } + } + if (parser.present("--loader-perf-log")) + { + auto tmp = path(parser.get("--loader-perf-log")); + if (tmp.empty()) + return logFail("Invalid --loader-perf-log value."); + if (tmp.is_relative()) + tmp = effectiveOutputCWD / tmp; + m_loaderPerfLogPath = tmp; + } + if (parser["--update-references"] == true) + m_updateGeometryHashReferences = true; + if (parser["--loader-content-hashes"] == true) + m_forceLoaderContentHashes = true; + if (parser.present("--runtime-tuning")) + { + auto mode = parser.get("--runtime-tuning"); + std::transform(mode.begin(), mode.end(), mode.begin(), [](unsigned char c) { return static_cast(std::tolower(c)); }); + if (mode == "none") + m_runtimeTuningMode = asset::SFileIOPolicy::SRuntimeTuning::Mode::None; + else if (mode == "heuristic") + m_runtimeTuningMode = asset::SFileIOPolicy::SRuntimeTuning::Mode::Heuristic; + else if (mode == "hybrid") + m_runtimeTuningMode = asset::SFileIOPolicy::SRuntimeTuning::Mode::Hybrid; + else + return logFail("Invalid --runtime-tuning value. Expected: none|heuristic|hybrid."); + } + + const path inputReferencesDir = effectiveInputCWD / "references"; + const path outputReferencesDir = effectiveOutputCWD / "references"; + std::error_code referenceDirEc; + const bool hasInputReferencesDir = std::filesystem::is_directory(inputReferencesDir, referenceDirEc) && !referenceDirEc; + referenceDirEc.clear(); + const bool hasOutputReferencesDir = std::filesystem::is_directory(outputReferencesDir, referenceDirEc) && !referenceDirEc; + m_geometryHashReferenceDir = hasOutputReferencesDir || !hasInputReferencesDir ? outputReferencesDir : inputReferencesDir; + if (hasOutputReferencesDir && !hasInputReferencesDir) + m_logger->log("Geometry hash references resolved to output directory: %s", system::ILogger::ELL_INFO, m_geometryHashReferenceDir.string().c_str()); + if (m_runMode == RunMode::CI || m_updateGeometryHashReferences) + { + std::error_code ec; + std::filesystem::create_directories(m_geometryHashReferenceDir, ec); + if (ec) + return logFail("Failed to create geometry hash reference directory: %s", m_geometryHashReferenceDir.string().c_str()); + } + + if (m_saveGeom) + std::filesystem::create_directories(m_saveGeomPrefixPath); + std::filesystem::create_directories(m_screenshotPrefixPath); + m_assetLoadLogger = m_logger; + if (m_loaderPerfLogPath) + { + if (!initLoaderPerfLogger(*m_loaderPerfLogPath)) + return false; + m_logger->log("Loader diagnostics will be written to %s", ILogger::ELL_INFO, m_loaderPerfLogPath->string().c_str()); + } + + m_semaphore = m_device->createSemaphore(m_realFrameIx); + if (!m_semaphore) + return logFail("Failed to Create a Semaphore!"); + + auto pool = m_device->createCommandPool(getGraphicsQueue()->getFamilyIndex(), IGPUCommandPool::CREATE_FLAGS::RESET_COMMAND_BUFFER_BIT); + for (auto i = 0u; i < MaxFramesInFlight; i++) + { + if (!pool) + return logFail("Couldn't create Command Pool!"); + if (!pool->createCommandBuffers(IGPUCommandPool::BUFFER_LEVEL::PRIMARY, { m_cmdBufs.data() + i,1 })) + return logFail("Couldn't create Command Buffer!"); + } + + auto scRes = static_cast(m_surface->getSwapchainResources()); + m_renderer = CSimpleDebugRenderer::create(m_assetMgr.get(), scRes->getRenderpass(), 0, {}); + if (!m_renderer) + return logFail("Failed to create renderer!"); + +#ifdef NBL_BUILD_DEBUG_DRAW + { + auto* renderpass = scRes->getRenderpass(); + ext::debug_draw::DrawAABB::SCreationParameters params = {}; + params.assetManager = m_assetMgr; + params.transfer = getTransferUpQueue(); + params.drawMode = ext::debug_draw::DrawAABB::ADM_DRAW_BATCH; + params.batchPipelineLayout = ext::debug_draw::DrawAABB::createDefaultPipelineLayout(m_device.get()); + params.renderpass = smart_refctd_ptr(renderpass); + params.utilities = m_utils; + m_drawAABB = ext::debug_draw::DrawAABB::create(std::move(params)); + } +#endif + + if (!initTestCases()) + return false; + + if (m_hashTestOnly) + { + if (!runHashConsistencyChecks()) + return false; + m_shouldQuit = true; + onAppInitializedFinish(); + return true; + } + + if (isRowViewActive()) + { + m_nonInteractiveTest = false; + if (!loadRowView(RowViewReloadMode::Full)) + return false; + if (m_rowAddPath) + if (!addRowViewCaseFromPath(*m_rowAddPath)) + return false; + if (m_rowDuplicateCount > 0u && !m_cases.empty()) + { + const auto lastPath = m_cases.back().path; + for (uint32_t i = 0u; i < m_rowDuplicateCount; ++i) + if (!addRowViewCaseFromPath(lastPath)) + return false; + } + } + else + { + if (m_runMode != RunMode::Interactive) + m_nonInteractiveTest = true; + if (!startCase(0u)) + return false; + } + + camera.mapKeysToArrows(); + + onAppInitializedFinish(); + return true; +} + +IQueue::SSubmitInfo::SSemaphoreInfo MeshLoadersApp::renderFrame(const std::chrono::microseconds nextPresentationTimestamp) +{ + m_inputSystem->getDefaultMouse(&mouse); + m_inputSystem->getDefaultKeyboard(&keyboard); + + const auto resourceIx = m_realFrameIx % MaxFramesInFlight; + + auto* const cb = m_cmdBufs.data()[resourceIx].get(); + cb->reset(IGPUCommandBuffer::RESET_FLAGS::RELEASE_RESOURCES_BIT); + cb->begin(IGPUCommandBuffer::USAGE::ONE_TIME_SUBMIT_BIT); + // clear to black for both things + { + // begin renderpass + { + auto scRes = static_cast(m_surface->getSwapchainResources()); + auto* framebuffer = scRes->getFramebuffer(device_base_t::getCurrentAcquire().imageIndex); + const IGPUCommandBuffer::SClearColorValue clearValue = { .float32 = {1.f,0.f,1.f,1.f} }; + const IGPUCommandBuffer::SClearDepthStencilValue depthValue = { .depth = 0.f }; + const VkRect2D currentRenderArea = + { + .offset = {0,0}, + .extent = {framebuffer->getCreationParameters().width,framebuffer->getCreationParameters().height} + }; + const IGPUCommandBuffer::SRenderpassBeginInfo info = + { + .framebuffer = framebuffer, + .colorClearValues = &clearValue, + .depthStencilClearValues = &depthValue, + .renderArea = currentRenderArea + }; + cb->beginRenderPass(info, IGPUCommandBuffer::SUBPASS_CONTENTS::INLINE); + + const SViewport viewport = { + .x = static_cast(currentRenderArea.offset.x), + .y = static_cast(currentRenderArea.offset.y), + .width = static_cast(currentRenderArea.extent.width), + .height = static_cast(currentRenderArea.extent.height) + }; + cb->setViewport(0u,1u,&viewport); + + cb->setScissor(0u,1u,¤tRenderArea); + } + // late latch input + if (!m_nonInteractiveTest) + { + bool reloadInteractiveRequested = false; + bool reloadListRequested = false; + bool addRowViewRequested = false; + bool clearRowViewRequested = false; + camera.beginInputProcessing(nextPresentationTimestamp); + mouse.consumeEvents([&](const IMouseEventChannel::range_t& events) -> void { camera.mouseProcess(events); }, m_logger.get()); + keyboard.consumeEvents([&](const IKeyboardEventChannel::range_t& events) -> void + { + for (const auto& event : events) + { + if (event.action != SKeyboardEvent::ECA_RELEASED) + continue; + if (event.keyCode == E_KEY_CODE::EKC_R) + { + if (isRowViewActive()) + reloadListRequested = true; + else + reloadInteractiveRequested = true; + } + else if (event.keyCode == E_KEY_CODE::EKC_A) + { + if (isRowViewActive()) + addRowViewRequested = true; + } + else if (event.keyCode == E_KEY_CODE::EKC_X) + { + if (isRowViewActive()) + clearRowViewRequested = true; + } + } + camera.keyboardProcess(events); + }, + m_logger.get() + ); + camera.endInputProcessing(nextPresentationTimestamp); + if (clearRowViewRequested) + resetRowViewScene(); + if (addRowViewRequested) + addRowViewCase(); + if (reloadListRequested) + { + if (!reloadFromTestList()) + failExit("Failed to reload test list."); + } + if (reloadInteractiveRequested) + reloadInteractive(); + } + // draw scene + const auto& viewMatrix = camera.getViewMatrix(); + const auto& viewProjMatrix = camera.getConcatenatedMatrix(); + { + m_renderer->render(cb,CSimpleDebugRenderer::SViewParams(viewMatrix,viewProjMatrix)); + } +#ifdef NBL_BUILD_DEBUG_DRAW + { + const ISemaphore::SWaitInfo drawFinished = { .semaphore = m_semaphore.get(),.value = m_realFrameIx + 1u }; + ext::debug_draw::DrawAABB::DrawParameters drawParams; + drawParams.commandBuffer = cb; + drawParams.cameraMat = viewProjMatrix; + m_drawAABB->render(drawParams, drawFinished, m_aabbInstances); + } +#endif + cb->endRenderPass(); + } + cb->end(); + + IQueue::SSubmitInfo::SSemaphoreInfo retval = + { + .semaphore = m_semaphore.get(), + .value = ++m_realFrameIx, + .stageMask = PIPELINE_STAGE_FLAGS::ALL_GRAPHICS_BITS + }; + const IQueue::SSubmitInfo::SCommandBufferInfo commandBuffers[] = + { + {.cmdbuf = cb } + }; + const IQueue::SSubmitInfo::SSemaphoreInfo acquired[] = { + { + .semaphore = device_base_t::getCurrentAcquire().semaphore, + .value = device_base_t::getCurrentAcquire().acquireCount, + .stageMask = PIPELINE_STAGE_FLAGS::NONE + } + }; + const IQueue::SSubmitInfo infos[] = + { + { + .waitSemaphores = acquired, + .commandBuffers = commandBuffers, + .signalSemaphores = {&retval,1} + } + }; + + if (getGraphicsQueue()->submit(infos) != IQueue::RESULT::SUCCESS) + { + retval.semaphore = nullptr; // so that we don't wait on semaphore that will never signal + m_realFrameIx--; + } + + std::string caption = "[Nabla Engine] Mesh Loaders"; + { + caption += ", displaying ["; + caption += makeCaptionModelPath(m_modelPath, argv); + caption += "]"; + m_window->setCaption(caption); + } + if (isRowViewActive() && !m_rowViewScreenshotCaptured && m_realFrameIx >= RowViewFramesBeforeCapture) + { + if (!captureScreenshot(m_rowViewScreenshotPath, m_loadedScreenshot)) + failExit("Failed to capture row view screenshot."); + m_rowViewScreenshotCaptured = true; + } + advanceCase(); + return retval; +} + +bool MeshLoadersApp::onAppTerminated() +{ + return device_base_t::onAppTerminated(); +} + +bool MeshLoadersApp::keepRunning() +{ + if (m_shouldQuit) + return false; + return device_base_t::keepRunning(); +} + +const video::IGPURenderpass::SCreationParams::SSubpassDependency* MeshLoadersApp::getDefaultSubpassDependencies() const +{ + // Subsequent submits don't wait for each other, hence its important to have External Dependencies which prevent users of the depth attachment overlapping. + const static IGPURenderpass::SCreationParams::SSubpassDependency dependencies[] = { + // wipe-transition of Color to ATTACHMENT_OPTIMAL and depth + { + .srcSubpass = IGPURenderpass::SCreationParams::SSubpassDependency::External, + .dstSubpass = 0, + .memoryBarrier = { + // last place where the depth can get modified in previous frame, `COLOR_ATTACHMENT_OUTPUT_BIT` is implicitly later + .srcStageMask = PIPELINE_STAGE_FLAGS::LATE_FRAGMENT_TESTS_BIT, + // don't want any writes to be available, we'll clear + .srcAccessMask = ACCESS_FLAGS::NONE, + // destination needs to wait as early as possible + .dstStageMask = PIPELINE_STAGE_FLAGS::EARLY_FRAGMENT_TESTS_BIT | PIPELINE_STAGE_FLAGS::COLOR_ATTACHMENT_OUTPUT_BIT, + // because depth and color get cleared first no read mask + .dstAccessMask = ACCESS_FLAGS::DEPTH_STENCIL_ATTACHMENT_WRITE_BIT | ACCESS_FLAGS::COLOR_ATTACHMENT_WRITE_BIT + } + // leave view offsets and flags default + }, + // color from ATTACHMENT_OPTIMAL to PRESENT_SRC + { + .srcSubpass = 0, + .dstSubpass = IGPURenderpass::SCreationParams::SSubpassDependency::External, + .memoryBarrier = { + // last place where the color can get modified, depth is implicitly earlier + .srcStageMask = PIPELINE_STAGE_FLAGS::COLOR_ATTACHMENT_OUTPUT_BIT, + // only write ops, reads can't be made available + .srcAccessMask = ACCESS_FLAGS::COLOR_ATTACHMENT_WRITE_BIT + // spec says nothing is needed when presentation is the destination + } + // leave view offsets and flags default + }, + IGPURenderpass::SCreationParams::DependenciesEnd + }; + return dependencies; +} + +[[noreturn]] void MeshLoadersApp::failExit(const char* msg, ...) +{ + char formatted[4096] = {}; + va_list args; + va_start(args, msg); + vsnprintf(formatted, sizeof(formatted), msg, args); + va_end(args); + if (m_logger) + m_logger->log("%s", ILogger::ELL_ERROR, formatted); + std::exit(-1); +} + +bool MeshLoadersApp::initTestCases() +{ + m_cases.clear(); + m_caseNameCounts.clear(); + if (m_runMode == RunMode::Interactive) + { + system::path picked; + if (!pickModelPath(picked)) + return logFail("No file selected."); + m_cases.push_back({ makeUniqueCaseName(picked), picked }); + return true; + } + return loadTestList(m_testListPath); +} + +bool MeshLoadersApp::pickModelPath(system::path& outPath) +{ + if (m_fileDialogOpen) + { + if (m_logger) + m_logger->log("File dialog is already open. Ignoring request.", ILogger::ELL_WARNING); + return false; + } + + struct DialogGuard + { + bool& flag; + ~DialogGuard() { flag = false; } + }; + + m_fileDialogOpen = true; + DialogGuard guard{m_fileDialogOpen}; + + pfd::open_file file( + "Choose a supported Model File", + sharedInputCWD.string(), + { + "All Supported Formats", "*.ply *.stl *.serialized *.obj", + "Polygon File Format (.ply)", "*.ply", + "Stereolithography (.stl)", "*.stl", + "Mitsuba 0.6 Serialized (.serialized)", "*.serialized", + "Wavefront Object (.obj)", "*.obj" + }, + false); + + const auto selected = file.result(); + if (selected.empty()) + return false; + outPath = selected[0]; + return true; +} + +bool MeshLoadersApp::loadTestList(const system::path& jsonPath) +{ + if (!std::filesystem::exists(jsonPath)) + return logFail("Missing test list: %s", jsonPath.string().c_str()); + m_rowViewEnabled = true; + + std::ifstream stream(jsonPath); + if (!stream.is_open()) + return logFail("Failed to open test list: %s", jsonPath.string().c_str()); + + nlohmann::json doc; + try + { + stream >> doc; + } + catch (const std::exception& e) + { + return logFail("Invalid JSON in test list: %s", e.what()); + } + + if (!doc.contains("cases") || !doc["cases"].is_array()) + return logFail("Test list JSON missing \"cases\" array."); + + m_caseNameCounts.clear(); + + if (doc.contains("row_view")) + { + if (!doc["row_view"].is_boolean()) + return logFail("\"row_view\" must be a boolean."); + m_rowViewEnabled = doc["row_view"].get(); + } + if (m_forceRowViewForCurrentTestList && m_runMode == RunMode::Batch) + m_rowViewEnabled = true; + + const auto baseDir = jsonPath.parent_path(); + for (const auto& entry : doc["cases"]) + { + std::string pathString; + + if (entry.is_string()) + { + pathString = entry.get(); + } + else if (entry.is_object()) + { + if (!entry.contains("path") || !entry["path"].is_string()) + return logFail("Test list entry missing \"path\"."); + pathString = entry["path"].get(); + } + else + return logFail("Invalid test list entry."); + + system::path path = pathString; + if (path.is_relative()) + path = baseDir / path; + if (!std::filesystem::exists(path)) + return logFail("Missing test input: %s", path.string().c_str()); + + m_cases.push_back({ makeUniqueCaseName(path), path }); + } + + if (m_cases.empty()) + return logFail("No test cases in test list."); + + return true; +} + +bool MeshLoadersApp::isRowViewActive() const +{ + return m_rowViewEnabled && m_runMode != RunMode::CI && m_runMode != RunMode::Interactive; +} + +std::string MeshLoadersApp::normalizeExtension(const system::path& path) +{ + auto ext = path.extension().string(); + std::transform(ext.begin(), ext.end(), ext.begin(), [](unsigned char c) { return static_cast(std::tolower(c)); }); + return ext; +} + +bool MeshLoadersApp::isWriteExtensionSupported(const std::string& ext) const +{ + if (ext == ".ply" || ext == ".stl") + return true; +#ifdef _NBL_COMPILE_WITH_OBJ_WRITER_ + if (ext == ".obj") + return true; +#endif + return false; +} + +system::path MeshLoadersApp::resolveSavePath(const system::path& modelPath) const +{ + if (m_specifiedGeomSavePath) + return path(*m_specifiedGeomSavePath); + const auto stem = modelPath.stem().string(); + auto ext = normalizeExtension(modelPath); + if (ext.empty()) + ext = ".ply"; + if (!isWriteExtensionSupported(ext)) + { + if (m_logger) + m_logger->log("No writer for %s, writing .ply instead.", ILogger::ELL_WARNING, ext.c_str()); + ext = ".ply"; + } + return m_saveGeomPrefixPath / (stem + "_written" + ext); +} + +std::string MeshLoadersApp::sanitizeCaseNameForFilename(std::string name) +{ + for (auto& ch : name) + { + const unsigned char uch = static_cast(ch); + if (!(std::isalnum(uch) || ch == '_' || ch == '-' || ch == '.')) + ch = '_'; + } + if (name.empty()) + name = "unnamed_case"; + return name; +} + +system::path MeshLoadersApp::getGeometryHashReferencePath(const std::string& caseName) const +{ + return m_geometryHashReferenceDir / (sanitizeCaseNameForFilename(caseName) + ".geomhash"); +} + +std::string MeshLoadersApp::geometryHashToHex(const core::blake3_hash_t& hash) +{ + static constexpr char HexDigits[] = "0123456789abcdef"; + std::string out; + out.resize(sizeof(hash.data) * 2ull); + for (size_t i = 0ull; i < sizeof(hash.data); ++i) + { + const uint8_t v = hash.data[i]; + out[2ull * i + 0ull] = HexDigits[(v >> 4) & 0xfu]; + out[2ull * i + 1ull] = HexDigits[v & 0xfu]; + } + return out; +} + +bool MeshLoadersApp::tryParseNibble(const char c, uint8_t& out) +{ + if (c >= '0' && c <= '9') + { + out = static_cast(c - '0'); + return true; + } + if (c >= 'a' && c <= 'f') + { + out = static_cast(10 + c - 'a'); + return true; + } + if (c >= 'A' && c <= 'F') + { + out = static_cast(10 + c - 'A'); + return true; + } + return false; +} + +bool MeshLoadersApp::tryParseGeometryHashHex(std::string hex, core::blake3_hash_t& outHash) +{ + hex.erase(std::remove_if(hex.begin(), hex.end(), [](unsigned char c) { return std::isspace(c) != 0; }), hex.end()); + if (hex.size() != sizeof(outHash.data) * 2ull) + return false; + + for (size_t i = 0ull; i < sizeof(outHash.data); ++i) + { + uint8_t hi = 0u; + uint8_t lo = 0u; + if (!tryParseNibble(hex[2ull * i + 0ull], hi) || !tryParseNibble(hex[2ull * i + 1ull], lo)) + return false; + outHash.data[i] = static_cast((hi << 4) | lo); + } + return true; +} + +bool MeshLoadersApp::readGeometryHashReference(const system::path& refPath, core::blake3_hash_t& outHash) const +{ + std::ifstream in(refPath); + if (!in.is_open()) + return false; + std::string line; + std::getline(in, line); + return tryParseGeometryHashHex(std::move(line), outHash); +} + +bool MeshLoadersApp::writeGeometryHashReference(const system::path& refPath, const core::blake3_hash_t& hash) const +{ + std::error_code ec; + std::filesystem::create_directories(refPath.parent_path(), ec); + if (ec) + return false; + std::ofstream out(refPath, std::ios::binary | std::ios::trunc); + if (!out.is_open()) + return false; + out << geometryHashToHex(hash) << '\n'; + return out.good(); +} + +bool MeshLoadersApp::startCase(const size_t index) +{ + if (index >= m_cases.size()) + return false; + + m_caseIndex = index; + m_phase = Phase::RenderOriginal; + m_phaseFrameCounter = 0u; + m_loadedScreenshot = nullptr; + m_writtenScreenshot = nullptr; + m_referenceCamera.reset(); + m_hasReferenceGeometryHash = false; + m_caseGeometryHashReferencePath.clear(); + + const auto& testCase = m_cases[m_caseIndex]; + m_caseName = testCase.name.empty() ? testCase.path.stem().string() : testCase.name; + m_writtenPath = resolveSavePath(testCase.path); + m_loadedScreenshotPath = m_screenshotPrefixPath / ("meshloaders_" + m_caseName + "_loaded.png"); + m_writtenScreenshotPath = m_screenshotPrefixPath / ("meshloaders_" + m_caseName + "_written.png"); + + if (!loadModel(testCase.path, true, true)) + return false; + + if (m_currentCpuGeom) + { + const auto loadedGeometryHash = hashGeometry(m_currentCpuGeom.get()); + m_referenceGeometryHash = loadedGeometryHash; + m_hasReferenceGeometryHash = true; + m_caseGeometryHashReferencePath = getGeometryHashReferencePath(m_caseName); + + if (m_updateGeometryHashReferences) + { + const bool referenceExisted = std::filesystem::exists(m_caseGeometryHashReferencePath); + if (!writeGeometryHashReference(m_caseGeometryHashReferencePath, loadedGeometryHash)) + return logFail("Failed to write geometry hash reference: %s", m_caseGeometryHashReferencePath.string().c_str()); + if (!referenceExisted) + m_logger->log("Geometry hash reference did not exist for %s. Created new reference at %s", ILogger::ELL_WARNING, m_caseName.c_str(), m_caseGeometryHashReferencePath.string().c_str()); + else + m_logger->log("Geometry hash reference updated for %s at %s", ILogger::ELL_INFO, m_caseName.c_str(), m_caseGeometryHashReferencePath.string().c_str()); + } + else if (m_runMode == RunMode::CI) + { + if (!std::filesystem::exists(m_caseGeometryHashReferencePath)) + return logFail("Missing geometry hash reference for %s at %s. Run once with --update-references.", m_caseName.c_str(), m_caseGeometryHashReferencePath.string().c_str()); + + core::blake3_hash_t onDiskHash = {}; + if (!readGeometryHashReference(m_caseGeometryHashReferencePath, onDiskHash)) + return logFail("Invalid geometry hash reference for %s at %s", m_caseName.c_str(), m_caseGeometryHashReferencePath.string().c_str()); + + m_referenceGeometryHash = onDiskHash; + m_hasReferenceGeometryHash = true; + if (loadedGeometryHash != onDiskHash) + { + m_logger->log("Loaded geometry hash mismatch for %s. Current=%s Reference=%s", ILogger::ELL_ERROR, m_caseName.c_str(), geometryHashToHex(loadedGeometryHash).c_str(), geometryHashToHex(onDiskHash).c_str()); + return logFail("Loaded asset differs from stored geometry hash reference for %s.", m_caseName.c_str()); + } + } + } + + return true; +} + +bool MeshLoadersApp::advanceToNextCase() +{ + const auto nextIndex = m_caseIndex + 1u; + if (nextIndex >= m_cases.size()) + { + m_shouldQuit = true; + return false; + } + if (!startCase(nextIndex)) + { + m_shouldQuit = true; + return false; + } + return true; +} + +void MeshLoadersApp::reloadInteractive() +{ + system::path picked; + if (!pickModelPath(picked)) + failExit("No file selected."); + if (!loadModel(picked, true, true)) + failExit("Failed to load asset %s.", picked.string().c_str()); + if (m_currentCpuGeom && m_saveGeom) + { + const auto savePath = resolveSavePath(picked); + if (!writeGeometry(m_currentCpuGeom, savePath.string())) + failExit("Geometry write failed."); + } +} + +bool MeshLoadersApp::addRowViewCase() +{ + system::path picked; + if (!pickModelPath(picked)) + return false; + return addRowViewCaseFromPath(picked); +} + +bool MeshLoadersApp::addRowViewCaseFromPath(const system::path& picked) +{ + if (picked.empty()) + return false; + m_cases.push_back({ makeUniqueCaseName(picked), picked }); + m_shouldQuit = false; + return loadRowView(RowViewReloadMode::Incremental); +} + +bool MeshLoadersApp::reloadFromTestList() +{ + m_cases.clear(); + if (!loadTestList(m_testListPath)) + return false; + m_shouldQuit = false; + m_rowViewScreenshotCaptured = false; + if (isRowViewActive()) + { + m_nonInteractiveTest = false; + return loadRowView(RowViewReloadMode::Full); + } + m_nonInteractiveTest = (m_runMode != RunMode::Interactive); + return startCase(0u); +} + +void MeshLoadersApp::resetRowViewScene() +{ + if (!isRowViewActive()) + return; + m_cases.clear(); + m_rowViewCache.clear(); + m_renderer->m_instances.clear(); + m_renderer->clearGeometries({ .semaphore = m_semaphore.get(),.value = m_realFrameIx }); +#ifdef NBL_BUILD_DEBUG_DRAW + m_aabbInstances.clear(); + m_obbInstances.clear(); +#endif + m_modelPath = "Row view (empty)"; + m_rowViewScreenshotCaptured = false; + m_shouldQuit = false; + m_nonInteractiveTest = false; + m_logger->log("Row view reset to empty. Press A to add a model.", ILogger::ELL_INFO); +} + + + diff --git a/12_MeshLoaders/MeshLoadersAppLoad.cpp b/12_MeshLoaders/MeshLoadersAppLoad.cpp new file mode 100644 index 000000000..b5e9fb96d --- /dev/null +++ b/12_MeshLoaders/MeshLoadersAppLoad.cpp @@ -0,0 +1,896 @@ +// Copyright (C) 2018-2025 - DevSH Graphics Programming Sp. z O.O. +// This file is part of the "Nabla Engine". +// For conditions of distribution and use, see copyright notice in nabla.h + +#include "MeshLoadersApp.hpp" + +#include +#include +#include + +#include + +namespace +{ +inline bool meshloadersIsFinite(const double value) +{ + return std::isfinite(value); +} + +inline bool meshloadersIsFinite(const hlsl::float64_t3& value) +{ + return meshloadersIsFinite(value.x) && meshloadersIsFinite(value.y) && meshloadersIsFinite(value.z); +} + +hlsl::shapes::AABB<3, double> meshloadersComputeFinitePositionAABB(const ICPUPolygonGeometry* geometry) +{ + auto aabb = hlsl::shapes::AABB<3, double>::create(); + if (!geometry) + return aabb; + const auto positionView = geometry->getPositionView(); + const auto vertexCount = positionView.getElementCount(); + bool hasFiniteVertex = false; + for (size_t i = 0u; i < vertexCount; ++i) + { + hlsl::float32_t3 decoded = {}; + positionView.decodeElement(i, decoded); + const hlsl::float64_t3 p = { + static_cast(decoded.x), + static_cast(decoded.y), + static_cast(decoded.z) + }; + if (!meshloadersIsFinite(p)) + continue; + if (!hasFiniteVertex) + { + aabb.minVx = p; + aabb.maxVx = p; + hasFiniteVertex = true; + continue; + } + aabb.minVx.x = std::min(aabb.minVx.x, p.x); + aabb.minVx.y = std::min(aabb.minVx.y, p.y); + aabb.minVx.z = std::min(aabb.minVx.z, p.z); + aabb.maxVx.x = std::max(aabb.maxVx.x, p.x); + aabb.maxVx.y = std::max(aabb.maxVx.y, p.y); + aabb.maxVx.z = std::max(aabb.maxVx.z, p.z); + } + if (hasFiniteVertex) + return aabb; + return hlsl::shapes::AABB<3, double>::create(); +} + +hlsl::shapes::AABB<3, double> meshloadersFallbackUnitAABB() +{ + hlsl::shapes::AABB<3, double> fallback = hlsl::shapes::AABB<3, double>::create(); + fallback.minVx = hlsl::float64_t3(-1.0, -1.0, -1.0); + fallback.maxVx = hlsl::float64_t3(1.0, 1.0, 1.0); + return fallback; +} +} + +bool MeshLoadersApp::loadModel(const system::path& modelPath, bool updateCamera, bool storeCamera) +{ + if (modelPath.empty()) + failExit("Empty model path."); + if (!std::filesystem::exists(modelPath)) + failExit("Missing input: %s", modelPath.string().c_str()); + using clock_t = std::chrono::high_resolution_clock; + const auto loadOuterStart = clock_t::now(); + + m_modelPath = modelPath.string(); + + // free up + m_renderer->m_instances.clear(); + m_renderer->clearGeometries({ .semaphore = m_semaphore.get(),.value = m_realFrameIx }); + m_assetMgr->clearAllAssetCache(); + + //! load the geometry + IAssetLoader::SAssetLoadParams params = makeLoadParams(); + AssetLoadCallResult loadResult = {}; + if (!loadAssetCallFromPath(modelPath, params, loadResult)) + failExit("Failed to open input file %s.", modelPath.string().c_str()); + const auto loadMs = loadResult.getAssetMs; + auto asset = std::move(loadResult.bundle); + m_logger->log( + "Asset load call perf: path=%s time=%.3f ms size=%llu", + ILogger::ELL_INFO, + m_modelPath.c_str(), + loadMs, + static_cast(loadResult.inputSize)); + if (asset.getContents().empty()) + failExit("Failed to load asset %s.", m_modelPath.c_str()); + + core::vector> geometries; + const auto extractStart = clock_t::now(); + if (!appendGeometriesFromBundle(asset, geometries)) + failExit("Asset loaded but not a supported type for %s.", m_modelPath.c_str()); + const auto extractMs = toMs(clock_t::now() - extractStart); + if (geometries.empty()) + failExit("No geometry found in asset %s.", m_modelPath.c_str()); + + const auto outerMs = toMs(clock_t::now() - loadOuterStart); + const auto nonLoaderMs = std::max(0.0, outerMs - loadMs); + m_logger->log( + "Asset load outer perf: path=%s getAsset=%.3f ms extract=%.3f ms total=%.3f ms non_loader=%.3f ms", + ILogger::ELL_INFO, + m_modelPath.c_str(), + loadMs, + extractMs, + outerMs, + nonLoaderMs); + + m_currentCpuGeom = geometries[0]; + + using aabb_t = hlsl::shapes::AABB<3, double>; + auto printAABB = [&](const aabb_t& aabb, const char* extraMsg = "")->void + { + m_logger->log("%s AABB is (%f,%f,%f) -> (%f,%f,%f)", ILogger::ELL_INFO, extraMsg, aabb.minVx.x, aabb.minVx.y, aabb.minVx.z, aabb.maxVx.x, aabb.maxVx.y, aabb.maxVx.z); + }; + auto bound = aabb_t::create(); + // convert the geometries + { + smart_refctd_ptr converter = CAssetConverter::create({ .device = m_device.get() }); + + const auto transferFamily = getTransferUpQueue()->getFamilyIndex(); + + struct SInputs : CAssetConverter::SInputs + { + virtual inline std::span getSharedOwnershipQueueFamilies(const size_t groupCopyID, const asset::ICPUBuffer* buffer, const CAssetConverter::patch_t& patch) const + { + return sharedBufferOwnership; + } + + core::vector sharedBufferOwnership; + } inputs = {}; + core::vector> patches(geometries.size(), CSimpleDebugRenderer::DefaultPolygonGeometryPatch); + { + inputs.logger = m_logger.get(); + std::get>(inputs.assets) = { &geometries.front().get(),geometries.size() }; + std::get>(inputs.patches) = patches; + // set up shared ownership so we don't have to + core::unordered_set families; + families.insert(transferFamily); + families.insert(getGraphicsQueue()->getFamilyIndex()); + if (families.size() > 1) + for (const auto fam : families) + inputs.sharedBufferOwnership.push_back(fam); + } + + // reserve + auto reservation = converter->reserve(inputs); + if (!reservation) + { + failExit("Failed to reserve GPU objects for CPU->GPU conversion."); + } + + // convert + { + auto semaphore = m_device->createSemaphore(0u); + + constexpr auto MultiBuffering = 2; + std::array, MultiBuffering> commandBuffers = {}; + { + auto pool = m_device->createCommandPool(transferFamily, IGPUCommandPool::CREATE_FLAGS::RESET_COMMAND_BUFFER_BIT | IGPUCommandPool::CREATE_FLAGS::TRANSIENT_BIT); + pool->createCommandBuffers(IGPUCommandPool::BUFFER_LEVEL::PRIMARY, commandBuffers, smart_refctd_ptr(m_logger)); + } + commandBuffers.front()->begin(IGPUCommandBuffer::USAGE::ONE_TIME_SUBMIT_BIT); + + std::array commandBufferSubmits; + for (auto i = 0; i < MultiBuffering; i++) + commandBufferSubmits[i].cmdbuf = commandBuffers[i].get(); + + SIntendedSubmitInfo transfer = {}; + transfer.queue = getTransferUpQueue(); + transfer.scratchCommandBuffers = commandBufferSubmits; + transfer.scratchSemaphore = { + .semaphore = semaphore.get(), + .value = 0u, + .stageMask = PIPELINE_STAGE_FLAGS::ALL_TRANSFER_BITS + }; + + CAssetConverter::SConvertParams cpar = {}; + cpar.utilities = m_utils.get(); + cpar.transfer = &transfer; + + auto future = reservation.convert(cpar); + if (future.copy() != IQueue::RESULT::SUCCESS) + failExit("Failed to await submission feature."); + } + + auto tmp = hlsl::float32_t4x3( + hlsl::float32_t3(1, 0, 0), + hlsl::float32_t3(0, 1, 0), + hlsl::float32_t3(0, 0, 1), + hlsl::float32_t3(0, 0, 0)); + core::vector worldTforms; + const auto& converted = reservation.getGPUObjects(); + m_aabbInstances.resize(converted.size()); + if (m_drawBBMode == DBBM_OBB) + m_obbInstances.resize(converted.size()); + for (uint32_t i = 0; i < converted.size(); i++) + { + const auto& cpuGeom = geometries[i].get(); + auto promoted = getGeometryAABB(cpuGeom); + if (!isValidAABB(promoted)) + { + m_logger->log("Invalid geometry AABB for %s (geo=%u). Using fallback unit AABB for framing.", ILogger::ELL_WARNING, m_modelPath.c_str(), i); + promoted = meshloadersFallbackUnitAABB(); + } + printAABB(promoted, "Geometry"); + const auto promotedWorld = hlsl::float64_t3x4(worldTforms.emplace_back(hlsl::transpose(tmp))); + const auto translation = hlsl::float64_t3( + static_cast(tmp[3].x), + static_cast(tmp[3].y), + static_cast(tmp[3].z)); + const auto transformed = translateAABB(promoted, translation); + printAABB(transformed, "Transformed"); + bound = hlsl::shapes::util::union_(transformed, bound); + +#ifdef NBL_BUILD_DEBUG_DRAW + auto& aabbInst = m_aabbInstances[i]; + const auto tmpAabb = shapes::AABB<3, float>(promoted.minVx, promoted.maxVx); + + hlsl::float32_t3x4 aabbTransform = ext::debug_draw::DrawAABB::getTransformFromAABB(tmpAabb); + const auto tmpWorld = hlsl::float32_t3x4(promotedWorld); + const auto world4x4 = float32_t4x4{ + tmpWorld[0], + tmpWorld[1], + tmpWorld[2], + float32_t4(0, 0, 0, 1) + }; + + aabbInst.color = { 1, 1, 1, 1 }; + aabbInst.transform = math::linalg::promoted_mul(world4x4, aabbTransform); + + if (m_drawBBMode == DBBM_OBB) + { + auto& obbInst = m_obbInstances[i]; + const auto obb = CPolygonGeometryManipulator::calculateOBB( + cpuGeom->getPositionView().getElementCount(), + [geo = cpuGeom, &world4x4](size_t vertex_i) { + hlsl::float32_t3 pt; + geo->getPositionView().decodeElement(vertex_i, pt); + return pt; + }); + obbInst.color = { 0, 0, 1, 1 }; + obbInst.transform = math::linalg::promoted_mul(world4x4, obb.transform); + } +#endif + } + + printAABB(bound, "Total"); + if (!m_renderer->addGeometries({ &converted.front().get(),converted.size() })) + failExit("Failed to add geometries to renderer."); + if (m_logger) + { + const auto& gpuGeos = m_renderer->getGeometries(); + for (size_t geoIx = 0u; geoIx < gpuGeos.size(); ++geoIx) + { + const auto& gpuGeo = gpuGeos[geoIx]; + m_logger->log( + "Renderer geo state: idx=%llu elem=%u posView=%u normalView=%u indexType=%u", + ILogger::ELL_DEBUG, + static_cast(geoIx), + gpuGeo.elementCount, + static_cast(gpuGeo.positionView), + static_cast(gpuGeo.normalView), + static_cast(gpuGeo.indexType)); + } + } + + auto worlTformsIt = worldTforms.begin(); + for (const auto& geo : m_renderer->getGeometries()) + m_renderer->m_instances.push_back({ + .world = *(worlTformsIt++), + .packedGeo = &geo + }); + } + + if (updateCamera) + { + setupCameraFromAABB(bound); + if (storeCamera) + storeCameraState(); + } + else if (m_referenceCamera) + applyCameraState(*m_referenceCamera); + else + setupCameraFromAABB(bound); + + return true; +} + +bool MeshLoadersApp::loadRowView(const RowViewReloadMode mode) +{ + if (m_cases.empty()) + failExit("No test cases loaded for row view."); + + using clock_t = std::chrono::high_resolution_clock; + RowViewPerfStats stats = {}; + stats.incremental = (mode == RowViewReloadMode::Incremental); + stats.cases = m_cases.size(); + const auto totalStart = clock_t::now(); + + const auto clearStart = clock_t::now(); + if (mode == RowViewReloadMode::Full) + { + m_renderer->m_instances.clear(); + m_renderer->clearGeometries({ .semaphore = m_semaphore.get(),.value = m_realFrameIx }); + } + stats.clearMs = toMs(clock_t::now() - clearStart); + + core::vector> geometries; + core::vector> aabbs; + geometries.reserve(m_cases.size()); + aabbs.reserve(m_cases.size()); + + core::vector> cpuToConvert; + core::vector convertEntries; + + m_rowViewCache.reserve(m_cases.size()); + + IAssetLoader::SAssetLoadParams params = makeLoadParams(); + + for (const auto& testCase : m_cases) + { + const auto& path = testCase.path; + if (!std::filesystem::exists(path)) + failExit("Missing input: %s", path.string().c_str()); + + const auto cacheKey = makeCacheKey(path); + auto& entry = m_rowViewCache[cacheKey]; + double assetLoadMs = 0.0; + bool cached = true; + if (!entry.cpu) + { + stats.cpuMisses++; + cached = false; + AssetLoadCallResult loadResult = {}; + if (!loadAssetCallFromPath(path, params, loadResult)) + failExit("Failed to open input file %s.", path.string().c_str()); + auto asset = std::move(loadResult.bundle); + assetLoadMs = loadResult.getAssetMs; + stats.loadMs += assetLoadMs; + if (asset.getContents().empty()) + failExit("Failed to load asset %s.", path.string().c_str()); + + const auto extractStart = clock_t::now(); + core::vector> found; + if (appendGeometriesFromBundle(asset, found)) + { + if (!found.empty()) + entry.cpu = found.front(); + } + stats.extractMs += toMs(clock_t::now() - extractStart); + if (!entry.cpu) + failExit("No geometry found in asset %s.", path.string().c_str()); + + const auto aabbStart = clock_t::now(); + entry.aabb = getGeometryAABB(entry.cpu.get()); + entry.hasAabb = isValidAABB(entry.aabb); + if (!entry.hasAabb) + { + m_logger->log("Invalid row-view geometry AABB for %s. Using fallback unit AABB.", ILogger::ELL_WARNING, path.string().c_str()); + entry.aabb = meshloadersFallbackUnitAABB(); + entry.hasAabb = true; + } + stats.aabbMs += toMs(clock_t::now() - aabbStart); + } + else + { + stats.cpuHits++; + if (!entry.hasAabb) + { + const auto aabbStart = clock_t::now(); + entry.aabb = getGeometryAABB(entry.cpu.get()); + entry.hasAabb = isValidAABB(entry.aabb); + if (!entry.hasAabb) + { + m_logger->log("Invalid row-view geometry AABB for %s. Using fallback unit AABB.", ILogger::ELL_WARNING, path.string().c_str()); + entry.aabb = meshloadersFallbackUnitAABB(); + entry.hasAabb = true; + } + stats.aabbMs += toMs(clock_t::now() - aabbStart); + } + } + logRowViewAssetLoad(path, assetLoadMs, cached); + + if (!entry.gpu) + { + stats.gpuMisses++; + cpuToConvert.push_back(entry.cpu); + convertEntries.push_back(&entry); + } + else + { + stats.gpuHits++; + } + + geometries.push_back(entry.cpu); + aabbs.push_back(entry.aabb); + } + + if (geometries.empty()) + failExit("No geometry found for row view."); + logRowViewLoadTotal(stats.loadMs, stats.cpuHits, stats.cpuMisses); + + if (!cpuToConvert.empty()) + { + stats.convertCount = cpuToConvert.size(); + const auto convertStart = clock_t::now(); + + smart_refctd_ptr converter = CAssetConverter::create({ .device = m_device.get() }); + const auto transferFamily = getTransferUpQueue()->getFamilyIndex(); + + struct SInputs : CAssetConverter::SInputs + { + virtual inline std::span getSharedOwnershipQueueFamilies(const size_t, const asset::ICPUBuffer*, const CAssetConverter::patch_t&) const + { + return sharedBufferOwnership; + } + + core::vector sharedBufferOwnership; + } inputs = {}; + core::vector> patches(cpuToConvert.size(), CSimpleDebugRenderer::DefaultPolygonGeometryPatch); + { + inputs.logger = m_logger.get(); + std::get>(inputs.assets) = { &cpuToConvert.front().get(),cpuToConvert.size() }; + std::get>(inputs.patches) = patches; + core::unordered_set families; + families.insert(transferFamily); + families.insert(getGraphicsQueue()->getFamilyIndex()); + if (families.size() > 1) + for (const auto fam : families) + inputs.sharedBufferOwnership.push_back(fam); + } + + auto reservation = converter->reserve(inputs); + if (!reservation) + failExit("Failed to reserve GPU objects for CPU->GPU conversion."); + + { + auto semaphore = m_device->createSemaphore(0u); + + constexpr auto MultiBuffering = 2; + std::array, MultiBuffering> commandBuffers = {}; + { + auto pool = m_device->createCommandPool(transferFamily, IGPUCommandPool::CREATE_FLAGS::RESET_COMMAND_BUFFER_BIT | IGPUCommandPool::CREATE_FLAGS::TRANSIENT_BIT); + pool->createCommandBuffers(IGPUCommandPool::BUFFER_LEVEL::PRIMARY, commandBuffers, smart_refctd_ptr(m_logger)); + } + commandBuffers.front()->begin(IGPUCommandBuffer::USAGE::ONE_TIME_SUBMIT_BIT); + + std::array commandBufferSubmits; + for (auto i = 0; i < MultiBuffering; i++) + commandBufferSubmits[i].cmdbuf = commandBuffers[i].get(); + + SIntendedSubmitInfo transfer = {}; + transfer.queue = getTransferUpQueue(); + transfer.scratchCommandBuffers = commandBufferSubmits; + transfer.scratchSemaphore = { + .semaphore = semaphore.get(), + .value = 0u, + .stageMask = PIPELINE_STAGE_FLAGS::ALL_TRANSFER_BITS + }; + + CAssetConverter::SConvertParams cpar = {}; + cpar.utilities = m_utils.get(); + cpar.transfer = &transfer; + + auto future = reservation.convert(cpar); + if (future.copy() != IQueue::RESULT::SUCCESS) + failExit("Failed to await submission feature."); + } + + const auto& converted = reservation.getGPUObjects(); + for (size_t i = 0u; i < converted.size(); ++i) + convertEntries[i]->gpu = converted[i]; + + stats.convertMs = toMs(clock_t::now() - convertStart); + } + + size_t existingCount = m_renderer->getGeometries().size(); + const bool incremental = (mode == RowViewReloadMode::Incremental) && (existingCount <= m_cases.size()); + if (!incremental && mode == RowViewReloadMode::Incremental) + return loadRowView(RowViewReloadMode::Full); + + if (mode == RowViewReloadMode::Full) + { + core::vector allGeometries; + allGeometries.reserve(m_cases.size()); + for (const auto& testCase : m_cases) + { + const auto& entry = m_rowViewCache[makeCacheKey(testCase.path)]; + if (!entry.gpu) + failExit("Missing GPU geometry for %s.", testCase.path.string().c_str()); + allGeometries.push_back(entry.gpu.get()); + } + stats.addCount = allGeometries.size(); + const auto addStart = clock_t::now(); + if (!allGeometries.empty()) + if (!m_renderer->addGeometries({ allGeometries.data(),allGeometries.size() })) + failExit("Failed to add geometries to renderer."); + stats.addGeoMs = toMs(clock_t::now() - addStart); + } + else + { + const size_t addCount = (existingCount < m_cases.size()) ? (m_cases.size() - existingCount) : 0u; + stats.addCount = addCount; + if (addCount > 0u) + { + core::vector newGeometries; + newGeometries.reserve(addCount); + for (size_t i = existingCount; i < m_cases.size(); ++i) + { + const auto& entry = m_rowViewCache[makeCacheKey(m_cases[i].path)]; + if (!entry.gpu) + failExit("Missing GPU geometry for %s.", m_cases[i].path.string().c_str()); + newGeometries.push_back(entry.gpu.get()); + } + const auto addStart = clock_t::now(); + if (!m_renderer->addGeometries({ newGeometries.data(),newGeometries.size() })) + failExit("Failed to add geometries to renderer."); + stats.addGeoMs = toMs(clock_t::now() - addStart); + } + } + + using aabb_t = hlsl::shapes::AABB<3, double>; + auto printAABB = [&](const aabb_t& aabb, const char* extraMsg = "")->void + { + m_logger->log("%s AABB is (%f,%f,%f) -> (%f,%f,%f)", ILogger::ELL_INFO, extraMsg, aabb.minVx.x, aabb.minVx.y, aabb.minVx.z, aabb.maxVx.x, aabb.maxVx.y, aabb.maxVx.z); + }; + auto bound = aabb_t::create(); + + const auto layoutStart = clock_t::now(); + double targetExtent = 0.0; + core::vector maxDims; + maxDims.reserve(aabbs.size()); + for (const auto& aabb : aabbs) + { + const auto extent = aabb.getExtent(); + const double maxDim = std::max({ extent.x, extent.y, extent.z, 0.001 }); + maxDims.push_back(maxDim); + if (maxDim > targetExtent) + targetExtent = maxDim; + } + + core::vector scales; + scales.reserve(aabbs.size()); + for (const auto maxDim : maxDims) + scales.push_back(targetExtent / maxDim); + + double maxWidth = 0.0; + double totalWidth = 0.0; + core::vector widths; + widths.reserve(aabbs.size()); + for (size_t i = 0; i < aabbs.size(); ++i) + { + const auto extent = aabbs[i].getExtent(); + const double width = std::max(0.001, extent.x * scales[i]); + widths.push_back(width); + totalWidth += width; + if (width > maxWidth) + maxWidth = width; + } + const double spacing = std::max(0.05 * maxWidth, 0.01); + const double totalSpan = totalWidth + spacing * double(widths.size() > 0 ? widths.size() - 1 : 0); + double cursor = -0.5 * totalSpan; + stats.layoutMs = toMs(clock_t::now() - layoutStart); + + const auto instanceStart = clock_t::now(); + auto tmp = hlsl::float32_t4x3( + hlsl::float32_t3(1, 0, 0), + hlsl::float32_t3(0, 1, 0), + hlsl::float32_t3(0, 0, 1), + hlsl::float32_t3(0, 0, 0) + ); + core::vector worldTforms; + worldTforms.reserve(geometries.size()); + m_aabbInstances.resize(geometries.size()); + if (m_drawBBMode == DBBM_OBB) + m_obbInstances.resize(geometries.size()); + m_renderer->m_instances.clear(); + + for (uint32_t i = 0; i < geometries.size(); i++) + { + const auto& cpuGeom = geometries[i].get(); + const auto aabb = aabbs[i]; + printAABB(aabb, "Geometry"); + + const double scale = scales[i]; + const auto center = (aabb.minVx + aabb.maxVx) * 0.5; + const double width = widths[i]; + const double targetCenterX = cursor + 0.5 * width; + cursor += width + spacing; + + const double tx = targetCenterX - scale * center.x; + const double ty = -scale * center.y; + const double tz = -scale * center.z; + tmp[0] = hlsl::float32_t3(static_cast(scale), 0.f, 0.f); + tmp[1] = hlsl::float32_t3(0.f, static_cast(scale), 0.f); + tmp[2] = hlsl::float32_t3(0.f, 0.f, static_cast(scale)); + tmp[3] = hlsl::float32_t3(static_cast(tx), static_cast(ty), static_cast(tz)); + + const auto promotedWorld = hlsl::float64_t3x4(worldTforms.emplace_back(hlsl::transpose(tmp))); + const auto translation = hlsl::float64_t3(tx, ty, tz); + const auto scaled = scaleAABB(aabb, scale); + const auto transformed = translateAABB(scaled, translation); + printAABB(transformed, "Transformed"); + bound = hlsl::shapes::util::union_(transformed, bound); + +#ifdef NBL_BUILD_DEBUG_DRAW + auto& aabbInst = m_aabbInstances[i]; + const auto tmpAabb = shapes::AABB<3, float>(aabb.minVx, aabb.maxVx); + hlsl::float32_t3x4 aabbTransform = ext::debug_draw::DrawAABB::getTransformFromAABB(tmpAabb); + const auto tmpWorld = hlsl::float32_t3x4(promotedWorld); + const auto world4x4 = float32_t4x4{ + tmpWorld[0], + tmpWorld[1], + tmpWorld[2], + float32_t4(0, 0, 0, 1) + }; + aabbInst.color = { 1,1,1,1 }; + aabbInst.transform = math::linalg::promoted_mul(world4x4, aabbTransform); + + if (m_drawBBMode == DBBM_OBB) + { + auto& obbInst = m_obbInstances[i]; + const auto obb = CPolygonGeometryManipulator::calculateOBB( + cpuGeom->getPositionView().getElementCount(), + [geo = cpuGeom](size_t vertex_i) { + hlsl::float32_t3 pt; + geo->getPositionView().decodeElement(vertex_i, pt); + return pt; + }); + obbInst.color = { 0, 0, 1, 1 }; + obbInst.transform = math::linalg::promoted_mul(world4x4, obb.transform); + } +#endif + } + + printAABB(bound, "Total"); + for (uint32_t i = 0; i < worldTforms.size(); i++) + { + m_renderer->m_instances.push_back({ + .world = worldTforms[i], + .packedGeo = &m_renderer->getGeometry(i) + }); + } + stats.instanceMs = toMs(clock_t::now() - instanceStart); + + const auto cameraStart = clock_t::now(); + setupCameraFromAABB(bound); + stats.cameraMs = toMs(clock_t::now() - cameraStart); + + m_modelPath = "Row view (all meshes)"; + m_rowViewScreenshotPath = m_screenshotPrefixPath / "meshloaders_row_view.png"; + m_rowViewScreenshotCaptured = false; + stats.totalMs = toMs(clock_t::now() - totalStart); + logRowViewPerf(stats); + return true; +} + +bool MeshLoadersApp::writeGeometry(smart_refctd_ptr geometry, const std::string& savePath) +{ + using clock_t = std::chrono::high_resolution_clock; + const auto writeOuterStart = clock_t::now(); + IAsset* assetPtr = const_cast(static_cast(geometry.get())); + const auto ext = normalizeExtension(system::path(savePath)); + auto flags = asset::EWF_MESH_IS_RIGHT_HANDED; + if (ext != ".obj") + flags = static_cast(flags | asset::EWF_BINARY); + IAssetWriter::SAssetWriteParams params{ assetPtr, flags }; + params.logger = getAssetLoadLogger(); + m_logger->log("Saving mesh to %s", ILogger::ELL_INFO, savePath.c_str()); + const auto openStart = clock_t::now(); + system::ISystem::future_t> writeFileFuture; + m_system->createFile(writeFileFuture, system::path(savePath), system::IFile::ECF_WRITE); + core::smart_refctd_ptr writeFile; + writeFileFuture.acquire().move_into(writeFile); + const auto openMs = toMs(clock_t::now() - openStart); + if (!writeFile) + { + m_logger->log("Failed to open output file %s", ILogger::ELL_ERROR, savePath.c_str()); + return false; + } + const auto start = clock_t::now(); + if (!m_assetMgr->writeAsset(writeFile.get(), params)) + { + const auto ms = toMs(clock_t::now() - start); + m_logger->log("Failed to save %s after %.3f ms", ILogger::ELL_ERROR, savePath.c_str(), ms); + return false; + } + const auto writeMs = toMs(clock_t::now() - start); + const auto statStart = clock_t::now(); + uintmax_t size = 0u; + if (std::filesystem::exists(savePath)) + size = std::filesystem::file_size(savePath); + const auto statMs = toMs(clock_t::now() - statStart); + const auto outerMs = toMs(clock_t::now() - writeOuterStart); + const auto nonWriterMs = std::max(0.0, outerMs - writeMs); + m_logger->log("Asset write call perf: path=%s ext=%s time=%.3f ms size=%llu", ILogger::ELL_INFO, savePath.c_str(), ext.c_str(), writeMs, static_cast(size)); + m_logger->log( + "Asset write outer perf: path=%s ext=%s open=%.3f ms writeAsset=%.3f ms stat=%.3f ms total=%.3f ms non_writer=%.3f ms size=%llu", + ILogger::ELL_INFO, + savePath.c_str(), + ext.c_str(), + openMs, + writeMs, + statMs, + outerMs, + nonWriterMs, + static_cast(size)); + m_logger->log("Writer perf: path=%s ext=%s time=%.3f ms size=%llu", ILogger::ELL_INFO, savePath.c_str(), ext.c_str(), writeMs, static_cast(size)); + m_logger->log("Mesh successfully saved!", ILogger::ELL_INFO); + return true; +} + +void MeshLoadersApp::setupCameraFromAABB(const hlsl::shapes::AABB<3, double>& bound) +{ + auto validBound = bound; + if (!isValidAABB(validBound)) + { + m_logger->log("Total AABB invalid; using fallback unit AABB for camera setup.", ILogger::ELL_WARNING); + validBound = meshloadersFallbackUnitAABB(); + } + const auto extent = validBound.getExtent(); + const auto aspectRatio = double(m_window->getWidth()) / double(m_window->getHeight()); + const double fovY = 1.2; + const double fovX = 2.0 * std::atan(std::tan(fovY * 0.5) * aspectRatio); + const auto center = (validBound.minVx + validBound.maxVx) * 0.5; + const auto halfExtent = extent * 0.5; + const double halfX = std::max(halfExtent.x, 0.001); + const double halfY = std::max(halfExtent.y, 0.001); + const double halfZ = std::max(halfExtent.z, 0.001); + const double safeRadius = std::max({ halfX, halfY, halfZ }); + + // Keep startup camera horizontal and in front of the scene. + const hlsl::float64_t3 dir(0.0, 0.0, 1.0); + const double planeHalfX = halfX; + const double planeHalfY = halfY; + const double depthHalf = halfZ; + const double distY = planeHalfY / std::tan(fovY * 0.5); + const double distX = planeHalfX / std::tan(fovX * 0.5); + const double framingMargin = std::max(0.1, safeRadius * 0.35); + const double dist = std::max(distX, distY) + depthHalf + framingMargin; + const double eyeHeightOffset = std::max(halfY * 0.2, 0.05); + const auto eyeCenter = center + hlsl::float64_t3(0.0, eyeHeightOffset, 0.0); + const auto pos = eyeCenter + dir * dist; + + const double tightNear = std::max(0.0, dist - depthHalf - framingMargin); + const double tightFar = dist + depthHalf + framingMargin; + const double nearByTight = tightNear * 0.01; + const double nearByRadius = safeRadius * 0.002; + const double nearPlane = std::max(0.001, std::min({ nearByTight, nearByRadius, 1.0 })); + const double farPlane = std::max({ tightFar * 16.0, nearPlane + safeRadius * 24.0 + 10.0, dist + safeRadius * 24.0 }); + + const auto projection = nbl::hlsl::buildProjectionMatrixPerspectiveFovRH( + static_cast(fovY), + static_cast(aspectRatio), + static_cast(nearPlane), + static_cast(farPlane)); + camera.setProjectionMatrix(projection); + const double moveSpeed = std::clamp(safeRadius * 0.015, 0.2, 40.0); + camera.setMoveSpeed(static_cast(moveSpeed)); + camera.setPosition(vectorSIMDf(pos.x, pos.y, pos.z)); + camera.setTarget(vectorSIMDf(eyeCenter.x, eyeCenter.y, eyeCenter.z)); +} + +hlsl::shapes::AABB<3, double> MeshLoadersApp::translateAABB(const hlsl::shapes::AABB<3, double>& aabb, const hlsl::float64_t3& translation) +{ + auto out = aabb; + out.minVx += translation; + out.maxVx += translation; + return out; +} + +hlsl::shapes::AABB<3, double> MeshLoadersApp::scaleAABB(const hlsl::shapes::AABB<3, double>& aabb, const double scale) +{ + auto out = aabb; + out.minVx *= scale; + out.maxVx *= scale; + return out; +} + +void MeshLoadersApp::storeCameraState() +{ + m_referenceCamera = CameraState{ + camera.getPosition(), + camera.getTarget(), + camera.getProjectionMatrix(), + camera.getMoveSpeed() + }; +} + +void MeshLoadersApp::applyCameraState(const CameraState& state) +{ + camera.setProjectionMatrix(state.projection); + camera.setPosition(state.position); + camera.setTarget(state.target); + camera.setMoveSpeed(state.moveSpeed); +} + +bool MeshLoadersApp::isValidAABB(const hlsl::shapes::AABB<3, double>& aabb) +{ + return + meshloadersIsFinite(aabb.minVx) && + meshloadersIsFinite(aabb.maxVx) && + (aabb.minVx.x <= aabb.maxVx.x) && + (aabb.minVx.y <= aabb.maxVx.y) && + (aabb.minVx.z <= aabb.maxVx.z); +} + +hlsl::shapes::AABB<3, double> MeshLoadersApp::getGeometryAABB(const ICPUPolygonGeometry* geometry) const +{ + if (!geometry) + return hlsl::shapes::AABB<3, double>::create(); + auto aabb = geometry->getAABB>(); + if (!isValidAABB(aabb)) + { + CPolygonGeometryManipulator::recomputeAABB(geometry); + aabb = geometry->getAABB>(); + if (!isValidAABB(aabb)) + aabb = meshloadersComputeFinitePositionAABB(geometry); + } + return aabb; +} + +system::ILogger* MeshLoadersApp::getAssetLoadLogger() const +{ + if (m_assetLoadLogger) + return m_assetLoadLogger.get(); + return m_logger.get(); +} + +IAssetLoader::SAssetLoadParams MeshLoadersApp::makeLoadParams() const +{ + IAssetLoader::SAssetLoadParams params = {}; + params.logger = getAssetLoadLogger(); + if ((m_runMode == RunMode::CI || isRowViewActive()) && !m_loaderPerfLogger) + params.logger = nullptr; + params.cacheFlags = IAssetLoader::ECF_DUPLICATE_TOP_LEVEL; + params.ioPolicy.runtimeTuning.mode = m_runtimeTuningMode; + if (!m_forceLoaderContentHashes) + params.loaderFlags = static_cast(params.loaderFlags | IAssetLoader::ELPF_DONT_COMPUTE_CONTENT_HASHES); + return params; +} + +bool MeshLoadersApp::loadAssetCallFromPath(const system::path& modelPath, const IAssetLoader::SAssetLoadParams& params, AssetLoadCallResult& out) +{ + using clock_t = std::chrono::high_resolution_clock; + if (std::filesystem::exists(modelPath)) + out.inputSize = std::filesystem::file_size(modelPath); + else + out.inputSize = 0u; + + const auto loadStart = clock_t::now(); + out.bundle = m_assetMgr->getAsset(modelPath.string(), params); + out.getAssetMs = toMs(clock_t::now() - loadStart); + return true; +} + +bool MeshLoadersApp::initLoaderPerfLogger(const system::path& logPath) +{ + if (!m_system) + return logFail("Could not initialize loader perf logger because system is unavailable."); + if (logPath.empty()) + return false; + const auto parent = logPath.parent_path(); + if (!parent.empty()) + { + std::error_code ec; + std::filesystem::create_directories(parent, ec); + if (ec) + return logFail("Could not create loader perf log directory %s", parent.string().c_str()); + } + system::ISystem::future_t> future; + m_system->createFile(future, logPath, system::IFile::ECF_READ_WRITE); + if (!future.wait() || !future.get()) + return logFail("Could not create loader perf log file %s", logPath.string().c_str()); + const auto logMask = core::bitflag(system::ILogger::ELL_ALL); + m_loaderPerfLogger = core::make_smart_refctd_ptr(future.copy(), false, logMask); + m_assetLoadLogger = m_loaderPerfLogger; + return true; +} + + diff --git a/12_MeshLoaders/MeshLoadersAppRuntime.cpp b/12_MeshLoaders/MeshLoadersAppRuntime.cpp new file mode 100644 index 000000000..ac2f1f5b7 --- /dev/null +++ b/12_MeshLoaders/MeshLoadersAppRuntime.cpp @@ -0,0 +1,473 @@ +// Copyright (C) 2018-2025 - DevSH Graphics Programming Sp. z O.O. +// This file is part of the "Nabla Engine". +// For conditions of distribution and use, see copyright notice in nabla.h + +#include "MeshLoadersApp.hpp" + +#include "nbl/ext/ScreenShot/ScreenShot.h" +#include "nbl/asset/interchange/SGeometryContentHashCommon.h" +#include "nbl/core/hash/blake.h" + +namespace +{ + +core::blake3_hash_t meshloadersHashBufferLegacySequential(const asset::ICPUBuffer* const buffer) +{ + if (!buffer) + return static_cast(core::blake3_hasher{}); + const auto* const ptr = buffer->getPointer(); + const size_t size = buffer->getSize(); + if (!ptr || size == 0ull) + return static_cast(core::blake3_hasher{}); + core::blake3_hasher hasher; + hasher.update(ptr, size); + return static_cast(hasher); +} + +} + +std::string MeshLoadersApp::makeUniqueCaseName(const system::path& path) +{ + auto base = path.stem().string(); + if (base.empty()) + base = "case"; + auto& counter = m_caseNameCounts[base]; + std::string name = (counter == 0u) ? base : (base + "_" + std::to_string(counter)); + ++counter; + return name; +} + +double MeshLoadersApp::toMs(const std::chrono::high_resolution_clock::duration& d) +{ + return std::chrono::duration(d).count(); +} + +std::string MeshLoadersApp::makeCacheKey(const system::path& path) const +{ + return path.lexically_normal().generic_string(); +} + +void MeshLoadersApp::logRowViewPerf(const RowViewPerfStats& stats) const +{ + if (!m_logger) + return; + m_logger->log( + "RowView perf: mode=%s cases=%llu cpuHit=%llu cpuMiss=%llu gpuHit=%llu gpuMiss=%llu convert=%llu add=%llu total=%.3f ms", + ILogger::ELL_INFO, + stats.incremental ? "inc" : "full", + static_cast(stats.cases), + static_cast(stats.cpuHits), + static_cast(stats.cpuMisses), + static_cast(stats.gpuHits), + static_cast(stats.gpuMisses), + static_cast(stats.convertCount), + static_cast(stats.addCount), + stats.totalMs); + m_logger->log( + "RowView perf: clear=%.3f load=%.3f extract=%.3f aabb=%.3f convert=%.3f add=%.3f layout=%.3f inst=%.3f cam=%.3f", + ILogger::ELL_INFO, + stats.clearMs, + stats.loadMs, + stats.extractMs, + stats.aabbMs, + stats.convertMs, + stats.addGeoMs, + stats.layoutMs, + stats.instanceMs, + stats.cameraMs); +} + +void MeshLoadersApp::logRowViewAssetLoad(const system::path& path, const double ms, const bool cached) const +{ + if (!m_logger) + return; + m_logger->log( + "RowView perf: asset %s load=%.3f ms%s", + ILogger::ELL_INFO, + path.string().c_str(), + ms, + cached ? " (cached)" : ""); +} + +void MeshLoadersApp::logRowViewLoadTotal(const double ms, const size_t hits, const size_t misses) const +{ + if (!m_logger) + return; + m_logger->log( + "RowView perf: asset load total=%.3f ms hits=%llu misses=%llu", + ILogger::ELL_INFO, + ms, + static_cast(hits), + static_cast(misses)); +} + +core::blake3_hash_t MeshLoadersApp::hashGeometry(const ICPUPolygonGeometry* geo) +{ + return CPolygonGeometryManipulator::computeDeterministicContentHash(geo); +} + +bool MeshLoadersApp::runHashConsistencyChecks() +{ + using clock_t = std::chrono::high_resolution_clock; + + if (m_cases.empty()) + return logFail("Hash test requires at least one test case."); + + IAssetLoader::SAssetLoadParams params = makeLoadParams(); + params.logger = nullptr; + params.loaderFlags = static_cast(params.loaderFlags | IAssetLoader::ELPF_DONT_COMPUTE_CONTENT_HASHES); + + double totalLoadMs = 0.0; + double totalLegacySequentialMs = 0.0; + double totalNewSequentialMs = 0.0; + double totalNewParallelMs = 0.0; + uint64_t totalGeometryCount = 0ull; + uint64_t totalBufferCount = 0ull; + + for (const auto& testCase : m_cases) + { + m_assetMgr->clearAllAssetCache(); + + AssetLoadCallResult loadResult = {}; + if (!loadAssetCallFromPath(testCase.path, params, loadResult)) + failExit("Hash test failed to load input %s.", testCase.path.string().c_str()); + totalLoadMs += loadResult.getAssetMs; + + if (loadResult.bundle.getContents().empty()) + failExit("Hash test loaded empty asset for %s.", testCase.path.string().c_str()); + + core::vector> geometries; + if (!appendGeometriesFromBundle(loadResult.bundle, geometries)) + failExit("Hash test found no polygon geometry in %s.", testCase.path.string().c_str()); + + double caseLegacySequentialMs = 0.0; + double caseNewSequentialMs = 0.0; + double caseNewParallelMs = 0.0; + uint64_t caseBufferCount = 0ull; + + for (size_t geoIx = 0u; geoIx < geometries.size(); ++geoIx) + { + auto* geometry = const_cast(geometries[geoIx].get()); + if (!geometry) + failExit("Hash test failed to access geometry %llu in %s.", static_cast(geoIx), testCase.path.string().c_str()); + + core::vector> buffers; + asset::collectGeometryBuffers(geometry, buffers); + if (buffers.empty()) + continue; + + core::vector legacySequentialHashes; + core::vector newSequentialHashes; + core::vector newParallelHashes; + legacySequentialHashes.reserve(buffers.size()); + newSequentialHashes.reserve(buffers.size()); + newParallelHashes.reserve(buffers.size()); + + for (const auto& buffer : buffers) + { + if (!buffer) + continue; + + const auto* const ptr = buffer->getPointer(); + const size_t size = buffer->getSize(); + + const auto legacyStart = clock_t::now(); + const auto legacyHash = meshloadersHashBufferLegacySequential(buffer.get()); + caseLegacySequentialMs += toMs(clock_t::now() - legacyStart); + legacySequentialHashes.push_back(legacyHash); + + const auto newSeqStart = clock_t::now(); + const auto newSeqHash = core::blake3_hash_buffer_sequential(ptr, size); + caseNewSequentialMs += toMs(clock_t::now() - newSeqStart); + newSequentialHashes.push_back(newSeqHash); + + const auto newParStart = clock_t::now(); + const auto newParHash = core::blake3_hash_buffer(ptr, size); + caseNewParallelMs += toMs(clock_t::now() - newParStart); + newParallelHashes.push_back(newParHash); + } + + if (legacySequentialHashes.size() != newSequentialHashes.size() || legacySequentialHashes.size() != newParallelHashes.size()) + failExit("Hash test buffer count mismatch for %s geo=%llu.", testCase.path.string().c_str(), static_cast(geoIx)); + + for (size_t hashIx = 0u; hashIx < legacySequentialHashes.size(); ++hashIx) + { + if (legacySequentialHashes[hashIx] == newSequentialHashes[hashIx] && legacySequentialHashes[hashIx] == newParallelHashes[hashIx]) + continue; + failExit( + "Hash mismatch for %s geo=%llu buffer=%llu legacy_seq=%s new_seq=%s new_parallel=%s", + testCase.path.string().c_str(), + static_cast(geoIx), + static_cast(hashIx), + geometryHashToHex(legacySequentialHashes[hashIx]).c_str(), + geometryHashToHex(newSequentialHashes[hashIx]).c_str(), + geometryHashToHex(newParallelHashes[hashIx]).c_str()); + } + + caseBufferCount += legacySequentialHashes.size(); + ++totalGeometryCount; + } + + totalLegacySequentialMs += caseLegacySequentialMs; + totalNewSequentialMs += caseNewSequentialMs; + totalNewParallelMs += caseNewParallelMs; + totalBufferCount += caseBufferCount; + + if (m_logger) + { + m_logger->log( + "Hash test case: %s load=%.3f ms geos=%llu buffers=%llu legacy_seq=%.3f ms new_seq=%.3f ms new_parallel=%.3f ms", + ILogger::ELL_INFO, + testCase.path.string().c_str(), + loadResult.getAssetMs, + static_cast(geometries.size()), + static_cast(caseBufferCount), + caseLegacySequentialMs, + caseNewSequentialMs, + caseNewParallelMs); + } + } + + if (m_logger) + { + m_logger->log( + "Hash test summary: cases=%llu geos=%llu buffers=%llu load=%.3f ms legacy_seq=%.3f ms new_seq=%.3f ms new_parallel=%.3f ms", + ILogger::ELL_INFO, + static_cast(m_cases.size()), + static_cast(totalGeometryCount), + static_cast(totalBufferCount), + totalLoadMs, + totalLegacySequentialMs, + totalNewSequentialMs, + totalNewParallelMs); + } + + return true; +} + +bool MeshLoadersApp::validateWrittenAsset(const system::path& path) +{ + if (!std::filesystem::exists(path)) + return false; + + m_assetMgr->clearAllAssetCache(); + + IAssetLoader::SAssetLoadParams params = makeLoadParams(); + auto asset = m_assetMgr->getAsset(path.string(), params); + if (asset.getContents().empty()) + return false; + + core::vector> geometries; + switch (asset.getAssetType()) + { + case IAsset::E_TYPE::ET_GEOMETRY: + for (const auto& item : asset.getContents()) + if (auto polyGeo = IAsset::castDown(item); polyGeo) + geometries.push_back(polyGeo); + break; + default: + return false; + } + return !geometries.empty(); +} + +bool MeshLoadersApp::captureScreenshot(const system::path& path, core::smart_refctd_ptr& outImage) +{ + if (!m_device || !m_surface || !m_assetMgr) + return false; + + m_device->waitIdle(); + + auto* scRes = static_cast(m_surface->getSwapchainResources()); + auto* fb = scRes ? scRes->getFramebuffer(device_base_t::getCurrentAcquire().imageIndex) : nullptr; + if (!fb) + return false; + + auto colorView = fb->getCreationParameters().colorAttachments[0u]; + if (!colorView) + return false; + + auto cpuView = ext::ScreenShot::createScreenShot( + m_device.get(), + getGraphicsQueue(), + nullptr, + colorView.get(), + asset::ACCESS_FLAGS::COLOR_ATTACHMENT_WRITE_BIT, + asset::IImage::LAYOUT::PRESENT_SRC); + if (!cpuView) + return false; + + if (!path.empty()) + std::filesystem::create_directories(path.parent_path()); + + IAssetWriter::SAssetWriteParams params(cpuView.get()); + if (!m_assetMgr->writeAsset(path.string(), params)) + return false; + + outImage = cpuView; + return true; +} + +bool MeshLoadersApp::appendGeometriesFromBundle(const asset::SAssetBundle& bundle, core::vector>& out) const +{ + if (bundle.getContents().empty()) + return false; + + switch (bundle.getAssetType()) + { + case IAsset::E_TYPE::ET_GEOMETRY: + for (const auto& item : bundle.getContents()) + { + if (auto polyGeo = IAsset::castDown(item); polyGeo) + out.push_back(polyGeo); + } + break; + case IAsset::E_TYPE::ET_GEOMETRY_COLLECTION: + for (const auto& item : bundle.getContents()) + { + auto collection = IAsset::castDown(item); + if (!collection) + continue; + auto* refs = collection->getGeometries(); + if (!refs) + continue; + for (const auto& ref : *refs) + { + if (!ref.geometry) + continue; + if (ref.geometry->getPrimitiveType() != IGeometryBase::EPrimitiveType::Polygon) + continue; + auto poly = core::smart_refctd_ptr_static_cast(ref.geometry); + if (poly) + out.push_back(poly); + } + } + break; + default: + return false; + } + + return !out.empty(); +} + +bool MeshLoadersApp::compareImages(const asset::ICPUImageView* a, const asset::ICPUImageView* b, uint64_t& diffCount, uint8_t& maxDiff) +{ + diffCount = 0u; + maxDiff = 0u; + if (!a || !b) + return false; + + const auto* imgA = a->getCreationParameters().image.get(); + const auto* imgB = b->getCreationParameters().image.get(); + if (!imgA || !imgB) + return false; + + const auto paramsA = imgA->getCreationParameters(); + const auto paramsB = imgB->getCreationParameters(); + if (paramsA.format != paramsB.format) + return false; + if (paramsA.extent != paramsB.extent) + return false; + + const auto* bufA = imgA->getBuffer(); + const auto* bufB = imgB->getBuffer(); + if (!bufA || !bufB) + return false; + + const size_t sizeA = bufA->getSize(); + if (sizeA != bufB->getSize()) + return false; + + const auto* dataA = static_cast(bufA->getPointer()); + const auto* dataB = static_cast(bufB->getPointer()); + if (!dataA || !dataB) + return false; + + for (size_t i = 0; i < sizeA; ++i) + { + const uint8_t va = dataA[i]; + const uint8_t vb = dataB[i]; + const uint8_t diff = va > vb ? static_cast(va - vb) : static_cast(vb - va); + if (diff) + { + ++diffCount; + if (diff > maxDiff) + maxDiff = diff; + } + } + + return true; +} + +void MeshLoadersApp::advanceCase() +{ + if (m_runMode == RunMode::Interactive || m_cases.empty()) + return; + if (isRowViewActive()) + return; + + const uint32_t frameLimit = m_runMode == RunMode::CI ? CiFramesBeforeCapture : NonCiFramesPerCase; + ++m_phaseFrameCounter; + if (m_phaseFrameCounter < frameLimit) + return; + + if (m_phase == Phase::RenderOriginal) + { + if (!captureScreenshot(m_loadedScreenshotPath, m_loadedScreenshot)) + failExit("Failed to capture loaded screenshot."); + + if (m_saveGeom) + { + if (!m_currentCpuGeom) + failExit("No geometry to write."); + if (!writeGeometry(m_currentCpuGeom, m_writtenPath.string())) + failExit("Geometry write failed."); + } + + if (m_runMode == RunMode::CI) + { + if (!loadModel(m_writtenPath, false, false)) + failExit("Failed to load written asset %s.", m_writtenPath.string().c_str()); + if (!m_currentCpuGeom) + failExit("Written geometry missing."); + m_phase = Phase::RenderWritten; + m_phaseFrameCounter = 0u; + return; + } + + if (m_saveGeom) + { + if (!validateWrittenAsset(m_writtenPath)) + failExit("Failed to load written asset %s.", m_writtenPath.string().c_str()); + } + + advanceToNextCase(); + return; + } + + if (m_phase == Phase::RenderWritten) + { + if (!captureScreenshot(m_writtenScreenshotPath, m_writtenScreenshot)) + failExit("Failed to capture written screenshot."); + + if (m_hasReferenceGeometryHash) + { + const auto writtenHash = hashGeometry(m_currentCpuGeom.get()); + if (writtenHash != m_referenceGeometryHash) + failExit("Geometry hash mismatch for %s. Current=%s Reference=%s ReferenceFile=%s", m_caseName.c_str(), geometryHashToHex(writtenHash).c_str(), geometryHashToHex(m_referenceGeometryHash).c_str(), m_caseGeometryHashReferencePath.empty() ? "" : m_caseGeometryHashReferencePath.string().c_str()); + } + + uint64_t diffCount = 0u; + uint8_t maxDiff = 0u; + if (!compareImages(m_loadedScreenshot.get(), m_writtenScreenshot.get(), diffCount, maxDiff)) + failExit("Image compare failed for %s.", m_caseName.c_str()); + if (diffCount > MaxImageDiffBytes || maxDiff > MaxImageDiffValue) + failExit("Image diff detected for %s. Bytes: %llu MaxDiff: %u", m_caseName.c_str(), static_cast(diffCount), maxDiff); + if (diffCount != 0u) + m_logger->log("Image diff within tolerance for %s. Bytes: %llu MaxDiff: %u", ILogger::ELL_WARNING, m_caseName.c_str(), static_cast(diffCount), maxDiff); + + advanceToNextCase(); + } +} + + diff --git a/12_MeshLoaders/README.md b/12_MeshLoaders/README.md index 6330f4673..eb6bfeecb 100644 --- a/12_MeshLoaders/README.md +++ b/12_MeshLoaders/README.md @@ -1,2 +1,133 @@ -https://github.com/user-attachments/assets/6f779700-e6d4-4e11-95fb-7a7fddc47255 +# 12_MeshLoaders +Example for loading and writing `OBJ`, `PLY` and `STL` meshes. + +## At a glance +- Default input list: `inputs.json` +- Default mode: `batch` +- Default tuning: `heuristic` +- Loader content hashes: enabled by default +- Output meshes: `saved/` +- Output screenshots: `screenshots/` + +## Mode cheat sheet +- `batch` + - Uses test list and runs normal workflow. + - If test list has `row_view: true`, renders all cases in one scene. +- `interactive` + - Opens file dialog and loads one model. +- `ci` + - Runs strict pass/fail validation per case. +- `hash-test` + - Headless hash parity check per geometry (`parallel` vs `sequential` recompute). + +## Common workflows +- Quick visual check: + - run default `batch` +- Inspect one local model: + - run with `--interactive` +- Validate load/write correctness: + - run with `--ci` +- Refresh geometry references: + - run with `--update-references` (usually with `--ci`) +- Validate content hash parity only (no write/screenshot roundtrip): + - run with `--hash-test` + +## Optional benchmark datasets via CMake +- Use this when you want larger/public inputs downloaded automatically. +- Public dataset repository: + - `https://github.com/Devsh-Graphics-Programming/Nabla-Benchmark-Datasets` +- Configure options: + - `NBL_MESHLOADERS_ENABLE_BENCHMARK_DATASETS=ON` + - `NBL_MESHLOADERS_DEFAULT_START_WITH_BENCHMARK_TESTLIST=ON|OFF` (default: `OFF`) + - `NBL_MESHLOADERS_ENABLE_HASH_CTESTS=ON|OFF` (default: `OFF`) + - `NBL_MESHLOADERS_BENCHMARK_DATASET_DIR=` (optional, default: build dir) + - `NBL_MESHLOADERS_BENCHMARK_DATASET_REPO=` (optional, default: public repo above) + - `NBL_MESHLOADERS_BENCHMARK_PAYLOAD_RELATIVE_PATH=` (optional, default: `inputs_benchmark.json`) +- What CMake does: + - fetches/clones dataset repo during configure via CMake `FetchContent` (if payload file is missing) + - resolves committed payload JSON from repo: + - `/` + - verifies payload is a regular Git file (not an LFS pointer) +- Run benchmark list with: + - `--testlist /` +- Default startup behavior when benchmark datasets are enabled: + - `NBL_MESHLOADERS_DEFAULT_START_WITH_BENCHMARK_TESTLIST=OFF`: still starts from local `inputs.json` (3 models) + - `NBL_MESHLOADERS_DEFAULT_START_WITH_BENCHMARK_TESTLIST=ON`: starts from benchmark payload test list +- Run benchmark CI directly via `ctest`: + - `ctest --output-on-failure -C Debug -R NBL_MESHLOADERS_CI_BENCHMARK` + - runs both benchmark CI modes: `heuristic` and `hybrid` + - benchmark CTest uses `--update-references` for payload-driven case names +- Run default CI directly via `ctest` (no benchmark datasets enabled): + - `ctest --output-on-failure -C Debug -R ^NBL_MESHLOADERS_CI$` + - uses default `inputs.json` (3 inputs) +- Run hash parity tests directly via `ctest`: + - `ctest --output-on-failure -C Debug -R NBL_MESHLOADERS_HASH_TEST` + - requires `NBL_MESHLOADERS_ENABLE_HASH_CTESTS=ON` at configure time + - runs both tuning modes: `heuristic` and `hybrid` + - if benchmark datasets are enabled, hash tests use benchmark payload test list + +## CLI +- `--ci` + - strict validation run +- `--interactive` + - file-dialog run +- `--hash-test` + - headless load + hash parity check (`parallel` vs `sequential`), then exit +- `--testlist ` + - custom JSON list +- `--savegeometry` + - keep writing output meshes +- `--savepath ` + - force output path +- `--row-add ` + - add model to row view at startup +- `--row-duplicate ` + - duplicate last row-view case +- `--loader-perf-log ` + - redirect loader diagnostics +- `--runtime-tuning ` + - IO runtime tuning mode +- `--loader-content-hashes` + - compatibility switch; already enabled by default +- `--update-references` + - regenerate `references/*.geomhash` + +## Controls (non-CI) +- Arrow keys: move camera +- Left mouse drag: rotate camera +- `Home`: reset view +- `A`: add model to row view +- `X`: clear row view (empty scene) +- `R`: reload row view from test list + +## Input list format (`inputs.json`) +```json +{ + "row_view": true, + "cases": [ + "../media/yellowflower.obj", + { "name": "spanner", "path": "../media/ply/Spanner-ply.ply" }, + { "path": "../media/Stanford_Bunny.stl" } + ] +} +``` + +Rules: +- `cases` is required and must be an array +- case item can be string path or object with `path` and optional `name` +- relative paths resolve against JSON file directory + +## What CI validates +- Per-case geometry hash: + - deterministic `BLAKE3` hash compared with `references/*.geomhash` +- Per-case image consistency: + - `*_loaded.png` vs `*_written.png` byte diff + - thresholds come from `MaxImageDiffBytes` and `MaxImageDiffValue` in `MeshLoadersApp.hpp` +- Any mismatch ends with non-zero exit code + +## Performance logs to trust +- `Asset load call perf` for `getAsset` +- `Asset write call perf` for `writeAsset` + +Internal loader stage logs are diagnostics only. diff --git a/12_MeshLoaders/bin/references/Spanner-ply.geomhash b/12_MeshLoaders/bin/references/Spanner-ply.geomhash new file mode 100644 index 000000000..1fc5eb419 --- /dev/null +++ b/12_MeshLoaders/bin/references/Spanner-ply.geomhash @@ -0,0 +1 @@ +264ac1d6b5de49770560164959e9f2dcc25ceb9c6474b9510c3c543ea68f8878 diff --git a/12_MeshLoaders/bin/references/Stanford_Bunny.geomhash b/12_MeshLoaders/bin/references/Stanford_Bunny.geomhash new file mode 100644 index 000000000..0cb59bc94 --- /dev/null +++ b/12_MeshLoaders/bin/references/Stanford_Bunny.geomhash @@ -0,0 +1 @@ +77f984c2ffe3211196f4d0f87259607c7fca371ff3c22c6fd6f4d2f0800367b1 diff --git a/12_MeshLoaders/bin/references/yellowflower.geomhash b/12_MeshLoaders/bin/references/yellowflower.geomhash new file mode 100644 index 000000000..90368fb9f --- /dev/null +++ b/12_MeshLoaders/bin/references/yellowflower.geomhash @@ -0,0 +1 @@ +d30d7e4a7f79156fcb18682d871256ac52bc12497bc7f959a150a6191f1074fb diff --git a/12_MeshLoaders/inputs.json b/12_MeshLoaders/inputs.json new file mode 100644 index 000000000..aa1c653f2 --- /dev/null +++ b/12_MeshLoaders/inputs.json @@ -0,0 +1,8 @@ +{ + "row_view": true, + "cases": [ + "../media/ply/Spanner-ply.ply", + "../media/yellowflower.obj", + "../media/Stanford_Bunny.stl" + ] +} diff --git a/12_MeshLoaders/main.cpp b/12_MeshLoaders/main.cpp index e27ed4be0..ee9ab7ef4 100644 --- a/12_MeshLoaders/main.cpp +++ b/12_MeshLoaders/main.cpp @@ -1,581 +1,7 @@ -// Copyright (C) 2018-2020 - DevSH Graphics Programming Sp. z O.O. +// Copyright (C) 2018-2025 - DevSH Graphics Programming Sp. z O.O. // This file is part of the "Nabla Engine". // For conditions of distribution and use, see copyright notice in nabla.h -#include "argparse/argparse.hpp" -#include "common.hpp" -#include "../3rdparty/portable-file-dialogs/portable-file-dialogs.h" -#include +#include "MeshLoadersApp.hpp" -#ifdef NBL_BUILD_MITSUBA_LOADER -#include "nbl/ext/MitsubaLoader/CSerializedLoader.h" -#endif - -#ifdef NBL_BUILD_DEBUG_DRAW -#include "nbl/ext/DebugDraw/CDrawAABB.h" -#endif - -class MeshLoadersApp final : public MonoWindowApplication, public BuiltinResourcesApplication -{ - using device_base_t = MonoWindowApplication; - using asset_base_t = BuiltinResourcesApplication; - - enum DrawBoundingBoxMode - { - DBBM_NONE, - DBBM_AABB, - DBBM_OBB, - DBBM_COUNT - }; - - public: - inline MeshLoadersApp(const path& _localInputCWD, const path& _localOutputCWD, const path& _sharedInputCWD, const path& _sharedOutputCWD) - : IApplicationFramework(_localInputCWD, _localOutputCWD, _sharedInputCWD, _sharedOutputCWD), - device_base_t({1280,720}, EF_D32_SFLOAT, _localInputCWD, _localOutputCWD, _sharedInputCWD, _sharedOutputCWD) {} - - inline bool onAppInitialized(smart_refctd_ptr&& system) override - { - if (!asset_base_t::onAppInitialized(smart_refctd_ptr(system))) - return false; -#ifdef NBL_BUILD_MITSUBA_LOADER - m_assetMgr->addAssetLoader(make_smart_refctd_ptr()); -#endif - if (!device_base_t::onAppInitialized(smart_refctd_ptr(system))) - return false; - - m_saveGeomPrefixPath = localOutputCWD / "saved"; - - // parse args - argparse::ArgumentParser parser("12_meshloaders"); - parser.add_argument("--savegeometry") - .help("Save the mesh on exit or reload") - .flag(); - - parser.add_argument("--savepath") - .nargs(1) - .help("Specify the file to which the mesh will be saved"); - - try - { - parser.parse_args({ argv.data(), argv.data() + argv.size() }); - } - catch (const std::exception& e) - { - return logFail(e.what()); - } - - if (parser["--savegeometry"] == true) - m_saveGeom = true; - - if (parser.present("--savepath")) - { - auto tmp = path(parser.get("--savepath")); - - if (tmp.empty() || !tmp.has_filename()) - return logFail("Invalid path has been specified in --savepath argument"); - - if (!std::filesystem::exists(tmp.parent_path())) - return logFail("Path specified in --savepath argument doesn't exist"); - - m_specifiedGeomSavePath.emplace(std::move(tmp.generic_string())); - } - - m_semaphore = m_device->createSemaphore(m_realFrameIx); - if (!m_semaphore) - return logFail("Failed to Create a Semaphore!"); - - auto pool = m_device->createCommandPool(getGraphicsQueue()->getFamilyIndex(), IGPUCommandPool::CREATE_FLAGS::RESET_COMMAND_BUFFER_BIT); - for (auto i = 0u; i < MaxFramesInFlight; i++) - { - if (!pool) - return logFail("Couldn't create Command Pool!"); - if (!pool->createCommandBuffers(IGPUCommandPool::BUFFER_LEVEL::PRIMARY, { m_cmdBufs.data() + i,1 })) - return logFail("Couldn't create Command Buffer!"); - } - - - auto scRes = static_cast(m_surface->getSwapchainResources()); - m_renderer = CSimpleDebugRenderer::create(m_assetMgr.get(), scRes->getRenderpass(), 0, {}); - if (!m_renderer) - return logFail("Failed to create renderer!"); - -#ifdef NBL_BUILD_DEBUG_DRAW - { - auto* renderpass = scRes->getRenderpass(); - ext::debug_draw::DrawAABB::SCreationParameters params = {}; - params.assetManager = m_assetMgr; - params.transfer = getTransferUpQueue(); - params.drawMode = ext::debug_draw::DrawAABB::ADM_DRAW_BATCH; - params.batchPipelineLayout = ext::debug_draw::DrawAABB::createDefaultPipelineLayout(m_device.get()); - params.renderpass = smart_refctd_ptr(renderpass); - params.utilities = m_utils; - m_drawAABB = ext::debug_draw::DrawAABB::create(std::move(params)); - } -#endif - - // - if (!reloadModel()) - return false; - - camera.mapKeysToArrows(); - - onAppInitializedFinish(); - return true; - } - - inline IQueue::SSubmitInfo::SSemaphoreInfo renderFrame(const std::chrono::microseconds nextPresentationTimestamp) override - { - m_inputSystem->getDefaultMouse(&mouse); - m_inputSystem->getDefaultKeyboard(&keyboard); - - // - const auto resourceIx = m_realFrameIx % MaxFramesInFlight; - - auto* const cb = m_cmdBufs.data()[resourceIx].get(); - cb->reset(IGPUCommandBuffer::RESET_FLAGS::RELEASE_RESOURCES_BIT); - cb->begin(IGPUCommandBuffer::USAGE::ONE_TIME_SUBMIT_BIT); - // clear to black for both things - { - // begin renderpass - { - auto scRes = static_cast(m_surface->getSwapchainResources()); - auto* framebuffer = scRes->getFramebuffer(device_base_t::getCurrentAcquire().imageIndex); - const IGPUCommandBuffer::SClearColorValue clearValue = { .float32 = {1.f,0.f,1.f,1.f} }; - const IGPUCommandBuffer::SClearDepthStencilValue depthValue = { .depth = 0.f }; - const VkRect2D currentRenderArea = - { - .offset = {0,0}, - .extent = {framebuffer->getCreationParameters().width,framebuffer->getCreationParameters().height} - }; - const IGPUCommandBuffer::SRenderpassBeginInfo info = - { - .framebuffer = framebuffer, - .colorClearValues = &clearValue, - .depthStencilClearValues = &depthValue, - .renderArea = currentRenderArea - }; - cb->beginRenderPass(info, IGPUCommandBuffer::SUBPASS_CONTENTS::INLINE); - - const SViewport viewport = { - .x = static_cast(currentRenderArea.offset.x), - .y = static_cast(currentRenderArea.offset.y), - .width = static_cast(currentRenderArea.extent.width), - .height = static_cast(currentRenderArea.extent.height) - }; - cb->setViewport(0u,1u,&viewport); - - cb->setScissor(0u,1u,¤tRenderArea); - } - // late latch input - { - bool reload = false; - camera.beginInputProcessing(nextPresentationTimestamp); - mouse.consumeEvents([&](const IMouseEventChannel::range_t& events) -> void { camera.mouseProcess(events); }, m_logger.get()); - keyboard.consumeEvents([&](const IKeyboardEventChannel::range_t& events) -> void - { - for (const auto& event : events) - { - if (event.keyCode == E_KEY_CODE::EKC_R && event.action == SKeyboardEvent::ECA_RELEASED) - reload = true; - if (event.keyCode == E_KEY_CODE::EKC_B && event.action == SKeyboardEvent::ECA_RELEASED) - { - m_drawBBMode = DrawBoundingBoxMode((m_drawBBMode + 1) % DBBM_COUNT); - } - } - camera.keyboardProcess(events); - }, - m_logger.get() - ); - camera.endInputProcessing(nextPresentationTimestamp); - if (reload) - reloadModel(); - } - // draw scene - float32_t3x4 viewMatrix = camera.getViewMatrix(); - float32_t4x4 viewProjMatrix = camera.getConcatenatedMatrix(); - m_renderer->render(cb,CSimpleDebugRenderer::SViewParams(viewMatrix,viewProjMatrix)); -#ifdef NBL_BUILD_DEBUG_DRAW - if (m_drawBBMode != DBBM_NONE) - { - const ISemaphore::SWaitInfo drawFinished = { .semaphore = m_semaphore.get(),.value = m_realFrameIx + 1u }; - ext::debug_draw::DrawAABB::DrawParameters drawParams; - drawParams.commandBuffer = cb; - drawParams.cameraMat = viewProjMatrix; - m_drawAABB->render(drawParams, drawFinished, m_drawBBMode == DBBM_OBB ? m_obbInstances : m_aabbInstances); - } -#endif - cb->endRenderPass(); - } - cb->end(); - - IQueue::SSubmitInfo::SSemaphoreInfo retval = - { - .semaphore = m_semaphore.get(), - .value = ++m_realFrameIx, - .stageMask = PIPELINE_STAGE_FLAGS::ALL_GRAPHICS_BITS - }; - const IQueue::SSubmitInfo::SCommandBufferInfo commandBuffers[] = - { - {.cmdbuf = cb } - }; - const IQueue::SSubmitInfo::SSemaphoreInfo acquired[] = { - { - .semaphore = device_base_t::getCurrentAcquire().semaphore, - .value = device_base_t::getCurrentAcquire().acquireCount, - .stageMask = PIPELINE_STAGE_FLAGS::NONE - } - }; - const IQueue::SSubmitInfo infos[] = - { - { - .waitSemaphores = acquired, - .commandBuffers = commandBuffers, - .signalSemaphores = {&retval,1} - } - }; - - if (getGraphicsQueue()->submit(infos) != IQueue::RESULT::SUCCESS) - { - retval.semaphore = nullptr; // so that we don't wait on semaphore that will never signal - m_realFrameIx--; - } - - std::string caption = "[Nabla Engine] Mesh Loaders"; - { - caption += ", displaying ["; - caption += m_modelPath; - caption += "]"; - m_window->setCaption(caption); - } - return retval; - } - - inline bool onAppTerminated() override - { - if (m_saveGeomTaskFuture.valid()) - { - m_logger->log("Waiting for geometry writer to finish writing...", ILogger::ELL_INFO); - m_saveGeomTaskFuture.wait(); - } - - return device_base_t::onAppTerminated(); - } - -protected: - const video::IGPURenderpass::SCreationParams::SSubpassDependency* getDefaultSubpassDependencies() const override - { - // Subsequent submits don't wait for each other, hence its important to have External Dependencies which prevent users of the depth attachment overlapping. - const static IGPURenderpass::SCreationParams::SSubpassDependency dependencies[] = { - // wipe-transition of Color to ATTACHMENT_OPTIMAL and depth - { - .srcSubpass = IGPURenderpass::SCreationParams::SSubpassDependency::External, - .dstSubpass = 0, - .memoryBarrier = { - // last place where the depth can get modified in previous frame, `COLOR_ATTACHMENT_OUTPUT_BIT` is implicitly later - .srcStageMask = PIPELINE_STAGE_FLAGS::LATE_FRAGMENT_TESTS_BIT, - // don't want any writes to be available, we'll clear - .srcAccessMask = ACCESS_FLAGS::NONE, - // destination needs to wait as early as possible - // TODO: `COLOR_ATTACHMENT_OUTPUT_BIT` shouldn't be needed, because its a logically later stage, see TODO in `ECommonEnums.h` - .dstStageMask = PIPELINE_STAGE_FLAGS::EARLY_FRAGMENT_TESTS_BIT | PIPELINE_STAGE_FLAGS::COLOR_ATTACHMENT_OUTPUT_BIT, - // because depth and color get cleared first no read mask - .dstAccessMask = ACCESS_FLAGS::DEPTH_STENCIL_ATTACHMENT_WRITE_BIT | ACCESS_FLAGS::COLOR_ATTACHMENT_WRITE_BIT - } - // leave view offsets and flags default - }, - // color from ATTACHMENT_OPTIMAL to PRESENT_SRC - { - .srcSubpass = 0, - .dstSubpass = IGPURenderpass::SCreationParams::SSubpassDependency::External, - .memoryBarrier = { - // last place where the color can get modified, depth is implicitly earlier - .srcStageMask = PIPELINE_STAGE_FLAGS::COLOR_ATTACHMENT_OUTPUT_BIT, - // only write ops, reads can't be made available - .srcAccessMask = ACCESS_FLAGS::COLOR_ATTACHMENT_WRITE_BIT - // spec says nothing is needed when presentation is the destination - } - // leave view offsets and flags default - }, - IGPURenderpass::SCreationParams::DependenciesEnd - }; - return dependencies; - } - -private: - // TODO: standardise this across examples, and take from `argv` - bool m_nonInteractiveTest = false; - - bool reloadModel() - { - if (m_nonInteractiveTest) // TODO: maybe also take from argv and argc - m_modelPath = (sharedInputCWD / "ply/Spanner-ply.ply").string(); - else - { - pfd::open_file file("Choose a supported Model File", sharedInputCWD.string(), - { - "All Supported Formats", "*.ply *.stl *.serialized *.obj", - "TODO (.ply)", "*.ply", - "TODO (.stl)", "*.stl", - "Mitsuba 0.6 Serialized (.serialized)", "*.serialized", - "Wavefront Object (.obj)", "*.obj" - }, - false - ); - if (file.result().empty()) - return false; - m_modelPath = file.result()[0]; - } - - // free up - m_renderer->m_instances.clear(); - m_renderer->clearGeometries({ .semaphore = m_semaphore.get(),.value = m_realFrameIx }); - m_assetMgr->clearAllAssetCache(); - - //! load the geometry - IAssetLoader::SAssetLoadParams params = {}; - params.logger = m_logger.get(); - auto asset = m_assetMgr->getAsset(m_modelPath, params); - if (asset.getContents().empty()) - return false; - - // - core::vector> geometries; - switch (asset.getAssetType()) - { - case IAsset::E_TYPE::ET_GEOMETRY: - for (const auto& item : asset.getContents()) - if (auto polyGeo = IAsset::castDown(item); polyGeo) - geometries.push_back(polyGeo); - break; - default: - m_logger->log("Asset loaded but not a supported type (ET_GEOMETRY,ET_GEOMETRY_COLLECTION)", ILogger::ELL_ERROR); - break; - } - if (geometries.empty()) - return false; - - if (m_saveGeom) - { - if (m_saveGeomTaskFuture.valid()) - { - m_logger->log("Waiting for previous geometry saving task to complete...", ILogger::ELL_INFO); - m_saveGeomTaskFuture.wait(); - } - - std::string currentGeomSavePath = m_specifiedGeomSavePath.value_or((m_saveGeomPrefixPath / path(m_modelPath).filename()).generic_string()); - m_saveGeomTaskFuture = std::async( - std::launch::async, - [this, geometries, currentGeomSavePath] { writeGeometry( - geometries[0], - currentGeomSavePath - ); } - ); - } - - using aabb_t = hlsl::shapes::AABB<3, double>; - auto printAABB = [&](const aabb_t& aabb, const char* extraMsg = "")->void - { - m_logger->log("%s AABB is (%f,%f,%f) -> (%f,%f,%f)", ILogger::ELL_INFO, extraMsg, aabb.minVx.x, aabb.minVx.y, aabb.minVx.z, aabb.maxVx.x, aabb.maxVx.y, aabb.maxVx.z); - }; - auto bound = aabb_t::create(); - // convert the geometries - { - smart_refctd_ptr converter = CAssetConverter::create({ .device = m_device.get() }); - - const auto transferFamily = getTransferUpQueue()->getFamilyIndex(); - - struct SInputs : CAssetConverter::SInputs - { - virtual inline std::span getSharedOwnershipQueueFamilies(const size_t groupCopyID, const asset::ICPUBuffer* buffer, const CAssetConverter::patch_t& patch) const - { - return sharedBufferOwnership; - } - - core::vector sharedBufferOwnership; - } inputs = {}; - core::vector> patches(geometries.size(), CSimpleDebugRenderer::DefaultPolygonGeometryPatch); - { - inputs.logger = m_logger.get(); - std::get>(inputs.assets) = { &geometries.front().get(),geometries.size() }; - std::get>(inputs.patches) = patches; - // set up shared ownership so we don't have to - core::unordered_set families; - families.insert(transferFamily); - families.insert(getGraphicsQueue()->getFamilyIndex()); - if (families.size() > 1) - for (const auto fam : families) - inputs.sharedBufferOwnership.push_back(fam); - } - - // reserve - auto reservation = converter->reserve(inputs); - if (!reservation) - { - m_logger->log("Failed to reserve GPU objects for CPU->GPU conversion!", ILogger::ELL_ERROR); - return false; - } - - // convert - { - auto semaphore = m_device->createSemaphore(0u); - - constexpr auto MultiBuffering = 2; - std::array, MultiBuffering> commandBuffers = {}; - { - auto pool = m_device->createCommandPool(transferFamily, IGPUCommandPool::CREATE_FLAGS::RESET_COMMAND_BUFFER_BIT | IGPUCommandPool::CREATE_FLAGS::TRANSIENT_BIT); - pool->createCommandBuffers(IGPUCommandPool::BUFFER_LEVEL::PRIMARY, commandBuffers, smart_refctd_ptr(m_logger)); - } - commandBuffers.front()->begin(IGPUCommandBuffer::USAGE::ONE_TIME_SUBMIT_BIT); - - std::array commandBufferSubmits; - for (auto i = 0; i < MultiBuffering; i++) - commandBufferSubmits[i].cmdbuf = commandBuffers[i].get(); - - SIntendedSubmitInfo transfer = {}; - transfer.queue = getTransferUpQueue(); - transfer.scratchCommandBuffers = commandBufferSubmits; - transfer.scratchSemaphore = { - .semaphore = semaphore.get(), - .value = 0u, - .stageMask = PIPELINE_STAGE_FLAGS::ALL_TRANSFER_BITS - }; - - CAssetConverter::SConvertParams cpar = {}; - cpar.utilities = m_utils.get(); - cpar.transfer = &transfer; - - // basically it records all data uploads and submits them right away - auto future = reservation.convert(cpar); - if (future.copy()!=IQueue::RESULT::SUCCESS) - { - m_logger->log("Failed to await submission feature!", ILogger::ELL_ERROR); - return false; - } - } - - auto tmp = hlsl::float32_t4x3( - hlsl::float32_t3(1,0,0), - hlsl::float32_t3(0,1,0), - hlsl::float32_t3(0,0,1), - hlsl::float32_t3(0,0,0) - ); - core::vector worldTforms; - const auto& converted = reservation.getGPUObjects(); - m_aabbInstances.resize(converted.size()); - m_obbInstances.resize(converted.size()); - for (uint32_t i = 0; i < converted.size(); i++) - { - const auto& geom = converted[i]; - const auto promoted = geom.value->getAABB(); - printAABB(promoted,"Geometry"); - tmp[3].x += promoted.getExtent().x; - const auto promotedWorld = hlsl::float64_t3x4(worldTforms.emplace_back(hlsl::transpose(tmp))); - const auto transformed = hlsl::shapes::util::transform(promotedWorld,promoted); - printAABB(transformed,"Transformed"); - bound = hlsl::shapes::util::union_(transformed,bound); - -#ifdef NBL_BUILD_DEBUG_DRAW - - auto& aabbInst = m_aabbInstances[i]; - const auto tmpAabb = shapes::AABB<3,float>(promoted.minVx, promoted.maxVx); - - hlsl::float32_t3x4 aabbTransform = ext::debug_draw::DrawAABB::getTransformFromAABB(tmpAabb); - const auto tmpWorld = hlsl::float32_t3x4(promotedWorld); - const auto world4x4 = float32_t4x4{ - tmpWorld[0], - tmpWorld[1], - tmpWorld[2], - float32_t4(0, 0, 0, 1) - }; - - aabbInst.color = { 1,1,1,1 }; - aabbInst.transform = math::linalg::promoted_mul(world4x4, aabbTransform); - - auto& obbInst = m_obbInstances[i]; - const auto& cpuGeom = geometries[i].get(); - const auto obb = CPolygonGeometryManipulator::calculateOBB( - cpuGeom->getPositionView().getElementCount(), - [geo = cpuGeom, &world4x4](size_t vertex_i) { - hlsl::float32_t3 pt; - geo->getPositionView().decodeElement(vertex_i, pt); - return pt; - }); - obbInst.color = { 0, 0, 1, 1 }; - obbInst.transform = math::linalg::promoted_mul(world4x4, obb.transform); -#endif - } - - printAABB(bound,"Total"); - if (!m_renderer->addGeometries({ &converted.front().get(),converted.size() })) - return false; - - auto worlTformsIt = worldTforms.begin(); - for (const auto& geo : m_renderer->getGeometries()) - m_renderer->m_instances.push_back({ - .world = *(worlTformsIt++), - .packedGeo = &geo - }); - } - - // get scene bounds and reset camera - { - const double distance = 0.05; - const auto diagonal = bound.getExtent(); - { - const auto measure = hlsl::length(diagonal); - const auto aspectRatio = float(m_window->getWidth()) / float(m_window->getHeight()); - camera.setProjectionMatrix(hlsl::math::thin_lens::rhPerspectiveFovMatrix(1.2f, aspectRatio, distance * measure * 0.1, measure * 4.0)); - camera.setMoveSpeed(measure * 0.04); - } - const auto pos = bound.maxVx + diagonal * distance; - camera.setPosition(vectorSIMDf(pos.x, pos.y, pos.z)); - const auto center = (bound.minVx + bound.maxVx) * 0.5; - camera.setTarget(vectorSIMDf(center.x, center.y, center.z)); - } - - // TODO: write out the geometry - - return true; - } - - void writeGeometry(smart_refctd_ptr geometry, const std::string& savePath) - { - IAsset* assetPtr = const_cast(static_cast(geometry.get())); - IAssetWriter::SAssetWriteParams params{ assetPtr }; - m_logger->log("Saving mesh to %s", ILogger::ELL_INFO, savePath.c_str()); - if (!m_assetMgr->writeAsset(savePath, params)) - m_logger->log("Failed to save %s", ILogger::ELL_ERROR, savePath.c_str()); - m_logger->log("Mesh successfully saved!", ILogger::ELL_INFO); - } - - // Maximum frames which can be simultaneously submitted, used to cycle through our per-frame resources like command buffers - constexpr static inline uint32_t MaxFramesInFlight = 3u; - // - smart_refctd_ptr m_renderer; - // - smart_refctd_ptr m_semaphore; - uint64_t m_realFrameIx = 0; - std::array, MaxFramesInFlight> m_cmdBufs; - // - InputSystem::ChannelReader mouse; - InputSystem::ChannelReader keyboard; - // - Camera camera = Camera(core::vectorSIMDf(0, 0, 0), core::vectorSIMDf(0, 0, 0), hlsl::float32_t4x4()); - // mutables - std::string m_modelPath; - - DrawBoundingBoxMode m_drawBBMode; -#ifdef NBL_BUILD_DEBUG_DRAW - smart_refctd_ptr m_drawAABB; - std::vector m_aabbInstances; - std::vector m_obbInstances; - -#endif - - bool m_saveGeom = false; - std::future m_saveGeomTaskFuture; - std::optional m_specifiedGeomSavePath; - nbl::system::path m_saveGeomPrefixPath; -}; - -NBL_MAIN_FUNC(MeshLoadersApp) \ No newline at end of file +NBL_MAIN_FUNC(MeshLoadersApp) diff --git a/CMakeLists.txt b/CMakeLists.txt index d945c547a..dffe9b829 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -4,6 +4,7 @@ if(NBL_BUILD_EXAMPLES) project(NablaExamples) + enable_testing() if(NBL_BUILD_ANDROID) nbl_android_create_media_storage_apk() @@ -136,4 +137,4 @@ if(NBL_BUILD_EXAMPLES) endforeach() NBL_ADJUST_FOLDERS(examples) -endif() \ No newline at end of file +endif() diff --git a/common/include/nbl/examples/common/MonoWindowApplication.hpp b/common/include/nbl/examples/common/MonoWindowApplication.hpp index a2048b7b0..7fd322e34 100644 --- a/common/include/nbl/examples/common/MonoWindowApplication.hpp +++ b/common/include/nbl/examples/common/MonoWindowApplication.hpp @@ -1,4 +1,4 @@ -// Copyright (C) 2023-2023 - DevSH Graphics Programming Sp. z O.O. +// Copyright (C) 2018-2025 - DevSH Graphics Programming Sp. z O.O. // This file is part of the "Nabla Engine". // For conditions of distribution and use, see copyright notice in nabla.h #ifndef _NBL_EXAMPLES_COMMON_MONO_WINDOW_APPLICATION_HPP_INCLUDED_ diff --git a/media b/media index 0f7ad42b3..293f204fd 160000 --- a/media +++ b/media @@ -1 +1 @@ -Subproject commit 0f7ad42b33abe3143a5d69c4d14b26cf3e538c88 +Subproject commit 293f204fd0cc0c443d2c732c6adaf4b7e3f9b0d7