Skip to content
Merged
Show file tree
Hide file tree
Changes from 11 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions Directory.Packages.props
Original file line number Diff line number Diff line change
Expand Up @@ -10,12 +10,14 @@
<PackageVersion Include="Avalonia.Themes.Fluent" Version="12.0.5" />
<PackageVersion Include="DocumentFormat.OpenXml" Version="3.5.1" />
<PackageVersion Include="Markdig" Version="0.37.0" />
<PackageVersion Include="Microsoft.Diagnostics.Tracing.TraceEvent" Version="3.1.15" />
<PackageVersion Include="Microsoft.NET.Test.Sdk" Version="17.11.1" />
<PackageVersion Include="netDxf.netstandard" Version="2.4.0" />
<PackageVersion Include="OpenFontSharp" Version="1.0.0" />
<PackageVersion Include="ProDiagnostics" Version="12.0.0" />
<PackageVersion Include="Silk.NET.Input" Version="2.23.0" />
<PackageVersion Include="Silk.NET.Input.Common" Version="2.23.0" />
<PackageVersion Include="Silk.NET.Input.Glfw" Version="2.23.0" />
<PackageVersion Include="Silk.NET.GLFW" Version="2.23.0" />
<PackageVersion Include="Silk.NET.WebGPU" Version="2.23.0" />
<PackageVersion Include="Silk.NET.WebGPU.Native.WGPU" Version="2.23.0" />
Expand Down
177 changes: 177 additions & 0 deletions docs/GPU_TEXT_COVERAGE_CACHE_ARCHITECTURE.md

Large diffs are not rendered by default.

223 changes: 223 additions & 0 deletions docs/SAMPLE_PAGE_MEMORY_PROFILE.md

Large diffs are not rendered by default.

92 changes: 92 additions & 0 deletions docs/WINDOW_RESIZE_PERFORMANCE.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
# Window resize performance

## Outcome

The desktop resize path now keeps synchronous live-resize repainting without drawing the same
size again when native event dispatch returns. Cocoa can hold the normal Silk render loop while
the user drags a window edge, so each framebuffer callback renders the latest physical size
immediately. The following scheduled render is suppressed; the next application update then
continues normally. This preserves continuous repainting while removing the duplicate frame.

The accompanying retained multi-column text path separates width-independent shaping inputs
from width-dependent line breaking. It retains those inputs per document block, recycles
positioned-character objects, represents candidate lines as index ranges instead of copying
`RichChar` structs, and skips a duplicate arrange layout when measure already used the final
size. Rasterization, DPI/subpixel snapping, glyph/path atlas keys, and shaders are unchanged.

## Matched Release measurements

Measurements use the `Text & Documents` sample, 120 warm-up frames, 300 measured frames,
VSync enabled, a repeated 800x600 to 1280x800 resize sweep, and memory counters enabled on
macOS/Metal. Both runs used the same final Release application. CPU allocation attribution
used EventPipe randomized allocation sampling; exact allocation and GC values come from the
runtime counters. Metal System Trace was captured for both runs.

| Metric | Before | After | Change |
|---|---:|---:|---:|
| Managed allocation / measured frame | 1,761,326 B | 422,345 B | **-76.0%** |
| Gen0 collections | 58 | 15 | **-74.1%** |
| GC pause | 36.53 ms | 10.69 ms | **-70.7%** |
| Live-resize callback average | 8.0714 ms | 7.5323 ms | **-6.7%** |
| Live-resize callback maximum | 52.8272 ms | 41.6815 ms | **-21.1%** |
| Physical footprint | 496.19 MB | 463.46 MB | **-6.6%** |
| Lifetime max physical footprint | 554.60 MB | 517.82 MB | **-6.6%** |

The old measurement contained 150 framebuffer callbacks and 149 callback frames among 300
reported frames, followed by a second scheduled render for the same resize. The corrected path
rendered all 299 measured framebuffer callbacks and suppressed all 299 redundant scheduled
frames. It sustained 116.15 visible frames/second under the deterministic workload while
keeping pixels current throughout live resize.

