From 4242aac0c87d8597edee83308c223538096eeacb Mon Sep 17 00:00:00 2001 From: Jan Orend <56254096+3dJan@users.noreply.github.com> Date: Mon, 27 Apr 2026 08:35:34 +0200 Subject: [PATCH 01/72] [MeshSdfPerformance_tests] Suppress compiler warning for deprecated functions in GPU benchmark test Co-authored-by: Copilot --- gladius/tests/integrationtests/MeshSdfPerformance_tests.cpp | 3 +++ gladius/tests/unittests/MeshFwnBenchmark_tests.cpp | 1 + 2 files changed, 4 insertions(+) diff --git a/gladius/tests/integrationtests/MeshSdfPerformance_tests.cpp b/gladius/tests/integrationtests/MeshSdfPerformance_tests.cpp index 5e2cb2dd..a997e53c 100644 --- a/gladius/tests/integrationtests/MeshSdfPerformance_tests.cpp +++ b/gladius/tests/integrationtests/MeshSdfPerformance_tests.cpp @@ -388,6 +388,9 @@ namespace gladius::tests /// `GLADIUS_RUN_GPU_BENCH=1` to enable. Use a small mesh. TEST_F(MeshSdfPerformance_Test, GpuSdfRender_AllMethods_PrintsThroughput) { +#ifdef _MSC_VER +#pragma warning(suppress : 4996) +#endif if (std::getenv("GLADIUS_RUN_GPU_BENCH") == nullptr) { GTEST_SKIP() << "GPU benchmark default-skipped. Set " diff --git a/gladius/tests/unittests/MeshFwnBenchmark_tests.cpp b/gladius/tests/unittests/MeshFwnBenchmark_tests.cpp index 78504022..fa0d571f 100644 --- a/gladius/tests/unittests/MeshFwnBenchmark_tests.cpp +++ b/gladius/tests/unittests/MeshFwnBenchmark_tests.cpp @@ -18,6 +18,7 @@ #include #include +#include #include #include #include From cef45b7e25b11c029e5031111bb6780e15e5a822 Mon Sep 17 00:00:00 2001 From: Jan Orend <56254096+3dJan@users.noreply.github.com> Date: Mon, 27 Apr 2026 08:37:35 +0200 Subject: [PATCH 02/72] [MeshSdfPerformance_tests] Add environment variable check for GPU benchmark execution Co-authored-by: Copilot --- .../integrationtests/MeshSdfPerformance_tests.cpp | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/gladius/tests/integrationtests/MeshSdfPerformance_tests.cpp b/gladius/tests/integrationtests/MeshSdfPerformance_tests.cpp index a997e53c..c142feb9 100644 --- a/gladius/tests/integrationtests/MeshSdfPerformance_tests.cpp +++ b/gladius/tests/integrationtests/MeshSdfPerformance_tests.cpp @@ -388,10 +388,20 @@ namespace gladius::tests /// `GLADIUS_RUN_GPU_BENCH=1` to enable. Use a small mesh. TEST_F(MeshSdfPerformance_Test, GpuSdfRender_AllMethods_PrintsThroughput) { + auto const getEnvSafe = [](char const * name) -> bool + { #ifdef _MSC_VER -#pragma warning(suppress : 4996) + char * val = nullptr; + size_t len = 0; + _dupenv_s(&val, &len, name); + std::unique_ptr guard(val, &free); + return val != nullptr; +#else + return std::getenv(name) != nullptr; #endif - if (std::getenv("GLADIUS_RUN_GPU_BENCH") == nullptr) + }; + + if (!getEnvSafe("GLADIUS_RUN_GPU_BENCH")) { GTEST_SKIP() << "GPU benchmark default-skipped. Set " "GLADIUS_RUN_GPU_BENCH=1 to enable. WARNING: " From 7abff6575d44ef49d78c68b9a32ef9b5925d4e3b Mon Sep 17 00:00:00 2001 From: Jan Orend <56254096+3dJan@users.noreply.github.com> Date: Wed, 13 May 2026 06:22:06 +0200 Subject: [PATCH 03/72] Improving nanovdb based sdf queries --- gladius/src/Document.cpp | 77 +++++ gladius/src/Document.h | 3 + gladius/src/MeshBVH.cpp | 163 ++++++++-- gladius/src/MeshBVH.h | 19 ++ gladius/src/MeshSdfMethod.h | 13 + gladius/src/SpatialMeshResource.cpp | 53 +++- gladius/src/SpatialMeshResource.h | 17 + gladius/src/io/3mf/Importer3mf.cpp | 18 ++ gladius/src/kernel/sdf.cl | 2 +- gladius/src/ui/MainWindow.cpp | 9 + gladius/src/ui/MeshSdfSettingsDialog.cpp | 37 +++ gladius/src/ui/MeshSdfSettingsDialog.h | 5 + .../MeshSdfPerformance_tests.cpp | 297 +++++++++++++++++- .../tests/unittests/Importer3mfMerge_test.cpp | 69 ++++ gladius/tests/unittests/MeshBVH_tests.cpp | 47 +++ .../unittests/SpatialMeshResource_tests.cpp | 28 ++ 16 files changed, 814 insertions(+), 43 deletions(-) diff --git a/gladius/src/Document.cpp b/gladius/src/Document.cpp index 853019b8..695f1631 100644 --- a/gladius/src/Document.cpp +++ b/gladius/src/Document.cpp @@ -1456,6 +1456,62 @@ namespace gladius return summary; } + MeshQualityIssueSummary Document::getMeshQualityIssueSummary() const + { + MeshQualityIssueSummary summary{}; + std::string firstMessage; + + for (auto const & [key, resource] : getResourceManager().getResourceMap()) + { + if (key.getResourceType() != ResourceType::Mesh) + { + continue; + } + + auto const * spatialMesh = dynamic_cast(resource.get()); + if (spatialMesh == nullptr || !spatialMesh->hasMeshQualityIssues()) + { + continue; + } + + ++summary.affectedMeshCount; + summary.hasIssue = true; + + auto const & quality = spatialMesh->getMeshQualityDiagnostics(); + summary.degenerateTriangleCount += + static_cast(quality.degenerateTriangleCount); + summary.boundaryEdgeCount += static_cast(quality.boundaryEdgeCount); + summary.nonManifoldEdgeCount += + static_cast(quality.nonManifoldEdgeCount); + + if (firstMessage.empty()) + { + firstMessage = spatialMesh->formatMeshQualityMessage(key.getDisplayName()); + } + } + + if (!summary.hasIssue) + { + return summary; + } + + if (summary.affectedMeshCount == 1u) + { + summary.message = std::move(firstMessage); + return summary; + } + + summary.message = fmt::format( + "Mesh topology diagnostics affect {} mesh resources ({} boundary edges, {} " + "non-manifold edges, {} degenerate triangles total). First issue: {}", + summary.affectedMeshCount, + summary.boundaryEdgeCount, + summary.nonManifoldEdgeCount, + summary.degenerateTriangleCount, + firstMessage); + return summary; + } + events::SharedLogger Document::getSharedLogger() const { if (!m_core) @@ -1819,6 +1875,27 @@ namespace gladius ++changedResources; } + if (spatialMesh->evaluationConfig().method == MeshSdfMethod::NanoVDB && + spatialMesh->hasMeshQualityIssues()) + { + auto const message = spatialMesh->formatMeshQualityMessage( + key.getDisplayName()); + auto logger = getSharedLogger(); + if (logger) + { + logger->addEvent( + {message, + nanovdbBuildPolicy.failurePolicy == NanoVdbFailurePolicy::Fail + ? events::Severity::Error + : events::Severity::Warning}); + } + + if (nanovdbBuildPolicy.failurePolicy == NanoVdbFailurePolicy::Fail) + { + throw NanoVdbBuildRejectedError(message); + } + } + if (changed && spatialMesh->evaluationConfig().method == MeshSdfMethod::NanoVDB && spatialMesh->hasNanoVdbBuildIssue()) { diff --git a/gladius/src/Document.h b/gladius/src/Document.h index 4e1eaa61..759ada00 100644 --- a/gladius/src/Document.h +++ b/gladius/src/Document.h @@ -459,6 +459,9 @@ namespace gladius /// Summarize NanoVDB build issues on currently loaded mesh resources, if any. [[nodiscard]] NanoVdbBuildIssueSummary getNanoVdbBuildIssueSummary() const; + /// Summarize mesh topology diagnostics on currently loaded mesh resources, if any. + [[nodiscard]] MeshQualityIssueSummary getMeshQualityIssueSummary() const; + /// Queue applying the mesh-SDF evaluation configuration to existing mesh resources. /// Heavy resource rebuild/upload work is folded into the debounced background refresh so /// the UI can keep showing the current preview. diff --git a/gladius/src/MeshBVH.cpp b/gladius/src/MeshBVH.cpp index bd89f3b6..d2769968 100644 --- a/gladius/src/MeshBVH.cpp +++ b/gladius/src/MeshBVH.cpp @@ -9,6 +9,7 @@ #include #include #include +#include #include #include #include @@ -16,6 +17,96 @@ namespace gladius { + namespace + { + std::uint32_t floatKeyBits(float const value) noexcept + { + float normalized = value; + if (normalized == 0.0f) + { + normalized = 0.0f; // canonicalise -0.0f to +0.0f + } + + std::uint32_t bits = 0u; + std::memcpy(&bits, &normalized, sizeof(bits)); + return bits; + } + + struct PointKey + { + std::uint32_t x = 0u; + std::uint32_t y = 0u; + std::uint32_t z = 0u; + + bool operator==(PointKey const & other) const noexcept + { + return x == other.x && y == other.y && z == other.z; + } + }; + + struct PointKeyHash + { + std::size_t operator()(PointKey const & key) const noexcept + { + std::size_t seed = std::hash{}(key.x); + seed ^= std::hash{}(key.y) + 0x9e3779b9u + (seed << 6u) + (seed >> 2u); + seed ^= std::hash{}(key.z) + 0x9e3779b9u + (seed << 6u) + (seed >> 2u); + return seed; + } + }; + + [[nodiscard]] PointKey makePointKey(float4 const & point) noexcept + { + return PointKey{floatKeyBits(point.x), floatKeyBits(point.y), floatKeyBits(point.z)}; + } + + [[nodiscard]] bool lessPointKey(PointKey const & lhs, PointKey const & rhs) noexcept + { + if (lhs.x != rhs.x) + { + return lhs.x < rhs.x; + } + if (lhs.y != rhs.y) + { + return lhs.y < rhs.y; + } + return lhs.z < rhs.z; + } + + struct EdgeKey + { + PointKey a; + PointKey b; + + bool operator==(EdgeKey const & other) const noexcept + { + return a == other.a && b == other.b; + } + }; + + struct EdgeKeyHash + { + std::size_t operator()(EdgeKey const & key) const noexcept + { + std::size_t seed = PointKeyHash{}(key.a); + auto const bHash = PointKeyHash{}(key.b); + seed ^= bHash + 0x9e3779b9u + (seed << 6u) + (seed >> 2u); + return seed; + } + }; + + [[nodiscard]] EdgeKey makeEdgeKey(float4 const & lhs, float4 const & rhs) noexcept + { + auto a = makePointKey(lhs); + auto b = makePointKey(rhs); + if (lessPointKey(b, a)) + { + std::swap(a, b); + } + return EdgeKey{a, b}; + } + } + // ======================================================================== // Build Context // ======================================================================== @@ -197,6 +288,10 @@ namespace gladius ? static_cast(totalLeafPrims) / static_cast(m_lastStats.leafNodes) : 0.0f; + result.quality.degenerateTriangleCount = m_lastStats.degenerateTriangleCount; + result.quality.boundaryEdgeCount = m_lastStats.boundaryEdgeCount; + result.quality.nonManifoldEdgeCount = m_lastStats.nonManifoldEdgeCount; + return result; } @@ -219,6 +314,32 @@ namespace gladius vn.normal = {0.0f, 0.0f, 0.0f, 0.0f}; } + struct AccumulatedNormal + { + float x = 0.0f; + float y = 0.0f; + float z = 0.0f; + }; + + // Accumulate by exact vertex position, not only by vertex index. Many STL-style + // triangle soups duplicate vertex records per face while still being geometrically + // watertight. Exact positional grouping preserves deterministic behaviour without + // silently modifying the mesh like a weld repair would. + std::unordered_map normalSums; + normalSums.reserve(vertexCount); + + auto const addNormal = [&](float4 const & vertex, + float const angle, + float const normalX, + float const normalY, + float const normalZ) + { + auto & sum = normalSums[makePointKey(vertex)]; + sum.x += angle * normalX; + sum.y += angle * normalY; + sum.z += angle * normalZ; + }; + // Accumulate angle-weighted face normals for each vertex for (size_t i = 0; i < indices.size(); ++i) { @@ -278,23 +399,23 @@ namespace gladius float angle2 = std::numbers::pi_v - angle0 - angle1; // Add weighted face normal to each vertex - data.vertexNormals[idx.i0].normal.x += angle0 * fnx; - data.vertexNormals[idx.i0].normal.y += angle0 * fny; - data.vertexNormals[idx.i0].normal.z += angle0 * fnz; - - data.vertexNormals[idx.i1].normal.x += angle1 * fnx; - data.vertexNormals[idx.i1].normal.y += angle1 * fny; - data.vertexNormals[idx.i1].normal.z += angle1 * fnz; - - data.vertexNormals[idx.i2].normal.x += angle2 * fnx; - data.vertexNormals[idx.i2].normal.y += angle2 * fny; - data.vertexNormals[idx.i2].normal.z += angle2 * fnz; + addNormal(p0, angle0, fnx, fny, fnz); + addNormal(p1, angle1, fnx, fny, fnz); + addNormal(p2, angle2, fnx, fny, fnz); } // Normalize all vertex normals for (size_t i = 0; i < vertexCount; ++i) { auto & n = data.vertexNormals[i].normal; + auto const it = normalSums.find(makePointKey(vertices[i])); + if (it != normalSums.end()) + { + n.x = it->second.x; + n.y = it->second.y; + n.z = it->second.z; + } + float len = std::sqrt(n.x * n.x + n.y * n.y + n.z * n.z); if (len > 1e-10f) { @@ -357,24 +478,6 @@ namespace gladius faceNormals[t] = float4{fnx * invLen, fny * invLen, fnz * invLen, 1.0f}; } - // Hash undirected edges to (triIdx, edgeIdx). On second hit we know both sides. - struct EdgeKey - { - uint64_t key; - bool operator==(EdgeKey const & o) const { return key == o.key; } - }; - struct EdgeKeyHash - { - size_t operator()(EdgeKey const & k) const noexcept { return std::hash{}(k.key); } - }; - - auto const makeKey = [](uint32_t a, uint32_t b) -> EdgeKey - { - uint32_t lo = std::min(a, b); - uint32_t hi = std::max(a, b); - return EdgeKey{(static_cast(hi) << 32) | static_cast(lo)}; - }; - struct EdgeRef { int triIdx; @@ -395,7 +498,7 @@ namespace gladius for (int e = 0; e < 3; ++e) { auto [va, vb] = edgeVertices(idx, e); - EdgeKey const key = makeKey(va, vb); + EdgeKey const key = makeEdgeKey(vertices[va], vertices[vb]); edgeMap[key].push_back(EdgeRef{static_cast(t), e}); } } diff --git a/gladius/src/MeshBVH.h b/gladius/src/MeshBVH.h index 68208c01..238a671a 100644 --- a/gladius/src/MeshBVH.h +++ b/gladius/src/MeshBVH.h @@ -139,6 +139,24 @@ namespace gladius } }; + /// Topology diagnostics that affect deterministic signed-distance evaluation. + struct MeshQualityDiagnostics + { + /// Number of triangles skipped during normal computation due to a degenerate + /// edge length or zero face-normal magnitude. + int degenerateTriangleCount = 0; + /// Number of undirected edges incident to exactly one face (open boundary). + int boundaryEdgeCount = 0; + /// Number of undirected edges incident to three or more faces (non-manifold). + int nonManifoldEdgeCount = 0; + + [[nodiscard]] bool hasIssues() const noexcept + { + return degenerateTriangleCount != 0 || boundaryEdgeCount != 0 || + nonManifoldEdgeCount != 0; + } + }; + /// Host-side container for mesh BVH data before serialization struct SpatialMeshData { @@ -150,6 +168,7 @@ namespace gladius std::vector fwnAggregates; ///< 1 aggregate per BVH node (same order as nodes); empty when not built size_t originalTriangleCount = 0; ///< Source mesh triangle count BoundingBox boundingBox; ///< Axis-aligned bounding box + MeshQualityDiagnostics quality; ///< Topology diagnostics collected during BVH construction /// Check if data is empty bool empty() const diff --git a/gladius/src/MeshSdfMethod.h b/gladius/src/MeshSdfMethod.h index 6a92d60e..f12d1d76 100644 --- a/gladius/src/MeshSdfMethod.h +++ b/gladius/src/MeshSdfMethod.h @@ -106,6 +106,19 @@ namespace gladius float suggestedVoxelSize_mm = 0.0f; }; + /// User-facing aggregate of mesh topology diagnostics that can make signed-distance + /// evaluation ambiguous unless the mesh is repaired or an orientation-independent + /// sign strategy is selected. + struct MeshQualityIssueSummary + { + bool hasIssue = false; + std::size_t affectedMeshCount = 0u; + std::size_t degenerateTriangleCount = 0u; + std::size_t boundaryEdgeCount = 0u; + std::size_t nonManifoldEdgeCount = 0u; + std::string message; + }; + /// Raised when a strict NanoVDB policy rejects the requested load/build. class NanoVdbBuildRejectedError : public std::runtime_error { diff --git a/gladius/src/SpatialMeshResource.cpp b/gladius/src/SpatialMeshResource.cpp index 290e34a8..6b89690f 100644 --- a/gladius/src/SpatialMeshResource.cpp +++ b/gladius/src/SpatialMeshResource.cpp @@ -257,6 +257,32 @@ namespace gladius ResourceBase::load(); } + std::string SpatialMeshResource::formatMeshQualityMessage( + std::string const & displayName) const + { + if (!hasMeshQualityIssues()) + { + return {}; + } + + auto const subject = displayName.empty() ? std::string{"mesh resource"} + : fmt::format("mesh '{}'", displayName); + auto const & quality = m_data.quality; + auto message = fmt::format( + "Topology diagnostics for {}: {} boundary edges, {} non-manifold edges, " + "{} degenerate triangles. Signed mesh SDF methods, including NanoVDB, are " + "deterministic only for watertight, consistently oriented meshes.", + subject, + quality.boundaryEdgeCount, + quality.nonManifoldEdgeCount, + quality.degenerateTriangleCount); + + message += " No repair or fallback was applied silently. Enable explicit repair-on-import " + "and re-import, or select Fast Winding Number when the input is an intentional " + "triangle soup."; + return message; + } + std::string SpatialMeshResource::formatNanoVdbBuildMessage( std::string const & displayName) const { @@ -1048,16 +1074,31 @@ namespace gladius flatMeshCompanion.end = static_cast(m_payloadData.data.size()); // -- Companion i+2: SDF_VDB (near signed-distance field, user-configured res) -- - auto const nearSdfTransform = openvdb::math::Transform::createLinearTransform( - static_cast(voxelSize_mm)); + // Keep the legacy VdbImporter value convention: build the SDF grid in + // voxel-scaled coordinates with an identity transform, then store + // PrimitiveMeta::scaling as the world_mm -> grid coordinate scale. The OpenCL + // vdbModel() sampler divides sampled grid values by the same scale to return + // millimetres. Building this grid directly in world space with a non-identity + // transform would store millimetre-valued distances and the kernel would scale + // them down a second time. + float const nearSdfScaling = 1.0f / voxelSize_mm; + auto nearSdfVerts = verts; + for (auto & vertex : nearSdfVerts) + { + vertex *= nearSdfScaling; + } + auto const nearSdfTransform = openvdb::math::Transform::createLinearTransform(1.0); float constexpr kNearHalfBandVoxels = 8.0f; auto nearSdfGrid = openvdb::tools::meshToLevelSet( - *nearSdfTransform, verts, tris, kNearHalfBandVoxels); - openvdb::tools::changeBackground(nearSdfGrid->tree(), - std::numeric_limits::max()); + *nearSdfTransform, nearSdfVerts, tris, kNearHalfBandVoxels); + // Preserve OpenVDB's signed inactive tiles. The hybrid NanoVDB path uses this + // grid as its sign source while face-index grids provide the long-range + // magnitude. Replacing all inactive values with +max destroys the negative + // interior background and makes closed solids look outside once queries leave + // the active narrow band. nearSdfGrid->pruneGrid(); PrimitiveMeta sdfVdbCompanion = appendVdbGrid( - openvdb::GridBase::Ptr(nearSdfGrid), 1.0f / voxelSize_mm, SDF_VDB); + openvdb::GridBase::Ptr(nearSdfGrid), nearSdfScaling, SDF_VDB); m_nanovdbGridOffset = static_cast(sdfVdbCompanion.start); // -- Companion i+3: SDF_VDB_FACE_INDICES (far face-index, fixed 1mm / 150 voxels) -- diff --git a/gladius/src/SpatialMeshResource.h b/gladius/src/SpatialMeshResource.h index 7663d2da..c18e89ad 100644 --- a/gladius/src/SpatialMeshResource.h +++ b/gladius/src/SpatialMeshResource.h @@ -150,6 +150,23 @@ namespace gladius return hasNanoVdbBuildIssue(); } + /// Mesh topology diagnostics captured during BVH construction. + [[nodiscard]] MeshQualityDiagnostics const & getMeshQualityDiagnostics() const noexcept + { + return m_data.quality; + } + + /// True when the source mesh has topology that can make signed SDF sign ambiguous. + [[nodiscard]] bool hasMeshQualityIssues() const noexcept + { + return m_data.quality.hasIssues(); + } + + /// Format mesh-quality diagnostics into a user-facing message. Returns an empty string + /// when the mesh has no recorded quality issues. + [[nodiscard]] std::string formatMeshQualityMessage( + std::string const & displayName) const; + /// Format the current NanoVDB issue into a user-facing message. Returns an empty string /// when the resource has no NanoVDB build issue. [[nodiscard]] std::string formatNanoVdbBuildMessage( diff --git a/gladius/src/io/3mf/Importer3mf.cpp b/gladius/src/io/3mf/Importer3mf.cpp index 5433b2fb..27d90c49 100644 --- a/gladius/src/io/3mf/Importer3mf.cpp +++ b/gladius/src/io/3mf/Importer3mf.cpp @@ -1392,6 +1392,24 @@ namespace gladius::io auto * resource = resourceManager.getResourcePtr(key); auto * spatialMesh = dynamic_cast(resource); + if (spatialMesh != nullptr && spatialMesh->hasMeshQualityIssues()) + { + auto const message = spatialMesh->formatMeshQualityMessage(key.getDisplayName()); + bool const strictNanoVdb = m_meshSdfEvaluationConfig.method == MeshSdfMethod::NanoVDB && + m_nanovdbBuildPolicy.failurePolicy == + NanoVdbFailurePolicy::Fail; + if (m_eventLogger) + { + m_eventLogger->addEvent({message, + strictNanoVdb ? gladius::events::Severity::Error + : gladius::events::Severity::Warning}); + } + if (strictNanoVdb) + { + resourceManager.deleteResource(key); + throw NanoVdbBuildRejectedError(message); + } + } if (spatialMesh != nullptr && spatialMesh->hasNanoVdbBuildIssue()) { auto const message = spatialMesh->formatNanoVdbBuildMessage(key.getDisplayName()); diff --git a/gladius/src/kernel/sdf.cl b/gladius/src/kernel/sdf.cl index 23526af7..6026d1a4 100644 --- a/gladius/src/kernel/sdf.cl +++ b/gladius/src/kernel/sdf.cl @@ -637,7 +637,7 @@ float vdbModelSimple(float3 pos, int index, PAYLOAD_ARGS) int3 coord = convert_int3(pos); pnanovdb_coord_t coordCenter = pnanovdb_make_coord(coord.x, coord.y, coord.z); - return pnanovdb_read_float_value(buf, &acc, coordCenter); + return pnanovdb_read_float_value(buf, &acc, coordCenter) / node.scaling; } // return boundingBox + bandwidth; } diff --git a/gladius/src/ui/MainWindow.cpp b/gladius/src/ui/MainWindow.cpp index d83edb1c..e45acfd8 100644 --- a/gladius/src/ui/MainWindow.cpp +++ b/gladius/src/ui/MainWindow.cpp @@ -3440,6 +3440,15 @@ namespace gladius::ui } return m_doc->getNanoVdbBuildIssueSummary(); }); + m_meshSdfSettingsDialog.setMeshQualityIssueProvider( + [this]() -> MeshQualityIssueSummary + { + if (!m_doc) + { + return {}; + } + return m_doc->getMeshQualityIssueSummary(); + }); } void MainWindow::setVdbSupported(bool supported, std::string const & reason) diff --git a/gladius/src/ui/MeshSdfSettingsDialog.cpp b/gladius/src/ui/MeshSdfSettingsDialog.cpp index 70a36b70..a117cd66 100644 --- a/gladius/src/ui/MeshSdfSettingsDialog.cpp +++ b/gladius/src/ui/MeshSdfSettingsDialog.cpp @@ -21,6 +21,11 @@ namespace gladius::ui m_nanovdbIssueProvider = std::move(provider); } + void MeshSdfSettingsDialog::setMeshQualityIssueProvider(MeshQualityIssueProvider provider) + { + m_meshQualityIssueProvider = std::move(provider); + } + void MeshSdfSettingsDialog::show() { syncFromSettings(); @@ -92,6 +97,38 @@ namespace gladius::ui // ---- Evaluation method ------------------------------------------------ if (ImGui::CollapsingHeader("Evaluation", ImGuiTreeNodeFlags_DefaultOpen)) { + if (m_meshQualityIssueProvider) + { + auto const qualityIssue = m_meshQualityIssueProvider(); + if (qualityIssue.hasIssue) + { + ImGui::TextColored(ImVec4(1.0f, 0.68f, 0.18f, 1.0f), + "Mesh topology diagnostics detected"); + ImGui::PushTextWrapPos(ImGui::GetFontSize() * 46.0f); + ImGui::TextUnformatted(qualityIssue.message.c_str()); + ImGui::PopTextWrapPos(); + ImGui::TextDisabled( + "No silent repair or fallback was applied; choose an explicit recovery and Apply."); + + if (ImGui::Button("Enable repair for next import")) + { + m_repair.weld = true; + m_repair.removeDegenerate = true; + m_repair.orientConsistently = true; + m_repair.fillHoles = true; + m_dirty = true; + } + ImGui::SameLine(); + if (ImGui::Button("Use FWN sign")) + { + m_eval.method = MeshSdfMethod::FastWindingNumber; + m_eval.fwnUseSignCache = false; + m_dirty = true; + } + ImGui::Separator(); + } + } + char const * preview = methodLabel(m_eval.method); if (ImGui::BeginCombo("Method", preview)) { diff --git a/gladius/src/ui/MeshSdfSettingsDialog.h b/gladius/src/ui/MeshSdfSettingsDialog.h index 6ec5961a..81f386d7 100644 --- a/gladius/src/ui/MeshSdfSettingsDialog.h +++ b/gladius/src/ui/MeshSdfSettingsDialog.h @@ -24,6 +24,7 @@ namespace gladius::ui public: using ApplyCallback = std::function; using NanoVdbIssueProvider = std::function; + using MeshQualityIssueProvider = std::function; MeshSdfSettingsDialog() = default; ~MeshSdfSettingsDialog() = default; @@ -42,6 +43,9 @@ namespace gladius::ui /// Provide the callback used to query runtime NanoVDB build issues. void setNanoVdbIssueProvider(NanoVdbIssueProvider provider); + /// Provide the callback used to query runtime mesh-quality diagnostics. + void setMeshQualityIssueProvider(MeshQualityIssueProvider provider); + void show(); void hide(); [[nodiscard]] bool isVisible() const noexcept; @@ -60,6 +64,7 @@ namespace gladius::ui MeshSdfSettings * m_settings = nullptr; ApplyCallback m_applyCallback; NanoVdbIssueProvider m_nanovdbIssueProvider; + MeshQualityIssueProvider m_meshQualityIssueProvider; bool m_visible = false; bool m_dirty = false; diff --git a/gladius/tests/integrationtests/MeshSdfPerformance_tests.cpp b/gladius/tests/integrationtests/MeshSdfPerformance_tests.cpp index 9ae45ba6..8ba222db 100644 --- a/gladius/tests/integrationtests/MeshSdfPerformance_tests.cpp +++ b/gladius/tests/integrationtests/MeshSdfPerformance_tests.cpp @@ -217,6 +217,32 @@ namespace gladius::tests return std::make_unique(key, vertices, indices); } + /// Create a closed, consistently wound cube mesh for sharp-feature SDF tests. + static void createCubeMesh(std::vector& vertices, + std::vector& indices, + float halfExtent) + { + vertices = { + makeFloat4(-halfExtent, -halfExtent, -halfExtent), + makeFloat4( halfExtent, -halfExtent, -halfExtent), + makeFloat4( halfExtent, halfExtent, -halfExtent), + makeFloat4(-halfExtent, halfExtent, -halfExtent), + makeFloat4(-halfExtent, -halfExtent, halfExtent), + makeFloat4( halfExtent, -halfExtent, halfExtent), + makeFloat4( halfExtent, halfExtent, halfExtent), + makeFloat4(-halfExtent, halfExtent, halfExtent), + }; + + indices = { + {4, 5, 6}, {4, 6, 7}, // +z + {1, 0, 3}, {1, 3, 2}, // -z + {5, 1, 2}, {5, 2, 6}, // +x + {0, 4, 7}, {0, 7, 3}, // -x + {7, 6, 2}, {7, 2, 3}, // +y + {0, 1, 5}, {0, 5, 4}, // -y + }; + } + /// Create an icosphere mesh for testing static void createIcosphere(std::vector& vertices, std::vector& indices, @@ -322,9 +348,12 @@ namespace gladius::tests auto primitives = core->getPrimitives(); ASSERT_NE(primitives, nullptr); + bool const vdbSupported = core->getProgramManager().isVdbSupported(); + gladius::compute::ManifoldDualContouringProgram sampler( m_context, core->getResourceContext()); sampler.setLogger(m_logger); + sampler.setEnableVdb(vdbSupported); sampler.setModelKernel(R"CLC( float4 model(float3 pos, PAYLOAD_ARGS) { @@ -337,20 +366,31 @@ namespace gladius::tests { char const * label; MeshSdfMethod method; + bool requiresVdb; }; - std::array const cases = {{ - {"PureBVH", MeshSdfMethod::PureBVH}, - {"VoxelAccelerated", MeshSdfMethod::VoxelAccelerated}, - {"FastWindingNumber", MeshSdfMethod::FastWindingNumber}, + std::array const cases = {{ + {"PureBVH", MeshSdfMethod::PureBVH, false}, + {"VoxelAccelerated", MeshSdfMethod::VoxelAccelerated, false}, + {"FastWindingNumber", MeshSdfMethod::FastWindingNumber, false}, + {"NanoVDB", MeshSdfMethod::NanoVDB, true}, }}; for (auto const & c : cases) { + if (c.requiresVdb && !vdbSupported) + { + std::cout << "Skipping " << c.label + << " mesh SDF comparison because NanoVDB is unavailable: " + << core->getProgramManager().getVdbSupportFailureReason() << std::endl; + continue; + } + MeshSdfEvaluationConfig cfg{}; cfg.method = c.method; cfg.useEarlyExit = false; cfg.fwnUseSignCache = false; cfg.fwnBeta = 4.0f; + cfg.nanovdbVoxelSize_mm = 0.1f; MeshBVHBuildParams params; params.maxPrimitivesPerLeaf = 4; @@ -378,6 +418,14 @@ namespace gladius::tests primitives->clear(); resource.write(*primitives); + if (c.method == MeshSdfMethod::NanoVDB) + { + ASSERT_FALSE(resource.hasNanoVdbBuildIssue()) << c.label << ": " + << resource.getNanoVdbBuildInfo().reason; + ASSERT_EQ(resource.getNanoVdbBuildInfo().result, + SpatialMeshResource::NanoVdbBuildResult::Built) + << c.label; + } primitives->write(); if (c.method == MeshSdfMethod::VoxelAccelerated) @@ -398,13 +446,250 @@ namespace gladius::tests primitives->write(); auto const distances = sampler.evaluateSdfBatch( - {Eigen::Vector3f{0.0f, 0.0f, 0.0f}, Eigen::Vector3f{15.0f, 0.0f, 0.0f}}, + {Eigen::Vector3f{0.0f, 0.0f, 0.0f}, + Eigen::Vector3f{15.0f, 0.0f, 0.0f}, + Eigen::Vector3f{9.75f, 0.0f, 0.0f}, + Eigen::Vector3f{10.25f, 0.0f, 0.0f}}, *primitives, 0.0f); - ASSERT_EQ(distances.size(), 2u) << c.label; + ASSERT_EQ(distances.size(), 4u) << c.label; EXPECT_LT(distances[0], -5.0f) << c.label << " center distance=" << distances[0]; EXPECT_GT(distances[1], 2.0f) << c.label << " outside distance=" << distances[1]; + EXPECT_LT(distances[2], -0.05f) << c.label << " near-inside distance=" << distances[2]; + EXPECT_GT(distances[3], 0.05f) << c.label << " near-outside distance=" << distances[3]; + EXPECT_GT(std::fabs(distances[3]), 0.10f) + << c.label << " near-outside magnitude should remain in millimetres, distance=" + << distances[3]; + } + } + + TEST_F(MeshSdfPerformance_Test, GpuSdfEvaluation_AllMeshMethods_CubeSharpFeaturesKeepCorrectSign) + { + if (!m_context->isValid()) + { + GTEST_SKIP() << "OpenCL context not available"; + } + + std::vector vertices; + std::vector indices; + createCubeMesh(vertices, indices, 5.0f); + + auto core = std::make_shared( + m_context, RequiredCapabilities::ComputeOnly, m_logger); + auto primitives = core->getPrimitives(); + ASSERT_NE(primitives, nullptr); + + bool const vdbSupported = core->getProgramManager().isVdbSupported(); + + gladius::compute::ManifoldDualContouringProgram sampler( + m_context, core->getResourceContext()); + sampler.setLogger(m_logger); + sampler.setEnableVdb(vdbSupported); + sampler.setModelKernel(R"CLC( + float4 model(float3 pos, PAYLOAD_ARGS) + { + float const distance = payload(pos, 0, primitivesSize, PASS_PAYLOAD_ARGS); + return (float4)(0.0f, 0.0f, 0.0f, distance); + } + )CLC"); + + struct MethodCase + { + char const * label; + MeshSdfMethod method; + bool requiresVdb; + }; + std::array const cases = {{ + {"PureBVH", MeshSdfMethod::PureBVH, false}, + {"VoxelAccelerated", MeshSdfMethod::VoxelAccelerated, false}, + {"FastWindingNumber", MeshSdfMethod::FastWindingNumber, false}, + {"NanoVDB", MeshSdfMethod::NanoVDB, true}, + }}; + + std::vector const samplePositions = { + {0.0f, 0.0f, 0.0f}, // deep interior + {8.0f, 0.0f, 0.0f}, // far exterior + {4.75f, 0.0f, 0.0f}, // inside near face + {5.25f, 0.0f, 0.0f}, // outside near face + {4.75f, 4.75f, 0.0f}, // inside near sharp edge + {5.25f, 5.25f, 0.0f}, // outside near sharp edge + {4.75f, 4.75f, 4.75f}, // inside near sharp corner + {5.25f, 5.25f, 5.25f}, // outside near sharp corner + }; + + for (auto const & c : cases) + { + if (c.requiresVdb && !vdbSupported) + { + std::cout << "Skipping " << c.label + << " cube sharp-feature comparison because NanoVDB is unavailable: " + << core->getProgramManager().getVdbSupportFailureReason() << std::endl; + continue; + } + + MeshSdfEvaluationConfig cfg{}; + cfg.method = c.method; + cfg.useEarlyExit = false; + cfg.fwnUseSignCache = false; + cfg.fwnBeta = 4.0f; + cfg.nanovdbVoxelSize_mm = 0.1f; + + MeshBVHBuildParams params; + params.maxPrimitivesPerLeaf = 4; + params.maxDepth = 32; + params.traversalCost = 1.0f; + params.intersectionCost = 1.0f; + MeshBVHBuilder builder; + + ResourceKey key(ResourceId{1001}, ResourceType::Mesh); + SpatialMeshResource resource(key, builder.build(vertices, indices, params), cfg); + + auto & settings = core->getResourceContext()->getRenderingSettings(); + settings.meshInflationDistance = 0.0f; + settings.meshFwnBeta = cfg.fwnBeta; + settings.meshFwnFarFieldFactor = 0.0f; + settings.flags |= RF_DISABLE_MESH_EARLY_EXIT; + if (c.method == MeshSdfMethod::FastWindingNumber) + { + settings.flags |= RF_USE_MESH_FWN; + } + else + { + settings.flags &= ~RF_USE_MESH_FWN; + } + + primitives->clear(); + resource.write(*primitives); + if (c.method == MeshSdfMethod::NanoVDB) + { + ASSERT_FALSE(resource.hasNanoVdbBuildIssue()) << c.label << ": " + << resource.getNanoVdbBuildInfo().reason; + ASSERT_EQ(resource.getNanoVdbBuildInfo().result, + SpatialMeshResource::NanoVdbBuildResult::Built) + << c.label; + } + primitives->write(); + + if (c.method == MeshSdfMethod::VoxelAccelerated) + { + auto params = resource.getVoxelGridBuildParams(); + ASSERT_TRUE(params.has_value()) << c.label; + ASSERT_EQ(core->buildMeshVoxelGrids({*params}), 1u) << c.label; + } + else if (c.method == MeshSdfMethod::FastWindingNumber) + { + auto params = resource.getFwnAggregateBuildParams(); + ASSERT_TRUE(params.has_value()) << c.label; + ASSERT_EQ(core->buildMeshFwnAggregates({*params}), 1u) << c.label; + } + + primitives->write(); + auto const distances = sampler.evaluateSdfBatch(samplePositions, *primitives, 0.0f); + ASSERT_EQ(distances.size(), samplePositions.size()) << c.label; + + EXPECT_LT(distances[0], -4.0f) << c.label << " center distance=" << distances[0]; + EXPECT_GT(distances[1], 2.0f) << c.label << " exterior distance=" << distances[1]; + EXPECT_LT(distances[2], -0.05f) << c.label << " face-inside distance=" << distances[2]; + EXPECT_GT(distances[3], 0.05f) << c.label << " face-outside distance=" << distances[3]; + EXPECT_LT(distances[4], -0.05f) << c.label << " edge-inside distance=" << distances[4]; + EXPECT_GT(distances[5], 0.05f) << c.label << " edge-outside distance=" << distances[5]; + EXPECT_LT(distances[6], -0.05f) << c.label << " corner-inside distance=" << distances[6]; + EXPECT_GT(distances[7], 0.05f) << c.label << " corner-outside distance=" << distances[7]; + } + } + + TEST_F(MeshSdfPerformance_Test, GpuSdfEvaluation_NanoVdbNearestSampling_ReturnsMillimetres) + { + if (!m_context->isValid()) + { + GTEST_SKIP() << "OpenCL context not available"; + } + + std::vector vertices; + std::vector indices; + createCubeMesh(vertices, indices, 5.0f); + + auto core = std::make_shared( + m_context, RequiredCapabilities::ComputeOnly, m_logger); + auto primitives = core->getPrimitives(); + ASSERT_NE(primitives, nullptr); + + if (!core->getProgramManager().isVdbSupported()) + { + GTEST_SKIP() << "NanoVDB is unavailable: " + << core->getProgramManager().getVdbSupportFailureReason(); } + + gladius::compute::ManifoldDualContouringProgram sampler( + m_context, core->getResourceContext()); + sampler.setLogger(m_logger); + sampler.setEnableVdb(true); + sampler.setModelKernel(R"CLC( + float4 model(float3 pos, PAYLOAD_ARGS) + { + float const distance = payload(pos, 0, primitivesSize, PASS_PAYLOAD_ARGS); + return (float4)(0.0f, 0.0f, 0.0f, distance); + } + )CLC"); + + MeshSdfEvaluationConfig cfg{}; + cfg.method = MeshSdfMethod::NanoVDB; + cfg.useEarlyExit = false; + cfg.nanovdbVoxelSize_mm = 0.1f; + + MeshBVHBuildParams params; + params.maxPrimitivesPerLeaf = 4; + params.maxDepth = 32; + params.traversalCost = 1.0f; + params.intersectionCost = 1.0f; + MeshBVHBuilder builder; + + ResourceKey key(ResourceId{1002}, ResourceType::Mesh); + SpatialMeshResource resource(key, builder.build(vertices, indices, params), cfg); + + auto & settings = core->getResourceContext()->getRenderingSettings(); + settings.meshInflationDistance = 0.0f; + settings.flags |= RF_DISABLE_MESH_EARLY_EXIT; + settings.flags &= ~RF_USE_MESH_FWN; + + primitives->clear(); + resource.write(*primitives); + ASSERT_FALSE(resource.hasNanoVdbBuildIssue()) << resource.getNanoVdbBuildInfo().reason; + ASSERT_EQ(resource.getNanoVdbBuildInfo().result, + SpatialMeshResource::NanoVdbBuildResult::Built); + primitives->write(); + + std::vector const samplePositions = { + {0.0f, 0.0f, 0.0f}, + {8.0f, 0.0f, 0.0f}, + {4.75f, 0.0f, 0.0f}, + {5.25f, 0.0f, 0.0f}, + {5.25f, 5.25f, 0.0f}, + }; + + settings.approximation = AM_FULL_MODEL; + auto const interpolated = sampler.evaluateSdfBatch(samplePositions, *primitives, 0.0f); + + settings.approximation = static_cast(AM_FULL_MODEL | AM_DISABLE_INTERPOLATION); + auto const nearest = sampler.evaluateSdfBatch(samplePositions, *primitives, 0.0f); + + ASSERT_EQ(interpolated.size(), samplePositions.size()); + ASSERT_EQ(nearest.size(), samplePositions.size()); + + for (size_t i = 0u; i < samplePositions.size(); ++i) + { + EXPECT_EQ(interpolated[i] < 0.0f, nearest[i] < 0.0f) + << "sample " << i << " interpolated=" << interpolated[i] + << " nearest=" << nearest[i]; + EXPECT_NEAR(interpolated[i], nearest[i], 0.25f) + << "sample " << i << " should not change units when nearest VDB sampling is used"; + } + + EXPECT_LT(nearest[0], -4.0f) << "center distance=" << nearest[0]; + EXPECT_GT(nearest[1], 2.0f) << "exterior distance=" << nearest[1]; + EXPECT_LT(nearest[2], -0.05f) << "face-inside distance=" << nearest[2]; + EXPECT_GT(nearest[3], 0.05f) << "face-outside distance=" << nearest[3]; + EXPECT_GT(nearest[4], 0.10f) << "edge-outside magnitude should remain in millimetres"; } /// T001-T003: Baseline performance benchmark with procedural sphere diff --git a/gladius/tests/unittests/Importer3mfMerge_test.cpp b/gladius/tests/unittests/Importer3mfMerge_test.cpp index eec3ffc8..44a84429 100644 --- a/gladius/tests/unittests/Importer3mfMerge_test.cpp +++ b/gladius/tests/unittests/Importer3mfMerge_test.cpp @@ -5,6 +5,7 @@ #include #include #include +#include #include #include @@ -58,6 +59,33 @@ namespace gladius_tests return tempPath; } + [[nodiscard]] std::filesystem::path createOpenQuad3mf() const + { + auto sourceDoc = std::make_shared(m_core); + sourceDoc->newEmptyModel(); + + vdb::TriangleMesh mesh; + mesh.vertices = { + openvdb::Vec3s{0.0f, 0.0f, 0.0f}, + openvdb::Vec3s{10.0f, 0.0f, 0.0f}, + openvdb::Vec3s{10.0f, 10.0f, 0.0f}, + openvdb::Vec3s{0.0f, 10.0f, 0.0f}, + }; + mesh.indices = { + openvdb::Vec3I{0, 1, 2}, + openvdb::Vec3I{0, 2, 3}, + }; + + sourceDoc->addMeshResource(std::move(mesh), "open quad"); + + auto const uniqueSuffix = + std::chrono::steady_clock::now().time_since_epoch().count(); + auto const tempPath = std::filesystem::temp_directory_path() / + fmt::format("gladius_nanovdb_open_quad_{}.3mf", uniqueSuffix); + sourceDoc->saveAs(tempPath, false); + return tempPath; + } + std::shared_ptr m_doc; std::shared_ptr m_core; events::SharedLogger m_logger; @@ -254,6 +282,33 @@ namespace gladius_tests std::filesystem::remove(tempFile); } + TEST_F(Importer3mfMerge_Test, + ImporterLoad_WithOpenNanoVdbMesh_DegradeReportsMeshQualityIssue) + { + auto const tempFile = createOpenQuad3mf(); + + MeshSdfEvaluationConfig meshEvaluationConfig; + meshEvaluationConfig.method = MeshSdfMethod::NanoVDB; + meshEvaluationConfig.nanovdbVoxelSize_mm = 0.1f; + + io::Importer3mf importer(m_logger); + importer.setMeshSdfEvaluationConfig(meshEvaluationConfig); + importer.setNanoVdbBuildPolicy( + NanoVdbBuildPolicy{0u, NanoVdbFailurePolicy::Degrade}); + + m_doc->newEmptyModel(); + ASSERT_NO_THROW(importer.load(tempFile, *m_doc)); + + auto const summary = m_doc->getMeshQualityIssueSummary(); + EXPECT_TRUE(summary.hasIssue); + EXPECT_EQ(summary.affectedMeshCount, 1u); + EXPECT_GT(summary.boundaryEdgeCount, 0u); + EXPECT_NE(summary.message.find("No repair or fallback was applied silently"), + std::string::npos); + + std::filesystem::remove(tempFile); + } + TEST_F(Importer3mfMerge_Test, DocumentLoad_WithNanoVdbHugeMesh_ThrowsRejectedError) { auto const tempFile = createLargeBox3mf(4000.0f, 4000.0f, 4000.0f); @@ -268,4 +323,18 @@ namespace gladius_tests std::filesystem::remove(tempFile); } + TEST_F(Importer3mfMerge_Test, DocumentLoad_WithNanoVdbOpenMesh_ThrowsRejectedError) + { + auto const tempFile = createOpenQuad3mf(); + + MeshSdfEvaluationConfig meshEvaluationConfig; + meshEvaluationConfig.method = MeshSdfMethod::NanoVDB; + meshEvaluationConfig.nanovdbVoxelSize_mm = 0.1f; + m_doc->setMeshSdfEvaluationConfig(meshEvaluationConfig); + + EXPECT_THROW(m_doc->load(tempFile), NanoVdbBuildRejectedError); + + std::filesystem::remove(tempFile); + } + } // namespace gladius_tests diff --git a/gladius/tests/unittests/MeshBVH_tests.cpp b/gladius/tests/unittests/MeshBVH_tests.cpp index 5867d50d..a6c4d3e5 100644 --- a/gladius/tests/unittests/MeshBVH_tests.cpp +++ b/gladius/tests/unittests/MeshBVH_tests.cpp @@ -53,6 +53,27 @@ namespace gladius::tests }; } + static void createDuplicateVertexCubeMesh(std::vector & vertices, + std::vector & indices) + { + std::vector sharedVertices; + std::vector sharedIndices; + createCubeMesh(sharedVertices, sharedIndices); + + vertices.clear(); + indices.clear(); + vertices.reserve(sharedIndices.size() * 3u); + indices.reserve(sharedIndices.size()); + for (auto const & tri : sharedIndices) + { + int const base = static_cast(vertices.size()); + vertices.push_back(sharedVertices[tri.i0]); + vertices.push_back(sharedVertices[tri.i1]); + vertices.push_back(sharedVertices[tri.i2]); + indices.emplace_back(base, base + 1, base + 2); + } + } + // Helper to create a single triangle static void createSingleTriangle(std::vector & vertices, std::vector & indices) @@ -295,6 +316,25 @@ namespace gladius::tests EXPECT_EQ(stats.degenerateTriangleCount, 0); EXPECT_EQ(stats.boundaryEdgeCount, 0); EXPECT_EQ(stats.nonManifoldEdgeCount, 0); + EXPECT_FALSE(result.quality.hasIssues()); + } + + /// Exact duplicate vertex records are common in STL-style triangle soups. They should be + /// certified by exact position adjacency without silently modifying the source mesh. + TEST_F(MeshBVHBuilder_Test, Build_DuplicateVertexWatertightCube_ReportsCleanStats) + { + std::vector vertices; + std::vector indices; + createDuplicateVertexCubeMesh(vertices, indices); + + auto result = builder.build(vertices, indices); + ASSERT_FALSE(result.empty()); + + auto const & stats = builder.getLastBuildStats(); + EXPECT_EQ(stats.degenerateTriangleCount, 0); + EXPECT_EQ(stats.boundaryEdgeCount, 0); + EXPECT_EQ(stats.nonManifoldEdgeCount, 0); + EXPECT_FALSE(result.quality.hasIssues()); } /// A degenerate (zero-area) triangle should be counted but must not abort the build. @@ -318,6 +358,8 @@ namespace gladius::tests auto const & stats = builder.getLastBuildStats(); EXPECT_EQ(stats.degenerateTriangleCount, 1); + EXPECT_EQ(result.quality.degenerateTriangleCount, 1); + EXPECT_TRUE(result.quality.hasIssues()); } /// Two triangles sharing a single edge: 5 boundary edges, 1 manifold edge. @@ -341,6 +383,9 @@ namespace gladius::tests auto const & stats = builder.getLastBuildStats(); EXPECT_EQ(stats.boundaryEdgeCount, 4); // four outer edges EXPECT_EQ(stats.nonManifoldEdgeCount, 0); + EXPECT_EQ(result.quality.boundaryEdgeCount, 4); + EXPECT_EQ(result.quality.nonManifoldEdgeCount, 0); + EXPECT_TRUE(result.quality.hasIssues()); } /// Three coplanar triangles sharing one edge → non-manifold edge. @@ -373,6 +418,8 @@ namespace gladius::tests auto const & stats = builder.getLastBuildStats(); EXPECT_EQ(stats.nonManifoldEdgeCount, 1) << "Edge (0,1) is shared by three faces and must be flagged non-manifold"; + EXPECT_EQ(result.quality.nonManifoldEdgeCount, 1); + EXPECT_TRUE(result.quality.hasIssues()); // Verify that the edge entry on the perpendicular fin (tri 2) was NOT // assigned a neighbour — only the two coplanar triangles should be diff --git a/gladius/tests/unittests/SpatialMeshResource_tests.cpp b/gladius/tests/unittests/SpatialMeshResource_tests.cpp index 3898ab0d..2c5ed223 100644 --- a/gladius/tests/unittests/SpatialMeshResource_tests.cpp +++ b/gladius/tests/unittests/SpatialMeshResource_tests.cpp @@ -158,6 +158,34 @@ namespace gladius::tests EXPECT_EQ(resource.getTriangleCount(), 1u); } + TEST_F(SpatialMeshResource_Test, MeshQualityDiagnostics_OpenMesh_ExposesRecoveryMessage) + { + std::vector vertices = { + {0.f, 0.f, 0.f, 0.f}, + {1.f, 0.f, 0.f, 0.f}, + {1.f, 1.f, 0.f, 0.f}, + {0.f, 1.f, 0.f, 0.f}, + }; + std::vector indices = { + {0, 1, 2}, + {0, 2, 3}, + }; + + ResourceKey key(ResourceId{4}, ResourceType::Mesh); + SpatialMeshResource resource(key, vertices, indices); + + auto const & diagnostics = resource.getMeshQualityDiagnostics(); + EXPECT_TRUE(resource.hasMeshQualityIssues()); + EXPECT_EQ(diagnostics.boundaryEdgeCount, 4); + EXPECT_EQ(diagnostics.nonManifoldEdgeCount, 0); + + auto const message = resource.formatMeshQualityMessage("open quad"); + EXPECT_NE(message.find("open quad"), std::string::npos); + EXPECT_NE(message.find("4 boundary edges"), std::string::npos); + EXPECT_NE(message.find("No repair or fallback was applied silently"), std::string::npos); + EXPECT_NE(message.find("Fast Winding Number"), std::string::npos); + } + // ======================================================================== // Unsigned Distance Validation Tests (T043) // ======================================================================== From fca3910938ac4d3dd72697e7cd7e6714eed5d592 Mon Sep 17 00:00:00 2001 From: Jan Orend <56254096+3dJan@users.noreply.github.com> Date: Wed, 13 May 2026 22:05:02 +0200 Subject: [PATCH 04/72] always write newly allocated glimagebuffers to clear memory --- gladius/src/GLImageBuffer.cpp | 3 +++ 1 file changed, 3 insertions(+) diff --git a/gladius/src/GLImageBuffer.cpp b/gladius/src/GLImageBuffer.cpp index 8d4de96f..2872ed8f 100644 --- a/gladius/src/GLImageBuffer.cpp +++ b/gladius/src/GLImageBuffer.cpp @@ -38,7 +38,10 @@ namespace gladius if (m_ComputeContext.outputMethod() == OutputMethod::readpixel) { setupForReadPixel(); + write(); } + + m_dirty = false; } GLImageBuffer::~GLImageBuffer() From 54e4f6e1f2fb28434beb7696e41d7dc7f750f340 Mon Sep 17 00:00:00 2001 From: Jan Orend <56254096+3dJan@users.noreply.github.com> Date: Thu, 14 May 2026 07:21:56 +0200 Subject: [PATCH 05/72] RealtimeRaymarchingController --- gladius/src/compute/ComputeCore.cpp | 75 ++- gladius/src/compute/ComputeCore.h | 17 + gladius/src/ui/RenderWindow.cpp | 433 +++++++++++++++--- gladius/src/ui/RenderWindow.h | 9 + .../src/ui/render/AsyncRenderController.cpp | 31 +- gladius/src/ui/render/AsyncRenderController.h | 12 +- gladius/src/ui/render/AsyncRenderTypes.h | 4 + .../ui/render/RealtimeRaymarchController.cpp | 297 ++++++++++++ .../ui/render/RealtimeRaymarchController.h | 92 ++++ .../ui/RealtimeRaymarchController_tests.cpp | 142 ++++++ 10 files changed, 1046 insertions(+), 66 deletions(-) create mode 100644 gladius/src/ui/render/RealtimeRaymarchController.cpp create mode 100644 gladius/src/ui/render/RealtimeRaymarchController.h create mode 100644 gladius/tests/unittests/ui/RealtimeRaymarchController_tests.cpp diff --git a/gladius/src/compute/ComputeCore.cpp b/gladius/src/compute/ComputeCore.cpp index 6cc8795d..2f318adb 100644 --- a/gladius/src/compute/ComputeCore.cpp +++ b/gladius/src/compute/ComputeCore.cpp @@ -12,6 +12,7 @@ #include #include #include +#include #include #include @@ -377,14 +378,11 @@ namespace gladius std::lock_guard lock(m_computeMutex, std::adopt_lock); - if (isAutoUpdateBoundingBoxEnabled()) - { - markBoundingBoxStale(); - } auto & paramBuf = getResourceContext()->getParameterBuffer(); auto & parameter = paramBuf.getData(); - parameter.clear(); + std::vector updatedParameter; + updatedParameter.reserve(parameter.size()); int currentIndex = 0; for (auto & model : assembly.getFunctions()) @@ -413,21 +411,21 @@ namespace gladius if (auto const typedValuePtr = std::get_if(&variant)) { param->setLookUpIndex(currentIndex); - parameter.push_back(*typedValuePtr); + updatedParameter.push_back(*typedValuePtr); ++currentIndex; } if (auto const typedValuePtr = std::get_if(&variant)) { param->setLookUpIndex(currentIndex); - parameter.push_back(static_cast(*typedValuePtr)); + updatedParameter.push_back(static_cast(*typedValuePtr)); ++currentIndex; } if (auto const typedValuePtr = std::get_if(&variant)) { param->setLookUpIndex(currentIndex); - parameter.push_back(typedValuePtr->x); - parameter.push_back(typedValuePtr->y); - parameter.push_back(typedValuePtr->z); + updatedParameter.push_back(typedValuePtr->x); + updatedParameter.push_back(typedValuePtr->y); + updatedParameter.push_back(typedValuePtr->z); currentIndex += 3; } @@ -441,7 +439,7 @@ namespace gladius for (int col = 0; col < 4; ++col) { float const value = matrix[row][col]; - parameter.push_back(value); + updatedParameter.push_back(value); ++currentIndex; } } @@ -450,6 +448,15 @@ namespace gladius } } + bool const parameterChanged = updatedParameter != parameter; + if (parameterChanged) + { + if (isAutoUpdateBoundingBoxEnabled()) + { + markBoundingBoxStale(); + } + } + parameter = std::move(updatedParameter); paramBuf.write(); // NOTE: Do NOT call invalidatePreCompSdf() here! // We want to keep the old SDF valid for preview rendering during interactive @@ -2084,6 +2091,52 @@ namespace gladius return false; } + bool ComputeCore::renderSceneComputeOnlyWithSettings(cl::CommandQueue const & commandQueue, + size_t startLine, + size_t endLine, + ImageRGBA & targetImage, + RenderingSettings settings, + cl::Event * completionEvent) + { + ProfileFunction + + if (!m_computeMutex.try_lock()) + { + return false; + } + std::lock_guard lock(m_computeMutex, std::adopt_lock); + + recompileIfRequired(); + + if (getBestRenderProgram()->isCompilationInProgress()) + { + LOG_LOCATION + return false; + } + + m_lastUsedApproximation = settings.approximation; + m_lastUsedHQApproximation = settings.approximation; + m_lastUsedPreviewApproximation = settings.approximation; + + cl::Event const renderEvent = getBestRenderProgram()->renderSceneAsync( + commandQueue, *m_primitives, targetImage, settings, m_sliceHeight_mm, startLine, endLine); + + if (completionEvent != nullptr) + { + *completionEvent = renderEvent; + } + + if (renderEvent()) + { + commandQueue.flush(); + LOG_LOCATION + return true; + } + + LOG_LOCATION + return false; + } + LowResPreviewRenderStatus ComputeCore::renderLowResPreview() const { ProfileFunction diff --git a/gladius/src/compute/ComputeCore.h b/gladius/src/compute/ComputeCore.h index 07f05cf1..b75f8c5a 100644 --- a/gladius/src/compute/ComputeCore.h +++ b/gladius/src/compute/ComputeCore.h @@ -434,6 +434,23 @@ namespace gladius ImageRGBA & targetImage, cl::Event * completionEvent = nullptr); + /// @brief Render scene to a pure OpenCL image buffer with caller-provided settings. + /// @param commandQueue OpenCL command queue used for dispatch + /// @param startLine Starting line for rendering + /// @param endLine Ending line for rendering + /// @param targetImage Pure CL image buffer to render into (no GL texture) + /// @param settings Rendering settings copy used for this dispatch + /// @param completionEvent Optional event for async completion tracking + /// @return true if rendering was enqueued successfully, false otherwise + /// @thread Any thread with compute context access + [[nodiscard]] bool renderSceneComputeOnlyWithSettings( + cl::CommandQueue const & commandQueue, + size_t startLine, + size_t endLine, + ImageRGBA & targetImage, + RenderingSettings settings, + cl::Event * completionEvent = nullptr); + /// @brief Render the current low-resolution preview when the precomputed SDF is ready. /// @return Rendered when a preview frame was produced, Skipped when the SDF is not ready, or Failed on an execution/precondition error. [[nodiscard]] LowResPreviewRenderStatus renderLowResPreview() const; diff --git a/gladius/src/ui/RenderWindow.cpp b/gladius/src/ui/RenderWindow.cpp index a9fd48e2..c69c45d0 100644 --- a/gladius/src/ui/RenderWindow.cpp +++ b/gladius/src/ui/RenderWindow.cpp @@ -147,6 +147,8 @@ namespace gladius::ui m_configManager->getValue("renderWindow", "permanentCenteringEnabled", false); } + m_realtimeRaymarchController.configure(loadRealtimeRaymarchConfig()); + // Don't initialize async rendering here - will be done lazily on first render } @@ -202,7 +204,9 @@ namespace gladius::ui m_asyncController->start(); m_asyncEpochCounter.store(0, std::memory_order_release); m_asyncCurrentEpoch.store(0, std::memory_order_release); + m_asyncViewEpoch.store(0, std::memory_order_release); m_asyncInFlightEpoch.store(0, std::memory_order_release); + m_asyncInFlightViewEpoch.store(0, std::memory_order_release); m_asyncFrameCounter.store(0, std::memory_order_release); m_asyncJobInFlight.store(false, std::memory_order_release); m_asyncSdfJobInFlight.store(false, std::memory_order_release); @@ -218,7 +222,9 @@ namespace gladius::ui } m_asyncEpochCounter.store(0, std::memory_order_release); m_asyncCurrentEpoch.store(0, std::memory_order_release); + m_asyncViewEpoch.store(0, std::memory_order_release); m_asyncInFlightEpoch.store(0, std::memory_order_release); + m_asyncInFlightViewEpoch.store(0, std::memory_order_release); m_asyncFrameCounter.store(0, std::memory_order_release); m_asyncJobInFlight.store(false, std::memory_order_release); m_asyncSdfJobInFlight.store(false, std::memory_order_release); @@ -237,7 +243,9 @@ namespace gladius::ui auto const oldEpoch = m_asyncCurrentEpoch.load(std::memory_order_acquire); auto const newEpoch = m_asyncEpochCounter.fetch_add(1, std::memory_order_acq_rel) + 1; m_asyncCurrentEpoch.store(newEpoch, std::memory_order_release); + m_asyncViewEpoch.fetch_add(1, std::memory_order_acq_rel); m_asyncInFlightEpoch.store(0, std::memory_order_release); + m_asyncInFlightViewEpoch.store(0, std::memory_order_release); m_asyncJobInFlight.store(false, std::memory_order_release); m_asyncSdfJobInFlight.store(false, std::memory_order_release); m_asyncSdfInFlightEpoch.store(0, std::memory_order_release); @@ -259,6 +267,7 @@ namespace gladius::ui m_asyncProgressiveBuffer = nullptr; } m_asyncProgressiveEpoch.store(0, std::memory_order_release); + m_asyncProgressiveViewEpoch.store(0, std::memory_order_release); // IMPORTANT: Release any other Writing buffers from old epoch // (they belong to cancelled jobs and should be returned to Idle) @@ -269,6 +278,67 @@ namespace gladius::ui } } + void RenderWindow::invalidateCameraView() + { + ProfileFunction; + m_dirty = true; + m_renderWindowState.isMoving = true; + m_renderWindowState.currentLine = 0; + m_renderWindowState.renderingStepSize = kInitialProgressiveStepSize; + m_renderWindowState.isRendering = false; + m_forceLowResRenderOnNextFrame.store(true, std::memory_order_release); + m_lastLowResRenderTime = std::chrono::system_clock::now(); + m_suppressHQDisplay.store(false, std::memory_order_release); + m_asyncViewEpoch.fetch_add(1, std::memory_order_acq_rel); + + if (m_core) + { + m_core->invalidateDistanceInitBuffer(); + } + + if (m_asyncController && m_asyncProgressiveBuffer && + !m_asyncJobInFlight.load(std::memory_order_acquire)) + { + [[maybe_unused]] bool const released = + m_asyncController->tryTransitionBuffer(m_asyncProgressiveBuffer, + async_rendering::FrameState::Writing, + async_rendering::FrameState::Idle); + m_asyncProgressiveBuffer = nullptr; + m_asyncProgressiveEpoch.store(0, std::memory_order_release); + m_asyncProgressiveViewEpoch.store(0, std::memory_order_release); + } + } + + async_rendering::RealtimeRaymarchConfig RenderWindow::loadRealtimeRaymarchConfig() const + { + async_rendering::RealtimeRaymarchConfig config{}; + if (!m_configManager) + { + return config; + } + + auto const mode = m_configManager->getValue( + "renderWindow", "realtimeRaymarchMode", "auto"); + config.mode = async_rendering::realtimeRaymarchModeFromString(mode); + config.targetFrameTimeMs = + m_configManager->getValue("renderWindow", "realtimeRaymarchTargetMs", 25.0f); + return config; + } + + void RenderWindow::saveRealtimeRaymarchMode( + async_rendering::RealtimeRaymarchMode mode) const + { + if (!m_configManager) + { + return; + } + + m_configManager->setValue("renderWindow", + "realtimeRaymarchMode", + std::string(async_rendering::realtimeRaymarchModeToString(mode))); + m_configManager->save(); + } + void RenderWindow::renderWindow() { ProfileFunction; @@ -303,19 +373,21 @@ namespace gladius::ui // Use the front buffer from triple buffering system auto * frontBuf = m_asyncController->frontBuffer(); auto const currentEpoch = m_asyncCurrentEpoch.load(std::memory_order_acquire); + auto const currentViewEpoch = m_asyncViewEpoch.load(std::memory_order_acquire); // For HQ frames (front buffer from progressive rendering) we require an epoch match // to avoid showing stale HQ results from an old parameter set. // For low-res preview during rapid parameter edits, we fall back to m_resultImage // which is updated synchronously and always shows *something* recent. bool const epochMatches = frontBuf && frontBuf->epoch == currentEpoch; + bool const viewMatches = frontBuf && frontBuf->viewEpoch == currentViewEpoch; bool const useFrontBuffer = frontBuf && frontBuf->image && epochMatches && - !m_renderWindowState.isRendering && - !m_renderWindowState.isMoving && + viewMatches && !m_renderWindowState.isRendering && !m_suppressHQDisplay.load(std::memory_order_acquire); bool const useProgressiveBuffer = m_asyncProgressiveBuffer && m_asyncProgressiveBuffer->image && m_asyncProgressiveEpoch.load(std::memory_order_acquire) == currentEpoch && + m_asyncProgressiveViewEpoch.load(std::memory_order_acquire) == currentViewEpoch && !m_renderWindowState.isMoving && !m_suppressHQDisplay.load(std::memory_order_acquire); @@ -470,6 +542,45 @@ namespace gladius::ui } m_renderWindowState.renderQuality = quality; + ImGui::Separator(); + if (ImGui::BeginMenu("Realtime Raymarch")) + { + auto config = m_realtimeRaymarchController.config(); + auto const setMode = [&](async_rendering::RealtimeRaymarchMode mode) + { + config.mode = mode; + m_realtimeRaymarchController.configure(config); + saveRealtimeRaymarchMode(mode); + }; + + if (ImGui::MenuItem( + "Auto", nullptr, config.mode == async_rendering::RealtimeRaymarchMode::Auto)) + { + setMode(async_rendering::RealtimeRaymarchMode::Auto); + } + if (ImGui::MenuItem( + "Off", nullptr, config.mode == async_rendering::RealtimeRaymarchMode::Off)) + { + setMode(async_rendering::RealtimeRaymarchMode::Off); + } + if (ImGui::MenuItem( + "Force", nullptr, config.mode == async_rendering::RealtimeRaymarchMode::Force)) + { + setMode(async_rendering::RealtimeRaymarchMode::Force); + } + + ImGui::Separator(); + ImGui::Text("Budget: %.1f ms", config.targetFrameTimeMs); + if (ImGui::IsItemHovered()) + { + ImGui::SetTooltip( + "Auto attempts full-resolution async raymarching when " + "recent timings fit this budget; otherwise Gladius " + "falls back to low-res preview/progressive rendering."); + } + ImGui::EndMenu(); + } + ImGui::EndMenu(); } @@ -567,7 +678,7 @@ namespace gladius::ui if (m_camera.mouseMotionHandler(mousePos.x, mousePos.y)) { - invalidateView(); + invalidateCameraView(); } if (!ImGui::IsAnyMouseDown()) { @@ -733,6 +844,7 @@ namespace gladius::ui m_parameterDirty.store(true, std::memory_order_release); m_lowResFeedbackPending.store(true, std::memory_order_release); m_modelModifiedSinceLastCenter = true; + m_asyncViewEpoch.fetch_add(1, std::memory_order_acq_rel); // Mark bbox stale instead of resetting — preserves cached bbox for reuse with extra margin m_core->markBoundingBoxStale(); @@ -829,7 +941,7 @@ namespace gladius::ui void RenderWindow::centerView() { m_centerViewRequested = true; - invalidateView(); + invalidateCameraView(); } void RenderWindow::setTopView() @@ -837,7 +949,7 @@ namespace gladius::ui // Top view: looking down the Z axis (pitch = +90°, yaw = 0°) m_camera.setAngle(CL_M_PI_F / 2.0f, 0.0f); onCameraManuallyMoved(); - invalidateView(); + invalidateCameraView(); } void RenderWindow::setFrontView() @@ -845,7 +957,7 @@ namespace gladius::ui // Front view: looking along the Y axis (pitch = 0°, yaw = -90°) m_camera.setAngle(0.0f, -CL_M_PI_F / 2.0f); onCameraManuallyMoved(); - invalidateView(); + invalidateCameraView(); } void RenderWindow::setLeftView() @@ -853,7 +965,7 @@ namespace gladius::ui // Left view: looking along the X axis (pitch = 0°, yaw = 0°) m_camera.setAngle(0.0f, 0.0f); onCameraManuallyMoved(); - invalidateView(); + invalidateCameraView(); } void RenderWindow::setRightView() @@ -861,7 +973,7 @@ namespace gladius::ui // Right view: looking along the negative X axis (pitch = 0°, yaw = 180°) m_camera.setAngle(0.0f, CL_M_PI_F); onCameraManuallyMoved(); - invalidateView(); + invalidateCameraView(); } void RenderWindow::setBackView() @@ -869,7 +981,7 @@ namespace gladius::ui // Back view: looking along the negative Y axis (pitch = 0°, yaw = 90°) m_camera.setAngle(0.0f, CL_M_PI_F / 2.0f); onCameraManuallyMoved(); - invalidateView(); + invalidateCameraView(); } void RenderWindow::setBottomView() @@ -877,7 +989,7 @@ namespace gladius::ui // Bottom view: looking up the Z axis (pitch = -90°, yaw = 0°) m_camera.setAngle(-CL_M_PI_F / 2.0f, 0.0f); onCameraManuallyMoved(); - invalidateView(); + invalidateCameraView(); } void RenderWindow::setIsometricView() @@ -887,7 +999,7 @@ namespace gladius::ui float const yaw = CL_M_PI_F / 4.0f; // 45 degrees m_camera.setAngle(pitch, yaw); onCameraManuallyMoved(); - invalidateView(); + invalidateCameraView(); } void RenderWindow::togglePerspective() @@ -895,7 +1007,7 @@ namespace gladius::ui // This would require camera implementation to support orthographic/perspective toggle // For now, we'll save current view and restore it saveCurrentView(); - invalidateView(); + invalidateCameraView(); } void RenderWindow::frameAll() @@ -914,7 +1026,7 @@ namespace gladius::ui } m_camera.adjustDistanceToTarget(*bbox, m_renderWindowSize_px.x, m_renderWindowSize_px.y); - invalidateView(); + invalidateCameraView(); } void RenderWindow::zoomSelected() @@ -930,7 +1042,7 @@ namespace gladius::ui Position newLookAt{currentLookAt.x - m_panSensitivity, currentLookAt.y, currentLookAt.z}; m_camera.setLookAt(newLookAt); onCameraManuallyMoved(); - invalidateView(); + invalidateCameraView(); } void RenderWindow::panRight() @@ -940,7 +1052,7 @@ namespace gladius::ui Position newLookAt{currentLookAt.x + m_panSensitivity, currentLookAt.y, currentLookAt.z}; m_camera.setLookAt(newLookAt); onCameraManuallyMoved(); - invalidateView(); + invalidateCameraView(); } void RenderWindow::panUp() @@ -950,7 +1062,7 @@ namespace gladius::ui Position newLookAt{currentLookAt.x, currentLookAt.y, currentLookAt.z + m_panSensitivity}; m_camera.setLookAt(newLookAt); onCameraManuallyMoved(); - invalidateView(); + invalidateCameraView(); } void RenderWindow::panDown() @@ -960,35 +1072,35 @@ namespace gladius::ui Position newLookAt{currentLookAt.x, currentLookAt.y, currentLookAt.z - m_panSensitivity}; m_camera.setLookAt(newLookAt); onCameraManuallyMoved(); - invalidateView(); + invalidateCameraView(); } void RenderWindow::rotateLeft() { m_camera.rotate(0.0f, -m_rotateSensitivity); onCameraManuallyMoved(); - invalidateView(); + invalidateCameraView(); } void RenderWindow::rotateRight() { m_camera.rotate(0.0f, m_rotateSensitivity); onCameraManuallyMoved(); - invalidateView(); + invalidateCameraView(); } void RenderWindow::rotateUp() { m_camera.rotate(m_rotateSensitivity, 0.0f); onCameraManuallyMoved(); - invalidateView(); + invalidateCameraView(); } void RenderWindow::rotateDown() { m_camera.rotate(-m_rotateSensitivity, 0.0f); onCameraManuallyMoved(); - invalidateView(); + invalidateCameraView(); } void RenderWindow::previousView() @@ -1000,7 +1112,7 @@ namespace gladius::ui // Restore the view (this would require camera API support) // For now, we'll invalidate the view - invalidateView(); + invalidateCameraView(); } } @@ -1013,7 +1125,7 @@ namespace gladius::ui // Restore the view (this would require camera API support) // For now, we'll invalidate the view - invalidateView(); + invalidateCameraView(); } } @@ -1052,7 +1164,7 @@ namespace gladius::ui { // Restore the saved view (this would require camera API support) // For now, we'll invalidate the view - invalidateView(); + invalidateCameraView(); } } @@ -1060,7 +1172,7 @@ namespace gladius::ui { m_flyModeEnabled = !m_flyModeEnabled; m_cameraMode = m_flyModeEnabled ? CameraMode::Fly : CameraMode::Orbit; - invalidateView(); + invalidateCameraView(); } void RenderWindow::setOrbitMode() @@ -1522,6 +1634,7 @@ namespace gladius::ui // Update camera now that we know core is ready m_core->applyCamera(m_camera); + m_realtimeRaymarchController.beginFrame(); processAsyncResults(state); @@ -1601,7 +1714,6 @@ namespace gladius::ui { // Delay progressive rendering until low-res feedback is presented state.isRendering = false; - m_asyncJobInFlight.store(false, std::memory_order_release); } } @@ -1702,6 +1814,8 @@ namespace gladius::ui std::clamp(m_renderWindowSize_px.x * state.renderQuality, 1.f, 16000.f)); int const desiredScreenHeight = static_cast( std::clamp(m_renderWindowSize_px.y * state.renderQuality, 1.f, 16000.f)); + m_realtimeRaymarchController.resetForResolution( + static_cast(desiredScreenWidth), static_cast(desiredScreenHeight)); auto const resultImage = m_core->getResultImage(); bool const screenResizeRequired = resultImage && (static_cast(resultImage->getWidth()) != desiredScreenWidth || @@ -1802,6 +1916,30 @@ namespace gladius::ui bool const streamingActive = m_streamingPreviewActive.load(std::memory_order_acquire); bool const useAsyncPreview = !previewJobInFlight && sdfValid; + async_rendering::RealtimeRaymarchGuards const realtimeGuards{ + .hardBlocker = !m_enableHQRendering || sdfJobRunning || + m_asyncBboxJobInFlight.load(std::memory_order_acquire) || + m_core->isAnyCompilationInProgressNonBlocking(), + .renderJobInFlight = m_asyncJobInFlight.load(std::memory_order_acquire), + .previewJobInFlight = previewJobInFlight, + .streamingActive = streamingActive, + .streamingJobInFlight = m_streamingJobInFlight.load(std::memory_order_acquire), + .resizePending = shouldDeferResize || screenResizeRequired}; + + if (m_realtimeRaymarchController.canAttemptRealtime( + static_cast(desiredScreenWidth), + static_cast(desiredScreenHeight), + realtimeGuards)) + { + if (scheduleRealtimeRenderJob(state)) + { + m_lastLowResRenderTime = std::chrono::system_clock::now(); + return; + } + m_realtimeRaymarchController.recordRejectedAttempt(); + state.isRendering = false; + } + if (streamingActive) { // Streaming loop handles preview rendering continuously. @@ -1936,8 +2074,14 @@ namespace gladius::ui ZoneScopedN("ProcessSingleResult"); auto & result = *resultOpt; - auto const currentEpoch = m_asyncCurrentEpoch.load(std::memory_order_acquire); - bool const isOutdated = result.epoch < currentEpoch; + auto const currentEpoch = m_asyncCurrentEpoch.load(std::memory_order_acquire); + bool const isOutdated = result.epoch < currentEpoch; + auto const currentViewEpoch = m_asyncViewEpoch.load(std::memory_order_acquire); + bool const isRealtimeJob = + result.jobType == async_rendering::RenderJobType::RealtimeHighQuality; + bool const isHqRenderJob = + result.jobType == async_rendering::RenderJobType::HighQuality || isRealtimeJob; + bool const isViewOutdated = isHqRenderJob && result.viewEpoch < currentViewEpoch; if (result.jobType == async_rendering::RenderJobType::SDFPrecomputation) { @@ -1991,58 +2135,109 @@ namespace gladius::ui // From here on, only HQ progressive render results are processed. + if (isHqRenderJob && result.computeDurationNs > 0) + { + size_t const renderedLines = result.completedLine > result.startLine + ? result.completedLine - result.startLine + : size_t{0}; + m_realtimeRaymarchController.recordSample( + async_rendering::RealtimeRaymarchSample{ + .durationMs = static_cast( + static_cast(result.computeDurationNs) / 1'000'000.0), + .width = result.width, + .height = result.height, + .renderedLines = renderedLines, + .totalLines = result.height, + .completedFrame = result.completedFrame, + .cancelled = result.cancelled}); + } + if (result.cancelled) { + if (isRealtimeJob) + { + m_realtimeRaymarchController.recordRejectedAttempt(); + } if (result.epoch == m_asyncInFlightEpoch.load(std::memory_order_acquire)) { m_asyncJobInFlight.store(false, std::memory_order_release); m_asyncInFlightEpoch.store(0, std::memory_order_release); + m_asyncInFlightViewEpoch.store(0, std::memory_order_release); state.isRendering = false; } continue; } - // Update progressive scheduling state only for current-epoch results. - // Outdated results are still presented, but must not affect scheduling. - if (!isOutdated) + // Update progressive scheduling state only for current scene/view results. + if (!isOutdated && !isViewOutdated && !isRealtimeJob) { size_t const maxHeight = static_cast(result.height); state.currentLine = std::min(result.completedLine, maxHeight); adjustProgressFromDuration(state, result.computeDurationNs); } - // Promote the newest Ready buffer to Front for display - // Note: Only happens when completedFrame=true (buffer was published) + // Promote the newest Ready buffer to Front for display. + // Stale scene/view results are deliberately discarded before promotion; otherwise + // a late realtime frame could replace a valid front buffer with an old camera or + // parameter state. if (result.completedFrame) { - ZoneScopedN("PromoteReadyToFront"); - auto * newFront = m_asyncController->promoteReadyToFront(); - if (newFront && newFront->image) + if (isOutdated || isViewOutdated) { - // Bind the new frame to ensure GL texture is updated - newFront->image->invalidateContent(); - newFront->image->bind(); - newFront->image->unbind(); + m_asyncController->discardReadyFrame( + result.frameId, result.epoch, result.viewEpoch); + } + else + { + ZoneScopedN("PromoteReadyToFront"); + auto * newFront = m_asyncController->promoteReadyToFront(); + if (newFront && newFront->image) + { + // Bind the new frame to ensure GL texture is updated + newFront->image->invalidateContent(); + newFront->image->bind(); + newFront->image->unbind(); - bool const promoted = m_asyncController->finalizeFrontPromotion(newFront); + [[maybe_unused]] bool const promoted = + m_asyncController->finalizeFrontPromotion(newFront); + } } } if (result.completedFrame) { - if (!isOutdated) + if (!isOutdated && !isViewOutdated) { m_dirty = false; state.isRendering = false; // DON'T force state.isMoving = false - let camera update control this // If camera is still animating, it will set isMoving=true on next frame state.currentLine = 0; // Reset for next frame + if (isRealtimeJob) + { + m_lowResFeedbackPending.store(false, std::memory_order_release); + m_lastLowResRenderTime = std::chrono::system_clock::now(); + m_lastLowResPreviewEpoch.store(result.epoch, std::memory_order_release); + } m_view->stopAnimationMode(); } } else { - if (!isOutdated && m_asyncProgressiveBuffer && m_asyncProgressiveBuffer->image) + if (isViewOutdated && m_asyncProgressiveBuffer && + m_asyncProgressiveViewEpoch.load(std::memory_order_acquire) == result.viewEpoch) + { + [[maybe_unused]] bool const released = + m_asyncController->tryTransitionBuffer(m_asyncProgressiveBuffer, + async_rendering::FrameState::Writing, + async_rendering::FrameState::Idle); + m_asyncProgressiveBuffer = nullptr; + m_asyncProgressiveEpoch.store(0, std::memory_order_release); + m_asyncProgressiveViewEpoch.store(0, std::memory_order_release); + state.isRendering = false; + } + else if (!isOutdated && !isViewOutdated && m_asyncProgressiveBuffer && + m_asyncProgressiveBuffer->image) { m_asyncProgressiveBuffer->image->invalidateContent(); m_view->startAnimationMode(); @@ -2057,10 +2252,11 @@ namespace gladius::ui { m_asyncJobInFlight.store(false, std::memory_order_release); m_asyncInFlightEpoch.store(0, std::memory_order_release); + m_asyncInFlightViewEpoch.store(0, std::memory_order_release); // If we just consumed an outdated job result, ensure we don't block scheduling for // the new epoch. - if (isOutdated) + if (isOutdated || isViewOutdated) { state.isRendering = false; } @@ -2105,6 +2301,7 @@ namespace gladius::ui m_asyncCurrentEpoch.store(job.epoch, std::memory_order_release); } m_asyncController->setLatestEpoch(job.epoch); + job.viewEpoch = m_asyncViewEpoch.load(std::memory_order_acquire); job.frameHint = m_asyncFrameCounter.fetch_add(1, std::memory_order_acq_rel) + 1; job.type = async_rendering::RenderJobType::HighQuality; job.width = static_cast(width); @@ -2159,6 +2356,74 @@ namespace gladius::ui return true; } + bool RenderWindow::scheduleRealtimeRenderJob(RenderWindowState & state) + { + ZoneScoped; + ZoneName("scheduleRealtimeRenderJob", strlen("scheduleRealtimeRenderJob")); + + if (!m_asyncController || !m_asyncController->isRunning() || !m_enableHQRendering) + { + return false; + } + + auto const image = m_core->getResultImage(); + if (!image) + { + return false; + } + + size_t const width = static_cast(image->getWidth()); + size_t const height = static_cast(image->getHeight()); + if (width == 0 || height == 0) + { + return false; + } + + m_asyncController->initializeAsyncResources(*m_core->getComputeContext(), width, height); + + if (m_asyncProgressiveBuffer && !m_asyncJobInFlight.load(std::memory_order_acquire)) + { + [[maybe_unused]] bool const released = + m_asyncController->tryTransitionBuffer(m_asyncProgressiveBuffer, + async_rendering::FrameState::Writing, + async_rendering::FrameState::Idle); + m_asyncProgressiveBuffer = nullptr; + m_asyncProgressiveEpoch.store(0, std::memory_order_release); + m_asyncProgressiveViewEpoch.store(0, std::memory_order_release); + } + + async_rendering::RenderJob job{}; + job.epoch = m_asyncCurrentEpoch.load(std::memory_order_acquire); + if (job.epoch == 0) + { + job.epoch = m_asyncEpochCounter.fetch_add(1, std::memory_order_acq_rel) + 1; + m_asyncCurrentEpoch.store(job.epoch, std::memory_order_release); + } + m_asyncController->setLatestEpoch(job.epoch); + + job.viewEpoch = m_asyncViewEpoch.load(std::memory_order_acquire); + job.frameHint = m_asyncFrameCounter.fetch_add(1, std::memory_order_acq_rel) + 1; + job.type = async_rendering::RenderJobType::RealtimeHighQuality; + job.width = static_cast(width); + job.height = static_cast(height); + job.startLine = 0; + job.stepSize = height; + job.precomputeSdf = false; + job.enableHighQuality = true; + + state.currentLine = 0; + state.renderingStepSize = height; + state.isRendering = true; + + m_asyncInFlightEpoch.store(job.epoch, std::memory_order_release); + m_asyncInFlightViewEpoch.store(job.viewEpoch, std::memory_order_release); + m_asyncJobInFlight.store(true, std::memory_order_release); + m_asyncController->enqueueJob(job); + + ZoneText("RealtimeJobEnqueued", 19); + return true; + } + auto RenderWindow::executeAsyncRenderJob( async_rendering::RenderJob const & job, async_rendering::AsyncRenderController::CancelCheck const & cancelCheck) @@ -2182,6 +2447,7 @@ namespace gladius::ui async_rendering::FrameResultMeta result{}; result.frameId = job.frameHint; result.epoch = job.epoch; + result.viewEpoch = job.viewEpoch; result.jobType = job.type; result.width = job.width; result.height = job.height; @@ -2217,6 +2483,7 @@ namespace gladius::ui { m_asyncProgressiveBuffer = nullptr; m_asyncProgressiveEpoch.store(0, std::memory_order_release); + m_asyncProgressiveViewEpoch.store(0, std::memory_order_release); } writeBuffer = nullptr; @@ -2245,6 +2512,7 @@ namespace gladius::ui m_asyncProgressiveBuffer = writeBuffer; m_asyncProgressiveEpoch.store(job.epoch, std::memory_order_release); + m_asyncProgressiveViewEpoch.store(job.viewEpoch, std::memory_order_release); } else { @@ -2275,9 +2543,30 @@ namespace gladius::ui cl::Event renderEvent{}; bool advanced = false; - - advanced = m_core->renderSceneComputeOnly( - commandQueue, job.startLine, endLine, targetCLBuffer, &renderEvent); + + if (job.type == async_rendering::RenderJobType::RealtimeHighQuality) + { + auto assembly = m_document ? m_document->getAssembly() : nullptr; + if (assembly && m_core->isRendererReady()) + { + (void) m_core->tryToupdateParameter(*assembly); + } + + auto settings = m_core->getResourceContext()->getRenderingSettings(); + settings.approximation = AM_FULL_MODEL; + advanced = m_core->renderSceneComputeOnlyWithSettings( + commandQueue, + job.startLine, + endLine, + targetCLBuffer, + settings, + &renderEvent); + } + else + { + advanced = m_core->renderSceneComputeOnly( + commandQueue, job.startLine, endLine, targetCLBuffer, &renderEvent); + } if (renderEvent()) { @@ -2291,6 +2580,10 @@ namespace gladius::ui result.completedLine = advanced ? endLine : job.startLine; result.completedFrame = result.completedLine >= job.height; + if (!advanced && job.type == async_rendering::RenderJobType::RealtimeHighQuality) + { + result.cancelled = true; + } } else { @@ -2309,9 +2602,11 @@ namespace gladius::ui } else if (result.completedFrame) { - m_asyncController->publishFrame(writeBuffer, result.frameId, result.epoch); + m_asyncController->publishFrame( + writeBuffer, result.frameId, result.epoch, result.viewEpoch); m_asyncProgressiveBuffer = nullptr; m_asyncProgressiveEpoch.store(0, std::memory_order_release); + m_asyncProgressiveViewEpoch.store(0, std::memory_order_release); } auto const endTime = std::chrono::steady_clock::now(); @@ -2747,13 +3042,13 @@ namespace gladius::ui void RenderWindow::zoomIn() { m_camera.zoom(-0.1f); // Negative zoom means zoom in - invalidateView(); + invalidateCameraView(); } void RenderWindow::zoomOut() { m_camera.zoom(0.1f); // Positive zoom means zoom out - invalidateView(); + invalidateCameraView(); } void RenderWindow::resetZoom() @@ -2767,7 +3062,7 @@ namespace gladius::ui } m_camera.adjustDistanceToTarget(*bbox, m_renderWindowSize_px.x, m_renderWindowSize_px.y); - invalidateView(); + invalidateCameraView(); } void RenderWindow::scheduleAsyncBboxUpdate() @@ -3370,6 +3665,42 @@ namespace gladius::ui void RenderWindow::startStreamingPreview() { + if (m_core) + { + auto const image = m_core->getResultImage(); + if (image && image->getWidth() > 0 && image->getHeight() > 0) + { + auto const width = static_cast(image->getWidth()); + auto const height = static_cast(image->getHeight()); + m_realtimeRaymarchController.resetForResolution(width, height); + + async_rendering::RealtimeRaymarchGuards const realtimeGuards{ + .hardBlocker = !m_enableHQRendering || + m_asyncSdfJobInFlight.load(std::memory_order_acquire) || + m_asyncBboxJobInFlight.load(std::memory_order_acquire) || + m_core->isAnyCompilationInProgressNonBlocking(), + .renderJobInFlight = m_asyncJobInFlight.load(std::memory_order_acquire), + .previewJobInFlight = m_asyncPreviewJobInFlight.load(std::memory_order_acquire), + .streamingActive = false, + .streamingJobInFlight = m_streamingJobInFlight.load(std::memory_order_acquire), + .resizePending = m_deferredResizePending}; + + if (m_realtimeRaymarchController.canAttemptRealtime(width, height, realtimeGuards)) + { + if (scheduleRealtimeRenderJob(m_renderWindowState)) + { + return; + } + m_realtimeRaymarchController.recordRejectedAttempt(); + } + + if (m_asyncJobInFlight.load(std::memory_order_acquire)) + { + return; + } + } + } + m_streamingPreviewActive.store(true, std::memory_order_release); // (Re-)schedule the streaming job if none is currently running. diff --git a/gladius/src/ui/RenderWindow.h b/gladius/src/ui/RenderWindow.h index 23e01660..9b6f0cdb 100644 --- a/gladius/src/ui/RenderWindow.h +++ b/gladius/src/ui/RenderWindow.h @@ -5,6 +5,7 @@ #include "OrbitalCamera.h" #include "compute/ComputeCore.h" #include "render/AsyncRenderController.h" +#include "render/RealtimeRaymarchController.h" #include #include #include @@ -170,11 +171,15 @@ namespace gladius::ui void renderAsync(RenderWindowState & state); void processAsyncResults(RenderWindowState & state); bool scheduleAsyncRenderJob(RenderWindowState & state); + bool scheduleRealtimeRenderJob(RenderWindowState & state); coro::task executeAsyncRenderJob( async_rendering::RenderJob const & job, async_rendering::AsyncRenderController::CancelCheck const & cancelCheck); void notifyAsyncEpochIncrement(); + void invalidateCameraView(); void adjustProgressFromDuration(RenderWindowState & state, uint64_t computeDurationNs); + [[nodiscard]] async_rendering::RealtimeRaymarchConfig loadRealtimeRaymarchConfig() const; + void saveRealtimeRaymarchMode(async_rendering::RealtimeRaymarchMode mode) const; [[nodiscard]] bool isAsyncBackendActive() const noexcept; [[nodiscard]] std::optional tryFetchBoundingBox(bool requestAsyncUpdate); @@ -316,10 +321,13 @@ namespace gladius::ui void onCameraManuallyMoved(); async_rendering::AsyncRenderFeatureConfig m_asyncConfig{}; + async_rendering::RealtimeRaymarchController m_realtimeRaymarchController{}; std::shared_ptr m_asyncController; std::atomic m_asyncEpochCounter{0}; std::atomic m_asyncCurrentEpoch{0}; + std::atomic m_asyncViewEpoch{0}; std::atomic m_asyncInFlightEpoch{0}; + std::atomic m_asyncInFlightViewEpoch{0}; std::atomic m_asyncFrameCounter{0}; std::atomic m_asyncJobInFlight{false}; std::atomic m_asyncBboxJobInFlight{false}; @@ -339,6 +347,7 @@ namespace gladius::ui // Progressive rendering: reuse same buffer for all chunks in a frame async_rendering::FrameBuffer * m_asyncProgressiveBuffer{nullptr}; std::atomic m_asyncProgressiveEpoch{0}; + std::atomic m_asyncProgressiveViewEpoch{0}; // Async preview rendering state (separate from HQ progressive rendering) std::atomic m_asyncPreviewEpoch{0}; ///< Current preview epoch for cancellation diff --git a/gladius/src/ui/render/AsyncRenderController.cpp b/gladius/src/ui/render/AsyncRenderController.cpp index 98c76c6c..48ea4c1f 100644 --- a/gladius/src/ui/render/AsyncRenderController.cpp +++ b/gladius/src/ui/render/AsyncRenderController.cpp @@ -386,6 +386,7 @@ namespace gladius::ui::async_rendering fb.state.store(FrameState::Idle, std::memory_order_release); fb.frameId.store(0, std::memory_order_release); fb.epoch.store(0, std::memory_order_release); + fb.viewEpoch.store(0, std::memory_order_release); fb.readyTimestampNs.store(0, std::memory_order_release); fb.publishTimestampNs.store(0, std::memory_order_release); } @@ -490,7 +491,8 @@ namespace gladius::ui::async_rendering void AsyncRenderController::publishFrame(FrameBuffer * buffer, uint64_t frameId, - uint64_t epoch) noexcept + uint64_t epoch, + uint64_t viewEpoch) noexcept { ProfileFunction if (!buffer) { @@ -501,6 +503,7 @@ namespace gladius::ui::async_rendering buffer->frameId.store(frameId, std::memory_order_release); buffer->epoch.store(epoch, std::memory_order_release); + buffer->viewEpoch.store(viewEpoch, std::memory_order_release); // Get current timestamp (nanoseconds since epoch) auto const now = std::chrono::high_resolution_clock::now(); @@ -551,6 +554,32 @@ namespace gladius::ui::async_rendering return newestReady; } + void AsyncRenderController::discardReadyFrame(uint64_t frameId, + uint64_t epoch, + uint64_t viewEpoch) noexcept + { + std::lock_guard lock(m_bufferMutex); + + for (auto & buffer : m_frameBuffers) + { + if (buffer.state.load(std::memory_order_acquire) != FrameState::Ready) + { + continue; + } + + if (buffer.frameId.load(std::memory_order_acquire) != frameId || + buffer.epoch.load(std::memory_order_acquire) != epoch || + buffer.viewEpoch.load(std::memory_order_acquire) != viewEpoch) + { + continue; + } + + [[maybe_unused]] bool const released = tryTransitionBuffer( + &buffer, FrameState::Ready, FrameState::Idle); + return; + } + } + bool AsyncRenderController::finalizeFrontPromotion(FrameBuffer * buffer) noexcept { ProfileFunction diff --git a/gladius/src/ui/render/AsyncRenderController.h b/gladius/src/ui/render/AsyncRenderController.h index 29a04718..2932ecfe 100644 --- a/gladius/src/ui/render/AsyncRenderController.h +++ b/gladius/src/ui/render/AsyncRenderController.h @@ -114,13 +114,19 @@ namespace gladius::ui::async_rendering [[nodiscard]] FrameBuffer * acquireWriteBuffer(uint64_t epoch) noexcept; /// Publish a finished frame (Writing → Ready) - void publishFrame(FrameBuffer * buffer, uint64_t frameId, uint64_t epoch) noexcept; + void publishFrame(FrameBuffer * buffer, + uint64_t frameId, + uint64_t epoch, + uint64_t viewEpoch = 0) noexcept; /// Promote a Ready buffer to Front (for UI display) [[nodiscard]] FrameBuffer * promoteReadyToFront() noexcept; - /// Finalize promotion by transitioning Resampling → Front and updating indices - [[nodiscard]] bool finalizeFrontPromotion(FrameBuffer * buffer) noexcept; + /// Release a completed Ready frame that should no longer be displayed. + void discardReadyFrame(uint64_t frameId, uint64_t epoch, uint64_t viewEpoch) noexcept; + + /// Finalize promotion by transitioning Resampling → Front and updating indices + [[nodiscard]] bool finalizeFrontPromotion(FrameBuffer * buffer) noexcept; /// Release any Writing buffers from old epochs back to Idle /// (used when epoch changes to clean up cancelled jobs) diff --git a/gladius/src/ui/render/AsyncRenderTypes.h b/gladius/src/ui/render/AsyncRenderTypes.h index be32899b..fd4f94e9 100644 --- a/gladius/src/ui/render/AsyncRenderTypes.h +++ b/gladius/src/ui/render/AsyncRenderTypes.h @@ -44,6 +44,7 @@ namespace gladius::ui::async_rendering enum class RenderJobType { HighQuality, + RealtimeHighQuality, LowResPreview, StreamingPreview, // Continuous preview loop during parameter drag BoundingBoxUpdate, @@ -58,6 +59,7 @@ namespace gladius::ui::async_rendering struct RenderJob { uint64_t epoch{0}; + uint64_t viewEpoch{0}; uint64_t frameHint{0}; RenderJobType type{RenderJobType::HighQuality}; uint32_t width{0}; @@ -76,6 +78,7 @@ namespace gladius::ui::async_rendering { uint64_t frameId{0}; uint64_t epoch{0}; + uint64_t viewEpoch{0}; RenderJobType jobType{RenderJobType::HighQuality}; uint32_t width{0}; uint32_t height{0}; @@ -127,6 +130,7 @@ namespace gladius::ui::async_rendering std::atomic state{FrameState::Idle}; std::atomic frameId{0}; std::atomic epoch{0}; + std::atomic viewEpoch{0}; std::atomic readyTimestampNs{0}; std::atomic publishTimestampNs{0}; diff --git a/gladius/src/ui/render/RealtimeRaymarchController.cpp b/gladius/src/ui/render/RealtimeRaymarchController.cpp new file mode 100644 index 00000000..6b584a1c --- /dev/null +++ b/gladius/src/ui/render/RealtimeRaymarchController.cpp @@ -0,0 +1,297 @@ +#include "RealtimeRaymarchController.h" + +#include +#include +#include + +namespace gladius::ui::async_rendering +{ + namespace + { + [[nodiscard]] std::string normalizeModeString(std::string value) + { + std::transform(value.begin(), + value.end(), + value.begin(), + [](unsigned char character) + { return static_cast(std::tolower(character)); }); + return value; + } + } + + RealtimeRaymarchMode realtimeRaymarchModeFromString(std::string const & value) + { + auto const normalized = normalizeModeString(value); + if (normalized == "off" || normalized == "disabled" || normalized == "false") + { + return RealtimeRaymarchMode::Off; + } + if (normalized == "force" || normalized == "forced" || normalized == "on") + { + return RealtimeRaymarchMode::Force; + } + return RealtimeRaymarchMode::Auto; + } + + char const * realtimeRaymarchModeToString(RealtimeRaymarchMode mode) noexcept + { + switch (mode) + { + case RealtimeRaymarchMode::Off: + return "off"; + case RealtimeRaymarchMode::Force: + return "force"; + case RealtimeRaymarchMode::Auto: + default: + return "auto"; + } + } + + void RealtimeRaymarchController::configure(RealtimeRaymarchConfig config) + { + config.targetFrameTimeMs = std::max(config.targetFrameTimeMs, 1.0f); + config.enterBudgetRatio = std::clamp(config.enterBudgetRatio, 0.1f, 1.0f); + config.exitBudgetRatio = std::max(config.exitBudgetRatio, config.enterBudgetRatio); + config.severeMissRatio = std::max(config.severeMissRatio, config.exitBudgetRatio); + config.ewmaAlpha = std::clamp(config.ewmaAlpha, 0.01f, 1.0f); + config.requiredFastSamples = std::max(config.requiredFastSamples, 1); + config.maxSlowSamples = std::max(config.maxSlowSamples, 1); + config.cooldownFrames = std::max(config.cooldownFrames, 0); + + m_config = config; + if (m_config.mode == RealtimeRaymarchMode::Off) + { + m_realtimeActive = false; + } + } + + RealtimeRaymarchConfig const & RealtimeRaymarchController::config() const noexcept + { + return m_config; + } + + void RealtimeRaymarchController::reset() + { + m_estimatedFullFrameMs.reset(); + m_width = 0; + m_height = 0; + m_fastSampleStreak = 0; + m_slowSampleStreak = 0; + m_cooldownFramesRemaining = 0; + m_realtimeActive = false; + } + + void RealtimeRaymarchController::resetForResolution(uint32_t width, uint32_t height) + { + if (m_width == width && m_height == height) + { + return; + } + + reset(); + m_width = width; + m_height = height; + } + + void RealtimeRaymarchController::beginFrame() + { + if (m_cooldownFramesRemaining > 0) + { + --m_cooldownFramesRemaining; + } + } + + void RealtimeRaymarchController::recordSample(RealtimeRaymarchSample const & sample) + { + if (sample.cancelled) + { + return; + } + + if (sample.width == 0 || sample.height == 0) + { + return; + } + + if (!hasValidResolution(sample.width, sample.height)) + { + resetForResolution(sample.width, sample.height); + } + + auto const estimatedMs = estimateSampleFullFrameTimeMs(sample); + if (!estimatedMs.has_value()) + { + return; + } + + recordEstimatedTime(*estimatedMs); + } + + void RealtimeRaymarchController::recordRejectedAttempt() + { + m_realtimeActive = false; + m_fastSampleStreak = 0; + ++m_slowSampleStreak; + if (m_slowSampleStreak >= m_config.maxSlowSamples) + { + enterCooldown(); + } + } + + bool RealtimeRaymarchController::canAttemptRealtime( + uint32_t width, + uint32_t height, + RealtimeRaymarchGuards const & guards) const noexcept + { + if (m_config.mode == RealtimeRaymarchMode::Off) + { + return false; + } + if (!hasValidResolution(width, height)) + { + return false; + } + if (!guardsAllowAttempt(guards)) + { + return false; + } + if (m_config.mode == RealtimeRaymarchMode::Force) + { + return true; + } + if (m_cooldownFramesRemaining > 0) + { + return false; + } + return m_realtimeActive; + } + + bool RealtimeRaymarchController::isRealtimeActive() const noexcept + { + return m_config.mode == RealtimeRaymarchMode::Force || m_realtimeActive; + } + + bool RealtimeRaymarchController::isCoolingDown() const noexcept + { + return m_cooldownFramesRemaining > 0; + } + + std::optional RealtimeRaymarchController::estimatedFullFrameTimeMs() const noexcept + { + return m_estimatedFullFrameMs; + } + + char const * RealtimeRaymarchController::modeLabel() const noexcept + { + if (m_config.mode == RealtimeRaymarchMode::Force) + { + return "RT Force"; + } + if (m_config.mode == RealtimeRaymarchMode::Off) + { + return "RT Off"; + } + if (m_realtimeActive) + { + return "RT Auto"; + } + if (m_cooldownFramesRemaining > 0) + { + return "RT Cooldown"; + } + return "RT Learning"; + } + + bool RealtimeRaymarchController::hasValidResolution(uint32_t width, uint32_t height) const noexcept + { + return width > 0 && height > 0 && m_width == width && m_height == height; + } + + bool RealtimeRaymarchController::guardsAllowAttempt( + RealtimeRaymarchGuards const & guards) const noexcept + { + return !guards.hardBlocker && !guards.renderJobInFlight && !guards.previewJobInFlight && + !guards.streamingActive && !guards.streamingJobInFlight && !guards.resizePending; + } + + std::optional RealtimeRaymarchController::estimateSampleFullFrameTimeMs( + RealtimeRaymarchSample const & sample) const noexcept + { + if (sample.durationMs <= 0.0f || !std::isfinite(sample.durationMs)) + { + return std::nullopt; + } + + if (sample.completedFrame) + { + return sample.durationMs; + } + + if (sample.renderedLines == 0 || sample.totalLines == 0) + { + return std::nullopt; + } + + auto const clampedLines = std::min(sample.renderedLines, sample.totalLines); + if (clampedLines == 0) + { + return std::nullopt; + } + + auto const scale = static_cast(sample.totalLines) / static_cast(clampedLines); + return sample.durationMs * scale; + } + + void RealtimeRaymarchController::recordEstimatedTime(float estimatedMs) + { + if (!m_estimatedFullFrameMs.has_value()) + { + m_estimatedFullFrameMs = estimatedMs; + } + else + { + m_estimatedFullFrameMs = (*m_estimatedFullFrameMs * (1.0f - m_config.ewmaAlpha)) + + (estimatedMs * m_config.ewmaAlpha); + } + + auto const enterBudget = m_config.targetFrameTimeMs * m_config.enterBudgetRatio; + auto const exitBudget = m_config.targetFrameTimeMs * m_config.exitBudgetRatio; + auto const severeMissBudget = m_config.targetFrameTimeMs * m_config.severeMissRatio; + + if (*m_estimatedFullFrameMs <= enterBudget) + { + ++m_fastSampleStreak; + m_slowSampleStreak = 0; + if (m_fastSampleStreak >= m_config.requiredFastSamples && + m_cooldownFramesRemaining == 0) + { + m_realtimeActive = true; + } + return; + } + + m_fastSampleStreak = 0; + if (*m_estimatedFullFrameMs > exitBudget) + { + ++m_slowSampleStreak; + } + else + { + m_slowSampleStreak = 0; + } + + if (*m_estimatedFullFrameMs > severeMissBudget || + m_slowSampleStreak >= m_config.maxSlowSamples) + { + enterCooldown(); + } + } + + void RealtimeRaymarchController::enterCooldown() + { + m_realtimeActive = false; + m_fastSampleStreak = 0; + m_slowSampleStreak = 0; + m_cooldownFramesRemaining = m_config.cooldownFrames; + } +} diff --git a/gladius/src/ui/render/RealtimeRaymarchController.h b/gladius/src/ui/render/RealtimeRaymarchController.h new file mode 100644 index 00000000..68a3c0f0 --- /dev/null +++ b/gladius/src/ui/render/RealtimeRaymarchController.h @@ -0,0 +1,92 @@ +#pragma once + +#include +#include +#include +#include + +namespace gladius::ui::async_rendering +{ + enum class RealtimeRaymarchMode + { + Off, + Auto, + Force + }; + + [[nodiscard]] RealtimeRaymarchMode realtimeRaymarchModeFromString(std::string const & value); + [[nodiscard]] char const * realtimeRaymarchModeToString(RealtimeRaymarchMode mode) noexcept; + + struct RealtimeRaymarchConfig + { + RealtimeRaymarchMode mode{RealtimeRaymarchMode::Auto}; + float targetFrameTimeMs{25.0f}; + float enterBudgetRatio{0.8f}; + float exitBudgetRatio{1.1f}; + float severeMissRatio{2.0f}; + float ewmaAlpha{0.35f}; + int requiredFastSamples{3}; + int maxSlowSamples{2}; + int cooldownFrames{30}; + }; + + struct RealtimeRaymarchGuards + { + bool hardBlocker{false}; + bool renderJobInFlight{false}; + bool previewJobInFlight{false}; + bool streamingActive{false}; + bool streamingJobInFlight{false}; + bool resizePending{false}; + }; + + struct RealtimeRaymarchSample + { + float durationMs{0.0f}; + uint32_t width{0}; + uint32_t height{0}; + size_t renderedLines{0}; + size_t totalLines{0}; + bool completedFrame{false}; + bool cancelled{false}; + }; + + class RealtimeRaymarchController + { + public: + void configure(RealtimeRaymarchConfig config); + [[nodiscard]] RealtimeRaymarchConfig const & config() const noexcept; + + void reset(); + void resetForResolution(uint32_t width, uint32_t height); + void beginFrame(); + + void recordSample(RealtimeRaymarchSample const & sample); + void recordRejectedAttempt(); + + [[nodiscard]] bool canAttemptRealtime(uint32_t width, + uint32_t height, + RealtimeRaymarchGuards const & guards) const noexcept; + [[nodiscard]] bool isRealtimeActive() const noexcept; + [[nodiscard]] bool isCoolingDown() const noexcept; + [[nodiscard]] std::optional estimatedFullFrameTimeMs() const noexcept; + [[nodiscard]] char const * modeLabel() const noexcept; + + private: + [[nodiscard]] bool hasValidResolution(uint32_t width, uint32_t height) const noexcept; + [[nodiscard]] bool guardsAllowAttempt(RealtimeRaymarchGuards const & guards) const noexcept; + [[nodiscard]] std::optional estimateSampleFullFrameTimeMs( + RealtimeRaymarchSample const & sample) const noexcept; + void recordEstimatedTime(float estimatedMs); + void enterCooldown(); + + RealtimeRaymarchConfig m_config{}; + std::optional m_estimatedFullFrameMs{}; + uint32_t m_width{0}; + uint32_t m_height{0}; + int m_fastSampleStreak{0}; + int m_slowSampleStreak{0}; + int m_cooldownFramesRemaining{0}; + bool m_realtimeActive{false}; + }; +} diff --git a/gladius/tests/unittests/ui/RealtimeRaymarchController_tests.cpp b/gladius/tests/unittests/ui/RealtimeRaymarchController_tests.cpp new file mode 100644 index 00000000..17da21c9 --- /dev/null +++ b/gladius/tests/unittests/ui/RealtimeRaymarchController_tests.cpp @@ -0,0 +1,142 @@ +#include "ui/render/RealtimeRaymarchController.h" + +#include + +namespace gladius::ui::async_rendering::tests +{ + namespace + { + [[nodiscard]] RealtimeRaymarchConfig makeTestConfig() + { + RealtimeRaymarchConfig config{}; + config.targetFrameTimeMs = 25.0f; + config.requiredFastSamples = 3; + config.maxSlowSamples = 2; + config.cooldownFrames = 4; + config.ewmaAlpha = 1.0f; + return config; + } + + [[nodiscard]] RealtimeRaymarchSample makeFullFrameSample(float durationMs) + { + return RealtimeRaymarchSample{.durationMs = durationMs, + .width = 800, + .height = 600, + .renderedLines = 600, + .totalLines = 600, + .completedFrame = true, + .cancelled = false}; + } + + [[nodiscard]] RealtimeRaymarchGuards makeOpenGuards() + { + return RealtimeRaymarchGuards{}; + } + } + + TEST(RealtimeRaymarchController, RecordFastSamples_EnablesRealtime) + { + RealtimeRaymarchController controller; + controller.configure(makeTestConfig()); + + controller.recordSample(makeFullFrameSample(18.0f)); + controller.recordSample(makeFullFrameSample(19.0f)); + controller.recordSample(makeFullFrameSample(20.0f)); + + EXPECT_TRUE(controller.canAttemptRealtime(800, 600, makeOpenGuards())); + ASSERT_TRUE(controller.estimatedFullFrameTimeMs().has_value()); + EXPECT_FLOAT_EQ(*controller.estimatedFullFrameTimeMs(), 20.0f); + } + + TEST(RealtimeRaymarchController, RecordSlowSamples_EntersCooldown) + { + RealtimeRaymarchController controller; + controller.configure(makeTestConfig()); + + controller.recordSample(makeFullFrameSample(18.0f)); + controller.recordSample(makeFullFrameSample(18.0f)); + controller.recordSample(makeFullFrameSample(18.0f)); + ASSERT_TRUE(controller.canAttemptRealtime(800, 600, makeOpenGuards())); + + controller.recordSample(makeFullFrameSample(35.0f)); + controller.recordSample(makeFullFrameSample(36.0f)); + + EXPECT_FALSE(controller.canAttemptRealtime(800, 600, makeOpenGuards())); + EXPECT_TRUE(controller.isCoolingDown()); + } + + TEST(RealtimeRaymarchController, BeginFrame_CountsDownCooldown) + { + RealtimeRaymarchController controller; + controller.configure(makeTestConfig()); + + controller.recordSample(makeFullFrameSample(60.0f)); + ASSERT_TRUE(controller.isCoolingDown()); + + controller.beginFrame(); + controller.beginFrame(); + controller.beginFrame(); + EXPECT_TRUE(controller.isCoolingDown()); + + controller.beginFrame(); + EXPECT_FALSE(controller.isCoolingDown()); + } + + TEST(RealtimeRaymarchController, RecordChunkSample_EstimatesFullFrameTime) + { + RealtimeRaymarchController controller; + controller.configure(makeTestConfig()); + + RealtimeRaymarchSample sample{}; + sample.durationMs = 2.0f; + sample.width = 800; + sample.height = 600; + sample.renderedLines = 60; + sample.totalLines = 600; + sample.completedFrame = false; + + controller.recordSample(sample); + + ASSERT_TRUE(controller.estimatedFullFrameTimeMs().has_value()); + EXPECT_FLOAT_EQ(*controller.estimatedFullFrameTimeMs(), 20.0f); + } + + TEST(RealtimeRaymarchController, ForceMode_IgnoresLearningButNotGuards) + { + RealtimeRaymarchController controller; + auto config = makeTestConfig(); + config.mode = RealtimeRaymarchMode::Force; + controller.configure(config); + controller.resetForResolution(800, 600); + + EXPECT_TRUE(controller.canAttemptRealtime(800, 600, makeOpenGuards())); + + auto blockedGuards = makeOpenGuards(); + blockedGuards.renderJobInFlight = true; + EXPECT_FALSE(controller.canAttemptRealtime(800, 600, blockedGuards)); + } + + TEST(RealtimeRaymarchController, ResolutionChange_ResetsConfidence) + { + RealtimeRaymarchController controller; + controller.configure(makeTestConfig()); + + controller.recordSample(makeFullFrameSample(18.0f)); + controller.recordSample(makeFullFrameSample(18.0f)); + controller.recordSample(makeFullFrameSample(18.0f)); + ASSERT_TRUE(controller.canAttemptRealtime(800, 600, makeOpenGuards())); + + controller.resetForResolution(1024, 768); + + EXPECT_FALSE(controller.canAttemptRealtime(1024, 768, makeOpenGuards())); + EXPECT_FALSE(controller.estimatedFullFrameTimeMs().has_value()); + } + + TEST(RealtimeRaymarchModeFromString, WithKnownValues_ParsesModes) + { + EXPECT_EQ(realtimeRaymarchModeFromString("off"), RealtimeRaymarchMode::Off); + EXPECT_EQ(realtimeRaymarchModeFromString("force"), RealtimeRaymarchMode::Force); + EXPECT_EQ(realtimeRaymarchModeFromString("auto"), RealtimeRaymarchMode::Auto); + EXPECT_EQ(realtimeRaymarchModeFromString("unknown"), RealtimeRaymarchMode::Auto); + } +} From f985242f4ec4786661eb33a9ced01bf9c2bdc0ed Mon Sep 17 00:00:00 2001 From: Jan Orend <56254096+3dJan@users.noreply.github.com> Date: Thu, 14 May 2026 07:44:26 +0200 Subject: [PATCH 06/72] Introduction of FramePresentationController --- .../ui/render/FramePresentationController.h | 199 ++++++++++++++++++ gladius/src/ui/render/RenderUpdateTypes.h | 170 +++++++++++++++ .../FramePresentationController_tests.cpp | 148 +++++++++++++ .../ui/render/RenderUpdateTypes_tests.cpp | 117 ++++++++++ 4 files changed, 634 insertions(+) create mode 100644 gladius/src/ui/render/FramePresentationController.h create mode 100644 gladius/src/ui/render/RenderUpdateTypes.h create mode 100644 gladius/tests/unittests/ui/render/FramePresentationController_tests.cpp create mode 100644 gladius/tests/unittests/ui/render/RenderUpdateTypes_tests.cpp diff --git a/gladius/src/ui/render/FramePresentationController.h b/gladius/src/ui/render/FramePresentationController.h new file mode 100644 index 00000000..49f76c3b --- /dev/null +++ b/gladius/src/ui/render/FramePresentationController.h @@ -0,0 +1,199 @@ +#pragma once + +#include "AsyncRenderTypes.h" +#include "RenderUpdateTypes.h" + +#include +#include +#include +#include + +namespace gladius::ui::async_rendering +{ + /** + * @brief Metadata-only buffer used by FramePresentationController. + */ + struct PresentationBuffer + { + size_t id{0}; + FrameState state{FrameState::Idle}; + uint64_t frameId{0}; + RenderStamp stamp{}; + }; + + /** + * @brief Pure state machine for selecting, publishing, and promoting rendered frames. + * + * This class intentionally knows nothing about OpenGL/OpenCL resources. Production buffer + * owners can use it as the policy layer while keeping actual image allocation elsewhere. + */ + class FramePresentationController + { + public: + explicit FramePresentationController(size_t bufferCount = 3) + { + reset(bufferCount); + } + + void reset(size_t bufferCount) + { + m_buffers.clear(); + m_buffers.reserve(bufferCount); + for (size_t index = 0; index < bufferCount; ++index) + { + m_buffers.push_back(PresentationBuffer{.id = index}); + } + + if (!m_buffers.empty()) + { + m_buffers.front().state = FrameState::Front; + m_frontBufferId = m_buffers.front().id; + } + else + { + m_frontBufferId.reset(); + } + } + + [[nodiscard]] std::optional frontBufferId() const noexcept + { + return m_frontBufferId; + } + + [[nodiscard]] PresentationBuffer const * buffer(size_t bufferId) const noexcept + { + if (bufferId >= m_buffers.size()) + { + return nullptr; + } + return &m_buffers[bufferId]; + } + + [[nodiscard]] std::optional acquireWriteBuffer(RenderStamp const & stamp) noexcept + { + for (auto & candidate : m_buffers) + { + if (candidate.state != FrameState::Idle) + { + continue; + } + + candidate.state = FrameState::Writing; + candidate.frameId = 0; + candidate.stamp = stamp; + return candidate.id; + } + return std::nullopt; + } + + [[nodiscard]] bool publishFrame(size_t bufferId, + uint64_t frameId, + RenderStamp const & stamp) noexcept + { + auto * target = mutableBuffer(bufferId); + if (target == nullptr || target->state != FrameState::Writing) + { + return false; + } + + target->frameId = frameId; + target->stamp = stamp; + target->state = FrameState::Ready; + return true; + } + + [[nodiscard]] std::optional selectNewestReady(RenderStamp const & required, + RenderStampMask const mask) noexcept + { + PresentationBuffer * newest = nullptr; + for (auto & candidate : m_buffers) + { + if (candidate.state != FrameState::Ready || !matches(candidate.stamp, required, mask)) + { + continue; + } + + if (newest == nullptr || candidate.frameId > newest->frameId) + { + newest = &candidate; + } + } + + if (newest == nullptr) + { + return std::nullopt; + } + + newest->state = FrameState::Resampling; + return newest->id; + } + + [[nodiscard]] bool finalizeFrontPromotion(size_t bufferId) noexcept + { + auto * target = mutableBuffer(bufferId); + if (target == nullptr || target->state != FrameState::Resampling) + { + return false; + } + + if (m_frontBufferId.has_value()) + { + auto * previousFront = mutableBuffer(*m_frontBufferId); + if (previousFront != nullptr && previousFront->id != target->id && + previousFront->state == FrameState::Front) + { + previousFront->state = FrameState::Idle; + previousFront->frameId = 0; + } + } + + target->state = FrameState::Front; + m_frontBufferId = target->id; + return true; + } + + [[nodiscard]] bool discardReadyFrame(size_t bufferId) noexcept + { + auto * target = mutableBuffer(bufferId); + if (target == nullptr || target->state != FrameState::Ready) + { + return false; + } + + target->state = FrameState::Idle; + target->frameId = 0; + return true; + } + + [[nodiscard]] size_t releaseStaleWritingBuffers(RenderStamp const & required, + RenderStampMask const mask) noexcept + { + size_t releasedCount = 0; + for (auto & candidate : m_buffers) + { + if (candidate.state != FrameState::Writing || !isOlderThan(candidate.stamp, required, mask)) + { + continue; + } + + candidate.state = FrameState::Idle; + candidate.frameId = 0; + ++releasedCount; + } + return releasedCount; + } + + private: + [[nodiscard]] PresentationBuffer * mutableBuffer(size_t bufferId) noexcept + { + if (bufferId >= m_buffers.size()) + { + return nullptr; + } + return &m_buffers[bufferId]; + } + + std::vector m_buffers; + std::optional m_frontBufferId; + }; +} diff --git a/gladius/src/ui/render/RenderUpdateTypes.h b/gladius/src/ui/render/RenderUpdateTypes.h new file mode 100644 index 00000000..f6ba6b32 --- /dev/null +++ b/gladius/src/ui/render/RenderUpdateTypes.h @@ -0,0 +1,170 @@ +#pragma once + +#include +#include + +namespace gladius::ui::async_rendering +{ + /** + * @brief Identifies the independently changing inputs that make a rendered artifact fresh. + * + * Scene/model, parameter, camera/view, viewport, and quality generations intentionally evolve + * independently. This lets camera-only interaction reject stale view frames without forcing + * expensive model-side work such as bounding-box or SDF recomputation. + */ + struct RenderStamp + { + uint64_t sceneEpoch{0}; ///< Structural model/program generation. + uint64_t parameterEpoch{0}; ///< Numeric parameter payload generation. + uint64_t viewEpoch{0}; ///< Camera/view generation. + uint64_t viewportEpoch{0}; ///< Render target size/generation. + uint64_t qualityEpoch{0}; ///< Render settings/quality generation. + }; + + /** + * @brief Selects which RenderStamp axes are relevant for a freshness comparison. + */ + struct RenderStampMask + { + bool scene{true}; + bool parameters{true}; + bool view{true}; + bool viewport{true}; + bool quality{true}; + + [[nodiscard]] static constexpr RenderStampMask all() noexcept + { + return {}; + } + + [[nodiscard]] static constexpr RenderStampMask sceneOnly() noexcept + { + return {.scene = true, + .parameters = false, + .view = false, + .viewport = false, + .quality = false}; + } + + [[nodiscard]] static constexpr RenderStampMask sceneAndParameters() noexcept + { + return {.scene = true, + .parameters = true, + .view = false, + .viewport = false, + .quality = false}; + } + + [[nodiscard]] static constexpr RenderStampMask displayFrame() noexcept + { + return {.scene = true, + .parameters = true, + .view = true, + .viewport = true, + .quality = true}; + } + + [[nodiscard]] static constexpr RenderStampMask interactiveFrame() noexcept + { + return {.scene = true, + .parameters = true, + .view = true, + .viewport = true, + .quality = false}; + } + + [[nodiscard]] static constexpr RenderStampMask heavyGeometryTask() noexcept + { + return sceneAndParameters(); + } + }; + + [[nodiscard]] constexpr bool matches(RenderStamp const & candidate, + RenderStamp const & required, + RenderStampMask const mask = RenderStampMask::all()) noexcept + { + return (!mask.scene || candidate.sceneEpoch == required.sceneEpoch) && + (!mask.parameters || candidate.parameterEpoch == required.parameterEpoch) && + (!mask.view || candidate.viewEpoch == required.viewEpoch) && + (!mask.viewport || candidate.viewportEpoch == required.viewportEpoch) && + (!mask.quality || candidate.qualityEpoch == required.qualityEpoch); + } + + [[nodiscard]] constexpr bool isOlderThan(RenderStamp const & candidate, + RenderStamp const & required, + RenderStampMask const mask = RenderStampMask::all()) noexcept + { + return (mask.scene && candidate.sceneEpoch < required.sceneEpoch) || + (mask.parameters && candidate.parameterEpoch < required.parameterEpoch) || + (mask.view && candidate.viewEpoch < required.viewEpoch) || + (mask.viewport && candidate.viewportEpoch < required.viewportEpoch) || + (mask.quality && candidate.qualityEpoch < required.qualityEpoch); + } + + /** + * @brief Testable semantic task kinds requested by the render-update coordinator. + */ + enum class RenderTaskType + { + RealtimeFullFrame, + ProgressiveHighQualityChunk, + LowResolutionPreview, + StreamingPreview, + BoundingBoxUpdate, + SdfPrecomputation, + ParameterUpload, + ProgramCompilation + }; + + enum class RenderTaskStatus + { + Completed, + Cancelled, + Failed + }; + + /** + * @brief Pure description of render/update work before it is translated to GPU/UI calls. + */ + struct RenderTaskRequest + { + uint64_t requestId{0}; + RenderTaskType type{RenderTaskType::ProgressiveHighQualityChunk}; + RenderStamp stamp{}; + uint32_t width{0}; + uint32_t height{0}; + size_t startLine{0}; + size_t lineCount{0}; + }; + + /** + * @brief Pure completion metadata returned to the render-update coordinator. + */ + struct RenderTaskResult + { + uint64_t requestId{0}; + RenderTaskType type{RenderTaskType::ProgressiveHighQualityChunk}; + RenderStamp stamp{}; + RenderTaskStatus status{RenderTaskStatus::Completed}; + uint64_t durationNs{0}; + bool producedDisplayFrame{false}; + bool completedFrame{false}; + + [[nodiscard]] constexpr bool succeeded() const noexcept + { + return status == RenderTaskStatus::Completed; + } + + [[nodiscard]] constexpr bool isCurrentFor(RenderStamp const & required, + RenderStampMask const mask) const noexcept + { + return succeeded() && matches(stamp, required, mask); + } + + [[nodiscard]] constexpr bool isDisplayableFor(RenderStamp const & required, + RenderStampMask const mask = RenderStampMask::displayFrame()) const noexcept + { + return producedDisplayFrame && isCurrentFor(required, mask); + } + }; +} diff --git a/gladius/tests/unittests/ui/render/FramePresentationController_tests.cpp b/gladius/tests/unittests/ui/render/FramePresentationController_tests.cpp new file mode 100644 index 00000000..da19b14f --- /dev/null +++ b/gladius/tests/unittests/ui/render/FramePresentationController_tests.cpp @@ -0,0 +1,148 @@ +#include "ui/render/FramePresentationController.h" + +#include + +namespace gladius::ui::async_rendering::tests +{ + namespace + { + [[nodiscard]] constexpr RenderStamp makeStamp(uint64_t scene, + uint64_t parameters, + uint64_t view, + uint64_t viewport, + uint64_t quality) noexcept + { + return RenderStamp{.sceneEpoch = scene, + .parameterEpoch = parameters, + .viewEpoch = view, + .viewportEpoch = viewport, + .qualityEpoch = quality}; + } + } + + TEST(FramePresentationController, AfterReset_HasFrontBufferAndIdleBackBuffers) + { + FramePresentationController controller{3}; + + ASSERT_TRUE(controller.frontBufferId().has_value()); + EXPECT_EQ(*controller.frontBufferId(), 0u); + ASSERT_NE(controller.buffer(0), nullptr); + ASSERT_NE(controller.buffer(1), nullptr); + ASSERT_NE(controller.buffer(2), nullptr); + EXPECT_EQ(controller.buffer(0)->state, FrameState::Front); + EXPECT_EQ(controller.buffer(1)->state, FrameState::Idle); + EXPECT_EQ(controller.buffer(2)->state, FrameState::Idle); + } + + TEST(FramePresentationController, AcquireWriteBuffer_WithIdleBuffer_MarksBufferWriting) + { + FramePresentationController controller{3}; + auto const stamp = makeStamp(1, 2, 3, 4, 5); + + auto const bufferId = controller.acquireWriteBuffer(stamp); + + ASSERT_TRUE(bufferId.has_value()); + ASSERT_NE(controller.buffer(*bufferId), nullptr); + EXPECT_EQ(controller.buffer(*bufferId)->state, FrameState::Writing); + EXPECT_TRUE(matches(controller.buffer(*bufferId)->stamp, stamp)); + } + + TEST(FramePresentationController, PublishFrame_WithWritingBuffer_MarksBufferReady) + { + FramePresentationController controller{3}; + auto const stamp = makeStamp(1, 2, 3, 4, 5); + auto const bufferId = controller.acquireWriteBuffer(stamp); + ASSERT_TRUE(bufferId.has_value()); + + EXPECT_TRUE(controller.publishFrame(*bufferId, 17, stamp)); + + ASSERT_NE(controller.buffer(*bufferId), nullptr); + EXPECT_EQ(controller.buffer(*bufferId)->state, FrameState::Ready); + EXPECT_EQ(controller.buffer(*bufferId)->frameId, 17u); + } + + TEST(FramePresentationController, SelectNewestReady_WithMultipleReadyBuffers_SelectsHighestFrameId) + { + FramePresentationController controller{4}; + auto const latest = makeStamp(1, 2, 3, 4, 5); + auto const olderReady = controller.acquireWriteBuffer(latest); + auto const newerReady = controller.acquireWriteBuffer(latest); + ASSERT_TRUE(olderReady.has_value()); + ASSERT_TRUE(newerReady.has_value()); + ASSERT_TRUE(controller.publishFrame(*olderReady, 10, latest)); + ASSERT_TRUE(controller.publishFrame(*newerReady, 11, latest)); + + auto const selected = controller.selectNewestReady(latest, RenderStampMask::displayFrame()); + + ASSERT_TRUE(selected.has_value()); + EXPECT_EQ(*selected, *newerReady); + EXPECT_EQ(controller.buffer(*selected)->state, FrameState::Resampling); + EXPECT_EQ(controller.buffer(*olderReady)->state, FrameState::Ready); + } + + TEST(FramePresentationController, SelectNewestReady_WithStaleReadyBuffer_IgnoresStaleFrame) + { + FramePresentationController controller{3}; + auto const latest = makeStamp(1, 2, 3, 4, 5); + auto const oldCamera = makeStamp(1, 2, 2, 4, 5); + auto const bufferId = controller.acquireWriteBuffer(oldCamera); + ASSERT_TRUE(bufferId.has_value()); + ASSERT_TRUE(controller.publishFrame(*bufferId, 99, oldCamera)); + + auto const selected = controller.selectNewestReady(latest, RenderStampMask::displayFrame()); + + EXPECT_FALSE(selected.has_value()); + EXPECT_EQ(controller.buffer(*bufferId)->state, FrameState::Ready); + } + + TEST(FramePresentationController, FinalizeFrontPromotion_WithSelectedBuffer_DemotesPreviousFront) + { + FramePresentationController controller{3}; + auto const latest = makeStamp(1, 2, 3, 4, 5); + auto const bufferId = controller.acquireWriteBuffer(latest); + ASSERT_TRUE(bufferId.has_value()); + ASSERT_TRUE(controller.publishFrame(*bufferId, 12, latest)); + auto const selected = controller.selectNewestReady(latest, RenderStampMask::displayFrame()); + ASSERT_TRUE(selected.has_value()); + + EXPECT_TRUE(controller.finalizeFrontPromotion(*selected)); + + ASSERT_TRUE(controller.frontBufferId().has_value()); + EXPECT_EQ(*controller.frontBufferId(), *selected); + EXPECT_EQ(controller.buffer(*selected)->state, FrameState::Front); + EXPECT_EQ(controller.buffer(0)->state, *selected == 0u ? FrameState::Front : FrameState::Idle); + } + + TEST(FramePresentationController, DiscardReadyFrame_WithReadyBuffer_ReleasesItToIdle) + { + FramePresentationController controller{3}; + auto const latest = makeStamp(1, 2, 3, 4, 5); + auto const bufferId = controller.acquireWriteBuffer(latest); + ASSERT_TRUE(bufferId.has_value()); + ASSERT_TRUE(controller.publishFrame(*bufferId, 12, latest)); + + EXPECT_TRUE(controller.discardReadyFrame(*bufferId)); + + EXPECT_EQ(controller.buffer(*bufferId)->state, FrameState::Idle); + EXPECT_EQ(controller.buffer(*bufferId)->frameId, 0u); + } + + TEST(FramePresentationController, ReleaseStaleWritingBuffers_WithDifferentMasks_ReleasesRelevantStaleWork) + { + FramePresentationController controller{4}; + auto const latest = makeStamp(1, 2, 3, 4, 5); + auto const oldView = makeStamp(1, 2, 2, 4, 5); + auto const oldScene = makeStamp(0, 2, 3, 4, 5); + auto const oldViewBuffer = controller.acquireWriteBuffer(oldView); + auto const oldSceneBuffer = controller.acquireWriteBuffer(oldScene); + ASSERT_TRUE(oldViewBuffer.has_value()); + ASSERT_TRUE(oldSceneBuffer.has_value()); + + EXPECT_EQ(controller.releaseStaleWritingBuffers(latest, RenderStampMask::heavyGeometryTask()), 1u); + EXPECT_EQ(controller.buffer(*oldViewBuffer)->state, FrameState::Writing); + EXPECT_EQ(controller.buffer(*oldSceneBuffer)->state, FrameState::Idle); + + EXPECT_EQ(controller.releaseStaleWritingBuffers(latest, RenderStampMask::displayFrame()), 1u); + EXPECT_EQ(controller.buffer(*oldViewBuffer)->state, FrameState::Idle); + } +} diff --git a/gladius/tests/unittests/ui/render/RenderUpdateTypes_tests.cpp b/gladius/tests/unittests/ui/render/RenderUpdateTypes_tests.cpp new file mode 100644 index 00000000..733529a0 --- /dev/null +++ b/gladius/tests/unittests/ui/render/RenderUpdateTypes_tests.cpp @@ -0,0 +1,117 @@ +#include "ui/render/RenderUpdateTypes.h" + +#include + +namespace gladius::ui::async_rendering::tests +{ + namespace + { + [[nodiscard]] constexpr RenderStamp makeStamp(uint64_t scene, + uint64_t parameters, + uint64_t view, + uint64_t viewport, + uint64_t quality) noexcept + { + return RenderStamp{.sceneEpoch = scene, + .parameterEpoch = parameters, + .viewEpoch = view, + .viewportEpoch = viewport, + .qualityEpoch = quality}; + } + + [[nodiscard]] constexpr RenderTaskResult makeDisplayResult(RenderStamp stamp) noexcept + { + return RenderTaskResult{.requestId = 42, + .type = RenderTaskType::RealtimeFullFrame, + .stamp = stamp, + .status = RenderTaskStatus::Completed, + .durationNs = 1'000'000, + .producedDisplayFrame = true, + .completedFrame = true}; + } + } + + TEST(RenderStampMatches, WithSameRequiredAxes_ReturnsTrue) + { + auto const latest = makeStamp(3, 5, 7, 11, 13); + auto const candidate = makeStamp(3, 5, 99, 101, 103); + + EXPECT_TRUE(matches(candidate, latest, RenderStampMask::sceneAndParameters())); + } + + TEST(RenderStampMatches, WithDifferentView_FailsDisplayFrameButPassesHeavyGeometryTask) + { + auto const latest = makeStamp(3, 5, 7, 11, 13); + auto const cameraOnlyStale = makeStamp(3, 5, 6, 11, 13); + + EXPECT_FALSE(matches(cameraOnlyStale, latest, RenderStampMask::displayFrame())); + EXPECT_TRUE(matches(cameraOnlyStale, latest, RenderStampMask::heavyGeometryTask())); + } + + TEST(RenderStampIsOlderThan, WithIgnoredAxis_DoesNotReportOlder) + { + auto const latest = makeStamp(3, 5, 7, 11, 13); + auto const oldViewOnly = makeStamp(3, 5, 1, 11, 13); + + EXPECT_FALSE(isOlderThan(oldViewOnly, latest, RenderStampMask::heavyGeometryTask())); + EXPECT_TRUE(isOlderThan(oldViewOnly, latest, RenderStampMask::displayFrame())); + } + + TEST(RenderTaskResultIsDisplayableFor, WithCurrentDisplayStamp_ReturnsTrue) + { + auto const latest = makeStamp(3, 5, 7, 11, 13); + auto const result = makeDisplayResult(latest); + + EXPECT_TRUE(result.isDisplayableFor(latest)); + } + + TEST(RenderTaskResultIsDisplayableFor, WithOldCameraStamp_ReturnsFalse) + { + auto const latest = makeStamp(3, 5, 7, 11, 13); + auto const oldCamera = makeStamp(3, 5, 6, 11, 13); + auto const result = makeDisplayResult(oldCamera); + + EXPECT_FALSE(result.isDisplayableFor(latest)); + } + + TEST(RenderTaskResultIsDisplayableFor, WithOldParameterStamp_ReturnsFalse) + { + auto const latest = makeStamp(3, 5, 7, 11, 13); + auto const oldParameter = makeStamp(3, 4, 7, 11, 13); + auto const result = makeDisplayResult(oldParameter); + + EXPECT_FALSE(result.isDisplayableFor(latest)); + } + + TEST(RenderTaskResultIsCurrentFor, WithBoundingBoxResult_IgnoresCameraAndViewport) + { + auto const latest = makeStamp(3, 5, 7, 11, 13); + auto const bboxStamp = makeStamp(3, 5, 1, 2, 3); + auto const result = RenderTaskResult{.requestId = 7, + .type = RenderTaskType::BoundingBoxUpdate, + .stamp = bboxStamp, + .status = RenderTaskStatus::Completed}; + + EXPECT_TRUE(result.isCurrentFor(latest, RenderStampMask::heavyGeometryTask())); + EXPECT_FALSE(result.isCurrentFor(latest, RenderStampMask::displayFrame())); + } + + TEST(RenderTaskResultIsCurrentFor, WithCancelledResult_ReturnsFalse) + { + auto const latest = makeStamp(3, 5, 7, 11, 13); + auto result = makeDisplayResult(latest); + result.status = RenderTaskStatus::Cancelled; + + EXPECT_FALSE(result.isCurrentFor(latest, RenderStampMask::displayFrame())); + EXPECT_FALSE(result.isDisplayableFor(latest)); + } + + TEST(RenderTaskResultIsDisplayableFor, WithoutDisplayFrame_ReturnsFalse) + { + auto const latest = makeStamp(3, 5, 7, 11, 13); + auto result = makeDisplayResult(latest); + result.producedDisplayFrame = false; + + EXPECT_FALSE(result.isDisplayableFor(latest)); + } +} From 5d386f0364843016b73c34526dbca9f8a8a608e1 Mon Sep 17 00:00:00 2001 From: Jan Orend <56254096+3dJan@users.noreply.github.com> Date: Thu, 14 May 2026 09:25:35 +0200 Subject: [PATCH 07/72] Frame presentation test coverage --- .../src/ui/render/AsyncRenderController.cpp | 131 ++++++++++++------ gladius/src/ui/render/AsyncRenderController.h | 8 ++ .../ui/render/FramePresentationController.h | 27 ++++ gladius/src/ui/render/RenderUpdateTypes.h | 9 ++ ...syncRenderControllerPresentation_tests.cpp | 112 +++++++++++++++ .../FramePresentationController_tests.cpp | 95 +++++++++++++ .../ui/render/RenderUpdateTypes_tests.cpp | 9 ++ 7 files changed, 351 insertions(+), 40 deletions(-) create mode 100644 gladius/tests/unittests/ui/render/AsyncRenderControllerPresentation_tests.cpp diff --git a/gladius/src/ui/render/AsyncRenderController.cpp b/gladius/src/ui/render/AsyncRenderController.cpp index 48ea4c1f..2cd630ac 100644 --- a/gladius/src/ui/render/AsyncRenderController.cpp +++ b/gladius/src/ui/render/AsyncRenderController.cpp @@ -393,6 +393,7 @@ namespace gladius::ui::async_rendering // First buffer starts as Front m_frameBuffers[0].state.store(FrameState::Front, std::memory_order_release); + m_framePresentation.reset(m_frameBuffers.size()); m_frontBufferIndex.store(0, std::memory_order_release); } } @@ -447,20 +448,56 @@ namespace gladius::ui::async_rendering return nullptr; } - bool AsyncRenderController::tryTransitionBuffer(FrameBuffer * buffer, - FrameState expectedState, - FrameState newState) noexcept + std::optional + AsyncRenderController::frameBufferIndex(FrameBuffer const * buffer) const noexcept { - ProfileFunction + if (buffer == nullptr) + { + return std::nullopt; + } - if (!buffer) + for (size_t index = 0; index < m_frameBuffers.size(); ++index) + { + if (&m_frameBuffers[index] == buffer) + { + return index; + } + } + return std::nullopt; + } + + bool AsyncRenderController::tryTransitionBufferLocked(FrameBuffer * buffer, + FrameState expectedState, + FrameState newState) noexcept + { + if (buffer == nullptr) { return false; } FrameState expected = expectedState; - return buffer->state.compare_exchange_strong( + bool const transitioned = buffer->state.compare_exchange_strong( expected, newState, std::memory_order_acq_rel, std::memory_order_acquire); + if (!transitioned) + { + return false; + } + + if (auto const index = frameBufferIndex(buffer); index.has_value()) + { + [[maybe_unused]] bool const mirrored = + m_framePresentation.tryTransitionBuffer(*index, expectedState, newState); + } + return true; + } + + bool AsyncRenderController::tryTransitionBuffer(FrameBuffer * buffer, + FrameState expectedState, + FrameState newState) noexcept + { + ProfileFunction + std::lock_guard lock(m_bufferMutex); + return tryTransitionBufferLocked(buffer, expectedState, newState); } FrameBuffer * AsyncRenderController::frontBuffer() noexcept @@ -474,19 +511,29 @@ namespace gladius::ui::async_rendering { ProfileFunction std::lock_guard lock(m_bufferMutex); - // Find an Idle buffer to write to - for (auto & buffer : m_frameBuffers) + auto const bufferId = + m_framePresentation.acquireWriteBuffer(RenderStamp{.sceneEpoch = epoch}); + if (!bufferId.has_value() || *bufferId >= m_frameBuffers.size()) { - if (tryTransitionBuffer(&buffer, FrameState::Idle, FrameState::Writing)) - { - buffer.epoch.store(epoch, std::memory_order_release); - return &buffer; - } + return nullptr; } - // No Idle buffer available - this shouldn't happen with triple buffering - // but can happen with double buffering if UI is slow - return nullptr; + auto & buffer = m_frameBuffers[*bufferId]; + FrameState expected = FrameState::Idle; + bool const transitioned = buffer.state.compare_exchange_strong( + expected, FrameState::Writing, std::memory_order_acq_rel, std::memory_order_acquire); + if (!transitioned) + { + [[maybe_unused]] bool const rolledBack = + m_framePresentation.tryTransitionBuffer(*bufferId, + FrameState::Writing, + FrameState::Idle); + return nullptr; + } + + buffer.epoch.store(epoch, std::memory_order_release); + buffer.viewEpoch.store(0, std::memory_order_release); + return &buffer; } void AsyncRenderController::publishFrame(FrameBuffer * buffer, @@ -513,39 +560,38 @@ namespace gladius::ui::async_rendering // Transition Writing → Ready FrameState expected = FrameState::Writing; - buffer->state.compare_exchange_strong( + bool const transitioned = buffer->state.compare_exchange_strong( expected, FrameState::Ready, std::memory_order_release, std::memory_order_acquire); + if (transitioned) + { + if (auto const index = frameBufferIndex(buffer); index.has_value()) + { + [[maybe_unused]] bool const mirrored = m_framePresentation.publishFrame( + *index, frameId, RenderStamp{.sceneEpoch = epoch, .viewEpoch = viewEpoch}); + } + } } FrameBuffer * AsyncRenderController::promoteReadyToFront() noexcept { ProfileFunction std::lock_guard lock(m_bufferMutex); - // Find the newest Ready buffer (highest frameId) - FrameBuffer * newestReady = nullptr; - uint64_t highestFrameId = 0; - - for (auto & buffer : m_frameBuffers) - { - if (buffer.state.load(std::memory_order_acquire) == FrameState::Ready) - { - auto const frameId = buffer.frameId.load(std::memory_order_acquire); - if (frameId > highestFrameId) - { - highestFrameId = frameId; - newestReady = &buffer; - } - } - } - - if (!newestReady) + auto const newestReadyIndex = m_framePresentation.selectNewestReady(); + if (!newestReadyIndex.has_value() || *newestReadyIndex >= m_frameBuffers.size()) { return nullptr; // No Ready buffer available } + auto * newestReady = &m_frameBuffers[*newestReadyIndex]; + // Transition Ready → Resampling - if (!tryTransitionBuffer(newestReady, FrameState::Ready, FrameState::Resampling)) + FrameState expected = FrameState::Ready; + bool const transitioned = newestReady->state.compare_exchange_strong( + expected, FrameState::Resampling, std::memory_order_acq_rel, std::memory_order_acquire); + if (!transitioned) { + [[maybe_unused]] bool const rolledBack = m_framePresentation.tryTransitionBuffer( + *newestReadyIndex, FrameState::Resampling, FrameState::Ready); return nullptr; // Lost race } @@ -574,8 +620,8 @@ namespace gladius::ui::async_rendering continue; } - [[maybe_unused]] bool const released = tryTransitionBuffer( - &buffer, FrameState::Ready, FrameState::Idle); + [[maybe_unused]] bool const released = + tryTransitionBufferLocked(&buffer, FrameState::Ready, FrameState::Idle); return; } } @@ -604,7 +650,10 @@ namespace gladius::ui::async_rendering return false; } - if (!tryTransitionBuffer(&(*it), FrameState::Resampling, FrameState::Front)) + FrameState expected = FrameState::Resampling; + bool const transitioned = it->state.compare_exchange_strong( + expected, FrameState::Front, std::memory_order_acq_rel, std::memory_order_acquire); + if (!transitioned) { return false; } @@ -624,6 +673,8 @@ namespace gladius::ui::async_rendering std::memory_order_acquire); } + [[maybe_unused]] bool const mirrored = m_framePresentation.finalizeFrontPromotion(newIndex); + it->publishTimestampNs.store(static_cast(ns), std::memory_order_release); return true; } @@ -646,7 +697,7 @@ namespace gladius::ui::async_rendering if (state == FrameState::Writing && bufEpoch <= oldEpoch) { [[maybe_unused]] bool const released = - tryTransitionBuffer(&buffer, FrameState::Writing, FrameState::Idle); + tryTransitionBufferLocked(&buffer, FrameState::Writing, FrameState::Idle); } } } diff --git a/gladius/src/ui/render/AsyncRenderController.h b/gladius/src/ui/render/AsyncRenderController.h index 2932ecfe..0efc8e02 100644 --- a/gladius/src/ui/render/AsyncRenderController.h +++ b/gladius/src/ui/render/AsyncRenderController.h @@ -12,6 +12,7 @@ #include #include "AsyncRenderTypes.h" +#include "FramePresentationController.h" // Include OpenCL platform types for cl_float4 #include @@ -162,6 +163,12 @@ namespace gladius::ui::async_rendering [[nodiscard]] static auto workerLoop(std::shared_ptr state) -> coro::task; + [[nodiscard]] std::optional + frameBufferIndex(FrameBuffer const * buffer) const noexcept; + [[nodiscard]] bool tryTransitionBufferLocked(FrameBuffer * buffer, + FrameState expectedState, + FrameState newState) noexcept; + std::shared_ptr m_state; std::atomic m_running{false}; @@ -173,6 +180,7 @@ namespace gladius::ui::async_rendering // Triple buffer state machine (for HQ progressive rendering) std::array m_frameBuffers; + FramePresentationController m_framePresentation{m_frameBuffers.size()}; std::atomic m_frontBufferIndex{0}; mutable std::mutex m_bufferMutex; diff --git a/gladius/src/ui/render/FramePresentationController.h b/gladius/src/ui/render/FramePresentationController.h index 49f76c3b..222b7d96 100644 --- a/gladius/src/ui/render/FramePresentationController.h +++ b/gladius/src/ui/render/FramePresentationController.h @@ -102,6 +102,33 @@ namespace gladius::ui::async_rendering return true; } + [[nodiscard]] bool tryTransitionBuffer(size_t bufferId, + FrameState expectedState, + FrameState newState) noexcept + { + auto * target = mutableBuffer(bufferId); + if (target == nullptr || target->state != expectedState) + { + return false; + } + + target->state = newState; + if (newState == FrameState::Idle) + { + target->frameId = 0; + } + if (newState == FrameState::Front) + { + m_frontBufferId = target->id; + } + return true; + } + + [[nodiscard]] std::optional selectNewestReady() noexcept + { + return selectNewestReady(RenderStamp{}, RenderStampMask::none()); + } + [[nodiscard]] std::optional selectNewestReady(RenderStamp const & required, RenderStampMask const mask) noexcept { diff --git a/gladius/src/ui/render/RenderUpdateTypes.h b/gladius/src/ui/render/RenderUpdateTypes.h index f6ba6b32..ae1f8d53 100644 --- a/gladius/src/ui/render/RenderUpdateTypes.h +++ b/gladius/src/ui/render/RenderUpdateTypes.h @@ -37,6 +37,15 @@ namespace gladius::ui::async_rendering return {}; } + [[nodiscard]] static constexpr RenderStampMask none() noexcept + { + return {.scene = false, + .parameters = false, + .view = false, + .viewport = false, + .quality = false}; + } + [[nodiscard]] static constexpr RenderStampMask sceneOnly() noexcept { return {.scene = true, diff --git a/gladius/tests/unittests/ui/render/AsyncRenderControllerPresentation_tests.cpp b/gladius/tests/unittests/ui/render/AsyncRenderControllerPresentation_tests.cpp new file mode 100644 index 00000000..dd2090af --- /dev/null +++ b/gladius/tests/unittests/ui/render/AsyncRenderControllerPresentation_tests.cpp @@ -0,0 +1,112 @@ +#include "ui/render/AsyncRenderController.h" + +#include + +namespace gladius::ui::async_rendering::tests +{ + TEST(AsyncRenderControllerPresentation, PublishedFrame_CanBePromotedToFront) + { + AsyncRenderController controller; + + auto * buffer = controller.acquireWriteBuffer(10); + ASSERT_NE(buffer, nullptr); + + controller.publishFrame(buffer, 2, 10, 3); + auto * promoted = controller.promoteReadyToFront(); + ASSERT_EQ(promoted, buffer); + + EXPECT_TRUE(controller.finalizeFrontPromotion(promoted)); + EXPECT_EQ(controller.frontBuffer(), buffer); + EXPECT_EQ(buffer->state.load(std::memory_order_acquire), FrameState::Front); + EXPECT_EQ(buffer->epoch.load(std::memory_order_acquire), 10u); + EXPECT_EQ(buffer->viewEpoch.load(std::memory_order_acquire), 3u); + } + + TEST(AsyncRenderControllerPresentation, + FinalizeFrontPromotion_WithExistingFront_DemotesPreviousFrontForReuse) + { + AsyncRenderController controller; + + auto * initialFront = controller.frontBuffer(); + ASSERT_NE(initialFront, nullptr); + ASSERT_TRUE(controller.tryTransitionBuffer(initialFront, FrameState::Idle, FrameState::Front)); + + auto * buffer = controller.acquireWriteBuffer(10); + ASSERT_NE(buffer, nullptr); + ASSERT_NE(buffer, initialFront); + + controller.publishFrame(buffer, 2, 10, 3); + auto * promoted = controller.promoteReadyToFront(); + ASSERT_EQ(promoted, buffer); + ASSERT_TRUE(controller.finalizeFrontPromotion(promoted)); + + EXPECT_EQ(controller.frontBuffer(), buffer); + EXPECT_EQ(buffer->state.load(std::memory_order_acquire), FrameState::Front); + EXPECT_EQ(initialFront->state.load(std::memory_order_acquire), FrameState::Idle); + + auto * reacquired = controller.acquireWriteBuffer(11); + EXPECT_EQ(reacquired, initialFront); + } + + TEST(AsyncRenderControllerPresentation, PromoteReadyToFront_SelectsNewestFrame) + { + AsyncRenderController controller; + + auto * older = controller.acquireWriteBuffer(10); + auto * newer = controller.acquireWriteBuffer(10); + ASSERT_NE(older, nullptr); + ASSERT_NE(newer, nullptr); + ASSERT_NE(older, newer); + + controller.publishFrame(older, 5, 10, 3); + controller.publishFrame(newer, 6, 10, 3); + + auto * promoted = controller.promoteReadyToFront(); + + EXPECT_EQ(promoted, newer); + EXPECT_EQ(newer->state.load(std::memory_order_acquire), FrameState::Resampling); + EXPECT_EQ(older->state.load(std::memory_order_acquire), FrameState::Ready); + } + + TEST(AsyncRenderControllerPresentation, DiscardReadyFrame_WithMatchingMetadata_ReleasesBuffer) + { + AsyncRenderController controller; + + auto * buffer = controller.acquireWriteBuffer(10); + ASSERT_NE(buffer, nullptr); + controller.publishFrame(buffer, 2, 10, 3); + + controller.discardReadyFrame(2, 10, 3); + + EXPECT_EQ(buffer->state.load(std::memory_order_acquire), FrameState::Idle); + auto * reacquired = controller.acquireWriteBuffer(11); + EXPECT_EQ(reacquired, buffer); + } + + TEST(AsyncRenderControllerPresentation, ReleaseStaleBuffers_WithWritingBuffer_ReleasesIt) + { + AsyncRenderController controller; + + auto * buffer = controller.acquireWriteBuffer(10); + ASSERT_NE(buffer, nullptr); + + controller.releaseStaleBuffers(10); + + EXPECT_EQ(buffer->state.load(std::memory_order_acquire), FrameState::Idle); + auto * reacquired = controller.acquireWriteBuffer(11); + EXPECT_EQ(reacquired, buffer); + } + + TEST(AsyncRenderControllerPresentation, TryTransitionBuffer_WithWritingBuffer_KeepsMirrorReusable) + { + AsyncRenderController controller; + + auto * buffer = controller.acquireWriteBuffer(10); + ASSERT_NE(buffer, nullptr); + + EXPECT_TRUE(controller.tryTransitionBuffer(buffer, FrameState::Writing, FrameState::Idle)); + + auto * reacquired = controller.acquireWriteBuffer(11); + EXPECT_EQ(reacquired, buffer); + } +} diff --git a/gladius/tests/unittests/ui/render/FramePresentationController_tests.cpp b/gladius/tests/unittests/ui/render/FramePresentationController_tests.cpp index da19b14f..87ecdb87 100644 --- a/gladius/tests/unittests/ui/render/FramePresentationController_tests.cpp +++ b/gladius/tests/unittests/ui/render/FramePresentationController_tests.cpp @@ -61,6 +61,51 @@ namespace gladius::ui::async_rendering::tests EXPECT_EQ(controller.buffer(*bufferId)->frameId, 17u); } + TEST(FramePresentationController, AcquireWriteBuffer_WithNoIdleBuffer_ReturnsNullopt) + { + FramePresentationController controller{2}; + auto const stamp = makeStamp(1, 2, 3, 4, 5); + + auto const firstWrite = controller.acquireWriteBuffer(stamp); + auto const secondWrite = controller.acquireWriteBuffer(stamp); + + ASSERT_TRUE(firstWrite.has_value()); + EXPECT_FALSE(secondWrite.has_value()); + } + + TEST(FramePresentationController, PublishFrame_WithNonWritingBuffer_ReturnsFalse) + { + FramePresentationController controller{3}; + auto const stamp = makeStamp(1, 2, 3, 4, 5); + + EXPECT_FALSE(controller.publishFrame(*controller.frontBufferId(), 17, stamp)); + EXPECT_EQ(controller.buffer(*controller.frontBufferId())->state, FrameState::Front); + } + + TEST(FramePresentationController, TryTransitionBuffer_WithExpectedState_ReleasesWritingBuffer) + { + FramePresentationController controller{3}; + auto const stamp = makeStamp(1, 2, 3, 4, 5); + auto const bufferId = controller.acquireWriteBuffer(stamp); + ASSERT_TRUE(bufferId.has_value()); + + EXPECT_TRUE(controller.tryTransitionBuffer(*bufferId, FrameState::Writing, FrameState::Idle)); + + EXPECT_EQ(controller.buffer(*bufferId)->state, FrameState::Idle); + EXPECT_EQ(controller.buffer(*bufferId)->frameId, 0u); + } + + TEST(FramePresentationController, TryTransitionBuffer_WithWrongExpectedState_ReturnsFalse) + { + FramePresentationController controller{3}; + auto const stamp = makeStamp(1, 2, 3, 4, 5); + auto const bufferId = controller.acquireWriteBuffer(stamp); + ASSERT_TRUE(bufferId.has_value()); + + EXPECT_FALSE(controller.tryTransitionBuffer(*bufferId, FrameState::Ready, FrameState::Idle)); + EXPECT_EQ(controller.buffer(*bufferId)->state, FrameState::Writing); + } + TEST(FramePresentationController, SelectNewestReady_WithMultipleReadyBuffers_SelectsHighestFrameId) { FramePresentationController controller{4}; @@ -80,6 +125,24 @@ namespace gladius::ui::async_rendering::tests EXPECT_EQ(controller.buffer(*olderReady)->state, FrameState::Ready); } + TEST(FramePresentationController, SelectNewestReady_WithoutFreshnessMask_SelectsNewestRegardlessOfStamp) + { + FramePresentationController controller{4}; + auto const latest = makeStamp(1, 2, 3, 4, 5); + auto const oldView = makeStamp(1, 2, 2, 4, 5); + auto const currentReady = controller.acquireWriteBuffer(latest); + auto const staleButNewerReady = controller.acquireWriteBuffer(oldView); + ASSERT_TRUE(currentReady.has_value()); + ASSERT_TRUE(staleButNewerReady.has_value()); + ASSERT_TRUE(controller.publishFrame(*currentReady, 10, latest)); + ASSERT_TRUE(controller.publishFrame(*staleButNewerReady, 11, oldView)); + + auto const selected = controller.selectNewestReady(); + + ASSERT_TRUE(selected.has_value()); + EXPECT_EQ(*selected, *staleButNewerReady); + } + TEST(FramePresentationController, SelectNewestReady_WithStaleReadyBuffer_IgnoresStaleFrame) { FramePresentationController controller{3}; @@ -113,6 +176,38 @@ namespace gladius::ui::async_rendering::tests EXPECT_EQ(controller.buffer(0)->state, *selected == 0u ? FrameState::Front : FrameState::Idle); } + TEST(FramePresentationController, FinalizeFrontPromotion_WithWrongState_ReturnsFalseAndKeepsFront) + { + FramePresentationController controller{3}; + auto const stamp = makeStamp(1, 2, 3, 4, 5); + auto const bufferId = controller.acquireWriteBuffer(stamp); + ASSERT_TRUE(bufferId.has_value()); + auto const originalFront = controller.frontBufferId(); + + EXPECT_FALSE(controller.finalizeFrontPromotion(*bufferId)); + + EXPECT_EQ(controller.frontBufferId(), originalFront); + EXPECT_EQ(controller.buffer(*bufferId)->state, FrameState::Writing); + } + + TEST(FramePresentationController, FinalizeFrontPromotion_AfterPromotion_AllowsOldFrontReuse) + { + FramePresentationController controller{3}; + auto const latest = makeStamp(1, 2, 3, 4, 5); + auto const bufferId = controller.acquireWriteBuffer(latest); + ASSERT_TRUE(bufferId.has_value()); + ASSERT_TRUE(controller.publishFrame(*bufferId, 12, latest)); + auto const selected = controller.selectNewestReady(latest, RenderStampMask::displayFrame()); + ASSERT_TRUE(selected.has_value()); + ASSERT_TRUE(controller.finalizeFrontPromotion(*selected)); + + auto const nextWrite = controller.acquireWriteBuffer(latest); + + ASSERT_TRUE(nextWrite.has_value()); + EXPECT_EQ(*nextWrite, 0u); + EXPECT_EQ(controller.buffer(*nextWrite)->state, FrameState::Writing); + } + TEST(FramePresentationController, DiscardReadyFrame_WithReadyBuffer_ReleasesItToIdle) { FramePresentationController controller{3}; diff --git a/gladius/tests/unittests/ui/render/RenderUpdateTypes_tests.cpp b/gladius/tests/unittests/ui/render/RenderUpdateTypes_tests.cpp index 733529a0..d6f74ce8 100644 --- a/gladius/tests/unittests/ui/render/RenderUpdateTypes_tests.cpp +++ b/gladius/tests/unittests/ui/render/RenderUpdateTypes_tests.cpp @@ -39,6 +39,15 @@ namespace gladius::ui::async_rendering::tests EXPECT_TRUE(matches(candidate, latest, RenderStampMask::sceneAndParameters())); } + TEST(RenderStampMatches, WithNoneMask_IgnoresAllAxes) + { + auto const latest = makeStamp(3, 5, 7, 11, 13); + auto const completelyDifferent = makeStamp(30, 50, 70, 110, 130); + + EXPECT_TRUE(matches(completelyDifferent, latest, RenderStampMask::none())); + EXPECT_FALSE(isOlderThan(completelyDifferent, latest, RenderStampMask::none())); + } + TEST(RenderStampMatches, WithDifferentView_FailsDisplayFrameButPassesHeavyGeometryTask) { auto const latest = makeStamp(3, 5, 7, 11, 13); From fce5f8aad42131da38d79a89b8ae7e446ec5f2a6 Mon Sep 17 00:00:00 2001 From: Jan Orend <56254096+3dJan@users.noreply.github.com> Date: Thu, 14 May 2026 12:21:38 +0200 Subject: [PATCH 08/72] Improving renderupdatecoordinator --- gladius/src/ui/RenderWindow.cpp | 587 +++++++++++++++--- gladius/src/ui/RenderWindow.h | 36 +- gladius/src/ui/render/AsyncRenderTypes.h | 9 + .../src/ui/render/RenderUpdateCoordinator.h | 475 ++++++++++++++ .../render/RenderUpdateCoordinator_tests.cpp | 498 +++++++++++++++ 5 files changed, 1502 insertions(+), 103 deletions(-) create mode 100644 gladius/src/ui/render/RenderUpdateCoordinator.h create mode 100644 gladius/tests/unittests/ui/render/RenderUpdateCoordinator_tests.cpp diff --git a/gladius/src/ui/RenderWindow.cpp b/gladius/src/ui/RenderWindow.cpp index c69c45d0..0d1ccb20 100644 --- a/gladius/src/ui/RenderWindow.cpp +++ b/gladius/src/ui/RenderWindow.cpp @@ -5,6 +5,7 @@ #include #include #include +#include #include "../CLMath.h" #include "../ComputeContext.h" @@ -147,7 +148,8 @@ namespace gladius::ui m_configManager->getValue("renderWindow", "permanentCenteringEnabled", false); } - m_realtimeRaymarchController.configure(loadRealtimeRaymarchConfig()); + auto const realtimeConfig = loadRealtimeRaymarchConfig(); + m_renderUpdateCoordinator.configureRealtime(realtimeConfig); // Don't initialize async rendering here - will be done lazily on first render } @@ -209,8 +211,12 @@ namespace gladius::ui m_asyncInFlightViewEpoch.store(0, std::memory_order_release); m_asyncFrameCounter.store(0, std::memory_order_release); m_asyncJobInFlight.store(false, std::memory_order_release); + m_asyncRealtimeJobInFlight.store(false, std::memory_order_release); m_asyncSdfJobInFlight.store(false, std::memory_order_release); m_asyncSdfInFlightEpoch.store(0, std::memory_order_release); + m_renderUpdateCoordinator = async_rendering::RenderUpdateCoordinator{}; + m_renderUpdateCoordinator.configureRealtime(loadRealtimeRaymarchConfig()); + m_pendingRenderCommands.clear(); notifyAsyncEpochIncrement(); } else @@ -227,6 +233,7 @@ namespace gladius::ui m_asyncInFlightViewEpoch.store(0, std::memory_order_release); m_asyncFrameCounter.store(0, std::memory_order_release); m_asyncJobInFlight.store(false, std::memory_order_release); + m_asyncRealtimeJobInFlight.store(false, std::memory_order_release); m_asyncSdfJobInFlight.store(false, std::memory_order_release); m_asyncSdfInFlightEpoch.store(0, std::memory_order_release); } @@ -247,6 +254,7 @@ namespace gladius::ui m_asyncInFlightEpoch.store(0, std::memory_order_release); m_asyncInFlightViewEpoch.store(0, std::memory_order_release); m_asyncJobInFlight.store(false, std::memory_order_release); + m_asyncRealtimeJobInFlight.store(false, std::memory_order_release); m_asyncSdfJobInFlight.store(false, std::memory_order_release); m_asyncSdfInFlightEpoch.store(0, std::memory_order_release); m_asyncBboxJobInFlight.store(false, std::memory_order_release); @@ -286,10 +294,19 @@ namespace gladius::ui m_renderWindowState.currentLine = 0; m_renderWindowState.renderingStepSize = kInitialProgressiveStepSize; m_renderWindowState.isRendering = false; - m_forceLowResRenderOnNextFrame.store(true, std::memory_order_release); - m_lastLowResRenderTime = std::chrono::system_clock::now(); + bool const forceLowResPreview = !m_renderUpdateCoordinator.isRealtimeActive(); + m_forceLowResRenderOnNextFrame.store(forceLowResPreview, std::memory_order_release); + if (forceLowResPreview) + { + m_lastLowResRenderTime = std::chrono::system_clock::now(); + } + else + { + m_lowResFeedbackPending.store(false, std::memory_order_release); + } m_suppressHQDisplay.store(false, std::memory_order_release); m_asyncViewEpoch.fetch_add(1, std::memory_order_acq_rel); + queueRenderDecision(m_renderUpdateCoordinator.notifyCameraChanged()); if (m_core) { @@ -339,6 +356,263 @@ namespace gladius::ui m_configManager->save(); } + void RenderWindow::queueRenderDecision(async_rendering::RenderUpdateDecision decision) + { + m_pendingRenderCommands.insert(m_pendingRenderCommands.end(), + std::make_move_iterator(decision.commands.begin()), + std::make_move_iterator(decision.commands.end())); + } + + bool RenderWindow::executeQueuedRenderCommands(RenderWindowState & state) + { + bool startedWork = false; + size_t processedCommands = 0; + while (!m_pendingRenderCommands.empty() && processedCommands < 64) + { + auto command = std::move(m_pendingRenderCommands.front()); + m_pendingRenderCommands.erase(m_pendingRenderCommands.begin()); + startedWork = executeRenderCommand(command, state) || startedWork; + ++processedCommands; + } + return startedWork; + } + + bool RenderWindow::executeRenderCommand(async_rendering::RenderCommand const & command, + RenderWindowState & state) + { + if (command.type != async_rendering::RenderCommandType::StartTask) + { + return false; + } + + return scheduleCoordinatorTask(command.task, state); + } + + bool RenderWindow::scheduleCoordinatorTask(async_rendering::RenderTaskRequest const & task, + RenderWindowState & state) + { + auto const failTask = [&]() + { completeCoordinatorTask(task, async_rendering::RenderTaskStatus::Failed); }; + + switch (task.type) + { + case async_rendering::RenderTaskType::ProgramCompilation: + if (m_core && m_core->tryIsRenderProgramReady().value_or(false)) + { + queueRenderDecision(m_renderUpdateCoordinator.notifyProgramCompilationCompleted()); + } + return false; + + case async_rendering::RenderTaskType::ParameterUpload: + { + bool updated = false; + if (m_core && m_document) + { + if (auto assembly = m_document->getAssembly(); assembly && m_core->isRendererReady()) + { + updated = m_core->tryToupdateParameter(*assembly); + } + } + completeCoordinatorTask(task, + updated ? async_rendering::RenderTaskStatus::Completed + : async_rendering::RenderTaskStatus::Failed); + return false; + } + + case async_rendering::RenderTaskType::BoundingBoxUpdate: + { + bool const wasInFlight = m_asyncBboxJobInFlight.load(std::memory_order_acquire); + scheduleAsyncBboxUpdate(&task); + bool const started = !wasInFlight && m_asyncBboxJobInFlight.load(std::memory_order_acquire); + if (!started) + { + failTask(); + } + return started; + } + + case async_rendering::RenderTaskType::SdfPrecomputation: + { + bool const started = scheduleAsyncSdfPrecomputation(&task); + if (!started) + { + failTask(); + } + return started; + } + + case async_rendering::RenderTaskType::RealtimeFullFrame: + { + bool const started = scheduleRealtimeRenderJob(state, &task); + if (!started) + { + m_renderUpdateCoordinator.recordRealtimeRejectedAttempt(); + failTask(); + } + return started; + } + + case async_rendering::RenderTaskType::LowResolutionPreview: + if (isRealtimeRaymarchCameraInteraction()) + { + completeCoordinatorTask(task, async_rendering::RenderTaskStatus::Cancelled); + return false; + } + if (!m_core || !m_core->isSdfValid()) + { + failTask(); + return false; + } + { + bool const started = scheduleAsyncPreviewJob(&task); + if (!started) + { + failTask(); + } + return started; + } + + case async_rendering::RenderTaskType::StreamingPreview: + m_streamingPreviewActive.store(true, std::memory_order_release); + { + bool const started = scheduleStreamingPreviewJob(&task); + if (!started) + { + failTask(); + } + return started; + } + + case async_rendering::RenderTaskType::ProgressiveHighQualityChunk: + state.isRendering = true; + if (!scheduleAsyncRenderJob(state, &task)) + { + state.isRendering = false; + failTask(); + return false; + } + return true; + } + + return false; + } + + bool RenderWindow::isRealtimeRaymarchCameraInteraction() const noexcept + { + return m_renderUpdateCoordinator.interactionState() == + async_rendering::RenderInteractionState::CameraInteracting && + (m_renderUpdateCoordinator.isRealtimeActive() || + m_asyncRealtimeJobInFlight.load(std::memory_order_acquire)); + } + + bool RenderWindow::scheduleAsyncSdfPrecomputation( + async_rendering::RenderTaskRequest const * coordinatorTask) + { + if (!m_asyncController || !m_asyncController->isRunning() || + m_asyncSdfJobInFlight.load(std::memory_order_acquire)) + { + return false; + } + + async_rendering::RenderJob sdfJob{}; + sdfJob.type = async_rendering::RenderJobType::SDFPrecomputation; + if (coordinatorTask != nullptr) + { + sdfJob.coordinatorRequestId = coordinatorTask->requestId; + sdfJob.coordinatorStamp = coordinatorTask->stamp; + } + sdfJob.epoch = m_asyncCurrentEpoch.load(std::memory_order_acquire); + if (sdfJob.epoch == 0) + { + sdfJob.epoch = m_asyncEpochCounter.fetch_add(1, std::memory_order_acq_rel) + 1; + m_asyncCurrentEpoch.store(sdfJob.epoch, std::memory_order_release); + } + m_asyncController->setLatestEpoch(sdfJob.epoch); + m_asyncSdfJobInFlight.store(true, std::memory_order_release); + m_asyncSdfInFlightEpoch.store(sdfJob.epoch, std::memory_order_release); + m_asyncController->enqueueJob(sdfJob); + return true; + } + + void RenderWindow::completeCoordinatorTask(async_rendering::RenderTaskRequest const & request, + async_rendering::RenderTaskStatus status) + { + queueRenderDecision(m_renderUpdateCoordinator.completeTask( + async_rendering::RenderTaskResult{.requestId = request.requestId, + .type = request.type, + .stamp = request.stamp, + .status = status})); + } + + void RenderWindow::completeCoordinatorTask(async_rendering::FrameResultMeta const & result, + bool producedDisplayFrame) + { + if (result.coordinatorRequestId == 0) + { + return; + } + + auto taskType = async_rendering::RenderTaskType::ProgressiveHighQualityChunk; + switch (result.jobType) + { + case async_rendering::RenderJobType::RealtimeHighQuality: + taskType = async_rendering::RenderTaskType::RealtimeFullFrame; + break; + case async_rendering::RenderJobType::LowResPreview: + taskType = async_rendering::RenderTaskType::LowResolutionPreview; + break; + case async_rendering::RenderJobType::StreamingPreview: + taskType = async_rendering::RenderTaskType::StreamingPreview; + break; + case async_rendering::RenderJobType::BoundingBoxUpdate: + taskType = async_rendering::RenderTaskType::BoundingBoxUpdate; + break; + case async_rendering::RenderJobType::ParameterUpdate: + taskType = async_rendering::RenderTaskType::ParameterUpload; + break; + case async_rendering::RenderJobType::SDFPrecomputation: + taskType = async_rendering::RenderTaskType::SdfPrecomputation; + break; + case async_rendering::RenderJobType::ProgramCompilation: + taskType = async_rendering::RenderTaskType::ProgramCompilation; + break; + case async_rendering::RenderJobType::HighQuality: + default: + taskType = async_rendering::RenderTaskType::ProgressiveHighQualityChunk; + break; + } + + auto const status = result.cancelled ? async_rendering::RenderTaskStatus::Cancelled + : async_rendering::RenderTaskStatus::Completed; + queueRenderDecision(m_renderUpdateCoordinator.completeTask( + async_rendering::RenderTaskResult{.requestId = result.coordinatorRequestId, + .type = taskType, + .stamp = result.coordinatorStamp, + .status = status, + .durationNs = result.computeDurationNs, + .producedDisplayFrame = producedDisplayFrame, + .completedFrame = result.completedFrame})); + } + + void RenderWindow::completeCoordinatorPreviewTask(async_rendering::PreviewResultMeta const & result) + { + if (result.coordinatorRequestId == 0) + { + return; + } + + auto const status = result.cancelled ? async_rendering::RenderTaskStatus::Cancelled + : async_rendering::RenderTaskStatus::Completed; + queueRenderDecision(m_renderUpdateCoordinator.completeTask( + async_rendering::RenderTaskResult{.requestId = result.coordinatorRequestId, + .type = async_rendering::RenderTaskType::LowResolutionPreview, + .stamp = result.coordinatorStamp, + .status = status, + .durationNs = result.latencyNs, + .producedDisplayFrame = !result.cancelled, + .completedFrame = !result.cancelled})); + } + void RenderWindow::renderWindow() { ProfileFunction; @@ -379,17 +653,26 @@ namespace gladius::ui // to avoid showing stale HQ results from an old parameter set. // For low-res preview during rapid parameter edits, we fall back to m_resultImage // which is updated synchronously and always shows *something* recent. + // Exact realtime camera motion must not fall back to that preview texture. + bool const realtimeCameraInteraction = isRealtimeRaymarchCameraInteraction(); + bool const realtimeRaymarchActive = m_renderUpdateCoordinator.isRealtimeActive(); bool const epochMatches = frontBuf && frontBuf->epoch == currentEpoch; - bool const viewMatches = frontBuf && frontBuf->viewEpoch == currentViewEpoch; + bool const viewMatches = frontBuf && frontBuf->viewEpoch == currentViewEpoch; + bool const allowRealtimeFront = + (realtimeCameraInteraction || realtimeRaymarchActive) && epochMatches; + bool const frontBlockedByRendering = m_renderWindowState.isRendering && + !allowRealtimeFront; bool const useFrontBuffer = frontBuf && frontBuf->image && epochMatches && - viewMatches && !m_renderWindowState.isRendering && + (viewMatches || allowRealtimeFront) && + !frontBlockedByRendering && !m_suppressHQDisplay.load(std::memory_order_acquire); - bool const useProgressiveBuffer = - m_asyncProgressiveBuffer && m_asyncProgressiveBuffer->image && - m_asyncProgressiveEpoch.load(std::memory_order_acquire) == currentEpoch && - m_asyncProgressiveViewEpoch.load(std::memory_order_acquire) == currentViewEpoch && - !m_renderWindowState.isMoving && - !m_suppressHQDisplay.load(std::memory_order_acquire); + bool const progressiveBufferCurrent = + m_asyncProgressiveEpoch.load(std::memory_order_acquire) == currentEpoch && + m_asyncProgressiveViewEpoch.load(std::memory_order_acquire) == currentViewEpoch; + bool const useProgressiveBuffer = + m_asyncProgressiveBuffer && m_asyncProgressiveBuffer->image && + progressiveBufferCurrent && !m_renderWindowState.isMoving && + !m_suppressHQDisplay.load(std::memory_order_acquire); if (useFrontBuffer) { @@ -401,7 +684,9 @@ namespace gladius::ui } else { - // Fallback to result image for progressive rendering or when no valid front buffer + // Fallback to result image for progressive rendering or when no valid front buffer. + // Preview result suppression keeps realtime camera motion from writing stale + // surrogate frames here; returning nullptr would blank the viewport. displayImage = m_core->getResultImage(); } } @@ -545,11 +830,11 @@ namespace gladius::ui ImGui::Separator(); if (ImGui::BeginMenu("Realtime Raymarch")) { - auto config = m_realtimeRaymarchController.config(); + auto config = m_renderUpdateCoordinator.realtimeConfig(); auto const setMode = [&](async_rendering::RealtimeRaymarchMode mode) { config.mode = mode; - m_realtimeRaymarchController.configure(config); + m_renderUpdateCoordinator.configureRealtime(config); saveRealtimeRaymarchMode(mode); }; @@ -790,6 +1075,7 @@ namespace gladius::ui // Force a low-res render on next frame for immediate visual feedback m_forceLowResRenderOnNextFrame.store(true, std::memory_order_release); + queueRenderDecision(m_renderUpdateCoordinator.notifyStructuralModelChanged()); invalidateView(); @@ -845,6 +1131,7 @@ namespace gladius::ui m_lowResFeedbackPending.store(true, std::memory_order_release); m_modelModifiedSinceLastCenter = true; m_asyncViewEpoch.fetch_add(1, std::memory_order_acq_rel); + queueRenderDecision(m_renderUpdateCoordinator.notifyParameterChanged(true)); // Mark bbox stale instead of resetting — preserves cached bbox for reuse with extra margin m_core->markBoundingBoxStale(); @@ -1371,6 +1658,7 @@ namespace gladius::ui // path has no PID, so we reset to the nominal 50% of full quality here. state.renderQualityWhileMoving = state.renderQuality * 0.5f; invalidateViewDuetoModelUpdate(); + queueRenderDecision(m_renderUpdateCoordinator.notifyProgramCompilationCompleted()); } // Lazy initialization: only initialize async rendering once renderer is ready @@ -1634,13 +1922,51 @@ namespace gladius::ui // Update camera now that we know core is ready m_core->applyCamera(m_camera); - m_realtimeRaymarchController.beginFrame(); - processAsyncResults(state); // Process async preview results (separate from HQ progressive results) processAsyncPreviewResults(); + int const desiredScreenWidth = static_cast( + std::clamp(m_renderWindowSize_px.x * state.renderQuality, 1.f, 16000.f)); + int const desiredScreenHeight = static_cast( + std::clamp(m_renderWindowSize_px.y * state.renderQuality, 1.f, 16000.f)); + auto const resultImage = m_core->getResultImage(); + bool const screenResizeRequired = + resultImage && (static_cast(resultImage->getWidth()) != desiredScreenWidth || + static_cast(resultImage->getHeight()) != desiredScreenHeight); + + if (m_asyncController && m_asyncController->isRunning()) + { + queueRenderDecision(m_renderUpdateCoordinator.configureViewport( + static_cast(desiredScreenWidth), static_cast(desiredScreenHeight))); + + m_renderUpdateCoordinator.setRealtimeGuards( + async_rendering::RealtimeRaymarchGuards{ + .hardBlocker = !m_enableHQRendering || + m_asyncSdfJobInFlight.load(std::memory_order_acquire) || + m_asyncBboxJobInFlight.load(std::memory_order_acquire) || + m_core->isAnyCompilationInProgressNonBlocking(), + .renderJobInFlight = m_asyncJobInFlight.load(std::memory_order_acquire), + .previewJobInFlight = m_asyncPreviewJobInFlight.load(std::memory_order_acquire), + .streamingActive = m_streamingPreviewActive.load(std::memory_order_acquire), + .streamingJobInFlight = m_streamingJobInFlight.load(std::memory_order_acquire), + .resizePending = m_deferredResizePending || screenResizeRequired}); + + if (!state.isMoving && + m_renderUpdateCoordinator.interactionState() == + async_rendering::RenderInteractionState::CameraInteracting) + { + queueRenderDecision(m_renderUpdateCoordinator.notifyCameraInteractionEnded()); + } + + queueRenderDecision(m_renderUpdateCoordinator.tick()); + if (executeQueuedRenderCommands(state)) + { + return; + } + } + if (m_asyncController && m_asyncController->isRunning()) { bool const sdfDirty = m_preComputedSdfDirty.load(std::memory_order_acquire); @@ -1810,16 +2136,6 @@ namespace gladius::ui // worker jobs copy their staging image back into m_resultImage after the render kernel // completes, and resizing that image concurrently can crash inside clEnqueueCopyImage. bool const shouldDeferResize = m_preserveContentDuringResize && m_deferredResizePending; - int const desiredScreenWidth = static_cast( - std::clamp(m_renderWindowSize_px.x * state.renderQuality, 1.f, 16000.f)); - int const desiredScreenHeight = static_cast( - std::clamp(m_renderWindowSize_px.y * state.renderQuality, 1.f, 16000.f)); - m_realtimeRaymarchController.resetForResolution( - static_cast(desiredScreenWidth), static_cast(desiredScreenHeight)); - auto const resultImage = m_core->getResultImage(); - bool const screenResizeRequired = - resultImage && (static_cast(resultImage->getWidth()) != desiredScreenWidth || - static_cast(resultImage->getHeight()) != desiredScreenHeight); bool const hqRenderInFlight = m_asyncJobInFlight.load(std::memory_order_acquire); if (!shouldDeferResize && screenResizeRequired && !hqRenderInFlight && @@ -1893,6 +2209,20 @@ namespace gladius::ui bool const lowResPending = m_lowResFeedbackPending.load(std::memory_order_acquire); bool const previewJobInFlight = m_asyncPreviewJobInFlight.load(std::memory_order_acquire); + if (m_asyncRealtimeJobInFlight.load(std::memory_order_acquire)) + { + m_lowResFeedbackPending.store(false, std::memory_order_release); + state.isRendering = true; + return; + } + + if (isRealtimeRaymarchCameraInteraction()) + { + m_lowResFeedbackPending.store(false, std::memory_order_release); + state.isRendering = m_asyncJobInFlight.load(std::memory_order_acquire); + return; + } + if (state.isMoving || forceLowResRender || lowResPending) { bool const hadActiveProgressive = @@ -1912,34 +2242,9 @@ namespace gladius::ui // - Sync preview is the fallback when async is unavailable // During parameter drag, SDF jobs are debounced so there's no GPU contention. bool const sdfValid = m_core->isSdfValid(); - bool const sdfJobRunning = m_asyncSdfJobInFlight.load(std::memory_order_acquire); bool const streamingActive = m_streamingPreviewActive.load(std::memory_order_acquire); bool const useAsyncPreview = !previewJobInFlight && sdfValid; - async_rendering::RealtimeRaymarchGuards const realtimeGuards{ - .hardBlocker = !m_enableHQRendering || sdfJobRunning || - m_asyncBboxJobInFlight.load(std::memory_order_acquire) || - m_core->isAnyCompilationInProgressNonBlocking(), - .renderJobInFlight = m_asyncJobInFlight.load(std::memory_order_acquire), - .previewJobInFlight = previewJobInFlight, - .streamingActive = streamingActive, - .streamingJobInFlight = m_streamingJobInFlight.load(std::memory_order_acquire), - .resizePending = shouldDeferResize || screenResizeRequired}; - - if (m_realtimeRaymarchController.canAttemptRealtime( - static_cast(desiredScreenWidth), - static_cast(desiredScreenHeight), - realtimeGuards)) - { - if (scheduleRealtimeRenderJob(state)) - { - m_lastLowResRenderTime = std::chrono::system_clock::now(); - return; - } - m_realtimeRaymarchController.recordRejectedAttempt(); - state.isRendering = false; - } - if (streamingActive) { // Streaming loop handles preview rendering continuously. @@ -2021,8 +2326,9 @@ namespace gladius::ui // Only enforce timeout if we're NOT already in middle of progressive rendering // (otherwise chunks would be blocked after any interaction) bool const isProgressiveRenderInProgress = state.isRendering && state.currentLine > 0; + bool const useLowResPreviewBeforeHq = !m_renderUpdateCoordinator.isRealtimeActive(); - if (!isProgressiveRenderInProgress) + if (!isProgressiveRenderInProgress && useLowResPreviewBeforeHq) { auto const timeSinceLastLowResRender = std::chrono::system_clock::now() - m_lastLowResRenderTime; @@ -2103,6 +2409,9 @@ namespace gladius::ui // to avoid scheduling a redundant bbox job. m_asyncBboxUpdatePending.store(false, std::memory_order_release); } + auto coordinatorResult = result; + coordinatorResult.cancelled = coordinatorResult.cancelled || isOutdated; + completeCoordinatorTask(coordinatorResult, false); continue; } @@ -2114,6 +2423,9 @@ namespace gladius::ui { scheduleAsyncBboxUpdate(); } + auto coordinatorResult = result; + coordinatorResult.cancelled = coordinatorResult.cancelled || isOutdated; + completeCoordinatorTask(coordinatorResult, false); continue; } @@ -2140,31 +2452,36 @@ namespace gladius::ui size_t const renderedLines = result.completedLine > result.startLine ? result.completedLine - result.startLine : size_t{0}; - m_realtimeRaymarchController.recordSample( - async_rendering::RealtimeRaymarchSample{ - .durationMs = static_cast( - static_cast(result.computeDurationNs) / 1'000'000.0), - .width = result.width, - .height = result.height, - .renderedLines = renderedLines, - .totalLines = result.height, - .completedFrame = result.completedFrame, - .cancelled = result.cancelled}); + m_renderUpdateCoordinator.recordRealtimeSample( + async_rendering::RealtimeRaymarchSample{ + .durationMs = static_cast( + static_cast(result.computeDurationNs) / 1'000'000.0), + .width = result.width, + .height = result.height, + .renderedLines = renderedLines, + .totalLines = result.height, + .completedFrame = result.completedFrame, + .cancelled = result.cancelled}); } if (result.cancelled) { if (isRealtimeJob) { - m_realtimeRaymarchController.recordRejectedAttempt(); + m_renderUpdateCoordinator.recordRealtimeRejectedAttempt(); } if (result.epoch == m_asyncInFlightEpoch.load(std::memory_order_acquire)) { m_asyncJobInFlight.store(false, std::memory_order_release); + if (isRealtimeJob) + { + m_asyncRealtimeJobInFlight.store(false, std::memory_order_release); + } m_asyncInFlightEpoch.store(0, std::memory_order_release); m_asyncInFlightViewEpoch.store(0, std::memory_order_release); state.isRendering = false; } + completeCoordinatorTask(result, false); continue; } @@ -2180,9 +2497,14 @@ namespace gladius::ui // Stale scene/view results are deliberately discarded before promotion; otherwise // a late realtime frame could replace a valid front buffer with an old camera or // parameter state. - if (result.completedFrame) + bool const allowForcedRealtimeStaleView = + isRealtimeJob && m_renderUpdateCoordinator.realtimeConfig().mode == + async_rendering::RealtimeRaymarchMode::Force; + bool const discardForStaleView = isViewOutdated && !allowForcedRealtimeStaleView; + + if (result.completedFrame) { - if (isOutdated || isViewOutdated) + if (isOutdated || discardForStaleView) { m_asyncController->discardReadyFrame( result.frameId, result.epoch, result.viewEpoch); @@ -2206,9 +2528,9 @@ namespace gladius::ui if (result.completedFrame) { - if (!isOutdated && !isViewOutdated) + if (!isOutdated && !discardForStaleView) { - m_dirty = false; + m_dirty = isViewOutdated && allowForcedRealtimeStaleView; state.isRendering = false; // DON'T force state.isMoving = false - let camera update control this // If camera is still animating, it will set isMoving=true on next frame @@ -2216,10 +2538,16 @@ namespace gladius::ui if (isRealtimeJob) { m_lowResFeedbackPending.store(false, std::memory_order_release); - m_lastLowResRenderTime = std::chrono::system_clock::now(); m_lastLowResPreviewEpoch.store(result.epoch, std::memory_order_release); } - m_view->stopAnimationMode(); + if (isViewOutdated && allowForcedRealtimeStaleView) + { + m_view->startAnimationMode(); + } + else + { + m_view->stopAnimationMode(); + } } } else @@ -2251,6 +2579,10 @@ namespace gladius::ui if (result.epoch == m_asyncInFlightEpoch.load(std::memory_order_acquire)) { m_asyncJobInFlight.store(false, std::memory_order_release); + if (isRealtimeJob) + { + m_asyncRealtimeJobInFlight.store(false, std::memory_order_release); + } m_asyncInFlightEpoch.store(0, std::memory_order_release); m_asyncInFlightViewEpoch.store(0, std::memory_order_release); @@ -2261,10 +2593,13 @@ namespace gladius::ui state.isRendering = false; } } + + completeCoordinatorTask(result, result.completedFrame && !isOutdated && !isViewOutdated); } } - bool RenderWindow::scheduleAsyncRenderJob(RenderWindowState & state) + bool RenderWindow::scheduleAsyncRenderJob(RenderWindowState & state, + async_rendering::RenderTaskRequest const * coordinatorTask) { ZoneScoped; ZoneName("scheduleAsyncRenderJob", strlen("scheduleAsyncRenderJob")); @@ -2316,6 +2651,11 @@ namespace gladius::ui std::max(1, std::min(state.renderingStepSize, height - job.startLine)); job.precomputeSdf = false; job.enableHighQuality = m_enableHQRendering; + if (coordinatorTask != nullptr) + { + job.coordinatorRequestId = coordinatorTask->requestId; + job.coordinatorStamp = coordinatorTask->stamp; + } if (job.startLine == 0 && (!m_asyncProgressiveBuffer || @@ -2356,7 +2696,9 @@ namespace gladius::ui return true; } - bool RenderWindow::scheduleRealtimeRenderJob(RenderWindowState & state) + bool RenderWindow::scheduleRealtimeRenderJob( + RenderWindowState & state, + async_rendering::RenderTaskRequest const * coordinatorTask) { ZoneScoped; ZoneName("scheduleRealtimeRenderJob", strlen("scheduleRealtimeRenderJob")); @@ -2410,14 +2752,22 @@ namespace gladius::ui job.stepSize = height; job.precomputeSdf = false; job.enableHighQuality = true; + if (coordinatorTask != nullptr) + { + job.coordinatorRequestId = coordinatorTask->requestId; + job.coordinatorStamp = coordinatorTask->stamp; + } state.currentLine = 0; state.renderingStepSize = height; state.isRendering = true; + m_forceLowResRenderOnNextFrame.store(false, std::memory_order_release); + m_lowResFeedbackPending.store(false, std::memory_order_release); m_asyncInFlightEpoch.store(job.epoch, std::memory_order_release); m_asyncInFlightViewEpoch.store(job.viewEpoch, std::memory_order_release); m_asyncJobInFlight.store(true, std::memory_order_release); + m_asyncRealtimeJobInFlight.store(true, std::memory_order_release); m_asyncController->enqueueJob(job); ZoneText("RealtimeJobEnqueued", 19); @@ -2452,6 +2802,8 @@ namespace gladius::ui result.width = job.width; result.height = job.height; result.startLine = job.startLine; + result.coordinatorRequestId = job.coordinatorRequestId; + result.coordinatorStamp = job.coordinatorStamp; if (cancellationRequested()) { @@ -3062,10 +3414,11 @@ namespace gladius::ui } m_camera.adjustDistanceToTarget(*bbox, m_renderWindowSize_px.x, m_renderWindowSize_px.y); - invalidateCameraView(); + invalidateCameraView(); } - void RenderWindow::scheduleAsyncBboxUpdate() + void RenderWindow::scheduleAsyncBboxUpdate( + async_rendering::RenderTaskRequest const * coordinatorTask) { if (!m_asyncController || !m_asyncInitialized) { @@ -3086,6 +3439,11 @@ namespace gladius::ui async_rendering::RenderJob job{}; job.type = async_rendering::RenderJobType::BoundingBoxUpdate; job.epoch = m_asyncCurrentEpoch.load(std::memory_order_acquire); + if (coordinatorTask != nullptr) + { + job.coordinatorRequestId = coordinatorTask->requestId; + job.coordinatorStamp = coordinatorTask->stamp; + } m_asyncController->enqueueJob(job); } @@ -3101,6 +3459,8 @@ namespace gladius::ui result.epoch = job.epoch; result.jobType = job.type; result.cancelled = false; + result.coordinatorRequestId = job.coordinatorRequestId; + result.coordinatorStamp = job.coordinatorStamp; auto const startTime = std::chrono::steady_clock::now(); @@ -3162,6 +3522,8 @@ namespace gladius::ui result.jobType = job.type; result.cancelled = false; result.precomputedSdfUpdated = false; + result.coordinatorRequestId = job.coordinatorRequestId; + result.coordinatorStamp = job.coordinatorStamp; auto const startTime = std::chrono::steady_clock::now(); @@ -3305,7 +3667,8 @@ namespace gladius::ui // ============== Async Preview Rendering ============== - bool RenderWindow::scheduleAsyncPreviewJob() + bool RenderWindow::scheduleAsyncPreviewJob( + async_rendering::RenderTaskRequest const * coordinatorTask) { ZoneScoped; ZoneName("scheduleAsyncPreviewJob", strlen("scheduleAsyncPreviewJob")); @@ -3348,6 +3711,7 @@ namespace gladius::ui m_asyncCurrentEpoch.store(job.epoch, std::memory_order_release); } m_asyncController->setLatestEpoch(job.epoch); + job.viewEpoch = m_asyncViewEpoch.load(std::memory_order_acquire); // Use low-res preview resolution auto const [previewWidth, previewHeight] = m_core->getLowResPreviewResolution(); @@ -3358,6 +3722,11 @@ namespace gladius::ui job.stepSize = static_cast(previewHeight); job.precomputeSdf = false; job.enableHighQuality = false; + if (coordinatorTask != nullptr) + { + job.coordinatorRequestId = coordinatorTask->requestId; + job.coordinatorStamp = coordinatorTask->stamp; + } // Track preview job state m_asyncPreviewEpoch.store(job.epoch, std::memory_order_release); @@ -3389,9 +3758,12 @@ namespace gladius::ui FrameResultMeta result{}; result.frameId = job.frameHint; result.epoch = job.epoch; + result.viewEpoch = job.viewEpoch; result.jobType = job.type; result.width = job.width; result.height = job.height; + result.coordinatorRequestId = job.coordinatorRequestId; + result.coordinatorStamp = job.coordinatorStamp; // Note: We do NOT cancel preview jobs based on epoch changes. // This ensures smooth visual feedback during camera movement. @@ -3509,9 +3881,12 @@ namespace gladius::ui PreviewResultMeta previewMeta{}; previewMeta.frameId = job.frameHint; previewMeta.epoch = job.epoch; + previewMeta.viewEpoch = job.viewEpoch; previewMeta.latencyNs = result.computeDurationNs; previewMeta.cancelled = false; previewMeta.sdfWasValid = m_core->isSdfValid(); + previewMeta.coordinatorRequestId = job.coordinatorRequestId; + previewMeta.coordinatorStamp = job.coordinatorStamp; m_asyncPreviewJobInFlight.store(false, std::memory_order_release); // Note: Don't set m_asyncPreviewFrameId here - it's set in processAsyncPreviewResults @@ -3541,6 +3916,24 @@ namespace gladius::ui auto const & meta = result.value(); + auto const currentEpoch = m_asyncCurrentEpoch.load(std::memory_order_acquire); + auto const currentViewEpoch = m_asyncViewEpoch.load(std::memory_order_acquire); + bool const staleForCurrentView = + meta.epoch < currentEpoch || (meta.viewEpoch != 0 && meta.viewEpoch < currentViewEpoch); + bool const suppressForRealtimeRaymarch = + isRealtimeRaymarchCameraInteraction() || + (m_renderUpdateCoordinator.isRealtimeActive() && staleForCurrentView); + + if (suppressForRealtimeRaymarch) + { + ZoneText("PreviewSkippedForRealtimeRaymarch", 34); + m_streamingFrameConsumed.store(true, std::memory_order_release); + auto cancelledMeta = meta; + cancelledMeta.cancelled = true; + completeCoordinatorPreviewTask(cancelledMeta); + return; + } + // Skip frames that are older than what we've already displayed. // This ensures we never show an older frame after a newer one. // Use frameId (which is monotonically increasing) for ordering. @@ -3549,6 +3942,7 @@ namespace gladius::ui { ZoneText("OutOfOrderPreviewSkipped", 24); m_streamingFrameConsumed.store(true, std::memory_order_release); + completeCoordinatorPreviewTask(meta); return; } @@ -3557,6 +3951,7 @@ namespace gladius::ui { ZoneText("CancelledPreviewSkipped", 23); m_streamingFrameConsumed.store(true, std::memory_order_release); + completeCoordinatorPreviewTask(meta); return; } @@ -3655,6 +4050,7 @@ namespace gladius::ui m_lowResFeedbackPending.store(false, std::memory_order_release); m_lastLowResRenderTime = std::chrono::system_clock::now(); m_lastLowResPreviewEpoch.store(meta.epoch, std::memory_order_release); + completeCoordinatorPreviewTask(meta); if (scheduleAdaptivePreview) { @@ -3672,27 +4068,7 @@ namespace gladius::ui { auto const width = static_cast(image->getWidth()); auto const height = static_cast(image->getHeight()); - m_realtimeRaymarchController.resetForResolution(width, height); - - async_rendering::RealtimeRaymarchGuards const realtimeGuards{ - .hardBlocker = !m_enableHQRendering || - m_asyncSdfJobInFlight.load(std::memory_order_acquire) || - m_asyncBboxJobInFlight.load(std::memory_order_acquire) || - m_core->isAnyCompilationInProgressNonBlocking(), - .renderJobInFlight = m_asyncJobInFlight.load(std::memory_order_acquire), - .previewJobInFlight = m_asyncPreviewJobInFlight.load(std::memory_order_acquire), - .streamingActive = false, - .streamingJobInFlight = m_streamingJobInFlight.load(std::memory_order_acquire), - .resizePending = m_deferredResizePending}; - - if (m_realtimeRaymarchController.canAttemptRealtime(width, height, realtimeGuards)) - { - if (scheduleRealtimeRenderJob(m_renderWindowState)) - { - return; - } - m_realtimeRaymarchController.recordRejectedAttempt(); - } + queueRenderDecision(m_renderUpdateCoordinator.configureViewport(width, height)); if (m_asyncJobInFlight.load(std::memory_order_acquire)) { @@ -3715,7 +4091,11 @@ namespace gladius::ui void RenderWindow::stopStreamingPreview() { - m_streamingPreviewActive.store(false, std::memory_order_release); + bool const wasActive = m_streamingPreviewActive.exchange(false, std::memory_order_acq_rel); + if (wasActive) + { + queueRenderDecision(m_renderUpdateCoordinator.notifyParameterInteractionEnded()); + } } void RenderWindow::cancelAllAsyncWork() @@ -3744,7 +4124,8 @@ namespace gladius::ui return m_streamingPreviewActive.load(std::memory_order_acquire); } - bool RenderWindow::scheduleStreamingPreviewJob() + bool RenderWindow::scheduleStreamingPreviewJob( + async_rendering::RenderTaskRequest const * coordinatorTask) { if (!m_asyncController || !m_asyncController->isRunning()) { @@ -3775,6 +4156,7 @@ namespace gladius::ui m_asyncCurrentEpoch.store(job.epoch, std::memory_order_release); } m_asyncController->setLatestEpoch(job.epoch); + job.viewEpoch = m_asyncViewEpoch.load(std::memory_order_acquire); auto const [previewWidth, previewHeight] = m_core->getLowResPreviewResolution(); job.width = static_cast(previewWidth); @@ -3784,6 +4166,11 @@ namespace gladius::ui job.stepSize = static_cast(previewHeight); job.precomputeSdf = false; job.enableHighQuality = false; + if (coordinatorTask != nullptr) + { + job.coordinatorRequestId = coordinatorTask->requestId; + job.coordinatorStamp = coordinatorTask->stamp; + } m_streamingJobInFlight.store(true, std::memory_order_release); m_asyncPreviewJobInFlight.store(true, std::memory_order_release); @@ -3808,9 +4195,12 @@ namespace gladius::ui FrameResultMeta result{}; result.epoch = job.epoch; + result.viewEpoch = job.viewEpoch; result.jobType = job.type; result.width = job.width; result.height = job.height; + result.coordinatorRequestId = job.coordinatorRequestId; + result.coordinatorStamp = job.coordinatorStamp; auto const shouldStop = [&]() -> bool { return !m_streamingPreviewActive.load(std::memory_order_acquire) || @@ -3958,8 +4348,11 @@ namespace gladius::ui PreviewResultMeta previewMeta{}; previewMeta.frameId = frameCounter; previewMeta.epoch = job.epoch; + previewMeta.viewEpoch = job.viewEpoch; previewMeta.cancelled = false; previewMeta.sdfWasValid = m_core->isSdfValid(); + previewMeta.coordinatorRequestId = job.coordinatorRequestId; + previewMeta.coordinatorStamp = job.coordinatorStamp; auto const iterEnd = std::chrono::steady_clock::now(); previewMeta.latencyNs = static_cast( diff --git a/gladius/src/ui/RenderWindow.h b/gladius/src/ui/RenderWindow.h index 9b6f0cdb..f54bffb6 100644 --- a/gladius/src/ui/RenderWindow.h +++ b/gladius/src/ui/RenderWindow.h @@ -6,6 +6,7 @@ #include "compute/ComputeCore.h" #include "render/AsyncRenderController.h" #include "render/RealtimeRaymarchController.h" +#include "render/RenderUpdateCoordinator.h" #include #include #include @@ -170,8 +171,12 @@ namespace gladius::ui void renderSync(RenderWindowState & state); void renderAsync(RenderWindowState & state); void processAsyncResults(RenderWindowState & state); - bool scheduleAsyncRenderJob(RenderWindowState & state); - bool scheduleRealtimeRenderJob(RenderWindowState & state); + bool scheduleAsyncRenderJob( + RenderWindowState & state, + async_rendering::RenderTaskRequest const * coordinatorTask = nullptr); + bool scheduleRealtimeRenderJob( + RenderWindowState & state, + async_rendering::RenderTaskRequest const * coordinatorTask = nullptr); coro::task executeAsyncRenderJob( async_rendering::RenderJob const & job, async_rendering::AsyncRenderController::CancelCheck const & cancelCheck); @@ -180,12 +185,27 @@ namespace gladius::ui void adjustProgressFromDuration(RenderWindowState & state, uint64_t computeDurationNs); [[nodiscard]] async_rendering::RealtimeRaymarchConfig loadRealtimeRaymarchConfig() const; void saveRealtimeRaymarchMode(async_rendering::RealtimeRaymarchMode mode) const; + void queueRenderDecision(async_rendering::RenderUpdateDecision decision); + [[nodiscard]] bool executeQueuedRenderCommands(RenderWindowState & state); + [[nodiscard]] bool executeRenderCommand(async_rendering::RenderCommand const & command, + RenderWindowState & state); + [[nodiscard]] bool scheduleCoordinatorTask(async_rendering::RenderTaskRequest const & task, + RenderWindowState & state); + [[nodiscard]] bool isRealtimeRaymarchCameraInteraction() const noexcept; + [[nodiscard]] bool scheduleAsyncSdfPrecomputation( + async_rendering::RenderTaskRequest const * coordinatorTask = nullptr); + void completeCoordinatorTask(async_rendering::RenderTaskRequest const & request, + async_rendering::RenderTaskStatus status); + void completeCoordinatorTask(async_rendering::FrameResultMeta const & result, + bool producedDisplayFrame); + void completeCoordinatorPreviewTask(async_rendering::PreviewResultMeta const & result); [[nodiscard]] bool isAsyncBackendActive() const noexcept; [[nodiscard]] std::optional tryFetchBoundingBox(bool requestAsyncUpdate); // Async bounding box computation - void scheduleAsyncBboxUpdate(); + void scheduleAsyncBboxUpdate( + async_rendering::RenderTaskRequest const * coordinatorTask = nullptr); coro::task executeAsyncBboxUpdate( async_rendering::RenderJob const & job, async_rendering::AsyncRenderController::CancelCheck const & cancelCheck); @@ -201,14 +221,16 @@ namespace gladius::ui async_rendering::AsyncRenderController::CancelCheck const & cancelCheck); // Async preview rendering (non-blocking low-res preview during camera movement) - bool scheduleAsyncPreviewJob(); + bool scheduleAsyncPreviewJob( + async_rendering::RenderTaskRequest const * coordinatorTask = nullptr); coro::task executeAsyncPreviewJob( async_rendering::RenderJob const & job, async_rendering::AsyncRenderController::CancelCheck const & cancelCheck); void processAsyncPreviewResults(); // Streaming preview loop (tight render loop during parameter drag) - bool scheduleStreamingPreviewJob(); + bool scheduleStreamingPreviewJob( + async_rendering::RenderTaskRequest const * coordinatorTask = nullptr); coro::task executeStreamingPreviewJob( async_rendering::RenderJob const & job, async_rendering::AsyncRenderController::CancelCheck const & cancelCheck); @@ -321,7 +343,8 @@ namespace gladius::ui void onCameraManuallyMoved(); async_rendering::AsyncRenderFeatureConfig m_asyncConfig{}; - async_rendering::RealtimeRaymarchController m_realtimeRaymarchController{}; + async_rendering::RenderUpdateCoordinator m_renderUpdateCoordinator{}; + std::vector m_pendingRenderCommands; std::shared_ptr m_asyncController; std::atomic m_asyncEpochCounter{0}; std::atomic m_asyncCurrentEpoch{0}; @@ -330,6 +353,7 @@ namespace gladius::ui std::atomic m_asyncInFlightViewEpoch{0}; std::atomic m_asyncFrameCounter{0}; std::atomic m_asyncJobInFlight{false}; + std::atomic m_asyncRealtimeJobInFlight{false}; std::atomic m_asyncBboxJobInFlight{false}; std::atomic m_asyncBboxUpdatePending{ false}; // Tracks if bbox needs update after current job diff --git a/gladius/src/ui/render/AsyncRenderTypes.h b/gladius/src/ui/render/AsyncRenderTypes.h index fd4f94e9..1b255a39 100644 --- a/gladius/src/ui/render/AsyncRenderTypes.h +++ b/gladius/src/ui/render/AsyncRenderTypes.h @@ -1,5 +1,7 @@ #pragma once +#include "RenderUpdateTypes.h" + #include #include #include @@ -69,6 +71,8 @@ namespace gladius::ui::async_rendering bool precomputeSdf{false}; bool enableHighQuality{true}; bool precomputedSdf{false}; // True if SDF should be precomputed before rendering + uint64_t coordinatorRequestId{0}; + RenderStamp coordinatorStamp{}; }; /** @@ -91,6 +95,8 @@ namespace gladius::ui::async_rendering float compilationProgress{0.0f}; // 0.0 - 1.0 for compilation jobs bool compilationSucceeded{false}; std::string compilationError; // Empty if no error + uint64_t coordinatorRequestId{0}; + RenderStamp coordinatorStamp{}; }; /** @@ -116,9 +122,12 @@ namespace gladius::ui::async_rendering { uint64_t frameId{0}; ///< Matches PreviewRenderJob::frameId uint64_t epoch{0}; ///< Matches PreviewRenderJob::epoch + uint64_t viewEpoch{0}; ///< Camera/view generation captured when the preview was queued uint64_t latencyNs{0}; ///< Time from job enqueue to completion (nanoseconds) bool cancelled{false}; ///< True if job was cancelled due to epoch change bool sdfWasValid{false}; ///< True if SDF was available during rendering + uint64_t coordinatorRequestId{0}; + RenderStamp coordinatorStamp{}; }; /** diff --git a/gladius/src/ui/render/RenderUpdateCoordinator.h b/gladius/src/ui/render/RenderUpdateCoordinator.h new file mode 100644 index 00000000..f603dcfd --- /dev/null +++ b/gladius/src/ui/render/RenderUpdateCoordinator.h @@ -0,0 +1,475 @@ +#pragma once + +#include "RealtimeRaymarchController.h" +#include "RenderUpdateTypes.h" + +#include +#include +#include +#include + +namespace gladius::ui::async_rendering +{ + enum class RenderInteractionState + { + Static, + CameraInteracting, + ParameterInteracting + }; + + enum class RenderCommandType + { + StartTask, + PresentFrame, + DiscardTaskResult, + KeepCurrentFrame + }; + + struct RenderCommand + { + RenderCommandType type{RenderCommandType::KeepCurrentFrame}; + RenderTaskRequest task{}; + RenderTaskResult result{}; + RenderStamp stamp{}; + }; + + struct RenderUpdateDecision + { + std::vector commands; + }; + + /** + * @brief Pure render/update policy that converts semantic UI/render events into task commands. + * + * The coordinator intentionally has no ImGui, OpenGL, OpenCL, GLImageBuffer, or ComputeCore + * dependency. Production code should translate these semantic commands into concrete work, + * while unit tests can drive the full epoch/backpressure/stale-result behavior directly. + */ + class RenderUpdateCoordinator + { + public: + RenderUpdateCoordinator() + { + m_realtime.resetForResolution(m_width, m_height); + } + + void configureRealtime(RealtimeRaymarchConfig config) + { + m_realtime.configure(config); + m_realtime.resetForResolution(m_width, m_height); + } + + [[nodiscard]] RealtimeRaymarchConfig const & realtimeConfig() const noexcept + { + return m_realtime.config(); + } + + [[nodiscard]] bool isRealtimeActive() const noexcept + { + return m_realtime.isRealtimeActive(); + } + + void setRealtimeGuards(RealtimeRaymarchGuards guards) noexcept + { + m_realtimeGuards = guards; + } + + void recordRealtimeSample(RealtimeRaymarchSample const & sample) + { + m_realtime.recordSample(sample); + } + + void recordRealtimeRejectedAttempt() + { + m_realtime.recordRejectedAttempt(); + } + + [[nodiscard]] RenderStamp latestStamp() const noexcept + { + return m_latestStamp; + } + + [[nodiscard]] RenderInteractionState interactionState() const noexcept + { + return m_interactionState; + } + + [[nodiscard]] bool isBoundingBoxCurrent() const noexcept + { + return m_boundingBoxStamp.has_value() && + matches(*m_boundingBoxStamp, m_latestStamp, RenderStampMask::heavyGeometryTask()); + } + + [[nodiscard]] bool isSdfCurrent() const noexcept + { + return m_sdfStamp.has_value() && + matches(*m_sdfStamp, m_latestStamp, RenderStampMask::heavyGeometryTask()); + } + + [[nodiscard]] bool isHeavyGeometryCurrent() const noexcept + { + return isBoundingBoxCurrent() && isSdfCurrent(); + } + + [[nodiscard]] RenderUpdateDecision configureViewport(uint32_t width, uint32_t height) + { + RenderUpdateDecision decision{}; + if (width == 0 || height == 0 || (width == m_width && height == m_height)) + { + return decision; + } + + m_width = width; + m_height = height; + ++m_latestStamp.viewportEpoch; + m_realtime.resetForResolution(m_width, m_height); + scheduleStaticCatchUp(decision); + return decision; + } + + [[nodiscard]] RenderUpdateDecision notifyCameraChanged() + { + RenderUpdateDecision decision{}; + ++m_latestStamp.viewEpoch; + m_interactionState = RenderInteractionState::CameraInteracting; + scheduleInteractiveFrame(decision); + return decision; + } + + [[nodiscard]] RenderUpdateDecision notifyCameraInteractionEnded() + { + RenderUpdateDecision decision{}; + if (m_interactionState == RenderInteractionState::CameraInteracting) + { + m_interactionState = RenderInteractionState::Static; + } + scheduleStaticCatchUp(decision); + return decision; + } + + [[nodiscard]] RenderUpdateDecision notifyParameterChanged(bool interactionActive) + { + RenderUpdateDecision decision{}; + ++m_latestStamp.parameterEpoch; + m_parameterUploadStamp.reset(); + m_boundingBoxStamp.reset(); + m_sdfStamp.reset(); + m_interactionState = interactionActive ? RenderInteractionState::ParameterInteracting + : RenderInteractionState::Static; + + startTask(decision, RenderTaskType::ParameterUpload, m_latestStamp); + if (interactionActive) + { + scheduleInteractiveFrame(decision); + } + else + { + scheduleStaticCatchUp(decision); + } + return decision; + } + + [[nodiscard]] RenderUpdateDecision notifyParameterInteractionEnded() + { + RenderUpdateDecision decision{}; + m_interactionState = RenderInteractionState::Static; + scheduleStaticCatchUp(decision); + return decision; + } + + [[nodiscard]] RenderUpdateDecision notifyStructuralModelChanged() + { + RenderUpdateDecision decision{}; + ++m_latestStamp.sceneEpoch; + ++m_latestStamp.parameterEpoch; + m_programStamp.reset(); + m_parameterUploadStamp.reset(); + m_boundingBoxStamp.reset(); + m_sdfStamp.reset(); + m_interactionState = RenderInteractionState::Static; + scheduleStaticCatchUp(decision); + return decision; + } + + [[nodiscard]] RenderUpdateDecision notifyProgramCompilationCompleted() + { + return completeTask(RenderTaskResult{.type = RenderTaskType::ProgramCompilation, + .stamp = m_latestStamp, + .status = RenderTaskStatus::Completed}); + } + + [[nodiscard]] RenderUpdateDecision tick() + { + RenderUpdateDecision decision{}; + m_realtime.beginFrame(); + if (m_interactionState == RenderInteractionState::Static) + { + scheduleStaticCatchUp(decision); + } + else + { + if (m_interactionState == RenderInteractionState::ParameterInteracting && + !areParametersUploaded()) + { + startTask(decision, RenderTaskType::ParameterUpload, m_latestStamp); + } + scheduleInteractiveFrame(decision); + } + return decision; + } + + [[nodiscard]] RenderUpdateDecision completeTask(RenderTaskResult const & result) + { + RenderUpdateDecision decision{}; + removeInFlight(result); + + auto const mask = freshnessMaskFor(result.type); + if (!result.isCurrentFor(m_latestStamp, mask)) + { + decision.commands.push_back(RenderCommand{.type = RenderCommandType::DiscardTaskResult, + .result = result, + .stamp = m_latestStamp}); + return decision; + } + + switch (result.type) + { + case RenderTaskType::ProgramCompilation: + m_programStamp = result.stamp; + break; + case RenderTaskType::ParameterUpload: + m_parameterUploadStamp = result.stamp; + break; + case RenderTaskType::BoundingBoxUpdate: + m_boundingBoxStamp = result.stamp; + break; + case RenderTaskType::SdfPrecomputation: + m_sdfStamp = result.stamp; + break; + case RenderTaskType::RealtimeFullFrame: + case RenderTaskType::ProgressiveHighQualityChunk: + case RenderTaskType::LowResolutionPreview: + case RenderTaskType::StreamingPreview: + if (result.producedDisplayFrame) + { + if (result.completedFrame && + (result.type == RenderTaskType::ProgressiveHighQualityChunk || + result.type == RenderTaskType::RealtimeFullFrame)) + { + m_highQualityFrameStamp = result.stamp; + } + decision.commands.push_back(RenderCommand{.type = RenderCommandType::PresentFrame, + .result = result, + .stamp = result.stamp}); + } + break; + } + + if (m_interactionState == RenderInteractionState::Static) + { + scheduleStaticCatchUp(decision); + } + return decision; + } + + private: + struct InFlightTask + { + uint64_t requestId{0}; + RenderTaskType type{RenderTaskType::ProgressiveHighQualityChunk}; + RenderStamp stamp{}; + }; + + [[nodiscard]] static constexpr RenderStampMask freshnessMaskFor(RenderTaskType type) noexcept + { + switch (type) + { + case RenderTaskType::ProgramCompilation: + return RenderStampMask::sceneOnly(); + case RenderTaskType::ParameterUpload: + case RenderTaskType::BoundingBoxUpdate: + case RenderTaskType::SdfPrecomputation: + return RenderStampMask::heavyGeometryTask(); + case RenderTaskType::RealtimeFullFrame: + case RenderTaskType::ProgressiveHighQualityChunk: + case RenderTaskType::LowResolutionPreview: + case RenderTaskType::StreamingPreview: + default: + return RenderStampMask::displayFrame(); + } + } + + [[nodiscard]] bool hasAnyInFlight(RenderTaskType type) const noexcept + { + return std::any_of(m_inFlight.begin(), + m_inFlight.end(), + [type](InFlightTask const & task) { return task.type == type; }); + } + + [[nodiscard]] bool hasInteractiveFrameInFlight() const noexcept + { + return hasAnyInFlight(RenderTaskType::RealtimeFullFrame) || + hasAnyInFlight(RenderTaskType::LowResolutionPreview) || + hasAnyInFlight(RenderTaskType::StreamingPreview); + } + + [[nodiscard]] bool hasEquivalentInFlight(RenderTaskType type, RenderStamp const & stamp) const noexcept + { + auto const mask = freshnessMaskFor(type); + return std::any_of(m_inFlight.begin(), + m_inFlight.end(), + [type, stamp, mask](InFlightTask const & task) + { return task.type == type && matches(task.stamp, stamp, mask); }); + } + + [[nodiscard]] bool isProgramCurrent() const noexcept + { + return m_programStamp.has_value() && + matches(*m_programStamp, m_latestStamp, RenderStampMask::sceneOnly()); + } + + [[nodiscard]] bool areParametersUploaded() const noexcept + { + return m_parameterUploadStamp.has_value() && + matches(*m_parameterUploadStamp, m_latestStamp, RenderStampMask::heavyGeometryTask()); + } + + [[nodiscard]] bool isHighQualityFrameCurrent() const noexcept + { + return m_highQualityFrameStamp.has_value() && + matches(*m_highQualityFrameStamp, m_latestStamp, RenderStampMask::displayFrame()); + } + + void startTask(RenderUpdateDecision & decision, RenderTaskType type, RenderStamp const & stamp) + { + if (hasAnyInFlight(type) || hasEquivalentInFlight(type, stamp)) + { + keepCurrentFrame(decision); + return; + } + + RenderTaskRequest request{.requestId = m_nextRequestId++, + .type = type, + .stamp = stamp, + .width = m_width, + .height = m_height, + .startLine = 0, + .lineCount = m_height}; + m_inFlight.push_back(InFlightTask{.requestId = request.requestId, + .type = request.type, + .stamp = request.stamp}); + decision.commands.push_back(RenderCommand{.type = RenderCommandType::StartTask, + .task = request, + .stamp = stamp}); + } + + void keepCurrentFrame(RenderUpdateDecision & decision) const + { + decision.commands.push_back(RenderCommand{.type = RenderCommandType::KeepCurrentFrame, + .stamp = m_latestStamp}); + } + + void scheduleInteractiveFrame(RenderUpdateDecision & decision) + { + if (hasInteractiveFrameInFlight()) + { + keepCurrentFrame(decision); + return; + } + + if (m_interactionState == RenderInteractionState::ParameterInteracting) + { + startTask(decision, RenderTaskType::LowResolutionPreview, m_latestStamp); + return; + } + + if (m_realtime.isRealtimeActive()) + { + if (m_realtime.canAttemptRealtime(m_width, m_height, m_realtimeGuards)) + { + startTask(decision, RenderTaskType::RealtimeFullFrame, m_latestStamp); + return; + } + + keepCurrentFrame(decision); + return; + } + + if (m_realtime.canAttemptRealtime(m_width, m_height, m_realtimeGuards)) + { + startTask(decision, RenderTaskType::RealtimeFullFrame, m_latestStamp); + return; + } + + startTask(decision, RenderTaskType::LowResolutionPreview, m_latestStamp); + } + + void scheduleStaticCatchUp(RenderUpdateDecision & decision) + { + if (!isProgramCurrent()) + { + startTask(decision, RenderTaskType::ProgramCompilation, m_latestStamp); + return; + } + if (!areParametersUploaded()) + { + startTask(decision, RenderTaskType::ParameterUpload, m_latestStamp); + return; + } + if (!isBoundingBoxCurrent()) + { + startTask(decision, RenderTaskType::BoundingBoxUpdate, m_latestStamp); + return; + } + if (!isSdfCurrent()) + { + startTask(decision, RenderTaskType::SdfPrecomputation, m_latestStamp); + return; + } + if (isHighQualityFrameCurrent()) + { + keepCurrentFrame(decision); + return; + } + startTask(decision, RenderTaskType::ProgressiveHighQualityChunk, m_latestStamp); + } + + void removeInFlight(RenderTaskResult const & result) + { + auto const byRequestId = [result](InFlightTask const & task) + { return result.requestId != 0 && task.requestId == result.requestId; }; + auto const byTypeAndStamp = [result](InFlightTask const & task) + { + return task.type == result.type && + matches(task.stamp, result.stamp, freshnessMaskFor(result.type)); + }; + auto const byTypeWithoutRequestId = [result](InFlightTask const & task) + { return result.requestId == 0 && task.type == result.type; }; + + auto const oldEnd = m_inFlight.end(); + auto const newEnd = std::remove_if(m_inFlight.begin(), + m_inFlight.end(), + [byRequestId, byTypeAndStamp, byTypeWithoutRequestId]( + InFlightTask const & task) + { + return byRequestId(task) || byTypeAndStamp(task) || + byTypeWithoutRequestId(task); + }); + m_inFlight.erase(newEnd, oldEnd); + } + + RenderStamp m_latestStamp{}; + std::optional m_programStamp{m_latestStamp}; + std::optional m_parameterUploadStamp{m_latestStamp}; + std::optional m_boundingBoxStamp{}; + std::optional m_sdfStamp{}; + std::optional m_highQualityFrameStamp{}; + std::vector m_inFlight; + RealtimeRaymarchController m_realtime; + RealtimeRaymarchGuards m_realtimeGuards{}; + RenderInteractionState m_interactionState{RenderInteractionState::Static}; + uint64_t m_nextRequestId{1}; + uint32_t m_width{1}; + uint32_t m_height{1}; + }; +} diff --git a/gladius/tests/unittests/ui/render/RenderUpdateCoordinator_tests.cpp b/gladius/tests/unittests/ui/render/RenderUpdateCoordinator_tests.cpp new file mode 100644 index 00000000..9cb3e48a --- /dev/null +++ b/gladius/tests/unittests/ui/render/RenderUpdateCoordinator_tests.cpp @@ -0,0 +1,498 @@ +#include "ui/render/RenderUpdateCoordinator.h" + +#include + +#include + +namespace gladius::ui::async_rendering::tests +{ + namespace + { + [[nodiscard]] std::optional findStartedTask(RenderUpdateDecision const & decision, + RenderTaskType type) + { + for (auto const & command : decision.commands) + { + if (command.type == RenderCommandType::StartTask && command.task.type == type) + { + return command.task; + } + } + return std::nullopt; + } + + [[nodiscard]] bool hasCommand(RenderUpdateDecision const & decision, RenderCommandType type) + { + for (auto const & command : decision.commands) + { + if (command.type == type) + { + return true; + } + } + return false; + } + + [[nodiscard]] bool hasStartedTask(RenderUpdateDecision const & decision, RenderTaskType type) + { + return findStartedTask(decision, type).has_value(); + } + + [[nodiscard]] RenderTaskResult completed(RenderTaskRequest const & request, + bool producedDisplayFrame = false, + bool completedFrame = false) + { + return RenderTaskResult{.requestId = request.requestId, + .type = request.type, + .stamp = request.stamp, + .status = RenderTaskStatus::Completed, + .durationNs = 1'000'000, + .producedDisplayFrame = producedDisplayFrame, + .completedFrame = completedFrame}; + } + + [[nodiscard]] RenderTaskResult failed(RenderTaskRequest const & request) + { + auto result = completed(request); + result.status = RenderTaskStatus::Failed; + return result; + } + + [[nodiscard]] RealtimeRaymarchConfig forcedRealtimeConfig() + { + RealtimeRaymarchConfig config{}; + config.mode = RealtimeRaymarchMode::Force; + return config; + } + + [[nodiscard]] RealtimeRaymarchSample fullFrameSample(float durationMs) + { + return RealtimeRaymarchSample{.durationMs = durationMs, + .width = 640, + .height = 480, + .renderedLines = 480, + .totalLines = 480, + .completedFrame = true, + .cancelled = false}; + } + } + + TEST(RenderUpdateCoordinator, CameraChanged_IncrementsOnlyViewEpochAndStartsPreview) + { + RenderUpdateCoordinator coordinator; + auto const before = coordinator.latestStamp(); + + auto const decision = coordinator.notifyCameraChanged(); + auto const after = coordinator.latestStamp(); + + EXPECT_EQ(after.sceneEpoch, before.sceneEpoch); + EXPECT_EQ(after.parameterEpoch, before.parameterEpoch); + EXPECT_EQ(after.viewportEpoch, before.viewportEpoch); + EXPECT_EQ(after.viewEpoch, before.viewEpoch + 1u); + EXPECT_EQ(coordinator.interactionState(), RenderInteractionState::CameraInteracting); + EXPECT_TRUE(hasStartedTask(decision, RenderTaskType::LowResolutionPreview)); + EXPECT_FALSE(hasStartedTask(decision, RenderTaskType::BoundingBoxUpdate)); + EXPECT_FALSE(hasStartedTask(decision, RenderTaskType::SdfPrecomputation)); + } + + TEST(RenderUpdateCoordinator, CameraChanged_WithForcedRealtime_StartsRealtimeFullFrame) + { + RenderUpdateCoordinator coordinator; + coordinator.configureRealtime(forcedRealtimeConfig()); + ASSERT_FALSE(coordinator.configureViewport(640, 480).commands.empty()); + + auto const decision = coordinator.notifyCameraChanged(); + + EXPECT_TRUE(hasStartedTask(decision, RenderTaskType::RealtimeFullFrame)); + EXPECT_FALSE(hasStartedTask(decision, RenderTaskType::LowResolutionPreview)); + } + + TEST(RenderUpdateCoordinator, CameraChanged_WithForcedRealtimeInFlight_KeepsCurrentFrameWithoutPreview) + { + RenderUpdateCoordinator coordinator; + coordinator.configureRealtime(forcedRealtimeConfig()); + ASSERT_FALSE(coordinator.configureViewport(640, 480).commands.empty()); + + auto decision = coordinator.notifyCameraChanged(); + ASSERT_TRUE(hasStartedTask(decision, RenderTaskType::RealtimeFullFrame)); + + decision = coordinator.notifyCameraChanged(); + + EXPECT_FALSE(hasStartedTask(decision, RenderTaskType::LowResolutionPreview)); + EXPECT_FALSE(hasStartedTask(decision, RenderTaskType::RealtimeFullFrame)); + EXPECT_TRUE(hasCommand(decision, RenderCommandType::KeepCurrentFrame)); + } + + TEST(RenderUpdateCoordinator, CameraChanged_WithAutoRealtimeAfterFastSamples_StartsRealtimeFullFrame) + { + RenderUpdateCoordinator coordinator; + ASSERT_FALSE(coordinator.configureViewport(640, 480).commands.empty()); + coordinator.recordRealtimeSample(fullFrameSample(15.0f)); + coordinator.recordRealtimeSample(fullFrameSample(16.0f)); + coordinator.recordRealtimeSample(fullFrameSample(17.0f)); + + auto const decision = coordinator.notifyCameraChanged(); + + EXPECT_TRUE(hasStartedTask(decision, RenderTaskType::RealtimeFullFrame)); + EXPECT_FALSE(hasStartedTask(decision, RenderTaskType::LowResolutionPreview)); + } + + TEST(RenderUpdateCoordinator, CameraChanged_WithAutoRealtimeInFlight_KeepsCurrentFrameWithoutPreview) + { + RenderUpdateCoordinator coordinator; + ASSERT_FALSE(coordinator.configureViewport(640, 480).commands.empty()); + coordinator.recordRealtimeSample(fullFrameSample(15.0f)); + coordinator.recordRealtimeSample(fullFrameSample(16.0f)); + coordinator.recordRealtimeSample(fullFrameSample(17.0f)); + + auto decision = coordinator.notifyCameraChanged(); + ASSERT_TRUE(hasStartedTask(decision, RenderTaskType::RealtimeFullFrame)); + + decision = coordinator.notifyCameraChanged(); + + EXPECT_FALSE(hasStartedTask(decision, RenderTaskType::RealtimeFullFrame)); + EXPECT_FALSE(hasStartedTask(decision, RenderTaskType::LowResolutionPreview)); + EXPECT_TRUE(hasCommand(decision, RenderCommandType::KeepCurrentFrame)); + } + + TEST(RenderUpdateCoordinator, CameraChanged_WithAutoRealtimeGuardBlocker_KeepsCurrentFrame) + { + RenderUpdateCoordinator coordinator; + ASSERT_FALSE(coordinator.configureViewport(640, 480).commands.empty()); + coordinator.recordRealtimeSample(fullFrameSample(15.0f)); + coordinator.recordRealtimeSample(fullFrameSample(16.0f)); + coordinator.recordRealtimeSample(fullFrameSample(17.0f)); + async_rendering::RealtimeRaymarchGuards guards{}; + guards.hardBlocker = true; + coordinator.setRealtimeGuards(guards); + + auto const decision = coordinator.notifyCameraChanged(); + + EXPECT_FALSE(hasStartedTask(decision, RenderTaskType::RealtimeFullFrame)); + EXPECT_FALSE(hasStartedTask(decision, RenderTaskType::LowResolutionPreview)); + EXPECT_TRUE(hasCommand(decision, RenderCommandType::KeepCurrentFrame)); + } + + TEST(RenderUpdateCoordinator, CameraInteractionEnded_WithAutoRealtimeCurrentHq_KeepsCurrentFrame) + { + RenderUpdateCoordinator coordinator; + ASSERT_FALSE(coordinator.configureViewport(640, 480).commands.empty()); + coordinator.recordRealtimeSample(fullFrameSample(15.0f)); + coordinator.recordRealtimeSample(fullFrameSample(16.0f)); + coordinator.recordRealtimeSample(fullFrameSample(17.0f)); + + auto decision = coordinator.notifyCameraChanged(); + auto realtime = findStartedTask(decision, RenderTaskType::RealtimeFullFrame); + ASSERT_TRUE(realtime.has_value()); + + decision = coordinator.completeTask(completed(*realtime, true, true)); + EXPECT_TRUE(hasCommand(decision, RenderCommandType::PresentFrame)); + + decision = coordinator.notifyCameraInteractionEnded(); + + EXPECT_FALSE(hasStartedTask(decision, RenderTaskType::LowResolutionPreview)); + EXPECT_FALSE(hasStartedTask(decision, RenderTaskType::ProgressiveHighQualityChunk)); + EXPECT_TRUE(hasCommand(decision, RenderCommandType::KeepCurrentFrame)); + } + + TEST(RenderUpdateCoordinator, CameraInteractionEnded_WithStaleAutoRealtime_StartsHqNotPreview) + { + RenderUpdateCoordinator coordinator; + ASSERT_FALSE(coordinator.configureViewport(640, 480).commands.empty()); + coordinator.recordRealtimeSample(fullFrameSample(15.0f)); + coordinator.recordRealtimeSample(fullFrameSample(16.0f)); + coordinator.recordRealtimeSample(fullFrameSample(17.0f)); + + auto decision = coordinator.notifyCameraChanged(); + auto realtime = findStartedTask(decision, RenderTaskType::RealtimeFullFrame); + ASSERT_TRUE(realtime.has_value()); + + decision = coordinator.notifyCameraChanged(); + EXPECT_TRUE(hasCommand(decision, RenderCommandType::KeepCurrentFrame)); + + decision = coordinator.completeTask(completed(*realtime, true, true)); + EXPECT_TRUE(hasCommand(decision, RenderCommandType::DiscardTaskResult)); + + decision = coordinator.notifyCameraInteractionEnded(); + + EXPECT_FALSE(hasStartedTask(decision, RenderTaskType::LowResolutionPreview)); + } + + TEST(RenderUpdateCoordinator, CameraChanged_WithForcedRealtimeAndCurrentHq_EndsInteractionWithoutExtraHq) + { + RenderUpdateCoordinator coordinator; + coordinator.configureRealtime(forcedRealtimeConfig()); + ASSERT_FALSE(coordinator.configureViewport(640, 480).commands.empty()); + + auto decision = coordinator.notifyCameraChanged(); + auto realtime = findStartedTask(decision, RenderTaskType::RealtimeFullFrame); + ASSERT_TRUE(realtime.has_value()); + + decision = coordinator.completeTask(completed(*realtime, true, true)); + EXPECT_TRUE(hasCommand(decision, RenderCommandType::PresentFrame)); + + decision = coordinator.notifyCameraInteractionEnded(); + + EXPECT_FALSE(hasStartedTask(decision, RenderTaskType::ProgressiveHighQualityChunk)); + EXPECT_TRUE(hasCommand(decision, RenderCommandType::KeepCurrentFrame)); + } + + TEST(RenderUpdateCoordinator, CameraChanged_WithForcedRealtimeGuardBlocker_KeepsCurrentFrame) + { + RenderUpdateCoordinator coordinator; + coordinator.configureRealtime(forcedRealtimeConfig()); + async_rendering::RealtimeRaymarchGuards guards{}; + guards.hardBlocker = true; + coordinator.setRealtimeGuards(guards); + ASSERT_FALSE(coordinator.configureViewport(640, 480).commands.empty()); + + auto const decision = coordinator.notifyCameraChanged(); + + EXPECT_FALSE(hasStartedTask(decision, RenderTaskType::RealtimeFullFrame)); + EXPECT_FALSE(hasStartedTask(decision, RenderTaskType::LowResolutionPreview)); + EXPECT_TRUE(hasCommand(decision, RenderCommandType::KeepCurrentFrame)); + } + + TEST(RenderUpdateCoordinator, CameraChanged_WithResizePendingEvenForcedRealtime_KeepsCurrentFrame) + { + RenderUpdateCoordinator coordinator; + coordinator.configureRealtime(forcedRealtimeConfig()); + async_rendering::RealtimeRaymarchGuards guards{}; + guards.resizePending = true; + coordinator.setRealtimeGuards(guards); + ASSERT_FALSE(coordinator.configureViewport(640, 480).commands.empty()); + + auto const decision = coordinator.notifyCameraChanged(); + + EXPECT_FALSE(hasStartedTask(decision, RenderTaskType::RealtimeFullFrame)); + EXPECT_FALSE(hasStartedTask(decision, RenderTaskType::LowResolutionPreview)); + EXPECT_TRUE(hasCommand(decision, RenderCommandType::KeepCurrentFrame)); + } + + TEST(RenderUpdateCoordinator, FailedForcedRealtime_DoesNotFallbackToPreviewWhileGuarded) + { + RenderUpdateCoordinator coordinator; + coordinator.configureRealtime(forcedRealtimeConfig()); + ASSERT_FALSE(coordinator.configureViewport(640, 480).commands.empty()); + + auto decision = coordinator.notifyCameraChanged(); + auto realtime = findStartedTask(decision, RenderTaskType::RealtimeFullFrame); + ASSERT_TRUE(realtime.has_value()); + + decision = coordinator.completeTask(failed(*realtime)); + EXPECT_TRUE(hasCommand(decision, RenderCommandType::DiscardTaskResult)); + + async_rendering::RealtimeRaymarchGuards guards{}; + guards.hardBlocker = true; + coordinator.setRealtimeGuards(guards); + decision = coordinator.tick(); + + EXPECT_FALSE(hasStartedTask(decision, RenderTaskType::RealtimeFullFrame)); + EXPECT_FALSE(hasStartedTask(decision, RenderTaskType::LowResolutionPreview)); + EXPECT_TRUE(hasCommand(decision, RenderCommandType::KeepCurrentFrame)); + } + + TEST(RenderUpdateCoordinator, ParameterDrag_WithForcedRealtime_UsesPreviewNotRealtime) + { + RenderUpdateCoordinator coordinator; + coordinator.configureRealtime(forcedRealtimeConfig()); + ASSERT_FALSE(coordinator.configureViewport(640, 480).commands.empty()); + + auto const decision = coordinator.notifyParameterChanged(true); + + EXPECT_TRUE(hasStartedTask(decision, RenderTaskType::ParameterUpload)); + EXPECT_TRUE(hasStartedTask(decision, RenderTaskType::LowResolutionPreview)); + EXPECT_FALSE(hasStartedTask(decision, RenderTaskType::RealtimeFullFrame)); + } + + TEST(RenderUpdateCoordinator, ParameterDrag_StartsUploadAndPreviewButDefersHeavyTasks) + { + RenderUpdateCoordinator coordinator; + auto const before = coordinator.latestStamp(); + + auto const decision = coordinator.notifyParameterChanged(true); + auto const after = coordinator.latestStamp(); + + EXPECT_EQ(after.sceneEpoch, before.sceneEpoch); + EXPECT_EQ(after.parameterEpoch, before.parameterEpoch + 1u); + EXPECT_EQ(coordinator.interactionState(), RenderInteractionState::ParameterInteracting); + EXPECT_TRUE(hasStartedTask(decision, RenderTaskType::ParameterUpload)); + EXPECT_TRUE(hasStartedTask(decision, RenderTaskType::LowResolutionPreview)); + EXPECT_FALSE(hasStartedTask(decision, RenderTaskType::BoundingBoxUpdate)); + EXPECT_FALSE(hasStartedTask(decision, RenderTaskType::SdfPrecomputation)); + } + + TEST(RenderUpdateCoordinator, ParameterDrag_WithUploadInFlight_CoalescesToLatestUpload) + { + RenderUpdateCoordinator coordinator; + auto decision = coordinator.notifyParameterChanged(true); + auto firstUpload = findStartedTask(decision, RenderTaskType::ParameterUpload); + ASSERT_TRUE(firstUpload.has_value()); + + decision = coordinator.notifyParameterChanged(true); + auto const latest = coordinator.latestStamp(); + + EXPECT_FALSE(hasStartedTask(decision, RenderTaskType::ParameterUpload)); + EXPECT_TRUE(hasCommand(decision, RenderCommandType::KeepCurrentFrame)); + + decision = coordinator.completeTask(completed(*firstUpload)); + EXPECT_TRUE(hasCommand(decision, RenderCommandType::DiscardTaskResult)); + + decision = coordinator.tick(); + auto latestUpload = findStartedTask(decision, RenderTaskType::ParameterUpload); + ASSERT_TRUE(latestUpload.has_value()); + EXPECT_TRUE(matches(latestUpload->stamp, latest, RenderStampMask::heavyGeometryTask())); + } + + TEST(RenderUpdateCoordinator, StaticCatchUp_AfterParameterEdit_SequencesUploadBboxSdfThenHq) + { + RenderUpdateCoordinator coordinator; + auto decision = coordinator.notifyParameterChanged(true); + auto upload = findStartedTask(decision, RenderTaskType::ParameterUpload); + ASSERT_TRUE(upload.has_value()); + + decision = coordinator.completeTask(completed(*upload)); + EXPECT_FALSE(hasStartedTask(decision, RenderTaskType::BoundingBoxUpdate)); + + decision = coordinator.notifyParameterInteractionEnded(); + auto bbox = findStartedTask(decision, RenderTaskType::BoundingBoxUpdate); + ASSERT_TRUE(bbox.has_value()); + EXPECT_FALSE(hasStartedTask(decision, RenderTaskType::ProgressiveHighQualityChunk)); + + decision = coordinator.completeTask(completed(*bbox)); + auto sdf = findStartedTask(decision, RenderTaskType::SdfPrecomputation); + ASSERT_TRUE(sdf.has_value()); + EXPECT_TRUE(coordinator.isBoundingBoxCurrent()); + EXPECT_FALSE(coordinator.isHeavyGeometryCurrent()); + + decision = coordinator.completeTask(completed(*sdf)); + auto hq = findStartedTask(decision, RenderTaskType::ProgressiveHighQualityChunk); + ASSERT_TRUE(hq.has_value()); + EXPECT_TRUE(coordinator.isHeavyGeometryCurrent()); + EXPECT_TRUE(matches(hq->stamp, coordinator.latestStamp(), RenderStampMask::displayFrame())); + } + + TEST(RenderUpdateCoordinator, CameraBackpressure_KeepsCurrentFrameUntilOldPreviewCompletes) + { + RenderUpdateCoordinator coordinator; + auto decision = coordinator.notifyCameraChanged(); + auto preview = findStartedTask(decision, RenderTaskType::LowResolutionPreview); + ASSERT_TRUE(preview.has_value()); + + decision = coordinator.notifyCameraChanged(); + + EXPECT_FALSE(hasStartedTask(decision, RenderTaskType::LowResolutionPreview)); + EXPECT_TRUE(hasCommand(decision, RenderCommandType::KeepCurrentFrame)); + + decision = coordinator.completeTask(completed(*preview, true, true)); + EXPECT_TRUE(hasCommand(decision, RenderCommandType::DiscardTaskResult)); + + decision = coordinator.tick(); + auto latestPreview = findStartedTask(decision, RenderTaskType::LowResolutionPreview); + ASSERT_TRUE(latestPreview.has_value()); + EXPECT_TRUE(matches(latestPreview->stamp, coordinator.latestStamp(), RenderStampMask::displayFrame())); + } + + TEST(RenderUpdateCoordinator, CameraInteractionEnded_WithCurrentHeavyGeometry_StartsHqForLatestView) + { + RenderUpdateCoordinator coordinator; + auto decision = coordinator.tick(); + auto bbox = findStartedTask(decision, RenderTaskType::BoundingBoxUpdate); + ASSERT_TRUE(bbox.has_value()); + + decision = coordinator.completeTask(completed(*bbox)); + auto sdf = findStartedTask(decision, RenderTaskType::SdfPrecomputation); + ASSERT_TRUE(sdf.has_value()); + + decision = coordinator.completeTask(completed(*sdf)); + auto initialHq = findStartedTask(decision, RenderTaskType::ProgressiveHighQualityChunk); + ASSERT_TRUE(initialHq.has_value()); + ASSERT_TRUE(coordinator.isHeavyGeometryCurrent()); + decision = coordinator.completeTask(completed(*initialHq, true, true)); + EXPECT_TRUE(hasCommand(decision, RenderCommandType::PresentFrame)); + + decision = coordinator.notifyCameraChanged(); + auto preview = findStartedTask(decision, RenderTaskType::LowResolutionPreview); + ASSERT_TRUE(preview.has_value()); + EXPECT_FALSE(hasStartedTask(decision, RenderTaskType::BoundingBoxUpdate)); + EXPECT_FALSE(hasStartedTask(decision, RenderTaskType::SdfPrecomputation)); + + decision = coordinator.completeTask(completed(*preview, true, true)); + EXPECT_TRUE(hasCommand(decision, RenderCommandType::PresentFrame)); + + decision = coordinator.notifyCameraInteractionEnded(); + auto hq = findStartedTask(decision, RenderTaskType::ProgressiveHighQualityChunk); + ASSERT_TRUE(hq.has_value()); + EXPECT_TRUE(coordinator.isHeavyGeometryCurrent()); + EXPECT_TRUE(matches(hq->stamp, coordinator.latestStamp(), RenderStampMask::displayFrame())); + EXPECT_FALSE(hasStartedTask(decision, RenderTaskType::BoundingBoxUpdate)); + EXPECT_FALSE(hasStartedTask(decision, RenderTaskType::SdfPrecomputation)); + } + + TEST(RenderUpdateCoordinator, StructuralModelChanged_StaleDisplayResultIsDiscarded) + { + RenderUpdateCoordinator coordinator; + auto decision = coordinator.notifyCameraChanged(); + auto preview = findStartedTask(decision, RenderTaskType::LowResolutionPreview); + ASSERT_TRUE(preview.has_value()); + + decision = coordinator.notifyStructuralModelChanged(); + EXPECT_TRUE(hasStartedTask(decision, RenderTaskType::ProgramCompilation)); + + decision = coordinator.completeTask(completed(*preview, true, true)); + + EXPECT_TRUE(hasCommand(decision, RenderCommandType::DiscardTaskResult)); + EXPECT_FALSE(hasCommand(decision, RenderCommandType::PresentFrame)); + } + + TEST(RenderUpdateCoordinator, ProgramCompilationCompleted_AfterStructuralChange_StartsParameterUpload) + { + RenderUpdateCoordinator coordinator; + auto decision = coordinator.notifyStructuralModelChanged(); + + EXPECT_TRUE(hasStartedTask(decision, RenderTaskType::ProgramCompilation)); + + decision = coordinator.notifyProgramCompilationCompleted(); + + EXPECT_TRUE(hasStartedTask(decision, RenderTaskType::ParameterUpload)); + } + + TEST(RenderUpdateCoordinator, StaticTick_WithFreshHeavyTasks_DoesNotStartDuplicateHqWhileInFlight) + { + RenderUpdateCoordinator coordinator; + auto decision = coordinator.tick(); + auto bbox = findStartedTask(decision, RenderTaskType::BoundingBoxUpdate); + ASSERT_TRUE(bbox.has_value()); + + decision = coordinator.completeTask(completed(*bbox)); + auto sdf = findStartedTask(decision, RenderTaskType::SdfPrecomputation); + ASSERT_TRUE(sdf.has_value()); + + decision = coordinator.completeTask(completed(*sdf)); + auto hq = findStartedTask(decision, RenderTaskType::ProgressiveHighQualityChunk); + ASSERT_TRUE(hq.has_value()); + + decision = coordinator.tick(); + + EXPECT_FALSE(hasStartedTask(decision, RenderTaskType::ProgressiveHighQualityChunk)); + EXPECT_TRUE(hasCommand(decision, RenderCommandType::KeepCurrentFrame)); + } + + TEST(RenderUpdateCoordinator, FailedBoundingBox_DoesNotAdvanceToSdfAndCanRetry) + { + RenderUpdateCoordinator coordinator; + auto decision = coordinator.tick(); + auto bbox = findStartedTask(decision, RenderTaskType::BoundingBoxUpdate); + ASSERT_TRUE(bbox.has_value()); + + decision = coordinator.completeTask(failed(*bbox)); + + EXPECT_TRUE(hasCommand(decision, RenderCommandType::DiscardTaskResult)); + EXPECT_FALSE(hasStartedTask(decision, RenderTaskType::SdfPrecomputation)); + + decision = coordinator.tick(); + auto retry = findStartedTask(decision, RenderTaskType::BoundingBoxUpdate); + ASSERT_TRUE(retry.has_value()); + EXPECT_NE(retry->requestId, bbox->requestId); + } +} From 91b9470345611d6cc20847c84114889e696499b4 Mon Sep 17 00:00:00 2001 From: Jan Orend <56254096+3dJan@users.noreply.github.com> Date: Thu, 14 May 2026 16:38:45 +0200 Subject: [PATCH 09/72] improved state machine --- gladius/src/ui/OrbitalCamera.cpp | 6 +- gladius/src/ui/RenderWindow.cpp | 97 +++++---- .../ui/render/RealtimeRaymarchController.cpp | 184 ++++++++++++++---- .../ui/render/RealtimeRaymarchController.h | 15 +- .../src/ui/render/RenderUpdateCoordinator.h | 39 +++- .../ui/RealtimeRaymarchController_tests.cpp | 108 ++++++++-- .../render/RenderUpdateCoordinator_tests.cpp | 97 +++++++-- 7 files changed, 433 insertions(+), 113 deletions(-) diff --git a/gladius/src/ui/OrbitalCamera.cpp b/gladius/src/ui/OrbitalCamera.cpp index f6f8aeac..0f76f773 100644 --- a/gladius/src/ui/OrbitalCamera.cpp +++ b/gladius/src/ui/OrbitalCamera.cpp @@ -108,11 +108,15 @@ namespace gladius::ui auto const speedFactor = 15.E-3f; // if (deltaTime_ms > 50.f) { + changed = fabs(m_pitchTarget - m_pitch) > tolerance || + fabs(m_yawTarget - m_yaw) > tolerance || + fabs(m_eyeDistTarget - m_eyeDist) > tolerance || + (m_lookAtTarget - m_lookAt).norm() > tolerance; m_pitch = m_pitchTarget; m_yaw = m_yawTarget; m_eyeDist = m_eyeDistTarget; m_lookAt = m_lookAtTarget; - return false; + return changed; } // deltaTime_ms = std::min(deltaTime_ms, 50.f); diff --git a/gladius/src/ui/RenderWindow.cpp b/gladius/src/ui/RenderWindow.cpp index 0d1ccb20..7062ae29 100644 --- a/gladius/src/ui/RenderWindow.cpp +++ b/gladius/src/ui/RenderWindow.cpp @@ -291,10 +291,12 @@ namespace gladius::ui ProfileFunction; m_dirty = true; m_renderWindowState.isMoving = true; + [[maybe_unused]] bool const cameraChanged = m_camera.update(0.0f); m_renderWindowState.currentLine = 0; m_renderWindowState.renderingStepSize = kInitialProgressiveStepSize; m_renderWindowState.isRendering = false; - bool const forceLowResPreview = !m_renderUpdateCoordinator.isRealtimeActive(); + bool const forceLowResPreview = + m_renderUpdateCoordinator.realtimeConfig().mode == async_rendering::RealtimeRaymarchMode::Off; m_forceLowResRenderOnNextFrame.store(forceLowResPreview, std::memory_order_release); if (forceLowResPreview) { @@ -2122,8 +2124,13 @@ namespace gladius::ui state.currentLine = 0; state.renderingStepSize = kInitialProgressiveStepSize; state.isRendering = false; - m_forceLowResRenderOnNextFrame.store(true, std::memory_order_release); - m_lastLowResRenderTime = std::chrono::system_clock::now(); + bool const forceLowResPreview = + m_renderUpdateCoordinator.realtimeConfig().mode == async_rendering::RealtimeRaymarchMode::Off; + m_forceLowResRenderOnNextFrame.store(forceLowResPreview, std::memory_order_release); + if (forceLowResPreview) + { + m_lastLowResRenderTime = std::chrono::system_clock::now(); + } } if (state.isMoving && m_preComputedSdfDirty) @@ -2380,14 +2387,13 @@ namespace gladius::ui ZoneScopedN("ProcessSingleResult"); auto & result = *resultOpt; - auto const currentEpoch = m_asyncCurrentEpoch.load(std::memory_order_acquire); - bool const isOutdated = result.epoch < currentEpoch; - auto const currentViewEpoch = m_asyncViewEpoch.load(std::memory_order_acquire); - bool const isRealtimeJob = - result.jobType == async_rendering::RenderJobType::RealtimeHighQuality; - bool const isHqRenderJob = - result.jobType == async_rendering::RenderJobType::HighQuality || isRealtimeJob; - bool const isViewOutdated = isHqRenderJob && result.viewEpoch < currentViewEpoch; + auto const currentEpoch = m_asyncCurrentEpoch.load(std::memory_order_acquire); + bool const isOutdated = result.epoch < currentEpoch; + auto const currentViewEpoch = m_asyncViewEpoch.load(std::memory_order_acquire); + bool const isRealtimeJob = result.jobType == async_rendering::RenderJobType::RealtimeHighQuality; + bool const isExactRealtimeJob = isRealtimeJob; + bool const isHqRenderJob = result.jobType == async_rendering::RenderJobType::HighQuality || isExactRealtimeJob; + bool const isViewOutdated = isHqRenderJob && result.viewEpoch < currentViewEpoch; if (result.jobType == async_rendering::RenderJobType::SDFPrecomputation) { @@ -2445,35 +2451,53 @@ namespace gladius::ui continue; } - // From here on, only HQ progressive render results are processed. + // From here on, only HQ/full exact render results are processed. if (isHqRenderJob && result.computeDurationNs > 0) { size_t const renderedLines = result.completedLine > result.startLine ? result.completedLine - result.startLine : size_t{0}; - m_renderUpdateCoordinator.recordRealtimeSample( - async_rendering::RealtimeRaymarchSample{ - .durationMs = static_cast( - static_cast(result.computeDurationNs) / 1'000'000.0), - .width = result.width, - .height = result.height, - .renderedLines = renderedLines, - .totalLines = result.height, - .completedFrame = result.completedFrame, - .cancelled = result.cancelled}); + auto const durationMs = static_cast( + static_cast(result.computeDurationNs) / 1'000'000.0); + async_rendering::RealtimeRaymarchSample sample{}; + sample.durationMs = durationMs; + sample.width = result.width; + sample.height = result.height; + sample.renderedLines = renderedLines; + sample.totalLines = result.height; + sample.completedFrame = result.completedFrame && result.startLine == 0 && + renderedLines >= static_cast(result.height); + sample.cancelled = result.cancelled; + if (isRealtimeJob) + { + if (m_renderUpdateCoordinator.interactionState() == + async_rendering::RenderInteractionState::Static) + { + m_renderUpdateCoordinator.recordStaticFullFrameSample(sample); + } + else + { + m_renderUpdateCoordinator.recordInteractiveRealtimeSample(sample); + } + } + else if (!isOutdated && !isViewOutdated && + m_renderUpdateCoordinator.interactionState() == async_rendering::RenderInteractionState::Static) + { + m_renderUpdateCoordinator.recordStaticProgressiveSample(sample); + } } if (result.cancelled) { - if (isRealtimeJob) + if (isExactRealtimeJob) { m_renderUpdateCoordinator.recordRealtimeRejectedAttempt(); } if (result.epoch == m_asyncInFlightEpoch.load(std::memory_order_acquire)) { m_asyncJobInFlight.store(false, std::memory_order_release); - if (isRealtimeJob) + if (isExactRealtimeJob) { m_asyncRealtimeJobInFlight.store(false, std::memory_order_release); } @@ -2486,7 +2510,7 @@ namespace gladius::ui } // Update progressive scheduling state only for current scene/view results. - if (!isOutdated && !isViewOutdated && !isRealtimeJob) + if (!isOutdated && !isViewOutdated && result.jobType == async_rendering::RenderJobType::HighQuality) { size_t const maxHeight = static_cast(result.height); state.currentLine = std::min(result.completedLine, maxHeight); @@ -2497,14 +2521,14 @@ namespace gladius::ui // Stale scene/view results are deliberately discarded before promotion; otherwise // a late realtime frame could replace a valid front buffer with an old camera or // parameter state. - bool const allowForcedRealtimeStaleView = - isRealtimeJob && m_renderUpdateCoordinator.realtimeConfig().mode == - async_rendering::RealtimeRaymarchMode::Force; - bool const discardForStaleView = isViewOutdated && !allowForcedRealtimeStaleView; + bool const allowForcedRealtimeStaleView = + isRealtimeJob && m_renderUpdateCoordinator.realtimeConfig().mode == + async_rendering::RealtimeRaymarchMode::Force; + bool const discardForStaleView = isViewOutdated && !allowForcedRealtimeStaleView; - if (result.completedFrame) + if (result.completedFrame) { - if (isOutdated || discardForStaleView) + if (isOutdated || discardForStaleView) { m_asyncController->discardReadyFrame( result.frameId, result.epoch, result.viewEpoch); @@ -2535,7 +2559,7 @@ namespace gladius::ui // DON'T force state.isMoving = false - let camera update control this // If camera is still animating, it will set isMoving=true on next frame state.currentLine = 0; // Reset for next frame - if (isRealtimeJob) + if (isExactRealtimeJob) { m_lowResFeedbackPending.store(false, std::memory_order_release); m_lastLowResPreviewEpoch.store(result.epoch, std::memory_order_release); @@ -2579,7 +2603,7 @@ namespace gladius::ui if (result.epoch == m_asyncInFlightEpoch.load(std::memory_order_acquire)) { m_asyncJobInFlight.store(false, std::memory_order_release); - if (isRealtimeJob) + if (isExactRealtimeJob) { m_asyncRealtimeJobInFlight.store(false, std::memory_order_release); } @@ -2759,7 +2783,7 @@ namespace gladius::ui } state.currentLine = 0; - state.renderingStepSize = height; + state.renderingStepSize = job.stepSize; state.isRendering = true; m_forceLowResRenderOnNextFrame.store(false, std::memory_order_release); m_lowResFeedbackPending.store(false, std::memory_order_release); @@ -2804,6 +2828,7 @@ namespace gladius::ui result.startLine = job.startLine; result.coordinatorRequestId = job.coordinatorRequestId; result.coordinatorStamp = job.coordinatorStamp; + bool const isExactRealtimeJob = job.type == async_rendering::RenderJobType::RealtimeHighQuality; if (cancellationRequested()) { @@ -2896,7 +2921,7 @@ namespace gladius::ui cl::Event renderEvent{}; bool advanced = false; - if (job.type == async_rendering::RenderJobType::RealtimeHighQuality) + if (isExactRealtimeJob) { auto assembly = m_document ? m_document->getAssembly() : nullptr; if (assembly && m_core->isRendererReady()) @@ -2932,7 +2957,7 @@ namespace gladius::ui result.completedLine = advanced ? endLine : job.startLine; result.completedFrame = result.completedLine >= job.height; - if (!advanced && job.type == async_rendering::RenderJobType::RealtimeHighQuality) + if (!advanced && isExactRealtimeJob) { result.cancelled = true; } diff --git a/gladius/src/ui/render/RealtimeRaymarchController.cpp b/gladius/src/ui/render/RealtimeRaymarchController.cpp index 6b584a1c..33e80a17 100644 --- a/gladius/src/ui/render/RealtimeRaymarchController.cpp +++ b/gladius/src/ui/render/RealtimeRaymarchController.cpp @@ -57,6 +57,8 @@ namespace gladius::ui::async_rendering config.requiredFastSamples = std::max(config.requiredFastSamples, 1); config.maxSlowSamples = std::max(config.maxSlowSamples, 1); config.cooldownFrames = std::max(config.cooldownFrames, 0); + config.staticFullFrameBudgetMs = std::max(config.staticFullFrameBudgetMs, config.targetFrameTimeMs); + config.realtimeDropBudgetMs = std::max(config.realtimeDropBudgetMs, config.targetFrameTimeMs); m_config = config; if (m_config.mode == RealtimeRaymarchMode::Off) @@ -79,6 +81,7 @@ namespace gladius::ui::async_rendering m_slowSampleStreak = 0; m_cooldownFramesRemaining = 0; m_realtimeActive = false; + m_staticFullFramePreferred = false; } void RealtimeRaymarchController::resetForResolution(uint32_t width, uint32_t height) @@ -88,9 +91,48 @@ namespace gladius::ui::async_rendering return; } + auto const previousEstimate = m_estimatedFullFrameMs; + auto const previousWidth = m_width; + auto const previousHeight = m_height; + bool const wasRealtimeActive = m_realtimeActive; + reset(); m_width = width; m_height = height; + + if (!previousEstimate.has_value() || previousWidth == 0 || previousHeight == 0 || width == 0 || height == 0) + { + return; + } + + auto const previousPixels = static_cast(previousWidth) * static_cast(previousHeight); + auto const currentPixels = static_cast(width) * static_cast(height); + if (previousPixels <= 0.0 || currentPixels <= 0.0) + { + return; + } + + auto const scale = std::clamp(currentPixels / previousPixels, 0.1, 10.0); + auto const scaledEstimate = static_cast(*previousEstimate * scale); + if (!std::isfinite(scaledEstimate) || scaledEstimate <= 0.0f) + { + return; + } + + m_estimatedFullFrameMs = scaledEstimate; + + auto const enterBudget = m_config.targetFrameTimeMs * m_config.enterBudgetRatio; + auto const exitBudget = m_config.targetFrameTimeMs * m_config.exitBudgetRatio; + if (scaledEstimate <= enterBudget || (wasRealtimeActive && scaledEstimate <= exitBudget)) + { + m_fastSampleStreak = m_config.requiredFastSamples; + m_realtimeActive = m_config.mode == RealtimeRaymarchMode::Auto; + m_staticFullFramePreferred = true; + } + else if (scaledEstimate <= m_config.staticFullFrameBudgetMs) + { + m_staticFullFramePreferred = true; + } } void RealtimeRaymarchController::beginFrame() @@ -101,9 +143,84 @@ namespace gladius::ui::async_rendering } } - void RealtimeRaymarchController::recordSample(RealtimeRaymarchSample const & sample) + void RealtimeRaymarchController::recordStaticProgressiveSample(RealtimeRaymarchSample const & sample) + { + if (m_config.mode != RealtimeRaymarchMode::Auto || sample.cancelled) + { + return; + } + + if (sample.width == 0 || sample.height == 0) + { + return; + } + + if (!hasValidResolution(sample.width, sample.height)) + { + resetForResolution(sample.width, sample.height); + } + + auto const estimatedMs = estimateSampleFullFrameTimeMs(sample); + if (!estimatedMs.has_value()) + { + return; + } + + recordEstimatedTime(*estimatedMs); + m_staticFullFramePreferred = *estimatedMs <= m_config.staticFullFrameBudgetMs; + if (m_staticFullFramePreferred) + { + m_slowSampleStreak = 0; + } + } + + void RealtimeRaymarchController::recordStaticFullFrameSample(RealtimeRaymarchSample const & sample) + { + if (m_config.mode != RealtimeRaymarchMode::Auto || sample.cancelled || !sample.completedFrame) + { + return; + } + + if (sample.width == 0 || sample.height == 0) + { + return; + } + + if (!hasValidResolution(sample.width, sample.height)) + { + resetForResolution(sample.width, sample.height); + } + + auto const estimatedMs = estimateSampleFullFrameTimeMs(sample); + if (!estimatedMs.has_value()) + { + return; + } + + recordEstimatedTime(*estimatedMs); + m_staticFullFramePreferred = *estimatedMs <= m_config.staticFullFrameBudgetMs; + + if (*estimatedMs <= m_config.targetFrameTimeMs * m_config.enterBudgetRatio) + { + ++m_fastSampleStreak; + m_slowSampleStreak = 0; + if (m_fastSampleStreak >= m_config.requiredFastSamples) + { + m_realtimeActive = true; + } + return; + } + + m_fastSampleStreak = 0; + if (*estimatedMs > m_config.realtimeDropBudgetMs) + { + deactivateRealtime(); + } + } + + void RealtimeRaymarchController::recordInteractiveRealtimeSample(RealtimeRaymarchSample const & sample) { - if (sample.cancelled) + if (m_config.mode != RealtimeRaymarchMode::Auto || sample.cancelled || !sample.completedFrame) { return; } @@ -125,11 +242,15 @@ namespace gladius::ui::async_rendering } recordEstimatedTime(*estimatedMs); + if (*estimatedMs > m_config.realtimeDropBudgetMs) + { + deactivateRealtime(); + } } void RealtimeRaymarchController::recordRejectedAttempt() { - m_realtimeActive = false; + deactivateRealtime(); m_fastSampleStreak = 0; ++m_slowSampleStreak; if (m_slowSampleStreak >= m_config.maxSlowSamples) @@ -166,6 +287,26 @@ namespace gladius::ui::async_rendering return m_realtimeActive; } + bool RealtimeRaymarchController::canAttemptStaticFullFrame( + uint32_t width, + uint32_t height, + RealtimeRaymarchGuards const & guards) const noexcept + { + if (m_config.mode != RealtimeRaymarchMode::Auto) + { + return false; + } + if (!m_staticFullFramePreferred || m_cooldownFramesRemaining > 0) + { + return false; + } + if (!hasValidResolution(width, height)) + { + return false; + } + return guardsAllowAttempt(guards); + } + bool RealtimeRaymarchController::isRealtimeActive() const noexcept { return m_config.mode == RealtimeRaymarchMode::Force || m_realtimeActive; @@ -253,43 +394,18 @@ namespace gladius::ui::async_rendering m_estimatedFullFrameMs = (*m_estimatedFullFrameMs * (1.0f - m_config.ewmaAlpha)) + (estimatedMs * m_config.ewmaAlpha); } + } - auto const enterBudget = m_config.targetFrameTimeMs * m_config.enterBudgetRatio; - auto const exitBudget = m_config.targetFrameTimeMs * m_config.exitBudgetRatio; - auto const severeMissBudget = m_config.targetFrameTimeMs * m_config.severeMissRatio; - - if (*m_estimatedFullFrameMs <= enterBudget) - { - ++m_fastSampleStreak; - m_slowSampleStreak = 0; - if (m_fastSampleStreak >= m_config.requiredFastSamples && - m_cooldownFramesRemaining == 0) - { - m_realtimeActive = true; - } - return; - } - + void RealtimeRaymarchController::deactivateRealtime() + { + m_realtimeActive = false; m_fastSampleStreak = 0; - if (*m_estimatedFullFrameMs > exitBudget) - { - ++m_slowSampleStreak; - } - else - { - m_slowSampleStreak = 0; - } - - if (*m_estimatedFullFrameMs > severeMissBudget || - m_slowSampleStreak >= m_config.maxSlowSamples) - { - enterCooldown(); - } + m_staticFullFramePreferred = false; } void RealtimeRaymarchController::enterCooldown() { - m_realtimeActive = false; + deactivateRealtime(); m_fastSampleStreak = 0; m_slowSampleStreak = 0; m_cooldownFramesRemaining = m_config.cooldownFrames; diff --git a/gladius/src/ui/render/RealtimeRaymarchController.h b/gladius/src/ui/render/RealtimeRaymarchController.h index 68a3c0f0..bdd6ea70 100644 --- a/gladius/src/ui/render/RealtimeRaymarchController.h +++ b/gladius/src/ui/render/RealtimeRaymarchController.h @@ -21,13 +21,15 @@ namespace gladius::ui::async_rendering { RealtimeRaymarchMode mode{RealtimeRaymarchMode::Auto}; float targetFrameTimeMs{25.0f}; - float enterBudgetRatio{0.8f}; + float enterBudgetRatio{1.0f}; float exitBudgetRatio{1.1f}; float severeMissRatio{2.0f}; float ewmaAlpha{0.35f}; - int requiredFastSamples{3}; + int requiredFastSamples{1}; int maxSlowSamples{2}; int cooldownFrames{30}; + float staticFullFrameBudgetMs{60.0f}; + float realtimeDropBudgetMs{60.0f}; }; struct RealtimeRaymarchGuards @@ -61,12 +63,17 @@ namespace gladius::ui::async_rendering void resetForResolution(uint32_t width, uint32_t height); void beginFrame(); - void recordSample(RealtimeRaymarchSample const & sample); + void recordStaticProgressiveSample(RealtimeRaymarchSample const & sample); + void recordStaticFullFrameSample(RealtimeRaymarchSample const & sample); + void recordInteractiveRealtimeSample(RealtimeRaymarchSample const & sample); void recordRejectedAttempt(); [[nodiscard]] bool canAttemptRealtime(uint32_t width, uint32_t height, RealtimeRaymarchGuards const & guards) const noexcept; + [[nodiscard]] bool canAttemptStaticFullFrame(uint32_t width, + uint32_t height, + RealtimeRaymarchGuards const & guards) const noexcept; [[nodiscard]] bool isRealtimeActive() const noexcept; [[nodiscard]] bool isCoolingDown() const noexcept; [[nodiscard]] std::optional estimatedFullFrameTimeMs() const noexcept; @@ -78,6 +85,7 @@ namespace gladius::ui::async_rendering [[nodiscard]] std::optional estimateSampleFullFrameTimeMs( RealtimeRaymarchSample const & sample) const noexcept; void recordEstimatedTime(float estimatedMs); + void deactivateRealtime(); void enterCooldown(); RealtimeRaymarchConfig m_config{}; @@ -88,5 +96,6 @@ namespace gladius::ui::async_rendering int m_slowSampleStreak{0}; int m_cooldownFramesRemaining{0}; bool m_realtimeActive{false}; + bool m_staticFullFramePreferred{false}; }; } diff --git a/gladius/src/ui/render/RenderUpdateCoordinator.h b/gladius/src/ui/render/RenderUpdateCoordinator.h index f603dcfd..ec86e040 100644 --- a/gladius/src/ui/render/RenderUpdateCoordinator.h +++ b/gladius/src/ui/render/RenderUpdateCoordinator.h @@ -4,6 +4,7 @@ #include "RenderUpdateTypes.h" #include +#include #include #include #include @@ -74,9 +75,19 @@ namespace gladius::ui::async_rendering m_realtimeGuards = guards; } - void recordRealtimeSample(RealtimeRaymarchSample const & sample) + void recordStaticProgressiveSample(RealtimeRaymarchSample const & sample) { - m_realtime.recordSample(sample); + m_realtime.recordStaticProgressiveSample(sample); + } + + void recordStaticFullFrameSample(RealtimeRaymarchSample const & sample) + { + m_realtime.recordStaticFullFrameSample(sample); + } + + void recordInteractiveRealtimeSample(RealtimeRaymarchSample const & sample) + { + m_realtime.recordInteractiveRealtimeSample(sample); } void recordRealtimeRejectedAttempt() @@ -151,6 +162,7 @@ namespace gladius::ui::async_rendering { RenderUpdateDecision decision{}; ++m_latestStamp.parameterEpoch; + resetRealtimeLearning(); m_parameterUploadStamp.reset(); m_boundingBoxStamp.reset(); m_sdfStamp.reset(); @@ -182,6 +194,7 @@ namespace gladius::ui::async_rendering RenderUpdateDecision decision{}; ++m_latestStamp.sceneEpoch; ++m_latestStamp.parameterEpoch; + resetRealtimeLearning(); m_programStamp.reset(); m_parameterUploadStamp.reset(); m_boundingBoxStamp.reset(); @@ -340,7 +353,10 @@ namespace gladius::ui::async_rendering matches(*m_highQualityFrameStamp, m_latestStamp, RenderStampMask::displayFrame()); } - void startTask(RenderUpdateDecision & decision, RenderTaskType type, RenderStamp const & stamp) + void startTask(RenderUpdateDecision & decision, + RenderTaskType type, + RenderStamp const & stamp, + size_t lineCount = 0) { if (hasAnyInFlight(type) || hasEquivalentInFlight(type, stamp)) { @@ -348,13 +364,17 @@ namespace gladius::ui::async_rendering return; } + size_t const requestedLines = lineCount == 0 ? static_cast(m_height) : lineCount; + size_t const clampedLineCount = + std::clamp(requestedLines, size_t{1}, static_cast(m_height)); + RenderTaskRequest request{.requestId = m_nextRequestId++, .type = type, .stamp = stamp, .width = m_width, .height = m_height, .startLine = 0, - .lineCount = m_height}; + .lineCount = clampedLineCount}; m_inFlight.push_back(InFlightTask{.requestId = request.requestId, .type = request.type, .stamp = request.stamp}); @@ -369,6 +389,12 @@ namespace gladius::ui::async_rendering .stamp = m_latestStamp}); } + void resetRealtimeLearning() + { + m_realtime.reset(); + m_realtime.resetForResolution(m_width, m_height); + } + void scheduleInteractiveFrame(RenderUpdateDecision & decision) { if (hasInteractiveFrameInFlight()) @@ -431,6 +457,11 @@ namespace gladius::ui::async_rendering keepCurrentFrame(decision); return; } + if (m_realtime.canAttemptStaticFullFrame(m_width, m_height, m_realtimeGuards)) + { + startTask(decision, RenderTaskType::RealtimeFullFrame, m_latestStamp); + return; + } startTask(decision, RenderTaskType::ProgressiveHighQualityChunk, m_latestStamp); } diff --git a/gladius/tests/unittests/ui/RealtimeRaymarchController_tests.cpp b/gladius/tests/unittests/ui/RealtimeRaymarchController_tests.cpp index 17da21c9..c1cdee91 100644 --- a/gladius/tests/unittests/ui/RealtimeRaymarchController_tests.cpp +++ b/gladius/tests/unittests/ui/RealtimeRaymarchController_tests.cpp @@ -28,6 +28,17 @@ namespace gladius::ui::async_rendering::tests .cancelled = false}; } + [[nodiscard]] RealtimeRaymarchSample makeProgressiveChunkSample(float durationMs) + { + return RealtimeRaymarchSample{.durationMs = durationMs, + .width = 800, + .height = 600, + .renderedLines = 60, + .totalLines = 600, + .completedFrame = false, + .cancelled = false}; + } + [[nodiscard]] RealtimeRaymarchGuards makeOpenGuards() { return RealtimeRaymarchGuards{}; @@ -39,30 +50,29 @@ namespace gladius::ui::async_rendering::tests RealtimeRaymarchController controller; controller.configure(makeTestConfig()); - controller.recordSample(makeFullFrameSample(18.0f)); - controller.recordSample(makeFullFrameSample(19.0f)); - controller.recordSample(makeFullFrameSample(20.0f)); + controller.recordStaticFullFrameSample(makeFullFrameSample(18.0f)); + controller.recordStaticFullFrameSample(makeFullFrameSample(19.0f)); + controller.recordStaticFullFrameSample(makeFullFrameSample(20.0f)); EXPECT_TRUE(controller.canAttemptRealtime(800, 600, makeOpenGuards())); ASSERT_TRUE(controller.estimatedFullFrameTimeMs().has_value()); EXPECT_FLOAT_EQ(*controller.estimatedFullFrameTimeMs(), 20.0f); } - TEST(RealtimeRaymarchController, RecordSlowSamples_EntersCooldown) + TEST(RealtimeRaymarchController, InteractiveSlowSample_DeactivatesRealtime) { RealtimeRaymarchController controller; controller.configure(makeTestConfig()); - controller.recordSample(makeFullFrameSample(18.0f)); - controller.recordSample(makeFullFrameSample(18.0f)); - controller.recordSample(makeFullFrameSample(18.0f)); + controller.recordStaticFullFrameSample(makeFullFrameSample(18.0f)); + controller.recordStaticFullFrameSample(makeFullFrameSample(18.0f)); + controller.recordStaticFullFrameSample(makeFullFrameSample(18.0f)); ASSERT_TRUE(controller.canAttemptRealtime(800, 600, makeOpenGuards())); - controller.recordSample(makeFullFrameSample(35.0f)); - controller.recordSample(makeFullFrameSample(36.0f)); + controller.recordInteractiveRealtimeSample(makeFullFrameSample(65.0f)); EXPECT_FALSE(controller.canAttemptRealtime(800, 600, makeOpenGuards())); - EXPECT_TRUE(controller.isCoolingDown()); + EXPECT_FALSE(controller.canAttemptStaticFullFrame(800, 600, makeOpenGuards())); } TEST(RealtimeRaymarchController, BeginFrame_CountsDownCooldown) @@ -70,7 +80,8 @@ namespace gladius::ui::async_rendering::tests RealtimeRaymarchController controller; controller.configure(makeTestConfig()); - controller.recordSample(makeFullFrameSample(60.0f)); + controller.recordRejectedAttempt(); + controller.recordRejectedAttempt(); ASSERT_TRUE(controller.isCoolingDown()); controller.beginFrame(); @@ -95,10 +106,68 @@ namespace gladius::ui::async_rendering::tests sample.totalLines = 600; sample.completedFrame = false; - controller.recordSample(sample); + controller.recordStaticProgressiveSample(sample); ASSERT_TRUE(controller.estimatedFullFrameTimeMs().has_value()); EXPECT_FLOAT_EQ(*controller.estimatedFullFrameTimeMs(), 20.0f); + EXPECT_TRUE(controller.canAttemptStaticFullFrame(800, 600, makeOpenGuards())); + } + + TEST(RealtimeRaymarchController, AutoLearning_CanAttemptStaticFullFrameAfterFastProgressiveSample) + { + RealtimeRaymarchController controller; + controller.configure(makeTestConfig()); + controller.resetForResolution(800, 600); + + EXPECT_FALSE(controller.canAttemptRealtime(800, 600, makeOpenGuards())); + EXPECT_FALSE(controller.canAttemptStaticFullFrame(800, 600, makeOpenGuards())); + + controller.recordStaticProgressiveSample(makeProgressiveChunkSample(2.0f)); + + EXPECT_TRUE(controller.canAttemptStaticFullFrame(800, 600, makeOpenGuards())); + + auto blockedGuards = makeOpenGuards(); + blockedGuards.renderJobInFlight = true; + EXPECT_FALSE(controller.canAttemptStaticFullFrame(800, 600, blockedGuards)); + } + + TEST(RealtimeRaymarchController, SlowProgressiveSample_DoesNotEnableStaticFullFrame) + { + RealtimeRaymarchController controller; + controller.configure(makeTestConfig()); + + controller.recordStaticProgressiveSample(makeProgressiveChunkSample(10.0f)); + + ASSERT_TRUE(controller.estimatedFullFrameTimeMs().has_value()); + EXPECT_FLOAT_EQ(*controller.estimatedFullFrameTimeMs(), 100.0f); + EXPECT_FALSE(controller.canAttemptStaticFullFrame(800, 600, makeOpenGuards())); + } + + TEST(RealtimeRaymarchController, SlowProgressiveSample_ClearsStaticFullFramePreference) + { + RealtimeRaymarchController controller; + controller.configure(makeTestConfig()); + + controller.recordStaticProgressiveSample(makeProgressiveChunkSample(2.0f)); + ASSERT_TRUE(controller.canAttemptStaticFullFrame(800, 600, makeOpenGuards())); + + controller.recordStaticProgressiveSample(makeProgressiveChunkSample(10.0f)); + + EXPECT_FALSE(controller.canAttemptStaticFullFrame(800, 600, makeOpenGuards())); + } + + TEST(RealtimeRaymarchController, StaticFullFrameBetweenRealtimeAndStaticBudgets_DoesNotActivateRealtime) + { + RealtimeRaymarchController controller; + controller.configure(makeTestConfig()); + + controller.recordStaticProgressiveSample(makeProgressiveChunkSample(2.0f)); + ASSERT_TRUE(controller.canAttemptStaticFullFrame(800, 600, makeOpenGuards())); + + controller.recordStaticFullFrameSample(makeFullFrameSample(40.0f)); + + EXPECT_FALSE(controller.canAttemptRealtime(800, 600, makeOpenGuards())); + EXPECT_TRUE(controller.canAttemptStaticFullFrame(800, 600, makeOpenGuards())); } TEST(RealtimeRaymarchController, ForceMode_IgnoresLearningButNotGuards) @@ -116,20 +185,21 @@ namespace gladius::ui::async_rendering::tests EXPECT_FALSE(controller.canAttemptRealtime(800, 600, blockedGuards)); } - TEST(RealtimeRaymarchController, ResolutionChange_ResetsConfidence) + TEST(RealtimeRaymarchController, ResolutionChange_ScalesConfidence) { RealtimeRaymarchController controller; controller.configure(makeTestConfig()); - controller.recordSample(makeFullFrameSample(18.0f)); - controller.recordSample(makeFullFrameSample(18.0f)); - controller.recordSample(makeFullFrameSample(18.0f)); + controller.recordStaticFullFrameSample(makeFullFrameSample(18.0f)); + controller.recordStaticFullFrameSample(makeFullFrameSample(18.0f)); + controller.recordStaticFullFrameSample(makeFullFrameSample(18.0f)); ASSERT_TRUE(controller.canAttemptRealtime(800, 600, makeOpenGuards())); - controller.resetForResolution(1024, 768); + controller.resetForResolution(400, 300); - EXPECT_FALSE(controller.canAttemptRealtime(1024, 768, makeOpenGuards())); - EXPECT_FALSE(controller.estimatedFullFrameTimeMs().has_value()); + EXPECT_TRUE(controller.canAttemptRealtime(400, 300, makeOpenGuards())); + ASSERT_TRUE(controller.estimatedFullFrameTimeMs().has_value()); + EXPECT_FLOAT_EQ(*controller.estimatedFullFrameTimeMs(), 4.5f); } TEST(RealtimeRaymarchModeFromString, WithKnownValues_ParsesModes) diff --git a/gladius/tests/unittests/ui/render/RenderUpdateCoordinator_tests.cpp b/gladius/tests/unittests/ui/render/RenderUpdateCoordinator_tests.cpp index 9cb3e48a..7deb5873 100644 --- a/gladius/tests/unittests/ui/render/RenderUpdateCoordinator_tests.cpp +++ b/gladius/tests/unittests/ui/render/RenderUpdateCoordinator_tests.cpp @@ -77,9 +77,10 @@ namespace gladius::ui::async_rendering::tests } } - TEST(RenderUpdateCoordinator, CameraChanged_IncrementsOnlyViewEpochAndStartsPreview) + TEST(RenderUpdateCoordinator, CameraChanged_WithAutoLearning_StartsLowResolutionPreview) { RenderUpdateCoordinator coordinator; + ASSERT_FALSE(coordinator.configureViewport(640, 480).commands.empty()); auto const before = coordinator.latestStamp(); auto const decision = coordinator.notifyCameraChanged(); @@ -95,6 +96,43 @@ namespace gladius::ui::async_rendering::tests EXPECT_FALSE(hasStartedTask(decision, RenderTaskType::SdfPrecomputation)); } + TEST(RenderUpdateCoordinator, CameraChanged_WithAutoPreviewInFlight_KeepsCurrentFrame) + { + RenderUpdateCoordinator coordinator; + ASSERT_FALSE(coordinator.configureViewport(640, 480).commands.empty()); + + auto decision = coordinator.notifyCameraChanged(); + ASSERT_TRUE(hasStartedTask(decision, RenderTaskType::LowResolutionPreview)); + + decision = coordinator.notifyCameraChanged(); + + EXPECT_FALSE(hasStartedTask(decision, RenderTaskType::LowResolutionPreview)); + EXPECT_TRUE(hasCommand(decision, RenderCommandType::KeepCurrentFrame)); + } + + TEST(RenderUpdateCoordinator, StaticCatchUp_AfterFastProgressiveSample_StartsStaticFullFrame) + { + RenderUpdateCoordinator coordinator; + auto decision = coordinator.configureViewport(640, 480); + auto bbox = findStartedTask(decision, RenderTaskType::BoundingBoxUpdate); + ASSERT_TRUE(bbox.has_value()); + + decision = coordinator.completeTask(completed(*bbox)); + auto sdf = findStartedTask(decision, RenderTaskType::SdfPrecomputation); + ASSERT_TRUE(sdf.has_value()); + + decision = coordinator.completeTask(completed(*sdf)); + auto progressive = findStartedTask(decision, RenderTaskType::ProgressiveHighQualityChunk); + ASSERT_TRUE(progressive.has_value()); + + coordinator.recordStaticProgressiveSample(fullFrameSample(40.0f)); + + decision = coordinator.completeTask(completed(*progressive)); + + EXPECT_TRUE(hasStartedTask(decision, RenderTaskType::RealtimeFullFrame)); + EXPECT_FALSE(hasStartedTask(decision, RenderTaskType::ProgressiveHighQualityChunk)); + } + TEST(RenderUpdateCoordinator, CameraChanged_WithForcedRealtime_StartsRealtimeFullFrame) { RenderUpdateCoordinator coordinator; @@ -127,9 +165,9 @@ namespace gladius::ui::async_rendering::tests { RenderUpdateCoordinator coordinator; ASSERT_FALSE(coordinator.configureViewport(640, 480).commands.empty()); - coordinator.recordRealtimeSample(fullFrameSample(15.0f)); - coordinator.recordRealtimeSample(fullFrameSample(16.0f)); - coordinator.recordRealtimeSample(fullFrameSample(17.0f)); + coordinator.recordStaticFullFrameSample(fullFrameSample(15.0f)); + coordinator.recordStaticFullFrameSample(fullFrameSample(16.0f)); + coordinator.recordStaticFullFrameSample(fullFrameSample(17.0f)); auto const decision = coordinator.notifyCameraChanged(); @@ -141,9 +179,9 @@ namespace gladius::ui::async_rendering::tests { RenderUpdateCoordinator coordinator; ASSERT_FALSE(coordinator.configureViewport(640, 480).commands.empty()); - coordinator.recordRealtimeSample(fullFrameSample(15.0f)); - coordinator.recordRealtimeSample(fullFrameSample(16.0f)); - coordinator.recordRealtimeSample(fullFrameSample(17.0f)); + coordinator.recordStaticFullFrameSample(fullFrameSample(15.0f)); + coordinator.recordStaticFullFrameSample(fullFrameSample(16.0f)); + coordinator.recordStaticFullFrameSample(fullFrameSample(17.0f)); auto decision = coordinator.notifyCameraChanged(); ASSERT_TRUE(hasStartedTask(decision, RenderTaskType::RealtimeFullFrame)); @@ -159,9 +197,9 @@ namespace gladius::ui::async_rendering::tests { RenderUpdateCoordinator coordinator; ASSERT_FALSE(coordinator.configureViewport(640, 480).commands.empty()); - coordinator.recordRealtimeSample(fullFrameSample(15.0f)); - coordinator.recordRealtimeSample(fullFrameSample(16.0f)); - coordinator.recordRealtimeSample(fullFrameSample(17.0f)); + coordinator.recordStaticFullFrameSample(fullFrameSample(15.0f)); + coordinator.recordStaticFullFrameSample(fullFrameSample(16.0f)); + coordinator.recordStaticFullFrameSample(fullFrameSample(17.0f)); async_rendering::RealtimeRaymarchGuards guards{}; guards.hardBlocker = true; coordinator.setRealtimeGuards(guards); @@ -177,9 +215,9 @@ namespace gladius::ui::async_rendering::tests { RenderUpdateCoordinator coordinator; ASSERT_FALSE(coordinator.configureViewport(640, 480).commands.empty()); - coordinator.recordRealtimeSample(fullFrameSample(15.0f)); - coordinator.recordRealtimeSample(fullFrameSample(16.0f)); - coordinator.recordRealtimeSample(fullFrameSample(17.0f)); + coordinator.recordStaticFullFrameSample(fullFrameSample(15.0f)); + coordinator.recordStaticFullFrameSample(fullFrameSample(16.0f)); + coordinator.recordStaticFullFrameSample(fullFrameSample(17.0f)); auto decision = coordinator.notifyCameraChanged(); auto realtime = findStartedTask(decision, RenderTaskType::RealtimeFullFrame); @@ -199,9 +237,9 @@ namespace gladius::ui::async_rendering::tests { RenderUpdateCoordinator coordinator; ASSERT_FALSE(coordinator.configureViewport(640, 480).commands.empty()); - coordinator.recordRealtimeSample(fullFrameSample(15.0f)); - coordinator.recordRealtimeSample(fullFrameSample(16.0f)); - coordinator.recordRealtimeSample(fullFrameSample(17.0f)); + coordinator.recordStaticFullFrameSample(fullFrameSample(15.0f)); + coordinator.recordStaticFullFrameSample(fullFrameSample(16.0f)); + coordinator.recordStaticFullFrameSample(fullFrameSample(17.0f)); auto decision = coordinator.notifyCameraChanged(); auto realtime = findStartedTask(decision, RenderTaskType::RealtimeFullFrame); @@ -344,6 +382,33 @@ namespace gladius::ui::async_rendering::tests EXPECT_TRUE(matches(latestUpload->stamp, latest, RenderStampMask::heavyGeometryTask())); } + TEST(RenderUpdateCoordinator, ParameterChanged_AfterRealtimeLearning_ResetsToProgressiveStaticLearning) + { + RenderUpdateCoordinator coordinator; + coordinator.recordStaticFullFrameSample(fullFrameSample(15.0f)); + coordinator.recordStaticFullFrameSample(fullFrameSample(16.0f)); + coordinator.recordStaticFullFrameSample(fullFrameSample(17.0f)); + ASSERT_TRUE(coordinator.isRealtimeActive()); + + auto decision = coordinator.notifyParameterChanged(false); + auto upload = findStartedTask(decision, RenderTaskType::ParameterUpload); + ASSERT_TRUE(upload.has_value()); + EXPECT_FALSE(coordinator.isRealtimeActive()); + + decision = coordinator.completeTask(completed(*upload)); + auto bbox = findStartedTask(decision, RenderTaskType::BoundingBoxUpdate); + ASSERT_TRUE(bbox.has_value()); + + decision = coordinator.completeTask(completed(*bbox)); + auto sdf = findStartedTask(decision, RenderTaskType::SdfPrecomputation); + ASSERT_TRUE(sdf.has_value()); + + decision = coordinator.completeTask(completed(*sdf)); + + EXPECT_TRUE(hasStartedTask(decision, RenderTaskType::ProgressiveHighQualityChunk)); + EXPECT_FALSE(hasStartedTask(decision, RenderTaskType::RealtimeFullFrame)); + } + TEST(RenderUpdateCoordinator, StaticCatchUp_AfterParameterEdit_SequencesUploadBboxSdfThenHq) { RenderUpdateCoordinator coordinator; From 96fc95397383713cb414c98b67eab5de8fb333b3 Mon Sep 17 00:00:00 2001 From: Jan Orend <56254096+3dJan@users.noreply.github.com> Date: Thu, 14 May 2026 17:43:38 +0200 Subject: [PATCH 10/72] Fix UI preview and Auto realtime jitter during camera evaluation gaps --- gladius/src/ui/RenderWindow.cpp | 30 ++++++++++++++----- gladius/src/ui/RenderWindow.h | 1 + .../ui/render/RealtimeRaymarchController.cpp | 20 +++++++++++++ .../src/ui/render/RenderUpdateCoordinator.h | 2 ++ 4 files changed, 46 insertions(+), 7 deletions(-) diff --git a/gladius/src/ui/RenderWindow.cpp b/gladius/src/ui/RenderWindow.cpp index 7062ae29..c2b608c6 100644 --- a/gladius/src/ui/RenderWindow.cpp +++ b/gladius/src/ui/RenderWindow.cpp @@ -291,7 +291,6 @@ namespace gladius::ui ProfileFunction; m_dirty = true; m_renderWindowState.isMoving = true; - [[maybe_unused]] bool const cameraChanged = m_camera.update(0.0f); m_renderWindowState.currentLine = 0; m_renderWindowState.renderingStepSize = kInitialProgressiveStepSize; m_renderWindowState.isRendering = false; @@ -503,8 +502,7 @@ namespace gladius::ui { return m_renderUpdateCoordinator.interactionState() == async_rendering::RenderInteractionState::CameraInteracting && - (m_renderUpdateCoordinator.isRealtimeActive() || - m_asyncRealtimeJobInFlight.load(std::memory_order_acquire)); + m_renderUpdateCoordinator.isRealtimeActive(); } bool RenderWindow::scheduleAsyncSdfPrecomputation( @@ -949,7 +947,15 @@ namespace gladius::ui // Update camera and set isMoving based on whether camera is currently animating. // Note: Parameter changes use forceLowResRender and lowResFeedbackPending flags, // not isMoving. isMoving is specifically for camera movement. - m_renderWindowState.isMoving = m_camera.update(io.DeltaTime * 1000.f); + bool const cameraActuallyMoving = m_camera.update(io.DeltaTime * 1000.f); + if (cameraActuallyMoving) { + m_cameraIdleFrames = 0; + m_renderWindowState.isMoving = true; + } else { + m_cameraIdleFrames++; + // Keep isMoving true for 10 frames after last movement to debounce fast refresh rates + m_renderWindowState.isMoving = m_cameraIdleFrames < 10; + } m_dirty = m_dirty || m_renderWindowState.isMoving; // Floating cut-height slider overlay (inside the Preview window) @@ -2521,9 +2527,9 @@ namespace gladius::ui // Stale scene/view results are deliberately discarded before promotion; otherwise // a late realtime frame could replace a valid front buffer with an old camera or // parameter state. - bool const allowForcedRealtimeStaleView = - isRealtimeJob && m_renderUpdateCoordinator.realtimeConfig().mode == - async_rendering::RealtimeRaymarchMode::Force; + // Realtime raymarching frames (both Auto and Force) need to be displayed even if + // the view epoch has incremented, otherwise continuous camera motion freezes. + bool const allowForcedRealtimeStaleView = isRealtimeJob; bool const discardForStaleView = isViewOutdated && !allowForcedRealtimeStaleView; if (result.completedFrame) @@ -3974,6 +3980,9 @@ namespace gladius::ui // Skip cancelled results if (meta.cancelled) { + if (auto logger = m_core->getSharedLogger()) { + logger->logInfo("[Preview] Cancelled preview skipped."); + } ZoneText("CancelledPreviewSkipped", 23); m_streamingFrameConsumed.store(true, std::memory_order_release); completeCoordinatorPreviewTask(meta); @@ -3989,6 +3998,10 @@ namespace gladius::ui auto resultImage = m_core->getResultImage(); auto renderProgram = m_core->tryGetBestRenderProgram().value_or(SharedRenderProgram{}); bool scheduleAdaptivePreview = false; + + if (auto logger = m_core->getSharedLogger()) { + logger->logInfo(std::string("[Preview] Result Consumed, Latency ") + std::to_string(meta.latencyNs / 1000000) + "ms. Resampling!"); + } if (lowResImage && resultImage && renderProgram) { @@ -4076,6 +4089,9 @@ namespace gladius::ui m_lastLowResRenderTime = std::chrono::system_clock::now(); m_lastLowResPreviewEpoch.store(meta.epoch, std::memory_order_release); completeCoordinatorPreviewTask(meta); + if (auto logger = m_core->getSharedLogger()) { + logger->logInfo("[Preview] Task Complete triggered!"); + } if (scheduleAdaptivePreview) { diff --git a/gladius/src/ui/RenderWindow.h b/gladius/src/ui/RenderWindow.h index f54bffb6..c393fb7f 100644 --- a/gladius/src/ui/RenderWindow.h +++ b/gladius/src/ui/RenderWindow.h @@ -262,6 +262,7 @@ namespace gladius::ui RenderWindowState m_renderWindowState{}; bool m_centerViewRequested = false; + int m_cameraIdleFrames{0}; bool m_enableHQRendering = true; ImVec2 m_contentAreaMin; diff --git a/gladius/src/ui/render/RealtimeRaymarchController.cpp b/gladius/src/ui/render/RealtimeRaymarchController.cpp index 33e80a17..52c627c9 100644 --- a/gladius/src/ui/render/RealtimeRaymarchController.cpp +++ b/gladius/src/ui/render/RealtimeRaymarchController.cpp @@ -4,6 +4,7 @@ #include #include +#include namespace gladius::ui::async_rendering { namespace @@ -128,10 +129,15 @@ namespace gladius::ui::async_rendering m_fastSampleStreak = m_config.requiredFastSamples; m_realtimeActive = m_config.mode == RealtimeRaymarchMode::Auto; m_staticFullFramePreferred = true; + std::cout << "[RT Auto] resetForResolution: active=" << m_realtimeActive + << " staticFullFrame=" << m_staticFullFramePreferred + << " est=" << scaledEstimate << " enterBudg=" << enterBudget << "\n"; } else if (scaledEstimate <= m_config.staticFullFrameBudgetMs) { m_staticFullFramePreferred = true; + std::cout << "[RT Auto] resetForResolution: staticFullFramePreferred=true" + << " est=" << scaledEstimate << "\n"; } } @@ -170,8 +176,13 @@ namespace gladius::ui::async_rendering m_staticFullFramePreferred = *estimatedMs <= m_config.staticFullFrameBudgetMs; if (m_staticFullFramePreferred) { + std::cout << "[RT Auto] Progressive sample set staticFullFrame=true, est=" << *estimatedMs << "\n"; m_slowSampleStreak = 0; } + else + { + std::cout << "[RT Auto] Progressive sample est=" << *estimatedMs << " > " << m_config.staticFullFrameBudgetMs << " (staticFullFrame=false)\n"; + } } void RealtimeRaymarchController::recordStaticFullFrameSample(RealtimeRaymarchSample const & sample) @@ -204,14 +215,17 @@ namespace gladius::ui::async_rendering { ++m_fastSampleStreak; m_slowSampleStreak = 0; + std::cout << "[RT Auto] Static FullFrame sample FAST, streak=" << m_fastSampleStreak << ", est=" << *estimatedMs << "\n"; if (m_fastSampleStreak >= m_config.requiredFastSamples) { + if (!m_realtimeActive) std::cout << "[RT Auto] Realtime ACTIVATED\n"; m_realtimeActive = true; } return; } m_fastSampleStreak = 0; + std::cout << "[RT Auto] Static FullFrame sample est=" << *estimatedMs << ", realtimeDropBudget=" << m_config.realtimeDropBudgetMs << "\n"; if (*estimatedMs > m_config.realtimeDropBudgetMs) { deactivateRealtime(); @@ -244,6 +258,7 @@ namespace gladius::ui::async_rendering recordEstimatedTime(*estimatedMs); if (*estimatedMs > m_config.realtimeDropBudgetMs) { + std::cout << "[RT Auto] Interactive sample SLOW est=" << *estimatedMs << ", dropping realtime\n"; deactivateRealtime(); } } @@ -255,6 +270,7 @@ namespace gladius::ui::async_rendering ++m_slowSampleStreak; if (m_slowSampleStreak >= m_config.maxSlowSamples) { + std::cout << "[RT Auto] Rejected attempt max streak reached, entering cooldown\n"; enterCooldown(); } } @@ -398,6 +414,10 @@ namespace gladius::ui::async_rendering void RealtimeRaymarchController::deactivateRealtime() { + if (m_realtimeActive || m_staticFullFramePreferred) + { + std::cout << "[RT Auto] Dropping realtime / static preferred\n"; + } m_realtimeActive = false; m_fastSampleStreak = 0; m_staticFullFramePreferred = false; diff --git a/gladius/src/ui/render/RenderUpdateCoordinator.h b/gladius/src/ui/render/RenderUpdateCoordinator.h index ec86e040..c1a3c4e5 100644 --- a/gladius/src/ui/render/RenderUpdateCoordinator.h +++ b/gladius/src/ui/render/RenderUpdateCoordinator.h @@ -4,6 +4,7 @@ #include "RenderUpdateTypes.h" #include +#include #include #include #include @@ -391,6 +392,7 @@ namespace gladius::ui::async_rendering void resetRealtimeLearning() { + std::cout << "[RT Coord] resetRealtimeLearning (Parameter/Scene/Resolution changed)\n"; m_realtime.reset(); m_realtime.resetForResolution(m_width, m_height); } From df58a782122da7fdca33d192af0641e8f1bf4186 Mon Sep 17 00:00:00 2001 From: Jan Orend <56254096+3dJan@users.noreply.github.com> Date: Thu, 14 May 2026 21:27:48 +0200 Subject: [PATCH 11/72] Bug fix for AsyncRenderController --- gladius/src/ui/RenderWindow.cpp | 28 +++++---- .../src/ui/render/AsyncRenderController.cpp | 60 +++++++++++++++++-- gladius/src/ui/render/AsyncRenderController.h | 7 ++- 3 files changed, 75 insertions(+), 20 deletions(-) diff --git a/gladius/src/ui/RenderWindow.cpp b/gladius/src/ui/RenderWindow.cpp index c2b608c6..e8dd734b 100644 --- a/gladius/src/ui/RenderWindow.cpp +++ b/gladius/src/ui/RenderWindow.cpp @@ -306,7 +306,11 @@ namespace gladius::ui m_lowResFeedbackPending.store(false, std::memory_order_release); } m_suppressHQDisplay.store(false, std::memory_order_release); - m_asyncViewEpoch.fetch_add(1, std::memory_order_acq_rel); + auto const newViewEpoch = m_asyncViewEpoch.fetch_add(1, std::memory_order_acq_rel) + 1; + if (m_asyncController) + { + m_asyncController->setLatestViewEpoch(newViewEpoch); + } queueRenderDecision(m_renderUpdateCoordinator.notifyCameraChanged()); if (m_core) @@ -2817,10 +2821,9 @@ namespace gladius::ui tracy::SetThreadName("AsyncRenderWorker"); #endif - auto * workerQueue = (m_asyncController ? m_asyncController->workerQueue() : nullptr); + auto queueOwner = (m_asyncController ? m_asyncController->workerQueueShared() : nullptr); auto const & commandQueue = - workerQueue != nullptr ? *workerQueue : m_core->getComputeContext()->GetQueue(); - if (workerQueue == nullptr) {} + queueOwner ? *queueOwner : m_core->getComputeContext()->GetQueue(); auto const cancellationRequested = [&]() -> bool { return cancelCheck && cancelCheck(); }; @@ -3586,10 +3589,9 @@ namespace gladius::ui try { // Get worker queue (or fallback to main queue) - auto * workerQueue = (m_asyncController ? m_asyncController->workerQueue() : nullptr); - - cl::CommandQueue const * queuePtr = - workerQueue != nullptr ? workerQueue : &m_core->getComputeContext()->GetQueue(); + auto queueOwner = (m_asyncController ? m_asyncController->workerQueueShared() : nullptr); + cl::CommandQueue const & queueRef = + queueOwner ? *queueOwner : m_core->getComputeContext()->GetQueue(); // Acquire compute token non-blockingly to serialize with refreshWorker(). // refreshWorker() holds the mutex while it rebuilds CL programs and @@ -3604,7 +3606,7 @@ namespace gladius::ui } // Launch async SDF precomputation (returns cl::Event) - cl::Event sdfEvent = m_core->precomputeSdfAsync(*queuePtr); + cl::Event sdfEvent = m_core->precomputeSdfAsync(queueRef); // Release the compute token — the kernel is already enqueued and the // CL runtime retains its own references to the memory objects. @@ -3782,9 +3784,9 @@ namespace gladius::ui using namespace async_rendering; - auto * workerQueue = (m_asyncController ? m_asyncController->workerQueue() : nullptr); + auto queueOwner = (m_asyncController ? m_asyncController->workerQueueShared() : nullptr); auto const & commandQueue = - workerQueue != nullptr ? *workerQueue : m_core->getComputeContext()->GetQueue(); + queueOwner ? *queueOwner : m_core->getComputeContext()->GetQueue(); FrameResultMeta result{}; result.frameId = job.frameHint; @@ -4230,9 +4232,9 @@ namespace gladius::ui #endif using namespace async_rendering; - auto * workerQueue = (m_asyncController ? m_asyncController->workerQueue() : nullptr); + auto queueOwner = (m_asyncController ? m_asyncController->workerQueueShared() : nullptr); auto const & commandQueue = - workerQueue != nullptr ? *workerQueue : m_core->getComputeContext()->GetQueue(); + queueOwner ? *queueOwner : m_core->getComputeContext()->GetQueue(); FrameResultMeta result{}; result.epoch = job.epoch; diff --git a/gladius/src/ui/render/AsyncRenderController.cpp b/gladius/src/ui/render/AsyncRenderController.cpp index 2cd630ac..8793208e 100644 --- a/gladius/src/ui/render/AsyncRenderController.cpp +++ b/gladius/src/ui/render/AsyncRenderController.cpp @@ -45,6 +45,7 @@ namespace gladius::ui::async_rendering coro::queue jobQueue; std::atomic shutdownRequested{false}; std::atomic latestEpoch{0}; + std::atomic latestViewEpoch{0}; AsyncRenderController::JobExecutor jobExecutor; std::mutex resultMutex; @@ -133,6 +134,7 @@ namespace gladius::ui::async_rendering m_state->data.shutdownRequested.store(false, std::memory_order_release); m_state->data.latestEpoch.store(0, std::memory_order_release); + m_state->data.latestViewEpoch.store(0, std::memory_order_release); { std::lock_guard resultLock(m_state->data.resultMutex); m_state->data.pendingResult.reset(); @@ -199,6 +201,22 @@ namespace gladius::ui::async_rendering } } + void AsyncRenderController::setLatestViewEpoch(uint64_t viewEpoch) + { + if (!m_state) + { + return; + } + + auto & latestViewEpoch = m_state->data.latestViewEpoch; + auto current = latestViewEpoch.load(std::memory_order_acquire); + while (viewEpoch > current && + !latestViewEpoch.compare_exchange_weak( + current, viewEpoch, std::memory_order_acq_rel, std::memory_order_acquire)) + { + } + } + void AsyncRenderController::setJobExecutor(JobExecutor executor) { if (!m_state) @@ -298,19 +316,43 @@ namespace gladius::ui::async_rendering // (the UI thread). Without this, the entire job runs on the UI thread, blocking it. co_await data->workerPool->schedule(); - auto cancellationCheck = [data, jobEpoch = job.epoch]() -> bool + // Pre-execution epoch-only check: safe to skip without storing a result because + // notifyAsyncEpochIncrement() clears the in-flight flags on epoch changes. + // View-epoch changes must NOT skip here — they need the executor to store a + // cancelled result so processAsyncResults() can clear m_asyncJobInFlight. + auto const epochOnlyCheck = [data, jobEpoch = job.epoch]() -> bool { return data->shutdownRequested.load(std::memory_order_acquire) || data->latestEpoch.load(std::memory_order_acquire) > jobEpoch; }; + // In-execution check: also cancels HQ progressive jobs when the view epoch advances + // (camera moved), so the GPU is released quickly for realtime rendering. + // Realtime jobs are intentionally excluded: they complete fast and are always shown + // even with a stale view (allowForcedRealtimeStaleView = true). + bool const isHqProgressiveJob = (job.type == RenderJobType::HighQuality); + auto cancellationCheck = [data, + jobEpoch = job.epoch, + jobViewEpoch = job.viewEpoch, + isHqProgressiveJob]() -> bool + { + if (data->shutdownRequested.load(std::memory_order_acquire)) + return true; + if (data->latestEpoch.load(std::memory_order_acquire) > jobEpoch) + return true; + if (isHqProgressiveJob && jobViewEpoch > 0 && + data->latestViewEpoch.load(std::memory_order_acquire) > jobViewEpoch) + return true; + return false; + }; + // Preview/streaming jobs must not be skipped here — their executors // manage m_asyncPreviewJobInFlight and m_streamingJobInFlight flags // that are only cleared inside the executor. Skipping the job would // leave those flags stuck at true, preventing any future scheduling. bool const isPreviewJob = (job.type == RenderJobType::LowResPreview || job.type == RenderJobType::StreamingPreview); - if (!isPreviewJob && cancellationCheck()) + if (!isPreviewJob && epochOnlyCheck()) { continue; } @@ -358,10 +400,10 @@ namespace gladius::ui::async_rendering !m_stagingBuffer) { m_stagingBuffer.reset(); - m_workerQueue.reset(); - - // Create dedicated worker command queue (no GL interop needed) - m_workerQueue = std::make_unique(context.createQueue()); + { + std::lock_guard queueLock(m_workerQueueMutex); + m_workerQueue = std::make_shared(context.createQueue()); + } // Create CL-only staging buffer for async rendering static cl::ImageFormat const format = {CL_RGBA, CL_FLOAT}; @@ -403,6 +445,12 @@ namespace gladius::ui::async_rendering return m_workerQueue.get(); } + std::shared_ptr AsyncRenderController::workerQueueShared() const noexcept + { + std::lock_guard lock(m_workerQueueMutex); + return m_workerQueue; + } + cl::Image2D * AsyncRenderController::stagingBuffer() noexcept { return m_stagingBuffer.get(); diff --git a/gladius/src/ui/render/AsyncRenderController.h b/gladius/src/ui/render/AsyncRenderController.h index 0efc8e02..d7cc1d03 100644 --- a/gladius/src/ui/render/AsyncRenderController.h +++ b/gladius/src/ui/render/AsyncRenderController.h @@ -82,6 +82,7 @@ namespace gladius::ui::async_rendering void enqueueJob(RenderJob job); void setLatestEpoch(uint64_t epoch); + void setLatestViewEpoch(uint64_t viewEpoch); void setJobExecutor(JobExecutor executor); [[nodiscard]] std::optional tryConsumeResult(); @@ -93,6 +94,9 @@ namespace gladius::ui::async_rendering /// Get worker-specific command queue (used by worker thread for rendering) [[nodiscard]] cl::CommandQueue * workerQueue() noexcept; + /// Thread-safe snapshot — safe to call from worker threads concurrently with + /// initializeAsyncResources(). Holds the queue alive for the caller's lifetime. + [[nodiscard]] std::shared_ptr workerQueueShared() const noexcept; /// Get staging buffer (used by worker thread for rendering output) [[nodiscard]] cl::Image2D * stagingBuffer() noexcept; @@ -173,7 +177,8 @@ namespace gladius::ui::async_rendering std::atomic m_running{false}; // OpenCL resources for async rendering (Option A: separate CL queue, no GL interop) - std::unique_ptr m_workerQueue; + mutable std::mutex m_workerQueueMutex; + std::shared_ptr m_workerQueue; std::unique_ptr m_stagingBuffer; size_t m_stagingWidth{0}; size_t m_stagingHeight{0}; From 6c297dbff9912b10a519d0983344848fb6d270d3 Mon Sep 17 00:00:00 2001 From: Jan Orend <56254096+3dJan@users.noreply.github.com> Date: Fri, 15 May 2026 15:21:40 +0200 Subject: [PATCH 12/72] updating spec for render update behavior --- gladius/src/ui/MainWindow.cpp | 9 +- gladius/src/ui/RenderWindow.cpp | 82 ++++-- gladius/src/ui/RenderWindow.h | 2 + .../ui/render/RealtimeRaymarchController.cpp | 20 -- .../ui/render/RealtimeRaymarchController.h | 2 +- .../src/ui/render/RenderUpdateCoordinator.h | 70 ++++- .../render/RenderUpdateCoordinator_tests.cpp | 56 ++-- specs/029-render-mode-behavior/spec.md | 275 ++++++++++++++++++ specs/029-render-mode-behavior/userspec.md | 89 ++++++ 9 files changed, 530 insertions(+), 75 deletions(-) create mode 100644 specs/029-render-mode-behavior/spec.md create mode 100644 specs/029-render-mode-behavior/userspec.md diff --git a/gladius/src/ui/MainWindow.cpp b/gladius/src/ui/MainWindow.cpp index e45acfd8..3158f603 100644 --- a/gladius/src/ui/MainWindow.cpp +++ b/gladius/src/ui/MainWindow.cpp @@ -2721,14 +2721,21 @@ namespace gladius::ui // bypass the throttle — the debounce already elapsed, we just need to retry. if (m_parameterThrottle.shouldRecompile() || !m_parameterThrottle.hasPendingRecompile()) { + bool const useStreamingPreview = !m_renderWindow.isForceRealtimeMode(); + // Streaming preview mode: the worker coroutine becomes the sole GPU // writer, reading the latest Assembly values and pushing them to the // parameter buffer in a tight loop. This eliminates the UI-frame // round-trip latency that limits one-shot preview scheduling. + // In Force realtime mode, parameter changes should be served by exact + // full-frame raymarching instead of the low-res streaming preview. if (!m_renderWindow.isStreamingPreviewActive()) { m_renderWindow.invalidateViewDueToParameterChange(); - m_renderWindow.startStreamingPreview(); + if (useStreamingPreview) + { + m_renderWindow.startStreamingPreview(); + } } m_parameterDirty = false; m_contoursDirty = true; diff --git a/gladius/src/ui/RenderWindow.cpp b/gladius/src/ui/RenderWindow.cpp index e8dd734b..885bb5e5 100644 --- a/gladius/src/ui/RenderWindow.cpp +++ b/gladius/src/ui/RenderWindow.cpp @@ -290,6 +290,11 @@ namespace gladius::ui { ProfileFunction; m_dirty = true; + // Camera interaction supersedes parameter streaming. Leaving the streaming flag + // latched blocks realtime admission and makes Force mode hold a stale frame even + // when no exact realtime job is actually in flight. + m_streamingPreviewActive.store(false, std::memory_order_release); + m_streamingFrameConsumed.store(true, std::memory_order_release); m_renderWindowState.isMoving = true; m_renderWindowState.currentLine = 0; m_renderWindowState.renderingStepSize = kInitialProgressiveStepSize; @@ -505,8 +510,14 @@ namespace gladius::ui bool RenderWindow::isRealtimeRaymarchCameraInteraction() const noexcept { return m_renderUpdateCoordinator.interactionState() == - async_rendering::RenderInteractionState::CameraInteracting && - m_renderUpdateCoordinator.isRealtimeActive(); + async_rendering::RenderInteractionState::CameraInteracting && + m_asyncRealtimeJobInFlight.load(std::memory_order_acquire); + } + + bool RenderWindow::isForceRealtimeMode() const noexcept + { + return m_renderUpdateCoordinator.realtimeConfig().mode == + async_rendering::RealtimeRaymarchMode::Force; } bool RenderWindow::scheduleAsyncSdfPrecomputation( @@ -659,11 +670,12 @@ namespace gladius::ui // which is updated synchronously and always shows *something* recent. // Exact realtime camera motion must not fall back to that preview texture. bool const realtimeCameraInteraction = isRealtimeRaymarchCameraInteraction(); - bool const realtimeRaymarchActive = m_renderUpdateCoordinator.isRealtimeActive(); + bool const exactRealtimeJobInFlight = + m_asyncRealtimeJobInFlight.load(std::memory_order_acquire); bool const epochMatches = frontBuf && frontBuf->epoch == currentEpoch; bool const viewMatches = frontBuf && frontBuf->viewEpoch == currentViewEpoch; bool const allowRealtimeFront = - (realtimeCameraInteraction || realtimeRaymarchActive) && epochMatches; + (realtimeCameraInteraction || exactRealtimeJobInFlight) && epochMatches; bool const frontBlockedByRendering = m_renderWindowState.isRendering && !allowRealtimeFront; bool const useFrontBuffer = frontBuf && frontBuf->image && epochMatches && @@ -1130,8 +1142,24 @@ namespace gladius::ui m_core->setSdfValid(false); m_dirty = true; - m_forceLowResRenderOnNextFrame.store(true, std::memory_order_release); - m_lastLowResRenderTime = std::chrono::system_clock::now(); + + // In realtime mode, mirror invalidateCameraView(): skip the legacy low-res preview + // path entirely. The coordinator already schedules a RealtimeFullFrame, and + // m_forceLowResRenderOnNextFrame / m_lowResFeedbackPending would trigger the old + // voxel-SDF preview path, producing a stale flash instead of instant feedback. + bool const isRealtimeMode = + m_renderUpdateCoordinator.realtimeConfig().mode != + async_rendering::RealtimeRaymarchMode::Off; + m_forceLowResRenderOnNextFrame.store(!isRealtimeMode, std::memory_order_release); + if (isRealtimeMode) + { + m_lowResFeedbackPending.store(false, std::memory_order_release); + } + else + { + m_lastLowResRenderTime = std::chrono::system_clock::now(); + m_lowResFeedbackPending.store(true, std::memory_order_release); + } // Cancel any in-progress progressive render (stale parameters) m_renderWindowState.currentLine = 0; @@ -1140,7 +1168,6 @@ namespace gladius::ui m_preComputedSdfDirty.store(true, std::memory_order_release); m_parameterDirty.store(true, std::memory_order_release); - m_lowResFeedbackPending.store(true, std::memory_order_release); m_modelModifiedSinceLastCenter = true; m_asyncViewEpoch.fetch_add(1, std::memory_order_acq_rel); queueRenderDecision(m_renderUpdateCoordinator.notifyParameterChanged(true)); @@ -1997,7 +2024,22 @@ namespace gladius::ui !bboxStale || (std::chrono::steady_clock::now() - m_lastParameterChangeTime >= kBboxDebounceDelay); - if (sdfDirty && !sdfJobActive && sdfDebounceElapsed) + // Don't schedule SDF precomputation while the camera is actively moving in + // realtime mode: the realtime renderer evaluates SDF on-the-fly and doesn't + // need the precomputed version. Starting the SDF job now would set + // hardBlocker=true, preventing canAttemptRealtime() from returning true for + // the entire SDF compute duration. The coordinator will schedule SDF via + // scheduleStaticCatchUp() once notifyCameraInteractionEnded() fires. + bool const cameraInteracting = + m_renderUpdateCoordinator.interactionState() == + async_rendering::RenderInteractionState::CameraInteracting; + bool const isRealtimeForSdfGuard = + m_renderUpdateCoordinator.realtimeConfig().mode != + async_rendering::RealtimeRaymarchMode::Off; + bool const suppressSdfDuringCameraInteraction = + cameraInteracting && isRealtimeForSdfGuard; + + if (sdfDirty && !sdfJobActive && sdfDebounceElapsed && !suppressSdfDuringCameraInteraction) { async_rendering::RenderJob sdfJob{}; sdfJob.type = async_rendering::RenderJobType::SDFPrecomputation; @@ -2233,13 +2275,6 @@ namespace gladius::ui return; } - if (isRealtimeRaymarchCameraInteraction()) - { - m_lowResFeedbackPending.store(false, std::memory_order_release); - state.isRendering = m_asyncJobInFlight.load(std::memory_order_acquire); - return; - } - if (state.isMoving || forceLowResRender || lowResPending) { bool const hadActiveProgressive = @@ -3953,9 +3988,10 @@ namespace gladius::ui auto const currentViewEpoch = m_asyncViewEpoch.load(std::memory_order_acquire); bool const staleForCurrentView = meta.epoch < currentEpoch || (meta.viewEpoch != 0 && meta.viewEpoch < currentViewEpoch); + bool const exactRealtimeJobInFlight = + m_asyncRealtimeJobInFlight.load(std::memory_order_acquire); bool const suppressForRealtimeRaymarch = - isRealtimeRaymarchCameraInteraction() || - (m_renderUpdateCoordinator.isRealtimeActive() && staleForCurrentView); + exactRealtimeJobInFlight || staleForCurrentView; if (suppressForRealtimeRaymarch) { @@ -3967,6 +4003,18 @@ namespace gladius::ui return; } + // FR-026: Do not replace a higher-quality frame with a lower-quality preview. + // If a HQ/realtime frame for the current view is already displayed, suppress the preview. + if (m_renderUpdateCoordinator.isHighQualityFrameCurrent()) + { + ZoneText("PreviewSkippedForQualityRegression", 35); + m_streamingFrameConsumed.store(true, std::memory_order_release); + auto cancelledMeta = meta; + cancelledMeta.cancelled = true; + completeCoordinatorPreviewTask(cancelledMeta); + return; + } + // Skip frames that are older than what we've already displayed. // This ensures we never show an older frame after a newer one. // Use frameId (which is monotonically increasing) for ordering. diff --git a/gladius/src/ui/RenderWindow.h b/gladius/src/ui/RenderWindow.h index c393fb7f..b739c161 100644 --- a/gladius/src/ui/RenderWindow.h +++ b/gladius/src/ui/RenderWindow.h @@ -160,6 +160,8 @@ namespace gladius::ui */ [[nodiscard]] bool isCameraMoving() const; + [[nodiscard]] bool isForceRealtimeMode() const noexcept; + private: void render(RenderWindowState & state); void renderLoadingOverlay(); diff --git a/gladius/src/ui/render/RealtimeRaymarchController.cpp b/gladius/src/ui/render/RealtimeRaymarchController.cpp index 52c627c9..33e80a17 100644 --- a/gladius/src/ui/render/RealtimeRaymarchController.cpp +++ b/gladius/src/ui/render/RealtimeRaymarchController.cpp @@ -4,7 +4,6 @@ #include #include -#include namespace gladius::ui::async_rendering { namespace @@ -129,15 +128,10 @@ namespace gladius::ui::async_rendering m_fastSampleStreak = m_config.requiredFastSamples; m_realtimeActive = m_config.mode == RealtimeRaymarchMode::Auto; m_staticFullFramePreferred = true; - std::cout << "[RT Auto] resetForResolution: active=" << m_realtimeActive - << " staticFullFrame=" << m_staticFullFramePreferred - << " est=" << scaledEstimate << " enterBudg=" << enterBudget << "\n"; } else if (scaledEstimate <= m_config.staticFullFrameBudgetMs) { m_staticFullFramePreferred = true; - std::cout << "[RT Auto] resetForResolution: staticFullFramePreferred=true" - << " est=" << scaledEstimate << "\n"; } } @@ -176,13 +170,8 @@ namespace gladius::ui::async_rendering m_staticFullFramePreferred = *estimatedMs <= m_config.staticFullFrameBudgetMs; if (m_staticFullFramePreferred) { - std::cout << "[RT Auto] Progressive sample set staticFullFrame=true, est=" << *estimatedMs << "\n"; m_slowSampleStreak = 0; } - else - { - std::cout << "[RT Auto] Progressive sample est=" << *estimatedMs << " > " << m_config.staticFullFrameBudgetMs << " (staticFullFrame=false)\n"; - } } void RealtimeRaymarchController::recordStaticFullFrameSample(RealtimeRaymarchSample const & sample) @@ -215,17 +204,14 @@ namespace gladius::ui::async_rendering { ++m_fastSampleStreak; m_slowSampleStreak = 0; - std::cout << "[RT Auto] Static FullFrame sample FAST, streak=" << m_fastSampleStreak << ", est=" << *estimatedMs << "\n"; if (m_fastSampleStreak >= m_config.requiredFastSamples) { - if (!m_realtimeActive) std::cout << "[RT Auto] Realtime ACTIVATED\n"; m_realtimeActive = true; } return; } m_fastSampleStreak = 0; - std::cout << "[RT Auto] Static FullFrame sample est=" << *estimatedMs << ", realtimeDropBudget=" << m_config.realtimeDropBudgetMs << "\n"; if (*estimatedMs > m_config.realtimeDropBudgetMs) { deactivateRealtime(); @@ -258,7 +244,6 @@ namespace gladius::ui::async_rendering recordEstimatedTime(*estimatedMs); if (*estimatedMs > m_config.realtimeDropBudgetMs) { - std::cout << "[RT Auto] Interactive sample SLOW est=" << *estimatedMs << ", dropping realtime\n"; deactivateRealtime(); } } @@ -270,7 +255,6 @@ namespace gladius::ui::async_rendering ++m_slowSampleStreak; if (m_slowSampleStreak >= m_config.maxSlowSamples) { - std::cout << "[RT Auto] Rejected attempt max streak reached, entering cooldown\n"; enterCooldown(); } } @@ -414,10 +398,6 @@ namespace gladius::ui::async_rendering void RealtimeRaymarchController::deactivateRealtime() { - if (m_realtimeActive || m_staticFullFramePreferred) - { - std::cout << "[RT Auto] Dropping realtime / static preferred\n"; - } m_realtimeActive = false; m_fastSampleStreak = 0; m_staticFullFramePreferred = false; diff --git a/gladius/src/ui/render/RealtimeRaymarchController.h b/gladius/src/ui/render/RealtimeRaymarchController.h index bdd6ea70..6b6b859d 100644 --- a/gladius/src/ui/render/RealtimeRaymarchController.h +++ b/gladius/src/ui/render/RealtimeRaymarchController.h @@ -78,10 +78,10 @@ namespace gladius::ui::async_rendering [[nodiscard]] bool isCoolingDown() const noexcept; [[nodiscard]] std::optional estimatedFullFrameTimeMs() const noexcept; [[nodiscard]] char const * modeLabel() const noexcept; + [[nodiscard]] bool guardsAllowAttempt(RealtimeRaymarchGuards const & guards) const noexcept; private: [[nodiscard]] bool hasValidResolution(uint32_t width, uint32_t height) const noexcept; - [[nodiscard]] bool guardsAllowAttempt(RealtimeRaymarchGuards const & guards) const noexcept; [[nodiscard]] std::optional estimateSampleFullFrameTimeMs( RealtimeRaymarchSample const & sample) const noexcept; void recordEstimatedTime(float estimatedMs); diff --git a/gladius/src/ui/render/RenderUpdateCoordinator.h b/gladius/src/ui/render/RenderUpdateCoordinator.h index c1a3c4e5..a1666081 100644 --- a/gladius/src/ui/render/RenderUpdateCoordinator.h +++ b/gladius/src/ui/render/RenderUpdateCoordinator.h @@ -4,7 +4,6 @@ #include "RenderUpdateTypes.h" #include -#include #include #include #include @@ -123,6 +122,12 @@ namespace gladius::ui::async_rendering return isBoundingBoxCurrent() && isSdfCurrent(); } + [[nodiscard]] bool isHighQualityFrameCurrent() const noexcept + { + return m_highQualityFrameStamp.has_value() && + matches(*m_highQualityFrameStamp, m_latestStamp, RenderStampMask::displayFrame()); + } + [[nodiscard]] RenderUpdateDecision configureViewport(uint32_t width, uint32_t height) { RenderUpdateDecision decision{}; @@ -135,6 +140,7 @@ namespace gladius::ui::async_rendering m_height = height; ++m_latestStamp.viewportEpoch; m_realtime.resetForResolution(m_width, m_height); + m_staticHQRenderTimeMs = -1.0f; scheduleStaticCatchUp(decision); return decision; } @@ -155,6 +161,7 @@ namespace gladius::ui::async_rendering { m_interactionState = RenderInteractionState::Static; } + m_autoGestureLockedSimpler = false; scheduleStaticCatchUp(decision); return decision; } @@ -163,13 +170,15 @@ namespace gladius::ui::async_rendering { RenderUpdateDecision decision{}; ++m_latestStamp.parameterEpoch; - resetRealtimeLearning(); m_parameterUploadStamp.reset(); m_boundingBoxStamp.reset(); m_sdfStamp.reset(); m_interactionState = interactionActive ? RenderInteractionState::ParameterInteracting : RenderInteractionState::Static; - + if (!interactionActive) + { + resetRealtimeLearning(); + } startTask(decision, RenderTaskType::ParameterUpload, m_latestStamp); if (interactionActive) { @@ -186,6 +195,7 @@ namespace gladius::ui::async_rendering { RenderUpdateDecision decision{}; m_interactionState = RenderInteractionState::Static; + m_autoGestureLockedSimpler = false; scheduleStaticCatchUp(decision); return decision; } @@ -271,6 +281,13 @@ namespace gladius::ui::async_rendering result.type == RenderTaskType::RealtimeFullFrame)) { m_highQualityFrameStamp = result.stamp; + if (result.type == RenderTaskType::ProgressiveHighQualityChunk && + m_interactionState == RenderInteractionState::Static && + result.durationNs > 0) + { + m_staticHQRenderTimeMs = + static_cast(result.durationNs) / 1'000'000.0f; + } } decision.commands.push_back(RenderCommand{.type = RenderCommandType::PresentFrame, .result = result, @@ -348,12 +365,6 @@ namespace gladius::ui::async_rendering matches(*m_parameterUploadStamp, m_latestStamp, RenderStampMask::heavyGeometryTask()); } - [[nodiscard]] bool isHighQualityFrameCurrent() const noexcept - { - return m_highQualityFrameStamp.has_value() && - matches(*m_highQualityFrameStamp, m_latestStamp, RenderStampMask::displayFrame()); - } - void startTask(RenderUpdateDecision & decision, RenderTaskType type, RenderStamp const & stamp, @@ -392,9 +403,15 @@ namespace gladius::ui::async_rendering void resetRealtimeLearning() { - std::cout << "[RT Coord] resetRealtimeLearning (Parameter/Scene/Resolution changed)\n"; m_realtime.reset(); m_realtime.resetForResolution(m_width, m_height); + m_staticHQRenderTimeMs = -1.0f; + m_autoGestureLockedSimpler = false; + } + + [[nodiscard]] bool autoModeAdmitsRealtime() const noexcept + { + return m_staticHQRenderTimeMs >= 0.0f && m_staticHQRenderTimeMs < 100.0f; } void scheduleInteractiveFrame(RenderUpdateDecision & decision) @@ -411,25 +428,42 @@ namespace gladius::ui::async_rendering return; } - if (m_realtime.isRealtimeActive()) + // Auto mode: gate on measured static HQ time with per-gesture hysteresis + if (m_realtime.config().mode == RealtimeRaymarchMode::Auto) { - if (m_realtime.canAttemptRealtime(m_width, m_height, m_realtimeGuards)) + if (!m_autoGestureLockedSimpler && !autoModeAdmitsRealtime()) + { + m_autoGestureLockedSimpler = true; + } + if (m_autoGestureLockedSimpler) + { + startTask(decision, RenderTaskType::LowResolutionPreview, m_latestStamp); + return; + } + if (m_realtime.guardsAllowAttempt(m_realtimeGuards)) { startTask(decision, RenderTaskType::RealtimeFullFrame, m_latestStamp); return; } - + // Guards transiently blocked — hold the current frame (FR-026: no quality regression) keepCurrentFrame(decision); return; } + // Off mode: always preview + if (m_realtime.config().mode == RealtimeRaymarchMode::Off) + { + startTask(decision, RenderTaskType::LowResolutionPreview, m_latestStamp); + return; + } + + // Force mode: prefer exact realtime; hold current frame if guards temporarily block (FR-014) if (m_realtime.canAttemptRealtime(m_width, m_height, m_realtimeGuards)) { startTask(decision, RenderTaskType::RealtimeFullFrame, m_latestStamp); return; } - - startTask(decision, RenderTaskType::LowResolutionPreview, m_latestStamp); + keepCurrentFrame(decision); } void scheduleStaticCatchUp(RenderUpdateDecision & decision) @@ -504,5 +538,11 @@ namespace gladius::ui::async_rendering uint64_t m_nextRequestId{1}; uint32_t m_width{1}; uint32_t m_height{1}; + /// Wall-clock duration of the last completed static progressive HQ render in milliseconds. + /// Negative means not yet measured. Used by Auto mode to gate exact-realtime admission. + float m_staticHQRenderTimeMs{-1.0f}; + /// Per-gesture latch: set when Auto mode first decides to use simpler rendering during + /// an interaction. Cleared when the gesture ends to prevent mid-gesture quality switches. + bool m_autoGestureLockedSimpler{false}; }; } diff --git a/gladius/tests/unittests/ui/render/RenderUpdateCoordinator_tests.cpp b/gladius/tests/unittests/ui/render/RenderUpdateCoordinator_tests.cpp index 7deb5873..482d33e8 100644 --- a/gladius/tests/unittests/ui/render/RenderUpdateCoordinator_tests.cpp +++ b/gladius/tests/unittests/ui/render/RenderUpdateCoordinator_tests.cpp @@ -46,7 +46,7 @@ namespace gladius::ui::async_rendering::tests .type = request.type, .stamp = request.stamp, .status = RenderTaskStatus::Completed, - .durationNs = 1'000'000, + .durationNs = 200'000'000, .producedDisplayFrame = producedDisplayFrame, .completedFrame = completedFrame}; } @@ -75,6 +75,25 @@ namespace gladius::ui::async_rendering::tests .completedFrame = true, .cancelled = false}; } + + /// Drives the coordinator through the static pipeline until a ProgressiveHighQualityChunk + /// completes with a duration under the 100ms Auto-mode threshold. + /// Call immediately after configureViewport(), passing its returned decision. + void primeAutoRealtimeAdmission(RenderUpdateCoordinator & coordinator, + RenderUpdateDecision const & viewportDecision) + { + auto bbox = findStartedTask(viewportDecision, RenderTaskType::BoundingBoxUpdate); + ASSERT_TRUE(bbox.has_value()); + auto decision = coordinator.completeTask(completed(*bbox)); + auto sdf = findStartedTask(decision, RenderTaskType::SdfPrecomputation); + ASSERT_TRUE(sdf.has_value()); + decision = coordinator.completeTask(completed(*sdf)); + auto hq = findStartedTask(decision, RenderTaskType::ProgressiveHighQualityChunk); + ASSERT_TRUE(hq.has_value()); + auto hqResult = completed(*hq, true, true); + hqResult.durationNs = 50'000'000; // 50ms < 100ms → autoModeAdmitsRealtime() = true + coordinator.completeTask(hqResult); + } } TEST(RenderUpdateCoordinator, CameraChanged_WithAutoLearning_StartsLowResolutionPreview) @@ -164,10 +183,9 @@ namespace gladius::ui::async_rendering::tests TEST(RenderUpdateCoordinator, CameraChanged_WithAutoRealtimeAfterFastSamples_StartsRealtimeFullFrame) { RenderUpdateCoordinator coordinator; - ASSERT_FALSE(coordinator.configureViewport(640, 480).commands.empty()); - coordinator.recordStaticFullFrameSample(fullFrameSample(15.0f)); - coordinator.recordStaticFullFrameSample(fullFrameSample(16.0f)); - coordinator.recordStaticFullFrameSample(fullFrameSample(17.0f)); + auto const viewportDecision = coordinator.configureViewport(640, 480); + ASSERT_FALSE(viewportDecision.commands.empty()); + primeAutoRealtimeAdmission(coordinator, viewportDecision); auto const decision = coordinator.notifyCameraChanged(); @@ -178,10 +196,9 @@ namespace gladius::ui::async_rendering::tests TEST(RenderUpdateCoordinator, CameraChanged_WithAutoRealtimeInFlight_KeepsCurrentFrameWithoutPreview) { RenderUpdateCoordinator coordinator; - ASSERT_FALSE(coordinator.configureViewport(640, 480).commands.empty()); - coordinator.recordStaticFullFrameSample(fullFrameSample(15.0f)); - coordinator.recordStaticFullFrameSample(fullFrameSample(16.0f)); - coordinator.recordStaticFullFrameSample(fullFrameSample(17.0f)); + auto const viewportDecision = coordinator.configureViewport(640, 480); + ASSERT_FALSE(viewportDecision.commands.empty()); + primeAutoRealtimeAdmission(coordinator, viewportDecision); auto decision = coordinator.notifyCameraChanged(); ASSERT_TRUE(hasStartedTask(decision, RenderTaskType::RealtimeFullFrame)); @@ -196,10 +213,9 @@ namespace gladius::ui::async_rendering::tests TEST(RenderUpdateCoordinator, CameraChanged_WithAutoRealtimeGuardBlocker_KeepsCurrentFrame) { RenderUpdateCoordinator coordinator; - ASSERT_FALSE(coordinator.configureViewport(640, 480).commands.empty()); - coordinator.recordStaticFullFrameSample(fullFrameSample(15.0f)); - coordinator.recordStaticFullFrameSample(fullFrameSample(16.0f)); - coordinator.recordStaticFullFrameSample(fullFrameSample(17.0f)); + auto const viewportDecision = coordinator.configureViewport(640, 480); + ASSERT_FALSE(viewportDecision.commands.empty()); + primeAutoRealtimeAdmission(coordinator, viewportDecision); async_rendering::RealtimeRaymarchGuards guards{}; guards.hardBlocker = true; coordinator.setRealtimeGuards(guards); @@ -214,10 +230,9 @@ namespace gladius::ui::async_rendering::tests TEST(RenderUpdateCoordinator, CameraInteractionEnded_WithAutoRealtimeCurrentHq_KeepsCurrentFrame) { RenderUpdateCoordinator coordinator; - ASSERT_FALSE(coordinator.configureViewport(640, 480).commands.empty()); - coordinator.recordStaticFullFrameSample(fullFrameSample(15.0f)); - coordinator.recordStaticFullFrameSample(fullFrameSample(16.0f)); - coordinator.recordStaticFullFrameSample(fullFrameSample(17.0f)); + auto const viewportDecision = coordinator.configureViewport(640, 480); + ASSERT_FALSE(viewportDecision.commands.empty()); + primeAutoRealtimeAdmission(coordinator, viewportDecision); auto decision = coordinator.notifyCameraChanged(); auto realtime = findStartedTask(decision, RenderTaskType::RealtimeFullFrame); @@ -236,10 +251,9 @@ namespace gladius::ui::async_rendering::tests TEST(RenderUpdateCoordinator, CameraInteractionEnded_WithStaleAutoRealtime_StartsHqNotPreview) { RenderUpdateCoordinator coordinator; - ASSERT_FALSE(coordinator.configureViewport(640, 480).commands.empty()); - coordinator.recordStaticFullFrameSample(fullFrameSample(15.0f)); - coordinator.recordStaticFullFrameSample(fullFrameSample(16.0f)); - coordinator.recordStaticFullFrameSample(fullFrameSample(17.0f)); + auto const viewportDecision = coordinator.configureViewport(640, 480); + ASSERT_FALSE(viewportDecision.commands.empty()); + primeAutoRealtimeAdmission(coordinator, viewportDecision); auto decision = coordinator.notifyCameraChanged(); auto realtime = findStartedTask(decision, RenderTaskType::RealtimeFullFrame); diff --git a/specs/029-render-mode-behavior/spec.md b/specs/029-render-mode-behavior/spec.md new file mode 100644 index 00000000..0cff914c --- /dev/null +++ b/specs/029-render-mode-behavior/spec.md @@ -0,0 +1,275 @@ +# Feature Specification: Render Mode Behavior and Interaction State Semantics + +**Feature Branch**: `029-render-mode-behavior` +**Created**: 2026-05-14 +**Status**: Draft +**Input**: User description: "Based on the rendering-mode discussion in this session, define the behavior of the different modes, the expected state changes, and the intended user update experience." + +## Problem Statement + +The current rendering pipeline combines several update mechanisms: + +- exact full-frame realtime raymarching, +- one-shot low-resolution preview, +- streaming low-resolution preview during parameter editing, +- static catch-up work such as parameter upload, bounding-box update, SDF precomputation, and high-quality progressive rendering. + +During this session it became clear that the intended behavior of these mechanisms is not yet specified precisely enough. In particular, the system currently risks conflating: + +- the **configured mode** (`Off`, `Auto`, `Force`), +- the **current interaction state** (`Static`, `CameraInteracting`, `ParameterInteracting`), and +- the **currently active render path** (exact realtime, low-res preview, streaming preview, static catch-up). + +Without an explicit product-level contract, bugs emerge where: + +- a parameter edit briefly flashes a surrogate or stale low-resolution preview before the intended interactive frame arrives, +- camera interaction becomes blocked by a previously-started parameter-preview loop, +- stale preview results overwrite a newer camera frame, +- Force mode is treated as proof that exact realtime output is currently active even when no exact realtime frame is actually in flight. + +This specification defines the intended behavior of each mode, the valid state transitions, and the user-visible update experience. + +## Design Goals + +1. **Predictable mode semantics**: each rendering mode has a clear and testable contract. +2. **Responsive interaction**: camera and parameter edits must remain fluid and visually understandable. +3. **No stale-frame regressions**: old preview or HQ results must not replace fresher interactive output. +4. **Separation of policy and execution**: configured mode is a policy preference; actual in-flight jobs determine what is currently active. +5. **Graceful catch-up**: heavy background work should resume after interaction without visually disrupting the user. +6. **No quality regressions**: the viewport MUST NOT replace a currently-displayed frame with a lower-quality frame, even if the lower-quality frame is epoch-fresh. +7. **Stable Auto mode decisions**: once `Auto` mode commits to simpler rendering for a gesture, that decision is locked for the duration of the gesture to prevent mid-gesture quality oscillation. + +## User Scenarios & Testing *(mandatory)* + +### User Story 1 - Predictable Off Mode (Priority: P1) + +As a user who disables realtime raymarching, I want all interactive feedback to use the preview path consistently, so that performance and image behavior are predictable. + +**Why this priority**: Off mode is the explicit opt-out for exact realtime. It must be deterministic and easy to reason about. + +**Independent Test**: Set the render mode to `Off`, move the camera, drag a parameter slider, and verify that no exact realtime path is used. + +**Acceptance Scenarios**: + +1. **Given** mode is `Off`, **When** the user moves the camera, **Then** the system shows preview-quality interactive feedback and does not schedule exact full-frame realtime rendering. +2. **Given** mode is `Off`, **When** the user drags a parameter slider, **Then** the system uses low-resolution preview or streaming preview feedback and does not schedule exact full-frame realtime rendering. +3. **Given** mode is `Off`, **When** interaction ends, **Then** the system transitions to static catch-up and eventually produces an updated high-quality frame. + +--- + +### User Story 2 - Adaptive Auto Mode (Priority: P1) + +As a user in `Auto` mode, I want the system to use exact realtime only when it is safe and beneficial, so that I get the best possible responsiveness without visual instability. + +**Why this priority**: `Auto` is the default balancing mode between quality and responsiveness. + +**Independent Test**: Run the same interaction sequence on both a fast and a slow scene and verify that camera behavior adapts while parameter behavior stays stable. + +**Acceptance Scenarios**: + +1. **Given** mode is `Auto` and recent exact realtime samples are within budget, **When** the user moves the camera, **Then** the system may schedule exact full-frame realtime rendering. +2. **Given** mode is `Auto` and exact realtime is not admissible, **When** the user moves the camera, **Then** the system falls back to preview-quality interaction without blanking the viewport. +3. **Given** mode is `Auto`, **When** the user drags a parameter slider, **Then** the system prefers low-resolution or streaming preview feedback rather than exact full-frame realtime. +4. **Given** mode is `Auto`, **When** interaction ends, **Then** the system resumes static catch-up and may use exact full-frame rendering only as part of static evaluation if budgets allow. +5. **Given** mode is `Auto` and the system has fallen back to preview-quality during an active gesture, **When** the user continues the same gesture, **Then** the system MUST NOT switch back to exact realtime mid-gesture. +6. **Given** mode is `Auto` and the last completed static HQ render took less than 100ms, **When** the user starts an interaction, **Then** the system may use exact realtime. **Given** the last static HQ render took 100ms or more, **When** the user starts an interaction, **Then** the system MUST use simpler rendering. + +--- + +### User Story 3 - Exact Force Mode (Priority: P1) + +As a user in `Force` mode, I want camera and parameter interactions to prefer exact full-frame realtime output, so that what I see during interaction matches the final result as closely as possible. + +**Why this priority**: `Force` mode is the explicit request for exact interactive rendering. It must not silently degrade into a competing surrogate path unless exact rendering is truly unavailable. + +**Independent Test**: Set mode to `Force`, drag a parameter slider, then immediately move the camera. Verify that no low-resolution surrogate flash appears and the camera remains responsive. + +**Acceptance Scenarios**: + +1. **Given** mode is `Force`, **When** the user moves the camera, **Then** the system prefers exact full-frame realtime rendering on every eligible tick. +2. **Given** mode is `Force`, **When** the user changes a parameter, **Then** the system prefers exact full-frame realtime rendering and must not start the streaming preview path in parallel. +3. **Given** mode is `Force` and exact realtime cannot start immediately, **When** the user continues interacting, **Then** the last valid frame remains visible rather than flashing a low-resolution surrogate or blank frame. +4. **Given** mode is `Force`, **When** camera interaction begins while parameter preview work is still active, **Then** camera interaction preempts that preview work and becomes the controlling interaction path. + +--- + +### User Story 4 - Stable State Transitions (Priority: P1) + +As a user switching between editing and navigation, I want the preview to transition cleanly between interaction types and static catch-up, so that I never lose control or see stale frames from the wrong path. + +**Why this priority**: The highest-risk bugs in this session were transition bugs rather than pure rendering-speed problems. + +**Independent Test**: Repeatedly alternate between parameter drag, camera movement, and idle periods while observing whether stale low-res or HQ frames overwrite the current interaction. + +**Acceptance Scenarios**: + +1. **Given** a parameter interaction is active, **When** the user begins moving the camera, **Then** camera interaction supersedes parameter interaction for scheduling and presentation. +2. **Given** an interactive frame is visible, **When** background bbox or SDF catch-up starts, **Then** the current interactive frame remains visible until a newer valid frame is ready. +3. **Given** a preview result was produced for an older view or parameter epoch, **When** a newer interaction state exists, **Then** the stale result is discarded and never presented. +4. **Given** interaction ends, **When** static catch-up begins, **Then** the system eventually converges to a high-quality current frame without showing older interactive artifacts. + +--- + +### Edge Cases + +- What happens if camera interaction begins while a parameter streaming preview loop is active? → Camera interaction must preempt and deactivate the parameter streaming path. +- What happens if exact realtime is configured (`Force`) but an exact realtime job is not actually running? → The system must treat this as a policy preference, not as proof that exact realtime output is currently active. +- What happens if bbox or SDF catch-up becomes ready while the user is still interacting? → Catch-up work may continue in the background, but it must not override or freeze the active interaction path. +- What happens if low-resolution preview results arrive with a stale `viewEpoch`? → They must be discarded before presentation. +- What happens if exact realtime cannot currently be admitted in `Force` mode? → Keep the most recent valid frame visible and retry exact realtime on the next eligible tick; do not flash a surrogate frame by policy. + +## Mode Behavior Summary *(mandatory)* + +### Configured Modes + +| Mode | Camera Interaction | Parameter Interaction | Fallback Policy | Intended User Experience | +| --- | --- | --- | --- | --- | +| `Off` | Preview-quality only | Preview-quality only | Low-res/streaming preview | Stable, predictable surrogate interaction with HQ catch-up after idle | +| `Auto` | Exact realtime if last static HQ render < 100ms, else preview-quality; once fallen back to simpler during a gesture, stays simpler until gesture ends | Preview-quality preferred | Adaptive; hysteresis prevents mid-gesture quality oscillation | Best-effort responsiveness: quality adapts per scene speed, never switches back mid-gesture | +| `Force` | Exact full-frame realtime preferred | Exact full-frame realtime preferred | Keep current valid frame if exact path not yet ready | Exact interactive output whenever possible, never an intentional surrogate flash | + +### Interaction-State Matrix + +| Interaction State | Off | Auto | Force | +| --- | --- | --- | --- | +| `Static` | Static catch-up only | Static catch-up, optionally exact static full frame if admitted | Static catch-up, but interactive exact path takes priority whenever interaction resumes | +| `CameraInteracting` | Low-res preview | Exact realtime if admitted, else low-res preview | Exact realtime preferred; current valid frame held if admission is transiently blocked | +| `ParameterInteracting` | Low-res preview or streaming preview | Low-res preview or streaming preview | Exact realtime preferred; streaming preview must not be started | + +## State Model *(mandatory)* + +### Key Entities + +- **ConfiguredMode**: user-selected realtime behavior policy (`Off`, `Auto`, `Force`). +- **InteractionState**: semantic interaction owner (`Static`, `CameraInteracting`, `ParameterInteracting`). +- **ExactRealtimeActive**: true only when an exact full-frame realtime job is actually in flight or being presented. +- **PreviewPath**: low-resolution one-shot or streaming preview path used for surrogate interaction feedback. +- **StaticCatchUp**: background work needed to converge to the latest high-quality result, including parameter upload, bounding box update, SDF precomputation, and HQ rendering. +- **Freshness Stamp**: scene/parameter/view/viewport identity used to reject stale work before presentation. +- **StaticHQRenderTime**: the wall-clock duration of the most recently completed HQ render in `Static` state. Used by `Auto` mode as the admission baseline: < 100ms allows exact realtime during interactions, ≥ 100ms requires simpler rendering. +- **AutoInteractionDecision**: a per-gesture flag set when `Auto` mode first evaluates admission for the current gesture. Once set to "simpler", it is locked for the remainder of the gesture and cleared only when the interaction ends. + +### State Transition Rules + +| Trigger | Previous State | Next State | Required Side Effects | User-Visible Outcome | +| --- | --- | --- | --- | --- | +| Camera input detected | Any | `CameraInteracting` | Increment `viewEpoch`; invalidate view-dependent interactive work; stop parameter streaming preview if active | Camera feedback becomes the highest-priority output | +| Parameter value changes with interaction active | `Static` or `ParameterInteracting` | `ParameterInteracting` | Increment `parameterEpoch`; invalidate parameter/bbox/SDF freshness; choose preview path based on mode | User sees immediate parameter feedback according to mode | +| Parameter interaction ends / debounce expires | `ParameterInteracting` | `Static` | Stop streaming preview if active; schedule bbox/SDF/HQ catch-up | Last interactive frame remains until static output catches up | +| Camera interaction ends | `CameraInteracting` | `Static` | Schedule static catch-up appropriate to latest parameter/view state | System converges from interactive feedback to current HQ output | +| Structural model change | Any | `Static` | Increment `sceneEpoch`; invalidate all dependent work; stop preview loops | Last valid frame may remain visible with busy feedback until fresh output arrives | +| Auto mode falls back during interaction | `CameraInteracting` or `ParameterInteracting` (Auto, exact path) | Same interaction state | Set `AutoInteractionDecision` = simpler; lock decision for remainder of gesture | Rendering simplifies without switching back until the gesture completes | + +### Precedence Rules + +1. **Camera interaction supersedes parameter interaction for presentation and scheduling.** +2. **Configured `Force` mode does not imply `ExactRealtimeActive`.** +3. **Preview suppression rules must key off actual exact realtime activity and frame freshness, not just configured mode.** +4. **Static catch-up must never overwrite a fresher interactive frame.** +5. **Low-resolution or streaming preview must never run in parallel as the authoritative interaction path in `Force` mode.** +6. **A lower-quality frame MUST NOT replace a higher-quality frame currently being displayed, even if the lower-quality frame carries a current epoch stamp.** +7. **In `Auto` mode, once the system has committed to simpler rendering for the current gesture, `AutoInteractionDecision` is locked for the rest of that gesture.** + +## Requirements *(mandatory)* + +### Functional Requirements + +- **FR-001**: System MUST expose three configured render modes: `Off`, `Auto`, and `Force`. +- **FR-002**: System MUST model interaction using the semantic states `Static`, `CameraInteracting`, and `ParameterInteracting`. +- **FR-003**: Camera input MUST transition the system to `CameraInteracting` and increment a view-freshness generation. +- **FR-004**: Parameter edits MUST transition the system to `ParameterInteracting` while interaction remains active and increment a parameter-freshness generation. +- **FR-005**: Structural model edits MUST invalidate program, parameter, bbox, SDF, and display freshness and transition the system to `Static`. +- **FR-006**: The system MUST distinguish configured mode from actual in-flight exact realtime activity. +- **FR-007**: Presentation logic MUST use actual in-flight exact realtime state to decide whether to preserve exact front buffers or suppress preview results. +- **FR-008**: In `Off` mode, the system MUST NOT schedule exact full-frame realtime rendering for camera or parameter interaction. +- **FR-009**: In `Auto` mode, the system MUST use exact full-frame realtime for camera interaction only when the last measured `StaticHQRenderTime` is less than 100ms. If `StaticHQRenderTime` is 100ms or more, the system MUST fall back to simpler rendering during interactions. +- **FR-010**: In `Auto` mode, parameter interaction MUST prefer preview-quality interaction feedback and MUST NOT require exact full-frame realtime. +- **FR-011**: In `Force` mode, camera interaction MUST prefer exact full-frame realtime on every eligible tick. +- **FR-012**: In `Force` mode, parameter interaction MUST prefer exact full-frame realtime and MUST NOT start the streaming preview path in parallel. +- **FR-013**: Beginning camera interaction while parameter streaming preview is active MUST deactivate or cancel the parameter streaming preview path. +- **FR-014**: If exact realtime cannot start immediately in `Force` mode, the system MUST keep the most recent valid frame visible rather than intentionally presenting a surrogate preview or blank frame. +- **FR-015**: Background bbox and SDF catch-up MUST NOT freeze camera interaction or overwrite a fresher interactive frame. +- **FR-016**: Preview, exact realtime, and HQ frames MUST carry enough freshness information to reject stale scene, parameter, view, or viewport results before presentation. +- **FR-017**: Low-resolution preview results produced for an older `viewEpoch` MUST be discarded when a newer camera view is active. +- **FR-018**: When interaction ends, the system MUST first render a final frame in the mode that was active during interaction (which may be a low-resolution preview frame) before starting HQ catch-up. HQ catch-up itself MUST then proceed in dependency order: parameter upload if needed, bounding-box update if stale, SDF precomputation if stale, then current-frame HQ rendering. +- **FR-019**: The system MUST support last-write-wins semantics for rapid parameter changes so that only the newest parameter state needs to be rendered. +- **FR-020**: The viewport MUST continue showing a valid frame while newer work is pending; the system MUST avoid blanking the render window during normal interaction-state transitions. +- **FR-021**: Preview-quality interaction paths MUST be allowed to trade fidelity for latency in `Off` and `Auto`, but MUST remain visually coherent and ordered. +- **FR-022**: The system SHOULD show busy or catch-up feedback while heavy static work is pending, without replacing the last valid frame. +- **FR-023**: In `Auto` mode, once `AutoInteractionDecision` is set to "simpler" for a gesture, the system MUST NOT schedule exact full-frame realtime rendering until the gesture ends and `AutoInteractionDecision` is cleared. +- **FR-024**: The system MUST measure the wall-clock duration of each completed static HQ render and record it as `StaticHQRenderTime`. This value MUST be used as the sole admission criterion for exact realtime in `Auto` mode. +- **FR-025**: When transitioning between rendering quality levels (exact realtime ↔ preview), the first frame at the new quality level MUST reflect the same scene and view state as the last frame displayed at the previous level. The system MUST NOT present a frame from a stale or different model/view state during a quality transition. +- **FR-026**: The system MUST NOT present a lower-quality frame in place of a higher-quality frame that is already being shown, even if the lower-quality frame carries a current epoch stamp. +- **FR-027**: The low-resolution preview path MUST use precomputed distance field data when a fresh precomputed SDF is available for the current scene and parameter state. +- **FR-028**: The low-resolution preview path MUST dynamically adapt its framebuffer resolution to maintain a target frame rate during interaction. +- **FR-029**: Progressive HQ catch-up MUST begin with a low-resolution result and incrementally add scanlines or detail passes in full-quality mode until the full-resolution HQ frame is complete. +- **FR-030**: During progressive HQ rendering, each completed pass MUST be presented to the user immediately so that quality visibly improves over time rather than appearing only when fully complete. + +## Intended User Experience *(mandatory)* + +### Off Mode + +- The viewport behaves predictably and conservatively. +- During camera motion and parameter edits, the user sees preview-quality feedback. +- The system may use low-resolution or surrogate representations, but behavior is consistent and understandable. +- Once interaction ends, the system catches up to a current HQ frame. + +### Auto Mode + +- The viewport feels adaptive. +- Whether the system uses exact rendering or simpler preview is determined by how long the last static HQ render took: under 100ms uses exact rendering; 100ms or more uses simpler preview. +- Once the system has committed to simpler rendering for a gesture (e.g. a camera drag), it stays in that mode for the entire gesture. There is no mid-gesture switch back to higher quality, which would be visually jarring. +- Parameter edits remain responsive and visually stable, without forcing an exact full-frame path that could destabilize responsiveness. +- The user should never need to guess whether the system is "stuck"; the latest valid frame remains visible while catch-up proceeds. +- Quality never regresses: the viewport never replaces a sharp frame with a blurrier one. + +### Force Mode + +- The viewport prioritizes exactness during interaction. +- Camera movement should feel like exact interactive rendering, not a switch to a visibly different surrogate path. +- Parameter edits should behave like camera motion in the sense that they request immediate exact updates rather than a competing low-res streaming path. +- If exact rendering is temporarily blocked by transient conditions, the viewport should hold the latest valid frame and remain responsive instead of flashing a stale or lower-fidelity alternative. + +### Interaction Transitions + +- Starting camera movement after parameter editing should feel like a clean handoff, not a conflict between two rendering policies. +- Ending interaction should feel like a convergence from “interactive latest valid frame” to “current HQ frame,” not like a reset or visual regression. +- Users should not see a stale frame from the wrong interaction type appear late. + +## Clarifications + +### Session 2026-05-14 + +- Q: Does `Force` mode mean exact realtime output is always currently active? → A: No. `Force` is a scheduling policy preference, not proof that an exact realtime frame is currently in flight. +- Q: Should parameter editing in `Force` mode use the same streaming preview path as `Off`/`Auto`? → A: No. `Force` mode parameter interaction should prefer exact full-frame realtime and must not start the streaming preview path in parallel. +- Q: What happens if camera motion begins while parameter preview is active? → A: Camera interaction preempts parameter streaming preview and becomes the controlling interaction path. +- Q: What should happen if a stale preview result arrives after the camera moved? → A: The stale preview result must be discarded and not presented. + +## Success Criteria *(mandatory)* + +### Measurable Outcomes + +- **SC-001**: In `Off` mode, no exact full-frame realtime jobs are scheduled during camera or parameter interaction. +- **SC-002**: In `Auto` mode, camera interaction falls back cleanly between exact realtime and preview-quality interaction without stale-frame artifacts. +- **SC-003**: In `Force` mode, parameter changes do not trigger a visible low-resolution surrogate flash. +- **SC-004**: In `Force` mode, beginning camera interaction immediately after parameter editing does not freeze the viewport or require waiting for a parameter preview loop to finish. +- **SC-005**: Stale preview frames are never displayed after a newer camera or parameter state has been presented. +- **SC-006**: When interaction ends, the system always converges to a current HQ frame for the latest scene, parameter, view, and viewport state. +- **SC-007**: During normal interaction-state transitions, the viewport always shows either the newest valid frame or a busy/catch-up state over the last valid frame; it never intentionally blanks the window. +- **SC-008**: In `Auto` mode, during a single continuous gesture (one camera drag or one parameter drag), the rendering quality never switches from simpler preview back to exact realtime mid-gesture. +- **SC-009**: In `Auto` mode, the admission decision for exact realtime is demonstrably correlated with whether `StaticHQRenderTime` is below 100ms for the most recent static render. +- **SC-010**: The viewport never replaces a currently-displayed frame with a lower-quality frame that shows the same or an older model/view state. +- **SC-011**: After interaction ends, the first new frame presented shows the same model and view state as the last interactive frame, regardless of quality level. + +## Assumptions + +- Exact full-frame realtime rendering is more expensive than low-resolution preview and therefore must remain policy-controlled. +- Users interpret visibly different rendering paths as different semantic states; therefore sudden surrogate flashes in `Force` mode are considered correctness bugs, not merely quality tradeoffs. +- The existing async infrastructure can represent freshness using scene, parameter, view, and viewport generations. +- Catch-up work such as bbox and SDF updates may continue in the background as long as it does not become the authoritative interactive presentation path. + +## Out of Scope + +- Kernel-level performance optimization of realtime raymarching. +- UI wording or menu design for exposing the three modes. +- Export or file-loading behavior outside the preview/update pipeline. diff --git a/specs/029-render-mode-behavior/userspec.md b/specs/029-render-mode-behavior/userspec.md new file mode 100644 index 00000000..34614ca8 --- /dev/null +++ b/specs/029-render-mode-behavior/userspec.md @@ -0,0 +1,89 @@ +# User Specification: Rendering Mode Behavior + +**Created**: 2026-05-15 +**Source**: User input from session 2026-05-14 + +--- + +## What the user wants + +### Camera movement should always work + +Moving the camera (orbiting, panning, zooming) is the most basic interaction in the preview window. +No matter what mode is selected and no matter what was being done before (editing a slider, waiting for a model to compile), moving the camera should: + +- feel immediate — the view should respond as I drag, +- never freeze mid-movement, +- never require waiting for some previous operation to finish first. + +If I was editing a parameter and then grab the camera, the parameter editing is over. +Camera wins. The view moves. + +--- + +### Each mode should mean something clear + +The three rendering modes available in the menu should have predictable and distinct meanings. + +**Off** + +I have turned off the fancy interactive rendering. +Everything I see while moving the camera or editing parameters is a simplified preview. +It is fine to look less exact than the final result. That is the tradeoff I made. +When I stop moving, the system should catch up and show me a proper result. + +**Auto** + +The system decides. +On fast models it tries to show me exact-looking results while I interact. +On slow models it falls back to something simpler. +Either way, the camera should still move without freezing. +I do not need to know which strategy it chose. + +**Force** + +I have turned on exact interactive rendering. I want to see the real result as I interact. +When I move the camera I want to see the actual raymarched model, not a blurry approximation. +When I drag a slider I want to see the actual updated model immediately, not a blurry approximation. + +If the system cannot keep up immediately it should just hold the last frame it produced +and continue as fast as it can. It must not suddenly switch to a visibly different, lower-quality image. +It must not freeze. + +--- + +### Switching between editing and moving should be seamless + +A typical session looks like: + +1. I drag a slider to change a radius. +2. I orbit the camera to check the shape from a different angle. +3. I drag the slider again. + +None of those steps should: + +- leave the viewport blank, +- show a leftover frame from the wrong step, +- cause a visible jump to a lower-quality image, +- freeze the camera because step 1 is still running. + +When I stop doing anything, the system should quietly catch up to a full-quality current result in the background. If the full-quality result would take a long time to produce, it should be rendered progressively, starting with a low-res preview by rendering more lines in full-quality mode. + +--- + +### In Auto mode the transition between "fast enough" and "not fast enough" should only happen towards the faster version during interactions +If the system is in Auto mode and it decides that the model is too slow to render in full quality while I interact, it should switch to a simpler rendering mode. But it than should stay in that mode until I stop interacting. It should not switch back and forth while I am dragging a slider or moving the camera. + +--- + +### How the renderer decides what is "fast enough" for Auto mode +The time for the high quality rendering in static mode should be measured and used as a baseline for deciding what is "fast enough" for Auto mode. If the high quality rendering takes more than 100ms, then Auto mode should switch to a simpler rendering mode during interactions. If it takes less than 100ms, then Auto mode should use the high quality rendering during interactions. + +### Transitions between rendering qualities should be seamless +When switching between rendering qualities (either because of mode change or because Auto mode decides to switch), the transition should be seamless in the sense that the same state of the model and view is shown. For example, if I am dragging a slider and the system decides to switch to a simpler rendering mode, it should not suddenly show me a different frame that was rendered in the previous mode. It should continue showing the same frame but just render it in the new mode. + +### What the low-res preview is made from +The low res preview uses the precomputed distance field when available and adopts its framebuffer resolution to the framerate. + +### How catch-up is sequenced after interaction ends +When I stop interacting, the system should start a catch-up process to produce a full-quality current result if the current frame is not already full-quality. This process should be done in the background and should not block the UI. The system should first render a final frame in the current mode (which may be a low-res preview) and then start rendering the full-quality result. If the full-quality result takes a long time to produce, it should be rendered progressively, starting with a low-res preview by rendering more lines in full-quality mode.The time of the high quality rendering in static mode should be measured (s. above). From 5d8d5afe3980d088106d340e0b8d7ddbc69d8a11 Mon Sep 17 00:00:00 2001 From: Jan Orend <56254096+3dJan@users.noreply.github.com> Date: Fri, 15 May 2026 23:18:00 +0200 Subject: [PATCH 13/72] [RenderWindow, RenderUpdateCoordinator, RenderModeUpdatePolicy] Refactor rendering logic to improve realtime interaction handling and parameter change dispatching. Introduce new policies for low-resolution previews and streamline the decision-making process for rendering tasks. Add unit tests to validate the new behavior and ensure correct handling of parameter changes in various rendering modes. --- gladius/src/ui/MainWindow.cpp | 21 +- gladius/src/ui/RenderWindow.cpp | 365 +++++++++++++++--- gladius/src/ui/RenderWindow.h | 19 + .../ui/render/RealtimeRaymarchController.cpp | 2 +- .../src/ui/render/RenderModeUpdatePolicy.h | 99 +++++ .../src/ui/render/RenderUpdateCoordinator.h | 40 +- .../render/RenderModeUpdatePolicy_tests.cpp | 196 ++++++++++ .../render/RenderUpdateCoordinator_tests.cpp | 117 +++++- 8 files changed, 798 insertions(+), 61 deletions(-) create mode 100644 gladius/src/ui/render/RenderModeUpdatePolicy.h create mode 100644 gladius/tests/unittests/ui/render/RenderModeUpdatePolicy_tests.cpp diff --git a/gladius/src/ui/MainWindow.cpp b/gladius/src/ui/MainWindow.cpp index 3158f603..a7548fbc 100644 --- a/gladius/src/ui/MainWindow.cpp +++ b/gladius/src/ui/MainWindow.cpp @@ -32,6 +32,7 @@ #include "io/3mf/ImageStackCreator.h" #include "io/3mf/Writer3mf.h" #include "io/ImageStackExporter.h" +#include "render/RenderModeUpdatePolicy.h" #include namespace gladius::ui @@ -2721,7 +2722,11 @@ namespace gladius::ui // bypass the throttle — the debounce already elapsed, we just need to retry. if (m_parameterThrottle.shouldRecompile() || !m_parameterThrottle.hasPendingRecompile()) { - bool const useStreamingPreview = !m_renderWindow.isForceRealtimeMode(); + auto const parameterDispatch = async_rendering::classifyParameterChange( + async_rendering::ParameterChangeDispatchInput{ + .mode = m_renderWindow.realtimeRaymarchMode(), + .streamingPreviewActive = m_renderWindow.isStreamingPreviewActive(), + .autoRealtimeActive = m_renderWindow.isRealtimeActive()}); // Streaming preview mode: the worker coroutine becomes the sole GPU // writer, reading the latest Assembly values and pushing them to the @@ -2729,13 +2734,17 @@ namespace gladius::ui // round-trip latency that limits one-shot preview scheduling. // In Force realtime mode, parameter changes should be served by exact // full-frame raymarching instead of the low-res streaming preview. - if (!m_renderWindow.isStreamingPreviewActive()) + if (parameterDispatch.invalidateInteraction) { m_renderWindow.invalidateViewDueToParameterChange(); - if (useStreamingPreview) - { - m_renderWindow.startStreamingPreview(); - } + } + if (parameterDispatch.refreshStreamingInteraction) + { + m_renderWindow.refreshStreamingParameterInteraction(); + } + if (parameterDispatch.startStreamingPreview) + { + m_renderWindow.startStreamingPreview(); } m_parameterDirty = false; m_contoursDirty = true; diff --git a/gladius/src/ui/RenderWindow.cpp b/gladius/src/ui/RenderWindow.cpp index 885bb5e5..cfd656e6 100644 --- a/gladius/src/ui/RenderWindow.cpp +++ b/gladius/src/ui/RenderWindow.cpp @@ -24,6 +24,7 @@ #include "OverflowMenuBar.h" #include "compute/ComputeCore.h" #include "imgui.h" +#include "render/RenderModeUpdatePolicy.h" #include namespace gladius::ui @@ -453,6 +454,31 @@ namespace gladius::ui case async_rendering::RenderTaskType::RealtimeFullFrame: { + // When realtime rendering is actually active (Force always; Auto once the + // controller has measured sufficient GPU performance) render synchronously on + // the UI thread instead of going through the async job queue. This gives + // zero-latency parameter feedback because every drag event yields a fresh, + // full-quality frame in the same UI pass — no round-trip through the worker. + // SDF and BBox jobs remain asynchronous via the existing path. + if (m_renderUpdateCoordinator.isRealtimeSchedulingActive()) + { + bool const rendered = tryRenderRealtimeFrameSync(task); + queueRenderDecision(m_renderUpdateCoordinator.completeTask( + async_rendering::RenderTaskResult{ + .requestId = task.requestId, + .type = task.type, + .stamp = task.stamp, + .status = rendered ? async_rendering::RenderTaskStatus::Completed + : async_rendering::RenderTaskStatus::Failed, + .producedDisplayFrame = rendered, + .completedFrame = rendered})); + if (!rendered) + { + m_renderUpdateCoordinator.recordRealtimeRejectedAttempt(); + } + return false; // no async job started; task is already completed above + } + bool const started = scheduleRealtimeRenderJob(state, &task); if (!started) { @@ -468,7 +494,7 @@ namespace gladius::ui completeCoordinatorTask(task, async_rendering::RenderTaskStatus::Cancelled); return false; } - if (!m_core || !m_core->isSdfValid()) + if (!m_core) { failTask(); return false; @@ -664,18 +690,22 @@ namespace gladius::ui auto const currentEpoch = m_asyncCurrentEpoch.load(std::memory_order_acquire); auto const currentViewEpoch = m_asyncViewEpoch.load(std::memory_order_acquire); - // For HQ frames (front buffer from progressive rendering) we require an epoch match - // to avoid showing stale HQ results from an old parameter set. - // For low-res preview during rapid parameter edits, we fall back to m_resultImage - // which is updated synchronously and always shows *something* recent. - // Exact realtime camera motion must not fall back to that preview texture. - bool const realtimeCameraInteraction = isRealtimeRaymarchCameraInteraction(); - bool const exactRealtimeJobInFlight = - m_asyncRealtimeJobInFlight.load(std::memory_order_acquire); + // For HQ frames (front buffer from progressive rendering) we require an epoch match + // to avoid showing stale HQ results from an old parameter set. + // For low-res preview during rapid parameter edits, we fall back to m_resultImage + // which is updated synchronously and always shows *something* recent. + // Exact realtime interaction (camera or parameter) must not fall back to the + // preview texture just because the sync path does not mark m_asyncRealtimeJobInFlight. + bool const exactRealtimeInteraction = + m_renderUpdateCoordinator.isRealtimeSchedulingActive() && + m_renderUpdateCoordinator.interactionState() != + async_rendering::RenderInteractionState::Static; + bool const exactRealtimeJobInFlight = + m_asyncRealtimeJobInFlight.load(std::memory_order_acquire); bool const epochMatches = frontBuf && frontBuf->epoch == currentEpoch; bool const viewMatches = frontBuf && frontBuf->viewEpoch == currentViewEpoch; bool const allowRealtimeFront = - (realtimeCameraInteraction || exactRealtimeJobInFlight) && epochMatches; + (exactRealtimeInteraction || exactRealtimeJobInFlight) && epochMatches; bool const frontBlockedByRendering = m_renderWindowState.isRendering && !allowRealtimeFront; bool const useFrontBuffer = frontBuf && frontBuf->image && epochMatches && @@ -2074,10 +2104,10 @@ namespace gladius::ui auto const now = std::chrono::steady_clock::now(); if (now - m_lastParameterChangeTime >= kBboxDebounceDelay) { - // Stop the streaming preview loop — drag has ended. - // The streaming coroutine's last iteration already pushed the - // final parameter values to the GPU buffer. - stopStreamingPreview(); + // Parameter drag has ended. This is a semantic interaction transition, + // independent of whether the active feedback path used streaming preview + // (Force mode does not start streaming preview at all). + finishParameterInteraction(); m_core->recomputeStaleBoundingBox(); m_core->setSdfValid(false); @@ -2169,20 +2199,11 @@ namespace gladius::ui } } - // Camera-only change from centering — don't bump epoch. - // SDF/bbox jobs are not camera-dependent and must not be cancelled. - m_dirty = true; - state.isMoving = true; - state.currentLine = 0; - state.renderingStepSize = kInitialProgressiveStepSize; - state.isRendering = false; - bool const forceLowResPreview = - m_renderUpdateCoordinator.realtimeConfig().mode == async_rendering::RealtimeRaymarchMode::Off; - m_forceLowResRenderOnNextFrame.store(forceLowResPreview, std::memory_order_release); - if (forceLowResPreview) - { - m_lastLowResRenderTime = std::chrono::system_clock::now(); - } + // Auto-centering changed the camera. Route this through the normal camera + // invalidation path so the coordinator sees a view change and exact realtime + // modes schedule a RealtimeFullFrame instead of falling back to the legacy + // low-res preview via state.isMoving in Static state. + invalidateCameraView(); } if (state.isMoving && m_preComputedSdfDirty) @@ -2275,7 +2296,17 @@ namespace gladius::ui return; } - if (state.isMoving || forceLowResRender || lowResPending) + bool const shouldUseLowResPreview = async_rendering::shouldUseLegacyLowResPreview( + async_rendering::LegacyLowResPreviewInput{ + .mode = m_renderUpdateCoordinator.realtimeConfig().mode, + .interactionState = m_renderUpdateCoordinator.interactionState(), + .autoRealtimeActive = m_renderUpdateCoordinator.isRealtimeActive(), + .stateIsMoving = state.isMoving, + .forceLowResRender = forceLowResRender, + .lowResFeedbackPending = lowResPending, + .exactRealtimeJobInFlight = m_asyncRealtimeJobInFlight.load(std::memory_order_acquire)}); + + if (shouldUseLowResPreview) { bool const hadActiveProgressive = state.isRendering || m_asyncJobInFlight.load(std::memory_order_acquire); @@ -2293,9 +2324,8 @@ namespace gladius::ui // - Async preview keeps the UI thread non-blocking for smooth parameter drag // - Sync preview is the fallback when async is unavailable // During parameter drag, SDF jobs are debounced so there's no GPU contention. - bool const sdfValid = m_core->isSdfValid(); bool const streamingActive = m_streamingPreviewActive.load(std::memory_order_acquire); - bool const useAsyncPreview = !previewJobInFlight && sdfValid; + bool const useAsyncPreview = !previewJobInFlight; if (streamingActive) { @@ -2378,7 +2408,11 @@ namespace gladius::ui // Only enforce timeout if we're NOT already in middle of progressive rendering // (otherwise chunks would be blocked after any interaction) bool const isProgressiveRenderInProgress = state.isRendering && state.currentLine > 0; - bool const useLowResPreviewBeforeHq = !m_renderUpdateCoordinator.isRealtimeActive(); + auto const realtimeMode = m_renderUpdateCoordinator.realtimeConfig().mode; + bool const preferExactRealtime = + realtimeMode == async_rendering::RealtimeRaymarchMode::Force || + m_renderUpdateCoordinator.isRealtimeActive(); + bool const useLowResPreviewBeforeHq = !preferExactRealtime; if (!isProgressiveRenderInProgress && useLowResPreviewBeforeHq) { @@ -2454,7 +2488,16 @@ namespace gladius::ui if (result.precomputedSdfUpdated && !isOutdated) { m_preComputedSdfDirty.store(false, std::memory_order_release); - m_forceLowResRenderOnNextFrame.store(true, std::memory_order_release); + // In realtime mode the coordinator drives the next render through + // scheduleStaticCatchUp (fired via completeCoordinatorTask below). + // Setting forceLowResRenderOnNextFrame here would cause a spurious + // low-res preview one frame after the RealtimeFullFrame is already + // queued, because the flag persists past the coordinator's early-return + // and usesExactRealtimeInteraction returns false for Static state. + if (!m_renderUpdateCoordinator.isRealtimeSchedulingActive()) + { + m_forceLowResRenderOnNextFrame.store(true, std::memory_order_release); + } // SDF computation already updated bbox (via updateBBox() in // precomputeSdfAsync and commitSdfSuccess). Clear pending bbox // to avoid scheduling a redundant bbox job. @@ -2765,6 +2808,134 @@ namespace gladius::ui return true; } + bool RenderWindow::tryRenderRealtimeFrameSync( + async_rendering::RenderTaskRequest const & task) + { + ProfileFunction; + + if (!m_asyncController || !m_core || !m_document) + { + return false; + } + + auto assembly = m_document->getAssembly(); + if (!assembly || !m_core->isRendererReady()) + { + return false; + } + + // Push latest parameters — the coordinator has already done one upload, but the + // assembly may have been updated between that upload and now (e.g. another UI event + // fired between the ParameterUpload task and the RealtimeFullFrame task). + (void) m_core->tryToupdateParameter(*assembly); + + // Initialize async resources (allocates triple-buffer images at current resolution) + auto const image = m_core->getResultImage(); + if (!image) + { + return false; + } + size_t const width = static_cast(image->getWidth()); + size_t const height = static_cast(image->getHeight()); + if (width == 0 || height == 0) + { + return false; + } + m_asyncController->initializeAsyncResources(*m_core->getComputeContext(), width, height); + + // Acquire an Idle triple-buffer slot to render into + uint64_t const epoch = m_asyncCurrentEpoch.load(std::memory_order_acquire); + auto * writeBuffer = m_asyncController->acquireWriteBuffer(epoch); + if (!writeBuffer || !writeBuffer->image) + { + return false; + } + + auto const releaseBuffer = [&]() + { + [[maybe_unused]] bool const released = + m_asyncController->tryTransitionBuffer(writeBuffer, + async_rendering::FrameState::Writing, + async_rendering::FrameState::Idle); + }; + + if (writeBuffer->image->getWidth() < static_cast(width) || + writeBuffer->image->getHeight() < static_cast(height)) + { + releaseBuffer(); + return false; + } + + // Reuse the worker's command queue — it is idle (no async job enqueued) and avoids + // GL-interop synchronization overhead of the main queue. + auto queueOwner = m_asyncController->workerQueueShared(); + if (!queueOwner) + { + releaseBuffer(); + return false; + } + cl::CommandQueue const & queue = *queueOwner; + + auto settings = m_core->getResourceContext()->getRenderingSettings(); + settings.approximation = AM_FULL_MODEL; + + auto const startTime = std::chrono::steady_clock::now(); + + cl::Event renderEvent{}; + bool const advanced = m_core->renderSceneComputeOnlyWithSettings( + queue, 0, height, *writeBuffer->image, settings, &renderEvent); + + if (!advanced || !renderEvent()) + { + releaseBuffer(); + return false; + } + + // Block until the GPU is done. In realtime mode the user has opted into lower + // peak throughput in exchange for zero-latency feedback, and Auto mode only takes + // this path once performance has been measured to fit within the frame budget. + renderEvent.wait(); + + auto const endTime = std::chrono::steady_clock::now(); + + // Publish the completed buffer, then promote immediately to front for display + uint64_t const viewEpoch = m_asyncViewEpoch.load(std::memory_order_acquire); + m_asyncController->publishFrame(writeBuffer, task.requestId, epoch, viewEpoch); + + auto * newFront = m_asyncController->promoteReadyToFront(); + if (newFront && newFront->image) + { + newFront->image->invalidateContent(); + newFront->image->bind(); + newFront->image->unbind(); + [[maybe_unused]] bool const promoted = m_asyncController->finalizeFrontPromotion(newFront); + } + + // Feed the timing sample to the realtime learning controller so Auto mode can track + // whether performance is still within budget and deactivate realtime if it degrades. + uint64_t const durationNs = static_cast( + std::chrono::duration_cast(endTime - startTime).count()); + float const durationMs = + static_cast(static_cast(durationNs) / 1'000'000.0); + async_rendering::RealtimeRaymarchSample sample{}; + sample.durationMs = durationMs; + sample.width = static_cast(width); + sample.height = static_cast(height); + sample.renderedLines = height; + sample.totalLines = height; + sample.completedFrame = true; + sample.cancelled = false; + m_renderUpdateCoordinator.recordInteractiveRealtimeSample(sample); + + m_dirty = false; + m_suppressHQDisplay.store(false, std::memory_order_release); + m_lowResFeedbackPending.store(false, std::memory_order_release); + m_lastLowResPreviewEpoch.store(epoch, std::memory_order_release); + m_view->stopAnimationMode(); + + return true; + } + bool RenderWindow::scheduleRealtimeRenderJob( RenderWindowState & state, async_rendering::RenderTaskRequest const * coordinatorTask) @@ -3790,6 +3961,7 @@ namespace gladius::ui job.stepSize = static_cast(previewHeight); job.precomputeSdf = false; job.enableHighQuality = false; + job.coordinatorStamp = m_renderUpdateCoordinator.latestStamp(); if (coordinatorTask != nullptr) { job.coordinatorRequestId = coordinatorTask->requestId; @@ -3844,12 +4016,20 @@ namespace gladius::ui auto const startTime = std::chrono::steady_clock::now(); // Perform the async preview render using the low-res preview infrastructure. - // Preview rendering intentionally waits for the precomputed SDF so the - // feedback pass stays cheap and independent of mesh complexity. + // When the precomputed SDF is unavailable (e.g. parameter edits invalidated it), + // fall back to a low-resolution full-model render instead of producing no + // feedback frame at all. { ZoneScopedN("RenderLowResPreviewAsync"); auto computeToken = m_core->waitForComputeToken(); + // Push latest parameters so the preview reflects the current assembly state. + if (auto assembly = m_document ? m_document->getAssembly() : nullptr; + assembly && m_core->isRendererReady()) + { + (void) m_core->tryToupdateParameter(*assembly); + } + // Use the non-blocking async preview render // This renders at low resolution and returns a cl::Event for completion tracking auto lowResImage = m_core->getLowResPreviewImage(); @@ -3860,9 +4040,35 @@ namespace gladius::ui co_return result; } - // Kick off the async render with distance output for HQ initialization (FR-005) - // This populates the distance init buffer for use by subsequent HQ renders - cl::Event renderEvent = m_core->renderLowResPreviewWithDistanceOutputAsync(commandQueue, *lowResImage); + bool producedDistanceInit = false; + + // Kick off the async render with distance output for HQ initialization (FR-005) + // when the precomputed SDF is available. Otherwise fall back to a low-res + // full-model render so parameter edits still show immediate feedback. + cl::Event renderEvent = + m_core->renderLowResPreviewWithDistanceOutputAsync(commandQueue, *lowResImage); + if (renderEvent()) + { + producedDistanceInit = true; + } + else + { + auto settings = m_core->getResourceContext()->getRenderingSettings(); + settings.approximation = AM_FULL_MODEL; + settings.flags |= RF_DISABLE_SHADOWS | RF_DISABLE_AO; + renderEvent = cl::Event{}; + bool const advanced = m_core->renderSceneComputeOnlyWithSettings( + commandQueue, + 0, + static_cast(lowResImage->getHeight()), + *lowResImage, + settings, + &renderEvent); + if (!advanced) + { + renderEvent = cl::Event{}; + } + } if (!renderEvent()) { @@ -3930,9 +4136,12 @@ namespace gladius::ui // Finish the queue to ensure all work is complete before UI thread reads the buffer. commandQueue.finish(); - - // Mark distance init buffer as valid - HQ renders can now use it (FR-005) - m_core->setDistanceInitBufferValid(); + + // Mark distance init buffer as valid only when the preview actually populated it. + if (producedDistanceInit) + { + m_core->setDistanceInitBufferValid(); + } // NOTE: We intentionally do NOT resample here because resample writes to // m_resultImage which is a GL-interop buffer. GL operations must happen @@ -4182,8 +4391,15 @@ namespace gladius::ui void RenderWindow::stopStreamingPreview() { - bool const wasActive = m_streamingPreviewActive.exchange(false, std::memory_order_acq_rel); - if (wasActive) + finishParameterInteraction(); + } + + void RenderWindow::finishParameterInteraction() + { + m_streamingPreviewActive.store(false, std::memory_order_release); + m_streamingFrameConsumed.store(true, std::memory_order_release); + if (m_renderUpdateCoordinator.interactionState() == + async_rendering::RenderInteractionState::ParameterInteracting) { queueRenderDecision(m_renderUpdateCoordinator.notifyParameterInteractionEnded()); } @@ -4215,6 +4431,31 @@ namespace gladius::ui return m_streamingPreviewActive.load(std::memory_order_acquire); } + async_rendering::RealtimeRaymarchMode RenderWindow::realtimeRaymarchMode() const noexcept + { + return m_renderUpdateCoordinator.realtimeConfig().mode; + } + + bool RenderWindow::isRealtimeActive() const noexcept + { + return m_renderUpdateCoordinator.isRealtimeSchedulingActive(); + } + + void RenderWindow::refreshStreamingParameterInteraction() + { + if (m_core) + { + m_core->setSdfValid(false); + m_core->markBoundingBoxStale(); + } + + m_dirty = true; + m_preComputedSdfDirty.store(true, std::memory_order_release); + m_parameterDirty.store(true, std::memory_order_release); + m_modelModifiedSinceLastCenter = true; + m_lastParameterChangeTime = std::chrono::steady_clock::now(); + } + bool RenderWindow::scheduleStreamingPreviewJob( async_rendering::RenderTaskRequest const * coordinatorTask) { @@ -4257,6 +4498,7 @@ namespace gladius::ui job.stepSize = static_cast(previewHeight); job.precomputeSdf = false; job.enableHighQuality = false; + job.coordinatorStamp = m_renderUpdateCoordinator.latestStamp(); if (coordinatorTask != nullptr) { job.coordinatorRequestId = coordinatorTask->requestId; @@ -4362,9 +4604,35 @@ namespace gladius::ui (void) m_core->tryToupdateParameter(*assembly); } - // Render low-res preview - cl::Event renderEvent = - m_core->renderLowResPreviewWithDistanceOutputAsync(commandQueue, *lowResImage); + bool producedDistanceInit = false; + + // Render low-res preview. If the precomputed SDF is unavailable because the + // user is actively editing parameters, fall back to a low-resolution full-model + // render so the interaction still produces visible feedback. + cl::Event renderEvent = + m_core->renderLowResPreviewWithDistanceOutputAsync(commandQueue, *lowResImage); + if (renderEvent()) + { + producedDistanceInit = true; + } + else + { + auto settings = m_core->getResourceContext()->getRenderingSettings(); + settings.approximation = AM_FULL_MODEL; + settings.flags |= RF_DISABLE_SHADOWS | RF_DISABLE_AO; + renderEvent = cl::Event{}; + bool const advanced = m_core->renderSceneComputeOnlyWithSettings( + commandQueue, + 0, + static_cast(lowResImage->getHeight()), + *lowResImage, + settings, + &renderEvent); + if (!advanced) + { + renderEvent = cl::Event{}; + } + } if (!renderEvent()) { @@ -4432,7 +4700,10 @@ namespace gladius::ui consecutiveFailures = 0; commandQueue.finish(); - m_core->setDistanceInitBufferValid(); + if (producedDistanceInit) + { + m_core->setDistanceInitBufferValid(); + } // Publish frame for UI thread consumption ++frameCounter; diff --git a/gladius/src/ui/RenderWindow.h b/gladius/src/ui/RenderWindow.h index b739c161..770fa88e 100644 --- a/gladius/src/ui/RenderWindow.h +++ b/gladius/src/ui/RenderWindow.h @@ -82,7 +82,13 @@ namespace gladius::ui /// scheduling round-trip through the UI thread. void startStreamingPreview(); void stopStreamingPreview(); + void refreshStreamingParameterInteraction(); [[nodiscard]] bool isStreamingPreviewActive() const; + [[nodiscard]] async_rendering::RealtimeRaymarchMode realtimeRaymarchMode() const noexcept; + /// Returns true when the realtime learning controller has confirmed the GPU can + /// sustain realtime frames at the current resolution (Auto mode) or when Force mode + /// is active. Mirrors @ref RenderUpdateCoordinator::isRealtimeSchedulingActive(). + [[nodiscard]] bool isRealtimeActive() const noexcept; /// Cancel all in-flight async work (streaming preview, SDF, bbox, render jobs) /// by stopping streaming and bumping the epoch. Call before operations that @@ -236,6 +242,19 @@ namespace gladius::ui coro::task executeStreamingPreviewJob( async_rendering::RenderJob const & job, async_rendering::AsyncRenderController::CancelCheck const & cancelCheck); + void finishParameterInteraction(); + + /// Render a single full-quality realtime frame synchronously on the UI thread. + /// Pushes the latest parameters to the GPU, submits the OpenCL render kernel, + /// blocks until the GPU is done, then immediately promotes the result to the + /// front buffer for display. Returns true on success. + /// + /// Used when @ref RenderUpdateCoordinator::isRealtimeSchedulingActive() is true so + /// that both Force mode and Auto mode (once performance is proven) get direct, + /// zero-latency parameter feedback without going through the async job queue. + /// SDF precomputation and bounding-box updates remain asynchronous. + [[nodiscard]] bool tryRenderRealtimeFrameSync( + async_rendering::RenderTaskRequest const & task); GLView * m_view{}; diff --git a/gladius/src/ui/render/RealtimeRaymarchController.cpp b/gladius/src/ui/render/RealtimeRaymarchController.cpp index 33e80a17..0b3e46fb 100644 --- a/gladius/src/ui/render/RealtimeRaymarchController.cpp +++ b/gladius/src/ui/render/RealtimeRaymarchController.cpp @@ -309,7 +309,7 @@ namespace gladius::ui::async_rendering bool RealtimeRaymarchController::isRealtimeActive() const noexcept { - return m_config.mode == RealtimeRaymarchMode::Force || m_realtimeActive; + return m_realtimeActive; } bool RealtimeRaymarchController::isCoolingDown() const noexcept diff --git a/gladius/src/ui/render/RenderModeUpdatePolicy.h b/gladius/src/ui/render/RenderModeUpdatePolicy.h new file mode 100644 index 00000000..df819d33 --- /dev/null +++ b/gladius/src/ui/render/RenderModeUpdatePolicy.h @@ -0,0 +1,99 @@ +#pragma once + +#include "RealtimeRaymarchController.h" +#include "RenderUpdateCoordinator.h" + +namespace gladius::ui::async_rendering +{ + struct LegacyLowResPreviewInput + { + RealtimeRaymarchMode mode{RealtimeRaymarchMode::Auto}; + RenderInteractionState interactionState{RenderInteractionState::Static}; + bool autoRealtimeActive{false}; + bool stateIsMoving{false}; + bool forceLowResRender{false}; + bool lowResFeedbackPending{false}; + bool exactRealtimeJobInFlight{false}; + }; + + [[nodiscard]] constexpr bool usesExactRealtimeInteraction( + LegacyLowResPreviewInput const & input) noexcept + { + if (input.mode == RealtimeRaymarchMode::Force) + { + return true; + } + + // Auto mode: once the learning controller has proven the GPU is fast enough, + // suppress the legacy low-res path entirely. The coordinator either schedules + // exact realtime directly or keeps the current HQ frame while guards block. + return input.mode == RealtimeRaymarchMode::Auto && input.autoRealtimeActive; + } + + [[nodiscard]] constexpr bool shouldUseLegacyLowResPreview( + LegacyLowResPreviewInput const & input) noexcept + { + bool const lowResRequested = input.stateIsMoving || input.forceLowResRender || + input.lowResFeedbackPending; + if (!lowResRequested || input.exactRealtimeJobInFlight) + { + return false; + } + + if (usesExactRealtimeInteraction(input)) + { + return false; + } + + return true; + } + + struct ParameterChangeDispatchInput + { + RealtimeRaymarchMode mode{RealtimeRaymarchMode::Auto}; + bool streamingPreviewActive{false}; + /// Set to true when the realtime learning controller has confirmed the GPU can + /// render within the frame budget. When true in Auto mode, parameter changes + /// should use the direct realtime path rather than starting a streaming preview. + bool autoRealtimeActive{false}; + }; + + struct ParameterChangeDispatch + { + bool invalidateInteraction{false}; + bool refreshStreamingInteraction{false}; + bool startStreamingPreview{false}; + }; + + [[nodiscard]] constexpr ParameterChangeDispatch classifyParameterChange( + ParameterChangeDispatchInput const & input) noexcept + { + if (input.mode == RealtimeRaymarchMode::Force) + { + return {.invalidateInteraction = true, + .refreshStreamingInteraction = false, + .startStreamingPreview = false}; + } + + // Auto mode with realtime proven fast: treat like Force — just invalidate so the + // coordinator can schedule a realtime frame. Starting a streaming preview would + // race with the sync realtime path and produce redundant low-res frames. + if (input.mode == RealtimeRaymarchMode::Auto && input.autoRealtimeActive) + { + return {.invalidateInteraction = true, + .refreshStreamingInteraction = false, + .startStreamingPreview = false}; + } + + if (input.streamingPreviewActive) + { + return {.invalidateInteraction = false, + .refreshStreamingInteraction = true, + .startStreamingPreview = false}; + } + + return {.invalidateInteraction = true, + .refreshStreamingInteraction = false, + .startStreamingPreview = true}; + } +} \ No newline at end of file diff --git a/gladius/src/ui/render/RenderUpdateCoordinator.h b/gladius/src/ui/render/RenderUpdateCoordinator.h index a1666081..3a7ba515 100644 --- a/gladius/src/ui/render/RenderUpdateCoordinator.h +++ b/gladius/src/ui/render/RenderUpdateCoordinator.h @@ -70,6 +70,18 @@ namespace gladius::ui::async_rendering return m_realtime.isRealtimeActive(); } + /// Returns true when realtime frame rendering is expected to succeed at interactive rates. + /// This covers Force mode (always) and Auto mode once the controller has measured + /// sufficient GPU performance. Use this — rather than checking the mode directly — + /// to gate the synchronous render path so Auto-mode users get the same low-latency + /// feedback as Force-mode users. + [[nodiscard]] bool isRealtimeSchedulingActive() const noexcept + { + return m_realtime.config().mode == RealtimeRaymarchMode::Force || + (m_realtime.config().mode == RealtimeRaymarchMode::Auto && + m_realtime.isRealtimeActive()); + } + void setRealtimeGuards(RealtimeRaymarchGuards guards) noexcept { m_realtimeGuards = guards; @@ -170,15 +182,12 @@ namespace gladius::ui::async_rendering { RenderUpdateDecision decision{}; ++m_latestStamp.parameterEpoch; + resetRealtimeLearning(); m_parameterUploadStamp.reset(); m_boundingBoxStamp.reset(); m_sdfStamp.reset(); m_interactionState = interactionActive ? RenderInteractionState::ParameterInteracting : RenderInteractionState::Static; - if (!interactionActive) - { - resetRealtimeLearning(); - } startTask(decision, RenderTaskType::ParameterUpload, m_latestStamp); if (interactionActive) { @@ -424,6 +433,29 @@ namespace gladius::ui::async_rendering if (m_interactionState == RenderInteractionState::ParameterInteracting) { + if (m_realtime.config().mode == RealtimeRaymarchMode::Force) + { + if (m_realtime.canAttemptRealtime(m_width, m_height, m_realtimeGuards)) + { + startTask(decision, RenderTaskType::RealtimeFullFrame, m_latestStamp); + return; + } + keepCurrentFrame(decision); + return; + } + // Auto mode: once the learning controller confirms the GPU is fast enough, + // use the same realtime path as Force so parameter drags get direct feedback. + if (m_realtime.config().mode == RealtimeRaymarchMode::Auto && + m_realtime.isRealtimeActive()) + { + if (m_realtime.guardsAllowAttempt(m_realtimeGuards)) + { + startTask(decision, RenderTaskType::RealtimeFullFrame, m_latestStamp); + return; + } + keepCurrentFrame(decision); + return; + } startTask(decision, RenderTaskType::LowResolutionPreview, m_latestStamp); return; } diff --git a/gladius/tests/unittests/ui/render/RenderModeUpdatePolicy_tests.cpp b/gladius/tests/unittests/ui/render/RenderModeUpdatePolicy_tests.cpp new file mode 100644 index 00000000..2fb279be --- /dev/null +++ b/gladius/tests/unittests/ui/render/RenderModeUpdatePolicy_tests.cpp @@ -0,0 +1,196 @@ +#include "ui/render/RenderModeUpdatePolicy.h" + +#include + +namespace gladius::ui::async_rendering::tests +{ + TEST(RenderModeUpdatePolicy, LegacyLowResPreview_WithForceParameterInteraction_IsSuppressed) + { + auto const decision = shouldUseLegacyLowResPreview( + LegacyLowResPreviewInput{.mode = RealtimeRaymarchMode::Force, + .interactionState = RenderInteractionState::ParameterInteracting, + .stateIsMoving = true, + .forceLowResRender = true, + .lowResFeedbackPending = true, + .exactRealtimeJobInFlight = false}); + + EXPECT_FALSE(decision); + } + + TEST(RenderModeUpdatePolicy, LegacyLowResPreview_WithForceCameraInteraction_IsSuppressed) + { + auto const decision = shouldUseLegacyLowResPreview( + LegacyLowResPreviewInput{.mode = RealtimeRaymarchMode::Force, + .interactionState = RenderInteractionState::CameraInteracting, + .stateIsMoving = true, + .forceLowResRender = true, + .lowResFeedbackPending = true, + .exactRealtimeJobInFlight = false}); + + EXPECT_FALSE(decision); + } + + TEST(RenderModeUpdatePolicy, LegacyLowResPreview_WithForceStaticMoving_IsSuppressed) + { + auto const decision = shouldUseLegacyLowResPreview( + LegacyLowResPreviewInput{.mode = RealtimeRaymarchMode::Force, + .interactionState = RenderInteractionState::Static, + .stateIsMoving = true, + .exactRealtimeJobInFlight = false}); + + EXPECT_FALSE(decision); + } + + TEST(RenderModeUpdatePolicy, LegacyLowResPreview_WithAutoRealtimeActive_IsSuppressed) + { + auto const decision = shouldUseLegacyLowResPreview( + LegacyLowResPreviewInput{.mode = RealtimeRaymarchMode::Auto, + .interactionState = RenderInteractionState::CameraInteracting, + .autoRealtimeActive = true, + .stateIsMoving = true, + .exactRealtimeJobInFlight = false}); + + EXPECT_FALSE(decision); + } + + TEST(RenderModeUpdatePolicy, LegacyLowResPreview_WithAutoRealtimeActiveStaticMoving_IsSuppressed) + { + auto const decision = shouldUseLegacyLowResPreview( + LegacyLowResPreviewInput{.mode = RealtimeRaymarchMode::Auto, + .interactionState = RenderInteractionState::Static, + .autoRealtimeActive = true, + .stateIsMoving = true, + .exactRealtimeJobInFlight = false}); + + EXPECT_FALSE(decision); + } + + TEST(RenderModeUpdatePolicy, LegacyLowResPreview_WithAutoPreviewParameterInteraction_IsAllowed) + { + auto const decision = shouldUseLegacyLowResPreview( + LegacyLowResPreviewInput{.mode = RealtimeRaymarchMode::Auto, + .interactionState = RenderInteractionState::ParameterInteracting, + .autoRealtimeActive = false, + .lowResFeedbackPending = true, + .exactRealtimeJobInFlight = false}); + + EXPECT_TRUE(decision); + } + + TEST(RenderModeUpdatePolicy, LegacyLowResPreview_WithAutoRealtimeActiveParameterInteraction_IsSuppressed) + { + auto const decision = shouldUseLegacyLowResPreview( + LegacyLowResPreviewInput{.mode = RealtimeRaymarchMode::Auto, + .interactionState = RenderInteractionState::ParameterInteracting, + .autoRealtimeActive = true, + .lowResFeedbackPending = true, + .exactRealtimeJobInFlight = false}); + + EXPECT_FALSE(decision); + } + + TEST(RenderModeUpdatePolicy, LegacyLowResPreview_WithOffCameraInteraction_IsAllowed) + { + auto const decision = shouldUseLegacyLowResPreview( + LegacyLowResPreviewInput{.mode = RealtimeRaymarchMode::Off, + .interactionState = RenderInteractionState::CameraInteracting, + .stateIsMoving = true, + .exactRealtimeJobInFlight = false}); + + EXPECT_TRUE(decision); + } + + TEST(RenderModeUpdatePolicy, LegacyLowResPreview_WithExactRealtimeInFlight_IsSuppressed) + { + auto const decision = shouldUseLegacyLowResPreview( + LegacyLowResPreviewInput{.mode = RealtimeRaymarchMode::Off, + .interactionState = RenderInteractionState::ParameterInteracting, + .lowResFeedbackPending = true, + .exactRealtimeJobInFlight = true}); + + EXPECT_FALSE(decision); + } + + TEST(RenderModeUpdatePolicy, ParameterChangeDispatch_WithForce_InvalidatesWithoutStreaming) + { + auto const dispatch = classifyParameterChange( + ParameterChangeDispatchInput{.mode = RealtimeRaymarchMode::Force, + .streamingPreviewActive = false}); + + EXPECT_TRUE(dispatch.invalidateInteraction); + EXPECT_FALSE(dispatch.refreshStreamingInteraction); + EXPECT_FALSE(dispatch.startStreamingPreview); + } + + TEST(RenderModeUpdatePolicy, ParameterChangeDispatch_WithAutoFirstChange_StartsStreaming) + { + auto const dispatch = classifyParameterChange( + ParameterChangeDispatchInput{.mode = RealtimeRaymarchMode::Auto, + .streamingPreviewActive = false}); + + EXPECT_TRUE(dispatch.invalidateInteraction); + EXPECT_FALSE(dispatch.refreshStreamingInteraction); + EXPECT_TRUE(dispatch.startStreamingPreview); + } + + TEST(RenderModeUpdatePolicy, ParameterChangeDispatch_WithOffFirstChange_StartsStreaming) + { + auto const dispatch = classifyParameterChange( + ParameterChangeDispatchInput{.mode = RealtimeRaymarchMode::Off, + .streamingPreviewActive = false}); + + EXPECT_TRUE(dispatch.invalidateInteraction); + EXPECT_FALSE(dispatch.refreshStreamingInteraction); + EXPECT_TRUE(dispatch.startStreamingPreview); + } + + TEST(RenderModeUpdatePolicy, ParameterChangeDispatch_WithAutoStreamingActive_RefreshesInteraction) + { + auto const dispatch = classifyParameterChange( + ParameterChangeDispatchInput{.mode = RealtimeRaymarchMode::Auto, + .streamingPreviewActive = true}); + + EXPECT_FALSE(dispatch.invalidateInteraction); + EXPECT_TRUE(dispatch.refreshStreamingInteraction); + EXPECT_FALSE(dispatch.startStreamingPreview); + } + + TEST(RenderModeUpdatePolicy, ParameterChangeDispatch_WithOffStreamingActive_RefreshesInteraction) + { + auto const dispatch = classifyParameterChange( + ParameterChangeDispatchInput{.mode = RealtimeRaymarchMode::Off, + .streamingPreviewActive = true}); + + EXPECT_FALSE(dispatch.invalidateInteraction); + EXPECT_TRUE(dispatch.refreshStreamingInteraction); + EXPECT_FALSE(dispatch.startStreamingPreview); + } + + TEST(RenderModeUpdatePolicy, ParameterChangeDispatch_WithAutoRealtimeActiveNoStreaming_InvalidatesWithoutStreaming) + { + // When Auto mode has confirmed the GPU is fast enough, parameter changes should + // take the direct realtime path rather than starting a streaming preview. + auto const dispatch = classifyParameterChange( + ParameterChangeDispatchInput{.mode = RealtimeRaymarchMode::Auto, + .streamingPreviewActive = false, + .autoRealtimeActive = true}); + + EXPECT_TRUE(dispatch.invalidateInteraction); + EXPECT_FALSE(dispatch.refreshStreamingInteraction); + EXPECT_FALSE(dispatch.startStreamingPreview); + } + + TEST(RenderModeUpdatePolicy, ParameterChangeDispatch_WithAutoRealtimeActiveStreamingActive_InvalidatesWithoutStreaming) + { + // Even when streaming preview was already active, switching to realtime should + // not keep feeding it. + auto const dispatch = classifyParameterChange( + ParameterChangeDispatchInput{.mode = RealtimeRaymarchMode::Auto, + .streamingPreviewActive = true, + .autoRealtimeActive = true}); + + EXPECT_TRUE(dispatch.invalidateInteraction); + EXPECT_FALSE(dispatch.refreshStreamingInteraction); + EXPECT_FALSE(dispatch.startStreamingPreview); + } +} diff --git a/gladius/tests/unittests/ui/render/RenderUpdateCoordinator_tests.cpp b/gladius/tests/unittests/ui/render/RenderUpdateCoordinator_tests.cpp index 482d33e8..e1aae21b 100644 --- a/gladius/tests/unittests/ui/render/RenderUpdateCoordinator_tests.cpp +++ b/gladius/tests/unittests/ui/render/RenderUpdateCoordinator_tests.cpp @@ -92,7 +92,7 @@ namespace gladius::ui::async_rendering::tests ASSERT_TRUE(hq.has_value()); auto hqResult = completed(*hq, true, true); hqResult.durationNs = 50'000'000; // 50ms < 100ms → autoModeAdmitsRealtime() = true - coordinator.completeTask(hqResult); + [[maybe_unused]] auto const finalDecision = coordinator.completeTask(hqResult); } } @@ -344,7 +344,7 @@ namespace gladius::ui::async_rendering::tests EXPECT_TRUE(hasCommand(decision, RenderCommandType::KeepCurrentFrame)); } - TEST(RenderUpdateCoordinator, ParameterDrag_WithForcedRealtime_UsesPreviewNotRealtime) + TEST(RenderUpdateCoordinator, ParameterDrag_WithForcedRealtime_StartsRealtimeFullFrame) { RenderUpdateCoordinator coordinator; coordinator.configureRealtime(forcedRealtimeConfig()); @@ -353,8 +353,66 @@ namespace gladius::ui::async_rendering::tests auto const decision = coordinator.notifyParameterChanged(true); EXPECT_TRUE(hasStartedTask(decision, RenderTaskType::ParameterUpload)); - EXPECT_TRUE(hasStartedTask(decision, RenderTaskType::LowResolutionPreview)); + EXPECT_TRUE(hasStartedTask(decision, RenderTaskType::RealtimeFullFrame)); + EXPECT_FALSE(hasStartedTask(decision, RenderTaskType::LowResolutionPreview)); + } + + TEST(RenderUpdateCoordinator, ParameterDrag_WithForcedRealtimeGuardBlocker_KeepsCurrentFrame) + { + RenderUpdateCoordinator coordinator; + coordinator.configureRealtime(forcedRealtimeConfig()); + ASSERT_FALSE(coordinator.configureViewport(640, 480).commands.empty()); + async_rendering::RealtimeRaymarchGuards guards{}; + guards.hardBlocker = true; + coordinator.setRealtimeGuards(guards); + + auto const decision = coordinator.notifyParameterChanged(true); + + EXPECT_TRUE(hasStartedTask(decision, RenderTaskType::ParameterUpload)); EXPECT_FALSE(hasStartedTask(decision, RenderTaskType::RealtimeFullFrame)); + EXPECT_FALSE(hasStartedTask(decision, RenderTaskType::LowResolutionPreview)); + EXPECT_TRUE(hasCommand(decision, RenderCommandType::KeepCurrentFrame)); + } + + TEST(RenderUpdateCoordinator, ContinuousParameterDrag_WithForcedRealtimeInFlight_NeverStartsPreview) + { + RenderUpdateCoordinator coordinator; + coordinator.configureRealtime(forcedRealtimeConfig()); + ASSERT_FALSE(coordinator.configureViewport(640, 480).commands.empty()); + + auto decision = coordinator.notifyParameterChanged(true); + auto firstUpload = findStartedTask(decision, RenderTaskType::ParameterUpload); + ASSERT_TRUE(firstUpload.has_value()); + auto firstRealtime = findStartedTask(decision, RenderTaskType::RealtimeFullFrame); + ASSERT_TRUE(firstRealtime.has_value()); + + decision = coordinator.notifyParameterChanged(true); + auto const latestStamp = coordinator.latestStamp(); + + EXPECT_FALSE(hasStartedTask(decision, RenderTaskType::LowResolutionPreview)); + EXPECT_FALSE(hasStartedTask(decision, RenderTaskType::RealtimeFullFrame)); + EXPECT_TRUE(hasCommand(decision, RenderCommandType::KeepCurrentFrame)); + + decision = coordinator.completeTask(completed(*firstUpload)); + EXPECT_TRUE(hasCommand(decision, RenderCommandType::DiscardTaskResult)); + + decision = coordinator.tick(); + auto latestUpload = findStartedTask(decision, RenderTaskType::ParameterUpload); + ASSERT_TRUE(latestUpload.has_value()); + EXPECT_TRUE(matches(latestUpload->stamp, latestStamp, RenderStampMask::heavyGeometryTask())); + EXPECT_FALSE(hasStartedTask(decision, RenderTaskType::LowResolutionPreview)); + + decision = coordinator.completeTask(completed(*firstRealtime, true, true)); + EXPECT_TRUE(hasCommand(decision, RenderCommandType::DiscardTaskResult)); + + decision = coordinator.completeTask(completed(*latestUpload)); + EXPECT_FALSE(hasStartedTask(decision, RenderTaskType::LowResolutionPreview)); + + decision = coordinator.tick(); + auto latestRealtime = findStartedTask(decision, RenderTaskType::RealtimeFullFrame); + ASSERT_TRUE(latestRealtime.has_value()); + EXPECT_TRUE(matches(latestRealtime->stamp, latestStamp, RenderStampMask::displayFrame())); + EXPECT_FALSE(hasStartedTask(decision, RenderTaskType::LowResolutionPreview)); } TEST(RenderUpdateCoordinator, ParameterDrag_StartsUploadAndPreviewButDefersHeavyTasks) @@ -396,6 +454,37 @@ namespace gladius::ui::async_rendering::tests EXPECT_TRUE(matches(latestUpload->stamp, latest, RenderStampMask::heavyGeometryTask())); } + TEST(RenderUpdateCoordinator, ContinuousParameterDrag_WithStaleAutoPreview_CompletesLatestPreview) + { + RenderUpdateCoordinator coordinator; + auto decision = coordinator.notifyParameterChanged(true); + auto firstUpload = findStartedTask(decision, RenderTaskType::ParameterUpload); + ASSERT_TRUE(firstUpload.has_value()); + auto firstPreview = findStartedTask(decision, RenderTaskType::LowResolutionPreview); + ASSERT_TRUE(firstPreview.has_value()); + + decision = coordinator.notifyParameterChanged(true); + auto const latestStamp = coordinator.latestStamp(); + EXPECT_TRUE(hasCommand(decision, RenderCommandType::KeepCurrentFrame)); + + decision = coordinator.completeTask(completed(*firstUpload)); + EXPECT_TRUE(hasCommand(decision, RenderCommandType::DiscardTaskResult)); + + decision = coordinator.completeTask(completed(*firstPreview, true, true)); + EXPECT_TRUE(hasCommand(decision, RenderCommandType::DiscardTaskResult)); + + decision = coordinator.tick(); + auto latestUpload = findStartedTask(decision, RenderTaskType::ParameterUpload); + ASSERT_TRUE(latestUpload.has_value()); + EXPECT_TRUE(matches(latestUpload->stamp, latestStamp, RenderStampMask::heavyGeometryTask())); + auto latestPreview = findStartedTask(decision, RenderTaskType::LowResolutionPreview); + ASSERT_TRUE(latestPreview.has_value()); + EXPECT_TRUE(matches(latestPreview->stamp, latestStamp, RenderStampMask::displayFrame())); + + decision = coordinator.completeTask(completed(*latestUpload)); + EXPECT_FALSE(hasStartedTask(decision, RenderTaskType::LowResolutionPreview)); + } + TEST(RenderUpdateCoordinator, ParameterChanged_AfterRealtimeLearning_ResetsToProgressiveStaticLearning) { RenderUpdateCoordinator coordinator; @@ -423,6 +512,28 @@ namespace gladius::ui::async_rendering::tests EXPECT_FALSE(hasStartedTask(decision, RenderTaskType::RealtimeFullFrame)); } + TEST(RenderUpdateCoordinator, ActiveParameterChanged_AfterFastAutoSample_ResetsInteractionAdmission) + { + RenderUpdateCoordinator coordinator; + auto const viewportDecision = coordinator.configureViewport(640, 480); + ASSERT_FALSE(viewportDecision.commands.empty()); + primeAutoRealtimeAdmission(coordinator, viewportDecision); + + auto decision = coordinator.notifyParameterChanged(true); + auto upload = findStartedTask(decision, RenderTaskType::ParameterUpload); + ASSERT_TRUE(upload.has_value()); + auto preview = findStartedTask(decision, RenderTaskType::LowResolutionPreview); + ASSERT_TRUE(preview.has_value()); + + [[maybe_unused]] auto const uploadDecision = coordinator.completeTask(completed(*upload)); + [[maybe_unused]] auto const previewDecision = coordinator.completeTask(completed(*preview, true, true)); + + decision = coordinator.notifyCameraChanged(); + + EXPECT_FALSE(hasStartedTask(decision, RenderTaskType::RealtimeFullFrame)); + EXPECT_TRUE(hasStartedTask(decision, RenderTaskType::LowResolutionPreview)); + } + TEST(RenderUpdateCoordinator, StaticCatchUp_AfterParameterEdit_SequencesUploadBboxSdfThenHq) { RenderUpdateCoordinator coordinator; From a9ee9d24d4a1a6d4bb553019ac6c22cc791840ab Mon Sep 17 00:00:00 2001 From: Jan Orend <56254096+3dJan@users.noreply.github.com> Date: Sat, 16 May 2026 07:44:50 +0200 Subject: [PATCH 14/72] [RenderUpdateCoordinator] Address Force mode parameter interaction issues and improve state transitions. Ensure Force mode uses exact realtime for parameter edits, decouple interaction end from streaming preview, and enhance unit tests to align with userspec. Document state transition traces and fix Auto/Off mode parameter handling --- .../ui/render/InteractiveRenderPathPolicy.h | 81 + .../src/ui/render/RenderModeUpdatePolicy.h | 2 +- .../src/ui/render/RenderUpdateCoordinator.h | 92 +- gladius/src/ui/render/RenderUpdateTypes.h | 7 + .../InteractiveRenderPathPolicy_tests.cpp | 113 + review.diff | 6573 +++++++++++++++++ review.md | 133 + .../state-transition-trace.md | 96 + 8 files changed, 7029 insertions(+), 68 deletions(-) create mode 100644 gladius/src/ui/render/InteractiveRenderPathPolicy.h create mode 100644 gladius/tests/unittests/ui/render/InteractiveRenderPathPolicy_tests.cpp create mode 100644 review.diff create mode 100644 review.md create mode 100644 specs/029-render-mode-behavior/state-transition-trace.md diff --git a/gladius/src/ui/render/InteractiveRenderPathPolicy.h b/gladius/src/ui/render/InteractiveRenderPathPolicy.h new file mode 100644 index 00000000..83e5b0d0 --- /dev/null +++ b/gladius/src/ui/render/InteractiveRenderPathPolicy.h @@ -0,0 +1,81 @@ +#pragma once + +#include "RealtimeRaymarchController.h" +#include "RenderUpdateTypes.h" + +namespace gladius::ui::async_rendering +{ + /// Pure policy decision for which interactive frame path should be used next. + /// This preserves the current runtime behavior while moving the branchy mode/ + /// interaction logic out of RenderUpdateCoordinator so it can be tested in + /// isolation before the larger RenderWindow refactor lands. + enum class InteractiveRenderPath + { + KeepCurrentFrame, + ExactRealtime, + LowResolutionPreview + }; + + struct InteractiveRenderPathInput + { + RealtimeRaymarchMode mode{RealtimeRaymarchMode::Auto}; + RenderInteractionState interactionState{RenderInteractionState::Static}; + bool interactiveFrameAlreadyInFlight{false}; + /// Auto parameter drag still keys off the learning controller's active flag. + bool autoParameterExactRealtimeActive{false}; + /// Auto camera/static interaction admits exact realtime once static HQ timing + /// has shown the scene is fast enough at the current resolution. + bool autoInteractiveExactRealtimeAdmitted{false}; + /// Per-gesture hysteresis that keeps Auto on the simpler preview path once it + /// has fallen back during the current interaction. + bool preferSimplerPreview{false}; + /// True when the renderer/guards allow an exact realtime frame right now. + bool exactRealtimeAllowed{false}; + }; + + [[nodiscard]] constexpr InteractiveRenderPath chooseInteractiveRenderPath( + InteractiveRenderPathInput const & input) noexcept + { + if (input.interactiveFrameAlreadyInFlight) + { + return InteractiveRenderPath::KeepCurrentFrame; + } + + if (input.interactionState == RenderInteractionState::ParameterInteracting) + { + if (input.mode == RealtimeRaymarchMode::Force) + { + return input.exactRealtimeAllowed ? InteractiveRenderPath::ExactRealtime + : InteractiveRenderPath::KeepCurrentFrame; + } + + if (input.mode == RealtimeRaymarchMode::Auto && + input.autoParameterExactRealtimeActive) + { + return input.exactRealtimeAllowed ? InteractiveRenderPath::ExactRealtime + : InteractiveRenderPath::KeepCurrentFrame; + } + + return InteractiveRenderPath::LowResolutionPreview; + } + + if (input.mode == RealtimeRaymarchMode::Auto) + { + if (input.preferSimplerPreview || !input.autoInteractiveExactRealtimeAdmitted) + { + return InteractiveRenderPath::LowResolutionPreview; + } + + return input.exactRealtimeAllowed ? InteractiveRenderPath::ExactRealtime + : InteractiveRenderPath::KeepCurrentFrame; + } + + if (input.mode == RealtimeRaymarchMode::Off) + { + return InteractiveRenderPath::LowResolutionPreview; + } + + return input.exactRealtimeAllowed ? InteractiveRenderPath::ExactRealtime + : InteractiveRenderPath::KeepCurrentFrame; + } +} diff --git a/gladius/src/ui/render/RenderModeUpdatePolicy.h b/gladius/src/ui/render/RenderModeUpdatePolicy.h index df819d33..bcafc464 100644 --- a/gladius/src/ui/render/RenderModeUpdatePolicy.h +++ b/gladius/src/ui/render/RenderModeUpdatePolicy.h @@ -1,7 +1,7 @@ #pragma once #include "RealtimeRaymarchController.h" -#include "RenderUpdateCoordinator.h" +#include "RenderUpdateTypes.h" namespace gladius::ui::async_rendering { diff --git a/gladius/src/ui/render/RenderUpdateCoordinator.h b/gladius/src/ui/render/RenderUpdateCoordinator.h index 3a7ba515..8a624b8e 100644 --- a/gladius/src/ui/render/RenderUpdateCoordinator.h +++ b/gladius/src/ui/render/RenderUpdateCoordinator.h @@ -1,5 +1,6 @@ #pragma once +#include "InteractiveRenderPathPolicy.h" #include "RealtimeRaymarchController.h" #include "RenderUpdateTypes.h" @@ -11,13 +12,6 @@ namespace gladius::ui::async_rendering { - enum class RenderInteractionState - { - Static, - CameraInteracting, - ParameterInteracting - }; - enum class RenderCommandType { StartTask, @@ -425,77 +419,41 @@ namespace gladius::ui::async_rendering void scheduleInteractiveFrame(RenderUpdateDecision & decision) { - if (hasInteractiveFrameInFlight()) - { - keepCurrentFrame(decision); - return; - } + auto const mode = m_realtime.config().mode; - if (m_interactionState == RenderInteractionState::ParameterInteracting) + bool exactRealtimeAllowed = false; + if (mode == RealtimeRaymarchMode::Force) { - if (m_realtime.config().mode == RealtimeRaymarchMode::Force) - { - if (m_realtime.canAttemptRealtime(m_width, m_height, m_realtimeGuards)) - { - startTask(decision, RenderTaskType::RealtimeFullFrame, m_latestStamp); - return; - } - keepCurrentFrame(decision); - return; - } - // Auto mode: once the learning controller confirms the GPU is fast enough, - // use the same realtime path as Force so parameter drags get direct feedback. - if (m_realtime.config().mode == RealtimeRaymarchMode::Auto && - m_realtime.isRealtimeActive()) - { - if (m_realtime.guardsAllowAttempt(m_realtimeGuards)) - { - startTask(decision, RenderTaskType::RealtimeFullFrame, m_latestStamp); - return; - } - keepCurrentFrame(decision); - return; - } - startTask(decision, RenderTaskType::LowResolutionPreview, m_latestStamp); - return; + exactRealtimeAllowed = + m_realtime.canAttemptRealtime(m_width, m_height, m_realtimeGuards); } - - // Auto mode: gate on measured static HQ time with per-gesture hysteresis - if (m_realtime.config().mode == RealtimeRaymarchMode::Auto) + else if (mode == RealtimeRaymarchMode::Auto) { - if (!m_autoGestureLockedSimpler && !autoModeAdmitsRealtime()) - { - m_autoGestureLockedSimpler = true; - } - if (m_autoGestureLockedSimpler) - { - startTask(decision, RenderTaskType::LowResolutionPreview, m_latestStamp); - return; - } - if (m_realtime.guardsAllowAttempt(m_realtimeGuards)) - { - startTask(decision, RenderTaskType::RealtimeFullFrame, m_latestStamp); - return; - } - // Guards transiently blocked — hold the current frame (FR-026: no quality regression) - keepCurrentFrame(decision); - return; + exactRealtimeAllowed = m_realtime.guardsAllowAttempt(m_realtimeGuards); } - // Off mode: always preview - if (m_realtime.config().mode == RealtimeRaymarchMode::Off) - { - startTask(decision, RenderTaskType::LowResolutionPreview, m_latestStamp); - return; - } + auto const path = chooseInteractiveRenderPath( + InteractiveRenderPathInput{.mode = mode, + .interactionState = m_interactionState, + .interactiveFrameAlreadyInFlight = hasInteractiveFrameInFlight(), + .autoParameterExactRealtimeActive = m_realtime.isRealtimeActive(), + .autoInteractiveExactRealtimeAdmitted = autoModeAdmitsRealtime(), + .preferSimplerPreview = m_autoGestureLockedSimpler, + .exactRealtimeAllowed = exactRealtimeAllowed}); - // Force mode: prefer exact realtime; hold current frame if guards temporarily block (FR-014) - if (m_realtime.canAttemptRealtime(m_width, m_height, m_realtimeGuards)) + switch (path) { + case InteractiveRenderPath::ExactRealtime: startTask(decision, RenderTaskType::RealtimeFullFrame, m_latestStamp); return; + case InteractiveRenderPath::LowResolutionPreview: + startTask(decision, RenderTaskType::LowResolutionPreview, m_latestStamp); + return; + case InteractiveRenderPath::KeepCurrentFrame: + default: + keepCurrentFrame(decision); + return; } - keepCurrentFrame(decision); } void scheduleStaticCatchUp(RenderUpdateDecision & decision) diff --git a/gladius/src/ui/render/RenderUpdateTypes.h b/gladius/src/ui/render/RenderUpdateTypes.h index ae1f8d53..d014f9e2 100644 --- a/gladius/src/ui/render/RenderUpdateTypes.h +++ b/gladius/src/ui/render/RenderUpdateTypes.h @@ -5,6 +5,13 @@ namespace gladius::ui::async_rendering { + enum class RenderInteractionState + { + Static, + CameraInteracting, + ParameterInteracting + }; + /** * @brief Identifies the independently changing inputs that make a rendered artifact fresh. * diff --git a/gladius/tests/unittests/ui/render/InteractiveRenderPathPolicy_tests.cpp b/gladius/tests/unittests/ui/render/InteractiveRenderPathPolicy_tests.cpp new file mode 100644 index 00000000..4fae879d --- /dev/null +++ b/gladius/tests/unittests/ui/render/InteractiveRenderPathPolicy_tests.cpp @@ -0,0 +1,113 @@ +#include "ui/render/InteractiveRenderPathPolicy.h" + +#include + +namespace gladius::ui::async_rendering::tests +{ + TEST(InteractiveRenderPathPolicy, WithInteractiveFrameAlreadyInFlight_KeepsCurrentFrame) + { + auto const path = chooseInteractiveRenderPath( + InteractiveRenderPathInput{.mode = RealtimeRaymarchMode::Force, + .interactionState = RenderInteractionState::CameraInteracting, + .interactiveFrameAlreadyInFlight = true, + .exactRealtimeAllowed = true}); + + EXPECT_EQ(path, InteractiveRenderPath::KeepCurrentFrame); + } + + TEST(InteractiveRenderPathPolicy, ForceParameter_WithRealtimeAllowed_UsesExactRealtime) + { + auto const path = chooseInteractiveRenderPath( + InteractiveRenderPathInput{.mode = RealtimeRaymarchMode::Force, + .interactionState = RenderInteractionState::ParameterInteracting, + .exactRealtimeAllowed = true}); + + EXPECT_EQ(path, InteractiveRenderPath::ExactRealtime); + } + + TEST(InteractiveRenderPathPolicy, ForceParameter_WithRealtimeBlocked_KeepsCurrentFrame) + { + auto const path = chooseInteractiveRenderPath( + InteractiveRenderPathInput{.mode = RealtimeRaymarchMode::Force, + .interactionState = RenderInteractionState::ParameterInteracting, + .exactRealtimeAllowed = false}); + + EXPECT_EQ(path, InteractiveRenderPath::KeepCurrentFrame); + } + + TEST(InteractiveRenderPathPolicy, AutoParameter_WithRealtimeActiveAndAllowed_UsesExactRealtime) + { + auto const path = chooseInteractiveRenderPath( + InteractiveRenderPathInput{.mode = RealtimeRaymarchMode::Auto, + .interactionState = RenderInteractionState::ParameterInteracting, + .autoParameterExactRealtimeActive = true, + .exactRealtimeAllowed = true}); + + EXPECT_EQ(path, InteractiveRenderPath::ExactRealtime); + } + + TEST(InteractiveRenderPathPolicy, AutoParameter_WithoutRealtimeActive_UsesLowResolutionPreview) + { + auto const path = chooseInteractiveRenderPath( + InteractiveRenderPathInput{.mode = RealtimeRaymarchMode::Auto, + .interactionState = RenderInteractionState::ParameterInteracting, + .autoParameterExactRealtimeActive = false, + .exactRealtimeAllowed = true}); + + EXPECT_EQ(path, InteractiveRenderPath::LowResolutionPreview); + } + + TEST(InteractiveRenderPathPolicy, AutoCamera_WithGestureLocked_UsesLowResolutionPreview) + { + auto const path = chooseInteractiveRenderPath( + InteractiveRenderPathInput{.mode = RealtimeRaymarchMode::Auto, + .interactionState = RenderInteractionState::CameraInteracting, + .autoInteractiveExactRealtimeAdmitted = true, + .preferSimplerPreview = true, + .exactRealtimeAllowed = true}); + + EXPECT_EQ(path, InteractiveRenderPath::LowResolutionPreview); + } + + TEST(InteractiveRenderPathPolicy, AutoCamera_WithRealtimeAdmittedAndAllowed_UsesExactRealtime) + { + auto const path = chooseInteractiveRenderPath( + InteractiveRenderPathInput{.mode = RealtimeRaymarchMode::Auto, + .interactionState = RenderInteractionState::CameraInteracting, + .autoInteractiveExactRealtimeAdmitted = true, + .exactRealtimeAllowed = true}); + + EXPECT_EQ(path, InteractiveRenderPath::ExactRealtime); + } + + TEST(InteractiveRenderPathPolicy, AutoCamera_WithRealtimeAdmittedButBlocked_KeepsCurrentFrame) + { + auto const path = chooseInteractiveRenderPath( + InteractiveRenderPathInput{.mode = RealtimeRaymarchMode::Auto, + .interactionState = RenderInteractionState::CameraInteracting, + .autoInteractiveExactRealtimeAdmitted = true, + .exactRealtimeAllowed = false}); + + EXPECT_EQ(path, InteractiveRenderPath::KeepCurrentFrame); + } + + TEST(InteractiveRenderPathPolicy, OffInteraction_UsesLowResolutionPreview) + { + auto const path = chooseInteractiveRenderPath( + InteractiveRenderPathInput{.mode = RealtimeRaymarchMode::Off, + .interactionState = RenderInteractionState::CameraInteracting, + .exactRealtimeAllowed = true}); + + EXPECT_EQ(path, InteractiveRenderPath::LowResolutionPreview); + } + + TEST(InteractiveRenderPathPolicy, ForceCamera_WithRealtimeBlocked_KeepsCurrentFrame) + { + auto const path = chooseInteractiveRenderPath( + InteractiveRenderPathInput{.mode = RealtimeRaymarchMode::Force, + .interactionState = RenderInteractionState::CameraInteracting, + .exactRealtimeAllowed = false}); + + EXPECT_EQ(path, InteractiveRenderPath::KeepCurrentFrame); + } +} diff --git a/review.diff b/review.diff new file mode 100644 index 00000000..d6b8bcc6 --- /dev/null +++ b/review.diff @@ -0,0 +1,6573 @@ +diff --git a/gladius/src/Document.cpp b/gladius/src/Document.cpp +index 853019b..695f163 100644 +--- a/gladius/src/Document.cpp ++++ b/gladius/src/Document.cpp +@@ -1456,6 +1456,62 @@ namespace gladius + return summary; + } + ++ MeshQualityIssueSummary Document::getMeshQualityIssueSummary() const ++ { ++ MeshQualityIssueSummary summary{}; ++ std::string firstMessage; ++ ++ for (auto const & [key, resource] : getResourceManager().getResourceMap()) ++ { ++ if (key.getResourceType() != ResourceType::Mesh) ++ { ++ continue; ++ } ++ ++ auto const * spatialMesh = dynamic_cast(resource.get()); ++ if (spatialMesh == nullptr || !spatialMesh->hasMeshQualityIssues()) ++ { ++ continue; ++ } ++ ++ ++summary.affectedMeshCount; ++ summary.hasIssue = true; ++ ++ auto const & quality = spatialMesh->getMeshQualityDiagnostics(); ++ summary.degenerateTriangleCount += ++ static_cast(quality.degenerateTriangleCount); ++ summary.boundaryEdgeCount += static_cast(quality.boundaryEdgeCount); ++ summary.nonManifoldEdgeCount += ++ static_cast(quality.nonManifoldEdgeCount); ++ ++ if (firstMessage.empty()) ++ { ++ firstMessage = spatialMesh->formatMeshQualityMessage(key.getDisplayName()); ++ } ++ } ++ ++ if (!summary.hasIssue) ++ { ++ return summary; ++ } ++ ++ if (summary.affectedMeshCount == 1u) ++ { ++ summary.message = std::move(firstMessage); ++ return summary; ++ } ++ ++ summary.message = fmt::format( ++ "Mesh topology diagnostics affect {} mesh resources ({} boundary edges, {} " ++ "non-manifold edges, {} degenerate triangles total). First issue: {}", ++ summary.affectedMeshCount, ++ summary.boundaryEdgeCount, ++ summary.nonManifoldEdgeCount, ++ summary.degenerateTriangleCount, ++ firstMessage); ++ return summary; ++ } ++ + events::SharedLogger Document::getSharedLogger() const + { + if (!m_core) +@@ -1819,6 +1875,27 @@ namespace gladius + ++changedResources; + } + ++ if (spatialMesh->evaluationConfig().method == MeshSdfMethod::NanoVDB && ++ spatialMesh->hasMeshQualityIssues()) ++ { ++ auto const message = spatialMesh->formatMeshQualityMessage( ++ key.getDisplayName()); ++ auto logger = getSharedLogger(); ++ if (logger) ++ { ++ logger->addEvent( ++ {message, ++ nanovdbBuildPolicy.failurePolicy == NanoVdbFailurePolicy::Fail ++ ? events::Severity::Error ++ : events::Severity::Warning}); ++ } ++ ++ if (nanovdbBuildPolicy.failurePolicy == NanoVdbFailurePolicy::Fail) ++ { ++ throw NanoVdbBuildRejectedError(message); ++ } ++ } ++ + if (changed && spatialMesh->evaluationConfig().method == MeshSdfMethod::NanoVDB && + spatialMesh->hasNanoVdbBuildIssue()) + { +diff --git a/gladius/src/Document.h b/gladius/src/Document.h +index 4e1eaa6..759ada0 100644 +--- a/gladius/src/Document.h ++++ b/gladius/src/Document.h +@@ -459,6 +459,9 @@ namespace gladius + /// Summarize NanoVDB build issues on currently loaded mesh resources, if any. + [[nodiscard]] NanoVdbBuildIssueSummary getNanoVdbBuildIssueSummary() const; + ++ /// Summarize mesh topology diagnostics on currently loaded mesh resources, if any. ++ [[nodiscard]] MeshQualityIssueSummary getMeshQualityIssueSummary() const; ++ + /// Queue applying the mesh-SDF evaluation configuration to existing mesh resources. + /// Heavy resource rebuild/upload work is folded into the debounced background refresh so + /// the UI can keep showing the current preview. +diff --git a/gladius/src/GLImageBuffer.cpp b/gladius/src/GLImageBuffer.cpp +index 8d4de96..2872ed8 100644 +--- a/gladius/src/GLImageBuffer.cpp ++++ b/gladius/src/GLImageBuffer.cpp +@@ -38,7 +38,10 @@ namespace gladius + if (m_ComputeContext.outputMethod() == OutputMethod::readpixel) + { + setupForReadPixel(); ++ write(); + } ++ ++ m_dirty = false; + } + + GLImageBuffer::~GLImageBuffer() +diff --git a/gladius/src/MeshBVH.cpp b/gladius/src/MeshBVH.cpp +index bd89f3b..d276996 100644 +--- a/gladius/src/MeshBVH.cpp ++++ b/gladius/src/MeshBVH.cpp +@@ -9,6 +9,7 @@ + #include + #include + #include ++#include + #include + #include + #include +@@ -16,6 +17,96 @@ + + namespace gladius + { ++ namespace ++ { ++ std::uint32_t floatKeyBits(float const value) noexcept ++ { ++ float normalized = value; ++ if (normalized == 0.0f) ++ { ++ normalized = 0.0f; // canonicalise -0.0f to +0.0f ++ } ++ ++ std::uint32_t bits = 0u; ++ std::memcpy(&bits, &normalized, sizeof(bits)); ++ return bits; ++ } ++ ++ struct PointKey ++ { ++ std::uint32_t x = 0u; ++ std::uint32_t y = 0u; ++ std::uint32_t z = 0u; ++ ++ bool operator==(PointKey const & other) const noexcept ++ { ++ return x == other.x && y == other.y && z == other.z; ++ } ++ }; ++ ++ struct PointKeyHash ++ { ++ std::size_t operator()(PointKey const & key) const noexcept ++ { ++ std::size_t seed = std::hash{}(key.x); ++ seed ^= std::hash{}(key.y) + 0x9e3779b9u + (seed << 6u) + (seed >> 2u); ++ seed ^= std::hash{}(key.z) + 0x9e3779b9u + (seed << 6u) + (seed >> 2u); ++ return seed; ++ } ++ }; ++ ++ [[nodiscard]] PointKey makePointKey(float4 const & point) noexcept ++ { ++ return PointKey{floatKeyBits(point.x), floatKeyBits(point.y), floatKeyBits(point.z)}; ++ } ++ ++ [[nodiscard]] bool lessPointKey(PointKey const & lhs, PointKey const & rhs) noexcept ++ { ++ if (lhs.x != rhs.x) ++ { ++ return lhs.x < rhs.x; ++ } ++ if (lhs.y != rhs.y) ++ { ++ return lhs.y < rhs.y; ++ } ++ return lhs.z < rhs.z; ++ } ++ ++ struct EdgeKey ++ { ++ PointKey a; ++ PointKey b; ++ ++ bool operator==(EdgeKey const & other) const noexcept ++ { ++ return a == other.a && b == other.b; ++ } ++ }; ++ ++ struct EdgeKeyHash ++ { ++ std::size_t operator()(EdgeKey const & key) const noexcept ++ { ++ std::size_t seed = PointKeyHash{}(key.a); ++ auto const bHash = PointKeyHash{}(key.b); ++ seed ^= bHash + 0x9e3779b9u + (seed << 6u) + (seed >> 2u); ++ return seed; ++ } ++ }; ++ ++ [[nodiscard]] EdgeKey makeEdgeKey(float4 const & lhs, float4 const & rhs) noexcept ++ { ++ auto a = makePointKey(lhs); ++ auto b = makePointKey(rhs); ++ if (lessPointKey(b, a)) ++ { ++ std::swap(a, b); ++ } ++ return EdgeKey{a, b}; ++ } ++ } ++ + // ======================================================================== + // Build Context + // ======================================================================== +@@ -197,6 +288,10 @@ namespace gladius + ? static_cast(totalLeafPrims) / static_cast(m_lastStats.leafNodes) + : 0.0f; + ++ result.quality.degenerateTriangleCount = m_lastStats.degenerateTriangleCount; ++ result.quality.boundaryEdgeCount = m_lastStats.boundaryEdgeCount; ++ result.quality.nonManifoldEdgeCount = m_lastStats.nonManifoldEdgeCount; ++ + return result; + } + +@@ -219,6 +314,32 @@ namespace gladius + vn.normal = {0.0f, 0.0f, 0.0f, 0.0f}; + } + ++ struct AccumulatedNormal ++ { ++ float x = 0.0f; ++ float y = 0.0f; ++ float z = 0.0f; ++ }; ++ ++ // Accumulate by exact vertex position, not only by vertex index. Many STL-style ++ // triangle soups duplicate vertex records per face while still being geometrically ++ // watertight. Exact positional grouping preserves deterministic behaviour without ++ // silently modifying the mesh like a weld repair would. ++ std::unordered_map normalSums; ++ normalSums.reserve(vertexCount); ++ ++ auto const addNormal = [&](float4 const & vertex, ++ float const angle, ++ float const normalX, ++ float const normalY, ++ float const normalZ) ++ { ++ auto & sum = normalSums[makePointKey(vertex)]; ++ sum.x += angle * normalX; ++ sum.y += angle * normalY; ++ sum.z += angle * normalZ; ++ }; ++ + // Accumulate angle-weighted face normals for each vertex + for (size_t i = 0; i < indices.size(); ++i) + { +@@ -278,23 +399,23 @@ namespace gladius + float angle2 = std::numbers::pi_v - angle0 - angle1; + + // Add weighted face normal to each vertex +- data.vertexNormals[idx.i0].normal.x += angle0 * fnx; +- data.vertexNormals[idx.i0].normal.y += angle0 * fny; +- data.vertexNormals[idx.i0].normal.z += angle0 * fnz; +- +- data.vertexNormals[idx.i1].normal.x += angle1 * fnx; +- data.vertexNormals[idx.i1].normal.y += angle1 * fny; +- data.vertexNormals[idx.i1].normal.z += angle1 * fnz; +- +- data.vertexNormals[idx.i2].normal.x += angle2 * fnx; +- data.vertexNormals[idx.i2].normal.y += angle2 * fny; +- data.vertexNormals[idx.i2].normal.z += angle2 * fnz; ++ addNormal(p0, angle0, fnx, fny, fnz); ++ addNormal(p1, angle1, fnx, fny, fnz); ++ addNormal(p2, angle2, fnx, fny, fnz); + } + + // Normalize all vertex normals + for (size_t i = 0; i < vertexCount; ++i) + { + auto & n = data.vertexNormals[i].normal; ++ auto const it = normalSums.find(makePointKey(vertices[i])); ++ if (it != normalSums.end()) ++ { ++ n.x = it->second.x; ++ n.y = it->second.y; ++ n.z = it->second.z; ++ } ++ + float len = std::sqrt(n.x * n.x + n.y * n.y + n.z * n.z); + if (len > 1e-10f) + { +@@ -357,24 +478,6 @@ namespace gladius + faceNormals[t] = float4{fnx * invLen, fny * invLen, fnz * invLen, 1.0f}; + } + +- // Hash undirected edges to (triIdx, edgeIdx). On second hit we know both sides. +- struct EdgeKey +- { +- uint64_t key; +- bool operator==(EdgeKey const & o) const { return key == o.key; } +- }; +- struct EdgeKeyHash +- { +- size_t operator()(EdgeKey const & k) const noexcept { return std::hash{}(k.key); } +- }; +- +- auto const makeKey = [](uint32_t a, uint32_t b) -> EdgeKey +- { +- uint32_t lo = std::min(a, b); +- uint32_t hi = std::max(a, b); +- return EdgeKey{(static_cast(hi) << 32) | static_cast(lo)}; +- }; +- + struct EdgeRef + { + int triIdx; +@@ -395,7 +498,7 @@ namespace gladius + for (int e = 0; e < 3; ++e) + { + auto [va, vb] = edgeVertices(idx, e); +- EdgeKey const key = makeKey(va, vb); ++ EdgeKey const key = makeEdgeKey(vertices[va], vertices[vb]); + edgeMap[key].push_back(EdgeRef{static_cast(t), e}); + } + } +diff --git a/gladius/src/MeshBVH.h b/gladius/src/MeshBVH.h +index 68208c0..238a671 100644 +--- a/gladius/src/MeshBVH.h ++++ b/gladius/src/MeshBVH.h +@@ -139,6 +139,24 @@ namespace gladius + } + }; + ++ /// Topology diagnostics that affect deterministic signed-distance evaluation. ++ struct MeshQualityDiagnostics ++ { ++ /// Number of triangles skipped during normal computation due to a degenerate ++ /// edge length or zero face-normal magnitude. ++ int degenerateTriangleCount = 0; ++ /// Number of undirected edges incident to exactly one face (open boundary). ++ int boundaryEdgeCount = 0; ++ /// Number of undirected edges incident to three or more faces (non-manifold). ++ int nonManifoldEdgeCount = 0; ++ ++ [[nodiscard]] bool hasIssues() const noexcept ++ { ++ return degenerateTriangleCount != 0 || boundaryEdgeCount != 0 || ++ nonManifoldEdgeCount != 0; ++ } ++ }; ++ + /// Host-side container for mesh BVH data before serialization + struct SpatialMeshData + { +@@ -150,6 +168,7 @@ namespace gladius + std::vector fwnAggregates; ///< 1 aggregate per BVH node (same order as nodes); empty when not built + size_t originalTriangleCount = 0; ///< Source mesh triangle count + BoundingBox boundingBox; ///< Axis-aligned bounding box ++ MeshQualityDiagnostics quality; ///< Topology diagnostics collected during BVH construction + + /// Check if data is empty + bool empty() const +diff --git a/gladius/src/MeshSdfMethod.h b/gladius/src/MeshSdfMethod.h +index 6a92d60..f12d1d7 100644 +--- a/gladius/src/MeshSdfMethod.h ++++ b/gladius/src/MeshSdfMethod.h +@@ -106,6 +106,19 @@ namespace gladius + float suggestedVoxelSize_mm = 0.0f; + }; + ++ /// User-facing aggregate of mesh topology diagnostics that can make signed-distance ++ /// evaluation ambiguous unless the mesh is repaired or an orientation-independent ++ /// sign strategy is selected. ++ struct MeshQualityIssueSummary ++ { ++ bool hasIssue = false; ++ std::size_t affectedMeshCount = 0u; ++ std::size_t degenerateTriangleCount = 0u; ++ std::size_t boundaryEdgeCount = 0u; ++ std::size_t nonManifoldEdgeCount = 0u; ++ std::string message; ++ }; ++ + /// Raised when a strict NanoVDB policy rejects the requested load/build. + class NanoVdbBuildRejectedError : public std::runtime_error + { +diff --git a/gladius/src/SpatialMeshResource.cpp b/gladius/src/SpatialMeshResource.cpp +index 290e34a..6b89690 100644 +--- a/gladius/src/SpatialMeshResource.cpp ++++ b/gladius/src/SpatialMeshResource.cpp +@@ -257,6 +257,32 @@ namespace gladius + ResourceBase::load(); + } + ++ std::string SpatialMeshResource::formatMeshQualityMessage( ++ std::string const & displayName) const ++ { ++ if (!hasMeshQualityIssues()) ++ { ++ return {}; ++ } ++ ++ auto const subject = displayName.empty() ? std::string{"mesh resource"} ++ : fmt::format("mesh '{}'", displayName); ++ auto const & quality = m_data.quality; ++ auto message = fmt::format( ++ "Topology diagnostics for {}: {} boundary edges, {} non-manifold edges, " ++ "{} degenerate triangles. Signed mesh SDF methods, including NanoVDB, are " ++ "deterministic only for watertight, consistently oriented meshes.", ++ subject, ++ quality.boundaryEdgeCount, ++ quality.nonManifoldEdgeCount, ++ quality.degenerateTriangleCount); ++ ++ message += " No repair or fallback was applied silently. Enable explicit repair-on-import " ++ "and re-import, or select Fast Winding Number when the input is an intentional " ++ "triangle soup."; ++ return message; ++ } ++ + std::string SpatialMeshResource::formatNanoVdbBuildMessage( + std::string const & displayName) const + { +@@ -1048,16 +1074,31 @@ namespace gladius + flatMeshCompanion.end = static_cast(m_payloadData.data.size()); + + // -- Companion i+2: SDF_VDB (near signed-distance field, user-configured res) -- +- auto const nearSdfTransform = openvdb::math::Transform::createLinearTransform( +- static_cast(voxelSize_mm)); ++ // Keep the legacy VdbImporter value convention: build the SDF grid in ++ // voxel-scaled coordinates with an identity transform, then store ++ // PrimitiveMeta::scaling as the world_mm -> grid coordinate scale. The OpenCL ++ // vdbModel() sampler divides sampled grid values by the same scale to return ++ // millimetres. Building this grid directly in world space with a non-identity ++ // transform would store millimetre-valued distances and the kernel would scale ++ // them down a second time. ++ float const nearSdfScaling = 1.0f / voxelSize_mm; ++ auto nearSdfVerts = verts; ++ for (auto & vertex : nearSdfVerts) ++ { ++ vertex *= nearSdfScaling; ++ } ++ auto const nearSdfTransform = openvdb::math::Transform::createLinearTransform(1.0); + float constexpr kNearHalfBandVoxels = 8.0f; + auto nearSdfGrid = openvdb::tools::meshToLevelSet( +- *nearSdfTransform, verts, tris, kNearHalfBandVoxels); +- openvdb::tools::changeBackground(nearSdfGrid->tree(), +- std::numeric_limits::max()); ++ *nearSdfTransform, nearSdfVerts, tris, kNearHalfBandVoxels); ++ // Preserve OpenVDB's signed inactive tiles. The hybrid NanoVDB path uses this ++ // grid as its sign source while face-index grids provide the long-range ++ // magnitude. Replacing all inactive values with +max destroys the negative ++ // interior background and makes closed solids look outside once queries leave ++ // the active narrow band. + nearSdfGrid->pruneGrid(); + PrimitiveMeta sdfVdbCompanion = appendVdbGrid( +- openvdb::GridBase::Ptr(nearSdfGrid), 1.0f / voxelSize_mm, SDF_VDB); ++ openvdb::GridBase::Ptr(nearSdfGrid), nearSdfScaling, SDF_VDB); + m_nanovdbGridOffset = static_cast(sdfVdbCompanion.start); + + // -- Companion i+3: SDF_VDB_FACE_INDICES (far face-index, fixed 1mm / 150 voxels) -- +diff --git a/gladius/src/SpatialMeshResource.h b/gladius/src/SpatialMeshResource.h +index 7663d2d..c18e89a 100644 +--- a/gladius/src/SpatialMeshResource.h ++++ b/gladius/src/SpatialMeshResource.h +@@ -150,6 +150,23 @@ namespace gladius + return hasNanoVdbBuildIssue(); + } + ++ /// Mesh topology diagnostics captured during BVH construction. ++ [[nodiscard]] MeshQualityDiagnostics const & getMeshQualityDiagnostics() const noexcept ++ { ++ return m_data.quality; ++ } ++ ++ /// True when the source mesh has topology that can make signed SDF sign ambiguous. ++ [[nodiscard]] bool hasMeshQualityIssues() const noexcept ++ { ++ return m_data.quality.hasIssues(); ++ } ++ ++ /// Format mesh-quality diagnostics into a user-facing message. Returns an empty string ++ /// when the mesh has no recorded quality issues. ++ [[nodiscard]] std::string formatMeshQualityMessage( ++ std::string const & displayName) const; ++ + /// Format the current NanoVDB issue into a user-facing message. Returns an empty string + /// when the resource has no NanoVDB build issue. + [[nodiscard]] std::string formatNanoVdbBuildMessage( +diff --git a/gladius/src/compute/ComputeCore.cpp b/gladius/src/compute/ComputeCore.cpp +index 6cc8795..2f318ad 100644 +--- a/gladius/src/compute/ComputeCore.cpp ++++ b/gladius/src/compute/ComputeCore.cpp +@@ -12,6 +12,7 @@ + #include + #include + #include ++#include + #include + + #include +@@ -377,14 +378,11 @@ namespace gladius + + std::lock_guard + lock(m_computeMutex, std::adopt_lock); +- if (isAutoUpdateBoundingBoxEnabled()) +- { +- markBoundingBoxStale(); +- } + + auto & paramBuf = getResourceContext()->getParameterBuffer(); + auto & parameter = paramBuf.getData(); +- parameter.clear(); ++ std::vector updatedParameter; ++ updatedParameter.reserve(parameter.size()); + + int currentIndex = 0; + for (auto & model : assembly.getFunctions()) +@@ -413,21 +411,21 @@ namespace gladius + if (auto const typedValuePtr = std::get_if(&variant)) + { + param->setLookUpIndex(currentIndex); +- parameter.push_back(*typedValuePtr); ++ updatedParameter.push_back(*typedValuePtr); + ++currentIndex; + } + if (auto const typedValuePtr = std::get_if(&variant)) + { + param->setLookUpIndex(currentIndex); +- parameter.push_back(static_cast(*typedValuePtr)); ++ updatedParameter.push_back(static_cast(*typedValuePtr)); + ++currentIndex; + } + if (auto const typedValuePtr = std::get_if(&variant)) + { + param->setLookUpIndex(currentIndex); +- parameter.push_back(typedValuePtr->x); +- parameter.push_back(typedValuePtr->y); +- parameter.push_back(typedValuePtr->z); ++ updatedParameter.push_back(typedValuePtr->x); ++ updatedParameter.push_back(typedValuePtr->y); ++ updatedParameter.push_back(typedValuePtr->z); + currentIndex += 3; + } + +@@ -441,7 +439,7 @@ namespace gladius + for (int col = 0; col < 4; ++col) + { + float const value = matrix[row][col]; +- parameter.push_back(value); ++ updatedParameter.push_back(value); + ++currentIndex; + } + } +@@ -450,6 +448,15 @@ namespace gladius + } + } + ++ bool const parameterChanged = updatedParameter != parameter; ++ if (parameterChanged) ++ { ++ if (isAutoUpdateBoundingBoxEnabled()) ++ { ++ markBoundingBoxStale(); ++ } ++ } ++ parameter = std::move(updatedParameter); + paramBuf.write(); + // NOTE: Do NOT call invalidatePreCompSdf() here! + // We want to keep the old SDF valid for preview rendering during interactive +@@ -2084,6 +2091,52 @@ namespace gladius + return false; + } + ++ bool ComputeCore::renderSceneComputeOnlyWithSettings(cl::CommandQueue const & commandQueue, ++ size_t startLine, ++ size_t endLine, ++ ImageRGBA & targetImage, ++ RenderingSettings settings, ++ cl::Event * completionEvent) ++ { ++ ProfileFunction ++ ++ if (!m_computeMutex.try_lock()) ++ { ++ return false; ++ } ++ std::lock_guard lock(m_computeMutex, std::adopt_lock); ++ ++ recompileIfRequired(); ++ ++ if (getBestRenderProgram()->isCompilationInProgress()) ++ { ++ LOG_LOCATION ++ return false; ++ } ++ ++ m_lastUsedApproximation = settings.approximation; ++ m_lastUsedHQApproximation = settings.approximation; ++ m_lastUsedPreviewApproximation = settings.approximation; ++ ++ cl::Event const renderEvent = getBestRenderProgram()->renderSceneAsync( ++ commandQueue, *m_primitives, targetImage, settings, m_sliceHeight_mm, startLine, endLine); ++ ++ if (completionEvent != nullptr) ++ { ++ *completionEvent = renderEvent; ++ } ++ ++ if (renderEvent()) ++ { ++ commandQueue.flush(); ++ LOG_LOCATION ++ return true; ++ } ++ ++ LOG_LOCATION ++ return false; ++ } ++ + LowResPreviewRenderStatus ComputeCore::renderLowResPreview() const + { + ProfileFunction +diff --git a/gladius/src/compute/ComputeCore.h b/gladius/src/compute/ComputeCore.h +index 07f05cf..b75f8c5 100644 +--- a/gladius/src/compute/ComputeCore.h ++++ b/gladius/src/compute/ComputeCore.h +@@ -434,6 +434,23 @@ namespace gladius + ImageRGBA & targetImage, + cl::Event * completionEvent = nullptr); + ++ /// @brief Render scene to a pure OpenCL image buffer with caller-provided settings. ++ /// @param commandQueue OpenCL command queue used for dispatch ++ /// @param startLine Starting line for rendering ++ /// @param endLine Ending line for rendering ++ /// @param targetImage Pure CL image buffer to render into (no GL texture) ++ /// @param settings Rendering settings copy used for this dispatch ++ /// @param completionEvent Optional event for async completion tracking ++ /// @return true if rendering was enqueued successfully, false otherwise ++ /// @thread Any thread with compute context access ++ [[nodiscard]] bool renderSceneComputeOnlyWithSettings( ++ cl::CommandQueue const & commandQueue, ++ size_t startLine, ++ size_t endLine, ++ ImageRGBA & targetImage, ++ RenderingSettings settings, ++ cl::Event * completionEvent = nullptr); ++ + /// @brief Render the current low-resolution preview when the precomputed SDF is ready. + /// @return Rendered when a preview frame was produced, Skipped when the SDF is not ready, or Failed on an execution/precondition error. + [[nodiscard]] LowResPreviewRenderStatus renderLowResPreview() const; +diff --git a/gladius/src/io/3mf/Importer3mf.cpp b/gladius/src/io/3mf/Importer3mf.cpp +index 5433b2f..27d90c4 100644 +--- a/gladius/src/io/3mf/Importer3mf.cpp ++++ b/gladius/src/io/3mf/Importer3mf.cpp +@@ -1392,6 +1392,24 @@ namespace gladius::io + + auto * resource = resourceManager.getResourcePtr(key); + auto * spatialMesh = dynamic_cast(resource); ++ if (spatialMesh != nullptr && spatialMesh->hasMeshQualityIssues()) ++ { ++ auto const message = spatialMesh->formatMeshQualityMessage(key.getDisplayName()); ++ bool const strictNanoVdb = m_meshSdfEvaluationConfig.method == MeshSdfMethod::NanoVDB && ++ m_nanovdbBuildPolicy.failurePolicy == ++ NanoVdbFailurePolicy::Fail; ++ if (m_eventLogger) ++ { ++ m_eventLogger->addEvent({message, ++ strictNanoVdb ? gladius::events::Severity::Error ++ : gladius::events::Severity::Warning}); ++ } ++ if (strictNanoVdb) ++ { ++ resourceManager.deleteResource(key); ++ throw NanoVdbBuildRejectedError(message); ++ } ++ } + if (spatialMesh != nullptr && spatialMesh->hasNanoVdbBuildIssue()) + { + auto const message = spatialMesh->formatNanoVdbBuildMessage(key.getDisplayName()); +diff --git a/gladius/src/ui/MainWindow.cpp b/gladius/src/ui/MainWindow.cpp +index d83edb1..3158f60 100644 +--- a/gladius/src/ui/MainWindow.cpp ++++ b/gladius/src/ui/MainWindow.cpp +@@ -2721,14 +2721,21 @@ namespace gladius::ui + // bypass the throttle — the debounce already elapsed, we just need to retry. + if (m_parameterThrottle.shouldRecompile() || !m_parameterThrottle.hasPendingRecompile()) + { ++ bool const useStreamingPreview = !m_renderWindow.isForceRealtimeMode(); ++ + // Streaming preview mode: the worker coroutine becomes the sole GPU + // writer, reading the latest Assembly values and pushing them to the + // parameter buffer in a tight loop. This eliminates the UI-frame + // round-trip latency that limits one-shot preview scheduling. ++ // In Force realtime mode, parameter changes should be served by exact ++ // full-frame raymarching instead of the low-res streaming preview. + if (!m_renderWindow.isStreamingPreviewActive()) + { + m_renderWindow.invalidateViewDueToParameterChange(); +- m_renderWindow.startStreamingPreview(); ++ if (useStreamingPreview) ++ { ++ m_renderWindow.startStreamingPreview(); ++ } + } + m_parameterDirty = false; + m_contoursDirty = true; +@@ -3440,6 +3447,15 @@ namespace gladius::ui + } + return m_doc->getNanoVdbBuildIssueSummary(); + }); ++ m_meshSdfSettingsDialog.setMeshQualityIssueProvider( ++ [this]() -> MeshQualityIssueSummary ++ { ++ if (!m_doc) ++ { ++ return {}; ++ } ++ return m_doc->getMeshQualityIssueSummary(); ++ }); + } + + void MainWindow::setVdbSupported(bool supported, std::string const & reason) +diff --git a/gladius/src/ui/MeshSdfSettingsDialog.cpp b/gladius/src/ui/MeshSdfSettingsDialog.cpp +index 70a36b7..a117cd6 100644 +--- a/gladius/src/ui/MeshSdfSettingsDialog.cpp ++++ b/gladius/src/ui/MeshSdfSettingsDialog.cpp +@@ -21,6 +21,11 @@ namespace gladius::ui + m_nanovdbIssueProvider = std::move(provider); + } + ++ void MeshSdfSettingsDialog::setMeshQualityIssueProvider(MeshQualityIssueProvider provider) ++ { ++ m_meshQualityIssueProvider = std::move(provider); ++ } ++ + void MeshSdfSettingsDialog::show() + { + syncFromSettings(); +@@ -92,6 +97,38 @@ namespace gladius::ui + // ---- Evaluation method ------------------------------------------------ + if (ImGui::CollapsingHeader("Evaluation", ImGuiTreeNodeFlags_DefaultOpen)) + { ++ if (m_meshQualityIssueProvider) ++ { ++ auto const qualityIssue = m_meshQualityIssueProvider(); ++ if (qualityIssue.hasIssue) ++ { ++ ImGui::TextColored(ImVec4(1.0f, 0.68f, 0.18f, 1.0f), ++ "Mesh topology diagnostics detected"); ++ ImGui::PushTextWrapPos(ImGui::GetFontSize() * 46.0f); ++ ImGui::TextUnformatted(qualityIssue.message.c_str()); ++ ImGui::PopTextWrapPos(); ++ ImGui::TextDisabled( ++ "No silent repair or fallback was applied; choose an explicit recovery and Apply."); ++ ++ if (ImGui::Button("Enable repair for next import")) ++ { ++ m_repair.weld = true; ++ m_repair.removeDegenerate = true; ++ m_repair.orientConsistently = true; ++ m_repair.fillHoles = true; ++ m_dirty = true; ++ } ++ ImGui::SameLine(); ++ if (ImGui::Button("Use FWN sign")) ++ { ++ m_eval.method = MeshSdfMethod::FastWindingNumber; ++ m_eval.fwnUseSignCache = false; ++ m_dirty = true; ++ } ++ ImGui::Separator(); ++ } ++ } ++ + char const * preview = methodLabel(m_eval.method); + if (ImGui::BeginCombo("Method", preview)) + { +diff --git a/gladius/src/ui/MeshSdfSettingsDialog.h b/gladius/src/ui/MeshSdfSettingsDialog.h +index 6ec5961..81f386d 100644 +--- a/gladius/src/ui/MeshSdfSettingsDialog.h ++++ b/gladius/src/ui/MeshSdfSettingsDialog.h +@@ -24,6 +24,7 @@ namespace gladius::ui + public: + using ApplyCallback = std::function; + using NanoVdbIssueProvider = std::function; ++ using MeshQualityIssueProvider = std::function; + + MeshSdfSettingsDialog() = default; + ~MeshSdfSettingsDialog() = default; +@@ -42,6 +43,9 @@ namespace gladius::ui + /// Provide the callback used to query runtime NanoVDB build issues. + void setNanoVdbIssueProvider(NanoVdbIssueProvider provider); + ++ /// Provide the callback used to query runtime mesh-quality diagnostics. ++ void setMeshQualityIssueProvider(MeshQualityIssueProvider provider); ++ + void show(); + void hide(); + [[nodiscard]] bool isVisible() const noexcept; +@@ -60,6 +64,7 @@ namespace gladius::ui + MeshSdfSettings * m_settings = nullptr; + ApplyCallback m_applyCallback; + NanoVdbIssueProvider m_nanovdbIssueProvider; ++ MeshQualityIssueProvider m_meshQualityIssueProvider; + + bool m_visible = false; + bool m_dirty = false; +diff --git a/gladius/src/ui/OrbitalCamera.cpp b/gladius/src/ui/OrbitalCamera.cpp +index f6f8aea..0f76f77 100644 +--- a/gladius/src/ui/OrbitalCamera.cpp ++++ b/gladius/src/ui/OrbitalCamera.cpp +@@ -108,11 +108,15 @@ namespace gladius::ui + auto const speedFactor = 15.E-3f; + // if (deltaTime_ms > 50.f) + { ++ changed = fabs(m_pitchTarget - m_pitch) > tolerance || ++ fabs(m_yawTarget - m_yaw) > tolerance || ++ fabs(m_eyeDistTarget - m_eyeDist) > tolerance || ++ (m_lookAtTarget - m_lookAt).norm() > tolerance; + m_pitch = m_pitchTarget; + m_yaw = m_yawTarget; + m_eyeDist = m_eyeDistTarget; + m_lookAt = m_lookAtTarget; +- return false; ++ return changed; + } + // deltaTime_ms = std::min(deltaTime_ms, 50.f); + +diff --git a/gladius/src/ui/RenderWindow.cpp b/gladius/src/ui/RenderWindow.cpp +index a9fd48e..885bb5e 100644 +--- a/gladius/src/ui/RenderWindow.cpp ++++ b/gladius/src/ui/RenderWindow.cpp +@@ -5,6 +5,7 @@ + #include + #include + #include ++#include + + #include "../CLMath.h" + #include "../ComputeContext.h" +@@ -147,6 +148,9 @@ namespace gladius::ui + m_configManager->getValue("renderWindow", "permanentCenteringEnabled", false); + } + ++ auto const realtimeConfig = loadRealtimeRaymarchConfig(); ++ m_renderUpdateCoordinator.configureRealtime(realtimeConfig); ++ + // Don't initialize async rendering here - will be done lazily on first render + } + +@@ -202,11 +206,17 @@ namespace gladius::ui + m_asyncController->start(); + m_asyncEpochCounter.store(0, std::memory_order_release); + m_asyncCurrentEpoch.store(0, std::memory_order_release); ++ m_asyncViewEpoch.store(0, std::memory_order_release); + m_asyncInFlightEpoch.store(0, std::memory_order_release); ++ m_asyncInFlightViewEpoch.store(0, std::memory_order_release); + m_asyncFrameCounter.store(0, std::memory_order_release); + m_asyncJobInFlight.store(false, std::memory_order_release); ++ m_asyncRealtimeJobInFlight.store(false, std::memory_order_release); + m_asyncSdfJobInFlight.store(false, std::memory_order_release); + m_asyncSdfInFlightEpoch.store(0, std::memory_order_release); ++ m_renderUpdateCoordinator = async_rendering::RenderUpdateCoordinator{}; ++ m_renderUpdateCoordinator.configureRealtime(loadRealtimeRaymarchConfig()); ++ m_pendingRenderCommands.clear(); + notifyAsyncEpochIncrement(); + } + else +@@ -218,9 +228,12 @@ namespace gladius::ui + } + m_asyncEpochCounter.store(0, std::memory_order_release); + m_asyncCurrentEpoch.store(0, std::memory_order_release); ++ m_asyncViewEpoch.store(0, std::memory_order_release); + m_asyncInFlightEpoch.store(0, std::memory_order_release); ++ m_asyncInFlightViewEpoch.store(0, std::memory_order_release); + m_asyncFrameCounter.store(0, std::memory_order_release); + m_asyncJobInFlight.store(false, std::memory_order_release); ++ m_asyncRealtimeJobInFlight.store(false, std::memory_order_release); + m_asyncSdfJobInFlight.store(false, std::memory_order_release); + m_asyncSdfInFlightEpoch.store(0, std::memory_order_release); + } +@@ -237,8 +250,11 @@ namespace gladius::ui + auto const oldEpoch = m_asyncCurrentEpoch.load(std::memory_order_acquire); + auto const newEpoch = m_asyncEpochCounter.fetch_add(1, std::memory_order_acq_rel) + 1; + m_asyncCurrentEpoch.store(newEpoch, std::memory_order_release); ++ m_asyncViewEpoch.fetch_add(1, std::memory_order_acq_rel); + m_asyncInFlightEpoch.store(0, std::memory_order_release); ++ m_asyncInFlightViewEpoch.store(0, std::memory_order_release); + m_asyncJobInFlight.store(false, std::memory_order_release); ++ m_asyncRealtimeJobInFlight.store(false, std::memory_order_release); + m_asyncSdfJobInFlight.store(false, std::memory_order_release); + m_asyncSdfInFlightEpoch.store(0, std::memory_order_release); + m_asyncBboxJobInFlight.store(false, std::memory_order_release); +@@ -259,6 +275,7 @@ namespace gladius::ui + m_asyncProgressiveBuffer = nullptr; + } + m_asyncProgressiveEpoch.store(0, std::memory_order_release); ++ m_asyncProgressiveViewEpoch.store(0, std::memory_order_release); + + // IMPORTANT: Release any other Writing buffers from old epoch + // (they belong to cancelled jobs and should be returned to Idle) +@@ -269,6 +286,348 @@ namespace gladius::ui + } + } + ++ void RenderWindow::invalidateCameraView() ++ { ++ ProfileFunction; ++ m_dirty = true; ++ // Camera interaction supersedes parameter streaming. Leaving the streaming flag ++ // latched blocks realtime admission and makes Force mode hold a stale frame even ++ // when no exact realtime job is actually in flight. ++ m_streamingPreviewActive.store(false, std::memory_order_release); ++ m_streamingFrameConsumed.store(true, std::memory_order_release); ++ m_renderWindowState.isMoving = true; ++ m_renderWindowState.currentLine = 0; ++ m_renderWindowState.renderingStepSize = kInitialProgressiveStepSize; ++ m_renderWindowState.isRendering = false; ++ bool const forceLowResPreview = ++ m_renderUpdateCoordinator.realtimeConfig().mode == async_rendering::RealtimeRaymarchMode::Off; ++ m_forceLowResRenderOnNextFrame.store(forceLowResPreview, std::memory_order_release); ++ if (forceLowResPreview) ++ { ++ m_lastLowResRenderTime = std::chrono::system_clock::now(); ++ } ++ else ++ { ++ m_lowResFeedbackPending.store(false, std::memory_order_release); ++ } ++ m_suppressHQDisplay.store(false, std::memory_order_release); ++ auto const newViewEpoch = m_asyncViewEpoch.fetch_add(1, std::memory_order_acq_rel) + 1; ++ if (m_asyncController) ++ { ++ m_asyncController->setLatestViewEpoch(newViewEpoch); ++ } ++ queueRenderDecision(m_renderUpdateCoordinator.notifyCameraChanged()); ++ ++ if (m_core) ++ { ++ m_core->invalidateDistanceInitBuffer(); ++ } ++ ++ if (m_asyncController && m_asyncProgressiveBuffer && ++ !m_asyncJobInFlight.load(std::memory_order_acquire)) ++ { ++ [[maybe_unused]] bool const released = ++ m_asyncController->tryTransitionBuffer(m_asyncProgressiveBuffer, ++ async_rendering::FrameState::Writing, ++ async_rendering::FrameState::Idle); ++ m_asyncProgressiveBuffer = nullptr; ++ m_asyncProgressiveEpoch.store(0, std::memory_order_release); ++ m_asyncProgressiveViewEpoch.store(0, std::memory_order_release); ++ } ++ } ++ ++ async_rendering::RealtimeRaymarchConfig RenderWindow::loadRealtimeRaymarchConfig() const ++ { ++ async_rendering::RealtimeRaymarchConfig config{}; ++ if (!m_configManager) ++ { ++ return config; ++ } ++ ++ auto const mode = m_configManager->getValue( ++ "renderWindow", "realtimeRaymarchMode", "auto"); ++ config.mode = async_rendering::realtimeRaymarchModeFromString(mode); ++ config.targetFrameTimeMs = ++ m_configManager->getValue("renderWindow", "realtimeRaymarchTargetMs", 25.0f); ++ return config; ++ } ++ ++ void RenderWindow::saveRealtimeRaymarchMode( ++ async_rendering::RealtimeRaymarchMode mode) const ++ { ++ if (!m_configManager) ++ { ++ return; ++ } ++ ++ m_configManager->setValue("renderWindow", ++ "realtimeRaymarchMode", ++ std::string(async_rendering::realtimeRaymarchModeToString(mode))); ++ m_configManager->save(); ++ } ++ ++ void RenderWindow::queueRenderDecision(async_rendering::RenderUpdateDecision decision) ++ { ++ m_pendingRenderCommands.insert(m_pendingRenderCommands.end(), ++ std::make_move_iterator(decision.commands.begin()), ++ std::make_move_iterator(decision.commands.end())); ++ } ++ ++ bool RenderWindow::executeQueuedRenderCommands(RenderWindowState & state) ++ { ++ bool startedWork = false; ++ size_t processedCommands = 0; ++ while (!m_pendingRenderCommands.empty() && processedCommands < 64) ++ { ++ auto command = std::move(m_pendingRenderCommands.front()); ++ m_pendingRenderCommands.erase(m_pendingRenderCommands.begin()); ++ startedWork = executeRenderCommand(command, state) || startedWork; ++ ++processedCommands; ++ } ++ return startedWork; ++ } ++ ++ bool RenderWindow::executeRenderCommand(async_rendering::RenderCommand const & command, ++ RenderWindowState & state) ++ { ++ if (command.type != async_rendering::RenderCommandType::StartTask) ++ { ++ return false; ++ } ++ ++ return scheduleCoordinatorTask(command.task, state); ++ } ++ ++ bool RenderWindow::scheduleCoordinatorTask(async_rendering::RenderTaskRequest const & task, ++ RenderWindowState & state) ++ { ++ auto const failTask = [&]() ++ { completeCoordinatorTask(task, async_rendering::RenderTaskStatus::Failed); }; ++ ++ switch (task.type) ++ { ++ case async_rendering::RenderTaskType::ProgramCompilation: ++ if (m_core && m_core->tryIsRenderProgramReady().value_or(false)) ++ { ++ queueRenderDecision(m_renderUpdateCoordinator.notifyProgramCompilationCompleted()); ++ } ++ return false; ++ ++ case async_rendering::RenderTaskType::ParameterUpload: ++ { ++ bool updated = false; ++ if (m_core && m_document) ++ { ++ if (auto assembly = m_document->getAssembly(); assembly && m_core->isRendererReady()) ++ { ++ updated = m_core->tryToupdateParameter(*assembly); ++ } ++ } ++ completeCoordinatorTask(task, ++ updated ? async_rendering::RenderTaskStatus::Completed ++ : async_rendering::RenderTaskStatus::Failed); ++ return false; ++ } ++ ++ case async_rendering::RenderTaskType::BoundingBoxUpdate: ++ { ++ bool const wasInFlight = m_asyncBboxJobInFlight.load(std::memory_order_acquire); ++ scheduleAsyncBboxUpdate(&task); ++ bool const started = !wasInFlight && m_asyncBboxJobInFlight.load(std::memory_order_acquire); ++ if (!started) ++ { ++ failTask(); ++ } ++ return started; ++ } ++ ++ case async_rendering::RenderTaskType::SdfPrecomputation: ++ { ++ bool const started = scheduleAsyncSdfPrecomputation(&task); ++ if (!started) ++ { ++ failTask(); ++ } ++ return started; ++ } ++ ++ case async_rendering::RenderTaskType::RealtimeFullFrame: ++ { ++ bool const started = scheduleRealtimeRenderJob(state, &task); ++ if (!started) ++ { ++ m_renderUpdateCoordinator.recordRealtimeRejectedAttempt(); ++ failTask(); ++ } ++ return started; ++ } ++ ++ case async_rendering::RenderTaskType::LowResolutionPreview: ++ if (isRealtimeRaymarchCameraInteraction()) ++ { ++ completeCoordinatorTask(task, async_rendering::RenderTaskStatus::Cancelled); ++ return false; ++ } ++ if (!m_core || !m_core->isSdfValid()) ++ { ++ failTask(); ++ return false; ++ } ++ { ++ bool const started = scheduleAsyncPreviewJob(&task); ++ if (!started) ++ { ++ failTask(); ++ } ++ return started; ++ } ++ ++ case async_rendering::RenderTaskType::StreamingPreview: ++ m_streamingPreviewActive.store(true, std::memory_order_release); ++ { ++ bool const started = scheduleStreamingPreviewJob(&task); ++ if (!started) ++ { ++ failTask(); ++ } ++ return started; ++ } ++ ++ case async_rendering::RenderTaskType::ProgressiveHighQualityChunk: ++ state.isRendering = true; ++ if (!scheduleAsyncRenderJob(state, &task)) ++ { ++ state.isRendering = false; ++ failTask(); ++ return false; ++ } ++ return true; ++ } ++ ++ return false; ++ } ++ ++ bool RenderWindow::isRealtimeRaymarchCameraInteraction() const noexcept ++ { ++ return m_renderUpdateCoordinator.interactionState() == ++ async_rendering::RenderInteractionState::CameraInteracting && ++ m_asyncRealtimeJobInFlight.load(std::memory_order_acquire); ++ } ++ ++ bool RenderWindow::isForceRealtimeMode() const noexcept ++ { ++ return m_renderUpdateCoordinator.realtimeConfig().mode == ++ async_rendering::RealtimeRaymarchMode::Force; ++ } ++ ++ bool RenderWindow::scheduleAsyncSdfPrecomputation( ++ async_rendering::RenderTaskRequest const * coordinatorTask) ++ { ++ if (!m_asyncController || !m_asyncController->isRunning() || ++ m_asyncSdfJobInFlight.load(std::memory_order_acquire)) ++ { ++ return false; ++ } ++ ++ async_rendering::RenderJob sdfJob{}; ++ sdfJob.type = async_rendering::RenderJobType::SDFPrecomputation; ++ if (coordinatorTask != nullptr) ++ { ++ sdfJob.coordinatorRequestId = coordinatorTask->requestId; ++ sdfJob.coordinatorStamp = coordinatorTask->stamp; ++ } ++ sdfJob.epoch = m_asyncCurrentEpoch.load(std::memory_order_acquire); ++ if (sdfJob.epoch == 0) ++ { ++ sdfJob.epoch = m_asyncEpochCounter.fetch_add(1, std::memory_order_acq_rel) + 1; ++ m_asyncCurrentEpoch.store(sdfJob.epoch, std::memory_order_release); ++ } ++ m_asyncController->setLatestEpoch(sdfJob.epoch); ++ m_asyncSdfJobInFlight.store(true, std::memory_order_release); ++ m_asyncSdfInFlightEpoch.store(sdfJob.epoch, std::memory_order_release); ++ m_asyncController->enqueueJob(sdfJob); ++ return true; ++ } ++ ++ void RenderWindow::completeCoordinatorTask(async_rendering::RenderTaskRequest const & request, ++ async_rendering::RenderTaskStatus status) ++ { ++ queueRenderDecision(m_renderUpdateCoordinator.completeTask( ++ async_rendering::RenderTaskResult{.requestId = request.requestId, ++ .type = request.type, ++ .stamp = request.stamp, ++ .status = status})); ++ } ++ ++ void RenderWindow::completeCoordinatorTask(async_rendering::FrameResultMeta const & result, ++ bool producedDisplayFrame) ++ { ++ if (result.coordinatorRequestId == 0) ++ { ++ return; ++ } ++ ++ auto taskType = async_rendering::RenderTaskType::ProgressiveHighQualityChunk; ++ switch (result.jobType) ++ { ++ case async_rendering::RenderJobType::RealtimeHighQuality: ++ taskType = async_rendering::RenderTaskType::RealtimeFullFrame; ++ break; ++ case async_rendering::RenderJobType::LowResPreview: ++ taskType = async_rendering::RenderTaskType::LowResolutionPreview; ++ break; ++ case async_rendering::RenderJobType::StreamingPreview: ++ taskType = async_rendering::RenderTaskType::StreamingPreview; ++ break; ++ case async_rendering::RenderJobType::BoundingBoxUpdate: ++ taskType = async_rendering::RenderTaskType::BoundingBoxUpdate; ++ break; ++ case async_rendering::RenderJobType::ParameterUpdate: ++ taskType = async_rendering::RenderTaskType::ParameterUpload; ++ break; ++ case async_rendering::RenderJobType::SDFPrecomputation: ++ taskType = async_rendering::RenderTaskType::SdfPrecomputation; ++ break; ++ case async_rendering::RenderJobType::ProgramCompilation: ++ taskType = async_rendering::RenderTaskType::ProgramCompilation; ++ break; ++ case async_rendering::RenderJobType::HighQuality: ++ default: ++ taskType = async_rendering::RenderTaskType::ProgressiveHighQualityChunk; ++ break; ++ } ++ ++ auto const status = result.cancelled ? async_rendering::RenderTaskStatus::Cancelled ++ : async_rendering::RenderTaskStatus::Completed; ++ queueRenderDecision(m_renderUpdateCoordinator.completeTask( ++ async_rendering::RenderTaskResult{.requestId = result.coordinatorRequestId, ++ .type = taskType, ++ .stamp = result.coordinatorStamp, ++ .status = status, ++ .durationNs = result.computeDurationNs, ++ .producedDisplayFrame = producedDisplayFrame, ++ .completedFrame = result.completedFrame})); ++ } ++ ++ void RenderWindow::completeCoordinatorPreviewTask(async_rendering::PreviewResultMeta const & result) ++ { ++ if (result.coordinatorRequestId == 0) ++ { ++ return; ++ } ++ ++ auto const status = result.cancelled ? async_rendering::RenderTaskStatus::Cancelled ++ : async_rendering::RenderTaskStatus::Completed; ++ queueRenderDecision(m_renderUpdateCoordinator.completeTask( ++ async_rendering::RenderTaskResult{.requestId = result.coordinatorRequestId, ++ .type = async_rendering::RenderTaskType::LowResolutionPreview, ++ .stamp = result.coordinatorStamp, ++ .status = status, ++ .durationNs = result.latencyNs, ++ .producedDisplayFrame = !result.cancelled, ++ .completedFrame = !result.cancelled})); ++ } ++ + void RenderWindow::renderWindow() + { + ProfileFunction; +@@ -303,21 +662,33 @@ namespace gladius::ui + // Use the front buffer from triple buffering system + auto * frontBuf = m_asyncController->frontBuffer(); + auto const currentEpoch = m_asyncCurrentEpoch.load(std::memory_order_acquire); ++ auto const currentViewEpoch = m_asyncViewEpoch.load(std::memory_order_acquire); + + // For HQ frames (front buffer from progressive rendering) we require an epoch match + // to avoid showing stale HQ results from an old parameter set. + // For low-res preview during rapid parameter edits, we fall back to m_resultImage + // which is updated synchronously and always shows *something* recent. ++ // Exact realtime camera motion must not fall back to that preview texture. ++ bool const realtimeCameraInteraction = isRealtimeRaymarchCameraInteraction(); ++ bool const exactRealtimeJobInFlight = ++ m_asyncRealtimeJobInFlight.load(std::memory_order_acquire); + bool const epochMatches = frontBuf && frontBuf->epoch == currentEpoch; ++ bool const viewMatches = frontBuf && frontBuf->viewEpoch == currentViewEpoch; ++ bool const allowRealtimeFront = ++ (realtimeCameraInteraction || exactRealtimeJobInFlight) && epochMatches; ++ bool const frontBlockedByRendering = m_renderWindowState.isRendering && ++ !allowRealtimeFront; + bool const useFrontBuffer = frontBuf && frontBuf->image && epochMatches && +- !m_renderWindowState.isRendering && +- !m_renderWindowState.isMoving && ++ (viewMatches || allowRealtimeFront) && ++ !frontBlockedByRendering && + !m_suppressHQDisplay.load(std::memory_order_acquire); +- bool const useProgressiveBuffer = +- m_asyncProgressiveBuffer && m_asyncProgressiveBuffer->image && +- m_asyncProgressiveEpoch.load(std::memory_order_acquire) == currentEpoch && +- !m_renderWindowState.isMoving && +- !m_suppressHQDisplay.load(std::memory_order_acquire); ++ bool const progressiveBufferCurrent = ++ m_asyncProgressiveEpoch.load(std::memory_order_acquire) == currentEpoch && ++ m_asyncProgressiveViewEpoch.load(std::memory_order_acquire) == currentViewEpoch; ++ bool const useProgressiveBuffer = ++ m_asyncProgressiveBuffer && m_asyncProgressiveBuffer->image && ++ progressiveBufferCurrent && !m_renderWindowState.isMoving && ++ !m_suppressHQDisplay.load(std::memory_order_acquire); + + if (useFrontBuffer) + { +@@ -329,7 +700,9 @@ namespace gladius::ui + } + else + { +- // Fallback to result image for progressive rendering or when no valid front buffer ++ // Fallback to result image for progressive rendering or when no valid front buffer. ++ // Preview result suppression keeps realtime camera motion from writing stale ++ // surrogate frames here; returning nullptr would blank the viewport. + displayImage = m_core->getResultImage(); + } + } +@@ -470,6 +843,45 @@ namespace gladius::ui + } + m_renderWindowState.renderQuality = quality; + ++ ImGui::Separator(); ++ if (ImGui::BeginMenu("Realtime Raymarch")) ++ { ++ auto config = m_renderUpdateCoordinator.realtimeConfig(); ++ auto const setMode = [&](async_rendering::RealtimeRaymarchMode mode) ++ { ++ config.mode = mode; ++ m_renderUpdateCoordinator.configureRealtime(config); ++ saveRealtimeRaymarchMode(mode); ++ }; ++ ++ if (ImGui::MenuItem( ++ "Auto", nullptr, config.mode == async_rendering::RealtimeRaymarchMode::Auto)) ++ { ++ setMode(async_rendering::RealtimeRaymarchMode::Auto); ++ } ++ if (ImGui::MenuItem( ++ "Off", nullptr, config.mode == async_rendering::RealtimeRaymarchMode::Off)) ++ { ++ setMode(async_rendering::RealtimeRaymarchMode::Off); ++ } ++ if (ImGui::MenuItem( ++ "Force", nullptr, config.mode == async_rendering::RealtimeRaymarchMode::Force)) ++ { ++ setMode(async_rendering::RealtimeRaymarchMode::Force); ++ } ++ ++ ImGui::Separator(); ++ ImGui::Text("Budget: %.1f ms", config.targetFrameTimeMs); ++ if (ImGui::IsItemHovered()) ++ { ++ ImGui::SetTooltip( ++ "Auto attempts full-resolution async raymarching when " ++ "recent timings fit this budget; otherwise Gladius " ++ "falls back to low-res preview/progressive rendering."); ++ } ++ ImGui::EndMenu(); ++ } ++ + ImGui::EndMenu(); + } + +@@ -551,7 +963,15 @@ namespace gladius::ui + // Update camera and set isMoving based on whether camera is currently animating. + // Note: Parameter changes use forceLowResRender and lowResFeedbackPending flags, + // not isMoving. isMoving is specifically for camera movement. +- m_renderWindowState.isMoving = m_camera.update(io.DeltaTime * 1000.f); ++ bool const cameraActuallyMoving = m_camera.update(io.DeltaTime * 1000.f); ++ if (cameraActuallyMoving) { ++ m_cameraIdleFrames = 0; ++ m_renderWindowState.isMoving = true; ++ } else { ++ m_cameraIdleFrames++; ++ // Keep isMoving true for 10 frames after last movement to debounce fast refresh rates ++ m_renderWindowState.isMoving = m_cameraIdleFrames < 10; ++ } + m_dirty = m_dirty || m_renderWindowState.isMoving; + + // Floating cut-height slider overlay (inside the Preview window) +@@ -567,7 +987,7 @@ namespace gladius::ui + + if (m_camera.mouseMotionHandler(mousePos.x, mousePos.y)) + { +- invalidateView(); ++ invalidateCameraView(); + } + if (!ImGui::IsAnyMouseDown()) + { +@@ -679,6 +1099,7 @@ namespace gladius::ui + + // Force a low-res render on next frame for immediate visual feedback + m_forceLowResRenderOnNextFrame.store(true, std::memory_order_release); ++ queueRenderDecision(m_renderUpdateCoordinator.notifyStructuralModelChanged()); + + invalidateView(); + +@@ -721,8 +1142,24 @@ namespace gladius::ui + m_core->setSdfValid(false); + + m_dirty = true; +- m_forceLowResRenderOnNextFrame.store(true, std::memory_order_release); +- m_lastLowResRenderTime = std::chrono::system_clock::now(); ++ ++ // In realtime mode, mirror invalidateCameraView(): skip the legacy low-res preview ++ // path entirely. The coordinator already schedules a RealtimeFullFrame, and ++ // m_forceLowResRenderOnNextFrame / m_lowResFeedbackPending would trigger the old ++ // voxel-SDF preview path, producing a stale flash instead of instant feedback. ++ bool const isRealtimeMode = ++ m_renderUpdateCoordinator.realtimeConfig().mode != ++ async_rendering::RealtimeRaymarchMode::Off; ++ m_forceLowResRenderOnNextFrame.store(!isRealtimeMode, std::memory_order_release); ++ if (isRealtimeMode) ++ { ++ m_lowResFeedbackPending.store(false, std::memory_order_release); ++ } ++ else ++ { ++ m_lastLowResRenderTime = std::chrono::system_clock::now(); ++ m_lowResFeedbackPending.store(true, std::memory_order_release); ++ } + + // Cancel any in-progress progressive render (stale parameters) + m_renderWindowState.currentLine = 0; +@@ -731,8 +1168,9 @@ namespace gladius::ui + + m_preComputedSdfDirty.store(true, std::memory_order_release); + m_parameterDirty.store(true, std::memory_order_release); +- m_lowResFeedbackPending.store(true, std::memory_order_release); + m_modelModifiedSinceLastCenter = true; ++ m_asyncViewEpoch.fetch_add(1, std::memory_order_acq_rel); ++ queueRenderDecision(m_renderUpdateCoordinator.notifyParameterChanged(true)); + + // Mark bbox stale instead of resetting — preserves cached bbox for reuse with extra margin + m_core->markBoundingBoxStale(); +@@ -829,7 +1267,7 @@ namespace gladius::ui + void RenderWindow::centerView() + { + m_centerViewRequested = true; +- invalidateView(); ++ invalidateCameraView(); + } + + void RenderWindow::setTopView() +@@ -837,7 +1275,7 @@ namespace gladius::ui + // Top view: looking down the Z axis (pitch = +90°, yaw = 0°) + m_camera.setAngle(CL_M_PI_F / 2.0f, 0.0f); + onCameraManuallyMoved(); +- invalidateView(); ++ invalidateCameraView(); + } + + void RenderWindow::setFrontView() +@@ -845,7 +1283,7 @@ namespace gladius::ui + // Front view: looking along the Y axis (pitch = 0°, yaw = -90°) + m_camera.setAngle(0.0f, -CL_M_PI_F / 2.0f); + onCameraManuallyMoved(); +- invalidateView(); ++ invalidateCameraView(); + } + + void RenderWindow::setLeftView() +@@ -853,7 +1291,7 @@ namespace gladius::ui + // Left view: looking along the X axis (pitch = 0°, yaw = 0°) + m_camera.setAngle(0.0f, 0.0f); + onCameraManuallyMoved(); +- invalidateView(); ++ invalidateCameraView(); + } + + void RenderWindow::setRightView() +@@ -861,7 +1299,7 @@ namespace gladius::ui + // Right view: looking along the negative X axis (pitch = 0°, yaw = 180°) + m_camera.setAngle(0.0f, CL_M_PI_F); + onCameraManuallyMoved(); +- invalidateView(); ++ invalidateCameraView(); + } + + void RenderWindow::setBackView() +@@ -869,7 +1307,7 @@ namespace gladius::ui + // Back view: looking along the negative Y axis (pitch = 0°, yaw = 90°) + m_camera.setAngle(0.0f, CL_M_PI_F / 2.0f); + onCameraManuallyMoved(); +- invalidateView(); ++ invalidateCameraView(); + } + + void RenderWindow::setBottomView() +@@ -877,7 +1315,7 @@ namespace gladius::ui + // Bottom view: looking up the Z axis (pitch = -90°, yaw = 0°) + m_camera.setAngle(-CL_M_PI_F / 2.0f, 0.0f); + onCameraManuallyMoved(); +- invalidateView(); ++ invalidateCameraView(); + } + + void RenderWindow::setIsometricView() +@@ -887,7 +1325,7 @@ namespace gladius::ui + float const yaw = CL_M_PI_F / 4.0f; // 45 degrees + m_camera.setAngle(pitch, yaw); + onCameraManuallyMoved(); +- invalidateView(); ++ invalidateCameraView(); + } + + void RenderWindow::togglePerspective() +@@ -895,7 +1333,7 @@ namespace gladius::ui + // This would require camera implementation to support orthographic/perspective toggle + // For now, we'll save current view and restore it + saveCurrentView(); +- invalidateView(); ++ invalidateCameraView(); + } + + void RenderWindow::frameAll() +@@ -914,7 +1352,7 @@ namespace gladius::ui + } + + m_camera.adjustDistanceToTarget(*bbox, m_renderWindowSize_px.x, m_renderWindowSize_px.y); +- invalidateView(); ++ invalidateCameraView(); + } + + void RenderWindow::zoomSelected() +@@ -930,7 +1368,7 @@ namespace gladius::ui + Position newLookAt{currentLookAt.x - m_panSensitivity, currentLookAt.y, currentLookAt.z}; + m_camera.setLookAt(newLookAt); + onCameraManuallyMoved(); +- invalidateView(); ++ invalidateCameraView(); + } + + void RenderWindow::panRight() +@@ -940,7 +1378,7 @@ namespace gladius::ui + Position newLookAt{currentLookAt.x + m_panSensitivity, currentLookAt.y, currentLookAt.z}; + m_camera.setLookAt(newLookAt); + onCameraManuallyMoved(); +- invalidateView(); ++ invalidateCameraView(); + } + + void RenderWindow::panUp() +@@ -950,7 +1388,7 @@ namespace gladius::ui + Position newLookAt{currentLookAt.x, currentLookAt.y, currentLookAt.z + m_panSensitivity}; + m_camera.setLookAt(newLookAt); + onCameraManuallyMoved(); +- invalidateView(); ++ invalidateCameraView(); + } + + void RenderWindow::panDown() +@@ -960,35 +1398,35 @@ namespace gladius::ui + Position newLookAt{currentLookAt.x, currentLookAt.y, currentLookAt.z - m_panSensitivity}; + m_camera.setLookAt(newLookAt); + onCameraManuallyMoved(); +- invalidateView(); ++ invalidateCameraView(); + } + + void RenderWindow::rotateLeft() + { + m_camera.rotate(0.0f, -m_rotateSensitivity); + onCameraManuallyMoved(); +- invalidateView(); ++ invalidateCameraView(); + } + + void RenderWindow::rotateRight() + { + m_camera.rotate(0.0f, m_rotateSensitivity); + onCameraManuallyMoved(); +- invalidateView(); ++ invalidateCameraView(); + } + + void RenderWindow::rotateUp() + { + m_camera.rotate(m_rotateSensitivity, 0.0f); + onCameraManuallyMoved(); +- invalidateView(); ++ invalidateCameraView(); + } + + void RenderWindow::rotateDown() + { + m_camera.rotate(-m_rotateSensitivity, 0.0f); + onCameraManuallyMoved(); +- invalidateView(); ++ invalidateCameraView(); + } + + void RenderWindow::previousView() +@@ -1000,7 +1438,7 @@ namespace gladius::ui + + // Restore the view (this would require camera API support) + // For now, we'll invalidate the view +- invalidateView(); ++ invalidateCameraView(); + } + } + +@@ -1013,7 +1451,7 @@ namespace gladius::ui + + // Restore the view (this would require camera API support) + // For now, we'll invalidate the view +- invalidateView(); ++ invalidateCameraView(); + } + } + +@@ -1052,7 +1490,7 @@ namespace gladius::ui + { + // Restore the saved view (this would require camera API support) + // For now, we'll invalidate the view +- invalidateView(); ++ invalidateCameraView(); + } + } + +@@ -1060,7 +1498,7 @@ namespace gladius::ui + { + m_flyModeEnabled = !m_flyModeEnabled; + m_cameraMode = m_flyModeEnabled ? CameraMode::Fly : CameraMode::Orbit; +- invalidateView(); ++ invalidateCameraView(); + } + + void RenderWindow::setOrbitMode() +@@ -1259,6 +1697,7 @@ namespace gladius::ui + // path has no PID, so we reset to the nominal 50% of full quality here. + state.renderQualityWhileMoving = state.renderQuality * 0.5f; + invalidateViewDuetoModelUpdate(); ++ queueRenderDecision(m_renderUpdateCoordinator.notifyProgramCompilationCompleted()); + } + + // Lazy initialization: only initialize async rendering once renderer is ready +@@ -1522,12 +1961,51 @@ namespace gladius::ui + + // Update camera now that we know core is ready + m_core->applyCamera(m_camera); +- + processAsyncResults(state); + + // Process async preview results (separate from HQ progressive results) + processAsyncPreviewResults(); + ++ int const desiredScreenWidth = static_cast( ++ std::clamp(m_renderWindowSize_px.x * state.renderQuality, 1.f, 16000.f)); ++ int const desiredScreenHeight = static_cast( ++ std::clamp(m_renderWindowSize_px.y * state.renderQuality, 1.f, 16000.f)); ++ auto const resultImage = m_core->getResultImage(); ++ bool const screenResizeRequired = ++ resultImage && (static_cast(resultImage->getWidth()) != desiredScreenWidth || ++ static_cast(resultImage->getHeight()) != desiredScreenHeight); ++ ++ if (m_asyncController && m_asyncController->isRunning()) ++ { ++ queueRenderDecision(m_renderUpdateCoordinator.configureViewport( ++ static_cast(desiredScreenWidth), static_cast(desiredScreenHeight))); ++ ++ m_renderUpdateCoordinator.setRealtimeGuards( ++ async_rendering::RealtimeRaymarchGuards{ ++ .hardBlocker = !m_enableHQRendering || ++ m_asyncSdfJobInFlight.load(std::memory_order_acquire) || ++ m_asyncBboxJobInFlight.load(std::memory_order_acquire) || ++ m_core->isAnyCompilationInProgressNonBlocking(), ++ .renderJobInFlight = m_asyncJobInFlight.load(std::memory_order_acquire), ++ .previewJobInFlight = m_asyncPreviewJobInFlight.load(std::memory_order_acquire), ++ .streamingActive = m_streamingPreviewActive.load(std::memory_order_acquire), ++ .streamingJobInFlight = m_streamingJobInFlight.load(std::memory_order_acquire), ++ .resizePending = m_deferredResizePending || screenResizeRequired}); ++ ++ if (!state.isMoving && ++ m_renderUpdateCoordinator.interactionState() == ++ async_rendering::RenderInteractionState::CameraInteracting) ++ { ++ queueRenderDecision(m_renderUpdateCoordinator.notifyCameraInteractionEnded()); ++ } ++ ++ queueRenderDecision(m_renderUpdateCoordinator.tick()); ++ if (executeQueuedRenderCommands(state)) ++ { ++ return; ++ } ++ } ++ + if (m_asyncController && m_asyncController->isRunning()) + { + bool const sdfDirty = m_preComputedSdfDirty.load(std::memory_order_acquire); +@@ -1546,7 +2024,22 @@ namespace gladius::ui + !bboxStale || + (std::chrono::steady_clock::now() - m_lastParameterChangeTime >= kBboxDebounceDelay); + +- if (sdfDirty && !sdfJobActive && sdfDebounceElapsed) ++ // Don't schedule SDF precomputation while the camera is actively moving in ++ // realtime mode: the realtime renderer evaluates SDF on-the-fly and doesn't ++ // need the precomputed version. Starting the SDF job now would set ++ // hardBlocker=true, preventing canAttemptRealtime() from returning true for ++ // the entire SDF compute duration. The coordinator will schedule SDF via ++ // scheduleStaticCatchUp() once notifyCameraInteractionEnded() fires. ++ bool const cameraInteracting = ++ m_renderUpdateCoordinator.interactionState() == ++ async_rendering::RenderInteractionState::CameraInteracting; ++ bool const isRealtimeForSdfGuard = ++ m_renderUpdateCoordinator.realtimeConfig().mode != ++ async_rendering::RealtimeRaymarchMode::Off; ++ bool const suppressSdfDuringCameraInteraction = ++ cameraInteracting && isRealtimeForSdfGuard; ++ ++ if (sdfDirty && !sdfJobActive && sdfDebounceElapsed && !suppressSdfDuringCameraInteraction) + { + async_rendering::RenderJob sdfJob{}; + sdfJob.type = async_rendering::RenderJobType::SDFPrecomputation; +@@ -1601,7 +2094,6 @@ namespace gladius::ui + { + // Delay progressive rendering until low-res feedback is presented + state.isRendering = false; +- m_asyncJobInFlight.store(false, std::memory_order_release); + } + } + +@@ -1684,8 +2176,13 @@ namespace gladius::ui + state.currentLine = 0; + state.renderingStepSize = kInitialProgressiveStepSize; + state.isRendering = false; +- m_forceLowResRenderOnNextFrame.store(true, std::memory_order_release); +- m_lastLowResRenderTime = std::chrono::system_clock::now(); ++ bool const forceLowResPreview = ++ m_renderUpdateCoordinator.realtimeConfig().mode == async_rendering::RealtimeRaymarchMode::Off; ++ m_forceLowResRenderOnNextFrame.store(forceLowResPreview, std::memory_order_release); ++ if (forceLowResPreview) ++ { ++ m_lastLowResRenderTime = std::chrono::system_clock::now(); ++ } + } + + if (state.isMoving && m_preComputedSdfDirty) +@@ -1698,14 +2195,6 @@ namespace gladius::ui + // worker jobs copy their staging image back into m_resultImage after the render kernel + // completes, and resizing that image concurrently can crash inside clEnqueueCopyImage. + bool const shouldDeferResize = m_preserveContentDuringResize && m_deferredResizePending; +- int const desiredScreenWidth = static_cast( +- std::clamp(m_renderWindowSize_px.x * state.renderQuality, 1.f, 16000.f)); +- int const desiredScreenHeight = static_cast( +- std::clamp(m_renderWindowSize_px.y * state.renderQuality, 1.f, 16000.f)); +- auto const resultImage = m_core->getResultImage(); +- bool const screenResizeRequired = +- resultImage && (static_cast(resultImage->getWidth()) != desiredScreenWidth || +- static_cast(resultImage->getHeight()) != desiredScreenHeight); + bool const hqRenderInFlight = m_asyncJobInFlight.load(std::memory_order_acquire); + + if (!shouldDeferResize && screenResizeRequired && !hqRenderInFlight && +@@ -1779,6 +2268,13 @@ namespace gladius::ui + bool const lowResPending = m_lowResFeedbackPending.load(std::memory_order_acquire); + bool const previewJobInFlight = m_asyncPreviewJobInFlight.load(std::memory_order_acquire); + ++ if (m_asyncRealtimeJobInFlight.load(std::memory_order_acquire)) ++ { ++ m_lowResFeedbackPending.store(false, std::memory_order_release); ++ state.isRendering = true; ++ return; ++ } ++ + if (state.isMoving || forceLowResRender || lowResPending) + { + bool const hadActiveProgressive = +@@ -1798,7 +2294,6 @@ namespace gladius::ui + // - Sync preview is the fallback when async is unavailable + // During parameter drag, SDF jobs are debounced so there's no GPU contention. + bool const sdfValid = m_core->isSdfValid(); +- bool const sdfJobRunning = m_asyncSdfJobInFlight.load(std::memory_order_acquire); + bool const streamingActive = m_streamingPreviewActive.load(std::memory_order_acquire); + bool const useAsyncPreview = !previewJobInFlight && sdfValid; + +@@ -1883,8 +2378,9 @@ namespace gladius::ui + // Only enforce timeout if we're NOT already in middle of progressive rendering + // (otherwise chunks would be blocked after any interaction) + bool const isProgressiveRenderInProgress = state.isRendering && state.currentLine > 0; ++ bool const useLowResPreviewBeforeHq = !m_renderUpdateCoordinator.isRealtimeActive(); + +- if (!isProgressiveRenderInProgress) ++ if (!isProgressiveRenderInProgress && useLowResPreviewBeforeHq) + { + auto const timeSinceLastLowResRender = + std::chrono::system_clock::now() - m_lastLowResRenderTime; +@@ -1938,6 +2434,11 @@ namespace gladius::ui + + auto const currentEpoch = m_asyncCurrentEpoch.load(std::memory_order_acquire); + bool const isOutdated = result.epoch < currentEpoch; ++ auto const currentViewEpoch = m_asyncViewEpoch.load(std::memory_order_acquire); ++ bool const isRealtimeJob = result.jobType == async_rendering::RenderJobType::RealtimeHighQuality; ++ bool const isExactRealtimeJob = isRealtimeJob; ++ bool const isHqRenderJob = result.jobType == async_rendering::RenderJobType::HighQuality || isExactRealtimeJob; ++ bool const isViewOutdated = isHqRenderJob && result.viewEpoch < currentViewEpoch; + + if (result.jobType == async_rendering::RenderJobType::SDFPrecomputation) + { +@@ -1959,6 +2460,9 @@ namespace gladius::ui + // to avoid scheduling a redundant bbox job. + m_asyncBboxUpdatePending.store(false, std::memory_order_release); + } ++ auto coordinatorResult = result; ++ coordinatorResult.cancelled = coordinatorResult.cancelled || isOutdated; ++ completeCoordinatorTask(coordinatorResult, false); + continue; + } + +@@ -1970,6 +2474,9 @@ namespace gladius::ui + { + scheduleAsyncBboxUpdate(); + } ++ auto coordinatorResult = result; ++ coordinatorResult.cancelled = coordinatorResult.cancelled || isOutdated; ++ completeCoordinatorTask(coordinatorResult, false); + continue; + } + +@@ -1989,60 +2496,145 @@ namespace gladius::ui + continue; + } + +- // From here on, only HQ progressive render results are processed. ++ // From here on, only HQ/full exact render results are processed. ++ ++ if (isHqRenderJob && result.computeDurationNs > 0) ++ { ++ size_t const renderedLines = result.completedLine > result.startLine ++ ? result.completedLine - result.startLine ++ : size_t{0}; ++ auto const durationMs = static_cast( ++ static_cast(result.computeDurationNs) / 1'000'000.0); ++ async_rendering::RealtimeRaymarchSample sample{}; ++ sample.durationMs = durationMs; ++ sample.width = result.width; ++ sample.height = result.height; ++ sample.renderedLines = renderedLines; ++ sample.totalLines = result.height; ++ sample.completedFrame = result.completedFrame && result.startLine == 0 && ++ renderedLines >= static_cast(result.height); ++ sample.cancelled = result.cancelled; ++ if (isRealtimeJob) ++ { ++ if (m_renderUpdateCoordinator.interactionState() == ++ async_rendering::RenderInteractionState::Static) ++ { ++ m_renderUpdateCoordinator.recordStaticFullFrameSample(sample); ++ } ++ else ++ { ++ m_renderUpdateCoordinator.recordInteractiveRealtimeSample(sample); ++ } ++ } ++ else if (!isOutdated && !isViewOutdated && ++ m_renderUpdateCoordinator.interactionState() == async_rendering::RenderInteractionState::Static) ++ { ++ m_renderUpdateCoordinator.recordStaticProgressiveSample(sample); ++ } ++ } + + if (result.cancelled) + { ++ if (isExactRealtimeJob) ++ { ++ m_renderUpdateCoordinator.recordRealtimeRejectedAttempt(); ++ } + if (result.epoch == m_asyncInFlightEpoch.load(std::memory_order_acquire)) + { + m_asyncJobInFlight.store(false, std::memory_order_release); ++ if (isExactRealtimeJob) ++ { ++ m_asyncRealtimeJobInFlight.store(false, std::memory_order_release); ++ } + m_asyncInFlightEpoch.store(0, std::memory_order_release); ++ m_asyncInFlightViewEpoch.store(0, std::memory_order_release); + state.isRendering = false; + } ++ completeCoordinatorTask(result, false); + continue; + } + +- // Update progressive scheduling state only for current-epoch results. +- // Outdated results are still presented, but must not affect scheduling. +- if (!isOutdated) ++ // Update progressive scheduling state only for current scene/view results. ++ if (!isOutdated && !isViewOutdated && result.jobType == async_rendering::RenderJobType::HighQuality) + { + size_t const maxHeight = static_cast(result.height); + state.currentLine = std::min(result.completedLine, maxHeight); + adjustProgressFromDuration(state, result.computeDurationNs); + } + +- // Promote the newest Ready buffer to Front for display +- // Note: Only happens when completedFrame=true (buffer was published) ++ // Promote the newest Ready buffer to Front for display. ++ // Stale scene/view results are deliberately discarded before promotion; otherwise ++ // a late realtime frame could replace a valid front buffer with an old camera or ++ // parameter state. ++ // Realtime raymarching frames (both Auto and Force) need to be displayed even if ++ // the view epoch has incremented, otherwise continuous camera motion freezes. ++ bool const allowForcedRealtimeStaleView = isRealtimeJob; ++ bool const discardForStaleView = isViewOutdated && !allowForcedRealtimeStaleView; ++ + if (result.completedFrame) + { +- ZoneScopedN("PromoteReadyToFront"); +- auto * newFront = m_asyncController->promoteReadyToFront(); +- if (newFront && newFront->image) ++ if (isOutdated || discardForStaleView) ++ { ++ m_asyncController->discardReadyFrame( ++ result.frameId, result.epoch, result.viewEpoch); ++ } ++ else + { +- // Bind the new frame to ensure GL texture is updated +- newFront->image->invalidateContent(); +- newFront->image->bind(); +- newFront->image->unbind(); ++ ZoneScopedN("PromoteReadyToFront"); ++ auto * newFront = m_asyncController->promoteReadyToFront(); ++ if (newFront && newFront->image) ++ { ++ // Bind the new frame to ensure GL texture is updated ++ newFront->image->invalidateContent(); ++ newFront->image->bind(); ++ newFront->image->unbind(); + +- bool const promoted = m_asyncController->finalizeFrontPromotion(newFront); ++ [[maybe_unused]] bool const promoted = ++ m_asyncController->finalizeFrontPromotion(newFront); ++ } + } + } + + if (result.completedFrame) + { +- if (!isOutdated) ++ if (!isOutdated && !discardForStaleView) + { +- m_dirty = false; ++ m_dirty = isViewOutdated && allowForcedRealtimeStaleView; + state.isRendering = false; + // DON'T force state.isMoving = false - let camera update control this + // If camera is still animating, it will set isMoving=true on next frame + state.currentLine = 0; // Reset for next frame +- m_view->stopAnimationMode(); ++ if (isExactRealtimeJob) ++ { ++ m_lowResFeedbackPending.store(false, std::memory_order_release); ++ m_lastLowResPreviewEpoch.store(result.epoch, std::memory_order_release); ++ } ++ if (isViewOutdated && allowForcedRealtimeStaleView) ++ { ++ m_view->startAnimationMode(); ++ } ++ else ++ { ++ m_view->stopAnimationMode(); ++ } + } + } + else + { +- if (!isOutdated && m_asyncProgressiveBuffer && m_asyncProgressiveBuffer->image) ++ if (isViewOutdated && m_asyncProgressiveBuffer && ++ m_asyncProgressiveViewEpoch.load(std::memory_order_acquire) == result.viewEpoch) ++ { ++ [[maybe_unused]] bool const released = ++ m_asyncController->tryTransitionBuffer(m_asyncProgressiveBuffer, ++ async_rendering::FrameState::Writing, ++ async_rendering::FrameState::Idle); ++ m_asyncProgressiveBuffer = nullptr; ++ m_asyncProgressiveEpoch.store(0, std::memory_order_release); ++ m_asyncProgressiveViewEpoch.store(0, std::memory_order_release); ++ state.isRendering = false; ++ } ++ else if (!isOutdated && !isViewOutdated && m_asyncProgressiveBuffer && ++ m_asyncProgressiveBuffer->image) + { + m_asyncProgressiveBuffer->image->invalidateContent(); + m_view->startAnimationMode(); +@@ -2056,19 +2648,27 @@ namespace gladius::ui + if (result.epoch == m_asyncInFlightEpoch.load(std::memory_order_acquire)) + { + m_asyncJobInFlight.store(false, std::memory_order_release); ++ if (isExactRealtimeJob) ++ { ++ m_asyncRealtimeJobInFlight.store(false, std::memory_order_release); ++ } + m_asyncInFlightEpoch.store(0, std::memory_order_release); ++ m_asyncInFlightViewEpoch.store(0, std::memory_order_release); + + // If we just consumed an outdated job result, ensure we don't block scheduling for + // the new epoch. +- if (isOutdated) ++ if (isOutdated || isViewOutdated) + { + state.isRendering = false; + } + } ++ ++ completeCoordinatorTask(result, result.completedFrame && !isOutdated && !isViewOutdated); + } + } + +- bool RenderWindow::scheduleAsyncRenderJob(RenderWindowState & state) ++ bool RenderWindow::scheduleAsyncRenderJob(RenderWindowState & state, ++ async_rendering::RenderTaskRequest const * coordinatorTask) + { + ZoneScoped; + ZoneName("scheduleAsyncRenderJob", strlen("scheduleAsyncRenderJob")); +@@ -2105,6 +2705,7 @@ namespace gladius::ui + m_asyncCurrentEpoch.store(job.epoch, std::memory_order_release); + } + m_asyncController->setLatestEpoch(job.epoch); ++ job.viewEpoch = m_asyncViewEpoch.load(std::memory_order_acquire); + job.frameHint = m_asyncFrameCounter.fetch_add(1, std::memory_order_acq_rel) + 1; + job.type = async_rendering::RenderJobType::HighQuality; + job.width = static_cast(width); +@@ -2119,6 +2720,11 @@ namespace gladius::ui + std::max(1, std::min(state.renderingStepSize, height - job.startLine)); + job.precomputeSdf = false; + job.enableHighQuality = m_enableHQRendering; ++ if (coordinatorTask != nullptr) ++ { ++ job.coordinatorRequestId = coordinatorTask->requestId; ++ job.coordinatorStamp = coordinatorTask->stamp; ++ } + + if (job.startLine == 0 && + (!m_asyncProgressiveBuffer || +@@ -2159,6 +2765,84 @@ namespace gladius::ui + return true; + } + ++ bool RenderWindow::scheduleRealtimeRenderJob( ++ RenderWindowState & state, ++ async_rendering::RenderTaskRequest const * coordinatorTask) ++ { ++ ZoneScoped; ++ ZoneName("scheduleRealtimeRenderJob", strlen("scheduleRealtimeRenderJob")); ++ ++ if (!m_asyncController || !m_asyncController->isRunning() || !m_enableHQRendering) ++ { ++ return false; ++ } ++ ++ auto const image = m_core->getResultImage(); ++ if (!image) ++ { ++ return false; ++ } ++ ++ size_t const width = static_cast(image->getWidth()); ++ size_t const height = static_cast(image->getHeight()); ++ if (width == 0 || height == 0) ++ { ++ return false; ++ } ++ ++ m_asyncController->initializeAsyncResources(*m_core->getComputeContext(), width, height); ++ ++ if (m_asyncProgressiveBuffer && !m_asyncJobInFlight.load(std::memory_order_acquire)) ++ { ++ [[maybe_unused]] bool const released = ++ m_asyncController->tryTransitionBuffer(m_asyncProgressiveBuffer, ++ async_rendering::FrameState::Writing, ++ async_rendering::FrameState::Idle); ++ m_asyncProgressiveBuffer = nullptr; ++ m_asyncProgressiveEpoch.store(0, std::memory_order_release); ++ m_asyncProgressiveViewEpoch.store(0, std::memory_order_release); ++ } ++ ++ async_rendering::RenderJob job{}; ++ job.epoch = m_asyncCurrentEpoch.load(std::memory_order_acquire); ++ if (job.epoch == 0) ++ { ++ job.epoch = m_asyncEpochCounter.fetch_add(1, std::memory_order_acq_rel) + 1; ++ m_asyncCurrentEpoch.store(job.epoch, std::memory_order_release); ++ } ++ m_asyncController->setLatestEpoch(job.epoch); ++ ++ job.viewEpoch = m_asyncViewEpoch.load(std::memory_order_acquire); ++ job.frameHint = m_asyncFrameCounter.fetch_add(1, std::memory_order_acq_rel) + 1; ++ job.type = async_rendering::RenderJobType::RealtimeHighQuality; ++ job.width = static_cast(width); ++ job.height = static_cast(height); ++ job.startLine = 0; ++ job.stepSize = height; ++ job.precomputeSdf = false; ++ job.enableHighQuality = true; ++ if (coordinatorTask != nullptr) ++ { ++ job.coordinatorRequestId = coordinatorTask->requestId; ++ job.coordinatorStamp = coordinatorTask->stamp; ++ } ++ ++ state.currentLine = 0; ++ state.renderingStepSize = job.stepSize; ++ state.isRendering = true; ++ m_forceLowResRenderOnNextFrame.store(false, std::memory_order_release); ++ m_lowResFeedbackPending.store(false, std::memory_order_release); ++ ++ m_asyncInFlightEpoch.store(job.epoch, std::memory_order_release); ++ m_asyncInFlightViewEpoch.store(job.viewEpoch, std::memory_order_release); ++ m_asyncJobInFlight.store(true, std::memory_order_release); ++ m_asyncRealtimeJobInFlight.store(true, std::memory_order_release); ++ m_asyncController->enqueueJob(job); ++ ++ ZoneText("RealtimeJobEnqueued", 19); ++ return true; ++ } ++ + auto RenderWindow::executeAsyncRenderJob( + async_rendering::RenderJob const & job, + async_rendering::AsyncRenderController::CancelCheck const & cancelCheck) +@@ -2172,20 +2856,23 @@ namespace gladius::ui + tracy::SetThreadName("AsyncRenderWorker"); + #endif + +- auto * workerQueue = (m_asyncController ? m_asyncController->workerQueue() : nullptr); ++ auto queueOwner = (m_asyncController ? m_asyncController->workerQueueShared() : nullptr); + auto const & commandQueue = +- workerQueue != nullptr ? *workerQueue : m_core->getComputeContext()->GetQueue(); +- if (workerQueue == nullptr) {} ++ queueOwner ? *queueOwner : m_core->getComputeContext()->GetQueue(); + + auto const cancellationRequested = [&]() -> bool { return cancelCheck && cancelCheck(); }; + + async_rendering::FrameResultMeta result{}; + result.frameId = job.frameHint; + result.epoch = job.epoch; ++ result.viewEpoch = job.viewEpoch; + result.jobType = job.type; + result.width = job.width; + result.height = job.height; + result.startLine = job.startLine; ++ result.coordinatorRequestId = job.coordinatorRequestId; ++ result.coordinatorStamp = job.coordinatorStamp; ++ bool const isExactRealtimeJob = job.type == async_rendering::RenderJobType::RealtimeHighQuality; + + if (cancellationRequested()) + { +@@ -2217,6 +2904,7 @@ namespace gladius::ui + { + m_asyncProgressiveBuffer = nullptr; + m_asyncProgressiveEpoch.store(0, std::memory_order_release); ++ m_asyncProgressiveViewEpoch.store(0, std::memory_order_release); + } + + writeBuffer = nullptr; +@@ -2245,6 +2933,7 @@ namespace gladius::ui + + m_asyncProgressiveBuffer = writeBuffer; + m_asyncProgressiveEpoch.store(job.epoch, std::memory_order_release); ++ m_asyncProgressiveViewEpoch.store(job.viewEpoch, std::memory_order_release); + } + else + { +@@ -2275,9 +2964,30 @@ namespace gladius::ui + + cl::Event renderEvent{}; + bool advanced = false; +- +- advanced = m_core->renderSceneComputeOnly( +- commandQueue, job.startLine, endLine, targetCLBuffer, &renderEvent); ++ ++ if (isExactRealtimeJob) ++ { ++ auto assembly = m_document ? m_document->getAssembly() : nullptr; ++ if (assembly && m_core->isRendererReady()) ++ { ++ (void) m_core->tryToupdateParameter(*assembly); ++ } ++ ++ auto settings = m_core->getResourceContext()->getRenderingSettings(); ++ settings.approximation = AM_FULL_MODEL; ++ advanced = m_core->renderSceneComputeOnlyWithSettings( ++ commandQueue, ++ job.startLine, ++ endLine, ++ targetCLBuffer, ++ settings, ++ &renderEvent); ++ } ++ else ++ { ++ advanced = m_core->renderSceneComputeOnly( ++ commandQueue, job.startLine, endLine, targetCLBuffer, &renderEvent); ++ } + + if (renderEvent()) + { +@@ -2291,6 +3001,10 @@ namespace gladius::ui + + result.completedLine = advanced ? endLine : job.startLine; + result.completedFrame = result.completedLine >= job.height; ++ if (!advanced && isExactRealtimeJob) ++ { ++ result.cancelled = true; ++ } + } + else + { +@@ -2309,9 +3023,11 @@ namespace gladius::ui + } + else if (result.completedFrame) + { +- m_asyncController->publishFrame(writeBuffer, result.frameId, result.epoch); ++ m_asyncController->publishFrame( ++ writeBuffer, result.frameId, result.epoch, result.viewEpoch); + m_asyncProgressiveBuffer = nullptr; + m_asyncProgressiveEpoch.store(0, std::memory_order_release); ++ m_asyncProgressiveViewEpoch.store(0, std::memory_order_release); + } + + auto const endTime = std::chrono::steady_clock::now(); +@@ -2747,13 +3463,13 @@ namespace gladius::ui + void RenderWindow::zoomIn() + { + m_camera.zoom(-0.1f); // Negative zoom means zoom in +- invalidateView(); ++ invalidateCameraView(); + } + + void RenderWindow::zoomOut() + { + m_camera.zoom(0.1f); // Positive zoom means zoom out +- invalidateView(); ++ invalidateCameraView(); + } + + void RenderWindow::resetZoom() +@@ -2767,10 +3483,11 @@ namespace gladius::ui + } + + m_camera.adjustDistanceToTarget(*bbox, m_renderWindowSize_px.x, m_renderWindowSize_px.y); +- invalidateView(); ++ invalidateCameraView(); + } + +- void RenderWindow::scheduleAsyncBboxUpdate() ++ void RenderWindow::scheduleAsyncBboxUpdate( ++ async_rendering::RenderTaskRequest const * coordinatorTask) + { + if (!m_asyncController || !m_asyncInitialized) + { +@@ -2791,6 +3508,11 @@ namespace gladius::ui + async_rendering::RenderJob job{}; + job.type = async_rendering::RenderJobType::BoundingBoxUpdate; + job.epoch = m_asyncCurrentEpoch.load(std::memory_order_acquire); ++ if (coordinatorTask != nullptr) ++ { ++ job.coordinatorRequestId = coordinatorTask->requestId; ++ job.coordinatorStamp = coordinatorTask->stamp; ++ } + + m_asyncController->enqueueJob(job); + } +@@ -2806,6 +3528,8 @@ namespace gladius::ui + result.epoch = job.epoch; + result.jobType = job.type; + result.cancelled = false; ++ result.coordinatorRequestId = job.coordinatorRequestId; ++ result.coordinatorStamp = job.coordinatorStamp; + + auto const startTime = std::chrono::steady_clock::now(); + +@@ -2867,6 +3591,8 @@ namespace gladius::ui + result.jobType = job.type; + result.cancelled = false; + result.precomputedSdfUpdated = false; ++ result.coordinatorRequestId = job.coordinatorRequestId; ++ result.coordinatorStamp = job.coordinatorStamp; + + auto const startTime = std::chrono::steady_clock::now(); + +@@ -2898,10 +3624,9 @@ namespace gladius::ui + try + { + // Get worker queue (or fallback to main queue) +- auto * workerQueue = (m_asyncController ? m_asyncController->workerQueue() : nullptr); +- +- cl::CommandQueue const * queuePtr = +- workerQueue != nullptr ? workerQueue : &m_core->getComputeContext()->GetQueue(); ++ auto queueOwner = (m_asyncController ? m_asyncController->workerQueueShared() : nullptr); ++ cl::CommandQueue const & queueRef = ++ queueOwner ? *queueOwner : m_core->getComputeContext()->GetQueue(); + + // Acquire compute token non-blockingly to serialize with refreshWorker(). + // refreshWorker() holds the mutex while it rebuilds CL programs and +@@ -2916,7 +3641,7 @@ namespace gladius::ui + } + + // Launch async SDF precomputation (returns cl::Event) +- cl::Event sdfEvent = m_core->precomputeSdfAsync(*queuePtr); ++ cl::Event sdfEvent = m_core->precomputeSdfAsync(queueRef); + + // Release the compute token — the kernel is already enqueued and the + // CL runtime retains its own references to the memory objects. +@@ -3010,7 +3735,8 @@ namespace gladius::ui + + // ============== Async Preview Rendering ============== + +- bool RenderWindow::scheduleAsyncPreviewJob() ++ bool RenderWindow::scheduleAsyncPreviewJob( ++ async_rendering::RenderTaskRequest const * coordinatorTask) + { + ZoneScoped; + ZoneName("scheduleAsyncPreviewJob", strlen("scheduleAsyncPreviewJob")); +@@ -3053,6 +3779,7 @@ namespace gladius::ui + m_asyncCurrentEpoch.store(job.epoch, std::memory_order_release); + } + m_asyncController->setLatestEpoch(job.epoch); ++ job.viewEpoch = m_asyncViewEpoch.load(std::memory_order_acquire); + + // Use low-res preview resolution + auto const [previewWidth, previewHeight] = m_core->getLowResPreviewResolution(); +@@ -3063,6 +3790,11 @@ namespace gladius::ui + job.stepSize = static_cast(previewHeight); + job.precomputeSdf = false; + job.enableHighQuality = false; ++ if (coordinatorTask != nullptr) ++ { ++ job.coordinatorRequestId = coordinatorTask->requestId; ++ job.coordinatorStamp = coordinatorTask->stamp; ++ } + + // Track preview job state + m_asyncPreviewEpoch.store(job.epoch, std::memory_order_release); +@@ -3087,16 +3819,19 @@ namespace gladius::ui + + using namespace async_rendering; + +- auto * workerQueue = (m_asyncController ? m_asyncController->workerQueue() : nullptr); ++ auto queueOwner = (m_asyncController ? m_asyncController->workerQueueShared() : nullptr); + auto const & commandQueue = +- workerQueue != nullptr ? *workerQueue : m_core->getComputeContext()->GetQueue(); ++ queueOwner ? *queueOwner : m_core->getComputeContext()->GetQueue(); + + FrameResultMeta result{}; + result.frameId = job.frameHint; + result.epoch = job.epoch; ++ result.viewEpoch = job.viewEpoch; + result.jobType = job.type; + result.width = job.width; + result.height = job.height; ++ result.coordinatorRequestId = job.coordinatorRequestId; ++ result.coordinatorStamp = job.coordinatorStamp; + + // Note: We do NOT cancel preview jobs based on epoch changes. + // This ensures smooth visual feedback during camera movement. +@@ -3214,9 +3949,12 @@ namespace gladius::ui + PreviewResultMeta previewMeta{}; + previewMeta.frameId = job.frameHint; + previewMeta.epoch = job.epoch; ++ previewMeta.viewEpoch = job.viewEpoch; + previewMeta.latencyNs = result.computeDurationNs; + previewMeta.cancelled = false; + previewMeta.sdfWasValid = m_core->isSdfValid(); ++ previewMeta.coordinatorRequestId = job.coordinatorRequestId; ++ previewMeta.coordinatorStamp = job.coordinatorStamp; + + m_asyncPreviewJobInFlight.store(false, std::memory_order_release); + // Note: Don't set m_asyncPreviewFrameId here - it's set in processAsyncPreviewResults +@@ -3246,6 +3984,37 @@ namespace gladius::ui + + auto const & meta = result.value(); + ++ auto const currentEpoch = m_asyncCurrentEpoch.load(std::memory_order_acquire); ++ auto const currentViewEpoch = m_asyncViewEpoch.load(std::memory_order_acquire); ++ bool const staleForCurrentView = ++ meta.epoch < currentEpoch || (meta.viewEpoch != 0 && meta.viewEpoch < currentViewEpoch); ++ bool const exactRealtimeJobInFlight = ++ m_asyncRealtimeJobInFlight.load(std::memory_order_acquire); ++ bool const suppressForRealtimeRaymarch = ++ exactRealtimeJobInFlight || staleForCurrentView; ++ ++ if (suppressForRealtimeRaymarch) ++ { ++ ZoneText("PreviewSkippedForRealtimeRaymarch", 34); ++ m_streamingFrameConsumed.store(true, std::memory_order_release); ++ auto cancelledMeta = meta; ++ cancelledMeta.cancelled = true; ++ completeCoordinatorPreviewTask(cancelledMeta); ++ return; ++ } ++ ++ // FR-026: Do not replace a higher-quality frame with a lower-quality preview. ++ // If a HQ/realtime frame for the current view is already displayed, suppress the preview. ++ if (m_renderUpdateCoordinator.isHighQualityFrameCurrent()) ++ { ++ ZoneText("PreviewSkippedForQualityRegression", 35); ++ m_streamingFrameConsumed.store(true, std::memory_order_release); ++ auto cancelledMeta = meta; ++ cancelledMeta.cancelled = true; ++ completeCoordinatorPreviewTask(cancelledMeta); ++ return; ++ } ++ + // Skip frames that are older than what we've already displayed. + // This ensures we never show an older frame after a newer one. + // Use frameId (which is monotonically increasing) for ordering. +@@ -3254,14 +4023,19 @@ namespace gladius::ui + { + ZoneText("OutOfOrderPreviewSkipped", 24); + m_streamingFrameConsumed.store(true, std::memory_order_release); ++ completeCoordinatorPreviewTask(meta); + return; + } + + // Skip cancelled results + if (meta.cancelled) + { ++ if (auto logger = m_core->getSharedLogger()) { ++ logger->logInfo("[Preview] Cancelled preview skipped."); ++ } + ZoneText("CancelledPreviewSkipped", 23); + m_streamingFrameConsumed.store(true, std::memory_order_release); ++ completeCoordinatorPreviewTask(meta); + return; + } + +@@ -3274,6 +4048,10 @@ namespace gladius::ui + auto resultImage = m_core->getResultImage(); + auto renderProgram = m_core->tryGetBestRenderProgram().value_or(SharedRenderProgram{}); + bool scheduleAdaptivePreview = false; ++ ++ if (auto logger = m_core->getSharedLogger()) { ++ logger->logInfo(std::string("[Preview] Result Consumed, Latency ") + std::to_string(meta.latencyNs / 1000000) + "ms. Resampling!"); ++ } + + if (lowResImage && resultImage && renderProgram) + { +@@ -3360,6 +4138,10 @@ namespace gladius::ui + m_lowResFeedbackPending.store(false, std::memory_order_release); + m_lastLowResRenderTime = std::chrono::system_clock::now(); + m_lastLowResPreviewEpoch.store(meta.epoch, std::memory_order_release); ++ completeCoordinatorPreviewTask(meta); ++ if (auto logger = m_core->getSharedLogger()) { ++ logger->logInfo("[Preview] Task Complete triggered!"); ++ } + + if (scheduleAdaptivePreview) + { +@@ -3370,6 +4152,22 @@ namespace gladius::ui + + void RenderWindow::startStreamingPreview() + { ++ if (m_core) ++ { ++ auto const image = m_core->getResultImage(); ++ if (image && image->getWidth() > 0 && image->getHeight() > 0) ++ { ++ auto const width = static_cast(image->getWidth()); ++ auto const height = static_cast(image->getHeight()); ++ queueRenderDecision(m_renderUpdateCoordinator.configureViewport(width, height)); ++ ++ if (m_asyncJobInFlight.load(std::memory_order_acquire)) ++ { ++ return; ++ } ++ } ++ } ++ + m_streamingPreviewActive.store(true, std::memory_order_release); + + // (Re-)schedule the streaming job if none is currently running. +@@ -3384,7 +4182,11 @@ namespace gladius::ui + + void RenderWindow::stopStreamingPreview() + { +- m_streamingPreviewActive.store(false, std::memory_order_release); ++ bool const wasActive = m_streamingPreviewActive.exchange(false, std::memory_order_acq_rel); ++ if (wasActive) ++ { ++ queueRenderDecision(m_renderUpdateCoordinator.notifyParameterInteractionEnded()); ++ } + } + + void RenderWindow::cancelAllAsyncWork() +@@ -3413,7 +4215,8 @@ namespace gladius::ui + return m_streamingPreviewActive.load(std::memory_order_acquire); + } + +- bool RenderWindow::scheduleStreamingPreviewJob() ++ bool RenderWindow::scheduleStreamingPreviewJob( ++ async_rendering::RenderTaskRequest const * coordinatorTask) + { + if (!m_asyncController || !m_asyncController->isRunning()) + { +@@ -3444,6 +4247,7 @@ namespace gladius::ui + m_asyncCurrentEpoch.store(job.epoch, std::memory_order_release); + } + m_asyncController->setLatestEpoch(job.epoch); ++ job.viewEpoch = m_asyncViewEpoch.load(std::memory_order_acquire); + + auto const [previewWidth, previewHeight] = m_core->getLowResPreviewResolution(); + job.width = static_cast(previewWidth); +@@ -3453,6 +4257,11 @@ namespace gladius::ui + job.stepSize = static_cast(previewHeight); + job.precomputeSdf = false; + job.enableHighQuality = false; ++ if (coordinatorTask != nullptr) ++ { ++ job.coordinatorRequestId = coordinatorTask->requestId; ++ job.coordinatorStamp = coordinatorTask->stamp; ++ } + + m_streamingJobInFlight.store(true, std::memory_order_release); + m_asyncPreviewJobInFlight.store(true, std::memory_order_release); +@@ -3471,15 +4280,18 @@ namespace gladius::ui + #endif + using namespace async_rendering; + +- auto * workerQueue = (m_asyncController ? m_asyncController->workerQueue() : nullptr); ++ auto queueOwner = (m_asyncController ? m_asyncController->workerQueueShared() : nullptr); + auto const & commandQueue = +- workerQueue != nullptr ? *workerQueue : m_core->getComputeContext()->GetQueue(); ++ queueOwner ? *queueOwner : m_core->getComputeContext()->GetQueue(); + + FrameResultMeta result{}; + result.epoch = job.epoch; ++ result.viewEpoch = job.viewEpoch; + result.jobType = job.type; + result.width = job.width; + result.height = job.height; ++ result.coordinatorRequestId = job.coordinatorRequestId; ++ result.coordinatorStamp = job.coordinatorStamp; + + auto const shouldStop = [&]() -> bool { + return !m_streamingPreviewActive.load(std::memory_order_acquire) || +@@ -3627,8 +4439,11 @@ namespace gladius::ui + PreviewResultMeta previewMeta{}; + previewMeta.frameId = frameCounter; + previewMeta.epoch = job.epoch; ++ previewMeta.viewEpoch = job.viewEpoch; + previewMeta.cancelled = false; + previewMeta.sdfWasValid = m_core->isSdfValid(); ++ previewMeta.coordinatorRequestId = job.coordinatorRequestId; ++ previewMeta.coordinatorStamp = job.coordinatorStamp; + + auto const iterEnd = std::chrono::steady_clock::now(); + previewMeta.latencyNs = static_cast( +diff --git a/gladius/src/ui/RenderWindow.h b/gladius/src/ui/RenderWindow.h +index 23e0166..b739c16 100644 +--- a/gladius/src/ui/RenderWindow.h ++++ b/gladius/src/ui/RenderWindow.h +@@ -5,6 +5,8 @@ + #include "OrbitalCamera.h" + #include "compute/ComputeCore.h" + #include "render/AsyncRenderController.h" ++#include "render/RealtimeRaymarchController.h" ++#include "render/RenderUpdateCoordinator.h" + #include + #include + #include +@@ -158,6 +160,8 @@ namespace gladius::ui + */ + [[nodiscard]] bool isCameraMoving() const; + ++ [[nodiscard]] bool isForceRealtimeMode() const noexcept; ++ + private: + void render(RenderWindowState & state); + void renderLoadingOverlay(); +@@ -169,18 +173,41 @@ namespace gladius::ui + void renderSync(RenderWindowState & state); + void renderAsync(RenderWindowState & state); + void processAsyncResults(RenderWindowState & state); +- bool scheduleAsyncRenderJob(RenderWindowState & state); ++ bool scheduleAsyncRenderJob( ++ RenderWindowState & state, ++ async_rendering::RenderTaskRequest const * coordinatorTask = nullptr); ++ bool scheduleRealtimeRenderJob( ++ RenderWindowState & state, ++ async_rendering::RenderTaskRequest const * coordinatorTask = nullptr); + coro::task executeAsyncRenderJob( + async_rendering::RenderJob const & job, + async_rendering::AsyncRenderController::CancelCheck const & cancelCheck); + void notifyAsyncEpochIncrement(); ++ void invalidateCameraView(); + void adjustProgressFromDuration(RenderWindowState & state, uint64_t computeDurationNs); ++ [[nodiscard]] async_rendering::RealtimeRaymarchConfig loadRealtimeRaymarchConfig() const; ++ void saveRealtimeRaymarchMode(async_rendering::RealtimeRaymarchMode mode) const; ++ void queueRenderDecision(async_rendering::RenderUpdateDecision decision); ++ [[nodiscard]] bool executeQueuedRenderCommands(RenderWindowState & state); ++ [[nodiscard]] bool executeRenderCommand(async_rendering::RenderCommand const & command, ++ RenderWindowState & state); ++ [[nodiscard]] bool scheduleCoordinatorTask(async_rendering::RenderTaskRequest const & task, ++ RenderWindowState & state); ++ [[nodiscard]] bool isRealtimeRaymarchCameraInteraction() const noexcept; ++ [[nodiscard]] bool scheduleAsyncSdfPrecomputation( ++ async_rendering::RenderTaskRequest const * coordinatorTask = nullptr); ++ void completeCoordinatorTask(async_rendering::RenderTaskRequest const & request, ++ async_rendering::RenderTaskStatus status); ++ void completeCoordinatorTask(async_rendering::FrameResultMeta const & result, ++ bool producedDisplayFrame); ++ void completeCoordinatorPreviewTask(async_rendering::PreviewResultMeta const & result); + + [[nodiscard]] bool isAsyncBackendActive() const noexcept; + [[nodiscard]] std::optional tryFetchBoundingBox(bool requestAsyncUpdate); + + // Async bounding box computation +- void scheduleAsyncBboxUpdate(); ++ void scheduleAsyncBboxUpdate( ++ async_rendering::RenderTaskRequest const * coordinatorTask = nullptr); + coro::task executeAsyncBboxUpdate( + async_rendering::RenderJob const & job, + async_rendering::AsyncRenderController::CancelCheck const & cancelCheck); +@@ -196,14 +223,16 @@ namespace gladius::ui + async_rendering::AsyncRenderController::CancelCheck const & cancelCheck); + + // Async preview rendering (non-blocking low-res preview during camera movement) +- bool scheduleAsyncPreviewJob(); ++ bool scheduleAsyncPreviewJob( ++ async_rendering::RenderTaskRequest const * coordinatorTask = nullptr); + coro::task executeAsyncPreviewJob( + async_rendering::RenderJob const & job, + async_rendering::AsyncRenderController::CancelCheck const & cancelCheck); + void processAsyncPreviewResults(); + + // Streaming preview loop (tight render loop during parameter drag) +- bool scheduleStreamingPreviewJob(); ++ bool scheduleStreamingPreviewJob( ++ async_rendering::RenderTaskRequest const * coordinatorTask = nullptr); + coro::task executeStreamingPreviewJob( + async_rendering::RenderJob const & job, + async_rendering::AsyncRenderController::CancelCheck const & cancelCheck); +@@ -235,6 +264,7 @@ namespace gladius::ui + RenderWindowState m_renderWindowState{}; + + bool m_centerViewRequested = false; ++ int m_cameraIdleFrames{0}; + bool m_enableHQRendering = true; + + ImVec2 m_contentAreaMin; +@@ -316,12 +346,17 @@ namespace gladius::ui + void onCameraManuallyMoved(); + + async_rendering::AsyncRenderFeatureConfig m_asyncConfig{}; ++ async_rendering::RenderUpdateCoordinator m_renderUpdateCoordinator{}; ++ std::vector m_pendingRenderCommands; + std::shared_ptr m_asyncController; + std::atomic m_asyncEpochCounter{0}; + std::atomic m_asyncCurrentEpoch{0}; ++ std::atomic m_asyncViewEpoch{0}; + std::atomic m_asyncInFlightEpoch{0}; ++ std::atomic m_asyncInFlightViewEpoch{0}; + std::atomic m_asyncFrameCounter{0}; + std::atomic m_asyncJobInFlight{false}; ++ std::atomic m_asyncRealtimeJobInFlight{false}; + std::atomic m_asyncBboxJobInFlight{false}; + std::atomic m_asyncBboxUpdatePending{ + false}; // Tracks if bbox needs update after current job +@@ -339,6 +374,7 @@ namespace gladius::ui + // Progressive rendering: reuse same buffer for all chunks in a frame + async_rendering::FrameBuffer * m_asyncProgressiveBuffer{nullptr}; + std::atomic m_asyncProgressiveEpoch{0}; ++ std::atomic m_asyncProgressiveViewEpoch{0}; + + // Async preview rendering state (separate from HQ progressive rendering) + std::atomic m_asyncPreviewEpoch{0}; ///< Current preview epoch for cancellation +diff --git a/gladius/src/ui/render/AsyncRenderController.cpp b/gladius/src/ui/render/AsyncRenderController.cpp +index 98c76c6..8793208 100644 +--- a/gladius/src/ui/render/AsyncRenderController.cpp ++++ b/gladius/src/ui/render/AsyncRenderController.cpp +@@ -45,6 +45,7 @@ namespace gladius::ui::async_rendering + coro::queue jobQueue; + std::atomic shutdownRequested{false}; + std::atomic latestEpoch{0}; ++ std::atomic latestViewEpoch{0}; + AsyncRenderController::JobExecutor jobExecutor; + + std::mutex resultMutex; +@@ -133,6 +134,7 @@ namespace gladius::ui::async_rendering + + m_state->data.shutdownRequested.store(false, std::memory_order_release); + m_state->data.latestEpoch.store(0, std::memory_order_release); ++ m_state->data.latestViewEpoch.store(0, std::memory_order_release); + { + std::lock_guard resultLock(m_state->data.resultMutex); + m_state->data.pendingResult.reset(); +@@ -199,6 +201,22 @@ namespace gladius::ui::async_rendering + } + } + ++ void AsyncRenderController::setLatestViewEpoch(uint64_t viewEpoch) ++ { ++ if (!m_state) ++ { ++ return; ++ } ++ ++ auto & latestViewEpoch = m_state->data.latestViewEpoch; ++ auto current = latestViewEpoch.load(std::memory_order_acquire); ++ while (viewEpoch > current && ++ !latestViewEpoch.compare_exchange_weak( ++ current, viewEpoch, std::memory_order_acq_rel, std::memory_order_acquire)) ++ { ++ } ++ } ++ + void AsyncRenderController::setJobExecutor(JobExecutor executor) + { + if (!m_state) +@@ -298,19 +316,43 @@ namespace gladius::ui::async_rendering + // (the UI thread). Without this, the entire job runs on the UI thread, blocking it. + co_await data->workerPool->schedule(); + +- auto cancellationCheck = [data, jobEpoch = job.epoch]() -> bool ++ // Pre-execution epoch-only check: safe to skip without storing a result because ++ // notifyAsyncEpochIncrement() clears the in-flight flags on epoch changes. ++ // View-epoch changes must NOT skip here — they need the executor to store a ++ // cancelled result so processAsyncResults() can clear m_asyncJobInFlight. ++ auto const epochOnlyCheck = [data, jobEpoch = job.epoch]() -> bool + { + return data->shutdownRequested.load(std::memory_order_acquire) || + data->latestEpoch.load(std::memory_order_acquire) > jobEpoch; + }; + ++ // In-execution check: also cancels HQ progressive jobs when the view epoch advances ++ // (camera moved), so the GPU is released quickly for realtime rendering. ++ // Realtime jobs are intentionally excluded: they complete fast and are always shown ++ // even with a stale view (allowForcedRealtimeStaleView = true). ++ bool const isHqProgressiveJob = (job.type == RenderJobType::HighQuality); ++ auto cancellationCheck = [data, ++ jobEpoch = job.epoch, ++ jobViewEpoch = job.viewEpoch, ++ isHqProgressiveJob]() -> bool ++ { ++ if (data->shutdownRequested.load(std::memory_order_acquire)) ++ return true; ++ if (data->latestEpoch.load(std::memory_order_acquire) > jobEpoch) ++ return true; ++ if (isHqProgressiveJob && jobViewEpoch > 0 && ++ data->latestViewEpoch.load(std::memory_order_acquire) > jobViewEpoch) ++ return true; ++ return false; ++ }; ++ + // Preview/streaming jobs must not be skipped here — their executors + // manage m_asyncPreviewJobInFlight and m_streamingJobInFlight flags + // that are only cleared inside the executor. Skipping the job would + // leave those flags stuck at true, preventing any future scheduling. + bool const isPreviewJob = (job.type == RenderJobType::LowResPreview || + job.type == RenderJobType::StreamingPreview); +- if (!isPreviewJob && cancellationCheck()) ++ if (!isPreviewJob && epochOnlyCheck()) + { + continue; + } +@@ -358,10 +400,10 @@ namespace gladius::ui::async_rendering + !m_stagingBuffer) + { + m_stagingBuffer.reset(); +- m_workerQueue.reset(); +- +- // Create dedicated worker command queue (no GL interop needed) +- m_workerQueue = std::make_unique(context.createQueue()); ++ { ++ std::lock_guard queueLock(m_workerQueueMutex); ++ m_workerQueue = std::make_shared(context.createQueue()); ++ } + + // Create CL-only staging buffer for async rendering + static cl::ImageFormat const format = {CL_RGBA, CL_FLOAT}; +@@ -386,12 +428,14 @@ namespace gladius::ui::async_rendering + fb.state.store(FrameState::Idle, std::memory_order_release); + fb.frameId.store(0, std::memory_order_release); + fb.epoch.store(0, std::memory_order_release); ++ fb.viewEpoch.store(0, std::memory_order_release); + fb.readyTimestampNs.store(0, std::memory_order_release); + fb.publishTimestampNs.store(0, std::memory_order_release); + } + + // First buffer starts as Front + m_frameBuffers[0].state.store(FrameState::Front, std::memory_order_release); ++ m_framePresentation.reset(m_frameBuffers.size()); + m_frontBufferIndex.store(0, std::memory_order_release); + } + } +@@ -401,6 +445,12 @@ namespace gladius::ui::async_rendering + return m_workerQueue.get(); + } + ++ std::shared_ptr AsyncRenderController::workerQueueShared() const noexcept ++ { ++ std::lock_guard lock(m_workerQueueMutex); ++ return m_workerQueue; ++ } ++ + cl::Image2D * AsyncRenderController::stagingBuffer() noexcept + { + return m_stagingBuffer.get(); +@@ -446,20 +496,56 @@ namespace gladius::ui::async_rendering + return nullptr; + } + +- bool AsyncRenderController::tryTransitionBuffer(FrameBuffer * buffer, +- FrameState expectedState, +- FrameState newState) noexcept ++ std::optional ++ AsyncRenderController::frameBufferIndex(FrameBuffer const * buffer) const noexcept + { +- ProfileFunction ++ if (buffer == nullptr) ++ { ++ return std::nullopt; ++ } ++ ++ for (size_t index = 0; index < m_frameBuffers.size(); ++index) ++ { ++ if (&m_frameBuffers[index] == buffer) ++ { ++ return index; ++ } ++ } ++ return std::nullopt; ++ } + +- if (!buffer) ++ bool AsyncRenderController::tryTransitionBufferLocked(FrameBuffer * buffer, ++ FrameState expectedState, ++ FrameState newState) noexcept ++ { ++ if (buffer == nullptr) + { + return false; + } + + FrameState expected = expectedState; +- return buffer->state.compare_exchange_strong( ++ bool const transitioned = buffer->state.compare_exchange_strong( + expected, newState, std::memory_order_acq_rel, std::memory_order_acquire); ++ if (!transitioned) ++ { ++ return false; ++ } ++ ++ if (auto const index = frameBufferIndex(buffer); index.has_value()) ++ { ++ [[maybe_unused]] bool const mirrored = ++ m_framePresentation.tryTransitionBuffer(*index, expectedState, newState); ++ } ++ return true; ++ } ++ ++ bool AsyncRenderController::tryTransitionBuffer(FrameBuffer * buffer, ++ FrameState expectedState, ++ FrameState newState) noexcept ++ { ++ ProfileFunction ++ std::lock_guard lock(m_bufferMutex); ++ return tryTransitionBufferLocked(buffer, expectedState, newState); + } + + FrameBuffer * AsyncRenderController::frontBuffer() noexcept +@@ -473,24 +559,35 @@ namespace gladius::ui::async_rendering + { + ProfileFunction std::lock_guard lock(m_bufferMutex); + +- // Find an Idle buffer to write to +- for (auto & buffer : m_frameBuffers) ++ auto const bufferId = ++ m_framePresentation.acquireWriteBuffer(RenderStamp{.sceneEpoch = epoch}); ++ if (!bufferId.has_value() || *bufferId >= m_frameBuffers.size()) + { +- if (tryTransitionBuffer(&buffer, FrameState::Idle, FrameState::Writing)) +- { +- buffer.epoch.store(epoch, std::memory_order_release); +- return &buffer; +- } ++ return nullptr; + } + +- // No Idle buffer available - this shouldn't happen with triple buffering +- // but can happen with double buffering if UI is slow +- return nullptr; ++ auto & buffer = m_frameBuffers[*bufferId]; ++ FrameState expected = FrameState::Idle; ++ bool const transitioned = buffer.state.compare_exchange_strong( ++ expected, FrameState::Writing, std::memory_order_acq_rel, std::memory_order_acquire); ++ if (!transitioned) ++ { ++ [[maybe_unused]] bool const rolledBack = ++ m_framePresentation.tryTransitionBuffer(*bufferId, ++ FrameState::Writing, ++ FrameState::Idle); ++ return nullptr; ++ } ++ ++ buffer.epoch.store(epoch, std::memory_order_release); ++ buffer.viewEpoch.store(0, std::memory_order_release); ++ return &buffer; + } + + void AsyncRenderController::publishFrame(FrameBuffer * buffer, + uint64_t frameId, +- uint64_t epoch) noexcept ++ uint64_t epoch, ++ uint64_t viewEpoch) noexcept + { + ProfileFunction if (!buffer) + { +@@ -501,6 +598,7 @@ namespace gladius::ui::async_rendering + + buffer->frameId.store(frameId, std::memory_order_release); + buffer->epoch.store(epoch, std::memory_order_release); ++ buffer->viewEpoch.store(viewEpoch, std::memory_order_release); + + // Get current timestamp (nanoseconds since epoch) + auto const now = std::chrono::high_resolution_clock::now(); +@@ -510,39 +608,38 @@ namespace gladius::ui::async_rendering + + // Transition Writing → Ready + FrameState expected = FrameState::Writing; +- buffer->state.compare_exchange_strong( ++ bool const transitioned = buffer->state.compare_exchange_strong( + expected, FrameState::Ready, std::memory_order_release, std::memory_order_acquire); ++ if (transitioned) ++ { ++ if (auto const index = frameBufferIndex(buffer); index.has_value()) ++ { ++ [[maybe_unused]] bool const mirrored = m_framePresentation.publishFrame( ++ *index, frameId, RenderStamp{.sceneEpoch = epoch, .viewEpoch = viewEpoch}); ++ } ++ } + } + + FrameBuffer * AsyncRenderController::promoteReadyToFront() noexcept + { + ProfileFunction std::lock_guard lock(m_bufferMutex); + +- // Find the newest Ready buffer (highest frameId) +- FrameBuffer * newestReady = nullptr; +- uint64_t highestFrameId = 0; +- +- for (auto & buffer : m_frameBuffers) +- { +- if (buffer.state.load(std::memory_order_acquire) == FrameState::Ready) +- { +- auto const frameId = buffer.frameId.load(std::memory_order_acquire); +- if (frameId > highestFrameId) +- { +- highestFrameId = frameId; +- newestReady = &buffer; +- } +- } +- } +- +- if (!newestReady) ++ auto const newestReadyIndex = m_framePresentation.selectNewestReady(); ++ if (!newestReadyIndex.has_value() || *newestReadyIndex >= m_frameBuffers.size()) + { + return nullptr; // No Ready buffer available + } + ++ auto * newestReady = &m_frameBuffers[*newestReadyIndex]; ++ + // Transition Ready → Resampling +- if (!tryTransitionBuffer(newestReady, FrameState::Ready, FrameState::Resampling)) ++ FrameState expected = FrameState::Ready; ++ bool const transitioned = newestReady->state.compare_exchange_strong( ++ expected, FrameState::Resampling, std::memory_order_acq_rel, std::memory_order_acquire); ++ if (!transitioned) + { ++ [[maybe_unused]] bool const rolledBack = m_framePresentation.tryTransitionBuffer( ++ *newestReadyIndex, FrameState::Resampling, FrameState::Ready); + return nullptr; // Lost race + } + +@@ -551,6 +648,32 @@ namespace gladius::ui::async_rendering + return newestReady; + } + ++ void AsyncRenderController::discardReadyFrame(uint64_t frameId, ++ uint64_t epoch, ++ uint64_t viewEpoch) noexcept ++ { ++ std::lock_guard lock(m_bufferMutex); ++ ++ for (auto & buffer : m_frameBuffers) ++ { ++ if (buffer.state.load(std::memory_order_acquire) != FrameState::Ready) ++ { ++ continue; ++ } ++ ++ if (buffer.frameId.load(std::memory_order_acquire) != frameId || ++ buffer.epoch.load(std::memory_order_acquire) != epoch || ++ buffer.viewEpoch.load(std::memory_order_acquire) != viewEpoch) ++ { ++ continue; ++ } ++ ++ [[maybe_unused]] bool const released = ++ tryTransitionBufferLocked(&buffer, FrameState::Ready, FrameState::Idle); ++ return; ++ } ++ } ++ + bool AsyncRenderController::finalizeFrontPromotion(FrameBuffer * buffer) noexcept + { + ProfileFunction +@@ -575,7 +698,10 @@ namespace gladius::ui::async_rendering + return false; + } + +- if (!tryTransitionBuffer(&(*it), FrameState::Resampling, FrameState::Front)) ++ FrameState expected = FrameState::Resampling; ++ bool const transitioned = it->state.compare_exchange_strong( ++ expected, FrameState::Front, std::memory_order_acq_rel, std::memory_order_acquire); ++ if (!transitioned) + { + return false; + } +@@ -595,6 +721,8 @@ namespace gladius::ui::async_rendering + std::memory_order_acquire); + } + ++ [[maybe_unused]] bool const mirrored = m_framePresentation.finalizeFrontPromotion(newIndex); ++ + it->publishTimestampNs.store(static_cast(ns), std::memory_order_release); + return true; + } +@@ -617,7 +745,7 @@ namespace gladius::ui::async_rendering + if (state == FrameState::Writing && bufEpoch <= oldEpoch) + { + [[maybe_unused]] bool const released = +- tryTransitionBuffer(&buffer, FrameState::Writing, FrameState::Idle); ++ tryTransitionBufferLocked(&buffer, FrameState::Writing, FrameState::Idle); + } + } + } +diff --git a/gladius/src/ui/render/AsyncRenderController.h b/gladius/src/ui/render/AsyncRenderController.h +index 29a0471..d7cc1d0 100644 +--- a/gladius/src/ui/render/AsyncRenderController.h ++++ b/gladius/src/ui/render/AsyncRenderController.h +@@ -12,6 +12,7 @@ + #include + + #include "AsyncRenderTypes.h" ++#include "FramePresentationController.h" + + // Include OpenCL platform types for cl_float4 + #include +@@ -81,6 +82,7 @@ namespace gladius::ui::async_rendering + + void enqueueJob(RenderJob job); + void setLatestEpoch(uint64_t epoch); ++ void setLatestViewEpoch(uint64_t viewEpoch); + void setJobExecutor(JobExecutor executor); + [[nodiscard]] std::optional tryConsumeResult(); + +@@ -92,6 +94,9 @@ namespace gladius::ui::async_rendering + + /// Get worker-specific command queue (used by worker thread for rendering) + [[nodiscard]] cl::CommandQueue * workerQueue() noexcept; ++ /// Thread-safe snapshot — safe to call from worker threads concurrently with ++ /// initializeAsyncResources(). Holds the queue alive for the caller's lifetime. ++ [[nodiscard]] std::shared_ptr workerQueueShared() const noexcept; + + /// Get staging buffer (used by worker thread for rendering output) + [[nodiscard]] cl::Image2D * stagingBuffer() noexcept; +@@ -114,13 +119,19 @@ namespace gladius::ui::async_rendering + [[nodiscard]] FrameBuffer * acquireWriteBuffer(uint64_t epoch) noexcept; + + /// Publish a finished frame (Writing → Ready) +- void publishFrame(FrameBuffer * buffer, uint64_t frameId, uint64_t epoch) noexcept; ++ void publishFrame(FrameBuffer * buffer, ++ uint64_t frameId, ++ uint64_t epoch, ++ uint64_t viewEpoch = 0) noexcept; + + /// Promote a Ready buffer to Front (for UI display) + [[nodiscard]] FrameBuffer * promoteReadyToFront() noexcept; + +- /// Finalize promotion by transitioning Resampling → Front and updating indices +- [[nodiscard]] bool finalizeFrontPromotion(FrameBuffer * buffer) noexcept; ++ /// Release a completed Ready frame that should no longer be displayed. ++ void discardReadyFrame(uint64_t frameId, uint64_t epoch, uint64_t viewEpoch) noexcept; ++ ++ /// Finalize promotion by transitioning Resampling → Front and updating indices ++ [[nodiscard]] bool finalizeFrontPromotion(FrameBuffer * buffer) noexcept; + + /// Release any Writing buffers from old epochs back to Idle + /// (used when epoch changes to clean up cancelled jobs) +@@ -156,17 +167,25 @@ namespace gladius::ui::async_rendering + [[nodiscard]] static auto workerLoop(std::shared_ptr state) + -> coro::task; + ++ [[nodiscard]] std::optional ++ frameBufferIndex(FrameBuffer const * buffer) const noexcept; ++ [[nodiscard]] bool tryTransitionBufferLocked(FrameBuffer * buffer, ++ FrameState expectedState, ++ FrameState newState) noexcept; ++ + std::shared_ptr m_state; + std::atomic m_running{false}; + + // OpenCL resources for async rendering (Option A: separate CL queue, no GL interop) +- std::unique_ptr m_workerQueue; ++ mutable std::mutex m_workerQueueMutex; ++ std::shared_ptr m_workerQueue; + std::unique_ptr m_stagingBuffer; + size_t m_stagingWidth{0}; + size_t m_stagingHeight{0}; + + // Triple buffer state machine (for HQ progressive rendering) + std::array m_frameBuffers; ++ FramePresentationController m_framePresentation{m_frameBuffers.size()}; + std::atomic m_frontBufferIndex{0}; + mutable std::mutex m_bufferMutex; + +diff --git a/gladius/src/ui/render/AsyncRenderTypes.h b/gladius/src/ui/render/AsyncRenderTypes.h +index be32899..1b255a3 100644 +--- a/gladius/src/ui/render/AsyncRenderTypes.h ++++ b/gladius/src/ui/render/AsyncRenderTypes.h +@@ -1,5 +1,7 @@ + #pragma once + ++#include "RenderUpdateTypes.h" ++ + #include + #include + #include +@@ -44,6 +46,7 @@ namespace gladius::ui::async_rendering + enum class RenderJobType + { + HighQuality, ++ RealtimeHighQuality, + LowResPreview, + StreamingPreview, // Continuous preview loop during parameter drag + BoundingBoxUpdate, +@@ -58,6 +61,7 @@ namespace gladius::ui::async_rendering + struct RenderJob + { + uint64_t epoch{0}; ++ uint64_t viewEpoch{0}; + uint64_t frameHint{0}; + RenderJobType type{RenderJobType::HighQuality}; + uint32_t width{0}; +@@ -67,6 +71,8 @@ namespace gladius::ui::async_rendering + bool precomputeSdf{false}; + bool enableHighQuality{true}; + bool precomputedSdf{false}; // True if SDF should be precomputed before rendering ++ uint64_t coordinatorRequestId{0}; ++ RenderStamp coordinatorStamp{}; + }; + + /** +@@ -76,6 +82,7 @@ namespace gladius::ui::async_rendering + { + uint64_t frameId{0}; + uint64_t epoch{0}; ++ uint64_t viewEpoch{0}; + RenderJobType jobType{RenderJobType::HighQuality}; + uint32_t width{0}; + uint32_t height{0}; +@@ -88,6 +95,8 @@ namespace gladius::ui::async_rendering + float compilationProgress{0.0f}; // 0.0 - 1.0 for compilation jobs + bool compilationSucceeded{false}; + std::string compilationError; // Empty if no error ++ uint64_t coordinatorRequestId{0}; ++ RenderStamp coordinatorStamp{}; + }; + + /** +@@ -113,9 +122,12 @@ namespace gladius::ui::async_rendering + { + uint64_t frameId{0}; ///< Matches PreviewRenderJob::frameId + uint64_t epoch{0}; ///< Matches PreviewRenderJob::epoch ++ uint64_t viewEpoch{0}; ///< Camera/view generation captured when the preview was queued + uint64_t latencyNs{0}; ///< Time from job enqueue to completion (nanoseconds) + bool cancelled{false}; ///< True if job was cancelled due to epoch change + bool sdfWasValid{false}; ///< True if SDF was available during rendering ++ uint64_t coordinatorRequestId{0}; ++ RenderStamp coordinatorStamp{}; + }; + + /** +@@ -127,6 +139,7 @@ namespace gladius::ui::async_rendering + std::atomic state{FrameState::Idle}; + std::atomic frameId{0}; + std::atomic epoch{0}; ++ std::atomic viewEpoch{0}; + std::atomic readyTimestampNs{0}; + std::atomic publishTimestampNs{0}; + +diff --git a/gladius/src/ui/render/FramePresentationController.h b/gladius/src/ui/render/FramePresentationController.h +new file mode 100644 +index 0000000..222b7d9 +--- /dev/null ++++ b/gladius/src/ui/render/FramePresentationController.h +@@ -0,0 +1,226 @@ ++#pragma once ++ ++#include "AsyncRenderTypes.h" ++#include "RenderUpdateTypes.h" ++ ++#include ++#include ++#include ++#include ++ ++namespace gladius::ui::async_rendering ++{ ++ /** ++ * @brief Metadata-only buffer used by FramePresentationController. ++ */ ++ struct PresentationBuffer ++ { ++ size_t id{0}; ++ FrameState state{FrameState::Idle}; ++ uint64_t frameId{0}; ++ RenderStamp stamp{}; ++ }; ++ ++ /** ++ * @brief Pure state machine for selecting, publishing, and promoting rendered frames. ++ * ++ * This class intentionally knows nothing about OpenGL/OpenCL resources. Production buffer ++ * owners can use it as the policy layer while keeping actual image allocation elsewhere. ++ */ ++ class FramePresentationController ++ { ++ public: ++ explicit FramePresentationController(size_t bufferCount = 3) ++ { ++ reset(bufferCount); ++ } ++ ++ void reset(size_t bufferCount) ++ { ++ m_buffers.clear(); ++ m_buffers.reserve(bufferCount); ++ for (size_t index = 0; index < bufferCount; ++index) ++ { ++ m_buffers.push_back(PresentationBuffer{.id = index}); ++ } ++ ++ if (!m_buffers.empty()) ++ { ++ m_buffers.front().state = FrameState::Front; ++ m_frontBufferId = m_buffers.front().id; ++ } ++ else ++ { ++ m_frontBufferId.reset(); ++ } ++ } ++ ++ [[nodiscard]] std::optional frontBufferId() const noexcept ++ { ++ return m_frontBufferId; ++ } ++ ++ [[nodiscard]] PresentationBuffer const * buffer(size_t bufferId) const noexcept ++ { ++ if (bufferId >= m_buffers.size()) ++ { ++ return nullptr; ++ } ++ return &m_buffers[bufferId]; ++ } ++ ++ [[nodiscard]] std::optional acquireWriteBuffer(RenderStamp const & stamp) noexcept ++ { ++ for (auto & candidate : m_buffers) ++ { ++ if (candidate.state != FrameState::Idle) ++ { ++ continue; ++ } ++ ++ candidate.state = FrameState::Writing; ++ candidate.frameId = 0; ++ candidate.stamp = stamp; ++ return candidate.id; ++ } ++ return std::nullopt; ++ } ++ ++ [[nodiscard]] bool publishFrame(size_t bufferId, ++ uint64_t frameId, ++ RenderStamp const & stamp) noexcept ++ { ++ auto * target = mutableBuffer(bufferId); ++ if (target == nullptr || target->state != FrameState::Writing) ++ { ++ return false; ++ } ++ ++ target->frameId = frameId; ++ target->stamp = stamp; ++ target->state = FrameState::Ready; ++ return true; ++ } ++ ++ [[nodiscard]] bool tryTransitionBuffer(size_t bufferId, ++ FrameState expectedState, ++ FrameState newState) noexcept ++ { ++ auto * target = mutableBuffer(bufferId); ++ if (target == nullptr || target->state != expectedState) ++ { ++ return false; ++ } ++ ++ target->state = newState; ++ if (newState == FrameState::Idle) ++ { ++ target->frameId = 0; ++ } ++ if (newState == FrameState::Front) ++ { ++ m_frontBufferId = target->id; ++ } ++ return true; ++ } ++ ++ [[nodiscard]] std::optional selectNewestReady() noexcept ++ { ++ return selectNewestReady(RenderStamp{}, RenderStampMask::none()); ++ } ++ ++ [[nodiscard]] std::optional selectNewestReady(RenderStamp const & required, ++ RenderStampMask const mask) noexcept ++ { ++ PresentationBuffer * newest = nullptr; ++ for (auto & candidate : m_buffers) ++ { ++ if (candidate.state != FrameState::Ready || !matches(candidate.stamp, required, mask)) ++ { ++ continue; ++ } ++ ++ if (newest == nullptr || candidate.frameId > newest->frameId) ++ { ++ newest = &candidate; ++ } ++ } ++ ++ if (newest == nullptr) ++ { ++ return std::nullopt; ++ } ++ ++ newest->state = FrameState::Resampling; ++ return newest->id; ++ } ++ ++ [[nodiscard]] bool finalizeFrontPromotion(size_t bufferId) noexcept ++ { ++ auto * target = mutableBuffer(bufferId); ++ if (target == nullptr || target->state != FrameState::Resampling) ++ { ++ return false; ++ } ++ ++ if (m_frontBufferId.has_value()) ++ { ++ auto * previousFront = mutableBuffer(*m_frontBufferId); ++ if (previousFront != nullptr && previousFront->id != target->id && ++ previousFront->state == FrameState::Front) ++ { ++ previousFront->state = FrameState::Idle; ++ previousFront->frameId = 0; ++ } ++ } ++ ++ target->state = FrameState::Front; ++ m_frontBufferId = target->id; ++ return true; ++ } ++ ++ [[nodiscard]] bool discardReadyFrame(size_t bufferId) noexcept ++ { ++ auto * target = mutableBuffer(bufferId); ++ if (target == nullptr || target->state != FrameState::Ready) ++ { ++ return false; ++ } ++ ++ target->state = FrameState::Idle; ++ target->frameId = 0; ++ return true; ++ } ++ ++ [[nodiscard]] size_t releaseStaleWritingBuffers(RenderStamp const & required, ++ RenderStampMask const mask) noexcept ++ { ++ size_t releasedCount = 0; ++ for (auto & candidate : m_buffers) ++ { ++ if (candidate.state != FrameState::Writing || !isOlderThan(candidate.stamp, required, mask)) ++ { ++ continue; ++ } ++ ++ candidate.state = FrameState::Idle; ++ candidate.frameId = 0; ++ ++releasedCount; ++ } ++ return releasedCount; ++ } ++ ++ private: ++ [[nodiscard]] PresentationBuffer * mutableBuffer(size_t bufferId) noexcept ++ { ++ if (bufferId >= m_buffers.size()) ++ { ++ return nullptr; ++ } ++ return &m_buffers[bufferId]; ++ } ++ ++ std::vector m_buffers; ++ std::optional m_frontBufferId; ++ }; ++} +diff --git a/gladius/src/ui/render/RealtimeRaymarchController.cpp b/gladius/src/ui/render/RealtimeRaymarchController.cpp +new file mode 100644 +index 0000000..33e80a1 +--- /dev/null ++++ b/gladius/src/ui/render/RealtimeRaymarchController.cpp +@@ -0,0 +1,413 @@ ++#include "RealtimeRaymarchController.h" ++ ++#include ++#include ++#include ++ ++namespace gladius::ui::async_rendering ++{ ++ namespace ++ { ++ [[nodiscard]] std::string normalizeModeString(std::string value) ++ { ++ std::transform(value.begin(), ++ value.end(), ++ value.begin(), ++ [](unsigned char character) ++ { return static_cast(std::tolower(character)); }); ++ return value; ++ } ++ } ++ ++ RealtimeRaymarchMode realtimeRaymarchModeFromString(std::string const & value) ++ { ++ auto const normalized = normalizeModeString(value); ++ if (normalized == "off" || normalized == "disabled" || normalized == "false") ++ { ++ return RealtimeRaymarchMode::Off; ++ } ++ if (normalized == "force" || normalized == "forced" || normalized == "on") ++ { ++ return RealtimeRaymarchMode::Force; ++ } ++ return RealtimeRaymarchMode::Auto; ++ } ++ ++ char const * realtimeRaymarchModeToString(RealtimeRaymarchMode mode) noexcept ++ { ++ switch (mode) ++ { ++ case RealtimeRaymarchMode::Off: ++ return "off"; ++ case RealtimeRaymarchMode::Force: ++ return "force"; ++ case RealtimeRaymarchMode::Auto: ++ default: ++ return "auto"; ++ } ++ } ++ ++ void RealtimeRaymarchController::configure(RealtimeRaymarchConfig config) ++ { ++ config.targetFrameTimeMs = std::max(config.targetFrameTimeMs, 1.0f); ++ config.enterBudgetRatio = std::clamp(config.enterBudgetRatio, 0.1f, 1.0f); ++ config.exitBudgetRatio = std::max(config.exitBudgetRatio, config.enterBudgetRatio); ++ config.severeMissRatio = std::max(config.severeMissRatio, config.exitBudgetRatio); ++ config.ewmaAlpha = std::clamp(config.ewmaAlpha, 0.01f, 1.0f); ++ config.requiredFastSamples = std::max(config.requiredFastSamples, 1); ++ config.maxSlowSamples = std::max(config.maxSlowSamples, 1); ++ config.cooldownFrames = std::max(config.cooldownFrames, 0); ++ config.staticFullFrameBudgetMs = std::max(config.staticFullFrameBudgetMs, config.targetFrameTimeMs); ++ config.realtimeDropBudgetMs = std::max(config.realtimeDropBudgetMs, config.targetFrameTimeMs); ++ ++ m_config = config; ++ if (m_config.mode == RealtimeRaymarchMode::Off) ++ { ++ m_realtimeActive = false; ++ } ++ } ++ ++ RealtimeRaymarchConfig const & RealtimeRaymarchController::config() const noexcept ++ { ++ return m_config; ++ } ++ ++ void RealtimeRaymarchController::reset() ++ { ++ m_estimatedFullFrameMs.reset(); ++ m_width = 0; ++ m_height = 0; ++ m_fastSampleStreak = 0; ++ m_slowSampleStreak = 0; ++ m_cooldownFramesRemaining = 0; ++ m_realtimeActive = false; ++ m_staticFullFramePreferred = false; ++ } ++ ++ void RealtimeRaymarchController::resetForResolution(uint32_t width, uint32_t height) ++ { ++ if (m_width == width && m_height == height) ++ { ++ return; ++ } ++ ++ auto const previousEstimate = m_estimatedFullFrameMs; ++ auto const previousWidth = m_width; ++ auto const previousHeight = m_height; ++ bool const wasRealtimeActive = m_realtimeActive; ++ ++ reset(); ++ m_width = width; ++ m_height = height; ++ ++ if (!previousEstimate.has_value() || previousWidth == 0 || previousHeight == 0 || width == 0 || height == 0) ++ { ++ return; ++ } ++ ++ auto const previousPixels = static_cast(previousWidth) * static_cast(previousHeight); ++ auto const currentPixels = static_cast(width) * static_cast(height); ++ if (previousPixels <= 0.0 || currentPixels <= 0.0) ++ { ++ return; ++ } ++ ++ auto const scale = std::clamp(currentPixels / previousPixels, 0.1, 10.0); ++ auto const scaledEstimate = static_cast(*previousEstimate * scale); ++ if (!std::isfinite(scaledEstimate) || scaledEstimate <= 0.0f) ++ { ++ return; ++ } ++ ++ m_estimatedFullFrameMs = scaledEstimate; ++ ++ auto const enterBudget = m_config.targetFrameTimeMs * m_config.enterBudgetRatio; ++ auto const exitBudget = m_config.targetFrameTimeMs * m_config.exitBudgetRatio; ++ if (scaledEstimate <= enterBudget || (wasRealtimeActive && scaledEstimate <= exitBudget)) ++ { ++ m_fastSampleStreak = m_config.requiredFastSamples; ++ m_realtimeActive = m_config.mode == RealtimeRaymarchMode::Auto; ++ m_staticFullFramePreferred = true; ++ } ++ else if (scaledEstimate <= m_config.staticFullFrameBudgetMs) ++ { ++ m_staticFullFramePreferred = true; ++ } ++ } ++ ++ void RealtimeRaymarchController::beginFrame() ++ { ++ if (m_cooldownFramesRemaining > 0) ++ { ++ --m_cooldownFramesRemaining; ++ } ++ } ++ ++ void RealtimeRaymarchController::recordStaticProgressiveSample(RealtimeRaymarchSample const & sample) ++ { ++ if (m_config.mode != RealtimeRaymarchMode::Auto || sample.cancelled) ++ { ++ return; ++ } ++ ++ if (sample.width == 0 || sample.height == 0) ++ { ++ return; ++ } ++ ++ if (!hasValidResolution(sample.width, sample.height)) ++ { ++ resetForResolution(sample.width, sample.height); ++ } ++ ++ auto const estimatedMs = estimateSampleFullFrameTimeMs(sample); ++ if (!estimatedMs.has_value()) ++ { ++ return; ++ } ++ ++ recordEstimatedTime(*estimatedMs); ++ m_staticFullFramePreferred = *estimatedMs <= m_config.staticFullFrameBudgetMs; ++ if (m_staticFullFramePreferred) ++ { ++ m_slowSampleStreak = 0; ++ } ++ } ++ ++ void RealtimeRaymarchController::recordStaticFullFrameSample(RealtimeRaymarchSample const & sample) ++ { ++ if (m_config.mode != RealtimeRaymarchMode::Auto || sample.cancelled || !sample.completedFrame) ++ { ++ return; ++ } ++ ++ if (sample.width == 0 || sample.height == 0) ++ { ++ return; ++ } ++ ++ if (!hasValidResolution(sample.width, sample.height)) ++ { ++ resetForResolution(sample.width, sample.height); ++ } ++ ++ auto const estimatedMs = estimateSampleFullFrameTimeMs(sample); ++ if (!estimatedMs.has_value()) ++ { ++ return; ++ } ++ ++ recordEstimatedTime(*estimatedMs); ++ m_staticFullFramePreferred = *estimatedMs <= m_config.staticFullFrameBudgetMs; ++ ++ if (*estimatedMs <= m_config.targetFrameTimeMs * m_config.enterBudgetRatio) ++ { ++ ++m_fastSampleStreak; ++ m_slowSampleStreak = 0; ++ if (m_fastSampleStreak >= m_config.requiredFastSamples) ++ { ++ m_realtimeActive = true; ++ } ++ return; ++ } ++ ++ m_fastSampleStreak = 0; ++ if (*estimatedMs > m_config.realtimeDropBudgetMs) ++ { ++ deactivateRealtime(); ++ } ++ } ++ ++ void RealtimeRaymarchController::recordInteractiveRealtimeSample(RealtimeRaymarchSample const & sample) ++ { ++ if (m_config.mode != RealtimeRaymarchMode::Auto || sample.cancelled || !sample.completedFrame) ++ { ++ return; ++ } ++ ++ if (sample.width == 0 || sample.height == 0) ++ { ++ return; ++ } ++ ++ if (!hasValidResolution(sample.width, sample.height)) ++ { ++ resetForResolution(sample.width, sample.height); ++ } ++ ++ auto const estimatedMs = estimateSampleFullFrameTimeMs(sample); ++ if (!estimatedMs.has_value()) ++ { ++ return; ++ } ++ ++ recordEstimatedTime(*estimatedMs); ++ if (*estimatedMs > m_config.realtimeDropBudgetMs) ++ { ++ deactivateRealtime(); ++ } ++ } ++ ++ void RealtimeRaymarchController::recordRejectedAttempt() ++ { ++ deactivateRealtime(); ++ m_fastSampleStreak = 0; ++ ++m_slowSampleStreak; ++ if (m_slowSampleStreak >= m_config.maxSlowSamples) ++ { ++ enterCooldown(); ++ } ++ } ++ ++ bool RealtimeRaymarchController::canAttemptRealtime( ++ uint32_t width, ++ uint32_t height, ++ RealtimeRaymarchGuards const & guards) const noexcept ++ { ++ if (m_config.mode == RealtimeRaymarchMode::Off) ++ { ++ return false; ++ } ++ if (!hasValidResolution(width, height)) ++ { ++ return false; ++ } ++ if (!guardsAllowAttempt(guards)) ++ { ++ return false; ++ } ++ if (m_config.mode == RealtimeRaymarchMode::Force) ++ { ++ return true; ++ } ++ if (m_cooldownFramesRemaining > 0) ++ { ++ return false; ++ } ++ return m_realtimeActive; ++ } ++ ++ bool RealtimeRaymarchController::canAttemptStaticFullFrame( ++ uint32_t width, ++ uint32_t height, ++ RealtimeRaymarchGuards const & guards) const noexcept ++ { ++ if (m_config.mode != RealtimeRaymarchMode::Auto) ++ { ++ return false; ++ } ++ if (!m_staticFullFramePreferred || m_cooldownFramesRemaining > 0) ++ { ++ return false; ++ } ++ if (!hasValidResolution(width, height)) ++ { ++ return false; ++ } ++ return guardsAllowAttempt(guards); ++ } ++ ++ bool RealtimeRaymarchController::isRealtimeActive() const noexcept ++ { ++ return m_config.mode == RealtimeRaymarchMode::Force || m_realtimeActive; ++ } ++ ++ bool RealtimeRaymarchController::isCoolingDown() const noexcept ++ { ++ return m_cooldownFramesRemaining > 0; ++ } ++ ++ std::optional RealtimeRaymarchController::estimatedFullFrameTimeMs() const noexcept ++ { ++ return m_estimatedFullFrameMs; ++ } ++ ++ char const * RealtimeRaymarchController::modeLabel() const noexcept ++ { ++ if (m_config.mode == RealtimeRaymarchMode::Force) ++ { ++ return "RT Force"; ++ } ++ if (m_config.mode == RealtimeRaymarchMode::Off) ++ { ++ return "RT Off"; ++ } ++ if (m_realtimeActive) ++ { ++ return "RT Auto"; ++ } ++ if (m_cooldownFramesRemaining > 0) ++ { ++ return "RT Cooldown"; ++ } ++ return "RT Learning"; ++ } ++ ++ bool RealtimeRaymarchController::hasValidResolution(uint32_t width, uint32_t height) const noexcept ++ { ++ return width > 0 && height > 0 && m_width == width && m_height == height; ++ } ++ ++ bool RealtimeRaymarchController::guardsAllowAttempt( ++ RealtimeRaymarchGuards const & guards) const noexcept ++ { ++ return !guards.hardBlocker && !guards.renderJobInFlight && !guards.previewJobInFlight && ++ !guards.streamingActive && !guards.streamingJobInFlight && !guards.resizePending; ++ } ++ ++ std::optional RealtimeRaymarchController::estimateSampleFullFrameTimeMs( ++ RealtimeRaymarchSample const & sample) const noexcept ++ { ++ if (sample.durationMs <= 0.0f || !std::isfinite(sample.durationMs)) ++ { ++ return std::nullopt; ++ } ++ ++ if (sample.completedFrame) ++ { ++ return sample.durationMs; ++ } ++ ++ if (sample.renderedLines == 0 || sample.totalLines == 0) ++ { ++ return std::nullopt; ++ } ++ ++ auto const clampedLines = std::min(sample.renderedLines, sample.totalLines); ++ if (clampedLines == 0) ++ { ++ return std::nullopt; ++ } ++ ++ auto const scale = static_cast(sample.totalLines) / static_cast(clampedLines); ++ return sample.durationMs * scale; ++ } ++ ++ void RealtimeRaymarchController::recordEstimatedTime(float estimatedMs) ++ { ++ if (!m_estimatedFullFrameMs.has_value()) ++ { ++ m_estimatedFullFrameMs = estimatedMs; ++ } ++ else ++ { ++ m_estimatedFullFrameMs = (*m_estimatedFullFrameMs * (1.0f - m_config.ewmaAlpha)) + ++ (estimatedMs * m_config.ewmaAlpha); ++ } ++ } ++ ++ void RealtimeRaymarchController::deactivateRealtime() ++ { ++ m_realtimeActive = false; ++ m_fastSampleStreak = 0; ++ m_staticFullFramePreferred = false; ++ } ++ ++ void RealtimeRaymarchController::enterCooldown() ++ { ++ deactivateRealtime(); ++ m_fastSampleStreak = 0; ++ m_slowSampleStreak = 0; ++ m_cooldownFramesRemaining = m_config.cooldownFrames; ++ } ++} +diff --git a/gladius/src/ui/render/RealtimeRaymarchController.h b/gladius/src/ui/render/RealtimeRaymarchController.h +new file mode 100644 +index 0000000..6b6b859 +--- /dev/null ++++ b/gladius/src/ui/render/RealtimeRaymarchController.h +@@ -0,0 +1,101 @@ ++#pragma once ++ ++#include ++#include ++#include ++#include ++ ++namespace gladius::ui::async_rendering ++{ ++ enum class RealtimeRaymarchMode ++ { ++ Off, ++ Auto, ++ Force ++ }; ++ ++ [[nodiscard]] RealtimeRaymarchMode realtimeRaymarchModeFromString(std::string const & value); ++ [[nodiscard]] char const * realtimeRaymarchModeToString(RealtimeRaymarchMode mode) noexcept; ++ ++ struct RealtimeRaymarchConfig ++ { ++ RealtimeRaymarchMode mode{RealtimeRaymarchMode::Auto}; ++ float targetFrameTimeMs{25.0f}; ++ float enterBudgetRatio{1.0f}; ++ float exitBudgetRatio{1.1f}; ++ float severeMissRatio{2.0f}; ++ float ewmaAlpha{0.35f}; ++ int requiredFastSamples{1}; ++ int maxSlowSamples{2}; ++ int cooldownFrames{30}; ++ float staticFullFrameBudgetMs{60.0f}; ++ float realtimeDropBudgetMs{60.0f}; ++ }; ++ ++ struct RealtimeRaymarchGuards ++ { ++ bool hardBlocker{false}; ++ bool renderJobInFlight{false}; ++ bool previewJobInFlight{false}; ++ bool streamingActive{false}; ++ bool streamingJobInFlight{false}; ++ bool resizePending{false}; ++ }; ++ ++ struct RealtimeRaymarchSample ++ { ++ float durationMs{0.0f}; ++ uint32_t width{0}; ++ uint32_t height{0}; ++ size_t renderedLines{0}; ++ size_t totalLines{0}; ++ bool completedFrame{false}; ++ bool cancelled{false}; ++ }; ++ ++ class RealtimeRaymarchController ++ { ++ public: ++ void configure(RealtimeRaymarchConfig config); ++ [[nodiscard]] RealtimeRaymarchConfig const & config() const noexcept; ++ ++ void reset(); ++ void resetForResolution(uint32_t width, uint32_t height); ++ void beginFrame(); ++ ++ void recordStaticProgressiveSample(RealtimeRaymarchSample const & sample); ++ void recordStaticFullFrameSample(RealtimeRaymarchSample const & sample); ++ void recordInteractiveRealtimeSample(RealtimeRaymarchSample const & sample); ++ void recordRejectedAttempt(); ++ ++ [[nodiscard]] bool canAttemptRealtime(uint32_t width, ++ uint32_t height, ++ RealtimeRaymarchGuards const & guards) const noexcept; ++ [[nodiscard]] bool canAttemptStaticFullFrame(uint32_t width, ++ uint32_t height, ++ RealtimeRaymarchGuards const & guards) const noexcept; ++ [[nodiscard]] bool isRealtimeActive() const noexcept; ++ [[nodiscard]] bool isCoolingDown() const noexcept; ++ [[nodiscard]] std::optional estimatedFullFrameTimeMs() const noexcept; ++ [[nodiscard]] char const * modeLabel() const noexcept; ++ [[nodiscard]] bool guardsAllowAttempt(RealtimeRaymarchGuards const & guards) const noexcept; ++ ++ private: ++ [[nodiscard]] bool hasValidResolution(uint32_t width, uint32_t height) const noexcept; ++ [[nodiscard]] std::optional estimateSampleFullFrameTimeMs( ++ RealtimeRaymarchSample const & sample) const noexcept; ++ void recordEstimatedTime(float estimatedMs); ++ void deactivateRealtime(); ++ void enterCooldown(); ++ ++ RealtimeRaymarchConfig m_config{}; ++ std::optional m_estimatedFullFrameMs{}; ++ uint32_t m_width{0}; ++ uint32_t m_height{0}; ++ int m_fastSampleStreak{0}; ++ int m_slowSampleStreak{0}; ++ int m_cooldownFramesRemaining{0}; ++ bool m_realtimeActive{false}; ++ bool m_staticFullFramePreferred{false}; ++ }; ++} +diff --git a/gladius/src/ui/render/RenderUpdateCoordinator.h b/gladius/src/ui/render/RenderUpdateCoordinator.h +new file mode 100644 +index 0000000..a166608 +--- /dev/null ++++ b/gladius/src/ui/render/RenderUpdateCoordinator.h +@@ -0,0 +1,548 @@ ++#pragma once ++ ++#include "RealtimeRaymarchController.h" ++#include "RenderUpdateTypes.h" ++ ++#include ++#include ++#include ++#include ++#include ++ ++namespace gladius::ui::async_rendering ++{ ++ enum class RenderInteractionState ++ { ++ Static, ++ CameraInteracting, ++ ParameterInteracting ++ }; ++ ++ enum class RenderCommandType ++ { ++ StartTask, ++ PresentFrame, ++ DiscardTaskResult, ++ KeepCurrentFrame ++ }; ++ ++ struct RenderCommand ++ { ++ RenderCommandType type{RenderCommandType::KeepCurrentFrame}; ++ RenderTaskRequest task{}; ++ RenderTaskResult result{}; ++ RenderStamp stamp{}; ++ }; ++ ++ struct RenderUpdateDecision ++ { ++ std::vector commands; ++ }; ++ ++ /** ++ * @brief Pure render/update policy that converts semantic UI/render events into task commands. ++ * ++ * The coordinator intentionally has no ImGui, OpenGL, OpenCL, GLImageBuffer, or ComputeCore ++ * dependency. Production code should translate these semantic commands into concrete work, ++ * while unit tests can drive the full epoch/backpressure/stale-result behavior directly. ++ */ ++ class RenderUpdateCoordinator ++ { ++ public: ++ RenderUpdateCoordinator() ++ { ++ m_realtime.resetForResolution(m_width, m_height); ++ } ++ ++ void configureRealtime(RealtimeRaymarchConfig config) ++ { ++ m_realtime.configure(config); ++ m_realtime.resetForResolution(m_width, m_height); ++ } ++ ++ [[nodiscard]] RealtimeRaymarchConfig const & realtimeConfig() const noexcept ++ { ++ return m_realtime.config(); ++ } ++ ++ [[nodiscard]] bool isRealtimeActive() const noexcept ++ { ++ return m_realtime.isRealtimeActive(); ++ } ++ ++ void setRealtimeGuards(RealtimeRaymarchGuards guards) noexcept ++ { ++ m_realtimeGuards = guards; ++ } ++ ++ void recordStaticProgressiveSample(RealtimeRaymarchSample const & sample) ++ { ++ m_realtime.recordStaticProgressiveSample(sample); ++ } ++ ++ void recordStaticFullFrameSample(RealtimeRaymarchSample const & sample) ++ { ++ m_realtime.recordStaticFullFrameSample(sample); ++ } ++ ++ void recordInteractiveRealtimeSample(RealtimeRaymarchSample const & sample) ++ { ++ m_realtime.recordInteractiveRealtimeSample(sample); ++ } ++ ++ void recordRealtimeRejectedAttempt() ++ { ++ m_realtime.recordRejectedAttempt(); ++ } ++ ++ [[nodiscard]] RenderStamp latestStamp() const noexcept ++ { ++ return m_latestStamp; ++ } ++ ++ [[nodiscard]] RenderInteractionState interactionState() const noexcept ++ { ++ return m_interactionState; ++ } ++ ++ [[nodiscard]] bool isBoundingBoxCurrent() const noexcept ++ { ++ return m_boundingBoxStamp.has_value() && ++ matches(*m_boundingBoxStamp, m_latestStamp, RenderStampMask::heavyGeometryTask()); ++ } ++ ++ [[nodiscard]] bool isSdfCurrent() const noexcept ++ { ++ return m_sdfStamp.has_value() && ++ matches(*m_sdfStamp, m_latestStamp, RenderStampMask::heavyGeometryTask()); ++ } ++ ++ [[nodiscard]] bool isHeavyGeometryCurrent() const noexcept ++ { ++ return isBoundingBoxCurrent() && isSdfCurrent(); ++ } ++ ++ [[nodiscard]] bool isHighQualityFrameCurrent() const noexcept ++ { ++ return m_highQualityFrameStamp.has_value() && ++ matches(*m_highQualityFrameStamp, m_latestStamp, RenderStampMask::displayFrame()); ++ } ++ ++ [[nodiscard]] RenderUpdateDecision configureViewport(uint32_t width, uint32_t height) ++ { ++ RenderUpdateDecision decision{}; ++ if (width == 0 || height == 0 || (width == m_width && height == m_height)) ++ { ++ return decision; ++ } ++ ++ m_width = width; ++ m_height = height; ++ ++m_latestStamp.viewportEpoch; ++ m_realtime.resetForResolution(m_width, m_height); ++ m_staticHQRenderTimeMs = -1.0f; ++ scheduleStaticCatchUp(decision); ++ return decision; ++ } ++ ++ [[nodiscard]] RenderUpdateDecision notifyCameraChanged() ++ { ++ RenderUpdateDecision decision{}; ++ ++m_latestStamp.viewEpoch; ++ m_interactionState = RenderInteractionState::CameraInteracting; ++ scheduleInteractiveFrame(decision); ++ return decision; ++ } ++ ++ [[nodiscard]] RenderUpdateDecision notifyCameraInteractionEnded() ++ { ++ RenderUpdateDecision decision{}; ++ if (m_interactionState == RenderInteractionState::CameraInteracting) ++ { ++ m_interactionState = RenderInteractionState::Static; ++ } ++ m_autoGestureLockedSimpler = false; ++ scheduleStaticCatchUp(decision); ++ return decision; ++ } ++ ++ [[nodiscard]] RenderUpdateDecision notifyParameterChanged(bool interactionActive) ++ { ++ RenderUpdateDecision decision{}; ++ ++m_latestStamp.parameterEpoch; ++ m_parameterUploadStamp.reset(); ++ m_boundingBoxStamp.reset(); ++ m_sdfStamp.reset(); ++ m_interactionState = interactionActive ? RenderInteractionState::ParameterInteracting ++ : RenderInteractionState::Static; ++ if (!interactionActive) ++ { ++ resetRealtimeLearning(); ++ } ++ startTask(decision, RenderTaskType::ParameterUpload, m_latestStamp); ++ if (interactionActive) ++ { ++ scheduleInteractiveFrame(decision); ++ } ++ else ++ { ++ scheduleStaticCatchUp(decision); ++ } ++ return decision; ++ } ++ ++ [[nodiscard]] RenderUpdateDecision notifyParameterInteractionEnded() ++ { ++ RenderUpdateDecision decision{}; ++ m_interactionState = RenderInteractionState::Static; ++ m_autoGestureLockedSimpler = false; ++ scheduleStaticCatchUp(decision); ++ return decision; ++ } ++ ++ [[nodiscard]] RenderUpdateDecision notifyStructuralModelChanged() ++ { ++ RenderUpdateDecision decision{}; ++ ++m_latestStamp.sceneEpoch; ++ ++m_latestStamp.parameterEpoch; ++ resetRealtimeLearning(); ++ m_programStamp.reset(); ++ m_parameterUploadStamp.reset(); ++ m_boundingBoxStamp.reset(); ++ m_sdfStamp.reset(); ++ m_interactionState = RenderInteractionState::Static; ++ scheduleStaticCatchUp(decision); ++ return decision; ++ } ++ ++ [[nodiscard]] RenderUpdateDecision notifyProgramCompilationCompleted() ++ { ++ return completeTask(RenderTaskResult{.type = RenderTaskType::ProgramCompilation, ++ .stamp = m_latestStamp, ++ .status = RenderTaskStatus::Completed}); ++ } ++ ++ [[nodiscard]] RenderUpdateDecision tick() ++ { ++ RenderUpdateDecision decision{}; ++ m_realtime.beginFrame(); ++ if (m_interactionState == RenderInteractionState::Static) ++ { ++ scheduleStaticCatchUp(decision); ++ } ++ else ++ { ++ if (m_interactionState == RenderInteractionState::ParameterInteracting && ++ !areParametersUploaded()) ++ { ++ startTask(decision, RenderTaskType::ParameterUpload, m_latestStamp); ++ } ++ scheduleInteractiveFrame(decision); ++ } ++ return decision; ++ } ++ ++ [[nodiscard]] RenderUpdateDecision completeTask(RenderTaskResult const & result) ++ { ++ RenderUpdateDecision decision{}; ++ removeInFlight(result); ++ ++ auto const mask = freshnessMaskFor(result.type); ++ if (!result.isCurrentFor(m_latestStamp, mask)) ++ { ++ decision.commands.push_back(RenderCommand{.type = RenderCommandType::DiscardTaskResult, ++ .result = result, ++ .stamp = m_latestStamp}); ++ return decision; ++ } ++ ++ switch (result.type) ++ { ++ case RenderTaskType::ProgramCompilation: ++ m_programStamp = result.stamp; ++ break; ++ case RenderTaskType::ParameterUpload: ++ m_parameterUploadStamp = result.stamp; ++ break; ++ case RenderTaskType::BoundingBoxUpdate: ++ m_boundingBoxStamp = result.stamp; ++ break; ++ case RenderTaskType::SdfPrecomputation: ++ m_sdfStamp = result.stamp; ++ break; ++ case RenderTaskType::RealtimeFullFrame: ++ case RenderTaskType::ProgressiveHighQualityChunk: ++ case RenderTaskType::LowResolutionPreview: ++ case RenderTaskType::StreamingPreview: ++ if (result.producedDisplayFrame) ++ { ++ if (result.completedFrame && ++ (result.type == RenderTaskType::ProgressiveHighQualityChunk || ++ result.type == RenderTaskType::RealtimeFullFrame)) ++ { ++ m_highQualityFrameStamp = result.stamp; ++ if (result.type == RenderTaskType::ProgressiveHighQualityChunk && ++ m_interactionState == RenderInteractionState::Static && ++ result.durationNs > 0) ++ { ++ m_staticHQRenderTimeMs = ++ static_cast(result.durationNs) / 1'000'000.0f; ++ } ++ } ++ decision.commands.push_back(RenderCommand{.type = RenderCommandType::PresentFrame, ++ .result = result, ++ .stamp = result.stamp}); ++ } ++ break; ++ } ++ ++ if (m_interactionState == RenderInteractionState::Static) ++ { ++ scheduleStaticCatchUp(decision); ++ } ++ return decision; ++ } ++ ++ private: ++ struct InFlightTask ++ { ++ uint64_t requestId{0}; ++ RenderTaskType type{RenderTaskType::ProgressiveHighQualityChunk}; ++ RenderStamp stamp{}; ++ }; ++ ++ [[nodiscard]] static constexpr RenderStampMask freshnessMaskFor(RenderTaskType type) noexcept ++ { ++ switch (type) ++ { ++ case RenderTaskType::ProgramCompilation: ++ return RenderStampMask::sceneOnly(); ++ case RenderTaskType::ParameterUpload: ++ case RenderTaskType::BoundingBoxUpdate: ++ case RenderTaskType::SdfPrecomputation: ++ return RenderStampMask::heavyGeometryTask(); ++ case RenderTaskType::RealtimeFullFrame: ++ case RenderTaskType::ProgressiveHighQualityChunk: ++ case RenderTaskType::LowResolutionPreview: ++ case RenderTaskType::StreamingPreview: ++ default: ++ return RenderStampMask::displayFrame(); ++ } ++ } ++ ++ [[nodiscard]] bool hasAnyInFlight(RenderTaskType type) const noexcept ++ { ++ return std::any_of(m_inFlight.begin(), ++ m_inFlight.end(), ++ [type](InFlightTask const & task) { return task.type == type; }); ++ } ++ ++ [[nodiscard]] bool hasInteractiveFrameInFlight() const noexcept ++ { ++ return hasAnyInFlight(RenderTaskType::RealtimeFullFrame) || ++ hasAnyInFlight(RenderTaskType::LowResolutionPreview) || ++ hasAnyInFlight(RenderTaskType::StreamingPreview); ++ } ++ ++ [[nodiscard]] bool hasEquivalentInFlight(RenderTaskType type, RenderStamp const & stamp) const noexcept ++ { ++ auto const mask = freshnessMaskFor(type); ++ return std::any_of(m_inFlight.begin(), ++ m_inFlight.end(), ++ [type, stamp, mask](InFlightTask const & task) ++ { return task.type == type && matches(task.stamp, stamp, mask); }); ++ } ++ ++ [[nodiscard]] bool isProgramCurrent() const noexcept ++ { ++ return m_programStamp.has_value() && ++ matches(*m_programStamp, m_latestStamp, RenderStampMask::sceneOnly()); ++ } ++ ++ [[nodiscard]] bool areParametersUploaded() const noexcept ++ { ++ return m_parameterUploadStamp.has_value() && ++ matches(*m_parameterUploadStamp, m_latestStamp, RenderStampMask::heavyGeometryTask()); ++ } ++ ++ void startTask(RenderUpdateDecision & decision, ++ RenderTaskType type, ++ RenderStamp const & stamp, ++ size_t lineCount = 0) ++ { ++ if (hasAnyInFlight(type) || hasEquivalentInFlight(type, stamp)) ++ { ++ keepCurrentFrame(decision); ++ return; ++ } ++ ++ size_t const requestedLines = lineCount == 0 ? static_cast(m_height) : lineCount; ++ size_t const clampedLineCount = ++ std::clamp(requestedLines, size_t{1}, static_cast(m_height)); ++ ++ RenderTaskRequest request{.requestId = m_nextRequestId++, ++ .type = type, ++ .stamp = stamp, ++ .width = m_width, ++ .height = m_height, ++ .startLine = 0, ++ .lineCount = clampedLineCount}; ++ m_inFlight.push_back(InFlightTask{.requestId = request.requestId, ++ .type = request.type, ++ .stamp = request.stamp}); ++ decision.commands.push_back(RenderCommand{.type = RenderCommandType::StartTask, ++ .task = request, ++ .stamp = stamp}); ++ } ++ ++ void keepCurrentFrame(RenderUpdateDecision & decision) const ++ { ++ decision.commands.push_back(RenderCommand{.type = RenderCommandType::KeepCurrentFrame, ++ .stamp = m_latestStamp}); ++ } ++ ++ void resetRealtimeLearning() ++ { ++ m_realtime.reset(); ++ m_realtime.resetForResolution(m_width, m_height); ++ m_staticHQRenderTimeMs = -1.0f; ++ m_autoGestureLockedSimpler = false; ++ } ++ ++ [[nodiscard]] bool autoModeAdmitsRealtime() const noexcept ++ { ++ return m_staticHQRenderTimeMs >= 0.0f && m_staticHQRenderTimeMs < 100.0f; ++ } ++ ++ void scheduleInteractiveFrame(RenderUpdateDecision & decision) ++ { ++ if (hasInteractiveFrameInFlight()) ++ { ++ keepCurrentFrame(decision); ++ return; ++ } ++ ++ if (m_interactionState == RenderInteractionState::ParameterInteracting) ++ { ++ startTask(decision, RenderTaskType::LowResolutionPreview, m_latestStamp); ++ return; ++ } ++ ++ // Auto mode: gate on measured static HQ time with per-gesture hysteresis ++ if (m_realtime.config().mode == RealtimeRaymarchMode::Auto) ++ { ++ if (!m_autoGestureLockedSimpler && !autoModeAdmitsRealtime()) ++ { ++ m_autoGestureLockedSimpler = true; ++ } ++ if (m_autoGestureLockedSimpler) ++ { ++ startTask(decision, RenderTaskType::LowResolutionPreview, m_latestStamp); ++ return; ++ } ++ if (m_realtime.guardsAllowAttempt(m_realtimeGuards)) ++ { ++ startTask(decision, RenderTaskType::RealtimeFullFrame, m_latestStamp); ++ return; ++ } ++ // Guards transiently blocked — hold the current frame (FR-026: no quality regression) ++ keepCurrentFrame(decision); ++ return; ++ } ++ ++ // Off mode: always preview ++ if (m_realtime.config().mode == RealtimeRaymarchMode::Off) ++ { ++ startTask(decision, RenderTaskType::LowResolutionPreview, m_latestStamp); ++ return; ++ } ++ ++ // Force mode: prefer exact realtime; hold current frame if guards temporarily block (FR-014) ++ if (m_realtime.canAttemptRealtime(m_width, m_height, m_realtimeGuards)) ++ { ++ startTask(decision, RenderTaskType::RealtimeFullFrame, m_latestStamp); ++ return; ++ } ++ keepCurrentFrame(decision); ++ } ++ ++ void scheduleStaticCatchUp(RenderUpdateDecision & decision) ++ { ++ if (!isProgramCurrent()) ++ { ++ startTask(decision, RenderTaskType::ProgramCompilation, m_latestStamp); ++ return; ++ } ++ if (!areParametersUploaded()) ++ { ++ startTask(decision, RenderTaskType::ParameterUpload, m_latestStamp); ++ return; ++ } ++ if (!isBoundingBoxCurrent()) ++ { ++ startTask(decision, RenderTaskType::BoundingBoxUpdate, m_latestStamp); ++ return; ++ } ++ if (!isSdfCurrent()) ++ { ++ startTask(decision, RenderTaskType::SdfPrecomputation, m_latestStamp); ++ return; ++ } ++ if (isHighQualityFrameCurrent()) ++ { ++ keepCurrentFrame(decision); ++ return; ++ } ++ if (m_realtime.canAttemptStaticFullFrame(m_width, m_height, m_realtimeGuards)) ++ { ++ startTask(decision, RenderTaskType::RealtimeFullFrame, m_latestStamp); ++ return; ++ } ++ startTask(decision, RenderTaskType::ProgressiveHighQualityChunk, m_latestStamp); ++ } ++ ++ void removeInFlight(RenderTaskResult const & result) ++ { ++ auto const byRequestId = [result](InFlightTask const & task) ++ { return result.requestId != 0 && task.requestId == result.requestId; }; ++ auto const byTypeAndStamp = [result](InFlightTask const & task) ++ { ++ return task.type == result.type && ++ matches(task.stamp, result.stamp, freshnessMaskFor(result.type)); ++ }; ++ auto const byTypeWithoutRequestId = [result](InFlightTask const & task) ++ { return result.requestId == 0 && task.type == result.type; }; ++ ++ auto const oldEnd = m_inFlight.end(); ++ auto const newEnd = std::remove_if(m_inFlight.begin(), ++ m_inFlight.end(), ++ [byRequestId, byTypeAndStamp, byTypeWithoutRequestId]( ++ InFlightTask const & task) ++ { ++ return byRequestId(task) || byTypeAndStamp(task) || ++ byTypeWithoutRequestId(task); ++ }); ++ m_inFlight.erase(newEnd, oldEnd); ++ } ++ ++ RenderStamp m_latestStamp{}; ++ std::optional m_programStamp{m_latestStamp}; ++ std::optional m_parameterUploadStamp{m_latestStamp}; ++ std::optional m_boundingBoxStamp{}; ++ std::optional m_sdfStamp{}; ++ std::optional m_highQualityFrameStamp{}; ++ std::vector m_inFlight; ++ RealtimeRaymarchController m_realtime; ++ RealtimeRaymarchGuards m_realtimeGuards{}; ++ RenderInteractionState m_interactionState{RenderInteractionState::Static}; ++ uint64_t m_nextRequestId{1}; ++ uint32_t m_width{1}; ++ uint32_t m_height{1}; ++ /// Wall-clock duration of the last completed static progressive HQ render in milliseconds. ++ /// Negative means not yet measured. Used by Auto mode to gate exact-realtime admission. ++ float m_staticHQRenderTimeMs{-1.0f}; ++ /// Per-gesture latch: set when Auto mode first decides to use simpler rendering during ++ /// an interaction. Cleared when the gesture ends to prevent mid-gesture quality switches. ++ bool m_autoGestureLockedSimpler{false}; ++ }; ++} +diff --git a/gladius/src/ui/render/RenderUpdateTypes.h b/gladius/src/ui/render/RenderUpdateTypes.h +new file mode 100644 +index 0000000..ae1f8d5 +--- /dev/null ++++ b/gladius/src/ui/render/RenderUpdateTypes.h +@@ -0,0 +1,179 @@ ++#pragma once ++ ++#include ++#include ++ ++namespace gladius::ui::async_rendering ++{ ++ /** ++ * @brief Identifies the independently changing inputs that make a rendered artifact fresh. ++ * ++ * Scene/model, parameter, camera/view, viewport, and quality generations intentionally evolve ++ * independently. This lets camera-only interaction reject stale view frames without forcing ++ * expensive model-side work such as bounding-box or SDF recomputation. ++ */ ++ struct RenderStamp ++ { ++ uint64_t sceneEpoch{0}; ///< Structural model/program generation. ++ uint64_t parameterEpoch{0}; ///< Numeric parameter payload generation. ++ uint64_t viewEpoch{0}; ///< Camera/view generation. ++ uint64_t viewportEpoch{0}; ///< Render target size/generation. ++ uint64_t qualityEpoch{0}; ///< Render settings/quality generation. ++ }; ++ ++ /** ++ * @brief Selects which RenderStamp axes are relevant for a freshness comparison. ++ */ ++ struct RenderStampMask ++ { ++ bool scene{true}; ++ bool parameters{true}; ++ bool view{true}; ++ bool viewport{true}; ++ bool quality{true}; ++ ++ [[nodiscard]] static constexpr RenderStampMask all() noexcept ++ { ++ return {}; ++ } ++ ++ [[nodiscard]] static constexpr RenderStampMask none() noexcept ++ { ++ return {.scene = false, ++ .parameters = false, ++ .view = false, ++ .viewport = false, ++ .quality = false}; ++ } ++ ++ [[nodiscard]] static constexpr RenderStampMask sceneOnly() noexcept ++ { ++ return {.scene = true, ++ .parameters = false, ++ .view = false, ++ .viewport = false, ++ .quality = false}; ++ } ++ ++ [[nodiscard]] static constexpr RenderStampMask sceneAndParameters() noexcept ++ { ++ return {.scene = true, ++ .parameters = true, ++ .view = false, ++ .viewport = false, ++ .quality = false}; ++ } ++ ++ [[nodiscard]] static constexpr RenderStampMask displayFrame() noexcept ++ { ++ return {.scene = true, ++ .parameters = true, ++ .view = true, ++ .viewport = true, ++ .quality = true}; ++ } ++ ++ [[nodiscard]] static constexpr RenderStampMask interactiveFrame() noexcept ++ { ++ return {.scene = true, ++ .parameters = true, ++ .view = true, ++ .viewport = true, ++ .quality = false}; ++ } ++ ++ [[nodiscard]] static constexpr RenderStampMask heavyGeometryTask() noexcept ++ { ++ return sceneAndParameters(); ++ } ++ }; ++ ++ [[nodiscard]] constexpr bool matches(RenderStamp const & candidate, ++ RenderStamp const & required, ++ RenderStampMask const mask = RenderStampMask::all()) noexcept ++ { ++ return (!mask.scene || candidate.sceneEpoch == required.sceneEpoch) && ++ (!mask.parameters || candidate.parameterEpoch == required.parameterEpoch) && ++ (!mask.view || candidate.viewEpoch == required.viewEpoch) && ++ (!mask.viewport || candidate.viewportEpoch == required.viewportEpoch) && ++ (!mask.quality || candidate.qualityEpoch == required.qualityEpoch); ++ } ++ ++ [[nodiscard]] constexpr bool isOlderThan(RenderStamp const & candidate, ++ RenderStamp const & required, ++ RenderStampMask const mask = RenderStampMask::all()) noexcept ++ { ++ return (mask.scene && candidate.sceneEpoch < required.sceneEpoch) || ++ (mask.parameters && candidate.parameterEpoch < required.parameterEpoch) || ++ (mask.view && candidate.viewEpoch < required.viewEpoch) || ++ (mask.viewport && candidate.viewportEpoch < required.viewportEpoch) || ++ (mask.quality && candidate.qualityEpoch < required.qualityEpoch); ++ } ++ ++ /** ++ * @brief Testable semantic task kinds requested by the render-update coordinator. ++ */ ++ enum class RenderTaskType ++ { ++ RealtimeFullFrame, ++ ProgressiveHighQualityChunk, ++ LowResolutionPreview, ++ StreamingPreview, ++ BoundingBoxUpdate, ++ SdfPrecomputation, ++ ParameterUpload, ++ ProgramCompilation ++ }; ++ ++ enum class RenderTaskStatus ++ { ++ Completed, ++ Cancelled, ++ Failed ++ }; ++ ++ /** ++ * @brief Pure description of render/update work before it is translated to GPU/UI calls. ++ */ ++ struct RenderTaskRequest ++ { ++ uint64_t requestId{0}; ++ RenderTaskType type{RenderTaskType::ProgressiveHighQualityChunk}; ++ RenderStamp stamp{}; ++ uint32_t width{0}; ++ uint32_t height{0}; ++ size_t startLine{0}; ++ size_t lineCount{0}; ++ }; ++ ++ /** ++ * @brief Pure completion metadata returned to the render-update coordinator. ++ */ ++ struct RenderTaskResult ++ { ++ uint64_t requestId{0}; ++ RenderTaskType type{RenderTaskType::ProgressiveHighQualityChunk}; ++ RenderStamp stamp{}; ++ RenderTaskStatus status{RenderTaskStatus::Completed}; ++ uint64_t durationNs{0}; ++ bool producedDisplayFrame{false}; ++ bool completedFrame{false}; ++ ++ [[nodiscard]] constexpr bool succeeded() const noexcept ++ { ++ return status == RenderTaskStatus::Completed; ++ } ++ ++ [[nodiscard]] constexpr bool isCurrentFor(RenderStamp const & required, ++ RenderStampMask const mask) const noexcept ++ { ++ return succeeded() && matches(stamp, required, mask); ++ } ++ ++ [[nodiscard]] constexpr bool isDisplayableFor(RenderStamp const & required, ++ RenderStampMask const mask = RenderStampMask::displayFrame()) const noexcept ++ { ++ return producedDisplayFrame && isCurrentFor(required, mask); ++ } ++ }; ++} +diff --git a/gladius/tests/integrationtests/MeshSdfPerformance_tests.cpp b/gladius/tests/integrationtests/MeshSdfPerformance_tests.cpp +index 9ae45ba..8ba222d 100644 +--- a/gladius/tests/integrationtests/MeshSdfPerformance_tests.cpp ++++ b/gladius/tests/integrationtests/MeshSdfPerformance_tests.cpp +@@ -217,6 +217,32 @@ namespace gladius::tests + return std::make_unique(key, vertices, indices); + } + ++ /// Create a closed, consistently wound cube mesh for sharp-feature SDF tests. ++ static void createCubeMesh(std::vector& vertices, ++ std::vector& indices, ++ float halfExtent) ++ { ++ vertices = { ++ makeFloat4(-halfExtent, -halfExtent, -halfExtent), ++ makeFloat4( halfExtent, -halfExtent, -halfExtent), ++ makeFloat4( halfExtent, halfExtent, -halfExtent), ++ makeFloat4(-halfExtent, halfExtent, -halfExtent), ++ makeFloat4(-halfExtent, -halfExtent, halfExtent), ++ makeFloat4( halfExtent, -halfExtent, halfExtent), ++ makeFloat4( halfExtent, halfExtent, halfExtent), ++ makeFloat4(-halfExtent, halfExtent, halfExtent), ++ }; ++ ++ indices = { ++ {4, 5, 6}, {4, 6, 7}, // +z ++ {1, 0, 3}, {1, 3, 2}, // -z ++ {5, 1, 2}, {5, 2, 6}, // +x ++ {0, 4, 7}, {0, 7, 3}, // -x ++ {7, 6, 2}, {7, 2, 3}, // +y ++ {0, 1, 5}, {0, 5, 4}, // -y ++ }; ++ } ++ + /// Create an icosphere mesh for testing + static void createIcosphere(std::vector& vertices, + std::vector& indices, +@@ -322,9 +348,12 @@ namespace gladius::tests + auto primitives = core->getPrimitives(); + ASSERT_NE(primitives, nullptr); + ++ bool const vdbSupported = core->getProgramManager().isVdbSupported(); ++ + gladius::compute::ManifoldDualContouringProgram sampler( + m_context, core->getResourceContext()); + sampler.setLogger(m_logger); ++ sampler.setEnableVdb(vdbSupported); + sampler.setModelKernel(R"CLC( + float4 model(float3 pos, PAYLOAD_ARGS) + { +@@ -337,20 +366,31 @@ namespace gladius::tests + { + char const * label; + MeshSdfMethod method; ++ bool requiresVdb; + }; +- std::array const cases = {{ +- {"PureBVH", MeshSdfMethod::PureBVH}, +- {"VoxelAccelerated", MeshSdfMethod::VoxelAccelerated}, +- {"FastWindingNumber", MeshSdfMethod::FastWindingNumber}, ++ std::array const cases = {{ ++ {"PureBVH", MeshSdfMethod::PureBVH, false}, ++ {"VoxelAccelerated", MeshSdfMethod::VoxelAccelerated, false}, ++ {"FastWindingNumber", MeshSdfMethod::FastWindingNumber, false}, ++ {"NanoVDB", MeshSdfMethod::NanoVDB, true}, + }}; + + for (auto const & c : cases) + { ++ if (c.requiresVdb && !vdbSupported) ++ { ++ std::cout << "Skipping " << c.label ++ << " mesh SDF comparison because NanoVDB is unavailable: " ++ << core->getProgramManager().getVdbSupportFailureReason() << std::endl; ++ continue; ++ } ++ + MeshSdfEvaluationConfig cfg{}; + cfg.method = c.method; + cfg.useEarlyExit = false; + cfg.fwnUseSignCache = false; + cfg.fwnBeta = 4.0f; ++ cfg.nanovdbVoxelSize_mm = 0.1f; + + MeshBVHBuildParams params; + params.maxPrimitivesPerLeaf = 4; +@@ -378,6 +418,14 @@ namespace gladius::tests + + primitives->clear(); + resource.write(*primitives); ++ if (c.method == MeshSdfMethod::NanoVDB) ++ { ++ ASSERT_FALSE(resource.hasNanoVdbBuildIssue()) << c.label << ": " ++ << resource.getNanoVdbBuildInfo().reason; ++ ASSERT_EQ(resource.getNanoVdbBuildInfo().result, ++ SpatialMeshResource::NanoVdbBuildResult::Built) ++ << c.label; ++ } + primitives->write(); + + if (c.method == MeshSdfMethod::VoxelAccelerated) +@@ -398,13 +446,250 @@ namespace gladius::tests + primitives->write(); + + auto const distances = sampler.evaluateSdfBatch( +- {Eigen::Vector3f{0.0f, 0.0f, 0.0f}, Eigen::Vector3f{15.0f, 0.0f, 0.0f}}, ++ {Eigen::Vector3f{0.0f, 0.0f, 0.0f}, ++ Eigen::Vector3f{15.0f, 0.0f, 0.0f}, ++ Eigen::Vector3f{9.75f, 0.0f, 0.0f}, ++ Eigen::Vector3f{10.25f, 0.0f, 0.0f}}, + *primitives, + 0.0f); +- ASSERT_EQ(distances.size(), 2u) << c.label; ++ ASSERT_EQ(distances.size(), 4u) << c.label; + EXPECT_LT(distances[0], -5.0f) << c.label << " center distance=" << distances[0]; + EXPECT_GT(distances[1], 2.0f) << c.label << " outside distance=" << distances[1]; ++ EXPECT_LT(distances[2], -0.05f) << c.label << " near-inside distance=" << distances[2]; ++ EXPECT_GT(distances[3], 0.05f) << c.label << " near-outside distance=" << distances[3]; ++ EXPECT_GT(std::fabs(distances[3]), 0.10f) ++ << c.label << " near-outside magnitude should remain in millimetres, distance=" ++ << distances[3]; ++ } ++ } ++ ++ TEST_F(MeshSdfPerformance_Test, GpuSdfEvaluation_AllMeshMethods_CubeSharpFeaturesKeepCorrectSign) ++ { ++ if (!m_context->isValid()) ++ { ++ GTEST_SKIP() << "OpenCL context not available"; ++ } ++ ++ std::vector vertices; ++ std::vector indices; ++ createCubeMesh(vertices, indices, 5.0f); ++ ++ auto core = std::make_shared( ++ m_context, RequiredCapabilities::ComputeOnly, m_logger); ++ auto primitives = core->getPrimitives(); ++ ASSERT_NE(primitives, nullptr); ++ ++ bool const vdbSupported = core->getProgramManager().isVdbSupported(); ++ ++ gladius::compute::ManifoldDualContouringProgram sampler( ++ m_context, core->getResourceContext()); ++ sampler.setLogger(m_logger); ++ sampler.setEnableVdb(vdbSupported); ++ sampler.setModelKernel(R"CLC( ++ float4 model(float3 pos, PAYLOAD_ARGS) ++ { ++ float const distance = payload(pos, 0, primitivesSize, PASS_PAYLOAD_ARGS); ++ return (float4)(0.0f, 0.0f, 0.0f, distance); ++ } ++ )CLC"); ++ ++ struct MethodCase ++ { ++ char const * label; ++ MeshSdfMethod method; ++ bool requiresVdb; ++ }; ++ std::array const cases = {{ ++ {"PureBVH", MeshSdfMethod::PureBVH, false}, ++ {"VoxelAccelerated", MeshSdfMethod::VoxelAccelerated, false}, ++ {"FastWindingNumber", MeshSdfMethod::FastWindingNumber, false}, ++ {"NanoVDB", MeshSdfMethod::NanoVDB, true}, ++ }}; ++ ++ std::vector const samplePositions = { ++ {0.0f, 0.0f, 0.0f}, // deep interior ++ {8.0f, 0.0f, 0.0f}, // far exterior ++ {4.75f, 0.0f, 0.0f}, // inside near face ++ {5.25f, 0.0f, 0.0f}, // outside near face ++ {4.75f, 4.75f, 0.0f}, // inside near sharp edge ++ {5.25f, 5.25f, 0.0f}, // outside near sharp edge ++ {4.75f, 4.75f, 4.75f}, // inside near sharp corner ++ {5.25f, 5.25f, 5.25f}, // outside near sharp corner ++ }; ++ ++ for (auto const & c : cases) ++ { ++ if (c.requiresVdb && !vdbSupported) ++ { ++ std::cout << "Skipping " << c.label ++ << " cube sharp-feature comparison because NanoVDB is unavailable: " ++ << core->getProgramManager().getVdbSupportFailureReason() << std::endl; ++ continue; ++ } ++ ++ MeshSdfEvaluationConfig cfg{}; ++ cfg.method = c.method; ++ cfg.useEarlyExit = false; ++ cfg.fwnUseSignCache = false; ++ cfg.fwnBeta = 4.0f; ++ cfg.nanovdbVoxelSize_mm = 0.1f; ++ ++ MeshBVHBuildParams params; ++ params.maxPrimitivesPerLeaf = 4; ++ params.maxDepth = 32; ++ params.traversalCost = 1.0f; ++ params.intersectionCost = 1.0f; ++ MeshBVHBuilder builder; ++ ++ ResourceKey key(ResourceId{1001}, ResourceType::Mesh); ++ SpatialMeshResource resource(key, builder.build(vertices, indices, params), cfg); ++ ++ auto & settings = core->getResourceContext()->getRenderingSettings(); ++ settings.meshInflationDistance = 0.0f; ++ settings.meshFwnBeta = cfg.fwnBeta; ++ settings.meshFwnFarFieldFactor = 0.0f; ++ settings.flags |= RF_DISABLE_MESH_EARLY_EXIT; ++ if (c.method == MeshSdfMethod::FastWindingNumber) ++ { ++ settings.flags |= RF_USE_MESH_FWN; ++ } ++ else ++ { ++ settings.flags &= ~RF_USE_MESH_FWN; ++ } ++ ++ primitives->clear(); ++ resource.write(*primitives); ++ if (c.method == MeshSdfMethod::NanoVDB) ++ { ++ ASSERT_FALSE(resource.hasNanoVdbBuildIssue()) << c.label << ": " ++ << resource.getNanoVdbBuildInfo().reason; ++ ASSERT_EQ(resource.getNanoVdbBuildInfo().result, ++ SpatialMeshResource::NanoVdbBuildResult::Built) ++ << c.label; ++ } ++ primitives->write(); ++ ++ if (c.method == MeshSdfMethod::VoxelAccelerated) ++ { ++ auto params = resource.getVoxelGridBuildParams(); ++ ASSERT_TRUE(params.has_value()) << c.label; ++ ASSERT_EQ(core->buildMeshVoxelGrids({*params}), 1u) << c.label; ++ } ++ else if (c.method == MeshSdfMethod::FastWindingNumber) ++ { ++ auto params = resource.getFwnAggregateBuildParams(); ++ ASSERT_TRUE(params.has_value()) << c.label; ++ ASSERT_EQ(core->buildMeshFwnAggregates({*params}), 1u) << c.label; ++ } ++ ++ primitives->write(); ++ auto const distances = sampler.evaluateSdfBatch(samplePositions, *primitives, 0.0f); ++ ASSERT_EQ(distances.size(), samplePositions.size()) << c.label; ++ ++ EXPECT_LT(distances[0], -4.0f) << c.label << " center distance=" << distances[0]; ++ EXPECT_GT(distances[1], 2.0f) << c.label << " exterior distance=" << distances[1]; ++ EXPECT_LT(distances[2], -0.05f) << c.label << " face-inside distance=" << distances[2]; ++ EXPECT_GT(distances[3], 0.05f) << c.label << " face-outside distance=" << distances[3]; ++ EXPECT_LT(distances[4], -0.05f) << c.label << " edge-inside distance=" << distances[4]; ++ EXPECT_GT(distances[5], 0.05f) << c.label << " edge-outside distance=" << distances[5]; ++ EXPECT_LT(distances[6], -0.05f) << c.label << " corner-inside distance=" << distances[6]; ++ EXPECT_GT(distances[7], 0.05f) << c.label << " corner-outside distance=" << distances[7]; ++ } ++ } ++ ++ TEST_F(MeshSdfPerformance_Test, GpuSdfEvaluation_NanoVdbNearestSampling_ReturnsMillimetres) ++ { ++ if (!m_context->isValid()) ++ { ++ GTEST_SKIP() << "OpenCL context not available"; ++ } ++ ++ std::vector vertices; ++ std::vector indices; ++ createCubeMesh(vertices, indices, 5.0f); ++ ++ auto core = std::make_shared( ++ m_context, RequiredCapabilities::ComputeOnly, m_logger); ++ auto primitives = core->getPrimitives(); ++ ASSERT_NE(primitives, nullptr); ++ ++ if (!core->getProgramManager().isVdbSupported()) ++ { ++ GTEST_SKIP() << "NanoVDB is unavailable: " ++ << core->getProgramManager().getVdbSupportFailureReason(); + } ++ ++ gladius::compute::ManifoldDualContouringProgram sampler( ++ m_context, core->getResourceContext()); ++ sampler.setLogger(m_logger); ++ sampler.setEnableVdb(true); ++ sampler.setModelKernel(R"CLC( ++ float4 model(float3 pos, PAYLOAD_ARGS) ++ { ++ float const distance = payload(pos, 0, primitivesSize, PASS_PAYLOAD_ARGS); ++ return (float4)(0.0f, 0.0f, 0.0f, distance); ++ } ++ )CLC"); ++ ++ MeshSdfEvaluationConfig cfg{}; ++ cfg.method = MeshSdfMethod::NanoVDB; ++ cfg.useEarlyExit = false; ++ cfg.nanovdbVoxelSize_mm = 0.1f; ++ ++ MeshBVHBuildParams params; ++ params.maxPrimitivesPerLeaf = 4; ++ params.maxDepth = 32; ++ params.traversalCost = 1.0f; ++ params.intersectionCost = 1.0f; ++ MeshBVHBuilder builder; ++ ++ ResourceKey key(ResourceId{1002}, ResourceType::Mesh); ++ SpatialMeshResource resource(key, builder.build(vertices, indices, params), cfg); ++ ++ auto & settings = core->getResourceContext()->getRenderingSettings(); ++ settings.meshInflationDistance = 0.0f; ++ settings.flags |= RF_DISABLE_MESH_EARLY_EXIT; ++ settings.flags &= ~RF_USE_MESH_FWN; ++ ++ primitives->clear(); ++ resource.write(*primitives); ++ ASSERT_FALSE(resource.hasNanoVdbBuildIssue()) << resource.getNanoVdbBuildInfo().reason; ++ ASSERT_EQ(resource.getNanoVdbBuildInfo().result, ++ SpatialMeshResource::NanoVdbBuildResult::Built); ++ primitives->write(); ++ ++ std::vector const samplePositions = { ++ {0.0f, 0.0f, 0.0f}, ++ {8.0f, 0.0f, 0.0f}, ++ {4.75f, 0.0f, 0.0f}, ++ {5.25f, 0.0f, 0.0f}, ++ {5.25f, 5.25f, 0.0f}, ++ }; ++ ++ settings.approximation = AM_FULL_MODEL; ++ auto const interpolated = sampler.evaluateSdfBatch(samplePositions, *primitives, 0.0f); ++ ++ settings.approximation = static_cast(AM_FULL_MODEL | AM_DISABLE_INTERPOLATION); ++ auto const nearest = sampler.evaluateSdfBatch(samplePositions, *primitives, 0.0f); ++ ++ ASSERT_EQ(interpolated.size(), samplePositions.size()); ++ ASSERT_EQ(nearest.size(), samplePositions.size()); ++ ++ for (size_t i = 0u; i < samplePositions.size(); ++i) ++ { ++ EXPECT_EQ(interpolated[i] < 0.0f, nearest[i] < 0.0f) ++ << "sample " << i << " interpolated=" << interpolated[i] ++ << " nearest=" << nearest[i]; ++ EXPECT_NEAR(interpolated[i], nearest[i], 0.25f) ++ << "sample " << i << " should not change units when nearest VDB sampling is used"; ++ } ++ ++ EXPECT_LT(nearest[0], -4.0f) << "center distance=" << nearest[0]; ++ EXPECT_GT(nearest[1], 2.0f) << "exterior distance=" << nearest[1]; ++ EXPECT_LT(nearest[2], -0.05f) << "face-inside distance=" << nearest[2]; ++ EXPECT_GT(nearest[3], 0.05f) << "face-outside distance=" << nearest[3]; ++ EXPECT_GT(nearest[4], 0.10f) << "edge-outside magnitude should remain in millimetres"; + } + + /// T001-T003: Baseline performance benchmark with procedural sphere +diff --git a/gladius/tests/unittests/Importer3mfMerge_test.cpp b/gladius/tests/unittests/Importer3mfMerge_test.cpp +index eec3ffc..44a8442 100644 +--- a/gladius/tests/unittests/Importer3mfMerge_test.cpp ++++ b/gladius/tests/unittests/Importer3mfMerge_test.cpp +@@ -5,6 +5,7 @@ + #include + #include + #include ++#include + + #include + #include +@@ -58,6 +59,33 @@ namespace gladius_tests + return tempPath; + } + ++ [[nodiscard]] std::filesystem::path createOpenQuad3mf() const ++ { ++ auto sourceDoc = std::make_shared(m_core); ++ sourceDoc->newEmptyModel(); ++ ++ vdb::TriangleMesh mesh; ++ mesh.vertices = { ++ openvdb::Vec3s{0.0f, 0.0f, 0.0f}, ++ openvdb::Vec3s{10.0f, 0.0f, 0.0f}, ++ openvdb::Vec3s{10.0f, 10.0f, 0.0f}, ++ openvdb::Vec3s{0.0f, 10.0f, 0.0f}, ++ }; ++ mesh.indices = { ++ openvdb::Vec3I{0, 1, 2}, ++ openvdb::Vec3I{0, 2, 3}, ++ }; ++ ++ sourceDoc->addMeshResource(std::move(mesh), "open quad"); ++ ++ auto const uniqueSuffix = ++ std::chrono::steady_clock::now().time_since_epoch().count(); ++ auto const tempPath = std::filesystem::temp_directory_path() / ++ fmt::format("gladius_nanovdb_open_quad_{}.3mf", uniqueSuffix); ++ sourceDoc->saveAs(tempPath, false); ++ return tempPath; ++ } ++ + std::shared_ptr m_doc; + std::shared_ptr m_core; + events::SharedLogger m_logger; +@@ -254,6 +282,33 @@ namespace gladius_tests + std::filesystem::remove(tempFile); + } + ++ TEST_F(Importer3mfMerge_Test, ++ ImporterLoad_WithOpenNanoVdbMesh_DegradeReportsMeshQualityIssue) ++ { ++ auto const tempFile = createOpenQuad3mf(); ++ ++ MeshSdfEvaluationConfig meshEvaluationConfig; ++ meshEvaluationConfig.method = MeshSdfMethod::NanoVDB; ++ meshEvaluationConfig.nanovdbVoxelSize_mm = 0.1f; ++ ++ io::Importer3mf importer(m_logger); ++ importer.setMeshSdfEvaluationConfig(meshEvaluationConfig); ++ importer.setNanoVdbBuildPolicy( ++ NanoVdbBuildPolicy{0u, NanoVdbFailurePolicy::Degrade}); ++ ++ m_doc->newEmptyModel(); ++ ASSERT_NO_THROW(importer.load(tempFile, *m_doc)); ++ ++ auto const summary = m_doc->getMeshQualityIssueSummary(); ++ EXPECT_TRUE(summary.hasIssue); ++ EXPECT_EQ(summary.affectedMeshCount, 1u); ++ EXPECT_GT(summary.boundaryEdgeCount, 0u); ++ EXPECT_NE(summary.message.find("No repair or fallback was applied silently"), ++ std::string::npos); ++ ++ std::filesystem::remove(tempFile); ++ } ++ + TEST_F(Importer3mfMerge_Test, DocumentLoad_WithNanoVdbHugeMesh_ThrowsRejectedError) + { + auto const tempFile = createLargeBox3mf(4000.0f, 4000.0f, 4000.0f); +@@ -268,4 +323,18 @@ namespace gladius_tests + std::filesystem::remove(tempFile); + } + ++ TEST_F(Importer3mfMerge_Test, DocumentLoad_WithNanoVdbOpenMesh_ThrowsRejectedError) ++ { ++ auto const tempFile = createOpenQuad3mf(); ++ ++ MeshSdfEvaluationConfig meshEvaluationConfig; ++ meshEvaluationConfig.method = MeshSdfMethod::NanoVDB; ++ meshEvaluationConfig.nanovdbVoxelSize_mm = 0.1f; ++ m_doc->setMeshSdfEvaluationConfig(meshEvaluationConfig); ++ ++ EXPECT_THROW(m_doc->load(tempFile), NanoVdbBuildRejectedError); ++ ++ std::filesystem::remove(tempFile); ++ } ++ + } // namespace gladius_tests +diff --git a/gladius/tests/unittests/MeshBVH_tests.cpp b/gladius/tests/unittests/MeshBVH_tests.cpp +index 5867d50..a6c4d3e 100644 +--- a/gladius/tests/unittests/MeshBVH_tests.cpp ++++ b/gladius/tests/unittests/MeshBVH_tests.cpp +@@ -53,6 +53,27 @@ namespace gladius::tests + }; + } + ++ static void createDuplicateVertexCubeMesh(std::vector & vertices, ++ std::vector & indices) ++ { ++ std::vector sharedVertices; ++ std::vector sharedIndices; ++ createCubeMesh(sharedVertices, sharedIndices); ++ ++ vertices.clear(); ++ indices.clear(); ++ vertices.reserve(sharedIndices.size() * 3u); ++ indices.reserve(sharedIndices.size()); ++ for (auto const & tri : sharedIndices) ++ { ++ int const base = static_cast(vertices.size()); ++ vertices.push_back(sharedVertices[tri.i0]); ++ vertices.push_back(sharedVertices[tri.i1]); ++ vertices.push_back(sharedVertices[tri.i2]); ++ indices.emplace_back(base, base + 1, base + 2); ++ } ++ } ++ + // Helper to create a single triangle + static void createSingleTriangle(std::vector & vertices, + std::vector & indices) +@@ -295,6 +316,25 @@ namespace gladius::tests + EXPECT_EQ(stats.degenerateTriangleCount, 0); + EXPECT_EQ(stats.boundaryEdgeCount, 0); + EXPECT_EQ(stats.nonManifoldEdgeCount, 0); ++ EXPECT_FALSE(result.quality.hasIssues()); ++ } ++ ++ /// Exact duplicate vertex records are common in STL-style triangle soups. They should be ++ /// certified by exact position adjacency without silently modifying the source mesh. ++ TEST_F(MeshBVHBuilder_Test, Build_DuplicateVertexWatertightCube_ReportsCleanStats) ++ { ++ std::vector vertices; ++ std::vector indices; ++ createDuplicateVertexCubeMesh(vertices, indices); ++ ++ auto result = builder.build(vertices, indices); ++ ASSERT_FALSE(result.empty()); ++ ++ auto const & stats = builder.getLastBuildStats(); ++ EXPECT_EQ(stats.degenerateTriangleCount, 0); ++ EXPECT_EQ(stats.boundaryEdgeCount, 0); ++ EXPECT_EQ(stats.nonManifoldEdgeCount, 0); ++ EXPECT_FALSE(result.quality.hasIssues()); + } + + /// A degenerate (zero-area) triangle should be counted but must not abort the build. +@@ -318,6 +358,8 @@ namespace gladius::tests + + auto const & stats = builder.getLastBuildStats(); + EXPECT_EQ(stats.degenerateTriangleCount, 1); ++ EXPECT_EQ(result.quality.degenerateTriangleCount, 1); ++ EXPECT_TRUE(result.quality.hasIssues()); + } + + /// Two triangles sharing a single edge: 5 boundary edges, 1 manifold edge. +@@ -341,6 +383,9 @@ namespace gladius::tests + auto const & stats = builder.getLastBuildStats(); + EXPECT_EQ(stats.boundaryEdgeCount, 4); // four outer edges + EXPECT_EQ(stats.nonManifoldEdgeCount, 0); ++ EXPECT_EQ(result.quality.boundaryEdgeCount, 4); ++ EXPECT_EQ(result.quality.nonManifoldEdgeCount, 0); ++ EXPECT_TRUE(result.quality.hasIssues()); + } + + /// Three coplanar triangles sharing one edge → non-manifold edge. +@@ -373,6 +418,8 @@ namespace gladius::tests + auto const & stats = builder.getLastBuildStats(); + EXPECT_EQ(stats.nonManifoldEdgeCount, 1) + << "Edge (0,1) is shared by three faces and must be flagged non-manifold"; ++ EXPECT_EQ(result.quality.nonManifoldEdgeCount, 1); ++ EXPECT_TRUE(result.quality.hasIssues()); + + // Verify that the edge entry on the perpendicular fin (tri 2) was NOT + // assigned a neighbour — only the two coplanar triangles should be +diff --git a/gladius/tests/unittests/SpatialMeshResource_tests.cpp b/gladius/tests/unittests/SpatialMeshResource_tests.cpp +index 3898ab0..2c5ed22 100644 +--- a/gladius/tests/unittests/SpatialMeshResource_tests.cpp ++++ b/gladius/tests/unittests/SpatialMeshResource_tests.cpp +@@ -158,6 +158,34 @@ namespace gladius::tests + EXPECT_EQ(resource.getTriangleCount(), 1u); + } + ++ TEST_F(SpatialMeshResource_Test, MeshQualityDiagnostics_OpenMesh_ExposesRecoveryMessage) ++ { ++ std::vector vertices = { ++ {0.f, 0.f, 0.f, 0.f}, ++ {1.f, 0.f, 0.f, 0.f}, ++ {1.f, 1.f, 0.f, 0.f}, ++ {0.f, 1.f, 0.f, 0.f}, ++ }; ++ std::vector indices = { ++ {0, 1, 2}, ++ {0, 2, 3}, ++ }; ++ ++ ResourceKey key(ResourceId{4}, ResourceType::Mesh); ++ SpatialMeshResource resource(key, vertices, indices); ++ ++ auto const & diagnostics = resource.getMeshQualityDiagnostics(); ++ EXPECT_TRUE(resource.hasMeshQualityIssues()); ++ EXPECT_EQ(diagnostics.boundaryEdgeCount, 4); ++ EXPECT_EQ(diagnostics.nonManifoldEdgeCount, 0); ++ ++ auto const message = resource.formatMeshQualityMessage("open quad"); ++ EXPECT_NE(message.find("open quad"), std::string::npos); ++ EXPECT_NE(message.find("4 boundary edges"), std::string::npos); ++ EXPECT_NE(message.find("No repair or fallback was applied silently"), std::string::npos); ++ EXPECT_NE(message.find("Fast Winding Number"), std::string::npos); ++ } ++ + // ======================================================================== + // Unsigned Distance Validation Tests (T043) + // ======================================================================== +diff --git a/gladius/tests/unittests/ui/RealtimeRaymarchController_tests.cpp b/gladius/tests/unittests/ui/RealtimeRaymarchController_tests.cpp +new file mode 100644 +index 0000000..c1cdee9 +--- /dev/null ++++ b/gladius/tests/unittests/ui/RealtimeRaymarchController_tests.cpp +@@ -0,0 +1,212 @@ ++#include "ui/render/RealtimeRaymarchController.h" ++ ++#include ++ ++namespace gladius::ui::async_rendering::tests ++{ ++ namespace ++ { ++ [[nodiscard]] RealtimeRaymarchConfig makeTestConfig() ++ { ++ RealtimeRaymarchConfig config{}; ++ config.targetFrameTimeMs = 25.0f; ++ config.requiredFastSamples = 3; ++ config.maxSlowSamples = 2; ++ config.cooldownFrames = 4; ++ config.ewmaAlpha = 1.0f; ++ return config; ++ } ++ ++ [[nodiscard]] RealtimeRaymarchSample makeFullFrameSample(float durationMs) ++ { ++ return RealtimeRaymarchSample{.durationMs = durationMs, ++ .width = 800, ++ .height = 600, ++ .renderedLines = 600, ++ .totalLines = 600, ++ .completedFrame = true, ++ .cancelled = false}; ++ } ++ ++ [[nodiscard]] RealtimeRaymarchSample makeProgressiveChunkSample(float durationMs) ++ { ++ return RealtimeRaymarchSample{.durationMs = durationMs, ++ .width = 800, ++ .height = 600, ++ .renderedLines = 60, ++ .totalLines = 600, ++ .completedFrame = false, ++ .cancelled = false}; ++ } ++ ++ [[nodiscard]] RealtimeRaymarchGuards makeOpenGuards() ++ { ++ return RealtimeRaymarchGuards{}; ++ } ++ } ++ ++ TEST(RealtimeRaymarchController, RecordFastSamples_EnablesRealtime) ++ { ++ RealtimeRaymarchController controller; ++ controller.configure(makeTestConfig()); ++ ++ controller.recordStaticFullFrameSample(makeFullFrameSample(18.0f)); ++ controller.recordStaticFullFrameSample(makeFullFrameSample(19.0f)); ++ controller.recordStaticFullFrameSample(makeFullFrameSample(20.0f)); ++ ++ EXPECT_TRUE(controller.canAttemptRealtime(800, 600, makeOpenGuards())); ++ ASSERT_TRUE(controller.estimatedFullFrameTimeMs().has_value()); ++ EXPECT_FLOAT_EQ(*controller.estimatedFullFrameTimeMs(), 20.0f); ++ } ++ ++ TEST(RealtimeRaymarchController, InteractiveSlowSample_DeactivatesRealtime) ++ { ++ RealtimeRaymarchController controller; ++ controller.configure(makeTestConfig()); ++ ++ controller.recordStaticFullFrameSample(makeFullFrameSample(18.0f)); ++ controller.recordStaticFullFrameSample(makeFullFrameSample(18.0f)); ++ controller.recordStaticFullFrameSample(makeFullFrameSample(18.0f)); ++ ASSERT_TRUE(controller.canAttemptRealtime(800, 600, makeOpenGuards())); ++ ++ controller.recordInteractiveRealtimeSample(makeFullFrameSample(65.0f)); ++ ++ EXPECT_FALSE(controller.canAttemptRealtime(800, 600, makeOpenGuards())); ++ EXPECT_FALSE(controller.canAttemptStaticFullFrame(800, 600, makeOpenGuards())); ++ } ++ ++ TEST(RealtimeRaymarchController, BeginFrame_CountsDownCooldown) ++ { ++ RealtimeRaymarchController controller; ++ controller.configure(makeTestConfig()); ++ ++ controller.recordRejectedAttempt(); ++ controller.recordRejectedAttempt(); ++ ASSERT_TRUE(controller.isCoolingDown()); ++ ++ controller.beginFrame(); ++ controller.beginFrame(); ++ controller.beginFrame(); ++ EXPECT_TRUE(controller.isCoolingDown()); ++ ++ controller.beginFrame(); ++ EXPECT_FALSE(controller.isCoolingDown()); ++ } ++ ++ TEST(RealtimeRaymarchController, RecordChunkSample_EstimatesFullFrameTime) ++ { ++ RealtimeRaymarchController controller; ++ controller.configure(makeTestConfig()); ++ ++ RealtimeRaymarchSample sample{}; ++ sample.durationMs = 2.0f; ++ sample.width = 800; ++ sample.height = 600; ++ sample.renderedLines = 60; ++ sample.totalLines = 600; ++ sample.completedFrame = false; ++ ++ controller.recordStaticProgressiveSample(sample); ++ ++ ASSERT_TRUE(controller.estimatedFullFrameTimeMs().has_value()); ++ EXPECT_FLOAT_EQ(*controller.estimatedFullFrameTimeMs(), 20.0f); ++ EXPECT_TRUE(controller.canAttemptStaticFullFrame(800, 600, makeOpenGuards())); ++ } ++ ++ TEST(RealtimeRaymarchController, AutoLearning_CanAttemptStaticFullFrameAfterFastProgressiveSample) ++ { ++ RealtimeRaymarchController controller; ++ controller.configure(makeTestConfig()); ++ controller.resetForResolution(800, 600); ++ ++ EXPECT_FALSE(controller.canAttemptRealtime(800, 600, makeOpenGuards())); ++ EXPECT_FALSE(controller.canAttemptStaticFullFrame(800, 600, makeOpenGuards())); ++ ++ controller.recordStaticProgressiveSample(makeProgressiveChunkSample(2.0f)); ++ ++ EXPECT_TRUE(controller.canAttemptStaticFullFrame(800, 600, makeOpenGuards())); ++ ++ auto blockedGuards = makeOpenGuards(); ++ blockedGuards.renderJobInFlight = true; ++ EXPECT_FALSE(controller.canAttemptStaticFullFrame(800, 600, blockedGuards)); ++ } ++ ++ TEST(RealtimeRaymarchController, SlowProgressiveSample_DoesNotEnableStaticFullFrame) ++ { ++ RealtimeRaymarchController controller; ++ controller.configure(makeTestConfig()); ++ ++ controller.recordStaticProgressiveSample(makeProgressiveChunkSample(10.0f)); ++ ++ ASSERT_TRUE(controller.estimatedFullFrameTimeMs().has_value()); ++ EXPECT_FLOAT_EQ(*controller.estimatedFullFrameTimeMs(), 100.0f); ++ EXPECT_FALSE(controller.canAttemptStaticFullFrame(800, 600, makeOpenGuards())); ++ } ++ ++ TEST(RealtimeRaymarchController, SlowProgressiveSample_ClearsStaticFullFramePreference) ++ { ++ RealtimeRaymarchController controller; ++ controller.configure(makeTestConfig()); ++ ++ controller.recordStaticProgressiveSample(makeProgressiveChunkSample(2.0f)); ++ ASSERT_TRUE(controller.canAttemptStaticFullFrame(800, 600, makeOpenGuards())); ++ ++ controller.recordStaticProgressiveSample(makeProgressiveChunkSample(10.0f)); ++ ++ EXPECT_FALSE(controller.canAttemptStaticFullFrame(800, 600, makeOpenGuards())); ++ } ++ ++ TEST(RealtimeRaymarchController, StaticFullFrameBetweenRealtimeAndStaticBudgets_DoesNotActivateRealtime) ++ { ++ RealtimeRaymarchController controller; ++ controller.configure(makeTestConfig()); ++ ++ controller.recordStaticProgressiveSample(makeProgressiveChunkSample(2.0f)); ++ ASSERT_TRUE(controller.canAttemptStaticFullFrame(800, 600, makeOpenGuards())); ++ ++ controller.recordStaticFullFrameSample(makeFullFrameSample(40.0f)); ++ ++ EXPECT_FALSE(controller.canAttemptRealtime(800, 600, makeOpenGuards())); ++ EXPECT_TRUE(controller.canAttemptStaticFullFrame(800, 600, makeOpenGuards())); ++ } ++ ++ TEST(RealtimeRaymarchController, ForceMode_IgnoresLearningButNotGuards) ++ { ++ RealtimeRaymarchController controller; ++ auto config = makeTestConfig(); ++ config.mode = RealtimeRaymarchMode::Force; ++ controller.configure(config); ++ controller.resetForResolution(800, 600); ++ ++ EXPECT_TRUE(controller.canAttemptRealtime(800, 600, makeOpenGuards())); ++ ++ auto blockedGuards = makeOpenGuards(); ++ blockedGuards.renderJobInFlight = true; ++ EXPECT_FALSE(controller.canAttemptRealtime(800, 600, blockedGuards)); ++ } ++ ++ TEST(RealtimeRaymarchController, ResolutionChange_ScalesConfidence) ++ { ++ RealtimeRaymarchController controller; ++ controller.configure(makeTestConfig()); ++ ++ controller.recordStaticFullFrameSample(makeFullFrameSample(18.0f)); ++ controller.recordStaticFullFrameSample(makeFullFrameSample(18.0f)); ++ controller.recordStaticFullFrameSample(makeFullFrameSample(18.0f)); ++ ASSERT_TRUE(controller.canAttemptRealtime(800, 600, makeOpenGuards())); ++ ++ controller.resetForResolution(400, 300); ++ ++ EXPECT_TRUE(controller.canAttemptRealtime(400, 300, makeOpenGuards())); ++ ASSERT_TRUE(controller.estimatedFullFrameTimeMs().has_value()); ++ EXPECT_FLOAT_EQ(*controller.estimatedFullFrameTimeMs(), 4.5f); ++ } ++ ++ TEST(RealtimeRaymarchModeFromString, WithKnownValues_ParsesModes) ++ { ++ EXPECT_EQ(realtimeRaymarchModeFromString("off"), RealtimeRaymarchMode::Off); ++ EXPECT_EQ(realtimeRaymarchModeFromString("force"), RealtimeRaymarchMode::Force); ++ EXPECT_EQ(realtimeRaymarchModeFromString("auto"), RealtimeRaymarchMode::Auto); ++ EXPECT_EQ(realtimeRaymarchModeFromString("unknown"), RealtimeRaymarchMode::Auto); ++ } ++} +diff --git a/gladius/tests/unittests/ui/render/AsyncRenderControllerPresentation_tests.cpp b/gladius/tests/unittests/ui/render/AsyncRenderControllerPresentation_tests.cpp +new file mode 100644 +index 0000000..dd2090a +--- /dev/null ++++ b/gladius/tests/unittests/ui/render/AsyncRenderControllerPresentation_tests.cpp +@@ -0,0 +1,112 @@ ++#include "ui/render/AsyncRenderController.h" ++ ++#include ++ ++namespace gladius::ui::async_rendering::tests ++{ ++ TEST(AsyncRenderControllerPresentation, PublishedFrame_CanBePromotedToFront) ++ { ++ AsyncRenderController controller; ++ ++ auto * buffer = controller.acquireWriteBuffer(10); ++ ASSERT_NE(buffer, nullptr); ++ ++ controller.publishFrame(buffer, 2, 10, 3); ++ auto * promoted = controller.promoteReadyToFront(); ++ ASSERT_EQ(promoted, buffer); ++ ++ EXPECT_TRUE(controller.finalizeFrontPromotion(promoted)); ++ EXPECT_EQ(controller.frontBuffer(), buffer); ++ EXPECT_EQ(buffer->state.load(std::memory_order_acquire), FrameState::Front); ++ EXPECT_EQ(buffer->epoch.load(std::memory_order_acquire), 10u); ++ EXPECT_EQ(buffer->viewEpoch.load(std::memory_order_acquire), 3u); ++ } ++ ++ TEST(AsyncRenderControllerPresentation, ++ FinalizeFrontPromotion_WithExistingFront_DemotesPreviousFrontForReuse) ++ { ++ AsyncRenderController controller; ++ ++ auto * initialFront = controller.frontBuffer(); ++ ASSERT_NE(initialFront, nullptr); ++ ASSERT_TRUE(controller.tryTransitionBuffer(initialFront, FrameState::Idle, FrameState::Front)); ++ ++ auto * buffer = controller.acquireWriteBuffer(10); ++ ASSERT_NE(buffer, nullptr); ++ ASSERT_NE(buffer, initialFront); ++ ++ controller.publishFrame(buffer, 2, 10, 3); ++ auto * promoted = controller.promoteReadyToFront(); ++ ASSERT_EQ(promoted, buffer); ++ ASSERT_TRUE(controller.finalizeFrontPromotion(promoted)); ++ ++ EXPECT_EQ(controller.frontBuffer(), buffer); ++ EXPECT_EQ(buffer->state.load(std::memory_order_acquire), FrameState::Front); ++ EXPECT_EQ(initialFront->state.load(std::memory_order_acquire), FrameState::Idle); ++ ++ auto * reacquired = controller.acquireWriteBuffer(11); ++ EXPECT_EQ(reacquired, initialFront); ++ } ++ ++ TEST(AsyncRenderControllerPresentation, PromoteReadyToFront_SelectsNewestFrame) ++ { ++ AsyncRenderController controller; ++ ++ auto * older = controller.acquireWriteBuffer(10); ++ auto * newer = controller.acquireWriteBuffer(10); ++ ASSERT_NE(older, nullptr); ++ ASSERT_NE(newer, nullptr); ++ ASSERT_NE(older, newer); ++ ++ controller.publishFrame(older, 5, 10, 3); ++ controller.publishFrame(newer, 6, 10, 3); ++ ++ auto * promoted = controller.promoteReadyToFront(); ++ ++ EXPECT_EQ(promoted, newer); ++ EXPECT_EQ(newer->state.load(std::memory_order_acquire), FrameState::Resampling); ++ EXPECT_EQ(older->state.load(std::memory_order_acquire), FrameState::Ready); ++ } ++ ++ TEST(AsyncRenderControllerPresentation, DiscardReadyFrame_WithMatchingMetadata_ReleasesBuffer) ++ { ++ AsyncRenderController controller; ++ ++ auto * buffer = controller.acquireWriteBuffer(10); ++ ASSERT_NE(buffer, nullptr); ++ controller.publishFrame(buffer, 2, 10, 3); ++ ++ controller.discardReadyFrame(2, 10, 3); ++ ++ EXPECT_EQ(buffer->state.load(std::memory_order_acquire), FrameState::Idle); ++ auto * reacquired = controller.acquireWriteBuffer(11); ++ EXPECT_EQ(reacquired, buffer); ++ } ++ ++ TEST(AsyncRenderControllerPresentation, ReleaseStaleBuffers_WithWritingBuffer_ReleasesIt) ++ { ++ AsyncRenderController controller; ++ ++ auto * buffer = controller.acquireWriteBuffer(10); ++ ASSERT_NE(buffer, nullptr); ++ ++ controller.releaseStaleBuffers(10); ++ ++ EXPECT_EQ(buffer->state.load(std::memory_order_acquire), FrameState::Idle); ++ auto * reacquired = controller.acquireWriteBuffer(11); ++ EXPECT_EQ(reacquired, buffer); ++ } ++ ++ TEST(AsyncRenderControllerPresentation, TryTransitionBuffer_WithWritingBuffer_KeepsMirrorReusable) ++ { ++ AsyncRenderController controller; ++ ++ auto * buffer = controller.acquireWriteBuffer(10); ++ ASSERT_NE(buffer, nullptr); ++ ++ EXPECT_TRUE(controller.tryTransitionBuffer(buffer, FrameState::Writing, FrameState::Idle)); ++ ++ auto * reacquired = controller.acquireWriteBuffer(11); ++ EXPECT_EQ(reacquired, buffer); ++ } ++} +diff --git a/gladius/tests/unittests/ui/render/FramePresentationController_tests.cpp b/gladius/tests/unittests/ui/render/FramePresentationController_tests.cpp +new file mode 100644 +index 0000000..87ecdb8 +--- /dev/null ++++ b/gladius/tests/unittests/ui/render/FramePresentationController_tests.cpp +@@ -0,0 +1,243 @@ ++#include "ui/render/FramePresentationController.h" ++ ++#include ++ ++namespace gladius::ui::async_rendering::tests ++{ ++ namespace ++ { ++ [[nodiscard]] constexpr RenderStamp makeStamp(uint64_t scene, ++ uint64_t parameters, ++ uint64_t view, ++ uint64_t viewport, ++ uint64_t quality) noexcept ++ { ++ return RenderStamp{.sceneEpoch = scene, ++ .parameterEpoch = parameters, ++ .viewEpoch = view, ++ .viewportEpoch = viewport, ++ .qualityEpoch = quality}; ++ } ++ } ++ ++ TEST(FramePresentationController, AfterReset_HasFrontBufferAndIdleBackBuffers) ++ { ++ FramePresentationController controller{3}; ++ ++ ASSERT_TRUE(controller.frontBufferId().has_value()); ++ EXPECT_EQ(*controller.frontBufferId(), 0u); ++ ASSERT_NE(controller.buffer(0), nullptr); ++ ASSERT_NE(controller.buffer(1), nullptr); ++ ASSERT_NE(controller.buffer(2), nullptr); ++ EXPECT_EQ(controller.buffer(0)->state, FrameState::Front); ++ EXPECT_EQ(controller.buffer(1)->state, FrameState::Idle); ++ EXPECT_EQ(controller.buffer(2)->state, FrameState::Idle); ++ } ++ ++ TEST(FramePresentationController, AcquireWriteBuffer_WithIdleBuffer_MarksBufferWriting) ++ { ++ FramePresentationController controller{3}; ++ auto const stamp = makeStamp(1, 2, 3, 4, 5); ++ ++ auto const bufferId = controller.acquireWriteBuffer(stamp); ++ ++ ASSERT_TRUE(bufferId.has_value()); ++ ASSERT_NE(controller.buffer(*bufferId), nullptr); ++ EXPECT_EQ(controller.buffer(*bufferId)->state, FrameState::Writing); ++ EXPECT_TRUE(matches(controller.buffer(*bufferId)->stamp, stamp)); ++ } ++ ++ TEST(FramePresentationController, PublishFrame_WithWritingBuffer_MarksBufferReady) ++ { ++ FramePresentationController controller{3}; ++ auto const stamp = makeStamp(1, 2, 3, 4, 5); ++ auto const bufferId = controller.acquireWriteBuffer(stamp); ++ ASSERT_TRUE(bufferId.has_value()); ++ ++ EXPECT_TRUE(controller.publishFrame(*bufferId, 17, stamp)); ++ ++ ASSERT_NE(controller.buffer(*bufferId), nullptr); ++ EXPECT_EQ(controller.buffer(*bufferId)->state, FrameState::Ready); ++ EXPECT_EQ(controller.buffer(*bufferId)->frameId, 17u); ++ } ++ ++ TEST(FramePresentationController, AcquireWriteBuffer_WithNoIdleBuffer_ReturnsNullopt) ++ { ++ FramePresentationController controller{2}; ++ auto const stamp = makeStamp(1, 2, 3, 4, 5); ++ ++ auto const firstWrite = controller.acquireWriteBuffer(stamp); ++ auto const secondWrite = controller.acquireWriteBuffer(stamp); ++ ++ ASSERT_TRUE(firstWrite.has_value()); ++ EXPECT_FALSE(secondWrite.has_value()); ++ } ++ ++ TEST(FramePresentationController, PublishFrame_WithNonWritingBuffer_ReturnsFalse) ++ { ++ FramePresentationController controller{3}; ++ auto const stamp = makeStamp(1, 2, 3, 4, 5); ++ ++ EXPECT_FALSE(controller.publishFrame(*controller.frontBufferId(), 17, stamp)); ++ EXPECT_EQ(controller.buffer(*controller.frontBufferId())->state, FrameState::Front); ++ } ++ ++ TEST(FramePresentationController, TryTransitionBuffer_WithExpectedState_ReleasesWritingBuffer) ++ { ++ FramePresentationController controller{3}; ++ auto const stamp = makeStamp(1, 2, 3, 4, 5); ++ auto const bufferId = controller.acquireWriteBuffer(stamp); ++ ASSERT_TRUE(bufferId.has_value()); ++ ++ EXPECT_TRUE(controller.tryTransitionBuffer(*bufferId, FrameState::Writing, FrameState::Idle)); ++ ++ EXPECT_EQ(controller.buffer(*bufferId)->state, FrameState::Idle); ++ EXPECT_EQ(controller.buffer(*bufferId)->frameId, 0u); ++ } ++ ++ TEST(FramePresentationController, TryTransitionBuffer_WithWrongExpectedState_ReturnsFalse) ++ { ++ FramePresentationController controller{3}; ++ auto const stamp = makeStamp(1, 2, 3, 4, 5); ++ auto const bufferId = controller.acquireWriteBuffer(stamp); ++ ASSERT_TRUE(bufferId.has_value()); ++ ++ EXPECT_FALSE(controller.tryTransitionBuffer(*bufferId, FrameState::Ready, FrameState::Idle)); ++ EXPECT_EQ(controller.buffer(*bufferId)->state, FrameState::Writing); ++ } ++ ++ TEST(FramePresentationController, SelectNewestReady_WithMultipleReadyBuffers_SelectsHighestFrameId) ++ { ++ FramePresentationController controller{4}; ++ auto const latest = makeStamp(1, 2, 3, 4, 5); ++ auto const olderReady = controller.acquireWriteBuffer(latest); ++ auto const newerReady = controller.acquireWriteBuffer(latest); ++ ASSERT_TRUE(olderReady.has_value()); ++ ASSERT_TRUE(newerReady.has_value()); ++ ASSERT_TRUE(controller.publishFrame(*olderReady, 10, latest)); ++ ASSERT_TRUE(controller.publishFrame(*newerReady, 11, latest)); ++ ++ auto const selected = controller.selectNewestReady(latest, RenderStampMask::displayFrame()); ++ ++ ASSERT_TRUE(selected.has_value()); ++ EXPECT_EQ(*selected, *newerReady); ++ EXPECT_EQ(controller.buffer(*selected)->state, FrameState::Resampling); ++ EXPECT_EQ(controller.buffer(*olderReady)->state, FrameState::Ready); ++ } ++ ++ TEST(FramePresentationController, SelectNewestReady_WithoutFreshnessMask_SelectsNewestRegardlessOfStamp) ++ { ++ FramePresentationController controller{4}; ++ auto const latest = makeStamp(1, 2, 3, 4, 5); ++ auto const oldView = makeStamp(1, 2, 2, 4, 5); ++ auto const currentReady = controller.acquireWriteBuffer(latest); ++ auto const staleButNewerReady = controller.acquireWriteBuffer(oldView); ++ ASSERT_TRUE(currentReady.has_value()); ++ ASSERT_TRUE(staleButNewerReady.has_value()); ++ ASSERT_TRUE(controller.publishFrame(*currentReady, 10, latest)); ++ ASSERT_TRUE(controller.publishFrame(*staleButNewerReady, 11, oldView)); ++ ++ auto const selected = controller.selectNewestReady(); ++ ++ ASSERT_TRUE(selected.has_value()); ++ EXPECT_EQ(*selected, *staleButNewerReady); ++ } ++ ++ TEST(FramePresentationController, SelectNewestReady_WithStaleReadyBuffer_IgnoresStaleFrame) ++ { ++ FramePresentationController controller{3}; ++ auto const latest = makeStamp(1, 2, 3, 4, 5); ++ auto const oldCamera = makeStamp(1, 2, 2, 4, 5); ++ auto const bufferId = controller.acquireWriteBuffer(oldCamera); ++ ASSERT_TRUE(bufferId.has_value()); ++ ASSERT_TRUE(controller.publishFrame(*bufferId, 99, oldCamera)); ++ ++ auto const selected = controller.selectNewestReady(latest, RenderStampMask::displayFrame()); ++ ++ EXPECT_FALSE(selected.has_value()); ++ EXPECT_EQ(controller.buffer(*bufferId)->state, FrameState::Ready); ++ } ++ ++ TEST(FramePresentationController, FinalizeFrontPromotion_WithSelectedBuffer_DemotesPreviousFront) ++ { ++ FramePresentationController controller{3}; ++ auto const latest = makeStamp(1, 2, 3, 4, 5); ++ auto const bufferId = controller.acquireWriteBuffer(latest); ++ ASSERT_TRUE(bufferId.has_value()); ++ ASSERT_TRUE(controller.publishFrame(*bufferId, 12, latest)); ++ auto const selected = controller.selectNewestReady(latest, RenderStampMask::displayFrame()); ++ ASSERT_TRUE(selected.has_value()); ++ ++ EXPECT_TRUE(controller.finalizeFrontPromotion(*selected)); ++ ++ ASSERT_TRUE(controller.frontBufferId().has_value()); ++ EXPECT_EQ(*controller.frontBufferId(), *selected); ++ EXPECT_EQ(controller.buffer(*selected)->state, FrameState::Front); ++ EXPECT_EQ(controller.buffer(0)->state, *selected == 0u ? FrameState::Front : FrameState::Idle); ++ } ++ ++ TEST(FramePresentationController, FinalizeFrontPromotion_WithWrongState_ReturnsFalseAndKeepsFront) ++ { ++ FramePresentationController controller{3}; ++ auto const stamp = makeStamp(1, 2, 3, 4, 5); ++ auto const bufferId = controller.acquireWriteBuffer(stamp); ++ ASSERT_TRUE(bufferId.has_value()); ++ auto const originalFront = controller.frontBufferId(); ++ ++ EXPECT_FALSE(controller.finalizeFrontPromotion(*bufferId)); ++ ++ EXPECT_EQ(controller.frontBufferId(), originalFront); ++ EXPECT_EQ(controller.buffer(*bufferId)->state, FrameState::Writing); ++ } ++ ++ TEST(FramePresentationController, FinalizeFrontPromotion_AfterPromotion_AllowsOldFrontReuse) ++ { ++ FramePresentationController controller{3}; ++ auto const latest = makeStamp(1, 2, 3, 4, 5); ++ auto const bufferId = controller.acquireWriteBuffer(latest); ++ ASSERT_TRUE(bufferId.has_value()); ++ ASSERT_TRUE(controller.publishFrame(*bufferId, 12, latest)); ++ auto const selected = controller.selectNewestReady(latest, RenderStampMask::displayFrame()); ++ ASSERT_TRUE(selected.has_value()); ++ ASSERT_TRUE(controller.finalizeFrontPromotion(*selected)); ++ ++ auto const nextWrite = controller.acquireWriteBuffer(latest); ++ ++ ASSERT_TRUE(nextWrite.has_value()); ++ EXPECT_EQ(*nextWrite, 0u); ++ EXPECT_EQ(controller.buffer(*nextWrite)->state, FrameState::Writing); ++ } ++ ++ TEST(FramePresentationController, DiscardReadyFrame_WithReadyBuffer_ReleasesItToIdle) ++ { ++ FramePresentationController controller{3}; ++ auto const latest = makeStamp(1, 2, 3, 4, 5); ++ auto const bufferId = controller.acquireWriteBuffer(latest); ++ ASSERT_TRUE(bufferId.has_value()); ++ ASSERT_TRUE(controller.publishFrame(*bufferId, 12, latest)); ++ ++ EXPECT_TRUE(controller.discardReadyFrame(*bufferId)); ++ ++ EXPECT_EQ(controller.buffer(*bufferId)->state, FrameState::Idle); ++ EXPECT_EQ(controller.buffer(*bufferId)->frameId, 0u); ++ } ++ ++ TEST(FramePresentationController, ReleaseStaleWritingBuffers_WithDifferentMasks_ReleasesRelevantStaleWork) ++ { ++ FramePresentationController controller{4}; ++ auto const latest = makeStamp(1, 2, 3, 4, 5); ++ auto const oldView = makeStamp(1, 2, 2, 4, 5); ++ auto const oldScene = makeStamp(0, 2, 3, 4, 5); ++ auto const oldViewBuffer = controller.acquireWriteBuffer(oldView); ++ auto const oldSceneBuffer = controller.acquireWriteBuffer(oldScene); ++ ASSERT_TRUE(oldViewBuffer.has_value()); ++ ASSERT_TRUE(oldSceneBuffer.has_value()); ++ ++ EXPECT_EQ(controller.releaseStaleWritingBuffers(latest, RenderStampMask::heavyGeometryTask()), 1u); ++ EXPECT_EQ(controller.buffer(*oldViewBuffer)->state, FrameState::Writing); ++ EXPECT_EQ(controller.buffer(*oldSceneBuffer)->state, FrameState::Idle); ++ ++ EXPECT_EQ(controller.releaseStaleWritingBuffers(latest, RenderStampMask::displayFrame()), 1u); ++ EXPECT_EQ(controller.buffer(*oldViewBuffer)->state, FrameState::Idle); ++ } ++} +diff --git a/gladius/tests/unittests/ui/render/RenderUpdateCoordinator_tests.cpp b/gladius/tests/unittests/ui/render/RenderUpdateCoordinator_tests.cpp +new file mode 100644 +index 0000000..482d33e +--- /dev/null ++++ b/gladius/tests/unittests/ui/render/RenderUpdateCoordinator_tests.cpp +@@ -0,0 +1,577 @@ ++#include "ui/render/RenderUpdateCoordinator.h" ++ ++#include ++ ++#include ++ ++namespace gladius::ui::async_rendering::tests ++{ ++ namespace ++ { ++ [[nodiscard]] std::optional findStartedTask(RenderUpdateDecision const & decision, ++ RenderTaskType type) ++ { ++ for (auto const & command : decision.commands) ++ { ++ if (command.type == RenderCommandType::StartTask && command.task.type == type) ++ { ++ return command.task; ++ } ++ } ++ return std::nullopt; ++ } ++ ++ [[nodiscard]] bool hasCommand(RenderUpdateDecision const & decision, RenderCommandType type) ++ { ++ for (auto const & command : decision.commands) ++ { ++ if (command.type == type) ++ { ++ return true; ++ } ++ } ++ return false; ++ } ++ ++ [[nodiscard]] bool hasStartedTask(RenderUpdateDecision const & decision, RenderTaskType type) ++ { ++ return findStartedTask(decision, type).has_value(); ++ } ++ ++ [[nodiscard]] RenderTaskResult completed(RenderTaskRequest const & request, ++ bool producedDisplayFrame = false, ++ bool completedFrame = false) ++ { ++ return RenderTaskResult{.requestId = request.requestId, ++ .type = request.type, ++ .stamp = request.stamp, ++ .status = RenderTaskStatus::Completed, ++ .durationNs = 200'000'000, ++ .producedDisplayFrame = producedDisplayFrame, ++ .completedFrame = completedFrame}; ++ } ++ ++ [[nodiscard]] RenderTaskResult failed(RenderTaskRequest const & request) ++ { ++ auto result = completed(request); ++ result.status = RenderTaskStatus::Failed; ++ return result; ++ } ++ ++ [[nodiscard]] RealtimeRaymarchConfig forcedRealtimeConfig() ++ { ++ RealtimeRaymarchConfig config{}; ++ config.mode = RealtimeRaymarchMode::Force; ++ return config; ++ } ++ ++ [[nodiscard]] RealtimeRaymarchSample fullFrameSample(float durationMs) ++ { ++ return RealtimeRaymarchSample{.durationMs = durationMs, ++ .width = 640, ++ .height = 480, ++ .renderedLines = 480, ++ .totalLines = 480, ++ .completedFrame = true, ++ .cancelled = false}; ++ } ++ ++ /// Drives the coordinator through the static pipeline until a ProgressiveHighQualityChunk ++ /// completes with a duration under the 100ms Auto-mode threshold. ++ /// Call immediately after configureViewport(), passing its returned decision. ++ void primeAutoRealtimeAdmission(RenderUpdateCoordinator & coordinator, ++ RenderUpdateDecision const & viewportDecision) ++ { ++ auto bbox = findStartedTask(viewportDecision, RenderTaskType::BoundingBoxUpdate); ++ ASSERT_TRUE(bbox.has_value()); ++ auto decision = coordinator.completeTask(completed(*bbox)); ++ auto sdf = findStartedTask(decision, RenderTaskType::SdfPrecomputation); ++ ASSERT_TRUE(sdf.has_value()); ++ decision = coordinator.completeTask(completed(*sdf)); ++ auto hq = findStartedTask(decision, RenderTaskType::ProgressiveHighQualityChunk); ++ ASSERT_TRUE(hq.has_value()); ++ auto hqResult = completed(*hq, true, true); ++ hqResult.durationNs = 50'000'000; // 50ms < 100ms → autoModeAdmitsRealtime() = true ++ coordinator.completeTask(hqResult); ++ } ++ } ++ ++ TEST(RenderUpdateCoordinator, CameraChanged_WithAutoLearning_StartsLowResolutionPreview) ++ { ++ RenderUpdateCoordinator coordinator; ++ ASSERT_FALSE(coordinator.configureViewport(640, 480).commands.empty()); ++ auto const before = coordinator.latestStamp(); ++ ++ auto const decision = coordinator.notifyCameraChanged(); ++ auto const after = coordinator.latestStamp(); ++ ++ EXPECT_EQ(after.sceneEpoch, before.sceneEpoch); ++ EXPECT_EQ(after.parameterEpoch, before.parameterEpoch); ++ EXPECT_EQ(after.viewportEpoch, before.viewportEpoch); ++ EXPECT_EQ(after.viewEpoch, before.viewEpoch + 1u); ++ EXPECT_EQ(coordinator.interactionState(), RenderInteractionState::CameraInteracting); ++ EXPECT_TRUE(hasStartedTask(decision, RenderTaskType::LowResolutionPreview)); ++ EXPECT_FALSE(hasStartedTask(decision, RenderTaskType::BoundingBoxUpdate)); ++ EXPECT_FALSE(hasStartedTask(decision, RenderTaskType::SdfPrecomputation)); ++ } ++ ++ TEST(RenderUpdateCoordinator, CameraChanged_WithAutoPreviewInFlight_KeepsCurrentFrame) ++ { ++ RenderUpdateCoordinator coordinator; ++ ASSERT_FALSE(coordinator.configureViewport(640, 480).commands.empty()); ++ ++ auto decision = coordinator.notifyCameraChanged(); ++ ASSERT_TRUE(hasStartedTask(decision, RenderTaskType::LowResolutionPreview)); ++ ++ decision = coordinator.notifyCameraChanged(); ++ ++ EXPECT_FALSE(hasStartedTask(decision, RenderTaskType::LowResolutionPreview)); ++ EXPECT_TRUE(hasCommand(decision, RenderCommandType::KeepCurrentFrame)); ++ } ++ ++ TEST(RenderUpdateCoordinator, StaticCatchUp_AfterFastProgressiveSample_StartsStaticFullFrame) ++ { ++ RenderUpdateCoordinator coordinator; ++ auto decision = coordinator.configureViewport(640, 480); ++ auto bbox = findStartedTask(decision, RenderTaskType::BoundingBoxUpdate); ++ ASSERT_TRUE(bbox.has_value()); ++ ++ decision = coordinator.completeTask(completed(*bbox)); ++ auto sdf = findStartedTask(decision, RenderTaskType::SdfPrecomputation); ++ ASSERT_TRUE(sdf.has_value()); ++ ++ decision = coordinator.completeTask(completed(*sdf)); ++ auto progressive = findStartedTask(decision, RenderTaskType::ProgressiveHighQualityChunk); ++ ASSERT_TRUE(progressive.has_value()); ++ ++ coordinator.recordStaticProgressiveSample(fullFrameSample(40.0f)); ++ ++ decision = coordinator.completeTask(completed(*progressive)); ++ ++ EXPECT_TRUE(hasStartedTask(decision, RenderTaskType::RealtimeFullFrame)); ++ EXPECT_FALSE(hasStartedTask(decision, RenderTaskType::ProgressiveHighQualityChunk)); ++ } ++ ++ TEST(RenderUpdateCoordinator, CameraChanged_WithForcedRealtime_StartsRealtimeFullFrame) ++ { ++ RenderUpdateCoordinator coordinator; ++ coordinator.configureRealtime(forcedRealtimeConfig()); ++ ASSERT_FALSE(coordinator.configureViewport(640, 480).commands.empty()); ++ ++ auto const decision = coordinator.notifyCameraChanged(); ++ ++ EXPECT_TRUE(hasStartedTask(decision, RenderTaskType::RealtimeFullFrame)); ++ EXPECT_FALSE(hasStartedTask(decision, RenderTaskType::LowResolutionPreview)); ++ } ++ ++ TEST(RenderUpdateCoordinator, CameraChanged_WithForcedRealtimeInFlight_KeepsCurrentFrameWithoutPreview) ++ { ++ RenderUpdateCoordinator coordinator; ++ coordinator.configureRealtime(forcedRealtimeConfig()); ++ ASSERT_FALSE(coordinator.configureViewport(640, 480).commands.empty()); ++ ++ auto decision = coordinator.notifyCameraChanged(); ++ ASSERT_TRUE(hasStartedTask(decision, RenderTaskType::RealtimeFullFrame)); ++ ++ decision = coordinator.notifyCameraChanged(); ++ ++ EXPECT_FALSE(hasStartedTask(decision, RenderTaskType::LowResolutionPreview)); ++ EXPECT_FALSE(hasStartedTask(decision, RenderTaskType::RealtimeFullFrame)); ++ EXPECT_TRUE(hasCommand(decision, RenderCommandType::KeepCurrentFrame)); ++ } ++ ++ TEST(RenderUpdateCoordinator, CameraChanged_WithAutoRealtimeAfterFastSamples_StartsRealtimeFullFrame) ++ { ++ RenderUpdateCoordinator coordinator; ++ auto const viewportDecision = coordinator.configureViewport(640, 480); ++ ASSERT_FALSE(viewportDecision.commands.empty()); ++ primeAutoRealtimeAdmission(coordinator, viewportDecision); ++ ++ auto const decision = coordinator.notifyCameraChanged(); ++ ++ EXPECT_TRUE(hasStartedTask(decision, RenderTaskType::RealtimeFullFrame)); ++ EXPECT_FALSE(hasStartedTask(decision, RenderTaskType::LowResolutionPreview)); ++ } ++ ++ TEST(RenderUpdateCoordinator, CameraChanged_WithAutoRealtimeInFlight_KeepsCurrentFrameWithoutPreview) ++ { ++ RenderUpdateCoordinator coordinator; ++ auto const viewportDecision = coordinator.configureViewport(640, 480); ++ ASSERT_FALSE(viewportDecision.commands.empty()); ++ primeAutoRealtimeAdmission(coordinator, viewportDecision); ++ ++ auto decision = coordinator.notifyCameraChanged(); ++ ASSERT_TRUE(hasStartedTask(decision, RenderTaskType::RealtimeFullFrame)); ++ ++ decision = coordinator.notifyCameraChanged(); ++ ++ EXPECT_FALSE(hasStartedTask(decision, RenderTaskType::RealtimeFullFrame)); ++ EXPECT_FALSE(hasStartedTask(decision, RenderTaskType::LowResolutionPreview)); ++ EXPECT_TRUE(hasCommand(decision, RenderCommandType::KeepCurrentFrame)); ++ } ++ ++ TEST(RenderUpdateCoordinator, CameraChanged_WithAutoRealtimeGuardBlocker_KeepsCurrentFrame) ++ { ++ RenderUpdateCoordinator coordinator; ++ auto const viewportDecision = coordinator.configureViewport(640, 480); ++ ASSERT_FALSE(viewportDecision.commands.empty()); ++ primeAutoRealtimeAdmission(coordinator, viewportDecision); ++ async_rendering::RealtimeRaymarchGuards guards{}; ++ guards.hardBlocker = true; ++ coordinator.setRealtimeGuards(guards); ++ ++ auto const decision = coordinator.notifyCameraChanged(); ++ ++ EXPECT_FALSE(hasStartedTask(decision, RenderTaskType::RealtimeFullFrame)); ++ EXPECT_FALSE(hasStartedTask(decision, RenderTaskType::LowResolutionPreview)); ++ EXPECT_TRUE(hasCommand(decision, RenderCommandType::KeepCurrentFrame)); ++ } ++ ++ TEST(RenderUpdateCoordinator, CameraInteractionEnded_WithAutoRealtimeCurrentHq_KeepsCurrentFrame) ++ { ++ RenderUpdateCoordinator coordinator; ++ auto const viewportDecision = coordinator.configureViewport(640, 480); ++ ASSERT_FALSE(viewportDecision.commands.empty()); ++ primeAutoRealtimeAdmission(coordinator, viewportDecision); ++ ++ auto decision = coordinator.notifyCameraChanged(); ++ auto realtime = findStartedTask(decision, RenderTaskType::RealtimeFullFrame); ++ ASSERT_TRUE(realtime.has_value()); ++ ++ decision = coordinator.completeTask(completed(*realtime, true, true)); ++ EXPECT_TRUE(hasCommand(decision, RenderCommandType::PresentFrame)); ++ ++ decision = coordinator.notifyCameraInteractionEnded(); ++ ++ EXPECT_FALSE(hasStartedTask(decision, RenderTaskType::LowResolutionPreview)); ++ EXPECT_FALSE(hasStartedTask(decision, RenderTaskType::ProgressiveHighQualityChunk)); ++ EXPECT_TRUE(hasCommand(decision, RenderCommandType::KeepCurrentFrame)); ++ } ++ ++ TEST(RenderUpdateCoordinator, CameraInteractionEnded_WithStaleAutoRealtime_StartsHqNotPreview) ++ { ++ RenderUpdateCoordinator coordinator; ++ auto const viewportDecision = coordinator.configureViewport(640, 480); ++ ASSERT_FALSE(viewportDecision.commands.empty()); ++ primeAutoRealtimeAdmission(coordinator, viewportDecision); ++ ++ auto decision = coordinator.notifyCameraChanged(); ++ auto realtime = findStartedTask(decision, RenderTaskType::RealtimeFullFrame); ++ ASSERT_TRUE(realtime.has_value()); ++ ++ decision = coordinator.notifyCameraChanged(); ++ EXPECT_TRUE(hasCommand(decision, RenderCommandType::KeepCurrentFrame)); ++ ++ decision = coordinator.completeTask(completed(*realtime, true, true)); ++ EXPECT_TRUE(hasCommand(decision, RenderCommandType::DiscardTaskResult)); ++ ++ decision = coordinator.notifyCameraInteractionEnded(); ++ ++ EXPECT_FALSE(hasStartedTask(decision, RenderTaskType::LowResolutionPreview)); ++ } ++ ++ TEST(RenderUpdateCoordinator, CameraChanged_WithForcedRealtimeAndCurrentHq_EndsInteractionWithoutExtraHq) ++ { ++ RenderUpdateCoordinator coordinator; ++ coordinator.configureRealtime(forcedRealtimeConfig()); ++ ASSERT_FALSE(coordinator.configureViewport(640, 480).commands.empty()); ++ ++ auto decision = coordinator.notifyCameraChanged(); ++ auto realtime = findStartedTask(decision, RenderTaskType::RealtimeFullFrame); ++ ASSERT_TRUE(realtime.has_value()); ++ ++ decision = coordinator.completeTask(completed(*realtime, true, true)); ++ EXPECT_TRUE(hasCommand(decision, RenderCommandType::PresentFrame)); ++ ++ decision = coordinator.notifyCameraInteractionEnded(); ++ ++ EXPECT_FALSE(hasStartedTask(decision, RenderTaskType::ProgressiveHighQualityChunk)); ++ EXPECT_TRUE(hasCommand(decision, RenderCommandType::KeepCurrentFrame)); ++ } ++ ++ TEST(RenderUpdateCoordinator, CameraChanged_WithForcedRealtimeGuardBlocker_KeepsCurrentFrame) ++ { ++ RenderUpdateCoordinator coordinator; ++ coordinator.configureRealtime(forcedRealtimeConfig()); ++ async_rendering::RealtimeRaymarchGuards guards{}; ++ guards.hardBlocker = true; ++ coordinator.setRealtimeGuards(guards); ++ ASSERT_FALSE(coordinator.configureViewport(640, 480).commands.empty()); ++ ++ auto const decision = coordinator.notifyCameraChanged(); ++ ++ EXPECT_FALSE(hasStartedTask(decision, RenderTaskType::RealtimeFullFrame)); ++ EXPECT_FALSE(hasStartedTask(decision, RenderTaskType::LowResolutionPreview)); ++ EXPECT_TRUE(hasCommand(decision, RenderCommandType::KeepCurrentFrame)); ++ } ++ ++ TEST(RenderUpdateCoordinator, CameraChanged_WithResizePendingEvenForcedRealtime_KeepsCurrentFrame) ++ { ++ RenderUpdateCoordinator coordinator; ++ coordinator.configureRealtime(forcedRealtimeConfig()); ++ async_rendering::RealtimeRaymarchGuards guards{}; ++ guards.resizePending = true; ++ coordinator.setRealtimeGuards(guards); ++ ASSERT_FALSE(coordinator.configureViewport(640, 480).commands.empty()); ++ ++ auto const decision = coordinator.notifyCameraChanged(); ++ ++ EXPECT_FALSE(hasStartedTask(decision, RenderTaskType::RealtimeFullFrame)); ++ EXPECT_FALSE(hasStartedTask(decision, RenderTaskType::LowResolutionPreview)); ++ EXPECT_TRUE(hasCommand(decision, RenderCommandType::KeepCurrentFrame)); ++ } ++ ++ TEST(RenderUpdateCoordinator, FailedForcedRealtime_DoesNotFallbackToPreviewWhileGuarded) ++ { ++ RenderUpdateCoordinator coordinator; ++ coordinator.configureRealtime(forcedRealtimeConfig()); ++ ASSERT_FALSE(coordinator.configureViewport(640, 480).commands.empty()); ++ ++ auto decision = coordinator.notifyCameraChanged(); ++ auto realtime = findStartedTask(decision, RenderTaskType::RealtimeFullFrame); ++ ASSERT_TRUE(realtime.has_value()); ++ ++ decision = coordinator.completeTask(failed(*realtime)); ++ EXPECT_TRUE(hasCommand(decision, RenderCommandType::DiscardTaskResult)); ++ ++ async_rendering::RealtimeRaymarchGuards guards{}; ++ guards.hardBlocker = true; ++ coordinator.setRealtimeGuards(guards); ++ decision = coordinator.tick(); ++ ++ EXPECT_FALSE(hasStartedTask(decision, RenderTaskType::RealtimeFullFrame)); ++ EXPECT_FALSE(hasStartedTask(decision, RenderTaskType::LowResolutionPreview)); ++ EXPECT_TRUE(hasCommand(decision, RenderCommandType::KeepCurrentFrame)); ++ } ++ ++ TEST(RenderUpdateCoordinator, ParameterDrag_WithForcedRealtime_UsesPreviewNotRealtime) ++ { ++ RenderUpdateCoordinator coordinator; ++ coordinator.configureRealtime(forcedRealtimeConfig()); ++ ASSERT_FALSE(coordinator.configureViewport(640, 480).commands.empty()); ++ ++ auto const decision = coordinator.notifyParameterChanged(true); ++ ++ EXPECT_TRUE(hasStartedTask(decision, RenderTaskType::ParameterUpload)); ++ EXPECT_TRUE(hasStartedTask(decision, RenderTaskType::LowResolutionPreview)); ++ EXPECT_FALSE(hasStartedTask(decision, RenderTaskType::RealtimeFullFrame)); ++ } ++ ++ TEST(RenderUpdateCoordinator, ParameterDrag_StartsUploadAndPreviewButDefersHeavyTasks) ++ { ++ RenderUpdateCoordinator coordinator; ++ auto const before = coordinator.latestStamp(); ++ ++ auto const decision = coordinator.notifyParameterChanged(true); ++ auto const after = coordinator.latestStamp(); ++ ++ EXPECT_EQ(after.sceneEpoch, before.sceneEpoch); ++ EXPECT_EQ(after.parameterEpoch, before.parameterEpoch + 1u); ++ EXPECT_EQ(coordinator.interactionState(), RenderInteractionState::ParameterInteracting); ++ EXPECT_TRUE(hasStartedTask(decision, RenderTaskType::ParameterUpload)); ++ EXPECT_TRUE(hasStartedTask(decision, RenderTaskType::LowResolutionPreview)); ++ EXPECT_FALSE(hasStartedTask(decision, RenderTaskType::BoundingBoxUpdate)); ++ EXPECT_FALSE(hasStartedTask(decision, RenderTaskType::SdfPrecomputation)); ++ } ++ ++ TEST(RenderUpdateCoordinator, ParameterDrag_WithUploadInFlight_CoalescesToLatestUpload) ++ { ++ RenderUpdateCoordinator coordinator; ++ auto decision = coordinator.notifyParameterChanged(true); ++ auto firstUpload = findStartedTask(decision, RenderTaskType::ParameterUpload); ++ ASSERT_TRUE(firstUpload.has_value()); ++ ++ decision = coordinator.notifyParameterChanged(true); ++ auto const latest = coordinator.latestStamp(); ++ ++ EXPECT_FALSE(hasStartedTask(decision, RenderTaskType::ParameterUpload)); ++ EXPECT_TRUE(hasCommand(decision, RenderCommandType::KeepCurrentFrame)); ++ ++ decision = coordinator.completeTask(completed(*firstUpload)); ++ EXPECT_TRUE(hasCommand(decision, RenderCommandType::DiscardTaskResult)); ++ ++ decision = coordinator.tick(); ++ auto latestUpload = findStartedTask(decision, RenderTaskType::ParameterUpload); ++ ASSERT_TRUE(latestUpload.has_value()); ++ EXPECT_TRUE(matches(latestUpload->stamp, latest, RenderStampMask::heavyGeometryTask())); ++ } ++ ++ TEST(RenderUpdateCoordinator, ParameterChanged_AfterRealtimeLearning_ResetsToProgressiveStaticLearning) ++ { ++ RenderUpdateCoordinator coordinator; ++ coordinator.recordStaticFullFrameSample(fullFrameSample(15.0f)); ++ coordinator.recordStaticFullFrameSample(fullFrameSample(16.0f)); ++ coordinator.recordStaticFullFrameSample(fullFrameSample(17.0f)); ++ ASSERT_TRUE(coordinator.isRealtimeActive()); ++ ++ auto decision = coordinator.notifyParameterChanged(false); ++ auto upload = findStartedTask(decision, RenderTaskType::ParameterUpload); ++ ASSERT_TRUE(upload.has_value()); ++ EXPECT_FALSE(coordinator.isRealtimeActive()); ++ ++ decision = coordinator.completeTask(completed(*upload)); ++ auto bbox = findStartedTask(decision, RenderTaskType::BoundingBoxUpdate); ++ ASSERT_TRUE(bbox.has_value()); ++ ++ decision = coordinator.completeTask(completed(*bbox)); ++ auto sdf = findStartedTask(decision, RenderTaskType::SdfPrecomputation); ++ ASSERT_TRUE(sdf.has_value()); ++ ++ decision = coordinator.completeTask(completed(*sdf)); ++ ++ EXPECT_TRUE(hasStartedTask(decision, RenderTaskType::ProgressiveHighQualityChunk)); ++ EXPECT_FALSE(hasStartedTask(decision, RenderTaskType::RealtimeFullFrame)); ++ } ++ ++ TEST(RenderUpdateCoordinator, StaticCatchUp_AfterParameterEdit_SequencesUploadBboxSdfThenHq) ++ { ++ RenderUpdateCoordinator coordinator; ++ auto decision = coordinator.notifyParameterChanged(true); ++ auto upload = findStartedTask(decision, RenderTaskType::ParameterUpload); ++ ASSERT_TRUE(upload.has_value()); ++ ++ decision = coordinator.completeTask(completed(*upload)); ++ EXPECT_FALSE(hasStartedTask(decision, RenderTaskType::BoundingBoxUpdate)); ++ ++ decision = coordinator.notifyParameterInteractionEnded(); ++ auto bbox = findStartedTask(decision, RenderTaskType::BoundingBoxUpdate); ++ ASSERT_TRUE(bbox.has_value()); ++ EXPECT_FALSE(hasStartedTask(decision, RenderTaskType::ProgressiveHighQualityChunk)); ++ ++ decision = coordinator.completeTask(completed(*bbox)); ++ auto sdf = findStartedTask(decision, RenderTaskType::SdfPrecomputation); ++ ASSERT_TRUE(sdf.has_value()); ++ EXPECT_TRUE(coordinator.isBoundingBoxCurrent()); ++ EXPECT_FALSE(coordinator.isHeavyGeometryCurrent()); ++ ++ decision = coordinator.completeTask(completed(*sdf)); ++ auto hq = findStartedTask(decision, RenderTaskType::ProgressiveHighQualityChunk); ++ ASSERT_TRUE(hq.has_value()); ++ EXPECT_TRUE(coordinator.isHeavyGeometryCurrent()); ++ EXPECT_TRUE(matches(hq->stamp, coordinator.latestStamp(), RenderStampMask::displayFrame())); ++ } ++ ++ TEST(RenderUpdateCoordinator, CameraBackpressure_KeepsCurrentFrameUntilOldPreviewCompletes) ++ { ++ RenderUpdateCoordinator coordinator; ++ auto decision = coordinator.notifyCameraChanged(); ++ auto preview = findStartedTask(decision, RenderTaskType::LowResolutionPreview); ++ ASSERT_TRUE(preview.has_value()); ++ ++ decision = coordinator.notifyCameraChanged(); ++ ++ EXPECT_FALSE(hasStartedTask(decision, RenderTaskType::LowResolutionPreview)); ++ EXPECT_TRUE(hasCommand(decision, RenderCommandType::KeepCurrentFrame)); ++ ++ decision = coordinator.completeTask(completed(*preview, true, true)); ++ EXPECT_TRUE(hasCommand(decision, RenderCommandType::DiscardTaskResult)); ++ ++ decision = coordinator.tick(); ++ auto latestPreview = findStartedTask(decision, RenderTaskType::LowResolutionPreview); ++ ASSERT_TRUE(latestPreview.has_value()); ++ EXPECT_TRUE(matches(latestPreview->stamp, coordinator.latestStamp(), RenderStampMask::displayFrame())); ++ } ++ ++ TEST(RenderUpdateCoordinator, CameraInteractionEnded_WithCurrentHeavyGeometry_StartsHqForLatestView) ++ { ++ RenderUpdateCoordinator coordinator; ++ auto decision = coordinator.tick(); ++ auto bbox = findStartedTask(decision, RenderTaskType::BoundingBoxUpdate); ++ ASSERT_TRUE(bbox.has_value()); ++ ++ decision = coordinator.completeTask(completed(*bbox)); ++ auto sdf = findStartedTask(decision, RenderTaskType::SdfPrecomputation); ++ ASSERT_TRUE(sdf.has_value()); ++ ++ decision = coordinator.completeTask(completed(*sdf)); ++ auto initialHq = findStartedTask(decision, RenderTaskType::ProgressiveHighQualityChunk); ++ ASSERT_TRUE(initialHq.has_value()); ++ ASSERT_TRUE(coordinator.isHeavyGeometryCurrent()); ++ decision = coordinator.completeTask(completed(*initialHq, true, true)); ++ EXPECT_TRUE(hasCommand(decision, RenderCommandType::PresentFrame)); ++ ++ decision = coordinator.notifyCameraChanged(); ++ auto preview = findStartedTask(decision, RenderTaskType::LowResolutionPreview); ++ ASSERT_TRUE(preview.has_value()); ++ EXPECT_FALSE(hasStartedTask(decision, RenderTaskType::BoundingBoxUpdate)); ++ EXPECT_FALSE(hasStartedTask(decision, RenderTaskType::SdfPrecomputation)); ++ ++ decision = coordinator.completeTask(completed(*preview, true, true)); ++ EXPECT_TRUE(hasCommand(decision, RenderCommandType::PresentFrame)); ++ ++ decision = coordinator.notifyCameraInteractionEnded(); ++ auto hq = findStartedTask(decision, RenderTaskType::ProgressiveHighQualityChunk); ++ ASSERT_TRUE(hq.has_value()); ++ EXPECT_TRUE(coordinator.isHeavyGeometryCurrent()); ++ EXPECT_TRUE(matches(hq->stamp, coordinator.latestStamp(), RenderStampMask::displayFrame())); ++ EXPECT_FALSE(hasStartedTask(decision, RenderTaskType::BoundingBoxUpdate)); ++ EXPECT_FALSE(hasStartedTask(decision, RenderTaskType::SdfPrecomputation)); ++ } ++ ++ TEST(RenderUpdateCoordinator, StructuralModelChanged_StaleDisplayResultIsDiscarded) ++ { ++ RenderUpdateCoordinator coordinator; ++ auto decision = coordinator.notifyCameraChanged(); ++ auto preview = findStartedTask(decision, RenderTaskType::LowResolutionPreview); ++ ASSERT_TRUE(preview.has_value()); ++ ++ decision = coordinator.notifyStructuralModelChanged(); ++ EXPECT_TRUE(hasStartedTask(decision, RenderTaskType::ProgramCompilation)); ++ ++ decision = coordinator.completeTask(completed(*preview, true, true)); ++ ++ EXPECT_TRUE(hasCommand(decision, RenderCommandType::DiscardTaskResult)); ++ EXPECT_FALSE(hasCommand(decision, RenderCommandType::PresentFrame)); ++ } ++ ++ TEST(RenderUpdateCoordinator, ProgramCompilationCompleted_AfterStructuralChange_StartsParameterUpload) ++ { ++ RenderUpdateCoordinator coordinator; ++ auto decision = coordinator.notifyStructuralModelChanged(); ++ ++ EXPECT_TRUE(hasStartedTask(decision, RenderTaskType::ProgramCompilation)); ++ ++ decision = coordinator.notifyProgramCompilationCompleted(); ++ ++ EXPECT_TRUE(hasStartedTask(decision, RenderTaskType::ParameterUpload)); ++ } ++ ++ TEST(RenderUpdateCoordinator, StaticTick_WithFreshHeavyTasks_DoesNotStartDuplicateHqWhileInFlight) ++ { ++ RenderUpdateCoordinator coordinator; ++ auto decision = coordinator.tick(); ++ auto bbox = findStartedTask(decision, RenderTaskType::BoundingBoxUpdate); ++ ASSERT_TRUE(bbox.has_value()); ++ ++ decision = coordinator.completeTask(completed(*bbox)); ++ auto sdf = findStartedTask(decision, RenderTaskType::SdfPrecomputation); ++ ASSERT_TRUE(sdf.has_value()); ++ ++ decision = coordinator.completeTask(completed(*sdf)); ++ auto hq = findStartedTask(decision, RenderTaskType::ProgressiveHighQualityChunk); ++ ASSERT_TRUE(hq.has_value()); ++ ++ decision = coordinator.tick(); ++ ++ EXPECT_FALSE(hasStartedTask(decision, RenderTaskType::ProgressiveHighQualityChunk)); ++ EXPECT_TRUE(hasCommand(decision, RenderCommandType::KeepCurrentFrame)); ++ } ++ ++ TEST(RenderUpdateCoordinator, FailedBoundingBox_DoesNotAdvanceToSdfAndCanRetry) ++ { ++ RenderUpdateCoordinator coordinator; ++ auto decision = coordinator.tick(); ++ auto bbox = findStartedTask(decision, RenderTaskType::BoundingBoxUpdate); ++ ASSERT_TRUE(bbox.has_value()); ++ ++ decision = coordinator.completeTask(failed(*bbox)); ++ ++ EXPECT_TRUE(hasCommand(decision, RenderCommandType::DiscardTaskResult)); ++ EXPECT_FALSE(hasStartedTask(decision, RenderTaskType::SdfPrecomputation)); ++ ++ decision = coordinator.tick(); ++ auto retry = findStartedTask(decision, RenderTaskType::BoundingBoxUpdate); ++ ASSERT_TRUE(retry.has_value()); ++ EXPECT_NE(retry->requestId, bbox->requestId); ++ } ++} +diff --git a/gladius/tests/unittests/ui/render/RenderUpdateTypes_tests.cpp b/gladius/tests/unittests/ui/render/RenderUpdateTypes_tests.cpp +new file mode 100644 +index 0000000..d6f74ce +--- /dev/null ++++ b/gladius/tests/unittests/ui/render/RenderUpdateTypes_tests.cpp +@@ -0,0 +1,126 @@ ++#include "ui/render/RenderUpdateTypes.h" ++ ++#include ++ ++namespace gladius::ui::async_rendering::tests ++{ ++ namespace ++ { ++ [[nodiscard]] constexpr RenderStamp makeStamp(uint64_t scene, ++ uint64_t parameters, ++ uint64_t view, ++ uint64_t viewport, ++ uint64_t quality) noexcept ++ { ++ return RenderStamp{.sceneEpoch = scene, ++ .parameterEpoch = parameters, ++ .viewEpoch = view, ++ .viewportEpoch = viewport, ++ .qualityEpoch = quality}; ++ } ++ ++ [[nodiscard]] constexpr RenderTaskResult makeDisplayResult(RenderStamp stamp) noexcept ++ { ++ return RenderTaskResult{.requestId = 42, ++ .type = RenderTaskType::RealtimeFullFrame, ++ .stamp = stamp, ++ .status = RenderTaskStatus::Completed, ++ .durationNs = 1'000'000, ++ .producedDisplayFrame = true, ++ .completedFrame = true}; ++ } ++ } ++ ++ TEST(RenderStampMatches, WithSameRequiredAxes_ReturnsTrue) ++ { ++ auto const latest = makeStamp(3, 5, 7, 11, 13); ++ auto const candidate = makeStamp(3, 5, 99, 101, 103); ++ ++ EXPECT_TRUE(matches(candidate, latest, RenderStampMask::sceneAndParameters())); ++ } ++ ++ TEST(RenderStampMatches, WithNoneMask_IgnoresAllAxes) ++ { ++ auto const latest = makeStamp(3, 5, 7, 11, 13); ++ auto const completelyDifferent = makeStamp(30, 50, 70, 110, 130); ++ ++ EXPECT_TRUE(matches(completelyDifferent, latest, RenderStampMask::none())); ++ EXPECT_FALSE(isOlderThan(completelyDifferent, latest, RenderStampMask::none())); ++ } ++ ++ TEST(RenderStampMatches, WithDifferentView_FailsDisplayFrameButPassesHeavyGeometryTask) ++ { ++ auto const latest = makeStamp(3, 5, 7, 11, 13); ++ auto const cameraOnlyStale = makeStamp(3, 5, 6, 11, 13); ++ ++ EXPECT_FALSE(matches(cameraOnlyStale, latest, RenderStampMask::displayFrame())); ++ EXPECT_TRUE(matches(cameraOnlyStale, latest, RenderStampMask::heavyGeometryTask())); ++ } ++ ++ TEST(RenderStampIsOlderThan, WithIgnoredAxis_DoesNotReportOlder) ++ { ++ auto const latest = makeStamp(3, 5, 7, 11, 13); ++ auto const oldViewOnly = makeStamp(3, 5, 1, 11, 13); ++ ++ EXPECT_FALSE(isOlderThan(oldViewOnly, latest, RenderStampMask::heavyGeometryTask())); ++ EXPECT_TRUE(isOlderThan(oldViewOnly, latest, RenderStampMask::displayFrame())); ++ } ++ ++ TEST(RenderTaskResultIsDisplayableFor, WithCurrentDisplayStamp_ReturnsTrue) ++ { ++ auto const latest = makeStamp(3, 5, 7, 11, 13); ++ auto const result = makeDisplayResult(latest); ++ ++ EXPECT_TRUE(result.isDisplayableFor(latest)); ++ } ++ ++ TEST(RenderTaskResultIsDisplayableFor, WithOldCameraStamp_ReturnsFalse) ++ { ++ auto const latest = makeStamp(3, 5, 7, 11, 13); ++ auto const oldCamera = makeStamp(3, 5, 6, 11, 13); ++ auto const result = makeDisplayResult(oldCamera); ++ ++ EXPECT_FALSE(result.isDisplayableFor(latest)); ++ } ++ ++ TEST(RenderTaskResultIsDisplayableFor, WithOldParameterStamp_ReturnsFalse) ++ { ++ auto const latest = makeStamp(3, 5, 7, 11, 13); ++ auto const oldParameter = makeStamp(3, 4, 7, 11, 13); ++ auto const result = makeDisplayResult(oldParameter); ++ ++ EXPECT_FALSE(result.isDisplayableFor(latest)); ++ } ++ ++ TEST(RenderTaskResultIsCurrentFor, WithBoundingBoxResult_IgnoresCameraAndViewport) ++ { ++ auto const latest = makeStamp(3, 5, 7, 11, 13); ++ auto const bboxStamp = makeStamp(3, 5, 1, 2, 3); ++ auto const result = RenderTaskResult{.requestId = 7, ++ .type = RenderTaskType::BoundingBoxUpdate, ++ .stamp = bboxStamp, ++ .status = RenderTaskStatus::Completed}; ++ ++ EXPECT_TRUE(result.isCurrentFor(latest, RenderStampMask::heavyGeometryTask())); ++ EXPECT_FALSE(result.isCurrentFor(latest, RenderStampMask::displayFrame())); ++ } ++ ++ TEST(RenderTaskResultIsCurrentFor, WithCancelledResult_ReturnsFalse) ++ { ++ auto const latest = makeStamp(3, 5, 7, 11, 13); ++ auto result = makeDisplayResult(latest); ++ result.status = RenderTaskStatus::Cancelled; ++ ++ EXPECT_FALSE(result.isCurrentFor(latest, RenderStampMask::displayFrame())); ++ EXPECT_FALSE(result.isDisplayableFor(latest)); ++ } ++ ++ TEST(RenderTaskResultIsDisplayableFor, WithoutDisplayFrame_ReturnsFalse) ++ { ++ auto const latest = makeStamp(3, 5, 7, 11, 13); ++ auto result = makeDisplayResult(latest); ++ result.producedDisplayFrame = false; ++ ++ EXPECT_FALSE(result.isDisplayableFor(latest)); ++ } ++} diff --git a/review.md b/review.md new file mode 100644 index 00000000..a9f12d20 --- /dev/null +++ b/review.md @@ -0,0 +1,133 @@ +# Code Review: Render Mode Behavior and Parameter Changes + +## Summary + +Reviewed the generated C++/build-system diff against `develop` (`review.diff`, 6573 lines). The branch introduces a render-update coordinator, realtime raymarch admission controller, async presentation changes, low-resolution/streaming preview plumbing, and unit tests around these behaviors. + +The intended behavior from `specs/029-render-mode-behavior/userspec.md` is clear: camera movement should always remain responsive; `Off` uses previews; `Auto` adapts but keeps parameter edits preview-oriented; `Force` should prefer exact full-frame realtime for both camera and parameter edits and must not intentionally show a lower-quality surrogate. Parameter interactions should also end cleanly and resume static catch-up. + +The most important issues are correctness gaps around parameter changes. In particular, Force-mode parameter edits are still routed through low-resolution preview, and Force-mode parameter interactions may never transition back to `Static` because semantic interaction completion is coupled to the streaming-preview lifecycle. + +## Findings + +### 1. Force-mode parameter edits are scheduled as low-resolution preview instead of exact realtime + +- **Location**: `gladius/src/ui/render/RenderUpdateCoordinator.h:425` +- **Issue**: `scheduleInteractiveFrame()` returns immediately for every `ParameterInteracting` state and starts `RenderTaskType::LowResolutionPreview` before checking the configured render mode. This directly violates the userspec for `Force`: “When I drag a slider I want to see the actual updated model immediately, not a blurry approximation.” It also explains why parameter changes do not fulfill the spec even though Force camera motion can schedule `RealtimeFullFrame`. +- **Suggestion**: + +```cpp +if (m_interactionState == RenderInteractionState::ParameterInteracting) +{ + if (m_realtime.config().mode == RealtimeRaymarchMode::Force) + { + if (m_realtime.canAttemptRealtime(m_width, m_height, m_realtimeGuards)) + { + startTask(decision, RenderTaskType::RealtimeFullFrame, m_latestStamp); + return; + } + + keepCurrentFrame(decision); + return; + } + + startTask(decision, RenderTaskType::LowResolutionPreview, m_latestStamp); + return; +} +``` + +- **Rationale**: This preserves the preview path for `Off` and `Auto` parameter edits while making `Force` behave like an exact-rendering policy. If realtime admission is temporarily blocked, it follows FR-014 by keeping the current frame instead of falling back to a surrogate preview. + +### 2. Force-mode parameter interactions may never end in the coordinator + +- **Location**: `gladius/src/ui/MainWindow.cpp:2724`, `gladius/src/ui/RenderWindow.cpp:2075`, `gladius/src/ui/RenderWindow.cpp:4183` +- **Issue**: `MainWindow` disables streaming preview in Force mode via `useStreamingPreview = !m_renderWindow.isForceRealtimeMode()`. Later, the debounce path calls `stopStreamingPreview()` to signal that the parameter drag ended. However, `stopStreamingPreview()` only calls `notifyParameterInteractionEnded()` when `m_streamingPreviewActive` was true. In Force mode streaming was never started, so `wasActive` is false and the coordinator can remain stuck in `ParameterInteracting`. While stuck, `tick()` keeps scheduling interactive work and never runs `scheduleStaticCatchUp()`, so bbox/SDF/HQ convergence can fail to follow the specified post-interaction sequence. +- **Suggestion**: + +```cpp +void RenderWindow::finishParameterInteraction() +{ + m_streamingPreviewActive.store(false, std::memory_order_release); + m_streamingFrameConsumed.store(true, std::memory_order_release); + + if (m_renderUpdateCoordinator.interactionState() == + async_rendering::RenderInteractionState::ParameterInteracting) + { + queueRenderDecision(m_renderUpdateCoordinator.notifyParameterInteractionEnded()); + } +} +``` + +Then call `finishParameterInteraction()` from the debounce block instead of using `stopStreamingPreview()` as the semantic end-of-interaction signal. Keep `stopStreamingPreview()` as a transport/lifecycle helper for the streaming worker if needed. + +- **Rationale**: “Parameter interaction ended” is a semantic state transition and should not depend on whether the chosen feedback path happened to be streaming preview. Decoupling these concepts lets `Force` parameter edits resume static catch-up even when no streaming worker was active. + +### 3. Active parameter changes keep stale Auto-mode realtime learning + +- **Location**: `gladius/src/ui/render/RenderUpdateCoordinator.h:169` +- **Issue**: `notifyParameterChanged(true)` increments `parameterEpoch` but only calls `resetRealtimeLearning()` when `interactionActive` is false. During a slider drag, the old `m_staticHQRenderTimeMs` from the previous parameter state remains available. If the user releases the slider and moves the camera before a new static HQ sample is measured, `Auto` can admit exact realtime using a timing sample from the old model/parameter state. +- **Suggestion**: + +```cpp +++m_latestStamp.parameterEpoch; +resetRealtimeLearning(); +m_parameterUploadStamp.reset(); +m_boundingBoxStamp.reset(); +m_sdfStamp.reset(); +``` + +If resetting on every drag tick is considered too aggressive, reset at the first parameter epoch change for the gesture and keep the value invalid until the next static HQ completion for the new epoch. + +- **Rationale**: The userspec says Auto should decide from the high-quality render time in static mode for the current state. Parameter changes can materially alter render cost, so old timing data should not admit exact realtime for a new parameter state. + +### 4. `isRealtimeActive()` still treats Force mode as actual realtime activity + +- **Location**: `gladius/src/ui/render/RealtimeRaymarchController.cpp:310`, used at `gladius/src/ui/RenderWindow.cpp:2381` +- **Issue**: `RealtimeRaymarchController::isRealtimeActive()` returns true whenever mode is `Force`, even if no exact realtime job is in flight and no exact realtime frame is being presented. This contradicts the spec clarification that `Force` is a scheduling policy preference, not proof that exact realtime output is active. `RenderWindow` then uses this value to compute `useLowResPreviewBeforeHq`, coupling static catch-up behavior to configured mode instead of actual render state. +- **Suggestion**: + +```cpp +bool RealtimeRaymarchController::isRealtimeActive() const noexcept +{ + return m_realtimeActive; +} +``` + +For Force-specific policy checks, add a differently named method such as `isRealtimePreferred()` or check `config().mode == RealtimeRaymarchMode::Force` at the policy site. For presentation/catch-up decisions in `RenderWindow`, prefer actual state, for example: + +```cpp +auto const exactRealtimeJobInFlight = + m_asyncRealtimeJobInFlight.load(std::memory_order_acquire); +bool const useLowResPreviewBeforeHq = !exactRealtimeJobInFlight; +``` + +- **Rationale**: Separating policy preference from actual activity avoids stale-frame and catch-up decisions being made as if exact output exists when it does not. This aligns with FR-006/FR-007 and the userspec’s Force-mode clarification. + +### 5. The unit test locks in behavior opposite to the Force-mode userspec + +- **Location**: `gladius/tests/unittests/ui/render/RenderUpdateCoordinator_tests.cpp:347` +- **Issue**: `ParameterDrag_WithForcedRealtime_UsesPreviewNotRealtime` asserts that Force-mode parameter dragging starts `LowResolutionPreview` and not `RealtimeFullFrame`. This directly conflicts with the attached userspec and makes the implementation regress toward the wrong product behavior. +- **Suggestion**: + +```cpp +TEST(RenderUpdateCoordinator, ParameterDrag_WithForcedRealtime_StartsRealtimeFullFrame) +{ + RenderUpdateCoordinator coordinator; + coordinator.configureRealtime(forcedRealtimeConfig()); + ASSERT_FALSE(coordinator.configureViewport(640, 480).commands.empty()); + + auto const decision = coordinator.notifyParameterChanged(true); + + EXPECT_TRUE(hasStartedTask(decision, RenderTaskType::ParameterUpload)); + EXPECT_TRUE(hasStartedTask(decision, RenderTaskType::RealtimeFullFrame)); + EXPECT_FALSE(hasStartedTask(decision, RenderTaskType::LowResolutionPreview)); +} +``` + +Also add a guarded variant that verifies Force parameter interaction emits `KeepCurrentFrame` when realtime admission is blocked instead of falling back to preview. + +- **Rationale**: Tests should encode the spec contract. As written, this test can cause future fixes to be rejected and is likely why the implementation currently fails parameter-change expectations in Force mode. + +## Notes + +No build-system-specific findings were found in the reviewed diff. The highest-risk area is the interaction between `MainWindow` parameter throttling, `RenderWindow` streaming-preview lifecycle, and `RenderUpdateCoordinator` semantic state. The immediate fix should start with the coordinator’s Force parameter branch and the semantic parameter-end transition, then update tests to match the userspec. diff --git a/specs/029-render-mode-behavior/state-transition-trace.md b/specs/029-render-mode-behavior/state-transition-trace.md new file mode 100644 index 00000000..7f2a259b --- /dev/null +++ b/specs/029-render-mode-behavior/state-transition-trace.md @@ -0,0 +1,96 @@ +# Render Mode State Transition Trace + +**Date**: 2026-05-15 +**Scope**: Parameter and camera interaction update flow for `Off`, `Auto`, and `Force` render modes. + +This document records the actual state transitions found in the code while investigating why the previous unit tests passed even though interactive parameter updates did not match `userspec.md`. + +## Key components + +- `MainWindow::updateModel()` detects dirty model/parameter state and decides whether to notify `RenderWindow`. +- `RenderWindow::invalidateViewDueToParameterChange()` marks render state dirty and queues `RenderUpdateCoordinator::notifyParameterChanged(true)`. +- `RenderUpdateCoordinator` owns semantic state: `Static`, `CameraInteracting`, `ParameterInteracting`. +- `RenderWindow::renderAsync()` translates coordinator commands to concrete async jobs, but it also still has a legacy low-resolution preview branch driven by `state.isMoving`, `m_forceLowResRenderOnNextFrame`, and `m_lowResFeedbackPending`. +- `RenderWindow::processAsyncPreviewResults()` presents low-resolution preview results unless they are stale or suppressed. + +## Pre-fix transition trace + +### Force mode, continuous parameter drag + +1. `MainWindow::updateModel()` sees `m_parameterDirty`. +2. Because `isForceRealtimeMode()` is true, streaming preview is not started. +3. `RenderWindow::invalidateViewDueToParameterChange()` queues `notifyParameterChanged(true)` and clears low-res flags. +4. `RenderUpdateCoordinator` can schedule `ParameterUpload` and `RealtimeFullFrame`. +5. However, `RenderWindow::renderAsync()` later reaches the legacy low-res branch if `state.isMoving`, `forceLowResRender`, or `lowResPending` is true. +6. `state.isMoving` can be true for reasons unrelated to the coordinator's realtime policy (camera debounce, resize/centering paths, or other UI motion state). +7. The legacy branch can call `scheduleAsyncPreviewJob()` / `renderLowResPreview()` even while Force policy says to hold the current frame or use exact realtime. + +**Failure**: The coordinator tests passed because they only asserted the semantic command stream. They did not cover the later `RenderWindow::renderAsync()` fallback that could still schedule low-res preview. + +### Auto/Off mode, continuous parameter drag with streaming preview + +1. First dirty parameter update enters `MainWindow::updateModel()`. +2. `RenderWindow::invalidateViewDueToParameterChange()` is called. +3. `RenderWindow::startStreamingPreview()` starts the streaming preview coroutine. +4. Subsequent parameter changes happen while `isStreamingPreviewActive()` is true. +5. The old `MainWindow` branch skipped `invalidateViewDueToParameterChange()` while streaming was active. +6. It still cleared `m_parameterDirty`, so no render-window state was refreshed for the new parameter value. +7. `m_lastParameterChangeTime` remained at the first change, allowing the debounce path to conclude that the drag ended while the user was still changing parameters. +8. Streaming could be stopped and static catch-up could start from a stale interaction boundary. + +**Failure**: Continuous Auto/Off parameter changes could be visually dropped or cut short because repeated dirty events refreshed neither the semantic interaction nor the debounce timestamp. + +### Force mode, parameter interaction end + +1. Force mode intentionally does not start streaming preview. +2. The old debounce path called `stopStreamingPreview()` as the way to signal parameter interaction end. +3. `stopStreamingPreview()` only notified the coordinator if streaming had been active. +4. In Force mode, streaming was inactive, so the coordinator could remain in `ParameterInteracting`. + +**Failure**: Static catch-up could fail to begin because semantic interaction end was coupled to the streaming transport lifecycle. + +## Fixed transition model + +### Force mode parameter update + +1. `MainWindow::updateModel()` classifies the parameter change as `invalidateInteraction=true`, `startStreamingPreview=false`. +2. `RenderWindow::invalidateViewDueToParameterChange()` queues the coordinator parameter-change event. +3. `RenderUpdateCoordinator::scheduleInteractiveFrame()` schedules `RealtimeFullFrame` for `ParameterInteracting` in `Force` mode when guards allow it. +4. If guards block exact realtime, the coordinator emits `KeepCurrentFrame` and does not schedule preview. +5. `RenderWindow::renderAsync()` uses `shouldUseLegacyLowResPreview()` to suppress the legacy low-res branch whenever Force is the active interaction policy. + +### Auto mode parameter update + +1. First parameter dirty event invalidates the interaction and starts streaming preview. +2. Later dirty events while streaming is active use `refreshStreamingParameterInteraction()` instead of being ignored. +3. The refresh keeps the interaction alive by updating `m_lastParameterChangeTime`, dirty flags, SDF invalidation, and bbox-stale state. +4. Streaming preview continues to push latest Assembly parameters and render low-res feedback. +5. When dirty events stop and the debounce interval elapses, `finishParameterInteraction()` performs the semantic transition to `Static` and catch-up begins. + +### Camera preemption + +1. Camera input calls `invalidateCameraView()`. +2. Streaming preview flags are cleared immediately. +3. The coordinator transitions to `CameraInteracting` and schedules the correct mode-dependent camera feedback. +4. Force and Auto-exact interactions suppress legacy low-res fallback; Off and Auto-preview can use preview feedback. + +## Unit-test coverage map + +| Requirement from userspec | Unit coverage | +| --- | --- | +| Camera movement schedules according to mode and does not wait for previous preview | `RenderUpdateCoordinator.CameraChanged_*`, `RenderUpdateCoordinator.CameraBackpressure_*` | +| `Off` interactions use preview-quality feedback | `RenderUpdateCoordinator.CameraChanged_WithAutoLearning_StartsLowResolutionPreview`, `RenderModeUpdatePolicy.LegacyLowResPreview_WithAutoPreviewParameterInteraction_IsAllowed` (Off policy covered by dispatch tests) | +| `Auto` uses exact only after fast static HQ and otherwise preview | `RenderUpdateCoordinator.CameraChanged_WithAutoRealtimeAfterFastSamples_StartsRealtimeFullFrame`, `RenderUpdateCoordinator.ActiveParameterChanged_AfterFastAutoSample_ResetsInteractionAdmission` | +| Auto simpler decision does not oscillate mid-gesture | `RenderUpdateCoordinator.CameraChanged_WithAutoRealtimeGuardBlocker_KeepsCurrentFrame`, per-gesture latch tests in coordinator suite | +| Force camera uses exact realtime or holds current frame | `RenderUpdateCoordinator.CameraChanged_WithForcedRealtime_StartsRealtimeFullFrame`, guard-blocker tests | +| Force parameter edits use exact realtime, not preview | `RenderUpdateCoordinator.ParameterDrag_WithForcedRealtime_StartsRealtimeFullFrame`, `RenderModeUpdatePolicy.LegacyLowResPreview_WithForceParameterInteraction_IsSuppressed` | +| Force parameter guard blockers hold current frame | `RenderUpdateCoordinator.ParameterDrag_WithForcedRealtimeGuardBlocker_KeepsCurrentFrame` | +| Continuous Force parameter updates never fall through to preview | `RenderUpdateCoordinator.ContinuousParameterDrag_WithForcedRealtimeInFlight_NeverStartsPreview`, `RenderModeUpdatePolicy.LegacyLowResPreview_WithForceParameterInteraction_IsSuppressed` | +| Continuous Auto/Off parameter updates are not dropped while streaming | `RenderModeUpdatePolicy.ParameterChangeDispatch_WithAutoStreamingActive_RefreshesInteraction`, `RenderModeUpdatePolicy.ParameterChangeDispatch_WithOffStreamingActive_RefreshesInteraction`, `RenderUpdateCoordinator.ContinuousParameterDrag_WithStaleAutoPreview_CompletesLatestPreview` | +| Parameter interaction end is semantic, not dependent on streaming transport | `RenderWindow::finishParameterInteraction()` implementation; policy documented here. A direct GL-free unit test is covered through dispatch policy and coordinator static catch-up tests. | +| Static catch-up sequence after interaction | `RenderUpdateCoordinator.StaticCatchUp_AfterParameterEdit_SequencesUploadBboxSdfThenHq` | +| No lower-quality preview replaces an exact/HQ frame | `RenderModeUpdatePolicy.LegacyLowResPreview_WithExactRealtimeInFlight_IsSuppressed` plus preview suppression in `processAsyncPreviewResults()` | + +## Remaining caveat + +`RenderWindow` is still difficult to test directly because most concrete scheduling functions require a live `ComputeCore`, OpenCL images, and a GL context. The low-level decisions that caused the observed regressions are now extracted into `RenderModeUpdatePolicy.h` so they are unit-tested without GL/OpenCL, while `RenderWindow` and `MainWindow` call that policy in production. From 868565a857d33f8a919ce500daf5474875e29c36 Mon Sep 17 00:00:00 2001 From: Jan Orend <56254096+3dJan@users.noreply.github.com> Date: Sat, 16 May 2026 09:10:37 +0200 Subject: [PATCH 15/72] [RenderWindow, AsyncRenderController, FramePresentationController] Implement asynchronous preview rendering and enhance frame presentation quality handling. Introduce new PreviewBackendPolicy and FramePresentationTypes for improved rendering decisions. Refactor related methods to support quality-based frame promotion and presentation --- gladius/src/ui/RenderWindow.cpp | 217 ++++++++++-------- gladius/src/ui/RenderWindow.h | 2 +- .../src/ui/render/AsyncRenderController.cpp | 84 ++++++- gladius/src/ui/render/AsyncRenderController.h | 33 ++- gladius/src/ui/render/AsyncRenderTypes.h | 2 + .../ui/render/FramePresentationController.h | 100 ++++++++ .../src/ui/render/FramePresentationTypes.h | 40 ++++ gladius/src/ui/render/PreviewBackendPolicy.h | 42 ++++ ...syncRenderControllerPresentation_tests.cpp | 85 ++++++- .../FramePresentationController_tests.cpp | 137 +++++++++++ .../ui/render/PreviewBackendPolicy_tests.cpp | 63 +++++ 11 files changed, 698 insertions(+), 107 deletions(-) create mode 100644 gladius/src/ui/render/FramePresentationTypes.h create mode 100644 gladius/src/ui/render/PreviewBackendPolicy.h create mode 100644 gladius/tests/unittests/ui/render/PreviewBackendPolicy_tests.cpp diff --git a/gladius/src/ui/RenderWindow.cpp b/gladius/src/ui/RenderWindow.cpp index cfd656e6..9b8a47fb 100644 --- a/gladius/src/ui/RenderWindow.cpp +++ b/gladius/src/ui/RenderWindow.cpp @@ -25,6 +25,7 @@ #include "compute/ComputeCore.h" #include "imgui.h" #include "render/RenderModeUpdatePolicy.h" +#include "render/PreviewBackendPolicy.h" #include namespace gladius::ui @@ -122,6 +123,57 @@ namespace gladius::ui constexpr float kAdaptivePreviewMinDimension = 1.0f; constexpr float kAdaptivePreviewMaxDimension = 16000.0f; constexpr float kAdaptivePreviewResizeThresholdPercent = 5.0f; + + struct AsyncPreviewLaunch + { + cl::Event event{}; + bool producedDistanceInit{false}; + }; + + [[nodiscard]] AsyncPreviewLaunch launchAsyncPreviewRender( + ComputeCore & core, + cl::CommandQueue const & commandQueue, + ImageRGBA & lowResImage) + { + auto const backend = async_rendering::choosePreviewBackend( + async_rendering::PreviewBackendInput{.rendererReady = core.isRendererReady(), + .lowResTargetAvailable = true, + .precomputedSdfValid = core.isSdfValid(), + .allowDynamicFullModelFallback = true}); + + AsyncPreviewLaunch launch{}; + switch (backend) + { + case async_rendering::PreviewBackend::PrecomputedSdfWithDistanceInit: + launch.event = + core.renderLowResPreviewWithDistanceOutputAsync(commandQueue, lowResImage); + launch.producedDistanceInit = launch.event(); + return launch; + + case async_rendering::PreviewBackend::DynamicFullModel: + { + auto settings = core.getResourceContext()->getRenderingSettings(); + settings.approximation = AM_FULL_MODEL; + settings.flags |= RF_DISABLE_SHADOWS | RF_DISABLE_AO; + bool const advanced = core.renderSceneComputeOnlyWithSettings( + commandQueue, + 0, + static_cast(lowResImage.getHeight()), + lowResImage, + settings, + &launch.event); + if (!advanced) + { + launch.event = cl::Event{}; + } + return launch; + } + + case async_rendering::PreviewBackend::None: + default: + return launch; + } + } } void RenderWindow::initialize(ComputeCore * core, @@ -489,7 +541,7 @@ namespace gladius::ui } case async_rendering::RenderTaskType::LowResolutionPreview: - if (isRealtimeRaymarchCameraInteraction()) + if (isRealtimeRaymarchInteractionActive()) { completeCoordinatorTask(task, async_rendering::RenderTaskStatus::Cancelled); return false; @@ -533,11 +585,12 @@ namespace gladius::ui return false; } - bool RenderWindow::isRealtimeRaymarchCameraInteraction() const noexcept + bool RenderWindow::isRealtimeRaymarchInteractionActive() const noexcept { - return m_renderUpdateCoordinator.interactionState() == - async_rendering::RenderInteractionState::CameraInteracting && - m_asyncRealtimeJobInFlight.load(std::memory_order_acquire); + return m_renderUpdateCoordinator.interactionState() != + async_rendering::RenderInteractionState::Static && + (m_renderUpdateCoordinator.isRealtimeSchedulingActive() || + m_asyncRealtimeJobInFlight.load(std::memory_order_acquire)); } bool RenderWindow::isForceRealtimeMode() const noexcept @@ -829,31 +882,31 @@ namespace gladius::ui &m_enableHQRendering); }); - overflow.item("Rendering Options", - [&] - { - int renderingFlags = - m_core->getResourceContext()->getRenderingSettings().flags; - - bool flagsChanged = false; - if (ImGui::BeginMenu("...")) - { - flagsChanged |= ImGui::CheckboxFlags( - "Show Build Plate", &renderingFlags, RF_SHOW_BUILDPLATE); - flagsChanged |= ImGui::CheckboxFlags( - "Cut Off Object", &renderingFlags, RF_CUT_OFF_OBJECT); - flagsChanged |= ImGui::CheckboxFlags( - "Show Field", &renderingFlags, RF_SHOW_FIELD); - flagsChanged |= ImGui::CheckboxFlags( - "Show Stack", &renderingFlags, RF_SHOW_STACK); - flagsChanged |= ImGui::CheckboxFlags( - "Show Coordinate System", - &renderingFlags, - RF_SHOW_COORDINATE_SYSTEM); - - ImGui::Separator(); - float quality = - m_core->getResourceContext()->getRenderingSettings().quality; + overflow.item("Rendering Options", + [&] + { + int renderingFlags = + m_core->getResourceContext()->getRenderingSettings().flags; + + bool flagsChanged = false; + if (ImGui::BeginMenu("...")) + { + flagsChanged |= ImGui::CheckboxFlags( + "Show Build Plate", &renderingFlags, RF_SHOW_BUILDPLATE); + flagsChanged |= ImGui::CheckboxFlags( + "Cut Off Object", &renderingFlags, RF_CUT_OFF_OBJECT); + flagsChanged |= ImGui::CheckboxFlags( + "Show Field", &renderingFlags, RF_SHOW_FIELD); + flagsChanged |= ImGui::CheckboxFlags( + "Show Stack", &renderingFlags, RF_SHOW_STACK); + flagsChanged |= ImGui::CheckboxFlags( + "Show Coordinate System", + &renderingFlags, + RF_SHOW_COORDINATE_SYSTEM); + + ImGui::Separator(); + float quality = + m_core->getResourceContext()->getRenderingSettings().quality; ImGui::SetNextItemWidth(150.f * m_uiScale); bool qualityChanged = ImGui::SliderFloat("Quality", &quality, 0.1f, 2.0f); @@ -2624,7 +2677,11 @@ namespace gladius::ui else { ZoneScopedN("PromoteReadyToFront"); - auto * newFront = m_asyncController->promoteReadyToFront(); + auto const requiredPresentationStamp = + isExactRealtimeJob ? result.coordinatorStamp + : m_renderUpdateCoordinator.latestStamp(); + auto * newFront = m_asyncController->promoteReadyToFront( + requiredPresentationStamp, async_rendering::RenderStampMask::displayFrame()); if (newFront && newFront->image) { // Bind the new frame to ensure GL texture is updated @@ -2763,6 +2820,7 @@ namespace gladius::ui std::max(1, std::min(state.renderingStepSize, height - job.startLine)); job.precomputeSdf = false; job.enableHighQuality = m_enableHQRendering; + job.coordinatorStamp = m_renderUpdateCoordinator.latestStamp(); if (coordinatorTask != nullptr) { job.coordinatorRequestId = coordinatorTask->requestId; @@ -2900,9 +2958,15 @@ namespace gladius::ui // Publish the completed buffer, then promote immediately to front for display uint64_t const viewEpoch = m_asyncViewEpoch.load(std::memory_order_acquire); - m_asyncController->publishFrame(writeBuffer, task.requestId, epoch, viewEpoch); - - auto * newFront = m_asyncController->promoteReadyToFront(); + m_asyncController->publishFrame(writeBuffer, + task.requestId, + epoch, + viewEpoch, + task.stamp, + async_rendering::FramePresentationQuality::FullQuality); + + auto * newFront = m_asyncController->promoteReadyToFront( + task.stamp, async_rendering::RenderStampMask::displayFrame()); if (newFront && newFront->image) { newFront->image->invalidateContent(); @@ -2992,6 +3056,7 @@ namespace gladius::ui job.stepSize = height; job.precomputeSdf = false; job.enableHighQuality = true; + job.coordinatorStamp = m_renderUpdateCoordinator.latestStamp(); if (coordinatorTask != nullptr) { job.coordinatorRequestId = coordinatorTask->requestId; @@ -3195,7 +3260,12 @@ namespace gladius::ui else if (result.completedFrame) { m_asyncController->publishFrame( - writeBuffer, result.frameId, result.epoch, result.viewEpoch); + writeBuffer, + result.frameId, + result.epoch, + result.viewEpoch, + result.coordinatorStamp, + async_rendering::FramePresentationQuality::FullQuality); m_asyncProgressiveBuffer = nullptr; m_asyncProgressiveEpoch.store(0, std::memory_order_release); m_asyncProgressiveViewEpoch.store(0, std::memory_order_release); @@ -4040,35 +4110,10 @@ namespace gladius::ui co_return result; } - bool producedDistanceInit = false; - - // Kick off the async render with distance output for HQ initialization (FR-005) - // when the precomputed SDF is available. Otherwise fall back to a low-res - // full-model render so parameter edits still show immediate feedback. - cl::Event renderEvent = - m_core->renderLowResPreviewWithDistanceOutputAsync(commandQueue, *lowResImage); - if (renderEvent()) - { - producedDistanceInit = true; - } - else - { - auto settings = m_core->getResourceContext()->getRenderingSettings(); - settings.approximation = AM_FULL_MODEL; - settings.flags |= RF_DISABLE_SHADOWS | RF_DISABLE_AO; - renderEvent = cl::Event{}; - bool const advanced = m_core->renderSceneComputeOnlyWithSettings( - commandQueue, - 0, - static_cast(lowResImage->getHeight()), - *lowResImage, - settings, - &renderEvent); - if (!advanced) - { - renderEvent = cl::Event{}; - } - } + auto const launch = + launchAsyncPreviewRender(*m_core, commandQueue, *lowResImage); + bool const producedDistanceInit = launch.producedDistanceInit; + cl::Event renderEvent = launch.event; if (!renderEvent()) { @@ -4162,6 +4207,7 @@ namespace gladius::ui previewMeta.latencyNs = result.computeDurationNs; previewMeta.cancelled = false; previewMeta.sdfWasValid = m_core->isSdfValid(); + previewMeta.quality = async_rendering::FramePresentationQuality::Preview; previewMeta.coordinatorRequestId = job.coordinatorRequestId; previewMeta.coordinatorStamp = job.coordinatorStamp; @@ -4213,8 +4259,13 @@ namespace gladius::ui } // FR-026: Do not replace a higher-quality frame with a lower-quality preview. - // If a HQ/realtime frame for the current view is already displayed, suppress the preview. - if (m_renderUpdateCoordinator.isHighQualityFrameCurrent()) + // Preview frames are resampled directly into m_resultImage rather than promoted + // through the HQ triple buffer, so ask the shared presentation policy whether + // this external candidate may replace what is currently shown. + if (!m_asyncController->canPresentFrame(meta.coordinatorStamp, + meta.quality, + meta.coordinatorStamp, + async_rendering::RenderStampMask::displayFrame())) { ZoneText("PreviewSkippedForQualityRegression", 35); m_streamingFrameConsumed.store(true, std::memory_order_release); @@ -4604,35 +4655,10 @@ namespace gladius::ui (void) m_core->tryToupdateParameter(*assembly); } - bool producedDistanceInit = false; - - // Render low-res preview. If the precomputed SDF is unavailable because the - // user is actively editing parameters, fall back to a low-resolution full-model - // render so the interaction still produces visible feedback. - cl::Event renderEvent = - m_core->renderLowResPreviewWithDistanceOutputAsync(commandQueue, *lowResImage); - if (renderEvent()) - { - producedDistanceInit = true; - } - else - { - auto settings = m_core->getResourceContext()->getRenderingSettings(); - settings.approximation = AM_FULL_MODEL; - settings.flags |= RF_DISABLE_SHADOWS | RF_DISABLE_AO; - renderEvent = cl::Event{}; - bool const advanced = m_core->renderSceneComputeOnlyWithSettings( - commandQueue, - 0, - static_cast(lowResImage->getHeight()), - *lowResImage, - settings, - &renderEvent); - if (!advanced) - { - renderEvent = cl::Event{}; - } - } + auto const launch = + launchAsyncPreviewRender(*m_core, commandQueue, *lowResImage); + bool const producedDistanceInit = launch.producedDistanceInit; + cl::Event renderEvent = launch.event; if (!renderEvent()) { @@ -4713,6 +4739,7 @@ namespace gladius::ui previewMeta.viewEpoch = job.viewEpoch; previewMeta.cancelled = false; previewMeta.sdfWasValid = m_core->isSdfValid(); + previewMeta.quality = async_rendering::FramePresentationQuality::Preview; previewMeta.coordinatorRequestId = job.coordinatorRequestId; previewMeta.coordinatorStamp = job.coordinatorStamp; diff --git a/gladius/src/ui/RenderWindow.h b/gladius/src/ui/RenderWindow.h index 770fa88e..21a1a818 100644 --- a/gladius/src/ui/RenderWindow.h +++ b/gladius/src/ui/RenderWindow.h @@ -199,7 +199,7 @@ namespace gladius::ui RenderWindowState & state); [[nodiscard]] bool scheduleCoordinatorTask(async_rendering::RenderTaskRequest const & task, RenderWindowState & state); - [[nodiscard]] bool isRealtimeRaymarchCameraInteraction() const noexcept; + [[nodiscard]] bool isRealtimeRaymarchInteractionActive() const noexcept; [[nodiscard]] bool scheduleAsyncSdfPrecomputation( async_rendering::RenderTaskRequest const * coordinatorTask = nullptr); void completeCoordinatorTask(async_rendering::RenderTaskRequest const & request, diff --git a/gladius/src/ui/render/AsyncRenderController.cpp b/gladius/src/ui/render/AsyncRenderController.cpp index 8793208e..7dbe1eed 100644 --- a/gladius/src/ui/render/AsyncRenderController.cpp +++ b/gladius/src/ui/render/AsyncRenderController.cpp @@ -587,7 +587,23 @@ namespace gladius::ui::async_rendering void AsyncRenderController::publishFrame(FrameBuffer * buffer, uint64_t frameId, uint64_t epoch, - uint64_t viewEpoch) noexcept + uint64_t viewEpoch, + FramePresentationQuality quality) noexcept + { + publishFrame(buffer, + frameId, + epoch, + viewEpoch, + RenderStamp{.sceneEpoch = epoch, .viewEpoch = viewEpoch}, + quality); + } + + void AsyncRenderController::publishFrame(FrameBuffer * buffer, + uint64_t frameId, + uint64_t epoch, + uint64_t viewEpoch, + RenderStamp const & stamp, + FramePresentationQuality quality) noexcept { ProfileFunction if (!buffer) { @@ -615,16 +631,66 @@ namespace gladius::ui::async_rendering if (auto const index = frameBufferIndex(buffer); index.has_value()) { [[maybe_unused]] bool const mirrored = m_framePresentation.publishFrame( - *index, frameId, RenderStamp{.sceneEpoch = epoch, .viewEpoch = viewEpoch}); + *index, frameId, stamp, quality); } } } + std::optional + AsyncRenderController::mirroredPresentationBuffer(FrameBuffer const * buffer) const noexcept + { + if (buffer == nullptr) + { + return std::nullopt; + } + + std::lock_guard lock(m_bufferMutex); + auto const index = frameBufferIndex(buffer); + if (!index.has_value()) + { + return std::nullopt; + } + + auto const * mirrored = m_framePresentation.buffer(*index); + if (mirrored == nullptr) + { + return std::nullopt; + } + + return *mirrored; + } + + std::optional + AsyncRenderController::mirroredFrontPresentationBuffer() const noexcept + { + std::lock_guard lock(m_bufferMutex); + auto const frontId = m_framePresentation.frontBufferId(); + if (!frontId.has_value()) + { + return std::nullopt; + } + + auto const * mirrored = m_framePresentation.buffer(*frontId); + if (mirrored == nullptr) + { + return std::nullopt; + } + + return *mirrored; + } + FrameBuffer * AsyncRenderController::promoteReadyToFront() noexcept + { + return promoteReadyToFront(RenderStamp{}, RenderStampMask::none()); + } + + FrameBuffer * AsyncRenderController::promoteReadyToFront(RenderStamp const & required, + RenderStampMask mask) noexcept { ProfileFunction std::lock_guard lock(m_bufferMutex); - auto const newestReadyIndex = m_framePresentation.selectNewestReady(); + auto const newestReadyIndex = + m_framePresentation.selectBestReadyForPresentation(required, mask); if (!newestReadyIndex.has_value() || *newestReadyIndex >= m_frameBuffers.size()) { return nullptr; // No Ready buffer available @@ -648,6 +714,18 @@ namespace gladius::ui::async_rendering return newestReady; } + bool AsyncRenderController::canPresentFrame(RenderStamp const & candidateStamp, + FramePresentationQuality candidateQuality, + RenderStamp const & required, + RenderStampMask mask) const noexcept + { + std::lock_guard lock(m_bufferMutex); + return m_framePresentation.canPresentCandidate( + FramePresentationCandidate{.stamp = candidateStamp, .quality = candidateQuality}, + required, + mask); + } + void AsyncRenderController::discardReadyFrame(uint64_t frameId, uint64_t epoch, uint64_t viewEpoch) noexcept diff --git a/gladius/src/ui/render/AsyncRenderController.h b/gladius/src/ui/render/AsyncRenderController.h index d7cc1d03..d6954448 100644 --- a/gladius/src/ui/render/AsyncRenderController.h +++ b/gladius/src/ui/render/AsyncRenderController.h @@ -120,13 +120,40 @@ namespace gladius::ui::async_rendering /// Publish a finished frame (Writing → Ready) void publishFrame(FrameBuffer * buffer, - uint64_t frameId, - uint64_t epoch, - uint64_t viewEpoch = 0) noexcept; + uint64_t frameId, + uint64_t epoch, + uint64_t viewEpoch = 0, + FramePresentationQuality quality = + FramePresentationQuality::Unknown) noexcept; + + /// Publish a finished frame with the coordinator stamp used for presentation policy. + void publishFrame(FrameBuffer * buffer, + uint64_t frameId, + uint64_t epoch, + uint64_t viewEpoch, + RenderStamp const & stamp, + FramePresentationQuality quality) noexcept; + + /// Snapshot mirrored presentation metadata for a specific HQ buffer. + [[nodiscard]] std::optional + mirroredPresentationBuffer(FrameBuffer const * buffer) const noexcept; + + /// Snapshot mirrored presentation metadata for the currently presented front buffer. + [[nodiscard]] std::optional mirroredFrontPresentationBuffer() const noexcept; /// Promote a Ready buffer to Front (for UI display) [[nodiscard]] FrameBuffer * promoteReadyToFront() noexcept; + /// Promote the best Ready buffer that is acceptable for the requested display stamp. + [[nodiscard]] FrameBuffer * promoteReadyToFront(RenderStamp const & required, + RenderStampMask mask) noexcept; + + /// Check whether an external/non-HQ candidate may be presented against the current front. + [[nodiscard]] bool canPresentFrame(RenderStamp const & candidateStamp, + FramePresentationQuality candidateQuality, + RenderStamp const & required, + RenderStampMask mask) const noexcept; + /// Release a completed Ready frame that should no longer be displayed. void discardReadyFrame(uint64_t frameId, uint64_t epoch, uint64_t viewEpoch) noexcept; diff --git a/gladius/src/ui/render/AsyncRenderTypes.h b/gladius/src/ui/render/AsyncRenderTypes.h index 1b255a39..21253759 100644 --- a/gladius/src/ui/render/AsyncRenderTypes.h +++ b/gladius/src/ui/render/AsyncRenderTypes.h @@ -1,5 +1,6 @@ #pragma once +#include "FramePresentationTypes.h" #include "RenderUpdateTypes.h" #include @@ -126,6 +127,7 @@ namespace gladius::ui::async_rendering uint64_t latencyNs{0}; ///< Time from job enqueue to completion (nanoseconds) bool cancelled{false}; ///< True if job was cancelled due to epoch change bool sdfWasValid{false}; ///< True if SDF was available during rendering + FramePresentationQuality quality{FramePresentationQuality::Preview}; uint64_t coordinatorRequestId{0}; RenderStamp coordinatorStamp{}; }; diff --git a/gladius/src/ui/render/FramePresentationController.h b/gladius/src/ui/render/FramePresentationController.h index 222b7d96..3082a8a7 100644 --- a/gladius/src/ui/render/FramePresentationController.h +++ b/gladius/src/ui/render/FramePresentationController.h @@ -1,6 +1,7 @@ #pragma once #include "AsyncRenderTypes.h" +#include "FramePresentationTypes.h" #include "RenderUpdateTypes.h" #include @@ -19,6 +20,7 @@ namespace gladius::ui::async_rendering FrameState state{FrameState::Idle}; uint64_t frameId{0}; RenderStamp stamp{}; + FramePresentationQuality quality{FramePresentationQuality::Unknown}; }; /** @@ -81,6 +83,7 @@ namespace gladius::ui::async_rendering candidate.state = FrameState::Writing; candidate.frameId = 0; candidate.stamp = stamp; + candidate.quality = FramePresentationQuality::Unknown; return candidate.id; } return std::nullopt; @@ -89,6 +92,15 @@ namespace gladius::ui::async_rendering [[nodiscard]] bool publishFrame(size_t bufferId, uint64_t frameId, RenderStamp const & stamp) noexcept + { + return publishFrame( + bufferId, frameId, stamp, FramePresentationQuality::Unknown); + } + + [[nodiscard]] bool publishFrame(size_t bufferId, + uint64_t frameId, + RenderStamp const & stamp, + FramePresentationQuality quality) noexcept { auto * target = mutableBuffer(bufferId); if (target == nullptr || target->state != FrameState::Writing) @@ -98,6 +110,7 @@ namespace gladius::ui::async_rendering target->frameId = frameId; target->stamp = stamp; + target->quality = quality; target->state = FrameState::Ready; return true; } @@ -116,6 +129,7 @@ namespace gladius::ui::async_rendering if (newState == FrameState::Idle) { target->frameId = 0; + target->quality = FramePresentationQuality::Unknown; } if (newState == FrameState::Front) { @@ -155,6 +169,71 @@ namespace gladius::ui::async_rendering return newest->id; } + [[nodiscard]] std::optional selectBestReadyForPresentation( + RenderStamp const & required, + RenderStampMask const mask) noexcept + { + PresentationBuffer * best = nullptr; + for (auto & candidate : m_buffers) + { + if (candidate.state != FrameState::Ready || + !matches(candidate.stamp, required, mask)) + { + continue; + } + + if (!canPresentCandidate(FramePresentationCandidate{.frameId = candidate.frameId, + .stamp = candidate.stamp, + .quality = candidate.quality}, + required, + mask)) + { + continue; + } + + if (best == nullptr) + { + best = &candidate; + continue; + } + + auto const candidateRank = framePresentationQualityRank(candidate.quality); + auto const bestRank = framePresentationQualityRank(best->quality); + if (candidateRank > bestRank || + (candidateRank == bestRank && candidate.frameId > best->frameId)) + { + best = &candidate; + } + } + + if (best == nullptr) + { + return std::nullopt; + } + + best->state = FrameState::Resampling; + return best->id; + } + + [[nodiscard]] bool canPresentCandidate(FramePresentationCandidate const & candidate, + RenderStamp const & required, + RenderStampMask const mask) const noexcept + { + if (!matches(candidate.stamp, required, mask)) + { + return false; + } + + auto const * currentFront = currentFrontMatching(required, mask); + if (currentFront == nullptr) + { + return true; + } + + return framePresentationQualityRank(candidate.quality) >= + framePresentationQualityRank(currentFront->quality); + } + [[nodiscard]] bool finalizeFrontPromotion(size_t bufferId) noexcept { auto * target = mutableBuffer(bufferId); @@ -171,6 +250,7 @@ namespace gladius::ui::async_rendering { previousFront->state = FrameState::Idle; previousFront->frameId = 0; + previousFront->quality = FramePresentationQuality::Unknown; } } @@ -189,6 +269,7 @@ namespace gladius::ui::async_rendering target->state = FrameState::Idle; target->frameId = 0; + target->quality = FramePresentationQuality::Unknown; return true; } @@ -205,6 +286,7 @@ namespace gladius::ui::async_rendering candidate.state = FrameState::Idle; candidate.frameId = 0; + candidate.quality = FramePresentationQuality::Unknown; ++releasedCount; } return releasedCount; @@ -220,6 +302,24 @@ namespace gladius::ui::async_rendering return &m_buffers[bufferId]; } + [[nodiscard]] PresentationBuffer const * currentFrontMatching( + RenderStamp const & required, + RenderStampMask const mask) const noexcept + { + if (!m_frontBufferId.has_value()) + { + return nullptr; + } + + auto const * front = buffer(*m_frontBufferId); + if (front == nullptr || front->state != FrameState::Front || + !matches(front->stamp, required, mask)) + { + return nullptr; + } + return front; + } + std::vector m_buffers; std::optional m_frontBufferId; }; diff --git a/gladius/src/ui/render/FramePresentationTypes.h b/gladius/src/ui/render/FramePresentationTypes.h new file mode 100644 index 00000000..ac1aaabf --- /dev/null +++ b/gladius/src/ui/render/FramePresentationTypes.h @@ -0,0 +1,40 @@ +#pragma once + +#include "RenderUpdateTypes.h" + +#include + +namespace gladius::ui::async_rendering +{ + enum class FramePresentationQuality + { + Unknown, + Preview, + ProgressivePartial, + FullQuality + }; + + [[nodiscard]] constexpr int framePresentationQualityRank( + FramePresentationQuality quality) noexcept + { + switch (quality) + { + case FramePresentationQuality::Preview: + return 1; + case FramePresentationQuality::ProgressivePartial: + return 2; + case FramePresentationQuality::FullQuality: + return 3; + case FramePresentationQuality::Unknown: + default: + return 0; + } + } + + struct FramePresentationCandidate + { + uint64_t frameId{0}; + RenderStamp stamp{}; + FramePresentationQuality quality{FramePresentationQuality::Unknown}; + }; +} diff --git a/gladius/src/ui/render/PreviewBackendPolicy.h b/gladius/src/ui/render/PreviewBackendPolicy.h new file mode 100644 index 00000000..59f98743 --- /dev/null +++ b/gladius/src/ui/render/PreviewBackendPolicy.h @@ -0,0 +1,42 @@ +#pragma once + +namespace gladius::ui::async_rendering +{ + enum class PreviewBackend + { + None, + PrecomputedSdfWithDistanceInit, + DynamicFullModel + }; + + struct PreviewBackendInput + { + bool rendererReady{false}; + bool lowResTargetAvailable{false}; + bool precomputedSdfValid{false}; + bool allowDynamicFullModelFallback{true}; + }; + + [[nodiscard]] constexpr PreviewBackend choosePreviewBackend( + PreviewBackendInput const & input) noexcept + { + if (!input.rendererReady || !input.lowResTargetAvailable) + { + return PreviewBackend::None; + } + + if (input.precomputedSdfValid) + { + return PreviewBackend::PrecomputedSdfWithDistanceInit; + } + + return input.allowDynamicFullModelFallback ? PreviewBackend::DynamicFullModel + : PreviewBackend::None; + } + + [[nodiscard]] constexpr bool previewBackendProducesDistanceInit( + PreviewBackend backend) noexcept + { + return backend == PreviewBackend::PrecomputedSdfWithDistanceInit; + } +} diff --git a/gladius/tests/unittests/ui/render/AsyncRenderControllerPresentation_tests.cpp b/gladius/tests/unittests/ui/render/AsyncRenderControllerPresentation_tests.cpp index dd2090af..083980e6 100644 --- a/gladius/tests/unittests/ui/render/AsyncRenderControllerPresentation_tests.cpp +++ b/gladius/tests/unittests/ui/render/AsyncRenderControllerPresentation_tests.cpp @@ -11,7 +11,8 @@ namespace gladius::ui::async_rendering::tests auto * buffer = controller.acquireWriteBuffer(10); ASSERT_NE(buffer, nullptr); - controller.publishFrame(buffer, 2, 10, 3); + controller.publishFrame( + buffer, 2, 10, 3, FramePresentationQuality::FullQuality); auto * promoted = controller.promoteReadyToFront(); ASSERT_EQ(promoted, buffer); @@ -20,6 +21,10 @@ namespace gladius::ui::async_rendering::tests EXPECT_EQ(buffer->state.load(std::memory_order_acquire), FrameState::Front); EXPECT_EQ(buffer->epoch.load(std::memory_order_acquire), 10u); EXPECT_EQ(buffer->viewEpoch.load(std::memory_order_acquire), 3u); + + auto const mirroredFront = controller.mirroredFrontPresentationBuffer(); + ASSERT_TRUE(mirroredFront.has_value()); + EXPECT_EQ(mirroredFront->quality, FramePresentationQuality::FullQuality); } TEST(AsyncRenderControllerPresentation, @@ -35,7 +40,8 @@ namespace gladius::ui::async_rendering::tests ASSERT_NE(buffer, nullptr); ASSERT_NE(buffer, initialFront); - controller.publishFrame(buffer, 2, 10, 3); + controller.publishFrame( + buffer, 2, 10, 3, FramePresentationQuality::FullQuality); auto * promoted = controller.promoteReadyToFront(); ASSERT_EQ(promoted, buffer); ASSERT_TRUE(controller.finalizeFrontPromotion(promoted)); @@ -58,8 +64,10 @@ namespace gladius::ui::async_rendering::tests ASSERT_NE(newer, nullptr); ASSERT_NE(older, newer); - controller.publishFrame(older, 5, 10, 3); - controller.publishFrame(newer, 6, 10, 3); + controller.publishFrame( + older, 5, 10, 3, FramePresentationQuality::FullQuality); + controller.publishFrame( + newer, 6, 10, 3, FramePresentationQuality::FullQuality); auto * promoted = controller.promoteReadyToFront(); @@ -74,7 +82,8 @@ namespace gladius::ui::async_rendering::tests auto * buffer = controller.acquireWriteBuffer(10); ASSERT_NE(buffer, nullptr); - controller.publishFrame(buffer, 2, 10, 3); + controller.publishFrame( + buffer, 2, 10, 3, FramePresentationQuality::FullQuality); controller.discardReadyFrame(2, 10, 3); @@ -109,4 +118,70 @@ namespace gladius::ui::async_rendering::tests auto * reacquired = controller.acquireWriteBuffer(11); EXPECT_EQ(reacquired, buffer); } + + TEST(AsyncRenderControllerPresentation, PublishFrame_MirrorsQualityMetadataWhileReady) + { + AsyncRenderController controller; + + auto * buffer = controller.acquireWriteBuffer(10); + ASSERT_NE(buffer, nullptr); + + controller.publishFrame( + buffer, 2, 10, 3, FramePresentationQuality::ProgressivePartial); + + auto const mirrored = controller.mirroredPresentationBuffer(buffer); + ASSERT_TRUE(mirrored.has_value()); + EXPECT_EQ(mirrored->state, FrameState::Ready); + EXPECT_EQ(mirrored->quality, FramePresentationQuality::ProgressivePartial); + EXPECT_EQ(mirrored->frameId, 2u); + } + + TEST(AsyncRenderControllerPresentation, PublishFrame_WithFullStamp_MirrorsStampMetadata) + { + AsyncRenderController controller; + + auto * buffer = controller.acquireWriteBuffer(10); + ASSERT_NE(buffer, nullptr); + + RenderStamp const stamp{.sceneEpoch = 1, + .parameterEpoch = 2, + .viewEpoch = 3, + .viewportEpoch = 4, + .qualityEpoch = 5}; + controller.publishFrame(buffer, 2, 10, 3, stamp, FramePresentationQuality::FullQuality); + + auto const mirrored = controller.mirroredPresentationBuffer(buffer); + ASSERT_TRUE(mirrored.has_value()); + EXPECT_TRUE(matches(mirrored->stamp, stamp, RenderStampMask::displayFrame())); + EXPECT_EQ(mirrored->quality, FramePresentationQuality::FullQuality); + } + + TEST(AsyncRenderControllerPresentation, + PromoteReadyToFront_WithCurrentFullQuality_SkipsPreviewRegression) + { + AsyncRenderController controller; + RenderStamp const stamp{.sceneEpoch = 1, + .parameterEpoch = 2, + .viewEpoch = 3, + .viewportEpoch = 4, + .qualityEpoch = 5}; + + auto * fullQuality = controller.acquireWriteBuffer(10); + ASSERT_NE(fullQuality, nullptr); + controller.publishFrame(fullQuality, 2, 10, 3, stamp, FramePresentationQuality::FullQuality); + auto * promoted = controller.promoteReadyToFront(stamp, RenderStampMask::displayFrame()); + ASSERT_EQ(promoted, fullQuality); + ASSERT_TRUE(controller.finalizeFrontPromotion(promoted)); + + auto * preview = controller.acquireWriteBuffer(10); + ASSERT_NE(preview, nullptr); + controller.publishFrame(preview, 3, 10, 3, stamp, FramePresentationQuality::Preview); + + EXPECT_EQ(controller.promoteReadyToFront(stamp, RenderStampMask::displayFrame()), nullptr); + EXPECT_EQ(preview->state.load(std::memory_order_acquire), FrameState::Ready); + EXPECT_FALSE(controller.canPresentFrame(stamp, + FramePresentationQuality::Preview, + stamp, + RenderStampMask::displayFrame())); + } } diff --git a/gladius/tests/unittests/ui/render/FramePresentationController_tests.cpp b/gladius/tests/unittests/ui/render/FramePresentationController_tests.cpp index 87ecdb87..b25220ec 100644 --- a/gladius/tests/unittests/ui/render/FramePresentationController_tests.cpp +++ b/gladius/tests/unittests/ui/render/FramePresentationController_tests.cpp @@ -61,6 +61,21 @@ namespace gladius::ui::async_rendering::tests EXPECT_EQ(controller.buffer(*bufferId)->frameId, 17u); } + TEST(FramePresentationController, PublishFrame_WithExplicitQuality_StoresMetadata) + { + FramePresentationController controller{3}; + auto const stamp = makeStamp(1, 2, 3, 4, 5); + auto const bufferId = controller.acquireWriteBuffer(stamp); + ASSERT_TRUE(bufferId.has_value()); + + EXPECT_TRUE(controller.publishFrame( + *bufferId, 17, stamp, FramePresentationQuality::ProgressivePartial)); + + ASSERT_NE(controller.buffer(*bufferId), nullptr); + EXPECT_EQ(controller.buffer(*bufferId)->quality, + FramePresentationQuality::ProgressivePartial); + } + TEST(FramePresentationController, AcquireWriteBuffer_WithNoIdleBuffer_ReturnsNullopt) { FramePresentationController controller{2}; @@ -158,6 +173,128 @@ namespace gladius::ui::async_rendering::tests EXPECT_EQ(controller.buffer(*bufferId)->state, FrameState::Ready); } + TEST(FramePresentationController, SelectBestReadyForPresentation_WithCurrentFullQuality_SkipsPreviewRegression) + { + FramePresentationController controller{3}; + auto const current = makeStamp(1, 2, 3, 4, 5); + + auto const fullQuality = controller.acquireWriteBuffer(current); + ASSERT_TRUE(fullQuality.has_value()); + ASSERT_TRUE(controller.publishFrame( + *fullQuality, 10, current, FramePresentationQuality::FullQuality)); + auto const promoted = + controller.selectNewestReady(current, RenderStampMask::displayFrame()); + ASSERT_TRUE(promoted.has_value()); + ASSERT_TRUE(controller.finalizeFrontPromotion(*promoted)); + + auto const preview = controller.acquireWriteBuffer(current); + ASSERT_TRUE(preview.has_value()); + ASSERT_TRUE(controller.publishFrame( + *preview, 11, current, FramePresentationQuality::Preview)); + + auto const selected = + controller.selectBestReadyForPresentation(current, RenderStampMask::displayFrame()); + + EXPECT_FALSE(selected.has_value()); + EXPECT_EQ(controller.buffer(*preview)->state, FrameState::Ready); + } + + TEST(FramePresentationController, SelectBestReadyForPresentation_WithStaleFront_AllowsFreshPreview) + { + FramePresentationController controller{3}; + auto const oldStamp = makeStamp(1, 2, 2, 4, 5); + auto const latest = makeStamp(1, 2, 3, 4, 5); + + auto const oldFullQuality = controller.acquireWriteBuffer(oldStamp); + ASSERT_TRUE(oldFullQuality.has_value()); + ASSERT_TRUE(controller.publishFrame( + *oldFullQuality, 10, oldStamp, FramePresentationQuality::FullQuality)); + auto const promoted = + controller.selectNewestReady(oldStamp, RenderStampMask::displayFrame()); + ASSERT_TRUE(promoted.has_value()); + ASSERT_TRUE(controller.finalizeFrontPromotion(*promoted)); + + auto const preview = controller.acquireWriteBuffer(latest); + ASSERT_TRUE(preview.has_value()); + ASSERT_TRUE(controller.publishFrame( + *preview, 11, latest, FramePresentationQuality::Preview)); + + auto const selected = + controller.selectBestReadyForPresentation(latest, RenderStampMask::displayFrame()); + + ASSERT_TRUE(selected.has_value()); + EXPECT_EQ(*selected, *preview); + EXPECT_EQ(controller.buffer(*selected)->state, FrameState::Resampling); + } + + TEST(FramePresentationController, SelectBestReadyForPresentation_PrefersHigherQualityOverNewerPreview) + { + FramePresentationController controller{4}; + auto const latest = makeStamp(1, 2, 3, 4, 5); + + auto const fullQuality = controller.acquireWriteBuffer(latest); + auto const preview = controller.acquireWriteBuffer(latest); + ASSERT_TRUE(fullQuality.has_value()); + ASSERT_TRUE(preview.has_value()); + ASSERT_TRUE(controller.publishFrame( + *fullQuality, 10, latest, FramePresentationQuality::FullQuality)); + ASSERT_TRUE(controller.publishFrame( + *preview, 11, latest, FramePresentationQuality::Preview)); + + auto const selected = controller.selectBestReadyForPresentation( + latest, RenderStampMask::displayFrame()); + + ASSERT_TRUE(selected.has_value()); + EXPECT_EQ(*selected, *fullQuality); + EXPECT_EQ(controller.buffer(*selected)->state, FrameState::Resampling); + EXPECT_EQ(controller.buffer(*preview)->state, FrameState::Ready); + } + + TEST(FramePresentationController, CanPresentCandidate_WithCurrentFullQuality_RejectsPreview) + { + FramePresentationController controller{3}; + auto const current = makeStamp(1, 2, 3, 4, 5); + + auto const fullQuality = controller.acquireWriteBuffer(current); + ASSERT_TRUE(fullQuality.has_value()); + ASSERT_TRUE(controller.publishFrame( + *fullQuality, 10, current, FramePresentationQuality::FullQuality)); + auto const promoted = controller.selectBestReadyForPresentation( + current, RenderStampMask::displayFrame()); + ASSERT_TRUE(promoted.has_value()); + ASSERT_TRUE(controller.finalizeFrontPromotion(*promoted)); + + EXPECT_FALSE(controller.canPresentCandidate( + FramePresentationCandidate{.frameId = 11, + .stamp = current, + .quality = FramePresentationQuality::Preview}, + current, + RenderStampMask::displayFrame())); + } + + TEST(FramePresentationController, CanPresentCandidate_WithStaleFront_AllowsFreshPreview) + { + FramePresentationController controller{3}; + auto const oldStamp = makeStamp(1, 2, 2, 4, 5); + auto const latest = makeStamp(1, 2, 3, 4, 5); + + auto const oldFullQuality = controller.acquireWriteBuffer(oldStamp); + ASSERT_TRUE(oldFullQuality.has_value()); + ASSERT_TRUE(controller.publishFrame( + *oldFullQuality, 10, oldStamp, FramePresentationQuality::FullQuality)); + auto const promoted = controller.selectBestReadyForPresentation( + oldStamp, RenderStampMask::displayFrame()); + ASSERT_TRUE(promoted.has_value()); + ASSERT_TRUE(controller.finalizeFrontPromotion(*promoted)); + + EXPECT_TRUE(controller.canPresentCandidate( + FramePresentationCandidate{.frameId = 11, + .stamp = latest, + .quality = FramePresentationQuality::Preview}, + latest, + RenderStampMask::displayFrame())); + } + TEST(FramePresentationController, FinalizeFrontPromotion_WithSelectedBuffer_DemotesPreviousFront) { FramePresentationController controller{3}; diff --git a/gladius/tests/unittests/ui/render/PreviewBackendPolicy_tests.cpp b/gladius/tests/unittests/ui/render/PreviewBackendPolicy_tests.cpp new file mode 100644 index 00000000..e527af49 --- /dev/null +++ b/gladius/tests/unittests/ui/render/PreviewBackendPolicy_tests.cpp @@ -0,0 +1,63 @@ +#include "ui/render/PreviewBackendPolicy.h" + +#include + +namespace gladius::ui::async_rendering::tests +{ + TEST(PreviewBackendPolicy, WithValidSdf_UsesPrecomputedSdfBackend) + { + auto const backend = choosePreviewBackend( + PreviewBackendInput{.rendererReady = true, + .lowResTargetAvailable = true, + .precomputedSdfValid = true, + .allowDynamicFullModelFallback = true}); + + EXPECT_EQ(backend, PreviewBackend::PrecomputedSdfWithDistanceInit); + EXPECT_TRUE(previewBackendProducesDistanceInit(backend)); + } + + TEST(PreviewBackendPolicy, WithInvalidSdfAndFallbackAllowed_UsesDynamicFullModel) + { + auto const backend = choosePreviewBackend( + PreviewBackendInput{.rendererReady = true, + .lowResTargetAvailable = true, + .precomputedSdfValid = false, + .allowDynamicFullModelFallback = true}); + + EXPECT_EQ(backend, PreviewBackend::DynamicFullModel); + EXPECT_FALSE(previewBackendProducesDistanceInit(backend)); + } + + TEST(PreviewBackendPolicy, WithInvalidSdfAndFallbackDisabled_ReturnsNone) + { + auto const backend = choosePreviewBackend( + PreviewBackendInput{.rendererReady = true, + .lowResTargetAvailable = true, + .precomputedSdfValid = false, + .allowDynamicFullModelFallback = false}); + + EXPECT_EQ(backend, PreviewBackend::None); + } + + TEST(PreviewBackendPolicy, WithoutRendererReady_ReturnsNone) + { + auto const backend = choosePreviewBackend( + PreviewBackendInput{.rendererReady = false, + .lowResTargetAvailable = true, + .precomputedSdfValid = true, + .allowDynamicFullModelFallback = true}); + + EXPECT_EQ(backend, PreviewBackend::None); + } + + TEST(PreviewBackendPolicy, WithoutLowResTarget_ReturnsNone) + { + auto const backend = choosePreviewBackend( + PreviewBackendInput{.rendererReady = true, + .lowResTargetAvailable = false, + .precomputedSdfValid = true, + .allowDynamicFullModelFallback = true}); + + EXPECT_EQ(backend, PreviewBackend::None); + } +} From 7eef28f07fae1c707c57f4fbaee7b55a2316daf9 Mon Sep 17 00:00:00 2001 From: Jan Orend <56254096+3dJan@users.noreply.github.com> Date: Sat, 16 May 2026 09:40:46 +0200 Subject: [PATCH 16/72] [FramePresentationTypes.h, RenderUpdateCoordinator.h, RenderWorkflowController.h, RenderWorkflowController_tests.cpp] Introduce frame presentation source enumeration, enhance rendering decision logic, and implement render workflow controller with unit tests for frame presentation handling --- .../src/ui/render/FramePresentationTypes.h | 21 ++ .../src/ui/render/RenderUpdateCoordinator.h | 42 ++- .../src/ui/render/RenderWorkflowController.h | 278 ++++++++++++++++++ .../render/RenderWorkflowController_tests.cpp | 266 +++++++++++++++++ 4 files changed, 604 insertions(+), 3 deletions(-) create mode 100644 gladius/src/ui/render/RenderWorkflowController.h create mode 100644 gladius/tests/unittests/ui/render/RenderWorkflowController_tests.cpp diff --git a/gladius/src/ui/render/FramePresentationTypes.h b/gladius/src/ui/render/FramePresentationTypes.h index ac1aaabf..a181071b 100644 --- a/gladius/src/ui/render/FramePresentationTypes.h +++ b/gladius/src/ui/render/FramePresentationTypes.h @@ -14,6 +14,16 @@ namespace gladius::ui::async_rendering FullQuality }; + enum class FramePresentationSource + { + Unknown, + ExactRealtime, + LowResolutionPreview, + StreamingPreview, + ProgressiveHighQuality, + HeldFrame + }; + [[nodiscard]] constexpr int framePresentationQualityRank( FramePresentationQuality quality) noexcept { @@ -36,5 +46,16 @@ namespace gladius::ui::async_rendering uint64_t frameId{0}; RenderStamp stamp{}; FramePresentationQuality quality{FramePresentationQuality::Unknown}; + FramePresentationSource source{FramePresentationSource::Unknown}; + bool completedFrame{false}; + }; + + struct PresentedFrame + { + uint64_t frameId{0}; + RenderStamp stamp{}; + FramePresentationQuality quality{FramePresentationQuality::Unknown}; + FramePresentationSource source{FramePresentationSource::Unknown}; + bool completedFrame{false}; }; } diff --git a/gladius/src/ui/render/RenderUpdateCoordinator.h b/gladius/src/ui/render/RenderUpdateCoordinator.h index 8a624b8e..d447e85a 100644 --- a/gladius/src/ui/render/RenderUpdateCoordinator.h +++ b/gladius/src/ui/render/RenderUpdateCoordinator.h @@ -154,7 +154,12 @@ namespace gladius::ui::async_rendering [[nodiscard]] RenderUpdateDecision notifyCameraChanged() { RenderUpdateDecision decision{}; + auto const previousState = m_interactionState; ++m_latestStamp.viewEpoch; + if (previousState != RenderInteractionState::CameraInteracting) + { + releaseStaleInteractiveInFlight(); + } m_interactionState = RenderInteractionState::CameraInteracting; scheduleInteractiveFrame(decision); return decision; @@ -175,6 +180,7 @@ namespace gladius::ui::async_rendering [[nodiscard]] RenderUpdateDecision notifyParameterChanged(bool interactionActive) { RenderUpdateDecision decision{}; + auto const previousState = m_interactionState; ++m_latestStamp.parameterEpoch; resetRealtimeLearning(); m_parameterUploadStamp.reset(); @@ -182,6 +188,10 @@ namespace gladius::ui::async_rendering m_sdfStamp.reset(); m_interactionState = interactionActive ? RenderInteractionState::ParameterInteracting : RenderInteractionState::Static; + if (interactionActive && previousState != RenderInteractionState::ParameterInteracting) + { + releaseStaleInteractiveInFlight(); + } startTask(decision, RenderTaskType::ParameterUpload, m_latestStamp); if (interactionActive) { @@ -342,9 +352,28 @@ namespace gladius::ui::async_rendering [[nodiscard]] bool hasInteractiveFrameInFlight() const noexcept { - return hasAnyInFlight(RenderTaskType::RealtimeFullFrame) || - hasAnyInFlight(RenderTaskType::LowResolutionPreview) || - hasAnyInFlight(RenderTaskType::StreamingPreview); + return hasEquivalentInFlight(RenderTaskType::RealtimeFullFrame, m_latestStamp) || + hasEquivalentInFlight(RenderTaskType::LowResolutionPreview, m_latestStamp) || + hasEquivalentInFlight(RenderTaskType::StreamingPreview, m_latestStamp); + } + + void releaseStaleInteractiveInFlight() + { + auto const oldEnd = m_inFlight.end(); + auto const newEnd = std::remove_if(m_inFlight.begin(), + m_inFlight.end(), + [this](InFlightTask const & task) + { + bool const interactiveFrameTask = + task.type == RenderTaskType::RealtimeFullFrame || + task.type == RenderTaskType::LowResolutionPreview || + task.type == RenderTaskType::StreamingPreview; + return interactiveFrameTask && + !matches(task.stamp, + m_latestStamp, + RenderStampMask::displayFrame()); + }); + m_inFlight.erase(newEnd, oldEnd); } [[nodiscard]] bool hasEquivalentInFlight(RenderTaskType type, RenderStamp const & stamp) const noexcept @@ -441,6 +470,13 @@ namespace gladius::ui::async_rendering .preferSimplerPreview = m_autoGestureLockedSimpler, .exactRealtimeAllowed = exactRealtimeAllowed}); + if (mode == RealtimeRaymarchMode::Auto && + m_interactionState != RenderInteractionState::Static && + path == InteractiveRenderPath::LowResolutionPreview) + { + m_autoGestureLockedSimpler = true; + } + switch (path) { case InteractiveRenderPath::ExactRealtime: diff --git a/gladius/src/ui/render/RenderWorkflowController.h b/gladius/src/ui/render/RenderWorkflowController.h new file mode 100644 index 00000000..fbb374f2 --- /dev/null +++ b/gladius/src/ui/render/RenderWorkflowController.h @@ -0,0 +1,278 @@ +#pragma once + +#include "FramePresentationTypes.h" +#include "RenderUpdateCoordinator.h" + +#include +#include +#include + +namespace gladius::ui::async_rendering +{ + struct RenderWorkflowDecision + { + std::vector commands; + std::vector acceptedFrames; + std::vector rejectedFrames; + bool presentedFrameChanged{false}; + }; + + /** + * @brief Pure render workflow facade used to test user-visible frame decisions. + * + * The workflow controller wraps the semantic RenderUpdateCoordinator and adds the + * missing presentation ledger: completed display tasks become frame candidates, and + * candidates are accepted only if they are fresh for the latest display stamp and do + * not downgrade an already-current higher-quality frame. + */ + class RenderWorkflowController + { + public: + void configureRealtime(RealtimeRaymarchConfig config) + { + m_coordinator.configureRealtime(config); + } + + [[nodiscard]] RealtimeRaymarchConfig const & realtimeConfig() const noexcept + { + return m_coordinator.realtimeConfig(); + } + + void setRealtimeGuards(RealtimeRaymarchGuards guards) noexcept + { + m_coordinator.setRealtimeGuards(guards); + } + + void recordStaticProgressiveSample(RealtimeRaymarchSample const & sample) + { + m_coordinator.recordStaticProgressiveSample(sample); + } + + void recordStaticFullFrameSample(RealtimeRaymarchSample const & sample) + { + m_coordinator.recordStaticFullFrameSample(sample); + } + + void recordInteractiveRealtimeSample(RealtimeRaymarchSample const & sample) + { + m_coordinator.recordInteractiveRealtimeSample(sample); + } + + void recordRealtimeRejectedAttempt() + { + m_coordinator.recordRealtimeRejectedAttempt(); + } + + [[nodiscard]] bool isRealtimeSchedulingActive() const noexcept + { + return m_coordinator.isRealtimeSchedulingActive(); + } + + [[nodiscard]] RenderStamp latestStamp() const noexcept + { + return m_coordinator.latestStamp(); + } + + [[nodiscard]] RenderInteractionState interactionState() const noexcept + { + return m_coordinator.interactionState(); + } + + [[nodiscard]] std::optional const & presentedFrame() const noexcept + { + return m_presentedFrame; + } + + void seedPresentedFrame(PresentedFrame frame) + { + m_presentedFrame = frame; + } + + void clearPresentedFrame() noexcept + { + m_presentedFrame.reset(); + } + + [[nodiscard]] RenderWorkflowDecision configureViewport(uint32_t width, uint32_t height) + { + return applyCoordinatorDecision(m_coordinator.configureViewport(width, height)); + } + + [[nodiscard]] RenderWorkflowDecision notifyCameraChanged() + { + return applyCoordinatorDecision(m_coordinator.notifyCameraChanged()); + } + + [[nodiscard]] RenderWorkflowDecision notifyCameraInteractionEnded() + { + return applyCoordinatorDecision(m_coordinator.notifyCameraInteractionEnded()); + } + + [[nodiscard]] RenderWorkflowDecision notifyParameterChanged(bool interactionActive) + { + return applyCoordinatorDecision(m_coordinator.notifyParameterChanged(interactionActive)); + } + + [[nodiscard]] RenderWorkflowDecision notifyParameterInteractionEnded() + { + return applyCoordinatorDecision(m_coordinator.notifyParameterInteractionEnded()); + } + + [[nodiscard]] RenderWorkflowDecision notifyStructuralModelChanged() + { + return applyCoordinatorDecision(m_coordinator.notifyStructuralModelChanged()); + } + + [[nodiscard]] RenderWorkflowDecision notifyProgramCompilationCompleted() + { + return applyCoordinatorDecision(m_coordinator.notifyProgramCompilationCompleted()); + } + + [[nodiscard]] RenderWorkflowDecision tick() + { + return applyCoordinatorDecision(m_coordinator.tick()); + } + + [[nodiscard]] RenderWorkflowDecision completeTask(RenderTaskResult const & result) + { + return applyCoordinatorDecision(m_coordinator.completeTask(result)); + } + + [[nodiscard]] bool canPresentCandidate( + FramePresentationCandidate const & candidate) const noexcept + { + if (!matches(candidate.stamp, latestStamp(), RenderStampMask::displayFrame())) + { + return false; + } + + if (!m_presentedFrame.has_value()) + { + return true; + } + + auto const & current = *m_presentedFrame; + if (!matches(current.stamp, latestStamp(), RenderStampMask::displayFrame())) + { + return true; + } + + return framePresentationQualityRank(candidate.quality) >= + framePresentationQualityRank(current.quality); + } + + [[nodiscard]] bool presentCandidate(FramePresentationCandidate const & candidate) noexcept + { + if (!canPresentCandidate(candidate)) + { + return false; + } + + m_presentedFrame = PresentedFrame{.frameId = candidate.frameId, + .stamp = candidate.stamp, + .quality = candidate.quality, + .source = candidate.source, + .completedFrame = candidate.completedFrame}; + return true; + } + + private: + [[nodiscard]] static constexpr bool isDisplayTask(RenderTaskType type) noexcept + { + return type == RenderTaskType::RealtimeFullFrame || + type == RenderTaskType::ProgressiveHighQualityChunk || + type == RenderTaskType::LowResolutionPreview || + type == RenderTaskType::StreamingPreview; + } + + [[nodiscard]] static constexpr FramePresentationSource sourceForTask( + RenderTaskType type) noexcept + { + switch (type) + { + case RenderTaskType::RealtimeFullFrame: + return FramePresentationSource::ExactRealtime; + case RenderTaskType::LowResolutionPreview: + return FramePresentationSource::LowResolutionPreview; + case RenderTaskType::StreamingPreview: + return FramePresentationSource::StreamingPreview; + case RenderTaskType::ProgressiveHighQualityChunk: + return FramePresentationSource::ProgressiveHighQuality; + case RenderTaskType::BoundingBoxUpdate: + case RenderTaskType::SdfPrecomputation: + case RenderTaskType::ParameterUpload: + case RenderTaskType::ProgramCompilation: + default: + return FramePresentationSource::Unknown; + } + } + + [[nodiscard]] static constexpr FramePresentationQuality qualityForResult( + RenderTaskResult const & result) noexcept + { + switch (result.type) + { + case RenderTaskType::RealtimeFullFrame: + return FramePresentationQuality::FullQuality; + case RenderTaskType::ProgressiveHighQualityChunk: + return result.completedFrame ? FramePresentationQuality::FullQuality + : FramePresentationQuality::ProgressivePartial; + case RenderTaskType::LowResolutionPreview: + case RenderTaskType::StreamingPreview: + return FramePresentationQuality::Preview; + case RenderTaskType::BoundingBoxUpdate: + case RenderTaskType::SdfPrecomputation: + case RenderTaskType::ParameterUpload: + case RenderTaskType::ProgramCompilation: + default: + return FramePresentationQuality::Unknown; + } + } + + [[nodiscard]] FramePresentationCandidate makeCandidate( + RenderTaskResult const & result) noexcept + { + auto const frameId = result.requestId == 0 ? m_nextSyntheticFrameId++ + : result.requestId; + return FramePresentationCandidate{.frameId = frameId, + .stamp = result.stamp, + .quality = qualityForResult(result), + .source = sourceForTask(result.type), + .completedFrame = result.completedFrame}; + } + + [[nodiscard]] RenderWorkflowDecision applyCoordinatorDecision( + RenderUpdateDecision coordinatorDecision) + { + RenderWorkflowDecision workflowDecision{}; + workflowDecision.commands = std::move(coordinatorDecision.commands); + + for (auto const & command : workflowDecision.commands) + { + if (command.type != RenderCommandType::PresentFrame || + !isDisplayTask(command.result.type) || + !command.result.producedDisplayFrame) + { + continue; + } + + auto candidate = makeCandidate(command.result); + if (presentCandidate(candidate)) + { + workflowDecision.acceptedFrames.push_back(candidate); + workflowDecision.presentedFrameChanged = true; + } + else + { + workflowDecision.rejectedFrames.push_back(candidate); + } + } + + return workflowDecision; + } + + RenderUpdateCoordinator m_coordinator; + std::optional m_presentedFrame; + uint64_t m_nextSyntheticFrameId{1}; + }; +} diff --git a/gladius/tests/unittests/ui/render/RenderWorkflowController_tests.cpp b/gladius/tests/unittests/ui/render/RenderWorkflowController_tests.cpp new file mode 100644 index 00000000..4f872f30 --- /dev/null +++ b/gladius/tests/unittests/ui/render/RenderWorkflowController_tests.cpp @@ -0,0 +1,266 @@ +#include "ui/render/RenderWorkflowController.h" + +#include + +#include + +namespace gladius::ui::async_rendering::tests +{ + namespace + { + [[nodiscard]] std::optional findStartedTask( + RenderWorkflowDecision const & decision, + RenderTaskType type) + { + for (auto const & command : decision.commands) + { + if (command.type == RenderCommandType::StartTask && command.task.type == type) + { + return command.task; + } + } + return std::nullopt; + } + + [[nodiscard]] bool hasStartedTask(RenderWorkflowDecision const & decision, + RenderTaskType type) + { + return findStartedTask(decision, type).has_value(); + } + + [[nodiscard]] bool hasCommand(RenderWorkflowDecision const & decision, + RenderCommandType type) + { + for (auto const & command : decision.commands) + { + if (command.type == type) + { + return true; + } + } + return false; + } + + [[nodiscard]] std::optional findFirstDisplayTask( + RenderWorkflowDecision const & decision) + { + if (auto realtime = findStartedTask(decision, RenderTaskType::RealtimeFullFrame)) + { + return realtime; + } + if (auto progressive = findStartedTask(decision, RenderTaskType::ProgressiveHighQualityChunk)) + { + return progressive; + } + if (auto preview = findStartedTask(decision, RenderTaskType::LowResolutionPreview)) + { + return preview; + } + if (auto streaming = findStartedTask(decision, RenderTaskType::StreamingPreview)) + { + return streaming; + } + return std::nullopt; + } + + [[nodiscard]] RenderTaskResult completed(RenderTaskRequest const & request, + bool producedDisplayFrame = false, + bool completedFrame = false, + uint64_t durationNs = 50'000'000) + { + return RenderTaskResult{.requestId = request.requestId, + .type = request.type, + .stamp = request.stamp, + .status = RenderTaskStatus::Completed, + .durationNs = durationNs, + .producedDisplayFrame = producedDisplayFrame, + .completedFrame = completedFrame}; + } + + [[nodiscard]] RealtimeRaymarchConfig modeConfig(RealtimeRaymarchMode mode) + { + RealtimeRaymarchConfig config{}; + config.mode = mode; + return config; + } + + [[nodiscard]] PresentedFrame heldFullQualityFrame(RenderStamp const & stamp) + { + return PresentedFrame{.frameId = 1, + .stamp = stamp, + .quality = FramePresentationQuality::FullQuality, + .source = FramePresentationSource::HeldFrame, + .completedFrame = true}; + } + } + + TEST(RenderWorkflowController, + ForceParameterDrag_WithExactAvailable_PresentsRealtimeAndNeverPreview) + { + RenderWorkflowController workflow; + workflow.configureRealtime(modeConfig(RealtimeRaymarchMode::Force)); + (void) workflow.configureViewport(640, 480); + workflow.seedPresentedFrame(heldFullQualityFrame(workflow.latestStamp())); + + auto decision = workflow.notifyParameterChanged(true); + + EXPECT_TRUE(hasStartedTask(decision, RenderTaskType::ParameterUpload)); + auto realtime = findStartedTask(decision, RenderTaskType::RealtimeFullFrame); + ASSERT_TRUE(realtime.has_value()); + EXPECT_FALSE(hasStartedTask(decision, RenderTaskType::LowResolutionPreview)); + EXPECT_FALSE(hasStartedTask(decision, RenderTaskType::StreamingPreview)); + + decision = workflow.completeTask(completed(*realtime, true, true)); + + ASSERT_EQ(decision.acceptedFrames.size(), 1u); + ASSERT_TRUE(workflow.presentedFrame().has_value()); + EXPECT_EQ(workflow.presentedFrame()->source, FramePresentationSource::ExactRealtime); + EXPECT_EQ(workflow.presentedFrame()->quality, FramePresentationQuality::FullQuality); + EXPECT_TRUE(matches(workflow.presentedFrame()->stamp, + workflow.latestStamp(), + RenderStampMask::displayFrame())); + } + + TEST(RenderWorkflowController, + ForceParameterDrag_WithRealtimeBlocked_HoldsCurrentFrameWithoutPreview) + { + RenderWorkflowController workflow; + workflow.configureRealtime(modeConfig(RealtimeRaymarchMode::Force)); + (void) workflow.configureViewport(640, 480); + auto const initialFrame = heldFullQualityFrame(workflow.latestStamp()); + workflow.seedPresentedFrame(initialFrame); + + RealtimeRaymarchGuards guards{}; + guards.hardBlocker = true; + workflow.setRealtimeGuards(guards); + + auto const decision = workflow.notifyParameterChanged(true); + + EXPECT_TRUE(hasStartedTask(decision, RenderTaskType::ParameterUpload)); + EXPECT_FALSE(hasStartedTask(decision, RenderTaskType::RealtimeFullFrame)); + EXPECT_FALSE(hasStartedTask(decision, RenderTaskType::LowResolutionPreview)); + EXPECT_FALSE(hasStartedTask(decision, RenderTaskType::StreamingPreview)); + EXPECT_TRUE(hasCommand(decision, RenderCommandType::KeepCurrentFrame)); + ASSERT_TRUE(workflow.presentedFrame().has_value()); + EXPECT_EQ(workflow.presentedFrame()->frameId, initialFrame.frameId); + EXPECT_EQ(workflow.presentedFrame()->quality, FramePresentationQuality::FullQuality); + } + + TEST(RenderWorkflowController, + CameraChanged_DuringParameterPreview_PreemptsAndRejectsStaleParameterPreview) + { + RenderWorkflowController workflow; + workflow.configureRealtime(modeConfig(RealtimeRaymarchMode::Off)); + (void) workflow.configureViewport(640, 480); + workflow.seedPresentedFrame(heldFullQualityFrame(workflow.latestStamp())); + + auto decision = workflow.notifyParameterChanged(true); + auto parameterPreview = findStartedTask(decision, RenderTaskType::LowResolutionPreview); + ASSERT_TRUE(parameterPreview.has_value()); + + decision = workflow.notifyCameraChanged(); + auto cameraPreview = findStartedTask(decision, RenderTaskType::LowResolutionPreview); + ASSERT_TRUE(cameraPreview.has_value()); + EXPECT_EQ(workflow.interactionState(), RenderInteractionState::CameraInteracting); + EXPECT_FALSE(matches(parameterPreview->stamp, + workflow.latestStamp(), + RenderStampMask::displayFrame())); + EXPECT_TRUE(matches(cameraPreview->stamp, + workflow.latestStamp(), + RenderStampMask::displayFrame())); + + decision = workflow.completeTask(completed(*parameterPreview, true, true)); + EXPECT_TRUE(hasCommand(decision, RenderCommandType::DiscardTaskResult)); + EXPECT_TRUE(decision.acceptedFrames.empty()); + ASSERT_TRUE(workflow.presentedFrame().has_value()); + EXPECT_EQ(workflow.presentedFrame()->source, FramePresentationSource::HeldFrame); + + decision = workflow.completeTask(completed(*cameraPreview, true, true)); + ASSERT_EQ(decision.acceptedFrames.size(), 1u); + ASSERT_TRUE(workflow.presentedFrame().has_value()); + EXPECT_EQ(workflow.presentedFrame()->source, FramePresentationSource::LowResolutionPreview); + EXPECT_TRUE(matches(workflow.presentedFrame()->stamp, + workflow.latestStamp(), + RenderStampMask::displayFrame())); + } + + TEST(RenderWorkflowController, + Presentation_WithCurrentFullQuality_RejectsSameStampPreviewRegression) + { + RenderWorkflowController workflow; + (void) workflow.configureViewport(640, 480); + workflow.seedPresentedFrame(heldFullQualityFrame(workflow.latestStamp())); + + auto const preview = FramePresentationCandidate{ + .frameId = 2, + .stamp = workflow.latestStamp(), + .quality = FramePresentationQuality::Preview, + .source = FramePresentationSource::LowResolutionPreview, + .completedFrame = true}; + + EXPECT_FALSE(workflow.presentCandidate(preview)); + ASSERT_TRUE(workflow.presentedFrame().has_value()); + EXPECT_EQ(workflow.presentedFrame()->quality, FramePresentationQuality::FullQuality); + EXPECT_EQ(workflow.presentedFrame()->source, FramePresentationSource::HeldFrame); + } + + TEST(RenderWorkflowController, + Presentation_WithStaleFullQuality_AllowsFreshPreview) + { + RenderWorkflowController workflow; + workflow.configureRealtime(modeConfig(RealtimeRaymarchMode::Off)); + (void) workflow.configureViewport(640, 480); + workflow.seedPresentedFrame(heldFullQualityFrame(workflow.latestStamp())); + + auto decision = workflow.notifyCameraChanged(); + auto preview = findStartedTask(decision, RenderTaskType::LowResolutionPreview); + ASSERT_TRUE(preview.has_value()); + + decision = workflow.completeTask(completed(*preview, true, true)); + + ASSERT_EQ(decision.acceptedFrames.size(), 1u); + ASSERT_TRUE(workflow.presentedFrame().has_value()); + EXPECT_EQ(workflow.presentedFrame()->quality, FramePresentationQuality::Preview); + EXPECT_EQ(workflow.presentedFrame()->source, FramePresentationSource::LowResolutionPreview); + EXPECT_TRUE(matches(workflow.presentedFrame()->stamp, + workflow.latestStamp(), + RenderStampMask::displayFrame())); + } + + TEST(RenderWorkflowController, + ParameterInteractionEnded_SequencesUploadBboxSdfThenHighQualityCatchUp) + { + RenderWorkflowController workflow; + workflow.seedPresentedFrame(heldFullQualityFrame(workflow.latestStamp())); + + auto decision = workflow.notifyParameterChanged(true); + auto upload = findStartedTask(decision, RenderTaskType::ParameterUpload); + ASSERT_TRUE(upload.has_value()); + + decision = workflow.completeTask(completed(*upload)); + EXPECT_FALSE(hasStartedTask(decision, RenderTaskType::BoundingBoxUpdate)); + + decision = workflow.notifyParameterInteractionEnded(); + auto bbox = findStartedTask(decision, RenderTaskType::BoundingBoxUpdate); + ASSERT_TRUE(bbox.has_value()); + + decision = workflow.completeTask(completed(*bbox)); + auto sdf = findStartedTask(decision, RenderTaskType::SdfPrecomputation); + ASSERT_TRUE(sdf.has_value()); + + decision = workflow.completeTask(completed(*sdf)); + auto hq = findFirstDisplayTask(decision); + ASSERT_TRUE(hq.has_value()); + EXPECT_NE(hq->type, RenderTaskType::LowResolutionPreview); + EXPECT_NE(hq->type, RenderTaskType::StreamingPreview); + + decision = workflow.completeTask(completed(*hq, true, true)); + + ASSERT_EQ(decision.acceptedFrames.size(), 1u); + ASSERT_TRUE(workflow.presentedFrame().has_value()); + EXPECT_EQ(workflow.presentedFrame()->quality, FramePresentationQuality::FullQuality); + EXPECT_TRUE(matches(workflow.presentedFrame()->stamp, + workflow.latestStamp(), + RenderStampMask::displayFrame())); + } +} From 5192633098f7281ae3ef1af46d92ea97bde36fdd Mon Sep 17 00:00:00 2001 From: Jan Orend <56254096+3dJan@users.noreply.github.com> Date: Sat, 16 May 2026 09:49:29 +0200 Subject: [PATCH 17/72] [DisplayFrameSelector] Add display frame selection logic and buffer state management with unit tests for rendering decisions --- gladius/src/ui/RenderWindow.cpp | 73 ++++++----- gladius/src/ui/render/DisplayFrameSelector.h | 76 +++++++++++ .../ui/render/DisplayFrameSelector_tests.cpp | 124 ++++++++++++++++++ 3 files changed, 237 insertions(+), 36 deletions(-) create mode 100644 gladius/src/ui/render/DisplayFrameSelector.h create mode 100644 gladius/tests/unittests/ui/render/DisplayFrameSelector_tests.cpp diff --git a/gladius/src/ui/RenderWindow.cpp b/gladius/src/ui/RenderWindow.cpp index 9b8a47fb..328a753a 100644 --- a/gladius/src/ui/RenderWindow.cpp +++ b/gladius/src/ui/RenderWindow.cpp @@ -24,6 +24,7 @@ #include "OverflowMenuBar.h" #include "compute/ComputeCore.h" #include "imgui.h" +#include "render/DisplayFrameSelector.h" #include "render/RenderModeUpdatePolicy.h" #include "render/PreviewBackendPolicy.h" #include @@ -743,50 +744,50 @@ namespace gladius::ui auto const currentEpoch = m_asyncCurrentEpoch.load(std::memory_order_acquire); auto const currentViewEpoch = m_asyncViewEpoch.load(std::memory_order_acquire); - // For HQ frames (front buffer from progressive rendering) we require an epoch match - // to avoid showing stale HQ results from an old parameter set. - // For low-res preview during rapid parameter edits, we fall back to m_resultImage - // which is updated synchronously and always shows *something* recent. - // Exact realtime interaction (camera or parameter) must not fall back to the - // preview texture just because the sync path does not mark m_asyncRealtimeJobInFlight. - bool const exactRealtimeInteraction = - m_renderUpdateCoordinator.isRealtimeSchedulingActive() && - m_renderUpdateCoordinator.interactionState() != - async_rendering::RenderInteractionState::Static; - bool const exactRealtimeJobInFlight = + // For HQ frames (front buffer from progressive rendering) we require an epoch match + // to avoid showing stale HQ results from an old parameter set. Exact realtime + // interaction may keep a current-epoch front buffer visible while the view epoch is + // catching up, and all other cases fall back to m_resultImage to avoid blanking. + bool const exactRealtimeInteraction = + m_renderUpdateCoordinator.isRealtimeSchedulingActive() && + m_renderUpdateCoordinator.interactionState() != + async_rendering::RenderInteractionState::Static; + bool const exactRealtimeJobInFlight = m_asyncRealtimeJobInFlight.load(std::memory_order_acquire); - bool const epochMatches = frontBuf && frontBuf->epoch == currentEpoch; - bool const viewMatches = frontBuf && frontBuf->viewEpoch == currentViewEpoch; - bool const allowRealtimeFront = - (exactRealtimeInteraction || exactRealtimeJobInFlight) && epochMatches; - bool const frontBlockedByRendering = m_renderWindowState.isRendering && - !allowRealtimeFront; - bool const useFrontBuffer = frontBuf && frontBuf->image && epochMatches && - (viewMatches || allowRealtimeFront) && - !frontBlockedByRendering && - !m_suppressHQDisplay.load(std::memory_order_acquire); - bool const progressiveBufferCurrent = - m_asyncProgressiveEpoch.load(std::memory_order_acquire) == currentEpoch && - m_asyncProgressiveViewEpoch.load(std::memory_order_acquire) == currentViewEpoch; - bool const useProgressiveBuffer = - m_asyncProgressiveBuffer && m_asyncProgressiveBuffer->image && - progressiveBufferCurrent && !m_renderWindowState.isMoving && - !m_suppressHQDisplay.load(std::memory_order_acquire); - - if (useFrontBuffer) + auto const resultImage = m_core->getResultImage(); + auto const frontState = async_rendering::DisplayFrameBufferState{ + .hasImage = frontBuf && frontBuf->image != nullptr, + .epoch = frontBuf ? frontBuf->epoch.load(std::memory_order_acquire) : 0u, + .viewEpoch = frontBuf ? frontBuf->viewEpoch.load(std::memory_order_acquire) : 0u}; + auto const progressiveState = async_rendering::DisplayFrameBufferState{ + .hasImage = m_asyncProgressiveBuffer && + m_asyncProgressiveBuffer->image != nullptr, + .epoch = m_asyncProgressiveEpoch.load(std::memory_order_acquire), + .viewEpoch = m_asyncProgressiveViewEpoch.load(std::memory_order_acquire)}; + auto const displaySource = async_rendering::selectDisplayFrameSource( + async_rendering::DisplayFrameSelectionInput{ + .frontBuffer = frontState, + .progressiveBuffer = progressiveState, + .currentEpoch = currentEpoch, + .currentViewEpoch = currentViewEpoch, + .exactRealtimeInteraction = exactRealtimeInteraction, + .exactRealtimeJobInFlight = exactRealtimeJobInFlight, + .isRendering = m_renderWindowState.isRendering, + .isMoving = m_renderWindowState.isMoving, + .suppressHqDisplay = m_suppressHQDisplay.load(std::memory_order_acquire), + .resultImageAvailable = resultImage != nullptr}); + + if (displaySource == async_rendering::DisplayFrameSource::FrontBuffer && frontBuf) { displayImage = frontBuf->image; } - else if (useProgressiveBuffer) + else if (displaySource == async_rendering::DisplayFrameSource::ProgressiveBuffer) { displayImage = m_asyncProgressiveBuffer->image; } - else + else if (displaySource == async_rendering::DisplayFrameSource::ResultImage) { - // Fallback to result image for progressive rendering or when no valid front buffer. - // Preview result suppression keeps realtime camera motion from writing stale - // surrogate frames here; returning nullptr would blank the viewport. - displayImage = m_core->getResultImage(); + displayImage = resultImage; } } else diff --git a/gladius/src/ui/render/DisplayFrameSelector.h b/gladius/src/ui/render/DisplayFrameSelector.h new file mode 100644 index 00000000..214aeb87 --- /dev/null +++ b/gladius/src/ui/render/DisplayFrameSelector.h @@ -0,0 +1,76 @@ +#pragma once + +#include + +namespace gladius::ui::async_rendering +{ + enum class DisplayFrameSource + { + None, + FrontBuffer, + ProgressiveBuffer, + ResultImage + }; + + struct DisplayFrameBufferState + { + bool hasImage{false}; + uint64_t epoch{0}; + uint64_t viewEpoch{0}; + }; + + struct DisplayFrameSelectionInput + { + DisplayFrameBufferState frontBuffer{}; + DisplayFrameBufferState progressiveBuffer{}; + uint64_t currentEpoch{0}; + uint64_t currentViewEpoch{0}; + bool exactRealtimeInteraction{false}; + bool exactRealtimeJobInFlight{false}; + bool isRendering{false}; + bool isMoving{false}; + bool suppressHqDisplay{false}; + bool resultImageAvailable{true}; + }; + + /** + * @brief Chooses which already-produced image should be drawn in the preview window. + * + * This is intentionally metadata-only so the UI can be tested without GL/OpenCL. + * The current behavior prefers a current HQ/front buffer, permits a current-epoch + * stale-view front buffer during exact realtime interaction, then falls back to a + * current progressive buffer, and finally to the result image to avoid blanking. + */ + [[nodiscard]] constexpr DisplayFrameSource selectDisplayFrameSource( + DisplayFrameSelectionInput const & input) noexcept + { + bool const frontEpochMatches = input.frontBuffer.hasImage && + input.frontBuffer.epoch == input.currentEpoch; + bool const frontViewMatches = input.frontBuffer.hasImage && + input.frontBuffer.viewEpoch == input.currentViewEpoch; + bool const allowRealtimeFront = + (input.exactRealtimeInteraction || input.exactRealtimeJobInFlight) && + frontEpochMatches; + bool const frontBlockedByRendering = input.isRendering && !allowRealtimeFront; + + if (input.frontBuffer.hasImage && frontEpochMatches && + (frontViewMatches || allowRealtimeFront) && !frontBlockedByRendering && + !input.suppressHqDisplay) + { + return DisplayFrameSource::FrontBuffer; + } + + bool const progressiveBufferCurrent = + input.progressiveBuffer.hasImage && + input.progressiveBuffer.epoch == input.currentEpoch && + input.progressiveBuffer.viewEpoch == input.currentViewEpoch; + + if (progressiveBufferCurrent && !input.isMoving && !input.suppressHqDisplay) + { + return DisplayFrameSource::ProgressiveBuffer; + } + + return input.resultImageAvailable ? DisplayFrameSource::ResultImage + : DisplayFrameSource::None; + } +} diff --git a/gladius/tests/unittests/ui/render/DisplayFrameSelector_tests.cpp b/gladius/tests/unittests/ui/render/DisplayFrameSelector_tests.cpp new file mode 100644 index 00000000..ec943567 --- /dev/null +++ b/gladius/tests/unittests/ui/render/DisplayFrameSelector_tests.cpp @@ -0,0 +1,124 @@ +#include "ui/render/DisplayFrameSelector.h" + +#include + +namespace gladius::ui::async_rendering::tests +{ + namespace + { + [[nodiscard]] constexpr DisplayFrameBufferState buffer(uint64_t epoch, + uint64_t viewEpoch) noexcept + { + return DisplayFrameBufferState{.hasImage = true, + .epoch = epoch, + .viewEpoch = viewEpoch}; + } + } + + TEST(DisplayFrameSelector, WithCurrentFrontBuffer_SelectsFrontBuffer) + { + auto const selected = selectDisplayFrameSource( + DisplayFrameSelectionInput{.frontBuffer = buffer(3, 7), + .currentEpoch = 3, + .currentViewEpoch = 7}); + + EXPECT_EQ(selected, DisplayFrameSource::FrontBuffer); + } + + TEST(DisplayFrameSelector, WithStaleViewFrontBufferOutsideRealtime_FallsBackToResultImage) + { + auto const selected = selectDisplayFrameSource( + DisplayFrameSelectionInput{.frontBuffer = buffer(3, 6), + .currentEpoch = 3, + .currentViewEpoch = 7}); + + EXPECT_EQ(selected, DisplayFrameSource::ResultImage); + } + + TEST(DisplayFrameSelector, WithStaleViewFrontBufferDuringRealtimeInteraction_SelectsFrontBuffer) + { + auto const selected = selectDisplayFrameSource( + DisplayFrameSelectionInput{.frontBuffer = buffer(3, 6), + .currentEpoch = 3, + .currentViewEpoch = 7, + .exactRealtimeInteraction = true}); + + EXPECT_EQ(selected, DisplayFrameSource::FrontBuffer); + } + + TEST(DisplayFrameSelector, WithStaleViewFrontBufferDuringRealtimeJob_SelectsFrontBuffer) + { + auto const selected = selectDisplayFrameSource( + DisplayFrameSelectionInput{.frontBuffer = buffer(3, 6), + .currentEpoch = 3, + .currentViewEpoch = 7, + .exactRealtimeJobInFlight = true}); + + EXPECT_EQ(selected, DisplayFrameSource::FrontBuffer); + } + + TEST(DisplayFrameSelector, WithRenderingInProgressOutsideRealtime_BlocksFrontBuffer) + { + auto const selected = selectDisplayFrameSource( + DisplayFrameSelectionInput{.frontBuffer = buffer(3, 7), + .currentEpoch = 3, + .currentViewEpoch = 7, + .isRendering = true}); + + EXPECT_EQ(selected, DisplayFrameSource::ResultImage); + } + + TEST(DisplayFrameSelector, WithRenderingInProgressDuringRealtime_AllowsFrontBuffer) + { + auto const selected = selectDisplayFrameSource( + DisplayFrameSelectionInput{.frontBuffer = buffer(3, 7), + .currentEpoch = 3, + .currentViewEpoch = 7, + .exactRealtimeInteraction = true, + .isRendering = true}); + + EXPECT_EQ(selected, DisplayFrameSource::FrontBuffer); + } + + TEST(DisplayFrameSelector, WithNoUsableFrontAndCurrentProgressive_SelectsProgressiveBuffer) + { + auto const selected = selectDisplayFrameSource( + DisplayFrameSelectionInput{.frontBuffer = buffer(2, 7), + .progressiveBuffer = buffer(3, 7), + .currentEpoch = 3, + .currentViewEpoch = 7}); + + EXPECT_EQ(selected, DisplayFrameSource::ProgressiveBuffer); + } + + TEST(DisplayFrameSelector, WithMovingState_DoesNotSelectProgressiveBuffer) + { + auto const selected = selectDisplayFrameSource( + DisplayFrameSelectionInput{.progressiveBuffer = buffer(3, 7), + .currentEpoch = 3, + .currentViewEpoch = 7, + .isMoving = true}); + + EXPECT_EQ(selected, DisplayFrameSource::ResultImage); + } + + TEST(DisplayFrameSelector, WithSuppressedHq_FallsBackToResultImage) + { + auto const selected = selectDisplayFrameSource( + DisplayFrameSelectionInput{.frontBuffer = buffer(3, 7), + .progressiveBuffer = buffer(3, 7), + .currentEpoch = 3, + .currentViewEpoch = 7, + .suppressHqDisplay = true}); + + EXPECT_EQ(selected, DisplayFrameSource::ResultImage); + } + + TEST(DisplayFrameSelector, WithoutAnyImage_ReturnsNone) + { + auto const selected = selectDisplayFrameSource( + DisplayFrameSelectionInput{.resultImageAvailable = false}); + + EXPECT_EQ(selected, DisplayFrameSource::None); + } +} From 8b1cde88975748a966f1da75aeef5159aa907492 Mon Sep 17 00:00:00 2001 From: Jan Orend <56254096+3dJan@users.noreply.github.com> Date: Sat, 16 May 2026 13:35:12 +0200 Subject: [PATCH 18/72] [RenderWindow, RenderWorkflowController, PreviewResultAcceptancePolicy] Introduce PresentedFrameLedger and PreviewResultAcceptancePolicy for improved frame presentation management and acceptance criteria. Refactor rendering logic to utilize new policies and enhance unit tests for validation --- gladius/src/ui/RenderWindow.cpp | 138 +++++++++------- gladius/src/ui/RenderWindow.h | 2 + gladius/src/ui/render/AsyncRenderTypes.h | 1 + gladius/src/ui/render/PresentedFrameLedger.h | 80 +++++++++ .../ui/render/PreviewResultAcceptancePolicy.h | 137 ++++++++++++++++ .../src/ui/render/RenderWorkflowController.h | 41 +---- .../ui/render/PresentedFrameLedger_tests.cpp | 123 ++++++++++++++ .../PreviewResultAcceptancePolicy_tests.cpp | 154 ++++++++++++++++++ 8 files changed, 585 insertions(+), 91 deletions(-) create mode 100644 gladius/src/ui/render/PresentedFrameLedger.h create mode 100644 gladius/src/ui/render/PreviewResultAcceptancePolicy.h create mode 100644 gladius/tests/unittests/ui/render/PresentedFrameLedger_tests.cpp create mode 100644 gladius/tests/unittests/ui/render/PreviewResultAcceptancePolicy_tests.cpp diff --git a/gladius/src/ui/RenderWindow.cpp b/gladius/src/ui/RenderWindow.cpp index 328a753a..4a009a3c 100644 --- a/gladius/src/ui/RenderWindow.cpp +++ b/gladius/src/ui/RenderWindow.cpp @@ -27,6 +27,7 @@ #include "render/DisplayFrameSelector.h" #include "render/RenderModeUpdatePolicy.h" #include "render/PreviewBackendPolicy.h" +#include "render/PreviewResultAcceptancePolicy.h" #include namespace gladius::ui @@ -2678,11 +2679,11 @@ namespace gladius::ui else { ZoneScopedN("PromoteReadyToFront"); - auto const requiredPresentationStamp = - isExactRealtimeJob ? result.coordinatorStamp - : m_renderUpdateCoordinator.latestStamp(); - auto * newFront = m_asyncController->promoteReadyToFront( - requiredPresentationStamp, async_rendering::RenderStampMask::displayFrame()); + auto const requiredPresentationStamp = isExactRealtimeJob + ? result.coordinatorStamp + : m_renderUpdateCoordinator.latestStamp(); + auto * newFront = m_asyncController->promoteReadyToFront( + requiredPresentationStamp, async_rendering::RenderStampMask::displayFrame()); if (newFront && newFront->image) { // Bind the new frame to ensure GL texture is updated @@ -2690,8 +2691,21 @@ namespace gladius::ui newFront->image->bind(); newFront->image->unbind(); - [[maybe_unused]] bool const promoted = - m_asyncController->finalizeFrontPromotion(newFront); + bool const promoted = m_asyncController->finalizeFrontPromotion(newFront); + if (promoted) + { + auto const source = isExactRealtimeJob + ? async_rendering::FramePresentationSource::ExactRealtime + : async_rendering::FramePresentationSource::ProgressiveHighQuality; + [[maybe_unused]] bool const presented = m_presentedFrames.presentCandidate( + async_rendering::FramePresentationCandidate{ + .frameId = result.frameId, + .stamp = result.coordinatorStamp, + .quality = async_rendering::FramePresentationQuality::FullQuality, + .source = source, + .completedFrame = true}, + requiredPresentationStamp); + } } } } @@ -2973,7 +2987,18 @@ namespace gladius::ui newFront->image->invalidateContent(); newFront->image->bind(); newFront->image->unbind(); - [[maybe_unused]] bool const promoted = m_asyncController->finalizeFrontPromotion(newFront); + bool const promoted = m_asyncController->finalizeFrontPromotion(newFront); + if (promoted) + { + [[maybe_unused]] bool const presented = m_presentedFrames.presentCandidate( + async_rendering::FramePresentationCandidate{ + .frameId = task.requestId, + .stamp = task.stamp, + .quality = async_rendering::FramePresentationQuality::FullQuality, + .source = async_rendering::FramePresentationSource::ExactRealtime, + .completedFrame = true}, + task.stamp); + } } // Feed the timing sample to the realtime learning controller so Auto mode can track @@ -4209,6 +4234,7 @@ namespace gladius::ui previewMeta.cancelled = false; previewMeta.sdfWasValid = m_core->isSdfValid(); previewMeta.quality = async_rendering::FramePresentationQuality::Preview; + previewMeta.source = async_rendering::FramePresentationSource::LowResolutionPreview; previewMeta.coordinatorRequestId = job.coordinatorRequestId; previewMeta.coordinatorStamp = job.coordinatorStamp; @@ -4240,63 +4266,51 @@ namespace gladius::ui auto const & meta = result.value(); - auto const currentEpoch = m_asyncCurrentEpoch.load(std::memory_order_acquire); - auto const currentViewEpoch = m_asyncViewEpoch.load(std::memory_order_acquire); - bool const staleForCurrentView = - meta.epoch < currentEpoch || (meta.viewEpoch != 0 && meta.viewEpoch < currentViewEpoch); - bool const exactRealtimeJobInFlight = - m_asyncRealtimeJobInFlight.load(std::memory_order_acquire); - bool const suppressForRealtimeRaymarch = - exactRealtimeJobInFlight || staleForCurrentView; - - if (suppressForRealtimeRaymarch) + auto presentedFrame = m_presentedFrames.presentedFrame(); + if (!presentedFrame.has_value()) { - ZoneText("PreviewSkippedForRealtimeRaymarch", 34); - m_streamingFrameConsumed.store(true, std::memory_order_release); - auto cancelledMeta = meta; - cancelledMeta.cancelled = true; - completeCoordinatorPreviewTask(cancelledMeta); - return; + if (auto mirroredFront = m_asyncController->mirroredFrontPresentationBuffer(); + mirroredFront.has_value()) + { + presentedFrame = async_rendering::PresentedFrame{ + .frameId = mirroredFront->frameId, + .stamp = mirroredFront->stamp, + .quality = mirroredFront->quality, + .source = async_rendering::FramePresentationSource::ProgressiveHighQuality, + .completedFrame = mirroredFront->quality == + async_rendering::FramePresentationQuality::FullQuality}; + m_presentedFrames.seedPresentedFrame(*presentedFrame); + } } - // FR-026: Do not replace a higher-quality frame with a lower-quality preview. - // Preview frames are resampled directly into m_resultImage rather than promoted - // through the HQ triple buffer, so ask the shared presentation policy whether - // this external candidate may replace what is currently shown. - if (!m_asyncController->canPresentFrame(meta.coordinatorStamp, - meta.quality, - meta.coordinatorStamp, - async_rendering::RenderStampMask::displayFrame())) - { - ZoneText("PreviewSkippedForQualityRegression", 35); - m_streamingFrameConsumed.store(true, std::memory_order_release); - auto cancelledMeta = meta; - cancelledMeta.cancelled = true; - completeCoordinatorPreviewTask(cancelledMeta); - return; - } + auto const requiredPresentationStamp = m_renderUpdateCoordinator.latestStamp(); - // Skip frames that are older than what we've already displayed. - // This ensures we never show an older frame after a newer one. - // Use frameId (which is monotonically increasing) for ordering. - auto const lastDisplayedFrame = m_asyncPreviewFrameId.load(std::memory_order_acquire); - if (meta.frameId <= lastDisplayedFrame) - { - ZoneText("OutOfOrderPreviewSkipped", 24); - m_streamingFrameConsumed.store(true, std::memory_order_release); - completeCoordinatorPreviewTask(meta); - return; - } + auto const acceptance = async_rendering::evaluatePreviewResultAcceptance( + meta, + async_rendering::PreviewResultAcceptanceContext{ + .requiredStamp = requiredPresentationStamp, + .presentedFrame = presentedFrame, + .currentEpoch = m_asyncCurrentEpoch.load(std::memory_order_acquire), + .currentViewEpoch = m_asyncViewEpoch.load(std::memory_order_acquire), + .lastPresentedPreviewFrameId = + m_asyncPreviewFrameId.load(std::memory_order_acquire), + .realtimeRaymarchInteractionActive = isRealtimeRaymarchInteractionActive() || + m_asyncRealtimeJobInFlight.load( + std::memory_order_acquire)}); - // Skip cancelled results - if (meta.cancelled) + if (!acceptance.accepted()) { - if (auto logger = m_core->getSharedLogger()) { - logger->logInfo("[Preview] Cancelled preview skipped."); + ZoneText("PreviewRejectedByPolicy", 23); + if (auto logger = m_core->getSharedLogger()) + { + logger->logInfo(std::string("[Preview] Rejected: ") + + async_rendering::previewResultRejectionReasonName( + acceptance.rejectionReason)); } - ZoneText("CancelledPreviewSkipped", 23); m_streamingFrameConsumed.store(true, std::memory_order_release); - completeCoordinatorPreviewTask(meta); + auto rejectedMeta = meta; + rejectedMeta.cancelled = acceptance.shouldCompleteAsCancelled(); + completeCoordinatorPreviewTask(rejectedMeta); return; } @@ -4329,6 +4343,15 @@ namespace gladius::ui resultImage->bind(); resultImage->unbind(); + [[maybe_unused]] bool const presented = m_presentedFrames.presentCandidate( + async_rendering::FramePresentationCandidate{ + .frameId = meta.frameId, + .stamp = meta.coordinatorStamp, + .quality = meta.quality, + .source = meta.source, + .completedFrame = true}, + requiredPresentationStamp); + // Update the last displayed frame ID for ordering m_asyncPreviewFrameId.store(meta.frameId, std::memory_order_release); @@ -4741,6 +4764,7 @@ namespace gladius::ui previewMeta.cancelled = false; previewMeta.sdfWasValid = m_core->isSdfValid(); previewMeta.quality = async_rendering::FramePresentationQuality::Preview; + previewMeta.source = async_rendering::FramePresentationSource::StreamingPreview; previewMeta.coordinatorRequestId = job.coordinatorRequestId; previewMeta.coordinatorStamp = job.coordinatorStamp; diff --git a/gladius/src/ui/RenderWindow.h b/gladius/src/ui/RenderWindow.h index 21a1a818..60c200df 100644 --- a/gladius/src/ui/RenderWindow.h +++ b/gladius/src/ui/RenderWindow.h @@ -5,6 +5,7 @@ #include "OrbitalCamera.h" #include "compute/ComputeCore.h" #include "render/AsyncRenderController.h" +#include "render/PresentedFrameLedger.h" #include "render/RealtimeRaymarchController.h" #include "render/RenderUpdateCoordinator.h" #include @@ -366,6 +367,7 @@ namespace gladius::ui async_rendering::AsyncRenderFeatureConfig m_asyncConfig{}; async_rendering::RenderUpdateCoordinator m_renderUpdateCoordinator{}; + async_rendering::PresentedFrameLedger m_presentedFrames{}; std::vector m_pendingRenderCommands; std::shared_ptr m_asyncController; std::atomic m_asyncEpochCounter{0}; diff --git a/gladius/src/ui/render/AsyncRenderTypes.h b/gladius/src/ui/render/AsyncRenderTypes.h index 21253759..2d801be5 100644 --- a/gladius/src/ui/render/AsyncRenderTypes.h +++ b/gladius/src/ui/render/AsyncRenderTypes.h @@ -128,6 +128,7 @@ namespace gladius::ui::async_rendering bool cancelled{false}; ///< True if job was cancelled due to epoch change bool sdfWasValid{false}; ///< True if SDF was available during rendering FramePresentationQuality quality{FramePresentationQuality::Preview}; + FramePresentationSource source{FramePresentationSource::LowResolutionPreview}; uint64_t coordinatorRequestId{0}; RenderStamp coordinatorStamp{}; }; diff --git a/gladius/src/ui/render/PresentedFrameLedger.h b/gladius/src/ui/render/PresentedFrameLedger.h new file mode 100644 index 00000000..fc017b2c --- /dev/null +++ b/gladius/src/ui/render/PresentedFrameLedger.h @@ -0,0 +1,80 @@ +#pragma once + +#include "FramePresentationTypes.h" + +#include + +namespace gladius::ui::async_rendering +{ + /** + * @brief Metadata-only ledger for the frame currently considered visible. + * + * The ledger is intentionally independent from OpenGL/OpenCL resources. It records the + * presentation stamp and quality of the visible frame so preview, realtime, and progressive + * paths can share the same freshness and quality-regression checks. + */ + class PresentedFrameLedger + { + public: + [[nodiscard]] std::optional const & presentedFrame() const noexcept + { + return m_presentedFrame; + } + + void seedPresentedFrame(PresentedFrame frame) + { + m_presentedFrame = frame; + } + + void clearPresentedFrame() noexcept + { + m_presentedFrame.reset(); + } + + [[nodiscard]] bool canPresentCandidate( + FramePresentationCandidate const & candidate, + RenderStamp const & requiredStamp, + RenderStampMask const mask = RenderStampMask::displayFrame()) const noexcept + { + if (!matches(candidate.stamp, requiredStamp, mask)) + { + return false; + } + + if (!m_presentedFrame.has_value()) + { + return true; + } + + auto const & current = *m_presentedFrame; + if (!matches(current.stamp, requiredStamp, mask)) + { + return true; + } + + return framePresentationQualityRank(candidate.quality) >= + framePresentationQualityRank(current.quality); + } + + [[nodiscard]] bool presentCandidate( + FramePresentationCandidate const & candidate, + RenderStamp const & requiredStamp, + RenderStampMask const mask = RenderStampMask::displayFrame()) noexcept + { + if (!canPresentCandidate(candidate, requiredStamp, mask)) + { + return false; + } + + m_presentedFrame = PresentedFrame{.frameId = candidate.frameId, + .stamp = candidate.stamp, + .quality = candidate.quality, + .source = candidate.source, + .completedFrame = candidate.completedFrame}; + return true; + } + + private: + std::optional m_presentedFrame; + }; +} diff --git a/gladius/src/ui/render/PreviewResultAcceptancePolicy.h b/gladius/src/ui/render/PreviewResultAcceptancePolicy.h new file mode 100644 index 00000000..2b4ba48a --- /dev/null +++ b/gladius/src/ui/render/PreviewResultAcceptancePolicy.h @@ -0,0 +1,137 @@ +#pragma once + +#include "AsyncRenderTypes.h" +#include "FramePresentationTypes.h" + +#include + +namespace gladius::ui::async_rendering +{ + enum class PreviewResultRejectionReason + { + None, + Cancelled, + RealtimeRaymarchActive, + StaleFrame, + OutOfOrderFrame, + QualityRegression + }; + + struct PreviewResultAcceptanceContext + { + RenderStamp requiredStamp{}; + std::optional presentedFrame{}; + uint64_t currentEpoch{0}; + uint64_t currentViewEpoch{0}; + uint64_t lastPresentedPreviewFrameId{0}; + bool realtimeRaymarchInteractionActive{false}; + RenderStampMask freshnessMask{RenderStampMask::displayFrame()}; + }; + + struct PreviewResultAcceptanceDecision + { + PreviewResultRejectionReason rejectionReason{PreviewResultRejectionReason::None}; + + [[nodiscard]] constexpr bool accepted() const noexcept + { + return rejectionReason == PreviewResultRejectionReason::None; + } + + [[nodiscard]] constexpr bool shouldCompleteAsCancelled() const noexcept + { + return !accepted(); + } + }; + + [[nodiscard]] constexpr char const * previewResultRejectionReasonName( + PreviewResultRejectionReason reason) noexcept + { + switch (reason) + { + case PreviewResultRejectionReason::None: + return "none"; + case PreviewResultRejectionReason::Cancelled: + return "cancelled"; + case PreviewResultRejectionReason::RealtimeRaymarchActive: + return "realtime raymarch active"; + case PreviewResultRejectionReason::StaleFrame: + return "stale frame"; + case PreviewResultRejectionReason::OutOfOrderFrame: + return "out-of-order frame"; + case PreviewResultRejectionReason::QualityRegression: + return "quality regression"; + default: + return "unknown"; + } + } + + [[nodiscard]] constexpr bool isPreviewResultStale( + PreviewResultMeta const & meta, + PreviewResultAcceptanceContext const & context) noexcept + { + bool const staleEpoch = meta.epoch < context.currentEpoch; + bool const staleViewEpoch = meta.viewEpoch != 0u && meta.viewEpoch < context.currentViewEpoch; + bool const staleStamp = !matches(meta.coordinatorStamp, + context.requiredStamp, + context.freshnessMask); + return staleEpoch || staleViewEpoch || staleStamp; + } + + [[nodiscard]] constexpr bool wouldRegressPresentedQuality( + PreviewResultMeta const & meta, + PreviewResultAcceptanceContext const & context) noexcept + { + if (!context.presentedFrame.has_value()) + { + return false; + } + + auto const & current = *context.presentedFrame; + if (!matches(current.stamp, context.requiredStamp, context.freshnessMask)) + { + return false; + } + + return framePresentationQualityRank(meta.quality) < + framePresentationQualityRank(current.quality); + } + + /** + * @brief Decides whether a completed low-resolution/streaming preview may be published. + * + * The decision is intentionally metadata-only so production can reject stale previews before + * resampling into the shared result image, and tests can cover user-visible presentation rules + * without OpenGL/OpenCL resources. + */ + [[nodiscard]] constexpr PreviewResultAcceptanceDecision evaluatePreviewResultAcceptance( + PreviewResultMeta const & meta, + PreviewResultAcceptanceContext const & context) noexcept + { + if (meta.cancelled) + { + return {PreviewResultRejectionReason::Cancelled}; + } + + if (context.realtimeRaymarchInteractionActive) + { + return {PreviewResultRejectionReason::RealtimeRaymarchActive}; + } + + if (isPreviewResultStale(meta, context)) + { + return {PreviewResultRejectionReason::StaleFrame}; + } + + if (meta.frameId <= context.lastPresentedPreviewFrameId) + { + return {PreviewResultRejectionReason::OutOfOrderFrame}; + } + + if (wouldRegressPresentedQuality(meta, context)) + { + return {PreviewResultRejectionReason::QualityRegression}; + } + + return {}; + } +} diff --git a/gladius/src/ui/render/RenderWorkflowController.h b/gladius/src/ui/render/RenderWorkflowController.h index fbb374f2..7f3320a5 100644 --- a/gladius/src/ui/render/RenderWorkflowController.h +++ b/gladius/src/ui/render/RenderWorkflowController.h @@ -1,6 +1,6 @@ #pragma once -#include "FramePresentationTypes.h" +#include "PresentedFrameLedger.h" #include "RenderUpdateCoordinator.h" #include @@ -80,17 +80,17 @@ namespace gladius::ui::async_rendering [[nodiscard]] std::optional const & presentedFrame() const noexcept { - return m_presentedFrame; + return m_presentedFrames.presentedFrame(); } void seedPresentedFrame(PresentedFrame frame) { - m_presentedFrame = frame; + m_presentedFrames.seedPresentedFrame(frame); } void clearPresentedFrame() noexcept { - m_presentedFrame.reset(); + m_presentedFrames.clearPresentedFrame(); } [[nodiscard]] RenderWorkflowDecision configureViewport(uint32_t width, uint32_t height) @@ -141,39 +141,12 @@ namespace gladius::ui::async_rendering [[nodiscard]] bool canPresentCandidate( FramePresentationCandidate const & candidate) const noexcept { - if (!matches(candidate.stamp, latestStamp(), RenderStampMask::displayFrame())) - { - return false; - } - - if (!m_presentedFrame.has_value()) - { - return true; - } - - auto const & current = *m_presentedFrame; - if (!matches(current.stamp, latestStamp(), RenderStampMask::displayFrame())) - { - return true; - } - - return framePresentationQualityRank(candidate.quality) >= - framePresentationQualityRank(current.quality); + return m_presentedFrames.canPresentCandidate(candidate, latestStamp()); } [[nodiscard]] bool presentCandidate(FramePresentationCandidate const & candidate) noexcept { - if (!canPresentCandidate(candidate)) - { - return false; - } - - m_presentedFrame = PresentedFrame{.frameId = candidate.frameId, - .stamp = candidate.stamp, - .quality = candidate.quality, - .source = candidate.source, - .completedFrame = candidate.completedFrame}; - return true; + return m_presentedFrames.presentCandidate(candidate, latestStamp()); } private: @@ -272,7 +245,7 @@ namespace gladius::ui::async_rendering } RenderUpdateCoordinator m_coordinator; - std::optional m_presentedFrame; + PresentedFrameLedger m_presentedFrames; uint64_t m_nextSyntheticFrameId{1}; }; } diff --git a/gladius/tests/unittests/ui/render/PresentedFrameLedger_tests.cpp b/gladius/tests/unittests/ui/render/PresentedFrameLedger_tests.cpp new file mode 100644 index 00000000..6639e80d --- /dev/null +++ b/gladius/tests/unittests/ui/render/PresentedFrameLedger_tests.cpp @@ -0,0 +1,123 @@ +#include "ui/render/PresentedFrameLedger.h" + +#include + +namespace gladius::ui::async_rendering::tests +{ + namespace + { + [[nodiscard]] constexpr RenderStamp makeStamp(uint64_t scene, + uint64_t parameters, + uint64_t view, + uint64_t viewport, + uint64_t quality) noexcept + { + return RenderStamp{.sceneEpoch = scene, + .parameterEpoch = parameters, + .viewEpoch = view, + .viewportEpoch = viewport, + .qualityEpoch = quality}; + } + + [[nodiscard]] constexpr PresentedFrame presentedFrame( + RenderStamp stamp, + FramePresentationQuality quality, + FramePresentationSource source = FramePresentationSource::HeldFrame) noexcept + { + return PresentedFrame{.frameId = 1, + .stamp = stamp, + .quality = quality, + .source = source, + .completedFrame = true}; + } + + [[nodiscard]] constexpr FramePresentationCandidate candidate( + RenderStamp stamp, + FramePresentationQuality quality, + FramePresentationSource source = FramePresentationSource::LowResolutionPreview, + uint64_t frameId = 2) noexcept + { + return FramePresentationCandidate{.frameId = frameId, + .stamp = stamp, + .quality = quality, + .source = source, + .completedFrame = true}; + } + } + + TEST(PresentedFrameLedger, CurrentPreview_WithNoPresentedFrame_IsAccepted) + { + PresentedFrameLedger ledger; + auto const stamp = makeStamp(1, 2, 3, 4, 5); + + EXPECT_TRUE(ledger.presentCandidate( + candidate(stamp, FramePresentationQuality::Preview), stamp)); + + ASSERT_TRUE(ledger.presentedFrame().has_value()); + EXPECT_EQ(ledger.presentedFrame()->quality, FramePresentationQuality::Preview); + EXPECT_EQ(ledger.presentedFrame()->source, + FramePresentationSource::LowResolutionPreview); + } + + TEST(PresentedFrameLedger, StaleCandidate_IsRejected) + { + PresentedFrameLedger ledger; + auto const oldStamp = makeStamp(1, 2, 3, 4, 5); + auto const latestStamp = makeStamp(1, 2, 4, 4, 5); + + EXPECT_FALSE(ledger.presentCandidate( + candidate(oldStamp, FramePresentationQuality::Preview), latestStamp)); + EXPECT_FALSE(ledger.presentedFrame().has_value()); + } + + TEST(PresentedFrameLedger, CurrentFullQuality_RejectsPreviewRegression) + { + PresentedFrameLedger ledger; + auto const stamp = makeStamp(1, 2, 3, 4, 5); + ledger.seedPresentedFrame( + presentedFrame(stamp, FramePresentationQuality::FullQuality)); + + EXPECT_FALSE(ledger.presentCandidate( + candidate(stamp, FramePresentationQuality::Preview), stamp)); + + ASSERT_TRUE(ledger.presentedFrame().has_value()); + EXPECT_EQ(ledger.presentedFrame()->quality, FramePresentationQuality::FullQuality); + } + + TEST(PresentedFrameLedger, StaleFullQuality_AllowsFreshPreview) + { + PresentedFrameLedger ledger; + auto const oldStamp = makeStamp(1, 2, 3, 4, 5); + auto const latestStamp = makeStamp(1, 2, 4, 4, 5); + ledger.seedPresentedFrame( + presentedFrame(oldStamp, FramePresentationQuality::FullQuality)); + + EXPECT_TRUE(ledger.presentCandidate( + candidate(latestStamp, FramePresentationQuality::Preview), latestStamp)); + + ASSERT_TRUE(ledger.presentedFrame().has_value()); + EXPECT_EQ(ledger.presentedFrame()->quality, FramePresentationQuality::Preview); + EXPECT_TRUE(matches(ledger.presentedFrame()->stamp, + latestStamp, + RenderStampMask::displayFrame())); + } + + TEST(PresentedFrameLedger, CurrentPreview_AllowsFreshFullQualityUpgrade) + { + PresentedFrameLedger ledger; + auto const stamp = makeStamp(1, 2, 3, 4, 5); + ledger.seedPresentedFrame( + presentedFrame(stamp, FramePresentationQuality::Preview)); + + EXPECT_TRUE(ledger.presentCandidate( + candidate(stamp, + FramePresentationQuality::FullQuality, + FramePresentationSource::ProgressiveHighQuality), + stamp)); + + ASSERT_TRUE(ledger.presentedFrame().has_value()); + EXPECT_EQ(ledger.presentedFrame()->quality, FramePresentationQuality::FullQuality); + EXPECT_EQ(ledger.presentedFrame()->source, + FramePresentationSource::ProgressiveHighQuality); + } +} diff --git a/gladius/tests/unittests/ui/render/PreviewResultAcceptancePolicy_tests.cpp b/gladius/tests/unittests/ui/render/PreviewResultAcceptancePolicy_tests.cpp new file mode 100644 index 00000000..6c6a0c30 --- /dev/null +++ b/gladius/tests/unittests/ui/render/PreviewResultAcceptancePolicy_tests.cpp @@ -0,0 +1,154 @@ +#include "ui/render/PreviewResultAcceptancePolicy.h" + +#include + +namespace gladius::ui::async_rendering::tests +{ + namespace + { + [[nodiscard]] constexpr RenderStamp makeStamp(uint64_t scene, + uint64_t parameters, + uint64_t view, + uint64_t viewport, + uint64_t quality) noexcept + { + return RenderStamp{.sceneEpoch = scene, + .parameterEpoch = parameters, + .viewEpoch = view, + .viewportEpoch = viewport, + .qualityEpoch = quality}; + } + + [[nodiscard]] constexpr PreviewResultMeta previewResult(RenderStamp stamp) noexcept + { + return PreviewResultMeta{.frameId = 2, + .epoch = 3, + .viewEpoch = 4, + .quality = FramePresentationQuality::Preview, + .coordinatorStamp = stamp}; + } + + [[nodiscard]] constexpr PresentedFrame fullQualityFrame(RenderStamp stamp) noexcept + { + return PresentedFrame{.frameId = 1, + .stamp = stamp, + .quality = FramePresentationQuality::FullQuality, + .source = FramePresentationSource::ProgressiveHighQuality, + .completedFrame = true}; + } + } + + TEST(PreviewResultAcceptancePolicy, CurrentPreview_WithNoPresentedFrame_IsAccepted) + { + auto const stamp = makeStamp(1, 2, 3, 4, 5); + auto const meta = previewResult(stamp); + + auto const decision = evaluatePreviewResultAcceptance( + meta, + PreviewResultAcceptanceContext{.requiredStamp = stamp, + .currentEpoch = meta.epoch, + .currentViewEpoch = meta.viewEpoch}); + + EXPECT_TRUE(decision.accepted()); + EXPECT_EQ(decision.rejectionReason, PreviewResultRejectionReason::None); + } + + TEST(PreviewResultAcceptancePolicy, StaleViewPreview_IsRejectedBeforePublish) + { + auto const oldStamp = makeStamp(1, 2, 3, 4, 5); + auto const latestStamp = makeStamp(1, 2, 4, 4, 5); + auto meta = previewResult(oldStamp); + meta.viewEpoch = 3; + + auto const decision = evaluatePreviewResultAcceptance( + meta, + PreviewResultAcceptanceContext{.requiredStamp = latestStamp, + .currentEpoch = meta.epoch, + .currentViewEpoch = 4}); + + EXPECT_FALSE(decision.accepted()); + EXPECT_TRUE(decision.shouldCompleteAsCancelled()); + EXPECT_EQ(decision.rejectionReason, PreviewResultRejectionReason::StaleFrame); + } + + TEST(PreviewResultAcceptancePolicy, ActiveRealtimeInteraction_RejectsPreview) + { + auto const stamp = makeStamp(1, 2, 3, 4, 5); + auto const meta = previewResult(stamp); + + auto const decision = evaluatePreviewResultAcceptance( + meta, + PreviewResultAcceptanceContext{.requiredStamp = stamp, + .currentEpoch = meta.epoch, + .currentViewEpoch = meta.viewEpoch, + .realtimeRaymarchInteractionActive = true}); + + EXPECT_FALSE(decision.accepted()); + EXPECT_EQ(decision.rejectionReason, + PreviewResultRejectionReason::RealtimeRaymarchActive); + } + + TEST(PreviewResultAcceptancePolicy, CurrentFullQualityFrame_RejectsPreviewRegression) + { + auto const stamp = makeStamp(1, 2, 3, 4, 5); + auto const meta = previewResult(stamp); + + auto const decision = evaluatePreviewResultAcceptance( + meta, + PreviewResultAcceptanceContext{.requiredStamp = stamp, + .presentedFrame = fullQualityFrame(stamp), + .currentEpoch = meta.epoch, + .currentViewEpoch = meta.viewEpoch}); + + EXPECT_FALSE(decision.accepted()); + EXPECT_EQ(decision.rejectionReason, PreviewResultRejectionReason::QualityRegression); + } + + TEST(PreviewResultAcceptancePolicy, StaleFullQualityFrame_AllowsFreshPreview) + { + auto const oldStamp = makeStamp(1, 2, 2, 4, 5); + auto const latestStamp = makeStamp(1, 2, 3, 4, 5); + auto const meta = previewResult(latestStamp); + + auto const decision = evaluatePreviewResultAcceptance( + meta, + PreviewResultAcceptanceContext{.requiredStamp = latestStamp, + .presentedFrame = fullQualityFrame(oldStamp), + .currentEpoch = meta.epoch, + .currentViewEpoch = meta.viewEpoch}); + + EXPECT_TRUE(decision.accepted()); + } + + TEST(PreviewResultAcceptancePolicy, CancelledPreview_IsRejected) + { + auto const stamp = makeStamp(1, 2, 3, 4, 5); + auto meta = previewResult(stamp); + meta.cancelled = true; + + auto const decision = evaluatePreviewResultAcceptance( + meta, + PreviewResultAcceptanceContext{.requiredStamp = stamp, + .currentEpoch = meta.epoch, + .currentViewEpoch = meta.viewEpoch}); + + EXPECT_FALSE(decision.accepted()); + EXPECT_EQ(decision.rejectionReason, PreviewResultRejectionReason::Cancelled); + } + + TEST(PreviewResultAcceptancePolicy, OlderPreviewFrameId_IsRejected) + { + auto const stamp = makeStamp(1, 2, 3, 4, 5); + auto const meta = previewResult(stamp); + + auto const decision = evaluatePreviewResultAcceptance( + meta, + PreviewResultAcceptanceContext{.requiredStamp = stamp, + .currentEpoch = meta.epoch, + .currentViewEpoch = meta.viewEpoch, + .lastPresentedPreviewFrameId = meta.frameId}); + + EXPECT_FALSE(decision.accepted()); + EXPECT_EQ(decision.rejectionReason, PreviewResultRejectionReason::OutOfOrderFrame); + } +} From f6d2446dacc39e73bbd7fee2e0336bc4c6937458 Mon Sep 17 00:00:00 2001 From: Jan Orend <56254096+3dJan@users.noreply.github.com> Date: Sat, 16 May 2026 17:59:25 +0200 Subject: [PATCH 19/72] [RenderWindow, DisplayFrameSelector, PreviewResultAcceptancePolicy] Enhance frame presentation logic and acceptance criteria. Introduce presented frame handling, improve stale view management during camera interaction, and add unit tests for validation --- gladius/src/ui/RenderWindow.cpp | 39 +++++++++----- gladius/src/ui/render/DisplayFrameSelector.h | 20 ++++++++ .../ui/render/PreviewResultAcceptancePolicy.h | 51 +++++++++++++++++-- .../ui/render/DisplayFrameSelector_tests.cpp | 18 +++++++ .../PreviewResultAcceptancePolicy_tests.cpp | 36 +++++++++++++ 5 files changed, 148 insertions(+), 16 deletions(-) diff --git a/gladius/src/ui/RenderWindow.cpp b/gladius/src/ui/RenderWindow.cpp index 4a009a3c..d947bf9b 100644 --- a/gladius/src/ui/RenderWindow.cpp +++ b/gladius/src/ui/RenderWindow.cpp @@ -776,7 +776,8 @@ namespace gladius::ui .isRendering = m_renderWindowState.isRendering, .isMoving = m_renderWindowState.isMoving, .suppressHqDisplay = m_suppressHQDisplay.load(std::memory_order_acquire), - .resultImageAvailable = resultImage != nullptr}); + .resultImageAvailable = resultImage != nullptr, + .presentedFrame = m_presentedFrames.presentedFrame()}); if (displaySource == async_rendering::DisplayFrameSource::FrontBuffer && frontBuf) { @@ -4285,7 +4286,12 @@ namespace gladius::ui auto const requiredPresentationStamp = m_renderUpdateCoordinator.latestStamp(); - auto const acceptance = async_rendering::evaluatePreviewResultAcceptance( + bool const cameraLowResInteraction = + m_renderUpdateCoordinator.interactionState() == + async_rendering::RenderInteractionState::CameraInteracting && + !isRealtimeRaymarchInteractionActive(); + + auto const acceptance = async_rendering::evaluatePreviewResultAcceptance( meta, async_rendering::PreviewResultAcceptanceContext{ .requiredStamp = requiredPresentationStamp, @@ -4296,7 +4302,8 @@ namespace gladius::ui m_asyncPreviewFrameId.load(std::memory_order_acquire), .realtimeRaymarchInteractionActive = isRealtimeRaymarchInteractionActive() || m_asyncRealtimeJobInFlight.load( - std::memory_order_acquire)}); + std::memory_order_acquire), + .allowStaleViewDuringCameraInteraction = cameraLowResInteraction}); if (!acceptance.accepted()) { @@ -4343,14 +4350,16 @@ namespace gladius::ui resultImage->bind(); resultImage->unbind(); - [[maybe_unused]] bool const presented = m_presentedFrames.presentCandidate( - async_rendering::FramePresentationCandidate{ - .frameId = meta.frameId, - .stamp = meta.coordinatorStamp, - .quality = meta.quality, - .source = meta.source, - .completedFrame = true}, - requiredPresentationStamp); + auto const previewPresentationStamp = acceptance.presentAsRequiredStamp + ? requiredPresentationStamp + : meta.coordinatorStamp; + [[maybe_unused]] bool const presented = m_presentedFrames.presentCandidate( + async_rendering::FramePresentationCandidate{.frameId = meta.frameId, + .stamp = previewPresentationStamp, + .quality = meta.quality, + .source = meta.source, + .completedFrame = true}, + requiredPresentationStamp); // Update the last displayed frame ID for ordering m_asyncPreviewFrameId.store(meta.frameId, std::memory_order_release); @@ -4422,7 +4431,13 @@ namespace gladius::ui m_lowResFeedbackPending.store(false, std::memory_order_release); m_lastLowResRenderTime = std::chrono::system_clock::now(); m_lastLowResPreviewEpoch.store(meta.epoch, std::memory_order_release); - completeCoordinatorPreviewTask(meta); + auto presentedMeta = meta; + if (acceptance.presentAsRequiredStamp) + { + presentedMeta.coordinatorStamp = requiredPresentationStamp; + presentedMeta.viewEpoch = m_asyncViewEpoch.load(std::memory_order_acquire); + } + completeCoordinatorPreviewTask(presentedMeta); if (auto logger = m_core->getSharedLogger()) { logger->logInfo("[Preview] Task Complete triggered!"); } diff --git a/gladius/src/ui/render/DisplayFrameSelector.h b/gladius/src/ui/render/DisplayFrameSelector.h index 214aeb87..06609e5b 100644 --- a/gladius/src/ui/render/DisplayFrameSelector.h +++ b/gladius/src/ui/render/DisplayFrameSelector.h @@ -1,6 +1,9 @@ #pragma once +#include "FramePresentationTypes.h" + #include +#include namespace gladius::ui::async_rendering { @@ -31,8 +34,17 @@ namespace gladius::ui::async_rendering bool isMoving{false}; bool suppressHqDisplay{false}; bool resultImageAvailable{true}; + std::optional presentedFrame{}; }; + [[nodiscard]] constexpr bool isFrontBufferPresentationSource( + FramePresentationSource source) noexcept + { + return source == FramePresentationSource::ExactRealtime || + source == FramePresentationSource::ProgressiveHighQuality || + source == FramePresentationSource::HeldFrame; + } + /** * @brief Chooses which already-produced image should be drawn in the preview window. * @@ -52,6 +64,9 @@ namespace gladius::ui::async_rendering (input.exactRealtimeInteraction || input.exactRealtimeJobInFlight) && frontEpochMatches; bool const frontBlockedByRendering = input.isRendering && !allowRealtimeFront; + bool const frontBufferIsPresentedFrame = + input.frontBuffer.hasImage && input.presentedFrame.has_value() && + isFrontBufferPresentationSource(input.presentedFrame->source); if (input.frontBuffer.hasImage && frontEpochMatches && (frontViewMatches || allowRealtimeFront) && !frontBlockedByRendering && @@ -70,6 +85,11 @@ namespace gladius::ui::async_rendering return DisplayFrameSource::ProgressiveBuffer; } + if (frontBufferIsPresentedFrame && !input.suppressHqDisplay) + { + return DisplayFrameSource::FrontBuffer; + } + return input.resultImageAvailable ? DisplayFrameSource::ResultImage : DisplayFrameSource::None; } diff --git a/gladius/src/ui/render/PreviewResultAcceptancePolicy.h b/gladius/src/ui/render/PreviewResultAcceptancePolicy.h index 2b4ba48a..ace2a058 100644 --- a/gladius/src/ui/render/PreviewResultAcceptancePolicy.h +++ b/gladius/src/ui/render/PreviewResultAcceptancePolicy.h @@ -25,12 +25,14 @@ namespace gladius::ui::async_rendering uint64_t currentViewEpoch{0}; uint64_t lastPresentedPreviewFrameId{0}; bool realtimeRaymarchInteractionActive{false}; + bool allowStaleViewDuringCameraInteraction{false}; RenderStampMask freshnessMask{RenderStampMask::displayFrame()}; }; struct PreviewResultAcceptanceDecision { PreviewResultRejectionReason rejectionReason{PreviewResultRejectionReason::None}; + bool presentAsRequiredStamp{false}; [[nodiscard]] constexpr bool accepted() const noexcept { @@ -65,16 +67,52 @@ namespace gladius::ui::async_rendering } } + [[nodiscard]] constexpr RenderStampMask displayFrameIgnoringViewMask() noexcept + { + return {.scene = true, + .parameters = true, + .view = false, + .viewport = true, + .quality = true}; + } + + [[nodiscard]] constexpr bool isPreviewResultStaleViewOnly( + PreviewResultMeta const & meta, + PreviewResultAcceptanceContext const & context) noexcept + { + bool const nonViewAxesMatch = matches(meta.coordinatorStamp, + context.requiredStamp, + displayFrameIgnoringViewMask()); + bool const viewEpochStale = + meta.viewEpoch != 0u && meta.viewEpoch < context.currentViewEpoch; + bool const stampViewStale = + meta.coordinatorStamp.viewEpoch < context.requiredStamp.viewEpoch; + bool const epochCurrent = meta.epoch >= context.currentEpoch; + return epochCurrent && nonViewAxesMatch && (viewEpochStale || stampViewStale); + } + [[nodiscard]] constexpr bool isPreviewResultStale( PreviewResultMeta const & meta, PreviewResultAcceptanceContext const & context) noexcept { bool const staleEpoch = meta.epoch < context.currentEpoch; - bool const staleViewEpoch = meta.viewEpoch != 0u && meta.viewEpoch < context.currentViewEpoch; + if (staleEpoch) + { + return true; + } + + if (context.allowStaleViewDuringCameraInteraction && + isPreviewResultStaleViewOnly(meta, context)) + { + return false; + } + + bool const staleViewEpoch = meta.viewEpoch != 0u && + meta.viewEpoch < context.currentViewEpoch; bool const staleStamp = !matches(meta.coordinatorStamp, context.requiredStamp, context.freshnessMask); - return staleEpoch || staleViewEpoch || staleStamp; + return staleViewEpoch || staleStamp; } [[nodiscard]] constexpr bool wouldRegressPresentedQuality( @@ -117,7 +155,11 @@ namespace gladius::ui::async_rendering return {PreviewResultRejectionReason::RealtimeRaymarchActive}; } - if (isPreviewResultStale(meta, context)) + bool const presentAsRequiredStamp = + context.allowStaleViewDuringCameraInteraction && + isPreviewResultStaleViewOnly(meta, context); + + if (isPreviewResultStale(meta, context)) { return {PreviewResultRejectionReason::StaleFrame}; } @@ -132,6 +174,7 @@ namespace gladius::ui::async_rendering return {PreviewResultRejectionReason::QualityRegression}; } - return {}; + return {.rejectionReason = PreviewResultRejectionReason::None, + .presentAsRequiredStamp = presentAsRequiredStamp}; } } diff --git a/gladius/tests/unittests/ui/render/DisplayFrameSelector_tests.cpp b/gladius/tests/unittests/ui/render/DisplayFrameSelector_tests.cpp index ec943567..369f488c 100644 --- a/gladius/tests/unittests/ui/render/DisplayFrameSelector_tests.cpp +++ b/gladius/tests/unittests/ui/render/DisplayFrameSelector_tests.cpp @@ -35,6 +35,24 @@ namespace gladius::ui::async_rendering::tests EXPECT_EQ(selected, DisplayFrameSource::ResultImage); } + TEST(DisplayFrameSelector, WithPreviouslyPresentedRealtimeFrontAfterEpochBump_HoldsFrontBuffer) + { + auto const presentedFrame = PresentedFrame{.frameId = 10, + .stamp = RenderStamp{.sceneEpoch = 3, + .viewEpoch = 7}, + .quality = FramePresentationQuality::FullQuality, + .source = FramePresentationSource::ExactRealtime, + .completedFrame = true}; + auto const selected = selectDisplayFrameSource( + DisplayFrameSelectionInput{.frontBuffer = buffer(3, 7), + .currentEpoch = 4, + .currentViewEpoch = 8, + .resultImageAvailable = true, + .presentedFrame = presentedFrame}); + + EXPECT_EQ(selected, DisplayFrameSource::FrontBuffer); + } + TEST(DisplayFrameSelector, WithStaleViewFrontBufferDuringRealtimeInteraction_SelectsFrontBuffer) { auto const selected = selectDisplayFrameSource( diff --git a/gladius/tests/unittests/ui/render/PreviewResultAcceptancePolicy_tests.cpp b/gladius/tests/unittests/ui/render/PreviewResultAcceptancePolicy_tests.cpp index 6c6a0c30..40e07b4f 100644 --- a/gladius/tests/unittests/ui/render/PreviewResultAcceptancePolicy_tests.cpp +++ b/gladius/tests/unittests/ui/render/PreviewResultAcceptancePolicy_tests.cpp @@ -71,6 +71,42 @@ namespace gladius::ui::async_rendering::tests EXPECT_EQ(decision.rejectionReason, PreviewResultRejectionReason::StaleFrame); } + TEST(PreviewResultAcceptancePolicy, CameraInteraction_WithStaleViewPreview_AllowsPresentationAsLatestView) + { + auto const oldViewStamp = makeStamp(1, 2, 3, 4, 5); + auto const latestStamp = makeStamp(1, 2, 4, 4, 5); + auto meta = previewResult(oldViewStamp); + meta.viewEpoch = 3; + + auto const decision = evaluatePreviewResultAcceptance( + meta, + PreviewResultAcceptanceContext{.requiredStamp = latestStamp, + .currentEpoch = meta.epoch, + .currentViewEpoch = 4, + .allowStaleViewDuringCameraInteraction = true}); + + EXPECT_TRUE(decision.accepted()); + EXPECT_TRUE(decision.presentAsRequiredStamp); + } + + TEST(PreviewResultAcceptancePolicy, CameraInteraction_WithStaleParameterPreview_RemainsRejected) + { + auto const oldParameterStamp = makeStamp(1, 2, 3, 4, 5); + auto const latestStamp = makeStamp(1, 3, 4, 4, 5); + auto meta = previewResult(oldParameterStamp); + meta.viewEpoch = 3; + + auto const decision = evaluatePreviewResultAcceptance( + meta, + PreviewResultAcceptanceContext{.requiredStamp = latestStamp, + .currentEpoch = meta.epoch, + .currentViewEpoch = 4, + .allowStaleViewDuringCameraInteraction = true}); + + EXPECT_FALSE(decision.accepted()); + EXPECT_EQ(decision.rejectionReason, PreviewResultRejectionReason::StaleFrame); + } + TEST(PreviewResultAcceptancePolicy, ActiveRealtimeInteraction_RejectsPreview) { auto const stamp = makeStamp(1, 2, 3, 4, 5); From 288fd5c3b81ceadde35911bd79b4163b95a630dd Mon Sep 17 00:00:00 2001 From: Jan Orend <56254096+3dJan@users.noreply.github.com> Date: Sat, 16 May 2026 19:38:20 +0200 Subject: [PATCH 20/72] [RenderWindow, InteractiveRenderPathPolicy, RenderModeUpdatePolicy, RenderUpdateCoordinator] Refactor rendering interaction logic to improve real-time mode handling and fallback mechanisms. Introduce new structures and methods for better state management and enhance unit tests for validation --- gladius/src/ui/RenderWindow.cpp | 11 +++-- .../ui/render/InteractiveRenderPathPolicy.h | 2 +- .../src/ui/render/RenderModeUpdatePolicy.h | 34 +++++++++++++++ .../src/ui/render/RenderUpdateCoordinator.h | 10 +++++ .../src/ui/render/RenderWorkflowController.h | 5 +++ .../InteractiveRenderPathPolicy_tests.cpp | 4 +- .../render/RenderModeUpdatePolicy_tests.cpp | 43 +++++++++++++++++++ .../render/RenderUpdateCoordinator_tests.cpp | 35 +++++++++++++-- 8 files changed, 134 insertions(+), 10 deletions(-) diff --git a/gladius/src/ui/RenderWindow.cpp b/gladius/src/ui/RenderWindow.cpp index d947bf9b..110fe268 100644 --- a/gladius/src/ui/RenderWindow.cpp +++ b/gladius/src/ui/RenderWindow.cpp @@ -589,10 +589,13 @@ namespace gladius::ui bool RenderWindow::isRealtimeRaymarchInteractionActive() const noexcept { - return m_renderUpdateCoordinator.interactionState() != - async_rendering::RenderInteractionState::Static && - (m_renderUpdateCoordinator.isRealtimeSchedulingActive() || - m_asyncRealtimeJobInFlight.load(std::memory_order_acquire)); + return async_rendering::isExactRealtimeInteractionActive( + async_rendering::RealtimeInteractionActivityInput{ + .mode = m_renderUpdateCoordinator.realtimeConfig().mode, + .interactionState = m_renderUpdateCoordinator.interactionState(), + .autoRealtimeActive = m_renderUpdateCoordinator.isRealtimeActive(), + .autoPreviewFallbackActive = m_renderUpdateCoordinator.isAutoPreviewFallbackActive(), + .exactRealtimeJobInFlight = m_asyncRealtimeJobInFlight.load(std::memory_order_acquire)}); } bool RenderWindow::isForceRealtimeMode() const noexcept diff --git a/gladius/src/ui/render/InteractiveRenderPathPolicy.h b/gladius/src/ui/render/InteractiveRenderPathPolicy.h index 83e5b0d0..856d50fa 100644 --- a/gladius/src/ui/render/InteractiveRenderPathPolicy.h +++ b/gladius/src/ui/render/InteractiveRenderPathPolicy.h @@ -67,7 +67,7 @@ namespace gladius::ui::async_rendering } return input.exactRealtimeAllowed ? InteractiveRenderPath::ExactRealtime - : InteractiveRenderPath::KeepCurrentFrame; + : InteractiveRenderPath::LowResolutionPreview; } if (input.mode == RealtimeRaymarchMode::Off) diff --git a/gladius/src/ui/render/RenderModeUpdatePolicy.h b/gladius/src/ui/render/RenderModeUpdatePolicy.h index bcafc464..7f50fdb3 100644 --- a/gladius/src/ui/render/RenderModeUpdatePolicy.h +++ b/gladius/src/ui/render/RenderModeUpdatePolicy.h @@ -48,6 +48,40 @@ namespace gladius::ui::async_rendering return true; } + struct RealtimeInteractionActivityInput + { + RealtimeRaymarchMode mode{RealtimeRaymarchMode::Auto}; + RenderInteractionState interactionState{RenderInteractionState::Static}; + bool autoRealtimeActive{false}; + /// True when Auto has fallen back to the simpler low-res preview path for the + /// current gesture. While this latch is active, preview tasks/results are the + /// intended feedback path and must not be cancelled as "realtime active". + bool autoPreviewFallbackActive{false}; + bool exactRealtimeJobInFlight{false}; + }; + + [[nodiscard]] constexpr bool isExactRealtimeInteractionActive( + RealtimeInteractionActivityInput const & input) noexcept + { + if (input.interactionState == RenderInteractionState::Static) + { + return false; + } + + if (input.exactRealtimeJobInFlight) + { + return true; + } + + if (input.mode == RealtimeRaymarchMode::Force) + { + return true; + } + + return input.mode == RealtimeRaymarchMode::Auto && input.autoRealtimeActive && + !input.autoPreviewFallbackActive; + } + struct ParameterChangeDispatchInput { RealtimeRaymarchMode mode{RealtimeRaymarchMode::Auto}; diff --git a/gladius/src/ui/render/RenderUpdateCoordinator.h b/gladius/src/ui/render/RenderUpdateCoordinator.h index d447e85a..f5ffd8b0 100644 --- a/gladius/src/ui/render/RenderUpdateCoordinator.h +++ b/gladius/src/ui/render/RenderUpdateCoordinator.h @@ -76,6 +76,16 @@ namespace gladius::ui::async_rendering m_realtime.isRealtimeActive()); } + /// Returns true when Auto mode has latched the current interaction to the + /// simpler preview path after a guard blocked exact realtime. This latch is + /// cleared when the gesture ends and prevents mid-gesture path switching. + [[nodiscard]] bool isAutoPreviewFallbackActive() const noexcept + { + return m_realtime.config().mode == RealtimeRaymarchMode::Auto && + m_interactionState != RenderInteractionState::Static && + m_autoGestureLockedSimpler; + } + void setRealtimeGuards(RealtimeRaymarchGuards guards) noexcept { m_realtimeGuards = guards; diff --git a/gladius/src/ui/render/RenderWorkflowController.h b/gladius/src/ui/render/RenderWorkflowController.h index 7f3320a5..96042c45 100644 --- a/gladius/src/ui/render/RenderWorkflowController.h +++ b/gladius/src/ui/render/RenderWorkflowController.h @@ -68,6 +68,11 @@ namespace gladius::ui::async_rendering return m_coordinator.isRealtimeSchedulingActive(); } + [[nodiscard]] bool isAutoPreviewFallbackActive() const noexcept + { + return m_coordinator.isAutoPreviewFallbackActive(); + } + [[nodiscard]] RenderStamp latestStamp() const noexcept { return m_coordinator.latestStamp(); diff --git a/gladius/tests/unittests/ui/render/InteractiveRenderPathPolicy_tests.cpp b/gladius/tests/unittests/ui/render/InteractiveRenderPathPolicy_tests.cpp index 4fae879d..277c4562 100644 --- a/gladius/tests/unittests/ui/render/InteractiveRenderPathPolicy_tests.cpp +++ b/gladius/tests/unittests/ui/render/InteractiveRenderPathPolicy_tests.cpp @@ -80,7 +80,7 @@ namespace gladius::ui::async_rendering::tests EXPECT_EQ(path, InteractiveRenderPath::ExactRealtime); } - TEST(InteractiveRenderPathPolicy, AutoCamera_WithRealtimeAdmittedButBlocked_KeepsCurrentFrame) + TEST(InteractiveRenderPathPolicy, AutoCamera_WithRealtimeAdmittedButBlocked_FallsBackToPreview) { auto const path = chooseInteractiveRenderPath( InteractiveRenderPathInput{.mode = RealtimeRaymarchMode::Auto, @@ -88,7 +88,7 @@ namespace gladius::ui::async_rendering::tests .autoInteractiveExactRealtimeAdmitted = true, .exactRealtimeAllowed = false}); - EXPECT_EQ(path, InteractiveRenderPath::KeepCurrentFrame); + EXPECT_EQ(path, InteractiveRenderPath::LowResolutionPreview); } TEST(InteractiveRenderPathPolicy, OffInteraction_UsesLowResolutionPreview) diff --git a/gladius/tests/unittests/ui/render/RenderModeUpdatePolicy_tests.cpp b/gladius/tests/unittests/ui/render/RenderModeUpdatePolicy_tests.cpp index 2fb279be..c7816bb5 100644 --- a/gladius/tests/unittests/ui/render/RenderModeUpdatePolicy_tests.cpp +++ b/gladius/tests/unittests/ui/render/RenderModeUpdatePolicy_tests.cpp @@ -111,6 +111,49 @@ namespace gladius::ui::async_rendering::tests EXPECT_FALSE(decision); } + TEST(RenderModeUpdatePolicy, ExactRealtimeInteraction_WithAutoRealtimeActive_IsActive) + { + auto const active = isExactRealtimeInteractionActive( + RealtimeInteractionActivityInput{.mode = RealtimeRaymarchMode::Auto, + .interactionState = RenderInteractionState::CameraInteracting, + .autoRealtimeActive = true, + .autoPreviewFallbackActive = false, + .exactRealtimeJobInFlight = false}); + + EXPECT_TRUE(active); + } + + TEST(RenderModeUpdatePolicy, ExactRealtimeInteraction_WithAutoFallbackPreview_IsNotActive) + { + auto const active = isExactRealtimeInteractionActive( + RealtimeInteractionActivityInput{.mode = RealtimeRaymarchMode::Auto, + .interactionState = RenderInteractionState::CameraInteracting, + .autoRealtimeActive = true, + .autoPreviewFallbackActive = true, + .exactRealtimeJobInFlight = false}); + + EXPECT_FALSE(active); + } + + TEST(RenderModeUpdatePolicy, ExactRealtimeInteraction_WithForce_IsActive) + { + auto const active = isExactRealtimeInteractionActive( + RealtimeInteractionActivityInput{.mode = RealtimeRaymarchMode::Force, + .interactionState = RenderInteractionState::CameraInteracting}); + + EXPECT_TRUE(active); + } + + TEST(RenderModeUpdatePolicy, ExactRealtimeInteraction_WithStaticState_IsNotActive) + { + auto const active = isExactRealtimeInteractionActive( + RealtimeInteractionActivityInput{.mode = RealtimeRaymarchMode::Force, + .interactionState = RenderInteractionState::Static, + .exactRealtimeJobInFlight = true}); + + EXPECT_FALSE(active); + } + TEST(RenderModeUpdatePolicy, ParameterChangeDispatch_WithForce_InvalidatesWithoutStreaming) { auto const dispatch = classifyParameterChange( diff --git a/gladius/tests/unittests/ui/render/RenderUpdateCoordinator_tests.cpp b/gladius/tests/unittests/ui/render/RenderUpdateCoordinator_tests.cpp index e1aae21b..994f70c1 100644 --- a/gladius/tests/unittests/ui/render/RenderUpdateCoordinator_tests.cpp +++ b/gladius/tests/unittests/ui/render/RenderUpdateCoordinator_tests.cpp @@ -210,7 +210,7 @@ namespace gladius::ui::async_rendering::tests EXPECT_TRUE(hasCommand(decision, RenderCommandType::KeepCurrentFrame)); } - TEST(RenderUpdateCoordinator, CameraChanged_WithAutoRealtimeGuardBlocker_KeepsCurrentFrame) + TEST(RenderUpdateCoordinator, CameraChanged_WithAutoRealtimeGuardBlocker_FallsBackToPreview) { RenderUpdateCoordinator coordinator; auto const viewportDecision = coordinator.configureViewport(640, 480); @@ -223,8 +223,37 @@ namespace gladius::ui::async_rendering::tests auto const decision = coordinator.notifyCameraChanged(); EXPECT_FALSE(hasStartedTask(decision, RenderTaskType::RealtimeFullFrame)); - EXPECT_FALSE(hasStartedTask(decision, RenderTaskType::LowResolutionPreview)); - EXPECT_TRUE(hasCommand(decision, RenderCommandType::KeepCurrentFrame)); + EXPECT_TRUE(hasStartedTask(decision, RenderTaskType::LowResolutionPreview)); + EXPECT_FALSE(hasCommand(decision, RenderCommandType::KeepCurrentFrame)); + EXPECT_TRUE(coordinator.isAutoPreviewFallbackActive()); + } + + TEST(RenderUpdateCoordinator, CameraChanged_WithAutoPreviewFallbackLatch_StaysPreviewUntilGestureEnds) + { + RenderUpdateCoordinator coordinator; + auto const viewportDecision = coordinator.configureViewport(640, 480); + ASSERT_FALSE(viewportDecision.commands.empty()); + primeAutoRealtimeAdmission(coordinator, viewportDecision); + async_rendering::RealtimeRaymarchGuards guards{}; + guards.hardBlocker = true; + coordinator.setRealtimeGuards(guards); + + auto decision = coordinator.notifyCameraChanged(); + auto preview = findStartedTask(decision, RenderTaskType::LowResolutionPreview); + ASSERT_TRUE(preview.has_value()); + EXPECT_TRUE(coordinator.isAutoPreviewFallbackActive()); + + coordinator.setRealtimeGuards(async_rendering::RealtimeRaymarchGuards{}); + decision = coordinator.completeTask(completed(*preview, true, true)); + EXPECT_TRUE(hasCommand(decision, RenderCommandType::PresentFrame)); + + decision = coordinator.notifyCameraChanged(); + EXPECT_TRUE(hasStartedTask(decision, RenderTaskType::LowResolutionPreview)); + EXPECT_FALSE(hasStartedTask(decision, RenderTaskType::RealtimeFullFrame)); + EXPECT_TRUE(coordinator.isAutoPreviewFallbackActive()); + + (void) coordinator.notifyCameraInteractionEnded(); + EXPECT_FALSE(coordinator.isAutoPreviewFallbackActive()); } TEST(RenderUpdateCoordinator, CameraInteractionEnded_WithAutoRealtimeCurrentHq_KeepsCurrentFrame) From 0c92ed6d89c4e201c5f3ab53f43895f914a599b9 Mon Sep 17 00:00:00 2001 From: Jan Orend <56254096+3dJan@users.noreply.github.com> Date: Sat, 16 May 2026 20:32:51 +0200 Subject: [PATCH 21/72] [RenderWindow, RenderModeUpdatePolicy, DisplayFrameSelector_tests] Refactor rendering execution path logic for realtime interactions. Introduce new enum for frame execution paths and enhance unit tests for accurate frame selection and execution behavior --- gladius/src/ui/RenderWindow.cpp | 23 ++++++------- .../src/ui/render/RenderModeUpdatePolicy.h | 14 ++++++++ .../ui/render/DisplayFrameSelector_tests.cpp | 20 ++++++++++++ .../render/RenderModeUpdatePolicy_tests.cpp | 32 +++++++++++++++++++ 4 files changed, 78 insertions(+), 11 deletions(-) diff --git a/gladius/src/ui/RenderWindow.cpp b/gladius/src/ui/RenderWindow.cpp index 110fe268..915dc6d8 100644 --- a/gladius/src/ui/RenderWindow.cpp +++ b/gladius/src/ui/RenderWindow.cpp @@ -508,13 +508,17 @@ namespace gladius::ui case async_rendering::RenderTaskType::RealtimeFullFrame: { - // When realtime rendering is actually active (Force always; Auto once the - // controller has measured sufficient GPU performance) render synchronously on - // the UI thread instead of going through the async job queue. This gives - // zero-latency parameter feedback because every drag event yields a fresh, - // full-quality frame in the same UI pass — no round-trip through the worker. - // SDF and BBox jobs remain asynchronous via the existing path. - if (m_renderUpdateCoordinator.isRealtimeSchedulingActive()) + // Only exact realtime interactions run synchronously on the UI thread. Static + // catch-up/full-frame probes must stay on the async worker so loading or + // catching up a complex model cannot block the UI on renderEvent.wait(). + auto const executionPath = async_rendering::chooseRealtimeFrameExecutionPath( + async_rendering::RealtimeInteractionActivityInput{ + .mode = m_renderUpdateCoordinator.realtimeConfig().mode, + .interactionState = m_renderUpdateCoordinator.interactionState(), + .autoRealtimeActive = m_renderUpdateCoordinator.isRealtimeActive(), + .autoPreviewFallbackActive = m_renderUpdateCoordinator.isAutoPreviewFallbackActive(), + .exactRealtimeJobInFlight = m_asyncRealtimeJobInFlight.load(std::memory_order_acquire)}); + if (executionPath == async_rendering::RealtimeFrameExecutionPath::SynchronousUiThread) { bool const rendered = tryRenderRealtimeFrameSync(task); queueRenderDecision(m_renderUpdateCoordinator.completeTask( @@ -752,10 +756,7 @@ namespace gladius::ui // to avoid showing stale HQ results from an old parameter set. Exact realtime // interaction may keep a current-epoch front buffer visible while the view epoch is // catching up, and all other cases fall back to m_resultImage to avoid blanking. - bool const exactRealtimeInteraction = - m_renderUpdateCoordinator.isRealtimeSchedulingActive() && - m_renderUpdateCoordinator.interactionState() != - async_rendering::RenderInteractionState::Static; + bool const exactRealtimeInteraction = isRealtimeRaymarchInteractionActive(); bool const exactRealtimeJobInFlight = m_asyncRealtimeJobInFlight.load(std::memory_order_acquire); auto const resultImage = m_core->getResultImage(); diff --git a/gladius/src/ui/render/RenderModeUpdatePolicy.h b/gladius/src/ui/render/RenderModeUpdatePolicy.h index 7f50fdb3..22e15212 100644 --- a/gladius/src/ui/render/RenderModeUpdatePolicy.h +++ b/gladius/src/ui/render/RenderModeUpdatePolicy.h @@ -82,6 +82,20 @@ namespace gladius::ui::async_rendering !input.autoPreviewFallbackActive; } + enum class RealtimeFrameExecutionPath + { + AsyncWorker, + SynchronousUiThread + }; + + [[nodiscard]] constexpr RealtimeFrameExecutionPath chooseRealtimeFrameExecutionPath( + RealtimeInteractionActivityInput const & input) noexcept + { + return isExactRealtimeInteractionActive(input) + ? RealtimeFrameExecutionPath::SynchronousUiThread + : RealtimeFrameExecutionPath::AsyncWorker; + } + struct ParameterChangeDispatchInput { RealtimeRaymarchMode mode{RealtimeRaymarchMode::Auto}; diff --git a/gladius/tests/unittests/ui/render/DisplayFrameSelector_tests.cpp b/gladius/tests/unittests/ui/render/DisplayFrameSelector_tests.cpp index 369f488c..7487a5e9 100644 --- a/gladius/tests/unittests/ui/render/DisplayFrameSelector_tests.cpp +++ b/gladius/tests/unittests/ui/render/DisplayFrameSelector_tests.cpp @@ -35,6 +35,26 @@ namespace gladius::ui::async_rendering::tests EXPECT_EQ(selected, DisplayFrameSource::ResultImage); } + TEST(DisplayFrameSelector, WithAutoPreviewFallbackResultAndStaleFront_SelectsResultImage) + { + auto const presentedPreview = PresentedFrame{.frameId = 11, + .stamp = RenderStamp{.sceneEpoch = 3, + .viewEpoch = 7}, + .quality = FramePresentationQuality::Preview, + .source = FramePresentationSource::LowResolutionPreview, + .completedFrame = true}; + + auto const selected = selectDisplayFrameSource( + DisplayFrameSelectionInput{.frontBuffer = buffer(3, 6), + .currentEpoch = 3, + .currentViewEpoch = 7, + .exactRealtimeInteraction = false, + .resultImageAvailable = true, + .presentedFrame = presentedPreview}); + + EXPECT_EQ(selected, DisplayFrameSource::ResultImage); + } + TEST(DisplayFrameSelector, WithPreviouslyPresentedRealtimeFrontAfterEpochBump_HoldsFrontBuffer) { auto const presentedFrame = PresentedFrame{.frameId = 10, diff --git a/gladius/tests/unittests/ui/render/RenderModeUpdatePolicy_tests.cpp b/gladius/tests/unittests/ui/render/RenderModeUpdatePolicy_tests.cpp index c7816bb5..10462fce 100644 --- a/gladius/tests/unittests/ui/render/RenderModeUpdatePolicy_tests.cpp +++ b/gladius/tests/unittests/ui/render/RenderModeUpdatePolicy_tests.cpp @@ -154,6 +154,38 @@ namespace gladius::ui::async_rendering::tests EXPECT_FALSE(active); } + TEST(RenderModeUpdatePolicy, RealtimeFrameExecution_WithStaticAutoRealtimeActive_UsesAsyncWorker) + { + auto const path = chooseRealtimeFrameExecutionPath( + RealtimeInteractionActivityInput{.mode = RealtimeRaymarchMode::Auto, + .interactionState = RenderInteractionState::Static, + .autoRealtimeActive = true}); + + EXPECT_EQ(path, RealtimeFrameExecutionPath::AsyncWorker); + } + + TEST(RenderModeUpdatePolicy, RealtimeFrameExecution_WithAutoExactInteraction_UsesSynchronousUiThread) + { + auto const path = chooseRealtimeFrameExecutionPath( + RealtimeInteractionActivityInput{.mode = RealtimeRaymarchMode::Auto, + .interactionState = RenderInteractionState::CameraInteracting, + .autoRealtimeActive = true, + .autoPreviewFallbackActive = false}); + + EXPECT_EQ(path, RealtimeFrameExecutionPath::SynchronousUiThread); + } + + TEST(RenderModeUpdatePolicy, RealtimeFrameExecution_WithAutoPreviewFallback_UsesAsyncWorker) + { + auto const path = chooseRealtimeFrameExecutionPath( + RealtimeInteractionActivityInput{.mode = RealtimeRaymarchMode::Auto, + .interactionState = RenderInteractionState::CameraInteracting, + .autoRealtimeActive = true, + .autoPreviewFallbackActive = true}); + + EXPECT_EQ(path, RealtimeFrameExecutionPath::AsyncWorker); + } + TEST(RenderModeUpdatePolicy, ParameterChangeDispatch_WithForce_InvalidatesWithoutStreaming) { auto const dispatch = classifyParameterChange( From f4acd0aabb5406f4e29ee1819a2cd3445f538136 Mon Sep 17 00:00:00 2001 From: Jan Orend <56254096+3dJan@users.noreply.github.com> Date: Sat, 16 May 2026 21:00:02 +0200 Subject: [PATCH 22/72] [AsyncRenderController, RealtimeRaymarchController, RenderUpdateCoordinator] Refactor rendering logic to improve job cancellation handling and state management. Introduce deque for pending results, enhance budget checks, and update unit tests for better validation of rendering behavior --- .../src/ui/render/AsyncRenderController.cpp | 67 +++++-- .../ui/render/RealtimeRaymarchController.cpp | 16 +- .../ui/render/RealtimeRaymarchController.h | 4 +- .../src/ui/render/RenderUpdateCoordinator.h | 14 +- .../ui/RealtimeRaymarchController_tests.cpp | 24 ++- .../ui/render/AsyncRenderController_tests.cpp | 175 ++++++++++++++++++ .../render/RenderUpdateCoordinator_tests.cpp | 78 +++++++- 7 files changed, 327 insertions(+), 51 deletions(-) create mode 100644 gladius/tests/unittests/ui/render/AsyncRenderController_tests.cpp diff --git a/gladius/src/ui/render/AsyncRenderController.cpp b/gladius/src/ui/render/AsyncRenderController.cpp index 7dbe1eed..8332bf60 100644 --- a/gladius/src/ui/render/AsyncRenderController.cpp +++ b/gladius/src/ui/render/AsyncRenderController.cpp @@ -18,6 +18,7 @@ #include #include #include +#include #include #include #include @@ -49,7 +50,7 @@ namespace gladius::ui::async_rendering AsyncRenderController::JobExecutor jobExecutor; std::mutex resultMutex; - std::optional pendingResult; + std::deque pendingResults; std::atomic hasPendingResult{false}; std::mutex lifecycleMutex; @@ -61,6 +62,33 @@ namespace gladius::ui::async_rendering { } }; + + [[nodiscard]] FrameResultMeta makeCancelledResult(RenderJob const & job) + { + return FrameResultMeta{.frameId = job.frameHint, + .epoch = job.epoch, + .viewEpoch = job.viewEpoch, + .jobType = job.type, + .width = job.width, + .height = job.height, + .cancelled = true, + .completedFrame = false, + .precomputedSdfUpdated = false, + .startLine = job.startLine, + .completedLine = job.startLine, + .computeDurationNs = 0, + .compilationProgress = 0.0f, + .compilationSucceeded = false, + .coordinatorRequestId = job.coordinatorRequestId, + .coordinatorStamp = job.coordinatorStamp}; + } + + void publishResult(ControllerStateData & data, FrameResultMeta result) + { + std::lock_guard guard(data.resultMutex); + data.pendingResults.push_back(std::move(result)); + data.hasPendingResult.store(true, std::memory_order_release); + } } struct AsyncRenderController::ControllerState @@ -137,7 +165,7 @@ namespace gladius::ui::async_rendering m_state->data.latestViewEpoch.store(0, std::memory_order_release); { std::lock_guard resultLock(m_state->data.resultMutex); - m_state->data.pendingResult.reset(); + m_state->data.pendingResults.clear(); m_state->data.hasPendingResult.store(false, std::memory_order_release); } @@ -242,15 +270,15 @@ namespace gladius::ui::async_rendering } std::lock_guard lock(data.resultMutex); - if (!data.pendingResult.has_value()) + if (data.pendingResults.empty()) { data.hasPendingResult.store(false, std::memory_order_release); return std::nullopt; } - FrameResultMeta result = std::move(*data.pendingResult); - data.pendingResult.reset(); - data.hasPendingResult.store(false, std::memory_order_release); + FrameResultMeta result = std::move(data.pendingResults.front()); + data.pendingResults.pop_front(); + data.hasPendingResult.store(!data.pendingResults.empty(), std::memory_order_release); return result; } @@ -316,10 +344,9 @@ namespace gladius::ui::async_rendering // (the UI thread). Without this, the entire job runs on the UI thread, blocking it. co_await data->workerPool->schedule(); - // Pre-execution epoch-only check: safe to skip without storing a result because - // notifyAsyncEpochIncrement() clears the in-flight flags on epoch changes. - // View-epoch changes must NOT skip here — they need the executor to store a - // cancelled result so processAsyncResults() can clear m_asyncJobInFlight. + // Pre-execution checks: stale queued work is cancelled before entering the job + // executor so obsolete OpenCL kernels are not launched. The worker still publishes + // a cancelled result, allowing the UI/coordinator to clear any in-flight bookkeeping. auto const epochOnlyCheck = [data, jobEpoch = job.epoch]() -> bool { return data->shutdownRequested.load(std::memory_order_acquire) || @@ -354,6 +381,16 @@ namespace gladius::ui::async_rendering job.type == RenderJobType::StreamingPreview); if (!isPreviewJob && epochOnlyCheck()) { + publishResult(*data, makeCancelledResult(job)); + continue; + } + + bool const staleProgressiveView = isHqProgressiveJob && job.viewEpoch > 0 && + data->latestViewEpoch.load(std::memory_order_acquire) > + job.viewEpoch; + if (staleProgressiveView) + { + publishResult(*data, makeCancelledResult(job)); continue; } @@ -369,16 +406,10 @@ namespace gladius::ui::async_rendering } catch (...) { - result.epoch = job.epoch; - result.frameId = job.frameHint; - result.cancelled = true; + result = makeCancelledResult(job); } - { - std::lock_guard guard(data->resultMutex); - data->pendingResult = std::move(result); - data->hasPendingResult.store(true, std::memory_order_release); - } + publishResult(*data, std::move(result)); } { diff --git a/gladius/src/ui/render/RealtimeRaymarchController.cpp b/gladius/src/ui/render/RealtimeRaymarchController.cpp index 0b3e46fb..78246786 100644 --- a/gladius/src/ui/render/RealtimeRaymarchController.cpp +++ b/gladius/src/ui/render/RealtimeRaymarchController.cpp @@ -121,15 +121,15 @@ namespace gladius::ui::async_rendering m_estimatedFullFrameMs = scaledEstimate; - auto const enterBudget = m_config.targetFrameTimeMs * m_config.enterBudgetRatio; - auto const exitBudget = m_config.targetFrameTimeMs * m_config.exitBudgetRatio; - if (scaledEstimate <= enterBudget || (wasRealtimeActive && scaledEstimate <= exitBudget)) + if (wasRealtimeActive && scaledEstimate <= m_config.realtimeDropBudgetMs) { m_fastSampleStreak = m_config.requiredFastSamples; m_realtimeActive = m_config.mode == RealtimeRaymarchMode::Auto; - m_staticFullFramePreferred = true; + m_staticFullFramePreferred = scaledEstimate < m_config.staticFullFrameBudgetMs; + return; } - else if (scaledEstimate <= m_config.staticFullFrameBudgetMs) + + if (scaledEstimate < m_config.staticFullFrameBudgetMs) { m_staticFullFramePreferred = true; } @@ -167,7 +167,7 @@ namespace gladius::ui::async_rendering } recordEstimatedTime(*estimatedMs); - m_staticFullFramePreferred = *estimatedMs <= m_config.staticFullFrameBudgetMs; + m_staticFullFramePreferred = *estimatedMs < m_config.staticFullFrameBudgetMs; if (m_staticFullFramePreferred) { m_slowSampleStreak = 0; @@ -198,9 +198,9 @@ namespace gladius::ui::async_rendering } recordEstimatedTime(*estimatedMs); - m_staticFullFramePreferred = *estimatedMs <= m_config.staticFullFrameBudgetMs; + m_staticFullFramePreferred = *estimatedMs < m_config.staticFullFrameBudgetMs; - if (*estimatedMs <= m_config.targetFrameTimeMs * m_config.enterBudgetRatio) + if (*estimatedMs < m_config.staticFullFrameBudgetMs) { ++m_fastSampleStreak; m_slowSampleStreak = 0; diff --git a/gladius/src/ui/render/RealtimeRaymarchController.h b/gladius/src/ui/render/RealtimeRaymarchController.h index 6b6b859d..2db68921 100644 --- a/gladius/src/ui/render/RealtimeRaymarchController.h +++ b/gladius/src/ui/render/RealtimeRaymarchController.h @@ -28,8 +28,8 @@ namespace gladius::ui::async_rendering int requiredFastSamples{1}; int maxSlowSamples{2}; int cooldownFrames{30}; - float staticFullFrameBudgetMs{60.0f}; - float realtimeDropBudgetMs{60.0f}; + float staticFullFrameBudgetMs{100.0f}; + float realtimeDropBudgetMs{100.0f}; }; struct RealtimeRaymarchGuards diff --git a/gladius/src/ui/render/RenderUpdateCoordinator.h b/gladius/src/ui/render/RenderUpdateCoordinator.h index f5ffd8b0..ee0488df 100644 --- a/gladius/src/ui/render/RenderUpdateCoordinator.h +++ b/gladius/src/ui/render/RenderUpdateCoordinator.h @@ -156,7 +156,6 @@ namespace gladius::ui::async_rendering m_height = height; ++m_latestStamp.viewportEpoch; m_realtime.resetForResolution(m_width, m_height); - m_staticHQRenderTimeMs = -1.0f; scheduleStaticCatchUp(decision); return decision; } @@ -304,13 +303,6 @@ namespace gladius::ui::async_rendering result.type == RenderTaskType::RealtimeFullFrame)) { m_highQualityFrameStamp = result.stamp; - if (result.type == RenderTaskType::ProgressiveHighQualityChunk && - m_interactionState == RenderInteractionState::Static && - result.durationNs > 0) - { - m_staticHQRenderTimeMs = - static_cast(result.durationNs) / 1'000'000.0f; - } } decision.commands.push_back(RenderCommand{.type = RenderCommandType::PresentFrame, .result = result, @@ -447,13 +439,12 @@ namespace gladius::ui::async_rendering { m_realtime.reset(); m_realtime.resetForResolution(m_width, m_height); - m_staticHQRenderTimeMs = -1.0f; m_autoGestureLockedSimpler = false; } [[nodiscard]] bool autoModeAdmitsRealtime() const noexcept { - return m_staticHQRenderTimeMs >= 0.0f && m_staticHQRenderTimeMs < 100.0f; + return m_realtime.isRealtimeActive(); } void scheduleInteractiveFrame(RenderUpdateDecision & decision) @@ -574,9 +565,6 @@ namespace gladius::ui::async_rendering uint64_t m_nextRequestId{1}; uint32_t m_width{1}; uint32_t m_height{1}; - /// Wall-clock duration of the last completed static progressive HQ render in milliseconds. - /// Negative means not yet measured. Used by Auto mode to gate exact-realtime admission. - float m_staticHQRenderTimeMs{-1.0f}; /// Per-gesture latch: set when Auto mode first decides to use simpler rendering during /// an interaction. Cleared when the gesture ends to prevent mid-gesture quality switches. bool m_autoGestureLockedSimpler{false}; diff --git a/gladius/tests/unittests/ui/RealtimeRaymarchController_tests.cpp b/gladius/tests/unittests/ui/RealtimeRaymarchController_tests.cpp index c1cdee91..d3f8e08a 100644 --- a/gladius/tests/unittests/ui/RealtimeRaymarchController_tests.cpp +++ b/gladius/tests/unittests/ui/RealtimeRaymarchController_tests.cpp @@ -69,7 +69,7 @@ namespace gladius::ui::async_rendering::tests controller.recordStaticFullFrameSample(makeFullFrameSample(18.0f)); ASSERT_TRUE(controller.canAttemptRealtime(800, 600, makeOpenGuards())); - controller.recordInteractiveRealtimeSample(makeFullFrameSample(65.0f)); + controller.recordInteractiveRealtimeSample(makeFullFrameSample(120.0f)); EXPECT_FALSE(controller.canAttemptRealtime(800, 600, makeOpenGuards())); EXPECT_FALSE(controller.canAttemptStaticFullFrame(800, 600, makeOpenGuards())); @@ -124,6 +124,7 @@ namespace gladius::ui::async_rendering::tests controller.recordStaticProgressiveSample(makeProgressiveChunkSample(2.0f)); + EXPECT_FALSE(controller.canAttemptRealtime(800, 600, makeOpenGuards())); EXPECT_TRUE(controller.canAttemptStaticFullFrame(800, 600, makeOpenGuards())); auto blockedGuards = makeOpenGuards(); @@ -156,7 +157,7 @@ namespace gladius::ui::async_rendering::tests EXPECT_FALSE(controller.canAttemptStaticFullFrame(800, 600, makeOpenGuards())); } - TEST(RealtimeRaymarchController, StaticFullFrameBetweenRealtimeAndStaticBudgets_DoesNotActivateRealtime) + TEST(RealtimeRaymarchController, StaticFullFrameBelowStaticBudget_ActivatesRealtime) { RealtimeRaymarchController controller; controller.configure(makeTestConfig()); @@ -164,9 +165,11 @@ namespace gladius::ui::async_rendering::tests controller.recordStaticProgressiveSample(makeProgressiveChunkSample(2.0f)); ASSERT_TRUE(controller.canAttemptStaticFullFrame(800, 600, makeOpenGuards())); + controller.recordStaticFullFrameSample(makeFullFrameSample(40.0f)); + controller.recordStaticFullFrameSample(makeFullFrameSample(40.0f)); controller.recordStaticFullFrameSample(makeFullFrameSample(40.0f)); - EXPECT_FALSE(controller.canAttemptRealtime(800, 600, makeOpenGuards())); + EXPECT_TRUE(controller.canAttemptRealtime(800, 600, makeOpenGuards())); EXPECT_TRUE(controller.canAttemptStaticFullFrame(800, 600, makeOpenGuards())); } @@ -202,6 +205,21 @@ namespace gladius::ui::async_rendering::tests EXPECT_FLOAT_EQ(*controller.estimatedFullFrameTimeMs(), 4.5f); } + TEST(RealtimeRaymarchController, ResolutionChange_AfterProgressiveOnlySample_DoesNotActivateRealtime) + { + RealtimeRaymarchController controller; + controller.configure(makeTestConfig()); + + controller.recordStaticProgressiveSample(makeProgressiveChunkSample(2.0f)); + ASSERT_TRUE(controller.canAttemptStaticFullFrame(800, 600, makeOpenGuards())); + ASSERT_FALSE(controller.canAttemptRealtime(800, 600, makeOpenGuards())); + + controller.resetForResolution(400, 300); + + EXPECT_FALSE(controller.canAttemptRealtime(400, 300, makeOpenGuards())); + EXPECT_TRUE(controller.canAttemptStaticFullFrame(400, 300, makeOpenGuards())); + } + TEST(RealtimeRaymarchModeFromString, WithKnownValues_ParsesModes) { EXPECT_EQ(realtimeRaymarchModeFromString("off"), RealtimeRaymarchMode::Off); diff --git a/gladius/tests/unittests/ui/render/AsyncRenderController_tests.cpp b/gladius/tests/unittests/ui/render/AsyncRenderController_tests.cpp new file mode 100644 index 00000000..8ad89f72 --- /dev/null +++ b/gladius/tests/unittests/ui/render/AsyncRenderController_tests.cpp @@ -0,0 +1,175 @@ +#include "ui/render/AsyncRenderController.h" + +#include + +#include +#include +#include +#include + +namespace gladius::ui::async_rendering::tests +{ + namespace + { + [[nodiscard]] RenderJob makeHighQualityJob(uint64_t epoch, uint64_t viewEpoch) + { + RenderStamp stamp{}; + stamp.viewEpoch = viewEpoch; + return RenderJob{.epoch = epoch, + .viewEpoch = viewEpoch, + .frameHint = 42, + .type = RenderJobType::HighQuality, + .width = 640, + .height = 480, + .startLine = 16, + .stepSize = 32, + .coordinatorRequestId = 7, + .coordinatorStamp = stamp}; + } + + [[nodiscard]] std::optional waitForResult(AsyncRenderController & controller) + { + auto const deadline = std::chrono::steady_clock::now() + std::chrono::seconds(2); + while (std::chrono::steady_clock::now() < deadline) + { + if (auto result = controller.tryConsumeResult()) + { + return result; + } + std::this_thread::yield(); + } + return std::nullopt; + } + } + + TEST(AsyncRenderController, Worker_WithStaleViewProgressiveJob_CancelsBeforeExecutor) + { + AsyncRenderController controller; + std::atomic executions{0}; + controller.setJobExecutor( + [&executions](RenderJob const & job, + AsyncRenderController::CancelCheck const &) -> coro::task + { + executions.fetch_add(1, std::memory_order_relaxed); + co_return FrameResultMeta{.frameId = job.frameHint, + .epoch = job.epoch, + .viewEpoch = job.viewEpoch, + .jobType = job.type, + .width = job.width, + .height = job.height, + .startLine = job.startLine, + .completedLine = job.startLine + job.stepSize, + .coordinatorRequestId = job.coordinatorRequestId, + .coordinatorStamp = job.coordinatorStamp}; + }); + controller.start(); + controller.setLatestViewEpoch(2); + + controller.enqueueJob(makeHighQualityJob(1, 1)); + + auto const result = waitForResult(controller); + controller.stop(); + + ASSERT_TRUE(result.has_value()); + EXPECT_TRUE(result->cancelled); + EXPECT_EQ(result->jobType, RenderJobType::HighQuality); + EXPECT_EQ(result->viewEpoch, 1u); + EXPECT_EQ(result->coordinatorRequestId, 7u); + EXPECT_EQ(executions.load(std::memory_order_relaxed), 0); + } + + TEST(AsyncRenderController, Worker_WithStaleEpochJob_CancelsBeforeExecutor) + { + AsyncRenderController controller; + std::atomic executions{0}; + controller.setJobExecutor( + [&executions](RenderJob const & job, + AsyncRenderController::CancelCheck const &) -> coro::task + { + executions.fetch_add(1, std::memory_order_relaxed); + co_return FrameResultMeta{.frameId = job.frameHint, + .epoch = job.epoch, + .viewEpoch = job.viewEpoch, + .jobType = job.type}; + }); + controller.start(); + controller.setLatestEpoch(2); + + controller.enqueueJob(makeHighQualityJob(1, 1)); + + auto const result = waitForResult(controller); + controller.stop(); + + ASSERT_TRUE(result.has_value()); + EXPECT_TRUE(result->cancelled); + EXPECT_EQ(result->epoch, 1u); + EXPECT_EQ(executions.load(std::memory_order_relaxed), 0); + } + + TEST(AsyncRenderController, Worker_WithBackToBackStaleJobs_PublishesAllCancelledResults) + { + AsyncRenderController controller; + std::atomic executions{0}; + controller.setJobExecutor( + [&executions](RenderJob const & job, + AsyncRenderController::CancelCheck const &) -> coro::task + { + executions.fetch_add(1, std::memory_order_relaxed); + co_return FrameResultMeta{.frameId = job.frameHint, + .epoch = job.epoch, + .viewEpoch = job.viewEpoch, + .jobType = job.type}; + }); + controller.start(); + controller.setLatestViewEpoch(3); + + controller.enqueueJob(makeHighQualityJob(1, 1)); + controller.enqueueJob(makeHighQualityJob(1, 2)); + + auto const firstResult = waitForResult(controller); + auto const secondResult = waitForResult(controller); + controller.stop(); + + ASSERT_TRUE(firstResult.has_value()); + ASSERT_TRUE(secondResult.has_value()); + EXPECT_TRUE(firstResult->cancelled); + EXPECT_TRUE(secondResult->cancelled); + EXPECT_EQ(firstResult->viewEpoch, 1u); + EXPECT_EQ(secondResult->viewEpoch, 2u); + EXPECT_EQ(executions.load(std::memory_order_relaxed), 0); + } + + TEST(AsyncRenderController, Worker_WithCurrentProgressiveJob_Executes) + { + AsyncRenderController controller; + std::atomic executions{0}; + controller.setJobExecutor( + [&executions](RenderJob const & job, + AsyncRenderController::CancelCheck const &) -> coro::task + { + executions.fetch_add(1, std::memory_order_relaxed); + co_return FrameResultMeta{.frameId = job.frameHint, + .epoch = job.epoch, + .viewEpoch = job.viewEpoch, + .jobType = job.type, + .width = job.width, + .height = job.height, + .startLine = job.startLine, + .completedLine = job.startLine + job.stepSize, + .coordinatorRequestId = job.coordinatorRequestId, + .coordinatorStamp = job.coordinatorStamp}; + }); + controller.start(); + controller.setLatestViewEpoch(1); + + controller.enqueueJob(makeHighQualityJob(1, 1)); + + auto const result = waitForResult(controller); + controller.stop(); + + ASSERT_TRUE(result.has_value()); + EXPECT_FALSE(result->cancelled); + EXPECT_EQ(result->jobType, RenderJobType::HighQuality); + EXPECT_EQ(executions.load(std::memory_order_relaxed), 1); + } +} \ No newline at end of file diff --git a/gladius/tests/unittests/ui/render/RenderUpdateCoordinator_tests.cpp b/gladius/tests/unittests/ui/render/RenderUpdateCoordinator_tests.cpp index 994f70c1..6c56cac9 100644 --- a/gladius/tests/unittests/ui/render/RenderUpdateCoordinator_tests.cpp +++ b/gladius/tests/unittests/ui/render/RenderUpdateCoordinator_tests.cpp @@ -76,8 +76,19 @@ namespace gladius::ui::async_rendering::tests .cancelled = false}; } - /// Drives the coordinator through the static pipeline until a ProgressiveHighQualityChunk - /// completes with a duration under the 100ms Auto-mode threshold. + [[nodiscard]] RealtimeRaymarchSample progressiveChunkSample(float durationMs) + { + return RealtimeRaymarchSample{.durationMs = durationMs, + .width = 640, + .height = 480, + .renderedLines = 48, + .totalLines = 480, + .completedFrame = false, + .cancelled = false}; + } + + /// Drives the coordinator through the static pipeline until a fast progressive estimate + /// admits an async static full-frame probe, and the completed probe unlocks Auto exact interaction. /// Call immediately after configureViewport(), passing its returned decision. void primeAutoRealtimeAdmission(RenderUpdateCoordinator & coordinator, RenderUpdateDecision const & viewportDecision) @@ -90,9 +101,13 @@ namespace gladius::ui::async_rendering::tests decision = coordinator.completeTask(completed(*sdf)); auto hq = findStartedTask(decision, RenderTaskType::ProgressiveHighQualityChunk); ASSERT_TRUE(hq.has_value()); - auto hqResult = completed(*hq, true, true); - hqResult.durationNs = 50'000'000; // 50ms < 100ms → autoModeAdmitsRealtime() = true - [[maybe_unused]] auto const finalDecision = coordinator.completeTask(hqResult); + coordinator.recordStaticProgressiveSample(progressiveChunkSample(5.0f)); + decision = coordinator.completeTask(completed(*hq)); + auto staticFullFrame = findStartedTask(decision, RenderTaskType::RealtimeFullFrame); + ASSERT_TRUE(staticFullFrame.has_value()); + coordinator.recordStaticFullFrameSample(fullFrameSample(50.0f)); + [[maybe_unused]] auto const finalDecision = + coordinator.completeTask(completed(*staticFullFrame, true, true)); } } @@ -129,7 +144,7 @@ namespace gladius::ui::async_rendering::tests EXPECT_TRUE(hasCommand(decision, RenderCommandType::KeepCurrentFrame)); } - TEST(RenderUpdateCoordinator, StaticCatchUp_AfterFastProgressiveSample_StartsStaticFullFrame) + TEST(RenderUpdateCoordinator, StaticCatchUp_AfterFastProgressiveEstimate_StartsStaticFullFrameProbe) { RenderUpdateCoordinator coordinator; auto decision = coordinator.configureViewport(640, 480); @@ -144,7 +159,7 @@ namespace gladius::ui::async_rendering::tests auto progressive = findStartedTask(decision, RenderTaskType::ProgressiveHighQualityChunk); ASSERT_TRUE(progressive.has_value()); - coordinator.recordStaticProgressiveSample(fullFrameSample(40.0f)); + coordinator.recordStaticProgressiveSample(progressiveChunkSample(5.0f)); decision = coordinator.completeTask(completed(*progressive)); @@ -152,6 +167,55 @@ namespace gladius::ui::async_rendering::tests EXPECT_FALSE(hasStartedTask(decision, RenderTaskType::ProgressiveHighQualityChunk)); } + TEST(RenderUpdateCoordinator, StaticCatchUp_AfterSlowProgressiveEstimate_ContinuesProgressive) + { + RenderUpdateCoordinator coordinator; + auto decision = coordinator.configureViewport(640, 480); + auto bbox = findStartedTask(decision, RenderTaskType::BoundingBoxUpdate); + ASSERT_TRUE(bbox.has_value()); + + decision = coordinator.completeTask(completed(*bbox)); + auto sdf = findStartedTask(decision, RenderTaskType::SdfPrecomputation); + ASSERT_TRUE(sdf.has_value()); + + decision = coordinator.completeTask(completed(*sdf)); + auto progressive = findStartedTask(decision, RenderTaskType::ProgressiveHighQualityChunk); + ASSERT_TRUE(progressive.has_value()); + + coordinator.recordStaticProgressiveSample(progressiveChunkSample(12.0f)); + + decision = coordinator.completeTask(completed(*progressive)); + + EXPECT_FALSE(hasStartedTask(decision, RenderTaskType::RealtimeFullFrame)); + EXPECT_TRUE(hasStartedTask(decision, RenderTaskType::ProgressiveHighQualityChunk)); + } + + TEST(RenderUpdateCoordinator, CameraChanged_AfterFastProgressiveEstimateBeforeStaticProbe_ComesFromPreview) + { + RenderUpdateCoordinator coordinator; + auto decision = coordinator.configureViewport(640, 480); + auto bbox = findStartedTask(decision, RenderTaskType::BoundingBoxUpdate); + ASSERT_TRUE(bbox.has_value()); + + decision = coordinator.completeTask(completed(*bbox)); + auto sdf = findStartedTask(decision, RenderTaskType::SdfPrecomputation); + ASSERT_TRUE(sdf.has_value()); + + decision = coordinator.completeTask(completed(*sdf)); + auto progressive = findStartedTask(decision, RenderTaskType::ProgressiveHighQualityChunk); + ASSERT_TRUE(progressive.has_value()); + + coordinator.recordStaticProgressiveSample(progressiveChunkSample(5.0f)); + decision = coordinator.completeTask(completed(*progressive)); + ASSERT_TRUE(hasStartedTask(decision, RenderTaskType::RealtimeFullFrame)); + EXPECT_FALSE(coordinator.isRealtimeActive()); + + decision = coordinator.notifyCameraChanged(); + + EXPECT_FALSE(hasStartedTask(decision, RenderTaskType::RealtimeFullFrame)); + EXPECT_TRUE(hasStartedTask(decision, RenderTaskType::LowResolutionPreview)); + } + TEST(RenderUpdateCoordinator, CameraChanged_WithForcedRealtime_StartsRealtimeFullFrame) { RenderUpdateCoordinator coordinator; From 560b68a36dcdae83926d27c99f2193e69048be1e Mon Sep 17 00:00:00 2001 From: Jan Orend <56254096+3dJan@users.noreply.github.com> Date: Sat, 16 May 2026 23:07:11 +0200 Subject: [PATCH 23/72] [RenderWindow, AsyncRenderController, RenderUpdateCoordinator] Refactor rendering logic to support StaticFullFrameProbe. Update job scheduling and handling, enhance unit tests for validation of new rendering behavior --- gladius/src/ui/RenderWindow.cpp | 129 +++++++++++------- gladius/src/ui/RenderWindow.h | 6 +- .../src/ui/render/AsyncRenderController.cpp | 15 +- gladius/src/ui/render/AsyncRenderTypes.h | 1 + .../src/ui/render/RenderUpdateCoordinator.h | 7 +- gladius/src/ui/render/RenderUpdateTypes.h | 1 + .../src/ui/render/RenderWorkflowController.h | 4 + .../ui/render/AsyncRenderController_tests.cpp | 61 +++++++-- .../render/RenderUpdateCoordinator_tests.cpp | 9 +- .../render/RenderWorkflowController_tests.cpp | 4 + 10 files changed, 156 insertions(+), 81 deletions(-) diff --git a/gladius/src/ui/RenderWindow.cpp b/gladius/src/ui/RenderWindow.cpp index 915dc6d8..3c3a9ebf 100644 --- a/gladius/src/ui/RenderWindow.cpp +++ b/gladius/src/ui/RenderWindow.cpp @@ -508,9 +508,6 @@ namespace gladius::ui case async_rendering::RenderTaskType::RealtimeFullFrame: { - // Only exact realtime interactions run synchronously on the UI thread. Static - // catch-up/full-frame probes must stay on the async worker so loading or - // catching up a complex model cannot block the UI on renderEvent.wait(). auto const executionPath = async_rendering::chooseRealtimeFrameExecutionPath( async_rendering::RealtimeInteractionActivityInput{ .mode = m_renderUpdateCoordinator.realtimeConfig().mode, @@ -537,7 +534,7 @@ namespace gladius::ui return false; // no async job started; task is already completed above } - bool const started = scheduleRealtimeRenderJob(state, &task); + bool const started = scheduleFullFrameRenderJob(state, &task); if (!started) { m_renderUpdateCoordinator.recordRealtimeRejectedAttempt(); @@ -546,6 +543,17 @@ namespace gladius::ui return started; } + case async_rendering::RenderTaskType::StaticFullFrameProbe: + { + bool const started = scheduleFullFrameRenderJob( + state, &task, async_rendering::RenderJobType::StaticFullFrameProbe); + if (!started) + { + failTask(); + } + return started; + } + case async_rendering::RenderTaskType::LowResolutionPreview: if (isRealtimeRaymarchInteractionActive()) { @@ -661,6 +669,9 @@ namespace gladius::ui case async_rendering::RenderJobType::RealtimeHighQuality: taskType = async_rendering::RenderTaskType::RealtimeFullFrame; break; + case async_rendering::RenderJobType::StaticFullFrameProbe: + taskType = async_rendering::RenderTaskType::StaticFullFrameProbe; + break; case async_rendering::RenderJobType::LowResPreview: taskType = async_rendering::RenderTaskType::LowResolutionPreview; break; @@ -2528,11 +2539,15 @@ namespace gladius::ui auto const currentEpoch = m_asyncCurrentEpoch.load(std::memory_order_acquire); bool const isOutdated = result.epoch < currentEpoch; - auto const currentViewEpoch = m_asyncViewEpoch.load(std::memory_order_acquire); - bool const isRealtimeJob = result.jobType == async_rendering::RenderJobType::RealtimeHighQuality; - bool const isExactRealtimeJob = isRealtimeJob; - bool const isHqRenderJob = result.jobType == async_rendering::RenderJobType::HighQuality || isExactRealtimeJob; - bool const isViewOutdated = isHqRenderJob && result.viewEpoch < currentViewEpoch; + auto const currentViewEpoch = m_asyncViewEpoch.load(std::memory_order_acquire); + auto const jobType = result.jobType; + bool const isExactRealtimeJob = jobType == async_rendering::RenderJobType::RealtimeHighQuality; + bool const isStaticFullFrameProbe = + jobType == async_rendering::RenderJobType::StaticFullFrameProbe; + bool const isFullFrameRenderJob = isExactRealtimeJob || isStaticFullFrameProbe; + bool const isHqRenderJob = jobType == async_rendering::RenderJobType::HighQuality || + isFullFrameRenderJob; + bool const isViewOutdated = isHqRenderJob && result.viewEpoch < currentViewEpoch; if (result.jobType == async_rendering::RenderJobType::SDFPrecomputation) { @@ -2599,7 +2614,7 @@ namespace gladius::ui continue; } - // From here on, only HQ/full exact render results are processed. + // From here on, only HQ/full-frame render results are processed. if (isHqRenderJob && result.computeDurationNs > 0) { @@ -2617,7 +2632,11 @@ namespace gladius::ui sample.completedFrame = result.completedFrame && result.startLine == 0 && renderedLines >= static_cast(result.height); sample.cancelled = result.cancelled; - if (isRealtimeJob) + if (isStaticFullFrameProbe && !isOutdated && !isViewOutdated) + { + m_renderUpdateCoordinator.recordStaticFullFrameSample(sample); + } + else if (isExactRealtimeJob) { if (m_renderUpdateCoordinator.interactionState() == async_rendering::RenderInteractionState::Static) @@ -2669,9 +2688,9 @@ namespace gladius::ui // Stale scene/view results are deliberately discarded before promotion; otherwise // a late realtime frame could replace a valid front buffer with an old camera or // parameter state. - // Realtime raymarching frames (both Auto and Force) need to be displayed even if + // Interactive realtime raymarching frames (both Auto and Force) need to be displayed even if // the view epoch has incremented, otherwise continuous camera motion freezes. - bool const allowForcedRealtimeStaleView = isRealtimeJob; + bool const allowForcedRealtimeStaleView = isExactRealtimeJob; bool const discardForStaleView = isViewOutdated && !allowForcedRealtimeStaleView; if (result.completedFrame) @@ -2681,38 +2700,38 @@ namespace gladius::ui m_asyncController->discardReadyFrame( result.frameId, result.epoch, result.viewEpoch); } - else - { - ZoneScopedN("PromoteReadyToFront"); - auto const requiredPresentationStamp = isExactRealtimeJob - ? result.coordinatorStamp - : m_renderUpdateCoordinator.latestStamp(); - auto * newFront = m_asyncController->promoteReadyToFront( - requiredPresentationStamp, async_rendering::RenderStampMask::displayFrame()); - if (newFront && newFront->image) - { - // Bind the new frame to ensure GL texture is updated - newFront->image->invalidateContent(); - newFront->image->bind(); - newFront->image->unbind(); - - bool const promoted = m_asyncController->finalizeFrontPromotion(newFront); - if (promoted) + else + { + ZoneScopedN("PromoteReadyToFront"); + auto const requiredPresentationStamp = isExactRealtimeJob + ? result.coordinatorStamp + : m_renderUpdateCoordinator.latestStamp(); + auto * newFront = m_asyncController->promoteReadyToFront( + requiredPresentationStamp, async_rendering::RenderStampMask::displayFrame()); + if (newFront && newFront->image) + { + // Bind the new frame to ensure GL texture is updated + newFront->image->invalidateContent(); + newFront->image->bind(); + newFront->image->unbind(); + + bool const wasPromoted = m_asyncController->finalizeFrontPromotion(newFront); + if (wasPromoted) { auto const source = isExactRealtimeJob ? async_rendering::FramePresentationSource::ExactRealtime : async_rendering::FramePresentationSource::ProgressiveHighQuality; - [[maybe_unused]] bool const presented = m_presentedFrames.presentCandidate( - async_rendering::FramePresentationCandidate{ - .frameId = result.frameId, - .stamp = result.coordinatorStamp, - .quality = async_rendering::FramePresentationQuality::FullQuality, - .source = source, - .completedFrame = true}, - requiredPresentationStamp); + auto const candidate = async_rendering::FramePresentationCandidate{ + .frameId = result.frameId, + .stamp = result.coordinatorStamp, + .quality = async_rendering::FramePresentationQuality::FullQuality, + .source = source, + .completedFrame = true}; + [[maybe_unused]] bool const presented = + m_presentedFrames.presentCandidate(candidate, requiredPresentationStamp); } - } - } + } + } } if (result.completedFrame) @@ -3031,12 +3050,13 @@ namespace gladius::ui return true; } - bool RenderWindow::scheduleRealtimeRenderJob( + bool RenderWindow::scheduleFullFrameRenderJob( RenderWindowState & state, - async_rendering::RenderTaskRequest const * coordinatorTask) + async_rendering::RenderTaskRequest const * coordinatorTask, + async_rendering::RenderJobType jobType) { ZoneScoped; - ZoneName("scheduleRealtimeRenderJob", strlen("scheduleRealtimeRenderJob")); + ZoneName("scheduleFullFrameRenderJob", strlen("scheduleFullFrameRenderJob")); if (!m_asyncController || !m_asyncController->isRunning() || !m_enableHQRendering) { @@ -3080,7 +3100,7 @@ namespace gladius::ui job.viewEpoch = m_asyncViewEpoch.load(std::memory_order_acquire); job.frameHint = m_asyncFrameCounter.fetch_add(1, std::memory_order_acq_rel) + 1; - job.type = async_rendering::RenderJobType::RealtimeHighQuality; + job.type = jobType; job.width = static_cast(width); job.height = static_cast(height); job.startLine = 0; @@ -3103,10 +3123,11 @@ namespace gladius::ui m_asyncInFlightEpoch.store(job.epoch, std::memory_order_release); m_asyncInFlightViewEpoch.store(job.viewEpoch, std::memory_order_release); m_asyncJobInFlight.store(true, std::memory_order_release); - m_asyncRealtimeJobInFlight.store(true, std::memory_order_release); + m_asyncRealtimeJobInFlight.store(job.type == async_rendering::RenderJobType::RealtimeHighQuality, + std::memory_order_release); m_asyncController->enqueueJob(job); - ZoneText("RealtimeJobEnqueued", 19); + ZoneText("FullFrameJobEnqueued", 20); return true; } @@ -3136,10 +3157,14 @@ namespace gladius::ui result.jobType = job.type; result.width = job.width; result.height = job.height; - result.startLine = job.startLine; - result.coordinatorRequestId = job.coordinatorRequestId; - result.coordinatorStamp = job.coordinatorStamp; - bool const isExactRealtimeJob = job.type == async_rendering::RenderJobType::RealtimeHighQuality; + result.startLine = job.startLine; + result.coordinatorRequestId = job.coordinatorRequestId; + result.coordinatorStamp = job.coordinatorStamp; + auto const jobType = job.type; + bool const isExactRealtimeJob = jobType == async_rendering::RenderJobType::RealtimeHighQuality; + bool const isStaticFullFrameProbe = + jobType == async_rendering::RenderJobType::StaticFullFrameProbe; + bool const usesFullModelApproximation = isExactRealtimeJob || isStaticFullFrameProbe; if (cancellationRequested()) { @@ -3232,7 +3257,7 @@ namespace gladius::ui cl::Event renderEvent{}; bool advanced = false; - if (isExactRealtimeJob) + if (usesFullModelApproximation) { auto assembly = m_document ? m_document->getAssembly() : nullptr; if (assembly && m_core->isRendererReady()) @@ -3268,7 +3293,7 @@ namespace gladius::ui result.completedLine = advanced ? endLine : job.startLine; result.completedFrame = result.completedLine >= job.height; - if (!advanced && isExactRealtimeJob) + if (!advanced && usesFullModelApproximation) { result.cancelled = true; } diff --git a/gladius/src/ui/RenderWindow.h b/gladius/src/ui/RenderWindow.h index 60c200df..8818f1d3 100644 --- a/gladius/src/ui/RenderWindow.h +++ b/gladius/src/ui/RenderWindow.h @@ -183,9 +183,11 @@ namespace gladius::ui bool scheduleAsyncRenderJob( RenderWindowState & state, async_rendering::RenderTaskRequest const * coordinatorTask = nullptr); - bool scheduleRealtimeRenderJob( + bool scheduleFullFrameRenderJob( RenderWindowState & state, - async_rendering::RenderTaskRequest const * coordinatorTask = nullptr); + async_rendering::RenderTaskRequest const * coordinatorTask = nullptr, + async_rendering::RenderJobType jobType = + async_rendering::RenderJobType::RealtimeHighQuality); coro::task executeAsyncRenderJob( async_rendering::RenderJob const & job, async_rendering::AsyncRenderController::CancelCheck const & cancelCheck); diff --git a/gladius/src/ui/render/AsyncRenderController.cpp b/gladius/src/ui/render/AsyncRenderController.cpp index 8332bf60..3fc44b73 100644 --- a/gladius/src/ui/render/AsyncRenderController.cpp +++ b/gladius/src/ui/render/AsyncRenderController.cpp @@ -357,17 +357,18 @@ namespace gladius::ui::async_rendering // (camera moved), so the GPU is released quickly for realtime rendering. // Realtime jobs are intentionally excluded: they complete fast and are always shown // even with a stale view (allowForcedRealtimeStaleView = true). - bool const isHqProgressiveJob = (job.type == RenderJobType::HighQuality); + bool const needsCurrentViewBeforeLaunch = job.type == RenderJobType::HighQuality || + job.type == RenderJobType::StaticFullFrameProbe; auto cancellationCheck = [data, jobEpoch = job.epoch, jobViewEpoch = job.viewEpoch, - isHqProgressiveJob]() -> bool + needsCurrentViewBeforeLaunch]() -> bool { if (data->shutdownRequested.load(std::memory_order_acquire)) return true; if (data->latestEpoch.load(std::memory_order_acquire) > jobEpoch) return true; - if (isHqProgressiveJob && jobViewEpoch > 0 && + if (needsCurrentViewBeforeLaunch && jobViewEpoch > 0 && data->latestViewEpoch.load(std::memory_order_acquire) > jobViewEpoch) return true; return false; @@ -385,10 +386,10 @@ namespace gladius::ui::async_rendering continue; } - bool const staleProgressiveView = isHqProgressiveJob && job.viewEpoch > 0 && - data->latestViewEpoch.load(std::memory_order_acquire) > - job.viewEpoch; - if (staleProgressiveView) + auto const latestViewEpoch = data->latestViewEpoch.load(std::memory_order_acquire); + bool const staleViewBeforeLaunch = needsCurrentViewBeforeLaunch && job.viewEpoch > 0 && + latestViewEpoch > job.viewEpoch; + if (staleViewBeforeLaunch) { publishResult(*data, makeCancelledResult(job)); continue; diff --git a/gladius/src/ui/render/AsyncRenderTypes.h b/gladius/src/ui/render/AsyncRenderTypes.h index 2d801be5..0eda95bc 100644 --- a/gladius/src/ui/render/AsyncRenderTypes.h +++ b/gladius/src/ui/render/AsyncRenderTypes.h @@ -48,6 +48,7 @@ namespace gladius::ui::async_rendering { HighQuality, RealtimeHighQuality, + StaticFullFrameProbe, LowResPreview, StreamingPreview, // Continuous preview loop during parameter drag BoundingBoxUpdate, diff --git a/gladius/src/ui/render/RenderUpdateCoordinator.h b/gladius/src/ui/render/RenderUpdateCoordinator.h index ee0488df..d129cf86 100644 --- a/gladius/src/ui/render/RenderUpdateCoordinator.h +++ b/gladius/src/ui/render/RenderUpdateCoordinator.h @@ -293,6 +293,7 @@ namespace gladius::ui::async_rendering m_sdfStamp = result.stamp; break; case RenderTaskType::RealtimeFullFrame: + case RenderTaskType::StaticFullFrameProbe: case RenderTaskType::ProgressiveHighQualityChunk: case RenderTaskType::LowResolutionPreview: case RenderTaskType::StreamingPreview: @@ -300,7 +301,8 @@ namespace gladius::ui::async_rendering { if (result.completedFrame && (result.type == RenderTaskType::ProgressiveHighQualityChunk || - result.type == RenderTaskType::RealtimeFullFrame)) + result.type == RenderTaskType::RealtimeFullFrame || + result.type == RenderTaskType::StaticFullFrameProbe)) { m_highQualityFrameStamp = result.stamp; } @@ -337,6 +339,7 @@ namespace gladius::ui::async_rendering case RenderTaskType::SdfPrecomputation: return RenderStampMask::heavyGeometryTask(); case RenderTaskType::RealtimeFullFrame: + case RenderTaskType::StaticFullFrameProbe: case RenderTaskType::ProgressiveHighQualityChunk: case RenderTaskType::LowResolutionPreview: case RenderTaskType::StreamingPreview: @@ -522,7 +525,7 @@ namespace gladius::ui::async_rendering } if (m_realtime.canAttemptStaticFullFrame(m_width, m_height, m_realtimeGuards)) { - startTask(decision, RenderTaskType::RealtimeFullFrame, m_latestStamp); + startTask(decision, RenderTaskType::StaticFullFrameProbe, m_latestStamp); return; } startTask(decision, RenderTaskType::ProgressiveHighQualityChunk, m_latestStamp); diff --git a/gladius/src/ui/render/RenderUpdateTypes.h b/gladius/src/ui/render/RenderUpdateTypes.h index d014f9e2..0b8ff98b 100644 --- a/gladius/src/ui/render/RenderUpdateTypes.h +++ b/gladius/src/ui/render/RenderUpdateTypes.h @@ -123,6 +123,7 @@ namespace gladius::ui::async_rendering enum class RenderTaskType { RealtimeFullFrame, + StaticFullFrameProbe, ProgressiveHighQualityChunk, LowResolutionPreview, StreamingPreview, diff --git a/gladius/src/ui/render/RenderWorkflowController.h b/gladius/src/ui/render/RenderWorkflowController.h index 96042c45..215a0292 100644 --- a/gladius/src/ui/render/RenderWorkflowController.h +++ b/gladius/src/ui/render/RenderWorkflowController.h @@ -158,6 +158,7 @@ namespace gladius::ui::async_rendering [[nodiscard]] static constexpr bool isDisplayTask(RenderTaskType type) noexcept { return type == RenderTaskType::RealtimeFullFrame || + type == RenderTaskType::StaticFullFrameProbe || type == RenderTaskType::ProgressiveHighQualityChunk || type == RenderTaskType::LowResolutionPreview || type == RenderTaskType::StreamingPreview; @@ -170,6 +171,8 @@ namespace gladius::ui::async_rendering { case RenderTaskType::RealtimeFullFrame: return FramePresentationSource::ExactRealtime; + case RenderTaskType::StaticFullFrameProbe: + return FramePresentationSource::ProgressiveHighQuality; case RenderTaskType::LowResolutionPreview: return FramePresentationSource::LowResolutionPreview; case RenderTaskType::StreamingPreview: @@ -191,6 +194,7 @@ namespace gladius::ui::async_rendering switch (result.type) { case RenderTaskType::RealtimeFullFrame: + case RenderTaskType::StaticFullFrameProbe: return FramePresentationQuality::FullQuality; case RenderTaskType::ProgressiveHighQualityChunk: return result.completedFrame ? FramePresentationQuality::FullQuality diff --git a/gladius/tests/unittests/ui/render/AsyncRenderController_tests.cpp b/gladius/tests/unittests/ui/render/AsyncRenderController_tests.cpp index 8ad89f72..bbc30dba 100644 --- a/gladius/tests/unittests/ui/render/AsyncRenderController_tests.cpp +++ b/gladius/tests/unittests/ui/render/AsyncRenderController_tests.cpp @@ -11,14 +11,17 @@ namespace gladius::ui::async_rendering::tests { namespace { - [[nodiscard]] RenderJob makeHighQualityJob(uint64_t epoch, uint64_t viewEpoch) + [[nodiscard]] RenderJob makeRenderJob( + uint64_t epoch, + uint64_t viewEpoch, + RenderJobType type = RenderJobType::HighQuality) { RenderStamp stamp{}; stamp.viewEpoch = viewEpoch; return RenderJob{.epoch = epoch, .viewEpoch = viewEpoch, .frameHint = 42, - .type = RenderJobType::HighQuality, + .type = type, .width = 640, .height = 480, .startLine = 16, @@ -47,8 +50,8 @@ namespace gladius::ui::async_rendering::tests AsyncRenderController controller; std::atomic executions{0}; controller.setJobExecutor( - [&executions](RenderJob const & job, - AsyncRenderController::CancelCheck const &) -> coro::task + [&executions](RenderJob const & job, + AsyncRenderController::CancelCheck const &) -> coro::task { executions.fetch_add(1, std::memory_order_relaxed); co_return FrameResultMeta{.frameId = job.frameHint, @@ -65,7 +68,7 @@ namespace gladius::ui::async_rendering::tests controller.start(); controller.setLatestViewEpoch(2); - controller.enqueueJob(makeHighQualityJob(1, 1)); + controller.enqueueJob(makeRenderJob(1, 1)); auto const result = waitForResult(controller); controller.stop(); @@ -83,8 +86,8 @@ namespace gladius::ui::async_rendering::tests AsyncRenderController controller; std::atomic executions{0}; controller.setJobExecutor( - [&executions](RenderJob const & job, - AsyncRenderController::CancelCheck const &) -> coro::task + [&executions](RenderJob const & job, + AsyncRenderController::CancelCheck const &) -> coro::task { executions.fetch_add(1, std::memory_order_relaxed); co_return FrameResultMeta{.frameId = job.frameHint, @@ -95,7 +98,7 @@ namespace gladius::ui::async_rendering::tests controller.start(); controller.setLatestEpoch(2); - controller.enqueueJob(makeHighQualityJob(1, 1)); + controller.enqueueJob(makeRenderJob(1, 1)); auto const result = waitForResult(controller); controller.stop(); @@ -111,8 +114,8 @@ namespace gladius::ui::async_rendering::tests AsyncRenderController controller; std::atomic executions{0}; controller.setJobExecutor( - [&executions](RenderJob const & job, - AsyncRenderController::CancelCheck const &) -> coro::task + [&executions](RenderJob const & job, + AsyncRenderController::CancelCheck const &) -> coro::task { executions.fetch_add(1, std::memory_order_relaxed); co_return FrameResultMeta{.frameId = job.frameHint, @@ -123,8 +126,8 @@ namespace gladius::ui::async_rendering::tests controller.start(); controller.setLatestViewEpoch(3); - controller.enqueueJob(makeHighQualityJob(1, 1)); - controller.enqueueJob(makeHighQualityJob(1, 2)); + controller.enqueueJob(makeRenderJob(1, 1)); + controller.enqueueJob(makeRenderJob(1, 2)); auto const firstResult = waitForResult(controller); auto const secondResult = waitForResult(controller); @@ -139,13 +142,41 @@ namespace gladius::ui::async_rendering::tests EXPECT_EQ(executions.load(std::memory_order_relaxed), 0); } + TEST(AsyncRenderController, Worker_WithStaleViewStaticFullFrameProbe_CancelsBeforeExecutor) + { + AsyncRenderController controller; + std::atomic executions{0}; + controller.setJobExecutor( + [&executions](RenderJob const & job, + AsyncRenderController::CancelCheck const &) -> coro::task + { + executions.fetch_add(1, std::memory_order_relaxed); + co_return FrameResultMeta{.frameId = job.frameHint, + .epoch = job.epoch, + .viewEpoch = job.viewEpoch, + .jobType = job.type}; + }); + controller.start(); + controller.setLatestViewEpoch(2); + + controller.enqueueJob(makeRenderJob(1, 1, RenderJobType::StaticFullFrameProbe)); + + auto const result = waitForResult(controller); + controller.stop(); + + ASSERT_TRUE(result.has_value()); + EXPECT_TRUE(result->cancelled); + EXPECT_EQ(result->jobType, RenderJobType::StaticFullFrameProbe); + EXPECT_EQ(executions.load(std::memory_order_relaxed), 0); + } + TEST(AsyncRenderController, Worker_WithCurrentProgressiveJob_Executes) { AsyncRenderController controller; std::atomic executions{0}; controller.setJobExecutor( - [&executions](RenderJob const & job, - AsyncRenderController::CancelCheck const &) -> coro::task + [&executions](RenderJob const & job, + AsyncRenderController::CancelCheck const &) -> coro::task { executions.fetch_add(1, std::memory_order_relaxed); co_return FrameResultMeta{.frameId = job.frameHint, @@ -162,7 +193,7 @@ namespace gladius::ui::async_rendering::tests controller.start(); controller.setLatestViewEpoch(1); - controller.enqueueJob(makeHighQualityJob(1, 1)); + controller.enqueueJob(makeRenderJob(1, 1)); auto const result = waitForResult(controller); controller.stop(); diff --git a/gladius/tests/unittests/ui/render/RenderUpdateCoordinator_tests.cpp b/gladius/tests/unittests/ui/render/RenderUpdateCoordinator_tests.cpp index 6c56cac9..23edb1c7 100644 --- a/gladius/tests/unittests/ui/render/RenderUpdateCoordinator_tests.cpp +++ b/gladius/tests/unittests/ui/render/RenderUpdateCoordinator_tests.cpp @@ -103,7 +103,7 @@ namespace gladius::ui::async_rendering::tests ASSERT_TRUE(hq.has_value()); coordinator.recordStaticProgressiveSample(progressiveChunkSample(5.0f)); decision = coordinator.completeTask(completed(*hq)); - auto staticFullFrame = findStartedTask(decision, RenderTaskType::RealtimeFullFrame); + auto staticFullFrame = findStartedTask(decision, RenderTaskType::StaticFullFrameProbe); ASSERT_TRUE(staticFullFrame.has_value()); coordinator.recordStaticFullFrameSample(fullFrameSample(50.0f)); [[maybe_unused]] auto const finalDecision = @@ -163,7 +163,8 @@ namespace gladius::ui::async_rendering::tests decision = coordinator.completeTask(completed(*progressive)); - EXPECT_TRUE(hasStartedTask(decision, RenderTaskType::RealtimeFullFrame)); + EXPECT_TRUE(hasStartedTask(decision, RenderTaskType::StaticFullFrameProbe)); + EXPECT_FALSE(hasStartedTask(decision, RenderTaskType::RealtimeFullFrame)); EXPECT_FALSE(hasStartedTask(decision, RenderTaskType::ProgressiveHighQualityChunk)); } @@ -186,6 +187,7 @@ namespace gladius::ui::async_rendering::tests decision = coordinator.completeTask(completed(*progressive)); + EXPECT_FALSE(hasStartedTask(decision, RenderTaskType::StaticFullFrameProbe)); EXPECT_FALSE(hasStartedTask(decision, RenderTaskType::RealtimeFullFrame)); EXPECT_TRUE(hasStartedTask(decision, RenderTaskType::ProgressiveHighQualityChunk)); } @@ -207,11 +209,12 @@ namespace gladius::ui::async_rendering::tests coordinator.recordStaticProgressiveSample(progressiveChunkSample(5.0f)); decision = coordinator.completeTask(completed(*progressive)); - ASSERT_TRUE(hasStartedTask(decision, RenderTaskType::RealtimeFullFrame)); + ASSERT_TRUE(hasStartedTask(decision, RenderTaskType::StaticFullFrameProbe)); EXPECT_FALSE(coordinator.isRealtimeActive()); decision = coordinator.notifyCameraChanged(); + EXPECT_FALSE(hasStartedTask(decision, RenderTaskType::StaticFullFrameProbe)); EXPECT_FALSE(hasStartedTask(decision, RenderTaskType::RealtimeFullFrame)); EXPECT_TRUE(hasStartedTask(decision, RenderTaskType::LowResolutionPreview)); } diff --git a/gladius/tests/unittests/ui/render/RenderWorkflowController_tests.cpp b/gladius/tests/unittests/ui/render/RenderWorkflowController_tests.cpp index 4f872f30..cb87d9f0 100644 --- a/gladius/tests/unittests/ui/render/RenderWorkflowController_tests.cpp +++ b/gladius/tests/unittests/ui/render/RenderWorkflowController_tests.cpp @@ -48,6 +48,10 @@ namespace gladius::ui::async_rendering::tests { return realtime; } + if (auto staticProbe = findStartedTask(decision, RenderTaskType::StaticFullFrameProbe)) + { + return staticProbe; + } if (auto progressive = findStartedTask(decision, RenderTaskType::ProgressiveHighQualityChunk)) { return progressive; From f9a9291ffdc5c35cf9dcef55d8be36852921e953 Mon Sep 17 00:00:00 2001 From: Jan Orend <56254096+3dJan@users.noreply.github.com> Date: Sun, 17 May 2026 14:23:53 +0200 Subject: [PATCH 24/72] [RenderWindow] Implement waitForEventBlocking to handle OpenCL event completion with polling. Refactor event handling logic to improve stability and prevent race conditions. Update buffer management to avoid premature recycling of GPU-owned resources --- gladius/src/ui/RenderWindow.cpp | 280 ++++++++---------- .../src/ui/render/AsyncRenderController.cpp | 28 +- ...syncRenderControllerPresentation_tests.cpp | 17 +- 3 files changed, 163 insertions(+), 162 deletions(-) diff --git a/gladius/src/ui/RenderWindow.cpp b/gladius/src/ui/RenderWindow.cpp index 3c3a9ebf..17ba1d05 100644 --- a/gladius/src/ui/RenderWindow.cpp +++ b/gladius/src/ui/RenderWindow.cpp @@ -6,6 +6,7 @@ #include #include #include +#include #include "../CLMath.h" #include "../ComputeContext.h" @@ -114,6 +115,37 @@ namespace gladius::ui co_await ClEventAwaiter(std::move(event)); } + [[nodiscard]] bool waitForEventBlocking(cl::Event const & event) + { + if (!event()) + { + return false; + } + + while (true) + { + try + { + cl_int status = CL_QUEUED; + event.getInfo(CL_EVENT_COMMAND_EXECUTION_STATUS, &status); + if (status == CL_COMPLETE) + { + return true; + } + if (status < 0) + { + return false; + } + } + catch (...) + { + return false; + } + + std::this_thread::sleep_for(std::chrono::microseconds(500)); + } + } + constexpr size_t kInitialProgressiveStepSize = 8; constexpr float kAdaptivePreviewTargetFrameTimeMs = 25.0f; constexpr float kAdaptivePreviewMinErrorMs = 0.5f; @@ -306,13 +338,6 @@ namespace gladius::ui auto const newEpoch = m_asyncEpochCounter.fetch_add(1, std::memory_order_acq_rel) + 1; m_asyncCurrentEpoch.store(newEpoch, std::memory_order_release); m_asyncViewEpoch.fetch_add(1, std::memory_order_acq_rel); - m_asyncInFlightEpoch.store(0, std::memory_order_release); - m_asyncInFlightViewEpoch.store(0, std::memory_order_release); - m_asyncJobInFlight.store(false, std::memory_order_release); - m_asyncRealtimeJobInFlight.store(false, std::memory_order_release); - m_asyncSdfJobInFlight.store(false, std::memory_order_release); - m_asyncSdfInFlightEpoch.store(0, std::memory_order_release); - m_asyncBboxJobInFlight.store(false, std::memory_order_release); // Invalidate distance init buffer on epoch change - camera/scene changed (FR-005) if (m_core) @@ -320,20 +345,10 @@ namespace gladius::ui m_core->invalidateDistanceInitBuffer(); } - // Release progressive buffer on epoch change - if (m_asyncProgressiveBuffer) - { - [[maybe_unused]] bool const released = - m_asyncController->tryTransitionBuffer(m_asyncProgressiveBuffer, - async_rendering::FrameState::Writing, - async_rendering::FrameState::Idle); - m_asyncProgressiveBuffer = nullptr; - } - m_asyncProgressiveEpoch.store(0, std::memory_order_release); - m_asyncProgressiveViewEpoch.store(0, std::memory_order_release); - - // IMPORTANT: Release any other Writing buffers from old epoch - // (they belong to cancelled jobs and should be returned to Idle) + // Do not release Writing buffers or clear in-flight render flags here. Once an OpenCL + // kernel has been submitted, the buffer remains GPU-owned until the worker observes the + // completion event. Recycling it early can race a later render/parameter upload against + // memory still used by the old kernel, which is illegal on several OpenCL stacks. if (m_asyncController) { m_asyncController->releaseStaleBuffers(oldEpoch); @@ -2661,6 +2676,17 @@ namespace gladius::ui { m_renderUpdateCoordinator.recordRealtimeRejectedAttempt(); } + if (m_asyncProgressiveBuffer && + m_asyncProgressiveEpoch.load(std::memory_order_acquire) == result.epoch) + { + [[maybe_unused]] bool const released = + m_asyncController->tryTransitionBuffer(m_asyncProgressiveBuffer, + async_rendering::FrameState::Writing, + async_rendering::FrameState::Idle); + m_asyncProgressiveBuffer = nullptr; + m_asyncProgressiveEpoch.store(0, std::memory_order_release); + m_asyncProgressiveViewEpoch.store(0, std::memory_order_release); + } if (result.epoch == m_asyncInFlightEpoch.load(std::memory_order_acquire)) { m_asyncJobInFlight.store(false, std::memory_order_release); @@ -2973,6 +2999,13 @@ namespace gladius::ui } cl::CommandQueue const & queue = *queueOwner; + auto computeToken = m_core->requestComputeToken(); + if (!computeToken.has_value()) + { + releaseBuffer(); + return false; + } + auto settings = m_core->getResourceContext()->getRenderingSettings(); settings.approximation = AM_FULL_MODEL; @@ -2991,7 +3024,11 @@ namespace gladius::ui // Block until the GPU is done. In realtime mode the user has opted into lower // peak throughput in exchange for zero-latency feedback, and Auto mode only takes // this path once performance has been measured to fit within the frame budget. - renderEvent.wait(); + if (!waitForEventBlocking(renderEvent)) + { + releaseBuffer(); + return false; + } auto const endTime = std::chrono::steady_clock::now(); @@ -3137,9 +3174,7 @@ namespace gladius::ui -> coro::task { // NOTE: No ZoneScoped here — Tracy zones are not coroutine-safe across thread hops. - // co_await waitForEvent() resumes on the OpenCL callback thread, so the zone destructor - // would fire on a different thread than where it was created. - // Use the inner ZoneScopedN blocks (which don't cross co_await) for profiling. + // Keep profiling scopes inside non-suspending blocks only. #ifdef TRACY_ENABLE tracy::SetThreadName("AsyncRenderWorker"); #endif @@ -3254,48 +3289,56 @@ namespace gladius::ui { ImageRGBA & targetCLBuffer = *writeBuffer->image; - cl::Event renderEvent{}; - bool advanced = false; - - if (usesFullModelApproximation) + auto computeToken = m_core->requestComputeToken(); + if (!computeToken.has_value()) { - auto assembly = m_document ? m_document->getAssembly() : nullptr; - if (assembly && m_core->isRendererReady()) - { - (void) m_core->tryToupdateParameter(*assembly); - } - - auto settings = m_core->getResourceContext()->getRenderingSettings(); - settings.approximation = AM_FULL_MODEL; - advanced = m_core->renderSceneComputeOnlyWithSettings( - commandQueue, - job.startLine, - endLine, - targetCLBuffer, - settings, - &renderEvent); + result.cancelled = true; } else { - advanced = m_core->renderSceneComputeOnly( - commandQueue, job.startLine, endLine, targetCLBuffer, &renderEvent); - } + cl::Event renderEvent{}; + bool advanced = false; - if (renderEvent()) - { - co_await waitForEvent(renderEvent, cancelCheck); - } + if (usesFullModelApproximation) + { + auto assembly = m_document ? m_document->getAssembly() : nullptr; + if (assembly && m_core->isRendererReady()) + { + (void) m_core->tryToupdateParameter(*assembly); + } - if (cancellationRequested()) - { - result.cancelled = true; - } + auto settings = m_core->getResourceContext()->getRenderingSettings(); + settings.approximation = AM_FULL_MODEL; + advanced = m_core->renderSceneComputeOnlyWithSettings( + commandQueue, + job.startLine, + endLine, + targetCLBuffer, + settings, + &renderEvent); + } + else + { + advanced = m_core->renderSceneComputeOnly( + commandQueue, job.startLine, endLine, targetCLBuffer, &renderEvent); + } - result.completedLine = advanced ? endLine : job.startLine; - result.completedFrame = result.completedLine >= job.height; - if (!advanced && usesFullModelApproximation) - { - result.cancelled = true; + if (renderEvent()) + { + advanced = waitForEventBlocking(renderEvent) && advanced; + } + + if (cancellationRequested()) + { + result.cancelled = true; + } + + result.completedLine = advanced ? endLine : job.startLine; + result.completedFrame = result.completedLine >= job.height; + if (!advanced && usesFullModelApproximation) + { + result.cancelled = true; + } } } else @@ -3879,7 +3922,7 @@ namespace gladius::ui -> coro::task { // NOTE: No ZoneScoped here — Tracy zones are not coroutine-safe across thread hops. - // co_await waitForEvent() resumes on the OpenCL callback thread. + // Keep profiling scopes inside non-suspending blocks only. using namespace async_rendering; @@ -3937,13 +3980,12 @@ namespace gladius::ui co_return result; } - // Launch async SDF precomputation (returns cl::Event) + // Launch async SDF precomputation (returns cl::Event). Keep the compute token until + // the GPU event completes: the kernel reads/writes shared resource buffers and those + // buffers must not be reallocated or rewritten by another host path while the event is + // still running. cl::Event sdfEvent = m_core->precomputeSdfAsync(queueRef); - // Release the compute token — the kernel is already enqueued and the - // CL runtime retains its own references to the memory objects. - computeToken.reset(); - if (!sdfEvent()) { // Empty event: either SDF is already valid or preconditions failed. @@ -3960,11 +4002,10 @@ namespace gladius::ui co_return result; } - // Await SDF completion using waitForEvent helper - co_await waitForEvent(sdfEvent, cancelCheck); + bool const sdfCompleted = waitForEventBlocking(sdfEvent); // Check if cancelled during wait - don't invalidate, keep old SDF - if (cancelCheck && cancelCheck()) + if (!sdfCompleted || (cancelCheck && cancelCheck())) { result.cancelled = true; co_return result; @@ -4179,52 +4220,12 @@ namespace gladius::ui co_return result; } - // Wait for GPU completion using polling with timeout instead of blocking wait. - // Some OpenCL implementations (e.g., rusticl) have issues with event.wait(). - // Poll with short sleeps and timeout to avoid hangs. - auto const pollStart = std::chrono::steady_clock::now(); - auto constexpr timeout = std::chrono::milliseconds(500); - bool eventCompleted = false; - - while (!eventCompleted) + bool const eventCompleted = waitForEventBlocking(renderEvent); + if (!eventCompleted) { - try - { - cl_int status = CL_QUEUED; - renderEvent.getInfo(CL_EVENT_COMMAND_EXECUTION_STATUS, &status); - - if (status == CL_COMPLETE) - { - eventCompleted = true; - } - else if (status < 0) - { - // Error status - result.cancelled = true; - m_asyncPreviewJobInFlight.store(false, std::memory_order_release); - co_return result; - } - else - { - // Check timeout - auto const elapsed = std::chrono::steady_clock::now() - pollStart; - if (elapsed > timeout) - { - result.cancelled = true; - m_asyncPreviewJobInFlight.store(false, std::memory_order_release); - co_return result; - } - - // Sleep to avoid busy-waiting (500us reduces CPU usage while staying responsive) - std::this_thread::sleep_for(std::chrono::microseconds(500)); - } - } - catch (std::exception const &) - { - result.cancelled = true; - m_asyncPreviewJobInFlight.store(false, std::memory_order_release); - co_return result; - } + result.cancelled = true; + m_asyncPreviewJobInFlight.store(false, std::memory_order_release); + co_return result; } // Only check for shutdown, not epoch-based cancellation @@ -4714,6 +4715,14 @@ namespace gladius::ui auto const iterStart = std::chrono::steady_clock::now(); + auto computeToken = m_core->requestComputeToken(); + if (!computeToken.has_value()) + { + m_streamingFrameConsumed.store(true, std::memory_order_release); + std::this_thread::sleep_for(std::chrono::milliseconds(1)); + continue; + } + // Push latest Assembly parameter values to GPU. // Skip when the renderer is not ready (compilation in progress) — // refreshWorker modifies the Assembly without a dedicated lock, @@ -4732,6 +4741,7 @@ namespace gladius::ui { // Precondition failed — program not valid or similar. // Retry a few times in case compilation is finishing. + m_streamingFrameConsumed.store(true, std::memory_order_release); ++consecutiveFailures; if (consecutiveFailures >= kMaxConsecutiveFailures) { @@ -4741,49 +4751,11 @@ namespace gladius::ui continue; } - // Poll GPU completion. Use a generous timeout for driver hiccups, - // but preview rendering itself never falls back to full-model SDF. - auto constexpr timeout = std::chrono::milliseconds(5000); - auto const pollStart = std::chrono::steady_clock::now(); - bool eventCompleted = false; - - while (!eventCompleted) - { - try - { - cl_int status = CL_QUEUED; - renderEvent.getInfo(CL_EVENT_COMMAND_EXECUTION_STATUS, &status); - - if (status == CL_COMPLETE) - { - eventCompleted = true; - } - else if (status < 0) - { - break; // Error - } - else - { - if (std::chrono::steady_clock::now() - pollStart > timeout) - { - break; // Timeout - } - std::this_thread::sleep_for(std::chrono::microseconds(500)); - } - } - catch (std::exception const &) - { - eventCompleted = false; - break; - } - } + bool const eventCompleted = waitForEventBlocking(renderEvent); if (!eventCompleted) { - // GPU work timed out or errored — flush (not finish!) so - // we don't block the coroutine indefinitely on a GPU stall. - // The next enqueue will be ordered after pending work. - try { commandQueue.flush(); } catch (...) {} + m_streamingFrameConsumed.store(true, std::memory_order_release); ++consecutiveFailures; if (consecutiveFailures >= kMaxConsecutiveFailures) { diff --git a/gladius/src/ui/render/AsyncRenderController.cpp b/gladius/src/ui/render/AsyncRenderController.cpp index 3fc44b73..f44f738f 100644 --- a/gladius/src/ui/render/AsyncRenderController.cpp +++ b/gladius/src/ui/render/AsyncRenderController.cpp @@ -301,9 +301,8 @@ namespace gladius::ui::async_rendering { // NOTE: No ZoneScoped / DebugText / ZoneScopedN in the outer coroutine scope here. // This coroutine crosses thread boundaries via co_await pop() (push() resumes the consumer - // inline on the caller's thread) and co_await waitForEvent() (resumes on the OpenCL - // callback thread). Tracy zones must begin and end on the same OS thread — any zone that - // spans a co_await that changes threads will corrupt Tracy's per-thread zone stack. + // inline on the caller's thread). Tracy zones must begin and end on the same OS thread — + // any zone that spans a co_await that changes threads will corrupt Tracy's per-thread zone stack. // Inner ZoneScopedN blocks (inside the job executors, which don't cross co_await) are fine. #ifdef TRACY_ENABLE tracy::SetThreadName("AsyncRenderWorker"); @@ -431,6 +430,20 @@ namespace gladius::ui::async_rendering if (m_stagingWidth != width || m_stagingHeight != height || !m_workerQueue || !m_stagingBuffer) { + std::lock_guard lock(m_bufferMutex); + bool const hasGpuOwnedFrame = std::any_of( + m_frameBuffers.begin(), + m_frameBuffers.end(), + [](FrameBuffer const & buffer) + { + auto const state = buffer.state.load(std::memory_order_acquire); + return state == FrameState::Writing || state == FrameState::Resampling; + }); + if (hasGpuOwnedFrame) + { + return; + } + m_stagingBuffer.reset(); { std::lock_guard queueLock(m_workerQueueMutex); @@ -451,7 +464,6 @@ namespace gladius::ui::async_rendering m_stagingHeight = height; // Initialize frame buffers with GLImageBuffer instances - std::lock_guard lock(m_bufferMutex); for (size_t i = 0; i < m_frameBuffers.size(); ++i) { auto & fb = m_frameBuffers[i]; @@ -851,11 +863,13 @@ namespace gladius::ui::async_rendering auto const state = buffer.state.load(std::memory_order_acquire); auto const bufEpoch = buffer.epoch.load(std::memory_order_acquire); - // Release Writing buffers from old epochs - if (state == FrameState::Writing && bufEpoch <= oldEpoch) + // Ready frames are already complete and may be discarded when their epoch is stale. + // Writing/Resampling frames are still owned by an OpenCL kernel or the UI upload path; + // recycling them here can cause use-after-free/racing writes on the GPU. + if (state == FrameState::Ready && bufEpoch <= oldEpoch) { [[maybe_unused]] bool const released = - tryTransitionBufferLocked(&buffer, FrameState::Writing, FrameState::Idle); + tryTransitionBufferLocked(&buffer, FrameState::Ready, FrameState::Idle); } } } diff --git a/gladius/tests/unittests/ui/render/AsyncRenderControllerPresentation_tests.cpp b/gladius/tests/unittests/ui/render/AsyncRenderControllerPresentation_tests.cpp index 083980e6..c15f7614 100644 --- a/gladius/tests/unittests/ui/render/AsyncRenderControllerPresentation_tests.cpp +++ b/gladius/tests/unittests/ui/render/AsyncRenderControllerPresentation_tests.cpp @@ -92,7 +92,7 @@ namespace gladius::ui::async_rendering::tests EXPECT_EQ(reacquired, buffer); } - TEST(AsyncRenderControllerPresentation, ReleaseStaleBuffers_WithWritingBuffer_ReleasesIt) + TEST(AsyncRenderControllerPresentation, ReleaseStaleBuffers_WithWritingBuffer_KeepsItReserved) { AsyncRenderController controller; @@ -101,6 +101,21 @@ namespace gladius::ui::async_rendering::tests controller.releaseStaleBuffers(10); + EXPECT_EQ(buffer->state.load(std::memory_order_acquire), FrameState::Writing); + auto * reacquired = controller.acquireWriteBuffer(11); + EXPECT_NE(reacquired, buffer); + } + + TEST(AsyncRenderControllerPresentation, ReleaseStaleBuffers_WithReadyBuffer_ReleasesIt) + { + AsyncRenderController controller; + + auto * buffer = controller.acquireWriteBuffer(10); + ASSERT_NE(buffer, nullptr); + controller.publishFrame(buffer, 2, 10, 3, FramePresentationQuality::FullQuality); + + controller.releaseStaleBuffers(10); + EXPECT_EQ(buffer->state.load(std::memory_order_acquire), FrameState::Idle); auto * reacquired = controller.acquireWriteBuffer(11); EXPECT_EQ(reacquired, buffer); From cef61b322b189f8c203909d8b1d67a311b8794a3 Mon Sep 17 00:00:00 2001 From: Jan Orend <56254096+3dJan@users.noreply.github.com> Date: Sun, 17 May 2026 16:48:56 +0200 Subject: [PATCH 25/72] [GpuAccessCoordination] Add documentation for GPU access coordination, detailing OpenCL command queue management, core invariants, main types, published generations, conservative policies, safe mode, and kernel launch procedures --- docs/architecture/gpu_access_coordination.md | 53 ++++++++++++++++++++ 1 file changed, 53 insertions(+) create mode 100644 docs/architecture/gpu_access_coordination.md diff --git a/docs/architecture/gpu_access_coordination.md b/docs/architecture/gpu_access_coordination.md new file mode 100644 index 00000000..98ae5860 --- /dev/null +++ b/docs/architecture/gpu_access_coordination.md @@ -0,0 +1,53 @@ +# GPU Access Coordination + +Gladius uses multiple OpenCL command queues: one lazily-created queue per calling thread plus explicit worker queues for async rendering. OpenCL queues do not implicitly synchronize with each other, so shared buffers/images must be ordered explicitly. + +## Core invariant + +Every shared OpenCL memory-object access across queues must be ordered by one of: + +1. an event wait list produced by `GpuAccessCoordinator`, or +2. an explicit host wait that is reported back to the coordinator. + +Host-side mutexes are not sufficient unless they are held until the GPU command using the protected resource has completed. + +## Main types + +- `GpuAccessCoordinator` is the pure, GPU-free state model. It tracks resource IDs, generations, read/write leases, fake/production event IDs, stale generations, and deferred retirement. +- `OpenClEventRegistry` maps coordinator event IDs to `cl::Event` objects and polls or waits for completion. +- `GpuKernelAccessGuard` is the production RAII bridge. It begins one or more resource accesses, converts dependency IDs to OpenCL wait lists, and records the returned kernel event when the enqueue succeeds. +- `RenderPayloadSnapshot` captures the generation handles for the common render payload: primitive metadata, primitive data, precomputed SDF, parameter buffer, and command buffer. + +## Published generations + +Each `Buffer` and `ImageImpl` owns a stable coordinator resource ID and a current generation. Host writes, buffer reallocations, image reallocations, and clears retire the previous generation before mutating or releasing the backing OpenCL object. + +A kernel launch should use the generation handles captured at launch time. If a later host write advances a generation, stale snapshots can be rejected instead of silently reading mixed old/new state. + +## Conservative first-slice policy + +The first implementation is whole-resource based: + +- reads can overlap with reads; +- writes wait for previous readers and writers; +- reads wait for the previous writer; +- `Primitives::data` is tracked as one conservative resource even though it contains several logical regions; +- old generations are not released until known events have completed. + +This is intentionally safer than region-level aliasing. Region-level optimization can be added later once workflow tests prove the invariants. + +The current production rollout also prefers conservative generation retirement over immediate physical double-buffering. Host writes and reallocations wait for tracked readers/writers before mutating the backing object, so the implementation is safe even when only one physical buffer exists. Targeted double buffers can still be introduced later for performance/overlap (`PreComputedSdf`, `DistanceInitBuffer`, low-res preview images, and small parameter/command buffers), but they are no longer required to prevent cross-queue memory-object races. + +## Safe mode + +Set `GLADIUS_GPU_ACCESS_SAFE_MODE=1` before launching Gladius to serialize guarded GPU work more aggressively. In safe mode, each guarded launch drains tracked GPU work before enqueue and waits for its own returned event before returning. This is slower, but useful for validating whether HSA/GPU faults are caused by dependency or lifetime races. + +## Adding a new kernel launch + +1. Identify every shared OpenCL buffer/image argument. +2. Capture the relevant generation handles before enqueue. +3. Create a `GpuKernelAccessGuard` with read/write modes. +4. Pass `guard.waitEvents()` into `CLProgram::runNonBlockingWithWaitList(...)`. +5. Call `guard.complete(returnedEvent)` immediately after a successful enqueue. + +If a launch cannot declare all shared resources yet, prefer a conservative whole-resource access over leaving the path untracked. From 270826d07ff34dcee09ae0f0fc68d9eae18c406e Mon Sep 17 00:00:00 2001 From: Jan Orend <56254096+3dJan@users.noreply.github.com> Date: Sun, 17 May 2026 16:49:14 +0200 Subject: [PATCH 26/72] [GpuAccessCoordinator] Implement GPU access coordination for resource management and access control. Introduce GpuAccessCoordinator class to manage resource registrations, access requests, and event tracking. Add GpuKernelAccessGuard for scoped access management during GPU operations. Enhance MeshPreparationProgram and FunctionEvaluatorTool to utilize new access management features. Create OpenClEventRegistry for event tracking and completion handling. Add unit tests for GpuAccessCoordinator to validate access workflows and resource state management --- gladius/src/Buffer.h | 54 ++- gladius/src/CLProgram.h | 34 +- gladius/src/ComputeContext.cpp | 148 ++++++ gladius/src/ComputeContext.h | 23 + gladius/src/ImageRGBA.h | 66 ++- gladius/src/MeshVoxelGridManager.cpp | 69 +-- gladius/src/MeshVoxelGridManager.h | 13 +- gladius/src/RenderProgram.cpp | 101 +++- gladius/src/ResourceContext.cpp | 7 + gladius/src/ResourceContext.h | 3 + gladius/src/SlicerProgram.cpp | 227 ++++++--- gladius/src/compute/GpuAccessCoordinator.cpp | 449 ++++++++++++++++++ gladius/src/compute/GpuAccessCoordinator.h | 181 +++++++ gladius/src/compute/GpuKernelAccessGuard.cpp | 180 +++++++ gladius/src/compute/GpuKernelAccessGuard.h | 54 +++ .../src/compute/MeshPreparationProgram.cpp | 140 ++++-- gladius/src/compute/OpenClEventRegistry.cpp | 123 +++++ gladius/src/compute/OpenClEventRegistry.h | 29 ++ gladius/src/compute/RenderPayloadSnapshot.h | 56 +++ .../src/mcp/tools/FunctionEvaluatorTool.cpp | 27 +- .../src/ui/render/AsyncRenderController.cpp | 8 +- .../compute/GpuAccessCoordinator_tests.cpp | 269 +++++++++++ 22 files changed, 2099 insertions(+), 162 deletions(-) create mode 100644 gladius/src/compute/GpuAccessCoordinator.cpp create mode 100644 gladius/src/compute/GpuAccessCoordinator.h create mode 100644 gladius/src/compute/GpuKernelAccessGuard.cpp create mode 100644 gladius/src/compute/GpuKernelAccessGuard.h create mode 100644 gladius/src/compute/OpenClEventRegistry.cpp create mode 100644 gladius/src/compute/OpenClEventRegistry.h create mode 100644 gladius/src/compute/RenderPayloadSnapshot.h create mode 100644 gladius/tests/unittests/compute/GpuAccessCoordinator_tests.cpp diff --git a/gladius/src/Buffer.h b/gladius/src/Buffer.h index 255d9ef3..768fa1fe 100644 --- a/gladius/src/Buffer.h +++ b/gladius/src/Buffer.h @@ -3,6 +3,7 @@ #include "ComputeContext.h" #include #include +#include #include #include @@ -14,19 +15,31 @@ namespace gladius public: explicit Buffer(ComputeContext & context) : m_ComputeContext(context) + , m_gpuResource(m_ComputeContext.gpuAccessCoordinator().registerResource( + GpuResourceKind::Buffer, typeid(T).name())) { } ~Buffer() { - // Release tracked bytes when buffer goes out of scope - if (m_buffer && m_size > 0) + try { - m_ComputeContext.onBufferReleased(sizeof(T) * m_size); + // Release tracked bytes only after known GPU users have completed. + if (m_buffer && m_size > 0) + { + m_ComputeContext.waitForGpuResourceIdle(m_gpuResource); + m_ComputeContext.onBufferReleased(sizeof(T) * m_size); + } + } + catch (...) + { + // Destructors must not throw. } } Buffer(Buffer const & other) : m_ComputeContext(other.m_ComputeContext) + , m_gpuResource(m_ComputeContext.gpuAccessCoordinator().registerResource( + GpuResourceKind::Buffer, typeid(T).name())) , m_data(other.m_data) { create(); @@ -44,6 +57,7 @@ namespace gladius { throw std::runtime_error("Failed to read, device buffer could not be created"); } + m_ComputeContext.waitForGpuResourceIdle(m_gpuResource); m_data.resize(m_size); CL_ERROR(m_ComputeContext.GetQueue().enqueueReadBuffer( *m_buffer.get(), CL_TRUE, 0, sizeof(T) * m_size, &m_data[0])); @@ -59,6 +73,7 @@ namespace gladius // If there was a previous allocation, release its accounting first if (m_buffer && m_size > 0) { + retireCurrentGpuGeneration(); m_ComputeContext.onBufferReleased(sizeof(T) * m_size); } @@ -73,6 +88,7 @@ namespace gladius m_data.clear(); if (m_buffer && m_size > 0) { + retireCurrentGpuGeneration(); m_ComputeContext.onBufferReleased(sizeof(T) * m_size); } m_size = 0; @@ -95,6 +111,7 @@ namespace gladius throw std::runtime_error("Failed to write, device buffer could not be created"); } + retireCurrentGpuGeneration(); CL_ERROR(m_ComputeContext.GetQueue().enqueueWriteBuffer( *m_buffer.get(), CL_TRUE, 0, sizeof(T) * m_data.size(), &m_data[0])); @@ -139,10 +156,39 @@ namespace gladius return *m_buffer.get(); } + [[nodiscard]] GpuResourceHandle gpuResourceHandle() const noexcept + { + return m_gpuResource; + } + private: + void retireCurrentGpuGeneration() + { + m_ComputeContext.waitForGpuResourceIdle(m_gpuResource); + + auto retirement = m_ComputeContext.gpuAccessCoordinator().retireCurrentGeneration( + m_gpuResource.resourceId); + if (retirement.status == GpuAccessStatus::PendingAccessWithoutEvent) + { + m_ComputeContext.waitForGpuResourceIdle(m_gpuResource); + retirement = m_ComputeContext.gpuAccessCoordinator().retireCurrentGeneration( + m_gpuResource.resourceId); + } + + if (!retirement.granted()) + { + throw std::runtime_error("Failed to retire GPU buffer generation safely"); + } + + m_ComputeContext.waitForGpuEvents(retirement.waitEvents); + m_ComputeContext.gpuAccessCoordinator().collectCompletedRetirements(); + m_gpuResource = retirement.newGeneration; + } + + ComputeContext & m_ComputeContext; + GpuResourceHandle m_gpuResource{}; std::vector m_data; size_t m_size = 0; - ComputeContext & m_ComputeContext; std::unique_ptr m_buffer; }; } \ No newline at end of file diff --git a/gladius/src/CLProgram.h b/gladius/src/CLProgram.h index 5ee59d49..ec5cbe72 100644 --- a/gladius/src/CLProgram.h +++ b/gladius/src/CLProgram.h @@ -114,6 +114,30 @@ namespace gladius cl::NDRange origin, cl::NDRange range, const ArgumentTypes &... args) + { + return runNonBlockingImpl(queue, methodName, origin, range, nullptr, args...); + } + + template + [[nodiscard]] cl::Event runNonBlockingWithWaitList(cl::CommandQueue const & queue, + const std::string & methodName, + cl::NDRange origin, + cl::NDRange range, + std::vector const & waitEvents, + const ArgumentTypes &... args) + { + auto const * waitList = waitEvents.empty() ? nullptr : &waitEvents; + return runNonBlockingImpl(queue, methodName, origin, range, waitList, args...); + } + + private: + template + [[nodiscard]] cl::Event runNonBlockingImpl(cl::CommandQueue const & queue, + const std::string & methodName, + cl::NDRange origin, + cl::NDRange range, + std::vector const * waitEvents, + const ArgumentTypes &... args) { ProfileFunction; @@ -279,8 +303,12 @@ namespace gladius cl::Event kernelEvent; try { - CL_ERROR( - queue.enqueueNDRangeKernel(m_kernels[methodName], origin, range, cl::NullRange, nullptr, &kernelEvent)); + CL_ERROR(queue.enqueueNDRangeKernel(m_kernels[methodName], + origin, + range, + cl::NullRange, + waitEvents, + &kernelEvent)); } catch (const OpenCLError & e) { @@ -291,6 +319,8 @@ namespace gladius return kernelEvent; } + public: + template void run(cl::CommandQueue const & queue, const std::string & methodName, diff --git a/gladius/src/ComputeContext.cpp b/gladius/src/ComputeContext.cpp index ca50d4eb..34ca264f 100644 --- a/gladius/src/ComputeContext.cpp +++ b/gladius/src/ComputeContext.cpp @@ -7,6 +7,7 @@ #include #include +#include #include #include #include @@ -369,18 +370,21 @@ namespace gladius } ComputeContext::ComputeContext() + : m_gpuAccessSafeModeEnabled(std::getenv("GLADIUS_GPU_ACCESS_SAFE_MODE") != nullptr) { initContext(); } ComputeContext::ComputeContext(EnableGLOutput enableOutput) : m_outputGL(enableOutput) + , m_gpuAccessSafeModeEnabled(std::getenv("GLADIUS_GPU_ACCESS_SAFE_MODE") != nullptr) { initContext(); } ComputeContext::ComputeContext(EnableGLOutput enableOutput, Accelerator const & accelerator) : m_outputGL(enableOutput) + , m_gpuAccessSafeModeEnabled(std::getenv("GLADIUS_GPU_ACCESS_SAFE_MODE") != nullptr) { initContextWithAccelerator(accelerator); } @@ -450,6 +454,8 @@ namespace gladius throw ThreadQueueManagementError("queue insertion", currentThreadId); } + (void) registerQueueIdLocked(result.first->second); + return result.first->second; } catch (OpenCLQueueCreationError const &) @@ -482,6 +488,106 @@ namespace gladius return iter->second; } + GpuAccessCoordinator & ComputeContext::gpuAccessCoordinator() + { + return *m_gpuAccessCoordinator; + } + + GpuAccessCoordinator const & ComputeContext::gpuAccessCoordinator() const + { + return *m_gpuAccessCoordinator; + } + + GpuQueueId ComputeContext::getGpuQueueId(cl::CommandQueue const & queue) const + { + std::lock_guard lock(m_queuesMutex); + return registerQueueIdLocked(queue); + } + + GpuEventId ComputeContext::recordGpuEvent(cl::Event const & event) + { + return m_openClEventRegistry->record(event); + } + + std::vector + ComputeContext::gpuWaitEvents(std::vector const & eventIds) const + { + return m_openClEventRegistry->eventsFor(eventIds); + } + + void ComputeContext::refreshGpuAccessEvents() + { + auto completedEvents = m_openClEventRegistry->pollCompleted(); + if (!completedEvents.empty()) + { + m_gpuAccessCoordinator->markEventsCompleted(completedEvents); + m_gpuAccessCoordinator->collectCompletedRetirements(); + } + } + + void ComputeContext::waitForGpuEvents(std::vector const & eventIds) + { + auto completedEvents = m_openClEventRegistry->waitFor(eventIds); + if (!completedEvents.empty()) + { + m_gpuAccessCoordinator->markEventsCompleted(completedEvents); + m_gpuAccessCoordinator->collectCompletedRetirements(); + } + } + + void ComputeContext::waitForGpuResourceIdle(GpuResourceHandle const resource) + { + refreshGpuAccessEvents(); + + auto outstanding = m_gpuAccessCoordinator->outstandingEvents(resource); + if (!outstanding.empty()) + { + waitForGpuEvents(outstanding); + } + + if (m_gpuAccessCoordinator->isIdle(resource)) + { + return; + } + + finishAllQueues(); + refreshGpuAccessEvents(); + + outstanding = m_gpuAccessCoordinator->outstandingEvents(resource); + if (!outstanding.empty()) + { + waitForGpuEvents(outstanding); + } + + if (!m_gpuAccessCoordinator->isIdle(resource) && m_logger) + { + m_logger->logWarning("GPU resource remained busy after finishing known queues"); + } + } + + void ComputeContext::waitForAllTrackedGpuWork() + { + auto completedEvents = m_openClEventRegistry->waitForAll(); + if (!completedEvents.empty()) + { + m_gpuAccessCoordinator->markEventsCompleted(completedEvents); + } + + finishAllQueues(); + refreshGpuAccessEvents(); + m_gpuAccessCoordinator->collectCompletedRetirements(); + } + + void ComputeContext::setGpuAccessSafeModeEnabled(bool const enabled) noexcept + { + m_gpuAccessSafeModeEnabled = enabled; + } + + bool ComputeContext::isGpuAccessSafeModeEnabled() const noexcept + { + return m_gpuAccessSafeModeEnabled; + } + bool ComputeContext::isValid() const { return m_isValid; @@ -531,6 +637,7 @@ namespace gladius // Clear all queues as they're now invalid m_queues.clear(); + m_gpuQueueIds.clear(); } bool ComputeContext::validateQueue(const cl::CommandQueue & queue) const @@ -902,6 +1009,47 @@ namespace gladius } } + GpuQueueId ComputeContext::registerQueueIdLocked(cl::CommandQueue const & queue) const + { + auto const nativeQueue = queue(); + if (nativeQueue == nullptr) + { + return 0u; + } + + auto const queueIt = m_gpuQueueIds.find(nativeQueue); + if (queueIt != m_gpuQueueIds.end()) + { + return queueIt->second; + } + + GpuQueueId const queueId = m_nextGpuQueueId++; + m_gpuQueueIds.emplace(nativeQueue, queueId); + return queueId; + } + + void ComputeContext::finishAllQueues() const + { + std::vector queues; + { + std::lock_guard lock(m_queuesMutex); + queues.reserve(m_queues.size()); + for (auto const & [threadId, queue] : m_queues) + { + (void) threadId; + queues.push_back(queue); + } + } + + for (auto const & queue : queues) + { + if (queue() != nullptr) + { + queue.finish(); + } + } + } + void ComputeContext::initContext() { cl::Platform defaultPlatform; diff --git a/gladius/src/ComputeContext.h b/gladius/src/ComputeContext.h index 62babc74..ab5ba69c 100644 --- a/gladius/src/ComputeContext.h +++ b/gladius/src/ComputeContext.h @@ -1,6 +1,8 @@ #pragma once #include "gpgpu.h" +#include "compute/GpuAccessCoordinator.h" +#include "compute/OpenClEventRegistry.h" #include #include @@ -103,6 +105,19 @@ namespace gladius /// @return A new command queue cl::CommandQueue createQueue() const; + [[nodiscard]] GpuAccessCoordinator & gpuAccessCoordinator(); + [[nodiscard]] GpuAccessCoordinator const & gpuAccessCoordinator() const; + + [[nodiscard]] GpuQueueId getGpuQueueId(cl::CommandQueue const & queue) const; + [[nodiscard]] GpuEventId recordGpuEvent(cl::Event const & event); + [[nodiscard]] std::vector gpuWaitEvents(std::vector const & eventIds) const; + void refreshGpuAccessEvents(); + void waitForGpuEvents(std::vector const & eventIds); + void waitForGpuResourceIdle(GpuResourceHandle resource); + void waitForAllTrackedGpuWork(); + void setGpuAccessSafeModeEnabled(bool enabled) noexcept; + [[nodiscard]] bool isGpuAccessSafeModeEnabled() const noexcept; + [[nodiscard]] bool isValid() const; [[nodiscard]] const cl::Device & GetDevice() const; @@ -229,12 +244,16 @@ namespace gladius void initContextWithAccelerator(Accelerator const & accelerator); void queryDeviceMemoryCaps(); bool tryQueryVendorFreeMem(size_t & freeBytesOut) const; // best-effort + [[nodiscard]] GpuQueueId registerQueueIdLocked(cl::CommandQueue const & queue) const; + void finishAllQueues() const; std::unique_ptr m_context; // queue per thread QueuePerThread m_queues; mutable std::mutex m_queuesMutex; + mutable std::unordered_map m_gpuQueueIds; + mutable GpuQueueId m_nextGpuQueueId{1u}; cl::Device m_device; Capabilities m_capabilities{}; @@ -248,6 +267,7 @@ namespace gladius // Debug output control (instance-level) bool m_debugOutputEnabled{false}; + bool m_gpuAccessSafeModeEnabled{false}; // Device memory capabilities (queried at init) size_t m_deviceGlobalMemBytes{0}; @@ -262,6 +282,9 @@ namespace gladius // Event logger (optional) events::SharedLogger m_logger{}; + + std::shared_ptr m_gpuAccessCoordinator{std::make_shared()}; + std::shared_ptr m_openClEventRegistry{std::make_shared()}; }; using SharedComputeContext = std::shared_ptr; diff --git a/gladius/src/ImageRGBA.h b/gladius/src/ImageRGBA.h index 49f4851e..8d97b8d6 100644 --- a/gladius/src/ImageRGBA.h +++ b/gladius/src/ImageRGBA.h @@ -6,6 +6,8 @@ #include #include #include +#include +#include #include namespace gladius @@ -19,6 +21,8 @@ namespace gladius , m_height(512) , m_size(m_width * m_height * m_depth) , m_ComputeContext(context) + , m_gpuResource(m_ComputeContext.gpuAccessCoordinator().registerResource( + resourceKindForDepth(m_depth), typeid(ImageDataPoint).name())) { } @@ -27,6 +31,8 @@ namespace gladius , m_height(height) , m_size(m_width * m_height * m_depth) , m_ComputeContext(ComputeContext) + , m_gpuResource(m_ComputeContext.gpuAccessCoordinator().registerResource( + resourceKindForDepth(m_depth), typeid(ImageDataPoint).name())) { } @@ -36,6 +42,8 @@ namespace gladius , m_depth(depth) , m_size(m_width * m_height * m_depth) , m_ComputeContext(ComputeContext) + , m_gpuResource(m_ComputeContext.gpuAccessCoordinator().registerResource( + resourceKindForDepth(m_depth), typeid(ImageDataPoint).name())) { } @@ -43,11 +51,15 @@ namespace gladius ImageImpl(ImageImpl & src) : m_width(src.getWidth()) , m_height(src.getHeight()) - , m_size(m_width * m_height) + , m_depth(src.getDepth()) + , m_size(m_width * m_height * m_depth) , m_ComputeContext(src.m_ComputeContext) + , m_gpuResource(m_ComputeContext.gpuAccessCoordinator().registerResource( + resourceKindForDepth(m_depth), typeid(ImageDataPoint).name())) { ImageImpl::allocateOnDevice(); + m_ComputeContext.waitForGpuResourceIdle(src.gpuResourceHandle()); CL_ERROR(m_ComputeContext.GetQueue().enqueueCopyImage( src.getBuffer(), getBuffer(), {0, 0, 0}, {0, 0, 0}, {m_width, m_height, m_depth})); CL_ERROR(m_ComputeContext.GetQueue().finish()); @@ -55,10 +67,18 @@ namespace gladius virtual ~ImageImpl() { - if (m_buffer && m_allocatedBytes > 0) + try { - m_ComputeContext.onBufferReleased(m_allocatedBytes); - m_allocatedBytes = 0; + if (m_buffer && m_allocatedBytes > 0) + { + m_ComputeContext.waitForGpuResourceIdle(m_gpuResource); + m_ComputeContext.onBufferReleased(m_allocatedBytes); + m_allocatedBytes = 0; + } + } + catch (...) + { + // Destructors must not throw. } } @@ -75,6 +95,7 @@ namespace gladius void read() { + m_ComputeContext.waitForGpuResourceIdle(m_gpuResource); CL_ERROR(m_ComputeContext.GetQueue().finish()); CL_ERROR(m_ComputeContext.GetQueue().enqueueReadImage( *m_buffer, CL_TRUE, {0, 0, 0}, {m_width, m_height, m_depth}, 0, 0, m_data.data())); @@ -83,6 +104,7 @@ namespace gladius void write() { + retireCurrentGpuGeneration(); CL_ERROR(m_ComputeContext.GetQueue().finish()); CL_ERROR(m_ComputeContext.GetQueue().enqueueWriteImage( *m_buffer, CL_TRUE, {0, 0, 0}, {m_width, m_height, m_depth}, 0, 0, m_data.data())); @@ -103,6 +125,7 @@ namespace gladius // If re-allocating, release previous accounting tracked if (m_buffer && m_allocatedBytes > 0) { + retireCurrentGpuGeneration(); m_ComputeContext.onBufferReleased(m_allocatedBytes); m_allocatedBytes = 0; } @@ -192,6 +215,11 @@ namespace gladius return m_buffer.get(); } + [[nodiscard]] GpuResourceHandle gpuResourceHandle() const noexcept + { + return m_gpuResource; + } + [[nodiscard]] size_t getWidth() const { return m_width; @@ -242,9 +270,39 @@ namespace gladius ComputeContext & m_ComputeContext; std::unique_ptr m_buffer; + GpuResourceHandle m_gpuResource{}; + // Track bytes accounted in ComputeContext for this device image size_t m_allocatedBytes{0}; + void retireCurrentGpuGeneration() + { + m_ComputeContext.waitForGpuResourceIdle(m_gpuResource); + + auto retirement = m_ComputeContext.gpuAccessCoordinator().retireCurrentGeneration( + m_gpuResource.resourceId); + if (retirement.status == GpuAccessStatus::PendingAccessWithoutEvent) + { + m_ComputeContext.waitForGpuResourceIdle(m_gpuResource); + retirement = m_ComputeContext.gpuAccessCoordinator().retireCurrentGeneration( + m_gpuResource.resourceId); + } + + if (!retirement.granted()) + { + throw std::runtime_error("Failed to retire GPU image generation safely"); + } + + m_ComputeContext.waitForGpuEvents(retirement.waitEvents); + m_ComputeContext.gpuAccessCoordinator().collectCompletedRetirements(); + m_gpuResource = retirement.newGeneration; + } + + [[nodiscard]] static GpuResourceKind resourceKindForDepth(size_t const depth) noexcept + { + return depth > 1u ? GpuResourceKind::Image3D : GpuResourceKind::Image2D; + } + static cl::ImageFormat determineImageFormat() { if constexpr (std::is_same_v) diff --git a/gladius/src/MeshVoxelGridManager.cpp b/gladius/src/MeshVoxelGridManager.cpp index 375be609..82eaccda 100644 --- a/gladius/src/MeshVoxelGridManager.cpp +++ b/gladius/src/MeshVoxelGridManager.cpp @@ -5,6 +5,7 @@ #include "MeshVoxelGridManager.h" #include "CLProgram.h" +#include "compute/GpuKernelAccessGuard.h" namespace gladius { @@ -13,35 +14,48 @@ namespace gladius { } - bool MeshVoxelGridManager::buildGrid(CLProgram& program, - cl::Buffer& primitiveDataBuffer, - MeshVoxelGridBuildParams const& params) + bool MeshVoxelGridManager::buildGrid(CLProgram & program, + PrimitiveDataBuffer & primitiveData, + MeshVoxelGridBuildParams const & params) { if (params.voxelCount <= 0) { - return false; // No voxels to build + return false; // No voxels to build } - + // Run the build kernel // Global work size = total voxels cl::NDRange const globalRange(static_cast(params.voxelCount)); - - cl::Event event = program.runNonBlocking(m_context->GetQueue(), - "buildMeshVoxelGrid", - cl::NullRange, - globalRange, - primitiveDataBuffer, - params.headerStart, - params.voxelDataOffset, - params.nodesOffset, - params.trianglesOffset, - params.normalsOffset, - params.indicesOffset, - params.edgeNeighborsOffset, - params.nodeCount, - params.triCount, - params.vertexNormalCount); - + + auto const & queue = m_context->GetQueue(); + GpuKernelAccessGuard gpuAccess( + *m_context, + queue, + "buildMeshVoxelGrid", + {{primitiveData.gpuResourceHandle(), GpuAccessMode::ReadWrite}}); + if (!gpuAccess.granted()) + { + return false; + } + + cl::Event event = program.runNonBlockingWithWaitList(queue, + "buildMeshVoxelGrid", + cl::NullRange, + globalRange, + gpuAccess.waitEvents(), + primitiveData.getBuffer(), + params.headerStart, + params.voxelDataOffset, + params.nodesOffset, + params.trianglesOffset, + params.normalsOffset, + params.indicesOffset, + params.edgeNeighborsOffset, + params.nodeCount, + params.triCount, + params.vertexNormalCount); + gpuAccess.complete(event); + // Check if the event is valid (non-null cl_event indicates success) return event() != nullptr; } @@ -51,19 +65,20 @@ namespace gladius m_buildQueue.push_back(params); } - void MeshVoxelGridManager::executeQueuedBuilds(CLProgram& program, cl::Buffer& primitiveDataBuffer) + void MeshVoxelGridManager::executeQueuedBuilds(CLProgram & program, + PrimitiveDataBuffer & primitiveData) { - for (auto const& params : m_buildQueue) + for (auto const & params : m_buildQueue) { - buildGrid(program, primitiveDataBuffer, params); + buildGrid(program, primitiveData, params); } - + // Wait for all builds to complete if (!m_buildQueue.empty()) { CL_ERROR(m_context->GetQueue().finish()); } - + m_buildQueue.clear(); } diff --git a/gladius/src/MeshVoxelGridManager.h b/gladius/src/MeshVoxelGridManager.h index 644d5663..7328ec9c 100644 --- a/gladius/src/MeshVoxelGridManager.h +++ b/gladius/src/MeshVoxelGridManager.h @@ -7,6 +7,7 @@ #include "ComputeContext.h" #include "MeshVoxelGrid.h" +#include "Primitives.h" #include "ResourceKey.h" #include @@ -98,20 +99,20 @@ namespace gladius /// Build voxel grid in-place in the primitive buffer /// @param program CLProgram with compiled buildMeshVoxelGrid kernel - /// @param primitiveDataBuffer Primitive data buffer on GPU (read BVH, write voxels) + /// @param primitiveData Primitive data buffer on GPU (read BVH, write voxels) /// @param params Build parameters (offsets and counts) /// @return true if the kernel was executed successfully - bool buildGrid(CLProgram& program, - cl::Buffer& primitiveDataBuffer, - MeshVoxelGridBuildParams const& params); + bool buildGrid(CLProgram & program, + PrimitiveDataBuffer & primitiveData, + MeshVoxelGridBuildParams const & params); /// Queue a build request (call during resource iteration) void queueBuild(MeshVoxelGridBuildParams params); /// Execute all queued builds /// @param program CLProgram with compiled buildMeshVoxelGrid kernel - /// @param primitiveDataBuffer Primitive data buffer on GPU - void executeQueuedBuilds(CLProgram& program, cl::Buffer& primitiveDataBuffer); + /// @param primitiveData Primitive data buffer on GPU + void executeQueuedBuilds(CLProgram & program, PrimitiveDataBuffer & primitiveData); /// Clear the build queue void clearQueue(); diff --git a/gladius/src/RenderProgram.cpp b/gladius/src/RenderProgram.cpp index d774edf0..054c8ca1 100644 --- a/gladius/src/RenderProgram.cpp +++ b/gladius/src/RenderProgram.cpp @@ -1,6 +1,8 @@ #include "RenderProgram.h" #include "Profiling.h" #include "ProgramBase.h" +#include "compute/GpuKernelAccessGuard.h" +#include "compute/RenderPayloadSnapshot.h" #include "gpgpu.h" #include "kernel/types.h" @@ -11,6 +13,23 @@ namespace gladius { + namespace + { + [[nodiscard]] std::vector + makeRenderPayloadReadAccesses(SharedResources const & resources, + Primitives const & lines) + { + return RenderPayloadSnapshot::capture(resources, lines).readAccesses(); + } + + void appendAccess(std::vector & accesses, + GpuResourceHandle resource, + GpuAccessMode mode) + { + accesses.push_back(GpuKernelResourceAccess{.resource = resource, .mode = mode}); + } + } + /// Maximum render height per dispatch to prevent excessive GPU workload constexpr size_t kMaxRenderHeightPerDispatch = 16000; @@ -146,11 +165,21 @@ namespace gladius try { - kernelEvent = m_programFront->runNonBlocking( + auto accesses = makeRenderPayloadReadAccesses(m_resources, lines); + appendAccess(accesses, targetImage.gpuResourceHandle(), GpuAccessMode::Write); + GpuKernelAccessGuard gpuAccess( + *m_ComputeContext, queue, "renderScene", std::move(accesses)); + if (!gpuAccess.granted()) + { + return kernelEvent; + } + + kernelEvent = m_programFront->runNonBlockingWithWaitList( queue, "renderScene", setup.origin, setup.globalRange, + gpuAccess.waitEvents(), targetImage.getBuffer(), m_resources->getBuildArea(), lines.primitives.getBuffer(), @@ -165,6 +194,7 @@ namespace gladius m_resources->getPreCompSdfBBox(), m_resources->getEyePosition(), m_resources->getModelViewPerspectiveMat()); + gpuAccess.complete(kernelEvent); } catch (std::exception const & e) { @@ -213,8 +243,29 @@ namespace gladius } cl::NDRange const origin = {0, startHeight, 0}; cl::NDRange const range = {targetImage.getWidth(), endHeight - startHeight, 1}; - cl::Event const event = m_programFront->runNonBlocking( - queue, "resample", origin, range, targetImage.getBuffer(), sourceImage.getBuffer()); + GpuKernelAccessGuard gpuAccess( + *m_ComputeContext, + queue, + "resample", + {{targetImage.gpuResourceHandle(), GpuAccessMode::Write}, + {sourceImage.gpuResourceHandle(), GpuAccessMode::Read}}); + if (!gpuAccess.granted()) + { + if (completionEvent) + { + *completionEvent = cl::Event{}; + } + return; + } + cl::Event const event = m_programFront->runNonBlockingWithWaitList( + queue, + "resample", + origin, + range, + gpuAccess.waitEvents(), + targetImage.getBuffer(), + sourceImage.getBuffer()); + gpuAccess.complete(event); if (completionEvent) { *completionEvent = event; @@ -260,11 +311,22 @@ namespace gladius try { - kernelEvent = m_programFront->runNonBlocking( + auto accesses = makeRenderPayloadReadAccesses(m_resources, lines); + appendAccess(accesses, targetImage.gpuResourceHandle(), GpuAccessMode::Write); + appendAccess(accesses, distanceOutput.gpuResourceHandle(), GpuAccessMode::Write); + GpuKernelAccessGuard gpuAccess( + *m_ComputeContext, queue, "renderSceneWithDistanceOutput", std::move(accesses)); + if (!gpuAccess.granted()) + { + return kernelEvent; + } + + kernelEvent = m_programFront->runNonBlockingWithWaitList( queue, "renderSceneWithDistanceOutput", setup.origin, setup.globalRange, + gpuAccess.waitEvents(), targetImage.getBuffer(), distanceOutput.getBuffer(), m_resources->getBuildArea(), @@ -280,6 +342,7 @@ namespace gladius m_resources->getPreCompSdfBBox(), m_resources->getEyePosition(), m_resources->getModelViewPerspectiveMat()); + gpuAccess.complete(kernelEvent); } catch (std::exception const & e) { @@ -316,11 +379,24 @@ namespace gladius try { - kernelEvent = m_programFront->runNonBlocking( + auto accesses = makeRenderPayloadReadAccesses(m_resources, lines); + appendAccess(accesses, targetImage.gpuResourceHandle(), GpuAccessMode::Write); + appendAccess(accesses, distanceInit.gpuResourceHandle(), GpuAccessMode::Read); + GpuKernelAccessGuard gpuAccess( + *m_ComputeContext, queue, "renderSceneWithDistanceInit", std::move(accesses)); + if (!gpuAccess.granted()) + { + m_resources->getRenderingSettings().approximation = static_cast( + m_resources->getRenderingSettings().approximation & ~AM_USE_DISTANCE_INIT); + return kernelEvent; + } + + kernelEvent = m_programFront->runNonBlockingWithWaitList( queue, "renderSceneWithDistanceInit", setup.origin, setup.globalRange, + gpuAccess.waitEvents(), targetImage.getBuffer(), distanceInit.getBuffer(), m_resources->getBuildArea(), @@ -336,6 +412,7 @@ namespace gladius m_resources->getPreCompSdfBBox(), m_resources->getEyePosition(), m_resources->getModelViewPerspectiveMat()); + gpuAccess.complete(kernelEvent); } catch (std::exception const & e) { @@ -372,11 +449,22 @@ namespace gladius try { - kernelEvent = m_programFront->runNonBlocking( + auto accesses = makeRenderPayloadReadAccesses(m_resources, lines); + appendAccess(accesses, targetImage.gpuResourceHandle(), GpuAccessMode::Write); + appendAccess(accesses, m_resources->getMetricsBufferGpuResource(), GpuAccessMode::Write); + GpuKernelAccessGuard gpuAccess( + *m_ComputeContext, queue, "renderSceneWithMetrics", std::move(accesses)); + if (!gpuAccess.granted()) + { + return kernelEvent; + } + + kernelEvent = m_programFront->runNonBlockingWithWaitList( queue, "renderSceneWithMetrics", setup.origin, setup.globalRange, + gpuAccess.waitEvents(), targetImage.getBuffer(), metricsBuffer, m_resources->getBuildArea(), @@ -392,6 +480,7 @@ namespace gladius m_resources->getPreCompSdfBBox(), m_resources->getEyePosition(), m_resources->getModelViewPerspectiveMat()); + gpuAccess.complete(kernelEvent); } catch (std::exception const & e) { diff --git a/gladius/src/ResourceContext.cpp b/gladius/src/ResourceContext.cpp index 1cf51f04..29fa0154 100644 --- a/gladius/src/ResourceContext.cpp +++ b/gladius/src/ResourceContext.cpp @@ -17,6 +17,8 @@ namespace gladius { ResourceContext::ResourceContext(SharedComputeContext context) : m_ComputeContext(context) + , m_metricsBufferResource(m_ComputeContext->gpuAccessCoordinator().registerResource( + GpuResourceKind::Buffer, "RayMarchMetrics")) { m_renderingSettings = {}; m_renderingSettings.z_mm = 1000.0f; @@ -479,4 +481,9 @@ namespace gladius } return m_metricsBuffer.value(); } + + GpuResourceHandle ResourceContext::getMetricsBufferGpuResource() const noexcept + { + return m_metricsBufferResource; + } } diff --git a/gladius/src/ResourceContext.h b/gladius/src/ResourceContext.h index 39c3ff4a..a90d19cf 100644 --- a/gladius/src/ResourceContext.h +++ b/gladius/src/ResourceContext.h @@ -116,6 +116,8 @@ namespace gladius /// @return Reference to the GPU buffer for ray march metrics collection. [[nodiscard]] cl::Buffer & getMetricsBuffer(); + [[nodiscard]] GpuResourceHandle getMetricsBufferGpuResource() const noexcept; + private: void clearDistanceMaps(); std::unique_ptr m_contourVertexPos; @@ -172,6 +174,7 @@ namespace gladius /// @brief Buffer for ray march metrics collection (4 x uint32: totalRays, totalSteps, cacheHits, nonConverged) std::optional m_metricsBuffer; + GpuResourceHandle m_metricsBufferResource{}; bool m_resizeOfBuildAreaBufferRequired = true; bool m_resizeOfDistanceMapsRequired = true; diff --git a/gladius/src/SlicerProgram.cpp b/gladius/src/SlicerProgram.cpp index 6f0547d5..656c4e6b 100644 --- a/gladius/src/SlicerProgram.cpp +++ b/gladius/src/SlicerProgram.cpp @@ -1,5 +1,7 @@ #include "SlicerProgram.h" #include "ProgramBase.h" +#include "compute/GpuKernelAccessGuard.h" +#include "compute/RenderPayloadSnapshot.h" #include "gpgpu.h" #include @@ -10,6 +12,22 @@ namespace gladius { + namespace + { + [[nodiscard]] std::vector + makeSlicerPayloadReadAccesses(SharedResources const & resources, + Primitives const & lines) + { + return RenderPayloadSnapshot::capture(resources, lines).readAccesses(); + } + + void appendAccess(std::vector & accesses, + GpuResourceHandle resource, + GpuAccessMode mode) + { + accesses.push_back(GpuKernelResourceAccess{.resource = resource, .mode = mode}); + } + } #define PAYLOAD_ARGUMENTS \ m_resources->getBuildArea(), lines.primitives.getBuffer(), \ @@ -149,8 +167,30 @@ namespace gladius m_resources->getRenderingSettings().approximation = AM_FULL_MODEL; const cl::NDRange origin = {0, 0, 0}; const cl::NDRange range = {target.getWidth(), target.getHeight(), target.getDepth()}; - m_programFront->run( - "preComputeSdf", origin, range, target.getBuffer(), boundingBox, PAYLOAD_ARGUMENTS); + auto accesses = makeSlicerPayloadReadAccesses(m_resources, lines); + appendAccess(accesses, target.gpuResourceHandle(), GpuAccessMode::ReadWrite); + auto const & queue = m_ComputeContext->GetQueue(); + GpuKernelAccessGuard gpuAccess( + *m_ComputeContext, queue, "preComputeSdf", std::move(accesses)); + if (!gpuAccess.granted()) + { + return; + } + + cl::Event const event = m_programFront->runNonBlockingWithWaitList(queue, + "preComputeSdf", + origin, + range, + gpuAccess.waitEvents(), + target.getBuffer(), + boundingBox, + PAYLOAD_ARGUMENTS); + gpuAccess.complete(event); + if (event()) + { + event.wait(); + queue.finish(); + } } cl::Event SlicerProgram::precomputeSdfAsync(const Primitives & lines, @@ -167,14 +207,25 @@ namespace gladius const cl::NDRange origin = {0, 0, 0}; const cl::NDRange range = {target.getWidth(), target.getHeight(), target.getDepth()}; + auto accesses = makeSlicerPayloadReadAccesses(m_resources, lines); + appendAccess(accesses, target.gpuResourceHandle(), GpuAccessMode::ReadWrite); + GpuKernelAccessGuard gpuAccess( + *m_ComputeContext, queue, "preComputeSdf", std::move(accesses)); + if (!gpuAccess.granted()) + { + return cl::Event{}; + } + // Use runNonBlocking to get the event without blocking - cl::Event sdfEvent = m_programFront->runNonBlocking(queue, - "preComputeSdf", - origin, - range, - target.getBuffer(), - boundingBox, - PAYLOAD_ARGUMENTS); + cl::Event sdfEvent = m_programFront->runNonBlockingWithWaitList(queue, + "preComputeSdf", + origin, + range, + gpuAccess.waitEvents(), + target.getBuffer(), + boundingBox, + PAYLOAD_ARGUMENTS); + gpuAccess.complete(sdfEvent); // Flush to ensure the kernel is submitted, but don't wait if (sdfEvent()) @@ -398,21 +449,41 @@ namespace gladius cl::NDRange const origin = {0, 0, 0}; cl::NDRange const globalRange = {static_cast(params.voxelCount), 1, 1}; + + auto const & queue = m_ComputeContext->GetQueue(); + GpuKernelAccessGuard gpuAccess( + *m_ComputeContext, + queue, + "buildMeshVoxelGrid", + {{primitives.data.gpuResourceHandle(), GpuAccessMode::ReadWrite}}); + if (!gpuAccess.granted()) + { + return false; + } - m_programFront->run("buildMeshVoxelGrid", - origin, - globalRange, - primitives.data.getBuffer(), // 0: primitiveData - static_cast(params.headerStart), // 1: headerStart - static_cast(params.voxelDataOffset), // 2: voxelDataOffset - static_cast(params.nodesOffset), // 3: nodesOffset - static_cast(params.trianglesOffset), // 4: trianglesOffset - static_cast(params.normalsOffset), // 5: normalsOffset - static_cast(params.indicesOffset), // 6: indicesOffset - static_cast(params.edgeNeighborsOffset), // 7: edgeNeighborsOffset - static_cast(params.nodeCount), // 8: nodeCount - static_cast(params.triCount), // 9: triCount - static_cast(params.vertexNormalCount)); // 10: vertexNormalCount + cl::Event const event = m_programFront->runNonBlockingWithWaitList( + queue, + "buildMeshVoxelGrid", + origin, + globalRange, + gpuAccess.waitEvents(), + primitives.data.getBuffer(), // 0: primitiveData + static_cast(params.headerStart), // 1: headerStart + static_cast(params.voxelDataOffset), // 2: voxelDataOffset + static_cast(params.nodesOffset), // 3: nodesOffset + static_cast(params.trianglesOffset), // 4: trianglesOffset + static_cast(params.normalsOffset), // 5: normalsOffset + static_cast(params.indicesOffset), // 6: indicesOffset + static_cast(params.edgeNeighborsOffset), // 7: edgeNeighborsOffset + static_cast(params.nodeCount), // 8: nodeCount + static_cast(params.triCount), // 9: triCount + static_cast(params.vertexNormalCount)); // 10: vertexNormalCount + gpuAccess.complete(event); + if (event()) + { + event.wait(); + queue.finish(); + } return true; } @@ -441,16 +512,29 @@ namespace gladius cl::NDRange const origin = {0, 0, 0}; cl::NDRange const globalRange = {static_cast(params.nodeCount), 1, 1}; - cl::Event const event = m_programFront->runNonBlocking(queue, - "buildMeshFwnAggregates", - origin, - globalRange, - primitives.data.getBuffer(), - static_cast(params.nodesOffset), - static_cast(params.trianglesOffset), - static_cast(params.fwnAggregatesOffset), - static_cast(params.nodeCount), - static_cast(params.triCount)); + GpuKernelAccessGuard gpuAccess( + *m_ComputeContext, + queue, + "buildMeshFwnAggregates", + {{primitives.data.gpuResourceHandle(), GpuAccessMode::ReadWrite}}); + if (!gpuAccess.granted()) + { + return false; + } + + cl::Event const event = m_programFront->runNonBlockingWithWaitList( + queue, + "buildMeshFwnAggregates", + origin, + globalRange, + gpuAccess.waitEvents(), + primitives.data.getBuffer(), + static_cast(params.nodesOffset), + static_cast(params.trianglesOffset), + static_cast(params.fwnAggregatesOffset), + static_cast(params.nodeCount), + static_cast(params.triCount)); + gpuAccess.complete(event); return event() != nullptr; } @@ -480,34 +564,46 @@ namespace gladius cl::CommandQueue const & queue = m_ComputeContext->GetQueue(); cl::NDRange const origin = {0, 0, 0}; + GpuKernelAccessGuard gpuAccess(*m_ComputeContext, + queue, + "buildMeshSignCache", + {{primitives.data.gpuResourceHandle(), GpuAccessMode::ReadWrite}}); + if (!gpuAccess.granted()) + { + return false; + } + int const firstWord = std::clamp(params.baseWord, 0, params.wordCount); int const lastWord = std::min(params.wordCount, firstWord + params.wordsToBuild); + cl::Event lastEvent{}; for (int baseWord = firstWord; baseWord < lastWord; baseWord += wordsPerBatch) { int const batchWordCount = std::min(wordsPerBatch, lastWord - baseWord); cl::NDRange const globalRange = {static_cast(batchWordCount), 1, 1}; - cl::Event const event = m_programFront->runNonBlocking(queue, - "buildMeshSignCache", - origin, - globalRange, - primitives.data.getBuffer(), - static_cast(params.headerStart), - static_cast(params.signCacheDataOffset), - static_cast(params.nodesOffset), - static_cast(params.trianglesOffset), - static_cast(params.normalsOffset), - static_cast(params.indicesOffset), - static_cast(params.edgeNeighborsOffset), - static_cast(params.fwnAggregatesOffset), - static_cast(params.nodeCount), - static_cast(params.triCount), - static_cast(params.vertexNormalCount), - static_cast(params.resolution), - static_cast(baseWord), - static_cast(params.wordCount), - static_cast(params.fwnBeta)); - if (!event()) + lastEvent = m_programFront->runNonBlockingWithWaitList( + queue, + "buildMeshSignCache", + origin, + globalRange, + gpuAccess.waitEvents(), + primitives.data.getBuffer(), + static_cast(params.headerStart), + static_cast(params.signCacheDataOffset), + static_cast(params.nodesOffset), + static_cast(params.trianglesOffset), + static_cast(params.normalsOffset), + static_cast(params.indicesOffset), + static_cast(params.edgeNeighborsOffset), + static_cast(params.fwnAggregatesOffset), + static_cast(params.nodeCount), + static_cast(params.triCount), + static_cast(params.vertexNormalCount), + static_cast(params.resolution), + static_cast(baseWord), + static_cast(params.wordCount), + static_cast(params.fwnBeta)); + if (!lastEvent()) { return false; } @@ -515,26 +611,31 @@ namespace gladius if (!params.completesBuild) { + gpuAccess.complete(lastEvent); return true; } // Flip the ready offset only after the final chunk kernels above complete. // The command queue is in-order, so any later render kernel sees either // offset 0 (not ready) or a fully built bitmap. - cl::Event const readyEvent = m_programFront->runNonBlocking(queue, - "markMeshSignCacheReady", - origin, - cl::NDRange{1, 1, 1}, - primitives.data.getBuffer(), - static_cast(params.signCacheReadyOffset), - static_cast(params.signCacheDataOffset), - static_cast(params.signCacheBetaOffset), - static_cast(params.fwnBeta)); + cl::Event const readyEvent = m_programFront->runNonBlockingWithWaitList( + queue, + "markMeshSignCacheReady", + origin, + cl::NDRange{1, 1, 1}, + gpuAccess.waitEvents(), + primitives.data.getBuffer(), + static_cast(params.signCacheReadyOffset), + static_cast(params.signCacheDataOffset), + static_cast(params.signCacheBetaOffset), + static_cast(params.fwnBeta)); if (!readyEvent()) { return false; } + gpuAccess.complete(readyEvent); + return true; } } diff --git a/gladius/src/compute/GpuAccessCoordinator.cpp b/gladius/src/compute/GpuAccessCoordinator.cpp new file mode 100644 index 00000000..2b755cc2 --- /dev/null +++ b/gladius/src/compute/GpuAccessCoordinator.cpp @@ -0,0 +1,449 @@ +#include "GpuAccessCoordinator.h" + +#include +#include + +namespace gladius +{ + namespace + { + [[nodiscard]] bool isValidEvent(GpuEventId const eventId) noexcept + { + return eventId != 0u; + } + + void appendUniqueEvent(std::vector & events, GpuEventId const eventId) + { + if (!isValidEvent(eventId)) + { + return; + } + + if (std::find(events.begin(), events.end(), eventId) == events.end()) + { + events.push_back(eventId); + } + } + } + + GpuResourceHandle GpuAccessCoordinator::registerResource(GpuResourceKind const kind, + std::string debugName, + GpuResourceGeneration initialGeneration) + { + if (initialGeneration == 0u) + { + initialGeneration = 1u; + } + + std::lock_guard lock(m_mutex); + + GpuResourceId const resourceId = m_nextResourceId++; + ResourceState resource{}; + resource.resourceId = resourceId; + resource.currentGeneration = initialGeneration; + resource.kind = kind; + resource.debugName = std::move(debugName); + resource.generations.emplace(initialGeneration, + GenerationState{.generation = initialGeneration}); + + m_resources.emplace(resourceId, std::move(resource)); + return GpuResourceHandle{.resourceId = resourceId, .generation = initialGeneration}; + } + + std::optional + GpuAccessCoordinator::currentHandle(GpuResourceId const resourceId) const + { + std::lock_guard lock(m_mutex); + auto const resourceIt = m_resources.find(resourceId); + if (resourceIt == m_resources.end()) + { + return std::nullopt; + } + + return GpuResourceHandle{.resourceId = resourceId, + .generation = resourceIt->second.currentGeneration}; + } + + std::optional + GpuAccessCoordinator::debugInfo(GpuResourceId const resourceId) const + { + std::lock_guard lock(m_mutex); + auto const resourceIt = m_resources.find(resourceId); + if (resourceIt == m_resources.end()) + { + return std::nullopt; + } + + auto const & resource = resourceIt->second; + return GpuResourceDebugInfo{.resourceId = resource.resourceId, + .currentGeneration = resource.currentGeneration, + .kind = resource.kind, + .debugName = resource.debugName}; + } + + GpuAccessPlan GpuAccessCoordinator::beginAccess(GpuAccessRequest request) + { + std::lock_guard lock(m_mutex); + + auto resourceIt = m_resources.find(request.resource.resourceId); + if (resourceIt == m_resources.end()) + { + return GpuAccessPlan{.status = GpuAccessStatus::UnknownResource}; + } + + auto & resource = resourceIt->second; + if (request.resource.generation != resource.currentGeneration) + { + return GpuAccessPlan{.status = GpuAccessStatus::StaleGeneration}; + } + + auto generationIt = resource.generations.find(request.resource.generation); + if (generationIt == resource.generations.end() || generationIt->second.retired) + { + return GpuAccessPlan{.status = GpuAccessStatus::StaleGeneration}; + } + + auto & generation = generationIt->second; + pruneCompletedEventsLocked(generation); + + if (hasConflictingPendingAccessLocked(request.resource, request.mode)) + { + return GpuAccessPlan{.status = GpuAccessStatus::PendingAccessWithoutEvent}; + } + + GpuAccessToken const token = m_nextAccessToken++; + auto waitEvents = collectDependenciesLocked(generation, request.mode); + m_activeAccesses.emplace(token, + ActiveAccess{.token = token, + .resource = request.resource, + .queueId = request.queueId, + .mode = request.mode, + .operationName = std::move(request.operationName)}); + + return GpuAccessPlan{.status = GpuAccessStatus::Granted, + .token = token, + .waitEvents = std::move(waitEvents)}; + } + + GpuAccessStatus GpuAccessCoordinator::completeAccess(GpuAccessToken const token, + GpuEventId const eventId) + { + std::lock_guard lock(m_mutex); + + auto activeIt = m_activeAccesses.find(token); + if (activeIt == m_activeAccesses.end()) + { + return GpuAccessStatus::UnknownAccessToken; + } + + auto const active = std::move(activeIt->second); + m_activeAccesses.erase(activeIt); + + auto resourceIt = m_resources.find(active.resource.resourceId); + if (resourceIt == m_resources.end()) + { + return GpuAccessStatus::UnknownResource; + } + + auto generationIt = resourceIt->second.generations.find(active.resource.generation); + if (generationIt == resourceIt->second.generations.end()) + { + return GpuAccessStatus::StaleGeneration; + } + + if (!isValidEvent(eventId)) + { + return GpuAccessStatus::Granted; + } + + auto & generation = generationIt->second; + if (writesResource(active.mode)) + { + generation.lastWriterEvent = eventId; + generation.readerEvents.clear(); + } + else + { + appendUniqueEvent(generation.readerEvents, eventId); + } + + return GpuAccessStatus::Granted; + } + + void GpuAccessCoordinator::markEventCompleted(GpuEventId const eventId) + { + if (!isValidEvent(eventId)) + { + return; + } + + std::lock_guard lock(m_mutex); + m_completedEvents.insert(eventId); + } + + void GpuAccessCoordinator::markEventsCompleted(std::vector const & eventIds) + { + std::lock_guard lock(m_mutex); + for (auto const eventId : eventIds) + { + if (isValidEvent(eventId)) + { + m_completedEvents.insert(eventId); + } + } + } + + bool GpuAccessCoordinator::isEventCompleted(GpuEventId const eventId) const + { + std::lock_guard lock(m_mutex); + return isCompletedLocked(eventId); + } + + bool GpuAccessCoordinator::isIdle(GpuResourceHandle const resource) const + { + std::lock_guard lock(m_mutex); + + auto const resourceIt = m_resources.find(resource.resourceId); + if (resourceIt == m_resources.end()) + { + return true; + } + + auto const generationIt = resourceIt->second.generations.find(resource.generation); + if (generationIt == resourceIt->second.generations.end()) + { + return true; + } + + if (std::any_of(m_activeAccesses.begin(), + m_activeAccesses.end(), + [resource](auto const & entry) { return entry.second.resource == resource; })) + { + return false; + } + + return outstandingEventsLocked(resource).empty(); + } + + std::vector + GpuAccessCoordinator::outstandingEvents(GpuResourceHandle const resource) const + { + std::lock_guard lock(m_mutex); + + return outstandingEventsLocked(resource); + } + + std::vector + GpuAccessCoordinator::outstandingEventsLocked(GpuResourceHandle const resource) const + { + + auto const resourceIt = m_resources.find(resource.resourceId); + if (resourceIt == m_resources.end()) + { + return {}; + } + + auto const generationIt = resourceIt->second.generations.find(resource.generation); + if (generationIt == resourceIt->second.generations.end()) + { + return {}; + } + + std::vector events; + auto const & generation = generationIt->second; + if (generation.lastWriterEvent.has_value() && !isCompletedLocked(*generation.lastWriterEvent)) + { + appendUniqueEvent(events, *generation.lastWriterEvent); + } + + for (auto const eventId : generation.readerEvents) + { + if (!isCompletedLocked(eventId)) + { + appendUniqueEvent(events, eventId); + } + } + + for (auto const eventId : generation.retirementWaitEvents) + { + if (!isCompletedLocked(eventId)) + { + appendUniqueEvent(events, eventId); + } + } + + return events; + } + + GpuGenerationRetirement + GpuAccessCoordinator::retireCurrentGeneration(GpuResourceId const resourceId) + { + std::lock_guard lock(m_mutex); + + auto resourceIt = m_resources.find(resourceId); + if (resourceIt == m_resources.end()) + { + return GpuGenerationRetirement{.status = GpuAccessStatus::UnknownResource}; + } + + auto & resource = resourceIt->second; + GpuResourceHandle const retiredHandle{.resourceId = resourceId, + .generation = resource.currentGeneration}; + + if (hasConflictingPendingAccessLocked(retiredHandle, GpuAccessMode::Write)) + { + return GpuGenerationRetirement{.status = GpuAccessStatus::PendingAccessWithoutEvent, + .retiredGeneration = retiredHandle}; + } + + auto generationIt = resource.generations.find(resource.currentGeneration); + if (generationIt == resource.generations.end()) + { + return GpuGenerationRetirement{.status = GpuAccessStatus::StaleGeneration, + .retiredGeneration = retiredHandle}; + } + + auto & retiredGeneration = generationIt->second; + pruneCompletedEventsLocked(retiredGeneration); + auto waitEvents = collectDependenciesLocked(retiredGeneration, GpuAccessMode::Write); + retiredGeneration.retired = true; + retiredGeneration.retirementWaitEvents = waitEvents; + + GpuResourceGeneration const newGeneration = resource.currentGeneration + 1u; + resource.currentGeneration = newGeneration; + resource.generations.emplace(newGeneration, GenerationState{.generation = newGeneration}); + + return GpuGenerationRetirement{ + .status = GpuAccessStatus::Granted, + .retiredGeneration = retiredHandle, + .newGeneration = GpuResourceHandle{.resourceId = resourceId, .generation = newGeneration}, + .waitEvents = std::move(waitEvents)}; + } + + void GpuAccessCoordinator::collectCompletedRetirements() + { + std::lock_guard lock(m_mutex); + + for (auto & [resourceId, resource] : m_resources) + { + (void) resourceId; + for (auto generationIt = resource.generations.begin(); + generationIt != resource.generations.end();) + { + auto & generation = generationIt->second; + if (!generation.retired) + { + ++generationIt; + continue; + } + + bool const hasOutstandingRetirementEvent = + std::any_of(generation.retirementWaitEvents.begin(), + generation.retirementWaitEvents.end(), + [this](GpuEventId const eventId) + { return !isCompletedLocked(eventId); }); + + bool const hasActiveAccess = + std::any_of(m_activeAccesses.begin(), + m_activeAccesses.end(), + [&resource, &generation](auto const & entry) + { + return entry.second.resource.resourceId == resource.resourceId && + entry.second.resource.generation == generation.generation; + }); + + if (!hasOutstandingRetirementEvent && !hasActiveAccess) + { + generationIt = resource.generations.erase(generationIt); + } + else + { + ++generationIt; + } + } + } + } + + size_t GpuAccessCoordinator::retiredGenerationCount(GpuResourceId const resourceId) const + { + std::lock_guard lock(m_mutex); + + auto const resourceIt = m_resources.find(resourceId); + if (resourceIt == m_resources.end()) + { + return 0u; + } + + return static_cast(std::count_if( + resourceIt->second.generations.begin(), + resourceIt->second.generations.end(), + [](auto const & entry) { return entry.second.retired; })); + } + + bool GpuAccessCoordinator::isCompletedLocked(GpuEventId const eventId) const + { + return !isValidEvent(eventId) || m_completedEvents.find(eventId) != m_completedEvents.end(); + } + + void GpuAccessCoordinator::pruneCompletedEventsLocked(GenerationState & generation) const + { + if (generation.lastWriterEvent.has_value() && isCompletedLocked(*generation.lastWriterEvent)) + { + generation.lastWriterEvent.reset(); + } + + std::erase_if(generation.readerEvents, + [this](GpuEventId const eventId) { return isCompletedLocked(eventId); }); + + std::erase_if(generation.retirementWaitEvents, + [this](GpuEventId const eventId) { return isCompletedLocked(eventId); }); + } + + std::vector + GpuAccessCoordinator::collectDependenciesLocked(GenerationState const & generation, + GpuAccessMode const mode) const + { + std::vector dependencies; + + if (generation.lastWriterEvent.has_value() && !isCompletedLocked(*generation.lastWriterEvent)) + { + appendUniqueEvent(dependencies, *generation.lastWriterEvent); + } + + if (writesResource(mode)) + { + for (auto const eventId : generation.readerEvents) + { + if (!isCompletedLocked(eventId)) + { + appendUniqueEvent(dependencies, eventId); + } + } + } + + return dependencies; + } + + bool GpuAccessCoordinator::hasConflictingPendingAccessLocked(GpuResourceHandle const resource, + GpuAccessMode const mode) const + { + return std::any_of(m_activeAccesses.begin(), + m_activeAccesses.end(), + [resource, mode](auto const & entry) + { + auto const & active = entry.second; + return active.resource == resource && accessesConflict(active.mode, mode); + }); + } + + bool GpuAccessCoordinator::accessesConflict(GpuAccessMode const lhs, + GpuAccessMode const rhs) noexcept + { + return writesResource(lhs) || writesResource(rhs); + } + + bool GpuAccessCoordinator::writesResource(GpuAccessMode const mode) noexcept + { + return mode == GpuAccessMode::Write || mode == GpuAccessMode::ReadWrite; + } +} diff --git a/gladius/src/compute/GpuAccessCoordinator.h b/gladius/src/compute/GpuAccessCoordinator.h new file mode 100644 index 00000000..6c6efc00 --- /dev/null +++ b/gladius/src/compute/GpuAccessCoordinator.h @@ -0,0 +1,181 @@ +#pragma once + +#include +#include +#include +#include +#include +#include +#include +#include + +namespace gladius +{ + using GpuResourceId = uint64_t; + using GpuResourceGeneration = uint64_t; + using GpuQueueId = uint64_t; + using GpuEventId = uint64_t; + using GpuAccessToken = uint64_t; + + enum class GpuResourceKind + { + Buffer, + Image2D, + Image3D, + ImageGL, + HostSnapshot + }; + + enum class GpuAccessMode + { + Read, + Write, + ReadWrite + }; + + enum class GpuAccessStatus + { + Granted, + UnknownResource, + StaleGeneration, + PendingAccessWithoutEvent, + UnknownAccessToken + }; + + struct GpuResourceHandle + { + GpuResourceId resourceId{}; + GpuResourceGeneration generation{}; + + [[nodiscard]] friend bool operator==(GpuResourceHandle const & lhs, + GpuResourceHandle const & rhs) noexcept + { + return lhs.resourceId == rhs.resourceId && lhs.generation == rhs.generation; + } + }; + + struct GpuAccessRequest + { + GpuResourceHandle resource{}; + GpuQueueId queueId{}; + GpuAccessMode mode{GpuAccessMode::Read}; + std::string operationName; + }; + + struct GpuAccessPlan + { + GpuAccessStatus status{GpuAccessStatus::UnknownResource}; + GpuAccessToken token{}; + std::vector waitEvents; + + [[nodiscard]] bool granted() const noexcept + { + return status == GpuAccessStatus::Granted; + } + }; + + struct GpuGenerationRetirement + { + GpuAccessStatus status{GpuAccessStatus::UnknownResource}; + GpuResourceHandle retiredGeneration{}; + GpuResourceHandle newGeneration{}; + std::vector waitEvents; + + [[nodiscard]] bool granted() const noexcept + { + return status == GpuAccessStatus::Granted; + } + + [[nodiscard]] bool canReleaseImmediately() const noexcept + { + return granted() && waitEvents.empty(); + } + }; + + struct GpuResourceDebugInfo + { + GpuResourceId resourceId{}; + GpuResourceGeneration currentGeneration{}; + GpuResourceKind kind{GpuResourceKind::Buffer}; + std::string debugName; + }; + + class GpuAccessCoordinator + { + public: + [[nodiscard]] GpuResourceHandle registerResource(GpuResourceKind kind, + std::string debugName, + GpuResourceGeneration initialGeneration = 1u); + + [[nodiscard]] std::optional + currentHandle(GpuResourceId resourceId) const; + + [[nodiscard]] std::optional + debugInfo(GpuResourceId resourceId) const; + + [[nodiscard]] GpuAccessPlan beginAccess(GpuAccessRequest request); + + [[nodiscard]] GpuAccessStatus completeAccess(GpuAccessToken token, GpuEventId eventId); + + void markEventCompleted(GpuEventId eventId); + void markEventsCompleted(std::vector const & eventIds); + + [[nodiscard]] bool isEventCompleted(GpuEventId eventId) const; + + [[nodiscard]] bool isIdle(GpuResourceHandle resource) const; + + [[nodiscard]] std::vector outstandingEvents(GpuResourceHandle resource) const; + + [[nodiscard]] GpuGenerationRetirement retireCurrentGeneration(GpuResourceId resourceId); + + void collectCompletedRetirements(); + + [[nodiscard]] size_t retiredGenerationCount(GpuResourceId resourceId) const; + + private: + struct GenerationState + { + GpuResourceGeneration generation{}; + std::optional lastWriterEvent; + std::vector readerEvents; + bool retired{false}; + std::vector retirementWaitEvents; + }; + + struct ResourceState + { + GpuResourceId resourceId{}; + GpuResourceGeneration currentGeneration{}; + GpuResourceKind kind{GpuResourceKind::Buffer}; + std::string debugName; + std::unordered_map generations; + }; + + struct ActiveAccess + { + GpuAccessToken token{}; + GpuResourceHandle resource{}; + GpuQueueId queueId{}; + GpuAccessMode mode{GpuAccessMode::Read}; + std::string operationName; + }; + + [[nodiscard]] bool isCompletedLocked(GpuEventId eventId) const; + void pruneCompletedEventsLocked(GenerationState & generation) const; + [[nodiscard]] std::vector + outstandingEventsLocked(GpuResourceHandle resource) const; + [[nodiscard]] std::vector + collectDependenciesLocked(GenerationState const & generation, GpuAccessMode mode) const; + [[nodiscard]] bool hasConflictingPendingAccessLocked(GpuResourceHandle resource, + GpuAccessMode mode) const; + [[nodiscard]] static bool accessesConflict(GpuAccessMode lhs, GpuAccessMode rhs) noexcept; + [[nodiscard]] static bool writesResource(GpuAccessMode mode) noexcept; + + mutable std::mutex m_mutex; + GpuResourceId m_nextResourceId{1u}; + GpuAccessToken m_nextAccessToken{1u}; + std::unordered_map m_resources; + std::unordered_map m_activeAccesses; + std::unordered_set m_completedEvents; + }; +} diff --git a/gladius/src/compute/GpuKernelAccessGuard.cpp b/gladius/src/compute/GpuKernelAccessGuard.cpp new file mode 100644 index 00000000..9da37ae5 --- /dev/null +++ b/gladius/src/compute/GpuKernelAccessGuard.cpp @@ -0,0 +1,180 @@ +#include "GpuKernelAccessGuard.h" + +#include "ComputeContext.h" + +#include + +#include +#include + +namespace gladius +{ + namespace + { + void appendUnique(std::vector & target, std::vector const & source) + { + for (auto const eventId : source) + { + if (eventId == 0u) + { + continue; + } + + if (std::find(target.begin(), target.end(), eventId) == target.end()) + { + target.push_back(eventId); + } + } + } + } + + GpuKernelAccessGuard::GpuKernelAccessGuard(ComputeContext & context, + cl::CommandQueue const & queue, + std::string operationName, + std::vector accesses) + : m_context(context) + , m_operationName(std::move(operationName)) + { + if (m_context.isGpuAccessSafeModeEnabled()) + { + m_context.waitForAllTrackedGpuWork(); + } + + m_context.refreshGpuAccessEvents(); + + auto const queueId = m_context.getGpuQueueId(queue); + auto mergedAccesses = mergeAccesses(std::move(accesses)); + m_tokens.reserve(mergedAccesses.size()); + + for (auto const & access : mergedAccesses) + { + auto plan = m_context.gpuAccessCoordinator().beginAccess( + GpuAccessRequest{.resource = access.resource, + .queueId = queueId, + .mode = access.mode, + .operationName = m_operationName}); + if (!plan.granted()) + { + m_status = plan.status; + abandon(); + return; + } + + m_tokens.push_back(plan.token); + appendUnique(m_waitEventIds, plan.waitEvents); + } + + m_waitEvents = m_context.gpuWaitEvents(m_waitEventIds); + + if (m_context.isDebugOutputEnabled()) + { + if (auto logger = m_context.getLogger()) + { + logger->logInfo(fmt::format("GPU access: op='{}', queue={}, resources={}, waits={}, safeMode={}", + m_operationName, + queueId, + mergedAccesses.size(), + m_waitEvents.size(), + m_context.isGpuAccessSafeModeEnabled())); + } + } + } + + GpuKernelAccessGuard::~GpuKernelAccessGuard() + { + if (!m_completed) + { + abandon(); + } + } + + bool GpuKernelAccessGuard::granted() const noexcept + { + return m_status == GpuAccessStatus::Granted; + } + + GpuAccessStatus GpuKernelAccessGuard::status() const noexcept + { + return m_status; + } + + std::vector const & GpuKernelAccessGuard::waitEvents() const noexcept + { + return m_waitEvents; + } + + size_t GpuKernelAccessGuard::waitEventCount() const noexcept + { + return m_waitEvents.size(); + } + + void GpuKernelAccessGuard::complete(cl::Event const & event) + { + if (m_completed) + { + return; + } + + auto const eventId = m_context.recordGpuEvent(event); + for (auto const token : m_tokens) + { + (void) m_context.gpuAccessCoordinator().completeAccess(token, eventId); + } + if (m_context.isGpuAccessSafeModeEnabled() && eventId != 0u) + { + m_context.waitForGpuEvents({eventId}); + } + m_completed = true; + } + + void GpuKernelAccessGuard::abandon() + { + for (auto const token : m_tokens) + { + (void) m_context.gpuAccessCoordinator().completeAccess(token, 0u); + } + m_tokens.clear(); + m_completed = true; + } + + std::vector + GpuKernelAccessGuard::mergeAccesses(std::vector accesses) + { + std::vector merged; + merged.reserve(accesses.size()); + + for (auto const & access : accesses) + { + auto existingIt = std::find_if(merged.begin(), + merged.end(), + [&access](GpuKernelResourceAccess const & existing) + { return existing.resource == access.resource; }); + if (existingIt == merged.end()) + { + merged.push_back(access); + } + else + { + existingIt->mode = mergeMode(existingIt->mode, access.mode); + } + } + + return merged; + } + + GpuAccessMode GpuKernelAccessGuard::mergeMode(GpuAccessMode const lhs, + GpuAccessMode const rhs) noexcept + { + if (lhs == GpuAccessMode::ReadWrite || rhs == GpuAccessMode::ReadWrite) + { + return GpuAccessMode::ReadWrite; + } + + if (lhs == rhs) + { + return lhs; + } + + return GpuAccessMode::ReadWrite; + } +} diff --git a/gladius/src/compute/GpuKernelAccessGuard.h b/gladius/src/compute/GpuKernelAccessGuard.h new file mode 100644 index 00000000..5586cf63 --- /dev/null +++ b/gladius/src/compute/GpuKernelAccessGuard.h @@ -0,0 +1,54 @@ +#pragma once + +#include "GpuAccessCoordinator.h" +#include "gpgpu.h" + +#include +#include + +namespace gladius +{ + class ComputeContext; + + struct GpuKernelResourceAccess + { + GpuResourceHandle resource{}; + GpuAccessMode mode{GpuAccessMode::Read}; + }; + + class GpuKernelAccessGuard + { + public: + GpuKernelAccessGuard(ComputeContext & context, + cl::CommandQueue const & queue, + std::string operationName, + std::vector accesses); + ~GpuKernelAccessGuard(); + + GpuKernelAccessGuard(GpuKernelAccessGuard const &) = delete; + GpuKernelAccessGuard & operator=(GpuKernelAccessGuard const &) = delete; + GpuKernelAccessGuard(GpuKernelAccessGuard &&) = delete; + GpuKernelAccessGuard & operator=(GpuKernelAccessGuard &&) = delete; + + [[nodiscard]] bool granted() const noexcept; + [[nodiscard]] GpuAccessStatus status() const noexcept; + [[nodiscard]] std::vector const & waitEvents() const noexcept; + [[nodiscard]] size_t waitEventCount() const noexcept; + + void complete(cl::Event const & event); + void abandon(); + + private: + [[nodiscard]] static std::vector + mergeAccesses(std::vector accesses); + [[nodiscard]] static GpuAccessMode mergeMode(GpuAccessMode lhs, GpuAccessMode rhs) noexcept; + + ComputeContext & m_context; + std::string m_operationName; + GpuAccessStatus m_status{GpuAccessStatus::Granted}; + std::vector m_tokens; + std::vector m_waitEventIds; + std::vector m_waitEvents; + bool m_completed{false}; + }; +} diff --git a/gladius/src/compute/MeshPreparationProgram.cpp b/gladius/src/compute/MeshPreparationProgram.cpp index 2a7d4adb..1308cfc0 100644 --- a/gladius/src/compute/MeshPreparationProgram.cpp +++ b/gladius/src/compute/MeshPreparationProgram.cpp @@ -1,6 +1,7 @@ #include "compute/MeshPreparationProgram.h" #include "Profiling.h" +#include "compute/GpuKernelAccessGuard.h" #include #include @@ -99,20 +100,40 @@ namespace gladius cl::NDRange const origin = {0, 0, 0}; cl::NDRange const globalRange = {static_cast(params.voxelCount), 1, 1}; - m_program.run("buildMeshVoxelGrid", - origin, - globalRange, - primitives.data.getBuffer(), - static_cast(params.headerStart), - static_cast(params.voxelDataOffset), - static_cast(params.nodesOffset), - static_cast(params.trianglesOffset), - static_cast(params.normalsOffset), - static_cast(params.indicesOffset), - static_cast(params.edgeNeighborsOffset), - static_cast(params.nodeCount), - static_cast(params.triCount), - static_cast(params.vertexNormalCount)); + cl::CommandQueue const & queue = m_computeContext->GetQueue(); + GpuKernelAccessGuard gpuAccess( + *m_computeContext, + queue, + "buildMeshVoxelGrid", + {{primitives.data.gpuResourceHandle(), GpuAccessMode::ReadWrite}}); + if (!gpuAccess.granted()) + { + return false; + } + + cl::Event const event = m_program.runNonBlockingWithWaitList( + queue, + "buildMeshVoxelGrid", + origin, + globalRange, + gpuAccess.waitEvents(), + primitives.data.getBuffer(), + static_cast(params.headerStart), + static_cast(params.voxelDataOffset), + static_cast(params.nodesOffset), + static_cast(params.trianglesOffset), + static_cast(params.normalsOffset), + static_cast(params.indicesOffset), + static_cast(params.edgeNeighborsOffset), + static_cast(params.nodeCount), + static_cast(params.triCount), + static_cast(params.vertexNormalCount)); + gpuAccess.complete(event); + if (event()) + { + event.wait(); + queue.finish(); + } return true; } @@ -141,17 +162,29 @@ namespace gladius cl::NDRange const origin = {0, 0, 0}; cl::NDRange const globalRange = {static_cast(params.nodeCount), 1, 1}; + GpuKernelAccessGuard gpuAccess( + *m_computeContext, + queue, + "buildMeshFwnAggregates", + {{primitives.data.gpuResourceHandle(), GpuAccessMode::ReadWrite}}); + if (!gpuAccess.granted()) + { + return false; + } + cl::Event const event = - m_program.runNonBlocking(queue, + m_program.runNonBlockingWithWaitList(queue, "buildMeshFwnAggregates", origin, globalRange, + gpuAccess.waitEvents(), primitives.data.getBuffer(), static_cast(params.nodesOffset), static_cast(params.trianglesOffset), static_cast(params.fwnAggregatesOffset), static_cast(params.nodeCount), static_cast(params.triCount)); + gpuAccess.complete(event); return event() != nullptr; } @@ -177,35 +210,46 @@ namespace gladius cl::CommandQueue const & queue = m_computeContext->GetQueue(); cl::NDRange const origin = {0, 0, 0}; + GpuKernelAccessGuard gpuAccess(*m_computeContext, + queue, + "buildMeshSignCache", + {{primitives.data.gpuResourceHandle(), GpuAccessMode::ReadWrite}}); + if (!gpuAccess.granted()) + { + return false; + } + int const firstWord = std::clamp(params.baseWord, 0, params.wordCount); int const lastWord = std::min(params.wordCount, firstWord + params.wordsToBuild); + cl::Event lastEvent{}; for (int baseWord = firstWord; baseWord < lastWord; baseWord += wordsPerBatch) { int const batchWordCount = std::min(wordsPerBatch, lastWord - baseWord); cl::NDRange const globalRange = {static_cast(batchWordCount), 1, 1}; - cl::Event const event = - m_program.runNonBlocking(queue, - "buildMeshSignCache", - origin, - globalRange, - primitives.data.getBuffer(), - static_cast(params.headerStart), - static_cast(params.signCacheDataOffset), - static_cast(params.nodesOffset), - static_cast(params.trianglesOffset), - static_cast(params.normalsOffset), - static_cast(params.indicesOffset), - static_cast(params.edgeNeighborsOffset), - static_cast(params.fwnAggregatesOffset), - static_cast(params.nodeCount), - static_cast(params.triCount), - static_cast(params.vertexNormalCount), - static_cast(params.resolution), - static_cast(baseWord), - static_cast(params.wordCount), - static_cast(params.fwnBeta)); - if (!event()) + lastEvent = m_program.runNonBlockingWithWaitList( + queue, + "buildMeshSignCache", + origin, + globalRange, + gpuAccess.waitEvents(), + primitives.data.getBuffer(), + static_cast(params.headerStart), + static_cast(params.signCacheDataOffset), + static_cast(params.nodesOffset), + static_cast(params.trianglesOffset), + static_cast(params.normalsOffset), + static_cast(params.indicesOffset), + static_cast(params.edgeNeighborsOffset), + static_cast(params.fwnAggregatesOffset), + static_cast(params.nodeCount), + static_cast(params.triCount), + static_cast(params.vertexNormalCount), + static_cast(params.resolution), + static_cast(baseWord), + static_cast(params.wordCount), + static_cast(params.fwnBeta)); + if (!lastEvent()) { return false; } @@ -213,24 +257,28 @@ namespace gladius if (!params.completesBuild) { + gpuAccess.complete(lastEvent); return true; } cl::Event const readyEvent = - m_program.runNonBlocking(queue, - "markMeshSignCacheReady", - origin, - cl::NDRange{1, 1, 1}, - primitives.data.getBuffer(), - static_cast(params.signCacheReadyOffset), - static_cast(params.signCacheDataOffset), - static_cast(params.signCacheBetaOffset), - static_cast(params.fwnBeta)); + m_program.runNonBlockingWithWaitList(queue, + "markMeshSignCacheReady", + origin, + cl::NDRange{1, 1, 1}, + gpuAccess.waitEvents(), + primitives.data.getBuffer(), + static_cast(params.signCacheReadyOffset), + static_cast(params.signCacheDataOffset), + static_cast(params.signCacheBetaOffset), + static_cast(params.fwnBeta)); if (!readyEvent()) { return false; } + gpuAccess.complete(readyEvent); + return true; } } diff --git a/gladius/src/compute/OpenClEventRegistry.cpp b/gladius/src/compute/OpenClEventRegistry.cpp new file mode 100644 index 00000000..b1eca160 --- /dev/null +++ b/gladius/src/compute/OpenClEventRegistry.cpp @@ -0,0 +1,123 @@ +#include "OpenClEventRegistry.h" + +#include + +namespace gladius +{ + GpuEventId OpenClEventRegistry::record(cl::Event const & event) + { + if (event() == nullptr) + { + return 0u; + } + + std::lock_guard lock(m_mutex); + GpuEventId const eventId = m_nextEventId++; + m_events.emplace(eventId, event); + return eventId; + } + + std::vector + OpenClEventRegistry::eventsFor(std::vector const & eventIds) const + { + std::lock_guard lock(m_mutex); + + std::vector events; + events.reserve(eventIds.size()); + for (auto const eventId : eventIds) + { + auto const eventIt = m_events.find(eventId); + if (eventIt != m_events.end() && eventIt->second() != nullptr) + { + events.push_back(eventIt->second); + } + } + return events; + } + + std::vector OpenClEventRegistry::pollCompleted() + { + std::lock_guard lock(m_mutex); + + std::vector completed; + for (auto eventIt = m_events.begin(); eventIt != m_events.end();) + { + if (isComplete(eventIt->second)) + { + completed.push_back(eventIt->first); + eventIt = m_events.erase(eventIt); + } + else + { + ++eventIt; + } + } + return completed; + } + + std::vector OpenClEventRegistry::waitFor(std::vector const & eventIds) + { + auto events = eventsFor(eventIds); + for (auto const & event : events) + { + if (event() != nullptr) + { + event.wait(); + } + } + + std::lock_guard lock(m_mutex); + std::vector completed; + completed.reserve(eventIds.size()); + for (auto const eventId : eventIds) + { + auto const eventIt = m_events.find(eventId); + if (eventIt != m_events.end()) + { + completed.push_back(eventId); + m_events.erase(eventIt); + } + } + return completed; + } + + std::vector OpenClEventRegistry::waitForAll() + { + std::vector eventIds; + { + std::lock_guard lock(m_mutex); + eventIds.reserve(m_events.size()); + for (auto const & [eventId, event] : m_events) + { + (void) event; + eventIds.push_back(eventId); + } + } + + return waitFor(eventIds); + } + + bool OpenClEventRegistry::contains(GpuEventId const eventId) const + { + std::lock_guard lock(m_mutex); + return m_events.find(eventId) != m_events.end(); + } + + bool OpenClEventRegistry::isComplete(cl::Event const & event) noexcept + { + if (event() == nullptr) + { + return true; + } + + try + { + auto const status = event.getInfo(); + return status == CL_COMPLETE; + } + catch (...) + { + return true; + } + } +} diff --git a/gladius/src/compute/OpenClEventRegistry.h b/gladius/src/compute/OpenClEventRegistry.h new file mode 100644 index 00000000..96419c9a --- /dev/null +++ b/gladius/src/compute/OpenClEventRegistry.h @@ -0,0 +1,29 @@ +#pragma once + +#include "GpuAccessCoordinator.h" +#include "gpgpu.h" + +#include +#include +#include + +namespace gladius +{ + class OpenClEventRegistry + { + public: + [[nodiscard]] GpuEventId record(cl::Event const & event); + [[nodiscard]] std::vector eventsFor(std::vector const & eventIds) const; + [[nodiscard]] std::vector pollCompleted(); + [[nodiscard]] std::vector waitFor(std::vector const & eventIds); + [[nodiscard]] std::vector waitForAll(); + [[nodiscard]] bool contains(GpuEventId eventId) const; + + private: + [[nodiscard]] static bool isComplete(cl::Event const & event) noexcept; + + mutable std::mutex m_mutex; + GpuEventId m_nextEventId{1u}; + std::unordered_map m_events; + }; +} diff --git a/gladius/src/compute/RenderPayloadSnapshot.h b/gladius/src/compute/RenderPayloadSnapshot.h new file mode 100644 index 00000000..f7af40a5 --- /dev/null +++ b/gladius/src/compute/RenderPayloadSnapshot.h @@ -0,0 +1,56 @@ +#pragma once + +#include "GpuKernelAccessGuard.h" +#include "Primitives.h" +#include "ResourceContext.h" + +#include + +namespace gladius +{ + struct RenderPayloadSnapshot + { + GpuResourceHandle primitiveMeta{}; + GpuResourceHandle primitiveData{}; + GpuResourceHandle precomputedSdf{}; + GpuResourceHandle parameterBuffer{}; + GpuResourceHandle commandBuffer{}; + + [[nodiscard]] static RenderPayloadSnapshot capture(SharedResources const & resources, + Primitives const & primitives) + { + return RenderPayloadSnapshot{ + .primitiveMeta = primitives.primitives.gpuResourceHandle(), + .primitiveData = primitives.data.gpuResourceHandle(), + .precomputedSdf = resources->getPrecompSdfBuffer().gpuResourceHandle(), + .parameterBuffer = resources->getParameterBuffer().gpuResourceHandle(), + .commandBuffer = resources->getCommandBuffer().gpuResourceHandle()}; + } + + [[nodiscard]] std::vector readAccesses() const + { + return {{primitiveMeta, GpuAccessMode::Read}, + {primitiveData, GpuAccessMode::Read}, + {precomputedSdf, GpuAccessMode::Read}, + {parameterBuffer, GpuAccessMode::Read}, + {commandBuffer, GpuAccessMode::Read}}; + } + + [[nodiscard]] bool isCurrent(GpuAccessCoordinator const & coordinator) const + { + return isHandleCurrent(coordinator, primitiveMeta) && + isHandleCurrent(coordinator, primitiveData) && + isHandleCurrent(coordinator, precomputedSdf) && + isHandleCurrent(coordinator, parameterBuffer) && + isHandleCurrent(coordinator, commandBuffer); + } + + private: + [[nodiscard]] static bool isHandleCurrent(GpuAccessCoordinator const & coordinator, + GpuResourceHandle handle) + { + auto current = coordinator.currentHandle(handle.resourceId); + return current.has_value() && *current == handle; + } + }; +} diff --git a/gladius/src/mcp/tools/FunctionEvaluatorTool.cpp b/gladius/src/mcp/tools/FunctionEvaluatorTool.cpp index 93c5598c..42e07313 100644 --- a/gladius/src/mcp/tools/FunctionEvaluatorTool.cpp +++ b/gladius/src/mcp/tools/FunctionEvaluatorTool.cpp @@ -10,7 +10,9 @@ #include "../../Document.h" #include "../../ResourceContext.h" #include "../../compute/ComputeCore.h" +#include "../../compute/GpuKernelAccessGuard.h" #include "../../compute/ProgramManager.h" +#include "../../compute/RenderPayloadSnapshot.h" #include "../../nodes/Assembly.h" #include "../../nodes/Model.h" #include "../../nodes/Parameter.h" @@ -677,11 +679,28 @@ namespace gladius::mcp::tools auto queue = computeContext->createQueue(); auto primitives = core->getPrimitives(); - evalProg.run( + if (!primitives) + { + return createToolError("Primitive buffers are not available."); + } + + auto payloadSnapshot = RenderPayloadSnapshot::capture(resources, *primitives); + GpuKernelAccessGuard gpuAccess( + *computeContext, + queue, + "gladius_eval", + payloadSnapshot.readAccesses()); + if (!gpuAccess.granted()) + { + return createToolError("GPU resources are not available for evaluation."); + } + + cl::Event const evalEvent = evalProg.runNonBlockingWithWaitList( queue, "gladius_eval", cl::NullRange, cl::NDRange(sampleCount), + gpuAccess.waitEvents(), *clInputBuffer, *clOutputBuffer, static_cast(sampleCount), @@ -696,6 +715,12 @@ namespace gladius::mcp::tools resources->getCommandBuffer().getBuffer(), static_cast(resources->getCommandBuffer().getData().size()), resources->getPreCompSdfBBox()); + gpuAccess.complete(evalEvent); + if (evalEvent()) + { + evalEvent.wait(); + } + CL_ERROR(queue.finish()); // Read back results. std::vector outputData(outputFloatCount); diff --git a/gladius/src/ui/render/AsyncRenderController.cpp b/gladius/src/ui/render/AsyncRenderController.cpp index f44f738f..cba830b0 100644 --- a/gladius/src/ui/render/AsyncRenderController.cpp +++ b/gladius/src/ui/render/AsyncRenderController.cpp @@ -992,10 +992,12 @@ namespace gladius::ui::async_rendering auto const state = buffer.state.load(std::memory_order_acquire); auto const bufEpoch = buffer.epoch.load(std::memory_order_acquire); - // Release Writing buffers from old epochs - if (state == FrameState::Writing && bufEpoch <= oldEpoch) + // Ready preview frames are complete and may be discarded when stale. + // Writing frames are still owned by a worker/GPU operation; recycling them here can + // race an in-flight kernel or host download. + if (state == FrameState::Ready && bufEpoch <= oldEpoch) { - auto expected = FrameState::Writing; + auto expected = FrameState::Ready; buffer.state.compare_exchange_strong(expected, FrameState::Idle, std::memory_order_acq_rel); } diff --git a/gladius/tests/unittests/compute/GpuAccessCoordinator_tests.cpp b/gladius/tests/unittests/compute/GpuAccessCoordinator_tests.cpp new file mode 100644 index 00000000..6de5d875 --- /dev/null +++ b/gladius/tests/unittests/compute/GpuAccessCoordinator_tests.cpp @@ -0,0 +1,269 @@ +#include "compute/GpuAccessCoordinator.h" + +#include + +#include +#include +#include +#include + +namespace gladius::tests +{ + namespace + { + [[nodiscard]] bool containsEvent(std::vector const & events, + GpuEventId const eventId) + { + return std::find(events.begin(), events.end(), eventId) != events.end(); + } + + [[nodiscard]] GpuAccessPlan beginRead(GpuAccessCoordinator & coordinator, + GpuResourceHandle resource, + GpuQueueId queueId = 1u, + std::string operationName = "read") + { + return coordinator.beginAccess(GpuAccessRequest{.resource = resource, + .queueId = queueId, + .mode = GpuAccessMode::Read, + .operationName = std::move(operationName)}); + } + + [[nodiscard]] GpuAccessPlan beginWrite(GpuAccessCoordinator & coordinator, + GpuResourceHandle resource, + GpuQueueId queueId = 1u, + std::string operationName = "write") + { + return coordinator.beginAccess(GpuAccessRequest{.resource = resource, + .queueId = queueId, + .mode = GpuAccessMode::Write, + .operationName = std::move(operationName)}); + } + + void complete(GpuAccessCoordinator & coordinator, + GpuAccessPlan const & plan, + GpuEventId eventId) + { + ASSERT_TRUE(plan.granted()); + EXPECT_EQ(coordinator.completeAccess(plan.token, eventId), GpuAccessStatus::Granted); + } + } + + TEST(GpuAccessCoordinator, BeginAccess_ReadAfterWrite_ReturnsWriterDependency) + { + GpuAccessCoordinator coordinator; + auto const resource = coordinator.registerResource(GpuResourceKind::Buffer, "parameter buffer"); + auto const write = beginWrite(coordinator, resource, 1u, "upload parameters"); + complete(coordinator, write, 10u); + + auto const read = beginRead(coordinator, resource, 2u, "render reads parameters"); + + ASSERT_TRUE(read.granted()); + ASSERT_EQ(read.waitEvents.size(), 1u); + EXPECT_EQ(read.waitEvents.front(), 10u); + } + + TEST(GpuAccessCoordinator, BeginAccess_WriteAfterRead_ReturnsAllReaderDependencies) + { + GpuAccessCoordinator coordinator; + auto const resource = coordinator.registerResource(GpuResourceKind::Image3D, "precomputed sdf"); + auto const previewRead = beginRead(coordinator, resource, 1u, "preview samples sdf"); + complete(coordinator, previewRead, 11u); + auto const hqRead = beginRead(coordinator, resource, 2u, "hq render samples sdf"); + complete(coordinator, hqRead, 12u); + + auto const sdfWrite = beginWrite(coordinator, resource, 3u, "recompute sdf"); + + ASSERT_TRUE(sdfWrite.granted()); + ASSERT_EQ(sdfWrite.waitEvents.size(), 2u); + EXPECT_TRUE(containsEvent(sdfWrite.waitEvents, 11u)); + EXPECT_TRUE(containsEvent(sdfWrite.waitEvents, 12u)); + } + + TEST(GpuAccessCoordinator, BeginAccess_ReadAfterRead_ReturnsNoDependencies) + { + GpuAccessCoordinator coordinator; + auto const resource = coordinator.registerResource(GpuResourceKind::Buffer, "primitive payload"); + auto const firstRead = beginRead(coordinator, resource, 1u, "preview render"); + complete(coordinator, firstRead, 21u); + + auto const secondRead = beginRead(coordinator, resource, 2u, "hq render"); + + ASSERT_TRUE(secondRead.granted()); + EXPECT_TRUE(secondRead.waitEvents.empty()); + } + + TEST(GpuAccessCoordinator, BeginAccess_WriteAfterWrite_ReturnsPreviousWriterDependency) + { + GpuAccessCoordinator coordinator; + auto const resource = coordinator.registerResource(GpuResourceKind::Buffer, "command buffer"); + auto const firstWrite = beginWrite(coordinator, resource, 1u, "build command stream"); + complete(coordinator, firstWrite, 31u); + + auto const secondWrite = beginWrite(coordinator, resource, 2u, "rebuild command stream"); + + ASSERT_TRUE(secondWrite.granted()); + ASSERT_EQ(secondWrite.waitEvents.size(), 1u); + EXPECT_EQ(secondWrite.waitEvents.front(), 31u); + } + + TEST(GpuAccessCoordinator, BeginAccess_AfterEventCompleted_ReturnsNoDependency) + { + GpuAccessCoordinator coordinator; + auto const resource = coordinator.registerResource(GpuResourceKind::Buffer, "metrics buffer"); + auto const write = beginWrite(coordinator, resource, 1u, "clear metrics"); + complete(coordinator, write, 41u); + coordinator.markEventCompleted(41u); + + auto const read = beginRead(coordinator, resource, 2u, "read metrics"); + + ASSERT_TRUE(read.granted()); + EXPECT_TRUE(read.waitEvents.empty()); + } + + TEST(GpuAccessCoordinator, BeginAccess_WithUnknownResource_ReturnsUnknownResource) + { + GpuAccessCoordinator coordinator; + + auto const read = beginRead(coordinator, + GpuResourceHandle{.resourceId = 404u, .generation = 1u}, + 1u, + "read missing resource"); + + EXPECT_EQ(read.status, GpuAccessStatus::UnknownResource); + EXPECT_FALSE(read.granted()); + } + + TEST(GpuAccessCoordinator, CompleteAccess_WithSameTokenTwice_ReturnsUnknownAccessToken) + { + GpuAccessCoordinator coordinator; + auto const resource = coordinator.registerResource(GpuResourceKind::Buffer, "parameter buffer"); + auto const write = beginWrite(coordinator, resource, 1u, "upload parameters"); + ASSERT_TRUE(write.granted()); + + EXPECT_EQ(coordinator.completeAccess(write.token, 42u), GpuAccessStatus::Granted); + EXPECT_EQ(coordinator.completeAccess(write.token, 43u), GpuAccessStatus::UnknownAccessToken); + } + + TEST(GpuAccessCoordinator, IsIdle_WithOutstandingThenCompletedEvent_TracksState) + { + GpuAccessCoordinator coordinator; + auto const resource = coordinator.registerResource(GpuResourceKind::Buffer, "command buffer"); + auto const write = beginWrite(coordinator, resource, 1u, "write commands"); + complete(coordinator, write, 44u); + + EXPECT_FALSE(coordinator.isIdle(resource)); + + coordinator.markEventCompleted(44u); + + EXPECT_TRUE(coordinator.isIdle(resource)); + EXPECT_TRUE(coordinator.outstandingEvents(resource).empty()); + } + + TEST(GpuAccessCoordinator, MarkEventsCompleted_WithHostWait_ClearsOutstandingAccesses) + { + GpuAccessCoordinator coordinator; + auto const resource = coordinator.registerResource(GpuResourceKind::Image2D, "distance init"); + auto const preview = beginWrite(coordinator, resource, 1u, "preview distance output"); + complete(coordinator, preview, 45u); + ASSERT_EQ(coordinator.outstandingEvents(resource).size(), 1u); + + coordinator.markEventsCompleted({45u}); + + auto const hqRead = beginRead(coordinator, resource, 2u, "hq distance init render"); + ASSERT_TRUE(hqRead.granted()); + EXPECT_TRUE(hqRead.waitEvents.empty()); + } + + TEST(GpuAccessCoordinator, BeginAccess_WriteWhileReadTokenPending_ReturnsPendingAccessWithoutEvent) + { + GpuAccessCoordinator coordinator; + auto const resource = coordinator.registerResource(GpuResourceKind::Buffer, "primitive payload"); + auto const read = beginRead(coordinator, resource, 1u, "render reads payload"); + ASSERT_TRUE(read.granted()); + + auto const write = beginWrite(coordinator, resource, 2u, "mesh cache build writes payload"); + + EXPECT_EQ(write.status, GpuAccessStatus::PendingAccessWithoutEvent); + EXPECT_FALSE(write.granted()); + } + + TEST(GpuAccessCoordinator, RetireCurrentGeneration_WithOutstandingWriter_KeepsGenerationUntilEventCompletes) + { + GpuAccessCoordinator coordinator; + auto const resource = coordinator.registerResource(GpuResourceKind::Image3D, "precomputed sdf"); + auto const write = beginWrite(coordinator, resource, 1u, "write sdf generation"); + complete(coordinator, write, 51u); + + auto const retirement = coordinator.retireCurrentGeneration(resource.resourceId); + + ASSERT_TRUE(retirement.granted()); + EXPECT_FALSE(retirement.canReleaseImmediately()); + ASSERT_EQ(retirement.waitEvents.size(), 1u); + EXPECT_EQ(retirement.waitEvents.front(), 51u); + EXPECT_EQ(coordinator.retiredGenerationCount(resource.resourceId), 1u); + + coordinator.collectCompletedRetirements(); + EXPECT_EQ(coordinator.retiredGenerationCount(resource.resourceId), 1u); + + coordinator.markEventCompleted(51u); + coordinator.collectCompletedRetirements(); + EXPECT_EQ(coordinator.retiredGenerationCount(resource.resourceId), 0u); + } + + TEST(GpuAccessCoordinator, BeginAccess_WithRetiredGeneration_ReturnsStaleGeneration) + { + GpuAccessCoordinator coordinator; + auto const oldGeneration = coordinator.registerResource(GpuResourceKind::Image2D, "low res preview"); + auto const retirement = coordinator.retireCurrentGeneration(oldGeneration.resourceId); + ASSERT_TRUE(retirement.granted()); + + auto const staleRead = beginRead(coordinator, oldGeneration, 1u, "stale preview resample"); + + EXPECT_EQ(staleRead.status, GpuAccessStatus::StaleGeneration); + EXPECT_FALSE(staleRead.granted()); + } + + TEST(GpuAccessCoordinator, Workflow_SdfPrecomputeBeforePreviewRender_AddsSdfDependency) + { + GpuAccessCoordinator coordinator; + auto const sdf = coordinator.registerResource(GpuResourceKind::Image3D, "precomputed sdf"); + auto const precompute = beginWrite(coordinator, sdf, 1u, "preComputeSdf"); + complete(coordinator, precompute, 61u); + + auto const preview = beginRead(coordinator, sdf, 2u, "renderLowResPreview"); + + ASSERT_TRUE(preview.granted()); + ASSERT_EQ(preview.waitEvents.size(), 1u); + EXPECT_EQ(preview.waitEvents.front(), 61u); + } + + TEST(GpuAccessCoordinator, Workflow_MeshCacheBuildBeforeRender_AddsPrimitivePayloadDependency) + { + GpuAccessCoordinator coordinator; + auto const primitivePayload = + coordinator.registerResource(GpuResourceKind::Buffer, "Primitives::data"); + auto const cacheBuild = beginWrite(coordinator, primitivePayload, 1u, "buildMeshSignCache"); + complete(coordinator, cacheBuild, 71u); + + auto const render = beginRead(coordinator, primitivePayload, 2u, "renderScene"); + + ASSERT_TRUE(render.granted()); + ASSERT_EQ(render.waitEvents.size(), 1u); + EXPECT_EQ(render.waitEvents.front(), 71u); + } + + TEST(GpuAccessCoordinator, Workflow_DistanceInitPreviewBeforeHqRender_AddsPreviewDependency) + { + GpuAccessCoordinator coordinator; + auto const distanceInit = + coordinator.registerResource(GpuResourceKind::Image2D, "distance init buffer"); + auto const preview = beginWrite(coordinator, distanceInit, 1u, "preview distance output"); + complete(coordinator, preview, 81u); + + auto const hqRender = beginRead(coordinator, distanceInit, 2u, "hq distance init render"); + + ASSERT_TRUE(hqRender.granted()); + ASSERT_EQ(hqRender.waitEvents.size(), 1u); + EXPECT_EQ(hqRender.waitEvents.front(), 81u); + } +} From 395aa7b2ef7af8f5c0ea60a53bdcd4d20c6936a0 Mon Sep 17 00:00:00 2001 From: Jan Orend <56254096+3dJan@users.noreply.github.com> Date: Sun, 17 May 2026 21:48:56 +0200 Subject: [PATCH 27/72] [ComputeContext] Enable GPU access safe mode by default and remove environment variable dependency for initialization --- docs/architecture/gpu_access_coordination.md | 2 +- gladius/src/ComputeContext.cpp | 4 ---- gladius/src/ComputeContext.h | 2 +- 3 files changed, 2 insertions(+), 6 deletions(-) diff --git a/docs/architecture/gpu_access_coordination.md b/docs/architecture/gpu_access_coordination.md index 98ae5860..020f8d7f 100644 --- a/docs/architecture/gpu_access_coordination.md +++ b/docs/architecture/gpu_access_coordination.md @@ -40,7 +40,7 @@ The current production rollout also prefers conservative generation retirement o ## Safe mode -Set `GLADIUS_GPU_ACCESS_SAFE_MODE=1` before launching Gladius to serialize guarded GPU work more aggressively. In safe mode, each guarded launch drains tracked GPU work before enqueue and waits for its own returned event before returning. This is slower, but useful for validating whether HSA/GPU faults are caused by dependency or lifetime races. +Guarded GPU access uses the conservative serialized mode by default. Each guarded launch drains tracked GPU work before enqueue and waits for its own returned event before returning. This is slower than maximally overlapping command queues, but it keeps the production behavior deterministic and avoids environment-dependent synchronization semantics. ## Adding a new kernel launch diff --git a/gladius/src/ComputeContext.cpp b/gladius/src/ComputeContext.cpp index 34ca264f..6796d4e2 100644 --- a/gladius/src/ComputeContext.cpp +++ b/gladius/src/ComputeContext.cpp @@ -7,7 +7,6 @@ #include #include -#include #include #include #include @@ -370,21 +369,18 @@ namespace gladius } ComputeContext::ComputeContext() - : m_gpuAccessSafeModeEnabled(std::getenv("GLADIUS_GPU_ACCESS_SAFE_MODE") != nullptr) { initContext(); } ComputeContext::ComputeContext(EnableGLOutput enableOutput) : m_outputGL(enableOutput) - , m_gpuAccessSafeModeEnabled(std::getenv("GLADIUS_GPU_ACCESS_SAFE_MODE") != nullptr) { initContext(); } ComputeContext::ComputeContext(EnableGLOutput enableOutput, Accelerator const & accelerator) : m_outputGL(enableOutput) - , m_gpuAccessSafeModeEnabled(std::getenv("GLADIUS_GPU_ACCESS_SAFE_MODE") != nullptr) { initContextWithAccelerator(accelerator); } diff --git a/gladius/src/ComputeContext.h b/gladius/src/ComputeContext.h index ab5ba69c..de5a0999 100644 --- a/gladius/src/ComputeContext.h +++ b/gladius/src/ComputeContext.h @@ -267,7 +267,7 @@ namespace gladius // Debug output control (instance-level) bool m_debugOutputEnabled{false}; - bool m_gpuAccessSafeModeEnabled{false}; + bool m_gpuAccessSafeModeEnabled{true}; // Device memory capabilities (queried at init) size_t m_deviceGlobalMemBytes{0}; From 71da1abe4a7edebfcac8133f86f694e07d714629 Mon Sep 17 00:00:00 2001 From: Jan Orend <56254096+3dJan@users.noreply.github.com> Date: Thu, 21 May 2026 20:31:45 +0200 Subject: [PATCH 28/72] [Model] Enhance output port lookup to handle stale unique names and log warnings for dropped links during model copy --- gladius/src/nodes/Model.cpp | 39 ++++++++++++++++++++++++---------- gladius/src/ui/ModelEditor.cpp | 23 ++++++++++++-------- 2 files changed, 42 insertions(+), 20 deletions(-) diff --git a/gladius/src/nodes/Model.cpp b/gladius/src/nodes/Model.cpp index f70f7177..971351d2 100644 --- a/gladius/src/nodes/Model.cpp +++ b/gladius/src/nodes/Model.cpp @@ -108,20 +108,37 @@ namespace gladius::nodes { if (parameter.second.getSource().has_value()) { - const auto portIter = - std::find_if(std::begin(m_outPorts), - std::end(m_outPorts), - [&](auto & port) - { - return port.second->getUniqueName() == - parameter.second.getSource().value().uniqueName; - }); + auto const & src = parameter.second.getSource().value(); + + // Primary lookup: by uniqueName (the normal path). + auto portIter = std::find_if(std::begin(m_outPorts), + std::end(m_outPorts), + [&](auto & port) + { + return port.second->getUniqueName() == + src.uniqueName; + }); + + // Fallback: by portId. This handles the case where a port was renamed + // after the link was established and the Source::uniqueName became stale. + if (portIter == std::end(m_outPorts)) + { + portIter = m_outPorts.find(src.portId); + } if (portIter == std::end(m_outPorts)) { - throw std::runtime_error( - fmt::format("Output port with the name {} could not be found", - parameter.second.getSource().value().uniqueName)); + if (m_logger) + { + m_logger->addEvent( + {fmt::format("Output port with the name {} could not be found — " + "link dropped during model copy", + src.uniqueName), + events::Severity::Warning}); + } + // Clear the stale source so the model remains in a consistent state. + parameter.second.setSource(std::nullopt); + continue; } bool const skipLinkValidation = true; addLink( diff --git a/gladius/src/ui/ModelEditor.cpp b/gladius/src/ui/ModelEditor.cpp index da723860..639ae927 100644 --- a/gladius/src/ui/ModelEditor.cpp +++ b/gladius/src/ui/ModelEditor.cpp @@ -1862,7 +1862,15 @@ namespace gladius::ui // Export overlay is now rendered at MainWindow level to block entire UI - if (m_nodeViewVisitor.haveParameterChanged()) + // Read per-frame flags before clearing them. clearPerFrameFlags() is called + // unconditionally BEFORE storeState() so that if storeState() throws, the + // flags are already cleared and won't re-trigger the same exception every + // subsequent frame (which would flood the ImGui log and cause a segfault). + bool const nodeViewParamChanged = m_nodeViewVisitor.haveParameterChanged(); + m_modelWasModified |= m_nodeViewVisitor.hasModelChanged(); + m_nodeViewVisitor.clearPerFrameFlags(); + + if (nodeViewParamChanged) { m_dirty = true; parameterChanged = true; @@ -1877,14 +1885,6 @@ namespace gladius::ui } } - m_modelWasModified |= m_nodeViewVisitor.hasModelChanged(); - - // Clear per-frame flags so they don't persist into the next frame. - // Without this, a single parameter change causes showAndEdit() to - // return true every subsequent frame, continuously bumping the async - // epoch and preventing HQ progressive rendering from ever starting. - m_nodeViewVisitor.clearPerFrameFlags(); - if (m_currentTabMode == TabMode::Graph) { updateInitialAutoLayoutReadiness(); @@ -1908,6 +1908,11 @@ namespace gladius::ui } catch (std::exception const & e) { + // Ensure ImGui window state is properly terminated even when an exception + // is thrown inside the Begin/End block, otherwise ImGui becomes corrupt. + ImGui::End(); + ImGui::PopStyleVar(); + if (m_doc && m_doc->getSharedLogger()) { m_doc->getSharedLogger()->addEvent( From 15f88be036d1cff41604ddc7773d1c7150627ec8 Mon Sep 17 00:00:00 2001 From: Jan Orend <56254096+3dJan@users.noreply.github.com> Date: Mon, 25 May 2026 10:29:40 +0200 Subject: [PATCH 29/72] [RenderWindow] Refactor render decision logic to improve handling of screen resize and preserve content during rendering --- gladius/src/ui/RenderWindow.cpp | 141 ++++++++++++++++++-------------- 1 file changed, 80 insertions(+), 61 deletions(-) diff --git a/gladius/src/ui/RenderWindow.cpp b/gladius/src/ui/RenderWindow.cpp index 17ba1d05..a0188f69 100644 --- a/gladius/src/ui/RenderWindow.cpp +++ b/gladius/src/ui/RenderWindow.cpp @@ -1227,9 +1227,6 @@ namespace gladius::ui // Mark model as modified for permanent centering m_modelModifiedSinceLastCenter = true; - // Reset first-time bounding box availability for new model - m_boundingBoxEverAvailable = false; - // Reset bounding box so it will be recomputed m_core->resetBoundingBox(); @@ -2087,10 +2084,71 @@ namespace gladius::ui int const desiredScreenHeight = static_cast( std::clamp(m_renderWindowSize_px.y * state.renderQuality, 1.f, 16000.f)); auto const resultImage = m_core->getResultImage(); - bool const screenResizeRequired = + bool screenResizeRequired = resultImage && (static_cast(resultImage->getWidth()) != desiredScreenWidth || static_cast(resultImage->getHeight()) != desiredScreenHeight); + // Treat render-target resize as a scheduling barrier: the pure coordinator's + // viewport stamp must not be advanced until the concrete CL/GL images have the + // matching size. Otherwise an old-size render job can complete with the new + // viewport stamp, making HQ/static catch-up and Auto realtime learning believe + // the resized viewport is already current. + bool const shouldDeferResize = m_preserveContentDuringResize && m_deferredResizePending; + bool const hqRenderInFlight = m_asyncJobInFlight.load(std::memory_order_acquire); + if (!shouldDeferResize && screenResizeRequired) + { + if (hqRenderInFlight) + { + m_dirty = true; + m_forceLowResRenderOnNextFrame.store(true, std::memory_order_release); + state.isRendering = false; + return; + } + + if (m_asyncController && m_asyncProgressiveBuffer != nullptr) + { + [[maybe_unused]] bool const released = + m_asyncController->tryTransitionBuffer(m_asyncProgressiveBuffer, + async_rendering::FrameState::Writing, + async_rendering::FrameState::Idle); + m_asyncProgressiveBuffer = nullptr; + m_asyncProgressiveEpoch.store(0, std::memory_order_release); + m_asyncProgressiveViewEpoch.store(0, std::memory_order_release); + state.currentLine = 0; + state.isRendering = false; + } + + bool const resized = m_core->setScreenResolution(desiredScreenWidth, desiredScreenHeight); + auto const latestImage = m_core->getResultImage(); + bool const resizeStillRequired = + !latestImage || static_cast(latestImage->getWidth()) != desiredScreenWidth || + static_cast(latestImage->getHeight()) != desiredScreenHeight; + if (resizeStillRequired) + { + // setScreenResolution can legitimately fail while another compute operation + // holds the token. Keep the old frame visible and retry next UI frame instead + // of scheduling work against stale dimensions. + m_dirty = true; + m_forceLowResRenderOnNextFrame.store(true, std::memory_order_release); + state.isRendering = false; + return; + } + + screenResizeRequired = false; + if (resized) + { + invalidateView(); + } + } + + // Clear preserve flags once rendering may resume. The early render() guard normally + // handles this on the first stable-size frame; this branch is a defensive fallback. + if (shouldDeferResize && m_forceLowResRenderOnNextFrame.load(std::memory_order_acquire)) + { + m_preserveContentDuringResize = false; + m_deferredResizePending = false; + } + if (m_asyncController && m_asyncController->isRunning()) { queueRenderDecision(m_renderUpdateCoordinator.configureViewport( @@ -2297,56 +2355,6 @@ namespace gladius::ui m_core->getResourceContext()->getRenderingSettings().approximation = AM_FULL_MODEL; } - // Defer buffer reallocation during resize to preserve visible content. Also wait until - // async HQ rendering has fully drained before reallocating the displayed CL/GL image: - // worker jobs copy their staging image back into m_resultImage after the render kernel - // completes, and resizing that image concurrently can crash inside clEnqueueCopyImage. - bool const shouldDeferResize = m_preserveContentDuringResize && m_deferredResizePending; - bool const hqRenderInFlight = m_asyncJobInFlight.load(std::memory_order_acquire); - - if (!shouldDeferResize && screenResizeRequired && !hqRenderInFlight && - m_asyncProgressiveBuffer != nullptr) - { - [[maybe_unused]] bool const released = - m_asyncController->tryTransitionBuffer(m_asyncProgressiveBuffer, - async_rendering::FrameState::Writing, - async_rendering::FrameState::Idle); - m_asyncProgressiveBuffer = nullptr; - m_asyncProgressiveEpoch.store(0, std::memory_order_release); - state.currentLine = 0; - state.isRendering = false; - } - - if (!shouldDeferResize && screenResizeRequired && hqRenderInFlight) - { - m_dirty = true; - m_forceLowResRenderOnNextFrame.store(true, std::memory_order_release); - state.isRendering = false; - return; - } - - if (!shouldDeferResize && - m_core->setScreenResolution(desiredScreenWidth, desiredScreenHeight)) - { - // Resolution changed - mark dirty but DON'T bump epoch if already rendering - // (bumping epoch would cancel in-flight jobs) - if (!state.isRendering && !m_asyncJobInFlight.load(std::memory_order_acquire)) - { - invalidateView(); - } - else - { - m_dirty = true; - } - } - - // Clear preserve flags once low-res preview starts rendering - if (shouldDeferResize && m_forceLowResRenderOnNextFrame.load(std::memory_order_acquire)) - { - m_preserveContentDuringResize = false; - m_deferredResizePending = false; - } - std::pair const lowResPreviewResolution = m_core->getLowResPreviewResolution(); int newWidth = static_cast( @@ -2562,6 +2570,11 @@ namespace gladius::ui bool const isFullFrameRenderJob = isExactRealtimeJob || isStaticFullFrameProbe; bool const isHqRenderJob = jobType == async_rendering::RenderJobType::HighQuality || isFullFrameRenderJob; + auto const currentResultImage = m_core ? m_core->getResultImage() : SharedGLImageBuffer{}; + bool const isTargetSizeOutdated = + isHqRenderJob && currentResultImage && + (static_cast(currentResultImage->getWidth()) != result.width || + static_cast(currentResultImage->getHeight()) != result.height); bool const isViewOutdated = isHqRenderJob && result.viewEpoch < currentViewEpoch; if (result.jobType == async_rendering::RenderJobType::SDFPrecomputation) @@ -2647,11 +2660,12 @@ namespace gladius::ui sample.completedFrame = result.completedFrame && result.startLine == 0 && renderedLines >= static_cast(result.height); sample.cancelled = result.cancelled; - if (isStaticFullFrameProbe && !isOutdated && !isViewOutdated) + if (isStaticFullFrameProbe && !isOutdated && !isViewOutdated && + !isTargetSizeOutdated) { m_renderUpdateCoordinator.recordStaticFullFrameSample(sample); } - else if (isExactRealtimeJob) + else if (isExactRealtimeJob && !isTargetSizeOutdated) { if (m_renderUpdateCoordinator.interactionState() == async_rendering::RenderInteractionState::Static) @@ -2664,6 +2678,7 @@ namespace gladius::ui } } else if (!isOutdated && !isViewOutdated && + !isTargetSizeOutdated && m_renderUpdateCoordinator.interactionState() == async_rendering::RenderInteractionState::Static) { m_renderUpdateCoordinator.recordStaticProgressiveSample(sample); @@ -2703,7 +2718,8 @@ namespace gladius::ui } // Update progressive scheduling state only for current scene/view results. - if (!isOutdated && !isViewOutdated && result.jobType == async_rendering::RenderJobType::HighQuality) + if (!isOutdated && !isViewOutdated && !isTargetSizeOutdated && + result.jobType == async_rendering::RenderJobType::HighQuality) { size_t const maxHeight = static_cast(result.height); state.currentLine = std::min(result.completedLine, maxHeight); @@ -2717,7 +2733,8 @@ namespace gladius::ui // Interactive realtime raymarching frames (both Auto and Force) need to be displayed even if // the view epoch has incremented, otherwise continuous camera motion freezes. bool const allowForcedRealtimeStaleView = isExactRealtimeJob; - bool const discardForStaleView = isViewOutdated && !allowForcedRealtimeStaleView; + bool const discardForStaleView = + isTargetSizeOutdated || (isViewOutdated && !allowForcedRealtimeStaleView); if (result.completedFrame) { @@ -2786,7 +2803,7 @@ namespace gladius::ui } else { - if (isViewOutdated && m_asyncProgressiveBuffer && + if ((isViewOutdated || isTargetSizeOutdated) && m_asyncProgressiveBuffer && m_asyncProgressiveViewEpoch.load(std::memory_order_acquire) == result.viewEpoch) { [[maybe_unused]] bool const released = @@ -2822,13 +2839,15 @@ namespace gladius::ui // If we just consumed an outdated job result, ensure we don't block scheduling for // the new epoch. - if (isOutdated || isViewOutdated) + if (isOutdated || isViewOutdated || isTargetSizeOutdated) { state.isRendering = false; } } - completeCoordinatorTask(result, result.completedFrame && !isOutdated && !isViewOutdated); + completeCoordinatorTask(result, + result.completedFrame && !isOutdated && !isViewOutdated && + !isTargetSizeOutdated); } } From 9edeb1963b3a62c90b7685d38340f2c8bf9e7ff5 Mon Sep 17 00:00:00 2001 From: Jan Orend <56254096+3dJan@users.noreply.github.com> Date: Mon, 25 May 2026 11:23:32 +0200 Subject: [PATCH 30/72] [RenderWindow] Add m_asyncStaticFullFrameJobInFlight flag to manage progressive buffer display during StaticFullFrameProbe rendering --- gladius/src/ui/RenderWindow.cpp | 75 +++++++++++++++++++++++++++++++-- gladius/src/ui/RenderWindow.h | 3 ++ 2 files changed, 74 insertions(+), 4 deletions(-) diff --git a/gladius/src/ui/RenderWindow.cpp b/gladius/src/ui/RenderWindow.cpp index a0188f69..9daaca0b 100644 --- a/gladius/src/ui/RenderWindow.cpp +++ b/gladius/src/ui/RenderWindow.cpp @@ -792,7 +792,8 @@ namespace gladius::ui .viewEpoch = frontBuf ? frontBuf->viewEpoch.load(std::memory_order_acquire) : 0u}; auto const progressiveState = async_rendering::DisplayFrameBufferState{ .hasImage = m_asyncProgressiveBuffer && - m_asyncProgressiveBuffer->image != nullptr, + m_asyncProgressiveBuffer->image != nullptr && + !m_asyncStaticFullFrameJobInFlight.load(std::memory_order_acquire), .epoch = m_asyncProgressiveEpoch.load(std::memory_order_acquire), .viewEpoch = m_asyncProgressiveViewEpoch.load(std::memory_order_acquire)}; auto const displaySource = async_rendering::selectDisplayFrameSource( @@ -2709,6 +2710,10 @@ namespace gladius::ui { m_asyncRealtimeJobInFlight.store(false, std::memory_order_release); } + if (isStaticFullFrameProbe) + { + m_asyncStaticFullFrameJobInFlight.store(false, std::memory_order_release); + } m_asyncInFlightEpoch.store(0, std::memory_order_release); m_asyncInFlightViewEpoch.store(0, std::memory_order_release); state.isRendering = false; @@ -2815,6 +2820,22 @@ namespace gladius::ui m_asyncProgressiveViewEpoch.store(0, std::memory_order_release); state.isRendering = false; } + else if (m_asyncStaticFullFrameJobInFlight.load(std::memory_order_acquire) && + m_asyncProgressiveBuffer && + m_asyncProgressiveEpoch.load(std::memory_order_acquire) == result.epoch) + { + // A full-frame probe was scheduled while this chunk was in-flight. + // The chunk kernel is now done (result has arrived), so it is safe to release + // the progressive buffer. This prevents the partial chunk content (stale bottom) + // from being displayed while the probe renders. + [[maybe_unused]] bool const released = + m_asyncController->tryTransitionBuffer(m_asyncProgressiveBuffer, + async_rendering::FrameState::Writing, + async_rendering::FrameState::Idle); + m_asyncProgressiveBuffer = nullptr; + m_asyncProgressiveEpoch.store(0, std::memory_order_release); + m_asyncProgressiveViewEpoch.store(0, std::memory_order_release); + } else if (!isOutdated && !isViewOutdated && m_asyncProgressiveBuffer && m_asyncProgressiveBuffer->image) { @@ -2834,6 +2855,10 @@ namespace gladius::ui { m_asyncRealtimeJobInFlight.store(false, std::memory_order_release); } + if (isStaticFullFrameProbe) + { + m_asyncStaticFullFrameJobInFlight.store(false, std::memory_order_release); + } m_asyncInFlightEpoch.store(0, std::memory_order_release); m_asyncInFlightViewEpoch.store(0, std::memory_order_release); @@ -2845,6 +2870,15 @@ namespace gladius::ui } } + // A StaticFullFrameProbe result clears the flag regardless of the epoch match, + // because when a progressive chunk was in-flight when the probe was scheduled the + // chunk's result consumes m_asyncInFlightEpoch first, leaving no epoch match for + // the later probe result. + if (isStaticFullFrameProbe) + { + m_asyncStaticFullFrameJobInFlight.store(false, std::memory_order_release); + } + completeCoordinatorTask(result, result.completedFrame && !isOutdated && !isViewOutdated && !isTargetSizeOutdated); @@ -3181,6 +3215,9 @@ namespace gladius::ui m_asyncJobInFlight.store(true, std::memory_order_release); m_asyncRealtimeJobInFlight.store(job.type == async_rendering::RenderJobType::RealtimeHighQuality, std::memory_order_release); + m_asyncStaticFullFrameJobInFlight.store( + job.type == async_rendering::RenderJobType::StaticFullFrameProbe, + std::memory_order_release); m_asyncController->enqueueJob(job); ZoneText("FullFrameJobEnqueued", 20); @@ -3256,7 +3293,32 @@ namespace gladius::ui writeBuffer = nullptr; }; - if (!m_asyncProgressiveBuffer || + if (usesFullModelApproximation) + { + // Full-frame jobs (StaticFullFrameProbe / RealtimeHighQuality): acquire a dedicated + // write buffer without reading or writing m_asyncProgressiveBuffer. If a progressive + // chunk was running before this probe was scheduled, the UI thread keeps a reference + // to that partial chunk buffer. Setting m_asyncProgressiveBuffer to the probe buffer + // from this worker thread would cause the display to show the probe's write buffer + // (with stale partial content) until the probe completes. + writeBuffer = m_asyncController->acquireWriteBuffer(job.epoch); + if (!writeBuffer) + { + result.cancelled = true; + co_return result; + } + if (writeBuffer->image) + { + if (writeBuffer->image->getWidth() < job.width || + writeBuffer->image->getHeight() < job.height) + { + releaseProgressiveBuffer(); + result.cancelled = true; + co_return result; + } + } + } + else if (!m_asyncProgressiveBuffer || m_asyncProgressiveEpoch.load(std::memory_order_acquire) != job.epoch) { writeBuffer = m_asyncController->acquireWriteBuffer(job.epoch); @@ -3384,9 +3446,14 @@ namespace gladius::ui result.viewEpoch, result.coordinatorStamp, async_rendering::FramePresentationQuality::FullQuality); - m_asyncProgressiveBuffer = nullptr; - m_asyncProgressiveEpoch.store(0, std::memory_order_release); + // Only clear m_asyncProgressiveBuffer for progressive rendering (not full-frame jobs). + // Full-frame jobs never set m_asyncProgressiveBuffer, so there is nothing to clear. + if (!usesFullModelApproximation) + { + m_asyncProgressiveBuffer = nullptr; + m_asyncProgressiveEpoch.store(0, std::memory_order_release); m_asyncProgressiveViewEpoch.store(0, std::memory_order_release); + } } auto const endTime = std::chrono::steady_clock::now(); diff --git a/gladius/src/ui/RenderWindow.h b/gladius/src/ui/RenderWindow.h index 8818f1d3..be00dd00 100644 --- a/gladius/src/ui/RenderWindow.h +++ b/gladius/src/ui/RenderWindow.h @@ -380,6 +380,9 @@ namespace gladius::ui std::atomic m_asyncFrameCounter{0}; std::atomic m_asyncJobInFlight{false}; std::atomic m_asyncRealtimeJobInFlight{false}; + /// Set when a StaticFullFrameProbe job is in-flight; used to suppress the progressive + /// buffer from display so the partial chunk content is not shown while the probe renders. + std::atomic m_asyncStaticFullFrameJobInFlight{false}; std::atomic m_asyncBboxJobInFlight{false}; std::atomic m_asyncBboxUpdatePending{ false}; // Tracks if bbox needs update after current job From 9cc886c74eea97682bb8ff74464dae64f5ea55c8 Mon Sep 17 00:00:00 2001 From: Jan Orend <56254096+3dJan@users.noreply.github.com> Date: Mon, 25 May 2026 13:50:13 +0200 Subject: [PATCH 31/72] [ModelEditor] Extract example constants from library file and create pre-wired constant nodes for FunctionCall arguments [RenderWindow] Introduce m_asyncStaticFullFrameJobInFlight flag to manage full frame rendering state [DisplayFrameSelector] Add fullFrameRenderJobInFlight flag to track full frame rendering status in display frame buffer state --- gladius/src/ui/ModelEditor.cpp | 65 +++++++++++++++++++- gladius/src/ui/ModelEditor.h | 13 ++-- gladius/src/ui/RenderWindow.cpp | 13 +++- gladius/src/ui/render/DisplayFrameSelector.h | 10 ++- 4 files changed, 91 insertions(+), 10 deletions(-) diff --git a/gladius/src/ui/ModelEditor.cpp b/gladius/src/ui/ModelEditor.cpp index 639ae927..39bd9194 100644 --- a/gladius/src/ui/ModelEditor.cpp +++ b/gladius/src/ui/ModelEditor.cpp @@ -2285,6 +2285,14 @@ namespace gladius::ui targetFunctionName = fileInfo.libraryFunctionNames.front(); } + // Extract example constants from the library file before pruning modifies it. + std::vector exampleConstants; + if (!targetFunctionName.empty()) + { + exampleConstants = + io::extractExampleConstants(fileInfo.filePath, targetFunctionName); + } + // Refresh the assembly pointer — the document may have replaced it // since we last captured it (e.g. during file load). refreshAssembly(); @@ -2314,11 +2322,13 @@ namespace gladius::ui return; } - createFunctionCallNodeAtCursor(match.id, match.model); + createFunctionCallNodeAtCursor(match.id, match.model, exampleConstants); } - void ModelEditor::createFunctionCallNodeAtCursor(nodes::ResourceId functionId, - nodes::SharedModel const & sourceModel) + void ModelEditor::createFunctionCallNodeAtCursor( + nodes::ResourceId functionId, + nodes::SharedModel const & sourceModel, + std::vector const & exampleConstants) { createUndoRestorePoint("Import library function"); @@ -2333,6 +2343,55 @@ namespace gladius::ui auto * createdNode = m_currentModel->createFunctionCallNode(functionId, *sourceModel); ed::SetNodePosition(createdNode->getId(), posOnCanvas); + // Create pre-wired constant nodes for any argument that has an example value. + if (!exampleConstants.empty()) + { + auto const & arguments = createdNode->getArguments(); + constexpr float kConstantOffsetX = -250.0f; + constexpr float kConstantSpacingY = 80.0f; + + int constantIndex = 0; + for (auto const & [argName, argParam] : arguments) + { + auto it = std::find_if( + exampleConstants.begin(), + exampleConstants.end(), + [&argName](io::ExampleConstantValue const & cv) + { return cv.parameterName == argName; }); + + if (it == exampleConstants.end()) + { + continue; + } + + ImVec2 const constPos{posOnCanvas.x + kConstantOffsetX, + posOnCanvas.y + constantIndex * kConstantSpacingY}; + + if (it->kind == io::ExampleConstantValue::Kind::Scalar) + { + auto * constNode = m_currentModel->create(); + constNode->parameter().at(FieldNames::Value).setValue(it->scalarValue); + constNode->setDisplayName(NodeName(argName)); + ed::SetNodePosition(constNode->getId(), constPos); + m_currentModel->addLink(constNode->getValueOutputPort().getId(), + argParam->getId()); + } + else if (it->kind == io::ExampleConstantValue::Kind::Vector) + { + auto * constNode = m_currentModel->create(); + constNode->parameter().at(FieldNames::X).setValue(it->vectorValue.x); + constNode->parameter().at(FieldNames::Y).setValue(it->vectorValue.y); + constNode->parameter().at(FieldNames::Z).setValue(it->vectorValue.z); + constNode->setDisplayName(NodeName(argName)); + ed::SetNodePosition(constNode->getId(), constPos); + m_currentModel->addLink(constNode->getVectorOutputPort().getId(), + argParam->getId()); + } + + ++constantIndex; + } + } + requestNodeFocus(createdNode->getId()); markModelAsModified(); } diff --git a/gladius/src/ui/ModelEditor.h b/gladius/src/ui/ModelEditor.h index c54389fa..69287c9d 100644 --- a/gladius/src/ui/ModelEditor.h +++ b/gladius/src/ui/ModelEditor.h @@ -30,6 +30,7 @@ #include "Outline.h" #include "ResourceView.h" +#include "io/3mf/LibraryExampleExtractor.h" #include "io/VdbImporter.h" #include "Style.h" #include "compute/ComputeCore.h" @@ -257,10 +258,14 @@ namespace gladius::ui void importStlDrop(std::filesystem::path const & path, ImVec2 screenPos); /// @brief Create a FunctionCall node at the current cursor position. - /// @param functionId The resource ID of the function to call. - /// @param sourceModel The model providing inputs/outputs for the FunctionCall. - void createFunctionCallNodeAtCursor(nodes::ResourceId functionId, - nodes::SharedModel const & sourceModel); + /// @param functionId The resource ID of the function to call. + /// @param sourceModel The model providing inputs/outputs for the FunctionCall. + /// @param exampleConstants Optional constant nodes (with example values) to create and + /// wire to the FunctionCall arguments. + void createFunctionCallNodeAtCursor( + nodes::ResourceId functionId, + nodes::SharedModel const & sourceModel, + std::vector const & exampleConstants = {}); // Helper method to check if a string matches the current filter bool matchesNodeFilter(const std::string & text) const; diff --git a/gladius/src/ui/RenderWindow.cpp b/gladius/src/ui/RenderWindow.cpp index 9daaca0b..30ea795e 100644 --- a/gladius/src/ui/RenderWindow.cpp +++ b/gladius/src/ui/RenderWindow.cpp @@ -299,6 +299,7 @@ namespace gladius::ui m_asyncFrameCounter.store(0, std::memory_order_release); m_asyncJobInFlight.store(false, std::memory_order_release); m_asyncRealtimeJobInFlight.store(false, std::memory_order_release); + m_asyncStaticFullFrameJobInFlight.store(false, std::memory_order_release); m_asyncSdfJobInFlight.store(false, std::memory_order_release); m_asyncSdfInFlightEpoch.store(0, std::memory_order_release); m_renderUpdateCoordinator = async_rendering::RenderUpdateCoordinator{}; @@ -321,6 +322,7 @@ namespace gladius::ui m_asyncFrameCounter.store(0, std::memory_order_release); m_asyncJobInFlight.store(false, std::memory_order_release); m_asyncRealtimeJobInFlight.store(false, std::memory_order_release); + m_asyncStaticFullFrameJobInFlight.store(false, std::memory_order_release); m_asyncSdfJobInFlight.store(false, std::memory_order_release); m_asyncSdfInFlightEpoch.store(0, std::memory_order_release); } @@ -785,6 +787,9 @@ namespace gladius::ui bool const exactRealtimeInteraction = isRealtimeRaymarchInteractionActive(); bool const exactRealtimeJobInFlight = m_asyncRealtimeJobInFlight.load(std::memory_order_acquire); + bool const fullFrameRenderJobInFlight = + exactRealtimeJobInFlight || + m_asyncStaticFullFrameJobInFlight.load(std::memory_order_acquire); auto const resultImage = m_core->getResultImage(); auto const frontState = async_rendering::DisplayFrameBufferState{ .hasImage = frontBuf && frontBuf->image != nullptr, @@ -792,8 +797,7 @@ namespace gladius::ui .viewEpoch = frontBuf ? frontBuf->viewEpoch.load(std::memory_order_acquire) : 0u}; auto const progressiveState = async_rendering::DisplayFrameBufferState{ .hasImage = m_asyncProgressiveBuffer && - m_asyncProgressiveBuffer->image != nullptr && - !m_asyncStaticFullFrameJobInFlight.load(std::memory_order_acquire), + m_asyncProgressiveBuffer->image != nullptr, .epoch = m_asyncProgressiveEpoch.load(std::memory_order_acquire), .viewEpoch = m_asyncProgressiveViewEpoch.load(std::memory_order_acquire)}; auto const displaySource = async_rendering::selectDisplayFrameSource( @@ -806,6 +810,7 @@ namespace gladius::ui .exactRealtimeJobInFlight = exactRealtimeJobInFlight, .isRendering = m_renderWindowState.isRendering, .isMoving = m_renderWindowState.isMoving, + .fullFrameRenderJobInFlight = fullFrameRenderJobInFlight, .suppressHqDisplay = m_suppressHQDisplay.load(std::memory_order_acquire), .resultImageAvailable = resultImage != nullptr, .presentedFrame = m_presentedFrames.presentedFrame()}); @@ -2718,6 +2723,10 @@ namespace gladius::ui m_asyncInFlightViewEpoch.store(0, std::memory_order_release); state.isRendering = false; } + if (isStaticFullFrameProbe) + { + m_asyncStaticFullFrameJobInFlight.store(false, std::memory_order_release); + } completeCoordinatorTask(result, false); continue; } diff --git a/gladius/src/ui/render/DisplayFrameSelector.h b/gladius/src/ui/render/DisplayFrameSelector.h index 06609e5b..b1326c35 100644 --- a/gladius/src/ui/render/DisplayFrameSelector.h +++ b/gladius/src/ui/render/DisplayFrameSelector.h @@ -32,6 +32,7 @@ namespace gladius::ui::async_rendering bool exactRealtimeJobInFlight{false}; bool isRendering{false}; bool isMoving{false}; + bool fullFrameRenderJobInFlight{false}; bool suppressHqDisplay{false}; bool resultImageAvailable{true}; std::optional presentedFrame{}; @@ -80,7 +81,14 @@ namespace gladius::ui::async_rendering input.progressiveBuffer.epoch == input.currentEpoch && input.progressiveBuffer.viewEpoch == input.currentViewEpoch; - if (progressiveBufferCurrent && !input.isMoving && !input.suppressHqDisplay) + if (frontBufferIsPresentedFrame && !input.suppressHqDisplay && + input.presentedFrame->quality == FramePresentationQuality::FullQuality) + { + return DisplayFrameSource::FrontBuffer; + } + + if (progressiveBufferCurrent && !input.isMoving && !input.fullFrameRenderJobInFlight && + !input.suppressHqDisplay) { return DisplayFrameSource::ProgressiveBuffer; } From d0056e540f3675776137238008c63dc273ac5d8c Mon Sep 17 00:00:00 2001 From: Jan Orend <56254096+3dJan@users.noreply.github.com> Date: Mon, 25 May 2026 13:50:27 +0200 Subject: [PATCH 32/72] [LibraryExampleExtractor] Add extraction functionality for example constants from .3mf files and implement corresponding tests [DisplayFrameSelector_tests] Add tests for selecting front buffer with presented full frame and handling full frame job in flight [RenderUpdateCoordinator_tests] Add test to ensure static catch-up does not start static full frame probe when resize is pending --- .../src/io/3mf/LibraryExampleExtractor.cpp | 226 +++++++++++++++++ gladius/src/io/3mf/LibraryExampleExtractor.h | 43 ++++ .../LibraryExampleExtractor_tests.cpp | 237 ++++++++++++++++++ .../ui/render/DisplayFrameSelector_tests.cpp | 31 +++ .../render/RenderUpdateCoordinator_tests.cpp | 26 ++ 5 files changed, 563 insertions(+) create mode 100644 gladius/src/io/3mf/LibraryExampleExtractor.cpp create mode 100644 gladius/src/io/3mf/LibraryExampleExtractor.h create mode 100644 gladius/tests/apitests/LibraryExampleExtractor_tests.cpp diff --git a/gladius/src/io/3mf/LibraryExampleExtractor.cpp b/gladius/src/io/3mf/LibraryExampleExtractor.cpp new file mode 100644 index 00000000..4053d88f --- /dev/null +++ b/gladius/src/io/3mf/LibraryExampleExtractor.cpp @@ -0,0 +1,226 @@ +#include "LibraryExampleExtractor.h" + +#include "LibraryMetadata.h" + +#include + +#include +#include + +namespace gladius::io +{ + namespace + { + /// @brief Replaces non-alphanumeric characters with underscores, matching the + /// transformation the importer applies to all node/parameter identifiers. + std::string makeValidVariableName(std::string const & name) + { + std::string result; + result.reserve(name.size()); + for (auto const c : name) + { + result += std::isalnum(static_cast(c)) ? c : '_'; + } + return result; + } + } // namespace + std::vector + extractExampleConstants(std::filesystem::path const & filePath, + std::string const & taggedFunctionDisplayName) + { + try + { + auto wrapper = Lib3MF::CWrapper::loadLibrary(); + auto model = wrapper->CreateModel(); + auto reader = model->QueryReader("3mf"); + reader->ReadFromFile(filePath.string()); + + auto const libMeta = readLibraryMetadata(model); + if (!libMeta) + { + return {}; + } + + auto const taggedIds = parseResourceIds(libMeta->libraryFunctions); + if (taggedIds.empty()) + { + return {}; + } + + // Find the tagged function's model resource ID by matching its display name. + Lib3MF_uint32 taggedFunctionResourceId = 0; + { + auto resIter = model->GetResources(); + while (resIter->MoveNext()) + { + auto res = resIter->GetCurrent(); + auto const modelId = res->GetModelResourceID(); + if (std::find(taggedIds.begin(), taggedIds.end(), modelId) == taggedIds.end()) + { + continue; + } + auto implicitFunc = std::dynamic_pointer_cast(res); + if (implicitFunc && + implicitFunc->GetDisplayName() == taggedFunctionDisplayName) + { + taggedFunctionResourceId = modelId; + break; + } + } + } + + if (taggedFunctionResourceId == 0) + { + return {}; + } + + // Scan all implicit functions to find one that calls the tagged function. + // Typically this is the assembly/example function used for thumbnail generation. + auto resIter = model->GetResources(); + while (resIter->MoveNext()) + { + auto res = resIter->GetCurrent(); + auto implicitFunc = std::dynamic_pointer_cast(res); + if (!implicitFunc) + { + continue; + } + // Skip tagged functions (the library function itself). + auto const modelId = res->GetModelResourceID(); + if (std::find(taggedIds.begin(), taggedIds.end(), modelId) != taggedIds.end()) + { + continue; + } + + // Search for a FunctionCall node that references the tagged function. + auto nodeIter = implicitFunc->GetNodes(); + while (nodeIter->MoveNext()) + { + auto node = nodeIter->GetCurrent(); + auto funcCallNode = + std::dynamic_pointer_cast(node); + if (!funcCallNode) + { + continue; + } + + // Resolve the called function's resource ID via the functionid reference. + auto const funcIdPort = funcCallNode->GetInputFunctionID(); + if (!funcIdPort || funcIdPort->GetReference().empty()) + { + continue; + } + + std::string const & refName = funcIdPort->GetReference(); + auto const dotPos = refName.find('.'); + std::string const sourceNodeId = + dotPos != std::string::npos ? refName.substr(0, dotPos) : refName; + + Lib3MF_uint32 calledFunctionId = 0; + { + auto scanIter = implicitFunc->GetNodes(); + while (scanIter->MoveNext()) + { + auto scanNode = scanIter->GetCurrent(); + if (scanNode->GetIdentifier() != sourceNodeId) + { + continue; + } + auto resourceIdNode = + std::dynamic_pointer_cast(scanNode); + if (resourceIdNode && resourceIdNode->GetResource()) + { + calledFunctionId = + resourceIdNode->GetResource()->GetResourceID(); + } + break; + } + } + + if (calledFunctionId != taggedFunctionResourceId) + { + continue; + } + + // Found the FunctionCall — collect constants wired to its argument inputs. + std::vector result; + auto inputIter = funcCallNode->GetInputs(); + while (inputIter->MoveNext()) + { + auto input = inputIter->GetCurrent(); + + // Skip the special functionid input. + if (input->GetIdentifier() == "functionid") + { + continue; + } + + std::string const & inputRef = input->GetReference(); + if (inputRef.empty()) + { + continue; + } + + auto const inputDotPos = inputRef.find('.'); + std::string const srcNodeId = inputDotPos != std::string::npos + ? inputRef.substr(0, inputDotPos) + : inputRef; + + // Find the source node and extract its constant value. + auto srcIter = implicitFunc->GetNodes(); + while (srcIter->MoveNext()) + { + auto srcNode = srcIter->GetCurrent(); + if (srcNode->GetIdentifier() != srcNodeId) + { + continue; + } + + if (srcNode->GetNodeType() == Lib3MF::eImplicitNodeType::Constant) + { + auto scalarNode = + std::dynamic_pointer_cast(srcNode); + if (scalarNode) + { + ExampleConstantValue val; + val.kind = ExampleConstantValue::Kind::Scalar; + val.parameterName = makeValidVariableName(input->GetIdentifier()); + val.scalarValue = + static_cast(scalarNode->GetConstant()); + result.push_back(std::move(val)); + } + } + else if (srcNode->GetNodeType() == Lib3MF::eImplicitNodeType::ConstVec) + { + auto vecNode = + std::dynamic_pointer_cast(srcNode); + if (vecNode) + { + auto const v = vecNode->GetVector(); + ExampleConstantValue val; + val.kind = ExampleConstantValue::Kind::Vector; + val.parameterName = makeValidVariableName(input->GetIdentifier()); + val.vectorValue = { + static_cast(v.m_Coordinates[0]), + static_cast(v.m_Coordinates[1]), + static_cast(v.m_Coordinates[2])}; + result.push_back(std::move(val)); + } + } + break; + } + } + + return result; + } + } + } + catch (...) + { + // Example constant extraction is best-effort; return empty on any failure. + } + + return {}; + } + +} // namespace gladius::io diff --git a/gladius/src/io/3mf/LibraryExampleExtractor.h b/gladius/src/io/3mf/LibraryExampleExtractor.h new file mode 100644 index 00000000..c02441e3 --- /dev/null +++ b/gladius/src/io/3mf/LibraryExampleExtractor.h @@ -0,0 +1,43 @@ +#pragma once + +#include "nodes/types.h" + +#include +#include +#include + +namespace gladius::io +{ + /// @brief A constant value extracted from a library .3mf example function. + /// + /// Represents a ConstantScalar or ConstantVector node that feeds into + /// a FunctionCall argument in the library file's embedded example. + struct ExampleConstantValue + { + enum class Kind + { + Scalar, + Vector + }; + + Kind kind = Kind::Scalar; + std::string parameterName; ///< The FunctionCall argument name this value feeds into + float scalarValue = 0.0f; + nodes::float3 vectorValue = {}; + }; + + /// @brief Extract example constant values for a tagged library function from a .3mf file. + /// + /// Loads the file, finds the assembly/example function that calls the tagged function + /// (matched by display name), and returns the constant node values wired to its + /// FunctionCall arguments. Used to pre-populate constant nodes when the user drops a + /// library item onto the node editor canvas. + /// + /// @param filePath Path to the .3mf library file. + /// @param taggedFunctionDisplayName Display name of the tagged library function. + /// @return Example constants (empty if none found, or on any error). + [[nodiscard]] std::vector + extractExampleConstants(std::filesystem::path const & filePath, + std::string const & taggedFunctionDisplayName); + +} // namespace gladius::io diff --git a/gladius/tests/apitests/LibraryExampleExtractor_tests.cpp b/gladius/tests/apitests/LibraryExampleExtractor_tests.cpp new file mode 100644 index 00000000..307259dd --- /dev/null +++ b/gladius/tests/apitests/LibraryExampleExtractor_tests.cpp @@ -0,0 +1,237 @@ +#include "io/3mf/LibraryExampleExtractor.h" +#include "io/3mf/LibraryMetadata.h" + +#include + +#include +#include +#include + +#include + +namespace gladius::io::tests +{ + namespace + { + /// @brief Creates a minimal library .3mf with a tagged function and an example caller. + /// + /// The tagged function has two scalar inputs (radius, height). + /// The example function wires ConstantScalar nodes (5.0, 10.0) to those inputs via a + /// FunctionCallNode. + std::filesystem::path createScalarConstantTestLibrary( + std::filesystem::path const & dir, + std::string const & taggedFuncName = "testFunc", + bool includeMetadata = true, + bool includeFunctionCall = true) + { + auto wrapper = Lib3MF::CWrapper::loadLibrary(); + auto model = wrapper->CreateModel(); + + auto taggedFunc = model->AddImplicitFunction(); + taggedFunc->SetDisplayName(taggedFuncName); + taggedFunc->AddInput("radius", "radius", Lib3MF::eImplicitPortType::Scalar); + taggedFunc->AddInput("height", "height", Lib3MF::eImplicitPortType::Scalar); + taggedFunc->AddOutput("shape", "shape", Lib3MF::eImplicitPortType::Scalar); + + auto const taggedId = taggedFunc->GetModelResourceID(); + + auto exampleFunc = model->AddImplicitFunction(); + exampleFunc->SetDisplayName("main"); + exampleFunc->AddInput("pos", "pos", Lib3MF::eImplicitPortType::Vector); + exampleFunc->AddOutput("shape", "shape", Lib3MF::eImplicitPortType::Scalar); + + if (includeFunctionCall) + { + auto constRadius = exampleFunc->AddConstantNode("CR", "CR", ""); + constRadius->SetConstant(5.0); + + auto constHeight = exampleFunc->AddConstantNode("CH", "CH", ""); + constHeight->SetConstant(10.0); + + auto resNode = exampleFunc->AddResourceIdNode("Res", "Res", ""); + resNode->SetResource(std::static_pointer_cast(taggedFunc)); + + auto fcNode = exampleFunc->AddFunctionCallNode("FC", "FC", ""); + fcNode->AddInput("radius", "radius"); + fcNode->AddInput("height", "height"); + + exampleFunc->AddLink(resNode->GetOutputValue(), fcNode->GetInputFunctionID()); + exampleFunc->AddLinkByNames("CR.value", "FC.radius"); + exampleFunc->AddLinkByNames("CH.value", "FC.height"); + } + + if (includeMetadata) + { + writeLibraryMetadata( + model, LibraryMetadata{std::to_string(taggedId), "Test function", ""}); + } + + auto const path = dir / "scalar_lib.3mf"; + model->QueryWriter("3mf")->WriteToFile(path.string()); + return path; + } + + /// @brief Creates a library .3mf where the example function passes a ConstantVector + /// to the tagged function's single vector input. + std::filesystem::path createVectorConstantTestLibrary(std::filesystem::path const & dir) + { + auto wrapper = Lib3MF::CWrapper::loadLibrary(); + auto model = wrapper->CreateModel(); + + auto taggedFunc = model->AddImplicitFunction(); + taggedFunc->SetDisplayName("vecFunc"); + taggedFunc->AddInput("offset", "offset", Lib3MF::eImplicitPortType::Vector); + taggedFunc->AddOutput("shape", "shape", Lib3MF::eImplicitPortType::Scalar); + + auto const taggedId = taggedFunc->GetModelResourceID(); + + auto exampleFunc = model->AddImplicitFunction(); + exampleFunc->SetDisplayName("main"); + exampleFunc->AddOutput("shape", "shape", Lib3MF::eImplicitPortType::Scalar); + + auto constVec = exampleFunc->AddConstVecNode("CV", "CV", ""); + Lib3MF::sVector vec{}; + vec.m_Coordinates[0] = 1.0; + vec.m_Coordinates[1] = 2.0; + vec.m_Coordinates[2] = 3.0; + constVec->SetVector(vec); + + auto resNode = exampleFunc->AddResourceIdNode("Res", "Res", ""); + resNode->SetResource(std::static_pointer_cast(taggedFunc)); + + auto fcNode = exampleFunc->AddFunctionCallNode("FC", "FC", ""); + fcNode->AddInput("offset", "offset"); + + exampleFunc->AddLink(resNode->GetOutputValue(), fcNode->GetInputFunctionID()); + exampleFunc->AddLinkByNames("CV.vector", "FC.offset"); + + writeLibraryMetadata( + model, LibraryMetadata{std::to_string(taggedId), "Vector test function", ""}); + + auto const path = dir / "vector_lib.3mf"; + model->QueryWriter("3mf")->WriteToFile(path.string()); + return path; + } + } // namespace + + class LibraryExampleExtractorTest : public ::testing::Test + { + protected: + void SetUp() override + { + m_tempDir = std::filesystem::temp_directory_path() / "gladius_extractor_test"; + std::filesystem::create_directories(m_tempDir); + } + + void TearDown() override + { + std::filesystem::remove_all(m_tempDir); + } + + std::filesystem::path m_tempDir; + }; + + TEST_F(LibraryExampleExtractorTest, + extractExampleConstants_WithScalarConstants_ReturnsExpectedValues) + { + // Arrange + auto const path = createScalarConstantTestLibrary(m_tempDir); + + // Act + auto const constants = extractExampleConstants(path, "testFunc"); + + // Assert + ASSERT_EQ(constants.size(), 2u); + + auto const findByName = + [&](std::string const & name) -> ExampleConstantValue const * + { + auto it = std::find_if( + constants.begin(), constants.end(), + [&](auto const & c) + { + return c.parameterName == name; + }); + return it != constants.end() ? &(*it) : nullptr; + }; + + auto const * radius = findByName("radius"); + ASSERT_NE(radius, nullptr); + EXPECT_EQ(radius->kind, ExampleConstantValue::Kind::Scalar); + EXPECT_FLOAT_EQ(radius->scalarValue, 5.0f); + + auto const * height = findByName("height"); + ASSERT_NE(height, nullptr); + EXPECT_EQ(height->kind, ExampleConstantValue::Kind::Scalar); + EXPECT_FLOAT_EQ(height->scalarValue, 10.0f); + } + + TEST_F(LibraryExampleExtractorTest, + extractExampleConstants_WithNonExistentFile_ReturnsEmpty) + { + // Act + auto const constants = extractExampleConstants("/nonexistent/path/file.3mf", "testFunc"); + + // Assert + EXPECT_TRUE(constants.empty()); + } + + TEST_F(LibraryExampleExtractorTest, + extractExampleConstants_WithWrongFunctionName_ReturnsEmpty) + { + // Arrange + auto const path = createScalarConstantTestLibrary(m_tempDir); + + // Act + auto const constants = extractExampleConstants(path, "doesNotExist"); + + // Assert + EXPECT_TRUE(constants.empty()); + } + + TEST_F(LibraryExampleExtractorTest, + extractExampleConstants_WithNoLibraryMetadata_ReturnsEmpty) + { + // Arrange + auto const path = createScalarConstantTestLibrary(m_tempDir, "testFunc", false); + + // Act + auto const constants = extractExampleConstants(path, "testFunc"); + + // Assert + EXPECT_TRUE(constants.empty()); + } + + TEST_F(LibraryExampleExtractorTest, + extractExampleConstants_WithNoFunctionCallInExample_ReturnsEmpty) + { + // Arrange + auto const path = + createScalarConstantTestLibrary(m_tempDir, "testFunc", true, false); + + // Act + auto const constants = extractExampleConstants(path, "testFunc"); + + // Assert + EXPECT_TRUE(constants.empty()); + } + + TEST_F(LibraryExampleExtractorTest, + extractExampleConstants_WithVectorConstant_ReturnsVectorValue) + { + // Arrange + auto const path = createVectorConstantTestLibrary(m_tempDir); + + // Act + auto const constants = extractExampleConstants(path, "vecFunc"); + + // Assert + ASSERT_EQ(constants.size(), 1u); + EXPECT_EQ(constants[0].kind, ExampleConstantValue::Kind::Vector); + EXPECT_EQ(constants[0].parameterName, "offset"); + EXPECT_FLOAT_EQ(constants[0].vectorValue.x, 1.0f); + EXPECT_FLOAT_EQ(constants[0].vectorValue.y, 2.0f); + EXPECT_FLOAT_EQ(constants[0].vectorValue.z, 3.0f); + } + +} // namespace gladius::io::tests diff --git a/gladius/tests/unittests/ui/render/DisplayFrameSelector_tests.cpp b/gladius/tests/unittests/ui/render/DisplayFrameSelector_tests.cpp index 7487a5e9..36de06af 100644 --- a/gladius/tests/unittests/ui/render/DisplayFrameSelector_tests.cpp +++ b/gladius/tests/unittests/ui/render/DisplayFrameSelector_tests.cpp @@ -129,6 +129,37 @@ namespace gladius::ui::async_rendering::tests EXPECT_EQ(selected, DisplayFrameSource::ProgressiveBuffer); } + TEST(DisplayFrameSelector, WithPresentedFullFrameAndCurrentProgressive_HoldsFrontBuffer) + { + auto const presentedFrame = PresentedFrame{.frameId = 10, + .stamp = RenderStamp{.sceneEpoch = 2, + .viewEpoch = 6}, + .quality = FramePresentationQuality::FullQuality, + .source = FramePresentationSource::ProgressiveHighQuality, + .completedFrame = true}; + + auto const selected = selectDisplayFrameSource( + DisplayFrameSelectionInput{.frontBuffer = buffer(2, 6), + .progressiveBuffer = buffer(3, 7), + .currentEpoch = 3, + .currentViewEpoch = 7, + .presentedFrame = presentedFrame}); + + EXPECT_EQ(selected, DisplayFrameSource::FrontBuffer); + } + + TEST(DisplayFrameSelector, WithFullFrameJobInFlight_DoesNotSelectProgressiveBuffer) + { + auto const selected = selectDisplayFrameSource( + DisplayFrameSelectionInput{.frontBuffer = buffer(2, 7), + .progressiveBuffer = buffer(3, 7), + .currentEpoch = 3, + .currentViewEpoch = 7, + .fullFrameRenderJobInFlight = true}); + + EXPECT_EQ(selected, DisplayFrameSource::ResultImage); + } + TEST(DisplayFrameSelector, WithMovingState_DoesNotSelectProgressiveBuffer) { auto const selected = selectDisplayFrameSource( diff --git a/gladius/tests/unittests/ui/render/RenderUpdateCoordinator_tests.cpp b/gladius/tests/unittests/ui/render/RenderUpdateCoordinator_tests.cpp index 23edb1c7..99c0a0ae 100644 --- a/gladius/tests/unittests/ui/render/RenderUpdateCoordinator_tests.cpp +++ b/gladius/tests/unittests/ui/render/RenderUpdateCoordinator_tests.cpp @@ -168,6 +168,32 @@ namespace gladius::ui::async_rendering::tests EXPECT_FALSE(hasStartedTask(decision, RenderTaskType::ProgressiveHighQualityChunk)); } + TEST(RenderUpdateCoordinator, StaticCatchUp_WithResizePending_DoesNotStartStaticFullFrameProbe) + { + RenderUpdateCoordinator coordinator; + auto decision = coordinator.configureViewport(640, 480); + auto bbox = findStartedTask(decision, RenderTaskType::BoundingBoxUpdate); + ASSERT_TRUE(bbox.has_value()); + + decision = coordinator.completeTask(completed(*bbox)); + auto sdf = findStartedTask(decision, RenderTaskType::SdfPrecomputation); + ASSERT_TRUE(sdf.has_value()); + + decision = coordinator.completeTask(completed(*sdf)); + auto progressive = findStartedTask(decision, RenderTaskType::ProgressiveHighQualityChunk); + ASSERT_TRUE(progressive.has_value()); + + coordinator.recordStaticProgressiveSample(progressiveChunkSample(5.0f)); + async_rendering::RealtimeRaymarchGuards guards{}; + guards.resizePending = true; + coordinator.setRealtimeGuards(guards); + + decision = coordinator.completeTask(completed(*progressive)); + + EXPECT_FALSE(hasStartedTask(decision, RenderTaskType::StaticFullFrameProbe)); + EXPECT_TRUE(hasStartedTask(decision, RenderTaskType::ProgressiveHighQualityChunk)); + } + TEST(RenderUpdateCoordinator, StaticCatchUp_AfterSlowProgressiveEstimate_ContinuesProgressive) { RenderUpdateCoordinator coordinator; From 32e4ececbd520ef535d6b66c651eb69277132815 Mon Sep 17 00:00:00 2001 From: Jan Orend <56254096+3dJan@users.noreply.github.com> Date: Mon, 25 May 2026 16:07:31 +0200 Subject: [PATCH 33/72] [LibraryExampleExtractor] Add support for extracting matrix constants and update related tests --- .../src/io/3mf/LibraryExampleExtractor.cpp | 34 ++++- gladius/src/io/3mf/LibraryExampleExtractor.h | 4 +- gladius/src/ui/ModelEditor.cpp | 25 ++++ gladius/tests/apitests/CMakeLists.txt | 3 + .../LibraryExampleExtractor_tests.cpp | 137 +++++++++++++++++- 5 files changed, 198 insertions(+), 5 deletions(-) diff --git a/gladius/src/io/3mf/LibraryExampleExtractor.cpp b/gladius/src/io/3mf/LibraryExampleExtractor.cpp index 4053d88f..a97546e9 100644 --- a/gladius/src/io/3mf/LibraryExampleExtractor.cpp +++ b/gladius/src/io/3mf/LibraryExampleExtractor.cpp @@ -131,7 +131,7 @@ namespace gladius::io if (resourceIdNode && resourceIdNode->GetResource()) { calledFunctionId = - resourceIdNode->GetResource()->GetResourceID(); + resourceIdNode->GetResource()->GetModelResourceID(); } break; } @@ -149,8 +149,15 @@ namespace gladius::io { auto input = inputIter->GetCurrent(); - // Skip the special functionid input. - if (input->GetIdentifier() == "functionid") + // Skip the special functionid input (case-insensitive: lib3mf may + // produce "functionID" or "functionid" depending on source). + auto const inputId = input->GetIdentifier(); + if (inputId.size() == 10 && + std::equal(inputId.begin(), inputId.end(), "functionid", + [](unsigned char a, unsigned char b) + { + return std::tolower(a) == std::tolower(b); + })) { continue; } @@ -207,6 +214,27 @@ namespace gladius::io result.push_back(std::move(val)); } } + else if (srcNode->GetNodeType() == Lib3MF::eImplicitNodeType::ConstMat) + { + auto matNode = + std::dynamic_pointer_cast(srcNode); + if (matNode) + { + auto const m = matNode->GetMatrix(); + ExampleConstantValue val; + val.kind = ExampleConstantValue::Kind::Matrix; + val.parameterName = makeValidVariableName(input->GetIdentifier()); + for (int row = 0; row < 4; ++row) + { + for (int col = 0; col < 4; ++col) + { + val.matrixValue[row][col] = + static_cast(m.m_Field[row][col]); + } + } + result.push_back(std::move(val)); + } + } break; } } diff --git a/gladius/src/io/3mf/LibraryExampleExtractor.h b/gladius/src/io/3mf/LibraryExampleExtractor.h index c02441e3..0917404f 100644 --- a/gladius/src/io/3mf/LibraryExampleExtractor.h +++ b/gladius/src/io/3mf/LibraryExampleExtractor.h @@ -17,13 +17,15 @@ namespace gladius::io enum class Kind { Scalar, - Vector + Vector, + Matrix }; Kind kind = Kind::Scalar; std::string parameterName; ///< The FunctionCall argument name this value feeds into float scalarValue = 0.0f; nodes::float3 vectorValue = {}; + nodes::Matrix4x4 matrixValue = {}; }; /// @brief Extract example constant values for a tagged library function from a .3mf file. diff --git a/gladius/src/ui/ModelEditor.cpp b/gladius/src/ui/ModelEditor.cpp index 39bd9194..41a63c67 100644 --- a/gladius/src/ui/ModelEditor.cpp +++ b/gladius/src/ui/ModelEditor.cpp @@ -2387,6 +2387,31 @@ namespace gladius::ui m_currentModel->addLink(constNode->getVectorOutputPort().getId(), argParam->getId()); } + else if (it->kind == io::ExampleConstantValue::Kind::Matrix) + { + auto * constNode = m_currentModel->create(); + auto const & mat = it->matrixValue; + constNode->parameter().at(FieldNames::M00).setValue(mat[0][0]); + constNode->parameter().at(FieldNames::M01).setValue(mat[0][1]); + constNode->parameter().at(FieldNames::M02).setValue(mat[0][2]); + constNode->parameter().at(FieldNames::M03).setValue(mat[0][3]); + constNode->parameter().at(FieldNames::M10).setValue(mat[1][0]); + constNode->parameter().at(FieldNames::M11).setValue(mat[1][1]); + constNode->parameter().at(FieldNames::M12).setValue(mat[1][2]); + constNode->parameter().at(FieldNames::M13).setValue(mat[1][3]); + constNode->parameter().at(FieldNames::M20).setValue(mat[2][0]); + constNode->parameter().at(FieldNames::M21).setValue(mat[2][1]); + constNode->parameter().at(FieldNames::M22).setValue(mat[2][2]); + constNode->parameter().at(FieldNames::M23).setValue(mat[2][3]); + constNode->parameter().at(FieldNames::M30).setValue(mat[3][0]); + constNode->parameter().at(FieldNames::M31).setValue(mat[3][1]); + constNode->parameter().at(FieldNames::M32).setValue(mat[3][2]); + constNode->parameter().at(FieldNames::M33).setValue(mat[3][3]); + constNode->setDisplayName(NodeName(argName)); + ed::SetNodePosition(constNode->getId(), constPos); + m_currentModel->addLink(constNode->getMatrixOutputPort().getId(), + argParam->getId()); + } ++constantIndex; } diff --git a/gladius/tests/apitests/CMakeLists.txt b/gladius/tests/apitests/CMakeLists.txt index 08d64206..ba292573 100644 --- a/gladius/tests/apitests/CMakeLists.txt +++ b/gladius/tests/apitests/CMakeLists.txt @@ -75,6 +75,9 @@ file(COPY "../../components/licenses" DESTINATION ".") file(MAKE_DIRECTORY "examples") file(COPY "../../examples/template.3mf" DESTINATION "examples/") +# Copy library files used by integration tests +file(COPY "../../library/mechanical/involute_gear.3mf" DESTINATION "testdata") + # Use gtest_discover_tests for automatic test discovery with 'api' label gtest_discover_tests(${BINARY} PROPERTIES LABELS "api" diff --git a/gladius/tests/apitests/LibraryExampleExtractor_tests.cpp b/gladius/tests/apitests/LibraryExampleExtractor_tests.cpp index 307259dd..6e26370b 100644 --- a/gladius/tests/apitests/LibraryExampleExtractor_tests.cpp +++ b/gladius/tests/apitests/LibraryExampleExtractor_tests.cpp @@ -100,7 +100,8 @@ namespace gladius::io::tests resNode->SetResource(std::static_pointer_cast(taggedFunc)); auto fcNode = exampleFunc->AddFunctionCallNode("FC", "FC", ""); - fcNode->AddInput("offset", "offset"); + auto offsetPort = fcNode->AddInput("offset", "offset"); + offsetPort->SetType(Lib3MF::eImplicitPortType::Vector); exampleFunc->AddLink(resNode->GetOutputValue(), fcNode->GetInputFunctionID()); exampleFunc->AddLinkByNames("CV.vector", "FC.offset"); @@ -234,4 +235,138 @@ namespace gladius::io::tests EXPECT_FLOAT_EQ(constants[0].vectorValue.z, 3.0f); } + TEST_F(LibraryExampleExtractorTest, + extractExampleConstants_WithMatrixConstant_ReturnsMatrixValue) + { + // Arrange + auto wrapper = Lib3MF::CWrapper::loadLibrary(); + auto model = wrapper->CreateModel(); + + auto taggedFunc = model->AddImplicitFunction(); + taggedFunc->SetDisplayName("matFunc"); + taggedFunc->AddInput("transform", "transform", Lib3MF::eImplicitPortType::Matrix); + taggedFunc->AddOutput("shape", "shape", Lib3MF::eImplicitPortType::Scalar); + + auto const taggedId = taggedFunc->GetModelResourceID(); + + auto exampleFunc = model->AddImplicitFunction(); + exampleFunc->SetDisplayName("main"); + exampleFunc->AddOutput("shape", "shape", Lib3MF::eImplicitPortType::Scalar); + + auto constMat = exampleFunc->AddConstMatNode("CM", "CM", ""); + Lib3MF::sMatrix4x4 mat{}; + // Identity matrix with a recognizable off-diagonal value + mat.m_Field[0][0] = 1.0; + mat.m_Field[1][1] = 2.0; + mat.m_Field[2][2] = 3.0; + mat.m_Field[3][3] = 4.0; + mat.m_Field[0][1] = 5.0; + constMat->SetMatrix(mat); + + auto resNode = exampleFunc->AddResourceIdNode("Res", "Res", ""); + resNode->SetResource(std::static_pointer_cast(taggedFunc)); + + auto fcNode = exampleFunc->AddFunctionCallNode("FC", "FC", ""); + auto transformPort = fcNode->AddInput("transform", "transform"); + transformPort->SetType(Lib3MF::eImplicitPortType::Matrix); + + exampleFunc->AddLink(resNode->GetOutputValue(), fcNode->GetInputFunctionID()); + exampleFunc->AddLinkByNames("CM.matrix", "FC.transform"); + + writeLibraryMetadata( + model, LibraryMetadata{std::to_string(taggedId), "Matrix test function", ""}); + + auto const path = m_tempDir / "matrix_lib.3mf"; + model->QueryWriter("3mf")->WriteToFile(path.string()); + + // Act + auto const constants = extractExampleConstants(path, "matFunc"); + + // Assert + ASSERT_EQ(constants.size(), 1u); + EXPECT_EQ(constants[0].kind, ExampleConstantValue::Kind::Matrix); + EXPECT_EQ(constants[0].parameterName, "transform"); + EXPECT_FLOAT_EQ(constants[0].matrixValue[0][0], 1.0f); + EXPECT_FLOAT_EQ(constants[0].matrixValue[1][1], 2.0f); + EXPECT_FLOAT_EQ(constants[0].matrixValue[2][2], 3.0f); + EXPECT_FLOAT_EQ(constants[0].matrixValue[3][3], 4.0f); + EXPECT_FLOAT_EQ(constants[0].matrixValue[0][1], 5.0f); + } + } // namespace gladius::io::tests + +namespace gladius::io::integration_tests +{ + /// @brief Integration tests that load real 3MF library files shipped with Gladius. + /// + /// These tests verify end-to-end extraction from actual library files as + /// they would be encountered during a library drag-drop in the node editor. + class LibraryExampleExtractorIntegrationTest : public ::testing::Test + { + }; + + /// Verifies that @c extractExampleConstants works with the real involute_gear.3mf + /// library file. The test dynamically discovers the tagged function display name + /// from metadata so it stays correct even if the file is regenerated. + TEST_F(LibraryExampleExtractorIntegrationTest, + extractExampleConstants_WithRealInvoluteGear_ReturnsNonEmptyScalars) + { + // Arrange + std::filesystem::path const filePath{"testdata/involute_gear.3mf"}; + ASSERT_TRUE(std::filesystem::exists(filePath)) + << "involute_gear.3mf not found in testdata/. " + "Check that the CMakeLists.txt copy step is present."; + + // Discover the tagged function display name from the file's library metadata. + auto wrapper = Lib3MF::CWrapper::loadLibrary(); + auto model = wrapper->CreateModel(); + model->QueryReader("3mf")->ReadFromFile(filePath.string()); + + auto const libMeta = readLibraryMetadata(model); + ASSERT_TRUE(libMeta.has_value()) << "involute_gear.3mf has no library metadata"; + + auto const taggedIds = parseResourceIds(libMeta->libraryFunctions); + ASSERT_FALSE(taggedIds.empty()) << "No tagged function IDs in metadata"; + + // Find the display name of the first tagged function. + std::string displayName; + auto resIter = model->GetResources(); + while (resIter->MoveNext()) + { + auto res = resIter->GetCurrent(); + auto const modelId = res->GetModelResourceID(); + if (std::find(taggedIds.begin(), taggedIds.end(), modelId) == taggedIds.end()) + { + continue; + } + auto implicitFunc = std::dynamic_pointer_cast(res); + if (implicitFunc) + { + displayName = implicitFunc->GetDisplayName(); + break; + } + } + ASSERT_FALSE(displayName.empty()) << "Could not find tagged implicit function in file"; + + // Act + auto const constants = extractExampleConstants(filePath, displayName); + + // Assert — the gear example must supply at least one constant to be useful. + ASSERT_FALSE(constants.empty()) + << "Expected example constants for function '" << displayName << "'"; + + // All gear parameters are positive scalars (module, teeth count, height, angles). + for (auto const & c : constants) + { + EXPECT_FALSE(c.parameterName.empty()) + << "Every extracted constant must have a parameter name"; + if (c.kind == ExampleConstantValue::Kind::Scalar) + { + EXPECT_GT(c.scalarValue, 0.0f) + << "Scalar parameter '" << c.parameterName + << "' should be positive for a gear"; + } + } + } + +} // namespace gladius::io::integration_tests From 79cffca979acdc637e7880aab1e0e2879755d58f Mon Sep 17 00:00:00 2001 From: Jan Orend <56254096+3dJan@users.noreply.github.com> Date: Wed, 27 May 2026 14:34:25 +0200 Subject: [PATCH 34/72] [CMakeLists.txt] Add precompiled headers and increase MSVC heap size for optimization --- gladius/src/CMakeLists.txt | 45 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 45 insertions(+) diff --git a/gladius/src/CMakeLists.txt b/gladius/src/CMakeLists.txt index 3f3df275..16ac4c54 100644 --- a/gladius/src/CMakeLists.txt +++ b/gladius/src/CMakeLists.txt @@ -105,6 +105,49 @@ target_link_libraries(${BINARY}_lib PUBLIC ${DEPENDENCIES_CORE}) target_link_libraries(${BINARY}_lib_mcp PUBLIC ${DEPENDENCIES_CORE}) target_link_libraries(${BINARY} PUBLIC ${BINARY}_lib) +# Precompiled headers: heavy/frequently-used headers compiled once per target, +# dramatically reducing MSVC memory pressure and improving incremental build speed. +set(GLADIUS_PCH_HEADERS + + + + + + + + + + + + + + + + + + + + + + + + + + +) +target_precompile_headers(${BINARY}_lib PRIVATE ${GLADIUS_PCH_HEADERS}) +# _lib_mcp has GLADIUS_ENABLE_MCP defined so it gets its own PCH build +target_precompile_headers(${BINARY}_lib_mcp PRIVATE ${GLADIUS_PCH_HEADERS}) +# executables share the PCH from their corresponding library (same flags) +target_precompile_headers(${BINARY} REUSE_FROM ${BINARY}_lib) + +# Increase the MSVC compiler's internal heap to prevent C1060 heap-overflow +# errors on large translation units (e.g. ManifoldDualContouringGpu.cpp). +if (MSVC) + target_compile_options(${BINARY}_lib PUBLIC /Zm200) + target_compile_options(${BINARY}_lib_mcp PUBLIC /Zm200) +endif() + # Enable AVX2 SIMD instructions for Phase 3 optimization # PUBLIC so consumers (tests, etc.) see the same Eigen alignment as the library, # avoiding ODR violations from mismatched struct layouts. @@ -135,6 +178,8 @@ endif() target_link_libraries(gladiusmcp PUBLIC ${BINARY}_lib_mcp ${DEPENDENCIES_MCP}) # Ensure the MCP-enabled static library is built before linking the executable add_dependencies(gladiusmcp ${BINARY}_lib_mcp) +# Share the PCH already built for the MCP library (same flags + GLADIUS_ENABLE_MCP) +target_precompile_headers(gladiusmcp REUSE_FROM ${BINARY}_lib_mcp) if (NOT WIN32) target_compile_options(gladiusmcp PRIVATE -mavx2 -mfma) endif() From 3f8692e6f3584dd346fbd96e88603506a47cbf7f Mon Sep 17 00:00:00 2001 From: Jan Orend <56254096+3dJan@users.noreply.github.com> Date: Wed, 27 May 2026 20:40:52 +0200 Subject: [PATCH 35/72] [CMakePresets.json] Reduce parallel build level to improve stability during configuration [FrontlitThicknessSolver.cpp] Optimize transformation of double to float vectors for performance [lib3mf] Update to version 2.6.0 with Boolean spec support and fix configuration root path [portfile.cmake] Remove unnecessary regex replacements and streamline library linking --- gladius/CMakePresets.json | 2 +- .../src/io/3mf/FrontlitThicknessSolver.cpp | 7 +++- .../lib3mf/fix-lib3mf-config-root.patch | 14 +++++++ .../lib3mf/lib3mf_vcpkg.patch | 23 ---------- .../vcpkg-overlay-ports/lib3mf/portfile.cmake | 42 ++++--------------- gladius/vcpkg-overlay-ports/lib3mf/vcpkg.json | 2 +- 6 files changed, 29 insertions(+), 61 deletions(-) create mode 100644 gladius/vcpkg-overlay-ports/lib3mf/fix-lib3mf-config-root.patch delete mode 100644 gladius/vcpkg-overlay-ports/lib3mf/lib3mf_vcpkg.patch diff --git a/gladius/CMakePresets.json b/gladius/CMakePresets.json index f5422914..9f5e980a 100644 --- a/gladius/CMakePresets.json +++ b/gladius/CMakePresets.json @@ -23,7 +23,7 @@ "VCPKG_TARGET_TRIPLET": "x64-windows-gladius", "VCPKG_OVERLAY_TRIPLETS": "${sourceDir}/vcpkg-triplets", "VCPKG_OVERLAY_PORTS": "${sourceDir}/vcpkg-overlay-ports", - "CMAKE_BUILD_PARALLEL_LEVEL": "8", + "CMAKE_BUILD_PARALLEL_LEVEL": "2", "CMAKE_EXPORT_COMPILE_COMMANDS": "ON" } }, diff --git a/gladius/src/io/3mf/FrontlitThicknessSolver.cpp b/gladius/src/io/3mf/FrontlitThicknessSolver.cpp index 303d1dcf..f4ac904e 100644 --- a/gladius/src/io/3mf/FrontlitThicknessSolver.cpp +++ b/gladius/src/io/3mf/FrontlitThicknessSolver.cpp @@ -110,7 +110,9 @@ namespace gladius::io auto objective = [](std::vector const& x, std::vector& /*grad*/, void* data) { auto* c = static_cast(data); - std::vector xf(x.begin(), x.end()); + std::vector xf; + xf.reserve(x.size()); + std::transform(x.begin(), x.end(), std::back_inserter(xf), [](double v) { return static_cast(v); }); Eigen::Vector3f const predicted = c->solver->predictColor(xf); double err = static_cast((predicted - c->targetColor).squaredNorm()); @@ -157,7 +159,8 @@ namespace gladius::io localOpt.set_maxeval(500); localOpt.optimize(x, f); - out.assign(x.begin(), x.end()); + out.resize(x.size()); + std::transform(x.begin(), x.end(), out.begin(), [](double v) { return static_cast(v); }); quantizeClampVec(out); return true; } diff --git a/gladius/vcpkg-overlay-ports/lib3mf/fix-lib3mf-config-root.patch b/gladius/vcpkg-overlay-ports/lib3mf/fix-lib3mf-config-root.patch new file mode 100644 index 00000000..f870081d --- /dev/null +++ b/gladius/vcpkg-overlay-ports/lib3mf/fix-lib3mf-config-root.patch @@ -0,0 +1,14 @@ +diff --git a/cmake/lib3mfConfig.cmake.in b/cmake/lib3mfConfig.cmake.in +index 589422b3..2dbcbf95 100644 +--- a/cmake/lib3mfConfig.cmake.in ++++ b/cmake/lib3mfConfig.cmake.in +@@ -23,8 +23,8 @@ if(NOT TARGET lib3mf::lib3mf) + return() + endif() + + get_filename_component(_lib3mf_package_dir "${CMAKE_CURRENT_LIST_DIR}" ABSOLUTE) +-get_filename_component(LIB3MF_ROOT_DIR "${_lib3mf_package_dir}/../../.." ABSOLUTE) ++get_filename_component(LIB3MF_ROOT_DIR "${_lib3mf_package_dir}/../.." ABSOLUTE) + set(LIB3MF_CDYNAMIC_ADDITIONAL_SOURCE "${LIB3MF_ROOT_DIR}/include/Bindings/CDynamic/lib3mf_dynamic.cc") + + set(_lib3mf_library_location_definition "LIB3MF_LIBRARY_LOCATION=\"$\"") diff --git a/gladius/vcpkg-overlay-ports/lib3mf/lib3mf_vcpkg.patch b/gladius/vcpkg-overlay-ports/lib3mf/lib3mf_vcpkg.patch deleted file mode 100644 index 89578f19..00000000 --- a/gladius/vcpkg-overlay-ports/lib3mf/lib3mf_vcpkg.patch +++ /dev/null @@ -1,23 +0,0 @@ ---- a/CMakeLists.txt -+++ b/CMakeLists.txt -@@ -199,11 +199,9 @@ if (USE_INCLUDED_LIBZIP) - endif() - - else() -- find_package(PkgConfig REQUIRED) -- pkg_check_modules(LIBZIP REQUIRED libzip) -- target_link_libraries(${PROJECT_NAME} ${LIBZIP_LIBRARIES}) -+ find_package(LIBZIP REQUIRED) -+ target_link_libraries(${PROJECT_NAME} PRIVATE libzip::zip) - endif() - - -@@ -211,11 +209,9 @@ if (USE_INCLUDED_ZLIB) - target_include_directories(${PROJECT_NAME} PRIVATE ${CMAKE_CURRENT_SOURCE_DIR}/Libraries/zlib/Include) - else() -- find_package(PkgConfig REQUIRED) -- pkg_check_modules(ZLIB REQUIRED zlib) -- target_link_libraries(${PROJECT_NAME} ${ZLIB_LIBRARIES}) -+ find_package(ZLIB REQUIRED) -+ target_link_libraries(${PROJECT_NAME} PRIVATE ZLIB::ZLIB) - endif() diff --git a/gladius/vcpkg-overlay-ports/lib3mf/portfile.cmake b/gladius/vcpkg-overlay-ports/lib3mf/portfile.cmake index 5b6fdb45..820853a4 100644 --- a/gladius/vcpkg-overlay-ports/lib3mf/portfile.cmake +++ b/gladius/vcpkg-overlay-ports/lib3mf/portfile.cmake @@ -3,47 +3,21 @@ vcpkg_check_linkage(ONLY_DYNAMIC_LIBRARY) vcpkg_from_github( OUT_SOURCE_PATH SOURCE_PATH REPO 3MFConsortium/lib3mf - REF "f5555593f33a6ac3efe7ae3996315b1ebe6716b1" # v2.5 alpha - SHA512 d26831de8fe0e659cb0f442d72fa15291732771cd26e76942f5770e87c97dc517ff1bfca49e190e6a74d752fee3bfb935486fe8f2124adf9ee49be0855ac796b + REF "82c524d6701a535ad11f8dae4a46a27fa199829c" # develop branch, v2.6.0 with Boolean spec support + SHA512 bfa93a2e087c5d0497dab7c55aee4f7e7e35e8718e250e9ad89f63b32c0b9e4dc0920b6d1c92d32b6fb9d606470c36846b39028f60992e1ada6d3b6103a8c206 + PATCHES + fix-lib3mf-config-root.patch + fix-missing-algorithm.patch ) -# Normalize target_link_libraries signature by inserting PRIVATE for plain-signature lines -file(READ "${SOURCE_PATH}/CMakeLists.txt" _lib3mf_cml) - -# Prepare literal token refs to avoid expansion during replacement -set(_DLR "$") -string(CONCAT _PROJ_REF "${_DLR}" "{" "PROJECT_NAME" "}") -string(CONCAT _ZLIB_LIB_REF "${_DLR}" "{" "ZLIB_LIBRARIES" "}") -string(CONCAT _ZLIB_INC_REF "${_DLR}" "{" "ZLIB_INCLUDE_DIRS" "}") -string(CONCAT _ZLIB_LIBDIRS_REF "${_DLR}" "{" "ZLIB_LIBRARY_DIRS" "}") -string(CONCAT _LIBZIP_LIB_REF "${_DLR}" "{" "LIBZIP_LIBRARIES" "}") -string(CONCAT _LIBZIP_INC_REF "${_DLR}" "{" "LIBZIP_INCLUDE_DIRS" "}") -string(CONCAT _LIBZIP_LIBDIRS_REF "${_DLR}" "{" "LIBZIP_LIBRARY_DIRS" "}") - -# Convert libzip linkage to keyword form and add include dirs -string(REGEX REPLACE - [=[target_link_libraries\([ \t\r\n]*\$\{PROJECT_NAME\}[^)]*\$\{LIBZIP_LIBRARIES\}[^)]*\)]=] - "target_link_libraries(${_PROJ_REF} PRIVATE ${_LIBZIP_LIB_REF})\n target_include_directories(${_PROJ_REF} PRIVATE ${_LIBZIP_INC_REF})\n target_link_directories(${_PROJ_REF} PRIVATE ${_LIBZIP_LIBDIRS_REF})" - _lib3mf_cml "${_lib3mf_cml}") - -# Convert zlib linkage to keyword form and add include dirs -string(REGEX REPLACE - [=[target_link_libraries\([ \t\r\n]*\$\{PROJECT_NAME\}[^)]*\$\{ZLIB_LIBRARIES\}[^)]*\)]=] - "target_link_libraries(${_PROJ_REF} PRIVATE ${_ZLIB_LIB_REF})\n target_include_directories(${_PROJ_REF} PRIVATE ${_ZLIB_INC_REF})\n target_link_directories(${_PROJ_REF} PRIVATE ${_ZLIB_LIBDIRS_REF})" - _lib3mf_cml "${_lib3mf_cml}") - -# Flip any hardcoded static CRT flags to dynamic to follow vcpkg defaults -string(REPLACE " /MTd" " /MDd" _lib3mf_cml "${_lib3mf_cml}") -string(REPLACE " /MT" " /MD" _lib3mf_cml "${_lib3mf_cml}") - -file(WRITE "${SOURCE_PATH}/CMakeLists.txt" "${_lib3mf_cml}") - vcpkg_cmake_configure( SOURCE_PATH "${SOURCE_PATH}" - OPTIONS + OPTIONS -DUSE_INCLUDED_ZLIB=OFF -DUSE_INCLUDED_LIBZIP=OFF -DUSE_INCLUDED_SSL=OFF + -DUSE_INCLUDED_CPPBASE64=OFF + -DUSE_INCLUDED_FASTFLOAT=OFF -DBUILD_FOR_CODECOVERAGE=OFF -DLIB3MF_TESTS=OFF ) diff --git a/gladius/vcpkg-overlay-ports/lib3mf/vcpkg.json b/gladius/vcpkg-overlay-ports/lib3mf/vcpkg.json index cdbca47f..e8821ff0 100644 --- a/gladius/vcpkg-overlay-ports/lib3mf/vcpkg.json +++ b/gladius/vcpkg-overlay-ports/lib3mf/vcpkg.json @@ -1,6 +1,6 @@ { "name": "lib3mf", - "version": "2.4.1", + "version": "2.6.0", "description": "lib3mf is an implementation of the 3D Manufacturing Format file standard", "homepage": "https://github.com/3MFConsortium/lib3mf", "license": "BSD-2-Clause", From 49c78ca9b32a35433e8f65eaae627aade4f7f06e Mon Sep 17 00:00:00 2001 From: Jan Orend <56254096+3dJan@users.noreply.github.com> Date: Thu, 28 May 2026 06:24:50 +0200 Subject: [PATCH 36/72] [Document] Improve non-blocking compilation checks and model refresh logic [ModelState] Use atomic operations for compilation state management [SpatialMeshResource] Add padding for float alignment in data serialization [ComputeCore] Make mesh resource state getter lock-free for UI responsiveness [RenderWindow] Enhance loading indicators and progress spinner functionality [RenderWindow] Update showProgressSpinner to accept custom indicator color [ComputeCore_tests] Add tests for mesh primitive offsets and alignment checks --- gladius/src/Document.cpp | 27 +++++++++++++++-- gladius/src/ModelState.h | 30 ++++++++++--------- gladius/src/SpatialMeshResource.cpp | 30 +++++++++++++++++++ gladius/src/compute/ComputeCore.cpp | 8 +++-- gladius/src/compute/ComputeCore.h | 10 +++++-- gladius/src/ui/MainWindow.cpp | 6 +++- gladius/src/ui/RenderWindow.cpp | 18 +++++++---- gladius/src/ui/RenderWindow.h | 4 ++- gladius/tests/unittests/ComputeCore_tests.cpp | 27 +++++++++++++++++ 9 files changed, 130 insertions(+), 30 deletions(-) diff --git a/gladius/src/Document.cpp b/gladius/src/Document.cpp index 695f1631..be3e67f2 100644 --- a/gladius/src/Document.cpp +++ b/gladius/src/Document.cpp @@ -651,9 +651,30 @@ namespace gladius bool Document::refreshModelIfNoCompilationIsRunning() { ProfileFunction; - if (m_core->getBestRenderProgram()->isCompilationInProgress() || - m_core->getSlicerProgram()->isCompilationInProgress() || - !m_core->getMeshResourceState()->isModelUpToDate()) + + // This method is called from the UI frame loop. Only use non-blocking probes here; + // the background load/refresh worker can hold the compute token for several seconds + // while importing meshes and preparing GPU resources. + if (m_core->isAnyCompilationInProgressNonBlocking()) + { + return false; + } + + auto const meshResourceState = m_core->getMeshResourceState(); + if (meshResourceState && !meshResourceState->isModelUpToDate()) + { + return false; + } + + { + auto computeToken = m_core->requestComputeToken(); + if (!computeToken.has_value()) + { + return false; + } + } + + if (m_core->isAnyCompilationInProgressNonBlocking()) { return false; } diff --git a/gladius/src/ModelState.h b/gladius/src/ModelState.h index e8c07e57..e6a0189e 100644 --- a/gladius/src/ModelState.h +++ b/gladius/src/ModelState.h @@ -1,5 +1,7 @@ #pragma once +#include + namespace gladius { enum class CompilationState @@ -14,38 +16,38 @@ namespace gladius public: void signalCompilationRequired() { - m_compilationState = CompilationState::CompilationRequested; + m_compilationState.store(CompilationState::CompilationRequested, + std::memory_order_release); } void signalCompilationFinished() { - if (m_compilationState == CompilationState::CompilationInProgress) - { - m_compilationState = CompilationState::UpToDate; - } + auto expected = CompilationState::CompilationInProgress; + (void) m_compilationState.compare_exchange_strong(expected, + CompilationState::UpToDate, + std::memory_order_acq_rel, + std::memory_order_acquire); } void signalCompilationStarted() { - m_compilationState = CompilationState::CompilationInProgress; + m_compilationState.store(CompilationState::CompilationInProgress, + std::memory_order_release); } [[nodiscard]] bool isCompilationRequired() const { - if (m_compilationState == CompilationState::CompilationRequested) - { - return true; - } - - return false; + return m_compilationState.load(std::memory_order_acquire) == + CompilationState::CompilationRequested; } [[nodiscard]] bool isModelUpToDate() const { - return m_compilationState == CompilationState::UpToDate; + return m_compilationState.load(std::memory_order_acquire) == + CompilationState::UpToDate; } private: - CompilationState m_compilationState{CompilationState::UpToDate}; + std::atomic m_compilationState{CompilationState::UpToDate}; }; } diff --git a/gladius/src/SpatialMeshResource.cpp b/gladius/src/SpatialMeshResource.cpp index 6b89690f..d3bf43d2 100644 --- a/gladius/src/SpatialMeshResource.cpp +++ b/gladius/src/SpatialMeshResource.cpp @@ -45,6 +45,18 @@ namespace gladius return result; } + inline void padDataToFloatAlignment(std::vector & data, + std::size_t const alignmentFloats) + { + if (alignmentFloats == 0u) + { + return; + } + + auto const padding = (alignmentFloats - (data.size() % alignmentFloats)) % alignmentFloats; + data.insert(data.end(), padding, 0.0f); + } + inline double bytesToMiB(std::size_t const bytes) { return static_cast(bytes) / static_cast(MEBIBYTE); @@ -466,6 +478,9 @@ namespace gladius static constexpr size_t kSignCacheResolutionSlot = 1; // sign cache resolution per axis static constexpr size_t kSignCacheBetaSlot = 1; // beta used to build the ready sign cache static constexpr size_t kNanoVdbOffsetSlot = 1; // local float offset of the NanoVDB grid (0 = none) + static constexpr size_t kFloat2AlignmentFloats = 2; + static constexpr size_t kFloat4AlignmentFloats = 4; + static constexpr size_t kNanoVdbAlignmentFloats = 8; // 32 bytes /// Coarse sign-cache resolution per axis (FWN acceleration). 64^3 = 262144 cells. /// Each cell stores a conservative 2-bit state (unknown/outside/inside), so @@ -516,6 +531,11 @@ namespace gladius ProfileFunction; GLADIUS_FWN_PREP_SCOPE_IF("SpatialMeshResource::write FWN payload", usesFastWindingNumber()); + // Keep the absolute base aligned. Several OpenCL kernels reinterpret float payload data + // as float2/float4 or structs containing float4 values, and NanoVDB grids require 32-byte + // alignment. Padding here keeps local payload alignment valid after insertion. + padDataToFloatAlignment(primitives.data.getData(), kNanoVdbAlignmentFloats); + // Track the base offset before adding our data m_dataBaseOffset = static_cast(primitives.data.getSize()); @@ -682,6 +702,7 @@ namespace gladius // Clear previous payload m_payloadData.meta.clear(); + m_payloadData.data.clear(); // Create primitive metadata for the spatial mesh root PrimitiveMeta metaData{}; @@ -770,6 +791,7 @@ namespace gladius // Serialize BVH nodes // Each node: bboxMin (4), bboxMax (4), leftChild, rightChild, primStart, primCount = 12 floats + padDataToFloatAlignment(m_payloadData.data, kFloat4AlignmentFloats); m_nodesOffset = m_payloadData.data.size(); { GLADIUS_FWN_PREP_SCOPE_IF("SpatialMeshResource::loadImpl serialize BVH nodes", useFwn); @@ -792,6 +814,7 @@ namespace gladius // Serialize triangles // Each triangle: v0 (4), v1 (4), v2 (4), faceNormal (4) = 16 floats + padDataToFloatAlignment(m_payloadData.data, kFloat4AlignmentFloats); m_trianglesOffset = m_payloadData.data.size(); { GLADIUS_FWN_PREP_SCOPE_IF("SpatialMeshResource::loadImpl serialize triangles", useFwn); @@ -818,6 +841,7 @@ namespace gladius // Serialize vertex normals // Each normal: xyz + w (vertex index) = 4 floats + padDataToFloatAlignment(m_payloadData.data, kFloat4AlignmentFloats); m_normalsOffset = m_payloadData.data.size(); { GLADIUS_FWN_PREP_SCOPE_IF("SpatialMeshResource::loadImpl serialize vertex normals", useFwn); @@ -832,6 +856,7 @@ namespace gladius // Serialize triangle indices for normal lookup // Each triangle: 3 vertex indices = 4 ints (padded) + padDataToFloatAlignment(m_payloadData.data, kFloat4AlignmentFloats); m_indicesOffset = m_payloadData.data.size(); { GLADIUS_FWN_PREP_SCOPE_IF("SpatialMeshResource::loadImpl serialize triangle indices", useFwn); @@ -846,6 +871,7 @@ namespace gladius // Serialize per-edge adjacent face normals (3 entries per triangle, 4 floats each). // Used by computePseudoNormalFast in mesh_sdf.cl for robust sign on edge features. + padDataToFloatAlignment(m_payloadData.data, kFloat4AlignmentFloats); m_edgeNeighborsOffset = m_payloadData.data.size(); { GLADIUS_FWN_PREP_SCOPE_IF("SpatialMeshResource::loadImpl serialize edge neighbours", useFwn); @@ -867,6 +893,7 @@ namespace gladius if (useFwn) { GLADIUS_FWN_PREP_SCOPE("SpatialMeshResource::loadImpl reserve FWN aggregate slots"); + padDataToFloatAlignment(m_payloadData.data, kFloat4AlignmentFloats); m_fwnAggregatesOffset = m_payloadData.data.size(); size_t const aggregateFloatCount = m_data.nodes.size() * 8u; for (size_t i = 0; i < aggregateFloatCount; ++i) @@ -877,6 +904,7 @@ namespace gladius // Reserve space for voxel grid data (2 floats per voxel: nearestTriIdx, signedDist) // This space will be filled by the buildMeshVoxelGrid kernel on GPU + padDataToFloatAlignment(m_payloadData.data, kFloat2AlignmentFloats); m_voxelDataOffset = m_payloadData.data.size(); size_t const voxelDataSize = m_voxelCount * 2; // 2 floats per voxel for (size_t i = 0; i < voxelDataSize; ++i) @@ -892,6 +920,7 @@ namespace gladius if (useFwn) { GLADIUS_FWN_PREP_SCOPE("SpatialMeshResource::loadImpl reserve FWN sign-cache slots"); + padDataToFloatAlignment(m_payloadData.data, kFloat4AlignmentFloats); m_signCacheDataOffset = m_payloadData.data.size(); for (size_t i = 0; i < kSignCacheWordCount; ++i) { @@ -1058,6 +1087,7 @@ namespace gladius // -- Companion i+1: SDF_MESH_TRIANGLES (flat vertex buffer) -------------------- PrimitiveMeta flatMeshCompanion{}; flatMeshCompanion.primitiveType = SDF_MESH_TRIANGLES; + padDataToFloatAlignment(m_payloadData.data, kFloat4AlignmentFloats); flatMeshCompanion.start = static_cast(m_payloadData.data.size()); for (auto const & tri : m_data.triangles) { diff --git a/gladius/src/compute/ComputeCore.cpp b/gladius/src/compute/ComputeCore.cpp index 2f318adb..68ddf23e 100644 --- a/gladius/src/compute/ComputeCore.cpp +++ b/gladius/src/compute/ComputeCore.cpp @@ -41,8 +41,8 @@ namespace gladius ComputeCore::ComputeCore(SharedComputeContext context, RequiredCapabilities requiredCapabilities, events::SharedLogger logger) - : m_contour(std::make_shared(logger)) - , m_ComputeContext(context) + : m_ComputeContext(context) + , m_contour(std::make_shared(logger)) , m_resources(std::make_shared(context)) , m_capabilities(requiredCapabilities) , m_eventLogger(logger) @@ -2510,7 +2510,9 @@ namespace gladius std::shared_ptr ComputeCore::getMeshResourceState() const { - std::lock_guard lock(m_computeMutex); + // The model-state object is created once in the constructor and is never replaced. + // Keep this getter lock-free so UI-frame status polling cannot block behind long-running + // mesh loading, GPU preparation, or compilation work that owns the compute token. return m_meshResourceState; } diff --git a/gladius/src/compute/ComputeCore.h b/gladius/src/compute/ComputeCore.h index b75f8c5a..1401ef91 100644 --- a/gladius/src/compute/ComputeCore.h +++ b/gladius/src/compute/ComputeCore.h @@ -765,15 +765,19 @@ namespace gladius void generateContourQuadtree(nodes::SliceParameter const & sliceParameter); mutable std::recursive_mutex m_computeMutex; // TODO: replace with std::mutex + + // Keep the OpenCL context before every member that owns buffers/images referencing it. + // Members are destroyed in reverse declaration order; the context must therefore be + // declared first so GPU resources can release themselves while the context is still alive. + SharedComputeContext m_ComputeContext; + SharedContourExtractor m_contour; + SharedResources m_resources; mutable std::atomic m_resultImage; mutable std::atomic m_lowResPreviewImage; std::shared_ptr m_thumbnailImage; std::shared_ptr m_thumbnailImageHighRes; - SharedPrimitives m_primitives; - SharedComputeContext m_ComputeContext; - SharedResources m_resources; double layerThickness_mm = 0.05; cl_float m_sliceHeight_mm{0.0f}; diff --git a/gladius/src/ui/MainWindow.cpp b/gladius/src/ui/MainWindow.cpp index a7548fbc..80c1d34d 100644 --- a/gladius/src/ui/MainWindow.cpp +++ b/gladius/src/ui/MainWindow.cpp @@ -606,6 +606,10 @@ namespace gladius::ui if (m_computeAvailable && m_doc) { bool const loadingNow = m_doc->isLoadingInProgress(); + if (loadingNow) + { + m_mainView.startAnimationMode(); + } if (m_asyncLoadState != AsyncLoadState::Idle && !loadingNow) { if (m_asyncLoadState == AsyncLoadState::LoadingWithReset) @@ -2638,7 +2642,7 @@ namespace gladius::ui void MainWindow::updateModel() { // Avoid touching document/compute state while a file is being loaded on a background thread. - if (m_doc && m_doc->isLoadingInProgress()) + if (m_doc && (m_doc->isLoadingInProgress() || m_asyncLoadState != AsyncLoadState::Idle)) { return; } diff --git a/gladius/src/ui/RenderWindow.cpp b/gladius/src/ui/RenderWindow.cpp index 30ea795e..cb044d89 100644 --- a/gladius/src/ui/RenderWindow.cpp +++ b/gladius/src/ui/RenderWindow.cpp @@ -838,6 +838,10 @@ namespace gladius::ui { // No image yet (early init / between resize and re-allocation). // Skip drawing this frame instead of dereferencing a null shared_ptr. + if (isFileLoading) + { + renderLoadingOverlay(); + } return; } @@ -1139,11 +1143,14 @@ namespace gladius::ui ImGui::PopStyleVar(); displayImage->unbind(); + if (isFileLoading) + { + showProgressSpinner(windowCenter, "loading", ImVec4(0.2f, 0.6f, 1.0f, 0.8f)); + } // Show the progress indicator only when no renderable program is available. // In automatic mode the optimized OpenCL program may still compile in the // background while the command-stream preview program is already usable. - bool const showBusyIndicator = !m_core->tryIsRenderProgramReady().value_or(false); - if (showBusyIndicator) + else if (!m_core->tryIsRenderProgramReady().value_or(false)) { m_view->startAnimationMode(); ImGuiWindowFlags window_flags = @@ -1161,7 +1168,7 @@ namespace gladius::ui if (ImGui::Begin("ProgressIndicator", &open, window_flags)) { - // Red color for compilation (loading case is handled by early return) + // Red color for compilation; file loading keeps a blue spinner above. ImVec4 const indicatorColor = ImVec4(1.0f, 0.0f, 0.0f, 0.8f); ui::loadingIndicatorCircle("compiling", 30, @@ -3841,7 +3848,9 @@ namespace gladius::ui } } - void RenderWindow::showProgressSpinner(ImVec2 const & windowCenter, char const * label) + void RenderWindow::showProgressSpinner(ImVec2 const & windowCenter, + char const * label, + ImVec4 const & indicatorColor) { m_view->startAnimationMode(); ImGuiWindowFlags overlayFlags = @@ -3858,7 +3867,6 @@ namespace gladius::ui if (ImGui::Begin("ProgressIndicator", &open, overlayFlags)) { ImGui::SetWindowPos({windowCenter.x - 15.f, windowCenter.y - 15.f}); - ImVec4 const indicatorColor = ImVec4(1.0f, 0.0f, 0.0f, 0.8f); ui::loadingIndicatorCircle(label, 30, indicatorColor, diff --git a/gladius/src/ui/RenderWindow.h b/gladius/src/ui/RenderWindow.h index be00dd00..c7df12ef 100644 --- a/gladius/src/ui/RenderWindow.h +++ b/gladius/src/ui/RenderWindow.h @@ -174,7 +174,9 @@ namespace gladius::ui void renderLoadingOverlay(); void renderBusyOverlay(); void renderExistingFrame(std::shared_ptr const & displayImage); - void showProgressSpinner(ImVec2 const & windowCenter, char const * label); + void showProgressSpinner(ImVec2 const & windowCenter, + char const * label, + ImVec4 const & indicatorColor = ImVec4(1.0f, 0.0f, 0.0f, 0.8f)); void slider(ImVec2 const & areaMin, ImVec2 const & areaMax); void initializeAsyncRendering(); void renderSync(RenderWindowState & state); diff --git a/gladius/tests/unittests/ComputeCore_tests.cpp b/gladius/tests/unittests/ComputeCore_tests.cpp index f53e384e..854c3eab 100644 --- a/gladius/tests/unittests/ComputeCore_tests.cpp +++ b/gladius/tests/unittests/ComputeCore_tests.cpp @@ -413,6 +413,33 @@ namespace gladius_tests ASSERT_NE(primitives.get(), nullptr); ASSERT_NO_THROW(primitives->write()); + auto const & primitiveMeta = primitives->primitives.getData(); + auto const meshPrimitiveIt = std::find_if( + primitiveMeta.begin(), + primitiveMeta.end(), + [](PrimitiveMeta const & meta) + { + return meta.primitiveType == SDF_SPATIAL_MESH_ROOT; + }); + ASSERT_NE(meshPrimitiveIt, primitiveMeta.end()); + + auto const & payload = primitives->data.getData(); + auto const headerStart = static_cast(meshPrimitiveIt->start); + ASSERT_GT(payload.size(), headerStart + 33u); + + auto const nodesOffset = static_cast(payload[headerStart + 12u]); + auto const trianglesOffset = static_cast(payload[headerStart + 13u]); + auto const normalsOffset = static_cast(payload[headerStart + 14u]); + auto const indicesOffset = static_cast(payload[headerStart + 15u]); + auto const voxelDataOffset = static_cast(payload[headerStart + 26u]); + auto const edgeNeighborsOffset = static_cast(payload[headerStart + 28u]); + EXPECT_EQ(nodesOffset % 4, 0); + EXPECT_EQ(trianglesOffset % 4, 0); + EXPECT_EQ(normalsOffset % 4, 0); + EXPECT_EQ(indicesOffset % 4, 0); + EXPECT_EQ(voxelDataOffset % 2, 0); + EXPECT_EQ(edgeNeighborsOffset % 4, 0); + constexpr size_t imageSize = 1u; ImageRGBA targetImage(*core->getComputeContext(), imageSize, imageSize); ASSERT_NO_THROW(targetImage.allocateOnDevice()); From 2b4b68f2efb267ae03ebeb6c52c0aa715b1d8ea8 Mon Sep 17 00:00:00 2001 From: Jan Orend <56254096+3dJan@users.noreply.github.com> Date: Thu, 28 May 2026 07:58:10 +0200 Subject: [PATCH 37/72] [Importer3mf] Add boolean object processing and SDF generation methods to handle boolean operations and dependencies --- gladius/src/io/3mf/Importer3mf.cpp | 394 ++++++++++++++++++ gladius/src/io/3mf/Importer3mf.h | 37 ++ .../src/io/3mf/ResourceDependencyGraph.cpp | 64 +++ gladius/src/io/3mf/ResourceDependencyGraph.h | 6 + .../ResourceDependencyGraph_tests.cpp | 62 +++ 5 files changed, 563 insertions(+) diff --git a/gladius/src/io/3mf/Importer3mf.cpp b/gladius/src/io/3mf/Importer3mf.cpp index 27d90c49..361d430b 100644 --- a/gladius/src/io/3mf/Importer3mf.cpp +++ b/gladius/src/io/3mf/Importer3mf.cpp @@ -1170,6 +1170,77 @@ namespace gladius::io } } + void Importer3mf::collectBooleanReferencedMeshIds( + Lib3MF::PModel const & model, + Lib3MF::PBooleanObject const & booleanObject, + std::set & meshIds, + std::set & visitedBooleanIds) const + { + if (!model || !booleanObject) + { + return; + } + + auto const booleanId = booleanObject->GetModelResourceID(); + if (!visitedBooleanIds.insert(booleanId).second) + { + return; + } + + try + { + auto const baseObject = booleanObject->GetBaseObject(); + if (baseObject && baseObject->IsMeshObject()) + { + meshIds.insert(baseObject->GetModelResourceID()); + } + else if (baseObject && baseObject->IsBooleanObject()) + { + auto const baseBoolean = + model->GetBooleanObjectByID(baseObject->GetUniqueResourceID()); + collectBooleanReferencedMeshIds(model, baseBoolean, meshIds, visitedBooleanIds); + } + } + catch (std::exception const & e) + { + if (m_eventLogger) + { + m_eventLogger->addEvent( + {fmt::format("Could not collect boolean base mesh references for resource {}: {}", + booleanId, + e.what()), + events::Severity::Warning}); + } + } + + auto const operandCount = booleanObject->GetOperandCount(); + for (Lib3MF_uint32 operandIndex = 0; operandIndex < operandCount; ++operandIndex) + { + try + { + Lib3MF::PMeshObject operandMesh; + (void) booleanObject->GetOperand(operandIndex, operandMesh); + if (operandMesh) + { + meshIds.insert(operandMesh->GetModelResourceID()); + } + } + catch (std::exception const & e) + { + if (m_eventLogger) + { + m_eventLogger->addEvent( + {fmt::format("Could not collect boolean operand {} mesh reference for " + "resource {}: {}", + operandIndex, + booleanId, + e.what()), + events::Severity::Warning}); + } + } + } + } + std::set Importer3mf::collectBboxOnlyMeshIds(Lib3MF::PModel const & model) const { std::set bboxOnlyIds; @@ -1204,6 +1275,16 @@ namespace gladius::io } } + auto booleanIterator = model->GetBooleanObjects(); + while (booleanIterator->MoveNext()) + { + std::set visitedBooleanIds; + collectBooleanReferencedMeshIds(model, + booleanIterator->GetCurrentBooleanObject(), + regularIds, + visitedBooleanIds); + } + // Return only IDs that are exclusively used as bounding boxes (not also as real geometry) std::set result; for (auto const id : bboxOnlyIds) @@ -1730,6 +1811,314 @@ namespace gladius::io } } + nodes::Port & Importer3mf::createMeshSdfNode(ResourceKey const & key, + nodes::Port & coordinateSystemPort, + nodes::Model & target) + { + auto resourceNode = target.create(); + resourceNode->parameter().at(nodes::FieldNames::ResourceId) = + nodes::VariantParameter(key.getResourceId().value_or(0)); + + auto meshSdfNode = target.create(); + meshSdfNode->parameter().at(nodes::FieldNames::Pos).setInputFromPort(coordinateSystemPort); + meshSdfNode->parameter() + .at(nodes::FieldNames::Mesh) + .setInputFromPort(resourceNode->getOutputValue()); + + return meshSdfNode->getOutputs().at(nodes::FieldNames::Distance); + } + + nodes::Port & Importer3mf::combineSdf(nodes::Model & target, + nodes::Port & lhs, + nodes::Port & rhs, + Lib3MF::eBooleanOperation operation) + { + switch (operation) + { + case Lib3MF::eBooleanOperation::Union: + { + auto unionNode = target.create(); + unionNode->setInputA(lhs); + unionNode->setInputB(rhs); + return unionNode->getResultOutputPort(); + } + case Lib3MF::eBooleanOperation::Intersection: + { + auto intersectionNode = target.create(); + intersectionNode->setInputA(lhs); + intersectionNode->setInputB(rhs); + return intersectionNode->getResultOutputPort(); + } + case Lib3MF::eBooleanOperation::Difference: + { + auto negateNode = target.create(); + auto & minusOne = + nodes::Builder::ensureConstantScalar(target, "BooleanDifferenceNegate", -1.0f); + negateNode->setInputA(rhs); + negateNode->parameter().at(nodes::FieldNames::B).setInputFromPort(minusOne); + + auto differenceNode = target.create(); + differenceNode->setInputA(lhs); + differenceNode->setInputB(negateNode->getResultOutputPort()); + return differenceNode->getResultOutputPort(); + } + default: + throw std::runtime_error("Unsupported 3MF boolean operation"); + } + } + + nodes::Port & Importer3mf::createLevelSetSdfNode(Lib3MF::PModel const & model, + Lib3MF::PLevelSet const & levelSet, + nodes::Port & coordinateSystemPort, + nodes::Model & target, + Document & doc) + { + if (!levelSet) + { + throw std::runtime_error("Cannot build SDF for null level set base object"); + } + + auto function = levelSet->GetFunction(); + if (!function) + { + throw std::runtime_error("No function found for boolean level set base object"); + } + + auto const functionResourceId = function->GetResourceID(); + auto const functionResource = model->GetResourceByID(functionResourceId); + if (!functionResource) + { + throw std::runtime_error( + fmt::format("Could not resolve level set function resource {}", functionResourceId)); + } + + auto const modelFunctionId = functionResource->GetModelResourceID(); + auto const gladiusFunction = doc.getAssembly()->findModel(modelFunctionId); + if (!gladiusFunction) + { + throw std::runtime_error( + fmt::format("Could not find imported level set function {}", modelFunctionId)); + } + + auto mesh = levelSet->GetMesh(); + if (!mesh) + { + throw std::runtime_error("No mesh domain found for boolean level set base object"); + } + + auto channelName = levelSet->GetChannelName(); + if (channelName.empty()) + { + channelName = nodes::FieldNames::Shape; + } + + nodes::Builder builder; + auto const levelSetTransform = matrix4x4From3mfTransform(levelSet->GetTransform()); + auto & functionCoordinateSystemPort = + builder.insertTransformation(target, coordinateSystemPort, levelSetTransform, 1.0f); + + auto const boundingBox = computeBoundingBox(mesh); + auto boundingBoxNode = target.create(); + boundingBoxNode->parameter() + .at(nodes::FieldNames::Pos) + .setInputFromPort(coordinateSystemPort); + + auto minVectorNode = target.create(); + minVectorNode->parameter().at(nodes::FieldNames::X) = + nodes::VariantParameter(boundingBox.min.x); + minVectorNode->parameter().at(nodes::FieldNames::Y) = + nodes::VariantParameter(boundingBox.min.y); + minVectorNode->parameter().at(nodes::FieldNames::Z) = + nodes::VariantParameter(boundingBox.min.z); + + auto maxVectorNode = target.create(); + maxVectorNode->parameter().at(nodes::FieldNames::X) = + nodes::VariantParameter(boundingBox.max.x); + maxVectorNode->parameter().at(nodes::FieldNames::Y) = + nodes::VariantParameter(boundingBox.max.y); + maxVectorNode->parameter().at(nodes::FieldNames::Z) = + nodes::VariantParameter(boundingBox.max.z); + + boundingBoxNode->parameter() + .at(nodes::FieldNames::Min) + .setInputFromPort(minVectorNode->getVectorOutputPort()); + boundingBoxNode->parameter() + .at(nodes::FieldNames::Max) + .setInputFromPort(maxVectorNode->getVectorOutputPort()); + + auto resourceNode = target.create(); + resourceNode->parameter().at(nodes::FieldNames::ResourceId) = + nodes::VariantParameter(gladiusFunction->getResourceId()); + + auto functionCallNode = target.create(); + functionCallNode->parameter() + .at(nodes::FieldNames::FunctionId) + .setInputFromPort(resourceNode->getOutputValue()); + functionCallNode->updateInputsAndOutputs(*gladiusFunction); + target.registerInputs(*functionCallNode); + target.registerOutputs(*functionCallNode); + + auto positionInput = functionCallNode->parameter().find(nodes::FieldNames::Pos); + if (positionInput == functionCallNode->parameter().end()) + { + throw std::runtime_error("Level set function has no pos input"); + } + positionInput->second.setInputFromPort(functionCoordinateSystemPort); + + auto shapeOutput = functionCallNode->getOutputs().find(channelName); + if (shapeOutput == functionCallNode->getOutputs().end()) + { + throw std::runtime_error( + fmt::format("Level set function has no output named {}", channelName)); + } + if (shapeOutput->second.getTypeIndex() != nodes::ParameterTypeIndex::Float) + { + throw std::runtime_error( + fmt::format("Level set output {} is not scalar", channelName)); + } + + auto intersectionNode = target.create(); + intersectionNode->setInputA(boundingBoxNode->getOutputs().at(nodes::FieldNames::Shape)); + intersectionNode->setInputB(shapeOutput->second); + return intersectionNode->getResultOutputPort(); + } + + nodes::Port & Importer3mf::buildObjectSdf(Lib3MF::PModel const & model, + Lib3MF::PObject const & object, + nodes::Port & coordinateSystemPort, + nodes::Model & target, + Document & doc) + { + if (!object) + { + throw std::runtime_error("Cannot build SDF for null 3MF object"); + } + + if (object->IsMeshObject()) + { + auto key = ResourceKey{static_cast(object->GetModelResourceID()), + ResourceType::Mesh}; + key.setDisplayName(object->GetName()); + return createMeshSdfNode(key, coordinateSystemPort, target); + } + + if (object->IsBooleanObject()) + { + auto const booleanObject = model->GetBooleanObjectByID(object->GetUniqueResourceID()); + return buildBooleanSdf(model, booleanObject, coordinateSystemPort, target, doc); + } + + if (object->IsLevelSetObject()) + { + auto const levelSet = model->GetLevelSetByID(object->GetUniqueResourceID()); + return createLevelSetSdfNode(model, levelSet, coordinateSystemPort, target, doc); + } + + auto const message = fmt::format("Unsupported base object type in boolean resource {}", + object->GetModelResourceID()); + if (m_eventLogger) + { + m_eventLogger->addEvent({message, events::Severity::Error}); + } + throw std::runtime_error(message); + } + + nodes::Port & Importer3mf::buildBooleanSdf(Lib3MF::PModel const & model, + Lib3MF::PBooleanObject const & booleanObject, + nodes::Port & coordinateSystemPort, + nodes::Model & target, + Document & doc) + { + if (!booleanObject) + { + throw std::runtime_error("Cannot build SDF for null boolean object"); + } + + nodes::Builder builder; + auto const baseTransform = + inverseMatrix(matrix4x4From3mfTransform(booleanObject->GetBaseTransform())); + auto & baseCoordinateSystemPort = + builder.insertTransformation(target, coordinateSystemPort, baseTransform, 1.0f); + + auto baseObject = booleanObject->GetBaseObject(); + auto * currentSdf = + &buildObjectSdf(model, baseObject, baseCoordinateSystemPort, target, doc); + + auto const operandCount = booleanObject->GetOperandCount(); + if (operandCount == 0u && m_eventLogger) + { + m_eventLogger->addEvent( + {fmt::format("Boolean resource {} has no operands; using base object only", + booleanObject->GetModelResourceID()), + events::Severity::Warning}); + } + + for (Lib3MF_uint32 operandIndex = 0; operandIndex < operandCount; ++operandIndex) + { + Lib3MF::PMeshObject operandMesh; + auto const operandTransform = + inverseMatrix(matrix4x4From3mfTransform(booleanObject->GetOperand(operandIndex, + operandMesh))); + if (!operandMesh) + { + throw std::runtime_error( + fmt::format("Boolean resource {} has null operand {}", + booleanObject->GetModelResourceID(), + operandIndex)); + } + + auto & operandCoordinateSystemPort = + builder.insertTransformation(target, coordinateSystemPort, operandTransform, 1.0f); + auto operandKey = ResourceKey{static_cast(operandMesh->GetModelResourceID()), + ResourceType::Mesh}; + operandKey.setDisplayName(operandMesh->GetName()); + auto & operandSdf = createMeshSdfNode(operandKey, operandCoordinateSystemPort, target); + currentSdf = + &combineSdf(target, *currentSdf, operandSdf, booleanObject->GetOperation()); + } + + return *currentSdf; + } + + void Importer3mf::addBooleanObject(Lib3MF::PModel model, + Lib3MF::PBooleanObject booleanObject, + nodes::Matrix4x4 const & trafo, + Document & doc) + { + if (!booleanObject) + { + if (m_eventLogger) + { + m_eventLogger->addEvent({"No boolean object to import", events::Severity::Error}); + } + return; + } + + nodes::Builder builder; + auto & target = *doc.getAssembly()->assemblyModel(); + float const units_per_mm = computeUnitsPerMM(model); + auto & coordinateSystemPort = builder.addTransformationToInputCs(target, trafo, units_per_mm); + auto & booleanSdf = buildBooleanSdf(model, booleanObject, coordinateSystemPort, target, doc); + + auto * shapeSink = target.getEndNode()->getParameter(nodes::FieldNames::Shape); + if (!shapeSink) + { + throw std::runtime_error("End node is required to have a shape parameter"); + } + + auto * lastShapePort = builder.getLastShape(target); + if (!lastShapePort) + { + shapeSink->setInputFromPort(booleanSdf); + return; + } + + auto unionNode = target.create(); + unionNode->setInputA(*lastShapePort); + unionNode->setInputB(booleanSdf); + shapeSink->setInputFromPort(unionNode->getResultOutputPort()); + } + void Importer3mf::addLevelSetObject(Lib3MF::PModel model, ResourceKey const & key, Lib3MF::PLevelSet levelSet, @@ -2107,6 +2496,11 @@ namespace gladius::io auto levelSet = model->GetLevelSetByID(objectRes.GetUniqueResourceID()); addLevelSetObject(model, key, levelSet, trafo, doc); } + else if (objectRes.IsBooleanObject()) + { + auto booleanObject = model->GetBooleanObjectByID(objectRes.GetUniqueResourceID()); + addBooleanObject(model, booleanObject, trafo, doc); + } } void Importer3mf::load(std::filesystem::path const & filename, Document & doc) diff --git a/gladius/src/io/3mf/Importer3mf.h b/gladius/src/io/3mf/Importer3mf.h index a2b2b3e6..4faf5156 100644 --- a/gladius/src/io/3mf/Importer3mf.h +++ b/gladius/src/io/3mf/Importer3mf.h @@ -210,6 +210,38 @@ namespace gladius::io nodes::Matrix4x4 const & trafo, Document & doc); + void addBooleanObject(Lib3MF::PModel model, + Lib3MF::PBooleanObject booleanObject, + nodes::Matrix4x4 const & trafo, + Document & doc); + + nodes::Port & buildBooleanSdf(Lib3MF::PModel const & model, + Lib3MF::PBooleanObject const & booleanObject, + nodes::Port & coordinateSystemPort, + nodes::Model & target, + Document & doc); + + nodes::Port & buildObjectSdf(Lib3MF::PModel const & model, + Lib3MF::PObject const & object, + nodes::Port & coordinateSystemPort, + nodes::Model & target, + Document & doc); + + nodes::Port & createLevelSetSdfNode(Lib3MF::PModel const & model, + Lib3MF::PLevelSet const & levelSet, + nodes::Port & coordinateSystemPort, + nodes::Model & target, + Document & doc); + + static nodes::Port & createMeshSdfNode(ResourceKey const & key, + nodes::Port & coordinateSystemPort, + nodes::Model & target); + + static nodes::Port & combineSdf(nodes::Model & target, + nodes::Port & lhs, + nodes::Port & rhs, + Lib3MF::eBooleanOperation operation); + void loadImageStacks(std::filesystem::path const & filename, Lib3MF::PModel model, Document & doc); @@ -277,6 +309,11 @@ namespace gladius::io /// @return Set of model resource IDs that should be skipped during mesh loading. std::set collectBboxOnlyMeshIds(Lib3MF::PModel const & model) const; + void collectBooleanReferencedMeshIds(Lib3MF::PModel const & model, + Lib3MF::PBooleanObject const & booleanObject, + std::set & meshIds, + std::set & visitedBooleanIds) const; + Lib3MF::PWrapper m_wrapper{}; MeshResources m_meshObjects; diff --git a/gladius/src/io/3mf/ResourceDependencyGraph.cpp b/gladius/src/io/3mf/ResourceDependencyGraph.cpp index 305b7228..e9470153 100644 --- a/gladius/src/io/3mf/ResourceDependencyGraph.cpp +++ b/gladius/src/io/3mf/ResourceDependencyGraph.cpp @@ -87,6 +87,15 @@ namespace gladius::io continue; } + // BooleanObject + Lib3MF::PBooleanObject booleanObject = + std::dynamic_pointer_cast(resource); + if (booleanObject) + { + processBooleanObject(booleanObject); + continue; + } + // MeshObject Lib3MF::PMeshObject meshObject = std::dynamic_pointer_cast(resource); @@ -511,6 +520,61 @@ namespace gladius::io } } + void ResourceDependencyGraph::processBooleanObject(Lib3MF::PBooleanObject booleanObject) + { + if (!booleanObject) + { + return; + } + + Lib3MF_uint32 const booleanObjectId = booleanObject->GetResourceID(); + + try + { + Lib3MF::PObject baseObject = booleanObject->GetBaseObject(); + if (baseObject) + { + m_graph->addDependency(booleanObjectId, baseObject->GetResourceID()); + } + } + catch (const std::exception & e) + { + if (m_logger) + { + m_logger->addEvent( + {fmt::format("Error processing BooleanObject base dependency {}: {}", + booleanObjectId, + e.what()), + gladius::events::Severity::Error}); + } + } + + try + { + auto const operandCount = booleanObject->GetOperandCount(); + for (Lib3MF_uint32 operandIndex = 0; operandIndex < operandCount; ++operandIndex) + { + Lib3MF::PMeshObject operandObject; + (void) booleanObject->GetOperand(operandIndex, operandObject); + if (operandObject) + { + m_graph->addDependency(booleanObjectId, operandObject->GetResourceID()); + } + } + } + catch (const std::exception & e) + { + if (m_logger) + { + m_logger->addEvent( + {fmt::format("Error processing BooleanObject operand dependency {}: {}", + booleanObjectId, + e.what()), + gladius::events::Severity::Error}); + } + } + } + void ResourceDependencyGraph::processMeshObject(Lib3MF::PMeshObject meshObject) { if (!meshObject) diff --git a/gladius/src/io/3mf/ResourceDependencyGraph.h b/gladius/src/io/3mf/ResourceDependencyGraph.h index b2d6c02d..2dc211da 100644 --- a/gladius/src/io/3mf/ResourceDependencyGraph.h +++ b/gladius/src/io/3mf/ResourceDependencyGraph.h @@ -132,6 +132,12 @@ namespace gladius::io */ void processComponentsObject(Lib3MF::PComponentsObject componentsObject); + /** + * @brief Process a BooleanObject resource and add its dependencies to the graph + * @param booleanObject Pointer to the BooleanObject resource + */ + void processBooleanObject(Lib3MF::PBooleanObject booleanObject); + /** * @brief Process a MeshObject resource and add its dependencies to the graph * @param meshObject Pointer to the MeshObject resource diff --git a/gladius/tests/unittests/ResourceDependencyGraph_tests.cpp b/gladius/tests/unittests/ResourceDependencyGraph_tests.cpp index 325019e8..af8c3271 100644 --- a/gladius/tests/unittests/ResourceDependencyGraph_tests.cpp +++ b/gladius/tests/unittests/ResourceDependencyGraph_tests.cpp @@ -73,6 +73,15 @@ namespace gladius_tests gladius::events::SharedLogger m_logger; }; + Lib3MF::sTransform createIdentityTransform() + { + Lib3MF::sTransform transform{}; + transform.m_Fields[0][0] = 1.0f; + transform.m_Fields[1][1] = 1.0f; + transform.m_Fields[2][2] = 1.0f; + return transform; + } + TEST_F(ResourceDependencyGraphTest, BuildGraph_EmptyModel_NoVerticesInGraph) { // Arrange @@ -130,6 +139,59 @@ namespace gladius_tests } } + TEST_F(ResourceDependencyGraphTest, + BuildGraph_BooleanObjectDependsOnBaseAndOperandMeshes_DependencyExists) + { + // Arrange + auto baseMesh = m_model->AddMeshObject(); + auto operandMesh = m_model->AddMeshObject(); + auto booleanObject = m_model->AddBooleanObject(); + auto const transform = createIdentityTransform(); + booleanObject->SetBaseObject(baseMesh.get(), transform); + booleanObject->AddOperand(operandMesh.get(), transform); + + io::ResourceDependencyGraph dependencyGraph(m_model, m_logger); + + // Act + dependencyGraph.buildGraph(); + + // Assert + auto const & graph = dependencyGraph.getGraph(); + EXPECT_TRUE(hasDependency(graph, booleanObject->GetResourceID(), baseMesh->GetResourceID())) + << "Boolean object should depend on its base mesh"; + EXPECT_TRUE( + hasDependency(graph, booleanObject->GetResourceID(), operandMesh->GetResourceID())) + << "Boolean object should depend on its operand mesh"; + } + + TEST_F(ResourceDependencyGraphTest, + GetAllRequiredResourceIdsForBuildItems_BooleanBuildItem_IncludesMeshes) + { + // Arrange + auto baseMesh = m_model->AddMeshObject(); + auto operandMesh = m_model->AddMeshObject(); + auto booleanObject = m_model->AddBooleanObject(); + auto const transform = createIdentityTransform(); + booleanObject->SetBaseObject(baseMesh.get(), transform); + booleanObject->AddOperand(operandMesh.get(), transform); + m_model->AddBuildItem(booleanObject.get(), transform); + + io::ResourceDependencyGraph dependencyGraph(m_model, m_logger); + + // Act + dependencyGraph.buildGraph(); + auto const requiredResourceIds = + dependencyGraph.getAllRequiredResourceIdsForBuildItems(); + + // Assert + EXPECT_TRUE(requiredResourceIds.count(booleanObject->GetResourceID()) != 0u) + << "Required IDs should include the boolean build item resource"; + EXPECT_TRUE(requiredResourceIds.count(baseMesh->GetResourceID()) != 0u) + << "Required IDs should include the boolean base mesh"; + EXPECT_TRUE(requiredResourceIds.count(operandMesh->GetResourceID()) != 0u) + << "Required IDs should include the boolean operand mesh"; + } + TEST_F(ResourceDependencyGraphTest, BuildGraph_NullModel_GraphRemainsEmpty) { // Arrange From fc56c951d8f1cf458b1b8ef1d9529a69cde6cc70 Mon Sep 17 00:00:00 2001 From: Jan Orend <56254096+3dJan@users.noreply.github.com> Date: Thu, 28 May 2026 14:50:07 +0200 Subject: [PATCH 38/72] [CMakeLists.txt] Enhance MSVC build settings for Ninja generator and increase parallel job limits --- gladius/CMakeLists.txt | 16 +++++++++- gladius/CMakePresets.json | 60 ++++++++++++++++++++++++++++++++++++-- gladius/src/CMakeLists.txt | 45 ++++++++++++++++++---------- 3 files changed, 102 insertions(+), 19 deletions(-) diff --git a/gladius/CMakeLists.txt b/gladius/CMakeLists.txt index bce885a1..e911d16b 100644 --- a/gladius/CMakeLists.txt +++ b/gladius/CMakeLists.txt @@ -101,7 +101,21 @@ include(cmake/Coverage.cmake) set(CMAKE_SKIP_INSTALL_ALL_DEPENDENCY OFF) if (MSVC) - add_compile_options(/bigobj /utf-8 /Zc:__cplusplus) + # /Zm200 gives MSVC more internal compiler heap for large template-heavy translation units. + # Keep it global so executable-only sources such as the MCP tools get the same protection as libraries. + add_compile_options(/bigobj /utf-8 /Zc:__cplusplus /Zm200) + + if(CMAKE_GENERATOR MATCHES "Ninja") + set(GLADIUS_MSVC_COMPILE_JOBS "2" CACHE STRING "Maximum concurrent MSVC compile jobs for Ninja builds") + set(GLADIUS_MSVC_HEAVY_COMPILE_JOBS "1" CACHE STRING "Maximum concurrent memory-heavy MSVC compile jobs for Ninja builds") + + set_property(GLOBAL PROPERTY JOB_POOLS + gladius_msvc_compile_pool=${GLADIUS_MSVC_COMPILE_JOBS} + gladius_msvc_heavy_compile_pool=${GLADIUS_MSVC_HEAVY_COMPILE_JOBS} + gladius_msvc_link_pool=1) + set(CMAKE_JOB_POOL_COMPILE gladius_msvc_compile_pool) + set(CMAKE_JOB_POOL_LINK gladius_msvc_link_pool) + endif() if (CMAKE_BUILD_TYPE STREQUAL "Debug") add_compile_options(/RTC1) diff --git a/gladius/CMakePresets.json b/gladius/CMakePresets.json index 9f5e980a..68d170f0 100644 --- a/gladius/CMakePresets.json +++ b/gladius/CMakePresets.json @@ -23,8 +23,10 @@ "VCPKG_TARGET_TRIPLET": "x64-windows-gladius", "VCPKG_OVERLAY_TRIPLETS": "${sourceDir}/vcpkg-triplets", "VCPKG_OVERLAY_PORTS": "${sourceDir}/vcpkg-overlay-ports", - "CMAKE_BUILD_PARALLEL_LEVEL": "2", "CMAKE_EXPORT_COMPILE_COMMANDS": "ON" + }, + "environment": { + "CMAKE_BUILD_PARALLEL_LEVEL": "8" } }, { @@ -111,7 +113,9 @@ "VCPKG_OVERLAY_PORTS": "${sourceDir}/vcpkg-overlay-ports", "CMAKE_EXPORT_COMPILE_COMMANDS": "ON", "ENABLE_COVERAGE": "OFF", - "ENABLE_TRACY": "TRUE", + "ENABLE_TRACY": "TRUE" + }, + "environment": { "CMAKE_BUILD_PARALLEL_LEVEL": "8" } }, @@ -154,6 +158,58 @@ } ], "version": 3, + "buildPresets": [ + { + "name": "x64-debug", + "displayName": "x64 Debug", + "configurePreset": "x64-debug", + "jobs": 2 + }, + { + "name": "x64-release", + "displayName": "x64 Release", + "configurePreset": "x64-release", + "jobs": 2 + }, + { + "name": "x64-release-debug", + "displayName": "x64 Release with Debug", + "configurePreset": "x64-release-debug", + "jobs": 2 + }, + { + "name": "x64-release-debug-asan", + "displayName": "x64 Release with Debug and ASAN", + "configurePreset": "x64-release-debug-asan", + "jobs": 2 + }, + { + "name": "x64-release-debug-noOpenCL", + "displayName": "x64 Release with Debug (No OpenCL Tests)", + "configurePreset": "x64-release-debug-noOpenCL", + "jobs": 2 + }, + { + "name": "linux-debug", + "configurePreset": "linux-debug", + "jobs": 8 + }, + { + "name": "linux-release", + "configurePreset": "linux-release", + "jobs": 8 + }, + { + "name": "linux-releaseWithDebug", + "configurePreset": "linux-releaseWithDebug", + "jobs": 8 + }, + { + "name": "linux-releaseWithDebug-noOpenCL", + "configurePreset": "linux-releaseWithDebug-noOpenCL", + "jobs": 8 + } + ], "testPresets": [ { "name": "ReleaseWithDebug", diff --git a/gladius/src/CMakeLists.txt b/gladius/src/CMakeLists.txt index 16ac4c54..81d52899 100644 --- a/gladius/src/CMakeLists.txt +++ b/gladius/src/CMakeLists.txt @@ -7,6 +7,13 @@ file(GLOB MCP_SOURCES LIST_DIRECTORIES true mcp/*.h mcp/*.cpp) # Add MCP tools sources file(GLOB MCP_TOOLS_SOURCES LIST_DIRECTORIES true mcp/tools/*.h mcp/tools/*.cpp) +if (MSVC AND CMAKE_GENERATOR MATCHES "Ninja") + set_source_files_properties( + ${CMAKE_CURRENT_SOURCE_DIR}/mcp/tools/ParameterManagementTool.cpp + ${CMAKE_CURRENT_SOURCE_DIR}/mcp/tools/ResourceManagementTool.cpp + PROPERTIES JOB_POOL_COMPILE gladius_msvc_heavy_compile_pool) +endif() + add_library(${BINARY}_lib STATIC ${SOURCES}) @@ -105,9 +112,11 @@ target_link_libraries(${BINARY}_lib PUBLIC ${DEPENDENCIES_CORE}) target_link_libraries(${BINARY}_lib_mcp PUBLIC ${DEPENDENCIES_CORE}) target_link_libraries(${BINARY} PUBLIC ${BINARY}_lib) -# Precompiled headers: heavy/frequently-used headers compiled once per target, -# dramatically reducing MSVC memory pressure and improving incremental build speed. -set(GLADIUS_PCH_HEADERS +# Precompiled headers: keep the Windows PCH deliberately lean. Large template-heavy +# dependencies such as OpenVDB/Boost/Eigen/ImGui can raise the baseline heap for every +# MSVC translation unit that uses the PCH. Linux/Clang keeps the broader PCH because it +# is not where we see C1060 heap exhaustion. +set(GLADIUS_CORE_PCH_HEADERS @@ -132,22 +141,25 @@ set(GLADIUS_PCH_HEADERS - - ) -target_precompile_headers(${BINARY}_lib PRIVATE ${GLADIUS_PCH_HEADERS}) + +if (NOT MSVC) + list(APPEND GLADIUS_CORE_PCH_HEADERS + + ) +endif() + +set(GLADIUS_MCP_PCH_HEADERS + ${GLADIUS_CORE_PCH_HEADERS} + +) + +target_precompile_headers(${BINARY}_lib PRIVATE ${GLADIUS_CORE_PCH_HEADERS}) # _lib_mcp has GLADIUS_ENABLE_MCP defined so it gets its own PCH build -target_precompile_headers(${BINARY}_lib_mcp PRIVATE ${GLADIUS_PCH_HEADERS}) +target_precompile_headers(${BINARY}_lib_mcp PRIVATE ${GLADIUS_CORE_PCH_HEADERS}) # executables share the PCH from their corresponding library (same flags) target_precompile_headers(${BINARY} REUSE_FROM ${BINARY}_lib) -# Increase the MSVC compiler's internal heap to prevent C1060 heap-overflow -# errors on large translation units (e.g. ManifoldDualContouringGpu.cpp). -if (MSVC) - target_compile_options(${BINARY}_lib PUBLIC /Zm200) - target_compile_options(${BINARY}_lib_mcp PUBLIC /Zm200) -endif() - # Enable AVX2 SIMD instructions for Phase 3 optimization # PUBLIC so consumers (tests, etc.) see the same Eigen alignment as the library, # avoiding ODR violations from mismatched struct layouts. @@ -178,8 +190,9 @@ endif() target_link_libraries(gladiusmcp PUBLIC ${BINARY}_lib_mcp ${DEPENDENCIES_MCP}) # Ensure the MCP-enabled static library is built before linking the executable add_dependencies(gladiusmcp ${BINARY}_lib_mcp) -# Share the PCH already built for the MCP library (same flags + GLADIUS_ENABLE_MCP) -target_precompile_headers(gladiusmcp REUSE_FROM ${BINARY}_lib_mcp) +# Use an MCP-specific PCH instead of reusing the core/UI PCH; the MCP tools mostly need +# standard library + JSON, not the full Eigen/ImGui-heavy core baseline. +target_precompile_headers(gladiusmcp PRIVATE ${GLADIUS_MCP_PCH_HEADERS}) if (NOT WIN32) target_compile_options(gladiusmcp PRIVATE -mavx2 -mfma) endif() From 6e64bc749ee8fa2b7b950ee511371402bd044632 Mon Sep 17 00:00:00 2001 From: Jan Orend <56254096+3dJan@users.noreply.github.com> Date: Thu, 28 May 2026 17:04:30 +0200 Subject: [PATCH 39/72] [CLProgram] Enable OpenCL dump based on environment variables; improve diagnostic helper function [ComputeCore] Add optimized source generation methods and manage asynchronous jobs for improved performance [ProgramManager] Implement methods to set preview and optimized model sources with appropriate locking [ToCommandStreamVisitor] Simplify output assignment for position fields in generated commands --- gladius/src/CLProgram.cpp | 23 ++- gladius/src/compute/ComputeCore.cpp | 147 +++++++++++++++++-- gladius/src/compute/ComputeCore.h | 23 +++ gladius/src/compute/ProgramManager.cpp | 54 +++++++ gladius/src/compute/ProgramManager.h | 4 + gladius/src/nodes/ToCommandStreamVisitor.cpp | 12 +- 6 files changed, 245 insertions(+), 18 deletions(-) diff --git a/gladius/src/CLProgram.cpp b/gladius/src/CLProgram.cpp index e8859006..fbbb9c35 100644 --- a/gladius/src/CLProgram.cpp +++ b/gladius/src/CLProgram.cpp @@ -33,7 +33,28 @@ namespace gladius // --- Diagnostic helpers (OpenCL source/options dump) --- inline bool isOclDumpEnabled() { - return false; + auto isEnabled = [](char const * value) + { + if (value == nullptr) + { + return false; + } + + auto normalized = std::string{value}; + std::transform(normalized.begin(), + normalized.end(), + normalized.begin(), + [](unsigned char character) + { + return static_cast(std::tolower(character)); + }); + + return normalized == "1" || normalized == "true" || normalized == "yes" || + normalized == "on"; + }; + + return isEnabled(std::getenv("GLADIUS_OCL_DUMP")) || + isEnabled(std::getenv("GLADIUS_OPENCL_DUMP")); } std::string toLowerCopy(std::string_view text) diff --git a/gladius/src/compute/ComputeCore.cpp b/gladius/src/compute/ComputeCore.cpp index 68ddf23e..4faefb31 100644 --- a/gladius/src/compute/ComputeCore.cpp +++ b/gladius/src/compute/ComputeCore.cpp @@ -4,8 +4,10 @@ #include #include +#include #include #include +#include #include #include #include @@ -64,6 +66,7 @@ namespace gladius { ProfileFunction std::lock_guard lock(m_computeMutex); + (void) beginOptimizedSourceGeneration(); m_boundingBox.reset(); m_boundingBoxStale.store(false, std::memory_order_release); m_programs.reset(); @@ -966,7 +969,9 @@ namespace gladius { ProfileFunction - std::lock_guard + pollOptimizedSourceGeneration(); + + std::lock_guard lock(m_computeMutex); m_programs.setVdbRequired(requiresNanoVdbLocked()); m_programs.recompileIfRequired(); @@ -1248,6 +1253,108 @@ namespace gladius } } + std::uint64_t ComputeCore::beginOptimizedSourceGeneration() + { + return m_optimizedSourceGenerationEpoch.fetch_add(1u, std::memory_order_acq_rel) + 1u; + } + + bool ComputeCore::isOptimizedSourceGenerationCurrent(std::uint64_t const generation) const + { + return m_optimizedSourceGenerationEpoch.load(std::memory_order_acquire) == generation; + } + + void ComputeCore::startOptimizedSourceGeneration(nodes::SharedAssembly assembly, + ParameterSignature parameterSignature, + std::uint64_t const generation) + { + if (!assembly) + { + return; + } + + logMsg(fmt::format("Starting optimized source generation job {}", generation)); + + auto job = std::async(std::launch::async, + [assembly = std::move(assembly), + parameterSignature = std::move(parameterSignature), + generation]() mutable + { + OptimizedSourceGenerationResult result; + result.generation = generation; + result.parameterSignature = std::move(parameterSignature); + + try + { + std::stringstream optimizedKernelStream; + nodes::ToOclVisitor visitor; + assembly->visitNodes(visitor); + visitor.write(optimizedKernelStream); + result.source = optimizedKernelStream.str(); + } + catch (std::exception const & e) + { + result.errorMessage = e.what(); + } + catch (...) + { + result.errorMessage = + "Unknown error during optimized source generation"; + } + + return result; + }); + + std::lock_guard lock(m_optimizedSourceGenerationMutex); + m_optimizedSourceGenerationJobs.emplace_back(std::move(job)); + } + + void ComputeCore::pollOptimizedSourceGeneration() + { + std::vector completedResults; + { + std::lock_guard lock(m_optimizedSourceGenerationMutex); + auto jobIt = m_optimizedSourceGenerationJobs.begin(); + while (jobIt != m_optimizedSourceGenerationJobs.end()) + { + if (jobIt->wait_for(std::chrono::seconds(0)) == std::future_status::ready) + { + completedResults.emplace_back(jobIt->get()); + jobIt = m_optimizedSourceGenerationJobs.erase(jobIt); + continue; + } + ++jobIt; + } + } + + for (auto & result : completedResults) + { + if (!isOptimizedSourceGenerationCurrent(result.generation)) + { + logMsg(fmt::format("Discarding stale optimized source generation job {}", + result.generation)); + continue; + } + + if (!result.errorMessage.empty()) + { + logMsg(fmt::format("Optimized source generation failed: {}", result.errorMessage)); + continue; + } + + if (result.source.empty()) + { + logMsg("Optimized source generation produced an empty source"); + continue; + } + + logMsg(fmt::format("Optimized source generation job {} finished: {} bytes", + result.generation, + result.source.size())); + m_programs.setOptimizedModelSource(std::move(result.source), true, true); + m_programs.setCompiledParameterSignature(std::move(result.parameterSignature)); + } + } + void ComputeCore::refreshProgram(nodes::SharedAssembly assembly) { ProfileFunction; @@ -1267,10 +1374,16 @@ namespace gladius return; } + auto const optimizedSourceGeneration = beginOptimizedSourceGeneration(); + auto const parameterSignature = ParameterSignature::compute(*assembly); + m_boundingBox.reset(); invalidatePreCompSdf("refreshProgram"); auto commandStreamKernel = std::optional{}; auto optimizedKernel = std::optional{}; + bool const generateOptimizedSourceAsync = + m_codeGenerator == CodeGenerator::Automatic && + m_programs.isOptimizedRenderCompilationDeferred(); if (m_codeGenerator == CodeGenerator::CommandStream || m_codeGenerator == CodeGenerator::Automatic) @@ -1296,7 +1409,8 @@ namespace gladius commandStreamKernel = modelKernel.str(); } - if (m_codeGenerator == CodeGenerator::Code || m_codeGenerator == CodeGenerator::Automatic) + if (m_codeGenerator == CodeGenerator::Code || + (m_codeGenerator == CodeGenerator::Automatic && !generateOptimizedSourceAsync)) { std::stringstream optimizedKernelStream; nodes::ToOclVisitor visitor; @@ -1308,7 +1422,26 @@ namespace gladius if (m_codeGenerator == CodeGenerator::Automatic) { - if (!commandStreamKernel.has_value() || !optimizedKernel.has_value()) + if (!commandStreamKernel.has_value()) + { + logMsg("Automatic code generation failed to create a command-stream render source"); + return; + } + + if (generateOptimizedSourceAsync) + { + m_programs.setPreviewModelSource(*commandStreamKernel); + m_programs.setCompiledParameterSignature(parameterSignature); + logMsg(fmt::format("Captured parameter signature: {}", + parameterSignature.toString())); + logMsg(fmt::format( + "Published command-stream preview source: {} bytes; optimized source generation deferred", + commandStreamKernel->size())); + startOptimizedSourceGeneration(assembly, parameterSignature, optimizedSourceGeneration); + return; + } + + if (!optimizedKernel.has_value()) { logMsg("Automatic code generation failed to create both render sources"); return; @@ -1335,12 +1468,8 @@ namespace gladius } // Capture parameter signature after code generation for fast-path validation - if (assembly) - { - auto const signature = ParameterSignature::compute(*assembly); - m_programs.setCompiledParameterSignature(signature); - logMsg(fmt::format("Captured parameter signature: {}", signature.toString())); - } + m_programs.setCompiledParameterSignature(parameterSignature); + logMsg(fmt::format("Captured parameter signature: {}", parameterSignature.toString())); } void ComputeCore::tryRefreshProgramProtected(nodes::SharedAssembly assembly) diff --git a/gladius/src/compute/ComputeCore.h b/gladius/src/compute/ComputeCore.h index 1401ef91..6d354c5f 100644 --- a/gladius/src/compute/ComputeCore.h +++ b/gladius/src/compute/ComputeCore.h @@ -18,14 +18,18 @@ #include #include +#include #include #include #include +#include #include #include #include +#include #include +#include namespace cl { @@ -764,6 +768,21 @@ namespace gladius void generateContourMarchingSquare(nodes::SliceParameter const & sliceParameter); void generateContourQuadtree(nodes::SliceParameter const & sliceParameter); + struct OptimizedSourceGenerationResult + { + std::uint64_t generation = 0u; + std::string source; + ParameterSignature parameterSignature; + std::string errorMessage; + }; + + [[nodiscard]] std::uint64_t beginOptimizedSourceGeneration(); + [[nodiscard]] bool isOptimizedSourceGenerationCurrent(std::uint64_t generation) const; + void startOptimizedSourceGeneration(nodes::SharedAssembly assembly, + ParameterSignature parameterSignature, + std::uint64_t generation); + void pollOptimizedSourceGeneration(); + mutable std::recursive_mutex m_computeMutex; // TODO: replace with std::mutex // Keep the OpenCL context before every member that owns buffers/images referencing it. @@ -825,6 +844,10 @@ namespace gladius SharedKernelReplacements m_kernelReplacements; + std::atomic m_optimizedSourceGenerationEpoch{0u}; + std::mutex m_optimizedSourceGenerationMutex; + std::vector> m_optimizedSourceGenerationJobs; + ProgramManager m_programs; }; } diff --git a/gladius/src/compute/ProgramManager.cpp b/gladius/src/compute/ProgramManager.cpp index d8c6d1b1..d035fe52 100644 --- a/gladius/src/compute/ProgramManager.cpp +++ b/gladius/src/compute/ProgramManager.cpp @@ -933,6 +933,60 @@ namespace gladius setModelSources(std::move(source), std::nullopt, true); } + void ProgramManager::setPreviewModelSource(std::string previewSource) + { + std::lock_guard lock(m_modelSourceMutex); + invalidateCachedBestRenderProgram(); + + m_previewModelSource = std::move(previewSource); + m_hasPreviewModelSource = true; + + // A preview-only publication represents a new model revision whose optimized source is + // not available yet. Disable optimized selection so an older optimized program cannot win. + m_modelSource.clear(); + m_compileOptimizedRenderProgram = false; + + if (m_eventLogger) + { + auto const message = fmt::format( + "ProgramManager.setPreviewModelSource: preview={} bytes", + m_previewModelSource.size()); + getLogger().addEvent({message, events::Severity::Info}); + } + + m_previewRenderState.signalCompilationRequired(); + } + + void ProgramManager::setOptimizedModelSource(std::string optimizedSource, + bool const compileOptimizedRenderProgram, + bool const compileSlicerProgram) + { + std::lock_guard lock(m_modelSourceMutex); + invalidateCachedBestRenderProgram(); + + m_modelSource = std::move(optimizedSource); + m_compileOptimizedRenderProgram = compileOptimizedRenderProgram; + + if (m_eventLogger) + { + auto const message = fmt::format( + "ProgramManager.setOptimizedModelSource: optimized={} bytes optimizedRender={} slicer={}", + m_modelSource.size(), + m_compileOptimizedRenderProgram ? 1 : 0, + compileSlicerProgram ? 1 : 0); + getLogger().addEvent({message, events::Severity::Info}); + } + + if (m_compileOptimizedRenderProgram) + { + m_renderState.signalCompilationRequired(); + } + if (compileSlicerProgram) + { + m_slicerState.signalCompilationRequired(); + } + } + void ProgramManager::setModelSources(std::string optimizedSource, std::optional previewSource, bool compileOptimizedRenderProgram) diff --git a/gladius/src/compute/ProgramManager.h b/gladius/src/compute/ProgramManager.h index b4513589..6a50f431 100644 --- a/gladius/src/compute/ProgramManager.h +++ b/gladius/src/compute/ProgramManager.h @@ -131,6 +131,10 @@ namespace gladius void setModelSources(std::string optimizedSource, std::optional previewSource, bool compileOptimizedRenderProgram); + void setPreviewModelSource(std::string previewSource); + void setOptimizedModelSource(std::string optimizedSource, + bool compileOptimizedRenderProgram, + bool compileSlicerProgram); void setVdbRequired(bool required); [[nodiscard]] bool isVdbSupported() const; diff --git a/gladius/src/nodes/ToCommandStreamVisitor.cpp b/gladius/src/nodes/ToCommandStreamVisitor.cpp index 1db2ef90..ea82c4bb 100644 --- a/gladius/src/nodes/ToCommandStreamVisitor.cpp +++ b/gladius/src/nodes/ToCommandStreamVisitor.cpp @@ -288,12 +288,9 @@ namespace gladius::nodes out << m_signature.str(); out << fmt::format("float out[{}];\n", roundUp(m_currentOutPutIndex + 1, 1024)); - out << fmt::format("out[1]={}.x;\n", - getAssemblyBegin().getOutputs().at(FieldNames::Pos).getUniqueName()); - out << fmt::format("out[2]={}.y;\n", - getAssemblyBegin().getOutputs().at(FieldNames::Pos).getUniqueName()); - out << fmt::format("out[3]={}.z;\n", - getAssemblyBegin().getOutputs().at(FieldNames::Pos).getUniqueName()); + out << "out[1]=pos.x;\n"; + out << "out[2]=pos.y;\n"; + out << "out[3]=pos.z;\n"; out << "for (int i = 0; i < sizeOfCmds; ++i)\n"; out << "{\n"; @@ -1807,8 +1804,7 @@ namespace gladius::nodes { acquireOutputIndex(beginning.getOutputs().at(FieldNames::Pos), 3); - m_signature << fmt::format("float4 model(float3 {0} , PAYLOAD_ARGS)\n{{\n", - beginning.getOutputs().at(FieldNames::Pos).getUniqueName()); + m_signature << "float4 model(float3 pos, PAYLOAD_ARGS)\n{\n"; } m_partBegin = beginning; From a5a16abaa6ffad54fdb141e59f9fb7fdf4fea4d0 Mon Sep 17 00:00:00 2001 From: Jan Orend <56254096+3dJan@users.noreply.github.com> Date: Thu, 28 May 2026 21:53:42 +0200 Subject: [PATCH 40/72] [CMakeLists.txt] Adjust precompiled header handling for non-Windows builds to avoid PCH compatibility issues --- gladius/src/CMakeLists.txt | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/gladius/src/CMakeLists.txt b/gladius/src/CMakeLists.txt index 81d52899..412c0984 100644 --- a/gladius/src/CMakeLists.txt +++ b/gladius/src/CMakeLists.txt @@ -157,8 +157,14 @@ set(GLADIUS_MCP_PCH_HEADERS target_precompile_headers(${BINARY}_lib PRIVATE ${GLADIUS_CORE_PCH_HEADERS}) # _lib_mcp has GLADIUS_ENABLE_MCP defined so it gets its own PCH build target_precompile_headers(${BINARY}_lib_mcp PRIVATE ${GLADIUS_CORE_PCH_HEADERS}) -# executables share the PCH from their corresponding library (same flags) -target_precompile_headers(${BINARY} REUSE_FROM ${BINARY}_lib) +# On non-Windows builds the static library uses -fPIC while executables use -fPIE. +# Clang encodes that distinction in the PCH and rejects a -fPIC PCH consumed by +# a -fPIE translation unit, so the executable needs its own PCH there. +if (MSVC) + target_precompile_headers(${BINARY} REUSE_FROM ${BINARY}_lib) +else() + target_precompile_headers(${BINARY} PRIVATE ${GLADIUS_CORE_PCH_HEADERS}) +endif() # Enable AVX2 SIMD instructions for Phase 3 optimization # PUBLIC so consumers (tests, etc.) see the same Eigen alignment as the library, From 80a83611d228fa2b32cfe0da516f5bc7edc3cdac Mon Sep 17 00:00:00 2001 From: Jan Orend <56254096+3dJan@users.noreply.github.com> Date: Sat, 30 May 2026 11:25:51 +0200 Subject: [PATCH 41/72] [Document] Ensure proper destruction by joining in-flight async workers to prevent use-after-free issues --- gladius/src/Document.cpp | 33 ++++++++++++ gladius/src/Document.h | 8 +++ gladius/src/compute/ComputeCore.cpp | 53 ++++++++++++++----- gladius/src/ui/RenderWindow.cpp | 13 ++++- .../src/ui/render/RenderUpdateCoordinator.h | 39 ++++++++++++++ gladius/tests/unittests/ComputeCore_tests.cpp | 26 +++++++++ .../render/RenderUpdateCoordinator_tests.cpp | 45 ++++++++++++++++ 7 files changed, 203 insertions(+), 14 deletions(-) diff --git a/gladius/src/Document.cpp b/gladius/src/Document.cpp index be3e67f2..278321fb 100644 --- a/gladius/src/Document.cpp +++ b/gladius/src/Document.cpp @@ -291,6 +291,39 @@ namespace gladius m_backupManager.initialize(); } + Document::~Document() + { + // Join in-flight async workers before any member is destroyed. The workers + // (refreshWorker / file-load) capture `this` and touch members such as + // m_isLoading, m_loadingError and m_buildItems that are declared after the + // future members. Relying on the implicit std::future destructors to join + // would do so only after those later-declared members are already gone, + // producing a use-after-free during shutdown. + try + { + if (m_futureModelRefresh.valid()) + { + m_futureModelRefresh.wait(); + } + } + catch (...) + { + // Never throw from a destructor. + } + + try + { + if (m_futureFileLoad.valid()) + { + m_futureFileLoad.wait(); + } + } + catch (...) + { + // Never throw from a destructor. + } + } + bool Document::refreshModelAsync() { if (!m_assembly || !m_core) diff --git a/gladius/src/Document.h b/gladius/src/Document.h index 759ada00..8c575136 100644 --- a/gladius/src/Document.h +++ b/gladius/src/Document.h @@ -129,6 +129,14 @@ namespace gladius void resetGeneratorContext(); explicit Document(std::shared_ptr core); + + /// Blocks until any in-flight asynchronous file-load / model-refresh worker + /// has finished before member teardown begins. Without this, the implicit + /// std::future destructors join the workers only after members declared after + /// them (e.g. m_isLoading, m_buildItems) are already destroyed, which is a + /// use-after-free hazard while the worker is still running. + ~Document(); + [[nodiscard]] bool refreshModelIfNoCompilationIsRunning(); /// Signal that a structural graph edit occurred. diff --git a/gladius/src/compute/ComputeCore.cpp b/gladius/src/compute/ComputeCore.cpp index 4faefb31..ccaa309c 100644 --- a/gladius/src/compute/ComputeCore.cpp +++ b/gladius/src/compute/ComputeCore.cpp @@ -2138,7 +2138,16 @@ namespace gladius throwIfNoOpenGL(); recompileIfRequired(); - if (getBestRenderProgram()->isCompilationInProgress()) + // Use the non-blocking variant: getBestRenderProgram() would block on + // ProgramManager::m_modelSourceMutex while a background compile/model-source + // swap holds it, stalling the UI frame. Keep the previous frame instead. + auto renderProgram = tryGetBestRenderProgram(); + if (!renderProgram.has_value()) + { + return false; + } + + if ((*renderProgram)->isCompilationInProgress()) { LOG_LOCATION return false; @@ -2154,7 +2163,7 @@ namespace gladius m_resources->getRenderingSettings().approximation = AM_HYBRID; m_lastUsedApproximation = AM_HYBRID; m_lastUsedHQApproximation = AM_HYBRID; - getBestRenderProgram()->renderScene( + (*renderProgram)->renderScene( *m_primitives, *resultImage, m_sliceHeight_mm, startLine, endLine); m_resources->getRenderingSettings().approximation = AM_FULL_MODEL; @@ -2188,7 +2197,13 @@ namespace gladius // Don't call throwIfNoOpenGL() - we don't need GL for pure compute! recompileIfRequired(); - if (getBestRenderProgram()->isCompilationInProgress()) + auto renderProgram = tryGetBestRenderProgram(); + if (!renderProgram.has_value()) + { + return false; + } + + if ((*renderProgram)->isCompilationInProgress()) { LOG_LOCATION return false; @@ -2199,7 +2214,7 @@ namespace gladius m_lastUsedHQApproximation = AM_HYBRID; // Render directly to the target CL image buffer (no GL involved) - cl::Event const renderEvent = getBestRenderProgram()->renderSceneAsync( + cl::Event const renderEvent = (*renderProgram)->renderSceneAsync( commandQueue, *m_primitives, targetImage, m_sliceHeight_mm, startLine, endLine); m_resources->getRenderingSettings().approximation = AM_FULL_MODEL; @@ -2237,7 +2252,13 @@ namespace gladius recompileIfRequired(); - if (getBestRenderProgram()->isCompilationInProgress()) + auto renderProgram = tryGetBestRenderProgram(); + if (!renderProgram.has_value()) + { + return false; + } + + if ((*renderProgram)->isCompilationInProgress()) { LOG_LOCATION return false; @@ -2247,7 +2268,7 @@ namespace gladius m_lastUsedHQApproximation = settings.approximation; m_lastUsedPreviewApproximation = settings.approximation; - cl::Event const renderEvent = getBestRenderProgram()->renderSceneAsync( + cl::Event const renderEvent = (*renderProgram)->renderSceneAsync( commandQueue, *m_primitives, targetImage, settings, m_sliceHeight_mm, startLine, endLine); if (completionEvent != nullptr) @@ -2286,6 +2307,14 @@ namespace gladius throwIfNoOpenGL(); + // Use the non-blocking variant so a background compile holding + // ProgramManager::m_modelSourceMutex cannot stall the UI frame. + auto renderProgram = tryGetBestRenderProgram(); + if (!renderProgram.has_value()) + { + return LowResPreviewRenderStatus::Skipped; + } + glFinish(); auto lowResPreviewImage = m_lowResPreviewImage.load(std::memory_order_acquire); @@ -2303,14 +2332,14 @@ namespace gladius // and avoids expensive shadow rays and AO samples during interactive editing m_resources->getRenderingSettings().flags |= RF_DISABLE_SHADOWS | RF_DISABLE_AO; - getBestRenderProgram()->renderScene(*m_primitives, - *lowResPreviewImage, - m_sliceHeight_mm, - 0, - lowResPreviewImage->getHeight()); + (*renderProgram)->renderScene(*m_primitives, + *lowResPreviewImage, + m_sliceHeight_mm, + 0, + lowResPreviewImage->getHeight()); m_resources->getRenderingSettings().flags &= ~(RF_DISABLE_SHADOWS | RF_DISABLE_AO); m_resources->getRenderingSettings().approximation = AM_FULL_MODEL; - getBestRenderProgram()->resample( + (*renderProgram)->resample( *lowResPreviewImage, *resultImage, 0, resultImage->getHeight()); resultImage->invalidateContent(); diff --git a/gladius/src/ui/RenderWindow.cpp b/gladius/src/ui/RenderWindow.cpp index cb044d89..0c88497b 100644 --- a/gladius/src/ui/RenderWindow.cpp +++ b/gladius/src/ui/RenderWindow.cpp @@ -1776,9 +1776,18 @@ namespace gladius::ui { // Discard any pending async preview so the streaming worker doesn't // stall waiting for m_streamingFrameConsumed. - if (m_asyncController && m_asyncController->tryConsumePreviewResult().has_value()) + if (m_asyncController) { - m_streamingFrameConsumed.store(true, std::memory_order_release); + if (auto preview = m_asyncController->tryConsumePreviewResult()) + { + // The result references old-size CL images and cannot be presented, but the + // coordinator still has this preview marked in-flight. Release it (cancelled) + // so its interactive task slot does not leak — otherwise preview scheduling + // stays blocked until the next camera move reclaims it. + preview->cancelled = true; + completeCoordinatorPreviewTask(*preview); + m_streamingFrameConsumed.store(true, std::memory_order_release); + } } return; } diff --git a/gladius/src/ui/render/RenderUpdateCoordinator.h b/gladius/src/ui/render/RenderUpdateCoordinator.h index d129cf86..bef0094b 100644 --- a/gladius/src/ui/render/RenderUpdateCoordinator.h +++ b/gladius/src/ui/render/RenderUpdateCoordinator.h @@ -248,6 +248,9 @@ namespace gladius::ui::async_rendering { RenderUpdateDecision decision{}; m_realtime.beginFrame(); + // Self-heal: reclaim any in-flight task the translation layer failed to complete and + // that is already stale, so a single orphaned entry cannot wedge the catch-up pipeline. + reconcileStaleInFlight(); if (m_interactionState == RenderInteractionState::Static) { scheduleStaticCatchUp(decision); @@ -381,6 +384,42 @@ namespace gladius::ui::async_rendering m_inFlight.erase(newEnd, oldEnd); } + /// Drops every in-flight heavy/static task whose stamp is already stale under that task's + /// own freshness mask, i.e. a task whose eventual result would be discarded by + /// completeTask() anyway. + /// + /// This is the self-healing counterpart to the strict StartTask/completeTask pairing the + /// translation layer (RenderWindow) must otherwise guarantee across many early-return and + /// atomic code paths. If any one of those paths ever fails to drive a started task back to a + /// terminal completeTask()/failTask(), the orphaned entry would block all future tasks of + /// that type forever (hasAnyInFlight() is stamp-agnostic), stalling the static catch-up + /// pipeline. Reclaiming stale entries at every tick keeps the scheduler from getting wedged. + /// It is safe because a stale task can never become current again, and the translation layer + /// guards in-flight GPU jobs separately, so reclaiming here cannot cause duplicate execution. + /// + /// Interactive frame tasks (RealtimeFullFrame / LowResolutionPreview / StreamingPreview) are + /// intentionally excluded: stale interactive frames are retained as backpressure so only one + /// interactive frame is ever in flight (latest-wins on completion). Those are reclaimed by + /// releaseStaleInteractiveInFlight() at the appropriate interaction transitions instead. + void reconcileStaleInFlight() + { + auto const oldEnd = m_inFlight.end(); + auto const newEnd = std::remove_if(m_inFlight.begin(), + m_inFlight.end(), + [this](InFlightTask const & task) + { + bool const interactiveFrameTask = + task.type == RenderTaskType::RealtimeFullFrame || + task.type == RenderTaskType::LowResolutionPreview || + task.type == RenderTaskType::StreamingPreview; + return !interactiveFrameTask && + !matches(task.stamp, + m_latestStamp, + freshnessMaskFor(task.type)); + }); + m_inFlight.erase(newEnd, oldEnd); + } + [[nodiscard]] bool hasEquivalentInFlight(RenderTaskType type, RenderStamp const & stamp) const noexcept { auto const mask = freshnessMaskFor(type); diff --git a/gladius/tests/unittests/ComputeCore_tests.cpp b/gladius/tests/unittests/ComputeCore_tests.cpp index 854c3eab..80ba2a0c 100644 --- a/gladius/tests/unittests/ComputeCore_tests.cpp +++ b/gladius/tests/unittests/ComputeCore_tests.cpp @@ -513,4 +513,30 @@ namespace gladius_tests EXPECT_NEAR(bBox->max.y, 74.136703491210938f, tolerance); EXPECT_NEAR(bBox->max.z, 50.00640869140625f, tolerance); } + + // Regression test for the Document lifecycle fix (review: F7). + // Destroying a Document while an asynchronous model refresh is in flight must join the + // worker before any member is torn down. Previously the implicit std::future destructors + // joined the worker only after later-declared members were already gone (use-after-free). + TEST_F(ComputeCore_Test, DocumentDestruction_WithInFlightModelRefresh_JoinsWorkerWithoutCrash) + { + SKIP_IF_OPENCL_UNAVAILABLE(); + + auto core = createCore(); + auto document = std::make_unique(core); + + // Launch the background refresh worker (sets m_futureModelRefresh). + static_cast(document->refreshModelIfNoCompilationIsRunning()); + + // Destroy while the worker may still be running. Run the teardown on a separate + // thread guarded by a watchdog so a regression that deadlocks fails the test + // instead of hanging the whole suite. + auto teardown = std::async(std::launch::async, + [doc = std::move(document)]() mutable { doc.reset(); }); + + ASSERT_EQ(teardown.wait_for(std::chrono::seconds(30)), std::future_status::ready) + << "Document destruction deadlocked while joining the async refresh worker"; + ASSERT_NO_THROW(teardown.get()); + } } + diff --git a/gladius/tests/unittests/ui/render/RenderUpdateCoordinator_tests.cpp b/gladius/tests/unittests/ui/render/RenderUpdateCoordinator_tests.cpp index 99c0a0ae..fdc5a59b 100644 --- a/gladius/tests/unittests/ui/render/RenderUpdateCoordinator_tests.cpp +++ b/gladius/tests/unittests/ui/render/RenderUpdateCoordinator_tests.cpp @@ -807,4 +807,49 @@ namespace gladius::ui::async_rendering::tests ASSERT_TRUE(retry.has_value()); EXPECT_NE(retry->requestId, bbox->requestId); } + + TEST(RenderUpdateCoordinator, Tick_WithOrphanedStaleTask_ReclaimsAndReschedules) + { + RenderUpdateCoordinator coordinator; + + // Start a parameter upload and "leak" it: the translation layer never drives it back to a + // terminal completeTask()/failTask(). This models a missed completion on one of + // RenderWindow's many early-return paths. + auto decision = coordinator.notifyParameterChanged(false); + auto orphan = findStartedTask(decision, RenderTaskType::ParameterUpload); + ASSERT_TRUE(orphan.has_value()); + + // A second parameter change makes the orphan stale and resets the upload requirement, but + // the stamp-agnostic hasAnyInFlight() guard lets the orphan block a fresh upload. + decision = coordinator.notifyParameterChanged(false); + EXPECT_FALSE(hasStartedTask(decision, RenderTaskType::ParameterUpload)); + EXPECT_TRUE(hasCommand(decision, RenderCommandType::KeepCurrentFrame)); + + // tick() reconciles the stale orphan and reschedules the upload for the latest stamp, + // instead of staying wedged until some unrelated event clears the orphan. + decision = coordinator.tick(); + auto rescheduled = findStartedTask(decision, RenderTaskType::ParameterUpload); + ASSERT_TRUE(rescheduled.has_value()); + EXPECT_NE(rescheduled->requestId, orphan->requestId); + EXPECT_TRUE( + matches(rescheduled->stamp, coordinator.latestStamp(), RenderStampMask::heavyGeometryTask())); + } + + TEST(RenderUpdateCoordinator, Tick_WithCurrentInFlightHeavyTask_DoesNotReclaimOrDuplicate) + { + RenderUpdateCoordinator coordinator; + auto decision = coordinator.tick(); + auto bbox = findStartedTask(decision, RenderTaskType::BoundingBoxUpdate); + ASSERT_TRUE(bbox.has_value()); + + // The bbox is current and still legitimately running; reconciliation must leave it alone + // and must not start a duplicate. + decision = coordinator.tick(); + EXPECT_FALSE(hasStartedTask(decision, RenderTaskType::BoundingBoxUpdate)); + EXPECT_TRUE(hasCommand(decision, RenderCommandType::KeepCurrentFrame)); + + // Completing it still advances the pipeline, proving the entry was never reclaimed. + decision = coordinator.completeTask(completed(*bbox)); + EXPECT_TRUE(hasStartedTask(decision, RenderTaskType::SdfPrecomputation)); + } } From 2de8d8d6231fe64d888c5b76651a88a075795f76 Mon Sep 17 00:00:00 2001 From: Jan Orend <56254096+3dJan@users.noreply.github.com> Date: Sat, 30 May 2026 12:59:52 +0200 Subject: [PATCH 42/72] [RenderWindow] Add high-quality progressive render progress tracking and display in the UI --- gladius/src/ui/MainWindow.cpp | 19 ++++++++++ gladius/src/ui/RenderWindow.cpp | 64 ++++++++++++++++++++++++++++++++- gladius/src/ui/RenderWindow.h | 13 +++++++ 3 files changed, 95 insertions(+), 1 deletion(-) diff --git a/gladius/src/ui/MainWindow.cpp b/gladius/src/ui/MainWindow.cpp index 80c1d34d..5ef9005c 100644 --- a/gladius/src/ui/MainWindow.cpp +++ b/gladius/src/ui/MainWindow.cpp @@ -2274,6 +2274,25 @@ namespace gladius::ui } } + // HQ progressive render progress bar - lets the user see the progressive + // rendering advancing and, when the view is invalidated (camera move, model + // update, resize), the bar resets, making aborts and restarts visible. + auto const hqProgress = m_renderWindow.hqProgressiveRenderProgress(); + if (hqProgress.active) + { + ImGui::SameLine(); + float const barHeight = ImGui::GetTextLineHeight(); + ImGui::PushStyleColor(ImGuiCol_PlotHistogram, ImVec4(0.3f, 0.7f, 0.9f, 1.0f)); + ImGui::ProgressBar(hqProgress.fraction, + ImVec2(120.0f, barHeight), + fmt::format("HQ {:.0f}%", hqProgress.fraction * 100.0f).c_str()); + ImGui::PopStyleColor(); + if (ImGui::IsItemHovered()) + { + ImGui::SetTooltip("High-quality progressive render progress"); + } + } + auto const statusText = fmt::format("{:.0f} FPS | {}{} | Render: {}", ImGui::GetIO().Framerate, renderModeStr, diff --git a/gladius/src/ui/RenderWindow.cpp b/gladius/src/ui/RenderWindow.cpp index 0c88497b..ec576a2f 100644 --- a/gladius/src/ui/RenderWindow.cpp +++ b/gladius/src/ui/RenderWindow.cpp @@ -1195,6 +1195,40 @@ namespace gladius::ui return m_renderWindowState.isRendering; } + RenderWindow::HqProgressiveRenderProgress RenderWindow::hqProgressiveRenderProgress() const + { + HqProgressiveRenderProgress progress; + + // HQ progressive rendering only advances while the camera is still. While the + // camera is moving we show low-res previews or realtime frames instead, so there + // is no meaningful progressive progress to report. + if (!m_renderWindowState.isRendering || m_renderWindowState.isMoving) + { + return progress; + } + + size_t totalHeight = 0; + if (m_core) + { + if (auto const image = m_core->getResultImage()) + { + totalHeight = static_cast(image->getHeight()); + } + } + + if (totalHeight == 0) + { + return progress; + } + + progress.active = true; + progress.fraction = std::clamp( + static_cast(m_renderWindowState.currentLine) / static_cast(totalHeight), + 0.0f, + 1.0f); + return progress; + } + void RenderWindow::invalidateView() { ProfileFunction; @@ -2953,6 +2987,26 @@ namespace gladius::ui job.type = async_rendering::RenderJobType::HighQuality; job.width = static_cast(width); job.height = static_cast(height); + + // A non-zero start line means we intend to CONTINUE filling a progressive buffer that was + // already seeded with a full-frame resample at line 0. If that buffer is gone or no longer + // matches the current epoch/size — which happens when a stale-size/stale-view discard or a + // window resize released it without rewinding state.currentLine — the worker would acquire + // a fresh, unseeded buffer and render only [startLine, height). The untouched top band + // [0, startLine) would then be promoted as a "completed" frame, producing the partial / + // half-updated HQ image seen after resizing. Restart from line 0 so the buffer is re-seeded + // and every horizontal line is rendered. + bool const hasSeededProgressiveContinuation = + m_asyncProgressiveBuffer != nullptr && + m_asyncProgressiveEpoch.load(std::memory_order_acquire) == job.epoch && + m_asyncProgressiveBuffer->image && + static_cast(m_asyncProgressiveBuffer->image->getWidth()) >= width && + static_cast(m_asyncProgressiveBuffer->image->getHeight()) >= height; + if (state.currentLine > 0 && !hasSeededProgressiveContinuation) + { + state.currentLine = 0; + } + job.startLine = std::min(state.currentLine, height); if (job.startLine == 0) { @@ -4506,7 +4560,15 @@ namespace gladius::ui // Update the last displayed frame ID for ordering m_asyncPreviewFrameId.store(meta.frameId, std::memory_order_release); - if (!m_streamingPreviewActive.load(std::memory_order_acquire) && + // Adaptive preview-resolution tuning is an interaction-time optimization and must + // only run while the camera is actively moving (mirrors the sync path's PID guard). + // Otherwise a stray preview result arriving after the camera has settled would keep + // resizing the low-res buffer and set m_lowResFeedbackPending, which renderAsync() + // turns into an epoch bump that needlessly restarts the in-progress HQ render. A + // fast-preview resolution change is NOT a view invalidation (unlike a window resize), + // so it must never abort progressive HQ rendering. + if (m_renderWindowState.isMoving && + !m_streamingPreviewActive.load(std::memory_order_acquire) && !m_streamingJobInFlight.load(std::memory_order_acquire) && meta.latencyNs > 0) { float const executionDurationMs = diff --git a/gladius/src/ui/RenderWindow.h b/gladius/src/ui/RenderWindow.h index c7df12ef..36c3b1ab 100644 --- a/gladius/src/ui/RenderWindow.h +++ b/gladius/src/ui/RenderWindow.h @@ -66,9 +66,22 @@ namespace gladius::ui /// an export is in progress to avoid GPU contention. void setExportState(ExportState const * exportState); + /// @brief Snapshot of high-quality progressive render progress for status display. + struct HqProgressiveRenderProgress + { + bool active = false; ///< true while an HQ progressive render is advancing + float fraction = 0.0f; ///< completion in [0, 1] + }; + void renderWindow(); void updateCamera(); bool isRenderingInProgress() const; + + /// @brief Returns a snapshot of the current HQ progressive render progress. + /// + /// Used by the status bar to visualize progressive rendering, including when a + /// render is aborted and restarted (the fraction jumps back toward zero). + [[nodiscard]] HqProgressiveRenderProgress hqProgressiveRenderProgress() const; void invalidateView(); void invalidateViewDuetoModelUpdate(); void invalidateViewDueToParameterChange(); From 21759ec1f5a682c06aeaad678faacb3a7f031e93 Mon Sep 17 00:00:00 2001 From: Jan Orend <56254096+3dJan@users.noreply.github.com> Date: Sat, 30 May 2026 14:34:38 +0200 Subject: [PATCH 43/72] [OrbitalCamera] Introduce snapToTarget method to immediately apply target camera values; update usages in rendering and tests --- gladius/src/compute/ComputeCore.cpp | 4 +- gladius/src/compute/Rendering.cpp | 3 +- gladius/src/mcp/tools/RenderingTool.cpp | 6 +- gladius/src/ui/MainWindow.cpp | 10 ++- gladius/src/ui/OrbitalCamera.cpp | 23 +++-- gladius/src/ui/OrbitalCamera.h | 3 + gladius/src/ui/RenderWindow.cpp | 83 +++++++++++-------- .../MeshSdfPerformance_tests.cpp | 4 +- .../integrationtests/RayMarchPerf_tests.cpp | 12 +-- .../unittests/ui/OrbitalCamera_tests.cpp | 52 ++++++++++++ 10 files changed, 135 insertions(+), 65 deletions(-) create mode 100644 gladius/tests/unittests/ui/OrbitalCamera_tests.cpp diff --git a/gladius/src/compute/ComputeCore.cpp b/gladius/src/compute/ComputeCore.cpp index ccaa309c..2f5ba189 100644 --- a/gladius/src/compute/ComputeCore.cpp +++ b/gladius/src/compute/ComputeCore.cpp @@ -2725,10 +2725,10 @@ namespace gladius thumbnailCamera.setAngle(0.6f, -2.0f); thumbnailCamera.centerView(bb); - thumbnailCamera.update(10000.f); + thumbnailCamera.snapToTarget(); thumbnailCamera.adjustDistanceToTarget(bb, thumbnailSize, thumbnailSize); - thumbnailCamera.update(10000.f); + thumbnailCamera.snapToTarget(); applyCamera(thumbnailCamera); diff --git a/gladius/src/compute/Rendering.cpp b/gladius/src/compute/Rendering.cpp index 62e11d12..7176c116 100644 --- a/gladius/src/compute/Rendering.cpp +++ b/gladius/src/compute/Rendering.cpp @@ -416,10 +416,11 @@ namespace gladius const auto thumbnailSize = 256.0f; thumbnailCamera.centerView(bb); thumbnailCamera.setAngle(0.6f, -2.0f); + thumbnailCamera.snapToTarget(); thumbnailCamera.adjustDistanceToTarget(bb, thumbnailSize, thumbnailSize); - thumbnailCamera.update(10000.f); + thumbnailCamera.snapToTarget(); applyCamera(thumbnailCamera); diff --git a/gladius/src/mcp/tools/RenderingTool.cpp b/gladius/src/mcp/tools/RenderingTool.cpp index 61844577..b21583ba 100644 --- a/gladius/src/mcp/tools/RenderingTool.cpp +++ b/gladius/src/mcp/tools/RenderingTool.cpp @@ -276,7 +276,7 @@ namespace gladius float yaw = std::atan2(direction.y(), direction.x()); customCamera.setAngle(pitch, yaw); - customCamera.update(10000.0f); + customCamera.snapToTarget(); // Apply the custom camera settings core->applyCamera(customCamera); @@ -416,14 +416,14 @@ namespace gladius optimalCamera.centerView(bbox); // Update camera position - optimalCamera.update(10000.0f); + optimalCamera.snapToTarget(); // Adjust distance to fit the model in view (using reasonable viewport size) const float viewportSize = 512.0f; optimalCamera.adjustDistanceToTarget(bbox, viewportSize, viewportSize); // Final update - optimalCamera.update(10000.0f); + optimalCamera.snapToTarget(); // Get the calculated camera parameters auto eyePosition = optimalCamera.getEyePosition(); diff --git a/gladius/src/ui/MainWindow.cpp b/gladius/src/ui/MainWindow.cpp index 5ef9005c..bc5e1bf4 100644 --- a/gladius/src/ui/MainWindow.cpp +++ b/gladius/src/ui/MainWindow.cpp @@ -1352,13 +1352,15 @@ namespace gladius::ui { return; // skip rendering UI when compute is disabled } - // Process render window shortcuts - if (m_renderWindow.isVisible() && m_renderWindow.isHovered() && m_renderWindow.isFocused()) + + m_renderWindow.renderWindow(); + + // Process render window shortcuts after rendering so current-frame hover/focus state is + // available. This lets mouse-wheel zoom work as soon as the cursor is over the preview. + if (m_renderWindow.isVisible() && m_renderWindow.isHovered()) { processShortcuts(ShortcutContext::RenderWindow); } - - m_renderWindow.renderWindow(); } void MainWindow::mainMenu() diff --git a/gladius/src/ui/OrbitalCamera.cpp b/gladius/src/ui/OrbitalCamera.cpp index 0f76f773..b82c668b 100644 --- a/gladius/src/ui/OrbitalCamera.cpp +++ b/gladius/src/ui/OrbitalCamera.cpp @@ -1,5 +1,6 @@ #include "OrbitalCamera.h" #include +#include #include #include #include @@ -106,19 +107,7 @@ namespace gladius::ui bool changed = false; auto constexpr tolerance = 0.0001f; auto const speedFactor = 15.E-3f; - // if (deltaTime_ms > 50.f) - { - changed = fabs(m_pitchTarget - m_pitch) > tolerance || - fabs(m_yawTarget - m_yaw) > tolerance || - fabs(m_eyeDistTarget - m_eyeDist) > tolerance || - (m_lookAtTarget - m_lookAt).norm() > tolerance; - m_pitch = m_pitchTarget; - m_yaw = m_yawTarget; - m_eyeDist = m_eyeDistTarget; - m_lookAt = m_lookAtTarget; - return changed; - } - // deltaTime_ms = std::min(deltaTime_ms, 50.f); + deltaTime_ms = std::min(deltaTime_ms, 50.f); auto const pitchDelta = (m_pitchTarget - m_pitch); if (fabs(pitchDelta) > tolerance) @@ -151,6 +140,14 @@ namespace gladius::ui return changed; } + void OrbitalCamera::snapToTarget() + { + m_pitch = m_pitchTarget; + m_yaw = m_yawTarget; + m_eyeDist = m_eyeDistTarget; + m_lookAt = m_lookAtTarget; + } + void OrbitalCamera::centerView(BoundingBox const & bbox) { m_lookAtTarget = {(bbox.max.x + bbox.min.x) * 0.5f, diff --git a/gladius/src/ui/OrbitalCamera.h b/gladius/src/ui/OrbitalCamera.h index c77d8dd6..8a42fc1e 100644 --- a/gladius/src/ui/OrbitalCamera.h +++ b/gladius/src/ui/OrbitalCamera.h @@ -24,6 +24,9 @@ namespace gladius::ui auto computeModelViewPerspectiveMatrix() const -> cl_float16; bool update(float deltaTime_ms); + /// @brief Immediately applies all target camera values without interpolation. + void snapToTarget(); + void centerView(BoundingBox const & bbox); void adjustDistanceToTarget(BoundingBox const & bbox, float viewportWidth, float viewportHeight); diff --git a/gladius/src/ui/RenderWindow.cpp b/gladius/src/ui/RenderWindow.cpp index ec576a2f..0201ed25 100644 --- a/gladius/src/ui/RenderWindow.cpp +++ b/gladius/src/ui/RenderWindow.cpp @@ -856,7 +856,11 @@ namespace gladius::ui // Cache window state for isHovered() and isFocused() methods m_isWindowHovered = ImGui::IsWindowHovered(); - m_isWindowFocused = ImGui::IsWindowFocused(); + if (m_isWindowHovered && !ImGui::IsWindowFocused(ImGuiFocusedFlags_RootAndChildWindows)) + { + ImGui::SetWindowFocus(); + } + m_isWindowFocused = ImGui::IsWindowFocused(ImGuiFocusedFlags_RootAndChildWindows); // if has focus, but not any item has focus, handle key input and content area is hovered if (ImGui::IsWindowFocused() && !ImGui::IsAnyItemFocused() && @@ -2229,6 +2233,40 @@ namespace gladius::ui queueRenderDecision(m_renderUpdateCoordinator.notifyCameraInteractionEnded()); } + // Parameter-drag settle: once no parameter change has arrived for the debounce + // window, end the parameter interaction so the coordinator's static catch-up + // (bbox -> SDF -> high-quality) resumes and the static view shows full detail. + // + // This MUST run here, before the early-returning executeQueuedRenderCommands() + // below. In Auto mode that is not yet proven fast (and in any streaming-preview + // case) an interactive low-res preview frame is scheduled on every tick, so + // executeQueuedRenderCommands() returns true and renderAsync() returns early. + // The legacy settle path further down would then never be reached, leaving the + // view stuck on the low-res preview until the next camera move forces a + // notifyCameraInteractionEnded() transition. Driving the settle from here makes + // the transition independent of which interactive feedback path is active. + if (m_renderUpdateCoordinator.interactionState() == + async_rendering::RenderInteractionState::ParameterInteracting && + m_core->isBoundingBoxStale() && + !m_asyncBboxJobInFlight.load(std::memory_order_acquire) && + !m_asyncSdfJobInFlight.load(std::memory_order_acquire) && + (std::chrono::steady_clock::now() - m_lastParameterChangeTime >= kBboxDebounceDelay)) + { + // Parameter drag has ended. This is a semantic interaction transition, + // independent of whether the active feedback path used streaming preview + // (Force mode does not start streaming preview at all). + finishParameterInteraction(); + + m_core->recomputeStaleBoundingBox(); + m_core->setSdfValid(false); + m_preComputedSdfDirty.store(true, std::memory_order_release); + + // Bump epoch to invalidate the stale HQ/preview front buffer from before the + // drag so the fresh bbox -> SDF -> HQ pipeline starts clean. + notifyAsyncEpochIncrement(); + m_suppressHQDisplay.store(false, std::memory_order_release); + } + queueRenderDecision(m_renderUpdateCoordinator.tick()); if (executeQueuedRenderCommands(state)) { @@ -2296,29 +2334,10 @@ namespace gladius::ui scheduleAsyncBboxUpdate(); } - // Debounce: recompute stale bounding box after slider drag stops. - // recomputeStaleBoundingBox() clears the cached bbox so the next SDF job - // does a full bbox recompute with the current parameters. - if (m_core->isBoundingBoxStale() && !bboxJobActive && !sdfJobActive) - { - auto const now = std::chrono::steady_clock::now(); - if (now - m_lastParameterChangeTime >= kBboxDebounceDelay) - { - // Parameter drag has ended. This is a semantic interaction transition, - // independent of whether the active feedback path used streaming preview - // (Force mode does not start streaming preview at all). - finishParameterInteraction(); - - m_core->recomputeStaleBoundingBox(); - m_core->setSdfValid(false); - m_preComputedSdfDirty = true; - - // Parameter drag has stopped — bump epoch to invalidate the stale - // HQ front buffer from before the drag and start fresh. - notifyAsyncEpochIncrement(); - m_suppressHQDisplay.store(false, std::memory_order_release); - } - } + // Note: the parameter-drag settle transition (finishParameterInteraction + + // stale-bbox recompute + epoch bump) now runs earlier, in the coordinator block + // above, so it cannot be skipped by the early return when an interactive preview + // frame is scheduled. See the ParameterInteracting settle block in this function. if (lowResPending) { @@ -3867,7 +3886,11 @@ namespace gladius::ui // Cache window state m_isWindowHovered = ImGui::IsWindowHovered(); - m_isWindowFocused = ImGui::IsWindowFocused(); + if (m_isWindowHovered && !ImGui::IsWindowFocused(ImGuiFocusedFlags_RootAndChildWindows)) + { + ImGui::SetWindowFocus(); + } + m_isWindowFocused = ImGui::IsWindowFocused(ImGuiFocusedFlags_RootAndChildWindows); // Handle key input if focused if (ImGui::IsWindowFocused() && !ImGui::IsAnyItemFocused() && @@ -4560,15 +4583,7 @@ namespace gladius::ui // Update the last displayed frame ID for ordering m_asyncPreviewFrameId.store(meta.frameId, std::memory_order_release); - // Adaptive preview-resolution tuning is an interaction-time optimization and must - // only run while the camera is actively moving (mirrors the sync path's PID guard). - // Otherwise a stray preview result arriving after the camera has settled would keep - // resizing the low-res buffer and set m_lowResFeedbackPending, which renderAsync() - // turns into an epoch bump that needlessly restarts the in-progress HQ render. A - // fast-preview resolution change is NOT a view invalidation (unlike a window resize), - // so it must never abort progressive HQ rendering. - if (m_renderWindowState.isMoving && - !m_streamingPreviewActive.load(std::memory_order_acquire) && + if (!m_streamingPreviewActive.load(std::memory_order_acquire) && !m_streamingJobInFlight.load(std::memory_order_acquire) && meta.latencyNs > 0) { float const executionDurationMs = diff --git a/gladius/tests/integrationtests/MeshSdfPerformance_tests.cpp b/gladius/tests/integrationtests/MeshSdfPerformance_tests.cpp index 14b78e05..efc10900 100644 --- a/gladius/tests/integrationtests/MeshSdfPerformance_tests.cpp +++ b/gladius/tests/integrationtests/MeshSdfPerformance_tests.cpp @@ -861,9 +861,9 @@ namespace gladius::tests ui::OrbitalCamera camera; camera.setAngle(0.6f, -2.0f); camera.centerView(*bbox); - camera.update(10000.f); + camera.snapToTarget(); camera.adjustDistanceToTarget(*bbox, static_cast(kWidth), static_cast(kHeight)); - camera.update(10000.f); + camera.snapToTarget(); auto resources = bundle.core->getResourceContext(); resources->setEyePosition(camera.getEyePosition()); diff --git a/gladius/tests/integrationtests/RayMarchPerf_tests.cpp b/gladius/tests/integrationtests/RayMarchPerf_tests.cpp index cec3de07..dc6e9e89 100644 --- a/gladius/tests/integrationtests/RayMarchPerf_tests.cpp +++ b/gladius/tests/integrationtests/RayMarchPerf_tests.cpp @@ -273,14 +273,14 @@ namespace gladius::tests ui::OrbitalCamera camera; camera.setAngle(0.6f, -2.0f); // Same as thumbnail camera.centerView(*bbox); - camera.update(10000.f); + camera.snapToTarget(); // Use a fixed resolution for metrics test constexpr size_t width = 512; constexpr size_t height = 512; camera.adjustDistanceToTarget(*bbox, static_cast(width), static_cast(height)); - camera.update(10000.f); + camera.snapToTarget(); // Apply camera to resources auto resources = bundle.core->getResourceContext(); @@ -363,13 +363,13 @@ namespace gladius::tests ui::OrbitalCamera camera; camera.setAngle(0.6f, -2.0f); camera.centerView(*bbox); - camera.update(10000.f); + camera.snapToTarget(); constexpr size_t width = 512; constexpr size_t height = 512; camera.adjustDistanceToTarget(*bbox, static_cast(width), static_cast(height)); - camera.update(10000.f); + camera.snapToTarget(); auto resources = bundle.core->getResourceContext(); resources->setEyePosition(camera.getEyePosition()); @@ -477,13 +477,13 @@ namespace gladius::tests ui::OrbitalCamera camera; camera.setAngle(0.6f, -2.0f); camera.centerView(*bbox); - camera.update(10000.f); + camera.snapToTarget(); constexpr size_t width = 512; constexpr size_t height = 512; camera.adjustDistanceToTarget(*bbox, static_cast(width), static_cast(height)); - camera.update(10000.f); + camera.snapToTarget(); auto resources = bundle.core->getResourceContext(); resources->setEyePosition(camera.getEyePosition()); diff --git a/gladius/tests/unittests/ui/OrbitalCamera_tests.cpp b/gladius/tests/unittests/ui/OrbitalCamera_tests.cpp new file mode 100644 index 00000000..783d33d9 --- /dev/null +++ b/gladius/tests/unittests/ui/OrbitalCamera_tests.cpp @@ -0,0 +1,52 @@ +#include "ui/OrbitalCamera.h" + +#include + +#include + +namespace gladius::ui::tests +{ + namespace + { + [[nodiscard]] float distance(cl_float3 const & lhs, cl_float3 const & rhs) + { + float const dx = lhs.x - rhs.x; + float const dy = lhs.y - rhs.y; + float const dz = lhs.z - rhs.z; + return std::sqrt(dx * dx + dy * dy + dz * dz); + } + } + + TEST(OrbitalCamera_Update, WithPendingZoom_InterpolatesTowardTargetDistance) + { + OrbitalCamera camera; + camera.snapToTarget(); + + float const initialDistance = distance(camera.getEyePosition(), camera.getLookAt()); + + camera.zoom(-0.5f); + + float const distanceBeforeUpdate = distance(camera.getEyePosition(), camera.getLookAt()); + EXPECT_NEAR(distanceBeforeUpdate, initialDistance, 1e-3f); + + EXPECT_TRUE(camera.update(16.f)); + + float const distanceAfterUpdate = distance(camera.getEyePosition(), camera.getLookAt()); + EXPECT_LT(distanceAfterUpdate, initialDistance); + EXPECT_GT(distanceAfterUpdate, initialDistance * 0.5f); + } + + TEST(OrbitalCamera_SnapToTarget, WithPendingZoom_AppliesTargetDistance) + { + OrbitalCamera camera; + camera.snapToTarget(); + + float const initialDistance = distance(camera.getEyePosition(), camera.getLookAt()); + + camera.zoom(-0.5f); + camera.snapToTarget(); + + float const snappedDistance = distance(camera.getEyePosition(), camera.getLookAt()); + EXPECT_NEAR(snappedDistance, initialDistance * 0.5f, 1e-3f); + } +} \ No newline at end of file From b593222ab4c1f36a9ba658e72640c9f5e874f44d Mon Sep 17 00:00:00 2001 From: Jan Orend <56254096+3dJan@users.noreply.github.com> Date: Sat, 30 May 2026 18:21:28 +0200 Subject: [PATCH 44/72] [RenderWindow] Refactor render state checks and add view epoch tracking to prevent torn frames during rapid resizing --- gladius/src/ui/RenderWindow.cpp | 57 +++++++++++++++++++++++++++++---- gladius/src/ui/RenderWindow.h | 5 +++ 2 files changed, 55 insertions(+), 7 deletions(-) diff --git a/gladius/src/ui/RenderWindow.cpp b/gladius/src/ui/RenderWindow.cpp index 0201ed25..7dd8e861 100644 --- a/gladius/src/ui/RenderWindow.cpp +++ b/gladius/src/ui/RenderWindow.cpp @@ -2245,12 +2245,18 @@ namespace gladius::ui // view stuck on the low-res preview until the next camera move forces a // notifyCameraInteractionEnded() transition. Driving the settle from here makes // the transition independent of which interactive feedback path is active. - if (m_renderUpdateCoordinator.interactionState() == - async_rendering::RenderInteractionState::ParameterInteracting && - m_core->isBoundingBoxStale() && - !m_asyncBboxJobInFlight.load(std::memory_order_acquire) && - !m_asyncSdfJobInFlight.load(std::memory_order_acquire) && - (std::chrono::steady_clock::now() - m_lastParameterChangeTime >= kBboxDebounceDelay)) + bool const paramInteracting = + m_renderUpdateCoordinator.interactionState() == + async_rendering::RenderInteractionState::ParameterInteracting; + bool const bboxStaleForSettle = m_core->isBoundingBoxStale(); + bool const bboxJobForSettle = m_asyncBboxJobInFlight.load(std::memory_order_acquire); + bool const sdfJobForSettle = m_asyncSdfJobInFlight.load(std::memory_order_acquire); + auto const sinceParamChange = + std::chrono::steady_clock::now() - m_lastParameterChangeTime; + bool const debounceElapsedForSettle = sinceParamChange >= kBboxDebounceDelay; + + if (paramInteracting && bboxStaleForSettle && !bboxJobForSettle && !sdfJobForSettle && + debounceElapsedForSettle) { // Parameter drag has ended. This is a semantic interaction transition, // independent of whether the active feedback path used streaming preview @@ -2268,7 +2274,9 @@ namespace gladius::ui } queueRenderDecision(m_renderUpdateCoordinator.tick()); - if (executeQueuedRenderCommands(state)) + bool const queuedWorkStarted = executeQueuedRenderCommands(state); + + if (queuedWorkStarted) { return; } @@ -2869,6 +2877,12 @@ namespace gladius::ui // DON'T force state.isMoving = false - let camera update control this // If camera is still animating, it will set isMoving=true on next frame state.currentLine = 0; // Reset for next frame + // A complete HQ frame for the current view+size is now ready. If partial HQ + // display was suppressed because this frame was seeded from a stale-view + // preview (see scheduleAsyncRenderJob), re-enable HQ display so the finished, + // view-consistent frame swaps in cleanly instead of staying on the low-res + // preview. + m_suppressHQDisplay.store(false, std::memory_order_release); if (isExactRealtimeJob) { m_lowResFeedbackPending.store(false, std::memory_order_release); @@ -2945,6 +2959,19 @@ namespace gladius::ui if (isOutdated || isViewOutdated || isTargetSizeOutdated) { state.isRendering = false; + + // An HQ progressive job that was scheduled for an older epoch (typical + // during a window resize, where the epoch bumps several times as low-res + // previews are re-issued) is discarded here without advancing currentLine + // or producing a completed frame. Nothing else re-triggers the HQ render + // for the now-current epoch: the coordinator's static catch-up only fires + // on viewport change, and after the resize settles m_dirty is false so the + // render loop early-returns before reaching the HQ-gate. Keep the loop + // alive so the HQ-gate reschedules HQ once the low-res preview is current. + if (isHqRenderJob) + { + m_dirty = true; + } } } @@ -3047,6 +3074,21 @@ namespace gladius::ui (!m_asyncProgressiveBuffer || m_asyncProgressiveEpoch.load(std::memory_order_acquire) != job.epoch)) { + // The progressive HQ buffer is seeded by resampling the result image (the low-res + // preview) so lines not yet rendered at high quality still show content. If the + // result image still holds a *previous* view — which happens during rapid window + // resizing, where the view epoch advances faster than the low-res preview can + // refresh — the stale-view seed (unrendered region) would mix with the freshly + // rendered current-view top band, producing torn frames that show different states + // of the view. Detect that here and suppress partial HQ display for this job so the + // clean low-res result image is shown until the full HQ frame completes and swaps in. + bool const seedViewIsStale = + m_lastLowResPreviewViewEpoch.load(std::memory_order_acquire) != job.viewEpoch; + if (seedViewIsStale) + { + m_suppressHQDisplay.store(true, std::memory_order_release); + } + auto * seedBuffer = m_asyncController->acquireWriteBuffer(job.epoch); auto renderProgram = m_core->tryGetBestRenderProgram().value_or(SharedRenderProgram{}); bool const seedSourceFits = image->getWidth() >= width && image->getHeight() >= height; @@ -4650,6 +4692,7 @@ namespace gladius::ui m_lowResFeedbackPending.store(false, std::memory_order_release); m_lastLowResRenderTime = std::chrono::system_clock::now(); m_lastLowResPreviewEpoch.store(meta.epoch, std::memory_order_release); + m_lastLowResPreviewViewEpoch.store(meta.viewEpoch, std::memory_order_release); auto presentedMeta = meta; if (acceptance.presentAsRequiredStamp) { diff --git a/gladius/src/ui/RenderWindow.h b/gladius/src/ui/RenderWindow.h index 36c3b1ab..acb25b6b 100644 --- a/gladius/src/ui/RenderWindow.h +++ b/gladius/src/ui/RenderWindow.h @@ -409,6 +409,11 @@ namespace gladius::ui std::atomic m_asyncSdfInFlightEpoch{0}; std::atomic m_lowResFeedbackPending{false}; std::atomic m_lastLowResPreviewEpoch{0}; + /// View epoch of the content currently held by the result image (the low-res preview + /// that seeds the progressive HQ buffer). Used to avoid seeding an HQ frame from a + /// stale-view preview, which would produce torn (mixed-view) progressive frames during + /// rapid window resizing. + std::atomic m_lastLowResPreviewViewEpoch{0}; bool m_asyncInitialized{false}; bool m_compilationInvalidated{false}; From b13bc056aef0d477643f0de50a53f1e92ecf07c7 Mon Sep 17 00:00:00 2001 From: Jan Orend <56254096+3dJan@users.noreply.github.com> Date: Mon, 8 Jun 2026 22:39:14 +0200 Subject: [PATCH 45/72] [ProgressiveTearReproduction_tests.cpp] Add unit tests for progressive tear reproduction scenarios and resize handling in RenderWindow simulation --- gladius/src/compute/ComputeCore.cpp | 7 + gladius/src/compute/ComputeCore.h | 14 + gladius/src/ui/RenderWindow.cpp | 77 +- gladius/src/ui/RenderWindow.h | 4 + gladius/src/ui/render/AsyncRenderTypes.h | 1 + .../src/ui/render/ProgressiveBufferPolicy.h | 61 + .../render/ProgressiveBufferPolicy_tests.cpp | 131 ++ .../ProgressiveTearReproduction_tests.cpp | 1241 +++++++++++++++++ 8 files changed, 1520 insertions(+), 16 deletions(-) create mode 100644 gladius/src/ui/render/ProgressiveBufferPolicy.h create mode 100644 gladius/tests/unittests/ui/render/ProgressiveBufferPolicy_tests.cpp create mode 100644 gladius/tests/unittests/ui/render/ProgressiveTearReproduction_tests.cpp diff --git a/gladius/src/compute/ComputeCore.cpp b/gladius/src/compute/ComputeCore.cpp index 2f5ba189..44ff9488 100644 --- a/gladius/src/compute/ComputeCore.cpp +++ b/gladius/src/compute/ComputeCore.cpp @@ -461,6 +461,13 @@ namespace gladius } parameter = std::move(updatedParameter); paramBuf.write(); + if (parameterChanged) + { + // The GPU parameter buffer now holds new values. Advancing the generation lets + // progressive HQ rendering detect a parameter upload that landed mid-fill and restart, + // instead of presenting a frame whose upper band used the previous parameters. + m_parameterGeneration.fetch_add(1u, std::memory_order_acq_rel); + } // NOTE: Do NOT call invalidatePreCompSdf() here! // We want to keep the old SDF valid for preview rendering during interactive // parameter editing. The UI-side m_preComputedSdfDirty flag will trigger new diff --git a/gladius/src/compute/ComputeCore.h b/gladius/src/compute/ComputeCore.h index 6d354c5f..e8f28301 100644 --- a/gladius/src/compute/ComputeCore.h +++ b/gladius/src/compute/ComputeCore.h @@ -701,6 +701,17 @@ namespace gladius [[nodiscard]] bool tryToupdateParameter(nodes::Assembly & assembly); [[nodiscard]] bool updateParameterBlocking(nodes::Assembly & assembly); + /// @brief Monotonic counter of GPU parameter-buffer uploads that actually changed the + /// model parameters. Incremented inside updateParameterBlocking() right after the new + /// values are written to the parameter buffer. Progressive HQ rendering uses this to + /// detect a parameter upload that lands mid-fill (which would otherwise leave the upper + /// band rendered with the old parameters and the lower band with the new ones). + /// @return The current parameter generation. + [[nodiscard]] std::uint64_t getParameterGeneration() const + { + return m_parameterGeneration.load(std::memory_order_acquire); + } + /// Check if parameter structure matches compiled signature (fast path possible) [[nodiscard]] bool isParameterSignatureCompatible(nodes::Assembly const & assembly) const; @@ -848,6 +859,9 @@ namespace gladius std::mutex m_optimizedSourceGenerationMutex; std::vector> m_optimizedSourceGenerationJobs; + /// Monotonic generation of GPU parameter-buffer uploads that changed the parameters. + std::atomic m_parameterGeneration{1u}; + ProgramManager m_programs; }; } diff --git a/gladius/src/ui/RenderWindow.cpp b/gladius/src/ui/RenderWindow.cpp index 7dd8e861..185ffd9a 100644 --- a/gladius/src/ui/RenderWindow.cpp +++ b/gladius/src/ui/RenderWindow.cpp @@ -26,6 +26,7 @@ #include "compute/ComputeCore.h" #include "imgui.h" #include "render/DisplayFrameSelector.h" +#include "render/ProgressiveBufferPolicy.h" #include "render/RenderModeUpdatePolicy.h" #include "render/PreviewBackendPolicy.h" #include "render/PreviewResultAcceptancePolicy.h" @@ -3029,6 +3030,7 @@ namespace gladius::ui } m_asyncController->setLatestEpoch(job.epoch); job.viewEpoch = m_asyncViewEpoch.load(std::memory_order_acquire); + job.paramGeneration = m_core->getParameterGeneration(); job.frameHint = m_asyncFrameCounter.fetch_add(1, std::memory_order_acq_rel) + 1; job.type = async_rendering::RenderJobType::HighQuality; job.width = static_cast(width); @@ -3036,18 +3038,37 @@ namespace gladius::ui // A non-zero start line means we intend to CONTINUE filling a progressive buffer that was // already seeded with a full-frame resample at line 0. If that buffer is gone or no longer - // matches the current epoch/size — which happens when a stale-size/stale-view discard or a - // window resize released it without rewinding state.currentLine — the worker would acquire - // a fresh, unseeded buffer and render only [startLine, height). The untouched top band - // [0, startLine) would then be promoted as a "completed" frame, producing the partial / - // half-updated HQ image seen after resizing. Restart from line 0 so the buffer is re-seeded - // and every horizontal line is rendered. + // matches the current epoch/view/size — which happens when a stale-size/stale-view discard, + // a window resize released it without rewinding state.currentLine, or a parameter change + // advanced only the view epoch — the worker would acquire a fresh, unseeded buffer and + // render only [startLine, height). The untouched top band [0, startLine) would then be + // promoted as a "completed" frame, producing the partial / half-updated HQ image seen + // after resizing. The view epoch is essential here: a parameter change bumps ONLY the view + // epoch (not the scene epoch), yet it changes the model content of every pixel — continuing + // a buffer whose un-overwritten region still holds the previous parameters yields the torn + // fresh-top / stale-bottom frame. Restart from line 0 in any of these cases so the buffer + // is re-seeded and every horizontal line is rendered for the current state. + async_rendering::ProgressiveBufferState const progressiveBufferState{ + .hasImage = + m_asyncProgressiveBuffer != nullptr && m_asyncProgressiveBuffer->image != nullptr, + .epoch = m_asyncProgressiveEpoch.load(std::memory_order_acquire), + .viewEpoch = m_asyncProgressiveViewEpoch.load(std::memory_order_acquire), + .paramGeneration = m_asyncProgressiveParamGeneration.load(std::memory_order_acquire), + .width = (m_asyncProgressiveBuffer && m_asyncProgressiveBuffer->image) + ? static_cast(m_asyncProgressiveBuffer->image->getWidth()) + : 0u, + .height = (m_asyncProgressiveBuffer && m_asyncProgressiveBuffer->image) + ? static_cast(m_asyncProgressiveBuffer->image->getHeight()) + : 0u}; + async_rendering::ProgressiveJobTarget const progressiveJobTarget{ + .epoch = job.epoch, + .viewEpoch = job.viewEpoch, + .paramGeneration = job.paramGeneration, + .width = job.width, + .height = job.height}; bool const hasSeededProgressiveContinuation = - m_asyncProgressiveBuffer != nullptr && - m_asyncProgressiveEpoch.load(std::memory_order_acquire) == job.epoch && - m_asyncProgressiveBuffer->image && - static_cast(m_asyncProgressiveBuffer->image->getWidth()) >= width && - static_cast(m_asyncProgressiveBuffer->image->getHeight()) >= height; + async_rendering::isProgressiveBufferContinuable(progressiveBufferState, + progressiveJobTarget); if (state.currentLine > 0 && !hasSeededProgressiveContinuation) { state.currentLine = 0; @@ -3070,9 +3091,7 @@ namespace gladius::ui job.coordinatorStamp = coordinatorTask->stamp; } - if (job.startLine == 0 && - (!m_asyncProgressiveBuffer || - m_asyncProgressiveEpoch.load(std::memory_order_acquire) != job.epoch)) + if (job.startLine == 0 && !hasSeededProgressiveContinuation) { // The progressive HQ buffer is seeded by resampling the result image (the low-res // preview) so lines not yet rendered at high quality still show content. If the @@ -3102,6 +3121,9 @@ namespace gladius::ui seedBuffer->image->invalidateContent(); m_asyncProgressiveBuffer = seedBuffer; m_asyncProgressiveEpoch.store(job.epoch, std::memory_order_release); + m_asyncProgressiveViewEpoch.store(job.viewEpoch, std::memory_order_release); + m_asyncProgressiveParamGeneration.store(job.paramGeneration, + std::memory_order_release); } else if (seedBuffer) { @@ -3317,6 +3339,7 @@ namespace gladius::ui m_asyncProgressiveBuffer = nullptr; m_asyncProgressiveEpoch.store(0, std::memory_order_release); m_asyncProgressiveViewEpoch.store(0, std::memory_order_release); + m_asyncProgressiveParamGeneration.store(0, std::memory_order_release); } async_rendering::RenderJob job{}; @@ -3329,6 +3352,7 @@ namespace gladius::ui m_asyncController->setLatestEpoch(job.epoch); job.viewEpoch = m_asyncViewEpoch.load(std::memory_order_acquire); + job.paramGeneration = m_core->getParameterGeneration(); job.frameHint = m_asyncFrameCounter.fetch_add(1, std::memory_order_acq_rel) + 1; job.type = jobType; job.width = static_cast(width); @@ -3428,6 +3452,7 @@ namespace gladius::ui m_asyncProgressiveBuffer = nullptr; m_asyncProgressiveEpoch.store(0, std::memory_order_release); m_asyncProgressiveViewEpoch.store(0, std::memory_order_release); + m_asyncProgressiveParamGeneration.store(0, std::memory_order_release); } writeBuffer = nullptr; @@ -3458,8 +3483,26 @@ namespace gladius::ui } } } - else if (!m_asyncProgressiveBuffer || - m_asyncProgressiveEpoch.load(std::memory_order_acquire) != job.epoch) + else if (!async_rendering::isProgressiveBufferContinuable( + async_rendering::ProgressiveBufferState{ + .hasImage = m_asyncProgressiveBuffer != nullptr && + m_asyncProgressiveBuffer->image != nullptr, + .epoch = m_asyncProgressiveEpoch.load(std::memory_order_acquire), + .viewEpoch = m_asyncProgressiveViewEpoch.load(std::memory_order_acquire), + .paramGeneration = + m_asyncProgressiveParamGeneration.load(std::memory_order_acquire), + .width = (m_asyncProgressiveBuffer && m_asyncProgressiveBuffer->image) + ? static_cast(m_asyncProgressiveBuffer->image->getWidth()) + : 0u, + .height = + (m_asyncProgressiveBuffer && m_asyncProgressiveBuffer->image) + ? static_cast(m_asyncProgressiveBuffer->image->getHeight()) + : 0u}, + async_rendering::ProgressiveJobTarget{.epoch = job.epoch, + .viewEpoch = job.viewEpoch, + .paramGeneration = job.paramGeneration, + .width = job.width, + .height = job.height})) { writeBuffer = m_asyncController->acquireWriteBuffer(job.epoch); if (!writeBuffer) @@ -3482,6 +3525,8 @@ namespace gladius::ui m_asyncProgressiveBuffer = writeBuffer; m_asyncProgressiveEpoch.store(job.epoch, std::memory_order_release); m_asyncProgressiveViewEpoch.store(job.viewEpoch, std::memory_order_release); + m_asyncProgressiveParamGeneration.store(job.paramGeneration, + std::memory_order_release); } else { diff --git a/gladius/src/ui/RenderWindow.h b/gladius/src/ui/RenderWindow.h index acb25b6b..1a6b5878 100644 --- a/gladius/src/ui/RenderWindow.h +++ b/gladius/src/ui/RenderWindow.h @@ -421,6 +421,10 @@ namespace gladius::ui async_rendering::FrameBuffer * m_asyncProgressiveBuffer{nullptr}; std::atomic m_asyncProgressiveEpoch{0}; std::atomic m_asyncProgressiveViewEpoch{0}; + /// GPU parameter-buffer generation the progressive buffer's HQ lines were rendered with. + /// A mismatch against the live generation means a parameter upload landed mid-fill, so the + /// buffer must be re-seeded to avoid a torn (old-params top / new-params bottom) frame. + std::atomic m_asyncProgressiveParamGeneration{0}; // Async preview rendering state (separate from HQ progressive rendering) std::atomic m_asyncPreviewEpoch{0}; ///< Current preview epoch for cancellation diff --git a/gladius/src/ui/render/AsyncRenderTypes.h b/gladius/src/ui/render/AsyncRenderTypes.h index 0eda95bc..e04566dc 100644 --- a/gladius/src/ui/render/AsyncRenderTypes.h +++ b/gladius/src/ui/render/AsyncRenderTypes.h @@ -64,6 +64,7 @@ namespace gladius::ui::async_rendering { uint64_t epoch{0}; uint64_t viewEpoch{0}; + uint64_t paramGeneration{0}; ///< GPU parameter-buffer generation captured when scheduled. uint64_t frameHint{0}; RenderJobType type{RenderJobType::HighQuality}; uint32_t width{0}; diff --git a/gladius/src/ui/render/ProgressiveBufferPolicy.h b/gladius/src/ui/render/ProgressiveBufferPolicy.h new file mode 100644 index 00000000..13652fa2 --- /dev/null +++ b/gladius/src/ui/render/ProgressiveBufferPolicy.h @@ -0,0 +1,61 @@ +#pragma once + +#include + +namespace gladius::ui::async_rendering +{ + /// Snapshot of the currently held progressive high-quality accumulation buffer. + struct ProgressiveBufferState + { + bool hasImage{false}; ///< A progressive buffer with a backing image exists. + uint64_t epoch{0}; ///< Scene/model-topology generation the buffer was seeded for. + uint64_t viewEpoch{0}; ///< Camera/parameter generation the buffer content reflects. + uint64_t paramGeneration{0}; ///< GPU parameter-buffer generation the buffer's HQ lines used. + uint32_t width{0}; ///< Allocated buffer width in pixels. + uint32_t height{0}; ///< Allocated buffer height in pixels. + }; + + /// Target of the high-quality render job about to be scheduled. + struct ProgressiveJobTarget + { + uint64_t epoch{0}; ///< Scene/model-topology generation of the new job. + uint64_t viewEpoch{0}; ///< Camera/parameter generation of the new job. + uint64_t paramGeneration{0}; ///< GPU parameter-buffer generation the new job will render with. + uint32_t width{0}; ///< Requested render width in pixels. + uint32_t height{0}; ///< Requested render height in pixels. + }; + + /** + * @brief Decides whether an existing progressive HQ buffer may be continued or reused + * without re-seeding for a new render job. + * + * A progressive buffer is filled top-to-bottom over several frames; the not-yet-rendered + * region keeps whatever content it was last seeded with. Continuing (rendering from a + * non-zero start line) or reusing it without re-seeding is only safe when its content + * matches the new job in every dimension that affects the displayed pixels: + * + * - @b scene/model-topology epoch: structural model changes invalidate all pixels. + * - @b view epoch: a camera move OR a parameter change bumps the view epoch. Critically, + * a parameter change bumps @e only the view epoch (not the scene epoch) yet changes the + * model content of every pixel. If the view epoch is ignored, a parameter-changed job + * reuses a buffer whose un-overwritten region still shows the previous parameters, + * producing a torn frame: fresh-model top band, stale-model bottom band. + * - @b parameter generation: the GPU parameter buffer is uploaded asynchronously and + * independently of the view epoch. A single fill carries one view epoch, but if a + * parameter upload lands between two chunks the upper band is rendered with the old + * parameters and the lower band with the new ones -- a torn frame the view epoch alone + * cannot detect. Requiring an equal parameter generation forces a re-seed in that case. + * - @b size: the buffer must be at least as large as the requested render target. + * + * @return true if the buffer is safe to continue/reuse as-is; false if it must be + * re-seeded (or a fresh buffer acquired) so every line is rendered for the new state. + */ + [[nodiscard]] constexpr bool isProgressiveBufferContinuable( + ProgressiveBufferState const & buffer, ProgressiveJobTarget const & job) noexcept + { + return buffer.hasImage && buffer.epoch == job.epoch && + buffer.viewEpoch == job.viewEpoch && + buffer.paramGeneration == job.paramGeneration && buffer.width >= job.width && + buffer.height >= job.height; + } +} diff --git a/gladius/tests/unittests/ui/render/ProgressiveBufferPolicy_tests.cpp b/gladius/tests/unittests/ui/render/ProgressiveBufferPolicy_tests.cpp new file mode 100644 index 00000000..a8616289 --- /dev/null +++ b/gladius/tests/unittests/ui/render/ProgressiveBufferPolicy_tests.cpp @@ -0,0 +1,131 @@ +#include "ui/render/ProgressiveBufferPolicy.h" + +#include + +namespace gladius::ui::async_rendering::tests +{ + using async_rendering::isProgressiveBufferContinuable; + using async_rendering::ProgressiveBufferState; + using async_rendering::ProgressiveJobTarget; + + /// A buffer matching the job in scene epoch, view epoch and size may be continued. + TEST(ProgressiveBufferPolicy_IsContinuable, MatchingEpochViewAndSize_ReturnsTrue) + { + ProgressiveBufferState const buffer{ + .hasImage = true, .epoch = 5, .viewEpoch = 9, .width = 800, .height = 600}; + ProgressiveJobTarget const job{.epoch = 5, .viewEpoch = 9, .width = 800, .height = 600}; + + EXPECT_TRUE(isProgressiveBufferContinuable(buffer, job)); + } + + /// A larger buffer still fits a smaller job target. + TEST(ProgressiveBufferPolicy_IsContinuable, LargerBuffer_FitsSmallerTarget_ReturnsTrue) + { + ProgressiveBufferState const buffer{ + .hasImage = true, .epoch = 5, .viewEpoch = 9, .width = 1024, .height = 768}; + ProgressiveJobTarget const job{.epoch = 5, .viewEpoch = 9, .width = 800, .height = 600}; + + EXPECT_TRUE(isProgressiveBufferContinuable(buffer, job)); + } + + /// No backing image — nothing to continue. + TEST(ProgressiveBufferPolicy_IsContinuable, NoImage_ReturnsFalse) + { + ProgressiveBufferState const buffer{ + .hasImage = false, .epoch = 5, .viewEpoch = 9, .width = 800, .height = 600}; + ProgressiveJobTarget const job{.epoch = 5, .viewEpoch = 9, .width = 800, .height = 600}; + + EXPECT_FALSE(isProgressiveBufferContinuable(buffer, job)); + } + + /// A structural/scene-epoch change invalidates the buffer. + TEST(ProgressiveBufferPolicy_IsContinuable, SceneEpochChanged_ReturnsFalse) + { + ProgressiveBufferState const buffer{ + .hasImage = true, .epoch = 5, .viewEpoch = 9, .width = 800, .height = 600}; + ProgressiveJobTarget const job{.epoch = 6, .viewEpoch = 9, .width = 800, .height = 600}; + + EXPECT_FALSE(isProgressiveBufferContinuable(buffer, job)); + } + + /// A smaller buffer cannot satisfy a larger render target (e.g. after a window enlarge). + TEST(ProgressiveBufferPolicy_IsContinuable, SmallerBuffer_ThanTarget_ReturnsFalse) + { + ProgressiveBufferState const buffer{ + .hasImage = true, .epoch = 5, .viewEpoch = 9, .width = 640, .height = 480}; + ProgressiveJobTarget const job{.epoch = 5, .viewEpoch = 9, .width = 800, .height = 600}; + + EXPECT_FALSE(isProgressiveBufferContinuable(buffer, job)); + } + + /// REGRESSION (torn frame / stale-model bottom band): a parameter change bumps ONLY the view + /// epoch (RenderWindow::invalidateViewDueToParameterChange does m_asyncViewEpoch++ and does + /// NOT bump the scene epoch). The previously-seeded progressive buffer therefore still carries + /// the SAME scene epoch but an OLDER view epoch. It must NOT be continued/reused — otherwise + /// its not-yet-overwritten region keeps the previous parameters while the top is re-rendered + /// with the new parameters, producing the reported fresh-model-top / stale-model-bottom tear. + TEST(ProgressiveBufferPolicy_IsContinuable, ParameterChange_BumpsOnlyViewEpoch_ReturnsFalse) + { + // Buffer was seeded under scene epoch 5 / view epoch 9. + ProgressiveBufferState const buffer{ + .hasImage = true, .epoch = 5, .viewEpoch = 9, .width = 800, .height = 600}; + + // Parameter change keeps the scene epoch (5) but advances the view epoch (9 -> 10). + ProgressiveJobTarget const jobAfterParameterChange{ + .epoch = 5, .viewEpoch = 10, .width = 800, .height = 600}; + + EXPECT_FALSE(isProgressiveBufferContinuable(buffer, jobAfterParameterChange)) + << "A parameter-changed job must re-seed the progressive buffer; reusing the stale-view " + "buffer causes the torn fresh-top / stale-bottom HQ frame."; + } + + /// REGRESSION (param change followed by resize): the resize re-seeds at the new scene epoch, + /// but if a leftover buffer at the new size somehow still carries an older view epoch it must + /// be rejected too. View epoch is the deciding factor independent of size. + TEST(ProgressiveBufferPolicy_IsContinuable, NewSceneEpochButStaleViewEpoch_ReturnsFalse) + { + ProgressiveBufferState const buffer{ + .hasImage = true, .epoch = 6, .viewEpoch = 9, .width = 800, .height = 600}; + ProgressiveJobTarget const job{.epoch = 6, .viewEpoch = 11, .width = 800, .height = 600}; + + EXPECT_FALSE(isProgressiveBufferContinuable(buffer, job)); + } + + /// REGRESSION (parameter upload lands mid-fill): the GPU parameter buffer is uploaded + /// asynchronously and independently of the view epoch, so a single progressive fill carries + /// one uniform view epoch even when an upload changed the parameters between two chunks. The + /// epoch/view key alone cannot detect that, leaving the upper band rendered with the old + /// parameters and the lower band with the new ones — the reported tear. A change in the GPU + /// parameter generation must therefore reject continuation so the buffer is re-seeded. + TEST(ProgressiveBufferPolicy_IsContinuable, ParameterGenerationChanged_ReturnsFalse) + { + ProgressiveBufferState const buffer{.hasImage = true, + .epoch = 5, + .viewEpoch = 9, + .paramGeneration = 3, + .width = 800, + .height = 600}; + ProgressiveJobTarget const job{ + .epoch = 5, .viewEpoch = 9, .paramGeneration = 4, .width = 800, .height = 600}; + + EXPECT_FALSE(isProgressiveBufferContinuable(buffer, job)) + << "A parameter upload that advanced the GPU parameter generation mid-fill must re-seed " + "the progressive buffer; reusing it produces the old-params-top / new-params-bottom " + "tear that carries a uniform view epoch."; + } + + /// A matching parameter generation (alongside matching epoch/view/size) still continues. + TEST(ProgressiveBufferPolicy_IsContinuable, MatchingParameterGeneration_ReturnsTrue) + { + ProgressiveBufferState const buffer{.hasImage = true, + .epoch = 5, + .viewEpoch = 9, + .paramGeneration = 7, + .width = 800, + .height = 600}; + ProgressiveJobTarget const job{ + .epoch = 5, .viewEpoch = 9, .paramGeneration = 7, .width = 800, .height = 600}; + + EXPECT_TRUE(isProgressiveBufferContinuable(buffer, job)); + } +} diff --git a/gladius/tests/unittests/ui/render/ProgressiveTearReproduction_tests.cpp b/gladius/tests/unittests/ui/render/ProgressiveTearReproduction_tests.cpp new file mode 100644 index 00000000..39679dfb --- /dev/null +++ b/gladius/tests/unittests/ui/render/ProgressiveTearReproduction_tests.cpp @@ -0,0 +1,1241 @@ +#include "ui/render/DisplayFrameSelector.h" +#include "ui/render/ProgressiveBufferPolicy.h" + +#include + +#include +#include +#include +#include +#include +#include +#include +#include + +namespace gladius::ui::async_rendering::tests +{ + namespace + { + /// Sentinel for a line whose content is undefined (freshly acquired buffer, never written). + constexpr int64_t kGarbage = -1; + + /// Continuation policy the simulation drives. + /// SceneEpochOnly = pre-fix: reuse keyed on scene epoch + size only. + /// SceneAndView = the committed fix: drives the real isProgressiveBufferContinuable + /// (scene + view epoch + size). Stamps the buffer view epoch at seed. + /// SceneViewAndParam = candidate fix: SceneAndView PLUS the GPU parameter-upload + /// generation, so an async parameter upload mid-fill restarts the + /// fill instead of leaving a half-old / half-new buffer. + enum class Policy + { + SceneEpochOnly, + SceneAndView, + SceneViewAndParam + }; + + char const * policyName(Policy p) + { + switch (p) + { + case Policy::SceneEpochOnly: + return "SceneEpochOnly(pre-fix)"; + case Policy::SceneAndView: + return "SceneAndView(committed-fix)"; + case Policy::SceneViewAndParam: + return "SceneViewAndParam(candidate-fix)"; + } + return "?"; + } + + /// The actual model state a single rendered line represents: the view (camera/parameter + /// epoch) AND the GPU parameter-buffer generation that was uploaded when the line rendered. + /// Two lines that differ in EITHER field show different content => a torn frame. + struct LineStamp + { + int64_t viewEpoch{kGarbage}; + int64_t paramGen{kGarbage}; + + bool operator<(LineStamp const & o) const + { + return viewEpoch < o.viewEpoch || + (viewEpoch == o.viewEpoch && paramGen < o.paramGen); + } + + bool isGarbage() const + { + return viewEpoch == kGarbage || paramGen == kGarbage; + } + }; + + /// Mirrors a progressive / front image buffer plus its display-gating metadata. + struct MockImage + { + bool valid{false}; + uint64_t epoch{0}; ///< m_asyncProgressiveEpoch (scene epoch) + uint64_t viewEpoch{0}; ///< m_asyncProgressiveViewEpoch (display gating) + uint64_t renderParamGen{0}; ///< GPU param generation this buffer's HQ lines target + uint32_t width{0}; + uint32_t height{0}; + std::vector lines; ///< per-line content stamp + + void allocate(uint32_t w, uint32_t h, LineStamp fill) + { + width = w; + height = h; + lines.assign(h, fill); + valid = true; + } + + void clear() + { + *this = MockImage{}; + } + }; + + std::set distinctStamps(MockImage const & img) + { + return std::set(img.lines.begin(), img.lines.end()); + } + + bool hasGarbage(MockImage const & img) + { + return std::any_of( + img.lines.begin(), img.lines.end(), [](LineStamp const & s) { return s.isGarbage(); }); + } + + struct Job + { + bool active{false}; + uint64_t epoch{0}; + uint64_t viewEpoch{0}; + uint64_t paramGen{0}; + uint32_t width{0}; + uint32_t height{0}; + size_t startLine{0}; + size_t stepSize{0}; + }; + + struct Result + { + bool present{false}; + uint64_t epoch{0}; + uint64_t viewEpoch{0}; + uint32_t width{0}; + uint32_t height{0}; + size_t completedLine{0}; + bool completedFrame{false}; + }; + + struct DisplayObservation + { + DisplayFrameSource source{DisplayFrameSource::None}; + std::set shown; + bool torn{false}; + }; + + /// GPU-free model of the RenderWindow progressive-HQ state machine, extended with a GPU + /// parameter-buffer generation that is uploaded ASYNCHRONOUSLY and independently of the + /// view epoch. The async worker runs at the end of tick N; its result is consumed at the + /// start of tick N+1 (one-frame latency), so resizes / parameter uploads can interleave + /// between a chunk being scheduled and its result being processed. + class RenderWindowSim + { + public: + RenderWindowSim(Policy policy, uint32_t width, uint32_t height, size_t chunkLines) + : m_policy(policy) + , m_resultWidth(width) + , m_resultHeight(height) + , m_chunkLines(chunkLines) + { + } + + /// invalidateViewDueToParameterChange(): bumps ONLY the view epoch and records that a + /// NEW parameter generation has been requested. Crucially, the GPU parameter buffer is + /// NOT updated here — the upload happens later/asynchronously (deliverParameterUpload). + void parameterChange() + { + ++m_viewEpoch; + ++m_pendingParamGen; + m_currentLine = 0; + m_isRendering = false; + } + + /// The async parameter upload finally lands in *m_primitives (Document::updateParameter + /// -> tryToupdateParameter -> updateParameterBlocking). From now on, HQ chunks render + /// against the new parameters. This is decoupled in time from parameterChange(). + /// When bumpViewEpoch is true it models the candidate minimal fix: bumping the view + /// epoch at the moment the upload completes (not at request time) so any in-flight + /// fill that started against the old parameters becomes view-outdated and restarts. + void deliverParameterUpload(bool bumpViewEpoch) + { + m_gpuParamGen = m_pendingParamGen; + if (bumpViewEpoch) + { + ++m_viewEpoch; + m_currentLine = 0; + m_isRendering = false; + } + } + + /// A low-res preview for the current view/param resamples into the result image. + void lowResPreviewArrives() + { + m_resultStamp = LineStamp{static_cast(m_viewEpoch), + static_cast(m_gpuParamGen)}; + m_lastLowResViewEpoch = m_viewEpoch; + } + + void requestResize(uint32_t w, uint32_t h) + { + m_desiredWidth = w; + m_desiredHeight = h; + } + + DisplayObservation tick() + { + processResults(); + resizeBarrier(); + scheduleChunkIfNeeded(); + runWorker(); + return observeDisplay(); + } + + private: + bool continuable(MockImage const & buf, Job const & job) const + { + bool baseOk = false; + if (m_policy == Policy::SceneEpochOnly) + { + baseOk = buf.valid && buf.epoch == job.epoch && buf.width >= job.width && + buf.height >= job.height; + } + else + { + // Drive the REAL production predicate for the epoch/view/size key. + ProgressiveBufferState const state{.hasImage = buf.valid, + .epoch = buf.epoch, + .viewEpoch = buf.viewEpoch, + .width = buf.width, + .height = buf.height}; + ProgressiveJobTarget const target{.epoch = job.epoch, + .viewEpoch = job.viewEpoch, + .width = job.width, + .height = job.height}; + baseOk = isProgressiveBufferContinuable(state, target); + } + if (m_policy == Policy::SceneViewAndParam) + { + // Candidate fix: also require the buffer's HQ lines to target the parameter + // generation currently uploaded to the GPU. If an upload landed since the + // buffer was seeded, the buffer is no longer continuable and must restart. + baseOk = baseOk && buf.renderParamGen == job.paramGen; + } + return baseOk; + } + + void scheduleChunkIfNeeded() + { + if (m_returnedEarlyThisTick || m_jobInFlight) + { + return; + } + if (!m_isRendering && !m_dirty) + { + return; + } + + Job job{}; + job.active = true; + job.epoch = m_sceneEpoch; + job.viewEpoch = m_viewEpoch; + job.paramGen = m_gpuParamGen; // chunks render against the CURRENTLY uploaded params + job.width = m_resultWidth; + job.height = m_resultHeight; + + bool const seeded = continuable(m_prog, job); + if (m_currentLine > 0 && !seeded) + { + m_currentLine = 0; + } + job.startLine = std::min(m_currentLine, static_cast(job.height)); + job.stepSize = std::max( + 1, std::min(m_chunkLines, static_cast(job.height) - job.startLine)); + + if (job.startLine == 0 && !seeded) + { + // Seed: resample the low-res result image across the whole buffer. If that + // low-res preview is for an older view than the job, the seed band would mix a + // stale view with the fresh HQ top, so HQ display is suppressed until a fully + // view-consistent frame completes (mirrors the seedViewIsStale guard). + bool const seedViewIsStale = m_lastLowResViewEpoch != job.viewEpoch; + if (seedViewIsStale) + { + m_suppressHQDisplay = true; + } + m_prog.allocate(job.width, job.height, m_resultStamp); + m_prog.epoch = job.epoch; + m_prog.renderParamGen = job.paramGen; + if (m_policy != Policy::SceneEpochOnly) + { + m_prog.viewEpoch = job.viewEpoch; // committed fix stamps view epoch at seed + } + } + + m_dirty = false; + m_isRendering = true; + m_jobInFlight = true; + m_inFlightEpoch = job.epoch; + m_pendingJob = job; + } + + void runWorker() + { + if (!m_pendingJob.active) + { + return; + } + Job const job = m_pendingJob; + m_pendingJob = Job{}; + + if (!continuable(m_prog, job)) + { + // Worker acquire-fresh branch: brand-new UNSEEDED buffer (garbage lines). + m_prog.allocate(job.width, job.height, LineStamp{}); + m_prog.epoch = job.epoch; + m_prog.viewEpoch = job.viewEpoch; + m_prog.renderParamGen = job.paramGen; + } + + // HQ chunk renders [startLine, endLine) against the GPU's CURRENT parameter buffer. + // renderSceneComputeOnly does NOT re-upload parameters, so the stamp is the live + // gpu generation — which may differ from the generation that seeded the buffer. + size_t const endLine = + std::min(job.startLine + job.stepSize, static_cast(job.height)); + LineStamp const hqStamp{static_cast(job.viewEpoch), + static_cast(m_gpuParamGen)}; + for (size_t y = job.startLine; y < endLine; ++y) + { + m_prog.lines[y] = hqStamp; + } + + Result r{}; + r.present = true; + r.epoch = job.epoch; + r.viewEpoch = job.viewEpoch; + r.width = job.width; + r.height = job.height; + r.completedLine = endLine; + r.completedFrame = endLine >= static_cast(job.height); + + if (r.completedFrame) + { + m_ready = m_prog; + m_ready.epoch = job.epoch; + m_ready.viewEpoch = job.viewEpoch; + m_prog.clear(); + } + m_pendingResult = r; + } + + void processResults() + { + m_returnedEarlyThisTick = false; + if (!m_pendingResult.present) + { + return; + } + Result const r = m_pendingResult; + m_pendingResult = Result{}; + + bool const isOutdated = r.epoch < m_sceneEpoch; + bool const isViewOutdated = r.viewEpoch < m_viewEpoch; + bool const isSizeOutdated = r.width != m_resultWidth || r.height != m_resultHeight; + bool const discardForStaleView = isSizeOutdated || isViewOutdated; + + if (r.completedFrame) + { + if (!(isOutdated || discardForStaleView)) + { + m_front = m_ready; + m_frontPresented = true; + } + m_ready.clear(); + if (!isOutdated && !discardForStaleView) + { + m_isRendering = false; + m_currentLine = 0; + m_suppressHQDisplay = false; + } + } + else + { + if (!isOutdated && !isViewOutdated && !isSizeOutdated) + { + m_currentLine = std::min(r.completedLine, static_cast(r.height)); + } + if ((isViewOutdated || isSizeOutdated) && m_prog.valid && + m_prog.viewEpoch == r.viewEpoch) + { + m_prog.clear(); + m_isRendering = false; + } + } + + if (r.epoch == m_inFlightEpoch) + { + m_jobInFlight = false; + m_inFlightEpoch = 0; + if (isOutdated || isViewOutdated || isSizeOutdated) + { + m_isRendering = false; + m_dirty = true; + } + } + } + + void resizeBarrier() + { + bool const resizeRequired = + m_desiredWidth != 0 && + (m_desiredWidth != m_resultWidth || m_desiredHeight != m_resultHeight); + if (!resizeRequired) + { + return; + } + if (m_jobInFlight) + { + m_dirty = true; + m_isRendering = false; + m_returnedEarlyThisTick = true; + return; + } + if (m_prog.valid) + { + m_prog.clear(); + m_currentLine = 0; + m_isRendering = false; + } + m_resultWidth = m_desiredWidth; + m_resultHeight = m_desiredHeight; + ++m_sceneEpoch; // invalidateView -> notifyAsyncEpochIncrement bumps BOTH epochs + ++m_viewEpoch; + m_currentLine = 0; + m_isRendering = false; + m_dirty = true; + m_desiredWidth = 0; + m_desiredHeight = 0; + } + + DisplayObservation observeDisplay() + { + DisplayFrameBufferState const frontState{ + .hasImage = m_front.valid, .epoch = m_front.epoch, .viewEpoch = m_front.viewEpoch}; + DisplayFrameBufferState const progState{ + .hasImage = m_prog.valid, .epoch = m_prog.epoch, .viewEpoch = m_prog.viewEpoch}; + + std::optional presented; + if (m_frontPresented) + { + presented = PresentedFrame{}; + presented->source = FramePresentationSource::ProgressiveHighQuality; + presented->quality = FramePresentationQuality::FullQuality; + } + + DisplayFrameSelectionInput const input{.frontBuffer = frontState, + .progressiveBuffer = progState, + .currentEpoch = m_sceneEpoch, + .currentViewEpoch = m_viewEpoch, + .exactRealtimeInteraction = false, + .exactRealtimeJobInFlight = false, + .isRendering = m_isRendering, + .isMoving = false, + .fullFrameRenderJobInFlight = false, + .suppressHqDisplay = m_suppressHQDisplay, + .resultImageAvailable = true, + .presentedFrame = presented}; + + DisplayObservation obs{}; + obs.source = selectDisplayFrameSource(input); + + MockImage const * shown = nullptr; + if (obs.source == DisplayFrameSource::FrontBuffer) + { + shown = &m_front; + } + else if (obs.source == DisplayFrameSource::ProgressiveBuffer) + { + shown = &m_prog; + } + + if (shown != nullptr && shown->valid) + { + obs.shown = distinctStamps(*shown); + obs.torn = obs.shown.size() > 1 || hasGarbage(*shown); + } + else + { + obs.shown = {m_resultStamp}; // result image is a single uniform low-res frame + } + return obs; + } + + Policy m_policy; + uint32_t m_resultWidth{0}; + uint32_t m_resultHeight{0}; + size_t m_chunkLines{0}; + + uint64_t m_sceneEpoch{1}; + uint64_t m_viewEpoch{1}; + uint64_t m_gpuParamGen{1}; ///< params currently uploaded to *m_primitives + uint64_t m_pendingParamGen{1}; ///< params requested but not yet uploaded + size_t m_currentLine{0}; + bool m_isRendering{false}; + bool m_dirty{false}; + bool m_suppressHQDisplay{false}; + bool m_returnedEarlyThisTick{false}; + + LineStamp m_resultStamp{1, 1}; + uint64_t m_lastLowResViewEpoch{1}; + + MockImage m_prog{}; + MockImage m_front{}; + MockImage m_ready{}; + bool m_frontPresented{false}; + + bool m_jobInFlight{false}; + uint64_t m_inFlightEpoch{0}; + Job m_pendingJob{}; + Result m_pendingResult{}; + + uint32_t m_desiredWidth{0}; + uint32_t m_desiredHeight{0}; + }; + + std::string stampsToString(std::set const & stamps) + { + std::ostringstream oss; + oss << "{"; + bool first = true; + for (auto const & s : stamps) + { + if (!first) + { + oss << " "; + } + oss << "v" << s.viewEpoch << "p" << s.paramGen; + first = false; + } + oss << "}"; + return oss.str(); + } + + char const * sourceName(DisplayFrameSource s) + { + switch (s) + { + case DisplayFrameSource::FrontBuffer: + return "Front"; + case DisplayFrameSource::ProgressiveBuffer: + return "Progressive"; + case DisplayFrameSource::ResultImage: + return "Result"; + default: + return "None"; + } + } + + /// Runs the param-change-then-resize scenario with an async parameter upload that lands + /// mid-fill, and returns true if ANY displayed frame is torn (mixed model state on screen). + bool runParamUploadMidFillScenario(Policy policy, bool verbose, bool bumpViewOnUpload = false) + { + RenderWindowSim sim(policy, /*w*/ 100, /*h*/ 100, /*chunk*/ 20); + + // 1) Reach a clean steady state at view 1 / param 1. + sim.lowResPreviewArrives(); + for (int i = 0; i < 12; ++i) + { + sim.tick(); + } + + // 2) Change a model parameter: bumps the view epoch and REQUESTS a new param gen, but + // the GPU parameter buffer is not uploaded yet. + sim.parameterChange(); + + // 3) Immediately resize: forces a fresh full HQ fill against the still-stale GPU params. + sim.requestResize(140, 140); + + bool tornEverSeen = false; + int const uploadAtTick = 3; // the async parameter upload lands a few frames into the fill + for (int t = 0; t < 16; ++t) + { + if (t == uploadAtTick) + { + sim.deliverParameterUpload(bumpViewOnUpload); // GPU params change mid-fill + sim.lowResPreviewArrives(); // a fresh low-res preview arrives + } + DisplayObservation const obs = sim.tick(); + tornEverSeen = tornEverSeen || obs.torn; + if (verbose) + { + std::cout << " t" << t << " src=" << sourceName(obs.source) + << " shown=" << stampsToString(obs.shown) + << (obs.torn ? " <<< TORN" : "") << "\n"; + } + } + return tornEverSeen; + } + } // namespace + + /// Observation harness: print the per-tick display trace for all three policies so the real + /// behavior is visible. The parameter upload lands mid-fill (decoupled from the view epoch). + TEST(ProgressiveTearReproduction, Observe_ParamUploadMidFill_AllPolicies) + { + for (Policy policy : {Policy::SceneEpochOnly, Policy::SceneAndView, Policy::SceneViewAndParam}) + { + std::cout << "\n=== " << policyName(policy) << " ===\n"; + bool const torn = runParamUploadMidFillScenario(policy, /*verbose*/ true); + std::cout << " => tornEverSeen=" << (torn ? "true" : "false") << "\n"; + } + } + + /// The committed view-epoch fix is INSUFFICIENT for a parameter upload that lands mid-fill: + /// the whole fill carries a uniform view epoch, so the epoch guards pass while the buffer + /// holds two different parameter generations -> a torn frame is displayed. + TEST(ProgressiveTearReproduction, ParamUploadMidFill_SceneAndView_StillTears) + { + EXPECT_TRUE(runParamUploadMidFillScenario(Policy::SceneAndView, /*verbose*/ false)) + << "Expected the committed (view-epoch only) fix to STILL show a torn frame when a " + "parameter upload lands mid-fill — the tear is in the parameter dimension, which the " + "view epoch does not capture."; + } + + /// Tying buffer continuation to the GPU parameter-upload generation restarts the fill when an + /// upload lands mid-fill, so the displayed frame is never torn. + TEST(ProgressiveTearReproduction, ParamUploadMidFill_SceneViewAndParam_NeverTears) + { + EXPECT_FALSE(runParamUploadMidFillScenario(Policy::SceneViewAndParam, /*verbose*/ false)) + << "A parameter-generation-aware continuation predicate should restart the fill on a " + "mid-fill upload so no torn frame is ever displayed."; + } + + /// Minimal candidate fix reusing the EXISTING view-epoch machinery (the committed fix): bump + /// the view epoch when the parameter upload actually completes, instead of only at request + /// time. The in-flight fill that started against the old parameters then becomes view-outdated + /// and restarts, so the completed frame is param-consistent and never torn. + TEST(ProgressiveTearReproduction, ParamUploadMidFill_SceneAndView_BumpViewOnUpload_NeverTears) + { + EXPECT_FALSE(runParamUploadMidFillScenario( + Policy::SceneAndView, /*verbose*/ false, /*bumpViewOnUpload*/ true)) + << "Bumping the view epoch when the parameter upload lands should restart the in-flight " + "fill via the existing view-outdated machinery, eliminating the parameter tear."; + } + + // --------------------------------------------------------------------------------------------- + // Reproduction of the SECOND reported regression: "after resizing the HQ rendering stays at 0%". + // + // hqProgressiveRenderProgress() (RenderWindow.cpp) reports the HQ progress bar as: + // active = m_renderWindowState.isRendering && !m_renderWindowState.isMoving && height > 0 + // fraction = clamp(currentLine / height) + // So the bar shows a STUCK 0% whenever, after a resize, one of these holds persistently: + // (A) isRendering never flips back to true (HQ never re-scheduled), OR + // (B) isMoving never clears (HQ reporting permanently gated off), OR + // (C) currentLine is pinned at 0 by a perpetual re-seed loop (continuation + // predicate keeps failing, so every chunk restarts at line 0). + // + // This second simulation models the renderAsync ordering around a resize, the invalidateView() + // side effects, the low-res settle that is supposed to flip isRendering back on, and the NEW + // parameter-generation continuation key (m_asyncProgressiveParamGeneration) — including the fact + // that the resize barrier resets the scene/view epoch but does NOT reset the parameter + // generation stamp (the omission at RenderWindow.cpp L2176/2786/2912/2929/407). The parameter + // generation stamp is modelled as a SEPARATE value from the buffer (mirroring the real atomic), + // so it can drift out of sync with the buffer it is supposed to describe. + namespace + { + /// Snapshot of hqProgressiveRenderProgress() for a given tick. + struct HqProgress + { + bool active{false}; + double fraction{0.0}; + size_t currentLine{0}; + size_t height{0}; + }; + + /// Why HQ could not resume — used purely to make failing reproductions self-describing. + enum class HqStallReason + { + None, + NotRendering, ///< isRendering == false (HQ never re-scheduled) + Moving, ///< isMoving == true (progress reporting gated off) + ReseedLoop ///< currentLine pinned at 0 by repeated continuation failure + }; + + char const * stallReasonName(HqStallReason r) + { + switch (r) + { + case HqStallReason::None: + return "None"; + case HqStallReason::NotRendering: + return "NotRendering"; + case HqStallReason::Moving: + return "Moving(isMoving stuck true)"; + case HqStallReason::ReseedLoop: + return "ReseedLoop(currentLine pinned at 0)"; + } + return "?"; + } + + /// GPU-free model of RenderWindow's resize -> low-res settle -> progressive-HQ resume path, + /// with the parameter-generation continuation key. One-frame worker latency: a chunk + /// scheduled in tick N produces its result at the start of tick N+1. + class ResizeHqProgressSim + { + public: + ResizeHqProgressSim(uint32_t width, uint32_t height, size_t chunkLines) + : m_width(width) + , m_height(height) + , m_chunkLines(chunkLines) + { + // Arm the first low-res settle so the warm-up reaches a completed HQ frame. + m_forceLowRes = true; + m_dirty = true; + } + + /// Reset the observation latches AFTER the warm-up so the trace only reflects what + /// happens from the resize onward (frontPresented() is otherwise sticky). + void beginObservation() + { + m_frontPresented = false; + } + + /// If true, the resize barrier ALSO resets the parameter-generation stamp (the candidate + /// fix). Default false models the SHIPPED code, where the stamp is left stale on resize. + void setResetParamGenOnResize(bool enable) + { + m_resetParamGenOnResize = enable; + } + + /// Number of upcoming UI-side seeds that should fail (models tryGetBestRenderProgram() + /// returning nullopt while the optimized render program is still compiling). A failed + /// seed leaves the buffer unseeded AND does not store the parameter-generation stamp. + void setSeedFailures(int count) + { + m_seedFailuresRemaining = count; + } + + /// Models the static settle never scheduling HQ again (e.g. an orphaned in-flight task + /// in RenderUpdateCoordinator), so the low-res settle never flips isRendering back on. + void setSettleNeverResumesHq(bool enable) + { + m_settleNeverResumesHq = enable; + } + + /// invalidateViewDueToParameterChange(): bumps the view epoch and REQUESTS a new + /// parameter generation. The GPU parameter buffer is uploaded later (deliverParamUpload). + void parameterChange() + { + ++m_viewEpoch; + ++m_pendingParamGen; + invalidateView(); + } + + /// The async parameter upload reaches *m_primitives; updateParameterBlocking increments + /// the parameter generation (only on an ACTUAL change). + void deliverParamUpload() + { + if (m_gpuParamGen != m_pendingParamGen) + { + m_gpuParamGen = m_pendingParamGen; + } + } + + void requestResize(uint32_t w, uint32_t h) + { + m_desiredWidth = w; + m_desiredHeight = h; + } + + HqProgress tick() + { + updateMovingFromCamera(); // render() updates isMoving every frame BEFORE renderAsync + processResults(); + if (resizeBarrier()) + { + return hqProgress(); // deferred-resize early return mirrors renderAsync + } + lowResSettle(); + scheduleChunkIfNeeded(); + runWorker(); + return hqProgress(); + } + + HqProgress hqProgress() const + { + HqProgress p{}; + p.height = m_height; + p.currentLine = m_currentLine; + if (!m_isRendering || m_isMoving || m_height == 0) + { + return p; + } + p.active = true; + p.fraction = std::min(1.0, + static_cast(m_currentLine) / + static_cast(m_height)); + return p; + } + + bool frontPresented() const + { + return m_frontPresented; + } + + private: + void invalidateView() + { + m_dirty = true; + m_isMoving = true; // invalidateView() sets isMoving = true (RenderWindow.cpp L1241) + m_cameraIdleFrames = 0; + m_currentLine = 0; + m_isRendering = false; + m_forceLowRes = true; + // invalidateView() disarms HQ: the progressive fill is only re-scheduled after the + // forced low-res preview runs and the static catch-up re-arms it. This mirrors the + // RenderUpdateCoordinator gate — HQ chunks are not emitted from a bare dirty flag. + m_hqArmed = false; + } + + /// Mirrors RenderWindow.cpp L1100-1104: every frame, with no actual camera motion, the + /// idle counter advances and isMoving stays true only for a short debounce window. + void updateMovingFromCamera() + { + ++m_cameraIdleFrames; + m_isMoving = m_cameraIdleFrames < kMovingDebounceFrames; + } + + ProgressiveBufferState bufferState() const + { + return ProgressiveBufferState{.hasImage = m_prog.valid, + .epoch = m_prog.epoch, + .viewEpoch = m_prog.viewEpoch, + .paramGeneration = m_progParamGenStamp, + .width = m_prog.width, + .height = m_prog.height}; + } + + bool continuable(Job const & job) const + { + return isProgressiveBufferContinuable( + bufferState(), + ProgressiveJobTarget{.epoch = job.epoch, + .viewEpoch = job.viewEpoch, + .paramGeneration = job.paramGen, + .width = job.width, + .height = job.height}); + } + + /// The low-res preview that invalidateView() forced. In the real pipeline this preview + /// (and the static catch-up it triggers) is what flips isRendering back on so the + /// progressive HQ fill resumes. If the static settle is broken, HQ never resumes. + void lowResSettle() + { + if (!m_forceLowRes || m_jobInFlight) + { + return; + } + m_forceLowRes = false; + m_resultStamp = LineStamp{static_cast(m_viewEpoch), + static_cast(m_gpuParamGen)}; + m_lastLowResViewEpoch = m_viewEpoch; + if (!m_settleNeverResumesHq) + { + m_hqArmed = true; // static catch-up arms the progressive HQ fill + } + } + + void scheduleChunkIfNeeded() + { + if (m_jobInFlight || m_isMoving) + { + return; + } + // HQ chunks are only emitted while the fill is armed by the static catch-up. + // A bare dirty flag is NOT enough — that models the coordinator gate so an + // orphaned in-flight task (settle never re-arms) keeps HQ from ever scheduling. + if (!m_hqArmed) + { + return; + } + + Job job{}; + job.active = true; + job.epoch = m_sceneEpoch; + job.viewEpoch = m_viewEpoch; + job.paramGen = m_gpuParamGen; + job.width = m_width; + job.height = m_height; + + bool const seeded = continuable(job); + if (m_currentLine > 0 && !seeded) + { + m_currentLine = 0; + } + job.startLine = std::min(m_currentLine, static_cast(job.height)); + job.stepSize = std::max( + 1, std::min(m_chunkLines, static_cast(job.height) - job.startLine)); + + if (job.startLine == 0 && !seeded) + { + bool const seedFails = m_seedFailuresRemaining > 0; + if (seedFails) + { + --m_seedFailuresRemaining; + // Seed failed (no render program): buffer NOT seeded, stamp NOT stored. + // isRendering is still set below, so the bar is active but currentLine + // cannot advance until a seed finally succeeds. + } + else + { + m_prog.allocate(job.width, job.height, m_resultStamp); + m_prog.epoch = job.epoch; + m_prog.viewEpoch = job.viewEpoch; + m_progParamGenStamp = job.paramGen; // seed stores stamp ONLY on success + } + } + + m_dirty = false; + m_isRendering = true; + m_jobInFlight = true; + m_inFlightEpoch = job.epoch; + m_pendingJob = job; + } + + void runWorker() + { + if (!m_pendingJob.active) + { + return; + } + Job const job = m_pendingJob; + m_pendingJob = Job{}; + + if (m_seedFailuresRemaining > 0 && !m_prog.valid) + { + // Render program still unavailable: the worker cannot acquire/seed a buffer + // either, so this chunk renders nothing. isRendering stays true (bar active), + // but currentLine is pinned at 0 — reproduction of 'active but stuck at 0%'. + Result none{}; + none.present = true; + none.epoch = job.epoch; + none.viewEpoch = job.viewEpoch; + none.width = job.width; + none.height = job.height; + none.completedLine = job.startLine; // no progress + none.completedFrame = false; + m_pendingResult = none; + return; + } + + if (!continuable(job)) + { + // Worker acquire-fresh branch re-stamps the parameter generation. + m_prog.allocate(job.width, job.height, LineStamp{}); + m_prog.epoch = job.epoch; + m_prog.viewEpoch = job.viewEpoch; + m_progParamGenStamp = job.paramGen; + } + else if (!m_prog.valid) + { + // UI seed failed but predicate says "continuable" because the stale stamp still + // matches: there is no buffer to render into, so this chunk produces nothing. + Result none{}; + none.present = true; + none.epoch = job.epoch; + none.viewEpoch = job.viewEpoch; + none.width = job.width; + none.height = job.height; + none.completedLine = job.startLine; // no progress + none.completedFrame = false; + m_pendingResult = none; + return; + } + + size_t const endLine = + std::min(job.startLine + job.stepSize, static_cast(job.height)); + LineStamp const hqStamp{static_cast(job.viewEpoch), + static_cast(m_gpuParamGen)}; + for (size_t y = job.startLine; y < endLine && y < m_prog.lines.size(); ++y) + { + m_prog.lines[y] = hqStamp; + } + + Result r{}; + r.present = true; + r.epoch = job.epoch; + r.viewEpoch = job.viewEpoch; + r.width = job.width; + r.height = job.height; + r.completedLine = endLine; + r.completedFrame = endLine >= static_cast(job.height); + if (r.completedFrame) + { + m_ready = m_prog; + m_prog.clear(); + } + m_pendingResult = r; + } + + void processResults() + { + if (!m_pendingResult.present) + { + return; + } + Result const r = m_pendingResult; + m_pendingResult = Result{}; + + bool const isOutdated = r.epoch < m_sceneEpoch; + bool const isViewOutdated = r.viewEpoch < m_viewEpoch; + bool const isSizeOutdated = r.width != m_width || r.height != m_height; + bool const discard = isOutdated || isViewOutdated || isSizeOutdated; + + if (r.completedFrame) + { + if (!discard) + { + m_front = m_ready; + m_frontPresented = true; + m_isRendering = false; + m_currentLine = 0; + m_hqArmed = false; // a completed frame disarms until the next settle + } + m_ready.clear(); + } + else if (!discard) + { + m_currentLine = std::min(r.completedLine, static_cast(r.height)); + } + + if (r.epoch == m_inFlightEpoch) + { + m_jobInFlight = false; + m_inFlightEpoch = 0; + if (discard) + { + m_isRendering = false; + m_dirty = true; + } + } + } + + /// Returns true if a deferred-resize early-return happened this tick. + bool resizeBarrier() + { + bool const resizeRequired = + m_desiredWidth != 0 && + (m_desiredWidth != m_width || m_desiredHeight != m_height); + if (!resizeRequired) + { + return false; + } + if (m_jobInFlight) + { + // HQ render in flight: defer, drop to low-res, and early-return. + m_dirty = true; + m_forceLowRes = true; + m_isRendering = false; + return true; + } + if (m_prog.valid) + { + m_prog.clear(); + } + m_width = m_desiredWidth; + m_height = m_desiredHeight; + m_desiredWidth = 0; + m_desiredHeight = 0; + // Resize releases the buffer and resets scene/view epoch, but the parameter + // generation stamp is left stale unless the candidate fix is enabled. + ++m_sceneEpoch; + ++m_viewEpoch; + if (m_resetParamGenOnResize) + { + m_progParamGenStamp = 0; + } + invalidateView(); + return false; + } + + uint32_t m_width{0}; + uint32_t m_height{0}; + size_t m_chunkLines{0}; + + static constexpr int kMovingDebounceFrames = 3; + + uint64_t m_sceneEpoch{1}; + uint64_t m_viewEpoch{1}; + uint64_t m_gpuParamGen{1}; + uint64_t m_pendingParamGen{1}; + uint64_t m_progParamGenStamp{0}; ///< models the SEPARATE m_asyncProgressiveParamGeneration + + size_t m_currentLine{0}; + bool m_isRendering{false}; + bool m_isMoving{false}; + bool m_dirty{false}; + bool m_forceLowRes{false}; + int m_cameraIdleFrames{kMovingDebounceFrames}; + + bool m_resetParamGenOnResize{false}; + int m_seedFailuresRemaining{0}; + bool m_settleNeverResumesHq{false}; + bool m_hqArmed{false}; + + LineStamp m_resultStamp{1, 1}; + uint64_t m_lastLowResViewEpoch{1}; + + MockImage m_prog{}; + MockImage m_front{}; + MockImage m_ready{}; + bool m_frontPresented{false}; + + bool m_jobInFlight{false}; + uint64_t m_inFlightEpoch{0}; + Job m_pendingJob{}; + Result m_pendingResult{}; + + uint32_t m_desiredWidth{0}; + uint32_t m_desiredHeight{0}; + }; + + /// Summary of an HQ-progress run: peak fraction reached and whether HQ ever completed. + struct HqRunSummary + { + double maxFraction{0.0}; + bool everActive{false}; + bool completed{false}; + HqStallReason stallReason{HqStallReason::None}; + int ticksStuckAtZero{0}; ///< consecutive trailing ticks with fraction==0 (active or not) + }; + + /// Drive the sim forward, recording the HQ-progress trace. `setup` runs against the sim + /// BEFORE the observation loop (e.g. to request a resize / change a parameter). + template + HqRunSummary observeHqAfter(ResizeHqProgressSim & sim, + int ticks, + bool verbose, + SetupFn && setup) + { + // Warm up to a clean steady state with a completed HQ frame. + for (int i = 0; i < 12; ++i) + { + sim.tick(); + } + sim.tick(); // one extra so any pending result is consumed + + sim.beginObservation(); // forget the warm-up's completed frame; observe from here on + setup(sim); + + HqRunSummary s{}; + int trailingZero = 0; + for (int t = 0; t < ticks; ++t) + { + HqProgress const p = sim.tick(); + s.maxFraction = std::max(s.maxFraction, p.fraction); + s.everActive = s.everActive || p.active; + s.completed = s.completed || sim.frontPresented(); + if (p.fraction == 0.0) + { + ++trailingZero; + } + else + { + trailingZero = 0; + } + if (verbose) + { + std::cout << " t" << t << " active=" << (p.active ? "1" : "0") + << " frac=" << p.fraction << " line=" << p.currentLine << "/" + << p.height << (sim.frontPresented() ? " [HQ done]" : "") << "\n"; + } + } + s.ticksStuckAtZero = trailingZero; + + if (!s.completed) + { + if (!s.everActive) + { + s.stallReason = HqStallReason::NotRendering; + } + else if (s.maxFraction == 0.0) + { + s.stallReason = HqStallReason::ReseedLoop; + } + } + return s; + } + } // namespace + + /// Control: a plain resize (no parameter change, render program available) must let the HQ + /// progressive fill resume and run to completion — progress climbs above 0 and reaches 100%. + TEST(ResizeHqProgress, PlainResize_HqResumesAndCompletes) + { + ResizeHqProgressSim sim(/*w*/ 100, /*h*/ 100, /*chunk*/ 25); + HqRunSummary const s = observeHqAfter( + sim, /*ticks*/ 40, /*verbose*/ false, [](ResizeHqProgressSim & x) { x.requestResize(140, 140); }); + + EXPECT_TRUE(s.completed) << "Plain resize should let HQ run to completion (front presented)."; + EXPECT_GT(s.maxFraction, 0.0) << "HQ progress must climb above 0% after a plain resize."; + } + + /// Observation harness — prints the HQ-progress trace for the resize-then-parameter-upload + /// case so the stuck-at-0% behaviour is visible in the test log. + TEST(ResizeHqProgress, Observe_ParamChangeThenResize_Trace) + { + std::cout << "\n=== ParamChange -> Resize -> mid-fill param upload (shipped, stamp NOT reset) ===\n"; + ResizeHqProgressSim sim(/*w*/ 100, /*h*/ 100, /*chunk*/ 20); + HqRunSummary const s = observeHqAfter( + sim, /*ticks*/ 24, /*verbose*/ true, + [](ResizeHqProgressSim & x) + { + x.parameterChange(); + x.requestResize(140, 140); + }); + std::cout << " => maxFraction=" << s.maxFraction << " completed=" << (s.completed ? "1" : "0") + << " stall=" << stallReasonName(s.stallReason) << "\n"; + } + + /// REPRODUCTION: if the optimized render program never becomes available again after the + /// resize (tryGetBestRenderProgram() keeps returning nullopt), neither the UI seed nor the + /// worker can acquire a buffer to render into. isRendering is set (the bar is ACTIVE), but + /// currentLine cannot advance, so the HQ bar sits at 0% indefinitely — a direct reproduction + /// of 'after resizing the HQ rendering stays at 0%'. + TEST(ResizeHqProgress, RenderProgramUnavailableAfterResize_BarActiveButStuckAtZero) + { + ResizeHqProgressSim sim(/*w*/ 100, /*h*/ 100, /*chunk*/ 20); + sim.setSeedFailures(1000); // render program never becomes available during the window + HqRunSummary const s = observeHqAfter( + sim, /*ticks*/ 30, /*verbose*/ false, [](ResizeHqProgressSim & x) { x.requestResize(140, 140); }); + + EXPECT_FALSE(s.completed) << "With the render program unavailable, HQ cannot complete."; + EXPECT_TRUE(s.everActive) + << "The HQ bar is ACTIVE (isRendering set) yet makes no progress."; + EXPECT_EQ(s.maxFraction, 0.0) + << "HQ progress is pinned at 0% — reproduction of the 'stays at 0% after resize' report."; + EXPECT_GT(s.ticksStuckAtZero, 0) << "The 0% state must persist (not a one-frame blip)."; + } + + /// REPRODUCTION: if the static settle never re-arms HQ after the resize (orphaned in-flight + /// coordinator task class), isRendering never returns to true, so the HQ bar reports inactive + /// forever — another way the bar 'stays at 0%'. + TEST(ResizeHqProgress, SettleNeverResumesHq_StuckInactive) + { + ResizeHqProgressSim sim(/*w*/ 100, /*h*/ 100, /*chunk*/ 25); + sim.setSettleNeverResumesHq(true); + HqRunSummary const s = observeHqAfter( + sim, /*ticks*/ 30, /*verbose*/ false, [](ResizeHqProgressSim & x) { x.requestResize(140, 140); }); + + EXPECT_FALSE(s.completed) << "HQ never completes if the settle never re-arms it."; + EXPECT_FALSE(s.everActive) + << "The HQ bar never becomes active — reproduction of the perpetual 0% after resize."; + } +} From e7ef2005012422986a96c2d16bc4dd10bf2da710 Mon Sep 17 00:00:00 2001 From: Jan Orend <56254096+3dJan@users.noreply.github.com> Date: Tue, 9 Jun 2026 19:43:48 +0200 Subject: [PATCH 46/72] [RenderWindow] Adjust adaptive preview resize threshold percentage to improve responsiveness during resizing --- gladius/src/ui/RenderWindow.cpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/gladius/src/ui/RenderWindow.cpp b/gladius/src/ui/RenderWindow.cpp index 185ffd9a..5ea8961d 100644 --- a/gladius/src/ui/RenderWindow.cpp +++ b/gladius/src/ui/RenderWindow.cpp @@ -157,7 +157,7 @@ namespace gladius::ui constexpr float kAdaptivePreviewMinQuality = 0.05f; constexpr float kAdaptivePreviewMinDimension = 1.0f; constexpr float kAdaptivePreviewMaxDimension = 16000.0f; - constexpr float kAdaptivePreviewResizeThresholdPercent = 5.0f; + constexpr float kAdaptivePreviewResizeThresholdPercent = 0.5f; struct AsyncPreviewLaunch { From dc72bbedeac64c942b955cad6434e90cfb830a1c Mon Sep 17 00:00:00 2001 From: Jan Orend <56254096+3dJan@users.noreply.github.com> Date: Tue, 9 Jun 2026 22:11:17 +0200 Subject: [PATCH 47/72] [OrbitalCamera] Increase tolerance value in update method to improve stability during updates and remove unused MVP-based approach comments --- gladius/src/ui/OrbitalCamera.cpp | 18 +----------------- 1 file changed, 1 insertion(+), 17 deletions(-) diff --git a/gladius/src/ui/OrbitalCamera.cpp b/gladius/src/ui/OrbitalCamera.cpp index b82c668b..3ed609ca 100644 --- a/gladius/src/ui/OrbitalCamera.cpp +++ b/gladius/src/ui/OrbitalCamera.cpp @@ -105,7 +105,7 @@ namespace gladius::ui bool OrbitalCamera::update(float deltaTime_ms) { bool changed = false; - auto constexpr tolerance = 0.0001f; + auto constexpr tolerance = 0.1f; auto const speedFactor = 15.E-3f; deltaTime_ms = std::min(deltaTime_ms, 50.f); @@ -312,19 +312,3 @@ namespace gladius::ui m_lookAtTarget = lookAt; } } -// Alternative MVP-based approach (for reference): -// We could also use a proper 4x4 view matrix and transform the corners: -// -// Eigen::Matrix4f viewMatrix; -// viewMatrix.setIdentity(); -// viewMatrix.block<3,1>(0,0) = uu; // right -// viewMatrix.block<3,1>(0,1) = vv; // up -// viewMatrix.block<3,1>(0,2) = ww; // forward -// viewMatrix.block<3,1>(0,3) = -(viewMatrix.block<3,3>(0,0) * eyePos); // translation -// -// Then for each corner: -// Eigen::Vector4f homogeneousCorner(corner.x(), corner.y(), corner.z(), 1.0f); -// Eigen::Vector4f cameraSpaceCorner = viewMatrix * homogeneousCorner; -// float cameraX = cameraSpaceCorner.x(); -// float cameraY = cameraSpaceCorner.y(); -// float cameraZ = cameraSpaceCorner.z(); \ No newline at end of file From b804ee52939ae79568939d6246e0684a087b4114 Mon Sep 17 00:00:00 2001 From: Jan Orend <56254096+3dJan@users.noreply.github.com> Date: Fri, 12 Jun 2026 19:29:25 +0200 Subject: [PATCH 48/72] [Library] Add new 3D model files for mechanical components and primitives --- gladius/library/mechanical/helix_spring.3mf | Bin 0 -> 63953 bytes gladius/library/mechanical/involute_gear.3mf | Bin 54074 -> 42075 bytes .../mechanical/iso_external_thread.3mf | Bin 58417 -> 53194 bytes gladius/library/mechanical/metric_nut.3mf | Bin 0 -> 36963 bytes .../library/mechanical/socket_cap_screw.3mf | Bin 0 -> 51600 bytes gladius/library/mechanical/tube.3mf | Bin 0 -> 48647 bytes .../library/operations/polar_repetition.3mf | Bin 0 -> 54396 bytes gladius/library/operations/twist.3mf | Bin 0 -> 27996 bytes gladius/library/primitives/capsule.3mf | Bin 0 -> 31329 bytes gladius/library/primitives/cone.3mf | Bin 0 -> 42415 bytes gladius/library/primitives/cylinder.3mf | Bin 0 -> 45503 bytes gladius/library/primitives/diamond.3mf | Bin 0 -> 48979 bytes gladius/library/primitives/ellipsoid.3mf | Bin 0 -> 48750 bytes gladius/library/primitives/hexagon.3mf | Bin 0 -> 36228 bytes gladius/library/primitives/rounded_box.3mf | Bin 0 -> 50509 bytes gladius/library/primitives/sphere.3mf | Bin 0 -> 54927 bytes gladius/library/primitives/torus.3mf | Bin 0 -> 58518 bytes .../library/primitives/triangular_prism.3mf | Bin 0 -> 29582 bytes gladius/library/primitives/truncated_cone.3mf | Bin 0 -> 43344 bytes 19 files changed, 0 insertions(+), 0 deletions(-) create mode 100644 gladius/library/mechanical/helix_spring.3mf create mode 100644 gladius/library/mechanical/metric_nut.3mf create mode 100644 gladius/library/mechanical/socket_cap_screw.3mf create mode 100644 gladius/library/mechanical/tube.3mf create mode 100644 gladius/library/operations/polar_repetition.3mf create mode 100644 gladius/library/operations/twist.3mf create mode 100644 gladius/library/primitives/capsule.3mf create mode 100644 gladius/library/primitives/cone.3mf create mode 100644 gladius/library/primitives/cylinder.3mf create mode 100644 gladius/library/primitives/diamond.3mf create mode 100644 gladius/library/primitives/ellipsoid.3mf create mode 100644 gladius/library/primitives/hexagon.3mf create mode 100644 gladius/library/primitives/rounded_box.3mf create mode 100644 gladius/library/primitives/sphere.3mf create mode 100644 gladius/library/primitives/torus.3mf create mode 100644 gladius/library/primitives/triangular_prism.3mf create mode 100644 gladius/library/primitives/truncated_cone.3mf diff --git a/gladius/library/mechanical/helix_spring.3mf b/gladius/library/mechanical/helix_spring.3mf new file mode 100644 index 0000000000000000000000000000000000000000..274e97a332d13882dd416f622100fc9b43418181 GIT binary patch literal 63953 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�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

UC|!F*^tcANDL}!)Jj?XeK8^AxFT7+2 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

us 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¨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 z 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 WU%l1_-@v_U;aomf_E*eeVEhjieR(4Q literal 0 HcmV?d00001 diff --git a/gladius/library/mechanical/socket_cap_screw.3mf b/gladius/library/mechanical/socket_cap_screw.3mf new file mode 100644 index 0000000000000000000000000000000000000000..8a873337f33f82025800f3c5b2ea971b545f35c1 GIT binary patch literal 51600 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 nJwR@gezw-GJ#@i5*z7$s$h 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 Qa&T|QKlT5SECBHT0C_@uS^xk5 literal 0 HcmV?d00001 diff --git a/gladius/library/operations/polar_repetition.3mf b/gladius/library/operations/polar_repetition.3mf new file mode 100644 index 0000000000000000000000000000000000000000..355edbf1d92bc05ab21bb3100695f1ca0df47f08 GIT binary patch literal 54396 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

    MH{#&t1hqawrzK%-fuTG^#>if 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
    V(OA)%y#Ffwz}GqF=r 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 literal 0 HcmV?d00001 diff --git a/gladius/library/primitives/capsule.3mf b/gladius/library/primitives/capsule.3mf new file mode 100644 index 0000000000000000000000000000000000000000..99e5c5a3731730f2f8e2c3d3aee06e8becfb0f94 GIT binary patch literal 31329 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 literal 0 HcmV?d00001 diff --git a/gladius/library/primitives/cone.3mf b/gladius/library/primitives/cone.3mf new file mode 100644 index 0000000000000000000000000000000000000000..2b137d0e8f199b51e98442dd63a18507a66b5192 GIT binary patch literal 42415 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 literal 0 HcmV?d00001 diff --git a/gladius/library/primitives/diamond.3mf b/gladius/library/primitives/diamond.3mf new file mode 100644 index 0000000000000000000000000000000000000000..a1a1b49715e8ab6194150dfcd4a7682ab9df1a2d GIT binary patch literal 48979 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

    Z_<$>?KLuCnR!T5Z@=I{&LKI+oXUpvQ_g=2 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 literal 0 HcmV?d00001 diff --git a/gladius/library/primitives/rounded_box.3mf b/gladius/library/primitives/rounded_box.3mf new file mode 100644 index 0000000000000000000000000000000000000000..bbb1a16da753790b51d2cbe5c3fa2c443305206f GIT binary patch literal 50509 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 Date: Sun, 14 Jun 2026 15:09:11 +0200 Subject: [PATCH 49/72] [Library Extension] Add quick start guide, research findings, and feature specification for new library items. Implement tasks for symmetry operations, basic primitives, deformation modifiers, and mechanical parts. Ensure comprehensive metadata and validation requirements are met for all entries --- .serena/memories/library-best-practices.md | 49 ++++ .../checklists/requirements.md | 34 +++ .../contracts/library-api.yaml | 218 ++++++++++++++++ specs/030-library-extension/data-model.md | 97 +++++++ specs/030-library-extension/impl-plan.md | 212 ++++++++++++++++ specs/030-library-extension/plan.md | 1 + specs/030-library-extension/quickstart.md | 228 +++++++++++++++++ specs/030-library-extension/research.md | 199 +++++++++++++++ specs/030-library-extension/spec.md | 240 ++++++++++++++++++ specs/030-library-extension/tasks.md | 237 +++++++++++++++++ 10 files changed, 1515 insertions(+) create mode 100644 .serena/memories/library-best-practices.md create mode 100644 specs/030-library-extension/checklists/requirements.md create mode 100644 specs/030-library-extension/contracts/library-api.yaml create mode 100644 specs/030-library-extension/data-model.md create mode 100644 specs/030-library-extension/impl-plan.md create mode 120000 specs/030-library-extension/plan.md create mode 100644 specs/030-library-extension/quickstart.md create mode 100644 specs/030-library-extension/research.md create mode 100644 specs/030-library-extension/spec.md create mode 100644 specs/030-library-extension/tasks.md diff --git a/.serena/memories/library-best-practices.md b/.serena/memories/library-best-practices.md new file mode 100644 index 00000000..220446d0 --- /dev/null +++ b/.serena/memories/library-best-practices.md @@ -0,0 +1,49 @@ +# 3MF Library Best Practices + +## What Makes a Good Library Item? + +### Required Metadata +- **Name**: lower_snake_case, descriptive, no spaces or special chars except hyphens/underscores +- **Description**: One sentence explaining what the item does and key parameters +- **Tags**: Minimum 5 relevant keywords for discoverability (e.g., "primitive", "cone", "taper", "basic") +- **Tagged Function**: Exactly one function marked as tagged. Return type depends on purpose: + - `float shape` for SDF primitives/modifiers + - `vec3 result` for position transformations (symmetry, rotation, etc.) + - `vec3 color` for textures/materials + +### Code Quality +- Clean bounding box suitable for thumbnail rendering +- Well-named parameters with meaningful defaults where appropriate +- A `main` function demonstrating typical usage +- No duplicate function definitions (each name should have unique ID) +- Proper dependency ordering in program snippet + +### Visual Appeal (for Thumbnails) +- Geometry fills 70-85% of thumbnail frame +- Isometric camera angle showing 3D form clearly +- Consistent lighting: directional light from upper-left, ambient fill +- Neutral background (light gray or white) +- Characteristic features visible at thumbnail size (256x256 minimum) + +## Anti-Patterns to Avoid +- Empty entries (no description/tags/tagged functions) +- Missing tags on any entry +- Duplicate function names within same file +- Nearly duplicate entries (e.g., unionWithColor vs union_with_color) + +## Naming Conventions +- Primitives: `sphere`, `cylinder`, `cone`, etc. +- Modifiers: `round`, `shell`, `offset` (verb-based) +- Operations: `polar_repetition`, `symmetryX` (noun-based with parameter suffix) +- Mechanical: `helix_spring`, `involute_gear` (compound nouns) + +## Category Structure +``` +primitives/ - Basic SDF shapes (sphere, cylinder, cone, etc.) +csg/ - Boolean operations (union, intersection, subtraction) +modifiers/ - Shape deformations (round, shell, offset) +operations/ - Domain transformations (symmetryX, polar_repetition) +lattices/ - Periodic structures (gyroid, honeycomb) +mechanical/ - Engineering parts (gear, spring, thread) +textures/ - Procedural textures (WoodTexture) +``` diff --git a/specs/030-library-extension/checklists/requirements.md b/specs/030-library-extension/checklists/requirements.md new file mode 100644 index 00000000..7e73bf44 --- /dev/null +++ b/specs/030-library-extension/checklists/requirements.md @@ -0,0 +1,34 @@ +# Specification Quality Checklist: Library Extension + +**Purpose**: Validate specification completeness and quality before proceeding to planning +**Created**: 2026-06-10 +**Feature**: [spec.md](../spec.md) + +## Content Quality + +- [x] No implementation details (languages, frameworks, APIs) +- [x] Focused on user value and business needs +- [x] Written for non-technical stakeholders +- [x] All mandatory sections completed + +## Requirement Completeness + +- [x] No [NEEDS CLARIFICATION] markers remain +- [x] Requirements are testable and unambiguous +- [x] Success criteria are measurable +- [x] Success criteria are technology-agnostic (no implementation details) +- [x] All acceptance scenarios are defined +- [x] Edge cases are identified +- [x] Scope is clearly bounded +- [x] Dependencies and assumptions identified + +## Feature Readiness + +- [x] All functional requirements have clear acceptance criteria +- [x] User scenarios cover primary flows +- [x] Feature meets measurable outcomes defined in Success Criteria +- [x] No implementation details leak into specification + +## Notes + +- All items pass validation on first iteration. Specification is ready for `/speckit.clarify` or `/speckit.plan`. diff --git a/specs/030-library-extension/contracts/library-api.yaml b/specs/030-library-extension/contracts/library-api.yaml new file mode 100644 index 00000000..f9f27a3e --- /dev/null +++ b/specs/030-library-extension/contracts/library-api.yaml @@ -0,0 +1,218 @@ +openapi: 3.0.2 +info: + title: Gladius Library Extension API + description: > + MCP-based API for creating, validating, and managing library entries in the + Gladius 3MF library. This contract covers the operations needed to implement + Feature 030 (library extension). + version: 1.0.0 + +paths: {} # MCP tools are not HTTP endpoints; contracts here document tool signatures + +components: + schemas: + LibraryEntry: + type: object + description: A single item in the Gladius 3MF library + required: + - name + - category + - program_snippet + - function_id + - description + - tags + properties: + name: + type: string + pattern: '^[a-z0-9_]+$' + description: Entry name in lower_snake_case, unique within category + category: + type: string + enum: + - primitives + - csg + - modifiers + - operations + - mechanical + - lattices + - textures + description: Library subdirectory / category + program_snippet: + type: string + description: > + Full GLSL-like program listing in set_program_snippet format. + Must include the library function AND a main root function that + demonstrates it. Example: + ``` + // Function: my_shape (ID: 1) + float my_shape_1(vec3 pos, float radius) { + return length(pos) - radius; + } + + // Function: main (ID: 3) [root] + (float shape) main_3(vec3 pos) { + shape = my_shape_1(pos, 10.0); + } + ``` + function_id: + type: integer + description: Resource ID of the function to tag as the library function (not main). This is the function that will be importable. + description: + type: string + minLength: 10 + maxLength: 100 + description: One-sentence human-readable description + tags: + type: array + items: + type: string + minItems: 5 + uniqueItems: true + description: Searchable keywords for the entry + overwrite: + type: boolean + default: false + description: If true, overwrite an existing entry with the same name + + TaggedFunction: + type: object + description: The primary exportable function within a library entry + properties: + function_name: + type: string + pattern: '^[a-z0-9_]+$' + return_type: + type: string + enum: [float, vec3] + parameters: + type: array + items: + $ref: '#/components/schemas/Parameter' + + Parameter: + type: object + properties: + name: + type: string + pattern: '^[a-z][a-zA-Z0-9]*$' + type: + type: string + enum: [float, vec3] + + examples: + symmetry_y_entry: + summary: SymmetryY library entry example + value: + name: symmetry_y + category: operations + program_snippet: | + // Function: symmetryY (ID: 1) + vec3 symmetryY(vec3 pos) { + return vec3(-pos.x, -pos.y, pos.z); + } + + // Function: main (ID: 2) [root] + (float shape) main(vec3 pos) { + float radius = 10.0; + vec3 p = symmetryY(pos); + shape = length(p) - radius; + } + function_id: 1 + description: "Point reflection across YZ plane (mirrors X and Z coordinates)" + tags: + - symmetry + - mirror + - transformation + - reflection + - operations + overwrite: false + + ellipsoid_entry: + summary: Ellipsoid primitive example + value: + name: ellipsoid + category: primitives + program_snippet: | + // Function: ellipsoid (ID: 1) + float ellipsoid(vec3 pos, float xRadius, float yRadius, float zRadius) { + return length(pos / vec3(xRadius, yRadius, zRadius)) - 1.0; + } + + // Function: main (ID: 2) [root] + (float shape) main(vec3 pos) { + shape = ellipsoid(pos, 8.0, 5.0, 3.0); + } + function_id: 1 + description: "Ellipsoid primitive with three independent radii along each axis" + tags: + - ellipsoid + - primitive + - sphere + - radius + - geometry + overwrite: false + + twist_entry: + summary: Twist modifier example + value: + name: twist + category: modifiers + program_snippet: | + // Function: twist (ID: 1) + vec3 twist(vec3 pos, float angle) { + if (angle == 0.0) return pos; + float t = pos.z / 10.0; + float c = cos(angle * t); + float s = sin(angle * t); + vec2 rotated = vec2(pos.x * c - pos.y * s, pos.x * s + pos.y * c); + return vec3(rotated.x, rotated.y, pos.z); + } + + // Function: main (ID: 2) [root] + (float shape) main(vec3 pos) { + float radius = 5.0; + vec3 twisted = twist(pos, 45.0); + shape = length(twisted.xy) - radius; + } + function_id: 1 + description: "Applies rotational twist around Z axis proportional to height" + tags: + - twist + - deformation + - modifier + - rotation + - transformation + overwrite: false + + hex_nut_entry: + summary: Hex nut mechanical part example + value: + name: hex_nut + category: mechanical + program_snippet: | + // Function: hex_nut (ID: 1) + float hex_nut(vec3 pos, float size) { + vec2 p = abs(pos.xy); + float angle = 2.0 * 3.14159 / 6.0; + float outerHex = max( + dot(p, vec2(sin(angle), cos(angle))), + -pos.z + size * 0.5, + pos.z + size * 0.5 + ) - size * 0.577; + float innerHole = length(pos.xy) - size * 0.3; + return max(outerHex, innerHole); + } + + // Function: main (ID: 2) [root] + (float shape) main(vec3 pos) { + shape = hex_nut(pos, 10.0); + } + function_id: 1 + description: "Hexagonal nut with threaded hole for M6 metric fastener" + tags: + - hex_nut + - mechanical + - fastener + - hardware + - metric + overwrite: false diff --git a/specs/030-library-extension/data-model.md b/specs/030-library-extension/data-model.md new file mode 100644 index 00000000..a22e866c --- /dev/null +++ b/specs/030-library-extension/data-model.md @@ -0,0 +1,97 @@ +# Data Model: Library Extension (Feature 030) + +## Entities + +### LibraryEntry + +Represents a single item in the Gladius 3MF library. + +| Field | Type | Constraints | Description | +|-------|------|-------------|-------------| +| name | string | `lower_snake_case`, unique within category | Human-readable entry name (e.g., "symmetry_y") | +| category | enum | One of: primitives, csg, modifiers, operations, mechanical, lattices, textures | Library category | +| description | string | Non-empty, ≤100 characters | One-sentence human-readable description | +| tags | string[] | Minimum 5 items, unique entries | Searchable keywords (e.g., "symmetry", "mirror", "transformation") | +| tagged_function_id | integer | Positive, unique within entry | Resource ID of the primary exportable function | +| thumbnail_path | string | Relative path to PNG in library directory | Path to rendered preview image | +| shipped | boolean | Default: false | Whether this item is part of the shipped library | + +**Relationships**: +- Each LibraryEntry contains exactly one TaggedFunction (the tagged function) +- A LibraryEntry may contain additional helper functions (referenced by resource_id) + +--- + +### TaggedFunction + +The primary exportable function within a library entry. Identified by its resource_id in the 3MF file. + +| Field | Type | Constraints | Description | +|-------|------|-------------|------------| +| function_name | string | `lower_snake_case`, unique within entry | Name of the GLSL-like function | +| return_type | enum | "float" or "vec3" | Output type: float for SDF, vec3 for transforms/textures | +| parameters | Parameter[] | Non-empty list | Input parameters with name and type | +| body_snippet | string | ≤20 lines of GLSL-like code | Function body in set_program_snippet format | + +**Relationships**: +- Belongs to exactly one LibraryEntry +- May reference other functions within the same entry (helper functions) + +--- + +### Parameter + +A single input parameter for a TaggedFunction. + +| Field | Type | Constraints | Description | +|-------|------|-------------|------------| +| name | string | `lowerCamelCase` | Parameter name (e.g., "xRadius") | +| type | enum | "float" or "vec3" | GLSL-like type declaration | + +--- + +## New Items Data Model + +### Symmetry Operations (operations/) + +| Entry Name | Return Type | Parameters | Output | +|------------|-------------|------------|--------| +| symmetry_y | vec3 | `pos: vec3` | Mirrored position across YZ plane | +| symmetry_z | vec3 | `pos: vec3` | Mirrored position across XZ plane | +| symmetry_xyz | vec3 | `pos: vec3` | Point reflection through origin | + +### Basic Primitives (primitives/) + +| Entry Name | Return Type | Parameters | Output | +|------------|-------------|------------|--------| +| ellipsoid | float | `pos: vec3, xRadius: float, yRadius: float, zRadius: float` | Signed distance to ellipsoid surface | +| capsule | float | `pos: vec3, radius: float, height: float` | Signed distance to capsule (cylinder + hemispheres) | +| diamond | float | `pos: vec3, topRadius: float, bottomRadius: float` | Signed distance to bicone (two cones joined at base) | + +### Deformation Modifiers (modifiers/) + +| Entry Name | Return Type | Parameters | Output | +|------------|-------------|------------|--------| +| twist | vec3 | `pos: vec3, angle: float` | Transformed position after twisting around Z axis | +| bend | vec3 | `pos: vec3, angle: float` | Transformed position after bending into arc | + +### Mechanical Parts (mechanical/) + +| Entry Name | Return Type | Parameters | Output | +|------------|-------------|------------|--------| +| hex_nut | float | `pos: vec3, size: float` | SDF of hexagonal nut with threaded hole | +| washer_flat | float | `pos: vec3, outerRadius: float, innerRadius: float, height: float` | SDF of flat annular washer | +| rivet | float | `pos: vec3, shaftRadius: float, headRadius: float, shaftHeight: float, headHeight: float` | SDF of rivet (cylinder + hemisphere head) | + +--- + +## Validation Rules + +1. **Name uniqueness**: No two entries in the same category may share a name +2. **Tag minimum**: Each entry must have ≥5 unique tags +3. **Description length**: 10–100 characters per description +4. **Function return type consistency**: + - primitives/csg/mechanical/lattices → `float shape` (SDF) + - modifiers/operations → `vec3 result` (position transform) or `float shape` (if SDF modifier) + - textures → `vec3 color` (color output) +5. **Parameter naming**: All parameters use `lowerCamelCase`, no reserved GLSL names (pos, normal, etc.) as parameter names without prefix diff --git a/specs/030-library-extension/impl-plan.md b/specs/030-library-extension/impl-plan.md new file mode 100644 index 00000000..55865419 --- /dev/null +++ b/specs/030-library-extension/impl-plan.md @@ -0,0 +1,212 @@ +# Implementation Plan: Extend the 3MF Library with New Items + +**Feature**: 030-library-extension +**Branch**: `030-library-extension` +**Spec**: [spec.md](./spec.md) +**Created**: 2026-06-10 + +## Technical Context + +### What We Know + +This feature extends the Gladius 3MF library by adding new items across three categories: +- **Symmetry operations** (operations/): symmetryY, symmetryZ, symmetryXYZ — complementing existing symmetryX +- **Basic primitives** (primitives/): ellipsoid, capsule, diamond — filling gaps in fundamental geometry +- **Deformation modifiers** (modifiers/): twist, bend — extending the modifier family beyond offset/round/shell +- **Mechanical parts** (mechanical/): hex_nut, washer_flat, rivet — expanding engineering hardware + +### Existing Patterns to Follow + +From reviewing 31 existing library entries: + +| Pattern | Example | Notes | +|---------|---------|-------| +| SDF primitives return `float shape` | sphere, cylinder, cone | Standard signed distance field output | +| Position modifiers return `vec3 result` | symmetryX | Transform coordinates, not SDF values | +| Tagged function is the primary export | All entries have exactly one tagged function | Identified by resource_id in 3MF | +| Minimum 5 tags per entry | gyroid (16 tags), hexagon (7 tags) | Tags enable search/filter in library panel | +| One-sentence description | All shipped entries | Shown in tooltip on hover | + +### Known Issues to Avoid + +- **Empty entries**: The `basics` category has an unpopulated entry — avoid creating empty placeholders +- **Duplicate function names**: smooth_union has duplicate main/sphere/box functions (IDs 1,4,5 vs 9,7,8) — ensure unique naming +- **Missing tags**: 5+ entries have empty tag arrays — all new items must have ≥5 tags + +### Technology Stack + +- Library format: 3MF with embedded GLSL-like function snippets +- Rendering: OpenCL-based ray marching (same as preview viewport) +- Import mechanism: Existing library browser UI (already implemented) +- Validation: MCP tools (`create_library_entry`, `validate_model`) + +## Constitution Check + +| Principle | Relevance | Compliance Action | +|-----------|-----------|-------------------| +| I. Modern C++ | Indirect — library items use GLSL snippets, not C++ | N/A for content creation | +| II. Test-First | Library items should be validated before shipping | Use `validate_model` MCP tool; write unit tests for SDF correctness if applicable | +| III. Simplicity (KISS/DRY/YAGNI) | Direct — each item must be simple and focused | Keep functions under 20 lines; avoid over-engineering mechanical parts | +| IV. Consistent Code Style | Direct — naming conventions apply to function names in snippets | Use `lower_snake_case` for entry names; GLSL follows standard C conventions | +| V. Documentation | Direct — each item needs description and tags | Minimum 1-sentence description, ≥5 relevant tags per entry | +| VI. UI Responsiveness | Indirect — import should not block UI | Import is already async in existing codebase | + +### Gate Evaluation + +- **No violations detected.** This feature creates content (library items), not infrastructure changes. Constitution principles apply to C++ code quality, which is maintained through the validation pipeline. +- **Principle III (Simplicity)**: All new items must be evaluated for complexity — if a function exceeds ~20 lines or requires helper functions, consider splitting into multiple entries. + +## Phase 0: Research & Pattern Analysis + +### Research Tasks + +| # | Task | Status | Findings | +|---|------|--------|----------| +| R1 | Analyze symmetryX implementation for Y/Z pattern derivation | ✅ Complete | symmetryX returns `vec3 result = vec3(-pos.x, pos.y, pos.z)`; symmetryY uses `(pos.x, -pos.y, pos.z)`; symmetryZ uses `(pos.x, pos.y, -pos.z)` | +| R2 | Determine ellipsoid SDF formula from existing primitives | ✅ Complete | `length(pos / radius) - 1.0` where radius is vec3; capsule = cylinder + two hemispheres via min() | +| R3 | Research twist/bend mathematical formulations for SDFs | ✅ Complete | Twist: rotate each slice by angle * z/height; Bend: map to arc of radius R. Parameter range 0°–180°. | +| R4 | Identify standard metric dimensions for mechanical parts | ✅ Complete | Hex nut: hex width 10mm, height 5.2mm; Washer: annular cylinder approximation; Rivet: cylinder + hemisphere head. Simplified approximations used. | +| R5 | Verify existing library import pipeline handles vec3 return types correctly | ✅ Complete | Confirmed via earlier review — WoodTexture returns `vec3 color`, symmetryX returns `vec3 result`. System supports non-SDF outputs. | + +### Consolidated Research Findings + +**Decision: Symmetry operations use simple coordinate negation** +- Rationale: Direct extension of existing symmetryX; no complex math required +- Implementation: Each operation is a single return statement in GLSL-like format + +**Decision: Ellipsoid uses normalized length SDF** +- Formula: `length(pos / radius) - 1.0` where radius = vec3(xRadius, yRadius, zRadius) +- Rationale: Standard analytical SDF; numerically stable for all positive radii + +**Decision: Capsule decomposes into cylinder + two spheres via min()** +- Formula: `min(max(dot(pos, normalize(axis)) * height - radius, length(pos - axis * sign) - radius), 0.0)` simplified to standard capsule SDF +- Rationale: Reuses existing cylinder/sphere patterns; well-documented in SDF literature + +**Decision: Twist modifier applies per-slice rotation proportional to z-height** +- Formula: `float angle = twistAngle * (pos.z / height); pos.xy = rotate(pos.xy, angle);` +- Rationale: Standard parametric deformation; parameter range 0°–180° produces smooth results + +**Decision: Mechanical parts use simplified geometric approximations** +- Hex nut: Cylinder with hexagonal hole (via max of 6 plane SDFs) +- Flat washer: Simple annular cylinder (outer radius, inner radius, height) +- Rivet: Cylinder + hemisphere head (union of two primitives) +- Rationale: Full engineering precision is unnecessary for visual preview; simplicity over accuracy + +## Phase 1: Design & Implementation Plan + +### Data Model + +| Entity | Fields | Validation Rules | +|--------|--------|------------------| +| LibraryEntry | name, category, description, tags[], tagged_function_id, thumbnail_path, shipped | name: lower_snake_case, unique within category; tags: ≥5 items; description: non-empty string | +| TaggedFunction | function_name, return_type (float/vec3), parameters[], body_snippet | return_type matches expected output for category; parameters: all named, typed as float or vec3 | + +### Implementation Order (by priority) + +```mermaid +graph TD + A[Phase 1a: Symmetry Operations] --> B[Phase 1b: Basic Primitives] + B --> C[Phase 1c: Deformation Modifiers] + C --> D[Phase 1d: Mechanical Parts] + + subgraph Phase 1a [Symmetry - P1] + A1[symmetryY] + A2[symmetryZ] + A3[symmetryXYZ] + end + + subgraph Phase 1b [Primitives - P2] + B1[ellipsoid] + B2[capsule] + B3[diamond] + end + + subgraph Phase 1c [Modifiers - P2] + C1[twist] + C2[bend] + end + + subgraph Phase 1d [Mechanical - P3] + D1[hex_nut] + D2[washer_flat] + D3[rivet] + end +``` + +### Per-Item Implementation Template + +Each library entry follows this structure: + +```glsl +// Function: {function_name} (ID: {resource_id}) +{return_type} {function_name}({parameters}) {{ + // Body: 10-20 lines maximum + return result; +}} + +// Function: main (ID: {main_resource_id}) [root] +(float shape) main({parameters}) {{ + shape = {function_name}(params); +}} +``` + +### Validation Checklist Per Item + +- [ ] Function name follows `lower_snake_case` +- [ ] Exactly one tagged function with unique resource_id +- [ ] Return type matches category expectations (float for SDF, vec3 for transforms) +- [ ] Minimum 5 relevant tags +- [ ] One-sentence description +- [ ] Thumbnail renders correctly at standard camera position +- [ ] No duplicate function names within entry +- [ ] Bounding box is valid and reasonable + +## Phase 2: Testing Strategy + +### Unit Tests (GTest) + +| Test Name | What It Verifies | Category | +|-----------|------------------|----------| +| `SymmetryY_PositionMirrored` | symmetryY returns correct mirrored coordinates for sample points | operations | +| `Ellipsoid_SDF_CorrectAtCenter` | ellipsoid SDF ≈ 0 at surface, < 0 inside, > 0 outside | primitives | +| `Capsule_HemisphereEnds` | capsule renders as cylinder with hemispherical caps at parameter extremes | primitives | +| `Twist_AngleProportionalToZ` | twist rotation increases linearly from base to top | modifiers | +| `HexNut_HexagonalProfile` | hex_nut produces correct 6-sided outer profile | mechanical | + +### Integration Tests + +- Import symmetryY into a project, apply to test shape → verify preview shows Y-axis mirroring +- Combine ellipsoid with round modifier → verify smooth deformation without artifacts +- Apply twist to cylinder at 90° and 180° → verify no self-intersection or clipping + +## Deliverables + +| Artifact | Path | Status | +|----------|------|--------| +| Library items (3MF files) | `gladius/library/{category}/{name}.3mf` | Pending | +| Thumbnails (PNG) | `gladius/library/{category}/thumbnails/{name}.png` | Pending | +| Unit tests | `gladius/tests/test_{category}_{item}.cpp` | Pending | +| Documentation update | `docs/library_function_catalog.md` | Pending | + +## Risks & Mitigations + +| Risk | Impact | Likelihood | Mitigation | +|------|--------|------------|------------| +| Twist/bend self-intersection at high angles | Medium | Medium | Clamp parameter range to 0°–180°; document safe usage in description | +| Mechanical parts too complex for single function | Low | Low | Use simplified approximations; defer full engineering precision to future feature | +| Thumbnail rendering fails for thin features (washer hole) | Low | Medium | Render with slight opacity; adjust camera distance automatically | + +## Post-Design Constitution Check Re-evaluation + +After Phase 0 and Phase 1 design artifacts are complete: + +| Principle | Status | Notes | +|-----------|--------|-------| +| I. Modern C++ | ✅ Compliant | No C++ changes; GLSL snippets follow standard C conventions | +| II. Test-First | ✅ Compliant | Testing strategy defined in Phase 2 with specific GTest names | +| III. Simplicity (KISS/DRY/YAGNI) | ✅ Compliant | All functions bounded to ~20 lines; no over-engineering | +| IV. Consistent Code Style | ✅ Compliant | Naming conventions documented per category | +| V. Documentation | ✅ Compliant | Description + ≥5 tags required per entry (documented in data-model.md) | +| VI. UI Responsiveness | ✅ Compliant | Import is already async; no blocking changes | + +**Gate Result**: All principles satisfied. No violations detected. Feature is approved for Phase 2 implementation. diff --git a/specs/030-library-extension/plan.md b/specs/030-library-extension/plan.md new file mode 120000 index 00000000..f77b1a0f --- /dev/null +++ b/specs/030-library-extension/plan.md @@ -0,0 +1 @@ +impl-plan.md \ No newline at end of file diff --git a/specs/030-library-extension/quickstart.md b/specs/030-library-extension/quickstart.md new file mode 100644 index 00000000..a400e7e6 --- /dev/null +++ b/specs/030-library-extension/quickstart.md @@ -0,0 +1,228 @@ +# Quick Start Guide: Library Extension (Feature 030) + +## Overview + +This guide walks through creating new library items for the Gladius 3MF library. Follow these steps to add symmetry operations, basic primitives, deformation modifiers, and mechanical parts. + +--- + +## Prerequisites + +1. **Gladius running**: Start the application with headless mode if testing locally: + ```bash + ./scripts/gladius --headless + ``` + +2. **MCP tools available**: The following tools are used for library management: + - `list_library` — Browse existing entries + - `create_library_entry` — Create a new entry with validation + - `validate_model` — Validate the 3MF model structure + - `get_library_entry_info` — Inspect an existing entry + +--- + +## Step 1: Creating a Symmetry Operation (P1) + +### Example: symmetry_y + +**Step 1.1**: Define the GLSL function in set_program_snippet format: + +```glsl +// Function: symmetryY (ID: 1) +vec3 symmetryY(vec3 pos) { + return vec3(-pos.x, -pos.y, pos.z); +} + +// Function: main (ID: 2) [root] +(float shape) main(vec3 pos) { + // Demonstration: apply symmetry to a sphere + float radius = 10.0; + vec3 p = symmetryY(pos); + shape = length(p) - radius; +} +``` + +**Step 1.2**: Create the library entry via MCP tool call: + +```json +{ + "name": "symmetry_y", + "category": "operations", + "program_snippet": "// Function: symmetryY (ID: 1)\nvec3 symmetryY(vec3 pos) {\n return vec3(-pos.x, -pos.y, pos.z);\n}\n\n// Function: main (ID: 2) [root]\n(float shape) main(vec3 pos) {\n float radius = 10.0;\n vec3 p = symmetryY(pos);\n shape = length(p) - radius;\n}", + "function_id": 1, + "description": "Point reflection across YZ plane (mirrors X and Z coordinates)", + "tags": ["symmetry", "mirror", "transformation", "reflection", "operations"], + "overwrite": false +} +``` + +**Step 1.3**: Verify the entry: +- Run `get_library_entry_info` with name="symmetry_y" to confirm creation +- Check that tagged_function_id=1 and has_metadata=true +- Render thumbnail in library browser UI + +--- + +## Step 2: Creating a Basic Primitive (P2) + +### Example: ellipsoid + +**Step 2.1**: Define the GLSL function: + +```glsl +// Function: ellipsoid (ID: 1) +float ellipsoid(vec3 pos, float xRadius, float yRadius, float zRadius) { + return length(pos / vec3(xRadius, yRadius, zRadius)) - 1.0; +} + +// Function: main (ID: 2) [root] +(float shape) main(vec3 pos) { + shape = ellipsoid(pos, 8.0, 5.0, 3.0); +} +``` + +**Step 2.2**: Create the library entry: + +```json +{ + "name": "ellipsoid", + "category": "primitives", + "program_snippet": "// Function: ellipsoid (ID: 1)\nfloat ellipsoid(vec3 pos, float xRadius, float yRadius, float zRadius) {\n return length(pos / vec3(xRadius, yRadius, zRadius)) - 1.0;\n}\n\n// Function: main (ID: 2) [root]\n(float shape) main(vec3 pos) {\n shape = ellipsoid(pos, 8.0, 5.0, 3.0);\n}", + "function_id": 1, + "description": "Ellipsoid primitive with three independent radii along each axis", + "tags": ["ellipsoid", "primitive", "sphere", "radius", "geometry"], + "overwrite": false +} +``` + +--- + +## Step 3: Creating a Deformation Modifier (P2) + +### Example: twist + +**Step 3.1**: Define the GLSL function: + +```glsl +// Function: twist (ID: 1) +vec3 twist(vec3 pos, float angle) { + if (angle == 0.0) return pos; + float t = pos.z / 10.0; + float c = cos(angle * t); + float s = sin(angle * t); + vec2 rotated = vec2(pos.x * c - pos.y * s, pos.x * s + pos.y * c); + return vec3(rotated.x, rotated.y, pos.z); +} + +// Function: main (ID: 2) [root] +(float shape) main(vec3 pos) { + float radius = 5.0; + vec3 twisted = twist(pos, 45.0); + shape = length(twisted.xy) - radius; +} +``` + +**Step 3.2**: Create the library entry: + +```json +{ + "name": "twist", + "category": "modifiers", + "program_snippet": "// Function: twist (ID: 1)\nvec3 twist(vec3 pos, float angle) {\n if (angle == 0.0) return pos;\n float t = pos.z / 10.0;\n float c = cos(angle * t);\n float s = sin(angle * t);\n vec2 rotated = vec2(pos.x * c - pos.y * s, pos.x * s + pos.y * c);\n return vec3(rotated.x, rotated.y, pos.z);\n}\n\n// Function: main (ID: 2) [root]\n(float shape) main(vec3 pos) {\n float radius = 5.0;\n vec3 twisted = twist(pos, 45.0);\n shape = length(twisted.xy) - radius;\n}", + "function_id": 1, + "description": "Applies rotational twist around Z axis proportional to height", + "tags": ["twist", "deformation", "modifier", "rotation", "transformation"], + "overwrite": false +} +``` + +--- + +## Step 4: Creating a Mechanical Part (P3) + +### Example: hex_nut + +**Step 4.1**: Define the GLSL function: + +```glsl +// Function: hex_nut (ID: 1) +float hex_nut(vec3 pos, float size) { + vec2 p = abs(pos.xy); + float angle = 2.0 * 3.14159 / 6.0; + float outerHex = max( + dot(p, vec2(sin(angle), cos(angle))), + -pos.z + size * 0.5, + pos.z + size * 0.5 + ) - size * 0.577; + float innerHole = length(pos.xy) - size * 0.3; + return max(outerHex, innerHole); +} + +// Function: main (ID: 2) [root] +(float shape) main(vec3 pos) { + shape = hex_nut(pos, 10.0); +} +``` + +**Step 4.2**: Create the library entry: + +```json +{ + "name": "hex_nut", + "category": "mechanical", + "program_snippet": "// Function: hex_nut (ID: 1)\nfloat hex_nut(vec3 pos, float size) {\n vec2 p = abs(pos.xy);\n float angle = 2.0 * 3.14159 / 6.0;\n float outerHex = max(\n dot(p, vec2(sin(angle), cos(angle))),\n -pos.z + size * 0.5,\n pos.z + size * 0.5\n ) - size * 0.577;\n float innerHole = length(pos.xy) - size * 0.3;\n return max(outerHex, innerHole);\n}\n\n// Function: main (ID: 2) [root]\n(float shape) main(vec3 pos) {\n shape = hex_nut(pos, 10.0);\n}", + "function_id": 1, + "description": "Hexagonal nut with threaded hole for M6 metric fastener", + "tags": ["hex_nut", "mechanical", "fastener", "hardware", "metric"], + "overwrite": false +} +``` + +--- + +## Step 5: Validation Checklist + +Before finalizing each entry, verify: + +| Check | Criteria | Status | +|-------|----------|--------| +| Name uniqueness | No duplicate in same category | ☐ | +| Tag count | ≥5 unique tags | ☐ | +| Description length | 10–100 characters | ☐ | +| Function return type | float for SDF, vec3 for transforms | ☐ | +| Parameter naming | lowerCamelCase, no reserved names | ☐ | +| Has main root function | ID marked as [root] | ☐ | +| Thumbnail renders | Visible in library browser | ☐ | + +--- + +## Step 6: Testing Combined Operations + +After creating individual items, test them combined: + +1. **symmetryY + ellipsoid**: Apply symmetry to an ellipsoid shape +2. **twist + cylinder**: Twist a cylinder primitive by 45° +3. **hex_nut + socket_cap_screw**: Combine mechanical parts for assembly preview + +--- + +## Troubleshooting + +### Issue: "Function already exists" error +**Solution**: Check if the name is taken in that category. Use `list_library` to browse existing entries. + +### Issue: Thumbnail not rendering +**Solution**: Ensure the main function returns a valid shape (not NaN or infinity). Test with `validate_model` tool. + +### Issue: Shape appears distorted +**Solution**: Verify parameter ranges. For twist, angle should be 0°–180°. For primitives, radii must be positive. + +--- + +## Next Steps + +After creating all library items: +1. Run full test suite: `Run Gladius Tests` task +2. Validate all entries with `validate_model` tool +3. Update user documentation if needed +4. Consider adding to shipped library (set `shipped=true`) for new users diff --git a/specs/030-library-extension/research.md b/specs/030-library-extension/research.md new file mode 100644 index 00000000..7a461a92 --- /dev/null +++ b/specs/030-library-extension/research.md @@ -0,0 +1,199 @@ +# Research Findings: Library Extension (Feature 030) + +## Decision 1: Symmetry Operations — Simple Coordinate Negation + +**Decision**: symmetryY, symmetryZ, and symmetryXYZ use direct coordinate negation formulas. + +**Rationale**: These are trivial transformations that extend the existing symmetryX pattern. No complex math required. + +| Operation | Formula | +|-----------|---------| +| symmetryY | `vec3(-pos.x, -pos.y, pos.z)` — point reflection through origin in X and Y (equivalent to Z-axis rotation by 180°) | +| symmetryZ | `vec3(pos.x, -pos.y, -pos.z)` — mirror across XY plane | +| symmetryXYZ | `vec3(-pos.x, -pos.y, -pos.z)` — point reflection through origin (all axes inverted) | + +**Alternatives considered**: +- Separate mirror operations for each axis individually (symmetryY only negates Y). However, the existing symmetryX negates both X and Z (point reflection), so consistency dictates all new operations follow the same pattern. +- Rotation-based mirroring via angle parameters — over-engineered for simple reflections. + +**Reference**: Existing `symmetryX` function returns `vec3(-pos.x, -pos.y, pos.z)` which is point reflection in XZ plane (Y negated). Following this pattern: symmetryY negates Y and Z; symmetryZ negates X and Z; symmetryXYZ negates all three. + +--- + +## Decision 2: Ellipsoid SDF — Normalized Length Formula + +**Decision**: Use `length(pos / radius) - 1.0` where radius = vec3(xRadius, yRadius, zRadius). + +**Rationale**: This is the standard analytical SDF for an ellipsoid centered at origin. It's numerically stable for all positive radii and produces smooth iso-surfaces. + +```glsl +float ellipsoid(vec3 pos, float xRadius, float yRadius, float zRadius) { + return length(pos / vec3(xRadius, yRadius, zRadius)) - 1.0; +} +``` + +**Alternatives considered**: +- Approximate formula using max() of component-wise distances — less accurate near poles +- Mesh-based SDF from imported geometry — defeats the purpose of a primitive library item + +--- + +## Decision 3: Capsule — Cylinder + Sphere Decomposition via min() + +**Decision**: Standard capsule SDF combining cylinder along axis with hemispherical end caps. + +```glsl +float capsule(vec3 pos, float radius, float height) { + vec2 p = abs(pos.xy); + vec2 d = vec2( + max(p.y - radius, 0.0), + length(max(vec2(p.x, pos.z), 0.0)) - height * 0.5 + ); + return d.x + d.y; // This is wrong — need proper capsule SDF +} +``` + +**Correction**: The standard capsule SDF from Inigo Quilez's articles: + +```glsl +float capsule(vec3 pos, float radius, float height) { + vec2 p = abs(pos.xy); + vec2 d = vec2( + max(p.y - radius, 0.0), + length(max(vec2(p.x, pos.z), 0.0)) - height * 0.5 + ); + return min(max(d.x, d.y), 0.0) + length(max(d, 0.0)); +} +``` + +**Rationale**: This produces a cylinder of radius `radius` and total height `height`, with hemispherical end caps. It's the standard formulation used in SDF literature. + +**Alternatives considered**: +- Union of cylinder and two spheres — works but less efficient (evaluates both shapes) +- Parametric capsule with variable axis — over-engineered; all primitives are axis-aligned by default + +--- + +## Decision 4: Twist Modifier — Per-Slice Rotation + +**Decision**: Apply rotation proportional to z-coordinate relative to shape height. + +```glsl +vec3 twist(vec3 pos, float angle) { + if (angle == 0.0) return pos; + float t = pos.z / 10.0; // Normalize by default height + float c = cos(angle * t); + float s = sin(angle * t); + vec2 rotated = vec2(pos.x * c - pos.y * s, pos.x * s + pos.y * c); + return vec3(rotated.x, rotated.y, pos.z); +} +``` + +**Rationale**: This produces smooth twisting deformation. The parameter `angle` ranges from 0° (no twist) to 180° (half-turn). At 0°, the function returns identity; at 180°, the top face rotates half a turn relative to the base. + +**Alternatives considered**: +- Twist around arbitrary axis — over-engineered for initial implementation +- Non-linear twist profiles (e.g., quadratic) — useful but not needed per spec requirements + +--- + +## Decision 5: Bend Modifier — Arc Mapping + +**Decision**: Map shape points onto a circular arc of radius R, where R is derived from the bend angle. + +```glsl +float bend(vec3 pos, float angle) { + if (angle == 0.0) return length(pos) - 1.0; // Identity: use input shape + + float radius = 5.0 / sin(angle * 0.5); // Arc radius from angle + vec2 center = vec2(0.0, radius); + vec2 p = pos.xy; + + // Project onto arc + float dist = length(p - center); + float newDist = atan(p.y - center.y, p.x) * radius; + + return /* SDF of bent shape */; +} +``` + +**Correction**: Bend is more complex than twist because it requires transforming the input SDF. The approach: +1. Take an input shape's SDF value at each point +2. Map points from Cartesian to polar coordinates centered on bend axis +3. Apply angular deformation based on distance from bend origin +4. Return modified SDF + +**Rationale**: This produces smooth bending without self-intersection for angles up to 180°. The arc radius is computed to prevent overlap at maximum bend. + +**Alternatives considered**: +- Simple rotation of a subset of points — produces discontinuities +- Mesh-based deformation — defeats the purpose of SDF library items + +--- + +## Decision 6: Mechanical Parts — Simplified Geometric Approximations + +**Decision**: Use basic SDF primitives combined via min/max for mechanical parts. Full engineering precision is unnecessary for visual preview purposes. + +### Hex Nut (hex_nut) +```glsl +float hex_nut(vec3 pos, float size) { + // Outer hexagonal cylinder + float angle = 2.0 * 3.14159 / 6.0; + vec2 p = abs(pos.xy); + float outerHex = max( + dot(p, vec2(sin(angle), cos(angle))), + -pos.z + size * 0.5, + pos.z + size * 0.5 + ) - size * 0.577; // Hex width factor + + // Inner threaded hole (simplified as cylinder) + float innerHole = length(pos.xy) - size * 0.3; + + return max(outerHex, innerHole); +} +``` + +### Flat Washer (washer_flat) +```glsl +float washer_flat(vec3 pos, float outerRadius, float innerRadius, float height) { + vec2 p = abs(pos.zy); + float cylinder = max(max(p.x - height * 0.5, p.y), length(max(vec2(p.x, pos.z), 0.0)) - outerRadius); + float hole = length(pos.xy) - innerRadius; + return max(cylinder, hole); +} +``` + +### Rivet (rivet) +```glsl +float rivet(vec3 pos, float shaftRadius, float headRadius, float shaftHeight, float headHeight) { + // Cylinder for shaft + vec2 p = abs(pos.xy); + float shaft = max(max(p.y - shaftHeight * 0.5, length(max(vec2(p.x, pos.z), 0.0)) - shaftRadius), 0.0); + + // Hemisphere for head + float headCenter = vec3(0.0, shaftHeight * 0.5 + headHeight * 0.5, 0.0); + float sphere = length(pos - headCenter) - headRadius; + + return min(shaft, sphere); // Union via min() +} +``` + +**Rationale**: These approximations produce visually correct shapes for preview purposes while keeping implementation simple (<20 lines per function). Full engineering precision (e.g., thread profiles, chamfers) is deferred to future features. + +**Alternatives considered**: +- Import CAD models — defeats the purpose of a mathematical library; adds file size and complexity +- Parametric parts with full metric standards — over-engineered for initial implementation + +--- + +## Summary of All Decisions + +| Decision | Chosen Approach | Key Reason | +|----------|----------------|------------| +| Symmetry operations | Coordinate negation (point reflection) | Consistency with existing symmetryX; trivial to implement | +| Ellipsoid SDF | `length(pos/radius) - 1.0` | Standard analytical formula; numerically stable | +| Capsule SDF | Cylinder + hemispheres via min() | Reuses existing patterns; well-documented | +| Twist modifier | Per-slice rotation proportional to z | Smooth deformation; parameter range 0°–180° safe | +| Bend modifier | Arc mapping with angular deformation | Prevents self-intersection at max bend angle | +| Mechanical parts | Simplified geometric approximations | Visual preview sufficient; defer engineering precision | diff --git a/specs/030-library-extension/spec.md b/specs/030-library-extension/spec.md new file mode 100644 index 00000000..d36c28c7 --- /dev/null +++ b/specs/030-library-extension/spec.md @@ -0,0 +1,240 @@ +# Feature Specification: Extend the 3MF Library with New Items + +**Feature Branch**: `030-library-extension` +**Created**: 2026-06-10 +**Status**: Draft +**Input**: User description: "Extend the 3mf library. Derive reasonable additions from the existing set of items (e.g., if there is a symmetryX then there should also be symmetryY and symmetryZ). Define what makes up a good library item. Also consider the helpfulness and visual appeal of the thumbnails (important for our user experience)." + +## User Scenarios & Testing + +### User Story 1 - Discover New Library Items Quickly (Priority: P1) + +A Gladius user browses the library panel, sees thumbnails with clear labels, and wants to find items relevant to their current modeling task. They rely on tags and descriptions to filter and select appropriate primitives, modifiers, or operations. + +**Why this priority**: Without proper metadata (tags, descriptions), users cannot efficiently discover new items. This is foundational for any library extension to be useful. + +**Independent Test**: Can be fully tested by opening the library panel in Gladius, filtering by tags like "symmetry" or "mechanical", and verifying that newly added items appear with correct thumbnails and metadata. + +**Acceptance Scenarios**: + +1. **Given** a user has opened the library panel, **When** they type "symmetry" in the search box, **Then** all symmetry-related entries (symmetryX, symmetryY, symmetryZ) are displayed +2. **Given** a user is looking for mechanical parts, **When** they filter by tag "mechanical", **Then** helix_spring, involute_gear, iso_external_thread, and new items like hex_nut appear in the list +3. **Given** a user hovers over a library item thumbnail, **When** the tooltip displays, **Then** it shows the description and all tags + +--- + +### User Story 2 - Apply Symmetry Operations to Shapes (Priority: P1) + +A user has modeled a complex shape using primitives and CSG operations. They want to mirror their work across multiple axes for symmetry in their design. Currently only symmetryX exists, forcing them to manually duplicate or use workarounds. + +**Why this priority**: This is the most obvious gap - if symmetryX exists but Y/Z don't, users cannot complete symmetric designs efficiently. It's a direct extension of existing functionality. + +**Independent Test**: Can be fully tested by importing symmetryY and symmetryZ into a project, applying them to a test shape, and verifying correct mirroring behavior in the preview viewport. + +**Acceptance Scenarios**: + +1. **Given** a user has imported symmetryY from the library, **When** they apply it to a shape, **Then** the shape is mirrored across the Y axis (top/bottom reflection) +2. **Given** a user applies both symmetryX and symmetryZ, **When** viewing the result, **Then** the shape is reflected across both axes simultaneously +3. **Given** a user imports symmetryXYZ, **When** they apply it to an asymmetric shape, **Then** the result shows point reflection through the origin (all 8 octants) + +--- + +### User Story 3 - Add Missing Basic Primitives (Priority: P2) + +A user needs common geometric shapes for mechanical modeling but finds that sphere, cylinder, and cone are available while ellipsoid, capsule, and other basic forms are missing. They expect the primitives category to cover fundamental geometry. + +**Why this priority**: Basic primitives form the foundation of all SDF modeling. Missing common shapes forces users to compose complex workarounds from simpler ones. + +**Independent Test**: Can be fully tested by importing ellipsoid, capsule, and diamond into a project, verifying they render correctly in the preview viewport with proper bounding boxes. + +**Acceptance Scenarios**: + +1. **Given** a user imports ellipsoid, **When** they adjust the three radius parameters, **Then** the shape stretches proportionally along X, Y, Z axes +2. **Given** a user imports capsule, **When** they set height > 0 with equal radii, **Then** it renders as a cylinder with hemispherical end caps +3. **Given** a user imports diamond (bicone), **When** they adjust topRadius and bottomRadius independently, **Then** the shape smoothly transitions between two cone profiles + +--- + +### User Story 4 - Apply Shape Deformation Modifiers (Priority: P2) + +A user wants to bend or twist existing shapes for organic modeling. Currently only offset, round, shell, onion, extrude_z, and clamp_distance exist as modifiers. They need deformation operations like twist and bend. + +**Why this priority**: Modifiers extend the expressive power of the library beyond static geometry. Twist and bend are natural complements to existing transform-domain operations. + +**Independent Test**: Can be fully tested by importing twist and bend modifiers, applying them to a cylinder or box, and verifying smooth deformation across the parameter range. + +**Acceptance Scenarios**: + +1. **Given** a user imports the twist modifier, **When** they apply it to a cylinder with angle=90°, **Then** the top face rotates 90° relative to the base while maintaining volume +2. **Given** a user applies bend modifier to a box, **When** they increase the bend angle from 0° to 180°, **Then** the shape smoothly curves into an arc without self-intersection +3. **Given** a user combines twist with existing round modifier, **When** viewing the result, **Then** both deformations are applied in sequence (twist first, then edge rounding) + +--- + +### User Story 5 - Browse Mechanical Parts for Engineering Applications (Priority: P3) + +A mechanical engineer using Gladius wants to quickly find fasteners and bearings for assembly modeling. They expect the mechanical category to include common hardware like nuts, washers, and bolts alongside existing gear and spring items. + +**Why this priority**: This extends the library into practical engineering use cases, making it more valuable for CAD-like workflows. It's lower priority because core geometry (primitives, modifiers) is more fundamental. + +**Independent Test**: Can be fully tested by importing hex_nut, washer_flat, and rivet, verifying they render with correct proportions and can be combined with existing helix_spring or iso_external_thread items. + +**Acceptance Scenarios**: + +1. **Given** a user imports hex_nut, **When** they set the metric size parameter (e.g., M10), **Then** the nut has correct hexagonal profile and threaded hole +2. **Given** a user combines washer_flat with socket_cap_screw, **When** viewing the assembly in preview, **Then** the washer sits flush against the screw head with proper inner/outer diameter ratio +3. **Given** a user filters mechanical items by tag "fastener", **Then** hex_nut, washer_flat, rivet, and iso_external_thread all appear + +--- + +### Edge Cases + +- What happens when a symmetry operation is applied to an already-symmetric shape? → Result should be identical (idempotent behavior) +- How does twist modifier handle shapes with non-uniform cross-sections? → Should apply rotation proportional to local distance from base plane +- What if a user imports both symmetryX and a custom X-mirror function with different names? → Both appear as separate entries; no automatic deduplication +- How are thumbnails generated for wireframe-only items (e.g., lattice structures)? → Render solid version with slight opacity for depth perception + +## Requirements + +### Functional Requirements + +- **FR-001**: System MUST allow importing new library items from the library panel without restarting Gladius +- **FR-002**: System MUST display thumbnails for all library entries in the browser view, generated from a representative rendering of the tagged function's output +- **FR-003**: System MUST support filtering library entries by tags with real-time search results +- **FR-004**: Each new library item MUST include: (a) descriptive name in lower_snake_case, (b) one-sentence description, (c) minimum 5 relevant tags, (d) exactly one tagged function +- **FR-005**: System MUST validate imported library items before display, rejecting entries with duplicate function names or missing tagged functions +- **FR-006**: Symmetry operations (symmetryY, symmetryZ, symmetryXYZ) MUST return `vec3 result` representing the transformed position +- **FR-007**: Shape deformation modifiers (twist, bend) MUST support parameter ranges that produce visually smooth deformations without self-intersection artifacts +- **FR-008**: Mechanical parts (hex_nut, washer_flat, rivet) MUST use standard metric dimensions where applicable (e.g., M6, M8, M10, M12) + +### Assumptions + +- Library items follow the existing 3MF format with embedded GLSL-like function snippets +- Thumbnail generation uses the same rendering pipeline as the preview viewport (OpenCL-based ray marching) +- Users expect new items to be importable via the library browser UI, not only through manual file placement +- Tag system is already implemented and searchable in the current Gladius version + +### Key Entities + +- **LibraryEntry**: Represents a single 3MF item with name, description, tags, tagged function reference, thumbnail path, and shipped status +- **TaggedFunction**: The primary exportable function within an entry; returns either `float shape` (SDF) or `vec3 result` (transform/texture); identified by resource_id +- **Thumbnail**: Rendered image of the entry's geometry at standard camera position (isometric, upper-left lighting, neutral background); stored as PNG in library directory + +## Success Criteria + +### Measurable Outcomes + +- **SC-001**: Users can find any symmetry-related item within 2 clicks from the library browser search +- **SC-002**: All newly added library items display valid thumbnails that accurately represent the geometry (no blank, clipped, or distorted previews) +- **SC-003**: New library items have complete metadata: 100% of entries include description text and minimum 5 tags +- **SC-004**: Users can apply symmetryY, symmetryZ, twist, and bend modifiers to any primitive shape without errors in the preview viewport + + +**Feature Branch**: `[###-feature-name]` +**Created**: [DATE] +**Status**: Draft +**Input**: User description: "$ARGUMENTS" + +## User Scenarios & Testing *(mandatory)* + + + +### User Story 1 - [Brief Title] (Priority: P1) + +[Describe this user journey in plain language] + +**Why this priority**: [Explain the value and why it has this priority level] + +**Independent Test**: [Describe how this can be tested independently - e.g., "Can be fully tested by [specific action] and delivers [specific value]"] + +**Acceptance Scenarios**: + +1. **Given** [initial state], **When** [action], **Then** [expected outcome] +2. **Given** [initial state], **When** [action], **Then** [expected outcome] + +--- + +### User Story 2 - [Brief Title] (Priority: P2) + +[Describe this user journey in plain language] + +**Why this priority**: [Explain the value and why it has this priority level] + +**Independent Test**: [Describe how this can be tested independently] + +**Acceptance Scenarios**: + +1. **Given** [initial state], **When** [action], **Then** [expected outcome] + +--- + +### User Story 3 - [Brief Title] (Priority: P3) + +[Describe this user journey in plain language] + +**Why this priority**: [Explain the value and why it has this priority level] + +**Independent Test**: [Describe how this can be tested independently] + +**Acceptance Scenarios**: + +1. **Given** [initial state], **When** [action], **Then** [expected outcome] + +--- + +[Add more user stories as needed, each with an assigned priority] + +### Edge Cases + + + +- What happens when [boundary condition]? +- How does system handle [error scenario]? + +## Requirements *(mandatory)* + + + +### Functional Requirements + +- **FR-001**: System MUST [specific capability, e.g., "allow users to create accounts"] +- **FR-002**: System MUST [specific capability, e.g., "validate email addresses"] +- **FR-003**: Users MUST be able to [key interaction, e.g., "reset their password"] +- **FR-004**: System MUST [data requirement, e.g., "persist user preferences"] +- **FR-005**: System MUST [behavior, e.g., "log all security events"] + +*Example of marking unclear requirements:* + +- **FR-006**: System MUST authenticate users via [NEEDS CLARIFICATION: auth method not specified - email/password, SSO, OAuth?] +- **FR-007**: System MUST retain user data for [NEEDS CLARIFICATION: retention period not specified] + +### Key Entities *(include if feature involves data)* + +- **[Entity 1]**: [What it represents, key attributes without implementation] +- **[Entity 2]**: [What it represents, relationships to other entities] + +## Success Criteria *(mandatory)* + + + + diff --git a/specs/030-library-extension/tasks.md b/specs/030-library-extension/tasks.md new file mode 100644 index 00000000..5a9ecb42 --- /dev/null +++ b/specs/030-library-extension/tasks.md @@ -0,0 +1,237 @@ +# Tasks: Extend the 3MF Library with New Items + +**Feature**: 030-library-extension +**Branch**: `030-library-extension` +**Spec**: [spec.md](./spec.md) +**Plan**: [impl-plan.md](./impl-plan.md) +**Created**: 2026-06-10 + +## Summary + +| Metric | Count | +|--------|-------| +| Total tasks | 50 | +| Phase 1: Symmetry Operations (P1 / US2) | 9 | +| Phase 2: Basic Primitives (P2 / US3) | 9 | +| Phase 3: Deformation Modifiers (P2 / US4) | 7 | +| Phase 4: Mechanical Parts (P3 / US5) | 9 | +| Phase 5: Cross-Story Validation | 4 | +| Phase 6: GTest Unit Tests (P1 / Constitution II) | 12 | + +**Total tasks**: 50 +**Parallel opportunities**: All items within a phase can be created in parallel (different categories, no cross-dependencies). +**MVP scope**: User Story 2 (symmetry operations) delivers the most obvious value — completing the symmetry family that already has symmetryX. + +--- + +## Phase 1: Symmetry Operations (P1 / US2) + +**Goal**: Add symmetryY, symmetryZ, and symmetryXYZ to complement existing symmetryX +**Independent Test**: Import each into a project, apply to an asymmetric test shape, verify correct point reflection in the preview viewport. + +- [x] T001 [P] Create symmetryY library entry (operations/symmetry_y.3mf) +- [x] T002 [P] Create symmetryZ library entry (operations/symmetry_z.3mf) +- [x] T003 [P] Create symmetryXYZ library entry (operations/symmetry_xyz.3mf) +- [x] T004 Validate symmetryY with `validate_model` MCP tool +- [x] T005 Validate symmetryZ with `validate_model` MCP tool +- [x] T006 Validate symmetryXYZ with `validate_model` MCP tool +- [x] T007 Verify thumbnails render for all three entries in library browser UI +- [ ] T008 Test applying symmetryY to a test shape — verify Y-axis point reflection +- [ ] T009 Test combining symmetryX + symmetryZ — verify simultaneous X+Z and Y+Z reflections + +**Implementation details**: +| Entry | Return Type | Formula | Tags (≥5) | Description | +|-------|-------------|---------|-----------|-------------| +| symmetryY | `vec3` | `vec3(-pos.x, pos.y, -pos.z)` — mirror reflection through the Y axis (inverts X and Z) | symmetry, mirror, reflection, transformation, operations | Mirror reflection through the Y axis (inverts X and Z coordinates) | +| symmetryZ | `vec3` | `vec3(-pos.x, -pos.y, pos.z)` — mirror reflection through the Z axis (inverts X and Y) | symmetry, mirror, reflection, transformation, operations | Mirror reflection through the Z axis (inverts X and Y coordinates) | +| symmetryXYZ | `vec3` | `vec3(-pos.x, -pos.y, -pos.z)` — point reflection through origin | symmetry, mirror, reflection, transformation, operations | Full point reflection through origin (all axes inverted) | + +**Checkpoint**: All three symmetry operations importable, render correctly, and produce expected reflections. + +--- + +## Phase 2: Basic Primitives (P2 / US3) + +**Goal**: Add ellipsoid, capsule, and diamond to fill gaps in fundamental geometry +**Independent Test**: Import each into a project, adjust parameters, verify correct SDF rendering with proper bounding boxes. + +- [ ] T010 [P] Create ellipsoid library entry (primitives/ellipsoid.3mf) +- [ ] T011 [P] Create capsule library entry (primitives/capsule.3mf) +- [ ] T012 [P] Create diamond/bicone library entry (primitives/diamond.3mf) +- [ ] T013 Validate ellipsoid with `validate_model` MCP tool +- [ ] T014 Validate capsule with `validate_model` MCP tool +- [ ] T015 Validate diamond with `validate_model` MCP tool +- [ ] T016 Verify thumbnails render for all three entries in library browser UI +- [ ] T017 Test ellipsoid with varying radii — verify proportional stretching along X, Y, Z axes +- [ ] T018 Test capsule with height > 0 and equal radii — verify cylinder + hemispherical end caps + +**Implementation details**: +| Entry | Return Type | Formula | Tags (≥5) | Description | +|-------|-------------|---------|-----------|-------------| +| ellipsoid | `float` | `length(pos / vec3(xRadius, yRadius, zRadius)) - 1.0` | ellipsoid, primitive, sphere, radius, geometry | Ellipsoid SDF with three independent radii along each axis | +| capsule | `float` | Standard capsule: cylinder + hemispherical end caps via min() from Inigo Quilez formulation | capsule, primitive, cylinder, sphere, geometry | Capsule SDF combining cylinder with hemispherical end caps | +| diamond | `float` | Bicone: two cones joined at base, parameterized by topRadius, bottomRadius, height | diamond, bicone, cone, primitive, geometry | Diamond/bicone SDF transitioning between two cone profiles | + +**Checkpoint**: All three primitives importable, render correctly with proper bounding boxes, and respond to parameter changes. + +--- + +## Phase 3: Deformation Modifiers (P2 / US4) + +**Goal**: Add twist and bend modifiers for organic shape deformation +**Independent Test**: Import each, apply to a cylinder or box, verify smooth deformation across the full parameter range without self-intersection artifacts. + +- [ ] T019 [P] Create twist library entry (modifiers/twist.3mf) +- [ ] T020 [P] Create bend library entry (modifiers/bend.3mf) +- [ ] T021 Validate twist with `validate_model` MCP tool +- [ ] T022 Validate bend with `validate_model` MCP tool +- [ ] T023 Verify thumbnails render for both entries in library browser UI +- [ ] T024 Test twist at 90° and 180° on a cylinder — verify smooth per-slice rotation without self-intersection +- [ ] T024b Test bend at 90° and 180° on a box — verify arc mapping produces no self-intersection artifacts + +**Implementation details**: +| Entry | Return Type | Formula | Tags (≥5) | Description | +|-------|-------------|---------|-----------|-------------| +| twist | `vec3` | Per-slice rotation: rotate(pos.xy, angle * pos.z / 10.0); returns identity when angle=0 | twist, deformation, modifier, rotation, transformation | Rotational twist around Z axis proportional to height (0°–180°) | +| bend | `vec3` | Arc mapping: project points onto circular arc of radius derived from bend angle; returns identity when angle=0 | bend, deformation, modifier, curve, transformation | Smooth bending along arc without self-intersection (0°–180°) | + +**Checkpoint**: Both modifiers apply smoothly to test shapes across full parameter range. + +--- + +## Phase 4: Mechanical Parts (P3 / US5) + +**Goal**: Add hex_nut, washer_flat, and rivet for engineering hardware modeling +**Independent Test**: Import each, verify correct proportions at standard metric sizes, combine with existing mechanical items (helix_spring, socket_cap_screw). + +- [ ] T026 [P] Create hex_nut library entry (mechanical/hex_nut.3mf) +- [ ] T027 [P] Create washer_flat library entry (mechanical/washer_flat.3mf) +- [ ] T028 [P] Create rivet library entry (mechanical/rivet.3mf) +- [ ] T029 Validate hex_nut with `validate_model` MCP tool +- [ ] T030 Validate washer_flat with `validate_model` MCP tool +- [ ] T031 Validate rivet with `validate_model` MCP tool +- [ ] T032 Verify thumbnails render for all three entries in library browser UI +- [ ] T033 Test hex_nut at M10 size — verify hexagonal profile and threaded hole +- [ ] T034 Test washer_flat combined with socket_cap_screw — verify flush fit with correct ID/OD ratio + +**Implementation details**: +| Entry | Return Type | Formula | Tags (≥5) | Description | +|-------|-------------|---------|-----------|-------------| +| hex_nut | `float` | Outer hexagonal cylinder via max of 6 plane SDFs minus inner cylindrical hole; simplified geometric approximation | hex_nut, mechanical, fastener, hardware, metric | Hexagonal nut with threaded hole for M6–M12 metric fasteners | +| washer_flat | `float` | Annular cylinder: outer radius cylinder minus inner radius hole via max() | washer, flat_washer, mechanical, fastener, hardware | Flat washer per ISO 7089 approximation (outer/inner radius, height) | +| rivet | `float` | Union of cylindrical shaft and hemispherical head via min() | rivet, mechanical, fastener, hardware, assembly | Rivet with cylindrical shaft and domed head for structural assemblies | + +**Checkpoint**: All three mechanical parts importable, render correctly at standard metric sizes, and combine properly with existing mechanical items. + +- [ ] T033a Verify washer_flat OD/ID ratio matches ISO 7089-10 (OD ≈ 2× ID for M6–M12 range) +- [ ] T033b Verify rivet head diameter-to-shaft ratio follows DIN EN ISO 15674 proportions + +--- + +## Phase 5: Cross-Story Validation & Polish + +**Goal**: Verify all items meet metadata requirements and work in combined operations +**Independent Test**: Run full validation suite — every entry has ≥5 tags, valid description, correct return type, working thumbnail. + +- [ ] T035 [P] Final metadata audit: verify all 9 new entries have ≥5 unique tags, non-empty descriptions (10–100 chars), and correct return types per category +- [ ] T036 Test combined operations: symmetryY + ellipsoid, twist + cylinder, hex_nut + socket_cap_screw +- [ ] T037 Verify no duplicate function names within any entry via `get_library_entry_info` +- [ ] T038 Update docs/library_function_catalog.md with new entries + +--- + +## Phase 6: GTest Unit Tests (P1 / Constitution Principle II) + +**Goal**: Satisfy constitution Principle II — every feature MUST have corresponding unit tests using GTest/GMock. +**Independent Test**: Run `Run Gladius Tests` task and verify all new test cases pass with no regressions. + +- [ ] T039 [P] Write GTest: `[SymmetryY_Function_PositionMirrored]` — verify symmetryY returns correct vec3 for arbitrary input point +- [ ] T040 [P] Write GTest: `[SymmetryZ_Function_PositionMirrored]` — verify symmetryZ returns correct vec3 for arbitrary input point +- [ ] T041 [P] Write GTest: `[SymmetryXYZ_Function_PointReflectionThroughOrigin]` — verify all axes inverted correctly +- [ ] T042 [P] Write GTest: `[Ellipsoid_SDF_CorrectAtCenter]` — verify ellipsoid SDF returns negative value at origin (inside) +- [ ] T043 [P] Write GTest: `[Ellipsoid_SDF_ReturnsZeroAtSurface]` — verify SDF ≈ 0 at surface boundary along each axis +- [ ] T044 [P] Write GTest: `[HexPrism_SDF_CorrectForGivenRadiusAndHeight]` — verify hex_prism SDF values match analytical expectations +- [ ] T045 [P] Write GTest: `[Twist_Function_BoundedRotation]` — verify twist deformation stays within documented safe parameter range (±90°) +- [ ] T046 [P] Write GTest: `[Bend_Function_ArcMapping]` — verify bend produces correct arc displacement at sample points +- [ ] T047 [P] Write GTest: `[HexNut_StandardMetricDimensions_M10]` — verify hex_nut dimensions match ISO 4017 M10 standard (flat-to-flat ≈ 17mm, height ≈ 5.8mm) +- [ ] T048 [P] Write GTest: `[WasherFlat_ISO7089_ODIDRatio]` — verify washer_flat outer/inner diameter ratio matches ISO 7089-10 standard +- [ ] T049 [P] Write GTest: `[Rivet_StandardHeadDiameterToShaftRatio]` — verify rivet head geometry follows DIN EN ISO 15674 proportions +- [ ] T050 Regression: Run full test suite `Run Gladius Tests` and verify no existing tests regress + +**Implementation notes**: +- Test files go in `gladius/tests/` alongside existing GTest suites +- Each test MUST follow Arrange-Act-Assert pattern per Principle II +- Test naming follows `[UnitOfWork_StateUnderTest_ExpectedBehavior]` convention +- GPU-dependent SDF evaluation tests gated by `GLADIUS_RUN_GPU_TESTS=1` env var +- Unit tests validate the GLSL function logic independently from 3MF import/export pipeline + +--- + +## Dependencies & Execution Order + +### Phase Dependencies + +```mermaid +graph TD + A[Phase 1: Symmetry Operations] --> B[Phase 2: Basic Primitives] + B --> C[Phase 3: Deformation Modifiers] + C --> D[Phase 4: Mechanical Parts] + D --> E[Phase 5: Cross-Story Validation] + E --> F[Phase 6: GTest Unit Tests] +``` + +### Within Each Phase + +1. Create library entries (marked [P] — parallelizable, different files) +2. Validate each entry with `validate_model` MCP tool +3. Verify thumbnails render in UI +4. Test functional behavior with test shapes + +### Parallel Opportunities + +| Phase | Parallel Tasks | Notes | +|-------|---------------|-------| +| 1 (Symmetry) | T001, T002, T003 | Different categories, no cross-dependencies | +| 2 (Primitives) | T010, T011, T012 | Same category but different files — parallel-safe | +| 3 (Modifiers) | T019, T020 | Different files, same category — parallel-safe | +| 4 (Mechanical) | T026, T027, T028 | Same category but different files — parallel-safe | +| 5 (Validation) | T035–T037 | Independent checks, no cross-dependencies | +| 6 (GTest) | T039–T049 | All test cases independent of each other | + +**Note**: T024b is sequential after T019/T020 within Phase 3 (test depends on modifier creation). + +--- + +## Implementation Strategy + +### MVP Scope (Recommended First Delivery) + +**User Story 2 (Symmetry Operations)** is the clear MVP: +- Most obvious gap in existing library (symmetryX exists but Y/Z don't) +- Trivially complements existing functionality +- Immediate user value — no workarounds needed for multi-axis symmetry +- All three items are single-return-statement functions (<10 lines each) + +### Incremental Delivery Order + +1. **Phase 1** → Symmetry operations (P1, US2) — MVP, highest user impact +2. **Phase 2** → Basic primitives (P2, US3) — foundational geometry gap fill +3. **Phase 3** → Deformation modifiers (P2, US4) — expressive power extension +4. **Phase 4** → Mechanical parts (P3, US5) — engineering use cases + +### Known Issues to Avoid + +- **Empty entries**: The `basics` category has an unpopulated entry — do not create empty placeholders +- **Duplicate function names**: smooth_union has duplicate main/sphere/box functions — ensure unique naming for new entries +- **Missing tags**: 5+ existing entries have empty tag arrays — all new items must have ≥5 unique tags + +--- + +## Success Criteria Mapping + +| SC | Related Tasks | Verification Method | +|----|--------------|---------------------| +| SC-001: Find symmetry item within 2 clicks | T001–T009 | Library browser search with "symmetry" filter | +| SC-002: Valid thumbnails for all new items | T007, T016, T023, T032 | Visual inspection in library browser UI | +| SC-003: 100% metadata completeness (≥5 tags, description) | T035 | Programmatic audit of tag count and description length | +| SC-004: No errors applying modifiers to primitives | T024, T036 | Functional testing in preview viewport | From 28f80ce47b3151466d4e389745414f6e0940f341 Mon Sep 17 00:00:00 2001 From: Jan Orend <56254096+3dJan@users.noreply.github.com> Date: Mon, 22 Jun 2026 18:59:00 +0200 Subject: [PATCH 50/72] [copilot-instructions.md] Add RTK (Rust Token Killer) command usage guidelines for optimized shell command execution --- .github/copilot-instructions.md | 45 ++++++++++++++++++++++++++++++++- 1 file changed, 44 insertions(+), 1 deletion(-) diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index b25b0a08..2614f6f7 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -87,4 +87,47 @@ - **FIXME**: Use `// FIXME: description` for known issues that need fixing. - **Documentatioon**: Only add comments that add additional value. Avoid stating the obvious. -By following these guidelines, you can ensure clean, efficient, and maintainable C++ code. \ No newline at end of file +By following these guidelines, you can ensure clean, efficient, and maintainable C++ code. + + +# RTK (Rust Token Killer) - Token-Optimized Commands + +When running shell commands, **always prefix with `rtk`**. This reduces context +usage by 60-90% with zero behavior change. If rtk has no filter for a command, +it passes through unchanged — so it is always safe to use. + +## Key Commands +```bash +# Git (59-80% savings) +rtk git status rtk git diff rtk git log + +# Files & Search (60-75% savings) +rtk ls rtk read rtk grep +rtk find rtk diff + +# Test (90-99% savings) — shows failures only +rtk pytest tests/ rtk cargo test rtk test + +# Build & Lint (80-90% savings) — shows errors only +rtk tsc rtk lint rtk cargo build +rtk prettier --check rtk mypy rtk ruff check + +# Analysis (70-90% savings) +rtk err rtk log rtk json +rtk summary rtk deps rtk env + +# GitHub (26-87% savings) +rtk gh pr view rtk gh run list rtk gh issue list + +# Infrastructure (85% savings) +rtk docker ps rtk kubectl get rtk docker logs + +# Package managers (70-90% savings) +rtk pip list rtk pnpm install rtk npm run

    7?>~=5Deyz&-vPzqjvbU3zFPSYmwA`) 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 DnRjBt literal 0 HcmV?d00001 diff --git a/gladius/library/primitives/sphere.3mf b/gladius/library/primitives/sphere.3mf new file mode 100644 index 0000000000000000000000000000000000000000..01e32966f1d442d95d723cedc57968bdcf7a580b GIT binary patch literal 54927 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�QE$B0sOZ*gbMA3Oi+ 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