diff --git a/src/ImageSharp.Drawing.WebGPU/Shaders/WgslSource/Shared/config.wgsl b/src/ImageSharp.Drawing.WebGPU/Shaders/WgslSource/Shared/config.wgsl index 9d6725dd..b44c6ccc 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/pathtag.wgsl b/src/ImageSharp.Drawing.WebGPU/Shaders/WgslSource/Shared/pathtag.wgsl index dba8b40f..1a93d50e 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/Shared/ptcl.wgsl b/src/ImageSharp.Drawing.WebGPU/Shaders/WgslSource/Shared/ptcl.wgsl index ff676e05..9f517317 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 00000000..ae3adbe8 --- /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 014b44dc..7af97f0f 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 7f37732b..18431a17 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 e48c70d0..08f9e9a7 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 49c670c5..30f5eec2 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 6741f2e1..3706f88a 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_count.wgsl b/src/ImageSharp.Drawing.WebGPU/Shaders/WgslSource/path_count.wgsl index 48b1c132..0a809f49 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 472f7035..97206ce8 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; @@ -94,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 @@ -426,7 +428,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 +501,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 +561,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. @@ -567,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. @@ -576,7 +594,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 +619,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 +641,15 @@ 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]); + 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; @@ -672,9 +706,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 7298047e..6c001007 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 095721f8..a3a6d933 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 b232f83b..6e5e42d2 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 905e55f9..6663f2f4 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 d612b79c..da210fc6 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 c5854175..ae8ec0fb 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 80d37d7b..df0a4c6a 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,9 @@ public void RenderScene( resourceArena ??= webGPUScene.RentResourceArena() ?? this.RentResourceArena(); schedulingArena ??= webGPUScene.RentSchedulingArena() ?? this.RentSchedulingArena(); + // 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, @@ -289,7 +428,7 @@ public void RenderScene( targetDescriptor, requiredFeature, webGPUScene, - deferOverflowCheck: true, + webGPUTarget.IsPresentationSurface, ref currentBumpSizes, ref resourceArena, ref schedulingArena); @@ -308,11 +447,15 @@ 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. /// + /// + /// 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. /// The native surface holding the target texture handles. @@ -320,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 may be deferred; corrective re-renders 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. @@ -379,17 +525,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 +547,7 @@ private void RenderEncodedSceneWithGrowth( if (requiresGrowth) { + this.CountScratchOverflow(); currentBumpSizes = MaxBumpSizes(currentBumpSizes, grownBumpSizes); continue; } @@ -883,33 +1025,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 +1041,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 +1057,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 +1080,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 +1109,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 7c6fcbdf..00aff283 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 8989249d..9e8cdfe2 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 ec86e669..104990d7 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 a46b3d78..66724537 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. /// @@ -357,16 +369,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 +516,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 +546,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 +589,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; + } + + 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++; } - 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 +865,7 @@ public static bool TryValidateBindingSizes( out bindingLimitFailure, out error) || !TryValidateBufferSize( - GetBindingByteLength(encodedScene.SceneWordCount), + GetBindingByteLength(encodedScene.SceneBufferWordCount), "scene data", BindingLimitBuffer.None, maxStorageBufferBindingSize, @@ -803,7 +1081,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 +1342,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 +1683,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 +1775,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) @@ -1678,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. @@ -1742,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. @@ -1831,7 +2111,7 @@ public static unsafe bool TryRenderStagedScene( flushContext, stagedScene.Resources, stagedScene.Config.BufferSizes, - GetBindingByteLength(stagedScene.EncodedScene.SceneWordCount), + GetBindingByteLength(stagedScene.EncodedScene.SceneBufferWordCount), scheduling, outputTextureView, backdropTarget.TextureView, @@ -1842,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, @@ -1984,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. @@ -2016,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, @@ -2054,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) @@ -2093,8 +2394,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) @@ -2139,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 @@ -2209,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. @@ -2248,7 +2549,7 @@ private static unsafe bool TryRenderSegmentChunkedStagedScene( return false; } - if (deferOverflowCheck) + if (deferStatus) { pendingStatus = new WebGPUPendingSchedulingStatus( flushContext.Api, @@ -2335,7 +2636,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 +2704,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 +2949,183 @@ 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. 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 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(originY + ((int)end * WebGPUSceneEncoder.TileHeight)); + 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 (end >= targetTileHeight) + { + break; + } + } + + return new WebGPUSceneWindowBounds(pathRows, tiles, crossings, ptclWords); + } + + /// + /// 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 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, + 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 +3135,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 +3156,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 +3183,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 number of tiles in the target, each of which can leave one partial PTCL chunk. + /// 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. /// 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 +3196,68 @@ 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(GetPtclChunkBound(estimatedPtclWords, targetTileCount)); 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). + /// 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. + /// + /// 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. @@ -2763,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 91fdd852..8603e5d7 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,48 @@ 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 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. + /// + public const int SceneSegmentEntryWordCount = 13; + /// /// Vello's clip blend word, (MIX_CLIP << 8) | COMPOSE_SRC_OVER. Values must match blend.wgsl. /// @@ -207,7 +255,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 +366,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 +396,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 +408,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 +429,7 @@ public static bool TryEncodeOrdered( SceneEncodingCheckpoint rangeStart = encoding.BeginPackedIndependentRange(targetBounds, ClipBlendMode, replayActiveClips: true); if (!TryEncodeOrderedOperations( scene, + scopedSceneLayers, 0, scene.CommandCount, targetBounds, @@ -383,6 +450,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 +482,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 +498,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 +519,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 +542,7 @@ private static bool TryEncodeOrderedOperations( SceneEncodingCheckpoint childStart = encoding.BeginPackedIndependentRange(layerBounds, ClipBlendMode, replayActiveClips: true); if (!TryEncodeOrderedOperations( scene, + scopedSceneLayers, i + 1, layerEnd, layerBounds, @@ -517,6 +600,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 +686,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 +1178,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 +1195,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 +1292,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 +1949,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 +1976,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 +2016,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 +2045,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 +2053,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 +2137,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 +2233,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 +2256,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 bool IsEmpty => this.FillCount == 0; + public readonly long EstimatedPathTiles => this.estimatedPathTiles; + + /// + /// Gets the union region of each blend depth, starting at depth 1, or . + /// + 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 +2322,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 +2333,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 +2393,7 @@ public WebGPUSceneRange EndPackedIndependentRange( } this.EndIndependentRange(); - return CreateRange(start, this.CaptureCheckpoint(), targetBounds); + return this.CreateRange(start, this.CaptureCheckpoint(), targetBounds); } /// @@ -2013,38 +2459,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,6 +2598,14 @@ public void Dispose() this.PathData.Dispose(); this.PathTags.Dispose(); } + + if (!this.rowBoundsDetached && this.ownedRowBounds is { } ownedRowBounds) + { + foreach (WebGPUSceneRowBounds bounds in ownedRowBounds) + { + bounds.Dispose(); + } + } } /// @@ -2226,12 +2757,147 @@ 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; + } + /// /// Appends one prepared fill-path or layer-based command to the scene streams when the command kind is supported. /// @@ -2648,7 +3314,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 +3355,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 +3491,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 +3513,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 +3634,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 +3656,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 +3773,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 +3795,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 +3869,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 +4019,7 @@ private bool AppendClipDescriptor(in DrawingClipDescriptor descriptor, Point des ref this.PathTags, ref this.PathData, out clipLineCount, + out _, out _); } } @@ -3400,7 +4134,22 @@ 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++; + + // 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; @@ -3487,7 +4236,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 +4258,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 +4322,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 +4347,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 +4365,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; + + 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; - // 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; + // 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 +4521,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 +4533,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 +4853,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 +4979,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 +5008,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 +5071,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 +5161,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 +5225,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), - encoding.EstimatedTileCrossings, - encoding.EstimatedPtclWords, - encoding.EstimatedBinFootprint, + 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 + 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, + 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)); + } + + this.AddBlocks(scene.Insertions); + } + } + + /// + /// 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) + { + 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) { - sceneDataOwner.Dispose(); - throw; + // 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 +6240,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 +6268,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 +6481,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 +7121,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 +7130,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 +7163,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 +7205,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 +7458,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 +7471,14 @@ 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; + 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); @@ -5530,6 +7527,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; @@ -5538,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; @@ -5594,16 +7602,45 @@ 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; - 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; @@ -5685,6 +7722,8 @@ private static int EncodeStrokePath( } tileCrossings = BoundStrokeTileCrossings(outlineLength, residualStretch, lineCount); + rowCrossings = bodyRowCrossings; + endCrossings = GetStrokeEndTileCrossings(lineCount, bodySegmentCount, pen, widthScale) + innerJoinCrossings; if (encodedContourCount == 0) { return 0; @@ -5832,6 +7871,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 +7885,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 +7899,55 @@ 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; + } + + /// + /// 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. /// @@ -5963,7 +8052,7 @@ private static int EncodeRectanglePath( pathData.Advance(data.Length); pathTags.Advance(tags.Length); - lineCount = 2; + lineCount = 4; return 1; } @@ -6010,7 +8099,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 +8123,7 @@ private static int EncodeRectangleClipPath( ref pathTags, ref pathData); - lineCount += 2; + lineCount += 4; } break; @@ -6052,7 +8141,7 @@ private static int EncodeRectangleClipPath( } AppendRectangleContour(rectangle, translateX, translateY, ref pathTags, ref pathData); - lineCount += 2; + lineCount += 4; } break; @@ -6368,16 +8457,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); @@ -6436,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. @@ -6444,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 { @@ -6474,15 +8569,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 +8590,13 @@ 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. 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), + LineCap.Round => GetStrokeArcLineCost(halfWidth, MathF.PI, pen.StrokeOptions.ArcDetailScale) + 1, _ => 1 }; } @@ -6675,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. @@ -6702,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), @@ -6891,7 +9004,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 +9633,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 +9791,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 +9991,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 +10234,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 +10356,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 +10604,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 4874a501..243a85c9 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 bf4b879e..8276579f 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 00000000..1fbeb583 --- /dev/null +++ b/src/ImageSharp.Drawing.WebGPU/WebGPUSceneRowBounds.cs @@ -0,0 +1,338 @@ +// 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 tile row. 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 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 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 . +/// +/// +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 RowCount + 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 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.stride = rowCount + 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 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; + } + + /// + /// 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; + 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; + } + + /// + /// 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 rows = this.RowCount; + + // 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 rowValue = 0; + long sum = 0; + for (int r = 0; r < rows; r++) + { + rowValue += array[r]; + array[r] = sum; + sum += rowValue; + } + + array[rows] = sum; + } + + // 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 r = 0; r < rows; r++) + { + long value = array[r]; + array[r] = sum; + sum += value; + } + + array[rows] = 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. + 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); + 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. + /// + /// 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; + data[offset + firstRow] += valuePerRow; + data[offset + endRow] -= valuePerRow; + } + + /// + /// Gets the window sum of an area array. + /// + /// The sealed block. + /// The array index. + /// 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]; + + /// + /// Gets the full bounds of the draws that reach a window. + /// + /// The sealed block. + /// 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]; + + /// + /// 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 00000000..472a22db --- /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 d859048f..a8d38d9f 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 42910400..cdc0981e 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 f50fcd10..0690809f 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 e0104f18..f64aadd4 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 04218f1b..15aabe36 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 ef969466..52a65893 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 6be0dfd3..4856d487 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 7a269db9..62a6b26c 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 1c53340b..9c9f2b02 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 1f87e740..6ab3dc72 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/DrawingOptions.cs b/src/ImageSharp.Drawing/Processing/DrawingOptions.cs index 7cb149d2..b2259ca9 100644 --- a/src/ImageSharp.Drawing/Processing/DrawingOptions.cs +++ b/src/ImageSharp.Drawing/Processing/DrawingOptions.cs @@ -70,7 +70,6 @@ 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. /// Defaults to . /// public Matrix4x4 Transform { get; set; } diff --git a/src/ImageSharp.Drawing/Processing/GradientBrush.cs b/src/ImageSharp.Drawing/Processing/GradientBrush.cs index 838e027f..c2c4795a 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 bf3de7d9..35a7f8b0 100644 --- a/src/ImageSharp.Drawing/Processing/Pen.cs +++ b/src/ImageSharp.Drawing/Processing/Pen.cs @@ -107,6 +107,38 @@ 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 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. + /// + /// 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; + double miterLimit = Math.Max(this.StrokeOptions.MiterLimit, 1D); + float joinRadius = this.StrokeOptions.LineJoin switch + { + LineJoin.Miter => (float)(halfWidth * Math.Sqrt((miterLimit * miterLimit) + 1D)), + LineJoin.MiterRevert or LineJoin.MiterRound => (float)(halfWidth * miterLimit), + _ => 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/Barcodes/BarcodeDecodeTests.cs b/tests/ImageSharp.Drawing.Tests/Barcodes/BarcodeDecodeTests.cs index aa9e130b..332e6f69 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. /// diff --git a/tests/ImageSharp.Drawing.Tests/Processing/Backends/WebGPUDrawingBackendTests.Diagnostics.cs b/tests/ImageSharp.Drawing.Tests/Processing/Backends/WebGPUDrawingBackendTests.Diagnostics.cs index a6c9e37b..2c8c9d1a 100644 --- a/tests/ImageSharp.Drawing.Tests/Processing/Backends/WebGPUDrawingBackendTests.Diagnostics.cs +++ b/tests/ImageSharp.Drawing.Tests/Processing/Backends/WebGPUDrawingBackendTests.Diagnostics.cs @@ -158,6 +158,30 @@ 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, + // 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_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")); + using Image expected = RenderManyLinesToImage(width, height, background, lines, passes: 1); + + using WebGPURenderTarget target = new(WebGPUTextureFormat.Bgra8Unorm, width, height); + target.Backend.ScratchBufferBindingSizeLimit = 128U * 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, CountDifferingPixels(expected, chunked)); + } + // 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 +204,168 @@ 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. 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. 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++) + { + _ = 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(); + } + } + } + + // 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 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.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++) + { + _ = canvas.SaveLayer(new GraphicsOptions(), new Rectangle(0, 0, 512, 512)); + } + + canvas.RenderScene(scene); + for (int i = 0; i < 5; i++) + { + canvas.Restore(); + } + } + } + + // 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(DrawingCanvas canvas, int depth, int size) + { + 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) { 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 b7a052f7..ad5dc0bd 100644 --- a/tests/ImageSharp.Drawing.Tests/Processing/Backends/WebGPUDrawingBackendTests.cs +++ b/tests/ImageSharp.Drawing.Tests/Processing/Backends/WebGPUDrawingBackendTests.cs @@ -2489,6 +2489,551 @@ 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); + + // 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 + { + foreach (DrawingBackendScene scene in scenes) + { + scene.Dispose(); + } + } + } + [WebGPUTheory] [WithSolidFilledImages(160, 120, "White", PixelTypes.Rgba32)] public void RetainedScene_WithLayerCommands_MatchesDefaultOutput(TestImageProvider provider) @@ -2654,6 +3199,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 +3516,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 +3594,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 b19c2669..28f17182 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 7549b118..24333ddc 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 00000000..5e7807ee --- /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 00000000..2660da1e --- /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 00000000..b7cd52a0 --- /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 00000000..b7cd52a0 --- /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 b7aa6943..3f6482a9 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 b7aa6943..3f6482a9 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 1eeb0177..1f4de617 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 4c8c8571..5bbe45fe 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 362a0651..c77a27ae 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 00000000..5e7807ee --- /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 00000000..15565f97 --- /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 00000000..2660da1e --- /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 00000000..05c71d36 --- /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 2fcf50f0..e601f451 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 34c1b4cd..73eff527 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 bdc65683..fcad0799 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 63e65b5b..6de8a776 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 00000000..d8f3a147 --- /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 00000000..d8f3a147 --- /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 00000000..d8f3a147 --- /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 00000000..caa75aad --- /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 00000000..790c79fd --- /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 00000000..790c79fd --- /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 00000000..226c7398 --- /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 00000000..398d4f18 --- /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 00000000..790c79fd --- /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 00000000..790c79fd --- /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 00000000..226c7398 --- /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 00000000..398d4f18 --- /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 00000000..04824bce --- /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 00000000..04824bce --- /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 00000000..66dacb57 --- /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 00000000..fda5fa1e --- /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 00000000..6697a2e5 --- /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 00000000..6697a2e5 --- /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 00000000..3fb845bb --- /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 00000000..15255acb --- /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 00000000..04824bce --- /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 00000000..04824bce --- /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 00000000..66dacb57 --- /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 00000000..fda5fa1e --- /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 00000000..5998c6e0 --- /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 00000000..24b2d710 --- /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 00000000..0aca9238 --- /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 00000000..5cf2416d --- /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 00000000..419e3227 --- /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 00000000..fd7a9a80 --- /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 00000000..f9a77d73 --- /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 00000000..e6741f9d --- /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 00000000..7c725fe0 --- /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 00000000..086a956a --- /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 00000000..adaf9c37 --- /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 00000000..7b71d748 --- /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 00000000..131ec792 --- /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 00000000..131ec792 --- /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 00000000..deb16277 --- /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 00000000..af655be5 --- /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 00000000..eb8cdab0 --- /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 00000000..feaf3f21 --- /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 00000000..02d88c5c --- /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 00000000..7e8f4474 --- /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 00000000..5998c6e0 --- /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 00000000..37ace1eb --- /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 00000000..0aca9238 --- /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 00000000..5cf2416d --- /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 00000000..04824bce --- /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 00000000..04824bce --- /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 00000000..66dacb57 --- /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 00000000..fda5fa1e --- /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 00000000..419e3227 --- /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 00000000..fd7a9a80 --- /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 00000000..f9a77d73 --- /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 00000000..e6741f9d --- /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