GPU residency and quality-sensitive work remained stable: the glyph atlas stayed at
1024x2560, the color atlas at 256x512, the path atlas at 512x2048 with 134 entries, and the
post-trace recorded 58 glyph-outline writes and 47 compute batches. No atlas clear, eviction,
shader, raster scale, or subpixel policy changed. Focused rich-text, bidi, flow-direction,
and table tests cover the line-range refactor; a new regression verifies that identical
measure/arrange sizes perform one multi-column layout. The browser Release AOT host is also
resized through multiple viewport sizes to verify its independent canvas configuration path.

## Cross-engine design record

This is a clean-room implementation. External sources informed architecture and observable
contracts only; no source was copied, translated, or structurally reproduced.

| Engine / primary source | Relevant production behavior | ProGPU decision |
|---|---|---|
| [Vello winit example](https://github.com/linebender/vello/blob/main/examples/with_winit/src/lib.rs) | `Resized` updates the surface and requests redraw; actual rendering remains in `RedrawRequested`. Outdated/suboptimal surfaces are reconfigured and redrawn. | Adopt one draw per resize and latest-size rendering. Adapt dispatch to Cocoa/Silk, where the callback must repaint synchronously during the native live-resize loop, then suppress the duplicate scheduled draw. |
| [Direct2D HWND render target](https://learn.microsoft.com/en-us/windows/win32/api/d2d1/nn-d2d1-id2d1hwndrendertarget), [Direct2D quickstart](https://learn.microsoft.com/en-us/windows/win32/direct2d/direct2d-quickstart), [DXGI `ResizeBuffers`](https://learn.microsoft.com/en-us/windows/win32/api/dxgi/nf-dxgi-idxgiswapchain-resizebuffers) | Retain the render target, resize its backing buffers for the new client size, and draw through the platform paint path after releasing outstanding references. | Retain device/pipelines/atlases and configure only the latest physical size; map the single paint to each platform's live-resize dispatch contract. |
| [Win2D without controls](https://microsoft.github.io/Win2D/WinUI3/html/WithoutControls.htm), [CanvasSwapChain.ResizeBuffers](https://microsoft.github.io/Win2D/WinUI3/html/M_Microsoft_Graphics_Canvas_CanvasSwapChain_ResizeBuffers_2.htm) | Size changes update swapchain buffers while draw/present remain distinct operations. | Keep surface configuration in the render pipeline and prevent a second scheduled present for the same callback frame. |
| [WebRender rendering overview](https://firefox-source-docs.mozilla.org/gfx/RenderingOverview.html), [Firefox vsync pipeline](https://firefox-source-docs.mozilla.org/gfx/Silk.html) | A retained scene is culled into frames, caches localize changed work, and compositor/painting are aligned to hardware vsync. | Preserve compiled-scene and atlas reuse and align resize work with the normal display tick. Full damage tiling is outside this fix. |
| [Skia shaped-text design](https://docs.skia.org/docs/dev/design/text_shaper/), [SkParagraph API](https://skia.googlesource.com/skia/+/refs/heads/main/modules/skparagraph/include/Paragraph.h) | Shaping and width-dependent formatting are separable retained stages; paragraph objects can be laid out again at a new width. | Retain block shaping inputs and redo only line/column placement when width changes. |
| [DirectWrite `IDWriteTextLayout`](https://learn.microsoft.com/en-us/windows/win32/api/dwrite/nn-dwrite-idwritetextlayout), [`SetMaxWidth`](https://learn.microsoft.com/en-us/windows/win32/api/dwrite/nf-dwrite-idwritetextlayout-setmaxwidth) | A fully analyzed/formatted text block is retained while layout constraints can change. | Adapt the retained-layout contract per presenter without sharing viewport coordinates between controls. |
| [Parley layout](https://docs.rs/parley/latest/parley/), [Parley retained layout data](https://docs.rs/parley/latest/src/parley/layout/data.rs.html) | Shaped runs/clusters/glyphs are stored separately from lines; re-linebreaking and alignment do not require reconstructing text styles. | Retain block character/font/style resolution and paragraph metrics; compute new column breaks from index ranges. |
| [HarfBuzz shaping plans and caching](https://harfbuzz.github.io/shaping-plans-and-caching.html) | Stable face/script/language/feature work is reusable, while shaping results still obey Unicode/OpenType context. | Preserve CPU shaping and line-boundary reshaping for correctness; reject a GPU-only shaper or width-keyed global cache. |

Across these engines, startup remains lazy, shaping/layout and retained scene data survive
unrelated frames, visibility controls demand, uploads are demand-driven, and GPU work is
batched at the render boundary. ProGPU keeps those existing contracts. Surface capability
selection is now cached for a surface lifetime; a lost/outdated surface explicitly refreshes
capabilities. Font fallback, variable-font instances, DPI/subpixel snapping, atlas generation,
and device-loss invalidation remain part of their existing typed cache keys.

Rejected alternatives were deferring every repaint until a normal tick (which freezes Cocoa
live resize), drawing both synchronously and again on the following scheduled tick, adding a
timer debounce that would lag behind the pointer, drawing a lower-resolution stretched frame,
dropping text features, lowering coverage quality, reshaping unchanged text globally, or
pre-uploading hidden content. Each either increases latency/memory or violates quality and
invalidation contracts.

## Reproduction

```bash
PROGPU_SAMPLE_BENCHMARK_PAGE='Text & Documents' \
PROGPU_SAMPLE_BENCHMARK_WARMUP_FRAMES=120 \
PROGPU_SAMPLE_BENCHMARK_MEASURE_FRAMES=300 \
PROGPU_SAMPLE_BENCHMARK_RESIZE=true \
PROGPU_SAMPLE_BENCHMARK_MEMORY=true \
PROGPU_SAMPLE_BENCHMARK_VSYNC=true \
src/ProGPU.Samples.Desktop/bin/Release/net10.0/ProGPU.Samples.Desktop
```

Set `PROGPU_SAMPLE_BENCHMARK_RESIZE_MIN_WIDTH`, `_MIN_HEIGHT`, `_MAX_WIDTH`,
`_MAX_HEIGHT`, and `_STEP` to change the deterministic sweep.
96 changes: 96 additions & 0 deletions src/ProGPU.Backend/GpuCoverageUpload.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
using Silk.NET.WebGPU;

namespace ProGPU.Backend;

/// <summary>
/// Records copies from GPU-computed, tightly packed coverage bytes into a filterable R8 atlas.
/// Rasterization remains GPU-only while avoiding the four-channel storage-texture footprint.
/// </summary>
public static unsafe class GpuCoverageUpload
{
public const uint CopyRowAlignment = 256;

public static uint GetBytesPerRow(uint width)
{
if (width == 0)
{
return 0;
}

return checked((width + CopyRowAlignment - 1) & ~(CopyRowAlignment - 1));
}

public static uint GetRequiredBytes(uint width, uint height) =>
checked(GetBytesPerRow(width) * height);

public static void RecordCopy(
WgpuContext context,
CommandEncoder* encoder,
GpuBuffer source,
uint sourceOffset,
uint bytesPerRow,
GpuTexture destination,
uint destinationX,
uint destinationY,
uint width,
uint height)
{
ArgumentNullException.ThrowIfNull(context);
ArgumentNullException.ThrowIfNull(source);
ArgumentNullException.ThrowIfNull(destination);
if (encoder == null)
{
throw new ArgumentNullException(nameof(encoder));
}
if (destination.Format != TextureFormat.R8Unorm)
{
throw new ArgumentException("Coverage copies require an R8Unorm destination.", nameof(destination));
}
if (!source.Usage.HasFlag(BufferUsage.CopySrc))
{
throw new ArgumentException("Coverage staging buffers require CopySrc usage.", nameof(source));
}
if (width == 0 || height == 0)
{
return;
}
if (bytesPerRow < width || bytesPerRow % CopyRowAlignment != 0)
{
throw new ArgumentOutOfRangeException(nameof(bytesPerRow));
}

var copySource = new ImageCopyBuffer
{
Buffer = source.BufferPtr,
Layout = new TextureDataLayout
{
Offset = sourceOffset,
BytesPerRow = bytesPerRow,
RowsPerImage = height
}
};
var copyDestination = new ImageCopyTexture
{
Texture = destination.TexturePtr,
MipLevel = 0,
Origin = new Origin3D
{
X = destinationX,
Y = destinationY,
Z = 0
},
Aspect = TextureAspect.All
};
var copySize = new Extent3D
{
Width = width,
Height = height,
DepthOrArrayLayers = 1
};
context.Api.CommandEncoderCopyBufferToTexture(
encoder,
&copySource,
&copyDestination,
&copySize);
}
}
87 changes: 87 additions & 0 deletions src/ProGPU.Backend/GpuTexture.cs
Original file line number Diff line number Diff line change
Expand Up @@ -1201,6 +1201,93 @@ public void CopyBaseLevelFrom(GpuTexture source)
Generation++;
}

/// <summary>
/// Copies a top-left base-level region from a compatible texture. This is used
/// by demand-grown atlases to preserve resident texels without a CPU readback.
/// </summary>
public void CopyBaseLevelRegionFrom(GpuTexture source, uint width, uint height)
{
if (IsDisposed) throw new ObjectDisposedException(nameof(GpuTexture));
ArgumentNullException.ThrowIfNull(source);
if (source.IsDisposed) throw new ObjectDisposedException(nameof(GpuTexture));
if (!ReferenceEquals(source.Context, _context))
{
throw new ArgumentException(
"Source texture must belong to the same WebGPU context.",
nameof(source));
}
if (source.DepthOrArrayLayers != 1 || DepthOrArrayLayers != 1 ||
source.Dimension != GpuTextureDimension.Dimension2D ||
Dimension != GpuTextureDimension.Dimension2D ||
source.Format != Format || source.SampleCount != SampleCount)
{
throw new ArgumentException(
"Source and destination must be compatible single-layer 2D textures.",
nameof(source));
}
if (width == 0 || width > source.Width || width > Width)
{
throw new ArgumentOutOfRangeException(nameof(width));
}
if (height == 0 || height > source.Height || height > Height)
{
throw new ArgumentOutOfRangeException(nameof(height));
}
if (!source.Usage.HasFlag(TextureUsage.CopySrc))
{
throw new InvalidOperationException("Source texture was not created with CopySrc usage.");
}
if (!Usage.HasFlag(TextureUsage.CopyDst))
{
throw new InvalidOperationException("Destination texture was not created with CopyDst usage.");
}

var encoderDescriptor = new CommandEncoderDescriptor();
var encoder = _context.Api.DeviceCreateCommandEncoder(_context.Device, &encoderDescriptor);
if (encoder == null)
{
throw new InvalidOperationException(
"Failed to create a command encoder for the regional texture copy.");
}

var copySource = new ImageCopyTexture
{
Texture = source.TexturePtr,
MipLevel = 0,
Origin = new Origin3D(),
Aspect = GetTextureCopyAspect(source.Format)
};
var copyDestination = new ImageCopyTexture
{
Texture = TexturePtr,
MipLevel = 0,
Origin = new Origin3D(),
Aspect = GetTextureCopyAspect(Format)
};
var copySize = new Extent3D
{
Width = width,
Height = height,
DepthOrArrayLayers = 1
};
_context.Api.CommandEncoderCopyTextureToTexture(
encoder,
&copySource,
&copyDestination,
&copySize);

var commandBufferDescriptor = new CommandBufferDescriptor();
var commandBuffer = _context.Api.CommandEncoderFinish(
encoder,
&commandBufferDescriptor);
_context.Api.QueueSubmit(_context.Queue, 1, &commandBuffer);
_context.Api.CommandBufferRelease(commandBuffer);
_context.Api.CommandEncoderRelease(encoder);

AlphaMode = source.AlphaMode;
Generation++;
}

private void EnsurePbgra32CompatibleFormat()
{
if (Format is not (TextureFormat.Bgra8Unorm or TextureFormat.Bgra8UnormSrgb))
Expand Down
2 changes: 2 additions & 0 deletions src/ProGPU.Backend/IWebGpuApi.cs
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ public unsafe interface IWebGpuApi
ComputePassEncoder* CommandEncoderBeginComputePass(CommandEncoder* commandEncoder, ComputePassDescriptor* descriptor);
RenderPassEncoder* CommandEncoderBeginRenderPass(CommandEncoder* commandEncoder, RenderPassDescriptor* descriptor);
void CommandEncoderCopyBufferToBuffer(CommandEncoder* commandEncoder, WgpuBuffer* source, ulong sourceOffset, WgpuBuffer* destination, ulong destinationOffset, ulong size);
void CommandEncoderCopyBufferToTexture(CommandEncoder* commandEncoder, ImageCopyBuffer* source, ImageCopyTexture* destination, Extent3D* copySize);
void CommandEncoderCopyTextureToBuffer(CommandEncoder* commandEncoder, ImageCopyTexture* source, ImageCopyBuffer* destination, Extent3D* copySize);
void CommandEncoderCopyTextureToTexture(CommandEncoder* commandEncoder, ImageCopyTexture* source, ImageCopyTexture* destination, Extent3D* copySize);
CommandBuffer* CommandEncoderFinish(CommandEncoder* commandEncoder, CommandBufferDescriptor* descriptor);
Expand Down Expand Up @@ -118,6 +119,7 @@ private static void CompleteBufferMap(BufferMapAsyncStatus status, void* userDat
public ComputePassEncoder* CommandEncoderBeginComputePass(CommandEncoder* e, ComputePassDescriptor* x) => api.CommandEncoderBeginComputePass(e, x);
public RenderPassEncoder* CommandEncoderBeginRenderPass(CommandEncoder* e, RenderPassDescriptor* x) => api.CommandEncoderBeginRenderPass(e, x);
public void CommandEncoderCopyBufferToBuffer(CommandEncoder* e, WgpuBuffer* s, ulong so, WgpuBuffer* d, ulong @do, ulong z) => api.CommandEncoderCopyBufferToBuffer(e, s, so, d, @do, z);
public void CommandEncoderCopyBufferToTexture(CommandEncoder* e, ImageCopyBuffer* s, ImageCopyTexture* d, Extent3D* z) => api.CommandEncoderCopyBufferToTexture(e, s, d, z);
public void CommandEncoderCopyTextureToBuffer(CommandEncoder* e, ImageCopyTexture* s, ImageCopyBuffer* d, Extent3D* z) => api.CommandEncoderCopyTextureToBuffer(e, s, d, z);
public void CommandEncoderCopyTextureToTexture(CommandEncoder* e, ImageCopyTexture* s, ImageCopyTexture* d, Extent3D* z) => api.CommandEncoderCopyTextureToTexture(e, s, d, z);
public CommandBuffer* CommandEncoderFinish(CommandEncoder* e, CommandBufferDescriptor* x) => api.CommandEncoderFinish(e, x);
Expand Down
1 change: 1 addition & 0 deletions src/ProGPU.Backend/ProGPU.Backend.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
<PackageReference Include="Silk.NET.WebGPU.Native.WGPU" />
<PackageReference Include="Silk.NET.Windowing.Common" />
<PackageReference Include="Silk.NET.Windowing.Glfw" />
<PackageReference Include="Silk.NET.Input.Glfw" />
<PackageReference Include="Silk.NET.GLFW" />
</ItemGroup>
<ItemGroup>
Expand Down
Loading
Loading