diff --git a/12_MeshLoaders/App.hpp b/12_MeshLoaders/App.hpp new file mode 100644 index 000000000..c3a0acc8b --- /dev/null +++ b/12_MeshLoaders/App.hpp @@ -0,0 +1,464 @@ +#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/MonoWindowApplication.hpp" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#ifdef NBL_BUILD_DEBUG_DRAW +#include "nbl/ext/DebugDraw/CDrawAABB.h" +#endif + +class MeshLoadersWindowedApplication : public virtual nbl::examples::MonoWindowApplication +{ + using base_t = nbl::examples::MonoWindowApplication; + +public: + template + MeshLoadersWindowedApplication(const hlsl::uint16_t2 initialResolution, const asset::E_FORMAT depthFormat, Args&&... args) + : base_t(initialResolution, depthFormat, std::forward(args)...) {} + +protected: + inline const char* getWindowCaption() const override + { + return "MeshLoaders"; + } + inline void amendSwapchainCreateParams(video::ISwapchain::SCreationParams& swapchainParams) const override + { + swapchainParams.sharedParams.imageUsage |= IGPUImage::E_USAGE_FLAGS::EUF_TRANSFER_SRC_BIT; + } +}; + +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, + CaptureOriginalPending, + WrittenAssetPending, + RenderWritten, + CaptureWrittenPending + }; + + enum class RowViewReloadMode + { + Full, + Incremental + }; + + struct TestCase + { + std::string name; + nbl::system::path path; + }; + + struct CachedGeometryEntry + { + core::vector> cpu; + core::vector> gpu; + core::vector> aabbs; + core::vector world; + hlsl::shapes::AABB<3, double> tileAABB = hlsl::shapes::AABB<3, double>::create(); + bool layoutAsSingleTile = 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 + { + hlsl::float32_t3 position = hlsl::float32_t3(0.0f, 0.0f, 0.0f); + hlsl::float32_t3 target = hlsl::float32_t3(0.0f, 0.0f, -1.0f); + nbl::hlsl::float32_t4x4 projection; + float moveSpeed = 1.0f; + }; + + struct AssetLoadCallResult + { + asset::SAssetBundle bundle = {}; + double getAssetMs = 0.0; + uintmax_t inputSize = 0u; + }; + + struct PendingScreenshotCapture + { + nbl::system::path path; + core::smart_refctd_ptr sourceView; + core::smart_refctd_ptr commandBuffer; + core::smart_refctd_ptr texelBuffer; + core::smart_refctd_ptr completionSemaphore; + asset::IImage::SCreationParams imageParams = {}; + asset::IImage::SSubresourceRange subresourceRange = {}; + asset::E_FORMAT viewFormat = asset::EF_UNKNOWN; + uint64_t completionValue = 0u; + + inline bool active() const + { + return static_cast(completionSemaphore); + } + }; + +public: + struct LoadStageMetrics + { + double getAssetMs = 0.0; + double extractMs = 0.0; + double totalMs = 0.0; + double nonLoaderMs = 0.0; + uintmax_t inputSize = 0u; + bool valid = false; + }; + + struct WriteStageMetrics + { + double openMs = 0.0; + double writeMs = 0.0; + double statMs = 0.0; + double totalMs = 0.0; + double nonWriterMs = 0.0; + uintmax_t outputSize = 0u; + bool usedMemoryTransport = false; + bool usedDiskFallback = false; + bool persistedDiskArtifact = false; + bool valid = false; + }; + + struct CasePerformanceMetrics + { + std::string caseName; + nbl::system::path inputPath; + LoadStageMetrics originalLoad = {}; + WriteStageMetrics write = {}; + LoadStageMetrics writtenLoad = {}; + }; + +private: + struct PerformanceOptions + { + std::optional dumpDir; + std::optional referenceDir; + std::optional profileOverride; + bool strict = false; + bool updateReference = false; + }; + + struct WrittenAssetRequest + { + core::smart_refctd_ptr asset; + nbl::system::path path; + IAssetLoader::SAssetLoadParams loadParams = {}; + bool useMemoryTransport = false; + bool allowDiskFallback = false; + bool persistDiskArtifact = false; + }; + + struct WrittenAssetResult + { + bool success = false; + std::string error; + nbl::system::path path; + std::string extension; + double openMs = 0.0; + double writeMs = 0.0; + double statMs = 0.0; + double totalWriteMs = 0.0; + double nonWriterMs = 0.0; + uintmax_t outputSize = 0u; + bool usedMemoryTransport = false; + bool usedDiskFallback = false; + bool persistedDiskArtifact = false; + AssetLoadCallResult loadResult = {}; + }; + + struct BackgroundAssetWorker + { + std::mutex mutex; + std::condition_variable cv; + std::thread thread; + std::optional request; + std::optional result; + bool busy = false; + bool stop = false; + }; + + struct PreparedAssetLoad + { + size_t caseIndex = ~size_t(0u); + bool success = false; + std::string error; + nbl::system::path path; + AssetLoadCallResult loadResult = {}; + }; + + struct BackgroundLoadWorker + { + std::mutex mutex; + std::condition_variable cv; + std::thread thread; + std::optional requestCaseIndex; + nbl::system::path requestPath; + IAssetLoader::SAssetLoadParams requestParams = {}; + std::optional result; + bool busy = false; + bool stop = false; + }; + + struct PerformanceState + { + PerformanceOptions options = {}; + bool enabled = false; + bool finalized = false; + std::chrono::steady_clock::time_point runStart = {}; + size_t currentCaseIndex = ~size_t(0u); + std::string profileId; + std::string workloadId; + nbl::system::path dumpPath = {}; + nbl::system::path referencePath = {}; + bool referenceMatched = false; + core::vector comparisonFailures = {}; + core::vector completedCases = {}; + }; + + struct RuntimeState + { + bool nonInteractiveTest = false; + bool rowViewEnabled = true; + bool forceRowViewForCurrentTestList = false; + bool rowViewScreenshotCaptured = false; + bool fileDialogOpen = false; + + RunMode mode = RunMode::Batch; + Phase phase = Phase::RenderOriginal; + uint32_t phaseFrameCounter = 0u; + size_t caseIndex = 0u; + core::vector cases; + std::unordered_map caseNameCounts; + bool shouldQuit = false; + }; + + struct OutputState + { + bool saveGeom = true; + std::optional specifiedGeomSavePath; + nbl::system::path saveGeomPrefixPath; + nbl::system::path screenshotPrefixPath; + nbl::system::path rowViewScreenshotPath; + nbl::system::path testListPath; + std::optional loaderPerfLogPath; + std::optional rowAddPath; + uint32_t rowDuplicateCount = 0u; + + nbl::system::path writtenPath; + nbl::system::path loadedScreenshotPath; + nbl::system::path writtenScreenshotPath; + }; + + struct RenderState + { + smart_refctd_ptr renderer; + smart_refctd_ptr semaphore; + uint64_t realFrameIx = 0u; + std::array, 3u> cmdBufs; + + core::smart_refctd_ptr currentCpuAsset; + core::smart_refctd_ptr currentCpuGeom; + core::smart_refctd_ptr loadedScreenshot; + core::smart_refctd_ptr writtenScreenshot; + PendingScreenshotCapture pendingScreenshot; + }; + + struct RowViewState + { + std::unordered_map cache; + }; + +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; + +protected: + core::bitflag getLogLevelMask() override + { + return core::bitflag(system::ILogger::ELL_WARNING) | system::ILogger::ELL_ERROR; + } + + 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); + asset::writer_flags_t getWriterFlagsForPath(const IAsset* asset, const system::path& path) const; + bool isWriteExtensionSupported(const std::string& ext) const; + system::path resolveSavePath(const system::path& modelPath) 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 loadModel(const system::path& modelPath, bool updateCamera, bool storeCamera, LoadStageMetrics* perfMetrics); + bool loadPreparedModel(const system::path& modelPath, AssetLoadCallResult&& loadResult, bool updateCamera, bool storeCamera); + bool loadPreparedModel(const system::path& modelPath, AssetLoadCallResult&& loadResult, bool updateCamera, bool storeCamera, LoadStageMetrics* perfMetrics); + bool loadRowView(RowViewReloadMode mode); + bool writeAssetRoot(smart_refctd_ptr asset, const std::string& savePath); + bool writeAssetRoot(smart_refctd_ptr asset, const std::string& savePath, WriteStageMetrics* perfMetrics); + + void setupCameraFromAABB(const hlsl::shapes::AABB<3, double>& bound); + + 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; + + bool validateWrittenAsset(const system::path& path); + static bool validateWrittenBundle(const asset::SAssetBundle& bundle); + bool requestScreenshotCapture(const system::path& path); + bool finalizeScreenshotCapture(core::smart_refctd_ptr& outImage, bool& ready, bool waitForCompletion=false); + bool startWrittenAssetWork(smart_refctd_ptr asset, const system::path& path); + bool finalizeWrittenAssetWork(WrittenAssetResult& result, bool& ready, bool waitForCompletion=false); + void logWrittenAssetWork(const WrittenAssetResult& result) const; + bool startBackgroundAssetWorker(); + void stopBackgroundAssetWorker(); + void backgroundAssetWorkerMain(); + bool startBackgroundLoadWorker(); + void stopBackgroundLoadWorker(); + void backgroundLoadWorkerMain(); + bool startPreparedAssetLoad(size_t caseIndex, const system::path& path); + bool finalizePreparedAssetLoad(PreparedAssetLoad& result, bool& ready, bool waitForCompletion=false); + bool performanceEnabled() const; + void beginPerformanceRun(); + void beginPerformanceCase(const TestCase& testCase); + void recordOriginalLoadMetrics(const LoadStageMetrics& metrics); + void recordWrittenLoadMetrics(const LoadStageMetrics& metrics); + void recordWriteMetrics(const WriteStageMetrics& metrics); + void recordWriteMetrics(const WrittenAssetResult& result); + void endPerformanceCase(); + void finalizePerformanceRun(); + bool compareImages( + const asset::ICPUImageView* a, + const asset::ICPUImageView* b, + uint64_t& diffCodeUnitCount, + uint32_t& maxDiffCodeUnitValue); + + void advanceCase(); + bool shouldKeepRunning() const override; + + 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 MaxImageDiffCodeUnits = 16u; + constexpr static inline uint32_t MaxImageDiffCodeUnitValue = 1u; + + RenderState m_render; + RuntimeState m_runtime; + OutputState m_output; + RowViewState m_rowView; + BackgroundAssetWorker m_backgroundAssetWorker; + BackgroundLoadWorker m_backgroundLoadWorker; + PerformanceState m_perf; + + 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 + + smart_refctd_ptr m_assetLoadLogger; + smart_refctd_ptr m_loaderPerfLogger; + asset::SFileIOPolicy::SRuntimeTuning::Mode m_runtimeTuningMode = asset::SFileIOPolicy::SRuntimeTuning::Mode::Heuristic; + bool m_forceLoaderContentHashes = true; + + std::optional m_referenceCamera; +}; + +#endif diff --git a/12_MeshLoaders/AppLifecycle.cpp b/12_MeshLoaders/AppLifecycle.cpp new file mode 100644 index 000000000..b88450605 --- /dev/null +++ b/12_MeshLoaders/AppLifecycle.cpp @@ -0,0 +1,1198 @@ +// 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 "App.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 +{ + +void setupMeshLoadersArgumentParser(argparse::ArgumentParser& parser) +{ + 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("--interactive") + .help("Use file dialog to select a single model.") + .flag(); + parser.add_argument("--testlist") + .nargs(1) + .help("JSON file with test cases. Relative JSON path resolves against local input CWD. Relative case paths inside the JSON resolve against the JSON file directory."); + 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("--perf-dump-dir") + .nargs(1) + .help("Write structured performance run artifacts to this directory."); + parser.add_argument("--perf-ref-dir") + .nargs(1) + .help("Lookup directory for structured performance reference artifacts."); + parser.add_argument("--perf-strict") + .help("Fail the run if a matching structured performance reference exists and comparison exceeds thresholds.") + .flag(); + parser.add_argument("--perf-profile-override") + .nargs(1) + .help("Override the automatically generated performance profile id."); + parser.add_argument("--perf-update-reference") + .help("Write the current structured performance run to the matching reference path.") + .flag(); + parser.add_argument("--loader-content-hashes") + .help("Keep loader content hashes enabled. This is already the default for this example.") + .flag(); + parser.add_argument("--runtime-tuning") + .nargs(1) + .help("Runtime tuning mode for loaders: sequential|heuristic|hybrid. Default: heuristic."); +} + +std::optional parseUInt32Argument(const std::string_view value) +{ + uint32_t parsed = 0u; + const auto parseResult = std::from_chars(value.data(), value.data() + value.size(), parsed, 10); + if (parseResult.ec != std::errc() || parseResult.ptr != value.data() + value.size()) + return std::nullopt; + return parsed; +} + +bool parseRuntimeTuningMode(const std::string_view modeRaw, asset::SFileIOPolicy::SRuntimeTuning::Mode& outMode) +{ + std::string mode(modeRaw); + std::transform(mode.begin(), mode.end(), mode.begin(), [](unsigned char c) { return static_cast(std::tolower(c)); }); + + if (mode == "sequential" || mode == "none") + { + outMode = asset::SFileIOPolicy::SRuntimeTuning::Mode::Sequential; + return true; + } + if (mode == "heuristic") + { + outMode = asset::SFileIOPolicy::SRuntimeTuning::Mode::Heuristic; + return true; + } + if (mode == "hybrid") + { + outMode = asset::SFileIOPolicy::SRuntimeTuning::Mode::Hybrid; + return true; + } + return false; +} + +struct ParsedCommandLineOptions +{ + bool saveGeom = true; + bool interactive = false; + bool ci = false; + bool forceRowViewForCurrentTestList = false; + bool forceLoaderContentHashes = true; + system::path saveGeomPrefixPath; + system::path screenshotPrefixPath; + system::path testListPath; + std::optional specifiedGeomSavePath; + std::optional loaderPerfLogPath; + std::optional perfDumpDir; + std::optional perfReferenceDir; + std::optional perfProfileOverride; + bool perfStrict = false; + bool perfUpdateReference = false; + std::optional rowAddPath; + uint32_t rowDuplicateCount = 0u; + asset::SFileIOPolicy::SRuntimeTuning::Mode runtimeTuningMode = asset::SFileIOPolicy::SRuntimeTuning::Mode::Heuristic; +}; + +struct CaseArtifacts +{ + std::string caseName; + system::path writtenPath; + system::path loadedScreenshotPath; + system::path writtenScreenshotPath; +}; + +system::path resolveRuntimeCWD(const system::path& preferred) +{ + if (preferred.empty() || preferred == path("/") || preferred == path("\\")) + return path(std::filesystem::current_path()); + return preferred; +} + +system::path makeShortRuntimePath(const system::path& inputPath) +{ + if (inputPath.empty()) + return inputPath; + + std::filesystem::path pathValue(inputPath.string()); + pathValue = pathValue.lexically_normal(); + if (!pathValue.is_absolute()) + return system::path(pathValue.generic_string()); + + std::error_code ec; + const auto cwd = std::filesystem::current_path(ec); + if (!ec) + { + const auto relativePath = std::filesystem::relative(pathValue, cwd, ec); + if (!ec && !relativePath.empty() && !relativePath.is_absolute() && relativePath.generic_string().size() < pathValue.generic_string().size()) + return system::path(relativePath.lexically_normal().generic_string()); + } + return system::path(pathValue.generic_string()); +} + +system::path resolveDefaultTestListPath(const system::path& effectiveInputCWD, const core::vector& argv) +{ + const auto tryExisting = [](std::filesystem::path candidate) -> std::optional + { + std::error_code ec; + candidate = candidate.lexically_normal(); + if (!candidate.empty() && std::filesystem::exists(candidate, ec) && !ec) + return system::path(candidate.generic_string()); + return std::nullopt; + }; + + if (auto resolved = tryExisting(effectiveInputCWD / "inputs.json"); resolved.has_value()) + return *resolved; + + if (!argv.empty() && !argv[0].empty()) + { + std::error_code ec; + auto exePath = std::filesystem::absolute(std::filesystem::path(argv[0]), ec); + if (!ec) + { + if (auto resolved = tryExisting(exePath.parent_path() / ".." / "inputs.json"); resolved.has_value()) + return *resolved; + if (auto resolved = tryExisting(exePath.parent_path() / "inputs.json"); resolved.has_value()) + return *resolved; + } + } + + return (effectiveInputCWD / "inputs.json").lexically_normal(); +} + +std::string makeCaptionModelPath(const std::string& modelPath, const core::vector& 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(); +} + +template +CaseArtifacts makeCaseArtifacts( + const std::string& preferredName, + const system::path& casePath, + const system::path& screenshotPrefixPath, + ResolveSavePathFn&& resolveSavePath) +{ + const auto caseName = preferredName.empty() ? casePath.stem().string() : preferredName; + return { + .caseName = caseName, + .writtenPath = resolveSavePath(casePath), + .loadedScreenshotPath = screenshotPrefixPath / ("meshloaders_" + caseName + "_loaded.png"), + .writtenScreenshotPath = screenshotPrefixPath / ("meshloaders_" + caseName + "_written.png") + }; +} + +template +bool appendRowViewDuplicates(const uint32_t duplicateCount, const system::path& lastPath, AddCaseFn&& addCase) +{ + for (uint32_t i = 0u; i < duplicateCount; ++i) + if (!addCase(lastPath)) + return false; + return true; +} + +bool parseMeshLoadersCommandLine( + const core::vector& argv, + const system::path& effectiveInputCWD, + const system::path& effectiveOutputCWD, + const system::path& defaultBenchmarkTestListPath, + ParsedCommandLineOptions& out, + std::string& error) +{ + out.saveGeomPrefixPath = effectiveOutputCWD / "saved"; + out.screenshotPrefixPath = effectiveOutputCWD / "screenshots"; + out.testListPath = resolveDefaultTestListPath(effectiveInputCWD, argv); + + argparse::ArgumentParser parser("12_meshloaders"); + setupMeshLoadersArgumentParser(parser); + + try + { + parser.parse_args({ argv.data(), argv.data() + argv.size() }); + } + catch (const std::exception& e) + { + error = e.what(); + return false; + } + + if (parser["--savegeometry"] == true) + out.saveGeom = true; + if (parser["--interactive"] == true) + out.interactive = true; + if (parser["--ci"] == true) + out.ci = true; + const bool hasExplicitTestListArg = parser.present("--testlist").has_value(); + + if (parser.present("--savepath")) + { + auto tmp = path(parser.get("--savepath")); + if (tmp.empty() || !tmp.has_filename()) + { + error = "Invalid path has been specified in --savepath argument"; + return false; + } + if (!std::filesystem::exists(tmp.parent_path())) + { + error = "Path specified in --savepath argument doesn't exist"; + return false; + } + out.specifiedGeomSavePath.emplace(std::move(tmp.generic_string())); + } + + if (hasExplicitTestListArg) + { + auto tmp = path(parser.get("--testlist")); + if (tmp.empty()) + { + error = "Invalid path has been specified in --testlist argument"; + return false; + } + if (tmp.is_relative()) + tmp = effectiveInputCWD / tmp; + out.testListPath = tmp; + } + else if (!out.interactive && !out.ci && !defaultBenchmarkTestListPath.empty()) + { + std::error_code benchmarkPathEc; + if (std::filesystem::exists(defaultBenchmarkTestListPath, benchmarkPathEc) && !benchmarkPathEc) + { + out.testListPath = defaultBenchmarkTestListPath; + out.forceRowViewForCurrentTestList = true; + } + } + + if (parser.present("--row-add")) + { + auto tmp = path(parser.get("--row-add")); + if (tmp.is_relative()) + tmp = effectiveInputCWD / tmp; + out.rowAddPath = tmp; + } + if (parser.present("--row-duplicate")) + { + const auto parsedCount = parseUInt32Argument(parser.get("--row-duplicate")); + if (!parsedCount.has_value()) + { + error = "Invalid --row-duplicate value."; + return false; + } + out.rowDuplicateCount = *parsedCount; + } + if (parser.present("--loader-perf-log")) + { + auto tmp = path(parser.get("--loader-perf-log")); + if (tmp.empty()) + { + error = "Invalid --loader-perf-log value."; + return false; + } + if (tmp.is_relative()) + tmp = effectiveOutputCWD / tmp; + out.loaderPerfLogPath = tmp; + } + if (parser.present("--perf-dump-dir")) + { + auto tmp = path(parser.get("--perf-dump-dir")); + if (tmp.empty()) + { + error = "Invalid --perf-dump-dir value."; + return false; + } + if (tmp.is_relative()) + tmp = effectiveOutputCWD / tmp; + out.perfDumpDir = makeShortRuntimePath(tmp); + } + if (parser.present("--perf-ref-dir")) + { + auto tmp = path(parser.get("--perf-ref-dir")); + if (tmp.empty()) + { + error = "Invalid --perf-ref-dir value."; + return false; + } + if (tmp.is_relative()) + tmp = effectiveOutputCWD / tmp; + out.perfReferenceDir = makeShortRuntimePath(tmp); + } + if (parser["--perf-strict"] == true) + out.perfStrict = true; + if (parser.present("--perf-profile-override")) + { + const auto value = parser.get("--perf-profile-override"); + if (value.empty()) + { + error = "Invalid --perf-profile-override value."; + return false; + } + out.perfProfileOverride = value; + } + if (parser["--perf-update-reference"] == true) + out.perfUpdateReference = true; + if (parser["--loader-content-hashes"] == true) + out.forceLoaderContentHashes = true; + if (parser.present("--runtime-tuning")) + { + if (!parseRuntimeTuningMode(parser.get("--runtime-tuning"), out.runtimeTuningMode)) + { + error = "Invalid --runtime-tuning value. Expected: sequential|heuristic|hybrid."; + return false; + } + } + if (out.perfStrict && out.perfUpdateReference) + { + error = "Use either --perf-strict or --perf-update-reference, not both."; + return false; + } + if (out.perfUpdateReference && !out.perfReferenceDir.has_value()) + { + error = "--perf-update-reference requires --perf-ref-dir."; + return false; + } + + return true; +} +} + +MeshLoadersApp::MeshLoadersApp( + const path& localInputCWD, + const path& localOutputCWD, + const path& sharedInputCWD, + const path& sharedOutputCWD) + : nbl::examples::MonoWindowApplication({1280, 720}, EF_D32_SFLOAT, localInputCWD, localOutputCWD, sharedInputCWD, 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 path effectiveInputCWD = ::resolveRuntimeCWD(localInputCWD); + const path effectiveOutputCWD = ::resolveRuntimeCWD(localOutputCWD); +#if defined(NBL_MESHLOADERS_DEFAULT_BENCHMARK_TESTLIST_PATH) + const path defaultBenchmarkTestListPath = path(NBL_MESHLOADERS_DEFAULT_BENCHMARK_TESTLIST_PATH); +#else + const path defaultBenchmarkTestListPath; +#endif + ParsedCommandLineOptions parsed = {}; + std::string parseError; + if (!parseMeshLoadersCommandLine(argv, effectiveInputCWD, effectiveOutputCWD, defaultBenchmarkTestListPath, parsed, parseError)) + return logFail(parseError.c_str()); + auto applyParsedCommandLineOptions = [this](ParsedCommandLineOptions&& options) -> void + { + m_runtime.mode = options.interactive ? RunMode::Interactive : (options.ci ? RunMode::CI : RunMode::Batch); + m_runtime.forceRowViewForCurrentTestList = options.forceRowViewForCurrentTestList; + m_output.saveGeom = options.saveGeom; + m_output.saveGeomPrefixPath = std::move(options.saveGeomPrefixPath); + m_output.screenshotPrefixPath = std::move(options.screenshotPrefixPath); + m_output.testListPath = std::move(options.testListPath); + if (options.specifiedGeomSavePath) + m_output.specifiedGeomSavePath.emplace(std::move(*options.specifiedGeomSavePath)); + m_output.loaderPerfLogPath = std::move(options.loaderPerfLogPath); + m_output.rowAddPath = std::move(options.rowAddPath); + m_output.rowDuplicateCount = options.rowDuplicateCount; + m_perf.options.dumpDir = std::move(options.perfDumpDir); + m_perf.options.referenceDir = std::move(options.perfReferenceDir); + m_perf.options.profileOverride = std::move(options.perfProfileOverride); + m_perf.options.strict = options.perfStrict; + m_perf.options.updateReference = options.perfUpdateReference; + m_perf.enabled = m_perf.options.dumpDir.has_value() || m_perf.options.referenceDir.has_value() || m_perf.options.strict || m_perf.options.updateReference; + m_forceLoaderContentHashes = options.forceLoaderContentHashes; + m_runtimeTuningMode = options.runtimeTuningMode; + }; + applyParsedCommandLineOptions(std::move(parsed)); + + if (m_runtime.forceRowViewForCurrentTestList) + m_logger->log("Using benchmark test list for default batch startup: %s", ILogger::ELL_INFO, m_output.testListPath.string().c_str()); + + if (m_output.saveGeom) + std::filesystem::create_directories(m_output.saveGeomPrefixPath); + std::filesystem::create_directories(m_output.screenshotPrefixPath); + m_assetLoadLogger = m_logger; + if (m_output.loaderPerfLogPath) + { + if (!initLoaderPerfLogger(*m_output.loaderPerfLogPath)) + return false; + m_logger->log("Loader diagnostics will be written to %s", ILogger::ELL_INFO, m_output.loaderPerfLogPath->string().c_str()); + } + + m_render.semaphore = m_device->createSemaphore(m_render.realFrameIx); + if (!m_render.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_render.cmdBufs.data() + i,1 })) + return logFail("Couldn't create Command Buffer!"); + } + + auto scRes = static_cast(m_surface->getSwapchainResources()); + m_render.renderer = CSimpleDebugRenderer::create(m_assetMgr.get(), scRes->getRenderpass(), 0, {}); + if (!m_render.renderer) + return logFail("Failed to create renderer!"); + if (!startBackgroundAssetWorker()) + return logFail("Failed to start background asset worker."); + if (!startBackgroundLoadWorker()) + return logFail("Failed to start background load worker."); + +#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 (performanceEnabled()) + beginPerformanceRun(); + + auto runInitialContent = [&]() -> bool + { + if (isRowViewActive()) + { + m_runtime.nonInteractiveTest = false; + if (!loadRowView(RowViewReloadMode::Full)) + return false; + if (m_output.rowAddPath) + if (!addRowViewCaseFromPath(*m_output.rowAddPath)) + return false; + if (m_output.rowDuplicateCount > 0u && !m_runtime.cases.empty()) + { + const auto lastPath = m_runtime.cases.back().path; + if (!appendRowViewDuplicates(m_output.rowDuplicateCount, lastPath, [this](const system::path& path) { + return addRowViewCaseFromPath(path); + })) + return false; + } + return true; + } + + if (m_runtime.mode != RunMode::Interactive) + m_runtime.nonInteractiveTest = true; + return startCase(0u); + }; + if (!runInitialContent()) + 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_render.realFrameIx % MaxFramesInFlight; + + auto* const cb = m_render.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_runtime.nonInteractiveTest) + { + struct SPendingInputActions + { + bool reloadInteractive = false; + bool reloadList = false; + bool addRowView = false; + bool clearRowView = false; + } pending; + 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()) + pending.reloadList = true; + else + pending.reloadInteractive = true; + } + else if (event.keyCode == E_KEY_CODE::EKC_A) + { + if (isRowViewActive()) + pending.addRowView = true; + } + else if (event.keyCode == E_KEY_CODE::EKC_X) + { + if (isRowViewActive()) + pending.clearRowView = true; + } + } + camera.keyboardProcess(events); + }, + m_logger.get() + ); + camera.endInputProcessing(nextPresentationTimestamp); + if (pending.clearRowView) + resetRowViewScene(); + if (pending.addRowView) + addRowViewCase(); + if (pending.reloadList) + { + if (!reloadFromTestList()) + failExit("Failed to reload test list."); + } + if (pending.reloadInteractive) + reloadInteractive(); + } + // draw scene + const auto& viewMatrix = camera.getViewMatrix(); + const auto& viewProjMatrix = camera.getConcatenatedMatrix(); + { + m_render.renderer->render(cb,CSimpleDebugRenderer::SViewParams(viewMatrix,viewProjMatrix)); + } +#ifdef NBL_BUILD_DEBUG_DRAW + { + const ISemaphore::SWaitInfo drawFinished = { .semaphore = m_render.semaphore.get(),.value = m_render.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_render.semaphore.get(), + .value = ++m_render.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_render.realFrameIx--; + } + + std::string caption = "[Nabla Engine] Mesh Loaders"; + { + caption += ", displaying ["; + caption += ::makeCaptionModelPath(m_modelPath, argv); + caption += "]"; + m_window->setCaption(caption); + } + const uint64_t rowViewCaptureRequestFrame = (RowViewFramesBeforeCapture > 1u) ? (RowViewFramesBeforeCapture - 1u) : RowViewFramesBeforeCapture; + if (isRowViewActive() && !m_runtime.rowViewScreenshotCaptured && m_render.realFrameIx >= rowViewCaptureRequestFrame) + { + if (!m_render.pendingScreenshot.active()) + { + if (!requestScreenshotCapture(m_output.rowViewScreenshotPath)) + failExit("Failed to request row view screenshot capture."); + } + else + { + bool ready = false; + if (!finalizeScreenshotCapture(m_render.loadedScreenshot, ready)) + failExit("Failed to finalize row view screenshot."); + if (ready) + m_runtime.rowViewScreenshotCaptured = true; + } + } + advanceCase(); + return retval; +} + +bool MeshLoadersApp::onAppTerminated() +{ + if (performanceEnabled() && !m_perf.finalized) + { + endPerformanceCase(); + finalizePerformanceRun(); + } + stopBackgroundLoadWorker(); + stopBackgroundAssetWorker(); + return device_base_t::onAppTerminated(); +} + +bool MeshLoadersApp::shouldKeepRunning() const +{ + return !m_runtime.shouldQuit; +} + +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_runtime.cases.clear(); + m_runtime.caseNameCounts.clear(); + if (m_runtime.mode == RunMode::Interactive) + { + system::path picked; + if (!pickModelPath(picked)) + return logFail("No file selected."); + m_runtime.cases.push_back({ makeUniqueCaseName(picked), picked }); + return true; + } + return loadTestList(m_output.testListPath); +} + +bool MeshLoadersApp::pickModelPath(system::path& outPath) +{ + if (m_runtime.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_runtime.fileDialogOpen = true; + DialogGuard guard{m_runtime.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_runtime.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_runtime.caseNameCounts.clear(); + + if (doc.contains("row_view")) + { + if (!doc["row_view"].is_boolean()) + return logFail("\"row_view\" must be a boolean."); + m_runtime.rowViewEnabled = doc["row_view"].get(); + } + if (m_runtime.forceRowViewForCurrentTestList && m_runtime.mode == RunMode::Batch) + m_runtime.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_runtime.cases.push_back({ makeUniqueCaseName(path), path }); + } + + if (m_runtime.cases.empty()) + return logFail("No test cases in test list."); + + return true; +} + +bool MeshLoadersApp::isRowViewActive() const +{ + return m_runtime.rowViewEnabled && m_runtime.mode != RunMode::CI && m_runtime.mode != 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; +} + +asset::writer_flags_t MeshLoadersApp::getWriterFlagsForPath(const IAsset* const asset, const system::path& path) const +{ + if (!asset) + return asset::EWF_NONE; + + const auto extension = system::extension_wo_dot(path); + auto flags = asset::writer_flags_t(asset::EWF_NONE); + if (const auto writerInfo = m_assetMgr->getAssetWriterFlagInfo(asset->getAssetType(), extension); writerInfo.has_value()) + { + flags = writerInfo->forced; + const auto preferred = asset::writer_flags_t(asset::EWF_MESH_IS_RIGHT_HANDED | asset::EWF_BINARY); + flags |= preferred & writerInfo->supported; + return flags; + } + + return asset::writer_flags_t(asset::EWF_MESH_IS_RIGHT_HANDED); +} + +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_output.specifiedGeomSavePath) + return path(*m_output.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_output.saveGeomPrefixPath / (stem + "_written" + ext); +} + +bool MeshLoadersApp::startCase(const size_t index) +{ + if (index >= m_runtime.cases.size()) + return false; + + auto resetCasePresentationState = [&]() -> void + { + m_runtime.phase = Phase::RenderOriginal; + m_runtime.phaseFrameCounter = 0u; + m_render.loadedScreenshot = nullptr; + m_render.writtenScreenshot = nullptr; + m_render.pendingScreenshot = {}; + m_referenceCamera.reset(); + }; + + m_runtime.caseIndex = index; + resetCasePresentationState(); + + const auto& testCase = m_runtime.cases[m_runtime.caseIndex]; + if (performanceEnabled()) + beginPerformanceCase(testCase); + const auto artifacts = makeCaseArtifacts( + testCase.name, + testCase.path, + m_output.screenshotPrefixPath, + [this](const system::path& path) { return resolveSavePath(path); }); + m_caseName = artifacts.caseName; + m_output.writtenPath = artifacts.writtenPath; + m_output.loadedScreenshotPath = artifacts.loadedScreenshotPath; + m_output.writtenScreenshotPath = artifacts.writtenScreenshotPath; + + bool loaded = false; + LoadStageMetrics loadMetrics = {}; + if (m_runtime.mode == RunMode::CI && !performanceEnabled()) + { + PreparedAssetLoad preparedLoad = {}; + bool preparedReady = false; + const bool loadWorkerStateValid = finalizePreparedAssetLoad(preparedLoad, preparedReady, true); + if (loadWorkerStateValid && preparedReady && preparedLoad.success && preparedLoad.caseIndex == index && preparedLoad.path == testCase.path) + loaded = loadPreparedModel(testCase.path, std::move(preparedLoad.loadResult), true, true, &loadMetrics); + else + loaded = loadModel(testCase.path, true, true, &loadMetrics); + } + else + loaded = loadModel(testCase.path, true, true, &loadMetrics); + if (!loaded) + return false; + if (performanceEnabled() && loadMetrics.valid) + recordOriginalLoadMetrics(loadMetrics); + + if (m_runtime.mode != RunMode::Interactive && m_output.saveGeom && m_render.currentCpuAsset) + { + if (!startWrittenAssetWork(m_render.currentCpuAsset, m_output.writtenPath)) + { + if (m_runtime.mode == RunMode::CI) + return logFail("Background written-asset preparation did not start for %s.", m_caseName.c_str()); + m_logger->log("Background written-asset preparation did not start for %s. Falling back to synchronous flow.", ILogger::ELL_WARNING, m_caseName.c_str()); + } + } + if (m_runtime.mode == RunMode::CI) + { + const auto nextIndex = index + 1u; + if (!performanceEnabled() && nextIndex < m_runtime.cases.size()) + startPreparedAssetLoad(nextIndex, m_runtime.cases[nextIndex].path); + } + + return true; +} + +bool MeshLoadersApp::advanceToNextCase() +{ + const auto nextIndex = m_runtime.caseIndex + 1u; + if (nextIndex >= m_runtime.cases.size()) + { + if (performanceEnabled()) + { + endPerformanceCase(); + finalizePerformanceRun(); + } + m_runtime.shouldQuit = true; + return false; + } + if (performanceEnabled()) + endPerformanceCase(); + if (!startCase(nextIndex)) + { + m_runtime.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_render.currentCpuAsset && m_output.saveGeom) + { + const auto savePath = resolveSavePath(picked); + if (!writeAssetRoot(m_render.currentCpuAsset, 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_runtime.cases.push_back({ makeUniqueCaseName(picked), picked }); + m_runtime.shouldQuit = false; + return loadRowView(RowViewReloadMode::Incremental); +} + +bool MeshLoadersApp::reloadFromTestList() +{ + m_runtime.cases.clear(); + m_render.pendingScreenshot = {}; + if (!loadTestList(m_output.testListPath)) + return false; + m_runtime.shouldQuit = false; + m_runtime.rowViewScreenshotCaptured = false; + if (isRowViewActive()) + { + m_runtime.nonInteractiveTest = false; + return loadRowView(RowViewReloadMode::Full); + } + m_runtime.nonInteractiveTest = (m_runtime.mode != RunMode::Interactive); + return startCase(0u); +} + +void MeshLoadersApp::resetRowViewScene() +{ + if (!isRowViewActive()) + return; + m_runtime.cases.clear(); + m_render.pendingScreenshot = {}; + m_rowView.cache.clear(); + m_render.renderer->m_instances.clear(); + m_render.renderer->clearGeometries({ .semaphore = m_render.semaphore.get(),.value = m_render.realFrameIx }); +#ifdef NBL_BUILD_DEBUG_DRAW + m_aabbInstances.clear(); + m_obbInstances.clear(); +#endif + m_modelPath = "Row view (empty)"; + m_runtime.rowViewScreenshotCaptured = false; + m_runtime.shouldQuit = false; + m_runtime.nonInteractiveTest = false; + m_logger->log("Row view reset to empty. Press A to add a model.", ILogger::ELL_INFO); +} + + + + diff --git a/12_MeshLoaders/AppLoad.cpp b/12_MeshLoaders/AppLoad.cpp new file mode 100644 index 000000000..2fe26cea5 --- /dev/null +++ b/12_MeshLoaders/AppLoad.cpp @@ -0,0 +1,1005 @@ +// 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 "App.hpp" +#include "BundleGeometryItems.h" + +#include +#include +#include +#include +#include +#include "nbl/examples/common/GeometryAABBUtilities.h" + +namespace +{ +using display_aabb_t = hlsl::shapes::AABB<3, double>; +struct DisplayLayout +{ + core::vector worldTransforms = {}; + display_aabb_t bound = display_aabb_t::create(); +}; +struct RowLayoutGroup +{ + size_t firstGeometry = 0u; + size_t geometryCount = 0u; + display_aabb_t layoutAABB = display_aabb_t::create(); + bool preserveInternalTransforms = false; + bool addAggregateDebugAABB = false; +}; +struct PreparedGeometryBatch +{ + core::vector> geometries; + core::vector worlds; +}; +static std::optional>> convertPolygonGeometries( + video::ILogicalDevice* device, + video::IQueue* transferQueue, + video::IQueue* graphicsQueue, + video::IUtilities* utilities, + system::ILogger* logger, + const core::vector>& geometries) +{ + if (geometries.empty()) + return core::vector>{}; + + smart_refctd_ptr converter = CAssetConverter::create({ .device = device }); + const auto transferFamily = transferQueue->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(geometries.size(), CSimpleDebugRenderer::DefaultPolygonGeometryPatch); + { + inputs.logger = logger; + std::get>(inputs.assets) = { &geometries.front().get(),geometries.size() }; + std::get>(inputs.patches) = patches; + core::unordered_set families; + families.insert(transferFamily); + families.insert(graphicsQueue->getFamilyIndex()); + if (families.size() > 1) + for (const auto fam : families) + inputs.sharedBufferOwnership.push_back(fam); + } + + auto reservation = converter->reserve(inputs); + if (!reservation) + return std::nullopt; + + { + auto semaphore = device->createSemaphore(0u); + + constexpr auto MultiBuffering = 2; + std::array, MultiBuffering> commandBuffers = {}; + { + auto pool = device->createCommandPool(transferFamily, IGPUCommandPool::CREATE_FLAGS::RESET_COMMAND_BUFFER_BIT | IGPUCommandPool::CREATE_FLAGS::TRANSIENT_BIT); + pool->createCommandBuffers(IGPUCommandPool::BUFFER_LEVEL::PRIMARY, commandBuffers, core::smart_refctd_ptr(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 = transferQueue; + transfer.scratchCommandBuffers = commandBufferSubmits; + transfer.scratchSemaphore = { + .semaphore = semaphore.get(), + .value = 0u, + .stageMask = PIPELINE_STAGE_FLAGS::ALL_TRANSFER_BITS + }; + + CAssetConverter::SConvertParams cpar = {}; + cpar.utilities = utilities; + cpar.transfer = &transfer; + + auto future = reservation.convert(cpar); + if (future.copy() != IQueue::RESULT::SUCCESS) + return std::nullopt; + } + + const auto convertedSpan = reservation.getGPUObjects(); + return core::vector>(convertedSpan.begin(), convertedSpan.end()); +} +static hlsl::float32_t3x4 makeIdentityWorld() +{ + 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)); + return hlsl::transpose(tmp); +} +static hlsl::float32_t4x4 makeAffine4x4(const hlsl::float32_t3x4& world) +{ + return hlsl::float32_t4x4{ + world[0], + world[1], + world[2], + hlsl::float32_t4(0, 0, 0, 1) + }; +} +#ifdef NBL_BUILD_DEBUG_DRAW +static ext::debug_draw::InstanceData makeAABBInstance( + const display_aabb_t& aabb, + const hlsl::float32_t3x4& world, + const hlsl::float32_t4& color) +{ + ext::debug_draw::InstanceData instance = {}; + instance.color = color; + instance.transform = math::linalg::promoted_mul( + makeAffine4x4(world), + ext::debug_draw::DrawAABB::getTransformFromAABB(shapes::AABB<3, float>(aabb.minVx, aabb.maxVx))); + return instance; +} +static ext::debug_draw::InstanceData makeOBBInstance( + const ICPUPolygonGeometry* geometry, + const hlsl::float32_t3x4& world, + const hlsl::float32_t4& color) +{ + ext::debug_draw::InstanceData instance = {}; + instance.color = color; + const auto obb = CPolygonGeometryManipulator::calculateOBB( + geometry->getPositionView().getElementCount(), + [geometry](size_t vertex_i) { + hlsl::float32_t3 pt; + geometry->getPositionView().decodeElement(vertex_i, pt); + return pt; + }); + instance.transform = math::linalg::promoted_mul(makeAffine4x4(world), obb.transform); + return instance; +} +#endif +static DisplayLayout buildDisplayLayout(const core::vector& aabbs, const bool arrangeInRow) +{ + DisplayLayout retval = {}; + retval.worldTransforms.reserve(aabbs.size()); + if (!arrangeInRow) + { + const auto identity = makeIdentityWorld(); + for (const auto& aabb : aabbs) + { + retval.worldTransforms.push_back(identity); + retval.bound = hlsl::shapes::util::union_(aabb, retval.bound); + } + return retval; + } + 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 = 0u; 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() > 0u ? widths.size() - 1u : 0u); + double cursor = -0.5 * totalSpan; + 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)); + for (size_t i = 0u; i < aabbs.size(); ++i) + { + const auto& aabb = aabbs[i]; + 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 world = hlsl::transpose(tmp); + retval.worldTransforms.push_back(world); + retval.bound = hlsl::shapes::util::union_(nbl::hlsl::math::linalg::pseudo_mul(hlsl::float64_t3x4(world), aabb), retval.bound); + } + return retval; +} +static bool extractPreparedGeometryBatch(const asset::SAssetBundle& bundle, PreparedGeometryBatch& out, const bool preserveTransforms) +{ + core::vector items; + if (!meshloaders::collectBundleGeometryItems(bundle, items, preserveTransforms)) + return false; + out.geometries.reserve(items.size()); + out.worlds.reserve(items.size()); + for (auto& item : items) + { + out.worlds.push_back(item.world); + out.geometries.push_back(std::move(item.geometry)); + } + return !out.geometries.empty(); +} +template +static void collectGeometryAABBs( + const core::vector>& geometries, + core::vector& out, + GetAABB&& getAABB, + WarnInvalid&& warnInvalid) +{ + out.clear(); + out.reserve(geometries.size()); + for (uint32_t i = 0u; i < geometries.size(); ++i) + { + auto aabb = getAABB(geometries[i].get()); + if (!nbl::examples::geometry::isValidAABB(aabb)) + { + warnInvalid(i); + aabb = nbl::examples::geometry::fallbackUnitAABB(); + } + out.push_back(aabb); + } +} +} + +bool MeshLoadersApp::loadModel(const system::path& modelPath, bool updateCamera, bool storeCamera) +{ + return loadModel(modelPath, updateCamera, storeCamera, nullptr); +} + +bool MeshLoadersApp::loadModel(const system::path& modelPath, bool updateCamera, bool storeCamera, LoadStageMetrics* const perfMetrics) +{ + if (modelPath.empty()) + failExit("Empty model path."); + if (!std::filesystem::exists(modelPath)) + failExit("Missing input: %s", modelPath.string().c_str()); + IAssetLoader::SAssetLoadParams params = makeLoadParams(); + AssetLoadCallResult loadResult = {}; + if (!loadAssetCallFromPath(modelPath, params, loadResult)) + failExit("Failed to open input file %s.", modelPath.string().c_str()); + return loadPreparedModel(modelPath, std::move(loadResult), updateCamera, storeCamera, perfMetrics); +} + +bool MeshLoadersApp::loadPreparedModel(const system::path& modelPath, AssetLoadCallResult&& loadResult, bool updateCamera, bool storeCamera) +{ + return loadPreparedModel(modelPath, std::move(loadResult), updateCamera, storeCamera, nullptr); +} + +bool MeshLoadersApp::loadPreparedModel(const system::path& modelPath, AssetLoadCallResult&& loadResult, bool updateCamera, bool storeCamera, LoadStageMetrics* const perfMetrics) +{ + using clock_t = std::chrono::high_resolution_clock; + + m_modelPath = modelPath.string(); + + m_render.renderer->m_instances.clear(); + m_render.renderer->clearGeometries({ .semaphore = m_render.semaphore.get(),.value = m_render.realFrameIx }); + m_assetMgr->clearAllAssetCache(); + + 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()); + m_render.currentCpuAsset = (asset.getContents().size() == 1u) ? asset.getContents()[0] : nullptr; + + PreparedGeometryBatch batch = {}; + const auto extractStart = clock_t::now(); + const bool renderAsScene = asset.getAssetType() == IAsset::E_TYPE::ET_SCENE; + if (!extractPreparedGeometryBatch(asset, batch, renderAsScene)) + failExit("Asset loaded but not a supported type for %s.", m_modelPath.c_str()); + const auto extractMs = toMs(clock_t::now() - extractStart); + if (batch.geometries.empty()) + failExit("No geometry found in asset %s.", m_modelPath.c_str()); + + const auto outerMs = loadMs + extractMs; + const auto nonLoaderMs = extractMs; + 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); + if (perfMetrics) + { + *perfMetrics = LoadStageMetrics{ + .getAssetMs = loadMs, + .extractMs = extractMs, + .totalMs = outerMs, + .nonLoaderMs = nonLoaderMs, + .inputSize = loadResult.inputSize, + .valid = true + }; + } + + m_render.currentCpuGeom = batch.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); + }; + core::vector aabbs; + collectGeometryAABBs( + batch.geometries, + aabbs, + [this](const ICPUPolygonGeometry* geometry) { return getGeometryAABB(geometry); }, + [this](const uint32_t geoIx) { + m_logger->log("Invalid geometry AABB for %s (geo=%u). Using fallback unit AABB for framing.", ILogger::ELL_WARNING, m_modelPath.c_str(), geoIx); + }); + core::vector worldTforms; + worldTforms.reserve(batch.geometries.size()); + auto bound = display_aabb_t::create(); + if (renderAsScene) + { + for (uint32_t i = 0u; i < batch.worlds.size(); ++i) + { + const auto& world = batch.worlds[i]; + worldTforms.push_back(world); + bound = hlsl::shapes::util::union_(nbl::hlsl::math::linalg::pseudo_mul(hlsl::float64_t3x4(world), aabbs[i]), bound); + } + } + else + { + const auto layout = buildDisplayLayout(aabbs, batch.geometries.size() > 1u); + worldTforms = layout.worldTransforms; + bound = layout.bound; + } + // convert the geometries + { + const auto converted = convertPolygonGeometries( + m_device.get(), + getTransferUpQueue(), + getGraphicsQueue(), + m_utils.get(), + m_logger.get(), + batch.geometries); + if (!converted.has_value()) + failExit("Failed to convert CPU geometries to GPU."); + const auto& convertedGeometries = *converted; + m_aabbInstances.resize(convertedGeometries.size()); + if (m_drawBBMode == DBBM_OBB) + m_obbInstances.resize(convertedGeometries.size()); + for (uint32_t i = 0; i < convertedGeometries.size(); i++) + { + const auto& cpuGeom = batch.geometries[i].get(); + const auto& promoted = aabbs[i]; + printAABB(promoted, "Geometry"); + const auto promotedWorld = hlsl::float64_t3x4(worldTforms[i]); + const auto transformed = nbl::hlsl::math::linalg::pseudo_mul(promotedWorld, promoted); + printAABB(transformed, "Transformed"); + +#ifdef NBL_BUILD_DEBUG_DRAW + m_aabbInstances[i] = makeAABBInstance(promoted, worldTforms[i], hlsl::float32_t4(1, 1, 1, 1)); + + if (m_drawBBMode == DBBM_OBB) + { + m_obbInstances[i] = makeOBBInstance(cpuGeom, worldTforms[i], hlsl::float32_t4(0, 0, 1, 1)); + } +#endif + } + + printAABB(bound, "Total"); + if (!m_render.renderer->addGeometries({ &convertedGeometries.front().get(),convertedGeometries.size() })) + failExit("Failed to add geometries to renderer."); + if (m_logger) + { + const auto& gpuGeos = m_render.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 worldTformsIt = worldTforms.begin(); + for (const auto& geo : m_render.renderer->getGeometries()) + m_render.renderer->m_instances.push_back({ + .world = *(worldTformsIt++), + .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_runtime.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_runtime.cases.size(); + const auto totalStart = clock_t::now(); + + const auto clearStart = clock_t::now(); + if (mode == RowViewReloadMode::Full) + { + m_render.renderer->m_instances.clear(); + m_render.renderer->clearGeometries({ .semaphore = m_render.semaphore.get(),.value = m_render.realFrameIx }); + } + stats.clearMs = toMs(clock_t::now() - clearStart); + + core::vector> geometries; + core::vector> aabbs; + core::vector sourceWorlds; + core::vector layoutGroups; + + core::vector> cpuToConvert; + struct ConvertTarget { CachedGeometryEntry* entry = nullptr; size_t geometryIx = 0u; }; + core::vector convertTargets; + + m_rowView.cache.reserve(m_runtime.cases.size()); + + IAssetLoader::SAssetLoadParams params = makeLoadParams(); + auto rebuildTileAABB = [&](CachedGeometryEntry& entry, const system::path& path) -> void + { + entry.tileAABB = display_aabb_t::create(); + if (!entry.layoutAsSingleTile) + return; + for (uint32_t geoIx = 0u; geoIx < entry.aabbs.size(); ++geoIx) + entry.tileAABB = hlsl::shapes::util::union_( + nbl::hlsl::math::linalg::pseudo_mul(hlsl::float64_t3x4(entry.world[geoIx]), entry.aabbs[geoIx]), + entry.tileAABB); + if (!isValidAABB(entry.tileAABB)) + { + m_logger->log("Invalid row-view scene AABB for %s. Using fallback unit AABB.", ILogger::ELL_WARNING, path.string().c_str()); + entry.tileAABB = nbl::examples::geometry::fallbackUnitAABB(); + } + }; + auto assignCachedEntryGeometryBatch = [&](CachedGeometryEntry& entry, PreparedGeometryBatch&& batch) -> void + { + entry.cpu = std::move(batch.geometries); + entry.world = std::move(batch.worlds); + if (!entry.layoutAsSingleTile) + entry.world.assign(entry.cpu.size(), hlsl::math::linalg::identity()); + entry.gpu.resize(entry.cpu.size()); + }; + auto refreshCachedEntryAABBs = [&](CachedGeometryEntry& entry, const system::path& path) -> void + { + collectGeometryAABBs( + entry.cpu, + entry.aabbs, + [this](const ICPUPolygonGeometry* geometry) { return getGeometryAABB(geometry); }, + [this, &path](const uint32_t geoIx) { + m_logger->log("Invalid row-view geometry AABB for %s (geo=%u). Using fallback unit AABB.", ILogger::ELL_WARNING, path.string().c_str(), geoIx); + }); + rebuildTileAABB(entry, path); + }; + auto appendCachedEntryToLayoutInputs = [&]( + const CachedGeometryEntry& entry, + core::vector>& geometryOut, + core::vector>& aabbOut, + core::vector& worldOut, + core::vector& groupOut) -> void + { + const size_t firstGeometry = geometryOut.size(); + geometryOut.insert(geometryOut.end(), entry.cpu.begin(), entry.cpu.end()); + aabbOut.insert(aabbOut.end(), entry.aabbs.begin(), entry.aabbs.end()); + worldOut.insert(worldOut.end(), entry.world.begin(), entry.world.end()); + if (entry.layoutAsSingleTile) + { + groupOut.push_back({ + .firstGeometry = firstGeometry, + .geometryCount = entry.cpu.size(), + .layoutAABB = entry.tileAABB, + .preserveInternalTransforms = true, + .addAggregateDebugAABB = true + }); + return; + } + for (size_t geoIx = 0u; geoIx < entry.cpu.size(); ++geoIx) + { + groupOut.push_back({ + .firstGeometry = firstGeometry + geoIx, + .geometryCount = 1u, + .layoutAABB = entry.aabbs[geoIx], + .preserveInternalTransforms = false, + .addAggregateDebugAABB = false + }); + } + }; + + for (const auto& testCase : m_runtime.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_rowView.cache[cacheKey]; + double assetLoadMs = 0.0; + bool cached = true; + if (entry.cpu.empty()) + { + 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(); + entry.world.clear(); + entry.layoutAsSingleTile = (asset.getAssetType() == IAsset::E_TYPE::ET_SCENE); + PreparedGeometryBatch batch = {}; + if (extractPreparedGeometryBatch(asset, batch, entry.layoutAsSingleTile)) + assignCachedEntryGeometryBatch(entry, std::move(batch)); + stats.extractMs += toMs(clock_t::now() - extractStart); + if (entry.cpu.empty()) + failExit("No geometry found in asset %s.", path.string().c_str()); + + const auto aabbStart = clock_t::now(); + refreshCachedEntryAABBs(entry, path); + stats.aabbMs += toMs(clock_t::now() - aabbStart); + } + else + { + stats.cpuHits++; + if (entry.gpu.size() != entry.cpu.size()) + entry.gpu.resize(entry.cpu.size()); + if (entry.world.size() != entry.cpu.size()) + entry.world.assign(entry.cpu.size(), hlsl::math::linalg::identity()); + if (entry.aabbs.size() != entry.cpu.size()) + { + const auto aabbStart = clock_t::now(); + refreshCachedEntryAABBs(entry, path); + stats.aabbMs += toMs(clock_t::now() - aabbStart); + } + } + logRowViewAssetLoad(path, assetLoadMs, cached); + + for (size_t geoIx = 0u; geoIx < entry.cpu.size(); ++geoIx) + { + if (!entry.gpu[geoIx]) + { + stats.gpuMisses++; + cpuToConvert.push_back(entry.cpu[geoIx]); + convertTargets.push_back({.entry = &entry,.geometryIx = geoIx}); + } + else + stats.gpuHits++; + } + + appendCachedEntryToLayoutInputs(entry, geometries, aabbs, sourceWorlds, layoutGroups); + } + + 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(); + const auto converted = convertPolygonGeometries( + m_device.get(), + getTransferUpQueue(), + getGraphicsQueue(), + m_utils.get(), + m_logger.get(), + cpuToConvert); + if (!converted.has_value()) + failExit("Failed to convert CPU geometries to GPU."); + const auto& convertedGeometries = *converted; + for (size_t i = 0u; i < convertedGeometries.size(); ++i) + convertTargets[i].entry->gpu[convertTargets[i].geometryIx] = convertedGeometries[i]; + + stats.convertMs = toMs(clock_t::now() - convertStart); + } + + const size_t totalGeometryCount = geometries.size(); + size_t existingCount = m_render.renderer->getGeometries().size(); + const bool incremental = (mode == RowViewReloadMode::Incremental) && (existingCount <= totalGeometryCount); + if (!incremental && mode == RowViewReloadMode::Incremental) + return loadRowView(RowViewReloadMode::Full); + + core::vector allGeometries; + allGeometries.reserve(totalGeometryCount); + for (const auto& testCase : m_runtime.cases) + { + const auto& entry = m_rowView.cache[makeCacheKey(testCase.path)]; + for (size_t geoIx = 0u; geoIx < entry.gpu.size(); ++geoIx) + { + if (!entry.gpu[geoIx]) + failExit("Missing GPU geometry for %s.", testCase.path.string().c_str()); + allGeometries.push_back(entry.gpu[geoIx].get()); + } + } + + if (mode == RowViewReloadMode::Full) + { + stats.addCount = allGeometries.size(); + const auto addStart = clock_t::now(); + if (!allGeometries.empty()) + if (!m_render.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 < totalGeometryCount) ? (totalGeometryCount - existingCount) : 0u; + stats.addCount = addCount; + if (addCount > 0u) + { + const auto addStart = clock_t::now(); + if (!m_render.renderer->addGeometries({ allGeometries.data() + existingCount,addCount })) + 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); + }; + const auto layoutStart = clock_t::now(); + core::vector worldTforms; + worldTforms.resize(geometries.size()); + auto bound = aabb_t::create(); + core::vector tileAABBs; + tileAABBs.reserve(layoutGroups.size()); + for (const auto& group : layoutGroups) + tileAABBs.push_back(group.layoutAABB); + const auto layout = buildDisplayLayout(tileAABBs, true); +#ifdef NBL_BUILD_DEBUG_DRAW + struct AggregateDebugAABB + { + aabb_t aabb = aabb_t::create(); + hlsl::float32_t3x4 world = makeIdentityWorld(); + }; + core::vector sceneDebugAABBs; + sceneDebugAABBs.reserve(layoutGroups.size()); +#endif + for (uint32_t groupIx = 0u; groupIx < layoutGroups.size(); ++groupIx) + { + const auto& group = layoutGroups[groupIx]; + const auto& tileWorld = layout.worldTransforms[groupIx]; +#ifdef NBL_BUILD_DEBUG_DRAW + if (group.addAggregateDebugAABB) + sceneDebugAABBs.push_back({.aabb = group.layoutAABB,.world = tileWorld}); +#endif + for (uint32_t localIx = 0u; localIx < group.geometryCount; ++localIx) + { + const uint32_t geometryIx = static_cast(group.firstGeometry + localIx); + worldTforms[geometryIx] = group.preserveInternalTransforms ? + hlsl::math::linalg::promoted_mul(tileWorld, sourceWorlds[geometryIx]) : + tileWorld; + bound = hlsl::shapes::util::union_( + nbl::hlsl::math::linalg::pseudo_mul(hlsl::float64_t3x4(worldTforms[geometryIx]), aabbs[geometryIx]), + bound); + } + } + stats.layoutMs = toMs(clock_t::now() - layoutStart); + + const auto instanceStart = clock_t::now(); +#ifdef NBL_BUILD_DEBUG_DRAW + m_aabbInstances.clear(); + m_aabbInstances.reserve(geometries.size() + sceneDebugAABBs.size()); + if (m_drawBBMode == DBBM_OBB) + m_obbInstances.resize(geometries.size()); +#endif + m_render.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 auto promotedWorld = hlsl::float64_t3x4(worldTforms[i]); + const auto transformed = nbl::hlsl::math::linalg::pseudo_mul(promotedWorld, aabb); + printAABB(transformed, "Transformed"); + +#ifdef NBL_BUILD_DEBUG_DRAW + m_aabbInstances.push_back(makeAABBInstance(aabb, worldTforms[i], hlsl::float32_t4(1, 1, 1, 1))); + + if (m_drawBBMode == DBBM_OBB) + m_obbInstances[i] = makeOBBInstance(cpuGeom, worldTforms[i], hlsl::float32_t4(0, 0, 1, 1)); +#endif + } +#ifdef NBL_BUILD_DEBUG_DRAW + for (const auto& sceneAABB : sceneDebugAABBs) + m_aabbInstances.push_back(makeAABBInstance(sceneAABB.aabb, sceneAABB.world, hlsl::float32_t4(1, 0.65f, 0, 1))); +#endif + + printAABB(bound, "Total"); + for (uint32_t i = 0; i < worldTforms.size(); i++) + { + m_render.renderer->m_instances.push_back({ + .world = worldTforms[i], + .packedGeo = &m_render.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_output.rowViewScreenshotPath = m_output.screenshotPrefixPath / "meshloaders_row_view.png"; + m_runtime.rowViewScreenshotCaptured = false; + stats.totalMs = toMs(clock_t::now() - totalStart); + logRowViewPerf(stats); + return true; +} + +bool MeshLoadersApp::writeAssetRoot(smart_refctd_ptr asset, const std::string& savePath) +{ + return writeAssetRoot(std::move(asset), savePath, nullptr); +} + +bool MeshLoadersApp::writeAssetRoot(smart_refctd_ptr asset, const std::string& savePath, WriteStageMetrics* const perfMetrics) +{ + using clock_t = std::chrono::high_resolution_clock; + const auto writeOuterStart = clock_t::now(); + if (!asset) + return false; + + IAsset* assetPtr = const_cast(asset.get()); + const auto ext = normalizeExtension(system::path(savePath)); + const auto flags = getWriterFlagsForPath(asset.get(), system::path(savePath)); + 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); + if (perfMetrics) + { + *perfMetrics = WriteStageMetrics{ + .openMs = openMs, + .writeMs = writeMs, + .statMs = statMs, + .totalMs = outerMs, + .nonWriterMs = nonWriterMs, + .outputSize = size, + .usedMemoryTransport = false, + .usedDiskFallback = false, + .persistedDiskArtifact = true, + .valid = true + }; + } + 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 = nbl::examples::geometry::fallbackUnitAABB(); + } + 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 }); + + 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::math::thin_lens::rhPerspectiveFovMatrix( + 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)); +} + +void MeshLoadersApp::storeCameraState() +{ + const auto position = camera.getPosition(); + const auto target = camera.getTarget(); + m_referenceCamera = CameraState{ + hlsl::float32_t3(position.x, position.y, position.z), + hlsl::float32_t3(target.x, target.y, target.z), + camera.getProjectionMatrix(), + camera.getMoveSpeed() + }; +} + +void MeshLoadersApp::applyCameraState(const CameraState& state) +{ + camera.setProjectionMatrix(state.projection); + camera.setPosition(vectorSIMDf(state.position.x, state.position.y, state.position.z)); + camera.setTarget(vectorSIMDf(state.target.x, state.target.y, state.target.z)); + camera.setMoveSpeed(state.moveSpeed); +} + +bool MeshLoadersApp::isValidAABB(const hlsl::shapes::AABB<3, double>& aabb) +{ + return nbl::examples::geometry::isValidAABB(aabb); +} + +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 = nbl::examples::geometry::computeFiniteUsedPositionAABB(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_runtime.mode == RunMode::CI || isRowViewActive()) && !m_loaderPerfLogger) + params.logger = nullptr; + params.cacheFlags = IAssetLoader::ECF_DUPLICATE_TOP_LEVEL; + params.ioPolicy.runtimeTuning.mode = m_runtimeTuningMode; + 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/AppPerf.cpp b/12_MeshLoaders/AppPerf.cpp new file mode 100644 index 000000000..de683aa7c --- /dev/null +++ b/12_MeshLoaders/AppPerf.cpp @@ -0,0 +1,549 @@ +// 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 "App.hpp" + +#include "nbl/examples/git/info.h" + +#include "nlohmann/json.hpp" +#include "nbl/core/hash/blake.h" + +#include +#include +#include +#include +#include +#include +#include +#include + +namespace +{ + +using perf_json_t = nlohmann::ordered_json; + +constexpr std::string_view PerfSchemaVersion = "meshloaders-perf-run-v1"; +constexpr std::string_view PerfProtocolVersion = "meshloaders-roundtrip-v1"; + +std::string normalizeProfileComponent(std::string_view value) +{ + std::string normalized; + normalized.reserve(value.size()); + bool lastWasSeparator = false; + for (const auto ch : value) + { + if (std::isalnum(static_cast(ch))) + { + normalized.push_back(static_cast(std::tolower(static_cast(ch)))); + lastWasSeparator = false; + } + else if (!lastWasSeparator) + { + normalized.push_back('-'); + lastWasSeparator = true; + } + } + while (!normalized.empty() && normalized.back() == '-') + normalized.pop_back(); + if (normalized.empty()) + normalized = "unknown"; + return normalized; +} + +std::string hashToHex(const nbl::core::blake3_hash_t& hash) +{ + std::ostringstream oss; + oss << std::hex << std::setfill('0'); + for (const auto byte : hash.data) + oss << std::setw(2) << static_cast(byte); + return oss.str(); +} + +std::string currentTimestampTag() +{ + const auto now = std::chrono::system_clock::now(); + const auto time = std::chrono::system_clock::to_time_t(now); + std::tm tm = {}; +#ifdef _WIN32 + gmtime_s(&tm, &time); +#else + gmtime_r(&time, &tm); +#endif + std::ostringstream oss; + oss << std::put_time(&tm, "%Y%m%d_%H%M%S"); + return oss.str(); +} + +std::string currentTimestampIsoUtc() +{ + const auto now = std::chrono::system_clock::now(); + const auto time = std::chrono::system_clock::to_time_t(now); + std::tm tm = {}; +#ifdef _WIN32 + gmtime_s(&tm, &time); +#else + gmtime_r(&time, &tm); +#endif + std::ostringstream oss; + oss << std::put_time(&tm, "%Y-%m-%dT%H:%M:%SZ"); + return oss.str(); +} + +std::string runModeName(const uint32_t modeValue) +{ + switch (modeValue) + { + case 0u: + return "interactive"; + case 1u: + return "batch"; + case 2u: + return "ci"; + default: + return "unknown"; + } +} + +std::string runtimeTuningModeName(const asset::SFileIOPolicy::SRuntimeTuning::Mode mode) +{ + switch (mode) + { + case asset::SFileIOPolicy::SRuntimeTuning::Mode::Sequential: + return "sequential"; + case asset::SFileIOPolicy::SRuntimeTuning::Mode::Heuristic: + return "heuristic"; + case asset::SFileIOPolicy::SRuntimeTuning::Mode::Hybrid: + return "hybrid"; + default: + return "unknown"; + } +} + +std::string platformName() +{ +#if defined(_WIN32) + return "windows"; +#elif defined(__linux__) + return "linux"; +#elif defined(__ANDROID__) + return "android"; +#else + return "unknown"; +#endif +} + +std::string buildConfigName() +{ +#if defined(NBL_MESHLOADERS_BUILD_CONFIG) + return NBL_MESHLOADERS_BUILD_CONFIG; +#elif defined(_DEBUG) + return "Debug"; +#else + return "Release"; +#endif +} + +perf_json_t dirtyStateJson(const std::optional& dirty) +{ + if (!dirty.has_value()) + return nullptr; + return perf_json_t(dirty.value()); +} + +std::filesystem::path normalizePathForPerf(const std::filesystem::path& path) +{ + if (path.empty()) + return {}; + + std::error_code ec; + auto normalized = path.lexically_normal(); + if (std::filesystem::exists(normalized, ec) && !ec) + { + auto canonical = std::filesystem::weakly_canonical(normalized, ec); + if (!ec) + return canonical.lexically_normal(); + ec.clear(); + } + else + ec.clear(); + + if (!normalized.is_absolute()) + { + auto absolute = std::filesystem::absolute(normalized, ec); + if (!ec) + return absolute.lexically_normal(); + ec.clear(); + } + + return normalized; +} + +std::optional tryRelativePerfPath(const std::filesystem::path& targetPath, const std::filesystem::path& basePath) +{ + if (targetPath.empty() || basePath.empty()) + return std::nullopt; + + std::error_code ec; + const auto normalizedTarget = normalizePathForPerf(targetPath); + const auto normalizedBase = normalizePathForPerf(basePath); + const auto relative = std::filesystem::relative(normalizedTarget, normalizedBase, ec); + if (ec || relative.empty() || relative.is_absolute()) + return std::nullopt; + + return relative.lexically_normal().generic_string(); +} + +std::string makePortablePerfPath(const std::filesystem::path& path, const std::optional& preferredBase = std::nullopt) +{ + if (path.empty()) + return {}; + + auto normalized = path.lexically_normal(); + if (!normalized.is_absolute()) + return normalized.generic_string(); + + if (preferredBase.has_value()) + if (auto relative = tryRelativePerfPath(path, *preferredBase); relative.has_value()) + return *relative; + + std::error_code ec; + const auto cwd = std::filesystem::current_path(ec); + if (!ec) + if (auto relative = tryRelativePerfPath(path, cwd); relative.has_value()) + return *relative; + + normalized = normalizePathForPerf(path); + if (!normalized.filename().empty()) + return normalized.filename().generic_string(); + + return normalizeProfileComponent(normalized.generic_string()); +} + +perf_json_t toJson(const MeshLoadersApp::LoadStageMetrics& metrics) +{ + return perf_json_t{ + {"valid", metrics.valid}, + {"input_size", metrics.inputSize}, + {"get_asset_ms", metrics.getAssetMs}, + {"extract_ms", metrics.extractMs}, + {"total_ms", metrics.totalMs}, + {"non_loader_ms", metrics.nonLoaderMs} + }; +} + +perf_json_t toJson(const MeshLoadersApp::WriteStageMetrics& metrics) +{ + return perf_json_t{ + {"valid", metrics.valid}, + {"output_size", metrics.outputSize}, + {"open_ms", metrics.openMs}, + {"write_ms", metrics.writeMs}, + {"stat_ms", metrics.statMs}, + {"total_ms", metrics.totalMs}, + {"non_writer_ms", metrics.nonWriterMs}, + {"used_memory_transport", metrics.usedMemoryTransport}, + {"used_disk_fallback", metrics.usedDiskFallback}, + {"persisted_disk_artifact", metrics.persistedDiskArtifact} + }; +} + +bool metricRegression(const double current, const double reference, const double relativeThreshold, const double absoluteThreshold) +{ + if (reference <= 0.0) + return current > absoluteThreshold; + const double allowed = std::max(reference * (1.0 + relativeThreshold), reference + absoluteThreshold); + return current > allowed; +} + +void compareMetric(core::vector& failures, const std::string& caseName, const std::string_view metricName, const double current, const double reference, const double relativeThreshold, const double absoluteThreshold) +{ + if (!metricRegression(current, reference, relativeThreshold, absoluteThreshold)) + return; + + std::ostringstream oss; + oss << caseName << ": " << metricName << " regressed from " << reference << " ms to " << current << " ms"; + failures.push_back(oss.str()); +} + +perf_json_t buildCaseJson(const MeshLoadersApp::CasePerformanceMetrics& metrics, const std::optional& testListDir) +{ + return perf_json_t{ + {"name", metrics.caseName}, + {"input_path", makePortablePerfPath(metrics.inputPath, testListDir)}, + {"original_load", toJson(metrics.originalLoad)}, + {"write", toJson(metrics.write)}, + {"written_load", toJson(metrics.writtenLoad)} + }; +} + +bool writePerfJson(system::ISystem* const system, const system::path& path, const perf_json_t& json) +{ + if (!system) + return false; + + const auto parentDir = path.parent_path(); + if (!parentDir.empty()) + std::filesystem::create_directories(parentDir); + system->deleteFile(path); + + system::ISystem::future_t> writeFileFuture; + system->createFile(writeFileFuture, path, system::IFile::ECF_WRITE); + core::smart_refctd_ptr writeFile; + writeFileFuture.acquire().move_into(writeFile); + if (!writeFile) + return false; + + const auto serialized = json.dump(2); + size_t written = 0ull; + while (written < serialized.size()) + { + system::IFile::success_t success; + writeFile->write(success, serialized.data() + written, written, serialized.size() - written); + const auto processed = success.getBytesProcessed(); + if (!success || processed == 0ull) + return false; + written += processed; + } + return true; +} + +} + +bool MeshLoadersApp::performanceEnabled() const +{ + return m_perf.enabled; +} + +void MeshLoadersApp::beginPerformanceRun() +{ + if (!performanceEnabled()) + return; + + m_perf.finalized = false; + m_perf.completedCases.clear(); + m_perf.completedCases.reserve(m_runtime.cases.size()); + m_perf.comparisonFailures.clear(); + m_perf.runStart = std::chrono::steady_clock::now(); + + const auto systemInfo = m_system->getSystemInfo(); + const auto normalizedCpuName = normalizeProfileComponent(systemInfo.cpuName); + if (m_perf.options.profileOverride.has_value()) + m_perf.profileId = normalizeProfileComponent(*m_perf.options.profileOverride); + else + { + std::ostringstream profile; + profile << platformName() + << "__" << normalizedCpuName + << "__thr-" << std::thread::hardware_concurrency() + << "__" << normalizeProfileComponent(buildConfigName()); + m_perf.profileId = normalizeProfileComponent(profile.str()); + } + + nbl::core::blake3_hasher workloadHasher; + workloadHasher.update(PerfProtocolVersion.data(), PerfProtocolVersion.size()); + workloadHasher << static_cast(m_runtime.mode); + workloadHasher << static_cast(m_runtimeTuningMode); + workloadHasher << m_runtime.rowViewEnabled; + if (!m_output.testListPath.empty() && std::filesystem::exists(m_output.testListPath)) + { + std::ifstream stream(m_output.testListPath, std::ios::binary); + std::string content((std::istreambuf_iterator(stream)), std::istreambuf_iterator()); + workloadHasher.update(content.data(), content.size()); + } + else + { + workloadHasher << m_runtime.cases.size(); + for (const auto& testCase : m_runtime.cases) + { + workloadHasher << testCase.name; + workloadHasher << makePortablePerfPath(testCase.path); + } + } + m_perf.workloadId = hashToHex(static_cast(workloadHasher)); +} + +void MeshLoadersApp::beginPerformanceCase(const TestCase& testCase) +{ + if (!performanceEnabled()) + return; + + m_perf.currentCaseIndex = m_perf.completedCases.size(); + m_perf.completedCases.push_back(CasePerformanceMetrics{ + .caseName = testCase.name, + .inputPath = testCase.path + }); +} + +void MeshLoadersApp::recordOriginalLoadMetrics(const LoadStageMetrics& metrics) +{ + if (!performanceEnabled() || m_perf.currentCaseIndex >= m_perf.completedCases.size()) + return; + m_perf.completedCases[m_perf.currentCaseIndex].originalLoad = metrics; +} + +void MeshLoadersApp::recordWrittenLoadMetrics(const LoadStageMetrics& metrics) +{ + if (!performanceEnabled() || m_perf.currentCaseIndex >= m_perf.completedCases.size()) + return; + m_perf.completedCases[m_perf.currentCaseIndex].writtenLoad = metrics; +} + +void MeshLoadersApp::recordWriteMetrics(const WriteStageMetrics& metrics) +{ + if (!performanceEnabled() || m_perf.currentCaseIndex >= m_perf.completedCases.size()) + return; + m_perf.completedCases[m_perf.currentCaseIndex].write = metrics; +} + +void MeshLoadersApp::recordWriteMetrics(const WrittenAssetResult& result) +{ + WriteStageMetrics metrics = {}; + metrics.openMs = result.openMs; + metrics.writeMs = result.writeMs; + metrics.statMs = result.statMs; + metrics.totalMs = result.totalWriteMs; + metrics.nonWriterMs = result.nonWriterMs; + metrics.outputSize = result.outputSize; + metrics.usedMemoryTransport = result.usedMemoryTransport; + metrics.usedDiskFallback = result.usedDiskFallback; + metrics.persistedDiskArtifact = result.persistedDiskArtifact; + metrics.valid = true; + recordWriteMetrics(metrics); +} + +void MeshLoadersApp::endPerformanceCase() +{ + if (!performanceEnabled() || m_perf.currentCaseIndex >= m_perf.completedCases.size()) + return; + + m_perf.currentCaseIndex = ~size_t(0u); +} + +void MeshLoadersApp::finalizePerformanceRun() +{ + if (!performanceEnabled() || m_perf.finalized) + return; + + m_perf.finalized = true; + + perf_json_t root = {}; + root["schema_version"] = PerfSchemaVersion; + root["protocol_version"] = PerfProtocolVersion; + root["profile_id"] = m_perf.profileId; + root["workload_id"] = m_perf.workloadId; + root["run_mode"] = runModeName(static_cast(m_runtime.mode)); + root["runtime_tuning"] = runtimeTuningModeName(m_runtimeTuningMode); + root["provenance"] = { + {"created_at_utc", currentTimestampIsoUtc()}, + {"nabla_commit", std::string(nbl::gtml::nabla_git_info.commitHash())}, + {"nabla_dirty", dirtyStateJson(nbl::gtml::nabla_git_info.hasUncommittedChanges())}, + {"examples_commit", std::string(nbl::examples::gtml::examples_git_info.commitHash())}, + {"examples_dirty", dirtyStateJson(nbl::examples::gtml::examples_git_info.hasUncommittedChanges())} + }; + + const auto systemInfo = m_system->getSystemInfo(); + root["environment"] = { + {"platform", platformName()}, + {"os_full_name", systemInfo.OSFullName}, + {"cpu_name", systemInfo.cpuName}, + {"physical_core_count", systemInfo.physicalCoreCount}, + {"gpu_name", m_physicalDevice ? m_physicalDevice->getProperties().deviceName : "unknown"}, + {"total_memory_bytes", systemInfo.totalMemory}, + {"available_memory_bytes", systemInfo.availableMemory}, + {"hardware_concurrency", std::thread::hardware_concurrency()}, + {"build_config", buildConfigName()} + }; + const auto testListDir = m_output.testListPath.empty() ? std::optional{} : std::optional{m_output.testListPath.parent_path()}; + const auto testListName = m_output.testListPath.empty() ? std::string{} : m_output.testListPath.filename().generic_string(); + root["inputs"] = { + {"test_list_name", testListName}, + {"row_view_enabled", m_runtime.rowViewEnabled}, + {"case_count", m_runtime.cases.size()} + }; + + root["cases"] = perf_json_t::array(); + for (const auto& metrics : m_perf.completedCases) + root["cases"].push_back(buildCaseJson(metrics, testListDir)); + root["totals"] = { + {"run_wall_ms", toMs(std::chrono::steady_clock::now() - m_perf.runStart)} + }; + + if (m_perf.options.referenceDir) + { + m_perf.referencePath = *m_perf.options.referenceDir / m_perf.workloadId / (m_perf.profileId + ".json"); + root["reference"]["lookup_key"] = m_perf.workloadId + "/" + m_perf.profileId + ".json"; + if (!m_perf.options.updateReference && std::filesystem::exists(m_perf.referencePath)) + { + m_perf.referenceMatched = true; + std::ifstream stream(m_perf.referencePath); + perf_json_t reference; + stream >> reference; + + if (!reference.contains("cases") || !reference["cases"].is_array()) + m_perf.comparisonFailures.push_back("Reference file does not contain a valid cases array."); + else + { + std::unordered_map referenceCases; + for (const auto& caseJson : reference["cases"]) + referenceCases.emplace(caseJson.value("name", ""), caseJson); + + if (referenceCases.size() != m_perf.completedCases.size()) + m_perf.comparisonFailures.push_back("Reference case count does not match the current run."); + + for (const auto& metrics : m_perf.completedCases) + { + const auto refIt = referenceCases.find(metrics.caseName); + if (refIt == referenceCases.end()) + { + m_perf.comparisonFailures.push_back(metrics.caseName + ": missing reference case."); + continue; + } + + const auto& refCase = refIt->second; + compareMetric(m_perf.comparisonFailures, metrics.caseName, "original_load.total_ms", metrics.originalLoad.totalMs, refCase["original_load"].value("total_ms", 0.0), 0.20, 5.0); + compareMetric(m_perf.comparisonFailures, metrics.caseName, "write.total_ms", metrics.write.totalMs, refCase["write"].value("total_ms", 0.0), 0.20, 5.0); + compareMetric(m_perf.comparisonFailures, metrics.caseName, "written_load.total_ms", metrics.writtenLoad.totalMs, refCase["written_load"].value("total_ms", 0.0), 0.20, 5.0); + + const bool refUsedMemoryTransport = refCase["write"].value("used_memory_transport", false); + if (metrics.write.valid && metrics.write.usedMemoryTransport != refUsedMemoryTransport) + m_perf.comparisonFailures.push_back(metrics.caseName + ": memory transport usage does not match the reference."); + } + } + } + } + root["reference"]["matched"] = m_perf.referenceMatched; + root["reference"]["strict"] = m_perf.options.strict; + root["reference"]["updated"] = m_perf.options.updateReference; + root["reference"]["comparison_failures"] = m_perf.comparisonFailures; + + if (m_perf.options.dumpDir) + { + const auto dumpDir = *m_perf.options.dumpDir / m_perf.workloadId; + std::filesystem::create_directories(dumpDir); + m_perf.dumpPath = dumpDir / (currentTimestampTag() + "__" + m_perf.profileId + ".json"); + if (!writePerfJson(m_system.get(), m_perf.dumpPath, root)) + failExit("Failed to write performance dump file: %s", m_perf.dumpPath.string().c_str()); + } + if (m_perf.options.updateReference) + { + if (!writePerfJson(m_system.get(), m_perf.referencePath, root)) + failExit("Failed to write performance reference file: %s", m_perf.referencePath.string().c_str()); + } + + if (m_logger) + { + if (m_perf.options.updateReference) + m_logger->log("Performance reference updated for workload=%s profile=%s.", ILogger::ELL_INFO, m_perf.workloadId.c_str(), m_perf.profileId.c_str()); + else if (!m_perf.referenceMatched) + m_logger->log("Performance reference not found for workload=%s profile=%s.", ILogger::ELL_INFO, m_perf.workloadId.c_str(), m_perf.profileId.c_str()); + else if (m_perf.comparisonFailures.empty()) + m_logger->log("Performance reference comparison passed for workload=%s profile=%s.", ILogger::ELL_INFO, m_perf.workloadId.c_str(), m_perf.profileId.c_str()); + else + for (const auto& failure : m_perf.comparisonFailures) + m_logger->log("%s", ILogger::ELL_ERROR, failure.c_str()); + } + + if (m_perf.options.strict && m_perf.referenceMatched && !m_perf.comparisonFailures.empty()) + failExit("Structured performance comparison failed."); +} diff --git a/12_MeshLoaders/AppRuntime.cpp b/12_MeshLoaders/AppRuntime.cpp new file mode 100644 index 000000000..f8a970cc0 --- /dev/null +++ b/12_MeshLoaders/AppRuntime.cpp @@ -0,0 +1,1015 @@ +// 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 "App.hpp" +#include "BundleGeometryItems.h" + +#include "nbl/examples/common/ImageComparison.h" +#include "nbl/builtin/hlsl/math/linalg/fast_affine.hlsl" +#include "nbl/system/CGrowableMemoryFile.h" + +#include + +namespace +{ + +bool persistMemoryFileToDisk(system::ISystem* const system, const system::path& path, const system::CGrowableMemoryFile* const file) +{ + if (!system || !file) + return false; + + system::ISystem::future_t> writeFileFuture; + system->createFile(writeFileFuture, path, system::IFile::ECF_WRITE); + core::smart_refctd_ptr writeFile; + writeFileFuture.acquire().move_into(writeFile); + if (!writeFile) + return false; + + const auto* const data = file->data(); + const auto size = file->getSize(); + system::IFile::success_t success; + writeFile->write(success, data, 0ull, size); + return static_cast(success); +} + +} + +std::string MeshLoadersApp::makeUniqueCaseName(const system::path& path) +{ + auto base = path.stem().string(); + if (base.empty()) + base = "case"; + auto& counter = m_runtime.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)); +} + +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); + return validateWrittenBundle(asset); +} + +bool MeshLoadersApp::validateWrittenBundle(const asset::SAssetBundle& bundle) +{ + core::vector geometries; + if (!meshloaders::collectBundleGeometryItems(bundle, geometries, false)) + return false; + return !geometries.empty(); +} + +bool MeshLoadersApp::startBackgroundAssetWorker() +{ + auto& worker = m_backgroundAssetWorker; + if (worker.thread.joinable()) + return true; + + worker.stop = false; + worker.thread = std::thread(&MeshLoadersApp::backgroundAssetWorkerMain, this); + return true; +} + +void MeshLoadersApp::stopBackgroundAssetWorker() +{ + auto& worker = m_backgroundAssetWorker; + { + std::lock_guard lock(worker.mutex); + worker.stop = true; + } + worker.cv.notify_all(); + if (worker.thread.joinable()) + worker.thread.join(); + + std::lock_guard lock(worker.mutex); + worker.request.reset(); + worker.result.reset(); + worker.busy = false; + worker.stop = false; +} + +bool MeshLoadersApp::startBackgroundLoadWorker() +{ + auto& worker = m_backgroundLoadWorker; + if (worker.thread.joinable()) + return true; + + worker.stop = false; + worker.thread = std::thread(&MeshLoadersApp::backgroundLoadWorkerMain, this); + return true; +} + +void MeshLoadersApp::stopBackgroundLoadWorker() +{ + auto& worker = m_backgroundLoadWorker; + { + std::lock_guard lock(worker.mutex); + worker.stop = true; + } + worker.cv.notify_all(); + if (worker.thread.joinable()) + worker.thread.join(); + + std::lock_guard lock(worker.mutex); + worker.requestCaseIndex.reset(); + worker.requestPath.clear(); + worker.result.reset(); + worker.busy = false; + worker.stop = false; +} + +void MeshLoadersApp::backgroundAssetWorkerMain() +{ + auto workerSystem = core::smart_refctd_ptr(m_system); + auto workerAssetMgr = workerSystem ? core::make_smart_refctd_ptr(core::smart_refctd_ptr(workerSystem)) : nullptr; + + for (;;) + { + WrittenAssetRequest request = {}; + { + std::unique_lock lock(m_backgroundAssetWorker.mutex); + m_backgroundAssetWorker.cv.wait(lock, [this] { + return m_backgroundAssetWorker.stop || m_backgroundAssetWorker.request.has_value(); + }); + if (m_backgroundAssetWorker.stop && !m_backgroundAssetWorker.request.has_value()) + break; + request = std::move(*m_backgroundAssetWorker.request); + m_backgroundAssetWorker.request.reset(); + } + + WrittenAssetResult result = {}; + result.path = request.path; + result.extension = normalizeExtension(request.path); + + if (!workerSystem || !workerAssetMgr) + { + result.error = "Background asset worker is unavailable."; + } + else if (!request.asset) + { + result.error = "Background asset worker received an empty asset."; + } + else + { + using clock_t = std::chrono::high_resolution_clock; + const auto writeOuterStart = clock_t::now(); + auto* const assetPtr = const_cast(request.asset.get()); + const auto flags = getWriterFlagsForPath(request.asset.get(), request.path); + IAssetWriter::SAssetWriteParams writeParams{ assetPtr, flags }; + writeParams.logger = request.loadParams.logger; + + bool useDiskTransport = !request.useMemoryTransport; + result.usedMemoryTransport = request.useMemoryTransport; + if (request.useMemoryTransport) + { + auto memoryFile = core::make_smart_refctd_ptr(system::path(request.path)); + bool memoryTransportSucceeded = false; + + if (memoryFile) + { + const auto writeStart = clock_t::now(); + if (!workerAssetMgr->writeAsset(memoryFile.get(), writeParams)) + { + if (request.allowDiskFallback) + useDiskTransport = true; + else + result.error = "Background asset worker failed to write the asset to the in-memory transport."; + } + else + { + result.writeMs = toMs(clock_t::now() - writeStart); + result.outputSize = memoryFile->getSize(); + result.totalWriteMs = toMs(clock_t::now() - writeOuterStart); + result.nonWriterMs = std::max(0.0, result.totalWriteMs - result.writeMs); + + workerAssetMgr->clearAllAssetCache(); + result.loadResult.inputSize = memoryFile->getSize(); + const auto loadStart = clock_t::now(); + result.loadResult.bundle = workerAssetMgr->getAsset(memoryFile.get(), request.path.string(), request.loadParams); + result.loadResult.getAssetMs = toMs(clock_t::now() - loadStart); + if (result.loadResult.bundle.getContents().empty()) + { + if (request.allowDiskFallback) + useDiskTransport = true; + else + result.error = "Background asset worker failed to load the in-memory written asset."; + } + else + memoryTransportSucceeded = true; + } + + if (memoryTransportSucceeded && request.persistDiskArtifact) + { + if (!persistMemoryFileToDisk(workerSystem.get(), request.path, memoryFile.get())) + { + if (request.allowDiskFallback) + { + useDiskTransport = true; + result.usedDiskFallback = true; + } + else + result.error = "Background asset worker failed to persist the in-memory written asset."; + if (useDiskTransport) + { + result.loadResult = {}; + result.outputSize = 0u; + result.totalWriteMs = 0.0; + result.nonWriterMs = 0.0; + } + } + else + result.persistedDiskArtifact = true; + } + } + else + { + if (request.allowDiskFallback) + { + useDiskTransport = true; + result.usedDiskFallback = true; + } + else + result.error = "Background asset worker could not create the in-memory transport."; + } + } + + if (useDiskTransport && result.error.empty()) + { + result.usedDiskFallback = result.usedDiskFallback || request.useMemoryTransport; + result.persistedDiskArtifact = true; + const auto openStart = clock_t::now(); + system::ISystem::future_t> writeFileFuture; + workerSystem->createFile(writeFileFuture, request.path, system::IFile::ECF_WRITE); + core::smart_refctd_ptr writeFile; + writeFileFuture.acquire().move_into(writeFile); + result.openMs = toMs(clock_t::now() - openStart); + if (!writeFile) + { + result.error = "Background asset worker failed to open the output file."; + } + else + { + const auto writeStart = clock_t::now(); + if (!workerAssetMgr->writeAsset(writeFile.get(), writeParams)) + { + result.error = "Background asset worker failed to write the asset."; + } + result.writeMs = toMs(clock_t::now() - writeStart); + writeFile = nullptr; + } + + if (result.error.empty()) + { + const auto statStart = clock_t::now(); + if (std::filesystem::exists(request.path)) + result.outputSize = std::filesystem::file_size(request.path); + result.statMs = toMs(clock_t::now() - statStart); + result.totalWriteMs = toMs(clock_t::now() - writeOuterStart); + result.nonWriterMs = std::max(0.0, result.totalWriteMs - result.writeMs); + + workerAssetMgr->clearAllAssetCache(); + if (std::filesystem::exists(request.path)) + result.loadResult.inputSize = std::filesystem::file_size(request.path); + else + result.loadResult.inputSize = 0u; + + const auto loadStart = clock_t::now(); + result.loadResult.bundle = workerAssetMgr->getAsset(request.path.string(), request.loadParams); + result.loadResult.getAssetMs = toMs(clock_t::now() - loadStart); + if (result.loadResult.bundle.getContents().empty()) + result.error = "Background asset worker failed to load the written asset."; + } + } + } + + result.success = result.error.empty(); + { + std::lock_guard lock(m_backgroundAssetWorker.mutex); + m_backgroundAssetWorker.result = std::move(result); + m_backgroundAssetWorker.busy = false; + } + m_backgroundAssetWorker.cv.notify_all(); + } +} + +void MeshLoadersApp::backgroundLoadWorkerMain() +{ + auto workerSystem = core::smart_refctd_ptr(m_system); + auto workerAssetMgr = workerSystem ? core::make_smart_refctd_ptr(core::smart_refctd_ptr(workerSystem)) : nullptr; + + for (;;) + { + std::optional caseIndex; + system::path requestPath; + IAssetLoader::SAssetLoadParams requestParams = {}; + { + std::unique_lock lock(m_backgroundLoadWorker.mutex); + m_backgroundLoadWorker.cv.wait(lock, [this] { + return m_backgroundLoadWorker.stop || m_backgroundLoadWorker.requestCaseIndex.has_value(); + }); + if (m_backgroundLoadWorker.stop && !m_backgroundLoadWorker.requestCaseIndex.has_value()) + break; + caseIndex = m_backgroundLoadWorker.requestCaseIndex; + requestPath = m_backgroundLoadWorker.requestPath; + requestParams = m_backgroundLoadWorker.requestParams; + m_backgroundLoadWorker.requestCaseIndex.reset(); + m_backgroundLoadWorker.requestPath.clear(); + } + + PreparedAssetLoad result = {}; + result.caseIndex = *caseIndex; + result.path = requestPath; + if (!workerAssetMgr) + { + result.error = "Background load worker is unavailable."; + } + else if (!std::filesystem::exists(requestPath)) + { + result.error = "Background load worker did not find the requested input."; + } + else + { + workerAssetMgr->clearAllAssetCache(); + result.loadResult.inputSize = std::filesystem::file_size(requestPath); + const auto loadStart = std::chrono::high_resolution_clock::now(); + result.loadResult.bundle = workerAssetMgr->getAsset(requestPath.string(), requestParams); + result.loadResult.getAssetMs = toMs(std::chrono::high_resolution_clock::now() - loadStart); + if (result.loadResult.bundle.getContents().empty()) + result.error = "Background load worker failed to load the requested asset."; + } + result.success = result.error.empty(); + { + std::lock_guard lock(m_backgroundLoadWorker.mutex); + m_backgroundLoadWorker.result = std::move(result); + m_backgroundLoadWorker.busy = false; + } + m_backgroundLoadWorker.cv.notify_all(); + } +} + +bool MeshLoadersApp::startWrittenAssetWork(smart_refctd_ptr asset, const system::path& path) +{ + if (!asset || path.empty()) + return false; + if (!startBackgroundAssetWorker()) + return false; + + std::unique_lock lock(m_backgroundAssetWorker.mutex); + if (m_backgroundAssetWorker.busy || m_backgroundAssetWorker.request.has_value() || m_backgroundAssetWorker.result.has_value()) + return false; + + m_backgroundAssetWorker.request = WrittenAssetRequest{ + .asset = std::move(asset), + .path = path, + .loadParams = makeLoadParams(), + .useMemoryTransport = true, + .allowDiskFallback = (m_runtime.mode != RunMode::CI), + .persistDiskArtifact = (m_runtime.mode != RunMode::CI) + }; + m_backgroundAssetWorker.busy = true; + lock.unlock(); + m_backgroundAssetWorker.cv.notify_one(); + return true; +} + +bool MeshLoadersApp::finalizeWrittenAssetWork(WrittenAssetResult& result, bool& ready, bool waitForCompletion) +{ + ready = false; + if (!m_backgroundAssetWorker.thread.joinable()) + return false; + + std::unique_lock lock(m_backgroundAssetWorker.mutex); + if (waitForCompletion) + { + m_backgroundAssetWorker.cv.wait(lock, [this] { + return !m_backgroundAssetWorker.busy && !m_backgroundAssetWorker.request.has_value(); + }); + } + if (m_backgroundAssetWorker.result.has_value()) + { + result = std::move(*m_backgroundAssetWorker.result); + m_backgroundAssetWorker.result.reset(); + ready = true; + return true; + } + return m_backgroundAssetWorker.busy || m_backgroundAssetWorker.request.has_value(); +} + +bool MeshLoadersApp::startPreparedAssetLoad(const size_t caseIndex, const system::path& path) +{ + if (path.empty()) + return false; + if (!startBackgroundLoadWorker()) + return false; + + std::unique_lock lock(m_backgroundLoadWorker.mutex); + if (m_backgroundLoadWorker.busy || m_backgroundLoadWorker.requestCaseIndex.has_value()) + return false; + + m_backgroundLoadWorker.result.reset(); + m_backgroundLoadWorker.requestCaseIndex = caseIndex; + m_backgroundLoadWorker.requestPath = path; + m_backgroundLoadWorker.requestParams = makeLoadParams(); + m_backgroundLoadWorker.busy = true; + lock.unlock(); + m_backgroundLoadWorker.cv.notify_one(); + return true; +} + +bool MeshLoadersApp::finalizePreparedAssetLoad(PreparedAssetLoad& result, bool& ready, bool waitForCompletion) +{ + ready = false; + if (!m_backgroundLoadWorker.thread.joinable()) + return false; + + std::unique_lock lock(m_backgroundLoadWorker.mutex); + if (waitForCompletion) + { + m_backgroundLoadWorker.cv.wait(lock, [this] { + return !m_backgroundLoadWorker.busy && !m_backgroundLoadWorker.requestCaseIndex.has_value(); + }); + } + if (m_backgroundLoadWorker.result.has_value()) + { + result = std::move(*m_backgroundLoadWorker.result); + m_backgroundLoadWorker.result.reset(); + ready = true; + return true; + } + return m_backgroundLoadWorker.busy || m_backgroundLoadWorker.requestCaseIndex.has_value(); +} + +void MeshLoadersApp::logWrittenAssetWork(const WrittenAssetResult& result) const +{ + m_logger->log( + "Asset write call perf: path=%s ext=%s time=%.3f ms size=%llu", + ILogger::ELL_INFO, + result.path.string().c_str(), + result.extension.c_str(), + result.writeMs, + static_cast(result.outputSize)); + 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, + result.path.string().c_str(), + result.extension.c_str(), + result.openMs, + result.writeMs, + result.statMs, + result.totalWriteMs, + result.nonWriterMs, + static_cast(result.outputSize)); + m_logger->log( + "Writer perf: path=%s ext=%s time=%.3f ms size=%llu", + ILogger::ELL_INFO, + result.path.string().c_str(), + result.extension.c_str(), + result.writeMs, + static_cast(result.outputSize)); + m_logger->log("Mesh successfully saved!", ILogger::ELL_INFO); +} + +bool MeshLoadersApp::requestScreenshotCapture(const system::path& path) +{ + if (!m_device || !m_surface || !m_assetMgr) + return false; + if (m_render.pendingScreenshot.active()) + return false; + + auto* const scRes = static_cast(m_surface->getSwapchainResources()); + auto* const 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 gpuImage = colorView->getCreationParameters().image; + if (!gpuImage) + return false; + + const auto imageParams = gpuImage->getCreationParameters(); + if (!imageParams.usage.hasFlags(asset::IImage::EUF_TRANSFER_SRC_BIT)) + return false; + if (asset::isBlockCompressionFormat(imageParams.format)) + return false; + + auto commandPool = m_device->createCommandPool(getGraphicsQueue()->getFamilyIndex(), IGPUCommandPool::CREATE_FLAGS::TRANSIENT_BIT); + if (!commandPool) + return false; + + core::smart_refctd_ptr commandBuffer; + if (!commandPool->createCommandBuffers(IGPUCommandPool::BUFFER_LEVEL::PRIMARY, { &commandBuffer, 1u }) || !commandBuffer) + return false; + if (!commandBuffer->begin(IGPUCommandBuffer::USAGE::ONE_TIME_SUBMIT_BIT)) + return false; + + const auto imageViewParams = colorView->getCreationParameters(); + const auto extent = gpuImage->getMipSize(); + + IGPUImage::SBufferCopy copyRegion = {}; + copyRegion.imageSubresource.aspectMask = imageViewParams.subresourceRange.aspectMask; + copyRegion.imageSubresource.mipLevel = imageViewParams.subresourceRange.baseMipLevel; + copyRegion.imageSubresource.baseArrayLayer = imageViewParams.subresourceRange.baseArrayLayer; + copyRegion.imageSubresource.layerCount = imageViewParams.subresourceRange.layerCount; + copyRegion.imageExtent = { extent.x, extent.y, extent.z }; + + IGPUBuffer::SCreationParams bufferParams = {}; + bufferParams.size = static_cast(extent.x) * static_cast(extent.y) * static_cast(extent.z) * asset::getTexelOrBlockBytesize(imageParams.format); + bufferParams.usage = video::IGPUBuffer::EUF_TRANSFER_DST_BIT; + auto texelBuffer = m_device->createBuffer(std::move(bufferParams)); + if (!texelBuffer) + return false; + + auto texelBufferMemReqs = texelBuffer->getMemoryReqs(); + texelBufferMemReqs.memoryTypeBits &= m_device->getPhysicalDevice()->getDownStreamingMemoryTypeBits(); + if (!texelBufferMemReqs.memoryTypeBits) + return false; + auto texelBufferMemory = m_device->allocate(texelBufferMemReqs, texelBuffer.get()); + if (!texelBufferMemory.isValid()) + return false; + + IGPUCommandBuffer::SPipelineBarrierDependencyInfo dependencyInfo = {}; + IGPUCommandBuffer::SPipelineBarrierDependencyInfo::image_barrier_t imageBarrier = {}; + dependencyInfo.imgBarriers = { &imageBarrier, &imageBarrier + 1 }; + + imageBarrier.barrier.dep.srcStageMask = PIPELINE_STAGE_FLAGS::ALL_COMMANDS_BITS; + imageBarrier.barrier.dep.srcAccessMask = asset::ACCESS_FLAGS::COLOR_ATTACHMENT_WRITE_BIT; + imageBarrier.barrier.dep.dstStageMask = PIPELINE_STAGE_FLAGS::COPY_BIT; + imageBarrier.barrier.dep.dstAccessMask = asset::ACCESS_FLAGS::TRANSFER_READ_BIT; + imageBarrier.oldLayout = asset::IImage::LAYOUT::PRESENT_SRC; + imageBarrier.newLayout = asset::IImage::LAYOUT::TRANSFER_SRC_OPTIMAL; + imageBarrier.image = gpuImage.get(); + imageBarrier.subresourceRange.aspectMask = imageViewParams.subresourceRange.aspectMask; + imageBarrier.subresourceRange.baseMipLevel = imageViewParams.subresourceRange.baseMipLevel; + imageBarrier.subresourceRange.levelCount = 1u; + imageBarrier.subresourceRange.baseArrayLayer = imageViewParams.subresourceRange.baseArrayLayer; + imageBarrier.subresourceRange.layerCount = imageViewParams.subresourceRange.layerCount; + commandBuffer->pipelineBarrier(asset::EDF_NONE, dependencyInfo); + + commandBuffer->copyImageToBuffer(gpuImage.get(), asset::IImage::LAYOUT::TRANSFER_SRC_OPTIMAL, texelBuffer.get(), 1u, ©Region); + + imageBarrier.barrier.dep.srcStageMask = imageBarrier.barrier.dep.dstStageMask; + imageBarrier.barrier.dep.srcAccessMask = asset::ACCESS_FLAGS::NONE; + imageBarrier.barrier.dep.dstStageMask = PIPELINE_STAGE_FLAGS::ALL_COMMANDS_BITS; + imageBarrier.barrier.dep.dstAccessMask = asset::ACCESS_FLAGS::NONE; + imageBarrier.oldLayout = imageBarrier.newLayout; + imageBarrier.newLayout = asset::IImage::LAYOUT::PRESENT_SRC; + commandBuffer->pipelineBarrier(asset::EDF_NONE, dependencyInfo); + + if (!commandBuffer->end()) + return false; + + auto completionSemaphore = m_device->createSemaphore(0u); + if (!completionSemaphore) + return false; + + IQueue::SSubmitInfo::SCommandBufferInfo commandBufferInfo = { .cmdbuf = commandBuffer.get() }; + IQueue::SSubmitInfo::SSemaphoreInfo waitInfo = { + .semaphore = m_render.semaphore.get(), + .value = m_render.realFrameIx, + .stageMask = PIPELINE_STAGE_FLAGS::COPY_BIT + }; + IQueue::SSubmitInfo::SSemaphoreInfo signalInfo = { + .semaphore = completionSemaphore.get(), + .value = 1u, + .stageMask = PIPELINE_STAGE_FLAGS::COPY_BIT + }; + IQueue::SSubmitInfo submitInfo = { + .waitSemaphores = { &waitInfo, 1u }, + .commandBuffers = { &commandBufferInfo, 1u }, + .signalSemaphores = { &signalInfo, 1u } + }; + if (getGraphicsQueue()->submit({ &submitInfo, 1u }) != IQueue::RESULT::SUCCESS) + return false; + + m_render.pendingScreenshot.path = path; + m_render.pendingScreenshot.sourceView = std::move(colorView); + m_render.pendingScreenshot.commandBuffer = std::move(commandBuffer); + m_render.pendingScreenshot.texelBuffer = std::move(texelBuffer); + m_render.pendingScreenshot.completionSemaphore = std::move(completionSemaphore); + m_render.pendingScreenshot.imageParams = imageParams; + m_render.pendingScreenshot.subresourceRange = imageViewParams.subresourceRange; + m_render.pendingScreenshot.viewFormat = imageViewParams.format; + m_render.pendingScreenshot.completionValue = 1u; + return true; +} + +bool MeshLoadersApp::finalizeScreenshotCapture(core::smart_refctd_ptr& outImage, bool& ready, bool waitForCompletion) +{ + ready = false; + if (!m_render.pendingScreenshot.active()) + return false; + + if (waitForCompletion) + { + const ISemaphore::SWaitInfo waitInfo = { + .semaphore = m_render.pendingScreenshot.completionSemaphore.get(), + .value = m_render.pendingScreenshot.completionValue + }; + if (m_device->blockForSemaphores({ &waitInfo, 1u }) != ISemaphore::WAIT_RESULT::SUCCESS) + { + m_render.pendingScreenshot = {}; + return false; + } + } + + if (m_render.pendingScreenshot.completionSemaphore->getCounterValue() < m_render.pendingScreenshot.completionValue) + return true; + + const auto texelBufferSize = m_render.pendingScreenshot.texelBuffer->getSize(); + auto* const allocation = m_render.pendingScreenshot.texelBuffer->getBoundMemory().memory; + if (!allocation) + { + m_render.pendingScreenshot = {}; + return false; + } + + bool mappedHere = false; + if (!allocation->getMappedPointer()) + { + const IDeviceMemoryAllocation::MemoryRange range = { 0u, texelBufferSize }; + if (!allocation->map(range, IDeviceMemoryAllocation::EMCAF_READ)) + { + m_render.pendingScreenshot = {}; + return false; + } + mappedHere = true; + } + + if (allocation->haveToMakeVisible()) + { + const ILogicalDevice::MappedMemoryRange mappedRange(allocation, 0u, texelBufferSize); + m_device->invalidateMappedMemoryRanges(1u, &mappedRange); + } + + auto cpuImage = asset::ICPUImage::create(m_render.pendingScreenshot.imageParams); + auto cpuBuffer = asset::ICPUBuffer::create({ texelBufferSize }); + if (!cpuImage || !cpuBuffer) + { + if (mappedHere) + allocation->unmap(); + m_render.pendingScreenshot = {}; + return false; + } + + std::memcpy(cpuBuffer->getPointer(), allocation->getMappedPointer(), texelBufferSize); + + auto regions = core::make_refctd_dynamic_array>(1u); + auto& region = regions->front(); + region.imageSubresource.aspectMask = asset::IImage::EAF_COLOR_BIT; + region.imageSubresource.mipLevel = 0u; + region.imageSubresource.baseArrayLayer = 0u; + region.imageSubresource.layerCount = 1u; + region.bufferOffset = 0u; + region.bufferRowLength = m_render.pendingScreenshot.imageParams.extent.width; + region.bufferImageHeight = 0u; + region.imageOffset = { 0u, 0u, 0u }; + region.imageExtent = m_render.pendingScreenshot.imageParams.extent; + cpuImage->setBufferAndRegions(core::smart_refctd_ptr(cpuBuffer), regions); + + if (mappedHere) + allocation->unmap(); + + asset::ICPUImageView::SCreationParams viewParams = {}; + viewParams.image = std::move(cpuImage); + viewParams.format = m_render.pendingScreenshot.viewFormat; + viewParams.viewType = asset::ICPUImageView::ET_2D; + viewParams.subresourceRange = m_render.pendingScreenshot.subresourceRange; + + auto cpuView = asset::ICPUImageView::create(std::move(viewParams)); + if (!cpuView) + { + m_render.pendingScreenshot = {}; + return false; + } + + if (!m_render.pendingScreenshot.path.empty()) + { + const auto parentPath = m_render.pendingScreenshot.path.parent_path(); + if (!parentPath.empty()) + std::filesystem::create_directories(parentPath); + IAssetWriter::SAssetWriteParams params(cpuView.get()); + if (!m_assetMgr->writeAsset(m_render.pendingScreenshot.path.string(), params)) + { + m_render.pendingScreenshot = {}; + return false; + } + } + + outImage = std::move(cpuView); + ready = true; + m_render.pendingScreenshot = {}; + return true; +} + +bool MeshLoadersApp::compareImages( + const asset::ICPUImageView* a, + const asset::ICPUImageView* b, + uint64_t& diffCodeUnitCount, + uint32_t& maxDiffCodeUnitValue) +{ + return nbl::examples::image::compareCpuImageViewsByCodeUnit(a, b, diffCodeUnitCount, maxDiffCodeUnitValue); +} + +void MeshLoadersApp::advanceCase() +{ + if (m_runtime.mode == RunMode::Interactive || m_runtime.cases.empty()) + return; + if (isRowViewActive()) + return; + + const auto finalizePendingCapture = [this](core::smart_refctd_ptr& outImage, const char* const failureMessage, const bool waitForCompletion = false) -> bool + { + bool ready = false; + if (!finalizeScreenshotCapture(outImage, ready, waitForCompletion)) + failExit("%s", failureMessage); + return ready; + }; + + const auto handleWrittenAssetReady = [this](WrittenAssetResult&& result) -> void + { + if (!result.success) + failExit("%s", result.error.c_str()); + logWrittenAssetWork(result); + if (performanceEnabled()) + recordWriteMetrics(result); + + if (m_runtime.mode == RunMode::CI) + { + LoadStageMetrics writtenLoadMetrics = {}; + if (!loadPreparedModel(m_output.writtenPath, std::move(result.loadResult), false, false, &writtenLoadMetrics)) + failExit("Failed to load written asset %s.", m_output.writtenPath.string().c_str()); + if (performanceEnabled() && writtenLoadMetrics.valid) + recordWrittenLoadMetrics(writtenLoadMetrics); + if (!m_render.currentCpuGeom) + failExit("Written geometry missing."); + m_runtime.phase = Phase::RenderWritten; + m_runtime.phaseFrameCounter = 0u; + return; + } + + if (!validateWrittenBundle(result.loadResult.bundle)) + failExit("Failed to load written asset %s.", m_output.writtenPath.string().c_str()); + + advanceToNextCase(); + }; + + if (m_runtime.mode == RunMode::CI && m_runtime.phase == Phase::RenderOriginal) + { + ++m_runtime.phaseFrameCounter; + if (m_runtime.phaseFrameCounter == 0u) + return; + + if (!requestScreenshotCapture(m_output.loadedScreenshotPath)) + failExit("Failed to request loaded screenshot capture."); + if (!finalizePendingCapture(m_render.loadedScreenshot, "Failed to finalize loaded screenshot.", true)) + failExit("Loaded screenshot capture did not complete."); + + const bool canWriteCurrentAsset = m_output.saveGeom && static_cast(m_render.currentCpuAsset); + if (m_output.saveGeom && !canWriteCurrentAsset) + m_logger->log("Skipping write/reload for %s because the loaded case expands to multiple root geometries.", ILogger::ELL_INFO, m_caseName.c_str()); + if (!canWriteCurrentAsset) + { + advanceToNextCase(); + return; + } + + WrittenAssetResult result = {}; + bool ready = false; + if (!finalizeWrittenAssetWork(result, ready, true) || !ready) + failExit("Written asset preparation did not complete."); + handleWrittenAssetReady(std::move(result)); + return; + } + + if (m_runtime.mode == RunMode::CI && m_runtime.phase == Phase::RenderWritten) + { + ++m_runtime.phaseFrameCounter; + if (m_runtime.phaseFrameCounter == 0u) + return; + + if (!requestScreenshotCapture(m_output.writtenScreenshotPath)) + failExit("Failed to request written screenshot capture."); + if (!finalizePendingCapture(m_render.writtenScreenshot, "Failed to finalize written screenshot.", true)) + failExit("Written screenshot capture did not complete."); + + uint64_t diffCodeUnitCount = 0u; + uint32_t maxDiffCodeUnitValue = 0u; + if (!compareImages(m_render.loadedScreenshot.get(), m_render.writtenScreenshot.get(), diffCodeUnitCount, maxDiffCodeUnitValue)) + failExit("Image compare failed for %s.", m_caseName.c_str()); + if (diffCodeUnitCount > MaxImageDiffCodeUnits || maxDiffCodeUnitValue > MaxImageDiffCodeUnitValue) + failExit("Image diff detected for %s. CodeUnits: %llu MaxCodeUnitDiff: %u", m_caseName.c_str(), static_cast(diffCodeUnitCount), maxDiffCodeUnitValue); + if (diffCodeUnitCount != 0u) + m_logger->log("Image diff within tolerance for %s. CodeUnits: %llu MaxCodeUnitDiff: %u", ILogger::ELL_WARNING, m_caseName.c_str(), static_cast(diffCodeUnitCount), maxDiffCodeUnitValue); + + advanceToNextCase(); + return; + } + + if (m_runtime.phase == Phase::CaptureOriginalPending) + { + if (!finalizePendingCapture(m_render.loadedScreenshot, "Failed to finalize loaded screenshot.")) + return; + + const bool canWriteCurrentAsset = m_output.saveGeom && static_cast(m_render.currentCpuAsset); + if (m_output.saveGeom) + { + if (!canWriteCurrentAsset) + m_logger->log("Skipping write/reload for %s because the loaded case expands to multiple root geometries.", ILogger::ELL_INFO, m_caseName.c_str()); + else + { + WrittenAssetResult result = {}; + bool ready = false; + const bool workerStateValid = finalizeWrittenAssetWork(result, ready); + if (!workerStateValid) + { + if (m_runtime.mode == RunMode::CI) + failExit("Background written asset preparation is unavailable."); + WriteStageMetrics writeMetrics = {}; + if (!writeAssetRoot(m_render.currentCpuAsset, m_output.writtenPath.string(), &writeMetrics)) + failExit("Geometry write failed."); + if (performanceEnabled() && writeMetrics.valid) + recordWriteMetrics(writeMetrics); + + if (m_runtime.mode == RunMode::CI) + { + LoadStageMetrics writtenLoadMetrics = {}; + if (!loadModel(m_output.writtenPath, false, false, &writtenLoadMetrics)) + failExit("Failed to load written asset %s.", m_output.writtenPath.string().c_str()); + if (performanceEnabled() && writtenLoadMetrics.valid) + recordWrittenLoadMetrics(writtenLoadMetrics); + if (!m_render.currentCpuGeom) + failExit("Written geometry missing."); + m_runtime.phase = Phase::RenderWritten; + m_runtime.phaseFrameCounter = 0u; + return; + } + + if (!validateWrittenAsset(m_output.writtenPath)) + failExit("Failed to load written asset %s.", m_output.writtenPath.string().c_str()); + + advanceToNextCase(); + return; + } + + if (!ready) + { + m_runtime.phase = Phase::WrittenAssetPending; + return; + } + + handleWrittenAssetReady(std::move(result)); + return; + } + } + + if (m_runtime.mode == RunMode::CI) + { + if (!canWriteCurrentAsset) + { + advanceToNextCase(); + return; + } + if (!loadModel(m_output.writtenPath, false, false)) + failExit("Failed to load written asset %s.", m_output.writtenPath.string().c_str()); + if (!m_render.currentCpuGeom) + failExit("Written geometry missing."); + m_runtime.phase = Phase::RenderWritten; + m_runtime.phaseFrameCounter = 0u; + return; + } + + if (canWriteCurrentAsset) + { + if (!validateWrittenAsset(m_output.writtenPath)) + failExit("Failed to load written asset %s.", m_output.writtenPath.string().c_str()); + } + + advanceToNextCase(); + return; + } + + if (m_runtime.phase == Phase::WrittenAssetPending) + { + WrittenAssetResult result = {}; + bool ready = false; + if (!finalizeWrittenAssetWork(result, ready)) + failExit("Background written asset work failed unexpectedly."); + if (!ready) + return; + handleWrittenAssetReady(std::move(result)); + return; + } + + if (m_runtime.phase == Phase::CaptureWrittenPending) + { + if (!finalizePendingCapture(m_render.writtenScreenshot, "Failed to finalize written screenshot.")) + return; + + uint64_t diffCodeUnitCount = 0u; + uint32_t maxDiffCodeUnitValue = 0u; + if (!compareImages(m_render.loadedScreenshot.get(), m_render.writtenScreenshot.get(), diffCodeUnitCount, maxDiffCodeUnitValue)) + failExit("Image compare failed for %s.", m_caseName.c_str()); + if (diffCodeUnitCount > MaxImageDiffCodeUnits || maxDiffCodeUnitValue > MaxImageDiffCodeUnitValue) + failExit("Image diff detected for %s. CodeUnits: %llu MaxCodeUnitDiff: %u", m_caseName.c_str(), static_cast(diffCodeUnitCount), maxDiffCodeUnitValue); + if (diffCodeUnitCount != 0u) + m_logger->log("Image diff within tolerance for %s. CodeUnits: %llu MaxCodeUnitDiff: %u", ILogger::ELL_WARNING, m_caseName.c_str(), static_cast(diffCodeUnitCount), maxDiffCodeUnitValue); + + advanceToNextCase(); + return; + } + + const uint32_t frameLimit = m_runtime.mode == RunMode::CI ? CiFramesBeforeCapture : NonCiFramesPerCase; + const uint32_t captureRequestThreshold = (frameLimit > 1u) ? (frameLimit - 1u) : frameLimit; + ++m_runtime.phaseFrameCounter; + if (m_runtime.phaseFrameCounter < captureRequestThreshold) + return; + + if (m_runtime.phase == Phase::RenderOriginal) + { + if (!requestScreenshotCapture(m_output.loadedScreenshotPath)) + failExit("Failed to request loaded screenshot capture."); + m_runtime.phase = Phase::CaptureOriginalPending; + return; + } + + if (m_runtime.phase == Phase::RenderWritten) + { + if (!requestScreenshotCapture(m_output.writtenScreenshotPath)) + failExit("Failed to request written screenshot capture."); + m_runtime.phase = Phase::CaptureWrittenPending; + } +} diff --git a/12_MeshLoaders/BundleGeometryItems.h b/12_MeshLoaders/BundleGeometryItems.h new file mode 100644 index 000000000..e0b801af4 --- /dev/null +++ b/12_MeshLoaders/BundleGeometryItems.h @@ -0,0 +1,57 @@ +#ifndef _NBL_EXAMPLES_12_MESHLOADERS_BUNDLE_GEOMETRY_ITEMS_H_INCLUDED_ +#define _NBL_EXAMPLES_12_MESHLOADERS_BUNDLE_GEOMETRY_ITEMS_H_INCLUDED_ + +#include "include/common.hpp" + +#include "nbl/asset/interchange/SGeometryWriterCommon.h" + +namespace meshloaders +{ + +struct BundleGeometryItem +{ + core::smart_refctd_ptr geometry; + hlsl::float32_t3x4 world = hlsl::math::linalg::identity(); +}; + +inline bool collectBundleGeometryItems( + const asset::SAssetBundle& bundle, + core::vector& out, + const bool preserveTransforms) +{ + if (bundle.getContents().empty()) + return false; + + switch (bundle.getAssetType()) + { + case IAsset::E_TYPE::ET_GEOMETRY: + case IAsset::E_TYPE::ET_GEOMETRY_COLLECTION: + case IAsset::E_TYPE::ET_SCENE: + break; + default: + return false; + } + + const auto identity = hlsl::math::linalg::identity(); + const bool useItemTransforms = preserveTransforms && bundle.getAssetType() == IAsset::E_TYPE::ET_SCENE; + for (const auto& root : bundle.getContents()) + { + if (!root) + continue; + const auto items = asset::SGeometryWriterCommon::collectPolygonGeometryWriteItems(root.get()); + for (const auto& item : items) + { + if (!item.geometry) + continue; + out.push_back({ + .geometry = core::smart_refctd_ptr(const_cast(item.geometry)), + .world = useItemTransforms ? item.transform : identity + }); + } + } + return !out.empty(); +} + +} + +#endif diff --git a/12_MeshLoaders/CMakeLists.txt b/12_MeshLoaders/CMakeLists.txt index 709b7d40b..c92f6a034 100644 --- a/12_MeshLoaders/CMakeLists.txt +++ b/12_MeshLoaders/CMakeLists.txt @@ -1,10 +1,34 @@ -set(NBL_INCLUDE_SERACH_DIRECTORIES +set(SRCs + main.cpp + App.hpp + BundleGeometryItems.h + AppLifecycle.cpp + AppLoad.cpp + AppPerf.cpp + AppRuntime.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) +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_MESHLOADERS_MEDIA_PAYLOAD_RELATIVE_PATH "inputs_benchmark.json" CACHE STRING "Preferred benchmark payload relative path inside examples_tests/media.") +set(NBL_MESHLOADERS_PERF_DUMP_DIR "${CMAKE_BINARY_DIR}/meshloaders_perf_runs" CACHE PATH "Directory where 12_MeshLoaders writes structured performance run artifacts.") +set(NBL_MESHLOADERS_PERF_REFERENCE_DIR "" CACHE PATH "Directory containing structured performance reference artifacts for 12_MeshLoaders.") + +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,15 +36,101 @@ 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}") +target_compile_definitions(${EXECUTABLE_NAME} PRIVATE NBL_MESHLOADERS_BUILD_CONFIG="$") if (NBL_BUILD_DEBUG_DRAW) target_link_libraries(${EXECUTABLE_NAME} PRIVATE Nabla::ext::DebugDraw) 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 "4e8af0a9b706aca95ae16f04733f5c2bed6fdb9d" + 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) + if (POLICY CMP0169) + cmake_policy(PUSH) + cmake_policy(SET CMP0169 OLD) + endif() + FetchContent_Populate(nbl_meshloaders_benchmark_dataset) + if (POLICY CMP0169) + cmake_policy(POP) + endif() + endif () + endif () + endif () + if (NOT EXISTS "${NBL_MESHLOADERS_BENCHMARK_INPUTS_JSON}") + message(FATAL_ERROR "Benchmark payload JSON not found: ${NBL_MESHLOADERS_BENCHMARK_INPUTS_JSON}. Check NBL_MESHLOADERS_BENCHMARK_DATASET_DIR and NBL_MESHLOADERS_BENCHMARK_PAYLOAD_RELATIVE_PATH.") + 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_PERF_REFERENCE_DIR STREQUAL "") + set(NBL_MESHLOADERS_PERF_REFERENCE_DIR "${NBL_MESHLOADERS_BENCHMARK_DATASET_DIR}/meshloaders/perf_refs" CACHE PATH "Directory containing structured performance reference artifacts for 12_MeshLoaders." FORCE) + endif() + 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 --loader-content-hashes + WORKING_DIRECTORY "$" + COMMAND_EXPAND_LISTS +) + +if (NBL_MESHLOADERS_ENABLE_BENCHMARK_DATASETS) + set(_NBL_MESHLOADERS_BENCHMARK_CI_COMMON_ARGS + --ci + --testlist "${NBL_MESHLOADERS_BENCHMARK_INPUTS_JSON}" + --loader-content-hashes + --perf-dump-dir "../../../build/dynamic/meshloaders_perf_runs" + --perf-ref-dir "../../../build/dynamic/meshloaders_benchmark_datasets/meshloaders/perf_refs" + --perf-strict + ) + + 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/README.md b/12_MeshLoaders/README.md index 6330f4673..364091a44 100644 --- a/12_MeshLoaders/README.md +++ b/12_MeshLoaders/README.md @@ -1,2 +1,169 @@ -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` +- Output meshes: `saved/` +- Output screenshots: `screenshots/` +- Default startup resolves `inputs.json` from the example directory layout and is not tied to the current working directory + +## Mode cheat sheet +- `batch` + - Uses test list and runs normal workflow. + - If test list has `row_view: true`, assets are laid out in one inspection scene. +- `interactive` + - Opens file dialog and loads one model. +- `ci` + - Runs strict pass/fail validation per case. + +## Row view concept +- `row_view` means one inspection scene containing all cases from the test list. +- `geometry` and `geometry collection` assets are normalized and laid out left-to-right so camera framing is stable for comparisons. +- `scene` assets are laid out as one row tile while keeping their authored internal instance transforms. + +## Common workflows +- Quick visual check: + - run default `batch` +- Inspect one local model: + - run with `--interactive` +- Validate load/write correctness: + - run with `--ci` + +## 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_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: + - first tries payload from `examples_tests/media/` + - 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` + - when benchmark datasets are enabled, benchmark `ctest` also writes structured performance run artifacts + - if a matching reference exists for the current workload and machine profile, strict comparison is enabled automatically +- 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) + +## CLI +- `--ci` + - strict validation run +- `--interactive` + - file-dialog run +- `--testlist ` + - custom JSON list + - relative JSON path resolves against local input CWD + - relative case paths inside the JSON resolve against the JSON file directory +- `--savegeometry` + - keep writing output meshes +- `--savepath ` + - force output path +- `--row-add ` + - add model to row view at startup + - scene assets added this way keep their internal transforms inside one row tile +- `--row-duplicate ` + - duplicate last row-view case +- `--loader-perf-log ` + - redirect loader diagnostics +- `--loader-content-hashes` + - keep loader content hashes enabled + - this is already the default for this example +- `--runtime-tuning ` + - IO runtime tuning mode +- `--perf-dump-dir ` + - write structured performance run JSON artifacts + - relative paths resolve against local output CWD +- `--perf-ref-dir ` + - lookup directory for structured performance references + - relative paths resolve against local output CWD + - absolute paths may be shortened internally when a shorter relative runtime path is available +- `--perf-strict` + - fail on performance regression only when a matching reference exists + - if no matching reference exists, the run stays record-only +- `--perf-profile-override ` + - override the automatically derived machine profile id +- `--perf-update-reference` + - write the current structured performance run to the matching reference path + - requires `--perf-ref-dir` + - cannot be combined with `--perf-strict` + +## 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 inspection scene +- `R`: reload current test list or interactive model + +## Input list format (`inputs.json`) +```json +{ + "row_view": true, + "cases": [ + "../media/cornell_box_multimaterial.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 +- default startup uses `inputs.json` resolved from the example directory layout rather than the process working directory +- `row_view: true` keeps geometry assets in direct row layout and places each scene asset as one row tile with authored internal transforms preserved inside that tile. + +## What CI validates +- Per-case image consistency: + - `*_loaded.png` vs `*_written.png` code-unit diff + - thresholds come from `MaxImageDiffCodeUnits` and `MaxImageDiffCodeUnitValue` in `App.hpp` +- Any mismatch ends with non-zero exit code + +## Structured Perf Runs +- Structured performance output is keyed by: + - `workload_id` + - derived from the benchmark/test input definition and runtime mode + - `profile_id` + - derived from the current CPU-centric machine/runtime profile or overridden explicitly +- Structured performance artifacts also carry provenance: + - `created_at_utc` + - `nabla_commit` + - `nabla_dirty` + - `examples_commit` + - `examples_dirty` +- Reference lookup uses: + - `//.json` +- If no matching reference exists: + - no comparison is performed + - the run only writes its current JSON artifact +- If a matching reference exists: + - per-case `original_load`, `write`, and `written_load` stage metrics are compared + - strict mode fails only on actual regression, not on missing references +- If `--perf-update-reference` is used: + - the current run is written directly to `//.json` + - existing comparison is skipped for that run +- JSON artifacts avoid host-specific absolute paths and store portable case/test-list identifiers instead + +## 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..05abdb30a --- /dev/null +++ b/12_MeshLoaders/bin/references/Spanner-ply.geomhash @@ -0,0 +1 @@ +106f4067832a0bb1cab846b4224088d78c3a7965cf19f14de30448744a509093 diff --git a/12_MeshLoaders/bin/references/Stanford_Bunny.geomhash b/12_MeshLoaders/bin/references/Stanford_Bunny.geomhash new file mode 100644 index 000000000..750968b01 --- /dev/null +++ b/12_MeshLoaders/bin/references/Stanford_Bunny.geomhash @@ -0,0 +1 @@ +9b063da4dd48cb276be30a55e64323418140fc88395b7753ff19edc452fb0253 diff --git a/12_MeshLoaders/bin/references/yellowflower.geomhash b/12_MeshLoaders/bin/references/yellowflower.geomhash new file mode 100644 index 000000000..811367aec --- /dev/null +++ b/12_MeshLoaders/bin/references/yellowflower.geomhash @@ -0,0 +1 @@ +21405720f3f8f7be6310ff20913dee7b1d3a4a16095c4e6a7445de60338bee79 diff --git a/12_MeshLoaders/inputs.json b/12_MeshLoaders/inputs.json new file mode 100644 index 000000000..482ffdac4 --- /dev/null +++ b/12_MeshLoaders/inputs.json @@ -0,0 +1,8 @@ +{ + "row_view": true, + "cases": [ + "../media/cornell_box_multimaterial.obj", + "../media/ply/Spanner-ply.ply", + "../media/Stanford_Bunny.stl" + ] +} diff --git a/12_MeshLoaders/main.cpp b/12_MeshLoaders/main.cpp index e27ed4be0..b14581b0d 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 "App.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/40_PathTracer/main.cpp b/40_PathTracer/main.cpp index 8fd248f0a..fe374fef9 100644 --- a/40_PathTracer/main.cpp +++ b/40_PathTracer/main.cpp @@ -4,6 +4,7 @@ #include "nbl/examples/common/BuiltinResourcesApplication.hpp" #include "nbl/examples/examples.hpp" +#include "nbl/gtml/SJsonFormatter.h" #include "renderer/CRenderer.h" #include "renderer/resolve/CBasicRWMCResolver.h" @@ -34,35 +35,13 @@ class PathTracingApp final : public SimpleWindowedApplication, public BuiltinRes using device_base_t = SimpleWindowedApplication; using asset_base_t = BuiltinResourcesApplication; - // TODO: move to Nabla proper - static inline void jsonizeGitInfo(nlohmann::json& target, const nbl::gtml::GitInfo& info) - { - target["isPopulated"] = info.isPopulated; - if (info.hasUncommittedChanges.has_value()) - target["hasUncommittedChanges"] = info.hasUncommittedChanges.value(); - else - target["hasUncommittedChanges"] = "UNKNOWN, BUILT WITHOUT DIRTY-CHANGES CAPTURE"; - - target["commitAuthorName"] = info.commitAuthorName; - target["commitAuthorEmail"] = info.commitAuthorEmail; - target["commitHash"] = info.commitHash; - target["commitShortHash"] = info.commitShortHash; - target["commitDate"] = info.commitDate; - target["commitSubject"] = info.commitSubject; - target["commitBody"] = info.commitBody; - target["describe"] = info.describe; - target["branchName"] = info.branchName; - target["latestTag"] = info.latestTag; - target["latestTagName"] = info.latestTagName; - } - inline void printGitInfos() const { nlohmann::json j; auto& modules = j["modules"]; - jsonizeGitInfo(modules["nabla"],nbl::gtml::nabla_git_info); - jsonizeGitInfo(modules["dxc"],nbl::gtml::dxc_git_info); + modules["nabla"] = nlohmann::json::parse(::gtml::SJsonFormatter::toString(nbl::gtml::nabla_git_info)); + modules["dxc"] = nlohmann::json::parse(::gtml::SJsonFormatter::toString(nbl::gtml::dxc_git_info)); m_logger->log("Build Info:\n%s",ILogger::ELL_INFO,j.dump(4).c_str()); } @@ -590,4 +569,4 @@ class PathTracingApp final : public SimpleWindowedApplication, public BuiltinRes #endif }; -NBL_MAIN_FUNC(PathTracingApp) \ No newline at end of file +NBL_MAIN_FUNC(PathTracingApp) diff --git a/CMakeLists.txt b/CMakeLists.txt index a93a86a4f..74cb758b9 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -4,6 +4,18 @@ if(NBL_BUILD_EXAMPLES) project(NablaExamples) + enable_testing() + set(NBL_EXAMPLES_OPTIONAL_COMMON_TARGETS) + if (EXISTS "${CMAKE_CURRENT_SOURCE_DIR}/.git") + NBL_ADD_GIT_TRACKING_META_LIBRARY( + TARGET NblExamplesGitInfo + NAMESPACE nbl::examples::gtml + HEADER_PATH nbl/examples/git/info.h + REPOS + examples "${CMAKE_CURRENT_SOURCE_DIR}" + ) + list(APPEND NBL_EXAMPLES_OPTIONAL_COMMON_TARGETS NblExamplesGitInfo) + endif() if(NBL_BUILD_ANDROID) nbl_android_create_media_storage_apk() @@ -115,6 +127,7 @@ if(NBL_BUILD_EXAMPLES) # add new examples *before* NBL_GET_ALL_TARGETS invocation, it gathers recursively all targets created so far in this subdirectory NBL_GET_ALL_TARGETS(TARGETS) + list(REMOVE_ITEM TARGETS NblExamplesGitInfo) # we want to loop only over the examples so we exclude examples' interface libraries created in common subdirectory list(REMOVE_ITEM TARGETS ${NBL_EXAMPLES_API_TARGET} ${NBL_EXAMPLES_API_LIBRARIES}) @@ -122,8 +135,11 @@ if(NBL_BUILD_EXAMPLES) # we link common example api library and force examples to reuse its PCH foreach(T IN LISTS TARGETS) get_target_property(TYPE ${T} TYPE) - if(NOT ${TYPE} MATCHES INTERFACE) + if(NOT ${TYPE} MATCHES "INTERFACE|UTILITY") target_link_libraries(${T} PUBLIC ${NBL_EXAMPLES_API_TARGET}) + if(NBL_EXAMPLES_OPTIONAL_COMMON_TARGETS) + target_link_libraries(${T} PUBLIC ${NBL_EXAMPLES_OPTIONAL_COMMON_TARGETS}) + endif() target_include_directories(${T} PUBLIC $) set_target_properties(${T} PROPERTIES DISABLE_PRECOMPILE_HEADERS OFF) target_precompile_headers(${T} REUSE_FROM "${NBL_EXAMPLES_API_TARGET}") @@ -137,4 +153,4 @@ if(NBL_BUILD_EXAMPLES) endforeach() NBL_ADJUST_FOLDERS(examples) -endif() \ No newline at end of file +endif() diff --git a/common/CMakeLists.txt b/common/CMakeLists.txt index b3e57da6f..bd6db8cbc 100644 --- a/common/CMakeLists.txt +++ b/common/CMakeLists.txt @@ -106,4 +106,4 @@ set(NBL_EXAMPLES_API_TARGET ${LIB_NAME} PARENT_SCOPE) ]] set(NBL_EXAMPLES_API_LIBRARIES ${TARGETS} PARENT_SCOPE) -NBL_ADJUST_FOLDERS(common) \ No newline at end of file +NBL_ADJUST_FOLDERS(common) diff --git a/common/include/nbl/examples/PCH.hpp b/common/include/nbl/examples/PCH.hpp index a20984464..d03b9b74f 100644 --- a/common/include/nbl/examples/PCH.hpp +++ b/common/include/nbl/examples/PCH.hpp @@ -26,4 +26,4 @@ #include "nbl/examples/geometry/CSimpleDebugRenderer.hpp" -#endif // _NBL_EXAMPLES_COMMON_PCH_HPP_ \ No newline at end of file +#endif // _NBL_EXAMPLES_COMMON_PCH_HPP_ diff --git a/common/include/nbl/examples/common/GeometryAABBUtilities.h b/common/include/nbl/examples/common/GeometryAABBUtilities.h new file mode 100644 index 000000000..4f10e9a8c --- /dev/null +++ b/common/include/nbl/examples/common/GeometryAABBUtilities.h @@ -0,0 +1,145 @@ +// 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_GEOMETRY_AABB_UTILITIES_INCLUDED_ +#define _NBL_EXAMPLES_COMMON_GEOMETRY_AABB_UTILITIES_INCLUDED_ + +#include "nbl/asset/ICPUPolygonGeometry.h" + +#include +#include +#include + +namespace nbl::examples::geometry +{ + +inline bool isFinite(const double value) +{ + return std::isfinite(value); +} + +inline bool isFinite(const hlsl::float64_t3& value) +{ + return isFinite(value.x) && isFinite(value.y) && isFinite(value.z); +} + +template +inline bool isValidAABB(const hlsl::shapes::AABB<3, Scalar>& aabb) +{ + return + std::isfinite(static_cast(aabb.minVx.x)) && + std::isfinite(static_cast(aabb.minVx.y)) && + std::isfinite(static_cast(aabb.minVx.z)) && + std::isfinite(static_cast(aabb.maxVx.x)) && + std::isfinite(static_cast(aabb.maxVx.y)) && + std::isfinite(static_cast(aabb.maxVx.z)) && + (aabb.minVx.x <= aabb.maxVx.x) && + (aabb.minVx.y <= aabb.maxVx.y) && + (aabb.minVx.z <= aabb.maxVx.z); +} + +template +inline hlsl::shapes::AABB<3, Scalar> translateAABB(const hlsl::shapes::AABB<3, Scalar>& aabb, const hlsl::vector& translation) +{ + auto out = aabb; + out.minVx += translation; + out.maxVx += translation; + return out; +} + +template +inline hlsl::shapes::AABB<3, Scalar> scaleAABB(const hlsl::shapes::AABB<3, Scalar>& aabb, const Scalar scale) +{ + auto out = aabb; + out.minVx *= scale; + out.maxVx *= scale; + return out; +} + +inline hlsl::shapes::AABB<3, double> computeFiniteUsedPositionAABB(const asset::ICPUPolygonGeometry* geometry) +{ + auto aabb = hlsl::shapes::AABB<3, double>::create(); + if (!geometry) + return aabb; + + const auto positionView = geometry->getPositionView(); + const uint64_t vertexCount = positionView.getElementCount(); + if (!vertexCount) + return aabb; + + const auto indexView = geometry->getIndexView(); + const uint64_t vertexRefCount = geometry->getVertexReferenceCount(); + bool hasFiniteVertex = false; + for (uint64_t i = 0u; i < vertexRefCount; ++i) + { + uint64_t vertexIx = i; + if (indexView) + { + const auto* const ptr = static_cast(indexView.getPointer(i)); + if (!ptr) + continue; + switch (indexView.composed.format) + { + case asset::EF_R16_UINT: + { + uint16_t index = 0u; + memcpy(&index, ptr, sizeof(index)); + vertexIx = index; + } + break; + case asset::EF_R32_UINT: + { + uint32_t index = 0u; + memcpy(&index, ptr, sizeof(index)); + vertexIx = index; + } + break; + default: + continue; + } + } + if (vertexIx >= vertexCount) + continue; + + hlsl::float32_t3 decoded = {}; + positionView.decodeElement(vertexIx, decoded); + const hlsl::float64_t3 point = { + static_cast(decoded.x), + static_cast(decoded.y), + static_cast(decoded.z) + }; + if (!isFinite(point)) + continue; + + if (!hasFiniteVertex) + { + aabb.minVx = point; + aabb.maxVx = point; + hasFiniteVertex = true; + continue; + } + + aabb.minVx.x = std::min(aabb.minVx.x, point.x); + aabb.minVx.y = std::min(aabb.minVx.y, point.y); + aabb.minVx.z = std::min(aabb.minVx.z, point.z); + aabb.maxVx.x = std::max(aabb.maxVx.x, point.x); + aabb.maxVx.y = std::max(aabb.maxVx.y, point.y); + aabb.maxVx.z = std::max(aabb.maxVx.z, point.z); + } + + if (!hasFiniteVertex) + return hlsl::shapes::AABB<3, double>::create(); + return aabb; +} + +inline hlsl::shapes::AABB<3, double> fallbackUnitAABB() +{ + 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; +} + +} // namespace nbl::examples::geometry + +#endif diff --git a/common/include/nbl/examples/common/ImageComparison.h b/common/include/nbl/examples/common/ImageComparison.h new file mode 100644 index 000000000..aabb31e94 --- /dev/null +++ b/common/include/nbl/examples/common/ImageComparison.h @@ -0,0 +1,128 @@ +// 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_IMAGE_COMPARISON_INCLUDED_ +#define _NBL_EXAMPLES_COMMON_IMAGE_COMPARISON_INCLUDED_ + +#include "nbl/asset/ICPUImage.h" +#include "nbl/asset/ICPUImageView.h" +#include "nbl/asset/format/EFormat.h" + +#include + +namespace nbl::examples::image +{ + +struct SCodeUnitDiff +{ + static inline uint32_t resolveCodeUnitBytes(const asset::E_FORMAT format) + { + if (asset::isBlockCompressionFormat(format)) + return 1u; + const uint32_t channels = asset::getFormatChannelCount(format); + if (!channels) + return 1u; + const uint32_t texelBytes = asset::getTexelOrBlockBytesize(format); + if (!texelBytes || (texelBytes % channels) != 0u) + return 1u; + const uint32_t bytesPerChannel = texelBytes / channels; + if (bytesPerChannel == 1u || bytesPerChannel == 2u || bytesPerChannel == 4u) + return bytesPerChannel; + return 1u; + } + + template + static inline uint32_t absoluteDiff(const T a, const T b) + { + return (a >= b) ? static_cast(a - b) : static_cast(b - a); + } +}; + +inline bool compareCpuImageViewsByCodeUnit( + const asset::ICPUImageView* a, + const asset::ICPUImageView* b, + uint64_t& diffCodeUnitCount, + uint32_t& maxDiffCodeUnitValue +) +{ + diffCodeUnitCount = 0u; + maxDiffCodeUnitValue = 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; + + const uint32_t codeUnitBytes = SCodeUnitDiff::resolveCodeUnitBytes(paramsA.format); + const size_t comparableSize = sizeA - (sizeA % codeUnitBytes); + for (size_t i = 0u; i < comparableSize; i += codeUnitBytes) + { + uint32_t absDiff = 0u; + if (codeUnitBytes == 1u) + { + const uint8_t va = dataA[i]; + const uint8_t vb = dataB[i]; + absDiff = SCodeUnitDiff::absoluteDiff(va, vb); + } + else if (codeUnitBytes == 2u) + { + uint16_t va = 0u; + uint16_t vb = 0u; + std::memcpy(&va, dataA + i, sizeof(va)); + std::memcpy(&vb, dataB + i, sizeof(vb)); + absDiff = SCodeUnitDiff::absoluteDiff(va, vb); + } + else + { + uint32_t va = 0u; + uint32_t vb = 0u; + std::memcpy(&va, dataA + i, sizeof(va)); + std::memcpy(&vb, dataB + i, sizeof(vb)); + absDiff = SCodeUnitDiff::absoluteDiff(va, vb); + } + if (!absDiff) + continue; + ++diffCodeUnitCount; + if (absDiff > maxDiffCodeUnitValue) + maxDiffCodeUnitValue = absDiff; + } + + for (size_t i = comparableSize; i < sizeA; ++i) + { + const uint32_t absDiff = SCodeUnitDiff::absoluteDiff(dataA[i], dataB[i]); + if (!absDiff) + continue; + ++diffCodeUnitCount; + if (absDiff > maxDiffCodeUnitValue) + maxDiffCodeUnitValue = absDiff; + } + + return true; +} + +} // namespace nbl::examples::image + +#endif diff --git a/common/include/nbl/examples/common/MonoWindowApplication.hpp b/common/include/nbl/examples/common/MonoWindowApplication.hpp index ab7cff307..f5988895c 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_ @@ -42,7 +42,7 @@ class MonoWindowApplication : public virtual SimpleWindowedApplication params.x = 32; params.y = 32; params.flags = ui::IWindow::ECF_HIDDEN | IWindow::ECF_BORDERLESS | IWindow::ECF_RESIZABLE | IWindow::ECF_CAN_MINIMIZE; - params.windowCaption = "MonoWindowApplication"; + params.windowCaption = getWindowCaption(); params.callback = windowCallback; const_cast&>(m_window) = m_winMgr->createWindow(std::move(params)); } @@ -70,6 +70,7 @@ class MonoWindowApplication : public virtual SimpleWindowedApplication return false; ISwapchain::SCreationParams swapchainParams = { .surface = smart_refctd_ptr(m_surface->getSurface()) }; + amendSwapchainCreateParams(swapchainParams); if (!swapchainParams.deduceFormat(m_physicalDevice)) return logFail("Could not choose a Surface Format for the Swapchain!"); @@ -140,6 +141,8 @@ class MonoWindowApplication : public virtual SimpleWindowedApplication // virtual inline bool keepRunning() override { + if (!shouldKeepRunning()) + return false; if (m_surface->irrecoverable()) return false; @@ -159,6 +162,17 @@ class MonoWindowApplication : public virtual SimpleWindowedApplication } protected: + virtual inline const char* getWindowCaption() const + { + return "MonoWindowApplication"; + } + virtual inline void amendSwapchainCreateParams(video::ISwapchain::SCreationParams&) const + { + } + virtual inline bool shouldKeepRunning() const + { + return true; + } inline void onAppInitializedFinish() { m_winMgr->show(m_window.get()); diff --git a/media b/media index 0f7ad42b3..3e3b92d99 160000 --- a/media +++ b/media @@ -1 +1 @@ -Subproject commit 0f7ad42b33abe3143a5d69c4d14b26cf3e538c88 +Subproject commit 3e3b92d9924581bcdd8fe7b8884126b7bda10b77