From b576143afbae288a0e6a61cee2b6258861fa5c05 Mon Sep 17 00:00:00 2001 From: omigamedev Date: Wed, 3 Jun 2026 18:37:58 +0200 Subject: [PATCH] Use blend gate plan for canvas copy decisions --- docs/modernization/capability-map.md | 2 +- docs/modernization/debt.md | 2 +- docs/modernization/roadmap.md | 7 ++++++- src/canvas.cpp | 22 ++++++++++++++++------ src/node_canvas.cpp | 22 ++++++++++++++++------ src/paint_renderer/compositor.cpp | 17 ++++++++++++++--- tests/paint_renderer/compositor_tests.cpp | 20 +++++++++++++++++++- 7 files changed, 73 insertions(+), 19 deletions(-) diff --git a/docs/modernization/capability-map.md b/docs/modernization/capability-map.md index 8b5c518..0ff4350 100644 --- a/docs/modernization/capability-map.md +++ b/docs/modernization/capability-map.md @@ -37,7 +37,7 @@ and validation command. | PPBR import/export | brush panel/dialog | `pp_assets`, `pp_panopainter_ui` | Round-trip fixture | | Stroke sampling | `Stroke`, `Canvas` | `pp_paint` | Property tests for spacing, pressure, jitter | | Dual brush/pattern behavior | `Brush`, shaders | `pp_paint`, `pp_paint_renderer` | Stroke-alpha CPU reference, dual/pattern feedback planning, GPU golden | -| Blend modes | GLSL include files, layer rendering | `pp_paint`, `pp_paint_renderer` | Final RGBA and stroke-alpha CPU reference vectors, fixed-function/framebuffer-fetch/ping-pong stroke composite planning, live `Canvas`/`NodeCanvas` blend-gate coverage, and GPU parity | +| Blend modes | GLSL include files, layer rendering | `pp_paint`, `pp_paint_renderer` | Final RGBA and stroke-alpha CPU reference vectors, fixed-function/framebuffer-fetch/ping-pong stroke composite planning, live `Canvas`/`NodeCanvas` blend-gate and destination-copy coverage, and GPU parity | | Erase/flood fill/masks | `Canvas`, modes, shaders | `pp_document`, `pp_paint_renderer` | Edge masks, alpha lock, dirty rects | ## Layers And Animation diff --git a/docs/modernization/debt.md b/docs/modernization/debt.md index 0db381f..3225d46 100644 --- a/docs/modernization/debt.md +++ b/docs/modernization/debt.md @@ -53,7 +53,7 @@ agent or engineer to remove them without reconstructing context from chat. | DEBT-0033 | Open | Modernization | Tools menu planning and direct command execution dispatch now consume pure `pp_app_core` through `App::init_menu_tools`, `pano_cli plan-tools-menu`, `pano_cli plan-tools-panel`, and the `ToolsMenuServices` boundary, but live adapters still construct legacy `NodePanelFloating` panels, mutate legacy panel nodes, clear `CanvasModeGrid`, reset `NodeCanvas` camera state, open legacy shortcuts UI, and call the iOS SonarPen bridge directly | Preserve current Tools menu behavior while UI shell actions move toward app/UI/platform services | `pp_app_core_tools_menu_tests`; `pano_cli plan-tools-menu --command shortcuts`; `pano_cli plan-tools-panel --panel layers`; `pano_cli plan-tools-panel --panel animation --already-visible`; `ctest --preset desktop-fast --build-config Debug` | Tools panel creation, submenu routing, grid clear, camera reset, shortcuts dialog, and SonarPen dispatch are owned by injected app/UI/platform services with `App::init_menu_tools` acting only as a UI adapter and no legacy Tools adapter | | DEBT-0034 | Open | Modernization | About menu command planning and execution dispatch now consume pure `pp_app_core` through `App::init_menu_about`, `pano_cli plan-about-menu`, and the `AboutMenuServices` boundary, but the live adapter still opens legacy About/manual/what's-new dialogs, invokes the injected crash hook, and runs the legacy Canvas stroke performance test directly | Preserve About menu behavior while dialogs and diagnostics move toward app/UI/platform services | `pp_app_core_about_menu_tests`; `pano_cli plan-about-menu --command news --version-major 2 --version-minor 5 --version-fix 7`; `pano_cli plan-about-menu --command performance --no-canvas`; `ctest --preset desktop-fast --build-config Debug` | About/manual/what's-new dialog dispatch, crash-test dispatch, and performance-test execution are owned by injected app/UI/platform services with `App::init_menu_about` acting only as a UI adapter and no legacy About adapter | | DEBT-0035 | Open | Modernization | Main toolbar/status command planning and execution dispatch now consume pure `pp_app_core` through `App::init_toolbar_main`, `pano_cli plan-main-toolbar`, and the `MainToolbarServices` boundary, and history/canvas commands now hand off through `HistoryUiServices` and `DocumentCanvasClearServices`, but the live adapter still opens legacy open/save/settings/message-box dialogs and delegates to legacy history/canvas adapters | Preserve reachable toolbar/status behavior while app shell commands move toward app/document/UI services | `pp_app_core_main_toolbar_tests`; `pano_cli plan-main-toolbar --command undo --undo-count 2`; `pano_cli plan-main-toolbar --command clear-canvas --no-canvas`; `ctest --preset desktop-fast --build-config Debug` | Open/save/settings/message-box routing, undo/redo/clear-history execution, and canvas-clear execution are owned by injected app/document/UI services with `App::init_toolbar_main` acting only as a UI adapter and no legacy toolbar adapter | -| DEBT-0036 | Open | Modernization | `pp_renderer_api`, `pp_paint_renderer`, `pano_cli plan-paint-feedback`, and `pano_cli plan-stroke-composite` can choose backend-neutral complex paint feedback strategies for fixed-function blending, framebuffer-fetch-capable renderers, or ping-pong render targets. OpenGL extension detection now stores `pp::renderer::RenderDeviceFeatures` through `ShaderManager`, using `pp_renderer_gl::render_device_features` as the backend conversion point. `pp_paint_renderer::plan_canvas_blend_gate` owns the compatibility mapping from persisted layer/brush blend indices to the extracted stroke-composite planner, and live `Canvas::draw_merge` plus `NodeCanvas` panorama rendering both call it with the stored renderer-neutral feature set for their existing shader-blend gates. Actual live stroke compositing, dual-brush feedback, and pattern feedback still use the legacy OpenGL canvas path | Preserve current painting behavior while the renderer boundary matures for OpenGL parity and later Vulkan/Metal experiments | `pp_renderer_api_tests`; `pp_renderer_gl_capabilities_tests`; `pp_paint_renderer_compositor_tests`; `pano_cli plan-paint-feedback --framebuffer-fetch --explicit-transitions --render-only`; `pano_cli plan-paint-feedback --texture-copy`; `pano_cli plan-stroke-composite --stroke-blend 10 --framebuffer-fetch --explicit-transitions --render-only`; `pano_cli plan-stroke-composite --layer-blend 4 --dual-blend --texture-copy`; `ctest --preset desktop-fast --build-config Debug`; `cmake --build --preset windows-msvc-default --config Debug --target PanoPainter` | Live stroke/layer compositing chooses its feedback path through `pp_paint_renderer` and renderer services, with OpenGL golden parity and Vulkan/Metal lab tests covering framebuffer-fetch and ping-pong behavior | +| DEBT-0036 | Open | Modernization | `pp_renderer_api`, `pp_paint_renderer`, `pano_cli plan-paint-feedback`, and `pano_cli plan-stroke-composite` can choose backend-neutral complex paint feedback strategies for fixed-function blending, framebuffer-fetch-capable renderers, or ping-pong render targets. OpenGL extension detection now stores `pp::renderer::RenderDeviceFeatures` through `ShaderManager`, using `pp_renderer_gl::render_device_features` as the backend conversion point. `pp_paint_renderer::plan_canvas_blend_gate` owns the compatibility mapping from persisted layer/brush blend indices to the extracted stroke-composite planner, and live `Canvas::draw_merge` plus `NodeCanvas` panorama rendering both call it with the stored renderer-neutral feature set for their existing shader-blend gates and destination-copy versus framebuffer-fetch decisions. Actual live stroke compositing, dual-brush feedback, and pattern feedback still use the legacy OpenGL canvas path | Preserve current painting behavior while the renderer boundary matures for OpenGL parity and later Vulkan/Metal experiments | `pp_renderer_api_tests`; `pp_renderer_gl_capabilities_tests`; `pp_paint_renderer_compositor_tests`; `pano_cli plan-paint-feedback --framebuffer-fetch --explicit-transitions --render-only`; `pano_cli plan-paint-feedback --texture-copy`; `pano_cli plan-stroke-composite --stroke-blend 10 --framebuffer-fetch --explicit-transitions --render-only`; `pano_cli plan-stroke-composite --layer-blend 4 --dual-blend --texture-copy`; `ctest --preset desktop-fast --build-config Debug`; `cmake --build --preset windows-msvc-default --config Debug --target PanoPainter` | Live stroke/layer compositing chooses its feedback path through `pp_paint_renderer` and renderer services, with OpenGL golden parity and Vulkan/Metal lab tests covering framebuffer-fetch and ping-pong behavior | ## Closed Debt diff --git a/docs/modernization/roadmap.md b/docs/modernization/roadmap.md index eb4cd08..33e1c8f 100644 --- a/docs/modernization/roadmap.md +++ b/docs/modernization/roadmap.md @@ -882,7 +882,8 @@ the app calls through renderer services for the whole compositing path. mapping from persisted layer and brush blend indices to that planner, including fallback behavior for unknown nonzero indices. Both `Canvas::draw_merge` and `NodeCanvas` panorama rendering consume that shared gate, so the live app no -longer has duplicate local blend-trigger logic. +longer has duplicate local blend-trigger logic or duplicate destination-copy +versus framebuffer-fetch decisions in those paths. The OpenGL shader initialization path now stores a renderer-neutral `RenderDeviceFeatures` snapshot converted by `pp_renderer_gl`, and those live canvas gates consume that snapshot instead of rebuilding feature flags from @@ -1618,6 +1619,10 @@ Results: feature snapshot through the legacy shader manager, and live canvas blend gates consume that `RenderDeviceFeatures` value instead of hand-built framebuffer-fetch/texture-copy flags. +- Canvas draw-merge and `NodeCanvas` panorama shader-blend paths now use the + shared canvas blend-gate plan to decide whether they can read destination + color through framebuffer fetch or must copy the destination texture before + the legacy OpenGL blend draw. - Canvas equirectangular import drawing and depth export rendering now route depth/blend state and active texture units through the renderer GL backend mapping. diff --git a/src/canvas.cpp b/src/canvas.cpp index ff17326..9089297 100644 --- a/src/canvas.cpp +++ b/src/canvas.cpp @@ -49,7 +49,7 @@ pp::renderer::RenderDeviceFeatures canvas_stroke_composite_features() noexcept return ShaderManager::render_device_features(); } -bool draw_merge_needs_shader_blend( +pp::paint_renderer::CanvasBlendGatePlan draw_merge_blend_gate_plan( int width, int height, const std::vector>& layers, @@ -75,7 +75,15 @@ bool draw_merge_needs_shader_blend( .has_stroke_blend_mode = brush != nullptr, .stroke_blend_mode = brush ? brush->m_blend_mode : 0, }); - return plan ? plan.value().shader_blend : true; + if (plan) { + return plan.value(); + } + + pp::paint_renderer::CanvasBlendGatePlan fallback; + fallback.shader_blend = true; + fallback.complex_blend = true; + fallback.compatibility_fallback = true; + return fallback; } GLenum unsigned_byte_component_type() @@ -1121,11 +1129,13 @@ void Canvas::draw_merge(bool draw_checkerboard, std::array faces /*= SI auto ortho = glm::ortho(-0.5f, 0.5f, -0.5f, 0.5f, -1.f, 1.f); const auto& b = m_current_stroke->m_brush; - const bool use_blend = draw_merge_needs_shader_blend( + const auto blend_gate = draw_merge_blend_gate_plan( m_width, m_height, m_layers, m_current_stroke ? m_current_stroke->m_brush.get() : nullptr); + const bool use_blend = blend_gate.shader_blend; + const bool copy_blend_destination = use_blend && !blend_gate.reads_destination_color; // if not using shader blend, use gl rasterizer blend glDisable(depth_test_state()); @@ -1289,7 +1299,7 @@ void Canvas::draw_merge(bool draw_checkerboard, std::array faces /*= SI ShaderManager::u_int(kShaderUniform::BlendMode, m_layers[layer_index]->m_blend_mode); ShaderManager::u_float(kShaderUniform::Alpha, 1.f); ShaderManager::u_mat4(kShaderUniform::MVP, ortho); - if (!ShaderManager::ext_framebuffer_fetch) + if (copy_blend_destination) { m_sampler.bind(2); ShaderManager::u_int(kShaderUniform::TexBG, 2); @@ -1297,7 +1307,7 @@ void Canvas::draw_merge(bool draw_checkerboard, std::array faces /*= SI set_active_texture_unit(0); m_merge_rtt.bindTexture(); - if (!ShaderManager::ext_framebuffer_fetch) + if (copy_blend_destination) { set_active_texture_unit(2); m_merge_tex.bind(); @@ -1306,7 +1316,7 @@ void Canvas::draw_merge(bool draw_checkerboard, std::array faces /*= SI m_plane.draw_fill(); - if (!ShaderManager::ext_framebuffer_fetch) + if (copy_blend_destination) { set_active_texture_unit(2); m_merge_tex.unbind(); diff --git a/src/node_canvas.cpp b/src/node_canvas.cpp index 89965d7..273635b 100644 --- a/src/node_canvas.cpp +++ b/src/node_canvas.cpp @@ -32,7 +32,7 @@ pp::renderer::RenderDeviceFeatures node_canvas_stroke_composite_features() noexc return ShaderManager::render_device_features(); } -bool node_canvas_needs_shader_blend( +pp::paint_renderer::CanvasBlendGatePlan node_canvas_blend_gate_plan( int width, int height, const std::vector>& layers, @@ -58,7 +58,15 @@ bool node_canvas_needs_shader_blend( .has_stroke_blend_mode = brush != nullptr, .stroke_blend_mode = brush ? brush->m_blend_mode : 0, }); - return plan ? plan.value().shader_blend : true; + if (plan) { + return plan.value(); + } + + pp::paint_renderer::CanvasBlendGatePlan fallback; + fallback.shader_blend = true; + fallback.complex_blend = true; + fallback.compatibility_fallback = true; + return fallback; } void run_history_undo_if_available() @@ -290,11 +298,13 @@ void NodeCanvas::draw() } else { - const bool use_blend = node_canvas_needs_shader_blend( + const auto blend_gate = node_canvas_blend_gate_plan( m_cache_rtt.getWidth(), m_cache_rtt.getHeight(), m_canvas->m_layers, m_canvas->m_current_stroke ? m_canvas->m_current_stroke->m_brush.get() : nullptr); + const bool use_blend = blend_gate.shader_blend; + const bool copy_blend_destination = use_blend && !blend_gate.reads_destination_color; if (use_blend) { @@ -485,7 +495,7 @@ void NodeCanvas::draw() ShaderManager::use(kShader::TextureBlend); ShaderManager::u_int(kShaderUniform::Tex, 0); - if (!ShaderManager::ext_framebuffer_fetch) + if (copy_blend_destination) ShaderManager::u_int(kShaderUniform::TexBG, 2); ShaderManager::u_int(kShaderUniform::BlendMode, m_canvas->m_layers[layer_index]->m_blend_mode); ShaderManager::u_float(kShaderUniform::Alpha, 1.f); @@ -493,7 +503,7 @@ void NodeCanvas::draw() set_active_texture_unit(0); m_blender_rtt.bindTexture(); - if (!ShaderManager::ext_framebuffer_fetch) + if (copy_blend_destination) { set_active_texture_unit(2); m_blender_bg.bind(); @@ -503,7 +513,7 @@ void NodeCanvas::draw() m_face_plane.draw_fill(); - if (!ShaderManager::ext_framebuffer_fetch) + if (copy_blend_destination) { set_active_texture_unit(2); m_blender_bg.unbind(); diff --git a/src/paint_renderer/compositor.cpp b/src/paint_renderer/compositor.cpp index 55d0b1f..5140c19 100644 --- a/src/paint_renderer/compositor.cpp +++ b/src/paint_renderer/compositor.cpp @@ -116,11 +116,22 @@ void apply_stroke_plan(CanvasBlendGatePlan& gate, const StrokeCompositePlan& str gate.requires_render_target_blit = stroke.requires_render_target_blit; } -void mark_shader_blend_fallback(CanvasBlendGatePlan& gate) noexcept +void mark_shader_blend_fallback( + CanvasBlendGatePlan& gate, + pp::renderer::RenderDeviceFeatures features) noexcept { gate.shader_blend = true; gate.complex_blend = true; gate.compatibility_fallback = true; + if (features.framebuffer_fetch) { + gate.path = StrokeCompositePath::framebuffer_fetch; + gate.reads_destination_color = true; + } else if (features.texture_copy || features.render_target_blit) { + gate.path = StrokeCompositePath::ping_pong_textures; + gate.requires_auxiliary_texture = true; + gate.requires_texture_copy = features.texture_copy; + gate.requires_render_target_blit = !features.texture_copy && features.render_target_blit; + } } } @@ -227,7 +238,7 @@ pp::foundation::Result plan_canvas_blend_gate( if (!paint_blend_mode_from_persisted_index(request.layer_blend_modes[i], layer_blend)) { if (request.layer_blend_modes[i] != 0) { gate.first_complex_layer_index = static_cast(i); - mark_shader_blend_fallback(gate); + mark_shader_blend_fallback(gate, features); return pp::foundation::Result::success(gate); } continue; @@ -259,7 +270,7 @@ pp::foundation::Result plan_canvas_blend_gate( if (!stroke_blend_mode_from_persisted_index(request.stroke_blend_mode, stroke_blend)) { if (request.stroke_blend_mode != 0) { gate.stroke_complex = true; - mark_shader_blend_fallback(gate); + mark_shader_blend_fallback(gate, features); return pp::foundation::Result::success(gate); } } else if (stroke_blend != pp::paint::StrokeBlendMode::normal) { diff --git a/tests/paint_renderer/compositor_tests.cpp b/tests/paint_renderer/compositor_tests.cpp index 0b5ed38..1de520c 100644 --- a/tests/paint_renderer/compositor_tests.cpp +++ b/tests/paint_renderer/compositor_tests.cpp @@ -317,7 +317,7 @@ void canvas_blend_gate_preserves_legacy_fallbacks(pp::tests::Harness& h) { const std::vector unknown_layer { 0, 99 }; const auto unknown = plan_canvas_blend_gate( - RenderDeviceFeatures {}, + RenderDeviceFeatures { .texture_copy = true }, CanvasBlendGateRequest { .extent = Extent2D { .width = 32, .height = 16 }, .layer_blend_modes = unknown_layer, @@ -328,6 +328,9 @@ void canvas_blend_gate_preserves_legacy_fallbacks(pp::tests::Harness& h) PP_EXPECT(h, unknown.value().complex_blend); PP_EXPECT(h, unknown.value().compatibility_fallback); PP_EXPECT(h, unknown.value().first_complex_layer_index == 1); + PP_EXPECT(h, unknown.value().path == StrokeCompositePath::ping_pong_textures); + PP_EXPECT(h, unknown.value().requires_auxiliary_texture); + PP_EXPECT(h, unknown.value().requires_texture_copy); } const std::vector normal_layers { 0 }; @@ -344,6 +347,21 @@ void canvas_blend_gate_preserves_legacy_fallbacks(pp::tests::Harness& h) PP_EXPECT(h, unsupported.value().shader_blend); PP_EXPECT(h, unsupported.value().stroke_complex); PP_EXPECT(h, unsupported.value().compatibility_fallback); + PP_EXPECT(h, !unsupported.value().requires_texture_copy); + } + + const auto unknown_fetch = plan_canvas_blend_gate( + RenderDeviceFeatures { .framebuffer_fetch = true }, + CanvasBlendGateRequest { + .extent = Extent2D { .width = 32, .height = 16 }, + .layer_blend_modes = unknown_layer, + }); + PP_EXPECT(h, unknown_fetch); + if (unknown_fetch) { + PP_EXPECT(h, unknown_fetch.value().compatibility_fallback); + PP_EXPECT(h, unknown_fetch.value().path == StrokeCompositePath::framebuffer_fetch); + PP_EXPECT(h, unknown_fetch.value().reads_destination_color); + PP_EXPECT(h, !unknown_fetch.value().requires_texture_copy); } const auto dual_pattern = plan_canvas_blend_gate(