diff --git a/docs/modernization/roadmap.md b/docs/modernization/roadmap.md index 5953f79..9df3782 100644 --- a/docs/modernization/roadmap.md +++ b/docs/modernization/roadmap.md @@ -3123,6 +3123,10 @@ Results: texture-input dispatch now shares retained stroke execution helpers, while concrete GL object mapping, framebuffer ownership, shader timing, and final draw execution remain in the legacy Canvas path. +- `pp_paint_renderer_stroke_execution_tests` now covers retained stroke texture + input binding order, sample execution destination-copy behavior, live-pass + face-framebuffer dirty tracking, and pad-face destination-copy behavior + without depending on the broader compositor test translation unit. - `Canvas::stroke_draw` pad-pass destination bind/copy/unbind ordering now shares the retained stroke execution helper callback surface, while shader setup, pad color selection, framebuffer ownership, and final OpenGL draw diff --git a/docs/modernization/tasks.md b/docs/modernization/tasks.md index 985507d..c602464 100644 --- a/docs/modernization/tasks.md +++ b/docs/modernization/tasks.md @@ -509,6 +509,12 @@ Done Checks: Progress Notes: +- 2026-06-13: Added `pp_paint_renderer_stroke_execution_tests` as a dedicated + retained stroke execution-helper target covering texture-input binding order, + sample destination-copy behavior, live-pass face-framebuffer dirty tracking, + and pad-face destination-copy behavior. Next test slice should extend this + target toward the newer sampler-dispatch helpers or preview-side retained + execution helpers. - 2026-06-13: `Canvas::stroke_draw` live-pass sampler bind/unbind plus semantic texture-input dispatch now routes through retained stroke execution helpers; concrete GL object mapping, framebuffer ownership, shader timing, diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index 0478cb8..536e486 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -277,6 +277,30 @@ add_test(NAME pp_paint_renderer_compositor_tests COMMAND pp_paint_renderer_compo set_tests_properties(pp_paint_renderer_compositor_tests PROPERTIES LABELS "renderer;paint;desktop-fast") +add_executable(pp_paint_renderer_stroke_execution_tests + paint_renderer/stroke_execution_tests.cpp) +target_link_libraries(pp_paint_renderer_stroke_execution_tests PRIVATE + pp_paint_renderer + pp_test_harness) +target_include_directories(pp_paint_renderer_stroke_execution_tests PRIVATE + ${PP_LEGACY_INCLUDE_DIRS}) +target_compile_definitions(pp_paint_renderer_stroke_execution_tests PRIVATE + ENUM_BITFIELDS_NOT_SUPPORTED + UNICODE + _UNICODE + _CRT_SECURE_NO_WARNINGS + _SCL_SECURE_NO_WARNINGS + _SILENCE_CXX17_CODECVT_HEADER_DEPRECATION_WARNING + _SILENCE_STDEXT_ARR_ITERS_DEPRECATION_WARNING + _CONSOLE + WITH_CURL=1) +target_precompile_headers(pp_paint_renderer_stroke_execution_tests PRIVATE + "${PROJECT_SOURCE_DIR}/src/pch.h") + +add_test(NAME pp_paint_renderer_stroke_execution_tests COMMAND pp_paint_renderer_stroke_execution_tests) +set_tests_properties(pp_paint_renderer_stroke_execution_tests PROPERTIES + LABELS "renderer;paint;desktop-fast") + add_executable(pp_platform_api_tests platform_api/platform_services_tests.cpp) target_link_libraries(pp_platform_api_tests PRIVATE diff --git a/tests/paint_renderer/stroke_execution_tests.cpp b/tests/paint_renderer/stroke_execution_tests.cpp new file mode 100644 index 0000000..a0a87a7 --- /dev/null +++ b/tests/paint_renderer/stroke_execution_tests.cpp @@ -0,0 +1,382 @@ +#include "pch.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "legacy_canvas_stroke_execution_services.h" +#include "test_harness.h" + +using pp::panopainter::LegacyCanvasStrokePadExecutionRequest; +using pp::panopainter::LegacyCanvasStrokePadFace; +using pp::panopainter::LegacyCanvasStrokeTextureBinding; +using pp::panopainter::LegacyCanvasStrokeTextureInput; +using pp::panopainter::LegacyStrokeSampleExecutionRequest; + +namespace { + +bool nearly_equal(float a, float b) +{ + return std::fabs(a - b) < 0.0001F; +} + +std::array make_quad_vertices() +{ + return { + vertex_t(glm::vec2(10.0F, 20.0F)), + vertex_t(glm::vec2(10.0F, 30.0F)), + vertex_t(glm::vec2(30.0F, 30.0F)), + vertex_t(glm::vec2(30.0F, 20.0F)), + }; +} + +std::array make_sample_points() +{ + return { + pp::paint_renderer::CanvasStrokePoint { .x = 10.0F, .y = 20.0F }, + pp::paint_renderer::CanvasStrokePoint { .x = 10.0F, .y = 30.0F }, + pp::paint_renderer::CanvasStrokePoint { .x = 30.0F, .y = 30.0F }, + pp::paint_renderer::CanvasStrokePoint { .x = 30.0F, .y = 20.0F }, + }; +} + +void retained_stroke_texture_inputs_bind_and_unbind_in_declared_order(pp::tests::Harness& h) +{ + const std::array bindings { + LegacyCanvasStrokeTextureBinding { .input = LegacyCanvasStrokeTextureInput::brush_tip, .slot = 1 }, + LegacyCanvasStrokeTextureBinding { .input = LegacyCanvasStrokeTextureInput::stroke_destination, .slot = 0 }, + LegacyCanvasStrokeTextureBinding { .input = LegacyCanvasStrokeTextureInput::mixer, .slot = 3 }, + LegacyCanvasStrokeTextureBinding { .input = LegacyCanvasStrokeTextureInput::pattern, .slot = 4 }, + }; + + std::vector> bound; + std::vector> unbound; + + pp::panopainter::bind_legacy_canvas_stroke_texture_inputs( + bindings, + [&](LegacyCanvasStrokeTextureInput input, int slot) { + bound.emplace_back(input, slot); + }); + pp::panopainter::unbind_legacy_canvas_stroke_texture_inputs( + bindings, + [&](LegacyCanvasStrokeTextureInput input, int slot) { + unbound.emplace_back(input, slot); + }); + + PP_EXPECT(h, bound.size() == bindings.size()); + PP_EXPECT(h, unbound.size() == bindings.size()); + for (std::size_t index = 0; index < bindings.size(); ++index) { + PP_EXPECT(h, bound[index].first == bindings[index].input); + PP_EXPECT(h, bound[index].second == bindings[index].slot); + PP_EXPECT(h, unbound[index] == bound[index]); + } +} + +void retained_stroke_sample_executor_copies_destination_and_expands_quads(pp::tests::Harness& h) +{ + const auto vertices = make_quad_vertices(); + const auto sample_points = make_sample_points(); + + std::vector events; + std::vector uploaded_vertices; + std::array copy_args {}; + + const auto result = pp::panopainter::execute_legacy_canvas_stroke_sample( + LegacyStrokeSampleExecutionRequest { + .context = "test", + .target_size = glm::vec2(64.0F, 64.0F), + .vertices = vertices, + .sample_points = sample_points, + .copy_stroke_destination = true, + .bind_destination_texture = [&]() { events.emplace_back("bind"); }, + .copy_framebuffer_to_destination_texture = + [&](int dst_x, int dst_y, int src_x, int src_y, int width, int height) { + events.emplace_back("copy"); + copy_args = { dst_x, dst_y, src_x, src_y, width, height }; + }, + .unbind_destination_texture = [&]() { events.emplace_back("unbind"); }, + .upload_brush_vertices = [&](std::span uploaded) { + events.emplace_back("upload"); + uploaded_vertices.assign(uploaded.begin(), uploaded.end()); + }, + .draw_brush_shape = [&]() { events.emplace_back("draw"); }, + }); + + PP_EXPECT(h, result.ok); + PP_EXPECT(h, result.copy_position == glm::ivec2(9, 19)); + PP_EXPECT(h, result.copy_size == glm::ivec2(22, 12)); + const std::vector expected_events { "bind", "copy", "upload", "draw", "unbind" }; + PP_EXPECT(h, nearly_equal(result.dirty_bounds.x, 9.0F)); + PP_EXPECT(h, nearly_equal(result.dirty_bounds.y, 19.0F)); + PP_EXPECT(h, nearly_equal(result.dirty_bounds.z, 31.0F)); + PP_EXPECT(h, nearly_equal(result.dirty_bounds.w, 31.0F)); + PP_EXPECT(h, events == expected_events); + PP_EXPECT(h, copy_args[0] == 9); + PP_EXPECT(h, copy_args[1] == 19); + PP_EXPECT(h, copy_args[2] == 9); + PP_EXPECT(h, copy_args[3] == 19); + PP_EXPECT(h, copy_args[4] == 22); + PP_EXPECT(h, copy_args[5] == 12); + PP_EXPECT(h, uploaded_vertices.size() == 6U); + PP_EXPECT(h, nearly_equal(uploaded_vertices[0].pos.x, vertices[0].pos.x)); + PP_EXPECT(h, nearly_equal(uploaded_vertices[1].pos.y, vertices[1].pos.y)); + PP_EXPECT(h, nearly_equal(uploaded_vertices[2].pos.x, vertices[2].pos.x)); + PP_EXPECT(h, nearly_equal(uploaded_vertices[3].pos.x, vertices[0].pos.x)); + PP_EXPECT(h, nearly_equal(uploaded_vertices[4].pos.x, vertices[2].pos.x)); + PP_EXPECT(h, nearly_equal(uploaded_vertices[5].pos.y, vertices[3].pos.y)); +} + +void retained_stroke_sample_executor_unbinds_and_skips_draw_when_bounds_are_empty(pp::tests::Harness& h) +{ + const auto vertices = make_quad_vertices(); + + int bind_calls = 0; + int copy_calls = 0; + int unbind_calls = 0; + int upload_calls = 0; + int draw_calls = 0; + + const auto result = pp::panopainter::execute_legacy_canvas_stroke_sample( + LegacyStrokeSampleExecutionRequest { + .context = "test", + .target_size = glm::vec2(64.0F, 64.0F), + .vertices = vertices, + .sample_points = {}, + .copy_stroke_destination = true, + .bind_destination_texture = [&]() { ++bind_calls; }, + .copy_framebuffer_to_destination_texture = + [&](int, int, int, int, int, int) { ++copy_calls; }, + .unbind_destination_texture = [&]() { ++unbind_calls; }, + .upload_brush_vertices = [&](std::span) { ++upload_calls; }, + .draw_brush_shape = [&]() { ++draw_calls; }, + }); + + PP_EXPECT(h, !result.ok); + PP_EXPECT(h, bind_calls == 1); + PP_EXPECT(h, copy_calls == 0); + PP_EXPECT(h, unbind_calls == 1); + PP_EXPECT(h, upload_calls == 0); + PP_EXPECT(h, draw_calls == 0); +} + +struct StrokeFrame { + int id = -1; + std::array, 6> shapes {}; +}; + +struct DummyFramebuffer { + std::vector* events = nullptr; + int face_index = -1; + + void bindFramebuffer() + { + events->push_back("bind:" + std::to_string(face_index)); + } + + void unbindFramebuffer() + { + events->push_back("unbind:" + std::to_string(face_index)); + } +}; + +void retained_stroke_live_pass_with_face_framebuffers_preserves_order_and_dirty_tracking(pp::tests::Harness& h) +{ + StrokeFrame frame; + frame.id = 7; + frame.shapes[0] = { + vertex_t(glm::vec2(0.0F, 0.0F)), + vertex_t(glm::vec2(1.0F, 0.0F)), + vertex_t(glm::vec2(1.0F, 1.0F)), + }; + frame.shapes[2] = { + vertex_t(glm::vec2(2.0F, 2.0F)), + vertex_t(glm::vec2(3.0F, 2.0F)), + vertex_t(glm::vec2(3.0F, 3.0F)), + }; + + std::array frames { frame }; + std::array accumulated_dirty_boxes; + std::array pass_dirty_boxes; + accumulated_dirty_boxes.fill(glm::vec4(64.0F, 64.0F, 0.0F, 0.0F)); + pass_dirty_boxes.fill(glm::vec4(64.0F, 64.0F, 0.0F, 0.0F)); + accumulated_dirty_boxes[2] = glm::vec4(7.0F, 8.0F, 9.0F, 10.0F); + pass_dirty_boxes[2] = glm::vec4(20.0F, 21.0F, 22.0F, 23.0F); + std::array include_in_committed_dirty_box { true, true, false, true, true, true }; + std::array committed_dirty_faces {}; + std::array pass_dirty_faces {}; + + std::vector events; + std::array face_framebuffers {}; + for (int face_index = 0; face_index < 6; ++face_index) { + face_framebuffers[face_index].events = &events; + face_framebuffers[face_index].face_index = face_index; + } + + const auto executed_faces = pp::panopainter::execute_legacy_canvas_stroke_live_pass_with_face_framebuffers( + frames, + pp::renderer::Extent2D { .width = 64, .height = 64 }, + accumulated_dirty_boxes, + pass_dirty_boxes, + include_in_committed_dirty_box, + [&](StrokeFrame& current_frame) { + events.push_back("begin-frame:" + std::to_string(current_frame.id)); + }, + [&](StrokeFrame&, int face_index, std::span) { + events.push_back("prepare:" + std::to_string(face_index)); + }, + [&](StrokeFrame&, int face_index, std::span) { + events.push_back("execute:" + std::to_string(face_index)); + if (face_index == 0) { + return glm::vec4(1.0F, 2.0F, 3.0F, 4.0F); + } + return glm::vec4(10.0F, 11.0F, 12.0F, 13.0F); + }, + face_framebuffers, + true, + committed_dirty_faces, + pass_dirty_faces); + + PP_EXPECT(h, executed_faces == 2U); + const std::vector expected_events { + "begin-frame:7", + "prepare:0", + "bind:0", + "execute:0", + "unbind:0", + "prepare:2", + "bind:2", + "execute:2", + "unbind:2", + }; + PP_EXPECT(h, events == expected_events); + PP_EXPECT(h, accumulated_dirty_boxes[0].x <= 1.0F); + PP_EXPECT(h, accumulated_dirty_boxes[0].y <= 2.0F); + PP_EXPECT(h, nearly_equal(accumulated_dirty_boxes[0].z, 3.0F)); + PP_EXPECT(h, nearly_equal(accumulated_dirty_boxes[0].w, 4.0F)); + PP_EXPECT(h, nearly_equal(pass_dirty_boxes[0].x, 1.0F)); + PP_EXPECT(h, nearly_equal(pass_dirty_boxes[0].w, 4.0F)); + PP_EXPECT(h, committed_dirty_faces[0]); + PP_EXPECT(h, pass_dirty_faces[0]); + PP_EXPECT(h, nearly_equal(accumulated_dirty_boxes[2].x, 7.0F)); + PP_EXPECT(h, nearly_equal(accumulated_dirty_boxes[2].y, 8.0F)); + PP_EXPECT(h, nearly_equal(accumulated_dirty_boxes[2].z, 9.0F)); + PP_EXPECT(h, nearly_equal(accumulated_dirty_boxes[2].w, 10.0F)); + PP_EXPECT(h, nearly_equal(pass_dirty_boxes[2].x, 10.0F)); + PP_EXPECT(h, nearly_equal(pass_dirty_boxes[2].y, 11.0F)); + PP_EXPECT(h, nearly_equal(pass_dirty_boxes[2].z, 12.0F)); + PP_EXPECT(h, nearly_equal(pass_dirty_boxes[2].w, 13.0F)); + PP_EXPECT(h, !committed_dirty_faces[2]); + PP_EXPECT(h, pass_dirty_faces[2]); +} + +void retained_stroke_pad_executor_copies_destination_for_dirty_faces_only(pp::tests::Harness& h) +{ + const std::array dirty_faces { true, false, true }; + const std::array pass_dirty_boxes { + glm::vec4(5.0F, 10.0F, 20.0F, 30.0F), + glm::vec4(0.0F, 0.0F, 0.0F, 0.0F), + glm::vec4(60.0F, 15.0F, 70.0F, 25.0F), + }; + const auto faces = pp::panopainter::make_legacy_canvas_stroke_pad_faces(dirty_faces, pass_dirty_boxes); + + std::vector events; + std::vector copy_regions; + + const auto result = pp::panopainter::execute_legacy_canvas_stroke_pad_faces( + LegacyCanvasStrokePadExecutionRequest { + .context = "test", + .extent = pp::renderer::Extent2D { .width = 100, .height = 80 }, + .faces = std::span(faces), + .copy_stroke_destination = true, + .upload_pad_vertices = [&](std::span vertices) { + events.emplace_back("upload:" + std::to_string(vertices.size())); + }, + .begin_face = [&](int face_index) { + events.emplace_back("begin:" + std::to_string(face_index)); + }, + .bind_destination_texture = [&](int face_index) { + events.emplace_back("bind:" + std::to_string(face_index)); + }, + .copy_framebuffer_to_destination_texture = + [&](const pp::paint_renderer::CanvasStrokeCopyRegion& copy_region) { + events.emplace_back("copy"); + copy_regions.push_back(copy_region); + }, + .unbind_destination_texture = [&](int face_index) { + events.emplace_back("unbind:" + std::to_string(face_index)); + }, + .draw_pad = [&]() { events.emplace_back("draw"); }, + .finish_face = [&](int face_index) { + events.emplace_back("finish:" + std::to_string(face_index)); + }, + }); + + PP_EXPECT(h, result.ok); + PP_EXPECT(h, result.padded_faces == 2U); + PP_EXPECT(h, faces[0].index == 0); + PP_EXPECT(h, faces[1].index == 1); + PP_EXPECT(h, faces[2].index == 2); + PP_EXPECT(h, faces[0].dirty); + PP_EXPECT(h, !faces[1].dirty); + PP_EXPECT(h, faces[2].dirty); + const std::vector expected_events { + "upload:6", + "begin:0", + "bind:0", + "copy", + "draw", + "unbind:0", + "finish:0", + "upload:6", + "begin:2", + "bind:2", + "copy", + "draw", + "unbind:2", + "finish:2", + }; + PP_EXPECT(h, events == expected_events); + PP_EXPECT(h, copy_regions.size() == 2U); + PP_EXPECT(h, copy_regions[0].x == 0); + PP_EXPECT(h, copy_regions[0].y == 0); + PP_EXPECT(h, copy_regions[0].width == 40); + PP_EXPECT(h, copy_regions[0].height == 50); +} + +} // namespace + +int main() +{ + pp::tests::Harness harness; + harness.run( + "retained_stroke_texture_inputs_bind_and_unbind_in_declared_order", + retained_stroke_texture_inputs_bind_and_unbind_in_declared_order); + harness.run( + "retained_stroke_sample_executor_copies_destination_and_expands_quads", + retained_stroke_sample_executor_copies_destination_and_expands_quads); + harness.run( + "retained_stroke_sample_executor_unbinds_and_skips_draw_when_bounds_are_empty", + retained_stroke_sample_executor_unbinds_and_skips_draw_when_bounds_are_empty); + harness.run( + "retained_stroke_live_pass_with_face_framebuffers_preserves_order_and_dirty_tracking", + retained_stroke_live_pass_with_face_framebuffers_preserves_order_and_dirty_tracking); + harness.run( + "retained_stroke_pad_executor_copies_destination_for_dirty_faces_only", + retained_stroke_pad_executor_copies_destination_for_dirty_faces_only); + return harness.finish(); +}