From 8dd5241d0eeba591f677d02fdb15173ae421f2a4 Mon Sep 17 00:00:00 2001 From: James Jackson-South Date: Tue, 15 Sep 2026 17:07:52 +1000 Subject: [PATCH 1/3] Cut WebGPU first-flush compile time and stroke scratch reservation compose_source evaluates the blend overlap once per call instead of inside five switch cases. The fine pipeline compiles in 0.5 s instead of 1.7 s with identical arithmetic per mode. The scheduling pipelines compile in parallel when the device is probed. The fine pipeline compiles once per target format, started when the target is created, instead of four fixed variants started at the first render. Stroke scratch estimates are bounded from the outline geometry (offset edges, caps, joins) instead of the bounding-box diagonal. The PTCL seed is a per-crossing bound plus one chunk per target tile. --- .../Shaders/WgslSource/Shared/blend.wgsl | 20 +- .../WebGPUNativeSurface.cs | 4 + .../WebGPURuntime.DeviceSharedState.cs | 48 +++- .../WebGPURuntime.cs | 9 +- .../WebGPUSceneDispatch.cs | 233 +++++++++++------- .../WebGPUSceneEncoder.cs | 166 ++++++++++++- 6 files changed, 368 insertions(+), 112 deletions(-) diff --git a/src/ImageSharp.Drawing.WebGPU/Shaders/WgslSource/Shared/blend.wgsl b/src/ImageSharp.Drawing.WebGPU/Shaders/WgslSource/Shared/blend.wgsl index d04a858f..94b16c07 100644 --- a/src/ImageSharp.Drawing.WebGPU/Shaders/WgslSource/Shared/blend.wgsl +++ b/src/ImageSharp.Drawing.WebGPU/Shaders/WgslSource/Shared/blend.wgsl @@ -369,6 +369,16 @@ fn compose_source(backdrop: vec4, source: vec4, opacity: f32, mode: u3 } let normal = mix_mode == MIX_NORMAL; + + // The overlap term is evaluated once, with the operand order of the compose mode + // that consumes it. Evaluating it inside each case inlines the whole mix-mode switch + // once per case, and the driver compile time of this shader grows with every copy. + var overlap = vec3(0.0); + if !normal { + let dest_first = compose_mode == COMPOSE_DEST_OVER || compose_mode == COMPOSE_DEST_ATOP; + overlap = blend_overlap(select(backdrop, scaled, dest_first), select(scaled, backdrop, dest_first), mix_mode); + } + switch compose_mode { case COMPOSE_CLEAR: { return vec4(0.0); @@ -384,7 +394,7 @@ fn compose_source(backdrop: vec4, source: vec4, opacity: f32, mode: u3 return compose_over_normal(scaled, backdrop); } - return compose_over(scaled, backdrop, blend_overlap(scaled, backdrop, mix_mode)); + return compose_over(scaled, backdrop, overlap); } case COMPOSE_SRC_IN: { return compose_in(backdrop, scaled); @@ -403,14 +413,14 @@ fn compose_source(backdrop: vec4, source: vec4, opacity: f32, mode: u3 return compose_atop_normal(backdrop, scaled); } - return compose_atop(backdrop, scaled, blend_overlap(backdrop, scaled, mix_mode)); + return compose_atop(backdrop, scaled, overlap); } case COMPOSE_DEST_ATOP: { if normal { return compose_atop_normal(scaled, backdrop); } - return compose_atop(scaled, backdrop, blend_overlap(scaled, backdrop, mix_mode)); + return compose_atop(scaled, backdrop, overlap); } case COMPOSE_XOR: { return compose_xor(backdrop, scaled); @@ -420,14 +430,14 @@ fn compose_source(backdrop: vec4, source: vec4, opacity: f32, mode: u3 return compose_plus_normal(backdrop, scaled); } - return compose_plus(backdrop, scaled, blend_overlap(backdrop, scaled, mix_mode)); + return compose_plus(backdrop, scaled, overlap); } default: { if normal { return compose_over_normal(backdrop, scaled); } - return compose_over(backdrop, scaled, blend_overlap(backdrop, scaled, mix_mode)); + return compose_over(backdrop, scaled, overlap); } } } diff --git a/src/ImageSharp.Drawing.WebGPU/WebGPUNativeSurface.cs b/src/ImageSharp.Drawing.WebGPU/WebGPUNativeSurface.cs index a368b491..b9c41398 100644 --- a/src/ImageSharp.Drawing.WebGPU/WebGPUNativeSurface.cs +++ b/src/ImageSharp.Drawing.WebGPU/WebGPUNativeSurface.cs @@ -54,6 +54,10 @@ public WebGPUNativeSurface( this.TextureCoordinateOffset = textureCoordinateOffset; this.IsPresentationSurface = isPresentationSurface; this.RequiresPresentationCopies = requiresPresentationCopies; + + // The target format is known here for the first time. Start the fine pipeline compile for it so + // the first flush finds it ready or waits only for the remaining part. + WebGPUSceneDispatch.BeginFinePipelineWarmup(WebGPURuntime.GetOrCreateDeviceState(WebGPURuntime.GetApi(), deviceHandle), targetDescriptor); } /// diff --git a/src/ImageSharp.Drawing.WebGPU/WebGPURuntime.DeviceSharedState.cs b/src/ImageSharp.Drawing.WebGPU/WebGPURuntime.DeviceSharedState.cs index 6b4e37a7..33bc0542 100644 --- a/src/ImageSharp.Drawing.WebGPU/WebGPURuntime.DeviceSharedState.cs +++ b/src/ImageSharp.Drawing.WebGPU/WebGPURuntime.DeviceSharedState.cs @@ -184,9 +184,19 @@ internal sealed partial class DeviceSharedState : IDisposable private readonly object singlePixelSync = new(); /// - /// Signals that the one-time background pipeline warm-up no longer uses this state. + /// Set while no background pipeline warm-up uses this state. /// - private readonly ManualResetEventSlim pipelineWarmupCompleted = new(false); + private readonly ManualResetEventSlim pipelineWarmupCompleted = new(true); + + /// + /// Guards and the transitions of . + /// + private readonly object pipelineWarmupSync = new(); + + /// + /// The number of background pipeline warm-ups in flight. + /// + private int pipelineWarmupCount; /// /// Upper bound on pooled status readback buffers; returns beyond it release instead. @@ -336,10 +346,40 @@ public void MarkLost() => Volatile.Write(ref this.isLost, 1); /// - /// Signals that background pipeline warm-up has stopped using this state. + /// Registers one background pipeline warm-up that uses this state until is called. + /// + public void RegisterPipelineWarmup() + { + lock (this.pipelineWarmupSync) + { + if (this.pipelineWarmupCount++ == 0) + { + this.pipelineWarmupCompleted.Reset(); + } + } + } + + /// + /// Signals that one background pipeline warm-up has stopped using this state. /// public void CompletePipelineWarmup() - => this.pipelineWarmupCompleted.Set(); + { + lock (this.pipelineWarmupSync) + { + if (--this.pipelineWarmupCount == 0) + { + this.pipelineWarmupCompleted.Set(); + } + } + } + + /// + /// Reports whether the compute pipeline for the given key has already been created. + /// + /// The pipeline cache key. + /// when the pipeline exists; otherwise, . + public bool HasCompositeComputePipeline(string pipelineKey) + => this.compositeComputePipelines.TryGetValue(pipelineKey, out CompositeComputePipelineInfrastructure? infrastructure) && infrastructure.Pipeline is not null; /// /// Rents a pooled map-readable status buffer, or creates one when the pool has no buffer diff --git a/src/ImageSharp.Drawing.WebGPU/WebGPURuntime.cs b/src/ImageSharp.Drawing.WebGPU/WebGPURuntime.cs index 4bb2960b..03ecbefc 100644 --- a/src/ImageSharp.Drawing.WebGPU/WebGPURuntime.cs +++ b/src/ImageSharp.Drawing.WebGPU/WebGPURuntime.cs @@ -324,9 +324,16 @@ public static WebGPUEnvironmentError ProbeAvailability() try { - availabilityProbeResult = TryGetOrCreateDevice(out _, out _, out WebGPUEnvironmentError errorCode) + availabilityProbeResult = TryGetOrCreateDevice(out WebGPUDeviceHandle? probedDevice, out _, out WebGPUEnvironmentError errorCode) ? WebGPUEnvironmentError.Success : errorCode; + + // Creating the device state here starts the background compile of the scheduling + // pipelines at probe time, before the application creates its first target. + if (availabilityProbeResult == WebGPUEnvironmentError.Success && probedDevice is not null) + { + _ = GetOrCreateDeviceState(GetApi(), probedDevice); + } } catch (InvalidOperationException) { diff --git a/src/ImageSharp.Drawing.WebGPU/WebGPUSceneDispatch.cs b/src/ImageSharp.Drawing.WebGPU/WebGPUSceneDispatch.cs index 5b904750..b81a7e5e 100644 --- a/src/ImageSharp.Drawing.WebGPU/WebGPUSceneDispatch.cs +++ b/src/ImageSharp.Drawing.WebGPU/WebGPUSceneDispatch.cs @@ -68,10 +68,14 @@ internal static class WebGPUSceneDispatch private const int MaxClipStackDepth = 256; // The PTCL word budget attributed to each estimated tile crossing when seeding the PTCL - // scratch capacity. Each crossed tile costs one CMD_FILL (9 words) plus a paint command and - // amortized jump/end overhead; the multiplier was calibrated against measured demand - // (~6.5 words per crossing on stroke-heavy scenes) with headroom for paint-heavy draws. - private const long PtclWordsPerCrossing = 8; + // scratch capacity. Every crossing belongs to one (draw, tile) pair with segments, and such a + // pair writes one CMD_FILL (9 words) plus one paint command of at most 5 words. + private const long PtclWordsPerCrossing = 14; + + // 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. + private const long PtclChunkWords = 256; /// /// Identifies the staged-scene storage binding that exceeded the device limit for one flush attempt. @@ -2447,6 +2451,7 @@ private static WebGPUSceneBumpSizes SeedSceneBumpSizes(WebGPUSceneBumpSizes curr scene.PathCount, scene.EstimatedTileCrossings, scene.EstimatedBinFootprint, + (long)scene.TileCountX * scene.TileCountY, maxStorageBufferBindingSize); /// @@ -2464,6 +2469,7 @@ private static WebGPUSceneBumpSizes SeedSceneBumpSizes(WebGPUSceneBumpSizes curr range.PathCount, range.EstimatedTileCrossings, range.EstimatedBinFootprint, + (long)((range.TargetBounds.Width + 15) / 16) * ((range.TargetBounds.Height + 15) / 16), maxStorageBufferBindingSize); /// @@ -2479,6 +2485,7 @@ private static WebGPUSceneBumpSizes SeedSceneBumpSizes(WebGPUSceneBumpSizes curr /// The encoded path count. /// The CPU-side upper bound for tile-boundary crossings. /// 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 device-reported maximum size of one storage-buffer binding. /// The retained capacities raised to the known CPU-side lower bounds. private static WebGPUSceneBumpSizes SeedSceneBumpSizes( @@ -2488,6 +2495,7 @@ private static WebGPUSceneBumpSizes SeedSceneBumpSizes( int pathCount, long estimatedTileCrossings, long estimatedBinFootprint, + long targetTileCount, ulong maxStorageBufferBindingSize) { uint lineFloor = AddSizingSlack(checked((uint)Math.Max(lineCount, 1))); @@ -2503,7 +2511,8 @@ private static WebGPUSceneBumpSizes SeedSceneBumpSizes( ClampEstimate(estimatedTileCrossings, maxStorageBufferBindingSize, (uint)Unsafe.SizeOf()), pathRowFloor); uint binningFloor = ClampEstimate(estimatedBinFootprint, maxStorageBufferBindingSize, sizeof(uint)); - uint ptclFloor = ClampEstimate(estimatedTileCrossings * PtclWordsPerCrossing, maxStorageBufferBindingSize, sizeof(uint)); + long ptclWords = ((estimatedTileCrossings * PtclWordsPerCrossing * 105L) / 100L) + (targetTileCount * PtclChunkWords); + uint ptclFloor = ClampEstimate(ptclWords, maxStorageBufferBindingSize, sizeof(uint)); return new WebGPUSceneBumpSizes( Math.Max(currentSizes.Lines, lineFloor), @@ -4030,23 +4039,11 @@ private static unsafe bool TryDispatchFineArea( { // A single analytic fine pass handles every flush. Aliased coverage is applied per fill inside // the shader from the draw-flags aliased bit, so there is no separate aliased pipeline variant. - PixelAlphaRepresentation alphaRepresentation = flushContext.TargetDescriptor.AlphaRepresentation; - WebGPUTargetNumericEncoding numericEncoding = flushContext.TargetDescriptor.NumericEncoding; - byte[] shaderCode = FineAreaComputeShader.GetCode(flushContext.TextureFormat, alphaRepresentation, numericEncoding); - - bool LayoutFactory(WebGPU api, WGPUDeviceImpl* device, out WGPUBindGroupLayoutImpl* layout, out string? layoutError) - => FineAreaComputeShader.TryCreateBindGroupLayout( - api, - device, + if (!TryResolveFinePipeline( + flushContext.DeviceState, flushContext.TextureFormat, - out layout, - out layoutError); - - if (!flushContext.DeviceState.TryGetOrCreateCompositeComputePipeline( - $"{FineAreaPipelineKey}/{flushContext.TextureFormat}/{alphaRepresentation}/{numericEncoding}", - shaderCode, - FineAreaComputeShader.EntryPoint, - LayoutFactory, + flushContext.TargetDescriptor.AlphaRepresentation, + flushContext.TargetDescriptor.NumericEncoding, out WGPUBindGroupLayoutImpl* bindGroupLayout, out WGPUComputePipelineImpl* pipeline, out error)) @@ -4425,16 +4422,15 @@ bool LayoutFactory(WebGPU api, WGPUDeviceImpl* device, out WGPUBindGroupLayoutIm } /// - /// Queues a background warmup that eagerly compiles every staged-scene compute pipeline for a - /// newly created device, plus the fine pipeline for the common target formats. First-ever use - /// of the pipeline set on a machine pays multi-second driver shader compilation; warming at - /// device creation moves that cost off the first flush and overlaps it with application - /// startup. The pipeline caches are thread-safe, so a flush that arrives mid-warmup simply - /// blocks on the specific pipelines it needs. + /// Queues a background warmup that compiles every scheduling compute pipeline for a newly + /// created device, in parallel. The fine pipeline depends on the target format and is compiled + /// by when a target is created. The pipeline caches are + /// thread-safe, so a flush that arrives mid-warmup waits only for the pipelines it needs. /// /// The shared device state whose pipeline caches are warmed. public static void BeginPipelineWarmup(WebGPURuntime.DeviceSharedState deviceState) { + deviceState.RegisterPipelineWarmup(); bool queued = ThreadPool.UnsafeQueueUserWorkItem( static state => { @@ -4459,74 +4455,133 @@ public static void BeginPipelineWarmup(WebGPURuntime.DeviceSharedState deviceSta } /// - /// Compiles the full staged-scene pipeline set into the shared device caches. Failures are - /// deliberately swallowed: warmup is best-effort and the flush path re-attempts creation with - /// proper error reporting. + /// Queues a background compile of the fine pipeline for one target format. A flush to a target of + /// that format then finds the pipeline ready, or waits only for the remaining part of the compile. + /// + /// The shared device state whose pipeline cache receives the pipeline. + /// The target format, alpha representation, and numeric encoding. + public static unsafe void BeginFinePipelineWarmup(WebGPURuntime.DeviceSharedState deviceState, WebGPUTargetDescriptor targetDescriptor) + { + WebGPUDrawingBackend.GetCompositeTextureFormatInfo(targetDescriptor.Format, out WGPUTextureFormat textureFormat, out _); + if (deviceState.HasCompositeComputePipeline(GetFinePipelineKey(textureFormat, targetDescriptor.AlphaRepresentation, targetDescriptor.NumericEncoding))) + { + return; + } + + deviceState.RegisterPipelineWarmup(); + (WebGPURuntime.DeviceSharedState DeviceState, WGPUTextureFormat TextureFormat, WebGPUTargetDescriptor Descriptor) warmupState = (deviceState, textureFormat, targetDescriptor); + bool queued = ThreadPool.UnsafeQueueUserWorkItem( + static (state) => + { + try + { + _ = TryResolveFinePipeline(state.DeviceState, state.TextureFormat, state.Descriptor.AlphaRepresentation, state.Descriptor.NumericEncoding, out _, out _, out _); + } + catch + { + // Best-effort warmup only; the render path surfaces real pipeline failures. + } + finally + { + state.DeviceState.CompletePipelineWarmup(); + } + }, + warmupState, + false); + + if (!queued) + { + deviceState.CompletePipelineWarmup(); + } + } + + /// + /// Builds the pipeline cache key of the fine pipeline for one target format. + /// + /// The output texture format. + /// The target alpha representation. + /// The target numeric encoding. + /// The cache key. + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private static string GetFinePipelineKey(WGPUTextureFormat textureFormat, PixelAlphaRepresentation alphaRepresentation, WebGPUTargetNumericEncoding numericEncoding) + => $"{FineAreaPipelineKey}/{textureFormat}/{alphaRepresentation}/{numericEncoding}"; + + /// + /// Gets or creates the fine pipeline for one target format. + /// + /// The shared device state that caches the pipeline. + /// The output texture format. + /// The target alpha representation. + /// The target numeric encoding. + /// Receives the fine bind group layout. + /// Receives the fine compute pipeline. + /// Receives the failure reason when the pipeline cannot be created. + /// when the pipeline is available; otherwise, . + private static unsafe bool TryResolveFinePipeline( + WebGPURuntime.DeviceSharedState deviceState, + WGPUTextureFormat textureFormat, + PixelAlphaRepresentation alphaRepresentation, + WebGPUTargetNumericEncoding numericEncoding, + out WGPUBindGroupLayoutImpl* bindGroupLayout, + out WGPUComputePipelineImpl* pipeline, + out string? error) + { + byte[] shaderCode = FineAreaComputeShader.GetCode(textureFormat, alphaRepresentation, numericEncoding); + + bool LayoutFactory(WebGPU api, WGPUDeviceImpl* device, out WGPUBindGroupLayoutImpl* layout, out string? layoutError) + => FineAreaComputeShader.TryCreateBindGroupLayout(api, device, textureFormat, out layout, out layoutError); + + return deviceState.TryGetOrCreateCompositeComputePipeline( + GetFinePipelineKey(textureFormat, alphaRepresentation, numericEncoding), + shaderCode, + FineAreaComputeShader.EntryPoint, + LayoutFactory, + out bindGroupLayout, + out pipeline, + out error); + } + + /// + /// The scheduling shaders in warmup order: the most expensive compiles first, so they start earliest. + /// + private static readonly WebGPUSceneShaderId[] SchedulingWarmupOrder = + [ + WebGPUSceneShaderId.PathLowering, + WebGPUSceneShaderId.Coarse, + WebGPUSceneShaderId.DrawLeaf, + WebGPUSceneShaderId.PathCount, + WebGPUSceneShaderId.PathTiling, + WebGPUSceneShaderId.Binning, + WebGPUSceneShaderId.ClipLeaf, + WebGPUSceneShaderId.TileAlloc, + WebGPUSceneShaderId.PathRowAlloc, + WebGPUSceneShaderId.PathRowSpan, + WebGPUSceneShaderId.Backdrop, + WebGPUSceneShaderId.ClipReduce, + WebGPUSceneShaderId.DrawReduce, + WebGPUSceneShaderId.PathtagReduce, + WebGPUSceneShaderId.PathtagReduce2, + WebGPUSceneShaderId.PathtagScan1, + WebGPUSceneShaderId.PathtagScan, + WebGPUSceneShaderId.PathtagScanSmall, + WebGPUSceneShaderId.BboxClear, + WebGPUSceneShaderId.PathCountSetup, + WebGPUSceneShaderId.PathTilingSetup, + WebGPUSceneShaderId.ChunkReset, + WebGPUSceneShaderId.Prepare, + ]; + + /// + /// Compiles every scheduling pipeline into the shared device caches, in parallel. Failures are + /// swallowed: warmup is best-effort and the flush path re-attempts creation with proper error + /// reporting. /// /// The shared device state whose pipeline caches are warmed. private static unsafe void WarmPipelines(WebGPURuntime.DeviceSharedState deviceState) { try { - // Most expensive shaders first so their driver compilation starts as early as possible. - ReadOnlySpan order = - [ - WebGPUSceneShaderId.PathLowering, - WebGPUSceneShaderId.Coarse, - WebGPUSceneShaderId.DrawLeaf, - WebGPUSceneShaderId.PathCount, - WebGPUSceneShaderId.PathTiling, - WebGPUSceneShaderId.Binning, - WebGPUSceneShaderId.ClipLeaf, - WebGPUSceneShaderId.TileAlloc, - WebGPUSceneShaderId.PathRowAlloc, - WebGPUSceneShaderId.PathRowSpan, - WebGPUSceneShaderId.Backdrop, - WebGPUSceneShaderId.ClipReduce, - WebGPUSceneShaderId.DrawReduce, - WebGPUSceneShaderId.PathtagReduce, - WebGPUSceneShaderId.PathtagReduce2, - WebGPUSceneShaderId.PathtagScan1, - WebGPUSceneShaderId.PathtagScan, - WebGPUSceneShaderId.PathtagScanSmall, - WebGPUSceneShaderId.BboxClear, - WebGPUSceneShaderId.PathCountSetup, - WebGPUSceneShaderId.PathTilingSetup, - WebGPUSceneShaderId.ChunkReset, - WebGPUSceneShaderId.Prepare, - ]; - - // Warm the format/representation pairs used by the default offscreen target and by - // opaque and transparent presentation surfaces. Other supported pairs compile on demand. - ReadOnlySpan<(WGPUTextureFormat Format, PixelAlphaRepresentation AlphaRepresentation, WebGPUTargetNumericEncoding NumericEncoding)> fineTargets = - [ - (WGPUTextureFormat.RGBA8Unorm, PixelAlphaRepresentation.Unassociated, WebGPUTargetNumericEncoding.Unit), - (WGPUTextureFormat.RGBA8Unorm, PixelAlphaRepresentation.Associated, WebGPUTargetNumericEncoding.Unit), - (WGPUTextureFormat.BGRA8Unorm, PixelAlphaRepresentation.Unassociated, WebGPUTargetNumericEncoding.Unit), - (WGPUTextureFormat.BGRA8Unorm, PixelAlphaRepresentation.Associated, WebGPUTargetNumericEncoding.Unit) - ]; - - foreach ((WGPUTextureFormat format, PixelAlphaRepresentation alphaRepresentation, WebGPUTargetNumericEncoding numericEncoding) in fineTargets) - { - byte[] shaderCode = FineAreaComputeShader.GetCode(format, alphaRepresentation, numericEncoding); - - bool LayoutFactory(WebGPU api, WGPUDeviceImpl* device, out WGPUBindGroupLayoutImpl* layout, out string? layoutError) - => FineAreaComputeShader.TryCreateBindGroupLayout(api, device, format, out layout, out layoutError); - - _ = deviceState.TryGetOrCreateCompositeComputePipeline( - $"{FineAreaPipelineKey}/{format}/{alphaRepresentation}/{numericEncoding}", - shaderCode, - FineAreaComputeShader.EntryPoint, - LayoutFactory, - out _, - out _, - out _); - } - - foreach (WebGPUSceneShaderId shaderId in order) - { - _ = TryResolveComputeShader(deviceState, shaderId, out _, out _, out _); - } + _ = Parallel.ForEach(SchedulingWarmupOrder, shaderId => TryResolveComputeShader(deviceState, shaderId, out _, out _, out _)); } catch { diff --git a/src/ImageSharp.Drawing.WebGPU/WebGPUSceneEncoder.cs b/src/ImageSharp.Drawing.WebGPU/WebGPUSceneEncoder.cs index 68485d2b..97304230 100644 --- a/src/ImageSharp.Drawing.WebGPU/WebGPUSceneEncoder.cs +++ b/src/ImageSharp.Drawing.WebGPU/WebGPUSceneEncoder.cs @@ -2378,6 +2378,7 @@ public bool TryAppend(in StrokeLineSegmentCommand command, out string? error) resolved.BrushBounds, resolved.Pen, widthScale, + GetResidualStretch(segmentResidual), start, end); error = null; @@ -2422,6 +2423,7 @@ public bool TryAppend( resolved.BrushBounds, resolved.Pen, widthScale, + GetResidualStretch(polylineResidual), geometry); error = null; return true; @@ -2683,6 +2685,7 @@ private void AppendPlainStroke( Vector2 scale = MatrixUtilities.GetScale(command.Transform); geometry ??= command.Path.ToLinearGeometry(scale); float widthScale = GetTransformWidthScale(command.Transform); + float residualStretch = GetResidualStretch(MatrixUtilities.GetResidual(scale, command.Transform)); uint drawTag = GetDrawTag(brush); GpuSceneDrawMonoid drawTagMonoid = GpuSceneDrawTag.Map(drawTag); Rectangle interestBounds = ToTargetLocal(command.RasterizerOptions.Interest, this.rootTargetBounds); @@ -2735,10 +2738,12 @@ private void AppendPlainStroke( pathDataOffset, pen, widthScale, + residualStretch, this.rootTargetBounds, ref this.PathTags, ref this.PathData, - out int geometryLineCount); + out int geometryLineCount, + out long geometryTileCrossings); if (encodedPathCount == 0) { @@ -2758,7 +2763,7 @@ private void AppendPlainStroke( this.lastStyle7 = style7; this.lastStyle8 = style8; this.lastStyle9 = style9; - this.AccumulateDrawRowEstimate(command.RasterizerOptions.Interest, geometryLineCount, -1); + this.AccumulateDrawRowEstimate(command.RasterizerOptions.Interest, geometryLineCount, geometryTileCrossings); this.FillCount++; this.PathCount += encodedPathCount; this.LineCount += geometryLineCount; @@ -2793,6 +2798,7 @@ private void AppendPlainStroke( /// The absolute brush sampling bounds. /// The pen that defines stroke width, joins, and caps. /// The transform-derived scale applied to the stroke width. + /// The largest length scale of the residual transform the GPU applies to the points. /// The prepared open centerline geometry. private void AppendExplicitStroke( Brush brush, @@ -2802,6 +2808,7 @@ private void AppendExplicitStroke( Rectangle brushBounds, Pen pen, float widthScale, + float residualStretch, LinearGeometry geometry) { uint drawTag = GetDrawTag(brush); @@ -2856,10 +2863,12 @@ private void AppendExplicitStroke( pathDataOffset, pen, widthScale, + residualStretch, this.rootTargetBounds, ref this.PathTags, ref this.PathData, - out int geometryLineCount); + out int geometryLineCount, + out long geometryTileCrossings); if (encodedPathCount == 0) { @@ -2879,7 +2888,7 @@ private void AppendExplicitStroke( this.lastStyle7 = style7; this.lastStyle8 = style8; this.lastStyle9 = style9; - this.AccumulateDrawRowEstimate(rasterizerOptions.Interest, geometryLineCount, -1); + this.AccumulateDrawRowEstimate(rasterizerOptions.Interest, geometryLineCount, geometryTileCrossings); this.FillCount++; this.PathCount += encodedPathCount; this.LineCount += geometryLineCount; @@ -2914,6 +2923,7 @@ private void AppendExplicitStroke( /// The absolute brush sampling bounds. /// The pen that defines stroke width, joins, and caps. /// The transform-derived scale applied to the stroke width. + /// The largest length scale of the residual transform the GPU applies to the points. /// The segment start point with the transform scale applied. /// The segment end point with the transform scale applied. private void AppendExplicitStroke( @@ -2924,6 +2934,7 @@ private void AppendExplicitStroke( Rectangle brushBounds, Pen pen, float widthScale, + float residualStretch, PointF start, PointF end) { @@ -2979,10 +2990,12 @@ private void AppendExplicitStroke( pathDataOffset, pen, widthScale, + residualStretch, this.rootTargetBounds, ref this.PathTags, ref this.PathData, - out int geometryLineCount); + out int geometryLineCount, + out long geometryTileCrossings); if (encodedPathCount == 0) { @@ -3002,7 +3015,7 @@ private void AppendExplicitStroke( this.lastStyle7 = style7; this.lastStyle8 = style8; this.lastStyle9 = style9; - this.AccumulateDrawRowEstimate(rasterizerOptions.Interest, geometryLineCount, -1); + this.AccumulateDrawRowEstimate(rasterizerOptions.Interest, geometryLineCount, geometryTileCrossings); this.FillCount++; this.PathCount += encodedPathCount; this.LineCount += geometryLineCount; @@ -3434,8 +3447,8 @@ private void AppendBeginLayer(in CompositionCommand command) /// The draw object's absolute raster interest bounds. /// The draw object's linearized or GPU-expanded line count. /// - /// The exact summed per-line tile-crossing bound when the caller linearized the geometry on the CPU - /// (fills), or a negative value to fall back to the bounding-box diagonal bound (strokes, clips). + /// The summed per-line tile-crossing bound of the geometry (fills and strokes), or a negative value + /// to fall back to the bounding-box diagonal bound (clips). /// private void AccumulateDrawRowEstimate(Rectangle absoluteInterest, int lineCount, long tileCrossings = -1) => this.AccumulateDrawRowEstimateLocal(ToTargetLocal(absoluteInterest, this.rootTargetBounds), lineCount, tileCrossings); @@ -3448,8 +3461,8 @@ private void AccumulateDrawRowEstimate(Rectangle absoluteInterest, int lineCount /// The draw object's root-target-local raster interest bounds. /// The draw object's linearized or GPU-expanded line count. /// - /// The exact summed per-line tile-crossing bound when the caller linearized the geometry on the CPU - /// (fills), or a negative value to fall back to the bounding-box diagonal bound (strokes, clips). + /// The summed per-line tile-crossing bound of the geometry (fills and strokes), or a negative value + /// to fall back to the bounding-box diagonal bound (clips). /// private void AccumulateDrawRowEstimateLocal(Rectangle localBounds, int lineCount, long tileCrossings = -1) { @@ -5356,25 +5369,32 @@ private static float Sum(Vector2 value) /// The absolute destination offset applied to every point. /// The pen that defines stroke width, joins, and caps. /// The transform-derived scale applied to the stroke width. + /// The largest length scale of the residual transform the GPU applies to the points. /// The root target bounds used for target-local conversion. /// The path-tag stream. /// 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. /// The number of encoded path objects: 1, or 0 when no contour survived preprocessing. private static int EncodeStrokePath( LinearGeometry geometry, Point destinationOffset, Pen pen, float widthScale, + float residualStretch, in Rectangle rootTargetBounds, ref OwnedStream pathTags, ref OwnedStream pathData, - out int lineCount) + out int lineCount, + out long tileCrossings) { float pointTranslateX = destinationOffset.X - rootTargetBounds.X; float pointTranslateY = destinationOffset.Y - rootTargetBounds.Y; lineCount = EstimateStrokeLineCount(geometry, pen, widthScale); float strokeWidth = pen.StrokeWidth * widthScale; + float halfWidth = strokeWidth * 0.5F; + float capChainLength = GetStrokeCapChainLength(pen, halfWidth); + double outlineLength = 0D; int encodedContourCount = 0; LinearContour[] contours = (LinearContour[])geometry.Contours; PointF[] geometryPoints = (PointF[])geometry.Points; @@ -5395,17 +5415,40 @@ private static int EncodeStrokePath( PointF lastKept = firstPoint; int pointIndex = 1; int keptCount = 1; + Vector2 firstDirection = default; + float firstLength = 0F; + Vector2 previousDirection = default; + float previousLength = 0F; + double contourOutlineLength = 0D; // Count the filtered points and retain only the few values needed to classify the // contour. The second pass writes them directly to the scene streams, so no temporary // point buffer is rented for each stroke geometry. - while (TryGetNextStrokePoint(contourPoints, ref pointIndex, ref previousKept, ref pointLike, out PointF point)) + while (true) { + PointF segmentStart = previousKept; + if (!TryGetNextStrokePoint(contourPoints, ref pointIndex, ref previousKept, ref pointLike, out PointF point)) + { + break; + } + + Vector2 segment = (Vector2)point - (Vector2)segmentStart; + float segmentLength = segment.Length(); + Vector2 direction = segment / segmentLength; + contourOutlineLength += 2D * segmentLength; if (keptCount == 1) { secondPoint = point; + firstDirection = direction; + firstLength = segmentLength; + } + else + { + contourOutlineLength += GetStrokeJoinChainLength(pen, halfWidth, previousDirection, previousLength, direction, segmentLength); } + previousDirection = direction; + previousLength = segmentLength; lastKept = point; keptCount++; } @@ -5430,6 +5473,7 @@ private static int EncodeStrokePath( if (segmentCount == 0) { EncodePointStrokeContour(pointLike, pointTranslateX, pointTranslateY, ref pathTags, ref pathData); + outlineLength += (4D * PointStrokeSegmentHalfLength) + (2D * capChainLength); encodedContourCount++; continue; } @@ -5439,6 +5483,7 @@ private static int EncodeStrokePath( // The CPU stroker emits these as one capped open segment even when declared closed. Span segmentPoints = [firstPoint, secondPoint]; EncodeOpenStrokeContour(segmentPoints, pointTranslateX, pointTranslateY, ref pathTags, ref pathData); + outlineLength += (2D * Vector2.Distance(firstPoint, secondPoint)) + (2D * capChainLength); encodedContourCount++; continue; } @@ -5453,6 +5498,21 @@ private static int EncodeStrokePath( (segmentCount > 1 && Vector2.DistanceSquared(lastKept, firstPoint) > StrokeMicroSegmentEpsilon * StrokeMicroSegmentEpsilon); + if (closingSegment && !duplicateClosingPoint) + { + 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); + } + else + { + contourOutlineLength += GetStrokeJoinChainLength(pen, halfWidth, previousDirection, previousLength, firstDirection, firstLength); + } + + outlineLength += contourOutlineLength; int linetoCount = (emitCount - 1) + (closingSegment ? 1 : 0); Span contourData = pathData.GetAppendSpan(2 + (linetoCount * 2) + 2); Span contourTags = pathTags.GetAppendSpan(linetoCount + 1); @@ -5498,6 +5558,7 @@ private static int EncodeStrokePath( } int openLinetoCount = keptCount - 1; + outlineLength += contourOutlineLength + (2D * capChainLength); Span openData = pathData.GetAppendSpan(2 + (openLinetoCount * 2) + 4); Span openTags = pathTags.GetAppendSpan(openLinetoCount + 1); int openDataIndex = 0; @@ -5529,6 +5590,7 @@ private static int EncodeStrokePath( encodedContourCount++; } + tileCrossings = BoundStrokeTileCrossings(outlineLength, residualStretch, lineCount); if (encodedContourCount == 0) { return 0; @@ -5670,10 +5732,12 @@ private static void EncodePointStrokeContour( /// The absolute destination offset applied to every point. /// The pen that defines stroke width, joins, and caps. /// The transform-derived scale applied to the stroke width. + /// The largest length scale of the residual transform the GPU applies to the points. /// The root target bounds used for target-local conversion. /// The path-tag stream. /// 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. /// The number of encoded path objects; always 1. private static int EncodeOpenSegmentStrokePath( PointF start, @@ -5681,10 +5745,12 @@ private static int EncodeOpenSegmentStrokePath( Point destinationOffset, Pen pen, float widthScale, + float residualStretch, in Rectangle rootTargetBounds, ref OwnedStream pathTags, ref OwnedStream pathData, - out int lineCount) + out int lineCount, + out long tileCrossings) { float pointTranslateX = destinationOffset.X - rootTargetBounds.X; float pointTranslateY = destinationOffset.Y - rootTargetBounds.Y; @@ -5693,6 +5759,9 @@ private static int EncodeOpenSegmentStrokePath( EncodeOpenStrokeContour(segmentPoints, pointTranslateX, pointTranslateY, ref pathTags, ref pathData); pathTags.Add(PackPathTag(PathTag.Path)); lineCount = EstimateStrokeLineCountForOpenSegment(pen, widthScale); + float halfWidth = pen.StrokeWidth * widthScale * 0.5F; + double outlineLength = (2D * Vector2.Distance(start, end)) + (2D * GetStrokeCapChainLength(pen, halfWidth)); + tileCrossings = BoundStrokeTileCrossings(outlineLength, residualStretch, lineCount); return 1; } @@ -6224,6 +6293,77 @@ private static int EstimateStrokeLineCount(LinearGeometry geometry, Pen pen, flo private static int EstimateStrokeLineCountForOpenSegment(Pen pen, float widthScale) => Math.Max(2 + (GetStrokeCapLineCost(pen, widthScale) * 2), 1); + /// + /// Bounds the tile crossings of the lines the GPU stroker emits for one stroke. One line crosses at most + /// (|dx| + |dy|) / 16 + 3 tiles, so the emitted outline crosses at most sqrt(2) times its device-space + /// length in tiles plus three per line. + /// + /// The bound on the emitted outline length before the residual transform. + /// The largest length scale of the residual transform the GPU applies to the points. + /// The estimated emitted line count. + /// The tile-crossing bound. + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private static long BoundStrokeTileCrossings(double outlineLength, float residualStretch, int lineCount) + => (long)Math.Ceiling(outlineLength * residualStretch * Math.Sqrt(2D) / TileWidth) + (3L * lineCount); + + /// + /// Returns the largest length scale of a residual transform: the Frobenius norm of its 2x2 part, which is + /// at least its largest singular value. + /// + /// The residual transform the GPU applies to the points. + /// The length scale; 1 for the identity. + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private static float GetResidualStretch(Matrix4x4 residual) + => residual.IsIdentity + ? 1F + : MathF.Sqrt((residual.M11 * residual.M11) + (residual.M12 * residual.M12) + (residual.M21 * residual.M21) + (residual.M22 * residual.M22)); + + /// + /// Returns the length of the line chain the GPU stroker emits for one cap. + /// + /// The pen that defines the cap style. + /// Half the transform-scaled stroke width. + /// The chain length in pixels. + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private static float GetStrokeCapChainLength(Pen pen, float halfWidth) + => pen.StrokeOptions.LineCap switch + { + LineCap.Square => 4F * halfWidth, + LineCap.Round => MathF.PI * halfWidth, + _ => 2F * 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. + /// + /// The pen that defines the join style and miter limit. + /// Half the transform-scaled stroke width. + /// The unit direction of the segment entering the join. + /// 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. + /// The chain length bound in pixels. + private static float GetStrokeJoinChainLength(Pen pen, float halfWidth, Vector2 incoming, float incomingLength, Vector2 outgoing, float outgoingLength) + { + 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)); + float miterLimit = (float)Math.Max(pen.StrokeOptions.MiterLimit, 1D); + float outerExtent = pen.StrokeOptions.LineJoin switch + { + LineJoin.Miter => halfWidth * (1F + miterLimit), + LineJoin.MiterRevert => MathF.Max(MathF.Min(miterApex, halfWidth * miterLimit), 2F * halfWidth), + LineJoin.MiterRound => MathF.Max(MathF.Min(miterApex, halfWidth * miterLimit), MathF.PI * halfWidth), + LineJoin.Round => MathF.PI * halfWidth, + _ => 2F * halfWidth + }; + + return (2F * (outerExtent + halfWidth)) + (2F * (innerExtent + halfWidth)); + } + /// /// Returns the conservative final line cost of one stroke join. /// From 1536258710e11ec4fbafba057b706a86a32ec924 Mon Sep 17 00:00:00 2001 From: James Jackson-South Date: Tue, 15 Sep 2026 17:30:57 +1000 Subject: [PATCH 2/3] Shrink PTCL commands and compose paint once per command CMD_FILL and CMD_SOLID reference their raster interest rectangle by its info-stream offset instead of copying four words into every tile. CMD_COLOR references the packed colour in the scene stream and the draw flags in the info stream. Fill goes from 9 words to 6, solid from 5 to 2, colour from 4 to 3. The PTCL seed bound drops from 14 to 11 words per crossing. The fine shader stores each paint command's per-pixel colour and coverage and composes them at one site after the command switch. One compose site keeps the blend-mode switch from being inlined into every paint case, which cuts the cold fine compile from 1.8 s to 1.1 s. --- .../Shaders/WgslSource/Shared/ptcl.wgsl | 12 ++- .../Shaders/WgslSource/coarse.wgsl | 71 ++++++------- .../Shaders/WgslSource/fine.wgsl | 100 ++++++++++++------ .../WebGPUSceneDispatch.cs | 4 +- 4 files changed, 112 insertions(+), 75 deletions(-) diff --git a/src/ImageSharp.Drawing.WebGPU/Shaders/WgslSource/Shared/ptcl.wgsl b/src/ImageSharp.Drawing.WebGPU/Shaders/WgslSource/Shared/ptcl.wgsl index 00ee4192..ff676e05 100644 --- a/src/ImageSharp.Drawing.WebGPU/Shaders/WgslSource/Shared/ptcl.wgsl +++ b/src/ImageSharp.Drawing.WebGPU/Shaders/WgslSource/Shared/ptcl.wgsl @@ -38,11 +38,17 @@ const CMD_END_CLIP = 11u; const CMD_JUMP = 12u; const CMD_PATH_GRAD = 16u; +// 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. +const CMD_INTEREST_FULL_TARGET = 0xffffffffu; + // The individual PTCL structs are written here, but read/write is by // hand in the relevant shaders // Fill coverage command: rasterize the tile's segment slice into coverage. -// Written by coarse write_path, read by fine read_fill. +// Written by coarse write_path, read by fine read_fill. The command holds five words after +// the tag: size_and_rule, seg_data, backdrop, coverage_data, and the info-stream offset of +// the interest rectangle, which read_fill resolves into the vec4 below. struct CmdFill { size_and_rule: u32, // bit 0 = even-odd, bit 1 = aliased coverage, bits 2.. = segment count seg_data: u32, // index of the tile's first Segment in segment storage @@ -59,7 +65,9 @@ struct CmdJump { new_ix: u32, } -// Solid color paint. +// Solid color paint. The command holds two words after the tag: the scene offset of the two +// packed color words and the info offset of the draw flags, which read_color resolves into +// the struct below. struct CmdColor { color_rg: u32, // associated RG packed as binary16 color_ba: u32, // associated BA packed as binary16 diff --git a/src/ImageSharp.Drawing.WebGPU/Shaders/WgslSource/coarse.wgsl b/src/ImageSharp.Drawing.WebGPU/Shaders/WgslSource/coarse.wgsl index d81e84b2..7f37732b 100644 --- a/src/ImageSharp.Drawing.WebGPU/Shaders/WgslSource/coarse.wgsl +++ b/src/ImageSharp.Drawing.WebGPU/Shaders/WgslSource/coarse.wgsl @@ -144,7 +144,7 @@ fn solid_tile_has_coverage(draw_flags: u32, backdrop: i32) -> bool { // When emit_empty_solid is false, solid tiles whose backdrop resolves to // zero coverage are skipped entirely. Returns true when a command was // written, meaning the caller should emit the matching paint command. -fn write_path(tile: Tile, tile_ix: u32, path: Path, global_x: u32, global_y: u32, draw_flags: u32, coverage_data: f32, interest: vec4, emit_empty_solid: bool) -> bool { +fn write_path(tile: Tile, tile_ix: u32, path: Path, global_x: u32, global_y: u32, draw_flags: u32, coverage_data: f32, interest_ref: u32, emit_empty_solid: bool) -> bool { // We overload the "segments" field to store both count (written by // path_count stage) and segment allocation (used by path_tiling and // fine). @@ -152,7 +152,7 @@ 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(9u); + 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; @@ -186,44 +186,37 @@ fn write_path(tile: Tile, tile_ix: u32, path: Path, global_x: u32, global_y: u32 // size_and_rule: bit 0 = even-odd, bit 1 = aliased coverage, bits 2.. = segment count. let size_and_rule = (n_segs << 2u) | (u32(aliased) << 1u) | u32(even_odd); - let fill = CmdFill(size_and_rule, seg_ix, tile.backdrop, packed_coverage_data, interest); - ptcl[cmd_offset + 1u] = fill.size_and_rule; - ptcl[cmd_offset + 2u] = fill.seg_data; - ptcl[cmd_offset + 3u] = u32(fill.backdrop); - ptcl[cmd_offset + 4u] = fill.coverage_data; - ptcl[cmd_offset + 5u] = bitcast(fill.interest.x); - ptcl[cmd_offset + 6u] = bitcast(fill.interest.y); - ptcl[cmd_offset + 7u] = bitcast(fill.interest.z); - ptcl[cmd_offset + 8u] = bitcast(fill.interest.w); + ptcl[cmd_offset + 1u] = size_and_rule; + ptcl[cmd_offset + 2u] = seg_ix; + ptcl[cmd_offset + 3u] = u32(tile.backdrop); + ptcl[cmd_offset + 4u] = packed_coverage_data; + ptcl[cmd_offset + 5u] = interest_ref; // The winding backdrop is now in PTCL. Reuse its tile field for the original segment // count so fine can read adjacent slices after segment_count_or_ix becomes the allocation. tiles[tile_ix].backdrop = i32(n_segs); - cmd_offset += 9u; + cmd_offset += 6u; return true; } else { if !emit_empty_solid && !solid_tile_has_coverage(draw_flags, tile.backdrop) { return false; } - alloc_cmd(5u); + alloc_cmd(2u); ptcl[cmd_offset] = CMD_SOLID; - ptcl[cmd_offset + 1u] = bitcast(interest.x); - ptcl[cmd_offset + 2u] = bitcast(interest.y); - ptcl[cmd_offset + 3u] = bitcast(interest.z); - ptcl[cmd_offset + 4u] = bitcast(interest.w); - cmd_offset += 5u; + ptcl[cmd_offset + 1u] = interest_ref; + cmd_offset += 2u; return true; } } -// Emits a CMD_COLOR paint command (binary16 RGBA color plus draw flags). -fn write_color(color: CmdColor) { - alloc_cmd(4u); +// Emits a CMD_COLOR paint command referencing the draw's packed color words in the scene +// stream and its draw flags in the info stream. +fn write_color(scene_offset: u32, info_offset: u32) { + alloc_cmd(3u); ptcl[cmd_offset] = CMD_COLOR; - ptcl[cmd_offset + 1u] = color.color_rg; - ptcl[cmd_offset + 2u] = color.color_ba; - ptcl[cmd_offset + 3u] = color.draw_flags; - cmd_offset += 4u; + ptcl[cmd_offset + 1u] = scene_offset; + ptcl[cmd_offset + 2u] = info_offset; + cmd_offset += 3u; } // Emits a CMD_RECOLOR command referencing one target-specialized auxiliary record. @@ -597,18 +590,14 @@ fn main( let di = dm.info_offset; let draw_flags = info_bin_data[di]; var coverage_data = 0.0; - var interest = vec4(0.0, 0.0, f32(config.target_width), f32(config.target_height)); + var interest_ref = CMD_INTEREST_FULL_TARGET; // Draw tags whose info block spans at least five words append a // coverage data plus interest rectangle at the end of it. let drawtag_info_size = (drawtag >> 6u) & 0xfu; if drawtag_info_size >= 5u { let interest_offset = di + drawtag_info_size - 5u; coverage_data = bitcast(info_bin_data[interest_offset]); - interest = vec4( - bitcast(info_bin_data[interest_offset + 1u]), - bitcast(info_bin_data[interest_offset + 2u]), - bitcast(info_bin_data[interest_offset + 3u]), - bitcast(info_bin_data[interest_offset + 4u])); + interest_ref = interest_offset + 1u; } if clip_zero_depth == 0u { @@ -622,50 +611,50 @@ fn main( let tile = tiles[tile_ix]; switch drawtag { case DRAWTAG_FILL_COLOR: { - if write_path(tile, tile_ix, path, bin_tile_x + tile_x, bin_tile_y + tile_y, draw_flags, coverage_data, interest, false) { - write_color(CmdColor(scene[dd], scene[dd + 1u], draw_flags)); + 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_color(dd, di); } } 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, false) { + 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); } } 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, false) { + 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 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, false) { + 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 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, false) { + 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 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, false) { + 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 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, false) { + 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); } } case DRAWTAG_FILL_IMAGE: { - if write_path(tile, tile_ix, path, bin_tile_x + tile_x, bin_tile_y + tile_y, draw_flags, coverage_data, interest, false) { + 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_image(di + 1u); } } @@ -689,7 +678,7 @@ fn main( case DRAWTAG_END_CLIP: { clip_depth -= 1u; let blend = scene[dd]; - write_path(tile, tile_ix, path, bin_tile_x + tile_x, bin_tile_y + tile_y, draw_flags, coverage_data, interest, true); + write_path(tile, tile_ix, path, bin_tile_x + tile_x, bin_tile_y + tile_y, draw_flags, coverage_data, interest_ref, true); let alpha = bitcast(scene[dd + 1u]); write_end_clip(CmdEndClip(blend, alpha)); render_blend_depth -= 1u; diff --git a/src/ImageSharp.Drawing.WebGPU/Shaders/WgslSource/fine.wgsl b/src/ImageSharp.Drawing.WebGPU/Shaders/WgslSource/fine.wgsl index 49572376..6741f2e1 100644 --- a/src/ImageSharp.Drawing.WebGPU/Shaders/WgslSource/fine.wgsl +++ b/src/ImageSharp.Drawing.WebGPU/Shaders/WgslSource/fine.wgsl @@ -95,26 +95,36 @@ fn read_fill(cmd_ix: u32) -> CmdFill { let seg_data = ptcl[cmd_ix + 2u]; let backdrop = i32(ptcl[cmd_ix + 3u]); let coverage_data = ptcl[cmd_ix + 4u]; - let interest = vec4( - bitcast(ptcl[cmd_ix + 5u]), - bitcast(ptcl[cmd_ix + 6u]), - bitcast(ptcl[cmd_ix + 7u]), - bitcast(ptcl[cmd_ix + 8u])); + let interest = resolve_interest(ptcl[cmd_ix + 5u]); return CmdFill(size_and_rule, seg_data, backdrop, coverage_data, interest); } +// Resolves the raster interest rectangle referenced by a CMD_FILL or CMD_SOLID: the four +// words at the info-stream offset, or the whole target for CMD_INTEREST_FULL_TARGET. +fn resolve_interest(interest_ref: u32) -> vec4 { + if interest_ref == CMD_INTEREST_FULL_TARGET { + return vec4(0.0, 0.0, f32(config.target_width), f32(config.target_height)); + } + + return vec4( + bitcast(info[interest_ref]), + bitcast(info[interest_ref + 1u]), + bitcast(info[interest_ref + 2u]), + bitcast(info[interest_ref + 3u])); +} + // Expands one RGBA color stored as two binary16 pairs. Brush payloads use // binary16 so RgbaHalf targets are not prematurely reduced to RGBA8. fn unpack_color_f16(rg: u32, ba: u32) -> vec4 { return vec4(unpack2x16float(rg), unpack2x16float(ba)); } -// Decodes a CMD_COLOR payload: binary16 associated color and draw flags. +// Decodes a CMD_COLOR payload: the binary16 associated color from the scene stream and the +// draw flags from the info stream, both by offset. fn read_color(cmd_ix: u32) -> CmdColor { - let color_rg = ptcl[cmd_ix + 1u]; - let color_ba = ptcl[cmd_ix + 2u]; - let draw_flags = ptcl[cmd_ix + 3u]; - return CmdColor(color_rg, color_ba, draw_flags); + let scene_offset = ptcl[cmd_ix + 1u]; + let info_offset = ptcl[cmd_ix + 2u]; + return CmdColor(scene_data[scene_offset], scene_data[scene_offset + 1u], info[info_offset]); } // Decodes a CMD_RECOLOR reference to its target-specialized auxiliary record. @@ -1167,6 +1177,13 @@ fn main( var blend_stack: array, PIXELS_PER_THREAD>, BLEND_STACK_SPLIT>; var clip_depth = 0u; var area: array; + // Paint commands store their per-pixel source color and coverage here, and the loop tail + // composes them at one site. A compose call inlines the blend-mode switch, so one site per + // paint command would multiply the shader's compile time. + var paint_color: array, PIXELS_PER_THREAD>; + var paint_coverage: array; + var paint_flags = 0u; + var paint_pending = false; var cmd_ix = tile_ix * PTCL_INITIAL_ALLOC; // The first word of each tile's PTCL slot is its blend spill offset. let blend_offset = ptcl[cmd_ix]; @@ -1181,31 +1198,28 @@ fn main( case CMD_FILL: { let fill = read_fill(cmd_ix); fill_path(fill, local_xy, xy, &area); - cmd_ix += 9u; + cmd_ix += 6u; } case CMD_SOLID: { // Full coverage, restricted to the command's raster interest // rectangle. - let interest = vec4( - bitcast(ptcl[cmd_ix + 1u]), - bitcast(ptcl[cmd_ix + 2u]), - bitcast(ptcl[cmd_ix + 3u]), - bitcast(ptcl[cmd_ix + 4u])); + let interest = resolve_interest(ptcl[cmd_ix + 1u]); for (var i = 0u; i < PIXELS_PER_THREAD; i += 1u) { let pixel = xy + vec2(f32(i), 0.0); area[i] = select(0.0, 1.0, pixel.x >= interest.x && pixel.y >= interest.y && pixel.x < interest.z && pixel.y < interest.w); } - cmd_ix += 5u; + cmd_ix += 2u; } case CMD_COLOR: { let color = read_color(cmd_ix); let fg = decode_paint_color(unpack_color_f16(color.color_rg, color.color_ba)); for (var i = 0u; i < PIXELS_PER_THREAD; i += 1u) { - if area[i] != 0.0 { - rgba[i] = compose_draw_with_coverage(rgba[i], fg, area[i], color.draw_flags); - } + paint_color[i] = fg; + paint_coverage[i] = area[i]; } - cmd_ix += 4u; + paint_flags = color.draw_flags; + paint_pending = true; + cmd_ix += 3u; } case CMD_RECOLOR: { let recolor = read_recolor(cmd_ix); @@ -1332,6 +1346,7 @@ fn main( 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; for (var i = 0u; i < PIXELS_PER_THREAD; i += 1u) { + paint_coverage[i] = area[i]; if area[i] != 0.0 { let my_d = d + lin.line_x * f32(i); let t = extend_mode_normalized(my_d, lin.extend_mode); @@ -1344,9 +1359,11 @@ fn main( // CPU gradient brushes return a transparent overlay for DontFill samples, // then blend it normally. Destructive composition modes must therefore // still run when no ramp texel is selected. - rgba[i] = compose_draw_with_coverage(rgba[i], fg_rgba, area[i], draw_flags); + paint_color[i] = fg_rgba; } } + paint_flags = draw_flags; + paint_pending = true; cmd_ix += 3u; } case CMD_RAD_GRAD: { @@ -1403,14 +1420,18 @@ fn main( // Invalid conical solutions and DontFill both produce the transparent // overlay that the CPU still sends through coverage and composition. - rgba[i] = compose_draw_with_coverage(rgba[i], fg_rgba, area[i], draw_flags); + paint_color[i] = fg_rgba; + paint_coverage[i] = area[i]; } + paint_flags = draw_flags; + paint_pending = true; cmd_ix += 3u; } case CMD_ELLIPTIC_GRAD: { let elliptic = read_elliptic_grad(cmd_ix); let draw_flags = info[ptcl[cmd_ix + 2u] - 1u]; 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); if elliptic.kind == ELLIPTIC_GRAD_KIND_NORMAL { @@ -1428,7 +1449,7 @@ fn main( } } - rgba[i] = compose_draw_with_coverage(rgba[i], fg_rgba, area[i], draw_flags); + paint_color[i] = fg_rgba; } else { // Keep the CPU order of operations for a collapsed ellipse: subtract // the center first, then rotate. An affine translation would introduce @@ -1448,10 +1469,12 @@ fn main( fg_rgba = textureLoad(gradients, vec2(i32(GRADIENT_WIDTH - 1), i32(elliptic.index)), 0); } - rgba[i] = compose_draw_with_coverage(rgba[i], fg_rgba, area[i], draw_flags); + paint_color[i] = fg_rgba; } } } + paint_flags = draw_flags; + paint_pending = true; cmd_ix += 3u; } case CMD_SWEEP_GRAD: { @@ -1459,6 +1482,7 @@ fn main( let draw_flags = info[ptcl[cmd_ix + 2u] - 1u]; let scale = 1.0 / (sweep.t1 - sweep.t0); 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 local_xy = sweep.matrx.xy * my_xy.x + sweep.matrx.zw * my_xy.y + sweep.xlat; @@ -1496,26 +1520,31 @@ fn main( // DontFill is a transparent brush sample, not an omitted draw, so it // still participates in Src, Clear, and every other composition mode. - rgba[i] = compose_draw_with_coverage(rgba[i], fg_rgba, area[i], draw_flags); + paint_color[i] = fg_rgba; } } + paint_flags = draw_flags; + paint_pending = true; cmd_ix += 3u; } case CMD_PATH_GRAD: { let path_grad = read_path_grad(cmd_ix); 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 fg_rgba = evaluate_path_gradient(path_grad, my_xy); - rgba[i] = compose_draw_with_coverage(rgba[i], fg_rgba, area[i], path_grad.draw_flags); + paint_color[i] = evaluate_path_gradient(path_grad, my_xy); } } + paint_flags = path_grad.draw_flags; + paint_pending = true; cmd_ix += 5u; } case CMD_IMAGE: { let image = read_image(cmd_ix); let draw_flags = info[ptcl[cmd_ix + 1u] - 1u]; for (var i = 0u; i < PIXELS_PER_THREAD; i += 1u) { + 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); @@ -1531,15 +1560,26 @@ fn main( // sources both enter the common composition space. let atlas_color = decode_image_numeric(textureLoad(image_atlas, atlas_uv_clamped, 0), image.signed_unit); let fg_rgba = maybe_premul_alpha(atlas_color, image.alpha_type); - let fg_i = pixel_format(fg_rgba * image.alpha, image.format); - rgba[i] = compose_draw_with_coverage(rgba[i], fg_i, area[i], draw_flags); + paint_color[i] = pixel_format(fg_rgba * image.alpha, image.format); + paint_coverage[i] = area[i]; } } } + paint_flags = draw_flags; + paint_pending = true; cmd_ix += 2u; } default: {} } + + if paint_pending { + for (var i = 0u; i < PIXELS_PER_THREAD; i += 1u) { + if paint_coverage[i] != 0.0 { + rgba[i] = compose_draw_with_coverage(rgba[i], paint_color[i], paint_coverage[i], paint_flags); + } + } + paint_pending = false; + } } for (var i = 0u; i < PIXELS_PER_THREAD; i += 1u) { let coords = xy_uint + vec2(i, 0u); diff --git a/src/ImageSharp.Drawing.WebGPU/WebGPUSceneDispatch.cs b/src/ImageSharp.Drawing.WebGPU/WebGPUSceneDispatch.cs index b81a7e5e..417ba81d 100644 --- a/src/ImageSharp.Drawing.WebGPU/WebGPUSceneDispatch.cs +++ b/src/ImageSharp.Drawing.WebGPU/WebGPUSceneDispatch.cs @@ -69,8 +69,8 @@ internal static class WebGPUSceneDispatch // The PTCL word budget attributed to each estimated tile crossing when seeding the PTCL // scratch capacity. Every crossing belongs to one (draw, tile) pair with segments, and such a - // pair writes one CMD_FILL (9 words) plus one paint command of at most 5 words. - private const long PtclWordsPerCrossing = 14; + // pair writes one CMD_FILL (6 words) plus one paint command of at most 5 words. + private const long PtclWordsPerCrossing = 11; // 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 From 620eae3936b86621a39347dbd47df250ec45d046 Mon Sep 17 00:00:00 2001 From: James Jackson-South Date: Tue, 15 Sep 2026 17:48:26 +1000 Subject: [PATCH 3/3] Seed the PTCL scratch from each draw's paint command size The encoder accumulates a PTCL word bound per draw: one CMD_FILL (6 words) plus the paint command coarse writes for that draw tag, times the draw's tile-crossing bound. The bound flows through partitions, checkpoints, ranges, and the scene, and replaces the fixed 11 words per crossing in the scratch seed. A solid-colour scene now seeds 9 words per crossing. --- .../WebGPUSceneDispatch.cs | 11 ++- .../WebGPUSceneEncoder.cs | 88 ++++++++++++++++--- .../WebGPUSceneOperations.cs | 8 ++ 3 files changed, 91 insertions(+), 16 deletions(-) diff --git a/src/ImageSharp.Drawing.WebGPU/WebGPUSceneDispatch.cs b/src/ImageSharp.Drawing.WebGPU/WebGPUSceneDispatch.cs index 417ba81d..40ab9f87 100644 --- a/src/ImageSharp.Drawing.WebGPU/WebGPUSceneDispatch.cs +++ b/src/ImageSharp.Drawing.WebGPU/WebGPUSceneDispatch.cs @@ -67,11 +67,6 @@ internal static class WebGPUSceneDispatch // validation rejects deeper nesting before anything is dispatched. private const int MaxClipStackDepth = 256; - // The PTCL word budget attributed to each estimated tile crossing when seeding the PTCL - // scratch capacity. Every crossing belongs to one (draw, tile) pair with segments, and such a - // pair writes one CMD_FILL (6 words) plus one paint command of at most 5 words. - private const long PtclWordsPerCrossing = 11; - // 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. @@ -2450,6 +2445,7 @@ private static WebGPUSceneBumpSizes SeedSceneBumpSizes(WebGPUSceneBumpSizes curr scene.TotalPathRowCount, scene.PathCount, scene.EstimatedTileCrossings, + scene.EstimatedPtclWords, scene.EstimatedBinFootprint, (long)scene.TileCountX * scene.TileCountY, maxStorageBufferBindingSize); @@ -2468,6 +2464,7 @@ private static WebGPUSceneBumpSizes SeedSceneBumpSizes(WebGPUSceneBumpSizes curr range.TotalPathRowCount, range.PathCount, range.EstimatedTileCrossings, + range.EstimatedPtclWords, range.EstimatedBinFootprint, (long)((range.TargetBounds.Width + 15) / 16) * ((range.TargetBounds.Height + 15) / 16), maxStorageBufferBindingSize); @@ -2484,6 +2481,7 @@ private static WebGPUSceneBumpSizes SeedSceneBumpSizes(WebGPUSceneBumpSizes curr /// The estimated sparse path-row count. /// The encoded path count. /// 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 device-reported maximum size of one storage-buffer binding. @@ -2494,6 +2492,7 @@ private static WebGPUSceneBumpSizes SeedSceneBumpSizes( int totalPathRowCount, int pathCount, long estimatedTileCrossings, + long estimatedPtclWords, long estimatedBinFootprint, long targetTileCount, ulong maxStorageBufferBindingSize) @@ -2511,7 +2510,7 @@ private static WebGPUSceneBumpSizes SeedSceneBumpSizes( ClampEstimate(estimatedTileCrossings, maxStorageBufferBindingSize, (uint)Unsafe.SizeOf()), pathRowFloor); uint binningFloor = ClampEstimate(estimatedBinFootprint, maxStorageBufferBindingSize, sizeof(uint)); - long ptclWords = ((estimatedTileCrossings * PtclWordsPerCrossing * 105L) / 100L) + (targetTileCount * PtclChunkWords); + long ptclWords = ((estimatedPtclWords * 105L) / 100L) + (targetTileCount * PtclChunkWords); uint ptclFloor = ClampEstimate(ptclWords, maxStorageBufferBindingSize, sizeof(uint)); return new WebGPUSceneBumpSizes( diff --git a/src/ImageSharp.Drawing.WebGPU/WebGPUSceneEncoder.cs b/src/ImageSharp.Drawing.WebGPU/WebGPUSceneEncoder.cs index 97304230..502a4ae4 100644 --- a/src/ImageSharp.Drawing.WebGPU/WebGPUSceneEncoder.cs +++ b/src/ImageSharp.Drawing.WebGPU/WebGPUSceneEncoder.cs @@ -73,6 +73,11 @@ internal static class WebGPUSceneEncoder /// public const int TileHeight = 16; + /// + /// The PTCL word count of one CMD_FILL command. Must match write_path in coarse.wgsl. + /// + private const int PtclFillWords = 6; + /// /// 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 @@ -853,6 +858,7 @@ private readonly struct SceneEncodingCheckpoint /// The number of non-horizontal line segments emitted so far. /// The accumulated sparse path-row estimate. /// The accumulated tile-crossing upper bound. + /// The accumulated PTCL word upper bound. /// The accumulated per-(draw, bin) record upper bound. public SceneEncodingCheckpoint( int pathTagByteCount, @@ -868,6 +874,7 @@ public SceneEncodingCheckpoint( int lineCount, long estimatedPathRowCount, long estimatedTileCrossings, + long estimatedPtclWords, long estimatedBinFootprint) { this.PathTagByteCount = pathTagByteCount; @@ -883,6 +890,7 @@ public SceneEncodingCheckpoint( this.LineCount = lineCount; this.EstimatedPathRowCount = estimatedPathRowCount; this.EstimatedTileCrossings = estimatedTileCrossings; + this.EstimatedPtclWords = estimatedPtclWords; this.EstimatedBinFootprint = estimatedBinFootprint; } @@ -951,6 +959,11 @@ public SceneEncodingCheckpoint( /// public long EstimatedTileCrossings { get; } + /// + /// Gets the accumulated PTCL word upper bound. + /// + public long EstimatedPtclWords { get; } + /// /// Gets the accumulated per-(draw, bin) record upper bound. /// @@ -1593,8 +1606,10 @@ private ref struct SupportedSubsetSceneEncoding // CPU-side scratch-demand estimates accumulated per draw so the first GPU attempt can be // seeded near true demand instead of discovering it through the overflow retry protocol. // estimatedTileCrossings bounds tile-boundary crossings (segment/seg-count/path-tile - // records); estimatedBinFootprint bounds per-(draw, bin) binning records. + // records); estimatedPtclWords bounds the dynamic PTCL words those crossings can write; + // estimatedBinFootprint bounds per-(draw, bin) binning records. private long estimatedTileCrossings; + private long estimatedPtclWords; private long estimatedBinFootprint; // Cache of the last emitted 10-word style record. Consecutive draws with identical @@ -1669,6 +1684,7 @@ public SupportedSubsetSceneEncoding( this.openLayerBounds = null; this.estimatedPathRowCount = 0; this.estimatedTileCrossings = 0; + this.estimatedPtclWords = 0; this.estimatedBinFootprint = 0; this.VisibleFillCount = 0; @@ -1797,6 +1813,12 @@ public SupportedSubsetSceneEncoding( /// public readonly long EstimatedTileCrossings => this.estimatedTileCrossings; + /// + /// 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. + /// + public readonly long EstimatedPtclWords => this.estimatedPtclWords; + /// /// Gets the CPU-side upper-bound estimate of per-(draw, bin) binning records. /// @@ -1826,6 +1848,7 @@ public readonly SceneEncodingCheckpoint CaptureCheckpoint() this.LineCount, this.estimatedPathRowCount, this.estimatedTileCrossings, + this.estimatedPtclWords, this.estimatedBinFootprint); /// @@ -1988,6 +2011,7 @@ public static WebGPUSceneRange CreateRange( end.LineCount - start.LineCount, checked((int)(end.EstimatedPathRowCount - start.EstimatedPathRowCount)), end.EstimatedTileCrossings - start.EstimatedTileCrossings, + end.EstimatedPtclWords - start.EstimatedPtclWords, end.EstimatedBinFootprint - start.EstimatedBinFootprint); /// @@ -2614,7 +2638,7 @@ private void AppendPlainFill( this.lastStyle7 = style7; this.lastStyle8 = style8; this.lastStyle9 = style9; - this.AccumulateDrawRowEstimate(command.RasterizerOptions.Interest, geometryLineCount, geometryTileCrossings); + this.AccumulateDrawRowEstimate(command.RasterizerOptions.Interest, geometryLineCount, geometryTileCrossings, GetPtclPaintWords(drawTag)); this.FillCount++; this.PathCount += encodedPathCount; @@ -2763,7 +2787,7 @@ private void AppendPlainStroke( this.lastStyle7 = style7; this.lastStyle8 = style8; this.lastStyle9 = style9; - this.AccumulateDrawRowEstimate(command.RasterizerOptions.Interest, geometryLineCount, geometryTileCrossings); + this.AccumulateDrawRowEstimate(command.RasterizerOptions.Interest, geometryLineCount, geometryTileCrossings, GetPtclPaintWords(drawTag)); this.FillCount++; this.PathCount += encodedPathCount; this.LineCount += geometryLineCount; @@ -2888,7 +2912,7 @@ private void AppendExplicitStroke( this.lastStyle7 = style7; this.lastStyle8 = style8; this.lastStyle9 = style9; - this.AccumulateDrawRowEstimate(rasterizerOptions.Interest, geometryLineCount, geometryTileCrossings); + this.AccumulateDrawRowEstimate(rasterizerOptions.Interest, geometryLineCount, geometryTileCrossings, GetPtclPaintWords(drawTag)); this.FillCount++; this.PathCount += encodedPathCount; this.LineCount += geometryLineCount; @@ -3015,7 +3039,7 @@ private void AppendExplicitStroke( this.lastStyle7 = style7; this.lastStyle8 = style8; this.lastStyle9 = style9; - this.AccumulateDrawRowEstimate(rasterizerOptions.Interest, geometryLineCount, geometryTileCrossings); + this.AccumulateDrawRowEstimate(rasterizerOptions.Interest, geometryLineCount, geometryTileCrossings, GetPtclPaintWords(drawTag)); this.FillCount++; this.PathCount += encodedPathCount; this.LineCount += geometryLineCount; @@ -3343,7 +3367,7 @@ private bool AppendClipDescriptor(in DrawingClipDescriptor descriptor, Point des this.lastStyle7 = style7; this.lastStyle8 = style8; this.lastStyle9 = style9; - this.AccumulateDrawRowEstimateLocal(clipBounds, clipLineCount); + this.AccumulateDrawRowEstimateLocal(clipBounds, clipLineCount, -1, GetPtclPaintWords(GpuSceneDrawTag.EndClip)); this.PathCount += encodedPathCount; this.LineCount += clipLineCount; this.InfoWordCount += (int)GpuSceneDrawTag.Map(GpuSceneDrawTag.BeginClip).InfoOffset; @@ -3430,7 +3454,7 @@ private void AppendBeginLayer(in CompositionCommand command) this.lastStyle7 = style7; this.lastStyle8 = style8; this.lastStyle9 = style9; - this.AccumulateDrawRowEstimateLocal(layerBounds, clipLineCount); + this.AccumulateDrawRowEstimateLocal(layerBounds, clipLineCount, -1, GetPtclPaintWords(GpuSceneDrawTag.EndClip)); this.PathCount += encodedPathCount; this.LineCount += clipLineCount; this.InfoWordCount += (int)GpuSceneDrawTag.Map(GpuSceneDrawTag.BeginClip).InfoOffset; @@ -3450,8 +3474,9 @@ private void AppendBeginLayer(in CompositionCommand command) /// The summed per-line tile-crossing bound of the geometry (fills and strokes), or a negative value /// to fall back to the bounding-box diagonal bound (clips). /// - private void AccumulateDrawRowEstimate(Rectangle absoluteInterest, int lineCount, long tileCrossings = -1) - => this.AccumulateDrawRowEstimateLocal(ToTargetLocal(absoluteInterest, this.rootTargetBounds), lineCount, tileCrossings); + /// 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); /// /// Adds one draw object's clipped root-target-local footprint to the sparse scratch estimates: @@ -3464,7 +3489,8 @@ private void AccumulateDrawRowEstimate(Rectangle absoluteInterest, int lineCount /// The summed per-line tile-crossing bound of the geometry (fills and strokes), or a negative value /// to fall back to the bounding-box diagonal bound (clips). /// - private void AccumulateDrawRowEstimateLocal(Rectangle localBounds, int lineCount, long tileCrossings = -1) + /// The PTCL word count of the draw's paint command. + private void AccumulateDrawRowEstimateLocal(Rectangle localBounds, int lineCount, long tileCrossings, int paintWords) { Rectangle clippedBounds = Rectangle.Intersect(localBounds, new Rectangle(0, 0, this.rootTargetBounds.Width, this.rootTargetBounds.Height)); @@ -3496,12 +3522,31 @@ 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); + // 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; } + /// + /// Returns the PTCL word count of the paint command coarse writes for one draw tag, matching + /// the write_* helpers in coarse.wgsl. + /// + /// The draw tag. + /// The paint command word count. + private static int GetPtclPaintWords(uint drawTag) + => drawTag switch + { + GpuSceneDrawTag.FillPathGradient => 5, + GpuSceneDrawTag.FillImage => 2, + GpuSceneDrawTag.BeginClip => 2, + _ => 3 + }; + /// /// Encodes the closing record for the next end-layer command in the retained timeline. /// @@ -3573,6 +3618,7 @@ private sealed class SceneEncodingPartition : IDisposable /// The number of emitted gradient-ramp rows. /// The CPU-side estimate of active tile rows. /// The CPU-side upper bound for tile-boundary crossings. + /// The CPU-side upper bound for the dynamic PTCL words. /// The CPU-side upper bound for per-(draw, bin) records. /// The unpadded path-tag byte count. /// The path-data word count. @@ -3605,6 +3651,7 @@ private SceneEncodingPartition( int gradientRowCount, int estimatedPathRowCount, long estimatedTileCrossings, + long estimatedPtclWords, long estimatedBinFootprint, int pathTagByteCount, int pathDataWordCount, @@ -3638,6 +3685,7 @@ private SceneEncodingPartition( this.GradientRowCount = gradientRowCount; this.EstimatedPathRowCount = estimatedPathRowCount; this.EstimatedTileCrossings = estimatedTileCrossings; + this.EstimatedPtclWords = estimatedPtclWords; this.EstimatedBinFootprint = estimatedBinFootprint; this.PathTagByteCount = pathTagByteCount; this.PathDataWordCount = pathDataWordCount; @@ -3766,6 +3814,11 @@ public ReadOnlySpan PathGradientData /// public long EstimatedTileCrossings { get; } + /// + /// Gets the CPU-side upper bound for the dynamic PTCL words. + /// + public long EstimatedPtclWords { get; } + /// /// Gets the CPU-side upper bound for per-(draw, bin) binning records. /// @@ -3867,6 +3920,7 @@ public static SceneEncodingPartition Detach(ref SupportedSubsetSceneEncoding enc gradientRowCount, encoding.EstimatedPathRowCount, encoding.EstimatedTileCrossings, + encoding.EstimatedPtclWords, encoding.EstimatedBinFootprint, pathTagByteCount, pathDataWordCount, @@ -4029,6 +4083,7 @@ public static WebGPUEncodedScene Resolve( DivideRoundUp(targetBounds.Width, TileWidth), DivideRoundUp(targetBounds.Height, TileHeight), encoding.EstimatedTileCrossings, + encoding.EstimatedPtclWords, encoding.EstimatedBinFootprint, operations); @@ -4416,6 +4471,7 @@ public static WebGPUEncodedScene Resolve( int profileFillCount = 0; long estimatedPathRowCount = 0; long estimatedTileCrossings = 0; + long estimatedPtclWords = 0; long estimatedBinFootprint = 0; for (int i = 0; i < partitions.Length; i++) @@ -4441,6 +4497,7 @@ public static WebGPUEncodedScene Resolve( profileFillCount += partition.ProfileFills?.Count ?? 0; estimatedPathRowCount = Math.Min(estimatedPathRowCount + partition.EstimatedPathRowCount, int.MaxValue); estimatedTileCrossings += partition.EstimatedTileCrossings; + estimatedPtclWords += partition.EstimatedPtclWords; estimatedBinFootprint += partition.EstimatedBinFootprint; } @@ -4628,6 +4685,7 @@ public static WebGPUEncodedScene Resolve( DivideRoundUp(targetBounds.Width, TileWidth), DivideRoundUp(targetBounds.Height, TileHeight), estimatedTileCrossings, + estimatedPtclWords, estimatedBinFootprint, []); @@ -7453,6 +7511,7 @@ public void SetProfileRegions(uint slotsBase, uint recordsBase) 0, 0L, 0L, + 0L, []); private readonly IMemoryOwner? sceneDataOwner; @@ -7503,6 +7562,7 @@ public void SetProfileRegions(uint slotsBase, uint recordsBase) /// The horizontal tile count. /// The vertical tile count. /// The CPU-side upper bound for tile-boundary crossings. + /// The CPU-side upper bound for the dynamic PTCL words. /// The CPU-side upper bound for per-(draw, bin) binning records. /// The scene operations associated with the encoded payload. public WebGPUEncodedScene( @@ -7535,6 +7595,7 @@ public WebGPUEncodedScene( int tileCountX, int tileCountY, long estimatedTileCrossings, + long estimatedPtclWords, long estimatedBinFootprint, WebGPUSceneOperation[] operations) { @@ -7568,6 +7629,7 @@ public WebGPUEncodedScene( this.TileCountX = tileCountX; this.TileCountY = tileCountY; this.EstimatedTileCrossings = estimatedTileCrossings; + this.EstimatedPtclWords = estimatedPtclWords; this.EstimatedBinFootprint = estimatedBinFootprint; int targetCount = operations.Length == 0 ? 0 : 1; @@ -7654,6 +7716,12 @@ public WebGPUEncodedScene( /// public long EstimatedTileCrossings { get; } + /// + /// 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. + /// + public long EstimatedPtclWords { get; } + /// /// Gets the CPU-side upper-bound estimate of per-(draw, bin) binning records. /// diff --git a/src/ImageSharp.Drawing.WebGPU/WebGPUSceneOperations.cs b/src/ImageSharp.Drawing.WebGPU/WebGPUSceneOperations.cs index bf03ef15..04503e2b 100644 --- a/src/ImageSharp.Drawing.WebGPU/WebGPUSceneOperations.cs +++ b/src/ImageSharp.Drawing.WebGPU/WebGPUSceneOperations.cs @@ -205,6 +205,7 @@ internal readonly struct WebGPUSceneRange /// 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. public WebGPUSceneRange( Rectangle targetBounds, @@ -227,6 +228,7 @@ public WebGPUSceneRange( int lineCount, int totalPathRowCount, long estimatedTileCrossings, + long estimatedPtclWords, long estimatedBinFootprint) { this.TargetBounds = targetBounds; @@ -249,6 +251,7 @@ public WebGPUSceneRange( this.LineCount = lineCount; this.TotalPathRowCount = totalPathRowCount; this.EstimatedTileCrossings = estimatedTileCrossings; + this.EstimatedPtclWords = estimatedPtclWords; this.EstimatedBinFootprint = estimatedBinFootprint; } @@ -352,6 +355,11 @@ public WebGPUSceneRange( /// public long EstimatedTileCrossings { get; } + /// + /// Gets the CPU-side upper bound for the range's dynamic PTCL words. + /// + public long EstimatedPtclWords { get; } + /// /// Gets the CPU-side upper bound for the range's per-(draw, bin) binning records. ///