From 81726d30a59dfdaae10b70057df14eb89fb8595a Mon Sep 17 00:00:00 2001 From: omigamedev Date: Fri, 12 Jun 2026 22:13:21 +0200 Subject: [PATCH] Plan live stroke rasterization boundaries --- docs/modernization/debt.md | 7 ++ docs/modernization/roadmap.md | 13 +- docs/modernization/tasks.md | 3 +- src/canvas.cpp | 31 +++-- src/legacy_canvas_stroke_services.h | 35 ++++++ src/paint_renderer/compositor.cpp | 70 +++++++++++ src/paint_renderer/compositor.h | 67 ++++++++++ tests/paint_renderer/compositor_tests.cpp | 144 ++++++++++++++++++++++ 8 files changed, 348 insertions(+), 22 deletions(-) create mode 100644 src/legacy_canvas_stroke_services.h diff --git a/docs/modernization/debt.md b/docs/modernization/debt.md index 96a2fe8..032802f 100644 --- a/docs/modernization/debt.md +++ b/docs/modernization/debt.md @@ -18,6 +18,13 @@ agent or engineer to remove them without reconstructing context from chat. ## Recent Reductions +- 2026-06-12: DEBT-0036 was narrowed again. Live `Canvas::stroke_draw` + destination feedback now consumes a named `CanvasStrokeRasterizationPlan` + through `plan_legacy_canvas_stroke_rasterization`, and `pp_paint_renderer` + owns pure material/pass planning for stroke pattern, mixer, dual-brush, and + final composite texture/uniform intent. Retained OpenGL stroke execution + still lives in `Canvas`, but feedback/material decisions now have tested + renderer-facing plan boundaries. - 2026-06-12: DEBT-0036 was narrowed again. The opt-in `desktop-gpu` preset now owns a real OpenGL readback golden gate through `pp_renderer_gl_gpu_readback_tests`, validating a deterministic 1x1 clear diff --git a/docs/modernization/roadmap.md b/docs/modernization/roadmap.md index f62ea95..f73b50e 100644 --- a/docs/modernization/roadmap.md +++ b/docs/modernization/roadmap.md @@ -1332,9 +1332,16 @@ catalog now consumed by the legacy OpenGL app initialization path. OpenGL capability detection for framebuffer fetch, map-buffer alignment, and float texture support. It also owns the OpenGL texture upload-type mapping used by legacy `Texture2D` and `RTT` creation, RGBA pixel-format mapping used by -`RTT` texture allocation, plus image channel-count to texture -format mapping for `Texture2D` image uploads and framebuffer status naming for -`RTT` and `Texture2D` diagnostics. It also owns renderer API texture-format to +`RTT` texture allocation, plus image channel-count to texture format mapping +for `Texture2D` image uploads and framebuffer status naming for `RTT` and +`Texture2D` diagnostics. Live stroke rasterization has started moving toward renderer +services: `Canvas::stroke_draw` now consumes a named +`CanvasStrokeRasterizationPlan` through a legacy adapter boundary for +destination feedback/copy decisions, and `pp_paint_renderer` owns pure +stroke material/pass planning for pattern, mixer, dual-brush, and final +composite texture/uniform intent. Actual retained OpenGL draw execution remains +in `Canvas` under `DEBT-0036`. +It also owns renderer API texture-format to OpenGL internal/pixel/component token mapping, including depth-stencil formats, for future backend texture objects. `Texture2D` 2D texture binding, upload, mipmap generation, framebuffer readback setup, and update component-type tokens diff --git a/docs/modernization/tasks.md b/docs/modernization/tasks.md index 3cb3e82..911171a 100644 --- a/docs/modernization/tasks.md +++ b/docs/modernization/tasks.md @@ -495,10 +495,9 @@ Done Checks: ### LATER-003 - Live Stroke Rasterization Through Renderer Services -Status: Blocked +Status: Ready Score: +5 renderer boundary and OpenGL parity Debt: `DEBT-0036` -Blocked By: `RND-001`, `RND-002`, `RND-003`, and at least one GPU golden gate Done Checks: diff --git a/src/canvas.cpp b/src/canvas.cpp index cc1c94e..bec4bbc 100644 --- a/src/canvas.cpp +++ b/src/canvas.cpp @@ -3,6 +3,7 @@ #include "canvas.h" #include "app.h" #include "legacy_gl_renderbuffer_dispatch.h" +#include "legacy_canvas_stroke_services.h" #include "legacy_ui_gl_dispatch.h" #include "legacy_ui_overlay_services.h" #include "app_core/document_canvas.h" @@ -43,25 +44,21 @@ pp::renderer::RenderDeviceFeatures canvas_render_device_features() noexcept return ShaderManager::render_device_features(); } +pp::paint_renderer::CanvasStrokeRasterizationPlan canvas_stroke_rasterization_plan( + int width, + int height) noexcept +{ + return pp::panopainter::plan_legacy_canvas_stroke_rasterization( + canvas_render_device_features(), + width, + height); +} + pp::paint_renderer::CanvasStrokeFeedbackPlan canvas_destination_feedback_plan( int width, int height) noexcept { - const auto plan = pp::paint_renderer::plan_canvas_stroke_feedback( - canvas_render_device_features(), - pp::renderer::Extent2D { - .width = static_cast(std::max(width, 0)), - .height = static_cast(std::max(height, 0)), - }); - if (plan) { - return plan.value(); - } - - pp::paint_renderer::CanvasStrokeFeedbackPlan fallback; - fallback.compatibility_fallback = true; - fallback.path = pp::paint_renderer::StrokeCompositePath::ping_pong_textures; - fallback.requires_auxiliary_texture = true; - return fallback; + return canvas_stroke_rasterization_plan(width, height).feedback; } pp::paint_renderer::CanvasBlendGatePlan draw_merge_blend_gate_plan( @@ -667,8 +664,8 @@ void Canvas::stroke_draw() if (brush->m_pattern_flipx) patt_scale.x *= -1.f; if (brush->m_pattern_flipy) patt_scale.y *= -1.f; - const auto stroke_feedback = canvas_destination_feedback_plan(m_width, m_height); - const bool copy_stroke_destination = !stroke_feedback.reads_destination_color; + const auto stroke_rasterization = canvas_stroke_rasterization_plan(m_width, m_height); + const bool copy_stroke_destination = stroke_rasterization.copy_stroke_destination; apply_canvas_capability(blend_state(), false); ShaderManager::use(kShader::Stroke); diff --git a/src/legacy_canvas_stroke_services.h b/src/legacy_canvas_stroke_services.h new file mode 100644 index 0000000..ead7e74 --- /dev/null +++ b/src/legacy_canvas_stroke_services.h @@ -0,0 +1,35 @@ +#pragma once + +#include "paint_renderer/compositor.h" +#include "renderer_api/renderer_api.h" + +#include +#include + +namespace pp::panopainter { + +[[nodiscard]] inline pp::paint_renderer::CanvasStrokeRasterizationPlan plan_legacy_canvas_stroke_rasterization( + pp::renderer::RenderDeviceFeatures features, + int width, + int height) noexcept +{ + const auto plan = pp::paint_renderer::plan_canvas_stroke_rasterization( + features, + pp::renderer::Extent2D { + .width = static_cast(std::max(width, 0)), + .height = static_cast(std::max(height, 0)), + }); + if (plan) { + return plan.value(); + } + + pp::paint_renderer::CanvasStrokeRasterizationPlan fallback; + fallback.feedback.compatibility_fallback = true; + fallback.feedback.path = pp::paint_renderer::StrokeCompositePath::ping_pong_textures; + fallback.feedback.requires_auxiliary_texture = true; + fallback.copy_stroke_destination = true; + fallback.compatibility_fallback = true; + return fallback; +} + +} // namespace pp::panopainter diff --git a/src/paint_renderer/compositor.cpp b/src/paint_renderer/compositor.cpp index 41673c0..955735e 100644 --- a/src/paint_renderer/compositor.cpp +++ b/src/paint_renderer/compositor.cpp @@ -1186,6 +1186,56 @@ pp::foundation::Result plan_stroke_composite( return pp::foundation::Result::success(plan); } +CanvasStrokeMaterialPlan plan_canvas_stroke_material(CanvasStrokeMaterialRequest request) noexcept +{ + CanvasStrokeMaterialPlan plan; + + auto bind = [&plan](CanvasStrokeTextureRole role, std::uint8_t slot) noexcept { + if (plan.texture_binding_count >= plan.texture_bindings.size()) { + return; + } + plan.texture_bindings[plan.texture_binding_count] = CanvasStrokeTextureBindingPlan { + .role = role, + .slot = slot, + }; + ++plan.texture_binding_count; + }; + + bind(CanvasStrokeTextureRole::main_brush_tip, 0); + + plan.stroke_pass.uses_destination_feedback = request.destination_feedback_needed; + if (request.destination_feedback_needed) { + bind(CanvasStrokeTextureRole::destination_feedback, 1); + } + + plan.stroke_pass.uses_pattern = request.pattern_enabled && request.pattern_eachsample; + if (plan.stroke_pass.uses_pattern) { + bind(CanvasStrokeTextureRole::pattern, 2); + } + + plan.stroke_pass.uses_mixer = request.wet_blend || request.mix_blend || request.noise_enabled; + if (plan.stroke_pass.uses_mixer) { + bind(CanvasStrokeTextureRole::mixer, 3); + } + + plan.dual_pass.enabled = request.dual_brush_enabled; + plan.dual_pass.uses_pattern = false; + if (request.dual_brush_enabled) { + bind(CanvasStrokeTextureRole::dual_brush_tip, 4); + } + + plan.composite_pass.use_dual = request.dual_brush_enabled; + plan.composite_pass.use_pattern = request.pattern_enabled && !request.pattern_eachsample; + plan.composite_pass.dual_blend_mode = request.dual_blend_mode; + plan.composite_pass.pattern_blend_mode = request.pattern_blend_mode; + plan.composite_pass.dual_alpha = request.dual_alpha; + if (plan.composite_pass.use_pattern && !plan.stroke_pass.uses_pattern) { + bind(CanvasStrokeTextureRole::pattern, 2); + } + + return plan; +} + pp::foundation::Result plan_canvas_blend_gate( pp::renderer::RenderDeviceFeatures features, CanvasBlendGateRequest request) noexcept @@ -1301,6 +1351,26 @@ pp::foundation::Result plan_canvas_stroke_feedback( return pp::foundation::Result::success(fallback); } +pp::foundation::Result plan_canvas_stroke_rasterization( + pp::renderer::RenderDeviceFeatures features, + pp::renderer::Extent2D extent) noexcept +{ + const auto feedback = plan_canvas_stroke_feedback(features, extent); + if (!feedback) { + return pp::foundation::Result::failure(feedback.status()); + } + + CanvasStrokeRasterizationPlan plan; + plan.feedback = feedback.value(); + plan.copy_stroke_destination = !plan.feedback.reads_destination_color; + plan.can_route_feedback_through_renderer = + plan.feedback.reads_destination_color + || plan.feedback.requires_texture_copy + || plan.feedback.requires_render_target_blit; + plan.compatibility_fallback = plan.feedback.compatibility_fallback; + return pp::foundation::Result::success(plan); +} + const char* stroke_composite_path_name(StrokeCompositePath path) noexcept { switch (path) { diff --git a/src/paint_renderer/compositor.h b/src/paint_renderer/compositor.h index 0bab26f..00dd238 100644 --- a/src/paint_renderer/compositor.h +++ b/src/paint_renderer/compositor.h @@ -56,6 +56,59 @@ struct StrokeCompositePlan { bool requires_explicit_transition = false; }; +enum class CanvasStrokeTextureRole : std::uint8_t { + main_brush_tip, + destination_feedback, + pattern, + mixer, + dual_brush_tip, +}; + +struct CanvasStrokeTextureBindingPlan { + CanvasStrokeTextureRole role = CanvasStrokeTextureRole::main_brush_tip; + std::uint8_t slot = 0; +}; + +struct CanvasStrokeMaterialRequest { + bool destination_feedback_needed = false; + bool pattern_enabled = false; + bool pattern_eachsample = false; + bool wet_blend = false; + bool mix_blend = false; + bool noise_enabled = false; + bool dual_brush_enabled = false; + int dual_blend_mode = 0; + int pattern_blend_mode = 0; + float dual_alpha = 1.0F; +}; + +struct CanvasStrokeShaderPassPlan { + bool uses_destination_feedback = false; + bool uses_pattern = false; + bool uses_mixer = false; +}; + +struct CanvasStrokeDualPassPlan { + bool enabled = false; + bool uses_pattern = false; +}; + +struct CanvasStrokeCompositePassPlan { + bool use_dual = false; + bool use_pattern = false; + int dual_blend_mode = 0; + int pattern_blend_mode = 0; + float dual_alpha = 1.0F; +}; + +struct CanvasStrokeMaterialPlan { + CanvasStrokeShaderPassPlan stroke_pass {}; + CanvasStrokeDualPassPlan dual_pass {}; + CanvasStrokeCompositePassPlan composite_pass {}; + std::array texture_bindings {}; + std::size_t texture_binding_count = 0; +}; + struct CanvasBlendGateRequest { pp::renderer::Extent2D extent {}; std::span layer_blend_modes; @@ -89,6 +142,13 @@ struct CanvasStrokeFeedbackPlan { bool compatibility_fallback = false; }; +struct CanvasStrokeRasterizationPlan { + CanvasStrokeFeedbackPlan feedback {}; + bool copy_stroke_destination = true; + bool can_route_feedback_through_renderer = false; + bool compatibility_fallback = false; +}; + struct DocumentFaceCompositeRequest { const pp::document::CanvasDocument* document = nullptr; std::size_t frame_index = 0; @@ -318,6 +378,9 @@ export_document_animation_frames_equirectangular_pngs( pp::renderer::RenderDeviceFeatures features, StrokeCompositeRequest request) noexcept; +[[nodiscard]] CanvasStrokeMaterialPlan plan_canvas_stroke_material( + CanvasStrokeMaterialRequest request) noexcept; + [[nodiscard]] pp::foundation::Result plan_canvas_blend_gate( pp::renderer::RenderDeviceFeatures features, CanvasBlendGateRequest request) noexcept; @@ -326,6 +389,10 @@ export_document_animation_frames_equirectangular_pngs( pp::renderer::RenderDeviceFeatures features, pp::renderer::Extent2D extent) noexcept; +[[nodiscard]] pp::foundation::Result plan_canvas_stroke_rasterization( + pp::renderer::RenderDeviceFeatures features, + pp::renderer::Extent2D extent) noexcept; + [[nodiscard]] const char* stroke_composite_path_name(StrokeCompositePath path) noexcept; } diff --git a/tests/paint_renderer/compositor_tests.cpp b/tests/paint_renderer/compositor_tests.cpp index 096e1bf..e9f0054 100644 --- a/tests/paint_renderer/compositor_tests.cpp +++ b/tests/paint_renderer/compositor_tests.cpp @@ -15,6 +15,8 @@ using pp::paint::Rgba; using pp::paint::StrokeBlendMode; using pp::assets::decode_png_rgba8; using pp::paint_renderer::CanvasBlendGateRequest; +using pp::paint_renderer::CanvasStrokeMaterialRequest; +using pp::paint_renderer::CanvasStrokeTextureRole; using pp::paint_renderer::DocumentFaceCompositeRequest; using pp::paint_renderer::DocumentFrameCompositeRequest; using pp::paint_renderer::LayerCompositeView; @@ -26,6 +28,8 @@ using pp::paint_renderer::composite_document_frame; using pp::paint_renderer::export_document_depth_pngs; using pp::paint_renderer::plan_canvas_blend_gate; using pp::paint_renderer::plan_canvas_stroke_feedback; +using pp::paint_renderer::plan_canvas_stroke_material; +using pp::paint_renderer::plan_canvas_stroke_rasterization; using pp::paint_renderer::plan_document_depth_export_render; using pp::paint_renderer::plan_stroke_composite; using pp::paint_renderer::stroke_composite_path_name; @@ -49,6 +53,19 @@ bool near(float a, float b) return std::fabs(a - b) < 0.0001F; } +bool has_texture_binding( + const pp::paint_renderer::CanvasStrokeMaterialPlan& plan, + CanvasStrokeTextureRole role, + std::uint8_t slot) +{ + for (std::size_t i = 0; i < plan.texture_binding_count; ++i) { + if (plan.texture_bindings[i].role == role && plan.texture_bindings[i].slot == slot) { + return true; + } + } + return false; +} + std::vector solid_rgba8( std::uint32_t width, std::uint32_t height, @@ -1559,6 +1576,87 @@ void rejects_bad_stroke_composite_plans(pp::tests::Harness& h) PP_EXPECT(h, stroke_composite_path_name(static_cast(255)) == std::string_view("unknown")); } +void plans_canvas_stroke_material_passes(pp::tests::Harness& h) +{ + const auto simple = plan_canvas_stroke_material(CanvasStrokeMaterialRequest {}); + PP_EXPECT(h, simple.texture_binding_count == 1U); + PP_EXPECT(h, has_texture_binding(simple, CanvasStrokeTextureRole::main_brush_tip, 0)); + PP_EXPECT(h, !simple.stroke_pass.uses_destination_feedback); + PP_EXPECT(h, !simple.stroke_pass.uses_pattern); + PP_EXPECT(h, !simple.stroke_pass.uses_mixer); + PP_EXPECT(h, !simple.dual_pass.enabled); + PP_EXPECT(h, !simple.composite_pass.use_dual); + PP_EXPECT(h, !simple.composite_pass.use_pattern); + + const auto eachsample = plan_canvas_stroke_material( + CanvasStrokeMaterialRequest { + .destination_feedback_needed = true, + .pattern_enabled = true, + .pattern_eachsample = true, + .wet_blend = true, + .noise_enabled = true, + }); + PP_EXPECT(h, eachsample.stroke_pass.uses_destination_feedback); + PP_EXPECT(h, eachsample.stroke_pass.uses_pattern); + PP_EXPECT(h, eachsample.stroke_pass.uses_mixer); + PP_EXPECT(h, !eachsample.composite_pass.use_pattern); + PP_EXPECT(h, has_texture_binding(eachsample, CanvasStrokeTextureRole::main_brush_tip, 0)); + PP_EXPECT(h, has_texture_binding(eachsample, CanvasStrokeTextureRole::destination_feedback, 1)); + PP_EXPECT(h, has_texture_binding(eachsample, CanvasStrokeTextureRole::pattern, 2)); + PP_EXPECT(h, has_texture_binding(eachsample, CanvasStrokeTextureRole::mixer, 3)); + + const auto composite_pattern = plan_canvas_stroke_material( + CanvasStrokeMaterialRequest { + .pattern_enabled = true, + .pattern_eachsample = false, + .pattern_blend_mode = 6, + }); + PP_EXPECT(h, !composite_pattern.stroke_pass.uses_pattern); + PP_EXPECT(h, composite_pattern.composite_pass.use_pattern); + PP_EXPECT(h, composite_pattern.composite_pass.pattern_blend_mode == 6); + PP_EXPECT(h, has_texture_binding(composite_pattern, CanvasStrokeTextureRole::pattern, 2)); +} + +void plans_canvas_stroke_dual_material_intent(pp::tests::Harness& h) +{ + const auto dual = plan_canvas_stroke_material( + CanvasStrokeMaterialRequest { + .pattern_enabled = true, + .pattern_eachsample = true, + .dual_brush_enabled = true, + .dual_blend_mode = 3, + .pattern_blend_mode = 4, + .dual_alpha = 0.625F, + }); + + PP_EXPECT(h, dual.stroke_pass.uses_pattern); + PP_EXPECT(h, dual.dual_pass.enabled); + PP_EXPECT(h, !dual.dual_pass.uses_pattern); + PP_EXPECT(h, dual.composite_pass.use_dual); + PP_EXPECT(h, !dual.composite_pass.use_pattern); + PP_EXPECT(h, dual.composite_pass.dual_blend_mode == 3); + PP_EXPECT(h, dual.composite_pass.pattern_blend_mode == 4); + PP_EXPECT(h, near(dual.composite_pass.dual_alpha, 0.625F)); + PP_EXPECT(h, has_texture_binding(dual, CanvasStrokeTextureRole::dual_brush_tip, 4)); + + const auto dual_composite_pattern = plan_canvas_stroke_material( + CanvasStrokeMaterialRequest { + .pattern_enabled = true, + .pattern_eachsample = false, + .mix_blend = true, + .dual_brush_enabled = true, + }); + PP_EXPECT(h, !dual_composite_pattern.stroke_pass.uses_pattern); + PP_EXPECT(h, dual_composite_pattern.stroke_pass.uses_mixer); + PP_EXPECT(h, dual_composite_pattern.dual_pass.enabled); + PP_EXPECT(h, !dual_composite_pattern.dual_pass.uses_pattern); + PP_EXPECT(h, dual_composite_pattern.composite_pass.use_dual); + PP_EXPECT(h, dual_composite_pattern.composite_pass.use_pattern); + PP_EXPECT(h, has_texture_binding(dual_composite_pattern, CanvasStrokeTextureRole::mixer, 3)); + PP_EXPECT(h, has_texture_binding(dual_composite_pattern, CanvasStrokeTextureRole::dual_brush_tip, 4)); + PP_EXPECT(h, has_texture_binding(dual_composite_pattern, CanvasStrokeTextureRole::pattern, 2)); +} + void plans_canvas_blend_gate_from_persisted_indices(pp::tests::Harness& h) { const std::vector normal_layers { 0, 0, 0 }; @@ -1738,6 +1836,49 @@ void canvas_stroke_feedback_preserves_legacy_fallback(pp::tests::Harness& h) PP_EXPECT(h, invalid.status().code == StatusCode::invalid_argument); } +void plans_canvas_stroke_rasterization_boundary(pp::tests::Harness& h) +{ + const Extent2D extent { .width = 32, .height = 16 }; + const auto fetch = plan_canvas_stroke_rasterization( + RenderDeviceFeatures { .framebuffer_fetch = true }, + extent); + PP_EXPECT(h, fetch); + if (fetch) { + PP_EXPECT(h, fetch.value().feedback.path == StrokeCompositePath::framebuffer_fetch); + PP_EXPECT(h, !fetch.value().copy_stroke_destination); + PP_EXPECT(h, fetch.value().can_route_feedback_through_renderer); + PP_EXPECT(h, !fetch.value().compatibility_fallback); + } + + const auto copy = plan_canvas_stroke_rasterization( + RenderDeviceFeatures { .texture_copy = true }, + extent); + PP_EXPECT(h, copy); + if (copy) { + PP_EXPECT(h, copy.value().feedback.path == StrokeCompositePath::ping_pong_textures); + PP_EXPECT(h, copy.value().copy_stroke_destination); + PP_EXPECT(h, copy.value().can_route_feedback_through_renderer); + PP_EXPECT(h, !copy.value().compatibility_fallback); + } + + const auto fallback = plan_canvas_stroke_rasterization( + RenderDeviceFeatures {}, + extent); + PP_EXPECT(h, fallback); + if (fallback) { + PP_EXPECT(h, fallback.value().feedback.path == StrokeCompositePath::ping_pong_textures); + PP_EXPECT(h, fallback.value().copy_stroke_destination); + PP_EXPECT(h, !fallback.value().can_route_feedback_through_renderer); + PP_EXPECT(h, fallback.value().compatibility_fallback); + } + + const auto invalid = plan_canvas_stroke_rasterization( + RenderDeviceFeatures { .texture_copy = true }, + Extent2D { .width = 0, .height = 16 }); + PP_EXPECT(h, !invalid.ok()); + PP_EXPECT(h, invalid.status().code == StatusCode::invalid_argument); +} + } int main() @@ -1772,9 +1913,12 @@ int main() harness.run("detects_feedback_requirements", detects_feedback_requirements); harness.run("plans_stroke_composite_paths", plans_stroke_composite_paths); harness.run("rejects_bad_stroke_composite_plans", rejects_bad_stroke_composite_plans); + harness.run("plans_canvas_stroke_material_passes", plans_canvas_stroke_material_passes); + harness.run("plans_canvas_stroke_dual_material_intent", plans_canvas_stroke_dual_material_intent); harness.run("plans_canvas_blend_gate_from_persisted_indices", plans_canvas_blend_gate_from_persisted_indices); harness.run("canvas_blend_gate_preserves_legacy_fallbacks", canvas_blend_gate_preserves_legacy_fallbacks); harness.run("plans_canvas_stroke_feedback_paths", plans_canvas_stroke_feedback_paths); harness.run("canvas_stroke_feedback_preserves_legacy_fallback", canvas_stroke_feedback_preserves_legacy_fallback); + harness.run("plans_canvas_stroke_rasterization_boundary", plans_canvas_stroke_rasterization_boundary); return harness.finish(); }