From 9c373546dcb9350f5a81516113841130c55c4450 Mon Sep 17 00:00:00 2001 From: James Jackson-South Date: Mon, 26 Oct 2026 15:28:03 +1000 Subject: [PATCH 1/6] Fix seals inside layers, gradient repetition and WebGPU scratch overflow Gradient brushes with Repeat and Reflect now repeat before the start point. Repeat subtracts the floor of the position, and Reflect uses the magnitude of the position. The WebGPU shaders use the same rules. Flush, RenderScene, CreateScene and CopyPixelsFrom inside an open layer keep the layer whole. A retained scene renders inside a layer on both backends. The WebGPU encoder reads it in place through a scene segment table. CopyPixelsFrom inside a layer reads the source rectangle with ReadRegion and draws the image into the layer with Src. The WebGPU encoder computes upper bounds for the scratch buffers, with row bounds for tile-row chunks and blend depth regions for the blend spill. fine adds the coverage of each pixel in fixed point, so the output does not depend on the GPU scheduling order, and it equals the CPU output. An offscreen WebGPU render checks its scratch status before it returns, and it renders again with larger buffers after an overflow. The deferred corrective re-render is removed, so a scene releases its resources when it is disposed. After an overflow, fine writes the backdrop, so a deferred presentation frame keeps the previous pixels. Each backend finds the retained scenes with ordered work through DrawingCommandBatch.FindOrderedScenes. The public API does not change. Tests cover each seal inside a layer on both backends, the gradient repetition, the scratch bounds, and a 100k-line offscreen render that overflows and chunks. The WebGPU reference images that held the old output are updated: the new WebGPU output equals the CPU output. --- .../Shaders/WgslSource/Shared/config.wgsl | 16 +- .../Shaders/WgslSource/Shared/ptcl.wgsl | 11 +- .../WgslSource/Shared/scene_segment.wgsl | 113 + .../Shaders/WgslSource/Shared/util.wgsl | 20 +- .../Shaders/WgslSource/coarse.wgsl | 90 +- .../Shaders/WgslSource/draw_leaf.wgsl | 29 +- .../Shaders/WgslSource/draw_reduce.wgsl | 8 +- .../Shaders/WgslSource/fine.wgsl | 121 +- .../Shaders/WgslSource/path_lowering.wgsl | 67 +- .../Shaders/WgslSource/path_row_alloc.wgsl | 3 +- .../Shaders/WgslSource/pathtag_reduce.wgsl | 6 +- .../Shaders/WgslSource/pathtag_scan.wgsl | 3 +- .../WebGPUDrawingBackend.CopyPixels.cs | 33 +- .../WebGPUDrawingBackend.Ordered.cs | 96 +- .../WebGPUDrawingBackend.Readback.cs | 6 +- .../WebGPUDrawingBackend.cs | 342 +-- .../WebGPUDrawingBackendScene.cs | 132 +- .../WebGPUFlushContext.cs | 5 +- .../WebGPUSceneConfig.cs | 2 +- .../WebGPUSceneDispatch.cs | 741 ++++- .../WebGPUSceneEncoder.cs | 2524 ++++++++++++++++- .../WebGPUSceneOperations.cs | 293 ++ .../WebGPUSceneResources.cs | 414 ++- .../WebGPUSceneRowBounds.cs | 371 +++ .../WebGPUSceneWindowBounds.cs | 45 + .../Backends/CompositionSceneCommandBase.cs | 51 + .../Backends/DefaultDrawingBackend.cs | 120 +- .../Backends/DrawingCommandBatch.cs | 180 +- .../Backends/FlushScene.RetainedTypes.cs | 44 +- .../Processing/Backends/FlushScene.cs | 199 +- .../Processing/Backends/IDrawingBackend.cs | 48 + .../Processing/DrawingCanvas.cs | 21 +- .../DrawingCanvasBatcher{TPixel}.cs | 502 +++- .../Processing/DrawingCanvasTimelineEntry.cs | 71 +- .../Processing/DrawingCanvas{TPixel}.cs | 198 +- .../Processing/GradientBrush.cs | 12 +- src/ImageSharp.Drawing/Processing/Pen.cs | 26 + .../WebGPUDrawingBackendTests.Diagnostics.cs | 152 + .../Backends/WebGPUDrawingBackendTests.cs | 613 +++- .../Processing/DrawingCanvasTests.Factory.cs | 34 + ...sWithDrawingCanvasTests.GradientBrushes.cs | 38 + ...rushRepeatsBeforeTheStartPoint_Reflect.png | 3 + ...BrushRepeatsBeforeTheStartPoint_Repeat.png | 3 + ...sScenesRecordedBeforeItInOrder_Default.png | 3 + ...edBeforeItInOrder_WebGPU_NativeSurface.png | 3 + ...tput_Normal_Clear_WebGPU_NativeSurface.png | 4 +- ...tput_Normal_Clear_WebGPU_NativeSurface.png | 4 +- ...chesDefaultOutput_WebGPU_NativeSurface.png | 4 +- ...sh_Repeat_MatchesDefaultOutput_Default.png | 4 +- ...chesDefaultOutput_WebGPU_NativeSurface.png | 4 +- ...atsBeforeTheStartPoint_Reflect_Default.png | 3 + ...tartPoint_Reflect_WebGPU_NativeSurface.png | 3 + ...eatsBeforeTheStartPoint_Repeat_Default.png | 3 + ...StartPoint_Repeat_WebGPU_NativeSurface.png | 3 + ...artialArc_MatchesDefaultOutput_Default.png | 4 +- ...chesDefaultOutput_WebGPU_NativeSurface.png | 4 +- ...ntBrushes_MatchesDefaultOutput_Default.png | 4 +- ...chesDefaultOutput_WebGPU_NativeSurface.png | 4 +- ...awing_CopyPixelsFromSelf_Plain_Default.png | 3 + ...elsFromSelf_Plain_WebGPU_NativeSurface.png | 3 + ...wing_CopyPixelsFromSelf_Scoped_Default.png | 3 + ...lsFromSelf_Scoped_WebGPU_NativeSurface.png | 3 + ...yPixelsFromSourceChanges_Plain_Default.png | 3 + ...urceChanges_Plain_WebGPU_NativeSurface.png | 3 + ...PixelsFromSourceChanges_Scoped_Default.png | 3 + ...rceChanges_Scoped_WebGPU_NativeSurface.png | 3 + ...ctDrawing_CopyPixelsFrom_Plain_Default.png | 3 + ...yPixelsFrom_Plain_WebGPU_NativeSurface.png | 3 + ...tDrawing_CopyPixelsFrom_Scoped_Default.png | 3 + ...PixelsFrom_Scoped_WebGPU_NativeSurface.png | 3 + ..._CreateSceneAfterRestore_Plain_Default.png | 3 + ...fterRestore_Plain_WebGPU_NativeSurface.png | 3 + ...CreateSceneAfterRestore_Scoped_Default.png | 3 + ...terRestore_Scoped_WebGPU_NativeSurface.png | 3 + ...irectDrawing_CreateScene_Plain_Default.png | 3 + ...CreateScene_Plain_WebGPU_NativeSurface.png | 3 + ...rectDrawing_CreateScene_Scoped_Default.png | 3 + ...reateScene_Scoped_WebGPU_NativeSurface.png | 3 + ...tchesDirectDrawing_Flush_Plain_Default.png | 3 + ...awing_Flush_Plain_WebGPU_NativeSurface.png | 3 + ...chesDirectDrawing_Flush_Scoped_Default.png | 3 + ...wing_Flush_Scoped_WebGPU_NativeSurface.png | 3 + ...rawing_RenderNestedScene_Plain_Default.png | 3 + ...NestedScene_Plain_WebGPU_NativeSurface.png | 3 + ...awing_RenderNestedScene_Scoped_Default.png | 3 + ...estedScene_Scoped_WebGPU_NativeSurface.png | 3 + ...Drawing_RenderPartsScene_Plain_Default.png | 3 + ...rPartsScene_Plain_WebGPU_NativeSurface.png | 3 + ...rawing_RenderPartsScene_Scoped_Default.png | 3 + ...PartsScene_Scoped_WebGPU_NativeSurface.png | 3 + ...hGradientSceneAfterApply_Plain_Default.png | 3 + ...eAfterApply_Plain_WebGPU_NativeSurface.png | 3 + ...GradientSceneAfterApply_Scoped_Default.png | 3 + ...AfterApply_Scoped_WebGPU_NativeSurface.png | 3 + ...ng_RenderSceneAfterApply_Plain_Default.png | 3 + ...eAfterApply_Plain_WebGPU_NativeSurface.png | 3 + ...g_RenderSceneAfterApply_Scoped_Default.png | 3 + ...AfterApply_Scoped_WebGPU_NativeSurface.png | 3 + ...RenderSceneInScopedChild_Plain_Default.png | 3 + ...ScopedChild_Plain_WebGPU_NativeSurface.png | 3 + ...enderSceneInScopedChild_Scoped_Default.png | 3 + ...copedChild_Scoped_WebGPU_NativeSurface.png | 3 + ...ing_RenderSceneWithLayer_Plain_Default.png | 3 + ...neWithLayer_Plain_WebGPU_NativeSurface.png | 3 + ...ng_RenderSceneWithLayer_Scoped_Default.png | 3 + ...eWithLayer_Scoped_WebGPU_NativeSurface.png | 3 + ...irectDrawing_RenderScene_Plain_Default.png | 3 + ...RenderScene_Plain_WebGPU_NativeSurface.png | 3 + ...rectDrawing_RenderScene_Scoped_Default.png | 3 + ...enderScene_Scoped_WebGPU_NativeSurface.png | 3 + ...sDirectDrawing_TwoScenes_Plain_Default.png | 3 + ...g_TwoScenes_Plain_WebGPU_NativeSurface.png | 3 + ...DirectDrawing_TwoScenes_Scoped_Default.png | 3 + ..._TwoScenes_Scoped_WebGPU_NativeSurface.png | 3 + 114 files changed, 7103 insertions(+), 925 deletions(-) create mode 100644 src/ImageSharp.Drawing.WebGPU/Shaders/WgslSource/Shared/scene_segment.wgsl create mode 100644 src/ImageSharp.Drawing.WebGPU/WebGPUSceneRowBounds.cs create mode 100644 src/ImageSharp.Drawing.WebGPU/WebGPUSceneWindowBounds.cs create mode 100644 tests/Images/ReferenceOutput/Drawing/ProcessWithDrawingCanvasTests/FillLinearGradientBrushRepeatsBeforeTheStartPoint_Reflect.png create mode 100644 tests/Images/ReferenceOutput/Drawing/ProcessWithDrawingCanvasTests/FillLinearGradientBrushRepeatsBeforeTheStartPoint_Repeat.png create mode 100644 tests/Images/ReferenceOutput/Drawing/WebGPUDrawingBackendTests/CreateScene_KeepsScenesRecordedBeforeItInOrder_Default.png create mode 100644 tests/Images/ReferenceOutput/Drawing/WebGPUDrawingBackendTests/CreateScene_KeepsScenesRecordedBeforeItInOrder_WebGPU_NativeSurface.png create mode 100644 tests/Images/ReferenceOutput/Drawing/WebGPUDrawingBackendTests/FillPath_WithLinearGradientBrush_RepeatsBeforeTheStartPoint_Reflect_Default.png create mode 100644 tests/Images/ReferenceOutput/Drawing/WebGPUDrawingBackendTests/FillPath_WithLinearGradientBrush_RepeatsBeforeTheStartPoint_Reflect_WebGPU_NativeSurface.png create mode 100644 tests/Images/ReferenceOutput/Drawing/WebGPUDrawingBackendTests/FillPath_WithLinearGradientBrush_RepeatsBeforeTheStartPoint_Repeat_Default.png create mode 100644 tests/Images/ReferenceOutput/Drawing/WebGPUDrawingBackendTests/FillPath_WithLinearGradientBrush_RepeatsBeforeTheStartPoint_Repeat_WebGPU_NativeSurface.png create mode 100644 tests/Images/ReferenceOutput/Drawing/WebGPUDrawingBackendTests/SaveLayer_WithSealInsideLayer_MatchesDirectDrawing_CopyPixelsFromSelf_Plain_Default.png create mode 100644 tests/Images/ReferenceOutput/Drawing/WebGPUDrawingBackendTests/SaveLayer_WithSealInsideLayer_MatchesDirectDrawing_CopyPixelsFromSelf_Plain_WebGPU_NativeSurface.png create mode 100644 tests/Images/ReferenceOutput/Drawing/WebGPUDrawingBackendTests/SaveLayer_WithSealInsideLayer_MatchesDirectDrawing_CopyPixelsFromSelf_Scoped_Default.png create mode 100644 tests/Images/ReferenceOutput/Drawing/WebGPUDrawingBackendTests/SaveLayer_WithSealInsideLayer_MatchesDirectDrawing_CopyPixelsFromSelf_Scoped_WebGPU_NativeSurface.png create mode 100644 tests/Images/ReferenceOutput/Drawing/WebGPUDrawingBackendTests/SaveLayer_WithSealInsideLayer_MatchesDirectDrawing_CopyPixelsFromSourceChanges_Plain_Default.png create mode 100644 tests/Images/ReferenceOutput/Drawing/WebGPUDrawingBackendTests/SaveLayer_WithSealInsideLayer_MatchesDirectDrawing_CopyPixelsFromSourceChanges_Plain_WebGPU_NativeSurface.png create mode 100644 tests/Images/ReferenceOutput/Drawing/WebGPUDrawingBackendTests/SaveLayer_WithSealInsideLayer_MatchesDirectDrawing_CopyPixelsFromSourceChanges_Scoped_Default.png create mode 100644 tests/Images/ReferenceOutput/Drawing/WebGPUDrawingBackendTests/SaveLayer_WithSealInsideLayer_MatchesDirectDrawing_CopyPixelsFromSourceChanges_Scoped_WebGPU_NativeSurface.png create mode 100644 tests/Images/ReferenceOutput/Drawing/WebGPUDrawingBackendTests/SaveLayer_WithSealInsideLayer_MatchesDirectDrawing_CopyPixelsFrom_Plain_Default.png create mode 100644 tests/Images/ReferenceOutput/Drawing/WebGPUDrawingBackendTests/SaveLayer_WithSealInsideLayer_MatchesDirectDrawing_CopyPixelsFrom_Plain_WebGPU_NativeSurface.png create mode 100644 tests/Images/ReferenceOutput/Drawing/WebGPUDrawingBackendTests/SaveLayer_WithSealInsideLayer_MatchesDirectDrawing_CopyPixelsFrom_Scoped_Default.png create mode 100644 tests/Images/ReferenceOutput/Drawing/WebGPUDrawingBackendTests/SaveLayer_WithSealInsideLayer_MatchesDirectDrawing_CopyPixelsFrom_Scoped_WebGPU_NativeSurface.png create mode 100644 tests/Images/ReferenceOutput/Drawing/WebGPUDrawingBackendTests/SaveLayer_WithSealInsideLayer_MatchesDirectDrawing_CreateSceneAfterRestore_Plain_Default.png create mode 100644 tests/Images/ReferenceOutput/Drawing/WebGPUDrawingBackendTests/SaveLayer_WithSealInsideLayer_MatchesDirectDrawing_CreateSceneAfterRestore_Plain_WebGPU_NativeSurface.png create mode 100644 tests/Images/ReferenceOutput/Drawing/WebGPUDrawingBackendTests/SaveLayer_WithSealInsideLayer_MatchesDirectDrawing_CreateSceneAfterRestore_Scoped_Default.png create mode 100644 tests/Images/ReferenceOutput/Drawing/WebGPUDrawingBackendTests/SaveLayer_WithSealInsideLayer_MatchesDirectDrawing_CreateSceneAfterRestore_Scoped_WebGPU_NativeSurface.png create mode 100644 tests/Images/ReferenceOutput/Drawing/WebGPUDrawingBackendTests/SaveLayer_WithSealInsideLayer_MatchesDirectDrawing_CreateScene_Plain_Default.png create mode 100644 tests/Images/ReferenceOutput/Drawing/WebGPUDrawingBackendTests/SaveLayer_WithSealInsideLayer_MatchesDirectDrawing_CreateScene_Plain_WebGPU_NativeSurface.png create mode 100644 tests/Images/ReferenceOutput/Drawing/WebGPUDrawingBackendTests/SaveLayer_WithSealInsideLayer_MatchesDirectDrawing_CreateScene_Scoped_Default.png create mode 100644 tests/Images/ReferenceOutput/Drawing/WebGPUDrawingBackendTests/SaveLayer_WithSealInsideLayer_MatchesDirectDrawing_CreateScene_Scoped_WebGPU_NativeSurface.png create mode 100644 tests/Images/ReferenceOutput/Drawing/WebGPUDrawingBackendTests/SaveLayer_WithSealInsideLayer_MatchesDirectDrawing_Flush_Plain_Default.png create mode 100644 tests/Images/ReferenceOutput/Drawing/WebGPUDrawingBackendTests/SaveLayer_WithSealInsideLayer_MatchesDirectDrawing_Flush_Plain_WebGPU_NativeSurface.png create mode 100644 tests/Images/ReferenceOutput/Drawing/WebGPUDrawingBackendTests/SaveLayer_WithSealInsideLayer_MatchesDirectDrawing_Flush_Scoped_Default.png create mode 100644 tests/Images/ReferenceOutput/Drawing/WebGPUDrawingBackendTests/SaveLayer_WithSealInsideLayer_MatchesDirectDrawing_Flush_Scoped_WebGPU_NativeSurface.png create mode 100644 tests/Images/ReferenceOutput/Drawing/WebGPUDrawingBackendTests/SaveLayer_WithSealInsideLayer_MatchesDirectDrawing_RenderNestedScene_Plain_Default.png create mode 100644 tests/Images/ReferenceOutput/Drawing/WebGPUDrawingBackendTests/SaveLayer_WithSealInsideLayer_MatchesDirectDrawing_RenderNestedScene_Plain_WebGPU_NativeSurface.png create mode 100644 tests/Images/ReferenceOutput/Drawing/WebGPUDrawingBackendTests/SaveLayer_WithSealInsideLayer_MatchesDirectDrawing_RenderNestedScene_Scoped_Default.png create mode 100644 tests/Images/ReferenceOutput/Drawing/WebGPUDrawingBackendTests/SaveLayer_WithSealInsideLayer_MatchesDirectDrawing_RenderNestedScene_Scoped_WebGPU_NativeSurface.png create mode 100644 tests/Images/ReferenceOutput/Drawing/WebGPUDrawingBackendTests/SaveLayer_WithSealInsideLayer_MatchesDirectDrawing_RenderPartsScene_Plain_Default.png create mode 100644 tests/Images/ReferenceOutput/Drawing/WebGPUDrawingBackendTests/SaveLayer_WithSealInsideLayer_MatchesDirectDrawing_RenderPartsScene_Plain_WebGPU_NativeSurface.png create mode 100644 tests/Images/ReferenceOutput/Drawing/WebGPUDrawingBackendTests/SaveLayer_WithSealInsideLayer_MatchesDirectDrawing_RenderPartsScene_Scoped_Default.png create mode 100644 tests/Images/ReferenceOutput/Drawing/WebGPUDrawingBackendTests/SaveLayer_WithSealInsideLayer_MatchesDirectDrawing_RenderPartsScene_Scoped_WebGPU_NativeSurface.png create mode 100644 tests/Images/ReferenceOutput/Drawing/WebGPUDrawingBackendTests/SaveLayer_WithSealInsideLayer_MatchesDirectDrawing_RenderPathGradientSceneAfterApply_Plain_Default.png create mode 100644 tests/Images/ReferenceOutput/Drawing/WebGPUDrawingBackendTests/SaveLayer_WithSealInsideLayer_MatchesDirectDrawing_RenderPathGradientSceneAfterApply_Plain_WebGPU_NativeSurface.png create mode 100644 tests/Images/ReferenceOutput/Drawing/WebGPUDrawingBackendTests/SaveLayer_WithSealInsideLayer_MatchesDirectDrawing_RenderPathGradientSceneAfterApply_Scoped_Default.png create mode 100644 tests/Images/ReferenceOutput/Drawing/WebGPUDrawingBackendTests/SaveLayer_WithSealInsideLayer_MatchesDirectDrawing_RenderPathGradientSceneAfterApply_Scoped_WebGPU_NativeSurface.png create mode 100644 tests/Images/ReferenceOutput/Drawing/WebGPUDrawingBackendTests/SaveLayer_WithSealInsideLayer_MatchesDirectDrawing_RenderSceneAfterApply_Plain_Default.png create mode 100644 tests/Images/ReferenceOutput/Drawing/WebGPUDrawingBackendTests/SaveLayer_WithSealInsideLayer_MatchesDirectDrawing_RenderSceneAfterApply_Plain_WebGPU_NativeSurface.png create mode 100644 tests/Images/ReferenceOutput/Drawing/WebGPUDrawingBackendTests/SaveLayer_WithSealInsideLayer_MatchesDirectDrawing_RenderSceneAfterApply_Scoped_Default.png create mode 100644 tests/Images/ReferenceOutput/Drawing/WebGPUDrawingBackendTests/SaveLayer_WithSealInsideLayer_MatchesDirectDrawing_RenderSceneAfterApply_Scoped_WebGPU_NativeSurface.png create mode 100644 tests/Images/ReferenceOutput/Drawing/WebGPUDrawingBackendTests/SaveLayer_WithSealInsideLayer_MatchesDirectDrawing_RenderSceneInScopedChild_Plain_Default.png create mode 100644 tests/Images/ReferenceOutput/Drawing/WebGPUDrawingBackendTests/SaveLayer_WithSealInsideLayer_MatchesDirectDrawing_RenderSceneInScopedChild_Plain_WebGPU_NativeSurface.png create mode 100644 tests/Images/ReferenceOutput/Drawing/WebGPUDrawingBackendTests/SaveLayer_WithSealInsideLayer_MatchesDirectDrawing_RenderSceneInScopedChild_Scoped_Default.png create mode 100644 tests/Images/ReferenceOutput/Drawing/WebGPUDrawingBackendTests/SaveLayer_WithSealInsideLayer_MatchesDirectDrawing_RenderSceneInScopedChild_Scoped_WebGPU_NativeSurface.png create mode 100644 tests/Images/ReferenceOutput/Drawing/WebGPUDrawingBackendTests/SaveLayer_WithSealInsideLayer_MatchesDirectDrawing_RenderSceneWithLayer_Plain_Default.png create mode 100644 tests/Images/ReferenceOutput/Drawing/WebGPUDrawingBackendTests/SaveLayer_WithSealInsideLayer_MatchesDirectDrawing_RenderSceneWithLayer_Plain_WebGPU_NativeSurface.png create mode 100644 tests/Images/ReferenceOutput/Drawing/WebGPUDrawingBackendTests/SaveLayer_WithSealInsideLayer_MatchesDirectDrawing_RenderSceneWithLayer_Scoped_Default.png create mode 100644 tests/Images/ReferenceOutput/Drawing/WebGPUDrawingBackendTests/SaveLayer_WithSealInsideLayer_MatchesDirectDrawing_RenderSceneWithLayer_Scoped_WebGPU_NativeSurface.png create mode 100644 tests/Images/ReferenceOutput/Drawing/WebGPUDrawingBackendTests/SaveLayer_WithSealInsideLayer_MatchesDirectDrawing_RenderScene_Plain_Default.png create mode 100644 tests/Images/ReferenceOutput/Drawing/WebGPUDrawingBackendTests/SaveLayer_WithSealInsideLayer_MatchesDirectDrawing_RenderScene_Plain_WebGPU_NativeSurface.png create mode 100644 tests/Images/ReferenceOutput/Drawing/WebGPUDrawingBackendTests/SaveLayer_WithSealInsideLayer_MatchesDirectDrawing_RenderScene_Scoped_Default.png create mode 100644 tests/Images/ReferenceOutput/Drawing/WebGPUDrawingBackendTests/SaveLayer_WithSealInsideLayer_MatchesDirectDrawing_RenderScene_Scoped_WebGPU_NativeSurface.png create mode 100644 tests/Images/ReferenceOutput/Drawing/WebGPUDrawingBackendTests/SaveLayer_WithSealInsideLayer_MatchesDirectDrawing_TwoScenes_Plain_Default.png create mode 100644 tests/Images/ReferenceOutput/Drawing/WebGPUDrawingBackendTests/SaveLayer_WithSealInsideLayer_MatchesDirectDrawing_TwoScenes_Plain_WebGPU_NativeSurface.png create mode 100644 tests/Images/ReferenceOutput/Drawing/WebGPUDrawingBackendTests/SaveLayer_WithSealInsideLayer_MatchesDirectDrawing_TwoScenes_Scoped_Default.png create mode 100644 tests/Images/ReferenceOutput/Drawing/WebGPUDrawingBackendTests/SaveLayer_WithSealInsideLayer_MatchesDirectDrawing_TwoScenes_Scoped_WebGPU_NativeSurface.png diff --git a/src/ImageSharp.Drawing.WebGPU/Shaders/WgslSource/Shared/config.wgsl b/src/ImageSharp.Drawing.WebGPU/Shaders/WgslSource/Shared/config.wgsl index 9d6725ddc..b44c6ccce 100644 --- a/src/ImageSharp.Drawing.WebGPU/Shaders/WgslSource/Shared/config.wgsl +++ b/src/ImageSharp.Drawing.WebGPU/Shaders/WgslSource/Shared/config.wgsl @@ -62,9 +62,19 @@ struct Config { // Word offset of the profile record table, or zero when the table is absent. The table starts // with the X and Y record counts. Each record then stores two axis limits and one contour link. profile_records_base: u32, - profile_pad0: u32, - profile_pad1: u32, - profile_pad2: u32, + // Number of entries in the scene segment table, or zero when the scene has no retained scene + // inside a layer. With zero, every shader reads the streams through the bases above. See + // scene_segment.wgsl for the table layout. + segment_count: u32, + // Word offset of the scene segment table in the scene buffer. + segment_table_base: u32, + // Position of the target in the coordinates of the encoded data, packed as x | (y << 16). The + // data of an ordered scene is in the coordinates of its root target, so a scoped layer target + // has a value that is not zero: path_lowering moves the lines into target space, and fine + // moves sample positions back into data space for brushes and profiles. A layer target has at + // most maxTextureDimension2D (8192) texels on each axis and starts inside the canvas, so each + // value fits in 16 bits. + target_origin: u32, } // Geometry of tiles and bins diff --git a/src/ImageSharp.Drawing.WebGPU/Shaders/WgslSource/Shared/ptcl.wgsl b/src/ImageSharp.Drawing.WebGPU/Shaders/WgslSource/Shared/ptcl.wgsl index ff676e05b..9f517317e 100644 --- a/src/ImageSharp.Drawing.WebGPU/Shaders/WgslSource/Shared/ptcl.wgsl +++ b/src/ImageSharp.Drawing.WebGPU/Shaders/WgslSource/Shared/ptcl.wgsl @@ -37,6 +37,12 @@ const CMD_BEGIN_CLIP = 10u; const CMD_END_CLIP = 11u; const CMD_JUMP = 12u; const CMD_PATH_GRAD = 16u; +// Sets the profile record table for the next CMD_FILL. Its one payload word is the word offset of +// the table. With a segment table, each retained scene keeps its own table (see +// scene_segment.wgsl), so coarse writes this command before each aliased fill. Coarse writes it +// only when config.segment_count is not zero. A separate command keeps CMD_FILL, the most +// frequent command in fine, the same for every scene. +const CMD_PROFILE_TABLE = 17u; // CMD_FILL and CMD_SOLID reference their raster interest rectangle by its offset in the // info stream. This value marks a draw without an interest block: the whole target. @@ -130,8 +136,9 @@ struct CmdSweepGrad { // whose edge list lives in the shared info/bin-data buffer. struct CmdPathGrad { // Absolute offset of the gradient data in the shared info/bin-data - // buffer; coarse rebases the scene-relative offset by - // config.brush_data_base before writing the command. + // buffer. coarse rebases the scene-relative offset by the brush base + // of the scene segment (config.brush_data_base outside a segment table) + // before it writes the command. data_offset: u32, edge_count: u32, flags: u32, diff --git a/src/ImageSharp.Drawing.WebGPU/Shaders/WgslSource/Shared/scene_segment.wgsl b/src/ImageSharp.Drawing.WebGPU/Shaders/WgslSource/Shared/scene_segment.wgsl new file mode 100644 index 000000000..ae3adbe8d --- /dev/null +++ b/src/ImageSharp.Drawing.WebGPU/Shaders/WgslSource/Shared/scene_segment.wgsl @@ -0,0 +1,113 @@ +// Copyright (c) Six Labors. +// Licensed under the Six Labors Split License. + +// The scene segment table: one dispatch reads retained scenes in place. +// +// A retained scene drawn inside a layer does not copy its streams into the outer scene. The +// scene buffer holds the outer scene data, then this table, then one resident block for each +// retained scene. Each stream is a virtual stream: the outer elements before the first retained +// scene, then the retained elements, then the next outer elements, and so on. The monoid scans +// run over the virtual streams, so every monoid offset is a virtual offset. +// +// One table entry describes one segment, that is one contiguous piece of every stream. A shader +// finds the segment of an element one time, from the virtual index of its path-tag word or of +// its draw object. Then it reads each stream as scene[bias + virtual offset]. A bias is the +// physical start minus the virtual start, in wrapping u32 arithmetic. The segments are in stream +// order, and the first segment starts at virtual index zero in every stream. +// +// When config.segment_count is zero, the scene has no table and every helper returns the config +// bases. Then the cost for each element is one compare of a uniform value. +// +// Like util.wgsl, this module references the `scene` and `config` bindings by name, so the root +// shader must declare them. WGSL module-scope declarations do not depend on order, so the import +// can be at the top of the root shader. The C# writer of the table is WebGPUSceneEncoder.cs. + +// Words in one table entry. +const SCENE_SEGMENT_WORDS = 13u; + +// Virtual index of the first path-tag word of the segment. This is the key for path-tag elements. +const SCENE_SEGMENT_PATHTAG_START = 0u; +// Bias of the path-tag word stream. +const SCENE_SEGMENT_PATHTAG_BIAS = 1u; +// Bias of the path-data word stream. +const SCENE_SEGMENT_PATHDATA_BIAS = 2u; +// Bias of the style word stream. +const SCENE_SEGMENT_STYLE_BIAS = 3u; +// Bias of the transform stream: the physical word start minus 9 times the virtual transform index. +const SCENE_SEGMENT_TRANSFORM_BIAS = 4u; +// Virtual index of the first draw object of the segment. This is the key for draw objects. +const SCENE_SEGMENT_DRAWTAG_START = 5u; +// Bias of the draw-tag stream. +const SCENE_SEGMENT_DRAWTAG_BIAS = 6u; +// Bias of the draw-data word stream. +const SCENE_SEGMENT_DRAWDATA_BIAS = 7u; +// Word offset of the auxiliary brush data of the segment in the combined info buffer. +const SCENE_SEGMENT_BRUSH_BASE = 8u; +// First gradient-ramp row of the segment. +const SCENE_SEGMENT_GRADIENT_ROW_BASE = 9u; +// First image-atlas row of the segment. +const SCENE_SEGMENT_ATLAS_Y_BASE = 10u; +// Profile slot origin: the slot of a fill line is this value plus half the physical path-data +// word of the line start. Zero when the segment has no profile table. +const SCENE_SEGMENT_PROFILE_SLOTS = 11u; +// Word offset of the profile record table of the segment, or zero when it has none. +const SCENE_SEGMENT_PROFILE_RECORDS = 12u; + +// Returned in place of a table entry when the scene has no segment table. +const SCENE_SEGMENT_NONE = 0xffffffffu; + +// Returns the word offset of the table entry for the last segment whose key at `key_field` is +// less than or equal to `element_ix`. Empty segments share their key with the next segment, so +// the search selects the segment that holds the element. Call this only when +// config.segment_count is not zero. +fn find_scene_segment(key_field: u32, element_ix: u32) -> u32 { + var lo = 0u; + var hi = config.segment_count; + while lo + 1u < hi { + let mid = (lo + hi) >> 1u; + if scene[config.segment_table_base + mid * SCENE_SEGMENT_WORDS + key_field] <= element_ix { + lo = mid; + } else { + hi = mid; + } + } + + return config.segment_table_base + lo * SCENE_SEGMENT_WORDS; +} + +// Returns the bias of the path-tag word stream for the path-tag word `word_ix`. +fn resolve_path_tag_bias(word_ix: u32) -> u32 { + if config.segment_count == 0u { + return config.pathtag_base; + } + + return scene[find_scene_segment(SCENE_SEGMENT_PATHTAG_START, word_ix) + SCENE_SEGMENT_PATHTAG_BIAS]; +} + +// Returns the table entry of the segment that holds draw object `ix`, or SCENE_SEGMENT_NONE +// when the scene has no segment table. +fn resolve_draw_segment(ix: u32) -> u32 { + if config.segment_count == 0u { + return SCENE_SEGMENT_NONE; + } + + return find_scene_segment(SCENE_SEGMENT_DRAWTAG_START, ix); +} + +// Returns the bias of the draw-tag stream for a segment entry from resolve_draw_segment. +fn draw_tag_bias(entry: u32) -> u32 { + if entry == SCENE_SEGMENT_NONE { + return config.drawtag_base; + } + + return scene[entry + SCENE_SEGMENT_DRAWTAG_BIAS]; +} + +// Returns the bias of the draw-data stream for a segment entry from resolve_draw_segment. +fn draw_data_bias(entry: u32) -> u32 { + if entry == SCENE_SEGMENT_NONE { + return config.drawdata_base; + } + + return scene[entry + SCENE_SEGMENT_DRAWDATA_BIAS]; +} diff --git a/src/ImageSharp.Drawing.WebGPU/Shaders/WgslSource/Shared/util.wgsl b/src/ImageSharp.Drawing.WebGPU/Shaders/WgslSource/Shared/util.wgsl index 014b44dc2..7af97f0f2 100644 --- a/src/ImageSharp.Drawing.WebGPU/Shaders/WgslSource/Shared/util.wgsl +++ b/src/ImageSharp.Drawing.WebGPU/Shaders/WgslSource/Shared/util.wgsl @@ -3,11 +3,11 @@ // Utility functions that interact with host-shareable buffer objects. // -// Unlike the other shared modules, this one references bindings by name, so -// it must be imported once, after the resource binding declarations, in the -// shader module that accesses them. Imported by draw_leaf.wgsl and -// draw_reduce.wgsl. Ported from Vello's shader/shared/util.wgsl -// (linebender/vello). +// Like scene_segment.wgsl, and unlike the other shared modules, this one +// references bindings by name, so the root shader must declare them. It calls +// the scene_segment.wgsl helpers, so the root shader imports scene_segment too. +// Imported by draw_leaf.wgsl and draw_reduce.wgsl. Ported from Vello's +// shader/shared/util.wgsl (linebender/vello). // Reads a draw tag from the scene buffer, defaulting to DRAWTAG_NOP if the given `ix` is beyond the // range of valid draw objects (e.g this can happen if `ix` is derived from an invocation ID in a @@ -16,11 +16,17 @@ // This function depends on the following global declarations: // * `scene`: array // * `config`: Config (see config.wgsl) +// * the scene_segment.wgsl module, imported before this module fn read_draw_tag_from_scene(ix: u32) -> u32 { + return read_draw_tag_from_segment(resolve_draw_segment(ix), ix); +} + +// Reads the draw tag of draw object `ix` through the segment entry that resolve_draw_segment +// returned for it, defaulting to DRAWTAG_NOP beyond the valid draw objects. +fn read_draw_tag_from_segment(entry: u32, ix: u32) -> u32 { var tag_word: u32; if ix < config.n_drawobj { - let tag_ix = config.drawtag_base + ix; - tag_word = scene[tag_ix]; + tag_word = scene[draw_tag_bias(entry) + ix]; } else { tag_word = DRAWTAG_NOP; } diff --git a/src/ImageSharp.Drawing.WebGPU/Shaders/WgslSource/coarse.wgsl b/src/ImageSharp.Drawing.WebGPU/Shaders/WgslSource/coarse.wgsl index 7f37732bb..18431a173 100644 --- a/src/ImageSharp.Drawing.WebGPU/Shaders/WgslSource/coarse.wgsl +++ b/src/ImageSharp.Drawing.WebGPU/Shaders/WgslSource/coarse.wgsl @@ -28,6 +28,7 @@ #import drawtag #import ptcl #import tile +#import scene_segment @group(0) @binding(0) var config: Config; @@ -98,6 +99,30 @@ struct BinHeader { var cmd_offset: u32; var cmd_limit: u32; +// Profile record table of the draw object whose commands coarse writes now. When the scene has a +// segment table, write_path puts it in a CMD_PROFILE_TABLE before an aliased fill. +var segment_profile_records: u32; + +// With a segment table, sh_drawobj_ix holds the draw object index in the low 24 bits and the +// index of its segment in the high 8 bits. Each draw object is therefore resolved one time in the +// bin load loop. Without a table, it holds the plain index and these helpers return it unchanged. +fn drawobj_index(packed: u32) -> u32 { + if config.segment_count == 0u { + return packed; + } + + return packed & 0xffffffu; +} + +// Returns the segment table entry of a packed draw object value, or SCENE_SEGMENT_NONE. +fn drawobj_segment(packed: u32) -> u32 { + if config.segment_count == 0u { + return SCENE_SEGMENT_NONE; + } + + return config.segment_table_base + (packed >> 24u) * SCENE_SEGMENT_WORDS; +} + // Ensures the current PTCL chunk has room for a command of the given size // plus jump headroom. When the chunk is exhausted, bump-allocates a new one // from bump.ptcl and links it with a CMD_JUMP; on overflow the coarse @@ -152,10 +177,20 @@ fn write_path(tile: Tile, tile_ix: u32, path: Path, global_x: u32, global_y: u32 if n_segs != 0u { var seg_ix = atomicAdd(&bump.segments, n_segs); tiles[tile_ix].segment_count_or_ix = ~seg_ix; - alloc_cmd(6u); - ptcl[cmd_offset] = CMD_FILL; let even_odd = (draw_flags & DRAW_INFO_FLAGS_FILL_RULE_BIT) != 0u; let aliased = (draw_flags & DRAW_INFO_FLAGS_ALIASED_BIT) != 0u; + + // Only aliased fills read profile records in fine. With a segment table, each retained + // scene has its own record table, so a CMD_PROFILE_TABLE before the fill sets it. + let with_profile_records = aliased && config.segment_count != 0u; + alloc_cmd(select(6u, 8u, with_profile_records)); + if with_profile_records { + ptcl[cmd_offset] = CMD_PROFILE_TABLE; + ptcl[cmd_offset + 1u] = segment_profile_records; + cmd_offset += 2u; + } + + ptcl[cmd_offset] = CMD_FILL; var packed_coverage_data = bitcast(coverage_data); if aliased { // Pack the data needed by the aliased half-pixel halo into one word: @@ -462,7 +497,15 @@ fn main( } ix -= select(part_start_ix, sh_part_count[part_ix - 1u], part_ix > 0u); let offset = config.bin_data_start + sh_part_offsets[part_ix]; - sh_drawobj_ix[local_id.x] = info_bin_data[offset + ix]; + var drawobj = info_bin_data[offset + ix]; + if config.segment_count != 0u { + // This resolves the segment of the draw object one time. The table has at most + // 255 entries, and a scene with a table has fewer than 2^24 draw objects. + let entry = find_scene_segment(SCENE_SEGMENT_DRAWTAG_START, drawobj); + drawobj |= ((entry - config.segment_table_base) / SCENE_SEGMENT_WORDS) << 24u; + } + + sh_drawobj_ix[local_id.x] = drawobj; } wr_ix = min(rd_ix + N_TILE, ready_ix); if wr_ix - rd_ix >= N_TILE || (wr_ix >= ready_ix && partition_ix >= n_partitions) { @@ -474,8 +517,9 @@ fn main( var tag = DRAWTAG_NOP; var drawobj_ix: u32; if local_id.x + rd_ix < wr_ix { - drawobj_ix = sh_drawobj_ix[local_id.x]; - tag = scene[config.drawtag_base + drawobj_ix]; + let packed = sh_drawobj_ix[local_id.x]; + drawobj_ix = drawobj_index(packed); + tag = scene[draw_tag_bias(drawobj_segment(packed)) + drawobj_ix]; } var tile_count = 0u; @@ -508,8 +552,9 @@ fn main( el_ix = probe; } } - drawobj_ix = sh_drawobj_ix[el_ix]; - tag = scene[config.drawtag_base + drawobj_ix]; + let packed = sh_drawobj_ix[el_ix]; + drawobj_ix = drawobj_index(packed); + tag = scene[draw_tag_bias(drawobj_segment(packed)) + drawobj_ix]; let seq_ix = ix - select(0u, sh_tile_count[el_ix - 1u], el_ix > 0u); let path_ix = draw_monoids[drawobj_ix].path_ix; let path = paths[path_ix]; @@ -581,10 +626,25 @@ fn main( } let el_ix = slice_ix * 32u + firstTrailingBit(bitmap); - drawobj_ix = sh_drawobj_ix[el_ix]; + let packed = sh_drawobj_ix[el_ix]; + drawobj_ix = drawobj_index(packed); // clear LSB of bitmap, using bit magic bitmap &= bitmap - 1u; - let drawtag = scene[config.drawtag_base + drawobj_ix]; + let segment = drawobj_segment(packed); + let drawtag = scene[draw_tag_bias(segment) + drawobj_ix]; + + // A retained scene keeps the indices of its own brush data and gradient rows. Those + // follow the outer data in this dispatch, so its segment supplies the bases. + var brush_data_base = config.brush_data_base; + var gradient_index_base = 0u; + segment_profile_records = config.profile_records_base; + if segment != SCENE_SEGMENT_NONE { + brush_data_base = scene[segment + SCENE_SEGMENT_BRUSH_BASE]; + // The gradient index word packs the row above the two extend-mode bits. + gradient_index_base = scene[segment + SCENE_SEGMENT_GRADIENT_ROW_BASE] << 2u; + segment_profile_records = scene[segment + SCENE_SEGMENT_PROFILE_RECORDS]; + } + let dm = draw_monoids[drawobj_ix]; let dd = config.drawdata_base + dm.scene_offset; let di = dm.info_offset; @@ -617,40 +677,40 @@ fn main( } case DRAWTAG_FILL_RECOLOR: { if write_path(tile, tile_ix, path, bin_tile_x + tile_x, bin_tile_y + tile_y, draw_flags, coverage_data, interest_ref, false) { - write_recolor(config.brush_data_base + scene[dd], draw_flags); + write_recolor(brush_data_base + scene[dd], draw_flags); } } case DRAWTAG_FILL_LIN_GRADIENT: { if write_path(tile, tile_ix, path, bin_tile_x + tile_x, bin_tile_y + tile_y, draw_flags, coverage_data, interest_ref, false) { - let index = scene[dd]; + let index = scene[dd] + gradient_index_base; let info_offset = di + 1u; write_grad(CMD_LIN_GRAD, index, info_offset); } } case DRAWTAG_FILL_RAD_GRADIENT: { if write_path(tile, tile_ix, path, bin_tile_x + tile_x, bin_tile_y + tile_y, draw_flags, coverage_data, interest_ref, false) { - let index = scene[dd]; + let index = scene[dd] + gradient_index_base; let info_offset = di + 1u; write_grad(CMD_RAD_GRAD, index, info_offset); } } case DRAWTAG_FILL_ELLIPTIC_GRADIENT: { if write_path(tile, tile_ix, path, bin_tile_x + tile_x, bin_tile_y + tile_y, draw_flags, coverage_data, interest_ref, false) { - let index = scene[dd]; + let index = scene[dd] + gradient_index_base; let info_offset = di + 1u; write_grad(CMD_ELLIPTIC_GRAD, index, info_offset); } } case DRAWTAG_FILL_SWEEP_GRADIENT: { if write_path(tile, tile_ix, path, bin_tile_x + tile_x, bin_tile_y + tile_y, draw_flags, coverage_data, interest_ref, false) { - let index = scene[dd]; + let index = scene[dd] + gradient_index_base; let info_offset = di + 1u; write_grad(CMD_SWEEP_GRAD, index, info_offset); } } case DRAWTAG_FILL_PATH_GRADIENT: { if write_path(tile, tile_ix, path, bin_tile_x + tile_x, bin_tile_y + tile_y, draw_flags, coverage_data, interest_ref, false) { - write_path_grad(config.brush_data_base + scene[dd], scene[dd + 1u], scene[dd + 2u], draw_flags); + write_path_grad(brush_data_base + scene[dd], scene[dd + 1u], scene[dd + 2u], draw_flags); } } case DRAWTAG_FILL_IMAGE: { diff --git a/src/ImageSharp.Drawing.WebGPU/Shaders/WgslSource/draw_leaf.wgsl b/src/ImageSharp.Drawing.WebGPU/Shaders/WgslSource/draw_leaf.wgsl index e48c70d0a..08f9e9a7c 100644 --- a/src/ImageSharp.Drawing.WebGPU/Shaders/WgslSource/draw_leaf.wgsl +++ b/src/ImageSharp.Drawing.WebGPU/Shaders/WgslSource/draw_leaf.wgsl @@ -26,6 +26,8 @@ #import drawtag #import bbox #import transform +#import scene_segment +#import util @group(0) @binding(0) var config: Config; @@ -48,8 +50,6 @@ var info: array; @group(0) @binding(6) var clip_inp: array; -#import util - // Reads one transform from the scene stream. ImageSharp encodes 9 words per // transform (2x2 matrix, translation, perspective row) where Vello encodes // 6 affine words. transform_base is the u32 offset of the transform stream @@ -117,7 +117,8 @@ fn main( let blocks_end = block_start + n_blocks * WG_SIZE; while block_start != blocks_end { let ix = block_start + local_id.x; - let tag_word = read_draw_tag_from_scene(ix); + let segment = resolve_draw_segment(ix); + let tag_word = read_draw_tag_from_segment(segment, ix); agg = map_draw_tag(tag_word); workgroupBarrier(); sh_scratch[local_id.x] = agg; @@ -136,11 +137,18 @@ fn main( m = combine_draw_monoid(m, sh_scratch[local_id.x - 1u]); } // m now contains exclusive prefix sum of draw monoid + let dd = draw_data_bias(segment) + m.scene_offset; + let di = m.info_offset; if ix < config.n_drawobj { - draw_monoid[ix] = m; + // Later stages read draw data at config.drawdata_base + scene_offset. Store the physical + // offset, so a draw object of a retained scene needs no segment lookup there. Without a + // segment table this is the monoid value unchanged. clip_leaf copies the offset of a + // BeginClip to its EndClip. The offset is physical, so the EndClip reads the BeginClip + // data also when a retained scene lies between them. + var stored = m; + stored.scene_offset = dd - config.drawdata_base; + draw_monoid[ix] = stored; } - let dd = config.drawdata_base + m.scene_offset; - let di = m.info_offset; if tag_word == DRAWTAG_FILL_COLOR || tag_word == DRAWTAG_FILL_RECOLOR || tag_word == DRAWTAG_FILL_LIN_GRADIENT || tag_word == DRAWTAG_FILL_RAD_GRADIENT || tag_word == DRAWTAG_FILL_ELLIPTIC_GRADIENT || tag_word == DRAWTAG_FILL_SWEEP_GRADIENT || tag_word == DRAWTAG_FILL_PATH_GRADIENT || tag_word == DRAWTAG_FILL_IMAGE || tag_word == DRAWTAG_BEGIN_CLIP @@ -367,7 +375,14 @@ fn main( info[di + 4u] = bitcast(1.0); info[di + 5u] = bitcast(-bitcast(scene[dd + 3u])); info[di + 6u] = bitcast(-bitcast(scene[dd + 4u])); - info[di + 7u] = scene[dd]; + // The atlas offset packs (x << 16) | y. A retained scene keeps the atlas layout + // of its own images, which follow the outer images, so add its first atlas row. + var atlas_y_base = 0u; + if segment != SCENE_SEGMENT_NONE { + atlas_y_base = scene[segment + SCENE_SEGMENT_ATLAS_Y_BASE]; + } + + info[di + 7u] = scene[dd] + atlas_y_base; info[di + 8u] = scene[dd + 1u]; info[di + 9u] = scene[dd + 2u]; } diff --git a/src/ImageSharp.Drawing.WebGPU/Shaders/WgslSource/draw_reduce.wgsl b/src/ImageSharp.Drawing.WebGPU/Shaders/WgslSource/draw_reduce.wgsl index 49c670c53..30f5eec24 100644 --- a/src/ImageSharp.Drawing.WebGPU/Shaders/WgslSource/draw_reduce.wgsl +++ b/src/ImageSharp.Drawing.WebGPU/Shaders/WgslSource/draw_reduce.wgsl @@ -6,8 +6,8 @@ // DrawMonoid values and reduces them to a single aggregate. draw_leaf later // combines these per-workgroup aggregates into a full exclusive prefix sum. // -// Inputs: config uniform; scene stream (draw tags read at -// config.drawtag_base via read_draw_tag_from_scene). +// Inputs: config uniform; scene stream (draw tags read through the segment bias +// of scene_segment.wgsl, via read_draw_tag_from_scene). // Outputs: reduced[wg] holds the combined DrawMonoid of workgroup wg's blocks. // // Ported from Vello's draw_reduce.wgsl (linebender/vello, @@ -15,6 +15,8 @@ #import config #import drawtag +#import scene_segment +#import util @group(0) @binding(0) var config: Config; @@ -29,8 +31,6 @@ const WG_SIZE = 256u; var sh_scratch: array; -#import util - // Reduces this workgroup's blocks of draw tags to a single DrawMonoid. // Each thread serially accumulates one lane across its strided block reads, // then a workgroup tree reduction folds the lane values together; thread 0 diff --git a/src/ImageSharp.Drawing.WebGPU/Shaders/WgslSource/fine.wgsl b/src/ImageSharp.Drawing.WebGPU/Shaders/WgslSource/fine.wgsl index 6741f2e11..3706f88a1 100644 --- a/src/ImageSharp.Drawing.WebGPU/Shaders/WgslSource/fine.wgsl +++ b/src/ImageSharp.Drawing.WebGPU/Shaders/WgslSource/fine.wgsl @@ -25,6 +25,8 @@ #import config #import drawtag #import tile +#import blend +#import ptcl // Scene configuration: target dimensions, tile counts, and the chunk window. @group(0) @binding(0) @@ -34,9 +36,6 @@ var config: Config; @group(0) @binding(1) var segments: array; -#import blend -#import ptcl - // Width in texels of each gradient ramp row. const GRADIENT_WIDTH = 512; @@ -84,6 +83,18 @@ var scene_data: array; @group(0) @binding(10) var path_tiles: array; +// Word offset of the profile record table of the current fill. main sets the scene table. +// CMD_PROFILE_TABLE sets the table of the scene segment of the next fill. Only aliased fills read +// it. With a segment table, coarse writes a CMD_PROFILE_TABLE before every aliased fill, so a fill +// never reads a table that belongs to another segment. +var fill_profile_records: u32; + +// The position of the target in data coordinates, unpacked from config.target_origin. Coverage +// is in target space, because path_lowering moved the lines. Brushes and profile records are in +// data space, so their sample positions add this value. It is zero for the root target, and the +// layer position for a scoped layer target. +var data_origin: vec2; + // Decodes a CMD_FILL payload: packed segment-count/fill-rule word, segment // base index, backdrop winding, coverage data, and the raster interest // rectangle. Antialiased text may use the coverage word for a perceptual boost; @@ -334,23 +345,22 @@ const EXTEND_REPEAT: u32 = 1u; const EXTEND_REFLECT: u32 = 2u; const EXTEND_DECAL: u32 = 3u; // Maps a gradient parameter t into [0, 1] according to the extend mode. -// Negative-t handling deliberately matches the CPU gradient brushes; see the -// per-case notes. +// Repeat and reflect wrap in both directions, before the start point and after +// the end point, matching the CPU gradient brushes. fn extend_mode_normalized(t: f32, mode: u32) -> f32 { switch mode { case EXTEND_PAD: { return clamp(t, 0.0, 1.0); } case EXTEND_REPEAT: { - // The CPU gradient brushes do not wrap values before the first stop. - // They hold the first stop for t < 0 and only repeat for t >= 0. - return select(fract(t), 0.0, t < 0.0); + // fract(t) is t - floor(t), which is in [0, 1) for negative t as well. + return fract(t); } case EXTEND_REFLECT: { - // Likewise, CPU reflection clamps negative values to the first stop - // and only reflects once the parameter moves forward beyond 0. - let clamped = max(t, 0.0); - return abs(clamped - 2.0 * round(0.5 * clamped)); + // t - 2 * round(t / 2) is the signed distance to the nearest even + // integer, in [-1, 1]. Its magnitude is a triangle wave that is + // symmetric about 0, so negative t reflects like positive t. + return abs(t - 2.0 * round(0.5 * t)); } case EXTEND_DECAL, default: { // DontFill contributes no color outside the finite gradient interval. A negative @@ -587,13 +597,13 @@ const PIXELS_PER_THREAD = 4u; fn profile_is_stub(x_axis: bool, a: u32, b: u32, centre_px: f32, span_px: f32) -> bool { // A sentinel identifier or an absent record table means the interval cannot be classified as // a terminating tip. Keep its midpoint pixel. - if a == PROFILE_ID_SENTINEL || b == PROFILE_ID_SENTINEL || config.profile_records_base == 0u { + if a == PROFILE_ID_SENTINEL || b == PROFILE_ID_SENTINEL || fill_profile_records == 0u { return false; } // The record region starts with the X and Y counts. Each following record has three words: // minimum, maximum, and adjacency link. - let records = config.profile_records_base; + let records = fill_profile_records; let x_count = scene_data[records]; let y_count = scene_data[records + 1u]; var base = records + 2u; @@ -964,7 +974,7 @@ fn fill_path_aliased(fill: CmdFill, xy: vec2, global_xy: vec2, result: let midpoint_pixel = ((interval_start_fixed >> 1) + (position_fixed >> 1)) >> 8; let local = midpoint_pixel - i32(xy.x); if local >= 0 && local < i32(PIXELS_PER_THREAD) && !lit[u32(local)] - && !profile_is_stub(false, enter_id, packed & PROFILE_ID_MASK, global_xy.y + 0.5, position - interval_start) { + && !profile_is_stub(false, enter_id, packed & PROFILE_ID_MASK, global_xy.y + data_origin.y + 0.5, position - interval_start) { lit[u32(local)] = true; } } @@ -1019,7 +1029,7 @@ fn fill_path_aliased(fill: CmdFill, xy: vec2, global_xy: vec2, result: let position_fixed = i32(round(position * 256.0)); let midpoint_row = ((interval_start_fixed >> 1) + (position_fixed >> 1)) >> 8; if midpoint_row == i32(xy.y) - && !profile_is_stub(true, enter_id, packed & PROFILE_ID_MASK, global_xy.x + f32(i) + 0.5, position - interval_start) { + && !profile_is_stub(true, enter_id, packed & PROFILE_ID_MASK, global_xy.x + data_origin.x + f32(i) + 0.5, position - interval_start) { lit[i] = true; } } @@ -1041,6 +1051,19 @@ fn fill_path_aliased(fill: CmdFill, xy: vec2, global_xy: vec2, result: *result = area; } +// Fixed-point units for one full pixel of area in fill_path. 2^18 units keep each rounded +// contribution within 2^-19 of its f32 value, far below one 8-bit or binary16 coverage step, +// and an i32 sum holds +-8192 full pixels of contributions. +const AREA_FIXED_SCALE: f32 = 262144.0; + +// Adding 1.5 * 2^23 to an f32 whose magnitude is below 2^22 gives a sum in [2^23, 2^24), where +// one unit in the last place is 1. The add therefore rounds the value to the nearest integer +// (ties to even). That integer is the bit pattern of the sum minus the bit pattern of the +// constant. Both scaled contributions stay within +-2^18, so the identity holds. fma applies +// the scale and the rounding in one operation, with no round or float-to-integer conversion. +const AREA_ROUND_BIAS: f32 = 12582912.0; +const AREA_ROUND_BIAS_BITS: u32 = 0x4b400000u; + // Computes per-pixel coverage for a CMD_FILL using analytic area // anti-aliasing. Signed winding is accumulated from the backdrop and every // segment in the tile, the fill rule is applied, and coverage outside the @@ -1060,11 +1083,23 @@ fn fill_path(fill: CmdFill, xy: vec2, global_xy: vec2, result: ptr; - let backdrop_f = f32(fill.backdrop); + var area_bits: array; for (var i = 0u; i < PIXELS_PER_THREAD; i += 1u) { - area[i] = backdrop_f; + area_bits[i] = 0u; } + var shared_fixed = 0u; for (var i = 0u; i < n_segs; i++) { let seg_off = fill.seg_data + i; let segment = segments[seg_off]; @@ -1088,6 +1123,9 @@ fn fill_path(fill: CmdFill, xy: vec2, global_xy: vec2, result: ptr, global_xy: vec2, result: ptr(fma(a, dy_fixed, AREA_ROUND_BIAS)); } } - // Winding contribution from segments crossing the tile's left edge. + // Winding contribution from segments crossing the tile's left edge. It is the same for + // every pixel of this thread. let y_edge = sign(delta.x) * clamp(xy.y - segment.y_edge + 1.0, 0.0, 1.0); - for (var i = 0u; i < PIXELS_PER_THREAD; i += 1u) { - area[i] += y_edge; - } + shared_fixed += bitcast(fma(y_edge, AREA_FIXED_SCALE, AREA_ROUND_BIAS)) - AREA_ROUND_BIAS_BITS; + } + // The backdrop is an integer winding, so it is added after the fixed-point sum. + let backdrop_f = f32(fill.backdrop); + for (var i = 0u; i < PIXELS_PER_THREAD; i += 1u) { + let area_fixed = bitcast(area_bits[i] + shared_fixed); + area[i] = backdrop_f + f32(area_fixed) * (1.0 / AREA_FIXED_SCALE); } if even_odd { // even-odd winding rule @@ -1156,12 +1199,26 @@ fn main( if ptcl[0] == ~0u { // An earlier stage has failed, don't try to render. // We use ptcl[0] for this so we don't use up a binding for bump. + // The output texture can hold texels of an earlier flush. A deferred presentation flush + // copies it to the target before the failure is known. This path therefore writes the raw + // backdrop texels, so the copy leaves the target pixels unchanged. + let failed_xy = vec2(global_id.x * PIXELS_PER_THREAD, config.chunk_tile_y_start * TILE_HEIGHT + global_id.y); + for (var i = 0u; i < PIXELS_PER_THREAD; i += 1u) { + let coords = failed_xy + vec2(i, 0u); + if coords.x < config.target_width && coords.y < config.target_height { + textureStore(output, vec2(coords), textureLoad(backdrop_texture, vec2(coords), 0)); + } + } return; } let tile_ix = wg_id.y * config.width_in_tiles + wg_id.x; + fill_profile_records = config.profile_records_base; + data_origin = vec2(f32(config.target_origin & 0xffffu), f32(config.target_origin >> 16u)); // Full-target pixel position; y is offset by the chunk's starting tile row // so oversized scenes render correctly in tile-row windows. let xy = vec2(f32(global_id.x * PIXELS_PER_THREAD), f32(config.chunk_tile_y_start * TILE_HEIGHT + global_id.y)); + // The same pixel position in data space, where the brush payloads are defined. + let paint_xy = xy + data_origin; let xy_uint = vec2(xy); let local_xy = vec2(f32(local_id.x * PIXELS_PER_THREAD), f32(local_id.y)); var rgba: array, PIXELS_PER_THREAD>; @@ -1196,10 +1253,16 @@ fn main( } switch tag { case CMD_FILL: { + // This is the only fill_path call site. Each call site inlines a copy of the fill + // rasterizer, so one call site keeps the shader small and fast. let fill = read_fill(cmd_ix); fill_path(fill, local_xy, xy, &area); cmd_ix += 6u; } + case CMD_PROFILE_TABLE: { + fill_profile_records = ptcl[cmd_ix + 1u]; + cmd_ix += 2u; + } case CMD_SOLID: { // Full coverage, restricted to the command's raster interest // rectangle. @@ -1344,7 +1407,7 @@ fn main( // The draw-flags word sits immediately before the brush data // in the info stream (same layout for all gradient commands). let draw_flags = info[ptcl[cmd_ix + 2u] - 1u]; - let d = lin.line_x * (xy.x + 0.5) + lin.line_y * (xy.y + 0.5) + lin.line_c; + let d = lin.line_x * (paint_xy.x + 0.5) + lin.line_y * (paint_xy.y + 0.5) + lin.line_c; for (var i = 0u; i < PIXELS_PER_THREAD; i += 1u) { paint_coverage[i] = area[i]; if area[i] != 0.0 { @@ -1382,7 +1445,7 @@ fn main( let less_scale = select(1.0, -1.0, is_swapped || (1.0 - focal_x) < 0.0); let t_sign = sign(1.0 - focal_x); for (var i = 0u; i < PIXELS_PER_THREAD; i += 1u) { - let my_xy = vec2(xy.x + f32(i) + 0.5, xy.y + 0.5); + let my_xy = vec2(paint_xy.x + f32(i) + 0.5, paint_xy.y + 0.5); let local_xy = rad.matrx.xy * my_xy.x + rad.matrx.zw * my_xy.y + rad.xlat; let x = local_xy.x; let y = local_xy.y; @@ -1433,7 +1496,7 @@ fn main( for (var i = 0u; i < PIXELS_PER_THREAD; i += 1u) { paint_coverage[i] = area[i]; if area[i] != 0.0 { - let my_xy = vec2(xy.x + f32(i) + 0.5, xy.y + 0.5); + let my_xy = vec2(paint_xy.x + f32(i) + 0.5, paint_xy.y + 0.5); if elliptic.kind == ELLIPTIC_GRAD_KIND_NORMAL { let local_xy = elliptic.matrx.xy * my_xy.x + elliptic.matrx.zw * my_xy.y + elliptic.xlat; let radius = length(local_xy); @@ -1484,7 +1547,7 @@ fn main( for (var i = 0u; i < PIXELS_PER_THREAD; i += 1u) { paint_coverage[i] = area[i]; if area[i] != 0.0 { - let my_xy = vec2(xy.x + f32(i) + 0.5, xy.y + 0.5); + let my_xy = vec2(paint_xy.x + f32(i) + 0.5, paint_xy.y + 0.5); let local_xy = sweep.matrx.xy * my_xy.x + sweep.matrx.zw * my_xy.y + sweep.xlat; let x = local_xy.x; let y = local_xy.y; @@ -1532,7 +1595,7 @@ fn main( for (var i = 0u; i < PIXELS_PER_THREAD; i += 1u) { paint_coverage[i] = area[i]; if area[i] != 0.0 { - let my_xy = vec2(xy.x + f32(i) + 0.5, xy.y + 0.5); + let my_xy = vec2(paint_xy.x + f32(i) + 0.5, paint_xy.y + 0.5); paint_color[i] = evaluate_path_gradient(path_grad, my_xy); } } @@ -1547,7 +1610,7 @@ fn main( paint_coverage[i] = 0.0; // We only need to load from the textures if the value will be used. if area[i] != 0.0 { - let my_xy = vec2(xy.x + f32(i), xy.y); + let my_xy = vec2(paint_xy.x + f32(i), paint_xy.y); var atlas_uv = image.matrx.xy * my_xy.x + image.matrx.zw * my_xy.y + image.xlat; let atlas_ix = image_extend_mode_i32(atlas_uv.x, image.x_extend_mode, i32(image.extents.x)); let atlas_iy = image_extend_mode_i32(atlas_uv.y, image.y_extend_mode, i32(image.extents.y)); diff --git a/src/ImageSharp.Drawing.WebGPU/Shaders/WgslSource/path_lowering.wgsl b/src/ImageSharp.Drawing.WebGPU/Shaders/WgslSource/path_lowering.wgsl index 472f7035e..ee96d0593 100644 --- a/src/ImageSharp.Drawing.WebGPU/Shaders/WgslSource/path_lowering.wgsl +++ b/src/ImageSharp.Drawing.WebGPU/Shaders/WgslSource/path_lowering.wgsl @@ -14,6 +14,7 @@ #import pathtag #import segment #import bump +#import scene_segment @group(0) @binding(0) var config: Config; @@ -426,7 +427,7 @@ struct PathTagData { // prefix from tag_monoids (produced by pathtag_reduce/scan) with a local reduction of the // preceding bytes in the same tag word. fn compute_tag_monoid(ix: u32) -> PathTagData { - let tag_word = scene[config.pathtag_base + (ix >> 2u)]; + let tag_word = scene[pathtag_base + (ix >> 2u)]; let shift = (ix & 3u) * 8u; var tm = reduce_tag(tag_word & ((1u << shift) - 1u)); // TODO: this can be a read buf overflow. Conditionalize by tag byte? @@ -499,8 +500,10 @@ fn snap_to_pixel_grid(p: vec2f) -> vec2f { // invocation's running bbox. Out-of-range writes are dropped (the bump counter may legitimately // exceed the buffer size). fn write_line(line_ix: u32, path_ix: u32, p0: vec2f, p1: vec2f) { - let s0 = snap_to_pixel_grid(p0); - let s1 = snap_to_pixel_grid(p1); + // Snap in data space, then move into target space. The origin is a whole pixel, so the move + // keeps the snapped values exact. + let s0 = snap_to_pixel_grid(p0) - target_origin; + let s1 = snap_to_pixel_grid(p1) - target_origin; bbox = vec4(min(bbox.xy, min(s0, s1)), max(bbox.zw, max(s0, s1))); if line_ix < config.lines_size { @@ -557,8 +560,19 @@ fn read_neighboring_segment(ix: u32) -> NeighboringSegment { return NeighboringSegment(do_join, length(tangent), tangent); } -// `pathdata_base` is decoded once and reused by helpers above. +// The stream bases are decoded once and reused by the helpers above. With a scene segment +// table they are the biases of the segment that holds this invocation's path tag, so the +// virtual monoid offsets index the segment's physical words (see scene_segment.wgsl). A path and +// the tag that follows it in a stroke are always in the same segment. +var pathtag_base: u32; var pathdata_base: u32; +var style_base: u32; +var transform_base: u32; +var profile_slots_base: u32; + +// The position of the target in data coordinates, unpacked from config.target_origin. It is zero +// for the root target, and the layer position for a scoped layer target. +var target_origin: vec2f; // X and Y profile identifiers for the current fill segment. Stroke segments and fills without // profile data use the all-ones sentinel. @@ -576,7 +590,21 @@ fn main( @builtin(local_invocation_id) local_id: vec3, ) { let ix = global_id.x; + pathtag_base = config.pathtag_base; pathdata_base = config.pathdata_base; + style_base = config.style_base; + transform_base = config.transform_base; + profile_slots_base = config.profile_slots_base; + if config.segment_count != 0u { + let segment = find_scene_segment(SCENE_SEGMENT_PATHTAG_START, ix >> 2u); + pathtag_base = scene[segment + SCENE_SEGMENT_PATHTAG_BIAS]; + pathdata_base = scene[segment + SCENE_SEGMENT_PATHDATA_BIAS]; + style_base = scene[segment + SCENE_SEGMENT_STYLE_BIAS]; + transform_base = scene[segment + SCENE_SEGMENT_TRANSFORM_BIAS]; + profile_slots_base = scene[segment + SCENE_SEGMENT_PROFILE_SLOTS]; + } + + target_origin = vec2f(f32(config.target_origin & 0xffffu), f32(config.target_origin >> 16u)); bbox = vec4(1e31, 1e31, -1e31, -1e31); let tag = compute_tag_monoid(ix); @@ -587,14 +615,15 @@ fn main( let out = &path_bboxes[path_ix]; // Style stream layout (words): 0 flags, 1 line width, 2 draw flags, 3 miter limit, // 4 arc detail scale, 5 antialiased coverage adjustment, 6..9 interest rect. - let style_flags = scene[config.style_base + style_ix]; - let style_draw_flags = scene[config.style_base + style_ix + 2u]; - let coverage_data = bitcast(scene[config.style_base + style_ix + 5u]); + let style_flags = scene[style_base + style_ix]; + let style_draw_flags = scene[style_base + style_ix + 2u]; + let coverage_data = bitcast(scene[style_base + style_ix + 5u]); + // The interest rectangle is in data space, like the lines, so it moves into target space too. let style_interest = vec4( - bitcast(scene[config.style_base + style_ix + 6u]), - bitcast(scene[config.style_base + style_ix + 7u]), - bitcast(scene[config.style_base + style_ix + 8u]), - bitcast(scene[config.style_base + style_ix + 9u])); + bitcast(scene[style_base + style_ix + 6u]), + bitcast(scene[style_base + style_ix + 7u]), + bitcast(scene[style_base + style_ix + 8u]), + bitcast(scene[style_base + style_ix + 9u])) - vec4f(target_origin, target_origin); // The fill bit is always zero for strokes, which selects the non-zero fill rule. let fill_rule = select(DRAW_INFO_FLAGS_FILL_RULE_BIT, 0u, (style_flags & STYLE_FLAGS_FILL) == 0u); let draw_flags = style_draw_flags | fill_rule; @@ -608,14 +637,14 @@ fn main( let seg_type = tag.tag_byte & PATH_TAG_SEG_TYPE; if seg_type != 0u { let is_stroke = (style_flags & STYLE_FLAGS_STYLE) != 0u; - let transform = read_transform(config.transform_base, trans_ix); + let transform = read_transform(transform_base, trans_ix); let pts = read_path_segment(tag, is_stroke); if is_stroke { - let linewidth = bitcast(scene[config.style_base + style_ix + 1u]); + let linewidth = bitcast(scene[style_base + style_ix + 1u]); let half_width = 0.5 * linewidth; - let miter_limit = bitcast(scene[config.style_base + style_ix + 3u]); - let arc_detail_scale = bitcast(scene[config.style_base + style_ix + 4u]); + let miter_limit = bitcast(scene[style_base + style_ix + 3u]); + let arc_detail_scale = bitcast(scene[style_base + style_ix + 4u]); let is_open = (tag.tag_byte & PATH_TAG_SEG_TYPE) != PATH_TAG_LINETO; let is_stroke_cap_marker = (tag.tag_byte & PATH_TAG_SUBPATH_END) != 0u; @@ -672,9 +701,11 @@ fn main( } else { // Each final fill segment starts at one pair of path-data words. The same pair index // selects its profile tag. Add pathdata_base because a large scene can dispatch only a - // range of its path tags while all ranges still share one profile-tag table. - if config.profile_slots_base != 0u { - let slot = config.profile_slots_base + ((config.pathdata_base + tag.monoid.pathseg_offset) >> 1u); + // range of its path tags while all ranges still share one profile-tag table. With a + // segment table, the sum is the physical word and the slot base of the segment + // already removes half the start of its retained block. + if profile_slots_base != 0u { + let slot = profile_slots_base + ((pathdata_base + tag.monoid.pathseg_offset) >> 1u); profile_tag = scene[slot]; } diff --git a/src/ImageSharp.Drawing.WebGPU/Shaders/WgslSource/path_row_alloc.wgsl b/src/ImageSharp.Drawing.WebGPU/Shaders/WgslSource/path_row_alloc.wgsl index 7298047ed..6c0010077 100644 --- a/src/ImageSharp.Drawing.WebGPU/Shaders/WgslSource/path_row_alloc.wgsl +++ b/src/ImageSharp.Drawing.WebGPU/Shaders/WgslSource/path_row_alloc.wgsl @@ -22,6 +22,7 @@ #import drawtag #import bbox #import tile +#import scene_segment @group(0) @binding(0) var config: Config; @@ -58,7 +59,7 @@ fn main( return; } - let drawtag = scene[config.drawtag_base + drawobj_ix]; + let drawtag = scene[draw_tag_bias(resolve_draw_segment(drawobj_ix)) + drawobj_ix]; let path_bbox = path_bboxes[drawobj_ix]; // Only aliased fills use the original left edge. Fine needs this distinction for the one-pixel // extension of an interval that started before the drawing bounds. diff --git a/src/ImageSharp.Drawing.WebGPU/Shaders/WgslSource/pathtag_reduce.wgsl b/src/ImageSharp.Drawing.WebGPU/Shaders/WgslSource/pathtag_reduce.wgsl index 095721f84..a3a6d9331 100644 --- a/src/ImageSharp.Drawing.WebGPU/Shaders/WgslSource/pathtag_reduce.wgsl +++ b/src/ImageSharp.Drawing.WebGPU/Shaders/WgslSource/pathtag_reduce.wgsl @@ -8,13 +8,15 @@ // per workgroup to the reduced buffer. The scan stages (pathtag_scan / // pathtag_scan1) later turn these partials into exclusive prefixes. // -// Inputs: config uniform, scene buffer (tag words at config.pathtag_base). +// Inputs: config uniform, scene buffer (tag words read through the segment bias of +// scene_segment.wgsl). // Outputs: reduced (one TagMonoid per workgroup). // // Ported from Vello's pathtag_reduce.wgsl. #import config #import pathtag +#import scene_segment @group(0) @binding(0) var config: Config; @@ -41,7 +43,7 @@ fn main( @builtin(local_invocation_id) local_id: vec3, ) { let ix = global_id.x; - let tag_word = scene[config.pathtag_base + ix]; + let tag_word = scene[resolve_path_tag_bias(ix) + ix]; var agg = reduce_tag(tag_word); sh_scratch[local_id.x] = agg; for (var i = 0u; i < firstTrailingBit(WG_SIZE); i += 1u) { diff --git a/src/ImageSharp.Drawing.WebGPU/Shaders/WgslSource/pathtag_scan.wgsl b/src/ImageSharp.Drawing.WebGPU/Shaders/WgslSource/pathtag_scan.wgsl index b232f83b7..6e5e42d26 100644 --- a/src/ImageSharp.Drawing.WebGPU/Shaders/WgslSource/pathtag_scan.wgsl +++ b/src/ImageSharp.Drawing.WebGPU/Shaders/WgslSource/pathtag_scan.wgsl @@ -19,6 +19,7 @@ #import config #import pathtag +#import scene_segment @group(0) @binding(0) var config: Config; @@ -72,7 +73,7 @@ fn main( #endif let ix = global_id.x; - let tag_word = scene[config.pathtag_base + ix]; + let tag_word = scene[resolve_path_tag_bias(ix) + ix]; var agg_part = reduce_tag(tag_word); sh_monoid[local_id.x] = agg_part; for (var i = 0u; i < LG_WG_SIZE; i += 1u) { diff --git a/src/ImageSharp.Drawing.WebGPU/WebGPUDrawingBackend.CopyPixels.cs b/src/ImageSharp.Drawing.WebGPU/WebGPUDrawingBackend.CopyPixels.cs index 905e55f9b..6663f2f4c 100644 --- a/src/ImageSharp.Drawing.WebGPU/WebGPUDrawingBackend.CopyPixels.cs +++ b/src/ImageSharp.Drawing.WebGPU/WebGPUDrawingBackend.CopyPixels.cs @@ -54,33 +54,20 @@ public void CopyPixels( throw new NotSupportedException($"Pixel type '{typeof(TPixel).Name}' does not match one of the WebGPU target formats."); } - // Clip in two stages: first the request against the source bounds, then the shifted - // result against the target bounds. Out-of-range copies clip silently rather than throw. - Rectangle sourceBounds = new(0, 0, source.Bounds.Width, source.Bounds.Height); - Rectangle clippedSource = Rectangle.Intersect(sourceBounds, sourceRectangle); - if (clippedSource.Width <= 0 || clippedSource.Height <= 0) + // Out-of-range copies clip silently rather than throw. + if (!IDrawingBackend.TryClipPixelCopy( + source.Bounds.Size, + target.Bounds.Size, + sourceRectangle, + targetPoint, + out Point sourceOrigin, + out Rectangle clippedTarget)) { return; } - Rectangle targetBounds = new(0, 0, target.Bounds.Width, target.Bounds.Height); - Rectangle targetRectangle = new( - targetPoint.X + clippedSource.X - sourceRectangle.X, - targetPoint.Y + clippedSource.Y - sourceRectangle.Y, - clippedSource.Width, - clippedSource.Height); - - Rectangle clippedTarget = Rectangle.Intersect(targetBounds, targetRectangle); - - if (clippedTarget.Width <= 0 || clippedTarget.Height <= 0) - { - return; - } - - // Propagate any target-side clipping back to the source origin so both rectangles - // describe the same clipped extent. - int sourceX = clippedSource.X + clippedTarget.X - targetRectangle.X; - int sourceY = clippedSource.Y + clippedTarget.Y - targetRectangle.Y; + int sourceX = sourceOrigin.X; + int sourceY = sourceOrigin.Y; int targetX = clippedTarget.X; int targetY = clippedTarget.Y; int width = clippedTarget.Width; diff --git a/src/ImageSharp.Drawing.WebGPU/WebGPUDrawingBackend.Ordered.cs b/src/ImageSharp.Drawing.WebGPU/WebGPUDrawingBackend.Ordered.cs index d612b79cf..da210fc68 100644 --- a/src/ImageSharp.Drawing.WebGPU/WebGPUDrawingBackend.Ordered.cs +++ b/src/ImageSharp.Drawing.WebGPU/WebGPUDrawingBackend.Ordered.cs @@ -104,6 +104,10 @@ private sealed unsafe class WebGPUOrderedExecutor : IDisposable private readonly WebGPUEffectRenderer? effectRenderer; private WebGPUSceneResourceArena? resourceArena; private WebGPUSceneSchedulingArena? schedulingArena; + + // The resource arenas of the retained scenes that this transaction renders, by first use. + private List<(WebGPUDrawingBackendScene Scene, WebGPUSceneResourceArena? Arena)>? retainedArenas; + private WGPUMapAsyncStatus mapStatus; private ulong lastSubmissionIndex; private int currentTargetId; @@ -255,6 +259,7 @@ public void Execute() case WebGPUSceneOperationKind.RenderRange: this.SubmitRange( operation.Range, + operation.RetainedScene, this.currentTargetId, WebGPUOrderedExternalSourceKind.None, null, @@ -269,6 +274,7 @@ public void Execute() this.currentTargetId = operation.ParentTargetId; this.SubmitRange( operation.Range, + operation.RetainedScene, operation.ParentTargetId, WebGPUOrderedExternalSourceKind.Target, null, @@ -290,7 +296,7 @@ public void Execute() this.CommitTargets(); } - this.ProcessShaderEffect(operation.ShaderEffect!); + this.ProcessShaderEffect(operation); break; } } @@ -315,6 +321,40 @@ public void Dispose() this.DisposeApplyImages(this.applyImages.Length); this.mapCallback?.Dispose(); this.mapReady?.Dispose(); + + // Queue order makes every submitted pass finish with an arena before a later render + // can use it, so the arenas return now, the same as after a render of the scene alone. + if (this.retainedArenas is not null) + { + for (int i = 0; i < this.retainedArenas.Count; i++) + { + (WebGPUDrawingBackendScene scene, WebGPUSceneResourceArena? arena) = this.retainedArenas[i]; + scene.ReturnArenas(arena, null, this.backend); + } + } + } + + /// + /// Gets the resource arena slot of a retained scene for this transaction. The first call for + /// a scene rents the scene's own arena. + /// + /// The retained scene. + /// A reference to the arena slot. It stays valid until the next call. + private ref WebGPUSceneResourceArena? GetRetainedResourceArena(WebGPUDrawingBackendScene scene) + { + List<(WebGPUDrawingBackendScene Scene, WebGPUSceneResourceArena? Arena)> arenas = this.retainedArenas ??= []; + int index = 0; + while (index < arenas.Count && !ReferenceEquals(arenas[index].Scene, scene)) + { + index++; + } + + if (index == arenas.Count) + { + arenas.Add((scene, scene.RentResourceArena() ?? this.backend.RentResourceArena())); + } + + return ref CollectionsMarshal.AsSpan(arenas)[index].Arena; } /// @@ -407,12 +447,14 @@ private void BeginLayer(WebGPUSceneOperation operation) /// Submits one range with synchronous transaction validation or deferred presentation validation. /// /// The retained range to submit. + /// The retained scene whose data the range reads, or for the ordered scene itself. /// The logical target receiving the range. /// The source bound to the range's external texture slot. /// The fixed Apply upload view, when applicable. /// The layer target supplying a composite view, when applicable. private void SubmitRange( WebGPUSceneRange range, + WebGPUDrawingBackendScene? retainedScene, int targetId, WebGPUOrderedExternalSourceKind externalSourceKind, WGPUTextureViewImpl* externalTextureView, @@ -425,6 +467,7 @@ private void SubmitRange( WebGPUOrderedJournalEntry newEntry = new( range, + retainedScene, targetId, externalSourceKind, externalTextureView, @@ -463,14 +506,25 @@ private void SubmitRangeAttempt(ref WebGPUOrderedJournalEntry entry) _ => null }; - WebGPUStagedScene stagedScene = WebGPUSceneDispatch.CreateStagedScene( - this.flushContext, - this.encodedScene, - entry.Range, - this.requiredFeature, - this.BumpSizes, - externalTextureView, - ref this.resourceArena); + // A retained scene keeps its data resident in its own arena, so the passes of the + // ordered scene and of the retained scene do not upload each other's data again. + WebGPUStagedScene stagedScene = entry.RetainedScene is null + ? WebGPUSceneDispatch.CreateStagedScene( + this.flushContext, + this.encodedScene, + entry.Range, + this.requiredFeature, + this.BumpSizes, + externalTextureView, + ref this.resourceArena) + : WebGPUSceneDispatch.CreateStagedScene( + this.flushContext, + entry.RetainedScene.EncodedScene, + entry.Range, + this.requiredFeature, + this.BumpSizes, + externalTextureView, + ref this.GetRetainedResourceArena(entry.RetainedScene)); nuint statusBufferCapacity = 0; WGPUBufferImpl* statusReadbackBuffer = this.readbackBuffer; @@ -549,7 +603,7 @@ private void SubmitRangeAttempt(ref WebGPUOrderedJournalEntry entry) submissionIndex); statusBufferOwnershipTransferred = true; - this.backend.EnqueuePendingSchedulingStatus(pendingStatus, this.scene, correctiveRender: null); + this.backend.EnqueuePendingSchedulingStatus(pendingStatus); } else { @@ -681,6 +735,8 @@ private void ResolveApplyBarrier(int firstOperationIndex, int applyCount) if (requiresReplay) { + this.backend.CountScratchOverflow(); + // CopyDst commands cannot target a mapped buffer. Replay starts only after // the failed barrier has been unmapped. this.ReplayJournal(); @@ -722,6 +778,7 @@ private void ResolveFinalBarrier() if (requiresReplay) { + this.backend.CountScratchOverflow(); this.ReplayJournal(); continue; } @@ -923,7 +980,8 @@ private void ProcessApplyGroup(int firstOperationIndex, int applyCount) { for (; processedCount < applyCount; processedCount++) { - WebGPUApplySceneItem apply = operations[firstOperationIndex + processedCount].Apply!; + WebGPUSceneOperation applyOperation = operations[firstOperationIndex + processedCount]; + WebGPUApplySceneItem apply = applyOperation.Apply!; Image image = this.applyImages[processedCount]!; image.Mutate(apply.Operation); @@ -952,6 +1010,7 @@ private void ProcessApplyGroup(int firstOperationIndex, int applyCount) this.applyImages[processedCount] = null; this.SubmitRange( apply.DrawRange, + applyOperation.RetainedScene, this.currentTargetId, WebGPUOrderedExternalSourceKind.TextureView, textureView, @@ -984,9 +1043,11 @@ private void ProcessApplyGroup(int firstOperationIndex, int applyCount) /// /// Executes one native effect against the latest ordered source and journals its retained write-back range. /// - /// The native effect operation. - private void ProcessShaderEffect(WebGPUShaderEffectSceneItem effect) + /// The native effect operation. + private void ProcessShaderEffect(WebGPUSceneOperation operation) { + WebGPUShaderEffectSceneItem effect = operation.ShaderEffect!; + // A fully clipped write-back has no fill to draw. Skip the passes entirely: nothing // samples the result, and SubmitRange would return without submitting, leaving the // recorded passes in the shared encoder while the next invocation rewrites the @@ -1000,6 +1061,7 @@ private void ProcessShaderEffect(WebGPUShaderEffectSceneItem effect) WGPUTextureViewImpl* effectTextureView = this.effectRenderer!.Render(effect, sourceTarget); this.SubmitRange( effect.DrawRange, + operation.RetainedScene, this.currentTargetId, WebGPUOrderedExternalSourceKind.TextureView, effectTextureView, @@ -1305,18 +1367,21 @@ private struct WebGPUOrderedJournalEntry /// Initializes a new instance of the struct. /// /// The retained range to replay. + /// The retained scene whose data the range reads, or for the ordered scene itself. /// The target identity receiving the range. /// The range's external texture source. /// The fixed Apply upload view. /// The layer target supplying a composite view. public WebGPUOrderedJournalEntry( WebGPUSceneRange range, + WebGPUDrawingBackendScene? retainedScene, int targetId, WebGPUOrderedExternalSourceKind externalSourceKind, WGPUTextureViewImpl* externalTextureView, int externalTargetId) { this.Range = range; + this.RetainedScene = retainedScene; this.TargetId = targetId; this.ExternalSourceKind = externalSourceKind; this.ExternalTextureView = externalTextureView; @@ -1332,6 +1397,11 @@ public WebGPUOrderedJournalEntry( /// public WebGPUSceneRange Range { get; } + /// + /// Gets the retained scene whose data the range reads, or for the ordered scene itself. + /// + public WebGPUDrawingBackendScene? RetainedScene { get; } + /// /// Gets the stable target identity receiving the range. /// diff --git a/src/ImageSharp.Drawing.WebGPU/WebGPUDrawingBackend.Readback.cs b/src/ImageSharp.Drawing.WebGPU/WebGPUDrawingBackend.Readback.cs index c5854175a..ae8ec0fb4 100644 --- a/src/ImageSharp.Drawing.WebGPU/WebGPUDrawingBackend.Readback.cs +++ b/src/ImageSharp.Drawing.WebGPU/WebGPUDrawingBackend.Readback.cs @@ -35,9 +35,9 @@ public void ReadRegion( Guard.NotNull(target, nameof(target)); Guard.NotNull(destination.Buffer, nameof(destination)); - // CPU reads observe content as final, so every deferred overflow readback must be - // resolved first: an overflowed offscreen flush is corrected by its parked re-render - // before the copy below is recorded, and queue ordering makes the copy see the fix. + // An offscreen render checks its scratch status before it returns, so its pixels are final. + // The parked statuses of presentation flushes resolve here, and their observed demand grows + // the scratch capacities. this.HarvestPendingSchedulingStatuses(scene: null, drainAll: true); // Readback is only available for native WebGPU targets with valid interop handles. diff --git a/src/ImageSharp.Drawing.WebGPU/WebGPUDrawingBackend.cs b/src/ImageSharp.Drawing.WebGPU/WebGPUDrawingBackend.cs index 80d37d7b8..da206cf45 100644 --- a/src/ImageSharp.Drawing.WebGPU/WebGPUDrawingBackend.cs +++ b/src/ImageSharp.Drawing.WebGPU/WebGPUDrawingBackend.cs @@ -43,15 +43,14 @@ public sealed unsafe partial class WebGPUDrawingBackend : IDrawingBackend, IDisp private WebGPUSceneSchedulingArena? cachedSchedulingArena; private WebGPUSceneResourceArena? cachedResourceArena; - // Deferred scheduling-status readbacks from recent flushes. The backend is the owner - // because it outlives the per-frame canvases and scenes: parking a pending map on a - // per-frame scene would force a blocking resolve at that scene's disposal, putting the - // CPU-GPU sync right back into the frame. The queue is harvested wait-free at the start - // of each flush; entries are only force-resolved when the queue exceeds its bound. - // Offscreen entries carry the context for a corrective re-render on overflow and hold a - // deferred-render reference that keeps their scene's GPU payload alive until harvested. + // Deferred scheduling-status readbacks from recent presentation flushes. The backend is the + // owner because it outlives the per-frame canvases and scenes. A pending map on a per-frame + // scene forces a blocking resolve when that scene is disposed, which puts the CPU-GPU sync + // back into the frame. Each flush harvests the queue wait-free at its start. Entries are + // force-resolved only when the queue exceeds its bound. An entry only grows the scratch + // capacities, because the next frame redraws the surface. private readonly object pendingStatusSync = new(); - private readonly List pendingSchedulingStatuses = []; + private readonly List pendingSchedulingStatuses = []; // Bounds the deferred-readback queue. The GPU typically completes a frame's map by the // next flush, so the queue holds roughly one entry per flush in the current frame; the @@ -70,6 +69,9 @@ public sealed unsafe partial class WebGPUDrawingBackend : IDrawingBackend, IDisp private WebGPUSceneDispatch.BindingLimitBuffer lastChunkingBindingFailure; private bool isDisposed; + // The count behind DiagnosticScratchOverflowCount. Concurrent renders add to it with Interlocked. + private int scratchOverflowCount; + /// /// Initializes a new instance of the class. /// @@ -85,6 +87,16 @@ public WebGPUDrawingBackend() /// internal bool DiagnosticLastFlushUsedChunking { get; private set; } + /// + /// Gets the number of renders on this backend instance whose scratch buffers overflowed. + /// + /// + /// An offscreen overflow is counted before its render returns. A presentation overflow is + /// counted when a later flush harvests its status readback. Concurrent renders can count at the + /// same time, so the count changes only through . + /// + internal int DiagnosticScratchOverflowCount => Volatile.Read(ref this.scratchOverflowCount); + /// /// Gets or sets the upper bound used when validating staged-scene storage bindings. /// @@ -99,11 +111,15 @@ public WebGPUDrawingBackend() /// /// /// This value describes only the most recent flush on this backend instance. When the most recent flush did not use - /// chunked rendering, this property returns None. A corrective re-render triggered by a deferred overflow - /// readback also updates this value, so it can change after the originating flush call has returned. + /// chunked rendering, this property returns None. /// internal string DiagnosticLastChunkingBindingFailure => this.lastChunkingBindingFailure.ToString(); + /// + /// Counts one render whose scratch buffers overflowed. + /// + internal void CountScratchOverflow() => Interlocked.Increment(ref this.scratchOverflowCount); + /// public DrawingBackendScene CreateScene( Configuration configuration, @@ -113,11 +129,32 @@ public DrawingBackendScene CreateScene( { this.ThrowIfDisposed(); + if (commandBatch.HasScenes) + { + int index = 0; + DrawingBackendScene? retainedScene = commandBatch.ReadRootPart(ref index, out DrawingCommandBatch run); + if (retainedScene is not null) + { + return this.CreatePartsScene(configuration, targetBounds, commandBatch, run, retainedScene, index, ownedResources); + } + + // No retained scene is outside a layer. The run has the flags of the whole batch. + commandBatch = run; + } + + // A retained scene with ordered work inside a layer orders the batch the same as an Apply + // drawn there, and each layer open around it renders as a scoped layer. + bool[]? scopedSceneLayers = null; + bool hasOrderedScenes = commandBatch.HasScenes && commandBatch.FindOrderedScenes( + static scene => scene is WebGPUDrawingBackendScene { HasOrderedWork: true }, + out scopedSceneLayers); + // Batches containing Apply need the ordered encoder: Apply reads pixels back mid-scene, // so the operations before and after it must stay in submission order. - bool encoded = commandBatch.HasApply + bool encoded = commandBatch.HasApply || hasOrderedScenes ? WebGPUSceneEncoder.TryEncodeOrdered( commandBatch, + scopedSceneLayers, targetBounds, configuration.MemoryAllocator, configuration.MaxDegreeOfParallelism, @@ -143,6 +180,87 @@ public DrawingBackendScene CreateScene( ownedResources); } + /// + /// Creates a scene for a batch that has retained scenes outside every layer. + /// + /// + /// Each retained scene outside a layer renders as its own pass, in stream order, and each run + /// of commands between them renders as its own scene. This is the same work as the canvas + /// replay of each run and each retained scene on its own. + /// + /// The active processing configuration. + /// The target bounds of the batch. + /// The batch to split. + /// The commands before the first retained scene. + /// The first retained scene outside every layer. + /// The first command index after the first retained scene. + /// The resources that must stay alive for the returned scene. + /// The created backend scene. + private WebGPUDrawingBackendScene CreatePartsScene( + Configuration configuration, + Rectangle targetBounds, + DrawingCommandBatch commandBatch, + DrawingCommandBatch firstRun, + DrawingBackendScene firstScene, + int index, + IReadOnlyList? ownedResources) + { + List parts = []; + List partResources = []; + + try + { + DrawingCommandBatch run = firstRun; + DrawingBackendScene? retainedScene = firstScene; + while (true) + { + if (run.CommandCount != 0) + { + // The run has no retained scene outside a layer, so this call encodes it directly. + // The parts scene owns the images that the run reads. + DrawingBackendScene runScene = this.CreateScene(configuration, targetBounds, run); + + // The parts scene owns the run scenes. The caller owns the retained scenes. + partResources.Add(runScene); + parts.Add(runScene); + } + + if (retainedScene is null) + { + break; + } + + parts.Add(retainedScene); + if (index == commandBatch.CommandCount) + { + break; + } + + retainedScene = commandBatch.ReadRootPart(ref index, out run); + } + } + catch + { + // The caller disposes the image resources on failure, so only the run scenes are + // released here. + for (int i = 0; i < partResources.Count; i++) + { + partResources[i].Dispose(); + } + + throw; + } + + // The run scenes come first so that they are disposed before the image resources that + // they use, the same order as a single scene that disposes its own state first. + if (ownedResources is not null) + { + partResources.AddRange(ownedResources); + } + + return new WebGPUDrawingBackendScene([.. parts], targetBounds, this.bumpSizes, partResources); + } + /// public void RenderScene( Configuration configuration, @@ -159,6 +277,24 @@ public void RenderScene( throw new InvalidOperationException("The scene is not compatible with the WebGPU drawing backend."); } + if (webGPUScene.Parts is DrawingBackendScene[] parts) + { + if (nativeTarget.Bounds != webGPUScene.Bounds) + { + throw new InvalidOperationException("The target bounds do not match the WebGPU drawing backend scene bounds."); + } + + // Each part goes through this method again, so every part gets the target checks and + // its own deferred overflow handling. This is the same work as one canvas replay entry + // for each part. + for (int i = 0; i < parts.Length; i++) + { + this.RenderScene(configuration, target, parts[i]); + } + + return; + } + if (!TryGetCompositeTargetDescriptor(out WebGPUTargetDescriptor pixelDescriptor, out WGPUFeatureName requiredFeature)) { throw new NotSupportedException($"The WebGPU backend does not support pixel format '{typeof(TPixel).Name}'."); @@ -282,6 +418,8 @@ public void RenderScene( resourceArena ??= webGPUScene.RentResourceArena() ?? this.RentResourceArena(); schedulingArena ??= webGPUScene.RentSchedulingArena() ?? this.RentSchedulingArena(); + // The same rule as the ordered path: offscreen pixels must be complete before + // control returns to user code, so only a presentation target defers the check. this.RenderEncodedSceneWithGrowth( configuration, webGPUTarget, @@ -289,7 +427,7 @@ public void RenderScene( targetDescriptor, requiredFeature, webGPUScene, - deferOverflowCheck: true, + webGPUTarget.IsPresentationSurface, ref currentBumpSizes, ref resourceArena, ref schedulingArena); @@ -308,11 +446,14 @@ public void RenderScene( /// /// Renders a plain (non-ordered) encoded scene through the staged pipeline with the dynamic - /// scratch-growth retry loop. All texture targets defer the scratch-overflow readback so the - /// flush never blocks on the GPU: presentation surfaces are corrected by the next frame's - /// redraw, while offscreen targets park a corrective re-render that a later harvest invokes - /// on the rare overflow, so no incomplete content can persist. + /// scratch-growth retry loop. /// + /// + /// An offscreen render reads the scratch status before the copy to the target, so an overflow + /// retries here and never reaches the target. A presentation render defers the status: an + /// overflowed frame keeps the previous surface content, and the next frame redraws with the + /// grown capacities. + /// /// The pixel format of the flush target. /// The active processing configuration. /// The native surface holding the target texture handles. @@ -320,7 +461,7 @@ public void RenderScene( /// The target texture format and alpha representation. /// The device feature required by the target format, or the default value. /// The retained scene being rendered. - /// Whether the overflow readback may be deferred; corrective re-renders pass . + /// Whether the overflow readback is deferred. Only presentation targets pass . /// The scratch capacities carried across attempts. /// The rented scene resource arena. /// The rented scheduling arena. @@ -379,17 +520,12 @@ private void RenderEncodedSceneWithGrowth( { currentBumpSizes = MaxBumpSizes(currentBumpSizes, grownBumpSizes); - // A deferred render parks its readback on the backend, which outlives - // per-frame scenes; a later flush harvests it and applies any observed - // growth. Offscreen targets additionally park a corrective re-render: - // their content is not redrawn every frame, so a rare overflow must be - // repaired rather than waiting for the next redraw. + // A deferred presentation render parks its readback on the backend, which + // outlives per-frame scenes. A later flush harvests it and applies any + // observed growth. if (pendingStatus is not null) { - Action? correctiveRender = webGPUTarget.IsPresentationSurface - ? null - : () => this.RenderCorrective(configuration, webGPUTarget, targetBounds, targetDescriptor, requiredFeature, webGPUScene); - this.EnqueuePendingSchedulingStatus(pendingStatus, webGPUScene, correctiveRender); + this.EnqueuePendingSchedulingStatus(pendingStatus); } if (successfulChunkTileHeight != 0) @@ -406,6 +542,7 @@ private void RenderEncodedSceneWithGrowth( if (requiresGrowth) { + this.CountScratchOverflow(); currentBumpSizes = MaxBumpSizes(currentBumpSizes, grownBumpSizes); continue; } @@ -883,33 +1020,15 @@ public void Dispose() } /// - /// Parks the deferred scheduling-status readback of a just-submitted flush on this backend - /// for a later flush to harvest. Offscreen targets pass a corrective re-render and hold a - /// deferred-render reference on their scene so the correction remains possible until the - /// readback resolves; presentation surfaces are corrected by their next redraw and pass - /// . + /// Parks the deferred scheduling-status readback of a just-submitted presentation flush on + /// this backend for a later flush to harvest. /// /// The pending readback to park. - /// The retained scene the flush rendered. - /// The corrective re-render to invoke on overflow, or for presentation flushes. - private void EnqueuePendingSchedulingStatus( - WebGPUPendingSchedulingStatus pendingStatus, - WebGPUDrawingBackendScene scene, - Action? correctiveRender) + private void EnqueuePendingSchedulingStatus(WebGPUPendingSchedulingStatus pendingStatus) { - if (correctiveRender is not null) - { - scene.AddDeferredRenderReference(); - } - - PendingSchedulingStatusEntry entry = new( - pendingStatus, - correctiveRender is null ? null : scene, - correctiveRender); - lock (this.pendingStatusSync) { - this.pendingSchedulingStatuses.Add(entry); + this.pendingSchedulingStatuses.Add(pendingStatus); } } @@ -917,15 +1036,13 @@ private void EnqueuePendingSchedulingStatus( /// Resolves parked deferred readbacks. Completed maps resolve wait-free; unfinished maps /// stay parked unless the queue exceeds its bound (or is set), /// where the oldest entries are resolved blocking as GPU backpressure. Observed demand is - /// merged into the backend capacities and the scene about to render, and offscreen entries - /// that overflowed are re-rendered with the grown capacities so no incomplete content can - /// persist in a texture the app may cache. + /// merged into the backend capacities and the scene about to render. /// /// The scene about to render, or during teardown or readback drains. /// Whether to resolve every parked entry regardless of readiness. private void HarvestPendingSchedulingStatuses(WebGPUDrawingBackendScene? scene, bool drainAll) { - List? due = null; + List? due = null; lock (this.pendingStatusSync) { if (this.pendingSchedulingStatuses.Count == 0) @@ -935,18 +1052,18 @@ private void HarvestPendingSchedulingStatuses(WebGPUDrawingBackendScene? scene, // Map callbacks only advance while the device is pumped; one non-blocking poll // delivers every callback that has already completed on the GPU timeline. - this.pendingSchedulingStatuses[0].Status.PollDevice(); + this.pendingSchedulingStatuses[0].PollDevice(); int overflow = this.pendingSchedulingStatuses.Count - MaxPendingSchedulingStatuses; for (int i = this.pendingSchedulingStatuses.Count - 1; i >= 0; i--) { - PendingSchedulingStatusEntry entry = this.pendingSchedulingStatuses[i]; + WebGPUPendingSchedulingStatus status = this.pendingSchedulingStatuses[i]; // Entries are ordered oldest-first, so indexes below the overflow count are // exactly the oldest entries beyond the queue bound. - if (drainAll || entry.Status.IsReady || i < overflow) + if (drainAll || status.IsReady || i < overflow) { - (due ??= []).Add(entry); + (due ??= []).Add(status); this.pendingSchedulingStatuses.RemoveAt(i); } } @@ -958,32 +1075,26 @@ private void HarvestPendingSchedulingStatuses(WebGPUDrawingBackendScene? scene, } // Entries were already removed from the queue above, so a failure on one entry must not - // abandon the rest: every remaining status would leak its readback buffer and its scene - // reference, leaving the scene's deferred teardown permanently blocked. The first + // abandon the rest: every remaining status leaks its readback buffer. The first // failure is rethrown after every entry has been retired. Exception? firstFailure = null; - foreach (PendingSchedulingStatusEntry entry in due) + foreach (WebGPUPendingSchedulingStatus status in due) { try { - bool overflowed = WebGPUSceneDispatch.ResolveDeferredSchedulingStatus(entry.Status, out WebGPUSceneBumpSizes resolvedBumpSizes); + bool overflowed = WebGPUSceneDispatch.ResolveDeferredSchedulingStatus(status, out WebGPUSceneBumpSizes resolvedBumpSizes); scene?.UpdateBumpSizes(resolvedBumpSizes); - entry.Scene?.UpdateBumpSizes(resolvedBumpSizes); this.bumpSizes = MaxBumpSizes(this.bumpSizes, resolvedBumpSizes); if (overflowed) { - entry.CorrectiveRender?.Invoke(); + this.CountScratchOverflow(); } } catch (Exception failure) { firstFailure ??= failure; - entry.Status.Dispose(); - } - finally - { - entry.Scene?.ReleaseDeferredRenderReference(); + status.Dispose(); } } @@ -993,107 +1104,10 @@ private void HarvestPendingSchedulingStatuses(WebGPUDrawingBackendScene? scene, } } - /// - /// Re-renders an offscreen flush whose deferred readback reported a scratch overflow. - /// Runs the staged pipeline synchronously with the grown capacities so the corrected - /// content replaces the incomplete render before anything can observe it as final. A - /// target whose owner was disposed in the meantime is skipped: its content is gone too. - /// - /// The pixel format of the flush target. - /// The processing configuration of the original flush. - /// The native surface holding the target texture handles. - /// The target bounds of the original flush. - /// The target texture format and alpha representation. - /// The device feature required by the target format, or the default value. - /// The retained scene to replay, kept alive by a deferred-render reference. - private void RenderCorrective( - Configuration configuration, - WebGPUNativeSurface webGPUTarget, - Rectangle targetBounds, - WebGPUTargetDescriptor targetDescriptor, - WGPUFeatureName requiredFeature, - WebGPUDrawingBackendScene webGPUScene) - where TPixel : unmanaged, IPixel - { - WebGPUSceneBumpSizes currentBumpSizes = webGPUScene.BumpSizes; - WebGPUSceneResourceArena? resourceArena = webGPUScene.RentResourceArena() ?? this.RentResourceArena(); - WebGPUSceneSchedulingArena? schedulingArena = webGPUScene.RentSchedulingArena() ?? this.RentSchedulingArena(); - - try - { - this.RenderEncodedSceneWithGrowth( - configuration, - webGPUTarget, - targetBounds, - targetDescriptor, - requiredFeature, - webGPUScene, - deferOverflowCheck: false, - ref currentBumpSizes, - ref resourceArena, - ref schedulingArena); - - webGPUScene.UpdateBumpSizes(currentBumpSizes); - this.bumpSizes = MaxBumpSizes(this.bumpSizes, currentBumpSizes); - } - catch (ObjectDisposedException) - { - // The target texture handles were released between the flush and the harvest; - // the content the correction would repaint no longer exists. - } - catch (InvalidOperationException) - { - // Handle acquisition or device validation failed for the retired target; skip. - } - finally - { - webGPUScene.ReturnArenas(resourceArena, schedulingArena, this); - } - } - /// /// Throws when this backend is disposed. /// [MethodImpl(MethodImplOptions.AggressiveInlining)] private void ThrowIfDisposed() => ObjectDisposedException.ThrowIf(this.isDisposed, this); - - /// - /// One parked deferred readback and the corrective context for its flush. - /// - private readonly struct PendingSchedulingStatusEntry - { - /// - /// Initializes a new instance of the struct. - /// - /// The deferred scheduling-status readback. - /// The rendered scene, held alive via a deferred-render reference; for presentation flushes. - /// The corrective re-render invoked when the readback reports overflow; for presentation flushes. - public PendingSchedulingStatusEntry( - WebGPUPendingSchedulingStatus status, - WebGPUDrawingBackendScene? scene, - Action? correctiveRender) - { - this.Status = status; - this.Scene = scene; - this.CorrectiveRender = correctiveRender; - } - - /// - /// Gets the deferred scheduling-status readback. - /// - public WebGPUPendingSchedulingStatus Status { get; } - - /// - /// Gets the rendered scene, held alive via a deferred-render reference; - /// for presentation flushes. - /// - public WebGPUDrawingBackendScene? Scene { get; } - - /// - /// Gets the corrective re-render invoked when the readback reports overflow; - /// for presentation flushes. - /// - public Action? CorrectiveRender { get; } - } } diff --git a/src/ImageSharp.Drawing.WebGPU/WebGPUDrawingBackendScene.cs b/src/ImageSharp.Drawing.WebGPU/WebGPUDrawingBackendScene.cs index 7c6fcbdf0..00aff2836 100644 --- a/src/ImageSharp.Drawing.WebGPU/WebGPUDrawingBackendScene.cs +++ b/src/ImageSharp.Drawing.WebGPU/WebGPUDrawingBackendScene.cs @@ -20,13 +20,8 @@ public sealed class WebGPUDrawingBackendScene : DrawingBackendScene private WebGPUSceneResourceArena? resourceArena; private WebGPUSceneSchedulingArena? schedulingArena; - // Deferred-render references keep the encoded GPU payload alive past user disposal while - // the backend may still need it for a corrective re-render (a deferred overflow readback - // is outstanding). Teardown runs when disposal has been requested and the last reference - // releases; the tornDown guard makes the actual teardown single-shot under races. - private int deferredRenderReferences; - private int teardownRequested; - private int tornDown; + // Set by DisposeCore, so an arena return that races disposal hands the arenas to the backend. + private int disposeRequested; // Volatile works on int, so the uint scratch capacities are stored bit-for-bit in // signed fields. The values are compared and restored as uint in the accessors. @@ -59,11 +54,67 @@ internal WebGPUDrawingBackendScene( this.UpdateBumpSizes(bumpSizes); } + /// + /// Initializes a new instance of the class that renders + /// other scenes in order. + /// + /// The scenes to render, in render order. + /// The target bounds of the scenes. + /// The initial scratch capacities for the scene. + /// Resources that must stay alive for the retained scene, including the parts that this scene owns. + internal WebGPUDrawingBackendScene( + DrawingBackendScene[] parts, + Rectangle bounds, + WebGPUSceneBumpSizes bumpSizes, + IReadOnlyList? ownedResources) + : this(WebGPUEncodedScene.Empty, bounds, bumpSizes, ownedResources) + => this.Parts = parts; + /// /// Gets the retained encoded scene. /// internal WebGPUEncodedScene EncodedScene { get; } + /// + /// Gets the scenes that this scene renders in order, or when the scene + /// renders . + /// + /// + /// A batch with retained scenes outside every layer creates these parts: each run of commands + /// and each retained scene renders as its own pass. The scene disposes only the parts in its + /// owned resources. The caller keeps the other parts alive. + /// + internal DrawingBackendScene[]? Parts { get; } + + /// + /// Gets a value indicating whether the scene holds ordered work, such as an apply barrier, that + /// must see every pixel drawn before it. + /// + /// + /// A batch that contains such a scene needs the ordered encoder, and each layer open around the + /// scene renders as a scoped layer (see ). + /// + internal bool HasOrderedWork + { + get + { + if (this.Parts is not DrawingBackendScene[] parts) + { + return this.EncodedScene.HasOperations; + } + + for (int i = 0; i < parts.Length; i++) + { + if (parts[i] is WebGPUDrawingBackendScene { HasOrderedWork: true }) + { + return true; + } + } + + return false; + } + } + /// /// Gets the scratch capacities for the scene. /// @@ -100,27 +151,6 @@ internal void UpdateBumpSizes(WebGPUSceneBumpSizes bumpSizes) UpdateBumpSize(ref this.bumpPtcl, bumpSizes.Ptcl); } - /// - /// Adds a deferred-render reference that keeps this scene's encoded GPU payload alive past - /// user disposal, so a corrective re-render for an outstanding deferred overflow readback - /// can still replay it. - /// - internal void AddDeferredRenderReference() - => Interlocked.Increment(ref this.deferredRenderReferences); - - /// - /// Releases a deferred-render reference. When disposal has already been requested and this - /// was the last reference, the actual teardown runs now. - /// - internal void ReleaseDeferredRenderReference() - { - if (Interlocked.Decrement(ref this.deferredRenderReferences) == 0 - && Volatile.Read(ref this.teardownRequested) == 1) - { - this.TeardownCore(); - } - } - /// /// Rents reusable scene resource buffers for one render, leaving the scene slot empty. /// @@ -176,7 +206,7 @@ internal void ReturnArenas( // A return that races scene disposal would strand the arenas on a dead scene and // starve the backend cache into recreating GPU buffers every flush; pull them back // out and hand them to the backend instead. - if (Volatile.Read(ref this.teardownRequested) == 1) + if (Volatile.Read(ref this.disposeRequested) == 1) { this.ReleaseCachedArenas(); } @@ -213,21 +243,23 @@ private static void UpdateBumpSize(ref int target, uint value) } /// + /// + /// The backend keeps no reference to the scene after a render returns, so no render can read + /// the scene after this call. This method releases the encoded payload, and the base class then + /// releases the owned resources. + /// protected override void DisposeCore() { - Volatile.Write(ref this.teardownRequested, 1); - - // Cached arenas return to the backend immediately even when encoded-payload teardown - // is deferred: corrective re-renders rent fresh arenas, and keeping these parked on a - // disposed scene starves the backend cache into recreating GPU buffers every flush. - this.ReleaseCachedArenas(); + Volatile.Write(ref this.disposeRequested, 1); - // Encoded-payload teardown is deferred while the backend still holds deferred-render - // references for outstanding overflow readbacks; the last release runs it instead. - if (Volatile.Read(ref this.deferredRenderReferences) == 0) + if (!ReferenceEquals(this.EncodedScene, WebGPUEncodedScene.Empty)) { - this.TeardownCore(); + this.EncodedScene.Dispose(); } + + // Cached arenas go back to the backend, so the next scene does not create GPU buffers again. + this.ReleaseCachedArenas(); + this.ArenaOwner = null; } /// @@ -256,26 +288,4 @@ private void ReleaseCachedArenas() WebGPUSceneResourceArena.Dispose(resourceArena); } } - - /// - /// Releases the encoded GPU payload exactly once, after both disposal has been requested - /// and no deferred-render references remain. Also sweeps arenas a corrective re-render - /// may have parked after disposal returned the original set. - /// - private void TeardownCore() - { - // Disposal request and reference release can race to this point; only one wins. - if (Interlocked.Exchange(ref this.tornDown, 1) == 1) - { - return; - } - - if (!ReferenceEquals(this.EncodedScene, WebGPUEncodedScene.Empty)) - { - this.EncodedScene.Dispose(); - } - - this.ReleaseCachedArenas(); - this.ArenaOwner = null; - } } diff --git a/src/ImageSharp.Drawing.WebGPU/WebGPUFlushContext.cs b/src/ImageSharp.Drawing.WebGPU/WebGPUFlushContext.cs index 8989249d7..9e8cdfe2a 100644 --- a/src/ImageSharp.Drawing.WebGPU/WebGPUFlushContext.cs +++ b/src/ImageSharp.Drawing.WebGPU/WebGPUFlushContext.cs @@ -248,9 +248,8 @@ public static WebGPUFlushContext Create( } /// - /// Creates a flush context directly over a native WebGPU surface and target bounds. Used by - /// corrective re-renders, which outlive the per-flush canvas frame and therefore cannot go - /// through the frame overload. + /// Creates a flush context directly over a native WebGPU surface and target bounds. The staged + /// scene path uses it, because that path has the native surface and not a canvas frame. /// /// The native surface holding the target texture handles. /// The target bounds for the flush. diff --git a/src/ImageSharp.Drawing.WebGPU/WebGPUSceneConfig.cs b/src/ImageSharp.Drawing.WebGPU/WebGPUSceneConfig.cs index ec86e6698..104990d7e 100644 --- a/src/ImageSharp.Drawing.WebGPU/WebGPUSceneConfig.cs +++ b/src/ImageSharp.Drawing.WebGPU/WebGPUSceneConfig.cs @@ -945,7 +945,7 @@ public static WebGPUSceneBufferSizes Create( WebGPUSceneBufferSize.Create(checked((uint)range.PathCount)), WebGPUSceneBufferSize.Create(workgroupCounts.DrawReduceX), WebGPUSceneBufferSize.Create(drawObjectCount), - WebGPUSceneBufferSize.Create(checked((uint)(range.InfoWordCount + scene.PathGradientDataWordCount))), + WebGPUSceneBufferSize.Create(checked((uint)(range.InfoWordCount + scene.PathGradientDataWordCount + scene.BlockBrushDataWordCount))), WebGPUSceneBufferSize.Create(checked((uint)range.ClipCount)), WebGPUSceneBufferSize.Create(checked((uint)range.ClipCount)), WebGPUSceneBufferSize.Create(checked((uint)range.ClipCount) / 256U), diff --git a/src/ImageSharp.Drawing.WebGPU/WebGPUSceneDispatch.cs b/src/ImageSharp.Drawing.WebGPU/WebGPUSceneDispatch.cs index a46b3d784..5ee08f73f 100644 --- a/src/ImageSharp.Drawing.WebGPU/WebGPUSceneDispatch.cs +++ b/src/ImageSharp.Drawing.WebGPU/WebGPUSceneDispatch.cs @@ -357,16 +357,144 @@ public static unsafe WebGPUStagedScene CreateStagedScene( /// Receives the validation failure reason. /// when the clip stream is safe for GPU scheduling. private static bool TryValidateEncodedClipStream(WebGPUEncodedScene encodedScene, out string? error) - => TryValidateEncodedClipStream( + { + if (encodedScene.Insertions.IsEmpty) + { + return TryValidateEncodedClipStream( + encodedScene, + 0, + encodedScene.DrawTagCount, + encodedScene.DrawDataWordCount, + encodedScene.InfoWordCount, + encodedScene.PathCount, + encodedScene.ClipCount, + "full scene", + out error); + } + + // The shaders read the joined virtual draw-tag stream, so validate that stream: the clip + // scopes of a layer around a retained scene open before it and close after it. The counts + // of the scene are already joined totals. + return TryValidateJoinedClipStream( encodedScene, + WebGPUSceneRange.CreateWholeScene(encodedScene, default, default), 0, - encodedScene.DrawTagCount, - encodedScene.DrawDataWordCount, - encodedScene.InfoWordCount, - encodedScene.PathCount, - encodedScene.ClipCount, + GetOwnDrawTagCount(encodedScene), + encodedScene.Insertions, "full scene", out error); + } + + /// + /// Validates the joined virtual draw-tag stream of a scene or range that reads retained scenes in place. + /// + /// The scene that owns the packed draw-tag stream. + /// The scene or range whose joined counts the stream must match. + /// The first own draw tag of the scene or range. + /// The end of the own draw tags of the scene or range. + /// The retained scenes of the scene or range, in stream order. + /// A short label used in the validation message. + /// Receives the validation failure reason. + /// when the joined stream satisfies the shader clip-stack contract. + private static bool TryValidateJoinedClipStream( + WebGPUEncodedScene encodedScene, + in WebGPUSceneRange totals, + int ownStart, + int ownEnd, + ReadOnlySpan insertions, + string label, + out string? error) + { + Span clipPathStack = stackalloc long[MaxClipStackDepth]; + Span clipDrawStack = stackalloc long[MaxClipStackDepth]; + ClipStreamValidation validation = new(clipPathStack, clipDrawStack, ownStart); + if (!TryAddJoinedDrawTags(ref validation, totals, encodedScene, ownStart, ownEnd, insertions, label, out error)) + { + return false; + } + + return validation.TryFinish( + totals.DrawDataWordCount, + totals.InfoWordCount, + totals.PathCount, + totals.ClipCount, + label, + out error); + } + + /// + /// Gets the own draw-tag count of a scene. The draw-tag count of a scene with retained scenes is + /// a joined total, so the own extent comes from the layout. + /// + /// The encoded scene. + /// The own draw-tag count. + private static int GetOwnDrawTagCount(WebGPUEncodedScene scene) + => (int)(scene.Layout.DrawDataBase - scene.Layout.DrawTagBase); + + /// + /// Adds the draw tags of one scene or range to a clip-stream validation in virtual stream + /// order: each piece of its own draw tags, and the draw tags of each retained scene between them. + /// + /// The running validation. + /// The scene or range that supplies the joined totals. + /// The scene whose draw tags are added. + /// The first own draw tag to add. + /// The end of the own draw tags to add. + /// The retained scenes between the own draw tags, in stream order. + /// A short label used in the validation message. + /// Receives the validation failure reason. + /// when the draw tags satisfy the shader clip-stack contract so far. + private static bool TryAddJoinedDrawTags( + ref ClipStreamValidation validation, + in WebGPUSceneRange totals, + WebGPUEncodedScene scene, + int ownStart, + int ownEnd, + ReadOnlySpan insertions, + string label, + out string? error) + { + ReadOnlySpan sceneWords = scene.SceneData.Span; + int drawTagBase = (int)scene.Layout.DrawTagBase; + int start = ownStart; + for (int i = 0; i <= insertions.Length; i++) + { + int end = i == insertions.Length ? ownEnd : insertions[i].DrawTag; + if (!validation.TryAdd( + sceneWords.Slice(drawTagBase + start, end - start), + totals.DrawTagCount, + totals.DrawDataWordCount, + totals.InfoWordCount, + totals.PathCount, + label, + out error)) + { + return false; + } + + if (i < insertions.Length) + { + WebGPUEncodedScene retained = insertions[i].Scene.EncodedScene; + if (!TryAddJoinedDrawTags( + ref validation, + totals, + retained, + 0, + GetOwnDrawTagCount(retained), + retained.Insertions, + label, + out error)) + { + return false; + } + } + + start = end; + } + + error = null; + return true; + } /// /// Validates one encoded range clip-control stream before GPU staging. @@ -376,7 +504,27 @@ private static bool TryValidateEncodedClipStream(WebGPUEncodedScene encodedScene /// Receives the validation failure reason. /// when the clip stream is safe for GPU scheduling. private static bool TryValidateEncodedClipStream(WebGPUEncodedScene encodedScene, WebGPUSceneRange range, out string? error) - => TryValidateEncodedClipStream( + { + // A whole-scene range reads the same virtual stream as a render of the full scene. + if (range.IsWholeScene) + { + return TryValidateEncodedClipStream(encodedScene, out error); + } + + // A range of an ordered scene that reads retained scenes reads its joined virtual stream. + if (range.Segments is WebGPUSceneRangeSegments segments) + { + return TryValidateJoinedClipStream( + encodedScene, + range, + range.DrawTagStart, + range.DrawTagStart + segments.DrawTagCount, + encodedScene.Insertions.Slice(segments.InsertionStart, segments.InsertionCount), + "scene range", + out error); + } + + return TryValidateEncodedClipStream( encodedScene, range.DrawTagStart, range.DrawTagCount, @@ -386,6 +534,7 @@ private static bool TryValidateEncodedClipStream(WebGPUEncodedScene encodedScene range.ClipCount, "scene range", out error); + } /// /// Validates the BeginClip/EndClip sequence consumed by clip_leaf.wgsl. @@ -428,127 +577,244 @@ private static bool TryValidateEncodedClipStream( } ReadOnlySpan drawTags = sceneWords.Slice((int)drawTagWordStart, drawTagCount); - int depth = 0; - int clipCount = 0; - long pathIndex = 0; - long clipIndex = 0; - long sceneOffset = 0; - long infoOffset = 0; Span clipPathStack = stackalloc long[MaxClipStackDepth]; Span clipDrawStack = stackalloc long[MaxClipStackDepth]; - for (int i = 0; i < drawTags.Length; i++) + ClipStreamValidation validation = new(clipPathStack, clipDrawStack, drawTagStart); + if (!validation.TryAdd( + drawTags, + drawTagCount, + expectedDrawDataWordCount, + expectedInfoWordCount, + expectedPathCount, + label, + out error)) { - uint drawTag = drawTags[i]; - GpuSceneDrawMonoid drawMonoid = GpuSceneDrawTag.Map(drawTag); - if (drawTag != GpuSceneDrawTag.Nop && pathIndex >= expectedPathCount) - { - error = $"The WebGPU {label} draw tag {drawTagStart + i} references path {pathIndex}, but the stream declares {expectedPathCount} paths."; - return false; - } + return false; + } - if (sceneOffset + drawMonoid.SceneOffset > expectedDrawDataWordCount) - { - error = $"The WebGPU {label} draw tag {drawTagStart + i} reads draw-data words [{sceneOffset}, {sceneOffset + drawMonoid.SceneOffset}), " + - $"but the stream declares {expectedDrawDataWordCount} words."; - return false; - } + return validation.TryFinish( + expectedDrawDataWordCount, + expectedInfoWordCount, + expectedPathCount, + expectedClipCount, + label, + out error); + } - if (infoOffset + drawMonoid.InfoOffset > expectedInfoWordCount) - { - error = $"The WebGPU {label} draw tag {drawTagStart + i} writes info words [{infoOffset}, {infoOffset + drawMonoid.InfoOffset}), " + - $"but the stream declares {expectedInfoWordCount} words."; - return false; - } + /// + /// Holds the running state of one BeginClip/EndClip validation, so one stream can be + /// validated in pieces: one range, or the pieces of a scene that reads retained scenes in place. + /// + private ref struct ClipStreamValidation + { + private readonly Span clipPathStack; + private readonly Span clipDrawStack; + private readonly int drawTagStart; + private int depth; + private int clipCount; + private long drawIndex; + private long pathIndex; + private long clipIndex; + private long sceneOffset; + private long infoOffset; - if (drawTag == GpuSceneDrawTag.BeginClip) + /// + /// Initializes a new instance of the struct. + /// + /// The path-index stack, entries. + /// The draw-index stack, entries. + /// The draw-tag index of the first validated tag, used in messages. + public ClipStreamValidation(Span clipPathStack, Span clipDrawStack, int drawTagStart) + { + this.clipPathStack = clipPathStack; + this.clipDrawStack = clipDrawStack; + this.drawTagStart = drawTagStart; + this.depth = 0; + this.clipCount = 0; + this.drawIndex = 0; + this.pathIndex = 0; + this.clipIndex = 0; + this.sceneOffset = 0; + this.infoOffset = 0; + } + + /// + /// Validates the next draw tags of the stream. + /// + /// The next draw tags. + /// The draw-tag count of the whole stream. + /// The draw-data word count declared for the whole stream. + /// The info word count declared for the whole stream. + /// The path count declared for the whole stream. + /// A short label used in the validation message. + /// Receives the validation failure reason. + /// when the draw tags satisfy the shader clip-stack contract so far. + public bool TryAdd( + ReadOnlySpan drawTags, + int drawTagCount, + int expectedDrawDataWordCount, + int expectedInfoWordCount, + int expectedPathCount, + string label, + out string? error) + { + // The loop runs once for each draw tag of every staged stream, so it works on locals + // and stores the running state once at the end. A failed validation stops the stream, + // so a return with an error does not store it. + Span clipPathStack = this.clipPathStack; + Span clipDrawStack = this.clipDrawStack; + int depth = this.depth; + int clipCount = this.clipCount; + long drawIndex = this.drawIndex; + long pathIndex = this.pathIndex; + long clipIndex = this.clipIndex; + long sceneOffset = this.sceneOffset; + long infoOffset = this.infoOffset; + for (int i = 0; i < drawTags.Length; i++, drawIndex++) { - // clip_leaf.wgsl reconstructs clip parents with one 256-entry workgroup stack. - if (depth == MaxClipStackDepth) + long tagIndex = this.drawTagStart + drawIndex; + uint drawTag = drawTags[i]; + GpuSceneDrawMonoid drawMonoid = GpuSceneDrawTag.Map(drawTag); + if (drawTag != GpuSceneDrawTag.Nop && pathIndex >= expectedPathCount) { - error = $"The WebGPU {label} clip stack exceeds {MaxClipStackDepth} entries at draw tag {drawTagStart + i}."; + error = $"The WebGPU {label} draw tag {tagIndex} references path {pathIndex}, but the stream declares {expectedPathCount} paths."; return false; } - clipPathStack[depth] = pathIndex; - clipDrawStack[depth] = i; - depth++; - clipCount++; - } - else if (drawTag == GpuSceneDrawTag.EndClip) - { - if (depth == 0) + if (sceneOffset + drawMonoid.SceneOffset > expectedDrawDataWordCount) { - error = $"The WebGPU {label} has an EndClip without a matching BeginClip at draw tag {drawTagStart + i}."; + error = $"The WebGPU {label} draw tag {tagIndex} reads draw-data words " + + $"[{sceneOffset}, {sceneOffset + drawMonoid.SceneOffset}), " + + $"but the stream declares {expectedDrawDataWordCount} words."; return false; } - depth--; - - // This mirrors Vello's CPU clip_leaf stack: EndClip must pop a BeginClip whose - // path and draw indices remain valid inside the range-local GPU buffers. - long parentPathIndex = clipPathStack[depth]; - long parentDrawIndex = clipDrawStack[depth]; - if (parentPathIndex < 0 || parentPathIndex >= expectedPathCount) + if (infoOffset + drawMonoid.InfoOffset > expectedInfoWordCount) { - error = $"The WebGPU {label} EndClip at draw tag {drawTagStart + i} resolves to path {parentPathIndex}, " + - $"but the stream declares {expectedPathCount} paths."; + error = $"The WebGPU {label} draw tag {tagIndex} writes info words [{infoOffset}, {infoOffset + drawMonoid.InfoOffset}), " + + $"but the stream declares {expectedInfoWordCount} words."; return false; } - if (parentDrawIndex < 0 || parentDrawIndex >= drawTagCount) + if (drawTag == GpuSceneDrawTag.BeginClip) { - error = $"The WebGPU {label} EndClip at draw tag {drawTagStart + i} resolves to draw tag {parentDrawIndex}, " + - $"but the stream contains {drawTagCount} draw tags."; - return false; + // clip_leaf.wgsl reconstructs clip parents with one 256-entry workgroup stack. + if (depth == MaxClipStackDepth) + { + error = $"The WebGPU {label} clip stack exceeds {MaxClipStackDepth} entries at draw tag {tagIndex}."; + return false; + } + + clipPathStack[depth] = pathIndex; + clipDrawStack[depth] = drawIndex; + depth++; + clipCount++; } + else if (drawTag == GpuSceneDrawTag.EndClip) + { + if (depth == 0) + { + error = $"The WebGPU {label} has an EndClip without a matching BeginClip at draw tag {tagIndex}."; + return false; + } - clipCount++; + depth--; + + // This mirrors Vello's CPU clip_leaf stack: EndClip must pop a BeginClip whose + // path and draw indices remain valid inside the range-local GPU buffers. + long parentPathIndex = clipPathStack[depth]; + long parentDrawIndex = clipDrawStack[depth]; + if (parentPathIndex < 0 || parentPathIndex >= expectedPathCount) + { + error = $"The WebGPU {label} EndClip at draw tag {tagIndex} resolves to path {parentPathIndex}, " + + $"but the stream declares {expectedPathCount} paths."; + return false; + } + + if (parentDrawIndex < 0 || parentDrawIndex >= drawTagCount) + { + error = $"The WebGPU {label} EndClip at draw tag {tagIndex} resolves to draw tag {parentDrawIndex}, " + + $"but the stream contains {drawTagCount} draw tags."; + return false; + } + + clipCount++; + } + + pathIndex += drawMonoid.PathIndex; + clipIndex += drawMonoid.ClipIndex; + sceneOffset += drawMonoid.SceneOffset; + infoOffset += drawMonoid.InfoOffset; } - pathIndex += drawMonoid.PathIndex; - clipIndex += drawMonoid.ClipIndex; - sceneOffset += drawMonoid.SceneOffset; - infoOffset += drawMonoid.InfoOffset; + this.depth = depth; + this.clipCount = clipCount; + this.drawIndex = drawIndex; + this.pathIndex = pathIndex; + this.clipIndex = clipIndex; + this.sceneOffset = sceneOffset; + this.infoOffset = infoOffset; + error = null; + return true; } - if (depth != 0) - { - error = $"The WebGPU {label} closes with {depth} unmatched BeginClip records."; - return false; - } + /// + /// Checks the totals after the last draw tag of the stream. + /// + /// The draw-data word count declared for the stream. + /// The info word count declared for the stream. + /// The path count declared for the stream. + /// The clip record count declared for the stream. + /// A short label used in the validation message. + /// Receives the validation failure reason. + /// when the stream satisfies the shader clip-stack contract. + public readonly bool TryFinish( + int expectedDrawDataWordCount, + int expectedInfoWordCount, + int expectedPathCount, + int expectedClipCount, + string label, + out string? error) + { + if (this.depth != 0) + { + error = $"The WebGPU {label} closes with {this.depth} unmatched BeginClip records."; + return false; + } - if (pathIndex != expectedPathCount) - { - error = $"The WebGPU {label} declares {expectedPathCount} paths but the draw-tag stream encodes {pathIndex}."; - return false; - } + if (this.pathIndex != expectedPathCount) + { + error = $"The WebGPU {label} declares {expectedPathCount} paths but the draw-tag stream encodes {this.pathIndex}."; + return false; + } - if (clipIndex != expectedClipCount) - { - error = $"The WebGPU {label} declares {expectedClipCount} clip records but the draw-tag monoid encodes {clipIndex}."; - return false; - } + if (this.clipIndex != expectedClipCount) + { + error = $"The WebGPU {label} declares {expectedClipCount} clip records but the draw-tag monoid encodes {this.clipIndex}."; + return false; + } - if (sceneOffset != expectedDrawDataWordCount) - { - error = $"The WebGPU {label} declares {expectedDrawDataWordCount} draw-data words but the draw-tag stream encodes {sceneOffset}."; - return false; - } + if (this.sceneOffset != expectedDrawDataWordCount) + { + error = $"The WebGPU {label} declares {expectedDrawDataWordCount} draw-data words but the draw-tag stream encodes {this.sceneOffset}."; + return false; + } - if (infoOffset != expectedInfoWordCount) - { - error = $"The WebGPU {label} declares {expectedInfoWordCount} info words but the draw-tag stream encodes {infoOffset}."; - return false; - } + if (this.infoOffset != expectedInfoWordCount) + { + error = $"The WebGPU {label} declares {expectedInfoWordCount} info words but the draw-tag stream encodes {this.infoOffset}."; + return false; + } - if (clipCount != expectedClipCount) - { - error = $"The WebGPU {label} declares {expectedClipCount} clip records but contains {clipCount} BeginClip/EndClip draw tags."; - return false; - } + if (this.clipCount != expectedClipCount) + { + error = $"The WebGPU {label} declares {expectedClipCount} clip records but contains {this.clipCount} BeginClip/EndClip draw tags."; + return false; + } - error = null; - return true; + error = null; + return true; + } } /// @@ -587,7 +853,7 @@ public static bool TryValidateBindingSizes( out bindingLimitFailure, out error) || !TryValidateBufferSize( - GetBindingByteLength(encodedScene.SceneWordCount), + GetBindingByteLength(encodedScene.SceneBufferWordCount), "scene data", BindingLimitBuffer.None, maxStorageBufferBindingSize, @@ -803,7 +1069,7 @@ private static unsafe bool TryDispatchSharedSchedulingStages( WebGPUSceneConfig config = stagedScene.Config; WebGPUSceneBufferSizes bufferSizes = config.BufferSizes; WebGPUSceneWorkgroupCounts workgroupCounts = config.WorkgroupCounts; - nuint sceneBufferSize = GetBindingByteLength(encodedScene.SceneWordCount); + nuint sceneBufferSize = GetBindingByteLength(encodedScene.SceneBufferWordCount); nuint pathReducedBufferSize = bufferSizes.PathReduced.ByteLength; nuint pathReduced2BufferSize = bufferSizes.PathReduced2.ByteLength; nuint pathReducedScanBufferSize = bufferSizes.PathReducedScan.ByteLength; @@ -1064,7 +1330,7 @@ private static unsafe bool TryDispatchChunkLocalSchedulingStages( WebGPUSceneConfig config = stagedScene.Config; WebGPUSceneBufferSizes bufferSizes = config.BufferSizes; WebGPUSceneWorkgroupCounts workgroupCounts = config.WorkgroupCounts; - nuint sceneBufferSize = GetBindingByteLength(encodedScene.SceneWordCount); + nuint sceneBufferSize = GetBindingByteLength(encodedScene.SceneBufferWordCount); nuint drawMonoidBufferSize = bufferSizes.DrawMonoids.ByteLength; nuint infoBinDataBufferSize = checked(bufferSizes.Info.ByteLength + bufferSizes.BinData.ByteLength + bufferSizes.BinHeaders.ByteLength); nuint pathBboxBufferSize = bufferSizes.PathBboxes.ByteLength; @@ -1405,7 +1671,7 @@ public static unsafe bool TrySubmitTransactionalStagedScene( flushContext, stagedScene.Resources, stagedScene.Config.BufferSizes, - GetBindingByteLength(stagedScene.EncodedScene.SceneWordCount), + GetBindingByteLength(stagedScene.EncodedScene.SceneBufferWordCount), scheduling, outputTextureView, backdropTarget.TextureView, @@ -1497,8 +1763,8 @@ private static unsafe bool TrySubmitTransactionalChunkedStagedScene( { WebGPUSceneChunkWindow chunkWindow = CreateChunkWindow(tileYStart, requestedTileHeight, remainingTileHeight); WebGPUSceneBumpSizes chunkBumpSize = stagedScene.Range.HasValue - ? ScaleChunkBumpSizes(stagedScene.Config.BumpSizes, stagedScene.Range.Value, chunkWindow.TileHeight) - : ScaleChunkBumpSizes(stagedScene.Config.BumpSizes, encodedScene, chunkWindow.TileHeight); + ? GetChunkBumpSizes(stagedScene.Config.BumpSizes, stagedScene.Range.Value, chunkWindow) + : GetChunkBumpSizes(stagedScene.Config.BumpSizes, encodedScene, chunkWindow); WebGPUSceneConfig chunkConfig = stagedScene.Range.HasValue ? WebGPUSceneConfig.Create(encodedScene, stagedScene.Range.Value, chunkBumpSize, chunkWindow) @@ -1831,7 +2097,7 @@ public static unsafe bool TryRenderStagedScene( flushContext, stagedScene.Resources, stagedScene.Config.BufferSizes, - GetBindingByteLength(stagedScene.EncodedScene.SceneWordCount), + GetBindingByteLength(stagedScene.EncodedScene.SceneBufferWordCount), scheduling, outputTextureView, backdropTarget.TextureView, @@ -2093,8 +2359,8 @@ private static unsafe bool TryRenderSegmentChunkedStagedScene( { WebGPUSceneChunkWindow chunkWindow = CreateChunkWindow(tileYStart, requestedTileHeight, remainingTileHeight); WebGPUSceneBumpSizes chunkBumpSize = stagedScene.Range.HasValue - ? ScaleChunkBumpSizes(stagedScene.Config.BumpSizes, stagedScene.Range.Value, chunkWindow.TileHeight) - : ScaleChunkBumpSizes(stagedScene.Config.BumpSizes, encodedScene, chunkWindow.TileHeight); + ? GetChunkBumpSizes(stagedScene.Config.BumpSizes, stagedScene.Range.Value, chunkWindow) + : GetChunkBumpSizes(stagedScene.Config.BumpSizes, encodedScene, chunkWindow); WebGPUSceneConfig chunkConfig = stagedScene.Range.HasValue ? WebGPUSceneConfig.Create(encodedScene, stagedScene.Range.Value, chunkBumpSize, chunkWindow) @@ -2335,7 +2601,7 @@ private static unsafe bool TryRenderChunkAttempt( stagedScene.FlushContext, stagedScene.Resources, stagedScene.Config.BufferSizes, - GetBindingByteLength(stagedScene.EncodedScene.SceneWordCount), + GetBindingByteLength(stagedScene.EncodedScene.SceneBufferWordCount), scheduling, outputTextureView, backdropTarget.TextureView, @@ -2403,7 +2669,7 @@ private static unsafe bool TryRecordChunkAttempt( stagedScene.FlushContext, stagedScene.Resources, stagedScene.Config.BufferSizes, - GetBindingByteLength(stagedScene.EncodedScene.SceneWordCount), + GetBindingByteLength(stagedScene.EncodedScene.SceneBufferWordCount), scheduling, outputTextureView, backdropTarget.TextureView, @@ -2648,12 +2914,199 @@ private static WebGPUSceneBumpSizes ScaleChunkBumpSizes(WebGPUSceneBumpSizes sou } /// - /// Raises the caller-provided scratch capacities to the scene-specific lower bounds already known on the CPU so the first GPU attempt does not waste a full-scene pass on allocations that cannot possibly fit. + /// Gets the scratch capacities of the tile-row chunks of a scene that have one height: the + /// scaled scene budget, raised to the largest bounds over every window of that height. + /// + /// + /// The chunk loop runs the shared stages once into one scheduling arena, and a later chunk + /// must not need a larger arena. Chunk heights never grow during a render, and every window + /// of a smaller height is inside a window of a larger height, so the sizes of the first chunk + /// hold every later chunk. + /// + /// The full-scene scratch budget. + /// The encoded scene. + /// The chunk window. + /// The chunk-local scratch capacities. + private static WebGPUSceneBumpSizes GetChunkBumpSizes(WebGPUSceneBumpSizes sourceBumpSizes, WebGPUEncodedScene scene, WebGPUSceneChunkWindow chunkWindow) + { + WebGPUSceneBumpSizes scaled = ScaleChunkBumpSizes(sourceBumpSizes, scene, chunkWindow.TileHeight); + if (scene.RowBounds is not { } rowBounds) + { + return scaled; + } + + // A whole-scene dispatch uses the root target grid, so its rows start at data row 0. + WebGPUSceneWindowBounds window = GetLargestWindowBounds( + rowBounds, + 0, + checked((uint)scene.TileCountY), + chunkWindow.TileHeight, + scene, + out long blendSpillSlots); + + return RaiseToWindowBounds( + scaled, + window, + scene.TileCountX, + chunkWindow.TileHeight, + scene.SegmentCount != 0, + blendSpillSlots); + } + + /// + /// Gets the scratch capacities of the tile-row chunks of a range that have one height: the + /// scaled range budget, raised to the largest bounds over every window of that height. + /// + /// The range scratch budget. + /// The encoded range. + /// The chunk window. + /// The chunk-local scratch capacities. + private static WebGPUSceneBumpSizes GetChunkBumpSizes(WebGPUSceneBumpSizes sourceBumpSizes, WebGPUSceneRange range, WebGPUSceneChunkWindow chunkWindow) + { + WebGPUSceneBumpSizes scaled = ScaleChunkBumpSizes(sourceBumpSizes, range, chunkWindow.TileHeight); + if (range.RowBounds is not { } rowBounds) + { + return scaled; + } + + // The chunk rows are rows of the range target. Origin is the position of that target in + // the data coordinates of the row bounds. The range keeps no blend regions, so its whole + // blend-spill bound applies to every chunk. + WebGPUSceneWindowBounds window = GetLargestWindowBounds( + rowBounds, + range.Origin.Y, + checked((uint)WebGPUSceneEncoder.DivideRoundUp(range.TargetBounds.Height, WebGPUSceneEncoder.TileHeight)), + chunkWindow.TileHeight, + null, + out _); + + return RaiseToWindowBounds( + scaled, + window, + WebGPUSceneEncoder.DivideRoundUp(range.TargetBounds.Width, WebGPUSceneEncoder.TileWidth), + chunkWindow.TileHeight, + range.Segments is not null || range.IsWholeScene, + range.EstimatedBlendSpillSlots); + } + + /// + /// Gets the largest bounds, for each scratch buffer, over every window of one chunk height. + /// + /// The sealed row bounds. + /// The data-space Y position of target tile row 0. + /// The target height in tile rows. + /// The chunk height in tile rows. + /// The scene whose blend regions bound the blend spill, or . + /// Receives the largest blend-spill slot bound of a window, or zero without a scene. + /// The largest bounds. + private static WebGPUSceneWindowBounds GetLargestWindowBounds( + WebGPUSceneRowBounds rowBounds, + int originY, + uint targetTileHeight, + uint chunkTileHeight, + WebGPUEncodedScene? scene, + out long blendSpillSlots) + { + long pathRows = 0; + long tiles = 0; + long crossings = 0; + long ptclWords = 0; + blendSpillSlots = 0; + + // A chunk of one bin or more starts on a bin and holds whole bins, except the last chunk, + // which ends at the target. A chunk smaller than one bin happens only when one bin alone + // does not fit the binding limit, and it stays inside its bin. The row bounds keep bins, + // so such a chunk gets the bin bound shared by its rows, the same sizing as + // ScaleChunkBumpSizes. That share is not a bound, so the overflow protocol stays the safety + // net for that case only. + uint binRows = WebGPUSceneRowBounds.BinRows; + uint windowRows = Math.Max(chunkTileHeight, binRows); + int windowPixels = checked((int)windowRows * WebGPUSceneEncoder.TileHeight); + for (uint start = 0; start < Math.Max(targetTileHeight, 1U); start += binRows) + { + int y0 = checked(originY + ((int)start * WebGPUSceneEncoder.TileHeight)); + int y1 = checked(y0 + windowPixels); + WebGPUSceneWindowBounds window = rowBounds.GetWindow(y0, y1); + pathRows = Math.Max(pathRows, window.PathRows); + tiles = Math.Max(tiles, window.Tiles); + crossings = Math.Max(crossings, window.Crossings); + ptclWords = Math.Max(ptclWords, window.PtclWords); + if (scene is not null) + { + blendSpillSlots = Math.Max(blendSpillSlots, scene.GetBlendSpillSlots(y0, y1)); + } + + if (start + windowRows >= targetTileHeight) + { + break; + } + } + + if (chunkTileHeight < binRows) + { + pathRows = ShareBinBound(pathRows, chunkTileHeight, binRows); + tiles = ShareBinBound(tiles, chunkTileHeight, binRows); + crossings = ShareBinBound(crossings, chunkTileHeight, binRows); + ptclWords = ShareBinBound(ptclWords, chunkTileHeight, binRows); + blendSpillSlots = ShareBinBound(blendSpillSlots, chunkTileHeight, binRows); + } + + return new WebGPUSceneWindowBounds(pathRows, tiles, crossings, ptclWords); + } + + /// + /// Shares a bin bound by the rows of a chunk that is smaller than one bin. + /// + /// The bound of one bin. + /// The chunk height in tile rows. + /// The tile rows of one bin. + /// The share, rounded up. + private static long ShareBinBound(long binBound, uint chunkTileHeight, uint binRows) + => ((binBound * chunkTileHeight) + binRows - 1) / binRows; + + /// + /// Raises chunk-local scratch capacities to the bounds of one chunk window. + /// + /// The scaled budget of the chunk. + /// The bounds of the draws that reach the chunk rows. + /// The target width in tiles. + /// The real tile rows of the chunk, the rows that path_row_alloc keeps. + /// Whether coarse can write a CMD_PROFILE_TABLE before an aliased fill. + /// The blend-spill slot bound of the chunk. + /// The raised capacities. + private static WebGPUSceneBumpSizes RaiseToWindowBounds( + WebGPUSceneBumpSizes scaled, + WebGPUSceneWindowBounds window, + int widthInTiles, + uint chunkTileHeight, + bool hasSegmentTable, + long blendSpillSlots) + { + // The PTCL bound matches SeedSceneBumpSizes: the draw words with the same 5% margin, two + // words for each crossing when a CMD_PROFILE_TABLE can precede a fill, and one partial + // chunk for each tile of the window. + long ptclWords = ((window.PtclWords * 105L) / 100L) + + (hasSegmentTable ? 2L * window.Crossings : 0L) + + ((long)widthInTiles * chunkTileHeight * PtclChunkWords); + + return new WebGPUSceneBumpSizes( + scaled.Lines, + scaled.Binning, + Math.Max(scaled.PathRows, ToBoundCount(window.PathRows)), + Math.Max(scaled.PathTiles, ToBoundCount(Math.Max(window.Tiles, window.PathRows))), + Math.Max(scaled.SegCounts, ToBoundCount(window.Crossings)), + Math.Max(scaled.Segments, ToBoundCount(window.Crossings)), + Math.Max(scaled.BlendSpill, ToBoundCount(blendSpillSlots)), + Math.Max(scaled.Ptcl, ToBoundCount(ptclWords))); + } + + /// + /// Raises the caller-provided scratch capacities to the upper bounds that the encoder computed for the scene. /// /// The persisted scratch capacities carried into this flush. /// The encoded scene whose CPU-side demand estimates provide minimum capacities. /// The device-reported maximum size of one storage-buffer binding, used to cap conservative estimates. - /// The retained capacities raised to the scene's known CPU-side lower bounds. + /// The retained capacities raised to the CPU-side upper bounds of the scene. private static WebGPUSceneBumpSizes SeedSceneBumpSizes(WebGPUSceneBumpSizes currentSizes, WebGPUEncodedScene scene, ulong maxStorageBufferBindingSize) => SeedSceneBumpSizes( currentSizes, @@ -2663,16 +3116,18 @@ private static WebGPUSceneBumpSizes SeedSceneBumpSizes(WebGPUSceneBumpSizes curr scene.EstimatedTileCrossings, scene.EstimatedPtclWords, scene.EstimatedBinFootprint, + scene.EstimatedPathTiles, + scene.EstimatedBlendSpillSlots, (long)scene.TileCountX * scene.TileCountY, maxStorageBufferBindingSize); /// - /// Raises the caller-provided scratch capacities to the range-specific lower bounds already known on the CPU. + /// Raises the caller-provided scratch capacities to the upper bounds that the encoder computed for the range. /// /// The persisted scratch capacities carried into this flush. /// The encoded range whose CPU-side demand estimates provide minimum capacities. /// The device-reported maximum size of one storage-buffer binding, used to cap conservative estimates. - /// The retained capacities raised to the range's known CPU-side lower bounds. + /// The retained capacities raised to the CPU-side upper bounds of the range. private static WebGPUSceneBumpSizes SeedSceneBumpSizes(WebGPUSceneBumpSizes currentSizes, WebGPUSceneRange range, ulong maxStorageBufferBindingSize) => SeedSceneBumpSizes( currentSizes, @@ -2682,16 +3137,26 @@ private static WebGPUSceneBumpSizes SeedSceneBumpSizes(WebGPUSceneBumpSizes curr range.EstimatedTileCrossings, range.EstimatedPtclWords, range.EstimatedBinFootprint, + range.EstimatedPathTiles, + range.EstimatedBlendSpillSlots, (long)((range.TargetBounds.Width + 15) / 16) * ((range.TargetBounds.Height + 15) / 16), maxStorageBufferBindingSize); /// - /// Raises scratch capacities to CPU-side lower bounds derived from encode-time demand - /// estimates. Tile crossings bound the segment, segment-count, and path-tile records; the - /// bin footprint bounds binning records; the PTCL budget derives from crossings. Estimates - /// are conservative upper bounds, so each is capped at the device binding limit; the GPU - /// overflow protocol remains the safety net for any underestimate. + /// Raises scratch capacities to the upper bounds that the encoder computed: + /// + /// Tile crossings bound the segment and segment-count records. + /// The path-tile bound bounds the path-tile records. + /// The bin footprint bounds the binning records. + /// The PTCL and blend-spill bounds bound those buffers. + /// /// + /// + /// The per-chunk buffers are not capped at the binding limit. A bound above the limit fails + /// binding validation, and the scene then renders in tile-row chunks, each sized by + /// . + /// The binning buffer does not shrink with a chunk, so it stays capped at the limit. + /// /// The persisted scratch capacities carried into this flush. /// The encoded (or GPU-expansion estimated) line count. /// The estimated sparse path-row count. @@ -2699,9 +3164,11 @@ private static WebGPUSceneBumpSizes SeedSceneBumpSizes(WebGPUSceneBumpSizes curr /// The CPU-side upper bound for tile-boundary crossings. /// The CPU-side upper bound for the dynamic PTCL words those crossings write. /// The CPU-side upper bound for per-(draw, bin) records. + /// The CPU-side upper bound for path-tile records. + /// The CPU-side upper bound for blend-spill slots. /// The number of tiles in the target, each of which can leave one partial PTCL chunk. /// The device-reported maximum size of one storage-buffer binding. - /// The retained capacities raised to the known CPU-side lower bounds. + /// The retained capacities raised to the CPU-side upper bounds. private static WebGPUSceneBumpSizes SeedSceneBumpSizes( WebGPUSceneBumpSizes currentSizes, int lineCount, @@ -2710,41 +3177,49 @@ private static WebGPUSceneBumpSizes SeedSceneBumpSizes( long estimatedTileCrossings, long estimatedPtclWords, long estimatedBinFootprint, + long estimatedPathTiles, + long estimatedBlendSpillSlots, long targetTileCount, ulong maxStorageBufferBindingSize) { uint lineFloor = AddSizingSlack(checked((uint)Math.Max(lineCount, 1))); uint pathRowFloor = checked((uint)Math.Max(totalPathRowCount, pathCount)); - uint segCountFloor = Math.Max( - ClampEstimate(estimatedTileCrossings, maxStorageBufferBindingSize, (uint)Unsafe.SizeOf()), - lineFloor); - uint segmentFloor = Math.Max( - ClampEstimate(estimatedTileCrossings, maxStorageBufferBindingSize, (uint)Unsafe.SizeOf()), - lineFloor); - uint pathTileFloor = Math.Max( - ClampEstimate(estimatedTileCrossings, maxStorageBufferBindingSize, (uint)Unsafe.SizeOf()), - pathRowFloor); + // Each tile crossing writes one segment count and one segment, so one floor sizes both. + uint segmentFloor = Math.Max(ToBoundCount(estimatedTileCrossings), lineFloor); + + // tile_alloc gives each path row the span of its touched tiles, so the path-tile bound of + // each draw (its clipped tile area or its row-span bound) bounds the path tiles. + uint pathTileFloor = Math.Max(ToBoundCount(estimatedPathTiles), pathRowFloor); + uint blendSpillFloor = ToBoundCount(estimatedBlendSpillSlots); uint binningFloor = ClampEstimate(estimatedBinFootprint, maxStorageBufferBindingSize, sizeof(uint)); long ptclWords = ((estimatedPtclWords * 105L) / 100L) + (targetTileCount * PtclChunkWords); - uint ptclFloor = ClampEstimate(ptclWords, maxStorageBufferBindingSize, sizeof(uint)); + uint ptclFloor = ToBoundCount(ptclWords); return new WebGPUSceneBumpSizes( Math.Max(currentSizes.Lines, lineFloor), Math.Max(currentSizes.Binning, binningFloor), Math.Max(currentSizes.PathRows, pathRowFloor), Math.Max(currentSizes.PathTiles, pathTileFloor), - Math.Max(currentSizes.SegCounts, segCountFloor), + Math.Max(currentSizes.SegCounts, segmentFloor), Math.Max(currentSizes.Segments, segmentFloor), - currentSizes.BlendSpill, + Math.Max(currentSizes.BlendSpill, blendSpillFloor), Math.Max(currentSizes.Ptcl, ptclFloor)); } /// - /// Converts one conservative CPU-side demand estimate into a seed capacity: sizing slack is - /// applied, then the count is capped so the resulting buffer cannot exceed the device's - /// storage-buffer binding limit (an over-cap estimate must not push an otherwise-fitting - /// scene onto the chunked path). + /// Converts one bound into an element count. A bound above the 32-bit range keeps the + /// largest count, which fails binding validation in the same way. + /// + /// The element-count bound. + /// The element count. + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private static uint ToBoundCount(long bound) + => (uint)Math.Clamp(bound, 0L, uint.MaxValue); + + /// + /// Converts one CPU-side estimate for a buffer that does not shrink with a chunk into a seed + /// capacity, capped so the buffer cannot exceed the device's storage-buffer binding limit. /// /// The conservative element-count estimate. /// The device-reported maximum size of one storage-buffer binding. diff --git a/src/ImageSharp.Drawing.WebGPU/WebGPUSceneEncoder.cs b/src/ImageSharp.Drawing.WebGPU/WebGPUSceneEncoder.cs index 91fdd8520..82044e440 100644 --- a/src/ImageSharp.Drawing.WebGPU/WebGPUSceneEncoder.cs +++ b/src/ImageSharp.Drawing.WebGPU/WebGPUSceneEncoder.cs @@ -66,7 +66,7 @@ internal static class WebGPUSceneEncoder /// /// The tile width in pixels used by binning, coarse, and fine. Must match TILE_WIDTH in config.wgsl. /// - private const int TileWidth = 16; + public const int TileWidth = 16; /// /// The tile height in pixels used by binning, coarse, and fine. Must match TILE_HEIGHT in config.wgsl. @@ -78,6 +78,12 @@ internal static class WebGPUSceneEncoder /// private const int PtclFillWords = 6; + /// + /// The extra PTCL words of one clip or layer on a tile: BeginClip (2), the forced solid and + /// EndClip (5), and its path (2). Must match coarse.wgsl. + /// + private const int PtclClipWords = 9; + /// /// Half the length of the synthetic horizontal segment substituted for point-like strokes so caps /// still render a dot. The full segment length equals , keeping @@ -92,6 +98,41 @@ internal static class WebGPUSceneEncoder /// private const float StrokeMicroSegmentEpsilon = 1F / 64F; + /// + /// The largest scene segment count of one encoding. A range has at most this count. coarse.wgsl stores the segment index in 8 bits. + /// + private const int MaxSceneSegmentCount = 255; + + /// + /// The draw-object count limit of a scene with a segment table. coarse.wgsl stores the draw + /// object index in 24 bits, so the count must be less than this value. + /// + private const int SegmentedDrawObjectLimit = 1 << 24; + + /// + /// Gets the error message for a scene that reads retained scenes in place and has more draw + /// objects than allows. + /// + private static string SegmentedDrawObjectLimitError + => $"A WebGPU scene that reads retained scenes in place can have at most {SegmentedDrawObjectLimit - 1} draw objects."; + + /// + /// The blend depth that fine keeps in registers. Must match BLEND_STACK_SPLIT in Shared/config.wgsl. + /// + public const int BlendStackSplit = 4; + + /// + /// The bounding-box tile count from which a polyline stroke walks its rows for the row-span + /// tile bound. The walk costs about one step for each row that a segment spans. A smaller + /// draw keeps its area bound, which is at most this many tiles. + /// + private const int RowWalkMinimumTiles = 1024; + + /// + /// The words in one scene segment table entry. Must match SCENE_SEGMENT_WORDS in scene_segment.wgsl. + /// + public const int SceneSegmentEntryWordCount = 13; + /// /// Vello's clip blend word, (MIX_CLIP << 8) | COMPOSE_SRC_OVER. Values must match blend.wgsl. /// @@ -207,7 +248,11 @@ public static bool TryEncode( // layer's graphics options when they close, so a partition boundary may not cut through // an open layer; the prescan snaps boundaries forward to the next point where no layer // scope is open instead of serializing the whole scene. - int partitionCount = GetPartitionCount(maxDegreeOfParallelism, commandCount, targetRowCount); + // A retained scene inside a layer is a gap in the outer streams at a stream position. One + // partition keeps every gap position in the final streams, so the resolver can build the + // segment table without a partition offset map. Only batches with a retained scene inside + // a layer pay this. + int partitionCount = scene.HasScenes ? 1 : GetPartitionCount(maxDegreeOfParallelism, commandCount, targetRowCount); int[]? partitionBoundaries = null; CompositionCommand[][]? partitionSeedClips = null; if (partitionCount > 1 && (scene.HasClipControls || scene.HasLayers)) @@ -314,6 +359,15 @@ public static bool TryEncode( return false; } + // TryAppendRetainedScene checks the limit at each retained scene. Outer draws after the + // last one also count, so check the final total here. + if (encoding.ExceedsSegmentedDrawObjectLimit) + { + encodedScene = WebGPUEncodedScene.Empty; + error = SegmentedDrawObjectLimitError; + return false; + } + if (encoding.IsEmpty) { encodedScene = WebGPUEncodedScene.Empty; @@ -335,6 +389,10 @@ public static bool TryEncode( /// Encodes a prepared command batch containing Apply into ordered WebGPU scene operations. /// /// The prepared command batch. + /// + /// For each command index, whether the BeginLayer command there opens a layer around a retained + /// scene with ordered work, or when there is no such layer. + /// /// The target bounds used for target-local coordinate conversion. /// The allocator used for temporary and packed scene storage. /// The maximum degree of parallelism used by leaf scene encoding. @@ -343,6 +401,7 @@ public static bool TryEncode( /// when the scene encoded successfully; otherwise, . public static bool TryEncodeOrdered( DrawingCommandBatch scene, + bool[]? scopedSceneLayers, in Rectangle targetBounds, MemoryAllocator allocator, int maxDegreeOfParallelism, @@ -363,6 +422,7 @@ public static bool TryEncodeOrdered( SceneEncodingCheckpoint rangeStart = encoding.BeginPackedIndependentRange(targetBounds, ClipBlendMode, replayActiveClips: true); if (!TryEncodeOrderedOperations( scene, + scopedSceneLayers, 0, scene.CommandCount, targetBounds, @@ -383,6 +443,15 @@ public static bool TryEncodeOrdered( operations.Add(new WebGPUSceneOperation(finalRange)); } + // The same check as TryEncode. A range has at most the draw objects of the whole + // scene, so the check of the whole scene holds for each range dispatch. + if (encoding.ExceedsSegmentedDrawObjectLimit) + { + encodedScene = WebGPUEncodedScene.Empty; + error = SegmentedDrawObjectLimitError; + return false; + } + if (encoding.IsEmpty) { encodedScene = WebGPUEncodedScene.Empty; @@ -406,6 +475,10 @@ public static bool TryEncodeOrdered( /// splitting the packed draw stream at every Apply barrier. /// /// The prepared command batch. + /// + /// For each command index, whether the BeginLayer command there opens a layer around a retained + /// scene with ordered work, or when there is no such layer. + /// /// The inclusive command start index. /// The exclusive command end index. /// The bounds of the target the range renders into. @@ -418,6 +491,7 @@ public static bool TryEncodeOrdered( /// when the range encoded successfully. private static bool TryEncodeOrderedOperations( DrawingCommandBatch scene, + bool[]? scopedSceneLayers, int commandStart, int commandEnd, Rectangle targetBounds, @@ -438,7 +512,8 @@ private static bool TryEncodeOrderedOperations( if (sceneCommand is PathCompositionSceneCommand pathCommand) { CompositionCommand command = pathCommand.Command; - if (command.Kind == CompositionCommandKind.BeginLayer && command.RequiresScopedApply) + if (command.Kind == CompositionCommandKind.BeginLayer && + (command.RequiresScopedApply || scopedSceneLayers?[i] == true)) { WebGPUSceneRange pendingRange = encoding.EndPackedIndependentRange(targetBounds, rangeStart, closeActiveClips: true); if (pendingRange.FillCount != 0) @@ -460,6 +535,7 @@ private static bool TryEncodeOrderedOperations( SceneEncodingCheckpoint childStart = encoding.BeginPackedIndependentRange(layerBounds, ClipBlendMode, replayActiveClips: true); if (!TryEncodeOrderedOperations( scene, + scopedSceneLayers, i + 1, layerEnd, layerBounds, @@ -517,6 +593,24 @@ private static bool TryEncodeOrderedOperations( } } + if (sceneCommand is RetainedSceneCompositionSceneCommand retainedCommand) + { + if (!TryAppendOrderedRetainedScene( + retainedCommand, + targetBounds, + targetId, + ref encoding, + operations, + ref rangeStart, + ref nextLayerTargetId, + out error)) + { + return false; + } + + continue; + } + if (sceneCommand is PathCompositionSceneCommand fillCommand) { CompositionCommand command = fillCommand.Command; @@ -585,6 +679,194 @@ private static void AddRenderRange( rangeStart = encoding.BeginPackedIndependentRange(targetBounds, nextClipBlendMode, replayActiveClips: true); } + /// + /// Encodes one retained scene command of an ordered scene. + /// + /// + /// + /// Directly inside a scoped layer, the retained scene renders as its own passes into the layer + /// target, in stream order. An Apply there splits the layer content into passes in the same way. + /// Its operations read its own encoded data. Their origin moves its data coordinates into the + /// layer target, so the retained data does not change. + /// + /// + /// Inside a layer of the current range that is not scoped, the layer content cannot split into + /// passes, so the range reads the retained scene in place through the segment table. + /// + /// + /// The retained scene command. + /// The bounds of the target the current range renders into. + /// The stable identity of that target. + /// The shared mutable scene encoding. + /// Receives the ordered scene operations. + /// The checkpoint of the currently open packed range. The method updates it when it splits the range. + /// The next stable target identity available for a scoped layer. + /// Receives the failure reason when encoding fails. + /// when the command encoded successfully. + private static bool TryAppendOrderedRetainedScene( + RetainedSceneCompositionSceneCommand command, + Rectangle targetBounds, + int targetId, + ref SupportedSubsetSceneEncoding encoding, + List operations, + ref SceneEncodingCheckpoint rangeStart, + ref int nextLayerTargetId, + out string? error) + { + if (encoding.OpenLayerCount != 0) + { + return encoding.TryAppendRetainedScene(command.Scene, out error); + } + + // The retained data is in the coordinates of the root target, which the scene bounds must + // match. The layer target lies inside the root target, so the origin is never negative. + Rectangle rootTargetBounds = encoding.RootTargetBounds; + Point origin = new(targetBounds.X - rootTargetBounds.X, targetBounds.Y - rootTargetBounds.Y); + AddRenderRange(targetBounds, ref encoding, operations, ref rangeStart, ClipBlendMode); + return AppendRetainedSceneOperations(command.Scene, rootTargetBounds, targetBounds, targetId, origin, operations, ref nextLayerTargetId, out error); + } + + /// + /// Appends the operations that render one retained scene into a scoped layer target. + /// + /// The retained scene. It must be a WebGPU scene. + /// The bounds of the root target of the encoding, which the scene bounds must match. + /// The bounds of the layer target. + /// The stable identity of the layer target. + /// The position of the layer target in the coordinates of the scene data. + /// Receives the ordered scene operations. + /// The next stable target identity available for a scoped layer. + /// Receives the failure reason when the scene cannot render into the layer. + /// when the operations were appended. + private static bool AppendRetainedSceneOperations( + DrawingBackendScene retainedScene, + Rectangle rootTargetBounds, + Rectangle targetBounds, + int targetId, + Point origin, + List operations, + ref int nextLayerTargetId, + out string? error) + { + // The same rules as RenderScene: the scene must come from this backend, and it renders only + // into a target with its own bounds. + if (retainedScene is not WebGPUDrawingBackendScene scene) + { + error = "The scene is not compatible with the WebGPU drawing backend."; + return false; + } + + if (scene.Bounds != rootTargetBounds) + { + error = "The target bounds do not match the WebGPU drawing backend scene bounds."; + return false; + } + + if (scene.Parts is DrawingBackendScene[] parts) + { + // A parts scene renders its parts in order. The call checks each part. + for (int i = 0; i < parts.Length; i++) + { + if (!AppendRetainedSceneOperations(parts[i], rootTargetBounds, targetBounds, targetId, origin, operations, ref nextLayerTargetId, out error)) + { + return false; + } + } + + error = null; + return true; + } + + WebGPUEncodedScene encoded = scene.EncodedScene; + if (!encoded.HasOperations) + { + if (encoded.FillCount != 0) + { + operations.Add(new WebGPUSceneOperation(WebGPUSceneRange.CreateWholeScene(encoded, targetBounds, origin)) + { + RetainedScene = scene + }); + } + + error = null; + return true; + } + + // The operations of an ordered scene keep their order. Those at depth zero render into its + // root target, which is now the layer target, so they take its bounds and the origin. Its + // own scoped layers get new target identities and keep their own bounds. An operation that + // already reads a retained scene of its own keeps that scene, because its range indexes + // the data of that scene. + ReadOnlySpan source = encoded.OrderedOperations; + int[] targetIds = new int[encoded.OrderedTargetCount]; + targetIds[0] = targetId; + int depth = 0; + for (int i = 0; i < source.Length; i++) + { + WebGPUSceneOperation operation = source[i]; + switch (operation.Kind) + { + case WebGPUSceneOperationKind.RenderRange: + operations.Add(new WebGPUSceneOperation(depth == 0 ? operation.Range.WithTarget(targetBounds, origin) : operation.Range) + { + RetainedScene = operation.RetainedScene ?? scene + }); + break; + + case WebGPUSceneOperationKind.BeginLayer: + targetIds[operation.LayerTargetId] = nextLayerTargetId++; + operations.Add(new WebGPUSceneOperation(operation.LayerBounds, targetIds[operation.LayerTargetId], targetIds[operation.ParentTargetId]) + { + RetainedScene = operation.RetainedScene ?? scene + }); + + depth++; + break; + + case WebGPUSceneOperationKind.EndLayer: + depth--; + operations.Add(new WebGPUSceneOperation( + depth == 0 ? operation.Range.WithTarget(targetBounds, origin) : operation.Range, + targetIds[operation.LayerTargetId], + targetIds[operation.ParentTargetId]) + { + RetainedScene = operation.RetainedScene ?? scene + }); + break; + + case WebGPUSceneOperationKind.Apply: + WebGPUApplySceneItem apply = operation.Apply!; + operations.Add(new WebGPUSceneOperation(new WebGPUApplySceneItem( + apply.Operation, + apply.Effect, + apply.InputRect, + apply.OutputRect, + apply.ReadOffset, + depth == 0 ? apply.DrawRange.WithTarget(targetBounds, origin) : apply.DrawRange)) + { + RetainedScene = operation.RetainedScene ?? scene + }); + break; + + case WebGPUSceneOperationKind.ShaderEffect: + WebGPUShaderEffectSceneItem effect = operation.ShaderEffect!; + operations.Add(new WebGPUSceneOperation(new WebGPUShaderEffectSceneItem( + effect.Effect, + effect.InputRect, + effect.OutputRect, + effect.ReadOffset, + depth == 0 ? effect.DrawRange.WithTarget(targetBounds, origin) : effect.DrawRange)) + { + RetainedScene = operation.RetainedScene ?? scene + }); + break; + } + } + + error = null; + return true; + } + /// /// Encodes one Apply command as an external-texture fill and wraps the pending draw range in an /// Apply scene item that carries the canvas source rectangle to sample at render time. @@ -889,6 +1171,8 @@ private readonly struct SceneEncodingCheckpoint /// The accumulated tile-crossing upper bound. /// The accumulated PTCL word upper bound. /// The accumulated per-(draw, bin) record upper bound. + /// The number of retained scenes recorded so far. + /// The accumulated path-tile bound. public SceneEncodingCheckpoint( int pathTagByteCount, int pathDataWordCount, @@ -904,8 +1188,12 @@ public SceneEncodingCheckpoint( long estimatedPathRowCount, long estimatedTileCrossings, long estimatedPtclWords, - long estimatedBinFootprint) + long estimatedBinFootprint, + int insertionCount, + long estimatedPathTiles) { + this.InsertionCount = insertionCount; + this.EstimatedPathTiles = estimatedPathTiles; this.PathTagByteCount = pathTagByteCount; this.PathDataWordCount = pathDataWordCount; this.DrawTagCount = drawTagCount; @@ -997,6 +1285,16 @@ public SceneEncodingCheckpoint( /// Gets the accumulated per-(draw, bin) record upper bound. /// public long EstimatedBinFootprint { get; } + + /// + /// Gets the number of retained scenes recorded so far. + /// + public int InsertionCount { get; } + + /// + /// Gets the accumulated path-tile bound. + /// + public long EstimatedPathTiles { get; } } /// @@ -1644,6 +1942,20 @@ private ref struct SupportedSubsetSceneEncoding private long estimatedPtclWords; private long estimatedBinFootprint; + // The path-tile bound: the tiles of each draw's clipped bounds. + private long estimatedPathTiles; + + // The clip and layer nesting depth in stream order, and the union region of each depth, + // starting at depth 1, in the tile grid of the current range. + private int blendDepth; + private List? blendLevels; + + // The row bounds of the current range, and every row bounds instance this encoding + // created. The resolved scene or partition takes ownership of the list. + private WebGPUSceneRowBounds? rowBounds; + private List? ownedRowBounds; + private bool rowBoundsDetached; + // Cache of the last emitted 10-word style record. Consecutive draws with identical // styles share one record, so the encoder only appends a style when a word changes. private uint lastStyle0; @@ -1657,7 +1969,15 @@ private ref struct SupportedSubsetSceneEncoding private uint lastStyle8; private uint lastStyle9; private GpuSceneTransform lastTransform; + + // The encoded data of every range is in the coordinates of the root target, so retained + // scenes of the root target can be read in place in any range. A range for another target, + // for example a scoped layer, has that target position as its origin (WebGPUSceneRange.Origin). private Rectangle rootTargetBounds; + + // The target of the current range, in the coordinates of the root target. The GPU tile grid + // of the range starts at its top-left corner. + private Rectangle rangeDataBounds; private List? activeClips; private List? openLayerBounds; @@ -1689,6 +2009,13 @@ public SupportedSubsetSceneEncoding( this.Recolors = []; this.SolidColors = []; this.GradientRamps = []; + this.Insertions = null; + this.SegmentedRanges = null; + this.retainedDrawTagCount = 0; + this.rangeSegmentEntryCount = 0; + + // A scene with no retained scene is one piece of every stream. + this.SegmentCount = 1; this.FillCount = 0; this.PathCount = 0; this.LineCount = 0; @@ -1711,6 +2038,7 @@ public SupportedSubsetSceneEncoding( this.lastStyle8 = 0; this.lastStyle9 = 0; this.rootTargetBounds = rootTargetBounds; + this.rangeDataBounds = new Rectangle(0, 0, rootTargetBounds.Width, rootTargetBounds.Height); this.lastTransform = GpuSceneTransform.Identity; this.activeClips = null; this.openLayerBounds = null; @@ -1718,6 +2046,9 @@ public SupportedSubsetSceneEncoding( this.estimatedTileCrossings = 0; this.estimatedPtclWords = 0; this.estimatedBinFootprint = 0; + this.estimatedPathTiles = 0; + this.blendDepth = 0; + this.blendLevels = null; this.VisibleFillCount = 0; this.PathTags.Add(PackPathTag(PathTag.Transform)); @@ -1799,6 +2130,56 @@ public SupportedSubsetSceneEncoding( /// public List GradientRamps; + /// + /// Gets or sets the retained scenes read in place inside layers, in stream order, or + /// when the encoding has none. + /// + public List? Insertions; + + /// + /// Gets or sets the ranges that read retained scenes in place, in stream order, or + /// when no range does. The resolver writes one segment table for each. + /// + public List? SegmentedRanges; + + // The draw tags of the retained scenes in Insertions, which the virtual draw-tag stream adds. + private int retainedDrawTagCount; + + // The table entries of the ranges in SegmentedRanges, which the range tables of the scene hold. + private int rangeSegmentEntryCount; + + /// + /// Gets the scene segment count that the retained scenes recorded so far give. + /// + public int SegmentCount { get; private set; } + + /// + /// Gets the virtual draw-tag count: the draw tags of this encoding and of its retained scenes. + /// + public readonly int JoinedDrawTagCount => this.DrawTags.Count + this.retainedDrawTagCount; + + /// + /// Gets a value indicating whether the encoding reads retained scenes in place and has more + /// draw objects than coarse can pack. + /// + public readonly bool ExceedsSegmentedDrawObjectLimit + => this.Insertions is not null && this.JoinedDrawTagCount >= SegmentedDrawObjectLimit; + + /// + /// Gets the number of table entries of the ranges that read retained scenes in place. + /// + public readonly int RangeSegmentEntryCount => this.rangeSegmentEntryCount; + + /// + /// Gets the number of layers open in the current range, which encode inline as clip groups. + /// + public readonly int OpenLayerCount => this.openLayerBounds?.Count ?? 0; + + /// + /// Gets the bounds of the root target, whose coordinates every range of the encoding uses. + /// + public readonly Rectangle RootTargetBounds => this.rootTargetBounds; + /// /// Gets the number of emitted fill records. /// @@ -1845,6 +2226,17 @@ public SupportedSubsetSceneEncoding( /// public readonly long EstimatedTileCrossings => this.estimatedTileCrossings; + /// + /// Gets a value indicating whether the tile grid of the current range has the alignment of the + /// tile grid of the root target. + /// + /// + /// The encoder counts tile crossings on the grid of the root target. When the grids do not + /// align, each line can cross one more tile on each axis. + /// + private readonly bool IsRangeTileAligned + => this.rangeDataBounds.X % TileWidth == 0 && this.rangeDataBounds.Y % TileHeight == 0; + /// /// Gets the CPU-side upper-bound estimate of the dynamic PTCL words the scene's tile /// crossings can write: one fill command plus the draw's paint command per crossing. @@ -1857,9 +2249,51 @@ public SupportedSubsetSceneEncoding( public readonly long EstimatedBinFootprint => this.estimatedBinFootprint; /// - /// Gets a value indicating whether the encoding produced no fill work. + /// Gets the path-tile bound: the tiles of each draw's clipped bounds. + /// + public readonly long EstimatedPathTiles => this.estimatedPathTiles; + + /// + /// Gets the union region of each blend depth, starting at depth 1, or . /// - public readonly bool IsEmpty => this.FillCount == 0; + public readonly List? BlendLevels => this.blendLevels; + + /// + /// Gets the row bounds of the current range, or when it has no draws. + /// + public readonly WebGPUSceneRowBounds? RowBounds => this.rowBounds; + + /// + /// Takes ownership of every row bounds instance that this encoding created. + /// + /// The instances, or an empty array. + public WebGPUSceneRowBounds[] DetachRowBounds() + { + this.rowBoundsDetached = true; + return this.ownedRowBounds is { } owned ? [.. owned] : []; + } + + /// + /// Gets the row bounds of the current range, and creates them for the range grid when the + /// range has none yet. + /// + /// The row bounds. + private WebGPUSceneRowBounds GetOrCreateRowBounds() + { + if (this.rowBounds is null) + { + this.rowBounds = new WebGPUSceneRowBounds(DivideRoundUp(this.rangeDataBounds.Height, TileHeight), this.rangeDataBounds.Y); + (this.ownedRowBounds ??= []).Add(this.rowBounds); + } + + return this.rowBounds; + } + + /// + /// Gets a value indicating whether the encoding produced no fill work. A retained scene is + /// recorded only when it has fills. + /// + public readonly bool IsEmpty => this.FillCount == 0 && this.Insertions is null; /// /// Captures the current stream offsets for a renderable range inside this scene. @@ -1881,7 +2315,9 @@ public readonly SceneEncodingCheckpoint CaptureCheckpoint() this.estimatedPathRowCount, this.estimatedTileCrossings, this.estimatedPtclWords, - this.estimatedBinFootprint); + this.estimatedBinFootprint, + this.Insertions?.Count ?? 0, + this.estimatedPathTiles); /// /// Starts a range that can be decoded independently by the staged pipeline. @@ -1890,7 +2326,10 @@ public readonly SceneEncodingCheckpoint CaptureCheckpoint() private void BeginIndependentRange(Rectangle targetBounds) { this.PadPathTagsToSegmentBoundary(); - this.rootTargetBounds = targetBounds; + this.rangeDataBounds = ToTargetLocal(targetBounds, this.rootTargetBounds); + + // The row bounds use the grid of one range. The new range starts its own. + this.rowBounds = null; this.openLayerBounds?.Clear(); this.hasLastStyle = false; this.lastTransform = GpuSceneTransform.Identity; @@ -1947,7 +2386,7 @@ public WebGPUSceneRange EndPackedIndependentRange( } this.EndIndependentRange(); - return CreateRange(start, this.CaptureCheckpoint(), targetBounds); + return this.CreateRange(start, this.CaptureCheckpoint(), targetBounds); } /// @@ -2013,38 +2452,115 @@ private void AppendActiveClipEnds() /// Ranges begin on the padded scan-segment boundaries produced by /// , so the starting path-tag byte offset always /// divides exactly into the word offset consumed by the tag-scan shaders. + /// A range that holds retained scenes gets joined counts and a segment table of its own. /// /// The checkpoint captured at the start of the range. /// The checkpoint captured at the end of the range. /// The target bounds for the range. /// The created scene range. - public static WebGPUSceneRange CreateRange( + private WebGPUSceneRange CreateRange( in SceneEncodingCheckpoint start, in SceneEncodingCheckpoint end, Rectangle targetBounds) - => new( + { + int pathTagByteCount = end.PathTagByteCount - start.PathTagByteCount; + int drawTagCount = end.DrawTagCount - start.DrawTagCount; + int drawDataWordCount = end.DrawDataWordCount - start.DrawDataWordCount; + SupportedSubsetSceneResolver.JoinedSceneCounts counts = new( + end.FillCount - start.FillCount, + end.PathCount - start.PathCount, + end.LineCount - start.LineCount, + end.ClipCount - start.ClipCount, + end.InfoWordCount - start.InfoWordCount, + drawTagCount, + drawDataWordCount, + checked((int)(end.EstimatedPathRowCount - start.EstimatedPathRowCount)), + end.EstimatedTileCrossings - start.EstimatedTileCrossings, + end.EstimatedPtclWords - start.EstimatedPtclWords, + end.EstimatedBinFootprint - start.EstimatedBinFootprint, + end.EstimatedPathTiles - start.EstimatedPathTiles); + + WebGPUSceneRangeSegments? segments = null; + int insertionCount = end.InsertionCount - start.InsertionCount; + if (insertionCount != 0) + { + // The range starts on a scan-segment boundary and ends padded to one, so its own + // path tags are whole words. Each retained scene adds its virtual path-tag words. + ReadOnlySpan insertions = CollectionsMarshal.AsSpan(this.Insertions).Slice(start.InsertionCount, insertionCount); + int pathTagWordCount = pathTagByteCount / 4; + for (int i = 0; i < insertions.Length; i++) + { + WebGPUEncodedScene retained = insertions[i].Scene.EncodedScene; + counts.Add(retained); + pathTagWordCount = checked(pathTagWordCount + retained.VirtualPathTagWordCount); + } + + int segmentCount = SupportedSubsetSceneResolver.SceneSegmentTableWriter.CountSegments(insertions); + segments = new WebGPUSceneRangeSegments( + start.InsertionCount, + insertionCount, + this.rangeSegmentEntryCount, + segmentCount, + pathTagByteCount / 4, + drawTagCount, + drawDataWordCount); + + this.rangeSegmentEntryCount = checked(this.rangeSegmentEntryCount + segmentCount); + pathTagByteCount = checked(pathTagWordCount * 4); + } + + // The range takes the blend depth regions and the row bounds recorded since the last + // range, so the next range starts its own. + long blendSpillSlots = WebGPUEncodedScene.GetBlendSpillSlots(CollectionsMarshal.AsSpan(this.blendLevels), int.MinValue, int.MaxValue); + this.blendLevels?.Clear(); + WebGPUSceneRowBounds? rangeRowBounds = this.rowBounds; + this.rowBounds = null; + + WebGPUSceneRange range = new( targetBounds, start.PathTagByteCount / 4, - end.PathTagByteCount - start.PathTagByteCount, + pathTagByteCount, start.PathDataWordCount, end.PathDataWordCount - start.PathDataWordCount, start.DrawTagCount, - end.DrawTagCount - start.DrawTagCount, + counts.DrawTagCount, start.DrawDataWordCount, - end.DrawDataWordCount - start.DrawDataWordCount, + counts.DrawDataWordCount, start.TransformWordCount, end.TransformWordCount - start.TransformWordCount, start.StyleWordCount, end.StyleWordCount - start.StyleWordCount, - end.InfoWordCount - start.InfoWordCount, - end.PathCount - start.PathCount, - end.ClipCount - start.ClipCount, - end.FillCount - start.FillCount, - end.LineCount - start.LineCount, - checked((int)(end.EstimatedPathRowCount - start.EstimatedPathRowCount)), - end.EstimatedTileCrossings - start.EstimatedTileCrossings, - end.EstimatedPtclWords - start.EstimatedPtclWords, - end.EstimatedBinFootprint - start.EstimatedBinFootprint); + counts.InfoWordCount, + counts.PathCount, + counts.ClipCount, + counts.FillCount, + counts.LineCount, + counts.EstimatedPathRowCount, + counts.EstimatedTileCrossings, + counts.EstimatedPtclWords, + counts.EstimatedBinFootprint, + segments) + { + EstimatedPathTiles = counts.EstimatedPathTiles, + EstimatedBlendSpillSlots = blendSpillSlots, + RowBounds = rangeRowBounds + }; + + // The data is in the coordinates of the root target. A range for another target, such + // as a scoped layer, moves the data by the position of that target in those coordinates. + Point origin = this.rangeDataBounds.Location; + if (origin != Point.Empty) + { + range = range.WithTarget(targetBounds, origin); + } + + if (segments is not null) + { + (this.SegmentedRanges ??= []).Add(range); + } + + return range; + } /// /// Disposes all owned stream storage that has not already been detached. @@ -2075,10 +2591,18 @@ public void Dispose() this.PathData.Dispose(); this.PathTags.Dispose(); } - } - /// - /// Marks the main scene streams as detached so disposal does not free retained partition storage. + if (!this.rowBoundsDetached && this.ownedRowBounds is { } ownedRowBounds) + { + foreach (WebGPUSceneRowBounds bounds in ownedRowBounds) + { + bounds.Dispose(); + } + } + } + + /// + /// Marks the main scene streams as detached so disposal does not free retained partition storage. /// public void MarkStreamsDetached() => this.streamsDetached = true; @@ -2226,8 +2750,143 @@ private bool TryBuildCore( return false; } } + else if (command is RetainedSceneCompositionSceneCommand sceneCommand) + { + if (!this.TryAppendRetainedScene(sceneCommand.Scene, out error)) + { + return false; + } + } + } + + error = null; + return true; + } + + /// + /// Records one retained scene inside a layer, or each part of a parts scene in order, as gaps + /// at the current position of every stream. + /// + /// + /// The backend renders a retained scene outside every layer as its own pass, so only a scene + /// inside a layer reaches the encoder. The retained streams are not copied. The resolver + /// builds the segment table from the gap positions, and the shaders read the retained scene + /// in place (see scene_segment.wgsl). + /// + /// The retained scene. + /// The error message when the scene cannot be read in place. + /// when the scene was recorded. + public bool TryAppendRetainedScene(DrawingBackendScene scene, out string? error) + { + if (scene is not WebGPUDrawingBackendScene webGPUScene) + { + error = "The scene is not compatible with the WebGPU drawing backend."; + return false; + } + + // A retained scene uses the coordinates of its own target, so it must match the root + // target of this encoding, the same as RenderScene requires. Every range of the + // encoding uses those coordinates (see rootTargetBounds). + if (webGPUScene.Bounds != this.rootTargetBounds) + { + error = "The target bounds do not match the WebGPU drawing backend scene bounds."; + return false; + } + + // A parts scene renders its parts in order, so its parts are read in place in order. + // A part with ordered operations makes the parts scene require a scoped layer, so + // here every part is plain. + if (webGPUScene.Parts is DrawingBackendScene[] parts) + { + for (int i = 0; i < parts.Length; i++) + { + if (!this.TryAppendRetainedScene(parts[i], out error)) + { + return false; + } + } + + error = null; + return true; + } + + if (webGPUScene.EncodedScene.HasOperations) + { + error = "A WebGPU retained scene with ordered operations cannot render inside a layer that is not scoped."; + return false; + } + + WebGPUEncodedScene retained = webGPUScene.EncodedScene; + if (retained.FillCount == 0) + { + error = null; + return true; + } + + // coarse.wgsl packs the segment index in 8 bits and the draw object index in 24 bits. + // One retained scene adds its own segments and one more outer piece. + int segmentCount = checked(this.SegmentCount + Math.Max(retained.SegmentCount, 1) + 1); + int drawObjectCount = checked(this.JoinedDrawTagCount + retained.DrawTagCount); + if (segmentCount > MaxSceneSegmentCount) + { + error = $"A WebGPU scene can read at most {MaxSceneSegmentCount} scene segments in one encoding."; + return false; + } + + if (drawObjectCount >= SegmentedDrawObjectLimit) + { + error = SegmentedDrawObjectLimitError; + return false; } + this.SegmentCount = segmentCount; + this.retainedDrawTagCount = checked(this.retainedDrawTagCount + retained.DrawTagCount); + + // The clips and layers of the retained scene nest inside the open clips and layers of + // this encoding, so its blend depths start at the current depth. + this.AddRetainedBlendDepthRegions(retained.BlendDepthRegions); + + // The retained crossings are counted on the grid of the root target. When the range grid + // has another alignment, each retained line can cross one more tile on each axis. + long alignmentCrossings = this.IsRangeTileAligned ? 0 : 2L * retained.LineCount; + this.estimatedTileCrossings += alignmentCrossings; + + // The rows of the retained draws are not tracked here, so their totals count in every + // chunk window of this range. + this.GetOrCreateRowBounds().AddRetained( + retained.TotalPathRowCount, + retained.EstimatedPathTiles, + retained.EstimatedTileCrossings + alignmentCrossings, + retained.EstimatedPtclWords); + + // A gap starts on a path-tag word. The None bytes add nothing to the tag monoid. + int padding = (4 - (this.PathTags.Count & 3)) & 3; + if (padding != 0) + { + Span paddedTags = this.PathTags.GetAppendSpan(padding); + paddedTags.Clear(); + this.PathTags.Advance(padding); + } + + (this.Insertions ??= []).Add(new WebGPUSceneInsertion( + webGPUScene, + this.PathTags.Count / 4, + this.PathData.Count, + this.Transforms.Count, + this.Styles.Count, + this.DrawTags.Count, + this.DrawData.Count)); + + // The last transform and style in the virtual stream are now those of the retained + // scene. Restart both, so the next outer path emits its own records and does not skip + // them as equal to the cached outer values. + this.PathTags.EnsureAdditionalCapacity(1); + this.Transforms.EnsureAdditionalCapacity(TransformWordCount); + this.PathTags.Add(PackPathTag(PathTag.Transform)); + AppendIdentityTransform(ref this.Transforms); + this.lastTransform = GpuSceneTransform.Identity; + this.hasLastStyle = false; + error = null; return true; } @@ -2648,7 +3307,25 @@ private void AppendPlainFill( ref this.PathTags, ref this.PathData, out int geometryLineCount, - out long geometryTileCrossings); + out long geometryTileCrossings, + out long geometryRowCrossings); + + // EncodePath counts tile crossings on the grid of the root target, before the GPU + // transform. A translation, or a range grid with another alignment, moves the tile + // alignment, so each line can cross one more tile on each axis. Any other transform + // changes the lengths, so the bound falls back to the bounding box. + GpuSceneTransform pathTransform = this.lastTransform; + if (IsProfileCompatibleTransform(pathTransform)) + { + if (pathTransform.Tx != 0F || pathTransform.Ty != 0F || !this.IsRangeTileAligned) + { + geometryTileCrossings += 2L * geometryLineCount; + } + } + else + { + geometryTileCrossings = -1; + } if (encodedPathCount == 0) { @@ -2671,7 +3348,14 @@ private void AppendPlainFill( this.lastStyle7 = style7; this.lastStyle8 = style8; this.lastStyle9 = style9; - this.AccumulateDrawRowEstimate(command.RasterizerOptions.Interest, geometryLineCount, geometryTileCrossings, GetPtclPaintWords(drawTag)); + RowSpanSource spanSource = this.CreateRowSpanSource(geometry, default, default, pathDataOffset, 0F, geometryRowCrossings, 0); + this.AccumulateDrawRowEstimate( + command.RasterizerOptions.Interest, + geometryLineCount, + geometryTileCrossings, + GetPtclPaintWords(drawTag), + spanSource); + this.FillCount++; this.PathCount += encodedPathCount; @@ -2800,7 +3484,9 @@ private void AppendPlainStroke( ref this.PathTags, ref this.PathData, out int geometryLineCount, - out long geometryTileCrossings); + out long geometryTileCrossings, + out long geometryRowCrossings, + out long geometryEndCrossings); if (encodedPathCount == 0) { @@ -2820,7 +3506,23 @@ private void AppendPlainStroke( this.lastStyle7 = style7; this.lastStyle8 = style8; this.lastStyle9 = style9; - this.AccumulateDrawRowEstimate(command.RasterizerOptions.Interest, geometryLineCount, geometryTileCrossings, GetPtclPaintWords(drawTag)); + float outlineRadius = GetStrokeOutlineRadius(pen, widthScale); + RowSpanSource spanSource = this.CreateRowSpanSource( + geometry, + default, + default, + pathDataOffset, + outlineRadius, + geometryRowCrossings, + geometryEndCrossings); + + this.AccumulateDrawRowEstimate( + command.RasterizerOptions.Interest, + geometryLineCount, + geometryTileCrossings, + GetPtclPaintWords(drawTag), + spanSource); + this.FillCount++; this.PathCount += encodedPathCount; this.LineCount += geometryLineCount; @@ -2925,7 +3627,9 @@ private void AppendExplicitStroke( ref this.PathTags, ref this.PathData, out int geometryLineCount, - out long geometryTileCrossings); + out long geometryTileCrossings, + out long geometryRowCrossings, + out long geometryEndCrossings); if (encodedPathCount == 0) { @@ -2945,7 +3649,17 @@ private void AppendExplicitStroke( this.lastStyle7 = style7; this.lastStyle8 = style8; this.lastStyle9 = style9; - this.AccumulateDrawRowEstimate(rasterizerOptions.Interest, geometryLineCount, geometryTileCrossings, GetPtclPaintWords(drawTag)); + float outlineRadius = GetStrokeOutlineRadius(pen, widthScale); + RowSpanSource spanSource = this.CreateRowSpanSource( + geometry, + default, + default, + pathDataOffset, + outlineRadius, + geometryRowCrossings, + geometryEndCrossings); + + this.AccumulateDrawRowEstimate(rasterizerOptions.Interest, geometryLineCount, geometryTileCrossings, GetPtclPaintWords(drawTag), spanSource); this.FillCount++; this.PathCount += encodedPathCount; this.LineCount += geometryLineCount; @@ -3052,7 +3766,9 @@ private void AppendExplicitStroke( ref this.PathTags, ref this.PathData, out int geometryLineCount, - out long geometryTileCrossings); + out long geometryTileCrossings, + out long geometryRowCrossings, + out long geometryEndCrossings); if (encodedPathCount == 0) { @@ -3072,7 +3788,17 @@ private void AppendExplicitStroke( this.lastStyle7 = style7; this.lastStyle8 = style8; this.lastStyle9 = style9; - this.AccumulateDrawRowEstimate(rasterizerOptions.Interest, geometryLineCount, geometryTileCrossings, GetPtclPaintWords(drawTag)); + float outlineRadius = GetStrokeOutlineRadius(pen, widthScale); + RowSpanSource spanSource = this.CreateRowSpanSource( + null, + (Vector2)start, + (Vector2)end, + pathDataOffset, + outlineRadius, + geometryRowCrossings, + geometryEndCrossings); + + this.AccumulateDrawRowEstimate(rasterizerOptions.Interest, geometryLineCount, geometryTileCrossings, GetPtclPaintWords(drawTag), spanSource); this.FillCount++; this.PathCount += encodedPathCount; this.LineCount += geometryLineCount; @@ -3136,11 +3862,11 @@ private bool AppendClipDescriptor(in DrawingClipDescriptor descriptor, Point des const uint style5 = 0U; // Clip coverage must not be restricted by a raster interest rectangle; a zero interest - // makes binning intersect the clip path away. Use the full target region. - uint style6 = BitcastSingle(0F); - uint style7 = BitcastSingle(0F); - uint style8 = BitcastSingle(this.rootTargetBounds.Width); - uint style9 = BitcastSingle(this.rootTargetBounds.Height); + // makes binning intersect the clip path away. Use the full target region of the range. + uint style6 = BitcastSingle(this.rangeDataBounds.Left); + uint style7 = BitcastSingle(this.rangeDataBounds.Top); + uint style8 = BitcastSingle(this.rangeDataBounds.Right); + uint style9 = BitcastSingle(this.rangeDataBounds.Bottom); int pathTagCheckpoint = this.PathTags.Count; int pathDataCheckpoint = this.PathData.Count; @@ -3286,6 +4012,7 @@ private bool AppendClipDescriptor(in DrawingClipDescriptor descriptor, Point des ref this.PathTags, ref this.PathData, out clipLineCount, + out _, out _); } } @@ -3400,7 +4127,8 @@ private bool AppendClipDescriptor(in DrawingClipDescriptor descriptor, Point des this.lastStyle7 = style7; this.lastStyle8 = style8; this.lastStyle9 = style9; - this.AccumulateDrawRowEstimateLocal(clipBounds, clipLineCount, -1, GetPtclPaintWords(GpuSceneDrawTag.EndClip)); + this.blendDepth++; + this.AccumulateDrawRowEstimateLocal(clipBounds, clipLineCount, -1, GetPtclPaintWords(GpuSceneDrawTag.EndClip), true, RowSpanSource.None); this.PathCount += encodedPathCount; this.LineCount += clipLineCount; this.InfoWordCount += (int)GpuSceneDrawTag.Map(GpuSceneDrawTag.BeginClip).InfoOffset; @@ -3487,7 +4215,8 @@ private void AppendBeginLayer(in CompositionCommand command) this.lastStyle7 = style7; this.lastStyle8 = style8; this.lastStyle9 = style9; - this.AccumulateDrawRowEstimateLocal(layerBounds, clipLineCount, -1, GetPtclPaintWords(GpuSceneDrawTag.EndClip)); + this.blendDepth++; + this.AccumulateDrawRowEstimateLocal(layerBounds, clipLineCount, -1, GetPtclPaintWords(GpuSceneDrawTag.EndClip), true, RowSpanSource.None); this.PathCount += encodedPathCount; this.LineCount += clipLineCount; this.InfoWordCount += (int)GpuSceneDrawTag.Map(GpuSceneDrawTag.BeginClip).InfoOffset; @@ -3508,14 +4237,63 @@ private void AppendBeginLayer(in CompositionCommand command) /// to fall back to the bounding-box diagonal bound (clips). /// /// The PTCL word count of the draw's paint command. - private void AccumulateDrawRowEstimate(Rectangle absoluteInterest, int lineCount, long tileCrossings, int paintWords) - => this.AccumulateDrawRowEstimateLocal(ToTargetLocal(absoluteInterest, this.rootTargetBounds), lineCount, tileCrossings, paintWords); + /// The source segments for the row-span tile bound. + private void AccumulateDrawRowEstimate(Rectangle absoluteInterest, int lineCount, long tileCrossings, int paintWords, in RowSpanSource spanSource) + => this.AccumulateDrawRowEstimateLocal( + ToTargetLocal(absoluteInterest, this.rootTargetBounds), + lineCount, + tileCrossings, + paintWords, + false, + spanSource); + + /// + /// Returns the row-span source of a draw whose points the GPU only translates. + /// + /// The source geometry, or for one segment. + /// The segment start when is . + /// The segment end when is . + /// The destination offset that path data applies directly. + /// The radius around the segments that contains the GPU lines. + /// An upper bound for the tile crossings of the GPU body lines in one tile row. + /// The tile crossings of the other GPU lines, such as stroke joins and caps. + /// The source, or when the transform is not a translation. + private readonly RowSpanSource CreateRowSpanSource( + LinearGeometry? geometry, + Vector2 start, + Vector2 end, + Point pathDataOffset, + float radius, + long crossingsPerRow, + long endCrossings) + { + GpuSceneTransform transform = this.lastTransform; + if (!IsProfileCompatibleTransform(transform)) + { + return RowSpanSource.None; + } + + Vector2 translate = new( + pathDataOffset.X - this.rootTargetBounds.X + transform.Tx, + pathDataOffset.Y - this.rootTargetBounds.Y + transform.Ty); + + return new RowSpanSource(geometry, start, end, translate, radius, crossingsPerRow, endCrossings); + } /// - /// Adds one draw object's clipped root-target-local footprint to the sparse scratch estimates: - /// tile-row span for path rows, per-line tile-crossing bound for segments and path tiles, and - /// bin footprint for binning records. + /// Adds one draw object's footprint, clipped to the range data bounds and the open layers, to + /// the scratch estimates of the range. /// + /// + /// The footprint is measured on the tile grid of the range. It adds these values: + /// + /// the tile-row span for path rows; + /// the tile-crossing bound for segments; + /// the row-span bound for path tiles; + /// the bin footprint for binning records; + /// the PTCL words, the row bounds and the blend depth regions. + /// + /// /// The draw object's root-target-local raster interest bounds. /// The draw object's linearized or GPU-expanded line count. /// @@ -3523,9 +4301,17 @@ private void AccumulateDrawRowEstimate(Rectangle absoluteInterest, int lineCount /// to fall back to the bounding-box diagonal bound (clips). /// /// The PTCL word count of the draw's paint command. - private void AccumulateDrawRowEstimateLocal(Rectangle localBounds, int lineCount, long tileCrossings, int paintWords) + /// Whether the draw opens a clip or a layer, which the GPU also closes with an EndClip. + /// The source segments for the row-span tile bound, or . + private void AccumulateDrawRowEstimateLocal( + Rectangle localBounds, + int lineCount, + long tileCrossings, + int paintWords, + bool isClip, + in RowSpanSource spanSource) { - Rectangle clippedBounds = Rectangle.Intersect(localBounds, new Rectangle(0, 0, this.rootTargetBounds.Width, this.rootTargetBounds.Height)); + Rectangle clippedBounds = Rectangle.Intersect(localBounds, this.rangeDataBounds); if (this.openLayerBounds is { Count: > 0 }) { @@ -3540,6 +4326,9 @@ private void AccumulateDrawRowEstimateLocal(Rectangle localBounds, int lineCount return; } + // The tile grid of the range starts at the range target. + clippedBounds.Offset(-this.rangeDataBounds.X, -this.rangeDataBounds.Y); + int tileY0 = clippedBounds.Top / TileHeight; int tileY1 = DivideRoundUp(clippedBounds.Bottom, TileHeight); long rowCount = Math.Max(tileY1 - tileY0, 0); @@ -3555,14 +4344,124 @@ private void AccumulateDrawRowEstimateLocal(Rectangle localBounds, int lineCount long crossings = tileCrossings >= 0 ? Math.Min(tileCrossings, boundingBoxCrossings) : boundingBoxCrossings; this.estimatedTileCrossings += crossings; - // Every crossing belongs to one (draw, tile) pair with segments, and such a pair writes - // one CMD_FILL plus the draw's paint command. - this.estimatedPtclWords += crossings * (PtclFillWords + paintWords); + // tile_alloc gives each row the span between its first and last touched tile, so the + // tiles of the clipped bounds bound the path tiles. When that area is larger than the + // crossings plus the rows, the geometry is sparse and the row-span bound can be smaller. + // The row walk costs about one step for each row that a segment spans, so dense draws, + // whose area is close to their crossings, skip it. + int tileX0 = clippedBounds.Left / TileWidth; + int tileX1 = DivideRoundUp(clippedBounds.Right, TileWidth); + long tiles = (long)(tileX1 - tileX0) * rowCount; + + // coarse writes for every covered tile: a fill command (6 words) on a tile with + // segments, a solid command (2 words) otherwise, and the paint command. A clip or a + // layer also writes its PtclClipWords. + int ptclWordsPerTile = PtclFillWords + paintWords + (isClip ? PtclClipWords : 0); + + // A stroke outline is thin, so its row spans can be far smaller than its bbox. A fill + // is usually solid, so its row spans are close to the bbox width and it keeps the area + // bound. A stroke with a positive outline radius and a sparse bbox gets a row bound: + // - One segment: the capsule width in one row, the same in every row, O(1). + // - A polyline: the row walk, which adds its span of each row to the row bounds. + // A chunk window then gets the spans of its own rows instead of the bbox width. + WebGPUSceneRowBounds rowBounds = this.GetOrCreateRowBounds(); + int areaWidthTiles = tileX1 - tileX0; + if (spanSource.IsValid && spanSource.Radius > 0F && tiles > crossings + rowCount) + { + if (spanSource.Geometry is null) + { + areaWidthTiles = Math.Min(areaWidthTiles, GetSegmentRowSpanTiles(spanSource.Start, spanSource.End, spanSource.Radius)); + tiles = Math.Min(tiles, (long)areaWidthTiles * rowCount); + } + else if (spanSource.Geometry.Contours is [{ SegmentCount: 1, PointCount: 2 } contour]) + { + // A two-point polyline is one segment, so it uses the same O(1) bound. + IReadOnlyList points = spanSource.Geometry.Points; + areaWidthTiles = Math.Min( + areaWidthTiles, + GetSegmentRowSpanTiles(points[contour.PointStart], points[contour.PointStart + 1], spanSource.Radius)); + + tiles = Math.Min(tiles, (long)areaWidthTiles * rowCount); + } + else if (tiles >= RowWalkMinimumTiles) + { + Vector2 gridTranslate = spanSource.Translate - new Vector2(this.rangeDataBounds.X, this.rangeDataBounds.Y); + tiles = Math.Min(tiles, BoundRowSpanTiles(spanSource, gridTranslate, tileX0, tileX1, tileY0, tileY1, rowBounds, ptclWordsPerTile)); + areaWidthTiles = 0; + } + } + + this.estimatedPathTiles += tiles; - // Binning emits one record per (draw, 16x16-tile bin) pair the draw's bounds touch. - long binsWide = (clippedBounds.Width / (TileWidth * 16)) + 2; - long binsHigh = (clippedBounds.Height / (TileHeight * 16)) + 2; - this.estimatedBinFootprint += binsWide * binsHigh; + long segmentTiles = Math.Min(crossings, tiles); + long ptclWords = (segmentTiles * (PtclFillWords - 2)) + (tiles * (2 + paintWords)); + if (isClip) + { + ptclWords += tiles * PtclClipWords; + } + + this.estimatedPtclWords += ptclWords; + + // The per-row crossing bound needs the slopes of the GPU lines, so it exists only when + // the GPU translates the points. Otherwise every row can hold all the crossings. + long crossingsPerRow = spanSource.IsValid ? Math.Min(spanSource.CrossingsPerRow, crossings) : crossings; + long endCrossings = spanSource.IsValid ? Math.Min(spanSource.EndCrossings, crossings) : 0; + rowBounds.AddDraw(tileY0, tileY1, areaWidthTiles, tiles, crossings, ptclWords, ptclWordsPerTile, crossingsPerRow, endCrossings); + + // Binning emits one record per (draw, 16x16-tile bin) pair the draw's bounds touch. The + // EndClip of a clip or a layer bins again with the same bounds. + const int binSize = TileWidth * 16; + long bins = (long)(DivideRoundUp(clippedBounds.Right, binSize) - (clippedBounds.Left / binSize)) * + (DivideRoundUp(clippedBounds.Bottom, binSize) - (clippedBounds.Top / binSize)); + + this.estimatedBinFootprint += isClip ? bins * 2 : bins; + + // A clip or a layer raises the blend depth of its tiles. Tiles past BlendStackSplit spill + // TileWidth * TileHeight slots for each level, so each depth keeps the union of the + // regions opened at it. Every depth is kept, not only the depths past BlendStackSplit. + // A retained scene read in place adds its depths to the depth of the outer clips and + // layers. + if (isClip) + { + this.AddBlendDepthRegion(this.blendDepth - 1, clippedBounds); + } + } + + /// + /// Adds a region to the union region of one blend depth. + /// + /// The blend depth minus one. + /// The region in tile-grid pixels of the current range. + private void AddBlendDepthRegion(int index, Rectangle region) + { + List levels = this.blendLevels ??= []; + while (levels.Count <= index) + { + levels.Add(Rectangle.Empty); + } + + levels[index] = levels[index].IsEmpty ? region : Rectangle.Union(levels[index], region); + } + + /// + /// Adds the blend depth regions of a retained scene read in place at the current depth. + /// + /// The union region of each blend depth of the retained scene, in root-target-local pixels. + private void AddRetainedBlendDepthRegions(Rectangle[] regions) + { + // The retained scene uses the root target coordinates. The regions of this encoding + // use the tile grid of the current range, which starts at the range target. + Rectangle rangeGrid = new(0, 0, this.rangeDataBounds.Width, this.rangeDataBounds.Height); + for (int i = 0; i < regions.Length; i++) + { + Rectangle region = regions[i]; + region.Offset(-this.rangeDataBounds.X, -this.rangeDataBounds.Y); + region = Rectangle.Intersect(region, rangeGrid); + if (region.Width > 0 && region.Height > 0) + { + this.AddBlendDepthRegion(this.blendDepth + i, region); + } + } } /// @@ -3601,9 +4500,11 @@ private void AppendEndLayer() /// private void AppendEndClip() { - // EndClip is fixed-size: one draw tag plus a zero-data path terminator. The canvas - // records balanced command ranges, so this method does not inspect local stack state. + // EndClip is fixed-size: one draw tag plus a zero-data path terminator. The blend depth + // counts across the ranges of this encoding, and a partition replays its seed clips, so + // every EndClip closes a depth that this encoding opened. ReserveEndLayerCapacity(ref this.PathTags, ref this.DrawTags); + this.blendDepth--; this.DrawTags.Add(GpuSceneDrawTag.EndClip); this.PathTags.Add(PackPathTag(PathTag.Path)); this.PathCount++; @@ -3611,6 +4512,80 @@ private void AppendEndClip() } } + /// + /// The source segments of one draw for the row-span tile bound. + /// + private readonly struct RowSpanSource + { + /// + /// Initializes a new instance of the struct. + /// + /// The source geometry, or for one segment. + /// The segment start when is . + /// The segment end when is . + /// The translation from source points to root-target-local device pixels. + /// The radius around the segments that contains the GPU lines. + /// An upper bound for the tile crossings of the GPU body lines in one tile row. + /// The tile crossings of the other GPU lines, such as stroke joins and caps. + public RowSpanSource(LinearGeometry? geometry, Vector2 start, Vector2 end, Vector2 translate, float radius, long crossingsPerRow, long endCrossings) + { + this.Geometry = geometry; + this.Start = start; + this.End = end; + this.Translate = translate; + this.Radius = radius; + this.CrossingsPerRow = crossingsPerRow; + this.EndCrossings = endCrossings; + this.IsValid = true; + } + + /// + /// Gets a source that has no segments. The bound then uses the bounding box. + /// + public static RowSpanSource None => default; + + /// + /// Gets the source geometry, or for one segment. + /// + public LinearGeometry? Geometry { get; } + + /// + /// Gets the segment start when is . + /// + public Vector2 Start { get; } + + /// + /// Gets the segment end when is . + /// + public Vector2 End { get; } + + /// + /// Gets the translation from source points to root-target-local device pixels. + /// + public Vector2 Translate { get; } + + /// + /// Gets the radius around the segments that contains the GPU lines. + /// + public float Radius { get; } + + /// + /// Gets an upper bound for the tile crossings of the GPU body lines in one tile row. + /// + public long CrossingsPerRow { get; } + + /// + /// Gets the tile crossings of the other GPU lines, such as stroke joins and caps. They + /// are near the segment ends, so all of them can be in one row. + /// + public long EndCrossings { get; } + + /// + /// Gets a value indicating whether the source has segments. + /// + public bool IsValid { get; } + } + /// /// Owns one command-range encoding produced by the parallel scene encoder. /// @@ -3857,6 +4832,26 @@ public ReadOnlySpan PathGradientData /// public long EstimatedBinFootprint { get; } + /// + /// Gets the path-tile bound of the partition. + /// + public long EstimatedPathTiles { get; init; } + + /// + /// Gets the union region of each clip and layer depth of the partition, starting at depth 1. + /// + public Rectangle[] BlendDepthRegions { get; init; } = []; + + /// + /// Gets the row bounds of the partition, or when it has no draws. + /// + public WebGPUSceneRowBounds? RowBounds { get; init; } + + /// + /// Gets the row bounds instances that this partition disposes. + /// + public WebGPUSceneRowBounds[] OwnedRowBounds { get; init; } = []; + /// /// Gets the unpadded path-tag byte count. /// @@ -3963,7 +4958,13 @@ public static SceneEncodingPartition Detach(ref SupportedSubsetSceneEncoding enc styleWordCount, pathGradientDataWordCount, encoding.ProfileStorage, - encoding.ProfileFills); + encoding.ProfileFills) + { + EstimatedPathTiles = encoding.EstimatedPathTiles, + BlendDepthRegions = encoding.BlendLevels is { } blendLevels ? [.. blendLevels] : [], + RowBounds = encoding.RowBounds, + OwnedRowBounds = encoding.DetachRowBounds() + }; } /// @@ -3986,6 +4987,10 @@ public void Dispose() this.gradientPixelsOwner?.Dispose(); this.pathGradientDataOwner?.Dispose(); this.profileStorage?.Dispose(); + for (int i = 0; i < this.OwnedRowBounds.Length; i++) + { + this.OwnedRowBounds[i].Dispose(); + } } } @@ -4045,12 +5050,61 @@ public static WebGPUEncodedScene Resolve( sceneWordCount = checked(profileRecordsBase + 2 + ((xProfileCount + yProfileCount) * 3)); } + // Retained scenes inside layers add their elements to the virtual streams, so the + // counts that size the dispatch are joined totals. The physical stream bases and + // extents above stay those of this scene. The segment table ends the scene data. + List? insertions = encoding.Insertions; + JoinedSceneCounts joined = new(ref encoding, drawTagCount, drawDataWordCount); + SceneSegmentTableWriter? segmentWriter = null; + int segmentCount = 0; + int segmentTableBase = 0; + int blockBrushDataWordCount = 0; + if (insertions is not null) + { + for (int i = 0; i < insertions.Count; i++) + { + joined.Add(insertions[i].Scene.EncodedScene); + } + + // A scene with no ordered operations renders as one dispatch, which reads one table + // for all its streams. Each range of an ordered scene is a dispatch of its own, so + // each range that reads retained scenes has a table of its own. + int tableEntryCount; + if (operations.Length == 0) + { + segmentCount = SceneSegmentTableWriter.CountSegments(CollectionsMarshal.AsSpan(insertions)); + tableEntryCount = segmentCount; + } + else + { + tableEntryCount = encoding.RangeSegmentEntryCount; + } + + // The shaders pack the segment index and the draw object index into one word. + // TryAppendRetainedScene and the final checks of TryEncode and TryEncodeOrdered refuse + // a scene that exceeds these limits, so they hold here. + // A range has at most the segments and draw objects of the whole scene. + Debug.Assert(encoding.SegmentCount <= MaxSceneSegmentCount, "The segment count exceeds the coarse packing limit."); + Debug.Assert(joined.DrawTagCount < SegmentedDrawObjectLimit, "The draw-object count exceeds the coarse packing limit."); + segmentTableBase = AlignUp(sceneWordCount, 2); + sceneWordCount = checked(segmentTableBase + (tableEntryCount * SceneSegmentEntryWordCount)); + + // The blocks follow the scene data, and their gradient rows follow those of this + // scene. Each table places the brush data and atlas rows of the blocks. + segmentWriter = new SceneSegmentTableWriter( + CollectionsMarshal.AsSpan(insertions), + AlignUp(sceneWordCount, 2), + encoding.GradientRowCount); + + blockBrushDataWordCount = segmentWriter.BlockBrushDataWordCount; + } + GpuSceneLayout layout = new( - (uint)drawTagCount, - (uint)encoding.PathCount, - (uint)encoding.ClipCount, - (uint)(encoding.InfoWordCount + pathGradientDataWordCount), - (uint)encoding.InfoWordCount, + (uint)joined.DrawTagCount, + (uint)joined.PathCount, + (uint)joined.ClipCount, + (uint)(joined.InfoWordCount + pathGradientDataWordCount + blockBrushDataWordCount), + (uint)joined.InfoWordCount, 0U, 0U, (uint)pathTagWordCount, @@ -4086,9 +5140,59 @@ public static WebGPUEncodedScene Resolve( yProfileCount); } + // The path-tag dispatch covers the virtual path-tag words, which the table defines. + int virtualPathTagByteCount = pathTagByteCount; + if (segmentWriter is not null) + { + SceneSegmentSource source = new( + new SceneStreamWindow(0, 0, pathTagWordCount), + new SceneStreamWindow(pathTagWordCount, 0, pathDataWordCount), + new SceneStreamWindow(transformBase, 0, transformWordCount), + new SceneStreamWindow(styleBase, 0, styleWordCount), + new SceneStreamWindow(drawTagBase, 0, drawTagCount), + new SceneStreamWindow(drawDataBase, 0, drawDataWordCount), + profileSlotsBase, + profileRecordsBase); + + // The brush data of the blocks follows the info prefix and the brush data of + // the dispatch. The atlas rows of the blocks follow the rows of its images. + Span tables = sceneDataOwner.Memory.Span[segmentTableBase..sceneWordCount]; + ReadOnlySpan allInsertions = CollectionsMarshal.AsSpan(insertions); + if (operations.Length == 0) + { + int virtualPathTagWordCount = segmentWriter.Write( + tables, + source, + allInsertions, + joined.InfoWordCount, + joined.InfoWordCount + pathGradientDataWordCount, + WebGPUSceneResources.GetImageAtlasHeight(encoding.Images, null)); + + virtualPathTagByteCount = checked(virtualPathTagWordCount * 4); + } + else + { + // Every retained scene of an ordered scene is in a range, and the range + // already holds its joined counts. + foreach (WebGPUSceneRange range in encoding.SegmentedRanges!) + { + WebGPUSceneRangeSegments segments = range.Segments!; + int virtualPathTagWordCount = segmentWriter.Write( + tables.Slice(segments.SegmentEntryStart * SceneSegmentEntryWordCount, segments.SegmentCount * SceneSegmentEntryWordCount), + source.Slice(range), + allInsertions.Slice(segments.InsertionStart, segments.InsertionCount), + range.InfoWordCount, + range.InfoWordCount + pathGradientDataWordCount, + WebGPUSceneResources.GetImageAtlasHeight(encoding.Images, range)); + + Debug.Assert(virtualPathTagWordCount * 4 == range.PathTagByteCount, "The range path-tag count does not match its segment table."); + } + } + } + WebGPUEncodedScene resolvedScene = new( targetBounds.Size, - encoding.InfoWordCount, + joined.InfoWordCount, sceneDataOwner, sceneWordCount, DetachGradientPixels(ref encoding), @@ -4100,33 +5204,642 @@ public static WebGPUEncodedScene Resolve( encoding.GradientRamps, encoding.GradientRowCount, layout, - encoding.FillCount, - encoding.PathCount, - encoding.LineCount, - pathTagByteCount, + joined.FillCount, + joined.PathCount, + joined.LineCount, + virtualPathTagByteCount, pathTagWordCount, pathDataWordCount, - drawTagCount, - drawDataWordCount, + joined.DrawTagCount, + joined.DrawDataWordCount, transformWordCount, styleWordCount, - encoding.ClipCount, - encoding.FillCount, - Math.Max(encoding.EstimatedPathRowCount, encoding.PathCount), + joined.ClipCount, + joined.FillCount, + Math.Max(joined.EstimatedPathRowCount, joined.PathCount), DivideRoundUp(targetBounds.Width, TileWidth), DivideRoundUp(targetBounds.Height, TileHeight), + joined.EstimatedTileCrossings, + joined.EstimatedPtclWords, + joined.EstimatedBinFootprint, + operations); + + resolvedScene.SetProfileRegions((uint)profileSlotsBase, (uint)profileRecordsBase); + resolvedScene.SetScratchBounds( + joined.EstimatedPathTiles, + WebGPUEncodedScene.GetBlendSpillSlots(CollectionsMarshal.AsSpan(encoding.BlendLevels), int.MinValue, int.MaxValue), + encoding.BlendLevels is { } blendLevels ? [.. blendLevels] : []); + + resolvedScene.SetRowBounds(encoding.RowBounds, encoding.DetachRowBounds()); + + if (segmentWriter is not null) + { + resolvedScene.SetSegments( + [.. insertions!], + segmentWriter.Blocks, + segmentCount, + segmentTableBase, + segmentWriter.SceneBufferWordCount, + segmentWriter.BlockBrushDataWordCount, + segmentWriter.BlockGradientRowCount); + } + + return resolvedScene; + } + catch + { + sceneDataOwner.Dispose(); + throw; + } + } + + /// + /// Holds the counts that size a dispatch: the counts of one encoding plus those of the + /// retained scenes that it reads in place. + /// + public struct JoinedSceneCounts + { + private bool hasInsertions; + + /// + /// Initializes a new instance of the struct with the counts of one encoding. + /// + /// The encoding. + /// The draw-tag count of the encoding. + /// The draw-data word count of the encoding. + public JoinedSceneCounts(ref SupportedSubsetSceneEncoding encoding, int drawTagCount, int drawDataWordCount) + : this( + encoding.FillCount, + encoding.PathCount, + encoding.LineCount, + encoding.ClipCount, + encoding.InfoWordCount, + drawTagCount, + drawDataWordCount, + encoding.EstimatedPathRowCount, encoding.EstimatedTileCrossings, encoding.EstimatedPtclWords, encoding.EstimatedBinFootprint, - operations); + encoding.EstimatedPathTiles) + { + } + + /// + /// Initializes a new instance of the struct with the own counts of one scene or range. + /// + /// The fill count. + /// The path count. + /// The line count. + /// The clip record count. + /// The info word count. + /// The draw-tag count. + /// The draw-data word count. + /// The estimated path-row count. + /// The estimated tile-crossing count. + /// The estimated dynamic PTCL word count. + /// The estimated binning record count. + /// The path-tile bound. + public JoinedSceneCounts( + int fillCount, + int pathCount, + int lineCount, + int clipCount, + int infoWordCount, + int drawTagCount, + int drawDataWordCount, + int estimatedPathRowCount, + long estimatedTileCrossings, + long estimatedPtclWords, + long estimatedBinFootprint, + long estimatedPathTiles) + { + this.hasInsertions = false; + this.FillCount = fillCount; + this.PathCount = pathCount; + this.LineCount = lineCount; + this.ClipCount = clipCount; + this.InfoWordCount = infoWordCount; + this.DrawTagCount = drawTagCount; + this.DrawDataWordCount = drawDataWordCount; + this.EstimatedPathRowCount = estimatedPathRowCount; + this.EstimatedTileCrossings = estimatedTileCrossings; + this.EstimatedPtclWords = estimatedPtclWords; + this.EstimatedBinFootprint = estimatedBinFootprint; + this.EstimatedPathTiles = estimatedPathTiles; + } + + /// + /// Gets the fill count. + /// + public int FillCount { get; private set; } + + /// + /// Gets the path count. + /// + public int PathCount { get; private set; } + + /// + /// Gets the line count. + /// + public int LineCount { get; private set; } + + /// + /// Gets the clip record count. + /// + public int ClipCount { get; private set; } + + /// + /// Gets the info word count. + /// + public int InfoWordCount { get; private set; } + + /// + /// Gets the draw-tag count. + /// + public int DrawTagCount { get; private set; } + + /// + /// Gets the draw-data word count. + /// + public int DrawDataWordCount { get; private set; } + + /// + /// Gets the estimated path-row count. + /// + public int EstimatedPathRowCount { get; private set; } + + /// + /// Gets the estimated tile-crossing count. + /// + public long EstimatedTileCrossings { get; private set; } + + /// + /// Gets the estimated dynamic PTCL word count. + /// + public long EstimatedPtclWords { get; private set; } + + /// + /// Gets the estimated binning record count. + /// + public long EstimatedBinFootprint { get; private set; } + + /// + /// Gets the path-tile bound. + /// + public long EstimatedPathTiles { get; private set; } + + /// + /// Adds the counts of one retained scene. Its counts already include its own retained scenes. + /// + /// The retained scene. + public void Add(WebGPUEncodedScene scene) + { + // With a segment table, every aliased fill command follows a two-word + // CMD_PROFILE_TABLE (coarse.wgsl). Two words for each tile crossing bound these + // commands, for this encoding and for each retained scene. + if (!this.hasInsertions) + { + this.hasInsertions = true; + this.EstimatedPtclWords += 2 * this.EstimatedTileCrossings; + } + + this.FillCount = checked(this.FillCount + scene.FillCount); + this.PathCount = checked(this.PathCount + scene.PathCount); + this.LineCount = checked(this.LineCount + scene.LineCount); + this.ClipCount = checked(this.ClipCount + scene.ClipCount); + this.InfoWordCount = checked(this.InfoWordCount + scene.InfoWordCount); + this.DrawTagCount = checked(this.DrawTagCount + scene.DrawTagCount); + this.DrawDataWordCount = checked(this.DrawDataWordCount + scene.DrawDataWordCount); + this.EstimatedPathRowCount = checked(this.EstimatedPathRowCount + scene.TotalPathRowCount); + this.EstimatedTileCrossings += scene.EstimatedTileCrossings; + this.EstimatedPtclWords += scene.EstimatedPtclWords + (2 * scene.EstimatedTileCrossings); + this.EstimatedBinFootprint += scene.EstimatedBinFootprint; + this.EstimatedPathTiles += scene.EstimatedPathTiles; + } + } + + /// + /// Describes one physical stream window: where stream position zero is in the scene data, + /// and the stream positions that the window covers. + /// + public readonly struct SceneStreamWindow + { + /// + /// Initializes a new instance of the struct. + /// + /// The scene-data word of stream position zero. + /// The stream position of the window start. + /// The stream position of the window end. + public SceneStreamWindow(int baseWord, int start, int end) + { + this.Base = baseWord; + this.Start = start; + this.End = end; + } + + /// + /// Gets the scene-data word of stream position zero. + /// + public int Base { get; } + + /// + /// Gets the stream position of the window start. + /// + public int Start { get; } + + /// + /// Gets the stream position of the window end. + /// + public int End { get; } + } + + /// + /// Describes the physical streams of one scene, or of one range of an ordered scene, in its + /// scene data. Stream positions are positions in the streams of the scene, the same as the + /// positions of its insertions. + /// + public readonly struct SceneSegmentSource + { + /// + /// Initializes a new instance of the struct. + /// + /// The path-tag word window. The path tags start at word zero. + /// The path-data word window. + /// The transform word window. + /// The style word window. + /// The draw-tag window. + /// The draw-data word window. + /// The word offset of the profile slot table, or zero. + /// The word offset of the profile record table, or zero. + public SceneSegmentSource( + in SceneStreamWindow pathTags, + in SceneStreamWindow pathData, + in SceneStreamWindow transforms, + in SceneStreamWindow styles, + in SceneStreamWindow drawTags, + in SceneStreamWindow drawData, + int profileSlotsBase, + int profileRecordsBase) + { + this.PathTags = pathTags; + this.PathData = pathData; + this.Transforms = transforms; + this.Styles = styles; + this.DrawTags = drawTags; + this.DrawData = drawData; + this.ProfileSlotsBase = profileSlotsBase; + this.ProfileRecordsBase = profileRecordsBase; + } + + /// + /// Gets the path-tag word window. The path tags start at word zero. + /// + public SceneStreamWindow PathTags { get; } + + /// + /// Gets the path-data word window. + /// + public SceneStreamWindow PathData { get; } + + /// + /// Gets the transform word window. + /// + public SceneStreamWindow Transforms { get; } + + /// + /// Gets the style word window. + /// + public SceneStreamWindow Styles { get; } + + /// + /// Gets the draw-tag window. + /// + public SceneStreamWindow DrawTags { get; } + + /// + /// Gets the draw-data word window. + /// + public SceneStreamWindow DrawData { get; } + + /// + /// Gets the word offset of the profile slot table, or zero when the scene has none. + /// + public int ProfileSlotsBase { get; } + + /// + /// Gets the word offset of the profile record table, or zero when the scene has none. + /// + public int ProfileRecordsBase { get; } + + /// + /// Creates the source of every stream of an encoded scene. The draw-tag and draw-data + /// extents come from the layout, because the counts of a scene with retained scenes + /// are joined totals. + /// + /// The encoded scene. + /// The physical stream source. + public static SceneSegmentSource From(WebGPUEncodedScene scene) + { + GpuSceneLayout layout = scene.Layout; + return new SceneSegmentSource( + new SceneStreamWindow(0, 0, scene.PathTagWordCount), + new SceneStreamWindow((int)layout.PathDataBase, 0, scene.PathDataWordCount), + new SceneStreamWindow((int)layout.TransformBase, 0, scene.TransformWordCount), + new SceneStreamWindow((int)layout.StyleBase, 0, scene.StyleWordCount), + new SceneStreamWindow((int)layout.DrawTagBase, 0, (int)(layout.DrawDataBase - layout.DrawTagBase)), + new SceneStreamWindow((int)layout.DrawDataBase, 0, (int)(layout.TransformBase - layout.DrawDataBase)), + (int)scene.ProfileSlotsBase, + (int)scene.ProfileRecordsBase); + } + + /// + /// Creates the source of the own streams of one range of this scene. + /// + /// The range. It reads retained scenes in place. + /// The physical stream source of the range. + public SceneSegmentSource Slice(in WebGPUSceneRange range) + { + // The joined counts of the range size the dispatch. The own extents of the joined + // streams are in its segments. + WebGPUSceneRangeSegments segments = range.Segments!; + return new SceneSegmentSource( + new SceneStreamWindow(this.PathTags.Base, range.PathTagWordStart, range.PathTagWordStart + segments.PathTagWordCount), + new SceneStreamWindow(this.PathData.Base, range.PathDataWordStart, range.PathDataWordStart + range.PathDataWordCount), + new SceneStreamWindow(this.Transforms.Base, range.TransformWordStart, range.TransformWordStart + range.TransformWordCount), + new SceneStreamWindow(this.Styles.Base, range.StyleWordStart, range.StyleWordStart + range.StyleWordCount), + new SceneStreamWindow(this.DrawTags.Base, range.DrawTagStart, range.DrawTagStart + segments.DrawTagCount), + new SceneStreamWindow(this.DrawData.Base, range.DrawDataWordStart, range.DrawDataWordStart + segments.DrawDataWordCount), + this.ProfileSlotsBase, + this.ProfileRecordsBase); + } + } + + /// + /// Builds the resident blocks and the scene segment tables of one scene that reads retained + /// scenes in place. scene_segment.wgsl reads the tables. + /// + /// + /// + /// The segments are the pieces of every virtual stream, in stream order: + /// + /// the outer elements before the first retained scene; + /// the retained scene, as its own pieces when it reads more retained scenes; + /// the next outer elements, and so on. + /// + /// Each entry stores the virtual start of its path tags and draw objects, and a wrapping bias + /// for each stream. A shader then reads scene[bias + virtual offset]. A retained scene drawn + /// several times has one block. + /// + /// + /// A scene with no ordered operations has one table for all its streams. An ordered scene has + /// one table for each range that reads retained scenes, because each dispatch reads one range. + /// The blocks are shared by all tables. + /// + /// + public sealed class SceneSegmentTableWriter + { + private readonly List blocks = []; + private readonly int firstBlockGradientRow; + private int nextBlockWord; + private int nextBrushDataOffset; + private int nextGradientRow; + private int nextAtlasRowOffset; + + // The bases of the block brush data and of the block atlas rows for the table that is + // written now. Each table has its own info prefix and its own atlas rows before them. + private int blockBrushDataBase; + private int blockAtlasYBase; + + // The virtual start of the next segment in each stream. The transform cursor counts + // transform records, because the shaders index transforms by record. + private uint virtualPathTag; + private uint virtualPathData; + private uint virtualTransform; + private uint virtualStyle; + private uint virtualDrawTag; + private uint virtualDrawData; + private int entryIndex; + + /// + /// Initializes a new instance of the class and + /// assigns one block to each distinct retained scene, in first-use order. + /// + /// The retained scenes of the root scene, in stream order. + /// The scene-buffer word of the first block, after the root scene data. + /// The root gradient-ramp row count. + public SceneSegmentTableWriter(ReadOnlySpan insertions, int firstBlockWord, int rootGradientRowCount) + { + this.firstBlockGradientRow = rootGradientRowCount; + this.nextBlockWord = firstBlockWord; + this.nextGradientRow = rootGradientRowCount; + this.AddBlocks(insertions); + this.Blocks = [.. this.blocks]; + } + + /// + /// Gets the resident blocks, in scene-buffer order. + /// + public WebGPUSceneBlock[] Blocks { get; } + + /// + /// Gets the word length of the scene buffer: the root scene data, then the blocks. + /// + public int SceneBufferWordCount => this.nextBlockWord; + + /// + /// Gets the brush-data word count of the blocks. + /// + public int BlockBrushDataWordCount => this.nextBrushDataOffset; + + /// + /// Gets the gradient-ramp row count of the blocks. + /// + public int BlockGradientRowCount => this.nextGradientRow - this.firstBlockGradientRow; + + /// + /// Counts the segments of a scene or range that reads the given retained scenes in place. + /// + /// The retained scenes, in stream order. + /// The segment count. + public static int CountSegments(ReadOnlySpan insertions) + { + // N retained scenes divide the outer streams into N + 1 pieces. Each retained + // scene adds its own pieces. + int count = insertions.Length + 1; + for (int i = 0; i < insertions.Length; i++) + { + count = checked(count + CountSegments(insertions[i].Scene.EncodedScene.Insertions)); + } + + return count; + } + + /// + /// Writes one table: the table of a scene, or the table of one range of an ordered scene. + /// + /// The table words, for each segment. + /// The physical streams of the scene or range. + /// The retained scenes of the scene or range, in stream order. + /// The info-buffer word of the brush data of the scene: the info word count of the dispatch. + /// The info-buffer word of the brush data of the first block. + /// The first image-atlas row of the first block. + /// The virtual path-tag word count. + public int Write( + Span table, + in SceneSegmentSource source, + ReadOnlySpan insertions, + int brushDataBase, + int blockBrushDataBase, + int blockAtlasYBase) + { + // Each table indexes its virtual streams from zero, because each dispatch reads + // one scene or one range from its start. + this.virtualPathTag = 0; + this.virtualPathData = 0; + this.virtualTransform = 0; + this.virtualStyle = 0; + this.virtualDrawTag = 0; + this.virtualDrawData = 0; + this.entryIndex = 0; + this.blockBrushDataBase = blockBrushDataBase; + this.blockAtlasYBase = blockAtlasYBase; + this.WriteScene(table, source, 0, brushDataBase, 0, 0, insertions); + return checked((int)this.virtualPathTag); + } + + /// + /// Assigns a block to each distinct retained scene that is not yet in a block, depth first. + /// + /// The retained scenes, in stream order. + private void AddBlocks(ReadOnlySpan insertions) + { + for (int i = 0; i < insertions.Length; i++) + { + WebGPUEncodedScene scene = insertions[i].Scene.EncodedScene; + if (this.FindBlock(scene) < 0) + { + // Even block offsets keep half the block offset exact in the profile slot + // origin (SCENE_SEGMENT_PROFILE_SLOTS). + int offset = AlignUp(this.nextBlockWord, 2); + this.blocks.Add(new WebGPUSceneBlock(scene, offset, this.nextBrushDataOffset, this.nextGradientRow, this.nextAtlasRowOffset)); + this.nextBlockWord = checked(offset + scene.SceneWordCount); + this.nextBrushDataOffset = checked(this.nextBrushDataOffset + scene.PathGradientDataWordCount); + this.nextGradientRow = checked(this.nextGradientRow + scene.GradientRowCount); + this.nextAtlasRowOffset = checked(this.nextAtlasRowOffset + WebGPUSceneResources.GetImageAtlasHeight(scene.Images, null)); + } - resolvedScene.SetProfileRegions((uint)profileSlotsBase, (uint)profileRecordsBase); - return resolvedScene; + this.AddBlocks(scene.Insertions); + } } - catch + + /// + /// Gets the block index of a retained scene, or -1 when it has no block. + /// + /// The retained scene. + /// The block index. + private int FindBlock(WebGPUEncodedScene scene) { - sceneDataOwner.Dispose(); - throw; + for (int i = 0; i < this.blocks.Count; i++) + { + if (ReferenceEquals(this.blocks[i].Scene, scene)) + { + return i; + } + } + + return -1; + } + + /// + /// Writes the entries of one scene or range: one for each piece of its own streams, and + /// the entries of each retained scene between them. + /// + /// The table words. + /// The physical streams of the scene or range. + /// The scene-buffer word of the scene data. + /// The info-buffer word of the scene brush data. + /// The first gradient-ramp row of the scene. + /// The first image-atlas row of the scene. + /// The retained scenes of the scene or range, in stream order. + private void WriteScene( + Span table, + in SceneSegmentSource source, + int blockWord, + int brushDataBase, + int gradientRowBase, + int atlasYBase, + ReadOnlySpan insertions) + { + // The start of the next piece in the scene's own streams. + int pathTag = source.PathTags.Start; + int pathData = source.PathData.Start; + int transform = source.Transforms.Start; + int style = source.Styles.Start; + int drawTag = source.DrawTags.Start; + int drawData = source.DrawData.Start; + + // The extra iteration writes the piece after the last retained scene. + for (int i = 0; i <= insertions.Length; i++) + { + bool isTail = i == insertions.Length; + WebGPUSceneInsertion insertion = isTail ? default : insertions[i]; + int pathTagEnd = isTail ? source.PathTags.End : insertion.PathTagWord; + int pathDataEnd = isTail ? source.PathData.End : insertion.PathDataWord; + int transformEnd = isTail ? source.Transforms.End : insertion.TransformWord; + int styleEnd = isTail ? source.Styles.End : insertion.StyleWord; + int drawTagEnd = isTail ? source.DrawTags.End : insertion.DrawTag; + int drawDataEnd = isTail ? source.DrawData.End : insertion.DrawDataWord; + + // Each bias is the physical start minus the virtual start, in wrapping arithmetic. + Span entry = table.Slice(this.entryIndex * SceneSegmentEntryWordCount, SceneSegmentEntryWordCount); + entry[0] = this.virtualPathTag; + entry[1] = unchecked((uint)(blockWord + source.PathTags.Base + pathTag) - this.virtualPathTag); + entry[2] = unchecked((uint)(blockWord + source.PathData.Base + pathData) - this.virtualPathData); + entry[3] = unchecked((uint)(blockWord + source.Styles.Base + style) - this.virtualStyle); + entry[4] = unchecked((uint)(blockWord + source.Transforms.Base + transform) - (this.virtualTransform * TransformWordCount)); + entry[5] = this.virtualDrawTag; + entry[6] = unchecked((uint)(blockWord + source.DrawTags.Base + drawTag) - this.virtualDrawTag); + entry[7] = unchecked((uint)(blockWord + source.DrawData.Base + drawData) - this.virtualDrawData); + entry[8] = (uint)brushDataBase; + entry[9] = (uint)gradientRowBase; + entry[10] = (uint)atlasYBase; + + // The profile slot of a line is the slot base plus half its path-data word in + // the scene's own data. In the scene buffer that word moves by blockWord, so the + // origin moves by half of it. blockWord is even. + entry[11] = source.ProfileSlotsBase == 0 ? 0U : (uint)(source.ProfileSlotsBase + (blockWord / 2)); + entry[12] = source.ProfileRecordsBase == 0 ? 0U : (uint)(blockWord + source.ProfileRecordsBase); + this.entryIndex++; + + this.virtualPathTag += (uint)(pathTagEnd - pathTag); + this.virtualPathData += (uint)(pathDataEnd - pathData); + this.virtualTransform += (uint)((transformEnd - transform) / TransformWordCount); + this.virtualStyle += (uint)(styleEnd - style); + this.virtualDrawTag += (uint)(drawTagEnd - drawTag); + this.virtualDrawData += (uint)(drawDataEnd - drawData); + pathTag = pathTagEnd; + pathData = pathDataEnd; + transform = transformEnd; + style = styleEnd; + drawTag = drawTagEnd; + drawData = drawDataEnd; + + if (!isTail) + { + // The block bases of this table place the brush data and atlas rows of the + // block after the info prefix and atlas rows of the dispatch. + WebGPUEncodedScene scene = insertion.Scene.EncodedScene; + WebGPUSceneBlock block = this.blocks[this.FindBlock(scene)]; + this.WriteScene( + table, + SceneSegmentSource.From(scene), + block.SceneWordOffset, + this.blockBrushDataBase + block.BrushDataOffset, + block.GradientRowBase, + this.blockAtlasYBase + block.AtlasYOffset, + scene.Insertions); + } + } } } @@ -4506,6 +6219,8 @@ public static WebGPUEncodedScene Resolve( long estimatedTileCrossings = 0; long estimatedPtclWords = 0; long estimatedBinFootprint = 0; + long estimatedPathTiles = 0; + List blendDepthRegions = []; for (int i = 0; i < partitions.Length; i++) { @@ -4532,6 +6247,25 @@ public static WebGPUEncodedScene Resolve( estimatedTileCrossings += partition.EstimatedTileCrossings; estimatedPtclWords += partition.EstimatedPtclWords; estimatedBinFootprint += partition.EstimatedBinFootprint; + estimatedPathTiles += partition.EstimatedPathTiles; + + // Partitions do not cut through layers, and each partition replays the clips open + // at its boundary, so each partition counts the true depth of its clips. All + // partitions use the same tile grid, so the regions of one depth join by union. + Rectangle[] partitionRegions = partition.BlendDepthRegions; + for (int depth = 0; depth < partitionRegions.Length; depth++) + { + if (blendDepthRegions.Count <= depth) + { + blendDepthRegions.Add(partitionRegions[depth]); + } + else if (!partitionRegions[depth].IsEmpty) + { + blendDepthRegions[depth] = blendDepthRegions[depth].IsEmpty + ? partitionRegions[depth] + : Rectangle.Union(blendDepthRegions[depth], partitionRegions[depth]); + } + } } // Path tags are reduced in 256-word scan segments (pathtag_reduce WG_SIZE), so the @@ -4726,6 +6460,22 @@ public static WebGPUEncodedScene Resolve( []); resolved.SetProfileRegions((uint)profileSlotsBase, (uint)profileRecordsBase); + resolved.SetScratchBounds( + estimatedPathTiles, + WebGPUEncodedScene.GetBlendSpillSlots(CollectionsMarshal.AsSpan(blendDepthRegions), int.MinValue, int.MaxValue), + [.. blendDepthRegions]); + + // All partitions use the grid of the whole target, so their row bounds add. + WebGPUSceneRowBounds rowBounds = new(DivideRoundUp(targetBounds.Height, TileHeight), 0); + for (int i = 0; i < partitions.Length; i++) + { + if (partitions[i]!.RowBounds is { } partitionRowBounds) + { + rowBounds.Add(partitionRowBounds); + } + } + + resolved.SetRowBounds(rowBounds, [rowBounds]); sceneDataOwner = null; gradientPixelsOwner = null; pathGradientDataOwner = null; @@ -5350,6 +7100,7 @@ private static uint GetDrawTag(in ResolvedPathCommand command) /// Receives the summed conservative tile-crossing bound over every encoded segment, used to seed the /// segment and path-tile scratch buffers far more tightly than the draw's bounding-box diagonal. /// + /// Receives an upper bound for the tile crossings of the segments in one tile row. /// The number of encoded path objects: 1, or 0 when the geometry has no segments. private static int EncodePath( Point destinationOffset, @@ -5358,11 +7109,13 @@ private static int EncodePath( ref OwnedStream pathTags, ref OwnedStream pathData, out int lineCount, - out long tileCrossings) + out long tileCrossings, + out long rowCrossings) { Vector2 translate = new(destinationOffset.X - rootTargetBounds.X, destinationOffset.Y - rootTargetBounds.Y); lineCount = 0; tileCrossings = 0; + rowCrossings = 0; for (int i = 0; i < geometry.Contours.Count; i++) { @@ -5389,12 +7142,19 @@ private static int EncodePath( contourData[dataIndex++] = BitcastSingle(end.X); contourData[dataIndex++] = BitcastSingle(end.Y); contourTags[j] = PackPathTag(PathTag.LineToF32); - if (end.Y != current.Y) + + // path_lowering takes a line slot for every segment of nonzero length, + // horizontal ones too, so all of them count toward the line bound. + tileCrossings += CountLineTileCrossings(current, end, out long columnCrossings); + if (end != current) { + // In one tile row a segment visits at most all of its tile columns. One more + // column covers a translation of the tile alignment, and one more the f32 + // rounding of the GPU walk. This bound needs no division in this loop. lineCount++; + rowCrossings += columnCrossings + 3; } - tileCrossings += CountLineTileCrossings(current, end); current = end; } @@ -5424,15 +7184,223 @@ private static int EncodePath( /// /// The segment start point in pixels. /// The segment end point in pixels. + /// Receives the tile column boundaries crossed between the two endpoints. /// The number of tiles the segment covers. [MethodImpl(MethodImplOptions.AggressiveInlining)] - private static long CountLineTileCrossings(Vector2 start, Vector2 end) + private static long CountLineTileCrossings(Vector2 start, Vector2 end, out long columnCrossings) { Vector2 tileSize = new(TileWidth, TileHeight); Vector2 spanned = Vector2.Abs(Floor(end / tileSize) - Floor(start / tileSize)); + columnCrossings = (long)spanned.X; return 1 + (long)Sum(spanned); } + /// + /// Returns an upper bound for the tiles that one straight segment visits in a single tile row. + /// + /// + /// In a 16-pixel row band a segment spans at most min(|dx|, 16 |dx| / |dy|) pixels horizontally, + /// and a span of w pixels touches at most floor(w / 16) + 2 tile columns at any alignment. One + /// more tile absorbs the f32 rounding of the GPU walk. + /// + /// The segment start in pixels. + /// The segment end in pixels. + /// The tile bound for one tile row. + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private static long GetRowTileCrossings(Vector2 start, Vector2 end) + { + // A horizontal segment divides by zero, which gives +Infinity, so Min keeps the |dx| term. + Vector2 delta = Vector2.Abs(end - start); + return (long)(MathF.Min(delta.X / TileWidth, delta.X / delta.Y) + 3F); + } + + /// + /// Returns an upper bound for the tile span of the stroke of one segment in a single tile row. + /// + /// + /// The stroke lies inside the capsule of the segment with the outline radius. The capsule part + /// in one 16-pixel row band comes from the centerline part in that band widened by the radius + /// on both sides, which spans at most min(|dx|, (16 + 2r) |dx| / |dy|) pixels horizontally. + /// Widened by 2r, that span touches at most its width / 16 + 2 tile columns, and one more tile + /// covers the backdrop tile at x + 1 and the f32 rounding of the GPU walk. + /// + /// The segment start in pixels. + /// The segment end in pixels. + /// The outline radius in pixels. + /// The tile span bound for one tile row. + private static int GetSegmentRowSpanTiles(Vector2 start, Vector2 end, float radius) + { + // A horizontal segment divides by zero, which gives +Infinity, so Min keeps the |dx| term. + Vector2 delta = Vector2.Abs(end - start); + float centerlineWidth = MathF.Min(delta.X, (TileHeight + (2F * radius)) * delta.X / delta.Y); + return (int)MathF.Min(((centerlineWidth + (2F * radius)) / TileWidth) + 3F, int.MaxValue); + } + + /// + /// Returns an upper bound for the path tiles that tile_alloc allocates for one draw. + /// + /// + /// path_row_span gives each tile row the span from the first to the last tile that a line + /// touches. When a line passes outside the clipped bbox on one side, the span widens to that bbox + /// edge. The lines of a draw lie inside the capsules of its source segments with the stroke + /// radius, so the hull of the capsule extents in each row bounds that span. One extra tile on each side + /// covers the top-edge backdrop tile at x + 1 and the f32 rounding of the GPU walk. + /// + /// The source segments of the draw. + /// The translation from source points to tile-grid pixels. + /// The first tile column of the clipped bounds. + /// The tile column after the clipped bounds. + /// The first tile row of the clipped bounds. + /// The tile row after the clipped bounds. + /// The row bounds that receive the span of each row. + /// The largest PTCL word count that the draw writes in one tile. + /// The sum of the row-span tiles. + private static long BoundRowSpanTiles( + in RowSpanSource source, + Vector2 gridTranslate, + int tileX0, + int tileX1, + int tileY0, + int tileY1, + WebGPUSceneRowBounds rowBounds, + int ptclWordsPerTile) + { + int rowCount = tileY1 - tileY0; + int[]? rented = null; + Span spans = rowCount <= 256 + ? stackalloc int[512] + : (rented = ArrayPool.Shared.Rent(rowCount * 2)); + + // Each row keeps its first and last tile column in two adjacent slots. + spans = spans[..(rowCount * 2)]; + for (int i = 0; i < rowCount; i++) + { + spans[i * 2] = int.MaxValue; + spans[(i * 2) + 1] = int.MinValue; + } + + if (source.Geometry is LinearGeometry geometry) + { + IReadOnlyList contours = geometry.Contours; + IReadOnlyList points = geometry.Points; + for (int i = 0; i < contours.Count; i++) + { + LinearContour contour = contours[i]; + if (contour.PointCount == 0) + { + continue; + } + + // The segment walk matches EncodePath: a closed contour ends at its first point. + Vector2 current = (Vector2)points[contour.PointStart] + gridTranslate; + for (int j = 0; j < contour.SegmentCount; j++) + { + int endPointIndex = contour.PointStart + ((j + 1) == contour.PointCount ? 0 : j + 1); + Vector2 end = (Vector2)points[endPointIndex] + gridTranslate; + AccumulateSegmentRowSpans(spans, current, end, source.Radius, tileX0, tileX1, tileY0, tileY1); + current = end; + } + + if (contour.SegmentCount == 0) + { + AccumulateSegmentRowSpans(spans, current, current, source.Radius, tileX0, tileX1, tileY0, tileY1); + } + } + } + else + { + AccumulateSegmentRowSpans(spans, source.Start + gridTranslate, source.End + gridTranslate, source.Radius, tileX0, tileX1, tileY0, tileY1); + } + + // Each row span also goes to the row bounds, so a chunk window gets the spans of its rows. + long tiles = 0; + for (int i = 0; i < rowCount; i++) + { + int first = spans[i * 2]; + int last = spans[(i * 2) + 1]; + if (last >= first) + { + int rowTiles = last - first + 1; + tiles += rowTiles; + rowBounds.AddRowTiles(tileY0 + i, rowTiles, (long)rowTiles * ptclWordsPerTile); + } + } + + if (rented is not null) + { + ArrayPool.Shared.Return(rented); + } + + return tiles; + } + + /// + /// Widens the row spans with the capsule of one segment. + /// + /// The first and last tile column of each row, in adjacent slots. + /// The segment start in tile-grid pixels. + /// The segment end in tile-grid pixels. + /// The capsule radius in pixels. + /// The first tile column of the clipped bounds. + /// The tile column after the clipped bounds. + /// The first tile row of the clipped bounds. + /// The tile row after the clipped bounds. + private static void AccumulateSegmentRowSpans(Span spans, Vector2 a, Vector2 b, float radius, int tileX0, int tileX1, int tileY0, int tileY1) + { + float yMin = MathF.Min(a.Y, b.Y); + float yMax = MathF.Max(a.Y, b.Y); + int rowFirst = Math.Max((int)MathF.Floor((yMin - radius) / TileHeight), tileY0); + int rowLast = Math.Min((int)MathF.Floor((yMax + radius) / TileHeight), tileY1 - 1); + if (rowFirst > rowLast) + { + return; + } + + float dy = b.Y - a.Y; + bool flat = MathF.Abs(dy) < 1E-6F; + float inverseDy = flat ? 0F : (b.X - a.X) / dy; + for (int row = rowFirst; row <= rowLast; row++) + { + // The part of the segment whose capsule reaches this row lies between the row band + // widened by the radius, clamped to the y range of the segment. + float xa; + float xb; + if (flat) + { + xa = MathF.Min(a.X, b.X); + xb = MathF.Max(a.X, b.X); + } + else + { + float ya = Math.Clamp((row * TileHeight) - radius, yMin, yMax); + float yb = Math.Clamp(((row + 1) * TileHeight) + radius, yMin, yMax); + float x0 = a.X + ((ya - a.Y) * inverseDy); + float x1 = a.X + ((yb - a.Y) * inverseDy); + xa = MathF.Min(x0, x1); + xb = MathF.Max(x0, x1); + } + + int first = Math.Clamp((int)MathF.Floor((xa - radius) / TileWidth) - 1, tileX0, tileX1 - 1); + int last = Math.Clamp((int)MathF.Floor((xb + radius) / TileWidth) + 1, tileX0, tileX1 - 1); + int slot = (row - tileY0) * 2; + spans[slot] = Math.Min(spans[slot], first); + spans[slot + 1] = Math.Max(spans[slot + 1], last); + } + } + + /// + /// Returns the radius around the stroke centerline that contains the GPU stroke outline: the + /// outline radius of the pen plus one pixel. + /// + /// The pen that defines stroke width, joins, and caps. + /// The transform-derived scale applied to the stroke width. + /// The radius in pixels. + private static float GetStrokeOutlineRadius(Pen pen, float widthScale) + { + // One pixel absorbs the f32 differences of the GPU stroker. + return pen.GetOutlineRadius(widthScale) + 1F; + } + /// /// Returns the component-wise floor of a vector. /// @@ -5469,6 +7437,8 @@ private static float Sum(Vector2 value) /// The path-data stream. /// Receives the estimated final line workload for the stroke. /// Receives the bound on the tile crossings of the emitted stroke lines. + /// Receives an upper bound for the tile crossings of the emitted body lines in one tile row. + /// Receives the tile crossings of the emitted join and cap lines. /// The number of encoded path objects: 1, or 0 when no contour survived preprocessing. private static int EncodeStrokePath( LinearGeometry geometry, @@ -5480,8 +7450,12 @@ private static int EncodeStrokePath( ref OwnedStream pathTags, ref OwnedStream pathData, out int lineCount, - out long tileCrossings) + out long tileCrossings, + out long rowCrossings, + out long endCrossings) { + long bodyRowCrossings = 0; + int bodySegmentCount = 0; float pointTranslateX = destinationOffset.X - rootTargetBounds.X; float pointTranslateY = destinationOffset.Y - rootTargetBounds.Y; lineCount = EstimateStrokeLineCount(geometry, pen, widthScale); @@ -5530,6 +7504,8 @@ private static int EncodeStrokePath( float segmentLength = segment.Length(); Vector2 direction = segment / segmentLength; contourOutlineLength += 2D * segmentLength; + bodyRowCrossings += 2L * GetRowTileCrossings(segmentStart, point); + bodySegmentCount++; if (keptCount == 1) { secondPoint = point; @@ -5594,6 +7570,8 @@ private static int EncodeStrokePath( if (closingSegment && !duplicateClosingPoint) { + bodyRowCrossings += 2L * GetRowTileCrossings(lastKept, firstPoint); + bodySegmentCount++; Vector2 closingVector = (Vector2)firstPoint - (Vector2)lastKept; float closingLength = closingVector.Length(); Vector2 closingDirection = closingVector / closingLength; @@ -5685,6 +7663,8 @@ private static int EncodeStrokePath( } tileCrossings = BoundStrokeTileCrossings(outlineLength, residualStretch, lineCount); + rowCrossings = bodyRowCrossings; + endCrossings = GetStrokeEndTileCrossings(lineCount, bodySegmentCount, pen, widthScale); if (encodedContourCount == 0) { return 0; @@ -5832,6 +7812,8 @@ private static void EncodePointStrokeContour( /// The path-data stream. /// Receives the estimated final line workload for the stroke. /// Receives the bound on the tile crossings of the emitted stroke lines. + /// Receives an upper bound for the tile crossings of the emitted body lines in one tile row. + /// Receives the tile crossings of the emitted join and cap lines. /// The number of encoded path objects; always 1. private static int EncodeOpenSegmentStrokePath( PointF start, @@ -5844,7 +7826,9 @@ private static int EncodeOpenSegmentStrokePath( ref OwnedStream pathTags, ref OwnedStream pathData, out int lineCount, - out long tileCrossings) + out long tileCrossings, + out long rowCrossings, + out long endCrossings) { float pointTranslateX = destinationOffset.X - rootTargetBounds.X; float pointTranslateY = destinationOffset.Y - rootTargetBounds.Y; @@ -5856,9 +7840,32 @@ private static int EncodeOpenSegmentStrokePath( float halfWidth = pen.StrokeWidth * widthScale * 0.5F; double outlineLength = (2D * Vector2.Distance(start, end)) + (2D * GetStrokeCapChainLength(pen, halfWidth)); tileCrossings = BoundStrokeTileCrossings(outlineLength, residualStretch, lineCount); + rowCrossings = 2L * GetRowTileCrossings(start, end); + endCrossings = GetStrokeEndTileCrossings(lineCount, 1, pen, widthScale); return 1; } + /// + /// Returns an upper bound for the tile crossings of the join and cap lines of a stroke. + /// + /// + /// Each body of a centerline segment is two lines parallel to it, which the per-row bound of + /// the segment covers. The other lines are joins and caps. Each fits in a square of twice the + /// outline radius r. It crosses at most 2r / 16 + 1 tile boundaries on each axis, so it visits at + /// most 4r / 16 + 3 tiles. They are near the segment ends, so all of them can be in one tile row. + /// + /// The estimated GPU line count of the stroke. + /// The number of centerline segments with a body. + /// The pen that defines stroke width, joins, and caps. + /// The transform-derived scale applied to the stroke width. + /// The tile-crossing bound of the join and cap lines. + private static long GetStrokeEndTileCrossings(int lineCount, int segmentCount, Pen pen, float widthScale) + { + long otherLines = Math.Max(lineCount - (2L * segmentCount), 0L); + long otherLineCrossings = (long)(4F * GetStrokeOutlineRadius(pen, widthScale) / TileWidth) + 3L; + return otherLines * otherLineCrossings; + } + /// /// Replaces a point-like explicit stroke segment with the tiny tangent segment used for point strokes. /// @@ -5963,7 +7970,7 @@ private static int EncodeRectanglePath( pathData.Advance(data.Length); pathTags.Advance(tags.Length); - lineCount = 2; + lineCount = 4; return 1; } @@ -6010,7 +8017,7 @@ private static int EncodeRectangleClipPath( if (descriptor.Rectangle.Width > 0 && descriptor.Rectangle.Height > 0) { AppendRectangleContour(descriptor.Rectangle, translateX, translateY, ref pathTags, ref pathData); - lineCount = 2; + lineCount = 4; } break; @@ -6034,7 +8041,7 @@ private static int EncodeRectangleClipPath( ref pathTags, ref pathData); - lineCount += 2; + lineCount += 4; } break; @@ -6052,7 +8059,7 @@ private static int EncodeRectangleClipPath( } AppendRectangleContour(rectangle, translateX, translateY, ref pathTags, ref pathData); - lineCount += 2; + lineCount += 4; } break; @@ -6368,16 +8375,11 @@ private static int EstimateStrokeLineCount(LinearGeometry geometry, Pen pen, flo { LinearContour contour = geometry.Contours[i]; - total += contour.SegmentCount * 2; - if (contour.IsClosed) - { - total += contour.SegmentCount * joinCost; - } - else - { - total += Math.Max(contour.SegmentCount - 1, 0) * joinCost; - total += capCost * 2; - } + // The stroke encoder can close an open contour whose ends meet, which adds one body + // and one join, and it draws a two-point closed contour as a capped open segment. So + // every contour gets the closing body, a join for each body and two caps. + int bodies = contour.IsClosed ? contour.SegmentCount : contour.SegmentCount + 1; + total += (bodies * 2) + (bodies * joinCost) + (capCost * 2); } return Math.Max(total, 1); @@ -6474,15 +8476,15 @@ private static float GetStrokeJoinChainLength(Pen pen, float halfWidth, Vector2 private static int GetStrokeJoinLineCost(Pen pen, float widthScale) { float halfWidth = pen.StrokeWidth * widthScale * 0.5F; - int outerCost = pen.StrokeOptions.LineJoin switch + + // The GPU stroker writes at most 2 inner points, 2 outer points for bevel and miter joins + // or the arc points plus 2 for round joins, and one connector on each side. At a reversal + // both sides are outer. + return pen.StrokeOptions.LineJoin switch { - LineJoin.Miter => 3, - LineJoin.MiterRound => GetStrokeArcLineCost(halfWidth, MathF.PI, pen.StrokeOptions.ArcDetailScale), - LineJoin.MiterRevert => 2, - LineJoin.Round => GetStrokeArcLineCost(halfWidth, MathF.PI, pen.StrokeOptions.ArcDetailScale), - _ => 2 + LineJoin.Round or LineJoin.MiterRound => (2 * (GetStrokeArcLineCost(halfWidth, MathF.PI, pen.StrokeOptions.ArcDetailScale) + 1)) + 4, + _ => 6 }; - return outerCost + 2; } /// @@ -6495,10 +8497,12 @@ private static int GetStrokeJoinLineCost(Pen pen, float widthScale) private static int GetStrokeCapLineCost(Pen pen, float widthScale) { float halfWidth = pen.StrokeWidth * widthScale * 0.5F; + + // A round cap writes the arc points plus one. One more point absorbs f32 differences in acos. return pen.StrokeOptions.LineCap switch { LineCap.Square => 3, - LineCap.Round => GetStrokeArcLineCost(halfWidth, MathF.PI, pen.StrokeOptions.ArcDetailScale), + LineCap.Round => GetStrokeArcLineCost(halfWidth, MathF.PI, pen.StrokeOptions.ArcDetailScale) + 2, _ => 1 }; } @@ -6891,7 +8895,7 @@ private static uint BitcastSingle(float value) /// The divisor. /// The quotient rounded toward positive infinity. [MethodImpl(MethodImplOptions.AggressiveInlining)] - private static int DivideRoundUp(int value, int divisor) + public static int DivideRoundUp(int value, int divisor) => (value + divisor - 1) / divisor; /// @@ -7520,6 +9524,138 @@ private static uint MapExtendMode(GradientRepetitionMode repetitionMode) }; } +/// +/// One retained scene that an encoded scene reads in place, at one position inside a layer. +/// +/// +/// The positions are the counts of the outer stream elements before the retained scene. The +/// shaders read the retained streams through the scene segment table (see scene_segment.wgsl). +/// +internal readonly struct WebGPUSceneInsertion +{ + /// + /// Initializes a new instance of the struct. + /// + /// The retained scene. + /// The number of outer path-tag words before the retained scene. + /// The number of outer path-data words before the retained scene. + /// The number of outer transform words before the retained scene. + /// The number of outer style words before the retained scene. + /// The number of outer draw tags before the retained scene. + /// The number of outer draw-data words before the retained scene. + public WebGPUSceneInsertion( + WebGPUDrawingBackendScene scene, + int pathTagWord, + int pathDataWord, + int transformWord, + int styleWord, + int drawTag, + int drawDataWord) + { + this.Scene = scene; + this.PathTagWord = pathTagWord; + this.PathDataWord = pathDataWord; + this.TransformWord = transformWord; + this.StyleWord = styleWord; + this.DrawTag = drawTag; + this.DrawDataWord = drawDataWord; + } + + /// + /// Gets the retained scene. + /// + public WebGPUDrawingBackendScene Scene { get; } + + /// + /// Gets the number of outer path-tag words before the retained scene. + /// + public int PathTagWord { get; } + + /// + /// Gets the number of outer path-data words before the retained scene. + /// + public int PathDataWord { get; } + + /// + /// Gets the number of outer transform words before the retained scene. + /// + public int TransformWord { get; } + + /// + /// Gets the number of outer style words before the retained scene. + /// + public int StyleWord { get; } + + /// + /// Gets the number of outer draw tags before the retained scene. + /// + public int DrawTag { get; } + + /// + /// Gets the number of outer draw-data words before the retained scene. + /// + public int DrawDataWord { get; } +} + +/// +/// One retained scene stored as a resident block in the scene buffer of a dispatch. +/// +/// +/// A retained scene drawn several times has one block. The scene data and gradient rows of the +/// blocks follow those of the outer scene. The brush data and atlas rows of the blocks follow the +/// info prefix and atlas rows of each dispatch. Those change from range to range, so the block +/// stores offsets in those block areas. +/// +internal readonly struct WebGPUSceneBlock +{ + /// + /// Initializes a new instance of the struct. + /// + /// The retained scene. + /// The word offset of the scene data in the scene buffer. + /// The word offset of the brush data in the brush data of the blocks. + /// The first gradient-ramp row. + /// The first image-atlas row in the atlas rows of the blocks. + public WebGPUSceneBlock( + WebGPUEncodedScene scene, + int sceneWordOffset, + int brushDataOffset, + int gradientRowBase, + int atlasYOffset) + { + this.Scene = scene; + this.SceneWordOffset = sceneWordOffset; + this.BrushDataOffset = brushDataOffset; + this.GradientRowBase = gradientRowBase; + this.AtlasYOffset = atlasYOffset; + } + + /// + /// Gets the retained scene. + /// + public WebGPUEncodedScene Scene { get; } + + /// + /// Gets the word offset of the scene data in the scene buffer. + /// + public int SceneWordOffset { get; } + + /// + /// Gets the word offset of the brush data in the brush data of the blocks. + /// + public int BrushDataOffset { get; } + + /// + /// Gets the first gradient-ramp row. + /// + public int GradientRowBase { get; } + + /// + /// Gets the first image-atlas row in the atlas rows of the blocks. + /// + public int AtlasYOffset { get; } +} + /// /// Flush-scoped encoded scene payload. /// @@ -7546,6 +9682,161 @@ public void SetProfileRegions(uint slotsBase, uint recordsBase) this.ProfileRecordsBase = recordsBase; } + /// + /// Gets the path-tile bound of a dispatch of the whole scene. + /// + public long EstimatedPathTiles { get; private set; } + + /// + /// Gets the blend-spill slot bound of a dispatch of the whole scene. + /// + public long EstimatedBlendSpillSlots { get; private set; } + + /// + /// Gets the union region of each clip and layer depth of the scene, starting at depth 1, in + /// root-target-local pixels. An encoding that reads this scene in place adds these depths to + /// its own depth at that position. + /// + public Rectangle[] BlendDepthRegions { get; private set; } = []; + + /// + /// Sets the scratch bounds that the counts of the scene do not carry. + /// + /// The path-tile bound. + /// The blend-spill slot bound. + /// The union region of each clip and layer depth, starting at depth 1. + public void SetScratchBounds(long pathTiles, long blendSpillSlots, Rectangle[] blendDepthRegions) + { + this.EstimatedPathTiles = pathTiles; + this.EstimatedBlendSpillSlots = blendSpillSlots; + this.BlendDepthRegions = blendDepthRegions; + } + + /// + /// Gets the row bounds of a dispatch of the whole scene, or when the + /// scene has no draws outside its ranges. + /// + public WebGPUSceneRowBounds? RowBounds { get; private set; } + + /// + /// Gets the blend-spill slot bound of the tiles in a root-target-local row interval. + /// + /// The top of the interval in pixels. + /// The bottom of the interval in pixels. + /// The blend-spill slot bound. + public long GetBlendSpillSlots(int y0, int y1) => GetBlendSpillSlots(this.BlendDepthRegions, y0, y1); + + /// + /// Gets the blend-spill slot bound of the tiles of blend depth regions in a row interval. + /// + /// The union region of each blend depth in tile-grid pixels, starting at depth 1. + /// The top of the interval in pixels. + /// The bottom of the interval in pixels. + /// The blend-spill slot bound. + public static long GetBlendSpillSlots(ReadOnlySpan regions, int y0, int y1) + { + // Only the depths past the blend stack split spill: index i is depth i + 1. + long slots = 0; + for (int i = WebGPUSceneEncoder.BlendStackSplit; i < regions.Length; i++) + { + Rectangle region = regions[i]; + int top = Math.Max(region.Top, y0); + int bottom = Math.Min(region.Bottom, y1); + if (region.Width > 0 && bottom > top) + { + long columns = WebGPUSceneEncoder.DivideRoundUp(region.Right, WebGPUSceneEncoder.TileWidth) - (region.Left / WebGPUSceneEncoder.TileWidth); + long rows = WebGPUSceneEncoder.DivideRoundUp(bottom, WebGPUSceneEncoder.TileHeight) - (top / WebGPUSceneEncoder.TileHeight); + slots += columns * rows * WebGPUSceneEncoder.TileWidth * WebGPUSceneEncoder.TileHeight; + } + } + + return slots; + } + + /// + /// Sets the row bounds of the scene and takes ownership of every row bounds instance that its + /// encoding created, including those of its ranges. Each instance is sealed for queries. + /// + /// The row bounds of a dispatch of the whole scene. + /// The instances that this scene disposes. + public void SetRowBounds(WebGPUSceneRowBounds? rowBounds, WebGPUSceneRowBounds[] ownedRowBounds) + { + for (int i = 0; i < ownedRowBounds.Length; i++) + { + ownedRowBounds[i].Seal(); + } + + this.RowBounds = rowBounds; + this.ownedRowBounds = ownedRowBounds; + } + + /// + /// Gets the retained scenes that this scene reads in place inside its layers, in stream order. + /// + public ReadOnlySpan Insertions => this.insertions; + + /// + /// Gets the resident blocks of the retained scenes, in scene-buffer order. A nested retained + /// scene has its own block. + /// + public ReadOnlySpan Blocks => this.blocks; + + /// + /// Gets the number of entries of the scene segment table of all streams, or zero when the scene + /// has no such table. An ordered scene has no such table: each of its ranges that reads retained + /// scenes has a table of its own (see ). + /// + public int SegmentCount { get; private set; } + + /// + /// Gets the word offset of the scene segment tables. The tables are the end of . + /// + public int SegmentTableBase { get; private set; } + + /// + /// Gets the word length of the scene buffer: , then the resident blocks. + /// + public int SceneBufferWordCount => this.blocks.Length == 0 ? this.SceneWordCount : this.sceneBufferWordCount; + + /// + /// Gets the brush-data word count of the resident blocks, which follows the brush data of this scene. + /// + public int BlockBrushDataWordCount { get; private set; } + + /// + /// Gets the gradient-ramp row count of the resident blocks, which follows the rows of this scene. + /// + public int BlockGradientRowCount { get; private set; } + + /// + /// Records the retained scenes that this scene reads in place, and the scene buffer layout of + /// their blocks and segment table. + /// + /// The retained scenes, in stream order. + /// The resident blocks, in scene-buffer order. + /// The number of entries of the table of all streams, or zero for an ordered scene. + /// The word offset of the segment tables in . + /// The word length of the scene buffer. + /// The brush-data word count of the blocks. + /// The gradient-ramp row count of the blocks. + public void SetSegments( + WebGPUSceneInsertion[] insertions, + WebGPUSceneBlock[] blocks, + int segmentCount, + int segmentTableBase, + int sceneBufferWordCount, + int blockBrushDataWordCount, + int blockGradientRowCount) + { + this.insertions = insertions; + this.blocks = blocks; + this.SegmentCount = segmentCount; + this.SegmentTableBase = segmentTableBase; + this.sceneBufferWordCount = sceneBufferWordCount; + this.BlockBrushDataWordCount = blockBrushDataWordCount; + this.BlockGradientRowCount = blockGradientRowCount; + } + /// /// Gets the empty encoded scene instance. /// @@ -7591,6 +9882,10 @@ public void SetProfileRegions(uint slotsBase, uint recordsBase) private readonly List solidColors; private readonly List gradientRamps; private readonly WebGPUSceneOperation[] operations; + private WebGPUSceneInsertion[] insertions = []; + private WebGPUSceneBlock[] blocks = []; + private int sceneBufferWordCount; + private WebGPUSceneRowBounds[] ownedRowBounds = []; private bool disposed; /// @@ -7830,7 +10125,7 @@ public ReadOnlyMemory PathGradientData /// /// Gets the combined info/path-gradient prefix word count before dynamic bin data. /// - public int InfoBufferWordCount => this.InfoWordCount + this.PathGradientDataWordCount; + public int InfoBufferWordCount => this.InfoWordCount + this.PathGradientDataWordCount + this.BlockBrushDataWordCount; /// /// Gets the deferred image descriptors that must be patched after atlas creation. @@ -7952,6 +10247,13 @@ public ReadOnlyMemory PathGradientData /// public int PathTagWordCount { get; } + /// + /// Gets the path-tag word count that this scene adds to the virtual stream of a scene that reads + /// it in place: its padded words, or the virtual words of its own segment table when it reads + /// retained scenes in place. + /// + public int VirtualPathTagWordCount => this.insertions.Length == 0 ? this.PathTagWordCount : this.PathTagByteCount / 4; + /// /// Gets the path-data word count. /// @@ -8193,6 +10495,10 @@ public void Dispose() this.sceneDataOwner?.Dispose(); this.gradientPixelsOwner?.Dispose(); this.pathGradientDataOwner?.Dispose(); + for (int i = 0; i < this.ownedRowBounds.Length; i++) + { + this.ownedRowBounds[i].Dispose(); + } } } diff --git a/src/ImageSharp.Drawing.WebGPU/WebGPUSceneOperations.cs b/src/ImageSharp.Drawing.WebGPU/WebGPUSceneOperations.cs index 4874a5014..243a85c96 100644 --- a/src/ImageSharp.Drawing.WebGPU/WebGPUSceneOperations.cs +++ b/src/ImageSharp.Drawing.WebGPU/WebGPUSceneOperations.cs @@ -107,6 +107,17 @@ public WebGPUSceneOperation(WebGPUSceneRange compositeRange, int layerTargetId, /// public WebGPUSceneOperationKind Kind { get; } + /// + /// Gets the retained scene whose encoded data this operation reads, or + /// when it reads the scene that owns the operation list. + /// + /// + /// A retained scene inside a scoped layer renders through its own operations in the outer + /// list. They read its encoded data and use its resource arena, so its data is not uploaded + /// again for each pass. + /// + public WebGPUDrawingBackendScene? RetainedScene { get; init; } + /// /// Gets the encoded draw range for a render operation. /// Only meaningful when is ; default otherwise. @@ -230,7 +241,97 @@ public WebGPUSceneRange( long estimatedTileCrossings, long estimatedPtclWords, long estimatedBinFootprint) + : this( + targetBounds, + pathTagWordStart, + pathTagByteCount, + pathDataWordStart, + pathDataWordCount, + drawTagStart, + drawTagCount, + drawDataWordStart, + drawDataWordCount, + transformWordStart, + transformWordCount, + styleWordStart, + styleWordCount, + infoWordCount, + pathCount, + clipCount, + fillCount, + lineCount, + totalPathRowCount, + estimatedTileCrossings, + estimatedPtclWords, + estimatedBinFootprint, + null) { + } + + /// + /// Initializes a new instance of the struct for a range that can + /// read retained scenes in place. + /// + /// + /// When is not , these counts are joined totals + /// of the range and its retained scenes: + /// + /// the path-tag byte count; + /// the draw-tag and draw-data counts; + /// every element count and estimate. + /// + /// These are the values that size the dispatch. The own extents of the joined streams are in + /// . + /// + /// The target bounds used when this range was lowered. + /// The word offset of the range's path tags in the packed scene buffer. + /// The byte count of the range's path tags. + /// The word offset of the range's path data in the packed scene buffer. + /// The word count of the range's path data. + /// The draw-tag offset of the range. + /// The draw-tag count of the range. + /// The draw-data word offset of the range. + /// The draw-data word count of the range. + /// The transform word offset of the range. + /// The transform word count of the range. + /// The style word offset of the range. + /// The style word count of the range. + /// The range-local info word count. + /// The path count in the range. + /// The clip count in the range. + /// The visible fill count in the range. + /// The line count in the range. + /// The estimated sparse row count for the range. + /// The CPU-side upper bound for the range's tile-boundary crossings. + /// The CPU-side upper bound for the range's dynamic PTCL words. + /// The CPU-side upper bound for the range's per-(draw, bin) binning records. + /// The retained scenes that the range reads in place, or . + public WebGPUSceneRange( + Rectangle targetBounds, + int pathTagWordStart, + int pathTagByteCount, + int pathDataWordStart, + int pathDataWordCount, + int drawTagStart, + int drawTagCount, + int drawDataWordStart, + int drawDataWordCount, + int transformWordStart, + int transformWordCount, + int styleWordStart, + int styleWordCount, + int infoWordCount, + int pathCount, + int clipCount, + int fillCount, + int lineCount, + int totalPathRowCount, + long estimatedTileCrossings, + long estimatedPtclWords, + long estimatedBinFootprint, + WebGPUSceneRangeSegments? segments) + { + this.Segments = segments; this.TargetBounds = targetBounds; this.PathTagWordStart = pathTagWordStart; this.PathTagByteCount = pathTagByteCount; @@ -253,6 +354,32 @@ public WebGPUSceneRange( this.EstimatedTileCrossings = estimatedTileCrossings; this.EstimatedPtclWords = estimatedPtclWords; this.EstimatedBinFootprint = estimatedBinFootprint; + this.Origin = Point.Empty; + this.IsWholeScene = false; + } + + /// + /// Initializes a new instance of the struct from another range, + /// with other target bounds and another origin. + /// + /// The range to copy. + /// The bounds of the target that the range renders into. + /// The position of the target in the coordinates of the encoded data. + /// Whether the range is every stream of its scene. + private WebGPUSceneRange(in WebGPUSceneRange source, Rectangle targetBounds, Point origin, bool isWholeScene) + { + this = source; + this.TargetBounds = targetBounds; + this.Origin = origin; + this.IsWholeScene = isWholeScene; + + // The crossings are counted on the tile grid of the source origin. Another origin that does + // not start on a tile moves the alignment, so each line can cross one more tile on each axis. + if (origin != source.Origin && + (origin.X % WebGPUSceneEncoder.TileWidth != 0 || origin.Y % WebGPUSceneEncoder.TileHeight != 0)) + { + this.EstimatedTileCrossings = source.EstimatedTileCrossings + (2L * source.LineCount); + } } /// @@ -260,6 +387,27 @@ public WebGPUSceneRange( /// public Rectangle TargetBounds { get; } + /// + /// Gets the position of the target in the coordinates of the encoded data. The shaders move the + /// lines by minus this value, and paint at the pixel plus this value. The encoded data of an + /// ordered scene is in the coordinates of its root target. A range for a scoped layer therefore + /// has the layer position as its origin. A range for a retained scene drawn into a scoped layer + /// also has the layer position as its origin. The origin is zero for a range of the root target. + /// + public Point Origin { get; } + + /// + /// Gets a value indicating whether the range is every stream of its scene. Such a range reads + /// the scene segment table of a scene that reads retained scenes in place. + /// + public bool IsWholeScene { get; } + + /// + /// Gets the retained scenes that this range of an ordered scene reads in place, or + /// when it reads none. + /// + public WebGPUSceneRangeSegments? Segments { get; } + /// /// Gets the word offset of the range's path tags in the packed scene buffer. /// @@ -365,6 +513,22 @@ public WebGPUSceneRange( /// public long EstimatedBinFootprint { get; } + /// + /// Gets the path-tile bound of the range. + /// + public long EstimatedPathTiles { get; init; } + + /// + /// Gets the blend-spill slot bound of the range. + /// + public long EstimatedBlendSpillSlots { get; init; } + + /// + /// Gets the row bounds of the range, or when the range has no draws. The + /// encoded scene owns the instance. + /// + public WebGPUSceneRowBounds? RowBounds { get; init; } + /// /// Gets the maximum number of allocator-status records produced by this range. The staged /// pipeline emits one record per chunk, and one tile row is the smallest legal chunk. @@ -373,6 +537,135 @@ public int MaximumStatusRecordCount => this.TargetBounds.Height > 0 ? ((this.TargetBounds.Height - 1) / WebGPUSceneEncoder.TileHeight) + 1 : 1; + + /// + /// Creates the range of every stream of a retained scene, for a pass into another target. + /// + /// The retained encoded scene. + /// The bounds of the target that the scene renders into. + /// The position of the target in the coordinates of the scene data. + /// The whole-scene range. + public static WebGPUSceneRange CreateWholeScene(WebGPUEncodedScene scene, Rectangle targetBounds, Point origin) + { + // The counts of a scene that reads retained scenes in place are joined totals, the same + // values that size a render of the scene on its own. + WebGPUSceneRange range = new( + targetBounds, + 0, + scene.PathTagByteCount, + 0, + scene.PathDataWordCount, + 0, + scene.DrawTagCount, + 0, + scene.DrawDataWordCount, + 0, + scene.TransformWordCount, + 0, + scene.StyleWordCount, + scene.InfoWordCount, + scene.PathCount, + scene.ClipCount, + scene.FillCount, + scene.LineCount, + scene.TotalPathRowCount, + scene.EstimatedTileCrossings, + scene.EstimatedPtclWords, + scene.EstimatedBinFootprint); + + return new WebGPUSceneRange(range, targetBounds, origin, true) + { + EstimatedPathTiles = scene.EstimatedPathTiles, + EstimatedBlendSpillSlots = scene.EstimatedBlendSpillSlots, + RowBounds = scene.RowBounds + }; + } + + /// + /// Creates a copy of this range that renders into another target. + /// + /// The bounds of the target that the range renders into. + /// The position of the target in the coordinates of the range data. + /// The range copy. + public WebGPUSceneRange WithTarget(Rectangle targetBounds, Point origin) + => new(this, targetBounds, origin, this.IsWholeScene); +} + +/// +/// Describes the retained scenes that one range of an ordered scene reads in place, and the +/// segment table of the range. +/// +/// +/// A retained scene inside a layer that is not scoped is read in place, the same as in a scene with +/// no ordered operations (see scene_segment.wgsl). The table of a range has virtual starts relative +/// to the range start, because the shaders index each stream from the range start. +/// +internal sealed class WebGPUSceneRangeSegments +{ + /// + /// Initializes a new instance of the class. + /// + /// The index of the first retained scene of the range in the insertions of the scene. + /// The number of retained scenes of the range. + /// The index of the first table entry of the range in the range tables of the scene. + /// The number of table entries of the range. + /// The path-tag word count of the range's own streams. + /// The draw-tag count of the range's own streams. + /// The draw-data word count of the range's own streams. + public WebGPUSceneRangeSegments( + int insertionStart, + int insertionCount, + int segmentEntryStart, + int segmentCount, + int pathTagWordCount, + int drawTagCount, + int drawDataWordCount) + { + this.InsertionStart = insertionStart; + this.InsertionCount = insertionCount; + this.SegmentEntryStart = segmentEntryStart; + this.SegmentCount = segmentCount; + this.PathTagWordCount = pathTagWordCount; + this.DrawTagCount = drawTagCount; + this.DrawDataWordCount = drawDataWordCount; + } + + /// + /// Gets the index of the first retained scene of the range in the insertions of the scene. + /// + public int InsertionStart { get; } + + /// + /// Gets the number of retained scenes of the range. + /// + public int InsertionCount { get; } + + /// + /// Gets the index of the first table entry of the range in the range tables of the scene. + /// + public int SegmentEntryStart { get; } + + /// + /// Gets the number of table entries of the range. + /// + public int SegmentCount { get; } + + /// + /// Gets the path-tag word count of the range's own streams. The range path-tag byte count is + /// the joined total. + /// + public int PathTagWordCount { get; } + + /// + /// Gets the draw-tag count of the range's own streams. The range draw-tag count is the joined total. + /// + public int DrawTagCount { get; } + + /// + /// Gets the draw-data word count of the range's own streams. The range draw-data word count is + /// the joined total. + /// + public int DrawDataWordCount { get; } } /// diff --git a/src/ImageSharp.Drawing.WebGPU/WebGPUSceneResources.cs b/src/ImageSharp.Drawing.WebGPU/WebGPUSceneResources.cs index bf4b879ef..8276579fa 100644 --- a/src/ImageSharp.Drawing.WebGPU/WebGPUSceneResources.cs +++ b/src/ImageSharp.Drawing.WebGPU/WebGPUSceneResources.cs @@ -149,8 +149,14 @@ private static bool TryCreateCore( // The retained solid color words and ramp texels specialize to the target pixel type // before any upload below reads them, so the snapped values ride the ordinary scene - // upload and ramp texture write with no per-word GPU patching. + // upload and ramp texture write with no per-word GPU patching. A retained scene read in + // place has its own words and texels, which specialize the same way. scene.SpecializeBrushColors(); + ReadOnlySpan blocks = scene.Blocks; + for (int i = 0; i < blocks.Length; i++) + { + blocks[i].Scene.SpecializeBrushColors(); + } // Textures are scene-dependent and not pooled. if (!TryCreateGradientTexture(flushContext, scene, out WGPUTextureViewImpl* gradientTextureView, out error)) @@ -174,11 +180,11 @@ private static bool TryCreateCore( // info/bin-data buffer is laid out as [info | brush data | bin data | bin headers], // matching config.bin_data_start and config.brush_data_base in Shared/config.wgsl. nuint infoBinDataByteLength = checked( - GetBindingByteLength(infoWordCount + scene.PathGradientDataWordCount) + + GetBindingByteLength(infoWordCount + scene.PathGradientDataWordCount + scene.BlockBrushDataWordCount) + config.BufferSizes.BinData.ByteLength + config.BufferSizes.BinHeaders.ByteLength); - nuint sceneByteLength = GetBindingByteLength(scene.SceneWordCount); + nuint sceneByteLength = GetBindingByteLength(scene.SceneBufferWordCount); // Reuse arena buffers if all capacities fit this scene. if (arena is not null && arena.CanReuse(flushContext, config.BufferSizes, infoBinDataByteLength, sceneByteLength)) @@ -193,7 +199,8 @@ private static bool TryCreateCore( // re-uploads only the mutated span. Queue ordering already ran the resident upload // before any command submitted afterwards can read the buffer. ReadOnlySpan sceneData = scene.SceneData.Span; - if (!ReferenceEquals(arena.UploadedScene, scene)) + bool sceneChanged = !ReferenceEquals(arena.UploadedScene, scene); + if (sceneChanged) { fixed (uint* sceneDataPtr = sceneData) { @@ -218,6 +225,14 @@ private static bool TryCreateCore( } } + // A new root scene overwrites the words where earlier blocks were resident. + arena.UploadedBlockVersions = UploadSceneBlocks( + flushContext, + reuseQueue, + arena.SceneBuffer, + scene, + sceneChanged ? null : arena.UploadedBlockVersions); + GpuSceneConfig header = range.HasValue ? CreateHeader(scene, range.Value, config, baseColor) : CreateHeader(scene, config, baseColor); @@ -382,6 +397,16 @@ private static bool TryCreateCore( throw; } + using (WebGPUHandle.HandleReference missQueueReference = flushContext.QueueHandle.AcquireReference()) + { + arena.UploadedBlockVersions = UploadSceneBlocks( + flushContext, + (WGPUQueueImpl*)missQueueReference.Handle, + sceneBuffer, + scene, + null); + } + arena.UploadedScene = scene; arena.UploadedSceneVersion = scene.SceneDataVersion; arena.UploadedBrushInfoWordCount = infoWordCount; @@ -412,6 +437,57 @@ private static bool TryCreateCore( return true; } + /// + /// Uploads the blocks of the retained scenes that a scene reads in place, after its own data in + /// the scene buffer. A block whose content is already resident is not uploaded again. + /// + /// The active WebGPU flush context. + /// The queue that receives the writes. + /// The scene buffer. + /// The root encoded scene. + /// The resident block versions of this root scene, or when no block is resident. + /// The resident block versions after the upload, or when the scene has no blocks. + private static int[]? UploadSceneBlocks( + WebGPUFlushContext flushContext, + WGPUQueueImpl* queue, + WGPUBufferImpl* sceneBuffer, + WebGPUEncodedScene scene, + int[]? residentVersions) + { + ReadOnlySpan blocks = scene.Blocks; + if (blocks.IsEmpty) + { + return null; + } + + int[] versions = residentVersions ?? new int[blocks.Length]; + for (int i = 0; i < blocks.Length; i++) + { + WebGPUEncodedScene blockScene = blocks[i].Scene; + + // A block is uploaded one time, then again only when its tracked words change, for + // example after a different pixel type specializes its colors. This is the same rule + // as the residency of the root scene data. + if (residentVersions is null || residentVersions[i] != blockScene.SceneDataVersion) + { + ReadOnlySpan blockData = blockScene.SceneData.Span; + fixed (uint* blockDataPtr = blockData) + { + flushContext.Api.QueueWriteBuffer( + queue, + sceneBuffer, + checked((ulong)blocks[i].SceneWordOffset * sizeof(uint)), + blockDataPtr, + (nuint)(blockData.Length * sizeof(uint))); + } + } + + versions[i] = blockScene.SceneDataVersion; + } + + return versions; + } + /// /// Merges the required sizes for the arena-owned buffers with the capacities of an outgoing /// arena so recreation settles at the union of every range's dominant dimensions. @@ -492,7 +568,10 @@ public static GpuSceneConfig CreateHeader(WebGPUEncodedScene scene, WebGPUSceneC config.BumpSizes.BlendSpill, config.BumpSizes.Ptcl, scene.ProfileSlotsBase, - scene.ProfileRecordsBase); + scene.ProfileRecordsBase, + (uint)scene.SegmentCount, + (uint)scene.SegmentTableBase, + Point.Empty); } /// @@ -511,14 +590,15 @@ public static GpuSceneConfig CreateHeader(WebGPUEncodedScene scene, WebGPUSceneR // The combined info/bin-data buffer is [info | path-gradient | bin data | bin headers], // so bin data starts after both ranges and the gradient data starts after the info - // words. The ptcl_dyn_start value (sixth layout argument) marks where the + // words. The brush data of resident blocks, when the scene has them, follows the scene's + // own brush data. The ptcl_dyn_start value (sixth layout argument) marks where the // bump-allocated ptcl tail begins: after the fixed 64-word // (WebGPUSceneDispatch.PtclInitialAlloc) reservation for every tile slot. GpuSceneLayout layout = new( checked((uint)range.DrawTagCount), checked((uint)range.PathCount), checked((uint)range.ClipCount), - checked((uint)(range.InfoWordCount + scene.PathGradientDataWordCount)), + checked((uint)(range.InfoWordCount + scene.PathGradientDataWordCount + scene.BlockBrushDataWordCount)), checked((uint)range.InfoWordCount), checked((uint)tileCountX * config.ChunkWindow.TileBufferHeight * 64U), checked(scene.Layout.PathTagBase + (uint)range.PathTagWordStart), @@ -528,6 +608,23 @@ public static GpuSceneConfig CreateHeader(WebGPUEncodedScene scene, WebGPUSceneR checked(scene.Layout.TransformBase + (uint)range.TransformWordStart), checked(scene.Layout.StyleBase + (uint)range.StyleWordStart)); + // A whole-scene range reads the table of the whole scene: the table maps the virtual + // streams of the whole scene, and the starts of such a range are zero. A range of an + // ordered scene that reads retained scenes reads its own table, with virtual starts + // relative to the range starts. + uint segmentCount = 0; + uint segmentTableBase = 0; + if (range.IsWholeScene) + { + segmentCount = (uint)scene.SegmentCount; + segmentTableBase = (uint)scene.SegmentTableBase; + } + else if (range.Segments is WebGPUSceneRangeSegments segments) + { + segmentCount = (uint)segments.SegmentCount; + segmentTableBase = checked((uint)(scene.SegmentTableBase + (segments.SegmentEntryStart * WebGPUSceneEncoder.SceneSegmentEntryWordCount))); + } + return new GpuSceneConfig( checked((uint)tileCountX), checked((uint)tileCountY), @@ -546,7 +643,10 @@ public static GpuSceneConfig CreateHeader(WebGPUEncodedScene scene, WebGPUSceneR config.BumpSizes.BlendSpill, config.BumpSizes.Ptcl, scene.ProfileSlotsBase, - scene.ProfileRecordsBase); + scene.ProfileRecordsBase, + segmentCount, + segmentTableBase, + range.Origin); } /// @@ -610,14 +710,19 @@ private static bool TryCreateImageAtlasTexture( return true; } + // The images of the resident blocks follow the images of the scene or range, at the block + // offsets that its segment table uses. Only a dispatch that reads retained scenes in place + // reads the blocks. + bool readsSegmentTable = !range.HasValue || range.Value.IsWholeScene || range.Value.Segments is not null; + ReadOnlySpan blocks = readsSegmentTable ? scene.Blocks : []; int imageCount = 0; - foreach (GpuImageDescriptor descriptor in scene.Images) + int atlasWidth = 1; + int atlasHeight = 0; + MeasureSceneImages(scene, range, ref imageCount, ref atlasWidth, ref atlasHeight); + int blockAtlasYBase = atlasHeight; + for (int i = 0; i < blocks.Length; i++) { - if (IsImageDescriptorInRange(descriptor, range) && - descriptor.Source.Brush is ImageBrush or PatternBrush) - { - imageCount++; - } + MeasureSceneImages(blocks[i].Scene, null, ref imageCount, ref atlasWidth, ref atlasHeight); } if (imageCount == 0) @@ -630,32 +735,93 @@ private static bool TryCreateImageAtlasTexture( return TryCreateSinglePixelSampledTexture(flushContext, textureFormat, transparentPixel, out _, out textureView, out error); } - int atlasWidth = 1; - int atlasHeight = 0; + if (!TryCreateTexture(flushContext, textureFormat, atlasWidth, atlasHeight, "image atlas", out WGPUTextureImpl* texture, out textureView, out error)) + { + return false; + } + + TPixel[] rowBuffer = GC.AllocateUninitializedArray(atlasWidth); + if (!TryUploadSceneImages(flushContext, texture, scene, range, 0, rowBuffer, pixelNumericEncoding, out error)) + { + return false; + } + + for (int i = 0; i < blocks.Length; i++) + { + int atlasYBase = blockAtlasYBase + blocks[i].AtlasYOffset; + if (!TryUploadSceneImages(flushContext, texture, blocks[i].Scene, null, atlasYBase, rowBuffer, pixelNumericEncoding, out error)) + { + return false; + } + } + + error = null; + return true; + } + + /// + /// Adds the sampled image and pattern brush entries of one scene to the atlas measurements. + /// + /// The encoded scene containing image descriptors. + /// The scene range to stage, or for the full scene. + /// The running entry count. + /// The running atlas width: the widest entry. + /// The running atlas height: the sum of the entry heights. + private static void MeasureSceneImages( + WebGPUEncodedScene scene, + WebGPUSceneRange? range, + ref int imageCount, + ref int atlasWidth, + ref int atlasHeight) + { foreach (GpuImageDescriptor descriptor in scene.Images) { - if (!IsImageDescriptorInRange(descriptor, range) || - descriptor.Source.Brush is not (ImageBrush or PatternBrush)) + if (!TryGetAtlasBrush(descriptor, range, out Brush? brush)) { continue; } - GetImageEntrySize(descriptor.Source.Brush, out int width, out int height); + GetImageEntrySize(brush, out int width, out int height); + imageCount++; atlasWidth = Math.Max(atlasWidth, width); atlasHeight += height; } + } - if (!TryCreateTexture(flushContext, textureFormat, atlasWidth, atlasHeight, "image atlas", out WGPUTextureImpl* texture, out textureView, out error)) - { - return false; - } - - TPixel[] rowBuffer = GC.AllocateUninitializedArray(atlasWidth); - int atlasY = 0; + /// + /// Uploads the sampled image and pattern brush entries of one scene into the atlas, from row + /// , and patches each image's draw-data words. + /// + /// + /// The patched atlas row is relative to . A retained scene read in + /// place therefore keeps the words that it has when it renders alone, and draw_leaf.wgsl adds + /// the row base of its segment. + /// + /// The pixel format of the target and of any sampled image brushes. + /// The active WebGPU flush context. + /// The atlas texture. + /// The encoded scene containing image descriptors. + /// The scene range to stage, or for the full scene. + /// The first atlas row of the scene. + /// The scratch row buffer used to convert pattern colors. + /// The numeric encoding of the atlas texels. + /// The error message when an upload fails. + /// when every entry was uploaded. + private static bool TryUploadSceneImages( + WebGPUFlushContext flushContext, + WGPUTextureImpl* texture, + WebGPUEncodedScene scene, + WebGPUSceneRange? range, + int atlasYBase, + TPixel[] rowBuffer, + WebGPUTargetNumericEncoding pixelNumericEncoding, + out string? error) + where TPixel : unmanaged, IPixel + { + int atlasY = atlasYBase; foreach (GpuImageDescriptor descriptor in scene.Images) { - if (!IsImageDescriptorInRange(descriptor, range) || - descriptor.Source.Brush is not (ImageBrush or PatternBrush)) + if (!TryGetAtlasBrush(descriptor, range, out Brush? brush)) { continue; } @@ -663,7 +829,7 @@ descriptor.Source.Brush is not (ImageBrush or PatternBrush)) if (!TryUploadImageEntry( flushContext, texture, - descriptor.Source.Brush, + brush, atlasY, rowBuffer, out int entryWidth, @@ -675,14 +841,14 @@ descriptor.Source.Brush is not (ImageBrush or PatternBrush)) WrapMode wrapX = WrapMode.Repeat; WrapMode wrapY = WrapMode.Repeat; - if (descriptor.Source.Brush is ImageBrush imageBrush) + if (brush is ImageBrush imageBrush) { wrapX = imageBrush.WrapX; wrapY = imageBrush.WrapY; } int sceneIndex = (int)scene.Layout.DrawDataBase + descriptor.DrawDataWordOffset; - scene.SetSceneWord(sceneIndex, PackImageAtlasOffset(0, atlasY)); + scene.SetSceneWord(sceneIndex, PackImageAtlasOffset(0, atlasY - atlasYBase)); scene.SetSceneWord(sceneIndex + 1, PackImageExtents(entryWidth, entryHeight)); scene.SetSceneWord( sceneIndex + 2, @@ -711,9 +877,12 @@ private static bool IsImageDescriptorInRange(GpuImageDescriptor descriptor, WebG return true; } + // The draw-data count of a range that reads retained scenes is a joined total. Its own + // draw data, which holds its own images, is in its segments. WebGPUSceneRange value = range.Value; + int drawDataWordCount = value.Segments?.DrawDataWordCount ?? value.DrawDataWordCount; return descriptor.DrawDataWordOffset >= value.DrawDataWordStart && - descriptor.DrawDataWordOffset < value.DrawDataWordStart + value.DrawDataWordCount; + descriptor.DrawDataWordOffset < value.DrawDataWordStart + drawDataWordCount; } /// @@ -733,7 +902,10 @@ private static bool TryCreateGradientTexture( out WGPUTextureViewImpl* textureView, out string? error) { - if (scene.GradientRowCount == 0) + // The rows of the resident blocks follow the scene's own rows, at the row base that each + // block segment publishes. + int rowCount = scene.GradientRowCount + scene.BlockGradientRowCount; + if (rowCount == 0) { // Gradient rows contain associated binary16 components, for which physical zero is transparent. ulong transparentPixel = 0; @@ -756,7 +928,7 @@ private static bool TryCreateGradientTexture( WGPUTextureFormat.RGBA16Float, (ulong)usage, 512, - (uint)scene.GradientRowCount, + (uint)rowCount, out WGPUTextureImpl* texture, out textureView, out uint rentedWidth, @@ -772,7 +944,7 @@ private static bool TryCreateGradientTexture( dimension = WGPUTextureDimension._2D, // 512 must match GRADIENT_WIDTH in fine.wgsl and the encoder's ramp width. - size = new WGPUExtent3D(512, (uint)scene.GradientRowCount, 1), + size = new WGPUExtent3D(512, (uint)rowCount, 1), format = WGPUTextureFormat.RGBA16Float, mipLevelCount = 1, sampleCount = 1 @@ -810,7 +982,44 @@ private static bool TryCreateGradientTexture( } // Returned to the device pool with the flush so later flushes rent instead of recreating. - flushContext.TrackPooledTexture(texture, textureView, WGPUTextureFormat.RGBA16Float, (ulong)usage, 512, (uint)scene.GradientRowCount); + flushContext.TrackPooledTexture(texture, textureView, WGPUTextureFormat.RGBA16Float, (ulong)usage, 512, (uint)rowCount); + } + + using (WebGPUHandle.HandleReference queueReference = flushContext.QueueHandle.AcquireReference()) + { + WGPUQueueImpl* queue = (WGPUQueueImpl*)queueReference.Handle; + WriteGradientRows(flushContext, queue, texture, scene, 0); + + ReadOnlySpan blocks = scene.Blocks; + for (int i = 0; i < blocks.Length; i++) + { + WriteGradientRows(flushContext, queue, texture, blocks[i].Scene, blocks[i].GradientRowBase); + } + } + + flushContext.CacheGradientTextureView(scene, textureView); + error = null; + return true; + } + + /// + /// Writes the gradient-ramp rows of one scene into the gradient texture, from one row. + /// + /// The active WebGPU flush context. + /// The queue that receives the write. + /// The gradient texture. + /// The encoded scene supplying the rows. + /// The first texture row of the scene. + private static void WriteGradientRows( + WebGPUFlushContext flushContext, + WGPUQueueImpl* queue, + WGPUTextureImpl* texture, + WebGPUEncodedScene scene, + int firstRow) + { + if (scene.GradientRowCount == 0) + { + return; } WGPUTexelCopyBufferLayout layout = new() @@ -824,26 +1033,63 @@ private static bool TryCreateGradientTexture( { texture = texture, mipLevel = 0, - origin = new WGPUOrigin3D(0, 0, 0), + origin = new WGPUOrigin3D(0, (uint)firstRow, 0), aspect = WGPUTextureAspect.All }; fixed (uint* pixelPtr = scene.GradientPixels.Span) { WGPUExtent3D extent = new(512, (uint)scene.GradientRowCount, 1); - using WebGPUHandle.HandleReference queueReference = flushContext.QueueHandle.AcquireReference(); flushContext.Api.QueueWriteTexture( - (WGPUQueueImpl*)queueReference.Handle, + queue, in destination, pixelPtr, (nuint)(scene.GradientPixels.Length * sizeof(uint)), in layout, in extent); } + } - flushContext.CacheGradientTextureView(scene, textureView); - error = null; - return true; + /// + /// Gets the image-atlas row count of the sampled brush entries of one scene or range. + /// + /// + /// The atlas stacks these entries in one column, in descriptor order, the same as + /// lays out a scene or range. + /// + /// The image descriptors of the scene. + /// The scene range, or for the full scene. + /// The atlas row count. + public static int GetImageAtlasHeight(IReadOnlyList images, WebGPUSceneRange? range) + { + int height = 0; + for (int i = 0; i < images.Count; i++) + { + if (TryGetAtlasBrush(images[i], range, out Brush? brush)) + { + GetImageEntrySize(brush, out _, out int entryHeight); + height += entryHeight; + } + } + + return height; + } + + /// + /// Gets the brush of an image descriptor that has an atlas entry in the staged scene or range. + /// + /// + /// External textures are bound directly, so they have no atlas entry. + /// + /// The image descriptor. + /// The scene range to stage, or for the full scene. + /// Receives the sampled brush. + /// when the descriptor has an atlas entry. + private static bool TryGetAtlasBrush(GpuImageDescriptor descriptor, WebGPUSceneRange? range, [NotNullWhen(true)] out Brush? brush) + { + brush = descriptor.Source.Brush; + return brush is ImageBrush or PatternBrush && + IsImageDescriptorInRange(descriptor, range); } /// @@ -1010,7 +1256,7 @@ private static bool TryUploadImageBrushEntry( where TPixel : unmanaged, IPixel { ReadOnlySpan brushData = scene.PathGradientData.Span; - nuint infoByteLength = checked((nuint)(infoWordCount + brushData.Length) * (nuint)Unsafe.SizeOf()); + nuint infoByteLength = checked((nuint)(infoWordCount + brushData.Length + scene.BlockBrushDataWordCount) * (nuint)Unsafe.SizeOf()); nuint totalByteLength = Math.Max(checked(infoByteLength + dynamicBinByteLength), minimumTotalByteLength); if (totalByteLength == 0) { @@ -1032,6 +1278,7 @@ private static bool TryUploadImageBrushEntry( /// /// Writes auxiliary brush data immediately after the info region, at the /// word offset published as config.brush_data_base in Shared/config.wgsl. + /// The brush data of each resident block follows, at the base its segment publishes. /// /// The active WebGPU flush context. /// The combined info/bin-data buffer. @@ -1043,6 +1290,36 @@ private static void UploadBrushData( int infoWordCount, WebGPUEncodedScene scene) where TPixel : unmanaged, IPixel + { + using WebGPUHandle.HandleReference queueReference = flushContext.QueueHandle.AcquireReference(); + WGPUQueueImpl* queue = (WGPUQueueImpl*)queueReference.Handle; + UploadSceneBrushData(flushContext, queue, buffer, infoWordCount, scene); + + // The segment tables place the brush data of the blocks after the info prefix and the + // brush data of the dispatch. The words are few, so they are written for every dispatch. + ReadOnlySpan blocks = scene.Blocks; + int blockBrushDataBase = infoWordCount + scene.PathGradientDataWordCount; + for (int i = 0; i < blocks.Length; i++) + { + UploadSceneBrushData(flushContext, queue, buffer, blockBrushDataBase + blocks[i].BrushDataOffset, blocks[i].Scene); + } + } + + /// + /// Writes the auxiliary brush data of one scene at one word offset of the combined info buffer. + /// + /// The active WebGPU flush context. + /// The queue that receives the writes. + /// The combined info/bin-data buffer. + /// The word offset of the brush data. + /// The encoded scene supplying static and target-specialized brush data. + private static void UploadSceneBrushData( + WebGPUFlushContext flushContext, + WGPUQueueImpl* queue, + WGPUBufferImpl* buffer, + int brushDataBase, + WebGPUEncodedScene scene) + where TPixel : unmanaged, IPixel { ReadOnlySpan brushData = scene.PathGradientData.Span; if (brushData.IsEmpty) @@ -1050,11 +1327,9 @@ private static void UploadBrushData( return; } - nuint offset = checked((nuint)infoWordCount * (nuint)Unsafe.SizeOf()); + nuint offset = checked((nuint)brushDataBase * (nuint)Unsafe.SizeOf()); nuint byteLength = checked((nuint)brushData.Length * (nuint)Unsafe.SizeOf()); - using WebGPUHandle.HandleReference queueReference = flushContext.QueueHandle.AcquireReference(); - WGPUQueueImpl* queue = (WGPUQueueImpl*)queueReference.Handle; fixed (uint* dataPtr = brushData) { flushContext.Api.QueueWriteBuffer(queue, buffer, offset, dataPtr, byteLength); @@ -1083,7 +1358,7 @@ private static void UploadBrushData( payload[7] = BitConverter.SingleToUInt32Bits(target.W); payload[8] = BitConverter.SingleToUInt32Bits(descriptor.Threshold); - nuint payloadOffset = checked((nuint)(infoWordCount + descriptor.BrushDataWordOffset) * (nuint)Unsafe.SizeOf()); + nuint payloadOffset = checked((nuint)(brushDataBase + descriptor.BrushDataWordOffset) * (nuint)Unsafe.SizeOf()); fixed (uint* payloadPtr = payload) { flushContext.Api.QueueWriteBuffer(queue, buffer, payloadOffset, payloadPtr, 9U * (nuint)Unsafe.SizeOf()); @@ -1802,6 +2077,16 @@ public WebGPUSceneResourceArena( /// public int UploadedSceneVersion { get; set; } + /// + /// Gets or sets the of each resident block of + /// , by block index, or when no block is resident. + /// + /// + /// The blocks of an encoded scene never change, so the identity of + /// proves the block set and these versions prove the content of each block. + /// + public int[]? UploadedBlockVersions { get; set; } + /// /// Gets or sets the info-word prefix length used by the resident brush upload. Brush words /// start immediately after this prefix, so a different prefix relocates them. @@ -2434,6 +2719,9 @@ internal readonly struct GpuSceneConfig /// The PTCL buffer capacity in words. /// The word offset of the segment-to-profile table, or zero when absent. /// The word offset of the profile range and contour-link table, or zero when absent. + /// The number of scene segment table entries, or zero when the scene has no table. + /// The word offset of the scene segment table in the scene buffer. + /// The position of the target in the coordinates of the encoded data. public GpuSceneConfig( uint widthInTiles, uint heightInTiles, @@ -2452,13 +2740,18 @@ public GpuSceneConfig( uint blendSize, uint ptclSize, uint profileSlotsBase, - uint profileRecordsBase) + uint profileRecordsBase, + uint segmentCount, + uint segmentTableBase, + Point targetOrigin) { this.ProfileSlotsBase = profileSlotsBase; this.ProfileRecordsBase = profileRecordsBase; - this.profilePad0 = 0; - this.profilePad1 = 0; - this.profilePad2 = 0; + this.SegmentCount = segmentCount; + this.SegmentTableBase = segmentTableBase; + + // config.wgsl unpacks x from the low 16 bits and y from the high 16 bits. + this.TargetOrigin = (uint)targetOrigin.X | ((uint)targetOrigin.Y << 16); this.WidthInTiles = widthInTiles; this.HeightInTiles = heightInTiles; this.TargetWidth = targetWidth; @@ -2567,11 +2860,20 @@ public GpuSceneConfig( /// public uint ProfileRecordsBase { get; } - // Config in config.wgsl declares three final pad words so its size is a multiple of 16 bytes. - // Declare the same words after the auto-properties to preserve the uniform buffer layout. - private readonly uint profilePad0; - private readonly uint profilePad1; - private readonly uint profilePad2; + /// + /// Gets the number of scene segment table entries, or zero when the scene has no table. + /// + public uint SegmentCount { get; } + + /// + /// Gets the word offset of the scene segment table in the scene buffer. + /// + public uint SegmentTableBase { get; } + + /// + /// Gets the position of the target in the coordinates of the encoded data, packed as x | (y << 16). + /// + public uint TargetOrigin { get; } } /// diff --git a/src/ImageSharp.Drawing.WebGPU/WebGPUSceneRowBounds.cs b/src/ImageSharp.Drawing.WebGPU/WebGPUSceneRowBounds.cs new file mode 100644 index 000000000..d5ce00618 --- /dev/null +++ b/src/ImageSharp.Drawing.WebGPU/WebGPUSceneRowBounds.cs @@ -0,0 +1,371 @@ +// Copyright (c) Six Labors. +// Licensed under the Six Labors Split License. + +using System.Buffers; + +namespace SixLabors.ImageSharp.Drawing.Processing.Backends; + +/// +/// Holds the scratch bounds of an encoded scene or range for each bin of 16 tile rows. A tile-row +/// chunk window then gets a bound from the draws that reach it. +/// +/// +/// +/// The per-chunk scratch buffers are the path rows, path tiles, segment counts, segments and PTCL. +/// They hold only the work of the draws whose clipped bounds reach the chunk rows, because +/// path_row_alloc clips each path to the chunk window. A share of the target height is not a bound +/// when the draws are in a few rows. AddDraw therefore adds O(1) entries for each draw, a walked +/// draw adds one entry for each row, and a window query is O(1). +/// +/// +/// The entries are kept for each bin of 16 tile rows, which is also the alignment of the chunk +/// windows. A window widens to whole bins, so a chunk smaller than one bin gets the bound of its +/// bin. Bins keep the arrays 16 times smaller than rows, so a scene of any height pays almost +/// nothing to fill, merge and seal them. +/// +/// +/// Two kinds of sums are kept: +/// +/// +/// Area sums. A draw adds a value for each of its rows. Each bin receives the +/// value times the rows of the draw in that bin, through a difference array. The window sum is the +/// sum of its bins. +/// Overlap sums. A draw adds its full bound at the bin of its first row and at +/// the bin of its last row. The window sum is the full bound of every draw that reaches the window +/// bins. These are the draws whose first bin is before the window end, minus the draws whose last +/// bin is before the window start. +/// +/// +/// The arrays are rented as one block and returned by . +/// +/// +internal sealed class WebGPUSceneRowBounds : IDisposable +{ + /// + /// The tile rows of one bin. Must match N_TILE_Y, the bin height that chunk windows align to. + /// + public const int BinRows = 16; + + // Array indices in the rented block. Each array has binCount + 1 entries. + private const int PathRowsArea = 0; + private const int TilesArea = 1; + private const int PtclArea = 2; + private const int CrossingsArea = 3; + private const int TilesFirst = 4; + private const int TilesLast = 5; + private const int CrossingsFirst = 6; + private const int CrossingsLast = 7; + private const int PtclFirst = 8; + private const int PtclLast = 9; + private const int EndCrossingsFirst = 10; + private const int EndCrossingsLast = 11; + private const int ArrayCount = 12; + + private long[]? data; + private readonly int binCount; + private readonly int stride; + + /// + /// Initializes a new instance of the class. + /// + /// The number of tile rows of the grid. + /// The data-space Y position of tile row 0. + public WebGPUSceneRowBounds(int rowCount, int gridOriginY) + { + this.RowCount = rowCount; + this.GridOriginY = gridOriginY; + this.binCount = (rowCount + BinRows - 1) / BinRows; + this.stride = this.binCount + 1; + this.data = ArrayPool.Shared.Rent(ArrayCount * this.stride); + this.data.AsSpan(0, ArrayCount * this.stride).Clear(); + } + + /// + /// Gets the number of tile rows of the grid. + /// + public int RowCount { get; } + + /// + /// Gets the data-space Y position of tile row 0. + /// + public int GridOriginY { get; } + + /// + /// Gets the path rows of retained scenes read in place. They count in every window. + /// + public long RetainedPathRows { get; private set; } + + /// + /// Gets the path tiles of retained scenes read in place. They count in every window. + /// + public long RetainedTiles { get; private set; } + + /// + /// Gets the tile crossings of retained scenes read in place. They count in every window. + /// + public long RetainedCrossings { get; private set; } + + /// + /// Gets the PTCL words of retained scenes read in place. They count in every window. + /// + public long RetainedPtclWords { get; private set; } + + /// + /// Adds the bounds of one draw. + /// + /// The first tile row of the clipped draw bounds. + /// The tile row after the clipped draw bounds. + /// The width of the clipped draw bounds in tiles, or zero when the draw adds its rows with . + /// The path-tile bound of the draw. + /// The tile-crossing bound of the draw. + /// The PTCL word bound of the draw. + /// The largest PTCL word count that the draw writes in one tile. + /// An upper bound for the tile crossings of the body lines of the draw in one tile row. + /// The tile crossings of the other lines of the draw, such as stroke joins and caps, which can all be in one row. + public void AddDraw( + int firstRow, + int endRow, + int widthTiles, + long tiles, + long crossings, + long ptclWords, + int ptclWordsPerTile, + long crossingsPerRow, + long endCrossings) + { + // The encoder clips each draw to the range target, which is the grid of these bounds, so + // the rows are inside the grid and not empty. + Span data = this.data; + this.AddRows(data, PathRowsArea, firstRow, endRow, 1); + this.AddRows(data, TilesArea, firstRow, endRow, widthTiles); + this.AddRows(data, PtclArea, firstRow, endRow, (long)widthTiles * ptclWordsPerTile); + this.AddRows(data, CrossingsArea, firstRow, endRow, crossingsPerRow); + + int firstBin = firstRow / BinRows; + int lastBin = (endRow - 1) / BinRows; + data[(TilesFirst * this.stride) + firstBin] += tiles; + data[(TilesLast * this.stride) + lastBin] += tiles; + data[(CrossingsFirst * this.stride) + firstBin] += crossings; + data[(CrossingsLast * this.stride) + lastBin] += crossings; + data[(PtclFirst * this.stride) + firstBin] += ptclWords; + data[(PtclLast * this.stride) + lastBin] += ptclWords; + data[(EndCrossingsFirst * this.stride) + firstBin] += endCrossings; + data[(EndCrossingsLast * this.stride) + lastBin] += endCrossings; + } + + /// + /// Adds the walked tile span of one row of a draw. A draw whose rows are added here passes a + /// zero width to , so its area sums hold the walked spans. + /// + /// The tile row. + /// The tile span of the draw in the row. + /// The PTCL word bound of the draw in the row. + public void AddRowTiles(int row, long tiles, long ptclWords) + { + Span data = this.data; + int bin = row / BinRows; + data[(TilesArea * this.stride) + bin] += tiles; + data[(TilesArea * this.stride) + bin + 1] -= tiles; + data[(PtclArea * this.stride) + bin] += ptclWords; + data[(PtclArea * this.stride) + bin + 1] -= ptclWords; + } + + /// + /// Adds the totals of a retained scene read in place. Its rows are not tracked, so its totals + /// count in every window. + /// + /// The path-row bound of the retained scene. + /// The path-tile bound of the retained scene. + /// The tile-crossing bound of the retained scene. + /// The PTCL word bound of the retained scene. + public void AddRetained(long pathRows, long tiles, long crossings, long ptclWords) + { + this.RetainedPathRows += pathRows; + this.RetainedTiles += tiles; + this.RetainedCrossings += crossings; + this.RetainedPtclWords += ptclWords; + } + + /// + /// Adds the bounds of another grid with the same rows, such as one partition of the same scene. + /// + /// The other bounds. Neither instance is sealed. + public void Add(WebGPUSceneRowBounds other) + { + Span target = this.data.AsSpan(0, ArrayCount * this.stride); + ReadOnlySpan source = other.data.AsSpan(0, ArrayCount * other.stride); + for (int i = 0; i < target.Length; i++) + { + target[i] += source[i]; + } + + this.AddRetained(other.RetainedPathRows, other.RetainedTiles, other.RetainedCrossings, other.RetainedPtclWords); + } + + /// + /// Converts the added entries to prefix sums. Call once, after the last add and before the + /// first query. + /// + public void Seal() + { + Span data = this.data; + int bins = this.binCount; + + // An area array holds differences. The running sum is the per-bin value, and the entry at + // bin b becomes the sum of the per-bin values of bins 0 to b - 1. + for (int a = PathRowsArea; a <= CrossingsArea; a++) + { + Span array = data.Slice(a * this.stride, this.stride); + long binValue = 0; + long sum = 0; + for (int b = 0; b < bins; b++) + { + binValue += array[b]; + array[b] = sum; + sum += binValue; + } + + array[bins] = sum; + } + + // A first or last array holds per-bin additions. The entry at bin b becomes the sum of + // the additions at bins 0 to b - 1. + for (int a = TilesFirst; a <= EndCrossingsLast; a++) + { + Span array = data.Slice(a * this.stride, this.stride); + long sum = 0; + for (int b = 0; b < bins; b++) + { + long value = array[b]; + array[b] = sum; + sum += value; + } + + array[bins] = sum; + } + } + + /// + /// Gets the bounds of the draws that reach a data-space row interval. + /// + /// The data-space Y position of the window top. + /// The data-space Y position of the window bottom. + /// The window bounds. + public WebGPUSceneWindowBounds GetWindow(int dataY0, int dataY1) + { + // The window can start between grid rows when the target is not on the grid of the data, + // so it widens to whole grid rows, and then to whole bins. + const int binPixels = BinRows * WebGPUSceneEncoder.TileHeight; + int start = Math.Clamp(FloorDivide(dataY0 - this.GridOriginY, binPixels), 0, this.binCount); + int end = Math.Clamp(-FloorDivide(this.GridOriginY - dataY1, binPixels), start, this.binCount); + ReadOnlySpan data = this.data; + + long pathRows = this.Area(data, PathRowsArea, start, end); + long tiles = Math.Min(this.Area(data, TilesArea, start, end), this.Overlap(data, TilesFirst, TilesLast, start, end)); + long crossings = Math.Min( + this.Area(data, CrossingsArea, start, end) + this.Overlap(data, EndCrossingsFirst, EndCrossingsLast, start, end), + this.Overlap(data, CrossingsFirst, CrossingsLast, start, end)); + + long ptclWords = Math.Min(this.Area(data, PtclArea, start, end), this.Overlap(data, PtclFirst, PtclLast, start, end)); + + return new WebGPUSceneWindowBounds( + pathRows + this.RetainedPathRows, + tiles + this.RetainedTiles, + crossings + this.RetainedCrossings, + ptclWords + this.RetainedPtclWords); + } + + /// + /// Returns the rented block. + /// + public void Dispose() + { + long[]? data = this.data; + if (data is null) + { + return; + } + + this.data = null; + ArrayPool.Shared.Return(data); + } + + /// + /// Adds a value for each tile row of an interval to an area array. + /// + /// + /// A bin receives the value times the rows of the interval in that bin. The first and last bins + /// can be partial, so they get a single-bin entry. The full bins between them share one + /// difference entry. + /// + /// The unsealed block. + /// The array index. + /// The first tile row. + /// The tile row after the interval. + /// The value of each row. + private void AddRows(Span data, int array, int firstRow, int endRow, long valuePerRow) + { + if (valuePerRow == 0) + { + return; + } + + int offset = array * this.stride; + int firstBin = firstRow / BinRows; + int lastBin = (endRow - 1) / BinRows; + if (firstBin == lastBin) + { + long value = valuePerRow * (endRow - firstRow); + data[offset + firstBin] += value; + data[offset + firstBin + 1] -= value; + return; + } + + long firstValue = valuePerRow * (((firstBin + 1) * BinRows) - firstRow); + data[offset + firstBin] += firstValue; + data[offset + firstBin + 1] -= firstValue; + + long lastValue = valuePerRow * (endRow - (lastBin * BinRows)); + data[offset + lastBin] += lastValue; + data[offset + lastBin + 1] -= lastValue; + + if (lastBin > firstBin + 1) + { + long fullValue = valuePerRow * BinRows; + data[offset + firstBin + 1] += fullValue; + data[offset + lastBin] -= fullValue; + } + } + + /// + /// Gets the window sum of an area array. + /// + /// The sealed block. + /// The array index. + /// The first window bin. + /// The bin after the window. + /// The window sum. + private long Area(ReadOnlySpan data, int array, int start, int end) + => data[(array * this.stride) + end] - data[(array * this.stride) + start]; + + /// + /// Gets the full bounds of the draws that reach a window. + /// + /// The sealed block. + /// The index of the array keyed by the first bin of each draw. + /// The index of the array keyed by the last bin of each draw. + /// The first window bin. + /// The bin after the window. + /// The sum. + private long Overlap(ReadOnlySpan data, int first, int last, int start, int end) + => data[(first * this.stride) + end] - data[(last * this.stride) + start]; + + /// + /// Divides and rounds toward negative infinity. + /// + /// The dividend. + /// The positive divisor. + /// The quotient. + private static int FloorDivide(int value, int divisor) + => value >= 0 ? value / divisor : -((-value + divisor - 1) / divisor); +} diff --git a/src/ImageSharp.Drawing.WebGPU/WebGPUSceneWindowBounds.cs b/src/ImageSharp.Drawing.WebGPU/WebGPUSceneWindowBounds.cs new file mode 100644 index 000000000..472a22dbc --- /dev/null +++ b/src/ImageSharp.Drawing.WebGPU/WebGPUSceneWindowBounds.cs @@ -0,0 +1,45 @@ +// Copyright (c) Six Labors. +// Licensed under the Six Labors Split License. + +namespace SixLabors.ImageSharp.Drawing.Processing.Backends; + +/// +/// Holds the scratch bounds of the draws that reach one tile-row window. +/// +internal readonly struct WebGPUSceneWindowBounds +{ + /// + /// Initializes a new instance of the struct. + /// + /// The path-row bound. + /// The path-tile bound. + /// The tile-crossing bound. + /// The PTCL word bound, without the per-tile chunk allowance. + public WebGPUSceneWindowBounds(long pathRows, long tiles, long crossings, long ptclWords) + { + this.PathRows = pathRows; + this.Tiles = tiles; + this.Crossings = crossings; + this.PtclWords = ptclWords; + } + + /// + /// Gets the path-row bound. + /// + public long PathRows { get; } + + /// + /// Gets the path-tile bound. + /// + public long Tiles { get; } + + /// + /// Gets the tile-crossing bound. + /// + public long Crossings { get; } + + /// + /// Gets the PTCL word bound, without the per-tile chunk allowance. + /// + public long PtclWords { get; } +} diff --git a/src/ImageSharp.Drawing/Processing/Backends/CompositionSceneCommandBase.cs b/src/ImageSharp.Drawing/Processing/Backends/CompositionSceneCommandBase.cs index d859048f2..a8d38d9f5 100644 --- a/src/ImageSharp.Drawing/Processing/Backends/CompositionSceneCommandBase.cs +++ b/src/ImageSharp.Drawing/Processing/Backends/CompositionSceneCommandBase.cs @@ -138,3 +138,54 @@ public PolylineCompositionSceneCommand(in StrokePolylineCommand command) /// public override void Accept(ICompositionSceneCommandVisitor visitor) => visitor.Visit(this); } + +/// +/// Scene command that renders one retained backend scene at its position in the command stream. +/// +/// +/// This type is internal, so the public contract does +/// not change. Only the canvas batcher records this command, and only the backends of this library +/// read it. They select it by type, the same as the other commands. +/// +internal sealed class RetainedSceneCompositionSceneCommand : CompositionSceneCommand +{ + /// + /// Initializes a new instance of the class. + /// + /// + /// Only the canvas batcher records this command, so every batch that contains it comes from + /// the batcher and carries . + /// + /// The retained scene to render. + /// The absolute bounds of the innermost open layer, or the scene bounds outside every layer. + /// True if the command was recorded inside a layer. + internal RetainedSceneCompositionSceneCommand(DrawingBackendScene scene, Rectangle targetBounds, bool isInsideLayer) + { + this.Scene = scene; + this.TargetBounds = targetBounds; + this.IsInsideLayer = isInsideLayer; + } + + /// + /// Gets the retained scene to render. + /// + public DrawingBackendScene Scene { get; } + + /// + /// Gets the absolute bounds of the innermost open layer, or the scene bounds outside every layer. + /// + public Rectangle TargetBounds { get; } + + /// + /// Gets a value indicating whether the command was recorded inside a layer. + /// + public bool IsInsideLayer { get; } + + /// + /// + /// The public visitor has no member for this internal command, so the visitor receives nothing. + /// + public override void Accept(ICompositionSceneCommandVisitor visitor) + { + } +} diff --git a/src/ImageSharp.Drawing/Processing/Backends/DefaultDrawingBackend.cs b/src/ImageSharp.Drawing/Processing/Backends/DefaultDrawingBackend.cs index 429104005..cdc0981ee 100644 --- a/src/ImageSharp.Drawing/Processing/Backends/DefaultDrawingBackend.cs +++ b/src/ImageSharp.Drawing/Processing/Backends/DefaultDrawingBackend.cs @@ -75,15 +75,47 @@ private static void ExecuteScene( FlushScene scene) where TPixel : unmanaged, IPixel { - // Warm the cached renderers before the row loop so the hot execution path only - // performs retained-scene work and brush application. + WarmRenderers(configuration, destinationFrame.Width, scene); + + // The root target carries null graphics options: options are only needed when a target + // is composited back as a layer, and the root frame is never composited onto anything. + BandTarget target = new(destinationFrame, targetBounds.X, targetBounds.Y, null); + + // Segments exist only when apply or scoped-layer barriers split the scene; each barrier + // requires all preceding rows to be fully rendered before it may read or composite pixels. + if (scene.Segments.Length != 0) + { + ExecuteSceneSegments(configuration, destinationFrame.Width, scene, scene.Segments, target); + return; + } + + if (scene.RowCount != 0) + { + ExecuteSceneRows(configuration, destinationFrame.Width, scene, target); + } + } + + /// + /// Creates the cached renderers of a retained scene and of the retained scenes whose rows it runs. + /// + /// + /// This runs before the row loop so the hot execution path only performs retained-scene work + /// and brush application. + /// + /// The pixel format. + /// The active processing configuration. + /// The destination canvas width. + /// The retained flush scene. + private static void WarmRenderers(Configuration configuration, int canvasWidth, FlushScene scene) + where TPixel : unmanaged, IPixel + { if (scene.FillItemCount > 0) { for (int i = 0; i < scene.FillItems.Length; i++) { if (scene.FillItems[i] is FlushScene.FillSceneItem item) { - _ = item.GetRenderer(configuration, destinationFrame.Width); + _ = item.GetRenderer(configuration, canvasWidth); } } } @@ -94,26 +126,19 @@ private static void ExecuteScene( { if (scene.StrokeItems[i] is FlushScene.StrokeSceneItem item) { - _ = item.GetRenderer(configuration, destinationFrame.Width); + _ = item.GetRenderer(configuration, canvasWidth); } } } - // The root target carries null graphics options: options are only needed when a target - // is composited back as a layer, and the root frame is never composited onto anything. - BandTarget target = new(destinationFrame, targetBounds.X, targetBounds.Y, null); - - // Segments exist only when apply or scoped-layer barriers split the scene; each barrier - // requires all preceding rows to be fully rendered before it may read or composite pixels. - if (scene.Segments.Length != 0) - { - ExecuteSceneSegments(configuration, destinationFrame.Width, scene, scene.Segments, target); - return; - } - - if (scene.RowCount != 0) + // The array is empty unless the batch had retained scene commands. Retained scenes with + // barriers are not in it. Their segment warms them before they run. + for (int i = 0; i < scene.RetainedScenes.Length; i++) { - ExecuteSceneRows(configuration, destinationFrame.Width, scene, target); + if (scene.RetainedScenes[i] is FlushScene retainedScene) + { + WarmRenderers(configuration, canvasWidth, retainedScene); + } } } @@ -161,6 +186,13 @@ private static void ExecuteSceneSegments( ExecuteSceneSegments(configuration, canvasWidth, scene, layerItem.Segments, layerTarget); CompositeLayerTarget(configuration, layerTarget, target); } + else if (segment.SceneItem is FlushScene retainedScene) + { + // A retained scene with barriers runs as a whole into the current target, which is + // the root target or a scoped layer target, the same as when it renders on its own. + WarmRenderers(configuration, canvasWidth, retainedScene); + ExecuteSceneSegments(configuration, canvasWidth, retainedScene, retainedScene.Segments, target); + } } } @@ -348,6 +380,22 @@ private static void ExecuteSceneRowOperations( strokeSceneItem.DestinationOffset, state); break; + + case FlushScene.SceneOperationKind.Scene: + // The retained row is a balanced operation stream for the same band. It runs + // with its own cursor and scene into the current target, so inside a layer it + // draws into the layer band. + FlushScene retainedScene = scene.RetainedScenes[operation.ItemIndex]!; + SceneOperationCursor retainedCursor = new(retainedScene.Rows[operation.LocalRowIndex].FirstBlock); + ExecuteSceneRowOperations( + ref retainedCursor, + configuration, + canvasWidth, + retainedScene, + target, + scratch, + state); + break; } } } @@ -374,6 +422,14 @@ private static int GetRowScratchWidth( continue; } + if (operation.Kind == FlushScene.SceneOperationKind.Scene) + { + // The retained row shares the worker scratch, so its items count too. + FlushScene retainedScene = scene.RetainedScenes[operation.ItemIndex]!; + width = GetRowScratchWidth(retainedScene, retainedScene.Rows[operation.LocalRowIndex], width); + continue; + } + int itemWidth = operation.Kind == FlushScene.SceneOperationKind.FillItem ? scene.FillItems[operation.ItemIndex]!.Rasterizable.Width : scene.StrokeItems[operation.ItemIndex]!.Rasterizable.Width; @@ -811,29 +867,19 @@ public void CopyPixels( throw new NotSupportedException($"{nameof(DefaultDrawingBackend)} requires CPU-accessible target frames."); } - Rectangle sourceBounds = new(0, 0, sourceRegion.Width, sourceRegion.Height); - Rectangle clippedSource = Rectangle.Intersect(sourceBounds, sourceRectangle); - if (clippedSource.Width <= 0 || clippedSource.Height <= 0) - { - return; - } - - Rectangle targetBounds = new(0, 0, targetRegion.Width, targetRegion.Height); - Rectangle targetRectangle = new( - targetPoint.X + clippedSource.X - sourceRectangle.X, - targetPoint.Y + clippedSource.Y - sourceRectangle.Y, - clippedSource.Width, - clippedSource.Height); - - Rectangle clippedTarget = Rectangle.Intersect(targetBounds, targetRectangle); - - if (clippedTarget.Width <= 0 || clippedTarget.Height <= 0) + if (!IDrawingBackend.TryClipPixelCopy( + new Size(sourceRegion.Width, sourceRegion.Height), + new Size(targetRegion.Width, targetRegion.Height), + sourceRectangle, + targetPoint, + out Point sourceOrigin, + out Rectangle clippedTarget)) { return; } - int sourceX = clippedSource.X + clippedTarget.X - targetRectangle.X; - int sourceY = clippedSource.Y + clippedTarget.Y - targetRectangle.Y; + int sourceX = sourceOrigin.X; + int sourceY = sourceOrigin.Y; int targetX = clippedTarget.X; int targetY = clippedTarget.Y; int width = clippedTarget.Width; diff --git a/src/ImageSharp.Drawing/Processing/Backends/DrawingCommandBatch.cs b/src/ImageSharp.Drawing/Processing/Backends/DrawingCommandBatch.cs index f50fcd108..0690809f2 100644 --- a/src/ImageSharp.Drawing/Processing/Backends/DrawingCommandBatch.cs +++ b/src/ImageSharp.Drawing/Processing/Backends/DrawingCommandBatch.cs @@ -12,8 +12,8 @@ public readonly struct DrawingCommandBatch /// Initializes a new instance of the struct. /// /// - /// Batches created through this constructor never contain apply barriers or clip controls; - /// those commands are only produced by the internal canvas batcher. + /// Batches created through this constructor never contain apply barriers, clip controls or + /// retained scene commands. Only the internal canvas batcher produces those commands. /// /// The draw-order scene commands. /// Indicates whether the command stream contains layer boundaries. @@ -25,27 +25,7 @@ public DrawingCommandBatch( this.HasLayers = hasLayers; this.HasApply = false; this.HasClipControls = false; - } - - /// - /// Initializes a new instance of the struct. - /// - /// The backing command buffer. - /// The number of commands in the prepared batch. - /// Indicates whether the command stream contains layer boundaries. - /// Indicates whether the command stream contains apply barriers. - /// Indicates whether the command stream contains clip controls. - internal DrawingCommandBatch( - CompositionSceneCommand[] commands, - int commandCount, - bool hasLayers, - bool hasApply, - bool hasClipControls) - { - this.Commands = new ArraySegment(commands, 0, commandCount); - this.HasLayers = hasLayers; - this.HasApply = hasApply; - this.HasClipControls = hasClipControls; + this.HasScenes = false; } /// @@ -57,18 +37,21 @@ internal DrawingCommandBatch( /// Indicates whether the command stream contains layer boundaries. /// Indicates whether the command stream contains apply barriers. /// Indicates whether the command stream contains clip controls. + /// Indicates whether the command stream contains retained scene commands. internal DrawingCommandBatch( CompositionSceneCommand[] commands, int startIndex, int commandCount, bool hasLayers, bool hasApply, - bool hasClipControls) + bool hasClipControls, + bool hasScenes) { this.Commands = new ArraySegment(commands, startIndex, commandCount); this.HasLayers = hasLayers; this.HasApply = hasApply; this.HasClipControls = hasClipControls; + this.HasScenes = hasScenes; } /// @@ -95,4 +78,153 @@ internal DrawingCommandBatch( /// Gets a value indicating whether this scene contains clip control commands. /// public bool HasClipControls { get; } + + /// + /// Gets a value indicating whether this scene contains retained scene commands. + /// + /// + /// This is internal because the retained scene command is internal. Only the canvas batcher sets it. + /// + internal bool HasScenes { get; } + + /// + /// Reads the next part of a batch that contains retained scene commands. A part is the run of + /// commands up to the next retained scene command outside every layer, and that retained scene. + /// + /// + /// + /// Call this only when is . Only the canvas batcher + /// records retained scene commands, and its batches always wrap an . + /// + /// + /// No layer is open at a retained scene command outside every layer, and the batcher closes the + /// open clips before it records one. Each run is therefore a balanced command stream that a + /// backend can render on its own, before the retained scene. + /// + /// + /// The first command index of the part. On return, the first command index of the next part. + /// The commands of the part before the retained scene command. + /// The retained scene that ends the part, or when the part ends at the end of the batch. + internal DrawingBackendScene? ReadRootPart(ref int index, out DrawingCommandBatch commands) + { + ArraySegment segment = (ArraySegment)this.Commands; + int start = index; + bool hasLayers = false; + bool hasApply = false; + bool hasClipControls = false; + bool hasScenes = false; + DrawingBackendScene? scene = null; + + // The flags of the run are computed again here, so a backend that renders the run alone + // takes the same path that it takes for a batch recorded without the retained scene. + for (; index < segment.Count; index++) + { + CompositionSceneCommand command = segment[index]; + if (command is RetainedSceneCompositionSceneCommand sceneCommand) + { + if (!sceneCommand.IsInsideLayer) + { + scene = sceneCommand.Scene; + break; + } + + hasScenes = true; + } + else if (command is PathCompositionSceneCommand pathCommand) + { + switch (pathCommand.Command.Kind) + { + case CompositionCommandKind.BeginLayer: + case CompositionCommandKind.EndLayer: + hasLayers = true; + break; + + case CompositionCommandKind.Apply: + hasApply = true; + break; + + case CompositionCommandKind.BeginClip: + case CompositionCommandKind.EndClip: + hasClipControls = true; + break; + } + } + } + + commands = new DrawingCommandBatch( + segment.Array!, + segment.Offset + start, + index - start, + hasLayers, + hasApply, + hasClipControls, + hasScenes); + + if (scene is not null) + { + index++; + } + + return scene; + } + + /// + /// Finds the retained scenes that hold ordered work, and the layers that must render as scoped + /// layers around them. + /// + /// + /// + /// Only a backend knows whether one of its scenes holds ordered work, such as an apply barrier, + /// so the backend supplies . A batch with such a scene needs the + /// ordered path of the backend, the same as a batch with an apply barrier. + /// + /// + /// Ordered work must see every pixel drawn before it. Each layer open around such a scene is + /// therefore scoped, the same as each layer open around an apply drawn directly in it. + /// + /// + /// Returns whether a retained scene of the backend holds ordered work. + /// + /// Receives, for each command index, whether the BeginLayer command at that index opens a layer + /// that must be scoped, or when no layer must be. + /// + /// when a retained scene in the batch holds ordered work. + internal bool FindOrderedScenes(Func hasOrderedWork, out bool[]? scopedLayers) + { + IReadOnlyList commands = this.Commands; + List? openLayers = null; + bool found = false; + scopedLayers = null; + + for (int i = 0; i < commands.Count; i++) + { + switch (commands[i]) + { + case PathCompositionSceneCommand { Command.Kind: CompositionCommandKind.BeginLayer }: + (openLayers ??= []).Add(i); + break; + + case PathCompositionSceneCommand { Command.Kind: CompositionCommandKind.EndLayer }: + // The canvas never seals a range inside a layer, so each EndLayer closes a + // BeginLayer of this batch. + openLayers!.RemoveAt(openLayers.Count - 1); + break; + + case RetainedSceneCompositionSceneCommand sceneCommand when hasOrderedWork(sceneCommand.Scene): + found = true; + if (openLayers is { Count: > 0 }) + { + scopedLayers ??= new bool[commands.Count]; + for (int j = 0; j < openLayers.Count; j++) + { + scopedLayers[openLayers[j]] = true; + } + } + + break; + } + } + + return found; + } } diff --git a/src/ImageSharp.Drawing/Processing/Backends/FlushScene.RetainedTypes.cs b/src/ImageSharp.Drawing/Processing/Backends/FlushScene.RetainedTypes.cs index e0104f18f..f64aadd4a 100644 --- a/src/ImageSharp.Drawing/Processing/Backends/FlushScene.RetainedTypes.cs +++ b/src/ImageSharp.Drawing/Processing/Backends/FlushScene.RetainedTypes.cs @@ -40,6 +40,11 @@ internal enum SceneOperationKind : byte /// Applies an image processor to the current target. /// Apply = 4, + + /// + /// Executes one row of a retained scene, or a whole retained scene with barriers, against the current target. + /// + Scene = 5, } /// @@ -83,12 +88,14 @@ public SceneOperation(CompositionCommandKind kind, Rectangle layerBounds, int it /// /// Gets the retained fill or stroke item index for draw operations. For layer operations /// this is the layer command's own index; begin-layer operations use it to look up the - /// shared layer state. In all cases this equals the original command index. + /// shared layer state. For scene operations it indexes . + /// In all cases this equals the original command index. /// public int ItemIndex { get; } /// - /// Gets the retained rasterizable row index for draw operations, or -1 for layer operations. + /// Gets the retained rasterizable row index for draw operations, the index into the + /// retained scene rows for scene operations, or -1 for layer operations. /// public int LocalRowIndex { get; } @@ -105,6 +112,7 @@ internal readonly struct SceneControlItem { private readonly ApplySceneItem? applyItem; private readonly DrawingCanvasLayer? layer; + private readonly FlushScene? scene; /// /// Initializes a new instance of the struct for a layer control operation. @@ -118,6 +126,7 @@ public SceneControlItem(CompositionCommandKind kind, Rectangle layerBounds, Draw this.LayerBounds = layerBounds; this.layer = layer; this.applyItem = null; + this.scene = null; } /// @@ -130,6 +139,21 @@ public SceneControlItem(ApplySceneItem applyItem) this.LayerBounds = default; this.layer = null; this.applyItem = applyItem; + this.scene = null; + } + + /// + /// Initializes a new instance of the struct for a retained + /// scene with barriers, which executes as a whole at its position in the stream. + /// + /// The retained scene. The caller owns it. + public SceneControlItem(FlushScene scene) + { + this.Kind = SceneOperationKind.Scene; + this.LayerBounds = default; + this.layer = null; + this.applyItem = null; + this.scene = scene; } /// @@ -151,6 +175,11 @@ public SceneControlItem(ApplySceneItem applyItem) /// Gets the retained apply operation. /// public ApplySceneItem ApplyItem => this.applyItem ?? throw new InvalidOperationException("Only apply steps carry apply state."); + + /// + /// Gets the retained scene for a scene operation. + /// + public FlushScene Scene => this.scene ?? throw new InvalidOperationException("Only scene steps carry a retained scene."); } /// @@ -398,16 +427,19 @@ internal sealed class SceneSegment : IDisposable /// The number of retained row operations in this segment. /// The apply operation executed after the rows. /// The scoped layer executed after the rows. + /// The retained scene with barriers executed after the rows. public SceneSegment( SceneRow[] rows, int rowItemCount, ApplySceneItem? applyItem, - ScopedLayerSceneItem? layerItem) + ScopedLayerSceneItem? layerItem, + FlushScene? sceneItem) { this.Rows = rows; this.RowItemCount = rowItemCount; this.ApplyItem = applyItem; this.LayerItem = layerItem; + this.SceneItem = sceneItem; } /// @@ -430,6 +462,12 @@ public SceneSegment( /// public ScopedLayerSceneItem? LayerItem { get; } + /// + /// Gets the retained scene with barriers executed after the rows. The caller of + /// owns it, so the segment does not dispose it. + /// + public FlushScene? SceneItem { get; } + /// public void Dispose() { diff --git a/src/ImageSharp.Drawing/Processing/Backends/FlushScene.cs b/src/ImageSharp.Drawing/Processing/Backends/FlushScene.cs index 04218f1bd..15aabe368 100644 --- a/src/ImageSharp.Drawing/Processing/Backends/FlushScene.cs +++ b/src/ImageSharp.Drawing/Processing/Backends/FlushScene.cs @@ -29,6 +29,7 @@ internal sealed partial class FlushScene : IDisposable strokeItems: [], layers: [], controlItems: [], + retainedScenes: [], hasApply: false, rows: [], segments: [], @@ -49,7 +50,8 @@ internal sealed partial class FlushScene : IDisposable /// The retained stroke items indexed by original command index. /// The retained layer state indexed by begin-layer command index. /// The layer and apply control operations indexed by original command index. - /// Whether the scene contains apply barriers. + /// The retained scenes whose rows run as scene operations, indexed by original command index. + /// Whether the scene contains apply barriers or retained scenes with barriers. /// The retained row lists. /// The retained target-wide execution segments for scenes containing apply barriers. /// The partition-owned aliased profile arenas. @@ -66,6 +68,7 @@ private FlushScene( StrokeSceneItem?[] strokeItems, DrawingCanvasLayer?[] layers, SceneControlItem?[] controlItems, + FlushScene?[] retainedScenes, bool hasApply, SceneRow[] rows, SceneSegment[] segments, @@ -83,6 +86,7 @@ private FlushScene( this.StrokeItems = strokeItems; this.Layers = layers; this.ControlItems = controlItems; + this.RetainedScenes = retainedScenes; this.HasApply = hasApply; this.Rows = rows; this.Segments = segments; @@ -125,7 +129,16 @@ private FlushScene( private SceneControlItem?[] ControlItems { get; } /// - /// Gets a value indicating whether the scene contains apply barriers. + /// Gets the retained scenes whose rows run as scene operations, indexed by original command index. + /// + /// + /// The caller of owns these scenes and keeps them alive + /// while this scene is in use, so this scene does not dispose them. + /// + public FlushScene?[] RetainedScenes { get; } + + /// + /// Gets a value indicating whether the scene contains apply barriers or retained scenes with barriers. /// public bool HasApply { get; } @@ -207,10 +220,22 @@ public static FlushScene Create( return Empty(); } + // A retained scene with barriers runs as segments, so a batch that contains one takes the + // segment path of an apply barrier, and each layer open around it is scoped. + bool hasBarriers = scene.HasApply; + bool[]? scopedSceneLayers = null; + if (scene.HasScenes) + { + hasBarriers |= scene.FindOrderedScenes( + static retained => retained is DefaultDrawingBackendScene { Scene.Segments.Length: > 0 }, + out scopedSceneLayers); + } + FillSceneItem?[] fillItems = new FillSceneItem?[commandCount]; StrokeSceneItem?[] strokeItems = new StrokeSceneItem?[commandCount]; DrawingCanvasLayer?[] layers = new DrawingCanvasLayer?[commandCount]; - SceneControlItem?[] controlItems = scene.HasApply ? new SceneControlItem?[commandCount] : []; + SceneControlItem?[] controlItems = hasBarriers ? new SceneControlItem?[commandCount] : []; + FlushScene?[] retainedScenes = scene.HasScenes ? new FlushScene?[commandCount] : []; int partitionCount = ParallelExecutionHelper.GetPartitionCount(maxDegreeOfParallelism, commandCount, targetRowCount); PartitionState[] partitions = new PartitionState[partitionCount]; LinearGeometryProfileStorage[] profileStorages = new LinearGeometryProfileStorage[partitionCount]; @@ -252,7 +277,8 @@ public static FlushScene Create( fillItems, strokeItems, layers, - controlItems); + controlItems, + retainedScenes); partitions[partitionIndex] = partition; profileStorages[partitionIndex] = partition.ProfileStorage; @@ -260,6 +286,7 @@ public static FlushScene Create( int fillItemCount = 0; int strokeItemCount = 0; + int sceneItemCount = 0; long totalEdgeCount = 0; int singleBandItemCount = 0; int smallEdgeItemCount = 0; @@ -273,6 +300,7 @@ public static FlushScene Create( PartitionState partition = partitions[i]; fillItemCount += partition.FillItemCount; strokeItemCount += partition.StrokeItemCount; + sceneItemCount += partition.SceneItemCount; totalEdgeCount += partition.TotalEdgeCount; singleBandItemCount += partition.SingleBandItemCount; smallEdgeItemCount += partition.SmallEdgeItemCount; @@ -302,8 +330,9 @@ public static FlushScene Create( } // Apply barriers execute even when no draw work survived clipping, so a scene that - // contains them can never collapse to the empty singleton. - if (((fillItemCount + strokeItemCount) == 0 || rowItemCount == 0) && !scene.HasApply) + // contains them can never collapse to the empty singleton. Retained scene rows are + // draw work of this scene, the same as its own fill and stroke items. + if (((fillItemCount + strokeItemCount + sceneItemCount) == 0 || rowItemCount == 0) && !hasBarriers) { // Retained items whose row operations were all clipped away still own rasterized // geometry and clip state; the empty singleton carries nothing, so release them here. @@ -318,12 +347,13 @@ public static FlushScene Create( : FinalizeRows(rowBuilders, firstTargetRowBandIndex, rowCount); rowsFinalized = true; - if (scene.HasApply) + if (hasBarriers) { segments = CreateApplySegments( commandCount, sceneRows, controlItems, + scopedSceneLayers, allocator, firstTargetRowBandIndex, targetRowCount, @@ -355,7 +385,8 @@ public static FlushScene Create( strokeItems, layers, controlItems, - scene.HasApply, + retainedScenes, + hasBarriers, sceneRows, segments, profileStorages); @@ -756,6 +787,10 @@ private static void DisposeRows(RowBuilder[] builders) /// The number of commands in the source batch. /// The merged scene rows whose operations are redistributed into segments. /// The layer and apply control operations indexed by original command index. + /// + /// For each command index, whether the BeginLayer command there opens a layer around a retained + /// scene with barriers, or when there is no such layer. + /// /// The allocator used for retained row storage. /// The first row-band index covered by the target. /// The number of row bands covered by the target. @@ -766,6 +801,7 @@ private static SceneSegment[] CreateApplySegments( int commandCount, SceneRow[] rows, SceneControlItem?[] controlItems, + bool[]? scopedSceneLayers, MemoryAllocator allocator, int firstTargetRowBandIndex, int targetRowCount, @@ -788,7 +824,7 @@ private static SceneSegment[] CreateApplySegments( switch (controlItem.Kind) { case SceneOperationKind.BeginLayer: - if (controlItem.Layer.RequiresScopedApply) + if (controlItem.Layer.RequiresScopedApply || scopedSceneLayers?[commandIndex] == true) { SceneSequenceBuilder child = new(allocator, firstTargetRowBandIndex, targetRowCount); scopedLayers.Add(new ScopedLayerBuildFrame(current, controlItem.Layer, controlItem.LayerBounds, child)); @@ -820,6 +856,10 @@ private static SceneSegment[] CreateApplySegments( case SceneOperationKind.Apply: current.AddApply(controlItem.ApplyItem); break; + + case SceneOperationKind.Scene: + current.AddScene(controlItem.Scene); + break; } } @@ -909,6 +949,7 @@ private static (DrawingClipDescriptor Descriptor, Point Anchor)[][] CreatePartit /// The shared retained stroke items indexed by original command index. /// The shared retained layer state indexed by begin-layer command index. /// The shared layer and apply control operations indexed by original command index; empty when the batch has no apply barriers. + /// The shared retained scenes indexed by original command index; empty when the batch has no retained scene commands. /// The partition-local counts and row builders to merge after all partitions complete. private static PartitionState ProcessPartition( IReadOnlyList commands, @@ -922,11 +963,13 @@ private static PartitionState ProcessPartition( FillSceneItem?[] fillItems, StrokeSceneItem?[] strokeItems, DrawingCanvasLayer?[] layers, - SceneControlItem?[] controlItems) + SceneControlItem?[] controlItems, + FlushScene?[] retainedScenes) { RowBuilder[] rowBuilders = new RowBuilder[targetRowCount]; int fillItemCount = 0; int strokeItemCount = 0; + int sceneItemCount = 0; long totalEdgeCount = 0; int singleBandItemCount = 0; int smallEdgeItemCount = 0; @@ -1035,11 +1078,25 @@ private static PartitionState ProcessPartition( ref singleBandItemCount, ref smallEdgeItemCount); } + else if (command is RetainedSceneCompositionSceneCommand sceneCommand) + { + ProcessSceneCommand( + sceneCommand, + commandIndex, + targetBounds, + firstTargetRowBandIndex, + rowBuilders, + allocator, + controlItems, + retainedScenes, + ref sceneItemCount); + } } return new PartitionState( fillItemCount, strokeItemCount, + sceneItemCount, totalEdgeCount, singleBandItemCount, smallEdgeItemCount, @@ -1433,6 +1490,85 @@ private static void ProcessPolylineCommand( AppendStrokeRowOperations(rowBuilders, rowStart, rowEnd, firstTargetRowBandIndex, commandIndex, preparedStroke.Rasterizable, allocator); } + /// + /// Prepares one retained scene command. A retained scene with rows only adds one scene + /// operation to each row band that it draws, so its rows run in the band order of this scene. + /// A retained scene with barriers becomes a control item that runs the whole scene at its + /// position in the stream. + /// + /// The retained scene command to prepare. + /// The original command index; the retained scene is stored at this index. + /// The destination bounds of the flush. + /// The first row-band index covered by the target. + /// The partition-owned row builders, one slot per target row band. + /// The allocator used for row-block storage. + /// The shared layer and apply control operations; empty when the batch has no apply barriers. + /// The shared retained scenes indexed by original command index. + /// The running partition count of retained scenes with work. + private static void ProcessSceneCommand( + RetainedSceneCompositionSceneCommand command, + int commandIndex, + in Rectangle targetBounds, + int firstTargetRowBandIndex, + RowBuilder[] rowBuilders, + MemoryAllocator allocator, + SceneControlItem?[] controlItems, + FlushScene?[] retainedScenes, + ref int sceneItemCount) + { + // RenderScene accepts any backend scene, so this is the boundary that checks it. The + // bounds must match so that both scenes use the same row bands. + if (command.Scene is not DefaultDrawingBackendScene cpuScene) + { + throw new InvalidOperationException("The scene is not compatible with the CPU drawing backend."); + } + + if (cpuScene.Bounds != targetBounds) + { + throw new InvalidOperationException("The target bounds do not match the CPU drawing backend scene bounds."); + } + + FlushScene scene = cpuScene.Scene; + if (scene.Segments.Length != 0) + { + // The scene holds barriers, so its rows cannot run inside the bands of this scene. Create + // found it with FindOrderedScenes, so the control items exist and every open layer + // around it is scoped. + controlItems[commandIndex] = new SceneControlItem(scene); + sceneItemCount++; + return; + } + + if (scene.RowCount == 0) + { + return; + } + + retainedScenes[commandIndex] = scene; + sceneItemCount++; + + // Inside a layer the rows are limited to the layer bands, the same as for a fill in that + // layer. Retained row operations outside the layer bounds would otherwise draw on the parent. + GetEffectiveRowSlotRange(command.TargetBounds, firstTargetRowBandIndex, rowBuilders.Length, command.IsInsideLayer, out int rowStart, out int rowEnd); + SceneRow[] rows = scene.Rows; + for (int rowIndex = 0; rowIndex < rows.Length; rowIndex++) + { + int rowSlot = rows[rowIndex].RowBandIndex - firstTargetRowBandIndex; + if (rowSlot < rowStart || rowSlot >= rowEnd) + { + continue; + } + + ref RowBuilder builder = ref rowBuilders[rowSlot]; + if (!builder.IsInitialized) + { + builder = new RowBuilder(allocator); + } + + builder.Append(new SceneOperation(SceneOperationKind.Scene, commandIndex, rowIndex)); + } + } + /// /// Appends one begin or end layer operation into every row builder the layer touches so that /// per-row rendering can open and close the layer at the correct point in row order. @@ -1822,19 +1958,7 @@ private static Rectangle ToConservativeBounds(RectangleF bounds) /// The inflated stroke bounds. private static RectangleF GetStrokeBounds(RectangleF bounds, Pen pen, float widthScale) { - float halfWidth = pen.StrokeWidth * widthScale * 0.5F; - float joinInflate = pen.StrokeOptions.LineJoin switch - { - LineJoin.Miter or LineJoin.MiterRevert or LineJoin.MiterRound => (float)(halfWidth * Math.Max(pen.StrokeOptions.MiterLimit, 1D)), - _ => halfWidth - }; - - float capInflate = pen.StrokeOptions.LineCap == LineCap.Square - ? halfWidth * MathF.Sqrt(2F) - : halfWidth; - - float inflate = MathF.Max(joinInflate, capInflate); - + float inflate = pen.GetOutlineRadius(widthScale); bounds.Inflate(new SizeF(inflate, inflate)); return bounds; } @@ -1961,6 +2085,7 @@ private readonly struct PartitionState /// /// The number of fill items retained by the partition. /// The number of stroke items retained by the partition. + /// The number of retained scenes with work in the partition. /// The total number of encoded raster edges retained by the partition. /// The number of items that occupy a single row band. /// The number of covered bands with a small edge count. @@ -1971,6 +2096,7 @@ private readonly struct PartitionState public PartitionState( int fillItemCount, int strokeItemCount, + int sceneItemCount, long totalEdgeCount, int singleBandItemCount, int smallEdgeItemCount, @@ -1981,6 +2107,7 @@ public PartitionState( { this.FillItemCount = fillItemCount; this.StrokeItemCount = strokeItemCount; + this.SceneItemCount = sceneItemCount; this.TotalEdgeCount = totalEdgeCount; this.SingleBandItemCount = singleBandItemCount; this.SmallEdgeItemCount = smallEdgeItemCount; @@ -2000,6 +2127,11 @@ public PartitionState( /// public int StrokeItemCount { get; } + /// + /// Gets the number of retained scenes with work in the partition. + /// + public int SceneItemCount { get; } + /// /// Gets the total number of encoded raster edges retained by the partition. /// @@ -2133,6 +2265,16 @@ public void AddLayer(ScopedLayerSceneBuilder layerBuilder) this.CurrentSegment = this.CreateSegment(); } + /// + /// Appends a retained scene with barriers after the current row segment. + /// + /// The retained scene. The caller owns it. + public void AddScene(FlushScene scene) + { + this.CurrentSegment.SetScene(scene); + this.CurrentSegment = this.CreateSegment(); + } + /// /// Finalizes all retained segments in this sequence. /// @@ -2180,6 +2322,7 @@ private sealed class SceneSegmentBuilder private RowBuilder[]? rowBuilders; private ApplySceneItem? applyItem; private ScopedLayerSceneBuilder? layerBuilder; + private FlushScene? sceneItem; /// /// Initializes a new instance of the class. @@ -2221,6 +2364,12 @@ public void Append(int rowSlot, SceneOperation operation) /// The retained scoped layer builder. public void SetLayer(ScopedLayerSceneBuilder builder) => this.layerBuilder = builder; + /// + /// Sets the retained scene with barriers executed after this segment's rows. + /// + /// The retained scene. The caller owns it. + public void SetScene(FlushScene scene) => this.sceneItem = scene; + /// /// Finalizes this builder into retained segment storage. /// @@ -2261,13 +2410,13 @@ public bool FinalizeSegment( rowCount = segmentRowCount + (layerItem?.RowCount ?? 0); rowItemCount = segmentRowItemCount + (layerItem?.RowItemCount ?? 0); - if (segmentRowCount == 0 && this.applyItem is null && layerItem is null) + if (segmentRowCount == 0 && this.applyItem is null && layerItem is null && this.sceneItem is null) { segment = null; return false; } - segment = new SceneSegment(rows, segmentRowItemCount, this.applyItem, layerItem); + segment = new SceneSegment(rows, segmentRowItemCount, this.applyItem, layerItem, this.sceneItem); return true; } } diff --git a/src/ImageSharp.Drawing/Processing/Backends/IDrawingBackend.cs b/src/ImageSharp.Drawing/Processing/Backends/IDrawingBackend.cs index ef9694662..52a658938 100644 --- a/src/ImageSharp.Drawing/Processing/Backends/IDrawingBackend.cs +++ b/src/ImageSharp.Drawing/Processing/Backends/IDrawingBackend.cs @@ -56,6 +56,54 @@ public void CopyPixels( Point targetPoint) where TPixel : unmanaged, IPixel; + /// + /// Clips a pixel copy the same way as : first the source rectangle + /// against the source frame, then the moved rectangle against the target frame. + /// + /// The size of the source frame. + /// The size of the target frame. + /// The source rectangle in source-local coordinates. + /// The target point in target-local coordinates. + /// Receives the source-local position of the first copied pixel. + /// Receives the target-local rectangle that the copy writes. + /// when the clipped copy is not empty. + internal static bool TryClipPixelCopy( + Size sourceSize, + Size targetSize, + Rectangle sourceRectangle, + Point targetPoint, + out Point sourceOrigin, + out Rectangle targetRectangle) + { + Rectangle clippedSource = Rectangle.Intersect(new Rectangle(Point.Empty, sourceSize), sourceRectangle); + if (clippedSource.Width <= 0 || clippedSource.Height <= 0) + { + sourceOrigin = default; + targetRectangle = default; + return false; + } + + Rectangle movedSource = new( + targetPoint.X + clippedSource.X - sourceRectangle.X, + targetPoint.Y + clippedSource.Y - sourceRectangle.Y, + clippedSource.Width, + clippedSource.Height); + + targetRectangle = Rectangle.Intersect(new Rectangle(Point.Empty, targetSize), movedSource); + if (targetRectangle.Width <= 0 || targetRectangle.Height <= 0) + { + sourceOrigin = default; + return false; + } + + // Target-side clipping moves the source origin by the same amount. + sourceOrigin = new Point( + clippedSource.X + targetRectangle.X - movedSource.X, + clippedSource.Y + targetRectangle.Y - movedSource.Y); + + return true; + } + /// /// Reads source pixels from the target into the destination region. /// diff --git a/src/ImageSharp.Drawing/Processing/DrawingCanvas.cs b/src/ImageSharp.Drawing/Processing/DrawingCanvas.cs index 6be0dfd3f..4856d4874 100644 --- a/src/ImageSharp.Drawing/Processing/DrawingCanvas.cs +++ b/src/ImageSharp.Drawing/Processing/DrawingCanvas.cs @@ -457,9 +457,13 @@ public abstract void DrawImage( /// canvas queue. /// /// - /// The active clip range is closed before the scene is built and reopened afterwards, so this - /// acts as an ordering boundary in the recorded timeline. Image resources retained for the - /// sealed commands are transferred to the returned scene, which owns and disposes them. + /// The canvas closes the active clips and layers at the end of the returned scene, and opens + /// them again after it. This call is therefore an ordering boundary in the recorded timeline. + /// A layer that is open at this call composites one time at the end of the returned scene. Its + /// later content composites as a second layer with the same options. Scenes recorded through + /// are part of the returned scene, in their recorded order. The caller + /// still owns those scenes and must keep them alive while the returned scene is in use. The + /// returned scene owns the image resources of the sealed commands and disposes them. /// /// The created backend scene. public abstract DrawingBackendScene CreateScene(); @@ -468,10 +472,11 @@ public abstract void DrawImage( /// Records a previously created backend scene into this canvas' command timeline. /// /// - /// The active command range is sealed before the scene is inserted so the scene renders in - /// submission order relative to the commands recorded around it. Like other recorded work, - /// the scene is replayed into the target frame when the root canvas is disposed or otherwise - /// flushed, not at the point of this call. + /// The scene renders in submission order relative to the commands recorded around it. Inside + /// an open layer, the scene renders into that layer, the same as commands drawn there. Outside + /// every layer, the active clips do not apply to the scene. Inside a layer, the clips opened in + /// that layer do not apply to the scene. The scene renders into the target frame with the other + /// recorded work, not at the point of this call. The caller must keep the scene alive until then. /// /// The backend scene to render. public abstract void RenderScene(DrawingBackendScene scene); @@ -485,7 +490,7 @@ public abstract void DrawImage( /// are replayed into the target only when the root canvas is disposed, or when they are /// lowered explicitly through , or /// . Use this to establish an ordering boundary in the recorded - /// timeline without ending the canvas. + /// timeline without ending the canvas. Inside an open layer, this method does nothing. /// public abstract void Flush(); diff --git a/src/ImageSharp.Drawing/Processing/DrawingCanvasBatcher{TPixel}.cs b/src/ImageSharp.Drawing/Processing/DrawingCanvasBatcher{TPixel}.cs index 7a269db93..62a6b26cc 100644 --- a/src/ImageSharp.Drawing/Processing/DrawingCanvasBatcher{TPixel}.cs +++ b/src/ImageSharp.Drawing/Processing/DrawingCanvasBatcher{TPixel}.cs @@ -2,6 +2,7 @@ // Licensed under the Six Labors Split License. using System.Numerics; +using System.Runtime.InteropServices; using SixLabors.ImageSharp.Drawing.Processing.Backends; namespace SixLabors.ImageSharp.Drawing.Processing; @@ -13,9 +14,9 @@ namespace SixLabors.ImageSharp.Drawing.Processing; /// /// The batcher owns command buffering and replay ordering only; it does not rasterize or composite. /// Draw commands are stored in the command buffer until a timeline command-range entry references -/// them. Existing retained scenes passed through are stored -/// separately and referenced by timeline entry index. During disposal replay, command ranges are -/// lowered to short-lived backend scenes at the position where the canvas recorded the range. +/// them. Existing retained scenes passed through are scene +/// commands in the same buffer. During disposal replay, command ranges are lowered to short-lived +/// backend scenes at the position where the canvas recorded the range. /// /// The pixel format. internal sealed class DrawingCanvasBatcher @@ -37,6 +38,8 @@ internal sealed class DrawingCanvasBatcher private int sealedApplyCommandCount; private int clipCommandCount; private int sealedClipCommandCount; + private int sceneCommandCount; + private int sealedSceneCommandCount; // Dash and brush-transform flags gate whole-buffer command preparation; prepared commands // remain in the same command buffer until replay consumes it. @@ -48,14 +51,18 @@ internal sealed class DrawingCanvasBatcher // drawing moves to a differently-offset context (for example a child region). private readonly List<(DrawingClipDescriptor Descriptor, Point AnchorOffset)> openClips = []; - // Timeline entries keep compact indexes into the command and retained - // scene buffers while preserving the order recorded by the canvas. - private DrawingCanvasTimelineEntry[] entries; + // Mirrors the physical begin/end-layer command stream. ClipDepth is the open clip count when + // the layer began, so the clips opened inside each layer are known. Every canvas that shares + // this batcher records into the same stream, so this is the one source of the open layers. + private readonly List<(Rectangle Bounds, DrawingCanvasLayer Layer, int ClipDepth)> openLayers = []; + + // The command index and the kind counters when the outermost open layer began. The commands + // from that index are the content of the open layers (see DetachOpenLayerCommands). + private CommandCounts outerLayerStart; - // These are existing retained scenes recorded through RenderScene, not scenes - // produced later from this batcher's own command ranges. - private DrawingBackendScene[] insertedScenes; - private int insertedSceneCount; + // Timeline entries keep compact indexes into the command buffer, and keep the order that the + // canvas recorded. + private DrawingCanvasTimelineEntry[] entries; /// /// Initializes a new instance of the class. @@ -66,7 +73,6 @@ public DrawingCanvasBatcher(Configuration configuration) this.configuration = configuration; this.commands = []; this.entries = []; - this.insertedScenes = []; } /// @@ -78,11 +84,15 @@ public DrawingCanvasBatcher(Configuration configuration) /// Gets the number of ordered replay items recorded in the canvas timeline. /// /// - /// This is not a draw-command count. A single entry can represent a contiguous command range, - /// an apply barrier, or an inserted retained scene. + /// This is not a draw-command count. A single entry represents a contiguous command range. /// public int TimelineEntryCount { get; private set; } + /// + /// Gets the number of layers that are open in the recorded command stream. + /// + public int OpenLayerCount => this.openLayers.Count; + /// /// Appends one normalized composition command to the pending queue. /// @@ -103,6 +113,19 @@ public void AddComposition(in CompositionCommand composition) break; + case CompositionCommandKind.BeginLayer: + if (this.openLayers.Count == 0) + { + this.outerLayerStart = this.CaptureCounts(); + } + + this.openLayers.Add((composition.LayerBounds, composition.Layer, this.openClips.Count)); + break; + + case CompositionCommandKind.EndLayer: + this.openLayers.RemoveAt(this.openLayers.Count - 1); + break; + default: break; } @@ -226,17 +249,19 @@ public void SealCommands() } this.EnsureEntryCapacity(this.TimelineEntryCount + 1); - this.entries[this.TimelineEntryCount++] = DrawingCanvasTimelineEntry.CreateCommandRange( + this.entries[this.TimelineEntryCount++] = new DrawingCanvasTimelineEntry( this.sealedCommandCount, count, this.layerCommandCount != this.sealedLayerCommandCount, this.applyCommandCount != this.sealedApplyCommandCount, - this.clipCommandCount != this.sealedClipCommandCount); + this.clipCommandCount != this.sealedClipCommandCount, + this.sceneCommandCount != this.sealedSceneCommandCount); this.sealedCommandCount = this.commandCount; this.sealedLayerCommandCount = this.layerCommandCount; this.sealedApplyCommandCount = this.applyCommandCount; this.sealedClipCommandCount = this.clipCommandCount; + this.sealedSceneCommandCount = this.sceneCommandCount; } /// @@ -251,24 +276,240 @@ public void AddApplyBarrier(ApplyBarrier barrier) } /// - /// Records an existing retained scene in the replay timeline after sealing queued commands. + /// Appends a scene command that renders an existing retained scene at this point in the stream. /// /// - /// This stores only scenes passed to . Scenes produced + /// + /// This records only scenes passed to . Scenes produced /// from this canvas's own command ranges are created later by the backend from command batches. + /// The command does not seal the range, so an open layer stays one layer around the scene. + /// + /// + /// Canvas clips do not apply to a retained scene, so the open clips close before the command and + /// open again after it. Inside a layer, only the clips opened in the innermost layer close. A clip + /// opened before that layer stays open, because the stream must stay nested. + /// /// - /// The retained scene to render at this point in the timeline. + /// The retained scene to render at this point in the stream. public void AddScene(DrawingBackendScene scene) { - this.SealCommands(); - this.EnsureInsertedSceneCapacity(this.insertedSceneCount + 1); + int layerCount = this.openLayers.Count; + (DrawingClipDescriptor Descriptor, Point AnchorOffset)[] clips = this.CloseInnermostLayerClips(); - int sceneIndex = this.insertedSceneCount; - this.insertedScenes[this.insertedSceneCount++] = scene; - this.EnsureEntryCapacity(this.TimelineEntryCount + 1); - this.entries[this.TimelineEntryCount++] = DrawingCanvasTimelineEntry.CreateScene(sceneIndex); + // Outside every layer the command holds the target bounds of the scene, which the backend + // requires to match its target. Inside a layer it holds the innermost layer bounds, which + // limit the rows that the scene can write, the same as for a fill in that layer. + Rectangle targetBounds = layerCount == 0 ? scene.Bounds : this.openLayers[layerCount - 1].Bounds; + this.EnsureCommandCapacity(this.commandCount + 1); + this.commands[this.commandCount++] = new RetainedSceneCompositionSceneCommand(scene, targetBounds, layerCount != 0); + this.sceneCommandCount++; + + this.ReopenClips(clips); + } + + /// + /// Appends one composition command outside the open clips of the innermost layer. + /// + /// + /// The open clips close before the command and open again after it, as for + /// . Outside every layer, no clip applies to the command. Inside a layer, + /// only the clips opened in the innermost layer close, and clips opened before that layer apply. + /// + /// The command to queue. + public void AddUnclippedComposition(in CompositionCommand composition) + { + (DrawingClipDescriptor Descriptor, Point AnchorOffset)[] clips = this.CloseInnermostLayerClips(); + this.AddComposition(composition); + this.ReopenClips(clips); + } + + /// + /// Closes the clips opened in the innermost open layer, or every open clip when no layer is open. + /// + /// + /// A clip opened before the innermost layer stays open, because the stream must stay nested. + /// + /// The closed clips, outermost first, for . + private (DrawingClipDescriptor Descriptor, Point AnchorOffset)[] CloseInnermostLayerClips() + { + int layerCount = this.openLayers.Count; + int clipStart = layerCount == 0 ? 0 : this.openLayers[layerCount - 1].ClipDepth; + (DrawingClipDescriptor Descriptor, Point AnchorOffset)[] clips = this.openClips.Count == clipStart + ? [] + : CollectionsMarshal.AsSpan(this.openClips)[clipStart..].ToArray(); + + for (int i = 0; i < clips.Length; i++) + { + this.AddComposition(CompositionCommand.CreateEndClip()); + } + + return clips; + } + + /// + /// Opens the clips that closed again, with the same anchors. + /// + /// The closed clips, outermost first. + private void ReopenClips((DrawingClipDescriptor Descriptor, Point AnchorOffset)[] clips) + { + for (int i = 0; i < clips.Length; i++) + { + this.AddComposition(CompositionCommand.CreateBeginClip(clips[i].Descriptor, clips[i].AnchorOffset)); + } + } + + /// + /// Closes every open clip and layer in the stream, innermost first. + /// + /// + /// The returned scopes are passed to after + /// to open the same clips and layers again, in the same nesting order. + /// + /// The open clips and layers, outermost first. + public OpenScopes CloseOpenScopes() + { + (DrawingClipDescriptor Descriptor, Point AnchorOffset)[] clips = [.. this.openClips]; + (Rectangle Bounds, DrawingCanvasLayer Layer, int ClipDepth)[] layers = [.. this.openLayers]; + + // Each layer closes after the clips opened inside it, so the stream stays nested. + for (int i = layers.Length - 1; i >= 0; i--) + { + while (this.openClips.Count > layers[i].ClipDepth) + { + this.AddComposition(CompositionCommand.CreateEndClip()); + } + + this.AddComposition(CompositionCommand.CreateEndLayer(layers[i].Bounds, layers[i].Layer)); + } + + while (this.openClips.Count > 0) + { + this.AddComposition(CompositionCommand.CreateEndClip()); + } + + return new OpenScopes(clips, layers); } + /// + /// Opens the clips and layers closed by again, outermost first. + /// + /// The open clips and layers, outermost first. + public void ReopenScopes(OpenScopes scopes) + { + int clipIndex = 0; + for (int i = 0; i < scopes.Layers.Length; i++) + { + for (; clipIndex < scopes.Layers[i].ClipDepth; clipIndex++) + { + this.AddComposition(CompositionCommand.CreateBeginClip(scopes.Clips[clipIndex].Descriptor, scopes.Clips[clipIndex].AnchorOffset)); + } + + this.AddComposition(CompositionCommand.CreateBeginLayer(scopes.Layers[i].Bounds, scopes.Layers[i].Layer)); + } + + for (; clipIndex < scopes.Clips.Length; clipIndex++) + { + this.AddComposition(CompositionCommand.CreateBeginClip(scopes.Clips[clipIndex].Descriptor, scopes.Clips[clipIndex].AnchorOffset)); + } + } + + /// + /// Takes the content of the open layers out of the stream, so the commands before the outermost + /// open layer can render alone. + /// + /// + /// + /// The content of a layer reaches the target only when the layer closes, so the commands before + /// the outermost open layer are all the commands that can render now. This method then closes + /// the clips that were open when that layer began, so the remaining stream is balanced. + /// puts the detached commands back after the remaining stream + /// renders and runs. + /// + /// + /// Nothing seals the stream while a layer is open, so every detached command is unsealed. The + /// detached commands are not prepared yet, so a later preparation prepares each of them once. + /// + /// + /// The detached commands and the stream state that they need. + public DetachedCommands DetachOpenLayerCommands() + { + CommandCounts start = this.outerLayerStart; + int clipDepth = this.openLayers[0].ClipDepth; + DetachedCommands detached = new( + this.commands[start.CommandIndex..this.commandCount], + [.. this.openClips], + [.. this.openLayers], + this.layerCommandCount - start.LayerCommandCount, + this.applyCommandCount - start.ApplyCommandCount, + this.clipCommandCount - start.ClipCommandCount, + this.sceneCommandCount - start.SceneCommandCount, + this.hasDashes, + this.hasBrushTransforms); + + Array.Clear(this.commands, start.CommandIndex, this.commandCount - start.CommandIndex); + this.commandCount = start.CommandIndex; + this.layerCommandCount = start.LayerCommandCount; + this.applyCommandCount = start.ApplyCommandCount; + this.clipCommandCount = start.ClipCommandCount; + this.sceneCommandCount = start.SceneCommandCount; + + // The mirrors return to the state at the start of the outermost layer. Then the clips + // that were open there close, so the remaining stream is balanced. + this.openLayers.Clear(); + this.openClips.RemoveRange(clipDepth, this.openClips.Count - clipDepth); + for (int i = 0; i < clipDepth; i++) + { + this.AddComposition(CompositionCommand.CreateEndClip()); + } + + return detached; + } + + /// + /// Puts commands taken out by back at the end of the stream. + /// + /// + /// The clips that were open when the outermost layer began open again first, with the same + /// anchors. Then the stream and its mirrors are the same as before the detach. + /// + /// The detached commands. + public void AttachCommands(DetachedCommands detached) + { + int clipDepth = detached.Layers[0].ClipDepth; + for (int i = 0; i < clipDepth; i++) + { + this.AddComposition(CompositionCommand.CreateBeginClip(detached.Clips[i].Descriptor, detached.Clips[i].AnchorOffset)); + } + + this.outerLayerStart = this.CaptureCounts(); + this.EnsureCommandCapacity(this.commandCount + detached.Commands.Length); + detached.Commands.CopyTo(this.commands, this.commandCount); + this.commandCount += detached.Commands.Length; + this.layerCommandCount += detached.LayerCommandCount; + this.applyCommandCount += detached.ApplyCommandCount; + this.clipCommandCount += detached.ClipCommandCount; + this.sceneCommandCount += detached.SceneCommandCount; + + // The flags are those of the whole buffer at the detach, so they include the detached + // commands. When only rendered commands set a flag, the next preparation scans commands + // that need no change. The detached commands were never prepared, so none is prepared two + // times. + this.hasDashes |= detached.HasDashes; + this.hasBrushTransforms |= detached.HasBrushTransforms; + + this.openClips.Clear(); + this.openClips.AddRange(detached.Clips); + this.openLayers.Clear(); + this.openLayers.AddRange(detached.Layers); + } + + /// + /// Captures the command index and the kind counters at the end of the stream. + /// + /// The captured counts. + private CommandCounts CaptureCounts() + => new(this.commandCount, this.layerCommandCount, this.applyCommandCount, this.clipCommandCount, this.sceneCommandCount); + /// /// Creates a retained backend scene from the recorded timeline. /// @@ -288,12 +529,14 @@ public DrawingBackendScene CreateScene( this.SealAndPrepareCommands(); + // Sealed command ranges are contiguous in the command buffer, so one batch covers them all. DrawingCommandBatch commandBatch = this.CreatePreparedCommandBatch( 0, this.commandCount, this.layerCommandCount > 0, this.applyCommandCount > 0, - this.clipCommandCount > 0); + this.clipCommandCount > 0, + this.sceneCommandCount > 0); return backend.CreateScene(this.configuration, targetBounds, commandBatch, ownedResources); } @@ -319,7 +562,8 @@ public DrawingCommandBatch CreateCommandBatch(DrawingCanvasTimelineEntry entry) entry.Count, entry.HasLayers, entry.HasApply, - entry.HasClipControls); + entry.HasClipControls, + entry.HasScenes); /// /// Creates a command batch over a contiguous range of the shared command buffer. @@ -329,14 +573,16 @@ public DrawingCommandBatch CreateCommandBatch(DrawingCanvasTimelineEntry entry) /// Indicates whether the range contains layer boundary commands. /// Indicates whether the range contains apply barriers. /// Indicates whether the range contains ordered clip-control commands. + /// Indicates whether the range contains retained scene commands. /// The command batch. private DrawingCommandBatch CreatePreparedCommandBatch( int startIndex, int commandCount, bool hasLayers, bool hasApply, - bool hasClipControls) - => new(this.commands, startIndex, commandCount, hasLayers, hasApply, hasClipControls); + bool hasClipControls, + bool hasScenes) + => new(this.commands, startIndex, commandCount, hasLayers, hasApply, hasClipControls, hasScenes); /// /// Gets one recorded timeline entry. @@ -346,14 +592,6 @@ private DrawingCommandBatch CreatePreparedCommandBatch( public DrawingCanvasTimelineEntry GetEntry(int index) => this.entries[index]; - /// - /// Gets one retained scene reference recorded through . - /// - /// The retained-scene reference index. - /// The retained scene to render at the timeline entry. - public DrawingBackendScene GetInsertedScene(int index) - => this.insertedScenes[index]; - /// /// Clears command references after a prepared batch has been consumed. /// @@ -361,7 +599,6 @@ public void ClearCommandBatch() { Array.Clear(this.commands, 0, this.commandCount); Array.Clear(this.entries, 0, this.TimelineEntryCount); - Array.Clear(this.insertedScenes, 0, this.insertedSceneCount); this.commandCount = 0; this.sealedCommandCount = 0; this.layerCommandCount = 0; @@ -370,14 +607,16 @@ public void ClearCommandBatch() this.sealedApplyCommandCount = 0; this.clipCommandCount = 0; this.sealedClipCommandCount = 0; + this.sceneCommandCount = 0; + this.sealedSceneCommandCount = 0; this.TimelineEntryCount = 0; - this.insertedSceneCount = 0; this.hasDashes = false; this.hasBrushTransforms = false; - // The canvas re-opens its active clip state after clearing, so the mirrored - // physical clip stream starts empty alongside the new command buffer. + // The canvas re-opens its active clip and layer state after clearing, so the mirrored + // physical clip and layer streams start empty alongside the new command buffer. this.openClips.Clear(); + this.openLayers.Clear(); } /// @@ -420,26 +659,6 @@ private void EnsureEntryCapacity(int requiredCapacity) Array.Resize(ref this.entries, nextCapacity); } - /// - /// Ensures that the inserted-scene buffer can store the requested scene count without reallocating. - /// - /// The required scene capacity. - private void EnsureInsertedSceneCapacity(int requiredCapacity) - { - if (requiredCapacity <= this.insertedScenes.Length) - { - return; - } - - int nextCapacity = this.insertedScenes.Length == 0 ? 2 : this.insertedScenes.Length * 2; - if (nextCapacity < requiredCapacity) - { - nextCapacity = requiredCapacity; - } - - Array.Resize(ref this.insertedScenes, nextCapacity); - } - /// /// Expands dashed strokes and normalizes brush transforms across the whole command buffer. /// @@ -626,4 +845,167 @@ private static void PrepareCommand(ref CompositionSceneCommand command) } } } + + /// + /// Holds the clips and layers that were open in the stream, outermost first. + /// + public readonly struct OpenScopes + { + /// + /// Initializes a new instance of the struct. + /// + /// The open clips, outermost first. + /// The open layers, outermost first, with the open clip count when each layer began. + public OpenScopes( + (DrawingClipDescriptor Descriptor, Point AnchorOffset)[] clips, + (Rectangle Bounds, DrawingCanvasLayer Layer, int ClipDepth)[] layers) + { + this.Clips = clips; + this.Layers = layers; + } + + /// + /// Gets the open clips, outermost first. + /// + public (DrawingClipDescriptor Descriptor, Point AnchorOffset)[] Clips { get; } + + /// + /// Gets the open layers, outermost first, with the open clip count when each layer began. + /// + public (Rectangle Bounds, DrawingCanvasLayer Layer, int ClipDepth)[] Layers { get; } + } + + /// + /// Holds a command index and the kind counters of the stream at that index. + /// + private readonly struct CommandCounts + { + /// + /// Initializes a new instance of the struct. + /// + /// The command index. + /// The layer command count before the index. + /// The apply command count before the index. + /// The clip command count before the index. + /// The scene command count before the index. + public CommandCounts(int commandIndex, int layerCommandCount, int applyCommandCount, int clipCommandCount, int sceneCommandCount) + { + this.CommandIndex = commandIndex; + this.LayerCommandCount = layerCommandCount; + this.ApplyCommandCount = applyCommandCount; + this.ClipCommandCount = clipCommandCount; + this.SceneCommandCount = sceneCommandCount; + } + + /// + /// Gets the command index. + /// + public int CommandIndex { get; } + + /// + /// Gets the layer command count before the index. + /// + public int LayerCommandCount { get; } + + /// + /// Gets the apply command count before the index. + /// + public int ApplyCommandCount { get; } + + /// + /// Gets the clip command count before the index. + /// + public int ClipCommandCount { get; } + + /// + /// Gets the scene command count before the index. + /// + public int SceneCommandCount { get; } + } + + /// + /// Holds the content of the open layers that took out of + /// the stream, with the stream state that restores. + /// + public sealed class DetachedCommands + { + /// + /// Initializes a new instance of the class. + /// + /// The detached commands, in stream order. + /// The open clips at the detach, outermost first. + /// The open layers at the detach, outermost first. + /// The layer command count of the detached commands. + /// The apply command count of the detached commands. + /// The clip command count of the detached commands. + /// The scene command count of the detached commands. + /// Whether the buffer had dashed strokes to prepare at the detach. + /// Whether the buffer had brush transforms to prepare at the detach. + public DetachedCommands( + CompositionSceneCommand[] commands, + (DrawingClipDescriptor Descriptor, Point AnchorOffset)[] clips, + (Rectangle Bounds, DrawingCanvasLayer Layer, int ClipDepth)[] layers, + int layerCommandCount, + int applyCommandCount, + int clipCommandCount, + int sceneCommandCount, + bool hasDashes, + bool hasBrushTransforms) + { + this.Commands = commands; + this.Clips = clips; + this.Layers = layers; + this.LayerCommandCount = layerCommandCount; + this.ApplyCommandCount = applyCommandCount; + this.ClipCommandCount = clipCommandCount; + this.SceneCommandCount = sceneCommandCount; + this.HasDashes = hasDashes; + this.HasBrushTransforms = hasBrushTransforms; + } + + /// + /// Gets the detached commands, in stream order. + /// + public CompositionSceneCommand[] Commands { get; } + + /// + /// Gets the open clips at the detach, outermost first. + /// + public (DrawingClipDescriptor Descriptor, Point AnchorOffset)[] Clips { get; } + + /// + /// Gets the open layers at the detach, outermost first. + /// + public (Rectangle Bounds, DrawingCanvasLayer Layer, int ClipDepth)[] Layers { get; } + + /// + /// Gets the layer command count of the detached commands. + /// + public int LayerCommandCount { get; } + + /// + /// Gets the apply command count of the detached commands. + /// + public int ApplyCommandCount { get; } + + /// + /// Gets the clip command count of the detached commands. + /// + public int ClipCommandCount { get; } + + /// + /// Gets the scene command count of the detached commands. + /// + public int SceneCommandCount { get; } + + /// + /// Gets a value indicating whether the buffer had dashed strokes to prepare at the detach. + /// + public bool HasDashes { get; } + + /// + /// Gets a value indicating whether the buffer had brush transforms to prepare at the detach. + /// + public bool HasBrushTransforms { get; } + } } diff --git a/src/ImageSharp.Drawing/Processing/DrawingCanvasTimelineEntry.cs b/src/ImageSharp.Drawing/Processing/DrawingCanvasTimelineEntry.cs index 1c53340b0..9c9f2b02e 100644 --- a/src/ImageSharp.Drawing/Processing/DrawingCanvasTimelineEntry.cs +++ b/src/ImageSharp.Drawing/Processing/DrawingCanvasTimelineEntry.cs @@ -4,68 +4,47 @@ namespace SixLabors.ImageSharp.Drawing.Processing; /// -/// Identifies the kind of replay item stored in a drawing canvas timeline. -/// -internal enum DrawingCanvasTimelineEntryKind -{ - /// - /// A contiguous range of draw commands. - /// - CommandRange, - - /// - /// An existing retained scene recorded through . - /// - Scene -} - -/// -/// Represents one ordered item in the canvas replay timeline. +/// Represents one ordered command range in the canvas replay timeline. /// /// -/// Command ranges reference contiguous draw commands; they are not backend scene objects yet. -/// Retained scene references point into a side buffer by index, keeping this type compact while -/// preserving the exact order in which the canvas recorded replay work. +/// Command ranges reference contiguous draw commands. They are not backend scene objects yet. +/// Retained scenes recorded through are commands inside +/// a range. This type therefore stays compact and keeps the exact order that the canvas recorded. /// internal readonly struct DrawingCanvasTimelineEntry { /// /// Initializes a new instance of the struct. /// - /// The kind of replay item. - /// The command start index for command ranges, or the retained-scene buffer index for scene entries. - /// The number of commands for command-range entries. + /// The command start index. + /// The number of commands. /// Indicates whether the command range contains layer boundary commands. /// Indicates whether the command range contains apply barriers. /// Indicates whether the command range contains ordered clip-control commands. - private DrawingCanvasTimelineEntry( - DrawingCanvasTimelineEntryKind kind, + /// Indicates whether the command range contains retained scene commands. + public DrawingCanvasTimelineEntry( int index, int count, bool hasLayers, bool hasApply, - bool hasClipControls) + bool hasClipControls, + bool hasScenes) { - this.Kind = kind; this.Index = index; this.Count = count; this.HasLayers = hasLayers; this.HasApply = hasApply; this.HasClipControls = hasClipControls; + this.HasScenes = hasScenes; } /// - /// Gets the kind of replay item represented by this entry. - /// - public DrawingCanvasTimelineEntryKind Kind { get; } - - /// - /// Gets the command start index for command ranges, or the retained-scene buffer index for scene entries. + /// Gets the command start index. /// public int Index { get; } /// - /// Gets the number of commands represented by a command-range entry. + /// Gets the number of commands represented by the entry. /// public int Count { get; } @@ -85,27 +64,7 @@ private DrawingCanvasTimelineEntry( public bool HasClipControls { get; } /// - /// Creates a command-range entry. - /// - /// The first command index. - /// The command count. - /// Indicates whether the command range contains layer boundary commands. - /// Indicates whether the command range contains apply barriers. - /// Indicates whether the command range contains ordered clip-control commands. - /// The command-range entry. - public static DrawingCanvasTimelineEntry CreateCommandRange( - int startIndex, - int count, - bool hasLayers, - bool hasApply, - bool hasClipControls) - => new(DrawingCanvasTimelineEntryKind.CommandRange, startIndex, count, hasLayers, hasApply, hasClipControls); - - /// - /// Creates an entry for an existing retained scene recorded through . + /// Gets a value indicating whether the command range contains retained scene commands. /// - /// The retained-scene reference index. - /// The retained-scene entry. - public static DrawingCanvasTimelineEntry CreateScene(int index) - => new(DrawingCanvasTimelineEntryKind.Scene, index, 0, false, false, false); + public bool HasScenes { get; } } diff --git a/src/ImageSharp.Drawing/Processing/DrawingCanvas{TPixel}.cs b/src/ImageSharp.Drawing/Processing/DrawingCanvas{TPixel}.cs index 1f87e740a..6ab3dc724 100644 --- a/src/ImageSharp.Drawing/Processing/DrawingCanvas{TPixel}.cs +++ b/src/ImageSharp.Drawing/Processing/DrawingCanvas{TPixel}.cs @@ -1212,7 +1212,20 @@ public void DrawImage( public override DrawingBackendScene CreateScene() { this.EnsureNotDisposed(); - this.CloseClipsAndSealActiveCommandRange(); + + // A scene is a balanced command stream. Open layers close at the end of the scene, nested + // with the open clips, and open again after it, the same as the clips. + bool hasOpenLayers = this.batcher.OpenLayerCount != 0; + DrawingCanvasBatcher.OpenScopes openScopes = default; + if (hasOpenLayers) + { + openScopes = this.batcher.CloseOpenScopes(); + this.batcher.SealCommands(); + } + else + { + this.CloseClipsAndSealActiveCommandRange(); + } IDisposable[]? ownedResources = this.DetachPendingImageResources(); @@ -1229,10 +1242,17 @@ public override DrawingBackendScene CreateScene() { this.batcher.ClearCommandBatch(); - // The sealed range consumed the balanced clip stream; reopen the active clip - // stack so later commands keep recording under the same clip state. - DrawingCanvasState state = this.ResolveState(); - this.AppendBeginClips(state.ClipState, state.DestinationOffset); + // The sealed range used the balanced clip and layer stream. The canvas opens the scopes + // again, so later commands record under the same clip and layer state. + if (hasOpenLayers) + { + this.batcher.ReopenScopes(openScopes); + } + else + { + DrawingCanvasState state = this.ResolveState(); + this.AppendBeginClips(state.ClipState, state.DestinationOffset); + } } } @@ -1242,11 +1262,7 @@ public override void RenderScene(DrawingBackendScene scene) this.EnsureNotDisposed(); Guard.NotNull(scene, nameof(scene)); - this.CloseClipsAndSealActiveCommandRange(); this.batcher.AddScene(scene); - - DrawingCanvasState state = this.ResolveState(); - this.AppendBeginClips(state.ClipState, state.DestinationOffset); } /// @@ -1263,12 +1279,33 @@ public override void CopyPixelsFrom(DrawingCanvas source, Rectangle sourceRectan typedSource.EnsureNotDisposed(); // Pixel copy transfers already-rasterized frame contents. Materialize both timelines at - // this ordering boundary so the backend copies pixels instead of replaying commands. - typedSource.CloseClipsAndSealActiveCommandRange(); - typedSource.RenderRecordedTimeline(); + // this ordering boundary so the backend copies pixels instead of replaying commands. The + // content of an open layer reaches the frame only when the layer closes. With an open + // layer, only the commands before the outermost open layer therefore render now. The + // source can share the stream of this canvas, for example as a region of it. + if (typedSource.batcher.OpenLayerCount == 0) + { + typedSource.CloseClipsAndSealActiveCommandRange(); + typedSource.RenderRecordedTimeline(); + + DrawingCanvasState sourceState = typedSource.ResolveState(); + typedSource.AppendBeginClips(sourceState.ClipState, sourceState.DestinationOffset); + } + else + { + DrawingCanvasBatcher.DetachedCommands detached = typedSource.batcher.DetachOpenLayerCommands(); + typedSource.RenderRecordedTimeline(); + typedSource.batcher.AttachCommands(detached); + } - DrawingCanvasState sourceState = typedSource.ResolveState(); - typedSource.AppendBeginClips(sourceState.ClipState, sourceState.DestinationOffset); + // Inside a layer, the target cannot render now, because the layer composites only at Restore + // or Dispose. The canvas therefore reads the source pixels into an image now. It then draws + // that image into the layer at this position of the stream. + if (this.batcher.OpenLayerCount != 0) + { + this.CopyPixelsIntoLayer(typedSource, sourceRectangle, targetPoint); + return; + } this.CloseClipsAndSealActiveCommandRange(); this.RenderRecordedTimeline(); @@ -1284,6 +1321,73 @@ public override void CopyPixelsFrom(DrawingCanvas source, Rectangle sourceRectan targetPoint); } + /// + /// Reads the source rectangle into an image now, and writes the image into the open layer at + /// this position of the stream with replace semantics. + /// + /// + /// The rectangles clip the same way as : first + /// against the source frame, then against the target frame. The copy writes raw pixels, so the + /// canvas transform does not apply. The clips opened in the innermost layer do not apply, the + /// same as for a retained scene. Clips opened before that layer stay open. + /// + /// The source canvas, already rendered. + /// The source rectangle in source-local coordinates. + /// The target point in target-local coordinates. + private void CopyPixelsIntoLayer(DrawingCanvas source, Rectangle sourceRectangle, Point targetPoint) + { + if (!IDrawingBackend.TryClipPixelCopy( + source.targetFrame.Bounds.Size, + this.targetFrame.Bounds.Size, + sourceRectangle, + targetPoint, + out Point sourceOrigin, + out Rectangle clippedTarget)) + { + return; + } + + Rectangle readRectangle = new(sourceOrigin, clippedTarget.Size); + + // The image is an owned resource, the same as a DrawImage image, so CreateScene moves it to + // the scene and Dispose releases it after the replay. It is added before the read, so a + // failed read cannot leak it. + Image image = new(this.configuration, readRectangle.Width, readRectangle.Height); + this.pendingImageResources.Add(image); + + // The source backend reads the rendered source pixels into the image now. Later draws to + // the source then cannot change the pixels that the layer receives. + source.backend.ReadRegion( + this.configuration, + source.targetFrame, + readRectangle, + image.Frames.RootFrame.PixelBuffer.GetRegion()); + + // Src replaces the layer pixels. A pixel copy has no partial coverage, so the rectangle + // rasterizes aliased. Coverage is exactly one inside and zero outside, and the image + // pixels pass through unchanged. + DrawingOptions options = new() + { + GraphicsOptions = new GraphicsOptions { Antialias = false, AlphaCompositionMode = PixelAlphaCompositionMode.Src } + }; + + RasterizerOptions rasterizerOptions = new( + ToRasterizerInterest(clippedTarget), + IntersectionRule.NonZero, + RasterizationMode.Aliased); + + DrawingCanvasState state = this.ResolveState(); + this.batcher.AddUnclippedComposition( + CompositionCommand.Create( + new RectanglePolygon(clippedTarget), + new ImageBrush(image, image.Bounds), + options, + in rasterizerOptions, + state.TargetBounds, + state.DestinationOffset, + state.Layer)); + } + /// /// Normalizes a pre-clipped image draw into an image-brush fill: source pixels are cropped/scaled, /// then baked through the canvas transform, and the result is queued as a rectangle fill. @@ -1971,6 +2075,14 @@ private void ExecuteWithTemporaryState( public override void Flush() { this.EnsureNotDisposed(); + + // A command range is a balanced stream that cannot split a layer, and a seal changes no + // pixels. Inside a layer there is therefore nothing to seal. + if (this.batcher.OpenLayerCount != 0) + { + return; + } + this.CloseClipsAndSealActiveCommandRange(); DrawingCanvasState state = this.ResolveState(); this.AppendBeginClips(state.ClipState, state.DestinationOffset); @@ -2022,8 +2134,8 @@ private void EnsureNotDisposed() /// Renders the recorded timeline owned by the root canvas. /// /// - /// Command-range entries are lowered to short-lived backend scenes here. Scene entries - /// reference retained scenes that were recorded earlier through . + /// Command-range entries are lowered to short-lived backend scenes here. Retained scenes + /// recorded through are scene commands inside the ranges. /// private void RenderRecordedTimeline() { @@ -2037,21 +2149,7 @@ private void RenderRecordedTimeline() { for (int i = 0; i < this.batcher.TimelineEntryCount; i++) { - DrawingCanvasTimelineEntry entry = this.batcher.GetEntry(i); - switch (entry.Kind) - { - case DrawingCanvasTimelineEntryKind.CommandRange: - this.RenderCommandBatch(this.batcher.CreateCommandBatch(entry)); - break; - - case DrawingCanvasTimelineEntryKind.Scene: - this.backend.RenderScene( - this.configuration, - this.targetFrame, - this.batcher.GetInsertedScene(entry.Index)); - - break; - } + this.RenderCommandBatch(this.batcher.CreateCommandBatch(this.batcher.GetEntry(i))); } } finally @@ -2061,10 +2159,46 @@ private void RenderRecordedTimeline() } /// - /// Creates and renders one backend scene for a prepared command batch. + /// Renders one prepared command batch. A retained scene outside every layer renders as its own + /// pass, and each run of commands between such scenes renders as one backend scene. /// + /// + /// A retained scene outside every layer needs no work in the scene of its run, so it renders + /// directly, with no scene to create for it. + /// /// The command batch to render. private void RenderCommandBatch(DrawingCommandBatch commandBatch) + { + if (!commandBatch.HasScenes) + { + this.RenderCommandRun(commandBatch); + return; + } + + int index = 0; + while (true) + { + DrawingBackendScene? retainedScene = commandBatch.ReadRootPart(ref index, out DrawingCommandBatch run); + if (run.CommandCount != 0) + { + this.RenderCommandRun(run); + } + + if (retainedScene is null) + { + return; + } + + // The caller of RenderScene owns the retained scene and its resources. + this.backend.RenderScene(this.configuration, this.targetFrame, retainedScene); + } + } + + /// + /// Creates and renders one backend scene for a run of commands. + /// + /// The commands to render. + private void RenderCommandRun(DrawingCommandBatch commandBatch) { using DrawingBackendScene scene = this.backend.CreateScene( this.configuration, diff --git a/src/ImageSharp.Drawing/Processing/GradientBrush.cs b/src/ImageSharp.Drawing/Processing/GradientBrush.cs index 838e027f3..c2c4795ab 100644 --- a/src/ImageSharp.Drawing/Processing/GradientBrush.cs +++ b/src/ImageSharp.Drawing/Processing/GradientBrush.cs @@ -217,12 +217,16 @@ protected GradientBrushRenderer( switch (this.repetitionMode) { case GradientRepetitionMode.Repeat: - positionOnCompleteGradient %= 1; + // The floor subtraction moves every position into [0, 1], so the pattern also + // repeats before the start point. The remainder operator keeps the sign of a + // negative position, and the brush then pads that position with the first stop. + positionOnCompleteGradient -= MathF.Floor(positionOnCompleteGradient); break; case GradientRepetitionMode.Reflect: - // Fold every second period back on itself so alternating - // repetitions run the stops in reverse order. - positionOnCompleteGradient %= 2; + // Reflection is symmetric about the start point, so the magnitude gives the + // same color as the signed position. Every second period folds back on itself, + // so alternate repetitions run the stops in reverse order. + positionOnCompleteGradient = MathF.Abs(positionOnCompleteGradient) % 2; if (positionOnCompleteGradient > 1) { positionOnCompleteGradient = 2 - positionOnCompleteGradient; diff --git a/src/ImageSharp.Drawing/Processing/Pen.cs b/src/ImageSharp.Drawing/Processing/Pen.cs index bf3de7d9c..a464a799a 100644 --- a/src/ImageSharp.Drawing/Processing/Pen.cs +++ b/src/ImageSharp.Drawing/Processing/Pen.cs @@ -107,6 +107,32 @@ public IPath GeneratePath(IPath path) /// The with the pen styling applied. public abstract IPath GeneratePath(IPath path, float strokeWidth); + /// + /// Gets the largest distance from the centerline that the stroke outline can reach. + /// + /// + /// A miter join reaches the miter limit times the half width. A square cap corner reaches + /// sqrt(2) times the half width. Round and bevel joins and round and butt caps stay inside the + /// half width. + /// + /// The transform-derived scale applied to the stroke width. + /// The distance in pixels. + internal float GetOutlineRadius(float widthScale) + { + float halfWidth = this.StrokeWidth * widthScale * 0.5F; + float joinRadius = this.StrokeOptions.LineJoin switch + { + LineJoin.Miter or LineJoin.MiterRevert or LineJoin.MiterRound => (float)(halfWidth * Math.Max(this.StrokeOptions.MiterLimit, 1D)), + _ => halfWidth + }; + + float capRadius = this.StrokeOptions.LineCap == LineCap.Square + ? halfWidth * MathF.Sqrt(2F) + : halfWidth; + + return MathF.Max(joinRadius, capRadius); + } + /// public virtual bool Equals(Pen? other) => other != null diff --git a/tests/ImageSharp.Drawing.Tests/Processing/Backends/WebGPUDrawingBackendTests.Diagnostics.cs b/tests/ImageSharp.Drawing.Tests/Processing/Backends/WebGPUDrawingBackendTests.Diagnostics.cs index a6c9e37bc..b79afc998 100644 --- a/tests/ImageSharp.Drawing.Tests/Processing/Backends/WebGPUDrawingBackendTests.Diagnostics.cs +++ b/tests/ImageSharp.Drawing.Tests/Processing/Backends/WebGPUDrawingBackendTests.Diagnostics.cs @@ -158,6 +158,44 @@ public void ChunkSchedulingScratchGrowth_IncludesBlendSpillOverflow() Assert.True(grown.BlendSpill > submitted.BlendSpill); } + // 100k random stroked lines, the same scene as the 100k run of the DrawingBackendBenchmark sample, + // need more scratch memory than the first render has. At the 128 MiB default binding limit they + // also render in tile-row chunks. An offscreen render must return complete pixels: it grows the + // memory and renders again before it returns. The GPU copy reads the target as the render left + // it, before any readback, and must equal a second render that starts with the grown sizes. + [WebGPUFact] + public void FillManyLines_FirstRenderOverflows_ReturnsCompletePixels() + { + const int width = 1600; + const int height = 1100; + (PointF Start, PointF End, Color Color, float Width)[] lines = CreateRandomLines(width, height, 100_000); + Brush background = Brushes.Solid(Color.ParseHex("#003366")); + Rectangle bounds = new(0, 0, width, height); + + using WebGPURenderTarget target = new(WebGPUTextureFormat.Bgra8Unorm, width, height); + using WebGPURenderTarget copy = target.CreateRenderTarget(width, height); + target.Backend.ScratchBufferBindingSizeLimit = 128U * 1024U * 1024U; + + DrawLines(target, background, lines); + int overflowCount = target.Backend.DiagnosticScratchOverflowCount; + bool chunked = target.Backend.DiagnosticLastFlushUsedChunking; + + using (DrawingCanvas source = target.CreateCanvas()) + using (DrawingCanvas destination = copy.CreateCanvas()) + { + destination.CopyPixelsFrom(source, bounds, Point.Empty); + } + + DrawLines(target, background, lines); + using Image copied = copy.ReadbackImage().CloneAs(); + using Image expected = target.ReadbackImage().CloneAs(); + + Assert.True(overflowCount > 0, "The first render must overflow, or this test does not run the retry."); + Assert.True(chunked, "The first render must use tile-row chunks."); + Assert.Equal(overflowCount, target.Backend.DiagnosticScratchOverflowCount); + Assert.Equal(0, CountDifferingPixels(expected, copied)); + } + // A correctly seeded scene renders fully on its first flush. An under-seeded scratch estimate (for // example counting only the stroke centerline instead of the emitted offset sides, joins and caps) // overflows the first flush, drops coverage, and only converges after later flushes grow the buffers. @@ -180,6 +218,120 @@ public void FillManyLines_SinglePass_MatchesConvergedRender() $"Single-pass render differs from the converged render in {differing} of {total} pixels, indicating under-seeded scratch."); } + // Nine nested full-target layers put every tile at blend depth 9, so fine spills five levels of + // 256 slots for each of the 1024 tiles. That is more than the initial blend-spill buffer, and + // the scratch bounds must size it before the first render. + [WebGPUFact] + public void FirstRender_NineNestedLayers_DoesNotOverflowScratch() + { + using WebGPURenderTarget target = new(512, 512); + using (DrawingCanvas canvas = target.CreateCanvas()) + { + DrawNestedLayers(canvas, 9, 512); + } + + Assert.Equal(0, target.Backend.DiagnosticScratchOverflowCount); + } + + // One hundred full-target layers in sequence write BeginClip, the forced solid and EndClip in + // every tile for each layer, which is more PTCL than the initial buffer. + [WebGPUFact] + public void FirstRender_HundredSequentialLayers_DoesNotOverflowScratch() + { + using WebGPURenderTarget target = new(2048, 2048); + using (DrawingCanvas canvas = target.CreateCanvas()) + { + for (int i = 0; i < 100; i++) + { + _ = canvas.SaveLayer(new GraphicsOptions(), new Rectangle(0, 0, 2048, 2048)); + canvas.Fill(Brushes.Solid(Color.FromPixel(new Rgba32((byte)(i * 2), 32, 200, 128))), new Rectangle((i % 10) * 200, (i / 10) * 200, 100, 100)); + canvas.Restore(); + } + } + + Assert.Equal(0, target.Backend.DiagnosticScratchOverflowCount); + } + + // A retained scene read in place inside layers nests its own layers inside the outer ones. Five + // outer layers and four retained layers reach blend depth 9, which only the shifted depths of + // the retained scene show. + [WebGPUFact] + public void FirstRender_RetainedNestedLayersInsideLayers_DoesNotOverflowScratch() + { + using WebGPURenderTarget target = new(512, 512); + using DrawingBackendScene scene = CreateNestedLayerScene(target, 4); + using (DrawingCanvas canvas = target.CreateCanvas()) + { + for (int i = 0; i < 5; i++) + { + _ = canvas.SaveLayer(new GraphicsOptions(), new Rectangle(0, 0, 512, 512)); + } + + canvas.RenderScene(scene); + for (int i = 0; i < 5; i++) + { + canvas.Restore(); + } + } + + Assert.Equal(0, target.Backend.DiagnosticScratchOverflowCount); + } + + // Dense lines in the top quarter of a large target render in tile-row chunks when the binding + // limit is 64 MiB: the per-tile PTCL allowance of 4096 x 4096 is already 64 MiB. A share of + // the target height would size the top chunks four times too small, so each chunk must be + // sized from the draws that reach its rows. The line buffer does not shrink with a chunk, and + // its initial 48 MiB fits the limit. The chunked image must equal the image rendered in one pass. + [WebGPUFact] + public void ChunkedRender_GeometryInFewRows_DoesNotOverflowScratch() + { + const int width = 4096; + const int height = 4096; + Random random = new(5); + (PointF Start, PointF End, Color Color, float Width)[] lines = new (PointF, PointF, Color, float)[20_000]; + for (int i = 0; i < lines.Length; i++) + { + lines[i] = ( + new PointF((float)(random.NextDouble() * width), (float)(random.NextDouble() * 1024)), + new PointF((float)(random.NextDouble() * width), (float)(random.NextDouble() * 1024)), + Color.FromPixel(new Rgba32((byte)random.Next(256), (byte)random.Next(256), (byte)random.Next(256))), + random.Next(1, 4)); + } + + Brush background = Brushes.Solid(Color.ParseHex("#003366")); + using Image expected = RenderManyLinesToImage(width, height, background, lines, passes: 1); + + using WebGPURenderTarget target = new(WebGPUTextureFormat.Bgra8Unorm, width, height); + target.Backend.ScratchBufferBindingSizeLimit = 64U * 1024U * 1024U; + DrawLines(target, background, lines); + using Image chunked = target.ReadbackImage().CloneAs(); + + Assert.True(target.Backend.DiagnosticLastFlushUsedChunking, "The binding limit must force chunked rendering."); + Assert.Equal(0, target.Backend.DiagnosticScratchOverflowCount); + Assert.Equal(0, CountDifferingPixels(expected, chunked)); + } + + private static void DrawNestedLayers(DrawingCanvas canvas, int depth, int size) + { + for (int i = 0; i < depth; i++) + { + _ = canvas.SaveLayer(new GraphicsOptions(), new Rectangle(0, 0, size, size)); + canvas.Fill(Brushes.Solid(Color.FromPixel(new Rgba32((byte)(i * 28), 64, 128, 200))), new Rectangle(i * 20, i * 20, 300, 300)); + } + + for (int i = 0; i < depth; i++) + { + canvas.Restore(); + } + } + + private static DrawingBackendScene CreateNestedLayerScene(WebGPURenderTarget target, int depth) + { + using DrawingCanvas canvas = target.CreateCanvas(); + DrawNestedLayers(canvas, depth, target.Width); + return canvas.CreateScene(); + } + private static Image RenderManyLinesToImage(int width, int height, Brush background, (PointF Start, PointF End, Color Color, float Width)[] lines, int passes) { using WebGPURenderTarget target = new(WebGPUTextureFormat.Bgra8Unorm, width, height); diff --git a/tests/ImageSharp.Drawing.Tests/Processing/Backends/WebGPUDrawingBackendTests.cs b/tests/ImageSharp.Drawing.Tests/Processing/Backends/WebGPUDrawingBackendTests.cs index b7a052f70..642f073cc 100644 --- a/tests/ImageSharp.Drawing.Tests/Processing/Backends/WebGPUDrawingBackendTests.cs +++ b/tests/ImageSharp.Drawing.Tests/Processing/Backends/WebGPUDrawingBackendTests.cs @@ -2489,6 +2489,546 @@ static void DrawRetainedFlow(DrawingCanvas canvas, DrawingBackendScene retainedS AssertBackendPairReferenceOutputs(provider, null, defaultImage, nativeSurfaceImage); } + [WebGPUTheory] + [WithSolidFilledImages(32, 16, "White", PixelTypes.Rgba32)] + public void CreateScene_KeepsScenesRecordedBeforeItInOrder(TestImageProvider provider) + where TPixel : unmanaged, IPixel + { + DrawingOptions drawingOptions = new() + { + GraphicsOptions = new GraphicsOptions { Antialias = false } + }; + + // Green is recorded before the inserted red scene and blue after it. Both overlap the red + // square, so the colors in the overlaps show the order of the three parts. The combined + // scene then renders into a second canvas. + static void DrawScenes(DrawingCanvas sourceCanvas, Func createTargetCanvas) + { + sourceCanvas.Fill(Brushes.Solid(Color.Red), new Rectangle(4, 4, 8, 8)); + using DrawingBackendScene inserted = sourceCanvas.CreateScene(); + + sourceCanvas.Fill(Brushes.Solid(Color.Green), new Rectangle(0, 4, 6, 8)); + sourceCanvas.RenderScene(inserted); + sourceCanvas.Fill(Brushes.Solid(Color.Blue), new Rectangle(10, 4, 8, 8)); + using DrawingBackendScene combined = sourceCanvas.CreateScene(); + + using DrawingCanvas targetCanvas = createTargetCanvas(); + targetCanvas.RenderScene(combined); + } + + using Image defaultSource = provider.GetImage(); + using Image defaultImage = provider.GetImage(); + using (DrawingCanvas sourceCanvas = defaultSource.Frames.RootFrame.CreateCanvas(defaultSource.Configuration, drawingOptions)) + { + DrawScenes(sourceCanvas, () => defaultImage.Frames.RootFrame.CreateCanvas(defaultImage.Configuration, drawingOptions)); + } + + using WebGPUDrawingBackend nativeSurfaceBackend = new(); + Configuration nativeConfig = Configuration.Default.Clone(); + nativeConfig.SetDrawingBackend(nativeSurfaceBackend); + + using WebGPURenderTarget sourceTarget = new(defaultImage.Width, defaultImage.Height); + using WebGPURenderTarget renderTarget = new(defaultImage.Width, defaultImage.Height); + using (DrawingCanvas nativeSourceCanvas = WebGPUCanvasFactory.CreateCanvas( + nativeConfig, + drawingOptions, + nativeSurfaceBackend, + sourceTarget.Bounds, + sourceTarget.Surface, + sourceTarget.Surface.TargetDescriptor)) + { + DrawScenes( + nativeSourceCanvas, + () => + { + DrawingCanvas canvas = WebGPUCanvasFactory.CreateCanvas( + nativeConfig, + drawingOptions, + nativeSurfaceBackend, + renderTarget.Bounds, + renderTarget.Surface, + renderTarget.Surface.TargetDescriptor); + + // The render target starts transparent, so match the white default image first. + canvas.Clear(Brushes.Solid(Color.White)); + return canvas; + }); + } + + using Image nativeSurfaceImage = renderTarget.ReadbackImage(); + DebugSaveBackendPair(provider, null, defaultImage, nativeSurfaceImage); + + foreach (Image image in new[] { defaultImage, nativeSurfaceImage }) + { + Assert.Equal(Color.Green.ToPixel(), image[2, 8]); + Assert.Equal(Color.Red.ToPixel(), image[5, 8]); + Assert.Equal(Color.Red.ToPixel(), image[8, 8]); + Assert.Equal(Color.Blue.ToPixel(), image[11, 8]); + Assert.Equal(Color.Blue.ToPixel(), image[16, 8]); + } + + AssertBackendPairSimilarity(defaultImage, nativeSurfaceImage, 0.005F); + AssertBackendPairReferenceOutputs(provider, null, defaultImage, nativeSurfaceImage); + } + + [WebGPUTheory] + [WithSolidFilledImages(32, 16, "White", PixelTypes.Rgba32, "Flush", false)] + [WithSolidFilledImages(32, 16, "White", PixelTypes.Rgba32, "Flush", true)] + [WithSolidFilledImages(32, 16, "White", PixelTypes.Rgba32, "RenderScene", false)] + [WithSolidFilledImages(32, 16, "White", PixelTypes.Rgba32, "RenderScene", true)] + [WithSolidFilledImages(32, 16, "White", PixelTypes.Rgba32, "RenderSceneWithLayer", false)] + [WithSolidFilledImages(32, 16, "White", PixelTypes.Rgba32, "RenderSceneWithLayer", true)] + [WithSolidFilledImages(32, 16, "White", PixelTypes.Rgba32, "TwoScenes", false)] + [WithSolidFilledImages(32, 16, "White", PixelTypes.Rgba32, "TwoScenes", true)] + [WithSolidFilledImages(32, 16, "White", PixelTypes.Rgba32, "CreateScene", false)] + [WithSolidFilledImages(32, 16, "White", PixelTypes.Rgba32, "CreateScene", true)] + [WithSolidFilledImages(32, 16, "White", PixelTypes.Rgba32, "CreateSceneAfterRestore", false)] + [WithSolidFilledImages(32, 16, "White", PixelTypes.Rgba32, "CreateSceneAfterRestore", true)] + [WithSolidFilledImages(32, 16, "White", PixelTypes.Rgba32, "CopyPixelsFrom", false)] + [WithSolidFilledImages(32, 16, "White", PixelTypes.Rgba32, "CopyPixelsFrom", true)] + [WithSolidFilledImages(32, 16, "White", PixelTypes.Rgba32, "CopyPixelsFromSourceChanges", false)] + [WithSolidFilledImages(32, 16, "White", PixelTypes.Rgba32, "CopyPixelsFromSourceChanges", true)] + [WithSolidFilledImages(32, 16, "White", PixelTypes.Rgba32, "RenderSceneAfterApply", false)] + [WithSolidFilledImages(32, 16, "White", PixelTypes.Rgba32, "RenderSceneAfterApply", true)] + [WithSolidFilledImages(32, 16, "White", PixelTypes.Rgba32, "RenderSceneInScopedChild", false)] + [WithSolidFilledImages(32, 16, "White", PixelTypes.Rgba32, "RenderSceneInScopedChild", true)] + [WithSolidFilledImages(32, 16, "White", PixelTypes.Rgba32, "RenderPathGradientSceneAfterApply", false)] + [WithSolidFilledImages(32, 16, "White", PixelTypes.Rgba32, "RenderPathGradientSceneAfterApply", true)] + [WithSolidFilledImages(32, 16, "White", PixelTypes.Rgba32, "RenderPartsScene", false)] + [WithSolidFilledImages(32, 16, "White", PixelTypes.Rgba32, "RenderPartsScene", true)] + [WithSolidFilledImages(32, 16, "White", PixelTypes.Rgba32, "RenderNestedScene", false)] + [WithSolidFilledImages(32, 16, "White", PixelTypes.Rgba32, "RenderNestedScene", true)] + [WithSolidFilledImages(32, 16, "White", PixelTypes.Rgba32, "CopyPixelsFromSelf", false)] + [WithSolidFilledImages(32, 16, "White", PixelTypes.Rgba32, "CopyPixelsFromSelf", true)] + public void SaveLayer_WithSealInsideLayer_MatchesDirectDrawing( + TestImageProvider provider, + string seal, + bool scopedLayer) + where TPixel : unmanaged, IPixel + { + DrawingOptions drawingOptions = new() + { + GraphicsOptions = new GraphicsOptions { Antialias = false } + }; + + GraphicsOptions layerOptions = new() { BlendPercentage = 0.5F }; + Rectangle layerBounds = new(0, 0, 32, 16); + Rectangle red = new(2, 4, 12, 8); + Rectangle blue = new(8, 4, 12, 8); + Rectangle green = new(14, 4, 12, 8); + Point copyTarget = new(2, 4); + + // The right half of the blue rectangle, yellow in the copy source. + Rectangle yellow = new(14, 4, 6, 8); + + // The pixels that the self copy reads: the green rectangle of the frame before the layer. + using Image copiedGreen = new(green.Width, green.Height, Color.Green.ToPixel()); + + // Opens the outer layer and draws the content before the seal. An Apply in the layer makes + // both backends render it on the scoped path. + void BeginLayer(DrawingCanvas canvas) + { + _ = canvas.SaveLayer(layerOptions, layerBounds); + canvas.Fill(Brushes.Solid(Color.Red), red); + if (scopedLayer) + { + canvas.Apply(new Rectangle(2, 4, 4, 8), context => context.Invert()); + } + } + + // Records commands into a retained scene of the same canvas before anything else is + // recorded, so the scene holds only those commands and has the backend and bounds of the canvas. + static DrawingBackendScene CreateRetainedScene(DrawingCanvas canvas, Action draw, List scenes) + { + draw(canvas); + DrawingBackendScene scene = canvas.CreateScene(); + scenes.Add(scene); + return scene; + } + + void DrawBlue(DrawingCanvas canvas) => canvas.Fill(Brushes.Solid(Color.Blue), blue); + + void DrawGreen(DrawingCanvas canvas) => canvas.Fill(Brushes.Solid(Color.Green), green); + + void DrawBlueInLayer(DrawingCanvas canvas) + { + _ = canvas.SaveLayer(layerOptions, layerBounds); + canvas.Fill(Brushes.Solid(Color.Blue), blue); + canvas.Restore(); + } + + // A path gradient has brush data outside the draw data, which a retained scene keeps in its own block. + void DrawPathGradient(DrawingCanvas canvas) + => canvas.Fill( + new PathGradientBrush( + [new PointF(8, 4), new PointF(20, 4), new PointF(20, 12), new PointF(8, 12)], + [Color.Blue, Color.Green, Color.Yellow, Color.Cyan]), + blue); + + // A scoped layer with an Apply, and the content drawn by drawContent after the Apply. + void DrawInScopedLayer(DrawingCanvas canvas, Action drawContent) + { + _ = canvas.SaveLayer(layerOptions, layerBounds); + canvas.Apply(new Rectangle(4, 2, 4, 4), context => context.Invert()); + drawContent(canvas); + canvas.Restore(); + } + + // An Apply before the layer makes the canvas scene ordered, and the layer stays plain unless + // scopedLayer is set. + void ApplyBeforeLayer(DrawingCanvas canvas) + => canvas.Apply(new Rectangle(26, 0, 6, 4), context => context.Invert()); + + // Opens a scoped layer that is smaller than the canvas, and a plain layer inside it. The + // Apply makes the smaller layer scoped. The content of the plain layer is drawn by drawContent. + void DrawInScopedChild(DrawingCanvas canvas, Action drawContent) + { + Rectangle childBounds = new(4, 2, 24, 12); + BeginLayer(canvas); + _ = canvas.SaveLayer(layerOptions, childBounds); + canvas.Apply(new Rectangle(4, 2, 4, 4), context => context.Invert()); + _ = canvas.SaveLayer(layerOptions, childBounds); + drawContent(canvas); + canvas.Restore(); + canvas.Restore(); + canvas.Restore(); + } + + // The sealed drawing: the same content as the direct drawing, with the seal inside the layer. + // The copy source is a canvas of the same backend whose frame is blue with a yellow right half + // in the blue rectangle, so a copy that reads from the wrong origin gives other pixels. + void DrawSealed(DrawingCanvas canvas, List scenes, Func createCopySource) + { + switch (seal) + { + case "CopyPixelsFrom": + { + using DrawingCanvas source = createCopySource(); + BeginLayer(canvas); + canvas.CopyPixelsFrom(source, blue, blue.Location); + canvas.Restore(); + break; + } + + case "CopyPixelsFromSourceChanges": + { + // The source frame changes and renders before the target replays. The copy must + // still give the source pixels from the time of the call. + DrawingCanvas source = createCopySource(); + BeginLayer(canvas); + canvas.CopyPixelsFrom(source, blue, blue.Location); + canvas.Restore(); + source.Fill(Brushes.Solid(Color.Green), blue); + source.Dispose(); + break; + } + + case "Flush": + BeginLayer(canvas); + canvas.Flush(); + DrawBlue(canvas); + canvas.Restore(); + break; + + case "RenderScene": + { + DrawingBackendScene blueScene = CreateRetainedScene(canvas, DrawBlue, scenes); + BeginLayer(canvas); + canvas.RenderScene(blueScene); + canvas.Restore(); + break; + } + + case "RenderSceneWithLayer": + { + DrawingBackendScene layerScene = CreateRetainedScene(canvas, DrawBlueInLayer, scenes); + BeginLayer(canvas); + canvas.RenderScene(layerScene); + canvas.Restore(); + break; + } + + case "TwoScenes": + { + DrawingBackendScene blueScene = CreateRetainedScene(canvas, DrawBlue, scenes); + DrawingBackendScene greenScene = CreateRetainedScene(canvas, DrawGreen, scenes); + BeginLayer(canvas); + canvas.RenderScene(blueScene); + canvas.RenderScene(greenScene); + canvas.Restore(); + break; + } + + case "CreateScene": + { + // The returned scene closes the open layer at its end, and the canvas opens the layer + // again. Rendering the scene back therefore puts its content in a nested layer. + BeginLayer(canvas); + DrawingBackendScene scene = canvas.CreateScene(); + scenes.Add(scene); + canvas.RenderScene(scene); + DrawBlue(canvas); + canvas.Restore(); + break; + } + + case "CreateSceneAfterRestore": + { + DrawingBackendScene blueScene = CreateRetainedScene(canvas, DrawBlue, scenes); + BeginLayer(canvas); + canvas.RenderScene(blueScene); + canvas.Restore(); + DrawingBackendScene combinedScene = canvas.CreateScene(); + scenes.Add(combinedScene); + canvas.RenderScene(combinedScene); + break; + } + + case "RenderSceneAfterApply": + { + DrawingBackendScene blueScene = CreateRetainedScene(canvas, DrawBlue, scenes); + ApplyBeforeLayer(canvas); + BeginLayer(canvas); + canvas.RenderScene(blueScene); + canvas.Restore(); + break; + } + + case "RenderSceneInScopedChild": + { + DrawingBackendScene blueScene = CreateRetainedScene(canvas, DrawBlue, scenes); + DrawInScopedChild(canvas, target => target.RenderScene(blueScene)); + break; + } + + case "RenderPathGradientSceneAfterApply": + { + DrawingBackendScene gradientScene = CreateRetainedScene(canvas, DrawPathGradient, scenes); + ApplyBeforeLayer(canvas); + BeginLayer(canvas); + canvas.RenderScene(gradientScene); + canvas.Restore(); + break; + } + + case "RenderPartsScene": + { + // A scene that renders another scene outside every layer holds its parts in order. + DrawingBackendScene blueScene = CreateRetainedScene(canvas, DrawBlue, scenes); + DrawingBackendScene partsScene = CreateRetainedScene( + canvas, + target => + { + target.RenderScene(blueScene); + DrawGreen(target); + }, + scenes); + + BeginLayer(canvas); + canvas.RenderScene(partsScene); + canvas.Restore(); + break; + } + + case "CopyPixelsFromSelf": + // CopyPixelsFrom applies the queued commands of the source first. The content + // of the open layer reaches the frame only at Restore, so the copy of the green + // rectangle holds the green fill that was recorded before the layer. + DrawGreen(canvas); + BeginLayer(canvas); + canvas.CopyPixelsFrom(canvas, green, copyTarget); + canvas.Restore(); + break; + + case "RenderNestedScene": + { + // The outer scene renders the blue scene directly inside its scoped layer, and + // the canvas renders the outer scene inside its own layer. + DrawingBackendScene blueScene = CreateRetainedScene(canvas, DrawBlue, scenes); + DrawingBackendScene outerScene = CreateRetainedScene( + canvas, + target => DrawInScopedLayer(target, layer => layer.RenderScene(blueScene)), + scenes); + + BeginLayer(canvas); + canvas.RenderScene(outerScene); + canvas.Restore(); + break; + } + } + } + + // The direct drawing: the expected result of each sealed drawing, with no seal. + void DrawDirect(DrawingCanvas canvas) + { + switch (seal) + { + case "RenderSceneWithLayer": + BeginLayer(canvas); + DrawBlueInLayer(canvas); + canvas.Restore(); + break; + + case "TwoScenes": + BeginLayer(canvas); + DrawBlue(canvas); + DrawGreen(canvas); + canvas.Restore(); + break; + + case "CreateScene": + _ = canvas.SaveLayer(layerOptions, layerBounds); + BeginLayer(canvas); + canvas.Restore(); + DrawBlue(canvas); + canvas.Restore(); + break; + + case "RenderSceneAfterApply": + ApplyBeforeLayer(canvas); + BeginLayer(canvas); + DrawBlue(canvas); + canvas.Restore(); + break; + + case "RenderSceneInScopedChild": + DrawInScopedChild(canvas, DrawBlue); + break; + + case "RenderPathGradientSceneAfterApply": + ApplyBeforeLayer(canvas); + BeginLayer(canvas); + DrawPathGradient(canvas); + canvas.Restore(); + break; + + case "RenderPartsScene": + BeginLayer(canvas); + DrawBlue(canvas); + DrawGreen(canvas); + canvas.Restore(); + break; + + case "RenderNestedScene": + BeginLayer(canvas); + DrawInScopedLayer(canvas, DrawBlue); + canvas.Restore(); + break; + + case "CopyPixelsFrom": + case "CopyPixelsFromSourceChanges": + // The copy writes the blue and yellow source pixels at the same position. + BeginLayer(canvas); + DrawBlue(canvas); + canvas.Fill(Brushes.Solid(Color.Yellow), yellow); + canvas.Restore(); + break; + + case "CopyPixelsFromSelf": + // The copy replaces the layer pixels with the green pixels of the frame. An + // image of those pixels, drawn with Src, is the same operation. + DrawGreen(canvas); + BeginLayer(canvas); + _ = canvas.Save(new DrawingOptions + { + GraphicsOptions = new GraphicsOptions { Antialias = false, AlphaCompositionMode = PixelAlphaCompositionMode.Src } + }); + + canvas.DrawImage(copiedGreen, copiedGreen.Bounds, new RectangleF(copyTarget, green.Size)); + canvas.Restore(); + canvas.Restore(); + break; + + default: + BeginLayer(canvas); + DrawBlue(canvas); + canvas.Restore(); + break; + } + } + + // The scenes must stay alive until each canvas replays its timeline on disposal. + List scenes = []; + string details = $"{seal}_{(scopedLayer ? "Scoped" : "Plain")}"; + try + { + using Image defaultDirect = provider.GetImage(); + RenderWithDefaultBackend(defaultDirect, drawingOptions, DrawDirect); + + // The copy source frame must live until the target replays. + using Image defaultCopyImage = provider.GetImage(); + DrawingCanvas CreateDefaultCopySource() + { + DrawingCanvas source = defaultCopyImage.Frames.RootFrame.CreateCanvas(defaultCopyImage.Configuration, drawingOptions); + source.Clear(Brushes.Solid(Color.Blue)); + source.Fill(Brushes.Solid(Color.Yellow), yellow); + return source; + } + + using Image defaultImage = provider.GetImage(); + RenderWithDefaultBackend(defaultImage, drawingOptions, canvas => DrawSealed(canvas, scenes, CreateDefaultCopySource)); + + // The CPU images are saved before the WebGPU render, so a WebGPU failure cannot hide them. + defaultDirect.DebugSave(provider, CreateBackendPairDetails($"{details}_Direct", "Default"), appendPixelTypeToFileName: false, appendSourceFileOrDescription: false); + defaultImage.DebugSave(provider, CreateBackendPairDetails(details, "Default"), appendPixelTypeToFileName: false, appendSourceFileOrDescription: false); + + using WebGPUDrawingBackend nativeSurfaceBackend = new(); + using Image nativeSurfaceInitialImage = provider.GetImage(); + using Image nativeSurfaceDirect = RenderWithNativeSurfaceWebGpuBackend( + defaultImage.Width, + defaultImage.Height, + nativeSurfaceBackend, + drawingOptions, + DrawDirect, + nativeSurfaceInitialImage); + + Configuration nativeConfig = Configuration.Default.Clone(); + nativeConfig.SetDrawingBackend(nativeSurfaceBackend); + using WebGPURenderTarget nativeCopyTarget = new(defaultImage.Width, defaultImage.Height); + DrawingCanvas CreateNativeCopySource() + { + DrawingCanvas source = WebGPUCanvasFactory.CreateCanvas( + nativeConfig, + drawingOptions, + nativeSurfaceBackend, + nativeCopyTarget.Bounds, + nativeCopyTarget.Surface, + nativeCopyTarget.Surface.TargetDescriptor); + + source.Clear(Brushes.Solid(Color.Blue)); + source.Fill(Brushes.Solid(Color.Yellow), yellow); + return source; + } + + using Image nativeSurfaceImage = RenderWithNativeSurfaceWebGpuBackend( + defaultImage.Width, + defaultImage.Height, + nativeSurfaceBackend, + drawingOptions, + canvas => DrawSealed(canvas, scenes, CreateNativeCopySource), + nativeSurfaceInitialImage); + + DebugSaveBackendPair(provider, $"{details}_Direct", defaultDirect, nativeSurfaceDirect); + DebugSaveBackendPair(provider, details, defaultImage, nativeSurfaceImage); + + ImageComparer.Exact.VerifySimilarity(defaultDirect, defaultImage); + ImageComparer.Exact.VerifySimilarity(nativeSurfaceDirect, nativeSurfaceImage); + + // The exact checks above are the contract of each backend. The direct drawings already + // differ by one unit in some blended layer pixels between the CPU and the GPU, so the + // pair tolerance is the largest measured difference of all cases plus one unit. + AssertBackendPairSimilarity(defaultImage, nativeSurfaceImage, 0.0859F); + AssertBackendPairReferenceOutputs(provider, details, defaultImage, nativeSurfaceImage); + } + finally + { + foreach (DrawingBackendScene scene in scenes) + { + scene.Dispose(); + } + } + } + [WebGPUTheory] [WithSolidFilledImages(160, 120, "White", PixelTypes.Rgba32)] public void RetainedScene_WithLayerCommands_MatchesDefaultOutput(TestImageProvider provider) @@ -2654,6 +3194,59 @@ public void FillPath_WithLinearGradientBrush_Repeat_MatchesDefaultOutput AssertBackendPairReferenceOutputs(provider, null, defaultImage, nativeSurfaceImage); } + [WebGPUTheory] + [WithBlankImage(300, 10, PixelTypes.Rgba32, GradientRepetitionMode.Repeat)] + [WithBlankImage(300, 10, PixelTypes.Rgba32, GradientRepetitionMode.Reflect)] + public void FillPath_WithLinearGradientBrush_RepeatsBeforeTheStartPoint( + TestImageProvider provider, + GradientRepetitionMode repetitionMode) + where TPixel : unmanaged, IPixel + { + DrawingOptions drawingOptions = new() + { + GraphicsOptions = new GraphicsOptions { Antialias = true } + }; + + // The axis runs from x = 100 to x = 200, so columns 0 to 99 lie before the start point. + Brush brush = new LinearGradientBrush( + new PointF(100, 0), + new PointF(200, 0), + repetitionMode, + new ColorStop(0, Color.Red), + new ColorStop(1, Color.Blue)); + + void DrawAction(DrawingCanvas canvas) => canvas.Fill(brush, new RectanglePolygon(0, 0, 300, 10)); + + using Image defaultImage = provider.GetImage(); + RenderWithDefaultBackend(defaultImage, drawingOptions, DrawAction); + + using WebGPUDrawingBackend nativeSurfaceBackend = new(); + using Image nativeSurfaceInitialImage = provider.GetImage(); + using Image nativeSurfaceImage = RenderWithNativeSurfaceWebGpuBackend( + defaultImage.Width, + defaultImage.Height, + nativeSurfaceBackend, + drawingOptions, + DrawAction, + nativeSurfaceInitialImage); + + string testName = $"{repetitionMode}"; + DebugSaveBackendPair(provider, testName, defaultImage, nativeSurfaceImage); + + // Repeat gives columns 0 to 99 the colors of the first period, columns 100 to 199. Reflect + // mirrors the first period about the start point. Neither mode pads with the first stop. + using Image beforeStart = nativeSurfaceImage.Clone(ctx => ctx.Crop(new Rectangle(0, 0, 100, 10))); + using Image firstPeriod = nativeSurfaceImage.Clone(ctx => ctx.Crop(new Rectangle(100, 0, 100, 10))); + if (repetitionMode == GradientRepetitionMode.Reflect) + { + firstPeriod.Mutate(ctx => ctx.Flip(FlipMode.Horizontal)); + } + + ImageComparer.TolerantPercentage(0F).VerifySimilarity(firstPeriod, beforeStart); + AssertBackendPairSimilarity(defaultImage, nativeSurfaceImage, 0.0236F); + AssertBackendPairReferenceOutputs(provider, testName, defaultImage, nativeSurfaceImage); + } + [WebGPUFact] public void FillPath_WithZeroLengthLinearGradient_MatchesDefaultEndColor() { @@ -2918,7 +3511,10 @@ void DrawAction(DrawingCanvas canvas) nativeSurfaceInitialImage); DebugSaveBackendPair(provider, null, defaultImage, nativeSurfaceImage); - AssertBackendPairSimilarity(defaultImage, nativeSurfaceImage, 0.0267F); + + // The brushes repeat and reflect before their start points. The measured backend difference + // is 0.0301%, and the tolerance is that value plus 0.0001. + AssertBackendPairSimilarity(defaultImage, nativeSurfaceImage, 0.0302F); AssertBackendPairReferenceOutputs(provider, null, defaultImage, nativeSurfaceImage); } @@ -2993,9 +3589,20 @@ public void FillPath_WithSwappedRadialRepetition_MatchesDefaultOutput(GradientRe // The sampled point lies before the original start circle after the shader's // canonical circle swap. Repetition must therefore see the restored negative t. - Rgba32 expected = Color.Red.ToPixel(); + // The pixel center (0.5, 0.5) lies on the circle at t = (262 - sqrt(214756)) / 192 = -1.04905. + // Repeat maps t to t - floor(t) = 0.95095, and Reflect maps |t| = 1.04905 to 2 - 1.04905 = 0.95095. + // That position is 4.905% red and 95.095% lime. The swapped t' = 1 - t = 2.04905 gives 0.04905, + // which is almost pure red. + Rgba32 expected = new(13, 242, 0, 255); Assert.Equal(expected, defaultImage[0, 0]); - Assert.Equal(defaultImage[0, 0], actual[0, 0]); + + // The exact channels are 12.508 and 242.49, next to a rounding boundary. The f32 shader can + // round to the other side, so WebGPU must be within one level of the CPU result. + Rgba32 actualPixel = actual[0, 0]; + Assert.InRange(Math.Abs(expected.R - actualPixel.R), 0, 1); + Assert.InRange(Math.Abs(expected.G - actualPixel.G), 0, 1); + Assert.Equal(expected.B, actualPixel.B); + Assert.Equal(expected.A, actualPixel.A); } [WebGPUTheory] diff --git a/tests/ImageSharp.Drawing.Tests/Processing/DrawingCanvasTests.Factory.cs b/tests/ImageSharp.Drawing.Tests/Processing/DrawingCanvasTests.Factory.cs index b19c26696..28f171820 100644 --- a/tests/ImageSharp.Drawing.Tests/Processing/DrawingCanvasTests.Factory.cs +++ b/tests/ImageSharp.Drawing.Tests/Processing/DrawingCanvasTests.Factory.cs @@ -51,4 +51,38 @@ public void CreateScene_CanRenderRetainedCommandsRepeatedly() Assert.Equal(Color.Blue.ToPixel(), firstTarget[1, 1]); Assert.Equal(Color.Red.ToPixel(), secondTarget[12, 10]); } + + [Fact] + public void CreateScene_KeepsScenesRecordedBeforeItInOrder() + { + using Image sourceImage = new(32, 16); + using Image target = new(32, 16); + + using (DrawingCanvas sourceCanvas = sourceImage.Frames.RootFrame.CreateCanvas(sourceImage.Configuration, new DrawingOptions())) + { + sourceCanvas.Fill(Brushes.Solid(Color.Red), new Rectangle(4, 4, 8, 8)); + using DrawingBackendScene inserted = sourceCanvas.CreateScene(); + + // Green is recorded before the inserted red scene and blue after it. Both overlap the red + // square, so the colors in the overlaps show the order of the three parts. + sourceCanvas.Fill(Brushes.Solid(Color.Green), new Rectangle(0, 4, 6, 8)); + sourceCanvas.RenderScene(inserted); + sourceCanvas.Fill(Brushes.Solid(Color.Blue), new Rectangle(10, 4, 8, 8)); + using DrawingBackendScene combined = sourceCanvas.CreateScene(); + + using (DrawingCanvas targetCanvas = target.Frames.RootFrame.CreateCanvas(target.Configuration, new DrawingOptions())) + { + targetCanvas.RenderScene(combined); + } + } + + Assert.Equal(Color.Green.ToPixel(), target[2, 8]); + Assert.Equal(Color.Red.ToPixel(), target[5, 8]); + Assert.Equal(Color.Red.ToPixel(), target[8, 8]); + Assert.Equal(Color.Blue.ToPixel(), target[11, 8]); + Assert.Equal(Color.Blue.ToPixel(), target[16, 8]); + + // The recorded work went into the scenes, so the source canvas drew nothing on disposal. + Assert.Equal(default, sourceImage[8, 8]); + } } diff --git a/tests/ImageSharp.Drawing.Tests/Processing/ProcessWithDrawingCanvasTests.GradientBrushes.cs b/tests/ImageSharp.Drawing.Tests/Processing/ProcessWithDrawingCanvasTests.GradientBrushes.cs index 7549b1182..24333ddc9 100644 --- a/tests/ImageSharp.Drawing.Tests/Processing/ProcessWithDrawingCanvasTests.GradientBrushes.cs +++ b/tests/ImageSharp.Drawing.Tests/Processing/ProcessWithDrawingCanvasTests.GradientBrushes.cs @@ -296,6 +296,44 @@ static LinearGradientBrush Create(Matrix4x4 gradientTransform) Assert.NotEqual(upright[250, 150], skewed[250, 150]); } + [Theory] + [WithBlankImage(300, 10, PixelTypes.Rgba32, GradientRepetitionMode.Repeat)] + [WithBlankImage(300, 10, PixelTypes.Rgba32, GradientRepetitionMode.Reflect)] + public void FillLinearGradientBrushRepeatsBeforeTheStartPoint( + TestImageProvider provider, + GradientRepetitionMode repetitionMode) + where TPixel : unmanaged, IPixel + { + // The axis runs from x = 100 to x = 200, so columns 0 to 99 lie before the start point. + LinearGradientBrush brush = new( + new PointF(100, 0), + new PointF(200, 0), + repetitionMode, + new ColorStop(0, Color.Red), + new ColorStop(1, Color.Blue)); + + using Image image = provider.GetImage(); + image.Mutate(ctx => ctx.Paint(canvas => canvas.Fill(brush))); + image.DebugSave(provider, $"{repetitionMode}", appendPixelTypeToFileName: false, appendSourceFileOrDescription: false); + + // Repeat gives columns 0 to 99 the colors of the first period, columns 100 to 199. Reflect + // mirrors the first period about the start point. Neither mode pads with the first stop. + using Image beforeStart = image.Clone(ctx => ctx.Crop(new Rectangle(0, 0, 100, 10))); + using Image firstPeriod = image.Clone(ctx => ctx.Crop(new Rectangle(100, 0, 100, 10))); + if (repetitionMode == GradientRepetitionMode.Reflect) + { + firstPeriod.Mutate(ctx => ctx.Flip(FlipMode.Horizontal)); + } + + LinearGradientTolerantComparer.VerifySimilarity(firstPeriod, beforeStart); + image.CompareToReferenceOutput( + LinearGradientTolerantComparer, + provider, + $"{repetitionMode}", + appendPixelTypeToFileName: false, + appendSourceFileOrDescription: false); + } + [Fact] public void EllipticGradientBrush_Transform_ComposesTheGradientTransform() { diff --git a/tests/Images/ReferenceOutput/Drawing/ProcessWithDrawingCanvasTests/FillLinearGradientBrushRepeatsBeforeTheStartPoint_Reflect.png b/tests/Images/ReferenceOutput/Drawing/ProcessWithDrawingCanvasTests/FillLinearGradientBrushRepeatsBeforeTheStartPoint_Reflect.png new file mode 100644 index 000000000..5e7807ee2 --- /dev/null +++ b/tests/Images/ReferenceOutput/Drawing/ProcessWithDrawingCanvasTests/FillLinearGradientBrushRepeatsBeforeTheStartPoint_Reflect.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:081d128c0c2d9ae86f4942cf19d45d16382e3282988b449ddb80ececafecdf37 +size 180 diff --git a/tests/Images/ReferenceOutput/Drawing/ProcessWithDrawingCanvasTests/FillLinearGradientBrushRepeatsBeforeTheStartPoint_Repeat.png b/tests/Images/ReferenceOutput/Drawing/ProcessWithDrawingCanvasTests/FillLinearGradientBrushRepeatsBeforeTheStartPoint_Repeat.png new file mode 100644 index 000000000..2660da1e6 --- /dev/null +++ b/tests/Images/ReferenceOutput/Drawing/ProcessWithDrawingCanvasTests/FillLinearGradientBrushRepeatsBeforeTheStartPoint_Repeat.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:7f9a7a39fc33df9d5db9e0aa0f48af1a9d8fe454c30a3d0955e691de3f7d2abd +size 171 diff --git a/tests/Images/ReferenceOutput/Drawing/WebGPUDrawingBackendTests/CreateScene_KeepsScenesRecordedBeforeItInOrder_Default.png b/tests/Images/ReferenceOutput/Drawing/WebGPUDrawingBackendTests/CreateScene_KeepsScenesRecordedBeforeItInOrder_Default.png new file mode 100644 index 000000000..b7cd52a07 --- /dev/null +++ b/tests/Images/ReferenceOutput/Drawing/WebGPUDrawingBackendTests/CreateScene_KeepsScenesRecordedBeforeItInOrder_Default.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:f5072ecff3f9aa5a4139e7e901275240687503136a8cfceb807d5b58137dfcdf +size 144 diff --git a/tests/Images/ReferenceOutput/Drawing/WebGPUDrawingBackendTests/CreateScene_KeepsScenesRecordedBeforeItInOrder_WebGPU_NativeSurface.png b/tests/Images/ReferenceOutput/Drawing/WebGPUDrawingBackendTests/CreateScene_KeepsScenesRecordedBeforeItInOrder_WebGPU_NativeSurface.png new file mode 100644 index 000000000..b7cd52a07 --- /dev/null +++ b/tests/Images/ReferenceOutput/Drawing/WebGPUDrawingBackendTests/CreateScene_KeepsScenesRecordedBeforeItInOrder_WebGPU_NativeSurface.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:f5072ecff3f9aa5a4139e7e901275240687503136a8cfceb807d5b58137dfcdf +size 144 diff --git a/tests/Images/ReferenceOutput/Drawing/WebGPUDrawingBackendTests/FillPath_WithGraphicsOptionsModes_ImageBrush_MatchesDefaultOutput_Normal_Clear_WebGPU_NativeSurface.png b/tests/Images/ReferenceOutput/Drawing/WebGPUDrawingBackendTests/FillPath_WithGraphicsOptionsModes_ImageBrush_MatchesDefaultOutput_Normal_Clear_WebGPU_NativeSurface.png index b7aa6943d..3f6482a94 100644 --- a/tests/Images/ReferenceOutput/Drawing/WebGPUDrawingBackendTests/FillPath_WithGraphicsOptionsModes_ImageBrush_MatchesDefaultOutput_Normal_Clear_WebGPU_NativeSurface.png +++ b/tests/Images/ReferenceOutput/Drawing/WebGPUDrawingBackendTests/FillPath_WithGraphicsOptionsModes_ImageBrush_MatchesDefaultOutput_Normal_Clear_WebGPU_NativeSurface.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:a839010836a445dbccecbb3428fd403818adb6593101a8569a68af265d0cdd3b -size 496 +oid sha256:d0f71b58b7dbc135d18cdf391c939117f3f68a9d80d6c32a5fa5c42661118dfb +size 1062 diff --git a/tests/Images/ReferenceOutput/Drawing/WebGPUDrawingBackendTests/FillPath_WithGraphicsOptionsModes_SolidBrush_MatchesDefaultOutput_Normal_Clear_WebGPU_NativeSurface.png b/tests/Images/ReferenceOutput/Drawing/WebGPUDrawingBackendTests/FillPath_WithGraphicsOptionsModes_SolidBrush_MatchesDefaultOutput_Normal_Clear_WebGPU_NativeSurface.png index b7aa6943d..3f6482a94 100644 --- a/tests/Images/ReferenceOutput/Drawing/WebGPUDrawingBackendTests/FillPath_WithGraphicsOptionsModes_SolidBrush_MatchesDefaultOutput_Normal_Clear_WebGPU_NativeSurface.png +++ b/tests/Images/ReferenceOutput/Drawing/WebGPUDrawingBackendTests/FillPath_WithGraphicsOptionsModes_SolidBrush_MatchesDefaultOutput_Normal_Clear_WebGPU_NativeSurface.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:a839010836a445dbccecbb3428fd403818adb6593101a8569a68af265d0cdd3b -size 496 +oid sha256:d0f71b58b7dbc135d18cdf391c939117f3f68a9d80d6c32a5fa5c42661118dfb +size 1062 diff --git a/tests/Images/ReferenceOutput/Drawing/WebGPUDrawingBackendTests/FillPath_WithImageBrush_MatchesDefaultOutput_WebGPU_NativeSurface.png b/tests/Images/ReferenceOutput/Drawing/WebGPUDrawingBackendTests/FillPath_WithImageBrush_MatchesDefaultOutput_WebGPU_NativeSurface.png index 1eeb01770..1f4de617c 100644 --- a/tests/Images/ReferenceOutput/Drawing/WebGPUDrawingBackendTests/FillPath_WithImageBrush_MatchesDefaultOutput_WebGPU_NativeSurface.png +++ b/tests/Images/ReferenceOutput/Drawing/WebGPUDrawingBackendTests/FillPath_WithImageBrush_MatchesDefaultOutput_WebGPU_NativeSurface.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:f3bfb3cb3510c77beb21625d4d45bb3c10629f5469b4b4910d202e71967dce94 -size 363 +oid sha256:16e3bd7570a7b1af658f7f44159b8b663808a3bc2bc80922e3b881cdffdd1ce8 +size 1159 diff --git a/tests/Images/ReferenceOutput/Drawing/WebGPUDrawingBackendTests/FillPath_WithLinearGradientBrush_Repeat_MatchesDefaultOutput_Default.png b/tests/Images/ReferenceOutput/Drawing/WebGPUDrawingBackendTests/FillPath_WithLinearGradientBrush_Repeat_MatchesDefaultOutput_Default.png index 4c8c85716..5bbe45fe0 100644 --- a/tests/Images/ReferenceOutput/Drawing/WebGPUDrawingBackendTests/FillPath_WithLinearGradientBrush_Repeat_MatchesDefaultOutput_Default.png +++ b/tests/Images/ReferenceOutput/Drawing/WebGPUDrawingBackendTests/FillPath_WithLinearGradientBrush_Repeat_MatchesDefaultOutput_Default.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:6831aad3db662c097dd6bc163eeeb80f90e802176dcbf7f29aa3696efae42d40 -size 1643 +oid sha256:16bed1e25326dd0080a66d24d7284f9c5868cef540aa19f3a9a6438b7a329591 +size 2866 diff --git a/tests/Images/ReferenceOutput/Drawing/WebGPUDrawingBackendTests/FillPath_WithLinearGradientBrush_Repeat_MatchesDefaultOutput_WebGPU_NativeSurface.png b/tests/Images/ReferenceOutput/Drawing/WebGPUDrawingBackendTests/FillPath_WithLinearGradientBrush_Repeat_MatchesDefaultOutput_WebGPU_NativeSurface.png index 362a0651a..c77a27ae4 100644 --- a/tests/Images/ReferenceOutput/Drawing/WebGPUDrawingBackendTests/FillPath_WithLinearGradientBrush_Repeat_MatchesDefaultOutput_WebGPU_NativeSurface.png +++ b/tests/Images/ReferenceOutput/Drawing/WebGPUDrawingBackendTests/FillPath_WithLinearGradientBrush_Repeat_MatchesDefaultOutput_WebGPU_NativeSurface.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:bb1337056126bea1d569742309a125713ffb3a03da6c1fc2478494b3f6964133 -size 1643 +oid sha256:9b3da00fa89a383ddc1a4f7067e58edd3d98216b4c22090a6c8877f9f0e9ac1c +size 2553 diff --git a/tests/Images/ReferenceOutput/Drawing/WebGPUDrawingBackendTests/FillPath_WithLinearGradientBrush_RepeatsBeforeTheStartPoint_Reflect_Default.png b/tests/Images/ReferenceOutput/Drawing/WebGPUDrawingBackendTests/FillPath_WithLinearGradientBrush_RepeatsBeforeTheStartPoint_Reflect_Default.png new file mode 100644 index 000000000..5e7807ee2 --- /dev/null +++ b/tests/Images/ReferenceOutput/Drawing/WebGPUDrawingBackendTests/FillPath_WithLinearGradientBrush_RepeatsBeforeTheStartPoint_Reflect_Default.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:081d128c0c2d9ae86f4942cf19d45d16382e3282988b449ddb80ececafecdf37 +size 180 diff --git a/tests/Images/ReferenceOutput/Drawing/WebGPUDrawingBackendTests/FillPath_WithLinearGradientBrush_RepeatsBeforeTheStartPoint_Reflect_WebGPU_NativeSurface.png b/tests/Images/ReferenceOutput/Drawing/WebGPUDrawingBackendTests/FillPath_WithLinearGradientBrush_RepeatsBeforeTheStartPoint_Reflect_WebGPU_NativeSurface.png new file mode 100644 index 000000000..15565f979 --- /dev/null +++ b/tests/Images/ReferenceOutput/Drawing/WebGPUDrawingBackendTests/FillPath_WithLinearGradientBrush_RepeatsBeforeTheStartPoint_Reflect_WebGPU_NativeSurface.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:d6ab6b3abe8b446e996814e776b94a261bd7aa414f48e471cc30814d900f5186 +size 168 diff --git a/tests/Images/ReferenceOutput/Drawing/WebGPUDrawingBackendTests/FillPath_WithLinearGradientBrush_RepeatsBeforeTheStartPoint_Repeat_Default.png b/tests/Images/ReferenceOutput/Drawing/WebGPUDrawingBackendTests/FillPath_WithLinearGradientBrush_RepeatsBeforeTheStartPoint_Repeat_Default.png new file mode 100644 index 000000000..2660da1e6 --- /dev/null +++ b/tests/Images/ReferenceOutput/Drawing/WebGPUDrawingBackendTests/FillPath_WithLinearGradientBrush_RepeatsBeforeTheStartPoint_Repeat_Default.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:7f9a7a39fc33df9d5db9e0aa0f48af1a9d8fe454c30a3d0955e691de3f7d2abd +size 171 diff --git a/tests/Images/ReferenceOutput/Drawing/WebGPUDrawingBackendTests/FillPath_WithLinearGradientBrush_RepeatsBeforeTheStartPoint_Repeat_WebGPU_NativeSurface.png b/tests/Images/ReferenceOutput/Drawing/WebGPUDrawingBackendTests/FillPath_WithLinearGradientBrush_RepeatsBeforeTheStartPoint_Repeat_WebGPU_NativeSurface.png new file mode 100644 index 000000000..05c71d36c --- /dev/null +++ b/tests/Images/ReferenceOutput/Drawing/WebGPUDrawingBackendTests/FillPath_WithLinearGradientBrush_RepeatsBeforeTheStartPoint_Repeat_WebGPU_NativeSurface.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:13720ca4c932e5d918de27af5428b722868ef01fa8251eb7943f2eb4565657dc +size 163 diff --git a/tests/Images/ReferenceOutput/Drawing/WebGPUDrawingBackendTests/FillPath_WithSweepGradientBrush_PartialArc_MatchesDefaultOutput_Default.png b/tests/Images/ReferenceOutput/Drawing/WebGPUDrawingBackendTests/FillPath_WithSweepGradientBrush_PartialArc_MatchesDefaultOutput_Default.png index 2fcf50f05..e601f4511 100644 --- a/tests/Images/ReferenceOutput/Drawing/WebGPUDrawingBackendTests/FillPath_WithSweepGradientBrush_PartialArc_MatchesDefaultOutput_Default.png +++ b/tests/Images/ReferenceOutput/Drawing/WebGPUDrawingBackendTests/FillPath_WithSweepGradientBrush_PartialArc_MatchesDefaultOutput_Default.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:5f12596ccf2f20c8845a5dbf4bafb0bc5827bd058b4596629213b676da45f317 -size 14294 +oid sha256:7248d16db34f74ffefefa5ecad908e5f91d8f4df7964704ae48e4f1456d7614b +size 21571 diff --git a/tests/Images/ReferenceOutput/Drawing/WebGPUDrawingBackendTests/FillPath_WithSweepGradientBrush_PartialArc_MatchesDefaultOutput_WebGPU_NativeSurface.png b/tests/Images/ReferenceOutput/Drawing/WebGPUDrawingBackendTests/FillPath_WithSweepGradientBrush_PartialArc_MatchesDefaultOutput_WebGPU_NativeSurface.png index 34c1b4cd1..73eff527d 100644 --- a/tests/Images/ReferenceOutput/Drawing/WebGPUDrawingBackendTests/FillPath_WithSweepGradientBrush_PartialArc_MatchesDefaultOutput_WebGPU_NativeSurface.png +++ b/tests/Images/ReferenceOutput/Drawing/WebGPUDrawingBackendTests/FillPath_WithSweepGradientBrush_PartialArc_MatchesDefaultOutput_WebGPU_NativeSurface.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:ac2d2d379102ee3725918613c8e954691ee2aaff4f98fb245538596f3fd0aea3 -size 12283 +oid sha256:547189d2fa87dc6740ca5db0bf0c55e5aa6daceecb5ab03c3dcb7d6f1a8538ef +size 18664 diff --git a/tests/Images/ReferenceOutput/Drawing/WebGPUDrawingBackendTests/FillPath_WithTransformedGradientBrushes_MatchesDefaultOutput_Default.png b/tests/Images/ReferenceOutput/Drawing/WebGPUDrawingBackendTests/FillPath_WithTransformedGradientBrushes_MatchesDefaultOutput_Default.png index bdc656830..fcad0799b 100644 --- a/tests/Images/ReferenceOutput/Drawing/WebGPUDrawingBackendTests/FillPath_WithTransformedGradientBrushes_MatchesDefaultOutput_Default.png +++ b/tests/Images/ReferenceOutput/Drawing/WebGPUDrawingBackendTests/FillPath_WithTransformedGradientBrushes_MatchesDefaultOutput_Default.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:a02bc1012d5b6c5ca0b2fe348a043eec4d4ab7d1d42842d2e9d45fed17adce54 -size 19283 +oid sha256:c810af7fbf82829a2f8cc6c4662e3222504dc5922c33734477b30ee527eb0b74 +size 24026 diff --git a/tests/Images/ReferenceOutput/Drawing/WebGPUDrawingBackendTests/FillPath_WithTransformedGradientBrushes_MatchesDefaultOutput_WebGPU_NativeSurface.png b/tests/Images/ReferenceOutput/Drawing/WebGPUDrawingBackendTests/FillPath_WithTransformedGradientBrushes_MatchesDefaultOutput_WebGPU_NativeSurface.png index 63e65b5b9..6de8a776c 100644 --- a/tests/Images/ReferenceOutput/Drawing/WebGPUDrawingBackendTests/FillPath_WithTransformedGradientBrushes_MatchesDefaultOutput_WebGPU_NativeSurface.png +++ b/tests/Images/ReferenceOutput/Drawing/WebGPUDrawingBackendTests/FillPath_WithTransformedGradientBrushes_MatchesDefaultOutput_WebGPU_NativeSurface.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:5c7b8eea469422124ee23f3307da8cffbec6a41bb47004441949f74cdff2b69a -size 19203 +oid sha256:50028acaf2e38e9032fae594dce7200766f808eb8ce799677acb95c700c89b19 +size 23879 diff --git a/tests/Images/ReferenceOutput/Drawing/WebGPUDrawingBackendTests/SaveLayer_WithSealInsideLayer_MatchesDirectDrawing_CopyPixelsFromSelf_Plain_Default.png b/tests/Images/ReferenceOutput/Drawing/WebGPUDrawingBackendTests/SaveLayer_WithSealInsideLayer_MatchesDirectDrawing_CopyPixelsFromSelf_Plain_Default.png new file mode 100644 index 000000000..d8f3a1478 --- /dev/null +++ b/tests/Images/ReferenceOutput/Drawing/WebGPUDrawingBackendTests/SaveLayer_WithSealInsideLayer_MatchesDirectDrawing_CopyPixelsFromSelf_Plain_Default.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:ece37fe5f8678538f071b86cbaa6061587a65dfbc7e66af35b6cb47b40353c24 +size 142 diff --git a/tests/Images/ReferenceOutput/Drawing/WebGPUDrawingBackendTests/SaveLayer_WithSealInsideLayer_MatchesDirectDrawing_CopyPixelsFromSelf_Plain_WebGPU_NativeSurface.png b/tests/Images/ReferenceOutput/Drawing/WebGPUDrawingBackendTests/SaveLayer_WithSealInsideLayer_MatchesDirectDrawing_CopyPixelsFromSelf_Plain_WebGPU_NativeSurface.png new file mode 100644 index 000000000..d8f3a1478 --- /dev/null +++ b/tests/Images/ReferenceOutput/Drawing/WebGPUDrawingBackendTests/SaveLayer_WithSealInsideLayer_MatchesDirectDrawing_CopyPixelsFromSelf_Plain_WebGPU_NativeSurface.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:ece37fe5f8678538f071b86cbaa6061587a65dfbc7e66af35b6cb47b40353c24 +size 142 diff --git a/tests/Images/ReferenceOutput/Drawing/WebGPUDrawingBackendTests/SaveLayer_WithSealInsideLayer_MatchesDirectDrawing_CopyPixelsFromSelf_Scoped_Default.png b/tests/Images/ReferenceOutput/Drawing/WebGPUDrawingBackendTests/SaveLayer_WithSealInsideLayer_MatchesDirectDrawing_CopyPixelsFromSelf_Scoped_Default.png new file mode 100644 index 000000000..d8f3a1478 --- /dev/null +++ b/tests/Images/ReferenceOutput/Drawing/WebGPUDrawingBackendTests/SaveLayer_WithSealInsideLayer_MatchesDirectDrawing_CopyPixelsFromSelf_Scoped_Default.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:ece37fe5f8678538f071b86cbaa6061587a65dfbc7e66af35b6cb47b40353c24 +size 142 diff --git a/tests/Images/ReferenceOutput/Drawing/WebGPUDrawingBackendTests/SaveLayer_WithSealInsideLayer_MatchesDirectDrawing_CopyPixelsFromSelf_Scoped_WebGPU_NativeSurface.png b/tests/Images/ReferenceOutput/Drawing/WebGPUDrawingBackendTests/SaveLayer_WithSealInsideLayer_MatchesDirectDrawing_CopyPixelsFromSelf_Scoped_WebGPU_NativeSurface.png new file mode 100644 index 000000000..caa75aad3 --- /dev/null +++ b/tests/Images/ReferenceOutput/Drawing/WebGPUDrawingBackendTests/SaveLayer_WithSealInsideLayer_MatchesDirectDrawing_CopyPixelsFromSelf_Scoped_WebGPU_NativeSurface.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:b15bccb7692b241003da9a570d254c4bf1b190d9a9ff423f017bc2f2610c9276 +size 148 diff --git a/tests/Images/ReferenceOutput/Drawing/WebGPUDrawingBackendTests/SaveLayer_WithSealInsideLayer_MatchesDirectDrawing_CopyPixelsFromSourceChanges_Plain_Default.png b/tests/Images/ReferenceOutput/Drawing/WebGPUDrawingBackendTests/SaveLayer_WithSealInsideLayer_MatchesDirectDrawing_CopyPixelsFromSourceChanges_Plain_Default.png new file mode 100644 index 000000000..790c79fd3 --- /dev/null +++ b/tests/Images/ReferenceOutput/Drawing/WebGPUDrawingBackendTests/SaveLayer_WithSealInsideLayer_MatchesDirectDrawing_CopyPixelsFromSourceChanges_Plain_Default.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:d61ec36a1a44607aa1b96e84faa0609aa9d7860ce845c331d30e401dc3790b2f +size 147 diff --git a/tests/Images/ReferenceOutput/Drawing/WebGPUDrawingBackendTests/SaveLayer_WithSealInsideLayer_MatchesDirectDrawing_CopyPixelsFromSourceChanges_Plain_WebGPU_NativeSurface.png b/tests/Images/ReferenceOutput/Drawing/WebGPUDrawingBackendTests/SaveLayer_WithSealInsideLayer_MatchesDirectDrawing_CopyPixelsFromSourceChanges_Plain_WebGPU_NativeSurface.png new file mode 100644 index 000000000..790c79fd3 --- /dev/null +++ b/tests/Images/ReferenceOutput/Drawing/WebGPUDrawingBackendTests/SaveLayer_WithSealInsideLayer_MatchesDirectDrawing_CopyPixelsFromSourceChanges_Plain_WebGPU_NativeSurface.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:d61ec36a1a44607aa1b96e84faa0609aa9d7860ce845c331d30e401dc3790b2f +size 147 diff --git a/tests/Images/ReferenceOutput/Drawing/WebGPUDrawingBackendTests/SaveLayer_WithSealInsideLayer_MatchesDirectDrawing_CopyPixelsFromSourceChanges_Scoped_Default.png b/tests/Images/ReferenceOutput/Drawing/WebGPUDrawingBackendTests/SaveLayer_WithSealInsideLayer_MatchesDirectDrawing_CopyPixelsFromSourceChanges_Scoped_Default.png new file mode 100644 index 000000000..226c73987 --- /dev/null +++ b/tests/Images/ReferenceOutput/Drawing/WebGPUDrawingBackendTests/SaveLayer_WithSealInsideLayer_MatchesDirectDrawing_CopyPixelsFromSourceChanges_Scoped_Default.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:59960a924e8ba5704038f23b3987d5e0d230cce0a92c27e1d69e424ec55548d7 +size 149 diff --git a/tests/Images/ReferenceOutput/Drawing/WebGPUDrawingBackendTests/SaveLayer_WithSealInsideLayer_MatchesDirectDrawing_CopyPixelsFromSourceChanges_Scoped_WebGPU_NativeSurface.png b/tests/Images/ReferenceOutput/Drawing/WebGPUDrawingBackendTests/SaveLayer_WithSealInsideLayer_MatchesDirectDrawing_CopyPixelsFromSourceChanges_Scoped_WebGPU_NativeSurface.png new file mode 100644 index 000000000..398d4f181 --- /dev/null +++ b/tests/Images/ReferenceOutput/Drawing/WebGPUDrawingBackendTests/SaveLayer_WithSealInsideLayer_MatchesDirectDrawing_CopyPixelsFromSourceChanges_Scoped_WebGPU_NativeSurface.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:483169e482f3c01ce39a238c00fab8582fcb93625b0cfbc5178ccf9adcac4580 +size 145 diff --git a/tests/Images/ReferenceOutput/Drawing/WebGPUDrawingBackendTests/SaveLayer_WithSealInsideLayer_MatchesDirectDrawing_CopyPixelsFrom_Plain_Default.png b/tests/Images/ReferenceOutput/Drawing/WebGPUDrawingBackendTests/SaveLayer_WithSealInsideLayer_MatchesDirectDrawing_CopyPixelsFrom_Plain_Default.png new file mode 100644 index 000000000..790c79fd3 --- /dev/null +++ b/tests/Images/ReferenceOutput/Drawing/WebGPUDrawingBackendTests/SaveLayer_WithSealInsideLayer_MatchesDirectDrawing_CopyPixelsFrom_Plain_Default.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:d61ec36a1a44607aa1b96e84faa0609aa9d7860ce845c331d30e401dc3790b2f +size 147 diff --git a/tests/Images/ReferenceOutput/Drawing/WebGPUDrawingBackendTests/SaveLayer_WithSealInsideLayer_MatchesDirectDrawing_CopyPixelsFrom_Plain_WebGPU_NativeSurface.png b/tests/Images/ReferenceOutput/Drawing/WebGPUDrawingBackendTests/SaveLayer_WithSealInsideLayer_MatchesDirectDrawing_CopyPixelsFrom_Plain_WebGPU_NativeSurface.png new file mode 100644 index 000000000..790c79fd3 --- /dev/null +++ b/tests/Images/ReferenceOutput/Drawing/WebGPUDrawingBackendTests/SaveLayer_WithSealInsideLayer_MatchesDirectDrawing_CopyPixelsFrom_Plain_WebGPU_NativeSurface.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:d61ec36a1a44607aa1b96e84faa0609aa9d7860ce845c331d30e401dc3790b2f +size 147 diff --git a/tests/Images/ReferenceOutput/Drawing/WebGPUDrawingBackendTests/SaveLayer_WithSealInsideLayer_MatchesDirectDrawing_CopyPixelsFrom_Scoped_Default.png b/tests/Images/ReferenceOutput/Drawing/WebGPUDrawingBackendTests/SaveLayer_WithSealInsideLayer_MatchesDirectDrawing_CopyPixelsFrom_Scoped_Default.png new file mode 100644 index 000000000..226c73987 --- /dev/null +++ b/tests/Images/ReferenceOutput/Drawing/WebGPUDrawingBackendTests/SaveLayer_WithSealInsideLayer_MatchesDirectDrawing_CopyPixelsFrom_Scoped_Default.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:59960a924e8ba5704038f23b3987d5e0d230cce0a92c27e1d69e424ec55548d7 +size 149 diff --git a/tests/Images/ReferenceOutput/Drawing/WebGPUDrawingBackendTests/SaveLayer_WithSealInsideLayer_MatchesDirectDrawing_CopyPixelsFrom_Scoped_WebGPU_NativeSurface.png b/tests/Images/ReferenceOutput/Drawing/WebGPUDrawingBackendTests/SaveLayer_WithSealInsideLayer_MatchesDirectDrawing_CopyPixelsFrom_Scoped_WebGPU_NativeSurface.png new file mode 100644 index 000000000..398d4f181 --- /dev/null +++ b/tests/Images/ReferenceOutput/Drawing/WebGPUDrawingBackendTests/SaveLayer_WithSealInsideLayer_MatchesDirectDrawing_CopyPixelsFrom_Scoped_WebGPU_NativeSurface.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:483169e482f3c01ce39a238c00fab8582fcb93625b0cfbc5178ccf9adcac4580 +size 145 diff --git a/tests/Images/ReferenceOutput/Drawing/WebGPUDrawingBackendTests/SaveLayer_WithSealInsideLayer_MatchesDirectDrawing_CreateSceneAfterRestore_Plain_Default.png b/tests/Images/ReferenceOutput/Drawing/WebGPUDrawingBackendTests/SaveLayer_WithSealInsideLayer_MatchesDirectDrawing_CreateSceneAfterRestore_Plain_Default.png new file mode 100644 index 000000000..04824bce9 --- /dev/null +++ b/tests/Images/ReferenceOutput/Drawing/WebGPUDrawingBackendTests/SaveLayer_WithSealInsideLayer_MatchesDirectDrawing_CreateSceneAfterRestore_Plain_Default.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:41f63ccee7901a0f8dbde605c0fc6d9baf2b35e54c3ae96777138b30f50422a9 +size 142 diff --git a/tests/Images/ReferenceOutput/Drawing/WebGPUDrawingBackendTests/SaveLayer_WithSealInsideLayer_MatchesDirectDrawing_CreateSceneAfterRestore_Plain_WebGPU_NativeSurface.png b/tests/Images/ReferenceOutput/Drawing/WebGPUDrawingBackendTests/SaveLayer_WithSealInsideLayer_MatchesDirectDrawing_CreateSceneAfterRestore_Plain_WebGPU_NativeSurface.png new file mode 100644 index 000000000..04824bce9 --- /dev/null +++ b/tests/Images/ReferenceOutput/Drawing/WebGPUDrawingBackendTests/SaveLayer_WithSealInsideLayer_MatchesDirectDrawing_CreateSceneAfterRestore_Plain_WebGPU_NativeSurface.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:41f63ccee7901a0f8dbde605c0fc6d9baf2b35e54c3ae96777138b30f50422a9 +size 142 diff --git a/tests/Images/ReferenceOutput/Drawing/WebGPUDrawingBackendTests/SaveLayer_WithSealInsideLayer_MatchesDirectDrawing_CreateSceneAfterRestore_Scoped_Default.png b/tests/Images/ReferenceOutput/Drawing/WebGPUDrawingBackendTests/SaveLayer_WithSealInsideLayer_MatchesDirectDrawing_CreateSceneAfterRestore_Scoped_Default.png new file mode 100644 index 000000000..66dacb57d --- /dev/null +++ b/tests/Images/ReferenceOutput/Drawing/WebGPUDrawingBackendTests/SaveLayer_WithSealInsideLayer_MatchesDirectDrawing_CreateSceneAfterRestore_Scoped_Default.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:71f6145188ef22559bfc10114c87689f1a2f1eb9f5c72af2e021777bb3221eca +size 142 diff --git a/tests/Images/ReferenceOutput/Drawing/WebGPUDrawingBackendTests/SaveLayer_WithSealInsideLayer_MatchesDirectDrawing_CreateSceneAfterRestore_Scoped_WebGPU_NativeSurface.png b/tests/Images/ReferenceOutput/Drawing/WebGPUDrawingBackendTests/SaveLayer_WithSealInsideLayer_MatchesDirectDrawing_CreateSceneAfterRestore_Scoped_WebGPU_NativeSurface.png new file mode 100644 index 000000000..fda5fa1e4 --- /dev/null +++ b/tests/Images/ReferenceOutput/Drawing/WebGPUDrawingBackendTests/SaveLayer_WithSealInsideLayer_MatchesDirectDrawing_CreateSceneAfterRestore_Scoped_WebGPU_NativeSurface.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:2f80db96a31b77c9a65f293a18f280411d7da0c2a75b70e220826f4e5556582c +size 144 diff --git a/tests/Images/ReferenceOutput/Drawing/WebGPUDrawingBackendTests/SaveLayer_WithSealInsideLayer_MatchesDirectDrawing_CreateScene_Plain_Default.png b/tests/Images/ReferenceOutput/Drawing/WebGPUDrawingBackendTests/SaveLayer_WithSealInsideLayer_MatchesDirectDrawing_CreateScene_Plain_Default.png new file mode 100644 index 000000000..6697a2e59 --- /dev/null +++ b/tests/Images/ReferenceOutput/Drawing/WebGPUDrawingBackendTests/SaveLayer_WithSealInsideLayer_MatchesDirectDrawing_CreateScene_Plain_Default.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:104a8506c3a92cbc768acd3587722fed4ff698a43ad932dadac7595df81148e5 +size 146 diff --git a/tests/Images/ReferenceOutput/Drawing/WebGPUDrawingBackendTests/SaveLayer_WithSealInsideLayer_MatchesDirectDrawing_CreateScene_Plain_WebGPU_NativeSurface.png b/tests/Images/ReferenceOutput/Drawing/WebGPUDrawingBackendTests/SaveLayer_WithSealInsideLayer_MatchesDirectDrawing_CreateScene_Plain_WebGPU_NativeSurface.png new file mode 100644 index 000000000..6697a2e59 --- /dev/null +++ b/tests/Images/ReferenceOutput/Drawing/WebGPUDrawingBackendTests/SaveLayer_WithSealInsideLayer_MatchesDirectDrawing_CreateScene_Plain_WebGPU_NativeSurface.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:104a8506c3a92cbc768acd3587722fed4ff698a43ad932dadac7595df81148e5 +size 146 diff --git a/tests/Images/ReferenceOutput/Drawing/WebGPUDrawingBackendTests/SaveLayer_WithSealInsideLayer_MatchesDirectDrawing_CreateScene_Scoped_Default.png b/tests/Images/ReferenceOutput/Drawing/WebGPUDrawingBackendTests/SaveLayer_WithSealInsideLayer_MatchesDirectDrawing_CreateScene_Scoped_Default.png new file mode 100644 index 000000000..3fb845bb2 --- /dev/null +++ b/tests/Images/ReferenceOutput/Drawing/WebGPUDrawingBackendTests/SaveLayer_WithSealInsideLayer_MatchesDirectDrawing_CreateScene_Scoped_Default.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:7fca503aa4d6ceb0e19981a1fcb912c7899f088ae4037fdb536324229ff3920c +size 150 diff --git a/tests/Images/ReferenceOutput/Drawing/WebGPUDrawingBackendTests/SaveLayer_WithSealInsideLayer_MatchesDirectDrawing_CreateScene_Scoped_WebGPU_NativeSurface.png b/tests/Images/ReferenceOutput/Drawing/WebGPUDrawingBackendTests/SaveLayer_WithSealInsideLayer_MatchesDirectDrawing_CreateScene_Scoped_WebGPU_NativeSurface.png new file mode 100644 index 000000000..15255acbf --- /dev/null +++ b/tests/Images/ReferenceOutput/Drawing/WebGPUDrawingBackendTests/SaveLayer_WithSealInsideLayer_MatchesDirectDrawing_CreateScene_Scoped_WebGPU_NativeSurface.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:5612209996ec121446f610b1e4c42476e0884aa1d2457316453d8417fa4ab7c5 +size 149 diff --git a/tests/Images/ReferenceOutput/Drawing/WebGPUDrawingBackendTests/SaveLayer_WithSealInsideLayer_MatchesDirectDrawing_Flush_Plain_Default.png b/tests/Images/ReferenceOutput/Drawing/WebGPUDrawingBackendTests/SaveLayer_WithSealInsideLayer_MatchesDirectDrawing_Flush_Plain_Default.png new file mode 100644 index 000000000..04824bce9 --- /dev/null +++ b/tests/Images/ReferenceOutput/Drawing/WebGPUDrawingBackendTests/SaveLayer_WithSealInsideLayer_MatchesDirectDrawing_Flush_Plain_Default.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:41f63ccee7901a0f8dbde605c0fc6d9baf2b35e54c3ae96777138b30f50422a9 +size 142 diff --git a/tests/Images/ReferenceOutput/Drawing/WebGPUDrawingBackendTests/SaveLayer_WithSealInsideLayer_MatchesDirectDrawing_Flush_Plain_WebGPU_NativeSurface.png b/tests/Images/ReferenceOutput/Drawing/WebGPUDrawingBackendTests/SaveLayer_WithSealInsideLayer_MatchesDirectDrawing_Flush_Plain_WebGPU_NativeSurface.png new file mode 100644 index 000000000..04824bce9 --- /dev/null +++ b/tests/Images/ReferenceOutput/Drawing/WebGPUDrawingBackendTests/SaveLayer_WithSealInsideLayer_MatchesDirectDrawing_Flush_Plain_WebGPU_NativeSurface.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:41f63ccee7901a0f8dbde605c0fc6d9baf2b35e54c3ae96777138b30f50422a9 +size 142 diff --git a/tests/Images/ReferenceOutput/Drawing/WebGPUDrawingBackendTests/SaveLayer_WithSealInsideLayer_MatchesDirectDrawing_Flush_Scoped_Default.png b/tests/Images/ReferenceOutput/Drawing/WebGPUDrawingBackendTests/SaveLayer_WithSealInsideLayer_MatchesDirectDrawing_Flush_Scoped_Default.png new file mode 100644 index 000000000..66dacb57d --- /dev/null +++ b/tests/Images/ReferenceOutput/Drawing/WebGPUDrawingBackendTests/SaveLayer_WithSealInsideLayer_MatchesDirectDrawing_Flush_Scoped_Default.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:71f6145188ef22559bfc10114c87689f1a2f1eb9f5c72af2e021777bb3221eca +size 142 diff --git a/tests/Images/ReferenceOutput/Drawing/WebGPUDrawingBackendTests/SaveLayer_WithSealInsideLayer_MatchesDirectDrawing_Flush_Scoped_WebGPU_NativeSurface.png b/tests/Images/ReferenceOutput/Drawing/WebGPUDrawingBackendTests/SaveLayer_WithSealInsideLayer_MatchesDirectDrawing_Flush_Scoped_WebGPU_NativeSurface.png new file mode 100644 index 000000000..fda5fa1e4 --- /dev/null +++ b/tests/Images/ReferenceOutput/Drawing/WebGPUDrawingBackendTests/SaveLayer_WithSealInsideLayer_MatchesDirectDrawing_Flush_Scoped_WebGPU_NativeSurface.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:2f80db96a31b77c9a65f293a18f280411d7da0c2a75b70e220826f4e5556582c +size 144 diff --git a/tests/Images/ReferenceOutput/Drawing/WebGPUDrawingBackendTests/SaveLayer_WithSealInsideLayer_MatchesDirectDrawing_RenderNestedScene_Plain_Default.png b/tests/Images/ReferenceOutput/Drawing/WebGPUDrawingBackendTests/SaveLayer_WithSealInsideLayer_MatchesDirectDrawing_RenderNestedScene_Plain_Default.png new file mode 100644 index 000000000..5998c6e01 --- /dev/null +++ b/tests/Images/ReferenceOutput/Drawing/WebGPUDrawingBackendTests/SaveLayer_WithSealInsideLayer_MatchesDirectDrawing_RenderNestedScene_Plain_Default.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:6ca15be549aa935e38973ddaa5b7f4ece65b770500a38dffbbbde43e31c92775 +size 150 diff --git a/tests/Images/ReferenceOutput/Drawing/WebGPUDrawingBackendTests/SaveLayer_WithSealInsideLayer_MatchesDirectDrawing_RenderNestedScene_Plain_WebGPU_NativeSurface.png b/tests/Images/ReferenceOutput/Drawing/WebGPUDrawingBackendTests/SaveLayer_WithSealInsideLayer_MatchesDirectDrawing_RenderNestedScene_Plain_WebGPU_NativeSurface.png new file mode 100644 index 000000000..24b2d710e --- /dev/null +++ b/tests/Images/ReferenceOutput/Drawing/WebGPUDrawingBackendTests/SaveLayer_WithSealInsideLayer_MatchesDirectDrawing_RenderNestedScene_Plain_WebGPU_NativeSurface.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:0d5f2a09c021d4932fcd1ce3e4920344f8ccf5a836749316feaa4bbf82083219 +size 144 diff --git a/tests/Images/ReferenceOutput/Drawing/WebGPUDrawingBackendTests/SaveLayer_WithSealInsideLayer_MatchesDirectDrawing_RenderNestedScene_Scoped_Default.png b/tests/Images/ReferenceOutput/Drawing/WebGPUDrawingBackendTests/SaveLayer_WithSealInsideLayer_MatchesDirectDrawing_RenderNestedScene_Scoped_Default.png new file mode 100644 index 000000000..0aca92380 --- /dev/null +++ b/tests/Images/ReferenceOutput/Drawing/WebGPUDrawingBackendTests/SaveLayer_WithSealInsideLayer_MatchesDirectDrawing_RenderNestedScene_Scoped_Default.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:cd4c1a41a0775903f1e27d34efba95823a02f8d0c6c77d53a3442489603ad77d +size 155 diff --git a/tests/Images/ReferenceOutput/Drawing/WebGPUDrawingBackendTests/SaveLayer_WithSealInsideLayer_MatchesDirectDrawing_RenderNestedScene_Scoped_WebGPU_NativeSurface.png b/tests/Images/ReferenceOutput/Drawing/WebGPUDrawingBackendTests/SaveLayer_WithSealInsideLayer_MatchesDirectDrawing_RenderNestedScene_Scoped_WebGPU_NativeSurface.png new file mode 100644 index 000000000..5cf2416df --- /dev/null +++ b/tests/Images/ReferenceOutput/Drawing/WebGPUDrawingBackendTests/SaveLayer_WithSealInsideLayer_MatchesDirectDrawing_RenderNestedScene_Scoped_WebGPU_NativeSurface.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:a4cb245dd5d003aeadafa8c5b557ed25a169663def1fc3f7b3d4a21611bfa18c +size 150 diff --git a/tests/Images/ReferenceOutput/Drawing/WebGPUDrawingBackendTests/SaveLayer_WithSealInsideLayer_MatchesDirectDrawing_RenderPartsScene_Plain_Default.png b/tests/Images/ReferenceOutput/Drawing/WebGPUDrawingBackendTests/SaveLayer_WithSealInsideLayer_MatchesDirectDrawing_RenderPartsScene_Plain_Default.png new file mode 100644 index 000000000..419e32274 --- /dev/null +++ b/tests/Images/ReferenceOutput/Drawing/WebGPUDrawingBackendTests/SaveLayer_WithSealInsideLayer_MatchesDirectDrawing_RenderPartsScene_Plain_Default.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:ba86d9d7376e6c71024700e39092503605815b584ae1a9c68aa686c22198131b +size 150 diff --git a/tests/Images/ReferenceOutput/Drawing/WebGPUDrawingBackendTests/SaveLayer_WithSealInsideLayer_MatchesDirectDrawing_RenderPartsScene_Plain_WebGPU_NativeSurface.png b/tests/Images/ReferenceOutput/Drawing/WebGPUDrawingBackendTests/SaveLayer_WithSealInsideLayer_MatchesDirectDrawing_RenderPartsScene_Plain_WebGPU_NativeSurface.png new file mode 100644 index 000000000..fd7a9a80e --- /dev/null +++ b/tests/Images/ReferenceOutput/Drawing/WebGPUDrawingBackendTests/SaveLayer_WithSealInsideLayer_MatchesDirectDrawing_RenderPartsScene_Plain_WebGPU_NativeSurface.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:a59308f3df4e9ee71d7aebd554bf66138fedbf57881b4ce11e792ecddeefc1d2 +size 150 diff --git a/tests/Images/ReferenceOutput/Drawing/WebGPUDrawingBackendTests/SaveLayer_WithSealInsideLayer_MatchesDirectDrawing_RenderPartsScene_Scoped_Default.png b/tests/Images/ReferenceOutput/Drawing/WebGPUDrawingBackendTests/SaveLayer_WithSealInsideLayer_MatchesDirectDrawing_RenderPartsScene_Scoped_Default.png new file mode 100644 index 000000000..f9a77d737 --- /dev/null +++ b/tests/Images/ReferenceOutput/Drawing/WebGPUDrawingBackendTests/SaveLayer_WithSealInsideLayer_MatchesDirectDrawing_RenderPartsScene_Scoped_Default.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:772d70b75bdb81c8aa3cf604bbefc4cbe83ce0c555cfa2abc6c21dff0973f0c7 +size 148 diff --git a/tests/Images/ReferenceOutput/Drawing/WebGPUDrawingBackendTests/SaveLayer_WithSealInsideLayer_MatchesDirectDrawing_RenderPartsScene_Scoped_WebGPU_NativeSurface.png b/tests/Images/ReferenceOutput/Drawing/WebGPUDrawingBackendTests/SaveLayer_WithSealInsideLayer_MatchesDirectDrawing_RenderPartsScene_Scoped_WebGPU_NativeSurface.png new file mode 100644 index 000000000..e6741f9d5 --- /dev/null +++ b/tests/Images/ReferenceOutput/Drawing/WebGPUDrawingBackendTests/SaveLayer_WithSealInsideLayer_MatchesDirectDrawing_RenderPartsScene_Scoped_WebGPU_NativeSurface.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:d0f2f57e1a54a0e295ea17b3f4cb305ee9f18c213610877126af93860a0b440e +size 149 diff --git a/tests/Images/ReferenceOutput/Drawing/WebGPUDrawingBackendTests/SaveLayer_WithSealInsideLayer_MatchesDirectDrawing_RenderPathGradientSceneAfterApply_Plain_Default.png b/tests/Images/ReferenceOutput/Drawing/WebGPUDrawingBackendTests/SaveLayer_WithSealInsideLayer_MatchesDirectDrawing_RenderPathGradientSceneAfterApply_Plain_Default.png new file mode 100644 index 000000000..7c725fe01 --- /dev/null +++ b/tests/Images/ReferenceOutput/Drawing/WebGPUDrawingBackendTests/SaveLayer_WithSealInsideLayer_MatchesDirectDrawing_RenderPathGradientSceneAfterApply_Plain_Default.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:89dc0be6b77a24a52d79cc01833e3c63e240185cbb5972137802001a2b7a6eaa +size 273 diff --git a/tests/Images/ReferenceOutput/Drawing/WebGPUDrawingBackendTests/SaveLayer_WithSealInsideLayer_MatchesDirectDrawing_RenderPathGradientSceneAfterApply_Plain_WebGPU_NativeSurface.png b/tests/Images/ReferenceOutput/Drawing/WebGPUDrawingBackendTests/SaveLayer_WithSealInsideLayer_MatchesDirectDrawing_RenderPathGradientSceneAfterApply_Plain_WebGPU_NativeSurface.png new file mode 100644 index 000000000..086a956a9 --- /dev/null +++ b/tests/Images/ReferenceOutput/Drawing/WebGPUDrawingBackendTests/SaveLayer_WithSealInsideLayer_MatchesDirectDrawing_RenderPathGradientSceneAfterApply_Plain_WebGPU_NativeSurface.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:680f2ea547f9f5f268c0c3bbde892225708323ff3ecf1828ed7ebe4e6c51f576 +size 272 diff --git a/tests/Images/ReferenceOutput/Drawing/WebGPUDrawingBackendTests/SaveLayer_WithSealInsideLayer_MatchesDirectDrawing_RenderPathGradientSceneAfterApply_Scoped_Default.png b/tests/Images/ReferenceOutput/Drawing/WebGPUDrawingBackendTests/SaveLayer_WithSealInsideLayer_MatchesDirectDrawing_RenderPathGradientSceneAfterApply_Scoped_Default.png new file mode 100644 index 000000000..adaf9c379 --- /dev/null +++ b/tests/Images/ReferenceOutput/Drawing/WebGPUDrawingBackendTests/SaveLayer_WithSealInsideLayer_MatchesDirectDrawing_RenderPathGradientSceneAfterApply_Scoped_Default.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:98b5d54366d9dc5d32f20446703de2f62df20fa5630d070e47b7bc71de0d6b30 +size 276 diff --git a/tests/Images/ReferenceOutput/Drawing/WebGPUDrawingBackendTests/SaveLayer_WithSealInsideLayer_MatchesDirectDrawing_RenderPathGradientSceneAfterApply_Scoped_WebGPU_NativeSurface.png b/tests/Images/ReferenceOutput/Drawing/WebGPUDrawingBackendTests/SaveLayer_WithSealInsideLayer_MatchesDirectDrawing_RenderPathGradientSceneAfterApply_Scoped_WebGPU_NativeSurface.png new file mode 100644 index 000000000..7b71d748d --- /dev/null +++ b/tests/Images/ReferenceOutput/Drawing/WebGPUDrawingBackendTests/SaveLayer_WithSealInsideLayer_MatchesDirectDrawing_RenderPathGradientSceneAfterApply_Scoped_WebGPU_NativeSurface.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:ebab6db3011e61377b055a842ba79c38a478d165d6784aad3cbda392ee9c3d13 +size 264 diff --git a/tests/Images/ReferenceOutput/Drawing/WebGPUDrawingBackendTests/SaveLayer_WithSealInsideLayer_MatchesDirectDrawing_RenderSceneAfterApply_Plain_Default.png b/tests/Images/ReferenceOutput/Drawing/WebGPUDrawingBackendTests/SaveLayer_WithSealInsideLayer_MatchesDirectDrawing_RenderSceneAfterApply_Plain_Default.png new file mode 100644 index 000000000..131ec7926 --- /dev/null +++ b/tests/Images/ReferenceOutput/Drawing/WebGPUDrawingBackendTests/SaveLayer_WithSealInsideLayer_MatchesDirectDrawing_RenderSceneAfterApply_Plain_Default.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:ddb9ff7345e4f1610aaf8f5b0bedf4dab1a7d0cfa074d89144699c69b1a2eef6 +size 145 diff --git a/tests/Images/ReferenceOutput/Drawing/WebGPUDrawingBackendTests/SaveLayer_WithSealInsideLayer_MatchesDirectDrawing_RenderSceneAfterApply_Plain_WebGPU_NativeSurface.png b/tests/Images/ReferenceOutput/Drawing/WebGPUDrawingBackendTests/SaveLayer_WithSealInsideLayer_MatchesDirectDrawing_RenderSceneAfterApply_Plain_WebGPU_NativeSurface.png new file mode 100644 index 000000000..131ec7926 --- /dev/null +++ b/tests/Images/ReferenceOutput/Drawing/WebGPUDrawingBackendTests/SaveLayer_WithSealInsideLayer_MatchesDirectDrawing_RenderSceneAfterApply_Plain_WebGPU_NativeSurface.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:ddb9ff7345e4f1610aaf8f5b0bedf4dab1a7d0cfa074d89144699c69b1a2eef6 +size 145 diff --git a/tests/Images/ReferenceOutput/Drawing/WebGPUDrawingBackendTests/SaveLayer_WithSealInsideLayer_MatchesDirectDrawing_RenderSceneAfterApply_Scoped_Default.png b/tests/Images/ReferenceOutput/Drawing/WebGPUDrawingBackendTests/SaveLayer_WithSealInsideLayer_MatchesDirectDrawing_RenderSceneAfterApply_Scoped_Default.png new file mode 100644 index 000000000..deb162778 --- /dev/null +++ b/tests/Images/ReferenceOutput/Drawing/WebGPUDrawingBackendTests/SaveLayer_WithSealInsideLayer_MatchesDirectDrawing_RenderSceneAfterApply_Scoped_Default.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:89f0df5979f969f7bdc35c9af95e0835bb8013ca5a91e86837270932788e43b1 +size 149 diff --git a/tests/Images/ReferenceOutput/Drawing/WebGPUDrawingBackendTests/SaveLayer_WithSealInsideLayer_MatchesDirectDrawing_RenderSceneAfterApply_Scoped_WebGPU_NativeSurface.png b/tests/Images/ReferenceOutput/Drawing/WebGPUDrawingBackendTests/SaveLayer_WithSealInsideLayer_MatchesDirectDrawing_RenderSceneAfterApply_Scoped_WebGPU_NativeSurface.png new file mode 100644 index 000000000..af655be5b --- /dev/null +++ b/tests/Images/ReferenceOutput/Drawing/WebGPUDrawingBackendTests/SaveLayer_WithSealInsideLayer_MatchesDirectDrawing_RenderSceneAfterApply_Scoped_WebGPU_NativeSurface.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:f396a3f8c198ff3aadd469394514e1f4965d73e27ea438b88ad482cd23158ef8 +size 148 diff --git a/tests/Images/ReferenceOutput/Drawing/WebGPUDrawingBackendTests/SaveLayer_WithSealInsideLayer_MatchesDirectDrawing_RenderSceneInScopedChild_Plain_Default.png b/tests/Images/ReferenceOutput/Drawing/WebGPUDrawingBackendTests/SaveLayer_WithSealInsideLayer_MatchesDirectDrawing_RenderSceneInScopedChild_Plain_Default.png new file mode 100644 index 000000000..eb8cdab02 --- /dev/null +++ b/tests/Images/ReferenceOutput/Drawing/WebGPUDrawingBackendTests/SaveLayer_WithSealInsideLayer_MatchesDirectDrawing_RenderSceneInScopedChild_Plain_Default.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:1c732cca58339e9682221ef09b1b6f5931b2f79d96d33a29757dfe7a3a5356d1 +size 148 diff --git a/tests/Images/ReferenceOutput/Drawing/WebGPUDrawingBackendTests/SaveLayer_WithSealInsideLayer_MatchesDirectDrawing_RenderSceneInScopedChild_Plain_WebGPU_NativeSurface.png b/tests/Images/ReferenceOutput/Drawing/WebGPUDrawingBackendTests/SaveLayer_WithSealInsideLayer_MatchesDirectDrawing_RenderSceneInScopedChild_Plain_WebGPU_NativeSurface.png new file mode 100644 index 000000000..feaf3f21f --- /dev/null +++ b/tests/Images/ReferenceOutput/Drawing/WebGPUDrawingBackendTests/SaveLayer_WithSealInsideLayer_MatchesDirectDrawing_RenderSceneInScopedChild_Plain_WebGPU_NativeSurface.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:e402a4e99c2137e0a2f10ceb5ffd66def4e5596685dae51d1ebaf37ae2003803 +size 147 diff --git a/tests/Images/ReferenceOutput/Drawing/WebGPUDrawingBackendTests/SaveLayer_WithSealInsideLayer_MatchesDirectDrawing_RenderSceneInScopedChild_Scoped_Default.png b/tests/Images/ReferenceOutput/Drawing/WebGPUDrawingBackendTests/SaveLayer_WithSealInsideLayer_MatchesDirectDrawing_RenderSceneInScopedChild_Scoped_Default.png new file mode 100644 index 000000000..02d88c5cc --- /dev/null +++ b/tests/Images/ReferenceOutput/Drawing/WebGPUDrawingBackendTests/SaveLayer_WithSealInsideLayer_MatchesDirectDrawing_RenderSceneInScopedChild_Scoped_Default.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:af705bb3dd00457c331ada623e88701647c70e4f63a0e2532df8bca48262716f +size 153 diff --git a/tests/Images/ReferenceOutput/Drawing/WebGPUDrawingBackendTests/SaveLayer_WithSealInsideLayer_MatchesDirectDrawing_RenderSceneInScopedChild_Scoped_WebGPU_NativeSurface.png b/tests/Images/ReferenceOutput/Drawing/WebGPUDrawingBackendTests/SaveLayer_WithSealInsideLayer_MatchesDirectDrawing_RenderSceneInScopedChild_Scoped_WebGPU_NativeSurface.png new file mode 100644 index 000000000..7e8f44747 --- /dev/null +++ b/tests/Images/ReferenceOutput/Drawing/WebGPUDrawingBackendTests/SaveLayer_WithSealInsideLayer_MatchesDirectDrawing_RenderSceneInScopedChild_Scoped_WebGPU_NativeSurface.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:0e6b3b2ce61049c562faf68302be0814c48e8045ce2e6a1d85d058d2c1f8704d +size 153 diff --git a/tests/Images/ReferenceOutput/Drawing/WebGPUDrawingBackendTests/SaveLayer_WithSealInsideLayer_MatchesDirectDrawing_RenderSceneWithLayer_Plain_Default.png b/tests/Images/ReferenceOutput/Drawing/WebGPUDrawingBackendTests/SaveLayer_WithSealInsideLayer_MatchesDirectDrawing_RenderSceneWithLayer_Plain_Default.png new file mode 100644 index 000000000..5998c6e01 --- /dev/null +++ b/tests/Images/ReferenceOutput/Drawing/WebGPUDrawingBackendTests/SaveLayer_WithSealInsideLayer_MatchesDirectDrawing_RenderSceneWithLayer_Plain_Default.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:6ca15be549aa935e38973ddaa5b7f4ece65b770500a38dffbbbde43e31c92775 +size 150 diff --git a/tests/Images/ReferenceOutput/Drawing/WebGPUDrawingBackendTests/SaveLayer_WithSealInsideLayer_MatchesDirectDrawing_RenderSceneWithLayer_Plain_WebGPU_NativeSurface.png b/tests/Images/ReferenceOutput/Drawing/WebGPUDrawingBackendTests/SaveLayer_WithSealInsideLayer_MatchesDirectDrawing_RenderSceneWithLayer_Plain_WebGPU_NativeSurface.png new file mode 100644 index 000000000..37ace1ebe --- /dev/null +++ b/tests/Images/ReferenceOutput/Drawing/WebGPUDrawingBackendTests/SaveLayer_WithSealInsideLayer_MatchesDirectDrawing_RenderSceneWithLayer_Plain_WebGPU_NativeSurface.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:a3d439ba8784845a2b6ae8c7d7a74244f92799d7aee3eb279e9b79fcd6b61b3c +size 147 diff --git a/tests/Images/ReferenceOutput/Drawing/WebGPUDrawingBackendTests/SaveLayer_WithSealInsideLayer_MatchesDirectDrawing_RenderSceneWithLayer_Scoped_Default.png b/tests/Images/ReferenceOutput/Drawing/WebGPUDrawingBackendTests/SaveLayer_WithSealInsideLayer_MatchesDirectDrawing_RenderSceneWithLayer_Scoped_Default.png new file mode 100644 index 000000000..0aca92380 --- /dev/null +++ b/tests/Images/ReferenceOutput/Drawing/WebGPUDrawingBackendTests/SaveLayer_WithSealInsideLayer_MatchesDirectDrawing_RenderSceneWithLayer_Scoped_Default.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:cd4c1a41a0775903f1e27d34efba95823a02f8d0c6c77d53a3442489603ad77d +size 155 diff --git a/tests/Images/ReferenceOutput/Drawing/WebGPUDrawingBackendTests/SaveLayer_WithSealInsideLayer_MatchesDirectDrawing_RenderSceneWithLayer_Scoped_WebGPU_NativeSurface.png b/tests/Images/ReferenceOutput/Drawing/WebGPUDrawingBackendTests/SaveLayer_WithSealInsideLayer_MatchesDirectDrawing_RenderSceneWithLayer_Scoped_WebGPU_NativeSurface.png new file mode 100644 index 000000000..5cf2416df --- /dev/null +++ b/tests/Images/ReferenceOutput/Drawing/WebGPUDrawingBackendTests/SaveLayer_WithSealInsideLayer_MatchesDirectDrawing_RenderSceneWithLayer_Scoped_WebGPU_NativeSurface.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:a4cb245dd5d003aeadafa8c5b557ed25a169663def1fc3f7b3d4a21611bfa18c +size 150 diff --git a/tests/Images/ReferenceOutput/Drawing/WebGPUDrawingBackendTests/SaveLayer_WithSealInsideLayer_MatchesDirectDrawing_RenderScene_Plain_Default.png b/tests/Images/ReferenceOutput/Drawing/WebGPUDrawingBackendTests/SaveLayer_WithSealInsideLayer_MatchesDirectDrawing_RenderScene_Plain_Default.png new file mode 100644 index 000000000..04824bce9 --- /dev/null +++ b/tests/Images/ReferenceOutput/Drawing/WebGPUDrawingBackendTests/SaveLayer_WithSealInsideLayer_MatchesDirectDrawing_RenderScene_Plain_Default.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:41f63ccee7901a0f8dbde605c0fc6d9baf2b35e54c3ae96777138b30f50422a9 +size 142 diff --git a/tests/Images/ReferenceOutput/Drawing/WebGPUDrawingBackendTests/SaveLayer_WithSealInsideLayer_MatchesDirectDrawing_RenderScene_Plain_WebGPU_NativeSurface.png b/tests/Images/ReferenceOutput/Drawing/WebGPUDrawingBackendTests/SaveLayer_WithSealInsideLayer_MatchesDirectDrawing_RenderScene_Plain_WebGPU_NativeSurface.png new file mode 100644 index 000000000..04824bce9 --- /dev/null +++ b/tests/Images/ReferenceOutput/Drawing/WebGPUDrawingBackendTests/SaveLayer_WithSealInsideLayer_MatchesDirectDrawing_RenderScene_Plain_WebGPU_NativeSurface.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:41f63ccee7901a0f8dbde605c0fc6d9baf2b35e54c3ae96777138b30f50422a9 +size 142 diff --git a/tests/Images/ReferenceOutput/Drawing/WebGPUDrawingBackendTests/SaveLayer_WithSealInsideLayer_MatchesDirectDrawing_RenderScene_Scoped_Default.png b/tests/Images/ReferenceOutput/Drawing/WebGPUDrawingBackendTests/SaveLayer_WithSealInsideLayer_MatchesDirectDrawing_RenderScene_Scoped_Default.png new file mode 100644 index 000000000..66dacb57d --- /dev/null +++ b/tests/Images/ReferenceOutput/Drawing/WebGPUDrawingBackendTests/SaveLayer_WithSealInsideLayer_MatchesDirectDrawing_RenderScene_Scoped_Default.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:71f6145188ef22559bfc10114c87689f1a2f1eb9f5c72af2e021777bb3221eca +size 142 diff --git a/tests/Images/ReferenceOutput/Drawing/WebGPUDrawingBackendTests/SaveLayer_WithSealInsideLayer_MatchesDirectDrawing_RenderScene_Scoped_WebGPU_NativeSurface.png b/tests/Images/ReferenceOutput/Drawing/WebGPUDrawingBackendTests/SaveLayer_WithSealInsideLayer_MatchesDirectDrawing_RenderScene_Scoped_WebGPU_NativeSurface.png new file mode 100644 index 000000000..fda5fa1e4 --- /dev/null +++ b/tests/Images/ReferenceOutput/Drawing/WebGPUDrawingBackendTests/SaveLayer_WithSealInsideLayer_MatchesDirectDrawing_RenderScene_Scoped_WebGPU_NativeSurface.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:2f80db96a31b77c9a65f293a18f280411d7da0c2a75b70e220826f4e5556582c +size 144 diff --git a/tests/Images/ReferenceOutput/Drawing/WebGPUDrawingBackendTests/SaveLayer_WithSealInsideLayer_MatchesDirectDrawing_TwoScenes_Plain_Default.png b/tests/Images/ReferenceOutput/Drawing/WebGPUDrawingBackendTests/SaveLayer_WithSealInsideLayer_MatchesDirectDrawing_TwoScenes_Plain_Default.png new file mode 100644 index 000000000..419e32274 --- /dev/null +++ b/tests/Images/ReferenceOutput/Drawing/WebGPUDrawingBackendTests/SaveLayer_WithSealInsideLayer_MatchesDirectDrawing_TwoScenes_Plain_Default.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:ba86d9d7376e6c71024700e39092503605815b584ae1a9c68aa686c22198131b +size 150 diff --git a/tests/Images/ReferenceOutput/Drawing/WebGPUDrawingBackendTests/SaveLayer_WithSealInsideLayer_MatchesDirectDrawing_TwoScenes_Plain_WebGPU_NativeSurface.png b/tests/Images/ReferenceOutput/Drawing/WebGPUDrawingBackendTests/SaveLayer_WithSealInsideLayer_MatchesDirectDrawing_TwoScenes_Plain_WebGPU_NativeSurface.png new file mode 100644 index 000000000..fd7a9a80e --- /dev/null +++ b/tests/Images/ReferenceOutput/Drawing/WebGPUDrawingBackendTests/SaveLayer_WithSealInsideLayer_MatchesDirectDrawing_TwoScenes_Plain_WebGPU_NativeSurface.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:a59308f3df4e9ee71d7aebd554bf66138fedbf57881b4ce11e792ecddeefc1d2 +size 150 diff --git a/tests/Images/ReferenceOutput/Drawing/WebGPUDrawingBackendTests/SaveLayer_WithSealInsideLayer_MatchesDirectDrawing_TwoScenes_Scoped_Default.png b/tests/Images/ReferenceOutput/Drawing/WebGPUDrawingBackendTests/SaveLayer_WithSealInsideLayer_MatchesDirectDrawing_TwoScenes_Scoped_Default.png new file mode 100644 index 000000000..f9a77d737 --- /dev/null +++ b/tests/Images/ReferenceOutput/Drawing/WebGPUDrawingBackendTests/SaveLayer_WithSealInsideLayer_MatchesDirectDrawing_TwoScenes_Scoped_Default.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:772d70b75bdb81c8aa3cf604bbefc4cbe83ce0c555cfa2abc6c21dff0973f0c7 +size 148 diff --git a/tests/Images/ReferenceOutput/Drawing/WebGPUDrawingBackendTests/SaveLayer_WithSealInsideLayer_MatchesDirectDrawing_TwoScenes_Scoped_WebGPU_NativeSurface.png b/tests/Images/ReferenceOutput/Drawing/WebGPUDrawingBackendTests/SaveLayer_WithSealInsideLayer_MatchesDirectDrawing_TwoScenes_Scoped_WebGPU_NativeSurface.png new file mode 100644 index 000000000..e6741f9d5 --- /dev/null +++ b/tests/Images/ReferenceOutput/Drawing/WebGPUDrawingBackendTests/SaveLayer_WithSealInsideLayer_MatchesDirectDrawing_TwoScenes_Scoped_WebGPU_NativeSurface.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:d0f2f57e1a54a0e295ea17b3f4cb305ee9f18c213610877126af93860a0b440e +size 149 From d8f4786c3c69440357ebb8684e8a24153539d827 Mon Sep 17 00:00:00 2001 From: James Jackson-South Date: Mon, 26 Oct 2026 17:48:10 +1000 Subject: [PATCH 2/6] Skip the WebGPU status readback when the scratch bounds hold A plain WebGPU render now submits its work and returns. It reads no scratch status when every capacity is an upper bound of the GPU use. Ordered ranges, a binning bound above the binding limit, and chunked scenes without row bounds still read the status. At 100k lines the flush call takes about 45 ms against 97 ms on the baseline, and the time per frame is the same. The bounds are now true upper bounds: - The row bounds keep one entry for each tile row, so a chunk shorter than one bin gets the bound of its own rows. - The stroke style flags hold the CPU arc count, and the shader limits each round arc to it. - A long inner join adds its own crossing bound, and the inner miter uses the shader limit. - The PTCL bound comes from the chunk allocation in coarse. - Each clip estimate grows by one pixel on each side. - The outline radius of a Miter join is sqrt(m^2 + 1) half widths, which is the clipped bevel point of both strokers. - path_count makes the y clip exact, so each line adds at most its tile count and the row index stays in the bbox. A test renders 100k lines in chunks shorter than one bin and compares them with an unchunked render. The layer tests compare their pixels with the CPU backend. --- .../Shaders/WgslSource/Shared/pathtag.wgsl | 5 + .../Shaders/WgslSource/path_count.wgsl | 16 +- .../Shaders/WgslSource/path_lowering.wgsl | 9 +- .../WebGPUDrawingBackend.cs | 19 +- .../WebGPUSceneDispatch.cs | 177 +++++++++++++----- .../WebGPUSceneEncoder.cs | 135 +++++++++++-- .../WebGPUSceneRowBounds.cs | 139 ++++++-------- src/ImageSharp.Drawing/Processing/Pen.cs | 10 +- .../WebGPUDrawingBackendTests.Diagnostics.cs | 106 +++++++---- 9 files changed, 419 insertions(+), 197 deletions(-) diff --git a/src/ImageSharp.Drawing.WebGPU/Shaders/WgslSource/Shared/pathtag.wgsl b/src/ImageSharp.Drawing.WebGPU/Shaders/WgslSource/Shared/pathtag.wgsl index dba8b40fa..1a93d50e2 100644 --- a/src/ImageSharp.Drawing.WebGPU/Shaders/WgslSource/Shared/pathtag.wgsl +++ b/src/ImageSharp.Drawing.WebGPU/Shaders/WgslSource/Shared/pathtag.wgsl @@ -61,6 +61,11 @@ const STYLE_FLAGS_JOIN_ROUND: u32 = 0x20000000u; const STYLE_FLAGS_JOIN_MITER_REVERT: u32 = 0x00400000u; const STYLE_FLAGS_JOIN_MITER_ROUND: u32 = 0x00800000u; +// Stroke styles only: the largest interior vertex count of one round arc. The C# encoder writes +// the CPU count of half a turn, so the GPU line count of a round join or cap stays inside the +// CPU line estimate. +const STYLE_FLAGS_ARC_LIMIT_MASK: u32 = 0x000007FFu; + // TODO: Declare the remaining STYLE flags here. // The scan identity: all counters zero. diff --git a/src/ImageSharp.Drawing.WebGPU/Shaders/WgslSource/path_count.wgsl b/src/ImageSharp.Drawing.WebGPU/Shaders/WgslSource/path_count.wgsl index 48b1c1322..0a809f49e 100644 --- a/src/ImageSharp.Drawing.WebGPU/Shaders/WgslSource/path_count.wgsl +++ b/src/ImageSharp.Drawing.WebGPU/Shaders/WgslSource/path_count.wgsl @@ -175,8 +175,22 @@ fn main( } } + // The clip indices come from rounded f32 formulas with one correction step, so a large error + // can leave them too wide. Index count and above are not crossings of the line. The row of + // crossing i is y0 + i - floor(a * i + b), which does not decrease with i, so stepping each end + // until its row is inside the bbox makes the y clip exact. Then each line adds at most count + // segments, the CPU crossing bound of the line, and every row index below is inside the bbox. + // The loops usually run zero or one time. + imin = min(imin, count); + imax = clamp(imax, imin, count); + while imin < imax && i32(y0 + f32(imin) - floor(a * f32(imin) + b)) < bbox.y { + imin += 1u; + } + while imax > imin && i32(y0 + f32(imax - 1u) - floor(a * f32(imax - 1u) + b)) >= bbox.w { + imax -= 1u; + } + let delta = select(1, -1, is_down); - imax = max(imin, imax); var last_z = floor(a * (f32(imin) - 1.0) + b); let seg_base = atomicAdd(&bump.seg_counts, imax - imin); for (var i = imin; i < imax; i += 1u) { diff --git a/src/ImageSharp.Drawing.WebGPU/Shaders/WgslSource/path_lowering.wgsl b/src/ImageSharp.Drawing.WebGPU/Shaders/WgslSource/path_lowering.wgsl index ee96d0593..97206ce82 100644 --- a/src/ImageSharp.Drawing.WebGPU/Shaders/WgslSource/path_lowering.wgsl +++ b/src/ImageSharp.Drawing.WebGPU/Shaders/WgslSource/path_lowering.wgsl @@ -95,8 +95,9 @@ fn stroke_arc_subdivision_count(radius: f32, sweep: f32, arc_detail_scale: f32) if theta <= 0.0 { return 0u; } - // Bounded for GPU safety; far above any real tessellation demand. - return min(u32(max(sweep / theta, 0.0)), 1024u); + // The f32 radius and acos can give more vertices than the CPU count. The CPU line estimate + // allows the arc limit, so the count is clamped to it. + return min(u32(max(sweep / theta, 0.0)), arc_limit); } // Port of the CPU stroker's AppendDirectedArcContour (round caps): steps through the positive @@ -581,6 +582,9 @@ var profile_tag: u32 = PROFILE_TAG_SENTINEL; // Device-space bounds of the lines written by this invocation. var bbox: vec4f; +// The interior vertex limit of one round arc of the current stroke, from the style flags. +var arc_limit: u32; + // One invocation processes one path tag. A segment tag emits one fill line or one part of a stroke // outline. A Path tag writes metadata for the path. At the end, each segment invocation merges its // line bounds into the path's shared bounds. @@ -645,6 +649,7 @@ fn main( let half_width = 0.5 * linewidth; let miter_limit = bitcast(scene[style_base + style_ix + 3u]); let arc_detail_scale = bitcast(scene[style_base + style_ix + 4u]); + arc_limit = style_flags & STYLE_FLAGS_ARC_LIMIT_MASK; let is_open = (tag.tag_byte & PATH_TAG_SEG_TYPE) != PATH_TAG_LINETO; let is_stroke_cap_marker = (tag.tag_byte & PATH_TAG_SUBPATH_END) != 0u; diff --git a/src/ImageSharp.Drawing.WebGPU/WebGPUDrawingBackend.cs b/src/ImageSharp.Drawing.WebGPU/WebGPUDrawingBackend.cs index da206cf45..df0a4c6aa 100644 --- a/src/ImageSharp.Drawing.WebGPU/WebGPUDrawingBackend.cs +++ b/src/ImageSharp.Drawing.WebGPU/WebGPUDrawingBackend.cs @@ -418,8 +418,9 @@ public void RenderScene( resourceArena ??= webGPUScene.RentResourceArena() ?? this.RentResourceArena(); schedulingArena ??= webGPUScene.RentSchedulingArena() ?? this.RentSchedulingArena(); - // The same rule as the ordered path: offscreen pixels must be complete before - // control returns to user code, so only a presentation target defers the check. + // Offscreen pixels must be complete before control returns to user code. A render + // whose capacities are upper bounds cannot overflow and reads no status. Otherwise + // only a presentation target defers the check. this.RenderEncodedSceneWithGrowth( configuration, webGPUTarget, @@ -449,10 +450,11 @@ public void RenderScene( /// scratch-growth retry loop. /// /// - /// An offscreen render reads the scratch status before the copy to the target, so an overflow - /// retries here and never reaches the target. A presentation render defers the status: an - /// overflowed frame keeps the previous surface content, and the next frame redraws with the - /// grown capacities. + /// A render whose capacities are upper bounds of its scratch use cannot overflow. It submits + /// and returns without a status readback. For other renders, an offscreen render reads the + /// scratch status before the copy to the target, so an overflow retries here and never reaches + /// the target. A presentation render defers the status: an overflowed frame keeps the previous + /// surface content, and the next frame redraws with the grown capacities. /// /// The pixel format of the flush target. /// The active processing configuration. @@ -461,7 +463,10 @@ public void RenderScene( /// The target texture format and alpha representation. /// The device feature required by the target format, or the default value. /// The retained scene being rendered. - /// Whether the overflow readback is deferred. Only presentation targets pass . + /// + /// Whether the overflow readback of a render without bounded capacities is deferred. Only + /// presentation targets pass . + /// /// The scratch capacities carried across attempts. /// The rented scene resource arena. /// The rented scheduling arena. diff --git a/src/ImageSharp.Drawing.WebGPU/WebGPUSceneDispatch.cs b/src/ImageSharp.Drawing.WebGPU/WebGPUSceneDispatch.cs index 5ee08f73f..667245372 100644 --- a/src/ImageSharp.Drawing.WebGPU/WebGPUSceneDispatch.cs +++ b/src/ImageSharp.Drawing.WebGPU/WebGPUSceneDispatch.cs @@ -20,9 +20,11 @@ namespace SixLabors.ImageSharp.Drawing.Processing.Backends; /// The stage order is fixed by data dependencies: prepare (bump reset), pathtag reduce/scan, /// bbox_clear, path lowering, draw reduce/leaf, clip reduce/leaf, binning, then the tile stages /// (path_row_alloc, path_row_span, tile_alloc, path_count, backdrop, coarse, path_tiling) and -/// finally the fine shading pass. Scratch buffers use GPU bump allocators sized from cached -/// estimates; after each attempt the allocator counters are read back and, on overflow, the caller -/// retries with grown capacities (see 's bounded retry loop). +/// finally the fine shading pass. Scratch buffers use GPU bump allocators sized from the encoder +/// bounds. When every capacity is an upper bound (see ), the render +/// cannot overflow and submits without a status readback. Otherwise the allocator counters are read +/// back and, on overflow, the caller retries with grown capacities (see +/// 's bounded retry loop). /// /// /// Scenes whose tile-dependent buffers exceed the device storage-buffer binding limit are rendered @@ -67,11 +69,21 @@ internal static class WebGPUSceneDispatch // validation rejects deeper nesting before anything is dispatched. private const int MaxClipStackDepth = 256; - // Coarse allocates the dynamic PTCL tail in PTCL_INCREMENT-word chunks (Shared/ptcl.wgsl); a - // command that does not fit the remaining chunk starts a new one, so each tile can leave one - // partial chunk and each chunk can waste up to one command of headroom. + // Coarse allocates the dynamic PTCL tail in PTCL_INCREMENT-word chunks (Shared/ptcl.wgsl). A + // command that does not fit the rest of the initial slot or chunk starts a new chunk. private const long PtclChunkWords = 256; + // The command words a tile writes before its first chunk. The initial slot is PTCL_INITIAL_ALLOC + // words: one blend-spill word, then commands up to PTCL_HEADROOM words before its end. A tile + // starts a chunk when the used words plus the next command reach 64 - 2 - 1 = 61. + private const long PtclFirstChunkCommandWords = 61; + + // The command words each chunk holds at least before the next one starts. A chunk holds + // PTCL_INCREMENT - PTCL_HEADROOM = 254 words, and the next chunk starts when the used words + // plus the next command reach 254. The largest command is 8 words (CMD_PROFILE_TABLE and + // CMD_FILL in coarse.wgsl write_path), so a chunk holds at least 254 - 8 = 246 words. + private const long PtclChunkCommandWords = 246; + /// /// Identifies the staged-scene storage binding that exceeded the device limit for one flush attempt. /// @@ -1944,7 +1956,8 @@ private static unsafe WebGPUSceneSchedulingArena CreateSchedulingArena( /// The reusable scheduling arena slot for this render. /// The advisory chunk height to try first when this scene requires chunking. /// - /// Whether to defer the scratch-overflow readback instead of blocking on the GPU. + /// Whether to defer the scratch-overflow readback instead of blocking on the GPU. A render whose + /// capacities are upper bounds reads no status. /// /// Receives whether the caller should retry with larger scratch capacities. /// Receives the scratch capacities reported by the render attempt. @@ -2008,7 +2021,8 @@ public static unsafe bool TryRenderStagedScene( /// The reusable scheduling arena slot for this render. /// The advisory chunk height to try first when this scene requires chunking. /// - /// Whether to defer the scratch-overflow readback instead of blocking on the GPU. + /// Whether to defer the scratch-overflow readback instead of blocking on the GPU. A render whose + /// capacities are upper bounds reads no status. /// /// Receives whether the caller should retry with larger scratch capacities. /// Receives the scratch capacities reported by the render attempt. @@ -2108,6 +2122,20 @@ public static unsafe bool TryRenderStagedScene( return false; } + if (HasScratchBounds(stagedScene)) + { + // The capacities are upper bounds of the GPU scratch use, so the GPU cannot overflow. + // The fine output and the target copy go in one submission, and no status is read. + CopyToTarget(ref stagedScene, target, outputTexture, targetWidth, targetHeight); + if (!WebGPUDrawingBackend.TrySubmitPendingCommands(flushContext)) + { + error = "Failed to submit the staged-scene frame."; + return false; + } + + return true; + } + if (deferOverflowCheck) { // Deferred (presentation) path: the frame is submitted whole - scheduling, fine, @@ -2250,7 +2278,10 @@ private static unsafe void CopyToTarget( /// The render-time target receiving the chunked output. /// The flush-local reusable scheduling scratch and readback arena. /// The advisory chunk height to try before estimating from the full-scene overflow. - /// Whether to defer the scratch-overflow readback instead of blocking on the GPU. + /// + /// Whether to defer the scratch-overflow readback instead of blocking on the GPU. A render whose + /// capacities are upper bounds reads no status. + /// /// Receives whether the chunked path needs the caller to retry with larger global scratch capacities. /// Receives the enlarged scratch capacities when is . /// Receives the largest chunk height that rendered successfully. @@ -2282,6 +2313,10 @@ private static unsafe bool TryRenderSegmentChunkedStagedScene( int targetHeight = target.Bounds.Height; nuint maxStorageBufferBindingSize = flushContext.ScratchBufferBindingSizeLimit; + // A render with bounded capacities cannot overflow, so it reads no status, now or later. + bool hasScratchBounds = HasScratchBounds(stagedScene); + bool deferStatus = deferOverflowCheck && !hasScratchBounds; + if (!WebGPUDrawingBackend.TryCreateCompositionTexture( flushContext, targetWidth, @@ -2320,7 +2355,7 @@ private static unsafe bool TryRenderSegmentChunkedStagedScene( nuint readbackByteLength = checked(Math.Max(totalTileHeight, 1U) * (uint)sizeof(GpuSceneBumpAllocators)); WGPUBufferImpl* deferredReadbackBuffer = null; bool deferredReadbackBufferTransferred = false; - if (deferOverflowCheck) + if (deferStatus) { deferredReadbackBuffer = flushContext.DeviceState.RentStatusReadbackBuffer(readbackByteLength); if (deferredReadbackBuffer is null) @@ -2405,7 +2440,7 @@ private static unsafe bool TryRenderSegmentChunkedStagedScene( readbackByteLength, ref schedulingArena); - WGPUBufferImpl* statusReadbackBuffer = deferOverflowCheck ? deferredReadbackBuffer : currentArena.ReadbackBuffer; + WGPUBufferImpl* statusReadbackBuffer = deferStatus ? deferredReadbackBuffer : currentArena.ReadbackBuffer; // A first chunk that already covers the full tile height renders through the // monolithic scheduling path below; genuine multi-chunk renders run the shared @@ -2475,7 +2510,7 @@ private static unsafe bool TryRenderSegmentChunkedStagedScene( return false; } - if (!deferOverflowCheck) + if (!hasScratchBounds && !deferStatus) { // All chunks submitted. Map the readback buffer once and check every chunk's // bump allocators. If any overflowed, grow sizes and the outer retry loop re-runs. @@ -2514,7 +2549,7 @@ private static unsafe bool TryRenderSegmentChunkedStagedScene( return false; } - if (deferOverflowCheck) + if (deferStatus) { pendingStatus = new WebGPUPendingSchedulingStatus( flushContext.Api, @@ -3015,17 +3050,21 @@ private static WebGPUSceneWindowBounds GetLargestWindowBounds( // A chunk of one bin or more starts on a bin and holds whole bins, except the last chunk, // which ends at the target. A chunk smaller than one bin happens only when one bin alone - // does not fit the binding limit, and it stays inside its bin. The row bounds keep bins, - // so such a chunk gets the bin bound shared by its rows, the same sizing as - // ScaleChunkBumpSizes. That share is not a bound, so the overflow protocol stays the safety - // net for that case only. + // does not fit the binding limit. It can start on any row, and CreateChunkWindow ends it + // at the end of its bin. The row bounds keep each tile row, so every such window gets a + // true bound. uint binRows = WebGPUSceneRowBounds.BinRows; - uint windowRows = Math.Max(chunkTileHeight, binRows); - int windowPixels = checked((int)windowRows * WebGPUSceneEncoder.TileHeight); - for (uint start = 0; start < Math.Max(targetTileHeight, 1U); start += binRows) + uint step = chunkTileHeight >= binRows ? binRows : 1U; + for (uint start = 0; start < Math.Max(targetTileHeight, 1U); start += step) { + uint end = start + chunkTileHeight; + if (chunkTileHeight < binRows) + { + end = Math.Min(end, (start | (binRows - 1U)) + 1U); + } + int y0 = checked(originY + ((int)start * WebGPUSceneEncoder.TileHeight)); - int y1 = checked(y0 + windowPixels); + int y1 = checked(originY + ((int)end * WebGPUSceneEncoder.TileHeight)); WebGPUSceneWindowBounds window = rowBounds.GetWindow(y0, y1); pathRows = Math.Max(pathRows, window.PathRows); tiles = Math.Max(tiles, window.Tiles); @@ -3036,34 +3075,15 @@ private static WebGPUSceneWindowBounds GetLargestWindowBounds( blendSpillSlots = Math.Max(blendSpillSlots, scene.GetBlendSpillSlots(y0, y1)); } - if (start + windowRows >= targetTileHeight) + if (end >= targetTileHeight) { break; } } - if (chunkTileHeight < binRows) - { - pathRows = ShareBinBound(pathRows, chunkTileHeight, binRows); - tiles = ShareBinBound(tiles, chunkTileHeight, binRows); - crossings = ShareBinBound(crossings, chunkTileHeight, binRows); - ptclWords = ShareBinBound(ptclWords, chunkTileHeight, binRows); - blendSpillSlots = ShareBinBound(blendSpillSlots, chunkTileHeight, binRows); - } - return new WebGPUSceneWindowBounds(pathRows, tiles, crossings, ptclWords); } - /// - /// Shares a bin bound by the rows of a chunk that is smaller than one bin. - /// - /// The bound of one bin. - /// The chunk height in tile rows. - /// The tile rows of one bin. - /// The share, rounded up. - private static long ShareBinBound(long binBound, uint chunkTileHeight, uint binRows) - => ((binBound * chunkTileHeight) + binRows - 1) / binRows; - /// /// Raises chunk-local scratch capacities to the bounds of one chunk window. /// @@ -3082,12 +3102,11 @@ private static WebGPUSceneBumpSizes RaiseToWindowBounds( bool hasSegmentTable, long blendSpillSlots) { - // The PTCL bound matches SeedSceneBumpSizes: the draw words with the same 5% margin, two - // words for each crossing when a CMD_PROFILE_TABLE can precede a fill, and one partial - // chunk for each tile of the window. - long ptclWords = ((window.PtclWords * 105L) / 100L) - + (hasSegmentTable ? 2L * window.Crossings : 0L) - + ((long)widthInTiles * chunkTileHeight * PtclChunkWords); + // The command words are the draw words and two words for each crossing when a + // CMD_PROFILE_TABLE can precede a fill. + long ptclWords = GetPtclChunkBound( + window.PtclWords + (hasSegmentTable ? 2L * window.Crossings : 0L), + widthInTiles * chunkTileHeight); return new WebGPUSceneBumpSizes( scaled.Lines, @@ -3166,7 +3185,7 @@ private static WebGPUSceneBumpSizes SeedSceneBumpSizes(WebGPUSceneBumpSizes curr /// The CPU-side upper bound for per-(draw, bin) records. /// The CPU-side upper bound for path-tile records. /// The CPU-side upper bound for blend-spill slots. - /// The number of tiles in the target, each of which can leave one partial PTCL chunk. + /// The number of tiles in the target. /// The device-reported maximum size of one storage-buffer binding. /// The retained capacities raised to the CPU-side upper bounds. private static WebGPUSceneBumpSizes SeedSceneBumpSizes( @@ -3193,8 +3212,7 @@ private static WebGPUSceneBumpSizes SeedSceneBumpSizes( uint pathTileFloor = Math.Max(ToBoundCount(estimatedPathTiles), pathRowFloor); uint blendSpillFloor = ToBoundCount(estimatedBlendSpillSlots); uint binningFloor = ClampEstimate(estimatedBinFootprint, maxStorageBufferBindingSize, sizeof(uint)); - long ptclWords = ((estimatedPtclWords * 105L) / 100L) + (targetTileCount * PtclChunkWords); - uint ptclFloor = ToBoundCount(ptclWords); + uint ptclFloor = ToBoundCount(GetPtclChunkBound(estimatedPtclWords, targetTileCount)); return new WebGPUSceneBumpSizes( Math.Max(currentSizes.Lines, lineFloor), @@ -3207,6 +3225,26 @@ private static WebGPUSceneBumpSizes SeedSceneBumpSizes( Math.Max(currentSizes.Ptcl, ptclFloor)); } + /// + /// Bounds the dynamic PTCL words that coarse allocates for a set of tiles. + /// + /// + /// A tile with j chunks writes at least 61 + 246 * (j - 1) command words, so it has at most + /// 1 + (W_t - 61) / 246 chunks. Over the tiles that start a chunk, the chunk count is at most + /// N + (W - 61 * N) / 246, with W the command words of the tiles and N the number of these + /// tiles. This grows with N, and N is at most the tile count and at most W / 61. Each chunk is + /// 256 words. + /// + /// The bound on the command words of all the tiles. + /// The number of tiles. + /// The bound in words. + private static long GetPtclChunkBound(long commandWords, long tileCount) + { + long chunkTiles = Math.Min(tileCount, commandWords / PtclFirstChunkCommandWords); + long chunks = chunkTiles + ((commandWords - (PtclFirstChunkCommandWords * chunkTiles)) / PtclChunkCommandWords); + return chunks * PtclChunkWords; + } + /// /// Converts one bound into an element count. A bound above the 32-bit range keeps the /// largest count, which fails binding validation in the same way. @@ -3238,6 +3276,45 @@ private static uint ClampEstimate(long estimate, ulong maxStorageBufferBindingSi return (uint)Math.Min((ulong)estimate, Math.Min(maxElements, uint.MaxValue)); } + /// + /// Determines whether the scratch capacities of a staged scene are upper bounds of its GPU + /// scratch use. Such a render cannot overflow, so it needs no status readback. + /// + /// + /// + /// raises every + /// capacity to the bound that the encoder computed for the whole scene, and + /// + /// raises every chunk capacity to the bound of its rows. These cases have no bound: + /// + /// + /// A range of an ordered scene. Its origin can move the tile grid of its + /// data, and the ordered path reads its status in its transaction. + /// A binning bound above the binding limit. Binning does not shrink with a + /// chunk, so its capacity is capped at the limit. + /// A chunked scene without row bounds. Its chunks get a share of the scene + /// capacities. + /// + /// + /// The staged scene. + /// when the capacities are upper bounds; otherwise, . + private static bool HasScratchBounds(in WebGPUStagedScene stagedScene) + { + if (stagedScene.Range.HasValue) + { + return false; + } + + WebGPUEncodedScene scene = stagedScene.EncodedScene; + ulong binningLimit = Math.Min(stagedScene.FlushContext.ScratchBufferBindingSizeLimit / sizeof(uint), uint.MaxValue); + if ((ulong)Math.Max(scene.EstimatedBinFootprint, 0L) > binningLimit) + { + return false; + } + + return !IsChunkableBindingFailure(stagedScene.BindingLimitFailure.Buffer) || scene.RowBounds is not null; + } + /// /// Determines whether a binding-limit failure can be resolved by tile-row chunking. /// diff --git a/src/ImageSharp.Drawing.WebGPU/WebGPUSceneEncoder.cs b/src/ImageSharp.Drawing.WebGPU/WebGPUSceneEncoder.cs index 82044e440..8603e5d7d 100644 --- a/src/ImageSharp.Drawing.WebGPU/WebGPUSceneEncoder.cs +++ b/src/ImageSharp.Drawing.WebGPU/WebGPUSceneEncoder.cs @@ -128,6 +128,13 @@ private static string SegmentedDrawObjectLimitError /// private const int RowWalkMinimumTiles = 1024; + /// + /// The largest interior vertex count of one round arc in the stroke shaders. The count is + /// stored in the low bits of a stroke style flags word, so it must fit in + /// STYLE_FLAGS_ARC_LIMIT_MASK in Shared/pathtag.wgsl. + /// + private const int MaxStrokeArcSubdivisions = 1024; + /// /// The words in one scene segment table entry. Must match SCENE_SEGMENT_WORDS in scene_segment.wgsl. /// @@ -4128,7 +4135,21 @@ private bool AppendClipDescriptor(in DrawingClipDescriptor descriptor, Point des this.lastStyle8 = style8; this.lastStyle9 = style9; this.blendDepth++; - this.AccumulateDrawRowEstimateLocal(clipBounds, clipLineCount, -1, GetPtclPaintWords(GpuSceneDrawTag.EndClip), true, RowSpanSource.None); + + // The clip interest is the whole range, so the GPU box of the clip is the box of its + // path, and it can be larger than clipBounds: + // - binning adds one pixel to the right edge of an aliased (Hard) path; + // - the GPU box is floor and ceil of the f32 points after the GPU transform, and + // clipBounds is floor and ceil of the transformed path bounds. + // One pixel on each side holds both. + this.AccumulateDrawRowEstimateLocal( + Rectangle.Inflate(clipBounds, 1, 1), + clipLineCount, + -1, + GetPtclPaintWords(GpuSceneDrawTag.EndClip), + true, + RowSpanSource.None); + this.PathCount += encodedPathCount; this.LineCount += clipLineCount; this.InfoWordCount += (int)GpuSceneDrawTag.Map(GpuSceneDrawTag.BeginClip).InfoOffset; @@ -7455,7 +7476,9 @@ private static int EncodeStrokePath( out long endCrossings) { long bodyRowCrossings = 0; + long innerJoinCrossings = 0; int bodySegmentCount = 0; + float outlineRadius = GetStrokeOutlineRadius(pen, widthScale); float pointTranslateX = destinationOffset.X - rootTargetBounds.X; float pointTranslateY = destinationOffset.Y - rootTargetBounds.Y; lineCount = EstimateStrokeLineCount(geometry, pen, widthScale); @@ -7514,7 +7537,16 @@ private static int EncodeStrokePath( } else { - contourOutlineLength += GetStrokeJoinChainLength(pen, halfWidth, previousDirection, previousLength, direction, segmentLength); + contourOutlineLength += GetStrokeJoinChainLength( + pen, + halfWidth, + previousDirection, + previousLength, + direction, + segmentLength, + out float innerExtent); + + innerJoinCrossings += GetInnerJoinTileCrossings(innerExtent, halfWidth, outlineRadius); } previousDirection = direction; @@ -7575,13 +7607,40 @@ private static int EncodeStrokePath( Vector2 closingVector = (Vector2)firstPoint - (Vector2)lastKept; float closingLength = closingVector.Length(); Vector2 closingDirection = closingVector / closingLength; - contourOutlineLength += (2D * closingLength) + - GetStrokeJoinChainLength(pen, halfWidth, previousDirection, previousLength, closingDirection, closingLength) + - GetStrokeJoinChainLength(pen, halfWidth, closingDirection, closingLength, firstDirection, firstLength); + float closingJoinLength = GetStrokeJoinChainLength( + pen, + halfWidth, + previousDirection, + previousLength, + closingDirection, + closingLength, + out float closingInnerExtent); + + float firstJoinLength = GetStrokeJoinChainLength( + pen, + halfWidth, + closingDirection, + closingLength, + firstDirection, + firstLength, + out float firstInnerExtent); + + contourOutlineLength += (2D * closingLength) + closingJoinLength + firstJoinLength; + innerJoinCrossings += GetInnerJoinTileCrossings(closingInnerExtent, halfWidth, outlineRadius) + + GetInnerJoinTileCrossings(firstInnerExtent, halfWidth, outlineRadius); } else { - contourOutlineLength += GetStrokeJoinChainLength(pen, halfWidth, previousDirection, previousLength, firstDirection, firstLength); + contourOutlineLength += GetStrokeJoinChainLength( + pen, + halfWidth, + previousDirection, + previousLength, + firstDirection, + firstLength, + out float innerExtent); + + innerJoinCrossings += GetInnerJoinTileCrossings(innerExtent, halfWidth, outlineRadius); } outlineLength += contourOutlineLength; @@ -7664,7 +7723,7 @@ private static int EncodeStrokePath( tileCrossings = BoundStrokeTileCrossings(outlineLength, residualStretch, lineCount); rowCrossings = bodyRowCrossings; - endCrossings = GetStrokeEndTileCrossings(lineCount, bodySegmentCount, pen, widthScale); + endCrossings = GetStrokeEndTileCrossings(lineCount, bodySegmentCount, pen, widthScale) + innerJoinCrossings; if (encodedContourCount == 0) { return 0; @@ -7866,6 +7925,29 @@ private static long GetStrokeEndTileCrossings(int lineCount, int segmentCount, P return otherLines * otherLineCrossings; } + /// + /// Bounds the tile crossings of the inner chain of one join when it does not fit in the square + /// of twice the outline radius that assumes. + /// + /// + /// An inner corner draws two lines: from the offset point of one segment to the inner miter + /// apex, and from the apex to the offset point of the next segment. The offset points are one + /// half width from the vertex, so each line is at most innerExtent + halfWidth long. A line + /// with length L crosses at most sqrt(2) * L / 16 + 3 tiles. This is a bound on the total of the + /// lines, so it holds in every tile-row window. + /// + /// The largest distance of the inner chain from the join vertex. + /// Half the transform-scaled stroke width. + /// The outline radius from . + /// The added tile-crossing bound, or zero when the lines fit the square. + private static long GetInnerJoinTileCrossings(float innerExtent, float halfWidth, float outlineRadius) + { + float lineLength = innerExtent + halfWidth; + return lineLength > 2F * outlineRadius + ? (long)Math.Ceiling(Math.Sqrt(2D) * 2D * lineLength / TileWidth) + 6L + : 0L; + } + /// /// Replaces a point-like explicit stroke segment with the tiny tangent segment used for point strokes. /// @@ -8438,7 +8520,8 @@ private static float GetStrokeCapChainLength(Pen pen, float halfWidth) /// /// Bounds the length of the line chains the GPU stroker emits for one join. The outer chain reaches at most /// the miter apex, the miter limit, the round arc, or the bevel chord. The inner chain reaches at most the - /// miter apex or the shorter neighboring segment. + /// miter apex or the inner miter limit of the shader, which is the shorter neighboring segment and at least + /// 1.01 half widths. /// /// The pen that defines the join style and miter limit. /// Half the transform-scaled stroke width. @@ -8446,13 +8529,23 @@ private static float GetStrokeCapChainLength(Pen pen, float halfWidth) /// The length of the segment entering the join. /// The unit direction of the segment leaving the join. /// The length of the segment leaving the join. + /// Receives the largest distance of the inner chain from the join vertex. /// The chain length bound in pixels. - private static float GetStrokeJoinChainLength(Pen pen, float halfWidth, Vector2 incoming, float incomingLength, Vector2 outgoing, float outgoingLength) + private static float GetStrokeJoinChainLength( + Pen pen, + float halfWidth, + Vector2 incoming, + float incomingLength, + Vector2 outgoing, + float outgoingLength, + out float innerExtent) { float cosTheta = -Vector2.Dot(incoming, outgoing); float sinHalfTheta = MathF.Sqrt(MathF.Max(0F, (1F - cosTheta) * 0.5F)); float miterApex = sinHalfTheta > 1E-6F ? halfWidth / sinHalfTheta : float.PositiveInfinity; - float innerExtent = MathF.Min(miterApex, MathF.Min(incomingLength, outgoingLength)); + + // stroke_side_join limits the inner miter to max(min(len1, len2), 1.01 * half_width). + innerExtent = MathF.Min(miterApex, MathF.Max(MathF.Min(incomingLength, outgoingLength), 1.01F * halfWidth)); float miterLimit = (float)Math.Max(pen.StrokeOptions.MiterLimit, 1D); float outerExtent = pen.StrokeOptions.LineJoin switch { @@ -8498,11 +8591,12 @@ private static int GetStrokeCapLineCost(Pen pen, float widthScale) { float halfWidth = pen.StrokeWidth * widthScale * 0.5F; - // A round cap writes the arc points plus one. One more point absorbs f32 differences in acos. + // A round cap writes the arc points plus one. The shader clamps the arc points to the arc + // limit in the style flags, which is this arc cost. return pen.StrokeOptions.LineCap switch { LineCap.Square => 3, - LineCap.Round => GetStrokeArcLineCost(halfWidth, MathF.PI, pen.StrokeOptions.ArcDetailScale) + 2, + LineCap.Round => GetStrokeArcLineCost(halfWidth, MathF.PI, pen.StrokeOptions.ArcDetailScale) + 1, _ => 1 }; } @@ -8679,6 +8773,13 @@ private static void AppendIdentityTransform(ref OwnedStream transforms) /// 2 = packed draw flags, 3 = miter limit, 4 = arc detail scale, 5 = zero, /// 6-9 = target-local interest rectangle as left, top, right, bottom. /// Strokes do not use antialiased text coverage boost data, so word 5 is zero in both modes. + /// + /// Bits 0-10 of word 0 hold the arc limit: the interior vertex count of a round arc of + /// half a turn, from . The shader clamps the vertex count of + /// every arc to it. The f32 arc math on the GPU can give a larger count than the CPU, for + /// example for a cap radius rounded at a large coordinate. The clamp makes the line estimates + /// of round joins and caps upper bounds. + /// /// /// The graphics options used for blending. /// The pen that defines stroke width, joins, caps, and miter limit. @@ -8706,8 +8807,16 @@ private static ( | EncodeStrokeJoinFlags(pen.StrokeOptions.LineJoin) | EncodeStrokeCapFlags(pen.StrokeOptions.LineCap); + // Only round joins and caps draw arcs, so other pens skip the acos. + uint arcLimit = 0U; + if (pen.StrokeOptions.LineJoin is LineJoin.Round or LineJoin.MiterRound || pen.StrokeOptions.LineCap == LineCap.Round) + { + int arcCount = GetStrokeArcLineCost(pen.StrokeWidth * widthScale * 0.5F, MathF.PI, pen.StrokeOptions.ArcDetailScale); + arcLimit = (uint)Math.Min(arcCount, MaxStrokeArcSubdivisions); + } + return ( - (uint)styleFlags, + (uint)styleFlags | arcLimit, BitcastSingle(pen.StrokeWidth * widthScale), PackStyleDrawFlags(options), BitcastSingle((float)pen.StrokeOptions.MiterLimit), diff --git a/src/ImageSharp.Drawing.WebGPU/WebGPUSceneRowBounds.cs b/src/ImageSharp.Drawing.WebGPU/WebGPUSceneRowBounds.cs index d5ce00618..1fbeb5836 100644 --- a/src/ImageSharp.Drawing.WebGPU/WebGPUSceneRowBounds.cs +++ b/src/ImageSharp.Drawing.WebGPU/WebGPUSceneRowBounds.cs @@ -6,8 +6,8 @@ namespace SixLabors.ImageSharp.Drawing.Processing.Backends; /// -/// Holds the scratch bounds of an encoded scene or range for each bin of 16 tile rows. A tile-row -/// chunk window then gets a bound from the draws that reach it. +/// Holds the scratch bounds of an encoded scene or range for each tile row. A tile-row chunk +/// window then gets a bound from the draws that reach it. /// /// /// @@ -18,22 +18,20 @@ namespace SixLabors.ImageSharp.Drawing.Processing.Backends; /// draw adds one entry for each row, and a window query is O(1). /// /// -/// The entries are kept for each bin of 16 tile rows, which is also the alignment of the chunk -/// windows. A window widens to whole bins, so a chunk smaller than one bin gets the bound of its -/// bin. Bins keep the arrays 16 times smaller than rows, so a scene of any height pays almost -/// nothing to fill, merge and seal them. +/// The entries are kept for each tile row, so a window of any rows, also a chunk smaller than one +/// bin, gets a bound. The arrays have one entry for each tile row, so a scene of any height pays +/// almost nothing to fill, merge and seal them. /// /// /// Two kinds of sums are kept: /// /// -/// Area sums. A draw adds a value for each of its rows. Each bin receives the -/// value times the rows of the draw in that bin, through a difference array. The window sum is the -/// sum of its bins. -/// Overlap sums. A draw adds its full bound at the bin of its first row and at -/// the bin of its last row. The window sum is the full bound of every draw that reaches the window -/// bins. These are the draws whose first bin is before the window end, minus the draws whose last -/// bin is before the window start. +/// Area sums. A draw adds a value for each of its rows through a difference +/// array. The window sum is the sum of its rows. +/// Overlap sums. A draw adds its full bound at its first row and at its last +/// row. The window sum is the full bound of every draw that reaches the window rows. These are the +/// draws whose first row is before the window end, minus the draws whose last row is before the +/// window start. /// /// /// The arrays are rented as one block and returned by . @@ -46,7 +44,7 @@ internal sealed class WebGPUSceneRowBounds : IDisposable /// public const int BinRows = 16; - // Array indices in the rented block. Each array has binCount + 1 entries. + // Array indices in the rented block. Each array has RowCount + 1 entries. private const int PathRowsArea = 0; private const int TilesArea = 1; private const int PtclArea = 2; @@ -62,7 +60,6 @@ internal sealed class WebGPUSceneRowBounds : IDisposable private const int ArrayCount = 12; private long[]? data; - private readonly int binCount; private readonly int stride; /// @@ -74,8 +71,7 @@ public WebGPUSceneRowBounds(int rowCount, int gridOriginY) { this.RowCount = rowCount; this.GridOriginY = gridOriginY; - this.binCount = (rowCount + BinRows - 1) / BinRows; - this.stride = this.binCount + 1; + this.stride = rowCount + 1; this.data = ArrayPool.Shared.Rent(ArrayCount * this.stride); this.data.AsSpan(0, ArrayCount * this.stride).Clear(); } @@ -141,16 +137,15 @@ public void AddDraw( this.AddRows(data, PtclArea, firstRow, endRow, (long)widthTiles * ptclWordsPerTile); this.AddRows(data, CrossingsArea, firstRow, endRow, crossingsPerRow); - int firstBin = firstRow / BinRows; - int lastBin = (endRow - 1) / BinRows; - data[(TilesFirst * this.stride) + firstBin] += tiles; - data[(TilesLast * this.stride) + lastBin] += tiles; - data[(CrossingsFirst * this.stride) + firstBin] += crossings; - data[(CrossingsLast * this.stride) + lastBin] += crossings; - data[(PtclFirst * this.stride) + firstBin] += ptclWords; - data[(PtclLast * this.stride) + lastBin] += ptclWords; - data[(EndCrossingsFirst * this.stride) + firstBin] += endCrossings; - data[(EndCrossingsLast * this.stride) + lastBin] += endCrossings; + int lastRow = endRow - 1; + data[(TilesFirst * this.stride) + firstRow] += tiles; + data[(TilesLast * this.stride) + lastRow] += tiles; + data[(CrossingsFirst * this.stride) + firstRow] += crossings; + data[(CrossingsLast * this.stride) + lastRow] += crossings; + data[(PtclFirst * this.stride) + firstRow] += ptclWords; + data[(PtclLast * this.stride) + lastRow] += ptclWords; + data[(EndCrossingsFirst * this.stride) + firstRow] += endCrossings; + data[(EndCrossingsLast * this.stride) + lastRow] += endCrossings; } /// @@ -163,11 +158,10 @@ public void AddDraw( public void AddRowTiles(int row, long tiles, long ptclWords) { Span data = this.data; - int bin = row / BinRows; - data[(TilesArea * this.stride) + bin] += tiles; - data[(TilesArea * this.stride) + bin + 1] -= tiles; - data[(PtclArea * this.stride) + bin] += ptclWords; - data[(PtclArea * this.stride) + bin + 1] -= ptclWords; + data[(TilesArea * this.stride) + row] += tiles; + data[(TilesArea * this.stride) + row + 1] -= tiles; + data[(PtclArea * this.stride) + row] += ptclWords; + data[(PtclArea * this.stride) + row + 1] -= ptclWords; } /// @@ -209,39 +203,39 @@ public void Add(WebGPUSceneRowBounds other) public void Seal() { Span data = this.data; - int bins = this.binCount; + int rows = this.RowCount; - // An area array holds differences. The running sum is the per-bin value, and the entry at - // bin b becomes the sum of the per-bin values of bins 0 to b - 1. + // An area array holds differences. The running sum is the per-row value, and the entry at + // row r becomes the sum of the per-row values of rows 0 to r - 1. for (int a = PathRowsArea; a <= CrossingsArea; a++) { Span array = data.Slice(a * this.stride, this.stride); - long binValue = 0; + long rowValue = 0; long sum = 0; - for (int b = 0; b < bins; b++) + for (int r = 0; r < rows; r++) { - binValue += array[b]; - array[b] = sum; - sum += binValue; + rowValue += array[r]; + array[r] = sum; + sum += rowValue; } - array[bins] = sum; + array[rows] = sum; } - // A first or last array holds per-bin additions. The entry at bin b becomes the sum of - // the additions at bins 0 to b - 1. + // A first or last array holds per-row additions. The entry at row r becomes the sum of + // the additions at rows 0 to r - 1. for (int a = TilesFirst; a <= EndCrossingsLast; a++) { Span array = data.Slice(a * this.stride, this.stride); long sum = 0; - for (int b = 0; b < bins; b++) + for (int r = 0; r < rows; r++) { - long value = array[b]; - array[b] = sum; + long value = array[r]; + array[r] = sum; sum += value; } - array[bins] = sum; + array[rows] = sum; } } @@ -254,10 +248,10 @@ public void Seal() public WebGPUSceneWindowBounds GetWindow(int dataY0, int dataY1) { // The window can start between grid rows when the target is not on the grid of the data, - // so it widens to whole grid rows, and then to whole bins. - const int binPixels = BinRows * WebGPUSceneEncoder.TileHeight; - int start = Math.Clamp(FloorDivide(dataY0 - this.GridOriginY, binPixels), 0, this.binCount); - int end = Math.Clamp(-FloorDivide(this.GridOriginY - dataY1, binPixels), start, this.binCount); + // so it widens to whole grid rows. + const int rowPixels = WebGPUSceneEncoder.TileHeight; + int start = Math.Clamp(FloorDivide(dataY0 - this.GridOriginY, rowPixels), 0, this.RowCount); + int end = Math.Clamp(-FloorDivide(this.GridOriginY - dataY1, rowPixels), start, this.RowCount); ReadOnlySpan data = this.data; long pathRows = this.Area(data, PathRowsArea, start, end); @@ -293,11 +287,6 @@ public void Dispose() /// /// Adds a value for each tile row of an interval to an area array. /// - /// - /// A bin receives the value times the rows of the interval in that bin. The first and last bins - /// can be partial, so they get a single-bin entry. The full bins between them share one - /// difference entry. - /// /// The unsealed block. /// The array index. /// The first tile row. @@ -311,30 +300,8 @@ private void AddRows(Span data, int array, int firstRow, int endRow, long } int offset = array * this.stride; - int firstBin = firstRow / BinRows; - int lastBin = (endRow - 1) / BinRows; - if (firstBin == lastBin) - { - long value = valuePerRow * (endRow - firstRow); - data[offset + firstBin] += value; - data[offset + firstBin + 1] -= value; - return; - } - - long firstValue = valuePerRow * (((firstBin + 1) * BinRows) - firstRow); - data[offset + firstBin] += firstValue; - data[offset + firstBin + 1] -= firstValue; - - long lastValue = valuePerRow * (endRow - (lastBin * BinRows)); - data[offset + lastBin] += lastValue; - data[offset + lastBin + 1] -= lastValue; - - if (lastBin > firstBin + 1) - { - long fullValue = valuePerRow * BinRows; - data[offset + firstBin + 1] += fullValue; - data[offset + lastBin] -= fullValue; - } + data[offset + firstRow] += valuePerRow; + data[offset + endRow] -= valuePerRow; } /// @@ -342,8 +309,8 @@ private void AddRows(Span data, int array, int firstRow, int endRow, long /// /// The sealed block. /// The array index. - /// The first window bin. - /// The bin after the window. + /// The first window row. + /// The row after the window. /// The window sum. private long Area(ReadOnlySpan data, int array, int start, int end) => data[(array * this.stride) + end] - data[(array * this.stride) + start]; @@ -352,10 +319,10 @@ private long Area(ReadOnlySpan data, int array, int start, int end) /// Gets the full bounds of the draws that reach a window. /// /// The sealed block. - /// The index of the array keyed by the first bin of each draw. - /// The index of the array keyed by the last bin of each draw. - /// The first window bin. - /// The bin after the window. + /// The index of the array keyed by the first row of each draw. + /// The index of the array keyed by the last row of each draw. + /// The first window row. + /// The row after the window. /// The sum. private long Overlap(ReadOnlySpan data, int first, int last, int start, int end) => data[(first * this.stride) + end] - data[(last * this.stride) + start]; diff --git a/src/ImageSharp.Drawing/Processing/Pen.cs b/src/ImageSharp.Drawing/Processing/Pen.cs index a464a799a..35a7f8b09 100644 --- a/src/ImageSharp.Drawing/Processing/Pen.cs +++ b/src/ImageSharp.Drawing/Processing/Pen.cs @@ -111,7 +111,11 @@ public IPath GeneratePath(IPath path) /// Gets the largest distance from the centerline that the stroke outline can reach. /// /// - /// A miter join reaches the miter limit times the half width. A square cap corner reaches + /// A miter apex reaches the miter limit m times the half width. A + /// join past the limit draws a clipped bevel, which reaches at most sqrt(m^2 + 1) half widths. + /// At a reversal the bevel points are one half width out from the centerline and m half widths + /// along it, which is that distance. The other miter joins draw a bevel or an arc past the + /// limit. A square cap corner reaches /// sqrt(2) times the half width. Round and bevel joins and round and butt caps stay inside the /// half width. /// @@ -120,9 +124,11 @@ public IPath GeneratePath(IPath path) internal float GetOutlineRadius(float widthScale) { float halfWidth = this.StrokeWidth * widthScale * 0.5F; + double miterLimit = Math.Max(this.StrokeOptions.MiterLimit, 1D); float joinRadius = this.StrokeOptions.LineJoin switch { - LineJoin.Miter or LineJoin.MiterRevert or LineJoin.MiterRound => (float)(halfWidth * Math.Max(this.StrokeOptions.MiterLimit, 1D)), + LineJoin.Miter => (float)(halfWidth * Math.Sqrt((miterLimit * miterLimit) + 1D)), + LineJoin.MiterRevert or LineJoin.MiterRound => (float)(halfWidth * miterLimit), _ => halfWidth }; diff --git a/tests/ImageSharp.Drawing.Tests/Processing/Backends/WebGPUDrawingBackendTests.Diagnostics.cs b/tests/ImageSharp.Drawing.Tests/Processing/Backends/WebGPUDrawingBackendTests.Diagnostics.cs index b79afc998..2c8c9d1a6 100644 --- a/tests/ImageSharp.Drawing.Tests/Processing/Backends/WebGPUDrawingBackendTests.Diagnostics.cs +++ b/tests/ImageSharp.Drawing.Tests/Processing/Backends/WebGPUDrawingBackendTests.Diagnostics.cs @@ -159,41 +159,27 @@ public void ChunkSchedulingScratchGrowth_IncludesBlendSpillOverflow() } // 100k random stroked lines, the same scene as the 100k run of the DrawingBackendBenchmark sample, - // need more scratch memory than the first render has. At the 128 MiB default binding limit they - // also render in tile-row chunks. An offscreen render must return complete pixels: it grows the - // memory and renders again before it returns. The GPU copy reads the target as the render left - // it, before any readback, and must equal a second render that starts with the grown sizes. + // render in tile-row chunks at the 128 MiB default binding limit. One bin of 16 tile rows does + // not fit the limit, so the chunks are 12 and 4 rows. Each chunk must be sized from the draws in + // its own rows: a share of the bin is too small, and the render does not read its status, so an + // overflow would leave the target unchanged. The chunked image must equal the image rendered + // without the limit. [WebGPUFact] - public void FillManyLines_FirstRenderOverflows_ReturnsCompletePixels() + public void FillManyLines_ChunksSmallerThanOneBin_MatchUnchunkedRender() { const int width = 1600; const int height = 1100; (PointF Start, PointF End, Color Color, float Width)[] lines = CreateRandomLines(width, height, 100_000); Brush background = Brushes.Solid(Color.ParseHex("#003366")); - Rectangle bounds = new(0, 0, width, height); + using Image expected = RenderManyLinesToImage(width, height, background, lines, passes: 1); using WebGPURenderTarget target = new(WebGPUTextureFormat.Bgra8Unorm, width, height); - using WebGPURenderTarget copy = target.CreateRenderTarget(width, height); target.Backend.ScratchBufferBindingSizeLimit = 128U * 1024U * 1024U; - - DrawLines(target, background, lines); - int overflowCount = target.Backend.DiagnosticScratchOverflowCount; - bool chunked = target.Backend.DiagnosticLastFlushUsedChunking; - - using (DrawingCanvas source = target.CreateCanvas()) - using (DrawingCanvas destination = copy.CreateCanvas()) - { - destination.CopyPixelsFrom(source, bounds, Point.Empty); - } - DrawLines(target, background, lines); - using Image copied = copy.ReadbackImage().CloneAs(); - using Image expected = target.ReadbackImage().CloneAs(); + using Image chunked = target.ReadbackImage().CloneAs(); - Assert.True(overflowCount > 0, "The first render must overflow, or this test does not run the retry."); - Assert.True(chunked, "The first render must use tile-row chunks."); - Assert.Equal(overflowCount, target.Backend.DiagnosticScratchOverflowCount); - Assert.Equal(0, CountDifferingPixels(expected, copied)); + Assert.True(target.Backend.DiagnosticLastFlushUsedChunking, "The binding limit must force chunked rendering."); + Assert.Equal(0, CountDifferingPixels(expected, chunked)); } // A correctly seeded scene renders fully on its first flush. An under-seeded scratch estimate (for @@ -220,26 +206,45 @@ public void FillManyLines_SinglePass_MatchesConvergedRender() // Nine nested full-target layers put every tile at blend depth 9, so fine spills five levels of // 256 slots for each of the 1024 tiles. That is more than the initial blend-spill buffer, and - // the scratch bounds must size it before the first render. + // the scratch bounds must size it before the first render. The render does not read its status, + // so an overflow would leave the target unchanged, and the pixels must match the CPU pixels. [WebGPUFact] public void FirstRender_NineNestedLayers_DoesNotOverflowScratch() { + using Image expected = RenderWithCpuCanvas(512, 512, static canvas => DrawNestedLayers(canvas, 9, 512)); + using WebGPURenderTarget target = new(512, 512); using (DrawingCanvas canvas = target.CreateCanvas()) { DrawNestedLayers(canvas, 9, 512); } + using Image actual = target.ReadbackImage().CloneAs(); Assert.Equal(0, target.Backend.DiagnosticScratchOverflowCount); + + // The CPU and GPU layer blends differ by one 8-bit level in some pixels: 0.0081% measured. + AssertBackendPairSimilarity(expected, actual, 0.0082F); } // One hundred full-target layers in sequence write BeginClip, the forced solid and EndClip in - // every tile for each layer, which is more PTCL than the initial buffer. + // every tile for each layer, which is more PTCL than the initial buffer. The pixels must equal + // the CPU pixels, for the same reason as the nested layers. [WebGPUFact] public void FirstRender_HundredSequentialLayers_DoesNotOverflowScratch() { + using Image expected = RenderWithCpuCanvas(2048, 2048, DrawSequentialLayers); + using WebGPURenderTarget target = new(2048, 2048); using (DrawingCanvas canvas = target.CreateCanvas()) + { + DrawSequentialLayers(canvas); + } + + using Image actual = target.ReadbackImage().CloneAs(); + Assert.Equal(0, target.Backend.DiagnosticScratchOverflowCount); + AssertBackendPairSimilarity(expected, actual, 0F); + + static void DrawSequentialLayers(DrawingCanvas canvas) { for (int i = 0; i < 100; i++) { @@ -248,19 +253,37 @@ public void FirstRender_HundredSequentialLayers_DoesNotOverflowScratch() canvas.Restore(); } } - - Assert.Equal(0, target.Backend.DiagnosticScratchOverflowCount); } // A retained scene read in place inside layers nests its own layers inside the outer ones. Five // outer layers and four retained layers reach blend depth 9, which only the shifted depths of - // the retained scene show. + // the retained scene show. The pixels must match the CPU pixels, for the same reason as the + // nested layers. [WebGPUFact] public void FirstRender_RetainedNestedLayersInsideLayers_DoesNotOverflowScratch() { + using Image expected = new(512, 512); + ImageFrame frame = expected.Frames.RootFrame; + using DrawingBackendScene cpuScene = CreateNestedLayerScene(frame.CreateCanvas(Configuration.Default, new DrawingOptions()), 4, 512); + using (DrawingCanvas canvas = frame.CreateCanvas(Configuration.Default, new DrawingOptions())) + { + DrawSceneInsideLayers(canvas, cpuScene); + } + using WebGPURenderTarget target = new(512, 512); - using DrawingBackendScene scene = CreateNestedLayerScene(target, 4); + using DrawingBackendScene scene = CreateNestedLayerScene(target.CreateCanvas(), 4, 512); using (DrawingCanvas canvas = target.CreateCanvas()) + { + DrawSceneInsideLayers(canvas, scene); + } + + using Image actual = target.ReadbackImage().CloneAs(); + Assert.Equal(0, target.Backend.DiagnosticScratchOverflowCount); + + // The CPU and GPU layer blends differ by one 8-bit level in some pixels: 0.0215% measured. + AssertBackendPairSimilarity(expected, actual, 0.0216F); + + static void DrawSceneInsideLayers(DrawingCanvas canvas, DrawingBackendScene scene) { for (int i = 0; i < 5; i++) { @@ -273,8 +296,6 @@ public void FirstRender_RetainedNestedLayersInsideLayers_DoesNotOverflowScratch( canvas.Restore(); } } - - Assert.Equal(0, target.Backend.DiagnosticScratchOverflowCount); } // Dense lines in the top quarter of a large target render in tile-row chunks when the binding @@ -325,11 +346,24 @@ private static void DrawNestedLayers(DrawingCanvas canvas, int depth, int size) } } - private static DrawingBackendScene CreateNestedLayerScene(WebGPURenderTarget target, int depth) + private static DrawingBackendScene CreateNestedLayerScene(DrawingCanvas canvas, int depth, int size) { - using DrawingCanvas canvas = target.CreateCanvas(); - DrawNestedLayers(canvas, depth, target.Width); - return canvas.CreateScene(); + using (canvas) + { + DrawNestedLayers(canvas, depth, size); + return canvas.CreateScene(); + } + } + + private static Image RenderWithCpuCanvas(int width, int height, Action draw) + { + Image image = new(width, height); + using (DrawingCanvas canvas = image.Frames.RootFrame.CreateCanvas(Configuration.Default, new DrawingOptions())) + { + draw(canvas); + } + + return image; } private static Image RenderManyLinesToImage(int width, int height, Brush background, (PointF Start, PointF End, Color Color, float Width)[] lines, int passes) From 1cd9d98a024e1e401035b9234c123fde898893ed Mon Sep 17 00:00:00 2001 From: James Jackson-South Date: Mon, 26 Oct 2026 17:51:55 +1000 Subject: [PATCH 3/6] Pin the pixels until the zxing-cpp read returns The decode tests failed at random on Aztec symbols. The native ImageView of zxing-cpp keeps the pointer to the pixels, and BarcodeReader.Read reads through it later. The P/Invoke of the ImageView constructor pins the array only during that call, so a garbage collection between the two calls can move the array. The reader then reads other memory. ReadBarcodes pins the array with fixed until the read returns, and both decode call sites use it. --- .../Barcodes/BarcodeDecodeTests.cs | 25 +++++++++++++++++-- 1 file changed, 23 insertions(+), 2 deletions(-) diff --git a/tests/ImageSharp.Drawing.Tests/Barcodes/BarcodeDecodeTests.cs b/tests/ImageSharp.Drawing.Tests/Barcodes/BarcodeDecodeTests.cs index aa9e130bd..332e6f696 100644 --- a/tests/ImageSharp.Drawing.Tests/Barcodes/BarcodeDecodeTests.cs +++ b/tests/ImageSharp.Drawing.Tests/Barcodes/BarcodeDecodeTests.cs @@ -453,7 +453,7 @@ public void Gs1Composite_LinearComponentRoundTrips(string kind, string linear, s image.CopyPixelDataTo(pixels); using ReaderOptions reader = new() { Formats = format, TryHarder = true, TextMode = TextMode.Plain }; - Barcode[] barcodes = BarcodeReader.Read(new ImageView(pixels, image.Width, image.Height, ImageFormat.Lum, 0, 0), reader); + Barcode[] barcodes = ReadBarcodes(pixels, image.Width, image.Height, reader); try { Barcode barcode = Assert.Single(barcodes); @@ -950,7 +950,7 @@ private static void AssertDecodes( // The plain text mode returns the data as the symbol transmits it, without the parentheses of a GS1 human readable interpretation. using ReaderOptions reader = new() { Formats = format, TryHarder = true, TextMode = TextMode.Plain }; - Barcode[] barcodes = BarcodeReader.Read(new ImageView(pixels, width, height, ImageFormat.Lum, 0, 0), reader); + Barcode[] barcodes = ReadBarcodes(pixels, width, height, reader); try { Assert.True(barcodes.Length > 0, $"No decode at {options.Dpi} dpi."); @@ -974,6 +974,27 @@ private static void AssertDecodes( } } + /// + /// Reads the barcodes in 8-bit grey pixels with zxing-cpp. + /// + /// + /// The native ImageView of zxing-cpp keeps the pointer to the pixels, and reads through it later. The P/Invoke of the + /// ImageView constructor pins the array only during that call. This method therefore pins the array until the read returns. Otherwise a garbage + /// collection between the two calls can move the array, and the reader then reads other memory. + /// + /// The grey pixels, row by row. + /// The image width in pixels. + /// The image height in pixels. + /// The reader options. + /// The barcodes that the reader found. + private static unsafe Barcode[] ReadBarcodes(byte[] pixels, int width, int height, ReaderOptions reader) + { + fixed (byte* data = pixels) + { + return BarcodeReader.Read(new ImageView((IntPtr)data, width, height, ImageFormat.Lum, 0, 0), reader); + } + } + /// /// Draws a symbol and decodes it with ZXing.Net, for the formats zxing-cpp does not read. /// From b38b3793a528c59380bcb7571741299c6ee6b6da Mon Sep 17 00:00:00 2001 From: James Jackson-South Date: Mon, 26 Oct 2026 18:16:07 +1000 Subject: [PATCH 4/6] Describe device-space strokes in the DrawingOptions.Transform documentation The documentation said that the pen expands in local geometry space and that the outline is transformed after. Both backends stroke the transformed path in device space, with the pen width multiplied by the square root of the absolute determinant of the transform. The documentation now describes this behavior. --- src/ImageSharp.Drawing/Processing/DrawingOptions.cs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/ImageSharp.Drawing/Processing/DrawingOptions.cs b/src/ImageSharp.Drawing/Processing/DrawingOptions.cs index 7cb149d23..7cf0c7b44 100644 --- a/src/ImageSharp.Drawing/Processing/DrawingOptions.cs +++ b/src/ImageSharp.Drawing/Processing/DrawingOptions.cs @@ -70,7 +70,9 @@ public GraphicsOptions GraphicsOptions /// /// Gets or sets the transform matrix applied to vector output before rasterization. - /// For strokes, the pen is expanded in local geometry space and the resulting outline is transformed before rasterization. + /// For strokes, the transform applies to the path. Then the pen strokes the transformed path in device space. + /// The stroke width is the pen width multiplied by the square root of the absolute determinant of the 2D part of the transform. + /// Thus the stroke width is the same in all directions, also under a non-uniform scale or a skew. /// Defaults to . /// public Matrix4x4 Transform { get; set; } From 12f238c7fdc8515c091b688fea88185a0cfa6c97 Mon Sep 17 00:00:00 2001 From: James Jackson-South Date: Mon, 26 Oct 2026 18:17:46 +1000 Subject: [PATCH 5/6] Remove the stroke detail from the DrawingOptions.Transform documentation The documentation gives only the observable contract of the property: the transform that applies to vector output before rasterization. --- src/ImageSharp.Drawing/Processing/DrawingOptions.cs | 3 --- 1 file changed, 3 deletions(-) diff --git a/src/ImageSharp.Drawing/Processing/DrawingOptions.cs b/src/ImageSharp.Drawing/Processing/DrawingOptions.cs index 7cf0c7b44..b2259ca90 100644 --- a/src/ImageSharp.Drawing/Processing/DrawingOptions.cs +++ b/src/ImageSharp.Drawing/Processing/DrawingOptions.cs @@ -70,9 +70,6 @@ public GraphicsOptions GraphicsOptions /// /// Gets or sets the transform matrix applied to vector output before rasterization. - /// For strokes, the transform applies to the path. Then the pen strokes the transformed path in device space. - /// The stroke width is the pen width multiplied by the square root of the absolute determinant of the 2D part of the transform. - /// Thus the stroke width is the same in all directions, also under a non-uniform scale or a skew. /// Defaults to . /// public Matrix4x4 Transform { get; set; } From 4c8895086308923aaf684d0fa7e189bfc991efe2 Mon Sep 17 00:00:00 2001 From: James Jackson-South Date: Tue, 27 Oct 2026 10:55:24 +1000 Subject: [PATCH 6/6] Set the macOS rounding tolerance for the self-copy layer test The CopyPixelsFromSelf case composites green 128 at 50% over white. The exact green value is 191.5, which is a rounding tie. The WebGPU backend on macOS gives 191, and the reference image has 192. The difference is 0.0184% in 96 pixels. The reference comparison of this case now uses 0.0185%, which is the measured difference plus one unit. The other cases keep 0.0003%. --- .../Processing/Backends/WebGPUDrawingBackendTests.cs | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/tests/ImageSharp.Drawing.Tests/Processing/Backends/WebGPUDrawingBackendTests.cs b/tests/ImageSharp.Drawing.Tests/Processing/Backends/WebGPUDrawingBackendTests.cs index 642f073cc..ad5dc0bdd 100644 --- a/tests/ImageSharp.Drawing.Tests/Processing/Backends/WebGPUDrawingBackendTests.cs +++ b/tests/ImageSharp.Drawing.Tests/Processing/Backends/WebGPUDrawingBackendTests.cs @@ -3018,7 +3018,12 @@ DrawingCanvas CreateNativeCopySource() // differ by one unit in some blended layer pixels between the CPU and the GPU, so the // pair tolerance is the largest measured difference of all cases plus one unit. AssertBackendPairSimilarity(defaultImage, nativeSurfaceImage, 0.0859F); - AssertBackendPairReferenceOutputs(provider, details, defaultImage, nativeSurfaceImage); + + // The self copy composites green 128 at 50% over white, so the exact green value is the + // rounding tie 191.5. The WebGPU output on macOS rounds it to 191, against 192 in the + // reference, so that case gets its measured difference plus one unit. + float referenceTolerance = seal == "CopyPixelsFromSelf" ? 0.0185F : 0.0003F; + AssertBackendPairReferenceOutputs(provider, details, defaultImage, nativeSurfaceImage, referenceTolerance); } finally {