From 8c0784f9c31bf8504480b76b1f1b78adaed23612 Mon Sep 17 00:00:00 2001 From: omigamedev Date: Tue, 2 Jun 2026 17:23:44 +0200 Subject: [PATCH] Add stroke alpha blend reference tests --- docs/modernization/build-inventory.md | 4 +- docs/modernization/capability-map.md | 5 +- docs/modernization/roadmap.md | 5 +- src/paint/blend.cpp | 129 ++++++++++++++++++++++++++ src/paint/blend.h | 20 ++++ tests/paint/blend_tests.cpp | 38 ++++++++ 6 files changed, 194 insertions(+), 7 deletions(-) diff --git a/docs/modernization/build-inventory.md b/docs/modernization/build-inventory.md index 51f1aa0..2715567 100644 --- a/docs/modernization/build-inventory.md +++ b/docs/modernization/build-inventory.md @@ -94,8 +94,8 @@ Known local toolchain state: including foundation event/logging/task queue coverage, PNG metadata and decode, PPI header/layout, settings document, document snapshot/per-layer-frame/move/duration/face-pixel/PPI export coverage, - snapshot-embedded face-payload rejection, paint brush/stroke/stroke-script - coverage, renderer shader descriptor and OpenGL capability coverage, UI + snapshot-embedded face-payload rejection, paint brush/final-blend/ + stroke-alpha-blend/stroke/stroke-script coverage, renderer shader descriptor and OpenGL capability coverage, UI color parsing, and layout XML parse coverage. - `pano_cli inspect-image` reports PNG IHDR metadata as JSON and is covered by `pano_cli_inspect_png_metadata_smoke` with a tiny IHDR fixture. diff --git a/docs/modernization/capability-map.md b/docs/modernization/capability-map.md index ffd0118..98eaeca 100644 --- a/docs/modernization/capability-map.md +++ b/docs/modernization/capability-map.md @@ -34,8 +34,8 @@ and validation command. | ABR import | `ABR`, `Brush` | `pp_assets`, `pp_paint` | Sample ABR and malformed ABR | | 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` | CPU reference and GPU golden | -| Blend modes | GLSL include files, layer rendering | `pp_paint`, `pp_paint_renderer` | CPU reference vectors and GPU parity | +| Dual brush/pattern behavior | `Brush`, shaders | `pp_paint`, `pp_paint_renderer` | Stroke-alpha CPU reference and GPU golden | +| Blend modes | GLSL include files, layer rendering | `pp_paint`, `pp_paint_renderer` | Final RGBA and stroke-alpha CPU reference vectors plus GPU parity | | Erase/flood fill/masks | `Canvas`, modes, shaders | `pp_document`, `pp_paint_renderer` | Edge masks, alpha lock, dirty rects | ## Layers And Animation @@ -80,4 +80,3 @@ and validation command. | Logging/crash reporting | `log`, BugTrap/AppCenter | `pp_foundation`, platform wrappers | Log formatting and platform compile | | Headless automation | none yet | `tools/pano_cli` | JSON command fixtures | | Tracing | none yet | `pp_foundation` | Span nesting/timing tests | - diff --git a/docs/modernization/roadmap.md b/docs/modernization/roadmap.md index 593ad27..ad09c98 100644 --- a/docs/modernization/roadmap.md +++ b/docs/modernization/roadmap.md @@ -318,7 +318,8 @@ asset-level RGBA PNG payload decoding, and a pure typed settings document model, with corrupt/truncated/unsupported, extreme-dimension, and key/value limit tests. `pp_paint` has started with pure brush parameter validation/stamp evaluation, -CPU reference math for the five current shader blend modes, and deterministic +CPU reference math for the five current final RGBA shader blend modes plus the +shader-style stroke-alpha blend modes used by pattern/dual-brush mixing, and deterministic stroke spacing/interpolation plus a pure text stroke-script parser. `pp_document` has started with a pure canvas/layer/frame model, alpha-lock metadata, snapshot @@ -537,7 +538,7 @@ Implementation tasks: - layer blend - equirect export - readback bounds -- Add CPU reference tests for blend modes. +- Add CPU reference tests for final RGBA and stroke-alpha blend modes. - Compare GPU output to golden/reference data with explicit tolerances. Gate: diff --git a/src/paint/blend.cpp b/src/paint/blend.cpp index 3d3810e..fd126c7 100644 --- a/src/paint/blend.cpp +++ b/src/paint/blend.cpp @@ -44,6 +44,67 @@ namespace { return stroke; } +[[nodiscard]] float blend_stroke_screen(float base, float stroke) noexcept +{ + return base + stroke - (base * stroke); +} + +[[nodiscard]] float blend_stroke_hard_light(float base, float stroke) noexcept +{ + return stroke < 0.5F + ? base * (stroke * 2.0F) + : blend_stroke_screen(base, 2.0F * stroke - 1.0F); +} + +[[nodiscard]] float blend_stroke_hard_mix(float base, float stroke) noexcept +{ + if (base == 0.0F) { + return 0.0F; + } + + return base + stroke < 0.5F ? 0.0F : saturate(base + stroke); +} + +[[nodiscard]] float blend_stroke_color_dodge(float base, float stroke) noexcept +{ + if (base == 0.0F) { + return 0.0F; + } + + if (stroke == 1.0F) { + return 1.0F; + } + + return base / (1.0F - stroke); +} + +[[nodiscard]] float blend_stroke_color_burn(float base, float stroke) noexcept +{ + if (base == 1.0F) { + return 1.0F; + } + + if (stroke == 0.0F) { + return 0.0F; + } + + return 1.0F - std::min(1.0F, (1.0F - base) / stroke); +} + +[[nodiscard]] float blend_stroke_linear_height(float base, float stroke, float depth) noexcept +{ + const auto partial = (1.0F - stroke) * std::pow(depth, 0.25F) + (base * depth * 10.0F); + return base * partial; +} + +[[nodiscard]] float blend_stroke_height(float base, float stroke, float depth) noexcept +{ + const auto a = std::pow(1.0F - stroke, std::max(1.0F, (1.0F - depth) * 10.0F)) + * std::pow(depth, 0.25F); + const auto b = base * depth * 5.0F; + return base * (a + b); +} + [[nodiscard]] float blend_rgb(float base, float stroke, float base_alpha, float stroke_alpha, float alpha_total, BlendMode mode) noexcept { if (alpha_total <= 0.0F) { @@ -88,6 +149,44 @@ Rgba blend_pixels(Rgba base, Rgba stroke, BlendMode mode) noexcept }; } +float blend_stroke_alpha( + float base, + float stroke, + float depth, + StrokeBlendMode mode) noexcept +{ + base = saturate(base); + stroke = saturate(stroke); + depth = saturate(depth); + + switch (mode) { + case StrokeBlendMode::normal: + return saturate(mix(base, stroke, depth)); + case StrokeBlendMode::multiply: + return saturate(mix(base, base * stroke, depth)); + case StrokeBlendMode::subtract: + return saturate(mix(base, std::max(0.0F, base - stroke), depth)); + case StrokeBlendMode::darken: + return saturate(mix(base, std::min(base, stroke), depth)); + case StrokeBlendMode::overlay: + return saturate(mix(base, blend_stroke_hard_light(stroke, base), depth)); + case StrokeBlendMode::color_dodge: + return saturate(mix(base, blend_stroke_color_dodge(base, stroke), depth)); + case StrokeBlendMode::color_burn: + return saturate(mix(base, blend_stroke_color_burn(base, stroke), depth)); + case StrokeBlendMode::linear_burn: + return saturate(mix(base, saturate(base + stroke - 1.0F), depth)); + case StrokeBlendMode::hard_mix: + return saturate(mix(base, blend_stroke_hard_mix(base, stroke), depth)); + case StrokeBlendMode::linear_height: + return saturate(blend_stroke_linear_height(base, stroke, depth)); + case StrokeBlendMode::height: + return saturate(blend_stroke_height(base, stroke, depth)); + } + + return 1.0F; +} + const char* blend_mode_name(BlendMode mode) noexcept { switch (mode) { @@ -106,4 +205,34 @@ const char* blend_mode_name(BlendMode mode) noexcept return "unknown"; } +const char* stroke_blend_mode_name(StrokeBlendMode mode) noexcept +{ + switch (mode) { + case StrokeBlendMode::normal: + return "normal"; + case StrokeBlendMode::multiply: + return "multiply"; + case StrokeBlendMode::subtract: + return "subtract"; + case StrokeBlendMode::darken: + return "darken"; + case StrokeBlendMode::overlay: + return "overlay"; + case StrokeBlendMode::color_dodge: + return "color_dodge"; + case StrokeBlendMode::color_burn: + return "color_burn"; + case StrokeBlendMode::linear_burn: + return "linear_burn"; + case StrokeBlendMode::hard_mix: + return "hard_mix"; + case StrokeBlendMode::linear_height: + return "linear_height"; + case StrokeBlendMode::height: + return "height"; + } + + return "unknown"; +} + } diff --git a/src/paint/blend.h b/src/paint/blend.h index 6aea086..7f28080 100644 --- a/src/paint/blend.h +++ b/src/paint/blend.h @@ -12,6 +12,20 @@ enum class BlendMode : std::uint8_t { overlay, }; +enum class StrokeBlendMode : std::uint8_t { + normal, + multiply, + subtract, + darken, + overlay, + color_dodge, + color_burn, + linear_burn, + hard_mix, + linear_height, + height, +}; + struct Rgba { float r = 0.0F; float g = 0.0F; @@ -20,6 +34,12 @@ struct Rgba { }; [[nodiscard]] Rgba blend_pixels(Rgba base, Rgba stroke, BlendMode mode) noexcept; +[[nodiscard]] float blend_stroke_alpha( + float base, + float stroke, + float depth, + StrokeBlendMode mode) noexcept; [[nodiscard]] const char* blend_mode_name(BlendMode mode) noexcept; +[[nodiscard]] const char* stroke_blend_mode_name(StrokeBlendMode mode) noexcept; } diff --git a/tests/paint/blend_tests.cpp b/tests/paint/blend_tests.cpp index b8ad8f4..4414e30 100644 --- a/tests/paint/blend_tests.cpp +++ b/tests/paint/blend_tests.cpp @@ -6,8 +6,11 @@ using pp::paint::BlendMode; using pp::paint::Rgba; +using pp::paint::StrokeBlendMode; using pp::paint::blend_mode_name; using pp::paint::blend_pixels; +using pp::paint::blend_stroke_alpha; +using pp::paint::stroke_blend_mode_name; namespace { @@ -91,6 +94,39 @@ void clamps_inputs_and_names_modes(pp::tests::Harness& h) PP_EXPECT(h, blend_mode_name(BlendMode::overlay) == std::string_view("overlay")); } +void stroke_alpha_blend_modes_match_shader_reference_vectors(pp::tests::Harness& h) +{ + PP_EXPECT(h, near(blend_stroke_alpha(0.2F, 0.8F, 0.25F, StrokeBlendMode::normal), 0.35F)); + PP_EXPECT(h, near(blend_stroke_alpha(0.6F, 0.5F, 1.0F, StrokeBlendMode::multiply), 0.3F)); + PP_EXPECT(h, near(blend_stroke_alpha(0.6F, 0.2F, 1.0F, StrokeBlendMode::subtract), 0.4F)); + PP_EXPECT(h, near(blend_stroke_alpha(0.6F, 0.2F, 1.0F, StrokeBlendMode::darken), 0.2F)); + PP_EXPECT(h, near(blend_stroke_alpha(0.75F, 0.25F, 1.0F, StrokeBlendMode::overlay), 0.625F)); + PP_EXPECT(h, near(blend_stroke_alpha(0.4F, 0.5F, 1.0F, StrokeBlendMode::color_dodge), 0.8F)); + PP_EXPECT(h, near(blend_stroke_alpha(0.6F, 0.5F, 1.0F, StrokeBlendMode::color_burn), 0.2F)); + PP_EXPECT(h, near(blend_stroke_alpha(0.75F, 0.5F, 1.0F, StrokeBlendMode::linear_burn), 0.25F)); + PP_EXPECT(h, near(blend_stroke_alpha(0.2F, 0.2F, 1.0F, StrokeBlendMode::hard_mix), 0.0F)); + PP_EXPECT(h, near(blend_stroke_alpha(0.7F, 0.8F, 1.0F, StrokeBlendMode::hard_mix), 1.0F)); + PP_EXPECT(h, near(blend_stroke_alpha(0.5F, 0.25F, 0.16F, StrokeBlendMode::linear_height), 0.637170F)); + PP_EXPECT(h, near(blend_stroke_alpha(0.5F, 0.25F, 0.16F, StrokeBlendMode::height), 0.228217F)); +} + +void stroke_alpha_blend_modes_handle_edges_and_names(pp::tests::Harness& h) +{ + PP_EXPECT(h, near(blend_stroke_alpha(0.8F, 0.2F, 0.0F, StrokeBlendMode::multiply), 0.8F)); + PP_EXPECT(h, near(blend_stroke_alpha(0.0F, 0.8F, 1.0F, StrokeBlendMode::color_dodge), 0.0F)); + PP_EXPECT(h, near(blend_stroke_alpha(0.8F, 1.0F, 1.0F, StrokeBlendMode::color_dodge), 1.0F)); + PP_EXPECT(h, near(blend_stroke_alpha(1.0F, 0.2F, 1.0F, StrokeBlendMode::color_burn), 1.0F)); + PP_EXPECT(h, near(blend_stroke_alpha(0.8F, 0.0F, 1.0F, StrokeBlendMode::color_burn), 0.0F)); + PP_EXPECT(h, near(blend_stroke_alpha(0.0F, 1.0F, 1.0F, StrokeBlendMode::hard_mix), 0.0F)); + PP_EXPECT(h, near(blend_stroke_alpha(-1.0F, 2.0F, 2.0F, StrokeBlendMode::normal), 1.0F)); + PP_EXPECT(h, near(blend_stroke_alpha(0.2F, 0.2F, 0.5F, static_cast(255)), 1.0F)); + + PP_EXPECT(h, stroke_blend_mode_name(StrokeBlendMode::normal) == std::string_view("normal")); + PP_EXPECT(h, stroke_blend_mode_name(StrokeBlendMode::linear_height) == std::string_view("linear_height")); + PP_EXPECT(h, stroke_blend_mode_name(StrokeBlendMode::height) == std::string_view("height")); + PP_EXPECT(h, stroke_blend_mode_name(static_cast(255)) == std::string_view("unknown")); +} + } int main() @@ -101,5 +137,7 @@ int main() harness.run("multiply_and_screen_are_bounded", multiply_and_screen_are_bounded); harness.run("color_dodge_and_overlay_handle_extremes", color_dodge_and_overlay_handle_extremes); harness.run("clamps_inputs_and_names_modes", clamps_inputs_and_names_modes); + harness.run("stroke_alpha_blend_modes_match_shader_reference_vectors", stroke_alpha_blend_modes_match_shader_reference_vectors); + harness.run("stroke_alpha_blend_modes_handle_edges_and_names", stroke_alpha_blend_modes_handle_edges_and_names); return harness.finish(); }