diff --git a/.gitmodules b/.gitmodules index 58b0c0c488..c9de63fb37 100755 --- a/.gitmodules +++ b/.gitmodules @@ -115,3 +115,6 @@ [submodule "3rdparty/opengametools"] path = 3rdparty/opengametools url = https://github.com/jpaver/opengametools +[submodule "3rdparty/OffsetAllocator"] + path = 3rdparty/OffsetAllocator + url = https://github.com/sebbbi/OffsetAllocator diff --git a/3rdparty/OffsetAllocator b/3rdparty/OffsetAllocator new file mode 160000 index 0000000000..3610a73770 --- /dev/null +++ b/3rdparty/OffsetAllocator @@ -0,0 +1 @@ +Subproject commit 3610a7377088b1e8c8f1525f458c96038a4e6fc0 diff --git a/src/app/main.cpp b/src/app/main.cpp index 8bf9eb1da3..e4e0f6cd90 100644 --- a/src/app/main.cpp +++ b/src/app/main.cpp @@ -254,6 +254,9 @@ static void setup_x11(int argc, char** argv) helper_dylibs.run_under_x11 = true; helper_dylibs.xwayland = wayland; + + // EGL is the only way to get zero-copy with dma-buf import + qputenv("QT_XCB_GL_INTEGRATION", "xcb_egl"); } } }; @@ -499,6 +502,7 @@ static void setup_opengl(bool& enable_opengl_ui) #ifndef QT_NO_OPENGL #if (defined(__arm__) || defined(__aarch64__)) && !defined(_WIN32) && !defined(__APPLE__) + // Raspberry Pi & such QSurfaceFormat fmt = QSurfaceFormat::defaultFormat(); fmt.setRenderableType(QSurfaceFormat::OpenGLES); fmt.setSwapInterval(1); @@ -514,6 +518,7 @@ static void setup_opengl(bool& enable_opengl_ui) fmt.setDefaultFormat(fmt); #else { + // Desktop GL std::vector> versions_to_test = {{4, 6}, {4, 5}, {4, 4}, {4, 3}, {4, 2}, {4, 1}, {4, 0}, {3, 3}, {3, 2}, {3, 1}, {3, 0}, {2, 1}, {2, 0}}; @@ -552,7 +557,9 @@ static void setup_opengl(bool& enable_opengl_ui) QSurfaceFormat fmt = QSurfaceFormat::defaultFormat(); fmt.setProfile(QSurfaceFormat::CoreProfile); + fmt.setRenderableType(QSurfaceFormat::OpenGL); fmt.setSwapInterval(1); + fmt.setStencilBufferSize(8); bool ok = false; for(auto [maj, min] : versions_to_test) { diff --git a/src/plugins/score-lib-process/Process/Drop/ProcessDropHandler.cpp b/src/plugins/score-lib-process/Process/Drop/ProcessDropHandler.cpp index c7dd2177d3..14f947b01c 100644 --- a/src/plugins/score-lib-process/Process/Drop/ProcessDropHandler.cpp +++ b/src/plugins/score-lib-process/Process/Drop/ProcessDropHandler.cpp @@ -4,6 +4,7 @@ #include +#include #include #include #include @@ -19,8 +20,22 @@ void ProcessDropHandler::getCustomDrops( std::vector& drops, const QMimeData& mime, const score::DocumentContext& ctx) const noexcept { - // Check for special mime handling code - return dropCustom(drops, mime, ctx); + // dropCustom is no longer noexcept (some overrides invoke parsers that + // can throw on malformed input — see ProcessDropHandler.hpp). Catch + // here so a throwing handler never escapes through the noexcept + // public API and tears down the editor. + try + { + dropCustom(drops, mime, ctx); + } + catch(const std::exception& e) + { + qWarning() << "ProcessDropHandler::dropCustom threw:" << e.what(); + } + catch(...) + { + qWarning() << "ProcessDropHandler::dropCustom threw an unknown exception"; + } } void ProcessDropHandler::getMimeDrops( @@ -61,7 +76,7 @@ QSet ProcessDropHandler::fileExtensions() const noexcept void ProcessDropHandler::dropCustom( std::vector&, const QMimeData& data, - const score::DocumentContext& ctx) const noexcept + const score::DocumentContext& ctx) const { } diff --git a/src/plugins/score-lib-process/Process/Drop/ProcessDropHandler.hpp b/src/plugins/score-lib-process/Process/Drop/ProcessDropHandler.hpp index 4f568657a1..a712f06016 100644 --- a/src/plugins/score-lib-process/Process/Drop/ProcessDropHandler.hpp +++ b/src/plugins/score-lib-process/Process/Drop/ProcessDropHandler.hpp @@ -59,7 +59,7 @@ class SCORE_LIB_PROCESS_EXPORT ProcessDropHandler : public score::InterfaceBase protected: virtual void dropCustom( std::vector& drops, const QMimeData& mime, - const score::DocumentContext& ctx) const noexcept; + const score::DocumentContext& ctx) const; virtual void dropPath( std::vector& drops, const score::FilePath& path, diff --git a/src/plugins/score-plugin-avnd/Crousti/Concepts.hpp b/src/plugins/score-plugin-avnd/Crousti/Concepts.hpp index 06905031fb..2b17f5ad2b 100644 --- a/src/plugins/score-plugin-avnd/Crousti/Concepts.hpp +++ b/src/plugins/score-plugin-avnd/Crousti/Concepts.hpp @@ -448,7 +448,35 @@ make_control_in(avnd::field_index, Id&& id, QObject* parent) auto [Mx, My, Mz] = c.max; auto [ix, iy, iz] = c.init; return new Process::XYZSpinboxes{{mx, my, mz}, {Mx, My, Mz}, {ix, iy, iz}, - qname, id, parent}; + false, qname, id, parent}; + } + } + else if constexpr(widg.widget == avnd::widget_type::xyzw_spinbox) + { + static constexpr auto c = avnd::get_range(); + if constexpr(requires { + c.min == 0.f; + c.max == 0.f; + c.init == 0.f; + }) + { + return new Process::XYZSpinboxes{ + {c.min, c.min, c.min}, + {c.max, c.max, c.max}, + {c.init, c.init, c.init}, + false, + qname, + id, + parent}; + } + else + { + auto [mx, my, mz, mw] = c.min; + auto [Mx, My, Mz, Mw] = c.max; + auto [ix, iy, iz, iw] = c.init; + // FIXME we don't have a good 4-way widget + return new Process::XYZSpinboxes{{mx, my, mz}, {Mx, My, Mz}, {ix, iy, iz}, + false, qname, id, parent}; } } else if constexpr(widg.widget == avnd::widget_type::color) diff --git a/src/plugins/score-plugin-avnd/Crousti/CpuAnalysisNode.hpp b/src/plugins/score-plugin-avnd/Crousti/CpuAnalysisNode.hpp index 3f049ab18a..e0e1035b29 100644 --- a/src/plugins/score-plugin-avnd/Crousti/CpuAnalysisNode.hpp +++ b/src/plugins/score-plugin-avnd/Crousti/CpuAnalysisNode.hpp @@ -5,10 +5,10 @@ namespace oscr { - template requires( - (avnd::texture_output_introspection::size + avnd::buffer_output_introspection::size + avnd::geometry_output_introspection::size) == 0 + (avnd::texture_output_introspection::size + avnd::buffer_output_introspection::size + avnd::geometry_output_introspection::size + scene_output_introspection::size) == 0 + && (avnd::gpu_render_target_output_port_output_introspection::size == 0) ) struct GfxRenderer final : score::gfx::OutputNodeRenderer { @@ -19,6 +19,7 @@ struct GfxRenderer final : score::gfx::OutputNodeRenderer AVND_NO_UNIQUE_ADDRESS texture_inputs_storage texture_ins; AVND_NO_UNIQUE_ADDRESS buffer_inputs_storage buffer_ins; AVND_NO_UNIQUE_ADDRESS geometry_inputs_storage geometry_ins; + AVND_NO_UNIQUE_ADDRESS scene_inputs_storage scene_ins; const GfxNode& node() const noexcept { @@ -44,9 +45,19 @@ struct GfxRenderer final : score::gfx::OutputNodeRenderer return {}; } - void init(score::gfx::RenderList& renderer, QRhiResourceUpdateBatch& res) override + // See CpuFilterNode.hpp for the reasoning: init must live in initState + // so the incremental edge-rewire path also runs it. + void initState(score::gfx::RenderList& renderer, QRhiResourceUpdateBatch& res) override { - auto& parent = node(); + if(m_initialized) + return; + + // See CpuFilterNode for the reasoning: optional renderlist + // backchannel populated via SFINAE so nodes can reach the + // RenderList's GpuResourceRegistry / AssetTable without plumbing. + if constexpr(requires { state->renderlist = &renderer; }) + state->renderlist = &renderer; + if constexpr(requires { state->prepare(); }) { this->node().processControlIn( @@ -59,6 +70,13 @@ struct GfxRenderer final : score::gfx::OutputNodeRenderer texture_ins.init(*this, renderer); if_possible(state->init(renderer, res)); + + m_initialized = true; + } + + void init(score::gfx::RenderList& renderer, QRhiResourceUpdateBatch& res) override + { + initState(renderer, res); } void update( @@ -82,32 +100,69 @@ struct GfxRenderer final : score::gfx::OutputNodeRenderer } } - void release(score::gfx::RenderList& r) override + void releaseState(score::gfx::RenderList& r) override { + if(!m_initialized) + return; + if constexpr(avnd::texture_input_introspection::size > 0) texture_ins.release(); if constexpr(avnd::geometry_input_introspection::size > 0) geometry_ins.release(r); + if constexpr(scene_input_introspection::size > 0) + scene_ins.release(r); + if constexpr( avnd::texture_input_introspection::size > 0 || avnd::texture_output_introspection::size > 0) { - // FIXME this->defaultRelease(r); + // No call-through to GenericNodeRenderer::defaultRelease here: + // CpuAnalysisNode's GfxRenderer derives from OutputNodeRenderer, + // not GenericNodeRenderer, and OutputNodeRenderer has no + // defaultRelease equivalent (it owns no pipeline / passes — it + // is a sink, not a node renderer with m_p / m_pipelineCache). + // CpuFilterNode's mirror at line ~357 IS valid because that + // GfxRenderer derives from GenericNodeRenderer. + // + // If a future CpuAnalysisNode uses textures via OutputNodeRenderer + // surfaces, they'll need their own per-storage release path + // (texture_ins.release above already handles texture INPUTS). } if_possible(state->release(r)); + + // Clear the optional renderlist backchannel. Paired with initState; + // same SFINAE guard. + if constexpr(requires { state->renderlist = nullptr; }) + state->renderlist = nullptr; + + m_initialized = false; + } + + void release(score::gfx::RenderList& r) override + { + releaseState(r); } void inputAboutToFinish( score::gfx::RenderList& renderer, const score::gfx::Port& p, QRhiResourceUpdateBatch*& res) override { + // Outer guard includes scene_input_introspection so a node with ONLY + // scene inputs (no texture / buffer / geometry) still allocates `res` + // — necessary if scene_inputs_storage ever grows an inputAboutToFinish + // method (today it's read-only via readInputScenes, but the storage's + // lifecycle is part of the new scene_port concept and may evolve). + // Without the include, a scene-only sink would silently skip the + // res allocation and any future scene-side write would have nowhere + // to land. if constexpr( avnd::texture_input_introspection::size > 0 || avnd::buffer_input_introspection::size > 0 - || avnd::geometry_input_introspection::size > 0) + || avnd::geometry_input_introspection::size > 0 + || scene_input_introspection::size > 0) { res = renderer.state.rhi->nextResourceUpdateBatch(); @@ -118,6 +173,8 @@ struct GfxRenderer final : score::gfx::OutputNodeRenderer if constexpr(avnd::geometry_input_introspection::size > 0) geometry_ins.inputAboutToFinish( renderer, res, this->geometry, *state, this->node()); + // No scene_ins.inputAboutToFinish today — the guard is forward- + // looking; add the call here when scene_inputs_storage grows one. } if_possible(state->inputAboutToFinish(renderer, p, res)); @@ -144,6 +201,8 @@ struct GfxRenderer final : score::gfx::OutputNodeRenderer buffer_ins.readInputBuffers(renderer, parent, *state); if constexpr(avnd::geometry_input_introspection::size > 0) geometry_ins.readInputGeometries(renderer, this->geometry, parent, *state); + if constexpr(scene_input_introspection::size > 0) + scene_ins.readInputScenes(this->scene, *state); parent.processControlIn( *this, *state, m_last_message, parent.last_message, parent.m_ctx); @@ -158,9 +217,13 @@ struct GfxRenderer final : score::gfx::OutputNodeRenderer }; template - requires( - (avnd::texture_output_introspection::size + avnd::buffer_output_introspection::size + avnd::geometry_output_introspection::size) == 0 - ) + requires((avnd::texture_output_introspection::size + + avnd::buffer_output_introspection::size + + avnd::geometry_output_introspection::size + + scene_output_introspection::size) + == 0 + && (avnd::gpu_render_target_output_port_output_introspection::size + == 0)) struct GfxNode final : CustomGpuOutputNodeBase , GpuNodeElements diff --git a/src/plugins/score-plugin-avnd/Crousti/CpuFilterNode.hpp b/src/plugins/score-plugin-avnd/Crousti/CpuFilterNode.hpp index 159c98a9f4..8bed9246d5 100644 --- a/src/plugins/score-plugin-avnd/Crousti/CpuFilterNode.hpp +++ b/src/plugins/score-plugin-avnd/Crousti/CpuFilterNode.hpp @@ -3,18 +3,25 @@ #if SCORE_PLUGIN_GFX #include +#include + namespace oscr { template requires( - (avnd::texture_output_introspection::size + avnd::buffer_output_introspection::size + avnd::geometry_output_introspection::size) >= 1 + (avnd::texture_output_introspection::size + avnd::buffer_output_introspection::size + avnd::geometry_output_introspection::size + scene_output_introspection::size + avnd::gpu_render_target_output_port_output_introspection::size) >= 1 ) struct GfxRenderer final : score::gfx::GenericNodeRenderer { std::shared_ptr state; score::gfx::Message m_last_message{}; - ossia::time_value m_last_time{-1}; + // RenderList::frame id of the last frame on which we ran the expensive + // once-per-frame body of runInitialPasses (input readbacks, operator()(), + // output uploads). runInitialPasses is invoked once PER OUTGOING EDGE, so + // without this guard that whole body re-ran for every downstream edge, + // every frame. -1 = never run yet. + int64_t m_last_frame{-1}; AVND_NO_UNIQUE_ADDRESS texture_inputs_storage texture_ins; AVND_NO_UNIQUE_ADDRESS texture_outputs_storage texture_outs; @@ -24,6 +31,8 @@ struct GfxRenderer final : score::gfx::GenericNodeRenderer AVND_NO_UNIQUE_ADDRESS geometry_inputs_storage geometry_ins; AVND_NO_UNIQUE_ADDRESS geometry_outputs_storage geometry_outs; + AVND_NO_UNIQUE_ADDRESS scene_inputs_storage scene_ins; + AVND_NO_UNIQUE_ADDRESS scene_outputs_storage scene_outs; const GfxNode& node() const noexcept { @@ -42,8 +51,14 @@ struct GfxRenderer final : score::gfx::GenericNodeRenderer { if constexpr(avnd::texture_input_introspection::size > 0) { + // Only texture-RT inputs live in m_rts. Geometry / buffer / scene + // inputs on the same node (e.g. PBRMesh: 4 gpu_texture_inputs + a + // dynamic_gpu_geometry mesh in) land here through the generic + // renderTargetForOutput path — return empty so the upstream's + // addOutputPass skips creating a graphics render pass for them. auto it = texture_ins.m_rts.find(&p); - SCORE_ASSERT(it != texture_ins.m_rts.end()); + if(it == texture_ins.m_rts.end()) + return {}; return it->second; } return {}; @@ -60,6 +75,71 @@ struct GfxRenderer final : score::gfx::GenericNodeRenderer return {}; } + // For non-2D gpu_texture_input fields (cubemap / array / 3D): the port + // is flagged GrabsFromSource (see initGfxPorts + + // port_flags_for_field), so Graph::updateSinkSampler calls us here + // with the upstream's QRhiTexture. Write it into the matching halp + // field so the node's operator()() / runInitialPasses see the handle. + // 2D (classic RT-rendered) inputs ignore this path — their handle is + // set up at init() time by texture_inputs_storage::init. + // + // depthTex: when the port also opts in via halp_meta(samplable_depth, + // true), Graph passes the upstream's depth attachment here too. Stored + // on `texture.depth_handle` for the consumer to sample alongside color. + void updateInputTexture( + const score::gfx::Port& input, QRhiTexture* tex, + QRhiTexture* depthTex = nullptr) override + { + if constexpr(avnd::texture_input_introspection::size > 0) + { + const auto& inputs = this->node().input; + int port_idx = -1; + for(int i = 0, n = (int)inputs.size(); i < n; ++i) + { + if(inputs[i] == &input) + { + port_idx = i; + break; + } + } + if(port_idx < 0) + return; + + avnd::texture_input_introspection::for_all_n2( + avnd::get_inputs(*state), + [&]( + F& t, avnd::predicate_index, avnd::field_index) { + if constexpr(avnd::gpu_texture_port + && halp::texture_kind_of() != halp::texture_kind::texture_2d) + { + if((int)N == port_idx) + { + t.texture.handle = tex; + if(tex) + { + const auto sz = tex->pixelSize(); + t.texture.width = sz.width(); + t.texture.height = sz.height(); + } + else + { + t.texture.width = 0; + t.texture.height = 0; + } + t.texture.kind = halp::texture_kind_of(); + if constexpr(halp::samplable_depth_of()) + { + t.texture.depth_handle = depthTex; + if(depthTex) + t.texture.depth_format + = qrhiToHalpDepthFormat(depthTex->format()); + } + } + } + }); + } + } + QRhiTexture* textureForOutput(const score::gfx::Port& output) override { if constexpr(avnd::gpu_texture_output_introspection::size > 0) @@ -95,9 +175,47 @@ struct GfxRenderer final : score::gfx::GenericNodeRenderer return nullptr; } - void init(score::gfx::RenderList& renderer, QRhiResourceUpdateBatch& res) override + // All of the setup lives in initState(), not init(). The incremental + // edge-rewire path (Graph::createPassForEdgeIfMissing) only calls + // initState() on newly-created renderers — so a halp scene-in/scene-out + // node inserted live would otherwise never allocate its storage, its + // operator()() would run against uninitialised state every frame, and + // nothing would flow downstream until a stop/start cycle forced a full + // rebuild through init(). + void initState(score::gfx::RenderList& renderer, QRhiResourceUpdateBatch& res) override { + if(m_initialized) + return; + auto& parent = node(); + + // Optional renderlist backchannel for CPU halp nodes that need to + // reach their hosting RenderList's GpuResourceRegistry / AssetTable + // (e.g. Camera / Light / PBRMesh / MaterialOverride allocating arena + // slots). Populated by SFINAE so nodes that don't declare the member + // pay nothing. Lifetime: valid from initState until releaseState + // clears it back to nullptr. + if constexpr(requires { state->renderlist = &renderer; }) + state->renderlist = &renderer; + + // Ordering invariant: init → processControlIn → operator()() + // + // For nodes WITHOUT prepare(): processControlIn is NOT called here. + // state->init() therefore runs (line below) before any control-update + // callback can fire rebuild(). All five scene producers — Camera, + // CameraArray, Light, Transform3D, SceneGroup — rely on this: they + // populate m_*_ref arena handles in init(), and rebuild() reads those + // handles unconditionally. The invariant is also enforced at the two + // call-graph roots: + // • Graph.cpp:865-893 (incremental edge update): initState() is + // called before seedInitialOutputs() / operator()(). + // • RenderList.cpp:434-470 (full graph init): init() for all + // renderers runs before the first render frame fires update(). + // + // If you add prepare() to a scene producer, processControlIn becomes + // reachable BEFORE state->init() (see branch below vs. line 202) and + // any m_*_ref read inside rebuild() will observe an empty handle. + // Re-audit the producer's rebuild() ref-read sites before doing so. if constexpr(requires { state->prepare(); }) { parent.processControlIn( @@ -116,6 +234,70 @@ struct GfxRenderer final : score::gfx::GenericNodeRenderer buffer_outs.init(renderer, *state, parent); if_possible(state->init(renderer, res)); + + m_initialized = true; + } + + void init(score::gfx::RenderList& renderer, QRhiResourceUpdateBatch& res) override + { + initState(renderer, res); + } + + // Called by Graph::reconcileAllRenderLists right after this renderer is + // spawned (in particular when the user live-inserts a scene-producing + // node — Camera, EnvironmentLoader, Light — into a running + // graph). Runs the node's operator()() once to populate its outputs and + // then pushes the result into every downstream sink's per-port scene + // cache immediately, rather than waiting for the first render-frame's + // upstream-input scan to find our new edge. Without this, the Camera + // live-insertion symptom is that the camera has no visible effect until + // the user stops and restarts transport (triggering a full render-list + // rebuild where every renderer's runInitialPasses runs from clean + // state). + void seedInitialOutputs(score::gfx::RenderList& renderer) override + { + if constexpr( + scene_output_introspection::size > 0 + || avnd::geometry_output_introspection::size > 0) + { + auto& parent = node(); + // Apply any control values that arrived before we were created. + // processControlIn is normally called from update() but the render + // loop won't run update() until the first frame after reconcile + // — the inserted Camera's slider defaults would leak through for + // one frame otherwise. + parent.processControlIn( + *this, *state, m_last_message, parent.last_message, parent.m_ctx); + + if_possible((*state)()); + + // Push to every existing output edge on scene/geometry ports. The + // upload helpers look at edge.sink to find the downstream renderer + // and call its NodeRenderer::process(port, scene_spec, source) — + // seeding exactly the same m_portScenes slot the first runInitialPasses + // would have filled one frame later. + // + // Scene and geometry ports both stamp score::gfx::Types::Geometry (per + // port_to_type_enum in GpuUtils.hpp — Process::GeometryInlet carries + // either a geometry or a full scene by design). Dispatching on the + // runtime port->type can never see Types::Scene, so we branch on + // compile-time introspection instead. Each upload helper is a no-op + // for nodes that don't have the corresponding output kind, and both + // branches can fire for nodes with mixed outputs. + const auto& outs = parent.output; + for(std::size_t i = 0; i < outs.size(); ++i) + { + auto* port = outs[i]; + if(!port || port->edges.empty()) + continue; + if constexpr(scene_output_introspection::size > 0) + for(auto* edge : port->edges) + scene_outs.upload(renderer, *this->state, *edge); + if constexpr(avnd::geometry_output_introspection::size > 0) + for(auto* edge : port->edges) + geometry_outs.upload(renderer, *this->state, *edge); + } + } } void update( @@ -145,8 +327,11 @@ struct GfxRenderer final : score::gfx::GenericNodeRenderer } } - void release(score::gfx::RenderList& r) override + void releaseState(score::gfx::RenderList& r) override { + if(!m_initialized) + return; + if constexpr(avnd::texture_input_introspection::size > 0) texture_ins.release(); @@ -159,12 +344,38 @@ struct GfxRenderer final : score::gfx::GenericNodeRenderer if constexpr(avnd::geometry_input_introspection::size > 0) geometry_ins.release(r); + if constexpr(scene_input_introspection::size > 0) + scene_ins.release(r); + + // Symmetric with the other *_outs.release calls above. No-ops today + // (scene_outputs_storage / geometry_outputs_storage own no QRhi + // resources — scene_spec is value-semantics + a shared_ptr; geometry + // wraps non-owning pointers + transform values). Wired so future + // RHI handles on the storages release cleanly. + if constexpr(avnd::geometry_output_introspection::size > 0) + geometry_outs.release(r); + if constexpr(scene_output_introspection::size > 0) + scene_outs.release(r); + if constexpr(avnd::texture_input_introspection::size > 0 || avnd::texture_output_introspection::size > 0) { this->defaultRelease(r); } if_possible(state->release(r)); + + // Clear the optional renderlist backchannel. Paired with the init + // assignment; same SFINAE guard so nodes without the member are + // unaffected. + if constexpr(requires { state->renderlist = nullptr; }) + state->renderlist = nullptr; + + m_initialized = false; + } + + void release(score::gfx::RenderList& r) override + { + releaseState(r); } void inputAboutToFinish( @@ -197,59 +408,112 @@ struct GfxRenderer final : score::gfx::GenericNodeRenderer auto& parent = node(); auto& rhi = *renderer.state.rhi; - if constexpr( - avnd::texture_input_introspection::size > 0 - || avnd::buffer_input_introspection::size > 0 - || avnd::geometry_input_introspection::size > 0) + // runInitialPasses is called once PER OUTGOING EDGE per frame. The + // expensive work below — rhi.finish() sync point, input readbacks, + // operator()(), and output buffer/texture uploads — only needs to run + // ONCE per frame: its result lives in `*this->state` and the storages, + // identical for every edge. We dedupe on RenderList::frame, which is + // bumped exactly once at the end of each RenderList::render() (see + // RenderList.cpp). This is NOT a transport-date gate: it does not + // freeze scene producers when the transport is paused (token.date + // frozen) — operator()() still re-runs every frame so live parameter + // edits take effect immediately. The per-edge geometry/scene uploads + // (which genuinely differ per edge — they target edge.sink) run for + // EVERY edge, below the guard. + const bool firstEdgeThisFrame = (renderer.frame != m_last_frame); + if(firstEdgeThisFrame) { - // FIXME: for geometry, here we should optimize if we know we aren't going to need them on the CPU, OR if it is a type ? - // Insert a synchronisation point to allow readbacks to complete - rhi.finish(); - } + m_last_frame = renderer.frame; - // If we are paused, we don't run the processor implementation. - if(parent.last_message.token.date == m_last_time) - return; - m_last_time = parent.last_message.token.date; + if constexpr( + avnd::texture_input_introspection::size > 0 + || avnd::buffer_input_introspection::size > 0 + || avnd::geometry_input_introspection::size > 0) + { + // FIXME: for geometry, here we should optimize if we know we aren't going to need them on the CPU, OR if it is a type ? + // Insert a synchronisation point to allow readbacks to complete + rhi.finish(); + } - if constexpr(avnd::texture_input_introspection::size > 0) - texture_ins.runInitialPasses(*this, rhi); - if constexpr(avnd::buffer_input_introspection::size > 0) - buffer_ins.readInputBuffers(renderer, parent, *state); - if constexpr(avnd::geometry_input_introspection::size > 0) - geometry_ins.readInputGeometries(renderer, this->geometry, parent, *state); + if constexpr(avnd::texture_input_introspection::size > 0) + texture_ins.runInitialPasses(*this, rhi); + if constexpr(avnd::buffer_input_introspection::size > 0) + buffer_ins.readInputBuffers(renderer, parent, *state); + if constexpr(avnd::geometry_input_introspection::size > 0) + geometry_ins.readInputGeometries(renderer, this->geometry, parent, *state); + if constexpr(scene_input_introspection::size > 0) + scene_ins.readInputScenes(this->scene, *state); - buffer_outs.prepareUpload(*res); + buffer_outs.prepareUpload(*res); - // Run the processor - if_possible(state->runInitialPasses(renderer, commands, res, edge)); - if_possible((*state)()); + // Run the processor + if_possible(state->runInitialPasses(renderer, commands, res, edge)); + if_possible((*state)()); - // Upload output buffers - if constexpr(avnd::buffer_output_introspection::size > 0) - buffer_outs.upload(renderer, *state, *res); + // Upload output buffers + if constexpr(avnd::buffer_output_introspection::size > 0) + buffer_outs.upload(renderer, *state, *res); - // Upload output textures - if constexpr(avnd::texture_output_introspection::size > 0) - { - texture_outs.runInitialPasses(*this, renderer, res); + // Upload output textures + if constexpr(avnd::texture_output_introspection::size > 0) + { + texture_outs.runInitialPasses(*this, renderer, res); - commands.resourceUpdate(res); - res = renderer.state.rhi->nextResourceUpdateBatch(); + commands.resourceUpdate(res); + res = renderer.state.rhi->nextResourceUpdateBatch(); + } + + // Copy the data to the model node + parent.processControlOut(*this->state); } + // Per-edge uploads: these target the specific downstream sink + // (edge.sink) and must run for every outgoing edge, even on edges + // after the first this frame. The producer's output is already + // populated in *this->state by the once-per-frame body above. + // Copy the geometry if constexpr(avnd::geometry_output_introspection::size > 0) geometry_outs.upload(renderer, *this->state, edge); - // Copy the data to the model node - parent.processControlOut(*this->state); + // Copy the scene (travels on the same Gfx::GeometryOutlet as geometry, + // published via NodeRenderer::process(scene_spec)). + if constexpr(scene_output_introspection::size > 0) + scene_outs.upload(renderer, *this->state, edge); + } + + // Customization point for halp nodes that produce their output via + // their own GPU pipeline (post-process effects, custom rasterizers). + // + // Default GenericNodeRenderer::runRenderPass calls defaultRenderPass, + // which uses a pre-built fullscreen-quad pipeline that samples + // m_samplers[0] (the upstream input texture, set up by + // m_material.init) and writes to the consumer's per-edge RT via the + // generic_texgen_fs shader. That hard-codes "blit upstream input → + // downstream input RT" — which is fine for halp filter nodes whose + // output IS just a CPU-uploaded copy of their input, but is wrong for + // any node that did real work in runInitialPasses (writing to its own + // m_outputTex / a private RT): the framework's input-blit overwrites + // the result, so the consumer sees the unmodified upstream. + // + // When the halp class declares its own runRenderPass, we hand off to + // it. The method runs INSIDE the consumer's beginPass/endPass cycle — + // it is expected to record draw commands only (no beginPass/endPass + // on its own) targeting the currently-bound (per-edge) render target. + void runRenderPass( + score::gfx::RenderList& renderer, QRhiCommandBuffer& commands, + score::gfx::Edge& edge) override + { + if constexpr(requires { state->runRenderPass(renderer, commands, edge); }) + state->runRenderPass(renderer, commands, edge); + else + score::gfx::GenericNodeRenderer::runRenderPass(renderer, commands, edge); } }; template requires( - (avnd::texture_output_introspection::size + avnd::buffer_output_introspection::size + avnd::geometry_output_introspection::size) >= 1 + (avnd::texture_output_introspection::size + avnd::buffer_output_introspection::size + avnd::geometry_output_introspection::size + scene_output_introspection::size + avnd::gpu_render_target_output_port_output_introspection::size) >= 1 ) struct GfxNode final : CustomGfxNodeBase diff --git a/src/plugins/score-plugin-avnd/Crousti/GppCoroutines.hpp b/src/plugins/score-plugin-avnd/Crousti/GppCoroutines.hpp index 676468cded..afebcbe7a7 100644 --- a/src/plugins/score-plugin-avnd/Crousti/GppCoroutines.hpp +++ b/src/plugins/score-plugin-avnd/Crousti/GppCoroutines.hpp @@ -266,6 +266,7 @@ struct handle_update requires { C::vertex; } || requires { C::index; }) { auto buf = rhi.newBuffer(buffer_type(), usage(), command.size); + buf->setName("GppCoroutines::vbuf_or_ibuf"); buf->create(); return reinterpret_cast(buf); } @@ -279,6 +280,7 @@ struct handle_update requires { C::ubo; } || requires { C::storage; }) { auto buf = rhi.newBuffer(buffer_type(), usage(), command.size); + buf->setName("GppCoroutines::ubo_or_ssbo"); buf->create(); // Replace it in our bindings diff --git a/src/plugins/score-plugin-avnd/Crousti/GpuComputeNode.hpp b/src/plugins/score-plugin-avnd/Crousti/GpuComputeNode.hpp index 876ce60ac3..a3faf4a521 100644 --- a/src/plugins/score-plugin-avnd/Crousti/GpuComputeNode.hpp +++ b/src/plugins/score-plugin-avnd/Crousti/GpuComputeNode.hpp @@ -65,6 +65,7 @@ struct GpuComputeRenderer final : ComputeRendererBaseType QRhiComputePipeline* m_pipeline{}; bool m_createdPipeline{}; + bool m_initialized{}; int sampler_k = 0; int ubo_k = 0; @@ -230,8 +231,28 @@ struct GpuComputeRenderer final : ComputeRendererBaseType createdUbos[ubo_type::binding()] = ubo; } - void init(score::gfx::RenderList& renderer, QRhiResourceUpdateBatch& res) override + // Compute renderers own a single shared compute pipeline + SRB; they + // don't allocate any per-output-edge state. Edge add/remove is a no-op + // for them. These overrides are required because NodeRenderer + // ::removeOutputPass is now pure-virtual, and Graph.cpp's incremental + // path drives renderers through addOutputPass (the per-edge passes a + // compute node simply doesn't have). + void removeOutputPass(score::gfx::RenderList&, score::gfx::Edge&) override { } + void addOutputPass( + score::gfx::RenderList&, score::gfx::Edge&, QRhiResourceUpdateBatch&) override { + } + + // All edge-independent setup lives in initState(), mirroring + // CustomGpuRenderer in GpuNode.hpp. The incremental edge-rewire path + // (Graph.cpp) only calls initState()/releaseState()/addOutputPass() on + // newly-spawned renderers; a compute node inserted live would otherwise + // never allocate its pipeline/SRB and run against uninitialised state. + void initState(score::gfx::RenderList& renderer, QRhiResourceUpdateBatch& res) override + { + if(m_initialized) + return; + auto& parent = node(); if constexpr(requires { state->prepare(); }) { @@ -255,6 +276,13 @@ struct GpuComputeRenderer final : ComputeRendererBaseType SCORE_ASSERT(m_pipeline->create()); m_createdPipeline = true; } + + m_initialized = true; + } + + void init(score::gfx::RenderList& renderer, QRhiResourceUpdateBatch& res) override + { + initState(renderer, res); } std::vector tmp; @@ -301,6 +329,8 @@ struct GpuComputeRenderer final : ComputeRendererBaseType if(m_createdPipeline) m_srb->destroy(); m_srb->setBindings(tmp.begin(), tmp.end()); + if(m_createdPipeline && !m_srb->create()) + qWarning("GpuComputeNode: SRB recreation failed"); } /* @@ -337,8 +367,11 @@ struct GpuComputeRenderer final : ComputeRendererBaseType } } - void release(score::gfx::RenderList& r) override + void releaseState(score::gfx::RenderList& r) override { + if(!m_initialized) + return; + m_createdPipeline = false; // Release the object's internal states @@ -382,6 +415,13 @@ struct GpuComputeRenderer final : ComputeRendererBaseType sampler_k = 0; ubo_k = 0; + + m_initialized = false; + } + + void release(score::gfx::RenderList& r) override + { + releaseState(r); } void runCompute( diff --git a/src/plugins/score-plugin-avnd/Crousti/GpuNode.hpp b/src/plugins/score-plugin-avnd/Crousti/GpuNode.hpp index 66387766da..32a5527d3e 100644 --- a/src/plugins/score-plugin-avnd/Crousti/GpuNode.hpp +++ b/src/plugins/score-plugin-avnd/Crousti/GpuNode.hpp @@ -6,6 +6,7 @@ #include #include #include +#include #include // #include @@ -27,9 +28,17 @@ struct CustomGpuRenderer final : score::gfx::NodeRenderer score::gfx::PassMap m_p; + // Per-pass "pipeline + SRB created" flags, kept index-parallel with m_p + // and `states` (same push_back in addOutputPass / same erase in + // removeOutputPass). A single global m_createdPipeline could not handle + // a pass added live onto an update()-driven node: the first frame would + // (re)create already-live passes, or skip the new one entirely. Each + // pass now gates its own srb->create()/pipeline->create(). + ossia::small_vector m_passCreated; + score::gfx::MeshBuffers m_meshBuffer{}; - bool m_createdPipeline{}; + QRhiShaderResourceBindings* m_srb{}; int sampler_k = 0; ossia::flat_map createdUbos; @@ -201,18 +210,18 @@ struct CustomGpuRenderer final : score::gfx::NodeRenderer createdUbos[ubo_type::binding()] = ubo; } - void init(score::gfx::RenderList& renderer, QRhiResourceUpdateBatch& res) override + void initState(score::gfx::RenderList& renderer, QRhiResourceUpdateBatch& res) override { - auto& parent = node(); - if constexpr(requires { states[0].prepare(); }) - { - for(auto& state : states) - { - parent.processControlIn( - *this, *state, m_last_message, parent.last_message, parent.m_ctx); - state.prepare(); - } - } + if(m_initialized) + return; + + // NB: prepare()/processControlIn for graphics nodes is NOT invoked + // here — `states` is empty at initState time (states are constructed + // per-edge in addOutputPass), so there is nothing to prepare. The old + // `states[0].prepare()` detection was also doubly-wrong: `states[0]` + // is a shared_ptr, so the requires-expression never matched, and even + // if it had, indexing an empty vector is UB. The prepare/control-in + // call now happens in addOutputPass right after each state is built. if(m_meshBuffer.buffers.empty()) { @@ -224,34 +233,154 @@ struct CustomGpuRenderer final : score::gfx::NodeRenderer avnd::input_introspection::for_all( [this, &renderer](auto f) { init_input(renderer, f); }); - // Create the initial srbs - // TODO when implementing multi-pass, we may have to - // move this back inside the loop below as they may depend on the pipelines... - auto srb = initBindings(renderer); + // Create the shared shader resource bindings + m_srb = initBindings(renderer); - // Create the states and pipelines - for(score::gfx::Edge* edge : parent.output[0]->edges) + m_initialized = true; + } + + void addOutputPass( + score::gfx::RenderList& renderer, score::gfx::Edge& edge, + QRhiResourceUpdateBatch& res) override + { + auto& parent = node(); + auto rt = renderer.renderTargetForOutput(edge); + if(rt.renderTarget) { - auto rt = renderer.renderTargetForOutput(*edge); - if(rt.renderTarget) + states.push_back(std::make_shared()); + prepareNewState(states.back(), parent); + + // Graphics nodes that declare prepare(): apply any pending control + // input and run prepare() on the freshly-constructed state, here — + // not in initState, where `states` is still empty. Detection uses + // operator-> because states.back() is a shared_ptr. + if constexpr(requires { states.back()->prepare(); }) { - states.push_back(std::make_shared()); - prepareNewState(states.back(), parent); + parent.processControlIn( + *this, *states.back(), m_last_message, parent.last_message, parent.m_ctx); + states.back()->prepare(); + } + + auto ps = createRenderPipeline(renderer, rt); + ps->setShaderResourceBindings(m_srb); + + m_p.emplace_back(&edge, score::gfx::Pass{rt, score::gfx::Pipeline{ps, m_srb}, nullptr}); + m_passCreated.push_back(false); + + // No update step: we can directly create this pass's pipeline here. + // The SRB is shared across all passes (m_srb); creating it is + // idempotent for our purposes, and the per-pass flag tracks the + // pipeline that is genuinely per-edge. + if constexpr(!requires { &Node_T::update; }) + { + SCORE_ASSERT(m_srb->create()); + SCORE_ASSERT(ps->create()); + m_passCreated.back() = true; + } + } + } - auto ps = createRenderPipeline(renderer, rt); - ps->setShaderResourceBindings(srb); + bool hasOutputPassForEdge(score::gfx::Edge& edge) const override + { + return ossia::find_if(m_p, [&](const auto& p) { return p.first == &edge; }) + != m_p.end(); + } + + void removeOutputPass(score::gfx::RenderList&, score::gfx::Edge& edge) override + { + // Mirror addOutputPass: each edge owns one entry in m_p (pipeline + + // SRB) and one parallel entry in `states`. Release both. The shared + // m_srb pointer is owned by initState; Pass::p.srb refers to the + // SAME pointer (see addOutputPass), so null it out before + // Pipeline::release() to avoid double-deleteLater of the shared SRB. + auto it + = ossia::find_if(m_p, [&](const auto& p) { return p.first == &edge; }); + if(it == m_p.end()) + return; + const auto idx = std::distance(m_p.begin(), it); + it->second.p.srb = nullptr; // shared with siblings — owned by initState + it->second.release(); + m_p.erase(it); + if((std::size_t)idx < states.size()) + states.erase(states.begin() + idx); + if((std::size_t)idx < m_passCreated.size()) + m_passCreated.erase(m_passCreated.begin() + idx); + } + + void releaseState(score::gfx::RenderList& r) override + { + if(!m_initialized) + return; - m_p.emplace_back(edge, score::gfx::Pipeline{ps, srb}); + m_passCreated.clear(); - // No update step: we can directly create the pipeline here - if constexpr(!requires { &Node_T::update; }) + // Release the object's internal states + if constexpr(requires { &Node_T::release; }) + { + for(auto& state : states) + { + for(auto& promise : state->release()) { - SCORE_ASSERT(srb->create()); - SCORE_ASSERT(ps->create()); - m_createdPipeline = true; + gpp::qrhi::handle_release handler{*r.state.rhi}; + visit(handler, promise.current_command); } } } + states.clear(); + + // Release the allocated mesh buffers + m_meshBuffer = {}; + + // Release the allocated textures + for(auto& [id, tex] : this->createdTexs) + tex->deleteLater(); + this->createdTexs.clear(); + + // Release the allocated samplers + for(auto& [id, sampl] : this->createdSamplers) + sampl->deleteLater(); + this->createdSamplers.clear(); + + // Release the allocated ubos + for(auto& [id, ubo] : this->createdUbos) + ubo->deleteLater(); + this->createdUbos.clear(); + + // Release the allocated rts + for(auto [port, rt] : m_rts) + rt.release(); + m_rts.clear(); + + // Release the allocated pipelines. Each Pass::p.srb refers to the + // SAME shared m_srb (see addOutputPass); null it out per-pass before + // Pipeline::release() so the shared SRB isn't deleteLater'd once per + // pass (it survived previously only via QRhi's QSet dedup), then + // delete it exactly once below — covering the m_p-empty case too, + // which formerly leaked m_srb. Mirrors removeOutputPass. + for(auto& pass : m_p) + { + pass.second.p.srb = nullptr; // shared — owned by initState + pass.second.release(); + } + m_p.clear(); + if(m_srb) + m_srb->deleteLater(); + m_srb = nullptr; + + m_meshBuffer = {}; + + sampler_k = 0; + + m_initialized = false; + } + + void init(score::gfx::RenderList& renderer, QRhiResourceUpdateBatch& res) override + { + initState(renderer, res); + + auto& parent = node(); + for(score::gfx::Edge* edge : parent.output[0]->edges) + addOutputPass(renderer, *edge, res); } std::vector tmp; @@ -289,14 +418,22 @@ struct CustomGpuRenderer final : score::gfx::NodeRenderer // as we have to take into account that buffers could be allocated, freed, etc. // and thus updated in the shader resource bindings SCORE_ASSERT(states.size() == m_p.size()); + SCORE_ASSERT(states.size() == m_passCreated.size()); //SCORE_SOFT_ASSERT(state.size() == edges); for(int k = 0; k < states.size(); k++) { auto& state = *states[k]; auto& pass = m_p[k].second; + // Per-pass creation flag: a pass added live (e.g. a new output + // edge onto an update()-driven node) starts at false and gets its + // srb/pipeline created on the next update; passes already live + // keep their pipeline. A single global flag would skip the new + // pass entirely (or needlessly destroy the live ones). + const bool created = m_passCreated[k]; + bool srb_touched{false}; - tmp.assign(pass.srb->cbeginBindings(), pass.srb->cendBindings()); + tmp.assign(pass.p.srb->cbeginBindings(), pass.p.srb->cendBindings()); for(auto& promise : state.update()) { using ret_type = decltype(promise.feedback_value); @@ -307,75 +444,26 @@ struct CustomGpuRenderer final : score::gfx::NodeRenderer if(srb_touched) { - if(m_createdPipeline) - pass.srb->destroy(); + if(created) + pass.p.srb->destroy(); - pass.srb->setBindings(tmp.begin(), tmp.end()); + pass.p.srb->setBindings(tmp.begin(), tmp.end()); + if(created && !pass.p.srb->create()) + qWarning("GpuNode: SRB recreation failed"); } - if(!m_createdPipeline) + if(!created) { - SCORE_ASSERT(pass.srb->create()); - SCORE_ASSERT(pass.pipeline->create()); + SCORE_ASSERT(pass.p.srb->create()); + SCORE_ASSERT(pass.p.pipeline->create()); + m_passCreated[k] = true; } } - m_createdPipeline = true; tmp.clear(); } } - void release(score::gfx::RenderList& r) override - { - m_createdPipeline = false; - - // Release the object's internal states - if constexpr(requires { &Node_T::release; }) - { - for(auto& state : states) - { - for(auto& promise : state->release()) - { - gpp::qrhi::handle_release handler{*r.state.rhi}; - visit(handler, promise.current_command); - } - } - } - states.clear(); - - // Release the allocated mesh buffers - m_meshBuffer = {}; - - // Release the allocated textures - for(auto& [id, tex] : this->createdTexs) - tex->deleteLater(); - this->createdTexs.clear(); - - // Release the allocated samplers - for(auto& [id, sampl] : this->createdSamplers) - sampl->deleteLater(); - this->createdSamplers.clear(); - - // Release the allocated ubos - for(auto& [id, ubo] : this->createdUbos) - ubo->deleteLater(); - this->createdUbos.clear(); - - // Release the allocated rts - // TODO investigate why reference does not work here: - for(auto [port, rt] : m_rts) - rt.release(); - m_rts.clear(); - - // Release the allocated pipelines - for(auto& pass : m_p) - pass.second.release(); - m_p.clear(); - - m_meshBuffer = {}; - m_createdPipeline = false; - - sampler_k = 0; - } + void release(score::gfx::RenderList& r) override { releaseState(r); } void runInitialPasses( score::gfx::RenderList& renderer, QRhiCommandBuffer& commands, diff --git a/src/plugins/score-plugin-avnd/Crousti/GpuUtils.hpp b/src/plugins/score-plugin-avnd/Crousti/GpuUtils.hpp index 366d1850b7..99ce89117a 100644 --- a/src/plugins/score-plugin-avnd/Crousti/GpuUtils.hpp +++ b/src/plugins/score-plugin-avnd/Crousti/GpuUtils.hpp @@ -8,6 +8,7 @@ #include #include #include +#include #include #include #include @@ -35,6 +36,7 @@ #include #include #include +#include #include @@ -170,6 +172,8 @@ struct GpuProcessIns { using node_type = std::remove_cvref_t; auto& node = const_cast(gpu.node()); + if(field_index >= mess.input.size()) + return; auto val = ossia::get_if(&mess.input[field_index]); if(!val) return; @@ -181,6 +185,8 @@ struct GpuProcessIns { using node_type = std::remove_cvref_t; auto& node = const_cast(gpu.node()); + if(field_index >= mess.input.size()) + return; auto val = ossia::get_if(&mess.input[field_index]); if(!val) return; @@ -190,10 +196,24 @@ struct GpuProcessIns template void operator()(Field& t, avnd::field_index field_index) { - using node_type = std::remove_cvref_t; - auto& node = const_cast(gpu.node()); + // Intentional no-op. Geometry data flows through its own publish path + // (geometry_inputs_storage::readInputGeometries / etc.); the + // GpuProcessIns visitor only handles per-message control fields + // (texture/parameter) — geometry data is not in the control message. + // The empty body keeps GpuProcessIns instantiable for nodes whose + // input list contains geometry fields without forcing them to hit + // the `= delete` catch-all at the end of this struct. + } - // FIXME + template + void operator()(Field& t, avnd::field_index field_index) + { + // Intentional no-op — same reasoning as the geometry_port overload above. + // Scene data flows through scene_inputs_storage / scene_outputs_storage + // separately; GpuProcessIns only handles per-message control fields. + // The empty body keeps GpuProcessIns instantiable for nodes whose + // input list contains scene_port fields without hitting the `= delete` + // catch-all at the end of this struct. } void operator()(auto& t, auto field_index) = delete; @@ -423,12 +443,24 @@ struct port_to_type_enum { return score::gfx::Types::Image; } + template + constexpr auto operator()(avnd::field_reflection p) + { + return score::gfx::Types::Image; + } template constexpr auto operator()(avnd::field_reflection p) { return score::gfx::Types::Geometry; } + // Scene ports reuse Types::Geometry — a scene is a richer form of geometry. + template + requires(!avnd::geometry_port) + constexpr auto operator()(avnd::field_reflection p) + { + return score::gfx::Types::Geometry; + } template constexpr auto operator()(avnd::field_reflection p) { @@ -500,19 +532,71 @@ struct port_to_type_enum } }; +// Compile-time port flags derived from a field's declarative metadata. +// Inspects: +// - `texture_target` (texture_kind_of) — non-2D textures bypass the +// local-RT allocation and grab the upstream texture directly. +// - `samplable_depth` (samplable_depth_of) — opt-in to having the +// framework allocate a sampleable depth attachment on the producing +// edge's RT and expose its handle through `texture.depth_handle`, +// mirroring the semantics CSF/ISF shaders get via "DEPTH": true. +template +constexpr score::gfx::Flag port_flags_for_field() noexcept +{ + if constexpr(avnd::gpu_texture_port) + { + constexpr auto kind = halp::texture_kind_of(); + constexpr bool nonD2 = (kind != halp::texture_kind::texture_2d); + constexpr bool depth = halp::samplable_depth_of(); + if constexpr(nonD2 && depth) + return score::gfx::Flag::GrabsFromSource | score::gfx::Flag::SamplableDepth; + else if constexpr(nonD2) + return score::gfx::Flag::GrabsFromSource; + else if constexpr(depth) + return score::gfx::Flag::SamplableDepth; + } + return score::gfx::Flag{}; +} + +// Map QRhi's depth-format taxonomy onto halp's depth_format_t. +// The 4-arg subset matches every depth format score's createRenderTarget +// can produce (today always D32F, but the API accepts the others). +inline constexpr halp::gpu_texture::depth_format_t qrhiToHalpDepthFormat( + QRhiTexture::Format f) noexcept +{ + using D = halp::gpu_texture::depth_format_t; + switch(f) + { + case QRhiTexture::D16: return D::D16; + case QRhiTexture::D24: return D::D24; + case QRhiTexture::D24S8: return D::D24S8; + case QRhiTexture::D32F: return D::D32F; + default: break; + } + return D::D32F; +} + template inline void initGfxPorts(auto* self, auto& input, auto& output) { avnd::input_introspection::for_all( [self, &input](avnd::field_reflection f) { static constexpr auto type = port_to_type_enum{}(f); - input.push_back(new score::gfx::Port{self, {}, type, {}, {}}); + static constexpr auto flags = port_flags_for_field(); + input.push_back(new score::gfx::Port{self, {}, type, flags, {}}); }); avnd::output_introspection::for_all( [self, &output](avnd::field_reflection f) { static constexpr auto type = port_to_type_enum{}(f); - output.push_back(new score::gfx::Port{self, {}, type, {}, {}}); + // port_flags_for_field encodes INPUT-side sink semantics + // (GrabsFromSource → "sample the upstream's texture directly"; + // SamplableDepth → "ask the producer for a sampleable depth + // attachment"). Neither has any meaning on an OUTPUT port — emitting + // them here would make the graph treat this node's own output as if it + // grabbed from / sampled some upstream source. Outputs carry no such + // flags. + output.push_back(new score::gfx::Port{self, {}, type, score::gfx::Flag{}, {}}); }); } @@ -706,6 +790,13 @@ struct geometry_inputs_storage allocated.push_back(buf); meshes.buffers[buffer_index] = buf; } + else if(auto* existing = meshes.buffers[buffer_index]; + existing && existing->size() < bytesize) + { + // Buffer exists but is too small — resize it. + existing->setSize(bytesize); + existing->create(); + } res->uploadStaticBuffer(meshes.buffers[buffer_index], 0, bytesize, data); }, [&](auto& write_buf, int buffer_index, void* handle) { @@ -743,9 +834,11 @@ template requires(avnd::geometry_input_introspection::size == 0) struct geometry_inputs_storage { - static void readInputBuffers(auto&&...) { } + static void readInputGeometries(auto&&...) { } static void inputAboutToFinish(auto&&...) { } + + static void release(auto&&...) { } }; template @@ -1050,7 +1143,7 @@ struct texture_inputs_storage template QRhiTexture* createInput( score::gfx::RenderList& renderer, score::gfx::Port* port, Tex& texture_spec, - const score::gfx::RenderTargetSpecs& spec) + const score::gfx::RenderTargetSpecs& spec, bool wantsSamplableDepth = false) { static constexpr auto flags = QRhiTexture::RenderTarget | QRhiTexture::UsedAsTransferSource; @@ -1070,8 +1163,14 @@ struct texture_inputs_storage fmt, spec.size, 1, flags); SCORE_ASSERT(texture->create()); + // wantsSamplableDepth implies wantsDepth: createRenderTarget allocates + // a sampleable single-sample depth texture (with MSAA-resolve when + // available) instead of a renderbuffer / non-resolve depth target. + // Same shape ISF/CSF inputs get when their port has SamplableDepth. + const bool wantsDepth = renderer.requiresDepth(*port) || wantsSamplableDepth; m_rts[port] = score::gfx::createRenderTarget( - renderer.state, texture, renderer.samples(), renderer.requiresDepth(*port)); + renderer.state, texture, renderer.samples(), + wantsDepth, wantsSamplableDepth); return texture; } @@ -1081,6 +1180,21 @@ struct texture_inputs_storage avnd::texture_input_introspection::for_all_n2( avnd::get_inputs(*self.state), [&](F& t, avnd::predicate_index, avnd::field_index) { + // Non-2D GPU texture inputs (cube / array / 3D) don't get a local + // render target — the port carries Flag::GrabsFromSource (set by + // initGfxPorts via texture_kind_of()), the graph will populate + // t.texture.handle through updateInputTexture when the edge + // resolves. Skipping the allocation here avoids wasting a 2D + // colour attachment that would never be rendered into anyway. + if constexpr(avnd::gpu_texture_port + && halp::texture_kind_of() != halp::texture_kind::texture_2d) + { + t.texture.kind = halp::texture_kind_of(); + // Handle + size populated later by updateInputTexture once the + // upstream is resolved. + return; + } + auto& parent = self.node(); auto spec = parent.resolveRenderTargetSpecs(N, renderer); if constexpr(requires { @@ -1092,7 +1206,10 @@ struct texture_inputs_storage spec.size.rheight() = t.request_height; } - auto tex = createInput(renderer, parent.input[N], t.texture, spec); + constexpr bool wantsSamplableDepth + = avnd::gpu_texture_port && halp::samplable_depth_of(); + auto tex = createInput( + renderer, parent.input[N], t.texture, spec, wantsSamplableDepth); if constexpr(avnd::cpu_texture_port) { t.texture.width = spec.size.width(); @@ -1103,6 +1220,16 @@ struct texture_inputs_storage t.texture.handle = tex; t.texture.width = spec.size.width(); t.texture.height = spec.size.height(); + if constexpr(wantsSamplableDepth) + { + // The local RT just allocated owns a sampleable depth texture + // that the upstream renders into when the edge runs — same + // pointer, stable for the RT's lifetime, no per-frame refresh. + const auto& rt = m_rts[parent.input[N]]; + t.texture.depth_handle = rt.depthTexture; + if(rt.depthTexture) + t.texture.depth_format = qrhiToHalpDepthFormat(rt.depthTexture->format()); + } } }); } @@ -1212,7 +1339,7 @@ struct texture_inputs_storage template static QRhiTexture* updateTexture(auto& self, score::gfx::RenderList& renderer, int k, const Tex& cpu_tex) { - auto& [sampler, texture] = self.m_samplers[k]; + auto& [sampler, texture, fb_] = self.m_samplers[k]; if(texture) { auto sz = texture->pixelSize(); @@ -1229,8 +1356,8 @@ static QRhiTexture* updateTexture(auto& self, score::gfx::RenderList& renderer, QRhiTexture::Flag{}); newtex->create(); for(auto& [edge, pass] : self.m_p) - if(pass.srb) - score::gfx::replaceTexture(*pass.srb, sampler, newtex); + if(pass.p.srb) + score::gfx::replaceTexture(*pass.p.srb, sampler, newtex); texture = newtex; if(oldtex && oldtex != &renderer.emptyTexture()) @@ -1243,8 +1370,8 @@ static QRhiTexture* updateTexture(auto& self, score::gfx::RenderList& renderer, else { for(auto& [edge, pass] : self.m_p) - if(pass.srb) - score::gfx::replaceTexture(*pass.srb, sampler, &renderer.emptyTexture()); + if(pass.p.srb) + score::gfx::replaceTexture(*pass.p.srb, sampler, &renderer.emptyTexture()); return &renderer.emptyTexture(); } @@ -1378,7 +1505,7 @@ struct texture_outputs_storage void release(auto& self, score::gfx::RenderList& r) { // Free outputs - for(auto& [sampl, texture] : self.m_samplers) + for(auto& [sampl, texture, fb_] : self.m_samplers) { if(texture != &r.emptyTexture()) texture->deleteLater(); @@ -1507,7 +1634,7 @@ struct geometry_outputs_storage SCORE_ASSERT(it != edge_sink->node->input.end()); int n = it - edge_sink->node->input.begin(); - rendered_node->second->process(n, spc); + rendered_node->second->process(n, spc, edge.source); // 3. Same for transform3d @@ -1534,6 +1661,12 @@ struct geometry_outputs_storage avnd::get_outputs(state), [&](auto& field, auto pred) { this->upload(renderer, field, edge, pred); }); } + + // Lifecycle parity with the other *_outs storages. The geometry_spec + // wrapper carries non-owning pointers + transform values today, so + // release is a no-op — wired so future RHI handles on the storage + // release cleanly. + void release(score::gfx::RenderList&) noexcept { } }; @@ -1545,7 +1678,131 @@ struct geometry_outputs_storage { } + static void release(auto&&...) noexcept { } +}; + +// Scene output support (Crousti-side pending promotion to avendish). +// The `scene_port` concept and `scene_dirt_flags` live in SceneConcepts.hpp +// so the port-creation visitor in ProcessModelPortInit.hpp can reuse them. + +template +using is_scene_port_t = boost::mp11::mp_bool>; + +template +using scene_output_introspection = + avnd::predicate_introspection::type, is_scene_port_t>; + +template +using scene_input_introspection = + avnd::predicate_introspection::type, is_scene_port_t>; + +// Scene input transport: NodeRenderer::process(port, scene_spec, source) +// already merges multi-producer scenes into `this->scene`, so scene_inputs_storage +// only needs to copy that merged scene_spec into each halp scene input field +// before operator()() runs. Cheap (shared_ptr assignment), no decode. +template +struct scene_inputs_storage; + +template + requires(scene_input_introspection::size > 0) +struct scene_inputs_storage +{ + void readInputScenes(const ossia::scene_spec& scene, auto& state) + { + scene_input_introspection::for_all( + avnd::get_inputs(state), [&](auto& field) { field.scene = scene; }); + } + + static void release(score::gfx::RenderList&) { } +}; + +template + requires(scene_input_introspection::size == 0) +struct scene_inputs_storage +{ + static void readInputScenes(auto&&...) { } + static void release(auto&&...) { } +}; + +template +struct scene_outputs_storage; + +template + requires(scene_output_introspection::size > 0) +struct scene_outputs_storage +{ + template + void upload( + score::gfx::RenderList& renderer, Field& ctrl, score::gfx::Edge& edge, + avnd::predicate_index) + { + // Publish the scene every frame. The old behaviour skipped the push + // when `ctrl.dirty == 0` — but that broke multi-producer graphs: any + // other producer on the same downstream inlet (e.g. a legacy Geometry + // outlet of the same loader, or a Light node) pushes every frame + // unconditionally, and the consumer's NodeRenderer::process(...) logic + // replaces `this->scene` on the first push of each frame when + // `sceneChanged` is false (i.e. at frame start). A once-only scene push + // then gets overwritten every subsequent frame and its transforms are + // lost. Downstream consumers already short-circuit via shared_ptr + // identity + version (ScenePreprocessor checks m_cachedSceneState), so + // pushing every frame is cheap — just a few atomic refcount bumps. + // + // Producers can still use `ctrl.dirty` to track what changed for their + // own purposes; we don't consume the bits here anymore. + if(!ctrl.scene.state) + return; + + auto* edge_sink = edge.sink; + if(!edge_sink || !edge_sink->node) + return; + + auto rendered_node = edge_sink->node->renderedNodes.find(&renderer); + if(rendered_node == edge_sink->node->renderedNodes.end()) + return; + + auto it = std::find( + edge_sink->node->input.begin(), edge_sink->node->input.end(), edge_sink); + if(it == edge_sink->node->input.end()) + return; + int n = it - edge_sink->node->input.begin(); + + // NodeRenderer::process(port, scene_spec, source_key) handles additive + // merging across multiple producers converging on the same sink port + // (keyed on the source edge's producer Port pointer), extracts a legacy + // geometry_spec for downstream consumers that only understand geometry, + // and sets sceneChanged=true. + rendered_node->second->process(n, ctrl.scene, edge.source); + + if constexpr(requires { ctrl.dirty; }) + ctrl.dirty = 0; + } + + void upload(score::gfx::RenderList& renderer, auto& state, score::gfx::Edge& edge) + { + scene_output_introspection::for_all_n( + avnd::get_outputs(state), + [&](auto& field, auto pred) { this->upload(renderer, field, edge, pred); }); + } + + // Lifecycle parity with texture_outputs_storage / buffer_outputs_storage: + // the storage owns no QRhi resources today (the scene_spec is a value- + // semantics struct + a shared_ptr to scene_state, both managed by their + // own destructors), so release is a documented no-op. Mirror the call + // site naming so future RHI handles added to the storage have a release + // hook ready, and so CpuFilterNode / CpuAnalysisNode releaseState calls + // are symmetric across all storages. + void release(score::gfx::RenderList&) noexcept { } }; + +template + requires(scene_output_introspection::size == 0) +struct scene_outputs_storage +{ + static void upload(auto&&...) { } + static void release(auto&&...) noexcept { } +}; + } #endif diff --git a/src/plugins/score-plugin-avnd/Crousti/Layer.hpp b/src/plugins/score-plugin-avnd/Crousti/Layer.hpp index 9313a5b905..983c001a2b 100644 --- a/src/plugins/score-plugin-avnd/Crousti/Layer.hpp +++ b/src/plugins/score-plugin-avnd/Crousti/Layer.hpp @@ -16,8 +16,6 @@ #include #include -#include - namespace oscr { template diff --git a/src/plugins/score-plugin-avnd/Crousti/Metadata.hpp b/src/plugins/score-plugin-avnd/Crousti/Metadata.hpp index e8336fa5fd..8b9a2a0762 100644 --- a/src/plugins/score-plugin-avnd/Crousti/Metadata.hpp +++ b/src/plugins/score-plugin-avnd/Crousti/Metadata.hpp @@ -2,6 +2,7 @@ #include #include +#include #include @@ -154,12 +155,25 @@ struct ProcessPortVisitor this->texture(); } + template + void operator()(const avnd::field_reflection) + { + this->texture(); + } template void operator()(const avnd::field_reflection) { this->geometry(); } + // Scene ports travel through the same Process::PortType::Geometry slot. + template + requires(!avnd::geometry_port) + void operator()(const avnd::field_reflection) + { + this->geometry(); + } + template void operator()(const avnd::field_reflection) { diff --git a/src/plugins/score-plugin-avnd/Crousti/Metadatas.hpp b/src/plugins/score-plugin-avnd/Crousti/Metadatas.hpp index f591246b74..50348b4f58 100644 --- a/src/plugins/score-plugin-avnd/Crousti/Metadatas.hpp +++ b/src/plugins/score-plugin-avnd/Crousti/Metadatas.hpp @@ -1,6 +1,7 @@ #pragma once #include +#include #include @@ -11,24 +12,40 @@ namespace oscr { template -concept GpuNode = avnd::texture_input_introspection::size > 0 - || avnd::texture_output_introspection::size > 0 - || avnd::buffer_input_introspection::size > 0 - || avnd::buffer_output_introspection::size > 0 - || avnd::geometry_input_introspection::size > 0 - || avnd::geometry_output_introspection::size > 0; - +concept GpuNode + = avnd::texture_input_introspection::size > 0 + || avnd::texture_output_introspection::size > 0 + || avnd::buffer_input_introspection::size > 0 + || avnd::buffer_output_introspection::size > 0 + || avnd::geometry_input_introspection::size > 0 + || avnd::geometry_output_introspection::size > 0 + || scene_input_introspection::size > 0 + || scene_output_introspection::size > 0 + || avnd::gpu_render_target_output_port_output_introspection::size > 0; + +// Halp shader nodes (vertex+fragment / compute) currently route through +// CustomGpuRenderer / GpuComputeRenderer, neither of which carries +// geometry_ / scene_ I/O storage today. Exclude nodes that declare those +// ports from the GpuGraphicsNode2 / GpuComputeNode2 dispatch so they fall +// through to GfxNode<> (which has the proper storage via CpuFilterNode / +// CpuAnalysisNode). When CustomGpuRenderer / GpuComputeRenderer gain +// dedicated scene_ / geometry_ storage, drop the requires-clause exclusion +// here and add init_input + readInput / upload paths in those renderers. template -concept GpuGraphicsNode2 = requires -{ - T::layout::graphics; -}; +concept GpuGraphicsNode2 + = requires { T::layout::graphics; } + && (avnd::geometry_input_introspection::size == 0) + && (avnd::geometry_output_introspection::size == 0) + && (scene_input_introspection::size == 0) + && (scene_output_introspection::size == 0); template -concept GpuComputeNode2 = requires -{ - T::layout::compute; -}; +concept GpuComputeNode2 + = requires { T::layout::compute; } + && (avnd::geometry_input_introspection::size == 0) + && (avnd::geometry_output_introspection::size == 0) + && (scene_input_introspection::size == 0) + && (scene_output_introspection::size == 0); template concept is_gpu = GpuNode || GpuGraphicsNode2 || GpuComputeNode2; diff --git a/src/plugins/score-plugin-avnd/Crousti/ProcessModelPortInit.hpp b/src/plugins/score-plugin-avnd/Crousti/ProcessModelPortInit.hpp index 39c3de7bc4..89e6bb2931 100644 --- a/src/plugins/score-plugin-avnd/Crousti/ProcessModelPortInit.hpp +++ b/src/plugins/score-plugin-avnd/Crousti/ProcessModelPortInit.hpp @@ -4,6 +4,7 @@ #include #include +#include #include #include @@ -276,6 +277,22 @@ struct InletInitFunc #endif } + // Scene inputs reuse Gfx::GeometryInlet — a scene is a richer form of + // geometry and travels through the same Process-layer port. Mirror of the + // outlet overload below. Needed so scene-modifying halp nodes (Transform, + // SceneFilter, ...) can declare `struct { ossia::scene_spec scene; } scene_in;` + // in their inputs{} struct and get wired up by the framework. + template + requires(!avnd::geometry_port) + void operator()(const T& in, auto idx) + { +#if SCORE_PLUGIN_GFX + auto p = new Gfx::GeometryInlet(portName(), Id(inlet++), &self); + setupNewPort(in, p); + ins.push_back(p); +#endif + } + template void operator()(const avnd::field_reflection& in, auto dummy) { @@ -407,6 +424,16 @@ struct OutletInitFunc #endif } + template + void operator()(const T& out, auto idx) + { +#if SCORE_PLUGIN_GFX + auto p = new Gfx::TextureOutlet(portName(), Id(outlet++), &self); + setupNewPort(out, p); + outs.push_back(p); +#endif + } + template void operator()(const T& out, auto idx) { @@ -417,6 +444,20 @@ struct OutletInitFunc #endif } + // Scene outputs reuse Gfx::GeometryOutlet — a scene is a richer form of + // geometry that travels through the same Process-layer port. The Crousti + // upload path publishes scene_spec via NodeRenderer::process(scene_spec). + template + requires(!avnd::geometry_port) + void operator()(const T& out, auto idx) + { +#if SCORE_PLUGIN_GFX + auto p = new Gfx::GeometryOutlet(portName(), Id(outlet++), &self); + setupNewPort(out, p); + outs.push_back(p); +#endif + } + template void operator()(const T& out, auto idx) { diff --git a/src/plugins/score-plugin-avnd/Crousti/SceneConcepts.hpp b/src/plugins/score-plugin-avnd/Crousti/SceneConcepts.hpp new file mode 100644 index 0000000000..abe4e50fa0 --- /dev/null +++ b/src/plugins/score-plugin-avnd/Crousti/SceneConcepts.hpp @@ -0,0 +1,45 @@ +#pragma once + +// Scene port concept — shared between Crousti's port setup (type dispatch, +// port factory) and the GPU upload path. +// +// A halp output struct field is a "scene port" when it carries an +// `ossia::scene_spec scene` field. Scene output travels through the +// existing Gfx::GeometryOutlet / Types::Geometry: a scene is a richer form +// of geometry, same pattern as Process::TexturePort carrying any GPU +// resource. +// +// Once the design proves out, this should be promoted to avendish itself +// (3rdparty/avendish/include/avnd/concepts/gfx.hpp) under a corresponding +// scene concept alongside `geometry_port`. + +#include + +#include +#include + +namespace oscr +{ + +template +concept scene_port = requires(T t) { + { t.scene } -> std::convertible_to; +}; + +// Dirty-flag lexicon mirrors ossia::scene_port::dirt_flags so shader authors +// can signal fine-grained changes without republishing the whole scene. +// Users set bits on the halp field's `dirty` member; the upload path clears +// them after publishing. +namespace scene_dirt_flags +{ +constexpr uint8_t transform = 0x01; +constexpr uint8_t geometry = 0x02; +constexpr uint8_t materials = 0x04; +constexpr uint8_t lights = 0x08; +constexpr uint8_t animation = 0x10; +constexpr uint8_t environment = 0x20; +constexpr uint8_t structure = 0x40; +constexpr uint8_t all = 0xFF; +} + +} diff --git a/src/plugins/score-plugin-gfx/3rdparty/libisf/src/isf.cpp b/src/plugins/score-plugin-gfx/3rdparty/libisf/src/isf.cpp index 66cb9114c9..c4500a5da0 100644 --- a/src/plugins/score-plugin-gfx/3rdparty/libisf/src/isf.cpp +++ b/src/plugins/score-plugin-gfx/3rdparty/libisf/src/isf.cpp @@ -41,10 +41,14 @@ layout(location = 0) out vec2 isf_FragNormCoord; static constexpr auto vertexInitFunc = R"_( void isf_vertShaderInit() { - gl_Position = clipSpaceCorrMatrix * vec4( position, 0.0, 1.0 ); + gl_Position = clipSpaceCorrMatrix * vec4(position, 0.0, 1.0); isf_FragNormCoord = vec2((gl_Position.x+1.0)/2.0, (gl_Position.y+1.0)/2.0); +} + +void isf_vertShaderFinish() +{ #if defined(QSHADER_SPIRV) || defined(QSHADER_HLSL) || defined(QSHADER_MSL) - gl_Position.y = - gl_Position.y; + gl_Position.y = -gl_Position.y; #endif } )_"; @@ -53,6 +57,7 @@ void isf_vertShaderInit() void main() { isf_vertShaderInit(); + isf_vertShaderFinish(); } )_"; @@ -67,12 +72,18 @@ layout(std140, binding = 0) uniform renderer_t { mat4 clipSpaceCorrMatrix_; vec2 RENDERSIZE_; + // MSAA sample count of the active output target (1 when MSAA is off). + // Mirrors RenderList::samples(); needed because glslang strips + // gl_NumSamples under SPIR-V. _pad0 keeps the struct vec4-aligned. + int MSAA_SAMPLES_; + int _renderer_pad0_; } isf_renderer_uniforms; // This dance is needed because otherwise // spirv-cross may generate different struct names in the vertex & fragment, causing crashes.. // but we have to keep compat with ISF #define clipSpaceCorrMatrix isf_renderer_uniforms.clipSpaceCorrMatrix_ +#define MSAA_SAMPLES isf_renderer_uniforms.MSAA_SAMPLES_ // Time-dependent uniforms, only relevant during execution layout(std140, binding = 1) uniform process_t { @@ -86,6 +97,15 @@ layout(std140, binding = 1) uniform process_t { vec2 RENDERSIZE_; vec4 DATE_; + // Mirrors gl_NumWorkGroups for compute shaders. SPIRV-Cross's HLSL + // backend refuses to emit code for the NumWorkgroups built-in unless + // remap_num_workgroups_builtin() is set up on both the cross-compiler + // and the QRhi side; QShaderBaker exposes neither, so any compute + // shader using gl_NumWorkGroups silently fails to bake to HLSL on + // D3D11/D3D12. We sidestep that by routing references through this + // uniform — populated host-side just before each dispatch — and + // textually shadowing the built-in via the #define below. + uvec3 NUMWORKGROUPS_; } isf_process_uniforms; #define TIME isf_process_uniforms.TIME_ @@ -95,12 +115,29 @@ layout(std140, binding = 1) uniform process_t { #define FRAMEINDEX isf_process_uniforms.FRAMEINDEX_ #define RENDERSIZE isf_process_uniforms.RENDERSIZE_ #define DATE isf_process_uniforms.DATE_ +#define SAMPLERATE isf_process_uniforms.SAMPLERATE_ +#define gl_NumWorkGroups isf_process_uniforms.NUMWORKGROUPS_ +#define isf_NumWorkGroups isf_process_uniforms.NUMWORKGROUPS_ )_"; static constexpr auto defaultFunctions = R"_( +// GLSL's textureSize is overloaded by sampler dimensionality — sampler2D +// returns ivec2, sampler3D returns ivec3. Authors typically reach for +// TEX_DIMENSIONS regardless of 2D/3D; the *_2D / *_3D aliases below make +// the intended dimensionality explicit in shader source. #define TEX_DIMENSIONS(tex) textureSize(tex, 0) +#define TEX_DIMENSIONS_2D(tex) textureSize(tex, 0) +#define TEX_DIMENSIONS_3D(tex) textureSize(tex, 0) #define IMG_SIZE(tex) textureSize(tex, 0) +#define IMG_SIZE_3D(tex) textureSize(tex, 0) + +// IMG_CUBE(tex, dir) — canonical colour-cube read; same in both coord systems +// since a direction vector has no Y-flip. IMG_CUBE_DEPTH(tex, dir) — +// canonical depth-cube read for inputs declared DEPTH: true on a cubemap, +// hides the internal `_depth` companion binding. +#define IMG_CUBE(tex, dir) texture(tex, dir) +#define IMG_CUBE_DEPTH(tex, dir) texture(tex##_depth, dir).r #if defined(QSHADER_SPIRV) #define isf_FragCoord vec4(gl_FragCoord.x, RENDERSIZE.y - gl_FragCoord.y, gl_FragCoord.z, gl_FragCoord.w) @@ -384,6 +421,86 @@ static bool parse_input_impl(sajson::value& v, bool) return v.get_type() == sajson::TYPE_TRUE; } +// Parse sampler-config fields from a JSON input object directly (flat fields, +// no nested "SAMPLER" object). All fields optional; missing = keep default. +static void parse_sampler_config(sampler_config& s, const sajson::value& v) +{ + auto str_field = [&](const char* key, std::string& out) { + if(auto k = v.find_object_key_insensitive(sajson::literal(key)); + k != v.get_length()) + { + auto val = v.get_object_value(k); + if(val.get_type() == sajson::TYPE_STRING) + out = val.as_string(); + } + }; + auto float_field = [&](const char* key, std::optional& out) { + if(auto k = v.find_object_key_insensitive(sajson::literal(key)); + k != v.get_length()) + { + auto val = v.get_object_value(k); + if(is_number(val)) + out = (float)val.get_number_value(); + } + }; + + str_field("WRAP", s.wrap); + str_field("WRAP_S", s.wrap_s); + str_field("WRAP_T", s.wrap_t); + str_field("WRAP_R", s.wrap_r); + str_field("FILTER", s.filter); + str_field("MIN_FILTER", s.min_filter); + str_field("MAG_FILTER", s.mag_filter); + str_field("MIPMAP_MODE", s.mipmap_mode); + str_field("BORDER_COLOR", s.border_color); + str_field("COMPARE", s.compare); + float_field("ANISOTROPY", s.anisotropy); + float_field("LOD_BIAS", s.lod_bias); + float_field("MIN_LOD", s.min_lod); + float_field("MAX_LOD", s.max_lod); +} + +// Audio inputs expose only FILTER and WRAP — audio textures are 1-mip +// 2D samplers so the rest of sampler_config (COMPARE / BORDER_COLOR / LOD +// / anisotropy) has no meaningful effect. +static void parse_audio_sampler_config(audio_sampler_config& s, const sajson::value& v) +{ + auto str_field = [&](const char* key, std::string& out) { + if(auto k = v.find_object_key_insensitive(sajson::literal(key)); + k != v.get_length()) + { + auto val = v.get_object_value(k); + if(val.get_type() == sajson::TYPE_STRING) + out = val.as_string(); + } + }; + str_field("FILTER", s.filter); + str_field("WRAP", s.wrap); +} + +// Drop COMPARE from a sampler config whose texture shape has no corresponding +// *Shadow GLSL sampler type. A non-"never" COMPARE makes the runtime call +// QRhiSampler::setTextureCompareOp, which on Vulkan requires the shader-side +// binding to be a shadow sampler (compareEnable=VK_TRUE is a validation +// error otherwise) and on the other backends produces undefined reads. The +// only core-GLSL shape without a shadow variant is 3D — sampler3DShadow is +// not a core type. 2D / 2D-array / cube / cube-array all have shadow +// counterparts and are handled by the emitter. +static void drop_unsupported_compare_3d(sampler_config& s, const char* where) +{ + if(s.compare.empty()) return; + std::string c = s.compare; + for(auto& ch : c) ch = (char)tolower(ch); + if(c == "never") return; + fmt::print( + stderr, + "[isf] {}: COMPARE is set but sampler3DShadow is not a core GLSL " + "sampler type — ignoring. Use a 2D, 2D-array, cubemap or cubemap-array " + "shadow sampler instead.\n", + where); + s.compare.clear(); +} + static void parse_input(image_input& inp, const sajson::value& v) { if(auto k = v.find_object_key_insensitive(sajson::literal("DIMENSIONS")); @@ -391,15 +508,64 @@ static void parse_input(image_input& inp, const sajson::value& v) { auto val = v.get_object_value(k); if(val.get_type() == sajson::TYPE_INTEGER) - inp.dimensions = val.get_integer_value(); + { + auto d = val.get_integer_value(); + if(d != 2 && d != 3) + throw invalid_file{ + "image_input DIMENSIONS must be 2 or 3 (got " + std::to_string(d) + + "). 1D and 4D textures are not supported."}; + inp.dimensions = d; + } } if(auto k = v.find_object_key_insensitive(sajson::literal("DEPTH")); k != v.get_length()) { inp.depth = v.get_object_value(k).get_type() == sajson::TYPE_TRUE; } + if(auto k = v.find_object_key_insensitive(sajson::literal("IS_ARRAY")); + k != v.get_length()) + { + inp.is_array = v.get_object_value(k).get_type() == sajson::TYPE_TRUE; + } + else if(auto k2 = v.find_object_key_insensitive(sajson::literal("ARRAY")); + k2 != v.get_length()) + { + inp.is_array = v.get_object_value(k2).get_type() == sajson::TYPE_TRUE; + } + // STATIC: shader author opts into "upstream publishes a long-lived + // QRhiTexture, bind it directly". Engine path = same Flag::GrabsFromSource + // already used for cube / 3D / array inputs (those grab implicitly + // because they can't be 2D color attachments). For plain 2D texture + // inputs both modes are valid — RT-render (compositor pattern) is the + // safe default; STATIC: true opts into direct binding for static-LUT / + // IBL-bake / asset-cache producers (avnd gpu_texture_output, etc.). + if(auto k = v.find_object_key_insensitive(sajson::literal("STATIC")); + k != v.get_length()) + { + inp.is_static = v.get_object_value(k).get_type() == sajson::TYPE_TRUE; + } + parse_sampler_config(inp.sampler, v); + if(inp.dimensions == 3) + { + drop_unsupported_compare_3d(inp.sampler, "image input (DIMENSIONS: 3)"); + if(inp.is_array) + { + throw invalid_file{ + "image input: DIMENSIONS: 3 with ARRAY: true is not supported — " + "sampler3DArray is not a core GLSL type. Use a 3D texture and drop " + "ARRAY, or a 2D-array texture and drop DIMENSIONS: 3."}; + } + } +} +static void parse_input(cubemap_input& inp, const sajson::value& v) +{ + if(auto k = v.find_object_key_insensitive(sajson::literal("DEPTH")); + k != v.get_length()) + { + inp.depth = v.get_object_value(k).get_type() == sajson::TYPE_TRUE; + } + parse_sampler_config(inp.sampler, v); } -static void parse_input(cubemap_input& inp, const sajson::value& v) { } static void parse_input(event_input& inp, const sajson::value& v) { } @@ -419,6 +585,7 @@ static void parse_input(audio_input& inp, const sajson::value& v) } } } + parse_audio_sampler_config(inp.sampler, v); } static void parse_input(audioHist_input& inp, const sajson::value& v) @@ -437,6 +604,7 @@ static void parse_input(audioHist_input& inp, const sajson::value& v) } } } + parse_audio_sampler_config(inp.sampler, v); } // CSF-specific parsing functions @@ -497,6 +665,106 @@ static void parse_input(storage_input& inp, const sajson::value& v) if(val.get_type() == sajson::TYPE_STRING) inp.buffer_usage = val.as_string(); } + else if(k == "PERSISTENT") + { + inp.persistent = v.get_object_value(i).get_type() == sajson::TYPE_TRUE; + } + else if(k == "VISIBILITY") + { + auto val = v.get_object_value(i); + if(val.get_type() == sajson::TYPE_STRING) + inp.visibility = val.as_string(); + } + } + + // Warn on semantically-impossible combinations. PERSISTENT allocates a + // ping-pong pair and always emits `_prev` as a readonly buffer — if the + // primary is write_only, nothing ever writes the data that _prev is + // supposed to read back, so it's silently always zero. + if(inp.persistent && inp.access == "write_only") + { + throw invalid_file{ + "storage input declared as PERSISTENT + ACCESS: write_only is " + "invalid — _prev would always read zero (no read path exists to " + "populate it). Use ACCESS: read_write or read_only with PERSISTENT, " + "or drop PERSISTENT if you don't need frame history."}; + } + + // Reject empty LAYOUT for non-indirect storage_inputs. The graphics + // emit at isf_emit_graphics_storage / isf_emit_ssbo_decl produces an + // empty `readonly buffer NAME_buf { };` block which is invalid GLSL + // (`buffer { };` requires at least one member declarator). shaderc + // then fails with a cryptic message pointing at the auto-emitted + // block. uniform_input has the symmetric check at parse_input(uniform). + // Indirect-draw SSBOs LEGITIMATELY have empty LAYOUT — they are + // skipped from graphics emit (isf.cpp:3361-3363) when buffer_usage is + // non-empty. Match that gate here so legitimate indirect-draw paths + // pass through unchallenged. + if(inp.layout.empty() && inp.buffer_usage.empty()) + { + throw invalid_file{ + "storage_input declares an empty LAYOUT and no BUFFER_USAGE — " + "the SSBO graphics emit would produce `readonly buffer NAME_buf " + "{ };` which is invalid GLSL (a buffer block must have at least " + "one member declarator). Empty LAYOUT only makes sense for " + "indirect-draw SSBOs which set BUFFER_USAGE: \"indirect_draw\" " + "or \"indirect_draw_indexed\". Either declare members in LAYOUT " + "or set BUFFER_USAGE."}; + } +} + +static void parse_input(uniform_input& inp, const sajson::value& v) +{ + std::size_t N = v.get_length(); + for(std::size_t i = 0; i < N; i++) + { + auto k = v.get_object_key(i).as_string(); + if(k == "LAYOUT") + { + auto val = v.get_object_value(i); + if(val.get_type() == sajson::TYPE_ARRAY) + { + std::size_t layout_size = val.get_length(); + inp.layout.reserve(layout_size); + for(std::size_t j = 0; j < layout_size; j++) + { + auto field = val.get_array_element(j); + if(field.get_type() != sajson::TYPE_OBJECT) + continue; + uniform_input::layout_field lf; + for(std::size_t f = 0; f < field.get_length(); f++) + { + auto fk = field.get_object_key(f).as_string(); + if(fk == "NAME") + { + auto nv = field.get_object_value(f); + if(nv.get_type() == sajson::TYPE_STRING) + lf.name = nv.as_string(); + } + else if(fk == "TYPE") + { + auto tv = field.get_object_value(f); + if(tv.get_type() == sajson::TYPE_STRING) + lf.type = tv.as_string(); + } + } + inp.layout.push_back(lf); + } + } + } + else if(k == "VISIBILITY") + { + auto val = v.get_object_value(i); + if(val.get_type() == sajson::TYPE_STRING) + inp.visibility = val.as_string(); + } + } + if(inp.layout.empty()) + { + throw invalid_file{ + "uniform_input declares an empty LAYOUT — std140 interface blocks " + "must contain at least one field. Either declare its members in " + "LAYOUT: [{ NAME, TYPE }, ...] or remove the input."}; } } @@ -507,8 +775,18 @@ static void parse_input(texture_input& inp, const sajson::value& v) { auto val = v.get_object_value(k); if(val.get_type() == sajson::TYPE_INTEGER) - inp.dimensions = val.get_integer_value(); + { + auto d = val.get_integer_value(); + if(d != 2 && d != 3) + throw invalid_file{ + "texture_input DIMENSIONS must be 2 or 3 (got " + std::to_string(d) + + "). 1D and 4D textures are not supported."}; + inp.dimensions = d; + } } + parse_sampler_config(inp.sampler, v); + if(inp.dimensions == 3) + drop_unsupported_compare_3d(inp.sampler, "texture input (DIMENSIONS: 3)"); } // Parse a COPY_FROM JSON object. @@ -540,17 +818,213 @@ parse_copy_from(const sajson::value& obj) return cf; } -// Parse an AUXILIARY JSON array into a vector of auxiliary_request. +// Detect whether an AUXILIARY entry declares a texture (TYPE: "image" / +// "cubemap" / "texture") rather than a buffer. Buffers are the default +// (TYPE absent, or "storage" / "buffer"). +// Three-way classification of an AUXILIARY JSON entry: +// Ssbo — default; declared either without TYPE or with TYPE: +// "storage" / "buffer" / "ssbo". Layout maps to an std430 +// `buffer` block bound as bufferLoad / bufferStore / bufferLoadStore. +// Ubo — TYPE: "uniform" / "ubo". Layout maps to an std140 `uniform` +// block bound as uniformBuffer. +// Texture — TYPE: "image" / "texture" / "cubemap" / "image_cube" / +// "storage_*". Goes through the auxiliary_texture_request pool. +enum class aux_kind { Ssbo, Ubo, Texture }; + +static aux_kind aux_entry_kind(const sajson::value& aux_obj) +{ + auto k = aux_obj.find_object_key_insensitive(sajson::literal("TYPE")); + if(k == aux_obj.get_length()) + return aux_kind::Ssbo; + auto v = aux_obj.get_object_value(k); + if(v.get_type() != sajson::TYPE_STRING) + return aux_kind::Ssbo; + std::string t = v.as_string(); + for(auto& c : t) c = (char)tolower(c); + if(t == "image" || t == "texture" || t == "cubemap" || t == "image_cube" + || t == "storage_image" || t == "storage_cube" + || t == "storage_image_array" || t == "storage_3d") + return aux_kind::Texture; + if(t == "uniform" || t == "ubo") + return aux_kind::Ubo; + return aux_kind::Ssbo; +} + +// Parse a single texture auxiliary entry. +static void parse_auxiliary_texture( + const sajson::value& aux_obj, + geometry_input::auxiliary_texture_request& out) +{ + for(std::size_t f = 0; f < aux_obj.get_length(); f++) + { + auto fkey = aux_obj.get_object_key(f).as_string(); + auto fval = aux_obj.get_object_value(f); + + if(fkey == "NAME" && fval.get_type() == sajson::TYPE_STRING) + out.name = fval.as_string(); + else if(fkey == "TYPE" && fval.get_type() == sajson::TYPE_STRING) + { + std::string t = fval.as_string(); + for(auto& c : t) c = (char)tolower(c); + if(t == "cubemap" || t == "image_cube") + out.is_cubemap = true; + else if(t == "storage_image") + out.is_storage = true; + else if(t == "storage_cube") + { out.is_storage = true; out.is_cubemap = true; } + else if(t == "storage_image_array") + { out.is_storage = true; out.is_array = true; } + else if(t == "storage_3d") + { out.is_storage = true; out.dimensions = 3; } + } + else if(fkey == "DIMENSIONS") + { + if(fval.get_type() == sajson::TYPE_INTEGER) + out.dimensions = fval.get_integer_value(); + } + else if(fkey == "IS_ARRAY" || fkey == "ARRAY") + out.is_array = (fval.get_type() == sajson::TYPE_TRUE); + else if(fkey == "DEPTH") + { + // DEPTH overload — context-dependent: + // "DEPTH": true → legacy sampleable-depth flag (paired with + // COMPARE for shadow-comparison samplers) + // "DEPTH": → 3D-texture depth dimension literal + // "DEPTH": "" → 3D-texture depth dimension expression + // Distinguishable by sajson type so authors can use either form + // without the parser silently dropping one. + const auto t = fval.get_type(); + if(t == sajson::TYPE_TRUE) + out.is_depth = true; + else if(t == sajson::TYPE_FALSE) + out.is_depth = false; + else if(t == sajson::TYPE_INTEGER) + out.depth_expression = std::to_string(fval.get_integer_value()); + else if(t == sajson::TYPE_DOUBLE) + out.depth_expression = std::to_string(fval.get_double_value()); + else if(t == sajson::TYPE_STRING) + out.depth_expression = fval.as_string(); + } + else if(fkey == "STORAGE") + out.is_storage = (fval.get_type() == sajson::TYPE_TRUE); + else if(fkey == "FORMAT" && fval.get_type() == sajson::TYPE_STRING) + out.format = fval.as_string(); + else if(fkey == "ACCESS" && fval.get_type() == sajson::TYPE_STRING) + out.access = fval.as_string(); + // WIDTH / HEIGHT / LAYERS — same expression-or-literal convention as + // csf_image_input. Strings allow `$var` substitution against the + // shader's long/float inputs at allocation time. + else if(fkey == "WIDTH") + { + const auto t = fval.get_type(); + if(t == sajson::TYPE_INTEGER) + out.width_expression = std::to_string(fval.get_integer_value()); + else if(t == sajson::TYPE_DOUBLE) + out.width_expression = std::to_string(fval.get_double_value()); + else if(t == sajson::TYPE_STRING) + out.width_expression = fval.as_string(); + } + else if(fkey == "HEIGHT") + { + const auto t = fval.get_type(); + if(t == sajson::TYPE_INTEGER) + out.height_expression = std::to_string(fval.get_integer_value()); + else if(t == sajson::TYPE_DOUBLE) + out.height_expression = std::to_string(fval.get_double_value()); + else if(t == sajson::TYPE_STRING) + out.height_expression = fval.as_string(); + } + else if(fkey == "LAYERS") + { + const auto t = fval.get_type(); + if(t == sajson::TYPE_INTEGER) + out.layers_expression = std::to_string(fval.get_integer_value()); + else if(t == sajson::TYPE_DOUBLE) + out.layers_expression = std::to_string(fval.get_double_value()); + else if(t == sajson::TYPE_STRING) + out.layers_expression = fval.as_string(); + } + } + + // depth_expression non-empty implies a 3D texture even if DIMENSIONS + // wasn't set explicitly. Mirrors csf_image_input::is3D() semantics — + // saves the author from writing both fields. + if(!out.depth_expression.empty() && out.dimensions == 2) + out.dimensions = 3; + + // Auto-infer storage-image semantics when FORMAT is explicitly set to + // anything other than the sampled-texture default (rgba8). Allows + // author-friendly declarations like: + // + // { "NAME": "voxel_grid", "TYPE": "image", "ACCESS": "read_write", + // "FORMAT": "r32ui", "DIMENSIONS": 3, ... } + // + // to be parsed as a storage image without forcing the author to + // additionally write `"STORAGE": true` or use the more-cryptic + // `"TYPE": "storage_3d"`. + // + // ONLY uses FORMAT — NOT ACCESS — because `access` defaults to + // "read_write" in the struct (it's only meaningful when is_storage is + // already true), so an ACCESS-based heuristic would mis-fire on every + // sampled-aux entry that doesn't explicitly override it. FORMAT + // defaults to "rgba8" which is also the sampled-image default, so the + // discriminator is "did the author explicitly write a non-rgba8 + // FORMAT?" — unambiguous either way. If you want a storage rgba8 + // image, write `"STORAGE": true` explicitly. + if(!out.is_storage) + { + const bool format_implies_storage + = !out.format.empty() && out.format != "rgba8"; + if(format_implies_storage) + out.is_storage = true; + } + // Inherit the flat sampler_config fields (WRAP/FILTER/COMPARE/…). + parse_sampler_config(out.sampler, aux_obj); + // Storage images don't use the sampler; regular samplers on a 3D texture + // have no shadow variant. Cubemap and 2D-array shapes have shadow variants + // and are fine. + if(!out.is_storage && !out.is_cubemap && out.dimensions == 3) + drop_unsupported_compare_3d( + out.sampler, + fmt::format("auxiliary texture '{}' (DIMENSIONS: 3)", out.name).c_str()); + // Cube-arrays (samplerCubeArray / imageCubeArray) are unsupported: every + // QRhi backend silently collapses `CubeMap | TextureArray` to one flag or + // the other at view-creation time (Vulkan qrhivulkan.cpp:7736+, + // D3D12:1160+, Metal:4025+, GL:6124+), so the shader-side type and the + // bound resource disagree. Reject at parse time rather than ship broken + // bindings. Same story for 3D cubemaps (nonsensical). + if(out.is_cubemap && out.is_array) + { + throw invalid_file{ + "auxiliary texture '" + out.name + + "': cubemap + ARRAY is not supported on any QRhi backend " + "(cube-array views are not constructible). Use a plain cubemap, " + "or decompose to a 2D array and do face math in the shader."}; + } + if(out.is_cubemap && out.dimensions == 3) + { + fmt::print( + stderr, + "[isf] auxiliary texture '{}': cubemap with DIMENSIONS: 3 is " + "meaningless (cube faces are 2D). Ignoring DIMENSIONS.\n", + out.name); + out.dimensions = 2; + } +} + +// Parse an AUXILIARY JSON array, dispatching each entry by TYPE into +// either the buffer list or the texture list. // Shared by geometry_input parsing and top-level AUXILIARY key. static void parse_auxiliary_array( const sajson::value& val, - std::vector& out) + std::vector& out_buffers, + std::vector& out_textures) { if(val.get_type() != sajson::TYPE_ARRAY) return; std::size_t aux_count = val.get_length(); - out.reserve(aux_count); + out_buffers.reserve(out_buffers.size() + aux_count); for(std::size_t j = 0; j < aux_count; j++) { @@ -558,7 +1032,21 @@ static void parse_auxiliary_array( if(aux_obj.get_type() != sajson::TYPE_OBJECT) continue; + const aux_kind kind = aux_entry_kind(aux_obj); + if(kind == aux_kind::Texture) + { + geometry_input::auxiliary_texture_request tr; + parse_auxiliary_texture(aux_obj, tr); + if(!tr.name.empty()) + out_textures.push_back(std::move(tr)); + continue; + } + geometry_input::auxiliary_request ar; + // UBO kind: flag set on the request so both parser-side GLSL emission + // and runtime-side binding know to treat it as a std140 uniform block. + // Buffer-kind SSBO is the default (is_uniform stays false). + ar.is_uniform = (kind == aux_kind::Ubo); for(std::size_t f = 0; f < aux_obj.get_length(); f++) { @@ -611,12 +1099,61 @@ static void parse_auxiliary_array( { ar.forward = parse_copy_from(fval); } + else if(fkey == "PERSISTENT") + { + if(fval.get_type() == sajson::TYPE_TRUE) + ar.persistent = true; + else if(fval.get_type() == sajson::TYPE_FALSE) + ar.persistent = false; + } } if(ar.access.empty()) ar.access = "read_only"; - out.push_back(std::move(ar)); + out_buffers.push_back(std::move(ar)); + } +} + +// Validate that every geometry_input ATTRIBUTE.TYPE either names a +// built-in GLSL scalar/vector/matrix type or matches a user-defined +// struct declared in descriptor::types. Run AFTER both RESOURCES and +// TYPES are parsed (TYPES may appear in any order in the JSON) — i.e. +// once at the end of parse_csf / parse_raw_raster_pipeline. Catches +// typos in TYPE strings at parse time instead of as a confusing +// "undefined identifier" GLSL compile error 30 lines deep into the +// generated shader. +static void validate_attribute_types(const descriptor& d) +{ + static constexpr std::string_view builtins[] = { + "float", "int", "uint", "bool", + "vec2", "vec3", "vec4", + "ivec2", "ivec3", "ivec4", + "uvec2", "uvec3", "uvec4", + "mat2", "mat3", "mat4" + }; + auto is_builtin = [](std::string_view t) noexcept { + for(auto b : builtins) if(t == b) return true; + return false; + }; + auto is_user_type = [&](std::string_view t) noexcept { + for(const auto& td : d.types) if(td.name == t) return true; + return false; + }; + for(const auto& inp : d.inputs) + { + auto* gi = ossia::get_if(&inp.data); + if(!gi) continue; + for(const auto& ar : gi->attributes) + { + if(ar.type.empty()) continue; + if(is_builtin(ar.type) || is_user_type(ar.type)) continue; + throw invalid_file{ + "ATTRIBUTES \"" + ar.name + "\" on geometry resource \"" + inp.name + + "\" declares TYPE \"" + ar.type + + "\", which is neither a built-in GLSL scalar/vector/matrix type " + "nor a user-defined type from the TYPES section."}; + } } } @@ -703,27 +1240,79 @@ static void parse_input(geometry_input& inp, const sajson::value& v) else if(val.get_type() == sajson::TYPE_DOUBLE) inp.instance_count = std::to_string((int)val.get_double_value()); } + else if(k == "FORMAT_ID") + { + // String tag stamped on the consumer geometry's filter_tag + // (rapidhash truncated to 32 bits). Lets a CSF that produces + // primitive-cloud-shaped output declare its format identity in + // the JSON header without engine-side knowledge of the format. + auto val = v.get_object_value(i); + if(val.get_type() == sajson::TYPE_STRING) + inp.format_id = val.as_string(); + } else if(k == "AUXILIARY") { - parse_auxiliary_array(v.get_object_value(i), inp.auxiliary); + parse_auxiliary_array(v.get_object_value(i), inp.auxiliary, inp.auxiliary_textures); } - else if(k == "INDIRECT_DRAW") + else if(k == "INDIRECT") { auto val = v.get_object_value(i); - if(val.get_type() == sajson::TYPE_TRUE) - inp.indirect_draw = true; - else if(val.get_type() == sajson::TYPE_FALSE) - inp.indirect_draw = false; + if(val.get_type() == sajson::TYPE_OBJECT) + { + geometry_input::indirect_request req; + for(std::size_t j = 0; j < val.get_length(); j++) + { + auto ik = val.get_object_key(j).as_string(); + boost::algorithm::to_upper(ik); + if(ik == "COUNT") + { + auto iv = val.get_object_value(j); + if(iv.get_type() == sajson::TYPE_STRING) + req.count = iv.as_string(); + else if(iv.get_type() == sajson::TYPE_INTEGER) + req.count = std::to_string(iv.get_integer_value()); + else if(iv.get_type() == sajson::TYPE_DOUBLE) + req.count = std::to_string((int)iv.get_double_value()); + } + } + if(req.count.empty()) + req.count = "1"; + inp.indirect = req; + } } - else if(k == "INDIRECT_DRAW_TYPE") + else if(k == "INDIRECT_DRAW") { auto val = v.get_object_value(i); - if(val.get_type() == sajson::TYPE_STRING) - inp.indirect_draw_type = val.as_string(); + if(val.get_type() == sajson::TYPE_TRUE) + inp.indirect = geometry_input::indirect_request{.count = "1"}; } } } +// Known GLSL image format qualifiers. Used for a parse-time sanity check — +// lets the shader author see a typo ("rgba16" vs "rgba16f") before the +// runtime silently falls back to rgba8. Strict GLSL image-format typing +// validation (matching imageStore argument types to declared formats) would +// need a full GLSL AST which this parser does not build; the most useful +// check we can do cheaply is reject unknown format strings. +static bool isf_is_known_image_format(std::string fmt) +{ + boost::algorithm::to_lower(fmt); + static const ossia::hash_set known{ + "rgba8", "rgba8_snorm", "rgba8ui", "rgba8i", + "rgba16", "rgba16_snorm", "rgba16f", "rgba16ui", "rgba16i", + "rgba32f","rgba32ui", "rgba32i", + "rg8", "rg8_snorm", "rg8ui", "rg8i", + "rg16", "rg16_snorm", "rg16f", "rg16ui", "rg16i", + "rg32f", "rg32ui", "rg32i", + "r8", "r8_snorm", "r8ui", "r8i", + "r16", "r16_snorm", "r16f", "r16ui", "r16i", + "r32f", "r32ui", "r32i", + "rgb10_a2", "rgb10_a2ui", "r11f_g11f_b10f", + "bgra8"}; + return known.count(fmt) > 0; +} + static void parse_input(csf_image_input& inp, const sajson::value& v) { std::size_t N = v.get_length(); @@ -741,7 +1330,18 @@ static void parse_input(csf_image_input& inp, const sajson::value& v) { auto val = v.get_object_value(i); if(val.get_type() == sajson::TYPE_STRING) + { inp.format = val.as_string(); + if(!inp.format.empty() && !isf_is_known_image_format(inp.format)) + { + fmt::print( + stderr, + "[isf] csf_image_input FORMAT \"{}\" is not a recognised GLSL " + "image qualifier — will fall back to rgba8 at runtime. Check " + "for typos (e.g. \"rgba16\" vs \"rgba16f\").\n", + inp.format); + } + } } else if(k == "WIDTH") { @@ -798,10 +1398,90 @@ static void parse_input(csf_image_input& inp, const sajson::value& v) { auto val = v.get_object_value(i); if(val.get_type() == sajson::TYPE_INTEGER) - inp.dimensions = val.get_integer_value(); + { + auto d = val.get_integer_value(); + if(d != 2 && d != 3) + throw invalid_file{ + "csf_image_input DIMENSIONS must be 2 or 3 (got " + std::to_string(d) + + "). 1D and 4D textures are not supported."}; + inp.dimensions = d; + } else if(val.get_type() == sajson::TYPE_DOUBLE) - inp.dimensions = (int)val.get_double_value(); + { + auto d = (int)val.get_double_value(); + if(d != 2 && d != 3) + throw invalid_file{ + "csf_image_input DIMENSIONS must be 2 or 3 (got " + std::to_string(d) + + "). 1D and 4D textures are not supported."}; + inp.dimensions = d; + } + } + else if(k == "VISIBILITY") + { + auto val = v.get_object_value(i); + if(val.get_type() == sajson::TYPE_STRING) + inp.visibility = val.as_string(); + } + else if(k == "PERSISTENT") + { + inp.persistent = v.get_object_value(i).get_type() == sajson::TYPE_TRUE; + } + else if(k == "GENERATE_MIPS") + { + inp.generate_mips = v.get_object_value(i).get_type() == sajson::TYPE_TRUE; + } + else if(k == "IS_ARRAY" || k == "ARRAY") + { + inp.is_array = v.get_object_value(i).get_type() == sajson::TYPE_TRUE; } + else if(k == "LAYERS") + { + auto val = v.get_object_value(i); + auto t = val.get_type(); + if(t == sajson::TYPE_STRING) + inp.layers_expression = val.as_string(); + else if(t == sajson::TYPE_INTEGER) + inp.layers_expression = std::to_string(val.get_integer_value()); + else if(t == sajson::TYPE_DOUBLE) + inp.layers_expression = std::to_string(val.get_double_value()); + } + else if(k == "CUBEMAP" || k == "IS_CUBE") + { + inp.cubemap = v.get_object_value(i).get_type() == sajson::TYPE_TRUE; + } + } + + // See the matching note on storage_input — persistent + write_only has no + // useful semantics because _prev is readonly and nothing writes it. + if(inp.persistent && inp.access == "write_only") + { + throw invalid_file{ + "csf_image_input declared as PERSISTENT + ACCESS: write_only is " + "invalid — _prev would always read zero (no read path exists to " + "populate it). Use ACCESS: read_write or read_only with PERSISTENT, " + "or drop PERSISTENT."}; + } + + // Cube-array writable images are unsupported (see sampler-side analysis in + // parse_auxiliary_texture / isf.hpp). Reject here so downstream allocators + // and the GLSL emitter can assume the combo never shows up. + if(inp.is_array && inp.cubemap) + { + throw invalid_file{ + "csf_image_input: IS_ARRAY + image_cube is not supported — " + "imageCubeArray views are broken on every QRhi backend. Bind N " + "separate cubemaps or use image2DArray and do face math in the " + "shader."}; + } + // 3D arrays do not exist as a core GLSL image type either. + if(inp.is_array && inp.is3D()) + { + fmt::print( + stderr, + "[isf] csf_image_input: IS_ARRAY + 3D image (DIMENSIONS: 3 or DEPTH " + "expression) is not a valid GLSL type (image3DArray is not core). " + "Dropping IS_ARRAY.\n"); + inp.is_array = false; } } @@ -821,6 +1501,7 @@ static void parse_input(audioFFT_input& inp, const sajson::value& v) } } } + parse_audio_sampler_config(inp.sampler, v); } static void parse_input(long_input& inp, const sajson::value& v) @@ -1010,6 +1691,13 @@ static void parse_input(Input_T& inp, const sajson::value& v) auto val = v.get_object_value(i); inp.def = parse_input_impl(val, value_type{}); } + else if(k == "AS_COLOR") + { + if constexpr(requires { inp.as_color; }) + { + inp.as_color = v.get_object_value(i).get_type() == sajson::TYPE_TRUE; + } + } } // Handle shaders without min / max @@ -1120,6 +1808,170 @@ input parse(const sajson::value& v) return i; } +// --- PIPELINE_STATE / MULTIVIEW parsing helpers --------------------------- + +static bool get_bool(const sajson::value& v, bool& out) +{ + if(v.get_type() == sajson::TYPE_TRUE) { out = true; return true; } + if(v.get_type() == sajson::TYPE_FALSE){ out = false; return true; } + return false; +} +static bool get_float(const sajson::value& v, float& out) +{ + if(v.get_type() == sajson::TYPE_DOUBLE) { out = (float)v.get_double_value(); return true; } + if(v.get_type() == sajson::TYPE_INTEGER) { out = (float)v.get_integer_value(); return true; } + return false; +} +static bool get_int(const sajson::value& v, int& out) +{ + if(v.get_type() == sajson::TYPE_INTEGER) { out = v.get_integer_value(); return true; } + if(v.get_type() == sajson::TYPE_DOUBLE) { out = (int)v.get_double_value(); return true; } + return false; +} +static bool get_uint(const sajson::value& v, uint32_t& out) +{ + int x{}; + if(get_int(v, x)) { out = (uint32_t)x; return true; } + return false; +} +static bool get_str(const sajson::value& v, std::string& out) +{ + if(v.get_type() == sajson::TYPE_STRING) { out = v.as_string(); return true; } + return false; +} + +static void parse_blend_attachment(const sajson::value& v, blend_attachment& out) +{ + if(v.get_type() != sajson::TYPE_OBJECT) + return; + std::size_t n = v.get_length(); + for(std::size_t i = 0; i < n; i++) + { + auto k = v.get_object_key(i).as_string(); + auto val = v.get_object_value(i); + bool b{}; + if (k == "ENABLE" ) { get_bool(val, b); out.enable = b; } + else if(k == "SRC_COLOR" ) get_str(val, out.src_color); + else if(k == "DST_COLOR" ) get_str(val, out.dst_color); + else if(k == "OP_COLOR" ) get_str(val, out.op_color); + else if(k == "SRC_ALPHA" ) get_str(val, out.src_alpha); + else if(k == "DST_ALPHA" ) get_str(val, out.dst_alpha); + else if(k == "OP_ALPHA" ) get_str(val, out.op_alpha); + else if(k == "COLOR_WRITE") get_str(val, out.color_write); + // Legacy shorter names + else if(k == "SRC" ) { get_str(val, out.src_color); out.src_alpha = out.src_color; } + else if(k == "DST" ) { get_str(val, out.dst_color); out.dst_alpha = out.dst_color; } + else if(k == "OP" ) { get_str(val, out.op_color); out.op_alpha = out.op_color; } + } +} + +static void parse_stencil_op_state(const sajson::value& v, stencil_op_state& out) +{ + if(v.get_type() != sajson::TYPE_OBJECT) + return; + std::size_t n = v.get_length(); + for(std::size_t i = 0; i < n; i++) + { + auto k = v.get_object_key(i).as_string(); + auto val = v.get_object_value(i); + if (k == "FAIL_OP" ) get_str(val, out.fail_op); + else if(k == "DEPTH_FAIL_OP") get_str(val, out.depth_fail_op); + else if(k == "PASS_OP" ) get_str(val, out.pass_op); + else if(k == "COMPARE_OP" ) get_str(val, out.compare_op); + else if(k == "COMPARE" ) get_str(val, out.compare_op); + } +} + +static void parse_pipeline_state(const sajson::value& v, pipeline_state& out) +{ + if(v.get_type() != sajson::TYPE_OBJECT) + return; + std::size_t n = v.get_length(); + for(std::size_t i = 0; i < n; i++) + { + auto k = v.get_object_key(i).as_string(); + auto val = v.get_object_value(i); + bool b{}; + float f{}; + uint32_t u{}; + std::string s; + + if (k == "DEPTH_TEST" ) { if(get_bool(val, b)) out.depth_test = b; } + else if(k == "DEPTH_WRITE") { if(get_bool(val, b)) out.depth_write = b; } + else if(k == "DEPTH_COMPARE") { if(get_str(val, s)) out.depth_compare = s; } + else if(k == "DEPTH_BIAS") { if(get_float(val, f)) out.depth_bias = f; } + else if(k == "SLOPE_SCALED_DEPTH_BIAS") { if(get_float(val, f)) out.slope_scaled_depth_bias = f; } + else if(k == "CULL_MODE") { if(get_str(val, s)) out.cull_mode = s; } + else if(k == "FRONT_FACE") { if(get_str(val, s)) out.front_face = s; } + else if(k == "POLYGON_MODE") { if(get_str(val, s)) out.polygon_mode = s; } + else if(k == "LINE_WIDTH") { if(get_float(val, f)) out.line_width = f; } + else if(k == "VERTEX_COUNT") { if(get_uint(val, u)) out.vertex_count = u; } + else if(k == "INSTANCE_COUNT") { if(get_uint(val, u)) out.instance_count = u; } + else if(k == "TOPOLOGY") { if(get_str(val, s)) out.topology = s; } + else if(k == "BLEND") + { + // Shortcut: "BLEND": true/false turns on the default alpha-blend. + if(val.get_type() == sajson::TYPE_TRUE || val.get_type() == sajson::TYPE_FALSE) + { + blend_attachment a{}; + a.enable = val.get_type() == sajson::TYPE_TRUE; + out.blend_all = a; + } + else if(val.get_type() == sajson::TYPE_OBJECT) + { + blend_attachment a{}; + a.enable = true; + parse_blend_attachment(val, a); + out.blend_all = a; + } + } + else if(k == "BLEND_PER_ATTACHMENT") + { + if(val.get_type() == sajson::TYPE_ARRAY) + { + std::size_t m = val.get_length(); + out.blend_per_attachment.clear(); + out.blend_per_attachment.reserve(m); + for(std::size_t j = 0; j < m; j++) + { + blend_attachment a{}; + a.enable = true; + parse_blend_attachment(val.get_array_element(j), a); + out.blend_per_attachment.push_back(a); + } + } + } + else if(k == "STENCIL_TEST") { if(get_bool(val, b)) out.stencil_test = b; } + else if(k == "STENCIL_READ_MASK") { if(get_uint(val, u)) out.stencil_read_mask = u; } + else if(k == "STENCIL_WRITE_MASK") { if(get_uint(val, u)) out.stencil_write_mask = u; } + else if(k == "STENCIL_FRONT") + { + stencil_op_state st{}; + parse_stencil_op_state(val, st); + out.stencil_front = st; + } + else if(k == "STENCIL_BACK") + { + stencil_op_state st{}; + parse_stencil_op_state(val, st); + out.stencil_back = st; + } + else if(k == "SHADING_RATE") + { + if(val.get_type() == sajson::TYPE_ARRAY && val.get_length() >= 2) + { + int w{}, h{}; + if(get_int(val.get_array_element(0), w) + && get_int(val.get_array_element(1), h) + && w >= 1 && h >= 1) + { + out.shading_rate = std::array{w, h}; + } + } + } + } +} + using root_fun = void (*)(descriptor&, const sajson::value&); using input_fun = input (*)(const sajson::value&); static const ossia::string_map& root_parse{[] { @@ -1166,6 +2018,7 @@ static const ossia::string_map& root_parse{[] { // CSF-specific types - note: 'image' in CSF context is csf_image_input, not image_input i.insert({"storage", [](const auto& s) { return parse(s); }}); + i.insert({"uniform", [](const auto& s) { return parse(s); }}); i.insert({"texture", [](const auto& s) { return parse(s); }}); i.insert({"geometry", [](const auto& s) { return parse(s); }}); @@ -1185,20 +2038,87 @@ static const ossia::string_map& root_parse{[] { auto k = obj.find_object_key_insensitive(sajson::literal("TYPE")); if(k != obj.get_length()) { - std::string type_str = obj.get_object_value(k).as_string(); + std::string type_str; + if(!get_str(obj.get_object_value(k), type_str)) + continue; boost::algorithm::to_lower(type_str); - auto inp = input_parse.find(type_str); - if(inp != input_parse.end()) - d.inputs.push_back((inp->second)(obj)); + + // "image" with ACCESS or FORMAT → storage image (csf_image_input), + // same as the RESOURCES section. This lets users declare storage + // images in INPUTS without having to move them to RESOURCES. + if(type_str == "image" + && (obj.find_object_key_insensitive(sajson::literal("ACCESS")) != obj.get_length() + || obj.find_object_key_insensitive(sajson::literal("FORMAT")) != obj.get_length())) + { + input inp; + parse_input_base(inp, obj); + csf_image_input ci; + parse_input(ci, obj); + inp.data = ci; + d.inputs.push_back(inp); + } + else + { + auto inp = input_parse.find(type_str); + if(inp != input_parse.end()) + d.inputs.push_back((inp->second)(obj)); + } } else { + // No TYPE specified — default to storage (SSBO). Matches the + // nested-AUXILIARY default (`aux_entry_kind`, ~L820) so the + // top-level INPUTS dispatcher behaves the same as nested + // declarations. This is the right default because: + // - The dual-bind UBO/SSBO design (scene_counts etc.) is + // SSBO-only after the cross-backend cleanup; readers + // declare `TYPE: "storage", ACCESS: "read_only"`. + // - Authors who omit TYPE on a buffer-shaped declaration + // almost always mean storage, not uniform — uniforms + // have a much smaller addressable subset (no runtime + // arrays, std140 padding) and writers always need + // storage anyway. + // - The previous behaviour silently dropped the entry + // without an error, so a typo'd `TYPE: "uniform"` → + // missing TYPE flipped scene_counts off entirely with + // no warning. Defaulting to storage means the next + // stage (binding emission) will catch the misuse via + // a layout/std430 check rather than a silent skip. + d.inputs.push_back(parse(obj)); } } } } }}); + // How many GLSL interface-block input/output locations a given type + // consumes, per GLSL 4.50 spec §4.4.1 "A matrix of sizes matM or matMxN + // takes M locations (one per column)". Non-matrix types consume one + // location. Doubles of >dvec2 width technically consume two locations + // each on desktop GL, but those are vanishingly rare in shader-toy- + // style pipelines — if anyone hits the edge they can pin LOCATION + // explicitly. The mat{M,MxN} cases matter because every existing + // preset that wants mat4 per-instance or per-vertex would otherwise + // have its subsequent attribute collide with column 2/3/4 of the + // matrix. + static constexpr auto locations_consumed = [](attribute_type t) noexcept -> int { + using A = attribute_type; + switch(t) + { + case A::Mat2: case A::Mat2x3: case A::Mat2x4: + case A::DMat2: case A::DMat2x3: case A::DMat2x4: + return 2; + case A::Mat3: case A::Mat3x2: case A::Mat3x4: + case A::DMat3: case A::DMat3x2: case A::DMat3x4: + return 3; + case A::Mat4: case A::Mat4x2: case A::Mat4x3: + case A::DMat4: case A::DMat4x2: case A::DMat4x3: + return 4; + default: + return 1; + } + }; + static constexpr auto parse_attributes = [](descriptor& d, const sajson::value& v) { using namespace std::literals; @@ -1223,33 +2143,140 @@ static const ossia::string_map& root_parse{[] { } else if(loc_obj.get_type() == sajson::TYPE_STRING) { - // Parse as integer, e.g. "LOCATION": "3" - ip.location = std::stoi(loc_obj.as_string()); + // Parse as integer, e.g. "LOCATION": "3". std::stoi throws + // std::invalid_argument (a logic_error, not runtime_error) + // on non-numeric input — catch it locally and surface a + // useful invalid_file message instead. The previous + // unguarded call escaped through the parser's outer + // catch(const std::runtime_error&) and either terminated + // (when the parser was invoked from a noexcept context; + // see ProcessDropHandler.cpp) or surfaced as the generic + // "Unknown error" via the catch(...) fallback at + // ShaderProgram.cpp. // FIXME parse standard locations from ossia::geometry_port + try + { + ip.location = std::stoi(loc_obj.as_string()); + } + catch(const std::exception&) + { + throw invalid_file{ + std::string("LOCATION must be integer or numeric " + "string, got: \"") + + std::string(loc_obj.as_string()) + "\""}; + } } } if(auto k = obj.find_object_key_insensitive(sajson::literal("TYPE")); k != obj.get_length()) { - std::string type_str = obj.get_object_value(k).as_string(); - boost::algorithm::to_lower(type_str); - auto inp = attribute_type_parse.find(type_str); - if(inp != attribute_type_parse.end()) - ip.type = inp->second; + std::string type_str; + if(get_str(obj.get_object_value(k), type_str)) + { + boost::algorithm::to_lower(type_str); + auto inp = attribute_type_parse.find(type_str); + if(inp != attribute_type_parse.end()) + ip.type = inp->second; + } } if(auto k = obj.find_object_key_insensitive(sajson::literal("NAME")); k != obj.get_length()) { - ip.name = obj.get_object_value(k).as_string(); + get_str(obj.get_object_value(k), ip.name); + } + + // SEMANTIC (only meaningful on vertex_input): explicit ossia + // attribute semantic name to use for upstream-buffer matching. + // When omitted, name is used as the semantic key. When set to + // "custom" the runtime falls back to NAME-based matching. + if(auto k = obj.find_object_key_insensitive(sajson::literal("SEMANTIC")); + k != obj.get_length()) + { + auto val = obj.get_object_value(k); + if(val.get_type() == sajson::TYPE_STRING) + ip.semantic = val.as_string(); + } + + // Interpolation qualifier: "smooth" (default, not emitted), "flat", + // "noperspective", "centroid", "sample". Applies to vertex outputs + // and fragment inputs (no effect on vertex inputs / fragment outputs). + if(auto k = obj.find_object_key_insensitive(sajson::literal("INTERPOLATION")); + k != obj.get_length()) + { + auto val = obj.get_object_value(k); + if(val.get_type() == sajson::TYPE_STRING) + ip.interpolation = val.as_string(); } - // If LOCATION was not specified, assign sequentially - // FIXME maybe try to match it from the name ? + // REQUIRED / DEFAULT: only meaningful on vertex_input (raw raster + // pipeline's strictness-vs-fallback control). Silently ignored on + // vertex_output / fragment_input / fragment_output — their matching + // rules are author-owned, not upstream-dependent. + if constexpr (std::is_same_v) + { + if(auto k = obj.find_object_key_insensitive(sajson::literal("REQUIRED")); + k != obj.get_length()) + { + const auto& rv = obj.get_object_value(k); + if(rv.get_type() == sajson::TYPE_FALSE) + ip.required = false; + else if(rv.get_type() == sajson::TYPE_TRUE) + ip.required = true; + // Other JSON types left at default (true). No error here — + // strict JSON typing is already enforced upstream by sajson. + } + + if(auto k = obj.find_object_key_insensitive(sajson::literal("DEFAULT")); + k != obj.get_length()) + { + const auto& dv = obj.get_object_value(k); + if(dv.get_type() == sajson::TYPE_ARRAY) + { + const std::size_t len = dv.get_length(); + ip.default_val.reserve(len); + for(std::size_t j = 0; j < len; ++j) + { + const auto& e = dv.get_array_element(j); + if(e.get_type() == sajson::TYPE_INTEGER) + ip.default_val.push_back((double)e.get_integer_value()); + else if(e.get_type() == sajson::TYPE_DOUBLE) + ip.default_val.push_back(e.get_double_value()); + // Non-numeric entries silently skipped — the runtime's + // component-pad rule will fill missing slots with zero. + } + } + else if(dv.get_type() == sajson::TYPE_INTEGER) + { + // Allow a bare scalar for 1-wide types: "DEFAULT": 1 + ip.default_val.push_back((double)dv.get_integer_value()); + } + else if(dv.get_type() == sajson::TYPE_DOUBLE) + { + ip.default_val.push_back(dv.get_double_value()); + } + } + } + + // If LOCATION was not specified, assign sequentially with + // per-type location counts so mat3/mat4 and their rectangular + // cousins claim the right number of slots (matMxN consumes M + // consecutive locations under GLSL 4.50 §4.4.1). Previously + // this was `(int)(d.*member).size()` — off-by-3 the moment a + // shader declared any mat4 input, and the next attribute + // would land inside the matrix, which the driver rejects. + // + // For mixed explicit / auto layouts the cumulative-sum above + // can collide with a user-pinned LOCATION; that's a pre-existing + // policy tradeoff left untouched here — the simpler "always + // auto" pattern is what 99% of shipped shaders use. if(ip.location < 0 && !ip.name.empty()) { - ip.location = (int)(d.*member).size(); + int next_loc = 0; + for(const auto& prev : d.*member) + next_loc += locations_consumed(prev.type); + ip.location = next_loc; } if(ip.type != attribute_type::Unknown && ip.location >= 0 && !ip.name.empty()) @@ -1277,9 +2304,12 @@ static const ossia::string_map& root_parse{[] { parse_attributes.operator()(d, v); }}); - // Top-level AUXILIARY for RAW_RASTER_PIPELINE: SSBOs expected from upstream geometry + // Top-level AUXILIARY for RAW_RASTER_PIPELINE: SSBOs AND textures travelling + // bundled with the upstream geometry. Buffer entries (default / TYPE: + // "storage") land in d.auxiliary; texture entries (TYPE: "image" / + // "texture" / "cubemap" / "image_cube") land in d.auxiliary_textures. p.insert({"AUXILIARY", [](descriptor& d, const sajson::value& v) { - parse_auxiliary_array(v, d.auxiliary); + parse_auxiliary_array(v, d.auxiliary, d.auxiliary_textures); }}); // Add RESOURCES parsing for CSF (which can contain both inputs and resources) @@ -1296,16 +2326,22 @@ static const ossia::string_map& root_parse{[] { auto k = obj.find_object_key_insensitive(sajson::literal("TYPE")); if(k != obj.get_length()) { - std::string type_str = obj.get_object_value(k).as_string(); + std::string type_str; + if(!get_str(obj.get_object_value(k), type_str)) + continue; boost::algorithm::to_lower(type_str); - // Handle special case for CSF image type - if(type_str == "image") + // Handle special cases for CSF image types + // "image" → 2D / 3D storage image (image2D / image3D) + // "image_cube" → writable cubemap storage image (imageCube) + if(type_str == "image" || type_str == "image_cube") { input inp; parse_input_base(inp, obj); csf_image_input ci; parse_input(ci, obj); + if(type_str == "image_cube") + ci.cubemap = true; inp.data = ci; d.inputs.push_back(inp); } @@ -1548,8 +2584,8 @@ static const ossia::string_map& root_parse{[] { = obj.find_object_key_insensitive(sajson::literal("TARGET")); target_k != obj.get_length()) { - p.target = obj.get_object_value(target_k).as_string(); - if(!p.target.empty()) + if(get_str(obj.get_object_value(target_k), p.target) + && !p.target.empty()) { d.pass_targets.push_back(p.target); } @@ -1619,6 +2655,54 @@ static const ossia::string_map& root_parse{[] { } } + // LAYER: render to a specific layer of a texture-array output. + if(auto layer_k + = obj.find_object_key_insensitive(sajson::literal("LAYER")); + layer_k != obj.get_length()) + { + int lyr{}; + if(get_int(obj.get_object_value(layer_k), lyr)) + p.layer = lyr; + } + + // Z: render to a specific Z-slice of a 3D target. Stored as an + // expression so it can reference $USER or input sizes; resolved + // at render time. + if(auto z_k = obj.find_object_key_insensitive(sajson::literal("Z")); + z_k != obj.get_length()) + { + auto t = obj.get_object_value(z_k).get_type(); + if(t == sajson::TYPE_STRING) + p.z_expression = obj.get_object_value(z_k).as_string(); + else if(t == sajson::TYPE_INTEGER) + p.z_expression + = std::to_string(obj.get_object_value(z_k).get_integer_value()); + else if(t == sajson::TYPE_DOUBLE) + p.z_expression + = std::to_string((int)obj.get_object_value(z_k).get_double_value()); + } + + // FORMAT: override the intermediate-render-target format for + // this pass only. Useful for separable-filter chains where one + // intermediate wants extra precision (rgba16f) but the final + // output is RGBA8. + if(auto fmt_k + = obj.find_object_key_insensitive(sajson::literal("FORMAT")); + fmt_k != obj.get_length()) + { + auto v2 = obj.get_object_value(fmt_k); + if(v2.get_type() == sajson::TYPE_STRING) + p.format = v2.as_string(); + } + + // PIPELINE_STATE: per-pass pipeline state overrides. + if(auto ps_k + = obj.find_object_key_insensitive(sajson::literal("PIPELINE_STATE")); + ps_k != obj.get_length()) + { + parse_pipeline_state(obj.get_object_value(ps_k), p.override_state); + } + d.passes.push_back(std::move(p)); } } @@ -1640,25 +2724,203 @@ static const ossia::string_map& root_parse{[] { if(auto name_k = obj.find_object_key_insensitive(sajson::literal("NAME")); name_k != obj.get_length()) { - out.name = obj.get_object_value(name_k).as_string(); + get_str(obj.get_object_value(name_k), out.name); } if(auto type_k = obj.find_object_key_insensitive(sajson::literal("TYPE")); type_k != obj.get_length()) { - out.type = obj.get_object_value(type_k).as_string(); + get_str(obj.get_object_value(type_k), out.type); } // Default type to "color" if not specified if(out.type.empty()) out.type = "color"; + // LAYERS: >1 allocates a texture array with this many layers. + if(auto layers_k = obj.find_object_key_insensitive(sajson::literal("LAYERS")); + layers_k != obj.get_length()) + { + int l{}; + if(get_int(obj.get_object_value(layers_k), l) && l > 0) + out.layers = l; + } + + // DEPTH: >1 allocates a 3D texture with this depth. Passes targeting + // this output can specify Z to write into a specific slice. + if(auto depth_k = obj.find_object_key_insensitive(sajson::literal("DEPTH")); + depth_k != obj.get_length()) + { + int d_val{}; + if(get_int(obj.get_object_value(depth_k), d_val) && d_val > 0) + out.depth = d_val; + } + + // FORMAT: optional explicit texture format (e.g. "rgba16f", "r32f", "d32f"). + if(auto fmt_k = obj.find_object_key_insensitive(sajson::literal("FORMAT")); + fmt_k != obj.get_length()) + { + auto v2 = obj.get_object_value(fmt_k); + if(v2.get_type() == sajson::TYPE_STRING) + out.format = v2.as_string(); + } + + // SAMPLES: MSAA sample count (1, 2, 4, 8, 16, ...). + if(auto s_k = obj.find_object_key_insensitive(sajson::literal("SAMPLES")); + s_k != obj.get_length()) + { + int s{}; + if(get_int(obj.get_object_value(s_k), s) && s >= 1) + out.samples = s; + } + + // CUBEMAP: when true the layered output is allocated as a cubemap + // (six faces sampled via samplerCube downstream) rather than a + // plain 2D array. Combines with `LAYERS: 6` + `MULTIVIEW: 6` for + // the IBL precompute case (one draw writes all six faces of the + // target cube). Consumer shaders declare a matching + // `TYPE: "cubemap"` INPUT to read it. + if(auto cube_k = obj.find_object_key_insensitive(sajson::literal("CUBEMAP")); + cube_k != obj.get_length()) + { + auto v2 = obj.get_object_value(cube_k); + if(v2.get_type() == sajson::TYPE_TRUE) + out.is_cubemap = true; + else if(v2.get_type() == sajson::TYPE_INTEGER) + out.is_cubemap = (v2.get_integer_value() != 0); + } + + // GENERATE_MIPS: post-pass mip-chain auto-fill. Implies the + // MipMapped + UsedWithGenerateMips allocator flags. Runtime + // issues a QRhiResourceUpdateBatch::generateMips after the + // render loop (and after any CUBEMAP+MULTIVIEW cube-copy). + if(auto gm_k = obj.find_object_key_insensitive(sajson::literal("GENERATE_MIPS")); + gm_k != obj.get_length()) + { + auto v2 = obj.get_object_value(gm_k); + if(v2.get_type() == sajson::TYPE_TRUE) + out.generate_mips = true; + else if(v2.get_type() == sajson::TYPE_INTEGER) + out.generate_mips = (v2.get_integer_value() != 0); + } + + // WIDTH / HEIGHT: explicit offscreen target size. Integer + // literal (fast path) or string expression (evaluated at + // init time against input-image sizes / scalar ports, + // mirroring CSF dispatch-expression semantics). Zero / + // unset → fall back to renderer.state.renderSize. + if(auto w_k = obj.find_object_key_insensitive(sajson::literal("WIDTH")); + w_k != obj.get_length()) + { + auto v2 = obj.get_object_value(w_k); + if(v2.get_type() == sajson::TYPE_INTEGER) + out.width = v2.get_integer_value(); + else if(v2.get_type() == sajson::TYPE_DOUBLE) + out.width = (int)v2.get_double_value(); + else if(v2.get_type() == sajson::TYPE_STRING) + out.width_expression = v2.as_string(); + } + if(auto h_k = obj.find_object_key_insensitive(sajson::literal("HEIGHT")); + h_k != obj.get_length()) + { + auto v2 = obj.get_object_value(h_k); + if(v2.get_type() == sajson::TYPE_INTEGER) + out.height = v2.get_integer_value(); + else if(v2.get_type() == sajson::TYPE_DOUBLE) + out.height = (int)v2.get_double_value(); + else if(v2.get_type() == sajson::TYPE_STRING) + out.height_expression = v2.as_string(); + } + d.outputs.push_back(std::move(out)); } } } }}); + p.insert({"PIPELINE_STATE", [](descriptor& d, const sajson::value& v) { + parse_pipeline_state(v, d.default_state); + }}); + + p.insert({"MULTIVIEW", [](descriptor& d, const sajson::value& v) { + if(v.get_type() == sajson::TYPE_INTEGER) + d.multiview_count = v.get_integer_value(); + else if(v.get_type() == sajson::TYPE_DOUBLE) + d.multiview_count = (int)v.get_double_value(); + else if(v.get_type() == sajson::TYPE_TRUE) + d.multiview_count = 2; // "MULTIVIEW": true => 2 views by default + }}); + + // EXECUTION_MODEL (top-level, RAW_RASTER_PIPELINE). Shape: + // "EXECUTION_MODEL": { + // "TYPE": "SINGLE" | "PER_MIP" | "PER_CUBE_FACE" | "PER_LAYER" | "MANUAL", + // "TARGET": "", // PER_MIP / PER_CUBE_FACE / PER_LAYER + // "COUNT": "" // MANUAL (int literal accepted too) + // } + // Distinct from the per-pass EXECUTION_MODEL inside DISPATCH / PASSES + // (CSF compute), which lives in `dispatch_info::execution_type`. + p.insert({"EXECUTION_MODEL", [](descriptor& d, const sajson::value& v) { + if(v.get_type() != sajson::TYPE_OBJECT) + return; + if(auto type_k + = v.find_object_key_insensitive(sajson::literal("TYPE")); + type_k != v.get_length()) + { + auto tv = v.get_object_value(type_k); + if(tv.get_type() == sajson::TYPE_STRING) + d.execution_model.type = tv.as_string(); + } + if(auto target_k + = v.find_object_key_insensitive(sajson::literal("TARGET")); + target_k != v.get_length()) + { + auto tv = v.get_object_value(target_k); + if(tv.get_type() == sajson::TYPE_STRING) + d.execution_model.target = tv.as_string(); + } + if(auto count_k + = v.find_object_key_insensitive(sajson::literal("COUNT")); + count_k != v.get_length()) + { + auto tv = v.get_object_value(count_k); + if(tv.get_type() == sajson::TYPE_STRING) + d.execution_model.count_expression = tv.as_string(); + else if(tv.get_type() == sajson::TYPE_INTEGER) + d.execution_model.count_expression + = std::to_string(tv.get_integer_value()); + } + }}); + + p.insert({"CLIP_DISTANCES", [](descriptor& d, const sajson::value& v) { + int n{}; + if(get_int(v, n) && n > 0 && n <= 8) + d.clip_distances = n; + }}); + + p.insert({"CULL_DISTANCES", [](descriptor& d, const sajson::value& v) { + int n{}; + if(get_int(v, n) && n > 0 && n <= 8) + d.cull_distances = n; + }}); + + p.insert({"DEPTH_LAYOUT", [](descriptor& d, const sajson::value& v) { + if(v.get_type() == sajson::TYPE_STRING) + d.depth_layout = v.as_string(); + }}); + + p.insert({"EXTENSIONS", [](descriptor& d, const sajson::value& v) { + if(v.get_type() != sajson::TYPE_ARRAY) + return; + std::size_t n = v.get_length(); + d.extensions.reserve(d.extensions.size() + n); + for(std::size_t i = 0; i < n; i++) + { + auto e = v.get_array_element(i); + if(e.get_type() == sajson::TYPE_STRING) + d.extensions.emplace_back(e.as_string()); + } + }}); + p.insert({"POINT_COUNT", [](descriptor& d, const sajson::value& v) { if(v.get_type() == sajson::TYPE_INTEGER) d.point_count = v.get_integer_value(); @@ -1708,7 +2970,7 @@ static const ossia::string_map& root_parse{[] { auto name_key = obj.find_object_key_insensitive(sajson::literal("NAME")); if(name_key != obj.get_length()) { - type_def.name = obj.get_object_value(name_key).as_string(); + get_str(obj.get_object_value(name_key), type_def.name); } // Parse LAYOUT field @@ -1731,7 +2993,7 @@ static const ossia::string_map& root_parse{[] { = field_obj.find_object_key_insensitive(sajson::literal("NAME")); if(field_name_key != field_obj.get_length()) { - field.name = field_obj.get_object_value(field_name_key).as_string(); + get_str(field_obj.get_object_value(field_name_key), field.name); } // Parse field TYPE @@ -1739,7 +3001,7 @@ static const ossia::string_map& root_parse{[] { = field_obj.find_object_key_insensitive(sajson::literal("TYPE")); if(field_type_key != field_obj.get_length()) { - field.type = field_obj.get_object_value(field_type_key).as_string(); + get_str(field_obj.get_object_value(field_type_key), field.type); } type_def.layout.push_back(field); @@ -1757,6 +3019,18 @@ static const ossia::string_map& root_parse{[] { return p; }()}; +// A non-empty compare op different from "never" turns the sampler into a +// shadow/comparison sampler. Mirrors QRhiSampler::CompareOp interpretation. +static bool isf_is_comparison_sampler(const sampler_config& s) +{ + if(s.compare.empty()) + return false; + std::string c = s.compare; + for(auto& ch : c) ch = (char)tolower(ch); + return c != "never"; +} + + struct create_val_visitor_450 { struct return_type @@ -1771,14 +3045,43 @@ struct create_val_visitor_450 return_type operator()(const point2d_input&) { return {"vec2", false}; } return_type operator()(const point3d_input&) { return {"vec3", false}; } return_type operator()(const color_input&) { return {"vec4", false}; } - return_type operator()(const image_input& i) { return {i.dimensions == 3 ? "uniform sampler3D" : "uniform sampler2D", true}; } - return_type operator()(const cubemap_input&) { return {"uniform samplerCube", true}; } + return_type operator()(const image_input& i) + { + const bool cmp = isf_is_comparison_sampler(i.sampler); + if(i.dimensions == 3) + return {"uniform sampler3D", true}; // 3D shadow samplers not commonly used + if(i.is_array) + return {cmp ? "uniform sampler2DArrayShadow" : "uniform sampler2DArray", true}; + return {cmp ? "uniform sampler2DShadow" : "uniform sampler2D", true}; + } + return_type operator()(const cubemap_input& c) + { + return {isf_is_comparison_sampler(c.sampler) ? "uniform samplerCubeShadow" + : "uniform samplerCube", + true}; + } return_type operator()(const audio_input&) { return {"uniform sampler2D", true}; } return_type operator()(const audioFFT_input&) { return {"uniform sampler2D", true}; } return_type operator()(const audioHist_input&) { return {"uniform sampler2D", true}; } return_type operator()(const storage_input&) { return {"buffer", true}; } - return_type operator()(const texture_input& i) { return {i.dimensions == 3 ? "uniform sampler3D" : "uniform sampler2D", true}; } - return_type operator()(const csf_image_input& i) { return {i.is3D() ? "uniform image3D" : "uniform image2D", true}; } + return_type operator()(const uniform_input&) { return {"uniform", true}; } + return_type operator()(const texture_input& i) + { + const bool cmp = isf_is_comparison_sampler(i.sampler); + if(i.dimensions == 3) + return {"uniform sampler3D", true}; + return {cmp ? "uniform sampler2DShadow" : "uniform sampler2D", true}; + } + return_type operator()(const csf_image_input& i) + { + if(i.isCube()) + return {"uniform imageCube", true}; + if(i.is3D()) + return {"uniform image3D", true}; + if(i.is_array) + return {"uniform image2DArray", true}; + return {"uniform image2D", true}; + } return_type operator()(const geometry_input&) { return {"buffer", true}; } }; @@ -1942,6 +3245,251 @@ void parser::parse_geometry_filter() m_geometry_filter = filter_ubo + geomWithoutISF + "\n"; } +// --- GLSL helpers for graphics-visible storage resources ---------------- +// +// Derive GLSL image/sampler prefix from a format string. +// Unsigned integer formats (R32UI, RGBA16UI, ...) → "u" +// Signed integer formats (R32I, RGBA16I, ...) → "i" +// Float/unorm formats (R32F, RGBA8, ...) → "" +static std::string isf_glsl_type_prefix(const std::string& format) +{ + if(format.empty()) + return ""; + std::string fmt = format; + for(auto& c : fmt) c = (char)toupper(c); + if(fmt.find("UI") != std::string::npos) + return "u"; + if(fmt.size() >= 2 && fmt.back() == 'I' && fmt[fmt.size() - 2] != 'U') + return "i"; + return ""; +} + +// Returns true when the visibility string indicates this resource should be +// declared in a graphics pipeline (vertex or fragment stage). +static bool is_graphics_visibility(std::string_view vis) +{ + return vis == "fragment" || vis == "vertex" || vis == "vertex+fragment" + || vis == "both" || vis == "graphics"; +} + +// Emit GLSL `struct { };` declarations from the TYPES +// section. Must be injected BEFORE any SSBO/UBO body that references the +// struct, in BOTH vertex and fragment stages — otherwise scene shaders that +// declare e.g. `Light` and use `readonly buffer { Light entries[]; }` fail +// VS compilation when the SSBO leaks into a vertex pipeline that never +// included the struct (the fragment-only TYPES emission was the long-standing +// bug here). The compute path has its own copy of this logic at +// parse_compute_shader; this helper is shared by parse_isf and +// parse_raw_raster_pipeline. +static std::string isf_emit_types_struct(const std::vector& types) +{ + if(types.empty()) + return {}; + + std::string out; + out += "// Struct definitions from TYPES section\n"; + for(const auto& type_def : types) + { + out += "struct " + type_def.name + " {\n"; + for(const auto& field : type_def.layout) + { + auto bracket = field.type.find('['); + if(bracket != std::string::npos) + out += " " + field.type.substr(0, bracket) + " " + field.name + + field.type.substr(bracket) + ";\n"; + else + out += " " + field.type + " " + field.name + ";\n"; + } + out += "};\n\n"; + } + return out; +} + +static std::string isf_emit_ssbo_decl( + int binding, std::string_view name, const storage_input& s, bool alias_prev) +{ + std::string out; + out += "layout(binding = "; + out += std::to_string(binding); + out += ", std430) "; + if(alias_prev || s.access == "read_only") + out += "readonly "; + else if(s.access == "write_only") + out += "writeonly "; + else + out += "restrict "; + out += "buffer "; + out += name; + out += "_buf {\n"; + for(const auto& field : s.layout) + { + auto bracket = field.type.find('['); + if(bracket != std::string::npos) + out += " " + field.type.substr(0, bracket) + " " + field.name + + field.type.substr(bracket) + ";\n"; + else + out += " " + field.type + " " + field.name + ";\n"; + } + out += "} "; + out += name; + out += ";\n\n"; + return out; +} + +static std::string isf_emit_ubo_decl( + int binding, std::string_view name, const uniform_input& u) +{ + std::string out; + out += "layout(binding = "; + out += std::to_string(binding); + out += ", std140) uniform "; + out += name; + out += "_t {\n"; + for(const auto& field : u.layout) + { + auto bracket = field.type.find('['); + if(bracket != std::string::npos) + out += " " + field.type.substr(0, bracket) + " " + field.name + + field.type.substr(bracket) + ";\n"; + else + out += " " + field.type + " " + field.name + ";\n"; + } + out += "} "; + out += name; + out += ";\n\n"; + return out; +} + +static std::string isf_emit_image_decl( + int binding, std::string_view name, const csf_image_input& img, + bool alias_prev = false) +{ + std::string out; + out += "layout(binding = "; + out += std::to_string(binding); + std::string fmt = img.format.empty() ? "rgba8" : img.format; + boost::algorithm::to_lower(fmt); + out += ", "; + out += fmt; + out += ") "; + if(alias_prev || img.access == "read_only") + out += "readonly "; + else if(img.access == "write_only") + out += "writeonly "; + else + out += "restrict "; + auto prefix = isf_glsl_type_prefix(img.format); + out += "uniform "; + out += prefix; + // Shape dispatch must mirror the compute-stage emit at isf_emit_compute_- + // image_decl below: parser admits CUBEMAP / IS_ARRAY / 3D shapes; the + // bound texture's QRhi flags must agree with the GLSL declaration. + // Cube and array variants on graphics-stage csf_image_input were + // previously emitted as flat image2D, mismatching the cube/array texture + // bound by IsfBindingsBuilder's allocator and triggering Vulkan + // VUID-VkGraphicsPipelineCreateInfo-layout-07990. + // Priority: cubemap > 3D > array > 2D (matches the parser's own reject + // table at isf.cpp:1446-1463 which forbids cube+array and array+3D). + const char* shape = "image2D "; + if(img.isCube()) shape = "imageCube "; + else if(img.is3D()) shape = "image3D "; + else if(img.is_array) shape = "image2DArray "; + out += shape; + out += name; + out += ";\n"; + return out; +} + +// Emit declarations for storage_input / csf_image_input inputs for a graphics +// shader (ISF or RawRaster). Starts at `binding`, returns the next free binding. +// Also emits `name_prev` readonly declarations for persistent SSBOs. +static int isf_emit_graphics_storage( + std::string& out, int binding, const std::vector& inputs) +{ + for(const auto& inp : inputs) + { + if(auto* s = ossia::get_if(&inp.data)) + { + if(!is_graphics_visibility(s->visibility)) + continue; + // Indirect-draw buffers don't need shader visibility. + if(!s->buffer_usage.empty()) + continue; + out += isf_emit_ssbo_decl(binding, inp.name, *s, /*alias_prev=*/false); + binding++; + if(s->persistent) + { + out += isf_emit_ssbo_decl( + binding, inp.name + "_prev", *s, /*alias_prev=*/true); + binding++; + } + } + else if(auto* img = ossia::get_if(&inp.data)) + { + if(!is_graphics_visibility(img->visibility)) + continue; + out += isf_emit_image_decl(binding, inp.name, *img, /*alias_prev=*/false); + binding++; + if(img->persistent) + { + out += isf_emit_image_decl( + binding, inp.name + "_prev", *img, /*alias_prev=*/true); + binding++; + } + } + else if(auto* u = ossia::get_if(&inp.data)) + { + if(!is_graphics_visibility(u->visibility)) + continue; + out += isf_emit_ubo_decl(binding, inp.name, *u); + binding++; + } + } + return binding; +} + +// The #extension pragma must come BEFORE any declarations — emit it separately +// so it can be prepended right after #version. +static std::string isf_emit_multiview_extension(int view_count) +{ + std::string out; + out += "#extension GL_EXT_multiview : require\n"; + out += "#define VIEW_INDEX gl_ViewIndex\n"; + out += "#define NUM_VIEWS "; + out += std::to_string(view_count); + out += "\n"; + return out; +} + +// User-declared EXTENSIONS from the descriptor. Emitted alongside the +// multiview extension, each as `#extension : require`. Advanced +// effects (subgroup ops, atomic floats, ray queries, …) go through here. +static std::string isf_emit_user_extensions(const std::vector& exts) +{ + std::string out; + for(const auto& e : exts) + { + if(e.empty()) + continue; + out += "#extension "; + out += e; + out += " : require\n"; + } + return out; +} + +// Emit the multiview view-projection UBO. +static std::string isf_emit_multiview_ubo(int binding, int view_count) +{ + std::string out; + out += "layout(std140, binding = "; + out += std::to_string(binding); + out += ") uniform multiview_t { mat4 viewProjection["; + out += std::to_string(view_count); + out += "]; } isf_mv;\n"; + return out; +} + void parser::parse_isf() { using namespace std::literals; @@ -1960,6 +3508,35 @@ void parser::parse_isf() m_desc.passes.push_back(isf::pass{}); } + // Fragment-mode ISF cannot drive PASSES that target a 3D / Z-sliced + // OUTPUT: that requires per-Z-slice color attachments / 3D image + // storage plumbing through the pass-target allocator and the + // beginPass site, which the RenderedISFNode renderer does not yet + // wire end-to-end. Authors should use a CSF compute shader + // (EXECUTION_MODEL: 3D_IMAGE) for true volumetric writes; refusing + // to load here is loud and prevents a silent 2D downgrade that + // would make every imageStore / fragment write target the wrong + // memory. + for(const auto& pass : m_desc.passes) + { + bool target_is_3d = false; + for(const auto& out : m_desc.outputs) + { + if(out.name == pass.target && out.depth > 1) + { + target_is_3d = true; + break; + } + } + if(!pass.z_expression.empty() || target_is_3d) + { + throw invalid_file{ + "fragment-mode ISF with PASSES targeting Z / 3D OUTPUTS is not " + "yet supported in this engine — use CSF compute " + "(EXECUTION_MODEL: 3D_IMAGE) for volumetric writes."}; + } + } + auto& d = m_desc; // We start from empty strings. @@ -1972,9 +3549,17 @@ void parser::parse_isf() switch(m_version) { case 450: { + // Extensions pragma block — must come right after #version, before + // any layout/uniform/in/out declarations. + std::string extensions_prelude; + if(d.multiview_count >= 2) + extensions_prelude += isf_emit_multiview_extension(d.multiview_count); + extensions_prelude += isf_emit_user_extensions(d.extensions); + // Setup vertex shader { m_vertex = GLSL45.versionPrelude; + m_vertex += extensions_prelude; if(m_sourceVertex.empty()) { @@ -1990,6 +3575,18 @@ void parser::parse_isf() { // Setup fragment shader m_fragment = GLSL45.versionPrelude; + m_fragment += extensions_prelude; + + // LAYER_INDEX for layered / multi-layer outputs: the vertex shader writes + // to gl_Layer and the fragment shader receives it via a flat varying. + bool has_layered_output = (d.multiview_count >= 2); + for(const auto& out : d.outputs) + if(out.layers > 1) + has_layered_output = true; + if(has_layered_output) + { + m_fragment += "#define LAYER_INDEX gl_Layer\n"; + } if(d.outputs.empty()) { @@ -2027,11 +3624,34 @@ void parser::parse_isf() } } } + + // Conservative-depth qualifier on gl_FragDepth (ISF path). + if(!d.depth_layout.empty()) + { + std::string dl = d.depth_layout; + for(auto& c : dl) c = (char)tolower(c); + const char* q = nullptr; + if(dl == "greater") q = "depth_greater"; + else if(dl == "less") q = "depth_less"; + else if(dl == "unchanged") q = "depth_unchanged"; + else if(dl == "any") q = "depth_any"; + if(q) + { + m_fragment += "layout("; + m_fragment += q; + m_fragment += ") out float gl_FragDepth;\n"; + } + } } // Setup the parameters UBOs std::string material_ubos = GLSL45.defaultUniforms; + // TYPES section structs must be visible in BOTH stages because SSBO + // declarations referencing them (e.g. `Light entries[]`) are appended + // to material_ubos, which is in turn injected into both VS and FS. + material_ubos += isf_emit_types_struct(d.types); + int sampler_binding = 3; if(!d.inputs.empty() || !d.pass_targets.empty()) @@ -2043,6 +3663,14 @@ void parser::parse_isf() uniforms += "layout(std140, binding = 2) uniform material_t {\n"; for(const isf::input& val : d.inputs) { + // Storage buffers / storage images are declared separately after + // samplers — skip them here to avoid emitting invalid GLSL. + if(ossia::get_if(&val.data) + || ossia::get_if(&val.data) + || ossia::get_if(&val.data) + || ossia::get_if(&val.data)) + continue; + auto [type, isSampler] = ossia::visit(create_val_visitor_450{}, val.data); if(isSampler) @@ -2069,6 +3697,18 @@ void parser::parse_isf() sampler_binding++; } } + else if(auto* cube = ossia::get_if(&val.data)) + { + if(cube->depth) + { + samplers += "layout(binding = "; + samplers += std::to_string(sampler_binding); + samplers += ") uniform samplerCube "; + samplers += val.name; + samplers += "_depth;\n"; + sampler_binding++; + } + } } else { @@ -2088,8 +3728,25 @@ void parser::parse_isf() } } + // Pass targets are bound as sampler2D for cross-pass reads. Two + // independent dedup checks: + // 1) the same TARGET can appear in multiple PASSES entries (e.g. + // LAYERS where each layer is a pass writing to the same target) + // — we must only emit one sampler per distinct name. + // 2) a TARGET may also appear as a FRAGMENT_OUTPUT for the current + // pass (typical for OUTPUTS with LAYERS) — those collide with + // the `out vec4 ;` declaration emitted above and would + // cause "redefinition" at GLSL compile time. + std::set output_names; + for(const auto& out : d.outputs) + output_names.insert(out.name); + std::set emitted_targets; for(const std::string& target : d.pass_targets) { + if(output_names.count(target)) + continue; + if(!emitted_targets.insert(target).second) + continue; samplers += "layout(binding = "; samplers += std::to_string(sampler_binding); samplers += ") uniform sampler2D "; @@ -2110,6 +3767,21 @@ void parser::parse_isf() } material_ubos += samplers; + + // Storage buffers (SSBOs) and storage images visible to the graphics + // pipeline. Bindings continue after samplers. + sampler_binding = isf_emit_graphics_storage( + material_ubos, sampler_binding, d.inputs); + + // Multiview UBO: injected when MULTIVIEW >= 2 in the descriptor. + // Only the UBO here — the #extension pragma must come right after + // #version, so it's emitted separately below. + if(d.multiview_count >= 2) + { + material_ubos += isf_emit_multiview_ubo( + sampler_binding, d.multiview_count); + sampler_binding++; + } } m_vertex += material_ubos; @@ -2159,6 +3831,17 @@ void parser::parse_raw_raster_pipeline() m_desc.mode = isf::descriptor::RawRaster; + // If FRAGMENT_OUTPUTS declares multiple outputs but OUTPUTS was not + // explicitly provided, auto-populate desc.outputs so the node graph + // creates the right number of output ports (one per attachment). + if(m_desc.outputs.empty() && m_desc.fragment_outputs.size() > 1) + { + for(const auto& fo : m_desc.fragment_outputs) + { + m_desc.outputs.push_back(output_declaration{.name = fo.name, .type = "color"}); + } + } + // Add the raw raster uniforms { static const auto default_ins = [] { @@ -2240,8 +3923,56 @@ void parser::parse_raw_raster_pipeline() m_vertex = GLSL45.versionPrelude; m_fragment = GLSL45.versionPrelude; + // Extensions pragma block — must come right after #version. + // GL_ARB_shader_draw_parameters exposes gl_BaseInstance / gl_BaseVertex / + // gl_DrawIDARB in the vertex shader. Required by MDI shaders that index + // per-draw data (per_draws[gl_BaseInstance], etc.). Harmless when unused. + m_vertex += "#extension GL_ARB_shader_draw_parameters : require\n"; + + if(m_desc.multiview_count >= 2) + { + std::string ext = isf_emit_multiview_extension(m_desc.multiview_count); + m_vertex += ext; + m_fragment += ext; + } + + { + std::string user_ext = isf_emit_user_extensions(m_desc.extensions); + m_vertex += user_ext; + m_fragment += user_ext; + } + + // LAYER_INDEX for layered outputs. + { + bool has_layered_output = (m_desc.multiview_count >= 2); + for(const auto& out : m_desc.outputs) + if(out.layers > 1) + has_layered_output = true; + if(has_layered_output) + m_fragment += "#define LAYER_INDEX gl_Layer\n"; + } + // Write down the inputs / outputs { + // Integer / boolean types require the `flat` interpolation qualifier on + // varyings (VERTEX_OUTPUTS → FRAGMENT_INPUTS). Without it, Vulkan GLSL + // compilation fails: "'uint' : must be qualified as flat in". + auto needs_flat = [](attribute_type t) { + return (t >= attribute_type::Int && t <= attribute_type::Uint4) + || (t >= attribute_type::Bool && t <= attribute_type::Bool4); + }; + + // Interpolation qualifier for a varying: user-specified (if valid) wins + // over the auto "flat" promotion for integer/bool types. + auto interp_qualifier = [&](const vertex_attribute& a) -> const char* { + if(a.interpolation == "flat") return "flat"; + if(a.interpolation == "noperspective") return "noperspective"; + if(a.interpolation == "centroid") return "centroid"; + if(a.interpolation == "sample") return "sample"; + if(a.interpolation == "smooth") return ""; // default, no keyword needed + return needs_flat(a.type) ? "flat" : ""; + }; + // Vertex for(auto& attr : m_desc.vertex_inputs) m_vertex += fmt::format( @@ -2249,22 +3980,56 @@ void parser::parse_raw_raster_pipeline() attribute_type_map.at((int)attr.type), attr.name); for(auto& attr : m_desc.vertex_outputs) m_vertex += fmt::format( - "layout(location = {}) out {} {};\n", attr.location, + "layout(location = {}) {} out {} {};\n", attr.location, + interp_qualifier(attr), attribute_type_map.at((int)attr.type), attr.name); for(auto& attr : m_desc.fragment_inputs) m_fragment += fmt::format( - "layout(location = {}) in {} {};\n", attr.location, + "layout(location = {}) {} in {} {};\n", attr.location, + interp_qualifier(attr), attribute_type_map.at((int)attr.type), attr.name); for(auto& attr : m_desc.fragment_outputs) m_fragment += fmt::format( "layout(location = {}) out {} {};\n", attr.location, attribute_type_map.at((int)attr.type), attr.name); + + // Clip / cull distances: user-declared count controls the size of the + // gl_ClipDistance / gl_CullDistance arrays. Required on some GLSL + // profiles; always explicit on Vulkan GLSL. + if(m_desc.clip_distances > 0) + m_vertex += fmt::format( + "out float gl_ClipDistance[{}];\n", m_desc.clip_distances); + if(m_desc.cull_distances > 0) + m_vertex += fmt::format( + "out float gl_CullDistance[{}];\n", m_desc.cull_distances); + + // Conservative-depth qualifier on gl_FragDepth. Allowed values map to + // GLSL layout qualifiers: greater/less/unchanged/any. + if(!m_desc.depth_layout.empty()) + { + std::string dl = m_desc.depth_layout; + for(auto& c : dl) c = (char)tolower(c); + const char* q = nullptr; + if(dl == "greater") q = "depth_greater"; + else if(dl == "less") q = "depth_less"; + else if(dl == "unchanged") q = "depth_unchanged"; + else if(dl == "any") q = "depth_any"; + if(q) + m_fragment += fmt::format( + "layout({}) out float gl_FragDepth;\n", q); + } } { // Setup the parameters UBOs std::string material_ubos = GLSL45.defaultUniforms; + // TYPES section structs visible in BOTH stages — see the matching emit + // in parse_isf for the rationale (SSBO bodies referencing user structs + // leak into VS via material_ubos and previously failed to compile when + // VISIBILITY was fragment-only). + material_ubos += isf_emit_types_struct(d.types); + int sampler_binding = 3; if(!d.inputs.empty()) @@ -2276,6 +4041,44 @@ void parser::parse_raw_raster_pipeline() uniforms += "layout(std140, binding = 2) uniform material_t {\n"; for(const isf::input& val : d.inputs) { + // Storage buffers / storage images / geometry inputs / UBOs are declared + // separately after samplers. BUT their synthesized host-side size ints + // (storage flex-array size, geometry $USER counts) ARE packed into this + // material blob, so they must be declared here too — otherwise every + // uniform after them reads shifted. Mirrors the CSF Params block. + if(auto* storage = ossia::get_if(&val.data)) + { + if(storage->access.find("write") != std::string::npos + && !storage->layout.empty() + && storage->layout.back().type.find("[]") != std::string::npos) + { + num_uniform++; + uniforms += "int " + val.name + "_size;\n"; + globalvars += "int " + val.name + "_size = isf_material_uniforms." + + val.name + "_size;\n"; + } + continue; + } + if(auto* geo = ossia::get_if(&val.data)) + { + auto emit_synth_int = [&](const std::string& nm) { + num_uniform++; + uniforms += "int " + nm + ";\n"; + globalvars += "int " + nm + " = isf_material_uniforms." + nm + ";\n"; + }; + if(geo->vertex_count.find("$USER") != std::string::npos) + emit_synth_int(val.name + "_vertex_count"); + if(geo->instance_count.find("$USER") != std::string::npos) + emit_synth_int(val.name + "_instance_count"); + for(const auto& aux : geo->auxiliary) + if(aux.size.find("$USER") != std::string::npos) + emit_synth_int(val.name + "_" + aux.name + "_size"); + continue; + } + if(ossia::get_if(&val.data) + || ossia::get_if(&val.data)) + continue; + auto [type, isSampler] = ossia::visit(create_val_visitor_450{}, val.data); if(isSampler) @@ -2302,6 +4105,18 @@ void parser::parse_raw_raster_pipeline() sampler_binding++; } } + else if(auto* cube = ossia::get_if(&val.data)) + { + if(cube->depth) + { + samplers += "layout(binding = "; + samplers += std::to_string(sampler_binding); + samplers += ") uniform samplerCube "; + samplers += val.name; + samplers += "_depth;\n"; + sampler_binding++; + } + } } else { @@ -2337,39 +4152,153 @@ void parser::parse_raw_raster_pipeline() material_ubos += samplers; } + // Storage buffers (SSBOs) and storage images declared via INPUTS with + // TYPE=storage or TYPE=image (visible to graphics stages). + sampler_binding = isf_emit_graphics_storage( + material_ubos, sampler_binding, d.inputs); + // Auxiliary SSBOs (from top-level AUXILIARY key) std::string ssbo_decls; - for(const auto& aux : d.auxiliary) - { - ssbo_decls += "layout(binding = " + std::to_string(sampler_binding) + ", std430) "; - if(aux.access == "read_only") - ssbo_decls += "readonly "; - else if(aux.access == "write_only") - ssbo_decls += "writeonly "; + // Emit a single buffer block for an auxiliary. `qualifier` is the std430 + // access qualifier ("readonly" / "writeonly" / "restrict") and `var` is + // the variable name (differs from `aux.name` for the _prev ping-pong + // slot). + auto emit_aux_block + = [&](const geometry_input::auxiliary_request& aux, int binding, + const char* qualifier, const std::string& var) { + if(aux.is_uniform) + { + // std140 UBO: no access qualifier (UBOs are inherently read-only + // from GLSL), `uniform` instead of `buffer`. + ssbo_decls += "layout(std140, binding = " + std::to_string(binding) + ") uniform "; + } else - ssbo_decls += "restrict "; - - ssbo_decls += "buffer " + aux.name + "_buf {\n"; + { + ssbo_decls += "layout(binding = " + std::to_string(binding) + ", std430) "; + ssbo_decls += qualifier; + ssbo_decls += " buffer "; + } + ssbo_decls += var; + ssbo_decls += "_buf {\n"; for(const auto& field : aux.layout) { - // Handle array types: "vec4[512]" → "vec4 entries[512];" auto bracket = field.type.find('['); if(bracket != std::string::npos) - { ssbo_decls += " " + field.type.substr(0, bracket) + " " + field.name + field.type.substr(bracket) + ";\n"; - } else - { ssbo_decls += " " + field.type + " " + field.name + ";\n"; + } + ssbo_decls += "} "; + ssbo_decls += var; + ssbo_decls += ";\n\n"; + }; + + for(const auto& aux : d.auxiliary) + { + const char* access_qualifier + = (aux.access == "read_only") ? "readonly" + : (aux.access == "write_only") ? "writeonly" + : "restrict"; + + // Persistent ping-pong only makes sense for writable SSBOs. UBOs + // declared persistent silently fall back to a single-block decl + // (the flag is ignored by the runtime allocator on the UBO path). + if(aux.persistent && !aux.is_uniform) + { + // Ping-pong pair: _prev is the previous frame's read-only snapshot, + // is the current frame's writable buffer. Runtime swaps + // the two buffer pointers each frame. + emit_aux_block(aux, sampler_binding, "readonly", aux.name + "_prev"); + sampler_binding++; + emit_aux_block(aux, sampler_binding, access_qualifier, aux.name); + sampler_binding++; + } + else + { + emit_aux_block(aux, sampler_binding, access_qualifier, aux.name); + sampler_binding++; + } + } + material_ubos += ssbo_decls; + + // Auxiliary textures (from top-level AUXILIARY with TYPE: image / + // texture / cubemap / image_cube / storage_*). No input port; the + // renderer resolves them from ossia::geometry::auxiliary_textures + // by name. Sampled textures emit `sampler*` decls with texture() + // semantics; storage images emit `image*` decls with imageLoad / + // imageStore semantics. + std::string aux_tex_decls; + for(const auto& atx : d.auxiliary_textures) + { + if(atx.is_storage) + { + // Storage image: imageLoad/Store target. FORMAT layout qualifier + // is mandatory on writable images; defaults to rgba8. + // Cube-arrays are parser-rejected so no imageCubeArray branch. + const char* image_type = "image2D"; + if(atx.is_cubemap) image_type = "imageCube"; + else if(atx.dimensions == 3) image_type = "image3D"; + else if(atx.is_array) image_type = "image2DArray"; + + const char* access_q = + (atx.access == "read_only") ? "readonly " : + (atx.access == "write_only") ? "writeonly " : ""; + + // Integer formats (r32ui, r32i, rgba32ui, …) require the + // `uimage*` / `iimage*` GLSL variants — the bare `image*` type + // paired with an integer layout qualifier is a compile error. + // Reuses the same prefix helper csf_image_input declarations + // already use, so float / int / uint emission stays consistent + // across the rasterizer-aux and csf-input code paths. + std::string scalar_prefix = isf_glsl_type_prefix(atx.format); + + aux_tex_decls += "layout(binding = " + std::to_string(sampler_binding) + + ", " + atx.format + ") uniform " + access_q + + scalar_prefix + image_type + " " + + atx.name + ";\n"; + sampler_binding++; + } + else + { + const bool cmp = isf_is_comparison_sampler(atx.sampler); + const char* sampler_type = "sampler2D"; + // Precedence: cubemap > 3D > array > 2D. sampler3D does not nest + // with array in core GLSL, so is_array is ignored when dimensions==3. + // Cube-arrays (samplerCubeArray) are parser-rejected — no backend + // plumbs CubeMap|TextureArray views correctly. + if(atx.is_cubemap) + sampler_type = cmp ? "samplerCubeShadow" : "samplerCube"; + else if(atx.dimensions == 3) + sampler_type = "sampler3D"; + else if(atx.is_array) + sampler_type = cmp ? "sampler2DArrayShadow" : "sampler2DArray"; + else + sampler_type = cmp ? "sampler2DShadow" : "sampler2D"; + + aux_tex_decls += "layout(binding = " + std::to_string(sampler_binding) + + ") uniform " + sampler_type + " " + atx.name + ";\n"; + sampler_binding++; + + // Paired depth sampler when DEPTH:true on a plain 2D tex. + if(atx.is_depth && !atx.is_cubemap && atx.dimensions != 3 && !atx.is_array) + { + aux_tex_decls += "layout(binding = " + std::to_string(sampler_binding) + + ") uniform sampler2D " + atx.name + "_depth;\n"; + sampler_binding++; } } - ssbo_decls += "} " + aux.name + ";\n\n"; + } + material_ubos += aux_tex_decls; + // Multiview UBO: injected when MULTIVIEW >= 2. + if(m_desc.multiview_count >= 2) + { + material_ubos += isf_emit_multiview_ubo( + sampler_binding, m_desc.multiview_count); sampler_binding++; } - material_ubos += ssbo_decls; int model_ubo_binding = sampler_binding; material_ubos += fmt::format( @@ -2385,6 +4314,18 @@ void parser::parse_raw_raster_pipeline() m_fragment += material_ubos; } + // The raw-raster path replaces gl_FragCoord → isf_FragCoord for the + // same Y-flip behaviour as fullscreen ISF, but unlike ISF the raw-raster + // FS prelude didn't define the macro — causing "isf_FragCoord : + // undeclared identifier" for any shader using gl_FragCoord. + m_fragment += R"_( +#if defined(QSHADER_SPIRV) || defined(QSHADER_HLSL) || defined(QSHADER_MSL) +#define isf_FragCoord vec4(gl_FragCoord.x, RENDERSIZE.y - gl_FragCoord.y, gl_FragCoord.z, gl_FragCoord.w) +#else +#define isf_FragCoord gl_FragCoord +#endif +)_"; + // Add the actual vert / frag code m_vertex += m_sourceVertex; m_fragment += fragWithoutISF; @@ -2392,6 +4333,9 @@ void parser::parse_raw_raster_pipeline() // Replace the special ISF stuff boost::replace_all(m_fragment, "gl_FragColor", "isf_FragColor"); boost::replace_all(m_fragment, "vv_Frag", "isf_Frag"); + + // Sanity-check ATTRIBUTES.TYPE references — see helper above. + validate_attribute_types(m_desc); } void parser::parse_shadertoy() @@ -2866,6 +4810,46 @@ void main(void) } // Helper function to escape JSON strings +// Serialize a sampler_config's non-empty fields as JSON key/value pairs +// onto `oss`, each prefixed with `", "`. Mirrors parse_sampler_config +// exactly so the JSON round-trip is lossless. Writes nothing when every +// field is at its default (empty strings, unset optionals). +static void emit_sampler_config(std::ostream& oss, const isf::sampler_config& s) +{ + auto esc = [](const std::string& x) { + std::string out; + out.reserve(x.size()); + for(char c : x) + { + if(c == '"' || c == '\\') { out += '\\'; out += c; } + else out += c; + } + return out; + }; + auto str_field = [&](const char* key, const std::string& val) { + if(!val.empty()) + oss << ", \"" << key << "\": \"" << esc(val) << "\""; + }; + auto float_field = [&](const char* key, const std::optional& val) { + if(val) oss << ", \"" << key << "\": " << *val; + }; + + str_field("WRAP", s.wrap); + str_field("WRAP_S", s.wrap_s); + str_field("WRAP_T", s.wrap_t); + str_field("WRAP_R", s.wrap_r); + str_field("FILTER", s.filter); + str_field("MIN_FILTER", s.min_filter); + str_field("MAG_FILTER", s.mag_filter); + str_field("MIPMAP_MODE", s.mipmap_mode); + str_field("BORDER_COLOR", s.border_color); + str_field("COMPARE", s.compare); + float_field("ANISOTROPY", s.anisotropy); + float_field("LOD_BIAS", s.lod_bias); + float_field("MIN_LOD", s.min_lod); + float_field("MAX_LOD", s.max_lod); +} + static auto escape_json(const std::string& str) -> std::string { std::string result; @@ -2926,6 +4910,24 @@ std::string parser::write_isf() const oss << "\n"; } oss << " ]"; + if(!m_desc.inputs.empty() || !m_desc.passes.empty() + || !m_desc.extensions.empty()) + oss << ","; + oss << "\n"; + } + + // Add extensions if present + if(!m_desc.extensions.empty()) + { + oss << " \"EXTENSIONS\": [\n"; + for(size_t i = 0; i < m_desc.extensions.size(); ++i) + { + oss << " \"" << escape_json(m_desc.extensions[i]) << "\""; + if(i + 1 < m_desc.extensions.size()) + oss << ","; + oss << "\n"; + } + oss << " ]"; if(!m_desc.inputs.empty() || !m_desc.passes.empty()) oss << ","; oss << "\n"; @@ -3037,6 +5039,8 @@ std::string parser::write_isf() const oss << ",\n \"DEFAULT\": [" << (*p.def)[0] << ", " << (*p.def)[1] << ", " << (*p.def)[2] << "]"; } + if(p.as_color) + oss << ",\n \"AS_COLOR\": true"; oss << "\n"; } @@ -3065,17 +5069,29 @@ std::string parser::write_isf() const oss << " \"TYPE\": \"image\""; if(img.depth) oss << ",\n \"DEPTH\": true"; + if(img.is_array) + oss << ",\n \"IS_ARRAY\": true"; + if(img.dimensions != 2) + oss << ",\n \"DIMENSIONS\": " << img.dimensions; + oss << "\n"; + } + void operator()(const cubemap_input& c) + { + oss << " \"TYPE\": \"cubemap\""; + if(c.depth) + oss << ",\n \"DEPTH\": true"; oss << "\n"; } - void operator()(const cubemap_input&) { oss << " \"TYPE\": \"cubemap\"\n"; } void operator()(const audio_input& a) { oss << " \"TYPE\": \"audio\""; if(a.max > 0) - { oss << ",\n \"MAX\": " << a.max; - } + if(!a.sampler.filter.empty()) + oss << ",\n \"FILTER\": \"" << escape_json(a.sampler.filter) << "\""; + if(!a.sampler.wrap.empty()) + oss << ",\n \"WRAP\": \"" << escape_json(a.sampler.wrap) << "\""; oss << "\n"; } @@ -3083,9 +5099,11 @@ std::string parser::write_isf() const { oss << " \"TYPE\": \"audioFFT\""; if(a.max > 0) - { oss << ",\n \"MAX\": " << a.max; - } + if(!a.sampler.filter.empty()) + oss << ",\n \"FILTER\": \"" << escape_json(a.sampler.filter) << "\""; + if(!a.sampler.wrap.empty()) + oss << ",\n \"WRAP\": \"" << escape_json(a.sampler.wrap) << "\""; oss << "\n"; } @@ -3093,9 +5111,11 @@ std::string parser::write_isf() const { oss << " \"TYPE\": \"audioHistogram\""; if(a.max > 0) - { oss << ",\n \"MAX\": " << a.max; - } + if(!a.sampler.filter.empty()) + oss << ",\n \"FILTER\": \"" << escape_json(a.sampler.filter) << "\""; + if(!a.sampler.wrap.empty()) + oss << ",\n \"WRAP\": \"" << escape_json(a.sampler.wrap) << "\""; oss << "\n"; } @@ -3104,6 +5124,12 @@ std::string parser::write_isf() const { oss << " \"TYPE\": \"storage\",\n"; oss << " \"ACCESS\": \"" << s.access << "\""; + if(!s.buffer_usage.empty()) + oss << ",\n \"BUFFER_USAGE\": \"" << escape_json(s.buffer_usage) << "\""; + if(s.persistent) + oss << ",\n \"PERSISTENT\": true"; + if(!s.visibility.empty() && s.visibility != "fragment") + oss << ",\n \"VISIBILITY\": \"" << escape_json(s.visibility) << "\""; if(!s.layout.empty()) { oss << ",\n \"LAYOUT\": [\n"; @@ -3121,13 +5147,41 @@ std::string parser::write_isf() const oss << "\n"; } + void operator()(const uniform_input& u) + { + oss << " \"TYPE\": \"uniform\",\n"; + oss << " \"LAYOUT\": [\n"; + for(std::size_t k = 0; k < u.layout.size(); ++k) + { + const auto& f = u.layout[k]; + oss << " { \"NAME\": \"" << escape_json(f.name) + << "\", \"TYPE\": \"" << escape_json(f.type) << "\" }"; + if(k + 1 < u.layout.size()) + oss << ","; + oss << "\n"; + } + oss << " ]"; + if(!u.visibility.empty() && u.visibility != "vertex+fragment") + oss << ",\n \"VISIBILITY\": \"" << escape_json(u.visibility) << "\""; + oss << "\n"; + } + void operator()(const texture_input&) { oss << " \"TYPE\": \"texture\"\n"; } void operator()(const csf_image_input& img) { oss << " \"TYPE\": \"image\",\n"; oss << " \"ACCESS\": \"" << img.access << "\",\n"; - oss << " \"FORMAT\": \"" << img.format << "\"\n"; + oss << " \"FORMAT\": \"" << img.format << "\""; + if(!img.visibility.empty() && img.visibility != "compute") + oss << ",\n \"VISIBILITY\": \"" << escape_json(img.visibility) << "\""; + if(img.persistent) + oss << ",\n \"PERSISTENT\": true"; + if(img.is_array) + oss << ",\n \"IS_ARRAY\": true"; + if(!img.layers_expression.empty()) + oss << ",\n \"LAYERS\": \"" << escape_json(img.layers_expression) << "\""; + oss << "\n"; } void operator()(const geometry_input& geo) @@ -3144,6 +5198,8 @@ std::string parser::write_isf() const try { std::stoi(geo.instance_count); oss << ",\n \"INSTANCE_COUNT\": " << geo.instance_count; } catch(...) { oss << ",\n \"INSTANCE_COUNT\": \"" << escape_json(geo.instance_count) << "\""; } } + if(!geo.format_id.empty()) + oss << ",\n \"FORMAT_ID\": \"" << escape_json(geo.format_id) << "\""; if(!geo.attributes.empty()) { oss << ",\n \"ATTRIBUTES\": [\n"; @@ -3168,14 +5224,20 @@ std::string parser::write_isf() const } oss << " ]"; } - if(!geo.auxiliary.empty()) + if(!geo.auxiliary.empty() || !geo.auxiliary_textures.empty()) { oss << ",\n \"AUXILIARY\": [\n"; - for(size_t i = 0; i < geo.auxiliary.size(); ++i) + const size_t nb = geo.auxiliary.size(); + const size_t nt = geo.auxiliary_textures.size(); + for(size_t i = 0; i < nb; ++i) { const auto& aux = geo.auxiliary[i]; oss << " {\"NAME\": \"" << escape_json(aux.name) << "\""; - if(!aux.access.empty()) + // TYPE: "uniform" for UBO-kind aux. SSBO kind omits TYPE — + // default parse dispatch lands there. + if(aux.is_uniform) + oss << ", \"TYPE\": \"uniform\""; + if(!aux.access.empty() && !aux.is_uniform) oss << ", \"ACCESS\": \"" << escape_json(aux.access) << "\""; if(!aux.size.empty()) { @@ -3196,7 +5258,53 @@ std::string parser::write_isf() const oss << "]"; } oss << "}"; - if(i < geo.auxiliary.size() - 1) + if(i < nb - 1 || nt > 0) + oss << ","; + oss << "\n"; + } + // Texture auxiliaries — identifying TYPE field so parse round- + // trips via aux_entry_is_texture. Full sampler_config fields + // are emitted via emit_sampler_config so WRAP/FILTER/COMPARE + // etc. round-trip losslessly. + for(size_t i = 0; i < nt; ++i) + { + const auto& atx = geo.auxiliary_textures[i]; + oss << " {\"NAME\": \"" << escape_json(atx.name) << "\""; + // TYPE field — reuse the specific storage_* variants so + // parse dispatch and re-emit stay symmetric. + if(atx.is_storage) + { + if(atx.is_cubemap && atx.is_array) + oss << ", \"TYPE\": \"storage_cube\""; // Note: no array-cube storage variant in current vocabulary + else if(atx.is_cubemap) + oss << ", \"TYPE\": \"storage_cube\""; + else if(atx.dimensions == 3) + oss << ", \"TYPE\": \"storage_3d\""; + else if(atx.is_array) + oss << ", \"TYPE\": \"storage_image_array\""; + else + oss << ", \"TYPE\": \"storage_image\""; + } + else if(atx.is_cubemap) + oss << ", \"TYPE\": \"cubemap\""; + else + oss << ", \"TYPE\": \"image\""; + if(atx.is_array && !atx.is_storage) + oss << ", \"IS_ARRAY\": true"; + if(atx.dimensions != 2 && !atx.is_storage) + oss << ", \"DIMENSIONS\": " << atx.dimensions; + if(atx.is_depth) + oss << ", \"DEPTH\": true"; + if(atx.is_storage) + { + if(!atx.format.empty() && atx.format != "rgba8") + oss << ", \"FORMAT\": \"" << escape_json(atx.format) << "\""; + if(!atx.access.empty() && atx.access != "read_write") + oss << ", \"ACCESS\": \"" << escape_json(atx.access) << "\""; + } + emit_sampler_config(oss, atx.sampler); + oss << "}"; + if(i < nt - 1) oss << ","; oss << "\n"; } @@ -3274,14 +5382,32 @@ std::string parser::write_isf() const try { std::stod(pass.height_expression); - oss << " \"HEIGHT\": " << pass.height_expression; + oss << " \"HEIGHT\": " << pass.height_expression << ",\n"; } catch(...) { - oss << " \"HEIGHT\": \"" << escape_json(pass.height_expression) << "\""; + oss << " \"HEIGHT\": \"" << escape_json(pass.height_expression) << "\",\n"; } } + if(!pass.z_expression.empty()) + { + try + { + std::stod(pass.z_expression); + oss << " \"Z\": " << pass.z_expression << ",\n"; + } + catch(...) + { + oss << " \"Z\": \"" << escape_json(pass.z_expression) << "\",\n"; + } + } + + if(!pass.format.empty()) + { + oss << " \"FORMAT\": \"" << escape_json(pass.format) << "\",\n"; + } + // Remove trailing comma if last property auto str = oss.str(); if(str.size() > 2 && str[str.size() - 2] == ',') @@ -3435,6 +5561,18 @@ void parser::parse_vsa() sampler_binding++; } } + else if(auto* cube = ossia::get_if(&val.data)) + { + if(cube->depth) + { + samplers += "layout(binding = "; + samplers += std::to_string(sampler_binding); + samplers += ") uniform samplerCube "; + samplers += val.name; + samplers += "_depth;\n"; + sampler_binding++; + } + } } else { @@ -3517,6 +5655,9 @@ void parser::parse_csf() // Add version m_fragment += "#version 450\n\n"; + // User-declared GLSL EXTENSIONS must come right after #version. + m_fragment += isf_emit_user_extensions(m_desc.extensions); + // Add standard ProcessUBO uniforms (same as ISF/VSA) m_fragment += GLSL45.defaultUniforms; m_fragment += "\n"; @@ -3527,34 +5668,37 @@ void parser::parse_csf() ", local_size_y = ISF_LOCAL_SIZE_Y" ", local_size_z = ISF_LOCAL_SIZE_Z) in;\n\n"; - // Generate struct definitions from TYPES section + // Generate struct definitions from TYPES section. + // + // No auto-padding: GLSL+std430 handles alignment based on actual member + // types (vec4 16B-aligned, float/uint 4B-aligned, struct rounds to its + // largest member). The previous "(4 - field_count % 4) % 4 trailing + // floats" heuristic was based on the field count modulo 4, completely + // unrelated to real alignment, and silently grew the struct stride + // when field_count wasn't a multiple of 4. RawLight (7 fields) became + // 68B → 80B std430-stride here while every rasterizer (graphics-path + // TYPES emitter has no such heuristic) and ScenePreprocessor's + // RawLight arena both use 64B stride — pack_lights_from_points writes + // landed at 80B intervals while the consumer rasterizer read at 64B + // intervals, garbling every slot past index 0 (the user's symptom: + // procedural light positions acting like colours, all lights piled up + // at the constant light_color value). Mirror the graphics-path + // emitter (isf_emit_types_struct) verbatim instead. if(!m_desc.types.empty()) { m_fragment += "// Struct definitions from TYPES section\n"; for(const auto& type_def : m_desc.types) { - m_fragment += "struct " + type_def.name + " \n{\n"; - + m_fragment += "struct " + type_def.name + " {\n"; for(const auto& field : type_def.layout) { auto bracket = field.type.find('['); if(bracket != std::string::npos) - m_fragment += " " + field.type.substr(0, bracket) + " " + field.name + m_fragment += " " + field.type.substr(0, bracket) + " " + field.name + field.type.substr(bracket) + ";\n"; else - m_fragment += " " + field.type + " " + field.name + ";\n"; - } - - // Add padding calculation for struct alignment - // This is a simplified approach - proper padding would require more complex size calculations - int field_count = type_def.layout.size(); - int padding_needed - = (4 - (field_count % 4)) % 4; // Simple 16-byte alignment padding - for(int i = 0; i < padding_needed; i++) - { - m_fragment += " float pad" + std::to_string(i) + ";\n"; + m_fragment += " " + field.type + " " + field.name + ";\n"; } - m_fragment += "};\n\n"; } } @@ -3678,6 +5822,20 @@ void parser::parse_csf() } } } + else if(auto* storage = ossia::get_if(&inp.data)) + { + // A writable storage buffer whose LAYOUT ends in a flexible-array + // member gets a synthesized host-side size int (see ISFVisitors / + // RenderedCSFNode). Declare it here so this std140 block matches the + // packed material blob; otherwise every uniform after it reads shifted. + if(storage->access.find("write") != std::string::npos + && !storage->layout.empty() + && storage->layout.back().type.find("[]") != std::string::npos) + { + k++; + material_block += " int " + inp.name + "_size;\n"; + } + } } material_block += "};\n\n"; @@ -3736,6 +5894,7 @@ void parser::parse_csf() // Generate resource bindings m_fragment += "// From RESOURCES - bindings assigned automatically\n"; + bool emitted_indirect_struct = false; for(const auto& inp : m_desc.inputs) { if(auto* storage_ptr = ossia::get_if(&inp.data)) @@ -3772,34 +5931,50 @@ void parser::parse_csf() { const auto& img = *img_ptr; - m_fragment += "layout(binding = " + std::to_string(binding); + // Emit the primary image binding, then — if persistent — emit a + // readonly `_prev` alias at the following slot. The runtime + // ping-pongs between two textures and swaps pointers each frame so + // the shader sees current-frame writes on `` and the previous + // frame's state on `_prev`. + auto emit_image = [&](int b, const std::string& decl_name, bool alias_prev) { + m_fragment += "layout(binding = " + std::to_string(b); - // Add format qualifier - if(!img.format.empty()) - { - std::string format = img.format; - boost::algorithm::to_lower(format); - m_fragment += ", " + format; - } - else - { - m_fragment += ", rgba8"; // Default format - } + if(!img.format.empty()) + { + std::string format = img.format; + boost::algorithm::to_lower(format); + m_fragment += ", " + format; + } + else + { + m_fragment += ", rgba8"; // Default format + } - m_fragment += ") "; + m_fragment += ") "; - // Add access qualifiers - if(img.access == "read_only") - m_fragment += "readonly "; - else if(img.access == "write_only") - m_fragment += "writeonly "; - else - m_fragment += "restrict "; + if(alias_prev || img.access == "read_only") + m_fragment += "readonly "; + else if(img.access == "write_only") + m_fragment += "writeonly "; + else + m_fragment += "restrict "; - auto prefix = glsl_type_prefix(img.format); - m_fragment += "uniform " + prefix + (img.is3D() ? "image3D " : "image2D "); - m_fragment += inp.name + ";\n"; + auto prefix = glsl_type_prefix(img.format); + const char* shape = "image2D"; + if(img.isCube()) shape = "imageCube"; + else if(img.is3D()) shape = "image3D"; + else if(img.is_array) shape = "image2DArray"; + m_fragment += "uniform " + prefix + shape + " "; + m_fragment += decl_name + ";\n"; + }; + + emit_image(binding, inp.name, /*alias_prev=*/false); binding++; + if(img.persistent) + { + emit_image(binding, inp.name + "_prev", /*alias_prev=*/true); + binding++; + } } else if(auto* tex_ptr = ossia::get_if(&inp.data)) { @@ -3809,6 +5984,11 @@ void parser::parse_csf() m_fragment += inp.name + ";\n"; binding++; } + else if(auto* uni_ptr = ossia::get_if(&inp.data)) + { + m_fragment += isf_emit_ubo_decl(binding, inp.name, *uni_ptr); + binding++; + } else if(auto* geo_ptr = ossia::get_if(&inp.data)) { const auto& geo = *geo_ptr; @@ -3816,6 +5996,26 @@ void parser::parse_csf() m_fragment += "// Geometry input \"" + inp.name + "\" — SoA: one SSBO per attribute\n"; m_fragment += "#define ISF_READ(geo, attr) geo ## _ ## attr ## _in\n"; m_fragment += "#define ISF_WRITE(geo, attr) geo ## _ ## attr ## _out\n"; + // Nested-aux structured-SSBO/UBO instance access. Resolves to the + // instance name emitted by the SSBO/UBO block below — bare aux name + // when there's no cross-geometry collision, prefixed otherwise. + // Use this instead of writing `scene_cluster_aabbs.data[...]` by + // hand: the macro keeps shaders working if the same aux name later + // appears in another geometry input and forces a name collision + // (the SSBO emitter switches to the prefixed instance name then). + m_fragment += "#define ISF_AUX(geo, name) geo ## _ ## name\n"; + // Nested-aux image access (storage images: read_only / write_only / + // read_write). For images there's no _in / _out distinction at the + // GLSL level — the same identifier carries the full access mode + // determined by the layout qualifier. Same one-name-per-image + // contract applies via the alias #define emitted in the texture + // block below. + m_fragment += "#define ISF_IMG(geo, name) geo ## _ ## name\n"; + // Nested-aux sampler access (read-only sampled textures with + // texture()/textureLod()/etc.). Symmetric to ISF_IMG — separate + // macro because the GLSL type differs (samplerXY vs imageXY) and + // future shaders may want to grep for usage independently. + m_fragment += "#define ISF_TEX(geo, name) geo ## _ ## name\n"; for(const auto& attr : geo.attributes) { @@ -3873,16 +6073,24 @@ void parser::parse_csf() const bool collides = colliding_aux_names.count(aux.name) > 0; const std::string instance_name = collides ? aux_prefix : aux.name; - m_fragment += "layout(binding = " + std::to_string(binding) + ", std430) "; - - if(aux.access == "read_only") - m_fragment += "readonly "; - else if(aux.access == "write_only") - m_fragment += "writeonly "; + if(aux.is_uniform) + { + // std140 UBO: no access qualifier, `uniform` not `buffer`. + m_fragment += "layout(std140, binding = " + std::to_string(binding) + ") uniform "; + } else - m_fragment += "restrict "; + { + m_fragment += "layout(binding = " + std::to_string(binding) + ", std430) "; + if(aux.access == "read_only") + m_fragment += "readonly "; + else if(aux.access == "write_only") + m_fragment += "writeonly "; + else + m_fragment += "restrict "; + m_fragment += "buffer "; + } - m_fragment += "buffer " + aux_prefix + "_buf {\n"; + m_fragment += aux_prefix + "_buf {\n"; for(const auto& field : aux.layout) { // Handle array types: "vec4[512]" → "vec4 entries[512];" @@ -3899,14 +6107,17 @@ void parser::parse_csf() } m_fragment += "} " + instance_name + ";\n"; - // Generate ISF_READ/ISF_WRITE-compatible aliases - if(aux.access == "read_only") + // Generate ISF_READ/ISF_WRITE-compatible aliases. UBOs are always + // read-only from GLSL's perspective (the `access` field is ignored + // for UBO kind), so only the `_in` / unqualified aliases exist. + const std::string eff_access = aux.is_uniform ? "read_only" : aux.access; + if(eff_access == "read_only") { m_fragment += "#define " + aux_prefix + "_in " + instance_name + "\n"; if(!collides) m_fragment += "#define " + aux_prefix + " " + instance_name + "\n"; } - else if(aux.access == "write_only") + else if(eff_access == "write_only") { m_fragment += "#define " + aux_prefix + "_out " + instance_name + "\n"; if(!collides) @@ -3916,12 +6127,110 @@ void parser::parse_csf() { m_fragment += "#define " + aux_prefix + "_in " + instance_name + "\n"; m_fragment += "#define " + aux_prefix + "_out " + instance_name + "\n"; + if(!collides) + m_fragment += "#define " + aux_prefix + " " + instance_name + "\n"; } m_fragment += "\n"; binding++; } + // Auxiliary textures (travel with the geometry; resolved by the + // renderer from ossia::geometry::auxiliary_textures by name). + // RenderedCSFNode binds them right after aux SSBOs in the compute + // SRB build loop — order here must match that order. + // + // Each texture is emitted under its bare aux name (e.g. + // `voxel_grid`) — same convention as the structured-SSBO/UBO block + // above when there's no name collision. A `#define + // _ ` alias is also emitted so author shaders can + // use either the prefixed form directly OR the ISF_IMG / + // ISF_TEX macros (which expand to `geo ## _ ## aux`). Keeps + // image-aux access symmetric with SSBO/UBO-aux access. + for(const auto& atx : geo.auxiliary_textures) + { + const std::string aux_prefix = inp.name + "_" + atx.name; + const bool aliased = (aux_prefix != atx.name); + + if(atx.is_storage) + { + // Cube-arrays are parser-rejected so no imageCubeArray branch. + const char* image_type = "image2D"; + if(atx.is_cubemap) image_type = "imageCube"; + else if(atx.dimensions == 3) image_type = "image3D"; + else if(atx.is_array) image_type = "image2DArray"; + + const char* access_q = + (atx.access == "read_only") ? "readonly " : + (atx.access == "write_only") ? "writeonly " : ""; + + // Integer formats (r32ui, r32i, …) require uimage*/iimage*. + std::string scalar_prefix = isf_glsl_type_prefix(atx.format); + + m_fragment += "layout(binding = " + std::to_string(binding) + + ", " + atx.format + ") uniform " + access_q + + scalar_prefix + image_type + " " + + atx.name + ";\n"; + if(aliased) + m_fragment += "#define " + aux_prefix + " " + atx.name + "\n"; + binding++; + } + else + { + const bool cmp = isf_is_comparison_sampler(atx.sampler); + const char* sampler_type = "sampler2D"; + // Cube-arrays (samplerCubeArray) are parser-rejected — no QRhi + // backend plumbs CubeMap|TextureArray views correctly. + if(atx.is_cubemap) + sampler_type = cmp ? "samplerCubeShadow" : "samplerCube"; + else if(atx.dimensions == 3) + sampler_type = "sampler3D"; + else if(atx.is_array) + sampler_type = cmp ? "sampler2DArrayShadow" : "sampler2DArray"; + else + sampler_type = cmp ? "sampler2DShadow" : "sampler2D"; + + m_fragment += "layout(binding = " + std::to_string(binding) + + ") uniform " + sampler_type + " " + atx.name + ";\n"; + if(aliased) + m_fragment += "#define " + aux_prefix + " " + atx.name + "\n"; + binding++; + + if(atx.is_depth && !atx.is_cubemap && atx.dimensions != 3 && !atx.is_array) + { + m_fragment += "layout(binding = " + std::to_string(binding) + + ") uniform sampler2D " + atx.name + "_depth;\n"; + if(aliased) + m_fragment += "#define " + aux_prefix + "_depth " + + atx.name + "_depth\n"; + binding++; + } + } + } + + // Indirect draw command buffer (user-writable SSBO) + if(geo.indirect) + { + if(!emitted_indirect_struct) + { + m_fragment += "struct DrawIndirectCommand {\n" + " uint vertexCount;\n" + " uint instanceCount;\n" + " uint firstVertex;\n" + " int baseVertex;\n" + " uint firstInstance;\n" + "};\n\n"; + emitted_indirect_struct = true; + } + const std::string buf_name = inp.name + "_indirect"; + m_fragment += "layout(binding = " + std::to_string(binding) + ", std430) " + "restrict buffer " + buf_name + "_buf {\n" + " DrawIndirectCommand " + buf_name + "[];\n" + "};\n"; + m_fragment += "#define ISF_INDIRECT(" + inp.name + ") " + buf_name + "\n\n"; + binding++; + } + // Element count uniform (packed into the material UBO or standalone) m_fragment += "// Element count for geometry input \"" + inp.name + "\"\n"; m_fragment += "// (set by the renderer from ossia::geometry::vertices)\n"; @@ -3944,6 +6253,11 @@ void parser::parse_csf() // Add the user's compute shader code (without the JSON header) boost::algorithm::trim(compWithoutCSF); m_fragment += compWithoutCSF; + + // Sanity-check: every ATTRIBUTES.TYPE references a real GLSL built-in + // or a TYPES entry. Throws invalid_file with the offending name on + // miss — surfaces typos at parse time. + validate_attribute_types(m_desc); } descriptor::Mode parser::mode() const diff --git a/src/plugins/score-plugin-gfx/3rdparty/libisf/src/isf.hpp b/src/plugins/score-plugin-gfx/3rdparty/libisf/src/isf.hpp index dd0ff5f4ec..6aae6b8ff9 100644 --- a/src/plugins/score-plugin-gfx/3rdparty/libisf/src/isf.hpp +++ b/src/plugins/score-plugin-gfx/3rdparty/libisf/src/isf.hpp @@ -34,13 +34,25 @@ struct long_input using has_minmax = std::true_type; std::vector> values; std::vector labels; - std::size_t def{}; // index of default value (enum mode) or default value (numeric mode) + + // Enum mode (values/labels non-empty): `def` is the INDEX into `values`. + // Numeric mode (values empty, min/max set): `def` is the default VALUE. + // + // The shader always receives the selected numeric VALUE from `values[i]` + // (for int/double entries) or the INDEX (for string-only VALUES, since + // GLSL can't consume strings). The renderer's UBO-init path resolves this + // index→value step so the initial shader state matches what arrives after + // any user interaction — see ISFNode.cpp / GeometryFilterNode.cpp long_input + // port visitors. + std::size_t def{}; // Numeric mode: when values/labels are empty and min/max are set, - // create an IntSpinBox instead of a ComboBox. + // create an IntSpinBox instead of a ComboBox. In that mode `def` is the + // default value directly (not an index). std::optional min; std::optional max; }; + struct float_input { using value_type = double; @@ -66,6 +78,12 @@ struct point3d_input std::optional def{}; std::optional min{}; std::optional max{}; + + // AS_COLOR: hint to the UI that this vec3 should be shown as a color + // swatch (RGB picker) rather than three spin boxes. Useful for e.g. + // direction-as-RGB visualisations where editing components individually + // is awkward. Does not affect the GLSL type (still vec3). + bool as_color{false}; }; struct color_input @@ -77,29 +95,133 @@ struct color_input std::optional max{}; }; +// Sampler configuration fields shared by image/texture/cubemap inputs. +// All fields are optional: empty/unset string keeps the current default. +// Address modes accept: "repeat", "clamp_to_edge"/"clamp", "mirror"/"mirrored_repeat", +// "mirror_once"/"mirror_clamp_to_edge". +// Filter modes accept: "nearest", "linear" (and "none" for mipmap_mode). +// Border color accepts: "transparent_black"/"transparent", "opaque_black", "opaque_white". +// Compare op accepts: "never", "less", "less_equal"/"lequal", "equal", +// "greater", "greater_equal"/"gequal", "not_equal"/"neq", "always". +// When set (and not "never") a comparison sampler is created and +// the GLSL type becomes sampler*Shadow. Supported on 2D, +// 2D-array, cubemap (image/texture/cubemap inputs) and +// cubemap-array (AUXILIARY only). Silently dropped with a +// stderr warning on 3D inputs (sampler3DShadow is not a core +// GLSL type) — use a 2D / 2D-array / cube shadow instead. +// With the engine's reverse-Z convention, the typical +// compare op for a standard "shadowed if closer" test is +// "greater_equal" (not "less_equal"). +struct sampler_config +{ + std::string wrap; // Applied to all 3 axes if individual WRAP_S/T/R unset + std::string wrap_s; + std::string wrap_t; + std::string wrap_r; + std::string filter; // Applied to both min and mag if individual MIN/MAG_FILTER unset + std::string min_filter; + std::string mag_filter; + std::string mipmap_mode; + std::optional anisotropy; + std::string border_color; + std::optional lod_bias; + std::optional min_lod; + std::optional max_lod; + std::string compare; // empty / "never" = no comparison sampler +}; + struct image_input { - int dimensions{2}; // 2 or 3 - bool depth{false}; // true = shader wants sampleable depth on this input + int dimensions{2}; // 2 or 3 + bool depth{false}; // true = shader wants sampleable depth on this input + bool is_array{false}; // true = sampler2DArray rather than sampler2D + // STATIC: producer publishes a long-lived QRhiTexture that downstream binds + // directly; engine skips the consumer-side render-target allocation. Use for + // precomputed LUTs, IBL bakes, asset caches — anything where the upstream + // is a CPU producer (avnd gpu_texture_output, etc.) rather than an ISF / + // raster pass that draws into the consumer's RT each frame. Orthogonal to + // dimensions / is_array (cube + 3D + array inputs already grab from source + // implicitly because they can't be 2D color attachments anyway). + bool is_static{false}; + sampler_config sampler; }; struct cubemap_input { + // DEPTH: true = request a sampleable depth cube alongside the color cube. + // Mirrors image_input::depth: pairs the main `samplerCube` (or + // `samplerCubeShadow` under COMPARE) with a `samplerCube _depth` + // companion for raw depth reads. Useful for omni-directional scene probes + // where the upstream provides both a colour cube and its depth cube. + // For plain shadow-cube sampling (HW PCF only) set COMPARE instead and + // leave DEPTH false — the texture already has to be depth-format for the + // compare sampler to return meaningful values. + // + // Note: cube-arrays (samplerCubeArray) are intentionally NOT exposed. No + // QRhi backend (Vulkan/D3D12/Metal/GL) constructs a cube-array view + // correctly from the CubeMap | TextureArray flag combination, so the + // shader-side type would always disagree with the bound resource. Bind N + // individual cubemap inputs instead, or decompose to a sampler2DArray + // with face math in the shader. + bool depth{false}; + sampler_config sampler; +}; + +// Sampler state accepted by all audio input flavours. Reuses the same +// string vocabulary as sampler_config (see above) — any unrecognised or +// empty string keeps the built-in default (linear / clamp_to_edge). Full +// sampler_config is overkill here: audio textures are 1-mip 2D samplers +// with no COMPARE / BORDER_COLOR / LOD semantics, so only FILTER and WRAP +// are honoured. Nearest filtering is the common ask for band-exact FFT +// reads where linear interpolation would smear adjacent bins. +struct audio_sampler_config +{ + std::string filter; // "nearest" or "linear" (default) + std::string wrap; // "repeat", "clamp_to_edge"/"clamp", "mirror"/"mirrored_repeat" }; struct audio_input { int max{}; + audio_sampler_config sampler; }; struct audioFFT_input { int max{}; + audio_sampler_config sampler; }; struct audioHist_input { int max{}; + audio_sampler_config sampler; +}; + +// UBO-style input declared in INPUTS as `"TYPE": "uniform"`. +// +// Emitted as `layout(std140, binding=N) uniform _t { ... } ;` +// and bound via QRhiShaderResourceBinding::uniformBuffer (not bufferLoad). +// +// Use for small (≤ MaxUniformBufferRange, typically 16KB), read-only data +// like cameras, light/material counts, indexing constants. For larger or +// writable data, use `storage_input` (SSBO) instead. +struct uniform_input +{ + // Reuse storage_input's layout_field shape via full struct definition here + // to keep the type self-contained. + struct layout_field + { + std::string name; + std::string type; + }; + + std::vector layout; + + // VISIBILITY: which shader stage(s) see this binding in a graphics pipeline. + // Accepted values: "vertex+fragment"/"both" (default), "fragment", "vertex", + // "compute" (implicit for CSF). + std::string visibility{"vertex+fragment"}; }; // CSF-specific input types @@ -116,11 +238,22 @@ struct storage_input std::vector layout; std::string buffer_usage; // "", "indirect_draw", "indirect_draw_indexed" + + // PERSISTENT: creates a ping-pong pair of SSBOs swapped each frame. + // In GLSL, `name` is the current (read-write) buffer, `name_prev` is the + // previous frame's read-only buffer. + bool persistent{false}; + + // VISIBILITY: which shader stage(s) see this binding in a graphics pipeline. + // Accepted values: "fragment" (default), "vertex", "vertex+fragment"/"both", + // "compute" (implicit for CSF), "none" (no shader binding). + std::string visibility{"fragment"}; }; struct texture_input { int dimensions{2}; // 2 or 3 + sampler_config sampler; }; struct csf_image_input @@ -134,7 +267,45 @@ struct csf_image_input int dimensions{2}; // 2 or 3 (alternative to depth_expression for declaring 3D) + // Set internally when the RESOURCES entry uses TYPE: "image_cube". + // Writable cubemap (imageCube in GLSL, QRhiTexture::CubeMap | + // UsedWithLoadStore). Width must equal height (face edge length). Use for + // in-compute reflection-probe baking, environment IBL, etc. Read-only + // sampling of the same data is done via TYPE: "cubemap". + bool cubemap{false}; + + // IS_ARRAY: writable 2D texture array (image2DArray in GLSL, allocated + // via QRhi::newTextureArray + UsedWithLoadStore). Layer count comes from + // layers_expression (LAYERS: "$USER" / literal). Useful for shadow + // cascades, layered G-buffers, compute-written texture atlases. + // + // Cube-arrays (imageCubeArray) are intentionally NOT supported: no QRhi + // backend plumbs CubeMap | TextureArray views correctly, and the shader- + // side type would disagree with the bound resource. The parser rejects + // is_array + cubemap combinations with a stderr warning. + bool is_array{false}; + std::string layers_expression; // LAYERS: expression for arraySize, may contain $USER + + // VISIBILITY: which shader stage(s) see this binding. + // Accepted: "compute" (default), "fragment", "vertex", "vertex+fragment"/"both". + std::string visibility{"compute"}; + + // PERSISTENT: creates a ping-pong pair of images swapped each frame. + // In GLSL, `` is the current (write or read_write) image and + // `_prev` is the previous frame's read-only image — mirrors the + // storage_input convention. Works for both 2D and 3D images. + bool persistent{false}; + + // GENERATE_MIPS: when true, the runtime runs QRhi's generateMips() on + // this image after every frame's compute dispatches complete, so + // downstream samplers with MIPMAP_MODE: linear / nearest see a valid + // mip chain instead of zero-filled upper levels. Ignored for 3D images, + // cubemaps, and 2D arrays where generateMips semantics differ across + // QRhi backends (per-face / per-layer / per-slice). + bool generate_mips{false}; + bool is3D() const noexcept { return dimensions == 3 || !depth_expression.empty(); } + bool isCube() const noexcept { return cubemap; } }; // CSF geometry port input: SoA layout, one SSBO per attribute. @@ -164,27 +335,101 @@ struct geometry_input std::optional forward; }; - // Structured SSBOs that travel with the geometry (matched by name - // against ossia::geometry::auxiliary_buffer entries). + // Structured buffers that travel with the geometry (matched by name + // against ossia::geometry::auxiliary_buffer entries). Default kind is + // SSBO (`layout(std430) buffer`); set `is_uniform = true` to declare a + // std140 UBO instead (`layout(std140) uniform`). struct auxiliary_request { std::string name; std::string access; // "read_only", "write_only", "read_write" + // (meaningful for SSBO kind only; UBO is always read-only from GLSL) std::vector layout; std::string size; // expression for flexible array count, may contain $USER + // (SSBO only; UBOs require fixed-size layouts per std140) // If set, this auxiliary is forwarded from another geometry's upstream. std::optional forward; + + // Raw-raster only: when true the node owns a ping-pong pair of buffers + // (allocated from the LAYOUT + SIZE) that are swapped each frame, and + // the auxiliary is NOT resolved from upstream geometry. In GLSL, + // `` is the current (writable) buffer, `_prev` is the + // previous frame's read-only buffer. Useful for temporal accumulation + // / history buffers that live only in the rendering node. + // (SSBO only; persistent ping-pong makes no sense for read-only UBOs.) + bool persistent{false}; + + // When true, declare/bind this auxiliary as a std140 uniform block + // (`layout(std140, binding=N) uniform name_t { … } name;`) and bind + // with QRhiShaderResourceBinding::uniformBuffer. When false (default), + // it's an std430 SSBO. The upstream geometry's + // ossia::geometry::auxiliary_buffer is kind-agnostic — the shader's + // declaration alone determines how the buffer is bound. + bool is_uniform{false}; + }; + + // Texture variant of auxiliary: resolved from ossia::geometry::auxiliary_textures + // by name, no score input port. Declared in the top-level AUXILIARY array + // with TYPE: "image" / "texture" / "cubemap". Unlike regular INPUTS + // textures, does not create an input port — the texture handle travels + // bundled with the geometry (e.g. ScenePreprocessor ships `base_color_array` + // / `skybox` / `shadow_atlas`). + struct auxiliary_texture_request + { + std::string name; + int dimensions{2}; // 2 or 3 + bool is_array{false}; // sampler2DArray when true + bool is_cubemap{false};// samplerCube when true + bool is_depth{false}; // sampleable depth (promotes comparison when cfg set) + // Storage-image kind: emit `image2D/3D/Cube/Array` with imageLoad/ + // imageStore semantics instead of `sampler2D/…` with texture(). Set + // by TYPE: "storage_image" in the AUXILIARY JSON. Paired with: + // - `format`: GLSL layout qualifier (e.g. "rgba8", "r32f", "rgba16f"). + // - `access`: "read_only" / "write_only" / "read_write", controlling + // imageLoad / imageStore / imageLoadStore binding type + the + // GLSL `readonly`/`writeonly` decoration. + bool is_storage{false}; + std::string format{"rgba8"}; // only meaningful when is_storage + std::string access{"read_write"}; // only meaningful when is_storage + + // Sizing expressions for write_only / read_write storage images. Same + // convention as csf_image_input (top-level INPUTS images): an integer + // literal or a `$variable` reference resolved against the shader's + // long/float input ports + the standard $WIDTH/$HEIGHT/$DEPTH/$LAYERS + // family. Empty → engine falls back to renderer state (renderSize for + // 2D, voxel-resolution heuristics for 3D). When the engine + // auto-allocates a writable nested-aux storage image, these strings + // drive its dimensions; for sampled (read-only) entries they're + // ignored — the texture comes from the upstream producer at whatever + // size that producer baked. + std::string width_expression; + std::string height_expression; + std::string depth_expression; // 3rd dimension for 3D textures + std::string layers_expression; // array slice count for 2D arrays + + sampler_config sampler; }; std::vector attributes; std::vector auxiliary; + std::vector auxiliary_textures; std::string vertex_count; // expression string, may contain $USER std::string instance_count; // expression string, may contain $USER - bool indirect_draw{false}; // compute shader writes draw args to an indirect buffer - std::string indirect_draw_type; // "draw" (default) or "draw_indexed" + // Optional format identity stamped onto the consumer geometry's + // filter_tag (rapidhash truncated to 32 bits). Only meaningful on + // RESOURCES of TYPE: geometry used as outputs (geoOut). Empty leaves + // filter_tag at 0 (the "untagged" sentinel) — no routing change for + // CSFs that don't author an output format. + std::string format_id; + + struct indirect_request + { + std::string count; // expression string (same resolver as vertex_count) + }; + std::optional indirect; }; struct input @@ -193,7 +438,7 @@ struct input float_input, long_input, event_input, bool_input, color_input, point2d_input, point3d_input, image_input, cubemap_input, audio_input, audioFFT_input, audioHist_input, storage_input, texture_input, csf_image_input, - geometry_input>; + geometry_input, uniform_input>; std::string name; std::string label; @@ -290,10 +535,53 @@ struct vertex_attribute int location{}; attribute_type type{}; std::string name; + + // Optional explicit ossia attribute_semantic name ("position", "velocity", + // "texcoord0", ..., "custom"). Only meaningful on `vertex_input` (raw + // raster), where it controls how the runtime matches the declared input + // to an upstream geometry attribute — same lookup algorithm as CSF + // attribute_request. When empty, the parser implicitly uses `name` as the + // semantic key. Set to "custom" to force exact-name matching against + // custom attributes. + std::string semantic; + + // Interpolation qualifier (only applicable to vertex_output / fragment_input). + // Allowed: "smooth" (default), "flat", "noperspective", "centroid", "sample". + // "sample" forces per-sample fragment shading on this varying — the fragment + // shader runs once per MSAA sample for that coverage. Required when MSAA + // outputs need per-sample correct interpolation (specular highlights, + // normal-mapped surfaces). Empty string = default smooth. + std::string interpolation; }; struct vertex_input : vertex_attribute { + // When false, the raw-raster renderer tolerates an upstream geometry that + // does not carry a matching attribute: instead of failing the pipeline + // build, it synthesises a tiny PerInstance step_rate=1 buffer filled with + // a neutral "identity" value (zero for translation, white for color, 1 + // for roughness, etc.) and binds that in place of the missing upstream + // attribute. Lets a single shader cover both instanced and non-instanced + // upstreams without per-shape variants. + // + // When false AND `default_val` is set, those explicit numbers are used + // verbatim (after component-truncation / zero-padding against the + // declared TYPE). When false AND `default_val` is empty, the runtime + // looks the semantic up in a built-in whitelist (see + // score::gfx::vertexFallbackDefault) — non-whitelisted semantics without + // an explicit DEFAULT are rejected at pipeline-build time with a clear + // error to avoid silently-wrong rendering. + // + // When true (default), the upstream geometry MUST provide the attribute + // or the pipeline build fails — existing strict behaviour. + bool required{true}; + + // Explicit DEFAULT numbers from the JSON header. Stored as doubles for + // JSON fidelity; converted to the runtime format (float / int) at + // buffer-build time. Empty = use the whitelist neutral (see `required`). + // Length is not pre-validated against TYPE here — the runtime truncates + // or zero-pads to match the declared GLSL type width. + std::vector default_val; }; struct vertex_output : vertex_attribute { @@ -305,6 +593,92 @@ struct fragment_output : vertex_attribute { }; +// --- Pipeline state control (PIPELINE_STATE descriptor key) --------------- +// +// All fields are optional (std::optional): missing = keep current/legacy +// default. Two instances live in `descriptor`: a global `default_state` +// (from PIPELINE_STATE), and a per-pass `override_state` that merges on top. + +struct blend_attachment +{ + bool enable{false}; + std::string src_color{"src_alpha"}; + std::string dst_color{"one_minus_src_alpha"}; + std::string op_color{"add"}; + std::string src_alpha{"one"}; + std::string dst_alpha{"one_minus_src_alpha"}; + std::string op_alpha{"add"}; + std::string color_write{"rgba"}; // "rgba", "rgb", "r", ... +}; + +struct stencil_op_state +{ + std::string fail_op{"keep"}; + std::string depth_fail_op{"keep"}; + std::string pass_op{"keep"}; + std::string compare_op{"always"}; +}; + +struct pipeline_state +{ + std::optional depth_test; + std::optional depth_write; + std::optional depth_compare; // "less", "less_equal", "greater", ... + std::optional depth_bias; + std::optional slope_scaled_depth_bias; + + std::optional cull_mode; // "none", "front", "back" + std::optional front_face; // "ccw", "cw" + std::optional polygon_mode;// "fill", "line" + std::optional line_width; + + // Procedural-draw override (Vertex Shader Art style). When + // `vertex_count` is set, the renderer issues a single + // cb.draw(vertex_count, instance_count, 0, 0) and ignores the + // incoming geometry's index / indirect buffers entirely. The vertex + // shader drives positions purely from gl_VertexIndex + + // gl_InstanceIndex. Use cases: + // - Fullscreen passes: VERTEX_COUNT=3, TOPOLOGY=triangles (skybox). + // - VSA-style plasma / curves: VERTEX_COUNT=10000, + // TOPOLOGY=line_strip. + // - Procedural particle grids: VERTEX_COUNT=65536, TOPOLOGY=points. + // + // Safety: if VERTEX_INPUTS is non-empty (the shader declares vertex + // attribute reads), the renderer clamps vertex_count to the incoming + // geometry's vertex_count to avoid reading past buffer ends. Shaders + // that rely purely on gl_VertexIndex should declare an empty + // `VERTEX_INPUTS: []` so the pipeline is built with no vertex + // bindings and the draw count is used verbatim. + std::optional vertex_count; + std::optional instance_count; + // Topology override. When unset, the incoming geometry's topology is + // used. Values: "triangles", "triangle_strip", "triangle_fan", + // "lines", "line_strip", "points". + std::optional topology; + + // Blending: either a single state applied to all color attachments, or a + // per-attachment vector. If both are present the per-attachment wins. + std::optional blend_all; + std::vector blend_per_attachment; + + // Stencil (optional) + std::optional stencil_test; + std::optional stencil_read_mask; + std::optional stencil_write_mask; + std::optional stencil_front; + std::optional stencil_back; + + // Variable-rate shading (VRS). + // "SHADING_RATE": [w, h] — per-draw shading rate where w,h ∈ {1, 2, 4}. + // [1,1] = 1×1 (full rate, default). + // [2,2] = 1 invocation per 2×2 pixel block. + // [4,4] = 1 per 4×4 block. + // Combined with a shading-rate map (set on the render target) the actual + // rate is the per-draw rate combined with the per-tile rate via the chosen + // combiner op. Requires QRhi::Feature::VariableRateShading (Vulkan, D3D12). + std::optional> shading_rate; +}; + struct pass { std::string target; @@ -313,12 +687,85 @@ struct pass bool nearest_filter{}; std::string width_expression{}; std::string height_expression{}; + + // Render to a specific layer of a texture-array output (-1 = layer 0). + int layer{-1}; + + // Render to a specific Z-slice of a 3D output. Expression string so the + // slice can be computed from inputs (e.g. "$USER_slice"). Empty = slice 0 + // when the target is 3D, or irrelevant when 2D. + std::string z_expression{}; + + // Optional format override for the intermediate render target of this + // pass (e.g. "rgba16f" for precision-sensitive blur stages). Empty = use + // FLOAT: true mapping (rgba32f / rgba8) as before. + std::string format{}; + + // Per-pass pipeline state overrides (merged with descriptor.default_state). + pipeline_state override_state; }; struct output_declaration { std::string name; // User-chosen name (e.g. "color", "sceneDepth") std::string type; // "color" (default) or "depth" + + // LAYERS: >1 allocates a texture array with this many layers. + int layers{1}; + + // DEPTH: >1 allocates a 3D texture of this depth. Mutually exclusive with + // LAYERS (a ThreeDimensional texture is not a TextureArray). A fragment + // PASSES entry with Z renders into a single Z-slice via a color attachment + // with setLayer(z). + int depth{1}; + + // FORMAT: optional explicit texture format ("rgba8", "rgba16f", "r32f", "d32f", ...). + // Empty = use the default (RGBA8 for color, D32F for depth). + std::string format; + + // SAMPLES: MSAA sample count (1, 2, 4, 8, 16, 32, 64). 1 = no MSAA (default). + // The renderer allocates an MSAA texture and inserts an automatic resolve + // pass when downstream consumers expect a non-MSAA input. Each declared + // OUTPUT can have its own sample count; the depth attachment for a colour + // OUTPUT inherits the same sample count. + int samples{1}; + + // CUBEMAP: when true the output is allocated with the QRhi cubemap flag + // so downstream consumers can bind it as a samplerCube. Implies + // `layers == 6` on allocation even when the shader didn't set LAYERS + // explicitly. Used by the IBL precompute path (irradiance_convolve, + // prefilter_ggx) together with MULTIVIEW:6. + bool is_cubemap{false}; + + // GENERATE_MIPS: when true the runtime calls generateMips() on this + // output's texture after the render pass completes, auto-averaging + // the base level into a full mip chain. Implies the QRhi + // `MipMapped` + `UsedWithGenerateMips` flags on allocation. Use this + // for "source-data" targets whose base level is authored by the + // fragment shader and whose sub-mips should be GPU-filtered (skybox + // converter, base color textures, SSAO LUTs…). NOT for the + // prefilter-style case where each mip has distinct shader-authored + // content — use EXECUTION_MODEL: PER_MIP instead. + bool generate_mips{false}; + + // WIDTH / HEIGHT: explicit target size for offscreen outputs. Set + // by the shader author when the intrinsic size of the algorithm + // isn't tied to the window / swap-chain (IBL precompute, shadow + // atlases, post-process LUTs, …). Zero → fall back to the + // renderer's render-size (classic behaviour). Integer literal or + // string expression; the expression is evaluated once at init + // against the same variable surface as CSF dispatch expressions + // ($WIDTH_ / $HEIGHT_ / scalar input values). + // + // All colour OUTPUTs of a single RAW_RASTER_PIPELINE shader share + // a render pass and must therefore resolve to the same final size; + // the runtime uses the first colour OUTPUT's resolved size as the + // RT size and allocates every attachment at that size. Cubemaps + // are additionally clamped to square via min(w, h) (QRhi contract). + int width{0}; + int height{0}; + std::string width_expression; + std::string height_expression; }; struct descriptor @@ -374,6 +821,91 @@ struct descriptor // Auxiliary SSBOs expected from upstream geometry (matched by name). // Populated from top-level AUXILIARY key in RAW_RASTER_PIPELINE mode. std::vector auxiliary; + + // Auxiliary textures travelling with the geometry (matched by name + // against ossia::geometry::auxiliary_textures). Populated from the same + // top-level AUXILIARY array when entries have TYPE: "image" / "texture" + // / "cubemap". Unlike INPUTS-declared textures they don't consume a + // score input port — the renderer looks them up on the geometry every + // frame. + std::vector auxiliary_textures; + + // PIPELINE_STATE: global pipeline state (depth, blend, cull, stencil, ...). + // Applies to every output pass; may be overridden per-pass via pass::override_state. + pipeline_state default_state; + + // MULTIVIEW: render to N layers of a texture array in a single draw. + // 0 or 1 = disabled. N>=2 = enabled (requires QRhi::MultiView capability). + int multiview_count{0}; + + // EXECUTION_MODEL (RAW_RASTER_PIPELINE only — silently ignored in other + // modes). Drives the invocation count of the single raster pass: + // + // "SINGLE" (default) — one invocation per frame, RT bound at + // mip 0. + // "PER_MIP" — N invocations, RT bound at mip `i` on iteration + // `i`. N is derived from the `target` texture's + // mip chain (floor(log2(min(w, h))) + 1). + // ProcessUBO.passIndex carries the mip index. + // "PER_CUBE_FACE" — 6 invocations, RT bound at cube layer `i` + // (face order +X, -X, +Y, -Y, +Z, -Z). Target + // OUTPUT must be CUBEMAP: true. Mutually + // exclusive with MULTIVIEW (which already + // amplifies one draw to 6 faces). + // "PER_LAYER" — N invocations, RT bound at array layer `i`. N + // comes from the target OUTPUT's `layers` + // declaration. Works on either colour TextureArray + // targets (setLayer attachment) or depth + // TextureArray targets (rendered to a scratch + // and copied into the array layer post-pass — + // QRhi 6.11 has no per-layer depth attachment + // API). ProcessUBO.passIndex carries the layer + // index. Drives shadow_cascades.frag. + // "MANUAL" — N invocations, same RT each time, where N is + // evaluated from the `count` expression string + // via the math_expression parser every frame + // (same variable bindings as CSF's stride / + // image-size expressions: $WIDTH, $HEIGHT, + // $, ...). + struct raster_execution_model + { + std::string type; // "SINGLE" / "PER_MIP" / "PER_CUBE_FACE" / "PER_LAYER" / "MANUAL" + std::string target; // PER_MIP / PER_CUBE_FACE / PER_LAYER: OUTPUT name to iterate + std::string count_expression; // MANUAL: integer-valued expression + }; + raster_execution_model execution_model; + + // User-declared GLSL extension names, emitted as `#extension NAME : require` + // immediately after `#version` in every generated stage. Examples: + // "GL_KHR_shader_subgroup_arithmetic", "GL_EXT_shader_atomic_float". + std::vector extensions{ + "GL_GOOGLE_include_directive", "GL_GOOGLE_cpp_style_line_directive"}; + + // CLIP_DISTANCES: number of gl_ClipDistance[N] outputs the vertex shader + // writes (1..8 typical). When > 0 the parser injects + // `out float gl_ClipDistance[N];` in the vertex stage so user code can + // assign without writing the declaration. Each declared distance enables + // one user-defined clipping plane: fragments where gl_ClipDistance[i] < 0 + // are discarded. + int clip_distances{0}; + + // CULL_DISTANCES: like clip distances but per-primitive: a primitive whose + // every vertex has all gl_CullDistance[i] < 0 is fully culled before + // rasterisation. Useful for cheap frustum-/occlusion-style culling. + int cull_distances{0}; + + // DEPTH_LAYOUT: conservative-depth qualifier on gl_FragDepth. Allowed: + // "any" — driver default (no guarantee, disables early-Z when + // gl_FragDepth is written). + // "greater" — promise the value written is >= the value rasterisation + // would have produced. Lets the HW keep early-Z reject + // for fragments already deeper than the depth buffer. + // "less" — symmetric promise in the other direction. + // "unchanged" — promise the written value equals the rasterised value + // (mostly for documentation; same fast path as "greater" + // on hardware where reverse-Z applies). + // Empty = no qualifier emitted. + std::string depth_layout; }; class SCORE_PLUGIN_GFX_EXPORT parser diff --git a/src/plugins/score-plugin-gfx/3rdparty/nv-dvp-bridge/CMakeLists.txt b/src/plugins/score-plugin-gfx/3rdparty/nv-dvp-bridge/CMakeLists.txt new file mode 100644 index 0000000000..ffbd63043e --- /dev/null +++ b/src/plugins/score-plugin-gfx/3rdparty/nv-dvp-bridge/CMakeLists.txt @@ -0,0 +1,65 @@ +# nv-dvp-bridge: NVIDIA "GPUDirect for Video" runtime-loaded shim + +# C API wrapper. Cross-platform: Windows uses dvp.dll; Linux uses +# libdvp.so.1. Consumers (AJA, planned DeckLink) link this target via +# its score-plugin-gfx parent. +# +# The bridge itself has no NVIDIA-SDK headers as a build dependency — +# all DVP entry points are dlsym'd at runtime from the NVIDIA library. + +add_library(score_nv_dvp_bridge STATIC + "${CMAKE_CURRENT_SOURCE_DIR}/nv_dvp_bridge.cpp" + "${CMAKE_CURRENT_SOURCE_DIR}/nv_dvp_bridge.h" + "${CMAKE_CURRENT_SOURCE_DIR}/dvpapi_shim.cpp" + "${CMAKE_CURRENT_SOURCE_DIR}/dvpapi_shim.h" +) + +target_include_directories(score_nv_dvp_bridge + PUBLIC + "${CMAKE_CURRENT_SOURCE_DIR}" +) + +if(WIN32) + # D3D11 path uses ID3D11Device / ID3D11Texture2D via d3d11.h. + target_link_libraries(score_nv_dvp_bridge PRIVATE d3d11) +else() + # Linux dlopen path needs libdl. + target_link_libraries(score_nv_dvp_bridge PRIVATE ${CMAKE_DL_LIBS}) +endif() + +target_compile_definitions(score_nv_dvp_bridge PUBLIC + SCORE_HAS_NV_DVP_BRIDGE=1 + # Bridge is built as a STATIC lib and linked into the consumer. The + # INLINE define makes NV_DVP_API empty (no dllexport/dllimport + # decoration) for both the bridge build and its consumers on Windows. + NV_DVP_BRIDGE_INLINE=1 +) + +set_property(TARGET score_nv_dvp_bridge PROPERTY POSITION_INDEPENDENT_CODE ON) + +# Optional dvp.dll fetch on Windows. Mirrors the previous AJA addon's +# SCORE_FETCH_DVP_DLL option so existing build invocations keep working. +if(WIN32 AND SCORE_FETCH_DVP_DLL) + set(_dvp_dll "${CMAKE_BINARY_DIR}/3rdparty/dvp/dvp.dll") + if(NOT EXISTS "${_dvp_dll}") + file(MAKE_DIRECTORY "${CMAKE_BINARY_DIR}/3rdparty/dvp") + message(STATUS "nv-dvp-bridge: fetching dvp.dll v1.70 from PlusToolkit/PlusLib") + file(DOWNLOAD + "https://github.com/PlusToolkit/PlusLib/raw/master/Tools/NVidia/dvp170/bin/x64/dvp.dll" + "${_dvp_dll}" + EXPECTED_HASH SHA256=602f2cefecb9b7c67d4ffebb3b15e11291bacf5a7342fcec6d52dd0118cbaddd + STATUS _dvp_dl_status + SHOW_PROGRESS) + list(GET _dvp_dl_status 0 _dvp_dl_rc) + if(NOT _dvp_dl_rc EQUAL 0) + message(WARNING "nv-dvp-bridge: dvp.dll download failed: ${_dvp_dl_status}") + endif() + endif() + if(EXISTS "${_dvp_dll}") + # Expose path so the consuming addon can copy_if_different next to its DLL. + set(SCORE_NV_DVP_DLL "${_dvp_dll}" CACHE INTERNAL "Fetched dvp.dll path") + endif() +elseif(WIN32 AND AJA_DVP_DLL AND EXISTS "${AJA_DVP_DLL}") + set(SCORE_NV_DVP_DLL "${AJA_DVP_DLL}" CACHE INTERNAL "User-supplied dvp.dll path") +endif() + +message(STATUS "score-plugin-gfx: nv-dvp-bridge enabled") diff --git a/src/plugins/score-plugin-gfx/3rdparty/nv-dvp-bridge/dvpapi_shim.cpp b/src/plugins/score-plugin-gfx/3rdparty/nv-dvp-bridge/dvpapi_shim.cpp new file mode 100755 index 0000000000..cd73a4362a --- /dev/null +++ b/src/plugins/score-plugin-gfx/3rdparty/nv-dvp-bridge/dvpapi_shim.cpp @@ -0,0 +1,394 @@ +/* + * NVIDIA "GPUDirect for Video" (DVP) runtime-loaded shim. + * See dvpapi_shim.h for design + license details. + * + * Adapted from Blender's intern/gpudirect/dvpapi.cpp (GPL-2.0+): + * Copyright (C) 2015 Blender Foundation. All rights reserved. + * + * Cross-platform — BOTH platforms ship libdvp as a C++ library (no + * `extern "C"`), so the shim resolves mangled symbol names on both: + * - Windows: MSVC C++ mangling. Names verified against `dvp.dll` v1.70 + * (SHA256 602f2cef…), available either from the PlusToolkit/PlusLib + * mirror or bundled with the Blackmagic DeckLink SDK at + * `decklink/Win/Samples/bin/dvp.dll`. Surface: D3D9 / D3D10 / D3D11, + * OpenGL, **CUDA** (dvpInitCUDAContext, dvpMemcpyCuda, …), plus + * sync primitives. We currently resolve only the GL + D3D11 subset. + * - Linux: GCC Itanium ABI mangling. Names verified against + * `libdvp.so.1` (same v1.70 ABI) shipped inside the DeckLink SDK at + * `decklink/Linux/Samples/NVIDIA_GPUDirect/x86_64/libdvp.so.1`. + * Surface: OpenGL + CUDA + `dvpCreateGPUBufferGL` (no D3D11, which + * doesn't exist on Linux). We currently resolve only the GL subset. + * + * The CUDA path (`dvpInit/Close/Bind/UnbindFromCUDACtx`, `dvpMemcpyCuda`, + * `dvpMapBuffer{Wait,End}CUDAStream`) is **cross-platform** on both + * Windows and Linux — score's existing GPU-direct pipeline uses + * `CudaP2PBridge` (which is independent of DVP), but the DVP CUDA + * surface is a viable second path. + * + * To dump symbols for cross-checking after an SDK ABI change: + * dumpbin -exports dvp.dll (Windows) + * nm -D --defined-only libdvp.so.1 | sort (Linux) + */ + +#include "dvpapi_shim.h" + +#if defined(_WIN32) +# define WIN32_LEAN_AND_MEAN +# include +#else +# include +#endif + +#include +#include +#include +#include + +/* ============================================================================ + * Global function pointer storage (definitions for the externs in the + * header). All start nulled; nv_dvp_load_runtime fills them. + * ============================================================================ */ + +extern "C" { + +PFN_dvpInitGLContext dvpInitGLContext = nullptr; +PFN_dvpCloseGLContext dvpCloseGLContext = nullptr; +PFN_dvpGetLibraryVersion dvpGetLibraryVersion = nullptr; +PFN_dvpBegin dvpBegin = nullptr; +PFN_dvpEnd dvpEnd = nullptr; +PFN_dvpCreateBuffer dvpCreateBuffer = nullptr; +PFN_dvpDestroyBuffer dvpDestroyBuffer = nullptr; +PFN_dvpFreeBuffer dvpFreeBuffer = nullptr; +PFN_dvpMemcpyLined dvpMemcpyLined = nullptr; +PFN_dvpMemcpy dvpMemcpy = nullptr; +PFN_dvpImportSyncObject dvpImportSyncObject = nullptr; +PFN_dvpFreeSyncObject dvpFreeSyncObject = nullptr; +PFN_dvpSyncObjClientWaitPartial dvpSyncObjClientWaitPartial = nullptr; +PFN_dvpMapBufferEndAPI dvpMapBufferEndAPI = nullptr; +PFN_dvpMapBufferWaitDVP dvpMapBufferWaitDVP = nullptr; +PFN_dvpMapBufferEndDVP dvpMapBufferEndDVP = nullptr; +PFN_dvpMapBufferWaitAPI dvpMapBufferWaitAPI = nullptr; +PFN_dvpBindToGLCtx dvpBindToGLCtx = nullptr; +PFN_dvpUnbindFromGLCtx dvpUnbindFromGLCtx = nullptr; +PFN_dvpCreateGPUTextureGL dvpCreateGPUTextureGL = nullptr; +PFN_dvpGetRequiredConstantsGLCtx dvpGetRequiredConstantsGLCtx = nullptr; + +PFN_dvpInitCUDAContext dvpInitCUDAContext = nullptr; +PFN_dvpCloseCUDAContext dvpCloseCUDAContext = nullptr; +PFN_dvpBindToCUDACtx dvpBindToCUDACtx = nullptr; +PFN_dvpUnbindFromCUDACtx dvpUnbindFromCUDACtx = nullptr; +PFN_dvpCreateGPUCUDAArray dvpCreateGPUCUDAArray = nullptr; +PFN_dvpCreateGPUCUDADevicePtr dvpCreateGPUCUDADevicePtr = nullptr; +PFN_dvpMapBufferWaitCUDAStream dvpMapBufferWaitCUDAStream = nullptr; +PFN_dvpMapBufferEndCUDAStream dvpMapBufferEndCUDAStream = nullptr; +PFN_dvpGetRequiredConstantsCUDACtx dvpGetRequiredConstantsCUDACtx = nullptr; + +PFN_dvpSyncObjClientWaitComplete dvpSyncObjClientWaitComplete = nullptr; +PFN_dvpSyncObjCompletion dvpSyncObjCompletion = nullptr; + +#if defined(_WIN32) +PFN_dvpInitD3D11Device dvpInitD3D11Device = nullptr; +PFN_dvpCloseD3D11Device dvpCloseD3D11Device = nullptr; +PFN_dvpCreateGPUD3D11Resource dvpCreateGPUD3D11Resource = nullptr; +PFN_dvpBindToD3D11Device dvpBindToD3D11Device = nullptr; +PFN_dvpUnbindFromD3D11Device dvpUnbindFromD3D11Device = nullptr; +PFN_dvpGetRequiredConstantsD3D11Device dvpGetRequiredConstantsD3D11Device = nullptr; +#endif + +} // extern "C" + +namespace +{ + +constexpr uint32_t kRequiredMajor = 1; +constexpr uint32_t kRequiredMinor = 63; + +#if defined(_WIN32) +HMODULE g_module = nullptr; +#else +void* g_module = nullptr; +#endif + +char g_error[512] = {}; +std::atomic g_glOk{false}; +std::atomic g_d3d11Ok{false}; +std::atomic g_cudaOk{false}; +std::once_flag g_onceFlag; + +// Lookup the symbol `name` in the loaded library; report a fix-it +// message on failure. Naming convention differs per platform — see +// the two-arg overload that picks the right name. +bool resolveSym(const char* name, void** outFn) +{ +#if defined(_WIN32) + FARPROC p = ::GetProcAddress(g_module, name); +#else + void* p = ::dlsym(g_module, name); +#endif + if(!p) + { + if(g_error[0] == '\0') + { +#if defined(_WIN32) + std::snprintf(g_error, sizeof(g_error), + "GetProcAddress failed: %s", name); +#else + const char* errMsg = ::dlerror(); + std::snprintf(g_error, sizeof(g_error), + "dlsym failed: %s (%s)", name, + errMsg ? errMsg : "no detail"); +#endif + } + return false; + } + *outFn = reinterpret_cast(p); + return true; +} + +// Resolve a symbol given its two mangled forms. Both Windows and Linux +// ship libdvp as a C++ library, so each platform has its own mangling +// scheme: +// - Windows: MSVC C++ mangling (`?dvpBegin@@YA?AW4DVPStatus@@XZ` style). +// - Linux: GCC Itanium ABI mangling (`_Z8dvpBeginv` style). +// Caller passes both; the active-platform name is used. +bool resolve(const char* mangledWin, const char* mangledLinux, void** outFn) +{ +#if defined(_WIN32) + (void)mangledLinux; + return resolveSym(mangledWin, outFn); +#else + (void)mangledWin; + return resolveSym(mangledLinux, outFn); +#endif +} + +void doLoad() +{ +#if defined(_WIN32) + g_module = ::LoadLibraryA("dvp.dll"); + if(!g_module) + { + std::snprintf(g_error, sizeof(g_error), + "LoadLibraryA(\"dvp.dll\") failed (Win32 error %lu)", + ::GetLastError()); + return; + } +#else + // Try the versioned soname first, then unversioned fallback. + g_module = ::dlopen("libdvp.so.1", RTLD_NOW); + if(!g_module) + g_module = ::dlopen("libdvp.so", RTLD_NOW); + if(!g_module) + { + const char* errMsg = ::dlerror(); + std::snprintf(g_error, sizeof(g_error), + "dlopen(\"libdvp.so[.1]\") failed: %s", + errMsg ? errMsg : "no detail"); + return; + } +#endif + + /* === Common (non-API-specific) DVP entry points. === + * Windows MSVC mangling (left arg) vs Linux Itanium mangling (right arg). + * Linux names verified against the libdvp.so.1 in DeckLink SDK 14.x's + * `NVIDIA_GPUDirect/x86_64/`. Re-derive after ABI changes with: + * nm -D --defined-only libdvp.so.1 | sort + */ + bool common = true; + common &= resolve("?dvpGetLibrayVersion@@YA?AW4DVPStatus@@PEAI0@Z", + "_Z19dvpGetLibrayVersionPjS_", + reinterpret_cast(&dvpGetLibraryVersion)); + common &= resolve("?dvpBegin@@YA?AW4DVPStatus@@XZ", + "_Z8dvpBeginv", + reinterpret_cast(&dvpBegin)); + common &= resolve("?dvpEnd@@YA?AW4DVPStatus@@XZ", + "_Z6dvpEndv", + reinterpret_cast(&dvpEnd)); + common &= resolve( + "?dvpCreateBuffer@@YA?AW4DVPStatus@@PEAUDVPSysmemBufferDescRec@@PEA_K@Z", + "_Z15dvpCreateBufferP22DVPSysmemBufferDescRecPm", + reinterpret_cast(&dvpCreateBuffer)); + common &= resolve("?dvpDestroyBuffer@@YA?AW4DVPStatus@@_K@Z", + "_Z16dvpDestroyBufferm", + reinterpret_cast(&dvpDestroyBuffer)); + common &= resolve("?dvpFreeBuffer@@YA?AW4DVPStatus@@_K@Z", + "_Z13dvpFreeBufferm", + reinterpret_cast(&dvpFreeBuffer)); + common &= resolve("?dvpMemcpyLined@@YA?AW4DVPStatus@@_K0I000III@Z", + "_Z14dvpMemcpyLinedmmjmmmjjj", + reinterpret_cast(&dvpMemcpyLined)); + /* dvpMemcpy is exported as dvpMemcpy2D in both dvp.dll and libdvp.so.1; + same signature so we re-target the symbol. Blender's shim does the + same on Windows. */ + common &= resolve("?dvpMemcpy2D@@YA?AW4DVPStatus@@_K0I000IIIII@Z", + "_Z11dvpMemcpy2Dmmjmmmjjjjj", + reinterpret_cast(&dvpMemcpy)); + common &= resolve( + "?dvpImportSyncObject@@YA?AW4DVPStatus@@PEAUDVPSyncObjectDescRec@@PEA_K@Z", + "_Z19dvpImportSyncObjectP20DVPSyncObjectDescRecPm", + reinterpret_cast(&dvpImportSyncObject)); + common &= resolve("?dvpFreeSyncObject@@YA?AW4DVPStatus@@_K@Z", + "_Z17dvpFreeSyncObjectm", + reinterpret_cast(&dvpFreeSyncObject)); + common &= resolve( + "?dvpSyncObjClientWaitPartial@@YA?AW4DVPStatus@@_KI0@Z", + "_Z27dvpSyncObjClientWaitPartialmjm", + reinterpret_cast(&dvpSyncObjClientWaitPartial)); + common &= resolve("?dvpMapBufferEndAPI@@YA?AW4DVPStatus@@_K@Z", + "_Z18dvpMapBufferEndAPIm", + reinterpret_cast(&dvpMapBufferEndAPI)); + common &= resolve("?dvpMapBufferWaitDVP@@YA?AW4DVPStatus@@_K@Z", + "_Z19dvpMapBufferWaitDVPm", + reinterpret_cast(&dvpMapBufferWaitDVP)); + common &= resolve("?dvpMapBufferEndDVP@@YA?AW4DVPStatus@@_K@Z", + "_Z18dvpMapBufferEndDVPm", + reinterpret_cast(&dvpMapBufferEndDVP)); + common &= resolve("?dvpMapBufferWaitAPI@@YA?AW4DVPStatus@@_K@Z", + "_Z19dvpMapBufferWaitAPIm", + reinterpret_cast(&dvpMapBufferWaitAPI)); + /* Additional sync primitives — present in v1.70 but not used by + * Blender's shim. dvpSyncObjClientWaitComplete is the blocking + * counterpart to *Partial; dvpSyncObjCompletion returns the current + * value. Optional; resolution failure isn't fatal. */ + (void)resolve("?dvpSyncObjClientWaitComplete@@YA?AW4DVPStatus@@_K0@Z", + "_Z28dvpSyncObjClientWaitCompletemm", + reinterpret_cast(&dvpSyncObjClientWaitComplete)); + (void)resolve("?dvpSyncObjCompletion@@YA?AW4DVPStatus@@_KPEA_K@Z", + "_Z20dvpSyncObjCompletionmPm", + reinterpret_cast(&dvpSyncObjCompletion)); + + /* Verify the library version - matches Blender's check. */ + if(common && dvpGetLibraryVersion) + { + uint32_t major = 0, minor = 0; + DVPStatus s = dvpGetLibraryVersion(&major, &minor); + if(s != DVP_STATUS_OK + || major != kRequiredMajor || minor < kRequiredMinor) + { + if(g_error[0] == '\0') + std::snprintf(g_error, sizeof(g_error), + "DVP runtime version mismatch: got %u.%u, need %u.%u+", + major, minor, kRequiredMajor, kRequiredMinor); + return; + } + } + + /* === OpenGL entry points === */ + bool gl = common; + gl &= resolve("?dvpInitGLContext@@YA?AW4DVPStatus@@I@Z", + "_Z16dvpInitGLContextj", + reinterpret_cast(&dvpInitGLContext)); + gl &= resolve("?dvpCloseGLContext@@YA?AW4DVPStatus@@XZ", + "_Z17dvpCloseGLContextv", + reinterpret_cast(&dvpCloseGLContext)); + gl &= resolve("?dvpBindToGLCtx@@YA?AW4DVPStatus@@_K@Z", + "_Z14dvpBindToGLCtxm", + reinterpret_cast(&dvpBindToGLCtx)); + gl &= resolve("?dvpUnbindFromGLCtx@@YA?AW4DVPStatus@@_K@Z", + "_Z18dvpUnbindFromGLCtxm", + reinterpret_cast(&dvpUnbindFromGLCtx)); + gl &= resolve("?dvpCreateGPUTextureGL@@YA?AW4DVPStatus@@IPEA_K@Z", + "_Z21dvpCreateGPUTextureGLjPm", + reinterpret_cast(&dvpCreateGPUTextureGL)); + gl &= resolve( + "?dvpGetRequiredConstantsGLCtx@@YA?AW4DVPStatus@@PEAI00000@Z", + "_Z28dvpGetRequiredConstantsGLCtxPjS_S_S_S_S_", + reinterpret_cast(&dvpGetRequiredConstantsGLCtx)); + + g_glOk.store(gl, std::memory_order_release); + + /* === CUDA entry points (cross-platform — v1.70 dvp.dll v1.70 and + * libdvp.so.1 v1.70 both export the full CUDA surface). === */ + bool cuda = common; + cuda &= resolve("?dvpInitCUDAContext@@YA?AW4DVPStatus@@I@Z", + "_Z18dvpInitCUDAContextj", + reinterpret_cast(&dvpInitCUDAContext)); + cuda &= resolve("?dvpCloseCUDAContext@@YA?AW4DVPStatus@@XZ", + "_Z19dvpCloseCUDAContextv", + reinterpret_cast(&dvpCloseCUDAContext)); + cuda &= resolve("?dvpBindToCUDACtx@@YA?AW4DVPStatus@@_K@Z", + "_Z16dvpBindToCUDACtxm", + reinterpret_cast(&dvpBindToCUDACtx)); + cuda &= resolve("?dvpUnbindFromCUDACtx@@YA?AW4DVPStatus@@_K@Z", + "_Z20dvpUnbindFromCUDACtxm", + reinterpret_cast(&dvpUnbindFromCUDACtx)); + cuda &= resolve( + "?dvpCreateGPUCUDAArray@@YA?AW4DVPStatus@@PEAUCUarray_st@@PEA_K@Z", + "_Z21dvpCreateGPUCUDAArrayP10CUarray_stPm", + reinterpret_cast(&dvpCreateGPUCUDAArray)); + cuda &= resolve( + "?dvpCreateGPUCUDADevicePtr@@YA?AW4DVPStatus@@_KPEA_K@Z", + "_Z25dvpCreateGPUCUDADevicePtryPm", + reinterpret_cast(&dvpCreateGPUCUDADevicePtr)); + cuda &= resolve( + "?dvpMapBufferWaitCUDAStream@@YA?AW4DVPStatus@@_KPEAUCUstream_st@@@Z", + "_Z26dvpMapBufferWaitCUDAStreammP11CUstream_st", + reinterpret_cast(&dvpMapBufferWaitCUDAStream)); + cuda &= resolve( + "?dvpMapBufferEndCUDAStream@@YA?AW4DVPStatus@@_KPEAUCUstream_st@@@Z", + "_Z25dvpMapBufferEndCUDAStreammP11CUstream_st", + reinterpret_cast(&dvpMapBufferEndCUDAStream)); + cuda &= resolve( + "?dvpGetRequiredConstantsCUDACtx@@YA?AW4DVPStatus@@PEAI00000@Z", + "_Z30dvpGetRequiredConstantsCUDACtxPjS_S_S_S_S_", + reinterpret_cast(&dvpGetRequiredConstantsCUDACtx)); + g_cudaOk.store(cuda, std::memory_order_release); + +#if defined(_WIN32) + /* === D3D11 entry points (Windows only — MSVC mangled names) === */ + bool d3d11 = common; + d3d11 &= resolveSym( + "?dvpInitD3D11Device@@YA?AW4DVPStatus@@PEAUID3D11Device@@I@Z", + reinterpret_cast(&dvpInitD3D11Device)); + d3d11 &= resolveSym( + "?dvpCloseD3D11Device@@YA?AW4DVPStatus@@PEAUID3D11Device@@@Z", + reinterpret_cast(&dvpCloseD3D11Device)); + d3d11 &= resolveSym( + "?dvpCreateGPUD3D11Resource@@YA?AW4DVPStatus@@PEAUID3D11Resource@@PEA_K@Z", + reinterpret_cast(&dvpCreateGPUD3D11Resource)); + d3d11 &= resolveSym( + "?dvpBindToD3D11Device@@YA?AW4DVPStatus@@_KPEAUID3D11Device@@@Z", + reinterpret_cast(&dvpBindToD3D11Device)); + d3d11 &= resolveSym( + "?dvpUnbindFromD3D11Device@@YA?AW4DVPStatus@@_KPEAUID3D11Device@@@Z", + reinterpret_cast(&dvpUnbindFromD3D11Device)); + d3d11 &= resolveSym( + "?dvpGetRequiredConstantsD3D11Device@@YA?AW4DVPStatus@@PEAI00000PEAUID3D11Device@@@Z", + reinterpret_cast(&dvpGetRequiredConstantsD3D11Device)); + g_d3d11Ok.store(d3d11, std::memory_order_release); +#endif +} + +} // namespace + +extern "C" { + +bool nv_dvp_load_runtime(void) +{ + std::call_once(g_onceFlag, doLoad); + return g_glOk.load(std::memory_order_acquire) + || g_d3d11Ok.load(std::memory_order_acquire) + || g_cudaOk.load(std::memory_order_acquire); +} + +bool nv_dvp_have_gl(void) +{ + return g_glOk.load(std::memory_order_acquire); +} + +bool nv_dvp_have_d3d11(void) +{ + return g_d3d11Ok.load(std::memory_order_acquire); +} + +bool nv_dvp_have_cuda(void) +{ + return g_cudaOk.load(std::memory_order_acquire); +} + +const char* nv_dvp_get_runtime_error(void) +{ + return g_error; +} + +} // extern "C" diff --git a/src/plugins/score-plugin-gfx/3rdparty/nv-dvp-bridge/dvpapi_shim.h b/src/plugins/score-plugin-gfx/3rdparty/nv-dvp-bridge/dvpapi_shim.h new file mode 100755 index 0000000000..5b2f5ca9c1 --- /dev/null +++ b/src/plugins/score-plugin-gfx/3rdparty/nv-dvp-bridge/dvpapi_shim.h @@ -0,0 +1,333 @@ +/* + * NVIDIA "GPUDirect for Video" (DVP) runtime-loaded shim. + * + * Adapted from Blender's intern/gpudirect/dvpapi.{h,cpp}: + * Copyright (C) 2015 Blender Foundation. All rights reserved. + * Licensed under the GNU GPL v2 or later. + * + * Modifications for ossia score: + * - Replaced BLI_dynlib with bare LoadLibraryA / GetProcAddress (Win32) + * and dlopen / dlsym (Linux). Zero Blender dependency. + * - Added D3D11 entry points (Windows only) using MSVC mangled names. + * - Linux symbol lookup uses GCC Itanium-ABI mangled names — + * `libdvp.so.1` is a C++ library too (no `extern "C"`), so + * `dlsym(handle, "dvpBegin")` returns NULL; we resolve + * `dlsym(handle, "_Z8dvpBeginv")` instead. Names verified against + * the libdvp.so.1 shipped with DeckLink SDK 14.x Linux samples + * (path: `decklink/Linux/Samples/NVIDIA_GPUDirect/x86_64/libdvp.so.1`). + * To re-derive after an ABI change: `nm -D --defined-only libdvp.so.1`. + * - The shim makes runtime-load failures explicit (reports which + * symbol failed to resolve) so future SDK updates can fix names + * individually. + * + * Runtime sources: + * - Windows: `dvp.dll` from NVIDIA's "GPUDirect for Video" SDK (free, + * NVIDIA developer login). The CMakeLists has an opt-in fetch + * (`SCORE_FETCH_DVP_DLL=ON`) that downloads v1.70 from + * PlusToolkit/PlusLib for development. + * - Linux: `libdvp.so.1` shipped inside the Blackmagic DeckLink SDK + * (`decklink/Linux/Samples/NVIDIA_GPUDirect/x86_64/libdvp.so.1`) or + * packaged with NVIDIA's video-codec SDK. The shipped binary uses + * Itanium C++ mangling — see the per-symbol comments below. + * + * Without the runtime on the system, `nv_dvp_load_runtime()` returns + * false and consumers (AJA DVP strategies, planned DeckLink DVP path) + * refuse to initialize. + */ + +#pragma once + +#include + +#ifdef __cplusplus +extern "C" { +#endif + +/* ============================================================================ + * NVIDIA DVP types (subset used by the AJA DVP path). + * Mirrors the layout in NVIDIA's DVPAPI.h v1.63. + * ============================================================================ */ + +typedef uint64_t DVPBufferHandle; +typedef uint64_t DVPSyncObjectHandle; + +typedef enum +{ + DVP_STATUS_OK = 0, + DVP_STATUS_INVALID_PARAMETER = 1, + DVP_STATUS_UNSUPPORTED = 2, + DVP_STATUS_END_ENUMERATION = 3, + DVP_STATUS_INVALID_DEVICE = 4, + DVP_STATUS_OUT_OF_MEMORY = 5, + DVP_STATUS_INVALID_OPERATION = 6, + DVP_STATUS_TIMEOUT = 7, + DVP_STATUS_INVALID_CONTEXT = 8, + DVP_STATUS_INVALID_RESOURCE_TYPE = 9, + DVP_STATUS_INVALID_FORMAT_OR_TYPE = 10, + DVP_STATUS_DEVICE_UNINITIALIZED = 11, + DVP_STATUS_UNSIGNALED = 12, + DVP_STATUS_SYNC_ERROR = 13, + DVP_STATUS_SYNC_STILL_BOUND = 14, + DVP_STATUS_ERROR = -1 +} DVPStatus; + +typedef enum +{ + DVP_BUFFER, + DVP_DEPTH_COMPONENT, + DVP_RGBA, + DVP_BGRA, + DVP_RED, + DVP_GREEN, + DVP_BLUE, + DVP_ALPHA, + DVP_RGB, + DVP_BGR, + DVP_LUMINANCE, + DVP_LUMINANCE_ALPHA, + DVP_CUDA_1_CHANNEL, + DVP_CUDA_2_CHANNELS, + DVP_CUDA_4_CHANNELS, + DVP_RGBA_INTEGER, + DVP_BGRA_INTEGER, + DVP_RED_INTEGER, + DVP_GREEN_INTEGER, + DVP_BLUE_INTEGER, + DVP_ALPHA_INTEGER, + DVP_RGB_INTEGER, + DVP_BGR_INTEGER, + DVP_LUMINANCE_INTEGER, + DVP_LUMINANCE_ALPHA_INTEGER +} DVPBufferFormats; + +typedef enum +{ + DVP_UNSIGNED_BYTE, + DVP_BYTE, + DVP_UNSIGNED_SHORT, + DVP_SHORT, + DVP_UNSIGNED_INT, + DVP_INT, + DVP_FLOAT, + DVP_HALF_FLOAT, + DVP_UNSIGNED_BYTE_3_3_2, + DVP_UNSIGNED_BYTE_2_3_3_REV, + DVP_UNSIGNED_SHORT_5_6_5, + DVP_UNSIGNED_SHORT_5_6_5_REV, + DVP_UNSIGNED_SHORT_4_4_4_4, + DVP_UNSIGNED_SHORT_4_4_4_4_REV, + DVP_UNSIGNED_SHORT_5_5_5_1, + DVP_UNSIGNED_SHORT_1_5_5_5_REV, + DVP_UNSIGNED_INT_8_8_8_8, + DVP_UNSIGNED_INT_8_8_8_8_REV, + DVP_UNSIGNED_INT_10_10_10_2, + DVP_UNSIGNED_INT_2_10_10_10_REV +} DVPBufferTypes; + +typedef struct DVPSysmemBufferDescRec +{ + uint32_t width; + uint32_t height; + uint32_t stride; + uint32_t size; + DVPBufferFormats format; + DVPBufferTypes type; + void* bufAddr; +} DVPSysmemBufferDesc; + +#define DVP_SYNC_OBJECT_FLAGS_USE_EVENTS 0x00000001 + +typedef struct DVPSyncObjectDescRec +{ + uint32_t* sem; + uint32_t flags; + DVPStatus (*externalClientWaitFunc)( + DVPSyncObjectHandle sync, uint32_t value, bool GEQ, uint64_t timeout); +} DVPSyncObjectDesc; + +#define DVP_TIMEOUT_IGNORED 0xFFFFFFFFFFFFFFFFull + +/* Forward declarations of D3D11 types — we don't include d3d11.h from a + * vendored shim. Translation units that actually call the D3D11 entry + * points include d3d11.h before this header (or after; either order + * works because the function pointer types take struct pointers and + * struct redeclarations are legal). Windows-only — the D3D11 entry + * points themselves are gated below. */ +#if defined(_WIN32) +struct ID3D11Device; +struct ID3D11Resource; +#endif + +/* Forward declarations of CUDA driver-API types. Identical to the + * declarations in Gfx/Graph/interop/CudaFunctions.hpp; C++ permits + * multiple typedef-name declarations naming the same type, so including + * both headers in the same translation unit is safe. */ +typedef struct CUstream_st* CUstream; +typedef struct CUarray_st* CUarray; +#if defined(_WIN64) || defined(__LP64__) +typedef unsigned long long CUdeviceptr; +#else +typedef unsigned int CUdeviceptr; +#endif + +/* ============================================================================ + * Function pointers (resolved at runtime by nv_dvp_load_runtime). + * + * Macros redirect the SDK-style names (dvpBegin etc.) to the underlying + * function pointers - this keeps consumer code looking like it links + * against the SDK while in fact dispatching through dlsym/GetProcAddress. + * ============================================================================ */ + +typedef DVPStatus (*PFN_dvpInitGLContext)(uint32_t flags); +typedef DVPStatus (*PFN_dvpCloseGLContext)(void); +typedef DVPStatus (*PFN_dvpGetLibraryVersion)(uint32_t* major, uint32_t* minor); +typedef DVPStatus (*PFN_dvpBegin)(void); +typedef DVPStatus (*PFN_dvpEnd)(void); +typedef DVPStatus (*PFN_dvpCreateBuffer)( + DVPSysmemBufferDesc* desc, DVPBufferHandle* hBuf); +typedef DVPStatus (*PFN_dvpDestroyBuffer)(DVPBufferHandle hBuf); +typedef DVPStatus (*PFN_dvpFreeBuffer)(DVPBufferHandle hBuf); +typedef DVPStatus (*PFN_dvpMemcpyLined)( + DVPBufferHandle src, DVPSyncObjectHandle srcSync, uint32_t srcAcq, + uint64_t timeout, DVPBufferHandle dst, DVPSyncObjectHandle dstSync, + uint32_t dstRel, uint32_t startLine, uint32_t numLines); +typedef DVPStatus (*PFN_dvpMemcpy)( + DVPBufferHandle src, DVPSyncObjectHandle srcSync, uint32_t srcAcq, + uint64_t timeout, DVPBufferHandle dst, DVPSyncObjectHandle dstSync, + uint32_t dstRel, uint32_t srcOffset, uint32_t dstOffset, uint32_t count); +typedef DVPStatus (*PFN_dvpImportSyncObject)( + DVPSyncObjectDesc* desc, DVPSyncObjectHandle* syncObject); +typedef DVPStatus (*PFN_dvpFreeSyncObject)(DVPSyncObjectHandle syncObject); +typedef DVPStatus (*PFN_dvpSyncObjClientWaitPartial)( + DVPSyncObjectHandle sync, uint32_t value, uint64_t timeout); +typedef DVPStatus (*PFN_dvpMapBufferEndAPI)(DVPBufferHandle hBuf); +typedef DVPStatus (*PFN_dvpMapBufferWaitDVP)(DVPBufferHandle hBuf); +typedef DVPStatus (*PFN_dvpMapBufferEndDVP)(DVPBufferHandle hBuf); +typedef DVPStatus (*PFN_dvpMapBufferWaitAPI)(DVPBufferHandle hBuf); +typedef DVPStatus (*PFN_dvpBindToGLCtx)(DVPBufferHandle hBuf); +typedef DVPStatus (*PFN_dvpUnbindFromGLCtx)(DVPBufferHandle hBuf); +typedef DVPStatus (*PFN_dvpCreateGPUTextureGL)( + uint32_t glTexId, DVPBufferHandle* hBuf); +typedef DVPStatus (*PFN_dvpGetRequiredConstantsGLCtx)( + uint32_t* bufferAddrAlignment, uint32_t* bufferGPUStrideAlignment, + uint32_t* semaphoreAddrAlignment, uint32_t* semaphoreAllocSize, + uint32_t* semaphorePayloadOffset, uint32_t* semaphorePayloadSize); + +/* CUDA — cross-platform (Windows v1.70 dvp.dll and Linux libdvp.so.1 + * both export the same surface). Used for sysmem↔CUDA-resource DMA + * with CUstream-side synchronisation. */ +typedef DVPStatus (*PFN_dvpInitCUDAContext)(uint32_t flags); +typedef DVPStatus (*PFN_dvpCloseCUDAContext)(void); +typedef DVPStatus (*PFN_dvpBindToCUDACtx)(DVPBufferHandle hBuf); +typedef DVPStatus (*PFN_dvpUnbindFromCUDACtx)(DVPBufferHandle hBuf); +typedef DVPStatus (*PFN_dvpCreateGPUCUDAArray)( + CUarray array, DVPBufferHandle* hBuf); +typedef DVPStatus (*PFN_dvpCreateGPUCUDADevicePtr)( + CUdeviceptr devPtr, DVPBufferHandle* hBuf); +typedef DVPStatus (*PFN_dvpMapBufferWaitCUDAStream)( + DVPBufferHandle hBuf, CUstream stream); +typedef DVPStatus (*PFN_dvpMapBufferEndCUDAStream)( + DVPBufferHandle hBuf, CUstream stream); +typedef DVPStatus (*PFN_dvpGetRequiredConstantsCUDACtx)( + uint32_t* bufferAddrAlignment, uint32_t* bufferGPUStrideAlignment, + uint32_t* semaphoreAddrAlignment, uint32_t* semaphoreAllocSize, + uint32_t* semaphorePayloadOffset, uint32_t* semaphorePayloadSize); + +/* Additional sync primitives (cross-platform, exported by v1.70): + * SyncObjClientWaitComplete blocks until the sync completes (vs the + * partial variant we already use); SyncObjCompletion returns the + * current completion value. */ +typedef DVPStatus (*PFN_dvpSyncObjClientWaitComplete)( + DVPSyncObjectHandle sync, uint64_t timeout); +typedef DVPStatus (*PFN_dvpSyncObjCompletion)( + DVPSyncObjectHandle sync, uint64_t* completionValue); + +/* D3D11 — Windows-only extension over Blender's GL-only shim. */ +#if defined(_WIN32) +typedef DVPStatus (*PFN_dvpInitD3D11Device)( + struct ID3D11Device* dev, uint32_t flags); +typedef DVPStatus (*PFN_dvpCloseD3D11Device)(struct ID3D11Device* dev); +typedef DVPStatus (*PFN_dvpCreateGPUD3D11Resource)( + struct ID3D11Resource* res, DVPBufferHandle* hBuf); +typedef DVPStatus (*PFN_dvpBindToD3D11Device)( + DVPBufferHandle hBuf, struct ID3D11Device* dev); +typedef DVPStatus (*PFN_dvpUnbindFromD3D11Device)( + DVPBufferHandle hBuf, struct ID3D11Device* dev); +typedef DVPStatus (*PFN_dvpGetRequiredConstantsD3D11Device)( + uint32_t* bufferAddrAlignment, uint32_t* bufferGPUStrideAlignment, + uint32_t* semaphoreAddrAlignment, uint32_t* semaphoreAllocSize, + uint32_t* semaphorePayloadOffset, uint32_t* semaphorePayloadSize, + struct ID3D11Device* dev); +#endif + +extern PFN_dvpInitGLContext dvpInitGLContext; +extern PFN_dvpCloseGLContext dvpCloseGLContext; +extern PFN_dvpGetLibraryVersion dvpGetLibraryVersion; +extern PFN_dvpBegin dvpBegin; +extern PFN_dvpEnd dvpEnd; +extern PFN_dvpCreateBuffer dvpCreateBuffer; +extern PFN_dvpDestroyBuffer dvpDestroyBuffer; +extern PFN_dvpFreeBuffer dvpFreeBuffer; +extern PFN_dvpMemcpyLined dvpMemcpyLined; +extern PFN_dvpMemcpy dvpMemcpy; +extern PFN_dvpImportSyncObject dvpImportSyncObject; +extern PFN_dvpFreeSyncObject dvpFreeSyncObject; +extern PFN_dvpSyncObjClientWaitPartial dvpSyncObjClientWaitPartial; +extern PFN_dvpMapBufferEndAPI dvpMapBufferEndAPI; +extern PFN_dvpMapBufferWaitDVP dvpMapBufferWaitDVP; +extern PFN_dvpMapBufferEndDVP dvpMapBufferEndDVP; +extern PFN_dvpMapBufferWaitAPI dvpMapBufferWaitAPI; +extern PFN_dvpBindToGLCtx dvpBindToGLCtx; +extern PFN_dvpUnbindFromGLCtx dvpUnbindFromGLCtx; +extern PFN_dvpCreateGPUTextureGL dvpCreateGPUTextureGL; +extern PFN_dvpGetRequiredConstantsGLCtx dvpGetRequiredConstantsGLCtx; + +extern PFN_dvpInitCUDAContext dvpInitCUDAContext; +extern PFN_dvpCloseCUDAContext dvpCloseCUDAContext; +extern PFN_dvpBindToCUDACtx dvpBindToCUDACtx; +extern PFN_dvpUnbindFromCUDACtx dvpUnbindFromCUDACtx; +extern PFN_dvpCreateGPUCUDAArray dvpCreateGPUCUDAArray; +extern PFN_dvpCreateGPUCUDADevicePtr dvpCreateGPUCUDADevicePtr; +extern PFN_dvpMapBufferWaitCUDAStream dvpMapBufferWaitCUDAStream; +extern PFN_dvpMapBufferEndCUDAStream dvpMapBufferEndCUDAStream; +extern PFN_dvpGetRequiredConstantsCUDACtx dvpGetRequiredConstantsCUDACtx; + +extern PFN_dvpSyncObjClientWaitComplete dvpSyncObjClientWaitComplete; +extern PFN_dvpSyncObjCompletion dvpSyncObjCompletion; + +#if defined(_WIN32) +extern PFN_dvpInitD3D11Device dvpInitD3D11Device; +extern PFN_dvpCloseD3D11Device dvpCloseD3D11Device; +extern PFN_dvpCreateGPUD3D11Resource dvpCreateGPUD3D11Resource; +extern PFN_dvpBindToD3D11Device dvpBindToD3D11Device; +extern PFN_dvpUnbindFromD3D11Device dvpUnbindFromD3D11Device; +extern PFN_dvpGetRequiredConstantsD3D11Device dvpGetRequiredConstantsD3D11Device; +#endif + +/* ============================================================================ + * Runtime loader + * ============================================================================ */ + +/** Try to load dvp.dll and resolve required GL + D3D11 entry points. + * Returns true on full success. On partial failure (e.g. dvp.dll loaded + * but a D3D11 symbol couldn't be resolved), the failed function pointer + * remains null and nv_dvp_get_runtime_error() reports which one. */ +bool nv_dvp_load_runtime(void); + +/** Are GL DVP entry points all resolved? Cheap; returns cached result. */ +bool nv_dvp_have_gl(void); + +/** Are D3D11 DVP entry points all resolved? Windows-only; always false + * on Linux. */ +bool nv_dvp_have_d3d11(void); + +/** Are CUDA DVP entry points all resolved? Cross-platform; both v1.70 + * dvp.dll and libdvp.so.1 export the CUDA suite. */ +bool nv_dvp_have_cuda(void); + +/** Last loader-time error message, NUL-terminated. Empty if no error. */ +const char* nv_dvp_get_runtime_error(void); + +#ifdef __cplusplus +} +#endif diff --git a/src/plugins/score-plugin-gfx/3rdparty/nv-dvp-bridge/nv_dvp_bridge.cpp b/src/plugins/score-plugin-gfx/3rdparty/nv-dvp-bridge/nv_dvp_bridge.cpp new file mode 100755 index 0000000000..244bc682bd --- /dev/null +++ b/src/plugins/score-plugin-gfx/3rdparty/nv-dvp-bridge/nv_dvp_bridge.cpp @@ -0,0 +1,989 @@ +/** + * @file nv_dvp_bridge.cpp + * @brief Implementation of nv_dvp_bridge.h. Wraps NVIDIA's "GPUDirect for + * Video" (DVP) SDK so score's AJA tier-3 path can DMA encoder output + * from GPU memory to AJA-DMA-locked sysmem on Windows. + * + * Reference implementation: AJA's `ntv2glTextureTransferNV.cpp` in + * `3rdparty/libajantv2/demos/NVIDIA/common/`. This bridge collapses the + * demo's chunked-async transfer into one synchronous call per frame; the + * AJA strategies in score-plugin-gfx provide pipelining via their slot + * ring + the AJAConsumerThread instead of via DVP-side chunking. + */ + +#include "nv_dvp_bridge.h" +#include "dvpapi_shim.h" + +#if defined(_WIN32) +#include +#include // _aligned_malloc / _aligned_free +#endif + +#include +#include +#include +#include +#include +#include + +namespace +{ + +/* DVP semaphores must be 32-bit ints aligned to alignment-bytes that + * `dvpGetRequiredConstants*` reports. `_semaphoreAllocSize` is the + * minimum byte count to allocate; we add `_semaphoreAddrAlignment` as + * slack and round up the resulting pointer. Same shape as AJA's demo. */ +struct SyncSlot +{ + volatile uint32_t* sem{}; + volatile uint32_t* semOrg{}; + uint32_t releaseValue{0}; + uint32_t acquireValue{0}; + DVPSyncObjectHandle obj{}; +}; + +struct ResourceState +{ + enum class Kind : uint8_t { Texture, Buffer }; + Kind kind{}; + DVPBufferHandle dvp{}; + + /* Geometry: needed for `dvpMemcpyLined` which takes an explicit line + * count. Both source and destination of a memcpy must agree on the + * line count, so the strategy registers the texture and buffer with + * matching (width, height) and the bridge passes height to the DMA. */ + uint32_t width{}; + uint32_t height{}; + + /* Buffer-only: one sync slot tracks DVP DMA completion (waited on by + * AJA before reading). For Texture, sync is implicit via API-side + * map/unmap calls; we do not allocate a slot. */ + SyncSlot bufSync; + + /* Strong references (texture only) so we can dvpUnbind on shutdown. */ +#if defined(_WIN32) + ID3D11Texture2D* d3d11Texture{}; +#endif +}; + +} // namespace + +struct NvDvpContext_t +{ + enum class Backend : uint8_t { D3D11, GL, CUDA }; + Backend backend{}; + + /* D3D11-only */ +#if defined(_WIN32) + ID3D11Device* d3d11Device{}; +#endif + + uint32_t bufferAddrAlignment{4096}; + uint32_t bufferGPUStrideAlignment{4096}; + uint32_t semaphoreAddrAlignment{16}; + uint32_t semaphoreAllocSize{16}; + + std::mutex mtx; + std::string lastError; + + /* Resource registry. Pointer-stable; handed back to the caller as + * an opaque NvDvpResourceHandle. */ + std::map> resources; + + bool initialized{false}; +}; + +namespace +{ + +/* ---------------------------------------------------------------------- + * Helpers + * ---------------------------------------------------------------------- */ + +inline ResourceState* asResource(NvDvpResourceHandle h) +{ + return reinterpret_cast(h); +} + +inline NvDvpResourceHandle asHandle(ResourceState* r) +{ + return reinterpret_cast(r); +} + +void initSyncSlot(NvDvpContext_t* ctx, SyncSlot& s) +{ + /* Aligned-up allocation for DVP semaphore memory. AJA's demo uses + * malloc + manual alignment, same here. */ + s.semOrg = static_cast( + std::calloc(1, ctx->semaphoreAllocSize + ctx->semaphoreAddrAlignment)); + uintptr_t v = reinterpret_cast(s.semOrg); + v += ctx->semaphoreAddrAlignment - 1; + v &= ~(uintptr_t(ctx->semaphoreAddrAlignment) - 1); + s.sem = reinterpret_cast(v); + *s.sem = 0; + + DVPSyncObjectDesc desc{}; + desc.externalClientWaitFunc = nullptr; + desc.flags = 0; + desc.sem = const_cast(s.sem); + dvpImportSyncObject(&desc, &s.obj); +} + +void freeSyncSlot(SyncSlot& s) +{ + if(s.obj) + dvpFreeSyncObject(s.obj); + s.obj = 0; + if(s.semOrg) + std::free(const_cast(s.semOrg)); + s.semOrg = nullptr; + s.sem = nullptr; +} + +uint32_t bytesPerPixel(NvDvpFormat fmt) +{ + switch(fmt) + { + case NV_DVP_FORMAT_RGBA8: + case NV_DVP_FORMAT_BGRA8: + return 4u; + } + return 4u; +} + +DVPBufferFormats toDvpFormat(NvDvpFormat f) +{ + switch(f) + { + case NV_DVP_FORMAT_RGBA8: + return DVP_RGBA; + case NV_DVP_FORMAT_BGRA8: + return DVP_BGRA; + } + return DVP_RGBA; +} + +/* Sticky last-init error. On init failure we destroy the context (the caller's + * out_ctx stays null), so the per-context lastError is lost and a subsequent + * nv_dvp_get_error_string(nullptr) would fall back to the generic "Invalid + * context" string. Stash the real failure (incl. DVP status) here so callers + * get the actual cause (e.g. "dvpInitGLContext failed (DVP status=-1)" — the + * runtime rejecting a non-Quadro GPU). */ +std::mutex& initErrorMutex() +{ + static std::mutex m; + return m; +} +std::string& lastInitErrorStorage() +{ + static std::string e; + return e; +} +void setInitError(const char* msg, DVPStatus status) +{ + std::lock_guard lk{initErrorMutex()}; + char buf[160]; + std::snprintf(buf, sizeof(buf), "%s (DVP status=%d)", msg, int(status)); + lastInitErrorStorage().assign(buf); +} + +/* Set ctx->lastError. Called under ctx->mtx. */ +void setError(NvDvpContext_t* ctx, const char* msg, DVPStatus status = DVP_STATUS_OK) +{ + if(!ctx) + return; + if(status != DVP_STATUS_OK) + { + char buf[160]; + std::snprintf(buf, sizeof(buf), "%s (DVP status=%d)", msg, int(status)); + ctx->lastError.assign(buf); + } + else + { + ctx->lastError.assign(msg); + } +} + +#define DVP_CHECK(call, ctx, retval) \ + do \ + { \ + DVPStatus _st = (call); \ + if(_st != DVP_STATUS_OK) \ + { \ + setError((ctx), #call, _st); \ + return (retval); \ + } \ + } while(0) + +} // namespace + +/* ============================================================================ + * Public API + * ============================================================================ */ + +extern "C" { + +NV_DVP_API bool nv_dvp_available(void) +{ + /* Try to load dvp.dll. If it's not present (no NVIDIA "GPUDirect for + * Video" SDK installed and dvp.dll not in PATH), this returns false + * and the AJA strategies refuse to use the DVP path. */ + return nv_dvp_load_runtime(); +} + +NV_DVP_API const char* nv_dvp_get_error_string(NvDvpContextHandle ctx) +{ + if(!ctx) + { + /* Loader-time error (dvp.dll missing, version mismatch, mangled-name + * mismatch on a required entry point) is sticky — surface it even if + * the caller never got a context. */ + const char* loadErr = nv_dvp_get_runtime_error(); + if(loadErr && loadErr[0]) + return loadErr; + /* Init-time error (e.g. dvpInit*Context rejecting a non-Quadro GPU) is + * also sticky: the context is destroyed on failure so it can't carry the + * message itself. */ + { + std::lock_guard lk{initErrorMutex()}; + if(!lastInitErrorStorage().empty()) + return lastInitErrorStorage().c_str(); + } + return "Invalid context"; + } + return ctx->lastError.c_str(); +} + +NV_DVP_API NvDvpError nv_dvp_init_d3d11( + NvDvpContextHandle* out_ctx, void* d3d11_device) +{ +#if !defined(_WIN32) + (void)d3d11_device; + if(out_ctx) + *out_ctx = nullptr; + return NV_DVP_ERROR_UNKNOWN; // D3D11 unavailable on non-Windows +#else + if(!out_ctx || !d3d11_device) + return NV_DVP_ERROR_INVALID_PARAMETER; + + *out_ctx = nullptr; + + if(!nv_dvp_load_runtime() || !nv_dvp_have_d3d11()) + return NV_DVP_ERROR_INIT_FAILED; + + auto ctx = std::make_unique(); + ctx->backend = NvDvpContext_t::Backend::D3D11; + ctx->d3d11Device = static_cast(d3d11_device); + + if(auto _st = dvpInitD3D11Device(ctx->d3d11Device, 0); _st != DVP_STATUS_OK) + { + fprintf( + stderr, "[nv-dvp] dvpInitD3D11Device failed: DVP_STATUS=%d\n", (int)_st); + setError(ctx.get(), "dvpInitD3D11Device failed", _st); + setInitError("dvpInitD3D11Device failed", _st); + return NV_DVP_ERROR_INIT_FAILED; + } + + uint32_t unused = 0; + if(dvpGetRequiredConstantsD3D11Device( + &ctx->bufferAddrAlignment, &ctx->bufferGPUStrideAlignment, + &ctx->semaphoreAddrAlignment, &ctx->semaphoreAllocSize, + &unused, &unused, ctx->d3d11Device) + != DVP_STATUS_OK) + { + dvpCloseD3D11Device(ctx->d3d11Device); + setError(ctx.get(), "dvpGetRequiredConstantsD3D11Device failed"); + return NV_DVP_ERROR_INIT_FAILED; + } + + ctx->initialized = true; + *out_ctx = ctx.release(); + return NV_DVP_SUCCESS; +#endif +} + +NV_DVP_API NvDvpError nv_dvp_init_gl(NvDvpContextHandle* out_ctx) +{ + if(!out_ctx) + return NV_DVP_ERROR_INVALID_PARAMETER; + + *out_ctx = nullptr; + + if(!nv_dvp_load_runtime() || !nv_dvp_have_gl()) + return NV_DVP_ERROR_INIT_FAILED; + + auto ctx = std::make_unique(); + ctx->backend = NvDvpContext_t::Backend::GL; + + if(auto _st = dvpInitGLContext(0); _st != DVP_STATUS_OK) + { + fprintf(stderr, "[nv-dvp] dvpInitGLContext failed: DVP_STATUS=%d\n", (int)_st); + setError(ctx.get(), "dvpInitGLContext failed", _st); + setInitError("dvpInitGLContext failed", _st); + return NV_DVP_ERROR_INIT_FAILED; + } + + uint32_t unused = 0; + if(dvpGetRequiredConstantsGLCtx( + &ctx->bufferAddrAlignment, &ctx->bufferGPUStrideAlignment, + &ctx->semaphoreAddrAlignment, &ctx->semaphoreAllocSize, + &unused, &unused) + != DVP_STATUS_OK) + { + dvpCloseGLContext(); + setError(ctx.get(), "dvpGetRequiredConstantsGLCtx failed"); + return NV_DVP_ERROR_INIT_FAILED; + } + + ctx->initialized = true; + *out_ctx = ctx.release(); + return NV_DVP_SUCCESS; +} + +NV_DVP_API NvDvpError nv_dvp_init_cuda(NvDvpContextHandle* out_ctx) +{ + if(!out_ctx) + return NV_DVP_ERROR_INVALID_PARAMETER; + + *out_ctx = nullptr; + + if(!nv_dvp_load_runtime() || !nv_dvp_have_cuda()) + return NV_DVP_ERROR_INIT_FAILED; + + auto ctx = std::make_unique(); + ctx->backend = NvDvpContext_t::Backend::CUDA; + + if(auto _st = dvpInitCUDAContext(0); _st != DVP_STATUS_OK) + { + fprintf( + stderr, "[nv-dvp] dvpInitCUDAContext failed: DVP_STATUS=%d\n", (int)_st); + setError(ctx.get(), "dvpInitCUDAContext failed", _st); + setInitError("dvpInitCUDAContext failed", _st); + return NV_DVP_ERROR_INIT_FAILED; + } + + uint32_t unused = 0; + if(dvpGetRequiredConstantsCUDACtx( + &ctx->bufferAddrAlignment, &ctx->bufferGPUStrideAlignment, + &ctx->semaphoreAddrAlignment, &ctx->semaphoreAllocSize, + &unused, &unused) + != DVP_STATUS_OK) + { + dvpCloseCUDAContext(); + setError(ctx.get(), "dvpGetRequiredConstantsCUDACtx failed"); + return NV_DVP_ERROR_INIT_FAILED; + } + + ctx->initialized = true; + *out_ctx = ctx.release(); + return NV_DVP_SUCCESS; +} + +NV_DVP_API void nv_dvp_shutdown(NvDvpContextHandle ctx) +{ + if(!ctx) + return; + std::lock_guard lock(ctx->mtx); + + /* Drop all registered resources. Textures returned from + * dvpCreateGPU*Resource were never explicitly bound (see comment in + * register_*_texture), so only sysmem buffers need the unbind. */ + for(auto& kv : ctx->resources) + { + auto* r = kv.second.get(); + if(r->kind == ResourceState::Kind::Buffer) + { + switch(ctx->backend) + { + case NvDvpContext_t::Backend::D3D11: +#if defined(_WIN32) + dvpUnbindFromD3D11Device(r->dvp, ctx->d3d11Device); +#endif + break; + case NvDvpContext_t::Backend::GL: + dvpUnbindFromGLCtx(r->dvp); + break; + case NvDvpContext_t::Backend::CUDA: + dvpUnbindFromCUDACtx(r->dvp); + break; + } + } + if(r->dvp) + dvpFreeBuffer(r->dvp); + freeSyncSlot(r->bufSync); + } + ctx->resources.clear(); + + if(ctx->initialized) + { + switch(ctx->backend) + { + case NvDvpContext_t::Backend::D3D11: +#if defined(_WIN32) + dvpCloseD3D11Device(ctx->d3d11Device); +#endif + break; + case NvDvpContext_t::Backend::GL: + dvpCloseGLContext(); + break; + case NvDvpContext_t::Backend::CUDA: + dvpCloseCUDAContext(); + break; + } + } + delete ctx; +} + +/* dvpBegin / dvpEnd is not held across the lifetime of the strategy: + * NVIDIA's docs require dvpMapBufferEndAPI / dvpMapBufferWaitAPI to be + * called *outside* a dvpBegin/dvpEnd pair, while WaitDVP/EndDVP/ + * Memcpy/ClientWait must be called *inside*. AJA's GL demo solves this + * by running the DVP DMA work on a separate thread that holds the + * begin/end pair; in our single-threaded model the bridge instead + * brackets each transfer call with its own dvpBegin/dvpEnd internally. + * + * These thread_begin / thread_end functions are kept for ABI + * compatibility but are no-ops. */ +NV_DVP_API NvDvpError nv_dvp_thread_begin(NvDvpContextHandle ctx) +{ + if(!ctx) + return NV_DVP_ERROR_INVALID_PARAMETER; + return NV_DVP_SUCCESS; +} + +NV_DVP_API NvDvpError nv_dvp_thread_end(NvDvpContextHandle ctx) +{ + if(!ctx) + return NV_DVP_ERROR_INVALID_PARAMETER; + return NV_DVP_SUCCESS; +} + +NV_DVP_API NvDvpError nv_dvp_register_d3d11_texture( + NvDvpContextHandle ctx, void* d3d11_texture, NvDvpFormat /*format*/, + uint32_t width, uint32_t height, NvDvpResourceHandle* out_handle) +{ +#if !defined(_WIN32) + (void)ctx; (void)d3d11_texture; (void)width; (void)height; + if(out_handle) + *out_handle = nullptr; + return NV_DVP_ERROR_UNKNOWN; +#else + if(!ctx || !d3d11_texture || !out_handle || width == 0 || height == 0 + || ctx->backend != NvDvpContext_t::Backend::D3D11) + return NV_DVP_ERROR_INVALID_PARAMETER; + + *out_handle = nullptr; + std::lock_guard lock(ctx->mtx); + + auto r = std::make_unique(); + r->kind = ResourceState::Kind::Texture; + r->width = width; + r->height = height; + r->d3d11Texture = static_cast(d3d11_texture); + + /* dvpCreateGPUD3D11Resource takes any ID3D11Resource*. ID3D11Texture2D + * derives from ID3D11Resource so the implicit upcast is fine. The + * resulting buffer handle is implicitly bound to the device that + * dvpInitD3D11Device was called against - we do NOT (and must not) + * also call dvpBindToD3D11Device on it. AJA's reference demo + * (ntv2glTextureTransferNV.cpp:RegisterTexture) only binds the + * sysmem buffers, never the GPU resources. */ + if(dvpCreateGPUD3D11Resource(r->d3d11Texture, &r->dvp) + != DVP_STATUS_OK) + { + setError(ctx, "dvpCreateGPUD3D11Resource failed"); + return NV_DVP_ERROR_INTEROP_FAILED; + } + + auto* raw = r.get(); + ctx->resources.emplace(raw, std::move(r)); + *out_handle = asHandle(raw); + return NV_DVP_SUCCESS; +#endif +} + +NV_DVP_API NvDvpError nv_dvp_register_gl_texture( + NvDvpContextHandle ctx, uint32_t gl_texture_id, NvDvpFormat /*format*/, + uint32_t width, uint32_t height, NvDvpResourceHandle* out_handle) +{ + if(!ctx || !out_handle || gl_texture_id == 0 || width == 0 || height == 0 + || ctx->backend != NvDvpContext_t::Backend::GL) + return NV_DVP_ERROR_INVALID_PARAMETER; + + *out_handle = nullptr; + std::lock_guard lock(ctx->mtx); + + auto r = std::make_unique(); + r->kind = ResourceState::Kind::Texture; + r->width = width; + r->height = height; + + if(dvpCreateGPUTextureGL(gl_texture_id, &r->dvp) != DVP_STATUS_OK) + { + setError(ctx, "dvpCreateGPUTextureGL failed"); + return NV_DVP_ERROR_INTEROP_FAILED; + } + /* GL textures are implicitly bound to the GL context dvpInitGLContext + * was called against; no separate dvpBindToGLCtx for GPU textures. */ + + auto* raw = r.get(); + ctx->resources.emplace(raw, std::move(r)); + *out_handle = asHandle(raw); + return NV_DVP_SUCCESS; +} + +NV_DVP_API NvDvpError nv_dvp_register_cuda_device_ptr( + NvDvpContextHandle ctx, uint64_t cuda_device_ptr, NvDvpFormat /*format*/, + uint32_t width, uint32_t height, NvDvpResourceHandle* out_handle) +{ + if(!ctx || !out_handle || cuda_device_ptr == 0 + || width == 0 || height == 0 + || ctx->backend != NvDvpContext_t::Backend::CUDA) + return NV_DVP_ERROR_INVALID_PARAMETER; + + *out_handle = nullptr; + std::lock_guard lock(ctx->mtx); + + auto r = std::make_unique(); + r->kind = ResourceState::Kind::Texture; + r->width = width; + r->height = height; + + /* CUDA flat device pointers are implicitly bound to the CUDA context + * dvpInitCUDAContext was called against - no dvpBindToCUDACtx for + * GPU resources, only sysmem. Same convention as GL/D3D11 GPU + * resources in DVP. */ + if(dvpCreateGPUCUDADevicePtr( + static_cast(cuda_device_ptr), &r->dvp) + != DVP_STATUS_OK) + { + setError(ctx, "dvpCreateGPUCUDADevicePtr failed"); + return NV_DVP_ERROR_INTEROP_FAILED; + } + + auto* raw = r.get(); + ctx->resources.emplace(raw, std::move(r)); + *out_handle = asHandle(raw); + return NV_DVP_SUCCESS; +} + +NV_DVP_API NvDvpError nv_dvp_register_cuda_array( + NvDvpContextHandle ctx, void* cuda_array, NvDvpFormat /*format*/, + uint32_t width, uint32_t height, NvDvpResourceHandle* out_handle) +{ + if(!ctx || !out_handle || !cuda_array || width == 0 || height == 0 + || ctx->backend != NvDvpContext_t::Backend::CUDA) + return NV_DVP_ERROR_INVALID_PARAMETER; + + *out_handle = nullptr; + std::lock_guard lock(ctx->mtx); + + auto r = std::make_unique(); + r->kind = ResourceState::Kind::Texture; + r->width = width; + r->height = height; + + if(dvpCreateGPUCUDAArray(static_cast(cuda_array), &r->dvp) + != DVP_STATUS_OK) + { + setError(ctx, "dvpCreateGPUCUDAArray failed"); + return NV_DVP_ERROR_INTEROP_FAILED; + } + + auto* raw = r.get(); + ctx->resources.emplace(raw, std::move(r)); + *out_handle = asHandle(raw); + return NV_DVP_SUCCESS; +} + +NV_DVP_API NvDvpError nv_dvp_register_sysmem_buffer( + NvDvpContextHandle ctx, void* sysmem_ptr, NvDvpFormat format, + uint32_t width, uint32_t height, uint32_t stride_bytes, + NvDvpResourceHandle* out_handle) +{ + if(!ctx || !sysmem_ptr || !out_handle || width == 0 || height == 0) + return NV_DVP_ERROR_INVALID_PARAMETER; + + /* Caller is responsible for sysmem_ptr being aligned to + * ctx->bufferAddrAlignment and stride to bufferGPUStrideAlignment. + * We don't enforce here because score's strategies use _aligned_malloc + * with alignment >= 4096 and align stride to the same, which is + * always >= what dvpGetRequiredConstants reports on current drivers. */ + + std::lock_guard lock(ctx->mtx); + + auto r = std::make_unique(); + r->kind = ResourceState::Kind::Buffer; + r->width = width; + r->height = height; + + DVPSysmemBufferDesc desc{}; + desc.width = width; + desc.height = height; + desc.stride = stride_bytes; + desc.size = uint32_t(stride_bytes) * height; + desc.format = toDvpFormat(format); + desc.type = DVP_UNSIGNED_BYTE; + desc.bufAddr = sysmem_ptr; + + (void)bytesPerPixel; /* size already supplied by caller via stride */ + + if(dvpCreateBuffer(&desc, &r->dvp) != DVP_STATUS_OK) + { + setError(ctx, "dvpCreateBuffer (sysmem) failed"); + return NV_DVP_ERROR_ALLOC_FAILED; + } + DVPStatus bindStatus = DVP_STATUS_OK; + const char* bindErr = nullptr; + switch(ctx->backend) + { + case NvDvpContext_t::Backend::D3D11: +#if defined(_WIN32) + bindStatus = dvpBindToD3D11Device(r->dvp, ctx->d3d11Device); + bindErr = "dvpBindToD3D11Device (sysmem) failed"; +#endif + break; + case NvDvpContext_t::Backend::GL: + bindStatus = dvpBindToGLCtx(r->dvp); + bindErr = "dvpBindToGLCtx (sysmem) failed"; + break; + case NvDvpContext_t::Backend::CUDA: + bindStatus = dvpBindToCUDACtx(r->dvp); + bindErr = "dvpBindToCUDACtx (sysmem) failed"; + break; + } + if(bindStatus != DVP_STATUS_OK) + { + dvpFreeBuffer(r->dvp); + setError(ctx, bindErr ? bindErr : "DVP sysmem bind failed", bindStatus); + return NV_DVP_ERROR_INTEROP_FAILED; + } + + initSyncSlot(ctx, r->bufSync); + + auto* raw = r.get(); + ctx->resources.emplace(raw, std::move(r)); + *out_handle = asHandle(raw); + return NV_DVP_SUCCESS; +} + +NV_DVP_API void nv_dvp_unregister( + NvDvpContextHandle ctx, NvDvpResourceHandle handle) +{ + if(!ctx || !handle) + return; + std::lock_guard lock(ctx->mtx); + + auto* r = asResource(handle); + auto it = ctx->resources.find(r); + if(it == ctx->resources.end()) + return; + + /* Textures (from dvpCreateGPU*Resource) are never explicitly bound, + * so they don't need unbinding. Only sysmem buffers do. */ + if(r->kind == ResourceState::Kind::Buffer) + { + switch(ctx->backend) + { + case NvDvpContext_t::Backend::D3D11: +#if defined(_WIN32) + dvpUnbindFromD3D11Device(r->dvp, ctx->d3d11Device); +#endif + break; + case NvDvpContext_t::Backend::GL: + dvpUnbindFromGLCtx(r->dvp); + break; + case NvDvpContext_t::Backend::CUDA: + dvpUnbindFromCUDACtx(r->dvp); + break; + } + } + + if(r->dvp) + dvpFreeBuffer(r->dvp); + freeSyncSlot(r->bufSync); + ctx->resources.erase(it); +} + +NV_DVP_API NvDvpError nv_dvp_acquire_texture( + NvDvpContextHandle ctx, NvDvpResourceHandle texture) +{ + if(!ctx || !texture) + return NV_DVP_ERROR_INVALID_PARAMETER; + auto* r = asResource(texture); + if(r->kind != ResourceState::Kind::Texture) + return NV_DVP_ERROR_INVALID_HANDLE; + + std::lock_guard lock(ctx->mtx); + /* dvpMapBufferWaitAPI inserts a wait on the API command queue (D3D11 + * deferred or GL command stream) until any pending DVP DMA completes. + * It does NOT block the CPU. */ + DVP_CHECK(dvpMapBufferWaitAPI(r->dvp), ctx, NV_DVP_ERROR_SYNC_FAILED); + return NV_DVP_SUCCESS; +} + +NV_DVP_API NvDvpError nv_dvp_release_texture( + NvDvpContextHandle ctx, NvDvpResourceHandle texture) +{ + if(!ctx || !texture) + return NV_DVP_ERROR_INVALID_PARAMETER; + auto* r = asResource(texture); + if(r->kind != ResourceState::Kind::Texture) + return NV_DVP_ERROR_INVALID_HANDLE; + + std::lock_guard lock(ctx->mtx); + /* dvpMapBufferEndAPI signals the API queue is done writing the + * texture; the next DVP DMA can proceed. */ + DVP_CHECK(dvpMapBufferEndAPI(r->dvp), ctx, NV_DVP_ERROR_SYNC_FAILED); + return NV_DVP_SUCCESS; +} + +NV_DVP_API NvDvpError nv_dvp_copy_texture_to_buffer( + NvDvpContextHandle ctx, NvDvpResourceHandle src_texture, + NvDvpResourceHandle dst_buffer) +{ + if(!ctx || !src_texture || !dst_buffer) + return NV_DVP_ERROR_INVALID_PARAMETER; + + auto* tex = asResource(src_texture); + auto* buf = asResource(dst_buffer); + if(tex->kind != ResourceState::Kind::Texture + || buf->kind != ResourceState::Kind::Buffer) + return NV_DVP_ERROR_INVALID_HANDLE; + + std::lock_guard lock(ctx->mtx); + + /* WaitDVP / Memcpy / EndDVP / ClientWaitPartial all must run inside a + * dvpBegin/dvpEnd pair (NVIDIA dvpapi.h docs). EndAPI / WaitAPI - + * called by nv_dvp_release_texture / nv_dvp_acquire_texture - must + * run *outside* such a pair, so we bracket only the DVP-side ops + * here, not the API-side ones. */ + DVP_CHECK(dvpBegin(), ctx, NV_DVP_ERROR_SYNC_FAILED); + + /* Lock the texture for DVP. The caller is expected to have called + * nv_dvp_release_texture (dvpMapBufferEndAPI) before this so the + * API queue has signalled it's done writing; if not, WaitDVP will + * stall waiting for that signal. */ + DVPStatus status = dvpMapBufferWaitDVP(tex->dvp); + if(status == DVP_STATUS_OK) + { + /* DMA into the sysmem buffer. The AJA strategy guarantees (via its + * producer-consumer pattern + AJAConsumerThread) that we only ever + * reuse a buffer AJA has finished reading; the ClientWaitPartial + * below blocks us until DMA is fully complete. */ + buf->bufSync.releaseValue++; + status = dvpMemcpyLined( + tex->dvp, /*srcSync=*/0, /*srcWaitValue=*/0, DVP_TIMEOUT_IGNORED, + buf->dvp, buf->bufSync.obj, buf->bufSync.releaseValue, + /*startingLine=*/0, /*numberOfLines=*/buf->height); + } + if(status == DVP_STATUS_OK) + status = dvpMapBufferEndDVP(tex->dvp); + if(status == DVP_STATUS_OK) + status = dvpSyncObjClientWaitPartial( + buf->bufSync.obj, buf->bufSync.releaseValue, DVP_TIMEOUT_IGNORED); + + /* Always close the dvpBegin scope, even on failure, otherwise + * subsequent acquire/release calls would see a dangling open scope. */ + DVPStatus endStatus = dvpEnd(); + if(status != DVP_STATUS_OK) + { + setError(ctx, "DVP texture->buffer transfer failed", status); + return NV_DVP_ERROR_TRANSFER_FAILED; + } + if(endStatus != DVP_STATUS_OK) + { + setError(ctx, "dvpEnd after texture->buffer", endStatus); + return NV_DVP_ERROR_SYNC_FAILED; + } + + return NV_DVP_SUCCESS; +} + +NV_DVP_API NvDvpError nv_dvp_copy_buffer_to_texture( + NvDvpContextHandle ctx, NvDvpResourceHandle src_buffer, + NvDvpResourceHandle dst_texture) +{ + if(!ctx || !src_buffer || !dst_texture) + return NV_DVP_ERROR_INVALID_PARAMETER; + + auto* buf = asResource(src_buffer); + auto* tex = asResource(dst_texture); + if(buf->kind != ResourceState::Kind::Buffer + || tex->kind != ResourceState::Kind::Texture) + return NV_DVP_ERROR_INVALID_HANDLE; + + std::lock_guard lock(ctx->mtx); + + DVP_CHECK(dvpBegin(), ctx, NV_DVP_ERROR_SYNC_FAILED); + + DVPStatus status = dvpMapBufferWaitDVP(tex->dvp); + if(status == DVP_STATUS_OK) + { + buf->bufSync.releaseValue++; + status = dvpMemcpyLined( + buf->dvp, /*srcSync=*/0, /*srcWaitValue=*/0, DVP_TIMEOUT_IGNORED, + tex->dvp, buf->bufSync.obj, buf->bufSync.releaseValue, + /*startingLine=*/0, /*numberOfLines=*/tex->height); + } + if(status == DVP_STATUS_OK) + status = dvpMapBufferEndDVP(tex->dvp); + if(status == DVP_STATUS_OK) + status = dvpSyncObjClientWaitPartial( + buf->bufSync.obj, buf->bufSync.releaseValue, DVP_TIMEOUT_IGNORED); + + DVPStatus endStatus = dvpEnd(); + if(status != DVP_STATUS_OK) + { + setError(ctx, "DVP buffer->texture transfer failed", status); + return NV_DVP_ERROR_TRANSFER_FAILED; + } + if(endStatus != DVP_STATUS_OK) + { + setError(ctx, "dvpEnd after buffer->texture", endStatus); + return NV_DVP_ERROR_SYNC_FAILED; + } + + return NV_DVP_SUCCESS; +} + +/* ============================================================================ + * Per-frame CUDA transfers + * + * The CUDA-stream sync pattern differs from the GL/D3D11 case: instead of + * the API-side dvpMapBufferEndAPI / dvpMapBufferWaitAPI calls (which are + * implicit at queue boundaries on GL/D3D11), the CUDA path uses + * dvpMapBufferWaitCUDAStream / dvpMapBufferEndCUDAStream which insert + * wait/signal operations into a user-supplied CUstream. These stream + * sync calls are themselves *inside* the dvpBegin/dvpEnd pair (per + * NVIDIA's dvpapi.h docs), unlike the API variants. + * ============================================================================ */ + +NV_DVP_API NvDvpError nv_dvp_copy_buffer_to_cuda( + NvDvpContextHandle ctx, NvDvpResourceHandle src_buffer, + NvDvpResourceHandle dst_cuda, void* cuda_stream) +{ + if(!ctx || !src_buffer || !dst_cuda + || ctx->backend != NvDvpContext_t::Backend::CUDA) + return NV_DVP_ERROR_INVALID_PARAMETER; + + auto* buf = asResource(src_buffer); + auto* dst = asResource(dst_cuda); + if(buf->kind != ResourceState::Kind::Buffer + || dst->kind != ResourceState::Kind::Texture) + return NV_DVP_ERROR_INVALID_HANDLE; + + std::lock_guard lock(ctx->mtx); + + DVP_CHECK(dvpBegin(), ctx, NV_DVP_ERROR_SYNC_FAILED); + + CUstream stream = static_cast(cuda_stream); + DVPStatus status = dvpMapBufferWaitCUDAStream(dst->dvp, stream); + if(status == DVP_STATUS_OK) + { + buf->bufSync.releaseValue++; + status = dvpMemcpyLined( + buf->dvp, /*srcSync=*/0, /*srcWaitValue=*/0, DVP_TIMEOUT_IGNORED, + dst->dvp, buf->bufSync.obj, buf->bufSync.releaseValue, + /*startingLine=*/0, /*numberOfLines=*/dst->height); + } + if(status == DVP_STATUS_OK) + status = dvpMapBufferEndCUDAStream(dst->dvp, stream); + if(status == DVP_STATUS_OK) + status = dvpSyncObjClientWaitPartial( + buf->bufSync.obj, buf->bufSync.releaseValue, DVP_TIMEOUT_IGNORED); + + DVPStatus endStatus = dvpEnd(); + if(status != DVP_STATUS_OK) + { + setError(ctx, "DVP buffer->CUDA transfer failed", status); + return NV_DVP_ERROR_TRANSFER_FAILED; + } + if(endStatus != DVP_STATUS_OK) + { + setError(ctx, "dvpEnd after buffer->CUDA", endStatus); + return NV_DVP_ERROR_SYNC_FAILED; + } + + return NV_DVP_SUCCESS; +} + +NV_DVP_API NvDvpError nv_dvp_copy_cuda_to_buffer( + NvDvpContextHandle ctx, NvDvpResourceHandle src_cuda, + NvDvpResourceHandle dst_buffer, void* cuda_stream) +{ + if(!ctx || !src_cuda || !dst_buffer + || ctx->backend != NvDvpContext_t::Backend::CUDA) + return NV_DVP_ERROR_INVALID_PARAMETER; + + auto* src = asResource(src_cuda); + auto* buf = asResource(dst_buffer); + if(src->kind != ResourceState::Kind::Texture + || buf->kind != ResourceState::Kind::Buffer) + return NV_DVP_ERROR_INVALID_HANDLE; + + std::lock_guard lock(ctx->mtx); + + DVP_CHECK(dvpBegin(), ctx, NV_DVP_ERROR_SYNC_FAILED); + + CUstream stream = static_cast(cuda_stream); + DVPStatus status = dvpMapBufferWaitCUDAStream(src->dvp, stream); + if(status == DVP_STATUS_OK) + { + buf->bufSync.releaseValue++; + status = dvpMemcpyLined( + src->dvp, /*srcSync=*/0, /*srcWaitValue=*/0, DVP_TIMEOUT_IGNORED, + buf->dvp, buf->bufSync.obj, buf->bufSync.releaseValue, + /*startingLine=*/0, /*numberOfLines=*/buf->height); + } + if(status == DVP_STATUS_OK) + status = dvpMapBufferEndCUDAStream(src->dvp, stream); + if(status == DVP_STATUS_OK) + status = dvpSyncObjClientWaitPartial( + buf->bufSync.obj, buf->bufSync.releaseValue, DVP_TIMEOUT_IGNORED); + + DVPStatus endStatus = dvpEnd(); + if(status != DVP_STATUS_OK) + { + setError(ctx, "DVP CUDA->buffer transfer failed", status); + return NV_DVP_ERROR_TRANSFER_FAILED; + } + if(endStatus != DVP_STATUS_OK) + { + setError(ctx, "dvpEnd after CUDA->buffer", endStatus); + return NV_DVP_ERROR_SYNC_FAILED; + } + + return NV_DVP_SUCCESS; +} + +/* ============================================================================ + * Cross-platform 4K-aligned allocation + * ============================================================================ */ + +NV_DVP_API void* nv_dvp_aligned_alloc(uint64_t bytes) +{ +#if defined(_WIN32) + return ::_aligned_malloc(static_cast(bytes), 4096); +#else + void* p = nullptr; + /* posix_memalign requires alignment to be a power of 2 and a multiple + * of sizeof(void*); 4096 satisfies both. */ + if(::posix_memalign(&p, 4096, static_cast(bytes)) != 0) + return nullptr; + return p; +#endif +} + +NV_DVP_API void nv_dvp_aligned_free(void* ptr) +{ + if(!ptr) + return; +#if defined(_WIN32) + ::_aligned_free(ptr); +#else + std::free(ptr); +#endif +} + +} // extern "C" diff --git a/src/plugins/score-plugin-gfx/3rdparty/nv-dvp-bridge/nv_dvp_bridge.h b/src/plugins/score-plugin-gfx/3rdparty/nv-dvp-bridge/nv_dvp_bridge.h new file mode 100755 index 0000000000..0c43fa63ff --- /dev/null +++ b/src/plugins/score-plugin-gfx/3rdparty/nv-dvp-bridge/nv_dvp_bridge.h @@ -0,0 +1,300 @@ +/** + * @file nv_dvp_bridge.h + * @brief C API for AJA <-> NVIDIA "GPUDirect for Video" (DVP) on Windows. + * + * Despite the name, NVIDIA's GPUDirect for Video does not implement true + * GPU<->card peer-to-peer DMA on Windows; it is a high-throughput, + * hardware-synchronised DMA path between a registered backend GPU + * resource (D3D11 texture / OpenGL texture) and a registered, page-locked + * system-memory buffer. AJA's `dvplowlatencydemo` uses this path. + * + * Per output frame on score's AJA tier-3 path: + * + * 1. QRhi encoder (V210 / UYVY / BGRA fragment encoder) writes the AJA + * pixel format into a QRhi RGBA8 texture whose dimensions match the + * encoded frame layout (e.g. 1280x1080 for 1920x1080 v210). + * 2. The bridge performs a `dvpMemcpyLined` from the texture into a + * page-locked, AJA-DMA-locked system-memory buffer. The DVP DMA + * engine handles ordering between QRhi's queue and the copy via + * DVP sync objects. + * 3. AJA's `AutoCirculateTransfer` ships the system buffer over PCIe + * to the SDI card. + * + * The bridge expects the NVIDIA GPUDirect for Video SDK headers + * (`DVPAPI.h`, `dvpapi_d3d11.h`, `dvpapi_gl.h`) to be available at build + * time. Without them, score-plugin-gfx skips this bridge and AJA falls + * back to encoder + CPU staging via QRhi readback. + * + * Thread model: every thread that calls a transfer function must bracket + * its work with `nv_dvp_thread_begin` / `nv_dvp_thread_end` once. The + * thread that calls `nv_dvp_init_*` does the API binding (e.g. + * `dvpInitD3D11Device`); the underlying GL or D3D11 device must be + * usable from the calling thread at init time. + */ + +#pragma once + +#ifdef __cplusplus +extern "C" { +#endif + +#include +#include + +/* Symbol visibility: the bridge's .cpp is compiled directly into the + * AJA addon (NV_DVP_BRIDGE_INLINE=1) so no DLL boundary is needed. + * The DLL build mode is preserved in case a future use needs it. + */ +#if defined(NV_DVP_BRIDGE_INLINE) + #define NV_DVP_API +#elif defined(_WIN32) + #ifdef NV_DVP_BRIDGE_EXPORTS + #define NV_DVP_API __declspec(dllexport) + #else + #define NV_DVP_API __declspec(dllimport) + #endif +#else + #define NV_DVP_API +#endif + +/* Opaque handle types */ +typedef struct NvDvpContext_t* NvDvpContextHandle; +typedef struct NvDvpResource_t* NvDvpResourceHandle; /* texture or buffer */ + +typedef enum NvDvpError { + NV_DVP_SUCCESS = 0, + NV_DVP_ERROR_NOT_INITIALIZED, + NV_DVP_ERROR_INIT_FAILED, + NV_DVP_ERROR_INVALID_PARAMETER, + NV_DVP_ERROR_INVALID_HANDLE, + NV_DVP_ERROR_INTEROP_FAILED, + NV_DVP_ERROR_TRANSFER_FAILED, + NV_DVP_ERROR_SYNC_FAILED, + NV_DVP_ERROR_ALLOC_FAILED, + NV_DVP_ERROR_UNKNOWN +} NvDvpError; + +/** Pixel format the DVP DMA understands. Only formats actually emitted + * by score's AJA encoders / used by AJA frame buffer formats are + * exposed. The bridge maps these to the matching DVP_* enum. */ +typedef enum NvDvpFormat { + NV_DVP_FORMAT_RGBA8 = 0, /**< DVP_RGBA + DVP_UNSIGNED_BYTE */ + NV_DVP_FORMAT_BGRA8 /**< DVP_BGRA + DVP_UNSIGNED_BYTE */ +} NvDvpFormat; + +/* ============================================================================ + * Context Management + * + * Each context binds DVP to a single backend (D3D11 device or GL context). + * To use both D3D11 and GL in the same process, create two contexts. + * ============================================================================ */ + +/** Bind DVP to an existing D3D11 device. The device must remain valid + * for the lifetime of the context. The bridge does not AddRef the + * device; the caller (QRhi in score's case) owns it. */ +NV_DVP_API NvDvpError nv_dvp_init_d3d11( + NvDvpContextHandle* out_ctx, + void* d3d11_device); /* ID3D11Device* */ + +/** Bind DVP to the current OpenGL context. The caller must have made + * the context current on the calling thread before calling this; DVP + * resolves required entry points from that context. */ +NV_DVP_API NvDvpError nv_dvp_init_gl(NvDvpContextHandle* out_ctx); + +/** Bind DVP to the current CUDA primary context. + * + * Cross-platform (Win + Linux). Caller must have a CUDA context + * active on the calling thread — `cuCtxSetCurrent(ctx)` from the + * shared `CudaFunctions` table is the typical setup. DVP's CUDA path + * is what enables sysmem↔CUDA-resource DMA with CUstream-side sync, + * useful for vendors without true GPU-direct P2P (e.g. DeckLink) and + * for cases where CUDA-stream integration is cleaner than a manual + * sync-object pair. */ +NV_DVP_API NvDvpError nv_dvp_init_cuda(NvDvpContextHandle* out_ctx); + +/** Tear down the DVP context. Must be called from the same thread that + * was last responsible for `nv_dvp_thread_begin` on this context if + * any thread is still inside `begin`/`end`. */ +NV_DVP_API void nv_dvp_shutdown(NvDvpContextHandle ctx); + +/** Whether DVP is available at runtime. Returns false on non-NVIDIA + * GPUs and when the DVP runtime DLL isn't present. Cheap to call. */ +NV_DVP_API bool nv_dvp_available(void); + +NV_DVP_API const char* nv_dvp_get_error_string(NvDvpContextHandle ctx); + +/* ============================================================================ + * Per-thread scope + * + * `dvpBegin` / `dvpEnd` is per-thread; AJA's demos call once at thread + * startup, not per frame. Score's AJA strategies call begin from + * AJANode's render thread (the one that does the offscreen frame + + * encoder dispatch) once and end on shutdown. + * ============================================================================ */ + +NV_DVP_API NvDvpError nv_dvp_thread_begin(NvDvpContextHandle ctx); +NV_DVP_API NvDvpError nv_dvp_thread_end(NvDvpContextHandle ctx); + +/* ============================================================================ + * Resource registration + * + * Textures and buffers are registered once and reused across many + * transfers. The returned handle owns DVP-side state including a sync + * object pair that tracks DMA ordering against API access. + * ============================================================================ */ + +/** Register a D3D11 texture (typically a colour-attachment texture + * produced by QRhi's encoder render target). The texture must be + * D3D11_USAGE_DEFAULT, ALLOW the bind flags QRhi sets for render + * targets, and have format + dimensions exactly matching the + * arguments. */ +NV_DVP_API NvDvpError nv_dvp_register_d3d11_texture( + NvDvpContextHandle ctx, + void* d3d11_texture, /* ID3D11Texture2D* */ + NvDvpFormat format, + uint32_t width, + uint32_t height, + NvDvpResourceHandle* out_handle); + +/** Register an OpenGL 2D texture by GL id. The OpenGL context bound + * at `nv_dvp_init_gl` time must be current when this is called. */ +NV_DVP_API NvDvpError nv_dvp_register_gl_texture( + NvDvpContextHandle ctx, + uint32_t gl_texture_id, + NvDvpFormat format, + uint32_t width, + uint32_t height, + NvDvpResourceHandle* out_handle); + +/** Register a CUDA-allocated GPU **flat device pointer** with DVP. + * Useful for true P2P GPU buffers (CUDA-imported VkBuffer, CUDA- + * allocated cudaMalloc region, etc) that need DVP-mediated transfers + * with stream-side sync. `cuda_device_ptr` is passed as `uint64_t` + * to avoid pulling `` into consumers. */ +NV_DVP_API NvDvpError nv_dvp_register_cuda_device_ptr( + NvDvpContextHandle ctx, + uint64_t cuda_device_ptr, + NvDvpFormat format, + uint32_t width, + uint32_t height, + NvDvpResourceHandle* out_handle); + +/** Register a CUDA array (typically the level-0 array of a CUDA-imported + * mipmapped array — see `cuda_p2p_import_vulkan_image`) with DVP. */ +NV_DVP_API NvDvpError nv_dvp_register_cuda_array( + NvDvpContextHandle ctx, + void* cuda_array, /* CUarray opaque pointer */ + NvDvpFormat format, + uint32_t width, + uint32_t height, + NvDvpResourceHandle* out_handle); + +/** Register a system-memory buffer. The caller owns the memory; the + * bridge stores the pointer and trusts it stays valid until + * unregister. Recommended allocation: `nv_dvp_aligned_alloc(size)` which + * returns 4K-aligned memory cross-platform (`_aligned_malloc` on + * Windows, `posix_memalign` on POSIX). Vendor-side pinning (e.g. + * AJA `DMABufferLock(inMap=true)`) is the caller's responsibility — + * the bridge does not pin. */ +NV_DVP_API NvDvpError nv_dvp_register_sysmem_buffer( + NvDvpContextHandle ctx, + void* sysmem_ptr, + NvDvpFormat format, + uint32_t width, + uint32_t height, + uint32_t stride_bytes, + NvDvpResourceHandle* out_handle); + +NV_DVP_API void nv_dvp_unregister( + NvDvpContextHandle ctx, NvDvpResourceHandle handle); + +/* ============================================================================ + * Cross-platform 4K-aligned allocation helper + * + * DVP wants `bufferAddrAlignment` (typically 4K). NVIDIA's documentation + * recommends page-aligned allocation regardless of the reported value. + * Windows uses `_aligned_malloc` / `_aligned_free`; POSIX uses + * `posix_memalign` paired with plain `free`. This helper hides the split. + * + * NULL is returned on allocation failure. + * ============================================================================ */ + +NV_DVP_API void* nv_dvp_aligned_alloc(uint64_t bytes); +NV_DVP_API void nv_dvp_aligned_free(void* ptr); + +/* ============================================================================ + * API-side sync: bracket GL/D3D access to a registered texture + * + * Between `nv_dvp_acquire_texture` and `nv_dvp_release_texture` the + * texture is owned by the API (QRhi) for rendering. Outside that range + * the bridge owns it for DVP DMA. Calling `copy_texture_to_buffer` + * implicitly releases the texture for DVP, then re-acquires it after + * the DMA completes; explicit acquire/release is only needed when QRhi + * wants to render to the texture between transfers without a transfer + * call in between (typical for the input pipeline overwriting the + * texture each frame). + * ============================================================================ */ + +NV_DVP_API NvDvpError nv_dvp_acquire_texture( + NvDvpContextHandle ctx, NvDvpResourceHandle texture); + +NV_DVP_API NvDvpError nv_dvp_release_texture( + NvDvpContextHandle ctx, NvDvpResourceHandle texture); + +/* ============================================================================ + * Per-frame transfers + * + * These are *synchronous* from the caller's perspective: the function + * returns when the DMA is complete and the destination is consistent. + * Synchronous transfer simplifies the AJA strategy at the cost of one + * frame of pipelining headroom; AJA's own dvplowlatencydemo uses + * asynchronous transfers + a multi-frame circular buffer for that + * reason. Score's AJANode already runs the AJA writes on a separate + * `AJAConsumerThread`, so the synchronous bridge call only stalls the + * render thread for the duration of the DMA (~ms on a typical PCIe). + * ============================================================================ */ + +/** OUTPUT path: copy from a registered texture to a registered sysmem + * buffer. Blocks until the destination is consistent. */ +NV_DVP_API NvDvpError nv_dvp_copy_texture_to_buffer( + NvDvpContextHandle ctx, + NvDvpResourceHandle src_texture, + NvDvpResourceHandle dst_buffer); + +/** INPUT path (capture): copy from a registered sysmem buffer to a + * registered texture. Blocks until the destination is consistent. */ +NV_DVP_API NvDvpError nv_dvp_copy_buffer_to_texture( + NvDvpContextHandle ctx, + NvDvpResourceHandle src_buffer, + NvDvpResourceHandle dst_texture); + +/* ============================================================================ + * Per-frame CUDA transfers + * + * Same shape as the texture variants but using CUstream-side sync. The + * caller provides a `CUstream` (or NULL for the default stream) and + * DVP's `dvpMapBufferWaitCUDAStream` / `dvpMapBufferEndCUDAStream` + * insert wait/signal operations into that stream. This is the path + * vendors without GPU-direct P2P (DeckLink, possibly some Magewell + * configurations) use to push captured frames into a CUDA-mapped + * texture without a CPU memcpy. + * + * `cuda_stream` is passed as `void*` (CUstream is opaque) — pass NULL + * for the default stream. + * ============================================================================ */ + +NV_DVP_API NvDvpError nv_dvp_copy_buffer_to_cuda( + NvDvpContextHandle ctx, + NvDvpResourceHandle src_buffer, + NvDvpResourceHandle dst_cuda, + void* cuda_stream); + +NV_DVP_API NvDvpError nv_dvp_copy_cuda_to_buffer( + NvDvpContextHandle ctx, + NvDvpResourceHandle src_cuda, + NvDvpResourceHandle dst_buffer, + void* cuda_stream); + +#ifdef __cplusplus +} +#endif diff --git a/src/plugins/score-plugin-gfx/CMakeLists.txt b/src/plugins/score-plugin-gfx/CMakeLists.txt index 175758b842..87c30cb602 100644 --- a/src/plugins/score-plugin-gfx/CMakeLists.txt +++ b/src/plugins/score-plugin-gfx/CMakeLists.txt @@ -15,6 +15,8 @@ score_common_setup() find_package(${QT_VERSION} REQUIRED Gui) +add_subdirectory(3rdparty/nv-dvp-bridge) + if(NOT TARGET avformat AND NOT EMSCRIPTEN) find_package(FFmpeg COMPONENTS AVCODEC AVFORMAT AVUTIL AVDEVICE) endif() @@ -53,9 +55,11 @@ set(SYPHON_SRCS set(VTB_SRCS Gfx/Graph/decoders/HWVideoToolbox_metal.hpp Gfx/Graph/decoders/HWVideoToolbox_metal.mm + Gfx/Graph/interop/TextureShareMetal.mm ) set_source_files_properties( Gfx/Graph/decoders/HWVideoToolbox_metal.mm + Gfx/Graph/interop/TextureShareMetal.mm PROPERTIES SKIP_UNITY_BUILD_INCLUSION ON ) @@ -136,6 +140,8 @@ set(HDRS Gfx/Filter/Library.hpp Gfx/Filter/PreviewWidget.hpp + Gfx/Widgets/RhiPreviewWidget.hpp + Gfx/GeometryFilter/Executor.hpp Gfx/GeometryFilter/Metadata.hpp Gfx/GeometryFilter/Process.hpp @@ -173,11 +179,12 @@ set(HDRS Gfx/Graph/BackgroundNode.hpp Gfx/Graph/CommonUBOs.hpp + Gfx/Graph/PhongNode.hpp Gfx/Graph/CustomMesh.hpp - Gfx/Graph/DepthNode.hpp Gfx/Graph/GeometryFilterNode.hpp Gfx/Graph/GeometryFilterNodeRenderer.hpp Gfx/Graph/RhiComputeBarrier.hpp + Gfx/Graph/RhiClearBuffer.hpp Gfx/Graph/GPUBufferScatter.hpp Gfx/Graph/RenderedCSFNode.hpp Gfx/Graph/Graph.hpp @@ -188,8 +195,21 @@ set(HDRS Gfx/Graph/Node.hpp Gfx/Graph/NodeRenderer.hpp Gfx/Graph/OutputNode.hpp - Gfx/Graph/PhongNode.hpp + Gfx/Graph/DMACaptureInputNode.hpp + Gfx/Graph/DirectVideoOutputBackend.hpp + Gfx/Graph/DirectVideoOutputNode.hpp Gfx/Graph/PreviewNode.hpp + Gfx/Graph/SceneGPUState.hpp + Gfx/Graph/GpuResourceRegistry.hpp + Gfx/Graph/VertexFallbackDefaults.hpp + Gfx/Graph/VertexFallbackPlan.hpp + Gfx/Graph/VertexFallbackPool.hpp + Gfx/Graph/GpuTiming.hpp + Gfx/Graph/ScenePreprocessorNode.hpp + Gfx/Graph/CameraMath.hpp + Gfx/Graph/SceneFilterNode.hpp + Gfx/Graph/FlattenedSceneFilterNode.hpp + Gfx/Graph/MergeGeometriesNode.hpp Gfx/Graph/RenderList.hpp Gfx/Graph/RenderState.hpp Gfx/Graph/RenderedISFNode.hpp @@ -203,6 +223,7 @@ set(HDRS Gfx/Graph/SimpleRenderedISFNode.hpp Gfx/Graph/TexgenNode.hpp Gfx/Graph/TextNode.hpp + Gfx/Graph/TextureLoader.hpp Gfx/Graph/Uniforms.hpp Gfx/Graph/Utils.hpp Gfx/Graph/VideoNode.hpp @@ -212,7 +233,9 @@ set(HDRS Gfx/Graph/Window.hpp Gfx/Graph/decoders/DXV.hpp - Gfx/Graph/decoders/DMABufImport.hpp + Gfx/Graph/interop/DMABufImport.hpp + Gfx/Graph/interop/EglDmaBufImport.hpp + Gfx/Graph/interop/EglDmaBufExport.hpp Gfx/Graph/encoders/ColorSpaceOut.hpp Gfx/Graph/encoders/GPUVideoEncoder.hpp Gfx/Graph/encoders/UYVY.hpp @@ -220,14 +243,70 @@ set(HDRS Gfx/Graph/encoders/I420.hpp Gfx/Graph/encoders/BGRA.hpp Gfx/Graph/encoders/V210.hpp + Gfx/Graph/encoders/YUV422P10.hpp + Gfx/Graph/encoders/P010.hpp Gfx/Graph/encoders/V210Compute.hpp Gfx/Graph/encoders/UYVYCompute.hpp Gfx/Graph/encoders/BGRACompute.hpp Gfx/Graph/encoders/ComputeEncoder.hpp + Gfx/Graph/encoders/PackedRGB.hpp + Gfx/Graph/encoders/YUY2.hpp + Gfx/Graph/encoders/YUVPlanar.hpp + Gfx/Graph/encoders/WireEncoderFactory.hpp + + Gfx/Graph/interop/CudaFunctions.hpp + Gfx/Graph/interop/CudaP2PBridge.h + Gfx/Graph/interop/CudaP2PBridge.cpp + Gfx/Graph/interop/VkExternalMemoryHelpers.hpp + Gfx/Graph/interop/VkExternalMemoryHelpers.cpp + Gfx/Graph/interop/TextureShare.hpp + Gfx/Graph/interop/TextureShare.cpp + Gfx/Graph/interop/AVHWFrameToQRhi.hpp + Gfx/Graph/interop/GpuRingBuffer.hpp + Gfx/Graph/interop/GpuRingBuffer.cpp + Gfx/Graph/interop/InteropFence.hpp + Gfx/Graph/interop/InteropFence.cpp + Gfx/Graph/interop/ComputeRingDispatcher.hpp + Gfx/Graph/interop/GpuDirectStrategy.hpp + Gfx/Graph/interop/GpuDirectCaptureStrategy.hpp + Gfx/Graph/interop/CaptureStrategyCommon.hpp + # Shared, vendor-neutral NVIDIA-DVP shim templates (parameterized by a + # per-vendor DMA-lock policy). Header-only; consumed by capture-card addons. + Gfx/Graph/interop/DmaLockPolicy.hpp + Gfx/Graph/interop/DvpCaptureGl.hpp + Gfx/Graph/interop/DvpCaptureD3D11.hpp + Gfx/Graph/interop/DvpOutputGl.hpp + Gfx/Graph/interop/DvpOutputD3D11.hpp + Gfx/Graph/interop/GLCaptureUpload.hpp + Gfx/Graph/interop/GpuDirectOutput.hpp + Gfx/Graph/interop/GpuDirectOutput.cpp + Gfx/Graph/interop/HostStagedOutput.hpp + Gfx/Graph/interop/HostStagedOutput.cpp + Gfx/Graph/interop/VendorDmaRegistrar.hpp + Gfx/Graph/interop/PacedFramePump.hpp + Gfx/Graph/interop/PacedFramePump.cpp + Gfx/Graph/interop/GpuDirectStrategySelect.hpp + Gfx/Graph/interop/VideoPixelFormatAV.hpp + Gfx/Graph/interop/VideoPixelFormatAV.cpp + Gfx/Graph/interop/GpuCapabilities.hpp + Gfx/Graph/interop/GpuCapabilities.cpp + Gfx/Graph/interop/CudaVmmAllocator.hpp + Gfx/Graph/interop/CudaVmmAllocator.cpp + Gfx/Graph/interop/RdmaGpuBuffer.hpp + Gfx/Graph/interop/RdmaGpuBuffer.cpp + Gfx/Graph/interop/AmdPinnedBuffers.hpp + Gfx/Graph/interop/AmdPinnedBuffers.cpp + Gfx/Graph/interop/HostPinnedRing.hpp + Gfx/Graph/interop/HostPinnedRing.cpp + Gfx/Graph/interop/VkCudaSemaphore.hpp + Gfx/Graph/interop/VkCudaSemaphore.cpp + Gfx/Graph/interop/VideoPixelFormat.hpp + Gfx/Graph/interop/VideoPixelFormat.cpp Gfx/Graph/decoders/GPUVideoDecoder.hpp Gfx/Graph/decoders/GPUVideoDecoderFactory.hpp Gfx/Graph/decoders/HAP.hpp + Gfx/Graph/decoders/WireDecoderFactory.hpp Gfx/Graph/decoders/HWTransfer.hpp Gfx/Graph/decoders/HWVAAPI.hpp Gfx/Graph/decoders/HWCUDA.hpp @@ -268,10 +347,14 @@ set(HDRS Gfx/Settings/View.hpp Gfx/Settings/Factory.hpp + Gfx/AssetTable.hpp + Gfx/FormatRegistry.hpp + Gfx/Hashes.hpp Gfx/Window/BackgroundDevice.hpp Gfx/Window/CollapsibleSection.hpp Gfx/Window/DesktopLayout.hpp Gfx/Window/MultiWindowDevice.hpp + Gfx/Window/OffscreenDevice.hpp Gfx/Window/OutputMapping.hpp Gfx/Window/OutputPreview.hpp Gfx/Window/TestCard.hpp @@ -321,6 +404,8 @@ set(SRCS Gfx/Filter/Process.cpp Gfx/Filter/PreviewWidget.cpp + Gfx/Widgets/RhiPreviewWidget.cpp + Gfx/GeometryFilter/Executor.cpp Gfx/GeometryFilter/Process.cpp Gfx/GeometryFilter/Library.cpp @@ -353,9 +438,11 @@ set(SRCS Gfx/Graph/decoders/HAP.cpp Gfx/Graph/BackgroundNode.cpp Gfx/Graph/CustomMesh.cpp + Gfx/Graph/PhongNode.cpp Gfx/Graph/GeometryFilterNode.cpp Gfx/Graph/GeometryFilterNodeRenderer.cpp Gfx/Graph/RhiComputeBarrier.cpp + Gfx/Graph/RhiClearBuffer.cpp Gfx/Graph/GPUBufferScatter.cpp Gfx/Graph/RenderedCSFNode.cpp Gfx/Graph/Graph.cpp @@ -366,16 +453,33 @@ set(SRCS Gfx/Graph/Node.cpp Gfx/Graph/NodeRenderer.cpp Gfx/Graph/OutputNode.cpp - Gfx/Graph/PhongNode.cpp + Gfx/Graph/DMACaptureInputNode.cpp + Gfx/Graph/DirectVideoOutputBackend.cpp + Gfx/Graph/DirectVideoOutputNode.cpp Gfx/Graph/PreviewNode.cpp + Gfx/Graph/SceneGPUState.cpp + Gfx/Graph/GpuResourceRegistry.cpp + Gfx/Graph/VertexFallbackDefaults.cpp + Gfx/Graph/VertexFallbackPool.cpp + Gfx/Graph/GpuTiming.cpp + Gfx/Graph/ScenePreprocessorNode.cpp + Gfx/Graph/CameraMath.cpp + Gfx/Graph/SceneFilterNode.cpp + Gfx/Graph/FlattenedSceneFilterNode.cpp + Gfx/Graph/MergeGeometriesNode.cpp Gfx/Graph/RenderList.cpp Gfx/Graph/RenderedISFNode.cpp Gfx/Graph/RenderedRawRasterPipelineNode.cpp Gfx/Graph/RenderedVSANode.cpp + Gfx/Graph/PipelineStateHelpers.hpp + Gfx/Graph/PipelineStateHelpers.cpp + Gfx/Graph/IsfBindingsBuilder.hpp + Gfx/Graph/IsfBindingsBuilder.cpp Gfx/Graph/ScreenNode.cpp Gfx/Graph/ShaderCache.cpp Gfx/Graph/SimpleRenderedISFNode.cpp Gfx/Graph/TextNode.cpp + Gfx/Graph/TextureLoader.cpp Gfx/Graph/Utils.cpp Gfx/Graph/VideoNode.cpp Gfx/Graph/VideoNodeRenderer.cpp @@ -383,6 +487,8 @@ set(SRCS Gfx/Graph/DirectVideoNodeRenderer.cpp Gfx/Graph/Window.cpp + Gfx/AssetTable.cpp + Gfx/FormatRegistry.cpp Gfx/GfxApplicationPlugin.cpp Gfx/GfxExecNode.cpp Gfx/GfxExecutionAction.cpp @@ -429,13 +535,17 @@ set_source_files_properties( "${3RDPARTY_FOLDER}/glsl-parser/glsl.parser.c" "${3RDPARTY_FOLDER}/glsl-parser/glsl.lexer.c" "${3RDPARTY_FOLDER}/dxv/dxv.c" + "${3RDPARTY_FOLDER}/OffsetAllocator/offsetAllocator.cpp" PROPERTIES SKIP_PRECOMPILE_HEADERS ON SKIP_UNITY_BUILD_INCLUSION ON ) # Creation of the library -add_library(${PROJECT_NAME} ${SRCS} ${HDRS}) +add_library(${PROJECT_NAME} ${SRCS} ${HDRS} + "${3RDPARTY_FOLDER}/OffsetAllocator/offsetAllocator.cpp" + "${3RDPARTY_FOLDER}/OffsetAllocator/offsetAllocator.hpp" +) # Code generation score_generate_command_list_file(${PROJECT_NAME} "${HDRS}") @@ -443,6 +553,7 @@ score_generate_command_list_file(${PROJECT_NAME} "${HDRS}") target_include_directories(${PROJECT_NAME} PUBLIC 3rdparty/libisf/src + "${3RDPARTY_FOLDER}/OffsetAllocator" PRIVATE "${3RDPARTY_FOLDER}/dxv" ) @@ -453,6 +564,10 @@ target_link_libraries(${PROJECT_NAME} PUBLIC ${QT_PREFIX}::ShaderTools ${QT_PREFIX}::ShaderToolsPrivate ${QT_PREFIX}::GuiPrivate "$" ) + +# nv-dvp-bridge: HostPinnedRing's DVP backend uses the bridge C API +# directly; needs the include path and the static lib. +target_link_libraries(${PROJECT_NAME} PRIVATE score_nv_dvp_bridge) if(TARGET ${QT_PREFIX}::Svg) target_link_libraries(${PROJECT_NAME} PUBLIC "${QT_PREFIX}::Svg") endif() @@ -582,11 +697,13 @@ elseif(APPLE) target_sources(${PROJECT_NAME} PRIVATE Gfx/CameraDevice.avf.mm Gfx/Graph/RhiBufferCopyMetal.mm + Gfx/Graph/RhiClearBufferMetal.mm ) set_source_files_properties( Gfx/CameraDevice.avf.mm Gfx/Graph/RhiBufferCopyMetal.mm + Gfx/Graph/RhiClearBufferMetal.mm PROPERTIES SKIP_UNITY_BUILD_INCLUSION 1 ) @@ -617,6 +734,30 @@ if(NOT EMSCRIPTEN) endif() endif() +if(NOT EMSCRIPTEN AND NOT APPLE AND NOT WIN32 AND TARGET pipewire::pipewire) + target_sources(${PROJECT_NAME} PRIVATE + Gfx/Pipewire/PipewireFormats.hpp + Gfx/Pipewire/PipewireInputDevice.hpp + Gfx/Pipewire/PipewireInputDevice.cpp + Gfx/Pipewire/PipewireOutputDevice.hpp + Gfx/Pipewire/PipewireOutputDevice.cpp + ) + set_source_files_properties( + Gfx/Pipewire/PipewireInputDevice.cpp + Gfx/Pipewire/PipewireOutputDevice.cpp + PROPERTIES SKIP_UNITY_BUILD_INCLUSION ON + ) + # Include-only: pull pipewire's INTERFACE_INCLUDE_DIRECTORIES without + # linking libpipewire-0.3 (DT_NEEDED would defeat dlopen). The shared + # layer in libremidi resolves every pw_* symbol at runtime. + get_target_property(_pw_includes pipewire::pipewire INTERFACE_INCLUDE_DIRECTORIES) + if(_pw_includes) + target_include_directories(${PROJECT_NAME} PRIVATE ${_pw_includes}) + endif() + target_compile_definitions(${PROJECT_NAME} PRIVATE SCORE_HAS_PIPEWIRE_VIDEO_IO) + list(APPEND SCORE_FEATURES_LIST pipewire_video) +endif() + if(freenect2_FOUND) target_compile_definitions(${PROJECT_NAME} PRIVATE HAS_FREENECT2) target_include_directories(${PROJECT_NAME} PRIVATE ${freenect2_INCLUDE_DIR}) @@ -640,3 +781,13 @@ add_executable(VideoTester tests/VideoTester.cpp) target_link_libraries(VideoTester PRIVATE score_plugin_gfx) endif() +# Offscreen unit test for the GPU video encoders (no AJA/libav/gst needed). +# Static-plugin builds can't link it: score_init_static_plugins() references +# every configured addon and the tester only links score_plugin_gfx/media. +option(SCORE_ENCODER_TESTER "Build the GPU encoder offscreen self-test" ON) +if(SCORE_ENCODER_TESTER AND NOT SCORE_STATIC_PLUGINS) +add_executable(EncoderTester tests/EncoderTester.cpp) +target_link_libraries(EncoderTester PRIVATE + score_plugin_gfx score_plugin_media ${QT_PREFIX}::Gui) +endif() + diff --git a/src/plugins/score-plugin-gfx/Gfx/AssetTable.cpp b/src/plugins/score-plugin-gfx/Gfx/AssetTable.cpp new file mode 100644 index 0000000000..12d3a65b78 --- /dev/null +++ b/src/plugins/score-plugin-gfx/Gfx/AssetTable.cpp @@ -0,0 +1,187 @@ +#include + +#include +#include + +namespace Gfx +{ + +namespace +{ +std::size_t estimateSize(const AssetTable::DecodedAsset& a) noexcept +{ + std::size_t total = 0; + if(!a.image.isNull()) + total += static_cast(a.image.sizeInBytes()); + if(a.bytes) + total += a.bytes->size(); + return total; +} +} + +void AssetTable::stage(uint64_t content_hash, QImage image) +{ + std::lock_guard lock{m_mutex}; + auto it = m_entries.find(content_hash); + if(it != m_entries.end()) + return; // Hash contract: same hash = same bytes. Idempotent stage. + + auto e = std::make_shared(); + e->image = std::move(image); + e->byte_size = estimateSize(*e); + m_total_bytes += e->byte_size; + + Slot s; + s.asset = std::move(e); + m_entries.emplace(content_hash, std::move(s)); +} + +void AssetTable::stage( + uint64_t content_hash, + std::shared_ptr> bytes, + std::string mime_type) +{ + std::lock_guard lock{m_mutex}; + auto it = m_entries.find(content_hash); + if(it != m_entries.end()) + return; + + auto e = std::make_shared(); + e->bytes = std::move(bytes); + e->mime_type = std::move(mime_type); + e->byte_size = estimateSize(*e); + m_total_bytes += e->byte_size; + + Slot s; + s.asset = std::move(e); + m_entries.emplace(content_hash, std::move(s)); +} + +std::shared_ptr +AssetTable::acquire(uint64_t content_hash) +{ + std::lock_guard lock{m_mutex}; + auto it = m_entries.find(content_hash); + if(it == m_entries.end()) + return {}; + auto& slot = it->second; + + // Resurrect from LRU if cold. + if(slot.in_lru) + { + m_lru.erase(slot.lru_it); + slot.in_lru = false; + m_cold_bytes -= slot.asset->byte_size; + } + + ++slot.asset->refcount; + return slot.asset; +} + +std::shared_ptr +AssetTable::peek(uint64_t content_hash) const +{ + std::lock_guard lock{m_mutex}; + auto it = m_entries.find(content_hash); + if(it == m_entries.end()) + return {}; + // Intentionally does NOT move out of LRU nor bump refcount — the + // caller just wants a read-through. If the entry is cold it stays + // cold (still evictable next trim). shared_ptr semantics keep the + // DecodedAsset alive as long as the caller holds the returned ptr, + // even if eviction happens concurrently on another thread. + return it->second.asset; +} + +void AssetTable::release(uint64_t content_hash) +{ + std::lock_guard lock{m_mutex}; + auto it = m_entries.find(content_hash); + if(it == m_entries.end()) + return; + auto& slot = it->second; + if(slot.asset->refcount > 0) + --slot.asset->refcount; + if(slot.asset->refcount == 0 && !slot.in_lru) + { + // Newest-first: push_front, tail is oldest. trim() pops from tail. + m_lru.push_front(content_hash); + slot.lru_it = m_lru.begin(); + slot.in_lru = true; + m_cold_bytes += slot.asset->byte_size; + } +} + +void AssetTable::evictOne() noexcept +{ + // Caller holds m_mutex. + if(m_lru.empty()) + return; + const uint64_t hash = m_lru.back(); + m_lru.pop_back(); + + auto it = m_entries.find(hash); + if(it == m_entries.end()) + return; + + const std::size_t sz = it->second.asset->byte_size; + m_total_bytes -= sz; + m_cold_bytes -= sz; + m_entries.erase(it); +} + +std::size_t AssetTable::trim(std::size_t max_bytes_budget) +{ + std::lock_guard lock{m_mutex}; + std::size_t evicted = 0; + // Only evict from cold pool — hot entries stay regardless of budget. + while(m_cold_bytes > max_bytes_budget && !m_lru.empty()) + { + const std::size_t before_total = m_total_bytes; + evictOne(); + evicted += (before_total - m_total_bytes); + } + return evicted; +} + +void AssetTable::maybeAutoTrim( + float utilization, float high_watermark, float target) +{ + if(utilization < high_watermark) + return; + + std::lock_guard lock{m_mutex}; + if(m_cold_bytes == 0) + return; + + // Convert target utilization to a cold-pool budget. Heuristic: + // scale the current cold pool by (target / utilization). At + // util=0.85, target=0.60 → trim to ~70% of current cold total. + // Not a proper memory-pressure solver — a low-cost knob that + // kicks in on sustained overload. + const float scale = target / utilization; + const auto budget + = static_cast(static_cast(m_cold_bytes) * scale); + while(m_cold_bytes > budget && !m_lru.empty()) + evictOne(); +} + +std::size_t AssetTable::size() const noexcept +{ + std::lock_guard lock{m_mutex}; + return m_entries.size(); +} + +std::size_t AssetTable::totalBytes() const noexcept +{ + std::lock_guard lock{m_mutex}; + return m_total_bytes; +} + +std::size_t AssetTable::coldCount() const noexcept +{ + std::lock_guard lock{m_mutex}; + return m_lru.size(); +} + +} // namespace Gfx diff --git a/src/plugins/score-plugin-gfx/Gfx/AssetTable.hpp b/src/plugins/score-plugin-gfx/Gfx/AssetTable.hpp new file mode 100644 index 0000000000..5b91d7fb98 --- /dev/null +++ b/src/plugins/score-plugin-gfx/Gfx/AssetTable.hpp @@ -0,0 +1,169 @@ +#pragma once + +#include + +#include + +#include + +#include +#include +#include +#include +#include +#include +#include + +namespace Gfx +{ + +/** + * @brief Cross-RenderList content-hash dedup for decoded asset bytes. + * + * Lives on GfxContext, shared across all RenderLists in the session. + * Keyed by `content_hash` (64-bit stable hash of the source bytes — + * the canonical primitive is `ossia::hash_bytes` from + * `ossia/detail/hash.hpp`, which dispatches to rapidhash; parsers and + * the preprocessor produce content_hash values through that helper). + * + * Purpose: one decode per asset across the whole session. When two + * glTF files reference the same `baseColor.jpg`, we decode it once + * and reuse. Per-RenderList GpuResourceRegistries upload from the + * cached QImage independently (Plan 09 §4.2: one decode, N uploads). + * + * Not the GPU-resource owner — GpuResourceRegistry does that per + * QRhi. AssetTable only holds CPU-side bytes + format metadata + * during the window between decode and eviction. + * + * # Lifecycle (Plan 09 S1) + * + * Three states per entry: + * + * - **hot** (refcount > 0): actively held by at least one consumer. + * Never evicted. + * - **cool** (refcount == 0, still referenced in the LRU list): + * eviction candidate. `acquire()` resurrects it at zero cost. + * - **evicted**: dropped from the map. Next `acquire()` misses; + * the caller re-decodes and restage()s. + * + * Transitions: + * - `stage()` inserts into hot map (or no-op if already present). + * - `acquire()` bumps refcount and (if resurrecting) splices out + * of the LRU list. + * - `release()` decrements; at 0 the entry moves to the LRU head. + * - `trim(max_bytes)` pops from the LRU tail until under budget. + * - `maybeAutoTrim()` called periodically: reads a supplied + * utilization ratio and trims when above a threshold. + * + * Byte accounting is approximate — `sizeInBytes(DecodedAsset)` hits + * QImage::sizeInBytes and the raw bytes vector size. Good enough + * for budget bookkeeping without a full allocator hook. + * + * # Thread safety + * + * All public methods take `m_mutex`. Fine for the access pattern + * (parser worker threads stage, render threads acquire/release, + * GUI tick trims) — the mutex is held for microseconds at a time. + */ +class SCORE_PLUGIN_GFX_EXPORT AssetTable +{ +public: + /// Decoded image or raw byte payload. `image` is preferred for 2D + /// textures (carries QImage's format metadata); `bytes` for generic + /// buffer assets (vertex/index streams etc.). + struct DecodedAsset + { + QImage image; + std::shared_ptr> bytes; + std::string mime_type; + int64_t refcount{0}; + // Approximate storage cost. Computed at stage() time; the + // allocator may report a different value but this is the number + // the LRU trim budgets against. + std::size_t byte_size{0}; + }; + + // For byte-range hashing use `ossia::hash_bytes` from + // `ossia/detail/hash.hpp` — it's the canonical rapidhash-tiered + // dispatcher that produces stable `content_hash` values across + // the codebase. Parsers call it directly when stamping + // `texture_source::content_hash` / `buffer_resource::content_hash`. + + AssetTable() = default; + AssetTable(const AssetTable&) = delete; + AssetTable& operator=(const AssetTable&) = delete; + ~AssetTable() = default; + + /// Publish a decoded asset under its content hash. Idempotent — + /// a second stage() with the same hash is a no-op (hash contract: + /// same hash = same bytes). + void stage(uint64_t content_hash, QImage image); + void stage( + uint64_t content_hash, std::shared_ptr> bytes, + std::string mime_type = {}); + + /// Return a shared pointer to the decoded asset, bumping its + /// refcount. Null when not staged. O(1) average. + std::shared_ptr acquire(uint64_t content_hash); + + /// Read-through without refcount bump. The returned shared_ptr + /// keeps the DecodedAsset alive on the caller's side even if the + /// AssetTable evicts the entry — but does NOT prevent eviction. + /// Suitable for the "upload once to GPU, then done" path where + /// the consumer doesn't care if the CPU-side bytes live on. + std::shared_ptr peek(uint64_t content_hash) const; + + /// Decrement refcount. At 0 the entry moves to the LRU head and + /// is eligible for eviction on the next trim. + void release(uint64_t content_hash); + + /// Force eviction until the cold-pool byte total is below + /// @p max_bytes. Called explicitly by UI ("unload unused") or + /// implicitly by maybeAutoTrim. + /// @return bytes evicted. + std::size_t trim(std::size_t max_bytes_budget); + + /// Called on a cadence (e.g. from the Gfx thread idle tick) to + /// pressure-trim when the supplied utilization ratio exceeds + /// @p high_watermark. Cost: O(n) in the LRU list when a trim + /// fires; constant otherwise. + /// + /// @p utilization in [0, 1]. Compute externally from + /// QRhiStats::usedBytes / (usedBytes + unusedBytes), or from a + /// hard OS-level memory query. + /// @p high_watermark default 0.80. @p target default 0.60. + void maybeAutoTrim( + float utilization, float high_watermark = 0.80f, + float target = 0.60f); + + /// Debug / inspector. + std::size_t size() const noexcept; + /// Approx total bytes held in cold pool + hot pool. + std::size_t totalBytes() const noexcept; + /// Number of cold entries eligible for eviction. + std::size_t coldCount() const noexcept; + +private: + struct Slot; // forward + + // Linked list of cold entries, newest at head. std::list for + // stable iterators under concurrent erase. + using LruList = std::list; + + struct Slot + { + std::shared_ptr asset; + LruList::iterator lru_it; // valid only when refcount == 0 + bool in_lru{false}; + }; + + void evictOne() noexcept; // Pops the LRU tail. Caller holds m_mutex. + + mutable std::mutex m_mutex; + ossia::hash_map m_entries; + LruList m_lru; // cold entries, newest at front + std::size_t m_total_bytes{0}; + std::size_t m_cold_bytes{0}; +}; + +} // namespace Gfx diff --git a/src/plugins/score-plugin-gfx/Gfx/CSF/Library.cpp b/src/plugins/score-plugin-gfx/Gfx/CSF/Library.cpp index de27d2090b..3ee61ae569 100644 --- a/src/plugins/score-plugin-gfx/Gfx/CSF/Library.cpp +++ b/src/plugins/score-plugin-gfx/Gfx/CSF/Library.cpp @@ -13,7 +13,7 @@ namespace Gfx::CSF QSet LibraryHandler::acceptedFiles() const noexcept { - return {"cs", "comp"}; + return {"cs", "comp", "csf"}; } void LibraryHandler::setup( @@ -62,7 +62,7 @@ QWidget* LibraryHandler::previewWidget( QSet DropHandler::fileExtensions() const noexcept { - return {"cs", "comp"}; + return {"cs", "comp", "csf"}; } void DropHandler::dropPath( diff --git a/src/plugins/score-plugin-gfx/Gfx/CSF/Process.cpp b/src/plugins/score-plugin-gfx/Gfx/CSF/Process.cpp index 95d1b063d3..20de4e309b 100644 --- a/src/plugins/score-plugin-gfx/Gfx/CSF/Process.cpp +++ b/src/plugins/score-plugin-gfx/Gfx/CSF/Process.cpp @@ -6,10 +6,13 @@ #include #include +#include #include #include +#include #include +#include #include @@ -78,7 +81,10 @@ Model::Model( QFile f{init}; if(f.open(QIODevice::ReadOnly)) + { + m_scriptPath = init; (void)setCompute(f.readAll()); + } } Model::~Model() { } @@ -87,8 +93,18 @@ bool Model::validate(const QString& txt) const noexcept { try { + // Expand #include directives against the model's origin dir + the + // global search paths before handing the source to the ISF parser. + auto [resolved, err] + = Gfx::preprocessShaderIncludes(txt.toUtf8(), m_scriptPath); + if(!err.isEmpty()) + { + this->errorMessage(0, err); + return false; + } + // Parse the CSF shader to extract metadata - std::string str = txt.toStdString(); + std::string str(resolved.constData(), resolved.size()); isf::parser p{str, isf::parser::ShaderType::CSF}; // Check if it's a valid CSF shader @@ -144,15 +160,25 @@ Process::ScriptChangeResult Model::setScript(const QString& f) { m_compute = f; - QString processed = m_compute; - auto inls = score::clearAndDeleteLater(m_inlets); auto outls = score::clearAndDeleteLater(m_outlets); try { + // Expand #include directives against the model's origin dir before + // feeding the source to the ISF parser. + auto [resolved, err] + = Gfx::preprocessShaderIncludes(m_compute.toUtf8(), m_scriptPath); + if(!err.isEmpty()) + { + this->errorMessage(0, err); + return {.valid = false, .inlets = std::move(inls), .outlets = std::move(outls)}; + } + // Parse CSF shader - isf::parser p{processed.toStdString(), isf::parser::ShaderType::CSF}; + isf::parser p{ + std::string(resolved.constData(), resolved.size()), + isf::parser::ShaderType::CSF}; m_processedProgram.descriptor = p.data(); m_processedProgram.fragment = QString::fromStdString(p.compute_shader()); m_processedProgram.type = isf::parser::ShaderType::CSF; @@ -310,8 +336,19 @@ void Model::setupCSF(const isf::descriptor& desc) alternatives.emplace_back("2", 2); } + // ComboBox::init is a VALUE that should match one of the alternatives' + // values — NOT an index. libisf stores `v.def` as the INDEX into + // values (see isf.hpp comment on long_input::def). Passing the raw + // index made the ComboBox fail to match any alternative and silently + // default to alternatives[0], which is why DEFAULT: 32 in + // VALUES: [16, 32, 64] showed up as 16 in the UI. Look up the + // alternative at v.def and pass its second (the value). + const std::size_t def_idx + = std::min(v.def, alternatives.size() - 1); + const ossia::value& init_value = alternatives[def_idx].second; + auto port = new Process::ComboBox( - std::move(alternatives), (int)v.def, QString::fromStdString(input.name), + std::move(alternatives), init_value, QString::fromStdString(input.name), Id(input_i++), &self); self.m_inlets.push_back(port); @@ -448,18 +485,34 @@ void Model::setupCSF(const isf::descriptor& desc) QString::fromStdString(input.name), Id(output_i++), &self); self.m_outlets.push_back(port); - auto size_inl = new Process::IntSpinBox{ - 1, - 536870911, - 1024, - QString::fromStdString(input.name) + " size", - Id(input_i++), - &self}; - self.m_inlets.push_back(size_inl); - self.controlAdded(size_inl->id()); + // Only writable buffers whose layout ends in a flexible-array member + // get a synthesized "size" inlet — this MUST match the renderer + // (isf_input_port_count_vis / isf_input_port_vis) and the generated + // GLSL, or every later control routes to the wrong port. + if(!v.layout.empty() + && v.layout.back().type.find("[]") != std::string::npos) + { + auto size_inl = new Process::IntSpinBox{ + 1, + 536870911, + 1024, + QString::fromStdString(input.name) + " size", + Id(input_i++), + &self}; + self.m_inlets.push_back(size_inl); + self.controlAdded(size_inl->id()); + } } } + void operator()(const uniform_input& v) + { + // UBO inputs sourced from upstream Buffer ports (read-only). + auto port = new Gfx::TextureInlet( + QString::fromStdString(input.name), Id(input_i++), &self); + self.m_inlets.push_back(port); + } + void operator()(const texture_input& v) { auto port = new Gfx::TextureInlet( @@ -606,7 +659,17 @@ Process::Descriptor ProcessFactory::descriptor(QString) const noexcept template <> void DataStreamReader::read(const Gfx::CSF::Model& proc) { - m_stream << proc.m_compute; + // documentContext() SCORE_ASSERTs when the model isn't in a document + // (e.g. saving a template / copy). Only relativize against the document + // when there's an actual script path to relativize — mirrors the + // JSON/load guards. The empty case writes an empty path verbatim. + QString relativeScriptPath; + if(!proc.m_scriptPath.isEmpty()) + { + auto& ctx = score::IDocument::documentContext(proc); + relativeScriptPath = score::relativizeFilePath(proc.m_scriptPath, ctx); + } + m_stream << proc.m_compute << relativeScriptPath; readPorts(*this, proc.m_inlets, proc.m_outlets); insertDelimiter(); @@ -616,7 +679,12 @@ template <> void DataStreamWriter::write(Gfx::CSF::Model& proc) { QString s; - m_stream >> s; + m_stream >> s >> proc.m_scriptPath; + if(!proc.m_scriptPath.isEmpty()) + { + auto& ctx = score::IDocument::documentContext(proc); + proc.m_scriptPath = score::locateFilePath(proc.m_scriptPath, ctx); + } (void)proc.setScript(s); writePorts( *this, components.interfaces(), proc.m_inlets, @@ -629,6 +697,11 @@ template <> void JSONReader::read(const Gfx::CSF::Model& proc) { obj["Compute"] = proc.script(); + if(!proc.m_scriptPath.isEmpty()) + { + auto& ctx = score::IDocument::documentContext(proc); + obj["Root"] = score::relativizeFilePath(proc.m_scriptPath, ctx); + } readPorts(*this, proc.m_inlets, proc.m_outlets); } @@ -636,6 +709,15 @@ template <> void JSONWriter::write(Gfx::CSF::Model& proc) { QString s = obj["Compute"].toString(); + if(auto r = obj.tryGet("Root")) + { + proc.m_scriptPath <<= *r; + if(!proc.m_scriptPath.isEmpty()) + { + auto& ctx = score::IDocument::documentContext(proc); + proc.m_scriptPath = score::locateFilePath(proc.m_scriptPath, ctx); + } + } (void)proc.setScript(s); writePorts( *this, components.interfaces(), proc.m_inlets, diff --git a/src/plugins/score-plugin-gfx/Gfx/CSF/Process.hpp b/src/plugins/score-plugin-gfx/Gfx/CSF/Process.hpp index a0c6580885..1ac120ddf7 100644 --- a/src/plugins/score-plugin-gfx/Gfx/CSF/Process.hpp +++ b/src/plugins/score-plugin-gfx/Gfx/CSF/Process.hpp @@ -75,6 +75,11 @@ class Model final : public Process::ProcessModel void errorMessage(int line, const QString& err) const W_SIGNAL(errorMessage, line, err); + // Absolute path of the shader file this model was loaded from. Used as + // the base for quoted #include resolution in ProgramCache::get. Empty + // when the shader source is in-memory. Mirrors JS::ProcessModel::m_root. + QString rootPath() const noexcept { return m_scriptPath; } + private: void loadPreset(const Process::Preset& preset) override; Process::Preset savePreset() const noexcept override; @@ -84,6 +89,7 @@ class Model final : public Process::ProcessModel QString m_compute; ProcessedProgram m_processedProgram; + QString m_scriptPath; }; struct ProcessFactory final : Process::ProcessFactory_T diff --git a/src/plugins/score-plugin-gfx/Gfx/CameraDevice.win32.cpp b/src/plugins/score-plugin-gfx/Gfx/CameraDevice.win32.cpp index 725a13bf26..6f53be1ce5 100644 --- a/src/plugins/score-plugin-gfx/Gfx/CameraDevice.win32.cpp +++ b/src/plugins/score-plugin-gfx/Gfx/CameraDevice.win32.cpp @@ -8,14 +8,19 @@ extern "C" { #include } -// ! +// clang-format off +// Order-sensitive — do NOT let clang-format sort these: +// - must precede / so the DirectShow GUIDs get +// a real definition (not just an extern declaration); +// - the Windows system headers must come before /. #include -// ! Needs to be present before, to ensure uuids get enumerated +#include #include #include -#include #include +#include +// clang-format on namespace Gfx { diff --git a/src/plugins/score-plugin-gfx/Gfx/Filter/Library.cpp b/src/plugins/score-plugin-gfx/Gfx/Filter/Library.cpp index bd1a1f1185..c733244f04 100644 --- a/src/plugins/score-plugin-gfx/Gfx/Filter/Library.cpp +++ b/src/plugins/score-plugin-gfx/Gfx/Filter/Library.cpp @@ -13,6 +13,7 @@ #include #include +#include #include #include #include @@ -163,7 +164,7 @@ void DropHandler::dropPath( void DropHandler::dropCustom( std::vector& vec, const QMimeData& mime, - const score::DocumentContext& ctx) const noexcept + const score::DocumentContext& ctx) const { // FIXME handle multipass / multibuffer for(const auto& uri : mime.urls()) @@ -186,28 +187,40 @@ void DropHandler::dropCustom( { continue; } - isf::parser parser("", shader_json, 450, isf::parser::ShaderType::ShaderToy); - auto isf = parser.write_isf(); - auto spec = parser.data(); - if(isf.empty()) + // The ISF parser throws invalid_file on malformed Shadertoy + // JSON (empty body, non-JSON response, missing fields, parse- + // time validation failures like non-numeric LOCATION). Catch + // per URL so one bad URL doesn't abort the whole drop batch. + try + { + isf::parser parser("", shader_json, 450, isf::parser::ShaderType::ShaderToy); + auto isf = parser.write_isf(); + auto spec = parser.data(); + if(isf.empty()) + { + continue; + } + // For immediate feedback, add a placeholder + Process::ProcessDropHandler::ProcessDrop p; + p.creation.key = Metadata::get(); + p.creation.prettyName = "Shadertoy " + shaderId; + p.setup = [isf](Process::ProcessModel& p, score::Dispatcher& d) { + auto& filter = (Gfx::Filter::Model&)p; + Gfx::ShaderSource source; + source.vertex = ""; + source.fragment = QString::fromStdString(isf); + auto cmd = new Gfx::ChangeShader{ + filter, source, score::IDocument::documentContext(p)}; + d.submit(cmd); + }; + + vec.push_back(std::move(p)); + } + catch(const std::exception& e) { + qWarning() << "Shadertoy drop failed for" << shaderId << ":" << e.what(); continue; } - // For immediate feedback, add a placeholder - Process::ProcessDropHandler::ProcessDrop p; - p.creation.key = Metadata::get(); - p.creation.prettyName = "Shadertoy " + shaderId; - p.setup = [isf](Process::ProcessModel& p, score::Dispatcher& d) { - auto& filter = (Gfx::Filter::Model&)p; - Gfx::ShaderSource source; - source.vertex = ""; - source.fragment = QString::fromStdString(isf); - auto cmd = new Gfx::ChangeShader{ - filter, source, score::IDocument::documentContext(p)}; - d.submit(cmd); - }; - - vec.push_back(std::move(p)); } } } diff --git a/src/plugins/score-plugin-gfx/Gfx/Filter/Library.hpp b/src/plugins/score-plugin-gfx/Gfx/Filter/Library.hpp index 53a41e75d7..152e3f8aba 100644 --- a/src/plugins/score-plugin-gfx/Gfx/Filter/Library.hpp +++ b/src/plugins/score-plugin-gfx/Gfx/Filter/Library.hpp @@ -44,7 +44,7 @@ class DropHandler final : public Process::ProcessDropHandler void dropCustom( std::vector& drops, const QMimeData& mime, - const score::DocumentContext& ctx) const noexcept override; + const score::DocumentContext& ctx) const override; }; struct VideoTextureDropHandler : public Process::ProcessDropHandler diff --git a/src/plugins/score-plugin-gfx/Gfx/Filter/PreviewWidget.cpp b/src/plugins/score-plugin-gfx/Gfx/Filter/PreviewWidget.cpp index 7175e95344..93df20d895 100644 --- a/src/plugins/score-plugin-gfx/Gfx/Filter/PreviewWidget.cpp +++ b/src/plugins/score-plugin-gfx/Gfx/Filter/PreviewWidget.cpp @@ -1,12 +1,15 @@ #include #include +#include #include -#include +#include #include +#include #include +#include #include #include @@ -137,7 +140,8 @@ struct PreviewInputVisitor // CSF-specific input handlers score::gfx::NodeModel* operator()(const isf::storage_input& v) { return nullptr; } - + score::gfx::NodeModel* operator()(const isf::uniform_input& v) { return nullptr; } + score::gfx::NodeModel* operator()(const isf::texture_input& v) { static std::array images{ @@ -175,61 +179,80 @@ struct PreviewPresetVisitor { score::gfx::ISFNode& node; ossia::flat_map& controls; + // Descriptor-input index: matches both the saved preset control keys + // (model inlet id == desc.inputs index, see setupISFModelPorts) and the + // controls flat_map key. int i{}; + // Render-port index: index into node.input[], advanced via + // walk_descriptor_inputs (an input may create 0 or 2 ports, so this + // drifts from the descriptor index). + int port{}; + + // Guarded material pointer for the current render port: nullptr if the + // port index is out of range or the port carries no material storage. + float* portValue() const noexcept + { + if(port < 0 || port >= (int)node.input.size()) + return nullptr; + return reinterpret_cast(node.input[port]->value); + // NB: for scalar/vector inputs value always points into the material + // UBO blob; image/audio inputs never reach this (their visitors no-op). + } + void operator()(const isf::float_input& v) { - if(float* v = controls[i].target()) - { - (*(float*)node.input[i]->value) = *v; - } + if(float* dst = portValue(); dst) + if(float* val = controls[i].target()) + *dst = *val; } void operator()(const isf::long_input& v) { - if(int* v = controls[i].target()) - { - (*(int*)node.input[i]->value) = *v; - } + if(float* dst = portValue(); dst) + if(int* val = controls[i].target()) + *reinterpret_cast(dst) = *val; } void operator()(const isf::event_input& v) { } void operator()(const isf::bool_input& v) { - if(bool* v = controls[i].target()) - { - (*(int*)node.input[i]->value) = *v ? 1 : 0; - } + if(float* dst = portValue(); dst) + if(bool* val = controls[i].target()) + *reinterpret_cast(dst) = *val ? 1 : 0; } void operator()(const isf::point2d_input& v) { - if(ossia::vec2f* v = controls[i].target()) - { - (*(float*)node.input[i]->value) = (*v)[0]; - (*((float*)node.input[i]->value + 1)) = (*v)[1]; - } + if(float* dst = portValue(); dst) + if(ossia::vec2f* val = controls[i].target()) + { + dst[0] = (*val)[0]; + dst[1] = (*val)[1]; + } } void operator()(const isf::point3d_input& v) { - if(ossia::vec3f* v = controls[i].target()) - { - (*(float*)node.input[i]->value) = (*v)[0]; - (*((float*)node.input[i]->value + 1)) = (*v)[1]; - (*((float*)node.input[i]->value + 2)) = (*v)[2]; - } + if(float* dst = portValue(); dst) + if(ossia::vec3f* val = controls[i].target()) + { + dst[0] = (*val)[0]; + dst[1] = (*val)[1]; + dst[2] = (*val)[2]; + } } void operator()(const isf::color_input& v) { - if(ossia::vec4f* v = controls[i].target()) - { - (*(float*)node.input[i]->value) = (*v)[0]; - (*((float*)node.input[i]->value + 1)) = (*v)[1]; - (*((float*)node.input[i]->value + 2)) = (*v)[2]; - (*((float*)node.input[i]->value + 3)) = (*v)[3]; - } + if(float* dst = portValue(); dst) + if(ossia::vec4f* val = controls[i].target()) + { + dst[0] = (*val)[0]; + dst[1] = (*val)[1]; + dst[2] = (*val)[2]; + dst[3] = (*val)[3]; + } } void operator()(const isf::image_input& v) { } @@ -244,6 +267,7 @@ struct PreviewPresetVisitor // CSF-specific input handlers void operator()(const isf::storage_input& v) { } + void operator()(const isf::uniform_input& v) { } void operator()(const isf::texture_input& v) { } @@ -256,18 +280,17 @@ struct PreviewPresetVisitor ShaderPreviewManager* g_shaderPreview{}; bool g_shaderPreviewScheduledForDeletion{}; -// Creating and destroying QRhi is fairly expensive, so -// we keep one around when we are showing ISF previews +// Holds the source ISF + image nodes shared across hover previews. +// The output side is owned by individual ShaderPreviewWidget / +// RhiPreviewWidget instances: each contributes a score::gfx::PreviewNode +// targeting its own QRhiWidget render target. Multiple previews can be +// attached at once (e.g. library hover + live texture-port preview). class ShaderPreviewManager : public QObject { public: ShaderPreviewManager() : QObject{qApp} { - score::gfx::OutputNode::Configuration conf{}; - m_screen = std::make_unique(conf, true); - m_graph.addNode(m_screen.get()); - connect(qApp, &QCoreApplication::aboutToQuit, this, [] { delete g_shaderPreview; g_shaderPreviewScheduledForDeletion = false; @@ -288,7 +311,8 @@ class ShaderPreviewManager : public QObject if(path.contains(".vs") || path.contains(".vert")) program = programFromVSAVertexShaderPath(path, {}); - if(const auto& [processed, error] = ProgramCache::instance().get(program); + if(const auto& [processed, error] + = ProgramCache::instance().get(program, path); bool(processed)) { m_program = *processed; @@ -311,6 +335,8 @@ class ShaderPreviewManager : public QObject auto vert = obj["Vertex"].GetString(); ShaderSource program{type, vert, frag}; + // Preset-loaded source has no origin file; includes resolve against + // global search paths only. if(const auto& [processed, error] = ProgramCache::instance().get(program); bool(processed)) { @@ -324,21 +350,49 @@ class ShaderPreviewManager : public QObject controls[arr[0].GetInt()] = JsonValue{arr[1]}.to(); } + // controls is keyed by descriptor-input index (== model inlet id); + // node.input[] is keyed by render-port index. walk_descriptor_inputs + // gives the render-port index (cur.inlets) for each descriptor entry, + // which drifts from the descriptor index for 0-/2-port inputs. int i = 0; - for(const isf::input& input : m_program.descriptor.inputs) - { - ossia::visit(PreviewPresetVisitor{*m_isf, controls, i}, input.data); - i++; - } + score::gfx::walk_descriptor_inputs( + m_program.descriptor, + [&](const isf::input& input, const score::gfx::port_counts& cur, + const score::gfx::port_counts&) { + ossia::visit( + PreviewPresetVisitor{*m_isf, controls, i, cur.inlets}, + input.data); + i++; + }); } } } - std::shared_ptr getWindow() + score::gfx::Graph& graph() noexcept { return m_graph; } + + // True while at least one preview widget is still attached to the shared + // graph. The deferred manager deletion must NOT fire while this holds, or + // a surviving widget's RhiPreviewWidget::m_graph would dangle (UAF on its + // detach()). + bool hasPreviews() const noexcept { return !m_previews.empty(); } + + void attachPreview(score::gfx::BackgroundNode& node) + { + m_previews.push_back(&node); + if(m_isf) + { + m_graph.addEdge( + m_isf->output[0], node.input[0], Process::CableType::ImmediateGlutton); + const auto& settings = score::AppContext().settings(); + m_graph.createAllRenderLists(settings.graphicsApiEnum()); + } + } + + void detachPreview(score::gfx::BackgroundNode& node) { - if(m_screen && m_screen.get()) - return m_screen.get()->window(); - return {}; + ossia::remove_erase(m_previews, &node); + if(m_isf) + m_graph.removeEdge(m_isf->output[0], node.input[0]); } std::vector> m_previewEdges; @@ -346,7 +400,7 @@ class ShaderPreviewManager : public QObject void setup() { const auto& settings = score::AppContext().settings(); - // Create our graph + // Tear down the previous set of source nodes. for(auto [a, b] : m_previewEdges) m_graph.removeEdge(a, b); m_previewEdges.clear(); @@ -359,48 +413,63 @@ class ShaderPreviewManager : public QObject if(m_isf) { - m_graph.removeEdge(m_isf->output[0], m_screen->input[0]); + for(auto* p : m_previews) + m_graph.removeEdge(m_isf->output[0], p->input[0]); m_graph.removeNode(m_isf.get()); } - m_graph.removeNode(m_screen.get()); - // Clear the graph, renderers etc. m_graph.createAllRenderLists(settings.graphicsApiEnum()); m_isf.reset(); m_textures.clear(); - // Recreate what we need - m_graph.addNode(m_screen.get()); - // FIXME add an error image if the shader did not parse m_isf = std::make_unique( m_program.descriptor, m_program.vertex, m_program.fragment); m_graph.addNode(m_isf.get()); - // Edge from filter to output - m_graph.addEdge( - m_isf->output[0], m_screen->input[0], Process::CableType::ImmediateGlutton); - // Edges from image nodes to image inputs - int image_i = 0; - int i = 0; - for(const isf::input& input : m_program.descriptor.inputs) - { - auto node = ossia::visit(PreviewInputVisitor{image_i}, input.data); - if(node) - { - m_graph.addNode(node); + // Wire ISF output to every currently-attached preview. + for(auto* p : m_previews) + m_graph.addEdge( + m_isf->output[0], p->input[0], Process::CableType::ImmediateGlutton); - m_graph.addEdge( - node->output[0], m_isf->input[i], Process::CableType::ImmediateGlutton); - m_previewEdges.emplace_back(node->output[0], m_isf->input[i]); - - m_textures.push_back(std::unique_ptr(node)); - } - i++; - } + // Edges from image nodes to image inputs. The render-port index of an + // input (cur.inlets, via walk_descriptor_inputs) drifts from the + // descriptor index for inputs that create 0 or 2 ports, so we must not + // equate them. PreviewInputVisitor only yields a node for image-like + // inputs, each of which creates exactly one input port at cur.inlets. + int image_i = 0; + score::gfx::walk_descriptor_inputs( + m_program.descriptor, + [&](const isf::input& input, const score::gfx::port_counts& cur, + const score::gfx::port_counts& delta) { + auto node = ossia::visit(PreviewInputVisitor{image_i}, input.data); + if(node) + { + const int port_idx = cur.inlets; + // Only wire when this input actually creates an input port: + // write-access csf_image_input yields a node but 0 inlets, and + // the render-port index must come from cur.inlets (not the + // descriptor index, which drifts for 0-/2-port inputs). + if(delta.inlets < 1 || port_idx < 0 + || port_idx >= (int)m_isf->input.size()) + { + delete node; + return; + } + + m_graph.addNode(node); + + m_graph.addEdge( + node->output[0], m_isf->input[port_idx], + Process::CableType::ImmediateGlutton); + m_previewEdges.emplace_back(node->output[0], m_isf->input[port_idx]); + + m_textures.push_back(std::unique_ptr(node)); + } + }); m_graph.createAllRenderLists(settings.graphicsApiEnum()); } @@ -463,10 +532,10 @@ class ShaderPreviewManager : public QObject } } - std::unique_ptr m_screen{}; private: std::unique_ptr m_isf{}; std::vector> m_textures; + std::vector m_previews; score::gfx::Graph m_graph{}; ProcessedProgram m_program; }; @@ -497,45 +566,59 @@ ShaderPreviewWidget::ShaderPreviewWidget(const Process::Preset& preset, QWidget* ShaderPreviewWidget::~ShaderPreviewWidget() { + // Tearing down the RhiPreviewWidget triggers detachPreview() on the + // manager, which removes the producer→preview edge. Do this before + // scheduling manager deletion so the deferred delete sees a clean + // graph. + delete m_rhi; + m_rhi = nullptr; + g_shaderPreviewScheduledForDeletion = true; QTimer::singleShot(std::chrono::seconds(5), qApp, []() { - if(g_shaderPreviewScheduledForDeletion) + // Multi-client safety: several ShaderPreviewWidgets can share the same + // manager (library hover + live texture-port preview). Destroying one + // schedules this deletion, but another may still be attached — its + // RhiPreviewWidget holds a raw pointer into g_shaderPreview->graph(). + // Only tear the manager down once no preview remains attached, otherwise + // the surviving widget would dereference a freed Graph on its own + // destruction (use-after-free). + if(g_shaderPreviewScheduledForDeletion && g_shaderPreview + && !g_shaderPreview->hasPreviews()) { delete g_shaderPreview; g_shaderPreview = nullptr; g_shaderPreviewScheduledForDeletion = false; } }); - - if(m_window) - m_window->setParent(nullptr); } void ShaderPreviewWidget::setup() { // UI setup auto lay = new QHBoxLayout(this); - if((m_window = g_shaderPreview->getWindow())) - { - auto widg = createWindowContainer(m_window.get(), this); - widg->setMinimumWidth(300); - widg->setMaximumWidth(300); - widg->setMinimumHeight(200); - widg->setMaximumHeight(200); - lay->addWidget(widg); - } - // FIXME else { display error widget } - - // so anyways, I started blasting... + m_rhi = new RhiPreviewWidget(this); + m_rhi->setMinimumSize(300, 200); + m_rhi->setMaximumSize(300, 200); + m_rhi->useGraph( + &g_shaderPreview->graph(), + [](score::gfx::BackgroundNode& n) { + if(g_shaderPreview) + g_shaderPreview->attachPreview(n); + }, + [](score::gfx::BackgroundNode& n) { + if(g_shaderPreview) + g_shaderPreview->detachPreview(n); + }); + lay->addWidget(m_rhi); + + // Drives ISF time/progress uniforms. Frame submission is owned by + // the QRhiWidget (it calls update() each render). startTimer(16); } void ShaderPreviewWidget::timerEvent(QTimerEvent* event) { if(g_shaderPreview) - { g_shaderPreview->updateControls(); - g_shaderPreview->m_screen->render(); - } } } diff --git a/src/plugins/score-plugin-gfx/Gfx/Filter/PreviewWidget.hpp b/src/plugins/score-plugin-gfx/Gfx/Filter/PreviewWidget.hpp index e58e7ded5a..76318f8189 100644 --- a/src/plugins/score-plugin-gfx/Gfx/Filter/PreviewWidget.hpp +++ b/src/plugins/score-plugin-gfx/Gfx/Filter/PreviewWidget.hpp @@ -3,11 +3,10 @@ #include #include #include -#include #include -#include #include +#include namespace score::gfx { class ISFNode; @@ -18,6 +17,7 @@ struct Preset; } namespace Gfx { +class RhiPreviewWidget; class ShaderPreviewManager; class ShaderPreviewWidget : public QWidget { @@ -30,7 +30,7 @@ class ShaderPreviewWidget : public QWidget void setup(); void timerEvent(QTimerEvent* event) override; - std::shared_ptr m_window; + RhiPreviewWidget* m_rhi{}; }; } diff --git a/src/plugins/score-plugin-gfx/Gfx/Filter/Process.cpp b/src/plugins/score-plugin-gfx/Gfx/Filter/Process.cpp index b6a900ae26..3499e835f3 100644 --- a/src/plugins/score-plugin-gfx/Gfx/Filter/Process.cpp +++ b/src/plugins/score-plugin-gfx/Gfx/Filter/Process.cpp @@ -11,8 +11,10 @@ #include #include +#include #include #include +#include #include @@ -71,10 +73,12 @@ Model::Model( if(init.endsWith("fs") || init.endsWith("frag")) { + m_scriptPath = init; (void)setProgram(programFromISFFragmentShaderPath(init, {})); } else if(init.endsWith("vs") || init.endsWith("vert")) { + m_scriptPath = init; (void)setProgram(programFromVSAVertexShaderPath(init, {})); } } @@ -83,7 +87,7 @@ Model::~Model() { } bool Model::validate(const ShaderSource& txt) const noexcept { - const auto& [_, error] = ProgramCache::instance().get(txt); + const auto& [_, error] = ProgramCache::instance().get(txt, m_scriptPath); if(!error.isEmpty()) { this->errorMessage(error); @@ -116,7 +120,9 @@ Process::ScriptChangeResult Model::setProgram(const ShaderSource& f) { setVertex(f.vertex); setFragment(f.fragment); - if(const auto& [processed, error] = ProgramCache::instance().get(f); bool(processed)) + if(const auto& [processed, error] + = ProgramCache::instance().get(f, m_scriptPath); + bool(processed)) { ossia::flat_map previous_values; for(auto inl : m_inlets) @@ -203,7 +209,17 @@ void DataStreamWriter::write(Gfx::ShaderSource& p) template <> void DataStreamReader::read(const Gfx::Filter::Model& proc) { - m_stream << proc.m_program; + // documentContext() SCORE_ASSERTs when the model isn't in a document + // (e.g. saving a template / copy). Only relativize against the document + // when there's an actual script path to relativize — mirrors the + // JSON/load guards. The empty case writes an empty path verbatim. + QString relativeScriptPath; + if(!proc.m_scriptPath.isEmpty()) + { + auto& ctx = score::IDocument::documentContext(proc); + relativeScriptPath = score::relativizeFilePath(proc.m_scriptPath, ctx); + } + m_stream << proc.m_program << relativeScriptPath; readPorts(*this, proc.m_inlets, proc.m_outlets); @@ -214,7 +230,12 @@ template <> void DataStreamWriter::write(Gfx::Filter::Model& proc) { Gfx::ShaderSource s; - m_stream >> s; + m_stream >> s >> proc.m_scriptPath; + if(!proc.m_scriptPath.isEmpty()) + { + auto& ctx = score::IDocument::documentContext(proc); + proc.m_scriptPath = score::locateFilePath(proc.m_scriptPath, ctx); + } s.type = isf::parser::ShaderType::ISF; (void)proc.setProgram(s); @@ -230,6 +251,11 @@ void JSONReader::read(const Gfx::Filter::Model& proc) { obj["Vertex"] = proc.vertex(); obj["Fragment"] = proc.fragment(); + if(!proc.m_scriptPath.isEmpty()) + { + auto& ctx = score::IDocument::documentContext(proc); + obj["Root"] = score::relativizeFilePath(proc.m_scriptPath, ctx); + } readPorts(*this, proc.m_inlets, proc.m_outlets); } @@ -241,6 +267,15 @@ void JSONWriter::write(Gfx::Filter::Model& proc) s.vertex = obj["Vertex"].toString(); s.fragment = obj["Fragment"].toString(); s.type = isf::parser::ShaderType::ISF; + if(auto r = obj.tryGet("Root")) + { + proc.m_scriptPath <<= *r; + if(!proc.m_scriptPath.isEmpty()) + { + auto& ctx = score::IDocument::documentContext(proc); + proc.m_scriptPath = score::locateFilePath(proc.m_scriptPath, ctx); + } + } (void)proc.setProgram(s); writePorts( diff --git a/src/plugins/score-plugin-gfx/Gfx/Filter/Process.hpp b/src/plugins/score-plugin-gfx/Gfx/Filter/Process.hpp index a6e04b48c2..b8fd28005b 100644 --- a/src/plugins/score-plugin-gfx/Gfx/Filter/Process.hpp +++ b/src/plugins/score-plugin-gfx/Gfx/Filter/Process.hpp @@ -64,6 +64,12 @@ class Model final : public Process::ProcessModel return m_processedProgram; } + // Absolute path of the shader file this model was loaded from. Used as + // the base for quoted #include resolution in ProgramCache::get. Empty + // when the shader source is in-memory (default preset, pasted text). + // Mirrors JS::ProcessModel::m_root. + QString rootPath() const noexcept { return m_scriptPath; } + void errorMessage(const QString& arg_2) const W_SIGNAL(errorMessage, arg_2); private: @@ -73,6 +79,7 @@ class Model final : public Process::ProcessModel ShaderSource m_program; ProcessedProgram m_processedProgram; + QString m_scriptPath; }; struct ProcessFactory final : Process::ProcessFactory_T diff --git a/src/plugins/score-plugin-gfx/Gfx/FormatRegistry.cpp b/src/plugins/score-plugin-gfx/Gfx/FormatRegistry.cpp new file mode 100644 index 0000000000..e69de29bb2 diff --git a/src/plugins/score-plugin-gfx/Gfx/FormatRegistry.hpp b/src/plugins/score-plugin-gfx/Gfx/FormatRegistry.hpp new file mode 100644 index 0000000000..e69de29bb2 diff --git a/src/plugins/score-plugin-gfx/Gfx/GStreamer/GStreamerDevice.cpp b/src/plugins/score-plugin-gfx/Gfx/GStreamer/GStreamerDevice.cpp index 81a74cd651..1df3e8e69e 100644 --- a/src/plugins/score-plugin-gfx/Gfx/GStreamer/GStreamerDevice.cpp +++ b/src/plugins/score-plugin-gfx/Gfx/GStreamer/GStreamerDevice.cpp @@ -53,6 +53,7 @@ struct gstreamer_pipeline AVPixelFormat pixfmt{AV_PIX_FMT_NONE}; int channels{}; int rate{}; + std::function on_format_change; }; std::vector appsinks; @@ -450,6 +451,33 @@ struct gstreamer_pipeline continue; } + if(info.is_video) + { + if(GstCaps* caps = gst.sample_get_caps(sample)) + { + int new_w = info.width; + int new_h = info.height; + AVPixelFormat new_pf = info.pixfmt; + if(GstStructure* s = gst.caps_get_structure(caps, 0)) + { + AppsinkInfo probed = info; + parse_video_caps(gst, s, probed); + new_w = probed.width; + new_h = probed.height; + new_pf = probed.pixfmt; + } + if(new_w != info.width || new_h != info.height + || new_pf != info.pixfmt) + { + info.width = new_w; + info.height = new_h; + info.pixfmt = new_pf; + if(info.on_format_change) + info.on_format_change(new_w, new_h, new_pf); + } + } + } + GstMapInfo map_info{}; if(gst.buffer_map(buffer, &map_info, GST_MAP_READ)) { @@ -549,6 +577,17 @@ class gstreamer_video_decoder : public ::Video::ExternalInput AVFrame* dequeue_frame() noexcept override { return queue->dequeue(); } void release_frame(AVFrame* frame) noexcept override { queue->release(frame); } + + // Mid-stream caps change: drain stale frames before the renderer + // dequeues, then commit the metadata update. + bool notifyFormatChange( + int new_w, int new_h, AVPixelFormat new_pixfmt) noexcept override + { + if(new_w == width && new_h == height && new_pixfmt == pixel_format) + return false; + queue->drain(); + return ::Video::ExternalInput::notifyFormatChange(new_w, new_h, new_pixfmt); + } }; class gstreamer_audio_parameter final : public ossia::audio_parameter @@ -672,6 +711,13 @@ class gstreamer_device : public ossia::net::device_base decoder->height = sink.height; decoder->fps = 30; + std::weak_ptr dec_weak{decoder}; + sink.on_format_change + = [dec_weak](int w, int h, AVPixelFormat f) { + if(auto d = dec_weak.lock()) + d->notifyFormatChange(w, h, f); + }; + root.add_child(std::make_unique( new score::gfx::CameraNode(decoder), &ctx, *this, sink.name)); diff --git a/src/plugins/score-plugin-gfx/Gfx/GStreamer/GStreamerOutputDevice.cpp b/src/plugins/score-plugin-gfx/Gfx/GStreamer/GStreamerOutputDevice.cpp index 756db583a4..e248bf6df4 100644 --- a/src/plugins/score-plugin-gfx/Gfx/GStreamer/GStreamerOutputDevice.cpp +++ b/src/plugins/score-plugin-gfx/Gfx/GStreamer/GStreamerOutputDevice.cpp @@ -13,6 +13,9 @@ #include #include #include +#include +#include +#include #include #include @@ -85,6 +88,7 @@ struct GStreamerOutputNode : score::gfx::OutputNode GstElement* m_audio_src{}; GStreamerSettings m_settings; bool m_started{}; + uint64_t m_video_max_bytes{}; // appsrc queue cap; 0 = disabled std::unique_ptr m_encoder[2]; int m_encoderIdx{}; // ping-pong index for double-buffered encoder QString m_detectedFormat; // UYVY, NV12, I420, or empty for RGBA @@ -125,6 +129,14 @@ struct GStreamerOutputNode : score::gfx::OutputNode qDebug() << "GStreamer output parse error:" << err->message; if(gst.g_error_free) gst.g_error_free(err); + // gst_parse_launch (non-_full) can return a non-NULL *partial* pipeline + // with *error set. Such a pipeline is broken (e.g. missing appsrcs) — + // unref it so we don't leak/retain it and never set it PLAYING. + if(m_pipeline) + { + gst.object_unref(m_pipeline); + m_pipeline = nullptr; + } return false; } if(!m_pipeline) @@ -177,10 +189,29 @@ struct GStreamerOutputNode : score::gfx::OutputNode gst.object_set_property(elem, prop, &gv); gst.value_unset(&gv); }; + auto setUInt64 = [&](GstElement* elem, const char* prop, uint64_t val) { + if(!gst.value_set_uint64) + return; + GValue gv{}; + gst.value_init(&gv, G_TYPE_UINT64); + gst.value_set_uint64(&gv, val); + gst.object_set_property(elem, prop, &gv); + gst.value_unset(&gv); + }; setBool(m_video_src, "is-live", true); setBool(m_video_src, "do-timestamp", true); setInt(m_video_src, "format", 3); // GST_FORMAT_TIME + + // Backpressure: the appsrc default max-bytes is 200000, far below a + // single 1080p RGBA frame (~8 MB). Bound the queue to a few frames so + // RSS can't grow without limit when downstream stalls. We additionally + // drop frames ourselves (see push_video_frame_*) by polling + // current-level-bytes, which gives downstream-leaky behaviour without + // depending on the leaky-type enum GType (not introspectable here) and + // without blocking the render thread. + m_video_max_bytes = (uint64_t)16 * 1024 * 1024; // ~2 frames @1080p RGBA + setUInt64(m_video_src, "max-bytes", m_video_max_bytes); } } @@ -279,6 +310,36 @@ struct GStreamerOutputNode : score::gfx::OutputNode m_started = true; } + // Non-blocking bus poll: surfaces otherwise-silent encoder/filesink/muxer + // errors. Called once per rendered frame; logs the first error then stops + // pushing (m_started=false) so we don't spam or feed a dead pipeline. + void poll_bus_errors() + { + if(!m_pipeline || !m_started) + return; + + auto& gst = libgstreamer::instance(); + if(!gst.element_get_bus || !gst.bus_timed_pop_filtered) + return; + + GstBus* bus = gst.element_get_bus(m_pipeline); + if(!bus) + return; + + // timeout==0 => return immediately if no matching message is queued. + while(GstMessage* msg = gst.bus_timed_pop_filtered( + bus, 0, (GstMessageType)(GST_MESSAGE_ERROR | GST_MESSAGE_WARNING))) + { + qWarning() << "GStreamer output: pipeline error/warning on the bus"; + if(gst.message_unref) + gst.message_unref(msg); + // An ERROR aborts the pipeline; stop feeding it. + m_started = false; + break; + } + gst.object_unref(bus); + } + void stop_pipeline() { if(!m_pipeline || !m_started) @@ -292,6 +353,33 @@ struct GStreamerOutputNode : score::gfx::OutputNode if(m_audio_src && gst.app_src_end_of_stream) gst.app_src_end_of_stream(m_audio_src); + // appsrc EOS is ASYNC: it travels through the pipeline as a buffer would, + // and muxers (mp4mux/matroskamux/...) only finalize the file once EOS + // reaches them. Setting the pipeline to NULL immediately would truncate + // the moov atom / cluster index, producing unplayable files. Wait for the + // EOS (or ERROR) message on the bus, with a bounded timeout so we never + // hang the UI thread on a stuck pipeline. + if(gst.element_get_bus && gst.bus_timed_pop_filtered) + { + if(GstBus* bus = gst.element_get_bus(m_pipeline)) + { + GstMessage* msg = gst.bus_timed_pop_filtered( + bus, 5 * GST_SECOND, + (GstMessageType)(GST_MESSAGE_EOS | GST_MESSAGE_ERROR)); + if(msg) + { + if(gst.message_unref) + gst.message_unref(msg); + } + else + { + qWarning() << "GStreamer output: timed out waiting for EOS; " + "output file may be truncated"; + } + gst.object_unref(bus); + } + } + gst.element_set_state(m_pipeline, GST_STATE_NULL); m_started = false; } @@ -309,12 +397,35 @@ struct GStreamerOutputNode : score::gfx::OutputNode } } + // Downstream-leaky backpressure: if appsrc's queued bytes already exceed the + // configured budget, drop this frame instead of growing RSS without bound. + // Reading current-level-bytes (guint64) is cheap and lock-free in appsrc. + bool video_queue_full() const + { + if(m_video_max_bytes == 0 || !m_video_src) + return false; + + auto& gst = libgstreamer::instance(); + if(!gst.object_get_property || !gst.value_init || !gst.value_unset + || !gst.value_get_uint64) + return false; + + GValue gv{}; + gst.value_init(&gv, G_TYPE_UINT64); + gst.object_get_property(m_video_src, "current-level-bytes", &gv); + uint64_t level = gst.value_get_uint64(&gv); + gst.value_unset(&gv); + return level >= m_video_max_bytes; + } + // Zero-copy push: takes a shallow copy of the QByteArray. // The QByteArray's refcount keeps the data alive until GStreamer is done. void push_video_frame_zerocopy(QByteArray data) { if(!m_video_src || !m_started) return; + if(video_queue_full()) + return; // drop: downstream can't keep up auto& gst = libgstreamer::instance(); if(!gst.buffer_new_wrapped_full) @@ -405,6 +516,9 @@ struct GStreamerOutputNode : score::gfx::OutputNode if(!renderer || !m_renderState) return; + // Surface any silent pipeline errors (encoder/filesink/muxer failures). + poll_bus_errors(); + auto rhi = m_renderState->rhi; QRhiCommandBuffer* cb{}; if(rhi->beginOffscreenFrame(&cb) != QRhi::FrameOpSuccess) @@ -492,22 +606,34 @@ struct GStreamerOutputNode : score::gfx::OutputNode void createOutput(score::gfx::OutputConfiguration conf) override { - m_renderState = std::make_shared(); - - m_renderState->surface = QRhiGles2InitParams::newFallbackSurface(); - QRhiGles2InitParams params; - params.fallbackSurface = m_renderState->surface; - score::GLCapabilities caps; - caps.setupFormat(params.format); - m_renderState->rhi = QRhi::create(QRhi::OpenGLES2, ¶ms, {}); - m_renderState->renderSize = QSize(m_settings.width, m_settings.height); + m_renderState = score::gfx::createRenderState( + conf.graphicsApi, QSize(m_settings.width, m_settings.height), nullptr); + if(!m_renderState || !m_renderState->rhi) + { + qWarning() << "GStreamerOutputNode: failed to create QRhi"; + m_renderState.reset(); + return; + } m_renderState->outputSize = m_renderState->renderSize; - m_renderState->api = score::gfx::GraphicsApi::OpenGL; - m_renderState->version = caps.qShaderVersion; auto rhi = m_renderState->rhi; + + // init_pipeline() negotiates with GStreamer and fills m_detectedFormat, so + // the scene render-target format (which depends on whether the output is + // 10-bit) must be chosen AFTER it. + const bool pipeline_ok = init_pipeline(); + if(!pipeline_ok) + qWarning() << "GStreamerOutputNode: pipeline init failed; output disabled"; + + // 10-bit output (packed v210, planar I422_10LE, or semi-planar P010_10LE) + // needs a >8-bit scene render target for real precision; else RGBA8. + const bool is_v210 = (m_detectedFormat == "v210"); + const bool tenBit = is_v210 || (m_detectedFormat == "I422_10LE") + || (m_detectedFormat == "P010_10LE"); + m_renderState->renderFormat + = tenBit ? QRhiTexture::RGBA16F : QRhiTexture::RGBA8; m_texture = rhi->newTexture( - QRhiTexture::RGBA8, m_renderState->renderSize, 1, + m_renderState->renderFormat, m_renderState->renderSize, 1, QRhiTexture::RenderTarget | QRhiTexture::UsedAsTransferSource); m_texture->create(); m_renderTarget = rhi->newTextureRenderTarget({m_texture}); @@ -517,10 +643,8 @@ struct GStreamerOutputNode : score::gfx::OutputNode m_renderState->renderPassDescriptor); m_renderTarget->create(); - init_pipeline(); - // Create GPU encoder if a YUV target format was detected - if(!m_detectedFormat.isEmpty() && rhi) + if(pipeline_ok && !m_detectedFormat.isEmpty() && rhi) { auto makeEncoder = [&]() -> std::unique_ptr { if(m_detectedFormat == "UYVY" || m_detectedFormat == "YUY2") @@ -529,6 +653,12 @@ struct GStreamerOutputNode : score::gfx::OutputNode return std::make_unique(); else if(m_detectedFormat == "I420" || m_detectedFormat == "YV12") return std::make_unique(); + else if(m_detectedFormat == "v210") + return std::make_unique(); + else if(m_detectedFormat == "I422_10LE") + return std::make_unique(); + else if(m_detectedFormat == "P010_10LE") + return std::make_unique(); return nullptr; }; @@ -538,6 +668,32 @@ struct GStreamerOutputNode : score::gfx::OutputNode if(m_encoder[0] && m_encoder[1]) { + // Stride alignment: QRhi reads textures back with TIGHTLY packed rows, + // but GStreamer's default GstVideoInfo strides are GST_ROUND_UP_4. For + // the planar/semi-planar YUV formats the two only agree when each plane + // row is already a multiple of 4: + // I420: Y stride = width, chroma stride = width/2 -> need width%8==0 + // NV12: Y stride = width, UV stride = width -> need width%4==0 + // UYVY: stride = width*2 (4:2:2 macropixels) -> need width%2==0 + // height must be even for 4:2:0 vertical subsampling. We round DOWN so + // we never sample past the rendered texture, and feed the SAME aligned + // dimensions to both the encoder and the negotiated caps so the tight + // readback matches GStreamer's expected (now no-op ROUND_UP_4) strides. + // v210 packs 6-px groups into a 128-byte-aligned row, so width must be + // a multiple of 48 for the tight readback to match GStreamer's v210 + // stride (((w+47)/48)*128); it has no vertical subsampling. Other + // formats: mult-of-8 width (covers 4:2:2 / 4:2:0) and even height. + // v210 packs 6-px groups into 128-byte rows (width % 48). Other + // formats need mult-of-8 width. Even height covers 4:2:0 vertical + // subsampling (P010 / NV12 / I420) and is harmless for 4:2:2. + const int enc_w = is_v210 ? std::max(48, (m_settings.width / 48) * 48) + : std::max(8, m_settings.width & ~7); + const int enc_h = std::max(2, m_settings.height & ~1); + if(enc_w != m_settings.width || enc_h != m_settings.height) + qDebug() << "GStreamer output: aligning" << m_detectedFormat + << "from" << m_settings.width << "x" << m_settings.height + << "to" << enc_w << "x" << enc_h << "for packed strides"; + auto input_trc = static_cast(m_settings.input_transfer); auto colorShader = colorShaderFromColorimetry(m_detectedColorimetry, input_trc); qDebug() << "GStreamer output: GPU encoder" @@ -546,9 +702,9 @@ struct GStreamerOutputNode : score::gfx::OutputNode << "inputTrc=" << m_settings.input_transfer << "shaderLen=" << colorShader.size(); m_encoder[0]->init(*rhi, *m_renderState, m_texture, - m_settings.width, m_settings.height, colorShader); + enc_w, enc_h, colorShader); m_encoder[1]->init(*rhi, *m_renderState, m_texture, - m_settings.width, m_settings.height, colorShader); + enc_w, enc_h, colorShader); // Update appsrc caps to match the encoder's output format if(auto& gst = libgstreamer::instance(); @@ -556,8 +712,8 @@ struct GStreamerOutputNode : score::gfx::OutputNode { auto capsStr = QString("video/x-raw,format=%1,width=%2,height=%3,framerate=%4/1") .arg(m_detectedFormat) - .arg(m_settings.width) - .arg(m_settings.height) + .arg(enc_w) + .arg(enc_h) .arg(m_settings.rate); if(auto* caps = gst.caps_from_string(capsStr.toStdString().c_str())) { @@ -582,6 +738,38 @@ struct GStreamerOutputNode : score::gfx::OutputNode } } cleanup_pipeline(); + + // Reset per-instance frame/encoder state so a subsequent createOutput() + // (re-create on settings change) starts clean instead of reusing a stale + // readback, ping-pong index, detected format or dangling renderer pointer. + m_currentReadback = &m_readback[0]; + m_readback[0] = {}; + m_readback[1] = {}; + m_encoderIdx = 0; + m_detectedFormat.clear(); + m_detectedColorimetry.clear(); + m_inv_y_renderer = nullptr; + m_video_max_bytes = 0; + + if(!m_renderState) + return; + + // Persist-across-rebuild contract: registry survives RL teardown, + // so we tear down its QRhi resources here BEFORE + // RenderState::destroy() (called below) frees the device. + releaseRegistry(); + + delete m_renderTarget; + m_renderTarget = nullptr; + + delete m_renderState->renderPassDescriptor; + m_renderState->renderPassDescriptor = nullptr; + + delete m_texture; + m_texture = nullptr; + + m_renderState->destroy(); + m_renderState.reset(); } std::shared_ptr renderState() const override diff --git a/src/plugins/score-plugin-gfx/Gfx/GeometryFilter/Process.cpp b/src/plugins/score-plugin-gfx/Gfx/GeometryFilter/Process.cpp index 4624286cd9..001e1ac064 100644 --- a/src/plugins/score-plugin-gfx/Gfx/GeometryFilter/Process.cpp +++ b/src/plugins/score-plugin-gfx/Gfx/GeometryFilter/Process.cpp @@ -324,8 +324,17 @@ void Model::setupIsf(const isf::descriptor& desc) alternatives.emplace_back("2", 2); } + // ComboBox::init expects the VALUE that should be initially selected, + // not an index. libisf stores `v.def` as the INDEX into values. + // Pass the alternative's value at v.def so the widget initialises + // to the author-intended entry instead of falling back to + // alternatives[0]. Same fix as CSF/Process.cpp. + const std::size_t def_idx + = std::min(v.def, alternatives.size() - 1); + const ossia::value& init_value = alternatives[def_idx].second; + auto port = new Process::ComboBox( - std::move(alternatives), (int)v.def, QString::fromStdString(input.name), + std::move(alternatives), init_value, QString::fromStdString(input.name), Id(i), &self); self.m_inlets.push_back(port); @@ -456,7 +465,9 @@ void Model::setupIsf(const isf::descriptor& desc) // They're managed by the system, so we don't create a UI control return nullptr; } - + + Process::Inlet* operator()(const uniform_input& v) { return nullptr; } + Process::Inlet* operator()(const texture_input& v) { auto port = new Gfx::TextureInlet( diff --git a/src/plugins/score-plugin-gfx/Gfx/GfxContext.cpp b/src/plugins/score-plugin-gfx/Gfx/GfxContext.cpp index 5fc416fffe..86c728aeb5 100644 --- a/src/plugins/score-plugin-gfx/Gfx/GfxContext.cpp +++ b/src/plugins/score-plugin-gfx/Gfx/GfxContext.cpp @@ -10,6 +10,8 @@ #include #include + +#include #include #include @@ -36,6 +38,10 @@ GfxContext::GfxContext(const score::DocumentContext& ctx) &GfxContext::recompute_graph); m_graph = new score::gfx::Graph; + // Hand the session-wide AssetTable down to the Graph so every + // RenderList it creates can participate in content-hash decode + // dedup. Plan 09 S1: one decode per asset per session, N uploads. + m_graph->setAssetTable(&m_assets); double rate = m_context.app.settings().getRate(); rate = qBound(1.0, rate, 1000.); @@ -61,6 +67,14 @@ GfxContext::~GfxContext() m_thread.wait(); #endif + // Stop all timers before destroying the graph and nodes, + // to prevent timer callbacks from accessing stale pointers. + m_manualTimers.clear(); + m_no_vsync_timer = nullptr; + m_watchdog_timer = nullptr; + std::destroy_at(&m_timers); + std::construct_at(&m_timers); + delete m_graph; } @@ -122,63 +136,79 @@ void GfxContext::disconnect_preview_node(EdgeSpec e) void GfxContext::add_edge(EdgeSpec edge) { auto source_node_it = this->nodes.find(edge.first.node); - if(source_node_it != this->nodes.end()) - { - auto sink_node_it = this->nodes.find(edge.second.node); - if(sink_node_it != this->nodes.end()) - { - assert(source_node_it->second); - assert(sink_node_it->second); - - auto& source_ports = source_node_it->second->output; - auto& sink_ports = sink_node_it->second->input; - - SCORE_ASSERT(source_ports.size() > 0); - SCORE_ASSERT(sink_ports.size() > 0); - SCORE_ASSERT(source_ports.size() > edge.first.port); - SCORE_ASSERT(sink_ports.size() > edge.second.port); - auto source_port = source_ports[edge.first.port]; - auto sink_port = sink_ports[edge.second.port]; - - m_graph->addEdge(source_port, sink_port, edge.type); - } - } + if(source_node_it == this->nodes.end()) + return; + auto sink_node_it = this->nodes.find(edge.second.node); + if(sink_node_it == this->nodes.end()) + return; + if(!source_node_it->second || !sink_node_it->second) + return; + + auto& source_ports = source_node_it->second->output; + auto& sink_ports = sink_node_it->second->input; + + // Silently drop malformed edges. A live-coded or half-wired patch can + // produce an edge whose declared port index doesn't exist on either side + // (e.g. a shader that parses to zero input ports but the script still + // issued a `connect(..., 0, consumer, 0)`). Aborting the whole renderer + // on a script-level wiring mistake is not an option — drop the edge and + // keep rendering. + if(edge.first.port >= source_ports.size() + || edge.second.port >= sink_ports.size()) + return; + + m_graph->addEdge(source_ports[edge.first.port], sink_ports[edge.second.port], + edge.type); } void GfxContext::remove_edge(EdgeSpec edge) { auto source_node_it = this->nodes.find(edge.first.node); - if(source_node_it != this->nodes.end()) - { - auto sink_node_it = this->nodes.find(edge.second.node); - if(sink_node_it != this->nodes.end()) - { - assert(source_node_it->second); - assert(sink_node_it->second); - - auto source_port = source_node_it->second->output[edge.first.port]; - auto sink_port = sink_node_it->second->input[edge.second.port]; - - m_graph->removeEdge(source_port, sink_port); - } - } + if(source_node_it == this->nodes.end()) + return; + auto sink_node_it = this->nodes.find(edge.second.node); + if(sink_node_it == this->nodes.end()) + return; + if(!source_node_it->second || !sink_node_it->second) + return; + + auto& source_ports = source_node_it->second->output; + auto& sink_ports = sink_node_it->second->input; + if(edge.first.port >= source_ports.size() + || edge.second.port >= sink_ports.size()) + return; + + m_graph->removeEdge(source_ports[edge.first.port], + sink_ports[edge.second.port]); } void GfxContext::recompute_edges() { m_graph->clearEdges(); - for(auto edge : edges) + // Snapshot under lock: writer in updateGraph reassigns `edges` under + // edges_lock on the render-driving thread, while this can be invoked from + // settings-change signals on the UI thread. Iterating the live container + // would race with that reassignment. + ossia::flat_set edges_snapshot; + ossia::flat_set preview_snapshot; + { + std::lock_guard l{edges_lock}; + edges_snapshot = edges; + preview_snapshot = preview_edges; + } + + for(auto edge : edges_snapshot) { add_edge(edge); } - for(auto edge : preview_edges) + for(auto edge : preview_snapshot) { add_edge(edge); } } -void GfxContext::recompute_graph() +void GfxContext::recomputeTimers() { // Clear previous timers std::destroy_at(&m_timers); @@ -195,15 +225,10 @@ void GfxContext::recompute_graph() output->setVSyncCallback({}); } - // Recreate the graph - recompute_edges(); - auto& settings = m_context.app.settings(); - const double settings_rate = m_context.app.settings().getRate(); + const double settings_rate = settings.getRate(); const auto api = settings.graphicsApiEnum(); - m_graph->createAllRenderLists(api); - // Recreate new timers const bool vsync = settings.getVSync() && m_graph->canDoVSync(); @@ -274,6 +299,24 @@ void GfxContext::recompute_graph() } } +void GfxContext::recomputeGraphTopology() +{ + recompute_edges(); + + auto& settings = m_context.app.settings(); + const auto api = settings.graphicsApiEnum(); + + m_graph->createAllRenderLists(api); +} + +void GfxContext::recompute_graph() +{ + // Topology first: refreshes m_graph->outputs() which recomputeTimers reads. + // Must run before timers because recomputeTimers iterates outputs(). + recomputeGraphTopology(); + recomputeTimers(); +} + void GfxContext::add_preview_output(score::gfx::OutputNode& node) { auto& settings = m_context.app.settings(); @@ -296,12 +339,131 @@ void GfxContext::add_preview_output(score::gfx::OutputNode& node) void GfxContext::recompute_connections() { recompute_graph(); - // FIXME for more performance - /* - recompute_edges(); - // m_graph->setupOutputs(m_api); - m_graph->relinkGraph(); - */ +} + +void GfxContext::incrementalEdgeUpdate( + const ossia::flat_set& old_edges, + const ossia::flat_set& cur_edges) +{ + // Compute diff + std::vector removed; + std::vector added; + + std::set_difference( + old_edges.begin(), old_edges.end(), + cur_edges.begin(), cur_edges.end(), + std::back_inserter(removed)); + + std::set_difference( + cur_edges.begin(), cur_edges.end(), + old_edges.begin(), old_edges.end(), + std::back_inserter(added)); + + // Pre-compute the set of sink ports that will be fed by an incoming edge + // in this same batch. Handing that set to onEdgeRemoved prevents the + // "remove A→B, add F→B" sequence from destroying B's input RT in the + // gap between the two, which was pure churn when the old and new feeds + // share a sink port (classic filter insertion). Reconcile reallocates + // RTs only when the slot is empty, so preserving the existing RT lets + // the new pass slot straight into place. Source: Graph.cpp + // createPassForEdgeIfMissing already treats a present RT as valid + // regardless of the edge that produced it. + ossia::hash_set preserveSinks; + preserveSinks.reserve(added.size()); + for(auto& spec : added) + { + auto sink_it = nodes.find(spec.second.node); + if(sink_it == nodes.end()) + continue; + // EdgeSpecs are script-supplied: guard against null nodes and + // out-of-range port indices before indexing, exactly as + // add_edge/remove_edge do. An OOB std::vector access is UB, not a + // catchable exception, so the try/catch around the caller cannot + // save us here. + if(!sink_it->second) + continue; + auto& sink_ports = sink_it->second->input; + if(spec.second.port >= sink_ports.size()) + continue; + preserveSinks.insert(sink_ports[spec.second.port]); + } + + // Process removals first (while edge objects still exist). + for(auto& spec : removed) + { + auto source_it = nodes.find(spec.first.node); + auto sink_it = nodes.find(spec.second.node); + if(source_it == nodes.end() || sink_it == nodes.end()) + continue; + if(!source_it->second || !sink_it->second) + continue; + + auto& source_ports = source_it->second->output; + auto& sink_ports = sink_it->second->input; + if(spec.first.port >= source_ports.size() + || spec.second.port >= sink_ports.size()) + continue; + + auto* source_port = source_ports[spec.first.port]; + auto* sink_port = sink_ports[spec.second.port]; + + // Find the actual Edge object + score::gfx::Edge* edge = nullptr; + for(auto* e : source_port->edges) + { + if(e->sink == sink_port) + { + edge = e; + break; + } + } + + if(edge) + { + // Notify graph BEFORE destroying the edge + m_graph->onEdgeRemoved(*edge, &preserveSinks); + m_graph->removeEdge(source_port, sink_port); + } + } + + // Process additions: first create all edge objects in the graph, + // then reconcile render lists in one pass. Processing edges one + // at a time doesn't work because edge ordering creates dependencies + // (e.g. edge A->B is skipped because B isn't in the RL yet, then + // edge B->C brings B into the RL, but A never gets a renderer). + for(auto& spec : added) + { + auto source_it = nodes.find(spec.first.node); + auto sink_it = nodes.find(spec.second.node); + if(source_it == nodes.end() || sink_it == nodes.end()) + continue; + if(!source_it->second || !sink_it->second) + continue; + + auto& source_ports = source_it->second->output; + auto& sink_ports = sink_it->second->input; + if(spec.first.port >= source_ports.size() + || spec.second.port >= sink_ports.size()) + continue; + + auto* source_port = source_ports[spec.first.port]; + auto* sink_port = sink_ports[spec.second.port]; + + m_graph->addEdge(source_port, sink_port, spec.type); + } + + // Reconcile: ensure all reachable nodes have renderers and passes. + // This handles NEW nodes (creates renderers + passes for all their edges). + if(!added.empty() || !removed.empty()) + m_graph->reconcileAllRenderLists(); + + // Create missing passes and update samplers for ALL edges in the graph, + // not just the newly-added ones. When a node becomes reachable through a + // new edge (e.g. filter→Grid makes filter reachable), pre-existing edges + // TO that node (e.g. A→filter) also need passes created. Checking only + // the diff misses these. + m_graph->createAllMissingPasses(); + m_graph->updateAllSinkSamplers(); } void GfxContext::update_inputs() @@ -328,13 +490,17 @@ void GfxContext::update_inputs() void GfxContext::remove_node( std::vector>& nursery, int32_t index) { - // Remove all edges involving that node - for(auto it = this->edges.begin(); it != this->edges.end();) + // Remove all edges involving that node. recompute_edges snapshots + // `edges` under edges_lock, so take it here too while mutating. { - if(it->first.node == index || it->second.node == index) - it = this->edges.erase(it); - else - ++it; + std::lock_guard l{edges_lock}; + for(auto it = this->edges.begin(); it != this->edges.end();) + { + if(it->first.node == index || it->second.node == index) + it = this->edges.erase(it); + else + ++it; + } } if(auto node_it = nodes.find(index); node_it != nodes.end()) @@ -392,7 +558,11 @@ void GfxContext::run_commands() case NodeCommand::ADD_NODE: { m_graph->addNode(cmd.node.get()); nodes[cmd.index] = {std::move(cmd.node)}; - recompute = true; + // Only output nodes require a full rebuild (new window/timer). + // Non-output nodes just wait for edges — the incremental + // reconciliation path creates their renderers when connected. + if(dynamic_cast(nodes[cmd.index].get())) + recompute = true; break; } case NodeCommand::REMOVE_PREVIEW_NODE: { @@ -400,13 +570,27 @@ void GfxContext::run_commands() auto n = dynamic_cast(node.get()); SCORE_ASSERT(n); { - auto it = ossia::find_if(this->preview_edges, [idx = cmd.index](EdgeSpec e) { - return e.second.node == idx; - }); - if(it != this->preview_edges.end()) + // recompute_edges snapshots preview_edges under edges_lock, + // so guard reads/mutations of it here too. remove_edge only + // touches m_graph, so keep it outside the lock. + EdgeSpec to_remove; + bool found = false; + { + std::lock_guard l{edges_lock}; + auto it = ossia::find_if(this->preview_edges, [idx = cmd.index](EdgeSpec e) { + return e.second.node == idx; + }); + if(it != this->preview_edges.end()) + { + to_remove = *it; + found = true; + } + } + if(found) { - this->remove_edge(*it); - this->preview_edges.erase(*it); + this->remove_edge(to_remove); + std::lock_guard l{edges_lock}; + this->preview_edges.erase(to_remove); } } m_graph->destroyOutputRenderList(*n); @@ -414,8 +598,27 @@ void GfxContext::run_commands() break; } case NodeCommand::REMOVE_NODE: { - remove_node(nursery, cmd.index); - recompute = true; + if(auto node_it = nodes.find(cmd.index); node_it != nodes.end()) + { + bool is_output = dynamic_cast(node_it->second.get()); + if(!is_output) + { + // Incremental removal: clean up edges, renderers, retopo sort. + // Must happen BEFORE remove_node deletes the node. + m_graph->removeNodeAndEdges(node_it->second.get()); + } + remove_node(nursery, cmd.index); + if(is_output) + { + // Recompute immediately so subsequent commands in this tick + // see a consistent graph state. Deferring until the end of + // the loop leaves the graph half-broken (node gone from + // m_nodes but renderer/output still wired) for any further + // commands or render frames that fire in this window. + recompute_graph(); + m_fullRebuildThisFrame = true; + } + } break; } case NodeCommand::RELINK: { @@ -430,12 +633,18 @@ void GfxContext::run_commands() switch(cmd.cmd) { case EdgeCommand::CONNECT_PREVIEW_NODE: { - this->preview_edges.emplace(cmd.edge); + { + std::lock_guard l{edges_lock}; + this->preview_edges.emplace(cmd.edge); + } add_edge(cmd.edge); break; } case EdgeCommand::DISCONNECT_PREVIEW_NODE: { - this->preview_edges.erase(cmd.edge); + { + std::lock_guard l{edges_lock}; + this->preview_edges.erase(cmd.edge); + } remove_edge(cmd.edge); break; } @@ -452,6 +661,11 @@ void GfxContext::run_commands() if(recompute) { recompute_graph(); + // Signal to updateGraph() that a full rebuild happened this frame. + // The incremental edge path should NOT run after a full rebuild, + // because the graph was just rebuilt with the old edge set and + // applying an incremental diff would result in a half-built state. + m_fullRebuildThisFrame = true; } // This will force the nodes to be deleted in the main thread a bit later @@ -470,14 +684,49 @@ void GfxContext::updateGraph() update_inputs(); - if(edges_changed) + // Clear the flag BEFORE copying new_edges so a producer that publishes a + // fresh edge set after our copy (and re-sets the flag) cannot have its + // signal lost: the worst case is one redundant reprocess next tick, never + // a dropped update. Clearing it after the copy (the previous behaviour) + // could clobber a set-after-copy and, with prev_edges dedup on the + // producer side, that update would never be re-sent. + if(edges_changed.exchange(false)) { + ossia::flat_set old_edges; + ossia::flat_set cur_edges; { std::lock_guard l{edges_lock}; - std::swap(edges, new_edges); + old_edges = edges; + edges = new_edges; + cur_edges = edges; + } + + // If a full rebuild happened this frame (nodes added/removed), + // use the nuclear path for edges too. The incremental path + // doesn't work correctly after a full rebuild because the graph + // was rebuilt with the old edge set. + if(m_fullRebuildThisFrame) + { + m_fullRebuildThisFrame = false; + recompute_connections(); + return; + } + // Incremental edge update: apply the diff between old and new edges. + try + { + incrementalEdgeUpdate(old_edges, cur_edges); + } + catch(const std::exception& e) + { + qWarning("Incremental edge update failed (%s), falling back to full rebuild", + e.what()); + recompute_connections(); + } + catch(...) + { + qWarning("Incremental edge update failed, falling back to full rebuild"); + recompute_connections(); } - recompute_connections(); - edges_changed = false; } } @@ -497,7 +746,8 @@ void GfxContext::on_manual_timer(score::HighResolutionTimer* self) if(auto ptr = m_manualTimers.find(self); ptr != m_manualTimers.end()) { for(auto output : ptr->second) { - output->render(); + if(output && output->canRender()) + output->render(); } } } diff --git a/src/plugins/score-plugin-gfx/Gfx/GfxContext.hpp b/src/plugins/score-plugin-gfx/Gfx/GfxContext.hpp index 7422bd1212..d2d104b795 100644 --- a/src/plugins/score-plugin-gfx/Gfx/GfxContext.hpp +++ b/src/plugins/score-plugin-gfx/Gfx/GfxContext.hpp @@ -1,6 +1,7 @@ #pragma once #include +#include #include #include @@ -75,6 +76,11 @@ class SCORE_PLUGIN_GFX_EXPORT GfxContext : public QObject void recompute_edges(); void recompute_graph(); void recompute_connections(); + void recomputeTimers(); + void recomputeGraphTopology(); + void incrementalEdgeUpdate( + const ossia::flat_set& old_edges, + const ossia::flat_set& cur_edges); void update_inputs(); void updateGraph(); @@ -84,6 +90,18 @@ class SCORE_PLUGIN_GFX_EXPORT GfxContext : public QObject tick_messages.enqueue(std::move(msg)); } + /** + * @brief Session-wide content-hash decode cache. + * + * Shared across all RenderLists in this GfxContext. Loaders stage + * decoded bytes here on their worker thread; downstream consumers + * (texture upload, mesh VB/IB assembly) acquire by content hash, + * avoiding re-decoding the same source asset across multiple outputs + * or reloads. See Gfx/AssetTable.hpp. + */ + AssetTable& assets() noexcept { return m_assets; } + const AssetTable& assets() const noexcept { return m_assets; } + private: void run_commands(); void add_preview_output(score::gfx::OutputNode& out); @@ -132,9 +150,10 @@ class SCORE_PLUGIN_GFX_EXPORT GfxContext : public QObject std::mutex edges_lock; ossia::flat_set new_edges TS_GUARDED_BY(edges_lock); - ossia::flat_set edges; - ossia::flat_set preview_edges; + ossia::flat_set edges TS_GUARDED_BY(edges_lock); + ossia::flat_set preview_edges TS_GUARDED_BY(edges_lock); std::atomic_bool edges_changed{}; + bool m_fullRebuildThisFrame{}; score::HighResolutionTimer* m_no_vsync_timer{}; score::HighResolutionTimer* m_watchdog_timer{}; @@ -143,6 +162,8 @@ class SCORE_PLUGIN_GFX_EXPORT GfxContext : public QObject ossia::object_pool> m_buffers; + AssetTable m_assets; + score::Timers m_timers; }; diff --git a/src/plugins/score-plugin-gfx/Gfx/GfxDevice.cpp b/src/plugins/score-plugin-gfx/Gfx/GfxDevice.cpp index 8a152e0a8c..744dbdec13 100644 --- a/src/plugins/score-plugin-gfx/Gfx/GfxDevice.cpp +++ b/src/plugins/score-plugin-gfx/Gfx/GfxDevice.cpp @@ -2,6 +2,7 @@ #include "GfxParameter.hpp" +#include #include #include diff --git a/src/plugins/score-plugin-gfx/Gfx/Graph/BackgroundNode.hpp b/src/plugins/score-plugin-gfx/Gfx/Graph/BackgroundNode.hpp index 24dd9c4675..51b9f9e787 100644 --- a/src/plugins/score-plugin-gfx/Gfx/Graph/BackgroundNode.hpp +++ b/src/plugins/score-plugin-gfx/Gfx/Graph/BackgroundNode.hpp @@ -5,6 +5,10 @@ #include #include #include +#include + +#include +#include namespace score::gfx { @@ -21,7 +25,7 @@ struct BackgroundNode : OutputNode m_conf = {.manualRenderingRate = 1000. / settings_rate}; } - virtual ~BackgroundNode() { } + virtual ~BackgroundNode() { destroyOutput(); } void startRendering() override { } void render() override @@ -56,6 +60,12 @@ struct BackgroundNode : OutputNode void createOutput(score::gfx::OutputConfiguration conf) override { m_onResize = conf.onResize; + // Cache the requested graphics API so setSwapchainFormat can rebuild + // through createOutput when the format actually changes (live HDR↔SDR + // toggle). Without this the format setter was inert: m_swapchainFormat + // was updated but the underlying QRhiTexture stayed in its original + // format, silently downgrading HDR to SDR. + m_lastGraphicsApi = conf.graphicsApi; QSize newSz = m_renderSize; if(newSz.width() <= 0 || newSz.height() <= 0) @@ -64,22 +74,38 @@ struct BackgroundNode : OutputNode newSz = QSize{1024, 1024}; m_renderState = score::gfx::createRenderState(conf.graphicsApi, newSz, nullptr); + if(!m_renderState || !m_renderState->rhi) + { + qWarning() << "BackgroundNode: failed to create QRhi"; + m_renderState.reset(); + return; + } m_renderState->outputSize = m_renderState->renderSize; + m_renderState->renderFormat + = (m_swapchainFormat != Gfx::SwapchainFormat::SDR) + ? QRhiTexture::RGBA32F + : QRhiTexture::RGBA8; auto rhi = m_renderState->rhi; m_texture = rhi->newTexture( - QRhiTexture::RGBA8, m_renderState->renderSize, 1, + m_renderState->renderFormat, m_renderState->renderSize, 1, QRhiTexture::RenderTarget | QRhiTexture::UsedAsTransferSource); m_texture->create(); - m_depthBuffer = rhi->newRenderBuffer( - QRhiRenderBuffer::DepthStencil, m_renderState->renderSize, 1); - m_depthBuffer->create(); + // Reverse-Z project rule: depth attachment is D32F (float). Fixed-point + // D24 combined with reverse-Z gives strictly worse precision than + // standard-Z, so we must allocate a float texture here. RenderTarget + // flag is required for attaching as a depth target. + m_depthTexture = rhi->newTexture( + QRhiTexture::D32F, m_renderState->renderSize, 1, + QRhiTexture::RenderTarget); + m_depthTexture->setName("BackgroundNode::m_depthTexture"); + m_depthTexture->create(); QRhiTextureRenderTargetDescription desc; desc.setColorAttachments({QRhiColorAttachment(m_texture)}); - desc.setDepthStencilBuffer(m_depthBuffer); + desc.setDepthTexture(m_depthTexture); m_renderTarget = rhi->newTextureRenderTarget(desc); m_renderState->renderPassDescriptor = m_renderTarget->newCompatibleRenderPassDescriptor(); @@ -93,11 +119,33 @@ struct BackgroundNode : OutputNode { if(m_renderState) { + // Drain the GPU before tearing resources down. Same rationale as + // ScreenNode::destroyOutput: when setSwapchainFormat invokes + // destroyOutput synchronously (C-16 / commit e2afe7874), an + // unfinished cbWrapper from a prior offscreen frame can still be + // referenced by ScenePreprocessor's per-frame copyBuffer + // (C-01 / commit fe146c8de). Recording into that CB after we've + // freed the rhi triggers VUID-vkCmdCopyBuffer-commandBuffer- + // recording and a device loss. Mirrors MultiWindowNode.cpp:1068. + if(m_renderState->rhi) + { + // Pre-condition: destroyOutput must not be called inside a + // frame. Mirrors ScreenNode::destroyOutput. + SCORE_ASSERT(!m_renderState->rhi->isRecordingFrame()); + m_renderState->rhi->finish(); + } + + // Persist-across-rebuild contract: the registry survives RL + // teardown, so we must release its QRhi resources here BEFORE + // RenderState::destroy() tears down the QRhi. destroyOwned() + // `delete`s the wrappers directly while the device is alive. + releaseRegistry(); + delete m_renderTarget; m_renderTarget = nullptr; - delete m_depthBuffer; - m_depthBuffer = nullptr; + delete m_depthTexture; + m_depthTexture = nullptr; delete m_texture; m_texture = nullptr; @@ -109,7 +157,39 @@ struct BackgroundNode : OutputNode m_renderState.reset(); } } - void updateGraphicsAPI(GraphicsApi) override { } + void updateGraphicsAPI(GraphicsApi api) override + { + if(!m_renderState) + return; + if(m_renderState->api != api) + destroyOutput(); + } + + void setSwapchainFormat(Gfx::SwapchainFormat format) + { + if(m_swapchainFormat == format) + return; + m_swapchainFormat = format; + + // Live format change while rendering: the existing m_texture was + // allocated at createOutput-time with the prior format. setFormat alone + // wouldn't re-allocate the GPU memory backing — only setPixelSize + + // recreate-via-resize does. Re-route through destroyOutput + + // createOutput so the renderTarget / RPD / depth tex / colour tex all + // come back in matching format. Skipped before any output exists + // (m_renderState null) — createOutput will pick up the new format + // naturally via m_swapchainFormat. + if(m_renderState) + { + score::gfx::OutputConfiguration conf; + conf.graphicsApi = m_lastGraphicsApi; + conf.onResize = m_onResize; + destroyOutput(); + createOutput(std::move(conf)); + if(m_onResize) + m_onResize(); + } + } void setSize(QSize newSz) { @@ -143,24 +223,38 @@ struct BackgroundNode : OutputNode auto rhi = m_renderState->rhi; + // Drain the GPU before destroying m_renderTarget / m_texture / + // m_depthTexture. Same anti-pattern that destroyOutput already + // avoids via FIX-A: the current frame's offscreen CB (or a + // queued one) may still reference these resources, and Qt's + // setPixelSize+create dance below does not internally drain. + // Without this, validation fires on the next vkCmd*-recording + // (-recording / -commandBuffer-recording / -in-use) and may + // device-loss. + rhi->finish(); + m_renderTarget->destroy(); m_texture->destroy(); m_texture->setPixelSize(newSz); m_texture->create(); - if(m_depthBuffer) - m_depthBuffer->destroy(); + if(m_depthTexture) + m_depthTexture->destroy(); else - m_depthBuffer = rhi->newRenderBuffer(QRhiRenderBuffer::DepthStencil, newSz); - m_depthBuffer->setPixelSize(newSz); - m_depthBuffer->create(); - - delete m_renderTarget; - delete m_renderState->renderPassDescriptor; + m_depthTexture = rhi->newTexture( + QRhiTexture::D32F, newSz, 1, QRhiTexture::RenderTarget); + m_depthTexture->setPixelSize(newSz); + m_depthTexture->create(); + + m_renderTarget->deleteLater(); + if(auto* rpd = m_renderState->renderPassDescriptor) + rpd->deleteLater(); + m_renderState->renderPassDescriptor = nullptr; + m_renderTarget = nullptr; QRhiTextureRenderTargetDescription desc; desc.setColorAttachments({QRhiColorAttachment(m_texture)}); - desc.setDepthStencilBuffer(m_depthBuffer); + desc.setDepthTexture(m_depthTexture); m_renderTarget = rhi->newTextureRenderTarget(desc); m_renderState->renderPassDescriptor = m_renderTarget->newCompatibleRenderPassDescriptor(); @@ -179,7 +273,8 @@ struct BackgroundNode : OutputNode score::gfx::TextureRenderTarget rt{ .texture = m_texture, .renderPass = m_renderState->renderPassDescriptor, - .renderTarget = m_renderTarget}; + .renderTarget = m_renderTarget, + .depthTexture = m_depthTexture}; return new Gfx::InvertYRenderer{ *this, rt, const_cast(*shared_readback)}; } @@ -193,12 +288,17 @@ struct BackgroundNode : OutputNode std::weak_ptr m_renderer{}; QRhiTexture* m_texture{}; - QRhiRenderBuffer* m_depthBuffer{}; + QRhiTexture* m_depthTexture{}; QRhiTextureRenderTarget* m_renderTarget{}; std::shared_ptr m_renderState{}; std::function m_onResize; QSize m_size{1024, 1024}; QSize m_renderSize{}; + Gfx::SwapchainFormat m_swapchainFormat{}; + // Cached graphics API from the last createOutput so setSwapchainFormat + // can route a live format change through destroyOutput + createOutput + // without having to re-derive it from the host. + GraphicsApi m_lastGraphicsApi{}; }; } diff --git a/src/plugins/score-plugin-gfx/Gfx/Graph/CameraMath.cpp b/src/plugins/score-plugin-gfx/Gfx/Graph/CameraMath.cpp new file mode 100644 index 0000000000..fe9fc89c5e --- /dev/null +++ b/src/plugins/score-plugin-gfx/Gfx/Graph/CameraMath.cpp @@ -0,0 +1,48 @@ +#include + +#include + +namespace score::gfx +{ + +void packCameraUBO( + CameraUBOData& out, const ossia::camera_component& cam, + const QMatrix4x4& worldTransform, QSize renderSize, float timeSeconds, + float aspectOverride) +{ + const QVector3D eye = worldTransform.column(3).toVector3D(); + + QMatrix4x4 view = worldTransform.inverted(); + + const float fovYDeg = cam.yfov * (180.f / float(M_PI)); + float aspect = aspectOverride; + if(aspect <= 0.f) + { + aspect = (renderSize.height() > 0) + ? (float(renderSize.width()) / float(renderSize.height())) + : (cam.aspect_ratio > 0.f ? cam.aspect_ratio : 1.f); + } + + QMatrix4x4 proj; + setReverseZPerspective(proj, fovYDeg, aspect, cam.znear, cam.zfar); + + QMatrix4x4 vp = proj * view; + + writeMat4(out.view, view); + writeMat4(out.projection, proj); + writeMat4(out.viewProjection, vp); + out.cameraPosition[0] = eye.x(); + out.cameraPosition[1] = eye.y(); + out.cameraPosition[2] = eye.z(); + out.cameraPosition[3] = 0.f; + out.renderSize[0] = float(renderSize.width()); + out.renderSize[1] = float(renderSize.height()); + out.renderSize[2] = 0.f; + out.renderSize[3] = 0.f; + out.params[0] = timeSeconds; + out.params[1] = cam.znear; + out.params[2] = cam.zfar; + out.params[3] = 0.f; +} + +} diff --git a/src/plugins/score-plugin-gfx/Gfx/Graph/CameraMath.hpp b/src/plugins/score-plugin-gfx/Gfx/Graph/CameraMath.hpp new file mode 100644 index 0000000000..5196c94107 --- /dev/null +++ b/src/plugins/score-plugin-gfx/Gfx/Graph/CameraMath.hpp @@ -0,0 +1,82 @@ +#pragma once +#include +#include +#include + +#include +#include +#include + +namespace ossia +{ +struct camera_component; +} + +namespace score::gfx +{ + +// std140 layout; must byte-for-byte match every shader's `uniform camera_t`. +// Packed into ScenePreprocessor's per-camera Camera UBO aux buffer (attached +// to Geometry Out and auto-bound in consuming shaders by name). +struct CameraUBOData +{ + float view[16]{}; + float projection[16]{}; + float viewProjection[16]{}; + float cameraPosition[4]{}; + float renderSize[4]{}; + float params[4]{}; +}; +static_assert(sizeof(CameraUBOData) == 240, "CameraUBO layout must match shader"); + +inline void writeMat4(float dst[16], const QMatrix4x4& src) +{ + std::memcpy(dst, src.constData(), 16 * sizeof(float)); +} + +// Reverse-Z perspective projection in OpenGL NDC convention. +// +// Standard OpenGL perspective: view_z ∈ [-far, -near] → NDC z ∈ [-1, +1]. +// Reverse-Z (this function): view_z ∈ [-far, -near] → NDC z ∈ [-1, +1] +// but INVERTED: near → +1, far → -1. +// +// QRhi's clipSpaceCorrMatrix on Vulkan/Metal/D3D remaps the output NDC z ∈ +// [-1, +1] down to the backend-native [0, 1] without further flipping: +// near → 1.0, far → 0.0 in the depth buffer. +// +// This is paired project-wide with a float (D32F) depth attachment, a +// GREATER depth compare and a clear-depth of 0.0. Mixing conventions on a +// single depth buffer produces garbage. +inline void setReverseZPerspective( + QMatrix4x4& out, float fovYDeg, float aspect, float nearPlane, + float farPlane) +{ + out.setToIdentity(); + if(nearPlane == farPlane || aspect == 0.f) + return; + + const float radians = (fovYDeg * 0.5f) * float(M_PI / 180.0); + const float sine = std::sin(radians); + if(sine == 0.f) + return; + const float cotan = std::cos(radians) / sine; + const float clip = farPlane - nearPlane; + + out(0, 0) = cotan / aspect; + out(1, 1) = cotan; + out(2, 2) = (farPlane + nearPlane) / clip; + out(2, 3) = (2.f * farPlane * nearPlane) / clip; + out(3, 2) = -1.f; + out(3, 3) = 0.f; +} + +// Pack a camera_component's view/projection/position into a CameraUBOData. +// `worldTransform` is the camera node's accumulated world matrix (its +// column 3 is the eye position and its inverse is the view matrix). +// `aspectOverride` of <= 0 falls back to `renderSize.width / renderSize.height`. +void packCameraUBO( + CameraUBOData& out, const ossia::camera_component& cam, + const QMatrix4x4& worldTransform, QSize renderSize, float timeSeconds, + float aspectOverride = -1.f); + +} diff --git a/src/plugins/score-plugin-gfx/Gfx/Graph/CommonUBOs.hpp b/src/plugins/score-plugin-gfx/Gfx/Graph/CommonUBOs.hpp index 45b121e877..23bb570f28 100644 --- a/src/plugins/score-plugin-gfx/Gfx/Graph/CommonUBOs.hpp +++ b/src/plugins/score-plugin-gfx/Gfx/Graph/CommonUBOs.hpp @@ -20,6 +20,15 @@ struct ProcessUBO float renderSize[2]{2048, 2048}; float date[4]{0.f, 0.f, 0.f, 0.f}; + + // Mirrors gl_NumWorkGroups for CSF compute shaders. Populated by + // RenderedCSFNode just before dispatch so the libisf-injected + // `#define gl_NumWorkGroups isf_process_uniforms.NUMWORKGROUPS_` + // resolves to real dispatch counts on every backend (especially D3D + // where SPIRV-Cross refuses to emit the built-in directly). + // std140 packs uvec3 into a vec4 slot — the trailing word is padding. + uint32_t numWorkgroups[3]{}; + uint32_t _numWorkgroups_pad{}; }; /** @@ -38,14 +47,23 @@ struct ModelCameraUBO }; float view[16]{}; float projection[16]{}; - float modelNormal[9]{}; - float padding[3]; // Needed as a mat3 needs a bit more space... - float fov = 90.; + // std140 mat3: three column vectors, each padded to vec4 alignment. + // Column c lives at modelNormal[c * 4 + row]; the 4th float of each + // column is padding. Writing 9 contiguous floats here garbles columns + // 1 and 2 as read by the shader. + float modelNormal[12]{}; + float fov = 90.f; + // NB: must NOT be named `near`/`far` — those are legacy macros defined by + // ; naming members after them forces an #undef that then breaks + // any Windows system header (mmeapi.h, combaseapi.h) included afterwards. + float znear = 0.001f; //!< Used by non-matrix projections (fulldome, …) for reverse-Z depth. + float zfar = 10000.f; //!< idem. // clang-format on }; static_assert( - sizeof(ModelCameraUBO) == sizeof(float) * (16 + 16 + 16 + 16 + 16 + 9 + 3 + 1)); + sizeof(ModelCameraUBO) + == sizeof(float) * (16 + 16 + 16 + 16 + 16 + 12 + 1 + 1 + 1)); /** * @brief UBO shared across all entities shown on the same output. @@ -55,6 +73,13 @@ struct OutputUBO float clipSpaceCorrMatrix[16]{}; float renderSize[2]{}; + + // MSAA sample count of the bound output target. Mirrors + // RenderList::samples(); shaders need it because gl_NumSamples is + // stripped by glslang under SPIR-V. The trailing pad keeps the UBO + // aligned to a vec4 boundary (std140-friendly). + int32_t sampleCount{1}; + int32_t _pad0{0}; }; /** diff --git a/src/plugins/score-plugin-gfx/Gfx/Graph/CustomMesh.cpp b/src/plugins/score-plugin-gfx/Gfx/Graph/CustomMesh.cpp index cfb926a829..7e437ef7c4 100644 --- a/src/plugins/score-plugin-gfx/Gfx/Graph/CustomMesh.cpp +++ b/src/plugins/score-plugin-gfx/Gfx/Graph/CustomMesh.cpp @@ -2,10 +2,25 @@ #include #include +#include + +#include + // TODO: extend MeshBufs to hold multiple buffers // TODO: check that rendering e.g. sponza still works namespace score::gfx{ +// [BUFTRACE] implementation — see CustomMesh.hpp. Turn off at runtime +// by setting SCORE_BUFTRACE=0. +bool buftrace_enabled() +{ + static const bool on = [] { + const char* v = std::getenv("SCORE_BUFTRACE"); + return !v || v[0] != '0'; + }(); + return on; +} + CustomMesh::CustomMesh(const ossia::mesh_list &g, const ossia::geometry_filter_list_ptr &f) { reload(g, f); @@ -19,7 +34,9 @@ QRhiBuffer *CustomMesh::init_vbo(const ossia::geometry::cpu_buffer &buf, QRhi &r QRhiBuffer::Static, QRhiBuffer::StorageBuffer | QRhiBuffer::VertexBuffer, vtx_buf_size); mesh_buf->setName( - QString("Mesh::vtx_buf.%1").arg(idx.load(std::memory_order_relaxed)).toLatin1()); + QString("Mesh::vtx_buf.%1") + .arg(idx.fetch_add(1, std::memory_order_relaxed)) + .toLatin1()); mesh_buf->create(); return mesh_buf; @@ -32,11 +49,15 @@ QRhiBuffer *CustomMesh::init_vbo(const ossia::geometry::gpu_buffer &buf, QRhi &r QRhiBuffer *CustomMesh::init_index(const ossia::geometry::cpu_buffer &buf, QRhi &rhi) const noexcept { + static std::atomic_int idx = 0; QRhiBuffer* idx_buf{}; if(const auto idx_buf_size = buf.byte_size; idx_buf_size > 0) { idx_buf = rhi.newBuffer(QRhiBuffer::Static, QRhiBuffer::IndexBuffer, idx_buf_size); - idx_buf->setName("Mesh::idx_buf"); + idx_buf->setName( + QString("Mesh::idx_buf.%1") + .arg(idx.fetch_add(1, std::memory_order_relaxed)) + .toLatin1()); idx_buf->create(); } @@ -54,132 +75,232 @@ MeshBuffers CustomMesh::init(QRhi &rhi) const noexcept { return {}; } - if(geom.meshes[0].buffers.empty()) - { - return {}; - } MeshBuffers ret; - // FIXME multi-mesh - auto& mesh = geom.meshes[0]; - // 1. Null check - bool any_is_null = false; - for(const auto& buf : mesh.buffers) + // Multi-mesh: concatenate every mesh's buffers into ret.buffers in order. + // Each sub-mesh's local `input[].buffer` / `index.buffer` indices are + // remapped at draw time by adding the sub-mesh's starting offset in + // ret.buffers. The first sub-mesh's layout drives the pipeline + // (vertex bindings / attributes) in reload() — sub-meshes with a + // different layout are not supported today and will draw incorrectly. + for(std::size_t mi = 0; mi < geom.meshes.size(); ++mi) { - any_is_null |= ossia::visit([&](Buffer& buf) { - if constexpr(std::is_same_v) - { - return buf.byte_size == 0 || buf.data == nullptr; - } - else if constexpr(std::is_same_v) - { - return buf.handle == nullptr; - } - return false; - }, buf.data); - } - - if(any_is_null) - { - return {}; - } - - int i = 0; - int index_i = mesh.index.buffer; + const auto& mesh = geom.meshes[mi]; + if(mesh.buffers.empty()) + continue; - for(const auto& buf : mesh.buffers) - { - if(i != index_i) + // Null check — skip a sub-mesh whose data isn't ready yet. + bool any_is_null = false; + for(const auto& buf : mesh.buffers) { - auto rhi_buf - = ossia::visit([&](auto& buf) { return init_vbo(buf, rhi); }, buf.data); - ret.buffers.emplace_back(rhi_buf, 0, 0); + any_is_null |= ossia::visit([&](Buffer& buf) { + if constexpr(std::is_same_v) + return buf.byte_size == 0 || buf.data == nullptr; + else if constexpr(std::is_same_v) + return buf.handle == nullptr; + return false; + }, buf.data); } - else + if(any_is_null) { - auto rhi_buf - = ossia::visit([&](auto& buf) { return init_index(buf, rhi); }, buf.data); - ret.buffers.emplace_back(rhi_buf, 0, 0); + // Emit null placeholders so indexing stays aligned with geom.meshes. + for(std::size_t k = 0; k < mesh.buffers.size(); ++k) + ret.buffers.emplace_back(nullptr, 0, 0); + continue; + } + + int i = 0; + const int index_i = mesh.index.buffer; + for(const auto& buf : mesh.buffers) + { + QRhiBuffer* rhi_buf = (i != index_i) + ? ossia::visit([&](auto& b) { return init_vbo(b, rhi); }, buf.data) + : ossia::visit([&](auto& b) { return init_index(b, rhi); }, buf.data); + // Ownership follows the source variant: cpu_buffer paths allocate + // fresh QRhiBuffers (owned), gpu_buffer paths borrow an upstream + // handle (unowned — the original producer still owns it). + const bool owned = ossia::visit( + [](const Buffer&) { + return std::is_same_v; + }, buf.data); + BufferView bv{}; + bv.handle = rhi_buf; + bv.owned = owned; + ret.buffers.emplace_back(bv); + i++; } - i++; } -#if QT_VERSION >= QT_VERSION_CHECK(6, 12, 0) - // Populate indirect draw buffer from geometry's indirect_count - if(mesh.indirect_count.handle) + if(ret.buffers.empty()) + return {}; + + // Indirect draw / cpu_draw_commands: only meaningful when a single output + // mesh carries them (ScenePreprocessor's MDI mode). Pick them up from mesh[0]. + const auto& first_mesh = geom.meshes[0]; + if(first_mesh.indirect_count.handle) { - ret.indirectDrawBuffer = static_cast(mesh.indirect_count.handle); + ret.indirectDrawBuffer = static_cast(first_mesh.indirect_count.handle); ret.useIndirectDraw = true; - ret.indirectDrawIndexed = (mesh.index.buffer >= 0); + ret.indirectDrawIndexed = (first_mesh.index.buffer >= 0); + ret.indirectDrawCount + = first_mesh.indirect_count.byte_size / (5 * sizeof(uint32_t)); + ret.indirectDrawStride = 5 * sizeof(uint32_t); + if(ret.indirectDrawCount == 0) + ret.indirectDrawCount = 1; } -#endif + if(!first_mesh.cpu_draw_commands.empty()) + ret.cpuDrawCommands.assign( + first_mesh.cpu_draw_commands.begin(), first_mesh.cpu_draw_commands.end()); return ret; } void CustomMesh::update_vbo( int buffer_index, const ossia::geometry::cpu_buffer& vtx_buf, MeshBuffers& meshbuf, - QRhiResourceUpdateBatch& rb) const noexcept + QRhi& rhi, QRhiResourceUpdateBatch& rb) const noexcept { if(meshbuf.buffers.size() <= buffer_index) return; - auto buffer = meshbuf.buffers[buffer_index].handle; // FIXME use offset here? - if(auto sz = vtx_buf.byte_size; sz != buffer->size()) + auto& slot = meshbuf.buffers[buffer_index]; + // Diag 009 — guard the cpu→over-unowned-slot UAF: the slot was last + // populated by an upstream gpu_buffer producer (owned=false). Calling + // setSize/create on the upstream's QRhiBuffer destroys the underlying + // VkBuffer through QRhi's deferred-release queue and bumps the + // generation, silently clobbering every downstream consumer of that + // upstream handle. Allocate a fresh owned buffer instead — leave the + // upstream wrapper untouched. + if(!slot.handle || !slot.owned) + { + static std::atomic_int idx = 0; + auto* fresh = rhi.newBuffer( + QRhiBuffer::Static, QRhiBuffer::StorageBuffer | QRhiBuffer::VertexBuffer, + vtx_buf.byte_size); + fresh->setName( + QString("Mesh::vtx_buf.%1") + .arg(idx.fetch_add(1, std::memory_order_relaxed)) + .toLatin1()); + if(!fresh->create()) + { + qWarning() << "CustomMesh::update_vbo: fresh buffer->create() FAILED"; + delete fresh; + return; + } + BUFTRACE() << "update_vbo(cpu) mesh=" << (void*)this + << " slot=" << buffer_index + << " allocating fresh owned buffer (was " + << (slot.handle ? "unowned upstream" : "empty") << ")" + << " new=" << (void*)fresh + << " size=" << (qint64)vtx_buf.byte_size; + slot.handle = fresh; + slot.owned = true; + } + else if(auto sz = vtx_buf.byte_size; sz != slot.handle->size()) { - buffer->destroy(); - buffer->setSize(sz); - buffer->create(); + qDebug() << "CustomMesh::update_vbo: resizing buffer from" + << slot.handle->size() << "to" << sz + << "buffer=" << (void*)slot.handle; + slot.handle->setSize(sz); + if(!slot.handle->create()) + qWarning() << "CustomMesh::update_vbo: buffer->create() FAILED after resize!"; } // FIXME support offset uploadStaticBufferWithStoredData( - &rb, buffer, 0, buffer->size(), (const char*)vtx_buf.raw_data.get()); + &rb, slot.handle, 0, slot.handle->size(), (const char*)vtx_buf.raw_data.get()); } void CustomMesh::update_vbo( int buffer_index, const ossia::geometry::gpu_buffer& vtx_buf, MeshBuffers& meshbuf, - QRhiResourceUpdateBatch& rb) const noexcept + QRhi& rhi, QRhiResourceUpdateBatch& rb) const noexcept { if(meshbuf.buffers.size() <= buffer_index) return; // FIXME offset, size ? - // FIXME check if memory of previous buffer gets freed? - meshbuf.buffers[buffer_index] = {static_cast(vtx_buf.handle), 0, 0}; + auto& slot = meshbuf.buffers[buffer_index]; + auto* old_buf = slot.handle; + auto* new_buf = static_cast(vtx_buf.handle); + if(old_buf != new_buf) + { + // Diag 009 — when the slot previously held an owned cpu-fed buffer, + // route it through deleteLater so QRhi's release queue tears it + // down (and any SRBs auto-rebind via m_id generation tracking on + // their next setShaderResources). Without this we leak both the + // QRhiBuffer wrapper and its underlying VkBuffer. + if(slot.owned && old_buf) + { + BUFTRACE() << "update_vbo(gpu) mesh=" << (void*)this + << " slot=" << buffer_index + << " deleteLater old owned=" << (void*)old_buf + << " new=" << (void*)new_buf + << " size=" << (qint64)vtx_buf.byte_size; + old_buf->deleteLater(); + } + else + { + BUFTRACE() << "update_vbo(gpu) mesh=" << (void*)this + << " slot=" << buffer_index + << " old(unowned)=" << (void*)old_buf + << " new=" << (void*)new_buf + << " size=" << (qint64)vtx_buf.byte_size; + } + } + // Replacement entry must carry owned=false: the handle belongs to the + // upstream gpu_buffer producer. Default-constructed BufferView has + // owned=true → RenderList::release would `delete` a borrowed handle. + BufferView bv{}; + bv.handle = new_buf; + bv.owned = false; + slot = bv; } void CustomMesh::update_index( int buffer_index, const ossia::geometry::cpu_buffer& idx_buf, MeshBuffers& meshbuf, - QRhiResourceUpdateBatch& rb) const noexcept + QRhi& rhi, QRhiResourceUpdateBatch& rb) const noexcept { if(meshbuf.buffers.size() <= buffer_index) return; + auto& slot = meshbuf.buffers[buffer_index]; void* idx_buf_data = nullptr; - auto buffer = meshbuf.buffers[buffer_index].handle; // FIXME use offset here? - if(buffer) + if(geom.meshes[0].buffers.size() > 1) { - if(geom.meshes[0].buffers.size() > 1) + if(const auto idx_buf_size = idx_buf.byte_size; idx_buf_size > 0) { - if(const auto idx_buf_size = idx_buf.byte_size; idx_buf_size > 0) + idx_buf_data = idx_buf.raw_data.get(); + // Diag 009 — same UAF guard as update_vbo(cpu): if the slot is + // empty or holds an upstream-owned (unowned) handle, do NOT + // setSize/create on it; allocate a fresh owned index buffer. + if(!slot.handle || !slot.owned) { - idx_buf_data = idx_buf.raw_data.get(); - // FIXME what if index disappears - if(auto sz = idx_buf.byte_size; sz != buffer->size()) - { - buffer->destroy(); - buffer->setSize(sz); - buffer->create(); - } - else + static std::atomic_int idx = 0; + auto* fresh = rhi.newBuffer( + QRhiBuffer::Static, QRhiBuffer::IndexBuffer, idx_buf_size); + fresh->setName( + QString("Mesh::idx_buf.%1") + .arg(idx.fetch_add(1, std::memory_order_relaxed)) + .toLatin1()); + if(!fresh->create()) { + qWarning() << "CustomMesh::update_index: fresh buffer->create() FAILED"; + delete fresh; + return; } + BUFTRACE() << "update_index(cpu) mesh=" << (void*)this + << " slot=" << buffer_index + << " allocating fresh owned index buffer (was " + << (slot.handle ? "unowned upstream" : "empty") << ")" + << " new=" << (void*)fresh + << " size=" << (qint64)idx_buf_size; + slot.handle = fresh; + slot.owned = true; + } + else if(auto sz = idx_buf.byte_size; sz != slot.handle->size()) + { + slot.handle->setSize(sz); + slot.handle->create(); } - } - else - { - // FIXME what if index appears } } else @@ -187,19 +308,49 @@ void CustomMesh::update_index( // FIXME what if index appears } - if(buffer && idx_buf_data) + if(slot.handle && idx_buf_data) { // FIXME support offset uploadStaticBufferWithStoredData( - &rb, buffer, 0, buffer->size(), (const char*)idx_buf_data); + &rb, slot.handle, 0, slot.handle->size(), (const char*)idx_buf_data); } } void CustomMesh::update_index( int buffer_index, const ossia::geometry::gpu_buffer& idx_buf, MeshBuffers& meshbuf, - QRhiResourceUpdateBatch& rb) const noexcept + QRhi& rhi, QRhiResourceUpdateBatch& rb) const noexcept { SCORE_ASSERT(meshbuf.buffers.size() > buffer_index); + auto& slot = meshbuf.buffers[buffer_index]; + auto* old_buf = slot.handle; + auto* new_buf = static_cast(idx_buf.handle); + if(old_buf != new_buf) + { + // Diag 009 — leak-fix: route a previously-owned handle through + // QRhi's release queue so we don't drop the wrapper on the floor + // when transitioning cpu→gpu on this slot. + if(slot.owned && old_buf) + { + BUFTRACE() << "update_index(gpu) mesh=" << (void*)this + << " slot=" << buffer_index + << " deleteLater old owned=" << (void*)old_buf + << " new=" << (void*)new_buf + << " size=" << (qint64)idx_buf.byte_size; + old_buf->deleteLater(); + } + else + { + BUFTRACE() << "update_index(gpu) mesh=" << (void*)this + << " slot=" << buffer_index + << " old(unowned)=" << (void*)old_buf + << " new=" << (void*)new_buf + << " size=" << (qint64)idx_buf.byte_size; + } + BufferView bv{}; + bv.handle = new_buf; + bv.owned = false; + slot = bv; + } } void CustomMesh::update( @@ -208,47 +359,87 @@ void CustomMesh::update( if(geom.meshes.empty()) return; - // FIXME multi-mesh - auto& input_mesh = geom.meshes[0]; - if(input_mesh.buffers.empty()) + // Grow output_meshbuf.buffers when the geometry has added more + // buffers than mb has slots for (e.g. a model swap from Box.gltf → + // Duck.gltf where Duck has more vertex buffers, or + // ScenePreprocessor appending instance + scene-aux entries beyond + // the existing slot count). Without this, update_vbo's + // `if(meshbuf.buffers.size() <= buffer_index) return;` silently + // drops writes for new high-index buffers, stale handles persist, + // and the next setVertexInput binds them as vertex inputs — + // validation flags `pBuffers[N] is INDEX_BUFFER / STORAGE_BUFFER, + // requires VERTEX_BUFFER`. + // + // We *grow* rather than re-init: re-initialising forces init() + // through its any-buffer-null bail-out (which emits null placeholders + // for the WHOLE sub-mesh whenever any single buffer is null), which + // breaks scenes where a conditional aux buffer transiently goes + // null. Growing preserves the live handles already bound to + // populated slots; new slots get null placeholders and the + // update_vbo / update_index loop below fills them in. + // + // Shrinking is intentionally not done: extra trailing slots beyond + // what g.input / g.index reference are harmless (the draw path + // never indexes into them), and shrinking would require explicit + // release of the truncated owned buffers. + std::size_t total_geom_buffers = 0; + for(const auto& m : geom.meshes) + total_geom_buffers += m.buffers.size(); + if(output_meshbuf.buffers.size() < total_geom_buffers) { - return; + BUFTRACE() << "CustomMesh::update: growing MeshBuffers from " + << (qsizetype)output_meshbuf.buffers.size() + << " to " << (qsizetype)total_geom_buffers + << " slots (preserving existing handles)"; + output_meshbuf.buffers.resize( + total_geom_buffers, BufferView{nullptr, 0, 0}); } + if(output_meshbuf.buffers.empty()) - { output_meshbuf = init(rhi); - } if(output_meshbuf.buffers.empty()) - { return; - } - - int i = 0; - int index_i = input_mesh.index.buffer; - for(const auto& buf : input_mesh.buffers) + // Upload each sub-mesh's buffers, remapping local indices to the flat + // offset in output_meshbuf.buffers built by init(). + std::size_t base = 0; + for(const auto& input_mesh : geom.meshes) { - if(i != index_i) - { - ossia::visit( - [&](auto& buf) { return update_vbo(i, buf, output_meshbuf, rb); }, buf.data); - } - else + if(input_mesh.buffers.empty()) + continue; + if(base + input_mesh.buffers.size() > output_meshbuf.buffers.size()) + break; + + int i = 0; + const int index_i = input_mesh.index.buffer; + for(const auto& buf : input_mesh.buffers) { - ossia::visit( - [&](auto& buf) { return update_index(i, buf, output_meshbuf, rb); }, buf.data); + const int flat = int(base) + i; + if(i != index_i) + { + ossia::visit( + [&](auto& buf) { return update_vbo(flat, buf, output_meshbuf, rhi, rb); }, + buf.data); + } + else + { + ossia::visit( + [&](auto& buf) { return update_index(flat, buf, output_meshbuf, rhi, rb); }, + buf.data); + } + i++; } - i++; + base += input_mesh.buffers.size(); } -#if QT_VERSION >= QT_VERSION_CHECK(6, 12, 0) - // Update indirect draw buffer reference - if(input_mesh.indirect_count.handle) + // Indirect draw / cpu_draw_commands: same single-mesh scoping as init(). + const auto& first_mesh = geom.meshes[0]; + if(first_mesh.indirect_count.handle) { output_meshbuf.indirectDrawBuffer - = static_cast(input_mesh.indirect_count.handle); + = static_cast(first_mesh.indirect_count.handle); output_meshbuf.useIndirectDraw = true; - output_meshbuf.indirectDrawIndexed = (input_mesh.index.buffer >= 0); + output_meshbuf.indirectDrawIndexed = (first_mesh.index.buffer >= 0); } else { @@ -256,7 +447,16 @@ void CustomMesh::update( output_meshbuf.useIndirectDraw = false; output_meshbuf.indirectDrawIndexed = false; } -#endif + + if(!first_mesh.cpu_draw_commands.empty()) + { + output_meshbuf.cpuDrawCommands.assign( + first_mesh.cpu_draw_commands.begin(), first_mesh.cpu_draw_commands.end()); + } + + // Note: GPU readback for the indirect draw fallback is handled + // synchronously in RenderedRawRasterPipelineNode::runInitialPasses, + // which has access to both the command buffer and QRhi::finish(). } Mesh::Flags CustomMesh::flags() const noexcept @@ -306,6 +506,8 @@ void CustomMesh::preparePipeline(QRhiGraphicsPipeline &pip) const noexcept { pip.setDepthTest(true); pip.setDepthWrite(true); + // Reverse-Z project rule. + pip.setDepthOp(QRhiGraphicsPipeline::Greater); } pip.setTopology(this->topology); @@ -321,6 +523,11 @@ void CustomMesh::preparePipeline(QRhiGraphicsPipeline &pip) const noexcept void CustomMesh::reload(const ossia::mesh_list &ml, const ossia::geometry_filter_list_ptr &f) { + BUFTRACE() << "CustomMesh::reload mesh=" << (void*)this + << " meshes=" << (qsizetype)ml.meshes.size() + << " first_buf_count=" + << (ml.meshes.empty() ? (qsizetype)-1 + : (qsizetype)ml.meshes[0].buffers.size()); this->geom = ml; this->filters = f; @@ -368,59 +575,211 @@ void CustomMesh::reload(const ossia::mesh_list &ml, const ossia::geometry_filter frontFace = (QRhiGraphicsPipeline::FrontFace)g.front_face; } -void CustomMesh::draw(const MeshBuffers &bufs, QRhiCommandBuffer &cb) const noexcept +bool CustomMesh::drawSingleMesh( + std::size_t mesh_index, std::size_t base, const MeshBuffers& bufs, + QRhiCommandBuffer& cb, + std::span fallback_slots) const noexcept { - for(auto& g : this->geom.meshes) + if(mesh_index >= geom.meshes.size()) + return false; + const auto& g = geom.meshes[mesh_index]; + + // Total vertex-input count = mesh bindings + fallback bindings. The + // fallback slots' binding_index values were allocated sequentially + // past the mesh's own bindings when the pipeline was built + // (remapPipelineVertexInputs); they land at indices sz, sz+1, ... here. + const auto mesh_input_count = g.input.size(); + const auto total = mesh_input_count + fallback_slots.size(); + QVarLengthArray draw_inputs(total); + + int i = 0; + for(auto& in : g.input) { - const auto sz = g.input.size(); + const std::size_t flat = base + (std::size_t)in.buffer; + if(flat >= bufs.buffers.size()) + return false; + auto buf = bufs.buffers[flat].handle; + if(!buf) + return false; + draw_inputs[i++] = {buf, in.byte_offset}; + } - QVarLengthArray draw_inputs(sz); + // Fallback slots. Each Slot::binding_index is expressed in the global + // binding-index space; for a single-sub-mesh raw-raster draw it's + // always `mesh_input_count + k` for the k'th slot, so we place the + // buffers by index. + for(const auto& slot : fallback_slots) + { + const std::size_t idx = (std::size_t)slot.binding_index; + if(idx >= total || !slot.buffer) + continue; // defensive: skip malformed plans rather than dropping the draw + draw_inputs[idx] = {slot.buffer, 0}; + } - int i = 0; - for(auto& in : g.input) - { - // FIXME buffer offset? input offset? - if(bufs.buffers.size() <= in.buffer) - return; - - auto buf = bufs.buffers[in.buffer].handle; - if(!buf) - return; - draw_inputs[i++] = {buf, in.byte_offset}; - } + if(g.index.buffer >= 0) + { + const std::size_t flat_idx = base + (std::size_t)g.index.buffer; + if(flat_idx >= bufs.buffers.size()) + return false; + auto buf = bufs.buffers[flat_idx].handle; + const auto idxFmt = g.index.format == decltype(g.index)::uint16 + ? QRhiCommandBuffer::IndexUInt16 + : QRhiCommandBuffer::IndexUInt32; + // If this bind crashes with a dangling buffer, the `buf` pointer + // logged here will match ASan's freed-at report. The mesh= and + // slot= fields tell us which CustomMesh and which MeshBuffers + // entry retained it. + BUFTRACE() << "bindIndexBuffer mesh=" << (void*)this + << " sub=" << mesh_index << " slot=" << flat_idx + << " buf=" << (void*)buf + << " offset=" << (qint64)g.index.byte_offset + << " bufs.size=" << (qsizetype)bufs.buffers.size(); + cb.setVertexInput( + 0, (int)total, draw_inputs.data(), buf, g.index.byte_offset, idxFmt); + } + else + { + cb.setVertexInput(0, (int)total, draw_inputs.data()); + } - if(g.index.buffer >= 0) - { - auto buf = bufs.buffers[g.index.buffer].handle; - const auto idxFmt = g.index.format == decltype(g.index)::uint16 - ? QRhiCommandBuffer::IndexUInt16 - : QRhiCommandBuffer::IndexUInt32; - cb.setVertexInput(0, sz, draw_inputs.data(), buf, g.index.byte_offset, idxFmt); - } - else - { - cb.setVertexInput(0, sz, draw_inputs.data()); - } + // Per-mesh indirect override: when THIS submesh carries its own + // `indirect_count` handle (different from bufs.indirectDrawBuffer), + // use it instead. Required for multi-batch MDI (opaque + transparent + // split emitted by ScenePreprocessor) where each sub-mesh drives a + // separate indirect-cmd list. Same rule for `cpu_draw_commands`. + QRhiBuffer* effIndirectBuf = bufs.indirectDrawBuffer; + quint32 effIndirectCount = bufs.indirectDrawCount; + const auto* effCpuCmds = &bufs.cpuDrawCommands; + std::decay_t perMeshCmds; + if(auto* h = static_cast(g.indirect_count.handle)) + { + effIndirectBuf = h; + effIndirectCount + = (quint32)(g.indirect_count.byte_size / (5 * sizeof(uint32_t))); + if(effIndirectCount == 0) + effIndirectCount = 1; + } + if(!g.cpu_draw_commands.empty()) + { + perMeshCmds.assign(g.cpu_draw_commands.begin(), g.cpu_draw_commands.end()); + effCpuCmds = &perMeshCmds; + } + // Multi-draw indirect: runtime capability check, not compile-time. + // Only meaningful for single-sub-mesh MDI-mode geometries. + if(bufs.useIndirectDraw && effIndirectBuf) + { #if QT_VERSION >= QT_VERSION_CHECK(6, 12, 0) - if(bufs.useIndirectDraw && bufs.indirectDrawBuffer) + if(bufs.gpuIndirectSupported) { if(bufs.indirectDrawIndexed) - cb.drawIndexedIndirect(bufs.indirectDrawBuffer, 0, 1); + cb.drawIndexedIndirect( + effIndirectBuf, bufs.indirectDrawOffset, + effIndirectCount, bufs.indirectDrawStride); else - cb.drawIndirect(bufs.indirectDrawBuffer, 0, 1); - continue; + cb.drawIndirect( + effIndirectBuf, bufs.indirectDrawOffset, + effIndirectCount, bufs.indirectDrawStride); + return true; } #endif - if(g.index.buffer > -1) - { - cb.drawIndexed(g.indices, g.instances); - } - else + // CPU fallback: iterate draw commands with correct firstInstance / + // baseVertex so each sub-draw gets its own per-draw data via + // gl_BaseInstance. Commands come from either the producer + // (ScenePreprocessor) or GPU readback (CSF). + if(!effCpuCmds->empty()) { - cb.draw(g.vertices, g.instances); + const bool indexed = (g.index.buffer >= 0); + for(const auto& cmd : *effCpuCmds) + { + if(indexed) + cb.drawIndexed( + cmd.index_or_vertex_count, cmd.instance_count, + cmd.first_index_or_vertex, cmd.base_vertex, cmd.first_instance); + else + cb.draw( + cmd.index_or_vertex_count, cmd.instance_count, + cmd.first_index_or_vertex, cmd.first_instance); + } + return true; } + // No CPU commands yet (readback pending or first frame) — skip. + return false; + } + + if(g.index.buffer > -1) + cb.drawIndexed(g.indices, g.instances); + else + cb.draw(g.vertices, g.instances); + return true; +} + +// The pipeline's vertex layout is derived from meshes[0] only (see +// init/reload); a sub-mesh whose bindings or attributes differ would be +// fetched through the wrong strides/offsets — garbled geometry or an +// out-of-bounds attribute fetch. Skip those instead of drawing them. +bool CustomMesh::subMeshLayoutMatchesFirst(std::size_t i) const noexcept +{ + if(i == 0) + return true; + const auto& a = geom.meshes[0]; + const auto& b = geom.meshes[i]; + if(a.topology != b.topology) + return false; + if(a.bindings.size() != b.bindings.size() + || a.attributes.size() != b.attributes.size()) + return false; + for(std::size_t k = 0; k < a.bindings.size(); ++k) + { + const auto& x = a.bindings[k]; + const auto& y = b.bindings[k]; + if(x.byte_stride != y.byte_stride || x.classification != y.classification + || x.step_rate != y.step_rate) + return false; + } + for(std::size_t k = 0; k < a.attributes.size(); ++k) + { + const auto& x = a.attributes[k]; + const auto& y = b.attributes[k]; + if(x.binding != y.binding || x.location != y.location + || x.format != y.format || x.byte_offset != y.byte_offset + || x.element_byte_size != y.element_byte_size) + return false; + } + return true; +} + +void CustomMesh::draw(const MeshBuffers &bufs, QRhiCommandBuffer &cb) const noexcept +{ + // Default draw path: iterate sub-meshes without any per-mesh state swap. + // Works for single-mesh geometries and for MDI mode (one sub-mesh with an + // indirect buffer). For multi-sub-mesh + per-mesh SRB auxes (classic + // per-mesh ScenePreprocessor output), the caller should instead iterate + // drawSingleMesh() itself and rebind the SRB between sub-meshes. + std::size_t base = 0; + for(std::size_t i = 0; i < geom.meshes.size(); ++i) + { + if(subMeshLayoutMatchesFirst(i)) + drawSingleMesh(i, base, bufs, cb); + base += geom.meshes[i].buffers.size(); + } +} + +void CustomMesh::drawWithFallbackBindings( + const MeshBuffers& bufs, QRhiCommandBuffer& cb, + std::span fallback_slots) const noexcept +{ + // Same as draw() but with the caller's fallback-binding plan threaded + // down to drawSingleMesh so the extra PerInstance identity buffers + // land in the vertex-input array at the indices the pipeline + // allocated for them. + std::size_t base = 0; + for(std::size_t i = 0; i < geom.meshes.size(); ++i) + { + if(subMeshLayoutMatchesFirst(i)) + drawSingleMesh(i, base, bufs, cb, fallback_slots); + base += geom.meshes[i].buffers.size(); } } diff --git a/src/plugins/score-plugin-gfx/Gfx/Graph/CustomMesh.hpp b/src/plugins/score-plugin-gfx/Gfx/Graph/CustomMesh.hpp index 5f8e977839..0bbaa37d58 100644 --- a/src/plugins/score-plugin-gfx/Gfx/Graph/CustomMesh.hpp +++ b/src/plugins/score-plugin-gfx/Gfx/Graph/CustomMesh.hpp @@ -1,13 +1,25 @@ #pragma once #include +#include #include +#include #include +#include + namespace score::gfx { +// [BUFTRACE] — diagnostic logging around QRhiBuffer lifetime during +// live graph edits (defined in CustomMesh.cpp). Exposed so other TUs +// (RenderList, ScenePreprocessorNode, RenderedRawRasterPipelineNode) can +// use BUFTRACE() with the same env-var gating. +SCORE_PLUGIN_GFX_EXPORT bool buftrace_enabled(); +#define BUFTRACE() if(::score::gfx::buftrace_enabled()) qDebug().nospace() << "[BUFTRACE] " + + class CustomMesh : public score::gfx::Mesh { ossia::mesh_list geom; @@ -47,19 +59,19 @@ class CustomMesh : public score::gfx::Mesh void update_vbo( int buffer_index, const ossia::geometry::cpu_buffer& vtx_buf, MeshBuffers& meshbuf, - QRhiResourceUpdateBatch& rb) const noexcept; + QRhi& rhi, QRhiResourceUpdateBatch& rb) const noexcept; void update_vbo( int buffer_index, const ossia::geometry::gpu_buffer& vtx_buf, MeshBuffers& meshbuf, - QRhiResourceUpdateBatch& rb) const noexcept; + QRhi& rhi, QRhiResourceUpdateBatch& rb) const noexcept; void update_index( int buffer_index, const ossia::geometry::cpu_buffer& idx_buf, MeshBuffers& meshbuf, - QRhiResourceUpdateBatch& rb) const noexcept; + QRhi& rhi, QRhiResourceUpdateBatch& rb) const noexcept; void update_index( int buffer_index, const ossia::geometry::gpu_buffer& idx_buf, MeshBuffers& meshbuf, - QRhiResourceUpdateBatch& rb) const noexcept; + QRhi& rhi, QRhiResourceUpdateBatch& rb) const noexcept; void update(QRhi& rhi, MeshBuffers& output_meshbuf, QRhiResourceUpdateBatch& rb) const noexcept override; Flags flags() const noexcept override; @@ -72,6 +84,38 @@ class CustomMesh : public score::gfx::Mesh void draw(const MeshBuffers& bufs, QRhiCommandBuffer& cb) const noexcept override; + // Fallback-aware variant: appends each `FallbackBindingPlan::Slot` + // buffer to the vertex-input array before issuing the draw. Used by + // raw-raster pipelines whose shaders declared "REQUIRED: false" + // VERTEX_INPUTS the upstream geometry doesn't provide. Non-virtual on + // purpose — only CustomMesh participates in the fallback path. + void drawWithFallbackBindings( + const MeshBuffers& bufs, QRhiCommandBuffer& cb, + std::span fallback_slots) const noexcept; + + // Draw a single sub-mesh (geom.meshes[mesh_index]) using the portion of + // `bufs.buffers` starting at `buffer_offset`. `buffer_offset` must match + // init()'s flat-concat layout: sum of geom.meshes[0..mesh_index-1].buffers.size(). + // Returns true if a draw call was issued. + // + // Exposed so consumers that need per-sub-mesh state (e.g. RawRaster + // swapping the per_draw SSBO between meshes) can iterate sub-meshes + // themselves instead of invoking the fire-and-forget `draw()` above. + // + // `fallback_slots` (default empty) is merged into the vertex-input + // array at each slot's binding_index — bindings appended by the + // fallback-aware remap land past the mesh's own bindings, so slot + // indices are always contiguous after geom.meshes[mesh_index].input. + bool drawSingleMesh( + std::size_t mesh_index, std::size_t buffer_offset, + const MeshBuffers& bufs, QRhiCommandBuffer& cb, + std::span fallback_slots = {}) const noexcept; + + //! True when sub-mesh i shares meshes[0]'s vertex layout — the only + //! layout the pipeline was built for; mismatching sub-meshes are + //! skipped by the default draw paths. + bool subMeshLayoutMatchesFirst(std::size_t i) const noexcept; + const char* defaultVertexShader() const noexcept override; const ossia::geometry* semanticGeometry() const noexcept override diff --git a/src/plugins/score-plugin-gfx/Gfx/Graph/DMACaptureInputNode.cpp b/src/plugins/score-plugin-gfx/Gfx/Graph/DMACaptureInputNode.cpp new file mode 100644 index 0000000000..cc5c1ad875 --- /dev/null +++ b/src/plugins/score-plugin-gfx/Gfx/Graph/DMACaptureInputNode.cpp @@ -0,0 +1,334 @@ +#include "DMACaptureInputNode.hpp" + +#include +#include +#include +#include + +#include + +#include +#include + +namespace score::gfx +{ + +DMACaptureBackend::~DMACaptureBackend() = default; + +/** + * @brief Generic renderer for DMA capture-card inputs. + * + * Owns the QRhi-side machinery; the vendor `DMACaptureBackend` owns the device + * and capture thread. Per-frame flow: + * capture thread (backend): DMA card -> strategy slot -> publish in slot ring + * render thread (here): poll ring -> acquireForRender (sysmem->texture) -> + * sample via decoder -> releaseAfterRender next tick + */ +class DMACaptureInputNode::Renderer final : public score::gfx::NodeRenderer +{ +public: + explicit Renderer(const DMACaptureInputNode& n) + : score::gfx::NodeRenderer{n} + , node{n} + { + } + ~Renderer() override = default; + + score::gfx::TextureRenderTarget + renderTargetForInput(const score::gfx::Port&) override + { + return {}; + } + + void + init(score::gfx::RenderList& renderer, QRhiResourceUpdateBatch& res) override + { + auto& rhi = *renderer.state.rhi; + m_backendKind = rhi.backend(); + + // Must be defaultQuad(): score::gfx::quadRenderPass() hardcodes + // renderer.defaultQuad() for the draw call (TexturedQuad::setupBindings + + // 4-vertex draw). Building the mesh buffer or pipeline from a *triangle* + // makes the draw read the texcoord vertex-binding at the quad's byte offset + // (4*2*float) instead of the triangle's (3*2*float) and over-run the vertex + // count — which garbles the sampled texcoords. + if(m_meshBuffer.buffers.empty()) + m_meshBuffer = renderer.initMeshBuffer(renderer.defaultQuad(), res); + + m_processUBO = rhi.newBuffer( + QRhiBuffer::Dynamic, QRhiBuffer::UniformBuffer, + sizeof(score::gfx::ProcessUBO)); + m_processUBO->create(); + m_materialUBO = rhi.newBuffer( + QRhiBuffer::Dynamic, QRhiBuffer::UniformBuffer, + sizeof(score::gfx::VideoMaterialUBO)); + m_materialUBO->create(); + + m_backend = node.makeCaptureBackend(m_ring); + if(!m_backend || !m_backend->open()) + { + qWarning() << "DMA capture: device open failed; falling back"; + m_backend.reset(); + return; + } + + // Strategy construction must come AFTER open: vendor strategies capture the + // device handle at construction. pickStrategy may return null (no GPU-direct + // strategy for this backend) — the CPU-staging fallback below still applies. + m_strategy = m_backend->pickStrategy(m_backendKind); + + // Decoder + texture. The decoder allocates an input texture sized to the + // card's wire byte layout; the strategy DMAs raw bytes into it and the + // decoder's shader unpacks + converts to RGB at sample time. The backend's + // colour metadata (VPID/InfoFrame-derived) drives the conversion. + static_cast(m_metadata) = m_backend->imageFormat(); + m_gpu = m_backend->makeDecoder(m_metadata); + if(!m_gpu) + { + qWarning() << "DMA capture: no decoder for wire format; falling back"; + m_backend.reset(); + return; + } + auto shaders = m_gpu->init(renderer); + m_vertexS = shaders.first; + m_fragmentS = shaders.second; + SCORE_ASSERT(!m_gpu->samplers.empty()); + + // Strategy init must precede defaultPassesInit: a zero-copy strategy may + // provide its own renderer-facing texture (see the swap below), which the + // pass's shader-resource bindings must reference. + score::gfx::interop::GpuDirectCaptureStrategyConfig icfg{ + .rhi = &rhi, + .state = &renderer.state, + .frameByteSize = m_backend->frameByteSize(), + .width = m_backend->width(), + .height = m_backend->height(), + .outputTexture = m_gpu->samplers[0].texture}; + if(m_strategy) + m_backend->setStrategy(m_strategy.get()); + if(!m_strategy || !m_strategy->init(icfg)) + { + if(m_strategy) + qDebug() << "DMA capture: strategy" << m_strategy->name() + << "init failed; using CPU-staging"; + // Universal CPU-staging fallback (works on every backend). + m_strategy = m_backend->makeCpuStrategy(); + if(m_strategy) + m_backend->setStrategy(m_strategy.get()); + if(!m_strategy || !m_strategy->init(icfg)) + { + qWarning() << "DMA capture: no working capture strategy; falling back"; + m_backend.reset(); + m_strategy.reset(); + return; + } + } + + // Some zero-copy strategies (Vulkan tier-3) allocate the renderer-facing + // texture themselves — an exportable, CUDA-mapped VkImage — instead of + // uploading into the decoder's. When the strategy provides its own texture, + // swap it into the decoder's sampler so the pass samples it. The strategy + // owns that texture (frees it in release()); release() below detaches it + // from the decoder to avoid a double-free. + if(auto* st = m_strategy->outputTexture(); + st && st != m_gpu->samplers[0].texture) + { + delete m_gpu->samplers[0].texture; // decoder's original input texture + m_gpu->samplers[0].texture = st; + m_strategyOwnsTexture = true; + } + + score::gfx::defaultPassesInit( + m_p, this->node.output[0]->edges, renderer, renderer.defaultQuad(), + shaders.first, shaders.second, m_processUBO, m_materialUBO, + m_gpu->samplers); + + // Material UBO holds the OUTPUT (consumer-visible) texture size, always the + // full pixel geometry regardless of whether the input texture is + // half-width / quarter-width. Decoders' shaders use mat.texSz.x to compute + // the output column index for chroma upsampling (UYVY) or v210 group decode. + m_material.textureSize[0] = float(m_metadata.width); + m_material.textureSize[1] = float(m_metadata.height); + res.updateDynamicBuffer( + m_materialUBO, 0, sizeof(score::gfx::VideoMaterialUBO), &m_material); + + qDebug() << "DMA capture: GPU-direct" << m_strategy->name() << "engaged" + << m_backend->width() << "x" << m_backend->height(); + + m_backend->start(); + } + + void update( + score::gfx::RenderList&, QRhiResourceUpdateBatch& res, + score::gfx::Edge*) override + { + if(!m_strategy) + return; + res.updateDynamicBuffer( + m_processUBO, 0, sizeof(score::gfx::ProcessUBO), + &this->node.standardUBO); + + // Poll the capture thread's frame counter. If it advanced since we last + // sampled, hand the previous frame's texture back to the strategy and take + // ownership of the new one. Doing the release on the NEXT update() — rather + // than a per-edge finishFrame hook — works because by the time we're back in + // update() the QRhi command buffer for the previous frame has been + // submitted, so the EndAPI signal sequences correctly with the WaitDVP in + // the capture thread's next ingestFrame. + const uint64_t latest = m_ring.latestFrameId.load(std::memory_order_acquire); + if(latest != m_lastIngestedFrameId) + { + if(m_renderHoldsTexture) + { + m_strategy->releaseAfterRender(); + m_renderHoldsTexture = false; + } + // Pass the active resource-update batch: the portable CPU strategy uses it + // for QRhi uploadTexture; raw-API strategies (GL/DVP) ignore it and fall + // through to the no-arg acquireForRender(). + if(m_timeUpload) [[unlikely]] + { + const auto t0 = std::chrono::steady_clock::now(); + m_strategy->acquireForRender(res); + m_uploadTotalNs += std::chrono::duration_cast( + std::chrono::steady_clock::now() - t0) + .count(); + ++m_uploadCount; + } + else + { + m_strategy->acquireForRender(res); + } + m_lastIngestedFrameId = latest; + m_renderHoldsTexture = true; + } + } + + void runRenderPass( + score::gfx::RenderList& renderer, QRhiCommandBuffer& cb, + score::gfx::Edge& edge) override + { + score::gfx::quadRenderPass(renderer, m_meshBuffer, cb, edge, m_p); + } + void addOutputPass( + score::gfx::RenderList& renderer, score::gfx::Edge& edge, + QRhiResourceUpdateBatch& res) override + { + if(!m_gpu || m_gpu->samplers.empty() || !m_vertexS.isValid() + || !m_fragmentS.isValid()) + return; + + auto rt = renderer.renderTargetForOutput(edge); + if(rt.renderTarget) + { + auto pip = score::gfx::buildPipeline( + renderer, renderer.defaultQuad(), m_vertexS, m_fragmentS, rt, + m_processUBO, m_materialUBO, m_gpu->samplers); + if(pip.pipeline) + m_p.emplace_back(&edge, score::gfx::Pass{rt, pip, nullptr}); + } + } + + void removeOutputPass( + score::gfx::RenderList& renderer, score::gfx::Edge& edge) override + { + auto it = ossia::find_if(m_p, [&](const auto& p) { return p.first == &edge; }); + if(it != m_p.end()) + { + it->second.release(); + m_p.erase(it); + } + } + + void release(score::gfx::RenderList& r) override + { + if(m_timeUpload && m_uploadCount > 0) [[unlikely]] + { + qDebug().nospace() + << "DMA capture: upload(" << (m_strategy ? m_strategy->name() : "?") + << ") mean " << (double(m_uploadTotalNs) / m_uploadCount / 1000.0) + << " us over " << m_uploadCount << " frames"; + } + // If the strategy owns the decoder's sampler texture (Vulkan tier-3 swap, + // recorded at init), detach it before either release() runs so the decoder + // doesn't free a texture the strategy also frees. For every other strategy + // outputTexture() IS the decoder's own texture — detaching would leak it, + // since GPUVideoDecoder::release() is what deletes it. + if(m_strategyOwnsTexture && m_gpu && !m_gpu->samplers.empty()) + m_gpu->samplers[0].texture = nullptr; + m_strategyOwnsTexture = false; + if(m_strategy && m_renderHoldsTexture) + { + m_strategy->releaseAfterRender(); + m_renderHoldsTexture = false; + } + if(m_backend) + { + m_backend->stop(); + m_backend.reset(); + } + if(m_strategy) + { + m_strategy->release(); + m_strategy.reset(); + } + for(auto& p : m_p) + p.second.release(); + m_p.clear(); + delete m_processUBO; + m_processUBO = nullptr; + delete m_materialUBO; + m_materialUBO = nullptr; + if(m_gpu) + { + m_gpu->release(r); + m_gpu.reset(); + } + } + +private: + const DMACaptureInputNode& node; + QRhi::Implementation m_backendKind{QRhi::Null}; + + std::unique_ptr m_backend; + std::unique_ptr m_strategy; + score::gfx::interop::GpuDirectCaptureSlotRing m_ring; + uint64_t m_lastIngestedFrameId{0}; + bool m_renderHoldsTexture{false}; + /// True when the strategy swapped its own texture into the decoder sampler + /// (Vulkan tier-3); gates the detach in release() so decoder-owned textures + /// are still freed by GPUVideoDecoder::release(). + bool m_strategyOwnsTexture{false}; + + std::unique_ptr m_gpu; + QShader m_vertexS, m_fragmentS; + score::gfx::PassMap m_p; + score::gfx::MeshBuffers m_meshBuffer{}; + QRhiBuffer* m_processUBO{}; + QRhiBuffer* m_materialUBO{}; + score::gfx::VideoMaterialUBO m_material; + Video::VideoMetadata m_metadata; + + // Diagnostic: SCORE_DMACAPTURE_TIME_UPLOAD=1 times the per-frame + // acquireForRender (the sysmem -> texture upload) to compare raw-API vs + // portable-QRhi cost. + const bool m_timeUpload + = qEnvironmentVariableIsSet("SCORE_DMACAPTURE_TIME_UPLOAD"); + uint64_t m_uploadTotalNs{0}; + uint64_t m_uploadCount{0}; +}; + +DMACaptureInputNode::DMACaptureInputNode() +{ + output.push_back(new score::gfx::Port{this, {}, score::gfx::Types::Image, {}}); +} + +DMACaptureInputNode::~DMACaptureInputNode() = default; + +NodeRenderer* +DMACaptureInputNode::createRenderer(RenderList& r) const noexcept +{ + return new Renderer{*this}; +} + +} // namespace score::gfx diff --git a/src/plugins/score-plugin-gfx/Gfx/Graph/DMACaptureInputNode.hpp b/src/plugins/score-plugin-gfx/Gfx/Graph/DMACaptureInputNode.hpp new file mode 100644 index 0000000000..4232a4c89b --- /dev/null +++ b/src/plugins/score-plugin-gfx/Gfx/Graph/DMACaptureInputNode.hpp @@ -0,0 +1,123 @@ +#pragma once + +/** + * @file DMACaptureInputNode.hpp + * @brief Shared base for professional capture-card INPUT nodes. + * + * Capture cards (AJA, Blackmagic DeckLink, Bluefish444, Deltacast, and + * Magewell on its host-staged path) all deliver frames the same way: a vendor + * capture thread DMAs each frame into a `GpuDirectCaptureStrategy`'s slot, then + * publishes the slot via a lock-free `GpuDirectCaptureSlotRing`; the render + * thread samples the strategy's texture through a `GPUVideoDecoder`. This is + * fundamentally different from the camera/NDI path (CPU `AVFrame` upload, served + * by `VideoNodeRenderer`) and the Spout/Syphon path (a shared GPU texture + * handle) — so it gets its own base rather than overloading those. + * + * `DMACaptureRenderer` owns all the generic machinery (mesh/UBO setup, decoder + * init, strategy init with CPU-staging fallback, the zero-copy texture swap, + * `defaultPassesInit`, the per-frame slot poll + acquire/release bracket, and + * teardown). The vendor supplies a `DMACaptureBackend`: open the device, report + * geometry + colour metadata, build the decoder for its wire format, pick the + * GPU-direct strategy (+ a CPU fallback), and run the capture thread that feeds + * the slot ring. + * + * This is the input-side counterpart to the output-side seams + * (`HostStagedOutput` / `GpuDirectStrategy` / `PacedFramePump`). A vendor input + * addon shrinks to: implement `DMACaptureBackend`, subclass + * `DMACaptureInputNode`, return the backend from `makeCaptureBackend`. + */ + +#include + +#include